2026年に「相互送客ブログロール」を作り直した話

  • #Astro
  • #RSS
  • #ブログロール
  • #静的サイト
  • #キャッシュ設計
  • #Node.js
2026年に「相互送客ブログロール」を作り直した話

この記事について

旅行ブログのサイドバー・記事末尾に「他媒体の新着」を出す機能(いわゆるブログロール)を、WordPressプラグインからAstro+静的ファイル配信の構成に移植した。3時間ごとに自動更新し、中身が変わっていないときはビルドを焚かない設計になっている。この記事はその設計と実装の記録。

実装ステータス: 実装済み(3時間ごとに本番稼働中)
環境: Node.js 22(組み込みfetch使用)、Astro 5.x


なぜ作り直すことになったか

旧来はWordPressプラグインがRSSを定期取得してDBに保存し、ショートコードで記事末尾に出力していた。Astroへの移行タイミングでこのプラグインの代替が必要になった。

単純に移植しようとすると2つの問題がある。

問題1: Astroビルドを外部依存にできない。Astroは output: 'static' なので、ビルド時に外部RSSを取りに行くと、アンテナ側の障害でビルドが落ちる。自分のサイトのビルドが「外部サイトの生存」に依存するのは避けたい。

問題2: レガシーHTMLへの対応。Astroページと並行して、旧WordPress時代からR2に配信しているHTMLファイル群がある。Astroを再ビルドしても旧HTMLは更新されない。どちらにも同じブログロールを表示するには別経路が必要だった。

構成の全体像

最終的に以下のファイル構成で落ち着いた。

役割ファイル
台帳(どの媒体・アンテナのRSSを出すか)src/data/reciprocal-feeds.json
キャッシュ(取得済みの正規化データ・生成物)src/data/reciprocal-feed-cache.json
取得・正規化・書き出しスクリプトscripts/refresh_reciprocal_feed.mjs
Astroページのサーバレンダリングコンポーネントsrc/components/ReciprocalFeed.astro
レガシーHTML向けJSONエンドポイントsrc/pages/reciprocal-feed.json.ts
レガシーHTML向けクライアントウィジェットpublic/reciprocal-feed.js

データの流れ:

台帳(reciprocal-feeds.json)
  ↓ 生成器が3時間ごとに取得・正規化
キャッシュ(reciprocal-feed-cache.json)  ← リポジトリにコミット(変化時のみ)
  ├→ Astroビルドが読み取り → HTMLに焼き込み(サーバレンダリング)
  └→ /reciprocal-feed.json エンドポイント → レガシーHTMLがJS経由で取得・描画

生成器とビルドを分けた理由

この設計の核心は「RSS取得」と「Astroビルド」を切り離したことにある。

Astroビルドはキャッシュ(JSONファイル)を読むだけ。ネットワークに触らない。外部アンテナが落ちていてもビルドは止まらない。

生成器(refresh_reciprocal_feed.mjs)は外部依存ゼロのNode.jsスクリプト。Node 22以降に組み込まれた fetchAbortController のみで動く。パッケージを持たないので npm install が不要で、launchdのスケジュールタスクから素直に呼べる。

RSSフォーマットの正規化は書いてみると意外と量がある。RSS 2.0・Atom・RDFの3形式に対応し、CDATAのアンラップ、名前空間付きタグ(dc:date 等)、Atomの <link href="..."/> 解釈を実装している。各フィードにはタイムアウト(既定9秒)がかかる。1本タイムアウトしても他フィードの取得は続く。

フォールバックと変更検知

フォールバック: 取得に失敗しても、前回成功時のアイテムをキャッシュに残したまま lastError を記録して続行する(last-known-good方式)。全フィードが同時に失敗しても、ブログロール全体が「空欄」になることはない。問題のある1媒体だけが古いまま、またはアイテム0件でセクション非表示になる。

変更検知: キャッシュの「中身のシグネチャ」を取得前後で比較する。タイムスタンプだけが変わった場合はコミット対象外にする。

