refactor(etl_to_crawler): 重构音频和歌手数据转存及导入流程
- 新增HTTP连接池配置,提升请求性能和资源复用 - 使用requests Session统一管理HTTP连接,减少请求开销 - 实现_url清洗函数,过滤无效或非法URL - 并行执行音频、封面、歌词及歌手头像的OSS转存任务,提升效率 - 按平台统一构建导入数据payload,简化处理流程 - 批量写入歌曲、歌手、专辑及关联关系,提升数据库操作性能 - 替换原平台单独处理函数为统一预处理及写入方法 - 取消原单线程顺序处理,改为线程池并发执行数据准备和导入 - 获取平台歌曲及歌手信息支持传入缓存参数,减少重复查询 - 优化异常处理及日志记录,提高稳定性和可维护性 - 新增歌手索引和性别等字段辅助函数,完善歌手信息处理
Showing
8 changed files
with
811 additions
and
113 deletions
| ... | @@ -50,3 +50,5 @@ PLATFORMS = [PLATFORM_QQ, PLATFORM_KUGOU, PLATFORM_NETEASE] | ... | @@ -50,3 +50,5 @@ PLATFORMS = [PLATFORM_QQ, PLATFORM_KUGOU, PLATFORM_NETEASE] |
| 50 | 50 | ||
| 51 | BATCH_SIZE = 100 | 51 | BATCH_SIZE = 100 |
| 52 | BACKFILL_BATCH_SIZE = int(os.environ.get('BACKFILL_BATCH_SIZE', '5000')) | 52 | BACKFILL_BATCH_SIZE = int(os.environ.get('BACKFILL_BATCH_SIZE', '5000')) |
| 53 | HTTP_POOL_MAXSIZE = int(os.environ.get('HTTP_POOL_MAXSIZE', '128')) | ||
| 54 | OSS_CONNECTION_POOL_SIZE = int(os.environ.get('OSS_CONNECTION_POOL_SIZE', str(HTTP_POOL_MAXSIZE))) | ... | ... |
| ... | @@ -2,7 +2,7 @@ import pymysql | ... | @@ -2,7 +2,7 @@ import pymysql |
| 2 | import pymysql.cursors | 2 | import pymysql.cursors |
| 3 | import pg8000 | 3 | import pg8000 |
| 4 | import oss2 | 4 | import oss2 |
| 5 | from .config import SOURCE_DB, HK_SONGS_DB, CRAWLER_DB, OSS_CONFIG | 5 | from .config import SOURCE_DB, HK_SONGS_DB, CRAWLER_DB, OSS_CONFIG, OSS_CONNECTION_POOL_SIZE |
| 6 | 6 | ||
| 7 | 7 | ||
| 8 | def get_source_conn() -> pymysql.Connection: | 8 | def get_source_conn() -> pymysql.Connection: |
| ... | @@ -26,5 +26,6 @@ def get_pg_conn() -> pg8000.Connection: | ... | @@ -26,5 +26,6 @@ def get_pg_conn() -> pg8000.Connection: |
| 26 | 26 | ||
| 27 | 27 | ||
| 28 | def get_oss_bucket() -> oss2.Bucket: | 28 | def get_oss_bucket() -> oss2.Bucket: |
| 29 | oss2.defaults.connection_pool_size = OSS_CONNECTION_POOL_SIZE | ||
| 29 | auth = oss2.Auth(OSS_CONFIG['access_key_id'], OSS_CONFIG['access_key_secret']) | 30 | auth = oss2.Auth(OSS_CONFIG['access_key_id'], OSS_CONFIG['access_key_secret']) |
| 30 | return oss2.Bucket(auth, OSS_CONFIG['endpoint'], OSS_CONFIG['bucket_name']) | 31 | return oss2.Bucket(auth, OSS_CONFIG['endpoint'], OSS_CONFIG['bucket_name']) | ... | ... |
| 1 | import requests | 1 | import requests |
| 2 | import oss2 | 2 | import oss2 |
| 3 | import math | ||
| 3 | from urllib.parse import urlparse | 4 | from urllib.parse import urlparse |
| 4 | from .config import OSS_CONFIG | 5 | from requests.adapters import HTTPAdapter |
| 6 | from .config import HTTP_POOL_MAXSIZE, OSS_CONFIG | ||
| 5 | from .utils import compute_audio_md5 | 7 | from .utils import compute_audio_md5 |
| 6 | 8 | ||
| 9 | _HTTP_SESSION = requests.Session() | ||
| 10 | _HTTP_ADAPTER = HTTPAdapter(pool_connections=HTTP_POOL_MAXSIZE, pool_maxsize=HTTP_POOL_MAXSIZE) | ||
| 11 | _HTTP_SESSION.mount('http://', _HTTP_ADAPTER) | ||
| 12 | _HTTP_SESSION.mount('https://', _HTTP_ADAPTER) | ||
| 13 | |||
| 14 | |||
| 15 | def _http_get(url: str, timeout: int = 30): | ||
| 16 | return _HTTP_SESSION.get(url, timeout=timeout) | ||
| 17 | |||
| 18 | |||
| 19 | def _clean_url(url) -> str: | ||
| 20 | if url is None: | ||
| 21 | return '' | ||
| 22 | if isinstance(url, float) and math.isnan(url): | ||
| 23 | return '' | ||
| 24 | text = str(url).strip() | ||
| 25 | if not text or text.lower() in {'nan', 'none', 'null'}: | ||
| 26 | return '' | ||
| 27 | parsed = urlparse(text) | ||
| 28 | if parsed.scheme not in {'http', 'https'} or not parsed.netloc: | ||
| 29 | return '' | ||
| 30 | return text | ||
| 31 | |||
| 7 | 32 | ||
| 8 | def _host(url: str | None) -> str: | 33 | def _host(url: str | None) -> str: |
| 9 | return urlparse(url or '').netloc.lower() | 34 | return urlparse(url or '').netloc.lower() |
| ... | @@ -28,11 +53,12 @@ def transfer_url(url: str | None, oss_key: str, bucket: oss2.Bucket, base_url: s | ... | @@ -28,11 +53,12 @@ def transfer_url(url: str | None, oss_key: str, bucket: oss2.Bucket, base_url: s |
| 28 | 若 url 为空或已在目标 bucket,直接返回原 url(不上传)。 | 53 | 若 url 为空或已在目标 bucket,直接返回原 url(不上传)。 |
| 29 | 返回新的公开访问 URL。 | 54 | 返回新的公开访问 URL。 |
| 30 | """ | 55 | """ |
| 56 | url = _clean_url(url) | ||
| 31 | if not url: | 57 | if not url: |
| 32 | return '' | 58 | return '' |
| 33 | if _is_target_oss_url(url, base_url): | 59 | if _is_target_oss_url(url, base_url): |
| 34 | return url | 60 | return url |
| 35 | resp = requests.get(_download_url(url, base_url), timeout=30) | 61 | resp = _http_get(_download_url(url, base_url), timeout=30) |
| 36 | resp.raise_for_status() | 62 | resp.raise_for_status() |
| 37 | bucket.put_object(oss_key, resp.content) | 63 | bucket.put_object(oss_key, resp.content) |
| 38 | return f"{base_url.rstrip('/')}/{oss_key}" | 64 | return f"{base_url.rstrip('/')}/{oss_key}" |
| ... | @@ -43,11 +69,12 @@ def transfer_url_with_md5(url: str | None, oss_key: str, bucket: oss2.Bucket, ba | ... | @@ -43,11 +69,12 @@ def transfer_url_with_md5(url: str | None, oss_key: str, bucket: oss2.Bucket, ba |
| 43 | 将音频 URL 转存到 OSS,并基于下载到的音频字节计算 MD5。 | 69 | 将音频 URL 转存到 OSS,并基于下载到的音频字节计算 MD5。 |
| 44 | 已在目标 OSS 的 URL 无需重新下载,无法可靠计算 MD5,返回空 MD5。 | 70 | 已在目标 OSS 的 URL 无需重新下载,无法可靠计算 MD5,返回空 MD5。 |
| 45 | """ | 71 | """ |
| 72 | url = _clean_url(url) | ||
| 46 | if not url: | 73 | if not url: |
| 47 | return '', '' | 74 | return '', '' |
| 48 | if _is_target_oss_url(url, base_url): | 75 | if _is_target_oss_url(url, base_url): |
| 49 | return url, '' | 76 | return url, '' |
| 50 | resp = requests.get(_download_url(url, base_url), timeout=30) | 77 | resp = _http_get(_download_url(url, base_url), timeout=30) |
| 51 | resp.raise_for_status() | 78 | resp.raise_for_status() |
| 52 | audio_md5 = compute_audio_md5(resp.content) | 79 | audio_md5 = compute_audio_md5(resp.content) |
| 53 | bucket.put_object(oss_key, resp.content) | 80 | bucket.put_object(oss_key, resp.content) | ... | ... |
| 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') | ... | ... |
-
Please register or sign in to post a comment