Commit 0ab1bdc0 0ab1bdc08cd2a1dd33e5a4baa7855cb99be4a44d by 沈秋雨

feat(etl): 优化录音数据选择并新增引擎歌曲记录写入

- 调整批处理大小为3以适应新需求
- 扩展fetch_platform_records查询字段,增加录音优先级排序机制
- 新增select_primary_record方法以选出最优录音记录
- 修改runner确保每首歌只写入一个主要引擎录音关联
- 新增upsert_yinyan_song_records接口,实现引擎歌曲记录的批量写入和替换
- 添加对应单元测试覆盖录音选择逻辑和写入功能
1 parent 0e50b4c2
...@@ -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 = 500 46 BATCH_SIZE = 3
......
...@@ -18,11 +18,14 @@ LIMIT %s OFFSET %s ...@@ -18,11 +18,14 @@ LIMIT %s OFFSET %s
18 _PLATFORM_QUERY = """ 18 _PLATFORM_QUERY = """
19 SELECT 19 SELECT
20 sar.song_id AS source_song_id, 20 sar.song_id AS source_song_id,
21 mr.id AS record_id,
21 mr.platform, 22 mr.platform,
22 mr.platform_unique_key, 23 mr.platform_unique_key,
23 mr.platform_mid, 24 mr.platform_mid,
24 mr.album_audio_id, 25 mr.album_audio_id,
25 sar.is_main_version 26 sar.is_main_version,
27 mr.is_high,
28 mr.pub_time
26 FROM hk_song_and_record sar 29 FROM hk_song_and_record sar
27 JOIN hk_music_record mr ON mr.id = sar.record_id 30 JOIN hk_music_record mr ON mr.id = sar.record_id
28 WHERE sar.song_id IN ({placeholders}) 31 WHERE sar.song_id IN ({placeholders})
...@@ -30,7 +33,12 @@ WHERE sar.song_id IN ({placeholders}) ...@@ -30,7 +33,12 @@ WHERE sar.song_id IN ({placeholders})
30 AND mr.platform_unique_key IS NOT NULL 33 AND mr.platform_unique_key IS NOT NULL
31 AND mr.platform_unique_key != '' 34 AND mr.platform_unique_key != ''
32 AND mr.deleted = 0 35 AND mr.deleted = 0
33 ORDER BY sar.song_id, mr.platform, sar.is_main_version DESC 36 ORDER BY sar.song_id,
37 COALESCE(sar.is_main_version, 0) DESC,
38 COALESCE(mr.is_high, 0) DESC,
39 (mr.pub_time IS NULL) ASC,
40 mr.pub_time ASC,
41 mr.id ASC
34 """ 42 """
35 43
36 44
...@@ -57,10 +65,32 @@ def fetch_platform_records(source_conn: pymysql.Connection, song_ids: list[int]) ...@@ -57,10 +65,32 @@ def fetch_platform_records(source_conn: pymysql.Connection, song_ids: list[int])
57 cur.execute(query, song_ids) 65 cur.execute(query, song_ids)
58 rows = cur.fetchall() 66 rows = cur.fetchall()
59 67
60 # 每个 (source_song_id, platform) 只保留 is_main_version 最高的一条 68 return select_platform_records(rows)
69
70
71 def select_platform_records(rows: list[dict]) -> list[dict]:
72 # 每个 (source_song_id, platform) 保留一条,用于继续导入多个平台的录音数据。
61 seen = {} 73 seen = {}
62 for row in rows: 74 for row in sorted(rows, key=_record_priority):
63 key = (row['source_song_id'], row['platform']) 75 key = (row['source_song_id'], row['platform'])
64 if key not in seen: 76 if key not in seen:
65 seen[key] = row 77 seen[key] = row
66 return list(seen.values()) 78 return list(seen.values())
79
80
81 def select_primary_record(rows: list[dict]) -> dict | None:
82 if not rows:
83 return None
84 return sorted(rows, key=_record_priority)[0]
85
86
87 def _record_priority(row: dict) -> tuple:
88 pub_time = row.get('pub_time')
89 return (
90 row['source_song_id'],
91 -(int(row.get('is_main_version') or 0)),
92 -(int(row.get('is_high') or 0)),
93 pub_time is None,
94 pub_time or '',
95 int(row.get('record_id') or 0),
96 )
......
...@@ -5,13 +5,14 @@ from tqdm import tqdm ...@@ -5,13 +5,14 @@ from tqdm import tqdm
5 5
6 from .config import PLATFORM_QQ, PLATFORM_KUGOU, PLATFORM_NETEASE, BATCH_SIZE, OSS_CONFIG 6 from .config import PLATFORM_QQ, PLATFORM_KUGOU, PLATFORM_NETEASE, BATCH_SIZE, OSS_CONFIG
7 from .connections import get_hk_songs_conn, get_source_conn, get_spider_conn, get_pg_conn, get_oss_bucket 7 from .connections import get_hk_songs_conn, get_source_conn, get_spider_conn, get_pg_conn, get_oss_bucket
8 from .reader import iter_hk_songs_batches, fetch_platform_records 8 from .reader import iter_hk_songs_batches, fetch_platform_records, select_primary_record
9 from .spider import ( 9 from .spider import (
10 fetch_qq_songs, fetch_qq_singers, 10 fetch_qq_songs, fetch_qq_singers,
11 fetch_kugou_songs, fetch_kugou_singers, 11 fetch_kugou_songs, fetch_kugou_singers,
12 fetch_netease_songs, fetch_netease_singers, 12 fetch_netease_songs, fetch_netease_singers,
13 ) 13 )
14 from .writer import ( 14 from .writer import (
15 upsert_yinyan_song_records,
15 upsert_qq_singers, upsert_qq_albums, upsert_qq_songs, 16 upsert_qq_singers, upsert_qq_albums, upsert_qq_songs,
16 upsert_qq_singer_songs, upsert_qq_singer_albums, 17 upsert_qq_singer_songs, upsert_qq_singer_albums,
17 upsert_kugou_singers, upsert_kugou_albums, upsert_kugou_songs, 18 upsert_kugou_singers, upsert_kugou_albums, upsert_kugou_songs,
...@@ -336,6 +337,8 @@ def run(platforms: list[str], max_batches: int | None = None) -> None: ...@@ -336,6 +337,8 @@ def run(platforms: list[str], max_batches: int | None = None) -> None:
336 src_id = int(hk_row['source_song_id']) if hk_row.get('source_song_id') else None 337 src_id = int(hk_row['source_song_id']) if hk_row.get('source_song_id') else None
337 if not src_id or src_id not in pr_by_song: 338 if not src_id or src_id not in pr_by_song:
338 continue 339 continue
340 primary_record = select_primary_record(pr_by_song[src_id])
341 wrote_yinyan_record = False
339 for pr in pr_by_song[src_id]: 342 for pr in pr_by_song[src_id]:
340 processor = _PROCESSORS.get(pr['platform']) 343 processor = _PROCESSORS.get(pr['platform'])
341 if not processor: 344 if not processor:
...@@ -343,6 +346,9 @@ def run(platforms: list[str], max_batches: int | None = None) -> None: ...@@ -343,6 +346,9 @@ def run(platforms: list[str], max_batches: int | None = None) -> None:
343 pg_cur.execute('SAVEPOINT sp_song') 346 pg_cur.execute('SAVEPOINT sp_song')
344 try: 347 try:
345 result = processor(hk_row, pr, spider_conn, pg_cur, bucket, base_url) 348 result = processor(hk_row, pr, spider_conn, pg_cur, bucket, base_url)
349 if result and primary_record and not wrote_yinyan_record:
350 upsert_yinyan_song_records(pg_cur, [(src_id, int(primary_record['record_id']))])
351 wrote_yinyan_record = True
346 pg_cur.execute('RELEASE SAVEPOINT sp_song') 352 pg_cur.execute('RELEASE SAVEPOINT sp_song')
347 total_ok += 1 353 total_ok += 1
348 if result: 354 if result:
......
1 import uuid 1 import uuid
2 2
3 3
4 def upsert_yinyan_song_records(cur, pairs: list[tuple[int, int]]) -> None:
5 """pairs: [(source_song_id, hk_music_record_id), ...]"""
6 if not pairs:
7 return
8 cur.executemany(
9 "DELETE FROM yinyan_song_records WHERE song_id = %s",
10 [(song_id,) for song_id, _ in pairs],
11 )
12 cur.executemany(
13 """
14 INSERT INTO yinyan_song_records (song_id, record_id)
15 VALUES (%s, %s)
16 """,
17 pairs,
18 )
19
20
4 # ─── QQ Music ──────────────────────────────────────────────────────────────── 21 # ─── QQ Music ────────────────────────────────────────────────────────────────
5 22
6 def upsert_qq_singers(cur, singers: list[dict]) -> None: 23 def upsert_qq_singers(cur, singers: list[dict]) -> None:
......
1 from unittest.mock import MagicMock
2
3 from etl_to_crawler.reader import fetch_platform_records, select_primary_record
4
5
6 def _make_cursor(rows):
7 cur = MagicMock()
8 cur.__enter__ = MagicMock(return_value=cur)
9 cur.__exit__ = MagicMock(return_value=False)
10 cur.fetchall.return_value = rows
11 return cur
12
13
14 def test_fetch_platform_records_keeps_one_record_per_song_and_platform():
15 conn = MagicMock()
16 conn.cursor.return_value = _make_cursor([
17 {
18 'source_song_id': 10,
19 'record_id': 100,
20 'platform': '1',
21 'platform_unique_key': 'qq-mid',
22 'platform_mid': '100',
23 'album_audio_id': None,
24 'is_main_version': 0,
25 'is_high': 1,
26 'pub_time': '2020-01-01',
27 },
28 {
29 'source_song_id': 10,
30 'record_id': 101,
31 'platform': '2',
32 'platform_unique_key': '200',
33 'platform_mid': 'kg-hash',
34 'album_audio_id': None,
35 'is_main_version': 1,
36 'is_high': 0,
37 'pub_time': '2021-01-01',
38 },
39 {
40 'source_song_id': 10,
41 'record_id': 102,
42 'platform': '4',
43 'platform_unique_key': '300',
44 'platform_mid': None,
45 'album_audio_id': None,
46 'is_main_version': 1,
47 'is_high': 1,
48 'pub_time': '2022-01-01',
49 },
50 ])
51
52 result = fetch_platform_records(conn, [10])
53
54 assert len(result) == 3
55 assert {row['record_id'] for row in result} == {100, 101, 102}
56
57
58 def test_select_primary_record_prefers_main_version_then_is_high():
59 rows = [
60 {
61 'source_song_id': 10,
62 'record_id': 100,
63 'platform': '1',
64 'platform_unique_key': 'qq-mid',
65 'platform_mid': '100',
66 'album_audio_id': None,
67 'is_main_version': 0,
68 'is_high': 1,
69 'pub_time': '2020-01-01',
70 },
71 {
72 'source_song_id': 10,
73 'record_id': 101,
74 'platform': '2',
75 'platform_unique_key': '200',
76 'platform_mid': 'kg-hash',
77 'album_audio_id': None,
78 'is_main_version': 1,
79 'is_high': 0,
80 'pub_time': '2021-01-01',
81 },
82 {
83 'source_song_id': 10,
84 'record_id': 102,
85 'platform': '4',
86 'platform_unique_key': '300',
87 'platform_mid': None,
88 'album_audio_id': None,
89 'is_main_version': 1,
90 'is_high': 1,
91 'pub_time': '2022-01-01',
92 },
93 ]
94
95 assert select_primary_record(rows)['record_id'] == 102
96
97
98 def test_select_primary_record_uses_earliest_pub_time_when_priority_ties():
99 rows = [
100 {
101 'source_song_id': 10,
102 'record_id': 100,
103 'platform': '1',
104 'platform_unique_key': 'qq-mid',
105 'platform_mid': '100',
106 'album_audio_id': None,
107 'is_main_version': 0,
108 'is_high': 0,
109 'pub_time': '2020-01-01',
110 },
111 {
112 'source_song_id': 10,
113 'record_id': 101,
114 'platform': '2',
115 'platform_unique_key': '200',
116 'platform_mid': 'kg-hash',
117 'album_audio_id': None,
118 'is_main_version': 0,
119 'is_high': 0,
120 'pub_time': '2019-01-01',
121 },
122 ]
123
124 assert select_primary_record(rows)['record_id'] == 101
1 from unittest.mock import MagicMock
2
3 from etl_to_crawler import runner
4
5
6 class _Cursor:
7 def __enter__(self):
8 return self
9
10 def __exit__(self, exc_type, exc, tb):
11 return False
12
13 def execute(self, *args, **kwargs):
14 return None
15
16
17 class _PgConnection:
18 def __init__(self):
19 self.cur = _Cursor()
20 self.commits = 0
21 self.closed = False
22
23 def cursor(self):
24 return self.cur
25
26 def commit(self):
27 self.commits += 1
28
29 def close(self):
30 self.closed = True
31
32
33 class _Connection:
34 def close(self):
35 return None
36
37
38 def test_run_imports_all_platform_records_but_writes_one_primary_yinyan_relation(monkeypatch):
39 pg_conn = _PgConnection()
40 processors = {
41 '1': MagicMock(return_value={'platform': 'qq', 'platform_song_id': 100, 'mid': 'qq-mid', 'title': '歌'}),
42 '2': MagicMock(return_value={'platform': 'kugou', 'platform_song_id': 200, 'hash': 'kg-hash', 'title': '歌'}),
43 }
44 yinyan_writer = MagicMock()
45
46 monkeypatch.setattr(runner, 'get_hk_songs_conn', lambda: _Connection())
47 monkeypatch.setattr(runner, 'get_source_conn', lambda: _Connection())
48 monkeypatch.setattr(runner, 'get_spider_conn', lambda: _Connection())
49 monkeypatch.setattr(runner, 'get_pg_conn', lambda: pg_conn)
50 monkeypatch.setattr(runner, 'get_oss_bucket', lambda: object())
51 monkeypatch.setattr(runner, 'iter_hk_songs_batches', lambda conn, batch_size: [[{
52 'source_song_id': 10,
53 'name': '歌',
54 'audio_url': 'https://example.com/a.mp3',
55 'singer': '歌手',
56 }]])
57 monkeypatch.setattr(runner, 'fetch_platform_records', lambda conn, song_ids: [
58 {
59 'source_song_id': 10,
60 'record_id': 100,
61 'platform': '1',
62 'platform_unique_key': 'qq-mid',
63 'platform_mid': '100',
64 'album_audio_id': None,
65 'is_main_version': 0,
66 'is_high': 1,
67 'pub_time': '2020-01-01',
68 },
69 {
70 'source_song_id': 10,
71 'record_id': 200,
72 'platform': '2',
73 'platform_unique_key': '200',
74 'platform_mid': 'kg-hash',
75 'album_audio_id': None,
76 'is_main_version': 1,
77 'is_high': 0,
78 'pub_time': '2021-01-01',
79 },
80 ])
81 monkeypatch.setattr(runner, '_PROCESSORS', processors)
82 monkeypatch.setattr(runner, 'upsert_yinyan_song_records', yinyan_writer)
83
84 runner.run(['1', '2'])
85
86 processors['1'].assert_called_once()
87 processors['2'].assert_called_once()
88 yinyan_writer.assert_called_once_with(pg_conn.cur, [(10, 200)])
89 assert pg_conn.commits == 1
1 from unittest.mock import MagicMock, call 1 from unittest.mock import MagicMock, call
2 from etl_to_crawler.writer import upsert_qq_singers, upsert_qq_singer_songs 2 from etl_to_crawler.writer import (
3 upsert_qq_singers,
4 upsert_qq_singer_songs,
5 upsert_yinyan_song_records,
6 )
3 7
4 8
5 def test_upsert_qq_singers_executes_insert(): 9 def test_upsert_qq_singers_executes_insert():
...@@ -27,3 +31,17 @@ def test_upsert_qq_singer_songs(): ...@@ -27,3 +31,17 @@ def test_upsert_qq_singer_songs():
27 assert cur.executemany.called 31 assert cur.executemany.called
28 sql = cur.executemany.call_args[0][0] 32 sql = cur.executemany.call_args[0][0]
29 assert 'crawler_qqmusic_singer_songs' in sql 33 assert 'crawler_qqmusic_singer_songs' in sql
34
35
36 def test_upsert_yinyan_song_records_replaces_existing_song_relation():
37 cur = MagicMock()
38 upsert_yinyan_song_records(cur, [(10, 100), (11, 101)])
39
40 assert cur.executemany.call_count == 2
41 delete_sql, delete_rows = cur.executemany.call_args_list[0][0]
42 insert_sql, insert_rows = cur.executemany.call_args_list[1][0]
43 assert 'DELETE FROM yinyan_song_records' in delete_sql
44 assert 'WHERE song_id = %s' in delete_sql
45 assert delete_rows == [(10,), (11,)]
46 assert 'INSERT INTO yinyan_song_records' in insert_sql
47 assert insert_rows == [(10, 100), (11, 101)]
......