feat: AIニュース自動収集・分析ツールを追加

Made-with: Cursor
This commit is contained in:
hiroki ito 2026-04-17 21:58:37 +09:00
commit 720b3617d0
35 changed files with 4756 additions and 0 deletions

20
.env.example Normal file
View File

@ -0,0 +1,20 @@
# X API (OAuth 1.0a user context) — console.x.com のアプリから取得
# 「キーとトークン」タブ → 「コンシューマーキー」セクション
X_CONSUMER_KEY=
X_CONSUMER_SECRET=
# 「キーとトークン」タブ → 「OAuth 1.0 キー」セクション → アクセストークンを生成
X_ACCESS_TOKEN=
X_ACCESS_TOKEN_SECRET=
# Jina Reader — jina.ai のダッシュボードから取得
JINA_API_KEY=
# Gemini — aistudio.google.com から取得
GEMINI_API_KEY=
# Slack — api.slack.com の Bot アプリから取得
SLACK_BOT_TOKEN=
SLACK_CHANNEL=
# モックルートtrue にすると X API を使わず fixtures/sample-tweets.json を読む)
USE_SAMPLE_DATA=false

59
.github/workflows/daily-news.yml vendored Normal file
View File

@ -0,0 +1,59 @@
name: Daily AI News
on:
workflow_dispatch:
inputs:
use_sample_data:
description: 'fixtures/sample-tweets.json を使って動作確認するX連携なし'
type: boolean
default: false
schedule:
- cron: '30 22 * * *' # 毎日 07:30 JST
permissions:
contents: read
concurrency:
group: daily-news
cancel-in-progress: false
jobs:
run:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version-file: '.nvmrc'
cache: 'npm'
- run: npm ci
- run: npm start
env:
USE_SAMPLE_DATA: ${{ inputs.use_sample_data }}
X_CONSUMER_KEY: ${{ secrets.X_CONSUMER_KEY }}
X_CONSUMER_SECRET: ${{ secrets.X_CONSUMER_SECRET }}
X_ACCESS_TOKEN: ${{ secrets.X_ACCESS_TOKEN }}
X_ACCESS_TOKEN_SECRET: ${{ secrets.X_ACCESS_TOKEN_SECRET }}
JINA_API_KEY: ${{ secrets.JINA_API_KEY }}
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
SLACK_CHANNEL: ${{ secrets.SLACK_CHANNEL }}
- name: Notify failure to Slack
if: failure()
run: |
curl -X POST "https://slack.com/api/chat.postMessage" \
-H "Authorization: Bearer $SLACK_BOT_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"channel": "'"${SLACK_CHANNEL}"'",
"text": ":x: *AIニュース エラー発生*\nワークフロー実行: <'"${FAILED_RUN_URL}"'|詳細を確認>"
}'
env:
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
SLACK_CHANNEL: ${{ secrets.SLACK_CHANNEL }}
FAILED_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
node_modules/
dist/
.env
.env.local

1
.nvmrc Normal file
View File

@ -0,0 +1 @@
22

61
CLAUDE.md Normal file
View File

@ -0,0 +1,61 @@
# AIニュース
Xのホームタイムラインから24時間以内のAI関連ニュースを収集し、Geminiで分析してSlackに投稿する自動配信ツール。
## プロジェクト構造
```
src/
├── main.ts # エントリポイント(最初にここを読む)
├── settings.ts # 受講生が触る全設定TS定数
├── config.ts # 環境変数の zod 検証
├── types.ts # 共通型定義
├── sources/
│ ├── x-timeline.ts # X API タイムライン取得 + モックルート分岐
│ └── url-content.ts # Jina Reader で URL 本文並列取得
├── analysis/
│ ├── url-summarizer.ts # Gemini Flash で各URL要約
│ ├── analyze.ts # Gemini Pro で最終トレンド分析
│ ├── schema.ts # zod スキーマSSoT
│ └── prompts.ts # プロンプト2つ
├── delivery/
│ └── slack.ts # Block Kit 組み立て + Slack投稿
└── utils/
├── chunk.ts # 配列チャンク分割
├── errors.ts # UserFacingError + assertSlackOk
└── post-optimizer.ts # URL抽出・t.co展開・テキスト整形
```
## 実行方法
```bash
npm start # 通常実行
USE_SAMPLE_DATA=true npm start # モックルートX API 不要)
```
## 読む順番
1. `src/main.ts` — 4ステップの全体像
2. `src/sources/x-timeline.ts` — ソース元
3. `src/analysis/analyze.ts` — 処理
4. `src/delivery/slack.ts` — 届ける先
5. `src/settings.ts` — 設定を変えたいとき
## GitHub Secrets
| シークレット名 | 用途 |
|---|---|
| `X_CONSUMER_KEY` | X OAuth 1.0a consumer keyconsole.x.com 「コンシューマーキー」の API Key |
| `X_CONSUMER_SECRET` | X OAuth 1.0a consumer secret同 API Key Secret |
| `X_ACCESS_TOKEN` | X OAuth 1.0a access tokenconsole.x.com 「OAuth 1.0 キー」のアクセストークン) |
| `X_ACCESS_TOKEN_SECRET` | X OAuth 1.0a access token secret同 アクセストークンシークレット) |
| `JINA_API_KEY` | Jina Reader |
| `GEMINI_API_KEY` | Gemini Flash + Pro |
| `SLACK_BOT_TOKEN` | Slack Bot`chat:write` スコープ) |
| `SLACK_CHANNEL` | 投稿先チャンネルID |
## 禁止事項
- curl で直接 Slack API / X API を叩かない
- `src/settings.ts` 以外のファイルで設定値をハードコードしない
- 受講生に見せたいエラー文言は `UserFacingError``message` に入れる(`main.ts` が `[USER-FACING]` プレフィックス付きで出力する)。`throw new Error(...)` だけだと `[INTERNAL]` 扱いになり、受講生が混乱する

317
README.md Normal file
View File

