同じ発表が2本公開される事故を、ローマ字の綴りゆれごとビルドで止める

セール情報サイトのコーヒーのセール情報で、味の素AGF の同じ発表を題材にした記事が2本公開されていた。片方の slug は news-agf-blendy-oomugi-to-coffee-2026-10-01、もう片方は news-agf-blendy-omugi-to-coffee-2026-10-01「大麦」のローマ字表記が oomugiomugi で1文字違うだけである。

重複を止める仕掛けは前から入れてあった。それが3段とも素通りした話と、どう塞いだかを書く。

3つの関門が同時に抜けた

このサイト群(お茶 / 洗剤 / 麺類 / コーヒー / ビール / マンガお得情報)には重複ガードが3つある。

  1. slug の完全一致oomugiomugi は別文字列なので当たらない。
  2. topicKey の完全一致 — 記事には「ブランド+商品名+年」を正規化した topicKey を持たせている。ところが値そのものが agf-blendy-oomugi-to-coffee-2026-10-01agf-blendy-omugi-to-coffee-2026-10-01 で、やはり別文字列だった。
  3. 出典URLの一致 — 1本目は PR TIMES 配信、2本目はメーカー公式と ASKUL。同じ発表でも配信経路が違えば URL は1つも重ならない

topicKey の正規化は入っていたが、吸収していたのは区切り文字・全角半角・大文字小文字だけだった。実装はこうなっている。

1
2
3
4
5
6
7
8
9
10
// build.js(6サイト同一テキスト。正本は ocha/build.js)
function normalizeTopicKey(v) {
if (typeof v !== "string") return "";
return v
.normalize("NFKC")
.toLowerCase()
.replace(/[\s _/::,,、.。||~〜―—ー]+/g, "-")
.replace(/-+/g, "-")
.replace(/^-+|-+$/g, "");
}

orion:natsu-fukubukuro:2026Orion natsu fukubukuro 2026 は同じになる。しかし長音をローマ字でどう書くかoomugi / omugi / ohmugiooiocha / 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-01agf-blendy-omugi-to-coffee-2026-10-01agfblendyomugitocofe2026101 になる。coffeefffull-renewal / fullrenewal の揺れも同時に吸収される。

意味の類似は見ていない。編集距離やトークンの重なりで判定すると、lion-nanox-one-2026-09lion-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;
// since 以降の記事が1本も無い組=既に公開されているもの。人の判断待ちなので止めない
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サイトに同一テキストで入れてある。コピーどうしがずれないよう、各サイトに同じ固定ベクタのテストを置いて検出している。


同じ発表が2本公開される事故を、ローマ字の綴りゆれごとビルドで止める
https://blog.hashito.biz/2026/09/20/sale-sites-topic-key-romaji-normalize-build-guard/
著者
hashito
作成日
2026年9月20日
著作権