記事を書く前に重複チェックを打つ運用にしている。同じ一次情報から2本目の記事を書かないための検査である。
この日は業界紙2本の URL でチェックを打ち、「未使用の一次情報です。記事にしてよい」と返ってきた。本文を最後まで書いた。ビルドの直前に走る検査で落ちた。
1 2 3
| duplicate-source:fail 同じ一次情報を別slugで二重掲載しています 1組: <www.ucc.co.jp/company/news/2026/rel260827.html> = 2026-08-27 news-ucc-and-healthy-... / 2026-09-23 news-ucc-andhealthy-...
|
落ちた URL は、チェックを打ったときには手元に無かった。業界紙の記事を読んだあとでメーカー公式のリリースを見つけ、出典としてはそちらを主に据えたからである。つまり最終的な出典3本のうち、事前にチェックしたのは2本だけだった。
記事は取り下げた。書いた時間がそのまま消えた。
部分集合で通る検査は「通った」と言ってはいけない
この構造は重複チェックに限らない。集合の一部で検査して、全体について結論を出しているという形である。
1 2 3
| 検査したもの: {A, B} 実際に使うもの: {A, B, C} 返した答え: 「重複はありません」
|
C が既出なら答えは嘘になる。しかも検査した時点では嘘ではなかったので、ログを見ても間違いに見えない。
厄介なのは、この順序が自然だという点である。調べる→書く、の間に出典が増えるのは調査が進んだということであって、悪いことではない。増えた瞬間に検査をやり直す理由は、普通は思いつかない。
最小の再現
dup.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 25 26 27 28 29
| "use strict";
const PUBLISHED = [ { slug: "news-ucc-a", sources: ["https://www.ucc.co.jp/company/news/2026/rel260827.html"] }, ];
function normSource(url) { return String(url) .replace(/^https?:\/\//i, "") .replace(/\/+$/, "") .toLowerCase(); }
function findUsed(urls) { const used = []; for (const url of urls) { const key = normSource(url); for (const a of PUBLISHED) { if (a.sources.some((s) => normSource(s) === key)) { used.push({ url, by: a.slug }); } } } return used; }
module.exports = { normSource, findUsed };
|
事前チェックと最終状態を並べて実行する。
1 2 3 4 5 6 7 8 9 10
| const { findUsed } = require("./dup");
const before = ["https://www.ssnp.co.jp/beverage/703453/", "https://shokuhin.net/157317/"]; console.log("before:", findUsed(before));
const after = [...before, "https://www.ucc.co.jp/company/news/2026/rel260827.html"]; console.log("after:", findUsed(after));
|
before は空配列を返す。何も間違っていない。間違っているのは、この空配列を「この記事は書いてよい」と読んだことのほうである。
直し方は2つある
1. 検査の呼び出しを「書く直前」に移す
調査が終わって出典が確定した時点で、確定した全 URL を渡してもう一度打つ。手順としてはこれだけで、実装は変わらない。
安いが、忘れる。忘れたときに何も起きないので、忘れたことにも気づかない。
2. 検査が「何を検査したか」を返す
呼び出し側の記憶に頼らない形にする。検査の結果に、そのとき渡された鍵をそのまま残しておく。
1 2 3 4
| function checkSources(urls) { const checked = urls.map(normSource); return { ok: findUsed(urls).length === 0, checked, checkedAt: new Date().toISOString() }; }
|
そして最終的な公開処理で、記事が載せている出典と checked が一致するかを見る。
1 2 3 4 5 6 7 8
| function verifyBeforePublish(article, lastCheck) { const actual = article.sources.map(normSource).sort(); const checked = [...(lastCheck.checked || [])].sort(); const missing = actual.filter((s) => !checked.includes(s)); if (missing.length) { throw new Error(`検査していない出典がある: ${missing.join(", ")}`); } }
|
これなら、出典が増えたときに公開処理が止まる。止まったら検査を打ち直せばよい。
退行ガードも置いておく。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| "use strict"; const { test } = require("node:test"); const assert = require("node:assert/strict"); const { normSource, findUsed } = require("./dup");
test("スキームと末尾スラッシュと大小文字を無視する", () => { assert.equal(normSource("HTTPS://Example.com/a/"), "example.com/a"); });
test("部分集合では重複が出ない(これが嘘のもと)", () => { assert.deepEqual(findUsed(["https://www.ssnp.co.jp/beverage/703453/"]), []); });
test("全集合なら重複が出る", () => { const hit = findUsed([ "https://www.ssnp.co.jp/beverage/703453/", "https://www.ucc.co.jp/company/news/2026/rel260827.html", ]); assert.equal(hit.length, 1); assert.equal(hit[0].by, "news-ucc-a"); });
|
二段構えの検査はそれ自体が正しい
誤解のないように書いておくと、事前チェックが無駄だったわけではない。ビルド直前の検査は記事を書き切ってからでないと動かないので、事前に安く落とせる分は落としたほうがよい。
問題は、安い検査のほうが「合格」と言い切ってしまうことにある。安い検査は「今渡された範囲では見つからなかった」としか言えない。その限界を文言と戻り値の両方に出しておかないと、呼ぶ側は全体の保証として読む。
今回書き損じた記事は コーヒーのセール情報 の1本だった。調査自体は残ったので、同じ日に別の題材で書き直して公開している。調べたことは無駄にならないが、書いたことは無駄になる。検査を打つ場所を1つ後ろにずらすだけで防げるなら、ずらしておくほうが安い。