Commit 6d8ccd04 6d8ccd044b9b30fc5cc811233db4dd41ac4e6d5a by 沈秋雨

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

- 新增HTTP连接池配置,提升请求性能和资源复用
- 使用requests Session统一管理HTTP连接,减少请求开销
- 实现_url清洗函数,过滤无效或非法URL
- 并行执行音频、封面、歌词及歌手头像的OSS转存任务,提升效率
- 按平台统一构建导入数据payload,简化处理流程
- 批量写入歌曲、歌手、专辑及关联关系,提升数据库操作性能
- 替换原平台单独处理函数为统一预处理及写入方法
- 取消原单线程顺序处理,改为线程池并发执行数据准备和导入
- 获取平台歌曲及歌手信息支持传入缓存参数,减少重复查询
- 优化异常处理及日志记录,提高稳定性和可维护性
- 新增歌手索引和性别等字段辅助函数,完善歌手信息处理
1 parent d3694c28
...@@ -50,3 +50,5 @@ PLATFORMS = [PLATFORM_QQ, PLATFORM_KUGOU, PLATFORM_NETEASE] ...@@ -50,3 +50,5 @@ PLATFORMS = [PLATFORM_QQ, PLATFORM_KUGOU, PLATFORM_NETEASE]
50 50
51 BATCH_SIZE = 100 51 BATCH_SIZE = 100
52 BACKFILL_BATCH_SIZE = int(os.environ.get('BACKFILL_BATCH_SIZE', '5000')) 52 BACKFILL_BATCH_SIZE = int(os.environ.get('BACKFILL_BATCH_SIZE', '5000'))
53 HTTP_POOL_MAXSIZE = int(os.environ.get('HTTP_POOL_MAXSIZE', '128'))
54 OSS_CONNECTION_POOL_SIZE = int(os.environ.get('OSS_CONNECTION_POOL_SIZE', str(HTTP_POOL_MAXSIZE)))
......
...@@ -2,7 +2,7 @@ import pymysql ...@@ -2,7 +2,7 @@ import pymysql
2 import pymysql.cursors 2 import pymysql.cursors
3 import pg8000 3 import pg8000
4 import oss2 4 import oss2
5 from .config import SOURCE_DB, HK_SONGS_DB, CRAWLER_DB, OSS_CONFIG 5 from .config import SOURCE_DB, HK_SONGS_DB, CRAWLER_DB, OSS_CONFIG, OSS_CONNECTION_POOL_SIZE
6 6
7 7
8 def get_source_conn() -> pymysql.Connection: 8 def get_source_conn() -> pymysql.Connection:
...@@ -26,5 +26,6 @@ def get_pg_conn() -> pg8000.Connection: ...@@ -26,5 +26,6 @@ def get_pg_conn() -> pg8000.Connection:
26 26
27 27
28 def get_oss_bucket() -> oss2.Bucket: 28 def get_oss_bucket() -> oss2.Bucket:
29 oss2.defaults.connection_pool_size = OSS_CONNECTION_POOL_SIZE
29 auth = oss2.Auth(OSS_CONFIG['access_key_id'], OSS_CONFIG['access_key_secret']) 30 auth = oss2.Auth(OSS_CONFIG['access_key_id'], OSS_CONFIG['access_key_secret'])
30 return oss2.Bucket(auth, OSS_CONFIG['endpoint'], OSS_CONFIG['bucket_name']) 31 return oss2.Bucket(auth, OSS_CONFIG['endpoint'], OSS_CONFIG['bucket_name'])
......
1 import requests 1 import requests
2 import oss2 2 import oss2
3 import math
3 from urllib.parse import urlparse 4 from urllib.parse import urlparse
4 from .config import OSS_CONFIG 5 from requests.adapters import HTTPAdapter
6 from .config import HTTP_POOL_MAXSIZE, OSS_CONFIG
5 from .utils import compute_audio_md5 7 from .utils import compute_audio_md5
6 8
9 _HTTP_SESSION = requests.Session()
10 _HTTP_ADAPTER = HTTPAdapter(pool_connections=HTTP_POOL_MAXSIZE, pool_maxsize=HTTP_POOL_MAXSIZE)
11 _HTTP_SESSION.mount('http://', _HTTP_ADAPTER)
12 _HTTP_SESSION.mount('https://', _HTTP_ADAPTER)
13
14
15 def _http_get(url: str, timeout: int = 30):
16 return _HTTP_SESSION.get(url, timeout=timeout)
17
18
19 def _clean_url(url) -> str:
20 if url is None:
21 return ''
22 if isinstance(url, float) and math.isnan(url):
23 return ''
24 text = str(url).strip()
25 if not text or text.lower() in {'nan', 'none', 'null'}:
26 return ''
27 parsed = urlparse(text)
28 if parsed.scheme not in {'http', 'https'} or not parsed.netloc:
29 return ''
30 return text
31
7 32
8 def _host(url: str | None) -> str: 33 def _host(url: str | None) -> str:
9 return urlparse(url or '').netloc.lower() 34 return urlparse(url or '').netloc.lower()
...@@ -28,11 +53,12 @@ def transfer_url(url: str | None, oss_key: str, bucket: oss2.Bucket, base_url: s ...@@ -28,11 +53,12 @@ def transfer_url(url: str | None, oss_key: str, bucket: oss2.Bucket, base_url: s
28 若 url 为空或已在目标 bucket,直接返回原 url(不上传)。 53 若 url 为空或已在目标 bucket,直接返回原 url(不上传)。
29 返回新的公开访问 URL。 54 返回新的公开访问 URL。
30 """ 55 """
56 url = _clean_url(url)
31 if not url: 57 if not url:
32 return '' 58 return ''
33 if _is_target_oss_url(url, base_url): 59 if _is_target_oss_url(url, base_url):
34 return url 60 return url
35 resp = requests.get(_download_url(url, base_url), timeout=30) 61 resp = _http_get(_download_url(url, base_url), timeout=30)
36 resp.raise_for_status() 62 resp.raise_for_status()
37 bucket.put_object(oss_key, resp.content) 63 bucket.put_object(oss_key, resp.content)
38 return f"{base_url.rstrip('/')}/{oss_key}" 64 return f"{base_url.rstrip('/')}/{oss_key}"
...@@ -43,11 +69,12 @@ def transfer_url_with_md5(url: str | None, oss_key: str, bucket: oss2.Bucket, ba ...@@ -43,11 +69,12 @@ def transfer_url_with_md5(url: str | None, oss_key: str, bucket: oss2.Bucket, ba
43 将音频 URL 转存到 OSS,并基于下载到的音频字节计算 MD5。 69 将音频 URL 转存到 OSS,并基于下载到的音频字节计算 MD5。
44 已在目标 OSS 的 URL 无需重新下载,无法可靠计算 MD5,返回空 MD5。 70 已在目标 OSS 的 URL 无需重新下载,无法可靠计算 MD5,返回空 MD5。
45 """ 71 """
72 url = _clean_url(url)
46 if not url: 73 if not url:
47 return '', '' 74 return '', ''
48 if _is_target_oss_url(url, base_url): 75 if _is_target_oss_url(url, base_url):
49 return url, '' 76 return url, ''
50 resp = requests.get(_download_url(url, base_url), timeout=30) 77 resp = _http_get(_download_url(url, base_url), timeout=30)
51 resp.raise_for_status() 78 resp.raise_for_status()
52 audio_md5 = compute_audio_md5(resp.content) 79 audio_md5 = compute_audio_md5(resp.content)
53 bucket.put_object(oss_key, resp.content) 80 bucket.put_object(oss_key, resp.content)
......
1 import uuid 1 import uuid
2 import json 2 import json
3 import logging 3 import logging
4 from concurrent.futures import ThreadPoolExecutor, as_completed
4 from tqdm import tqdm 5 from tqdm import tqdm
5 6
6 from .config import PLATFORM_QQ, PLATFORM_KUGOU, PLATFORM_NETEASE, BATCH_SIZE, BACKFILL_BATCH_SIZE, OSS_CONFIG 7 from .config import PLATFORM_QQ, PLATFORM_KUGOU, PLATFORM_NETEASE, BATCH_SIZE, BACKFILL_BATCH_SIZE, OSS_CONFIG
...@@ -41,6 +42,20 @@ from .lyric import ensure_newlines ...@@ -41,6 +42,20 @@ from .lyric import ensure_newlines
41 logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s') 42 logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s')
42 log = logging.getLogger(__name__) 43 log = logging.getLogger(__name__)
43 PROVIDER_YINYAN = 'yinyan' 44 PROVIDER_YINYAN = 'yinyan'
45 MAX_RESOURCE_WORKERS = 8
46 MAX_IMPORT_WORKERS = 16
47
48
49 def _run_io_tasks(tasks: dict) -> dict:
50 if not tasks:
51 return {}
52 max_workers = min(MAX_RESOURCE_WORKERS, len(tasks))
53 results = {}
54 with ThreadPoolExecutor(max_workers=max_workers) as executor:
55 futures = {executor.submit(task): name for name, task in tasks.items()}
56 for future in as_completed(futures):
57 results[futures[future]] = future.result()
58 return results
44 59
45 60
46 def _safe_transfer(url, oss_key, bucket, base_url): 61 def _safe_transfer(url, oss_key, bucket, base_url):
...@@ -75,47 +90,515 @@ def _source_json(data: dict) -> str: ...@@ -75,47 +90,515 @@ def _source_json(data: dict) -> str:
75 return json.dumps(data, ensure_ascii=False, default=_json_default) 90 return json.dumps(data, ensure_ascii=False, default=_json_default)
76 91
77 92
78 def _process_qq(hk_row: dict, pr: dict, spider_conn, pg_cur, bucket, base_url): 93 def _prepare_qq_payload(hk_row: dict, pr: dict, bucket, base_url, sp: dict, singer_list: list[dict]) -> dict:
79 mid = pr['platform_unique_key'] 94 mid = pr['platform_unique_key']
95 song_id_int = sp['id']
96 raw_lyric = sp.get('lyric') or ''
97 tasks = {
98 'audio': lambda: _safe_transfer_audio(
99 hk_row['audio_url'], build_oss_key('qq', 'audio', mid + '.mp3'), bucket, base_url
100 ),
101 'cover': lambda: _safe_transfer(
102 hk_row.get('cover_url') or sp.get('cover', ''),
103 build_oss_key('qq', 'cover', str(song_id_int) + '.jpg'),
104 bucket, base_url,
105 ),
106 'lyric': lambda: _safe_upload_lyric('qq', mid, raw_lyric, hk_row.get('lyrics_url'), bucket, base_url),
107 }
108 if sp.get('album_id') and sp.get('album_cover'):
109 tasks['album_cover'] = lambda: _safe_transfer(
110 sp['album_cover'], build_oss_key('qq', 'album', str(sp['album_id']) + '.jpg'), bucket, base_url
111 )
112 for sg in singer_list:
113 tasks[f"singer_avatar:{sg['singer_id']}"] = (
114 lambda sg=sg: _safe_transfer(
115 sg.get('avatar', ''),
116 build_oss_key('qq', 'singer', sg['mid'] + '.jpg'),
117 bucket, base_url,
118 )
119 )
120 assets = _run_io_tasks(tasks)
121 audio_url, audio_md5 = assets['audio']
122 cover_url = assets['cover']
123 lyric_url = assets['lyric']
124 album_cover = assets.get('album_cover') or cover_url
125
126 singer_rows = [{
127 **sg,
128 'id': sg['singer_id'],
129 'avatar': assets.get(f"singer_avatar:{sg['singer_id']}", sg.get('avatar', '')),
130 'provider_name': PROVIDER_YINYAN,
131 'crawler_source_data': _source_json(sg),
132 } for sg in singer_list]
133 singers_json = json.dumps([{
134 'name': sg['name'],
135 'singer_id': sg['singer_id'],
136 'platform_singer_id': sg['mid'],
137 } for sg in singer_list], ensure_ascii=False)
138
139 album_rows = []
140 album_id = None
141 album_json = None
142 if sp.get('album_id'):
143 album_id = sp['album_id']
144 album_payload = {
145 'id': sp['album_id'],
146 'mid': sp.get('album_mid') or '',
147 'cover': album_cover,
148 'title': sp.get('album_title') or '',
149 'intro': sp.get('album_intro'),
150 'type': sp.get('album_type') or '',
151 'company_id': sp.get('company_id') or 0,
152 'company': sp.get('company') or '',
153 'is_owner': sp.get('is_owner') or 0,
154 'published_at': str(sp.get('album_published_at')) if sp.get('album_published_at') else None,
155 }
156 album_json = json.dumps(album_payload, ensure_ascii=False)
157 album_rows.append({
158 'id': sp['album_id'],
159 'mid': sp.get('album_mid') or '',
160 'cover': album_cover,
161 'title': sp.get('album_title') or '',
162 'intro': sp.get('album_intro'),
163 'type': sp.get('album_type') or '',
164 'company_id': sp.get('company_id') or 0,
165 'company': sp.get('company') or '',
166 'is_owner': sp.get('is_owner') or 0,
167 'published_at': sp.get('album_published_at'),
168 'provider_name': PROVIDER_YINYAN,
169 'crawler_source_data': _source_json({
170 'id': sp.get('album_id'),
171 'mid': sp.get('album_mid'),
172 'cover': sp.get('album_cover'),
173 'title': sp.get('album_title'),
174 'intro': sp.get('album_intro'),
175 'type': sp.get('album_type'),
176 'company_id': sp.get('company_id'),
177 'company': sp.get('company'),
178 'is_owner': sp.get('is_owner'),
179 'published_at': sp.get('album_published_at'),
180 }),
181 })
182
183 platform_song_id = int(pr['platform_mid']) if pr.get('platform_mid') else song_id_int
184 song_uuid = str(uuid.uuid4())
185 title, version = split_title_version(sp.get('title') or hk_row['name'])
186 song_row = {
187 'song_uuid': song_uuid,
188 'platform_song_id': platform_song_id,
189 'mid': mid,
190 'album_id': album_id,
191 'album_json': album_json,
192 'cover': cover_url,
193 'title': title,
194 'version': version,
195 'name': hk_row['name'],
196 'duration': sp.get('duration') or hk_row.get('song_time') or 0,
197 'lyric': ensure_newlines(raw_lyric),
198 'composer_name': sp.get('composer_name') or hk_row.get('composer'),
199 'lyricist_name': sp.get('lyricist_name') or hk_row.get('lyricist'),
200 'url': audio_url,
201 'audio_md5': audio_md5,
202 'lyric_url': lyric_url,
203 'platform_index_url': sp.get('platform_index_url') or f'https://y.qq.com/n/ryqq/songDetail/{mid}',
204 'published_at': sp.get('published_at') or hk_row.get('issue_time'),
205 'singers_json': singers_json,
206 'provider_name': PROVIDER_YINYAN,
207 'crawler_source_data': _source_json(sp),
208 }
209 return {
210 'platform': PLATFORM_QQ,
211 'display_platform': 'qq',
212 'platform_song_id': platform_song_id,
213 'result': {'platform': 'qq', 'platform_song_id': platform_song_id, 'mid': mid, 'title': hk_row['name']},
214 'singers': singer_rows,
215 'albums': album_rows,
216 'songs': [song_row],
217 'singer_songs': [(sg['singer_id'], song_uuid) for sg in singer_list],
218 'singer_albums': [(sg['singer_id'], album_id) for sg in singer_list] if album_id else [],
219 }
220
221
222 def _prepare_kugou_payload(hk_row: dict, pr: dict, bucket, base_url, sp: dict, singer_list: list[dict]) -> dict:
223 song_id = int(pr['platform_unique_key'])
224 raw_lyric = sp.get('lyric') or ''
225 tasks = {
226 'audio': lambda: _safe_transfer_audio(
227 hk_row['audio_url'], build_oss_key('kugou', 'audio', str(song_id) + '.mp3'), bucket, base_url
228 ),
229 'cover': lambda: _safe_transfer(
230 hk_row.get('cover_url') or sp.get('cover', ''),
231 build_oss_key('kugou', 'cover', str(song_id) + '.jpg'),
232 bucket, base_url,
233 ),
234 'lyric': lambda: _safe_upload_lyric('kugou', str(song_id), raw_lyric, hk_row.get('lyrics_url'), bucket, base_url),
235 }
236 if sp.get('album_id') and sp.get('album_cover'):
237 tasks['album_cover'] = lambda: _safe_transfer(
238 sp['album_cover'], build_oss_key('kugou', 'album', str(sp['album_id']) + '.jpg'), bucket, base_url
239 )
240 for sg in singer_list:
241 tasks[f"singer_avatar:{sg['singer_id']}"] = (
242 lambda sg=sg: _safe_transfer(
243 sg.get('avatar', ''),
244 build_oss_key('kugou', 'singer', str(sg['singer_id']) + '.jpg'),
245 bucket, base_url,
246 )
247 )
248 assets = _run_io_tasks(tasks)
249 audio_url, audio_md5 = assets['audio']
250 cover_url = assets['cover']
251 lyric_url = assets['lyric']
252 album_cover = assets.get('album_cover') or cover_url
253
254 singer_rows = [{
255 **sg,
256 'id': sg['singer_id'],
257 'avatar': assets.get(f"singer_avatar:{sg['singer_id']}", sg.get('avatar', '')),
258 'provider_name': PROVIDER_YINYAN,
259 'crawler_source_data': _source_json(sg),
260 } for sg in singer_list]
261 singers_json = json.dumps([{
262 'name': sg['name'],
263 'singer_id': sg['singer_id'],
264 'platform_singer_id': str(sg['singer_id']),
265 } for sg in singer_list], ensure_ascii=False)
266
267 album_rows = []
268 album_id = None
269 if sp.get('album_id'):
270 album_id = sp['album_id']
271 album_rows.append({
272 'id': sp['album_id'],
273 'cover': album_cover,
274 'title': sp.get('album_title') or '',
275 'intro': sp.get('album_intro'),
276 'type': sp.get('album_type') or '',
277 'company_id': sp.get('company_id') or 0,
278 'company': sp.get('company') or '',
279 'is_owner': sp.get('is_owner') or 0,
280 'published_at': sp.get('album_published_at'),
281 'provider_name': PROVIDER_YINYAN,
282 'crawler_source_data': _source_json({
283 'id': sp.get('album_id'),
284 'cover': sp.get('album_cover'),
285 'title': sp.get('album_title'),
286 'intro': sp.get('album_intro'),
287 'type': sp.get('album_type'),
288 'company_id': sp.get('company_id'),
289 'company': sp.get('company'),
290 'is_owner': sp.get('is_owner'),
291 'published_at': sp.get('album_published_at'),
292 }),
293 })
294
295 song_uuid = str(uuid.uuid4())
296 title, version = split_title_version(sp.get('title') or hk_row['name'])
297 song_row = {
298 'song_uuid': song_uuid,
299 'platform_song_id': song_id,
300 'hash': sp.get('hid', pr.get('platform_mid', '')),
301 'album_audio_id': sp.get('album_audio_id') or pr.get('album_audio_id') or 0,
302 'album_id': album_id,
303 'cover': cover_url,
304 'title': title,
305 'version': version,
306 'name': hk_row['name'],
307 'duration': sp.get('duration') or hk_row.get('song_time') or 0,
308 'lyric': ensure_newlines(raw_lyric),
309 'composer_name': sp.get('composer_name') or hk_row.get('composer'),
310 'lyricist_name': sp.get('lyricist_name') or hk_row.get('lyricist'),
311 'url': audio_url,
312 'audio_md5': audio_md5,
313 'lyric_url': lyric_url,
314 'platform_index_url': sp.get('platform_index_url') or f'http://www.kugou.com/song/#hash={sp.get("hid") or pr.get("platform_mid", "")}',
315 'published_at': sp.get('published_at') or hk_row.get('issue_time'),
316 'singers_json': singers_json,
317 'provider_name': PROVIDER_YINYAN,
318 'crawler_source_data': _source_json(sp),
319 }
320 return {
321 'platform': PLATFORM_KUGOU,
322 'display_platform': 'kugou',
323 'platform_song_id': song_id,
324 'result': {'platform': 'kugou', 'platform_song_id': song_id, 'hash': sp.get('hid', ''), 'title': hk_row['name']},
325 'singers': singer_rows,
326 'albums': album_rows,
327 'songs': [song_row],
328 'singer_songs': [(sg['singer_id'], song_uuid) for sg in singer_list],
329 'singer_albums': [(sg['singer_id'], album_id) for sg in singer_list] if album_id else [],
330 }
331
332
333 def _prepare_netease_payload(hk_row: dict, pr: dict, bucket, base_url, sp: dict, singer_list: list[dict]) -> dict:
334 song_id = int(pr['platform_unique_key'])
335 raw_lyric = sp.get('lyric') or ''
336 tasks = {
337 'audio': lambda: _safe_transfer_audio(
338 hk_row['audio_url'], build_oss_key('netease', 'audio', str(song_id) + '.mp3'), bucket, base_url
339 ),
340 'cover': lambda: _safe_transfer(
341 hk_row.get('cover_url') or sp.get('cover', ''),
342 build_oss_key('netease', 'cover', str(song_id) + '.jpg'),
343 bucket, base_url,
344 ),
345 'lyric': lambda: _safe_upload_lyric('netease', str(song_id), raw_lyric, hk_row.get('lyrics_url'), bucket, base_url),
346 }
347 if sp.get('album_id') and sp.get('album_cover'):
348 tasks['album_cover'] = lambda: _safe_transfer(
349 sp['album_cover'], build_oss_key('netease', 'album', str(sp['album_id']) + '.jpg'), bucket, base_url
350 )
351 for sg in singer_list:
352 tasks[f"singer_avatar:{sg['singer_id']}"] = (
353 lambda sg=sg: _safe_transfer(
354 sg.get('avatar', ''),
355 build_oss_key('netease', 'singer', str(sg['singer_id']) + '.jpg'),
356 bucket, base_url,
357 )
358 )
359 assets = _run_io_tasks(tasks)
360 audio_url, audio_md5 = assets['audio']
361 cover_url = assets['cover']
362 lyric_url = assets['lyric']
363 album_cover = assets.get('album_cover') or cover_url
364
365 singer_rows = [{
366 **sg,
367 'id': sg['singer_id'],
368 'avatar': assets.get(f"singer_avatar:{sg['singer_id']}", sg.get('avatar', '')),
369 'provider_name': PROVIDER_YINYAN,
370 'crawler_source_data': _source_json(sg),
371 } for sg in singer_list]
372 singers_json = json.dumps([{
373 'name': sg['name'],
374 'singer_id': sg['singer_id'],
375 'platform_singer_id': str(sg['singer_id']),
376 } for sg in singer_list], ensure_ascii=False)
377
378 album_rows = []
379 album_id = None
380 album_json = None
381 if sp.get('album_id'):
382 album_id = sp['album_id']
383 album_payload = {
384 'id': sp['album_id'],
385 'cover': album_cover,
386 'title': sp.get('album_title') or '',
387 'intro': sp.get('album_intro'),
388 'type': sp.get('album_type') or '',
389 'company_id': sp.get('company_id') or 0,
390 'company': sp.get('company') or '',
391 'is_owner': sp.get('is_owner') or 0,
392 'published_at': str(sp.get('album_published_at')) if sp.get('album_published_at') else None,
393 }
394 album_json = json.dumps(album_payload, ensure_ascii=False)
395 album_rows.append({
396 'id': sp['album_id'],
397 'cover': album_cover,
398 'title': sp.get('album_title') or '',
399 'intro': sp.get('album_intro'),
400 'type': sp.get('album_type') or '',
401 'company_id': sp.get('company_id') or 0,
402 'company': sp.get('company') or '',
403 'is_owner': sp.get('is_owner') or 0,
404 'published_at': sp.get('album_published_at'),
405 'provider_name': PROVIDER_YINYAN,
406 'crawler_source_data': _source_json({
407 'id': sp.get('album_id'),
408 'cover': sp.get('album_cover'),
409 'title': sp.get('album_title'),
410 'intro': sp.get('album_intro'),
411 'type': sp.get('album_type'),
412 'company_id': sp.get('company_id'),
413 'company': sp.get('company'),
414 'is_owner': sp.get('is_owner'),
415 'published_at': sp.get('album_published_at'),
416 }),
417 })
418
419 song_uuid = str(uuid.uuid4())
420 title, version = split_title_version(sp.get('title') or hk_row['name'])
421 song_row = {
422 'song_uuid': song_uuid,
423 'platform_song_id': song_id,
424 'album_id': album_id,
425 'album_json': album_json,
426 'cover': cover_url,
427 'title': title,
428 'version': version,
429 'name': hk_row['name'],
430 'duration': sp.get('duration') or hk_row.get('song_time') or 0,
431 'lyric': ensure_newlines(raw_lyric),
432 'composer_name': sp.get('composer_name') or hk_row.get('composer'),
433 'lyricist_name': sp.get('lyricist_name') or hk_row.get('lyricist'),
434 'url': audio_url,
435 'audio_md5': audio_md5,
436 'lyric_url': lyric_url,
437 'platform_index_url': sp.get('platform_index_url') or f'https://music.163.com/#/song?id={song_id}',
438 'published_at': sp.get('published_at') or hk_row.get('issue_time'),
439 'singers_json': singers_json,
440 'provider_name': PROVIDER_YINYAN,
441 'crawler_source_data': _source_json(sp),
442 }
443 return {
444 'platform': PLATFORM_NETEASE,
445 'display_platform': 'netease',
446 'platform_song_id': song_id,
447 'result': {'platform': 'netease', 'platform_song_id': song_id, 'title': hk_row['name']},
448 'singers': singer_rows,
449 'albums': album_rows,
450 'songs': [song_row],
451 'singer_songs': [(sg['singer_id'], song_uuid) for sg in singer_list],
452 'singer_albums': [(sg['singer_id'], album_id) for sg in singer_list] if album_id else [],
453 }
454
455
456 _PREPARERS = {
457 PLATFORM_QQ: _prepare_qq_payload,
458 PLATFORM_KUGOU: _prepare_kugou_payload,
459 PLATFORM_NETEASE: _prepare_netease_payload,
460 }
461
462
463 def _prepare_import_payload(
464 pending: dict,
465 hk_row: dict,
466 pr: dict,
467 bucket,
468 base_url: str,
469 songs_maps: dict,
470 singers_maps: dict,
471 ) -> dict | None:
472 platform = str(pending['platform'])
473 preparer = _PREPARERS.get(platform)
474 if not preparer:
475 return None
476 platform_unique_id = pr['platform_unique_key']
477 song_key = platform_unique_id if platform == PLATFORM_QQ else int(platform_unique_id)
478 song_data = songs_maps.get(platform, {}).get(song_key)
479 if not song_data:
480 return None
481 singer_key = int(song_data['id']) if platform == PLATFORM_QQ else int(platform_unique_id)
482 singer_list = singers_maps.get(platform, {}).get(singer_key, [])
483 payload = preparer(hk_row, pr, bucket, base_url, song_data, singer_list)
484 payload['yinyan_record'] = {
485 'song_id': int(pending['song_id']),
486 'record_id': int(pr['record_id']),
487 'platform': platform,
488 'platform_song_id': int(payload['platform_song_id']),
489 }
490 return payload
491
492
493 def _extend_platform_payload(target: dict, payload: dict) -> None:
494 target['singers'].extend(payload.get('singers', []))
495 target['albums'].extend(payload.get('albums', []))
496 target['songs'].extend(payload.get('songs', []))
497 target['singer_songs'].extend(payload.get('singer_songs', []))
498 target['singer_albums'].extend(payload.get('singer_albums', []))
499
500
501 def _write_import_payloads(pg_cur, payloads: list[dict]) -> list[dict]:
502 if not payloads:
503 return []
504 grouped = {
505 PLATFORM_QQ: {'singers': [], 'albums': [], 'songs': [], 'singer_songs': [], 'singer_albums': []},
506 PLATFORM_KUGOU: {'singers': [], 'albums': [], 'songs': [], 'singer_songs': [], 'singer_albums': []},
507 PLATFORM_NETEASE: {'singers': [], 'albums': [], 'songs': [], 'singer_songs': [], 'singer_albums': []},
508 }
509 yinyan_records = []
510 imported = []
511 for payload in payloads:
512 _extend_platform_payload(grouped[payload['platform']], payload)
513 yinyan_records.append(payload['yinyan_record'])
514 imported.append(payload['result'])
515
516 qq = grouped[PLATFORM_QQ]
517 upsert_qq_singers(pg_cur, qq['singers'])
518 upsert_qq_albums(pg_cur, qq['albums'])
519 upsert_qq_songs(pg_cur, qq['songs'])
520 upsert_qq_singer_songs(pg_cur, qq['singer_songs'])
521 upsert_qq_singer_albums(pg_cur, qq['singer_albums'])
522
523 kugou = grouped[PLATFORM_KUGOU]
524 upsert_kugou_singers(pg_cur, kugou['singers'])
525 upsert_kugou_albums(pg_cur, kugou['albums'])
526 upsert_kugou_songs(pg_cur, kugou['songs'])
527 upsert_kugou_singer_songs(pg_cur, kugou['singer_songs'])
528 upsert_kugou_singer_albums(pg_cur, kugou['singer_albums'])
529
530 netease = grouped[PLATFORM_NETEASE]
531 upsert_netease_singers(pg_cur, netease['singers'])
532 upsert_netease_albums(pg_cur, netease['albums'])
533 upsert_netease_songs(pg_cur, netease['songs'])
534 upsert_netease_singer_songs(pg_cur, netease['singer_songs'])
535 upsert_netease_singer_albums(pg_cur, netease['singer_albums'])
536
537 upsert_yinyan_song_records(pg_cur, yinyan_records)
538 return imported
539
540
541 def _process_qq(
542 hk_row: dict,
543 pr: dict,
544 spider_conn,
545 pg_cur,
546 bucket,
547 base_url,
548 song_data: dict | None = None,
549 singer_list: list[dict] | None = None,
550 ):
551 mid = pr['platform_unique_key']
552 sp = song_data
553 if sp is None:
80 songs_map = fetch_qq_songs(spider_conn, [mid]) 554 songs_map = fetch_qq_songs(spider_conn, [mid])
81 if mid not in songs_map: 555 if mid not in songs_map:
82 return 556 return
83 sp = songs_map[mid] 557 sp = songs_map[mid]
84 song_id_int = sp['id'] 558 song_id_int = sp['id']
85 559
560 if singer_list is None:
86 singers_map = fetch_qq_singers(spider_conn, [song_id_int]) 561 singers_map = fetch_qq_singers(spider_conn, [song_id_int])
87 singer_list = singers_map.get(song_id_int, []) 562 singer_list = singers_map.get(song_id_int, [])
88 563
89 # OSS 转移 564 raw_lyric = sp.get('lyric') or ''
90 audio_url, audio_md5 = _safe_transfer_audio( 565 tasks = {
566 'audio': lambda: _safe_transfer_audio(
91 hk_row['audio_url'], 567 hk_row['audio_url'],
92 build_oss_key('qq', 'audio', mid + '.mp3'), 568 build_oss_key('qq', 'audio', mid + '.mp3'),
93 bucket, base_url 569 bucket, base_url
94 ) 570 ),
95 cover_url = _safe_transfer( 571 'cover': lambda: _safe_transfer(
96 hk_row.get('cover_url') or sp.get('cover', ''), 572 hk_row.get('cover_url') or sp.get('cover', ''),
97 build_oss_key('qq', 'cover', str(song_id_int) + '.jpg'), 573 build_oss_key('qq', 'cover', str(song_id_int) + '.jpg'),
98 bucket, base_url 574 bucket, base_url
99 ) 575 ),
100 album_cover = '' 576 'lyric': lambda: _safe_upload_lyric('qq', mid, raw_lyric, hk_row.get('lyrics_url'), bucket, base_url),
577 }
101 if sp.get('album_id') and sp.get('album_cover'): 578 if sp.get('album_id') and sp.get('album_cover'):
102 album_cover = _safe_transfer( 579 tasks['album_cover'] = lambda: _safe_transfer(
103 sp['album_cover'], 580 sp['album_cover'],
104 build_oss_key('qq', 'album', str(sp['album_id']) + '.jpg'), 581 build_oss_key('qq', 'album', str(sp['album_id']) + '.jpg'),
105 bucket, base_url 582 bucket, base_url
106 ) 583 )
107 # 专辑封面为空时回退使用歌曲封面
108 if not album_cover and cover_url:
109 album_cover = cover_url
110
111 # 歌手头像转移 + 写入 singers
112 singer_rows = []
113 for sg in singer_list: 584 for sg in singer_list:
114 avatar = _safe_transfer( 585 tasks[f"singer_avatar:{sg['singer_id']}"] = (
586 lambda sg=sg: _safe_transfer(
115 sg.get('avatar', ''), 587 sg.get('avatar', ''),
116 build_oss_key('qq', 'singer', sg['mid'] + '.jpg'), 588 build_oss_key('qq', 'singer', sg['mid'] + '.jpg'),
117 bucket, base_url 589 bucket, base_url
118 ) 590 )
591 )
592 assets = _run_io_tasks(tasks)
593 audio_url, audio_md5 = assets['audio']
594 cover_url = assets['cover']
595 lyric_url = assets['lyric']
596 album_cover = assets.get('album_cover') or cover_url
597
598 # 歌手头像转移 + 写入 singers
599 singer_rows = []
600 for sg in singer_list:
601 avatar = assets.get(f"singer_avatar:{sg['singer_id']}", sg.get('avatar', ''))
119 singer_rows.append({ 602 singer_rows.append({
120 **sg, 603 **sg,
121 'id': sg['singer_id'], 604 'id': sg['singer_id'],
...@@ -174,8 +657,6 @@ def _process_qq(hk_row: dict, pr: dict, spider_conn, pg_cur, bucket, base_url): ...@@ -174,8 +657,6 @@ def _process_qq(hk_row: dict, pr: dict, spider_conn, pg_cur, bucket, base_url):
174 # 写入 song 657 # 写入 song
175 platform_song_id = int(pr['platform_mid']) if pr.get('platform_mid') else song_id_int 658 platform_song_id = int(pr['platform_mid']) if pr.get('platform_mid') else song_id_int
176 song_uuid = str(uuid.uuid4()) 659 song_uuid = str(uuid.uuid4())
177 raw_lyric = sp.get('lyric') or ''
178 lyric_url = _safe_upload_lyric('qq', mid, raw_lyric, hk_row.get('lyrics_url'), bucket, base_url)
179 title, version = split_title_version(sp.get('title') or hk_row['name']) 660 title, version = split_title_version(sp.get('title') or hk_row['name'])
180 upsert_qq_songs(pg_cur, [{ 661 upsert_qq_songs(pg_cur, [{
181 'song_uuid': song_uuid, 662 'song_uuid': song_uuid,
...@@ -213,44 +694,65 @@ def _process_qq(hk_row: dict, pr: dict, spider_conn, pg_cur, bucket, base_url): ...@@ -213,44 +694,65 @@ def _process_qq(hk_row: dict, pr: dict, spider_conn, pg_cur, bucket, base_url):
213 return {'platform': 'qq', 'platform_song_id': platform_song_id, 'mid': mid, 'title': hk_row['name']} 694 return {'platform': 'qq', 'platform_song_id': platform_song_id, 'mid': mid, 'title': hk_row['name']}
214 695
215 696
216 def _process_kugou(hk_row: dict, pr: dict, spider_conn, pg_cur, bucket, base_url): 697 def _process_kugou(
698 hk_row: dict,
699 pr: dict,
700 spider_conn,
701 pg_cur,
702 bucket,
703 base_url,
704 song_data: dict | None = None,
705 singer_list: list[dict] | None = None,
706 ):
217 song_id = int(pr['platform_unique_key']) 707 song_id = int(pr['platform_unique_key'])
708 sp = song_data
709 if sp is None:
218 songs_map = fetch_kugou_songs(spider_conn, [song_id]) 710 songs_map = fetch_kugou_songs(spider_conn, [song_id])
219 if song_id not in songs_map: 711 if song_id not in songs_map:
220 return 712 return
221 sp = songs_map[song_id] 713 sp = songs_map[song_id]
222 714
715 if singer_list is None:
223 singers_map = fetch_kugou_singers(spider_conn, [song_id]) 716 singers_map = fetch_kugou_singers(spider_conn, [song_id])
224 singer_list = singers_map.get(song_id, []) 717 singer_list = singers_map.get(song_id, [])
225 718
226 audio_url, audio_md5 = _safe_transfer_audio( 719 raw_lyric = sp.get('lyric') or ''
720 tasks = {
721 'audio': lambda: _safe_transfer_audio(
227 hk_row['audio_url'], 722 hk_row['audio_url'],
228 build_oss_key('kugou', 'audio', str(song_id) + '.mp3'), 723 build_oss_key('kugou', 'audio', str(song_id) + '.mp3'),
229 bucket, base_url 724 bucket, base_url
230 ) 725 ),
231 cover_url = _safe_transfer( 726 'cover': lambda: _safe_transfer(
232 hk_row.get('cover_url') or sp.get('cover', ''), 727 hk_row.get('cover_url') or sp.get('cover', ''),
233 build_oss_key('kugou', 'cover', str(song_id) + '.jpg'), 728 build_oss_key('kugou', 'cover', str(song_id) + '.jpg'),
234 bucket, base_url 729 bucket, base_url
235 ) 730 ),
236 album_cover = '' 731 'lyric': lambda: _safe_upload_lyric('kugou', str(song_id), raw_lyric, hk_row.get('lyrics_url'), bucket, base_url),
732 }
237 if sp.get('album_id') and sp.get('album_cover'): 733 if sp.get('album_id') and sp.get('album_cover'):
238 album_cover = _safe_transfer( 734 tasks['album_cover'] = lambda: _safe_transfer(
239 sp['album_cover'], 735 sp['album_cover'],
240 build_oss_key('kugou', 'album', str(sp['album_id']) + '.jpg'), 736 build_oss_key('kugou', 'album', str(sp['album_id']) + '.jpg'),
241 bucket, base_url 737 bucket, base_url
242 ) 738 )
243 # 专辑封面为空时回退使用歌曲封面
244 if not album_cover and cover_url:
245 album_cover = cover_url
246
247 singer_rows = []
248 for sg in singer_list: 739 for sg in singer_list:
249 avatar = _safe_transfer( 740 tasks[f"singer_avatar:{sg['singer_id']}"] = (
741 lambda sg=sg: _safe_transfer(
250 sg.get('avatar', ''), 742 sg.get('avatar', ''),
251 build_oss_key('kugou', 'singer', str(sg['singer_id']) + '.jpg'), 743 build_oss_key('kugou', 'singer', str(sg['singer_id']) + '.jpg'),
252 bucket, base_url 744 bucket, base_url
253 ) 745 )
746 )
747 assets = _run_io_tasks(tasks)
748 audio_url, audio_md5 = assets['audio']
749 cover_url = assets['cover']
750 lyric_url = assets['lyric']
751 album_cover = assets.get('album_cover') or cover_url
752
753 singer_rows = []
754 for sg in singer_list:
755 avatar = assets.get(f"singer_avatar:{sg['singer_id']}", sg.get('avatar', ''))
254 singer_rows.append({ 756 singer_rows.append({
255 **sg, 757 **sg,
256 'id': sg['singer_id'], 758 'id': sg['singer_id'],
...@@ -290,8 +792,6 @@ def _process_kugou(hk_row: dict, pr: dict, spider_conn, pg_cur, bucket, base_url ...@@ -290,8 +792,6 @@ def _process_kugou(hk_row: dict, pr: dict, spider_conn, pg_cur, bucket, base_url
290 }]) 792 }])
291 793
292 song_uuid = str(uuid.uuid4()) 794 song_uuid = str(uuid.uuid4())
293 raw_lyric = sp.get('lyric') or ''
294 lyric_url = _safe_upload_lyric('kugou', str(song_id), raw_lyric, hk_row.get('lyrics_url'), bucket, base_url)
295 title, version = split_title_version(sp.get('title') or hk_row['name']) 795 title, version = split_title_version(sp.get('title') or hk_row['name'])
296 upsert_kugou_songs(pg_cur, [{ 796 upsert_kugou_songs(pg_cur, [{
297 'song_uuid': song_uuid, 797 'song_uuid': song_uuid,
...@@ -328,44 +828,65 @@ def _process_kugou(hk_row: dict, pr: dict, spider_conn, pg_cur, bucket, base_url ...@@ -328,44 +828,65 @@ def _process_kugou(hk_row: dict, pr: dict, spider_conn, pg_cur, bucket, base_url
328 return {'platform': 'kugou', 'platform_song_id': song_id, 'hash': sp.get('hid', ''), 'title': hk_row['name']} 828 return {'platform': 'kugou', 'platform_song_id': song_id, 'hash': sp.get('hid', ''), 'title': hk_row['name']}
329 829
330 830
331 def _process_netease(hk_row: dict, pr: dict, spider_conn, pg_cur, bucket, base_url): 831 def _process_netease(
832 hk_row: dict,
833 pr: dict,
834 spider_conn,
835 pg_cur,
836 bucket,
837 base_url,
838 song_data: dict | None = None,
839 singer_list: list[dict] | None = None,
840 ):
332 song_id = int(pr['platform_unique_key']) 841 song_id = int(pr['platform_unique_key'])
842 sp = song_data
843 if sp is None:
333 songs_map = fetch_netease_songs(spider_conn, [song_id]) 844 songs_map = fetch_netease_songs(spider_conn, [song_id])
334 if song_id not in songs_map: 845 if song_id not in songs_map:
335 return 846 return
336 sp = songs_map[song_id] 847 sp = songs_map[song_id]
337 848
849 if singer_list is None:
338 singers_map = fetch_netease_singers(spider_conn, [song_id]) 850 singers_map = fetch_netease_singers(spider_conn, [song_id])
339 singer_list = singers_map.get(song_id, []) 851 singer_list = singers_map.get(song_id, [])
340 852
341 audio_url, audio_md5 = _safe_transfer_audio( 853 raw_lyric = sp.get('lyric') or ''
854 tasks = {
855 'audio': lambda: _safe_transfer_audio(
342 hk_row['audio_url'], 856 hk_row['audio_url'],
343 build_oss_key('netease', 'audio', str(song_id) + '.mp3'), 857 build_oss_key('netease', 'audio', str(song_id) + '.mp3'),
344 bucket, base_url 858 bucket, base_url
345 ) 859 ),
346 cover_url = _safe_transfer( 860 'cover': lambda: _safe_transfer(
347 hk_row.get('cover_url') or sp.get('cover', ''), 861 hk_row.get('cover_url') or sp.get('cover', ''),
348 build_oss_key('netease', 'cover', str(song_id) + '.jpg'), 862 build_oss_key('netease', 'cover', str(song_id) + '.jpg'),
349 bucket, base_url 863 bucket, base_url
350 ) 864 ),
351 album_cover = '' 865 'lyric': lambda: _safe_upload_lyric('netease', str(song_id), raw_lyric, hk_row.get('lyrics_url'), bucket, base_url),
866 }
352 if sp.get('album_id') and sp.get('album_cover'): 867 if sp.get('album_id') and sp.get('album_cover'):
353 album_cover = _safe_transfer( 868 tasks['album_cover'] = lambda: _safe_transfer(
354 sp['album_cover'], 869 sp['album_cover'],
355 build_oss_key('netease', 'album', str(sp['album_id']) + '.jpg'), 870 build_oss_key('netease', 'album', str(sp['album_id']) + '.jpg'),
356 bucket, base_url 871 bucket, base_url
357 ) 872 )
358 # 专辑封面为空时回退使用歌曲封面
359 if not album_cover and cover_url:
360 album_cover = cover_url
361
362 singer_rows = []
363 for sg in singer_list: 873 for sg in singer_list:
364 avatar = _safe_transfer( 874 tasks[f"singer_avatar:{sg['singer_id']}"] = (
875 lambda sg=sg: _safe_transfer(
365 sg.get('avatar', ''), 876 sg.get('avatar', ''),
366 build_oss_key('netease', 'singer', str(sg['singer_id']) + '.jpg'), 877 build_oss_key('netease', 'singer', str(sg['singer_id']) + '.jpg'),
367 bucket, base_url 878 bucket, base_url
368 ) 879 )
880 )
881 assets = _run_io_tasks(tasks)
882 audio_url, audio_md5 = assets['audio']
883 cover_url = assets['cover']
884 lyric_url = assets['lyric']
885 album_cover = assets.get('album_cover') or cover_url
886
887 singer_rows = []
888 for sg in singer_list:
889 avatar = assets.get(f"singer_avatar:{sg['singer_id']}", sg.get('avatar', ''))
369 singer_rows.append({ 890 singer_rows.append({
370 **sg, 891 **sg,
371 'id': sg['singer_id'], 892 'id': sg['singer_id'],
...@@ -418,8 +939,6 @@ def _process_netease(hk_row: dict, pr: dict, spider_conn, pg_cur, bucket, base_u ...@@ -418,8 +939,6 @@ def _process_netease(hk_row: dict, pr: dict, spider_conn, pg_cur, bucket, base_u
418 }]) 939 }])
419 940
420 song_uuid = str(uuid.uuid4()) 941 song_uuid = str(uuid.uuid4())
421 raw_lyric = sp.get('lyric') or ''
422 lyric_url = _safe_upload_lyric('netease', str(song_id), raw_lyric, hk_row.get('lyrics_url'), bucket, base_url)
423 title, version = split_title_version(sp.get('title') or hk_row['name']) 942 title, version = split_title_version(sp.get('title') or hk_row['name'])
424 upsert_netease_songs(pg_cur, [{ 943 upsert_netease_songs(pg_cur, [{
425 'song_uuid': song_uuid, 944 'song_uuid': song_uuid,
...@@ -643,15 +1162,12 @@ def run( ...@@ -643,15 +1162,12 @@ def run(
643 PLATFORM_NETEASE: netease_singers_map, 1162 PLATFORM_NETEASE: netease_singers_map,
644 } 1163 }
645 1164
646 with pg_conn.cursor() as pg_cur: 1165 prepare_inputs = []
647 pushed_count = 0
648 for pending in pending_records: 1166 for pending in pending_records:
649 src_id = int(pending['song_id']) 1167 src_id = int(pending['song_id'])
650 platform = str(pending['platform']) 1168 platform = str(pending['platform'])
651 hk_row = hk_by_song.get(src_id) 1169 hk_row = hk_by_song.get(src_id)
652 if not hk_row: 1170 if not hk_row or platform not in platforms:
653 continue
654 if platform not in platforms:
655 continue 1171 continue
656 pr = pr_by_state_key.get((src_id, int(pending['record_id']), platform)) 1172 pr = pr_by_state_key.get((src_id, int(pending['record_id']), platform))
657 if not pr: 1173 if not pr:
...@@ -660,32 +1176,40 @@ def run( ...@@ -660,32 +1176,40 @@ def run(
660 src_id, pending['record_id'], platform, 1176 src_id, pending['record_id'], platform,
661 ) 1177 )
662 continue 1178 continue
663 processor = _PROCESSORS.get(platform) 1179 prepare_inputs.append((pending, hk_row, pr))
664 if not processor: 1180
665 continue 1181 payloads = []
666 pg_cur.execute('SAVEPOINT sp_song') 1182 max_workers = min(MAX_IMPORT_WORKERS, len(prepare_inputs)) if prepare_inputs else 0
1183 if max_workers:
1184 with ThreadPoolExecutor(max_workers=max_workers) as executor:
1185 future_map = {
1186 executor.submit(
1187 _prepare_import_payload,
1188 pending, hk_row, pr, bucket, base_url, songs_maps, singers_maps,
1189 ): (pending, hk_row, pr)
1190 for pending, hk_row, pr in prepare_inputs
1191 }
1192 for future in as_completed(future_map):
1193 pending, hk_row, pr = future_map[future]
667 try: 1194 try:
668 result = processor(hk_row, pr, spider_conn, pg_cur, bucket, base_url) 1195 payload = future.result()
669 if result: 1196 if payload:
670 upsert_yinyan_song_records(pg_cur, [{ 1197 payloads.append(payload)
671 'song_id': src_id,
672 'record_id': int(pr['record_id']),
673 'platform': platform,
674 'platform_song_id': int(result['platform_song_id']),
675 }])
676 pushed_count += 1
677 imported.append(result)
678 pg_cur.execute('RELEASE SAVEPOINT sp_song')
679 total_ok += 1 1198 total_ok += 1
1199 else:
1200 total_err += 1
680 except Exception as e: 1201 except Exception as e:
681 pg_cur.execute('ROLLBACK TO SAVEPOINT sp_song')
682 pg_cur.execute('RELEASE SAVEPOINT sp_song')
683 log.error("Error processing song %s platform %s: %s",
684 hk_row.get('name'), platform, e)
685 total_err += 1 1202 total_err += 1
1203 log.error("Error preparing song %s platform %s: %s",
1204 hk_row.get('name'), pr['platform'], e)
1205
1206 if payloads:
1207 with pg_conn.cursor() as pg_cur:
1208 _write_import_payloads(pg_cur, payloads)
686 pg_conn.commit() 1209 pg_conn.commit()
687 if pushed_count == 0: 1210 imported.extend(payload['result'] for payload in payloads)
688 log.error("No yinyan_song_records rows were marked pushed in this batch; stopping to avoid retry loop") 1211 else:
1212 log.error("No import payloads were prepared in this batch; stopping to avoid retry loop")
689 break 1213 break
690 batch_index += 1 1214 batch_index += 1
691 pbar.update(1) 1215 pbar.update(1)
......
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')
......