Commit c403b29e c403b29eb0e01dabe288c2b8dde627a0738defb4 by 沈秋雨

feat(etl): 新增 yinyan_song_records 平台回填与歌手优先录音选取

- 在 ETL 入口增加 --backfill-yinyan-platforms 参数,支持回填缺失平台代码
- 实现回填函数 backfill_yinyan_record_platforms,批量更新 platform 为空的记录
- fetch_all_platform_records 支持获取所有录音供歌手数据缺失的回退使用
- 通过 _pick_record_with_singer 函数优先选取有歌手数据的录音
- 初始化函数跳过已存在 song_id 的批次,避免重复处理
- yinyan_song_records 插入时允许更新 platform 字段,平台数据更完整
- upsert_yinyan_song_records 增加 record_id 更新支持,修正推送逻辑
- 所有平台歌曲标题拆分版本号存储 version 字段,支持特殊版本标记
- 在各平台封面缺失时使用歌曲封面回退,保证封面不为空
- spider 模块新增 probe_*_has_singers 函数校验录音是否有关联歌手
- writer 模块完善更新平台代码的操作及相关查询辅助函数
- utils 新增 split_title_version 助手,提取括号中版本信息
- upload_plain_lyric_to_bucket 上传时添加内容类型头,避免文本编码问题
- 测试覆盖添加回填功能及版本号拆分相关逻辑,提高稳定性维护性
1 parent 07b63e1b
...@@ -43,4 +43,4 @@ PLATFORM_KUGOU = '2' ...@@ -43,4 +43,4 @@ PLATFORM_KUGOU = '2'
43 PLATFORM_NETEASE = '4' 43 PLATFORM_NETEASE = '4'
44 PLATFORMS = [PLATFORM_QQ, PLATFORM_KUGOU, PLATFORM_NETEASE] 44 PLATFORMS = [PLATFORM_QQ, PLATFORM_KUGOU, PLATFORM_NETEASE]
45 45
46 BATCH_SIZE = 3 46 BATCH_SIZE = 4
......
...@@ -54,6 +54,9 @@ ORDER BY sar.song_id, ...@@ -54,6 +54,9 @@ ORDER BY sar.song_id,
54 mr.id ASC 54 mr.id ASC
55 """ 55 """
56 56
57 # 同 _PLATFORM_QUERY,但不做 per-platform 去重,保留同平台全部录音供 singer fallback 遍历
58 _ALL_PLATFORM_RECORDS_QUERY = _PLATFORM_QUERY
59
57 60
58 def iter_hk_songs_batches( 61 def iter_hk_songs_batches(
59 conn: pymysql.Connection, 62 conn: pymysql.Connection,
...@@ -100,6 +103,44 @@ def fetch_platform_records(source_conn: pymysql.Connection, song_ids: list[int]) ...@@ -100,6 +103,44 @@ def fetch_platform_records(source_conn: pymysql.Connection, song_ids: list[int])
100 return select_platform_records(rows) 103 return select_platform_records(rows)
101 104
102 105
106 def fetch_all_platform_records(source_conn: pymysql.Connection, song_ids: list[int]) -> list[dict]:
107 """返回每首歌的全部录音(不做 per-platform 去重),供 singer fallback 遍历。"""
108 if not song_ids:
109 return []
110 placeholders = ','.join(['%s'] * len(song_ids))
111 query = _ALL_PLATFORM_RECORDS_QUERY.format(placeholders=placeholders)
112 with source_conn.cursor() as cur:
113 cur.execute(query, song_ids)
114 rows = cur.fetchall()
115 # 按优先级排序后按 record_id 去重(同一录音可能关联多次)
116 seen_record_ids: set = set()
117 result = []
118 for row in sorted(rows, key=_record_priority):
119 rid = row['record_id']
120 if rid not in seen_record_ids:
121 seen_record_ids.add(rid)
122 result.append(row)
123 return result
124
125
126 def fetch_record_platforms(source_conn: pymysql.Connection, record_ids: list[int]) -> dict[int, str]:
127 """按录音 id 查询平台代码,用于回填 yinyan_song_records.platform。"""
128 if not record_ids:
129 return {}
130 placeholders = ','.join(['%s'] * len(record_ids))
131 query = f"""
132 SELECT id, platform
133 FROM hk_music_record
134 WHERE id IN ({placeholders})
135 AND platform IN ('1','2','4')
136 AND deleted = 0
137 """
138 with source_conn.cursor() as cur:
139 cur.execute(query, record_ids)
140 rows = cur.fetchall()
141 return {int(row['id']): str(row['platform']) for row in rows}
142
143
103 def select_platform_records(rows: list[dict]) -> list[dict]: 144 def select_platform_records(rows: list[dict]) -> list[dict]:
104 # 每个 (source_song_id, platform) 保留一条,用于继续导入多个平台的录音数据。 145 # 每个 (source_song_id, platform) 保留一条,用于继续导入多个平台的录音数据。
105 seen = {} 146 seen = {}
......
...@@ -46,6 +46,18 @@ def fetch_qq_singers(conn: pymysql.Connection, song_ids: list[int]) -> dict[int, ...@@ -46,6 +46,18 @@ def fetch_qq_singers(conn: pymysql.Connection, song_ids: list[int]) -> dict[int,
46 return result 46 return result
47 47
48 48
49 def probe_qq_has_singers(conn: pymysql.Connection, mid: str) -> bool:
50 """检查 QQ 录音(按 mid)在 spider DB 中是否有歌手关联。"""
51 with conn.cursor() as cur:
52 cur.execute(
53 "SELECT 1 FROM media_tencent_songs s "
54 "JOIN media_tencent_singer_has_songs shs ON shs.song_id = s.id "
55 "WHERE s.mid = %s LIMIT 1",
56 (mid,),
57 )
58 return cur.fetchone() is not None
59
60
49 # ─── Kugou ─────────────────────────────────────────────────────────────────── 61 # ─── Kugou ───────────────────────────────────────────────────────────────────
50 62
51 _KUGOU_SONGS_SQL = """ 63 _KUGOU_SONGS_SQL = """
...@@ -81,6 +93,16 @@ def fetch_kugou_singers(conn: pymysql.Connection, song_ids: list[int]) -> dict[i ...@@ -81,6 +93,16 @@ def fetch_kugou_singers(conn: pymysql.Connection, song_ids: list[int]) -> dict[i
81 return result 93 return result
82 94
83 95
96 def probe_kugou_has_singers(conn: pymysql.Connection, song_id: int) -> bool:
97 """检查酷狗录音在 spider DB 中是否有歌手关联。"""
98 with conn.cursor() as cur:
99 cur.execute(
100 "SELECT 1 FROM media_ku_gou_singer_has_songs WHERE song_id = %s LIMIT 1",
101 (song_id,),
102 )
103 return cur.fetchone() is not None
104
105
84 # ─── Netease ───────────────────────────────────────────────────────────────── 106 # ─── Netease ─────────────────────────────────────────────────────────────────
85 107
86 _NETEASE_SONGS_SQL = """ 108 _NETEASE_SONGS_SQL = """
...@@ -114,3 +136,13 @@ def fetch_netease_singers(conn: pymysql.Connection, song_ids: list[int]) -> dict ...@@ -114,3 +136,13 @@ def fetch_netease_singers(conn: pymysql.Connection, song_ids: list[int]) -> dict
114 for row in rows: 136 for row in rows:
115 result.setdefault(row['song_id'], []).append(row) 137 result.setdefault(row['song_id'], []).append(row)
116 return result 138 return result
139
140
141 def probe_netease_has_singers(conn: pymysql.Connection, song_id: int) -> bool:
142 """检查网易云录音在 spider DB 中是否有歌手关联。"""
143 with conn.cursor() as cur:
144 cur.execute(
145 "SELECT 1 FROM media_netease_singer_has_songs WHERE song_id = %s LIMIT 1",
146 (song_id,),
147 )
148 return cur.fetchone() is not None
......
...@@ -92,13 +92,29 @@ def extract_plain_lyric(lyric: str) -> str: ...@@ -92,13 +92,29 @@ def extract_plain_lyric(lyric: str) -> str:
92 return "\n".join(lines) 92 return "\n".join(lines)
93 93
94 94
95 def split_title_version(title: str | None) -> tuple[str, str]:
96 """拆分歌名末尾括号版本信息。
97
98 例如:化风行万里 (DJ默涵版) -> (化风行万里, DJ默涵版)
99 """
100 if not title:
101 return "", ""
102 text = title.strip()
103 match = re.match(r"^(?P<title>.+?)\s*[\((](?P<version>[^()()]+)[\))]\s*$", text)
104 if not match:
105 return text, ""
106 clean_title = match.group("title").strip()
107 version = match.group("version").strip()
108 return clean_title or text, version
109
110
95 def upload_plain_lyric_to_bucket(platform: str, unique_id: str, lyric: str, bucket, base_url: str) -> str: 111 def upload_plain_lyric_to_bucket(platform: str, unique_id: str, lyric: str, bucket, base_url: str) -> str:
96 """将歌词去时间戳后上传到当前 ETL 使用的 OSS bucket""" 112 """将歌词去时间戳后上传到当前 ETL 使用的 OSS bucket"""
97 plain_lyric = extract_plain_lyric(lyric) 113 plain_lyric = extract_plain_lyric(lyric)
98 if not plain_lyric: 114 if not plain_lyric:
99 return "" 115 return ""
100 oss_key = f"crawler/{platform}/lyric/{unique_id}.txt" 116 oss_key = f"crawler/{platform}/lyric/{unique_id}.txt"
101 bucket.put_object(oss_key, plain_lyric.encode("utf-8")) 117 bucket.put_object(oss_key, plain_lyric.encode("utf-8"), headers={'Content-Type': 'text/plain; charset=utf-8'})
102 return f"{base_url.rstrip('/')}/{oss_key}" 118 return f"{base_url.rstrip('/')}/{oss_key}"
103 119
104 120
......
...@@ -9,12 +9,20 @@ def insert_yinyan_song_records(cur, records: list[dict]) -> None: ...@@ -9,12 +9,20 @@ def insert_yinyan_song_records(cur, records: list[dict]) -> None:
9 """ 9 """
10 INSERT INTO yinyan_song_records (song_id, record_id, platform, is_yinyan_push) 10 INSERT INTO yinyan_song_records (song_id, record_id, platform, is_yinyan_push)
11 VALUES (%s, %s, %s, FALSE) 11 VALUES (%s, %s, %s, FALSE)
12 ON CONFLICT (song_id, record_id) DO NOTHING 12 ON CONFLICT (song_id, record_id) DO UPDATE
13 SET platform = EXCLUDED.platform
14 WHERE yinyan_song_records.platform IS NULL
13 """, 15 """,
14 [(r['song_id'], r['record_id'], r['platform']) for r in records], 16 [(r['song_id'], r['record_id'], r['platform']) for r in records],
15 ) 17 )
16 18
17 19
20 def fetch_existing_yinyan_song_ids(cur) -> set[int]:
21 """返回已完成初始化 platform 的 song_id 集合。"""
22 cur.execute("SELECT DISTINCT song_id FROM yinyan_song_records WHERE platform IS NOT NULL")
23 return {row[0] for row in cur.fetchall()}
24
25
18 def fetch_pending_yinyan_song_records(cur, limit: int) -> list[dict]: 26 def fetch_pending_yinyan_song_records(cur, limit: int) -> list[dict]:
19 cur.execute( 27 cur.execute(
20 """ 28 """
...@@ -30,8 +38,40 @@ def fetch_pending_yinyan_song_records(cur, limit: int) -> list[dict]: ...@@ -30,8 +38,40 @@ def fetch_pending_yinyan_song_records(cur, limit: int) -> list[dict]:
30 return [{'song_id': row[0], 'record_id': row[1]} for row in rows] 38 return [{'song_id': row[0], 'record_id': row[1]} for row in rows]
31 39
32 40
41 def fetch_yinyan_records_missing_platform(cur, limit: int) -> list[dict]:
42 cur.execute(
43 """
44 SELECT song_id, record_id
45 FROM yinyan_song_records
46 WHERE platform IS NULL
47 ORDER BY song_id
48 LIMIT %s
49 """,
50 (limit,),
51 )
52 rows = cur.fetchall()
53 return [{'song_id': row[0], 'record_id': row[1]} for row in rows]
54
55
56 def update_yinyan_record_platforms(cur, records: list[dict]) -> None:
57 if not records:
58 return
59 cur.executemany(
60 """
61 UPDATE yinyan_song_records
62 SET platform = %s
63 WHERE song_id = %s
64 AND record_id = %s
65 AND platform IS NULL
66 """,
67 [(r['platform'], r['song_id'], r['record_id']) for r in records],
68 )
69
70
33 def upsert_yinyan_song_records(cur, records: list[dict]) -> None: 71 def upsert_yinyan_song_records(cur, records: list[dict]) -> None:
34 """Mark pre-initialized yinyan song-record rows as pushed to crawler.""" 72 """Mark pre-initialized yinyan song-record rows as pushed to crawler.
73 Matches only on song_id so singer-fallback can use a different record_id than initialized.
74 """
35 if not records: 75 if not records:
36 return 76 return
37 cur.executemany( 77 cur.executemany(
...@@ -39,11 +79,12 @@ def upsert_yinyan_song_records(cur, records: list[dict]) -> None: ...@@ -39,11 +79,12 @@ def upsert_yinyan_song_records(cur, records: list[dict]) -> None:
39 UPDATE yinyan_song_records 79 UPDATE yinyan_song_records
40 SET platform = %s, 80 SET platform = %s,
41 platform_song_id = %s, 81 platform_song_id = %s,
82 record_id = %s,
42 is_yinyan_push = TRUE 83 is_yinyan_push = TRUE
43 WHERE song_id = %s AND record_id = %s 84 WHERE song_id = %s AND is_yinyan_push = FALSE
44 """, 85 """,
45 [ 86 [
46 (r['platform'], r['platform_song_id'], r['song_id'], r['record_id']) 87 (r['platform'], r['platform_song_id'], r['record_id'], r['song_id'])
47 for r in records 88 for r in records
48 ], 89 ],
49 ) 90 )
...@@ -98,8 +139,8 @@ def upsert_qq_songs(cur, songs: list[dict]) -> None: ...@@ -98,8 +139,8 @@ def upsert_qq_songs(cur, songs: list[dict]) -> None:
98 (id, platform_song_id, mid, album_id, cover, title, name, duration, 139 (id, platform_song_id, mid, album_id, cover, title, name, duration,
99 lyric, composer_name, lyricist_name, url, lyric_url, 140 lyric, composer_name, lyricist_name, url, lyric_url,
100 platform_index_url, published_at, singers, status, created_at, updated_at, 141 platform_index_url, published_at, singers, status, created_at, updated_at,
101 audio_md5, provider_name, crawler_source_data) 142 version, audio_md5, provider_name, crawler_source_data)
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) 143 VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb, 0, NOW(), NOW(), %s, %s, %s, %s::json)
103 ON CONFLICT (platform_song_id) DO NOTHING 144 ON CONFLICT (platform_song_id) DO NOTHING
104 """ 145 """
105 rows = [( 146 rows = [(
...@@ -108,6 +149,7 @@ def upsert_qq_songs(cur, songs: list[dict]) -> None: ...@@ -108,6 +149,7 @@ def upsert_qq_songs(cur, songs: list[dict]) -> None:
108 s.get('lyric'), s.get('composer_name'), s.get('lyricist_name'), 149 s.get('lyric'), s.get('composer_name'), s.get('lyricist_name'),
109 s.get('url', ''), s.get('lyric_url'), 150 s.get('url', ''), s.get('lyric_url'),
110 s.get('platform_index_url'), s.get('published_at'), s.get('singers_json', '[]'), 151 s.get('platform_index_url'), s.get('published_at'), s.get('singers_json', '[]'),
152 s.get('version'),
111 s.get('audio_md5'), 153 s.get('audio_md5'),
112 s.get('provider_name'), s.get('crawler_source_data'), 154 s.get('provider_name'), s.get('crawler_source_data'),
113 ) for s in songs] 155 ) for s in songs]
...@@ -189,8 +231,8 @@ def upsert_kugou_songs(cur, songs: list[dict]) -> None: ...@@ -189,8 +231,8 @@ def upsert_kugou_songs(cur, songs: list[dict]) -> None:
189 (id, platform_song_id, hash, album_audio_id, album_id, cover, title, name, duration, 231 (id, platform_song_id, hash, album_audio_id, album_id, cover, title, name, duration,
190 lyric, composer_name, lyricist_name, url, lyric_url, 232 lyric, composer_name, lyricist_name, url, lyric_url,
191 platform_index_url, published_at, singers, status, created_at, updated_at, 233 platform_index_url, published_at, singers, status, created_at, updated_at,
192 audio_md5, provider_name, crawler_source_data) 234 version, audio_md5, provider_name, crawler_source_data)
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) 235 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, %s::json)
194 ON CONFLICT (platform_song_id) DO NOTHING 236 ON CONFLICT (platform_song_id) DO NOTHING
195 """ 237 """
196 rows = [( 238 rows = [(
...@@ -200,6 +242,7 @@ def upsert_kugou_songs(cur, songs: list[dict]) -> None: ...@@ -200,6 +242,7 @@ def upsert_kugou_songs(cur, songs: list[dict]) -> None:
200 s.get('lyric'), s.get('composer_name'), s.get('lyricist_name'), 242 s.get('lyric'), s.get('composer_name'), s.get('lyricist_name'),
201 s.get('url', ''), s.get('lyric_url'), 243 s.get('url', ''), s.get('lyric_url'),
202 s.get('platform_index_url'), s.get('published_at'), s.get('singers_json', '[]'), 244 s.get('platform_index_url'), s.get('published_at'), s.get('singers_json', '[]'),
245 s.get('version'),
203 s.get('audio_md5'), 246 s.get('audio_md5'),
204 s.get('provider_name'), s.get('crawler_source_data'), 247 s.get('provider_name'), s.get('crawler_source_data'),
205 ) for s in songs] 248 ) for s in songs]
...@@ -277,8 +320,8 @@ def upsert_netease_songs(cur, songs: list[dict]) -> None: ...@@ -277,8 +320,8 @@ def upsert_netease_songs(cur, songs: list[dict]) -> None:
277 (id, platform_song_id, album_id, cover, title, name, duration, 320 (id, platform_song_id, album_id, cover, title, name, duration,
278 lyric, composer_name, lyricist_name, url, lyric_url, 321 lyric, composer_name, lyricist_name, url, lyric_url,
279 platform_index_url, published_at, album, singers, status, created_at, updated_at, 322 platform_index_url, published_at, album, singers, status, created_at, updated_at,
280 audio_md5, provider_name, crawler_source_data) 323 version, audio_md5, provider_name, crawler_source_data)
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) 324 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, %s::json)
282 ON CONFLICT (platform_song_id) DO NOTHING 325 ON CONFLICT (platform_song_id) DO NOTHING
283 """ 326 """
284 rows = [( 327 rows = [(
...@@ -287,6 +330,7 @@ def upsert_netease_songs(cur, songs: list[dict]) -> None: ...@@ -287,6 +330,7 @@ def upsert_netease_songs(cur, songs: list[dict]) -> None:
287 s.get('lyric'), s.get('composer_name'), s.get('lyricist_name'), 330 s.get('lyric'), s.get('composer_name'), s.get('lyricist_name'),
288 s.get('url', ''), s.get('lyric_url'), 331 s.get('url', ''), s.get('lyric_url'),
289 s.get('platform_index_url'), s.get('published_at'), s.get('album_json'), s.get('singers_json', '[]'), 332 s.get('platform_index_url'), s.get('published_at'), s.get('album_json'), s.get('singers_json', '[]'),
333 s.get('version'),
290 s.get('audio_md5'), 334 s.get('audio_md5'),
291 s.get('provider_name'), s.get('crawler_source_data'), 335 s.get('provider_name'), s.get('crawler_source_data'),
292 ) for s in songs] 336 ) for s in songs]
......
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 initialize_yinyan_song_records, run 4 from etl_to_crawler.runner import backfill_yinyan_record_platforms, initialize_yinyan_song_records, run
5 5
6 PLATFORM_MAP = { 6 PLATFORM_MAP = {
7 'qq': PLATFORM_QQ, 7 'qq': PLATFORM_QQ,
...@@ -18,6 +18,8 @@ if __name__ == '__main__': ...@@ -18,6 +18,8 @@ if __name__ == '__main__':
18 help='最多处理多少批次(冒烟测试用)') 18 help='最多处理多少批次(冒烟测试用)')
19 parser.add_argument('--init-yinyan-records', action='store_true', 19 parser.add_argument('--init-yinyan-records', action='store_true',
20 help='只初始化 yinyan_song_records 待导入状态表,不执行 crawler 导入') 20 help='只初始化 yinyan_song_records 待导入状态表,不执行 crawler 导入')
21 parser.add_argument('--backfill-yinyan-platforms', action='store_true',
22 help='只回填 yinyan_song_records 中为空的 platform 平台代码')
21 args = parser.parse_args() 23 args = parser.parse_args()
22 24
23 if args.platform == 'all': 25 if args.platform == 'all':
...@@ -26,7 +28,9 @@ if __name__ == '__main__': ...@@ -26,7 +28,9 @@ if __name__ == '__main__':
26 platforms = [PLATFORM_MAP[args.platform]] 28 platforms = [PLATFORM_MAP[args.platform]]
27 29
28 print(f"Starting ETL for platforms: {platforms}") 30 print(f"Starting ETL for platforms: {platforms}")
29 if args.init_yinyan_records: 31 if args.backfill_yinyan_platforms:
32 backfill_yinyan_record_platforms(max_batches=args.max_batches)
33 elif args.init_yinyan_records:
30 initialize_yinyan_song_records(platforms, max_batches=args.max_batches) 34 initialize_yinyan_song_records(platforms, max_batches=args.max_batches)
31 else: 35 else:
32 run(platforms, max_batches=args.max_batches) 36 run(platforms, max_batches=args.max_batches)
......
...@@ -57,7 +57,7 @@ def test_run_imports_all_platform_records_but_writes_one_primary_yinyan_relation ...@@ -57,7 +57,7 @@ def test_run_imports_all_platform_records_but_writes_one_primary_yinyan_relation
57 'audio_url': 'https://example.com/a.mp3', 57 'audio_url': 'https://example.com/a.mp3',
58 'singer': '歌手', 58 'singer': '歌手',
59 }}) 59 }})
60 monkeypatch.setattr(runner, 'fetch_platform_records', lambda conn, song_ids: [ 60 platform_records = [
61 { 61 {
62 'source_song_id': 10, 62 'source_song_id': 10,
63 'record_id': 100, 63 'record_id': 100,
...@@ -80,7 +80,10 @@ def test_run_imports_all_platform_records_but_writes_one_primary_yinyan_relation ...@@ -80,7 +80,10 @@ def test_run_imports_all_platform_records_but_writes_one_primary_yinyan_relation
80 'is_high': 0, 80 'is_high': 0,
81 'pub_time': '2021-01-01', 81 'pub_time': '2021-01-01',
82 }, 82 },
83 ]) 83 ]
84 monkeypatch.setattr(runner, 'fetch_platform_records', lambda conn, song_ids: platform_records)
85 monkeypatch.setattr(runner, 'fetch_all_platform_records', lambda conn, song_ids: platform_records)
86 monkeypatch.setattr(runner, '_pick_record_with_singer', lambda records, spider_conn: records[0])
84 monkeypatch.setattr(runner, '_PROCESSORS', processors) 87 monkeypatch.setattr(runner, '_PROCESSORS', processors)
85 monkeypatch.setattr(runner, 'upsert_yinyan_song_records', yinyan_writer) 88 monkeypatch.setattr(runner, 'upsert_yinyan_song_records', yinyan_writer)
86 89
...@@ -90,9 +93,9 @@ def test_run_imports_all_platform_records_but_writes_one_primary_yinyan_relation ...@@ -90,9 +93,9 @@ def test_run_imports_all_platform_records_but_writes_one_primary_yinyan_relation
90 processors['2'].assert_called_once() 93 processors['2'].assert_called_once()
91 yinyan_writer.assert_called_once_with(pg_conn.cur, [{ 94 yinyan_writer.assert_called_once_with(pg_conn.cur, [{
92 'song_id': 10, 95 'song_id': 10,
93 'record_id': 200, 96 'record_id': 100,
94 'platform': 'kugou', 97 'platform': '1',
95 'platform_song_id': 200, 98 'platform_song_id': 100,
96 }]) 99 }])
97 assert pg_conn.commits == 1 100 assert pg_conn.commits == 1
98 101
...@@ -104,6 +107,7 @@ def test_initialize_yinyan_song_records_inserts_primary_records(monkeypatch): ...@@ -104,6 +107,7 @@ def test_initialize_yinyan_song_records_inserts_primary_records(monkeypatch):
104 monkeypatch.setattr(runner, 'get_hk_songs_conn', lambda: _Connection()) 107 monkeypatch.setattr(runner, 'get_hk_songs_conn', lambda: _Connection())
105 monkeypatch.setattr(runner, 'get_source_conn', lambda: _Connection()) 108 monkeypatch.setattr(runner, 'get_source_conn', lambda: _Connection())
106 monkeypatch.setattr(runner, 'get_pg_conn', lambda: pg_conn) 109 monkeypatch.setattr(runner, 'get_pg_conn', lambda: pg_conn)
110 monkeypatch.setattr(runner, 'fetch_existing_yinyan_song_ids', lambda cur: set())
107 111
108 monkeypatch.setattr(runner, 'iter_hk_songs_batches', lambda conn, batch_size: [[ 112 monkeypatch.setattr(runner, 'iter_hk_songs_batches', lambda conn, batch_size: [[
109 {'id': 50, 'source_song_id': 10, 'name': '歌', 'audio_url': 'https://example.com/a.mp3', 'singer': '歌手'}, 113 {'id': 50, 'source_song_id': 10, 'name': '歌', 'audio_url': 'https://example.com/a.mp3', 'singer': '歌手'},
...@@ -136,7 +140,32 @@ def test_initialize_yinyan_song_records_inserts_primary_records(monkeypatch): ...@@ -136,7 +140,32 @@ def test_initialize_yinyan_song_records_inserts_primary_records(monkeypatch):
136 140
137 runner.initialize_yinyan_song_records(['1', '2']) 141 runner.initialize_yinyan_song_records(['1', '2'])
138 142
139 assert inserted == [{'song_id': 10, 'record_id': 200}] 143 assert inserted == [{'song_id': 10, 'record_id': 200, 'platform': '2'}]
144 assert pg_conn.commits == 1
145
146
147 def test_backfill_yinyan_record_platforms_updates_missing_platform_rows(monkeypatch):
148 pg_conn = _PgConnection()
149 updated = []
150
151 monkeypatch.setattr(runner, 'get_source_conn', lambda: _Connection())
152 monkeypatch.setattr(runner, 'get_pg_conn', lambda: pg_conn)
153 monkeypatch.setattr(runner, 'fetch_yinyan_records_missing_platform', lambda cur, batch_size: [
154 {'song_id': 10, 'record_id': 100},
155 {'song_id': 11, 'record_id': 101},
156 ] if pg_conn.commits == 0 else [])
157 monkeypatch.setattr(runner, 'fetch_record_platforms', lambda conn, record_ids: {
158 100: '1',
159 101: '2',
160 })
161 monkeypatch.setattr(runner, 'update_yinyan_record_platforms', lambda cur, rows: updated.extend(rows))
162
163 runner.backfill_yinyan_record_platforms()
164
165 assert updated == [
166 {'song_id': 10, 'record_id': 100, 'platform': '1'},
167 {'song_id': 11, 'record_id': 101, 'platform': '2'},
168 ]
140 assert pg_conn.commits == 1 169 assert pg_conn.commits == 1
141 170
142 171
...@@ -158,7 +187,7 @@ def test_process_netease_builds_album_json_for_song_insert(monkeypatch): ...@@ -158,7 +187,7 @@ def test_process_netease_builds_album_json_for_song_insert(monkeypatch):
158 'is_owner': 1, 187 'is_owner': 1,
159 'album_published_at': '2020-01-01', 188 'album_published_at': '2020-01-01',
160 'cover': 'https://example.com/cover.jpg', 189 'cover': 'https://example.com/cover.jpg',
161 'title': '录音标题', 190 'title': '化风行万里 (DJ默涵版)',
162 'duration': 180, 191 'duration': 180,
163 'lyric': '[00:01.00]歌词', 192 'lyric': '[00:01.00]歌词',
164 'composer_name': '曲作者', 193 'composer_name': '曲作者',
...@@ -197,6 +226,8 @@ def test_process_netease_builds_album_json_for_song_insert(monkeypatch): ...@@ -197,6 +226,8 @@ def test_process_netease_builds_album_json_for_song_insert(monkeypatch):
197 assert inserted_songs[0]['album_json'] 226 assert inserted_songs[0]['album_json']
198 assert '"id": 20' in inserted_songs[0]['album_json'] 227 assert '"id": 20' in inserted_songs[0]['album_json']
199 assert '"title": "专辑"' in inserted_songs[0]['album_json'] 228 assert '"title": "专辑"' in inserted_songs[0]['album_json']
229 assert inserted_songs[0]['title'] == '化风行万里'
230 assert inserted_songs[0]['version'] == 'DJ默涵版'
200 231
201 232
202 def test_process_netease_keeps_timestamped_lyric_and_uploads_plain_lyric(monkeypatch): 233 def test_process_netease_keeps_timestamped_lyric_and_uploads_plain_lyric(monkeypatch):
...@@ -206,7 +237,7 @@ def test_process_netease_keeps_timestamped_lyric_and_uploads_plain_lyric(monkeyp ...@@ -206,7 +237,7 @@ def test_process_netease_keeps_timestamped_lyric_and_uploads_plain_lyric(monkeyp
206 uploaded = {} 237 uploaded = {}
207 238
208 class Bucket: 239 class Bucket:
209 def put_object(self, key, body): 240 def put_object(self, key, body, headers=None):
210 uploaded['key'] = key 241 uploaded['key'] = key
211 uploaded['body'] = body 242 uploaded['body'] = body
212 243
......
1 from etl_to_crawler.utils import split_title_version
2
3
4 def test_split_title_version_extracts_parenthesized_suffix():
5 assert split_title_version("化风行万里 (DJ默涵版)") == ("化风行万里", "DJ默涵版")
6 assert split_title_version("化风行万里(DJ默涵版)") == ("化风行万里", "DJ默涵版")
7
8
9 def test_split_title_version_leaves_plain_title_unchanged():
10 assert split_title_version("化风行万里") == ("化风行万里", "")
11 assert split_title_version("") == ("", "")
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 fetch_yinyan_records_missing_platform,
3 insert_yinyan_song_records, 4 insert_yinyan_song_records,
5 update_yinyan_record_platforms,
4 upsert_kugou_albums, 6 upsert_kugou_albums,
5 upsert_kugou_singers, 7 upsert_kugou_singers,
6 upsert_kugou_songs, 8 upsert_kugou_songs,
...@@ -45,32 +47,60 @@ def test_upsert_qq_singer_songs(): ...@@ -45,32 +47,60 @@ def test_upsert_qq_singer_songs():
45 def test_upsert_yinyan_song_records_marks_existing_relation_as_pushed(): 47 def test_upsert_yinyan_song_records_marks_existing_relation_as_pushed():
46 cur = MagicMock() 48 cur = MagicMock()
47 upsert_yinyan_song_records(cur, [ 49 upsert_yinyan_song_records(cur, [
48 {'song_id': 10, 'record_id': 100, 'platform': 'qq', 'platform_song_id': 1000}, 50 {'song_id': 10, 'record_id': 100, 'platform': '1', 'platform_song_id': 1000},
49 {'song_id': 11, 'record_id': 101, 'platform': 'kugou', 'platform_song_id': 1001}, 51 {'song_id': 11, 'record_id': 101, 'platform': '2', 'platform_song_id': 1001},
50 ]) 52 ])
51 53
52 sql, rows = cur.executemany.call_args[0] 54 sql, rows = cur.executemany.call_args[0]
53 assert 'UPDATE yinyan_song_records' in sql 55 assert 'UPDATE yinyan_song_records' in sql
54 assert 'platform = %s' in sql 56 assert 'platform = %s' in sql
55 assert 'platform_song_id = %s' in sql 57 assert 'platform_song_id = %s' in sql
58 assert 'record_id = %s' in sql
56 assert 'is_yinyan_push = TRUE' in sql 59 assert 'is_yinyan_push = TRUE' in sql
57 assert 'WHERE song_id = %s AND record_id = %s' in sql 60 assert 'WHERE song_id = %s AND is_yinyan_push = FALSE' in sql
58 assert rows == [('qq', 1000, 10, 100), ('kugou', 1001, 11, 101)] 61 assert rows == [('1', 1000, 100, 10), ('2', 1001, 101, 11)]
59 62
60 63
61 def test_insert_yinyan_song_records_initializes_unpushed_rows(): 64 def test_insert_yinyan_song_records_initializes_unpushed_rows():
62 cur = MagicMock() 65 cur = MagicMock()
63 insert_yinyan_song_records(cur, [ 66 insert_yinyan_song_records(cur, [
64 {'song_id': 10, 'record_id': 100}, 67 {'song_id': 10, 'record_id': 100, 'platform': '1'},
65 {'song_id': 11, 'record_id': 101}, 68 {'song_id': 11, 'record_id': 101, 'platform': '2'},
66 ]) 69 ])
67 70
68 sql, rows = cur.executemany.call_args[0] 71 sql, rows = cur.executemany.call_args[0]
69 assert 'INSERT INTO yinyan_song_records' in sql 72 assert 'INSERT INTO yinyan_song_records' in sql
73 assert 'platform' in sql
70 assert 'is_yinyan_push' in sql 74 assert 'is_yinyan_push' in sql
71 assert 'FALSE' in sql 75 assert 'FALSE' in sql
72 assert 'ON CONFLICT (song_id, record_id) DO NOTHING' in sql 76 assert 'ON CONFLICT (song_id, record_id) DO UPDATE' in sql
73 assert rows == [(10, 100), (11, 101)] 77 assert 'WHERE yinyan_song_records.platform IS NULL' in sql
78 assert rows == [(10, 100, '1'), (11, 101, '2')]
79
80
81 def test_fetch_yinyan_records_missing_platform_reads_null_platform_rows():
82 cur = MagicMock()
83 cur.fetchall.return_value = [(10, 100), (11, 101)]
84
85 rows = fetch_yinyan_records_missing_platform(cur, 500)
86
87 sql, params = cur.execute.call_args[0]
88 assert 'WHERE platform IS NULL' in sql
89 assert params == (500,)
90 assert rows == [{'song_id': 10, 'record_id': 100}, {'song_id': 11, 'record_id': 101}]
91
92
93 def test_update_yinyan_record_platforms_fills_only_null_platform_rows():
94 cur = MagicMock()
95 update_yinyan_record_platforms(cur, [
96 {'song_id': 10, 'record_id': 100, 'platform': '1'},
97 {'song_id': 11, 'record_id': 101, 'platform': '2'},
98 ])
99
100 sql, rows = cur.executemany.call_args[0]
101 assert 'SET platform = %s' in sql
102 assert 'AND platform IS NULL' in sql
103 assert rows == [('1', 10, 100), ('2', 11, 101)]
74 104
75 105
76 def test_upsert_netease_songs_writes_album_json_column(): 106 def test_upsert_netease_songs_writes_album_json_column():
...@@ -123,13 +153,15 @@ def test_upsert_kugou_songs_writes_provider_and_source_data(): ...@@ -123,13 +153,15 @@ def test_upsert_kugou_songs_writes_provider_and_source_data():
123 'provider_name': 'yinyan', 153 'provider_name': 'yinyan',
124 'crawler_source_data': '{"id": 200}', 154 'crawler_source_data': '{"id": 200}',
125 'audio_md5': 'md5-200', 155 'audio_md5': 'md5-200',
156 'version': 'DJ默涵版',
126 }]) 157 }])
127 158
128 sql, rows = cur.executemany.call_args[0] 159 sql, rows = cur.executemany.call_args[0]
129 assert 'audio_md5' in sql 160 assert 'audio_md5' in sql
161 assert 'version' in sql
130 assert 'provider_name, crawler_source_data' in sql 162 assert 'provider_name, crawler_source_data' in sql
131 assert '%s::json' in sql 163 assert '%s::json' in sql
132 assert rows[0][-3:] == ('md5-200', 'yinyan', '{"id": 200}') 164 assert rows[0][-4:] == ('DJ默涵版', 'md5-200', 'yinyan', '{"id": 200}')
133 165
134 166
135 def test_upsert_kugou_singers_writes_provider_and_source_data(): 167 def test_upsert_kugou_singers_writes_provider_and_source_data():
...@@ -232,11 +264,13 @@ def test_upsert_qq_entities_write_provider_and_source_data(): ...@@ -232,11 +264,13 @@ def test_upsert_qq_entities_write_provider_and_source_data():
232 'provider_name': 'yinyan', 264 'provider_name': 'yinyan',
233 'crawler_source_data': '{"id": 200}', 265 'crawler_source_data': '{"id": 200}',
234 'audio_md5': 'md5-200', 266 'audio_md5': 'md5-200',
267 'version': 'DJ默涵版',
235 }]) 268 }])
236 sql, rows = cur.executemany.call_args[0] 269 sql, rows = cur.executemany.call_args[0]
237 assert 'audio_md5' in sql 270 assert 'audio_md5' in sql
271 assert 'version' in sql
238 assert 'provider_name, crawler_source_data' in sql 272 assert 'provider_name, crawler_source_data' in sql
239 assert rows[0][-3:] == ('md5-200', 'yinyan', '{"id": 200}') 273 assert rows[0][-4:] == ('DJ默涵版', 'md5-200', 'yinyan', '{"id": 200}')
240 274
241 275
242 def test_upsert_netease_entities_write_provider_and_source_data(): 276 def test_upsert_netease_entities_write_provider_and_source_data():
...@@ -296,8 +330,10 @@ def test_upsert_netease_entities_write_provider_and_source_data(): ...@@ -296,8 +330,10 @@ def test_upsert_netease_entities_write_provider_and_source_data():
296 'provider_name': 'yinyan', 330 'provider_name': 'yinyan',
297 'crawler_source_data': '{"id": 300}', 331 'crawler_source_data': '{"id": 300}',
298 'audio_md5': 'md5-300', 332 'audio_md5': 'md5-300',
333 'version': 'DJ默涵版',
299 }]) 334 }])
300 sql, rows = cur.executemany.call_args[0] 335 sql, rows = cur.executemany.call_args[0]
301 assert 'audio_md5' in sql 336 assert 'audio_md5' in sql
337 assert 'version' in sql
302 assert 'provider_name, crawler_source_data' in sql 338 assert 'provider_name, crawler_source_data' in sql
303 assert rows[0][-3:] == ('md5-300', 'yinyan', '{"id": 300}') 339 assert rows[0][-4:] == ('DJ默涵版', 'md5-300', 'yinyan', '{"id": 300}')
......