Use Cases (更新: 2026/6/6)

サイトマップを毎ビルド今日付にして順位を落とした話とClaude Code自動生成

lastmodを全URL今日付にしてGoogleの信頼を失った失敗から学ぶ。Claude CodeでXMLサイトマップを多言語hreflang込みで自動生成する手順を実体験で解説。

サイトマップを毎ビルド今日付にして順位を落とした話とClaude Code自動生成

ある日、Search Consoleを開いたら全記事の「最終更新日」がそろって昨日になっていました。

ビルドのたびに sitemap.xml を作り直すスクリプトを組んだら、本文を1文字も触っていない記事まで、毎回その日の日付が入る作りになっていたんです。100本以上の記事が、毎日「今さっき更新しました」とGoogleに申告し続けていた。数週間後、新しく公開した記事のインデックスが目に見えて遅くなりました。

「全部更新しました」と毎日叫ぶサイトを、Googleが信用しなくなった。当然です。狼少年と同じことを、僕は機械にやらせていたわけです。

サイトマップは「作って置けば終わり」のファイルだと思っていると、こういう地味な事故を踏みます。今日はこの失敗を起点に、Claude Codeでサイトマップを自動生成するときに本当に効くポイントを、コード込みで書きます。

この記事の要点

  • サイトマップは検索エンジンに「これが正規URLです」と渡す台帳。インデックスを保証する魔法のファイルではない。
  • lastmod を毎ビルドの日付で一律更新すると、更新日の信頼を失ってクロールが遅くなる。実際に本文が変わった日だけ入れる。
  • Astroなら公式の @astrojs/sitemap が最短。更新日や多言語を厳密に握りたいなら、Node.jsの自作スクリプトで「公開対象の収集」を明示する。
  • 多言語の hreflang は片方向だとほぼ効かない。日本語ページにも英語ページにも、自分自身を含む全言語の対応表を書く。
  • 生成して終わりにせず、robots.txt 掲載・Search Console送信・HTTP 200とXML整形の検証まで含めて初めて運用になる。

そもそもサイトマップは何のためにあるのか

サイトマップは、検索エンジンに「うちにはこういうページがあって、これが正規URLです」と渡す台帳です。

Google Search Centralの説明でもはっきり書かれていますが、これは登録を保証する魔法のファイルではありません。「検索結果に出したい正規URL」を伝え、更新日や多言語版の関係を補足するためのヒントにすぎない。だから優先すべきは、毎回それっぽいXMLを手で書くことではなく、公開対象・正規URL・lastmodhreflang・分割ルール・検証手順を、いつも同じ基準で吐き出す仕組みを作ることです。

ここを人力でやると、記事を1本足すたびに sitemap.xml へ追記し忘れる、言語版の対応表が片方向になる、消した記事のURLが残る、といった抜け漏れが必ず起きます。僕も最初は手で直していて、3回目で心が折れました。だからこそClaude Codeに任せる価値がある領域なんです。

この記事では、Astroの公式連携を使う方法と、Node.jsだけで動く自作スクリプトの両方を紹介します。さらに10言語のような多言語サイト、記事数が膨らんだサイト、Search Consoleで失敗を見つける運用まで、ClaudeCodeLabの公開前チェックに沿って実務寄りでまとめます。サイトマップをSEO全体の一部として見直すなら、先に Claude CodeでSEOを改善する実践ガイド を読んでおくと、どこに手をかけるべきか判断しやすくなります。

頼む前に固定しておく公式仕様

最初に仕様を固定します。ここが曖昧なままClaude Codeへ「サイトマップ作って」と丸投げすると、古いSEO記事に残っている priority やping送信を、そのまま再現してしまうことがあるからです。AIは「世間でよく書かれている形」を平気で持ってきます。だから先回りして縛る。

項目実務での判断
URL相対パスではなく https://example.com/page/ のような絶対URLを書く
文字コードUTF-8で保存し、XML内の &<" などはエスケープする
lastmod本文、構造化データ、重要リンクなどが実際に変わった日だけ更新する
changefreq / prioritysitemaps.orgには要素があるが、Googleは使わないので省略してよい
ファイル上限1サイトマップは50,000 URLまたは非圧縮50 MBまで
大規模サイト複数ファイルに分割し、サイトマップインデックスをSearch Consoleへ送る
通知方法Googleのサイトマップpingは廃止済み。robots.txt とSearch Consoleで知らせる

