Commit dc82bc10 dc82bc1084c4892bfcaccd24155b4c04b2acc8e5 by 沈秋雨

refactor(etl): 优化音频转存与歌词上传流程,支持音频 MD5 计算和歌词换行处理

- 移除 audit_hk_songs_duplicates.py 中删除零时长歌曲的逻辑
- 新增 utils.py 工具模块,提供音频 MD5 计算、歌词处理、OSS 上传等函数
- etl_to_crawler/oss.py 添加 transfer_url_with_md5 函数,用于下载音频、计算 MD5 并上传 OSS
- etl_to_crawler/lyric.py 增加 ensure_newlines 函数,确保歌词内容行间带换行符
- etl_to_crawler/runner.py 改进音频转存调用,返回音频 MD5 值
- 通过 safe_upload_lyric 函数上传去时间戳的纯文本歌词,失败时使用原始歌词 URL
- etl_to_crawler/writer.py 在歌曲数据写入中新增 audio_md5 字段支持
- 重构 yinyan_song_records 逻辑,新增初始化函数 initialize_yinyan_song_records
- run_etl.py 增加 --init-yinyan-records 命令行参数支持仅初始化状态表
- 优化 ETL 运行流程,从预标记状态表中拉取待处理歌曲逐批处理,提高效率
- 新增相关单元测试以覆盖音频 MD5 计算和任务处理逻辑修改
1 parent 8bd8c02b
...@@ -558,52 +558,9 @@ def main() -> int: ...@@ -558,52 +558,9 @@ def main() -> int:
558 help="补处理暂存表中 merge+skipped 记录:existing_into_new 入库+软删旧记录,new_into_existing 补作者合并", 558 help="补处理暂存表中 merge+skipped 记录:existing_into_new 入库+软删旧记录,new_into_existing 补作者合并",
559 ) 559 )
560 parser.add_argument("--skip-oss", action="store_true", help="--fix-merge 时跳过歌词 OSS 上传,保留原始文本") 560 parser.add_argument("--skip-oss", action="store_true", help="--fix-merge 时跳过歌词 OSS 上传,保留原始文本")
561 parser.add_argument("--dry-run", action="store_true", help="仅打印计划,不写库(配合 --fix-merge / --delete-zero-duration 使用)") 561 parser.add_argument("--dry-run", action="store_true", help="仅打印计划,不写库(配合 --fix-merge 使用)")
562 parser.add_argument(
563 "--delete-zero-duration",
564 action="store_true",
565 help="软删除 song_time=0 的记录(置 deleted='1')",
566 )
567 args = parser.parse_args() 562 args = parser.parse_args()
568 563
569 # --delete-zero-duration 模式
570 if args.delete_zero_duration:
571 conn = pymysql.connect(**TARGET_DB_CONFIG)
572 try:
573 table = quote_identifier(DEFAULT_TARGET_TABLE)
574 with conn.cursor() as cursor:
575 cursor.execute(
576 f"SELECT id, name, lyricist, composer, song_time "
577 f"FROM {table} WHERE deleted = '0' AND song_time = 0"
578 )
579 rows = list(cursor.fetchall())
580 print(f"song_time=0 记录数: {len(rows)}")
581 if args.dry_run:
582 for row in rows[:20]:
583 print(row)
584 if len(rows) > 20:
585 print(f"... {len(rows) - 20} more rows")
586 return 0
587 if not rows:
588 print("无需处理")
589 return 0
590 ids = [row["id"] for row in rows]
591 placeholders = ",".join(["%s"] * len(ids))
592 with conn.cursor() as cursor:
593 cursor.execute(
594 f"UPDATE {table} "
595 f"SET deleted = '1', modify_time = NOW(), "
596 f" off_shelf_remark = 'song_time=0 soft-deleted by audit script' "
597 f"WHERE deleted = '0' AND id IN ({placeholders})",
598 ids,
599 )
600 affected = cursor.rowcount
601 conn.commit()
602 print(f"soft_deleted={affected}")
603 finally:
604 conn.close()
605 return 0
606
607 # --fix-merge 模式:连双库,执行合并方向后处理 564 # --fix-merge 模式:连双库,执行合并方向后处理
608 if args.fix_merge: 565 if args.fix_merge:
609 target_conn = pymysql.connect(**TARGET_DB_CONFIG) 566 target_conn = pymysql.connect(**TARGET_DB_CONFIG)
......
...@@ -4,6 +4,16 @@ _TIMESTAMP_RE = re.compile(r'\[\d{2}:\d{2}\.\d{2,3}\]') ...@@ -4,6 +4,16 @@ _TIMESTAMP_RE = re.compile(r'\[\d{2}:\d{2}\.\d{2,3}\]')
4 _META_TAG_RE = re.compile(r'\[[a-zA-Z]+:[^\]]*\]') 4 _META_TAG_RE = re.compile(r'\[[a-zA-Z]+:[^\]]*\]')
5 5
6 6
7 def ensure_newlines(text: str | None) -> str:
8 """确保歌词每行之间有换行符,保留时间戳等原始内容"""
9 if not text:
10 return ''
11 # 将字面量 \\n / \\r 转为真正换行符
12 text = text.replace('\\n', '\n').replace('\\r', '')
13 lines = [line.strip() for line in text.splitlines() if line.strip()]
14 return '\n'.join(lines)
15
16
7 def strip_timestamps(text: str | None) -> str: 17 def strip_timestamps(text: str | None) -> str:
8 if not text: 18 if not text:
9 return '' 19 return ''
......
1 import requests 1 import requests
2 import oss2 2 import oss2
3 from urllib.parse import urlparse 3 from urllib.parse import urlparse
4 from .utils import compute_audio_md5
4 5
5 ARCHIVE_DEV_HOST = 'archive-dev.oss-cn-beijing.aliyuncs.com' 6 ARCHIVE_DEV_HOST = 'archive-dev.oss-cn-beijing.aliyuncs.com'
6 7
...@@ -21,6 +22,22 @@ def transfer_url(url: str | None, oss_key: str, bucket: oss2.Bucket, base_url: s ...@@ -21,6 +22,22 @@ def transfer_url(url: str | None, oss_key: str, bucket: oss2.Bucket, base_url: s
21 return f"{base_url.rstrip('/')}/{oss_key}" 22 return f"{base_url.rstrip('/')}/{oss_key}"
22 23
23 24
25 def transfer_url_with_md5(url: str | None, oss_key: str, bucket: oss2.Bucket, base_url: str) -> tuple[str, str]:
26 """
27 将音频 URL 转存到 OSS,并基于下载到的音频字节计算 MD5。
28 已在目标 OSS 的 URL 无需重新下载,无法可靠计算 MD5,返回空 MD5。
29 """
30 if not url:
31 return '', ''
32 if ARCHIVE_DEV_HOST in url:
33 return url, ''
34 resp = requests.get(url, timeout=30)
35 resp.raise_for_status()
36 audio_md5 = compute_audio_md5(resp.content)
37 bucket.put_object(oss_key, resp.content)
38 return f"{base_url.rstrip('/')}/{oss_key}", audio_md5
39
40
24 def build_oss_key(platform: str, category: str, filename: str) -> str: 41 def build_oss_key(platform: str, category: str, filename: str) -> str:
25 """ 42 """
26 构造 archive-dev 内的存储路径。 43 构造 archive-dev 内的存储路径。
......
...@@ -16,6 +16,18 @@ ORDER BY id ...@@ -16,6 +16,18 @@ ORDER BY id
16 LIMIT %s 16 LIMIT %s
17 """ 17 """
18 18
19 _HK_SONGS_BY_SOURCE_IDS_QUERY = """
20 SELECT id, name, lyricist, composer, audio_url, lyrics_url,
21 cover_url, singer, issue_time, source_song_id, song_time
22 FROM hk_songs_test
23 WHERE deleted = '0'
24 AND source_song_id IN ({placeholders})
25 AND name IS NOT NULL AND name != ''
26 AND audio_url IS NOT NULL AND audio_url != ''
27 AND singer IS NOT NULL AND singer != ''
28 ORDER BY id
29 """
30
19 _PLATFORM_QUERY = """ 31 _PLATFORM_QUERY = """
20 SELECT 32 SELECT
21 sar.song_id AS source_song_id, 33 sar.song_id AS source_song_id,
...@@ -61,6 +73,21 @@ def iter_hk_songs_batches( ...@@ -61,6 +73,21 @@ def iter_hk_songs_batches(
61 break 73 break
62 74
63 75
76 def fetch_hk_songs_by_source_ids(conn: pymysql.Connection, source_song_ids: list[int]) -> dict[int, dict]:
77 if not source_song_ids:
78 return {}
79 placeholders = ','.join(['%s'] * len(source_song_ids))
80 query = _HK_SONGS_BY_SOURCE_IDS_QUERY.format(placeholders=placeholders)
81 with conn.cursor() as cur:
82 cur.execute(query, source_song_ids)
83 rows = cur.fetchall()
84 return {
85 int(row['source_song_id']): row
86 for row in rows
87 if row.get('source_song_id') is not None
88 }
89
90
64 def fetch_platform_records(source_conn: pymysql.Connection, song_ids: list[int]) -> list[dict]: 91 def fetch_platform_records(source_conn: pymysql.Connection, song_ids: list[int]) -> list[dict]:
65 if not song_ids: 92 if not song_ids:
66 return [] 93 return []
......
1 import uuid 1 import uuid
2 import json 2 import json
3 import logging 3 import logging
4 from pathlib import Path
5 from tqdm import tqdm 4 from tqdm import tqdm
6 5
7 from .config import PLATFORM_QQ, PLATFORM_KUGOU, PLATFORM_NETEASE, BATCH_SIZE, OSS_CONFIG 6 from .config import PLATFORM_QQ, PLATFORM_KUGOU, PLATFORM_NETEASE, BATCH_SIZE, OSS_CONFIG
8 from .connections import get_hk_songs_conn, get_source_conn, get_spider_conn, get_pg_conn, get_oss_bucket 7 from .connections import get_hk_songs_conn, get_source_conn, get_spider_conn, get_pg_conn, get_oss_bucket
9 from .reader import iter_hk_songs_batches, fetch_platform_records, select_primary_record 8 from .reader import (
9 iter_hk_songs_batches,
10 fetch_hk_songs_by_source_ids,
11 fetch_platform_records,
12 select_primary_record,
13 )
10 from .spider import ( 14 from .spider import (
11 fetch_qq_songs, fetch_qq_singers, 15 fetch_qq_songs, fetch_qq_singers,
12 fetch_kugou_songs, fetch_kugou_singers, 16 fetch_kugou_songs, fetch_kugou_singers,
13 fetch_netease_songs, fetch_netease_singers, 17 fetch_netease_songs, fetch_netease_singers,
14 ) 18 )
15 from .writer import ( 19 from .writer import (
20 fetch_pending_yinyan_song_records,
21 insert_yinyan_song_records,
16 upsert_yinyan_song_records, 22 upsert_yinyan_song_records,
17 upsert_qq_singers, upsert_qq_albums, upsert_qq_songs, 23 upsert_qq_singers, upsert_qq_albums, upsert_qq_songs,
18 upsert_qq_singer_songs, upsert_qq_singer_albums, 24 upsert_qq_singer_songs, upsert_qq_singer_albums,
...@@ -21,8 +27,9 @@ from .writer import ( ...@@ -21,8 +27,9 @@ from .writer import (
21 upsert_netease_singers, upsert_netease_albums, upsert_netease_songs, 27 upsert_netease_singers, upsert_netease_albums, upsert_netease_songs,
22 upsert_netease_singer_songs, upsert_netease_singer_albums, 28 upsert_netease_singer_songs, upsert_netease_singer_albums,
23 ) 29 )
24 from .oss import transfer_url, build_oss_key 30 from .oss import transfer_url, transfer_url_with_md5, build_oss_key
25 from .lyric import strip_timestamps 31 from .utils import upload_plain_lyric_to_bucket
32 from .lyric import ensure_newlines
26 33
27 logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s') 34 logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s')
28 log = logging.getLogger(__name__) 35 log = logging.getLogger(__name__)
...@@ -37,6 +44,22 @@ def _safe_transfer(url, oss_key, bucket, base_url): ...@@ -37,6 +44,22 @@ def _safe_transfer(url, oss_key, bucket, base_url):
37 return url # 失败时保留原 URL,不阻断流程 44 return url # 失败时保留原 URL,不阻断流程
38 45
39 46
47 def _safe_transfer_audio(url, oss_key, bucket, base_url) -> tuple[str, str]:
48 try:
49 return transfer_url_with_md5(url, oss_key, bucket, base_url)
50 except Exception as e:
51 log.warning("Audio transfer failed for %s: %s", url, e)
52 return url or '', ''
53
54
55 def _safe_upload_lyric(platform: str, unique_id: str, lyric: str | None, fallback_url: str | None, bucket, base_url: str) -> str:
56 try:
57 return upload_plain_lyric_to_bucket(platform, unique_id, lyric or '', bucket, base_url) or (fallback_url or '')
58 except Exception as e:
59 log.warning("Lyric upload failed for %s/%s: %s", platform, unique_id, e)
60 return fallback_url or ''
61
62
40 def _json_default(value): 63 def _json_default(value):
41 return str(value) 64 return str(value)
42 65
...@@ -57,7 +80,7 @@ def _process_qq(hk_row: dict, pr: dict, spider_conn, pg_cur, bucket, base_url): ...@@ -57,7 +80,7 @@ def _process_qq(hk_row: dict, pr: dict, spider_conn, pg_cur, bucket, base_url):
57 singer_list = singers_map.get(song_id_int, []) 80 singer_list = singers_map.get(song_id_int, [])
58 81
59 # OSS 转移 82 # OSS 转移
60 audio_url = _safe_transfer( 83 audio_url, audio_md5 = _safe_transfer_audio(
61 hk_row['audio_url'], 84 hk_row['audio_url'],
62 build_oss_key('qq', 'audio', mid + '.mp3'), 85 build_oss_key('qq', 'audio', mid + '.mp3'),
63 bucket, base_url 86 bucket, base_url
...@@ -127,6 +150,8 @@ def _process_qq(hk_row: dict, pr: dict, spider_conn, pg_cur, bucket, base_url): ...@@ -127,6 +150,8 @@ def _process_qq(hk_row: dict, pr: dict, spider_conn, pg_cur, bucket, base_url):
127 # 写入 song 150 # 写入 song
128 platform_song_id = int(pr['platform_mid']) if pr.get('platform_mid') else song_id_int 151 platform_song_id = int(pr['platform_mid']) if pr.get('platform_mid') else song_id_int
129 song_uuid = str(uuid.uuid4()) 152 song_uuid = str(uuid.uuid4())
153 raw_lyric = sp.get('lyric') or ''
154 lyric_url = _safe_upload_lyric('qq', mid, raw_lyric, hk_row.get('lyrics_url'), bucket, base_url)
130 upsert_qq_songs(pg_cur, [{ 155 upsert_qq_songs(pg_cur, [{
131 'song_uuid': song_uuid, 156 'song_uuid': song_uuid,
132 'platform_song_id': platform_song_id, 157 'platform_song_id': platform_song_id,
...@@ -136,11 +161,12 @@ def _process_qq(hk_row: dict, pr: dict, spider_conn, pg_cur, bucket, base_url): ...@@ -136,11 +161,12 @@ def _process_qq(hk_row: dict, pr: dict, spider_conn, pg_cur, bucket, base_url):
136 'title': sp.get('title', hk_row['name']), 161 'title': sp.get('title', hk_row['name']),
137 'name': hk_row['name'], 162 'name': hk_row['name'],
138 'duration': sp.get('duration') or hk_row.get('song_time') or 0, 163 'duration': sp.get('duration') or hk_row.get('song_time') or 0,
139 'lyric': strip_timestamps(sp.get('lyric')), 164 'lyric': ensure_newlines(raw_lyric),
140 'composer_name': sp.get('composer_name') or hk_row.get('composer'), 165 'composer_name': sp.get('composer_name') or hk_row.get('composer'),
141 'lyricist_name': sp.get('lyricist_name') or hk_row.get('lyricist'), 166 'lyricist_name': sp.get('lyricist_name') or hk_row.get('lyricist'),
142 'url': audio_url, 167 'url': audio_url,
143 'lyric_url': hk_row.get('lyrics_url'), 168 'audio_md5': audio_md5,
169 'lyric_url': lyric_url,
144 'platform_index_url': sp.get('platform_index_url') or f'https://y.qq.com/n/ryqq/songDetail/{mid}', 170 'platform_index_url': sp.get('platform_index_url') or f'https://y.qq.com/n/ryqq/songDetail/{mid}',
145 'published_at': sp.get('published_at') or hk_row.get('issue_time'), 171 'published_at': sp.get('published_at') or hk_row.get('issue_time'),
146 'singers_json': singers_json, 172 'singers_json': singers_json,
...@@ -170,7 +196,7 @@ def _process_kugou(hk_row: dict, pr: dict, spider_conn, pg_cur, bucket, base_url ...@@ -170,7 +196,7 @@ def _process_kugou(hk_row: dict, pr: dict, spider_conn, pg_cur, bucket, base_url
170 singers_map = fetch_kugou_singers(spider_conn, [song_id]) 196 singers_map = fetch_kugou_singers(spider_conn, [song_id])
171 singer_list = singers_map.get(song_id, []) 197 singer_list = singers_map.get(song_id, [])
172 198
173 audio_url = _safe_transfer( 199 audio_url, audio_md5 = _safe_transfer_audio(
174 hk_row['audio_url'], 200 hk_row['audio_url'],
175 build_oss_key('kugou', 'audio', str(song_id) + '.mp3'), 201 build_oss_key('kugou', 'audio', str(song_id) + '.mp3'),
176 bucket, base_url 202 bucket, base_url
...@@ -234,6 +260,8 @@ def _process_kugou(hk_row: dict, pr: dict, spider_conn, pg_cur, bucket, base_url ...@@ -234,6 +260,8 @@ def _process_kugou(hk_row: dict, pr: dict, spider_conn, pg_cur, bucket, base_url
234 }]) 260 }])
235 261
236 song_uuid = str(uuid.uuid4()) 262 song_uuid = str(uuid.uuid4())
263 raw_lyric = sp.get('lyric') or ''
264 lyric_url = _safe_upload_lyric('kugou', str(song_id), raw_lyric, hk_row.get('lyrics_url'), bucket, base_url)
237 upsert_kugou_songs(pg_cur, [{ 265 upsert_kugou_songs(pg_cur, [{
238 'song_uuid': song_uuid, 266 'song_uuid': song_uuid,
239 'platform_song_id': song_id, 267 'platform_song_id': song_id,
...@@ -244,11 +272,12 @@ def _process_kugou(hk_row: dict, pr: dict, spider_conn, pg_cur, bucket, base_url ...@@ -244,11 +272,12 @@ def _process_kugou(hk_row: dict, pr: dict, spider_conn, pg_cur, bucket, base_url
244 'title': sp.get('title', hk_row['name']), 272 'title': sp.get('title', hk_row['name']),
245 'name': hk_row['name'], 273 'name': hk_row['name'],
246 'duration': sp.get('duration') or hk_row.get('song_time') or 0, 274 'duration': sp.get('duration') or hk_row.get('song_time') or 0,
247 'lyric': strip_timestamps(sp.get('lyric')), 275 'lyric': ensure_newlines(raw_lyric),
248 'composer_name': sp.get('composer_name') or hk_row.get('composer'), 276 'composer_name': sp.get('composer_name') or hk_row.get('composer'),
249 'lyricist_name': sp.get('lyricist_name') or hk_row.get('lyricist'), 277 'lyricist_name': sp.get('lyricist_name') or hk_row.get('lyricist'),
250 'url': audio_url, 278 'url': audio_url,
251 'lyric_url': hk_row.get('lyrics_url'), 279 'audio_md5': audio_md5,
280 'lyric_url': lyric_url,
252 'platform_index_url': sp.get('platform_index_url') or f'http://www.kugou.com/song/#hash={sp.get("hid") or pr.get("platform_mid", "")}', 281 'platform_index_url': sp.get('platform_index_url') or f'http://www.kugou.com/song/#hash={sp.get("hid") or pr.get("platform_mid", "")}',
253 'published_at': sp.get('published_at') or hk_row.get('issue_time'), 282 'published_at': sp.get('published_at') or hk_row.get('issue_time'),
254 'singers_json': singers_json, 283 'singers_json': singers_json,
...@@ -277,7 +306,7 @@ def _process_netease(hk_row: dict, pr: dict, spider_conn, pg_cur, bucket, base_u ...@@ -277,7 +306,7 @@ def _process_netease(hk_row: dict, pr: dict, spider_conn, pg_cur, bucket, base_u
277 singers_map = fetch_netease_singers(spider_conn, [song_id]) 306 singers_map = fetch_netease_singers(spider_conn, [song_id])
278 singer_list = singers_map.get(song_id, []) 307 singer_list = singers_map.get(song_id, [])
279 308
280 audio_url = _safe_transfer( 309 audio_url, audio_md5 = _safe_transfer_audio(
281 hk_row['audio_url'], 310 hk_row['audio_url'],
282 build_oss_key('netease', 'audio', str(song_id) + '.mp3'), 311 build_oss_key('netease', 'audio', str(song_id) + '.mp3'),
283 bucket, base_url 312 bucket, base_url
...@@ -354,6 +383,8 @@ def _process_netease(hk_row: dict, pr: dict, spider_conn, pg_cur, bucket, base_u ...@@ -354,6 +383,8 @@ def _process_netease(hk_row: dict, pr: dict, spider_conn, pg_cur, bucket, base_u
354 }]) 383 }])
355 384
356 song_uuid = str(uuid.uuid4()) 385 song_uuid = str(uuid.uuid4())
386 raw_lyric = sp.get('lyric') or ''
387 lyric_url = _safe_upload_lyric('netease', str(song_id), raw_lyric, hk_row.get('lyrics_url'), bucket, base_url)
357 upsert_netease_songs(pg_cur, [{ 388 upsert_netease_songs(pg_cur, [{
358 'song_uuid': song_uuid, 389 'song_uuid': song_uuid,
359 'platform_song_id': song_id, 390 'platform_song_id': song_id,
...@@ -363,11 +394,12 @@ def _process_netease(hk_row: dict, pr: dict, spider_conn, pg_cur, bucket, base_u ...@@ -363,11 +394,12 @@ def _process_netease(hk_row: dict, pr: dict, spider_conn, pg_cur, bucket, base_u
363 'title': sp.get('title', hk_row['name']), 394 'title': sp.get('title', hk_row['name']),
364 'name': hk_row['name'], 395 'name': hk_row['name'],
365 'duration': sp.get('duration') or hk_row.get('song_time') or 0, 396 'duration': sp.get('duration') or hk_row.get('song_time') or 0,
366 'lyric': strip_timestamps(sp.get('lyric')), 397 'lyric': ensure_newlines(raw_lyric),
367 'composer_name': sp.get('composer_name') or hk_row.get('composer'), 398 'composer_name': sp.get('composer_name') or hk_row.get('composer'),
368 'lyricist_name': sp.get('lyricist_name') or hk_row.get('lyricist'), 399 'lyricist_name': sp.get('lyricist_name') or hk_row.get('lyricist'),
369 'url': audio_url, 400 'url': audio_url,
370 'lyric_url': hk_row.get('lyrics_url'), 401 'audio_md5': audio_md5,
402 'lyric_url': lyric_url,
371 'platform_index_url': sp.get('platform_index_url') or f'https://music.163.com/#/song?id={song_id}', 403 'platform_index_url': sp.get('platform_index_url') or f'https://music.163.com/#/song?id={song_id}',
372 'published_at': sp.get('published_at') or hk_row.get('issue_time'), 404 'published_at': sp.get('published_at') or hk_row.get('issue_time'),
373 'singers_json': singers_json, 405 'singers_json': singers_json,
...@@ -393,36 +425,52 @@ _PROCESSORS = { ...@@ -393,36 +425,52 @@ _PROCESSORS = {
393 } 425 }
394 426
395 427
396 DEFAULT_STATE_FILE = Path('output/etl_to_crawler_state.json') 428 def initialize_yinyan_song_records(platforms: list[str], max_batches: int | None = None) -> None:
397 429 hk_conn = get_hk_songs_conn()
430 src_conn = get_source_conn()
431 pg_conn = get_pg_conn()
398 432
399 def _load_state(path: Path) -> dict: 433 total = 0
400 if not path.exists(): 434 try:
401 return {} 435 for i, batch in enumerate(tqdm(iter_hk_songs_batches(hk_conn, BATCH_SIZE), desc='init-yinyan')):
402 with path.open('r', encoding='utf-8') as f: 436 if max_batches is not None and i >= max_batches:
403 return json.load(f) 437 break
438 song_ids = [int(r['source_song_id']) for r in batch if r.get('source_song_id')]
439 platform_records = fetch_platform_records(src_conn, song_ids)
404 440
441 pr_by_song: dict[int, list] = {}
442 for pr in platform_records:
443 if pr['platform'] in platforms:
444 pr_by_song.setdefault(int(pr['source_song_id']), []).append(pr)
405 445
406 def _save_state(path: Path, state: dict) -> None: 446 init_rows = []
407 path.parent.mkdir(parents=True, exist_ok=True) 447 for hk_row in batch:
408 with path.open('w', encoding='utf-8') as f: 448 src_id = int(hk_row['source_song_id']) if hk_row.get('source_song_id') else None
409 json.dump(state, f, ensure_ascii=False, indent=2, sort_keys=True) 449 if not src_id or src_id not in pr_by_song:
410 f.write('\n') 450 continue
451 primary_record = select_primary_record(pr_by_song[src_id])
452 if primary_record:
453 init_rows.append({
454 'song_id': src_id,
455 'record_id': int(primary_record['record_id']),
456 })
411 457
458 if init_rows:
459 with pg_conn.cursor() as pg_cur:
460 insert_yinyan_song_records(pg_cur, init_rows)
461 pg_conn.commit()
462 total += len(init_rows)
463 finally:
464 hk_conn.close()
465 src_conn.close()
466 pg_conn.close()
412 467
413 def _state_last_id(state: dict, key: str) -> int: 468 log.info("Initialized yinyan_song_records candidates=%d", total)
414 value = state.get(key, {}).get('last_hk_songs_id', 0)
415 return int(value or 0)
416 469
417 470
418 def run( 471 def run(
419 platforms: list[str], 472 platforms: list[str],
420 max_batches: int | None = None, 473 max_batches: int | None = None,
421 resume: bool = False,
422 state_file: str | Path = DEFAULT_STATE_FILE,
423 state_key: str = 'all',
424 start_after_id: int | None = None,
425 reset_state: bool = False,
426 ) -> None: 474 ) -> None:
427 hk_conn = get_hk_songs_conn() 475 hk_conn = get_hk_songs_conn()
428 src_conn = get_source_conn() 476 src_conn = get_source_conn()
...@@ -430,25 +478,21 @@ def run( ...@@ -430,25 +478,21 @@ def run(
430 pg_conn = get_pg_conn() 478 pg_conn = get_pg_conn()
431 bucket = get_oss_bucket() 479 bucket = get_oss_bucket()
432 base_url = OSS_CONFIG['base_url'] 480 base_url = OSS_CONFIG['base_url']
433 state_path = Path(state_file)
434 state = _load_state(state_path) if resume and not reset_state else {}
435 resume_start_id = 0
436 if resume:
437 resume_start_id = _state_last_id(state, state_key)
438 if start_after_id is not None:
439 resume_start_id = start_after_id
440
441 total_ok = total_err = 0 481 total_ok = total_err = 0
442 imported: list[dict] = [] 482 imported: list[dict] = []
443 483
444 try: 484 try:
445 for i, batch in enumerate(tqdm( 485 batch_index = 0
446 iter_hk_songs_batches(hk_conn, BATCH_SIZE, start_after_id=resume_start_id), 486 pbar = tqdm(desc='batches')
447 desc='batches', 487 while max_batches is None or batch_index < max_batches:
448 )): 488 with pg_conn.cursor() as pg_cur:
449 if max_batches is not None and i >= max_batches: 489 pending_records = fetch_pending_yinyan_song_records(pg_cur, BATCH_SIZE)
490 if not pending_records:
450 break 491 break
451 song_ids = [int(r['source_song_id']) for r in batch if r.get('source_song_id')] 492
493 song_ids = [int(r['song_id']) for r in pending_records]
494 pending_by_song = {int(r['song_id']): int(r['record_id']) for r in pending_records}
495 hk_by_song = fetch_hk_songs_by_source_ids(hk_conn, song_ids)
452 platform_records = fetch_platform_records(src_conn, song_ids) 496 platform_records = fetch_platform_records(src_conn, song_ids)
453 497
454 # index platform records by source_song_id 498 # index platform records by source_song_id
...@@ -458,12 +502,15 @@ def run( ...@@ -458,12 +502,15 @@ def run(
458 pr_by_song.setdefault(int(pr['source_song_id']), []).append(pr) 502 pr_by_song.setdefault(int(pr['source_song_id']), []).append(pr)
459 503
460 with pg_conn.cursor() as pg_cur: 504 with pg_conn.cursor() as pg_cur:
461 for hk_row in batch: 505 pushed_count = 0
462 src_id = int(hk_row['source_song_id']) if hk_row.get('source_song_id') else None 506 for src_id in song_ids:
507 hk_row = hk_by_song.get(src_id)
508 if not hk_row:
509 continue
463 if not src_id or src_id not in pr_by_song: 510 if not src_id or src_id not in pr_by_song:
464 continue 511 continue
465 primary_record = select_primary_record(pr_by_song[src_id])
466 wrote_yinyan_record = False 512 wrote_yinyan_record = False
513 pending_record_id = pending_by_song[src_id]
467 for pr in pr_by_song[src_id]: 514 for pr in pr_by_song[src_id]:
468 processor = _PROCESSORS.get(pr['platform']) 515 processor = _PROCESSORS.get(pr['platform'])
469 if not processor: 516 if not processor:
...@@ -471,9 +518,19 @@ def run( ...@@ -471,9 +518,19 @@ def run(
471 pg_cur.execute('SAVEPOINT sp_song') 518 pg_cur.execute('SAVEPOINT sp_song')
472 try: 519 try:
473 result = processor(hk_row, pr, spider_conn, pg_cur, bucket, base_url) 520 result = processor(hk_row, pr, spider_conn, pg_cur, bucket, base_url)
474 if result and primary_record and not wrote_yinyan_record: 521 if (
475 upsert_yinyan_song_records(pg_cur, [(src_id, int(primary_record['record_id']))]) 522 result
523 and not wrote_yinyan_record
524 and int(pr['record_id']) == pending_record_id
525 ):
526 upsert_yinyan_song_records(pg_cur, [{
527 'song_id': src_id,
528 'record_id': pending_record_id,
529 'platform': result['platform'],
530 'platform_song_id': int(result['platform_song_id']),
531 }])
476 wrote_yinyan_record = True 532 wrote_yinyan_record = True
533 pushed_count += 1
477 pg_cur.execute('RELEASE SAVEPOINT sp_song') 534 pg_cur.execute('RELEASE SAVEPOINT sp_song')
478 total_ok += 1 535 total_ok += 1
479 if result: 536 if result:
...@@ -485,9 +542,12 @@ def run( ...@@ -485,9 +542,12 @@ def run(
485 hk_row.get('name'), pr['platform'], e) 542 hk_row.get('name'), pr['platform'], e)
486 total_err += 1 543 total_err += 1
487 pg_conn.commit() 544 pg_conn.commit()
488 if resume: 545 if pushed_count == 0:
489 state.setdefault(state_key, {})['last_hk_songs_id'] = int(batch[-1]['id']) 546 log.error("No yinyan_song_records rows were marked pushed in this batch; stopping to avoid retry loop")
490 _save_state(state_path, state) 547 break
548 batch_index += 1
549 pbar.update(1)
550 pbar.close()
491 551
492 finally: 552 finally:
493 hk_conn.close() 553 hk_conn.close()
......
1 """工具函数模块"""
2 import asyncio
3 import hashlib
4 import logging
5 import re
6 from datetime import datetime
7 from urllib.parse import urlparse
8 from zoneinfo import ZoneInfo
9
10 try:
11 import httpx
12 except ModuleNotFoundError:
13 httpx = None
14
15 try:
16 from app.core.config import settings
17 from app.core.oss_client import oss_client
18 except ModuleNotFoundError:
19 settings = None
20 oss_client = None
21
22 CHINA_TZ = ZoneInfo("Asia/Shanghai")
23 logger = logging.getLogger(__name__)
24
25 # OSS 上传默认超时(秒),优先使用全局配置
26 _OSS_UPLOAD_TIMEOUT = settings.OSS_UPLOAD_TIMEOUT_SECONDS if settings else 30
27 # 音频下载默认超时(秒),优先使用全局配置
28 _AUDIO_DOWNLOAD_TIMEOUT = settings.AUDIO_DOWNLOAD_TIMEOUT_SECONDS if settings else 30
29 # 音频下载最大限制(字节),优先使用全局配置
30 _MAX_AUDIO_SIZE = settings.MAX_AUDIO_DOWNLOAD_SIZE if settings else 50 * 1024 * 1024
31
32 # 歌词中常见的词曲作者匹配规则
33 _LYRICIST_PATTERNS = [
34 r"作\s*词\s*[::]\s*([^\r\n//]+)",
35 r"词\s*[::]\s*([^\r\n//]+)",
36 r"Lyrics?\s*[::]\s*([^\r\n//]+)",
37 ]
38 _COMPOSER_PATTERNS = [
39 r"作\s*曲\s*[::]\s*([^\r\n//]+)",
40 r"曲\s*[::]\s*([^\r\n//]+)",
41 r"Composer\s*[::]\s*([^\r\n//]+)",
42 ]
43
44
45 def _search_with_patterns(patterns: list, text: str) -> str:
46 """根据多个正则匹配文本,返回首个命中分组"""
47 if not text:
48 return ""
49 for pattern in patterns:
50 match = re.search(pattern, text)
51 if match:
52 return match.group(1).strip()
53 return ""
54
55
56 def extract_lyricist_composer(lyric: str) -> tuple[str, str]:
57 """从歌词文本中提取词作者和曲作者
58
59 Returns:
60 (lyricist_name, composer_name)
61 """
62 lyricist = _search_with_patterns(_LYRICIST_PATTERNS, lyric)
63 composer = _search_with_patterns(_COMPOSER_PATTERNS, lyric)
64 return lyricist, composer
65
66
67 def now_cn() -> datetime:
68 """返回中国时区的当前时间(无时区信息),保留微秒"""
69 return datetime.now(CHINA_TZ).replace(tzinfo=None)
70
71
72 def extract_plain_lyric(lyric: str) -> str:
73 """去除 LRC 歌词中的时间戳和标签,保留纯文本
74
75 Args:
76 lyric: 原始歌词内容(可能含 [mm:ss.xx] 时间戳和 [ti:xxx] 等标签)
77
78 Returns:
79 纯文本歌词,行之间用换行符连接
80 """
81 if not lyric:
82 return ""
83 lines = []
84 for line in lyric.splitlines():
85 # 去除 LRC 时间戳 [mm:ss.xx] 或 [mm:ss.xxx]
86 cleaned = re.sub(r"\[\d{2}:\d{2}(?:\.\d{2,3})?\]", "", line)
87 # 去除标签 [xx:yy]
88 cleaned = re.sub(r"\[[a-zA-Z]+:[^\]]+\]", "", cleaned)
89 cleaned = cleaned.strip()
90 if cleaned:
91 lines.append(cleaned)
92 return "\n".join(lines)
93
94
95 def upload_plain_lyric_to_bucket(platform: str, unique_id: str, lyric: str, bucket, base_url: str) -> str:
96 """将歌词去时间戳后上传到当前 ETL 使用的 OSS bucket"""
97 plain_lyric = extract_plain_lyric(lyric)
98 if not plain_lyric:
99 return ""
100 oss_key = f"crawler/{platform}/lyric/{unique_id}.txt"
101 bucket.put_object(oss_key, plain_lyric.encode("utf-8"))
102 return f"{base_url.rstrip('/')}/{oss_key}"
103
104
105 async def upload_lyric_to_oss(platform: str, unique_id: str, lyric: str) -> str:
106 """将歌词去除时间戳后上传为纯文本文件到 OSS
107
108 Args:
109 platform: 平台标识,如 qqmusic/kugou/kuwo/netease/migu
110 unique_id: 平台唯一标识,如 song_mid/hash/rid 等
111 lyric: 原始歌词内容
112
113 Returns:
114 OSS 文件访问 URL,上传失败或歌词为空返回空字符串
115 """
116 if not lyric:
117 return ""
118 if oss_client is None:
119 logger.error("OSS client is not configured")
120 return ""
121 plain_lyric = extract_plain_lyric(lyric)
122 if not plain_lyric:
123 return ""
124 oss_key = f"lyrics/{platform}/{unique_id}.txt"
125 try:
126 file_url = await asyncio.wait_for(
127 asyncio.to_thread(
128 oss_client.upload_bytes,
129 plain_lyric.encode("utf-8"),
130 oss_key,
131 "public-read",
132 "text/plain; charset=utf-8",
133 ),
134 timeout=_OSS_UPLOAD_TIMEOUT,
135 )
136 return file_url or ""
137 except asyncio.TimeoutError:
138 logger.exception(f"上传歌词到 OSS 超时 {_OSS_UPLOAD_TIMEOUT}s {platform}/{unique_id}")
139 return ""
140 except Exception as e:
141 logger.exception(f"上传歌词到 OSS 失败 {platform}/{unique_id}: {e}")
142 return ""
143
144
145 async def download_and_upload_audio(
146 audio_url: str,
147 oss_key: str,
148 *,
149 headers: dict | None = None,
150 allow_external_oss_url: bool = False,
151 ) -> tuple[str, str]:
152 """流式下载音频并上传到 OSS
153
154 内置大小限制、下载超时、上传超时保护,防止大文件或慢网络拖垮 worker。
155
156 Args:
157 audio_url: 音频下载地址
158 oss_key: OSS 对象键,如 songs/qqmusic/xxxx.mp3
159 headers: 可选的下载请求头
160 allow_external_oss_url: 若 audio_url 已是当前 OSS 域名下的地址,是否直接透传
161
162 Returns:
163 (OSS 文件 URL, 音频 MD5),失败返回 ("", "")
164 """
165 if not audio_url:
166 return "", ""
167
168 if allow_external_oss_url and settings.OSS_FILE_BASE_NAME and audio_url.startswith(settings.OSS_FILE_BASE_NAME):
169 return audio_url, ""
170
171 audio_bytes = bytearray()
172 try:
173 async with httpx.AsyncClient(timeout=_AUDIO_DOWNLOAD_TIMEOUT) as client:
174 async with client.stream("GET", audio_url, headers=headers or {}) as response:
175 response.raise_for_status()
176 content_length = response.headers.get("Content-Length")
177 if content_length and int(content_length) > _MAX_AUDIO_SIZE:
178 logger.warning(
179 f"音频文件过大,跳过: {oss_key}, "
180 f"size={int(content_length) / 1024 / 1024:.2f}MB"
181 )
182 return "", ""
183 async for chunk in response.aiter_bytes(chunk_size=64 * 1024):
184 audio_bytes.extend(chunk)
185 if len(audio_bytes) > _MAX_AUDIO_SIZE:
186 logger.warning(
187 f"音频下载超过大小限制,跳过: {oss_key}, "
188 f"size>{_MAX_AUDIO_SIZE / 1024 / 1024:.2f}MB"
189 )
190 return "", ""
191 except Exception as e:
192 logger.error(f"下载音频失败 {oss_key}: {e}")
193 return "", ""
194
195 audio_bytes = bytes(audio_bytes)
196 audio_md5 = compute_audio_md5(audio_bytes)
197 try:
198 file_url = await asyncio.wait_for(
199 asyncio.to_thread(oss_client.upload_bytes, audio_bytes, oss_key),
200 timeout=_OSS_UPLOAD_TIMEOUT,
201 )
202 except asyncio.TimeoutError:
203 logger.error(f"上传音频到 OSS 超时 {_OSS_UPLOAD_TIMEOUT}s: {oss_key}")
204 return "", ""
205 except Exception as e:
206 logger.error(f"上传音频到 OSS 失败 {oss_key}: {e}")
207 return "", ""
208 return file_url or "", audio_md5
209
210
211 _CONTENT_TYPE_EXT_MAP = {
212 "image/jpeg": "jpg",
213 "image/jpg": "jpg",
214 "image/png": "png",
215 "image/webp": "webp",
216 "image/gif": "gif",
217 }
218
219
220 def _guess_image_ext(content_type: str, url: str) -> str:
221 """根据 Content-Type 或 URL 路径推断图片扩展名,默认 jpg"""
222 if content_type:
223 mime = content_type.split(";")[0].strip().lower()
224 ext = _CONTENT_TYPE_EXT_MAP.get(mime)
225 if ext:
226 return ext
227 path = urlparse(url).path
228 suffix = path.rsplit(".", 1)[-1].lower()
229 if suffix in {"jpg", "jpeg", "png", "webp", "gif"}:
230 return "jpg" if suffix == "jpeg" else suffix
231 return "jpg"
232
233
234 async def upload_cover_to_oss(platform: str, unique_id: str, image_url: str) -> str:
235 """下载封面图片并上传到 OSS
236
237 Args:
238 platform: 平台标识,如 qqmusic/kugou/kuwo/netease/migu
239 unique_id: 平台唯一标识,如 song_mid/hash/rid 等
240 image_url: 封面图片原始 URL
241
242 Returns:
243 OSS 文件访问 URL,上传失败或 URL 为空返回空字符串
244 """
245 if not image_url:
246 return ""
247 try:
248 async with httpx.AsyncClient(timeout=10, follow_redirects=True) as client:
249 resp = await client.get(image_url, headers={
250 "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
251 "Referer": "https://www.kugou.com/",
252 })
253 resp.raise_for_status()
254 image_bytes = resp.content
255 content_type = resp.headers.get("content-type", "")
256 except Exception as e:
257 logger.exception(f"下载封面图片失败 {platform}/{unique_id} {image_url}: {e}")
258 return ""
259
260 ext = _guess_image_ext(content_type, image_url)
261 oss_key = f"covers/{platform}/{unique_id}.{ext}"
262 mime = _CONTENT_TYPE_EXT_MAP.get(content_type.split(";")[0].strip().lower(), f"image/{ext}")
263 try:
264 file_url = await asyncio.wait_for(
265 asyncio.to_thread(
266 oss_client.upload_bytes,
267 image_bytes,
268 oss_key,
269 "public-read",
270 mime,
271 ),
272 timeout=_OSS_UPLOAD_TIMEOUT,
273 )
274 return file_url or ""
275 except asyncio.TimeoutError:
276 logger.exception(f"上传封面到 OSS 超时 {_OSS_UPLOAD_TIMEOUT}s {platform}/{unique_id}")
277 return ""
278 except Exception as e:
279 logger.exception(f"上传封面到 OSS 失败 {platform}/{unique_id}: {e}")
280 return ""
281
282
283 def compute_audio_md5(audio_bytes: bytes) -> str:
284 """计算音频字节流的 MD5 值(32 位小写十六进制字符串)
285
286 Args:
287 audio_bytes: 音频文件字节流
288
289 Returns:
290 MD5 字符串,输入为空时返回空字符串
291 """
292 if not audio_bytes:
293 return ""
294 return hashlib.md5(audio_bytes).hexdigest()
1 import uuid 1 import uuid
2 2
3 3
4 def upsert_yinyan_song_records(cur, pairs: list[tuple[int, int]]) -> None: 4 def insert_yinyan_song_records(cur, records: list[dict]) -> None:
5 """pairs: [(source_song_id, hk_music_record_id), ...]""" 5 """Initialize yinyan_song_records rows before crawler import."""
6 if not pairs: 6 if not records:
7 return 7 return
8 cur.executemany( 8 cur.executemany(
9 "DELETE FROM yinyan_song_records WHERE song_id = %s", 9 """
10 [(song_id,) for song_id, _ in pairs], 10 INSERT INTO yinyan_song_records (song_id, record_id, is_yinyan_push)
11 VALUES (%s, %s, FALSE)
12 ON CONFLICT (song_id, record_id) DO NOTHING
13 """,
14 [(r['song_id'], r['record_id']) for r in records],
11 ) 15 )
16
17
18 def fetch_pending_yinyan_song_records(cur, limit: int) -> list[dict]:
19 cur.execute(
20 """
21 SELECT song_id, record_id
22 FROM yinyan_song_records
23 WHERE is_yinyan_push = FALSE
24 ORDER BY song_id
25 LIMIT %s
26 """,
27 (limit,),
28 )
29 rows = cur.fetchall()
30 return [{'song_id': row[0], 'record_id': row[1]} for row in rows]
31
32
33 def upsert_yinyan_song_records(cur, records: list[dict]) -> None:
34 """Mark pre-initialized yinyan song-record rows as pushed to crawler."""
35 if not records:
36 return
12 cur.executemany( 37 cur.executemany(
13 """ 38 """
14 INSERT INTO yinyan_song_records (song_id, record_id) 39 UPDATE yinyan_song_records
15 VALUES (%s, %s) 40 SET platform = %s,
41 platform_song_id = %s,
42 is_yinyan_push = TRUE
43 WHERE song_id = %s AND record_id = %s
16 """, 44 """,
17 pairs, 45 [
46 (r['platform'], r['platform_song_id'], r['song_id'], r['record_id'])
47 for r in records
48 ],
18 ) 49 )
19 50
20 51
...@@ -67,8 +98,8 @@ def upsert_qq_songs(cur, songs: list[dict]) -> None: ...@@ -67,8 +98,8 @@ def upsert_qq_songs(cur, songs: list[dict]) -> None:
67 (id, platform_song_id, mid, album_id, cover, title, name, duration, 98 (id, platform_song_id, mid, album_id, cover, title, name, duration,
68 lyric, composer_name, lyricist_name, url, lyric_url, 99 lyric, composer_name, lyricist_name, url, lyric_url,
69 platform_index_url, published_at, singers, status, created_at, updated_at, 100 platform_index_url, published_at, singers, status, created_at, updated_at,
70 provider_name, crawler_source_data) 101 audio_md5, provider_name, crawler_source_data)
71 VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb, 0, NOW(), NOW(), %s, %s::json) 102 VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb, 0, NOW(), NOW(), %s, %s, %s::json)
72 ON CONFLICT (platform_song_id) DO NOTHING 103 ON CONFLICT (platform_song_id) DO NOTHING
73 """ 104 """
74 rows = [( 105 rows = [(
...@@ -77,6 +108,7 @@ def upsert_qq_songs(cur, songs: list[dict]) -> None: ...@@ -77,6 +108,7 @@ def upsert_qq_songs(cur, songs: list[dict]) -> None:
77 s.get('lyric'), s.get('composer_name'), s.get('lyricist_name'), 108 s.get('lyric'), s.get('composer_name'), s.get('lyricist_name'),
78 s.get('url', ''), s.get('lyric_url'), 109 s.get('url', ''), s.get('lyric_url'),
79 s.get('platform_index_url'), s.get('published_at'), s.get('singers_json', '[]'), 110 s.get('platform_index_url'), s.get('published_at'), s.get('singers_json', '[]'),
111 s.get('audio_md5'),
80 s.get('provider_name'), s.get('crawler_source_data'), 112 s.get('provider_name'), s.get('crawler_source_data'),
81 ) for s in songs] 113 ) for s in songs]
82 cur.executemany(sql, rows) 114 cur.executemany(sql, rows)
...@@ -157,8 +189,8 @@ def upsert_kugou_songs(cur, songs: list[dict]) -> None: ...@@ -157,8 +189,8 @@ def upsert_kugou_songs(cur, songs: list[dict]) -> None:
157 (id, platform_song_id, hash, album_audio_id, album_id, cover, title, name, duration, 189 (id, platform_song_id, hash, album_audio_id, album_id, cover, title, name, duration,
158 lyric, composer_name, lyricist_name, url, lyric_url, 190 lyric, composer_name, lyricist_name, url, lyric_url,
159 platform_index_url, published_at, singers, status, created_at, updated_at, 191 platform_index_url, published_at, singers, status, created_at, updated_at,
160 provider_name, crawler_source_data) 192 audio_md5, provider_name, crawler_source_data)
161 VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb, 0, NOW(), NOW(), %s, %s::json) 193 VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb, 0, NOW(), NOW(), %s, %s, %s::json)
162 ON CONFLICT (platform_song_id) DO NOTHING 194 ON CONFLICT (platform_song_id) DO NOTHING
163 """ 195 """
164 rows = [( 196 rows = [(
...@@ -168,6 +200,7 @@ def upsert_kugou_songs(cur, songs: list[dict]) -> None: ...@@ -168,6 +200,7 @@ def upsert_kugou_songs(cur, songs: list[dict]) -> None:
168 s.get('lyric'), s.get('composer_name'), s.get('lyricist_name'), 200 s.get('lyric'), s.get('composer_name'), s.get('lyricist_name'),
169 s.get('url', ''), s.get('lyric_url'), 201 s.get('url', ''), s.get('lyric_url'),
170 s.get('platform_index_url'), s.get('published_at'), s.get('singers_json', '[]'), 202 s.get('platform_index_url'), s.get('published_at'), s.get('singers_json', '[]'),
203 s.get('audio_md5'),
171 s.get('provider_name'), s.get('crawler_source_data'), 204 s.get('provider_name'), s.get('crawler_source_data'),
172 ) for s in songs] 205 ) for s in songs]
173 cur.executemany(sql, rows) 206 cur.executemany(sql, rows)
...@@ -244,8 +277,8 @@ def upsert_netease_songs(cur, songs: list[dict]) -> None: ...@@ -244,8 +277,8 @@ def upsert_netease_songs(cur, songs: list[dict]) -> None:
244 (id, platform_song_id, album_id, cover, title, name, duration, 277 (id, platform_song_id, album_id, cover, title, name, duration,
245 lyric, composer_name, lyricist_name, url, lyric_url, 278 lyric, composer_name, lyricist_name, url, lyric_url,
246 platform_index_url, published_at, album, singers, status, created_at, updated_at, 279 platform_index_url, published_at, album, singers, status, created_at, updated_at,
247 provider_name, crawler_source_data) 280 audio_md5, provider_name, crawler_source_data)
248 VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s::json, %s::jsonb, 0, NOW(), NOW(), %s, %s::json) 281 VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s::json, %s::jsonb, 0, NOW(), NOW(), %s, %s, %s::json)
249 ON CONFLICT (platform_song_id) DO NOTHING 282 ON CONFLICT (platform_song_id) DO NOTHING
250 """ 283 """
251 rows = [( 284 rows = [(
...@@ -254,6 +287,7 @@ def upsert_netease_songs(cur, songs: list[dict]) -> None: ...@@ -254,6 +287,7 @@ def upsert_netease_songs(cur, songs: list[dict]) -> None:
254 s.get('lyric'), s.get('composer_name'), s.get('lyricist_name'), 287 s.get('lyric'), s.get('composer_name'), s.get('lyricist_name'),
255 s.get('url', ''), s.get('lyric_url'), 288 s.get('url', ''), s.get('lyric_url'),
256 s.get('platform_index_url'), s.get('published_at'), s.get('album_json'), s.get('singers_json', '[]'), 289 s.get('platform_index_url'), s.get('published_at'), s.get('album_json'), s.get('singers_json', '[]'),
290 s.get('audio_md5'),
257 s.get('provider_name'), s.get('crawler_source_data'), 291 s.get('provider_name'), s.get('crawler_source_data'),
258 ) for s in songs] 292 ) for s in songs]
259 cur.executemany(sql, rows) 293 cur.executemany(sql, rows)
......
1 #!/usr/bin/env python3 1 #!/usr/bin/env python3
2 import argparse 2 import argparse
3 from etl_to_crawler.config import PLATFORM_QQ, PLATFORM_KUGOU, PLATFORM_NETEASE, PLATFORMS 3 from etl_to_crawler.config import PLATFORM_QQ, PLATFORM_KUGOU, PLATFORM_NETEASE, PLATFORMS
4 from etl_to_crawler.runner import run 4 from etl_to_crawler.runner import initialize_yinyan_song_records, run
5 5
6 PLATFORM_MAP = { 6 PLATFORM_MAP = {
7 'qq': PLATFORM_QQ, 7 'qq': PLATFORM_QQ,
...@@ -16,14 +16,8 @@ if __name__ == '__main__': ...@@ -16,14 +16,8 @@ if __name__ == '__main__':
16 help='要导入的平台(默认 all)') 16 help='要导入的平台(默认 all)')
17 parser.add_argument('--max-batches', type=int, default=None, 17 parser.add_argument('--max-batches', type=int, default=None,
18 help='最多处理多少批次(冒烟测试用)') 18 help='最多处理多少批次(冒烟测试用)')
19 parser.add_argument('--resume', action='store_true', 19 parser.add_argument('--init-yinyan-records', action='store_true',
20 help='启用断点续传,按 hk_songs_test.id 从上次成功提交的批次后继续') 20 help='只初始化 yinyan_song_records 待导入状态表,不执行 crawler 导入')
21 parser.add_argument('--state-file', default='output/etl_to_crawler_state.json',
22 help='断点状态文件路径')
23 parser.add_argument('--start-after-id', type=int, default=None,
24 help='手动指定从哪个 hk_songs_test.id 之后开始读取')
25 parser.add_argument('--reset-state', action='store_true',
26 help='忽略已有断点状态,从头或 --start-after-id 指定位置重新开始')
27 args = parser.parse_args() 21 args = parser.parse_args()
28 22
29 if args.platform == 'all': 23 if args.platform == 'all':
...@@ -32,12 +26,7 @@ if __name__ == '__main__': ...@@ -32,12 +26,7 @@ if __name__ == '__main__':
32 platforms = [PLATFORM_MAP[args.platform]] 26 platforms = [PLATFORM_MAP[args.platform]]
33 27
34 print(f"Starting ETL for platforms: {platforms}") 28 print(f"Starting ETL for platforms: {platforms}")
35 run( 29 if args.init_yinyan_records:
36 platforms, 30 initialize_yinyan_song_records(platforms, max_batches=args.max_batches)
37 max_batches=args.max_batches, 31 else:
38 resume=args.resume, 32 run(platforms, max_batches=args.max_batches)
39 state_file=args.state_file,
40 state_key=args.platform,
41 start_after_id=args.start_after_id,
42 reset_state=args.reset_state,
43 )
......
1 from unittest.mock import MagicMock, patch 1 from unittest.mock import MagicMock, patch
2 from etl_to_crawler.oss import transfer_url 2 from etl_to_crawler.oss import transfer_url, transfer_url_with_md5
3 3
4 ARCHIVE_URL = "https://archive-dev.oss-cn-beijing.aliyuncs.com/some/path.mp3" 4 ARCHIVE_URL = "https://archive-dev.oss-cn-beijing.aliyuncs.com/some/path.mp3"
5 OTHER_URL = "https://hikoon-data-platform.oss-cn-beijing.aliyuncs.com/qq-audio/abc.mp3" 5 OTHER_URL = "https://hikoon-data-platform.oss-cn-beijing.aliyuncs.com/qq-audio/abc.mp3"
...@@ -31,3 +31,15 @@ def test_external_url_downloads_and_uploads(): ...@@ -31,3 +31,15 @@ def test_external_url_downloads_and_uploads():
31 result = transfer_url(OTHER_URL, "crawler/qq/audio/abc.mp3", bucket, BASE_URL) 31 result = transfer_url(OTHER_URL, "crawler/qq/audio/abc.mp3", bucket, BASE_URL)
32 bucket.put_object.assert_called_once_with("crawler/qq/audio/abc.mp3", fake_content) 32 bucket.put_object.assert_called_once_with("crawler/qq/audio/abc.mp3", fake_content)
33 assert result == f"{BASE_URL}/crawler/qq/audio/abc.mp3" 33 assert result == f"{BASE_URL}/crawler/qq/audio/abc.mp3"
34
35
36 def test_transfer_url_with_md5_hashes_downloaded_content_before_upload():
37 bucket = MagicMock()
38 fake_content = b"audio_bytes"
39 with patch('etl_to_crawler.oss.requests.get') as mock_get:
40 mock_get.return_value.content = fake_content
41 mock_get.return_value.raise_for_status = MagicMock()
42 result, audio_md5 = transfer_url_with_md5(OTHER_URL, "crawler/qq/audio/abc.mp3", bucket, BASE_URL)
43 bucket.put_object.assert_called_once_with("crawler/qq/audio/abc.mp3", fake_content)
44 assert result == f"{BASE_URL}/crawler/qq/audio/abc.mp3"
45 assert audio_md5 == "04d43544b267629d9089eaed3b847a99"
......
...@@ -48,12 +48,15 @@ def test_run_imports_all_platform_records_but_writes_one_primary_yinyan_relation ...@@ -48,12 +48,15 @@ def test_run_imports_all_platform_records_but_writes_one_primary_yinyan_relation
48 monkeypatch.setattr(runner, 'get_spider_conn', lambda: _Connection()) 48 monkeypatch.setattr(runner, 'get_spider_conn', lambda: _Connection())
49 monkeypatch.setattr(runner, 'get_pg_conn', lambda: pg_conn) 49 monkeypatch.setattr(runner, 'get_pg_conn', lambda: pg_conn)
50 monkeypatch.setattr(runner, 'get_oss_bucket', lambda: object()) 50 monkeypatch.setattr(runner, 'get_oss_bucket', lambda: object())
51 monkeypatch.setattr(runner, 'iter_hk_songs_batches', lambda conn, batch_size, start_after_id=0: [[{ 51 monkeypatch.setattr(runner, 'fetch_pending_yinyan_song_records', lambda cur, batch_size: [
52 {'song_id': 10, 'record_id': 200},
53 ] if pg_conn.commits == 0 else [])
54 monkeypatch.setattr(runner, 'fetch_hk_songs_by_source_ids', lambda conn, song_ids: {10: {
52 'source_song_id': 10, 55 'source_song_id': 10,
53 'name': '歌', 56 'name': '歌',
54 'audio_url': 'https://example.com/a.mp3', 57 'audio_url': 'https://example.com/a.mp3',
55 'singer': '歌手', 58 'singer': '歌手',
56 }]]) 59 }})
57 monkeypatch.setattr(runner, 'fetch_platform_records', lambda conn, song_ids: [ 60 monkeypatch.setattr(runner, 'fetch_platform_records', lambda conn, song_ids: [
58 { 61 {
59 'source_song_id': 10, 62 'source_song_id': 10,
...@@ -85,37 +88,56 @@ def test_run_imports_all_platform_records_but_writes_one_primary_yinyan_relation ...@@ -85,37 +88,56 @@ def test_run_imports_all_platform_records_but_writes_one_primary_yinyan_relation
85 88
86 processors['1'].assert_called_once() 89 processors['1'].assert_called_once()
87 processors['2'].assert_called_once() 90 processors['2'].assert_called_once()
88 yinyan_writer.assert_called_once_with(pg_conn.cur, [(10, 200)]) 91 yinyan_writer.assert_called_once_with(pg_conn.cur, [{
92 'song_id': 10,
93 'record_id': 200,
94 'platform': 'kugou',
95 'platform_song_id': 200,
96 }])
89 assert pg_conn.commits == 1 97 assert pg_conn.commits == 1
90 98
91 99
92 def test_run_resume_starts_after_saved_id_and_updates_state_after_commit(monkeypatch, tmp_path): 100 def test_initialize_yinyan_song_records_inserts_primary_records(monkeypatch):
93 pg_conn = _PgConnection() 101 pg_conn = _PgConnection()
94 state_file = tmp_path / 'etl_state.json' 102 inserted = []
95 state_file.write_text('{"all": {"last_hk_songs_id": 40}}', encoding='utf-8')
96 seen_start_ids = []
97 103
98 monkeypatch.setattr(runner, 'get_hk_songs_conn', lambda: _Connection()) 104 monkeypatch.setattr(runner, 'get_hk_songs_conn', lambda: _Connection())
99 monkeypatch.setattr(runner, 'get_source_conn', lambda: _Connection()) 105 monkeypatch.setattr(runner, 'get_source_conn', lambda: _Connection())
100 monkeypatch.setattr(runner, 'get_spider_conn', lambda: _Connection())
101 monkeypatch.setattr(runner, 'get_pg_conn', lambda: pg_conn) 106 monkeypatch.setattr(runner, 'get_pg_conn', lambda: pg_conn)
102 monkeypatch.setattr(runner, 'get_oss_bucket', lambda: object())
103 107
104 def fake_batches(conn, batch_size, start_after_id=0): 108 monkeypatch.setattr(runner, 'iter_hk_songs_batches', lambda conn, batch_size: [[
105 seen_start_ids.append(start_after_id)
106 return iter([[
107 {'id': 50, 'source_song_id': 10, 'name': '歌', 'audio_url': 'https://example.com/a.mp3', 'singer': '歌手'}, 109 {'id': 50, 'source_song_id': 10, 'name': '歌', 'audio_url': 'https://example.com/a.mp3', 'singer': '歌手'},
108 {'id': 60, 'source_song_id': 20, 'name': '歌2', 'audio_url': 'https://example.com/b.mp3', 'singer': '歌手2'},
109 ]]) 110 ]])
111 monkeypatch.setattr(runner, 'fetch_platform_records', lambda conn, song_ids: [
112 {
113 'source_song_id': 10,
114 'record_id': 100,
115 'platform': '1',
116 'platform_unique_key': 'qq-mid',
117 'platform_mid': '100',
118 'album_audio_id': None,
119 'is_main_version': 0,
120 'is_high': 0,
121 'pub_time': '2020-01-01',
122 },
123 {
124 'source_song_id': 10,
125 'record_id': 200,
126 'platform': '2',
127 'platform_unique_key': '200',
128 'platform_mid': 'kg-hash',
129 'album_audio_id': None,
130 'is_main_version': 1,
131 'is_high': 0,
132 'pub_time': '2021-01-01',
133 },
134 ])
135 monkeypatch.setattr(runner, 'insert_yinyan_song_records', lambda cur, rows: inserted.extend(rows))
110 136
111 monkeypatch.setattr(runner, 'iter_hk_songs_batches', fake_batches) 137 runner.initialize_yinyan_song_records(['1', '2'])
112 monkeypatch.setattr(runner, 'fetch_platform_records', lambda conn, song_ids: [])
113
114 runner.run(['1', '2', '4'], resume=True, state_file=state_file, state_key='all')
115 138
116 assert seen_start_ids == [40] 139 assert inserted == [{'song_id': 10, 'record_id': 200}]
117 assert pg_conn.commits == 1 140 assert pg_conn.commits == 1
118 assert '"last_hk_songs_id": 60' in state_file.read_text(encoding='utf-8')
119 141
120 142
121 def test_process_netease_builds_album_json_for_song_insert(monkeypatch): 143 def test_process_netease_builds_album_json_for_song_insert(monkeypatch):
...@@ -147,6 +169,7 @@ def test_process_netease_builds_album_json_for_song_insert(monkeypatch): ...@@ -147,6 +169,7 @@ def test_process_netease_builds_album_json_for_song_insert(monkeypatch):
147 }) 169 })
148 monkeypatch.setattr(runner, 'fetch_netease_singers', lambda conn, song_ids: {}) 170 monkeypatch.setattr(runner, 'fetch_netease_singers', lambda conn, song_ids: {})
149 monkeypatch.setattr(runner, '_safe_transfer', lambda url, oss_key, bucket, base_url: url) 171 monkeypatch.setattr(runner, '_safe_transfer', lambda url, oss_key, bucket, base_url: url)
172 monkeypatch.setattr(runner, '_safe_transfer_audio', lambda url, oss_key, bucket, base_url: (url, 'audio-md5'))
150 monkeypatch.setattr(runner, 'upsert_netease_singers', lambda cur, singers: None) 173 monkeypatch.setattr(runner, 'upsert_netease_singers', lambda cur, singers: None)
151 monkeypatch.setattr(runner, 'upsert_netease_albums', lambda cur, albums: None) 174 monkeypatch.setattr(runner, 'upsert_netease_albums', lambda cur, albums: None)
152 monkeypatch.setattr(runner, 'upsert_netease_songs', lambda cur, songs: inserted_songs.extend(songs)) 175 monkeypatch.setattr(runner, 'upsert_netease_songs', lambda cur, songs: inserted_songs.extend(songs))
...@@ -174,3 +197,60 @@ def test_process_netease_builds_album_json_for_song_insert(monkeypatch): ...@@ -174,3 +197,60 @@ def test_process_netease_builds_album_json_for_song_insert(monkeypatch):
174 assert inserted_songs[0]['album_json'] 197 assert inserted_songs[0]['album_json']
175 assert '"id": 20' in inserted_songs[0]['album_json'] 198 assert '"id": 20' in inserted_songs[0]['album_json']
176 assert '"title": "专辑"' in inserted_songs[0]['album_json'] 199 assert '"title": "专辑"' in inserted_songs[0]['album_json']
200
201
202 def test_process_netease_keeps_timestamped_lyric_and_uploads_plain_lyric(monkeypatch):
203 pg_cur = MagicMock()
204 pg_cur.fetchone.return_value = ('song-uuid',)
205 inserted_songs = []
206 uploaded = {}
207
208 class Bucket:
209 def put_object(self, key, body):
210 uploaded['key'] = key
211 uploaded['body'] = body
212
213 monkeypatch.setattr(runner, 'fetch_netease_songs', lambda conn, song_ids: {
214 300: {
215 'id': 300,
216 'album_id': None,
217 'cover': 'https://example.com/cover.jpg',
218 'title': '录音标题',
219 'duration': 180,
220 'lyric': '[ti:歌名]\n[00:01.00]第一句\n[00:02.00]第二句',
221 'composer_name': '曲作者',
222 'lyricist_name': '词作者',
223 'platform_index_url': None,
224 'published_at': '2020-01-02',
225 },
226 })
227 monkeypatch.setattr(runner, 'fetch_netease_singers', lambda conn, song_ids: {})
228 monkeypatch.setattr(runner, '_safe_transfer', lambda url, oss_key, bucket, base_url: url)
229 monkeypatch.setattr(runner, '_safe_transfer_audio', lambda url, oss_key, bucket, base_url: (url, 'audio-md5'))
230 monkeypatch.setattr(runner, 'upsert_netease_singers', lambda cur, singers: None)
231 monkeypatch.setattr(runner, 'upsert_netease_albums', lambda cur, albums: None)
232 monkeypatch.setattr(runner, 'upsert_netease_songs', lambda cur, songs: inserted_songs.extend(songs))
233 monkeypatch.setattr(runner, 'upsert_netease_singer_songs', lambda cur, pairs: None)
234
235 runner._process_netease(
236 {
237 'name': '词曲名',
238 'audio_url': 'https://example.com/audio.mp3',
239 'lyrics_url': 'https://example.com/original.lrc',
240 'cover_url': '',
241 'composer': '词曲曲作者',
242 'lyricist': '词曲词作者',
243 'issue_time': '2019-01-01',
244 'song_time': 120,
245 },
246 {'platform_unique_key': '300'},
247 spider_conn=object(),
248 pg_cur=pg_cur,
249 bucket=Bucket(),
250 base_url='https://bucket.example.com',
251 )
252
253 assert inserted_songs[0]['lyric'] == '[ti:歌名]\n[00:01.00]第一句\n[00:02.00]第二句'
254 assert inserted_songs[0]['audio_md5'] == 'audio-md5'
255 assert inserted_songs[0]['lyric_url'] == 'https://bucket.example.com/crawler/netease/lyric/300.txt'
256 assert uploaded['body'].decode('utf-8') == '第一句\n第二句'
......
1 from unittest.mock import MagicMock, call 1 from unittest.mock import MagicMock, call
2 from etl_to_crawler.writer import ( 2 from etl_to_crawler.writer import (
3 insert_yinyan_song_records,
3 upsert_kugou_albums, 4 upsert_kugou_albums,
4 upsert_kugou_singers, 5 upsert_kugou_singers,
5 upsert_kugou_songs, 6 upsert_kugou_songs,
...@@ -41,18 +42,35 @@ def test_upsert_qq_singer_songs(): ...@@ -41,18 +42,35 @@ def test_upsert_qq_singer_songs():
41 assert 'crawler_qqmusic_singer_songs' in sql 42 assert 'crawler_qqmusic_singer_songs' in sql
42 43
43 44
44 def test_upsert_yinyan_song_records_replaces_existing_song_relation(): 45 def test_upsert_yinyan_song_records_marks_existing_relation_as_pushed():
45 cur = MagicMock() 46 cur = MagicMock()
46 upsert_yinyan_song_records(cur, [(10, 100), (11, 101)]) 47 upsert_yinyan_song_records(cur, [
48 {'song_id': 10, 'record_id': 100, 'platform': 'qq', 'platform_song_id': 1000},
49 {'song_id': 11, 'record_id': 101, 'platform': 'kugou', 'platform_song_id': 1001},
50 ])
47 51
48 assert cur.executemany.call_count == 2 52 sql, rows = cur.executemany.call_args[0]
49 delete_sql, delete_rows = cur.executemany.call_args_list[0][0] 53 assert 'UPDATE yinyan_song_records' in sql
50 insert_sql, insert_rows = cur.executemany.call_args_list[1][0] 54 assert 'platform = %s' in sql
51 assert 'DELETE FROM yinyan_song_records' in delete_sql 55 assert 'platform_song_id = %s' in sql
52 assert 'WHERE song_id = %s' in delete_sql 56 assert 'is_yinyan_push = TRUE' in sql
53 assert delete_rows == [(10,), (11,)] 57 assert 'WHERE song_id = %s AND record_id = %s' in sql
54 assert 'INSERT INTO yinyan_song_records' in insert_sql 58 assert rows == [('qq', 1000, 10, 100), ('kugou', 1001, 11, 101)]
55 assert insert_rows == [(10, 100), (11, 101)] 59
60
61 def test_insert_yinyan_song_records_initializes_unpushed_rows():
62 cur = MagicMock()
63 insert_yinyan_song_records(cur, [
64 {'song_id': 10, 'record_id': 100},
65 {'song_id': 11, 'record_id': 101},
66 ])
67
68 sql, rows = cur.executemany.call_args[0]
69 assert 'INSERT INTO yinyan_song_records' in sql
70 assert 'is_yinyan_push' in sql
71 assert 'FALSE' in sql
72 assert 'ON CONFLICT (song_id, record_id) DO NOTHING' in sql
73 assert rows == [(10, 100), (11, 101)]
56 74
57 75
58 def test_upsert_netease_songs_writes_album_json_column(): 76 def test_upsert_netease_songs_writes_album_json_column():
...@@ -104,12 +122,14 @@ def test_upsert_kugou_songs_writes_provider_and_source_data(): ...@@ -104,12 +122,14 @@ def test_upsert_kugou_songs_writes_provider_and_source_data():
104 'singers_json': '[]', 122 'singers_json': '[]',
105 'provider_name': 'yinyan', 123 'provider_name': 'yinyan',
106 'crawler_source_data': '{"id": 200}', 124 'crawler_source_data': '{"id": 200}',
125 'audio_md5': 'md5-200',
107 }]) 126 }])
108 127
109 sql, rows = cur.executemany.call_args[0] 128 sql, rows = cur.executemany.call_args[0]
129 assert 'audio_md5' in sql
110 assert 'provider_name, crawler_source_data' in sql 130 assert 'provider_name, crawler_source_data' in sql
111 assert '%s::json' in sql 131 assert '%s::json' in sql
112 assert rows[0][-2:] == ('yinyan', '{"id": 200}') 132 assert rows[0][-3:] == ('md5-200', 'yinyan', '{"id": 200}')
113 133
114 134
115 def test_upsert_kugou_singers_writes_provider_and_source_data(): 135 def test_upsert_kugou_singers_writes_provider_and_source_data():
...@@ -211,10 +231,12 @@ def test_upsert_qq_entities_write_provider_and_source_data(): ...@@ -211,10 +231,12 @@ def test_upsert_qq_entities_write_provider_and_source_data():
211 'singers_json': '[]', 231 'singers_json': '[]',
212 'provider_name': 'yinyan', 232 'provider_name': 'yinyan',
213 'crawler_source_data': '{"id": 200}', 233 'crawler_source_data': '{"id": 200}',
234 'audio_md5': 'md5-200',
214 }]) 235 }])
215 sql, rows = cur.executemany.call_args[0] 236 sql, rows = cur.executemany.call_args[0]
237 assert 'audio_md5' in sql
216 assert 'provider_name, crawler_source_data' in sql 238 assert 'provider_name, crawler_source_data' in sql
217 assert rows[0][-2:] == ('yinyan', '{"id": 200}') 239 assert rows[0][-3:] == ('md5-200', 'yinyan', '{"id": 200}')
218 240
219 241
220 def test_upsert_netease_entities_write_provider_and_source_data(): 242 def test_upsert_netease_entities_write_provider_and_source_data():
...@@ -273,7 +295,9 @@ def test_upsert_netease_entities_write_provider_and_source_data(): ...@@ -273,7 +295,9 @@ def test_upsert_netease_entities_write_provider_and_source_data():
273 'singers_json': '[]', 295 'singers_json': '[]',
274 'provider_name': 'yinyan', 296 'provider_name': 'yinyan',
275 'crawler_source_data': '{"id": 300}', 297 'crawler_source_data': '{"id": 300}',
298 'audio_md5': 'md5-300',
276 }]) 299 }])
277 sql, rows = cur.executemany.call_args[0] 300 sql, rows = cur.executemany.call_args[0]
301 assert 'audio_md5' in sql
278 assert 'provider_name, crawler_source_data' in sql 302 assert 'provider_name, crawler_source_data' in sql
279 assert rows[0][-2:] == ('yinyan', '{"id": 300}') 303 assert rows[0][-3:] == ('md5-300', 'yinyan', '{"id": 300}')
......