Claude API + Cloudflare Workers で毎日の技術記事収集を自動化

背景

Apple Developer News、Swift.org Blog、個人ブログのフィードなど、毎朝チェックしたい技術情報源は気付けばじわじわ増えていきます。手動で巡回して記事を読み、要点をまとめて共有するだけで 30 分が消えます。さらに別の作業が立て込むと、その日は巡回そのものをスキップしてしまう日もありました。

そこで、Claude API と Cloudflare Workers で収集から共有までをまとめて回す仕組みを組みました。

なぜ Claude API + Cloudflare なのか

候補としては n8n や Zapier のような既製のワークフロー自動化ツールも考えました。最終的に Claude API と Cloudflare Workers を選んだのは、要約の質、拡張性、運用コストの三点で都合がよかったためです。

要約は Claude Sonnet 4.5 に任せています。英語記事をそのまま投げても、文脈を踏まえた日本語の 200 文字要約が返ってくるので、後段で翻訳と要約を分けるパイプを組まずに済みます。

処理は TypeScript で書くため、情報源の追加もパーサーの差し替えも、コードを読みながらそのまま手を入れられます。GUI のワークフローツールだと、こうした分岐の追加で「設定上の制約」と戦うことになりがちです。

運用面では Cloudflare Workers と Workflows の無料枠に収まる範囲で動かしています。Workflows はステップ単位でリトライポリシーを宣言でき、失敗したステップだけ自動で再試行されるため、リトライ用のロジックを自前で書かなくて済みます。実行履歴とエラーログは Cloudflare Dashboard から追えます。

システム概要

情報源

収集対象は RSS フィードと Web ページの 2 系統に分かれます。

RSS フィードは xml2js で解析しています。

RSS を提供していないサイトは cheerio でスクレイピングしています。

アーキテクチャ

技術記事収集フロー

毎日 10:00 JST (01:00 UTC) に Cloudflare Cron Trigger がワークフローを起動します。RSS と Web から取得した記事を昨日以降に絞り込み、Claude API でまとめて要約し、Slack に投稿する流れです。

Cron Trigger (毎日 01:00 UTC / 10:00 JST)

Cloudflare Workflow

  ├─ Step 1: fetch-articles (リトライ3回)
  │   └─ 各情報源から記事取得

  ├─ Step 2: summarize-articles (リトライ2回)
  │   └─ Claude Sonnet 4.5 で一括要約

  └─ Step 3: post-to-slack (リトライ3回)
      └─ Slack Webhook 投稿

Slack への投稿例

Slack 投稿例

情報源ごとにグループ化され、各記事のタイトル、URL、要約が自動投稿されます。

セットアップ手順

1. プロジェクトの初期化

npm create cloudflare@latest kusumoto-daily-report
cd kusumoto-daily-report
npm install @anthropic-ai/sdk cheerio xml2js
npm install -D @types/xml2js

2. 実装

プロジェクト構成に従って実装を行います。

src/
├── index.ts             # Cron エントリポイント
├── workflow.ts          # Workflow 定義
├── types.ts             # 型定義
├── steps/
│   ├── fetch-articles.ts
│   ├── summarize.ts
│   └── post-slack.ts
├── parsers/
│   ├── rss.ts
│   └── web.ts
├── clients/
│   ├── claude.ts
│   └── slack.ts
└── utils/
    └── date.ts

wrangler.toml に Cron Trigger と Workflow の設定を追加します。

name = "kusumoto-daily-report"
main = "src/index.ts"
compatibility_date = "2025-10-01"
compatibility_flags = ["nodejs_compat"]

[triggers]
crons = ["0 1 * * *"]

[[workflows]]
binding = "DAILY_REPORT_WORKFLOW"
name = "daily-report-workflow"
class_name = "DailyReportWorkflow"

3. Cloudflare にログイン

npx wrangler login

4. Secret の設定

Slack Webhook URL と Anthropic API Key を設定します。

npx wrangler secret put SLACK_WEBHOOK_URL
# プロンプトが表示されたら Slack Webhook URL を入力

npx wrangler secret put ANTHROPIC_API_KEY
# プロンプトが表示されたら Anthropic API Key を入力

5. デプロイ

npm run deploy

6. 動作確認

Cloudflare Dashboard で手動実行と実行状況を確認します。

ワークフロー画面

Cloudflare Workflow Dashboard

Workflows タブでは、過去 7 日間の実行履歴、完了したインスタンス数、エラー数などを確認できます。右上の「トリガー」ボタンから手動実行が可能です。

