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的录音导入逻辑调整
Showing
7 changed files
with
93 additions
and
41 deletions
| ... | @@ -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, [ | ... | ... |
-
Please register or sign in to post a comment