IT技術ブログ

記事を公開する前の自動チェックを、Astroのcontent collectionsの元ファイルを読むNodeスクリプトで作る。未来の日付、下書きのままの記事、表紙の未設定、seoTitleと説明文の長さ、下書きへの内部リンク、front matterの必須項目、テーマ別の入口の割り当て漏れ

Astroの記事のfront matterと本文を読み、未来の日付、下書きのままの記事、下書きへのリンク、入口ページの割り当て漏れを公開前に見つけるNodeスクリプトです。過去のコミットに流した結果も載せます。

この記事の結論:記事を公開する前の点検は、Astroのcontent collectionsの元になるMarkdownのfront matterと本文を、Nodeのスクリプトで直接読めば、ビルドでは止まらないミスまで機械で見つけられます。見るのは、公開する記事の日付が未来になっていないか、公開するはずの記事が下書きのまま残っていないか、下書きや存在しない記事へ内部リンクを張っていないか、表紙のアイコン・seoTitle・説明文が決まりどおりか、front matterの必須項目と型、テーマ別の入口(ハブ)への割り当て漏れです。

このブログ(やまやまブログ、Astro 7で書き出している記事185本)で流すと、エラーは0件でした。そこで、2026年10月3日〜4日に実際に起きた公開まわりのミスを、git archive でそのときのコミットの状態に戻し、「いま」の時刻をコミットの時刻に固定して流し直しました。未来の日付で公開した2回と、「公開」と書いたコミットで2本が下書きのまま残った1回は、どれもエラーとして出ました。加えて、気づいていなかったミスも1つ見つかりました。22本の更新日時が、コミットした時刻より32分先になっていたものです。この記事では、点検の中身、スクリプトの全文、このブログと過去のコミットに流した結果、5か所を壊したコピーで点検が働くかを見た結果を書きます。あわせて、会社サイトのコラムへのリンクが404だった下書きのように --online で見つけられるミスと、デプロイ直後に新しい記事が1分ほど404を返したように、このスクリプトでは見つけられないミスと手順での対処も書きます。

このブログで起きた、公開まわりのミス

起きたのは3種類で、どれもビルドは通り、そのまま本番に出る種類のミスでした。

コミット(日時) 起きたこと 直したコミット
a1351bd(10月3日 10:04) 3本を公開したが、date と modified が10:20〜10:24と、コミットの時刻より先になっていた 2c9a364(10:05)「公開した3本の日付を現在より前に修正」
a9d74e7(10月4日 7:29) 2本を公開したが、date と modified が8:00と8:02になっていた 7fb662f(7:30)「公開した2本の日付を現在より前に修正」
e2fae09(10月4日 23:21) 題は「記事2本を公開」なのに、2本とも draft: true のままで、date も翌朝の7:10と7:20だった b4e486c(26秒後)で draft: false にし、日付を23:15と23:16に直した

このブログでは、下書きの日付に公開予定の時刻を入れています(コミットの題にも「10/9公開予定の下書き」などと残っています)。その時刻より前に公開すると、日付が未来のまま出ます。このブログの記事一覧は draft だけで本番に出すかを決めていて、日付では絞っていません。そのため、未来の日付のまま draft: false にすると、その日時がそのまま公開日と構造化データの datePublished に出ます。Googleの日付についての説明には、未来の日付を指定しないことが挙げられています。

どれも2分以内に直してはいますが、毎回、公開してから直す形でした。公開の前に機械で見つけるために、このスクリプトを書きました。

点検する項目と、ビルドで止まるかどうか

点検の多くは、Astroのビルドでは止まらないものです。content collectionsのschemaに合わない値はビルドがエラーで止まりますが、値の形が正しければ、中身がおかしくても通ります。

項目 見つけ方 区分 ビルドで止まるか
front matterの必須項目と型 title・slug・date があるか、draft が真偽値か、cta が決められた値か、faq が q と a の組か エラー 止まる(ただし title: "" のような空の文字列は通る)
公開する記事の未来の日付 draft: false で、date か modified が現在より後 エラー 止まらない
更新日が公開日より前 modified < date 注意 止まらない
下書きのまま、予定の時刻を過ぎた記事 draft: true で、date が現在以前 注意 止まらない
公開するつもりの記事が下書きのまま --publish に渡したslugが draft: true、または存在しない エラー 止まらない
表紙のアイコンが無い draft: false で icon が無い 注意 止まらない(カテゴリの既定の表紙になる)
seoTitleと説明文の長さ seoTitleが32字を超える、descriptionが80〜120字の外、descriptionが空 注意・情報(空は公開ならエラー) 止まらない
下書き・存在しない記事への内部リンク 本文のリンク(コードの中は除く)の行き先のslugを、front matterと照らし合わせる エラー 止まらない(本番では404)
テーマ別の入口(ハブ)の割り当て src/data/hubs.ts を読み、ハブのカテゴリーの事業者向けの記事がどのテーマにも入っていないもの、重複、存在しないslug 公開する記事はエラー、下書きは注意 公開する記事の漏れは止まる(src/lib/hubs.ts)。下書きは公開した瞬間に止まる
会社サイトへのリンク(任意) --online のとき、公開する記事から張っている https://www.bundlyze.co.jp/ のURLにHEADを送り、200以外を出す エラー 止まらない

