Merge branch 'feat/etl-hk-songs-to-crawler'
Showing
18 changed files
with
822 additions
and
7 deletions
etl_to_crawler/__init__.py
0 → 100644
File mode changed
etl_to_crawler/config.py
0 → 100644
| 1 | import os | ||
| 2 | from dotenv import load_dotenv | ||
| 3 | |||
| 4 | load_dotenv() | ||
| 5 | |||
| 6 | SOURCE_DB = { | ||
| 7 | 'host': os.environ['SOURCE_DB_HOST'], | ||
| 8 | 'port': int(os.environ['SOURCE_DB_PORT']), | ||
| 9 | 'user': os.environ['SOURCE_DB_USER'], | ||
| 10 | 'password': os.environ['SOURCE_DB_PASSWORD'], | ||
| 11 | 'database': os.environ['SOURCE_DB_NAME'], | ||
| 12 | 'charset': 'utf8mb4', | ||
| 13 | } | ||
| 14 | |||
| 15 | HK_SONGS_DB = { | ||
| 16 | 'host': os.environ['TARGET_DB_HOST'], | ||
| 17 | 'port': int(os.environ['TARGET_DB_PORT']), | ||
| 18 | 'user': os.environ['TARGET_DB_USER'], | ||
| 19 | 'password': os.environ['TARGET_DB_PASSWORD'], | ||
| 20 | 'database': os.environ['TARGET_DB_NAME'], | ||
| 21 | 'charset': 'utf8mb4', | ||
| 22 | } | ||
| 23 | |||
| 24 | CRAWLER_DB = { | ||
| 25 | 'host': os.environ['CRAWLER_DB_HOST'], | ||
| 26 | 'port': int(os.environ.get('CRAWLER_DB_PORT', '5432')), | ||
| 27 | 'user': os.environ['CRAWLER_DB_USER'], | ||
| 28 | 'password': os.environ['CRAWLER_DB_PASSWORD'], | ||
| 29 | 'database': os.environ['CRAWLER_DB_NAME'], | ||
| 30 | 'ssl_context': None if os.environ.get('CRAWLER_DB_SSL', 'false').lower() != 'true' else True, | ||
| 31 | } | ||
| 32 | |||
| 33 | OSS_CONFIG = { | ||
| 34 | 'access_key_id': os.environ['OSS_ACCESS_KEY_ID'], | ||
| 35 | 'access_key_secret': os.environ['OSS_ACCESS_KEY_SECRET'], | ||
| 36 | 'endpoint': os.environ['OSS_ENDPOINT'], | ||
| 37 | 'bucket_name': os.environ['OSS_BUCKET_NAME'], | ||
| 38 | 'base_url': f"https://{os.environ['OSS_BUCKET_NAME']}.{os.environ['OSS_ENDPOINT']}", | ||
| 39 | } | ||
| 40 | |||
| 41 | PLATFORM_QQ = '1' | ||
| 42 | PLATFORM_KUGOU = '2' | ||
| 43 | PLATFORM_NETEASE = '4' | ||
| 44 | PLATFORMS = [PLATFORM_QQ, PLATFORM_KUGOU, PLATFORM_NETEASE] | ||
| 45 | |||
| 46 | BATCH_SIZE = 3 |
etl_to_crawler/connections.py
0 → 100644
| 1 | import pymysql | ||
| 2 | import pymysql.cursors | ||
| 3 | import pg8000 | ||
| 4 | import oss2 | ||
| 5 | from .config import SOURCE_DB, HK_SONGS_DB, CRAWLER_DB, OSS_CONFIG | ||
| 6 | |||
| 7 | |||
| 8 | def get_source_conn() -> pymysql.Connection: | ||
| 9 | return pymysql.connect(**SOURCE_DB, cursorclass=pymysql.cursors.DictCursor) | ||
| 10 | |||
| 11 | |||
| 12 | def get_spider_conn() -> pymysql.Connection: | ||
| 13 | cfg = SOURCE_DB.copy() | ||
| 14 | cfg['database'] = 'hikoon-data-spider' | ||
| 15 | return pymysql.connect(**cfg, cursorclass=pymysql.cursors.DictCursor) | ||
| 16 | |||
| 17 | |||
| 18 | def get_hk_songs_conn() -> pymysql.Connection: | ||
| 19 | return pymysql.connect(**HK_SONGS_DB, cursorclass=pymysql.cursors.DictCursor) | ||
| 20 | |||
| 21 | |||
| 22 | def get_pg_conn() -> pg8000.Connection: | ||
| 23 | return pg8000.connect(**CRAWLER_DB) | ||
| 24 | |||
| 25 | |||
| 26 | def get_oss_bucket() -> oss2.Bucket: | ||
| 27 | auth = oss2.Auth(OSS_CONFIG['access_key_id'], OSS_CONFIG['access_key_secret']) | ||
| 28 | return oss2.Bucket(auth, OSS_CONFIG['endpoint'], OSS_CONFIG['bucket_name']) |
etl_to_crawler/lyric.py
0 → 100644
| 1 | import re | ||
| 2 | |||
| 3 | _TIMESTAMP_RE = re.compile(r'\[\d{2}:\d{2}\.\d{2,3}\]') | ||
| 4 | _META_TAG_RE = re.compile(r'\[[a-zA-Z]+:[^\]]*\]') | ||
| 5 | |||
| 6 | |||
| 7 | def strip_timestamps(text: str | None) -> str: | ||
| 8 | if not text: | ||
| 9 | return '' | ||
| 10 | text = _META_TAG_RE.sub('', text) | ||
| 11 | text = _TIMESTAMP_RE.sub('', text) | ||
| 12 | lines = [line.strip() for line in text.splitlines() if line.strip()] | ||
| 13 | return '\n'.join(lines) |
etl_to_crawler/oss.py
0 → 100644
| 1 | import requests | ||
| 2 | import oss2 | ||
| 3 | from urllib.parse import urlparse | ||
| 4 | |||
| 5 | ARCHIVE_DEV_HOST = 'archive-dev.oss-cn-beijing.aliyuncs.com' | ||
| 6 | |||
| 7 | |||
| 8 | def transfer_url(url: str | None, oss_key: str, bucket: oss2.Bucket, base_url: str) -> str: | ||
| 9 | """ | ||
| 10 | 将 url 指向的文件转移到 archive-dev OSS 的 oss_key 路径。 | ||
| 11 | 若 url 为空或已在 archive-dev,直接返回原 url(不上传)。 | ||
| 12 | 返回新的 archive-dev URL。 | ||
| 13 | """ | ||
| 14 | if not url: | ||
| 15 | return '' | ||
| 16 | if ARCHIVE_DEV_HOST in url: | ||
| 17 | return url | ||
| 18 | resp = requests.get(url, timeout=30) | ||
| 19 | resp.raise_for_status() | ||
| 20 | bucket.put_object(oss_key, resp.content) | ||
| 21 | return f"{base_url.rstrip('/')}/{oss_key}" | ||
| 22 | |||
| 23 | |||
| 24 | def build_oss_key(platform: str, category: str, filename: str) -> str: | ||
| 25 | """ | ||
| 26 | 构造 archive-dev 内的存储路径。 | ||
| 27 | platform: 'qq' | 'kugou' | 'netease' | ||
| 28 | category: 'audio' | 'cover' | 'singer' | 'album' | ||
| 29 | filename: 带扩展名的文件名 | ||
| 30 | """ | ||
| 31 | return f"crawler/{platform}/{category}/{filename}" |
etl_to_crawler/reader.py
0 → 100644
| 1 | from typing import Iterator | ||
| 2 | import pymysql | ||
| 3 | from .config import PLATFORMS | ||
| 4 | |||
| 5 | |||
| 6 | _HK_SONGS_QUERY = """ | ||
| 7 | SELECT id, name, lyricist, composer, audio_url, lyrics_url, | ||
| 8 | cover_url, singer, issue_time, source_song_id, song_time | ||
| 9 | FROM hk_songs_test | ||
| 10 | WHERE deleted = '0' | ||
| 11 | AND id > %s | ||
| 12 | AND name IS NOT NULL AND name != '' | ||
| 13 | AND audio_url IS NOT NULL AND audio_url != '' | ||
| 14 | AND singer IS NOT NULL AND singer != '' | ||
| 15 | ORDER BY id | ||
| 16 | LIMIT %s | ||
| 17 | """ | ||
| 18 | |||
| 19 | _PLATFORM_QUERY = """ | ||
| 20 | SELECT | ||
| 21 | sar.song_id AS source_song_id, | ||
| 22 | mr.id AS record_id, | ||
| 23 | mr.platform, | ||
| 24 | mr.platform_unique_key, | ||
| 25 | mr.platform_mid, | ||
| 26 | mr.album_audio_id, | ||
| 27 | sar.is_main_version, | ||
| 28 | mr.is_high, | ||
| 29 | mr.pub_time | ||
| 30 | FROM hk_song_and_record sar | ||
| 31 | JOIN hk_music_record mr ON mr.id = sar.record_id | ||
| 32 | WHERE sar.song_id IN ({placeholders}) | ||
| 33 | AND mr.platform IN ('1','2','4') | ||
| 34 | AND mr.platform_unique_key IS NOT NULL | ||
| 35 | AND mr.platform_unique_key != '' | ||
| 36 | AND mr.deleted = 0 | ||
| 37 | ORDER BY sar.song_id, | ||
| 38 | COALESCE(sar.is_main_version, 0) DESC, | ||
| 39 | COALESCE(mr.is_high, 0) DESC, | ||
| 40 | (mr.pub_time IS NULL) ASC, | ||
| 41 | mr.pub_time ASC, | ||
| 42 | mr.id ASC | ||
| 43 | """ | ||
| 44 | |||
| 45 | |||
| 46 | def iter_hk_songs_batches( | ||
| 47 | conn: pymysql.Connection, | ||
| 48 | batch_size: int, | ||
| 49 | start_after_id: int = 0, | ||
| 50 | ) -> Iterator[list[dict]]: | ||
| 51 | last_id = start_after_id | ||
| 52 | with conn.cursor() as cur: | ||
| 53 | while True: | ||
| 54 | cur.execute(_HK_SONGS_QUERY, (last_id, batch_size)) | ||
| 55 | rows = cur.fetchall() | ||
| 56 | if not rows: | ||
| 57 | break | ||
| 58 | yield rows | ||
| 59 | last_id = int(rows[-1]['id']) | ||
| 60 | if len(rows) < batch_size: | ||
| 61 | break | ||
| 62 | |||
| 63 | |||
| 64 | def fetch_platform_records(source_conn: pymysql.Connection, song_ids: list[int]) -> list[dict]: | ||
| 65 | if not song_ids: | ||
| 66 | return [] | ||
| 67 | placeholders = ','.join(['%s'] * len(song_ids)) | ||
| 68 | query = _PLATFORM_QUERY.format(placeholders=placeholders) | ||
| 69 | with source_conn.cursor() as cur: | ||
| 70 | cur.execute(query, song_ids) | ||
| 71 | rows = cur.fetchall() | ||
| 72 | |||
| 73 | return select_platform_records(rows) | ||
| 74 | |||
| 75 | |||
| 76 | def select_platform_records(rows: list[dict]) -> list[dict]: | ||
| 77 | # 每个 (source_song_id, platform) 保留一条,用于继续导入多个平台的录音数据。 | ||
| 78 | seen = {} | ||
| 79 | for row in sorted(rows, key=_record_priority): | ||
| 80 | key = (row['source_song_id'], row['platform']) | ||
| 81 | if key not in seen: | ||
| 82 | seen[key] = row | ||
| 83 | return list(seen.values()) | ||
| 84 | |||
| 85 | |||
| 86 | def select_primary_record(rows: list[dict]) -> dict | None: | ||
| 87 | if not rows: | ||
| 88 | return None | ||
| 89 | return sorted(rows, key=_record_priority)[0] | ||
| 90 | |||
| 91 | |||
| 92 | def _record_priority(row: dict) -> tuple: | ||
| 93 | pub_time = row.get('pub_time') | ||
| 94 | return ( | ||
| 95 | row['source_song_id'], | ||
| 96 | -(int(row.get('is_main_version') or 0)), | ||
| 97 | -(int(row.get('is_high') or 0)), | ||
| 98 | pub_time is None, | ||
| 99 | pub_time or '', | ||
| 100 | int(row.get('record_id') or 0), | ||
| 101 | ) |
etl_to_crawler/runner.py
0 → 100644
This diff is collapsed.
Click to expand it.
etl_to_crawler/spider.py
0 → 100644
| 1 | import pymysql | ||
| 2 | |||
| 3 | |||
| 4 | def _ids_query(query_template: str, ids: list, conn: pymysql.Connection) -> list[dict]: | ||
| 5 | if not ids: | ||
| 6 | return [] | ||
| 7 | placeholders = ','.join(['%s'] * len(ids)) | ||
| 8 | query = query_template.format(placeholders=placeholders) | ||
| 9 | with conn.cursor() as cur: | ||
| 10 | cur.execute(query, ids) | ||
| 11 | return cur.fetchall() | ||
| 12 | |||
| 13 | |||
| 14 | # ─── QQ Music ──────────────────────────────────────────────────────────────── | ||
| 15 | |||
| 16 | _QQ_SONGS_SQL = """ | ||
| 17 | SELECT s.id, s.mid, s.album_id, s.cover, s.title, s.duration, | ||
| 18 | s.lyric, s.composer_name, s.lyricist_name, s.platform_index_url, s.published_at, | ||
| 19 | a.mid AS album_mid, a.cover AS album_cover, a.title AS album_title, | ||
| 20 | a.intro AS album_intro, a.type AS album_type, a.company_id, | ||
| 21 | a.company, a.is_owner, a.published_at AS album_published_at | ||
| 22 | FROM media_tencent_songs s | ||
| 23 | LEFT JOIN media_tencent_albums a ON a.id = s.album_id | ||
| 24 | WHERE s.mid IN ({placeholders}) | ||
| 25 | """ | ||
| 26 | |||
| 27 | _QQ_SINGERS_SQL = """ | ||
| 28 | SELECT shs.song_id, shs.singer_id, sg.mid, sg.name, sg.avatar, | ||
| 29 | sg.sex, sg.area, sg.`index`, sg.intro, sg.home_url | ||
| 30 | FROM media_tencent_singer_has_songs shs | ||
| 31 | JOIN media_tencent_singers sg ON sg.id = shs.singer_id | ||
| 32 | WHERE shs.song_id IN ({placeholders}) | ||
| 33 | """ | ||
| 34 | |||
| 35 | |||
| 36 | def fetch_qq_songs(conn: pymysql.Connection, mids: list[str]) -> dict[str, dict]: | ||
| 37 | rows = _ids_query(_QQ_SONGS_SQL, mids, conn) | ||
| 38 | return {row['mid']: row for row in rows} | ||
| 39 | |||
| 40 | |||
| 41 | def fetch_qq_singers(conn: pymysql.Connection, song_ids: list[int]) -> dict[int, list[dict]]: | ||
| 42 | rows = _ids_query(_QQ_SINGERS_SQL, song_ids, conn) | ||
| 43 | result: dict[int, list] = {} | ||
| 44 | for row in rows: | ||
| 45 | result.setdefault(row['song_id'], []).append(row) | ||
| 46 | return result | ||
| 47 | |||
| 48 | |||
| 49 | # ─── Kugou ─────────────────────────────────────────────────────────────────── | ||
| 50 | |||
| 51 | _KUGOU_SONGS_SQL = """ | ||
| 52 | SELECT s.id, s.hid, s.album_audio_id, s.album_id, s.cover, s.title, s.duration, | ||
| 53 | s.lyric, s.composer_name, s.lyricist_name, s.platform_index_url, s.published_at, | ||
| 54 | a.cover AS album_cover, a.title AS album_title, a.intro AS album_intro, | ||
| 55 | a.type AS album_type, a.company_id, a.company, a.is_owner, | ||
| 56 | a.published_at AS album_published_at | ||
| 57 | FROM media_ku_gou_songs s | ||
| 58 | LEFT JOIN media_ku_gou_albums a ON a.id = s.album_id | ||
| 59 | WHERE s.id IN ({placeholders}) | ||
| 60 | """ | ||
| 61 | |||
| 62 | _KUGOU_SINGERS_SQL = """ | ||
| 63 | SELECT shs.song_id, shs.singer_id, sg.name, sg.avatar, | ||
| 64 | sg.sex, sg.area, sg.`index`, sg.intro, sg.home_url | ||
| 65 | FROM media_ku_gou_singer_has_songs shs | ||
| 66 | JOIN media_ku_gou_singers sg ON sg.id = shs.singer_id | ||
| 67 | WHERE shs.song_id IN ({placeholders}) | ||
| 68 | """ | ||
| 69 | |||
| 70 | |||
| 71 | def fetch_kugou_songs(conn: pymysql.Connection, song_ids: list[int]) -> dict[int, dict]: | ||
| 72 | rows = _ids_query(_KUGOU_SONGS_SQL, song_ids, conn) | ||
| 73 | return {row['id']: row for row in rows} | ||
| 74 | |||
| 75 | |||
| 76 | def fetch_kugou_singers(conn: pymysql.Connection, song_ids: list[int]) -> dict[int, list[dict]]: | ||
| 77 | rows = _ids_query(_KUGOU_SINGERS_SQL, song_ids, conn) | ||
| 78 | result: dict[int, list] = {} | ||
| 79 | for row in rows: | ||
| 80 | result.setdefault(row['song_id'], []).append(row) | ||
| 81 | return result | ||
| 82 | |||
| 83 | |||
| 84 | # ─── Netease ───────────────────────────────────────────────────────────────── | ||
| 85 | |||
| 86 | _NETEASE_SONGS_SQL = """ | ||
| 87 | SELECT s.id, s.album_id, s.cover, s.title, s.duration, | ||
| 88 | s.lyric, s.composer_name, s.lyricist_name, s.platform_index_url, s.published_at, | ||
| 89 | a.cover AS album_cover, a.title AS album_title, a.intro AS album_intro, | ||
| 90 | a.type AS album_type, a.company_id, a.company, a.is_owner, | ||
| 91 | a.published_at AS album_published_at | ||
| 92 | FROM media_netease_songs s | ||
| 93 | LEFT JOIN media_netease_albums a ON a.id = s.album_id | ||
| 94 | WHERE s.id IN ({placeholders}) | ||
| 95 | """ | ||
| 96 | |||
| 97 | _NETEASE_SINGERS_SQL = """ | ||
| 98 | SELECT shs.song_id, shs.singer_id, sg.name, sg.avatar, | ||
| 99 | sg.sex, sg.area, sg.`index`, sg.intro, sg.home_url | ||
| 100 | FROM media_netease_singer_has_songs shs | ||
| 101 | JOIN media_netease_singers sg ON sg.id = shs.singer_id | ||
| 102 | WHERE shs.song_id IN ({placeholders}) | ||
| 103 | """ | ||
| 104 | |||
| 105 | |||
| 106 | def fetch_netease_songs(conn: pymysql.Connection, song_ids: list[int]) -> dict[int, dict]: | ||
| 107 | rows = _ids_query(_NETEASE_SONGS_SQL, song_ids, conn) | ||
| 108 | return {row['id']: row for row in rows} | ||
| 109 | |||
| 110 | |||
| 111 | def fetch_netease_singers(conn: pymysql.Connection, song_ids: list[int]) -> dict[int, list[dict]]: | ||
| 112 | rows = _ids_query(_NETEASE_SINGERS_SQL, song_ids, conn) | ||
| 113 | result: dict[int, list] = {} | ||
| 114 | for row in rows: | ||
| 115 | result.setdefault(row['song_id'], []).append(row) | ||
| 116 | return result |
etl_to_crawler/writer.py
0 → 100644
This diff is collapsed.
Click to expand it.
| 1 | pymysql | 1 | aliyun-python-sdk-core==2.16.0 |
| 2 | python-dotenv | 2 | aliyun-python-sdk-kms==2.16.5 |
| 3 | oss2 | 3 | asn1crypto==1.5.1 |
| 4 | requests | 4 | certifi==2026.6.17 |
| 5 | tqdm | 5 | cffi==2.0.0 |
| 6 | opencc-python-reimplemented | 6 | charset-normalizer==3.4.7 |
| 7 | pytest | 7 | cos_python_sdk_v5==1.9.44 |
| 8 | crcmod==1.7 | ||
| 9 | cryptography==49.0.0 | ||
| 10 | et_xmlfile==2.0.0 | ||
| 11 | exceptiongroup==1.3.1 | ||
| 12 | idna==3.18 | ||
| 13 | iniconfig==2.3.0 | ||
| 14 | jmespath==0.10.0 | ||
| 15 | OpenCC==1.4.0 | ||
| 16 | opencc-python-reimplemented==0.1.7 | ||
| 17 | openpyxl==3.1.5 | ||
| 18 | oss2==2.19.1 | ||
| 19 | packaging==26.2 | ||
| 20 | pg8000==1.31.5 | ||
| 21 | pluggy==1.6.0 | ||
| 22 | pycparser==3.0 | ||
| 23 | pycryptodome==3.23.0 | ||
| 24 | Pygments==2.20.0 | ||
| 25 | PyMySQL==1.2.0 | ||
| 26 | pytest==9.1.1 | ||
| 27 | python-dateutil==2.9.0.post0 | ||
| 28 | python-dotenv==1.2.2 | ||
| 29 | requests==2.34.2 | ||
| 30 | scramp==1.4.12 | ||
| 31 | six==1.17.0 | ||
| 32 | tomli==2.4.1 | ||
| 33 | tqdm==4.68.3 | ||
| 34 | typing_extensions==4.15.0 | ||
| 35 | urllib3==2.7.0 | ||
| 36 | xmltodict==1.0.4 | ... | ... |
run_etl.py
0 → 100644
| 1 | #!/usr/bin/env python3 | ||
| 2 | import argparse | ||
| 3 | from etl_to_crawler.config import PLATFORM_QQ, PLATFORM_KUGOU, PLATFORM_NETEASE, PLATFORMS | ||
| 4 | from etl_to_crawler.runner import run | ||
| 5 | |||
| 6 | PLATFORM_MAP = { | ||
| 7 | 'qq': PLATFORM_QQ, | ||
| 8 | 'kugou': PLATFORM_KUGOU, | ||
| 9 | 'netease': PLATFORM_NETEASE, | ||
| 10 | } | ||
| 11 | |||
| 12 | if __name__ == '__main__': | ||
| 13 | parser = argparse.ArgumentParser(description='ETL: hk_songs_test → crawler_dev') | ||
| 14 | parser.add_argument('--platform', default='all', | ||
| 15 | choices=['qq', 'kugou', 'netease', 'all'], | ||
| 16 | help='要导入的平台(默认 all)') | ||
| 17 | parser.add_argument('--max-batches', type=int, default=None, | ||
| 18 | help='最多处理多少批次(冒烟测试用)') | ||
| 19 | parser.add_argument('--resume', action='store_true', | ||
| 20 | help='启用断点续传,按 hk_songs_test.id 从上次成功提交的批次后继续') | ||
| 21 | parser.add_argument('--state-file', default='output/etl_to_crawler_state.json', | ||
| 22 | help='断点状态文件路径') | ||
| 23 | parser.add_argument('--start-after-id', type=int, default=None, | ||
| 24 | help='手动指定从哪个 hk_songs_test.id 之后开始读取') | ||
| 25 | parser.add_argument('--reset-state', action='store_true', | ||
| 26 | help='忽略已有断点状态,从头或 --start-after-id 指定位置重新开始') | ||
| 27 | args = parser.parse_args() | ||
| 28 | |||
| 29 | if args.platform == 'all': | ||
| 30 | platforms = PLATFORMS | ||
| 31 | else: | ||
| 32 | platforms = [PLATFORM_MAP[args.platform]] | ||
| 33 | |||
| 34 | print(f"Starting ETL for platforms: {platforms}") | ||
| 35 | run( | ||
| 36 | platforms, | ||
| 37 | max_batches=args.max_batches, | ||
| 38 | resume=args.resume, | ||
| 39 | state_file=args.state_file, | ||
| 40 | state_key=args.platform, | ||
| 41 | start_after_id=args.start_after_id, | ||
| 42 | reset_state=args.reset_state, | ||
| 43 | ) |
tests/__init__.py
0 → 100644
File mode changed
tests/test_lyric.py
0 → 100644
| 1 | from etl_to_crawler.lyric import strip_timestamps | ||
| 2 | |||
| 3 | def test_strips_standard_timestamps(): | ||
| 4 | lrc = "[00:12.34]第一行歌词\n[01:23.45]第二行歌词" | ||
| 5 | assert strip_timestamps(lrc) == "第一行歌词\n第二行歌词" | ||
| 6 | |||
| 7 | def test_strips_millisecond_timestamps(): | ||
| 8 | lrc = "[00:12.345]带三位毫秒" | ||
| 9 | assert strip_timestamps(lrc) == "带三位毫秒" | ||
| 10 | |||
| 11 | def test_strips_meta_tags(): | ||
| 12 | lrc = "[ti:歌曲名]\n[ar:歌手]\n[00:01.00]歌词" | ||
| 13 | result = strip_timestamps(lrc) | ||
| 14 | assert "歌词" in result | ||
| 15 | assert "[ti:" not in result | ||
| 16 | |||
| 17 | def test_empty_and_none(): | ||
| 18 | assert strip_timestamps(None) == '' | ||
| 19 | assert strip_timestamps('') == '' | ||
| 20 | |||
| 21 | def test_plain_text_unchanged(): | ||
| 22 | assert strip_timestamps("没有时间戳的歌词") == "没有时间戳的歌词" |
tests/test_oss.py
0 → 100644
| 1 | from unittest.mock import MagicMock, patch | ||
| 2 | from etl_to_crawler.oss import transfer_url | ||
| 3 | |||
| 4 | ARCHIVE_URL = "https://archive-dev.oss-cn-beijing.aliyuncs.com/some/path.mp3" | ||
| 5 | OTHER_URL = "https://hikoon-data-platform.oss-cn-beijing.aliyuncs.com/qq-audio/abc.mp3" | ||
| 6 | BASE_URL = "https://archive-dev.oss-cn-beijing.aliyuncs.com" | ||
| 7 | |||
| 8 | def test_already_archive_dev_returns_as_is(): | ||
| 9 | bucket = MagicMock() | ||
| 10 | result = transfer_url(ARCHIVE_URL, "any/key.mp3", bucket, BASE_URL) | ||
| 11 | assert result == ARCHIVE_URL | ||
| 12 | bucket.put_object.assert_not_called() | ||
| 13 | |||
| 14 | def test_empty_url_returns_empty(): | ||
| 15 | bucket = MagicMock() | ||
| 16 | result = transfer_url('', "any/key.mp3", bucket, BASE_URL) | ||
| 17 | assert result == '' | ||
| 18 | bucket.put_object.assert_not_called() | ||
| 19 | |||
| 20 | def test_none_url_returns_empty(): | ||
| 21 | bucket = MagicMock() | ||
| 22 | result = transfer_url(None, "any/key.mp3", bucket, BASE_URL) | ||
| 23 | assert result == '' | ||
| 24 | |||
| 25 | def test_external_url_downloads_and_uploads(): | ||
| 26 | bucket = MagicMock() | ||
| 27 | fake_content = b"audio_bytes" | ||
| 28 | with patch('etl_to_crawler.oss.requests.get') as mock_get: | ||
| 29 | mock_get.return_value.content = fake_content | ||
| 30 | mock_get.return_value.raise_for_status = MagicMock() | ||
| 31 | result = transfer_url(OTHER_URL, "crawler/qq/audio/abc.mp3", bucket, BASE_URL) | ||
| 32 | bucket.put_object.assert_called_once_with("crawler/qq/audio/abc.mp3", fake_content) | ||
| 33 | assert result == f"{BASE_URL}/crawler/qq/audio/abc.mp3" |
tests/test_reader.py
0 → 100644
| 1 | from unittest.mock import MagicMock | ||
| 2 | |||
| 3 | from etl_to_crawler.reader import ( | ||
| 4 | fetch_platform_records, | ||
| 5 | iter_hk_songs_batches, | ||
| 6 | select_primary_record, | ||
| 7 | ) | ||
| 8 | |||
| 9 | |||
| 10 | def _make_cursor(rows): | ||
| 11 | cur = MagicMock() | ||
| 12 | cur.__enter__ = MagicMock(return_value=cur) | ||
| 13 | cur.__exit__ = MagicMock(return_value=False) | ||
| 14 | cur.fetchall.return_value = rows | ||
| 15 | return cur | ||
| 16 | |||
| 17 | |||
| 18 | def test_fetch_platform_records_keeps_one_record_per_song_and_platform(): | ||
| 19 | conn = MagicMock() | ||
| 20 | conn.cursor.return_value = _make_cursor([ | ||
| 21 | { | ||
| 22 | 'source_song_id': 10, | ||
| 23 | 'record_id': 100, | ||
| 24 | 'platform': '1', | ||
| 25 | 'platform_unique_key': 'qq-mid', | ||
| 26 | 'platform_mid': '100', | ||
| 27 | 'album_audio_id': None, | ||
| 28 | 'is_main_version': 0, | ||
| 29 | 'is_high': 1, | ||
| 30 | 'pub_time': '2020-01-01', | ||
| 31 | }, | ||
| 32 | { | ||
| 33 | 'source_song_id': 10, | ||
| 34 | 'record_id': 101, | ||
| 35 | 'platform': '2', | ||
| 36 | 'platform_unique_key': '200', | ||
| 37 | 'platform_mid': 'kg-hash', | ||
| 38 | 'album_audio_id': None, | ||
| 39 | 'is_main_version': 1, | ||
| 40 | 'is_high': 0, | ||
| 41 | 'pub_time': '2021-01-01', | ||
| 42 | }, | ||
| 43 | { | ||
| 44 | 'source_song_id': 10, | ||
| 45 | 'record_id': 102, | ||
| 46 | 'platform': '4', | ||
| 47 | 'platform_unique_key': '300', | ||
| 48 | 'platform_mid': None, | ||
| 49 | 'album_audio_id': None, | ||
| 50 | 'is_main_version': 1, | ||
| 51 | 'is_high': 1, | ||
| 52 | 'pub_time': '2022-01-01', | ||
| 53 | }, | ||
| 54 | ]) | ||
| 55 | |||
| 56 | result = fetch_platform_records(conn, [10]) | ||
| 57 | |||
| 58 | assert len(result) == 3 | ||
| 59 | assert {row['record_id'] for row in result} == {100, 101, 102} | ||
| 60 | |||
| 61 | |||
| 62 | def test_select_primary_record_prefers_main_version_then_is_high(): | ||
| 63 | rows = [ | ||
| 64 | { | ||
| 65 | 'source_song_id': 10, | ||
| 66 | 'record_id': 100, | ||
| 67 | 'platform': '1', | ||
| 68 | 'platform_unique_key': 'qq-mid', | ||
| 69 | 'platform_mid': '100', | ||
| 70 | 'album_audio_id': None, | ||
| 71 | 'is_main_version': 0, | ||
| 72 | 'is_high': 1, | ||
| 73 | 'pub_time': '2020-01-01', | ||
| 74 | }, | ||
| 75 | { | ||
| 76 | 'source_song_id': 10, | ||
| 77 | 'record_id': 101, | ||
| 78 | 'platform': '2', | ||
| 79 | 'platform_unique_key': '200', | ||
| 80 | 'platform_mid': 'kg-hash', | ||
| 81 | 'album_audio_id': None, | ||
| 82 | 'is_main_version': 1, | ||
| 83 | 'is_high': 0, | ||
| 84 | 'pub_time': '2021-01-01', | ||
| 85 | }, | ||
| 86 | { | ||
| 87 | 'source_song_id': 10, | ||
| 88 | 'record_id': 102, | ||
| 89 | 'platform': '4', | ||
| 90 | 'platform_unique_key': '300', | ||
| 91 | 'platform_mid': None, | ||
| 92 | 'album_audio_id': None, | ||
| 93 | 'is_main_version': 1, | ||
| 94 | 'is_high': 1, | ||
| 95 | 'pub_time': '2022-01-01', | ||
| 96 | }, | ||
| 97 | ] | ||
| 98 | |||
| 99 | assert select_primary_record(rows)['record_id'] == 102 | ||
| 100 | |||
| 101 | |||
| 102 | def test_select_primary_record_uses_earliest_pub_time_when_priority_ties(): | ||
| 103 | rows = [ | ||
| 104 | { | ||
| 105 | 'source_song_id': 10, | ||
| 106 | 'record_id': 100, | ||
| 107 | 'platform': '1', | ||
| 108 | 'platform_unique_key': 'qq-mid', | ||
| 109 | 'platform_mid': '100', | ||
| 110 | 'album_audio_id': None, | ||
| 111 | 'is_main_version': 0, | ||
| 112 | 'is_high': 0, | ||
| 113 | 'pub_time': '2020-01-01', | ||
| 114 | }, | ||
| 115 | { | ||
| 116 | 'source_song_id': 10, | ||
| 117 | 'record_id': 101, | ||
| 118 | 'platform': '2', | ||
| 119 | 'platform_unique_key': '200', | ||
| 120 | 'platform_mid': 'kg-hash', | ||
| 121 | 'album_audio_id': None, | ||
| 122 | 'is_main_version': 0, | ||
| 123 | 'is_high': 0, | ||
| 124 | 'pub_time': '2019-01-01', | ||
| 125 | }, | ||
| 126 | ] | ||
| 127 | |||
| 128 | assert select_primary_record(rows)['record_id'] == 101 | ||
| 129 | |||
| 130 | |||
| 131 | def test_iter_hk_songs_batches_uses_id_cursor_instead_of_offset(): | ||
| 132 | cur = _make_cursor([]) | ||
| 133 | cur.fetchall.side_effect = [ | ||
| 134 | [{'id': 10, 'source_song_id': 100}, {'id': 20, 'source_song_id': 200}], | ||
| 135 | [{'id': 25, 'source_song_id': 250}], | ||
| 136 | ] | ||
| 137 | conn = MagicMock() | ||
| 138 | conn.cursor.return_value = cur | ||
| 139 | |||
| 140 | batches = list(iter_hk_songs_batches(conn, batch_size=2, start_after_id=5)) | ||
| 141 | |||
| 142 | assert batches == [ | ||
| 143 | [{'id': 10, 'source_song_id': 100}, {'id': 20, 'source_song_id': 200}], | ||
| 144 | [{'id': 25, 'source_song_id': 250}], | ||
| 145 | ] | ||
| 146 | assert cur.execute.call_args_list[0][0][1] == (5, 2) | ||
| 147 | assert cur.execute.call_args_list[1][0][1] == (20, 2) |
tests/test_runner.py
0 → 100644
| 1 | from unittest.mock import MagicMock | ||
| 2 | |||
| 3 | from etl_to_crawler import runner | ||
| 4 | |||
| 5 | |||
| 6 | class _Cursor: | ||
| 7 | def __enter__(self): | ||
| 8 | return self | ||
| 9 | |||
| 10 | def __exit__(self, exc_type, exc, tb): | ||
| 11 | return False | ||
| 12 | |||
| 13 | def execute(self, *args, **kwargs): | ||
| 14 | return None | ||
| 15 | |||
| 16 | |||
| 17 | class _PgConnection: | ||
| 18 | def __init__(self): | ||
| 19 | self.cur = _Cursor() | ||
| 20 | self.commits = 0 | ||
| 21 | self.closed = False | ||
| 22 | |||
| 23 | def cursor(self): | ||
| 24 | return self.cur | ||
| 25 | |||
| 26 | def commit(self): | ||
| 27 | self.commits += 1 | ||
| 28 | |||
| 29 | def close(self): | ||
| 30 | self.closed = True | ||
| 31 | |||
| 32 | |||
| 33 | class _Connection: | ||
| 34 | def close(self): | ||
| 35 | return None | ||
| 36 | |||
| 37 | |||
| 38 | def test_run_imports_all_platform_records_but_writes_one_primary_yinyan_relation(monkeypatch): | ||
| 39 | pg_conn = _PgConnection() | ||
| 40 | processors = { | ||
| 41 | '1': MagicMock(return_value={'platform': 'qq', 'platform_song_id': 100, 'mid': 'qq-mid', 'title': '歌'}), | ||
| 42 | '2': MagicMock(return_value={'platform': 'kugou', 'platform_song_id': 200, 'hash': 'kg-hash', 'title': '歌'}), | ||
| 43 | } | ||
| 44 | yinyan_writer = MagicMock() | ||
| 45 | |||
| 46 | monkeypatch.setattr(runner, 'get_hk_songs_conn', lambda: _Connection()) | ||
| 47 | monkeypatch.setattr(runner, 'get_source_conn', lambda: _Connection()) | ||
| 48 | monkeypatch.setattr(runner, 'get_spider_conn', lambda: _Connection()) | ||
| 49 | monkeypatch.setattr(runner, 'get_pg_conn', lambda: pg_conn) | ||
| 50 | monkeypatch.setattr(runner, 'get_oss_bucket', lambda: object()) | ||
| 51 | monkeypatch.setattr(runner, 'iter_hk_songs_batches', lambda conn, batch_size, start_after_id=0: [[{ | ||
| 52 | 'source_song_id': 10, | ||
| 53 | 'name': '歌', | ||
| 54 | 'audio_url': 'https://example.com/a.mp3', | ||
| 55 | 'singer': '歌手', | ||
| 56 | }]]) | ||
| 57 | monkeypatch.setattr(runner, 'fetch_platform_records', lambda conn, song_ids: [ | ||
| 58 | { | ||
| 59 | 'source_song_id': 10, | ||
| 60 | 'record_id': 100, | ||
| 61 | 'platform': '1', | ||
| 62 | 'platform_unique_key': 'qq-mid', | ||
| 63 | 'platform_mid': '100', | ||
| 64 | 'album_audio_id': None, | ||
| 65 | 'is_main_version': 0, | ||
| 66 | 'is_high': 1, | ||
| 67 | 'pub_time': '2020-01-01', | ||
| 68 | }, | ||
| 69 | { | ||
| 70 | 'source_song_id': 10, | ||
| 71 | 'record_id': 200, | ||
| 72 | 'platform': '2', | ||
| 73 | 'platform_unique_key': '200', | ||
| 74 | 'platform_mid': 'kg-hash', | ||
| 75 | 'album_audio_id': None, | ||
| 76 | 'is_main_version': 1, | ||
| 77 | 'is_high': 0, | ||
| 78 | 'pub_time': '2021-01-01', | ||
| 79 | }, | ||
| 80 | ]) | ||
| 81 | monkeypatch.setattr(runner, '_PROCESSORS', processors) | ||
| 82 | monkeypatch.setattr(runner, 'upsert_yinyan_song_records', yinyan_writer) | ||
| 83 | |||
| 84 | runner.run(['1', '2']) | ||
| 85 | |||
| 86 | processors['1'].assert_called_once() | ||
| 87 | processors['2'].assert_called_once() | ||
| 88 | yinyan_writer.assert_called_once_with(pg_conn.cur, [(10, 200)]) | ||
| 89 | assert pg_conn.commits == 1 | ||
| 90 | |||
| 91 | |||
| 92 | def test_run_resume_starts_after_saved_id_and_updates_state_after_commit(monkeypatch, tmp_path): | ||
| 93 | pg_conn = _PgConnection() | ||
| 94 | state_file = tmp_path / 'etl_state.json' | ||
| 95 | state_file.write_text('{"all": {"last_hk_songs_id": 40}}', encoding='utf-8') | ||
| 96 | seen_start_ids = [] | ||
| 97 | |||
| 98 | monkeypatch.setattr(runner, 'get_hk_songs_conn', lambda: _Connection()) | ||
| 99 | monkeypatch.setattr(runner, 'get_source_conn', lambda: _Connection()) | ||
| 100 | monkeypatch.setattr(runner, 'get_spider_conn', lambda: _Connection()) | ||
| 101 | monkeypatch.setattr(runner, 'get_pg_conn', lambda: pg_conn) | ||
| 102 | monkeypatch.setattr(runner, 'get_oss_bucket', lambda: object()) | ||
| 103 | |||
| 104 | def fake_batches(conn, batch_size, start_after_id=0): | ||
| 105 | seen_start_ids.append(start_after_id) | ||
| 106 | return iter([[ | ||
| 107 | {'id': 50, 'source_song_id': 10, 'name': '歌', 'audio_url': 'https://example.com/a.mp3', 'singer': '歌手'}, | ||
| 108 | {'id': 60, 'source_song_id': 20, 'name': '歌2', 'audio_url': 'https://example.com/b.mp3', 'singer': '歌手2'}, | ||
| 109 | ]]) | ||
| 110 | |||
| 111 | monkeypatch.setattr(runner, 'iter_hk_songs_batches', fake_batches) | ||
| 112 | monkeypatch.setattr(runner, 'fetch_platform_records', lambda conn, song_ids: []) | ||
| 113 | |||
| 114 | runner.run(['1', '2', '4'], resume=True, state_file=state_file, state_key='all') | ||
| 115 | |||
| 116 | assert seen_start_ids == [40] | ||
| 117 | assert pg_conn.commits == 1 | ||
| 118 | assert '"last_hk_songs_id": 60' in state_file.read_text(encoding='utf-8') |
tests/test_spider.py
0 → 100644
| 1 | from unittest.mock import MagicMock, patch | ||
| 2 | from etl_to_crawler.spider import fetch_qq_songs, fetch_qq_singers | ||
| 3 | |||
| 4 | def _make_cursor(rows): | ||
| 5 | cur = MagicMock() | ||
| 6 | cur.__enter__ = MagicMock(return_value=cur) | ||
| 7 | cur.__exit__ = MagicMock(return_value=False) | ||
| 8 | cur.fetchall.return_value = rows | ||
| 9 | return cur | ||
| 10 | |||
| 11 | def test_fetch_qq_songs_keys_by_mid(): | ||
| 12 | conn = MagicMock() | ||
| 13 | conn.cursor.return_value = _make_cursor([ | ||
| 14 | {'mid': 'abc123', 'id': 1, 'title': '歌曲A', 'duration': 200, | ||
| 15 | 'lyric': None, 'composer_name': None, 'lyricist_name': None, | ||
| 16 | 'platform_index_url': None, 'published_at': None, | ||
| 17 | 'cover': '', 'album_id': None, | ||
| 18 | 'album_mid': None, 'album_title': None, 'album_cover': None, | ||
| 19 | 'album_intro': None, 'album_type': None, 'company_id': 0, | ||
| 20 | 'company': None, 'is_owner': 0, 'album_published_at': None} | ||
| 21 | ]) | ||
| 22 | result = fetch_qq_songs(conn, ['abc123']) | ||
| 23 | assert 'abc123' in result | ||
| 24 | assert result['abc123']['title'] == '歌曲A' | ||
| 25 | |||
| 26 | def test_fetch_qq_songs_empty_list(): | ||
| 27 | conn = MagicMock() | ||
| 28 | result = fetch_qq_songs(conn, []) | ||
| 29 | assert result == {} | ||
| 30 | conn.cursor.assert_not_called() | ||
| 31 | |||
| 32 | def test_fetch_qq_singers_keys_by_song_id(): | ||
| 33 | conn = MagicMock() | ||
| 34 | conn.cursor.return_value = _make_cursor([ | ||
| 35 | {'song_id': 1, 'singer_id': 10, 'mid': 'sg1', 'name': '歌手A', | ||
| 36 | 'avatar': '', 'sex': 'M', 'area': '华语', 'index': 'G', | ||
| 37 | 'intro': None, 'home_url': None} | ||
| 38 | ]) | ||
| 39 | result = fetch_qq_singers(conn, [1]) | ||
| 40 | assert 1 in result | ||
| 41 | assert result[1][0]['name'] == '歌手A' |
tests/test_writer.py
0 → 100644
| 1 | from unittest.mock import MagicMock, call | ||
| 2 | from etl_to_crawler.writer import ( | ||
| 3 | upsert_qq_singers, | ||
| 4 | upsert_qq_singer_songs, | ||
| 5 | upsert_yinyan_song_records, | ||
| 6 | ) | ||
| 7 | |||
| 8 | |||
| 9 | def test_upsert_qq_singers_executes_insert(): | ||
| 10 | cur = MagicMock() | ||
| 11 | singers = [{ | ||
| 12 | 'id': 1, 'mid': 'abc', 'name': '歌手', 'avatar': 'https://archive-dev.oss-cn-beijing.aliyuncs.com/a.jpg', | ||
| 13 | 'sex': 'M', 'area': '华语', 'index': 'G', 'intro': None, 'home_url': None | ||
| 14 | }] | ||
| 15 | upsert_qq_singers(cur, singers) | ||
| 16 | assert cur.executemany.called | ||
| 17 | args = cur.executemany.call_args | ||
| 18 | assert 'crawler_qqmusic_singers' in args[0][0] | ||
| 19 | assert 'ON CONFLICT' in args[0][0] | ||
| 20 | |||
| 21 | |||
| 22 | def test_upsert_qq_singers_empty_does_nothing(): | ||
| 23 | cur = MagicMock() | ||
| 24 | upsert_qq_singers(cur, []) | ||
| 25 | cur.executemany.assert_not_called() | ||
| 26 | |||
| 27 | |||
| 28 | def test_upsert_qq_singer_songs(): | ||
| 29 | cur = MagicMock() | ||
| 30 | upsert_qq_singer_songs(cur, [(1, 'uuid-abc'), (2, 'uuid-def')]) | ||
| 31 | assert cur.executemany.called | ||
| 32 | sql = cur.executemany.call_args[0][0] | ||
| 33 | assert 'crawler_qqmusic_singer_songs' in sql | ||
| 34 | |||
| 35 | |||
| 36 | def test_upsert_yinyan_song_records_replaces_existing_song_relation(): | ||
| 37 | cur = MagicMock() | ||
| 38 | upsert_yinyan_song_records(cur, [(10, 100), (11, 101)]) | ||
| 39 | |||
| 40 | assert cur.executemany.call_count == 2 | ||
| 41 | delete_sql, delete_rows = cur.executemany.call_args_list[0][0] | ||
| 42 | insert_sql, insert_rows = cur.executemany.call_args_list[1][0] | ||
| 43 | assert 'DELETE FROM yinyan_song_records' in delete_sql | ||
| 44 | assert 'WHERE song_id = %s' in delete_sql | ||
| 45 | assert delete_rows == [(10,), (11,)] | ||
| 46 | assert 'INSERT INTO yinyan_song_records' in insert_sql | ||
| 47 | assert insert_rows == [(10, 100), (11, 101)] |
-
Please register or sign in to post a comment