参照元は Googleのサイトマップ作成ガイドGoogleのping廃止告知sitemaps.orgのプロトコル です。古いコード例で https://www.google.com/ping?sitemap=... を見かけても、2023年6月26日の告知以降はもう使わない。冒頭の僕の事故のうち半分は、この「lastmod は本当に変わった日だけ」を軽く見ていたのが原因でした。

Astro公式連携で静的ページを漏れなく出す

Astroで普通のページやブログを静的生成しているなら、まずは公式の @astrojs/sitemap が最短です。自作する前に、これで足りないか確かめるのが先。

Claude Codeへ依頼するときは「Astro公式連携を使い、site を必ず設定し、下書きや検索対象外のURLを除外して」と指定します。こう言わないと、なぜか独自XMLを一から書き始めることがあるので、最初に釘を刺しておく。

npx astro add sitemap
// astro.config.mjs
import { defineConfig } from 'astro/config';
import sitemap from '@astrojs/sitemap';

export default defineConfig({
  site: 'https://claudecodelab.com',
  integrations: [
    sitemap({
      filter: (page) => !page.includes('/draft/') && !page.includes('/preview/'),
      i18n: {
        defaultLocale: 'ja',
        locales: {
          ja: 'ja',
          en: 'en',
          zh: 'zh-CN',
          ko: 'ko',
          es: 'es',
          fr: 'fr',
          de: 'de',
          pt: 'pt-BR',
          hi: 'hi',
          id: 'id',
        },
      },
    }),
  ],
});

この設定で astro build の出力にサイトマップインデックスと分割ファイルができます。Astro公式ドキュメントでも、sitehttp:// または https:// で始まる公開URLとして設定する必要があると明記されています。ここにローカルの localhost やステージングURL、末尾スラッシュの揺れが混ざると、Search Consoleで「送信されたURLが正規URLとして選択されていない」といった不毛な調査が増えます。僕は一度、site をステージング用のまま本番ビルドして、全URLが別ドメインで出力された経験があります。ビルドは通るので気づきにくい。

ただし、公式連携だけでは記事frontmatterの updatedDate をどこまで反映するかはサイト設計に依存します。更新日を厳密に扱いたいメディア、10言語の記事を同じslugで束ねたいサイト、商品データベースからURLを出したいサイトでは、次のNode.jsスクリプトのように「公開対象の収集」を自分の手で明示したほうが、後々ラクになります。

Node.jsだけで多言語サイトマップを生成する

次のコードは依存パッケージなしで動く generate-sitemap.mjs です。site/src/content/blog を日本語、blog-en を英語、blog-zh を中国語、というように言語別ディレクトリを持つ構成を想定しています。lastmodupdatedDate があればそれを優先し、なければ pubDate、それもなければファイルの更新時刻を使います。冒頭の事故を二度と起こさないための優先順位です。

// scripts/generate-sitemap.mjs
import { mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises';
import path from 'node:path';

const SITE_URL = (process.env.SITE_URL ?? 'https://example.com').replace(/\/$/, '');
const OUT_DIR = 'public';
const OUT_FILE = path.join(OUT_DIR, 'sitemap.xml');

const collections = [
  { dir: 'site/src/content/blog', prefix: '/blog', hreflang: 'ja' },
  { dir: 'site/src/content/blog-en', prefix: '/en/blog', hreflang: 'en' },
  { dir: 'site/src/content/blog-zh', prefix: '/zh/blog', hreflang: 'zh-CN' },
  { dir: 'site/src/content/blog-ko', prefix: '/ko/blog', hreflang: 'ko' },
  { dir: 'site/src/content/blog-es', prefix: '/es/blog', hreflang: 'es' },
  { dir: 'site/src/content/blog-fr', prefix: '/fr/blog', hreflang: 'fr' },
  { dir: 'site/src/content/blog-de', prefix: '/de/blog', hreflang: 'de' },
  { dir: 'site/src/content/blog-pt', prefix: '/pt/blog', hreflang: 'pt-BR' },
  { dir: 'site/src/content/blog-hi', prefix: '/hi/blog', hreflang: 'hi' },
  { dir: 'site/src/content/blog-id', prefix: '/id/blog', hreflang: 'id' },
];

// XMLに入れてはいけない記号を実体参照に置き換える門番
function escapeXml(value) {
  return String(value).replace(/[<>&'"]/g, (char) => ({
    '<': '&lt;',
    '>': '&gt;',
    '&': '&amp;',
    "'": '&apos;',
    '"': '&quot;',
  })[char]);
}