seoTitleの32字と説明文の80〜120字は、このブログの書き方の目安で、Googleが決めた上限ではありません。

ハブの割り当ての点検は、このブログの作りに合わせたものです。カテゴリーのページを「テーマごとの目次」にしていて、事業者向けの記事は必ずどれかのテーマに入れる決まりにしています。公開する記事の漏れはビルドが止めてくれますが、下書きの段階では止まらないので、draft を外した瞬間に初めてビルドが失敗します。スクリプトでは下書きの漏れも「注意」として先に出します。

スクリプトの全文

Node.js 22.18以降なら、追加の準備なしで動きます。YAMLの読み込みには、Astroの依存として入っている js-yaml を、リポジトリの node_modules から読み込んでいます。テーマ別の入口の定義 src/data/hubs.ts はTypeScriptのファイルですが、Node.js 22.18からは型を取り除いて読み込む機能が既定で有効なので、そのまま import できます(中身が型の宣言と配列だけだからで、TypeScript固有の構文を使うファイルは読めないことがあります)。

js
// prepublish-check.mjs  記事を公開する前の点検(Astro の content collections の元ファイルを読む)
// 使い方: node prepublish-check.mjs [リポジトリのルート] [--publish slugA,slugB] [--online] [--now 2026-10-05T12:00:00+09:00]
//   --publish: 今回公開するつもりの記事。下書きのまま・見つからないならエラー
//   --online : 公開する記事から張っている会社サイト(bundlyze.co.jp)のURLを HEAD で確かめる
//   --now    : 「いま」を固定して試す(日付の点検の確認用)
// 終了コード: エラーが1件でもあれば 1
import fs from 'node:fs';
import path from 'node:path';
import { createRequire } from 'node:module';
import { pathToFileURL } from 'node:url';

const args = process.argv.slice(2);
// 引数を取り出す(値を取るものは次の1つも取り除く)
const take = (name, withValue) => {
  const i = args.indexOf(name);
  if (i < 0) return undefined;
  const [, v] = args.splice(i, withValue ? 2 : 1);
  return withValue ? v : true;
};
const ONLINE = take('--online', false) === true;
const nowArg = take('--now', true);
const NOW = nowArg ? new Date(nowArg) : new Date();
if (Number.isNaN(NOW.getTime())) throw new Error(`--now の日時が読めない: ${nowArg}`);
const PUBLISH = (take('--publish', true) ?? '').split(',').map((x) => x.trim()).filter(Boolean);
const ROOT = path.resolve(args[0] ?? '.');

// js-yaml は Astro の依存として入っている。リポジトリ側の node_modules から読む
const require = createRequire(path.join(ROOT, 'package.json'));
const yaml = require('js-yaml');

// ---- 決まり(src/content.config.ts と、このブログの書き方の目安) ----
const POSTS_DIR = path.join(ROOT, 'src/content/posts'); // glob: '*/index.md'
const CTA = ['system', 'website', 'ai', 'seo', 'ads', 'sns', 'cloud', 'strategy', 'bridal', 'fitness', 'learning', 'default', 'none'];
const RULES = { seoTitleMax: 32, descMin: 80, descMax: 120 }; // 自社の目安
const SITE_HOSTS = new Set(['www.yamayamabloglink.com', 'yamayamabloglink.com']);
// 記事以外のページ(src/pages と src/content/pages)。先頭の階層で見分ける
const NON_POST_TOP = new Set(['category', 'tag', 'page', 'tools', 'feed', 'about', 'contact', 'search', 'sitemap', 'privacy-policy', 'llms.txt', 'llms-full.txt', 'covers', 'images', 'wp-content']);

// ---- 結果の記録 ----
const results = [];
const add = (level, slug, item, detail) => results.push({ level, slug, item, detail });

