scripts/canonicalize_duplicates.py
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env python3
"""
Canonicalize duplicate source_url entries in search_index.json.
Strategy:
- group by source_url
- skip invalid / empty source_url
- score candidates by quality
- keep best entry as canonical
- mark the rest as excluded duplicates
Usage:
python3 scripts/canonicalize_duplicates.py
python3 scripts/canonicalize_duplicates.py --dry-run
"""
from __future__ import annotations
import argparse
import json
import os
import re
from collections import defaultdict
import sys
from pathlib import Path
from typing import Any
sys.path.insert(0, str(Path(__file__).resolve().parent))
import xkb_paths
import xkb_frontmatter
import xkb_index
WORKSPACE = xkb_paths.WORKSPACE
BOOKMARKS_DIR = xkb_paths.BOOKMARKS_DIR
INDEX_FILE = xkb_paths.INDEX_FILE
from normalize_index_quality import LOW_SIGNAL_SUMMARIES, clean_summary, is_valid_source_url
def entry_score(item: dict[str, Any]) -> tuple:
title = (item.get("title") or "").strip()
summary = clean_summary(item.get("summary") or "")
rel_path = (item.get("relative_path") or item.get("path") or "").strip()
enriched = 1 if item.get("enriched") else 0
has_good_summary = 1 if summary not in LOW_SIGNAL_SUMMARIES and len(summary) >= 18 else 0
has_human_title = 1
if not title or re.fullmatch(r"\d{15,20}", title) or re.fullmatch(r"tweet\s+\d{15,20}", title.lower()):
has_human_title = 0
in_cards = 1 if rel_path.startswith(("cards/", "memory/cards/")) else 0
not_excluded = 1 if not item.get("excluded") else 0
non_legacy = 1 if "legacy-" not in rel_path else 0
title_len = min(len(title), 120)
summary_len = min(len(summary), 300)
return (
not_excluded,
enriched,
in_cards,
has_good_summary,
has_human_title,
non_legacy,
summary_len,
title_len,
)
def main() -> int:
parser = argparse.ArgumentParser(description="Canonicalize duplicate source_url entries")
parser.add_argument("--dry-run", action="store_true")
args = parser.parse_args()
raw = json.loads(INDEX_FILE.read_text(encoding="utf-8"))
items = raw.get("items", raw) if isinstance(raw, dict) else raw
is_dict = isinstance(raw, dict)
groups: dict[str, list[int]] = defaultdict(list)
for idx, item in enumerate(items):
source_url = (item.get("source_url") or "").strip()
if source_url and is_valid_source_url(source_url):
groups[source_url].append(idx)
duplicate_groups = {url: idxs for url, idxs in groups.items() if len(idxs) > 1}
changed = 0
excluded_dupes = 0
examples = []
unmarkable: list[str] = []
for source_url, idxs in duplicate_groups.items():
ranked = sorted(idxs, key=lambda i: entry_score(items[i]), reverse=True)
keep_idx = ranked[0]
keep_item = items[keep_idx]
keep_rel = keep_item.get("relative_path") or keep_item.get("path") or ""
# 保留的那一份的所有檔案。這組底下沒有任何一個標記可以碰到它們。
keep_files = set(xkb_index.files_for_item(keep_item, by_source=False))
for idx in ranked[1:]:
item = items[idx]
rel_path = item.get("relative_path") or item.get("path") or ""
changed_here = False
# 標在檔案上,不是標在索引列上。索引是衍生物:標在列上的話,
# 下一次重建就把這個決定洗掉——實測正式索引 1680 筆裡帶 excluded
# 的是 0 筆,這支腳本過去的成果一次都沒留下來。
#
# 而且一列代表的是一組檔案:同一份知識可能被歸進兩個分類資料夾。
# 只標其中一份的話,重建時去重會挑「沒有被排除」的那一份留下,
# 排除等於沒發生。
#
# by_source=False 是這裡的關鍵:重複組本來就共用同一個來源,用來源
# 去找會把「要保留的那一份」也撈進來。加上 keep_files 這道保險——
# 保留的那一份無論如何都不能被標,否則整組一起消失。
files = [p for p in xkb_index.files_for_item(item, by_source=False)
if p not in keep_files]
if not files:
unmarkable.append(rel_path)
for path in files:
if xkb_frontmatter.mark_excluded(
path, f"duplicate_source_url; canonical={keep_rel}",
dry_run=args.dry_run):
changed_here = True
if changed_here:
changed += 1
excluded_dupes += 1
if len(examples) < 20:
examples.append((source_url, keep_rel, rel_path))
print(f"duplicate groups: {len(duplicate_groups)}")
print(f"duplicate entries excluded: {excluded_dupes}")
print(f"changed: {changed}")
if unmarkable:
# 標記不上跟標記了不一樣。安靜跳過會讓這批重複永遠標不掉,而輸出
# 看起來一切正常——那正是這支腳本原本在犯的錯。
print(f"could not mark (檔案不存在或沒有 frontmatter): {len(unmarkable)}")
for rel in unmarkable[:5]:
print(f" - {rel}")
print("\nexamples:")
for source_url, keep_rel, rel_path in examples:
print(f"- source: {source_url}")
print(f" keep: {keep_rel}")
print(f" drop: {rel_path}")
# 這支腳本不再寫索引。決定寫在卡片檔案上,索引由 build_search_index.sh
# 從檔案重算——那是唯一寫索引的地方。
if not args.dry_run and changed > 0:
print(f"已標記 {changed} 張卡片。索引重建後生效:")
print(" bash scripts/build_search_index.sh --incremental")
return 0
import xkb_usage # noqa: E402 — 量測誰在跑,見 scripts/xkb_usage.py
if __name__ == "__main__":
xkb_usage.record(__file__)
raise SystemExit(main())