// ディレクトリを再帰的にたどって md / mdx だけ拾う
async function* walk(dir) {
  let items;
  try {
    items = await readdir(dir, { withFileTypes: true });
  } catch (error) {
    if (error.code === 'ENOENT') return; // 言語ディレクトリが無くても落とさない
    throw error;
  }

  for (const item of items) {
    const fullPath = path.join(dir, item.name);
    if (item.isDirectory()) {
      yield* walk(fullPath);
    } else if (/\.(md|mdx)$/.test(item.name)) {
      yield fullPath;
    }
  }
}

function frontmatterOf(source) {
  return source.match(/^---\n([\s\S]*?)\n---/)?.[1] ?? '';
}

function dateField(frontmatter, key) {
  return frontmatter.match(new RegExp(`^${key}:\\s*["']?(\\d{4}-\\d{2}-\\d{2})`, 'm'))?.[1];
}

function routeSlug(collectionDir, filePath) {
  return path
    .relative(collectionDir, filePath)
    .replace(/\\/g, '/')
    .replace(/\.(md|mdx)$/, '')
    .replace(/\/index$/, '');
}

function encodeRoute(slug) {
  return slug.split('/').map(encodeURIComponent).join('/');
}

async function collectEntries() {
  const bySlug = new Map();

  for (const collection of collections) {
    for await (const filePath of walk(collection.dir)) {
      const source = await readFile(filePath, 'utf8');
      const frontmatter = frontmatterOf(source);
      if (/^draft:\s*true\s*$/m.test(frontmatter)) continue; // 下書きは出さない

      const info = await stat(filePath);
      const slug = routeSlug(collection.dir, filePath);
      // 更新日の優先順位: updatedDate → pubDate → ファイル更新時刻
      const lastmod =
        dateField(frontmatter, 'updatedDate') ??
        dateField(frontmatter, 'pubDate') ??
        info.mtime.toISOString().slice(0, 10);

      const route = `${collection.prefix}/${encodeRoute(slug)}/`;
      const variant = {
        loc: `${SITE_URL}${route}`,
        hreflang: collection.hreflang,
        lastmod,
      };

      const variants = bySlug.get(slug) ?? [];
      variants.push(variant);
      bySlug.set(slug, variants);
    }
  }

  // 同じslugの全言語版を、お互いに alternates として持たせる
  return [...bySlug.values()].flatMap((variants) =>
    variants.map((variant) => ({
      ...variant,
      alternates: variants.map(({ hreflang, loc }) => ({ hreflang, loc })),
    })),
  );
}

function buildSitemap(entries) {
  const urls = entries.map((entry) => `  <url>
    <loc>${escapeXml(entry.loc)}</loc>
    <lastmod>${entry.lastmod}</lastmod>
${entry.alternates.map((alt) => `    <xhtml:link rel="alternate" hreflang="${escapeXml(alt.hreflang)}" href="${escapeXml(alt.loc)}" />`).join('\n')}
  </url>`).join('\n');

  return `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
        xmlns:xhtml="http://www.w3.org/1999/xhtml">
${urls}
</urlset>
`;
}

const entries = await collectEntries();
if (entries.length === 0) {
  throw new Error('サイトマップに出力するURLがありません。公開対象のディレクトリを確認してください。');
}

await mkdir(OUT_DIR, { recursive: true });
await writeFile(OUT_FILE, buildSitemap(entries), 'utf8');
console.log(`${OUT_FILE} に ${entries.length} URLを書き出しました。`);

実行はこの形です。

SITE_URL=https://claudecodelab.com node scripts/generate-sitemap.mjs

このスクリプトの肝は、多言語ページごとに xhtml:link を出すところです。Googleの多言語ページガイドでは、XMLサイトマップで hreflang を伝える場合、それぞれのURLが自分自身を含む全言語版を列挙する必要があるとされています。つまり日本語ページに英語・中国語へのリンクを書くだけでなく、英語ページ側にも同じ対応表を書く。collectEntries の最後で全言語版を相互に持たせているのは、まさにこのためです。

記事・商品・ドキュメントを分割して管理する

