feat(crawler): 修复歌曲表 title/version 解析逻辑并完善导入流程日志
- 新增脚本 fix_title_version.py,从 hk_music_record.record_name 解析 title/version 字段 - 支持 dry-run 预览差异和 --apply 选项实际更新数据库 - 按平台批量同步和更新 PG crawler_*_songs 表的 title/version - 修正 runner.py 中无 preparer 或缺失 song_data 时增加警告日志 - 调整 runner.py 中 YINYAN_IMPORT_TABLE 为 yinyan_song_records2 时的软删除逻辑 - 避免因单条录音失败误删整首歌,提高数据导入准确性
Showing
2 changed files
with
197 additions
and
2 deletions
| ... | @@ -734,15 +734,20 @@ def _prepare_import_payload( | ... | @@ -734,15 +734,20 @@ def _prepare_import_payload( |
| 734 | platform = str(pending['platform']) | 734 | platform = str(pending['platform']) |
| 735 | preparer = _PREPARERS.get(platform) | 735 | preparer = _PREPARERS.get(platform) |
| 736 | if not preparer: | 736 | if not preparer: |
| 737 | log.warning("payload=None: no preparer for platform=%s song_id=%s", platform, pending['song_id']) | ||
| 737 | return None | 738 | return None |
| 738 | platform_unique_id = pr['platform_unique_key'] | 739 | platform_unique_id = pr['platform_unique_key'] |
| 739 | song_key = platform_unique_id if platform == PLATFORM_QQ else int(platform_unique_id) | 740 | song_key = platform_unique_id if platform == PLATFORM_QQ else int(platform_unique_id) |
| 740 | song_data = songs_maps.get(platform, {}).get(song_key) | 741 | song_data = songs_maps.get(platform, {}).get(song_key) |
| 741 | if not song_data: | 742 | if not song_data: |
| 743 | log.warning("payload=None: song_data missing platform=%s song_key=%s record_id=%s song_id=%s", | ||
| 744 | platform, song_key, pending['record_id'], pending['song_id']) | ||
| 742 | return None | 745 | return None |
| 743 | singer_key = int(song_data['id']) if platform == PLATFORM_QQ else int(platform_unique_id) | 746 | singer_key = int(song_data['id']) if platform == PLATFORM_QQ else int(platform_unique_id) |
| 744 | singer_list = singers_maps.get(platform, {}).get(singer_key, []) | 747 | singer_list = singers_maps.get(platform, {}).get(singer_key, []) |
| 745 | if not singer_list: | 748 | if not singer_list and YINYAN_IMPORT_TABLE != 'yinyan_song_records2': |
| 749 | log.warning("payload=None: singer_list missing platform=%s singer_key=%s song_id=%s", | ||
| 750 | platform, singer_key, pending['song_id']) | ||
| 746 | return None | 751 | return None |
| 747 | payload = preparer(hk_row, pr, bucket, base_url, song_data, singer_list) | 752 | payload = preparer(hk_row, pr, bucket, base_url, song_data, singer_list) |
| 748 | payload['yinyan_record'] = { | 753 | payload['yinyan_record'] = { |
| ... | @@ -2071,7 +2076,8 @@ def run( | ... | @@ -2071,7 +2076,8 @@ def run( |
| 2071 | int(pending['failure_code']), | 2076 | int(pending['failure_code']), |
| 2072 | ) | 2077 | ) |
| 2073 | pg_conn.commit() | 2078 | pg_conn.commit() |
| 2074 | if discarded_pending: | 2079 | # records2 同一首歌有多条录音,单条录音失败不应软删除整首歌 |
| 2080 | if discarded_pending and YINYAN_IMPORT_TABLE != 'yinyan_song_records2': | ||
| 2075 | mark_hk_songs_deleted(hk_conn, [int(pending['song_id']) for pending in discarded_pending]) | 2081 | mark_hk_songs_deleted(hk_conn, [int(pending['song_id']) for pending in discarded_pending]) |
| 2076 | hk_conn.commit() | 2082 | hk_conn.commit() |
| 2077 | imported.extend(payload['result'] for payload in payloads) | 2083 | imported.extend(payload['result'] for payload in payloads) |
| ... | @@ -2083,6 +2089,7 @@ def run( | ... | @@ -2083,6 +2089,7 @@ def run( |
| 2083 | int(pending['failure_code']), | 2089 | int(pending['failure_code']), |
| 2084 | ) | 2090 | ) |
| 2085 | pg_conn.commit() | 2091 | pg_conn.commit() |
| 2092 | if YINYAN_IMPORT_TABLE != 'yinyan_song_records2': | ||
| 2086 | mark_hk_songs_deleted(hk_conn, [int(pending['song_id']) for pending in discarded_pending]) | 2093 | mark_hk_songs_deleted(hk_conn, [int(pending['song_id']) for pending in discarded_pending]) |
| 2087 | hk_conn.commit() | 2094 | hk_conn.commit() |
| 2088 | else: | 2095 | else: | ... | ... |
fix_title_version.py
0 → 100644
| 1 | #!/usr/bin/env python3 | ||
| 2 | """修复已导入的 crawler 歌曲表 title/version 字段。 | ||
| 3 | |||
| 4 | 原导入逻辑从 hk_songs.name(歌曲名)解析 title/version, | ||
| 5 | 现需改为从 hk_music_record.record_name(录音名)解析。 | ||
| 6 | |||
| 7 | 用法: | ||
| 8 | # dry-run 预览差异(不写库) | ||
| 9 | .venv/bin/python fix_title_version.py | ||
| 10 | |||
| 11 | # 实际执行更新 | ||
| 12 | .venv/bin/python fix_title_version.py --apply | ||
| 13 | """ | ||
| 14 | import argparse | ||
| 15 | import logging | ||
| 16 | |||
| 17 | from etl_to_crawler.config import PLATFORM_QQ, PLATFORM_KUGOU, PLATFORM_NETEASE | ||
| 18 | from etl_to_crawler.connections import get_source_conn, get_pg_conn, close_all_pools | ||
| 19 | from etl_to_crawler.utils import split_title_version | ||
| 20 | |||
| 21 | logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s') | ||
| 22 | log = logging.getLogger(__name__) | ||
| 23 | |||
| 24 | # ── 平台配置 ────────────────────────────────────────────────────────────── | ||
| 25 | # 每个平台的: PG表名, MySQL平台码, PG platform_song_id 对应 hk_music_record 的哪个字段 | ||
| 26 | PLATFORM_CONFIGS = [ | ||
| 27 | { | ||
| 28 | 'name': 'QQ', | ||
| 29 | 'pg_table': 'crawler_qqmusic_songs', | ||
| 30 | 'mysql_platform': PLATFORM_QQ, | ||
| 31 | 'match_field': 'platform_mid', # PG.platform_song_id ↔ mr.platform_mid | ||
| 32 | }, | ||
| 33 | { | ||
| 34 | 'name': 'Kugou', | ||
| 35 | 'pg_table': 'crawler_kugou_songs', | ||
| 36 | 'mysql_platform': PLATFORM_KUGOU, | ||
| 37 | 'match_field': 'platform_unique_key', # PG.platform_song_id ↔ mr.platform_unique_key | ||
| 38 | }, | ||
| 39 | { | ||
| 40 | 'name': 'Netease', | ||
| 41 | 'pg_table': 'crawler_netease_songs', | ||
| 42 | 'mysql_platform': PLATFORM_NETEASE, | ||
| 43 | 'match_field': 'platform_unique_key', # PG.platform_song_id ↔ mr.platform_unique_key | ||
| 44 | }, | ||
| 45 | ] | ||
| 46 | |||
| 47 | BATCH_SIZE = 2000 | ||
| 48 | |||
| 49 | |||
| 50 | def fetch_record_name_map(mysql_conn, platform: str, match_field: str) -> dict[str, str]: | ||
| 51 | """从 hk_music_record 查询 platform_unique_key/platform_mid → record_name 映射。""" | ||
| 52 | sql = f""" | ||
| 53 | SELECT {match_field} AS match_key, record_name | ||
| 54 | FROM hk_music_record | ||
| 55 | WHERE platform = %s | ||
| 56 | AND deleted = 0 | ||
| 57 | AND {match_field} IS NOT NULL | ||
| 58 | AND TRIM({match_field}) != '' | ||
| 59 | AND record_name IS NOT NULL | ||
| 60 | AND TRIM(record_name) != '' | ||
| 61 | """ | ||
| 62 | with mysql_conn.cursor() as cur: | ||
| 63 | cur.execute(sql, (platform,)) | ||
| 64 | rows = cur.fetchall() | ||
| 65 | mapping = {} | ||
| 66 | for row in rows: | ||
| 67 | key = str(row['match_key']).strip() | ||
| 68 | if key: | ||
| 69 | mapping[key] = row['record_name'] | ||
| 70 | log.info(" MySQL %s: 获取 %d 条 record_name 映射", platform, len(mapping)) | ||
| 71 | return mapping | ||
| 72 | |||
| 73 | |||
| 74 | def fetch_pg_songs(pg_conn, table: str) -> list[dict]: | ||
| 75 | """从 PG 歌曲表查询 id, platform_song_id, title, version。""" | ||
| 76 | sql = f"SELECT id, platform_song_id, title, version FROM {table}" | ||
| 77 | with pg_conn.cursor() as cur: | ||
| 78 | cur.execute(sql) | ||
| 79 | rows = cur.fetchall() | ||
| 80 | # pg8000 返回 tuple,转为 dict | ||
| 81 | return [{'id': str(r[0]), 'platform_song_id': r[1], 'title': r[2], 'version': r[3]} for r in rows] | ||
| 82 | |||
| 83 | |||
| 84 | def compute_updates(songs: list[dict], name_map: dict[str, str]) -> list[tuple]: | ||
| 85 | """对比已有 title/version 与 record_name 解析结果,返回需更新的 (new_title, new_version, id) 列表。""" | ||
| 86 | updates = [] | ||
| 87 | skipped = 0 | ||
| 88 | for song in songs: | ||
| 89 | psid = str(song['platform_song_id']).strip() if song['platform_song_id'] is not None else '' | ||
| 90 | record_name = name_map.get(psid) | ||
| 91 | if not record_name: | ||
| 92 | skipped += 1 | ||
| 93 | continue | ||
| 94 | new_title, new_version = split_title_version(record_name) | ||
| 95 | old_title = song.get('title') or '' | ||
| 96 | old_version = song.get('version') or '' | ||
| 97 | if new_title != old_title or new_version != old_version: | ||
| 98 | updates.append((new_title, new_version, str(song['id']))) | ||
| 99 | if skipped: | ||
| 100 | log.info(" %d 条记录在 hk_music_record 中未找到匹配,跳过", skipped) | ||
| 101 | return updates | ||
| 102 | |||
| 103 | |||
| 104 | def batch_update(pg_conn, table: str, updates: list[tuple]) -> int: | ||
| 105 | """批量 UPDATE title, version,使用参数化 VALUES 构造单条 UPDATE。""" | ||
| 106 | if not updates: | ||
| 107 | return 0 | ||
| 108 | total = 0 | ||
| 109 | for i in range(0, len(updates), BATCH_SIZE): | ||
| 110 | chunk = updates[i:i + BATCH_SIZE] | ||
| 111 | placeholders = ", ".join(["(%s, %s, %s::uuid)"] * len(chunk)) | ||
| 112 | sql = f""" | ||
| 113 | UPDATE {table} AS t | ||
| 114 | SET title = v.new_title, | ||
| 115 | version = v.new_version, | ||
| 116 | updated_at = NOW() | ||
| 117 | FROM (VALUES {placeholders}) AS v(new_title, new_version, id) | ||
| 118 | WHERE t.id = v.id | ||
| 119 | """ | ||
| 120 | params = [] | ||
| 121 | for new_title, new_version, song_id in chunk: | ||
| 122 | params.extend([new_title, new_version, song_id]) | ||
| 123 | with pg_conn.cursor() as cur: | ||
| 124 | cur.execute(sql, params) | ||
| 125 | pg_conn.commit() | ||
| 126 | total += len(chunk) | ||
| 127 | log.info(" 已更新 %d / %d", total, len(updates)) | ||
| 128 | return total | ||
| 129 | |||
| 130 | |||
| 131 | def main(): | ||
| 132 | parser = argparse.ArgumentParser(description='修复 crawler 歌曲表 title/version 字段') | ||
| 133 | parser.add_argument('--apply', action='store_true', help='实际执行更新(默认 dry-run)') | ||
| 134 | args = parser.parse_args() | ||
| 135 | |||
| 136 | mysql_conn = get_source_conn() | ||
| 137 | pg_conn = get_pg_conn() | ||
| 138 | |||
| 139 | total_updated = 0 | ||
| 140 | total_diff = 0 | ||
| 141 | |||
| 142 | for cfg in PLATFORM_CONFIGS: | ||
| 143 | log.info("=== 处理 %s (%s) ===", cfg['name'], cfg['pg_table']) | ||
| 144 | |||
| 145 | # 1. 从 MySQL 获取 record_name 映射 | ||
| 146 | name_map = fetch_record_name_map(mysql_conn, cfg['mysql_platform'], cfg['match_field']) | ||
| 147 | |||
| 148 | # 2. 从 PG 获取现有歌曲 | ||
| 149 | songs = fetch_pg_songs(pg_conn, cfg['pg_table']) | ||
| 150 | log.info(" PG %s: %d 条歌曲记录", cfg['name'], len(songs)) | ||
| 151 | |||
| 152 | # 3. 计算差异 | ||
| 153 | updates = compute_updates(songs, name_map) | ||
| 154 | log.info(" 差异记录: %d 条", len(updates)) | ||
| 155 | total_diff += len(updates) | ||
| 156 | |||
| 157 | # 4. dry-run 预览前 20 条 | ||
| 158 | if updates: | ||
| 159 | log.info(" 差异示例(前20条):") | ||
| 160 | for new_title, new_version, song_id in updates[:20]: | ||
| 161 | # 找到对应的旧值 | ||
| 162 | old = next((s for s in songs if str(s['id']) == song_id), None) | ||
| 163 | if old: | ||
| 164 | log.info(" id=%s title: '%s' → '%s' version: '%s' → '%s'", | ||
| 165 | song_id, | ||
| 166 | old.get('title', ''), new_title, | ||
| 167 | old.get('version', ''), new_version) | ||
| 168 | |||
| 169 | # 5. 执行更新 | ||
| 170 | if args.apply and updates: | ||
| 171 | updated = batch_update(pg_conn, cfg['pg_table'], updates) | ||
| 172 | total_updated += updated | ||
| 173 | log.info(" %s 更新完成: %d 条", cfg['name'], updated) | ||
| 174 | elif not args.apply: | ||
| 175 | log.info(" [dry-run] 跳过更新,添加 --apply 执行") | ||
| 176 | |||
| 177 | log.info("=== 汇总 ===") | ||
| 178 | log.info("差异总数: %d", total_diff) | ||
| 179 | if args.apply: | ||
| 180 | log.info("已更新总数: %d", total_updated) | ||
| 181 | else: | ||
| 182 | log.info("[dry-run] 未执行更新,添加 --apply 参数以实际执行") | ||
| 183 | |||
| 184 | close_all_pools() | ||
| 185 | |||
| 186 | |||
| 187 | if __name__ == '__main__': | ||
| 188 | main() |
-
Please register or sign in to post a comment