セール情報サイトのコーヒーのセール情報で、味の素AGF の同じ発表を題材にした記事が2本公開されていた。片方の slug は news-agf-blendy-oomugi-to-coffee-2026-10-01、もう片方は news-agf-blendy-omugi-to-coffee-2026-10-01。「大麦」のローマ字表記が oomugi と omugi で1文字違うだけである。
重複を止める仕掛けは前から入れてあった。それが3段とも素通りした話と、どう塞いだかを書く。
3つの関門が同時に抜けた
このサイト群(お茶 / 洗剤 / 麺類 / コーヒー / ビール / マンガお得情報)には重複ガードが3つある。
- slug の完全一致 —
oomugi と omugi は別文字列なので当たらない。
topicKey の完全一致 — 記事には「ブランド+商品名+年」を正規化した topicKey を持たせている。ところが値そのものが agf-blendy-oomugi-to-coffee-2026-10-01 と agf-blendy-omugi-to-coffee-2026-10-01 で、やはり別文字列だった。
- 出典URLの一致 — 1本目は PR TIMES 配信、2本目はメーカー公式と ASKUL。同じ発表でも配信経路が違えば URL は1つも重ならない。
topicKey の正規化は入っていたが、吸収していたのは区切り文字・全角半角・大文字小文字だけだった。実装はこうなっている。
1 2 3 4 5 6 7 8 9 10
| function normalizeTopicKey(v) { if (typeof v !== "string") return ""; return v .normalize("NFKC") .toLowerCase() .replace(/[\s _/::,,、.。||~〜―—ー]+/g, "-") .replace(/-+/g, "-") .replace(/^-+|-+$/g, ""); }
|
orion:natsu-fukubukuro:2026 と Orion natsu fukubukuro 2026 は同じになる。しかし長音をローマ字でどう書くか(oomugi / omugi / ohmugi、ooiocha / oiocha)は、この正規化の外側にある。
畳むと同じになるキーで突き合わせる
対処は「英数字以外を落としたうえで、連続する同じ文字を1つに畳む」キーを別に作り、そちらでも突き合わせることにした。
1 2 3 4 5
| function looseTopicKey(v) { const base = normalizeTopicKey(v); if (!base) return ""; return base.replace(/[^a-z0-9]+/g, "").replace(/(.)\1+/g, "$1"); }
|
これで agf-blendy-oomugi-to-coffee-2026-10-01 も agf-blendy-omugi-to-coffee-2026-10-01 も agfblendyomugitocofe2026101 になる。coffee の ff や full-renewal / fullrenewal の揺れも同時に吸収される。
意味の類似は見ていない。編集距離やトークンの重なりで判定すると、lion-nanox-one-2026-09 と lion-nanox-one-pro-2026-09 のような別商品の型番違いを大量に誤検出する。誤検出が混ざった検査は読まれなくなるので、見るのは「同じ語を別の綴りで書いた」だけに絞る。実際、この2つは畳んでも別のキーになる。
公開済みの記事でビルドを止めない
素直に実装すると、既に公開されている重複3組でビルドが落ちる。どちらを残して 301 を向けるかは公開URLを消す判断なので人に委ねたいのに、その判断が下りるまでサイト全体が deploy できなくなる。それでは困る。
そこで境界日を置き、その日以降の記事を1本でも含む組だけを止めることにした。
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
| const LOOSE_TOPIC_KEY_SINCE = "2026-09-21";
function looseDuplicateTopicProblems(list, since) { const from = String(since || ""); const groups = new Map(); (list || []).forEach((a, i) => { const k = looseTopicKey(a && a.topicKey); if (!k) return; if (!groups.has(k)) groups.set(k, []); groups.get(k).push({ slug: String((a && a.slug) || `(slug なし・${i} 件目)`), topicKey: normalizeTopicKey(a && a.topicKey), when: String((a && a.confirmed) || (a && a.date) || ""), }); }); const problems = []; for (const [k, rs] of groups) { if (rs.length < 2) continue; if (new Set(rs.map((r) => r.topicKey)).size < 2) continue; if (from && !rs.some((r) => r.when && r.when >= from)) continue; problems.push(`topicKey の綴りゆれ重複: 畳むと "${k}" になる記事が ${rs.length} 件あります`); } return problems; }
|
実際の実装では、この文字列の先頭に「止める側の印」を付けている。警告と同じ見た目にすると読み飛ばされるので、止めるものと警告するものは印を変えるのが効く。
境界日を入れる前に、実データで既存の組が全部その手前にあることを確かめている。6サイト1082記事で見つかった3組は次のとおりで、いちばん新しいもので 2026-09-02 だった。
| サイト |
畳んだキー |
記事の日付 |
| ocha |
itoenoiochagimark20260921 |
2026-08-15 / 2026-08-08 |
| senzai |
pglenoreaudeluxefulrenewal202609 |
2026-09-02 / 2026-08-25 |
| coffee |
agfblendyomugitocofe2026101 |
2026-08-26 / 2026-09-01 |
境界日を 2026-09-21 にしておけば、既存記事は一件も止まらず、これから書く記事が既存とぶつかったときだけ落ちる。
実際に試す
手元の JSON で畳みの挙動を確かめられる。Node.js 18 以降で動く(実行は Node.js 24.11.1 で確認)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| node -e ' const norm = (v) => String(v || "").normalize("NFKC").toLowerCase() .replace(/[\s _\/::,,、.。||~〜―—ー]+/g, "-") .replace(/-+/g, "-").replace(/^-+|-+\$/g, ""); const loose = (v) => norm(v).replace(/[^a-z0-9]+/g, "").replace(/(.)\1+/g, "\$1");
const pairs = [ ["agf-blendy-oomugi-to-coffee-2026-10-01", "agf-blendy-omugi-to-coffee-2026-10-01"], ["itoen-ooiocha-gi-mark-2026-09-21", "itoen-oiocha-gi-mark-2026-09-21"], ["pg-lenor-eaudeluxe-full-renewal-2026-09","pg-lenor-eaudeluxe-fullrenewal-2026-09"], ["lion-nanox-one-2026-09", "lion-nanox-one-pro-2026-09"], ]; for (const [a, b] of pairs) { console.log(loose(a) === loose(b) ? "同じ題材" : "別の題材", "|", a, "/", b); }'
|
出力はこうなる。
1 2 3 4
| 同じ題材 | agf-blendy-oomugi-to-coffee-2026-10-01 / agf-blendy-omugi-to-coffee-2026-10-01 同じ題材 | itoen-ooiocha-gi-mark-2026-09-21 / itoen-oiocha-gi-mark-2026-09-21 同じ題材 | pg-lenor-eaudeluxe-full-renewal-2026-09 / pg-lenor-eaudeluxe-fullrenewal-2026-09 別の題材 | lion-nanox-one-2026-09 / lion-nanox-one-pro-2026-09
|
自分のデータで重複を洗うなら、topicKey に相当するキーを持つ配列に対してこれを回せばよい。
1 2 3 4 5 6 7 8 9 10 11
| node -e ' const arr = require("./data/articles.json"); const loose = (s) => String(s || "").toLowerCase() .replace(/[^a-z0-9]+/g, "").replace(/(.)\1+/g, "\$1"); const m = {}; for (const a of arr) { const k = loose(a.topicKey); if (!k) continue; (m[k] = m[k] || []).push(a.slug); } Object.entries(m).filter(([, v]) => v.length > 1).forEach(([k, v]) => console.log(k, v));'
|
学び
- 正規化は「どこまで畳むか」を決めた時点で穴の位置が決まる。区切り記号まで畳んでも、綴りの揺れは残る。
- 後から厳しくする検査には境界日を入れる。既存データを巻き込むと、判断が下りるまで出荷が止まる。止めたいのは「これから作るもの」だけのはずである。
- 緩めるより条件を足す。類似度のしきい値を下げるのではなく、綴りという別の軸を1本足したほうが誤検出が増えない。
同じ仕組みはお茶・洗剤・麺類・コーヒー・ビール・マンガお得情報の6サイトに同一テキストで入れてある。コピーどうしがずれないよう、各サイトに同じ固定ベクタのテストを置いて検出している。