@ -0,0 +1,317 @@
# AIニュース
Xのタイムラインから24時間以内のAI関連ニュースを自動収集し、Geminiで分析してSlackに投稿するツールです。
```
┌──────────────────────────────────────────────────────────┐
│ 🤖 24時間以内のAIトレンド │
│ │
│ 🔥 主要なニュース・話題 │
│ ────────────────────────────── │
│ GPT-5.5が発表、ネイティブtool useに対応 │
│ - エージェント時代の到来を示す大型アップデート │
│ - 複数のMCPサーバーを同時接続して自動実行が可能に │
│ │
│ ⚡️ 注目のアップデート │
│ ────────────────────────────── │
│ Cursor Background Agent — 寝ている間にPRを作成 │
│ - コードレビューコメントまで自動付与 │
│ │
│ 💡 技術トレンド │
│ ────────────────────────────── │
│ テスト時計算量スケーリングへのパラダイムシフト │
│ - より大きなモデルからよりスマートな推論へ │
│ │
│ AIニュースで自動生成 │
└──────────────────────────────────────────────────────────┘
```
Slackにはこのようなニュース分析レポートが毎朝届きます。
---
## 全体像
```
┌─────────────┐
│ トリガー │ GitHub Actions
│ (いつ動く) │ → 毎朝 07:30 JST定期実行を有効化した場合
└──────┬──────┘
┌─────────────┐
│ ソース元 │ X旧Twitter
│ (データ) │ → ホームタイムラインから24時間以内のツイートを取得
└──────┬──────┘
┌─────────────┐
│ 処理する場所 │ GitHub Actions 上の Node.js
│ (加工) │ → Gemini でトレンド分析し、構造化JSONに変換
└──────┬──────┘
┌─────────────┐
│ 届ける先 │ Slack
│ (配信) │ → チャンネルにニュース分析レポートを投稿
└─────────────┘
```
---
## 料金について
| サービス | 料金 |
|---|---|
| GitHub Actions | 毎月2,000分無料実行は数分で完了 |
| X APIpay-per-use | Post Read $0.005/件。500件/日×30日 = **約$75/月**。100件/日なら約$15/月 |
| Jina Reader | 無料枠あり1Mトークン、使い切り型 |
| GeminiFlash + Pro | 無料枠で完結1日1回の実行なら超過しない |
| Slack | 無料ワークスペースで可 |
X API 以外は全て無料枠で動きます。X API のコストを抑えたい場合は `src/settings.ts``maxTweets` を 100〜200 に下げてください。
> Jina Reader の無料1Mトークンは**アカウントに対する一括付与で、月次リセットされません**。枯渇してもツール自体は壊れず、ツイート本文だけで分析を続行します。
---
## セットアップ
### 準備するもの
- **GitHub アカウント**
- **Slack ワークスペース**(無料プランで可)
- **Google アカウント**Gemini API キー取得用)
- **Jina AI アカウント**(無料登録)
> このツールは GitHub Actions が全自動で実行します。あなたの PC で `npm install` を実行する必要はありません。
### Part A: ツール本体を GitHub に置く
1. https://github.com/new にアクセス
2. **Repository name**`ai-news` と入力
3. **Private** を選択APIキーの設定を含むため、**必ず Private** にしてください)
4. 「Add a README file」のチェックは**外したまま**にする
5. 「Create repository」をクリック
Cursor で AI に以下のように依頼してください:
> 「このコードを GitHub にpushして。リポジトリは `あなたのユーザー名/ai-news` です」
### Part B: Slack App を作成する
1. https://api.slack.com/apps にアクセスし「Create New App」をクリック
2. 「From scratch」を選択し、アプリ名例: AIニュースとワークスペースを設定
3. 左メニュー「OAuth & Permissions」→ Bot Token Scopes に **`chat:write`** を追加
4. 「Install to Workspace」→ 許可する
5. 表示される **Bot User OAuth Token**`xoxb-` で始まる)をコピー
6. レポートを投稿したいチャンネルにボットを追加(チャンネル設定 → インテグレーション → アプリを追加)
7. チャンネルの **チャンネルID** を確認(チャンネル名を右クリック → チャンネル詳細 → 最下部に表示)
GitHub Secrets に登録:
- `SLACK_BOT_TOKEN` — Bot User OAuth Token`xoxb-...`
- `SLACK_CHANNEL` — チャンネルID`C...`
### Part C: Gemini API キーを取得する
1. https://aistudio.google.com/apikey にアクセス
2. 「Create API Key」をクリック
3. 表示されたキーをコピー
GitHub Secrets に登録:
- `GEMINI_API_KEY` — コピーしたキー
> 無料枠で動きます。クレジットカードの登録は不要です。
### Part D: Jina API キーを取得する
1. https://jina.ai にアクセスし、アカウントを作成(またはログイン)
2. ダッシュボードで API キーを確認
GitHub Secrets に登録:
- `JINA_API_KEY` — コピーしたキー
> 無料枠1Mトークンで動きます。個人の学習・ニュース収集用途なら問題ありません。業務利用する場合は Paid プランjina.ai/pricingへの移行が必要です。
### Part E: モックルートで初回実行
X API の設定なしで動作確認できます。
1. リポジトリの「**Actions**」タブを開く
2. **左サイドバー**から「**Daily AI News**」をクリック
3. 「**Run workflow**」ボタンをクリック
4. **use_sample_data** にチェックを入れる
5. 緑の「**Run workflow**」ボタンをクリック
数分後、Slack にサンプルデータによる分析レポートが届きます。
> ここまでで「Gemini による分析 → Slack 投稿」の流れが確認できました。以降は本番の X 連携に進みます。
---
### Part F: X Developer アプリを作成する
**このツールで一番大変なステップです。** ここを越えれば、あとは動かすだけです。
#### 2026年の X API 料金体系
2026年2月以降、X API は **pay-per-use**(従量課金)型に移行しました。以前の月額 $200 の Basic プランは新規受付を停止しています。
- **初期費用なし**。クレジットカードを登録して、使った分だけ請求される仕組みです
- Post Readツイート取得は 1件あたり $0.005
#### 手順
1. [console.x.com](https://console.x.com/) にアクセスし、自分の X アカウントでログイン
2. 開発者規約が表示されたら内容を確認して同意
3. 左メニューの「クレジット」→「クレジットを購入」で最低 $5 をチャージ
4. プロジェクト名を入力(例: `ai-news`)し、ユースケースを選択
5. アプリケーション名を入力(例: `ai-news-2026`。世界中で一意の名前が必要)
アプリ情報の入力例Use case の Description:
> Personal project to collect AI-related tweets from my timeline and summarize them using Gemini API. The summaries are posted to my private Slack workspace for personal news curation. Non-commercial, personal use only.
6. 左メニュー「アプリ」→ 作成したアプリを開き、**User authentication settingsユーザー認証設定** を入力:
- **アプリの権限**: 「読む」を選択(タイムライン取得だけなので読み取り専用で十分)
- **アプリの種類**: 「ウェブアプリ、自動化アプリまたはボット」を選択
- **コールバックURI / リダイレクトURL必須**: `https://example.com`(このツールでは使わないが空にできない)
- **ウェブサイトURL必須**: `https://example.com`
- 「変更を保存する」をクリック
7. 「**キーとトークン**」タブを開く。このタブには4つのセクションが並んでいるが、**このツールで使うのは2つだけ**。
```
┌─ 使う2つ─────────────────────────────────────┐
│ コンシューマーキー │
│ → API Key / API Key Secret │
│ OAuth 1.0 キー │
│ → アクセストークン / アクセストークンシークレット │
└──────────────────────────────────────────────────┘
┌─ 使わない(触らなくてよい)─────────────────────────┐
│ ベアラートークン │
│ OAuth 2.0 クライアントID・シークレット │
└──────────────────────────────────────────────────┘
```
- **「コンシューマーキー」** セクションの「再生成」をクリック → **API Key****API Key Secret** を即メモ
- **「OAuth 1.0 キー」** セクションの「アクセストークン」の「生成する」をクリック → **Access Token****Access Token Secret** を即メモ
> どちらも画面を閉じるとキーは二度と表示されません。必ず全4つをコピーしてから次に進んでください。
GitHub Secrets に登録:
- `X_CONSUMER_KEY` — 「コンシューマーキー」の API Key
- `X_CONSUMER_SECRET` — 「コンシューマーキー」の API Key Secret
- `X_ACCESS_TOKEN` — 「OAuth 1.0 キー」のアクセストークン
- `X_ACCESS_TOKEN_SECRET` — 「OAuth 1.0 キー」のアクセストークンシークレット
> 申請が通らない場合や時間がかかる場合は、Part E のモックルートで開発・カスタマイズを進めてください。X 連携は後からいつでも有効にできます。
### Part G: 本番ルートで実行
1. リポジトリの「**Actions**」タブを開く
2. 「**Daily AI News**」→「**Run workflow**」
3. **use_sample_data のチェックは外したまま**実行
4. Slack に自分のタイムラインからの本物のニュース分析が届く
### チェックポイント
- [ ] Slack に AI トレンド分析レポートが届いた
- [ ] レポートに自分がフォローしているアカウントの情報が含まれている
---
## 定期実行を有効にする
`.github/workflows/daily-news.yml``schedule` ブロック2行のコメントを外す:
変更前:
```yaml
# schedule:
# - cron: '30 22 * * *' # 毎日 07:30 JST
```
変更後:
```yaml
schedule:
- cron: '30 22 * * *' # 毎日 07:30 JST
```
先頭のスペース(インデント)はそのまま残してください。時刻は UTC で指定します。
---
## テストで動作確認
ツールの核心ロジックが正しく動いているか、テストで確認できます。
```bash
npm test
```
全てのテストが通れば、ツールのロジックは正常です。
設定を変更した後(`src/settings.ts` や `src/analysis/prompts.ts` を編集した後)は、`npm test` を走らせて変更が壊れていないことを確認してください。
---
## カスタマイズ
設定は `src/settings.ts` に集約されています。
### レシピ 1: 分析カテゴリを変える
`src/analysis/schema.ts``tech_trends``design_trends` にリネームし、`src/analysis/prompts.ts` のプロンプトも合わせて変更すると、デザイン系トレンドの分析ツールになります。
### レシピ 2: 特定キーワードだけ分析する
`src/sources/x-timeline.ts` の取得後に以下のフィルタを挿入:
```ts
const filtered = tweets.filter(t => /GPT|Claude|Gemini/i.test(t.text));
```
### レシピ 3: 複数チャンネルに分けて投稿
`src/delivery/slack.ts``main_news``#ai-news`、`tech_trends` は `#ai-tech` のように section ごとにチャンネルを分けられます。
---
## 応用例
4パーツの一部を差し替えると、まったく別のツールになります。
- **ソース元を RSS に差し替える** → 毎朝のニュース要約ツール
- **処理を感情分析に差し替える** → 競合の口コミ見張り番
- **届ける先を Notion に差し替える** → 毎朝の社内ニュースDB
---
## セキュリティ
- **リポジトリは Private に**: API キーを GitHub Secrets に保存しているため、公開リポジトリにしないでください
- **Slack Bot Token**: Bot Token Scopes は `chat:write` のみに制限してください
- **トークンが漏洩した場合**:
- X: console.x.com → アプリ → キーとトークン → 該当セクションの「再生成」で即時失効
- Gemini: aistudio.google.com でキーを削除して再発行
- Slack: api.slack.com でトークンをローテーション
---
## 困ったとき
1. **まず AI に聞く**: Cursor で「セットアップで〇〇のエラーが出ました」と伝えてください
2. **GitHub Actions のログを確認**: Actions タブ → 失敗したジョブ → `[USER-FACING]` の行を読む
3. **エラー別の対処法**: [docs/troubleshooting.md](docs/troubleshooting.md) に主要なエラーと対処法をまとめています
4. **Slack で相談**: ADS Slack の質問チャンネルに投稿してください
---
## 技術スタック
| 項目 | 選定 | 理由 |
|---|---|---|
| 実行基盤 | GitHub Actions | 無料枠で十分、セットアップが簡単 |
| 言語 | TypeScriptNode.js 22 | X API公式SDKがTS対応、型安全 |
| X API | twitter-api-v2 | OAuth 1.0a対応のデファクト |
| URL本文取得 | Jina Reader | LLM最適化されたMarkdown化 |
| AIURL要約 | Gemini 2.5 Flash | 大量処理に適した軽量モデル |
| AI最終分析 | Gemini 2.5 Pro | 高品質な構造化出力 |
| 通知 | Slack APIBlock Kit | 構造化された見やすいレポート |

50
docs/troubleshooting.md Normal file
View File

@ -0,0 +1,50 @@
# 困ったとき
## GitHub Actions のログの読み方
1. リポジトリの **Actions** タブを開く
2. 実行したワークフローをクリック
3. **run** ジョブをクリックして各ステップを展開
4. `[USER-FACING]` で始まる行を最優先で読む — 次に何をすべきかが書いてある
5. `[INTERNAL]` で始まる行は開発者向けの詳細情報
---
## エラー別の対処法
### X API
| 症状 | 原因 | 対処 |
|---|---|---|
| `401 Unauthorized` | 4つのキーのいずれかが間違っているか期限切れ | console.x.com → アプリ → キーとトークン → 「OAuth 1.0 キー」のアクセストークンを再生成し、GitHub Secrets を更新 |
| `429 Too Many Requests` | レート制限に到達 | 15分待機してから再実行。`src/settings.ts` の `maxTweets` を下げる |
| X API が使えない / 申請が通らない | pay-per-use の Billing 未設定、または申請待ち | X Developer Portal → Billing → Add payment method。申請中は `USE_SAMPLE_DATA=true` のモックルートで動作確認 |
### Jina Reader
| 症状 | 原因 | 対処 |
|---|---|---|
| `402 Payment Required` | 1Mトークンの無料枠が枯渇 | **自動フォールバック済み**のため配布物は壊れない。長期利用なら Paid プラン (jina.ai/pricing) へ、または `src/settings.ts``urlContent.enabled``false` に |
### Gemini
| 症状 | 原因 | 対処 |
|---|---|---|
| `400 API key not valid` | APIキーの誤り | aistudio.google.com で再発行し、GitHub Secrets の `GEMINI_API_KEY` を更新 |
| `429 Resource exhausted` | Free枠の1日上限超過Flash 250/day, Pro 100/day | 明日まで待つ。手動実行は1日1〜2回に抑える |
| `SAFETY filter` | ツイート内容がセーフティフィルタに抵触 | `maxTweets` を減らす。タイムラインのフォロー先を見直す |
### Slack
| 症状 | 原因 | 対処 |
|---|---|---|
| `channel_not_found` | `SLACK_CHANNEL` のIDが間違っている | Slackでチャンネルを右クリック → チャンネル詳細 → 最下部のID`C`で始まる文字列)をコピーし直す |
| `not_in_channel` | BotアプリがチャンネルにいないBot | チャンネルで `/invite @あなたのBot名` を実行 |
| `not_authed` / `invalid_auth` | `SLACK_BOT_TOKEN` が間違っているか期限切れ | api.slack.com → アプリ → OAuth & Permissions → Bot User OAuth Token を再コピー |
### その他
| 症状 | 原因 | 対処 |
|---|---|---|
| 成功扱い(緑)だが Slack に投稿がない | `maxTweets: 0``lookbackHours: 0` の誤設定 | `src/settings.ts` の値を確認 |
| Actions がずっと黄色(実行中) | Jina Reader の大量タイムアウト | `src/settings.ts``urlContent.parallelism``5` に下げる、または `urlContent.enabled``false` にして再実行 |

