IT技術ブログ

サイトの全ページのtitle・description・見出し・構造化データ・canonicalを、ビルド後のHTMLから一括で点検するスクリプト。重複、noindexの混入、内部リンク切れまで、このブログで流した結果つき

ビルド後のHTMLを全部読み、titleとdescriptionの重複、h1の数、JSON-LD、canonical、noindexの混入、内部リンク切れを一度に点検するNodeのスクリプトです。このブログで流した結果も載せます。

この記事の結論:静的に書き出すサイトなら、SEOの基本項目は、ビルド後のdistのHTMLを全部読むスクリプトで一括チェックできます。点検するのは、①titleとdescriptionの欠け・重複・長さ、②h1の数、③JSON-LDが読めるか、④canonicalが自分を指しているか、⑤noindexのページがサイトマップに混ざっていないか、⑥内部リンクとページ内リンクの切れ、の6つです。このブログ(Astroで書き出し、265ページ)で流したところ、エラーは0件、注意は74件でした。注意の内訳は、短い説明文が69件(すべて一覧などの記事以外のページ)、長いtitleが4件、長い説明文が1件です。長いtitleと説明文はそのあと直し、流し直したところ、注意は短い説明文の69件だけになりました。

この記事では、そのスクリプトの全文と、このブログで2026年10月4日に流した実際の結果、わざと壊したコピーで見落とさないかを確かめた結果を載せます。公開中のサイトを巡回して調べることと、サーバーが返すヘッダーを見ることは、このスクリプトでは行っていません。

公開中のサイトを巡回せず、ビルド後のHTMLを読む

点検の相手は、公開中のサイトではなく、ビルドが書き出したHTMLのファイルにします。公開する前に問題を止められ、ネットワークを使わないので速く、同じ入力なら毎回同じ結果になるからです。

公開中のサイトを巡回するツールは、実際に届くページを見られる反面、公開してからでないと問題に気づけません。ビルド後のファイルを読む方法なら、記事を足した日の書き出しで重複や切れたリンクを見つけ、デプロイの前に止められます。このブログの265ページを読むのにかかった時間は、手元のMacで1秒ほどでした。

ただし、ファイルを読むだけでは分からないこともあります。サーバーやCDNが付けるヘッダー、JavaScriptで表示のあとに書き換える部分、外部サイトへのリンクです。これらは公開後の確認に回します(最後の節にまとめています)。

点検する項目と、エラーと注意の分け方

エラーは「公開を止めるもの」、注意は「直すかどうかを人が決めるもの」と分けます。分け方の基準は、Googleの説明で明確に避けるよう書かれているか、自社で決めた目安かです。

項目 見つけ方 区分 根拠
titleが無い・複数・重複 <title>を数え、インデックス可のページどうしで同じ文字列を探す エラー Googleは、ページごとに違うtitleを付けるよう案内している
titleが長い 文字数が45字を超える 注意 長さの上限はなく、端末の幅で切られる。45字は自社の目安
descriptionが無い・複数・重複 titleと同じ エラー 同じ説明文が並んでも役に立たないと案内されている
descriptionが短い・長い 50字未満・120字超 注意 上限はない。50〜120字は自社の目安
h1が無い <h1>が0個 エラー ページの主題を示す見出しが無い(自社の決まり)
h1が複数 <h1>が2個以上 注意 自社の決まり。Googleの規則ではない
JSON-LDが読めない JSON.parseで失敗する、@typeや@contextが無い エラー 読めない構造化データは、無いのと同じ
canonicalが無い・複数・相対URL・他ホスト <link rel="canonical">を数えて読む エラー Googleは絶対URLで書くよう案内している
canonicalが自分以外を指す canonicalのパスとファイルの場所を比べる 注意 意図して別のURLを指すこともある
canonicalの先が無い・noindex 指した先のページを探す エラー 存在しない・載せないページを正規にはできない
noindexなのにサイトマップにある metaのrobotsと_headersのX-Robots-Tagを見て、サイトマップと突き合わせる エラー サイトマップには正規のURLを載せるよう案内されている
内部リンク切れ・画像が無い hrefとsrcをdistのファイルに当てる エラー 読者にも検索エンジンにも行き止まりになる
ページ内リンクの行き先が無い #見出しのidが相手のページにあるかを見る エラー 目次や記事どうしのリンクが空振りする
リンク先が転送される 末尾の/が無い、_redirectsに当たる 注意 届くが、転送を1回挟む