// ---- 1. 記事を読む ----
const posts = [];
for (const dir of fs.readdirSync(POSTS_DIR).sort()) {
  const file = path.join(POSTS_DIR, dir, 'index.md');
  if (!fs.existsSync(file)) continue;
  const src = fs.readFileSync(file, 'utf8');
  const m = src.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
  if (!m) {
    add('error', dir, 'front matter', '先頭の --- で囲んだ front matter が見つからない');
    continue;
  }
  let data;
  try {
    data = yaml.load(m[1]) ?? {};
  } catch (e) {
    add('error', dir, 'front matter', `YAMLとして読めない: ${e.message.split('\n')[0]}`);
    continue;
  }
  posts.push({ dir, file, data, body: m[2], slug: data.slug ?? dir, draft: data.draft === true });
}
const bySlug = new Map(posts.map((p) => [String(p.slug), p]));

const toDate = (v) => {
  if (v === undefined || v === null || v === '') return undefined;
  const d = v instanceof Date ? v : new Date(v);
  return Number.isNaN(d.getTime()) ? null : d;
};
const fmt = (d) => d.toLocaleString('sv-SE', { timeZone: 'Asia/Tokyo' }).slice(0, 16);

// ---- 2. 1本ずつの点検 ----
const slugCount = new Map();
for (const p of posts) {
  const d = p.data;
  const s = String(p.slug);
  slugCount.set(s, (slugCount.get(s) ?? 0) + 1);

  // 必須項目と型(content.config.ts の schema と同じ条件)
  if (typeof d.title !== 'string' || !d.title.trim()) add('error', s, '必須項目', 'title がない');
  if (typeof d.slug !== 'string' || !d.slug.trim()) add('error', s, '必須項目', 'slug がない');
  if (d.draft !== undefined && typeof d.draft !== 'boolean') add('error', s, '型', `draft が真偽値ではない(${JSON.stringify(d.draft)})`);
  if (d.cta !== undefined && !CTA.includes(d.cta)) add('error', s, '型', `cta「${d.cta}」は決められた値にない`);
  if (d.categories !== undefined && !Array.isArray(d.categories)) add('error', s, '型', 'categories が配列ではない');
  if (d.faq !== undefined && (!Array.isArray(d.faq) || d.faq.some((f) => typeof f?.q !== 'string' || typeof f?.a !== 'string'))) {
    add('error', s, '型', 'faq は q と a の組の配列にする');
  }

  const date = toDate(d.date);
  const modified = toDate(d.modified);
  if (date === undefined) add('error', s, '必須項目', 'date がない');
  if (date === null) add('error', s, '型', `date を日時として読めない(${d.date})`);
  if (modified === null) add('error', s, '型', `modified を日時として読めない(${d.modified})`);

  // 日付:公開する記事の date・modified が未来になっていないか
  if (!p.draft) {
    if (date && date > NOW) add('error', s, '未来の日付', `date ${fmt(date)} が現在 ${fmt(NOW)} より後`);
    if (modified && modified > NOW) add('error', s, '未来の日付', `modified ${fmt(modified)} が現在より後`);
  }
  if (date && modified && modified < date) add('warn', s, '日付の前後', `modified ${fmt(modified)} が date ${fmt(date)} より前`);

  // 下書きのまま、公開予定の日時を過ぎていないか。過ぎていなくても、下書きは一覧に出す
  if (p.draft && date && date <= NOW) add('warn', s, '下書きのまま', `date ${fmt(date)} を過ぎているのに draft: true`);
  else if (p.draft) add('info', s, '下書き', `本番には出ない(date ${date ? fmt(date) : '未設定'})`);

  // 公開する記事に専用の表紙(icon)があるか
  if (!p.draft && !d.icon) add('warn', s, 'icon なし', 'draft: false なのに icon が未設定(カテゴリの既定の表紙になる)');

  // 長さ(自社の目安)
  const len = (x) => [...String(x ?? '')].length;
  if (d.seoTitle !== undefined && len(d.seoTitle) > RULES.seoTitleMax) add('warn', s, 'seoTitle の長さ', `${len(d.seoTitle)}字(目安 ${RULES.seoTitleMax}字以内)`);
  if (!d.description) add(p.draft ? 'warn' : 'error', s, 'description', 'description が空');
  else if (len(d.description) < RULES.descMin || len(d.description) > RULES.descMax) {
    add('info', s, 'description の長さ', `${len(d.description)}字(目安 ${RULES.descMin}〜${RULES.descMax}字)`);
  }
}
for (const [s, n] of slugCount) if (n > 1) add('error', s, 'slug の重複', `${n}本の記事が同じ slug`);