182
fixtures/sample-tweets.json Normal file
View File

@ -0,0 +1,182 @@
[
{
"authorId": "OpenAI",
"text": "Introducing GPT-5.5 — our most capable model yet. Improved reasoning, faster response times, and native tool use. Available today in the API.",
"createdAt": "2026-04-16T22:00:00.000Z",
"url": "https://x.com/OpenAI/status/100000000000000001"
},
{
"authorId": "AnthropicAI",
"text": "Claude Opus 4.7 is here. Extended thinking with thought signatures enables multi-step reasoning chains that persist across conversations.",
"createdAt": "2026-04-16T21:30:00.000Z",
"url": "https://x.com/AnthropicAI/status/100000000000000002"
},
{
"authorId": "GoogleDeepMind",
"text": "Gemini 3.1 Pro achieves state-of-the-art on mathematical reasoning benchmarks with our new thinking_level parameter.",
"createdAt": "2026-04-16T20:15:00.000Z",
"url": "https://x.com/GoogleDeepMind/status/100000000000000003"
},
{
"authorId": "kaborosu",
"text": "GPT-5.5のtool useがやばい。MCPサーバー3つ同時に繋いで、指示一発で調査→コード生成→テスト実行まで全自動で回った。エージェント時代が本当に来た。",
"createdAt": "2026-04-16T23:00:00.000Z",
"url": "https://x.com/kaborosu/status/100000000000000004"
},
{
"authorId": "ai_database",
"text": "速報: MicrosoftがGitHub Copilot Workspaceを全ユーザーに無料開放。リポジトリ単位でエージェントが動き、Issue→PR→マージまで自動化。",
"createdAt": "2026-04-16T19:00:00.000Z",
"url": "https://x.com/ai_database/status/100000000000000005"
},
{
"authorId": "ylecun",
"text": "New paper from FAIR: 'World Models for Robotic Manipulation' — our approach achieves 94% success rate on unseen objects using learned physics priors.",
"createdAt": "2026-04-16T18:30:00.000Z",
"url": "https://x.com/ylecun/status/100000000000000006"
},
{
"authorId": "sama",
"text": "We're releasing the OpenAI Agents SDK v2 today. Build production-grade agents with built-in memory, tool orchestration, and safety guardrails.",
"createdAt": "2026-04-16T17:00:00.000Z",
"url": "https://x.com/sama/status/100000000000000007"
},
{
"authorId": "emaborai",
"text": "Cursorの新機能Background Agentを使ってみた。寝てる間にPR作ってレビューコメントまで付けてくれた。朝起きたらマージするだけ。生産性が次元変わる。",
"createdAt": "2026-04-16T22:30:00.000Z",
"url": "https://x.com/emaborai/status/100000000000000008"
},
{
"authorId": "MetaAI",
"text": "Llama 4 Behemoth is now available. 2T parameter MoE with 256 experts. Open weights for research and commercial use.",
"createdAt": "2026-04-16T16:00:00.000Z",
"url": "https://x.com/MetaAI/status/100000000000000009"
},
{
"authorId": "hardmaru",
"text": "The gap between open and closed models continues to shrink. Llama 4 Behemoth matches GPT-5 on most benchmarks. Exciting times for the open source community.",
"createdAt": "2026-04-16T16:30:00.000Z",
"url": "https://x.com/hardmaru/status/100000000000000010"
},
{
"authorId": "ai_and_design",
"text": "Figma AIの新機能、コンポーネントの意味を理解してデザインシステムに沿った提案をしてくれる。デザイナーの仕事が設計判断に集中できるようになる。",
"createdAt": "2026-04-16T15:00:00.000Z",
"url": "https://x.com/ai_and_design/status/100000000000000011"
},
{
"authorId": "StabilityAI",
"text": "Stable Diffusion 4 is here. Real-time generation at 60fps, native video support, and unprecedented prompt adherence. Try it now on stability.ai.",
"createdAt": "2026-04-16T14:00:00.000Z",
"url": "https://x.com/StabilityAI/status/100000000000000012"
},
{
"authorId": "tech_and_future",
"text": "AI Agent同士が自律的に交渉してタスクを分担する研究が出た。マルチエージェント協調がいよいよ実用段階に入ってきている。",
"createdAt": "2026-04-16T13:00:00.000Z",
"url": "https://x.com/tech_and_future/status/100000000000000013"
},
{
"authorId": "GoogleAI",
"text": "Announcing Gemini Nano 2 — on-device AI that runs entirely on your phone. No internet needed. Privacy-first design with 3B parameters.",
"createdAt": "2026-04-16T12:00:00.000Z",
"url": "https://x.com/GoogleAI/status/100000000000000014"
},
{
"authorId": "aisafety_news",
"text": "EU AI Act enforcement begins today. Companies deploying high-risk AI systems must now demonstrate compliance with transparency and safety requirements.",
"createdAt": "2026-04-16T11:00:00.000Z",
"url": "https://x.com/aisafety_news/status/100000000000000015"
},
{
"authorId": "kaggle",
"text": "New competition: $1M prize for building an AI system that can reliably detect AI-generated scientific papers. Submissions open until June 2026.",
"createdAt": "2026-04-16T10:00:00.000Z",
"url": "https://x.com/kaggle/status/100000000000000016"
},
{
"authorId": "ai_business_jp",
"text": "日本のAIスタートアップの資金調達額が2025年比で3倍に。特にエンタープライズ向けRAGとエージェント基盤に投資が集中。",
"createdAt": "2026-04-16T09:00:00.000Z",
"url": "https://x.com/ai_business_jp/status/100000000000000017"
},
{
"authorId": "nvidia",
"text": "Blackwell Ultra GPUs now shipping. 2x the inference performance of H200 at the same power. Built for the age of AI agents.",
"createdAt": "2026-04-16T08:00:00.000Z",
"url": "https://x.com/nvidia/status/100000000000000018"
},
{
"authorId": "dev_tools_ai",
"text": "GitHub Actions now supports native AI agent steps. Define an agent in YAML, give it tools, and let it solve issues autonomously in your CI pipeline.",
"createdAt": "2026-04-16T07:00:00.000Z",
"url": "https://x.com/dev_tools_ai/status/100000000000000019"
},
{
"authorId": "amasaki",
"text": "Claude Codeのスキル機能を使い始めて2週間。同じ作業を3回やったらスキルにする、というルールを徹底したら作業速度が5倍になった。",
"createdAt": "2026-04-16T22:45:00.000Z",
"url": "https://x.com/amasaki/status/100000000000000020"
},
{
"authorId": "veraborai",
"text": "Vercel v0がついにフルスタック対応。フロントだけじゃなくAPIルートもDBスキーマも一発で生成してくれる。プロトタイピングの速度が異次元。",
"createdAt": "2026-04-16T21:00:00.000Z",
"url": "https://x.com/veraborai/status/100000000000000021"
},
{
"authorId": "research_ml",
"text": "Interesting trend: more papers using 'test-time compute' scaling instead of pre-training scaling. The paradigm shift from bigger models to smarter inference is real.",
"createdAt": "2026-04-16T20:00:00.000Z",
"url": "https://x.com/research_ml/status/100000000000000022"
},
{
"authorId": "aicoding_daily",
"text": "Cursor、Windsurf、Claude Code、GitHub Copilot Workspace。AIコーディングツールの選択肢が増えすぎて逆に迷う時代。結局はコンテキスト管理が上手いツールが勝つ。",
"createdAt": "2026-04-16T19:30:00.000Z",
"url": "https://x.com/aicoding_daily/status/100000000000000023"
},
{
"authorId": "huggingface",
"text": "Introducing SmolAgent 2.0 — build agents in 10 lines of Python. Now with native MCP support and automatic tool discovery.",
"createdAt": "2026-04-16T18:00:00.000Z",
"url": "https://x.com/huggingface/status/100000000000000024"
},
{
"authorId": "ai_regulation",
"text": "米国AIガバナンス法案が上院を通過。企業はAI利用の透明性レポートを四半期ごとに公開することが義務化される見込み。",
"createdAt": "2026-04-16T17:30:00.000Z",
"url": "https://x.com/ai_regulation/status/100000000000000025"
},
{
"authorId": "robotics_ai",
"text": "Boston Dynamics Atlas now powered by Gemini. The robot can understand natural language instructions and plan multi-step physical tasks autonomously.",
"createdAt": "2026-04-16T15:30:00.000Z",
"url": "https://x.com/robotics_ai/status/100000000000000026"
},
{
"authorId": "indie_hacker_jp",
"text": "AIツールだけで月収100万円のSaaSを一人で運営する人が増えてきた。技術力じゃなくて課題発見力が差になる時代。まさにAI-Drivenの世界。",
"createdAt": "2026-04-16T14:30:00.000Z",
"url": "https://x.com/indie_hacker_jp/status/100000000000000027"
},
{
"authorId": "perplexity_ai",
"text": "Perplexity Pro now includes real-time web search with citations, code execution, and file analysis. All in one conversational interface.",
"createdAt": "2026-04-16T13:30:00.000Z",
"url": "https://x.com/perplexity_ai/status/100000000000000028"
},
{
"authorId": "edge_ai_news",
"text": "Apple Intelligence 2.0 launches with on-device reasoning capabilities. Siri can now complete multi-step tasks without sending data to the cloud.",
"createdAt": "2026-04-16T12:30:00.000Z",
"url": "https://x.com/edge_ai_news/status/100000000000000029"
},
{
"authorId": "ml_ops_daily",
"text": "The real bottleneck in AI deployment isn't the model — it's evaluation. Teams spending 60% of their time building eval frameworks instead of shipping features.",
"createdAt": "2026-04-16T11:30:00.000Z",
"url": "https://x.com/ml_ops_daily/status/100000000000000030"
}
]

