重複チェックを鍵の一部で打つと通って嘘になる

記事を書く前に重複チェックを打つ運用にしている。同じ一次情報から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();
}

/** 渡された URL 集合のうち、既出のものを返す */
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");

// 書く前に打ったチェック(業界紙2本だけ)
const before = ["https://www.ssnp.co.jp/beverage/703453/", "https://shokuhin.net/157317/"];
console.log("before:", findUsed(before)); // before: []

// 書き終わった記事が実際に載せた出典(公式リリースが増えている)
const after = [...before, "https://www.ucc.co.jp/company/news/2026/rel260827.html"];
console.log("after:", findUsed(after));
// after: [ { url: '...rel260827.html', by: 'news-ucc-a' } ]

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つ後ろにずらすだけで防げるなら、ずらしておくほうが安い。