memo.nnenn0.com

RSSの新着をメールで知らせるbitfeedを作った

登録したRSS / Atomフィードを確認して、新しい記事が出たら1時間に1回、まとめて1通のメールで知らせてくれるbitfeedというサービスを作った。全部Cloudflareの無料枠に乗っかって動かしている。この記事の内容は2026年10月12日時点のもの。

作った理由は、自分に合うツールがほしかったから。RSSリーダーはいくつか使ってみたけど、通知が来なかったり、欲しい機能がなかったりした。

新着をメールで知らせるサービス

bitfeedはRSSリーダーではなく、記事はメールのリンクから元のサイトで読む。管理画面は「フィード管理」と「最近の配信」の2画面だけで、フィード管理ではフィードの登録と削除、フィードごとの通知のON / OFF、取得状態の確認ができる。最近の配信では、直近7日間にメールで送った記事のタイトルとリンクを配信ごとに見返せる。

bitfeedのフィード管理の画面。フィードのURLの入力欄と、登録済みのフィードごとの状態(正常、エラー(5回連続)など)、通知のON / OFFのボタンが並んでいる。表示しているのは架空のフィード

1つのフィードを確認するのは1時間に1回にしている。数分おきに取得すると、フィードを公開しているサイトの負担になるし、個人用の通知なら新着を知るのが1時間遅れても困らない。通知のメールも毎時0分にまとめて送るので、記事が公開されてから通知が届くまでには、最大で2時間くらいかかる。

使ったCloudflareの機能

全体はこんな構成になっている。

text
ブラウザ
   │
   ▼
Access(ログインの確認)
   │
   ▼
Worker ◀── Cron Trigger(毎分)
   │
   ├──▶ D1
   ├──▶ Email Routing ──▶ 自分の受信箱
   └──▶ 各サイトのフィード
機能bitfeedでの役割
Workersプログラム本体。管理画面のリクエストと、Cron Triggerからの定期実行の両方を1つのWorkerで受ける
Cron Triggers毎分Workerを起動する。1回の実行で確認するフィードは1件だけ
D1登録したフィード、見つけた記事、最近の配信を保存するSQLiteのデータベース
Email RoutingWorkerからメールを送る
Access管理画面の前でログインを確認する

D1やメールの送信は、WorkerからAPIキーで呼ぶのではなく、設定ファイル(wrangler.jsonc)に書いたバインディングとして受け取る。コードの中ではenv.DBやenv.EMAILとして使えるので、鍵をコードに書かずに済む。通知先のメールアドレスのような個人の値は、Cloudflareに登録するSecretに入れている。

新着の見分け方と通知の状態

フィードには登録した時点ですでに数十件の記事が入っているのが普通で、それを新着として扱うと、登録した直後に古い記事のメールが届いてしまう。そこで、登録時にあった記事は「既知」の状態で保存して、通知の対象から外している。フィードの行と既知の記事は1つのトランザクションで保存するので、フィードだけが保存されて、次の確認で古い記事がすべて新着になることはない。

記事は、フィードが付けているID(AtomのidやRSSのguid)で見分ける。IDがなければ記事のURL、それもなければタイトルと公開日時のハッシュを使う。同じフィードの同じ記事はD1のUNIQUE制約で1行しか入らないので、何度取得しても二重に通知されない。

記事には通知の状態を持たせていて、次のように移り変わる。

状態いつこの状態になるか
既知登録した時点ですでにあった記事。通知しない
未送信登録の後に見つかった新着。次のメールで送る
送信済みメールの送信に成功した記事
通知なし通知をOFFにしているフィードで見つかった記事。ONに戻しても送らない

未送信から送信済みに変えるのは、メールの送信が成功した後だけにした。送信に失敗したら未送信のまま残るので、次の通知で送り直される。逆に、送信は成功したのに直後のD1の更新が失敗すると、同じ記事がもう一度届く。通知を取りこぼすより二重に受け取る方がましなので、この順番にしている。

最近の配信の保存期間

最近の配信の画面に出す内容は7日間だけ保存して、通知を送る回のはじめに期限が切れたものを削除している。D1のFreeプランでは1つのデータベースの容量が500MBまで(D1の上限)なので、見返す必要のない古い配信で容量を使いたくなかった。

