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 的多表回滚机制,保证数据一致性
Showing
14 changed files
with
2653 additions
and
113 deletions
| ... | @@ -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) | ... | ... |
fix_record_titles.py
0 → 100644
| 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 | ... | ... |
gen_gap_xlsx.py
0 → 100644
| 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}") |
python/title-norm/tests/test_title_norm.py
0 → 100644
| 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 |
python/title-norm/title_norm/__init__.py
0 → 100644
| 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 |
sync_singer_hot_songs.py
0 → 100644
| 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 | ... | ... |
test_fix_record_titles.py
0 → 100644
| 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}, | ... | ... |
-
Please register or sign in to post a comment