noindexは、HTMLのmetaタグだけでなく、HTTPのX-Robots-Tagヘッダーでも指定できます。このブログでは、LINEで配るテンプレートのページに、Cloudflare Pagesの_headersファイルでX-Robots-Tagを付けているため、スクリプトでも_headersを読んでnoindexとして扱っています。

構造化データの中身(型ごとの必須の項目など)までは、このスクリプトでは見ていません。型ごとの確かめ方は、構造化データのJSON-LDの実装と検証の記事に書いています。

点検のスクリプト

スクリプトは、Node.jsとHTMLを読むためのcheerioだけで動きます。node seo-audit.mjs <distのフォルダ> <サイトのURL>で動かし、エラーが1件でもあれば終了コード1で終わるので、ビルドの流れに入れればそこで止められます。

seo-audit.mjsjs
// seo-audit.mjs — ビルド後の dist のHTMLを全部読んで、SEOの基本項目を一括で点検する
// 使い方: node seo-audit.mjs <distのフォルダ> <サイトのURL>
//   例:   node seo-audit.mjs ./dist https://www.example.com
// 依存: cheerio(npm i -D cheerio)
import fs from 'node:fs';
import path from 'node:path';
import * as cheerio from 'cheerio';

const DIST = path.resolve(process.argv[2] ?? 'dist');
const SITE = new URL(process.argv[3] ?? 'https://www.example.com');
// 同じサイトとして扱うホスト(www あり・なし)
const SAME_HOSTS = new Set([SITE.host, SITE.host.replace(/^www\./, ''), `www.${SITE.host.replace(/^www\./, '')}`]);

// しきい値は自社の目安(Googleが決めた上限ではない)
const RULES = { titleMax: 45, descMin: 50, descMax: 120 };
// 点検しないファイル(404ページなど)
const SKIP = new Set(['404.html']);

const issues = []; // { level: 'error' | 'warn', kind, page, detail }
const add = (level, kind, page, detail = '') => issues.push({ level, kind, page, detail });
const len = (s) => [...s].length; // 日本語も1文字ずつ数える

// ---- 1. dist のHTMLを集め、ファイルの場所からURLのパスを決める ----
function walk(dir) {
  return fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) => {
    const p = path.join(dir, e.name);
    return e.isDirectory() ? walk(p) : p.endsWith('.html') ? [p] : [];
  });
}
const fileToPath = (file) => {
  const rel = path.relative(DIST, file).split(path.sep).join('/');
  return '/' + rel.replace(/(^|\/)index\.html$/, '$1').replace(/\.html$/, '');
};

// ---- 2. _headers の X-Robots-Tag と _redirects を読む(Cloudflare Pages / Netlify の書式) ----
const toMatcher = (pattern) => {
  const re = pattern.split('*').map((s) => s.replace(/[.+?^${}()|[\]\\]/g, '\\$&')).join('.*');
  return new RegExp(`^${re}$`);
};
const headerRules = [];
if (fs.existsSync(path.join(DIST, '_headers'))) {
  let current = null;
  for (const line of fs.readFileSync(path.join(DIST, '_headers'), 'utf8').split('\n')) {
    if (!line.trim() || line.trim().startsWith('#')) continue;
    if (!/^\s/.test(line)) current = toMatcher(line.trim());
    else if (current && /^\s*x-robots-tag\s*:/i.test(line)) headerRules.push({ re: current, value: line.split(':').slice(1).join(':').trim() });
  }
}
const redirectRules = [];
if (fs.existsSync(path.join(DIST, '_redirects'))) {
  for (const line of fs.readFileSync(path.join(DIST, '_redirects'), 'utf8').split('\n')) {
    const [from, to, status] = line.trim().split(/\s+/);
    if (!from || from.startsWith('#') || !to) continue;
    redirectRules.push({ re: toMatcher(from.replace(/:\w+/g, '*')), to, status: status ?? '301' });
  }
}

