Commit a1e6ab0d a1e6ab0db85ba867ef09cad3e1eefe756bb9234b by 沈秋雨

feat(etl): 添加回填三平台空singers字段歌曲记录功能

- run_etl.py中新增--backfill-empty-singers参数支持回填操作
- runner.py新增backfill_empty_singers主流程函数及qq、酷狗、网易分平台回填实现
- writer.py新增查询缺失singers的歌曲及更新singers字段的数据库操作函数
- 回填过程中处理歌手头像的转存及歌手与歌曲关联关系的维护
- 支持批量分批次处理,避免长时间阻塞与重复无效查询
- 完整回填流程整合蜘蛛库和pg库连接资源管理,并日志记录处理结果
1 parent 6d8ccd04
...@@ -34,6 +34,8 @@ from .writer import ( ...@@ -34,6 +34,8 @@ from .writer import (
34 upsert_kugou_singer_songs, upsert_kugou_singer_albums, 34 upsert_kugou_singer_songs, upsert_kugou_singer_albums,
35 upsert_netease_singers, upsert_netease_albums, upsert_netease_songs, 35 upsert_netease_singers, upsert_netease_albums, upsert_netease_songs,
36 upsert_netease_singer_songs, upsert_netease_singer_albums, 36 upsert_netease_singer_songs, upsert_netease_singer_albums,
37 fetch_qq_songs_missing_singers, fetch_kugou_songs_missing_singers, fetch_netease_songs_missing_singers,
38 update_qq_song_singers, update_kugou_song_singers, update_netease_song_singers,
37 ) 39 )
38 from .oss import transfer_url, transfer_url_with_md5, build_oss_key 40 from .oss import transfer_url, transfer_url_with_md5, build_oss_key
39 from .utils import split_title_version, upload_plain_lyric_to_bucket 41 from .utils import split_title_version, upload_plain_lyric_to_bucket
...@@ -1105,6 +1107,239 @@ def backfill_yinyan_record_platforms(max_batches: int | None = None) -> None: ...@@ -1105,6 +1107,239 @@ def backfill_yinyan_record_platforms(max_batches: int | None = None) -> None:
1105 log.info("Backfilled yinyan_song_records platform rows=%d, skipped=%d", total, skipped) 1107 log.info("Backfilled yinyan_song_records platform rows=%d, skipped=%d", total, skipped)
1106 1108
1107 1109
1110 def _backfill_qq_singers(spider_conn, pg_conn, bucket, base_url, max_batches):
1111 batch_index = 0
1112 total_ok = total_skip = 0
1113 pbar = tqdm(desc='backfill-qq-singers')
1114 while max_batches is None or batch_index < max_batches:
1115 with pg_conn.cursor() as pg_cur:
1116 rows = fetch_qq_songs_missing_singers(pg_cur, BATCH_SIZE)
1117 if not rows:
1118 break
1119
1120 mids = [row['mid'] for row in rows]
1121 qq_songs_map = fetch_qq_songs(spider_conn, mids)
1122 qq_db_ids = [v['id'] for v in qq_songs_map.values()]
1123 qq_singers_map = fetch_qq_singers(spider_conn, qq_db_ids) if qq_db_ids else {}
1124
1125 singer_by_id: dict[int, dict] = {}
1126 for song_data in qq_songs_map.values():
1127 for sg in qq_singers_map.get(song_data['id'], []):
1128 singer_by_id[sg['singer_id']] = sg
1129
1130 avatar_tasks = {
1131 f"avatar:{sg['singer_id']}": (
1132 lambda sg=sg: _safe_transfer(
1133 sg.get('avatar', ''),
1134 build_oss_key('qq', 'singer', sg['mid'] + '.jpg'),
1135 bucket, base_url,
1136 )
1137 )
1138 for sg in singer_by_id.values()
1139 }
1140 avatar_assets = _run_io_tasks(avatar_tasks)
1141
1142 singer_rows = [{
1143 **sg,
1144 'id': sg['singer_id'],
1145 'avatar': avatar_assets.get(f"avatar:{sg['singer_id']}", sg.get('avatar', '')),
1146 'provider_name': PROVIDER_YINYAN,
1147 'crawler_source_data': _source_json(sg),
1148 } for sg in singer_by_id.values()]
1149
1150 song_updates = []
1151 singer_song_pairs = []
1152 for row in rows:
1153 song_data = qq_songs_map.get(row['mid'])
1154 if not song_data:
1155 total_skip += 1
1156 continue
1157 singer_list = qq_singers_map.get(song_data['id'], [])
1158 if not singer_list:
1159 total_skip += 1
1160 continue
1161 singers_json = json.dumps([{
1162 'name': sg['name'],
1163 'singer_id': sg['singer_id'],
1164 'platform_singer_id': sg['mid'],
1165 } for sg in singer_list], ensure_ascii=False)
1166 song_updates.append({'platform_song_id': row['platform_song_id'], 'singers_json': singers_json})
1167 singer_song_pairs.extend([(sg['singer_id'], row['id']) for sg in singer_list])
1168 total_ok += 1
1169
1170 if not song_updates:
1171 log.warning("QQ singers backfill: no singer data found for this batch, stopping to avoid retry loop")
1172 break
1173
1174 with pg_conn.cursor() as pg_cur:
1175 upsert_qq_singers(pg_cur, singer_rows)
1176 update_qq_song_singers(pg_cur, song_updates)
1177 upsert_qq_singer_songs(pg_cur, singer_song_pairs)
1178 pg_conn.commit()
1179
1180 batch_index += 1
1181 pbar.update(1)
1182 pbar.close()
1183 log.info("QQ singers backfill: ok=%d skip=%d", total_ok, total_skip)
1184
1185
1186 def _backfill_kugou_singers(spider_conn, pg_conn, bucket, base_url, max_batches):
1187 batch_index = 0
1188 total_ok = total_skip = 0
1189 pbar = tqdm(desc='backfill-kugou-singers')
1190 while max_batches is None or batch_index < max_batches:
1191 with pg_conn.cursor() as pg_cur:
1192 rows = fetch_kugou_songs_missing_singers(pg_cur, BATCH_SIZE)
1193 if not rows:
1194 break
1195
1196 song_ids = [row['platform_song_id'] for row in rows]
1197 kugou_singers_map = fetch_kugou_singers(spider_conn, song_ids)
1198
1199 singer_by_id: dict[int, dict] = {}
1200 for sgs in kugou_singers_map.values():
1201 for sg in sgs:
1202 singer_by_id[sg['singer_id']] = sg
1203
1204 avatar_tasks = {
1205 f"avatar:{sg['singer_id']}": (
1206 lambda sg=sg: _safe_transfer(
1207 sg.get('avatar', ''),
1208 build_oss_key('kugou', 'singer', str(sg['singer_id']) + '.jpg'),
1209 bucket, base_url,
1210 )
1211 )
1212 for sg in singer_by_id.values()
1213 }
1214 avatar_assets = _run_io_tasks(avatar_tasks)
1215
1216 singer_rows = [{
1217 **sg,
1218 'id': sg['singer_id'],
1219 'avatar': avatar_assets.get(f"avatar:{sg['singer_id']}", sg.get('avatar', '')),
1220 'provider_name': PROVIDER_YINYAN,
1221 'crawler_source_data': _source_json(sg),
1222 } for sg in singer_by_id.values()]
1223
1224 song_updates = []
1225 singer_song_pairs = []
1226 for row in rows:
1227 singer_list = kugou_singers_map.get(row['platform_song_id'], [])
1228 if not singer_list:
1229 total_skip += 1
1230 continue
1231 singers_json = json.dumps([{
1232 'name': sg['name'],
1233 'singer_id': sg['singer_id'],
1234 'platform_singer_id': str(sg['singer_id']),
1235 } for sg in singer_list], ensure_ascii=False)
1236 song_updates.append({'platform_song_id': row['platform_song_id'], 'singers_json': singers_json})
1237 singer_song_pairs.extend([(sg['singer_id'], row['id']) for sg in singer_list])
1238 total_ok += 1
1239
1240 if not song_updates:
1241 log.warning("Kugou singers backfill: no singer data found for this batch, stopping to avoid retry loop")
1242 break
1243
1244 with pg_conn.cursor() as pg_cur:
1245 upsert_kugou_singers(pg_cur, singer_rows)
1246 update_kugou_song_singers(pg_cur, song_updates)
1247 upsert_kugou_singer_songs(pg_cur, singer_song_pairs)
1248 pg_conn.commit()
1249
1250 batch_index += 1
1251 pbar.update(1)
1252 pbar.close()
1253 log.info("Kugou singers backfill: ok=%d skip=%d", total_ok, total_skip)
1254
1255
1256 def _backfill_netease_singers(spider_conn, pg_conn, bucket, base_url, max_batches):
1257 batch_index = 0
1258 total_ok = total_skip = 0
1259 pbar = tqdm(desc='backfill-netease-singers')
1260 while max_batches is None or batch_index < max_batches:
1261 with pg_conn.cursor() as pg_cur:
1262 rows = fetch_netease_songs_missing_singers(pg_cur, BATCH_SIZE)
1263 if not rows:
1264 break
1265
1266 song_ids = [row['platform_song_id'] for row in rows]
1267 netease_singers_map = fetch_netease_singers(spider_conn, song_ids)
1268
1269 singer_by_id: dict[int, dict] = {}
1270 for sgs in netease_singers_map.values():
1271 for sg in sgs:
1272 singer_by_id[sg['singer_id']] = sg
1273
1274 avatar_tasks = {
1275 f"avatar:{sg['singer_id']}": (
1276 lambda sg=sg: _safe_transfer(
1277 sg.get('avatar', ''),
1278 build_oss_key('netease', 'singer', str(sg['singer_id']) + '.jpg'),
1279 bucket, base_url,
1280 )
1281 )
1282 for sg in singer_by_id.values()
1283 }
1284 avatar_assets = _run_io_tasks(avatar_tasks)
1285
1286 singer_rows = [{
1287 **sg,
1288 'id': sg['singer_id'],
1289 'avatar': avatar_assets.get(f"avatar:{sg['singer_id']}", sg.get('avatar', '')),
1290 'provider_name': PROVIDER_YINYAN,
1291 'crawler_source_data': _source_json(sg),
1292 } for sg in singer_by_id.values()]
1293
1294 song_updates = []
1295 singer_song_pairs = []
1296 for row in rows:
1297 singer_list = netease_singers_map.get(row['platform_song_id'], [])
1298 if not singer_list:
1299 total_skip += 1
1300 continue
1301 singers_json = json.dumps([{
1302 'name': sg['name'],
1303 'singer_id': sg['singer_id'],
1304 'platform_singer_id': str(sg['singer_id']),
1305 } for sg in singer_list], ensure_ascii=False)
1306 song_updates.append({'platform_song_id': row['platform_song_id'], 'singers_json': singers_json})
1307 singer_song_pairs.extend([(sg['singer_id'], row['id']) for sg in singer_list])
1308 total_ok += 1
1309
1310 if not song_updates:
1311 log.warning("Netease singers backfill: no singer data found for this batch, stopping to avoid retry loop")
1312 break
1313
1314 with pg_conn.cursor() as pg_cur:
1315 upsert_netease_singers(pg_cur, singer_rows)
1316 update_netease_song_singers(pg_cur, song_updates)
1317 upsert_netease_singer_songs(pg_cur, singer_song_pairs)
1318 pg_conn.commit()
1319
1320 batch_index += 1
1321 pbar.update(1)
1322 pbar.close()
1323 log.info("Netease singers backfill: ok=%d skip=%d", total_ok, total_skip)
1324
1325
1326 def backfill_empty_singers(platforms: list[str], max_batches: int | None = None) -> None:
1327 spider_conn = get_spider_conn()
1328 pg_conn = get_pg_conn()
1329 bucket = get_oss_bucket()
1330 base_url = OSS_CONFIG['base_url']
1331 try:
1332 if PLATFORM_QQ in platforms:
1333 _backfill_qq_singers(spider_conn, pg_conn, bucket, base_url, max_batches)
1334 if PLATFORM_KUGOU in platforms:
1335 _backfill_kugou_singers(spider_conn, pg_conn, bucket, base_url, max_batches)
1336 if PLATFORM_NETEASE in platforms:
1337 _backfill_netease_singers(spider_conn, pg_conn, bucket, base_url, max_batches)
1338 finally:
1339 spider_conn.close()
1340 pg_conn.close()
1341
1342
1108 def run( 1343 def run(
1109 platforms: list[str], 1344 platforms: list[str],
1110 max_batches: int | None = None, 1345 max_batches: int | None = None,
......
...@@ -118,6 +118,108 @@ def upsert_yinyan_song_records(cur, records: list[dict]) -> None: ...@@ -118,6 +118,108 @@ def upsert_yinyan_song_records(cur, records: list[dict]) -> None:
118 ) 118 )
119 119
120 120
121 # ─── Backfill helpers ────────────────────────────────────────────────────────
122
123 def fetch_qq_songs_missing_singers(cur, limit: int) -> list[dict]:
124 cur.execute(
125 """
126 SELECT id, platform_song_id, mid
127 FROM crawler_qqmusic_songs
128 WHERE singers IS NULL OR jsonb_array_length(singers) = 0
129 ORDER BY platform_song_id
130 LIMIT %s
131 """,
132 (limit,),
133 )
134 rows = cur.fetchall()
135 return [{'id': str(row[0]), 'platform_song_id': row[1], 'mid': row[2]} for row in rows]
136
137
138 def fetch_kugou_songs_missing_singers(cur, limit: int) -> list[dict]:
139 cur.execute(
140 """
141 SELECT id, platform_song_id
142 FROM crawler_kugou_songs
143 WHERE singers IS NULL OR jsonb_array_length(singers) = 0
144 ORDER BY platform_song_id
145 LIMIT %s
146 """,
147 (limit,),
148 )
149 rows = cur.fetchall()
150 return [{'id': str(row[0]), 'platform_song_id': row[1]} for row in rows]
151
152
153 def fetch_netease_songs_missing_singers(cur, limit: int) -> list[dict]:
154 cur.execute(
155 """
156 SELECT id, platform_song_id
157 FROM crawler_netease_songs
158 WHERE singers IS NULL OR jsonb_array_length(singers) = 0
159 ORDER BY platform_song_id
160 LIMIT %s
161 """,
162 (limit,),
163 )
164 rows = cur.fetchall()
165 return [{'id': str(row[0]), 'platform_song_id': row[1]} for row in rows]
166
167
168 def update_qq_song_singers(cur, updates: list[dict]) -> None:
169 """updates: [{'platform_song_id': int, 'singers_json': str}, ...]"""
170 if not updates:
171 return
172 values_sql = ', '.join(['(%s::bigint, %s::jsonb)'] * len(updates))
173 params = []
174 for u in updates:
175 params.extend([u['platform_song_id'], u['singers_json']])
176 cur.execute(
177 f"""
178 UPDATE crawler_qqmusic_songs AS s
179 SET singers = v.singers, updated_at = NOW()
180 FROM (VALUES {values_sql}) AS v(platform_song_id, singers)
181 WHERE s.platform_song_id = v.platform_song_id
182 """,
183 tuple(params),
184 )
185
186
187 def update_kugou_song_singers(cur, updates: list[dict]) -> None:
188 if not updates:
189 return
190 values_sql = ', '.join(['(%s::bigint, %s::jsonb)'] * len(updates))
191 params = []
192 for u in updates:
193 params.extend([u['platform_song_id'], u['singers_json']])
194 cur.execute(
195 f"""
196 UPDATE crawler_kugou_songs AS s
197 SET singers = v.singers, updated_at = NOW()
198 FROM (VALUES {values_sql}) AS v(platform_song_id, singers)
199 WHERE s.platform_song_id = v.platform_song_id
200 """,
201 tuple(params),
202 )
203
204
205 def update_netease_song_singers(cur, updates: list[dict]) -> None:
206 if not updates:
207 return
208 values_sql = ', '.join(['(%s::bigint, %s::jsonb)'] * len(updates))
209 params = []
210 for u in updates:
211 params.extend([u['platform_song_id'], u['singers_json']])
212 cur.execute(
213 f"""
214 UPDATE crawler_netease_songs AS s
215 SET singers = v.singers, updated_at = NOW()
216 FROM (VALUES {values_sql}) AS v(platform_song_id, singers)
217 WHERE s.platform_song_id = v.platform_song_id
218 """,
219 tuple(params),
220 )
221
222
121 # ─── QQ Music ──────────────────────────────────────────────────────────────── 223 # ─── QQ Music ────────────────────────────────────────────────────────────────
122 224
123 def upsert_qq_singers(cur, singers: list[dict]) -> None: 225 def upsert_qq_singers(cur, singers: list[dict]) -> None:
......
1 #!/usr/bin/env python3 1 #!/usr/bin/env python3
2 import argparse 2 import argparse
3 from etl_to_crawler.config import PLATFORM_QQ, PLATFORM_KUGOU, PLATFORM_NETEASE, PLATFORMS 3 from etl_to_crawler.config import PLATFORM_QQ, PLATFORM_KUGOU, PLATFORM_NETEASE, PLATFORMS
4 from etl_to_crawler.runner import backfill_yinyan_record_platforms, initialize_yinyan_song_records, run 4 from etl_to_crawler.runner import backfill_yinyan_record_platforms, initialize_yinyan_song_records, run, backfill_empty_singers
5 5
6 PLATFORM_MAP = { 6 PLATFORM_MAP = {
7 'qq': PLATFORM_QQ, 7 'qq': PLATFORM_QQ,
...@@ -20,6 +20,8 @@ if __name__ == '__main__': ...@@ -20,6 +20,8 @@ if __name__ == '__main__':
20 help='只初始化 yinyan_song_records 待导入状态表,不执行 crawler 导入') 20 help='只初始化 yinyan_song_records 待导入状态表,不执行 crawler 导入')
21 parser.add_argument('--backfill-yinyan-platforms', action='store_true', 21 parser.add_argument('--backfill-yinyan-platforms', action='store_true',
22 help='只回填 yinyan_song_records 中为空的 platform 平台代码') 22 help='只回填 yinyan_song_records 中为空的 platform 平台代码')
23 parser.add_argument('--backfill-empty-singers', action='store_true',
24 help='回填三平台 singers 为空的歌曲记录')
23 args = parser.parse_args() 25 args = parser.parse_args()
24 26
25 if args.platform == 'all': 27 if args.platform == 'all':
...@@ -32,5 +34,7 @@ if __name__ == '__main__': ...@@ -32,5 +34,7 @@ if __name__ == '__main__':
32 backfill_yinyan_record_platforms(max_batches=args.max_batches) 34 backfill_yinyan_record_platforms(max_batches=args.max_batches)
33 elif args.init_yinyan_records: 35 elif args.init_yinyan_records:
34 initialize_yinyan_song_records(platforms, max_batches=args.max_batches) 36 initialize_yinyan_song_records(platforms, max_batches=args.max_batches)
37 elif args.backfill_empty_singers:
38 backfill_empty_singers(platforms, max_batches=args.max_batches)
35 else: 39 else:
36 run(platforms, max_batches=args.max_batches) 40 run(platforms, max_batches=args.max_batches)
......