ページ数が少ないうちは public/sitemap.xml ひとつで足ります。けれど記事、タグ、商品、ヘルプ、画像付きLPが増えてくると、1ファイルに全部詰める運用はレビューがつらくなります。上限は50,000 URLまたは非圧縮50 MBですが、実務では45,000 URLくらいで余裕を持って分けたほうが、障害調査がはるかにラクです。

<?xml version="1.0" encoding="UTF-8"?>
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
  <sitemap>
    <loc>https://example.com/sitemap-pages.xml</loc>
    <lastmod>2026-06-06</lastmod>
  </sitemap>
  <sitemap>
    <loc>https://example.com/sitemap-blog.xml</loc>
    <lastmod>2026-06-06</lastmod>
  </sitemap>
  <sitemap>
    <loc>https://example.com/sitemap-products.xml</loc>
    <lastmod>2026-06-06</lastmod>
  </sitemap>
</sitemapindex>

分割の何が嬉しいかというと、Search Consoleで「ブログのサイトマップだけ取得できない」「商品だけインデックス率が低い」と切り分けられることです。原因が種別単位で見えると、調査の当たりが一気に絞れる。Claude Codeに実装を頼むなら、「URL種別ごとに分割」「各ファイルの件数と最終更新日をログに出す」「50,000 URLを超える前にチャンク化する」「インデックスには同一サイト上のサイトマップだけを入れる」と条件を渡すと、保守しやすい出力になります。

robots.txtとSearch Consoleで発見経路を作る

生成しただけでは半分しか終わっていません。公開URLから取得できる場所に置き、robots.txt とSearch Consoleで「ここにあるよ」と知らせて、ようやく一周します。

User-agent: *
Allow: /

Sitemap: https://claudecodelab.com/sitemap.xml

robots.txt には複数の Sitemap: 行を書けますが、通常はサイトマップインデックスのURLを1つ書けば十分です。公開後はGoogle Search Consoleの「サイトマップ」画面に sitemap.xml または sitemap-index.xml を送信し、取得ステータス、検出URL数、エラーを確認します。Bing向けには Bing Webmaster Tools で同じURLを登録しておきます。

ここで古い自動pingスクリプトを残さないこと。Googleのpingエンドポイントは廃止済みで、今は robots.txt とSearch Consoleが基本です。CI/CDに組み込むなら、Claude CodeでCI/CDパイプラインを構築するガイド のように、ビルド後に「サイトマップを生成する」「HTTP 200で取れるか確認する」「XMLとして壊れていないか確認する」までを自動チェックに入れてしまうのが安全です。次の検証スクリプトを最後の砦に置いています。

// scripts/verify-sitemap.mjs
const sitemapUrl = process.env.SITEMAP_URL ?? 'https://example.com/sitemap.xml';
const response = await fetch(sitemapUrl);

if (!response.ok) {
  throw new Error(`サイトマップを取得できません: HTTP ${response.status}`);
}

const xml = await response.text();
if (!xml.includes('<urlset') && !xml.includes('<sitemapindex')) {
  throw new Error('サイトマップXMLのルート要素が見つかりません。');
}

console.log(`${sitemapUrl} を取得できました。サイズ: ${xml.length} bytes`);

僕が踏んだ落とし穴5つ

正直に書くと、ここに挙げる失敗はほぼ全部自分でやらかしたものです。

ひとつ目は、冒頭の事故そのもの。lastmod を毎ビルドの日時にしてしまうこと。Googleは、実際の重要更新と一致しているときだけ lastmod をクロール計画の参考にします。本文が変わっていないのに全URLを今日付にすると、更新日の信頼を自分で削りにいくことになります。

ふたつ目は、下書き、noindex、リダイレクト元、404予定のURLを混ぜること。サイトマップには「検索結果に出したい正規URL」だけを入れます。似たURLが複数あるなら、canonicalで選んだほうに寄せる。

みっつ目は、多言語の対応表が片方向になること。/blog/foo//en/blog/foo/ を指しているのに、英語側が日本語へ戻していないと、hreflang のクラスタが弱くなって効きが悪くなります。自己参照を入れるのも忘れずに。

よっつ目は、XMLエスケープ不足。URLに ?a=1&b=2 があると、XML内では &amp; にしないと壊れます。Nodeスクリプトに escapeXml を入れておけば、Claude Codeが生成したURL一覧でも事故が減ります。