// ---- 3. サイトマップのURLを読む ----
const sitemapPaths = new Set();
for (const f of fs.readdirSync(DIST).filter((f) => /^sitemap.*\.xml$/.test(f))) {
  const xml = fs.readFileSync(path.join(DIST, f), 'utf8');
  if (xml.includes('<sitemapindex')) continue; // 索引ファイルは中身のサイトマップを直接読む
  for (const m of xml.matchAll(/<loc>([^<]+)<\/loc>/g)) sitemapPaths.add(decodeURI(new URL(m[1]).pathname));
}

// ---- 4. ページごとに読む ----
const skipped = [];
const pages = new Map(); // パス → { title, desc, canonical, noindex, ids, links }
for (const file of walk(DIST)) {
  const rel = path.relative(DIST, file);
  if (SKIP.has(rel)) continue;
  const urlPath = fileToPath(file);
  const html = fs.readFileSync(file, 'utf8');
  // 拡張子が .html でも中身がHTMLでないもの(RSSを index.html で書き出した場合など)は飛ばす
  if (!/<html[\s>]/i.test(html.slice(0, 2000))) { skipped.push(urlPath); continue; }
  const $ = cheerio.load(html);

  const titles = $('head title');
  const title = titles.first().text().trim();
  const descs = $('head meta[name="description"]');
  const desc = (descs.attr('content') ?? '').trim();
  const canons = $('head link[rel="canonical"]');
  const canonical = canons.attr('href') ?? '';
  const metaRobots = $('meta[name="robots"], meta[name="googlebot"]').map((_, el) => $(el).attr('content')).get().join(',');
  const headerRobots = headerRules.filter((r) => r.re.test(urlPath)).map((r) => r.value).join(',');
  const noindex = /noindex/i.test(`${metaRobots},${headerRobots}`);

  // title
  if (titles.length === 0 || !title) add('error', 'titleが無い', urlPath);
  if (titles.length > 1) add('error', 'titleが複数', urlPath, `${titles.length}個`);
  if (len(title) > RULES.titleMax) add('warn', 'titleが長い', urlPath, `${len(title)}字: ${title}`);

  // description
  if (descs.length === 0 || !desc) add(noindex ? 'warn' : 'error', 'descriptionが無い', urlPath);
  if (descs.length > 1) add('error', 'descriptionが複数', urlPath, `${descs.length}個`);
  if (desc && len(desc) > RULES.descMax) add('warn', 'descriptionが長い', urlPath, `${len(desc)}字`);
  if (desc && len(desc) < RULES.descMin) add('warn', 'descriptionが短い', urlPath, `${len(desc)}字: ${desc}`);

  // h1
  const h1 = $('h1').length;
  if (h1 === 0) add('error', 'h1が無い', urlPath);
  if (h1 > 1) add('warn', 'h1が複数', urlPath, `${h1}個`);

  // canonical
  if (canons.length === 0) add(noindex ? 'warn' : 'error', 'canonicalが無い', urlPath);
  if (canons.length > 1) add('error', 'canonicalが複数', urlPath, `${canons.length}個`);
  if (canonical) {
    let c;
    try { c = new URL(canonical); } catch { add('error', 'canonicalが絶対URLでない', urlPath, canonical); }
    if (c) {
      if (c.host !== SITE.host) add('error', 'canonicalのホストが違う', urlPath, canonical);
      else if (decodeURI(c.pathname) !== urlPath) add('warn', 'canonicalが自分以外を指す', urlPath, canonical);
    }
    const og = $('meta[property="og:url"]').attr('content');
    if (og && og !== canonical) add('warn', 'og:urlとcanonicalが違う', urlPath, `${og} / ${canonical}`);
  }

  // noindex とサイトマップの食い違い
  if (noindex && sitemapPaths.has(urlPath)) add('error', 'noindexなのにサイトマップに載っている', urlPath, metaRobots || headerRobots);
  if (!noindex && sitemapPaths.size && !sitemapPaths.has(urlPath)) add('warn', 'インデックス可なのにサイトマップに無い', urlPath);

  // JSON-LD
  $('script[type="application/ld+json"]').each((i, el) => {
    let data;
    try { data = JSON.parse($(el).text()); } catch (e) { return add('error', 'JSON-LDが読めない', urlPath, `${i + 1}個目: ${e.message}`); }
    for (const node of [data].flat().flatMap((d) => d?.['@graph'] ?? [d])) {
      if (!node?.['@type']) add('error', 'JSON-LDに@typeが無い', urlPath, `${i + 1}個目`);
      if (!data['@context'] && !node['@context']) add('error', 'JSON-LDに@contextが無い', urlPath, `${i + 1}個目`);
      // 記事・ページの型は、示すURLが canonical と同じか
      if (/Article|BlogPosting|WebPage/.test([node['@type']].flat().join()) && canonical) {
        const main = typeof node.mainEntityOfPage === 'string' ? node.mainEntityOfPage : node.mainEntityOfPage?.['@id'];
        const u = main ?? node.url;
        if (u && u !== canonical) add('warn', 'JSON-LDのURLとcanonicalが違う', urlPath, `${node['@type']}: ${u}`);
      }
    }
  });

  // 内部リンク・画像(あとでまとめて確かめる)
  const links = [];
  $('a[href]').each((_, el) => links.push({ kind: 'a', href: $(el).attr('href') }));
  $('img[src]').each((_, el) => links.push({ kind: 'img', href: $(el).attr('src') }));
  const ids = new Set($('[id]').map((_, el) => $(el).attr('id')).get());
  $('a[name]').each((_, el) => ids.add($(el).attr('name')));

  pages.set(urlPath, { title, desc, canonical, noindex, ids, links, file });
}

