Commit 5217dcac 5217dcacb2271b093a7d2f0b8fb70f794fcfb696 by 沈秋雨

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 时的软删除逻辑
- 避免因单条录音失败误删整首歌,提高数据导入准确性
1 parent b13789b3
...@@ -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,8 +2089,9 @@ def run( ...@@ -2083,8 +2089,9 @@ def run(
2083 int(pending['failure_code']), 2089 int(pending['failure_code']),
2084 ) 2090 )
2085 pg_conn.commit() 2091 pg_conn.commit()
2086 mark_hk_songs_deleted(hk_conn, [int(pending['song_id']) for pending in discarded_pending]) 2092 if YINYAN_IMPORT_TABLE != 'yinyan_song_records2':
2087 hk_conn.commit() 2093 mark_hk_songs_deleted(hk_conn, [int(pending['song_id']) for pending in discarded_pending])
2094 hk_conn.commit()
2088 else: 2095 else:
2089 log.error("No import payloads were prepared in this batch; stopping to avoid retry loop") 2096 log.error("No import payloads were prepared in this batch; stopping to avoid retry loop")
2090 break 2097 break
......
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()