目次13

@astrojs/sitemap は MDX frontmatter の updatedDate を読まない。

何もしないと、Aulvem の sitemap.xml は各記事の lastmod をビルド時刻にしてしまう。「全記事が毎ビルド更新されている」というノイズシグナルを検索エンジンに送ることになって、鮮度判定で逆効果になる。

Aulvem では astro.config.mjs の中で MDX を直接走査して、updatedDate ?? pubDate をパスごとに引ける Map を作り、@astrojs/sitemapserialize で差し込む構成にした。ついでに paginated noindex ページの除外も filter でやっている。これは Aulvem ブログの作り で予告した派生記事の 2 本目です。

lastmod がなぜ効くのか

lastmod は Google / Bing の sitemap プロトコルで「最終更新日」として渡る値で、検索エンジンが再クロールの優先度を決めるヒントとして読む。AI 検索(ChatGPT search / Perplexity / Claude search)も引用元の最新性根拠としてこの値を見にくる挙動が報告されている。

つまり lastmod は次の 2 つの局面で効いている。

  • クロール優先度: 直近で更新された URL を優先的に再取得させたい
  • AI 検索の引用判定: 「数日前に書かれた最新情報」として引用されやすくする

逆に、ここを嘘で塗ると逆効果になる。Google 公式の Sitemap docs も「lastmod の値が信用できないとサイト全体の信頼度を下げる」と書いている。実態と乖離した更新日を流せば、検索エンジンに「このサイトの lastmod は信用ならない」と学習させるだけなので、運用ルール(content-rules.md A-2-1)と一緒に締めておく必要がある。

ビルド時に MDX を走査して lastmod マップを作る

astro.config.mjs の中で async 関数を走らせて、src/content/blog/{en,ja}/ 配下の MDX を全部読む。

import { readdir, readFile } from "node:fs/promises";
import { join } from "node:path";

async function buildBlogLastmodMap() {
  const map = new Map();
  for (const lang of ["en", "ja"]) {
    const dir = join(process.cwd(), "src", "content", "blog", lang);
    let files = [];
    try {
      files = await readdir(dir);
    } catch {
      continue;
    }
    for (const file of files) {
      if (!file.endsWith(".mdx") && !file.endsWith(".md")) continue;
      const slug = file.replace(/\.(mdx|md)$/, "");
      const raw = await readFile(join(dir, file), "utf8");
      const fm = /^---\n([\s\S]*?)\n---/.exec(raw);
      if (!fm) continue;
      const front = fm[1];
      if (/^draft:\s*true/m.test(front)) continue;
      const updated = /^updatedDate:\s*(\S+)/m.exec(front);
      const pub = /^pubDate:\s*(\S+)/m.exec(front);
      const dateStr = (updated && updated[1]) || (pub && pub[1]);
      if (!dateStr) continue;
      const d = new Date(dateStr);
      if (Number.isNaN(d.getTime())) continue;
      const path = lang === "ja" ? `/ja/blog/${slug}/` : `/blog/${slug}/`;
      map.set(path, d.toISOString());
    }
  }
  return map;
}

const blogLastmod = await buildBlogLastmodMap();

ここで getCollection("blog") のような Content Collections API を使わないのは、astro.config.mjscontent loader より早く評価される からだ。config が走っている時点では Content Collections 側はまだ初期化されておらず、API が存在しない。

frontmatter は軽量 regex で読んでいる。MDX の構造を fully parse する必要はなく、欲しいのは updatedDatepubDate の 2 行だけなので、依存を増やすメリットがない。型安全ではないが、型検証は本ビルド時に Zod schema が走るので、二重に網が張られている形になる。

draft: true の MDX はここで除外する。これを忘れると下書きの URL がサイトマップに乗ってしまうので、フィルタの位置が大事だと思う。

serialize でマップから lastmod を流す

@astrojs/sitemapserialize オプションは、生成された各 URL エントリを書き換えるフック。マップから lookup して item.lastmod に詰める。

sitemap({
  i18n: {
    defaultLocale: "en",
    locales: { en: "en", ja: "ja" },
  },
  serialize(item) {
    const url = new URL(item.url);
    // /ja/blog/foo/ も /blog/foo/ も同じ slug なので、
    // /ja/ プレフィクスを剥がして path 種別だけで分岐する
    const pathname = url.pathname.replace(/^\/ja\//, "/").replace(/^\/ja$/, "/");
    if (pathname === "/") {
      item.changefreq = "daily";
      item.priority = 1.0;
    } else if (pathname === "/blog/") {
      item.changefreq = "daily";
      item.priority = 0.9;
    } else if (pathname.startsWith("/blog/")) {
      item.changefreq = "monthly";
      item.priority = 0.7;
      // ja URL も en URL も blogLastmod の key は元の url.pathname のまま
      const lastmod = blogLastmod.get(url.pathname);
      if (lastmod) item.lastmod = lastmod;
    } else if (pathname.startsWith("/products/")) {
      item.changefreq = "monthly";
      item.priority = 0.8;
    } else {
      item.changefreq = "monthly";
      item.priority = 0.5;
    }
    return item;
  },
});

changefreqpriority もパス種別ごとに分けて与えている。実は priority は Google が「無視している」と公言しているフィールドだが、Bing と他検索エンジン・AI クローラーは依然読みに来るので、整合の取れた値を出しておく方針にした。

/ja/ プレフィクスを剥がして path 種別だけで分岐するのは、JA と EN で同じカテゴリの記事に同じ changefreq / priority を当てるためだ。lastmod の lookup には元の url.pathname/ja/blog/foo/ を含む)を使うので、Map のキーと整合する。

paginated noindex ページを filter で外す

