Commit 2911f147 2911f1479ec790b0d5aac60dfde250f4bcbb8313 by 沈秋雨

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歌曲的情况增加日志警告及延迟重试机制
1 parent a1e6ab0d
...@@ -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 []
......
...@@ -9,6 +9,7 @@ from .connections import get_hk_songs_conn, get_source_conn, get_spider_conn, ge ...@@ -9,6 +9,7 @@ from .connections import get_hk_songs_conn, get_source_conn, get_spider_conn, ge
9 from .reader import ( 9 from .reader import (
10 iter_hk_songs_batches, 10 iter_hk_songs_batches,
11 fetch_hk_songs_by_source_ids, 11 fetch_hk_songs_by_source_ids,
12 mark_hk_songs_deleted,
12 fetch_platform_records, 13 fetch_platform_records,
13 fetch_all_platform_records, 14 fetch_all_platform_records,
14 fetch_platform_records_by_record_ids, 15 fetch_platform_records_by_record_ids,
...@@ -36,6 +37,8 @@ from .writer import ( ...@@ -36,6 +37,8 @@ from .writer import (
36 upsert_netease_singer_songs, upsert_netease_singer_albums, 37 upsert_netease_singer_songs, upsert_netease_singer_albums,
37 fetch_qq_songs_missing_singers, fetch_kugou_songs_missing_singers, fetch_netease_songs_missing_singers, 38 fetch_qq_songs_missing_singers, fetch_kugou_songs_missing_singers, fetch_netease_songs_missing_singers,
38 update_qq_song_singers, update_kugou_song_singers, update_netease_song_singers, 39 update_qq_song_singers, update_kugou_song_singers, update_netease_song_singers,
40 fetch_qq_songs_with_invalid_covers, replace_qq_song, replace_yinyan_song_record,
41 delete_qq_song_and_yinyan_record, mark_yinyan_record_resource_failed,
39 ) 42 )
40 from .oss import transfer_url, transfer_url_with_md5, build_oss_key 43 from .oss import transfer_url, transfer_url_with_md5, build_oss_key
41 from .utils import split_title_version, upload_plain_lyric_to_bucket 44 from .utils import split_title_version, upload_plain_lyric_to_bucket
...@@ -46,6 +49,11 @@ log = logging.getLogger(__name__) ...@@ -46,6 +49,11 @@ log = logging.getLogger(__name__)
46 PROVIDER_YINYAN = 'yinyan' 49 PROVIDER_YINYAN = 'yinyan'
47 MAX_RESOURCE_WORKERS = 8 50 MAX_RESOURCE_WORKERS = 8
48 MAX_IMPORT_WORKERS = 16 51 MAX_IMPORT_WORKERS = 16
52 QQ_MISSING_ALBUM_COVER = 'http://y.gtimg.cn/music/photo_new/T002R300x300M000.jpg'
53
54
55 class RequiredAssetTransferError(RuntimeError):
56 """音频、封面或歌词未成功落到目标 OSS,当前歌曲不得入库。"""
49 57
50 58
51 def _run_io_tasks(tasks: dict) -> dict: 59 def _run_io_tasks(tasks: dict) -> dict:
...@@ -65,7 +73,7 @@ def _safe_transfer(url, oss_key, bucket, base_url): ...@@ -65,7 +73,7 @@ def _safe_transfer(url, oss_key, bucket, base_url):
65 return transfer_url(url, oss_key, bucket, base_url) 73 return transfer_url(url, oss_key, bucket, base_url)
66 except Exception as e: 74 except Exception as e:
67 log.warning("OSS transfer failed for %s: %s", url, e) 75 log.warning("OSS transfer failed for %s: %s", url, e)
68 return url # 失败时保留原 URL,不阻断流程 76 return '' # 不将未转存的外链写入 crawler
69 77
70 78
71 def _safe_transfer_audio(url, oss_key, bucket, base_url) -> tuple[str, str]: 79 def _safe_transfer_audio(url, oss_key, bucket, base_url) -> tuple[str, str]:
...@@ -73,15 +81,31 @@ def _safe_transfer_audio(url, oss_key, bucket, base_url) -> tuple[str, str]: ...@@ -73,15 +81,31 @@ def _safe_transfer_audio(url, oss_key, bucket, base_url) -> tuple[str, str]:
73 return transfer_url_with_md5(url, oss_key, bucket, base_url) 81 return transfer_url_with_md5(url, oss_key, bucket, base_url)
74 except Exception as e: 82 except Exception as e:
75 log.warning("Audio transfer failed for %s: %s", url, e) 83 log.warning("Audio transfer failed for %s: %s", url, e)
76 return url or '', '' 84 return '', '' # 不将未转存的音频外链写入 crawler
77 85
78 86
79 def _safe_upload_lyric(platform: str, unique_id: str, lyric: str | None, fallback_url: str | None, bucket, base_url: str) -> str: 87 def _safe_upload_lyric(platform: str, unique_id: str, lyric: str | None, fallback_url: str | None, bucket, base_url: str) -> str:
80 try: 88 try:
81 return upload_plain_lyric_to_bucket(platform, unique_id, lyric or '', bucket, base_url) or (fallback_url or '') 89 uploaded_url = upload_plain_lyric_to_bucket(platform, unique_id, lyric or '', bucket, base_url)
90 if uploaded_url:
91 return uploaded_url
92 fallback_url = str(fallback_url or '')
93 return fallback_url if fallback_url.startswith(base_url.rstrip('/') + '/') else ''
82 except Exception as e: 94 except Exception as e:
83 log.warning("Lyric upload failed for %s/%s: %s", platform, unique_id, e) 95 log.warning("Lyric upload failed for %s/%s: %s", platform, unique_id, e)
84 return fallback_url or '' 96 return ''
97
98
99 def _require_primary_assets(assets: dict) -> None:
100 """音频、歌曲封面、歌词均为入库必需资源,任一失败即拒绝整条记录。"""
101 audio_url, _ = assets.get('audio', ('', ''))
102 missing = [
103 name for name, value in (
104 ('audio', audio_url), ('cover', assets.get('cover')), ('lyric', assets.get('lyric')),
105 ) if not value
106 ]
107 if missing:
108 raise RequiredAssetTransferError(f"required asset transfer failed: {', '.join(missing)}")
85 109
86 110
87 def _json_default(value): 111 def _json_default(value):
...@@ -92,6 +116,123 @@ def _source_json(data: dict) -> str: ...@@ -92,6 +116,123 @@ def _source_json(data: dict) -> str:
92 return json.dumps(data, ensure_ascii=False, default=_json_default) 116 return json.dumps(data, ensure_ascii=False, default=_json_default)
93 117
94 118
119 def _is_usable_qq_cover(url: str | None) -> bool:
120 return bool(url and str(url).strip() and str(url).strip() != QQ_MISSING_ALBUM_COVER)
121
122
123 def _qq_cover_source(hk_row: dict, song_data: dict) -> str:
124 """HK 封面优先;QQ 的 M000 占位图视为无封面,改用当前录音的封面。"""
125 hk_cover = hk_row.get('cover_url')
126 return hk_cover if _is_usable_qq_cover(hk_cover) else (song_data.get('cover') or '')
127
128
129 def _has_usable_qq_lyric(song_data: dict) -> bool:
130 return bool(str(song_data.get('lyric') or '').strip())
131
132
133 def _has_kugou_cover(hk_row: dict, song_data: dict) -> bool:
134 """酷狗封面来源:hk_row.cover_url 优先,回退到 spider 录音封面。"""
135 return bool((hk_row.get('cover_url') or '').strip() or (song_data.get('cover') or '').strip())
136
137
138 def _has_kugou_lyric(song_data: dict) -> bool:
139 return bool(str(song_data.get('lyric') or '').strip())
140
141
142 def _has_netease_cover(hk_row: dict, song_data: dict) -> bool:
143 """网易封面来源:hk_row.cover_url 优先,回退到 spider 录音封面。"""
144 return bool((hk_row.get('cover_url') or '').strip() or (song_data.get('cover') or '').strip())
145
146
147 def _has_netease_lyric(song_data: dict) -> bool:
148 return bool(str(song_data.get('lyric') or '').strip())
149
150
151 def _select_qq_import_record(
152 hk_row: dict,
153 current_record: dict,
154 candidate_records: list[dict],
155 songs_by_mid: dict[str, dict],
156 ) -> dict | None:
157 """为导入选择 QQ 录音;当前录音的歌词或封面不可用时,改选同源的合格候选。"""
158 current_song = songs_by_mid.get(current_record['platform_unique_key'])
159 if not current_song:
160 return None
161 needs_lyric = not _has_usable_qq_lyric(current_song)
162 needs_cover = not _is_usable_qq_cover(_qq_cover_source(hk_row, current_song)) or not current_song.get('album_id')
163 if not needs_lyric and not needs_cover:
164 return current_record
165
166 for candidate in candidate_records:
167 song = songs_by_mid.get(candidate['platform_unique_key'])
168 if not song:
169 continue
170 if needs_lyric and not _has_usable_qq_lyric(song):
171 continue
172 if needs_cover and (not song.get('album_id') or not _is_usable_qq_cover(song.get('cover'))):
173 continue
174 return candidate
175 return None
176
177
178 def _select_kugou_import_record(
179 hk_row: dict,
180 current_record: dict,
181 candidate_records: list[dict],
182 songs_by_id: dict[int, dict],
183 ) -> dict | None:
184 """为导入选择酷狗录音;当前录音的歌词或封面不可用时,改选同源的合格候选。"""
185 song_id = int(current_record['platform_unique_key'])
186 current_song = songs_by_id.get(song_id)
187 if not current_song:
188 return None
189 needs_lyric = not _has_kugou_lyric(current_song)
190 needs_cover = not _has_kugou_cover(hk_row, current_song)
191 if not needs_lyric and not needs_cover:
192 return current_record
193
194 for candidate in candidate_records:
195 cid = int(candidate['platform_unique_key'])
196 song = songs_by_id.get(cid)
197 if not song:
198 continue
199 if needs_lyric and not _has_kugou_lyric(song):
200 continue
201 if needs_cover and not _has_kugou_cover(hk_row, song):
202 continue
203 return candidate
204 return None
205
206
207 def _select_netease_import_record(
208 hk_row: dict,
209 current_record: dict,
210 candidate_records: list[dict],
211 songs_by_id: dict[int, dict],
212 ) -> dict | None:
213 """为导入选择网易录音;当前录音的歌词或封面不可用时,改选同源的合格候选。"""
214 song_id = int(current_record['platform_unique_key'])
215 current_song = songs_by_id.get(song_id)
216 if not current_song:
217 return None
218 needs_lyric = not _has_netease_lyric(current_song)
219 needs_cover = not _has_netease_cover(hk_row, current_song)
220 if not needs_lyric and not needs_cover:
221 return current_record
222
223 for candidate in candidate_records:
224 cid = int(candidate['platform_unique_key'])
225 song = songs_by_id.get(cid)
226 if not song:
227 continue
228 if needs_lyric and not _has_netease_lyric(song):
229 continue
230 if needs_cover and not _has_netease_cover(hk_row, song):
231 continue
232 return candidate
233 return None
234
235
95 def _prepare_qq_payload(hk_row: dict, pr: dict, bucket, base_url, sp: dict, singer_list: list[dict]) -> dict: 236 def _prepare_qq_payload(hk_row: dict, pr: dict, bucket, base_url, sp: dict, singer_list: list[dict]) -> dict:
96 mid = pr['platform_unique_key'] 237 mid = pr['platform_unique_key']
97 song_id_int = sp['id'] 238 song_id_int = sp['id']
...@@ -101,7 +242,7 @@ def _prepare_qq_payload(hk_row: dict, pr: dict, bucket, base_url, sp: dict, sing ...@@ -101,7 +242,7 @@ def _prepare_qq_payload(hk_row: dict, pr: dict, bucket, base_url, sp: dict, sing
101 hk_row['audio_url'], build_oss_key('qq', 'audio', mid + '.mp3'), bucket, base_url 242 hk_row['audio_url'], build_oss_key('qq', 'audio', mid + '.mp3'), bucket, base_url
102 ), 243 ),
103 'cover': lambda: _safe_transfer( 244 'cover': lambda: _safe_transfer(
104 hk_row.get('cover_url') or sp.get('cover', ''), 245 _qq_cover_source(hk_row, sp),
105 build_oss_key('qq', 'cover', str(song_id_int) + '.jpg'), 246 build_oss_key('qq', 'cover', str(song_id_int) + '.jpg'),
106 bucket, base_url, 247 bucket, base_url,
107 ), 248 ),
...@@ -120,6 +261,7 @@ def _prepare_qq_payload(hk_row: dict, pr: dict, bucket, base_url, sp: dict, sing ...@@ -120,6 +261,7 @@ def _prepare_qq_payload(hk_row: dict, pr: dict, bucket, base_url, sp: dict, sing
120 ) 261 )
121 ) 262 )
122 assets = _run_io_tasks(tasks) 263 assets = _run_io_tasks(tasks)
264 _require_primary_assets(assets)
123 audio_url, audio_md5 = assets['audio'] 265 audio_url, audio_md5 = assets['audio']
124 cover_url = assets['cover'] 266 cover_url = assets['cover']
125 lyric_url = assets['lyric'] 267 lyric_url = assets['lyric']
...@@ -248,6 +390,7 @@ def _prepare_kugou_payload(hk_row: dict, pr: dict, bucket, base_url, sp: dict, s ...@@ -248,6 +390,7 @@ def _prepare_kugou_payload(hk_row: dict, pr: dict, bucket, base_url, sp: dict, s
248 ) 390 )
249 ) 391 )
250 assets = _run_io_tasks(tasks) 392 assets = _run_io_tasks(tasks)
393 _require_primary_assets(assets)
251 audio_url, audio_md5 = assets['audio'] 394 audio_url, audio_md5 = assets['audio']
252 cover_url = assets['cover'] 395 cover_url = assets['cover']
253 lyric_url = assets['lyric'] 396 lyric_url = assets['lyric']
...@@ -359,6 +502,7 @@ def _prepare_netease_payload(hk_row: dict, pr: dict, bucket, base_url, sp: dict, ...@@ -359,6 +502,7 @@ def _prepare_netease_payload(hk_row: dict, pr: dict, bucket, base_url, sp: dict,
359 ) 502 )
360 ) 503 )
361 assets = _run_io_tasks(tasks) 504 assets = _run_io_tasks(tasks)
505 _require_primary_assets(assets)
362 audio_url, audio_md5 = assets['audio'] 506 audio_url, audio_md5 = assets['audio']
363 cover_url = assets['cover'] 507 cover_url = assets['cover']
364 lyric_url = assets['lyric'] 508 lyric_url = assets['lyric']
...@@ -571,7 +715,7 @@ def _process_qq( ...@@ -571,7 +715,7 @@ def _process_qq(
571 bucket, base_url 715 bucket, base_url
572 ), 716 ),
573 'cover': lambda: _safe_transfer( 717 'cover': lambda: _safe_transfer(
574 hk_row.get('cover_url') or sp.get('cover', ''), 718 _qq_cover_source(hk_row, sp),
575 build_oss_key('qq', 'cover', str(song_id_int) + '.jpg'), 719 build_oss_key('qq', 'cover', str(song_id_int) + '.jpg'),
576 bucket, base_url 720 bucket, base_url
577 ), 721 ),
...@@ -592,6 +736,7 @@ def _process_qq( ...@@ -592,6 +736,7 @@ def _process_qq(
592 ) 736 )
593 ) 737 )
594 assets = _run_io_tasks(tasks) 738 assets = _run_io_tasks(tasks)
739 _require_primary_assets(assets)
595 audio_url, audio_md5 = assets['audio'] 740 audio_url, audio_md5 = assets['audio']
596 cover_url = assets['cover'] 741 cover_url = assets['cover']
597 lyric_url = assets['lyric'] 742 lyric_url = assets['lyric']
...@@ -747,6 +892,7 @@ def _process_kugou( ...@@ -747,6 +892,7 @@ def _process_kugou(
747 ) 892 )
748 ) 893 )
749 assets = _run_io_tasks(tasks) 894 assets = _run_io_tasks(tasks)
895 _require_primary_assets(assets)
750 audio_url, audio_md5 = assets['audio'] 896 audio_url, audio_md5 = assets['audio']
751 cover_url = assets['cover'] 897 cover_url = assets['cover']
752 lyric_url = assets['lyric'] 898 lyric_url = assets['lyric']
...@@ -881,6 +1027,7 @@ def _process_netease( ...@@ -881,6 +1027,7 @@ def _process_netease(
881 ) 1027 )
882 ) 1028 )
883 assets = _run_io_tasks(tasks) 1029 assets = _run_io_tasks(tasks)
1030 _require_primary_assets(assets)
884 audio_url, audio_md5 = assets['audio'] 1031 audio_url, audio_md5 = assets['audio']
885 cover_url = assets['cover'] 1032 cover_url = assets['cover']
886 lyric_url = assets['lyric'] 1033 lyric_url = assets['lyric']
...@@ -1051,6 +1198,19 @@ def initialize_yinyan_song_records(platforms: list[str], max_batches: int | None ...@@ -1051,6 +1198,19 @@ def initialize_yinyan_song_records(platforms: list[str], max_batches: int | None
1051 insert_yinyan_song_records(pg_cur, init_rows) 1198 insert_yinyan_song_records(pg_cur, init_rows)
1052 pg_conn.commit() 1199 pg_conn.commit()
1053 total += len(init_rows) 1200 total += len(init_rows)
1201
1202 # 本批次中无法匹配到任何录音的歌曲 → 软删 hk_songs_test
1203 matched_src_ids = {int(hk_row['source_song_id']) for hk_row in batch
1204 if hk_row.get('source_song_id') and int(hk_row['source_song_id']) in pr_by_song}
1205 unmatched_ids = [
1206 int(hk_row['source_song_id']) for hk_row in batch
1207 if hk_row.get('source_song_id') and int(hk_row['source_song_id']) not in matched_src_ids
1208 and int(hk_row['source_song_id']) not in existing_song_ids
1209 ]
1210 if unmatched_ids:
1211 mark_hk_songs_deleted(hk_conn, unmatched_ids)
1212 hk_conn.commit()
1213 log.info("Init: marked %d unmatched songs as deleted", len(unmatched_ids))
1054 finally: 1214 finally:
1055 hk_conn.close() 1215 hk_conn.close()
1056 src_conn.close() 1216 src_conn.close()
...@@ -1340,6 +1500,114 @@ def backfill_empty_singers(platforms: list[str], max_batches: int | None = None) ...@@ -1340,6 +1500,114 @@ def backfill_empty_singers(platforms: list[str], max_batches: int | None = None)
1340 pg_conn.close() 1500 pg_conn.close()
1341 1501
1342 1502
1503 def _is_target_oss_url(url: str | None, base_url: str) -> bool:
1504 return bool(url and str(url).startswith(base_url.rstrip('/') + '/'))
1505
1506
1507 def backfill_qq_invalid_covers(max_batches: int | None = None) -> None:
1508 """替换 QQ 无专辑占位封面的录音;无候选时删除 crawler 记录并软删 HK 歌曲。"""
1509 hk_conn = get_hk_songs_conn()
1510 src_conn = get_source_conn()
1511 spider_conn = get_spider_conn()
1512 pg_conn = get_pg_conn()
1513 bucket = get_oss_bucket()
1514 base_url = OSS_CONFIG['base_url']
1515 batch_index = replaced = filtered = retry_later = 0
1516 pbar = tqdm(desc='backfill-qq-invalid-covers')
1517 try:
1518 while max_batches is None or batch_index < max_batches:
1519 with pg_conn.cursor() as pg_cur:
1520 invalid_rows = fetch_qq_songs_with_invalid_covers(
1521 pg_cur, QQ_MISSING_ALBUM_COVER, BATCH_SIZE,
1522 )
1523 if not invalid_rows:
1524 break
1525
1526 source_ids = [row['song_id'] for row in invalid_rows]
1527 hk_by_song = fetch_hk_songs_by_source_ids(hk_conn, source_ids)
1528 records_by_song: dict[int, list[dict]] = {}
1529 for record in fetch_all_platform_records(src_conn, source_ids):
1530 if record['platform'] == PLATFORM_QQ:
1531 records_by_song.setdefault(int(record['source_song_id']), []).append(record)
1532
1533 mids = list({record['platform_unique_key'] for records in records_by_song.values() for record in records})
1534 songs_by_mid = fetch_qq_songs(spider_conn, mids) if mids else {}
1535 qq_song_ids = [song['id'] for song in songs_by_mid.values()]
1536 singers_by_song_id = fetch_qq_singers(spider_conn, qq_song_ids) if qq_song_ids else {}
1537
1538 replacements: list[tuple[dict, dict, dict]] = []
1539 removals: list[dict] = []
1540 for row in invalid_rows:
1541 hk_row = hk_by_song.get(row['song_id'])
1542 candidates = [
1543 record for record in records_by_song.get(row['song_id'], [])
1544 if (song := songs_by_mid.get(record['platform_unique_key']))
1545 and song.get('album_id')
1546 and _is_usable_qq_cover(song.get('cover'))
1547 ]
1548 if not candidates:
1549 removals.append(row)
1550 continue
1551 if not hk_row:
1552 log.warning('QQ cover backfill cannot find hk_songs_test row: source_song_id=%s', row['song_id'])
1553 retry_later += 1
1554 continue
1555
1556 payload = None
1557 selected_candidate = None
1558 for candidate in candidates:
1559 song = songs_by_mid[candidate['platform_unique_key']]
1560 candidate_payload = _prepare_qq_payload(
1561 hk_row, candidate, bucket, base_url, song,
1562 singers_by_song_id.get(song['id'], []),
1563 )
1564 cover = candidate_payload['songs'][0]['cover']
1565 if _is_target_oss_url(cover, base_url):
1566 payload = candidate_payload
1567 selected_candidate = candidate
1568 break
1569 log.warning(
1570 'QQ cover replacement download failed: source_song_id=%s candidate_mid=%s',
1571 row['song_id'], candidate['platform_unique_key'],
1572 )
1573 if payload is None:
1574 retry_later += 1
1575 continue
1576 replacements.append((row, selected_candidate, payload))
1577
1578 with pg_conn.cursor() as pg_cur:
1579 for row, candidate, payload in replacements:
1580 song = payload['songs'][0]
1581 upsert_qq_singers(pg_cur, payload['singers'])
1582 upsert_qq_albums(pg_cur, payload['albums'])
1583 replace_qq_song(pg_cur, row['platform_song_id'], song)
1584 pg_cur.execute('DELETE FROM crawler_qqmusic_singer_songs WHERE song_id = %s', (row['id'],))
1585 upsert_qq_singer_songs(pg_cur, [(sg['singer_id'], row['id']) for sg in payload['singers']])
1586 upsert_qq_singer_albums(pg_cur, payload['singer_albums'])
1587 replace_yinyan_song_record(
1588 pg_cur, row['song_id'], row['platform_song_id'],
1589 int(candidate['record_id']), int(song['platform_song_id']),
1590 )
1591 for row in removals:
1592 delete_qq_song_and_yinyan_record(pg_cur, row['song_id'], row['platform_song_id'], row['id'])
1593 pg_conn.commit()
1594
1595 if removals:
1596 mark_hk_songs_deleted(hk_conn, [row['song_id'] for row in removals])
1597 hk_conn.commit()
1598 replaced += len(replacements)
1599 filtered += len(removals)
1600 batch_index += 1
1601 pbar.update(1)
1602 finally:
1603 pbar.close()
1604 hk_conn.close()
1605 src_conn.close()
1606 spider_conn.close()
1607 pg_conn.close()
1608 log.info('QQ invalid cover backfill: replaced=%d filtered=%d retry_later=%d', replaced, filtered, retry_later)
1609
1610
1343 def run( 1611 def run(
1344 platforms: list[str], 1612 platforms: list[str],
1345 max_batches: int | None = None, 1613 max_batches: int | None = None,
...@@ -1372,10 +1640,37 @@ def run( ...@@ -1372,10 +1640,37 @@ def run(
1372 key = (int(pr['source_song_id']), int(pr['record_id']), pr['platform']) 1640 key = (int(pr['source_song_id']), int(pr['record_id']), pr['platform'])
1373 pr_by_state_key[key] = pr 1641 pr_by_state_key[key] = pr
1374 1642
1643 # 各平台在当前录音歌词或封面不可用时,需要同源的其他录音作为候选。
1644 all_source_ids = [int(pending['song_id']) for pending in pending_records]
1645 all_candidates = fetch_all_platform_records(src_conn, all_source_ids) if all_source_ids else []
1646 qq_records_by_song: dict[int, list[dict]] = {}
1647 kugou_records_by_song: dict[int, list[dict]] = {}
1648 netease_records_by_song: dict[int, list[dict]] = {}
1649 for candidate in all_candidates:
1650 sid = int(candidate['source_song_id'])
1651 if candidate['platform'] == PLATFORM_QQ:
1652 qq_records_by_song.setdefault(sid, []).append(candidate)
1653 elif candidate['platform'] == PLATFORM_KUGOU:
1654 kugou_records_by_song.setdefault(sid, []).append(candidate)
1655 elif candidate['platform'] == PLATFORM_NETEASE:
1656 netease_records_by_song.setdefault(sid, []).append(candidate)
1657
1375 # ── 批量预取 spider 数据(整批一次 IN 查询,不逐首调用)────────── 1658 # ── 批量预取 spider 数据(整批一次 IN 查询,不逐首调用)──────────
1376 qq_mids = list({pr['platform_unique_key'] for pr in platform_records if pr['platform'] == PLATFORM_QQ}) 1659 qq_mids = list({
1377 kugou_ids = list({int(pr['platform_unique_key']) for pr in platform_records if pr['platform'] == PLATFORM_KUGOU}) 1660 pr['platform_unique_key'] for pr in platform_records if pr['platform'] == PLATFORM_QQ
1378 netease_ids = list({int(pr['platform_unique_key']) for pr in platform_records if pr['platform'] == PLATFORM_NETEASE}) 1661 } | {
1662 pr['platform_unique_key'] for records in qq_records_by_song.values() for pr in records
1663 })
1664 kugou_ids = list({
1665 int(pr['platform_unique_key']) for pr in platform_records if pr['platform'] == PLATFORM_KUGOU
1666 } | {
1667 int(pr['platform_unique_key']) for records in kugou_records_by_song.values() for pr in records
1668 })
1669 netease_ids = list({
1670 int(pr['platform_unique_key']) for pr in platform_records if pr['platform'] == PLATFORM_NETEASE
1671 } | {
1672 int(pr['platform_unique_key']) for records in netease_records_by_song.values() for pr in records
1673 })
1379 1674
1380 qq_songs_map = fetch_qq_songs(spider_conn, qq_mids) if qq_mids else {} 1675 qq_songs_map = fetch_qq_songs(spider_conn, qq_mids) if qq_mids else {}
1381 kugou_songs_map = fetch_kugou_songs(spider_conn, kugou_ids) if kugou_ids else {} 1676 kugou_songs_map = fetch_kugou_songs(spider_conn, kugou_ids) if kugou_ids else {}
...@@ -1398,6 +1693,7 @@ def run( ...@@ -1398,6 +1693,7 @@ def run(
1398 } 1693 }
1399 1694
1400 prepare_inputs = [] 1695 prepare_inputs = []
1696 rejected_pending: list[dict] = []
1401 for pending in pending_records: 1697 for pending in pending_records:
1402 src_id = int(pending['song_id']) 1698 src_id = int(pending['song_id'])
1403 platform = str(pending['platform']) 1699 platform = str(pending['platform'])
...@@ -1411,9 +1707,37 @@ def run( ...@@ -1411,9 +1707,37 @@ def run(
1411 src_id, pending['record_id'], platform, 1707 src_id, pending['record_id'], platform,
1412 ) 1708 )
1413 continue 1709 continue
1710 if platform == PLATFORM_QQ:
1711 selected = _select_qq_import_record(
1712 hk_row, pr, qq_records_by_song.get(src_id, []), qq_songs_map,
1713 )
1714 elif platform == PLATFORM_KUGOU:
1715 selected = _select_kugou_import_record(
1716 hk_row, pr, kugou_records_by_song.get(src_id, []), kugou_songs_map,
1717 )
1718 elif platform == PLATFORM_NETEASE:
1719 selected = _select_netease_import_record(
1720 hk_row, pr, netease_records_by_song.get(src_id, []), netease_songs_map,
1721 )
1722 else:
1723 selected = pr
1724 if selected is None:
1725 log.warning(
1726 'Skip import without usable lyric/cover candidate: platform=%s source_song_id=%s record_id=%s',
1727 platform, src_id, pending['record_id'],
1728 )
1729 rejected_pending.append(pending)
1730 continue
1731 if selected['record_id'] != pr['record_id']:
1732 log.info(
1733 'Replace import record: platform=%s source_song_id=%s old_record_id=%s new_record_id=%s',
1734 platform, src_id, pr['record_id'], selected['record_id'],
1735 )
1736 pr = selected
1414 prepare_inputs.append((pending, hk_row, pr)) 1737 prepare_inputs.append((pending, hk_row, pr))
1415 1738
1416 payloads = [] 1739 payloads = []
1740 asset_failed_pending: list[dict] = []
1417 max_workers = min(MAX_IMPORT_WORKERS, len(prepare_inputs)) if prepare_inputs else 0 1741 max_workers = min(MAX_IMPORT_WORKERS, len(prepare_inputs)) if prepare_inputs else 0
1418 if max_workers: 1742 if max_workers:
1419 with ThreadPoolExecutor(max_workers=max_workers) as executor: 1743 with ThreadPoolExecutor(max_workers=max_workers) as executor:
...@@ -1433,16 +1757,48 @@ def run( ...@@ -1433,16 +1757,48 @@ def run(
1433 total_ok += 1 1757 total_ok += 1
1434 else: 1758 else:
1435 total_err += 1 1759 total_err += 1
1760 asset_failed_pending.append(pending)
1761 log.warning(
1762 "Skip song because payload is empty: %s platform %s song_id=%s",
1763 hk_row.get('name'), pr['platform'], pending['song_id'],
1764 )
1765 except RequiredAssetTransferError as e:
1766 total_err += 1
1767 asset_failed_pending.append(pending)
1768 log.warning(
1769 "Skip song because required asset transfer failed: %s platform %s: %s",
1770 hk_row.get('name'), pr['platform'], e,
1771 )
1436 except Exception as e: 1772 except Exception as e:
1437 total_err += 1 1773 total_err += 1
1438 log.error("Error preparing song %s platform %s: %s", 1774 asset_failed_pending.append(pending)
1439 hk_row.get('name'), pr['platform'], e) 1775 log.error(
1776 "Skip song because unexpected error: %s platform %s: %s",
1777 hk_row.get('name'), pr['platform'], e,
1778 )
1440 1779
1780 discarded_pending = rejected_pending + asset_failed_pending
1441 if payloads: 1781 if payloads:
1442 with pg_conn.cursor() as pg_cur: 1782 with pg_conn.cursor() as pg_cur:
1443 _write_import_payloads(pg_cur, payloads) 1783 _write_import_payloads(pg_cur, payloads)
1784 for pending in discarded_pending:
1785 mark_yinyan_record_resource_failed(
1786 pg_cur, int(pending['song_id']), int(pending['record_id']), str(pending['platform']),
1787 )
1444 pg_conn.commit() 1788 pg_conn.commit()
1789 if discarded_pending:
1790 mark_hk_songs_deleted(hk_conn, [int(pending['song_id']) for pending in discarded_pending])
1791 hk_conn.commit()
1445 imported.extend(payload['result'] for payload in payloads) 1792 imported.extend(payload['result'] for payload in payloads)
1793 elif discarded_pending:
1794 with pg_conn.cursor() as pg_cur:
1795 for pending in discarded_pending:
1796 mark_yinyan_record_resource_failed(
1797 pg_cur, int(pending['song_id']), int(pending['record_id']), str(pending['platform']),
1798 )
1799 pg_conn.commit()
1800 mark_hk_songs_deleted(hk_conn, [int(pending['song_id']) for pending in discarded_pending])
1801 hk_conn.commit()
1446 else: 1802 else:
1447 log.error("No import payloads were prepared in this batch; stopping to avoid retry loop") 1803 log.error("No import payloads were prepared in this batch; stopping to avoid retry loop")
1448 break 1804 break
......
...@@ -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'},
......