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 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 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)]