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"])
100 for row in crawler_rows
101 if str(row["platform"]) == platform and row["platform_song_id"] is not None
102 })
103 for batch in chunked(ids, batch_size):
96 placeholders = ", ".join(["%s"] * len(batch)) 104 placeholders = ", ".join(["%s"] * len(batch))
97 sql = f"""
98 SELECT id, record_name
99 FROM hk_music_record
100 WHERE id IN ({placeholders})
101 """
102 with mysql_conn.cursor() as cursor: 105 with mysql_conn.cursor() as cursor:
103 cursor.execute(sql, tuple(batch)) 106 cursor.execute(
107 f"SELECT id, title FROM {table} WHERE id IN ({placeholders})",
108 tuple(batch),
109 )
104 for row in cursor.fetchall(): 110 for row in cursor.fetchall():
105 names[int(row["id"])] = row["record_name"] 111 if row["title"] is not None:
112 titles[(platform, int(row["id"]))] = str(row["title"])
113 return titles
106 114
107 return names
108 115
109 116 def combine_rows(crawler_rows: list[dict], spider_titles: dict[tuple[str, int], str]) -> list[dict]:
110 def combine_rows(crawler_rows: list[dict], record_names: dict[int, str | None]) -> 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)
......
1 #!/usr/bin/env python3
2 """用 hikoon-data-spider 平台原始标题修复 archive/crawler 标题字段。
3
4 数据链路:
5 archive_crawler.yinyan_song_records (is_archive_push = TRUE)
6 的 (platform, platform_song_id)
7 -> hikoon-data-spider 对应 media_*_songs.id -> title
8 -> title_norm.normalize_title(spider_title) -> (title_norm, version)
9
10 写入目标:
11 * archive_data.archive_recording:title/title_norm/version_type
12 * archive_data.archive_recording_platform_source:title/title_norm/version_type
13 * archive_crawler.crawler_*_songs:title/version
14
15 默认只执行 dry-run 统计,不写库;必须显式添加 ``--apply`` 才会提交。
16 """
17
18 import argparse
19 import json
20 import logging
21 import os
22 from datetime import datetime, timezone
23 from dataclasses import dataclass
24 from pathlib import Path
25 from typing import Iterable, Sequence
26 from uuid import uuid4
27
28 from openpyxl import Workbook
29 from openpyxl.cell import WriteOnlyCell
30 from openpyxl.styles import Font, PatternFill
31 import title_norm as tn
32
33 from etl_to_crawler.connections import (
34 close_all_pools,
35 get_archive_conn,
36 get_archive_crawler_conn,
37 get_spider_conn,
38 )
39
40
41 log = logging.getLogger(__name__)
42
43 DEFAULT_BATCH_SIZE = 1000
44 DEFAULT_REPORT = "output/fix_record_titles_report.xlsx"
45 DEFAULT_BACKUP_DIR = Path("output/backups")
46 BACKUP_SCHEMA_VERSION = 1
47 MAX_VARCHAR_LEN = 50 # archive/crawler 表 title/title_norm/version_type 列最大长度
48
49 PLATFORMS = {
50 "1": {
51 "archive_name": "qqmusic",
52 "spider_table": "media_tencent_songs",
53 "crawler_table": "crawler_qqmusic_songs",
54 },
55 "2": {
56 "archive_name": "kugou",
57 "spider_table": "media_ku_gou_songs",
58 "crawler_table": "crawler_kugou_songs",
59 },
60 "4": {
61 "archive_name": "netease",
62 "spider_table": "media_netease_songs",
63 "crawler_table": "crawler_netease_songs",
64 },
65 }
66
67
68 @dataclass(frozen=True)
69 class InputRow:
70 record_id: int
71 platform: str
72 platform_song_id: int
73 recording_id: str
74
75
76 @dataclass(frozen=True)
77 class FixRow(InputRow):
78 spider_title: str
79 title_norm: str
80 version: str
81
82
83 def chunked(rows: Sequence, size: int) -> Iterable[Sequence]:
84 for start in range(0, len(rows), size):
85 yield rows[start:start + size]
86
87
88 def fetch_input_rows(crawler_conn, limit: int | None = None) -> tuple[list[InputRow], dict[str, int]]:
89 """直接读取正式 yinyan_song_records 的全部 archive 推送关系。"""
90 sql = """
91 SELECT record_id, platform, platform_song_id, recording_id
92 FROM yinyan_song_records
93 WHERE is_archive_push = TRUE
94 ORDER BY record_id, platform, platform_song_id
95 """
96 params = None
97 if limit is not None:
98 sql += " LIMIT %s"
99 params = (limit,)
100 with crawler_conn.cursor() as cursor:
101 cursor.execute(sql, params)
102 values_list = cursor.fetchall()
103
104 stats = {"total": 0, "missing_key": 0, "unsupported_platform": 0}
105 rows: list[InputRow] = []
106 seen_recordings: set[str] = set()
107 seen_platform_songs: set[tuple[str, int]] = set()
108 for values in values_list:
109 stats["total"] += 1
110 record_id, platform_value, platform_song_id, recording_id = values
111 platform = str(platform_value)
112 if platform not in PLATFORMS:
113 stats["unsupported_platform"] += 1
114 continue
115 if None in (platform_song_id, recording_id, record_id):
116 stats["missing_key"] += 1
117 continue
118
119 row = InputRow(
120 record_id=int(record_id),
121 platform=platform,
122 platform_song_id=int(platform_song_id),
123 recording_id=str(recording_id),
124 )
125 recording_key = row.recording_id
126 song_key = (row.platform, row.platform_song_id)
127 if recording_key in seen_recordings:
128 raise ValueError(f"Excel 中 recording_id 重复: {recording_key}")
129 if song_key in seen_platform_songs:
130 raise ValueError(f"Excel 中 platform_song_id 重复: {song_key}")
131 seen_recordings.add(recording_key)
132 seen_platform_songs.add(song_key)
133 rows.append(row)
134 return rows, stats
135
136
137 def fetch_spider_titles(spider_conn, rows: list[InputRow], batch_size: int) -> dict[tuple[str, int], str]:
138 """按平台从 spider 歌曲表读取原始 title;表名严格来自白名单。"""
139 titles: dict[tuple[str, int], str] = {}
140 for platform, config in PLATFORMS.items():
141 ids = sorted({row.platform_song_id for row in rows if row.platform == platform})
142 table = config["spider_table"]
143 for batch in chunked(ids, batch_size):
144 placeholders = ", ".join(["%s"] * len(batch))
145 with spider_conn.cursor() as cursor:
146 cursor.execute(
147 f"SELECT id, title FROM {table} WHERE id IN ({placeholders})",
148 tuple(batch),
149 )
150 for result in cursor.fetchall():
151 title = result["title"]
152 if title is not None and str(title).strip() != "":
153 titles[(platform, int(result["id"]))] = str(title)
154 return titles
155
156
157 def build_fix_rows(
158 input_rows: list[InputRow],
159 spider_titles: dict[tuple[str, int], str],
160 long_only: bool = False,
161 exclude_titles: set[str] | None = None,
162 ) -> tuple[list[FixRow], dict[str, int]]:
163 """以 spider title 为唯一修复源,并用 title_norm 提取归一标题和版本。
164
165 默认模式下超过 ``MAX_VARCHAR_LEN`` 的记录会被跳过,避免写入时 varchar 溢出。
166 当 ``long_only=True`` 时反转过滤:仅保留超长记录,用于 DBA 扩列后补跑。
167 """
168 fixes: list[FixRow] = []
169 exclude_titles = exclude_titles or set()
170 stats = {
171 "missing_spider": 0,
172 "excluded_title": 0,
173 "skip_long_title": 0,
174 "skip_long_title_norm": 0,
175 "skip_long_version": 0,
176 "skip_short": 0,
177 }
178 for row in input_rows:
179 spider_title = spider_titles.get((row.platform, row.platform_song_id))
180 if spider_title is None:
181 stats["missing_spider"] += 1
182 continue
183 if spider_title in exclude_titles:
184 stats["excluded_title"] += 1
185 continue
186 normalized_title, version = tn.normalize_title(spider_title)
187 is_long = (
188 len(spider_title) > MAX_VARCHAR_LEN
189 or len(normalized_title) > MAX_VARCHAR_LEN
190 or (version and len(version) > MAX_VARCHAR_LEN)
191 )
192 if long_only:
193 if not is_long:
194 stats["skip_short"] += 1
195 continue
196 else:
197 if is_long:
198 if len(spider_title) > MAX_VARCHAR_LEN:
199 stats["skip_long_title"] += 1
200 elif len(normalized_title) > MAX_VARCHAR_LEN:
201 stats["skip_long_title_norm"] += 1
202 else:
203 stats["skip_long_version"] += 1
204 continue
205 fixes.append(
206 FixRow(
207 **row.__dict__,
208 spider_title=spider_title,
209 title_norm=normalized_title,
210 version=version,
211 )
212 )
213 return fixes, stats
214
215
216 def _archive_recording_sql(batch_length: int, apply: bool, lock: bool = False) -> tuple[str, list[str]]:
217 placeholders = ", ".join(["(%s, %s, %s, %s)"] * batch_length)
218 values = "(recording_id, new_title, new_title_norm, new_version)"
219 params = ["recording_id", "spider_title", "title_norm", "archive_version"]
220 difference = """(
221 target.title IS DISTINCT FROM source.new_title
222 OR target.title_norm IS DISTINCT FROM source.new_title_norm
223 OR target.version_type IS DISTINCT FROM source.new_version
224 )"""
225 if apply:
226 sql = f"""
227 UPDATE archive_recording AS target
228 SET title = source.new_title,
229 title_norm = source.new_title_norm,
230 version_type = source.new_version,
231 updated_at = NOW()
232 FROM (VALUES {placeholders}) AS source{values}
233 WHERE target.recording_id = source.recording_id
234 AND {difference}
235 RETURNING target.recording_id
236 """
237 else:
238 sql = f"""
239 SELECT target.recording_id,
240 target.title, source.new_title,
241 target.title_norm, source.new_title_norm,
242 target.version_type, source.new_version,
243 target.updated_at
244 FROM archive_recording AS target
245 JOIN (VALUES {placeholders}) AS source{values}
246 ON target.recording_id = source.recording_id
247 WHERE {difference}
248 {"FOR UPDATE OF target" if lock else ""}
249 """
250 return sql, params
251
252
253 def _archive_source_sql(batch_length: int, apply: bool, lock: bool = False) -> tuple[str, list[str]]:
254 placeholders = ", ".join(["(%s, %s, %s, %s, %s, %s)"] * batch_length)
255 values = "(recording_id, platform, platform_song_id, new_title, new_title_norm, new_version)"
256 params = [
257 "recording_id", "archive_platform", "platform_song_id_text",
258 "spider_title", "title_norm", "archive_version",
259 ]
260 difference = """(
261 target.title IS DISTINCT FROM source.new_title
262 OR target.title_norm IS DISTINCT FROM source.new_title_norm
263 OR target.version_type IS DISTINCT FROM source.new_version
264 )"""
265 join = """target.recording_id = source.recording_id
266 AND target.platform = source.platform
267 AND target.platform_song_id = source.platform_song_id"""
268 if apply:
269 sql = f"""
270 UPDATE archive_recording_platform_source AS target
271 SET title = source.new_title,
272 title_norm = source.new_title_norm,
273 version_type = source.new_version,
274 updated_at = NOW()
275 FROM (VALUES {placeholders}) AS source{values}
276 WHERE {join} AND {difference}
277 RETURNING target.recording_id, target.platform, target.platform_song_id
278 """
279 else:
280 sql = f"""
281 SELECT target.recording_id, target.platform, target.platform_song_id,
282 target.title, source.new_title,
283 target.title_norm, source.new_title_norm,
284 target.version_type, source.new_version,
285 target.updated_at
286 FROM archive_recording_platform_source AS target
287 JOIN (VALUES {placeholders}) AS source{values} ON {join}
288 WHERE {difference}
289 {"FOR UPDATE OF target" if lock else ""}
290 """
291 return sql, params
292
293
294 def _crawler_sql(table: str, batch_length: int, apply: bool, lock: bool = False) -> tuple[str, list[str]]:
295 placeholders = ", ".join(["(%s, %s, %s)"] * batch_length)
296 values = "(platform_song_id, new_title, new_version)"
297 params = ["platform_song_id", "spider_title", "version"]
298 difference = """(
299 target.title IS DISTINCT FROM source.new_title
300 OR target.version IS DISTINCT FROM source.new_version
301 )"""
302 if apply:
303 sql = f"""
304 UPDATE {table} AS target
305 SET title = source.new_title,
306 version = source.new_version,
307 updated_at = NOW()
308 FROM (VALUES {placeholders}) AS source{values}
309 WHERE target.platform_song_id = source.platform_song_id::bigint
310 AND {difference}
311 RETURNING target.platform_song_id
312 """
313 else:
314 sql = f"""
315 SELECT target.platform_song_id,
316 target.title, source.new_title,
317 target.version, source.new_version,
318 target.updated_at
319 FROM {table} AS target
320 JOIN (VALUES {placeholders}) AS source{values}
321 ON target.platform_song_id = source.platform_song_id::bigint
322 WHERE {difference}
323 {"FOR UPDATE OF target" if lock else ""}
324 """
325 return sql, params
326
327
328 def _row_value(row: FixRow, name: str):
329 values = {
330 "recording_id": row.recording_id,
331 "archive_platform": PLATFORMS[row.platform]["archive_name"],
332 "platform_song_id_text": str(row.platform_song_id),
333 "platform_song_id": row.platform_song_id,
334 "spider_title": row.spider_title,
335 "title_norm": row.title_norm,
336 "archive_version": row.version or None,
337 "version": row.version,
338 }
339 return values[name]
340
341
342 def execute_batches(conn, rows: list[FixRow], sql_builder, batch_size: int, apply: bool) -> list[tuple]:
343 if not rows:
344 return []
345 matched_keys: list[tuple] = []
346 for batch in chunked(rows, batch_size):
347 sql, param_names = sql_builder(len(batch), apply)
348 params = [_row_value(row, name) for row in batch for name in param_names]
349 with conn.cursor() as cursor:
350 cursor.execute(sql, tuple(params))
351 matched_keys.extend(tuple(result) for result in cursor.fetchall())
352 return matched_keys
353
354
355 def _changed_fields(pairs: tuple[tuple[str, object, object], ...]) -> str:
356 return ",".join(field for field, old, new in pairs if old != new)
357
358
359 def build_report_rows(fixes: list[FixRow], preview: dict[str, list[tuple]], apply: bool) -> list[tuple]:
360 """把更新前快照和目标值转换成可审计的前后对比明细。"""
361 by_recording = {row.recording_id: row for row in fixes}
362 by_platform_song = {(row.platform, row.platform_song_id): row for row in fixes}
363 action = "updated" if apply else "would_update"
364 report_rows: list[tuple] = []
365
366 for result in preview.get("archive_recording", []):
367 row = by_recording.get(str(result[0]))
368 if row:
369 fields = _changed_fields((
370 ("title", result[1], result[2]),
371 ("title_norm", result[3], result[4]),
372 ("version_type", result[5], result[6]),
373 ))
374 report_rows.append((
375 "archive_data", "archive_recording", row.record_id, row.recording_id,
376 row.platform, row.platform_song_id, fields,
377 result[1], result[2], result[3], result[4], result[5], result[6], action,
378 ))
379
380 for result in preview.get("archive_recording_platform_source", []):
381 row = by_recording.get(str(result[0]))
382 if row:
383 fields = _changed_fields((
384 ("title", result[3], result[4]),
385 ("title_norm", result[5], result[6]),
386 ("version_type", result[7], result[8]),
387 ))
388 report_rows.append((
389 "archive_data", "archive_recording_platform_source", row.record_id,
390 row.recording_id, row.platform, row.platform_song_id,
391 fields, result[3], result[4], result[5], result[6],
392 result[7], result[8], action,
393 ))
394
395 for platform, config in PLATFORMS.items():
396 table = config["crawler_table"]
397 for result in preview.get(table, []):
398 row = by_platform_song.get((platform, int(result[0])))
399 if row:
400 fields = _changed_fields((
401 ("title", result[1], result[2]),
402 ("version", result[3], result[4]),
403 ))
404 report_rows.append((
405 "archive_crawler", table, row.record_id, row.recording_id,
406 row.platform, row.platform_song_id, fields,
407 result[1], result[2], None, None, result[3], result[4], action,
408 ))
409 return report_rows
410
411
412 def build_backup_records(
413 fixes: list[FixRow],
414 preview: dict[str, list[tuple]],
415 run_id: str,
416 ) -> list[dict]:
417 """将加锁读取的更新前快照转换成可机器回退的记录。"""
418 by_recording = {row.recording_id: row for row in fixes}
419 by_platform_song = {(row.platform, row.platform_song_id): row for row in fixes}
420 records: list[dict] = []
421
422 for result in preview.get("archive_recording", []):
423 row = by_recording[str(result[0])]
424 records.append({
425 "type": "change", "schema_version": BACKUP_SCHEMA_VERSION, "run_id": run_id,
426 "database": "archive_data", "table": "archive_recording",
427 "keys": {"recording_id": row.recording_id},
428 "old": {
429 "title": result[1], "title_norm": result[3],
430 "version_type": result[5], "updated_at": result[7],
431 },
432 "new": {
433 "title": result[2], "title_norm": result[4], "version_type": result[6],
434 },
435 })
436
437 for result in preview.get("archive_recording_platform_source", []):
438 records.append({
439 "type": "change", "schema_version": BACKUP_SCHEMA_VERSION, "run_id": run_id,
440 "database": "archive_data", "table": "archive_recording_platform_source",
441 "keys": {
442 "recording_id": str(result[0]), "platform": str(result[1]),
443 "platform_song_id": str(result[2]),
444 },
445 "old": {
446 "title": result[3], "title_norm": result[5],
447 "version_type": result[7], "updated_at": result[9],
448 },
449 "new": {
450 "title": result[4], "title_norm": result[6], "version_type": result[8],
451 },
452 })
453
454 for platform, config in PLATFORMS.items():
455 table = config["crawler_table"]
456 for result in preview.get(table, []):
457 row = by_platform_song[(platform, int(result[0]))]
458 records.append({
459 "type": "change", "schema_version": BACKUP_SCHEMA_VERSION, "run_id": run_id,
460 "database": "archive_crawler", "table": table,
461 "keys": {"platform_song_id": row.platform_song_id},
462 "old": {
463 "title": result[1], "version": result[3], "updated_at": result[5],
464 },
465 "new": {"title": result[2], "version": result[4]},
466 })
467 return records
468
469
470 def _json_default(value):
471 if isinstance(value, (datetime,)):
472 return value.isoformat()
473 raise TypeError(f"无法序列化 {type(value).__name__}")
474
475
476 def write_backup_atomic(path: Path, records: list[dict], run_id: str) -> None:
477 """先 fsync 临时文件再无覆盖发布,保证 UPDATE 前备份已持久化。"""
478 path.parent.mkdir(parents=True, exist_ok=True)
479 if path.exists():
480 raise FileExistsError(f"拒绝覆盖已有回退备份: {path}")
481 temp_path = path.with_name(path.name + f".{uuid4().hex}.tmp")
482 metadata = {
483 "type": "metadata",
484 "schema_version": BACKUP_SCHEMA_VERSION,
485 "run_id": run_id,
486 "created_at": datetime.now(timezone.utc).isoformat(),
487 "change_count": len(records),
488 }
489 with temp_path.open("w", encoding="utf-8") as handle:
490 handle.write(json.dumps(metadata, ensure_ascii=False) + "\n")
491 for record in records:
492 handle.write(json.dumps(record, ensure_ascii=False, default=_json_default) + "\n")
493 handle.flush()
494 os.fsync(handle.fileno())
495 try:
496 # 硬链接发布不会覆盖已存在目标;并发使用同一路径时也会安全失败。
497 os.link(temp_path, path)
498 directory_fd = os.open(path.parent, os.O_RDONLY)
499 try:
500 os.fsync(directory_fd)
501 finally:
502 os.close(directory_fd)
503 finally:
504 temp_path.unlink(missing_ok=True)
505
506
507 def load_backup(path: Path) -> tuple[dict, list[dict]]:
508 with path.open("r", encoding="utf-8") as handle:
509 lines = [json.loads(line) for line in handle if line.strip()]
510 if not lines or lines[0].get("type") != "metadata":
511 raise ValueError("无效的回退备份:缺少 metadata")
512 metadata = lines[0]
513 records = lines[1:]
514 if metadata.get("schema_version") != BACKUP_SCHEMA_VERSION:
515 raise ValueError("不支持的备份版本")
516 if metadata.get("change_count") != len(records):
517 raise ValueError("备份记录数校验失败")
518 return metadata, records
519
520
521 def _rollback_sql(table: str, batch_length: int) -> tuple[str, list[str]]:
522 """生成带新值保护的批量回退 SQL;表名只能来自内部白名单。"""
523 if table == "archive_recording":
524 placeholders = ", ".join(["(%s, %s, %s, %s, %s, %s, %s, %s)"] * batch_length)
525 sql = f"""
526 UPDATE archive_recording AS target
527 SET title = source.old_title,
528 title_norm = source.old_title_norm,
529 version_type = source.old_version,
530 updated_at = source.old_updated_at::timestamptz
531 FROM (VALUES {placeholders}) AS source(
532 recording_id, old_title, old_title_norm, old_version, old_updated_at,
533 new_title, new_title_norm, new_version
534 )
535 WHERE target.recording_id = source.recording_id
536 AND target.title IS NOT DISTINCT FROM source.new_title
537 AND target.title_norm IS NOT DISTINCT FROM source.new_title_norm
538 AND target.version_type IS NOT DISTINCT FROM source.new_version
539 RETURNING target.recording_id
540 """
541 names = [
542 "recording_id", "old_title", "old_title_norm", "old_version", "old_updated_at",
543 "new_title", "new_title_norm", "new_version",
544 ]
545 elif table == "archive_recording_platform_source":
546 placeholders = ", ".join(["(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)"] * batch_length)
547 sql = f"""
548 UPDATE archive_recording_platform_source AS target
549 SET title = source.old_title,
550 title_norm = source.old_title_norm,
551 version_type = source.old_version,
552 updated_at = source.old_updated_at::timestamptz
553 FROM (VALUES {placeholders}) AS source(
554 recording_id, platform, platform_song_id,
555 old_title, old_title_norm, old_version, old_updated_at,
556 new_title, new_title_norm, new_version
557 )
558 WHERE target.recording_id = source.recording_id
559 AND target.platform = source.platform
560 AND target.platform_song_id = source.platform_song_id
561 AND target.title IS NOT DISTINCT FROM source.new_title
562 AND target.title_norm IS NOT DISTINCT FROM source.new_title_norm
563 AND target.version_type IS NOT DISTINCT FROM source.new_version
564 RETURNING target.recording_id, target.platform, target.platform_song_id
565 """
566 names = [
567 "recording_id", "platform", "platform_song_id",
568 "old_title", "old_title_norm", "old_version", "old_updated_at",
569 "new_title", "new_title_norm", "new_version",
570 ]
571 elif table in {config["crawler_table"] for config in PLATFORMS.values()}:
572 placeholders = ", ".join(["(%s, %s, %s, %s, %s, %s)"] * batch_length)
573 sql = f"""
574 UPDATE {table} AS target
575 SET title = source.old_title,
576 version = source.old_version,
577 updated_at = source.old_updated_at::timestamp
578 FROM (VALUES {placeholders}) AS source(
579 platform_song_id, old_title, old_version, old_updated_at,
580 new_title, new_version
581 )
582 WHERE target.platform_song_id = source.platform_song_id::bigint
583 AND target.title IS NOT DISTINCT FROM source.new_title
584 AND target.version IS NOT DISTINCT FROM source.new_version
585 RETURNING target.platform_song_id
586 """
587 names = [
588 "platform_song_id", "old_title", "old_version", "old_updated_at",
589 "new_title", "new_version",
590 ]
591 else:
592 raise ValueError(f"备份包含不允许回退的表: {table}")
593 return sql, names
594
595
596 def _backup_value(record: dict, name: str):
597 if name in record["keys"]:
598 return record["keys"][name]
599 if name.startswith("old_"):
600 field = name[4:]
601 if field == "version":
602 field = "version_type" if "version_type" in record["old"] else "version"
603 return record["old"].get(field)
604 if name.startswith("new_"):
605 field = name[4:]
606 if field == "version":
607 field = "version_type" if "version_type" in record["new"] else "version"
608 return record["new"].get(field)
609 raise KeyError(name)
610
611
612 def execute_rollback_batches(conn, table: str, records: list[dict], batch_size: int) -> list[tuple]:
613 restored: list[tuple] = []
614 for batch in chunked(records, batch_size):
615 sql, names = _rollback_sql(table, len(batch))
616 params = [_backup_value(record, name) for record in batch for name in names]
617 with conn.cursor() as cursor:
618 cursor.execute(sql, tuple(params))
619 restored.extend(tuple(result) for result in cursor.fetchall())
620 return restored
621
622
623 def rollback_backup(path: Path, batch_size: int) -> dict[str, int]:
624 """按备份回退;当前值不再等于本次新值的记录会安全跳过。"""
625 metadata, records = load_backup(path)
626 groups: dict[tuple[str, str], list[dict]] = {}
627 for record in records:
628 if record.get("type") != "change" or record.get("run_id") != metadata.get("run_id"):
629 raise ValueError("备份记录 run_id 或类型无效")
630 key = (record.get("database"), record.get("table"))
631 groups.setdefault(key, []).append(record)
632
633 archive_conn = get_archive_conn()
634 crawler_conn = get_archive_crawler_conn()
635 restored: dict[str, int] = {}
636 try:
637 for (database, table), table_records in groups.items():
638 if database == "archive_data":
639 conn = archive_conn
640 elif database == "archive_crawler":
641 conn = crawler_conn
642 else:
643 raise ValueError(f"备份包含不允许回退的数据库: {database}")
644 keys = execute_rollback_batches(conn, table, table_records, batch_size)
645 restored[table] = len(keys)
646 archive_conn.commit()
647 crawler_conn.commit()
648 except Exception:
649 archive_conn.rollback()
650 crawler_conn.rollback()
651 raise
652 finally:
653 close_all_pools()
654
655 log.info("已按 run_id=%s 执行回退", metadata["run_id"])
656 for table, count in restored.items():
657 log.info("%s:已回退 %d 行", table, count)
658 return restored
659
660
661 def write_report(report_path: Path, rows: list[tuple]) -> None:
662 report_path.parent.mkdir(parents=True, exist_ok=True)
663 workbook = Workbook(write_only=True)
664 worksheet = workbook.create_sheet()
665 worksheet.title = "更新明细"
666 worksheet.freeze_panes = "A2"
667 headers = [
668 "database", "table", "record_id", "recording_id", "platform",
669 "platform_song_id", "changed_fields", "old_title", "new_title",
670 "old_title_norm", "new_title_norm", "old_version", "new_version", "action",
671 ]
672 header_fill = PatternFill(fill_type="solid", fgColor="4472C4")
673 header_font = Font(color="FFFFFF", bold=True)
674 old_fill = PatternFill(fill_type="solid", fgColor="FFC7CE")
675 new_fill = PatternFill(fill_type="solid", fgColor="C6EFCE")
676 header_cells = []
677 for header in headers:
678 cell = WriteOnlyCell(worksheet, value=header)
679 cell.fill = header_fill
680 cell.font = header_font
681 header_cells.append(cell)
682 worksheet.append(header_cells)
683 for values in rows:
684 output_cells = []
685 changed_fields = set(str(values[6]).split(","))
686 changed_columns = set()
687 if "title" in changed_fields:
688 changed_columns.update((8, 9))
689 if "title_norm" in changed_fields:
690 changed_columns.update((10, 11))
691 if "version" in changed_fields or "version_type" in changed_fields:
692 changed_columns.update((12, 13))
693 for column, value in enumerate(values, 1):
694 if value is None:
695 value = "<NULL>"
696 elif value == "":
697 value = "<EMPTY>"
698 cell = WriteOnlyCell(worksheet, value=value)
699 if column in changed_columns:
700 cell.fill = old_fill if column in (8, 10, 12) else new_fill
701 output_cells.append(cell)
702 worksheet.append(output_cells)
703 widths = (18, 42, 15, 24, 10, 20, 34, 48, 48, 40, 40, 24, 24, 16)
704 for column, width in enumerate(widths, 1):
705 worksheet.column_dimensions[chr(64 + column)].width = width
706 worksheet.auto_filter.ref = f"A1:N{len(rows) + 1}"
707 workbook.save(report_path)
708
709
710 def run(
711 batch_size: int,
712 apply: bool,
713 limit: int | None,
714 report_path: Path,
715 backup_path: Path | None = None,
716 long_only: bool = False,
717 exclude_titles: set[str] | None = None,
718 ) -> dict[str, int]:
719 crawler_conn = get_archive_crawler_conn()
720 input_rows, input_stats = fetch_input_rows(crawler_conn, limit)
721 spider_conn = get_spider_conn()
722 spider_titles = fetch_spider_titles(spider_conn, input_rows, batch_size)
723 fixes, fix_stats = build_fix_rows(
724 input_rows,
725 spider_titles,
726 long_only=long_only,
727 exclude_titles=exclude_titles,
728 )
729 skip_long = fix_stats["skip_long_title"] + fix_stats["skip_long_title_norm"] + fix_stats["skip_long_version"]
730 if long_only:
731 log.info(
732 "[long-only] yinyan archive记录=%d,有效键=%d,超长候选=%d,短标题跳过=%d,spider 缺失=%d",
733 input_stats["total"], len(input_rows), len(fixes), fix_stats["skip_short"], fix_stats["missing_spider"],
734 )
735 else:
736 log.info(
737 "yinyan archive记录=%d,有效键=%d,spider 标题=%d,spider 缺失=%d,超长跳过=%d (title=%d, title_norm=%d, version=%d)",
738 input_stats["total"], len(input_rows), len(fixes), fix_stats["missing_spider"],
739 skip_long, fix_stats["skip_long_title"], fix_stats["skip_long_title_norm"], fix_stats["skip_long_version"],
740 )
741 if fix_stats["excluded_title"]:
742 log.info("本次按 spider title 精确排除=%d 条,不进入预览、备份或更新", fix_stats["excluded_title"])
743
744 archive_conn = get_archive_conn()
745 preview: dict[str, list[tuple]] = {}
746 actual: dict[str, list[tuple]] = {}
747 run_id = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + "_" + uuid4().hex[:8]
748 try:
749 preview["archive_recording"] = execute_batches(
750 archive_conn, fixes,
751 lambda length, _: _archive_recording_sql(length, False, lock=apply),
752 batch_size, False,
753 )
754 preview["archive_recording_platform_source"] = execute_batches(
755 archive_conn, fixes,
756 lambda length, _: _archive_source_sql(length, False, lock=apply),
757 batch_size, False,
758 )
759 for platform, config in PLATFORMS.items():
760 table = config["crawler_table"]
761 platform_rows = [row for row in fixes if row.platform == platform]
762 preview[table] = execute_batches(
763 crawler_conn,
764 platform_rows,
765 lambda length, _, table=table: _crawler_sql(table, length, False, lock=apply),
766 batch_size,
767 False,
768 )
769 if apply:
770 backup_records = build_backup_records(fixes, preview, run_id)
771 if backup_path is None:
772 backup_path = DEFAULT_BACKUP_DIR / f"fix_record_titles_{run_id}.jsonl"
773 backup_path = backup_path.expanduser().resolve()
774 write_backup_atomic(backup_path, backup_records, run_id)
775 log.info("回退备份已持久化至 %s,共 %d 条", backup_path, len(backup_records))
776 actual["archive_recording"] = execute_batches(
777 archive_conn, fixes, _archive_recording_sql, batch_size, True,
778 )
779 actual["archive_recording_platform_source"] = execute_batches(
780 archive_conn, fixes, _archive_source_sql, batch_size, True,
781 )
782 for platform, config in PLATFORMS.items():
783 table = config["crawler_table"]
784 platform_rows = [row for row in fixes if row.platform == platform]
785 actual[table] = execute_batches(
786 crawler_conn,
787 platform_rows,
788 lambda length, do_apply, table=table: _crawler_sql(table, length, do_apply),
789 batch_size,
790 True,
791 )
792 archive_conn.commit()
793 crawler_conn.commit()
794 else:
795 actual = preview
796 archive_conn.rollback()
797 crawler_conn.rollback()
798 except Exception:
799 archive_conn.rollback()
800 crawler_conn.rollback()
801 raise
802 finally:
803 close_all_pools()
804
805 report_rows = build_report_rows(fixes, preview, apply)
806 write_report(report_path, report_rows)
807 results = {table: len(keys) for table, keys in actual.items()}
808 for table, count in results.items():
809 log.info("%s:%s %d 行", table, "已更新" if apply else "需更新", count)
810 if not apply:
811 log.info("当前为 dry-run,未修改数据库;确认后添加 --apply")
812 log.info("更新明细已输出至 %s,共 %d 行", report_path, len(report_rows))
813 return results
814
815
816 def parse_args() -> argparse.Namespace:
817 parser = argparse.ArgumentParser(description="按 spider 原始标题修复 archive/crawler 标题")
818 parser.add_argument("--batch-size", type=int, default=DEFAULT_BATCH_SIZE)
819 parser.add_argument("--limit", type=int, help="最多处理多少条 yinyan 关系(默认全部)")
820 parser.add_argument("--report", default=DEFAULT_REPORT, help=f"更新明细 Excel(默认: {DEFAULT_REPORT})")
821 parser.add_argument("--backup", help="--apply 前写入的回退 JSONL 路径(默认自动生成)")
822 parser.add_argument("--long-only", action="store_true", help="仅处理超长标题记录(DBA 扩列后补跑)")
823 parser.add_argument(
824 "--exclude-title",
825 action="append",
826 default=[],
827 help="本次运行不更新的 spider 完整标题(精确匹配,可重复传入)",
828 )
829 mode = parser.add_mutually_exclusive_group()
830 mode.add_argument("--apply", action="store_true", help="备份成功后实际提交更新;默认仅 dry-run")
831 mode.add_argument("--rollback", help="使用指定 JSONL 备份安全回退")
832 args = parser.parse_args()
833 if args.batch_size <= 0:
834 parser.error("--batch-size 必须大于 0")
835 if args.limit is not None and args.limit <= 0:
836 parser.error("--limit 必须大于 0")
837 if args.rollback and (args.limit is not None or args.backup or args.exclude_title):
838 parser.error("--rollback 不能与 --limit、--backup 或 --exclude-title 同时使用")
839 return args
840
841
842 def main() -> None:
843 args = parse_args()
844 if args.rollback:
845 rollback_backup(Path(args.rollback).expanduser().resolve(), args.batch_size)
846 return
847 run(
848 args.batch_size,
849 args.apply,
850 args.limit,
851 Path(args.report).expanduser().resolve(),
852 Path(args.backup) if args.backup else None,
853 long_only=args.long_only,
854 exclude_titles=set(args.exclude_title),
855 )
856
857
858 if __name__ == "__main__":
859 logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
860 main()
...@@ -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 #!/usr/bin/env python3
2 """生成港乐词曲资产数据缺口清单 Excel"""
3 import openpyxl
4 from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
5 from openpyxl.utils import get_column_letter
6
7 wb = openpyxl.Workbook()
8
9 # ── 样式定义 ──────────────────────────────────────────
10 title_font = Font(name="Microsoft YaHei", size=12, bold=True, color="FFFFFFFF")
11 title_fill = PatternFill(start_color="FF4472C4", end_color="FF4472C4", fill_type="solid")
12 title_align = Alignment(horizontal="center", vertical="center", wrap_text=True)
13
14 header_font = Font(name="宋体", size=10.5, bold=True)
15 header_fill = PatternFill(start_color="FFD9E2F3", end_color="FFD9E2F3", fill_type="solid")
16 header_align = Alignment(horizontal="center", vertical="center", wrap_text=True)
17
18 data_font = Font(name="Microsoft YaHei", size=10)
19 data_align = Alignment(vertical="top", wrap_text=True)
20 center_align = Alignment(horizontal="center", vertical="top", wrap_text=True)
21
22 # 处理方式颜色
23 FILL_RED = PatternFill(start_color="FFFFC7CE", end_color="FFFFC7CE", fill_type="solid") # 🔴 人工
24 FILL_GREEN = PatternFill(start_color="FFC6EFCE", end_color="FFC6EFCE", fill_type="solid") # 🟢 爬虫
25 FILL_YELLOW = PatternFill(start_color="FFFFEB9C", end_color="FFFFEB9C", fill_type="solid") # 🟡 待扩展
26 FILL_GRAY = PatternFill(start_color="FFD9D9D9", end_color="FFD9D9D9", fill_type="solid") # ⚪ 暂不可操作
27
28 thin_border = Border(
29 left=Side(style="thin"), right=Side(style="thin"),
30 top=Side(style="thin"), bottom=Side(style="thin"),
31 )
32
33 # ── Sheet 1: 数据缺口清单 ─────────────────────────────
34 ws1 = wb.active
35 ws1.title = "数据缺口清单"
36
37 # 标题行
38 ws1.merge_cells("A1:G1")
39 c = ws1["A1"]
40 c.value = "港乐词曲资产导入 — 数据缺口清单(2026-07-14)"
41 c.font = title_font; c.fill = title_fill; c.alignment = title_align
42
43 # 表头
44 headers = ["序号", "缺口类型", "数量", "处理方式", "涉及数据", "补齐说明", "优先级"]
45 for i, h in enumerate(headers, 1):
46 cell = ws1.cell(row=2, column=i, value=h)
47 cell.font = header_font; cell.fill = header_fill
48 cell.alignment = header_align; cell.border = thin_border
49
50 # 数据
51 rows = [
52 # (序号, 缺口类型, 数量, 处理方式, 涉及数据, 补齐说明, 优先级, fill)
53 (1, "疑似重复,待人工审核", "4,410 首",
54 "🔴 人工处理", "去重暂存表中的候选记录",
55 "系统判定与已有歌曲高度相似,需人工逐条判断是同一首歌(合并)还是不同歌曲(新增导入)。已部署 L2 审核看板支持批量操作。",
56 "P2", FILL_RED),
57
58 (2, "歌词为空且词曲作者均不详", "1,167 首",
59 "🔴 人工处理", "源库中歌词和作者字段同时缺失的歌曲",
60 "源数据本身缺失,爬虫无法生成不存在的元数据。如需导入,需人工查找并补录歌词和作者信息后重新触发导入。可先抽样检查这批歌曲在平台上是否实际有歌词。",
61 "P3", FILL_RED),
62
63 (3, "主录音导入失败——缺歌手信息", "96 条",
64 "🟢 爬虫补录", "records1 中因 spider DB 缺歌手关系而失败的记录",
65 "平台上有歌手信息但尚未同步到本地爬虫数据库。补录歌手关系后系统自动重试。也可调整策略允许空歌手容错(补充录音已支持,主录音暂未支持)。",
66 "P1", FILL_GREEN),
67
68 (4, "主录音导入失败——音频/歌词转存失败", "19 条",
69 "🟢 自动重试", "records1 中因网络或存储问题转存失败的记录(音频 10 条 + 歌词 9 条)",
70 "属于临时性故障,源文件仍在平台上。重新触发转存即可,无需重新爬取。",
71 "P1", FILL_GREEN),
72
73 (5, "主录音导入失败——完全无歌词", "3 条",
74 "🟢 爬虫尝试", "records1 中三个平台均无歌词数据的记录",
75 "可尝试爬虫从其他来源抓取歌词;如平台确实无歌词,需人工确认是否为纯音乐作品。数量极少,影响可忽略。",
76 "P2", FILL_GREEN),
77
78 (6, "歌曲仅有非目标平台录音", "809 首",
79 "🟡 扩展平台", "在三平台(QQ/酷狗/网易云)均无有效录音,仅在其他平台有录音的歌曲",
80 "当前爬虫仅覆盖三个目标平台。扩展平台覆盖后可自动获取录音数据。需业务确认:是否接受这些歌曲暂不入库?",
81 "P3", FILL_YELLOW),
82
83 (7, "已软删歌曲——录音已成功但歌曲不可见", "25 首",
84 "🔴 人工恢复", "records1 已成功但歌曲状态仍为 deleted 的记录",
85 "历史遗留的状态不一致,主录音数据已入库但歌曲被标记为已删除导致前端不可见。确认后批量恢复即可,无需重新爬取。",
86 "P0", FILL_RED),
87
88 (8, "已软删歌曲——现具备三平台录音条件", "71 首",
89 "🔴 人工恢复", "之前因审计/清理被软删除,但当前已具备有效录音条件的歌曲",
90 "数据已补齐,只需逐条确认删除原因是否已过时,符合条件后恢复歌曲状态。",
91 "P2", FILL_RED),
92
93 (9, "已软删歌曲——当前仍无有效三平台录音", "133 首",
94 "⚪ 暂不处理", "被软删除且当前仍无法获取三平台录音的歌曲(含 ETL 清理 143 中剩余部分 + song_time=0 + 元数据重复合并)",
95 "当前无法补齐录音数据。若后续扩展平台覆盖或有其他数据来源,可重新评估。",
96 "P3", FILL_GRAY),
97
98 (10, "原声类歌名", "79 首",
99 "🔴 人工处理", "歌名形如 '@XXX创作的原声' 或 '用户创作的原声' 的特殊歌曲",
100 "歌名为平台自动生成的标记文本,通常不具备导入价值。其中 78 首同时满足歌词/作者缺失条件被前置规则先拦截,仅 1 首 dedup_reason 显式标注为原声类。建议直接跳过。",
101 "P3", FILL_RED),
102 ]
103
104 for r_idx, (no, gtype, count, method, scope, desc, priority, fill) in enumerate(rows, 3):
105 vals = [no, gtype, count, method, scope, desc, priority]
106 for c_idx, v in enumerate(vals, 1):
107 cell = ws1.cell(row=r_idx, column=c_idx, value=v)
108 cell.font = data_font
109 cell.alignment = center_align if c_idx in (1, 3, 4, 7) else data_align
110 cell.border = thin_border
111 # 处理方式列上色
112 if c_idx == 4:
113 cell.fill = fill
114
115 # 列宽
116 col_widths = [6, 30, 10, 14, 30, 55, 6]
117 for i, w in enumerate(col_widths, 1):
118 ws1.column_dimensions[get_column_letter(i)].width = w
119
120 # ── Sheet 2: 总览 ────────────────────────────────────
121 ws2 = wb.create_sheet("总览")
122
123 ws2.merge_cells("A1:C1")
124 c = ws2["A1"]
125 c.value = "港乐词曲资产导入 — 缺口处理总览"
126 c.font = title_font; c.fill = title_fill; c.alignment = title_align
127
128 for i, h in enumerate(["处理方式", "涉及数量", "占比"], 1):
129 cell = ws2.cell(row=2, column=i, value=h)
130 cell.font = header_font; cell.fill = header_fill
131 cell.alignment = header_align; cell.border = thin_border
132
133 summary = [
134 ("🔴 需人工处理(审核/恢复/补录)", "5,752 首", "84.1%", FILL_RED),
135 ("🟢 可通过爬虫/自动手段解决", "118 条", "1.7%", FILL_GREEN),
136 ("🟡 需扩展平台覆盖后自动导入", "809 首", "11.9%", FILL_YELLOW),
137 ("⚪ 暂不可操作,等待外部条件变化", "133 首", "2.0%", FILL_GRAY),
138 ("缺口合计", "约 6,812 首/条", "—", None),
139 ]
140
141 for r_idx, (cat, cnt, pct, fill) in enumerate(summary, 3):
142 for c_idx, v in enumerate([cat, cnt, pct], 1):
143 cell = ws2.cell(row=r_idx, column=c_idx, value=v)
144 cell.font = data_font if r_idx < 7 else Font(name="Microsoft YaHei", size=10, bold=True)
145 cell.alignment = center_align if c_idx >= 2 else Alignment(vertical="top", wrap_text=True)
146 cell.border = thin_border
147 if fill and c_idx == 1:
148 cell.fill = fill
149
150 ws2.column_dimensions["A"].width = 36
151 ws2.column_dimensions["B"].width = 18
152 ws2.column_dimensions["C"].width = 10
153
154 # ── Sheet 3: 优先级排序 ──────────────────────────────
155 ws3 = wb.create_sheet("优先级排序")
156
157 ws3.merge_cells("A1:E1")
158 c = ws3["A1"]
159 c.value = "港乐词曲资产导入 — 补齐优先级排序"
160 c.font = title_font; c.fill = title_fill; c.alignment = title_align
161
162 for i, h in enumerate(["优先级", "处理方式", "涉及数量", "预期效果", "备注"], 1):
163 cell = ws3.cell(row=2, column=i, value=h)
164 cell.font = header_font; cell.fill = header_fill
165 cell.alignment = header_align; cell.border = thin_border
166
167 prio_rows = [
168 ("P0", "恢复 25 首状态不一致的歌曲", "25 首", "立即恢复前端可见,零成本", "仅需修改 deleted 字段"),
169 ("P1", "调整策略后自动重试 96 条缺歌手记录", "96 条", "主录音成功率 99.86% → 99.97%", "需开发评估空歌手容错策略"),
170 ("P1", "重试 19 条转存失败记录", "19 条", "自动完成,无额外开发", "临时性故障,重跑即可"),
171 ("P2", "人工审核 4,410 首疑似重复歌曲", "4,410 首", "释放暂存积压,可能新增歌曲或确认合并", "已部署审核看板"),
172 ("P2", "确认恢复 71 首已删除但已有录音的歌曲", "71 首", "增加有效歌曲数", "逐条确认删除原因"),
173 ("P2", "尝试爬取 3 条无歌词记录的歌词", "3 条", "数量极少,可忽略", "如确为纯音乐则标记跳过"),
174 ("P3", "扩展爬虫平台覆盖后自动导入", "809 首", "需平台扩展开发", "依赖技术规划"),
175 ("P3", "人工补录 1,245+1 首歌词/作者后重新导入", "1,246 首", "工作量大,建议先抽样评估价值", "优先检查是否有实际歌词"),
176 ("P3", "等待外部条件后评估 133 首已删歌曲", "133 首", "视平台扩展情况", "当前无可用数据"),
177 ]
178
179 for r_idx, (pri, action, cnt, effect, note) in enumerate(prio_rows, 3):
180 vals = [pri, action, cnt, effect, note]
181 for c_idx, v in enumerate(vals, 1):
182 cell = ws3.cell(row=r_idx, column=c_idx, value=v)
183 cell.font = data_font
184 cell.alignment = center_align if c_idx in (1, 3) else data_align
185 cell.border = thin_border
186 # 优先级上色
187 if c_idx == 1:
188 if "P0" in str(v):
189 cell.fill = PatternFill(start_color="FFFF6B6B", end_color="FFFF6B6B", fill_type="solid")
190 cell.font = Font(name="Microsoft YaHei", size=10, bold=True, color="FFFFFFFF")
191 elif "P1" in str(v):
192 cell.fill = PatternFill(start_color="FFFFD93D", end_color="FFFFD93D", fill_type="solid")
193 elif "P2" in str(v):
194 cell.fill = PatternFill(start_color="FF6BCB77", end_color="FF6BCB77", fill_type="solid")
195 cell.font = Font(name="Microsoft YaHei", size=10, color="FFFFFFFF")
196 elif "P3" in str(v):
197 cell.fill = PatternFill(start_color="FF4D96FF", end_color="FF4D96FF", fill_type="solid")
198 cell.font = Font(name="Microsoft YaHei", size=10, color="FFFFFFFF")
199
200 ws3.column_dimensions["A"].width = 8
201 ws3.column_dimensions["B"].width = 38
202 ws3.column_dimensions["C"].width = 12
203 ws3.column_dimensions["D"].width = 38
204 ws3.column_dimensions["E"].width = 30
205
206 # ── Sheet 4: 字段级缺口 ──────────────────────────────
207 ws4 = wb.create_sheet("字段级缺口")
208
209 ws4.merge_cells("A1:G1")
210 c = ws4["A1"]
211 c.value = "港乐词曲资产导入 — 字段级缺口(已入库但关键字段为空)"
212 c.font = title_font; c.fill = title_fill; c.alignment = title_align
213
214 for i, h in enumerate(["序号", "缺口类型", "为空数量", "所在表/范围", "平台分布", "补齐方式", "优先级"], 1):
215 cell = ws4.cell(row=2, column=i, value=h)
216 cell.font = header_font; cell.fill = header_fill
217 cell.alignment = header_align; cell.border = thin_border
218
219 field_rows = [
220 (1, "歌曲封面图片缺失", "1,960 首",
221 "hk_songs_test(86,870 首有效歌曲)", "全平台",
222 "🟢 爬虫可回补:重新触发封面下载/转存;如平台本身无封面则需人工提供",
223 "P1", FILL_GREEN),
224
225 (2, "曲作者字段为空", "3,232 首",
226 "hk_songs_test(86,870 首有效歌曲)", "全平台",
227 "🟢 爬虫可回补:从爬虫数据库 composer_name 批量回写;爬虫也无数据则需人工查找",
228 "P1", FILL_GREEN),
229
230 (3, "词作者字段为空", "1,488 首",
231 "hk_songs_test(86,870 首有效歌曲)", "全平台",
232 "🟢 爬虫可回补:从爬虫数据库 lyricist_name 批量回写;爬虫也无数据则需人工查找",
233 "P1", FILL_GREEN),
234
235 (4, "歌手信息为空(singers=[])", "393 条",
236 "爬虫库 songs 表(25,369 首已导入歌曲)",
237 "QQ: 373 / 酷狗: 5 / 网易云: 15",
238 "🟢 爬虫可回补:系统已内置 backfill-empty-singers 功能,从 spider DB 补录歌手关系",
239 "P1", FILL_GREEN),
240
241 (5, "歌手头像缺失", "4,057 人",
242 "爬虫库 singers 表(13,142 位歌手)",
243 "QQ: 3,039 / 酷狗: 549 / 网易云: 469",
244 "🟢 爬虫可回补:重新爬取歌手页面获取最新头像 URL 并转存 OSS",
245 "P1", FILL_GREEN),
246
247 (6, "歌词文本为空", "366 条",
248 "爬虫库 songs 表(25,369 首已导入歌曲)",
249 "QQ: 201 / 酷狗: 157 / 网易云: 8",
250 "🟢 爬虫可回补:重新爬取歌词页面;如平台确实无歌词则为纯音乐,无需补齐",
251 "P2", FILL_GREEN),
252
253 (7, "歌曲封面为空(爬虫侧)", "139 条",
254 "爬虫库 songs 表(25,369 首已导入歌曲)",
255 "QQ: 18 / 酷狗: 58 / 网易云: 63",
256 "🟢 爬虫可回补:重新触发封面下载/转存",
257 "P1", FILL_GREEN),
258
259 (8, "专辑封面为空", "82 个",
260 "爬虫库 albums 表(21,081 个专辑)",
261 "QQ: 0 / 酷狗: 21 / 网易云: 61",
262 "🟢 爬虫可回补:重新触发专辑封面下载/转存",
263 "P2", FILL_GREEN),
264 ]
265
266 for r_idx, (no, gtype, count, scope, dist, method, priority, fill) in enumerate(field_rows, 3):
267 vals = [no, gtype, count, scope, dist, method, priority]
268 for c_idx, v in enumerate(vals, 1):
269 cell = ws4.cell(row=r_idx, column=c_idx, value=v)
270 cell.font = data_font
271 cell.alignment = center_align if c_idx in (1, 3, 7) else data_align
272 cell.border = thin_border
273 if c_idx == 6:
274 cell.fill = fill
275
276 # 汇总行
277 sum_row = 3 + len(field_rows)
278 ws4.merge_cells(f"A{sum_row}:B{sum_row}")
279 ws4.cell(row=sum_row, column=1, value="字段级缺口合计").font = Font(name="Microsoft YaHei", size=10, bold=True)
280 ws4.cell(row=sum_row, column=3, value="约 11,578").font = Font(name="Microsoft YaHei", size=10, bold=True)
281 ws4.cell(row=sum_row, column=6, value="绝大部分可通过爬虫回补").font = Font(name="Microsoft YaHei", size=10, bold=True)
282 for c_idx in range(1, 8):
283 ws4.cell(row=sum_row, column=c_idx).border = thin_border
284
285 ws4.column_dimensions["A"].width = 6
286 ws4.column_dimensions["B"].width = 26
287 ws4.column_dimensions["C"].width = 12
288 ws4.column_dimensions["D"].width = 32
289 ws4.column_dimensions["E"].width = 28
290 ws4.column_dimensions["F"].width = 52
291 ws4.column_dimensions["G"].width = 6
292
293 # ── 更新 Sheet 3 优先级排序:加入字段级缺口 ──────────────
294 # 在现有 prio_rows 后面插入字段级缺口的优先级行
295 field_prio_rows = [
296 ("P1", "爬虫回补 1,960 首封面图片", "1,960 首", "前端展示效果显著改善", "重新触发封面下载/转存"),
297 ("P1", "爬虫回补 4,057 位歌手头像", "4,057 人", "歌手页面展示完整", "重新爬取歌手页面"),
298 ("P1", "爬虫回补词曲作者(3,232 + 1,488 首)", "4,720 首", "元数据完整性提升", "从爬虫库批量回写"),
299 ("P1", "爬虫回补 393 条歌手信息", "393 条", "歌手关联完整性提升", "backfill-empty-singers"),
300 ("P2", "爬虫回补 366 条歌词文本", "366 条", "歌词展示完整", "重新爬取歌词页面"),
301 ("P2", "爬虫回补 139 条歌曲封面 + 82 个专辑封面", "221 条", "爬虫侧数据完整性提升", "重新触发转存"),
302 ]
303
304 insert_row = 3 + len(prio_rows)
305 for i, (pri, action, cnt, effect, note) in enumerate(field_prio_rows):
306 r = insert_row + i
307 vals = [pri, action, cnt, effect, note]
308 for c_idx, v in enumerate(vals, 1):
309 cell = ws3.cell(row=r, column=c_idx, value=v)
310 cell.font = data_font
311 cell.alignment = center_align if c_idx in (1, 3) else data_align
312 cell.border = thin_border
313 if c_idx == 1:
314 if "P1" in str(v):
315 cell.fill = PatternFill(start_color="FFFFD93D", end_color="FFFFD93D", fill_type="solid")
316 elif "P2" in str(v):
317 cell.fill = PatternFill(start_color="FF6BCB77", end_color="FF6BCB77", fill_type="solid")
318 cell.font = Font(name="Microsoft YaHei", size=10, color="FFFFFFFF")
319
320 # ── 更新 Sheet 2 总览:加入字段级缺口分类 ──────────────
321 # 先取消已有的合并单元格,再插入新行
322 for merged in list(ws2.merged_cells.ranges):
323 ws2.unmerge_cells(str(merged))
324
325 # 在现有 summary 行后插入字段级缺口行
326 field_summary = [
327 ("", "", "", None), # 空行分隔
328 ("— 字段级缺口 —", "", "", None),
329 ("🟢 爬虫可回补(封面/歌手/作者/头像/歌词)", "约 11,578 条", "—", FILL_GREEN),
330 (" 其中:歌曲封面图片", "1,960 首", "—", None),
331 (" 其中:词曲作者", "4,720 首", "—", None),
332 (" 其中:歌手信息", "393 条", "—", None),
333 (" 其中:歌手头像", "4,057 人", "—", None),
334 (" 其中:歌词文本", "366 条", "—", None),
335 (" 其中:歌曲/专辑封面(爬虫侧)", "221 条", "—", None),
336 ]
337
338 insert_row2 = 3 + len(summary)
339 for i, (cat, cnt, pct, fill) in enumerate(field_summary):
340 r = insert_row2 + i
341 for c_idx, v in enumerate([cat, cnt, pct], 1):
342 cell = ws2.cell(row=r, column=c_idx, value=v)
343 cell.font = data_font
344 cell.alignment = center_align if c_idx >= 2 else Alignment(vertical="top", wrap_text=True)
345 cell.border = thin_border
346 if fill and c_idx == 1:
347 cell.fill = fill
348 if cat.startswith("—"):
349 cell.font = Font(name="Microsoft YaHei", size=10, bold=True)
350
351 # 重新设置说明区域
352 notes_start = insert_row2 + len(field_summary) + 1
353 ws2.merge_cells(f"A{notes_start}:C{notes_start}")
354 ws2.cell(row=notes_start, column=1, value="📋 处理方式说明:").font = Font(name="Microsoft YaHei", size=10, bold=True)
355
356 notes = [
357 "1. 🔴 人工处理:需业务/版权人员人工审核、恢复或补录源数据,系统仅辅助呈现和标记",
358 "2. 🟢 爬虫/自动:可通过爬虫补录歌手关系、重试转存失败、或重新触发导入流程自动完成",
359 "3. 🟡 扩展平台:当前爬虫仅覆盖 QQ/酷狗/网易云三平台,扩展覆盖范围后可自动导入",
360 "4. ⚪ 暂不处理:当前无法获取录音数据,等待平台扩展或外部数据源接入后再评估",
361 ]
362 for i, note in enumerate(notes):
363 cell = ws2.cell(row=notes_start + 1 + i, column=1, value=note)
364 cell.font = Font(name="Microsoft YaHei", size=9, color="FF666666")
365 ws2.merge_cells(f"A{notes_start + 1 + i}:C{notes_start + 1 + i}")
366
367 # ── Sheet 5: 已同步数据(records1)─────────────────────
368 ws5 = wb.create_sheet("已同步数据(records1)")
369
370 ws5.merge_cells("A1:G1")
371 c = ws5["A1"]
372 c.value = "港乐词曲资产导入 — 已同步数据总览(records1 主录音)"
373 c.font = title_font; c.fill = title_fill; c.alignment = title_align
374
375 # ── 5.1 平台分布与导入状态 ──
376 r = 3
377 ws5.cell(row=r, column=1, value="一、各平台导入状态").font = Font(name="Microsoft YaHei", size=10.5, bold=True)
378 r = 4
379 for i, h in enumerate(["平台", "总量", "已成功", "失败", "成功率"], 1):
380 cell = ws5.cell(row=r, column=i, value=h)
381 cell.font = header_font; cell.fill = header_fill
382 cell.alignment = header_align; cell.border = thin_border
383
384 plat_rows = [
385 ("QQ 音乐", "56,132", "56,068", "64", "99.89%"),
386 ("酷狗", "14,716", "14,698", "18", "99.88%"),
387 ("网易云", "15,338", "15,302", "36", "99.77%"),
388 ("合计", "86,186", "86,068", "118", "99.86%"),
389 ]
390 for i, (plat, total, ok, fail, rate) in enumerate(plat_rows, 5):
391 vals = [plat, total, ok, fail, rate]
392 for c_idx, v in enumerate(vals, 1):
393 cell = ws5.cell(row=i, column=c_idx, value=v)
394 cell.font = data_font if i < 8 else Font(name="Microsoft YaHei", size=10, bold=True)
395 cell.alignment = center_align
396 cell.border = thin_border
397 if i == 8:
398 cell.fill = PatternFill(start_color="FFD9E2F3", end_color="FFD9E2F3", fill_type="solid")
399
400 # ── 5.2 失败原因分布 ──
401 r = 10
402 ws5.cell(row=r, column=1, value="二、导入失败原因分布").font = Font(name="Microsoft YaHei", size=10.5, bold=True)
403 r = 11
404 for i, h in enumerate(["失败原因", "失败码", "QQ", "酷狗", "网易云", "合计", "占比"], 1):
405 cell = ws5.cell(row=r, column=i, value=h)
406 cell.font = header_font; cell.fill = header_fill
407 cell.alignment = header_align; cell.border = thin_border
408
409 fail_rows = [
410 ("spider DB 缺歌手关系", "-5", "48", "13", "35", "96", "81.4%"),
411 ("音频下载或 OSS 转存失败", "-6", "9", "1", "0", "10", "8.5%"),
412 ("歌词 OSS 转存失败", "-8", "7", "1", "1", "9", "7.6%"),
413 ("无可用歌词", "-2", "0", "3", "0", "3", "2.5%"),
414 ("合计", "", "64", "18", "36", "118", "100%"),
415 ]
416 for i, (reason, code, qq, kg, ne, total, pct) in enumerate(fail_rows, 12):
417 vals = [reason, code, qq, kg, ne, total, pct]
418 for c_idx, v in enumerate(vals, 1):
419 cell = ws5.cell(row=i, column=c_idx, value=v)
420 cell.font = data_font if i < 16 else Font(name="Microsoft YaHei", size=10, bold=True)
421 cell.alignment = center_align
422 cell.border = thin_border
423 if i == 16:
424 cell.fill = PatternFill(start_color="FFD9E2F3", end_color="FFD9E2F3", fill_type="solid")
425
426 # ── 5.3 已入库歌曲字段完整度 ──
427 r = 18
428 ws5.cell(row=r, column=1, value="三、已入库歌曲字段完整度(records1 严格范围,86,186 首)").font = Font(name="Microsoft YaHei", size=10.5, bold=True)
429 r = 19
430 for i, h in enumerate(["字段", "有值", "为空", "完整率", "补齐方式"], 1):
431 cell = ws5.cell(row=r, column=i, value=h)
432 cell.font = header_font; cell.fill = header_fill
433 cell.alignment = header_align; cell.border = thin_border
434
435 field_rows = [
436 ("歌名(name)", "86,186", "0", "100%", "—"),
437 ("音频链接(audio_url)", "86,186", "0", "100%", "—"),
438 ("歌词链接(lyrics_url)", "86,183", "3", "100%", "🟢 可忽略"),
439 ("歌手文本(singer)", "86,186", "0", "100%", "—"),
440 ("封面图片(cover_url)", "84,427", "1,759", "98.0%", "🟢 爬虫回补"),
441 ("曲作者(composer)", "84,702", "1,484", "98.3%", "🟢 从爬虫库批量回写"),
442 ("词作者(lyricist)", "82,955", "3,231", "96.3%", "🟢 从爬虫库批量回写"),
443 ("发行时间(issue_time)", "68,797", "17,389", "79.8%", "🟡 爬虫可尝试爬取"),
444 ("BPM 分类(bpm_class)", "35,169", "51,017", "40.8%", "🟡 需 BPM 分析工具计算"),
445 ]
446 for i, (field, has, empty, rate, method) in enumerate(field_rows, 20):
447 vals = [field, has, empty, rate, method]
448 for c_idx, v in enumerate(vals, 1):
449 cell = ws5.cell(row=i, column=c_idx, value=v)
450 cell.font = data_font
451 cell.alignment = center_align if c_idx >= 2 else data_align
452 cell.border = thin_border
453 if c_idx == 4 and rate != "—":
454 pct = float(rate.replace('%', ''))
455 if pct < 95:
456 cell.fill = FILL_YELLOW
457 elif pct < 99:
458 cell.fill = FILL_GREEN
459
460 ws5.column_dimensions["A"].width = 30
461 ws5.column_dimensions["B"].width = 12
462 ws5.column_dimensions["C"].width = 12
463 ws5.column_dimensions["D"].width = 12
464 ws5.column_dimensions["E"].width = 24
465 ws5.column_dimensions["F"].width = 10
466 ws5.column_dimensions["G"].width = 8
467
468 # 保存
469 out = "docs/港乐词曲资产数据缺口清单.xlsx"
470 wb.save(out)
471 print(f"✅ 已生成 {out}")
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
1 """
2 title_norm — 歌名归一化与版本提取算法。
3
4 archive-bridge `internal/subject/normalize.go` 的 Python 移植版本,
5 用于 title -> (title_norm, version_type) 的独立离线/跨语言复用场景。
6 移植时保持处理顺序与规则完全一致,覆盖以下能力:
7
8 1. NormalizeName : 姓名/文本归一化(全角转半角、繁转简、空白折叠、转小写)
9 2. NormalizeTitle : 歌名 -> (归一歌名, 版本枚举) 主算法
10 3. StripClipMarkers: 削去片段标题的切片/版本标记后缀
11 4. 其余辅助函数:SplitNames / NormalizeNameSet / NormalizeSet /
12 SingerIdentityKey / AuthorIdentityKey / OrgIdentityKey / PlatformFromSingerTable
13
14 不做拼音化、不做去标点。
15 """
16
17 from __future__ import annotations
18
19 import re
20 from typing import List, Optional, Tuple
21
22 try:
23 from opencc import OpenCC
24
25 _t2s = OpenCC("t2s")
26 except Exception: # pragma: no cover - opencc 加载失败时不影响其余归一逻辑
27 _t2s = None
28
29 __all__ = [
30 "normalize_name",
31 "normalize_title",
32 "strip_clip_markers",
33 "split_names",
34 "normalize_name_set",
35 "normalize_set",
36 "singer_identity_key",
37 "author_identity_key",
38 "org_identity_key",
39 "platform_from_singer_table",
40 "is_version_marker",
41 ]
42
43 # ---------------------------------------------------------------------------
44 # 全角转半角(对齐 golang.org/x/text/width.Fold)
45 # ---------------------------------------------------------------------------
46
47 _FULLWIDTH_SPACE = " "
48
49
50 def _fullwidth_to_halfwidth(s: str) -> str:
51 """全角转半角:全角空格 U+3000 -> ASCII 空格;U+FF01-FF5E -> ASCII。"""
52 out = []
53 for ch in s:
54 if ch == _FULLWIDTH_SPACE:
55 out.append(" ")
56 elif "!" <= ch <= "~":
57 out.append(chr(ord(ch) - 0xFEE0))
58 else:
59 out.append(ch)
60 return "".join(out)
61
62
63 _RE_SPACES = re.compile(r"\s+")
64
65
66 def normalize_name(s: str) -> str:
67 """归一化姓名/文本:去首尾空白 -> 全角转半角 -> 繁简转简体
68 -> 折叠连续空白为单空格 -> 转小写。不去标点、不拼音化。
69 """
70 if not s:
71 return ""
72 s = _fullwidth_to_halfwidth(s)
73 if _t2s is not None:
74 try:
75 s = _t2s.convert(s)
76 except Exception:
77 pass
78 s = _RE_SPACES.sub(" ", s)
79 s = s.strip()
80 s = s.lower()
81 return s
82
83
84 # ---------------------------------------------------------------------------
85 # 身份键辅助函数
86 # ---------------------------------------------------------------------------
87
88
89 def platform_from_singer_table(table: str) -> str:
90 """从 crawler 表名解析平台编码。
91
92 crawler_qqmusic_singers -> qqmusic
93 crawler_netease_songs -> netease
94 """
95 s = table
96 if s.startswith("crawler_"):
97 s = s[len("crawler_") :]
98 for suffix in ("_singers", "_songs"):
99 if s.endswith(suffix):
100 s = s[: -len(suffix)]
101 break
102 return s
103
104
105 def singer_identity_key(singer_table: str, singer_id: int) -> str:
106 """歌手身份键:平台:singer_id"""
107 platform = platform_from_singer_table(singer_table)
108 return f"{platform}:{singer_id}"
109
110
111 def author_identity_key(name: str) -> str:
112 """词曲作者身份键:name:归一名"""
113 return "name:" + normalize_name(name)
114
115
116 def org_identity_key(name: str) -> str:
117 """机构身份键:org:归一名"""
118 return "org:" + normalize_name(name)
119
120
121 _RE_SEP = re.compile(r"[,/、&&]")
122
123
124 def split_names(s: str) -> Optional[List[str]]:
125 """把逗号/斜杠/顿号/& 分隔的名字串拆成列表,去空白与空项。
126
127 split_names("周杰伦/方文山") -> ["周杰伦", "方文山"]
128 split_names("") -> None
129 """
130 if not s:
131 return None
132 parts = _RE_SEP.split(s)
133 result = [p.strip() for p in parts if p.strip()]
134 return result or None
135
136
137 def normalize_name_set(raw: str) -> Optional[List[str]]:
138 """把多值名字串切割 -> 逐个归一 -> 排序,返回顺序无关的规范集合。空串返回 None。"""
139 parts = split_names(raw)
140 if not parts:
141 return None
142 out = [n for n in (normalize_name(p) for p in parts) if n]
143 if not out:
144 return None
145 out.sort()
146 return out
147
148
149 def normalize_set(raw: Optional[List[str]]) -> Optional[List[str]]:
150 """把一组原始名字逐个归一并排序(顺序无关比对)。空返回 None。"""
151 if not raw:
152 return None
153 out = [n for n in (normalize_name(p) for p in raw) if n]
154 if not out:
155 return None
156 out.sort()
157 return out
158
159
160 # ---------------------------------------------------------------------------
161 # 版本识别词表
162 # ---------------------------------------------------------------------------
163
164 # versionAliases:版本别名 -> 标准枚举映射表。
165 # 标准枚举:""(原版)/live/remix/cover/accompaniment/instrumental
166 _VERSION_ALIASES = {
167 "live": "live",
168 "live版": "live",
169 "现场": "live",
170 "现场版": "live",
171 "remix": "remix",
172 "remix版": "remix",
173 "混音": "remix",
174 "混音版": "remix",
175 "翻唱": "cover",
176 "翻唱版": "cover",
177 "伴奏": "accompaniment",
178 "伴奏版": "accompaniment",
179 "纯音乐": "instrumental",
180 "纯音乐版": "instrumental",
181 }
182
183 # 按字符长度降序排列的版本后缀词,用于无括号版本后缀识别。
184 _VERSION_SUFFIX_WORDS = sorted(_VERSION_ALIASES.keys(), key=len, reverse=True)
185
186 # fixedVersionKeywords:不含"版"字但明确为版本的关键词集合(全部小写,查询前对输入 normalize_name)。
187 # 基于 66 万条真实歌曲版本数据整理。
188 _FIXED_VERSION_KEYWORDS_RAW = [
189 # 明确版本列表(业务确认)
190 "伴奏", "未知", "清晰版", "语言版", "恶搞版", "翻唱版",
191 "dj", "dj版", "dj长音频",
192 "live", "demo", "remix", "acoustic", "remaster",
193 "radio edit", "tv version", "lp版", "revival",
194 "clean", "explicit", "performance", "reprise",
195 "jam version", "cast version", "extended version",
196 "纯音乐", "unplugged", "口白", "铃声",
197 "mr", "试听版", "acappella", "arr", "performod",
198 "uk version", "ao vivo", "bonus track", "orchestra",
199 "其他",
200 # 演唱形式
201 "对唱", "合唱", "独唱", "清唱", "说唱", "cover",
202 # 性别/年龄
203 "女声", "男声", "童声", "女版", "男版",
204 # 语言
205 "粤语", "国语", "闽南语", "客家话",
206 "英文", "日文", "韩文", "cantonese", "mandarin",
207 # 乐器
208 "钢琴", "吉他", "古筝", "琵琶", "扬琴",
209 "二胡", "笛子", "萨克斯", "小提琴", "大提琴",
210 "piano", "guitar",
211 # 音乐风格
212 "摇滚", "民谣", "爵士", "布鲁斯",
213 "r&b", "rnb", "rock", "jazz", "blues",
214 "funk", "soul", "disco", "edm", "house",
215 "techno", "trap", "phonk", "dubstep",
216 "metal", "punk", "pop", "ballad",
217 # 片段/结构
218 "片段", "副歌", "前奏", "间奏", "尾奏", "主歌", "高潮",
219 # 音效/品质
220 "环绕", "混响", "立体声",
221 "3d", "8d", "hifi", "高品质", "无损",
222 # 制作类型
223 "edit", "extended", "original", "single",
224 "album", "ep", "deluxe",
225 "bootleg", "mashup", "tribute",
226 # 速度相关(文字)
227 "加速", "降速", "慢速", "快速",
228 "slowed", "sped", "speed",
229 # 其他
230 "inst", "instrumental", "karaoke",
231 "rehearsal", "studio",
232 ]
233 _FIXED_VERSION_KEYWORDS = {normalize_name(w) for w in _FIXED_VERSION_KEYWORDS_RAW}
234
235 # versionBlacklist:含"版"字但不当版本处理的词(归一后小写)。
236 _VERSION_BLACKLIST = {"完整版", "原版"}
237
238 # 速度模式:0.8x / 1.1X / ×0.93 / 0.8倍 / 1.2倍速
239 _RE_SPEED_X = re.compile(r"\d+\.?\d*[xX×]|[xX×]\d+\.?\d*")
240 _RE_SPEED_BEI = re.compile(r"\d+\.?\d*倍")
241
242 # 括号内容分类:0=非版本,1=版本,2=黑名单(剥离但不记版本)
243 _BRACKET_NOT_VERSION = 0
244 _BRACKET_VERSION = 1
245 _BRACKET_BLACKLIST = 2
246
247
248 def _classify_bracket(content: str) -> int:
249 content = content.strip()
250 if not content:
251 return _BRACKET_NOT_VERSION
252 norm = normalize_name(content)
253 if "版" in content:
254 if norm in _VERSION_BLACKLIST:
255 return _BRACKET_BLACKLIST
256 return _BRACKET_VERSION
257 if (
258 "version" in norm
259 or norm in _FIXED_VERSION_KEYWORDS
260 or _RE_SPEED_X.search(content)
261 or _RE_SPEED_BEI.search(content)
262 ):
263 return _BRACKET_VERSION
264 return _BRACKET_NOT_VERSION
265
266
267 def is_version_marker(content: str) -> bool:
268 """判定括号内/后缀内容是否为版本标记。
269
270 规则(content 应已去除首尾空白):
271 1. 含"版"字且不在黑名单 -> 版本
272 2. 归一后含 "version" 子串 -> 版本
273 3. 归一后在固定词表 -> 版本
274 4. 匹配速度模式 -> 版本
275 5. 否则 -> 非版本
276 """
277 return _classify_bracket(content) == _BRACKET_VERSION
278
279
280 # ---------------------------------------------------------------------------
281 # NormalizeTitle 主算法
282 # ---------------------------------------------------------------------------
283
284 # 歌名末尾成对括号(全/半角小括号、方括号、中文方括号)。
285 _RE_TITLE_VERSION = re.compile(r"^(.*?)[((\[【]([^((\[【]+)[))\]】]\s*$")
286
287 # 开头书名号《...》(》不必在末尾,如《七里香》(Live))。
288 _RE_BOOK_TITLE = re.compile(r"^《(.+?)》")
289
290 # 末尾空格+任意非空词,用于兜底识别任意尾词是否为版本标记。
291 _RE_TRAILING_WORD = re.compile(r"\s+(\S+)$")
292
293
294 def _strip_book_title_marks(s: str) -> str:
295 """剥离首部书名号《...》(》不必在末尾),循环直到无匹配。"""
296 while True:
297 m = _RE_BOOK_TITLE.match(s)
298 if m is None:
299 return s
300 s = m.group(1) + s[m.end() :]
301
302
303 def _dash_split(s: str, idx: int) -> Tuple[str, str]:
304 """在破折号分隔符 idx(字符位置)处切分,按版本感知返回。"""
305 head = s[:idx].strip()
306 suffix = s[idx + 1 :].strip()
307 if is_version_marker(suffix):
308 return head, suffix
309 return s, ""
310
311
312 def _strip_dash_suffix(s: str) -> Tuple[str, str]:
313 """全角/en dash/em dash 或半角 " - " 分隔符后缀的版本感知剥离。"""
314 for i, ch in enumerate(s):
315 if ch in "—–":
316 return _dash_split(s, i)
317 idx = s.find(" - ")
318 if idx >= 0:
319 head = s[:idx].strip()
320 suffix = s[idx + 3 :].strip()
321 if is_version_marker(suffix):
322 return head, suffix
323 return s, ""
324 return s, ""
325
326
327 def _strip_trailing_brackets(s: str) -> Tuple[str, str]:
328 """循环剥离末尾成对括号(版本感知三态):
329 - 版本:剥离并记 version(仅记首个)
330 - 黑名单:剥离但不记 version
331 - 非版本:停止剥离,整体保留
332 """
333 version = ""
334 while True:
335 m = _RE_TITLE_VERSION.match(s)
336 if m is None:
337 break
338 content = m.group(2).strip()
339 kind = _classify_bracket(content)
340 if kind == _BRACKET_NOT_VERSION:
341 return s, version
342 if kind == _BRACKET_VERSION:
343 if version == "":
344 version = content
345 # bracket_blacklist:剥离但不记 version
346 s = m.group(1).strip()
347 return s, version
348
349
350 def _strip_version_suffix(s: str) -> Tuple[str, str]:
351 """识别并剥离无括号版本后缀词(空格+版本词),返回剥离后的标题和版本词。
352 先查版本别名表(大小写不敏感),再用 is_version_marker 兜底任意尾词。
353 """
354 lower = s.lower()
355 for vw in _VERSION_SUFFIX_WORDS:
356 suffix = " " + vw
357 if lower.endswith(suffix):
358 return s[: len(s) - len(suffix)].strip(), s[len(s) - len(suffix) + 1 :]
359 m = _RE_TRAILING_WORD.search(s)
360 if m:
361 word = m.group(1)
362 if is_version_marker(word):
363 return s[: m.start()].strip(), word
364 return s, ""
365
366
367 def _normalize_version(v: str) -> str:
368 """对版本词做枚举归一:normalize_name 小写后查版本别名表映射到标准枚举。
369 查不到的版本词保留原文(不误归一)。
370 """
371 v = normalize_name(v)
372 return _VERSION_ALIASES.get(v, v)
373
374
375 def normalize_title(raw: str) -> Tuple[str, str]:
376 """拆分歌名与版本,处理顺序:
377
378 1. 书名号《》剥离
379 2. 破折号后缀版本感知剥离(版本进 version,非版本整体保留)
380 3. 多重括号循环剥离(首次括号内容为 version)
381 4. 无括号版本后缀词剥离
382 5. normalize_name(全角转半角+繁简转简体+折叠空白+小写)
383 6. version 枚举归一(查版本别名表映射到标准枚举)
384
385 返回 (title_norm, version)。
386 """
387 s = raw.strip()
388 s = _strip_book_title_marks(s)
389 version = ""
390
391 t, v = _strip_dash_suffix(s)
392 if v:
393 s = t
394 version = v
395
396 if version == "":
397 s, bv = _strip_trailing_brackets(s)
398 if bv:
399 version = bv
400 else:
401 s, _ = _strip_trailing_brackets(s)
402
403 t, v = _strip_version_suffix(s)
404 if v:
405 s = t
406 if version == "":
407 version = v
408
409 title_norm = normalize_name(s)
410 version = _normalize_version(version)
411 return title_norm, version
412
413
414 # ---------------------------------------------------------------------------
415 # StripClipMarkers(抖音片段标题削标记)
416 # ---------------------------------------------------------------------------
417
418 # 括号和破折号后缀里视为版本/切片标记的关键词(排除曲段词如主歌/副歌)。
419 _CLIP_BRACKET_KEYWORDS = [
420 "剪辑版", "剪辑一", "剪辑二", "剪辑三", "剪辑", "片段", "全曲",
421 "加速版", "加速", "完整版",
422 ]
423
424 # 无分隔符直接拼在末尾时视为标记的关键词(含曲段词)。
425 _CLIP_SUFFIX_KEYWORDS = [
426 "剪辑版", "剪辑一", "剪辑二", "剪辑三", "剪辑", "片段", "全曲", "主歌", "副歌",
427 "加速版", "加速", "完整版", "高潮", "前奏", "间奏", "尾奏",
428 ]
429
430 # 末尾成对括号(全/半角小括号)。
431 _RE_CLIP_BRACKET_TAIL = re.compile(r"^(.*?)[((]([^((]+)[))]\s*$")
432
433
434 def _contains_keywords(s: str, kws: List[str]) -> bool:
435 return any(kw in s for kw in kws)
436
437
438 def strip_clip_markers(raw: str) -> str:
439 """削去抖音片段标题里的切片/版本标记后缀,产出可与全曲匹配的名。
440
441 ① 末尾括号内容含标记关键词 -> 删整组;否则保留。
442 ② 破折号后缀(半/全角)含标记关键词 -> 删后缀。
443 ③ 无分隔符直接拼在末尾的标记 -> 削去。
444 """
445 s = raw.strip()
446
447 m = _RE_CLIP_BRACKET_TAIL.match(s)
448 if m is not None and _contains_keywords(m.group(2), _CLIP_BRACKET_KEYWORDS):
449 s = m.group(1).strip()
450
451 idx = -1
452 for i, ch in enumerate(s):
453 if ch in "-—–":
454 idx = i
455 if idx >= 0:
456 tail = s[idx + 1 :]
457 if _contains_keywords(tail, _CLIP_BRACKET_KEYWORDS):
458 s = s[:idx].strip()
459
460 for kw in _CLIP_SUFFIX_KEYWORDS:
461 if s.endswith(kw) and len(s) > len(kw):
462 s = s[: -len(kw)].strip()
463 break
464
465 return s
1 #!/usr/bin/env python3
2 """将采集库 crawler_*_singers.hot_songs 同步到档案库 archive_subject_account.hot_songs。
3
4 支持测试 / 正式环境,采集和档案同时切换。
5
6 数据链路:
7 测试: crawler_dev -> data_dev
8 正式: archive_crawler -> archive_data
9 ON archive_subject_account.crawler_singer_id = crawler_*_singers.id
10 UPDATE archive_subject_account.hot_songs
11
12 仅在 crawler_singer_id 能对应上时才更新,且只更新值有变化的行。
13 默认 dry-run,加 --apply 才会提交。
14
15 用法:
16 .venv/bin/python sync_singer_hot_songs.py # 测试环境 dry-run
17 .venv/bin/python sync_singer_hot_songs.py --apply # 测试环境执行
18 .venv/bin/python sync_singer_hot_songs.py --env prod # 正式环境 dry-run
19 .venv/bin/python sync_singer_hot_songs.py --env prod --apply # 正式环境执行
20 """
21
22 import argparse
23 import json
24 import logging
25 import sys
26
27 from etl_to_crawler.connections import (
28 close_all_pools,
29 get_test_crawler_conn,
30 get_test_archive_conn,
31 get_archive_crawler_conn,
32 get_archive_conn,
33 )
34
35 logging.basicConfig(
36 level=logging.INFO,
37 format="%(asctime)s %(levelname)s %(message)s",
38 )
39 log = logging.getLogger(__name__)
40
41 CRAWLER_SINGER_TABLES = [
42 "crawler_qqmusic_singers",
43 "crawler_kugou_singers",
44 "crawler_netease_singers",
45 ]
46
47 BATCH_SIZE = 500
48
49
50 def fetch_crawler_singer_hot_songs(pg_conn) -> list[dict]:
51 """从采集库读取所有有 hot_songs 的歌手记录。"""
52 all_rows: list[dict] = []
53 for table in CRAWLER_SINGER_TABLES:
54 sql = f"""
55 SELECT id, name, hot_songs
56 FROM {table}
57 WHERE hot_songs IS NOT NULL
58 """
59 with pg_conn.cursor() as cur:
60 cur.execute(sql)
61 rows = cur.fetchall()
62 for row in rows:
63 all_rows.append({
64 "id": str(row[0]), # 统一转 str,与档案库 varchar 类型对齐
65 "name": row[1],
66 "hot_songs": row[2],
67 "source_table": table,
68 })
69 log.info(" %s: %d 条有 hot_songs 的歌手", table, len(rows))
70 return all_rows
71
72
73 def fetch_archive_current_hot_songs(archive_conn, crawler_ids: set) -> dict:
74 """查询档案库中匹配记录的当前 hot_songs 值。
75
76 返回的 dict 的 key 即为已匹配的 crawler_singer_id,
77 未出现在 dict 中的 ID 表示档案库中无对应记录。
78 """
79 if not crawler_ids:
80 return {}
81 current: dict = {}
82 id_list = list(crawler_ids)
83 for i in range(0, len(id_list), BATCH_SIZE):
84 batch = id_list[i : i + BATCH_SIZE]
85 sql = """
86 SELECT crawler_singer_id, hot_songs
87 FROM archive_subject_account
88 WHERE crawler_singer_id = ANY(%s)
89 """
90 with archive_conn.cursor() as cur:
91 cur.execute(sql, (batch,))
92 for row in cur.fetchall():
93 current[row[0]] = row[1]
94 return current
95
96
97 def batch_update_archive(archive_conn, updates: list[tuple], apply: bool) -> int:
98 """批量更新 archive_subject_account.hot_songs。"""
99 if not updates:
100 return 0
101 total = 0
102 for i in range(0, len(updates), BATCH_SIZE):
103 batch = updates[i : i + BATCH_SIZE]
104 values_sql_parts = []
105 params: list = []
106 for idx, (crawler_singer_id, hot_songs) in enumerate(batch):
107 values_sql_parts.append(f"(%s, %s::jsonb)")
108 params.extend([crawler_singer_id, json.dumps(hot_songs, ensure_ascii=False)])
109 values_sql = ", ".join(values_sql_parts)
110 sql = f"""
111 UPDATE archive_subject_account AS a
112 SET hot_songs = v.hot_songs
113 FROM (VALUES {values_sql}) AS v(crawler_singer_id, hot_songs)
114 WHERE a.crawler_singer_id = v.crawler_singer_id
115 """
116 with archive_conn.cursor() as cur:
117 cur.execute(sql, params)
118 total += len(batch)
119 log.info(" 已处理 %d / %d", total, len(updates))
120 if apply:
121 archive_conn.commit()
122 return total
123
124
125 def _json_equal(a, b) -> bool:
126 """比较两个 JSON 值是否逻辑相等。"""
127 if a is None and b is None:
128 return True
129 if a is None or b is None:
130 return False
131 # 统一用 Python 对象比较(jsonb 反序列化后 key 序无关)
132 if isinstance(a, str):
133 a = json.loads(a)
134 if isinstance(b, str):
135 b = json.loads(b)
136 return a == b
137
138
139 def parse_args() -> argparse.Namespace:
140 parser = argparse.ArgumentParser(
141 description="同步采集库 crawler_*_singers.hot_songs 到档案库 archive_subject_account"
142 )
143 parser.add_argument(
144 "--env",
145 choices=["test", "prod"],
146 default="test",
147 help="运行环境: test=测试(crawler_dev+data_dev), prod=正式(archive_crawler+archive_data)(默认 test)",
148 )
149 parser.add_argument(
150 "--apply",
151 action="store_true",
152 help="实际执行更新(默认 dry-run 只预览不写库)",
153 )
154 return parser.parse_args()
155
156
157 def main() -> None:
158 args = parse_args()
159 mode = "APPLY" if args.apply else "DRY-RUN"
160 env_label = "正式环境" if args.env == "prod" else "测试环境"
161
162 # 根据环境选择连接函数
163 if args.env == "prod":
164 get_crawler_conn = get_archive_crawler_conn # 正式采集库: archive_crawler
165 get_archive_conn_fn = get_archive_conn # 正式档案库: archive_data
166 crawler_db = "archive_crawler"
167 archive_db = "archive_data"
168 else:
169 get_crawler_conn = get_test_crawler_conn # 测试采集库: crawler_dev
170 get_archive_conn_fn = get_test_archive_conn # 测试档案库: data_dev
171 crawler_db = "crawler_dev"
172 archive_db = "data_dev"
173
174 log.info("=== sync_singer_hot_songs [%s] [%s] ===", env_label, mode)
175 log.info("采集库: %s | 档案库: %s", crawler_db, archive_db)
176
177 # 1. 从采集库读取歌手 hot_songs
178 log.info("步骤 1: 读取采集库 crawler_*_singers.hot_songs (%s)", crawler_db)
179 crawler_conn = get_crawler_conn()
180 crawler_rows = fetch_crawler_singer_hot_songs(crawler_conn)
181 if not crawler_rows:
182 log.info("采集库无任何 hot_songs 数据,退出")
183 return
184 log.info("共读取 %d 条歌手记录", len(crawler_rows))
185
186 # 2+3. 一次查询档案库:匹配 + 获取当前值 + 比对变化
187 crawler_ids = {row["id"] for row in crawler_rows}
188 log.info("步骤 2: 查询档案库并比对变化 (%s,共 %d 个候选)", archive_db, len(crawler_ids))
189 archive_conn = get_archive_conn_fn()
190 current_hot_songs = fetch_archive_current_hot_songs(archive_conn, crawler_ids)
191 matched_ids = set(current_hot_songs.keys()) # dict 的 key 即为匹配到的 ID
192 log.info("匹配到 %d 个 crawler_singer_id", len(matched_ids))
193
194 if not matched_ids:
195 log.info("无可匹配的记录,退出")
196 return
197
198 updates: list[tuple] = [] # (crawler_singer_id, new_hot_songs)
199 changes: list[dict] = [] # 变更明细
200 updated_ids: set = set() # 已产生变更的 ID(去重)
201 for row in crawler_rows:
202 sid = row["id"]
203 if sid not in matched_ids or sid in updated_ids:
204 continue
205 new_val = row["hot_songs"]
206 old_val = current_hot_songs.get(sid)
207 if not _json_equal(old_val, new_val):
208 updated_ids.add(sid)
209 updates.append((sid, new_val))
210 changes.append({
211 "crawler_singer_id": sid,
212 "name": row["name"],
213 "source_table": row["source_table"],
214 "old_hot_songs": old_val,
215 "new_hot_songs": new_val,
216 })
217
218 skipped = len(matched_ids) - len(updated_ids)
219 log.info("需更新: %d 条,无变化跳过: %d 条,无法匹配跳过: %d 条",
220 len(updates), skipped, len(crawler_ids) - len(matched_ids))
221
222 if not updates:
223 print("\n" + "=" * 80)
224 print("统计摘要")
225 print("=" * 80)
226 print(f" 环境 : {env_label}")
227 print(f" 采集库 : {crawler_db}")
228 print(f" 档案库 : {archive_db}")
229 print(f" 采集库歌手总数 : {len(crawler_rows)}")
230 print(f" 匹配档案数 : {len(matched_ids)}")
231 print(f" 需更新 : 0")
232 print(f" 无变化跳过 : {len(matched_ids)}")
233 print(f" 无法匹配跳过 : {len(crawler_ids) - len(matched_ids)}")
234 print("=" * 80)
235 log.info("无变化需要更新,退出")
236 return
237
238 # 4. 执行更新
239 if args.apply:
240 log.info("步骤 4: 执行更新 (--apply)")
241 else:
242 log.info("步骤 4: DRY-RUN 预览(加 --apply 实际执行)")
243
244 if args.apply:
245 batch_update_archive(archive_conn, updates, apply=True)
246 log.info("更新完成,已提交")
247 else:
248 log.info("DRY-RUN 模式,未写库")
249
250 # 5. 输出变更明细
251 print("\n" + "=" * 80)
252 print(f"变更明细(共 {len(changes)} 条)")
253 print("=" * 80)
254 for c in changes:
255 print(f"\n crawler_singer_id : {c['crawler_singer_id']}")
256 print(f" name : {c['name']}")
257 print(f" source_table : {c['source_table']}")
258 old_display = json.dumps(c["old_hot_songs"], ensure_ascii=False, indent=2) if c["old_hot_songs"] else "NULL"
259 new_display = json.dumps(c["new_hot_songs"], ensure_ascii=False, indent=2) if c["new_hot_songs"] else "NULL"
260 print(f" old_hot_songs : {old_display}")
261 print(f" new_hot_songs : {new_display}")
262
263 # 6. 统计摘要
264 print("\n" + "=" * 80)
265 print("统计摘要")
266 print("=" * 80)
267 print(f" 环境 : {env_label}")
268 print(f" 采集库 : {crawler_db}")
269 print(f" 档案库 : {archive_db}")
270 print(f" 采集库歌手总数 : {len(crawler_rows)}")
271 print(f" 匹配档案数 : {len(matched_ids)}")
272 print(f" 需更新 : {len(updates)}")
273 print(f" 无变化跳过 : {skipped}")
274 print(f" 无法匹配跳过 : {len(crawler_ids) - len(matched_ids)}")
275 print(f" 执行模式 : {'已提交 (--apply)' if args.apply else 'DRY-RUN(未写库)'}")
276 print("=" * 80)
277
278
279 if __name__ == "__main__":
280 try:
281 main()
282 finally:
283 close_all_pools()
...@@ -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},
......