Engineering

ビルドのたびにOGPを取りに行くリンクカード、CIからDoSになりかけた話

  • Astro
  • remark
  • リンクカード
  • OGP
  • TypeScript
  • CI
  • GitHub Actions

Astroremarkプラグインで ::card[URL] と書くだけでリッチなリンクカードを表示する仕組みを作りました。最初はビルド時にOGPメタデータをfetchしていましたが、CIのビルドごとに外部サイトへリクエストが飛ぶため、ボットブロックに引っかかったりDoS的な負荷になりかけたりする問題が起きました。結論として、OGP取得結果をJSONファイルとしてリポジトリにコミットするキャッシュ方式に切り替えています。ビルド時にネットワークリクエストが走らなくなったことで、CIの安定性と外部サイトへの配慮の両方が解決しました。

実際に表示されるカードはこのような形です。

ブログiOSアプリを譲渡するiOSアプリの所有権をApp Store Connect上で譲渡する手順を解説します。譲渡条件の確認からリクエスト、承認までの流れについて紹介します。

既存プラグインを使わなかった理由

Astro向けのリンクカードプラグインはいくつか存在しますが、自作を選びました。理由は2つです。

1つ目は、内部リンクの扱いです。labee.jpではブログ記事とニュース記事がContent Collectionで管理されており、内部リンクのカード情報はfrontmatterから取得できます。外部リンクとは取得元が異なるため、既存プラグインの「すべてOGPで取得」という設計ではフィットしませんでした。

2つ目は、外部リンクの取得タイミングです。既存プラグインの多くはMarkdown処理中にOGPをfetchします。ビルドのたびにネットワークリクエストが走るため、CIでの利用には向きません。最初はこの問題に気づかず同じ設計にしてしまいましたが、後から修正することになります。

::card[URL] ディレクティブ

記法は ::card[URL] です。Markdownの段落として独立した行に書きます。

::card[/posts/ios-app-transfer]

::card[https://chimr.labee.dev/]

正規表現 /^::card\[([^\]\r\n]+)\]$/ で段落ノードのテキストを検出し、HTMLノードに置換します。既存のMarkdown構文とは衝突しません。

内部リンク(相対パスまたは同一ドメインのURL)と外部リンク(別ドメイン)を自動判定し、それぞれ異なる方法でカード情報を取得します。内部リンクはfrontmatterの titledescription を使い、サイトのブランドSVGをサムネイルに表示します。外部リンクはOGPメタデータから og:titleog:descriptionog:image を取得します。

エントリが見つからない場合はプレーンな <a> タグにフォールバックします。ビルドが止まることはありません。

最初の実装 — ビルド時にOGPをfetch

最初の実装では、loadLinkCardEntries() の中で外部URLに対して fetchOgEntry() を呼び、OGPメタデータをその場で取得していました。

// 当初の実装(簡略化)
export async function loadLinkCardEntries(): Promise<LinkCardEntry[]> {
  const internal = [/* frontmatterから取得 */]

  const externalUrls = collectExternalUrls(contentDirs, siteOrigin)
  const externalResults = await Promise.all(externalUrls.map(fetchOgEntry))
  const external = externalResults.filter((e): e is LinkCardEntry => e !== null)

  return [...internal, ...external]
}

この関数は astro.config.mjs のトップレベルで1度だけ呼ばれるため、ローカル開発ではdev server起動時に1回fetchするだけです。問題ないように見えました。

CIからDoSになりかける

GitHub Actionsでビルドするたびに、外部サイトに対してHTTPリクエストが送られます。PRを出すたびに、pushするたびに。外部URLが十数件あれば、ビルドごとにその分のリクエストが飛びます。1日に何度もpushすれば、同じURLに対して繰り返しリクエストを送ることになり、外部サイトからすればDoSと区別がつきません。

さらに厄介なのは、すべてのサイトが素直にレスポンスを返すわけではないという点です。Apple App Storeはボットと判定したリクエストに対してHTMLを返さず、ステータスコードやリダイレクトで弾く挙動をします。note.comもビルドログではfetchが失敗するケースがありました。fetchには AbortSignal.timeout(5000)5秒のタイムアウトを設定していますが、ブロックされた場合はそのタイムアウトまで待った上で失敗します。外部URLが増えれば増えるほど、ビルド時間が数十秒単位で伸びることになります。

fetch失敗時はフォールバックとしてプレーンな <a> リンクが表示されますが、ローカルでは正常にカードが表示されるのにCIでは表示されないという環境差が生まれます。レビュー時にプレビューを確認しても、ローカルビルドとCIビルドで見た目が異なるのはデバッグしづらく、問題のある状態でした。

キャッシュをリポジトリにコミットする方式へ

OGP取得結果をJSONファイルとしてリポジトリにコミットする方式に変更しました。

