feat(etl): 添加回填三平台空singers字段歌曲记录功能
- run_etl.py中新增--backfill-empty-singers参数支持回填操作 - runner.py新增backfill_empty_singers主流程函数及qq、酷狗、网易分平台回填实现 - writer.py新增查询缺失singers的歌曲及更新singers字段的数据库操作函数 - 回填过程中处理歌手头像的转存及歌手与歌曲关联关系的维护 - 支持批量分批次处理,避免长时间阻塞与重复无效查询 - 完整回填流程整合蜘蛛库和pg库连接资源管理,并日志记录处理结果
Showing
3 changed files
with
342 additions
and
1 deletions
| ... | @@ -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) | ... | ... |
-
Please register or sign in to post a comment