🍫

ZennのRSS を Cloud Run 関数でチェックして新着コンテンツをCloud Pub/Subへ流す

2024/10/31に公開

RSSフィードは新着コンテンツを把握するために広く利用されている方法ですが、これまで読み出す側の実装をしてこなかったので、Google Cloud のサーバーレスサービスを使って作成してみようと思いました。

RSSフィードを使ってなにかやりたいことがある方へ向けての記事です。

項目 利用したもの
Cloud Run 関数のランタイム Node.js 20(TypeScript で書いてトランスパイル)

本記事のソースコードはこちらです。

https://github.com/cm-wada-yusuke/llm-reviewer/tree/main/rss-manager

構成

最終的には Cloud Pub/Sub へ流したコンテンツを、Gemini でレビューするところまでやりたいです。が、本稿ではトピックに Publish する部分、赤枠で囲んだところを対象にします。ざっくり以下の手順で進めます。

  1. Firestore を用意する(RSS フィードの URL を登録する、最後に読んだコンテンツを記録する)
  2. Cloud Pub/Sub トピックを作成する
  3. Cloud Run 関数を作成する
  4. Cloud Scheduler を作成し、5 分ごとで起動するように

Firestore を用意する

RSSフィードを管理する場合、簡易的に記録できる場所があると都合がよさそうでした。Firestore を使います。Google Cloud 管理コンソールから、Firestore のページを開き、新しいデータベースrss-managerを作成しましょう。

データベースが作成されたら、コレクションrss-feedsを作成してください。

これで OK です。

Cloud Pub/Sub トピックを作成する

未読コンテンツの URL 情報を Publish したいので、宛先の Cloud Pub/Sub を作成しておきましょう。名前はrss-updatesにしました。

Cloud Run 関数を実装する

Cloud Scheduler からは HTTPS リクエストで起動します。Cloud Run関数もHTTPSリクエストを受け付けられるエントリポイントにします。

index.ts(抜粋)
// Cloud Scheduler によって定期的に RSS フィードをチェックする関数
const pubSubClient = new PubSub();
export const checkRssFeeds = async (req: Request, res: Response) => {
  try {
    // Firestore に登録された RSS フィードの URL を取得
    const snapshot = await db.collection(collectionName).get();

    // 各フィードを処理
    for (const doc of snapshot.docs) {
      const rssData = doc.data();
      const rssUrl = rssData.url;

      // RSS フィードを取得
      const feed = await parser.parseURL(rssUrl);

      // Firestore に保存された最後にチェックしたアイテムの GUID を取得
      const lastCheckedItemGuid = rssData.lastCheckedGuid;

      // 新しいアイテムを検出
      const newItems = feed.items.slice(0, Math.max(0, lastIndex));

      if (newItems.length > 0) {
        // 新しいアイテムがあれば Pub/Sub に通知
        for (const item of newItems) {
          const message = {
            title: item.title,
            link: item.link,
            pubDate: item.pubDate,
          };
          await pubSubClient
            .topic("rss-updates")
            .publishMessage({ json: message });
        }

        // 最新のアイテムの GUID を Firestore に更新
        const latestItem = newItems[0];
        await doc.ref.update({ lastCheckedGuid: latestItem.guid });
        res
          .status(200)
          .send(`Items published to Pub/Sub: lastItem: ${latestItem.title}`);
      } else {
        res.status(200).send(`No new items for feed: ${rssUrl}`);
      }
    }
  } catch (error) {
    res.status(200).send("Error checking RSS feeds");
    throw new Error("Failed to check RSS feeds");
  }
};
実装全文

実際には登録する処理も関数にしています。

index.ts
import { PubSub } from '@google-cloud/pubsub';
import * as RSSParser from 'rss-parser';
import type { Request, Response } from 'express';
import { Firestore } from '@google-cloud/firestore';

const db = new Firestore({
  databaseId: 'rss-manager',
});
const parser = new RSSParser();

// Firestore のコレクション名
const collectionName = 'rss-feeds';

// RSS フィードの URL を Firestore に登録する HTTP 関数
export const rssRegister = async (req: Request, res: Response) => {
  const rssUrl = req.body.url;

  if (!rssUrl) {
    res.status(400).send('RSS URL is required.');
    return;
  }

  try {
    // Firestore に RSS URL を保存
    await db.collection(collectionName).add({
      url: rssUrl,
      lastCheckedGuid: null, // 初回は null を設定して新しい記事を全て検出する
    });
    res.status(200).send(`RSS URL added: ${rssUrl}`);
  } catch (error) {
    console.error('Error adding RSS URL:', error);
    res.status(500).send('Failed to add RSS URL.');
  }
};