記事のIDを保存している行は、削除するとフィードにまだ載っている古い記事を次の確認で新着と見なしてしまうので、消さずに残している。ざっくりとした見積もりだけど、自分の使い方なら500MBを埋めるのには10年以上かかる。

メールの送信

メールの送信には、CloudflareのEmail Routingを使っている。Email Routingは独自ドメイン宛てのメールをほかのアドレスへ転送する機能で、有効にしたドメインのアドレスを差出人にして、Workerからメールを送ることもできる。転送先に使うアドレスは、本人が確認メールのリンクを押して検証しておく必要があり、Workerから送れる宛先もこの検証済みのアドレスだけに限られている。

会員登録の確認メールのように不特定の相手に送るなら、有料プランが必要なEmail Sendingを使うことになるけど、宛先が自分1人のbitfeedならEmail Routingで足りて、無料で使える。

管理画面のログイン

管理画面は自分しか使わないので、ログイン機能は作らずにCloudflare Accessに任せた。ポリシーで自分だけを許可している。

Workerに標準で付くworkers.devのURLはAccessを通らないので、無効にしている。Worker自身でもAccessを通ってきたリクエストかを確かめていて、Accessの設定を間違えたときに備えた二重の確認になっている。

FreeプランのCPU時間の上限

デプロイした後に引っかかったのが、WorkersのFreeプランのCPU時間の上限だった。公式の上限は、HTTPリクエストもCron Triggerも1回の実行あたり10ms。フィードの取得やD1の応答を待っている時間は数えないので、主に数えられるのはXMLを解析する時間になる。正直、10msは結構厳しいなと思った。

本番で測ったCPU時間

記事が300件近く入っている、あるブログのAtomフィード(約220KB)を登録した後、Workersのダッシュボードで実行ごとのCPU時間を見ると、こうなっていた。

実行CPU時間
管理画面の表示2〜7ms
登録前のCron3〜4ms
フィードの登録75ms
登録後のCron77ms、89ms

登録するときにフィードを1回解析するだけで75msかかっていて、そのあとのCronも、このフィードを解析するたびに80ms前後かかっていた。エラーは1件も出ていなくて、上限を超えても処理は最後まで動いていた。公式の説明では、たまに上限を超えるくらいなら許容する仕組みがあるけど、継続的に超えると上限どおりに打ち切られる、とある。このときはCronのたびに毎回80ms前後だったので、いつ打ち切られ始めてもおかしくなかった。

解析を減らすためにしたこと

フィードの解析を減らすために、次のように変えた。

これで重い解析が入るのは、新しい記事が出てフィードの内容が変わった回だけになる。変更した後に本番のダッシュボードを見ると、Cronの実行は3〜6msくらいで、10msに収まるようになった。

1回の実行で確認するフィードの数

1回の実行で何件も確認すると、解析のCPU時間が積み上がって10msを超えやすくなる。打ち切られたときには、同じ回のほかのフィードも確認されずに終わる。そこで、Cronを毎分動かして、1回に確認するフィードを1件だけにした。

毎時0分の回では、フィードを確認する前にメールを送る。先に確認すると、打ち切られたときにメールも送られなくなるため。

打ち切られた実行は、Worker自身では記録を残せない。そこで、確認を始める前に開始時刻を保存しておき、次の順番が来たときに確認を終えた時刻のほうが古ければ、途中で打ち切られたとして管理画面にエラーを出している。

作ってみて

Cloudflareのダッシュボードでの設定は手順が多くて、Email RoutingやAccess、APIトークンの権限を1つずつ調べて設定しているうちに、なんだか仕事っぽさを感じてきて、デプロイの途中で急に嫌になってきてしまった。

今のところはFreeプランの10msに収まっているけど、またCPU時間の上限で問題が出てきたら、Workersの有料プランにするかもしれない。有料プランなら、bitfeedのように毎分動くCron TriggerのCPU時間の上限は30秒になる(Workersの上限)。30秒もあれば、1回に1件ずつ確認するような10msに合わせた工夫は要らなくなるので、そのときは設計から見直すことになると思う。

この記事を書いたのは、bitfeedがうまく機能しているかを確かめるためでもある。このブログのRSSフィードもbitfeedに登録してあるので、この記事を公開すると、bitfeedから通知のメールが届くはず。