---
title: "ビルドのたびにOGPを取りに行くリンクカード、CIからDoSになりかけた話"
description: "Astroのremarkプラグインでリンクカードを自作しました。ビルド時にOGPメタデータを毎回fetchする設計にしたところ、GitHub ActionsからのビルドがDoS的な負荷になりかけたため、キャッシュをリポジトリにコミットする方式に切り替えた経緯を紹介します。"
category: "Web"
tags: ["Astro","remark","リンクカード","OGP","TypeScript","CI/CD"]
publishedAt: "2026-04-16"
lastmod: "2026-08-04"
---

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

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

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

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

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

1つ目は、内部リンクの扱いです。labee.jpではブログ記事とニュース記事が[Content Collection](https://docs.astro.build/en/guides/content-collections/)で管理されており、内部リンクのカード情報はfrontmatterから取得できます。外部リンクとは取得元が異なるため、既存プラグインの「すべてOGPで取得」という設計では合いませんでした。

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

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

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

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

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

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

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

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

## 最初はビルド時にOGPをfetchしていた

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

```typescript
// 当初の実装（簡略化）
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](https://github.com/features/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ファイルとしてリポジトリにコミットする方式に変更しました。

```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画像はそのまま残ります。

1つのURLについて、3つのフィールドがそれぞれどう扱われるかは次の通りです。

| フィールド | fetch 成功 | fetch 失敗 |
|---|---|---|
| `entry` | 取得したデータで上書き | 既存のキャッシュを引き継ぐ |
| `fetchedAt` | 現在時刻に更新 | 既存の値のまま |
| `lastAttemptAt` | 現在時刻に更新 | 現在時刻に更新 |

失敗した列で現在時刻に変わるのは `lastAttemptAt` だけです。表示に使う `entry` は触られないので、カードの見た目は前回成功時のまま保たれます。

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

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

```typescript
// 変更後（簡略化）
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` をワークフローに組み込みました。

```typescript
// 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データの陳腐化を防げます。