サイトマップの lastmod を毎ビルド更新すると「更新詐欺」になる

  • #Astro
  • #SEO
  • #サイトマップ
  • #lastmod
  • #静的サイト
  • #検索エンジン
サイトマップの lastmod を毎ビルド更新すると「更新詐欺」になる

この記事について

Astro の静的サイトで <lastmod> 付きのサイトマップを正確に出す実装メモ。@astrojs/sitemap<lastmod> を出力しないため自前実装が必要だが、素直に書くとビルド日時が入り「全記事が毎日更新された」と申告することになる。updatedDate ?? pubDate という frontmatter 由来の日付を使い、2日付運用と組み合わせることで、実際の更新に連動したサイトマップを出せるようにした。

実装ステータス: 実装済み

@astrojs/sitemap の盲点

Astro には @astrojs/sitemap という公式インテグレーションがある。astro.config.mjs に数行追加するだけでサイトマップが自動生成されるため、多くの Astro サイトで採用されている。

// astro.config.mjs
import sitemap from '@astrojs/sitemap';
export default defineConfig({
  integrations: [sitemap()],
});

ただし、このインテグレーションは <lastmod> を出力しない。生成されたサイトマップは次のような形になる。

<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
  <url>
    <loc>https://example.com/articles/foo/</loc>
    <!-- lastmod が無い -->
  </url>
</urlset>

<lastmod> がない状態は「いつ更新されたか不明」を意味する。Google をはじめとする検索エンジンは <lastmod> を更新シグナルとして使うため、これは「更新情報がゼロ」という状態になる。記事を書き直してもクロールの優先度に反映されない。

自前実装で陥りやすい罠

<lastmod> を出すために src/pages/sitemap.xml.ts として自前実装するのが一般的な対処だが、ここで典型的なミスが発生する。

// ❌ やりがちな実装(NG)
export async function GET(context) {
  const posts = await getCollection('articles', ({ data }) => !data.draft);
  const today = new Date().toISOString().slice(0, 10); // ビルド日時を入れてしまう
  const urls = posts.map((post) => ({
    loc: `${context.site.href}${post.id}/`,
    lastmod: today,  // 全記事が今日更新されたことになる
  }));
  // ...
}

このコードはビルドを走らせるたびに全記事の <lastmod> をその日の日付に更新する。デプロイのたびに「全記事が今日更新された」と申告することになり、実際には変更がない記事の <lastmod> も毎日バンプする。

検索エンジンは繰り返される虚偽の更新シグナルを学習し、そのサイトの <lastmod> を参考にしなくなることがある。意図せず「更新詐欺」を行っている状態になる。

正しい実装: frontmatter 由来の日付を使う

本来の <lastmod> は「このURLのコンテンツが最後に変更された日付」を表す。Astro であれば frontmatter の updatedDate または pubDate を使うのが正解になる。

// ✅ 正しい実装
export async function GET(context) {
  const base = context.site.href.replace(/\/$/, '');

  // draft と外部リダイレクト(legacyUrl)は除外
  const posts = await getCollection('articles', ({ data }) => !data.draft && !data.legacyUrl);
  const articleUrls = posts.map((post) => ({
    loc: `${base}/${post.id}/`,
    lastmod: (post.data.updatedDate ?? post.data.pubDate).toISOString().slice(0, 10),
  }));

  const staticUrls = [
    { loc: `${base}/` },
    { loc: `${base}/about/` },
  ];

  const urls = [...staticUrls, ...articleUrls];
  const body = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urls.map((u) =>
  `  <url><loc>${u.loc}</loc>${u.lastmod ? `<lastmod>${u.lastmod}</lastmod>` : ''}</url>`
).join('\n')}
</urlset>`;

  return new Response(body, {
    headers: { 'Content-Type': 'application/xml; charset=utf-8' },
  });
}

updatedDate ?? pubDate は「更新日があればそれを、なければ公開日を使う」という意味になる。記事を書き直したときだけ updatedDate を frontmatter に追記することで、サイトマップの <lastmod> が実際の更新に連動する。

2日付運用との組み合わせ

この実装が機能するためには、pubDateupdatedDate を別々の概念として運用することが前提になる。

---
title: "記事タイトル"
pubDate: 2026-06-01          # 最初に公開した日。固定。後から変えない
updatedDate: 2026-07-15      # 内容を変更したときだけ追記
---
  • pubDate: 記事を最初に公開した日付。固定。後から変更しない
  • updatedDate: 記事の内容を変更した日付。編集したときだけ追記

WordPress の一部プラグインは投稿の再保存で pubDate を更新日で上書きするが、それをやると「古い記事として公開されている」という性質が消える。pubDate は作成日のまま固定し、変更は updatedDate に別記する。

コンテンツを変更していないのに updatedDate を追記するのも誤りになる。「記事を再デプロイした」「typo を直した」「サイトのレイアウトを変えた」などはいずれも updatedDate を更新する正当な理由にならない。コンテンツの実質的な変更があったときだけ追記する。

サイトマップから除外すべきもの

カスタム実装のついでに整理しておくべき点がある。

draft: true の記事: 未公開コンテンツはサイトマップに含めない。URL が実際に存在しないか、noindex になっているため。

外部リダイレクトを持つ記事(legacyUrl: 旧URLが別の場所に移設されて 301 リダイレクトが設定されている場合、旧 URL はサイトマップから除外する。リダイレクト先の URL がインデックスされるため、旧 URL を含めると重複コンテンツとして扱われる可能性がある。

// draft と外部リダイレクトを両方除外
const posts = await getCollection('articles', ({ data }) => !data.draft && !data.legacyUrl);

確認方法

デプロイ後にサイトマップを確認する方法は2つある。

  1. https://your-domain.com/sitemap.xml に直接アクセスして <lastmod> が出ているか確認する
  2. Google Search Console の「サイトマップ」から取り込み結果を確認する

GSC には <lastmod> を受け取ったかどうかの直接の確認画面はないが、URL の「最終クロール日」が実際の更新日に近い値になっていれば機能している目安になる。


動作環境: Astro 5.x
更新履歴: 2026-07-23 — 初稿
訂正履歴: なし