Cloudflare Pages上でビルド時に外部サイトのOGP情報が取れなくなる問題


DE-TEIUです。

2025/01/02の記事にて、ビルド時にリンク先のOGP情報を取ってくるコンポーネントを作ったという話をしました。 しかし実はこれ、とある問題を抱えていたので今日はその話をします。

発生した問題

このブログは、Cloudflare Pages で動かしています。デプロイ時にnpm run buildコマンドが実行され、その結果が静的なWebサイトとして出力されるような建付けです。

で、このビルド実行時にこんなエラーがたまに出ます。

23:57:59.858	14:57:59 ▶ src/pages/blog/[...slug].astro
23:58:00.508	14:57:59   ├─ /blog/20250102/index.htmlCannot read properties of undefined (reading 'find')
23:58:00.508	  Hint:
23:58:00.508	    This issue often occurs when your MDX component encounters runtime errors.
23:58:00.509	  Stack trace:
23:58:00.509	    at findOGImage (file:///opt/buildhome/repo/dist/chunks/OGPItem_DzdhjgcR.mjs:44:32)
23:58:00.509	    at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
23:58:00.509	    at async renderToString (file:///opt/buildhome/repo/dist/chunks/astro/server_DWQj7351.mjs:1192:26)
23:58:00.509	    at async Promise.all (index 36)
23:58:00.509	    at async renderJSXVNode (file:///opt/buildhome/repo/dist/chunks/astro/server_DWQj7351.mjs:1898:20)
23:58:00.540	Failed: Error while executing user command. Exited with error code: 1
23:58:00.551	Failed: build command exited with code: 1
23:58:01.717	Failed: error occurred while running build command

これ、「外部サイトのOGPを取得しに行ってるコンポーネントが何か怪しいなぁ~~~」と思って軽く調べてみたところ、 どうやらビルド中に外部サイトにGETリクエストを投げた時に、500 Internal Server Error が返ってきているようだった。 しかもこれ、開発環境では一切発生せず、Cloudflare上でビルドしている時だけ発生するんですよね。 おまけに毎回発生する訳でもないので、何回かビルドを再実行すると無事に完了してしまう。何だこれは。

対策① 500 Internal Server Errorエラーが返ってきたら、catchして再実行するように変えてみよう

がっ‥!駄目っ‥! (まぁそんな気はしていた)

これもしかしたら、「ビルド中、怪しいbot的な使い方してそうな挙動を見つけたら処理を中断する」 みたいなことをCloudflareが気を利かせてやってる可能性あるな。 あるいは、GETリクエストを受け取った外部サイト側で 「怪しいリクエストが飛んできたら500を返す」 とかやってたりするのかね? やっててもまぁおかしくはないな。 (OGPを参照するために1回GETリクエスト投げるだけだから普通に毎回受け付けてくれよ、とは思う)

軽く調べたがはっきりとした原因は不明。他の手段も考えてみよう。

対策② ローカルでOGP情報を取得して、jsonファイルにまとめる処理を追加する

これならいける。こうやる。 元々実装済のOGP取得処理を、別ファイルにまとめて移送。

prepare-ogp.ts

import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
import openGraphScraper from "open-graph-scraper";
import type {
  ImageObject,
  OpenGraphScraperOptions,
} from "open-graph-scraper/types";

// パス定数
const PROJECT_ROOT = path.resolve(
  path.dirname(fileURLToPath(import.meta.url)),
  ".."
);
const BLOG_DIR = path.join(PROJECT_ROOT, "src", "content", "blog");
const OUTPUT_FILE = path.join(PROJECT_ROOT, "src", "resources", "OGP.json");

const USER_AGENT =
  "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
const NO_IMAGE = "/noimage.png";

interface OGPCacheEntry {
  ogTitle: string;
  ogDescription: string;
  ogImage: string;
}

type OGPCache = Record<string, OGPCacheEntry>;

/**
 * MDXファイルからOGPItemのurl属性を抽出する
 */
function extractOGPItemUrls(content: string): string[] {
  const regex = /<OGPItem\s+url=["']([^"']+)["']\s*\/>/g;
  return [...content.matchAll(regex)].map((match) => match[1]);
}

/**
 * 全MDXファイルからOGPItem URLを収集する
 */
function collectUrlsFromMdxFiles(): Set<string> {
  const urls = new Set<string>();
  const files = fs
    .readdirSync(BLOG_DIR)
    .filter((file) => file.endsWith(".mdx"));

  for (const file of files) {
    const content = fs.readFileSync(path.join(BLOG_DIR, file), "utf-8");
    extractOGPItemUrls(content).forEach((url) => urls.add(url));
  }

  return urls;
}

/**
 * amzn.to短縮URLを展開する
 */
async function expandAmazonShortUrl(shortUrl: string): Promise<string> {
  if (!shortUrl.includes("amzn.to")) {
    return shortUrl;
  }

  // レート制限対策で1〜5秒待機
  await new Promise((resolve) =>
    setTimeout(resolve, Math.random() * 4000 + 1000)
  );

  try {
    const response = await fetch(shortUrl, {
      method: "HEAD",
      redirect: "follow",
      headers: { "User-Agent": USER_AGENT },
    });
    return response.url;
  } catch {
    return shortUrl;
  }
}

/**
 * Amazon商品に最適なOGP画像を選択する
 */
function findOGImage(ogImages: ImageObject[] | undefined, url: string): string {
  if (!ogImages?.length) {
    return NO_IMAGE;
  }

  if (!url.includes("amzn.to")) {
    return ogImages[0].url;
  }

  // Amazon画像は特定のパターンを持つものを優先
  const amazonImage = ogImages.find(
    ({ url }) =>
      url.includes("/I/") && url.includes("_SX") && url.includes("_SY")
  );
  return amazonImage?.url ?? NO_IMAGE;
}

/**
 * URLからOGP情報を取得する
 */
async function fetchOGPData(url: string): Promise<OGPCacheEntry> {
  const expandedUrl = await expandAmazonShortUrl(url);

  const options: OpenGraphScraperOptions = {
    url: expandedUrl,
    onlyGetOpenGraphInfo: false,
    timeout: 10000,
    fetchOptions: {
      headers: { "User-Agent": USER_AGENT },
    },
  };

  const { result } = await openGraphScraper(options);

  return {
    ogTitle: result.ogTitle ?? "",
    ogDescription: result.ogDescription ?? "",
    ogImage: findOGImage(result.ogImage, url),
  };
}

/**
 * OGPキャッシュファイルを読み込む
 */
function loadCache(): OGPCache {
  if (!fs.existsSync(OUTPUT_FILE)) {
    return {};
  }
  return JSON.parse(fs.readFileSync(OUTPUT_FILE, "utf-8")) as OGPCache;
}

/**
 * OGPキャッシュファイルを保存する
 */
function saveCache(cache: OGPCache): void {
  const outputDir = path.dirname(OUTPUT_FILE);
  if (!fs.existsSync(outputDir)) {
    fs.mkdirSync(outputDir, { recursive: true });
  }
  fs.writeFileSync(OUTPUT_FILE, JSON.stringify(cache, null, 2), "utf-8");
}

/**
 * メイン処理
 */
async function main(): Promise<void> {
  const allUrls = collectUrlsFromMdxFiles();
  const existingCache = loadCache();
  const urlsToFetch = [...allUrls].filter((url) => !existingCache[url]);

  if (urlsToFetch.length === 0) {
    return;
  }

  const ogpData: OGPCache = { ...existingCache };

  for (const url of urlsToFetch) {
    ogpData[url] = await fetchOGPData(url);
  }

  saveCache(ogpData);
}

main();

package.jsonのscriptsにコマンドを追加。上記の処理をtsxで実行できるように。

  "prepare": "tsx scripts/prepare-ogp.ts"

で、実行すると、

  1. プロジェクト内の全記事ファイル(*.mdx)を覗きに行って、
  2. OGPItemコンポーネント(OGP表示用に作ったやつ)を使っている箇所からURLを引っこ抜き、
  3. それらのURLにHTTPリクエストを投げてOGP情報を取ってくる。

ということで、最終的に生成されるjsonファイルを見てみると、

{
  "https://amzn.to/3KKiYuU": {
    "ogTitle": "Amazon.co.jp: キーエンス解剖 最強企業のメカニズム eBook : 西岡 杏: Kindleストア",
    "ogDescription": "Amazon.co.jp: キーエンス解剖 最強企業のメカニズム eBook : 西岡 杏: Kindleストア",
    "ogImage": "https://m.media-amazon.com/images/I/51GmsUA8QGL._SY445_SX342_QL70_ML2_.jpg"
  },
  "https://forceoutput.connpass.com/event/372351/": {
    "ogTitle": "【札幌現地+オンライン開催】力強くブログを108記事アウトプットする日の 20251227 (2025/12/27 09:00〜)",
    "ogDescription": "# 108 本のブログが集まれば煩悩は消える  なんと今年も札幌現地で「みんなでブログ記事 108 本書くまで帰れま 108」をやることになりました.   \"帰れない\" の定義はぼくが決めるので,身体拘束等の拷問については行われない想定です.   また,オンライン会場はいつもどおり Discord に用意しますので,全銀河各地から参加可能です.      みんなで記事を書いて 108 の煩悩を打ちまかそう!!!   ※ 1 本書いておしまいでもいいですし,ひとりで 108 本書きたかったら書いていいです      あと,一応技術系のコミュニティということにしているので,それに関連すること...",
    "ogImage": "https://media.connpass.com/thumbs/b4/4f/b44fdefb22dd0acb4e6b618521b9fc2c.png"
  },
  "https://reflection-photo-generator.vercel.app/": {
    "ogTitle": "映り込みジェネレータ",
    "ogDescription": "ガラスやディスプレイに撮影者が映り込んでいる風の写真を作成できるぞ!!",
    "ogImage": "https://reflection-photo-generator.vercel.app/ogpimage.png"
  }
}

こんな感じで取れています。あとはOGPを表示する時にこのファイルを参照するように変えて完了。

こうすれば、Cloudflare上でビルドする際にOGPを取り直す必要がなくなるので、当初の問題は発生しなくなります。 (ついでにビルド時間は短くなった)