Commit bf8d1723 bf8d172363896b6811dcf22f5729187e27ff6398 by 沈秋雨

chore(config): 支持切换音眼导入状态表以兼容 records2

- 在配置中添加 YINYAN_IMPORT_TABLE 环境变量,支持 records 和 records2 两张表
- 增加对 YINYAN_IMPORT_TABLE 合法值的校验,防止配置错误
- 修改写入和查询相关函数以适配动态表名
- 优化 upsert 逻辑,针对 records2 版本避免多条同 song_id 记录一同更新
- run_etl.py 中打印日志增加当前使用的导入表信息
- writer.py、runner.py 调整查询及更新逻辑以支持动态导入表切换
- oss.py 新增下载歌词文本函数,确保即使目标 OSS 包含歌词文件也重新下载内容
- runner.py 优化歌词上传流程,优先转存音眼歌词 URL,失败后回退爬虫端歌词文本
- 增加多处歌词和封面判断逻辑,支持基于音眼歌词 URL 判断歌词可用性
- 添加单元测试覆盖眼眼歌词转存优先逻辑及导入状态表切换功能验证
1 parent bbcdcae8
...@@ -13,6 +13,9 @@ TARGET_DB_PASSWORD= ...@@ -13,6 +13,9 @@ TARGET_DB_PASSWORD=
13 TARGET_DB_NAME= 13 TARGET_DB_NAME=
14 TARGET_TABLE_NAME=hk_songs 14 TARGET_TABLE_NAME=hk_songs
15 15
16 # run_etl.py 默认读取并回写的导入状态表:yinyan_song_records 或 yinyan_song_records2
17 YINYAN_IMPORT_TABLE=yinyan_song_records
18
16 # OSS 配置 19 # OSS 配置
17 OSS_ACCESS_KEY_ID= 20 OSS_ACCESS_KEY_ID=
18 OSS_ACCESS_KEY_SECRET= 21 OSS_ACCESS_KEY_SECRET=
......
...@@ -48,6 +48,14 @@ PLATFORM_KUGOU = '2' ...@@ -48,6 +48,14 @@ PLATFORM_KUGOU = '2'
48 PLATFORM_NETEASE = '4' 48 PLATFORM_NETEASE = '4'
49 PLATFORMS = [PLATFORM_QQ, PLATFORM_KUGOU, PLATFORM_NETEASE] 49 PLATFORMS = [PLATFORM_QQ, PLATFORM_KUGOU, PLATFORM_NETEASE]
50 50
51 YINYAN_IMPORT_TABLE = os.environ.get('YINYAN_IMPORT_TABLE', 'yinyan_song_records').strip()
52 _ALLOWED_YINYAN_IMPORT_TABLES = {'yinyan_song_records', 'yinyan_song_records2'}
53 if YINYAN_IMPORT_TABLE not in _ALLOWED_YINYAN_IMPORT_TABLES:
54 raise ValueError(
55 f"YINYAN_IMPORT_TABLE must be one of {sorted(_ALLOWED_YINYAN_IMPORT_TABLES)}, "
56 f"got {YINYAN_IMPORT_TABLE!r}"
57 )
58
51 BATCH_SIZE = 1000 59 BATCH_SIZE = 1000
52 BACKFILL_BATCH_SIZE = int(os.environ.get('BACKFILL_BATCH_SIZE', '5000')) 60 BACKFILL_BATCH_SIZE = int(os.environ.get('BACKFILL_BATCH_SIZE', '5000'))
53 HTTP_POOL_MAXSIZE = int(os.environ.get('HTTP_POOL_MAXSIZE', '128')) 61 HTTP_POOL_MAXSIZE = int(os.environ.get('HTTP_POOL_MAXSIZE', '128'))
......
...@@ -85,6 +85,15 @@ def transfer_url(url: str | None, oss_key: str, bucket: oss2.Bucket, base_url: s ...@@ -85,6 +85,15 @@ def transfer_url(url: str | None, oss_key: str, bucket: oss2.Bucket, base_url: s
85 return f"{base_url.rstrip('/')}/{oss_key}" 85 return f"{base_url.rstrip('/')}/{oss_key}"
86 86
87 87
88 def download_text_url(url: str | None, base_url: str) -> str:
89 """下载歌词等文本 URL;即使源文件已在目标 OSS,也实际读取其内容。"""
90 url = _clean_url(url)
91 if not url:
92 return ''
93 content = _download_content(url, base_url)
94 return content.decode('utf-8-sig')
95
96
88 def transfer_url_with_md5(url: str | None, oss_key: str, bucket: oss2.Bucket, base_url: str) -> tuple[str, str]: 97 def transfer_url_with_md5(url: str | None, oss_key: str, bucket: oss2.Bucket, base_url: str) -> tuple[str, str]:
89 """ 98 """
90 将音频 URL 转存到 OSS,并基于下载到的音频字节计算 MD5。 99 将音频 URL 转存到 OSS,并基于下载到的音频字节计算 MD5。
......
...@@ -43,8 +43,13 @@ from .writer import ( ...@@ -43,8 +43,13 @@ from .writer import (
43 update_qq_song_singers, update_kugou_song_singers, update_netease_song_singers, 43 update_qq_song_singers, update_kugou_song_singers, update_netease_song_singers,
44 fetch_qq_songs_with_invalid_covers, replace_qq_song, replace_yinyan_song_record, 44 fetch_qq_songs_with_invalid_covers, replace_qq_song, replace_yinyan_song_record,
45 delete_qq_song_and_yinyan_record, mark_yinyan_record_resource_failed, 45 delete_qq_song_and_yinyan_record, mark_yinyan_record_resource_failed,
46 YINYAN_IMPORT_FAILURE_UNKNOWN, YINYAN_IMPORT_FAILURE_NO_LYRIC,
47 YINYAN_IMPORT_FAILURE_NO_COVER, YINYAN_IMPORT_FAILURE_NO_LYRIC_AND_COVER,
48 YINYAN_IMPORT_FAILURE_SOURCE_DATA_MISSING, YINYAN_IMPORT_FAILURE_AUDIO_TRANSFER,
49 YINYAN_IMPORT_FAILURE_COVER_TRANSFER, YINYAN_IMPORT_FAILURE_LYRIC_TRANSFER,
50 YINYAN_IMPORT_FAILURE_MULTIPLE_ASSET_TRANSFER, YINYAN_IMPORT_FAILURE_UNEXPECTED,
46 ) 51 )
47 from .oss import transfer_url, transfer_url_with_md5, build_oss_key 52 from .oss import transfer_url, transfer_url_with_md5, download_text_url, build_oss_key
48 from .utils import split_title_version, upload_plain_lyric_to_bucket 53 from .utils import split_title_version, upload_plain_lyric_to_bucket
49 from .lyric import ensure_newlines 54 from .lyric import ensure_newlines
50 55
...@@ -59,6 +64,10 @@ QQ_MISSING_ALBUM_COVER = 'http://y.gtimg.cn/music/photo_new/T002R300x300M000.jpg ...@@ -59,6 +64,10 @@ QQ_MISSING_ALBUM_COVER = 'http://y.gtimg.cn/music/photo_new/T002R300x300M000.jpg
59 class RequiredAssetTransferError(RuntimeError): 64 class RequiredAssetTransferError(RuntimeError):
60 """音频、封面或歌词未成功落到目标 OSS,当前歌曲不得入库。""" 65 """音频、封面或歌词未成功落到目标 OSS,当前歌曲不得入库。"""
61 66
67 def __init__(self, missing: list[str]):
68 self.missing = tuple(missing)
69 super().__init__(f"required asset transfer failed: {', '.join(missing)}")
70
62 71
63 def _run_io_tasks(tasks: dict) -> dict: 72 def _run_io_tasks(tasks: dict) -> dict:
64 if not tasks: 73 if not tasks:
...@@ -88,13 +97,25 @@ def _safe_transfer_audio(url, oss_key, bucket, base_url) -> tuple[str, str]: ...@@ -88,13 +97,25 @@ def _safe_transfer_audio(url, oss_key, bucket, base_url) -> tuple[str, str]:
88 return '', '' # 不将未转存的音频外链写入 crawler 97 return '', '' # 不将未转存的音频外链写入 crawler
89 98
90 99
91 def _safe_upload_lyric(platform: str, unique_id: str, lyric: str | None, fallback_url: str | None, bucket, base_url: str) -> str: 100 def _safe_upload_lyric(
101 platform: str,
102 unique_id: str,
103 spider_lyric: str | None,
104 hk_lyrics_url: str | None,
105 bucket,
106 base_url: str,
107 ) -> str:
108 """优先将音眼歌词 URL 转存到 crawler OSS;spider 歌词文本仅作回退。"""
109 if str(hk_lyrics_url or '').strip():
92 try: 110 try:
93 uploaded_url = upload_plain_lyric_to_bucket(platform, unique_id, lyric or '', bucket, base_url) 111 hk_lyric = download_text_url(hk_lyrics_url, base_url)
112 uploaded_url = upload_plain_lyric_to_bucket(platform, unique_id, hk_lyric, bucket, base_url)
94 if uploaded_url: 113 if uploaded_url:
95 return uploaded_url 114 return uploaded_url
96 fallback_url = str(fallback_url or '') 115 except Exception as e:
97 return fallback_url if fallback_url.startswith(base_url.rstrip('/') + '/') else '' 116 log.warning('HK lyric transfer failed for %s/%s: %s', platform, unique_id, e)
117 try:
118 return upload_plain_lyric_to_bucket(platform, unique_id, spider_lyric or '', bucket, base_url)
98 except Exception as e: 119 except Exception as e:
99 log.warning("Lyric upload failed for %s/%s: %s", platform, unique_id, e) 120 log.warning("Lyric upload failed for %s/%s: %s", platform, unique_id, e)
100 return '' 121 return ''
...@@ -109,7 +130,31 @@ def _require_primary_assets(assets: dict) -> None: ...@@ -109,7 +130,31 @@ def _require_primary_assets(assets: dict) -> None:
109 ) if not value 130 ) if not value
110 ] 131 ]
111 if missing: 132 if missing:
112 raise RequiredAssetTransferError(f"required asset transfer failed: {', '.join(missing)}") 133 raise RequiredAssetTransferError(missing)
134
135
136 def _selection_failure_code(reason: str) -> int:
137 if 'lyric' in reason and 'cover' in reason:
138 return YINYAN_IMPORT_FAILURE_NO_LYRIC_AND_COVER
139 if 'lyric' in reason:
140 return YINYAN_IMPORT_FAILURE_NO_LYRIC
141 if 'cover' in reason:
142 return YINYAN_IMPORT_FAILURE_NO_COVER
143 return YINYAN_IMPORT_FAILURE_SOURCE_DATA_MISSING
144
145
146 def _asset_failure_code(missing: tuple[str, ...]) -> int:
147 if len(missing) != 1:
148 return YINYAN_IMPORT_FAILURE_MULTIPLE_ASSET_TRANSFER
149 return {
150 'audio': YINYAN_IMPORT_FAILURE_AUDIO_TRANSFER,
151 'cover': YINYAN_IMPORT_FAILURE_COVER_TRANSFER,
152 'lyric': YINYAN_IMPORT_FAILURE_LYRIC_TRANSFER,
153 }.get(missing[0], YINYAN_IMPORT_FAILURE_UNKNOWN)
154
155
156 def _failed_pending(pending: dict, failure_code: int) -> dict:
157 return {**pending, 'failure_code': failure_code}
113 158
114 159
115 def _json_default(value): 160 def _json_default(value):
...@@ -130,8 +175,12 @@ def _qq_cover_source(hk_row: dict, song_data: dict) -> str: ...@@ -130,8 +175,12 @@ def _qq_cover_source(hk_row: dict, song_data: dict) -> str:
130 return hk_cover if _is_usable_qq_cover(hk_cover) else (song_data.get('cover') or '') 175 return hk_cover if _is_usable_qq_cover(hk_cover) else (song_data.get('cover') or '')
131 176
132 177
133 def _has_usable_qq_lyric(song_data: dict) -> bool: 178 def _has_usable_hk_lyric(hk_row: dict) -> bool:
134 return bool(str(song_data.get('lyric') or '').strip()) 179 return bool(str(hk_row.get('lyrics_url') or '').strip())
180
181
182 def _has_usable_qq_lyric(song_data: dict, hk_row: dict) -> bool:
183 return _has_usable_hk_lyric(hk_row) or bool(str(song_data.get('lyric') or '').strip())
135 184
136 185
137 def _has_kugou_cover(hk_row: dict, song_data: dict) -> bool: 186 def _has_kugou_cover(hk_row: dict, song_data: dict) -> bool:
...@@ -139,8 +188,8 @@ def _has_kugou_cover(hk_row: dict, song_data: dict) -> bool: ...@@ -139,8 +188,8 @@ def _has_kugou_cover(hk_row: dict, song_data: dict) -> bool:
139 return bool((hk_row.get('cover_url') or '').strip() or (song_data.get('cover') or '').strip()) 188 return bool((hk_row.get('cover_url') or '').strip() or (song_data.get('cover') or '').strip())
140 189
141 190
142 def _has_kugou_lyric(song_data: dict) -> bool: 191 def _has_kugou_lyric(song_data: dict, hk_row: dict) -> bool:
143 return bool(str(song_data.get('lyric') or '').strip()) 192 return _has_usable_hk_lyric(hk_row) or bool(str(song_data.get('lyric') or '').strip())
144 193
145 194
146 def _has_netease_cover(hk_row: dict, song_data: dict) -> bool: 195 def _has_netease_cover(hk_row: dict, song_data: dict) -> bool:
...@@ -148,8 +197,8 @@ def _has_netease_cover(hk_row: dict, song_data: dict) -> bool: ...@@ -148,8 +197,8 @@ def _has_netease_cover(hk_row: dict, song_data: dict) -> bool:
148 return bool((hk_row.get('cover_url') or '').strip() or (song_data.get('cover') or '').strip()) 197 return bool((hk_row.get('cover_url') or '').strip() or (song_data.get('cover') or '').strip())
149 198
150 199
151 def _has_netease_lyric(song_data: dict) -> bool: 200 def _has_netease_lyric(song_data: dict, hk_row: dict) -> bool:
152 return bool(str(song_data.get('lyric') or '').strip()) 201 return _has_usable_hk_lyric(hk_row) or bool(str(song_data.get('lyric') or '').strip())
153 202
154 203
155 def _select_qq_import_record( 204 def _select_qq_import_record(
...@@ -163,7 +212,7 @@ def _select_qq_import_record( ...@@ -163,7 +212,7 @@ def _select_qq_import_record(
163 current_song = songs_by_mid.get(current_record['platform_unique_key']) 212 current_song = songs_by_mid.get(current_record['platform_unique_key'])
164 if not current_song: 213 if not current_song:
165 return None, 'spider data missing' 214 return None, 'spider data missing'
166 needs_lyric = not _has_usable_qq_lyric(current_song) 215 needs_lyric = not _has_usable_qq_lyric(current_song, hk_row)
167 needs_cover = not _is_usable_qq_cover(_qq_cover_source(hk_row, current_song)) or not current_song.get('album_id') 216 needs_cover = not _is_usable_qq_cover(_qq_cover_source(hk_row, current_song)) or not current_song.get('album_id')
168 if not needs_lyric and not needs_cover: 217 if not needs_lyric and not needs_cover:
169 return current_record, 'ok' 218 return current_record, 'ok'
...@@ -172,7 +221,7 @@ def _select_qq_import_record( ...@@ -172,7 +221,7 @@ def _select_qq_import_record(
172 song = songs_by_mid.get(candidate['platform_unique_key']) 221 song = songs_by_mid.get(candidate['platform_unique_key'])
173 if not song: 222 if not song:
174 continue 223 continue
175 if needs_lyric and not _has_usable_qq_lyric(song): 224 if needs_lyric and not _has_usable_qq_lyric(song, hk_row):
176 continue 225 continue
177 if needs_cover and (not song.get('album_id') or not _is_usable_qq_cover(song.get('cover'))): 226 if needs_cover and (not song.get('album_id') or not _is_usable_qq_cover(song.get('cover'))):
178 continue 227 continue
...@@ -197,7 +246,7 @@ def _select_kugou_import_record( ...@@ -197,7 +246,7 @@ def _select_kugou_import_record(
197 current_song = songs_by_id.get(song_id) 246 current_song = songs_by_id.get(song_id)
198 if not current_song: 247 if not current_song:
199 return None, 'spider data missing' 248 return None, 'spider data missing'
200 needs_lyric = not _has_kugou_lyric(current_song) 249 needs_lyric = not _has_kugou_lyric(current_song, hk_row)
201 needs_cover = not _has_kugou_cover(hk_row, current_song) 250 needs_cover = not _has_kugou_cover(hk_row, current_song)
202 if not needs_lyric and not needs_cover: 251 if not needs_lyric and not needs_cover:
203 return current_record, 'ok' 252 return current_record, 'ok'
...@@ -207,7 +256,7 @@ def _select_kugou_import_record( ...@@ -207,7 +256,7 @@ def _select_kugou_import_record(
207 song = songs_by_id.get(cid) 256 song = songs_by_id.get(cid)
208 if not song: 257 if not song:
209 continue 258 continue
210 if needs_lyric and not _has_kugou_lyric(song): 259 if needs_lyric and not _has_kugou_lyric(song, hk_row):
211 continue 260 continue
212 if needs_cover and not _has_kugou_cover(hk_row, song): 261 if needs_cover and not _has_kugou_cover(hk_row, song):
213 continue 262 continue
...@@ -232,7 +281,7 @@ def _select_netease_import_record( ...@@ -232,7 +281,7 @@ def _select_netease_import_record(
232 current_song = songs_by_id.get(song_id) 281 current_song = songs_by_id.get(song_id)
233 if not current_song: 282 if not current_song:
234 return None, 'spider data missing' 283 return None, 'spider data missing'
235 needs_lyric = not _has_netease_lyric(current_song) 284 needs_lyric = not _has_netease_lyric(current_song, hk_row)
236 needs_cover = not _has_netease_cover(hk_row, current_song) 285 needs_cover = not _has_netease_cover(hk_row, current_song)
237 if not needs_lyric and not needs_cover: 286 if not needs_lyric and not needs_cover:
238 return current_record, 'ok' 287 return current_record, 'ok'
...@@ -242,7 +291,7 @@ def _select_netease_import_record( ...@@ -242,7 +291,7 @@ def _select_netease_import_record(
242 song = songs_by_id.get(cid) 291 song = songs_by_id.get(cid)
243 if not song: 292 if not song:
244 continue 293 continue
245 if needs_lyric and not _has_netease_lyric(song): 294 if needs_lyric and not _has_netease_lyric(song, hk_row):
246 continue 295 continue
247 if needs_cover and not _has_netease_cover(hk_row, song): 296 if needs_cover and not _has_netease_cover(hk_row, song):
248 continue 297 continue
...@@ -653,6 +702,7 @@ def _prepare_import_payload( ...@@ -653,6 +702,7 @@ def _prepare_import_payload(
653 payload = preparer(hk_row, pr, bucket, base_url, song_data, singer_list) 702 payload = preparer(hk_row, pr, bucket, base_url, song_data, singer_list)
654 payload['yinyan_record'] = { 703 payload['yinyan_record'] = {
655 'song_id': int(pending['song_id']), 704 'song_id': int(pending['song_id']),
705 'source_record_id': int(pending['record_id']),
656 'record_id': int(pr['record_id']), 706 'record_id': int(pr['record_id']),
657 'platform': platform, 707 'platform': platform,
658 'platform_song_id': int(payload['platform_song_id']), 708 'platform_song_id': int(payload['platform_song_id']),
...@@ -1800,7 +1850,11 @@ def run( ...@@ -1800,7 +1850,11 @@ def run(
1800 src_id = int(pending['song_id']) 1850 src_id = int(pending['song_id'])
1801 platform = str(pending['platform']) 1851 platform = str(pending['platform'])
1802 hk_row = hk_by_song.get(src_id) 1852 hk_row = hk_by_song.get(src_id)
1803 if not hk_row or platform not in platforms: 1853 if platform not in platforms:
1854 continue
1855 if not hk_row:
1856 log.warning('Pending yinyan record has no usable hk_songs row: song_id=%s', src_id)
1857 rejected_pending.append(_failed_pending(pending, YINYAN_IMPORT_FAILURE_SOURCE_DATA_MISSING))
1804 continue 1858 continue
1805 pr = pr_by_state_key.get((src_id, int(pending['record_id']), platform)) 1859 pr = pr_by_state_key.get((src_id, int(pending['record_id']), platform))
1806 if not pr: 1860 if not pr:
...@@ -1808,6 +1862,7 @@ def run( ...@@ -1808,6 +1862,7 @@ def run(
1808 "Pending yinyan record not found in source records: song_id=%s record_id=%s platform=%s", 1862 "Pending yinyan record not found in source records: song_id=%s record_id=%s platform=%s",
1809 src_id, pending['record_id'], platform, 1863 src_id, pending['record_id'], platform,
1810 ) 1864 )
1865 rejected_pending.append(_failed_pending(pending, YINYAN_IMPORT_FAILURE_SOURCE_DATA_MISSING))
1811 continue 1866 continue
1812 if platform == PLATFORM_QQ: 1867 if platform == PLATFORM_QQ:
1813 selected, reason = _select_qq_import_record( 1868 selected, reason = _select_qq_import_record(
...@@ -1828,7 +1883,7 @@ def run( ...@@ -1828,7 +1883,7 @@ def run(
1828 'Skip import (%s): platform=%s source_song_id=%s record_id=%s', 1883 'Skip import (%s): platform=%s source_song_id=%s record_id=%s',
1829 reason, platform, src_id, pending['record_id'], 1884 reason, platform, src_id, pending['record_id'],
1830 ) 1885 )
1831 rejected_pending.append(pending) 1886 rejected_pending.append(_failed_pending(pending, _selection_failure_code(reason)))
1832 continue 1887 continue
1833 if selected['record_id'] != pr['record_id']: 1888 if selected['record_id'] != pr['record_id']:
1834 log.info( 1889 log.info(
...@@ -1859,21 +1914,23 @@ def run( ...@@ -1859,21 +1914,23 @@ def run(
1859 total_ok += 1 1914 total_ok += 1
1860 else: 1915 else:
1861 total_err += 1 1916 total_err += 1
1862 asset_failed_pending.append(pending) 1917 asset_failed_pending.append(
1918 _failed_pending(pending, YINYAN_IMPORT_FAILURE_SOURCE_DATA_MISSING)
1919 )
1863 log.warning( 1920 log.warning(
1864 "Skip song because payload is empty: %s platform %s song_id=%s", 1921 "Skip song because payload is empty: %s platform %s song_id=%s",
1865 hk_row.get('name'), pr['platform'], pending['song_id'], 1922 hk_row.get('name'), pr['platform'], pending['song_id'],
1866 ) 1923 )
1867 except RequiredAssetTransferError as e: 1924 except RequiredAssetTransferError as e:
1868 total_err += 1 1925 total_err += 1
1869 asset_failed_pending.append(pending) 1926 asset_failed_pending.append(_failed_pending(pending, _asset_failure_code(e.missing)))
1870 log.warning( 1927 log.warning(
1871 "Skip song because required asset transfer failed: %s platform %s: %s", 1928 "Skip song because required asset transfer failed: %s platform %s: %s",
1872 hk_row.get('name'), pr['platform'], e, 1929 hk_row.get('name'), pr['platform'], e,
1873 ) 1930 )
1874 except Exception as e: 1931 except Exception as e:
1875 total_err += 1 1932 total_err += 1
1876 asset_failed_pending.append(pending) 1933 asset_failed_pending.append(_failed_pending(pending, YINYAN_IMPORT_FAILURE_UNEXPECTED))
1877 log.error( 1934 log.error(
1878 "Skip song because unexpected error: %s platform %s: %s", 1935 "Skip song because unexpected error: %s platform %s: %s",
1879 hk_row.get('name'), pr['platform'], e, 1936 hk_row.get('name'), pr['platform'], e,
...@@ -1886,6 +1943,7 @@ def run( ...@@ -1886,6 +1943,7 @@ def run(
1886 for pending in discarded_pending: 1943 for pending in discarded_pending:
1887 mark_yinyan_record_resource_failed( 1944 mark_yinyan_record_resource_failed(
1888 pg_cur, int(pending['song_id']), int(pending['record_id']), str(pending['platform']), 1945 pg_cur, int(pending['song_id']), int(pending['record_id']), str(pending['platform']),
1946 int(pending['failure_code']),
1889 ) 1947 )
1890 pg_conn.commit() 1948 pg_conn.commit()
1891 if discarded_pending: 1949 if discarded_pending:
...@@ -1897,6 +1955,7 @@ def run( ...@@ -1897,6 +1955,7 @@ def run(
1897 for pending in discarded_pending: 1955 for pending in discarded_pending:
1898 mark_yinyan_record_resource_failed( 1956 mark_yinyan_record_resource_failed(
1899 pg_cur, int(pending['song_id']), int(pending['record_id']), str(pending['platform']), 1957 pg_cur, int(pending['song_id']), int(pending['record_id']), str(pending['platform']),
1958 int(pending['failure_code']),
1900 ) 1959 )
1901 pg_conn.commit() 1960 pg_conn.commit()
1902 mark_hk_songs_deleted(hk_conn, [int(pending['song_id']) for pending in discarded_pending]) 1961 mark_hk_songs_deleted(hk_conn, [int(pending['song_id']) for pending in discarded_pending])
......
1 import uuid 1 import uuid
2 2
3 from .config import YINYAN_IMPORT_TABLE
4
3 _SINGER_INDEX_VALUES = set('ABCDEFGHIJKLMNOPQRSTUVWXYZ#') 5 _SINGER_INDEX_VALUES = set('ABCDEFGHIJKLMNOPQRSTUVWXYZ#')
4 _SINGER_SEX_VALUES = {'M', 'F', 'C', 'U'} 6 _SINGER_SEX_VALUES = {'M', 'F', 'C', 'U'}
5 _SINGER_AREA_VALUES = {'华语', '欧美', '韩国', '日本', '其他'} 7 _SINGER_AREA_VALUES = {'华语', '欧美', '韩国', '日本', '其他'}
6 # 资源转存失败哨兵值:platform_song_id 设为 -1 表示 OSS 失败,区别于 NULL(待导入)和正整数(成功) 8 # platform_song_id 负数为失败码;NULL 表示待导入,正整数表示成功。
7 PLATFORM_SONG_ID_RESOURCE_FAILED = -1 9 YINYAN_IMPORT_FAILURE_UNKNOWN = -1
10 YINYAN_IMPORT_FAILURE_NO_LYRIC = -2
11 YINYAN_IMPORT_FAILURE_NO_COVER = -3
12 YINYAN_IMPORT_FAILURE_NO_LYRIC_AND_COVER = -4
13 YINYAN_IMPORT_FAILURE_SOURCE_DATA_MISSING = -5
14 YINYAN_IMPORT_FAILURE_AUDIO_TRANSFER = -6
15 YINYAN_IMPORT_FAILURE_COVER_TRANSFER = -7
16 YINYAN_IMPORT_FAILURE_LYRIC_TRANSFER = -8
17 YINYAN_IMPORT_FAILURE_MULTIPLE_ASSET_TRANSFER = -9
18 YINYAN_IMPORT_FAILURE_UNEXPECTED = -10
19
20 YINYAN_IMPORT_FAILURE_CODES = {
21 YINYAN_IMPORT_FAILURE_UNKNOWN: 'unknown',
22 YINYAN_IMPORT_FAILURE_NO_LYRIC: 'no_usable_lyric',
23 YINYAN_IMPORT_FAILURE_NO_COVER: 'no_usable_cover',
24 YINYAN_IMPORT_FAILURE_NO_LYRIC_AND_COVER: 'no_usable_lyric_and_cover',
25 YINYAN_IMPORT_FAILURE_SOURCE_DATA_MISSING: 'source_or_spider_data_missing',
26 YINYAN_IMPORT_FAILURE_AUDIO_TRANSFER: 'audio_oss_transfer_failed',
27 YINYAN_IMPORT_FAILURE_COVER_TRANSFER: 'cover_oss_transfer_failed',
28 YINYAN_IMPORT_FAILURE_LYRIC_TRANSFER: 'lyric_oss_transfer_failed',
29 YINYAN_IMPORT_FAILURE_MULTIPLE_ASSET_TRANSFER: 'multiple_oss_transfers_failed',
30 YINYAN_IMPORT_FAILURE_UNEXPECTED: 'unexpected_error',
31 }
8 32
9 33
10 def _singer_index(value) -> str: 34 def _singer_index(value) -> str:
...@@ -90,9 +114,9 @@ def fetch_existing_yinyan_song_ids(cur) -> set[int]: ...@@ -90,9 +114,9 @@ def fetch_existing_yinyan_song_ids(cur) -> set[int]:
90 114
91 def fetch_pending_yinyan_song_records(cur, limit: int) -> list[dict]: 115 def fetch_pending_yinyan_song_records(cur, limit: int) -> list[dict]:
92 cur.execute( 116 cur.execute(
93 """ 117 f"""
94 SELECT song_id, record_id, platform 118 SELECT song_id, record_id, platform
95 FROM yinyan_song_records 119 FROM {YINYAN_IMPORT_TABLE}
96 WHERE is_yinyan_push = FALSE 120 WHERE is_yinyan_push = FALSE
97 AND platform IS NOT NULL 121 AND platform IS NOT NULL
98 AND platform_song_id IS NULL 122 AND platform_song_id IS NULL
...@@ -142,23 +166,31 @@ def update_yinyan_record_platforms(cur, records: list[dict]) -> None: ...@@ -142,23 +166,31 @@ def update_yinyan_record_platforms(cur, records: list[dict]) -> None:
142 166
143 def upsert_yinyan_song_records(cur, records: list[dict]) -> None: 167 def upsert_yinyan_song_records(cur, records: list[dict]) -> None:
144 """Mark pre-initialized yinyan song-record rows as pushed to crawler. 168 """Mark pre-initialized yinyan song-record rows as pushed to crawler.
145 Matches only on song_id so singer-fallback can use a different record_id than initialized. 169 精确定位原始待处理关系,避免 records2 同一 song_id 的多条录音被一起更新。
146 """ 170 """
147 if not records: 171 if not records:
148 return 172 return
149 values_sql = ', '.join(['(%s::bigint, %s::bigint, %s::varchar, %s::bigint)'] * len(records)) 173 values_sql = ', '.join(
174 ['(%s::bigint, %s::bigint, %s::bigint, %s::varchar, %s::bigint)'] * len(records)
175 )
150 params = [] 176 params = []
151 for r in records: 177 for r in records:
152 params.extend([r['song_id'], r['record_id'], r['platform'], r['platform_song_id']]) 178 params.extend([
179 r['song_id'], r.get('source_record_id', r['record_id']), r['record_id'],
180 r['platform'], r['platform_song_id'],
181 ])
182 record_id_assignment = '' if YINYAN_IMPORT_TABLE == 'yinyan_song_records2' else 'record_id = v.record_id,'
153 cur.execute( 183 cur.execute(
154 f""" 184 f"""
155 UPDATE yinyan_song_records AS ysr 185 UPDATE {YINYAN_IMPORT_TABLE} AS ysr
156 SET platform = v.platform, 186 SET platform = v.platform,
157 platform_song_id = v.platform_song_id, 187 platform_song_id = v.platform_song_id,
158 record_id = v.record_id, 188 {record_id_assignment}
159 is_yinyan_push = TRUE 189 is_yinyan_push = TRUE
160 FROM (VALUES {values_sql}) AS v(song_id, record_id, platform, platform_song_id) 190 FROM (VALUES {values_sql}) AS v(song_id, source_record_id, record_id, platform, platform_song_id)
161 WHERE ysr.song_id = v.song_id 191 WHERE ysr.song_id = v.song_id
192 AND ysr.record_id = v.source_record_id
193 AND ysr.platform = v.platform
162 AND ysr.is_yinyan_push = FALSE 194 AND ysr.is_yinyan_push = FALSE
163 """, 195 """,
164 tuple(params), 196 tuple(params),
...@@ -254,15 +286,23 @@ def delete_qq_song_and_yinyan_record(cur, song_id: int, platform_song_id: int, s ...@@ -254,15 +286,23 @@ def delete_qq_song_and_yinyan_record(cur, song_id: int, platform_song_id: int, s
254 ) 286 )
255 287
256 288
257 def mark_yinyan_record_resource_failed(cur, song_id: int, record_id: int, platform: str) -> None: 289 def mark_yinyan_record_resource_failed(
258 """保留未导入状态,并将 platform_song_id 置为哨兵值 -1,避免后续批次重复处理。""" 290 cur,
291 song_id: int,
292 record_id: int,
293 platform: str,
294 failure_code: int = YINYAN_IMPORT_FAILURE_UNKNOWN,
295 ) -> None:
296 """保留未导入状态,并以负数 platform_song_id 记录具体失败类型。"""
297 if failure_code not in YINYAN_IMPORT_FAILURE_CODES:
298 failure_code = YINYAN_IMPORT_FAILURE_UNKNOWN
259 cur.execute( 299 cur.execute(
260 """ 300 f"""
261 UPDATE yinyan_song_records 301 UPDATE {YINYAN_IMPORT_TABLE}
262 SET is_yinyan_push = FALSE, platform_song_id = %s 302 SET is_yinyan_push = FALSE, platform_song_id = %s
263 WHERE song_id = %s AND record_id = %s AND platform = %s AND is_yinyan_push = FALSE 303 WHERE song_id = %s AND record_id = %s AND platform = %s AND is_yinyan_push = FALSE
264 """, 304 """,
265 (PLATFORM_SONG_ID_RESOURCE_FAILED, song_id, record_id, platform), 305 (failure_code, song_id, record_id, platform),
266 ) 306 )
267 307
268 308
......
1 #!/usr/bin/env python3 1 #!/usr/bin/env python3
2 import argparse 2 import argparse
3 from etl_to_crawler.config import PLATFORM_QQ, PLATFORM_KUGOU, PLATFORM_NETEASE, PLATFORMS 3 from etl_to_crawler.config import PLATFORM_QQ, PLATFORM_KUGOU, PLATFORM_NETEASE, PLATFORMS, YINYAN_IMPORT_TABLE
4 from etl_to_crawler.runner import backfill_yinyan_record_platforms, initialize_yinyan_song_records, initialize_yinyan_song_records2, run, backfill_empty_singers, backfill_qq_invalid_covers 4 from etl_to_crawler.runner import backfill_yinyan_record_platforms, initialize_yinyan_song_records, initialize_yinyan_song_records2, run, backfill_empty_singers, backfill_qq_invalid_covers
5 5
6 PLATFORM_MAP = { 6 PLATFORM_MAP = {
...@@ -33,7 +33,7 @@ if __name__ == '__main__': ...@@ -33,7 +33,7 @@ if __name__ == '__main__':
33 else: 33 else:
34 platforms = [PLATFORM_MAP[args.platform]] 34 platforms = [PLATFORM_MAP[args.platform]]
35 35
36 print(f"Starting ETL for platforms: {platforms}") 36 print(f"Starting ETL for platforms: {platforms}, import table: {YINYAN_IMPORT_TABLE}")
37 if args.backfill_yinyan_platforms: 37 if args.backfill_yinyan_platforms:
38 backfill_yinyan_record_platforms(max_batches=args.max_batches) 38 backfill_yinyan_record_platforms(max_batches=args.max_batches)
39 elif args.init_yinyan_records: 39 elif args.init_yinyan_records:
......
1 from unittest.mock import MagicMock, patch 1 from unittest.mock import MagicMock, patch
2 import math 2 import math
3 import requests 3 import requests
4 from etl_to_crawler.oss import transfer_url, transfer_url_with_md5 4 from etl_to_crawler.oss import download_text_url, transfer_url, transfer_url_with_md5
5 5
6 ARCHIVE_URL = "https://archive-dev.oss-cn-beijing.aliyuncs.com/some/path.mp3" 6 ARCHIVE_URL = "https://archive-dev.oss-cn-beijing.aliyuncs.com/some/path.mp3"
7 OTHER_URL = "https://hikoon-data-platform.oss-cn-beijing.aliyuncs.com/qq-audio/abc.mp3" 7 OTHER_URL = "https://hikoon-data-platform.oss-cn-beijing.aliyuncs.com/qq-audio/abc.mp3"
...@@ -13,6 +13,15 @@ def test_already_archive_dev_returns_as_is(): ...@@ -13,6 +13,15 @@ def test_already_archive_dev_returns_as_is():
13 assert result == ARCHIVE_URL 13 assert result == ARCHIVE_URL
14 bucket.put_object.assert_not_called() 14 bucket.put_object.assert_not_called()
15 15
16
17 def test_download_text_url_reads_source_even_when_it_is_target_oss():
18 with patch('etl_to_crawler.oss._http_get') as mock_get:
19 mock_get.return_value.content = b'\xef\xbb\xbf[00:01.00]lyric'
20 mock_get.return_value.raise_for_status = MagicMock()
21 result = download_text_url(ARCHIVE_URL, BASE_URL)
22
23 assert result == '[00:01.00]lyric'
24
16 def test_empty_url_returns_empty(): 25 def test_empty_url_returns_empty():
17 bucket = MagicMock() 26 bucket = MagicMock()
18 result = transfer_url('', "any/key.mp3", bucket, BASE_URL) 27 result = transfer_url('', "any/key.mp3", bucket, BASE_URL)
......
...@@ -78,11 +78,33 @@ def test_required_asset_validation_rejects_partial_transfer(): ...@@ -78,11 +78,33 @@ def test_required_asset_validation_rejects_partial_transfer():
78 }) 78 })
79 79
80 80
81 def test_lyric_upload_does_not_fall_back_to_external_source_url(monkeypatch): 81 def test_lyric_upload_prefers_hk_url_and_forces_oss_transfer(monkeypatch):
82 monkeypatch.setattr(runner, 'upload_plain_lyric_to_bucket', lambda *args: '') 82 download = MagicMock(return_value='[00:01.00]HK lyric')
83 monkeypatch.setattr(runner, 'download_text_url', download)
84 upload = MagicMock(return_value='https://archive.example/crawler/lyric/qq/mid.txt')
85 monkeypatch.setattr(runner, 'upload_plain_lyric_to_bucket', upload)
86 bucket = object()
87
88 result = runner._safe_upload_lyric(
89 'qq', 'mid', 'spider lyric', 'https://source.example/lyric.txt', bucket, 'https://archive.example',
90 )
91
92 assert result == 'https://archive.example/crawler/lyric/qq/mid.txt'
93 download.assert_called_once_with('https://source.example/lyric.txt', 'https://archive.example')
94 upload.assert_called_once_with(
95 'qq', 'mid', '[00:01.00]HK lyric', bucket, 'https://archive.example',
96 )
97
98
99 def test_lyric_upload_falls_back_to_spider_text_when_hk_transfer_fails(monkeypatch):
100 monkeypatch.setattr(runner, 'download_text_url', MagicMock(side_effect=TimeoutError('timeout')))
101 upload = MagicMock(return_value='https://archive.example/crawler/lyric/qq/mid.txt')
102 monkeypatch.setattr(runner, 'upload_plain_lyric_to_bucket', upload)
103
83 assert runner._safe_upload_lyric( 104 assert runner._safe_upload_lyric(
84 'qq', 'mid', '', 'https://source.example/lyric.txt', object(), 'https://archive.example', 105 'qq', 'mid', 'spider lyric', 'https://source.example/lyric.txt', object(), 'https://archive.example',
85 ) == '' 106 ) == 'https://archive.example/crawler/lyric/qq/mid.txt'
107 assert upload.call_args.args[2] == 'spider lyric'
86 108
87 109
88 def test_qq_cover_source_ignores_missing_album_placeholder_from_hk(): 110 def test_qq_cover_source_ignores_missing_album_placeholder_from_hk():
...@@ -131,6 +153,25 @@ def test_select_qq_import_record_replaces_missing_lyric_with_same_source_candida ...@@ -131,6 +153,25 @@ def test_select_qq_import_record_replaces_missing_lyric_with_same_source_candida
131 assert reason == 'ok' 153 assert reason == 'ok'
132 154
133 155
156 def test_select_qq_import_record_accepts_hk_lyric_when_spider_lyric_is_empty():
157 current = {'record_id': 10, 'platform_unique_key': 'without-spider-lyric'}
158 selected, reason = runner._select_qq_import_record(
159 {'lyrics_url': 'https://archive-dev.example/lyrics/song.txt', 'cover_url': 'https://example.com/cover.jpg'},
160 current,
161 [current],
162 {
163 'without-spider-lyric': {
164 'album_id': 100,
165 'cover': 'https://example.com/cover.jpg',
166 'lyric': '',
167 },
168 },
169 )
170
171 assert selected == current
172 assert reason == 'ok'
173
174
134 def test_select_qq_import_record_returns_none_when_no_candidate_satisfies_missing_fields(): 175 def test_select_qq_import_record_returns_none_when_no_candidate_satisfies_missing_fields():
135 current = {'record_id': 10, 'platform_unique_key': 'bad-mid'} 176 current = {'record_id': 10, 'platform_unique_key': 'bad-mid'}
136 result, reason = runner._select_qq_import_record( 177 result, reason = runner._select_qq_import_record(
......
1 from unittest.mock import MagicMock, call 1 from unittest.mock import MagicMock, call
2 import etl_to_crawler.writer as writer
2 from etl_to_crawler.writer import ( 3 from etl_to_crawler.writer import (
3 fetch_pending_yinyan_song_records, 4 fetch_pending_yinyan_song_records,
4 fetch_yinyan_records_missing_platform, 5 fetch_yinyan_records_missing_platform,
...@@ -69,12 +70,19 @@ def test_mark_yinyan_record_resource_failed_keeps_unpushed_state_and_excludes_re ...@@ -69,12 +70,19 @@ def test_mark_yinyan_record_resource_failed_keeps_unpushed_state_and_excludes_re
69 mark_yinyan_record_resource_failed(cur, 10, 100, '1') 70 mark_yinyan_record_resource_failed(cur, 10, 100, '1')
70 71
71 sql, params = cur.execute.call_args[0] 72 sql, params = cur.execute.call_args[0]
72 assert 'UPDATE yinyan_song_records' in sql 73 assert f'UPDATE {writer.YINYAN_IMPORT_TABLE}' in sql
73 assert 'is_yinyan_push = FALSE' in sql 74 assert 'is_yinyan_push = FALSE' in sql
74 assert 'platform_song_id = %s' in sql 75 assert 'platform_song_id = %s' in sql
75 assert params == (-1, 10, 100, '1') 76 assert params == (-1, 10, 100, '1')
76 77
77 78
79 def test_mark_yinyan_record_resource_failed_writes_specific_failure_code():
80 cur = MagicMock()
81 mark_yinyan_record_resource_failed(cur, 10, 100, '1', writer.YINYAN_IMPORT_FAILURE_NO_LYRIC)
82
83 assert cur.execute.call_args[0][1] == (-2, 10, 100, '1')
84
85
78 def test_upsert_qq_singers_empty_does_nothing(): 86 def test_upsert_qq_singers_empty_does_nothing():
79 cur = MagicMock() 87 cur = MagicMock()
80 upsert_qq_singers(cur, []) 88 upsert_qq_singers(cur, [])
...@@ -97,16 +105,21 @@ def test_upsert_yinyan_song_records_marks_existing_relation_as_pushed(): ...@@ -97,16 +105,21 @@ def test_upsert_yinyan_song_records_marks_existing_relation_as_pushed():
97 ]) 105 ])
98 106
99 sql, params = cur.execute.call_args[0] 107 sql, params = cur.execute.call_args[0]
100 assert 'UPDATE yinyan_song_records' in sql 108 assert f'UPDATE {writer.YINYAN_IMPORT_TABLE}' in sql
101 assert 'UPDATE yinyan_song_records AS ysr' in sql 109 assert f'UPDATE {writer.YINYAN_IMPORT_TABLE} AS ysr' in sql
102 assert 'SET platform = v.platform' in sql 110 assert 'SET platform = v.platform' in sql
103 assert 'platform_song_id = v.platform_song_id' in sql 111 assert 'platform_song_id = v.platform_song_id' in sql
112 if writer.YINYAN_IMPORT_TABLE == 'yinyan_song_records2':
113 assert 'record_id = v.record_id' not in sql
114 else:
104 assert 'record_id = v.record_id' in sql 115 assert 'record_id = v.record_id' in sql
105 assert 'is_yinyan_push = TRUE' in sql 116 assert 'is_yinyan_push = TRUE' in sql
106 assert 'FROM (VALUES (%s::bigint, %s::bigint, %s::varchar, %s::bigint), (%s::bigint, %s::bigint, %s::varchar, %s::bigint))' in sql 117 assert 'source_record_id' in sql
107 assert 'ysr.song_id = v.song_id' in sql 118 assert 'ysr.song_id = v.song_id' in sql
119 assert 'ysr.record_id = v.source_record_id' in sql
120 assert 'ysr.platform = v.platform' in sql
108 assert 'ysr.is_yinyan_push = FALSE' in sql 121 assert 'ysr.is_yinyan_push = FALSE' in sql
109 assert params == (10, 100, '1', 1000, 11, 101, '2', 1001) 122 assert params == (10, 100, 100, '1', 1000, 11, 101, 101, '2', 1001)
110 123
111 124
112 def test_insert_yinyan_song_records_initializes_unpushed_rows(): 125 def test_insert_yinyan_song_records_initializes_unpushed_rows():
...@@ -193,6 +206,23 @@ def test_fetch_pending_yinyan_song_records_reads_platform_for_single_platform_im ...@@ -193,6 +206,23 @@ def test_fetch_pending_yinyan_song_records_reads_platform_for_single_platform_im
193 ] 206 ]
194 207
195 208
209 def test_import_state_queries_can_target_records2(monkeypatch):
210 monkeypatch.setattr(writer, 'YINYAN_IMPORT_TABLE', 'yinyan_song_records2')
211 cur = MagicMock()
212 cur.fetchall.return_value = []
213
214 writer.fetch_pending_yinyan_song_records(cur, 100)
215 assert 'FROM yinyan_song_records2' in cur.execute.call_args[0][0]
216
217 writer.upsert_yinyan_song_records(cur, [
218 {'song_id': 10, 'record_id': 100, 'platform': '1', 'platform_song_id': 1000},
219 ])
220 assert 'UPDATE yinyan_song_records2 AS ysr' in cur.execute.call_args[0][0]
221
222 writer.mark_yinyan_record_resource_failed(cur, 10, 100, '1')
223 assert 'UPDATE yinyan_song_records2' in cur.execute.call_args[0][0]
224
225
196 def test_update_yinyan_record_platforms_fills_only_null_platform_rows(): 226 def test_update_yinyan_record_platforms_fills_only_null_platform_rows():
197 cur = MagicMock() 227 cur = MagicMock()
198 update_yinyan_record_platforms(cur, [ 228 update_yinyan_record_platforms(cur, [
......
1 | **失败码** | **含义** |
2 | ---------- | ---------------------------- |
3 | -1 | 未分类/未知失败 |
4 | -2 | 没有可用歌词 |
5 | -3 | 没有可用封面 |
6 | -4 | 歌词和封面均不可用 |
7 | -5 | HK、音眼源库或spider数据缺失 |
8 | -6 | 音频转存OSS失败 |
9 | -7 | 封面转存OSS失败 |
10 | -8 | 歌词转存OSS失败 |
11 | -9 | 多项必需资源转存失败 |
12 | -10 | 未预期程序异常 |
...\ No newline at end of file ...\ No newline at end of file