// 今回公開するつもりの記事が、本当に draft: false になっているか
for (const s of PUBLISH) {
  const p = bySlug.get(s);
  if (!p) add('error', s, '公開する記事', 'その slug の記事がない');
  else if (p.draft) add('error', s, '公開する記事', 'まだ draft: true(このままでは本番に出ない)');
}

// ---- 3. 本文の内部リンク:行き先が下書き・存在しない記事になっていないか ----
const linkRe = /\]\(\s*(?:https?:\/\/([^/\s)]+))?(\/[^)\s"#?]*)/g;
// コードの中の例(```〜```、~~~〜~~~、`〜`)はリンクではないので外す
const stripCode = (md) => md.replace(/^(```|~~~)[^\n]*\n[\s\S]*?^\1[^\n]*$/gm, '').replace(/`[^`\n]*`/g, '');
for (const p of posts) {
  const seen = new Set();
  for (const m of stripCode(p.body).matchAll(linkRe)) {
    const host = m[1];
    if (host && !SITE_HOSTS.has(host)) continue;
    let pathname = m[2];
    try {
      pathname = decodeURI(pathname);
    } catch {}
    const seg = pathname.split('/').filter(Boolean);
    if (seg.length === 0 || NON_POST_TOP.has(seg[0])) continue;
    const target = seg[0];
    if (seen.has(target) || target === p.slug) continue;
    seen.add(target);
    const t = bySlug.get(target);
    if (!t) add('error', p.slug, '内部リンク', `/${target}/ という記事もページもない`);
    else if (t.draft && !p.draft) add('error', p.slug, '内部リンク', `/${target}/ は下書き(本番では404)`);
    else if (t.draft && p.draft) add('info', p.slug, '内部リンク', `/${target}/ も下書き。公開するときは先に(または同時に)公開する`);
  }
}

// ---- 4. テーマ別の入口(src/data/hubs.ts)への割り当て ----
// Node 22.18 以降は .ts を型を消して読み込める(hubs.ts は型の宣言と配列だけ)
const hubsFile = path.join(ROOT, 'src/data/hubs.ts');
const { hubDefs } = fs.existsSync(hubsFile) ? await import(pathToFileURL(hubsFile).href) : { hubDefs: [] };
if (!fs.existsSync(hubsFile)) add('info', '-', 'ハブ', 'src/data/hubs.ts が無いので割り当ての点検は飛ばした');
const categories = JSON.parse(fs.readFileSync(path.join(ROOT, 'src/data/categories.json'), 'utf8'));
const withDescendants = (slug) => {
  const out = new Set([slug]);
  let added = true;
  while (added) {
    added = false;
    for (const c of categories) if (c.parent && out.has(c.parent) && !out.has(c.slug)) out.add(c.slug), (added = true);
  }
  return out;
};
for (const def of hubDefs) {
  const inCat = withDescendants(def.category);
  const assigned = new Map();
  for (const sec of def.sections) {
    for (const slug of sec.posts) {
      if (assigned.has(slug)) add('error', slug, 'ハブ', `${def.category}: 「${assigned.get(slug)}」と「${sec.title}」の両方に入っている`);
      assigned.set(slug, sec.title);
      const p = bySlug.get(slug);
      if (!p) add('error', slug, 'ハブ', `${def.category}「${sec.title}」に書かれた slug の記事がない`);
      else if (!(p.data.categories ?? []).some((c) => inCat.has(c))) add('error', slug, 'ハブ', `${def.category} のカテゴリーの記事ではない`);
    }
  }
  for (const p of posts) {
    const business = (p.data.cta ?? 'none') !== 'none';
    if (!business || assigned.has(p.slug)) continue;
    if (!(p.data.categories ?? []).some((c) => inCat.has(c))) continue;
    // 公開する記事の漏れはビルドが止まる。下書きは公開した瞬間に止まるので、先に知らせる
    add(p.draft ? 'warn' : 'error', p.slug, 'ハブ', `${def.category} の事業者向けの記事なのに、どのテーマにも入っていない`);
  }
}

// ---- 5. (任意)公開する記事から張っている会社サイトのURLが開けるか ----
if (ONLINE) {
  const urls = new Map();
  for (const p of posts.filter((x) => !x.draft)) {
    for (const m of p.body.matchAll(/\]\((https:\/\/www\.bundlyze\.co\.jp\/[^)\s"]+)\)/g)) {
      const u = m[1].split('#')[0];
      if (!urls.has(u)) urls.set(u, []);
      urls.get(u).push(p.slug);
    }
  }
  for (const [u, from] of urls) {
    let status;
    try {
      const res = await fetch(u, { method: 'HEAD', redirect: 'manual' });
      status = res.status;
    } catch (e) {
      status = `接続できない(${e.cause?.code ?? e.message})`;
    }
    if (status !== 200) add('error', from.join(', '), '会社サイトのリンク', `${u} → ${status}`);
  }
  add('info', '-', '会社サイトのリンク', `${urls.size}件のURLを確かめた`);
}

// ---- 出力 ----
const order = { error: 0, warn: 1, info: 2 };
const label = { error: 'エラー', warn: '注意', info: '情報' };
results.sort((a, b) => order[a.level] - order[b.level] || a.item.localeCompare(b.item) || String(a.slug).localeCompare(String(b.slug)));
const drafts = posts.filter((p) => p.draft).length;
console.log(`記事 ${posts.length}本(公開 ${posts.length - drafts}・下書き ${drafts})/ 現在 ${fmt(NOW)}(日本時間)`);
for (const r of results) console.log(`[${label[r.level]}] ${r.item} | ${r.slug} | ${r.detail}`);
const count = (l) => results.filter((r) => r.level === l).length;
console.log(`---\nエラー ${count('error')}・注意 ${count('warn')}・情報 ${count('info')}`);
process.exitCode = count('error') > 0 ? 1 : 0;

front matterを読むのは、Astroの getCollection ではなく、content.config.ts の glob({ pattern: '*/index.md', base: './src/content/posts' }) と同じファイルを自分で開く形にしました。Astroの外のNodeから astro:content は読めないためです。その代わり、schemaの条件をスクリプトの側にも書いているので、content.config.ts を変えたときは、こちらも合わせて直す必要があります。

内部リンクの点検では、本文からコードブロックとインラインのコードを取り除いてから、リンクを探しています。最初は取り除いておらず、このブログで流すと、記事の中のサンプルコードに書いた /no-such-page/ と /sitemap-index.xml を「存在しない記事へのリンク」として2件のエラーにしていました。

このブログで流した結果

今のこのブログ(185本、うち下書き2本)で流すと、エラーは0件、注意は2件でした。

$ node prepublish-check.mjs . --online
記事 185本(公開 183・下書き 2)/ 現在 2026-10-05 13:05(日本時間)
[注意] 下書きのまま | astro-content-prepublish-check-script | date 2026-10-05 10:00 を過ぎているのに draft: true
[注意] 下書きのまま | search-console-generative-ai-control-settings | date 2026-10-05 10:00 を過ぎているのに draft: true
[情報] description の長さ | google-fonts-render-blocking-lcp | 122字(目安 80〜120字)
[情報] 会社サイトのリンク | - | 26件のURLを確かめた
---
エラー 0・注意 2・情報 2

注意の2件は、この記事と、同じ日に書いたもう1本の下書きです。どちらも日付に仮の時刻(10:00)を入れたまま、その時刻を過ぎたので出ています。公開するときに日付を直します。情報の2件は、説明文が122字で目安の120字を2字超えている公開中の記事1本と、--online の結果です。公開中の記事から張っている会社サイトのURL 26件にHEADを送り、すべて200でした。

過去のコミットの状態に戻して流すと、起きたミスは見つかったか

見つかりました。ミスが起きたコミットのファイルを git archive で取り出し、--now に、そのコミットの時刻を入れて流しています。

bash
# あるコミットの記事と設定だけを取り出し、そのときの時刻で流す
h=e2fae09
mkdir -p at-$h
git archive $h package.json src | tar -x -C at-$h
ln -s "$PWD/node_modules" at-$h/node_modules   # js-yaml を読むため
node prepublish-check.mjs at-$h --now 2026-10-04T23:21:18+09:00 \
  --publish google-fonts-render-blocking-lcp,utm-parameter-naming-audit-script
コミット(--now) エラー 注意 出たもの
a1351bd(10/3 10:04:56) 6 8 未来の日付:公開した3本の date と modified。注意:3本の icon なし、seoTitleが32字を超える5本
a9d74e7(10/4 7:29:30) 4 1 未来の日付:公開した2本の date と modified。注意:seoTitleが47字の1本
5519be3(10/4 22:58:44) 23 0 未来の日付:22本の modified が23:30、1本が23:00
e2fae09(10/4 23:21:18) 22 0 未来の日付:上と同じ22本の modified。情報:下書きの2本
e2fae09 に --publish を付けた場合 24 0 上に加えて、公開するはずの2本が draft: true のまま
b4e486c(10/4 23:21:44) 22 0 下書きは外れたが、22本の modified はまだ8分先

a1351bdでは、日付のエラーと一緒に、公開した3本に表紙のアイコンが無いことも出ました。このときは7分後の4ad2e9e(10:11)でアイコンを足しています。seoTitleが長い5本は、どれもWordPressの時代からある記事で、その後のd8f4bdc(10月4日 22:10)までに直しています。

e2fae09の出力は次のとおりです(22本の同じ行は途中を省いています)。

記事 181本(公開 179・下書き 2)/ 現在 2026-10-04 23:21(日本時間)
[エラー] 公開する記事 | google-fonts-render-blocking-lcp | まだ draft: true(このままでは本番に出ない)
[エラー] 公開する記事 | utm-parameter-naming-audit-script | まだ draft: true(このままでは本番に出ない)
[エラー] 未来の日付 | 【stripe】next-js-15とprismaで実装するサブスクリプション機能 | modified 2026-10-04 23:30 が現在より後
[エラー] 未来の日付 | ai-meeting-minutes-recording-workflow | modified 2026-10-04 23:30 が現在より後
(同じ形の「未来の日付」の行が18行続く)
[エラー] 未来の日付 | wedding-venue-seo-area-search | modified 2026-10-04 23:30 が現在より後
[エラー] 未来の日付 | wordpress-to-headless-cms-migration-steps | modified 2026-10-04 23:30 が現在より後
[情報] description の長さ | google-fonts-render-blocking-lcp | 122字(目安 80〜120字)
[情報] 下書き | google-fonts-render-blocking-lcp | 本番には出ない(date 2026-10-05 07:10)
[情報] 下書き | utm-parameter-naming-audit-script | 本番には出ない(date 2026-10-05 07:20)
---
エラー 24・注意 0・情報 3

--publish を付けない場合、下書きの2本は「情報」として一覧に出るだけで、エラーにはなりません。日付が未来の下書きは「予定どおりの下書き」と区別がつかないからです。公開するつもりだったかどうかはスクリプトには分からないので、公開する記事を --publish で伝える形にしました。

気づいていなかった、22本の更新日時

5519be3は、22本の記事の冒頭の文を短く直したコミットです。このとき modified を一律に「2026-10-04T23:30:00+09:00」にしていて、コミットした22:58の時点では32分先の時刻でした。23:30を過ぎれば自然に過去の時刻になるので、画面を見ても気づかず、このスクリプトを過去のコミットに流して初めて分かりました。デプロイした正確な時刻は記録していないため、本番で何分のあいだ未来の更新日時が出ていたかは分かりません。

会社サイトのコラムへのリンクが404だった下書き

10月4日に書いたSupabaseの記事(SupabaseのRLSで店舗ごとにデータを分ける実装)の下書きでは、最初、会社サイトのコラム /column/reservation-system-before-choosing にリンクしていました。このコラムは会社サイトの側でまだ下書きで、開くと404でした。書いている途中で気づき、公開中のコラム /column/saas-or-custom に差し替えています。下書きを最初にコミットしたc82a5ca(10月4日 16:02)の時点で、すでに差し替えた後のリンクです。

このミスは、--online で見つけられる種類のものです。今のリポジトリをコピーし、この記事のリンクだけを当時のURLに戻して流すと、エラーになりました。

$ node prepublish-check.mjs col404 --online
記事 185本(公開 183・下書き 2)/ 現在 2026-10-05 13:10(日本時間)
[エラー] 会社サイトのリンク | supabase-rls-multi-tenant-reservations | https://www.bundlyze.co.jp/column/reservation-system-before-choosing → 404
[注意] 下書きのまま | astro-content-prepublish-check-script | date 2026-10-05 10:00 を過ぎているのに draft: true
[注意] 下書きのまま | search-console-generative-ai-control-settings | date 2026-10-05 10:00 を過ぎているのに draft: true
[情報] description の長さ | google-fonts-render-blocking-lcp | 122字(目安 80〜120字)
[情報] 会社サイトのリンク | - | 27件のURLを確かめた
---
エラー 1・注意 2・情報 2

ただし、--online が確かめるのは、draft: false の記事から張っているURLだけです。この記事は今は公開しているので出ましたが、下書きの段階で流しても、このリンクは確かめません。公開するときに draft を外してから流せば、その時点で見つかります。リンク先が会社サイトの下書きの場合は、先に会社サイトのコラムを公開するか、公開中のページに差し替えるかを決めます。

わざと壊したコピーで、点検が働くかを確かめた

今のリポジトリのファイルをコピーし、5か所を壊して流すと、5か所とも出ました。

壊したところ 出たもの
他の2本からリンクされている記事を draft: true にした リンクしている2本の「下書き(本番では404)」のエラーと、その記事の「下書きのまま」の注意
src/data/hubs.ts から、ブライダルの事業者向けの記事を1本消した 「どのテーマにも入っていない」のエラー
1本の cta を、決められた値に無い lp にした 型のエラー
1本の title を空の文字列にした 必須項目のエラー
1本の icon を消した icon なしの注意
記事 185本(公開 182・下書き 3)/ 現在 2026-10-05 12:00(日本時間)
[エラー] ハブ | wedding-venue-seo-area-search | likelove の事業者向けの記事なのに、どのテーマにも入っていない
[エラー] 内部リンク | llmo-aio-audit-script-build-html | /seo-audit-script-build-html/ は下書き(本番では404)
[エラー] 内部リンク | utm-parameter-naming-audit-script | /seo-audit-script-build-html/ は下書き(本番では404)
[エラー] 型 | php-array-loop | cta「lp」は決められた値にない
[エラー] 必須項目 | xampp-setup | title がない
[注意] icon なし | one-click-copy | draft: false なのに icon が未設定(カテゴリの既定の表紙になる)
[注意] 下書きのまま | astro-content-prepublish-check-script | date 2026-10-05 10:00 を過ぎているのに draft: true
[注意] 下書きのまま | search-console-generative-ai-control-settings | date 2026-10-05 10:00 を過ぎているのに draft: true
[注意] 下書きのまま | seo-audit-script-build-html | date 2026-10-04 22:11 を過ぎているのに draft: true
[情報] description の長さ | google-fonts-render-blocking-lcp | 122字(目安 80〜120字)
[情報] 内部リンク | astro-content-prepublish-check-script | /seo-audit-script-build-html/ も下書き。公開するときは先に(または同時に)公開する
---
エラー 5・注意 4・情報 2

注意のうち2件は、この記事ともう1本の下書きの日付で、壊したところとは関係ありません。最後の「情報」は、この記事が本文で /seo-audit-script-build-html/ にリンクしているため、リンク先も下書きになったことを知らせているものです。

リポジトリの中だけでは確かめられないこと

このスクリプトが見ているのは、リポジトリの中のファイルです。デプロイした後の本番の状態は分かりません。

  • 公開した記事のURLが、本番で実際に200を返すか(デプロイが終わったかどうか)
  • 表紙の画像が実際に作られたか(icon の名前が、表紙を作るスクリプトの一覧にあるかまでは見ていません)
  • 会社サイト以外の外部リンクが切れていないか(--online は会社サイトのURLだけです)
  • titleやdescriptionが、ビルド後のHTMLで正しく出ているか

最後の点は、ビルド後のHTMLを全ページ読むSEO一括点検のスクリプトで見ています。公開前はこのスクリプトでファイルを、ビルドの後はHTMLを、デプロイの後は本番のURLを、と見る場所を分けています。

デプロイが成功した直後に、新しい記事が404を返した

1つ目の点は、実際に起きています。2026年10月4日 21:55ごろ、9本を公開したコミット806bca2(21:53)をデプロイし、Cloudflare Pagesのデプロイが成功と出た直後に新しい9本のURLを開くと、8本が404でした。1分ほど後に開き直すと、9本とも200でした。10月5日 9:35ごろにも、2本を公開したコミット7ce90c7(9:33)のデプロイの直後に、新しいURLの1本で同じことが起きています。

ファイルの中身には問題がなく、少し待てば200になったので、デプロイの完了と、新しいファイルが配信されるようになるまでのタイミングの差だと考えています(Cloudflareの仕組みとして確かめたわけではありません)。リポジトリのファイルを見るこのスクリプトでは、この種類のミスは見つけられません。そこで、公開の手順の側で、次の2つを決めました。

  • デプロイの後、新しい記事のURLに、毎回違う値のクエリを付けて開く(途中で404がキャッシュされていても、その結果を見ないため)
  • IndexNowで検索エンジンに送る直前に、もう一度200を確かめる。404のURLは送らない
bash
# デプロイの後と、IndexNow で送る直前に流す
for s in slugA slugB; do
  curl -s -o /dev/null -w "%{http_code} $s\n" "https://www.yamayamabloglink.com/$s/?v=$(date +%s)"
done

組み込み方と、止めるかどうかの決め方

このスクリプトは、まだリポジトリには入れておらず、公開の前に手で流して試した段階です。エラーが1件でもあると終了コードが1になるため、package.json の prebuild に入れれば、エラーのある状態ではビルドとデプロイが止まります。

止める仕組みにするかどうかは、誰が公開するかで決めるのがよいと考えています。1人で書いて公開しているなら、コミットの前に流す習慣で足ります。複数の人が記事を書き、レビューの後に自動でデプロイするなら、prebuild で止めておくほうが、見落としがそのまま本番に出ることを防げます。ただし、prebuild に入れると --now は現在の時刻になるので、予約公開の仕組みを作る場合は、公開予定の記事を日付で絞る処理を別に足す必要があります。

更新を誰がどう担うかの決め方は、会社のコラムホームページの更新は、頻度より「変わった日に直せるか」で決めるに書いています。株式会社bundlyzeでは、ホームページを作るときに、誰がどのくらいの頻度で何を更新するかを先に伺い、公開前の点検も含めた更新の進め方まで一緒に決めています。サイト制作のご相談はホームページ制作のページで受け付けています。

動作確認した環境

確認日は2026年10月5日です。

  • macOS 26.6.2、Node.js 22.23.2、Astro 7.3.5、js-yaml 4.3.2(Astroの依存として入っているもの)
  • 対象:このブログのリポジトリ(記事185本、うち下書き2本)と、git archive で取り出した a1351bd・a9d74e7・5519be3・e2fae09・b4e486c の状態
  • --online のHEADは、公開中の記事から張っている会社サイトのURL 26件に送った。404の確認は、リンクを当時のURLに戻したコピー(27件)で行った
  • デプロイ直後の404は、2026年10月4日 21:55ごろと10月5日 9:35ごろに、本番のURLを開いて見たもの

確かめていないこと:

  • Windowsでの動作(パスの扱いは node:path に任せていますが、流していません)
  • prebuild に組み込んだ状態での、Cloudflare Pagesのビルド環境での動作

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

スクリプトの作りと日付の扱いは、次の文書を根拠にしています。どれも2026年10月5日に開いて確かめました。

  • content collectionsのschemaはZodで検証され、合わないファイルがあればエラーになること、glob() で読むファイルの決め方 → Astro Docs「Content collections」
  • Node.js 22.18.0から、型を取り除いてTypeScriptのファイルを実行する機能が既定で有効になったこと、型を取り除くだけでは動かない構文があること → Node.js v22 Docs「Modules: TypeScript」
  • 未来の日付を指定しないこと、表示される日付と構造化データの日付をそろえること → Google 検索セントラル「Google 検索の検索結果に署名日を追加する」(英語版の最終更新 2025-12-10、日本語版 2026-02-20)
  • git archive で特定のコミットのファイルを取り出す方法 → Git Documentation「git-archive」
  • fetch の redirect: 'manual'(リダイレクトを追わずに応答を受け取る) → MDN「RequestInit: redirect」。MDNの説明はブラウザでの動きで、ブラウザでは中身を伏せた応答になります。Node.js 22の fetch では、https://yamayamabloglink.com/ に送ると、追わずに301とLocationを受け取れることを確かめました

よくある質問

Astroのビルドが通れば、公開してよい状態だと言えますか?

言えません。content collectionsのschemaに合わない値(必須の項目の抜け、決められた値以外のcta)はビルドで止まりますが、未来の日付、下書きの記事へのリンク、表紙の未設定、空の文字列のtitleは、ビルドが通ったまま本番に出ます。この記事のスクリプトは、ビルドでは止まらないものを中心に見ています。

日付が未来の記事を公開すると、何が困りますか?

Googleの日付についての説明には、未来の日付を指定しないことが挙げられています。このブログは日付をそのままページの公開日と構造化データのdatePublished・dateModifiedに出すので、日付が未来のまま公開すると、その時刻になるまで、まだ来ていない日時を公開日として示すことになります。

下書きの記事へのリンクは、ビルドで見つけられませんか?

このブログでは見つけられません。下書きはビルドの対象から外れるだけで、リンクを張った側の記事はそのまま書き出されるため、本番ではリンク先が404になります。ビルド後のHTMLを点検する方法もありますが、このスクリプトはfront matterのdraftを直接見るので、どの記事が下書きのせいで切れているかがすぐ分かります。

「公開する」と書いたコミットで、記事が下書きのまま残るのを防ぐには?

公開するつもりの記事のslugを --publish に渡します。その記事がまだ draft: true なら、エラーとして止まります。このブログで実際に起きた、公開と書いたコミットで2本が下書きのまま残ったときの状態に流すと、2本ともエラーになりました。