🐶

Hacker Newsの最新記事を要約するツールを作る

に公開

X(旧Twitter)で情報収集している方は多いと思います。

ノイズが多くて疲れませんか?驚き屋さんに辟易としていませんか?

Xから情報を収集するのは程々にして私はHacker Newsをよくみています。
特に気になるニュースは翻訳ツールやLLMに解説をお願いして理解に努めています。

仕事の前や仕事の合間に読んでいることが多いですが、業務がAIで効率化されたことによって日々の情報処理量が多くなって、Hacker Newsに目を通して英語の字を見るのも疲れてきました。

もう疲れたくない、その思いからHacker Newsの要約をするツール[1]を作り始めました。

成果物

作ったのはこのようなものです。Web UIで表示する形にしています。

https://news.ycombinator.com/ に一覧表示されている最新のニュースを取得し、その記事の内容及びHacker News内に記載されているコメントを要約するようにしました。

いわゆるAIエージェントっぽさも欲しかったので、チャット機能も設けました。
チャットを通じて記事のまとめ、最新のニュースの確認などを行うことができます。

現時点では、記事取得は一日1回のバッチ更新としています。

非常に便利に使っています。

システム全体の構成図

全体設計

アーキテクチャは、モノレポ構成のめちゃシンプルな作りです。

.
├─ apps/
│  ├─ web          # Next.js 14 App Router (UI + API)
└─ packages/
   └─ core         # 共有ロジック (HN API, LLM, Prisma)

また、システムのデータフローは以下の通りです。

データベース設計

Prismaを使用したスキーマ設計は以下の通りです。これまたシンプル。

  • Story: HN記事の基本情報を保持
  • StorySummary: LLMによる記事要約と分類情報(トピック、重要度、読書時間)
  • StoryCommentSummary: コメント欄の要約とハイライト
model Story {
  id           Int            @id
  title        String
  url          String?
  by           String?
  score        Int            @default(0)
  descendants  Int            @default(0)
  publishedAt  DateTime       @default(now())
  summary      StorySummary?
  commentSummary StoryCommentSummary?
}

model StorySummary {
  id                  Int      @id @default(autoincrement())
  storyId             Int      @unique
  summaryJa           String
  topic               String
  importance          Int
  readingTimeMinutes  Int
  reason              String
  llmModel            String?
  story               Story    @relation(fields: [storyId], references: [id])
}

model StoryCommentSummary {
  id             Int      @id @default(autoincrement())
  storyId        Int      @unique
  summaryJa      String
  highlights     String?
  sampleComments String?
  story          Story    @relation(fields: [storyId], references: [id])
}

実装の詳細

HN API連携

Hacker News APIはFirebase Realtime Databaseで公開されており、RESTful APIとして利用できます。太っ腹。

const HN_API_BASE = 'https://hacker-news.firebaseio.com/v0';

export async function fetchTopStoryIds(limit: number): Promise<number[]> {
  const ids = await pRetry(
    () => fetchJson<number[]>('topstories.json'),
    { retries: 2 }
  );
  return ids.slice(0, limit);
}

export async function fetchStoryById(id: number): Promise<HackerNewsStory | null> {
  return pRetry(
    () => fetchJson<HackerNewsStory | null>(`item/${id}.json`),
    { retries: 2 }
  );
}

念の為、p-retryライブラリを使用してネットワークエラーに対する耐性を確保しています。

LLM統合

OpenAI APIを使用して要約を生成することにしました。今回に関してはこだわりは特にないです。
GeminiだろうがClaudeだろうが、要約してくれるならなんでもよし。[2]

export async function summarizeStory(input: {
  title: string;
  url?: string;
  textSnippet?: string;
}): Promise<StorySummary> {
  const prompt = `
以下のHacker Newsの記事を日本語で要約してください。

タイトル: ${input.title}
URL: ${input.url || 'N/A'}
本文: ${input.textSnippet || 'なし'}

以下の形式でJSONを返してください:
{
  "summaryJa": "日本語の要約(2-3文)",
  "topic": "AI|Web|DevOps|Security|Business|Other",
  "importance": 1-5の数値,
  "readingTimeMinutes": 推定読書時間,
  "reason": "重要と判断した理由"
}
  `;

  const response = await this.request({
    model: this.config.model,
    messages: [{ role: 'user', content: prompt }],
    response_format: { type: 'json_object' }
  });

  return JSON.parse(response.choices[0].message.content);
}

リアルタイム翻訳機能

データベースに保存された記事とは別に、HNから直接記事を取得して翻訳する機能も実装しました。

export async function fetchAndTranslateLiveStories(limit = 30) {
  const llm = getLlmClient();
  const ids = await fetchTopStoryIds(limit);
  const stories = await Promise.all(ids.map((id) => fetchStoryById(id)));

  const liveStories = [];

  for (const story of validStories) {
    try {
      const summary = await llm.summarizeStory({
        title: story.title,
        url: story.url,
        textSnippet: story.text
      });

      liveStories.push({
        id: story.id,
        title: story.title,
        summary: {
          summaryJa: summary.summaryJa,
          topic: summary.topic,
          importance: summary.importance,
          readingTimeMinutes: summary.readingTimeMinutes,
          reason: summary.reason
        }
      });
    } catch (error) {
      // エラー時は要約なしで記事を含める
      console.error(`Failed to summarize story ${story.id}:`, error);
      liveStories.push({ id: story.id, title: story.title, summary: null });
    }
  }

  return liveStories;
}

このアプローチにより、データベースを経由せずにリアルタイムで最新の記事を取得・翻訳できます。

パフォーマンス最適化

バッチ処理

記事の要約生成は、コスト削減と処理時間の観点からバッチ処理で実行しています。

export async function summarizeUnprocessedStories(limitPerRun = 5) {
  const targets = await prisma.story.findMany({
    where: { summary: null },
    orderBy: { score: 'desc' },
    take: limitPerRun
  });

  for (const story of targets) {
    const summary = await llm.summarizeStory({
      title: story.title,
      url: story.url,
      textSnippet: story.textSnippet
    });

    await prisma.storySummary.upsert({
      where: { storyId: story.id },
      update: { ...summary },
      create: { storyId: story.id, ...summary }
    });
  }
}

まとめ

本記事では、Hacker Newsの記事を自動で収集・翻訳・要約するエージェントシステムの実装について解説しました。

やったこと

  • モノレポ構成による共通ロジックの再利用
  • Prisma ORMによる型安全なデータベース操作
  • OpenAI APIを使用した自動翻訳・要約
  • Next.js App Routerによるフルスタック実装

少しの時間でいい感じに仕上げることができました。
改善の余地はまだあるので、自分好みのツールに仕上げていこうと思います。

脚注
  1. AIエージェントかどうか微妙なのでツールと書いている ↩︎

  2. ollmaで構築したローカルLLMを呼び出すことも検討しました。軽く試したら日本語の精度が良くなかったので一旦これで。 ↩︎

TOKIUMプロダクトチーム テックブログ
設定によりコメント欄が無効化されています