手動実行手順

  1. https://dash.cloudflare.com/ にアクセス
  2. Workers & Pages → kusumoto-daily-report を選択
  3. Workflows タブを開く
  4. 右上の「トリガー」ボタンをクリック

Workflow トリガーダイアログ

  1. インスタンス ID(任意)とパラメータ(任意)を入力
  2. 「トリガー インスタンス」ボタンで実行
  3. インスタンス一覧から実行状況を確認
    • 各ステップの成功/失敗
    • リトライ回数
    • 実行時間
    • エラーログ

技術詳細

プロジェクト構成

KusumotoDailyReport/
├── src/
│   ├── index.ts             # Cron エントリポイント
│   ├── workflow.ts          # Workflow 定義
│   ├── types.ts             # 型定義
│   ├── steps/
│   │   ├── fetch-articles.ts
│   │   ├── summarize.ts
│   │   └── post-slack.ts
│   ├── parsers/
│   │   ├── rss.ts           # RSS パーサー
│   │   └── web.ts           # Web スクレイパー
│   ├── clients/
│   │   ├── claude.ts        # Claude API クライアント
│   │   └── slack.ts         # Slack Webhook クライアント
│   └── utils/
│       └── date.ts          # 日付ユーティリティ
├── wrangler.toml            # Cloudflare 設定
├── tsconfig.json
└── package.json

記事取得の仕組み

情報源ごとに専用パーサーを実装しました。RSS は xml2js、Web ページは cheerio で解析します。

各情報源は独立して処理され、失敗時は最大 3 回試行します(初回 + リトライ 2 回、指数バックオフで 2 秒 → 4 秒)。リトライ後も失敗した情報源は、Slack に通知されます。

// 情報源ごとのリトライ処理
async function fetchFromSource(
  source: Source,
  targetDate: string,
  retries = 3
): Promise<Article[]> {
  for (let attempt = 1; attempt <= retries; attempt++) {
    try {
      let articles: Article[] = [];

      if (source.type === 'rss') {
        articles = await parseRSS(source.url, source.name, targetDate);
      } else {
        // Web スクレイピング
        if (source.url.includes('ios-osushi')) {
          articles = await parseIOSOsushi(targetDate);
        } else if (source.url.includes('xcode-cloud')) {
          articles = await parseXcodeCloud(targetDate);
        } else if (source.url.includes('swift.org')) {
          articles = await parseSwiftBlog(targetDate);
        }
      }

      console.log(`✓ ${source.name}: ${articles.length}件 (試行 ${attempt}/${retries})`);
      return articles;
    } catch (error) {
      const errorMessage = error instanceof Error ? error.message : String(error);
      console.error(`✗ ${source.name}: ${errorMessage} (試行 ${attempt}/${retries})`);

      if (attempt === retries) {
        throw error;
      }

      // 指数バックオフでリトライ: 2s → 4s → 8s
      const delay = Math.pow(2, attempt) * 1000;
      await new Promise((resolve) => setTimeout(resolve, delay));
    }
  }

  return [];
}

// 全情報源から並列取得
export async function fetchArticles(sinceDate?: string): Promise<FetchSummary> {
  const targetDate = sinceDate || getYesterday();
  const allArticles: Article[] = [];
  const failedSources: Array<{ name: string; error: string }> = [];

  // 並列処理で全情報源から記事を取得
  const results = await Promise.allSettled(
    SOURCES.map((source) => fetchFromSource(source, targetDate, 3))
  );

  // 結果を集約
  results.forEach((result, index) => {
    if (result.status === 'fulfilled') {
      allArticles.push(...result.value);
    } else {
      const errorMessage =
        result.reason instanceof Error ? result.reason.message : String(result.reason);
      failedSources.push({
        name: SOURCES[index].name,
        error: errorMessage,
      });
    }
  });

  return {
    articles: allArticles,
    failedSources,
  };
}

失敗した情報源は、以下のように Slack に通知されます。

⚠️ *取得に失敗した情報源*

• *Yahoo! ニュース*
  エラー: Network timeout after 3 retries

• *Swift.org Blog*
  エラー: Failed to parse HTML structure

要約生成の仕組み(Claude API)

Claude API(Sonnet 4.5 モデル)を使って、全記事を一括で要約生成します。

export class ClaudeClient {
  private client: Anthropic;

  constructor(apiKey: string) {
    this.client = new Anthropic({ apiKey });
  }