// ---- 5. 重複(インデックス可のページどうし) ----
const dupes = (key, kind) => {
  const groups = new Map();
  for (const [p, v] of pages) if (!v.noindex && v[key]) groups.set(v[key], [...(groups.get(v[key]) ?? []), p]);
  for (const [value, ps] of groups) if (ps.length > 1) add('error', kind, ps.join(' , '), value);
};
dupes('title', 'titleが重複');
dupes('desc', 'descriptionが重複');

// canonical の先がインデックス可のページか
for (const [p, v] of pages) {
  if (!v.canonical) continue;
  const target = decodeURI(new URL(v.canonical, SITE).pathname);
  if (target === p) continue;
  const t = pages.get(target);
  if (!t) add('error', 'canonicalの先が存在しない', p, v.canonical);
  else if (t.noindex) add('error', 'canonicalの先がnoindex', p, v.canonical);
}

// ---- 6. 内部リンク切れ ----
const fileExists = (p) => fs.existsSync(path.join(DIST, p)) && fs.statSync(path.join(DIST, p)).isFile();
function resolvePath(p) {
  if (pages.has(p)) return { page: p };
  if (fileExists(p)) return { file: p };
  if (!p.endsWith('/') && pages.has(p + '/')) return { page: p + '/', note: '末尾の/が無い(リダイレクトを1回挟む)' };
  const r = redirectRules.find((r) => r.re.test(p));
  if (r) return { redirect: r.to, note: `_redirectsで${r.status}転送` };
  return null;
}
const seen = new Set();
let checkedLinks = 0;
for (const [p, v] of pages) {
  for (const { kind, href } of v.links) {
    if (!href || /^(mailto|tel|javascript|data):/i.test(href)) continue;
    let u;
    try { u = new URL(href, new URL(p, SITE)); } catch { add('error', 'リンクのURLが壊れている', p, href); continue; }
    if (!/^https?:$/.test(u.protocol) || !SAME_HOSTS.has(u.host)) continue; // 外部リンクは見ない
    if (u.host !== SITE.host) add('warn', '正式でないホストへのリンク', p, href);
    let target;
    try { target = decodeURI(u.pathname); } catch { target = u.pathname; }
    const key = `${p}|${kind}|${target}|${u.hash}`;
    if (seen.has(key)) continue;
    seen.add(key);
    checkedLinks++;

    const res = resolvePath(target);
    if (!res) { add('error', kind === 'img' ? '画像が見つからない' : '内部リンク切れ', p, href); continue; }
    if (res.note) add('warn', 'リンク先が転送される', p, `${href}(${res.note})`);
    // #見出し へのリンクは、相手のページにその id があるか
    if (kind === 'a' && u.hash.length > 1 && res.page) {
      const id = decodeURIComponent(u.hash.slice(1));
      if (!pages.get(res.page).ids.has(id)) add('error', 'ページ内リンクの行き先が無い', p, href);
    }
  }
}

