feat(audit): 新增 L2 审计脚本与作者补丁脚本
Showing
2 changed files
with
498 additions
and
0 deletions
audit_hk_songs_l2.py
0 → 100644
| 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() |
patch_merge_authors.py
0 → 100644
| 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) |
-
Please register or sign in to post a comment