"""M3 批量去重入口脚本。 输入: data/processed/{source}/{YYYYMMDD}/*.json (M2 产物) 输出: - 指纹库:data/dedup/fingerprints.sqlite3 - 唯一文章:data/deduped/{YYYYMMDD}/uniques/{url_hash}.json - 重复记录:data/deduped/{YYYYMMDD}/duplicates.jsonl 用法: uv run python -m scripts.run_dedup # 处理今日全部源 uv run python -m scripts.run_dedup --date 20260616 uv run python -m scripts.run_dedup --source sina --date 20260616 uv run python -m scripts.run_dedup --reset # 清空指纹库重新建立 """ from __future__ import annotations import argparse import json import sys from collections import Counter from datetime import date from pathlib import Path from loguru import logger from pydantic import ValidationError from dedup import Deduper from extractor import Article def _setup_logger(level: str) -> None: logger.remove() logger.add( sys.stderr, level=level, format="{time:YYYY-MM-DD HH:mm:ss} | {level} | {name} | {message}", ) log_path = Path("logs") / "dedup.log" log_path.parent.mkdir(parents=True, exist_ok=True) logger.add(log_path, level="DEBUG", rotation="10 MB", retention=5, encoding="utf-8") def _load_article(json_path: Path) -> Article | None: try: data = json.loads(json_path.read_text(encoding="utf-8")) return Article.model_validate(data) except (json.JSONDecodeError, ValidationError) as e: logger.warning("跳过无法解析的 article 文件 {}: {}", json_path, e) return None def _list_source_dirs(processed_root: Path) -> list[str]: if not processed_root.is_dir(): return [] return sorted(p.name for p in processed_root.iterdir() if p.is_dir()) def _process_source_day( source_id: str, day: str, processed_root: Path, out_root: Path, deduper: Deduper, ) -> tuple[int, int, Counter]: """处理单源单日。返回 (uniques, duplicates, layer_counter)。""" src_dir = processed_root / source_id / day if not src_dir.is_dir(): logger.info("源 {} 日期 {} 无 processed 目录,跳过", source_id, day) return 0, 0, Counter() files = sorted(src_dir.glob("*.json")) if not files: logger.info("源 {} 日期 {} 无文章,跳过", source_id, day) return 0, 0, Counter() uniques_dir = out_root / day / "uniques" uniques_dir.mkdir(parents=True, exist_ok=True) dup_log = out_root / day / "duplicates.jsonl" uniq_cnt = 0 dup_cnt = 0 layer_cnt: Counter = Counter() with dup_log.open("a", encoding="utf-8") as dup_f: for fp in files: article = _load_article(fp) if article is None: continue result = deduper.ingest(article) if result.is_duplicate: dup_cnt += 1 if result.matched_layer is not None: layer_cnt[result.matched_layer.value] += 1 dup_f.write( json.dumps( { "source_id": article.source_id, "url": article.url, "url_hash": article.url_hash, "title": article.title, "matched_layer": ( result.matched_layer.value if result.matched_layer else None ), "matched_url": result.matched_url, "matched_url_hash": result.matched_url_hash, "matched_title": result.matched_title, "hamming_distance": result.hamming_distance, }, ensure_ascii=False, ) + "\n" ) else: uniq_cnt += 1 out_path = uniques_dir / f"{article.url_hash}.json" out_path.write_text(article.model_dump_json(indent=2), encoding="utf-8") total = uniq_cnt + dup_cnt rate = dup_cnt / max(total, 1) logger.info( "源 {} 日期 {}: 唯一 {} / 重复 {} (重复率 {:.1%}) layers={}", source_id, day, uniq_cnt, dup_cnt, rate, dict(layer_cnt), ) return uniq_cnt, dup_cnt, layer_cnt def main() -> int: parser = argparse.ArgumentParser(description="A 股新闻三层去重 (M3)") parser.add_argument("--processed-root", default="data/processed") parser.add_argument("--out-root", default="data/deduped") parser.add_argument("--db", default="data/dedup/fingerprints.sqlite3") parser.add_argument("--source", default=None, help="只处理单源") parser.add_argument( "--date", default=date.today().strftime("%Y%m%d"), help="日期 YYYYMMDD" ) parser.add_argument("--simhash-threshold", type=int, default=3) parser.add_argument("--window-days", type=int, default=30) parser.add_argument("--reset", action="store_true", help="处理前清空指纹库") parser.add_argument("--log-level", default="INFO") args = parser.parse_args() _setup_logger(args.log_level) processed_root = Path(args.processed_root) out_root = Path(args.out_root) sources = [args.source] if args.source else _list_source_dirs(processed_root) if not sources: logger.error("{} 下无源目录", processed_root) return 2 with Deduper( db_path=args.db, simhash_threshold=args.simhash_threshold, time_window_days=args.window_days, ) as deduper: if args.reset: logger.warning("--reset:清空指纹库 {}", args.db) deduper.store.clear() # 清掉同日 duplicates.jsonl 避免重复追加(uniques 用 url_hash 文件名,会自然覆盖) dup_log = out_root / args.date / "duplicates.jsonl" if dup_log.exists(): dup_log.unlink() total_uniq = 0 total_dup = 0 total_layers: Counter = Counter() for src in sources: u, d, lc = _process_source_day( src, args.date, processed_root, out_root, deduper ) total_uniq += u total_dup += d total_layers.update(lc) total = total_uniq + total_dup rate = total_dup / max(total, 1) logger.info( "全部完成: 唯一 {} / 重复 {} (重复率 {:.1%}) layers={}", total_uniq, total_dup, rate, dict(total_layers), ) # 验收门槛: ≤ 5% return 0 if rate <= 0.05 or total == 0 else 1 if __name__ == "__main__": raise SystemExit(main())