2520
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

27
package.json Normal file
View File

@ -0,0 +1,27 @@
{
"name": "ai-news",
"version": "1.0.0",
"private": true,
"type": "module",
"engines": {
"node": ">=22"
},
"scripts": {
"start": "tsx src/main.ts",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@google/genai": "^1.1.0",
"@slack/web-api": "^7.8.0",
"twitter-api-v2": "^1.18.2",
"zod": "^3.24.2",
"zod-to-json-schema": "^3.24.1"
},
"devDependencies": {
"tsx": "^4.19.0",
"typescript": "^5.7.0",
"vitest": "^3.2.4"
}
}

View File

@ -0,0 +1,87 @@
import { describe, it, expect, vi } from "vitest";
import { UserFacingError } from "../utils/errors.js";
import type { Settings } from "../settings.js";
import type { Config } from "../config.js";
import type { EnrichedTweet } from "../types.js";
const { mockGenerateContent } = vi.hoisted(() => ({
mockGenerateContent: vi.fn(),
}));
vi.mock("@google/genai", () => ({
GoogleGenAI: class {
models = { generateContent: mockGenerateContent };
},
}));
const { analyzeTrends } = await import("./analyze.js");
const mockConfig: Config = {
JINA_API_KEY: "test",
GEMINI_API_KEY: "test",
SLACK_BOT_TOKEN: "xoxb-test",
SLACK_CHANNEL: "C123",
USE_SAMPLE_DATA: true,
};
const mockSettings: Settings = {
schedule: { lookbackHours: 24, maxTweets: 100 },
urlContent: {
enabled: false,
timeoutMs: 5000,
parallelism: 5,
maxSummaryChars: 200,
inputCharsMultiplier: 20,
},
analysis: {
urlSummaryModel: "gemini-2.5-flash",
trendAnalysisModel: "gemini-2.5-pro",
temperature: 0,
},
};
const sampleTweets: EnrichedTweet[] = [
{
authorId: "test",
text: "AI news",
createdAt: "2026-04-16T10:00:00.000Z",
url: "https://x.com/test/status/1",
enrichedText: "AI news",
},
];
const validResponse = {
main_news: [
{
title: "Test News",
details: ["Detail"],
sources: ["https://x.com/test/status/1"],
},
],
updates: [],
tech_trends: [],
};
describe("analyzeTrends", () => {
it("正常な JSON を返すと Analysis を返す", async () => {
mockGenerateContent.mockResolvedValue({
text: JSON.stringify(validResponse),
candidates: [{ finishReason: "STOP" }],
});
const result = await analyzeTrends(sampleTweets, mockConfig, mockSettings);
expect(result.main_news).toHaveLength(1);
expect(result.main_news[0]!.title).toBe("Test News");
});
it("SAFETY finishReason で UserFacingError をスローする", async () => {
mockGenerateContent.mockResolvedValue({
text: undefined,
candidates: [{ finishReason: "SAFETY" }],
});
await expect(
analyzeTrends(sampleTweets, mockConfig, mockSettings),
).rejects.toThrow(UserFacingError);
});
});

80
src/analysis/analyze.ts Normal file
View File

@ -0,0 +1,80 @@
import { GoogleGenAI } from "@google/genai";
import {
AnalysisSchema,
analysisResponseSchema,
type Analysis,
} from "./schema.js";
import { TREND_ANALYSIS_PROMPT } from "./prompts.js";
import type { Config } from "../config.js";
import type { Settings } from "../settings.js";
import type { EnrichedTweet } from "../types.js";
import { UserFacingError } from "../utils/errors.js";
import { cleanText } from "../utils/post-optimizer.js";
export async function analyzeTrends(
tweets: EnrichedTweet[],
config: Config,
settings: Settings,
): Promise<Analysis> {
console.info("[3b/4] タイムライン全体を Gemini Pro で分析中...");
const ai = new GoogleGenAI({ apiKey: config.GEMINI_API_KEY });
const tweetsForPrompt = Object.entries(groupByAuthor(tweets)).map(
([author, items]) => ({
author,
posts: items.map((t) => ({
text: cleanText(t.enrichedText),
url: t.url,
})),
}),
);
const prompt = TREND_ANALYSIS_PROMPT.replace(
"{json_data}",
JSON.stringify(tweetsForPrompt, null, 2),
);
const res = await ai.models.generateContent({
model: settings.analysis.trendAnalysisModel,
contents: prompt,
config: {
temperature: settings.analysis.temperature,
responseMimeType: "application/json",
responseSchema: analysisResponseSchema as Record<string, unknown>,
},
});
const candidate = (res as { candidates?: Array<{ finishReason?: string }> })
.candidates?.[0];
if (candidate?.finishReason === "SAFETY") {
throw new UserFacingError(
"Geminiのセーフティフィルタで分析結果がブロックされました。ツイート内容を減らして再実行してください。",
);
}
const text = res.text;
if (!text) {
throw new UserFacingError("Geminiから空の応答が返りました。");
}
try {
return AnalysisSchema.parse(JSON.parse(text));
} catch (cause) {
throw new UserFacingError(
"Geminiの応答をパースできませんでした。再実行してみてください。",
{ cause },
);
}
}
function groupByAuthor(
tweets: EnrichedTweet[],
): Record<string, EnrichedTweet[]> {
const groups: Record<string, EnrichedTweet[]> = {};
for (const tweet of tweets) {
const key = tweet.authorId || "unknown";
(groups[key] ??= []).push(tweet);
}
return groups;
}

33
src/analysis/prompts.ts Normal file
View File

@ -0,0 +1,33 @@
export const URL_SUMMARY_PROMPT = `以下の記事本文を200文字以内の日本語で要約してください。要点だけを簡潔にまとめてください。
---
{article_text}
---
:`;
export const TREND_ANALYSIS_PROMPT = `あなたはAI技術に精通したアナリストです。以下のJSONデータは、X(旧Twitter)から収集した最新24時間以内のAI関連のツイートです。
:
AIトレンドをまとめてください:
-
- AIツールやサービスのアップデート
-
JSON構造に従ってください:
{
"main_news": [{"title": "トピック名", "details": ["詳細1", "詳細2"], "sources": ["https://x.com/..."]}],
"updates": [{"title": "製品名と内容", "details": ["更新内容", "特徴"], "sources": ["https://x.com/..."]}],
"tech_trends": [{"title": "トレンド名", "details": ["概要", "影響"], "sources": ["https://x.com/..."]}]
}
:
- (title)
-
-
-
- detailsの各項目は200文字程度に収めてください
- sourcesにはツイートのURLをそのまま入れてください
JSONデータを分析してください:
{json_data}`;

View File

