自動生成した記事のデータに topic_anchors: ["一方向", "更新され続ける", "参加"] というフィールドを入れた。着想のもとにした題材から拾った語で、本文にその要素が織り込まれていることを示すためのものである。
ビルドは落ちた。
1 2 3
| Error: 題材・テイストの検証に失敗: encho-12kai-to-owaranai-hitokomi — topic_anchors「更新され続ける」が本文に1回も出てこない(表面挿入) / topic_anchors「参加」が本文に1回も出てこない(表面挿入)
|
正しい指摘だった。3語のうち1語しか本文に入っていなかった。残りの2語は、データの欄を埋めるために書いただけである。
この検査は こわいはなし の build.js に入っている。生成した短編に「何を着想にしたか」を記録する欄があり、その記録が本文と結びついていなければビルドを止める。
「表面挿入」という失敗
メタデータを持つコンテンツで起きる典型的な壊れ方がある。欄は埋まっているが、中身と関係していないという状態である。
- タグに「Docker」とあるのに本文に Docker が出てこない
keywords に10語並んでいるが、本文で扱っているのは2語だけ
- 着想元・参考元の欄が、実際には読まずに書かれている
どれも人間が読めば分かるが、読まないと分からない。そして生成の本数が増えるほど読まなくなる。
重要なのは、これが部分的には機械で測れるという点である。「タグの内容が本文の主題と合っているか」を判定するのは難しいが、「タグの文字列が本文に1度でも現れるか」を数えるのは簡単である。後者だけでも、欄を埋めるためだけに書かれた語はかなり落とせる。
最小の実装
anchor-check.js として保存する。Node 18 以降で動く。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| "use strict";
function checkAnchors(item) { const errs = []; const body = String(item.body || ""); for (const a of item.anchors || []) { const word = String(a).trim(); if (!word) { errs.push("anchors に空文字が入っている"); continue; } if (!body.includes(word)) { errs.push(`anchors「${word}」が本文に1回も出てこない(表面挿入)`); } } return errs; }
module.exports = { checkAnchors };
|
呼び出し側はこうする。エラーがあれば例外を投げてビルドを止める。
1 2 3 4 5 6 7 8 9 10 11
| const { checkAnchors } = require("./anchor-check");
function normalize(items) { for (const item of items) { const errs = checkAnchors(item); if (errs.length) { throw new Error(`検証に失敗: ${item.slug} — ${errs.join(" / ")}`); } } return items; }
|
実行して確かめる。
1 2 3 4 5 6 7 8
| const { checkAnchors } = require("./anchor-check");
console.log(checkAnchors({ slug: "sample", body: "帳面は一方向に書き足されていく。", anchors: ["一方向", "更新され続ける"], }));
|
node --test で回す退行ガードも一緒に置く。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| "use strict"; const { test } = require("node:test"); const assert = require("node:assert/strict"); const { checkAnchors } = require("./anchor-check");
test("本文に出てくる語は通る", () => { assert.deepEqual( checkAnchors({ slug: "a", body: "一方向に書き足す", anchors: ["一方向"] }), [] ); });
test("本文に無い語は落とす", () => { const errs = checkAnchors({ slug: "a", body: "一方向", anchors: ["参加"] }); assert.equal(errs.length, 1); assert.match(errs[0], /表面挿入/); });
test("空のアンカーを素通りさせない", () => { const errs = checkAnchors({ slug: "a", body: "本文", anchors: [" "] }); assert.equal(errs.length, 1); });
|
空文字を別扱いにしているのには理由がある。includes("") は常に true を返すので、空欄を許すと検査が素通りする穴になる。
warn にせず落とす
この種の検査を警告(warn)で出す設計にすると、まず効かない。出力が流れていくだけで、誰も直さないからである。
落とす設計にすると、直さないと公開できない。直す手段が2つあるのがこの検査の良いところで、
- 本文にその要素を実際に織り込む
- 欄からその語を削る
どちらでも合格になる。1を選べば内容が良くなり、2を選べばデータが正直になる。困るのは「欄は埋めたいが本文には入れたくない」という場合だけで、それはまさに止めたい状態である。
実際、冒頭の失敗は1で直した。本文に「口にすること自体が参加になっている」という一文と、「帳面は更新され続ける」という段落を足した。足してみると、もともと書きたかった話に近づいた。検査が内容を良くしたのであって、単に通しただけではない。
適用できる範囲
この形が効くのは、次の条件がそろうときである。
- メタデータの語が、本文にそのまま現れることが自然である(訳語や言い換えで表現されるものには効かない)
- 生成の本数が多く、人が全部読めない
- 欄を埋めること自体に動機がある(テンプレートの TODO を消したい、など)
逆に、分類タグのように本文に語が出ないほうが普通なものには使えない。その場合は「タグの語彙を固定リストに限る」「1記事あたりのタグ数に上限を置く」といった別の制約のほうが効く。
万能ではないが、書いたことと書いてあることのズレを機械で1段だけ潰せる。生成の量が増えたときに効いてくるのはこの1段である。