Zenn・Qiita・Hacker News のトレンド記事を 要約付きで 手元に集める個人用ローカルツール。 「読みに行く」から「流れてくる」へ。CLI で取得・要約し、付属の Web(web/)で自分の手元(localhost)で閲覧する。
⚠️ 公開デプロイはしない方針。Zenn / Qiita の利用規約(記事の無断転載・二次配布の禁止)に抵触し得るため、Web サイトとして一般公開はせず、ローカル個人利用に限定する(詳細は なぜ公開しないか)。
✅ ステータス: 機能一式 実装済み。
pnpm appで「取得 → 要約 → ローカル Web 起動」まで 1 コマンド。Web は 3 タブ(Zenn / Qiita / Hacker News)の日付アーカイブ表示。
画面イメージ(上記は架空のサンプルデータ。Zenn / Qiita / Hacker News の 3 タブ、FAQ 形式の要約、前日/翌日ナビ)。
| 取得元 | 対象 | 取得方法 |
|---|---|---|
| Zenn | Tech ジャンルのトレンド 上位 20 件 | 内部 JSON API(/api/articles?order=daily&article_type=tech の先頭 20 件=zenn.dev トップと同順 + /api/articles/{slug} 本文) |
| Qiita | トレンド(人気記事) | 公式 Atom フィード + 公式 API v2 /items/{id} で本文取得(任意 QIITA_TOKEN) |
| Hacker News | topstories Top 40 | 公式 API(Firebase)topstories.json + item/{id}.json。記事は外部リンクのためトップコメント(議論)を取得して要約のもとにする |
Zenn / Qiita は記事の本文全体を、Hacker News はトップコメント(議論)を取得し、Gemini Flash-Lite(無料枠)で FAQ 形式(問い 1 行+箇条書きの答え 2〜3 個)に要約。標準出力(人間向け整形)と articles.json(機械向け・Web フロントの入力)の双方に書き出します。いいね/ストック(Zenn / Qiita)・ポイント/コメント数(Hacker News)も取得し、HN の英語タイトルは日本語に翻訳して表示します。
- Node.js 22 以上(
.nvmrc参照) - pnpm
GEMINI_API_KEY(要約に使用 / 取得先・無料枠・クレカ不要)--no-summaryで要約をスキップする場合は不要
QIITA_TOKEN(任意:あれば Qiita API が匿名 60 → 1000 req/h に拡張、取得先)
pnpm install # 依存をインストール(pre-commit フックも自動設定)
cp .env.example .env # GEMINI_API_KEY を設定(要約スキップ時は不要)pnpm start 実行時に Node の --env-file-if-exists=.env(22.9+)が自動で .env をプロセス環境へ読み込む。dotenv 等の追加依存は不要。.env が無くてもエラーにならない(フラグ末尾の -if-exists)。ビルド済み成果物を直接実行する場合は node --env-file-if-exists=.env dist/index.js のように同じフラグを付ける。
# 既定: Zenn / Qiita / Hacker News を取得し Gemini で要約、stdout と articles.json + data/archive へ出力
pnpm start
# 各ソース 3 件に絞る(per-source HTTP コスト削減)
pnpm start --limit 3
# 要約をスキップ(Gemini を呼ばず、本文をそのまま excerpt として出力)
pnpm start --no-summary
# stdout を純粋な JSON にしてパイプ可能に
pnpm start --json-only > out.json
# 出力先を指定
pnpm start --out data/today.json| フラグ | 既定値 | 説明 |
|---|---|---|
--config <path> |
feeds.yaml |
設定ファイルのパス |
--limit <N> |
(無制限) | 各ソース最大 N 件。指定しなければ Zenn 20 / Qiita ~30 / Hacker News 40 を取得 |
--summary-limit <N> |
(無制限) | 要約を試みる最大件数(トレンド上位から)。未指定なら全件試行し無料枠上限で自動停止 |
--no-summary |
false |
要約をスキップ(excerpt をそのまま summary に流用、全件 summarized:false) |
--out <path> |
articles.json |
最新スナップショットの JSON 出力先 |
--archive-dir <dir> |
data/archive |
日次アーカイブの出力先。<取得日(JST)>.json を書き出す(Web の日付ページの入力) |
--json-only |
false |
stdout に純粋な JSON を出す(人間向け整形を抑制、パイプ用) |
-h, --help |
ヘルプを表示 | |
-v, --version |
バージョンを表示 |
終了コード: 0 成功 / 1 ユーザーエラー(設定不正・API キー欠落・--limit 不正等) / 2 システムエラー / 130 SIGINT。
feeds.yaml に取得元を記述する:
sources:
zennTrending: true # Zenn Tech ジャンルのトレンドを取得するか
qiitaTrending: true # Qiita /trend(人気記事)を取得するか
hackerNewsTop: true # Hacker News topstories(Top 40)を取得するか| コマンド | 内容 |
|---|---|
pnpm app [flags] |
Zenn/Qiita/Hacker News の取得→要約→Web 起動を 1 コマンドで実行。当日(JST)のアーカイブ data/archive/<本日>.json が既にあれば取得・要約をスキップして Web だけ起動。flags は pnpm start へ透過 |
pnpm start [flags] |
CLI を実行(tsx で src/index.ts、--env-file-if-exists=.env 込み) |
pnpm build |
dist/ へビルド(tsc + tsc-alias で @/ を相対 path に書き換え) |
pnpm typecheck |
型チェック(tsc --noEmit) |
pnpm test |
vitest 単体テスト |
pnpm test:watch |
watch モード |
pnpm test:coverage |
カバレッジ計測(80% 閾値) |
pnpm test:integration |
実 API を叩く smoke(手動実行、RUN_INTEGRATION=1) |
pnpm lint / pnpm lint:fix |
ESLint |
pnpm format / pnpm format:check |
Prettier |
stdout(人間向け整形):
[info] 📰 取得: zenn 20 / qiita 30 / hackernews 40 (合計 90 件)
[info] ↓ 要約中 (90 件)...
[info] 要約: 成功 90 / 未要約 0 件
--- Zenn (20 件) ---
[1] 「JWT を localStorage に置くな」はなぜ言われるのか、Cookie 回帰までの時系列整理 (khale)
URL: https://zenn.dev/khale/articles/web-session-jwt-cookie-history
要約: Q. なぜ JWT を localStorage に置くべきでないのか?
・localStorage は JavaScript から読めるため XSS でトークンを盗まれる
・…
[2] ...
--- Qiita (30 件) ---
[1] ...
--- Hacker News (40 件) ---
[1] ...
[info] 合計 90 件 (Zenn 20 / Qiita 30 / Hacker News 40)
[info] articles.json に 90 件を書き出しました
[info] data/archive/2026-06-04.json に 90 件を書き出しました
articles.json(機械向け、配列 1 要素のスキーマ例):
{
"source": "zenn",
"title": "「JWT を localStorage に置くな」はなぜ言われるのか…",
"url": "https://zenn.dev/khale/articles/web-session-jwt-cookie-history",
"author": "khale",
"publishedAt": "2026-05-27T07:58:44.000Z",
"summary": "Q. なぜ JWT を localStorage に置くべきでないのか?\n・localStorage は JavaScript から読めるため XSS でトークンを盗まれる\n・HttpOnly + SameSite な Cookie ならスクリプトから隔離できる\n・そのため Cookie ベースのセッション管理へ回帰する流れがある",
"excerpt": "はじめに Web セキュリティの第一人者である徳丸浩氏が X で、JWT と Cookie セッションの関係について…(HTML 除去済みの本文全体)",
"summarized": true,
"likes": 482
}summaryは FAQ 形式(1 行目に「問い」、続けて・始まりの答え 2〜3 個を改行でつなぐ 1 文字列)。Web 側は問いと箇条書きに分解して表示する。Hacker News は記事本文ではなく**コメント(議論)**を要約する。summarizedはsummaryが AI 要約かどうかのフラグ。無料枠の上限を超えた記事はsummarized: falseでsummaryにexcerpt(本文冒頭)を流用する。Web 側はこのフラグで「要約済み / 本文冒頭」を区別表示する。likes(任意)= いいね数(Zenn のliked_count/ Qiita のlikes_count)、stocks(任意・Qiita のみ)= ストック数。points/comments(任意・Hacker News のみ)= ポイント(score)/ コメント数(descendants)。titleTranslated(任意・主に Hacker News)= 英語タイトルの日本語訳。値があれば Web は主タイトルにこれを使い、原題titleをサブ行に併記する。- いずれの任意フィールドも取得できない場合は省略され、UI でも非表示。
tech-news/
├── src/ # CLI 本体(=コア。取得→要約。web からも型を共有)
│ ├── index.ts # エントリポイント(CLI 引数解析・終了コード制御)
│ ├── constants.ts # 既定値・各種定数
│ ├── core/ # ドメインロジック(取得 → 要約のオーケストレーション)
│ │ ├── pipeline.ts # 取得 → クランプ → 要約の全体制御
│ │ ├── config/ # feeds.yaml のロード・zod 検証
│ │ │ └── loader.ts
│ │ ├── fetchers/ # 取得元ごとの Fetcher(一覧 + 本文/コメント API の 2 段)
│ │ │ ├── zenn.ts
│ │ │ ├── qiita.ts
│ │ │ └── hackernews.ts # HN topstories + トップコメント取得
│ │ └── summarizer/ # Gemini 要約とクォータ制御
│ │ ├── gemini.ts # Gemini SDK を DI で隔離
│ │ ├── quota.ts # 429(無料枠上限)の分類・retryDelay 解釈
│ │ └── prompts.ts # prompts/ の Markdown ローダー
│ ├── sinks/ # 出力先(人間向け / 機械向け)
│ │ ├── stdout.ts # 人間向け整形出力
│ │ └── jsonFile.ts # articles.json への原子的書き込み
│ ├── types/ # 型定義(article / config / ports)
│ │ ├── article.ts
│ │ ├── config.ts
│ │ └── ports.ts
│ └── utils/ # 汎用ユーティリティ
│ ├── http.ts # fetch ラッパ(タイムアウト・リトライ)
│ ├── logger.ts # ログ出力
│ └── text.ts # HTML 除去・テキスト整形
├── test/ # テスト(src/ をミラー・1 ソース = 1 テスト)
├── prompts/ # 要約プロンプトの Markdown 定義(コードから外出し)
│ └── summarize.md
├── web/ # Next.js 静的サイト(日次アーカイブを読んで日付ごとに表示)
│ ├── src/
│ │ ├── app/ # layout.tsx / page.tsx(最新)/ [date]/page.tsx / globals.css / icon.svg
│ │ ├── components/ # Header / DateBar(ナビ) / DigestView / SourceFilter / ArticleList / ArticleItem / MascotIcon
│ │ └── lib/ # articles(検証)/ archive(日別読込・隣接日)/ filter / format(FAQ 分解)
│ ├── data/
│ │ └── archive/ # ビルド再現用サンプル 3 日分(コミット済み)
│ └── test/ # lib の単体テスト(src をミラー)
├── data/archive/ # 日次アーカイブ <取得日>.json(実データ・**gitignore / 未追跡**)
├── scripts/ # 開発用スクリプト(start-app.mjs = pnpm app の実体)
├── docs/ # screenshot.png / plan(実装計画)
├── feeds.yaml # 取得元の設定(Zenn / Qiita / Hacker News の ON/OFF)
├── pnpm-workspace.yaml # ルート(CLI) + web のワークスペース定義
├── DESIGN.md # UI 用デザインシステム定義(getdesign.md / Apple)
├── docs/plan/ # 実装計画ドキュメント
├── CLAUDE.md # Claude 向けプロジェクト指示
├── CONTRIBUTING.md # 開発フロー・規約
└── README.md
Web は CLI が出力する**日次アーカイブ(
data/archive/<取得日>.json、Articleスキーマ)**を契約として読む。Article型は web からsrc/types/article.tsを path alias(@core/*)で直接参照し、二重管理を避けている。
このリポジトリは「まず CLI を作り、あとから
web/を pnpm ワークスペースとして足した」という経緯で育ったため、構成にいくつか負債がある。動作はするが、作り直すなら直したい点:
- package.json が 2 つ(ルート CLI +
web/)で、tsconfig/eslint/vitest/prettier設定も二重化している。本来はpackages/clipackages/webpackages/shared(types)のように整理した monorepo か、せめて型共有用の小パッケージを切り出すのが理想。 - 型契約がパス参照(密結合): web が
@core/*で../src/*を直 import しているため、CLI 内部のリファクタが web を壊し得る(共有パッケージ化していない)。 - pre-commit の lint が
web/を検査しない: ルート ESLint に web の.tsを混ぜると TSConfigRootDir が曖昧になる問題を避けるため、lint 対象をsrc/test/に限定。web の lint は手動 / CI 任せ。 - 出力契約にスキーマバージョンが無い:
articles.json/ アーカイブの後方互換は zod のdefault頼み。 - 重複排除なし: 各日は全件スナップショットで、前日と同じ記事が別日にも出る。
data/archiveの扱いが運用前提に依存: いまは「実データは gitignore・ローカル閲覧のみ」。公開や自動更新をやめた分、cron 連携などは未整備。
flowchart LR
Yaml[feeds.yaml] --> Loader[ConfigLoader<br/>zod 検証]
Loader --> Pipeline
subgraph Fetchers[Fetchers: 一覧 + 本文/コメント API の 2 段]
Zenn[ZennTrendingFetcher<br/>article_type=tech の JSON API]
Qiita[QiitaTrendingFetcher<br/>Atom + API v2 rendered_body]
HN[HackerNewsFetcher<br/>topstories + トップコメント]
end
Pipeline -->|並列<br/>失敗隔離| Fetchers
Fetchers --> RawArticles[RawArticle 配列<br/>本文/議論を HTML 除去]
RawArticles --> Summarizer[GeminiSummarizer<br/>FAQ 要約+英語タイトル日本語訳<br/>バッチ/スロットル/無料枠上限で停止]
Summarizer --> Articles[Article 配列]
Articles --> StdoutSink[StdoutSink<br/>人間向け]
Articles --> JsonSink[JsonFileSink<br/>articles.json(最新)]
Articles --> Archive[JsonFileSink<br/>data/archive/<取得日>.json]
Archive -.読み込み.-> Web[web: Next.js 静的サイト<br/>日付ごとに表示]
主要モジュール:
- src/core/fetchers/ —
zenn.ts/qiita.ts/hackernews.ts(各サイト独立、一覧 + 本文/コメント API の 2 段) - src/core/summarizer/ —
gemini.ts(DI で SDK 隔離・FAQ 要約+タイトル翻訳)/quota.ts(429 の分類・retryDelay 解釈)/prompts.ts(ローダー) - src/core/pipeline.ts — 取得 → クランプ → 要約のオーケストレーション
- src/core/output.ts — 出力用整形(excerpt 切り詰め)
- src/sinks/ —
stdout.ts(人間向け)/jsonFile.ts(原子的書き込み) - prompts/ — 要約プロンプトの Markdown 定義(コードから外出し)
CLI が出力した日次アーカイブ(data/archive/<取得日>.json)を読み込み、日付ごとに一覧表示する静的サイト(web/)。Next.js App Router の output: "export"(本番ビルド時のみ)で完全静的に書き出せる。ただし規約上の理由で一般公開はせず、localhost での個人閲覧に使う(なぜ公開しないか)。配色・タイポは DESIGN.md(getdesign.md / Apple 風)のトークンを Tailwind v4 の @theme に写経して適用している。
- 構成: catnose 風のカード型リスト。表示名は Tech News、ヘッダにマスコット(🐶)+ブラウザタブの favicon も同意匠。取得元タブ(Zenn / Qiita / Hacker News の 3 つ・既定 Zenn)の選択はオレンジ。各カードに連番(黒丸)・太字タイトル(原文リンク↗・HN は日本語訳タイトル+下に英語原題)・「📝 要約」マーカー付きの FAQ 要約全文(問い+箇条書き)・出典 favicon+ドメイン+著者+いいね♥/ストック🔖(Zenn/Qiita)・▲ポイント/💬コメント(HN)+公開日。日付は本文側の日付バー(中央・太字)に表示。並びは各サイトのトレンド順(Zenn は zenn.dev トップ順、Qiita は qiita.com 順、HN は topstories 順)。未要約は「要約なし」バッジ+本文冒頭を表示。スクロールすると右下に**「上部へ戻る」ボタン**が出る。
- 日付アーカイブ: トップ
/は最新日、/<日付>(例/2026-06-04)で各日のまとめを表示。日付バーから 「← 前日」「翌日 →」で行き来でき、最新でない日では 「最新の日付へ →」を出す。各日 JSON はgenerateStaticParamsで日付ごとの静的ページに書き出す。各日はその日のトレンド全件スナップショット(クロス日の重複排除はしない)。 - データ解決の優先順位: 環境変数
ARCHIVE_DIR→ ルートの../data/archive→web/data/archive(コミット済みサンプル 3 日分)。データ未生成でもサンプルでビルドが通る。 - データ保持の方針: 日次 JSON は flat-file(クラウド DB は使わない)。実データ(
data/archive/<取得日>.json)は再配布扱いを避けるため git にコミットしない(.gitignore済み・ローカルにのみ蓄積)。ビルド/デモ用の架空サンプルだけweb/data/archiveにコミットしている(新規クローンでもサンプルでビルドが通る)。出力 JSON はexcerptを 400 字に切り詰める。 - 読み込みタイミング(重要): 静的サイトなので アーカイブは「dev 起動時 / build 時」に 1 回だけ読む。先にデータを生成してから起動すること。起動後にデータを作り直したら、dev サーバを再起動(Ctrl+C → 再実行)しないと反映されない。
pnpm install # ルートで実行(web の依存も解決)
# 【おすすめ】Zenn / Qiita / Hacker News の取得→要約→Web 起動を 1 コマンドで。
# 当日分のアーカイブ data/archive/<本日(JST)>.json があれば取得(=Gemini 要約)をスキップして起動。
# feeds.yaml の sources で各取得元の ON/OFF を切り替えられる。
pnpm app # (必要なら)取得・要約してから http://localhost:3000 を起動
# 当日分を取り直したいときは当日のアーカイブを消してから: rm data/archive/$(TZ=Asia/Tokyo date +%F).json
# --- 個別に実行したい場合は以下 ---
# 1) 先に実データを生成(要約は Gemini / GEMINI_API_KEY が必要)。
# 自分が要約した記事を見たいときは必ずこれを先に実行する。
pnpm start # → articles.json(最新)+ data/archive/<取得日>.json
# 要約を省くなら: pnpm start --no-summary(summary に本文をそのまま使う)
# 2) その後に web を起動する(順番が逆だとサンプルが表示される)
pnpm --filter web dev # 開発サーバ(http://localhost:3000)
pnpm --filter web build # 静的書き出し(web/out/)
pnpm --filter web typecheck # 型チェック
pnpm --filter web lint # ESLint
pnpm --filter web test # vitest(lib の単体テスト)日付アーカイブ(前日/翌日・最新へ)は実装済み(docs/plan/date-archive.md)。一般公開はしない方針のため、Vercel 等への公開デプロイや cron 自動更新は採用しない(必要なら手元で
pnpm appを回す)。
- Zenn の本文取得は内部 API(公式に文書化されていない)を利用。サイト改修で予告なく仕様変更される可能性あり。失敗時は記事単位で除外する設計(pipeline は止まらない)。
- Qiita 匿名 API は 60 req/h/IP。1 実行で Atom 1 + 本文 ~30 = 31 calls 使うため、1 時間に 1〜2 回までしか走らせられない。1 日 1 回運用なら問題なし。心配なら
QIITA_TOKENで 1000 req/h に拡張可。 - Hacker News は外部リンク記事で HN 自体に本文が無いため、トップコメント(議論)を要約する(catnose の「コメント要約」に相当)。英語コンテンツは Gemini が日本語 FAQ へ翻訳要約し、タイトルも日本語訳する。1 実行で topstories 1 + item 40 + コメント 40×6 ≒ 約 280 calls(無認証 Firebase 読み取り)。
- Gemini 無料枠は 1 日あたりの「リクエスト数」が上限(実測で
gemini-2.5-flash-liteは約 20 req/日)。これに対し、パイプラインは複数記事を 1 リクエストにまとめて要約(バッチ)する(既定 10 件/リクエスト)。Zenn 20 + Qiita 30 + Hacker News 40 = 90 件でも約 9 リクエストで済むため、無料枠でも全件要約を狙える。日次上限(429)に達した分は以降を止めて本文冒頭を流用(summarized:false)。RPM 保護のためリクエスト間はスロットル。要約件数を抑えたいときは--summary-limit <N>。 - クロス日の重複排除なし: 各日はその日のトレンド全件スナップショットとして保存するため、前日と同じ記事が別の日にも出ることがある。
- Web は localhost 閲覧のみ: 日次アーカイブを日付ごとに表示する静的サイト(web/・前日/翌日・最新へナビ)は実装済みだが、規約上の理由で一般公開はしない(下記)。
⚠️ 筆者は法律の専門家ではなく、以下は法的助言ではありません。 各サービスの最新規約を確認してください。
結論: 本プロジェクトは Web サイトとして一般公開(デプロイ)しない。 Zenn / Qiita の規約に抵触し得るため、ローカル(localhost)での個人利用に限定する。理由は以下。
- ローカル利用(非公開)は基本的に問題なし: 取得した記事を自分の手元で読むだけ(再配布しない)なら、規約・著作権上の懸念は小さい。コードのみの OSS 公開も、記事コンテンツを含まないので再配布には当たらない(実データ
data/archive/は gitignore 済み)。 - 公開(Web で第三者に配信)は再配布にあたり、特に Zenn は注意:
- Zenn: 利用規約で**「利用者コンテンツの無断転載・二次配布」を明示的に禁止**(第6条)。Zenn の記事タイトル・本文・要約を公開サイトに載せる行為は再配布とみなされ得る。さらに本文取得は非公式の内部 APIであり公認されていない。→ 公開時のリスクは高い。
- Qiita: 公式 API はあるが、記事の著作権は投稿者に帰属(Qiita のライセンスは Qiita 自身の利用許諾で、第三者の再配布を許可するものではない)。要約・抜粋の公開は投稿者の許諾が必要になり得る。
- Hacker News: 公式 API は寛容(レート制限なし・MIT のリポジトリ・再配布の明示的制限なし)。コメントはユーザー生成物。比較的リスクは低い(catnose の HN まとめ等の先行例あり)。
- 著作権一般(日本): タイトルのみ+原文リンクは一般に許容(RSS リーダー/ニュースアグリゲータ的)。一方、本文の抜粋や AI 要約の公開は複製権・公衆送信権・翻案権に触れ得る。引用(著作権法32条)は「主従関係・出所明示・必要最小限」が条件で、要約が主コンテンツになるダイジェストは引用に当たりにくい。
- 公開する場合の安全寄りの方針(例): ①タイトル+出典リンクのみにして本文抜粋・要約を載せない、②各取得元から許諾を得る、③HN など寛容なソースに絞る、④出所明示・著者表記・オプトアウト手段を用意。
- 本リポジトリの MIT ライセンスはこのアプリのコードに対するもので、取得した各記事の著作権には及ばない。
一般公開はしない方針なので、「公開デプロイ」「公開向け cron 自動更新」は対象外。手元での使い勝手・実装品質の改善を中心に。
- 重複排除・差分管理: 日付別 JSON + URL ハッシュで「その日の新着」を確定する(現状は全件スナップショット)。
- 構成のリファクタ: 上記「設計上の課題」のとおり、monorepo 整理・型共有パッケージ化など。
- 要約品質: 本文の構造をより残した整形(コードブロック保持等)、プロンプト改善。
- ローカル自動化(任意): 手元の launchd / cron で 1 日 1 回
pnpm appを回す程度(外部公開はしない)。
実装済み: 取得 → FAQ 要約(バッチ・タイトル翻訳)→ 日次アーカイブ出力 → 3 タブ静的 Web(日付アーカイブ・前日/翌日ナビ)。
開発フロー・コミット規約・コーディング規約は CONTRIBUTING.md を参照。
マスコット/favicon は 🐶(U+1F436)絵文字そのものを使用(OS のフォントで描画するため画像アセット・帰属表示は不要。閲覧環境によって見た目は変わる)。差し替え候補(OpenMoji / Twemoji / Noto / 自作 SVG)は DESIGN.md の「アイコン候補」を参照。
