各Techニュースサイトから技術トレンドのピックアップしてサマリーを作成するようChatGPT + Node.js + Docker Desktop (+ WSL2)を連携した環境を構築しました。
仕組みとしては、Techニュースサイトの記事(トピック)を渡してサマリーを作成しています。
ニュースサイトは
- TechCrunch
- HackerNews
- はてなブックマーク (日本語RSS)
- ニュースサイトURL指定 (HTMLスクレイピング)
を選択できるようにしています。
仕組みの全体像(イメージ)
以下をWSL2上で構築して、そのフォルダを Docker にマウントして使用します。
trend-ai/
├── Dockerfile
├── docker-compose.yml
├── package.json
├── .env
├── agents/
│ ├── techcrunch-agent.json
│ ├── hackernews-agent.json
│ ├── html-agent.json
│ └── ja-rss-agent.json
└── src/
├── run-agent-wrapper.js // ← ChatGPT APIへ指示を渡して
│ // 結果を受け取る (エージェント処理)
├── run-agent.js
├── server.js // ← Webサーバ兼チャット処理
├── crawler.js // ← API/RSS取得処理
├── scraper.js // ← HTMLスクレイピング処理
└── public/
└── index.html // ← フロントエンド
構築手順
-
PCはWindows 11 Pro / 23H2 / 22631.5472
-
WSL2 + Ubuntu インストール、Docker Desktopインストール + WSL統合設定、ChatGPT(OpenAI)のAPIキー設定は下記サイトの構築手順[1]、[2]、[4]と同じです。
https://qiita.com/xuepan07/items/d1550d3a5a67ab6c2d2f
1. 作業ディレクトリ 作成
(Ubuntu(WSL2)上で作業)
mkdir -p ~/projects/trend-ai && cd ~/projects/trend-ai
2. .env(OpenAI APIキー設定) 作成
(Ubuntu(WSL2)上で作業)
nvim .env
OPENAI_API_KEY=sk-xxxxxxxxxxxxxxxxxxxx
3. Dockerfile 作成
(Ubuntu(WSL2)上で作業)
nvim Dockerfile
FROM node:20-slim
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 3000
CMD ["node", "src/server.js"]
4. docker-compose.yml 作成
(Ubuntu(WSL2)上で作業)
nvim docker-compose.yml
version: '3.8'
services:
trend-ai:
build: .
ports:
- "3000:3000"
environment:
- OPENAI_API_KEY=${OPENAI_API_KEY}
volumes:
- .:/app:cached
5. package.json 作成
(Ubuntu(WSL2)上で作業)
nvim package.json
{
"name": "trend-ai",
"version": "1.0.0",
"main": "src/server.js",
"type": "module",
"scripts": {
"start": "node src/server.js",
"dev": "node --watch src/server.js"
},
"dependencies": {
"express": "^4.18.2",
"body-parser": "^1.20.2",
"node-fetch": "^3.3.2",
"cheerio": "^1.0.0-rc.12",
"rss-parser": "^3.13.0"
},
"devDependencies": {
"nodemon": "^3.0.2"
}
}
6. *-agent.json (エージェント:ニュースサイト毎のChatGPTへの指示) を作成
(Ubuntu(WSL2)上で作業)
mkdir agents
agents/techcrunch-agent.json
nvim agents/techcrunch-agent.json
{
"model": "gpt-3.5-turbo",
"messages": [
{
"role": "system",
"content": "以下はTechCrunchのRSSから取得した最新記事の内容です。それぞれの話題を1トピックを1文300文字に要約して、それを日本語訳してください。各トピックの間には改行を入れて表示してください。"
},
{
"role": "user",
"content": "{{input}}"
}
],
"input_variable": "input",
"output_key": "content"
}
agents/hackernews-agent.json
nvim agents/hackernews-agent.json
{
"model": "gpt-3.5-turbo",
"messages": [
{
"role": "system",
"content": "以下はHacker Newsの上位記事の一覧です。それぞれのトピックについて、1トピックを1文300文字に要約して、それを日本語訳してください。各トピックの間には改行を入れて表示してください。"
},
{
"role": "user",
"content": "{{input}}"
}
],
"input_variable": "input",
"output_key": "content"
}
agents/html-agent.json
nvim agents/html-agent.json
{
"model": "gpt-3.5-turbo",
"messages": [
{
"role": "system",
"content": "以下はHTMLページの内容です。主なトピックや重要な情報を、1トピックを1文300文字に要約してください。各トピックの間には改行を入れて表示してください。"
},
{
"role": "user",
"content": "{{input}}"
}
],
"input_variable": "input",
"output_key": "content"
}
agents/ja-rss-agent.json
nvim agents/ja-rss-agent.json
{
"model": "gpt-3.5-turbo",
"messages": [
{
"role": "system",
"content": "以下は日本語のRSSフィードの要約対象記事リストです。それぞれのトピックを1文300文字で要約してください。各トピックの間には改行を入れて表示してください。"
},
{
"role": "user",
"content": "{{input}}"
}
],
"input_variable": "input",
"output_key": "content"
}
7. エージェント処理 作成
(Ubuntu(WSL2)上で作業)
mkdir src
src/run-agent-wrapper.js
(※ server.js から呼ばれるユーティリティ)
nvim src/run-agent-wrapper.js
import { runAgent } from './run-agent.js';
/**
* 指定された agent JSON ファイルと入力テキストを使って agent を実行します。
* ログ出力も含み、エラー時は例外を投げます。
*
* @param {string} agentPath - agent JSON のパス(例: 'agents/hackernews-agent.json')
* @param {string} input - プロンプトや元データ
* @returns {Promise<string>} - agent からの出力
*/
export async function runAgentWrapper(agentPath, input) {
console.log(`[runAgentWrapper] running ${agentPath} with input:\n${input.slice(0, 300)}...`);
try {
const result = await runAgent(agentPath, input);
console.log(`[runAgentWrapper] result:\n${result}`);
return result;
} catch (err) {
console.error(`[runAgentWrapper] ERROR: ${err.message}`);
throw new Error('Agent execution failed');
}
}
src/run-agent.js
(※ ChatGPTへ指示・結果を取得)
nvim src/run-agent.js
import { spawn } from 'child_process';
import { readFileSync } from 'fs';
import fetch from 'node-fetch';
/**
* OpenAI APIを直接呼び出す
*/
export async function runAgent(agentPath, input) {
try {
// エージェント設定を読み込み
const agentConfig = JSON.parse(readFileSync(agentPath, 'utf-8'));
// プロンプトを構築
const messages = agentConfig.messages.map(msg => ({
...msg,
content: msg.content.replace('{{input}}', input)
}));
// OpenAI API呼び出し
const response = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${process.env.OPENAI_API_KEY}`
},
body: JSON.stringify({
model: agentConfig.model,
messages: messages,
max_tokens: 1000,
temperature: 0.7
})
});
if (!response.ok) {
throw new Error(`OpenAI API Error: ${response.status} ${response.statusText}`);
}
const data = await response.json();
return data.choices[0].message.content.trim();
} catch (error) {
console.error('[runAgent] Error:', error);
}
}
8. src/server.js 作成
(Ubuntu(WSL2)上で作業)
nvim src/server.js
import express from 'express';
import bodyParser from 'body-parser';
import path from 'path';
import { fileURLToPath } from 'url';
import { fetchTechCrunch, fetchHackerNews, fetchJapaneseRSS } from './crawler.js';
import { scrapeHeadlines } from './scraper.js';
import { runAgentWrapper } from './run-agent-wrapper.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const app = express();
app.use(bodyParser.json());
app.use(express.static(path.join(__dirname, 'public')));
app.post('/fetch/techcrunch', async (req, res) => {
try {
const text = await fetchTechCrunch();
const result = await runAgentWrapper('agents/techcrunch-agent.json', text);
res.json({ result });
} catch (err) {
console.error('TechCrunch処理エラー:', err);
res.status(500).json({ error: 'TechCrunch 処理失敗' });
}
});
app.post('/fetch/hackernews', async (req, res) => {
try {
const text = await fetchHackerNews();
const result = await runAgentWrapper('agents/hackernews-agent.json', text);
res.json({ result });
} catch (err) {
console.error('HackerNews処理エラー:', err);
res.status(500).json({ error: 'HackerNews 処理失敗' });
}
});
app.post('/fetch/html', async (req, res) => {
try {
const url = req.body.url;
const html = await scrapeHeadlines(url);
const result = await runAgentWrapper('agents/html-agent.json', html);
res.json({ result });
} catch (err) {
console.error('HTML処理エラー:', err);
res.status(500).json({ error: 'HTMLスクレイピング失敗' });
}
});
app.post('/fetch/jarss', async (req, res) => {
try {
const feed = await fetchJapaneseRSS();
const result = await runAgentWrapper('agents/ja-rss-agent.json', feed);
res.json({ result });
} catch (err) {
console.error('日本語RSS処理エラー:', err);
res.status(500).json({ error: '日本語RSS失敗' });
}
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => console.log(`Server running on http://localhost:${PORT}`));
9. src/crawler.js 作成
(Ubuntu(WSL2)上で作業)
nvim src/crawler.js
import fetch from 'node-fetch';
import Parser from 'rss-parser';
const parser = new Parser();
/**
* TechCrunchのRSSフィードを取得し、構造化されたデータで返す
*/
export async function fetchTechCrunch() {
try {
const feed = await parser.parseURL('https://techcrunch.com/feed/');
const articles = feed.items.slice(0, 10).map(item => ({
title: item.title,
link: item.link,
pubDate: item.pubDate,
contentSnippet: item.contentSnippet?.slice(0, 200) || ''
}));
return articles.map(article =>
`タイトル: ${article.title}\n内容: ${article.contentSnippet}\nリンク: ${article.link}\n---`
).join('\n\n');
} catch (error) {
console.error('[fetchTechCrunch] エラー:', error);
return 'TechCrunchの取得に失敗しました: ' + error.message;
}
}
/**
* HackerNewsのトップストーリーから上位10件を取得
*/
export async function fetchHackerNews() {
try {
const response = await fetch('https://hacker-news.firebaseio.com/v0/topstories.json');
const storyIds = await response.json();
const stories = await Promise.all(
storyIds.slice(0, 10).map(async (id) => {
try {
const storyResponse = await fetch(`https://hacker-news.firebaseio.com/v0/item/${id}.json`);
const story = await storyResponse.json();
return {
title: story.title,
url: story.url,
score: story.score,
time: story.time
};
} catch (error) {
console.error(`Story ${id} の取得に失敗:`, error);
return null;
}
})
);
return stories
.filter(story => story !== null)
.map(story => `タイトル: ${story.title}\nURL: ${story.url || 'なし'}\nスコア: ${story.score}\n---`)
.join('\n\n');
} catch (error) {
console.error('[fetchHackerNews] エラー:', error);
return 'HackerNewsの取得に失敗しました: ' + error.message;
}
}
/**
* 日本語IT系のRSS(はてなブックマーク)を取得
*/
export async function fetchJapaneseRSS() {
try {
const feed = await parser.parseURL('https://b.hatena.ne.jp/hotentry/it.rss');
const articles = feed.items.slice(0, 10).map(item => ({
title: item.title,
link: item.link,
pubDate: item.pubDate,
contentSnippet: item.contentSnippet?.slice(0, 200) || ''
}));
return articles.map(article =>
`タイトル: ${article.title}\n内容: ${article.contentSnippet}\nリンク: ${article.link}\n---`
).join('\n\n');
} catch (error) {
console.error('[fetchJapaneseRSS] エラー:', error);
return '日本語RSSの取得に失敗しました: ' + error.message;
}
}
10. src/scraper.js 作成
(Ubuntu(WSL2)上で作業)
nvim src/scraper.js
import fetch from 'node-fetch';
import * as cheerio from 'cheerio';
/**
* 指定されたURLから見出しと段落を抽出
*/
export async function scrapeHeadlines(url) {
try {
console.log(`[scrapeHeadlines] Fetching: ${url}`);
const response = await fetch(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
}
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const html = await response.text();
const $ = cheerio.load(html);
// 不要な要素を除去
$('script, style, nav, footer, header, .advertisement').remove();
const content = [];
// タイトル
const title = $('title').text().trim();
if (title) content.push(`タイトル: ${title}`);
// 見出し
$('h1, h2, h3').each((i, el) => {
const text = $(el).text().trim();
if (text && text.length > 3) {
content.push(`見出し: ${text}`);
}
});
// 段落
$('p').each((i, el) => {
const text = $(el).text().trim();
if (text && text.length > 10) {
content.push(`段落: ${text.slice(0, 300)}`);
}
});
// 記事コンテンツ
$('article').each((i, el) => {
const text = $(el).text().trim();
if (text && text.length > 10) {
content.push(`記事: ${text.slice(0, 500)}`);
}
});
const result = content.slice(0, 20).join('\n---\n');
console.log(`[scrapeHeadlines] 抽出完了: ${result.length} 文字`);
return result || 'コンテンツを抽出できませんでした';
} catch (error) {
console.error('[scrapeHeadlines] エラー:', error);
return `スクレイピングエラー: ${error.message}`;
}
}
11. src/public/index.html 作成 (フロントエンド)
(Ubuntu(WSL2)上で作業)
mkdir src/public
nvim src/public/index.html
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>技術トレンド速報AI</title>
<style>
body {
font-family: Arial, sans-serif;
padding: 2rem;
background-color: #f7f7f7;
}
h1 {
color: #333;
}
button {
margin: 0.5rem;
padding: 0.5rem 1rem;
font-size: 1rem;
cursor: pointer;
}
#output {
margin-top: 2rem;
white-space: pre-wrap;
background: #fff;
padding: 1rem;
border-radius: 6px;
box-shadow: 0 0 4px rgba(0,0,0,0.1);
}
</style>
</head>
<body>
<h1>技術トレンド速報AI</h1>
<div>
<button onclick="fetchData('/fetch/hackernews')">HackerNews</button>
<button onclick="fetchData('/fetch/techcrunch')">TechCrunch</button>
<button onclick="fetchData('/fetch/jarss')">日本語ニュース(RSS)</button>
<button onclick="fetchHTML()">任意のURLからスクレイピング</button>
<input type="text" id="customUrl" placeholder="https://example.com" style="width: 300px; padding: 0.5rem;" />
</div>
<div id="output">ここに要約結果が表示されます。</div>
<script>
async function fetchData(endpoint) {
const res = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
const data = await res.json();
const log = document.getElementById("output");
if (data.result) {
log.innerText = data.result;
} else if (data.error) {
log.innerText = `エラー: ${data.error}`;
} else {
log.innerText = "何も返ってきませんでした。";
}
}
async function fetchHTML() {
const url = document.getElementById("customUrl").value;
const log = document.getElementById("output");
if (!url) {
log.innerText = "URLを入力してください。";
return;
}
const res = await fetch('/fetch/html', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url })
});
const data = await res.json();
if (data.result) {
log.innerText = data.result;
} else if (data.error) {
log.innerText = `エラー: ${data.error}`;
} else {
log.innerText = "何も返ってきませんでした。";
}
}
</script>
</body>
</html>
12. Node.jsプロジェクトインストール
package.json内で定義されたdependencies(依存関係)を全てインストールします。
(Ubuntu(WSL2)上で作業)
npm install
13. Docker 起動
Docker Desktopのアイコンをダブルクリックしてアプリケーション起動後、コンテナのビルドと起動をおこないます。
(Ubuntu(WSL2)上で作業)
docker-compose up --build
14. Webブラウザ上で実行
ブラウザからサーバーにアクセス
http://localhost:3000/
Webフロントエンドが開き、各ボタンを任意に選択、またはTechニュースサイトのURLを入力して「任意のURLからスクレイピング」ボタンを選択する事で、各ニュースサイトが選ぶ技術トピックのサマリーが表示されます。
15. Docker 終了
(Ubuntu(WSL2)上で作業)
docker-compose down -v