Skip to content

Commit 391eef2

Browse files
committed
feat(toolify365): postbuild に meta description 門番を追加
Bing WMT のエラーは実質すべて description 超過だが、postbuild は <title> と canonical しか検査していなかった。buildMetadata() の clampDescription() を経由しないルートが1本増えるだけで再発し、 型も通り画面も正常なので気づけない。ビルド出力のHTMLを直接検査する。 ★長さは**コードポイント**で測る(Bing/Google の切り基準)。 表示幅で測ると日本語の適正な160字が298に見えて偽陽性になる。 幅は SERP の見た目の話なので警告どまり(build-gate-severity-split)。 致命: description欠落/空・160超(3タグとも)・生の翻訳キー・3タグ不一致 警告: 表示幅300超・50字未満 現状の実測: index対象651ページ中、全項目0件で通過。 Claude-Session: https://claude.ai/code/session_0197CFsVScT9VZB5Ww1NW4uj
1 parent 16895bf commit 391eef2

2 files changed

Lines changed: 156 additions & 1 deletion

File tree

‎site/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
"test": "vitest run",
1717
"test:watch": "vitest",
1818
"indexnow": "tsx scripts/indexnow.ts",
19-
"postbuild": "node scripts/check-built-titles.mjs && node scripts/check-built-canonical.mjs"
19+
"postbuild": "node scripts/check-built-titles.mjs && node scripts/check-built-canonical.mjs && node scripts/check-built-descriptions.mjs"
2020
},
2121
"dependencies": {
2222
"@types/qrcode": "^1.5.6",
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
/**
2+
* ビルド出力の meta description をデプロイ前に検査する門番。(toolify365)
3+
*
4+
* なぜ (2026-08-23):
5+
* Bing WMT のエラーは実質すべて「メタ説明が長すぎます」だった
6+
* ([[bing-errors-are-all-meta-description]])。出口 `buildMetadata()` の
7+
* clampDescription() で 160 コードポイントに一括クランプ済みだが、
8+
* postbuild の門番は <title> と canonical しか見ておらず description は無検査。
9+
* `buildMetadata()` を通さず自前で `export const metadata` を書いたルートが
10+
* 1本増えるだけで再発し、しかも型は通り画面も正常なので気づけない。
11+
* → 出来上がった HTML を検査する。
12+
*
13+
* ★clamp は**コードポイント数**で行う(Bing/Google の切り基準)。
14+
* 表示幅(全角2)で測ると日本語の適正な160字が「298」に見えて偽陽性になる。
15+
* 幅は SERP の見た目の話なので警告どまりにする。
16+
*
17+
* 致命 (デプロイ中止):
18+
* 1. index 対象ページに description が無い / 空
19+
* 2. description / og:description / twitter:description が 160 コードポイント超
20+
* = clamp をすり抜けた(buildMetadata() を経由していないルート)
21+
* 3. description に生の翻訳キーが出ている
22+
* 4. 3タグの不一致(buildMetadata() は同一値を入れるので、ズレ = 別経路)
23+
*
24+
* 警告 (出荷は止めない, [[build-gate-severity-split]]):
25+
* - 表示幅が 300 超(SERP で切れるだけ。ページは機能しインデックスもされる)
26+
* - 50 コードポイント未満(薄い説明文。順位への実害は未確認)
27+
*
28+
* 検査対象外: noindex ページ、隣の .meta が status 404 のプリレンダー、`_` 始まり。
29+
*
30+
* 使い方: node scripts/check-built-descriptions.mjs [.next/server/app]
31+
*/
32+
import { readdirSync, readFileSync, statSync, existsSync } from "node:fs";
33+
import { join, dirname } from "node:path";
34+
import { fileURLToPath } from "node:url";
35+
36+
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
37+
const DIST = process.env.NEXT_DIST_DIR || ".next";
38+
const DIR = process.argv[2] ?? join(ROOT, DIST, "server/app");
39+
if (!existsSync(DIR)) {
40+
console.error(`ビルド出力が無い: ${DIR}\n先に npm run build を実行すること。`);
41+
process.exit(1);
42+
}
43+
44+
// buildMetadata() の MAX_DESC と一致させること(片方だけ動かすと門番が嘘をつく)。
45+
const MAX_DESC = 160;
46+
const WARN_WIDTH = 300;
47+
const MIN_DESC = 50;
48+
49+
const WIDE = /[ᄀ-ᅟ⺀-꓏가-힣豈-﫿︰-﹏＀-⦆¢-₩]/;
50+
const serpWidth = (t) => [...t].reduce((n, c) => n + (WIDE.test(c) ? 2 : 1), 0);
51+
// ★HTML属性値は必ずアンエスケープしてから数えること。&amp; を5文字で数えると
52+
// 偽陽性になる([[verify-with-the-gates-own-normalizer]])。
53+
const unescapeHtml = (s) =>
54+
s.replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">")
55+
.replace(/&quot;/g, '"').replace(/&#x27;/g, "'").replace(/&#39;/g, "'");
56+
57+
const files = [];
58+
(function walk(d) {
59+
for (const e of readdirSync(d)) {
60+
const p = join(d, e);
61+
if (statSync(p).isDirectory()) walk(p);
62+
else if (e.endsWith(".html")) files.push(p);
63+
}
64+
})(DIR);
65+
66+
/** 隣の .meta が 404 のプリレンダーは 404 配信なので検査しない。
67+
* ★404 判定に HTML 本文を使うな: not-found 境界のマークアップは全ページの
68+
* RSC ペイロードに埋まっており、本文判定にすると全ページが 404 扱いになって
69+
* 検査が空振りする(check-built-canonical.mjs と同じ罠)。 */
70+
function isNotFound(file) {
71+
const meta = file.replace(/\.html$/, ".meta");
72+
if (!existsSync(meta)) return false;
73+
try {
74+
return JSON.parse(readFileSync(meta, "utf8")).status === 404;
75+
} catch {
76+
return false;
77+
}
78+
}
79+
80+
// ★1タグ1行とは限らない。属性抽出は行単位 grep ではなく全文の正規表現で行う
81+
// ([[line-anchored-grep-misses-inline-props]])。
82+
const TAGS = [
83+
["description", /<meta name="description" content="([^"]*)"/],
84+
["og:description", /<meta property="og:description" content="([^"]*)"/],
85+
["twitter:description", /<meta name="twitter:description" content="([^"]*)"/],
86+
];
87+
88+
const missing = [], empty = [], over = [], rawKey = [], mismatch = [];
89+
const wide = [], thin = [];
90+
let checked = 0;
91+
92+
for (const f of files) {
93+
const rel = f.slice(DIR.length + 1).replace(/\.html$/, "");
94+
if (rel.startsWith("_") || rel.split("/").some((s) => s.startsWith("_"))) continue;
95+
const html = readFileSync(f, "utf8");
96+
const rb = html.match(/<meta name="robots" content="([^"]*)"/);
97+
if (rb && /noindex/.test(rb[1])) continue;
98+
if (isNotFound(f)) continue;
99+
checked++;
100+
101+
const vals = {};
102+
for (const [name, re] of TAGS) {
103+
const m = html.match(re);
104+
if (!m) continue;
105+
vals[name] = unescapeHtml(m[1]).replace(/\s+/g, " ").trim();
106+
}
107+
108+
const d = vals["description"];
109+
if (d === undefined) { missing.push(rel); continue; }
110+
if (!d) { empty.push(rel); continue; }
111+
112+
for (const [name, v] of Object.entries(vals)) {
113+
const n = [...v].length;
114+
if (n > MAX_DESC) over.push(`${n}字 ${rel} [${name}] ${v.slice(0, 60)}…`);
115+
}
116+
if (/^[a-z][A-Za-z0-9_]*(\.[A-Za-z0-9_]+)+(\s|$)/.test(d)) rawKey.push(`${rel} ${d.slice(0, 80)}`);
117+
118+
const others = Object.entries(vals).filter(([k]) => k !== "description");
119+
for (const [name, v] of others) {
120+
if (v !== d) mismatch.push(`${rel} [${name}] が description と不一致`);
121+
}
122+
123+
const w = serpWidth(d);
124+
if (w > WARN_WIDTH) wide.push(`幅${w} ${rel}`);
125+
if ([...d].length < MIN_DESC) thin.push(`${[...d].length}字 ${rel}`);
126+
}
127+
128+
const fatal = [
129+
[`description が無い`, missing],
130+
[`description が空`, empty],
131+
[`description が ${MAX_DESC} コードポイント超(clamp をすり抜けている)`, over],
132+
[`description に生の翻訳キーが出ている`, rawKey],
133+
[`description / og / twitter が不一致(buildMetadata() を経由していない)`, mismatch],
134+
];
135+
136+
let failed = 0;
137+
console.log(`検査対象: ${checked} ページ (index対象 / ${files.length} HTML中) — ${DIR.replace(ROOT + "/", "")}`);
138+
for (const [label, list] of fatal) {
139+
if (!list.length) { console.log(` ✓ ${label}: 0`); continue; }
140+
failed += list.length;
141+
console.error(` ✗ ${label}: ${list.length}`);
142+
for (const x of list.slice(0, 8)) console.error(` ${x}`);
143+
if (list.length > 8) console.error(` … 他 ${list.length - 8} 件`);
144+
}
145+
for (const [label, list] of [
146+
[`description の表示幅が ${WARN_WIDTH} 超(SERPで切れる)`, wide],
147+
[`description が ${MIN_DESC} コードポイント未満(薄い)`, thin],
148+
]) {
149+
if (!list.length) continue;
150+
console.warn(` ⚠ ${label}: ${list.length}【警告・出荷は止めない】`);
151+
for (const x of list.slice(0, 8)) console.warn(` ${x}`);
152+
if (list.length > 8) console.warn(` … 他 ${list.length - 8} 件`);
153+
}
154+
if (failed) { console.error(`\n❌ ${failed} 件。出荷しない。`); process.exit(1); }
155+
console.log("\n✓ ビルド出力の meta description 検査を通過");

0 commit comments

Comments
 (0)