feat(crawler): 增加QQ无效专辑封面回填功能
- 在 run_etl.py 添加 backfill-qq-invalid-covers 命令行选项 - 配置文件中新增 HTTP_TRANSFER_RETRIES 和 RETRY_BACKOFF_SECONDS - oss.py 中实现下载资源的重试逻辑,避免超时和连接断开失败 - runner.py 中引入资源必要性校验,确保音频、封面、歌词均成功转存 - 实现根据歌词和封面有效性选择更合适的QQ录音记录进行替换 - 实现 backfill_qq_invalid_covers 函数,处理无专辑占位封面的QQ歌曲 - 无候选的QQ歌曲在crawler和yinyan记录中删除,并软删除 HK 歌曲 - 资源转存失败的记录在yinyan_song_records标记,避免重复处理 - run函数中增加对QQ、酷狗、网易备选录音的支持和筛选 - 增加对应的数据库操作函数支持替换、删除以及状态标记 - 测试覆盖下载重试、资源转存失败处理及选择替代录音逻辑 - 所有下载失败均返回空URL,不阻断流程以保证稳定性 - 异常处理增强,确保未成功上传资源的歌曲不入库 - 未匹配到HK歌曲的情况增加日志警告及延迟重试机制
Showing
9 changed files
with
314 additions
and
14 deletions
| ... | @@ -51,4 +51,6 @@ PLATFORMS = [PLATFORM_QQ, PLATFORM_KUGOU, PLATFORM_NETEASE] | ... | @@ -51,4 +51,6 @@ PLATFORMS = [PLATFORM_QQ, PLATFORM_KUGOU, PLATFORM_NETEASE] |
| 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')) | 53 | HTTP_POOL_MAXSIZE = int(os.environ.get('HTTP_POOL_MAXSIZE', '128')) |
| 54 | HTTP_TRANSFER_RETRIES = int(os.environ.get('HTTP_TRANSFER_RETRIES', '3')) | ||
| 55 | HTTP_TRANSFER_RETRY_BACKOFF_SECONDS = float(os.environ.get('HTTP_TRANSFER_RETRY_BACKOFF_SECONDS', '1')) | ||
| 54 | OSS_CONNECTION_POOL_SIZE = int(os.environ.get('OSS_CONNECTION_POOL_SIZE', str(HTTP_POOL_MAXSIZE))) | 56 | OSS_CONNECTION_POOL_SIZE = int(os.environ.get('OSS_CONNECTION_POOL_SIZE', str(HTTP_POOL_MAXSIZE))) | ... | ... |
| 1 | import requests | 1 | import requests |
| 2 | import oss2 | 2 | import oss2 |
| 3 | import math | 3 | import math |
| 4 | import time | ||
| 4 | from urllib.parse import urlparse | 5 | from urllib.parse import urlparse |
| 5 | from requests.adapters import HTTPAdapter | 6 | from requests.adapters import HTTPAdapter |
| 6 | from .config import HTTP_POOL_MAXSIZE, OSS_CONFIG | 7 | from .config import ( |
| 8 | HTTP_POOL_MAXSIZE, HTTP_TRANSFER_RETRIES, HTTP_TRANSFER_RETRY_BACKOFF_SECONDS, OSS_CONFIG, | ||
| 9 | ) | ||
| 7 | from .utils import compute_audio_md5 | 10 | from .utils import compute_audio_md5 |
| 8 | 11 | ||
| 9 | _HTTP_SESSION = requests.Session() | 12 | _HTTP_SESSION = requests.Session() |
| ... | @@ -47,6 +50,25 @@ def _download_url(url: str, base_url: str) -> str: | ... | @@ -47,6 +50,25 @@ def _download_url(url: str, base_url: str) -> str: |
| 47 | return f"{download_base_url.rstrip('/')}{url[len(rewrite_from.rstrip('/')):]}" | 50 | return f"{download_base_url.rstrip('/')}{url[len(rewrite_from.rstrip('/')):]}" |
| 48 | 51 | ||
| 49 | 52 | ||
| 53 | def _download_content(url: str, base_url: str) -> bytes: | ||
| 54 | """下载资源;针对超时、连接中断和服务端错误进行有限重试。""" | ||
| 55 | download_url = _download_url(url, base_url) | ||
| 56 | attempts = max(1, HTTP_TRANSFER_RETRIES) | ||
| 57 | for attempt in range(attempts): | ||
| 58 | try: | ||
| 59 | resp = _http_get(download_url, timeout=30) | ||
| 60 | resp.raise_for_status() | ||
| 61 | return resp.content | ||
| 62 | except requests.RequestException as exc: | ||
| 63 | status_code = getattr(getattr(exc, 'response', None), 'status_code', None) | ||
| 64 | # 4xx(429 除外)是确定性失败,不浪费时间重试。 | ||
| 65 | if status_code is not None and 400 <= status_code < 500 and status_code != 429: | ||
| 66 | raise | ||
| 67 | if attempt == attempts - 1: | ||
| 68 | raise | ||
| 69 | time.sleep(HTTP_TRANSFER_RETRY_BACKOFF_SECONDS * (2 ** attempt)) | ||
| 70 | |||
| 71 | |||
| 50 | def transfer_url(url: str | None, oss_key: str, bucket: oss2.Bucket, base_url: str) -> str: | 72 | def transfer_url(url: str | None, oss_key: str, bucket: oss2.Bucket, base_url: str) -> str: |
| 51 | """ | 73 | """ |
| 52 | 将 url 指向的文件转移到 archive-dev OSS 的 oss_key 路径。 | 74 | 将 url 指向的文件转移到 archive-dev OSS 的 oss_key 路径。 |
| ... | @@ -58,9 +80,8 @@ def transfer_url(url: str | None, oss_key: str, bucket: oss2.Bucket, base_url: s | ... | @@ -58,9 +80,8 @@ def transfer_url(url: str | None, oss_key: str, bucket: oss2.Bucket, base_url: s |
| 58 | return '' | 80 | return '' |
| 59 | if _is_target_oss_url(url, base_url): | 81 | if _is_target_oss_url(url, base_url): |
| 60 | return url | 82 | return url |
| 61 | resp = _http_get(_download_url(url, base_url), timeout=30) | 83 | content = _download_content(url, base_url) |
| 62 | resp.raise_for_status() | 84 | bucket.put_object(oss_key, content) |
| 63 | bucket.put_object(oss_key, resp.content) | ||
| 64 | return f"{base_url.rstrip('/')}/{oss_key}" | 85 | return f"{base_url.rstrip('/')}/{oss_key}" |
| 65 | 86 | ||
| 66 | 87 | ||
| ... | @@ -74,10 +95,9 @@ def transfer_url_with_md5(url: str | None, oss_key: str, bucket: oss2.Bucket, ba | ... | @@ -74,10 +95,9 @@ def transfer_url_with_md5(url: str | None, oss_key: str, bucket: oss2.Bucket, ba |
| 74 | return '', '' | 95 | return '', '' |
| 75 | if _is_target_oss_url(url, base_url): | 96 | if _is_target_oss_url(url, base_url): |
| 76 | return url, '' | 97 | return url, '' |
| 77 | resp = _http_get(_download_url(url, base_url), timeout=30) | 98 | content = _download_content(url, base_url) |
| 78 | resp.raise_for_status() | 99 | audio_md5 = compute_audio_md5(content) |
| 79 | audio_md5 = compute_audio_md5(resp.content) | 100 | bucket.put_object(oss_key, content) |
| 80 | bucket.put_object(oss_key, resp.content) | ||
| 81 | return f"{base_url.rstrip('/')}/{oss_key}", audio_md5 | 101 | return f"{base_url.rstrip('/')}/{oss_key}", audio_md5 |
| 82 | 102 | ||
| 83 | 103 | ... | ... |
| ... | @@ -111,6 +111,21 @@ def fetch_hk_songs_by_source_ids(conn: pymysql.Connection, source_song_ids: list | ... | @@ -111,6 +111,21 @@ def fetch_hk_songs_by_source_ids(conn: pymysql.Connection, source_song_ids: list |
| 111 | } | 111 | } |
| 112 | 112 | ||
| 113 | 113 | ||
| 114 | def mark_hk_songs_deleted(conn: pymysql.Connection, source_song_ids: list[int]) -> int: | ||
| 115 | """将无法导入的源歌曲在 hk_songs_test 中软删除。""" | ||
| 116 | if not source_song_ids: | ||
| 117 | return 0 | ||
| 118 | unique_ids = list(dict.fromkeys(int(song_id) for song_id in source_song_ids)) | ||
| 119 | placeholders = ','.join(['%s'] * len(unique_ids)) | ||
| 120 | with conn.cursor() as cur: | ||
| 121 | cur.execute( | ||
| 122 | f"UPDATE hk_songs_test SET deleted = '1' " | ||
| 123 | f"WHERE source_song_id IN ({placeholders}) AND deleted = '0'", | ||
| 124 | unique_ids, | ||
| 125 | ) | ||
| 126 | return cur.rowcount | ||
| 127 | |||
| 128 | |||
| 114 | def fetch_platform_records(source_conn: pymysql.Connection, song_ids: list[int]) -> list[dict]: | 129 | def fetch_platform_records(source_conn: pymysql.Connection, song_ids: list[int]) -> list[dict]: |
| 115 | if not song_ids: | 130 | if not song_ids: |
| 116 | return [] | 131 | return [] | ... | ... |
This diff is collapsed.
Click to expand it.
| ... | @@ -3,6 +3,7 @@ import uuid | ... | @@ -3,6 +3,7 @@ import uuid |
| 3 | _SINGER_INDEX_VALUES = set('ABCDEFGHIJKLMNOPQRSTUVWXYZ#') | 3 | _SINGER_INDEX_VALUES = set('ABCDEFGHIJKLMNOPQRSTUVWXYZ#') |
| 4 | _SINGER_SEX_VALUES = {'M', 'F', 'C', 'U'} | 4 | _SINGER_SEX_VALUES = {'M', 'F', 'C', 'U'} |
| 5 | _SINGER_AREA_VALUES = {'华语', '欧美', '韩国', '日本', '其他'} | 5 | _SINGER_AREA_VALUES = {'华语', '欧美', '韩国', '日本', '其他'} |
| 6 | RESOURCE_TRANSFER_FAILED = 'resource_transfer_failed' | ||
| 6 | 7 | ||
| 7 | 8 | ||
| 8 | def _singer_index(value) -> str: | 9 | def _singer_index(value) -> str: |
| ... | @@ -49,10 +50,11 @@ def fetch_pending_yinyan_song_records(cur, limit: int) -> list[dict]: | ... | @@ -49,10 +50,11 @@ def fetch_pending_yinyan_song_records(cur, limit: int) -> list[dict]: |
| 49 | FROM yinyan_song_records | 50 | FROM yinyan_song_records |
| 50 | WHERE is_yinyan_push = FALSE | 51 | WHERE is_yinyan_push = FALSE |
| 51 | AND platform IS NOT NULL | 52 | AND platform IS NOT NULL |
| 53 | AND COALESCE(recording_id, '') != %s | ||
| 52 | ORDER BY song_id | 54 | ORDER BY song_id |
| 53 | LIMIT %s | 55 | LIMIT %s |
| 54 | """, | 56 | """, |
| 55 | (limit,), | 57 | (RESOURCE_TRANSFER_FAILED, limit), |
| 56 | ) | 58 | ) |
| 57 | rows = cur.fetchall() | 59 | rows = cur.fetchall() |
| 58 | return [{'song_id': row[0], 'record_id': row[1], 'platform': row[2]} for row in rows] | 60 | return [{'song_id': row[0], 'record_id': row[1], 'platform': row[2]} for row in rows] |
| ... | @@ -135,6 +137,90 @@ def fetch_qq_songs_missing_singers(cur, limit: int) -> list[dict]: | ... | @@ -135,6 +137,90 @@ def fetch_qq_songs_missing_singers(cur, limit: int) -> list[dict]: |
| 135 | return [{'id': str(row[0]), 'platform_song_id': row[1], 'mid': row[2]} for row in rows] | 137 | return [{'id': str(row[0]), 'platform_song_id': row[1], 'mid': row[2]} for row in rows] |
| 136 | 138 | ||
| 137 | 139 | ||
| 140 | def fetch_qq_songs_with_invalid_covers(cur, invalid_cover: str, limit: int) -> list[dict]: | ||
| 141 | """返回已导入、但仍使用 QQ 无专辑占位封面的歌曲及其源歌曲状态。""" | ||
| 142 | cur.execute( | ||
| 143 | """ | ||
| 144 | SELECT qs.id, qs.platform_song_id, qs.mid, qs.name, | ||
| 145 | ysr.song_id, ysr.record_id | ||
| 146 | FROM crawler_qqmusic_songs qs | ||
| 147 | JOIN yinyan_song_records ysr | ||
| 148 | ON ysr.platform = '1' AND ysr.platform_song_id = qs.platform_song_id | ||
| 149 | WHERE qs.cover = %s | ||
| 150 | AND qs.album_id IS NULL | ||
| 151 | AND ysr.is_yinyan_push = TRUE | ||
| 152 | ORDER BY ysr.song_id | ||
| 153 | LIMIT %s | ||
| 154 | """, | ||
| 155 | (invalid_cover, limit), | ||
| 156 | ) | ||
| 157 | return [ | ||
| 158 | { | ||
| 159 | 'id': str(row[0]), 'platform_song_id': int(row[1]), 'mid': row[2], | ||
| 160 | 'name': row[3], 'song_id': int(row[4]), 'record_id': int(row[5]), | ||
| 161 | } | ||
| 162 | for row in cur.fetchall() | ||
| 163 | ] | ||
| 164 | |||
| 165 | |||
| 166 | def replace_qq_song(cur, old_platform_song_id: int, song: dict) -> None: | ||
| 167 | """用同一源歌曲下的另一条 QQ 录音更新既有 crawler 歌曲。""" | ||
| 168 | cur.execute( | ||
| 169 | """ | ||
| 170 | UPDATE crawler_qqmusic_songs | ||
| 171 | SET platform_song_id = %s, mid = %s, album_id = %s, cover = %s, | ||
| 172 | title = %s, name = %s, duration = %s, lyric = %s, | ||
| 173 | composer_name = %s, lyricist_name = %s, url = %s, audio_md5 = %s, | ||
| 174 | lyric_url = %s, platform_index_url = %s, published_at = %s, | ||
| 175 | album = %s::json, singers = %s::jsonb, version = %s, | ||
| 176 | provider_name = %s, crawler_source_data = %s::json, updated_at = NOW() | ||
| 177 | WHERE platform_song_id = %s | ||
| 178 | """, | ||
| 179 | ( | ||
| 180 | song['platform_song_id'], song['mid'], song.get('album_id'), song.get('cover', ''), | ||
| 181 | song.get('title', ''), song.get('name', ''), song.get('duration', 0) or 0, | ||
| 182 | song.get('lyric'), song.get('composer_name'), song.get('lyricist_name'), | ||
| 183 | song.get('url', ''), song.get('audio_md5'), song.get('lyric_url'), | ||
| 184 | song.get('platform_index_url'), song.get('published_at'), song.get('album_json'), | ||
| 185 | song.get('singers_json', '[]'), song.get('version'), song.get('provider_name'), | ||
| 186 | song.get('crawler_source_data'), old_platform_song_id, | ||
| 187 | ), | ||
| 188 | ) | ||
| 189 | |||
| 190 | |||
| 191 | def replace_yinyan_song_record(cur, song_id: int, old_platform_song_id: int, record_id: int, platform_song_id: int) -> None: | ||
| 192 | cur.execute( | ||
| 193 | """ | ||
| 194 | UPDATE yinyan_song_records | ||
| 195 | SET record_id = %s, platform_song_id = %s, platform = '1' | ||
| 196 | WHERE song_id = %s AND platform = '1' AND platform_song_id = %s | ||
| 197 | """, | ||
| 198 | (record_id, platform_song_id, song_id, old_platform_song_id), | ||
| 199 | ) | ||
| 200 | |||
| 201 | |||
| 202 | def delete_qq_song_and_yinyan_record(cur, song_id: int, platform_song_id: int, song_uuid: str) -> None: | ||
| 203 | """移除无法找到替代录音的 crawler 数据及其导入状态。""" | ||
| 204 | cur.execute("DELETE FROM crawler_qqmusic_singer_songs WHERE song_id = %s", (song_uuid,)) | ||
| 205 | cur.execute("DELETE FROM crawler_qqmusic_songs WHERE platform_song_id = %s", (platform_song_id,)) | ||
| 206 | cur.execute( | ||
| 207 | "DELETE FROM yinyan_song_records WHERE song_id = %s AND platform = '1' AND platform_song_id = %s", | ||
| 208 | (song_id, platform_song_id), | ||
| 209 | ) | ||
| 210 | |||
| 211 | |||
| 212 | def mark_yinyan_record_resource_failed(cur, song_id: int, record_id: int, platform: str) -> None: | ||
| 213 | """保留未导入状态,并标记资源转存失败,避免后续批次重复处理。""" | ||
| 214 | cur.execute( | ||
| 215 | """ | ||
| 216 | UPDATE yinyan_song_records | ||
| 217 | SET is_yinyan_push = FALSE, recording_id = %s | ||
| 218 | WHERE song_id = %s AND record_id = %s AND platform = %s AND is_yinyan_push = FALSE | ||
| 219 | """, | ||
| 220 | (RESOURCE_TRANSFER_FAILED, song_id, record_id, platform), | ||
| 221 | ) | ||
| 222 | |||
| 223 | |||
| 138 | def fetch_kugou_songs_missing_singers(cur, limit: int) -> list[dict]: | 224 | def fetch_kugou_songs_missing_singers(cur, limit: int) -> list[dict]: |
| 139 | cur.execute( | 225 | cur.execute( |
| 140 | """ | 226 | """ | ... | ... |
| 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 backfill_yinyan_record_platforms, initialize_yinyan_song_records, run, backfill_empty_singers | 4 | from etl_to_crawler.runner import backfill_yinyan_record_platforms, initialize_yinyan_song_records, run, backfill_empty_singers, backfill_qq_invalid_covers |
| 5 | 5 | ||
| 6 | PLATFORM_MAP = { | 6 | PLATFORM_MAP = { |
| 7 | 'qq': PLATFORM_QQ, | 7 | 'qq': PLATFORM_QQ, |
| ... | @@ -22,6 +22,8 @@ if __name__ == '__main__': | ... | @@ -22,6 +22,8 @@ if __name__ == '__main__': |
| 22 | help='只回填 yinyan_song_records 中为空的 platform 平台代码') | 22 | help='只回填 yinyan_song_records 中为空的 platform 平台代码') |
| 23 | parser.add_argument('--backfill-empty-singers', action='store_true', | 23 | parser.add_argument('--backfill-empty-singers', action='store_true', |
| 24 | help='回填三平台 singers 为空的歌曲记录') | 24 | help='回填三平台 singers 为空的歌曲记录') |
| 25 | parser.add_argument('--backfill-qq-invalid-covers', action='store_true', | ||
| 26 | help='替换 QQ 无专辑占位封面的录音;无候选时删除 crawler 记录并软删 hk_songs_test') | ||
| 25 | args = parser.parse_args() | 27 | args = parser.parse_args() |
| 26 | 28 | ||
| 27 | if args.platform == 'all': | 29 | if args.platform == 'all': |
| ... | @@ -36,5 +38,7 @@ if __name__ == '__main__': | ... | @@ -36,5 +38,7 @@ if __name__ == '__main__': |
| 36 | initialize_yinyan_song_records(platforms, max_batches=args.max_batches) | 38 | initialize_yinyan_song_records(platforms, max_batches=args.max_batches) |
| 37 | elif args.backfill_empty_singers: | 39 | elif args.backfill_empty_singers: |
| 38 | backfill_empty_singers(platforms, max_batches=args.max_batches) | 40 | backfill_empty_singers(platforms, max_batches=args.max_batches) |
| 41 | elif args.backfill_qq_invalid_covers: | ||
| 42 | backfill_qq_invalid_covers(max_batches=args.max_batches) | ||
| 39 | else: | 43 | else: |
| 40 | run(platforms, max_batches=args.max_batches) | 44 | run(platforms, max_batches=args.max_batches) | ... | ... |
| 1 | from unittest.mock import MagicMock, patch | 1 | from unittest.mock import MagicMock, patch |
| 2 | import math | 2 | import math |
| 3 | import requests | ||
| 3 | from etl_to_crawler.oss import transfer_url, transfer_url_with_md5 | 4 | from etl_to_crawler.oss import transfer_url, transfer_url_with_md5 |
| 4 | 5 | ||
| 5 | ARCHIVE_URL = "https://archive-dev.oss-cn-beijing.aliyuncs.com/some/path.mp3" | 6 | ARCHIVE_URL = "https://archive-dev.oss-cn-beijing.aliyuncs.com/some/path.mp3" |
| ... | @@ -75,3 +76,33 @@ def test_transfer_url_with_md5_hashes_downloaded_content_before_upload(): | ... | @@ -75,3 +76,33 @@ def test_transfer_url_with_md5_hashes_downloaded_content_before_upload(): |
| 75 | bucket.put_object.assert_called_once_with("crawler/qq/audio/abc.mp3", fake_content) | 76 | bucket.put_object.assert_called_once_with("crawler/qq/audio/abc.mp3", fake_content) |
| 76 | assert result == f"{BASE_URL}/crawler/qq/audio/abc.mp3" | 77 | assert result == f"{BASE_URL}/crawler/qq/audio/abc.mp3" |
| 77 | assert audio_md5 == "04d43544b267629d9089eaed3b847a99" | 78 | assert audio_md5 == "04d43544b267629d9089eaed3b847a99" |
| 79 | |||
| 80 | |||
| 81 | def test_audio_transfer_retries_timeout_then_uploads(): | ||
| 82 | bucket = MagicMock() | ||
| 83 | response = MagicMock(content=b'audio_bytes') | ||
| 84 | response.raise_for_status.return_value = None | ||
| 85 | with patch('etl_to_crawler.oss._http_get', side_effect=[requests.ReadTimeout('slow'), response]) as mock_get: | ||
| 86 | with patch('etl_to_crawler.oss.time.sleep') as mock_sleep: | ||
| 87 | result, _ = transfer_url_with_md5(OTHER_URL, 'crawler/qq/audio/abc.mp3', bucket, BASE_URL) | ||
| 88 | |||
| 89 | assert mock_get.call_count == 2 | ||
| 90 | mock_sleep.assert_called_once() | ||
| 91 | assert result == f"{BASE_URL}/crawler/qq/audio/abc.mp3" | ||
| 92 | |||
| 93 | |||
| 94 | def test_transfer_does_not_retry_not_found_response(): | ||
| 95 | bucket = MagicMock() | ||
| 96 | response = MagicMock(status_code=404) | ||
| 97 | error = requests.HTTPError(response=response) | ||
| 98 | with patch('etl_to_crawler.oss._http_get', side_effect=error) as mock_get: | ||
| 99 | with patch('etl_to_crawler.oss.time.sleep') as mock_sleep: | ||
| 100 | try: | ||
| 101 | transfer_url_with_md5(OTHER_URL, 'crawler/qq/audio/abc.mp3', bucket, BASE_URL) | ||
| 102 | except requests.HTTPError: | ||
| 103 | pass | ||
| 104 | else: | ||
| 105 | raise AssertionError('expected HTTPError') | ||
| 106 | |||
| 107 | mock_get.assert_called_once() | ||
| 108 | mock_sleep.assert_not_called() | ... | ... |
| 1 | from unittest.mock import MagicMock | 1 | from unittest.mock import MagicMock |
| 2 | import time | 2 | import time |
| 3 | import pytest | ||
| 3 | 4 | ||
| 4 | from etl_to_crawler import runner | 5 | from etl_to_crawler import runner |
| 5 | 6 | ||
| ... | @@ -35,6 +36,12 @@ class _Connection: | ... | @@ -35,6 +36,12 @@ class _Connection: |
| 35 | def close(self): | 36 | def close(self): |
| 36 | return None | 37 | return None |
| 37 | 38 | ||
| 39 | def cursor(self): | ||
| 40 | return _Cursor() | ||
| 41 | |||
| 42 | def commit(self): | ||
| 43 | return None | ||
| 44 | |||
| 38 | 45 | ||
| 39 | def test_run_io_tasks_returns_named_results(): | 46 | def test_run_io_tasks_returns_named_results(): |
| 40 | def slow(value): | 47 | def slow(value): |
| ... | @@ -52,6 +59,86 @@ def test_run_io_tasks_returns_named_results(): | ... | @@ -52,6 +59,86 @@ def test_run_io_tasks_returns_named_results(): |
| 52 | } | 59 | } |
| 53 | 60 | ||
| 54 | 61 | ||
| 62 | def test_safe_audio_transfer_returns_empty_url_after_final_failure(monkeypatch): | ||
| 63 | monkeypatch.setattr(runner, 'transfer_url_with_md5', MagicMock(side_effect=TimeoutError('timeout'))) | ||
| 64 | assert runner._safe_transfer_audio('https://source.example/audio.mp3', 'audio.mp3', object(), 'https://archive.example') == ('', '') | ||
| 65 | |||
| 66 | |||
| 67 | def test_safe_transfer_returns_empty_url_after_final_failure(monkeypatch): | ||
| 68 | monkeypatch.setattr(runner, 'transfer_url', MagicMock(side_effect=TimeoutError('timeout'))) | ||
| 69 | assert runner._safe_transfer('https://source.example/cover.jpg', 'cover.jpg', object(), 'https://archive.example') == '' | ||
| 70 | |||
| 71 | |||
| 72 | def test_required_asset_validation_rejects_partial_transfer(): | ||
| 73 | with pytest.raises(runner.RequiredAssetTransferError, match='audio, lyric'): | ||
| 74 | runner._require_primary_assets({ | ||
| 75 | 'audio': ('', ''), | ||
| 76 | 'cover': 'https://archive.example/cover.jpg', | ||
| 77 | 'lyric': '', | ||
| 78 | }) | ||
| 79 | |||
| 80 | |||
| 81 | def test_lyric_upload_does_not_fall_back_to_external_source_url(monkeypatch): | ||
| 82 | monkeypatch.setattr(runner, 'upload_plain_lyric_to_bucket', lambda *args: '') | ||
| 83 | assert runner._safe_upload_lyric( | ||
| 84 | 'qq', 'mid', '', 'https://source.example/lyric.txt', object(), 'https://archive.example', | ||
| 85 | ) == '' | ||
| 86 | |||
| 87 | |||
| 88 | def test_qq_cover_source_ignores_missing_album_placeholder_from_hk(): | ||
| 89 | assert runner._qq_cover_source( | ||
| 90 | {'cover_url': runner.QQ_MISSING_ALBUM_COVER}, | ||
| 91 | {'cover': 'https://example.com/valid-qq-cover.jpg'}, | ||
| 92 | ) == 'https://example.com/valid-qq-cover.jpg' | ||
| 93 | |||
| 94 | |||
| 95 | def test_qq_cover_source_keeps_valid_hk_cover(): | ||
| 96 | assert runner._qq_cover_source( | ||
| 97 | {'cover_url': 'https://example.com/hk-cover.jpg'}, | ||
| 98 | {'cover': 'https://example.com/qq-cover.jpg'}, | ||
| 99 | ) == 'https://example.com/hk-cover.jpg' | ||
| 100 | |||
| 101 | |||
| 102 | def test_select_qq_import_record_replaces_missing_album_cover_with_same_source_candidate(): | ||
| 103 | current = {'record_id': 10, 'platform_unique_key': 'bad-mid'} | ||
| 104 | candidate = {'record_id': 20, 'platform_unique_key': 'good-mid'} | ||
| 105 | selected = runner._select_qq_import_record( | ||
| 106 | {'cover_url': runner.QQ_MISSING_ALBUM_COVER}, | ||
| 107 | current, | ||
| 108 | [current, candidate], | ||
| 109 | { | ||
| 110 | 'bad-mid': {'album_id': 0, 'cover': runner.QQ_MISSING_ALBUM_COVER, 'lyric': '歌词'}, | ||
| 111 | 'good-mid': {'album_id': 100, 'cover': 'https://example.com/good.jpg', 'lyric': '歌词'}, | ||
| 112 | }, | ||
| 113 | ) | ||
| 114 | assert selected == candidate | ||
| 115 | |||
| 116 | |||
| 117 | def test_select_qq_import_record_replaces_missing_lyric_with_same_source_candidate(): | ||
| 118 | current = {'record_id': 10, 'platform_unique_key': 'without-lyric'} | ||
| 119 | candidate = {'record_id': 20, 'platform_unique_key': 'with-lyric'} | ||
| 120 | selected = runner._select_qq_import_record( | ||
| 121 | {'cover_url': 'https://example.com/hk-cover.jpg'}, | ||
| 122 | current, | ||
| 123 | [current, candidate], | ||
| 124 | { | ||
| 125 | 'without-lyric': {'album_id': 100, 'cover': 'https://example.com/current.jpg', 'lyric': ''}, | ||
| 126 | 'with-lyric': {'album_id': 100, 'cover': 'https://example.com/candidate.jpg', 'lyric': '有效歌词'}, | ||
| 127 | }, | ||
| 128 | ) | ||
| 129 | assert selected == candidate | ||
| 130 | |||
| 131 | |||
| 132 | def test_select_qq_import_record_returns_none_when_no_candidate_satisfies_missing_fields(): | ||
| 133 | current = {'record_id': 10, 'platform_unique_key': 'bad-mid'} | ||
| 134 | assert runner._select_qq_import_record( | ||
| 135 | {'cover_url': runner.QQ_MISSING_ALBUM_COVER}, | ||
| 136 | current, | ||
| 137 | [current], | ||
| 138 | {'bad-mid': {'album_id': 0, 'cover': runner.QQ_MISSING_ALBUM_COVER, 'lyric': ''}}, | ||
| 139 | ) is None | ||
| 140 | |||
| 141 | |||
| 55 | def test_run_imports_only_pending_yinyan_platform_record(monkeypatch): | 142 | def test_run_imports_only_pending_yinyan_platform_record(monkeypatch): |
| 56 | pg_conn = _PgConnection() | 143 | pg_conn = _PgConnection() |
| 57 | prepare = MagicMock(return_value={ | 144 | prepare = MagicMock(return_value={ |
| ... | @@ -95,10 +182,22 @@ def test_run_imports_only_pending_yinyan_platform_record(monkeypatch): | ... | @@ -95,10 +182,22 @@ def test_run_imports_only_pending_yinyan_platform_record(monkeypatch): |
| 95 | 'pub_time': '2021-01-01', | 182 | 'pub_time': '2021-01-01', |
| 96 | }] | 183 | }] |
| 97 | monkeypatch.setattr(runner, 'fetch_platform_records', lambda conn, song_ids: platform_records) | 184 | monkeypatch.setattr(runner, 'fetch_platform_records', lambda conn, song_ids: platform_records) |
| 98 | monkeypatch.setattr(runner, 'fetch_all_platform_records', MagicMock()) | 185 | monkeypatch.setattr(runner, 'fetch_all_platform_records', lambda conn, song_ids: [ |
| 186 | { | ||
| 187 | 'source_song_id': 10, | ||
| 188 | 'record_id': 200, | ||
| 189 | 'platform': '2', | ||
| 190 | 'platform_unique_key': '200', | ||
| 191 | 'platform_mid': 'kg-hash', | ||
| 192 | 'album_audio_id': None, | ||
| 193 | 'is_main_version': 1, | ||
| 194 | 'is_high': 0, | ||
| 195 | 'pub_time': '2021-01-01', | ||
| 196 | }, | ||
| 197 | ]) | ||
| 99 | monkeypatch.setattr(runner, 'fetch_platform_records_by_record_ids', lambda conn, record_ids: platform_records) | 198 | monkeypatch.setattr(runner, 'fetch_platform_records_by_record_ids', lambda conn, record_ids: platform_records) |
| 100 | monkeypatch.setattr(runner, 'fetch_kugou_songs', lambda conn, song_ids: { | 199 | monkeypatch.setattr(runner, 'fetch_kugou_songs', lambda conn, song_ids: { |
| 101 | 200: {'id': 200}, | 200 | 200: {'id': 200, 'lyric': '歌词', 'cover': 'https://example.com/cover.jpg'}, |
| 102 | }) | 201 | }) |
| 103 | monkeypatch.setattr(runner, 'fetch_kugou_singers', lambda conn, song_ids: {}) | 202 | monkeypatch.setattr(runner, 'fetch_kugou_singers', lambda conn, song_ids: {}) |
| 104 | monkeypatch.setattr(runner, '_prepare_import_payload', prepare) | 203 | monkeypatch.setattr(runner, '_prepare_import_payload', prepare) |
| ... | @@ -108,7 +207,6 @@ def test_run_imports_only_pending_yinyan_platform_record(monkeypatch): | ... | @@ -108,7 +207,6 @@ def test_run_imports_only_pending_yinyan_platform_record(monkeypatch): |
| 108 | 207 | ||
| 109 | prepare.assert_called_once() | 208 | prepare.assert_called_once() |
| 110 | write_payloads.assert_called_once() | 209 | write_payloads.assert_called_once() |
| 111 | runner.fetch_all_platform_records.assert_not_called() | ||
| 112 | assert pg_conn.commits == 1 | 210 | assert pg_conn.commits == 1 |
| 113 | 211 | ||
| 114 | 212 | ||
| ... | @@ -213,6 +311,7 @@ def test_process_qq_builds_album_json_for_song_insert(monkeypatch): | ... | @@ -213,6 +311,7 @@ def test_process_qq_builds_album_json_for_song_insert(monkeypatch): |
| 213 | monkeypatch.setattr(runner, 'fetch_qq_singers', lambda conn, song_ids: {}) | 311 | monkeypatch.setattr(runner, 'fetch_qq_singers', lambda conn, song_ids: {}) |
| 214 | monkeypatch.setattr(runner, '_safe_transfer', lambda url, oss_key, bucket, base_url: url) | 312 | monkeypatch.setattr(runner, '_safe_transfer', lambda url, oss_key, bucket, base_url: url) |
| 215 | monkeypatch.setattr(runner, '_safe_transfer_audio', lambda url, oss_key, bucket, base_url: (url, 'audio-md5')) | 313 | monkeypatch.setattr(runner, '_safe_transfer_audio', lambda url, oss_key, bucket, base_url: (url, 'audio-md5')) |
| 314 | monkeypatch.setattr(runner, '_safe_upload_lyric', lambda *args: 'https://bucket.example.com/lyric.txt') | ||
| 216 | monkeypatch.setattr(runner, 'upsert_qq_singers', lambda cur, singers: None) | 315 | monkeypatch.setattr(runner, 'upsert_qq_singers', lambda cur, singers: None) |
| 217 | monkeypatch.setattr(runner, 'upsert_qq_albums', lambda cur, albums: None) | 316 | monkeypatch.setattr(runner, 'upsert_qq_albums', lambda cur, albums: None) |
| 218 | monkeypatch.setattr(runner, 'upsert_qq_songs', lambda cur, songs: inserted_songs.extend(songs)) | 317 | monkeypatch.setattr(runner, 'upsert_qq_songs', lambda cur, songs: inserted_songs.extend(songs)) |
| ... | @@ -275,6 +374,7 @@ def test_process_netease_builds_album_json_for_song_insert(monkeypatch): | ... | @@ -275,6 +374,7 @@ def test_process_netease_builds_album_json_for_song_insert(monkeypatch): |
| 275 | monkeypatch.setattr(runner, 'fetch_netease_singers', lambda conn, song_ids: {}) | 374 | monkeypatch.setattr(runner, 'fetch_netease_singers', lambda conn, song_ids: {}) |
| 276 | monkeypatch.setattr(runner, '_safe_transfer', lambda url, oss_key, bucket, base_url: url) | 375 | monkeypatch.setattr(runner, '_safe_transfer', lambda url, oss_key, bucket, base_url: url) |
| 277 | monkeypatch.setattr(runner, '_safe_transfer_audio', lambda url, oss_key, bucket, base_url: (url, 'audio-md5')) | 376 | monkeypatch.setattr(runner, '_safe_transfer_audio', lambda url, oss_key, bucket, base_url: (url, 'audio-md5')) |
| 377 | monkeypatch.setattr(runner, '_safe_upload_lyric', lambda *args: 'https://bucket.example.com/lyric.txt') | ||
| 278 | monkeypatch.setattr(runner, 'upsert_netease_singers', lambda cur, singers: None) | 378 | monkeypatch.setattr(runner, 'upsert_netease_singers', lambda cur, singers: None) |
| 279 | monkeypatch.setattr(runner, 'upsert_netease_albums', lambda cur, albums: None) | 379 | monkeypatch.setattr(runner, 'upsert_netease_albums', lambda cur, albums: None) |
| 280 | monkeypatch.setattr(runner, 'upsert_netease_songs', lambda cur, songs: inserted_songs.extend(songs)) | 380 | monkeypatch.setattr(runner, 'upsert_netease_songs', lambda cur, songs: inserted_songs.extend(songs)) |
| ... | @@ -317,6 +417,7 @@ def test_process_netease_uses_prefetched_song_and_singers(monkeypatch): | ... | @@ -317,6 +417,7 @@ def test_process_netease_uses_prefetched_song_and_singers(monkeypatch): |
| 317 | monkeypatch.setattr(runner, 'fetch_netease_singers', fetch_singers) | 417 | monkeypatch.setattr(runner, 'fetch_netease_singers', fetch_singers) |
| 318 | monkeypatch.setattr(runner, '_safe_transfer', lambda url, oss_key, bucket, base_url: url) | 418 | 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')) | 419 | monkeypatch.setattr(runner, '_safe_transfer_audio', lambda url, oss_key, bucket, base_url: (url, 'audio-md5')) |
| 420 | monkeypatch.setattr(runner, '_safe_upload_lyric', lambda *args: 'https://bucket.example.com/lyric.txt') | ||
| 320 | monkeypatch.setattr(runner, 'upsert_netease_singers', lambda cur, singers: None) | 421 | monkeypatch.setattr(runner, 'upsert_netease_singers', lambda cur, singers: None) |
| 321 | monkeypatch.setattr(runner, 'upsert_netease_albums', lambda cur, albums: None) | 422 | monkeypatch.setattr(runner, 'upsert_netease_albums', lambda cur, albums: None) |
| 322 | monkeypatch.setattr(runner, 'upsert_netease_songs', lambda cur, songs: inserted_songs.extend(songs)) | 423 | monkeypatch.setattr(runner, 'upsert_netease_songs', lambda cur, songs: inserted_songs.extend(songs)) | ... | ... |
| ... | @@ -15,6 +15,9 @@ from etl_to_crawler.writer import ( | ... | @@ -15,6 +15,9 @@ from etl_to_crawler.writer import ( |
| 15 | upsert_qq_songs, | 15 | upsert_qq_songs, |
| 16 | upsert_qq_singer_songs, | 16 | upsert_qq_singer_songs, |
| 17 | upsert_yinyan_song_records, | 17 | upsert_yinyan_song_records, |
| 18 | replace_qq_song, | ||
| 19 | delete_qq_song_and_yinyan_record, | ||
| 20 | mark_yinyan_record_resource_failed, | ||
| 18 | ) | 21 | ) |
| 19 | 22 | ||
| 20 | 23 | ||
| ... | @@ -31,6 +34,44 @@ def test_upsert_qq_singers_executes_insert(): | ... | @@ -31,6 +34,44 @@ def test_upsert_qq_singers_executes_insert(): |
| 31 | assert 'ON CONFLICT' in args[0][0] | 34 | assert 'ON CONFLICT' in args[0][0] |
| 32 | 35 | ||
| 33 | 36 | ||
| 37 | def test_replace_qq_song_updates_existing_row_by_old_platform_song_id(): | ||
| 38 | cur = MagicMock() | ||
| 39 | replace_qq_song(cur, 100, { | ||
| 40 | 'platform_song_id': 200, 'mid': 'new-mid', 'album_id': 20, | ||
| 41 | 'cover': 'https://oss.example/cover.jpg', 'title': '标题', 'name': '歌名', | ||
| 42 | 'duration': 180, 'singers_json': '[]', 'provider_name': 'yinyan', | ||
| 43 | 'crawler_source_data': '{}', | ||
| 44 | }) | ||
| 45 | |||
| 46 | sql, params = cur.execute.call_args[0] | ||
| 47 | assert 'UPDATE crawler_qqmusic_songs' in sql | ||
| 48 | assert 'platform_song_id = %s' in sql | ||
| 49 | assert params[-1] == 100 | ||
| 50 | assert params[0] == 200 | ||
| 51 | |||
| 52 | |||
| 53 | def test_delete_qq_song_and_yinyan_record_cleans_song_relations_and_state(): | ||
| 54 | cur = MagicMock() | ||
| 55 | delete_qq_song_and_yinyan_record(cur, 10, 100, 'song-uuid') | ||
| 56 | |||
| 57 | assert cur.execute.call_count == 3 | ||
| 58 | calls = cur.execute.call_args_list | ||
| 59 | assert 'crawler_qqmusic_singer_songs' in calls[0].args[0] | ||
| 60 | assert 'crawler_qqmusic_songs' in calls[1].args[0] | ||
| 61 | assert 'yinyan_song_records' in calls[2].args[0] | ||
| 62 | |||
| 63 | |||
| 64 | def test_mark_yinyan_record_resource_failed_keeps_unpushed_state_and_excludes_retry(): | ||
| 65 | cur = MagicMock() | ||
| 66 | mark_yinyan_record_resource_failed(cur, 10, 100, '1') | ||
| 67 | |||
| 68 | sql, params = cur.execute.call_args[0] | ||
| 69 | assert 'UPDATE yinyan_song_records' in sql | ||
| 70 | assert 'is_yinyan_push = FALSE' in sql | ||
| 71 | assert 'recording_id = %s' in sql | ||
| 72 | assert params == ('resource_transfer_failed', 10, 100, '1') | ||
| 73 | |||
| 74 | |||
| 34 | def test_upsert_qq_singers_empty_does_nothing(): | 75 | def test_upsert_qq_singers_empty_does_nothing(): |
| 35 | cur = MagicMock() | 76 | cur = MagicMock() |
| 36 | upsert_qq_singers(cur, []) | 77 | upsert_qq_singers(cur, []) |
| ... | @@ -103,7 +144,7 @@ def test_fetch_pending_yinyan_song_records_reads_platform_for_single_platform_im | ... | @@ -103,7 +144,7 @@ def test_fetch_pending_yinyan_song_records_reads_platform_for_single_platform_im |
| 103 | sql, params = cur.execute.call_args[0] | 144 | sql, params = cur.execute.call_args[0] |
| 104 | assert 'SELECT song_id, record_id, platform' in sql | 145 | assert 'SELECT song_id, record_id, platform' in sql |
| 105 | assert 'platform IS NOT NULL' in sql | 146 | assert 'platform IS NOT NULL' in sql |
| 106 | assert params == (500,) | 147 | assert params == ('resource_transfer_failed', 500) |
| 107 | assert rows == [ | 148 | assert rows == [ |
| 108 | {'song_id': 10, 'record_id': 100, 'platform': '1'}, | 149 | {'song_id': 10, 'record_id': 100, 'platform': '1'}, |
| 109 | {'song_id': 11, 'record_id': 101, 'platform': '2'}, | 150 | {'song_id': 11, 'record_id': 101, 'platform': '2'}, | ... | ... |
-
Please register or sign in to post a comment