Commit 6d8ccd04 6d8ccd044b9b30fc5cc811233db4dd41ac4e6d5a by 沈秋雨

refactor(etl_to_crawler): 重构音频和歌手数据转存及导入流程

- 新增HTTP连接池配置,提升请求性能和资源复用
- 使用requests Session统一管理HTTP连接,减少请求开销
- 实现_url清洗函数,过滤无效或非法URL
- 并行执行音频、封面、歌词及歌手头像的OSS转存任务,提升效率
- 按平台统一构建导入数据payload,简化处理流程
- 批量写入歌曲、歌手、专辑及关联关系,提升数据库操作性能
- 替换原平台单独处理函数为统一预处理及写入方法
- 取消原单线程顺序处理,改为线程池并发执行数据准备和导入
- 获取平台歌曲及歌手信息支持传入缓存参数,减少重复查询
- 优化异常处理及日志记录,提高稳定性和可维护性
- 新增歌手索引和性别等字段辅助函数,完善歌手信息处理
1 parent d3694c28
...@@ -50,3 +50,5 @@ PLATFORMS = [PLATFORM_QQ, PLATFORM_KUGOU, PLATFORM_NETEASE] ...@@ -50,3 +50,5 @@ PLATFORMS = [PLATFORM_QQ, PLATFORM_KUGOU, PLATFORM_NETEASE]
50 50
51 BATCH_SIZE = 100 51 BATCH_SIZE = 100
52 BACKFILL_BATCH_SIZE = int(os.environ.get('BACKFILL_BATCH_SIZE', '5000')) 52 BACKFILL_BATCH_SIZE = int(os.environ.get('BACKFILL_BATCH_SIZE', '5000'))
53 HTTP_POOL_MAXSIZE = int(os.environ.get('HTTP_POOL_MAXSIZE', '128'))
54 OSS_CONNECTION_POOL_SIZE = int(os.environ.get('OSS_CONNECTION_POOL_SIZE', str(HTTP_POOL_MAXSIZE)))
......
...@@ -2,7 +2,7 @@ import pymysql ...@@ -2,7 +2,7 @@ import pymysql
2 import pymysql.cursors 2 import pymysql.cursors
3 import pg8000 3 import pg8000
4 import oss2 4 import oss2
5 from .config import SOURCE_DB, HK_SONGS_DB, CRAWLER_DB, OSS_CONFIG 5 from .config import SOURCE_DB, HK_SONGS_DB, CRAWLER_DB, OSS_CONFIG, OSS_CONNECTION_POOL_SIZE
6 6
7 7
8 def get_source_conn() -> pymysql.Connection: 8 def get_source_conn() -> pymysql.Connection:
...@@ -26,5 +26,6 @@ def get_pg_conn() -> pg8000.Connection: ...@@ -26,5 +26,6 @@ def get_pg_conn() -> pg8000.Connection:
26 26
27 27
28 def get_oss_bucket() -> oss2.Bucket: 28 def get_oss_bucket() -> oss2.Bucket:
29 oss2.defaults.connection_pool_size = OSS_CONNECTION_POOL_SIZE
29 auth = oss2.Auth(OSS_CONFIG['access_key_id'], OSS_CONFIG['access_key_secret']) 30 auth = oss2.Auth(OSS_CONFIG['access_key_id'], OSS_CONFIG['access_key_secret'])
30 return oss2.Bucket(auth, OSS_CONFIG['endpoint'], OSS_CONFIG['bucket_name']) 31 return oss2.Bucket(auth, OSS_CONFIG['endpoint'], OSS_CONFIG['bucket_name'])
......
1 import requests 1 import requests
2 import oss2 2 import oss2
3 import math
3 from urllib.parse import urlparse 4 from urllib.parse import urlparse
4 from .config import OSS_CONFIG 5 from requests.adapters import HTTPAdapter
6 from .config import HTTP_POOL_MAXSIZE, OSS_CONFIG
5 from .utils import compute_audio_md5 7 from .utils import compute_audio_md5
6 8
9 _HTTP_SESSION = requests.Session()
10 _HTTP_ADAPTER = HTTPAdapter(pool_connections=HTTP_POOL_MAXSIZE, pool_maxsize=HTTP_POOL_MAXSIZE)
11 _HTTP_SESSION.mount('http://', _HTTP_ADAPTER)
12 _HTTP_SESSION.mount('https://', _HTTP_ADAPTER)
13
14
15 def _http_get(url: str, timeout: int = 30):
16 return _HTTP_SESSION.get(url, timeout=timeout)
17
18
19 def _clean_url(url) -> str:
20 if url is None:
21 return ''
22 if isinstance(url, float) and math.isnan(url):
23 return ''
24 text = str(url).strip()
25 if not text or text.lower() in {'nan', 'none', 'null'}:
26 return ''
27 parsed = urlparse(text)
28 if parsed.scheme not in {'http', 'https'} or not parsed.netloc:
29 return ''
30 return text
31
7 32
8 def _host(url: str | None) -> str: 33 def _host(url: str | None) -> str:
9 return urlparse(url or '').netloc.lower() 34 return urlparse(url or '').netloc.lower()
...@@ -28,11 +53,12 @@ def transfer_url(url: str | None, oss_key: str, bucket: oss2.Bucket, base_url: s ...@@ -28,11 +53,12 @@ def transfer_url(url: str | None, oss_key: str, bucket: oss2.Bucket, base_url: s
28 若 url 为空或已在目标 bucket,直接返回原 url(不上传)。 53 若 url 为空或已在目标 bucket,直接返回原 url(不上传)。
29 返回新的公开访问 URL。 54 返回新的公开访问 URL。
30 """ 55 """
56 url = _clean_url(url)
31 if not url: 57 if not url:
32 return '' 58 return ''
33 if _is_target_oss_url(url, base_url): 59 if _is_target_oss_url(url, base_url):
34 return url 60 return url
35 resp = requests.get(_download_url(url, base_url), timeout=30) 61 resp = _http_get(_download_url(url, base_url), timeout=30)
36 resp.raise_for_status() 62 resp.raise_for_status()
37 bucket.put_object(oss_key, resp.content) 63 bucket.put_object(oss_key, resp.content)
38 return f"{base_url.rstrip('/')}/{oss_key}" 64 return f"{base_url.rstrip('/')}/{oss_key}"
...@@ -43,11 +69,12 @@ def transfer_url_with_md5(url: str | None, oss_key: str, bucket: oss2.Bucket, ba ...@@ -43,11 +69,12 @@ def transfer_url_with_md5(url: str | None, oss_key: str, bucket: oss2.Bucket, ba
43 将音频 URL 转存到 OSS,并基于下载到的音频字节计算 MD5。 69 将音频 URL 转存到 OSS,并基于下载到的音频字节计算 MD5。
44 已在目标 OSS 的 URL 无需重新下载,无法可靠计算 MD5,返回空 MD5。 70 已在目标 OSS 的 URL 无需重新下载,无法可靠计算 MD5,返回空 MD5。
45 """ 71 """
72 url = _clean_url(url)
46 if not url: 73 if not url:
47 return '', '' 74 return '', ''
48 if _is_target_oss_url(url, base_url): 75 if _is_target_oss_url(url, base_url):
49 return url, '' 76 return url, ''
50 resp = requests.get(_download_url(url, base_url), timeout=30) 77 resp = _http_get(_download_url(url, base_url), timeout=30)
51 resp.raise_for_status() 78 resp.raise_for_status()
52 audio_md5 = compute_audio_md5(resp.content) 79 audio_md5 = compute_audio_md5(resp.content)
53 bucket.put_object(oss_key, resp.content) 80 bucket.put_object(oss_key, resp.content)
......
1 import uuid 1 import uuid
2 2
3 _SINGER_INDEX_VALUES = set('ABCDEFGHIJKLMNOPQRSTUVWXYZ#')
4 _SINGER_SEX_VALUES = {'M', 'F', 'C', 'U'}
5 _SINGER_AREA_VALUES = {'华语', '欧美', '韩国', '日本', '其他'}
6
7
8 def _singer_index(value) -> str:
9 text = str(value or '').strip().upper()
10 return text if text in _SINGER_INDEX_VALUES else '#'
11
12
13 def _singer_sex(value) -> str:
14 text = str(value or '').strip().upper()
15 return text if text in _SINGER_SEX_VALUES else 'U'
16
17
18 def _singer_area(value) -> str:
19 text = str(value or '').strip()
20 return text if text in _SINGER_AREA_VALUES else '其他'
21
3 22
4 def insert_yinyan_song_records(cur, records: list[dict]) -> None: 23 def insert_yinyan_song_records(cur, records: list[dict]) -> None:
5 """Initialize yinyan_song_records rows before crawler import.""" 24 """Initialize yinyan_song_records rows before crawler import."""
...@@ -114,7 +133,7 @@ def upsert_qq_singers(cur, singers: list[dict]) -> None: ...@@ -114,7 +133,7 @@ def upsert_qq_singers(cur, singers: list[dict]) -> None:
114 """ 133 """
115 rows = [( 134 rows = [(
116 s['id'], s['mid'], s['name'], s.get('avatar', ''), 135 s['id'], s['mid'], s['name'], s.get('avatar', ''),
117 s.get('sex') or 'U', s.get('area') or '其他', s.get('index') or '#', 136 _singer_sex(s.get('sex')), _singer_area(s.get('area')), _singer_index(s.get('index')),
118 s.get('intro'), s.get('home_url'), 137 s.get('intro'), s.get('home_url'),
119 s.get('provider_name'), s.get('crawler_source_data'), 138 s.get('provider_name'), s.get('crawler_source_data'),
120 ) for s in singers] 139 ) for s in singers]
...@@ -206,7 +225,7 @@ def upsert_kugou_singers(cur, singers: list[dict]) -> None: ...@@ -206,7 +225,7 @@ def upsert_kugou_singers(cur, singers: list[dict]) -> None:
206 """ 225 """
207 rows = [( 226 rows = [(
208 s['id'], s['name'], s.get('avatar', ''), 227 s['id'], s['name'], s.get('avatar', ''),
209 s.get('sex') or 'U', s.get('area') or '其他', s.get('index') or '#', 228 _singer_sex(s.get('sex')), _singer_area(s.get('area')), _singer_index(s.get('index')),
210 s.get('intro'), s.get('home_url', ''), 229 s.get('intro'), s.get('home_url', ''),
211 s.get('provider_name'), s.get('crawler_source_data'), 230 s.get('provider_name'), s.get('crawler_source_data'),
212 ) for s in singers] 231 ) for s in singers]
...@@ -295,7 +314,7 @@ def upsert_netease_singers(cur, singers: list[dict]) -> None: ...@@ -295,7 +314,7 @@ def upsert_netease_singers(cur, singers: list[dict]) -> None:
295 """ 314 """
296 rows = [( 315 rows = [(
297 s['id'], s['name'], s.get('avatar', ''), 316 s['id'], s['name'], s.get('avatar', ''),
298 s.get('sex') or 'U', s.get('area') or '其他', s.get('index') or '#', 317 _singer_sex(s.get('sex')), _singer_area(s.get('area')), _singer_index(s.get('index')),
299 s.get('intro'), s.get('home_url'), 318 s.get('intro'), s.get('home_url'),
300 s.get('provider_name'), s.get('crawler_source_data'), 319 s.get('provider_name'), s.get('crawler_source_data'),
301 ) for s in singers] 320 ) for s in singers]
......
1 from unittest.mock import MagicMock, patch 1 from unittest.mock import MagicMock, patch
2 import math
2 from etl_to_crawler.oss import transfer_url, transfer_url_with_md5 3 from etl_to_crawler.oss import transfer_url, transfer_url_with_md5
3 4
4 ARCHIVE_URL = "https://archive-dev.oss-cn-beijing.aliyuncs.com/some/path.mp3" 5 ARCHIVE_URL = "https://archive-dev.oss-cn-beijing.aliyuncs.com/some/path.mp3"
...@@ -22,10 +23,22 @@ def test_none_url_returns_empty(): ...@@ -22,10 +23,22 @@ def test_none_url_returns_empty():
22 result = transfer_url(None, "any/key.mp3", bucket, BASE_URL) 23 result = transfer_url(None, "any/key.mp3", bucket, BASE_URL)
23 assert result == '' 24 assert result == ''
24 25
26
27 def test_invalid_nan_url_returns_empty_without_download():
28 bucket = MagicMock()
29 with patch('etl_to_crawler.oss._http_get') as mock_get:
30 assert transfer_url('nan', "any/key.mp3", bucket, BASE_URL) == ''
31 assert transfer_url(math.nan, "any/key.mp3", bucket, BASE_URL) == ''
32 assert transfer_url('not-a-url', "any/key.mp3", bucket, BASE_URL) == ''
33 assert transfer_url_with_md5('nan', "any/key.mp3", bucket, BASE_URL) == ('', '')
34 mock_get.assert_not_called()
35 bucket.put_object.assert_not_called()
36
37
25 def test_external_url_downloads_and_uploads(): 38 def test_external_url_downloads_and_uploads():
26 bucket = MagicMock() 39 bucket = MagicMock()
27 fake_content = b"audio_bytes" 40 fake_content = b"audio_bytes"
28 with patch('etl_to_crawler.oss.requests.get') as mock_get: 41 with patch('etl_to_crawler.oss._http_get') as mock_get:
29 mock_get.return_value.content = fake_content 42 mock_get.return_value.content = fake_content
30 mock_get.return_value.raise_for_status = MagicMock() 43 mock_get.return_value.raise_for_status = MagicMock()
31 result = transfer_url(OTHER_URL, "crawler/qq/audio/abc.mp3", bucket, BASE_URL) 44 result = transfer_url(OTHER_URL, "crawler/qq/audio/abc.mp3", bucket, BASE_URL)
...@@ -42,7 +55,7 @@ def test_external_url_can_rewrite_download_base_to_internal_endpoint(): ...@@ -42,7 +55,7 @@ def test_external_url_can_rewrite_download_base_to_internal_endpoint():
42 'download_rewrite_from_base_url': 'https://source-bucket.oss-cn-hangzhou.aliyuncs.com', 55 'download_rewrite_from_base_url': 'https://source-bucket.oss-cn-hangzhou.aliyuncs.com',
43 'download_base_url': internal_source_base, 56 'download_base_url': internal_source_base,
44 }): 57 }):
45 with patch('etl_to_crawler.oss.requests.get') as mock_get: 58 with patch('etl_to_crawler.oss._http_get') as mock_get:
46 mock_get.return_value.content = fake_content 59 mock_get.return_value.content = fake_content
47 mock_get.return_value.raise_for_status = MagicMock() 60 mock_get.return_value.raise_for_status = MagicMock()
48 result = transfer_url(public_source, "crawler/qq/audio/abc.mp3", bucket, BASE_URL) 61 result = transfer_url(public_source, "crawler/qq/audio/abc.mp3", bucket, BASE_URL)
...@@ -55,7 +68,7 @@ def test_external_url_can_rewrite_download_base_to_internal_endpoint(): ...@@ -55,7 +68,7 @@ def test_external_url_can_rewrite_download_base_to_internal_endpoint():
55 def test_transfer_url_with_md5_hashes_downloaded_content_before_upload(): 68 def test_transfer_url_with_md5_hashes_downloaded_content_before_upload():
56 bucket = MagicMock() 69 bucket = MagicMock()
57 fake_content = b"audio_bytes" 70 fake_content = b"audio_bytes"
58 with patch('etl_to_crawler.oss.requests.get') as mock_get: 71 with patch('etl_to_crawler.oss._http_get') as mock_get:
59 mock_get.return_value.content = fake_content 72 mock_get.return_value.content = fake_content
60 mock_get.return_value.raise_for_status = MagicMock() 73 mock_get.return_value.raise_for_status = MagicMock()
61 result, audio_md5 = transfer_url_with_md5(OTHER_URL, "crawler/qq/audio/abc.mp3", bucket, BASE_URL) 74 result, audio_md5 = transfer_url_with_md5(OTHER_URL, "crawler/qq/audio/abc.mp3", bucket, BASE_URL)
......
1 from unittest.mock import MagicMock 1 from unittest.mock import MagicMock
2 import time
2 3
3 from etl_to_crawler import runner 4 from etl_to_crawler import runner
4 5
...@@ -35,13 +36,38 @@ class _Connection: ...@@ -35,13 +36,38 @@ class _Connection:
35 return None 36 return None
36 37
37 38
39 def test_run_io_tasks_returns_named_results():
40 def slow(value):
41 time.sleep(0.01)
42 return value
43
44 result = runner._run_io_tasks({
45 'audio': lambda: slow(('audio-url', 'md5')),
46 'cover': lambda: slow('cover-url'),
47 })
48
49 assert result == {
50 'audio': ('audio-url', 'md5'),
51 'cover': 'cover-url',
52 }
53
54
38 def test_run_imports_only_pending_yinyan_platform_record(monkeypatch): 55 def test_run_imports_only_pending_yinyan_platform_record(monkeypatch):
39 pg_conn = _PgConnection() 56 pg_conn = _PgConnection()
40 processors = { 57 prepare = MagicMock(return_value={
41 '1': MagicMock(return_value={'platform': 'qq', 'platform_song_id': 100, 'mid': 'qq-mid', 'title': '歌'}), 58 'platform': '2',
42 '2': MagicMock(return_value={'platform': 'kugou', 'platform_song_id': 200, 'hash': 'kg-hash', 'title': '歌'}), 59 'platform_song_id': 200,
43 } 60 'result': {'platform': 'kugou', 'platform_song_id': 200, 'hash': 'kg-hash', 'title': '歌'},
44 yinyan_writer = MagicMock() 61 'yinyan_record': {'song_id': 10, 'record_id': 200, 'platform': '2', 'platform_song_id': 200},
62 'singers': [],
63 'albums': [],
64 'songs': [],
65 'singer_songs': [],
66 'singer_albums': [],
67 })
68 write_payloads = MagicMock(return_value=[
69 {'platform': 'kugou', 'platform_song_id': 200, 'hash': 'kg-hash', 'title': '歌'},
70 ])
45 71
46 monkeypatch.setattr(runner, 'get_hk_songs_conn', lambda: _Connection()) 72 monkeypatch.setattr(runner, 'get_hk_songs_conn', lambda: _Connection())
47 monkeypatch.setattr(runner, 'get_source_conn', lambda: _Connection()) 73 monkeypatch.setattr(runner, 'get_source_conn', lambda: _Connection())
...@@ -57,47 +83,32 @@ def test_run_imports_only_pending_yinyan_platform_record(monkeypatch): ...@@ -57,47 +83,32 @@ def test_run_imports_only_pending_yinyan_platform_record(monkeypatch):
57 'audio_url': 'https://example.com/a.mp3', 83 'audio_url': 'https://example.com/a.mp3',
58 'singer': '歌手', 84 'singer': '歌手',
59 }}) 85 }})
60 platform_records = [ 86 platform_records = [{
61 { 87 'source_song_id': 10,
62 'source_song_id': 10, 88 'record_id': 200,
63 'record_id': 100, 89 'platform': '2',
64 'platform': '1', 90 'platform_unique_key': '200',
65 'platform_unique_key': 'qq-mid', 91 'platform_mid': 'kg-hash',
66 'platform_mid': '100', 92 'album_audio_id': None,
67 'album_audio_id': None, 93 'is_main_version': 1,
68 'is_main_version': 0, 94 'is_high': 0,
69 'is_high': 1, 95 'pub_time': '2021-01-01',
70 'pub_time': '2020-01-01', 96 }]
71 },
72 {
73 'source_song_id': 10,
74 'record_id': 200,
75 'platform': '2',
76 'platform_unique_key': '200',
77 'platform_mid': 'kg-hash',
78 'album_audio_id': None,
79 'is_main_version': 1,
80 'is_high': 0,
81 'pub_time': '2021-01-01',
82 },
83 ]
84 monkeypatch.setattr(runner, 'fetch_platform_records', lambda conn, song_ids: platform_records) 97 monkeypatch.setattr(runner, 'fetch_platform_records', lambda conn, song_ids: platform_records)
85 monkeypatch.setattr(runner, 'fetch_all_platform_records', MagicMock()) 98 monkeypatch.setattr(runner, 'fetch_all_platform_records', MagicMock())
86 monkeypatch.setattr(runner, 'fetch_platform_records_by_record_ids', lambda conn, record_ids: platform_records) 99 monkeypatch.setattr(runner, 'fetch_platform_records_by_record_ids', lambda conn, record_ids: platform_records)
87 monkeypatch.setattr(runner, '_PROCESSORS', processors) 100 monkeypatch.setattr(runner, 'fetch_kugou_songs', lambda conn, song_ids: {
88 monkeypatch.setattr(runner, 'upsert_yinyan_song_records', yinyan_writer) 101 200: {'id': 200},
102 })
103 monkeypatch.setattr(runner, 'fetch_kugou_singers', lambda conn, song_ids: {})
104 monkeypatch.setattr(runner, '_prepare_import_payload', prepare)
105 monkeypatch.setattr(runner, '_write_import_payloads', write_payloads)
89 106
90 runner.run(['1', '2']) 107 runner.run(['1', '2'])
91 108
92 processors['1'].assert_not_called() 109 prepare.assert_called_once()
93 processors['2'].assert_called_once() 110 write_payloads.assert_called_once()
94 runner.fetch_all_platform_records.assert_not_called() 111 runner.fetch_all_platform_records.assert_not_called()
95 yinyan_writer.assert_called_once_with(pg_conn.cur, [{
96 'song_id': 10,
97 'record_id': 200,
98 'platform': '2',
99 'platform_song_id': 200,
100 }])
101 assert pg_conn.commits == 1 112 assert pg_conn.commits == 1
102 113
103 114
...@@ -295,6 +306,68 @@ def test_process_netease_builds_album_json_for_song_insert(monkeypatch): ...@@ -295,6 +306,68 @@ def test_process_netease_builds_album_json_for_song_insert(monkeypatch):
295 assert inserted_songs[0]['version'] == 'DJ默涵版' 306 assert inserted_songs[0]['version'] == 'DJ默涵版'
296 307
297 308
309 def test_process_netease_uses_prefetched_song_and_singers(monkeypatch):
310 pg_cur = MagicMock()
311 pg_cur.fetchone.return_value = ('song-uuid',)
312 inserted_songs = []
313 fetch_songs = MagicMock()
314 fetch_singers = MagicMock()
315
316 monkeypatch.setattr(runner, 'fetch_netease_songs', fetch_songs)
317 monkeypatch.setattr(runner, 'fetch_netease_singers', fetch_singers)
318 monkeypatch.setattr(runner, '_safe_transfer', lambda url, oss_key, bucket, base_url: url)
319 monkeypatch.setattr(runner, '_safe_transfer_audio', lambda url, oss_key, bucket, base_url: (url, 'audio-md5'))
320 monkeypatch.setattr(runner, 'upsert_netease_singers', lambda cur, singers: None)
321 monkeypatch.setattr(runner, 'upsert_netease_albums', lambda cur, albums: None)
322 monkeypatch.setattr(runner, 'upsert_netease_songs', lambda cur, songs: inserted_songs.extend(songs))
323 monkeypatch.setattr(runner, 'upsert_netease_singer_songs', lambda cur, pairs: None)
324
325 runner._process_netease(
326 {
327 'name': '词曲名',
328 'audio_url': 'https://example.com/audio.mp3',
329 'lyrics_url': 'https://example.com/lyric.lrc',
330 'cover_url': '',
331 'composer': '词曲曲作者',
332 'lyricist': '词曲词作者',
333 'issue_time': '2019-01-01',
334 'song_time': 120,
335 },
336 {'platform_unique_key': '300'},
337 spider_conn=object(),
338 pg_cur=pg_cur,
339 bucket=object(),
340 base_url='https://bucket.example.com',
341 song_data={
342 'id': 300,
343 'album_id': None,
344 'cover': 'https://example.com/cover.jpg',
345 'title': '录音标题',
346 'duration': 180,
347 'lyric': '[00:01.00]歌词',
348 'composer_name': '曲作者',
349 'lyricist_name': '词作者',
350 'platform_index_url': None,
351 'published_at': '2020-01-02',
352 },
353 singer_list=[{
354 'singer_id': 1,
355 'name': '歌手',
356 'avatar': '',
357 'sex': 'U',
358 'area': '其他',
359 'index': '#',
360 'intro': None,
361 'home_url': None,
362 }],
363 )
364
365 fetch_songs.assert_not_called()
366 fetch_singers.assert_not_called()
367 assert inserted_songs[0]['platform_song_id'] == 300
368 assert '"name": "歌手"' in inserted_songs[0]['singers_json']
369
370
298 def test_process_netease_keeps_timestamped_lyric_and_uploads_plain_lyric(monkeypatch): 371 def test_process_netease_keeps_timestamped_lyric_and_uploads_plain_lyric(monkeypatch):
299 pg_cur = MagicMock() 372 pg_cur = MagicMock()
300 pg_cur.fetchone.return_value = ('song-uuid',) 373 pg_cur.fetchone.return_value = ('song-uuid',)
......
...@@ -387,3 +387,42 @@ def test_upsert_netease_entities_write_provider_and_source_data(): ...@@ -387,3 +387,42 @@ def test_upsert_netease_entities_write_provider_and_source_data():
387 assert 'version' in sql 387 assert 'version' in sql
388 assert 'provider_name, crawler_source_data' in sql 388 assert 'provider_name, crawler_source_data' in sql
389 assert rows[0][-4:] == ('DJ默涵版', 'md5-300', 'yinyan', '{"id": 300}') 389 assert rows[0][-4:] == ('DJ默涵版', 'md5-300', 'yinyan', '{"id": 300}')
390
391
392 def test_upsert_singers_normalizes_invalid_enum_values():
393 cur = MagicMock()
394 upsert_netease_singers(cur, [{
395 'id': 1,
396 'name': '歌手',
397 'avatar': 'https://example.com/avatar.jpg',
398 'sex': '0',
399 'area': '未知地区',
400 'index': '0',
401 'intro': '简介',
402 'home_url': 'https://example.com/singer',
403 'provider_name': 'yinyan',
404 'crawler_source_data': '{"id": 1}',
405 }])
406
407 _, rows = cur.executemany.call_args[0]
408 assert rows[0][3:6] == ('U', '其他', '#')
409
410
411 def test_upsert_singers_preserves_valid_group_sex_enum_value():
412 cur = MagicMock()
413 upsert_qq_singers(cur, [{
414 'id': 1,
415 'mid': 'singer-mid',
416 'name': '组合',
417 'avatar': 'https://example.com/avatar.jpg',
418 'sex': 'C',
419 'area': '华语',
420 'index': 'z',
421 'intro': '简介',
422 'home_url': 'https://example.com/singer',
423 'provider_name': 'yinyan',
424 'crawler_source_data': '{"id": 1}',
425 }])
426
427 _, rows = cur.executemany.call_args[0]
428 assert rows[0][4:7] == ('C', '华语', 'Z')
......