いつつ目は、サイトマップだけで内部リンクを補えると勘違いすること。サイトマップは発見の補助であって、読者とクローラが自然にたどれる内部リンクの代わりにはなりません。関連する記事群を整理するなら、Claude Codeコンテンツファネル監査 の視点で、記事から講座、教材、相談導線までをつないでおきます。

Claude Codeに渡すプロンプト

ここまでの条件を、そのまま指示書にしたのが下です。「書いて」だけだと古い形を持ってくるので、何を除外し、更新日の根拠は何で、多言語の戻りリンクをどう作るか、まで言葉にして渡します。

ClaudeCodeLabのAstroサイト向けに、XMLサイトマップ生成を実装してください。

条件:
- 公開対象は site/src/content/blog* のMDXだけ
- draft: true、noindex: true の記事は除外
- URLは絶対URLで末尾スラッシュあり
- updatedDate を優先し、なければ pubDate を lastmod にする
- lastmod をビルド日時で一律更新しない
- 10言語の記事は同じslugで hreflang を相互に出す
- XML値はエスケープする
- 50,000 URLまたは50 MBを超える場合は分割し、sitemap indexを作る
- robots.txt とSearch Console登録手順もREADMEに追記
- pingエンドポイントは使わない

実装後に確認すること:
- 生成XMLが well-formed である
- description や draft記事が混ざっていない
- Search Consoleで送信できるURLになっている

この粒度まで渡すと、疑似コードではなく、運用事故を防ぐ実装に寄ります。逆にここを省くと、AIは「世間でよくあるサイトマップ」を作ってしまう。先回りして縛るのが、結局いちばん速いです。

よくある質問

Q. サイトマップを送れば必ずインデックスされますか? いいえ。サイトマップは「これが正規URLです」と伝えるヒントで、インデックスを保証するものではありません。中身が薄い、重複している、noindexが付いている、といったページは送っても拾われません。

Q. changefreqpriority は書いたほうがいいですか? Googleはどちらも使いません。sitemaps.orgの仕様には存在しますが、書いても効果はないので省略してかまいません。その分、lastmod を正確に保つほうに労力を回したほうが効きます。

Q. lastmod はいつ更新すべきですか? 本文、構造化データ、重要なリンクなど、検索結果に影響する中身が実際に変わった日だけです。誤字修正やビルドのたびに今日付へ書き換えると、更新日の信頼を失います。

Q. 多言語サイトで hreflang をサイトマップに書くときの注意は? 各URLが、自分自身を含む全言語版を列挙する必要があります。日本語ページにだけ英語版へのリンクを書くのではなく、英語ページにも同じ対応表(日本語・英語・…)を書いて相互参照にします。

Q. サイトマップのpingはもう送らなくていいんですか? はい。Googleのpingエンドポイントは2023年6月に廃止されました。今は robots.txtSitemap: 行を書き、Search Consoleで送信・確認するのが正攻法です。古いpingスクリプトは消してください。

実際に試した結果

ClaudeCodeLabの記事構成でこの手順を回してみて、いちばん効いたのは結局シンプルな3つでした。「全URLを今日更新にしない」「10言語の同一slugを hreflang で相互に出す」「pingを削除してSearch Console確認に寄せる」。

特に公開済み記事をリライトする運用では、updatedDatelastmod が一致していると、レビュー時にどの言語版が更新済みかひと目で分かって助かりました。冒頭の「毎日全部更新」事故から lastmod を本物の更新日に直したあと、新記事のインデックスがまた素直に進むようになったのが、何より安心した瞬間です。

サイトマップ生成は一度作って終わりではなく、公開前レビュー・CI/CD・Search Console確認まで含めて回して、ようやく価値が出ます。手を動かすなら、まず自分のサイトの lastmod が「本物の更新日」になっているかだけ、今日確認してみてください。仕組みづくりをまるごと相談したいときは Claude Code導入相談 ものぞいてもらえればと思います。

#Claude Code #サイトマップ #SEO #hreflang #多言語
無料

無料PDF: Claude Code はじめてのチートシート

まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。

スパムは送りません。登録情報は厳重に管理します。

Claude Codeを仕事で使える形にしませんか?

まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。

Masa

この記事を書いた人

Masa

Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。

PR

関連書籍・参考図書

この記事のテーマに関連する書籍を楽天ブックスで探せます。

※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。