Commit 2911f147 2911f1479ec790b0d5aac60dfde250f4bcbb8313 by 沈秋雨

feat(crawler): 增加QQ无效专辑封面回填功能

- 在 run_etl.py 添加 backfill-qq-invalid-covers 命令行选项
- 配置文件中新增 HTTP_TRANSFER_RETRIES 和 RETRY_BACKOFF_SECONDS
- oss.py 中实现下载资源的重试逻辑,避免超时和连接断开失败
- runner.py 中引入资源必要性校验,确保音频、封面、歌词均成功转存
- 实现根据歌词和封面有效性选择更合适的QQ录音记录进行替换
- 实现 backfill_qq_invalid_covers 函数,处理无专辑占位封面的QQ歌曲
- 无候选的QQ歌曲在crawler和yinyan记录中删除,并软删除 HK 歌曲
- 资源转存失败的记录在yinyan_song_records标记,避免重复处理
- run函数中增加对QQ、酷狗、网易备选录音的支持和筛选
- 增加对应的数据库操作函数支持替换、删除以及状态标记
- 测试覆盖下载重试、资源转存失败处理及选择替代录音逻辑
- 所有下载失败均返回空URL,不阻断流程以保证稳定性
- 异常处理增强,确保未成功上传资源的歌曲不入库
- 未匹配到HK歌曲的情况增加日志警告及延迟重试机制
1 parent a1e6ab0d
...@@ -51,4 +51,6 @@ PLATFORMS = [PLATFORM_QQ, PLATFORM_KUGOU, PLATFORM_NETEASE] ...@@ -51,4 +51,6 @@ PLATFORMS = [PLATFORM_QQ, PLATFORM_KUGOU, PLATFORM_NETEASE]
51 BATCH_SIZE = 100 51 BATCH_SIZE = 100
52 BACKFILL_BATCH_SIZE = int(os.environ.get('BACKFILL_BATCH_SIZE', '5000')) 52 BACKFILL_BATCH_SIZE = int(os.environ.get('BACKFILL_BATCH_SIZE', '5000'))
53 HTTP_POOL_MAXSIZE = int(os.environ.get('HTTP_POOL_MAXSIZE', '128')) 53 HTTP_POOL_MAXSIZE = int(os.environ.get('HTTP_POOL_MAXSIZE', '128'))
54 HTTP_TRANSFER_RETRIES = int(os.environ.get('HTTP_TRANSFER_RETRIES', '3'))
55 HTTP_TRANSFER_RETRY_BACKOFF_SECONDS = float(os.environ.get('HTTP_TRANSFER_RETRY_BACKOFF_SECONDS', '1'))
54 OSS_CONNECTION_POOL_SIZE = int(os.environ.get('OSS_CONNECTION_POOL_SIZE', str(HTTP_POOL_MAXSIZE))) 56 OSS_CONNECTION_POOL_SIZE = int(os.environ.get('OSS_CONNECTION_POOL_SIZE', str(HTTP_POOL_MAXSIZE)))
......
1 import requests 1 import requests
2 import oss2 2 import oss2
3 import math 3 import math
4 import time
4 from urllib.parse import urlparse 5 from urllib.parse import urlparse
5 from requests.adapters import HTTPAdapter 6 from requests.adapters import HTTPAdapter
6 from .config import HTTP_POOL_MAXSIZE, OSS_CONFIG 7 from .config import (
8 HTTP_POOL_MAXSIZE, HTTP_TRANSFER_RETRIES, HTTP_TRANSFER_RETRY_BACKOFF_SECONDS, OSS_CONFIG,
9 )
7 from .utils import compute_audio_md5 10 from .utils import compute_audio_md5
8 11
9 _HTTP_SESSION = requests.Session() 12 _HTTP_SESSION = requests.Session()
...@@ -47,6 +50,25 @@ def _download_url(url: str, base_url: str) -> str: ...@@ -47,6 +50,25 @@ def _download_url(url: str, base_url: str) -> str:
47 return f"{download_base_url.rstrip('/')}{url[len(rewrite_from.rstrip('/')):]}" 50 return f"{download_base_url.rstrip('/')}{url[len(rewrite_from.rstrip('/')):]}"
48 51
49 52
53 def _download_content(url: str, base_url: str) -> bytes:
54 """下载资源;针对超时、连接中断和服务端错误进行有限重试。"""
55 download_url = _download_url(url, base_url)
56 attempts = max(1, HTTP_TRANSFER_RETRIES)
57 for attempt in range(attempts):
58 try:
59 resp = _http_get(download_url, timeout=30)
60 resp.raise_for_status()
61 return resp.content
62 except requests.RequestException as exc:
63 status_code = getattr(getattr(exc, 'response', None), 'status_code', None)
64 # 4xx(429 除外)是确定性失败,不浪费时间重试。
65 if status_code is not None and 400 <= status_code < 500 and status_code != 429:
66 raise
67 if attempt == attempts - 1:
68 raise
69 time.sleep(HTTP_TRANSFER_RETRY_BACKOFF_SECONDS * (2 ** attempt))
70
71
50 def transfer_url(url: str | None, oss_key: str, bucket: oss2.Bucket, base_url: str) -> str: 72 def transfer_url(url: str | None, oss_key: str, bucket: oss2.Bucket, base_url: str) -> str:
51 """ 73 """
52 将 url 指向的文件转移到 archive-dev OSS 的 oss_key 路径。 74 将 url 指向的文件转移到 archive-dev OSS 的 oss_key 路径。
...@@ -58,9 +80,8 @@ def transfer_url(url: str | None, oss_key: str, bucket: oss2.Bucket, base_url: s ...@@ -58,9 +80,8 @@ def transfer_url(url: str | None, oss_key: str, bucket: oss2.Bucket, base_url: s
58 return '' 80 return ''
59 if _is_target_oss_url(url, base_url): 81 if _is_target_oss_url(url, base_url):
60 return url 82 return url
61 resp = _http_get(_download_url(url, base_url), timeout=30) 83 content = _download_content(url, base_url)
62 resp.raise_for_status() 84 bucket.put_object(oss_key, content)
63 bucket.put_object(oss_key, resp.content)
64 return f"{base_url.rstrip('/')}/{oss_key}" 85 return f"{base_url.rstrip('/')}/{oss_key}"
65 86
66 87
...@@ -74,10 +95,9 @@ def transfer_url_with_md5(url: str | None, oss_key: str, bucket: oss2.Bucket, ba ...@@ -74,10 +95,9 @@ def transfer_url_with_md5(url: str | None, oss_key: str, bucket: oss2.Bucket, ba
74 return '', '' 95 return '', ''
75 if _is_target_oss_url(url, base_url): 96 if _is_target_oss_url(url, base_url):
76 return url, '' 97 return url, ''
77 resp = _http_get(_download_url(url, base_url), timeout=30) 98 content = _download_content(url, base_url)
78 resp.raise_for_status() 99 audio_md5 = compute_audio_md5(content)
79 audio_md5 = compute_audio_md5(resp.content) 100 bucket.put_object(oss_key, content)
80 bucket.put_object(oss_key, resp.content)
81 return f"{base_url.rstrip('/')}/{oss_key}", audio_md5 101 return f"{base_url.rstrip('/')}/{oss_key}", audio_md5
82 102
83 103
......
...@@ -111,6 +111,21 @@ def fetch_hk_songs_by_source_ids(conn: pymysql.Connection, source_song_ids: list ...@@ -111,6 +111,21 @@ def fetch_hk_songs_by_source_ids(conn: pymysql.Connection, source_song_ids: list
111 } 111 }
112 112
113 113
114 def mark_hk_songs_deleted(conn: pymysql.Connection, source_song_ids: list[int]) -> int:
115 """将无法导入的源歌曲在 hk_songs_test 中软删除。"""
116 if not source_song_ids:
117 return 0
118 unique_ids = list(dict.fromkeys(int(song_id) for song_id in source_song_ids))
119 placeholders = ','.join(['%s'] * len(unique_ids))
120 with conn.cursor() as cur:
121 cur.execute(
122 f"UPDATE hk_songs_test SET deleted = '1' "
123 f"WHERE source_song_id IN ({placeholders}) AND deleted = '0'",
124 unique_ids,
125 )
126 return cur.rowcount
127
128
114 def fetch_platform_records(source_conn: pymysql.Connection, song_ids: list[int]) -> list[dict]: 129 def fetch_platform_records(source_conn: pymysql.Connection, song_ids: list[int]) -> list[dict]:
115 if not song_ids: 130 if not song_ids:
116 return [] 131 return []
......
...@@ -3,6 +3,7 @@ import uuid ...@@ -3,6 +3,7 @@ import uuid
3 _SINGER_INDEX_VALUES = set('ABCDEFGHIJKLMNOPQRSTUVWXYZ#') 3 _SINGER_INDEX_VALUES = set('ABCDEFGHIJKLMNOPQRSTUVWXYZ#')
4 _SINGER_SEX_VALUES = {'M', 'F', 'C', 'U'} 4 _SINGER_SEX_VALUES = {'M', 'F', 'C', 'U'}
5 _SINGER_AREA_VALUES = {'华语', '欧美', '韩国', '日本', '其他'} 5 _SINGER_AREA_VALUES = {'华语', '欧美', '韩国', '日本', '其他'}
6 RESOURCE_TRANSFER_FAILED = 'resource_transfer_failed'
6 7
7 8
8 def _singer_index(value) -> str: 9 def _singer_index(value) -> str:
...@@ -49,10 +50,11 @@ def fetch_pending_yinyan_song_records(cur, limit: int) -> list[dict]: ...@@ -49,10 +50,11 @@ def fetch_pending_yinyan_song_records(cur, limit: int) -> list[dict]:
49 FROM yinyan_song_records 50 FROM yinyan_song_records
50 WHERE is_yinyan_push = FALSE 51 WHERE is_yinyan_push = FALSE
51 AND platform IS NOT NULL 52 AND platform IS NOT NULL
53 AND COALESCE(recording_id, '') != %s
52 ORDER BY song_id 54 ORDER BY song_id
53 LIMIT %s 55 LIMIT %s
54 """, 56 """,
55 (limit,), 57 (RESOURCE_TRANSFER_FAILED, limit),
56 ) 58 )
57 rows = cur.fetchall() 59 rows = cur.fetchall()
58 return [{'song_id': row[0], 'record_id': row[1], 'platform': row[2]} for row in rows] 60 return [{'song_id': row[0], 'record_id': row[1], 'platform': row[2]} for row in rows]
...@@ -135,6 +137,90 @@ def fetch_qq_songs_missing_singers(cur, limit: int) -> list[dict]: ...@@ -135,6 +137,90 @@ def fetch_qq_songs_missing_singers(cur, limit: int) -> list[dict]:
135 return [{'id': str(row[0]), 'platform_song_id': row[1], 'mid': row[2]} for row in rows] 137 return [{'id': str(row[0]), 'platform_song_id': row[1], 'mid': row[2]} for row in rows]
136 138
137 139
140 def fetch_qq_songs_with_invalid_covers(cur, invalid_cover: str, limit: int) -> list[dict]:
141 """返回已导入、但仍使用 QQ 无专辑占位封面的歌曲及其源歌曲状态。"""
142 cur.execute(
143 """
144 SELECT qs.id, qs.platform_song_id, qs.mid, qs.name,
145 ysr.song_id, ysr.record_id
146 FROM crawler_qqmusic_songs qs
147 JOIN yinyan_song_records ysr
148 ON ysr.platform = '1' AND ysr.platform_song_id = qs.platform_song_id
149 WHERE qs.cover = %s
150 AND qs.album_id IS NULL
151 AND ysr.is_yinyan_push = TRUE
152 ORDER BY ysr.song_id
153 LIMIT %s
154 """,
155 (invalid_cover, limit),
156 )
157 return [
158 {
159 'id': str(row[0]), 'platform_song_id': int(row[1]), 'mid': row[2],
160 'name': row[3], 'song_id': int(row[4]), 'record_id': int(row[5]),
161 }
162 for row in cur.fetchall()
163 ]
164
165
166 def replace_qq_song(cur, old_platform_song_id: int, song: dict) -> None:
167 """用同一源歌曲下的另一条 QQ 录音更新既有 crawler 歌曲。"""
168 cur.execute(
169 """
170 UPDATE crawler_qqmusic_songs
171 SET platform_song_id = %s, mid = %s, album_id = %s, cover = %s,
172 title = %s, name = %s, duration = %s, lyric = %s,
173 composer_name = %s, lyricist_name = %s, url = %s, audio_md5 = %s,
174 lyric_url = %s, platform_index_url = %s, published_at = %s,
175 album = %s::json, singers = %s::jsonb, version = %s,
176 provider_name = %s, crawler_source_data = %s::json, updated_at = NOW()
177 WHERE platform_song_id = %s
178 """,
179 (
180 song['platform_song_id'], song['mid'], song.get('album_id'), song.get('cover', ''),
181 song.get('title', ''), song.get('name', ''), song.get('duration', 0) or 0,
182 song.get('lyric'), song.get('composer_name'), song.get('lyricist_name'),
183 song.get('url', ''), song.get('audio_md5'), song.get('lyric_url'),
184 song.get('platform_index_url'), song.get('published_at'), song.get('album_json'),
185 song.get('singers_json', '[]'), song.get('version'), song.get('provider_name'),
186 song.get('crawler_source_data'), old_platform_song_id,
187 ),
188 )
189
190
191 def replace_yinyan_song_record(cur, song_id: int, old_platform_song_id: int, record_id: int, platform_song_id: int) -> None:
192 cur.execute(
193 """
194 UPDATE yinyan_song_records
195 SET record_id = %s, platform_song_id = %s, platform = '1'
196 WHERE song_id = %s AND platform = '1' AND platform_song_id = %s
197 """,
198 (record_id, platform_song_id, song_id, old_platform_song_id),
199 )
200
201
202 def delete_qq_song_and_yinyan_record(cur, song_id: int, platform_song_id: int, song_uuid: str) -> None:
203 """移除无法找到替代录音的 crawler 数据及其导入状态。"""
204 cur.execute("DELETE FROM crawler_qqmusic_singer_songs WHERE song_id = %s", (song_uuid,))
205 cur.execute("DELETE FROM crawler_qqmusic_songs WHERE platform_song_id = %s", (platform_song_id,))
206 cur.execute(
207 "DELETE FROM yinyan_song_records WHERE song_id = %s AND platform = '1' AND platform_song_id = %s",
208 (song_id, platform_song_id),
209 )
210
211
212 def mark_yinyan_record_resource_failed(cur, song_id: int, record_id: int, platform: str) -> None:
213 """保留未导入状态,并标记资源转存失败,避免后续批次重复处理。"""
214 cur.execute(
215 """
216 UPDATE yinyan_song_records
217 SET is_yinyan_push = FALSE, recording_id = %s
218 WHERE song_id = %s AND record_id = %s AND platform = %s AND is_yinyan_push = FALSE
219 """,
220 (RESOURCE_TRANSFER_FAILED, song_id, record_id, platform),
221 )
222
223
138 def fetch_kugou_songs_missing_singers(cur, limit: int) -> list[dict]: 224 def fetch_kugou_songs_missing_singers(cur, limit: int) -> list[dict]:
139 cur.execute( 225 cur.execute(
140 """ 226 """
......
1 #!/usr/bin/env python3 1 #!/usr/bin/env python3
2 import argparse 2 import argparse
3 from etl_to_crawler.config import PLATFORM_QQ, PLATFORM_KUGOU, PLATFORM_NETEASE, PLATFORMS 3 from etl_to_crawler.config import PLATFORM_QQ, PLATFORM_KUGOU, PLATFORM_NETEASE, PLATFORMS
4 from etl_to_crawler.runner import backfill_yinyan_record_platforms, initialize_yinyan_song_records, run, backfill_empty_singers 4 from etl_to_crawler.runner import backfill_yinyan_record_platforms, initialize_yinyan_song_records, run, backfill_empty_singers, backfill_qq_invalid_covers
5 5
6 PLATFORM_MAP = { 6 PLATFORM_MAP = {
7 'qq': PLATFORM_QQ, 7 'qq': PLATFORM_QQ,
...@@ -22,6 +22,8 @@ if __name__ == '__main__': ...@@ -22,6 +22,8 @@ if __name__ == '__main__':
22 help='只回填 yinyan_song_records 中为空的 platform 平台代码') 22 help='只回填 yinyan_song_records 中为空的 platform 平台代码')
23 parser.add_argument('--backfill-empty-singers', action='store_true', 23 parser.add_argument('--backfill-empty-singers', action='store_true',
24 help='回填三平台 singers 为空的歌曲记录') 24 help='回填三平台 singers 为空的歌曲记录')
25 parser.add_argument('--backfill-qq-invalid-covers', action='store_true',
26 help='替换 QQ 无专辑占位封面的录音;无候选时删除 crawler 记录并软删 hk_songs_test')
25 args = parser.parse_args() 27 args = parser.parse_args()
26 28
27 if args.platform == 'all': 29 if args.platform == 'all':
...@@ -36,5 +38,7 @@ if __name__ == '__main__': ...@@ -36,5 +38,7 @@ if __name__ == '__main__':
36 initialize_yinyan_song_records(platforms, max_batches=args.max_batches) 38 initialize_yinyan_song_records(platforms, max_batches=args.max_batches)
37 elif args.backfill_empty_singers: 39 elif args.backfill_empty_singers:
38 backfill_empty_singers(platforms, max_batches=args.max_batches) 40 backfill_empty_singers(platforms, max_batches=args.max_batches)
41 elif args.backfill_qq_invalid_covers:
42 backfill_qq_invalid_covers(max_batches=args.max_batches)
39 else: 43 else:
40 run(platforms, max_batches=args.max_batches) 44 run(platforms, max_batches=args.max_batches)
......
1 from unittest.mock import MagicMock, patch 1 from unittest.mock import MagicMock, patch
2 import math 2 import math
3 import requests
3 from etl_to_crawler.oss import transfer_url, transfer_url_with_md5 4 from etl_to_crawler.oss import transfer_url, transfer_url_with_md5
4 5
5 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"
...@@ -75,3 +76,33 @@ def test_transfer_url_with_md5_hashes_downloaded_content_before_upload(): ...@@ -75,3 +76,33 @@ def test_transfer_url_with_md5_hashes_downloaded_content_before_upload():
75 bucket.put_object.assert_called_once_with("crawler/qq/audio/abc.mp3", fake_content) 76 bucket.put_object.assert_called_once_with("crawler/qq/audio/abc.mp3", fake_content)
76 assert result == f"{BASE_URL}/crawler/qq/audio/abc.mp3" 77 assert result == f"{BASE_URL}/crawler/qq/audio/abc.mp3"
77 assert audio_md5 == "04d43544b267629d9089eaed3b847a99" 78 assert audio_md5 == "04d43544b267629d9089eaed3b847a99"
79
80
81 def test_audio_transfer_retries_timeout_then_uploads():
82 bucket = MagicMock()
83 response = MagicMock(content=b'audio_bytes')
84 response.raise_for_status.return_value = None
85 with patch('etl_to_crawler.oss._http_get', side_effect=[requests.ReadTimeout('slow'), response]) as mock_get:
86 with patch('etl_to_crawler.oss.time.sleep') as mock_sleep:
87 result, _ = transfer_url_with_md5(OTHER_URL, 'crawler/qq/audio/abc.mp3', bucket, BASE_URL)
88
89 assert mock_get.call_count == 2
90 mock_sleep.assert_called_once()
91 assert result == f"{BASE_URL}/crawler/qq/audio/abc.mp3"
92
93
94 def test_transfer_does_not_retry_not_found_response():
95 bucket = MagicMock()
96 response = MagicMock(status_code=404)
97 error = requests.HTTPError(response=response)
98 with patch('etl_to_crawler.oss._http_get', side_effect=error) as mock_get:
99 with patch('etl_to_crawler.oss.time.sleep') as mock_sleep:
100 try:
101 transfer_url_with_md5(OTHER_URL, 'crawler/qq/audio/abc.mp3', bucket, BASE_URL)
102 except requests.HTTPError:
103 pass
104 else:
105 raise AssertionError('expected HTTPError')
106
107 mock_get.assert_called_once()
108 mock_sleep.assert_not_called()
......
1 from unittest.mock import MagicMock 1 from unittest.mock import MagicMock
2 import time 2 import time
3 import pytest
3 4
4 from etl_to_crawler import runner 5 from etl_to_crawler import runner
5 6
...@@ -35,6 +36,12 @@ class _Connection: ...@@ -35,6 +36,12 @@ class _Connection:
35 def close(self): 36 def close(self):
36 return None 37 return None
37 38
39 def cursor(self):
40 return _Cursor()
41
42 def commit(self):
43 return None
44
38 45
39 def test_run_io_tasks_returns_named_results(): 46 def test_run_io_tasks_returns_named_results():
40 def slow(value): 47 def slow(value):
...@@ -52,6 +59,86 @@ def test_run_io_tasks_returns_named_results(): ...@@ -52,6 +59,86 @@ def test_run_io_tasks_returns_named_results():
52 } 59 }
53 60
54 61
62 def test_safe_audio_transfer_returns_empty_url_after_final_failure(monkeypatch):
63 monkeypatch.setattr(runner, 'transfer_url_with_md5', MagicMock(side_effect=TimeoutError('timeout')))
64 assert runner._safe_transfer_audio('https://source.example/audio.mp3', 'audio.mp3', object(), 'https://archive.example') == ('', '')
65
66
67 def test_safe_transfer_returns_empty_url_after_final_failure(monkeypatch):
68 monkeypatch.setattr(runner, 'transfer_url', MagicMock(side_effect=TimeoutError('timeout')))
69 assert runner._safe_transfer('https://source.example/cover.jpg', 'cover.jpg', object(), 'https://archive.example') == ''
70
71
72 def test_required_asset_validation_rejects_partial_transfer():
73 with pytest.raises(runner.RequiredAssetTransferError, match='audio, lyric'):
74 runner._require_primary_assets({
75 'audio': ('', ''),
76 'cover': 'https://archive.example/cover.jpg',
77 'lyric': '',
78 })
79
80
81 def test_lyric_upload_does_not_fall_back_to_external_source_url(monkeypatch):
82 monkeypatch.setattr(runner, 'upload_plain_lyric_to_bucket', lambda *args: '')
83 assert runner._safe_upload_lyric(
84 'qq', 'mid', '', 'https://source.example/lyric.txt', object(), 'https://archive.example',
85 ) == ''
86
87
88 def test_qq_cover_source_ignores_missing_album_placeholder_from_hk():
89 assert runner._qq_cover_source(
90 {'cover_url': runner.QQ_MISSING_ALBUM_COVER},
91 {'cover': 'https://example.com/valid-qq-cover.jpg'},
92 ) == 'https://example.com/valid-qq-cover.jpg'
93
94
95 def test_qq_cover_source_keeps_valid_hk_cover():
96 assert runner._qq_cover_source(
97 {'cover_url': 'https://example.com/hk-cover.jpg'},
98 {'cover': 'https://example.com/qq-cover.jpg'},
99 ) == 'https://example.com/hk-cover.jpg'
100
101
102 def test_select_qq_import_record_replaces_missing_album_cover_with_same_source_candidate():
103 current = {'record_id': 10, 'platform_unique_key': 'bad-mid'}
104 candidate = {'record_id': 20, 'platform_unique_key': 'good-mid'}
105 selected = runner._select_qq_import_record(
106 {'cover_url': runner.QQ_MISSING_ALBUM_COVER},
107 current,
108 [current, candidate],
109 {
110 'bad-mid': {'album_id': 0, 'cover': runner.QQ_MISSING_ALBUM_COVER, 'lyric': '歌词'},
111 'good-mid': {'album_id': 100, 'cover': 'https://example.com/good.jpg', 'lyric': '歌词'},
112 },
113 )
114 assert selected == candidate
115
116
117 def test_select_qq_import_record_replaces_missing_lyric_with_same_source_candidate():
118 current = {'record_id': 10, 'platform_unique_key': 'without-lyric'}
119 candidate = {'record_id': 20, 'platform_unique_key': 'with-lyric'}
120 selected = runner._select_qq_import_record(
121 {'cover_url': 'https://example.com/hk-cover.jpg'},
122 current,
123 [current, candidate],
124 {
125 'without-lyric': {'album_id': 100, 'cover': 'https://example.com/current.jpg', 'lyric': ''},
126 'with-lyric': {'album_id': 100, 'cover': 'https://example.com/candidate.jpg', 'lyric': '有效歌词'},
127 },
128 )
129 assert selected == candidate
130
131
132 def test_select_qq_import_record_returns_none_when_no_candidate_satisfies_missing_fields():
133 current = {'record_id': 10, 'platform_unique_key': 'bad-mid'}
134 assert runner._select_qq_import_record(
135 {'cover_url': runner.QQ_MISSING_ALBUM_COVER},
136 current,
137 [current],
138 {'bad-mid': {'album_id': 0, 'cover': runner.QQ_MISSING_ALBUM_COVER, 'lyric': ''}},
139 ) is None
140
141
55 def test_run_imports_only_pending_yinyan_platform_record(monkeypatch): 142 def test_run_imports_only_pending_yinyan_platform_record(monkeypatch):
56 pg_conn = _PgConnection() 143 pg_conn = _PgConnection()
57 prepare = MagicMock(return_value={ 144 prepare = MagicMock(return_value={
...@@ -95,10 +182,22 @@ def test_run_imports_only_pending_yinyan_platform_record(monkeypatch): ...@@ -95,10 +182,22 @@ def test_run_imports_only_pending_yinyan_platform_record(monkeypatch):
95 'pub_time': '2021-01-01', 182 'pub_time': '2021-01-01',
96 }] 183 }]
97 monkeypatch.setattr(runner, 'fetch_platform_records', lambda conn, song_ids: platform_records) 184 monkeypatch.setattr(runner, 'fetch_platform_records', lambda conn, song_ids: platform_records)
98 monkeypatch.setattr(runner, 'fetch_all_platform_records', MagicMock()) 185 monkeypatch.setattr(runner, 'fetch_all_platform_records', lambda conn, song_ids: [
186 {
187 'source_song_id': 10,
188 'record_id': 200,
189 'platform': '2',
190 'platform_unique_key': '200',
191 'platform_mid': 'kg-hash',
192 'album_audio_id': None,
193 'is_main_version': 1,
194 'is_high': 0,
195 'pub_time': '2021-01-01',
196 },
197 ])
99 monkeypatch.setattr(runner, 'fetch_platform_records_by_record_ids', lambda conn, record_ids: platform_records) 198 monkeypatch.setattr(runner, 'fetch_platform_records_by_record_ids', lambda conn, record_ids: platform_records)
100 monkeypatch.setattr(runner, 'fetch_kugou_songs', lambda conn, song_ids: { 199 monkeypatch.setattr(runner, 'fetch_kugou_songs', lambda conn, song_ids: {
101 200: {'id': 200}, 200 200: {'id': 200, 'lyric': '歌词', 'cover': 'https://example.com/cover.jpg'},
102 }) 201 })
103 monkeypatch.setattr(runner, 'fetch_kugou_singers', lambda conn, song_ids: {}) 202 monkeypatch.setattr(runner, 'fetch_kugou_singers', lambda conn, song_ids: {})
104 monkeypatch.setattr(runner, '_prepare_import_payload', prepare) 203 monkeypatch.setattr(runner, '_prepare_import_payload', prepare)
...@@ -108,7 +207,6 @@ def test_run_imports_only_pending_yinyan_platform_record(monkeypatch): ...@@ -108,7 +207,6 @@ def test_run_imports_only_pending_yinyan_platform_record(monkeypatch):
108 207
109 prepare.assert_called_once() 208 prepare.assert_called_once()
110 write_payloads.assert_called_once() 209 write_payloads.assert_called_once()
111 runner.fetch_all_platform_records.assert_not_called()
112 assert pg_conn.commits == 1 210 assert pg_conn.commits == 1
113 211
114 212
...@@ -213,6 +311,7 @@ def test_process_qq_builds_album_json_for_song_insert(monkeypatch): ...@@ -213,6 +311,7 @@ def test_process_qq_builds_album_json_for_song_insert(monkeypatch):
213 monkeypatch.setattr(runner, 'fetch_qq_singers', lambda conn, song_ids: {}) 311 monkeypatch.setattr(runner, 'fetch_qq_singers', lambda conn, song_ids: {})
214 monkeypatch.setattr(runner, '_safe_transfer', lambda url, oss_key, bucket, base_url: url) 312 monkeypatch.setattr(runner, '_safe_transfer', lambda url, oss_key, bucket, base_url: url)
215 monkeypatch.setattr(runner, '_safe_transfer_audio', lambda url, oss_key, bucket, base_url: (url, 'audio-md5')) 313 monkeypatch.setattr(runner, '_safe_transfer_audio', lambda url, oss_key, bucket, base_url: (url, 'audio-md5'))
314 monkeypatch.setattr(runner, '_safe_upload_lyric', lambda *args: 'https://bucket.example.com/lyric.txt')
216 monkeypatch.setattr(runner, 'upsert_qq_singers', lambda cur, singers: None) 315 monkeypatch.setattr(runner, 'upsert_qq_singers', lambda cur, singers: None)
217 monkeypatch.setattr(runner, 'upsert_qq_albums', lambda cur, albums: None) 316 monkeypatch.setattr(runner, 'upsert_qq_albums', lambda cur, albums: None)
218 monkeypatch.setattr(runner, 'upsert_qq_songs', lambda cur, songs: inserted_songs.extend(songs)) 317 monkeypatch.setattr(runner, 'upsert_qq_songs', lambda cur, songs: inserted_songs.extend(songs))
...@@ -275,6 +374,7 @@ def test_process_netease_builds_album_json_for_song_insert(monkeypatch): ...@@ -275,6 +374,7 @@ def test_process_netease_builds_album_json_for_song_insert(monkeypatch):
275 monkeypatch.setattr(runner, 'fetch_netease_singers', lambda conn, song_ids: {}) 374 monkeypatch.setattr(runner, 'fetch_netease_singers', lambda conn, song_ids: {})
276 monkeypatch.setattr(runner, '_safe_transfer', lambda url, oss_key, bucket, base_url: url) 375 monkeypatch.setattr(runner, '_safe_transfer', lambda url, oss_key, bucket, base_url: url)
277 monkeypatch.setattr(runner, '_safe_transfer_audio', lambda url, oss_key, bucket, base_url: (url, 'audio-md5')) 376 monkeypatch.setattr(runner, '_safe_transfer_audio', lambda url, oss_key, bucket, base_url: (url, 'audio-md5'))
377 monkeypatch.setattr(runner, '_safe_upload_lyric', lambda *args: 'https://bucket.example.com/lyric.txt')
278 monkeypatch.setattr(runner, 'upsert_netease_singers', lambda cur, singers: None) 378 monkeypatch.setattr(runner, 'upsert_netease_singers', lambda cur, singers: None)
279 monkeypatch.setattr(runner, 'upsert_netease_albums', lambda cur, albums: None) 379 monkeypatch.setattr(runner, 'upsert_netease_albums', lambda cur, albums: None)
280 monkeypatch.setattr(runner, 'upsert_netease_songs', lambda cur, songs: inserted_songs.extend(songs)) 380 monkeypatch.setattr(runner, 'upsert_netease_songs', lambda cur, songs: inserted_songs.extend(songs))
...@@ -317,6 +417,7 @@ def test_process_netease_uses_prefetched_song_and_singers(monkeypatch): ...@@ -317,6 +417,7 @@ def test_process_netease_uses_prefetched_song_and_singers(monkeypatch):
317 monkeypatch.setattr(runner, 'fetch_netease_singers', fetch_singers) 417 monkeypatch.setattr(runner, 'fetch_netease_singers', fetch_singers)
318 monkeypatch.setattr(runner, '_safe_transfer', lambda url, oss_key, bucket, base_url: url) 418 monkeypatch.setattr(runner, '_safe_transfer', lambda url, oss_key, bucket, base_url: url)
319 monkeypatch.setattr(runner, '_safe_transfer_audio', lambda url, oss_key, bucket, base_url: (url, 'audio-md5')) 419 monkeypatch.setattr(runner, '_safe_transfer_audio', lambda url, oss_key, bucket, base_url: (url, 'audio-md5'))
420 monkeypatch.setattr(runner, '_safe_upload_lyric', lambda *args: 'https://bucket.example.com/lyric.txt')
320 monkeypatch.setattr(runner, 'upsert_netease_singers', lambda cur, singers: None) 421 monkeypatch.setattr(runner, 'upsert_netease_singers', lambda cur, singers: None)
321 monkeypatch.setattr(runner, 'upsert_netease_albums', lambda cur, albums: None) 422 monkeypatch.setattr(runner, 'upsert_netease_albums', lambda cur, albums: None)
322 monkeypatch.setattr(runner, 'upsert_netease_songs', lambda cur, songs: inserted_songs.extend(songs)) 423 monkeypatch.setattr(runner, 'upsert_netease_songs', lambda cur, songs: inserted_songs.extend(songs))
......
...@@ -15,6 +15,9 @@ from etl_to_crawler.writer import ( ...@@ -15,6 +15,9 @@ from etl_to_crawler.writer import (
15 upsert_qq_songs, 15 upsert_qq_songs,
16 upsert_qq_singer_songs, 16 upsert_qq_singer_songs,
17 upsert_yinyan_song_records, 17 upsert_yinyan_song_records,
18 replace_qq_song,
19 delete_qq_song_and_yinyan_record,
20 mark_yinyan_record_resource_failed,
18 ) 21 )
19 22
20 23
...@@ -31,6 +34,44 @@ def test_upsert_qq_singers_executes_insert(): ...@@ -31,6 +34,44 @@ def test_upsert_qq_singers_executes_insert():
31 assert 'ON CONFLICT' in args[0][0] 34 assert 'ON CONFLICT' in args[0][0]
32 35
33 36
37 def test_replace_qq_song_updates_existing_row_by_old_platform_song_id():
38 cur = MagicMock()
39 replace_qq_song(cur, 100, {
40 'platform_song_id': 200, 'mid': 'new-mid', 'album_id': 20,
41 'cover': 'https://oss.example/cover.jpg', 'title': '标题', 'name': '歌名',
42 'duration': 180, 'singers_json': '[]', 'provider_name': 'yinyan',
43 'crawler_source_data': '{}',
44 })
45
46 sql, params = cur.execute.call_args[0]
47 assert 'UPDATE crawler_qqmusic_songs' in sql
48 assert 'platform_song_id = %s' in sql
49 assert params[-1] == 100
50 assert params[0] == 200
51
52
53 def test_delete_qq_song_and_yinyan_record_cleans_song_relations_and_state():
54 cur = MagicMock()
55 delete_qq_song_and_yinyan_record(cur, 10, 100, 'song-uuid')
56
57 assert cur.execute.call_count == 3
58 calls = cur.execute.call_args_list
59 assert 'crawler_qqmusic_singer_songs' in calls[0].args[0]
60 assert 'crawler_qqmusic_songs' in calls[1].args[0]
61 assert 'yinyan_song_records' in calls[2].args[0]
62
63
64 def test_mark_yinyan_record_resource_failed_keeps_unpushed_state_and_excludes_retry():
65 cur = MagicMock()
66 mark_yinyan_record_resource_failed(cur, 10, 100, '1')
67
68 sql, params = cur.execute.call_args[0]
69 assert 'UPDATE yinyan_song_records' in sql
70 assert 'is_yinyan_push = FALSE' in sql
71 assert 'recording_id = %s' in sql
72 assert params == ('resource_transfer_failed', 10, 100, '1')
73
74
34 def test_upsert_qq_singers_empty_does_nothing(): 75 def test_upsert_qq_singers_empty_does_nothing():
35 cur = MagicMock() 76 cur = MagicMock()
36 upsert_qq_singers(cur, []) 77 upsert_qq_singers(cur, [])
...@@ -103,7 +144,7 @@ def test_fetch_pending_yinyan_song_records_reads_platform_for_single_platform_im ...@@ -103,7 +144,7 @@ def test_fetch_pending_yinyan_song_records_reads_platform_for_single_platform_im
103 sql, params = cur.execute.call_args[0] 144 sql, params = cur.execute.call_args[0]
104 assert 'SELECT song_id, record_id, platform' in sql 145 assert 'SELECT song_id, record_id, platform' in sql
105 assert 'platform IS NOT NULL' in sql 146 assert 'platform IS NOT NULL' in sql
106 assert params == (500,) 147 assert params == ('resource_transfer_failed', 500)
107 assert rows == [ 148 assert rows == [
108 {'song_id': 10, 'record_id': 100, 'platform': '1'}, 149 {'song_id': 10, 'record_id': 100, 'platform': '1'},
109 {'song_id': 11, 'record_id': 101, 'platform': '2'}, 150 {'song_id': 11, 'record_id': 101, 'platform': '2'},
......