// サイトマップにあるのに、書き出されていないURL
for (const sp of sitemapPaths) if (!pages.has(sp)) add('error', 'サイトマップのURLのページが無い', sp);

// ---- 7. 結果を出す ----
const byKind = new Map();
for (const i of issues) byKind.set(`${i.level}\t${i.kind}`, [...(byKind.get(`${i.level}\t${i.kind}`) ?? []), i]);
console.log(`点検したページ: ${pages.size}(noindex: ${[...pages.values()].filter((v) => v.noindex).length})`);
console.log(`サイトマップのURL: ${sitemapPaths.size} / 確かめた内部リンク・画像: ${checkedLinks}`);
if (skipped.length) console.log(`HTMLでないので飛ばした: ${skipped.join(' , ')}`);
console.log(`エラー: ${issues.filter((i) => i.level === 'error').length} / 注意: ${issues.filter((i) => i.level === 'warn').length}\n`);
for (const [k, list] of [...byKind].sort()) {
  const [level, kind] = k.split('\t');
  console.log(`[${level === 'error' ? 'エラー' : '注意'}] ${kind}: ${list.length}件`);
  for (const i of list.slice(0, 10)) console.log(`  - ${i.page}${i.detail ? `  ${i.detail}` : ''}`);
  if (list.length > 10) console.log(`  …ほか${list.length - 10}件`);
}
if (process.env.REPORT_JSON) fs.writeFileSync(process.env.REPORT_JSON, JSON.stringify(issues, null, 2));
process.exitCode = issues.some((i) => i.level === 'error') ? 1 : 0;

作りの要点は4つです。

  • ファイルの場所からURLを決める。 about/index.htmlは/about/、というように、書き出しの形(Astroのbuild.format: 'directory')に合わせてパスを決め、canonicalやリンクの先と比べます。
  • 重複は、インデックス可のページどうしだけで数える。 noindexのページは検索結果に出ないので、titleが同じでも問題にしません。
  • 内部リンクは、同じリンクを1回だけ確かめる。 ヘッダーやフッターのリンクは全ページに出るので、ページ・種類・行き先・#の組み合わせで重複を除いています。
  • _redirectsの規則を読む。 転送の元になっているURLへのリンクは、切れではなく「転送される」注意として出します。:splatのような書き方は*と同じに扱う、簡単な読み方です。

このブログで流した結果:エラー0件、注意74件

2026年10月4日21時55分にビルドしたdistをコピーし、スクリプトを流した結果です。エラーは0件で、注意の74件は、すべてtitleとdescriptionの長さの目安に関するものでした。

$ node seo-audit.mjs ./dist https://www.yamayamabloglink.com
点検したページ: 265(noindex: 11)
サイトマップのURL: 254 / 確かめた内部リンク・画像: 11134
HTMLでないので飛ばした: /feed/
エラー: 0 / 注意: 74

[注意] descriptionが短い: 69件
  - /about/  45字: やまやまブログの運営者、津嘉山 洸(株式会社bundlyze CTO)のプロフィールです。
  - /category/bridal/同棲/  18字: 同棲に関する記事の一覧です(7件)。
  …(中略)
[注意] descriptionが長い: 1件
  - /line-reminder-cloudflare-workers-cron/  132字
