refactor(etl_to_crawler): 重构音频和歌手数据转存及导入流程
- 新增HTTP连接池配置,提升请求性能和资源复用 - 使用requests Session统一管理HTTP连接,减少请求开销 - 实现_url清洗函数,过滤无效或非法URL - 并行执行音频、封面、歌词及歌手头像的OSS转存任务,提升效率 - 按平台统一构建导入数据payload,简化处理流程 - 批量写入歌曲、歌手、专辑及关联关系,提升数据库操作性能 - 替换原平台单独处理函数为统一预处理及写入方法 - 取消原单线程顺序处理,改为线程池并发执行数据准备和导入 - 获取平台歌曲及歌手信息支持传入缓存参数,减少重复查询 - 优化异常处理及日志记录,提高稳定性和可维护性 - 新增歌手索引和性别等字段辅助函数,完善歌手信息处理
Showing
8 changed files
with
214 additions
and
40 deletions
| ... | @@ -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) | ... | ... |
This diff is collapsed.
Click to expand it.
| 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,19 +83,7 @@ def test_run_imports_only_pending_yinyan_platform_record(monkeypatch): | ... | @@ -57,19 +83,7 @@ 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 | { | ||
| 62 | 'source_song_id': 10, | ||
| 63 | 'record_id': 100, | ||
| 64 | 'platform': '1', | ||
| 65 | 'platform_unique_key': 'qq-mid', | ||
| 66 | 'platform_mid': '100', | ||
| 67 | 'album_audio_id': None, | ||
| 68 | 'is_main_version': 0, | ||
| 69 | 'is_high': 1, | ||
| 70 | 'pub_time': '2020-01-01', | ||
| 71 | }, | ||
| 72 | { | ||
| 73 | 'source_song_id': 10, | 87 | 'source_song_id': 10, |
| 74 | 'record_id': 200, | 88 | 'record_id': 200, |
| 75 | 'platform': '2', | 89 | 'platform': '2', |
| ... | @@ -79,25 +93,22 @@ def test_run_imports_only_pending_yinyan_platform_record(monkeypatch): | ... | @@ -79,25 +93,22 @@ def test_run_imports_only_pending_yinyan_platform_record(monkeypatch): |
| 79 | 'is_main_version': 1, | 93 | 'is_main_version': 1, |
| 80 | 'is_high': 0, | 94 | 'is_high': 0, |
| 81 | 'pub_time': '2021-01-01', | 95 | 'pub_time': '2021-01-01', |
| 82 | }, | 96 | }] |
| 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') | ... | ... |
-
Please register or sign in to post a comment