東京23区まとめ は、区ごとの地域メディアが出した記事を集めて時系列に並べるリンク集である。収集は tools/collect.js が RSS を読み、data/entries.json に見出し・掲載日・元記事リンクを貯める。貯める前に「もう持っている記事か」を判定していて、持っていれば捨てる。
2026年10月5日の収集で、この判定が 52件すり抜けた。keizai.biz 系(すみだ経済新聞など)の同じ記事が、2件ずつ並んだ。回帰テストの「同じ記事を二度貯めていない」が落ちて気づいた。
既出判定の鍵は URL を正規化した文字列
判定は Set で持つ。収集済みの記事から鍵を作って集合に入れ、フィードから来た各記事の鍵が集合にあれば捨てる。鍵は URL をそのまま使わず、normUrl で正規化している。
1 2 3 4 5 6 7 8 9 10 11 12 13
| function normUrl(u) { try { var x = new URL(u); x.hash = ""; ["utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content", "fbclid"] .forEach(function (k) { x.searchParams.delete(k); }); var s = x.protocol + "//" + x.host.replace(/^www\./, "") + x.pathname.replace(/\/+$/, ""); var q = x.searchParams.toString(); return (s + (q ? "?" + q : "")).toLowerCase(); } catch (e) { return String(u).toLowerCase(); } }
|
フラグメント、計測パラメータ、先頭の www.、末尾のスラッシュ、大文字小文字の差は吸収する。スキーム(http: / https:)は残している。 ここは意図したもので、http:// と https:// で別の内容を返すサイトを同一視しないためだ。
収集済みの記事は id にこの正規化済み文字列を持つ。変更前の既出判定はこうだった。
1
| var seen = new Set(store.entries.map(function (e) { return e.id; }));
|
何が起きたか: 古い記録の id が http://、フィードの link が https://
keizai.biz 系のフィードは、ある時期まで記事リンクを http:// で配信していた。そのころ貯めた記録の id は http://sumida.keizai.biz/headline/1996 のような形で残っている。
同じフィードがその後 https:// で配信するようになった。10月5日のフィードに、昔の記事が https://sumida.keizai.biz/headline/1996/ として載っていた(地域メディアのフィードは、古い記事が再掲されることがある)。
normUrl はスキームを残すので、鍵は http://... と https://... で別の文字列になる。集合に無いので新規として貯めた。これが52件。
一方、記録には url という欄もある。こちらは収集時に実際に開けた URL で、http:// で書かれていても https:// に付け替えてある(元記事への掲載リンクはこちらを使う)。つまり 同じ記録の中に、スキームだけ違う2つの URL が既に入っていた。id だけを見ていたのが抜けだった。
直し方: id と url の両方を鍵にする
normUrl のスキーム保持は変えない。既出判定の側で、id と url の両方を正規化して集合に入れる。
1 2 3 4 5 6 7 8 9 10 11 12 13
|
function seenKeys(entries) { var seen = new Set(); (entries || []).forEach(function (e) { if (e.id) seen.add(normUrl(e.id)); if (e.url) seen.add(normUrl(e.url)); }); return seen; }
var seen = seenKeys(store.entries);
|
記録を書き換えて id を https:// に揃える案もあったが、採らなかった。id は記事の一意な鍵として他の場所(公開 HTML の要素 id など)からも参照されていて、書き換えると差分が全ページに及ぶ。判定側で両方を見るほうが、変更が1関数で閉じる。
実際に試す
Node.js 24.11.1 で確認した。normUrl と seenKeys をそのまま置き、変更前と変更後の判定を並べる。
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
| function normUrl(u) { try { var x = new URL(u); x.hash = ""; ["utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content", "fbclid"] .forEach(function (k) { x.searchParams.delete(k); }); var s = x.protocol + "//" + x.host.replace(/^www\./, "") + x.pathname.replace(/\/+$/, ""); var q = x.searchParams.toString(); return (s + (q ? "?" + q : "")).toLowerCase(); } catch (e) { return String(u).toLowerCase(); } } function seenKeys(entries) { var seen = new Set(); (entries || []).forEach(function (e) { if (e.id) seen.add(normUrl(e.id)); if (e.url) seen.add(normUrl(e.url)); }); return seen; } const store = [{ id: "http://sumida.keizai.biz/headline/1996", url: "https://sumida.keizai.biz/headline/1996/" }]; const link = "https://sumida.keizai.biz/headline/1996/"; console.log("normUrl(id) =", normUrl(store[0].id)); console.log("normUrl(link)=", normUrl(link)); const before = new Set(store.map(e => e.id)); console.log("before: seen.has =", before.has(normUrl(link))); const after = seenKeys(store); console.log("after : seen.has =", after.has(normUrl(link)), "keys =", [...after]);
|
実行結果:
1 2 3 4 5 6 7
| normUrl(id) = http://sumida.keizai.biz/headline/1996 normUrl(link)= https://sumida.keizai.biz/headline/1996 before: seen.has = false after : seen.has = true keys = [ 'http://sumida.keizai.biz/headline/1996', 'https://sumida.keizai.biz/headline/1996' ]
|
変更前は false(新規として貯める)、変更後は true(既出として捨てる)になる。末尾のスラッシュは normUrl が落とすので、url 側の /1996/ と link の /1996/ も一致する。
再発を機械で止める
この件は、既出判定を通ったあとの状態を見る回帰テスト「同じ記事を二度貯めていない」で見つかった。修正と一緒に、判定そのものを見るテストを足した。node --test で動く。
1 2 3 4 5 6 7
| test("既出判定は id が http:// のままでも https:// の同じ記事を弾く", () => { const seen = C.seenKeys([{ id: "http://sumida.keizai.biz/headline/1996", url: "https://sumida.keizai.biz/headline/1996/" }]); assert.ok(seen.has(C.normUrl("https://sumida.keizai.biz/headline/1996")), "https 版が既出扱いにならない"); assert.ok(seen.has(C.normUrl("http://sumida.keizai.biz/headline/1996/")), "http 版が既出扱いにならない"); assert.ok(!seen.has(C.normUrl("https://sumida.keizai.biz/headline/1997")), "別の記事まで既出扱いになる"); assert.equal(C.seenKeys([]).size, 0); });
|
3つ目の assert が要る。「http と https を同一視する」直し方を雑にやると(たとえばスキームを全部落とす)、別の記事まで既出になる方向の壊れ方があり、それは貯まらない記事が増えるだけで表には出ない。「弾くべきものを弾く」と「弾いてはいけないものを弾かない」の両方を書く。
まとめ
- 既出判定の鍵が URL なら、同じ記事が複数の URL 表記で来る前提で設計する。スキームの違いはその代表例で、フィードの配信元が http から https へ移ると、過去の記録と現在のフィードで必ず食い違う。
- 正規化関数でスキームを落とすか、判定側で複数の鍵を持つかは別の判断である。今回は正規化を変えず、記録が持っている
id と url の両方を鍵にした。
- 「二度貯まっていない」を見るテストは最後の砦で、原因の切り分けには「判定関数が何を弾くか」のテストが要る。
対象サイト: 東京23区まとめ(区ごとの地域ニュースを時系列に並べたリンク集。収集器の修正はコミット 0390188)。