@ -0,0 +1,98 @@
import { describe, it, expect } from "vitest";
import { AnalysisSchema, analysisResponseSchema } from "./schema.js";
const validAnalysis = {
main_news: [
{
title: "GPT-5.5が発表",
details: ["ネイティブtool use対応", "推論速度が大幅向上"],
sources: ["https://x.com/OpenAI/status/123"],
},
],
updates: [
{
title: "Cursor Background Agent",
details: ["寝ている間にPRを作成"],
sources: ["https://x.com/cursor/status/456"],
},
],
tech_trends: [
{
title: "テスト時計算量スケーリング",
details: ["より大きなモデルからスマートな推論へ", "推論コスト削減"],
sources: ["https://x.com/research/status/789"],
},
],
};
describe("AnalysisSchema", () => {
it("正常なデータをパースできる", () => {
const result = AnalysisSchema.parse(validAnalysis);
expect(result.main_news).toHaveLength(1);
expect(result.updates).toHaveLength(1);
expect(result.tech_trends).toHaveLength(1);
});
it("空セクションを許容する", () => {
const result = AnalysisSchema.parse({
main_news: [],
updates: [],
tech_trends: [],
});
expect(result.main_news).toHaveLength(0);
});
it("必須フィールドが欠けるとエラー", () => {
expect(() =>
AnalysisSchema.parse({ main_news: [], updates: [] }),
).toThrow();
});
it("details が3つを超えるとエラー", () => {
expect(() =>
AnalysisSchema.parse({
main_news: [
{
title: "test",
details: ["1", "2", "3", "4"],
sources: [],
},
],
updates: [],
tech_trends: [],
}),
).toThrow();
});
it("details が3つちょうどは許容する", () => {
const result = AnalysisSchema.parse({
main_news: [
{
title: "test",
details: ["1", "2", "3"],
sources: [],
},
],
updates: [],
tech_trends: [],
});
expect(result.main_news[0]!.details).toHaveLength(3);
});
});
describe("analysisResponseSchema", () => {
it("JSON Schema オブジェクトが生成される", () => {
expect(analysisResponseSchema).toBeDefined();
expect(typeof analysisResponseSchema).toBe("object");
});
it("3つのセクションプロパティを含む", () => {
const schema = analysisResponseSchema as Record<string, unknown>;
const props = (schema as { properties?: Record<string, unknown> })
.properties;
expect(props).toBeDefined();
expect(props).toHaveProperty("main_news");
expect(props).toHaveProperty("updates");
expect(props).toHaveProperty("tech_trends");
});
});

21
src/analysis/schema.ts Normal file
View File

@ -0,0 +1,21 @@
import { z } from "zod";
import { zodToJsonSchema } from "zod-to-json-schema";
const TopicSchema = z.object({
title: z.string(),
details: z.array(z.string()).max(3),
sources: z.array(z.string()),
});
export const AnalysisSchema = z.object({
main_news: z.array(TopicSchema),
updates: z.array(TopicSchema),
tech_trends: z.array(TopicSchema),
});
export type Analysis = z.infer<typeof AnalysisSchema>;
export const analysisResponseSchema = zodToJsonSchema(AnalysisSchema, {
target: "openApi3",
$refStrategy: "none",
});

View File

@ -0,0 +1,84 @@
import { describe, it, expect, vi } from "vitest";
import type { Settings } from "../settings.js";
import type { Config } from "../config.js";
import type { RawTweet } from "../types.js";
const { mockGenerateContent } = vi.hoisted(() => ({
mockGenerateContent: vi.fn(),
}));
vi.mock("@google/genai", () => ({
GoogleGenAI: class {
models = { generateContent: mockGenerateContent };
},
}));
const { summarizeUrls } = await import("./url-summarizer.js");
const mockConfig: Config = {
JINA_API_KEY: "test",
GEMINI_API_KEY: "test",
SLACK_BOT_TOKEN: "xoxb-test",
SLACK_CHANNEL: "C123",
USE_SAMPLE_DATA: true,
};
const mockSettings: Settings = {
schedule: { lookbackHours: 24, maxTweets: 100 },
urlContent: {
enabled: true,
timeoutMs: 5000,
parallelism: 5,
maxSummaryChars: 200,
inputCharsMultiplier: 20,
},
analysis: {
urlSummaryModel: "gemini-2.5-flash",
trendAnalysisModel: "gemini-2.5-pro",
temperature: 0,
},
};
const sampleTweets: RawTweet[] = [
{
authorId: "user1",
text: "Check out https://example.com/article",
createdAt: "2026-04-16T10:00:00.000Z",
url: "https://x.com/user1/status/1",
},
];
describe("summarizeUrls", () => {
it("urlContents が空のとき元テキストをそのまま返す", async () => {
const result = await summarizeUrls(
sampleTweets,
new Map(),
mockConfig,
mockSettings,
);
expect(result).toHaveLength(1);
expect(result[0]!.enrichedText).toBe(
"Check out https://example.com/article",
);
});
it("urlContents があるとき [補足情報] を挿入する", async () => {
mockGenerateContent.mockResolvedValue({
text: "記事の要約テキスト",
});
const urlContents = new Map([
["https://example.com/article", "Full article body text here..."],
]);
const result = await summarizeUrls(
sampleTweets,
urlContents,
mockConfig,
mockSettings,
);
expect(result).toHaveLength(1);
expect(result[0]!.enrichedText).toContain("[補足情報]");
expect(result[0]!.enrichedText).toContain("記事の要約テキスト");
});
});

View File

@ -0,0 +1,77 @@
import { GoogleGenAI } from "@google/genai";
import type { Config } from "../config.js";
import type { Settings } from "../settings.js";
import type { RawTweet, EnrichedTweet } from "../types.js";
import { extractUrls } from "../utils/post-optimizer.js";
import { chunkArray } from "../utils/chunk.js";
import { URL_SUMMARY_PROMPT } from "./prompts.js";
export async function summarizeUrls(
tweets: RawTweet[],
urlContents: Map<string, string>,
config: Config,
settings: Settings,
): Promise<EnrichedTweet[]> {
if (urlContents.size === 0) {
return tweets.map((t) => ({ ...t, enrichedText: buildFullText(t) }));
}
console.info("[3a/4] 各URLを Gemini Flash で要約中...");
const ai = new GoogleGenAI({ apiKey: config.GEMINI_API_KEY });
const summaryCache = new Map<string, string>();
const entries = [...urlContents.entries()];
const chunks = chunkArray(entries, settings.urlContent.parallelism);
for (const chunk of chunks) {
const results = await Promise.allSettled(
chunk.map(async ([url, content]) => {
const truncated = content.slice(
0,
settings.urlContent.maxSummaryChars *
settings.urlContent.inputCharsMultiplier,
);
const prompt = URL_SUMMARY_PROMPT.replace(
"{article_text}",
truncated,
);
const res = await ai.models.generateContent({
model: settings.analysis.urlSummaryModel,
contents: prompt,
config: { temperature: 0 },
});
const text = res.text?.slice(0, settings.urlContent.maxSummaryChars);
return { url, summary: text ?? "" };
}),
);
for (const r of results) {
if (r.status === "fulfilled" && r.value.summary) {
summaryCache.set(r.value.url, r.value.summary);
}
}
}
console.info(`→ URL要約完了: ${summaryCache.size}`);
return tweets.map((tweet) => {
let enrichedText = buildFullText(tweet);
for (const url of extractUrls(tweet.text)) {
const summary = summaryCache.get(url);
if (summary) {
enrichedText += `\n[補足情報]: ${summary}`;
}
}
return { ...tweet, enrichedText };
});
}
function buildFullText(tweet: RawTweet): string {
let text = tweet.text;
if (tweet.quotedText) {
text += `\n${tweet.quotedText}`;
}
return text;
}

109
src/config.test.ts Normal file
View File

@ -0,0 +1,109 @@
import { describe, it, expect, vi, beforeEach, afterAll } from "vitest";
import { loadConfig } from "./config.js";
describe("loadConfig", () => {
const original = process.env;
beforeEach(() => {
process.env = { ...original };
});
afterAll(() => {
process.env = original;
});
const commonEnv = {
JINA_API_KEY: "test",
GEMINI_API_KEY: "test",
SLACK_BOT_TOKEN: "xoxb-test",
SLACK_CHANNEL: "C123",
};
it("正常な環境変数をパースできる", () => {
process.env = {
...original,
...commonEnv,
X_CONSUMER_KEY: "ck",
X_CONSUMER_SECRET: "cs",
X_ACCESS_TOKEN: "at",
X_ACCESS_TOKEN_SECRET: "ats",
};
const config = loadConfig();
expect(config.USE_SAMPLE_DATA).toBe(false);
expect(config.GEMINI_API_KEY).toBe("test");
});
it("USE_SAMPLE_DATA=true を boolean に変換する", () => {
process.env = { ...original, ...commonEnv, USE_SAMPLE_DATA: "true" };
const config = loadConfig();
expect(config.USE_SAMPLE_DATA).toBe(true);
});
it("必須キーが欠落すると process.exit(1) を呼ぶ", () => {
process.env = {};
const exitSpy = vi
.spyOn(process, "exit")
.mockImplementation((() => {
throw new Error("process.exit called");
}) as never);
vi.spyOn(console, "error").mockImplementation(() => {});
expect(() => loadConfig()).toThrow("process.exit called");
expect(exitSpy).toHaveBeenCalledWith(1);
});
it("USE_SAMPLE_DATA=true のとき X_* なしで成功する", () => {
process.env = { ...original, ...commonEnv, USE_SAMPLE_DATA: "true" };
const config = loadConfig();
expect(config.USE_SAMPLE_DATA).toBe(true);
});
it("USE_SAMPLE_DATA=false かつ X_* なしで process.exit(1)", () => {
process.env = { ...original, ...commonEnv, USE_SAMPLE_DATA: "false" };
const exitSpy = vi
.spyOn(process, "exit")
.mockImplementation((() => {
throw new Error("process.exit called");
}) as never);
vi.spyOn(console, "error").mockImplementation(() => {});
expect(() => loadConfig()).toThrow("process.exit called");
expect(exitSpy).toHaveBeenCalledWith(1);
});
it("USE_SAMPLE_DATA=false かつ X_* 全てありで成功する", () => {
process.env = {
...original,
...commonEnv,
USE_SAMPLE_DATA: "false",
X_CONSUMER_KEY: "ck",
X_CONSUMER_SECRET: "cs",
X_ACCESS_TOKEN: "at",
X_ACCESS_TOKEN_SECRET: "ats",
};
const config = loadConfig();
expect(config.USE_SAMPLE_DATA).toBe(false);
if (!config.USE_SAMPLE_DATA) {
expect(config.X_CONSUMER_KEY).toBe("ck");
}
});
it("USE_SAMPLE_DATA=false かつ X_* が一部だけだと process.exit(1)", () => {
process.env = {
...original,
...commonEnv,
USE_SAMPLE_DATA: "false",
X_CONSUMER_KEY: "ck",
X_CONSUMER_SECRET: "cs",
};
const exitSpy = vi
.spyOn(process, "exit")
.mockImplementation((() => {
throw new Error("process.exit called");
}) as never);
vi.spyOn(console, "error").mockImplementation(() => {});
expect(() => loadConfig()).toThrow("process.exit called");
expect(exitSpy).toHaveBeenCalledWith(1);
});
});

