1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ChatGPT + Node.jsで技術トレンドをピックアップ

Posted at

各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からスクレイピング」ボタンを選択する事で、各ニュースサイトが選ぶ技術トピックのサマリーが表示されます。

front_techsum.png

15. Docker 終了

(Ubuntu(WSL2)上で作業)

docker-compose down -v
1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?