Commit f29de156 f29de156ac9941fb2ff69d777d531b45e0121626 by 沈秋雨

refactor(etl): 优化录音处理及OSS下载URL重写逻辑

- 配置新增公共访问及下载重写基础URL变量,支持下载地址重写
- oss模块新增内部下载地址重写函数,改进下载时的URL处理逻辑
- transfer_url函数判断是否为目标OSS桶URL,避免重复上传
- runner模块改为仅处理待推送yinyan_song_records对应的单条录音
- 精简runner中对录音记录的查询与处理逻辑,提升性能及正确性
- writer模块fetch_pending_yinyan_song_records增加platform字段过滤
- 新增单元测试覆盖OSS下载地址重写和runner的录音导入逻辑调整
1 parent 98b4f92e
...@@ -35,7 +35,12 @@ OSS_CONFIG = { ...@@ -35,7 +35,12 @@ OSS_CONFIG = {
35 'access_key_secret': os.environ['OSS_ACCESS_KEY_SECRET'], 35 'access_key_secret': os.environ['OSS_ACCESS_KEY_SECRET'],
36 'endpoint': os.environ['OSS_ENDPOINT'], 36 'endpoint': os.environ['OSS_ENDPOINT'],
37 'bucket_name': os.environ['OSS_BUCKET_NAME'], 37 'bucket_name': os.environ['OSS_BUCKET_NAME'],
38 'base_url': f"https://{os.environ['OSS_BUCKET_NAME']}.{os.environ['OSS_ENDPOINT']}", 38 'base_url': os.environ.get(
39 'OSS_PUBLIC_BASE_URL',
40 f"https://{os.environ['OSS_BUCKET_NAME']}.{os.environ['OSS_ENDPOINT']}",
41 ).rstrip('/'),
42 'download_base_url': os.environ.get('OSS_DOWNLOAD_BASE_URL', '').rstrip('/'),
43 'download_rewrite_from_base_url': os.environ.get('OSS_DOWNLOAD_REWRITE_FROM_BASE_URL', '').rstrip('/'),
39 } 44 }
40 45
41 PLATFORM_QQ = '1' 46 PLATFORM_QQ = '1'
......
1 import requests 1 import requests
2 import oss2 2 import oss2
3 from urllib.parse import urlparse 3 from urllib.parse import urlparse
4 from .config import OSS_CONFIG
4 from .utils import compute_audio_md5 5 from .utils import compute_audio_md5
5 6
6 ARCHIVE_DEV_HOST = 'archive-dev.oss-cn-beijing.aliyuncs.com' 7
8 def _host(url: str | None) -> str:
9 return urlparse(url or '').netloc.lower()
10
11
12 def _is_target_oss_url(url: str | None, base_url: str) -> bool:
13 return bool(url) and _host(url) == _host(base_url)
14
15
16 def _download_url(url: str, base_url: str) -> str:
17 """Optionally rewrite target-bucket downloads to an internal/CNAME base URL."""
18 download_base_url = OSS_CONFIG.get('download_base_url')
19 rewrite_from = OSS_CONFIG.get('download_rewrite_from_base_url') or base_url
20 if not download_base_url or not url.startswith(rewrite_from.rstrip('/') + '/'):
21 return url
22 return f"{download_base_url.rstrip('/')}{url[len(rewrite_from.rstrip('/')):]}"
7 23
8 24
9 def transfer_url(url: str | None, oss_key: str, bucket: oss2.Bucket, base_url: str) -> str: 25 def transfer_url(url: str | None, oss_key: str, bucket: oss2.Bucket, base_url: str) -> str:
10 """ 26 """
11 将 url 指向的文件转移到 archive-dev OSS 的 oss_key 路径。 27 将 url 指向的文件转移到 archive-dev OSS 的 oss_key 路径。
12 若 url 为空或已在 archive-dev,直接返回原 url(不上传)。 28 若 url 为空或已在目标 bucket,直接返回原 url(不上传)。
13 返回新的 archive-dev URL。 29 返回新的公开访问 URL。
14 """ 30 """
15 if not url: 31 if not url:
16 return '' 32 return ''
17 if ARCHIVE_DEV_HOST in url: 33 if _is_target_oss_url(url, base_url):
18 return url 34 return url
19 resp = requests.get(url, timeout=30) 35 resp = requests.get(_download_url(url, base_url), timeout=30)
20 resp.raise_for_status() 36 resp.raise_for_status()
21 bucket.put_object(oss_key, resp.content) 37 bucket.put_object(oss_key, resp.content)
22 return f"{base_url.rstrip('/')}/{oss_key}" 38 return f"{base_url.rstrip('/')}/{oss_key}"
...@@ -29,9 +45,9 @@ def transfer_url_with_md5(url: str | None, oss_key: str, bucket: oss2.Bucket, ba ...@@ -29,9 +45,9 @@ def transfer_url_with_md5(url: str | None, oss_key: str, bucket: oss2.Bucket, ba
29 """ 45 """
30 if not url: 46 if not url:
31 return '', '' 47 return '', ''
32 if ARCHIVE_DEV_HOST in url: 48 if _is_target_oss_url(url, base_url):
33 return url, '' 49 return url, ''
34 resp = requests.get(url, timeout=30) 50 resp = requests.get(_download_url(url, base_url), timeout=30)
35 resp.raise_for_status() 51 resp.raise_for_status()
36 audio_md5 = compute_audio_md5(resp.content) 52 audio_md5 = compute_audio_md5(resp.content)
37 bucket.put_object(oss_key, resp.content) 53 bucket.put_object(oss_key, resp.content)
......
...@@ -609,57 +609,52 @@ def run( ...@@ -609,57 +609,52 @@ def run(
609 609
610 song_ids = [int(r['song_id']) for r in pending_records] 610 song_ids = [int(r['song_id']) for r in pending_records]
611 hk_by_song = fetch_hk_songs_by_source_ids(hk_conn, song_ids) 611 hk_by_song = fetch_hk_songs_by_source_ids(hk_conn, song_ids)
612 # 全部录音(不去重),供 singer fallback 遍历
613 all_platform_records = fetch_all_platform_records(src_conn, song_ids) 612 all_platform_records = fetch_all_platform_records(src_conn, song_ids)
614 613 pr_by_state_key: dict[tuple[int, int, str], dict] = {}
615 # 按 (song_id, platform) 分组,每组已按优先级排好序
616 pr_by_song_platform: dict[tuple, list] = {}
617 for pr in all_platform_records: 614 for pr in all_platform_records:
618 if pr['platform'] in platforms: 615 if pr['platform'] in platforms:
619 key = (int(pr['source_song_id']), pr['platform']) 616 key = (int(pr['source_song_id']), int(pr['record_id']), pr['platform'])
620 pr_by_song_platform.setdefault(key, []).append(pr) 617 pr_by_state_key[key] = pr
621
622 # 每首歌:从各平台各选一条最优录音(有歌手优先)
623 pr_by_song: dict[int, list] = {}
624 for (src_id, _platform), records in pr_by_song_platform.items():
625 chosen = _pick_record_with_singer(records, spider_conn)
626 if chosen:
627 pr_by_song.setdefault(src_id, []).append(chosen)
628 618
629 with pg_conn.cursor() as pg_cur: 619 with pg_conn.cursor() as pg_cur:
630 pushed_count = 0 620 pushed_count = 0
631 for src_id in song_ids: 621 for pending in pending_records:
622 src_id = int(pending['song_id'])
623 platform = str(pending['platform'])
632 hk_row = hk_by_song.get(src_id) 624 hk_row = hk_by_song.get(src_id)
633 if not hk_row: 625 if not hk_row:
634 continue 626 continue
635 if not src_id or src_id not in pr_by_song: 627 if platform not in platforms:
628 continue
629 pr = pr_by_state_key.get((src_id, int(pending['record_id']), platform))
630 if not pr:
631 log.warning(
632 "Pending yinyan record not found in source records: song_id=%s record_id=%s platform=%s",
633 src_id, pending['record_id'], platform,
634 )
636 continue 635 continue
637 wrote_yinyan_record = False 636 processor = _PROCESSORS.get(platform)
638 for pr in pr_by_song[src_id]:
639 processor = _PROCESSORS.get(pr['platform'])
640 if not processor: 637 if not processor:
641 continue 638 continue
642 pg_cur.execute('SAVEPOINT sp_song') 639 pg_cur.execute('SAVEPOINT sp_song')
643 try: 640 try:
644 result = processor(hk_row, pr, spider_conn, pg_cur, bucket, base_url) 641 result = processor(hk_row, pr, spider_conn, pg_cur, bucket, base_url)
645 if result and not wrote_yinyan_record: 642 if result:
646 upsert_yinyan_song_records(pg_cur, [{ 643 upsert_yinyan_song_records(pg_cur, [{
647 'song_id': src_id, 644 'song_id': src_id,
648 'record_id': int(pr['record_id']), 645 'record_id': int(pr['record_id']),
649 'platform': pr['platform'], 646 'platform': platform,
650 'platform_song_id': int(result['platform_song_id']), 647 'platform_song_id': int(result['platform_song_id']),
651 }]) 648 }])
652 wrote_yinyan_record = True
653 pushed_count += 1 649 pushed_count += 1
650 imported.append(result)
654 pg_cur.execute('RELEASE SAVEPOINT sp_song') 651 pg_cur.execute('RELEASE SAVEPOINT sp_song')
655 total_ok += 1 652 total_ok += 1
656 if result:
657 imported.append(result)
658 except Exception as e: 653 except Exception as e:
659 pg_cur.execute('ROLLBACK TO SAVEPOINT sp_song') 654 pg_cur.execute('ROLLBACK TO SAVEPOINT sp_song')
660 pg_cur.execute('RELEASE SAVEPOINT sp_song') 655 pg_cur.execute('RELEASE SAVEPOINT sp_song')
661 log.error("Error processing song %s platform %s: %s", 656 log.error("Error processing song %s platform %s: %s",
662 hk_row.get('name'), pr['platform'], e) 657 hk_row.get('name'), platform, e)
663 total_err += 1 658 total_err += 1
664 pg_conn.commit() 659 pg_conn.commit()
665 if pushed_count == 0: 660 if pushed_count == 0:
......
...@@ -26,16 +26,17 @@ def fetch_existing_yinyan_song_ids(cur) -> set[int]: ...@@ -26,16 +26,17 @@ def fetch_existing_yinyan_song_ids(cur) -> set[int]:
26 def fetch_pending_yinyan_song_records(cur, limit: int) -> list[dict]: 26 def fetch_pending_yinyan_song_records(cur, limit: int) -> list[dict]:
27 cur.execute( 27 cur.execute(
28 """ 28 """
29 SELECT song_id, record_id 29 SELECT song_id, record_id, platform
30 FROM yinyan_song_records 30 FROM yinyan_song_records
31 WHERE is_yinyan_push = FALSE 31 WHERE is_yinyan_push = FALSE
32 AND platform IS NOT NULL
32 ORDER BY song_id 33 ORDER BY song_id
33 LIMIT %s 34 LIMIT %s
34 """, 35 """,
35 (limit,), 36 (limit,),
36 ) 37 )
37 rows = cur.fetchall() 38 rows = cur.fetchall()
38 return [{'song_id': row[0], 'record_id': row[1]} for row in rows] 39 return [{'song_id': row[0], 'record_id': row[1], 'platform': row[2]} for row in rows]
39 40
40 41
41 def fetch_yinyan_records_missing_platform(cur, limit: int) -> list[dict]: 42 def fetch_yinyan_records_missing_platform(cur, limit: int) -> list[dict]:
......
...@@ -33,6 +33,25 @@ def test_external_url_downloads_and_uploads(): ...@@ -33,6 +33,25 @@ def test_external_url_downloads_and_uploads():
33 assert result == f"{BASE_URL}/crawler/qq/audio/abc.mp3" 33 assert result == f"{BASE_URL}/crawler/qq/audio/abc.mp3"
34 34
35 35
36 def test_external_url_can_rewrite_download_base_to_internal_endpoint():
37 bucket = MagicMock()
38 public_source = "https://source-bucket.oss-cn-hangzhou.aliyuncs.com/audio/abc.mp3"
39 internal_source_base = "https://source-bucket.oss-cn-hangzhou-internal.aliyuncs.com"
40 fake_content = b"audio_bytes"
41 with patch.dict('etl_to_crawler.oss.OSS_CONFIG', {
42 'download_rewrite_from_base_url': 'https://source-bucket.oss-cn-hangzhou.aliyuncs.com',
43 'download_base_url': internal_source_base,
44 }):
45 with patch('etl_to_crawler.oss.requests.get') as mock_get:
46 mock_get.return_value.content = fake_content
47 mock_get.return_value.raise_for_status = MagicMock()
48 result = transfer_url(public_source, "crawler/qq/audio/abc.mp3", bucket, BASE_URL)
49
50 mock_get.assert_called_once_with(f"{internal_source_base}/audio/abc.mp3", timeout=30)
51 bucket.put_object.assert_called_once_with("crawler/qq/audio/abc.mp3", fake_content)
52 assert result == f"{BASE_URL}/crawler/qq/audio/abc.mp3"
53
54
36 def test_transfer_url_with_md5_hashes_downloaded_content_before_upload(): 55 def test_transfer_url_with_md5_hashes_downloaded_content_before_upload():
37 bucket = MagicMock() 56 bucket = MagicMock()
38 fake_content = b"audio_bytes" 57 fake_content = b"audio_bytes"
......
...@@ -35,7 +35,7 @@ class _Connection: ...@@ -35,7 +35,7 @@ class _Connection:
35 return None 35 return None
36 36
37 37
38 def test_run_imports_all_platform_records_but_writes_one_primary_yinyan_relation(monkeypatch): 38 def test_run_imports_only_pending_yinyan_platform_record(monkeypatch):
39 pg_conn = _PgConnection() 39 pg_conn = _PgConnection()
40 processors = { 40 processors = {
41 '1': MagicMock(return_value={'platform': 'qq', 'platform_song_id': 100, 'mid': 'qq-mid', 'title': '歌'}), 41 '1': MagicMock(return_value={'platform': 'qq', 'platform_song_id': 100, 'mid': 'qq-mid', 'title': '歌'}),
...@@ -49,7 +49,7 @@ def test_run_imports_all_platform_records_but_writes_one_primary_yinyan_relation ...@@ -49,7 +49,7 @@ def test_run_imports_all_platform_records_but_writes_one_primary_yinyan_relation
49 monkeypatch.setattr(runner, 'get_pg_conn', lambda: pg_conn) 49 monkeypatch.setattr(runner, 'get_pg_conn', lambda: pg_conn)
50 monkeypatch.setattr(runner, 'get_oss_bucket', lambda: object()) 50 monkeypatch.setattr(runner, 'get_oss_bucket', lambda: object())
51 monkeypatch.setattr(runner, 'fetch_pending_yinyan_song_records', lambda cur, batch_size: [ 51 monkeypatch.setattr(runner, 'fetch_pending_yinyan_song_records', lambda cur, batch_size: [
52 {'song_id': 10, 'record_id': 200}, 52 {'song_id': 10, 'record_id': 200, 'platform': '2'},
53 ] if pg_conn.commits == 0 else []) 53 ] if pg_conn.commits == 0 else [])
54 monkeypatch.setattr(runner, 'fetch_hk_songs_by_source_ids', lambda conn, song_ids: {10: { 54 monkeypatch.setattr(runner, 'fetch_hk_songs_by_source_ids', lambda conn, song_ids: {10: {
55 'source_song_id': 10, 55 'source_song_id': 10,
...@@ -83,19 +83,18 @@ def test_run_imports_all_platform_records_but_writes_one_primary_yinyan_relation ...@@ -83,19 +83,18 @@ def test_run_imports_all_platform_records_but_writes_one_primary_yinyan_relation
83 ] 83 ]
84 monkeypatch.setattr(runner, 'fetch_platform_records', lambda conn, song_ids: platform_records) 84 monkeypatch.setattr(runner, 'fetch_platform_records', lambda conn, song_ids: platform_records)
85 monkeypatch.setattr(runner, 'fetch_all_platform_records', lambda conn, song_ids: platform_records) 85 monkeypatch.setattr(runner, 'fetch_all_platform_records', lambda conn, song_ids: platform_records)
86 monkeypatch.setattr(runner, '_pick_record_with_singer', lambda records, spider_conn: records[0])
87 monkeypatch.setattr(runner, '_PROCESSORS', processors) 86 monkeypatch.setattr(runner, '_PROCESSORS', processors)
88 monkeypatch.setattr(runner, 'upsert_yinyan_song_records', yinyan_writer) 87 monkeypatch.setattr(runner, 'upsert_yinyan_song_records', yinyan_writer)
89 88
90 runner.run(['1', '2']) 89 runner.run(['1', '2'])
91 90
92 processors['1'].assert_called_once() 91 processors['1'].assert_not_called()
93 processors['2'].assert_called_once() 92 processors['2'].assert_called_once()
94 yinyan_writer.assert_called_once_with(pg_conn.cur, [{ 93 yinyan_writer.assert_called_once_with(pg_conn.cur, [{
95 'song_id': 10, 94 'song_id': 10,
96 'record_id': 100, 95 'record_id': 200,
97 'platform': '1', 96 'platform': '2',
98 'platform_song_id': 100, 97 'platform_song_id': 200,
99 }]) 98 }])
100 assert pg_conn.commits == 1 99 assert pg_conn.commits == 1
101 100
......
1 from unittest.mock import MagicMock, call 1 from unittest.mock import MagicMock, call
2 from etl_to_crawler.writer import ( 2 from etl_to_crawler.writer import (
3 fetch_pending_yinyan_song_records,
3 fetch_yinyan_records_missing_platform, 4 fetch_yinyan_records_missing_platform,
4 insert_yinyan_song_records, 5 insert_yinyan_song_records,
5 update_yinyan_record_platforms, 6 update_yinyan_record_platforms,
...@@ -90,6 +91,22 @@ def test_fetch_yinyan_records_missing_platform_reads_null_platform_rows(): ...@@ -90,6 +91,22 @@ def test_fetch_yinyan_records_missing_platform_reads_null_platform_rows():
90 assert rows == [{'song_id': 10, 'record_id': 100}, {'song_id': 11, 'record_id': 101}] 91 assert rows == [{'song_id': 10, 'record_id': 100}, {'song_id': 11, 'record_id': 101}]
91 92
92 93
94 def test_fetch_pending_yinyan_song_records_reads_platform_for_single_platform_import():
95 cur = MagicMock()
96 cur.fetchall.return_value = [(10, 100, '1'), (11, 101, '2')]
97
98 rows = fetch_pending_yinyan_song_records(cur, 500)
99
100 sql, params = cur.execute.call_args[0]
101 assert 'SELECT song_id, record_id, platform' in sql
102 assert 'platform IS NOT NULL' in sql
103 assert params == (500,)
104 assert rows == [
105 {'song_id': 10, 'record_id': 100, 'platform': '1'},
106 {'song_id': 11, 'record_id': 101, 'platform': '2'},
107 ]
108
109
93 def test_update_yinyan_record_platforms_fills_only_null_platform_rows(): 110 def test_update_yinyan_record_platforms_fills_only_null_platform_rows():
94 cur = MagicMock() 111 cur = MagicMock()
95 update_yinyan_record_platforms(cur, [ 112 update_yinyan_record_platforms(cur, [
......