Commit 5a9a4064 5a9a40645e5690f6c52d09380f51f4097dbbb88d by 沈秋雨

Merge branch 'feat/etl-hk-songs-to-crawler'

2 parents fac42d3e dcddb309
1 import os
2 from dotenv import load_dotenv
3
4 load_dotenv()
5
6 SOURCE_DB = {
7 'host': os.environ['SOURCE_DB_HOST'],
8 'port': int(os.environ['SOURCE_DB_PORT']),
9 'user': os.environ['SOURCE_DB_USER'],
10 'password': os.environ['SOURCE_DB_PASSWORD'],
11 'database': os.environ['SOURCE_DB_NAME'],
12 'charset': 'utf8mb4',
13 }
14
15 HK_SONGS_DB = {
16 'host': os.environ['TARGET_DB_HOST'],
17 'port': int(os.environ['TARGET_DB_PORT']),
18 'user': os.environ['TARGET_DB_USER'],
19 'password': os.environ['TARGET_DB_PASSWORD'],
20 'database': os.environ['TARGET_DB_NAME'],
21 'charset': 'utf8mb4',
22 }
23
24 CRAWLER_DB = {
25 'host': os.environ['CRAWLER_DB_HOST'],
26 'port': int(os.environ.get('CRAWLER_DB_PORT', '5432')),
27 'user': os.environ['CRAWLER_DB_USER'],
28 'password': os.environ['CRAWLER_DB_PASSWORD'],
29 'database': os.environ['CRAWLER_DB_NAME'],
30 'ssl_context': None if os.environ.get('CRAWLER_DB_SSL', 'false').lower() != 'true' else True,
31 }
32
33 OSS_CONFIG = {
34 'access_key_id': os.environ['OSS_ACCESS_KEY_ID'],
35 'access_key_secret': os.environ['OSS_ACCESS_KEY_SECRET'],
36 'endpoint': os.environ['OSS_ENDPOINT'],
37 'bucket_name': os.environ['OSS_BUCKET_NAME'],
38 'base_url': f"https://{os.environ['OSS_BUCKET_NAME']}.{os.environ['OSS_ENDPOINT']}",
39 }
40
41 PLATFORM_QQ = '1'
42 PLATFORM_KUGOU = '2'
43 PLATFORM_NETEASE = '4'
44 PLATFORMS = [PLATFORM_QQ, PLATFORM_KUGOU, PLATFORM_NETEASE]
45
46 BATCH_SIZE = 3
1 import pymysql
2 import pymysql.cursors
3 import pg8000
4 import oss2
5 from .config import SOURCE_DB, HK_SONGS_DB, CRAWLER_DB, OSS_CONFIG
6
7
8 def get_source_conn() -> pymysql.Connection:
9 return pymysql.connect(**SOURCE_DB, cursorclass=pymysql.cursors.DictCursor)
10
11
12 def get_spider_conn() -> pymysql.Connection:
13 cfg = SOURCE_DB.copy()
14 cfg['database'] = 'hikoon-data-spider'
15 return pymysql.connect(**cfg, cursorclass=pymysql.cursors.DictCursor)
16
17
18 def get_hk_songs_conn() -> pymysql.Connection:
19 return pymysql.connect(**HK_SONGS_DB, cursorclass=pymysql.cursors.DictCursor)
20
21
22 def get_pg_conn() -> pg8000.Connection:
23 return pg8000.connect(**CRAWLER_DB)
24
25
26 def get_oss_bucket() -> oss2.Bucket:
27 auth = oss2.Auth(OSS_CONFIG['access_key_id'], OSS_CONFIG['access_key_secret'])
28 return oss2.Bucket(auth, OSS_CONFIG['endpoint'], OSS_CONFIG['bucket_name'])
1 import re
2
3 _TIMESTAMP_RE = re.compile(r'\[\d{2}:\d{2}\.\d{2,3}\]')
4 _META_TAG_RE = re.compile(r'\[[a-zA-Z]+:[^\]]*\]')
5
6
7 def strip_timestamps(text: str | None) -> str:
8 if not text:
9 return ''
10 text = _META_TAG_RE.sub('', text)
11 text = _TIMESTAMP_RE.sub('', text)
12 lines = [line.strip() for line in text.splitlines() if line.strip()]
13 return '\n'.join(lines)
1 import requests
2 import oss2
3 from urllib.parse import urlparse
4
5 ARCHIVE_DEV_HOST = 'archive-dev.oss-cn-beijing.aliyuncs.com'
6
7
8 def transfer_url(url: str | None, oss_key: str, bucket: oss2.Bucket, base_url: str) -> str:
9 """
10 将 url 指向的文件转移到 archive-dev OSS 的 oss_key 路径。
11 若 url 为空或已在 archive-dev,直接返回原 url(不上传)。
12 返回新的 archive-dev URL。
13 """
14 if not url:
15 return ''
16 if ARCHIVE_DEV_HOST in url:
17 return url
18 resp = requests.get(url, timeout=30)
19 resp.raise_for_status()
20 bucket.put_object(oss_key, resp.content)
21 return f"{base_url.rstrip('/')}/{oss_key}"
22
23
24 def build_oss_key(platform: str, category: str, filename: str) -> str:
25 """
26 构造 archive-dev 内的存储路径。
27 platform: 'qq' | 'kugou' | 'netease'
28 category: 'audio' | 'cover' | 'singer' | 'album'
29 filename: 带扩展名的文件名
30 """
31 return f"crawler/{platform}/{category}/{filename}"
1 from typing import Iterator
2 import pymysql
3 from .config import PLATFORMS
4
5
6 _HK_SONGS_QUERY = """
7 SELECT id, name, lyricist, composer, audio_url, lyrics_url,
8 cover_url, singer, issue_time, source_song_id, song_time
9 FROM hk_songs_test
10 WHERE deleted = '0'
11 AND id > %s
12 AND name IS NOT NULL AND name != ''
13 AND audio_url IS NOT NULL AND audio_url != ''
14 AND singer IS NOT NULL AND singer != ''
15 ORDER BY id
16 LIMIT %s
17 """
18
19 _PLATFORM_QUERY = """
20 SELECT
21 sar.song_id AS source_song_id,
22 mr.id AS record_id,
23 mr.platform,
24 mr.platform_unique_key,
25 mr.platform_mid,
26 mr.album_audio_id,
27 sar.is_main_version,
28 mr.is_high,
29 mr.pub_time
30 FROM hk_song_and_record sar
31 JOIN hk_music_record mr ON mr.id = sar.record_id
32 WHERE sar.song_id IN ({placeholders})
33 AND mr.platform IN ('1','2','4')
34 AND mr.platform_unique_key IS NOT NULL
35 AND mr.platform_unique_key != ''
36 AND mr.deleted = 0
37 ORDER BY sar.song_id,
38 COALESCE(sar.is_main_version, 0) DESC,
39 COALESCE(mr.is_high, 0) DESC,
40 (mr.pub_time IS NULL) ASC,
41 mr.pub_time ASC,
42 mr.id ASC
43 """
44
45
46 def iter_hk_songs_batches(
47 conn: pymysql.Connection,
48 batch_size: int,
49 start_after_id: int = 0,
50 ) -> Iterator[list[dict]]:
51 last_id = start_after_id
52 with conn.cursor() as cur:
53 while True:
54 cur.execute(_HK_SONGS_QUERY, (last_id, batch_size))
55 rows = cur.fetchall()
56 if not rows:
57 break
58 yield rows
59 last_id = int(rows[-1]['id'])
60 if len(rows) < batch_size:
61 break
62
63
64 def fetch_platform_records(source_conn: pymysql.Connection, song_ids: list[int]) -> list[dict]:
65 if not song_ids:
66 return []
67 placeholders = ','.join(['%s'] * len(song_ids))
68 query = _PLATFORM_QUERY.format(placeholders=placeholders)
69 with source_conn.cursor() as cur:
70 cur.execute(query, song_ids)
71 rows = cur.fetchall()
72
73 return select_platform_records(rows)
74
75
76 def select_platform_records(rows: list[dict]) -> list[dict]:
77 # 每个 (source_song_id, platform) 保留一条,用于继续导入多个平台的录音数据。
78 seen = {}
79 for row in sorted(rows, key=_record_priority):
80 key = (row['source_song_id'], row['platform'])
81 if key not in seen:
82 seen[key] = row
83 return list(seen.values())
84
85
86 def select_primary_record(rows: list[dict]) -> dict | None:
87 if not rows:
88 return None
89 return sorted(rows, key=_record_priority)[0]
90
91
92 def _record_priority(row: dict) -> tuple:
93 pub_time = row.get('pub_time')
94 return (
95 row['source_song_id'],
96 -(int(row.get('is_main_version') or 0)),
97 -(int(row.get('is_high') or 0)),
98 pub_time is None,
99 pub_time or '',
100 int(row.get('record_id') or 0),
101 )
1 import uuid
2 import json
3 import logging
4 from pathlib import Path
5 from tqdm import tqdm
6
7 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
9 from .reader import iter_hk_songs_batches, fetch_platform_records, select_primary_record
10 from .spider import (
11 fetch_qq_songs, fetch_qq_singers,
12 fetch_kugou_songs, fetch_kugou_singers,
13 fetch_netease_songs, fetch_netease_singers,
14 )
15 from .writer import (
16 upsert_yinyan_song_records,
17 upsert_qq_singers, upsert_qq_albums, upsert_qq_songs,
18 upsert_qq_singer_songs, upsert_qq_singer_albums,
19 upsert_kugou_singers, upsert_kugou_albums, upsert_kugou_songs,
20 upsert_kugou_singer_songs, upsert_kugou_singer_albums,
21 upsert_netease_singers, upsert_netease_albums, upsert_netease_songs,
22 upsert_netease_singer_songs, upsert_netease_singer_albums,
23 )
24 from .oss import transfer_url, build_oss_key
25 from .lyric import strip_timestamps
26
27 logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s')
28 log = logging.getLogger(__name__)
29
30
31 def _safe_transfer(url, oss_key, bucket, base_url):
32 try:
33 return transfer_url(url, oss_key, bucket, base_url)
34 except Exception as e:
35 log.warning("OSS transfer failed for %s: %s", url, e)
36 return url # 失败时保留原 URL,不阻断流程
37
38
39 def _process_qq(hk_row: dict, pr: dict, spider_conn, pg_cur, bucket, base_url):
40 mid = pr['platform_unique_key']
41 songs_map = fetch_qq_songs(spider_conn, [mid])
42 if mid not in songs_map:
43 return
44 sp = songs_map[mid]
45 song_id_int = sp['id']
46
47 singers_map = fetch_qq_singers(spider_conn, [song_id_int])
48 singer_list = singers_map.get(song_id_int, [])
49
50 # OSS 转移
51 audio_url = _safe_transfer(
52 hk_row['audio_url'],
53 build_oss_key('qq', 'audio', mid + '.mp3'),
54 bucket, base_url
55 )
56 cover_url = _safe_transfer(
57 hk_row.get('cover_url') or sp.get('cover', ''),
58 build_oss_key('qq', 'cover', str(song_id_int) + '.jpg'),
59 bucket, base_url
60 )
61 album_cover = ''
62 if sp.get('album_id') and sp.get('album_cover'):
63 album_cover = _safe_transfer(
64 sp['album_cover'],
65 build_oss_key('qq', 'album', str(sp['album_id']) + '.jpg'),
66 bucket, base_url
67 )
68
69 # 歌手头像转移 + 写入 singers
70 singer_rows = []
71 for sg in singer_list:
72 avatar = _safe_transfer(
73 sg.get('avatar', ''),
74 build_oss_key('qq', 'singer', sg['mid'] + '.jpg'),
75 bucket, base_url
76 )
77 singer_rows.append({**sg, 'id': sg['singer_id'], 'avatar': avatar})
78 upsert_qq_singers(pg_cur, singer_rows)
79
80 # singers JSONB
81 singers_json = json.dumps([{
82 'name': sg['name'],
83 'singer_id': sg['singer_id'],
84 'platform_singer_id': sg['mid'],
85 } for sg in singer_list], ensure_ascii=False)
86
87 # 写入 album
88 album_id = None
89 if sp.get('album_id'):
90 album_id = sp['album_id']
91 upsert_qq_albums(pg_cur, [{
92 'id': sp['album_id'], 'mid': sp.get('album_mid') or '',
93 'cover': album_cover, 'title': sp.get('album_title') or '',
94 'intro': sp.get('album_intro'), 'type': sp.get('album_type') or '',
95 'company_id': sp.get('company_id') or 0, 'company': sp.get('company') or '',
96 'is_owner': sp.get('is_owner') or 0, 'published_at': sp.get('album_published_at'),
97 }])
98
99 # 写入 song
100 platform_song_id = int(pr['platform_mid']) if pr.get('platform_mid') else song_id_int
101 song_uuid = str(uuid.uuid4())
102 upsert_qq_songs(pg_cur, [{
103 'song_uuid': song_uuid,
104 'platform_song_id': platform_song_id,
105 'mid': mid,
106 'album_id': album_id,
107 'cover': cover_url,
108 'title': sp.get('title', hk_row['name']),
109 'name': hk_row['name'],
110 'duration': sp.get('duration') or hk_row.get('song_time') or 0,
111 'lyric': strip_timestamps(sp.get('lyric')),
112 'composer_name': sp.get('composer_name') or hk_row.get('composer'),
113 'lyricist_name': sp.get('lyricist_name') or hk_row.get('lyricist'),
114 'url': audio_url,
115 'lyric_url': hk_row.get('lyrics_url'),
116 'platform_index_url': sp.get('platform_index_url') or f'https://y.qq.com/n/ryqq/songDetail/{mid}',
117 'published_at': sp.get('published_at') or hk_row.get('issue_time'),
118 'singers_json': singers_json,
119 }])
120 # 查询实际 UUID(ON CONFLICT DO NOTHING 时使用已有 UUID)
121 pg_cur.execute('SELECT id FROM crawler_qqmusic_songs WHERE platform_song_id = %s', (platform_song_id,))
122 row = pg_cur.fetchone()
123 if row:
124 song_uuid = str(row[0])
125
126 # singer_songs / singer_albums
127 upsert_qq_singer_songs(pg_cur, [(sg['singer_id'], song_uuid) for sg in singer_list])
128 if album_id:
129 upsert_qq_singer_albums(pg_cur, [(sg['singer_id'], album_id) for sg in singer_list])
130 return {'platform': 'qq', 'platform_song_id': platform_song_id, 'mid': mid, 'title': hk_row['name']}
131
132
133 def _process_kugou(hk_row: dict, pr: dict, spider_conn, pg_cur, bucket, base_url):
134 song_id = int(pr['platform_unique_key'])
135 songs_map = fetch_kugou_songs(spider_conn, [song_id])
136 if song_id not in songs_map:
137 return
138 sp = songs_map[song_id]
139
140 singers_map = fetch_kugou_singers(spider_conn, [song_id])
141 singer_list = singers_map.get(song_id, [])
142
143 audio_url = _safe_transfer(
144 hk_row['audio_url'],
145 build_oss_key('kugou', 'audio', str(song_id) + '.mp3'),
146 bucket, base_url
147 )
148 cover_url = _safe_transfer(
149 hk_row.get('cover_url') or sp.get('cover', ''),
150 build_oss_key('kugou', 'cover', str(song_id) + '.jpg'),
151 bucket, base_url
152 )
153 album_cover = ''
154 if sp.get('album_id') and sp.get('album_cover'):
155 album_cover = _safe_transfer(
156 sp['album_cover'],
157 build_oss_key('kugou', 'album', str(sp['album_id']) + '.jpg'),
158 bucket, base_url
159 )
160
161 singer_rows = []
162 for sg in singer_list:
163 avatar = _safe_transfer(
164 sg.get('avatar', ''),
165 build_oss_key('kugou', 'singer', str(sg['singer_id']) + '.jpg'),
166 bucket, base_url
167 )
168 singer_rows.append({**sg, 'id': sg['singer_id'], 'avatar': avatar})
169 upsert_kugou_singers(pg_cur, singer_rows)
170
171 singers_json = json.dumps([{
172 'name': sg['name'],
173 'singer_id': sg['singer_id'],
174 'platform_singer_id': str(sg['singer_id']),
175 } for sg in singer_list], ensure_ascii=False)
176
177 album_id = None
178 if sp.get('album_id'):
179 album_id = sp['album_id']
180 upsert_kugou_albums(pg_cur, [{
181 'id': sp['album_id'], 'cover': album_cover,
182 'title': sp.get('album_title') or '', 'intro': sp.get('album_intro'),
183 'type': sp.get('album_type') or '', 'company_id': sp.get('company_id') or 0,
184 'company': sp.get('company') or '', 'is_owner': sp.get('is_owner') or 0,
185 'published_at': sp.get('album_published_at'),
186 }])
187
188 song_uuid = str(uuid.uuid4())
189 upsert_kugou_songs(pg_cur, [{
190 'song_uuid': song_uuid,
191 'platform_song_id': song_id,
192 'hash': sp.get('hid', pr.get('platform_mid', '')),
193 'album_audio_id': sp.get('album_audio_id') or pr.get('album_audio_id') or 0,
194 'album_id': album_id,
195 'cover': cover_url,
196 'title': sp.get('title', hk_row['name']),
197 'name': hk_row['name'],
198 'duration': sp.get('duration') or hk_row.get('song_time') or 0,
199 'lyric': strip_timestamps(sp.get('lyric')),
200 'composer_name': sp.get('composer_name') or hk_row.get('composer'),
201 'lyricist_name': sp.get('lyricist_name') or hk_row.get('lyricist'),
202 'url': audio_url,
203 'lyric_url': hk_row.get('lyrics_url'),
204 'platform_index_url': sp.get('platform_index_url') or f'http://www.kugou.com/song/#hash={sp.get("hid") or pr.get("platform_mid", "")}',
205 'published_at': sp.get('published_at') or hk_row.get('issue_time'),
206 'singers_json': singers_json,
207 }])
208 # 查询实际 UUID(ON CONFLICT DO NOTHING 时使用已有 UUID)
209 pg_cur.execute('SELECT id FROM crawler_kugou_songs WHERE platform_song_id = %s', (song_id,))
210 row = pg_cur.fetchone()
211 if row:
212 song_uuid = str(row[0])
213
214 upsert_kugou_singer_songs(pg_cur, [(sg['singer_id'], song_uuid) for sg in singer_list])
215 if album_id:
216 upsert_kugou_singer_albums(pg_cur, [(sg['singer_id'], album_id) for sg in singer_list])
217 return {'platform': 'kugou', 'platform_song_id': song_id, 'hash': sp.get('hid', ''), 'title': hk_row['name']}
218
219
220 def _process_netease(hk_row: dict, pr: dict, spider_conn, pg_cur, bucket, base_url):
221 song_id = int(pr['platform_unique_key'])
222 songs_map = fetch_netease_songs(spider_conn, [song_id])
223 if song_id not in songs_map:
224 return
225 sp = songs_map[song_id]
226
227 singers_map = fetch_netease_singers(spider_conn, [song_id])
228 singer_list = singers_map.get(song_id, [])
229
230 audio_url = _safe_transfer(
231 hk_row['audio_url'],
232 build_oss_key('netease', 'audio', str(song_id) + '.mp3'),
233 bucket, base_url
234 )
235 cover_url = _safe_transfer(
236 hk_row.get('cover_url') or sp.get('cover', ''),
237 build_oss_key('netease', 'cover', str(song_id) + '.jpg'),
238 bucket, base_url
239 )
240 album_cover = ''
241 if sp.get('album_id') and sp.get('album_cover'):
242 album_cover = _safe_transfer(
243 sp['album_cover'],
244 build_oss_key('netease', 'album', str(sp['album_id']) + '.jpg'),
245 bucket, base_url
246 )
247
248 singer_rows = []
249 for sg in singer_list:
250 avatar = _safe_transfer(
251 sg.get('avatar', ''),
252 build_oss_key('netease', 'singer', str(sg['singer_id']) + '.jpg'),
253 bucket, base_url
254 )
255 singer_rows.append({**sg, 'id': sg['singer_id'], 'avatar': avatar})
256 upsert_netease_singers(pg_cur, singer_rows)
257
258 singers_json = json.dumps([{
259 'name': sg['name'],
260 'singer_id': sg['singer_id'],
261 'platform_singer_id': str(sg['singer_id']),
262 } for sg in singer_list], ensure_ascii=False)
263
264 album_id = None
265 if sp.get('album_id'):
266 album_id = sp['album_id']
267 upsert_netease_albums(pg_cur, [{
268 'id': sp['album_id'], 'cover': album_cover,
269 'title': sp.get('album_title') or '', 'intro': sp.get('album_intro'),
270 'type': sp.get('album_type') or '', 'company_id': sp.get('company_id') or 0,
271 'company': sp.get('company') or '', 'is_owner': sp.get('is_owner') or 0,
272 'published_at': sp.get('album_published_at'),
273 }])
274
275 song_uuid = str(uuid.uuid4())
276 upsert_netease_songs(pg_cur, [{
277 'song_uuid': song_uuid,
278 'platform_song_id': song_id,
279 'album_id': album_id,
280 'cover': cover_url,
281 'title': sp.get('title', hk_row['name']),
282 'name': hk_row['name'],
283 'duration': sp.get('duration') or hk_row.get('song_time') or 0,
284 'lyric': strip_timestamps(sp.get('lyric')),
285 'composer_name': sp.get('composer_name') or hk_row.get('composer'),
286 'lyricist_name': sp.get('lyricist_name') or hk_row.get('lyricist'),
287 'url': audio_url,
288 'lyric_url': hk_row.get('lyrics_url'),
289 'platform_index_url': sp.get('platform_index_url') or f'https://music.163.com/#/song?id={song_id}',
290 'published_at': sp.get('published_at') or hk_row.get('issue_time'),
291 'singers_json': singers_json,
292 }])
293 # 查询实际 UUID(ON CONFLICT DO NOTHING 时使用已有 UUID)
294 pg_cur.execute('SELECT id FROM crawler_netease_songs WHERE platform_song_id = %s', (song_id,))
295 row = pg_cur.fetchone()
296 if row:
297 song_uuid = str(row[0])
298
299 upsert_netease_singer_songs(pg_cur, [(sg['singer_id'], song_uuid) for sg in singer_list])
300 if album_id:
301 upsert_netease_singer_albums(pg_cur, [(sg['singer_id'], album_id) for sg in singer_list])
302 return {'platform': 'netease', 'platform_song_id': song_id, 'title': hk_row['name']}
303
304
305 _PROCESSORS = {
306 PLATFORM_QQ: _process_qq,
307 PLATFORM_KUGOU: _process_kugou,
308 PLATFORM_NETEASE: _process_netease,
309 }
310
311
312 DEFAULT_STATE_FILE = Path('output/etl_to_crawler_state.json')
313
314
315 def _load_state(path: Path) -> dict:
316 if not path.exists():
317 return {}
318 with path.open('r', encoding='utf-8') as f:
319 return json.load(f)
320
321
322 def _save_state(path: Path, state: dict) -> None:
323 path.parent.mkdir(parents=True, exist_ok=True)
324 with path.open('w', encoding='utf-8') as f:
325 json.dump(state, f, ensure_ascii=False, indent=2, sort_keys=True)
326 f.write('\n')
327
328
329 def _state_last_id(state: dict, key: str) -> int:
330 value = state.get(key, {}).get('last_hk_songs_id', 0)
331 return int(value or 0)
332
333
334 def run(
335 platforms: list[str],
336 max_batches: int | None = None,
337 resume: bool = False,
338 state_file: str | Path = DEFAULT_STATE_FILE,
339 state_key: str = 'all',
340 start_after_id: int | None = None,
341 reset_state: bool = False,
342 ) -> None:
343 hk_conn = get_hk_songs_conn()
344 src_conn = get_source_conn()
345 spider_conn = get_spider_conn()
346 pg_conn = get_pg_conn()
347 bucket = get_oss_bucket()
348 base_url = OSS_CONFIG['base_url']
349 state_path = Path(state_file)
350 state = _load_state(state_path) if resume and not reset_state else {}
351 resume_start_id = 0
352 if resume:
353 resume_start_id = _state_last_id(state, state_key)
354 if start_after_id is not None:
355 resume_start_id = start_after_id
356
357 total_ok = total_err = 0
358 imported: list[dict] = []
359
360 try:
361 for i, batch in enumerate(tqdm(
362 iter_hk_songs_batches(hk_conn, BATCH_SIZE, start_after_id=resume_start_id),
363 desc='batches',
364 )):
365 if max_batches is not None and i >= max_batches:
366 break
367 song_ids = [int(r['source_song_id']) for r in batch if r.get('source_song_id')]
368 platform_records = fetch_platform_records(src_conn, song_ids)
369
370 # index platform records by source_song_id
371 pr_by_song: dict[int, list] = {}
372 for pr in platform_records:
373 if pr['platform'] in platforms:
374 pr_by_song.setdefault(int(pr['source_song_id']), []).append(pr)
375
376 with pg_conn.cursor() as pg_cur:
377 for hk_row in batch:
378 src_id = int(hk_row['source_song_id']) if hk_row.get('source_song_id') else None
379 if not src_id or src_id not in pr_by_song:
380 continue
381 primary_record = select_primary_record(pr_by_song[src_id])
382 wrote_yinyan_record = False
383 for pr in pr_by_song[src_id]:
384 processor = _PROCESSORS.get(pr['platform'])
385 if not processor:
386 continue
387 pg_cur.execute('SAVEPOINT sp_song')
388 try:
389 result = processor(hk_row, pr, spider_conn, pg_cur, bucket, base_url)
390 if result and primary_record and not wrote_yinyan_record:
391 upsert_yinyan_song_records(pg_cur, [(src_id, int(primary_record['record_id']))])
392 wrote_yinyan_record = True
393 pg_cur.execute('RELEASE SAVEPOINT sp_song')
394 total_ok += 1
395 if result:
396 imported.append(result)
397 except Exception as e:
398 pg_cur.execute('ROLLBACK TO SAVEPOINT sp_song')
399 pg_cur.execute('RELEASE SAVEPOINT sp_song')
400 log.error("Error processing song %s platform %s: %s",
401 hk_row.get('name'), pr['platform'], e)
402 total_err += 1
403 pg_conn.commit()
404 if resume:
405 state.setdefault(state_key, {})['last_hk_songs_id'] = int(batch[-1]['id'])
406 _save_state(state_path, state)
407
408 finally:
409 hk_conn.close()
410 src_conn.close()
411 spider_conn.close()
412 pg_conn.close()
413
414 log.info("Done. OK=%d ERR=%d", total_ok, total_err)
415 if imported:
416 print(f"\n导入记录(共 {len(imported)} 条):")
417 for r in imported:
418 p = r['platform']
419 if p == 'qq':
420 print(f" [QQ] platform_song_id={r['platform_song_id']} mid={r['mid']} title={r['title']}")
421 elif p == 'kugou':
422 print(f" [酷狗] platform_song_id={r['platform_song_id']} hash={r['hash']} title={r['title']}")
423 elif p == 'netease':
424 print(f" [网易] platform_song_id={r['platform_song_id']} title={r['title']}")
1 import pymysql
2
3
4 def _ids_query(query_template: str, ids: list, conn: pymysql.Connection) -> list[dict]:
5 if not ids:
6 return []
7 placeholders = ','.join(['%s'] * len(ids))
8 query = query_template.format(placeholders=placeholders)
9 with conn.cursor() as cur:
10 cur.execute(query, ids)
11 return cur.fetchall()
12
13
14 # ─── QQ Music ────────────────────────────────────────────────────────────────
15
16 _QQ_SONGS_SQL = """
17 SELECT s.id, s.mid, s.album_id, s.cover, s.title, s.duration,
18 s.lyric, s.composer_name, s.lyricist_name, s.platform_index_url, s.published_at,
19 a.mid AS album_mid, a.cover AS album_cover, a.title AS album_title,
20 a.intro AS album_intro, a.type AS album_type, a.company_id,
21 a.company, a.is_owner, a.published_at AS album_published_at
22 FROM media_tencent_songs s
23 LEFT JOIN media_tencent_albums a ON a.id = s.album_id
24 WHERE s.mid IN ({placeholders})
25 """
26
27 _QQ_SINGERS_SQL = """
28 SELECT shs.song_id, shs.singer_id, sg.mid, sg.name, sg.avatar,
29 sg.sex, sg.area, sg.`index`, sg.intro, sg.home_url
30 FROM media_tencent_singer_has_songs shs
31 JOIN media_tencent_singers sg ON sg.id = shs.singer_id
32 WHERE shs.song_id IN ({placeholders})
33 """
34
35
36 def fetch_qq_songs(conn: pymysql.Connection, mids: list[str]) -> dict[str, dict]:
37 rows = _ids_query(_QQ_SONGS_SQL, mids, conn)
38 return {row['mid']: row for row in rows}
39
40
41 def fetch_qq_singers(conn: pymysql.Connection, song_ids: list[int]) -> dict[int, list[dict]]:
42 rows = _ids_query(_QQ_SINGERS_SQL, song_ids, conn)
43 result: dict[int, list] = {}
44 for row in rows:
45 result.setdefault(row['song_id'], []).append(row)
46 return result
47
48
49 # ─── Kugou ───────────────────────────────────────────────────────────────────
50
51 _KUGOU_SONGS_SQL = """
52 SELECT s.id, s.hid, s.album_audio_id, s.album_id, s.cover, s.title, s.duration,
53 s.lyric, s.composer_name, s.lyricist_name, s.platform_index_url, s.published_at,
54 a.cover AS album_cover, a.title AS album_title, a.intro AS album_intro,
55 a.type AS album_type, a.company_id, a.company, a.is_owner,
56 a.published_at AS album_published_at
57 FROM media_ku_gou_songs s
58 LEFT JOIN media_ku_gou_albums a ON a.id = s.album_id
59 WHERE s.id IN ({placeholders})
60 """
61
62 _KUGOU_SINGERS_SQL = """
63 SELECT shs.song_id, shs.singer_id, sg.name, sg.avatar,
64 sg.sex, sg.area, sg.`index`, sg.intro, sg.home_url
65 FROM media_ku_gou_singer_has_songs shs
66 JOIN media_ku_gou_singers sg ON sg.id = shs.singer_id
67 WHERE shs.song_id IN ({placeholders})
68 """
69
70
71 def fetch_kugou_songs(conn: pymysql.Connection, song_ids: list[int]) -> dict[int, dict]:
72 rows = _ids_query(_KUGOU_SONGS_SQL, song_ids, conn)
73 return {row['id']: row for row in rows}
74
75
76 def fetch_kugou_singers(conn: pymysql.Connection, song_ids: list[int]) -> dict[int, list[dict]]:
77 rows = _ids_query(_KUGOU_SINGERS_SQL, song_ids, conn)
78 result: dict[int, list] = {}
79 for row in rows:
80 result.setdefault(row['song_id'], []).append(row)
81 return result
82
83
84 # ─── Netease ─────────────────────────────────────────────────────────────────
85
86 _NETEASE_SONGS_SQL = """
87 SELECT s.id, s.album_id, s.cover, s.title, s.duration,
88 s.lyric, s.composer_name, s.lyricist_name, s.platform_index_url, s.published_at,
89 a.cover AS album_cover, a.title AS album_title, a.intro AS album_intro,
90 a.type AS album_type, a.company_id, a.company, a.is_owner,
91 a.published_at AS album_published_at
92 FROM media_netease_songs s
93 LEFT JOIN media_netease_albums a ON a.id = s.album_id
94 WHERE s.id IN ({placeholders})
95 """
96
97 _NETEASE_SINGERS_SQL = """
98 SELECT shs.song_id, shs.singer_id, sg.name, sg.avatar,
99 sg.sex, sg.area, sg.`index`, sg.intro, sg.home_url
100 FROM media_netease_singer_has_songs shs
101 JOIN media_netease_singers sg ON sg.id = shs.singer_id
102 WHERE shs.song_id IN ({placeholders})
103 """
104
105
106 def fetch_netease_songs(conn: pymysql.Connection, song_ids: list[int]) -> dict[int, dict]:
107 rows = _ids_query(_NETEASE_SONGS_SQL, song_ids, conn)
108 return {row['id']: row for row in rows}
109
110
111 def fetch_netease_singers(conn: pymysql.Connection, song_ids: list[int]) -> dict[int, list[dict]]:
112 rows = _ids_query(_NETEASE_SINGERS_SQL, song_ids, conn)
113 result: dict[int, list] = {}
114 for row in rows:
115 result.setdefault(row['song_id'], []).append(row)
116 return result
1 import uuid
2
3
4 def upsert_yinyan_song_records(cur, pairs: list[tuple[int, int]]) -> None:
5 """pairs: [(source_song_id, hk_music_record_id), ...]"""
6 if not pairs:
7 return
8 cur.executemany(
9 "DELETE FROM yinyan_song_records WHERE song_id = %s",
10 [(song_id,) for song_id, _ in pairs],
11 )
12 cur.executemany(
13 """
14 INSERT INTO yinyan_song_records (song_id, record_id)
15 VALUES (%s, %s)
16 """,
17 pairs,
18 )
19
20
21 # ─── QQ Music ────────────────────────────────────────────────────────────────
22
23 def upsert_qq_singers(cur, singers: list[dict]) -> None:
24 if not singers:
25 return
26 sql = """
27 INSERT INTO crawler_qqmusic_singers
28 (id, mid, name, avatar, sex, area, "index", intro, home_url, created_at, updated_at)
29 VALUES (%s, %s, %s, %s, %s::singer_sex, %s::singer_area, %s::singer_index, %s, %s, NOW(), NOW())
30 ON CONFLICT (id) DO NOTHING
31 """
32 rows = [(
33 s['id'], s['mid'], s['name'], s.get('avatar', ''),
34 s.get('sex') or 'U', s.get('area') or '其他', s.get('index') or '#',
35 s.get('intro'), s.get('home_url'),
36 ) for s in singers]
37 cur.executemany(sql, rows)
38
39
40 def upsert_qq_albums(cur, albums: list[dict]) -> None:
41 if not albums:
42 return
43 sql = """
44 INSERT INTO crawler_qqmusic_albums
45 (id, mid, cover, title, intro, type, company_id, company, is_owner, published_at, created_at, updated_at)
46 VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW(), NOW())
47 ON CONFLICT (id) DO NOTHING
48 """
49 rows = [(
50 a['id'], a.get('mid', ''), a.get('cover', ''), a.get('title', ''),
51 a.get('intro'), a.get('type', ''), a.get('company_id', 0) or 0,
52 a.get('company', ''), a.get('is_owner', 0) or 0, a.get('published_at'),
53 ) for a in albums]
54 cur.executemany(sql, rows)
55
56
57 def upsert_qq_songs(cur, songs: list[dict]) -> None:
58 if not songs:
59 return
60 sql = """
61 INSERT INTO crawler_qqmusic_songs
62 (id, platform_song_id, mid, album_id, cover, title, name, duration,
63 lyric, composer_name, lyricist_name, url, lyric_url,
64 platform_index_url, published_at, singers, status, created_at, updated_at)
65 VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb, 0, NOW(), NOW())
66 ON CONFLICT (platform_song_id) DO NOTHING
67 """
68 rows = [(
69 s['song_uuid'], s['platform_song_id'], s['mid'], s.get('album_id'),
70 s.get('cover', ''), s.get('title', ''), s.get('name', ''), s.get('duration', 0) or 0,
71 s.get('lyric'), s.get('composer_name'), s.get('lyricist_name'),
72 s.get('url', ''), s.get('lyric_url'),
73 s.get('platform_index_url'), s.get('published_at'), s.get('singers_json', '[]'),
74 ) for s in songs]
75 cur.executemany(sql, rows)
76
77
78 def upsert_qq_singer_songs(cur, pairs: list[tuple]) -> None:
79 """pairs: [(singer_id, song_uuid), ...]"""
80 if not pairs:
81 return
82 sql = """
83 INSERT INTO crawler_qqmusic_singer_songs (id, singer_id, song_id)
84 VALUES (%s, %s, %s)
85 ON CONFLICT (singer_id, song_id) DO NOTHING
86 """
87 rows = [(str(uuid.uuid4()), singer_id, song_uuid) for singer_id, song_uuid in pairs]
88 cur.executemany(sql, rows)
89
90
91 def upsert_qq_singer_albums(cur, pairs: list[tuple]) -> None:
92 """pairs: [(singer_id, album_id), ...]"""
93 if not pairs:
94 return
95 sql = """
96 INSERT INTO crawler_qqmusic_singer_albums (id, singer_id, album_id)
97 VALUES (%s, %s, %s)
98 ON CONFLICT (singer_id, album_id) DO NOTHING
99 """
100 rows = [(str(uuid.uuid4()), singer_id, album_id) for singer_id, album_id in pairs]
101 cur.executemany(sql, rows)
102
103
104 # ─── Kugou ───────────────────────────────────────────────────────────────────
105
106 def upsert_kugou_singers(cur, singers: list[dict]) -> None:
107 if not singers:
108 return
109 sql = """
110 INSERT INTO crawler_kugou_singers
111 (id, name, avatar, sex, area, "index", intro, home_url, created_at, updated_at)
112 VALUES (%s, %s, %s, %s::kugou_singer_sex, %s::kugou_singer_area, %s::kugou_singer_index, %s, %s, NOW(), NOW())
113 ON CONFLICT (id) DO NOTHING
114 """
115 rows = [(
116 s['id'], s['name'], s.get('avatar', ''),
117 s.get('sex') or 'U', s.get('area') or '其他', s.get('index') or '#',
118 s.get('intro'), s.get('home_url', ''),
119 ) for s in singers]
120 cur.executemany(sql, rows)
121
122
123 def upsert_kugou_albums(cur, albums: list[dict]) -> None:
124 if not albums:
125 return
126 sql = """
127 INSERT INTO crawler_kugou_albums
128 (id, cover, title, intro, type, company_id, company, is_owner, published_at, created_at, updated_at)
129 VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, NOW(), NOW())
130 ON CONFLICT (id) DO NOTHING
131 """
132 rows = [(
133 a['id'], a.get('cover', ''), a.get('title', ''), a.get('intro'),
134 a.get('type', ''), a.get('company_id', 0) or 0,
135 a.get('company', ''), a.get('is_owner', 0) or 0, a.get('published_at'),
136 ) for a in albums]
137 cur.executemany(sql, rows)
138
139
140 def upsert_kugou_songs(cur, songs: list[dict]) -> None:
141 if not songs:
142 return
143 sql = """
144 INSERT INTO crawler_kugou_songs
145 (id, platform_song_id, hash, album_audio_id, album_id, cover, title, name, duration,
146 lyric, composer_name, lyricist_name, url, lyric_url,
147 platform_index_url, published_at, singers, status, created_at, updated_at)
148 VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb, 0, NOW(), NOW())
149 ON CONFLICT (platform_song_id) DO NOTHING
150 """
151 rows = [(
152 s['song_uuid'], s['platform_song_id'], s.get('hash', ''),
153 s.get('album_audio_id', 0) or 0, s.get('album_id'),
154 s.get('cover', ''), s.get('title', ''), s.get('name', ''), s.get('duration', 0) or 0,
155 s.get('lyric'), s.get('composer_name'), s.get('lyricist_name'),
156 s.get('url', ''), s.get('lyric_url'),
157 s.get('platform_index_url'), s.get('published_at'), s.get('singers_json', '[]'),
158 ) for s in songs]
159 cur.executemany(sql, rows)
160
161
162 def upsert_kugou_singer_songs(cur, pairs: list[tuple]) -> None:
163 if not pairs:
164 return
165 sql = """
166 INSERT INTO crawler_kugou_singer_songs (id, singer_id, song_id)
167 VALUES (%s, %s, %s)
168 ON CONFLICT (singer_id, song_id) DO NOTHING
169 """
170 cur.executemany(sql, [(str(uuid.uuid4()), s, sg) for s, sg in pairs])
171
172
173 def upsert_kugou_singer_albums(cur, pairs: list[tuple]) -> None:
174 if not pairs:
175 return
176 sql = """
177 INSERT INTO crawler_kugou_singer_albums (id, singer_id, album_id)
178 VALUES (%s, %s, %s)
179 ON CONFLICT (singer_id, album_id) DO NOTHING
180 """
181 cur.executemany(sql, [(str(uuid.uuid4()), s, a) for s, a in pairs])
182
183
184 # ─── Netease ─────────────────────────────────────────────────────────────────
185
186 def upsert_netease_singers(cur, singers: list[dict]) -> None:
187 if not singers:
188 return
189 sql = """
190 INSERT INTO crawler_netease_singers
191 (id, name, avatar, sex, area, "index", intro, home_url, created_at, updated_at)
192 VALUES (%s, %s, %s, %s::netease_singer_sex, %s::netease_singer_area, %s::netease_singer_index, %s, %s, NOW(), NOW())
193 ON CONFLICT (id) DO NOTHING
194 """
195 rows = [(
196 s['id'], s['name'], s.get('avatar', ''),
197 s.get('sex') or 'U', s.get('area') or '其他', s.get('index') or '#',
198 s.get('intro'), s.get('home_url'),
199 ) for s in singers]
200 cur.executemany(sql, rows)
201
202
203 def upsert_netease_albums(cur, albums: list[dict]) -> None:
204 if not albums:
205 return
206 sql = """
207 INSERT INTO crawler_netease_albums
208 (id, cover, title, intro, type, company_id, company, is_owner, published_at, created_at, updated_at)
209 VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, NOW(), NOW())
210 ON CONFLICT (id) DO NOTHING
211 """
212 rows = [(
213 a['id'], a.get('cover', ''), a.get('title', ''), a.get('intro'),
214 a.get('type', ''), a.get('company_id', 0) or 0,
215 a.get('company', ''), a.get('is_owner', 0) or 0, a.get('published_at'),
216 ) for a in albums]
217 cur.executemany(sql, rows)
218
219
220 def upsert_netease_songs(cur, songs: list[dict]) -> None:
221 if not songs:
222 return
223 sql = """
224 INSERT INTO crawler_netease_songs
225 (id, platform_song_id, album_id, cover, title, name, duration,
226 lyric, composer_name, lyricist_name, url, lyric_url,
227 platform_index_url, published_at, singers, status, created_at, updated_at)
228 VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb, 0, NOW(), NOW())
229 ON CONFLICT (platform_song_id) DO NOTHING
230 """
231 rows = [(
232 s['song_uuid'], s['platform_song_id'], s.get('album_id'),
233 s.get('cover', ''), s.get('title', ''), s.get('name', ''), s.get('duration', 0) or 0,
234 s.get('lyric'), s.get('composer_name'), s.get('lyricist_name'),
235 s.get('url', ''), s.get('lyric_url'),
236 s.get('platform_index_url'), s.get('published_at'), s.get('singers_json', '[]'),
237 ) for s in songs]
238 cur.executemany(sql, rows)
239
240
241 def upsert_netease_singer_songs(cur, pairs: list[tuple]) -> None:
242 if not pairs:
243 return
244 sql = """
245 INSERT INTO crawler_netease_singer_songs (id, singer_id, song_id)
246 VALUES (%s, %s, %s)
247 ON CONFLICT (singer_id, song_id) DO NOTHING
248 """
249 cur.executemany(sql, [(str(uuid.uuid4()), s, sg) for s, sg in pairs])
250
251
252 def upsert_netease_singer_albums(cur, pairs: list[tuple]) -> None:
253 if not pairs:
254 return
255 sql = """
256 INSERT INTO crawler_netease_singer_albums (id, singer_id, album_id)
257 VALUES (%s, %s, %s)
258 ON CONFLICT (singer_id, album_id) DO NOTHING
259 """
260 cur.executemany(sql, [(str(uuid.uuid4()), s, a) for s, a in pairs])
1 pymysql 1 aliyun-python-sdk-core==2.16.0
2 python-dotenv 2 aliyun-python-sdk-kms==2.16.5
3 oss2 3 asn1crypto==1.5.1
4 requests 4 certifi==2026.6.17
5 tqdm 5 cffi==2.0.0
6 opencc-python-reimplemented 6 charset-normalizer==3.4.7
7 pytest 7 cos_python_sdk_v5==1.9.44
8 crcmod==1.7
9 cryptography==49.0.0
10 et_xmlfile==2.0.0
11 exceptiongroup==1.3.1
12 idna==3.18
13 iniconfig==2.3.0
14 jmespath==0.10.0
15 OpenCC==1.4.0
16 opencc-python-reimplemented==0.1.7
17 openpyxl==3.1.5
18 oss2==2.19.1
19 packaging==26.2
20 pg8000==1.31.5
21 pluggy==1.6.0
22 pycparser==3.0
23 pycryptodome==3.23.0
24 Pygments==2.20.0
25 PyMySQL==1.2.0
26 pytest==9.1.1
27 python-dateutil==2.9.0.post0
28 python-dotenv==1.2.2
29 requests==2.34.2
30 scramp==1.4.12
31 six==1.17.0
32 tomli==2.4.1
33 tqdm==4.68.3
34 typing_extensions==4.15.0
35 urllib3==2.7.0
36 xmltodict==1.0.4
......
1 #!/usr/bin/env python3
2 import argparse
3 from etl_to_crawler.config import PLATFORM_QQ, PLATFORM_KUGOU, PLATFORM_NETEASE, PLATFORMS
4 from etl_to_crawler.runner import run
5
6 PLATFORM_MAP = {
7 'qq': PLATFORM_QQ,
8 'kugou': PLATFORM_KUGOU,
9 'netease': PLATFORM_NETEASE,
10 }
11
12 if __name__ == '__main__':
13 parser = argparse.ArgumentParser(description='ETL: hk_songs_test → crawler_dev')
14 parser.add_argument('--platform', default='all',
15 choices=['qq', 'kugou', 'netease', 'all'],
16 help='要导入的平台(默认 all)')
17 parser.add_argument('--max-batches', type=int, default=None,
18 help='最多处理多少批次(冒烟测试用)')
19 parser.add_argument('--resume', action='store_true',
20 help='启用断点续传,按 hk_songs_test.id 从上次成功提交的批次后继续')
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()
28
29 if args.platform == 'all':
30 platforms = PLATFORMS
31 else:
32 platforms = [PLATFORM_MAP[args.platform]]
33
34 print(f"Starting ETL for platforms: {platforms}")
35 run(
36 platforms,
37 max_batches=args.max_batches,
38 resume=args.resume,
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 )
File mode changed
1 from etl_to_crawler.lyric import strip_timestamps
2
3 def test_strips_standard_timestamps():
4 lrc = "[00:12.34]第一行歌词\n[01:23.45]第二行歌词"
5 assert strip_timestamps(lrc) == "第一行歌词\n第二行歌词"
6
7 def test_strips_millisecond_timestamps():
8 lrc = "[00:12.345]带三位毫秒"
9 assert strip_timestamps(lrc) == "带三位毫秒"
10
11 def test_strips_meta_tags():
12 lrc = "[ti:歌曲名]\n[ar:歌手]\n[00:01.00]歌词"
13 result = strip_timestamps(lrc)
14 assert "歌词" in result
15 assert "[ti:" not in result
16
17 def test_empty_and_none():
18 assert strip_timestamps(None) == ''
19 assert strip_timestamps('') == ''
20
21 def test_plain_text_unchanged():
22 assert strip_timestamps("没有时间戳的歌词") == "没有时间戳的歌词"
1 from unittest.mock import MagicMock, patch
2 from etl_to_crawler.oss import transfer_url
3
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"
6 BASE_URL = "https://archive-dev.oss-cn-beijing.aliyuncs.com"
7
8 def test_already_archive_dev_returns_as_is():
9 bucket = MagicMock()
10 result = transfer_url(ARCHIVE_URL, "any/key.mp3", bucket, BASE_URL)
11 assert result == ARCHIVE_URL
12 bucket.put_object.assert_not_called()
13
14 def test_empty_url_returns_empty():
15 bucket = MagicMock()
16 result = transfer_url('', "any/key.mp3", bucket, BASE_URL)
17 assert result == ''
18 bucket.put_object.assert_not_called()
19
20 def test_none_url_returns_empty():
21 bucket = MagicMock()
22 result = transfer_url(None, "any/key.mp3", bucket, BASE_URL)
23 assert result == ''
24
25 def test_external_url_downloads_and_uploads():
26 bucket = MagicMock()
27 fake_content = b"audio_bytes"
28 with patch('etl_to_crawler.oss.requests.get') as mock_get:
29 mock_get.return_value.content = fake_content
30 mock_get.return_value.raise_for_status = MagicMock()
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)
33 assert result == f"{BASE_URL}/crawler/qq/audio/abc.mp3"
1 from unittest.mock import MagicMock
2
3 from etl_to_crawler.reader import (
4 fetch_platform_records,
5 iter_hk_songs_batches,
6 select_primary_record,
7 )
8
9
10 def _make_cursor(rows):
11 cur = MagicMock()
12 cur.__enter__ = MagicMock(return_value=cur)
13 cur.__exit__ = MagicMock(return_value=False)
14 cur.fetchall.return_value = rows
15 return cur
16
17
18 def test_fetch_platform_records_keeps_one_record_per_song_and_platform():
19 conn = MagicMock()
20 conn.cursor.return_value = _make_cursor([
21 {
22 'source_song_id': 10,
23 'record_id': 100,
24 'platform': '1',
25 'platform_unique_key': 'qq-mid',
26 'platform_mid': '100',
27 'album_audio_id': None,
28 'is_main_version': 0,
29 'is_high': 1,
30 'pub_time': '2020-01-01',
31 },
32 {
33 'source_song_id': 10,
34 'record_id': 101,
35 'platform': '2',
36 'platform_unique_key': '200',
37 'platform_mid': 'kg-hash',
38 'album_audio_id': None,
39 'is_main_version': 1,
40 'is_high': 0,
41 'pub_time': '2021-01-01',
42 },
43 {
44 'source_song_id': 10,
45 'record_id': 102,
46 'platform': '4',
47 'platform_unique_key': '300',
48 'platform_mid': None,
49 'album_audio_id': None,
50 'is_main_version': 1,
51 'is_high': 1,
52 'pub_time': '2022-01-01',
53 },
54 ])
55
56 result = fetch_platform_records(conn, [10])
57
58 assert len(result) == 3
59 assert {row['record_id'] for row in result} == {100, 101, 102}
60
61
62 def test_select_primary_record_prefers_main_version_then_is_high():
63 rows = [
64 {
65 'source_song_id': 10,
66 'record_id': 100,
67 'platform': '1',
68 'platform_unique_key': 'qq-mid',
69 'platform_mid': '100',
70 'album_audio_id': None,
71 'is_main_version': 0,
72 'is_high': 1,
73 'pub_time': '2020-01-01',
74 },
75 {
76 'source_song_id': 10,
77 'record_id': 101,
78 'platform': '2',
79 'platform_unique_key': '200',
80 'platform_mid': 'kg-hash',
81 'album_audio_id': None,
82 'is_main_version': 1,
83 'is_high': 0,
84 'pub_time': '2021-01-01',
85 },
86 {
87 'source_song_id': 10,
88 'record_id': 102,
89 'platform': '4',
90 'platform_unique_key': '300',
91 'platform_mid': None,
92 'album_audio_id': None,
93 'is_main_version': 1,
94 'is_high': 1,
95 'pub_time': '2022-01-01',
96 },
97 ]
98
99 assert select_primary_record(rows)['record_id'] == 102
100
101
102 def test_select_primary_record_uses_earliest_pub_time_when_priority_ties():
103 rows = [
104 {
105 'source_song_id': 10,
106 'record_id': 100,
107 'platform': '1',
108 'platform_unique_key': 'qq-mid',
109 'platform_mid': '100',
110 'album_audio_id': None,
111 'is_main_version': 0,
112 'is_high': 0,
113 'pub_time': '2020-01-01',
114 },
115 {
116 'source_song_id': 10,
117 'record_id': 101,
118 'platform': '2',
119 'platform_unique_key': '200',
120 'platform_mid': 'kg-hash',
121 'album_audio_id': None,
122 'is_main_version': 0,
123 'is_high': 0,
124 'pub_time': '2019-01-01',
125 },
126 ]
127
128 assert select_primary_record(rows)['record_id'] == 101
129
130
131 def test_iter_hk_songs_batches_uses_id_cursor_instead_of_offset():
132 cur = _make_cursor([])
133 cur.fetchall.side_effect = [
134 [{'id': 10, 'source_song_id': 100}, {'id': 20, 'source_song_id': 200}],
135 [{'id': 25, 'source_song_id': 250}],
136 ]
137 conn = MagicMock()
138 conn.cursor.return_value = cur
139
140 batches = list(iter_hk_songs_batches(conn, batch_size=2, start_after_id=5))
141
142 assert batches == [
143 [{'id': 10, 'source_song_id': 100}, {'id': 20, 'source_song_id': 200}],
144 [{'id': 25, 'source_song_id': 250}],
145 ]
146 assert cur.execute.call_args_list[0][0][1] == (5, 2)
147 assert cur.execute.call_args_list[1][0][1] == (20, 2)
1 from unittest.mock import MagicMock
2
3 from etl_to_crawler import runner
4
5
6 class _Cursor:
7 def __enter__(self):
8 return self
9
10 def __exit__(self, exc_type, exc, tb):
11 return False
12
13 def execute(self, *args, **kwargs):
14 return None
15
16
17 class _PgConnection:
18 def __init__(self):
19 self.cur = _Cursor()
20 self.commits = 0
21 self.closed = False
22
23 def cursor(self):
24 return self.cur
25
26 def commit(self):
27 self.commits += 1
28
29 def close(self):
30 self.closed = True
31
32
33 class _Connection:
34 def close(self):
35 return None
36
37
38 def test_run_imports_all_platform_records_but_writes_one_primary_yinyan_relation(monkeypatch):
39 pg_conn = _PgConnection()
40 processors = {
41 '1': MagicMock(return_value={'platform': 'qq', 'platform_song_id': 100, 'mid': 'qq-mid', 'title': '歌'}),
42 '2': MagicMock(return_value={'platform': 'kugou', 'platform_song_id': 200, 'hash': 'kg-hash', 'title': '歌'}),
43 }
44 yinyan_writer = MagicMock()
45
46 monkeypatch.setattr(runner, 'get_hk_songs_conn', lambda: _Connection())
47 monkeypatch.setattr(runner, 'get_source_conn', lambda: _Connection())
48 monkeypatch.setattr(runner, 'get_spider_conn', lambda: _Connection())
49 monkeypatch.setattr(runner, 'get_pg_conn', lambda: pg_conn)
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: [[{
52 'source_song_id': 10,
53 'name': '歌',
54 'audio_url': 'https://example.com/a.mp3',
55 'singer': '歌手',
56 }]])
57 monkeypatch.setattr(runner, 'fetch_platform_records', lambda conn, song_ids: [
58 {
59 'source_song_id': 10,
60 'record_id': 100,
61 'platform': '1',
62 'platform_unique_key': 'qq-mid',
63 'platform_mid': '100',
64 'album_audio_id': None,
65 'is_main_version': 0,
66 'is_high': 1,
67 'pub_time': '2020-01-01',
68 },
69 {
70 'source_song_id': 10,
71 'record_id': 200,
72 'platform': '2',
73 'platform_unique_key': '200',
74 'platform_mid': 'kg-hash',
75 'album_audio_id': None,
76 'is_main_version': 1,
77 'is_high': 0,
78 'pub_time': '2021-01-01',
79 },
80 ])
81 monkeypatch.setattr(runner, '_PROCESSORS', processors)
82 monkeypatch.setattr(runner, 'upsert_yinyan_song_records', yinyan_writer)
83
84 runner.run(['1', '2'])
85
86 processors['1'].assert_called_once()
87 processors['2'].assert_called_once()
88 yinyan_writer.assert_called_once_with(pg_conn.cur, [(10, 200)])
89 assert pg_conn.commits == 1
90
91
92 def test_run_resume_starts_after_saved_id_and_updates_state_after_commit(monkeypatch, tmp_path):
93 pg_conn = _PgConnection()
94 state_file = tmp_path / 'etl_state.json'
95 state_file.write_text('{"all": {"last_hk_songs_id": 40}}', encoding='utf-8')
96 seen_start_ids = []
97
98 monkeypatch.setattr(runner, 'get_hk_songs_conn', lambda: _Connection())
99 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)
102 monkeypatch.setattr(runner, 'get_oss_bucket', lambda: object())
103
104 def fake_batches(conn, batch_size, start_after_id=0):
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': '歌手'},
108 {'id': 60, 'source_song_id': 20, 'name': '歌2', 'audio_url': 'https://example.com/b.mp3', 'singer': '歌手2'},
109 ]])
110
111 monkeypatch.setattr(runner, 'iter_hk_songs_batches', fake_batches)
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
116 assert seen_start_ids == [40]
117 assert pg_conn.commits == 1
118 assert '"last_hk_songs_id": 60' in state_file.read_text(encoding='utf-8')
1 from unittest.mock import MagicMock, patch
2 from etl_to_crawler.spider import fetch_qq_songs, fetch_qq_singers
3
4 def _make_cursor(rows):
5 cur = MagicMock()
6 cur.__enter__ = MagicMock(return_value=cur)
7 cur.__exit__ = MagicMock(return_value=False)
8 cur.fetchall.return_value = rows
9 return cur
10
11 def test_fetch_qq_songs_keys_by_mid():
12 conn = MagicMock()
13 conn.cursor.return_value = _make_cursor([
14 {'mid': 'abc123', 'id': 1, 'title': '歌曲A', 'duration': 200,
15 'lyric': None, 'composer_name': None, 'lyricist_name': None,
16 'platform_index_url': None, 'published_at': None,
17 'cover': '', 'album_id': None,
18 'album_mid': None, 'album_title': None, 'album_cover': None,
19 'album_intro': None, 'album_type': None, 'company_id': 0,
20 'company': None, 'is_owner': 0, 'album_published_at': None}
21 ])
22 result = fetch_qq_songs(conn, ['abc123'])
23 assert 'abc123' in result
24 assert result['abc123']['title'] == '歌曲A'
25
26 def test_fetch_qq_songs_empty_list():
27 conn = MagicMock()
28 result = fetch_qq_songs(conn, [])
29 assert result == {}
30 conn.cursor.assert_not_called()
31
32 def test_fetch_qq_singers_keys_by_song_id():
33 conn = MagicMock()
34 conn.cursor.return_value = _make_cursor([
35 {'song_id': 1, 'singer_id': 10, 'mid': 'sg1', 'name': '歌手A',
36 'avatar': '', 'sex': 'M', 'area': '华语', 'index': 'G',
37 'intro': None, 'home_url': None}
38 ])
39 result = fetch_qq_singers(conn, [1])
40 assert 1 in result
41 assert result[1][0]['name'] == '歌手A'
1 from unittest.mock import MagicMock, call
2 from etl_to_crawler.writer import (
3 upsert_qq_singers,
4 upsert_qq_singer_songs,
5 upsert_yinyan_song_records,
6 )
7
8
9 def test_upsert_qq_singers_executes_insert():
10 cur = MagicMock()
11 singers = [{
12 'id': 1, 'mid': 'abc', 'name': '歌手', 'avatar': 'https://archive-dev.oss-cn-beijing.aliyuncs.com/a.jpg',
13 'sex': 'M', 'area': '华语', 'index': 'G', 'intro': None, 'home_url': None
14 }]
15 upsert_qq_singers(cur, singers)
16 assert cur.executemany.called
17 args = cur.executemany.call_args
18 assert 'crawler_qqmusic_singers' in args[0][0]
19 assert 'ON CONFLICT' in args[0][0]
20
21
22 def test_upsert_qq_singers_empty_does_nothing():
23 cur = MagicMock()
24 upsert_qq_singers(cur, [])
25 cur.executemany.assert_not_called()
26
27
28 def test_upsert_qq_singer_songs():
29 cur = MagicMock()
30 upsert_qq_singer_songs(cur, [(1, 'uuid-abc'), (2, 'uuid-def')])
31 assert cur.executemany.called
32 sql = cur.executemany.call_args[0][0]
33 assert 'crawler_qqmusic_singer_songs' in sql
34
35
36 def test_upsert_yinyan_song_records_replaces_existing_song_relation():
37 cur = MagicMock()
38 upsert_yinyan_song_records(cur, [(10, 100), (11, 101)])
39
40 assert cur.executemany.call_count == 2
41 delete_sql, delete_rows = cur.executemany.call_args_list[0][0]
42 insert_sql, insert_rows = cur.executemany.call_args_list[1][0]
43 assert 'DELETE FROM yinyan_song_records' in delete_sql
44 assert 'WHERE song_id = %s' in delete_sql
45 assert delete_rows == [(10,), (11,)]
46 assert 'INSERT INTO yinyan_song_records' in insert_sql
47 assert insert_rows == [(10, 100), (11, 101)]