  async summarizeArticles(articles: Article[]): Promise<ArticleWithSummary[]> {
    const prompt = this.buildPrompt(articles);

    const message = await this.client.messages.create({
      model: 'claude-sonnet-4-5-20250929',
      max_tokens: 4000,
      messages: [{ role: 'user', content: prompt }],
    });

    const responseText = message.content[0].type === 'text'
      ? message.content[0].text
      : '';
    const summaries = this.parseResponse(responseText);

    // 記事と要約をマージ
    return articles.map((article) => {
      const summary = summaries.find((s) => s.url === article.url);
      return {
        ...article,
        summary: summary?.summary || '要約を生成できませんでした',
      };
    });
  }

  private buildPrompt(articles: Article[]): string {
    const articlesJson = JSON.stringify(
      articles.map((a) => ({
        title: a.title,
        url: a.url,
        source: a.source,
      })),
      null,
      2
    );

    return `以下の記事リストを要約してください。各記事を200文字以内の日本語で要約し、JSON配列で出力してください。
英語の記事も含め、すべて日本語で要約してください。
要約のみを出力し、「要約:」などの前置きは不要です。

記事リスト:
${articlesJson}

出力形式:
[
  {"url": "記事のURL", "summary": "要約文"},
  ...
]`;
  }
}

プロンプトでは記事タイトル、URL、情報源だけを JSON で渡し、応答も JSON 配列で受け取ります。記事 1 件ごとに API を呼ぶのではなく、その日の全記事をまとめて 1 リクエストで送るため、入力プロンプトの定型部分が 1 回しか課金されません。出力言語を「日本語」に固定しているので、英語記事と日本語記事が並んだ Slack 投稿でも要約のトーンが揃います。

Workflow の実装

Cloudflare Workflows でステップごとにリトライを制御しています。各情報源は独立して最大 3 回試行され、Step 1 全体の失敗時にも追加で最大 3 回試行します。

export class DailyReportWorkflow extends WorkflowEntrypoint<Env> {
  async run(event: unknown, step: WorkflowStep) {
    // Step 1: 記事取得
    // - 各情報源で最大3回試行(初回 + リトライ2回、指数バックオフ: 2s → 4s)
    // - Step 全体でも最大3回試行(初回 + リトライ2回、5秒間隔、指数バックオフ)
    const fetchResult: FetchSummary = await step.do(
      'fetch-articles',
      {
        retries: {
          limit: 3,
          delay: 5000,
          backoff: 'exponential',
        },
      },
      async () => {
        return await fetchArticles();
      }
    );

    const { articles, failedSources } = fetchResult;

    // 記事がある場合のみ要約を生成
    let summaries: ArticleWithSummary[] = [];
    if (articles.length > 0) {
      // Step 2: 要約生成(リトライ2回、10秒間隔)
      summaries = await step.do(
        'summarize-articles',
        {
          retries: {
            limit: 2,
            delay: 10000,
          },
        },
        async () => {
          return await summarizeArticles(articles, this.env.ANTHROPIC_API_KEY);
        }
      );
    }

    // Step 3: Slack 投稿(リトライ3回、5秒間隔)
    // 失敗した情報源も含めて通知
    await step.do(
      'post-to-slack',
      {
        retries: {
          limit: 3,
          delay: 5000,
        },
      },
      async () => {
        return await postToSlack(summaries, this.env.SLACK_WEBHOOK_URL, failedSources);
      }
    );
  }
}

GitHub との連携による自動デプロイ

Cloudflare Workers と GitHub を連携することで、コードの変更が自動的にデプロイされます。

PR でのデプロイ確認

Cloudflare Workers デプロイ通知

PR 内で Cloudflare によるデプロイ状況を確認できます。

運用とモニタリング

Cloudflare Dashboard での監視

Workflows タブで以下を確認できます。

  • 各ステップの実行状況(成功/失敗)
  • リトライ回数
  • 実行時間
  • エラーログ

ログ確認

ローカルでリアルタイムログを確認できます。

npx wrangler tail

コスト

Cloudflare 側は無料枠で収まっています。Workers の 1 日 100,000 リクエスト、Workflows の Beta 無料枠、Cron Triggers のいずれも、1 日 1 回起動するワークフローでは枠に届きません。

別途かかるのは Claude API のトークン料金だけです。1 日 1 回、全記事を 1 リクエストにまとめて要約しているため、使用量は最小限です。

まとめ

毎朝消えていた 30 分は、Slack に届いた要約を流し読みする時間に置き換わりました。情報源の追加もパーサーを 1 ファイル足せばよく、運用後に何度か RSS と Web スクレイピングを継ぎ足しています。

同じように情報源の巡回に時間を取られている方は、よければ試してみてください。

参考リンク