ページ送りの 2 ページ目以降(/blog/build/2/ /blog/reviews/3/ など)には <meta name="robots" content="noindex, follow"> を入れている。1 ページ目だけがインデックス対象で、2 ページ目以降は「クロールはしてほしいがインデックスはいらない」状態にしておきたいからだ。

このとき、sitemap で 2 ページ目以降の URL も送ってしまうと mixed signal になる。「sitemap に載せた = インデックスしてほしい」と「meta robots = noindex」が同時に立つので、Google も Bing もこの矛盾を SEO 品質スコアに反映してくる。

filter で除外する。

sitemap({
  filter: (page) => {
    if (page.endsWith("/404/") || page.endsWith("/404")) return false;
    // Paginated category pages (/blog/build/2/, /blog/reviews/3/ ...)
    // are noindex, follow — drop them from the sitemap to avoid
    // contradicting the meta robots tag.
    if (/\/blog\/(build|reviews)\/\d+\/?$/.test(new URL(page).pathname)) return false;
    return true;
  },
  // ...
});

paginated 系を noindex にする運用と sitemap 除外はセットで考えないといけない。片方だけ直すと信号がねじれます。

全体の流れ

flowchart LR
  MDX[src/content/blog/&lt;lang&gt;/&lt;slug&gt;.mdx]
  Map[blogLastmod Map<br/>path → ISO date]
  Pages[@astrojs/sitemap<br/>URL リスト]
  Filter[filter:<br/>noindex 除外]
  Serialize[serialize:<br/>lastmod / changefreq / priority]
  SM[sitemap.xml]

  MDX -->|fs.readdir + regex| Map
  Map -->|lookup| Serialize
  Pages --> Filter
  Filter --> Serialize
  Serialize --> SM

astro.config.mjs の中で 2 つの経路が走る。MDX 走査経路でビルド前に Map を作り切り、Astro 本体のルーティングが生成する URL リストを filter で間引いて、serialize で Map と突き合わせて lastmod を当てる。最終的に sitemap.xml が出力される。

落とし穴と運用ルール

実装で踏んだ・踏みかけた話を整理しておく。

  • top-level await: astro.config.mjsawait buildBlogLastmodMap() を呼ぶには、config が ESM で評価される前提が要る。Astro 5 系はこれが効く前提なのでそのまま動くが、古い構成だと .mjs に揃えるか defineConfig を return する async function に包む必要がある
  • draft: true 除外: lastmod Map を作る段階で draft を抜かないと、下書き URL もサイトマップに混入する
  • regex の緩さ: /^updatedDate:\s*(\S+)/mupdatedDate: 2026-05-25 のような単純行を想定している。YAML 引用符 "..." を使う場合は (\S+)"2026-05-25" がそのまま入るので、new Date() がパースできるかは要検証
  • 言語別フォルダの統合: enja を別ループで走らせて 1 つの Map に統合する。キーは /blog/<slug>//ja/blog/<slug>/ を別物として持つ
  • updatedDate の運用基準: lastmod の実装を整えても、updatedDate を雑に打ち替えれば信頼度は崩れる。Aulvem は「実質改稿のときだけ更新、注記追加だけ・誤字修正だけでは打たない」のルールを content-rules.md A-2-1 で固めている

何を諦めたか / 公式統合に戻すべき境界

この実装は @astrojs/sitemap に PR を出すべき話だとも思う。Content Collections と sitemap を連動させる機能は他の Astro ユーザーにも需要があるはずで、汎用化する余地はある。

今は Aulvem 内に閉じて 30 行に収まる実装で済んでいるので、当面は config 内完結のままにしている。@astrojs/sitemap が将来 Content Collections 連携を入れた時点でこの実装は剥がせるはずなので、剥がしやすい形に保っておくのが当面のスタンスです。

まとめ

lastmod は SEO + AI 検索の両方で効く鮮度シグナルなので、ビルド時刻で塗らずに updatedDate の実日付を流す。paginated noindex ページは sitemap から除外して信号矛盾を作らない。この 2 つはセットで運用すると、サイトマップが「信頼できる更新情報の集合」になります。

実装の経路は別記事に書いた Zod schema で運用ルールを強制する と地続きで、frontmatter を単一情報源として扱う設計の派生です。

よくある質問

Astro 公式の sitemap が updatedDate を読まないのはなぜ?

@astrojs/sitemap はビルド済みの URL リストを受け取って sitemap.xml を出力するだけで、Content Collections の frontmatter まで触りに行きません。MDX の構造に踏み込むと collection 名・型・loader 種別への依存が増えるので、汎用統合の責務として持たない設計に見えます。

Content Collections の API を使わずに fs.readdir で読むのは型安全ではないのでは?

型安全ではありません。ただし astro.config.mjs は Content Collections の loader より先に評価されるので、ここでは API が未初期化です。frontmatter の中で必要なのは updatedDatepubDate だけなので、軽量 regex で読む実装に倒しました。型検証は Zod schema 側で本ビルドのときに走るので、二重に網は張られています。

noindex のページを sitemap に送ってはダメなのか?

信号としてダメです。sitemap は「インデックスしてほしい URL のリスト」なので、noindex の URL を載せると mixed signal になります。Google も Bing も「sitemap で送ったのに noindex」という矛盾を見ると、サイト全体の品質シグナルとして減点する余地が出ます。

lastmod を実日付にすると AI 検索の引用率は本当に変わるのか?

数値での検証はしていません。ただ Google・Bing・各 AI 検索ベンダーのドキュメントはどれも lastmod を鮮度判定のヒントとして挙げており、嘘の lastmod は信頼を下げると明記されています。引用率の上振れより、雑な運用で評価を下げないための保険として考えるのが現実的だと思います。