// Cloud Scheduler によって定期的に RSS フィードをチェックする関数
const pubSubClient = new PubSub();
export const checkRssFeeds = async (req: Request, res: Response) => {
  try {
    // Firestore に登録された RSS フィードの URL を取得
    const snapshot = await db.collection(collectionName).get();
    if (snapshot.empty) {
      console.log('No RSS URLs found.');
    }

    // 各フィードを処理
    for (const doc of snapshot.docs) {
      const rssData = doc.data();
      const rssUrl = rssData.url;

      // RSS フィードを取得
      const feed = await parser.parseURL(rssUrl);

      // Firestore に保存された最後にチェックしたアイテムの GUID を取得
      const lastCheckedItemGuid = rssData.lastCheckedGuid;

      // 新しいアイテムを検出
      const lastIndex = (() => {
        if (!lastCheckedItemGuid) {
          return feed.items.length;
        } else {
          return feed.items.findIndex(
            (item) => item.guid === lastCheckedItemGuid
          );
        }
      })();
      const newItems = feed.items.slice(0, Math.max(0, lastIndex));

      if (newItems.length > 0) {
        // 新しいアイテムがあれば Pub/Sub に通知
        for (const item of newItems) {
          const message = {
            title: item.title,
            link: item.link,
            pubDate: item.pubDate,
          };
          await pubSubClient
            .topic('rss-updates')
            .publishMessage({ json: message });
          console.log(`New item published to Pub/Sub: ${item.title}`);
        }

        // 最新のアイテムの GUID を Firestore に更新
        const latestItem = newItems[0];
        await doc.ref.update({ lastCheckedGuid: latestItem.guid });
        res
          .status(200)
          .send(`Items published to Pub/Sub: lastItem: ${latestItem.title}`);
      } else {
        console.log(`No new items for feed: ${rssUrl}`);
        res.status(200).send(`No new items for feed: ${rssUrl}`);
      }
    }
  } catch (error) {
    console.error('Error checking RSS feeds:', error);
    res.status(200).send('Error checking RSS feeds');
    throw new Error('Failed to check RSS feeds');
  }
};

ここでRSSフィードのURL取得、未読コンテンツのURL取得とPublish、そしてFirestoreの更新まで行っています

デプロイ

checkRssFeeds という名前で Cloud Run 関数をデプロイしましょう。以下のコマンドを実行します。

gcloud functions deploy checkRssFeeds \
--region=asia-northeast1 \
--runtime=nodejs20 \
--memory=256 \
--timeout=30s \
--source=. \
--trigger-http \
--project=your-project-name \
--entry-point=checkRssFeeds \
--no-allow-unauthenticated

Cloud Scheduler から OIDC トークン付きで起動するため(後述)、--no-allow-unauthenticatedオプションをつけてください。

RSS 登録関数もデプロイ

POST メソッドでフィード URL を登録できるようになります。

gcloud functions deploy rssRegister \
--region=asia-northeast1 \
--runtime=nodejs20 \
--memory=256 \
--timeout=30s \
--source=. \
--trigger-http \
--project=your-project-name \
--entry-point=rssRegister \
--allow-unauthenticated

以下で登録を試してみてください。フィードの URL は自由に変えてください。

curl -i -X POST -H "Content-Type: application/json" \
"https://asia-northeast1-your-project-name.cloudfunctions.net/rssRegister" \
--data '{ "url": "https://zenn.dev/waddy/feed" }'

Google Cloud 管理コンソールでデプロイされたことが確認できれば OK です。

Cloud Scheduler を作成し、5 分ごとで起動するように

Google Cloud 管理コンソールから作成しましょう。次のように設定してください。

Cloud Scheduler から --no-allow-unauthenticated なCloud Run 関数を起動するためには、Auth ヘッダーに OIDC トークンを設定します。説明書きのとおり、オーディエンスに Cloud Run 関数の URL を登録すれば自動で OIDC トークンを設定してくれますので、画像のように項目を埋めます。

試す

RSS フィードを登録した状態で、Cloud Scheduler を強制起動してみましょう。Cloud Pub/Sub で以下のようになんとなくメッセージが Publish されていそうであれば OK です。

おわりに

これで、RSS が更新された場合は、新着コンテンツが以下の形式で Publish されるようになりました。

{
  "title": "ZennのRSSで流れてきた記事をGeminiでレビューしてSlackへ投稿する",
  "url": "https://zenn.dev/waddy/articles/cloud-run-functions-gemini-review-to-slack"
}

トピックを Subscribe すれば、このメッセージを受け取れます。たとえば、冒頭で述べたように、URL から HTML を読み込んで Gemini などの生成 AI にレビューしてもらう、といった用途がありそうです。

RSS フィードを登録する際、URL の重複チェックを行っていないので、複数の同一メッセージが流れてくる可能性があり、厳密な実装ではありません。が、Cloud Run 関数と Firestore を組み合わせることで RSS の更新をチェックする仕組みがシンプルに実装できることがわかりました。

RSSフィードを使ってなにかやりたいことがある方にとって少しでも参考になれば幸いです。

ソースコード

https://github.com/cm-wada-yusuke/llm-reviewer/tree/main/rss-manager

Discussion