[注意] titleが長い: 4件
  - /bridal-fair-form-mobile/  56字
  - /one-click-copy/  57字
  - /personal-gym-line-trial-booking/  56字
  - /wordpress-astro-cloudflare-migration/  78字

エラーになる項目のうち、このブログで0件だったものは次のとおりです。titleとdescriptionの重複、h1の欠け、読めないJSON-LD、自分以外を指すcanonical、サイトマップに載ったnoindexのページ、内部リンクとページ内リンクの切れ、書き出されていないサイトマップのURLは、いずれも見つかりませんでした。

見つかったものと、その扱いは次のとおりです。

見つかったもの 件数 扱い
/feed/がtitle・description・h1・canonicalの欠けでエラーになった 4件(1ページ) スクリプトを直した。 RSSをfeed/index.htmlという名前で書き出しているため、HTMLとして読んでいた。中身がHTMLでないファイルを飛ばすようにした
説明文が短い(50字未満) 69件 残している。 内訳は、タグの一覧32件、ページ送り17件、カテゴリの一覧12件、運営者情報・問い合わせ・プライバシーポリシー・検索・サイトの地図が各1件、LINEで配るテンプレート3件。記事のページは0件。一覧の説明文は記事数を入れた定型文で、記事より優先度が低いと判断した
titleが45字を超える 4件 直した。 3件は検索結果用の題(seoTitle)を付けておらず、記事の題がそのままtitleになっていたので、検索結果用の題を付けた。1件は英語の製品名で長くなっていたので短くした。直したあとに流し直して0件になったことを確かめた
説明文が120字を超える 1件(132字) 直した。 言い回しを詰めて118字にし、流し直して0件になったことを確かめた

最初の1行は、サイトの問題ではなくスクリプトの見落としでした。ファイルの拡張子だけでHTMLと決めると、こういう誤検知が出ます。初めて流すときは、エラーの1件目を開いて、本当にページの問題かを確かめるのが確実です。

説明文が短い69件のうち11件(タグの一覧6件、カテゴリの一覧1件、検索ページ、テンプレート3件)はnoindexにしているため、検索結果には出ません。残りの、インデックスに載せている一覧ページについては、Googleの説明に「同じか似た説明文が並ぶのは役に立たない」とあるので、カテゴリ名と件数だけが違う今の定型文は、いずれ見直す候補として残しています。

わざと壊したコピーで、見落とさないかを確かめた

エラーが0件だと、スクリプトが正しく働いた結果なのか、見落としているだけなのかが分かりません。そこで、distのコピーに6種類の問題をわざと入れて流し、入れたものがすべて出るかを確かめました。

break.mjsjs
// 点検の網にかかるかを確かめるため、コピーした dist をわざと壊す
import fs from 'node:fs';
const D = 'dist-broken';
const edit = (p, fn) => { const f = `${D}${p}index.html`; fs.writeFileSync(f, fn(fs.readFileSync(f, 'utf8'))); };
const titleOf = (p) => fs.readFileSync(`${D}${p}index.html`, 'utf8').match(/<title>([^<]*)<\/title>/)[1];
// 1. titleの重複
edit('/xampp-setup/', (h) => h.replace(/<title>[^<]*<\/title>/, `<title>${titleOf('/php-array-loop/')}</title>`));
// 2. JSON-LDを壊す(最初のJSON-LDの最後の } を消す)
edit('/indexnow-cloudflare-pages-deploy/', (h) => h.replace(/(<script type="application\/ld\+json">[\s\S]*?)\}(<\/script>)/, '$1$2'));
// 3. h1を2つにする
edit('/supabase-rls-multi-tenant-reservations/', (h) => h.replace('<main', '<h1>重複した見出し</h1><main'));
// 4. canonicalを別のページに向ける
edit('/llms-txt-astro-build-auto-generate/', (h) => h.replace(/(<link rel="canonical" href=")[^"]+/, '$1https://www.yamayamabloglink.com/indexnow-cloudflare-pages-deploy/'));
// 5. サイトマップに載っているページにnoindexを入れる
edit('/about/', (h) => h.replace('<head>', '<head><meta name="robots" content="noindex">'));
// 6. 内部リンク切れと、ページ内リンクの行き先切れ
edit('/structured-data-jsonld-implementation-check/', (h) => h.replace('</main>', '<a href="/no-such-page/">x</a><a href="/indexnow-cloudflare-pages-deploy/#no-such-heading">y</a></main>'));
console.log('壊したコピーを作りました');
$ node break.mjs && node seo-audit.mjs ./dist-broken https://www.yamayamabloglink.com
壊したコピーを作りました
点検したページ: 265(noindex: 12)
サイトマップのURL: 254 / 確かめた内部リンク・画像: 11136
HTMLでないので飛ばした: /feed/
エラー: 5 / 注意: 78