58
src/config.ts Normal file
View File

@ -0,0 +1,58 @@
import { z } from "zod";
const baseSchema = z.object({
JINA_API_KEY: z.string().min(1),
GEMINI_API_KEY: z.string().min(1),
SLACK_BOT_TOKEN: z.string().startsWith("xoxb-"),
SLACK_CHANNEL: z.string().startsWith("C"),
USE_SAMPLE_DATA: z
.enum(["true", "false", ""])
.default("false")
.transform((v) => v === "true"),
});
const xSchema = z.object({
X_CONSUMER_KEY: z.string().min(1),
X_CONSUMER_SECRET: z.string().min(1),
X_ACCESS_TOKEN: z.string().min(1),
X_ACCESS_TOKEN_SECRET: z.string().min(1),
});
type BaseParsed = z.infer<typeof baseSchema>;
type XParsed = z.infer<typeof xSchema>;
type Common = Omit<BaseParsed, "USE_SAMPLE_DATA">;
export type Config =
| (Common & { USE_SAMPLE_DATA: true })
| (Common & { USE_SAMPLE_DATA: false } & XParsed);
export function loadConfig(): Config {
const baseResult = baseSchema.safeParse(process.env);
if (!baseResult.success) {
reportAndExit("環境変数の検証に失敗しました", baseResult.error);
}
const { USE_SAMPLE_DATA, ...common } = baseResult.data;
if (USE_SAMPLE_DATA) {
return { ...common, USE_SAMPLE_DATA: true };
}
const xResult = xSchema.safeParse(process.env);
if (!xResult.success) {
reportAndExit(
"X API の環境変数が揃っていませんUSE_SAMPLE_DATA=false のとき X_CONSUMER_KEY / X_CONSUMER_SECRET / X_ACCESS_TOKEN / X_ACCESS_TOKEN_SECRET の4つが必須。モックで動作確認したい場合は USE_SAMPLE_DATA=true を指定してください",
xResult.error,
);
}
return { ...common, USE_SAMPLE_DATA: false, ...xResult.data };
}
function reportAndExit(title: string, error: z.ZodError): never {
const missing = error.issues
.map((i) => ` - ${i.path.join(".")}: ${i.message}`)
.join("\n");
console.error(`[CONFIG] ${title}:\n${missing}`);
process.exit(1);
}

View File

@ -0,0 +1,82 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { Config } from "../config.js";
import type { Analysis } from "../analysis/schema.js";
const mockPostMessage = vi.fn();
vi.mock("@slack/web-api", () => ({
WebClient: class {
chat = { postMessage: mockPostMessage };
},
}));
const { postToSlack } = await import("./slack.js");
const mockConfig: Config = {
JINA_API_KEY: "test-jina",
GEMINI_API_KEY: "test-gemini",
SLACK_BOT_TOKEN: "xoxb-test",
SLACK_CHANNEL: "C123",
USE_SAMPLE_DATA: true,
};
const validAnalysis: Analysis = {
main_news: [
{
title: "GPT-5.5が発表",
details: ["ネイティブtool use対応"],
sources: ["https://x.com/OpenAI/status/123"],
},
],
updates: [
{
title: "Cursor Background Agent",
details: ["寝ている間にPR作成"],
sources: ["https://x.com/cursor/status/456"],
},
],
tech_trends: [],
};
beforeEach(() => {
mockPostMessage.mockResolvedValue({ ok: true });
});
describe("postToSlack", () => {
it("正常な Analysis を投稿できる", async () => {
await postToSlack(validAnalysis, mockConfig);
expect(mockPostMessage).toHaveBeenCalledOnce();
const call = mockPostMessage.mock.calls[0]![0] as {
channel: string;
text: string;
blocks: unknown[];
};
expect(call.channel).toBe("C123");
expect(call.text).toBe("24時間以内のAIトレンド");
expect(call.blocks.length).toBeGreaterThan(0);
});
it("空の Analysis でもエラーにならない", async () => {
const emptyAnalysis: Analysis = {
main_news: [],
updates: [],
tech_trends: [],
};
await expect(
postToSlack(emptyAnalysis, mockConfig),
).resolves.not.toThrow();
});
it("Block Kit にヘッダーとセクションが含まれる", async () => {
await postToSlack(validAnalysis, mockConfig);
const call = mockPostMessage.mock.calls[0]![0] as {
blocks: Array<{ type: string }>;
};
const types = call.blocks.map((b) => b.type);
expect(types).toContain("header");
expect(types).toContain("section");
expect(types).toContain("divider");
});
});

84
src/delivery/slack.ts Normal file
View File

@ -0,0 +1,84 @@
import { WebClient, type KnownBlock, type ChatPostMessageResponse } from "@slack/web-api";
import type { Config } from "../config.js";
import type { Analysis } from "../analysis/schema.js";
import { UserFacingError } from "../utils/errors.js";
export async function postToSlack(
analysis: Analysis,
config: Config,
): Promise<void> {
const client = new WebClient(config.SLACK_BOT_TOKEN);
const blocks = buildBlocks(analysis);
const res = await client.chat.postMessage({
channel: config.SLACK_CHANNEL,
text: "24時間以内のAIトレンド",
blocks,
});
assertSlackOk(res);
console.info("Slack 投稿完了");
}
function assertSlackOk(res: ChatPostMessageResponse): void {
if (res.ok) return;
const msg =
res.error === "not_in_channel"
? "SlackチャンネルにBotが招待されていません。チャンネルで `/invite @あなたのBot名` を実行してください。"
: res.error === "channel_not_found"
? "SLACK_CHANNEL のIDが見つかりません。GitHub Secrets を確認してください。"
: `Slack投稿に失敗: ${res.error}`;
throw new UserFacingError(msg);
}
function buildBlocks(analysis: Analysis): KnownBlock[] {
const blocks: KnownBlock[] = [];
blocks.push({
type: "header",
text: { type: "plain_text", text: "🤖 24時間以内のAIトレンド", emoji: true },
});
blocks.push({ type: "divider" });
const sections: Array<{ title: string; items: Analysis["main_news"] }> = [
{ title: "🔥 主要なニュース・話題", items: analysis.main_news },
{ title: "⚡️ 注目のアップデート", items: analysis.updates },
{ title: "💡 技術トレンド", items: analysis.tech_trends },
];
for (const section of sections) {
if (section.items.length === 0) continue;
blocks.push({
type: "header",
text: { type: "plain_text", text: section.title, emoji: true },
});
blocks.push({ type: "divider" });
for (const topic of section.items) {
blocks.push({
type: "section",
text: { type: "mrkdwn", text: `*${topic.title}*` },
});
if (topic.details.length > 0) {
blocks.push({
type: "section",
text: { type: "mrkdwn", text: topic.details.join("\n") },
});
}
if (topic.sources.length > 0) {
const sourceLinks = topic.sources
.map((url) => `<${url}>`)
.join("\n");
blocks.push({
type: "context",
elements: [{ type: "mrkdwn", text: sourceLinks }],
});
}
}
}
return blocks;
}

53
src/main.ts Normal file
View File

@ -0,0 +1,53 @@
// ┌──────────────────────────────────────────────────────┐
// │ 読み順ガイド │
// │ この main() の4ステップを上から読めば全体が分かる。 │
// │ 詳しく見たくなったら各 import 先にジャンプ。 │
// │ 設定を変えたい場合は src/settings.ts を開く。 │
// └──────────────────────────────────────────────────────┘
import { loadConfig } from "./config.js";
import { settings } from "./settings.js";
import { fetchHomeTimeline } from "./sources/x-timeline.js";
import { fetchUrlContents } from "./sources/url-content.js";
import { summarizeUrls } from "./analysis/url-summarizer.js";
import { analyzeTrends } from "./analysis/analyze.js";
import { postToSlack } from "./delivery/slack.js";
import { UserFacingError } from "./utils/errors.js";
async function main() {
const config = loadConfig();
console.info("[1/4] X のホームタイムラインを取得中...");
const tweets = await fetchHomeTimeline(config, settings);
console.info(`→ ツイート ${tweets.length}件 を取得`);
console.info("[2/4] URL本文を Jina Reader で取得中...");
const urlContents = await fetchUrlContents(tweets, config, settings);
console.info(`→ 本文取得済みURL: ${urlContents.size}`);
const enrichedTweets = await summarizeUrls(
tweets,
urlContents,
config,
settings,
);
const analysis = await analyzeTrends(enrichedTweets, config, settings);
console.info("[4/4] Slack へ投稿中...");
await postToSlack(analysis, config);
console.info("すべての処理が完了しました");
}
main().catch((error: unknown) => {
if (error instanceof UserFacingError) {
console.error(`\n[USER-FACING] ${error.message}`);
console.error("対処法の詳細は docs/troubleshooting.md を参照してください。");
if (error.cause) {
console.error("[DETAIL]", error.cause);
}
} else {
console.error("\n[INTERNAL] Unexpected error:", error);
}
process.exit(1);
});

