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以降に組み込まれた fetch と AbortController のみで動く。パッケージを持たないので 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: 初版起稿(自動起稿)