Commit c403b29e c403b29eb0e01dabe288c2b8dde627a0738defb4 by 沈秋雨

feat(etl): 新增 yinyan_song_records 平台回填与歌手优先录音选取

- 在 ETL 入口增加 --backfill-yinyan-platforms 参数,支持回填缺失平台代码
- 实现回填函数 backfill_yinyan_record_platforms,批量更新 platform 为空的记录
- fetch_all_platform_records 支持获取所有录音供歌手数据缺失的回退使用
- 通过 _pick_record_with_singer 函数优先选取有歌手数据的录音
- 初始化函数跳过已存在 song_id 的批次,避免重复处理
- yinyan_song_records 插入时允许更新 platform 字段,平台数据更完整
- upsert_yinyan_song_records 增加 record_id 更新支持,修正推送逻辑
- 所有平台歌曲标题拆分版本号存储 version 字段,支持特殊版本标记
- 在各平台封面缺失时使用歌曲封面回退,保证封面不为空
- spider 模块新增 probe_*_has_singers 函数校验录音是否有关联歌手
- writer 模块完善更新平台代码的操作及相关查询辅助函数
- utils 新增 split_title_version 助手,提取括号中版本信息
- upload_plain_lyric_to_bucket 上传时添加内容类型头,避免文本编码问题
- 测试覆盖添加回填功能及版本号拆分相关逻辑,提高稳定性维护性
1 parent 07b63e1b
...@@ -43,4 +43,4 @@ PLATFORM_KUGOU = '2' ...@@ -43,4 +43,4 @@ PLATFORM_KUGOU = '2'
43 PLATFORM_NETEASE = '4' 43 PLATFORM_NETEASE = '4'
44 PLATFORMS = [PLATFORM_QQ, PLATFORM_KUGOU, PLATFORM_NETEASE] 44 PLATFORMS = [PLATFORM_QQ, PLATFORM_KUGOU, PLATFORM_NETEASE]
45 45
46 BATCH_SIZE = 3 46 BATCH_SIZE = 4
......
...@@ -54,6 +54,9 @@ ORDER BY sar.song_id, ...@@ -54,6 +54,9 @@ ORDER BY sar.song_id,
54 mr.id ASC 54 mr.id ASC
55 """ 55 """
56 56
57 # 同 _PLATFORM_QUERY,但不做 per-platform 去重,保留同平台全部录音供 singer fallback 遍历
58 _ALL_PLATFORM_RECORDS_QUERY = _PLATFORM_QUERY
59
57 60
58 def iter_hk_songs_batches( 61 def iter_hk_songs_batches(
59 conn: pymysql.Connection, 62 conn: pymysql.Connection,
...@@ -100,6 +103,44 @@ def fetch_platform_records(source_conn: pymysql.Connection, song_ids: list[int]) ...@@ -100,6 +103,44 @@ def fetch_platform_records(source_conn: pymysql.Connection, song_ids: list[int])
100 return select_platform_records(rows) 103 return select_platform_records(rows)
101 104
102 105
106 def fetch_all_platform_records(source_conn: pymysql.Connection, song_ids: list[int]) -> list[dict]:
107 """返回每首歌的全部录音(不做 per-platform 去重),供 singer fallback 遍历。"""
108 if not song_ids:
109 return []
110 placeholders = ','.join(['%s'] * len(song_ids))
111 query = _ALL_PLATFORM_RECORDS_QUERY.format(placeholders=placeholders)
112 with source_conn.cursor() as cur:
113 cur.execute(query, song_ids)
114 rows = cur.fetchall()
115 # 按优先级排序后按 record_id 去重(同一录音可能关联多次)
116 seen_record_ids: set = set()
117 result = []
118 for row in sorted(rows, key=_record_priority):
119 rid = row['record_id']
120 if rid not in seen_record_ids:
121 seen_record_ids.add(rid)
122 result.append(row)
123 return result
124
125
126 def fetch_record_platforms(source_conn: pymysql.Connection, record_ids: list[int]) -> dict[int, str]:
127 """按录音 id 查询平台代码,用于回填 yinyan_song_records.platform。"""
128 if not record_ids:
129 return {}
130 placeholders = ','.join(['%s'] * len(record_ids))
131 query = f"""
132 SELECT id, platform
133 FROM hk_music_record
134 WHERE id IN ({placeholders})
135 AND platform IN ('1','2','4')
136 AND deleted = 0
137 """
138 with source_conn.cursor() as cur:
139 cur.execute(query, record_ids)
140 rows = cur.fetchall()
141 return {int(row['id']): str(row['platform']) for row in rows}
142
143
103 def select_platform_records(rows: list[dict]) -> list[dict]: 144 def select_platform_records(rows: list[dict]) -> list[dict]:
104 # 每个 (source_song_id, platform) 保留一条,用于继续导入多个平台的录音数据。 145 # 每个 (source_song_id, platform) 保留一条,用于继续导入多个平台的录音数据。
105 seen = {} 146 seen = {}
......
...@@ -9,17 +9,23 @@ from .reader import ( ...@@ -9,17 +9,23 @@ from .reader import (
9 iter_hk_songs_batches, 9 iter_hk_songs_batches,
10 fetch_hk_songs_by_source_ids, 10 fetch_hk_songs_by_source_ids,
11 fetch_platform_records, 11 fetch_platform_records,
12 fetch_all_platform_records,
13 fetch_record_platforms,
12 select_primary_record, 14 select_primary_record,
13 ) 15 )
14 from .spider import ( 16 from .spider import (
15 fetch_qq_songs, fetch_qq_singers, 17 fetch_qq_songs, fetch_qq_singers,
16 fetch_kugou_songs, fetch_kugou_singers, 18 fetch_kugou_songs, fetch_kugou_singers,
17 fetch_netease_songs, fetch_netease_singers, 19 fetch_netease_songs, fetch_netease_singers,
20 probe_qq_has_singers, probe_kugou_has_singers, probe_netease_has_singers,
18 ) 21 )
19 from .writer import ( 22 from .writer import (
20 fetch_pending_yinyan_song_records, 23 fetch_pending_yinyan_song_records,
24 fetch_yinyan_records_missing_platform,
21 insert_yinyan_song_records, 25 insert_yinyan_song_records,
26 update_yinyan_record_platforms,
22 upsert_yinyan_song_records, 27 upsert_yinyan_song_records,
28 fetch_existing_yinyan_song_ids,
23 upsert_qq_singers, upsert_qq_albums, upsert_qq_songs, 29 upsert_qq_singers, upsert_qq_albums, upsert_qq_songs,
24 upsert_qq_singer_songs, upsert_qq_singer_albums, 30 upsert_qq_singer_songs, upsert_qq_singer_albums,
25 upsert_kugou_singers, upsert_kugou_albums, upsert_kugou_songs, 31 upsert_kugou_singers, upsert_kugou_albums, upsert_kugou_songs,
...@@ -28,7 +34,7 @@ from .writer import ( ...@@ -28,7 +34,7 @@ from .writer import (
28 upsert_netease_singer_songs, upsert_netease_singer_albums, 34 upsert_netease_singer_songs, upsert_netease_singer_albums,
29 ) 35 )
30 from .oss import transfer_url, transfer_url_with_md5, build_oss_key 36 from .oss import transfer_url, transfer_url_with_md5, build_oss_key
31 from .utils import upload_plain_lyric_to_bucket 37 from .utils import split_title_version, upload_plain_lyric_to_bucket
32 from .lyric import ensure_newlines 38 from .lyric import ensure_newlines
33 39
34 logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s') 40 logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s')
...@@ -97,6 +103,9 @@ def _process_qq(hk_row: dict, pr: dict, spider_conn, pg_cur, bucket, base_url): ...@@ -97,6 +103,9 @@ def _process_qq(hk_row: dict, pr: dict, spider_conn, pg_cur, bucket, base_url):
97 build_oss_key('qq', 'album', str(sp['album_id']) + '.jpg'), 103 build_oss_key('qq', 'album', str(sp['album_id']) + '.jpg'),
98 bucket, base_url 104 bucket, base_url
99 ) 105 )
106 # 专辑封面为空时回退使用歌曲封面
107 if not album_cover and cover_url:
108 album_cover = cover_url
100 109
101 # 歌手头像转移 + 写入 singers 110 # 歌手头像转移 + 写入 singers
102 singer_rows = [] 111 singer_rows = []
...@@ -152,13 +161,15 @@ def _process_qq(hk_row: dict, pr: dict, spider_conn, pg_cur, bucket, base_url): ...@@ -152,13 +161,15 @@ def _process_qq(hk_row: dict, pr: dict, spider_conn, pg_cur, bucket, base_url):
152 song_uuid = str(uuid.uuid4()) 161 song_uuid = str(uuid.uuid4())
153 raw_lyric = sp.get('lyric') or '' 162 raw_lyric = sp.get('lyric') or ''
154 lyric_url = _safe_upload_lyric('qq', mid, raw_lyric, hk_row.get('lyrics_url'), bucket, base_url) 163 lyric_url = _safe_upload_lyric('qq', mid, raw_lyric, hk_row.get('lyrics_url'), bucket, base_url)
164 title, version = split_title_version(sp.get('title') or hk_row['name'])
155 upsert_qq_songs(pg_cur, [{ 165 upsert_qq_songs(pg_cur, [{
156 'song_uuid': song_uuid, 166 'song_uuid': song_uuid,
157 'platform_song_id': platform_song_id, 167 'platform_song_id': platform_song_id,
158 'mid': mid, 168 'mid': mid,
159 'album_id': album_id, 169 'album_id': album_id,
160 'cover': cover_url, 170 'cover': cover_url,
161 'title': sp.get('title', hk_row['name']), 171 'title': title,
172 'version': version,
162 'name': hk_row['name'], 173 'name': hk_row['name'],
163 'duration': sp.get('duration') or hk_row.get('song_time') or 0, 174 'duration': sp.get('duration') or hk_row.get('song_time') or 0,
164 'lyric': ensure_newlines(raw_lyric), 175 'lyric': ensure_newlines(raw_lyric),
...@@ -213,6 +224,9 @@ def _process_kugou(hk_row: dict, pr: dict, spider_conn, pg_cur, bucket, base_url ...@@ -213,6 +224,9 @@ def _process_kugou(hk_row: dict, pr: dict, spider_conn, pg_cur, bucket, base_url
213 build_oss_key('kugou', 'album', str(sp['album_id']) + '.jpg'), 224 build_oss_key('kugou', 'album', str(sp['album_id']) + '.jpg'),
214 bucket, base_url 225 bucket, base_url
215 ) 226 )
227 # 专辑封面为空时回退使用歌曲封面
228 if not album_cover and cover_url:
229 album_cover = cover_url
216 230
217 singer_rows = [] 231 singer_rows = []
218 for sg in singer_list: 232 for sg in singer_list:
...@@ -262,6 +276,7 @@ def _process_kugou(hk_row: dict, pr: dict, spider_conn, pg_cur, bucket, base_url ...@@ -262,6 +276,7 @@ def _process_kugou(hk_row: dict, pr: dict, spider_conn, pg_cur, bucket, base_url
262 song_uuid = str(uuid.uuid4()) 276 song_uuid = str(uuid.uuid4())
263 raw_lyric = sp.get('lyric') or '' 277 raw_lyric = sp.get('lyric') or ''
264 lyric_url = _safe_upload_lyric('kugou', str(song_id), raw_lyric, hk_row.get('lyrics_url'), bucket, base_url) 278 lyric_url = _safe_upload_lyric('kugou', str(song_id), raw_lyric, hk_row.get('lyrics_url'), bucket, base_url)
279 title, version = split_title_version(sp.get('title') or hk_row['name'])
265 upsert_kugou_songs(pg_cur, [{ 280 upsert_kugou_songs(pg_cur, [{
266 'song_uuid': song_uuid, 281 'song_uuid': song_uuid,
267 'platform_song_id': song_id, 282 'platform_song_id': song_id,
...@@ -269,7 +284,8 @@ def _process_kugou(hk_row: dict, pr: dict, spider_conn, pg_cur, bucket, base_url ...@@ -269,7 +284,8 @@ def _process_kugou(hk_row: dict, pr: dict, spider_conn, pg_cur, bucket, base_url
269 'album_audio_id': sp.get('album_audio_id') or pr.get('album_audio_id') or 0, 284 'album_audio_id': sp.get('album_audio_id') or pr.get('album_audio_id') or 0,
270 'album_id': album_id, 285 'album_id': album_id,
271 'cover': cover_url, 286 'cover': cover_url,
272 'title': sp.get('title', hk_row['name']), 287 'title': title,
288 'version': version,
273 'name': hk_row['name'], 289 'name': hk_row['name'],
274 'duration': sp.get('duration') or hk_row.get('song_time') or 0, 290 'duration': sp.get('duration') or hk_row.get('song_time') or 0,
275 'lyric': ensure_newlines(raw_lyric), 291 'lyric': ensure_newlines(raw_lyric),
...@@ -323,6 +339,9 @@ def _process_netease(hk_row: dict, pr: dict, spider_conn, pg_cur, bucket, base_u ...@@ -323,6 +339,9 @@ def _process_netease(hk_row: dict, pr: dict, spider_conn, pg_cur, bucket, base_u
323 build_oss_key('netease', 'album', str(sp['album_id']) + '.jpg'), 339 build_oss_key('netease', 'album', str(sp['album_id']) + '.jpg'),
324 bucket, base_url 340 bucket, base_url
325 ) 341 )
342 # 专辑封面为空时回退使用歌曲封面
343 if not album_cover and cover_url:
344 album_cover = cover_url
326 345
327 singer_rows = [] 346 singer_rows = []
328 for sg in singer_list: 347 for sg in singer_list:
...@@ -385,13 +404,15 @@ def _process_netease(hk_row: dict, pr: dict, spider_conn, pg_cur, bucket, base_u ...@@ -385,13 +404,15 @@ def _process_netease(hk_row: dict, pr: dict, spider_conn, pg_cur, bucket, base_u
385 song_uuid = str(uuid.uuid4()) 404 song_uuid = str(uuid.uuid4())
386 raw_lyric = sp.get('lyric') or '' 405 raw_lyric = sp.get('lyric') or ''
387 lyric_url = _safe_upload_lyric('netease', str(song_id), raw_lyric, hk_row.get('lyrics_url'), bucket, base_url) 406 lyric_url = _safe_upload_lyric('netease', str(song_id), raw_lyric, hk_row.get('lyrics_url'), bucket, base_url)
407 title, version = split_title_version(sp.get('title') or hk_row['name'])
388 upsert_netease_songs(pg_cur, [{ 408 upsert_netease_songs(pg_cur, [{
389 'song_uuid': song_uuid, 409 'song_uuid': song_uuid,
390 'platform_song_id': song_id, 410 'platform_song_id': song_id,
391 'album_id': album_id, 411 'album_id': album_id,
392 'album_json': album_json, 412 'album_json': album_json,
393 'cover': cover_url, 413 'cover': cover_url,
394 'title': sp.get('title', hk_row['name']), 414 'title': title,
415 'version': version,
395 'name': hk_row['name'], 416 'name': hk_row['name'],
396 'duration': sp.get('duration') or hk_row.get('song_time') or 0, 417 'duration': sp.get('duration') or hk_row.get('song_time') or 0,
397 'lyric': ensure_newlines(raw_lyric), 418 'lyric': ensure_newlines(raw_lyric),
...@@ -424,18 +445,49 @@ _PROCESSORS = { ...@@ -424,18 +445,49 @@ _PROCESSORS = {
424 PLATFORM_NETEASE: _process_netease, 445 PLATFORM_NETEASE: _process_netease,
425 } 446 }
426 447
448 _PROBERS = {
449 PLATFORM_QQ: lambda conn, pr: probe_qq_has_singers(conn, pr['platform_unique_key']),
450 PLATFORM_KUGOU: lambda conn, pr: probe_kugou_has_singers(conn, int(pr['platform_unique_key'])),
451 PLATFORM_NETEASE: lambda conn, pr: probe_netease_has_singers(conn, int(pr['platform_unique_key'])),
452 }
453
454
455 def _pick_record_with_singer(records: list[dict], spider_conn) -> dict | None:
456 """遍历录音列表,返回第一条在 spider DB 中有歌手数据的录音;全部无歌手时返回首条。"""
457 if not records:
458 return None
459 for pr in records:
460 prober = _PROBERS.get(pr['platform'])
461 if prober and prober(spider_conn, pr):
462 return pr
463 return records[0] # fallback:保留优先级最高的录音,即使没有歌手
464
427 465
428 def initialize_yinyan_song_records(platforms: list[str], max_batches: int | None = None) -> None: 466 def initialize_yinyan_song_records(platforms: list[str], max_batches: int | None = None) -> None:
429 hk_conn = get_hk_songs_conn() 467 hk_conn = get_hk_songs_conn()
430 src_conn = get_source_conn() 468 src_conn = get_source_conn()
431 pg_conn = get_pg_conn() 469 pg_conn = get_pg_conn()
432 470
471 # 查询已存在的 song_id,用于跳过已处理的记录
472 with pg_conn.cursor() as pg_cur:
473 existing_song_ids = fetch_existing_yinyan_song_ids(pg_cur)
474 log.info("已存在的 yinyan_song_records: %d 条,将跳过这些记录", len(existing_song_ids))
475
433 total = 0 476 total = 0
477 skipped_batches = 0
434 try: 478 try:
435 for i, batch in enumerate(tqdm(iter_hk_songs_batches(hk_conn, BATCH_SIZE), desc='init-yinyan')): 479 for i, batch in enumerate(tqdm(iter_hk_songs_batches(hk_conn, BATCH_SIZE), desc='init-yinyan')):
436 if max_batches is not None and i >= max_batches: 480 if max_batches is not None and i >= max_batches:
437 break 481 break
438 song_ids = [int(r['source_song_id']) for r in batch if r.get('source_song_id')] 482 # 过滤掉已存在的 song_id
483 song_ids = [
484 int(r['source_song_id']) for r in batch
485 if r.get('source_song_id') and int(r['source_song_id']) not in existing_song_ids
486 ]
487 if not song_ids:
488 skipped_batches += 1
489 continue
490
439 platform_records = fetch_platform_records(src_conn, song_ids) 491 platform_records = fetch_platform_records(src_conn, song_ids)
440 492
441 pr_by_song: dict[int, list] = {} 493 pr_by_song: dict[int, list] = {}
...@@ -455,6 +507,7 @@ def initialize_yinyan_song_records(platforms: list[str], max_batches: int | None ...@@ -455,6 +507,7 @@ def initialize_yinyan_song_records(platforms: list[str], max_batches: int | None
455 'record_id': int(primary_record['record_id']), 507 'record_id': int(primary_record['record_id']),
456 'platform': primary_record['platform'], 508 'platform': primary_record['platform'],
457 }) 509 })
510 existing_song_ids.add(src_id) # 标记为已处理
458 511
459 if init_rows: 512 if init_rows:
460 with pg_conn.cursor() as pg_cur: 513 with pg_conn.cursor() as pg_cur:
...@@ -466,7 +519,55 @@ def initialize_yinyan_song_records(platforms: list[str], max_batches: int | None ...@@ -466,7 +519,55 @@ def initialize_yinyan_song_records(platforms: list[str], max_batches: int | None
466 src_conn.close() 519 src_conn.close()
467 pg_conn.close() 520 pg_conn.close()
468 521
469 log.info("Initialized yinyan_song_records candidates=%d", total) 522 log.info("Initialized yinyan_song_records candidates=%d, skipped_batches=%d", total, skipped_batches)
523
524
525 def backfill_yinyan_record_platforms(max_batches: int | None = None) -> None:
526 src_conn = get_source_conn()
527 pg_conn = get_pg_conn()
528 total = 0
529 skipped = 0
530
531 try:
532 batch_index = 0
533 pbar = tqdm(desc='backfill-yinyan-platforms')
534 while max_batches is None or batch_index < max_batches:
535 with pg_conn.cursor() as pg_cur:
536 rows = fetch_yinyan_records_missing_platform(pg_cur, BATCH_SIZE)
537 if not rows:
538 break
539
540 record_ids = [int(row['record_id']) for row in rows if row.get('record_id')]
541 platforms_by_record = fetch_record_platforms(src_conn, record_ids)
542 updates = []
543 for row in rows:
544 platform = platforms_by_record.get(int(row['record_id']))
545 if not platform:
546 skipped += 1
547 continue
548 updates.append({
549 'song_id': int(row['song_id']),
550 'record_id': int(row['record_id']),
551 'platform': platform,
552 })
553
554 if updates:
555 with pg_conn.cursor() as pg_cur:
556 update_yinyan_record_platforms(pg_cur, updates)
557 pg_conn.commit()
558 total += len(updates)
559 else:
560 log.error("No yinyan_song_records platform rows were backfilled in this batch; stopping to avoid retry loop")
561 break
562
563 batch_index += 1
564 pbar.update(1)
565 pbar.close()
566 finally:
567 src_conn.close()
568 pg_conn.close()
569
570 log.info("Backfilled yinyan_song_records platform rows=%d, skipped=%d", total, skipped)
470 571
471 572
472 def run( 573 def run(
...@@ -492,15 +593,23 @@ def run( ...@@ -492,15 +593,23 @@ def run(
492 break 593 break
493 594
494 song_ids = [int(r['song_id']) for r in pending_records] 595 song_ids = [int(r['song_id']) for r in pending_records]
495 pending_by_song = {int(r['song_id']): int(r['record_id']) for r in pending_records}
496 hk_by_song = fetch_hk_songs_by_source_ids(hk_conn, song_ids) 596 hk_by_song = fetch_hk_songs_by_source_ids(hk_conn, song_ids)
497 platform_records = fetch_platform_records(src_conn, song_ids) 597 # 全部录音(不去重),供 singer fallback 遍历
598 all_platform_records = fetch_all_platform_records(src_conn, song_ids)
498 599
499 # index platform records by source_song_id 600 # 按 (song_id, platform) 分组,每组已按优先级排好序
500 pr_by_song: dict[int, list] = {} 601 pr_by_song_platform: dict[tuple, list] = {}
501 for pr in platform_records: 602 for pr in all_platform_records:
502 if pr['platform'] in platforms: 603 if pr['platform'] in platforms:
503 pr_by_song.setdefault(int(pr['source_song_id']), []).append(pr) 604 key = (int(pr['source_song_id']), pr['platform'])
605 pr_by_song_platform.setdefault(key, []).append(pr)
606
607 # 每首歌:从各平台各选一条最优录音(有歌手优先)
608 pr_by_song: dict[int, list] = {}
609 for (src_id, _platform), records in pr_by_song_platform.items():
610 chosen = _pick_record_with_singer(records, spider_conn)
611 if chosen:
612 pr_by_song.setdefault(src_id, []).append(chosen)
504 613
505 with pg_conn.cursor() as pg_cur: 614 with pg_conn.cursor() as pg_cur:
506 pushed_count = 0 615 pushed_count = 0
...@@ -511,7 +620,6 @@ def run( ...@@ -511,7 +620,6 @@ def run(
511 if not src_id or src_id not in pr_by_song: 620 if not src_id or src_id not in pr_by_song:
512 continue 621 continue
513 wrote_yinyan_record = False 622 wrote_yinyan_record = False
514 pending_record_id = pending_by_song[src_id]
515 for pr in pr_by_song[src_id]: 623 for pr in pr_by_song[src_id]:
516 processor = _PROCESSORS.get(pr['platform']) 624 processor = _PROCESSORS.get(pr['platform'])
517 if not processor: 625 if not processor:
...@@ -519,15 +627,11 @@ def run( ...@@ -519,15 +627,11 @@ def run(
519 pg_cur.execute('SAVEPOINT sp_song') 627 pg_cur.execute('SAVEPOINT sp_song')
520 try: 628 try:
521 result = processor(hk_row, pr, spider_conn, pg_cur, bucket, base_url) 629 result = processor(hk_row, pr, spider_conn, pg_cur, bucket, base_url)
522 if ( 630 if result and not wrote_yinyan_record:
523 result
524 and not wrote_yinyan_record
525 and int(pr['record_id']) == pending_record_id
526 ):
527 upsert_yinyan_song_records(pg_cur, [{ 631 upsert_yinyan_song_records(pg_cur, [{
528 'song_id': src_id, 632 'song_id': src_id,
529 'record_id': pending_record_id, 633 'record_id': int(pr['record_id']),
530 'platform': result['platform'], 634 'platform': pr['platform'],
531 'platform_song_id': int(result['platform_song_id']), 635 'platform_song_id': int(result['platform_song_id']),
532 }]) 636 }])
533 wrote_yinyan_record = True 637 wrote_yinyan_record = True
......
...@@ -46,6 +46,18 @@ def fetch_qq_singers(conn: pymysql.Connection, song_ids: list[int]) -> dict[int, ...@@ -46,6 +46,18 @@ def fetch_qq_singers(conn: pymysql.Connection, song_ids: list[int]) -> dict[int,
46 return result 46 return result
47 47
48 48
49 def probe_qq_has_singers(conn: pymysql.Connection, mid: str) -> bool:
50 """检查 QQ 录音(按 mid)在 spider DB 中是否有歌手关联。"""
51 with conn.cursor() as cur:
52 cur.execute(
53 "SELECT 1 FROM media_tencent_songs s "
54 "JOIN media_tencent_singer_has_songs shs ON shs.song_id = s.id "
55 "WHERE s.mid = %s LIMIT 1",
56 (mid,),
57 )
58 return cur.fetchone() is not None
59
60
49 # ─── Kugou ─────────────────────────────────────────────────────────────────── 61 # ─── Kugou ───────────────────────────────────────────────────────────────────
50 62
51 _KUGOU_SONGS_SQL = """ 63 _KUGOU_SONGS_SQL = """
...@@ -81,6 +93,16 @@ def fetch_kugou_singers(conn: pymysql.Connection, song_ids: list[int]) -> dict[i ...@@ -81,6 +93,16 @@ def fetch_kugou_singers(conn: pymysql.Connection, song_ids: list[int]) -> dict[i
81 return result 93 return result
82 94
83 95
96 def probe_kugou_has_singers(conn: pymysql.Connection, song_id: int) -> bool:
97 """检查酷狗录音在 spider DB 中是否有歌手关联。"""
98 with conn.cursor() as cur:
99 cur.execute(
100 "SELECT 1 FROM media_ku_gou_singer_has_songs WHERE song_id = %s LIMIT 1",
101 (song_id,),
102 )
103 return cur.fetchone() is not None
104
105
84 # ─── Netease ───────────────────────────────────────────────────────────────── 106 # ─── Netease ─────────────────────────────────────────────────────────────────
85 107
86 _NETEASE_SONGS_SQL = """ 108 _NETEASE_SONGS_SQL = """
...@@ -114,3 +136,13 @@ def fetch_netease_singers(conn: pymysql.Connection, song_ids: list[int]) -> dict ...@@ -114,3 +136,13 @@ def fetch_netease_singers(conn: pymysql.Connection, song_ids: list[int]) -> dict
114 for row in rows: 136 for row in rows:
115 result.setdefault(row['song_id'], []).append(row) 137 result.setdefault(row['song_id'], []).append(row)
116 return result 138 return result
139
140
141 def probe_netease_has_singers(conn: pymysql.Connection, song_id: int) -> bool:
142 """检查网易云录音在 spider DB 中是否有歌手关联。"""
143 with conn.cursor() as cur:
144 cur.execute(
145 "SELECT 1 FROM media_netease_singer_has_songs WHERE song_id = %s LIMIT 1",
146 (song_id,),
147 )
148 return cur.fetchone() is not None
......
...@@ -92,13 +92,29 @@ def extract_plain_lyric(lyric: str) -> str: ...@@ -92,13 +92,29 @@ def extract_plain_lyric(lyric: str) -> str:
92 return "\n".join(lines) 92 return "\n".join(lines)
93 93
94 94
95 def split_title_version(title: str | None) -> tuple[str, str]:
96 """拆分歌名末尾括号版本信息。
97
98 例如:化风行万里 (DJ默涵版) -> (化风行万里, DJ默涵版)
99 """
100 if not title:
101 return "", ""
102 text = title.strip()
103 match = re.match(r"^(?P<title>.+?)\s*[\((](?P<version>[^()()]+)[\))]\s*$", text)
104 if not match:
105 return text, ""
106 clean_title = match.group("title").strip()
107 version = match.group("version").strip()
108 return clean_title or text, version
109
110
95 def upload_plain_lyric_to_bucket(platform: str, unique_id: str, lyric: str, bucket, base_url: str) -> str: 111 def upload_plain_lyric_to_bucket(platform: str, unique_id: str, lyric: str, bucket, base_url: str) -> str:
96 """将歌词去时间戳后上传到当前 ETL 使用的 OSS bucket""" 112 """将歌词去时间戳后上传到当前 ETL 使用的 OSS bucket"""
97 plain_lyric = extract_plain_lyric(lyric) 113 plain_lyric = extract_plain_lyric(lyric)
98 if not plain_lyric: 114 if not plain_lyric:
99 return "" 115 return ""
100 oss_key = f"crawler/{platform}/lyric/{unique_id}.txt" 116 oss_key = f"crawler/{platform}/lyric/{unique_id}.txt"
101 bucket.put_object(oss_key, plain_lyric.encode("utf-8")) 117 bucket.put_object(oss_key, plain_lyric.encode("utf-8"), headers={'Content-Type': 'text/plain; charset=utf-8'})
102 return f"{base_url.rstrip('/')}/{oss_key}" 118 return f"{base_url.rstrip('/')}/{oss_key}"
103 119
104 120
......
...@@ -9,12 +9,20 @@ def insert_yinyan_song_records(cur, records: list[dict]) -> None: ...@@ -9,12 +9,20 @@ def insert_yinyan_song_records(cur, records: list[dict]) -> None:
9 """ 9 """
10 INSERT INTO yinyan_song_records (song_id, record_id, platform, is_yinyan_push) 10 INSERT INTO yinyan_song_records (song_id, record_id, platform, is_yinyan_push)
11 VALUES (%s, %s, %s, FALSE) 11 VALUES (%s, %s, %s, FALSE)
12 ON CONFLICT (song_id, record_id) DO NOTHING 12 ON CONFLICT (song_id, record_id) DO UPDATE
13 SET platform = EXCLUDED.platform
14 WHERE yinyan_song_records.platform IS NULL
13 """, 15 """,
14 [(r['song_id'], r['record_id'], r['platform']) for r in records], 16 [(r['song_id'], r['record_id'], r['platform']) for r in records],
15 ) 17 )
16 18
17 19
20 def fetch_existing_yinyan_song_ids(cur) -> set[int]:
21 """返回已完成初始化 platform 的 song_id 集合。"""
22 cur.execute("SELECT DISTINCT song_id FROM yinyan_song_records WHERE platform IS NOT NULL")
23 return {row[0] for row in cur.fetchall()}
24
25
18 def fetch_pending_yinyan_song_records(cur, limit: int) -> list[dict]: 26 def fetch_pending_yinyan_song_records(cur, limit: int) -> list[dict]:
19 cur.execute( 27 cur.execute(
20 """ 28 """
...@@ -30,8 +38,40 @@ def fetch_pending_yinyan_song_records(cur, limit: int) -> list[dict]: ...@@ -30,8 +38,40 @@ def fetch_pending_yinyan_song_records(cur, limit: int) -> list[dict]:
30 return [{'song_id': row[0], 'record_id': row[1]} for row in rows] 38 return [{'song_id': row[0], 'record_id': row[1]} for row in rows]
31 39
32 40
41 def fetch_yinyan_records_missing_platform(cur, limit: int) -> list[dict]:
42 cur.execute(
43 """
44 SELECT song_id, record_id
45 FROM yinyan_song_records
46 WHERE platform IS NULL
47 ORDER BY song_id
48 LIMIT %s
49 """,
50 (limit,),
51 )
52 rows = cur.fetchall()
53 return [{'song_id': row[0], 'record_id': row[1]} for row in rows]
54
55
56 def update_yinyan_record_platforms(cur, records: list[dict]) -> None:
57 if not records:
58 return
59 cur.executemany(
60 """
61 UPDATE yinyan_song_records
62 SET platform = %s
63 WHERE song_id = %s
64 AND record_id = %s
65 AND platform IS NULL
66 """,
67 [(r['platform'], r['song_id'], r['record_id']) for r in records],
68 )
69
70
33 def upsert_yinyan_song_records(cur, records: list[dict]) -> None: 71 def upsert_yinyan_song_records(cur, records: list[dict]) -> None:
34 """Mark pre-initialized yinyan song-record rows as pushed to crawler.""" 72 """Mark pre-initialized yinyan song-record rows as pushed to crawler.
73 Matches only on song_id so singer-fallback can use a different record_id than initialized.
74 """
35 if not records: 75 if not records:
36 return 76 return
37 cur.executemany( 77 cur.executemany(
...@@ -39,11 +79,12 @@ def upsert_yinyan_song_records(cur, records: list[dict]) -> None: ...@@ -39,11 +79,12 @@ def upsert_yinyan_song_records(cur, records: list[dict]) -> None:
39 UPDATE yinyan_song_records 79 UPDATE yinyan_song_records
40 SET platform = %s, 80 SET platform = %s,
41 platform_song_id = %s, 81 platform_song_id = %s,
82 record_id = %s,
42 is_yinyan_push = TRUE 83 is_yinyan_push = TRUE
43 WHERE song_id = %s AND record_id = %s 84 WHERE song_id = %s AND is_yinyan_push = FALSE
44 """, 85 """,
45 [ 86 [
46 (r['platform'], r['platform_song_id'], r['song_id'], r['record_id']) 87 (r['platform'], r['platform_song_id'], r['record_id'], r['song_id'])
47 for r in records 88 for r in records
48 ], 89 ],
49 ) 90 )
...@@ -98,8 +139,8 @@ def upsert_qq_songs(cur, songs: list[dict]) -> None: ...@@ -98,8 +139,8 @@ def upsert_qq_songs(cur, songs: list[dict]) -> None:
98 (id, platform_song_id, mid, album_id, cover, title, name, duration, 139 (id, platform_song_id, mid, album_id, cover, title, name, duration,
99 lyric, composer_name, lyricist_name, url, lyric_url, 140 lyric, composer_name, lyricist_name, url, lyric_url,
100 platform_index_url, published_at, singers, status, created_at, updated_at, 141 platform_index_url, published_at, singers, status, created_at, updated_at,
101 audio_md5, provider_name, crawler_source_data) 142 version, audio_md5, provider_name, crawler_source_data)
102 VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb, 0, NOW(), NOW(), %s, %s, %s::json) 143 VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb, 0, NOW(), NOW(), %s, %s, %s, %s::json)
103 ON CONFLICT (platform_song_id) DO NOTHING 144 ON CONFLICT (platform_song_id) DO NOTHING
104 """ 145 """
105 rows = [( 146 rows = [(
...@@ -108,6 +149,7 @@ def upsert_qq_songs(cur, songs: list[dict]) -> None: ...@@ -108,6 +149,7 @@ def upsert_qq_songs(cur, songs: list[dict]) -> None:
108 s.get('lyric'), s.get('composer_name'), s.get('lyricist_name'), 149 s.get('lyric'), s.get('composer_name'), s.get('lyricist_name'),
109 s.get('url', ''), s.get('lyric_url'), 150 s.get('url', ''), s.get('lyric_url'),
110 s.get('platform_index_url'), s.get('published_at'), s.get('singers_json', '[]'), 151 s.get('platform_index_url'), s.get('published_at'), s.get('singers_json', '[]'),
152 s.get('version'),
111 s.get('audio_md5'), 153 s.get('audio_md5'),
112 s.get('provider_name'), s.get('crawler_source_data'), 154 s.get('provider_name'), s.get('crawler_source_data'),
113 ) for s in songs] 155 ) for s in songs]
...@@ -189,8 +231,8 @@ def upsert_kugou_songs(cur, songs: list[dict]) -> None: ...@@ -189,8 +231,8 @@ def upsert_kugou_songs(cur, songs: list[dict]) -> None:
189 (id, platform_song_id, hash, album_audio_id, album_id, cover, title, name, duration, 231 (id, platform_song_id, hash, album_audio_id, album_id, cover, title, name, duration,
190 lyric, composer_name, lyricist_name, url, lyric_url, 232 lyric, composer_name, lyricist_name, url, lyric_url,
191 platform_index_url, published_at, singers, status, created_at, updated_at, 233 platform_index_url, published_at, singers, status, created_at, updated_at,
192 audio_md5, provider_name, crawler_source_data) 234 version, audio_md5, provider_name, crawler_source_data)
193 VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb, 0, NOW(), NOW(), %s, %s, %s::json) 235 VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb, 0, NOW(), NOW(), %s, %s, %s, %s::json)
194 ON CONFLICT (platform_song_id) DO NOTHING 236 ON CONFLICT (platform_song_id) DO NOTHING
195 """ 237 """
196 rows = [( 238 rows = [(
...@@ -200,6 +242,7 @@ def upsert_kugou_songs(cur, songs: list[dict]) -> None: ...@@ -200,6 +242,7 @@ def upsert_kugou_songs(cur, songs: list[dict]) -> None:
200 s.get('lyric'), s.get('composer_name'), s.get('lyricist_name'), 242 s.get('lyric'), s.get('composer_name'), s.get('lyricist_name'),
201 s.get('url', ''), s.get('lyric_url'), 243 s.get('url', ''), s.get('lyric_url'),
202 s.get('platform_index_url'), s.get('published_at'), s.get('singers_json', '[]'), 244 s.get('platform_index_url'), s.get('published_at'), s.get('singers_json', '[]'),
245 s.get('version'),
203 s.get('audio_md5'), 246 s.get('audio_md5'),
204 s.get('provider_name'), s.get('crawler_source_data'), 247 s.get('provider_name'), s.get('crawler_source_data'),
205 ) for s in songs] 248 ) for s in songs]
...@@ -277,8 +320,8 @@ def upsert_netease_songs(cur, songs: list[dict]) -> None: ...@@ -277,8 +320,8 @@ def upsert_netease_songs(cur, songs: list[dict]) -> None:
277 (id, platform_song_id, album_id, cover, title, name, duration, 320 (id, platform_song_id, album_id, cover, title, name, duration,
278 lyric, composer_name, lyricist_name, url, lyric_url, 321 lyric, composer_name, lyricist_name, url, lyric_url,
279 platform_index_url, published_at, album, singers, status, created_at, updated_at, 322 platform_index_url, published_at, album, singers, status, created_at, updated_at,
280 audio_md5, provider_name, crawler_source_data) 323 version, audio_md5, provider_name, crawler_source_data)
281 VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s::json, %s::jsonb, 0, NOW(), NOW(), %s, %s, %s::json) 324 VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s::json, %s::jsonb, 0, NOW(), NOW(), %s, %s, %s, %s::json)
282 ON CONFLICT (platform_song_id) DO NOTHING 325 ON CONFLICT (platform_song_id) DO NOTHING
283 """ 326 """
284 rows = [( 327 rows = [(
...@@ -287,6 +330,7 @@ def upsert_netease_songs(cur, songs: list[dict]) -> None: ...@@ -287,6 +330,7 @@ def upsert_netease_songs(cur, songs: list[dict]) -> None:
287 s.get('lyric'), s.get('composer_name'), s.get('lyricist_name'), 330 s.get('lyric'), s.get('composer_name'), s.get('lyricist_name'),
288 s.get('url', ''), s.get('lyric_url'), 331 s.get('url', ''), s.get('lyric_url'),
289 s.get('platform_index_url'), s.get('published_at'), s.get('album_json'), s.get('singers_json', '[]'), 332 s.get('platform_index_url'), s.get('published_at'), s.get('album_json'), s.get('singers_json', '[]'),
333 s.get('version'),
290 s.get('audio_md5'), 334 s.get('audio_md5'),
291 s.get('provider_name'), s.get('crawler_source_data'), 335 s.get('provider_name'), s.get('crawler_source_data'),
292 ) for s in songs] 336 ) for s in songs]
......
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 initialize_yinyan_song_records, run 4 from etl_to_crawler.runner import backfill_yinyan_record_platforms, initialize_yinyan_song_records, run
5 5
6 PLATFORM_MAP = { 6 PLATFORM_MAP = {
7 'qq': PLATFORM_QQ, 7 'qq': PLATFORM_QQ,
...@@ -18,6 +18,8 @@ if __name__ == '__main__': ...@@ -18,6 +18,8 @@ if __name__ == '__main__':
18 help='最多处理多少批次(冒烟测试用)') 18 help='最多处理多少批次(冒烟测试用)')
19 parser.add_argument('--init-yinyan-records', action='store_true', 19 parser.add_argument('--init-yinyan-records', action='store_true',
20 help='只初始化 yinyan_song_records 待导入状态表,不执行 crawler 导入') 20 help='只初始化 yinyan_song_records 待导入状态表,不执行 crawler 导入')
21 parser.add_argument('--backfill-yinyan-platforms', action='store_true',
22 help='只回填 yinyan_song_records 中为空的 platform 平台代码')
21 args = parser.parse_args() 23 args = parser.parse_args()
22 24
23 if args.platform == 'all': 25 if args.platform == 'all':
...@@ -26,7 +28,9 @@ if __name__ == '__main__': ...@@ -26,7 +28,9 @@ if __name__ == '__main__':
26 platforms = [PLATFORM_MAP[args.platform]] 28 platforms = [PLATFORM_MAP[args.platform]]
27 29
28 print(f"Starting ETL for platforms: {platforms}") 30 print(f"Starting ETL for platforms: {platforms}")
29 if args.init_yinyan_records: 31 if args.backfill_yinyan_platforms:
32 backfill_yinyan_record_platforms(max_batches=args.max_batches)
33 elif args.init_yinyan_records:
30 initialize_yinyan_song_records(platforms, max_batches=args.max_batches) 34 initialize_yinyan_song_records(platforms, max_batches=args.max_batches)
31 else: 35 else:
32 run(platforms, max_batches=args.max_batches) 36 run(platforms, max_batches=args.max_batches)
......
...@@ -57,7 +57,7 @@ def test_run_imports_all_platform_records_but_writes_one_primary_yinyan_relation ...@@ -57,7 +57,7 @@ def test_run_imports_all_platform_records_but_writes_one_primary_yinyan_relation
57 'audio_url': 'https://example.com/a.mp3', 57 'audio_url': 'https://example.com/a.mp3',
58 'singer': '歌手', 58 'singer': '歌手',
59 }}) 59 }})
60 monkeypatch.setattr(runner, 'fetch_platform_records', lambda conn, song_ids: [ 60 platform_records = [
61 { 61 {
62 'source_song_id': 10, 62 'source_song_id': 10,
63 'record_id': 100, 63 'record_id': 100,
...@@ -80,7 +80,10 @@ def test_run_imports_all_platform_records_but_writes_one_primary_yinyan_relation ...@@ -80,7 +80,10 @@ def test_run_imports_all_platform_records_but_writes_one_primary_yinyan_relation
80 'is_high': 0, 80 'is_high': 0,
81 'pub_time': '2021-01-01', 81 'pub_time': '2021-01-01',
82 }, 82 },
83 ]) 83 ]
84 monkeypatch.setattr(runner, 'fetch_platform_records', lambda conn, song_ids: platform_records)
85 monkeypatch.setattr(runner, 'fetch_all_platform_records', lambda conn, song_ids: platform_records)
86 monkeypatch.setattr(runner, '_pick_record_with_singer', lambda records, spider_conn: records[0])
84 monkeypatch.setattr(runner, '_PROCESSORS', processors) 87 monkeypatch.setattr(runner, '_PROCESSORS', processors)
85 monkeypatch.setattr(runner, 'upsert_yinyan_song_records', yinyan_writer) 88 monkeypatch.setattr(runner, 'upsert_yinyan_song_records', yinyan_writer)
86 89
...@@ -90,9 +93,9 @@ def test_run_imports_all_platform_records_but_writes_one_primary_yinyan_relation ...@@ -90,9 +93,9 @@ def test_run_imports_all_platform_records_but_writes_one_primary_yinyan_relation
90 processors['2'].assert_called_once() 93 processors['2'].assert_called_once()
91 yinyan_writer.assert_called_once_with(pg_conn.cur, [{ 94 yinyan_writer.assert_called_once_with(pg_conn.cur, [{
92 'song_id': 10, 95 'song_id': 10,
93 'record_id': 200, 96 'record_id': 100,
94 'platform': 'kugou', 97 'platform': '1',
95 'platform_song_id': 200, 98 'platform_song_id': 100,
96 }]) 99 }])
97 assert pg_conn.commits == 1 100 assert pg_conn.commits == 1
98 101
...@@ -104,6 +107,7 @@ def test_initialize_yinyan_song_records_inserts_primary_records(monkeypatch): ...@@ -104,6 +107,7 @@ def test_initialize_yinyan_song_records_inserts_primary_records(monkeypatch):
104 monkeypatch.setattr(runner, 'get_hk_songs_conn', lambda: _Connection()) 107 monkeypatch.setattr(runner, 'get_hk_songs_conn', lambda: _Connection())
105 monkeypatch.setattr(runner, 'get_source_conn', lambda: _Connection()) 108 monkeypatch.setattr(runner, 'get_source_conn', lambda: _Connection())
106 monkeypatch.setattr(runner, 'get_pg_conn', lambda: pg_conn) 109 monkeypatch.setattr(runner, 'get_pg_conn', lambda: pg_conn)
110 monkeypatch.setattr(runner, 'fetch_existing_yinyan_song_ids', lambda cur: set())
107 111
108 monkeypatch.setattr(runner, 'iter_hk_songs_batches', lambda conn, batch_size: [[ 112 monkeypatch.setattr(runner, 'iter_hk_songs_batches', lambda conn, batch_size: [[
109 {'id': 50, 'source_song_id': 10, 'name': '歌', 'audio_url': 'https://example.com/a.mp3', 'singer': '歌手'}, 113 {'id': 50, 'source_song_id': 10, 'name': '歌', 'audio_url': 'https://example.com/a.mp3', 'singer': '歌手'},
...@@ -136,7 +140,32 @@ def test_initialize_yinyan_song_records_inserts_primary_records(monkeypatch): ...@@ -136,7 +140,32 @@ def test_initialize_yinyan_song_records_inserts_primary_records(monkeypatch):
136 140
137 runner.initialize_yinyan_song_records(['1', '2']) 141 runner.initialize_yinyan_song_records(['1', '2'])
138 142
139 assert inserted == [{'song_id': 10, 'record_id': 200}] 143 assert inserted == [{'song_id': 10, 'record_id': 200, 'platform': '2'}]
144 assert pg_conn.commits == 1
145
146
147 def test_backfill_yinyan_record_platforms_updates_missing_platform_rows(monkeypatch):
148 pg_conn = _PgConnection()
149 updated = []
150
151 monkeypatch.setattr(runner, 'get_source_conn', lambda: _Connection())
152 monkeypatch.setattr(runner, 'get_pg_conn', lambda: pg_conn)
153 monkeypatch.setattr(runner, 'fetch_yinyan_records_missing_platform', lambda cur, batch_size: [
154 {'song_id': 10, 'record_id': 100},
155 {'song_id': 11, 'record_id': 101},
156 ] if pg_conn.commits == 0 else [])
157 monkeypatch.setattr(runner, 'fetch_record_platforms', lambda conn, record_ids: {
158 100: '1',
159 101: '2',
160 })
161 monkeypatch.setattr(runner, 'update_yinyan_record_platforms', lambda cur, rows: updated.extend(rows))
162
163 runner.backfill_yinyan_record_platforms()
164
165 assert updated == [
166 {'song_id': 10, 'record_id': 100, 'platform': '1'},
167 {'song_id': 11, 'record_id': 101, 'platform': '2'},
168 ]
140 assert pg_conn.commits == 1 169 assert pg_conn.commits == 1
141 170
142 171
...@@ -158,7 +187,7 @@ def test_process_netease_builds_album_json_for_song_insert(monkeypatch): ...@@ -158,7 +187,7 @@ def test_process_netease_builds_album_json_for_song_insert(monkeypatch):
158 'is_owner': 1, 187 'is_owner': 1,
159 'album_published_at': '2020-01-01', 188 'album_published_at': '2020-01-01',
160 'cover': 'https://example.com/cover.jpg', 189 'cover': 'https://example.com/cover.jpg',
161 'title': '录音标题', 190 'title': '化风行万里 (DJ默涵版)',
162 'duration': 180, 191 'duration': 180,
163 'lyric': '[00:01.00]歌词', 192 'lyric': '[00:01.00]歌词',
164 'composer_name': '曲作者', 193 'composer_name': '曲作者',
...@@ -197,6 +226,8 @@ def test_process_netease_builds_album_json_for_song_insert(monkeypatch): ...@@ -197,6 +226,8 @@ def test_process_netease_builds_album_json_for_song_insert(monkeypatch):
197 assert inserted_songs[0]['album_json'] 226 assert inserted_songs[0]['album_json']
198 assert '"id": 20' in inserted_songs[0]['album_json'] 227 assert '"id": 20' in inserted_songs[0]['album_json']
199 assert '"title": "专辑"' in inserted_songs[0]['album_json'] 228 assert '"title": "专辑"' in inserted_songs[0]['album_json']
229 assert inserted_songs[0]['title'] == '化风行万里'
230 assert inserted_songs[0]['version'] == 'DJ默涵版'
200 231
201 232
202 def test_process_netease_keeps_timestamped_lyric_and_uploads_plain_lyric(monkeypatch): 233 def test_process_netease_keeps_timestamped_lyric_and_uploads_plain_lyric(monkeypatch):
...@@ -206,7 +237,7 @@ def test_process_netease_keeps_timestamped_lyric_and_uploads_plain_lyric(monkeyp ...@@ -206,7 +237,7 @@ def test_process_netease_keeps_timestamped_lyric_and_uploads_plain_lyric(monkeyp
206 uploaded = {} 237 uploaded = {}
207 238
208 class Bucket: 239 class Bucket:
209 def put_object(self, key, body): 240 def put_object(self, key, body, headers=None):
210 uploaded['key'] = key 241 uploaded['key'] = key
211 uploaded['body'] = body 242 uploaded['body'] = body
212 243
......
1 from etl_to_crawler.utils import split_title_version
2
3
4 def test_split_title_version_extracts_parenthesized_suffix():
5 assert split_title_version("化风行万里 (DJ默涵版)") == ("化风行万里", "DJ默涵版")
6 assert split_title_version("化风行万里(DJ默涵版)") == ("化风行万里", "DJ默涵版")
7
8
9 def test_split_title_version_leaves_plain_title_unchanged():
10 assert split_title_version("化风行万里") == ("化风行万里", "")
11 assert split_title_version("") == ("", "")
1 from unittest.mock import MagicMock, call 1 from unittest.mock import MagicMock, call
2 from etl_to_crawler.writer import ( 2 from etl_to_crawler.writer import (
3 fetch_yinyan_records_missing_platform,
3 insert_yinyan_song_records, 4 insert_yinyan_song_records,
5 update_yinyan_record_platforms,
4 upsert_kugou_albums, 6 upsert_kugou_albums,
5 upsert_kugou_singers, 7 upsert_kugou_singers,
6 upsert_kugou_songs, 8 upsert_kugou_songs,
...@@ -45,32 +47,60 @@ def test_upsert_qq_singer_songs(): ...@@ -45,32 +47,60 @@ def test_upsert_qq_singer_songs():
45 def test_upsert_yinyan_song_records_marks_existing_relation_as_pushed(): 47 def test_upsert_yinyan_song_records_marks_existing_relation_as_pushed():
46 cur = MagicMock() 48 cur = MagicMock()
47 upsert_yinyan_song_records(cur, [ 49 upsert_yinyan_song_records(cur, [
48 {'song_id': 10, 'record_id': 100, 'platform': 'qq', 'platform_song_id': 1000}, 50 {'song_id': 10, 'record_id': 100, 'platform': '1', 'platform_song_id': 1000},
49 {'song_id': 11, 'record_id': 101, 'platform': 'kugou', 'platform_song_id': 1001}, 51 {'song_id': 11, 'record_id': 101, 'platform': '2', 'platform_song_id': 1001},
50 ]) 52 ])
51 53
52 sql, rows = cur.executemany.call_args[0] 54 sql, rows = cur.executemany.call_args[0]
53 assert 'UPDATE yinyan_song_records' in sql 55 assert 'UPDATE yinyan_song_records' in sql
54 assert 'platform = %s' in sql 56 assert 'platform = %s' in sql
55 assert 'platform_song_id = %s' in sql 57 assert 'platform_song_id = %s' in sql
58 assert 'record_id = %s' in sql
56 assert 'is_yinyan_push = TRUE' in sql 59 assert 'is_yinyan_push = TRUE' in sql
57 assert 'WHERE song_id = %s AND record_id = %s' in sql 60 assert 'WHERE song_id = %s AND is_yinyan_push = FALSE' in sql
58 assert rows == [('qq', 1000, 10, 100), ('kugou', 1001, 11, 101)] 61 assert rows == [('1', 1000, 100, 10), ('2', 1001, 101, 11)]
59 62
60 63
61 def test_insert_yinyan_song_records_initializes_unpushed_rows(): 64 def test_insert_yinyan_song_records_initializes_unpushed_rows():
62 cur = MagicMock() 65 cur = MagicMock()
63 insert_yinyan_song_records(cur, [ 66 insert_yinyan_song_records(cur, [
64 {'song_id': 10, 'record_id': 100}, 67 {'song_id': 10, 'record_id': 100, 'platform': '1'},
65 {'song_id': 11, 'record_id': 101}, 68 {'song_id': 11, 'record_id': 101, 'platform': '2'},
66 ]) 69 ])
67 70
68 sql, rows = cur.executemany.call_args[0] 71 sql, rows = cur.executemany.call_args[0]
69 assert 'INSERT INTO yinyan_song_records' in sql 72 assert 'INSERT INTO yinyan_song_records' in sql
73 assert 'platform' in sql
70 assert 'is_yinyan_push' in sql 74 assert 'is_yinyan_push' in sql
71 assert 'FALSE' in sql 75 assert 'FALSE' in sql
72 assert 'ON CONFLICT (song_id, record_id) DO NOTHING' in sql 76 assert 'ON CONFLICT (song_id, record_id) DO UPDATE' in sql
73 assert rows == [(10, 100), (11, 101)] 77 assert 'WHERE yinyan_song_records.platform IS NULL' in sql
78 assert rows == [(10, 100, '1'), (11, 101, '2')]
79
80
81 def test_fetch_yinyan_records_missing_platform_reads_null_platform_rows():
82 cur = MagicMock()
83 cur.fetchall.return_value = [(10, 100), (11, 101)]
84
85 rows = fetch_yinyan_records_missing_platform(cur, 500)
86
87 sql, params = cur.execute.call_args[0]
88 assert 'WHERE platform IS NULL' in sql
89 assert params == (500,)
90 assert rows == [{'song_id': 10, 'record_id': 100}, {'song_id': 11, 'record_id': 101}]
91
92
93 def test_update_yinyan_record_platforms_fills_only_null_platform_rows():
94 cur = MagicMock()
95 update_yinyan_record_platforms(cur, [
96 {'song_id': 10, 'record_id': 100, 'platform': '1'},
97 {'song_id': 11, 'record_id': 101, 'platform': '2'},
98 ])
99
100 sql, rows = cur.executemany.call_args[0]
101 assert 'SET platform = %s' in sql
102 assert 'AND platform IS NULL' in sql
103 assert rows == [('1', 10, 100), ('2', 11, 101)]
74 104
75 105
76 def test_upsert_netease_songs_writes_album_json_column(): 106 def test_upsert_netease_songs_writes_album_json_column():
...@@ -123,13 +153,15 @@ def test_upsert_kugou_songs_writes_provider_and_source_data(): ...@@ -123,13 +153,15 @@ def test_upsert_kugou_songs_writes_provider_and_source_data():
123 'provider_name': 'yinyan', 153 'provider_name': 'yinyan',
124 'crawler_source_data': '{"id": 200}', 154 'crawler_source_data': '{"id": 200}',
125 'audio_md5': 'md5-200', 155 'audio_md5': 'md5-200',
156 'version': 'DJ默涵版',
126 }]) 157 }])
127 158
128 sql, rows = cur.executemany.call_args[0] 159 sql, rows = cur.executemany.call_args[0]
129 assert 'audio_md5' in sql 160 assert 'audio_md5' in sql
161 assert 'version' in sql
130 assert 'provider_name, crawler_source_data' in sql 162 assert 'provider_name, crawler_source_data' in sql
131 assert '%s::json' in sql 163 assert '%s::json' in sql
132 assert rows[0][-3:] == ('md5-200', 'yinyan', '{"id": 200}') 164 assert rows[0][-4:] == ('DJ默涵版', 'md5-200', 'yinyan', '{"id": 200}')
133 165
134 166
135 def test_upsert_kugou_singers_writes_provider_and_source_data(): 167 def test_upsert_kugou_singers_writes_provider_and_source_data():
...@@ -232,11 +264,13 @@ def test_upsert_qq_entities_write_provider_and_source_data(): ...@@ -232,11 +264,13 @@ def test_upsert_qq_entities_write_provider_and_source_data():
232 'provider_name': 'yinyan', 264 'provider_name': 'yinyan',
233 'crawler_source_data': '{"id": 200}', 265 'crawler_source_data': '{"id": 200}',
234 'audio_md5': 'md5-200', 266 'audio_md5': 'md5-200',
267 'version': 'DJ默涵版',
235 }]) 268 }])
236 sql, rows = cur.executemany.call_args[0] 269 sql, rows = cur.executemany.call_args[0]
237 assert 'audio_md5' in sql 270 assert 'audio_md5' in sql
271 assert 'version' in sql
238 assert 'provider_name, crawler_source_data' in sql 272 assert 'provider_name, crawler_source_data' in sql
239 assert rows[0][-3:] == ('md5-200', 'yinyan', '{"id": 200}') 273 assert rows[0][-4:] == ('DJ默涵版', 'md5-200', 'yinyan', '{"id": 200}')
240 274
241 275
242 def test_upsert_netease_entities_write_provider_and_source_data(): 276 def test_upsert_netease_entities_write_provider_and_source_data():
...@@ -296,8 +330,10 @@ def test_upsert_netease_entities_write_provider_and_source_data(): ...@@ -296,8 +330,10 @@ def test_upsert_netease_entities_write_provider_and_source_data():
296 'provider_name': 'yinyan', 330 'provider_name': 'yinyan',
297 'crawler_source_data': '{"id": 300}', 331 'crawler_source_data': '{"id": 300}',
298 'audio_md5': 'md5-300', 332 'audio_md5': 'md5-300',
333 'version': 'DJ默涵版',
299 }]) 334 }])
300 sql, rows = cur.executemany.call_args[0] 335 sql, rows = cur.executemany.call_args[0]
301 assert 'audio_md5' in sql 336 assert 'audio_md5' in sql
337 assert 'version' in sql
302 assert 'provider_name, crawler_source_data' in sql 338 assert 'provider_name, crawler_source_data' in sql
303 assert rows[0][-3:] == ('md5-300', 'yinyan', '{"id": 300}') 339 assert rows[0][-4:] == ('DJ默涵版', 'md5-300', 'yinyan', '{"id": 300}')
......