37
src/settings.ts Normal file
View File

@ -0,0 +1,37 @@
export interface Settings {
schedule: {
lookbackHours: number;
maxTweets: number;
};
urlContent: {
enabled: boolean;
timeoutMs: number;
parallelism: number;
maxSummaryChars: number;
inputCharsMultiplier: number;
};
analysis: {
urlSummaryModel: string;
trendAnalysisModel: string;
temperature: number;
};
}
export const settings: Settings = {
schedule: {
lookbackHours: 24,
maxTweets: 500,
},
urlContent: {
enabled: true,
timeoutMs: 10_000,
parallelism: 10,
maxSummaryChars: 200,
inputCharsMultiplier: 20,
},
analysis: {
urlSummaryModel: "gemini-2.5-flash",
trendAnalysisModel: "gemini-2.5-pro",
temperature: 0,
},
};

View File

@ -0,0 +1,129 @@
import { describe, it, expect, vi } from "vitest";
import { fetchUrlContents } from "./url-content.js";
import type { Config } from "../config.js";
import type { Settings } from "../settings.js";
import type { RawTweet } from "../types.js";
const mockConfig: Config = {
JINA_API_KEY: "test-jina",
GEMINI_API_KEY: "test-gemini",
SLACK_BOT_TOKEN: "xoxb-test",
SLACK_CHANNEL: "C123",
USE_SAMPLE_DATA: true,
};
const baseSettings: Settings = {
schedule: { lookbackHours: 24, maxTweets: 100 },
urlContent: {
enabled: true,
timeoutMs: 5000,
parallelism: 5,
maxSummaryChars: 200,
inputCharsMultiplier: 20,
},
analysis: {
urlSummaryModel: "gemini-2.5-flash",
trendAnalysisModel: "gemini-2.5-pro",
temperature: 0,
},
};
const disabledSettings: Settings = {
...baseSettings,
urlContent: { ...baseSettings.urlContent, enabled: false },
};
const tweetsNoUrl: RawTweet[] = [
{
authorId: "user1",
text: "URLなしのツイート",
createdAt: "2026-04-16T10:00:00.000Z",
url: "https://x.com/user1/status/1",
},
];
function makeTweetsWithUrls(...urls: string[]): RawTweet[] {
return urls.map((u, i) => ({
authorId: "user1",
text: `Check out ${u}`,
createdAt: "2026-04-16T10:00:00.000Z",
url: `https://x.com/user1/status/${i + 1}`,
}));
}
describe("fetchUrlContents", () => {
it("urlContent.enabled=false のとき空Mapを返す", async () => {
const result = await fetchUrlContents(
makeTweetsWithUrls("https://example.com/article"),
mockConfig,
disabledSettings,
);
expect(result.size).toBe(0);
});
it("URLがないツイートでは空Mapを返す", async () => {
const result = await fetchUrlContents(
tweetsNoUrl,
mockConfig,
baseSettings,
);
expect(result.size).toBe(0);
});
it("Jina 200 OK で本文を取得できる", async () => {
vi.stubGlobal(
"fetch",
vi.fn((input: string | URL | Request, init?: RequestInit) => {
const url = typeof input === "string" ? input : input.toString();
// HEAD展開: 展開後URLとしてそのまま返すx.com系でないので通過する
if (init?.method === "HEAD") {
return Promise.resolve({ url });
}
// Jina Reader 呼び出し
if (url.startsWith("https://r.jina.ai/")) {
return Promise.resolve(new Response("Article body content", { status: 200 }));
}
return Promise.resolve(new Response(null, { status: 404 }));
}),
);
const tweets = makeTweetsWithUrls("https://example.com/article");
const result = await fetchUrlContents(tweets, mockConfig, baseSettings);
expect(result.size).toBe(1);
const content = [...result.values()][0];
expect(content).toBe("Article body content");
});
it("Jina 402 でフォールバックし残りをスキップする", async () => {
let jinaCallCount = 0;
vi.stubGlobal(
"fetch",
vi.fn((input: string | URL | Request, init?: RequestInit) => {
const url = typeof input === "string" ? input : input.toString();
if (init?.method === "HEAD") {
return Promise.resolve({ url });
}
if (url.startsWith("https://r.jina.ai/")) {
jinaCallCount++;
if (jinaCallCount === 1) {
return Promise.resolve(new Response("First article", { status: 200 }));
}
return Promise.resolve(new Response(null, { status: 402 }));
}
return Promise.resolve(new Response(null, { status: 404 }));
}),
);
const tweets = makeTweetsWithUrls(
"https://a.example.com/1",
"https://b.example.com/2",
"https://c.example.com/3",
);
const result = await fetchUrlContents(tweets, mockConfig, {
...baseSettings,
urlContent: { ...baseSettings.urlContent, parallelism: 1 },
});
expect(result.size).toBe(1);
expect([...result.values()][0]).toBe("First article");
});
});

View File

@ -0,0 +1,96 @@
import type { Config } from "../config.js";
import type { Settings } from "../settings.js";
import type { RawTweet } from "../types.js";
import { extractUrls, expandUrls } from "../utils/post-optimizer.js";
import { chunkArray } from "../utils/chunk.js";
/**
* URLの本文を Jina Reader
* Map URLextractUrls
* url-summarizer Map.get(url)
*/
export async function fetchUrlContents(
tweets: RawTweet[],
config: Config,
settings: Settings,
): Promise<Map<string, string>> {
const contents = new Map<string, string>();
if (!settings.urlContent.enabled) {
console.info("URL本文取得は src/settings.ts の urlContent.enabled=false のため無効");
return contents;
}
const allRawUrls = tweets.flatMap((t) => extractUrls(t.text));
const uniqueRawUrls = [...new Set(allRawUrls)];
if (uniqueRawUrls.length === 0) return contents;
console.info(`${uniqueRawUrls.length}件 のURLをHEADで展開中`);
const urlMapping = await expandUrls(uniqueRawUrls);
console.info(
`→ 外部URL: ${urlMapping.size}x.com/twitter.com を除外済み)`,
);
if (urlMapping.size === 0) return contents;
let jina402Detected = false;
const entries = [...urlMapping.entries()];
const chunks = chunkArray(entries, settings.urlContent.parallelism);
for (const chunk of chunks) {
if (jina402Detected) break;
const results = await Promise.allSettled(
chunk.map(async ([originalUrl, expandedUrl]) => {
const ctl = new AbortController();
const timer = setTimeout(
() => ctl.abort(),
settings.urlContent.timeoutMs,
);
try {
const res = await fetch(`https://r.jina.ai/${expandedUrl}`, {
headers: {
Accept: "text/plain",
...(config.JINA_API_KEY
? { Authorization: `Bearer ${config.JINA_API_KEY}` }
: {}),
},
signal: ctl.signal,
});
if (res.status === 402 || res.status === 401) {
jina402Detected = true;
console.warn(
`[Jina] ${res.status} — 無料枠が枯渇しました。URL本文なしで分析を続行します。`,
);
return { originalUrl, content: undefined };
}
if (!res.ok) {
return { originalUrl, content: undefined };
}
const text = await res.text();
return { originalUrl, content: text };
} finally {
clearTimeout(timer);
}
}),
);
for (const r of results) {
if (r.status === "fulfilled" && r.value.content) {
contents.set(r.value.originalUrl, r.value.content);
}
}
}
if (jina402Detected) {
console.warn(
`[Jina] フォールバック: ${contents.size} URLs の本文を取得済み。残りはスキップします。`,
);
} else {
console.info(`→ 本文取得完了: ${contents.size}`);
}
return contents;
}

View File

