feat(etl): 优化录音数据选择并新增引擎歌曲记录写入
- 调整批处理大小为3以适应新需求 - 扩展fetch_platform_records查询字段,增加录音优先级排序机制 - 新增select_primary_record方法以选出最优录音记录 - 修改runner确保每首歌只写入一个主要引擎录音关联 - 新增upsert_yinyan_song_records接口,实现引擎歌曲记录的批量写入和替换 - 添加对应单元测试覆盖录音选择逻辑和写入功能
Showing
7 changed files
with
291 additions
and
7 deletions
| ... | @@ -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: | ... | ... |
tests/test_reader.py
0 → 100644
| 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 |
tests/test_runner.py
0 → 100644
| 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)] | ... | ... |
-
Please register or sign in to post a comment