{
  "version": 1,
  "entries": {
    "https://chimr.labee.dev/": {
      "entry": {
        "url": "https://chimr.labee.dev/",
        "title": "Chimr - Never miss another meeting",
        "description": "重要な会議を見逃さないための macOS メニューバーアプリ。",
        "type": "Chimr",
        "image": "",
        "external": true
      },
      "fetchedAt": "2026-04-14T10:07:28.761Z",
      "lastAttemptAt": "2026-04-14T10:07:28.761Z"
    }
  }
}

fetchedAt はOGPを正常取得できた最後の日時、lastAttemptAt はfetchを試みた日時です。この2つを分離しているのには理由があります。fetch失敗時は既存の entry を保持しつつ lastAttemptAt のみ更新するため、一時的なネットワーク障害やボットブロックでデータは失われません。たとえばnote.comが一時的にブロックしてfetchが失敗しても、前回成功時のタイトルやOG画像はそのまま残ります。

キャッシュの更新は pnpm link-card:cache:update で実行します。この updateLinkCardCache 関数は、まずコンテンツディレクトリ内のすべてのMarkdownファイルをスキャンし、::card[外部URL] ディレクティブから外部URLの一覧を収集します。次に、各URLに対して fetchOgEntry を呼び、OGPメタデータの取得を試みます。このとき、既存のキャッシュファイルも読み込んでおり、fetchが成功したURLは新しいデータで上書きし、失敗したURLは既存キャッシュの entry をそのまま引き継ぎます。つまり、既存キャッシュは破壊されず、新規URLのみが追加される設計です。コンテンツから削除されたURLはスキャン対象から外れるため、次回のキャッシュ更新時にJSONから自然に消えます。

loadLinkCardEntries() はキャッシュファイルを読み取るだけになり、ネットワークリクエストは一切送りません

// 変更後(簡略化)
export async function loadLinkCardEntries(options = {}): Promise<LinkCardEntry[]> {
  const internal = [/* frontmatterから取得 */]

  const externalUrls = collectExternalUrls(contentDirs, siteOrigin)
  const cache = readLinkCardCache(cachePath)
  const external: LinkCardEntry[] = []

  for (const url of externalUrls) {
    const entry = cache.entries[url]?.entry
    if (entry) external.push(entry)
  }

  return [...internal, ...external]
}

CIでキャッシュの整合性を検証する

新しい ::card[外部URL] を追加してキャッシュ更新を忘れると、フォールバックのプレーンリンクが表示されます。これをCIで検知するため、pnpm link-card:cache:check をワークフローに組み込みました。

// scripts/check-link-card-cache.ts
const { missingUrls } = validateLinkCardCache()

if (missingUrls.length > 0) {
  console.error('Missing link card cache entries for the following external URLs:')
  for (const url of missingUrls) {
    console.error(`- ${url}`)
  }
  console.error('Run `pnpm link-card:cache:update` and commit the updated cache file.')
  process.exit(1)
}

typecheck で型の整合性を検証するのと同じ考え方です。生成ファイルをリポジトリにコミットし、CIで最新であることを保証します。

運用

記事に ::card[URL] を追加したら、pnpm link-card:cache:update を実行してキャッシュファイルをコミットします。CIの link-card:cache:check がビルド前に走り、不足があればCIが失敗して通知されます。

ローカルのdev serverとの関係も補足します。loadLinkCardEntries()astro.config.mjs のトップレベルで呼ばれるため、dev server起動時にキャッシュファイルを1回読み込みます。キャッシュファイルはただのJSONの読み取りなので、ネットワーク待ちは発生しません。新しい ::card[外部URL] を記事に追加した場合は、先に pnpm link-card:cache:update でキャッシュを更新してからdev serverを再起動すれば反映されます。既存のキャッシュ済みURLだけを使っている限りは、記事のMarkdownを編集してもdev serverの再起動は不要で、ホットリロードでそのまま反映されます。

OGPデータは陳腐化しますが、定期的に link-card:cache:update を実行すれば最新化できます。fetchに失敗しても既存のキャッシュは保持されるため、外部サイトの一時的な障害に影響されません。

リンクカードの実装自体は1日で完了しましたが、CIからのDoSリスクに気づいてキャッシュ方式に切り替えるまでが、この機能の本質的な設計判断でした。

今後の展望

現状のキャッシュ方式はビルドの安定性を確保できていますが、いくつか改善したい点があります。

1つ目は、ローカル開発時の動的取得です。dev server起動時にキャッシュにないURLがあれば、その場でOGPをfetchしてカードを表示できると、link-card:cache:update を手動で実行する手間がなくなります。CIではキャッシュのみ、ローカルではキャッシュ + 動的fetchというハイブリッド方式です。

2つ目は、差分アップデートです。現在の link-card:cache:update は全URLを対象にfetchしますが、キャッシュに存在しないURLだけをfetchする差分更新にすれば、実行時間とリクエスト数を減らせます。

3つ目は、CIでのキャッシュ自動更新です。定期実行のGitHub Actionsで link-card:cache:update を走らせ、差分があればPRを自動作成する仕組みにすれば、OGPデータの陳腐化を防げます。