Commit bde8dd5f bde8dd5fe2425eb6999eaf2e8de14ca934f58a59 by 沈秋雨

feat(audit): 新增 L2 审计脚本与作者补丁脚本

1 parent 65293ff9
1 #!/usr/bin/env python3
2 """L2 歌词去重库内审计脚本
3
4 分两阶段运行:
5 Phase 1 (--phase download): 从目标库并行下载歌词 → 本地 JSONL 缓存(断点续传)
6 Phase 2 (--phase compare): 读缓存 → 倒排索引召回 → DuplicateChecker 判定 → 输出重复对 CSV
7
8 用法:
9 python audit_hk_songs_l2.py --phase download --workers 32
10 python audit_hk_songs_l2.py --phase compare
11 python audit_hk_songs_l2.py --phase all --workers 32
12 """
13
14 from __future__ import annotations
15
16 import argparse
17 import csv
18 import json
19 import os
20 import sys
21 import time
22 from collections import defaultdict
23 from concurrent.futures import ThreadPoolExecutor, as_completed
24 from pathlib import Path
25
26 import pymysql
27 from dotenv import load_dotenv
28 from tqdm import tqdm
29
30 sys.path.insert(0, str(Path(__file__).resolve().parent))
31 from import_hk_songs import (
32 L2CandidateIndex,
33 _decode_lyric_content,
34 _is_lyrics_effectively_empty,
35 download_file,
36 )
37 from lyric_dedup import DuplicateChecker, DuplicateDecision, LyricRecord
38
39 load_dotenv()
40
41 TARGET_DB_CONFIG = {
42 "host": os.getenv("TARGET_DB_HOST"),
43 "port": int(os.getenv("TARGET_DB_PORT", 3306)),
44 "user": os.getenv("TARGET_DB_USER"),
45 "password": os.getenv("TARGET_DB_PASSWORD"),
46 "database": os.getenv("TARGET_DB_NAME"),
47 "charset": "utf8mb4",
48 "cursorclass": pymysql.cursors.DictCursor,
49 }
50 TARGET_TABLE = os.getenv("TARGET_TABLE_NAME", "hk_songs")
51
52 OUTPUT_DIR = Path(__file__).resolve().parent / "output"
53 CACHE_DIR = OUTPUT_DIR / "cache"
54 REPORT_DIR = OUTPUT_DIR / "reports"
55 CACHE_FILE = CACHE_DIR / f"{TARGET_TABLE}_l2_audit_lyrics.jsonl"
56
57 # ── 阶段一:下载 ─────────────────────────────────────────────────────────────
58
59 def load_cached_ids(cache_path: Path) -> set[str]:
60 """读取已缓存的 record_id 集合,用于断点续传。"""
61 ids: set[str] = set()
62 if not cache_path.exists():
63 return ids
64 with cache_path.open("r", encoding="utf-8") as f:
65 for line in f:
66 line = line.strip()
67 if not line:
68 continue
69 try:
70 item = json.loads(line)
71 if item.get("id"):
72 ids.add(str(item["id"]))
73 except json.JSONDecodeError:
74 continue
75 return ids
76
77
78 def fetch_db_rows(table: str) -> list[dict]:
79 """从目标库拉取所有有歌词 URL 的记录元数据。"""
80 conn = pymysql.connect(**TARGET_DB_CONFIG)
81 try:
82 with conn.cursor() as cur:
83 cur.execute(
84 f"SELECT id, name, lyricist, composer, singer, lyrics_url "
85 f"FROM `{table}` "
86 f"WHERE deleted = '0' AND lyrics_url IS NOT NULL AND lyrics_url LIKE 'http%'"
87 )
88 return list(cur.fetchall())
89 finally:
90 conn.close()
91
92
93 def _download_one(row: dict) -> dict | None:
94 """下载单条歌词,返回缓存条目或 None(失败时)。"""
95 record_id = str(row["id"])
96 url = str(row["lyrics_url"])
97 content, _ = download_file(url, timeout=15, max_retries=3)
98 if not content:
99 return None
100 lyrics = _decode_lyric_content(content)
101 if _is_lyrics_effectively_empty(lyrics):
102 # 歌词实质为空:写占位符,让 compare 阶段跳过,但记录已处理避免重下
103 return {
104 "id": record_id,
105 "url": url,
106 "lyrics": "",
107 "name": row.get("name"),
108 "singer": row.get("singer"),
109 "lyricist": row.get("lyricist"),
110 "composer": row.get("composer"),
111 }
112 return {
113 "id": record_id,
114 "url": url,
115 "lyrics": lyrics,
116 "name": row.get("name"),
117 "singer": row.get("singer"),
118 "lyricist": row.get("lyricist"),
119 "composer": row.get("composer"),
120 }
121
122
123 def phase_download(workers: int) -> None:
124 CACHE_DIR.mkdir(parents=True, exist_ok=True)
125
126 print("从目标库加载记录列表...")
127 rows = fetch_db_rows(TARGET_TABLE)
128 print(f" 共 {len(rows)} 条有歌词 URL 的记录")
129
130 cached_ids = load_cached_ids(CACHE_FILE)
131 pending = [r for r in rows if str(r["id"]) not in cached_ids]
132 print(f" 已缓存: {len(cached_ids)} | 待下载: {len(pending)}")
133
134 if not pending:
135 print("所有歌词已缓存,跳过下载阶段。")
136 return
137
138 failed = 0
139 with CACHE_FILE.open("a", encoding="utf-8") as cache_f:
140 with ThreadPoolExecutor(max_workers=workers) as executor:
141 futures = {executor.submit(_download_one, row): row for row in pending}
142 with tqdm(total=len(pending), desc="下载歌词", unit="条") as pbar:
143 for future in as_completed(futures):
144 row = futures[future]
145 try:
146 item = future.result()
147 except Exception as e:
148 item = None
149 tqdm.write(f"[错误] id={row['id']} 下载异常: {e}")
150 if item is None:
151 failed += 1
152 tqdm.write(f"[失败] id={row['id']} url={row.get('lyrics_url', '')[:80]}")
153 else:
154 cache_f.write(json.dumps(item, ensure_ascii=False) + "\n")
155 pbar.update(1)
156 pbar.set_postfix(failed=failed)
157
158 total_cached = len(load_cached_ids(CACHE_FILE))
159 print(f"\n下载完成: 成功缓存 {total_cached} 条,失败 {failed} 条")
160 print(f"缓存文件: {CACHE_FILE}")
161
162
163 # ── 阶段二:比对 ─────────────────────────────────────────────────────────────
164
165 def load_cache(cache_path: Path) -> list[dict]:
166 """读取全部缓存条目(过滤空歌词)。"""
167 items = []
168 if not cache_path.exists():
169 return items
170 with cache_path.open("r", encoding="utf-8") as f:
171 for line in f:
172 line = line.strip()
173 if not line:
174 continue
175 try:
176 item = json.loads(line)
177 except json.JSONDecodeError:
178 continue
179 # 空歌词占位符跳过
180 if item.get("lyrics") and str(item["lyrics"]).strip():
181 items.append(item)
182 return items
183
184
185 def phase_compare(
186 output_path: Path,
187 threshold: float,
188 top_k: int,
189 decisions: set[str],
190 ) -> None:
191 REPORT_DIR.mkdir(parents=True, exist_ok=True)
192
193 print(f"读取本地歌词缓存: {CACHE_FILE}")
194 items = load_cache(CACHE_FILE)
195 if not items:
196 print("缓存为空或不存在,请先运行 --phase download")
197 return
198 print(f" 有效歌词条目: {len(items)}")
199
200 checker = DuplicateChecker(duplicate_jaccard_threshold=threshold)
201 index = L2CandidateIndex(checker, mode="topk", top_k=top_k)
202
203 print("构建 L2 倒排索引...")
204 for item in tqdm(items, desc="建索引", unit="条"):
205 index.add(LyricRecord(
206 record_id=str(item["id"]),
207 lyrics=item["lyrics"],
208 title=item.get("name"),
209 artist=item.get("singer"),
210 lyricist=item.get("lyricist"),
211 composer=item.get("composer"),
212 ))
213 print(f" 索引构建完成: {len(index)} 条")
214
215 # 已输出的 pair 集合(用 frozenset 去重 A→B / B→A)
216 seen_pairs: set[frozenset] = set()
217 dup_count = 0
218 review_count = 0
219
220 fieldnames = [
221 "id_a", "name_a", "lyricist_a", "composer_a",
222 "id_b", "name_b", "lyricist_b", "composer_b",
223 "decision", "confidence", "jaccard", "primary_jaccard",
224 "line_coverage", "primary_line_coverage", "reason",
225 ]
226
227 # 构建 id → item 映射,方便写 CSV 时取元数据
228 id_to_item = {str(item["id"]): item for item in items}
229
230 with output_path.open("w", encoding="utf-8-sig", newline="") as f:
231 writer = csv.DictWriter(f, fieldnames=fieldnames)
232 writer.writeheader()
233
234 for item in tqdm(items, desc="歌词比对", unit="条"):
235 qid = str(item["id"])
236 record = LyricRecord(
237 record_id=qid,
238 lyrics=item["lyrics"],
239 title=item.get("name"),
240 artist=item.get("singer"),
241 lyricist=item.get("lyricist"),
242 composer=item.get("composer"),
243 )
244
245 recalled = index.recall(record)
246 # 过滤自身
247 recalled = [r for r in recalled if r.record_id != qid]
248 if not recalled:
249 continue
250
251 result = checker.check_record_against_candidates(record, recalled, max_candidates=10)
252
253 for cm in result.candidates:
254 if cm.decision.value not in decisions:
255 continue
256 pair = frozenset({qid, cm.record_id})
257 if pair in seen_pairs:
258 continue
259 seen_pairs.add(pair)
260
261 b = id_to_item.get(cm.record_id, {})
262 row = {
263 "id_a": qid,
264 "name_a": item.get("name", ""),
265 "lyricist_a": item.get("lyricist", ""),
266 "composer_a": item.get("composer", ""),
267 "id_b": cm.record_id,
268 "name_b": b.get("name", ""),
269 "lyricist_b": b.get("lyricist", ""),
270 "composer_b": b.get("composer", ""),
271 "decision": cm.decision.value,
272 "confidence": f"{cm.confidence:.4f}",
273 "jaccard": f"{cm.jaccard:.4f}",
274 "primary_jaccard": f"{cm.primary_jaccard:.4f}",
275 "line_coverage": f"{cm.line_coverage:.4f}",
276 "primary_line_coverage": f"{cm.primary_line_coverage:.4f}",
277 "reason": cm.reason,
278 }
279 writer.writerow(row)
280 f.flush()
281
282 if cm.decision == DuplicateDecision.DUPLICATE:
283 dup_count += 1
284 else:
285 review_count += 1
286
287 print(f"\n比对完成:")
288 print(f" duplicate 对数: {dup_count}")
289 print(f" review 对数: {review_count}")
290 print(f" 输出文件: {output_path}")
291
292
293 # ── 入口 ──────────────────────────────────────────────────────────────────────
294
295 def main() -> None:
296 parser = argparse.ArgumentParser(description="hk_songs 库内 L2 歌词去重审计")
297 parser.add_argument(
298 "--phase",
299 choices=["download", "compare", "all"],
300 default="all",
301 help="运行阶段:download=仅下载缓存,compare=仅比对,all=两阶段连续执行(默认 all)",
302 )
303 parser.add_argument("--workers", type=int, default=24, help="下载并发线程数(默认 24)")
304 parser.add_argument(
305 "--threshold", type=float, default=0.78,
306 help="L2 Jaccard 判重阈值(默认 0.78,与导入脚本一致)",
307 )
308 parser.add_argument(
309 "--top-k", type=int, default=200,
310 help="每条记录最多召回候选数(默认 200)",
311 )
312 parser.add_argument(
313 "--decisions",
314 default="duplicate,review",
315 help="输出哪些判定结果,逗号分隔,可选 duplicate/review/new(默认 duplicate,review)",
316 )
317 parser.add_argument(
318 "--output", type=Path, default=None,
319 help="CSV 输出路径(默认 output/reports/l2_audit_<timestamp>.csv)",
320 )
321 args = parser.parse_args()
322
323 decisions = {d.strip() for d in args.decisions.split(",") if d.strip()}
324 if args.output is None:
325 from datetime import datetime
326 ts = datetime.now().strftime("%Y%m%d_%H%M%S")
327 args.output = REPORT_DIR / f"l2_audit_{ts}.csv"
328
329 if args.phase in ("download", "all"):
330 print("=" * 60)
331 print("阶段一:下载歌词缓存")
332 print("=" * 60)
333 phase_download(workers=args.workers)
334
335 if args.phase in ("compare", "all"):
336 print()
337 print("=" * 60)
338 print("阶段二:L2 歌词比对")
339 print("=" * 60)
340 phase_compare(
341 output_path=args.output,
342 threshold=args.threshold,
343 top_k=args.top_k,
344 decisions=decisions,
345 )
346
347
348 if __name__ == "__main__":
349 main()
1 #!/usr/bin/env python3
2 """补处理 merge+skipped 中 merge_authors=1 的 60 条记录。
3
4 暂存表 matched_song_id 存的是 source_song_id,通过 source_song_id 找到
5 目标库实际记录,再做作者字段增量合并。
6 """
7
8 from __future__ import annotations
9
10 import os
11 import sys
12 from pathlib import Path
13
14 import pymysql
15 from dotenv import load_dotenv
16
17 sys.path.insert(0, str(Path(__file__).resolve().parent))
18 from import_hk_songs import _merge_author_field
19
20 load_dotenv()
21
22 TARGET_DB_CONFIG = {
23 "host": os.getenv("TARGET_DB_HOST"),
24 "port": int(os.getenv("TARGET_DB_PORT", 3306)),
25 "user": os.getenv("TARGET_DB_USER"),
26 "password": os.getenv("TARGET_DB_PASSWORD"),
27 "database": os.getenv("TARGET_DB_NAME"),
28 "charset": "utf8mb4",
29 "cursorclass": pymysql.cursors.DictCursor,
30 }
31 TABLE = os.getenv("TARGET_TABLE_NAME", "hk_songs")
32 STAGING = "hk_songs_import_staging"
33
34
35 def main(dry_run: bool = False) -> None:
36 conn = pymysql.connect(**TARGET_DB_CONFIG)
37 try:
38 # 1. 拉取所有需要补作者合并的暂存记录
39 with conn.cursor() as cur:
40 cur.execute(f"""
41 SELECT staging_id, matched_song_id, lyricist, composer
42 FROM `{STAGING}`
43 WHERE dedup_action = 'merge'
44 AND staging_status = 'skipped'
45 AND merge_authors = 1
46 ORDER BY staging_id
47 """)
48 staging_rows = cur.fetchall()
49
50 print(f"待处理记录数: {len(staging_rows)}")
51
52 # 2. 批量查目标库(按 source_song_id)
53 source_ids = [r["matched_song_id"] for r in staging_rows if r["matched_song_id"]]
54 ph = ",".join(["%s"] * len(source_ids))
55 with conn.cursor() as cur:
56 cur.execute(
57 f"SELECT id, name, lyricist, composer, source_song_id FROM `{TABLE}` WHERE source_song_id IN ({ph})",
58 source_ids,
59 )
60 target_rows = {str(r["source_song_id"]): r for r in cur.fetchall()}
61
62 print(f"目标库命中: {len(target_rows)} / {len(source_ids)}")
63
64 # 3. 计算合并结果
65 updates: list[dict] = []
66 skipped: list[dict] = []
67
68 for s in staging_rows:
69 sid = s["matched_song_id"]
70 target = target_rows.get(str(sid))
71 if not target:
72 skipped.append({"staging_id": s["staging_id"], "source_song_id": sid, "reason": "目标库未找到"})
73 continue
74
75 merged_lyricist = _merge_author_field(target["lyricist"], s["lyricist"])
76 merged_composer = _merge_author_field(target["composer"], s["composer"])
77
78 lyricist_changed = bool(merged_lyricist) and merged_lyricist != (target["lyricist"] or "")
79 composer_changed = bool(merged_composer) and merged_composer != (target["composer"] or "")
80
81 if not lyricist_changed and not composer_changed:
82 skipped.append({"staging_id": s["staging_id"], "source_song_id": sid, "reason": "合并后无变化"})
83 continue
84
85 updates.append({
86 "target_id": target["id"],
87 "name": target["name"],
88 "source_song_id": sid,
89 "staging_id": s["staging_id"],
90 "orig_lyricist": target["lyricist"],
91 "orig_composer": target["composer"],
92 "new_lyricist": merged_lyricist if lyricist_changed else None,
93 "new_composer": merged_composer if composer_changed else None,
94 "staging_lyricist": s["lyricist"],
95 "staging_composer": s["composer"],
96 })
97
98 print(f"\n需要 UPDATE: {len(updates)} 条")
99 print(f"无需操作: {len(skipped)} 条")
100
101 if dry_run:
102 print("\n[dry-run] 预览变更(前 20 条):")
103 for u in updates[:20]:
104 print(f" id={u['target_id']} name={u['name']}")
105 if u["new_lyricist"] is not None:
106 print(f" lyricist: {u['orig_lyricist']!r} -> {u['new_lyricist']!r}")
107 if u["new_composer"] is not None:
108 print(f" composer: {u['orig_composer']!r} -> {u['new_composer']!r}")
109 return
110
111 # 4. 执行 UPDATE
112 updated = 0
113 errors = 0
114 with conn.cursor() as cur:
115 for u in updates:
116 try:
117 if u["new_lyricist"] is not None:
118 cur.execute(
119 f"UPDATE `{TABLE}` SET lyricist = %s WHERE id = %s",
120 (u["new_lyricist"], u["target_id"]),
121 )
122 if u["new_composer"] is not None:
123 cur.execute(
124 f"UPDATE `{TABLE}` SET composer = %s WHERE id = %s",
125 (u["new_composer"], u["target_id"]),
126 )
127 updated += 1
128 except Exception as e:
129 errors += 1
130 print(f" [错误] target_id={u['target_id']}: {e}")
131
132 conn.commit()
133 print(f"\n完成: 已更新 {updated} 条,错误 {errors} 条")
134
135 if skipped:
136 print(f"\n无需操作的 {len(skipped)} 条:")
137 for s in skipped:
138 print(f" staging_id={s['staging_id']} source_song_id={s['source_song_id']} 原因={s['reason']}")
139
140 finally:
141 conn.close()
142
143
144 if __name__ == "__main__":
145 import argparse
146 parser = argparse.ArgumentParser(description="补处理 merge+skipped 作者合并")
147 parser.add_argument("--dry-run", action="store_true", help="只预览,不写库")
148 args = parser.parse_args()
149 main(dry_run=args.dry_run)