[エラー] JSON-LDが読めない: 1件
  - /indexnow-cloudflare-pages-deploy/  1個目: Expected ',' or '}' after property value in JSON at position 1604 (line 1 column 1605)
[エラー] noindexなのにサイトマップに載っている: 1件
  - /about/  noindex
[エラー] titleが重複: 1件
  - /php-array-loop/ , /xampp-setup/  PHPの配列ループ|foreach・forの書き方と使い分け | やまやまブログ
[エラー] ページ内リンクの行き先が無い: 1件
  - /structured-data-jsonld-implementation-check/  /indexnow-cloudflare-pages-deploy/#no-such-heading
[エラー] 内部リンク切れ: 1件
  - /structured-data-jsonld-implementation-check/  /no-such-page/
[注意] JSON-LDのURLとcanonicalが違う: 1件
  - /llms-txt-astro-build-auto-generate/  BlogPosting: https://www.yamayamabloglink.com/llms-txt-astro-build-auto-generate/
[注意] canonicalが自分以外を指す: 1件
  - /llms-txt-astro-build-auto-generate/  https://www.yamayamabloglink.com/indexnow-cloudflare-pages-deploy/
[注意] h1が複数: 1件
  - /supabase-rls-multi-tenant-reservations/  2個
[注意] og:urlとcanonicalが違う: 1件
  - /llms-txt-astro-build-auto-generate/  https://www.yamayamabloglink.com/llms-txt-astro-build-auto-generate/ / https://www.yamayamabloglink.com/indexnow-cloudflare-pages-deploy/
(長さの注意は、壊す前と同じなので省略)

入れた6種類(titleの重複、壊れたJSON-LD、2つ目のh1、別のページを指すcanonical、サイトマップに載ったページのnoindex、存在しないページと見出しへのリンク)は、すべて出ました。canonicalを書き換えた1件は、canonicalだけでなく、og:urlとJSON-LDのURLとの食い違いとしても出ています。canonicalだけを書き換えると、ページの中の3か所が別々のURLを指すことになるので、こうして並べて出るほうが原因に気づきやすくなります。

ビルドのたびに流すときは、終了コードで止める

ビルドのあとに続けて流し、エラーがあれば終了コード1でデプロイを止めます。たとえば、package.jsonのビルドの命令の最後に足す形です。

package.json(例)json
{
  "scripts": {
    "build": "astro build && node scripts/seo-audit.mjs dist https://www.example.com"
  }
}

この形はまだこのブログのビルドには入れておらず、この記事のためにビルドとは別に手で流しただけです。入れる前に、上の表の注意のうち、残すと決めたもの(一覧ページの短い説明文など)を点検の対象から外す設定を足す必要があります。注意の件数が多いままだと、本当に見るべき注意が埋もれるからです。

点検で見つかった問題を直したあとは、検索エンジンが実際にそのページを読み直したかを、公開後に確かめます。インデックスの状況の切り分けは、Search Consoleの「検出 - インデックス未登録」の記事にまとめています。

このスクリプトでは分からないこと