const signature = (feedsMap) =>
  JSON.stringify(
    Object.fromEntries(
      Object.entries(feedsMap)
        .sort(([a], [b]) => a.localeCompare(b))
        .map(([id, f]) => [
          id,
          (f.items ?? []).map((i) => `${i.link}\t${i.title}\t${i.isoDate ?? ''}`),
        ]),
    ),
  );
const changed = signature(cache.feeds) !== beforeSig;

生成器は末尾に RECIPROCAL_SUMMARY ok=N fail=N skip=N changed=0|1 を出力する。dripスクリプトがこの changed=1 を見てからコミット→PR→即マージを実行する。新着がない時間帯はCloudflare Pagesの再ビルドが走らない。

ブランドセーフティ(AdSense対応)

掲載サイトはAdSense掲載媒体のため、外部アンテナの見出しをそのまま流すと広告近接リスクがある。生成器は取得段階で見出しをチェックし、NSFW_TITLE_PATTERNS(正規表現リスト)に一致するものを落とす。

設計方針は「明確な成人向け語のみ」に絞ること。過剰フィルタを避けるため、曖昧な語は入れない。

フィード種別NSFWフィルタ
外部アンテナ(partner)適用する
姉妹メディア(friend)適用しない(自媒体扱い)

フィルタは --no-nsfw-filter フラグで無効化できる。語彙リストは運用しながら調整する方針。

日本IPの壁

生成器の定期実行をGitHub Actionsから試みたところ、一部の国内アンテナサイトで取得ゼロ件になることがわかった。海外IPからのクロールを絞っているサービスがある。

そのため定期実行は日本回線の常駐サーバー上のlaunchdに移した。3時間ごとに生成器を回し、変化があれば自動コミット→PR→即マージ→CF Pages再ビルドまで行う。

# 手動で1回だけ実行する場合
node sites/tabinolog/scripts/refresh_reciprocal_feed.mjs

# タイムアウト調整とNSFWフィルタ無効化
node sites/tabinolog/scripts/refresh_reciprocal_feed.mjs --timeout 12000 --no-nsfw-filter

「本番IP要件は常駐サーバーに集約する」は他の箇所(WP REST API認証など)でも同じ結論が出ており、国内サービスと連携する定期タスクは常駐サーバー経由が基本になっている。

未解決の残件

台帳に登録済みでURLが確定できていないフィードが現在2件ある。

  • 候補A: ドメインのTLS証明書エラーのため実在確認不可。証明書が更新されたら再検証する
  • 候補B: 公開RSSが見当たらない。GA4でホスト名が特定できたら再確認する

この2件は url: null でキャッシュに登録されており、生成器はスキップする。表示側でセクションが空になるだけで他に影響はない。

やってわかったこと

静的サイトのビルド障害ポイントは「外部依存の混入」。RSSを取りに行くだけでも、その外部サービスの障害が自分のビルドに伝播する。「何がビルドを壊しうるか」を整理し、外部依存はビルドフェーズから追い出すべきだった。

「タイムスタンプだけの差分」はコミット対象外にすべき。Astroの静的ビルドはCloudflare Pagesに再ビルドを要求する。中身が変わっていないのにビルドが走ると、コスト・時間のムダに加えて「前回と同じ内容」なのに「更新した」という記録ノイズになる。シグネチャ比較は設計初日から入れるべきだった。

日本IPの壁は思ったより高い。GitHub Actionsの固定ページが廃止されてから、国内サービスに対して海外IP経由でアクセスできないケースが増えている。「取れているか」の確認を初期にやっておけばよかった。

外部依存ゼロの設計は長持ちする。Node.jsの組み込みfetchだけで動く設計にしたことで、ランタイム更新でも依存関係の棚卸しが不要。npm install がなければ壊れ方のバリエーションも減る。

更新履歴

  • 2026-07-24: 初版起稿(自動起稿)