Commit ae633f56 ae633f56f1aa24cc8606645f550c60decbb50c25 by 沈秋雨

feat(env): 更新目标库配置及添加 PostgreSQL 环境变量

- 目标库名称由 hk_songs 修改为 hk_songs_test,新增中间表 hk_songs_import_staging
- 添加测试环境 crawler_dev PostgreSQL 连接配置变量
- 添加正式环境 archive_crawler PostgreSQL 连接配置变量

refactor(check_record_titles): 调整平台原始标题核对逻辑

- 变更核对数据库源为 archive_crawler 与 hikoon-data-spider
- 按平台分表批量查询 spider 原始标题替代原录音名查询
- 合并结果中使用 spider_title 与 crawler_title 精确比对
- 输出 Excel 标题及字段相应更新,匹配时整行高亮
- 修改脚本参数说明和默认批处理数量描述
- 日志及信息输出文本调整以反映新数据源

feat(fix_record_titles): 新增基于 spider 标题的归一化批量修复工具

- 支持从 archive_crawler 读取待修复记录的推送关系
- 结合 spider 标题,计算归一化标题和版本号
- 支持对不同平台数据批量生成更新 SQL 预览或应用
- 支持备份更新前数据,支持安全回退操作
- 导出更新报告 Excel,突出显示变化字段
- 支持长文本分批过滤,避免数据库字段长度异常
- 增加命令行参数控制应用范围、批量大小及排除标题列表
- 使用严格的 SQL 语句构造与变更检测优化更新效率
- 实现基于 run_id 的多表回滚机制,保证数据一致性
1 parent 179a08f8
...@@ -5,13 +5,14 @@ SOURCE_DB_USER= ...@@ -5,13 +5,14 @@ SOURCE_DB_USER=
5 SOURCE_DB_PASSWORD= 5 SOURCE_DB_PASSWORD=
6 SOURCE_DB_NAME= 6 SOURCE_DB_NAME=
7 7
8 # 目标库 - 词曲 8 # 目标库 - 词曲测试
9 TARGET_DB_HOST= 9 TARGET_DB_HOST=
10 TARGET_DB_PORT=3306 10 TARGET_DB_PORT=3306
11 TARGET_DB_USER= 11 TARGET_DB_USER=
12 TARGET_DB_PASSWORD= 12 TARGET_DB_PASSWORD=
13 TARGET_DB_NAME= 13 TARGET_DB_NAME=
14 TARGET_TABLE_NAME=hk_songs 14 TARGET_TABLE_NAME=hk_songs_test
15 TARGET_TABLE_NAME_TMP=hk_songs_import_staging
15 16
16 # run_etl.py 默认读取并回写的导入状态表:yinyan_song_records 或 yinyan_song_records2 17 # run_etl.py 默认读取并回写的导入状态表:yinyan_song_records 或 yinyan_song_records2
17 YINYAN_IMPORT_TABLE=yinyan_song_records 18 YINYAN_IMPORT_TABLE=yinyan_song_records
...@@ -22,3 +23,21 @@ OSS_ACCESS_KEY_SECRET= ...@@ -22,3 +23,21 @@ OSS_ACCESS_KEY_SECRET=
22 OSS_ENDPOINT= 23 OSS_ENDPOINT=
23 OSS_BUCKET_NAME= 24 OSS_BUCKET_NAME=
24 OSS_FILE_BASE_NAME= 25 OSS_FILE_BASE_NAME=
26
27 # 测试环境 - crawler_dev PostgreSQL
28 TEST_CRAWLER_DB_HOST=
29 TEST_CRAWLER_DB_PORT=5432
30 TEST_CRAWLER_DB_USER=
31 TEST_CRAWLER_DB_PASSWORD=
32 TEST_CRAWLER_DB_NAME=crawler_dev
33 TEST_CRAWLER_DB_SSL=false
34 TEST_ARCHIVE_DATA_DB_NAME=data_dev
35
36 # 正式环境 - archive_crawler PostgreSQL
37 ARCHIVE_CRAWLER_DB_HOST=
38 ARCHIVE_CRAWLER_DB_PORT=5432
39 ARCHIVE_CRAWLER_DB_USER=
40 ARCHIVE_CRAWLER_DB_PASSWORD=
41 ARCHIVE_CRAWLER_DB_NAME=archive_crawler
42 ARCHIVE_CRAWLER_DB_SSL=false
43 ARCHIVE_DATA_DB_NAME=archive_data
......
1 #!/usr/bin/env python3 1 #!/usr/bin/env python3
2 """核对音眼录音名与 crawler 平台歌曲标题,并输出 Excel。 2 """核对 spider 平台原始标题与正式 crawler 歌曲标题,并输出 Excel。
3 3
4 数据链路: 4 数据链路:
5 CRAWLER_DB.yinyan_song_records.record_id 5 archive_crawler.yinyan_song_records.(platform, platform_song_id)
6 -> SOURCE_DB.hk_music_record.id -> record_name 6 -> hikoon-data-spider.media_*_songs.id -> spider_title
7 CRAWLER_DB.yinyan_song_records.(platform, platform_song_id) 7 -> archive_crawler.crawler_*_songs.platform_song_id -> crawler_title
8 -> 对应 crawler_*_songs.platform_song_id -> title
9 8
10 用法: 9 用法:
11 .venv/bin/python check_record_titles.py 10 .venv/bin/python check_record_titles.py
...@@ -21,7 +20,7 @@ from openpyxl import Workbook ...@@ -21,7 +20,7 @@ from openpyxl import Workbook
21 from openpyxl.styles import Alignment, Font, PatternFill 20 from openpyxl.styles import Alignment, Font, PatternFill
22 from openpyxl.utils import get_column_letter 21 from openpyxl.utils import get_column_letter
23 22
24 from etl_to_crawler.connections import close_all_pools, get_pg_conn, get_source_conn 23 from etl_to_crawler.connections import close_all_pools, get_archive_crawler_conn, get_spider_conn
25 24
26 25
27 log = logging.getLogger(__name__) 26 log = logging.getLogger(__name__)
...@@ -29,6 +28,11 @@ log = logging.getLogger(__name__) ...@@ -29,6 +28,11 @@ log = logging.getLogger(__name__)
29 DEFAULT_OUTPUT = "record_title_check.xlsx" 28 DEFAULT_OUTPUT = "record_title_check.xlsx"
30 DEFAULT_BATCH_SIZE = 2000 29 DEFAULT_BATCH_SIZE = 2000
31 ALLOWED_IMPORT_TABLES = {"yinyan_song_records", "yinyan_song_records2"} 30 ALLOWED_IMPORT_TABLES = {"yinyan_song_records", "yinyan_song_records2"}
31 SPIDER_TABLES = {
32 "1": "media_tencent_songs",
33 "2": "media_ku_gou_songs",
34 "4": "media_netease_songs",
35 }
32 36
33 HEADER_FILL = PatternFill(fill_type="solid", fgColor="4472C4") 37 HEADER_FILL = PatternFill(fill_type="solid", fgColor="4472C4")
34 MATCH_FILL = PatternFill(fill_type="solid", fgColor="C6EFCE") 38 MATCH_FILL = PatternFill(fill_type="solid", fgColor="C6EFCE")
...@@ -81,67 +85,77 @@ def fetch_crawler_rows(pg_conn, import_table: str) -> list[dict]: ...@@ -81,67 +85,77 @@ def fetch_crawler_rows(pg_conn, import_table: str) -> list[dict]:
81 "platform": row[1], 85 "platform": row[1],
82 "platform_song_id": row[2], 86 "platform_song_id": row[2],
83 "recording_id": row[3], 87 "recording_id": row[3],
84 "title": row[4], 88 "crawler_title": row[4],
85 } 89 }
86 for row in rows 90 for row in rows
87 ] 91 ]
88 92
89 93
90 def fetch_record_names(mysql_conn, record_ids: Iterable[int], batch_size: int) -> dict[int, str | None]: 94 def fetch_spider_titles(mysql_conn, crawler_rows: list[dict], batch_size: int) -> dict[tuple[str, int], str]:
91 """按 record_id 分批读取 hk_music_record.record_name。""" 95 """按 platform_song_id 分平台批量读取 spider 原始 title。"""
92 unique_ids = sorted({int(record_id) for record_id in record_ids if record_id is not None}) 96 titles: dict[tuple[str, int], str] = {}
93 names: dict[int, str | None] = {} 97 for platform, table in SPIDER_TABLES.items():
94 98 ids = sorted({
95 for batch in chunked(unique_ids, batch_size): 99 int(row["platform_song_id"])
96 placeholders = ", ".join(["%s"] * len(batch)) 100 for row in crawler_rows
97 sql = f""" 101 if str(row["platform"]) == platform and row["platform_song_id"] is not None
98 SELECT id, record_name 102 })
99 FROM hk_music_record 103 for batch in chunked(ids, batch_size):
100 WHERE id IN ({placeholders}) 104 placeholders = ", ".join(["%s"] * len(batch))
101 """ 105 with mysql_conn.cursor() as cursor:
102 with mysql_conn.cursor() as cursor: 106 cursor.execute(
103 cursor.execute(sql, tuple(batch)) 107 f"SELECT id, title FROM {table} WHERE id IN ({placeholders})",
104 for row in cursor.fetchall(): 108 tuple(batch),
105 names[int(row["id"])] = row["record_name"] 109 )
106 110 for row in cursor.fetchall():
107 return names 111 if row["title"] is not None:
108 112 titles[(platform, int(row["id"]))] = str(row["title"])
109 113 return titles
110 def combine_rows(crawler_rows: list[dict], record_names: dict[int, str | None]) -> list[dict]: 114
115
116 def combine_rows(crawler_rows: list[dict], spider_titles: dict[tuple[str, int], str]) -> list[dict]:
111 """合并两个数据库的查询结果,并计算是否完全相同。""" 117 """合并两个数据库的查询结果,并计算是否完全相同。"""
112 result = [] 118 result = []
113 for row in crawler_rows: 119 for row in crawler_rows:
114 record_id = row["record_id"] 120 platform = str(row["platform"])
115 record_name = record_names.get(int(record_id)) if record_id is not None else None 121 platform_song_id = row["platform_song_id"]
116 title = row["title"] 122 spider_title = (
123 spider_titles.get((platform, int(platform_song_id)))
124 if platform_song_id is not None else None
125 )
126 crawler_title = row["crawler_title"]
117 result.append( 127 result.append(
118 { 128 {
119 "record_id": record_id, 129 "record_id": row["record_id"],
120 "record_name": record_name, 130 "spider_title": spider_title,
121 "platform": row["platform"], 131 "crawler_title": crawler_title,
122 "platform_song_id": row["platform_song_id"], 132 "platform": platform,
133 "platform_song_id": platform_song_id,
123 "recording_id": row["recording_id"], 134 "recording_id": row["recording_id"],
124 "title": title,
125 # 两边都必须有值;不做 trim、大小写或繁简转换,按库中原值精确比较。 135 # 两边都必须有值;不做 trim、大小写或繁简转换,按库中原值精确比较。
126 "matched": record_name is not None and title is not None and record_name == title, 136 "matched": (
137 spider_title is not None
138 and crawler_title is not None
139 and spider_title == crawler_title
140 ),
127 } 141 }
128 ) 142 )
129 return result 143 return result
130 144
131 145
132 def write_excel(rows: list[dict], output_path: Path) -> None: 146 def write_excel(rows: list[dict], output_path: Path) -> None:
133 """写出三列表格;record_name 与 title 相同时整行标绿。""" 147 """写出核对表;spider_title 与 crawler_title 相同时整行标绿。"""
134 output_path.parent.mkdir(parents=True, exist_ok=True) 148 output_path.parent.mkdir(parents=True, exist_ok=True)
135 149
136 workbook = Workbook() 150 workbook = Workbook()
137 worksheet = workbook.active 151 worksheet = workbook.active
138 worksheet.title = "录音名与标题核对" 152 worksheet.title = "Spider与Crawler标题核对"
139 worksheet.freeze_panes = "A2" 153 worksheet.freeze_panes = "A2"
140 154
141 headers = ( 155 headers = (
142 "record_id", 156 "record_id",
143 "record_name", 157 "spider_title",
144 "title", 158 "crawler_title",
145 "platform", 159 "platform",
146 "platform_song_id", 160 "platform_song_id",
147 "recording_id", 161 "recording_id",
...@@ -155,8 +169,8 @@ def write_excel(rows: list[dict], output_path: Path) -> None: ...@@ -155,8 +169,8 @@ def write_excel(rows: list[dict], output_path: Path) -> None:
155 for row_number, row in enumerate(rows, start=2): 169 for row_number, row in enumerate(rows, start=2):
156 values = ( 170 values = (
157 row["record_id"], 171 row["record_id"],
158 row["record_name"], 172 row["spider_title"],
159 row["title"], 173 row["crawler_title"],
160 row["platform"], 174 row["platform"],
161 row["platform_song_id"], 175 row["platform_song_id"],
162 row["recording_id"], 176 row["recording_id"],
...@@ -175,15 +189,15 @@ def write_excel(rows: list[dict], output_path: Path) -> None: ...@@ -175,15 +189,15 @@ def write_excel(rows: list[dict], output_path: Path) -> None:
175 189
176 190
177 def parse_args() -> argparse.Namespace: 191 def parse_args() -> argparse.Namespace:
178 parser = argparse.ArgumentParser(description="核对 hk_music_record.record_name 与 crawler title") 192 parser = argparse.ArgumentParser(description="核对 spider title 与正式 crawler title")
179 parser.add_argument("--output", default=DEFAULT_OUTPUT, help=f"Excel 输出路径(默认: {DEFAULT_OUTPUT})") 193 parser.add_argument("--output", default=DEFAULT_OUTPUT, help=f"Excel 输出路径(默认: {DEFAULT_OUTPUT})")
180 parser.add_argument( 194 parser.add_argument(
181 "--table", 195 "--table",
182 choices=sorted(ALLOWED_IMPORT_TABLES), 196 choices=sorted(ALLOWED_IMPORT_TABLES),
183 default="yinyan_song_records", 197 default="yinyan_song_records",
184 help="待核对的 CRAWLER_DB 关联表", 198 help="待核对的 ARCHIVE_CRAWLER_DB 关联表",
185 ) 199 )
186 parser.add_argument("--batch-size", type=int, default=DEFAULT_BATCH_SIZE, help="MySQL 单批查询数量") 200 parser.add_argument("--batch-size", type=int, default=DEFAULT_BATCH_SIZE, help="spider MySQL 单批查询数量")
187 args = parser.parse_args() 201 args = parser.parse_args()
188 if args.batch_size <= 0: 202 if args.batch_size <= 0:
189 parser.error("--batch-size 必须大于 0") 203 parser.error("--batch-size 必须大于 0")
...@@ -195,28 +209,24 @@ def main() -> None: ...@@ -195,28 +209,24 @@ def main() -> None:
195 output_path = Path(args.output).expanduser().resolve() 209 output_path = Path(args.output).expanduser().resolve()
196 210
197 try: 211 try:
198 pg_conn = get_pg_conn() 212 pg_conn = get_archive_crawler_conn()
199 mysql_conn = get_source_conn() 213 mysql_conn = get_spider_conn()
200 214
201 crawler_rows = fetch_crawler_rows(pg_conn, args.table) 215 crawler_rows = fetch_crawler_rows(pg_conn, args.table)
202 log.info("从 %s 读取 %d 条关联记录", args.table, len(crawler_rows)) 216 log.info("从 %s 读取 %d 条关联记录", args.table, len(crawler_rows))
203 217
204 record_names = fetch_record_names( 218 spider_titles = fetch_spider_titles(mysql_conn, crawler_rows, args.batch_size)
205 mysql_conn, 219 log.info("从 hikoon-data-spider 读取 %d 个平台标题", len(spider_titles))
206 (row["record_id"] for row in crawler_rows),
207 args.batch_size,
208 )
209 log.info("从 hk_music_record 读取 %d 个录音名", len(record_names))
210 220
211 rows = combine_rows(crawler_rows, record_names) 221 rows = combine_rows(crawler_rows, spider_titles)
212 write_excel(rows, output_path) 222 write_excel(rows, output_path)
213 223
214 matched = sum(row["matched"] for row in rows) 224 matched = sum(row["matched"] for row in rows)
215 missing_name = sum(row["record_name"] is None for row in rows) 225 missing_spider = sum(row["spider_title"] is None for row in rows)
216 missing_title = sum(row["title"] is None for row in rows) 226 missing_crawler = sum(row["crawler_title"] is None for row in rows)
217 log.info( 227 log.info(
218 "核对完成:总数=%d,相同=%d,不同=%d,缺少 record_name=%d,缺少 title=%d", 228 "核对完成:总数=%d,相同=%d,不同=%d,缺少 spider_title=%d,缺少 crawler_title=%d",
219 len(rows), matched, len(rows) - matched, missing_name, missing_title, 229 len(rows), matched, len(rows) - matched, missing_spider, missing_crawler,
220 ) 230 )
221 log.info("Excel 已输出至 %s", output_path) 231 log.info("Excel 已输出至 %s", output_path)
222 finally: 232 finally:
......
...@@ -21,13 +21,32 @@ HK_SONGS_DB = { ...@@ -21,13 +21,32 @@ HK_SONGS_DB = {
21 'charset': 'utf8mb4', 21 'charset': 'utf8mb4',
22 } 22 }
23 23
24 CRAWLER_DB = { 24 TEST_CRAWLER_DB = {
25 'host': os.environ['CRAWLER_DB_HOST'], 25 'host': os.environ['TEST_CRAWLER_DB_HOST'],
26 'port': int(os.environ.get('CRAWLER_DB_PORT', '5432')), 26 'port': int(os.environ.get('TEST_CRAWLER_DB_PORT', '5432')),
27 'user': os.environ['CRAWLER_DB_USER'], 27 'user': os.environ['TEST_CRAWLER_DB_USER'],
28 'password': os.environ['CRAWLER_DB_PASSWORD'], 28 'password': os.environ['TEST_CRAWLER_DB_PASSWORD'],
29 'database': os.environ['CRAWLER_DB_NAME'], 29 'database': os.environ['TEST_CRAWLER_DB_NAME'],
30 'ssl_context': None if os.environ.get('CRAWLER_DB_SSL', 'false').lower() != 'true' else True, 30 'ssl_context': None if os.environ.get('TEST_CRAWLER_DB_SSL', 'false').lower() != 'true' else True,
31 }
32
33 TEST_ARCHIVE_DATA_DB = {
34 **TEST_CRAWLER_DB,
35 'database': os.environ.get('TEST_ARCHIVE_DATA_DB_NAME', 'data_dev'),
36 }
37
38 ARCHIVE_CRAWLER_DB = {
39 'host': os.environ['ARCHIVE_CRAWLER_DB_HOST'],
40 'port': int(os.environ.get('ARCHIVE_CRAWLER_DB_PORT', '5432')),
41 'user': os.environ['ARCHIVE_CRAWLER_DB_USER'],
42 'password': os.environ['ARCHIVE_CRAWLER_DB_PASSWORD'],
43 'database': os.environ.get('ARCHIVE_CRAWLER_DB_NAME', 'archive_crawler'),
44 'ssl_context': None if os.environ.get('ARCHIVE_CRAWLER_DB_SSL', 'false').lower() != 'true' else True,
45 }
46
47 ARCHIVE_DATA_DB = {
48 **ARCHIVE_CRAWLER_DB,
49 'database': os.environ.get('ARCHIVE_DATA_DB_NAME', 'archive_data'),
31 } 50 }
32 51
33 OSS_CONFIG = { 52 OSS_CONFIG = {
......
...@@ -3,7 +3,16 @@ import pymysql ...@@ -3,7 +3,16 @@ import pymysql
3 import pymysql.cursors 3 import pymysql.cursors
4 import pg8000 4 import pg8000
5 import oss2 5 import oss2
6 from .config import SOURCE_DB, HK_SONGS_DB, CRAWLER_DB, OSS_CONFIG, OSS_CONNECTION_POOL_SIZE 6 from .config import (
7 ARCHIVE_CRAWLER_DB,
8 ARCHIVE_DATA_DB,
9 SOURCE_DB,
10 HK_SONGS_DB,
11 TEST_CRAWLER_DB,
12 TEST_ARCHIVE_DATA_DB,
13 OSS_CONFIG,
14 OSS_CONNECTION_POOL_SIZE,
15 )
7 16
8 17
9 CONN_POOL_SIZE = 4 18 CONN_POOL_SIZE = 4
...@@ -238,7 +247,10 @@ class _PgConnPool: ...@@ -238,7 +247,10 @@ class _PgConnPool:
238 _source_pool = None 247 _source_pool = None
239 _hk_songs_pool = None 248 _hk_songs_pool = None
240 _spider_pool = None 249 _spider_pool = None
241 _pg_pool = None 250 _test_crawler_pool = None
251 _test_archive_pool = None
252 _archive_pool = None
253 _archive_crawler_pool = None
242 254
243 255
244 def _get_source_pool() -> _MySqlConnPool: 256 def _get_source_pool() -> _MySqlConnPool:
...@@ -264,11 +276,32 @@ def _get_spider_pool() -> _MySqlConnPool: ...@@ -264,11 +276,32 @@ def _get_spider_pool() -> _MySqlConnPool:
264 return _spider_pool 276 return _spider_pool
265 277
266 278
267 def _get_pg_pool() -> _PgConnPool: 279 def _get_test_crawler_pool() -> _PgConnPool:
268 global _pg_pool 280 global _test_crawler_pool
269 if _pg_pool is None: 281 if _test_crawler_pool is None:
270 _pg_pool = _PgConnPool(CRAWLER_DB) 282 _test_crawler_pool = _PgConnPool(TEST_CRAWLER_DB)
271 return _pg_pool 283 return _test_crawler_pool
284
285
286 def _get_test_archive_pool() -> _PgConnPool:
287 global _test_archive_pool
288 if _test_archive_pool is None:
289 _test_archive_pool = _PgConnPool(TEST_ARCHIVE_DATA_DB)
290 return _test_archive_pool
291
292
293 def _get_archive_pool() -> _PgConnPool:
294 global _archive_pool
295 if _archive_pool is None:
296 _archive_pool = _PgConnPool(ARCHIVE_DATA_DB)
297 return _archive_pool
298
299
300 def _get_archive_crawler_pool() -> _PgConnPool:
301 global _archive_crawler_pool
302 if _archive_crawler_pool is None:
303 _archive_crawler_pool = _PgConnPool(ARCHIVE_CRAWLER_DB)
304 return _archive_crawler_pool
272 305
273 306
274 def get_source_conn() -> pymysql.Connection: 307 def get_source_conn() -> pymysql.Connection:
...@@ -283,8 +316,20 @@ def get_hk_songs_conn() -> pymysql.Connection: ...@@ -283,8 +316,20 @@ def get_hk_songs_conn() -> pymysql.Connection:
283 return _get_hk_songs_pool().get() 316 return _get_hk_songs_pool().get()
284 317
285 318
286 def get_pg_conn() -> _NulSafePgConnection: 319 def get_test_crawler_conn() -> _NulSafePgConnection:
287 return _get_pg_pool().get() 320 return _get_test_crawler_pool().get()
321
322
323 def get_test_archive_conn() -> _NulSafePgConnection:
324 return _get_test_archive_pool().get()
325
326
327 def get_archive_conn() -> _NulSafePgConnection:
328 return _get_archive_pool().get()
329
330
331 def get_archive_crawler_conn() -> _NulSafePgConnection:
332 return _get_archive_crawler_pool().get()
288 333
289 334
290 def get_oss_bucket() -> oss2.Bucket: 335 def get_oss_bucket() -> oss2.Bucket:
...@@ -298,18 +343,24 @@ def close_all_pools(): ...@@ -298,18 +343,24 @@ def close_all_pools():
298 for pool in (_source_pool, _hk_songs_pool, _spider_pool): 343 for pool in (_source_pool, _hk_songs_pool, _spider_pool):
299 if pool is not None: 344 if pool is not None:
300 pool.close_all() 345 pool.close_all()
301 if _pg_pool is not None: 346 if _test_crawler_pool is not None:
302 _pg_pool.close_all() 347 _test_crawler_pool.close_all()
348 if _test_archive_pool is not None:
349 _test_archive_pool.close_all()
350 if _archive_pool is not None:
351 _archive_pool.close_all()
352 if _archive_crawler_pool is not None:
353 _archive_crawler_pool.close_all()
303 354
304 355
305 def refresh_conn(conn, pool_type: str): 356 def refresh_conn(conn, pool_type: str):
306 """验证连接健康度,断开时返回新连接,正常时原样返回。 357 """验证连接健康度,断开时返回新连接,正常时原样返回。
307 358
308 用法:在批次循环开头调用,如 ``conn = refresh_conn(conn, 'pg')``。 359 用法:在批次循环开头调用,如 ``conn = refresh_conn(conn, 'test_crawler')``。
309 pool_type 取 'source' | 'hk_songs' | 'spider' | 'pg'。 360 pool_type 取 'source' | 'hk_songs' | 'spider' | 'test_crawler'。
310 """ 361 """
311 try: 362 try:
312 if pool_type == 'pg': 363 if pool_type == 'test_crawler':
313 conn.run("SELECT 1") 364 conn.run("SELECT 1")
314 return conn 365 return conn
315 # MySQL 366 # MySQL
...@@ -322,7 +373,7 @@ def refresh_conn(conn, pool_type: str): ...@@ -322,7 +373,7 @@ def refresh_conn(conn, pool_type: str):
322 'source': _get_source_pool, 373 'source': _get_source_pool,
323 'hk_songs': _get_hk_songs_pool, 374 'hk_songs': _get_hk_songs_pool,
324 'spider': _get_spider_pool, 375 'spider': _get_spider_pool,
325 'pg': _get_pg_pool, 376 'test_crawler': _get_test_crawler_pool,
326 } 377 }
327 pool = pools.get(pool_type) 378 pool = pools.get(pool_type)
328 if pool is None: 379 if pool is None:
......
...@@ -8,7 +8,7 @@ from .config import ( ...@@ -8,7 +8,7 @@ from .config import (
8 PLATFORM_QQ, PLATFORM_KUGOU, PLATFORM_NETEASE, BATCH_SIZE, BACKFILL_BATCH_SIZE, 8 PLATFORM_QQ, PLATFORM_KUGOU, PLATFORM_NETEASE, BATCH_SIZE, BACKFILL_BATCH_SIZE,
9 OSS_CONFIG, YINYAN_IMPORT_TABLE, 9 OSS_CONFIG, YINYAN_IMPORT_TABLE,
10 ) 10 )
11 from .connections import get_hk_songs_conn, get_source_conn, get_spider_conn, get_pg_conn, get_oss_bucket, refresh_conn, close_all_pools 11 from .connections import get_hk_songs_conn, get_source_conn, get_spider_conn, get_test_crawler_conn, get_oss_bucket, refresh_conn, close_all_pools
12 from .reader import ( 12 from .reader import (
13 iter_hk_songs_batches, 13 iter_hk_songs_batches,
14 fetch_hk_songs_by_source_ids, 14 fetch_hk_songs_by_source_ids,
...@@ -1337,7 +1337,7 @@ def _pick_record_with_singer(records: list[dict], spider_conn) -> dict | None: ...@@ -1337,7 +1337,7 @@ def _pick_record_with_singer(records: list[dict], spider_conn) -> dict | None:
1337 def initialize_yinyan_song_records(platforms: list[str], max_batches: int | None = None) -> None: 1337 def initialize_yinyan_song_records(platforms: list[str], max_batches: int | None = None) -> None:
1338 hk_conn = get_hk_songs_conn() 1338 hk_conn = get_hk_songs_conn()
1339 src_conn = get_source_conn() 1339 src_conn = get_source_conn()
1340 pg_conn = get_pg_conn() 1340 pg_conn = get_test_crawler_conn()
1341 1341
1342 # 查询已存在的 song_id,用于跳过已处理的记录 1342 # 查询已存在的 song_id,用于跳过已处理的记录
1343 with pg_conn.cursor() as pg_cur: 1343 with pg_conn.cursor() as pg_cur:
...@@ -1351,7 +1351,7 @@ def initialize_yinyan_song_records(platforms: list[str], max_batches: int | None ...@@ -1351,7 +1351,7 @@ def initialize_yinyan_song_records(platforms: list[str], max_batches: int | None
1351 # 长任务连接可能断开,每批次开始前刷新 1351 # 长任务连接可能断开,每批次开始前刷新
1352 hk_conn = refresh_conn(hk_conn, 'hk_songs') 1352 hk_conn = refresh_conn(hk_conn, 'hk_songs')
1353 src_conn = refresh_conn(src_conn, 'source') 1353 src_conn = refresh_conn(src_conn, 'source')
1354 pg_conn = refresh_conn(pg_conn, 'pg') 1354 pg_conn = refresh_conn(pg_conn, 'test_crawler')
1355 1355
1356 if max_batches is not None and i >= max_batches: 1356 if max_batches is not None and i >= max_batches:
1357 break 1357 break
...@@ -1416,7 +1416,7 @@ def initialize_yinyan_song_records2(platforms: list[str], max_batches: int | Non ...@@ -1416,7 +1416,7 @@ def initialize_yinyan_song_records2(platforms: list[str], max_batches: int | Non
1416 后续导入允许歌曲的 singers 为空。 1416 后续导入允许歌曲的 singers 为空。
1417 """ 1417 """
1418 src_conn = get_source_conn() 1418 src_conn = get_source_conn()
1419 pg_conn = get_pg_conn() 1419 pg_conn = get_test_crawler_conn()
1420 total_inserted = 0 1420 total_inserted = 0
1421 1421
1422 try: 1422 try:
...@@ -1427,7 +1427,7 @@ def initialize_yinyan_song_records2(platforms: list[str], max_batches: int | Non ...@@ -1427,7 +1427,7 @@ def initialize_yinyan_song_records2(platforms: list[str], max_batches: int | Non
1427 for index, start in enumerate(tqdm(range(0, len(song_ids), BATCH_SIZE), desc='init-yinyan-records2')): 1427 for index, start in enumerate(tqdm(range(0, len(song_ids), BATCH_SIZE), desc='init-yinyan-records2')):
1428 # 长任务连接可能断开,每批次开始前刷新 1428 # 长任务连接可能断开,每批次开始前刷新
1429 src_conn = refresh_conn(src_conn, 'source') 1429 src_conn = refresh_conn(src_conn, 'source')
1430 pg_conn = refresh_conn(pg_conn, 'pg') 1430 pg_conn = refresh_conn(pg_conn, 'test_crawler')
1431 1431
1432 if max_batches is not None and index >= max_batches: 1432 if max_batches is not None and index >= max_batches:
1433 break 1433 break
...@@ -1467,7 +1467,7 @@ def initialize_yinyan_song_records2(platforms: list[str], max_batches: int | Non ...@@ -1467,7 +1467,7 @@ def initialize_yinyan_song_records2(platforms: list[str], max_batches: int | Non
1467 1467
1468 def backfill_yinyan_record_platforms(max_batches: int | None = None) -> None: 1468 def backfill_yinyan_record_platforms(max_batches: int | None = None) -> None:
1469 src_conn = get_source_conn() 1469 src_conn = get_source_conn()
1470 pg_conn = get_pg_conn() 1470 pg_conn = get_test_crawler_conn()
1471 total = 0 1471 total = 0
1472 skipped = 0 1472 skipped = 0
1473 1473
...@@ -1477,7 +1477,7 @@ def backfill_yinyan_record_platforms(max_batches: int | None = None) -> None: ...@@ -1477,7 +1477,7 @@ def backfill_yinyan_record_platforms(max_batches: int | None = None) -> None:
1477 while max_batches is None or batch_index < max_batches: 1477 while max_batches is None or batch_index < max_batches:
1478 # 长任务连接可能断开,每批次开始前刷新 1478 # 长任务连接可能断开,每批次开始前刷新
1479 src_conn = refresh_conn(src_conn, 'source') 1479 src_conn = refresh_conn(src_conn, 'source')
1480 pg_conn = refresh_conn(pg_conn, 'pg') 1480 pg_conn = refresh_conn(pg_conn, 'test_crawler')
1481 1481
1482 with pg_conn.cursor() as pg_cur: 1482 with pg_conn.cursor() as pg_cur:
1483 rows = fetch_yinyan_records_missing_platform(pg_cur, BACKFILL_BATCH_SIZE) 1483 rows = fetch_yinyan_records_missing_platform(pg_cur, BACKFILL_BATCH_SIZE)
...@@ -1523,7 +1523,7 @@ def _backfill_qq_singers(spider_conn, pg_conn, bucket, base_url, max_batches): ...@@ -1523,7 +1523,7 @@ def _backfill_qq_singers(spider_conn, pg_conn, bucket, base_url, max_batches):
1523 while max_batches is None or batch_index < max_batches: 1523 while max_batches is None or batch_index < max_batches:
1524 # 长任务连接可能断开,每批次开始前刷新 1524 # 长任务连接可能断开,每批次开始前刷新
1525 spider_conn = refresh_conn(spider_conn, 'spider') 1525 spider_conn = refresh_conn(spider_conn, 'spider')
1526 pg_conn = refresh_conn(pg_conn, 'pg') 1526 pg_conn = refresh_conn(pg_conn, 'test_crawler')
1527 1527
1528 with pg_conn.cursor() as pg_cur: 1528 with pg_conn.cursor() as pg_cur:
1529 rows = fetch_qq_songs_missing_singers(pg_cur, BATCH_SIZE) 1529 rows = fetch_qq_songs_missing_singers(pg_cur, BATCH_SIZE)
...@@ -1603,7 +1603,7 @@ def _backfill_kugou_singers(spider_conn, pg_conn, bucket, base_url, max_batches) ...@@ -1603,7 +1603,7 @@ def _backfill_kugou_singers(spider_conn, pg_conn, bucket, base_url, max_batches)
1603 while max_batches is None or batch_index < max_batches: 1603 while max_batches is None or batch_index < max_batches:
1604 # 长任务连接可能断开,每批次开始前刷新 1604 # 长任务连接可能断开,每批次开始前刷新
1605 spider_conn = refresh_conn(spider_conn, 'spider') 1605 spider_conn = refresh_conn(spider_conn, 'spider')
1606 pg_conn = refresh_conn(pg_conn, 'pg') 1606 pg_conn = refresh_conn(pg_conn, 'test_crawler')
1607 1607
1608 with pg_conn.cursor() as pg_cur: 1608 with pg_conn.cursor() as pg_cur:
1609 rows = fetch_kugou_songs_missing_singers(pg_cur, BATCH_SIZE) 1609 rows = fetch_kugou_songs_missing_singers(pg_cur, BATCH_SIZE)
...@@ -1677,7 +1677,7 @@ def _backfill_netease_singers(spider_conn, pg_conn, bucket, base_url, max_batche ...@@ -1677,7 +1677,7 @@ def _backfill_netease_singers(spider_conn, pg_conn, bucket, base_url, max_batche
1677 while max_batches is None or batch_index < max_batches: 1677 while max_batches is None or batch_index < max_batches:
1678 # 长任务连接可能断开,每批次开始前刷新 1678 # 长任务连接可能断开,每批次开始前刷新
1679 spider_conn = refresh_conn(spider_conn, 'spider') 1679 spider_conn = refresh_conn(spider_conn, 'spider')
1680 pg_conn = refresh_conn(pg_conn, 'pg') 1680 pg_conn = refresh_conn(pg_conn, 'test_crawler')
1681 1681
1682 with pg_conn.cursor() as pg_cur: 1682 with pg_conn.cursor() as pg_cur:
1683 rows = fetch_netease_songs_missing_singers(pg_cur, BATCH_SIZE) 1683 rows = fetch_netease_songs_missing_singers(pg_cur, BATCH_SIZE)
...@@ -1746,7 +1746,7 @@ def _backfill_netease_singers(spider_conn, pg_conn, bucket, base_url, max_batche ...@@ -1746,7 +1746,7 @@ def _backfill_netease_singers(spider_conn, pg_conn, bucket, base_url, max_batche
1746 1746
1747 def backfill_empty_singers(platforms: list[str], max_batches: int | None = None) -> None: 1747 def backfill_empty_singers(platforms: list[str], max_batches: int | None = None) -> None:
1748 spider_conn = get_spider_conn() 1748 spider_conn = get_spider_conn()
1749 pg_conn = get_pg_conn() 1749 pg_conn = get_test_crawler_conn()
1750 bucket = get_oss_bucket() 1750 bucket = get_oss_bucket()
1751 base_url = OSS_CONFIG['base_url'] 1751 base_url = OSS_CONFIG['base_url']
1752 try: 1752 try:
...@@ -1769,7 +1769,7 @@ def backfill_qq_invalid_covers(max_batches: int | None = None) -> None: ...@@ -1769,7 +1769,7 @@ def backfill_qq_invalid_covers(max_batches: int | None = None) -> None:
1769 hk_conn = get_hk_songs_conn() 1769 hk_conn = get_hk_songs_conn()
1770 src_conn = get_source_conn() 1770 src_conn = get_source_conn()
1771 spider_conn = get_spider_conn() 1771 spider_conn = get_spider_conn()
1772 pg_conn = get_pg_conn() 1772 pg_conn = get_test_crawler_conn()
1773 bucket = get_oss_bucket() 1773 bucket = get_oss_bucket()
1774 base_url = OSS_CONFIG['base_url'] 1774 base_url = OSS_CONFIG['base_url']
1775 batch_index = replaced = filtered = retry_later = 0 1775 batch_index = replaced = filtered = retry_later = 0
...@@ -1780,7 +1780,7 @@ def backfill_qq_invalid_covers(max_batches: int | None = None) -> None: ...@@ -1780,7 +1780,7 @@ def backfill_qq_invalid_covers(max_batches: int | None = None) -> None:
1780 hk_conn = refresh_conn(hk_conn, 'hk_songs') 1780 hk_conn = refresh_conn(hk_conn, 'hk_songs')
1781 src_conn = refresh_conn(src_conn, 'source') 1781 src_conn = refresh_conn(src_conn, 'source')
1782 spider_conn = refresh_conn(spider_conn, 'spider') 1782 spider_conn = refresh_conn(spider_conn, 'spider')
1783 pg_conn = refresh_conn(pg_conn, 'pg') 1783 pg_conn = refresh_conn(pg_conn, 'test_crawler')
1784 1784
1785 with pg_conn.cursor() as pg_cur: 1785 with pg_conn.cursor() as pg_cur:
1786 invalid_rows = fetch_qq_songs_with_invalid_covers( 1786 invalid_rows = fetch_qq_songs_with_invalid_covers(
...@@ -1879,7 +1879,7 @@ def run( ...@@ -1879,7 +1879,7 @@ def run(
1879 hk_conn = get_hk_songs_conn() 1879 hk_conn = get_hk_songs_conn()
1880 src_conn = get_source_conn() 1880 src_conn = get_source_conn()
1881 spider_conn = get_spider_conn() 1881 spider_conn = get_spider_conn()
1882 pg_conn = get_pg_conn() 1882 pg_conn = get_test_crawler_conn()
1883 bucket = get_oss_bucket() 1883 bucket = get_oss_bucket()
1884 base_url = OSS_CONFIG['base_url'] 1884 base_url = OSS_CONFIG['base_url']
1885 total_ok = total_err = 0 1885 total_ok = total_err = 0
...@@ -1905,7 +1905,7 @@ def run( ...@@ -1905,7 +1905,7 @@ def run(
1905 hk_conn = refresh_conn(hk_conn, 'hk_songs') 1905 hk_conn = refresh_conn(hk_conn, 'hk_songs')
1906 src_conn = refresh_conn(src_conn, 'source') 1906 src_conn = refresh_conn(src_conn, 'source')
1907 spider_conn = refresh_conn(spider_conn, 'spider') 1907 spider_conn = refresh_conn(spider_conn, 'spider')
1908 pg_conn = refresh_conn(pg_conn, 'pg') 1908 pg_conn = refresh_conn(pg_conn, 'test_crawler')
1909 1909
1910 with pg_conn.cursor() as pg_cur: 1910 with pg_conn.cursor() as pg_cur:
1911 pending_records = fetch_pending_yinyan_song_records(pg_cur, BATCH_SIZE, platforms) 1911 pending_records = fetch_pending_yinyan_song_records(pg_cur, BATCH_SIZE, platforms)
......
...@@ -15,7 +15,7 @@ import argparse ...@@ -15,7 +15,7 @@ import argparse
15 import logging 15 import logging
16 16
17 from etl_to_crawler.config import PLATFORM_QQ, PLATFORM_KUGOU, PLATFORM_NETEASE 17 from etl_to_crawler.config import PLATFORM_QQ, PLATFORM_KUGOU, PLATFORM_NETEASE
18 from etl_to_crawler.connections import get_source_conn, get_pg_conn, close_all_pools 18 from etl_to_crawler.connections import get_source_conn, get_test_crawler_conn, close_all_pools
19 from etl_to_crawler.utils import split_title_version 19 from etl_to_crawler.utils import split_title_version
20 20
21 logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s') 21 logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s')
...@@ -134,7 +134,7 @@ def main(): ...@@ -134,7 +134,7 @@ def main():
134 args = parser.parse_args() 134 args = parser.parse_args()
135 135
136 mysql_conn = get_source_conn() 136 mysql_conn = get_source_conn()
137 pg_conn = get_pg_conn() 137 pg_conn = get_test_crawler_conn()
138 138
139 total_updated = 0 139 total_updated = 0
140 total_diff = 0 140 total_diff = 0
......
1 import title_norm as tn
2
3
4 def test_normalize_name():
5 cases = [
6 ("周杰伦", "周杰伦"),
7 (" 周杰伦 ", "周杰伦"),
8 ("Glass Animals", "glass animals"),
9 ("Jay Chou", "jay chou"),
10 ("", ""),
11 ]
12 for raw, want in cases:
13 assert tn.normalize_name(raw) == want, raw
14
15
16 def test_singer_identity_key():
17 assert tn.singer_identity_key("crawler_qqmusic_singers", 162918) == "qqmusic:162918"
18
19
20 def test_author_identity_key():
21 assert tn.author_identity_key(" 周杰伦 ") == "name:周杰伦"
22
23
24 def test_platform_from_singer_table():
25 cases = [
26 ("crawler_qqmusic_singers", "qqmusic"),
27 ("crawler_netease_singers", "netease"),
28 ("crawler_kugou_songs", "kugou"),
29 ]
30 for raw, want in cases:
31 assert tn.platform_from_singer_table(raw) == want
32
33
34 def test_split_names():
35 got = tn.split_names("周杰伦/方文山")
36 assert got == ["周杰伦", "方文山"]
37 assert tn.split_names("") is None
38
39
40 def test_normalize_title():
41 cases = [
42 ("七里香", "七里香", ""),
43 (" 七里香 ", "七里香", ""),
44 ("七里香(Live)", "七里香", "live"),
45 ("七里香(Live版)", "七里香", "live"),
46 ("七里香 [Remix]", "七里香", "remix"),
47 ("七里香(混音版)", "七里香", "remix"),
48 ("七里香(现场版)", "七里香", "live"),
49 ("七里香(DJ版)", "七里香", "dj版"),
50 ("七里香(ass version)", "七里香", "ass version"),
51 ("ANGEL (天使)", "angel (天使)", ""),
52 ("Radio(Dum-Dum)", "radio(dum-dum)", ""),
53 ("七里香(完整版)", "七里香", ""),
54 ("七里香(原版)", "七里香", ""),
55 ("《七里香》", "七里香", ""),
56 ("《七裡香》(Live)", "七里香", "live"),
57 ("七裡香", "七里香", ""),
58 ("七里香 - DJ版", "七里香", "dj版"),
59 ("七里香 - 周杰伦", "七里香 - 周杰伦", ""),
60 ("七里香—Live", "七里香", "live"),
61 ("七里香(天使)(Live)", "七里香(天使)", "live"),
62 ("七里香 现场版", "七里香", "live"),
63 ]
64 for raw, want_title, want_ver in cases:
65 got_title, got_ver = tn.normalize_title(raw)
66 assert (got_title, got_ver) == (want_title, want_ver), raw
67
68
69 def test_normalize_name_set():
70 a = tn.normalize_name_set("周杰伦,方文山")
71 b = tn.normalize_name_set("方文山、周杰伦")
72 assert a == b
73 assert tn.normalize_name_set("") is None
74
75
76 def test_strip_clip_markers():
77 cases = [
78 ("父亲写的散文诗-剪辑版", "父亲写的散文诗"),
79 ("人生路漫漫主歌", "人生路漫漫"),
80 ("Stay with me(氛围beat)-全曲", "Stay with me(氛围beat)"),
81 ("咏春(副歌)", "咏春(副歌)"),
82 ("小气鬼(加速版)", "小气鬼"),
83 ("枪火(剪辑一)", "枪火"),
84 ("恋人", "恋人"),
85 ]
86 for raw, want in cases:
87 assert tn.strip_clip_markers(raw) == want, raw
88
89
90 def test_is_version_marker():
91 cases = [
92 ("DJ版", True),
93 ("钢琴版", True),
94 ("女生版", True),
95 ("完整版", False),
96 ("原版", False),
97 ("ass version", True),
98 ("TV Version", True),
99 ("Extended Version", True),
100 ("live", True),
101 ("Live", True),
102 ("伴奏", True),
103 ("对唱", True),
104 ("dj", True),
105 ("remix", True),
106 ("0.8x", True),
107 ("1.1X", True),
108 ("×0.93", True),
109 ("0.8倍", True),
110 ("1.2倍速", True),
111 ("天使", False),
112 ("Dum-Dum", False),
113 ("周杰伦", False),
114 ("", False),
115 ]
116 for raw, want in cases:
117 assert tn.is_version_marker(raw) == want, raw
118
119
120 def test_split_names_clip_authors():
121 cases = [
122 ("唐伯虎Annie、伯爵Johnny", 2),
123 ("梨香JZH & 口古口古", 2),
124 ("派偉俊/mac ova sea", 2),
125 ("李荣浩", 1),
126 ]
127 for raw, want in cases:
128 got = tn.split_names(raw)
129 assert len(got or []) == want, raw
130
131
132 def test_normalize_set():
133 a = tn.normalize_set(["方文山", "周杰伦"])
134 b = tn.normalize_set(["周杰伦", "方文山"])
135 assert a == b
136 assert tn.normalize_set(None) is None
...@@ -2,7 +2,7 @@ from pathlib import Path ...@@ -2,7 +2,7 @@ from pathlib import Path
2 2
3 from openpyxl import load_workbook 3 from openpyxl import load_workbook
4 4
5 from check_record_titles import combine_rows, fetch_crawler_rows, fetch_record_names, write_excel 5 from check_record_titles import combine_rows, fetch_crawler_rows, fetch_spider_titles, write_excel
6 6
7 7
8 class Cursor: 8 class Cursor:
...@@ -42,7 +42,7 @@ def test_fetch_crawler_rows_joins_all_platform_tables(): ...@@ -42,7 +42,7 @@ def test_fetch_crawler_rows_joins_all_platform_tables():
42 "platform": "1", 42 "platform": "1",
43 "platform_song_id": 101, 43 "platform_song_id": 101,
44 "recording_id": "recording-1", 44 "recording_id": "recording-1",
45 "title": "同名", 45 "crawler_title": "同名",
46 }] 46 }]
47 assert "WHERE ysr.is_archive_push = TRUE" in cursor.calls[0][0] 47 assert "WHERE ysr.is_archive_push = TRUE" in cursor.calls[0][0]
48 48
...@@ -58,31 +58,35 @@ def test_fetch_crawler_rows_rejects_unknown_table(): ...@@ -58,31 +58,35 @@ def test_fetch_crawler_rows_rejects_unknown_table():
58 raise AssertionError("应拒绝非白名单表") 58 raise AssertionError("应拒绝非白名单表")
59 59
60 60
61 def test_fetch_record_names_batches_queries(): 61 def test_fetch_spider_titles_batches_queries_by_platform():
62 connection = Connection([ 62 connection = Connection([
63 [{"id": 1, "record_name": "甲"}, {"id": 2, "record_name": "乙"}], 63 [{"id": 101, "title": "甲"}],
64 [{"id": 3, "record_name": "丙"}], 64 [{"id": 102, "title": "乙"}],
65 ]) 65 ])
66 crawler_rows = [
67 {"platform": "1", "platform_song_id": 101},
68 {"platform": "2", "platform_song_id": 102},
69 ]
66 70
67 names = fetch_record_names(connection, [3, 1, 2, 2], batch_size=2) 71 titles = fetch_spider_titles(connection, crawler_rows, batch_size=2)
68 72
69 assert names == {1: "甲", 2: "乙", 3: "丙"} 73 assert titles == {("1", 101): "甲", ("2", 102): "乙"}
70 74
71 75
72 def test_excel_marks_only_exact_non_null_matches_green(tmp_path: Path): 76 def test_excel_marks_only_exact_non_null_matches_green(tmp_path: Path):
73 crawler_rows = [ 77 crawler_rows = [
74 {"record_id": 1, "platform": "1", "platform_song_id": 101, "recording_id": "r1", "title": "相同"}, 78 {"record_id": 1, "platform": "1", "platform_song_id": 101, "recording_id": "r1", "crawler_title": "相同"},
75 {"record_id": 2, "platform": "2", "platform_song_id": 102, "recording_id": "r2", "title": " 名称 "}, 79 {"record_id": 2, "platform": "2", "platform_song_id": 102, "recording_id": "r2", "crawler_title": " 名称 "},
76 {"record_id": 3, "platform": "4", "platform_song_id": 103, "recording_id": None, "title": None}, 80 {"record_id": 3, "platform": "4", "platform_song_id": 103, "recording_id": None, "crawler_title": None},
77 ] 81 ]
78 rows = combine_rows(crawler_rows, {1: "相同", 2: "名称", 3: None}) 82 rows = combine_rows(crawler_rows, {("1", 101): "相同", ("2", 102): "名称"})
79 output = tmp_path / "check.xlsx" 83 output = tmp_path / "check.xlsx"
80 84
81 write_excel(rows, output) 85 write_excel(rows, output)
82 86
83 worksheet = load_workbook(output).active 87 worksheet = load_workbook(output).active
84 assert [worksheet.cell(1, column).value for column in range(1, 7)] == [ 88 assert [worksheet.cell(1, column).value for column in range(1, 7)] == [
85 "record_id", "record_name", "title", "platform", "platform_song_id", "recording_id", 89 "record_id", "spider_title", "crawler_title", "platform", "platform_song_id", "recording_id",
86 ] 90 ]
87 assert worksheet["A2"].fill.fgColor.rgb == "00C6EFCE" 91 assert worksheet["A2"].fill.fgColor.rgb == "00C6EFCE"
88 assert worksheet["A3"].fill.fill_type is None 92 assert worksheet["A3"].fill.fill_type is None
......
1 from openpyxl import load_workbook
2 from datetime import datetime, timezone
3
4 from fix_record_titles import (
5 InputRow,
6 _archive_recording_sql,
7 _archive_source_sql,
8 _crawler_sql,
9 _rollback_sql,
10 _row_value,
11 build_backup_records,
12 build_report_rows,
13 build_fix_rows,
14 fetch_input_rows,
15 load_backup,
16 write_backup_atomic,
17 write_report,
18 )
19
20
21 class Cursor:
22 def __init__(self, rows):
23 self.rows = rows
24 self.calls = []
25
26 def __enter__(self):
27 return self
28
29 def __exit__(self, exc_type, exc_value, traceback):
30 return False
31
32 def execute(self, sql, params=None):
33 self.calls.append((sql, params))
34
35 def fetchall(self):
36 return self.rows
37
38
39 class Connection:
40 def __init__(self, rows):
41 self.raw_cursor = Cursor(rows)
42
43 def cursor(self):
44 return self.raw_cursor
45
46
47 def test_fetch_input_rows_reads_archive_push_relations_directly():
48 connection = Connection([
49 (1, "1", 101, "REC-1"),
50 (2, "2", 102, "REC-2"),
51 ])
52
53 rows, stats = fetch_input_rows(connection)
54
55 assert rows == [
56 InputRow(1, "1", 101, "REC-1"),
57 InputRow(2, "2", 102, "REC-2"),
58 ]
59 assert stats == {"total": 2, "missing_key": 0, "unsupported_platform": 0}
60 sql = connection.raw_cursor.calls[0][0]
61 assert "FROM yinyan_song_records" in sql
62 assert "WHERE is_archive_push = TRUE" in sql
63
64
65 def test_fetch_input_rows_applies_limit():
66 connection = Connection([(1, "1", 101, "REC-1")])
67
68 fetch_input_rows(connection, limit=10)
69
70 sql, params = connection.raw_cursor.calls[0]
71 assert "LIMIT %s" in sql
72 assert params == (10,)
73
74
75 def test_build_fix_rows_uses_spider_title_for_normalization():
76 input_row = InputRow(1, "1", 101, "REC-1")
77
78 rows, fix_stats = build_fix_rows(
79 [input_row],
80 {("1", 101): "七里香(Live版)"},
81 )
82
83 assert fix_stats["missing_spider"] == 0
84 assert rows[0].spider_title == "七里香(Live版)"
85 assert rows[0].title_norm == "七里香"
86 assert rows[0].version == "live"
87 assert _row_value(rows[0], "archive_platform") == "qqmusic"
88 assert _row_value(rows[0], "archive_version") == "live"
89
90
91 def test_build_fix_rows_skips_missing_spider_title():
92 input_row = InputRow(1, "4", 101, "REC-1")
93
94 rows, fix_stats = build_fix_rows([input_row], {})
95
96 assert rows == []
97 assert fix_stats["missing_spider"] == 1
98
99
100 def test_build_fix_rows_temporarily_excludes_exact_spider_title():
101 excluded = 'Waiting On A Wish (From "Disney\'s Snow White"/Soundtrack Version|Reprise)'
102 input_rows = [
103 InputRow(1, "1", 101, "REC-1"),
104 InputRow(2, "1", 102, "REC-2"),
105 ]
106 spider_titles = {
107 ("1", 101): excluded,
108 ("1", 102): "Waiting On A Wish",
109 }
110
111 rows, fix_stats = build_fix_rows(
112 input_rows,
113 spider_titles,
114 exclude_titles={excluded},
115 )
116
117 assert [row.platform_song_id for row in rows] == [102]
118 assert fix_stats["excluded_title"] == 1
119
120
121 def test_update_sql_uses_spider_values_and_only_updates_differences():
122 recording_sql, recording_params = _archive_recording_sql(1, True)
123 source_sql, source_params = _archive_source_sql(1, True)
124 crawler_sql, crawler_params = _crawler_sql("crawler_qqmusic_songs", 1, True)
125
126 assert recording_params == ["recording_id", "spider_title", "title_norm", "archive_version"]
127 assert "title_norm = source.new_title_norm" in recording_sql
128 assert "IS DISTINCT FROM source.new_title" in recording_sql
129 assert "target.platform_song_id = source.platform_song_id" in source_sql
130 assert "spider_title" in source_params
131 assert crawler_params == ["platform_song_id", "spider_title", "version"]
132 assert "source.platform_song_id::bigint" in crawler_sql
133 assert "version = source.new_version" in crawler_sql
134 assert "RETURNING target.recording_id" in recording_sql
135 assert "RETURNING target.platform_song_id" in crawler_sql
136 locked_sql, _ = _archive_recording_sql(1, False, lock=True)
137 assert "FOR UPDATE OF target" in locked_sql
138
139
140 def test_report_lists_only_returned_target_keys(tmp_path):
141 fix = build_fix_rows(
142 [InputRow(1, "1", 101, "REC-1")],
143 {("1", 101): "七里香(Live版)"},
144 )[0][0]
145 matched = {
146 "archive_recording": [
147 ("REC-1", "旧标题", "七里香(Live版)", "旧标题", "七里香", None, "live"),
148 ],
149 "archive_recording_platform_source": [
150 ("REC-1", "qqmusic", "101", "旧标题", "七里香(Live版)", None, "七里香", None, "live"),
151 ],
152 "crawler_qqmusic_songs": [
153 (101, "旧标题", "七里香(Live版)", "", "live"),
154 ],
155 "crawler_kugou_songs": [],
156 "crawler_netease_songs": [],
157 }
158
159 rows = build_report_rows([fix], matched, apply=False)
160 output = tmp_path / "report.xlsx"
161 write_report(output, rows)
162
163 worksheet = load_workbook(output).active
164 assert len(rows) == 3
165 assert worksheet.max_row == 4
166 assert worksheet["A1"].value == "database"
167 assert worksheet["H1"].value == "old_title"
168 assert worksheet["I1"].value == "new_title"
169 assert worksheet["H2"].value == "旧标题"
170 assert worksheet["I2"].value == "七里香(Live版)"
171 assert worksheet["J3"].value == "<NULL>"
172 assert worksheet["J3"].fill.fgColor.rgb == "00FFC7CE"
173 assert worksheet["K3"].fill.fgColor.rgb == "00C6EFCE"
174 assert worksheet["N2"].value == "would_update"
175
176
177 def test_backup_round_trip_preserves_null_empty_and_updated_at(tmp_path):
178 fix = build_fix_rows(
179 [InputRow(1, "1", 101, "REC-1")],
180 {("1", 101): "七里香(Live版)"},
181 )[0][0]
182 updated_at = datetime(2026, 7, 15, 10, 0, tzinfo=timezone.utc)
183 preview = {
184 "archive_recording": [
185 ("REC-1", "旧标题", "七里香(Live版)", None, "七里香", None, "live", updated_at),
186 ],
187 "archive_recording_platform_source": [],
188 "crawler_qqmusic_songs": [
189 (101, "旧标题", "七里香(Live版)", "", "live", updated_at.replace(tzinfo=None)),
190 ],
191 "crawler_kugou_songs": [],
192 "crawler_netease_songs": [],
193 }
194 records = build_backup_records([fix], preview, "run-1")
195 path = tmp_path / "backup.jsonl"
196
197 write_backup_atomic(path, records, "run-1")
198 metadata, loaded = load_backup(path)
199
200 assert metadata["change_count"] == 2
201 assert loaded[0]["old"]["title_norm"] is None
202 assert loaded[1]["old"]["version"] == ""
203 assert loaded[0]["old"]["updated_at"] == updated_at.isoformat()
204
205 try:
206 write_backup_atomic(path, records, "run-2")
207 except FileExistsError:
208 pass
209 else:
210 raise AssertionError("已有备份不应被覆盖")
211
212
213 def test_rollback_sql_restores_old_values_with_new_value_guard():
214 archive_sql, _ = _rollback_sql("archive_recording", 1)
215 crawler_sql, _ = _rollback_sql("crawler_qqmusic_songs", 1)
216
217 assert "SET title = source.old_title" in archive_sql
218 assert "updated_at = source.old_updated_at::timestamptz" in archive_sql
219 assert "target.title IS NOT DISTINCT FROM source.new_title" in archive_sql
220 assert "target.title_norm IS NOT DISTINCT FROM source.new_title_norm" in archive_sql
221 assert "updated_at = source.old_updated_at::timestamp" in crawler_sql
222 assert "target.version IS NOT DISTINCT FROM source.new_version" in crawler_sql
...@@ -375,7 +375,7 @@ def test_run_imports_only_pending_yinyan_platform_record(monkeypatch): ...@@ -375,7 +375,7 @@ def test_run_imports_only_pending_yinyan_platform_record(monkeypatch):
375 monkeypatch.setattr(runner, 'get_hk_songs_conn', lambda: _Connection()) 375 monkeypatch.setattr(runner, 'get_hk_songs_conn', lambda: _Connection())
376 monkeypatch.setattr(runner, 'get_source_conn', lambda: _Connection()) 376 monkeypatch.setattr(runner, 'get_source_conn', lambda: _Connection())
377 monkeypatch.setattr(runner, 'get_spider_conn', lambda: _Connection()) 377 monkeypatch.setattr(runner, 'get_spider_conn', lambda: _Connection())
378 monkeypatch.setattr(runner, 'get_pg_conn', lambda: pg_conn) 378 monkeypatch.setattr(runner, 'get_test_crawler_conn', lambda: pg_conn)
379 monkeypatch.setattr(runner, 'get_oss_bucket', lambda: object()) 379 monkeypatch.setattr(runner, 'get_oss_bucket', lambda: object())
380 monkeypatch.setattr(runner, 'refresh_conn', lambda conn, name: conn) 380 monkeypatch.setattr(runner, 'refresh_conn', lambda conn, name: conn)
381 monkeypatch.setattr(runner, 'fetch_pending_yinyan_song_records', lambda cur, batch_size, platforms: [ 381 monkeypatch.setattr(runner, 'fetch_pending_yinyan_song_records', lambda cur, batch_size, platforms: [
...@@ -436,7 +436,7 @@ def test_run_retry_failed_resets_failures_and_restores_hk_sources(monkeypatch): ...@@ -436,7 +436,7 @@ def test_run_retry_failed_resets_failures_and_restores_hk_sources(monkeypatch):
436 monkeypatch.setattr(runner, 'get_hk_songs_conn', lambda: hk_conn) 436 monkeypatch.setattr(runner, 'get_hk_songs_conn', lambda: hk_conn)
437 monkeypatch.setattr(runner, 'get_source_conn', lambda: _Connection()) 437 monkeypatch.setattr(runner, 'get_source_conn', lambda: _Connection())
438 monkeypatch.setattr(runner, 'get_spider_conn', lambda: _Connection()) 438 monkeypatch.setattr(runner, 'get_spider_conn', lambda: _Connection())
439 monkeypatch.setattr(runner, 'get_pg_conn', lambda: pg_conn) 439 monkeypatch.setattr(runner, 'get_test_crawler_conn', lambda: pg_conn)
440 monkeypatch.setattr(runner, 'get_oss_bucket', lambda: object()) 440 monkeypatch.setattr(runner, 'get_oss_bucket', lambda: object())
441 monkeypatch.setattr(runner, 'refresh_conn', lambda conn, name: conn) 441 monkeypatch.setattr(runner, 'refresh_conn', lambda conn, name: conn)
442 monkeypatch.setattr(runner, 'reset_failed_yinyan_song_records', reset) 442 monkeypatch.setattr(runner, 'reset_failed_yinyan_song_records', reset)
...@@ -456,7 +456,7 @@ def test_initialize_yinyan_song_records_inserts_primary_records(monkeypatch): ...@@ -456,7 +456,7 @@ def test_initialize_yinyan_song_records_inserts_primary_records(monkeypatch):
456 456
457 monkeypatch.setattr(runner, 'get_hk_songs_conn', lambda: _Connection()) 457 monkeypatch.setattr(runner, 'get_hk_songs_conn', lambda: _Connection())
458 monkeypatch.setattr(runner, 'get_source_conn', lambda: _Connection()) 458 monkeypatch.setattr(runner, 'get_source_conn', lambda: _Connection())
459 monkeypatch.setattr(runner, 'get_pg_conn', lambda: pg_conn) 459 monkeypatch.setattr(runner, 'get_test_crawler_conn', lambda: pg_conn)
460 monkeypatch.setattr(runner, 'refresh_conn', lambda conn, name: conn) 460 monkeypatch.setattr(runner, 'refresh_conn', lambda conn, name: conn)
461 monkeypatch.setattr(runner, 'fetch_existing_yinyan_song_ids', lambda cur: set()) 461 monkeypatch.setattr(runner, 'fetch_existing_yinyan_song_ids', lambda cur: set())
462 462
...@@ -501,7 +501,7 @@ def test_initialize_yinyan_song_records2_writes_all_relations_then_deduplicates( ...@@ -501,7 +501,7 @@ def test_initialize_yinyan_song_records2_writes_all_relations_then_deduplicates(
501 dedupe_calls = [] 501 dedupe_calls = []
502 502
503 monkeypatch.setattr(runner, 'get_source_conn', lambda: _Connection()) 503 monkeypatch.setattr(runner, 'get_source_conn', lambda: _Connection())
504 monkeypatch.setattr(runner, 'get_pg_conn', lambda: pg_conn) 504 monkeypatch.setattr(runner, 'get_test_crawler_conn', lambda: pg_conn)
505 monkeypatch.setattr(runner, 'refresh_conn', lambda conn, name: conn) 505 monkeypatch.setattr(runner, 'refresh_conn', lambda conn, name: conn)
506 monkeypatch.setattr(runner, 'fetch_yinyan_song_ids', lambda cur: [10]) 506 monkeypatch.setattr(runner, 'fetch_yinyan_song_ids', lambda cur: [10])
507 monkeypatch.setattr(runner, 'fetch_all_song_record_relations', lambda conn, song_ids: [ 507 monkeypatch.setattr(runner, 'fetch_all_song_record_relations', lambda conn, song_ids: [
...@@ -532,7 +532,7 @@ def test_initialize_yinyan_song_records2_keeps_rows_without_valid_singers(monkey ...@@ -532,7 +532,7 @@ def test_initialize_yinyan_song_records2_keeps_rows_without_valid_singers(monkey
532 monkeypatch.setattr(runner, 'get_source_conn', lambda: _Connection()) 532 monkeypatch.setattr(runner, 'get_source_conn', lambda: _Connection())
533 get_spider_conn = MagicMock(side_effect=AssertionError('records2 init must not query spider')) 533 get_spider_conn = MagicMock(side_effect=AssertionError('records2 init must not query spider'))
534 monkeypatch.setattr(runner, 'get_spider_conn', get_spider_conn) 534 monkeypatch.setattr(runner, 'get_spider_conn', get_spider_conn)
535 monkeypatch.setattr(runner, 'get_pg_conn', lambda: pg_conn) 535 monkeypatch.setattr(runner, 'get_test_crawler_conn', lambda: pg_conn)
536 monkeypatch.setattr(runner, 'refresh_conn', lambda conn, name: conn) 536 monkeypatch.setattr(runner, 'refresh_conn', lambda conn, name: conn)
537 monkeypatch.setattr(runner, 'fetch_yinyan_song_ids', lambda cur: [10]) 537 monkeypatch.setattr(runner, 'fetch_yinyan_song_ids', lambda cur: [10])
538 monkeypatch.setattr(runner, 'fetch_all_song_record_relations', lambda conn, song_ids: [ 538 monkeypatch.setattr(runner, 'fetch_all_song_record_relations', lambda conn, song_ids: [
...@@ -561,7 +561,7 @@ def test_backfill_yinyan_record_platforms_updates_missing_platform_rows(monkeypa ...@@ -561,7 +561,7 @@ def test_backfill_yinyan_record_platforms_updates_missing_platform_rows(monkeypa
561 updated = [] 561 updated = []
562 562
563 monkeypatch.setattr(runner, 'get_source_conn', lambda: _Connection()) 563 monkeypatch.setattr(runner, 'get_source_conn', lambda: _Connection())
564 monkeypatch.setattr(runner, 'get_pg_conn', lambda: pg_conn) 564 monkeypatch.setattr(runner, 'get_test_crawler_conn', lambda: pg_conn)
565 monkeypatch.setattr(runner, 'refresh_conn', lambda conn, name: conn) 565 monkeypatch.setattr(runner, 'refresh_conn', lambda conn, name: conn)
566 monkeypatch.setattr(runner, 'fetch_yinyan_records_missing_platform', lambda cur, batch_size: [ 566 monkeypatch.setattr(runner, 'fetch_yinyan_records_missing_platform', lambda cur, batch_size: [
567 {'song_id': 10, 'record_id': 100}, 567 {'song_id': 10, 'record_id': 100},
......