@ -0,0 +1,32 @@
import { describe, it, expect } from "vitest";
import { fetchHomeTimeline } from "./x-timeline.js";
import type { Config } from "../config.js";
import { settings } from "../settings.js";
const mockConfig: Config = {
JINA_API_KEY: "test-jina",
GEMINI_API_KEY: "test-gemini",
SLACK_BOT_TOKEN: "xoxb-test",
SLACK_CHANNEL: "C123",
USE_SAMPLE_DATA: true,
};
describe("fetchHomeTimeline (モックルート)", () => {
it("USE_SAMPLE_DATA=true で fixtures/sample-tweets.json を読み込む", async () => {
const tweets = await fetchHomeTimeline(mockConfig, settings);
expect(tweets.length).toBe(30);
});
it("各ツイートに必須フィールドがある", async () => {
const tweets = await fetchHomeTimeline(mockConfig, settings);
for (const tweet of tweets) {
expect(tweet.authorId).toBeDefined();
expect(typeof tweet.authorId).toBe("string");
expect(tweet.text).toBeDefined();
expect(typeof tweet.text).toBe("string");
expect(tweet.createdAt).toBeDefined();
expect(tweet.url).toBeDefined();
expect(tweet.url).toMatch(/^https:\/\/x\.com\//);
}
});
});

118
src/sources/x-timeline.ts Normal file
View File

@ -0,0 +1,118 @@
import { TwitterApi } from "twitter-api-v2";
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import type { Config } from "../config.js";
import type { Settings } from "../settings.js";
import type { RawTweet } from "../types.js";
export async function fetchHomeTimeline(
config: Config,
settings: Settings,
): Promise<RawTweet[]> {
if (config.USE_SAMPLE_DATA) {
return loadSampleTweets();
}
return fetchFromX(config, settings);
}
async function loadSampleTweets(): Promise<RawTweet[]> {
const path = resolve(import.meta.dirname, "../../fixtures/sample-tweets.json");
const raw = await readFile(path, "utf-8");
return JSON.parse(raw) as RawTweet[];
}
type RealConfig = Extract<Config, { USE_SAMPLE_DATA: false }>;
async function fetchFromX(
config: RealConfig,
settings: Settings,
): Promise<RawTweet[]> {
const client = new TwitterApi({
appKey: config.X_CONSUMER_KEY,
appSecret: config.X_CONSUMER_SECRET,
accessToken: config.X_ACCESS_TOKEN,
accessSecret: config.X_ACCESS_TOKEN_SECRET,
});
const me = await client.v2.me();
const userId = me.data.id;
console.info(`X 認証成功: @${me.data.username} (${userId})`);
const cutoff = new Date(
Date.now() - settings.schedule.lookbackHours * 60 * 60 * 1000,
);
const posts: RawTweet[] = [];
let paginationToken: string | undefined;
while (posts.length < settings.schedule.maxTweets) {
const timeline = await client.v2.homeTimeline({
max_results: 100,
...(paginationToken ? { pagination_token: paginationToken } : {}),
"tweet.fields": "created_at,text,author_id,referenced_tweets,note_tweet",
"user.fields": "username",
expansions: "author_id,referenced_tweets.id",
exclude: "retweets",
});
if (!timeline.data.data?.length) break;
const users = new Map<string, string>();
for (const user of timeline.includes?.users ?? []) {
users.set(user.id, user.username);
}
const quotedTweets = new Map<string, (typeof timeline.includes.tweets)[0]>();
for (const tweet of timeline.includes?.tweets ?? []) {
quotedTweets.set(tweet.id, tweet);
}
for (const tweet of timeline.data.data) {
const createdAt = tweet.created_at ?? "";
if (new Date(createdAt) < cutoff) {
return posts;
}
const text = getNoteText(tweet) ?? tweet.text;
let quotedText: string | undefined;
for (const ref of tweet.referenced_tweets ?? []) {
if (ref.type === "quoted") {
const quoted = quotedTweets.get(ref.id);
if (quoted) {
const quoteAuthor = users.get(quoted.author_id ?? "") ?? "unknown";
const qText = getNoteText(quoted) ?? quoted.text;
quotedText = `引用ツイート by @${quoteAuthor}: ${qText}`;
}
}
}
const author = users.get(tweet.author_id ?? "") ?? "";
posts.push({
authorId: author,
text,
createdAt,
url: `https://x.com/${author}/status/${tweet.id}`,
...(quotedText ? { quotedText } : {}),
});
}
console.info(`→ これまでに ${posts.length}件 取得`);
paginationToken = timeline.data.meta?.next_token;
if (!paginationToken) break;
await sleep(1_000);
}
return posts;
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// twitter-api-v2 の型定義には note_tweet が含まれないため unknown 経由でアクセス
function getNoteText(tweet: unknown): string | undefined {
const obj = tweet as { note_tweet?: { text?: string } };
return obj.note_tweet?.text;
}

11
src/types.ts Normal file
View File

@ -0,0 +1,11 @@
export interface RawTweet {
authorId: string;
text: string;
createdAt: string;
url: string;
quotedText?: string;
}
export interface EnrichedTweet extends RawTweet {
enrichedText: string;
}

7
src/utils/chunk.ts Normal file
View File

@ -0,0 +1,7 @@
export function chunkArray<T>(arr: T[], size: number): T[][] {
const chunks: T[][] = [];
for (let i = 0; i < arr.length; i += size) {
chunks.push(arr.slice(i, i + size));
}
return chunks;
}

22
src/utils/errors.test.ts Normal file
View File

@ -0,0 +1,22 @@
import { describe, it, expect } from "vitest";
import { UserFacingError } from "./errors.js";
describe("UserFacingError", () => {
it("message を保持する", () => {
const err = new UserFacingError("APIキーが無効です");
expect(err.message).toBe("APIキーが無効です");
expect(err.name).toBe("UserFacingError");
});
it("cause を保持する", () => {
const cause = new Error("original");
const err = new UserFacingError("パース失敗", { cause });
expect(err.cause).toBe(cause);
});
it("instanceof Error が true", () => {
const err = new UserFacingError("test");
expect(err).toBeInstanceOf(Error);
expect(err).toBeInstanceOf(UserFacingError);
});
});

6
src/utils/errors.ts Normal file
View File

@ -0,0 +1,6 @@
export class UserFacingError extends Error {
constructor(message: string, options?: { cause?: unknown }) {
super(message, options);
this.name = "UserFacingError";
}
}

View File

@ -0,0 +1,102 @@
import { describe, it, expect, vi } from "vitest";
import { extractUrls, cleanText, expandUrls } from "./post-optimizer.js";
describe("extractUrls", () => {
it("テキストからURLを抽出する", () => {
const text = "Check out https://example.com and http://foo.bar/path";
expect(extractUrls(text)).toEqual([
"https://example.com",
"http://foo.bar/path",
]);
});
it("URLがなければ空配列を返す", () => {
expect(extractUrls("no urls here")).toEqual([]);
});
it("t.co短縮URLも抽出する", () => {
const text = "見て https://t.co/abc123 すごい";
expect(extractUrls(text)).toEqual(["https://t.co/abc123"]);
});
});
describe("cleanText", () => {
it("URLを除去する", () => {
expect(cleanText("hello https://example.com world")).toBe("hello world");
});
it("絵文字を除去する", () => {
expect(cleanText("すごい🔥ニュース")).toBe("すごいニュース");
});
it("改行をスペースに変換する", () => {
expect(cleanText("line1\nline2")).toBe("line1 line2");
});
it("全角スペースを除去する", () => {
expect(cleanText("全角\u3000スペース")).toBe("全角スペース");
});
it("連続スペースを1つにまとめる", () => {
expect(cleanText("multiple spaces")).toBe("multiple spaces");
});
it("連続するを1つにまとめる", () => {
expect(cleanText("すごい!!!")).toBe("すごい!");
});
it("連続する。を1つにまとめる", () => {
expect(cleanText("終わり。。。")).toBe("終わり。");
});
it("複合的なクリーニングが正しく動く", () => {
const input =
"🔥 新モデル https://example.com が発表!!! すごい。。。";
const expected = "新モデル が発表! すごい。";
expect(cleanText(input)).toBe(expected);
});
});
describe("expandUrls", () => {
it("空配列を渡すと空Mapを返す", async () => {
const result = await expandUrls([]);
expect(result.size).toBe(0);
});
it("x.com のURLを除外する", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({ url: "https://x.com/user/status/123" }),
);
const result = await expandUrls(["https://t.co/abc"]);
expect(result.size).toBe(0);
});
it("twitter.com のURLを除外する", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({ url: "https://twitter.com/user/status/123" }),
);
const result = await expandUrls(["https://t.co/def"]);
expect(result.size).toBe(0);
});
it("外部URLは元URL→展開URLのMapで返す", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({ url: "https://openai.com/blog/gpt5" }),
);
const result = await expandUrls(["https://t.co/ghi"]);
expect(result.size).toBe(1);
expect(result.get("https://t.co/ghi")).toBe("https://openai.com/blog/gpt5");
});
it("fetchが失敗したURLはスキップする", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockRejectedValue(new Error("network error")),
);
const result = await expandUrls(["https://t.co/fail"]);
expect(result.size).toBe(0);
});
});

View File

@ -0,0 +1,67 @@
const URL_RE = /https?:\/\/\S+/g;
const EMOJI_RE =
/[\u{1F300}-\u{1F9FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]/gu;
const MULTI_SPACE_RE = /\s+/g;
const MULTI_BANG_RE = /+/g;
const MULTI_PERIOD_RE = /。+/g;
export function extractUrls(text: string): string[] {
return text.match(URL_RE) ?? [];
}
export function cleanText(text: string): string {
let s = text;
s = s.replace(URL_RE, "");
s = s.replace(EMOJI_RE, "");
s = s.replace(/\n/g, " ");
s = s.replace(/\u3000/g, "");
s = s.replace(MULTI_SPACE_RE, " ");
s = s.replace(MULTI_BANG_RE, "");
s = s.replace(MULTI_PERIOD_RE, "。");
return s.trim();
}
const X_HOSTS = new Set(["x.com", "twitter.com", "t.co"]);
/**
* URLを並列HEAD展開しx.com/twitter.com/t.co
* Map<元の短縮URL, 展開後URL>
*/
export async function expandUrls(
urls: string[],
timeoutMs = 5_000,
): Promise<Map<string, string>> {
const mapping = new Map<string, string>();
if (urls.length === 0) return mapping;
const results = await Promise.allSettled(
urls.map(async (originalUrl) => {
const ctl = new AbortController();
const timer = setTimeout(() => ctl.abort(), timeoutMs);
try {
const res = await fetch(originalUrl, {
method: "HEAD",
redirect: "follow",
signal: ctl.signal,
});
return { originalUrl, expandedUrl: res.url };
} finally {
clearTimeout(timer);
}
}),
);
for (const r of results) {
if (r.status !== "fulfilled") continue;
const { originalUrl, expandedUrl } = r.value;
try {
if (!X_HOSTS.has(new URL(expandedUrl).hostname)) {
mapping.set(originalUrl, expandedUrl);
}
} catch {
// invalid URL — skip
}
}
return mapping;
}

14
tsconfig.json Normal file
View File

@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2023",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"noUncheckedIndexedAccess": true,
"outDir": "dist"
},
"include": ["src/**/*.ts"]
}

8
vitest.config.ts Normal file
View File

@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
unstubGlobals: true,
restoreMocks: true,
},
});