ファイルを読むだけの点検なので、公開後に届くものと、外の世界にあるものは分かりません。次の5つは、公開後に別の方法で確かめます。

  • サーバーやCDNが付けるヘッダー。 _headersは読んでいますが、関数(このブログではfunctions/_middleware.js)が付けるヘッダーや転送は見ていません。公開後にcurl -Iで確かめます。
  • robots.txtでのブロック。 Googleの説明では、noindexはrobots.txtでブロックされていないページでしか働きません。robots.txtとnoindexの組み合わせは、このスクリプトでは突き合わせていません。
  • JavaScriptで書き換える部分。 表示のあとにtitleやリンクを書き換えるサイトでは、書き出したHTMLと読まれる中身が違います。
  • 外部サイトへのリンク。 相手のサイトに問い合わせることになるので、対象から外しています。
  • 見た目の長さ。 長さは文字数で数えていて、検索結果で切られる幅(ピクセル)ではありません。

検索とAIの答えの両方に向けて、自分で確かめられる項目は、会社のコラムAIO対策は自分でできる。ChatGPTとAI Overviewに向けて確かめる11項目にまとめています。株式会社bundlyzeでは、自社サイトでLLMOを実践し、やったことの記録を会社のサイトで公開しています。サイト全体の点検の仕組みづくりから相談したい場合は、SEO・LLMO対策のページに進め方を載せています。

動作確認した環境

確認日は2026年10月4日、使ったのは手元のMacです。

項目 バージョン・内容
OS macOS 26(Darwin 25.6.0)
Node.js 22.23.2
cheerio 1.2.0
点検した相手 このブログを2026年10月4日21時55分にビルドしたdistのコピー(Astro 7.3.5。拡張子が.htmlのファイル267個のうち、404.htmlとRSSのfeed/index.htmlを除く265ページ)
確かめたこと 実際のdistでの結果、RSSの誤検知と直したあとの結果、6種類の問題を入れたコピーでの検出、1回にかかる時間(約1秒)

ビルドの命令への組み込み、CIでの実行、Windowsでのパスの扱い、Astro以外の書き出しの形(page.htmlのように書き出す形など)は試していません。見つかった長いtitleと説明文は、この記事を書いたあとに直し、点検を流し直してエラー0件を確かめました。

あとから分かった見落としもあります。macOSの標準のディスク(APFS)のように大文字小文字を区別しない環境では、上のfileExistsがURLの大文字小文字の違いを区別できません。大文字小文字だけが違う内部リンクがあっても手元では見つかったことになり、区別する公開先では404になるのを見落とします。新しい版のscripts/site-audit.mjsでは、パスを1段ずつ大文字小文字まで照合するように直しました(LLMO・AIOの点検を足した記事で説明する予定です)。

参照した公式ドキュメント(確認日:2026年10月4日)

よくある質問

titleやdescriptionの重複は、公開中のサイトを巡回するツールでなくても見つけられますか?

静的に書き出すサイトなら、ビルド後のHTMLのファイルを全部読めば見つけられます。公開中のサイトを巡回するより速く、公開する前に止められるのが利点です。このブログの265ページでは、1秒ほどで終わりました。ただし、サーバーやCDNが返すヘッダーや、JavaScriptで後から書き換える部分は、ファイルを読むだけでは分かりません。

titleとdescriptionは何文字までにすればよいですか?

Googleの説明では、どちらにも長さの上限はなく、検索結果では端末の幅に合わせて切られるとされています。そのため、スクリプトのしきい値は自社で決める目安です。このブログでは、検索結果用の題を32字以内にしていて、サイト名を足すと41字になるため、titleは45字、descriptionは50〜120字を目安にしています。

h1が2つあると、検索で不利になりますか?

この記事のスクリプトでは、h1が無いものはエラー、2つ以上は注意として出しています。h1を1つにするのは、ページの主題を人にもAIにも1か所で示すための、このブログの決まりです。Googleの規則として決まっているものではないため、エラーではなく注意にしています。

noindexのページがサイトマップに載っていると、何がいけないのですか?

サイトマップは「検索に載せたいURLの一覧」として検索エンジンに渡すもので、noindexは「載せないでほしい」という指示です。両方が同じURLに付いていると、伝えたいことが食い違います。Googleも、正規のURLを選んでサイトマップに載せるよう案内しています。スクリプトではこの組み合わせをエラーにしています。