Merge branch 'feat/etl-hk-songs-to-crawler'
Showing
18 changed files
with
1506 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
| 1 | import uuid | ||
| 2 | import json | ||
| 3 | import logging | ||
| 4 | from pathlib import Path | ||
| 5 | from tqdm import tqdm | ||
| 6 | |||
| 7 | from .config import PLATFORM_QQ, PLATFORM_KUGOU, PLATFORM_NETEASE, BATCH_SIZE, OSS_CONFIG | ||
| 8 | from .connections import get_hk_songs_conn, get_source_conn, get_spider_conn, get_pg_conn, get_oss_bucket | ||
| 9 | from .reader import iter_hk_songs_batches, fetch_platform_records, select_primary_record | ||
| 10 | from .spider import ( | ||
| 11 | fetch_qq_songs, fetch_qq_singers, | ||
| 12 | fetch_kugou_songs, fetch_kugou_singers, | ||
| 13 | fetch_netease_songs, fetch_netease_singers, | ||
| 14 | ) | ||
| 15 | from .writer import ( | ||
| 16 | upsert_yinyan_song_records, | ||
| 17 | upsert_qq_singers, upsert_qq_albums, upsert_qq_songs, | ||
| 18 | upsert_qq_singer_songs, upsert_qq_singer_albums, | ||
| 19 | upsert_kugou_singers, upsert_kugou_albums, upsert_kugou_songs, | ||
| 20 | upsert_kugou_singer_songs, upsert_kugou_singer_albums, | ||
| 21 | upsert_netease_singers, upsert_netease_albums, upsert_netease_songs, | ||
| 22 | upsert_netease_singer_songs, upsert_netease_singer_albums, | ||
| 23 | ) | ||
| 24 | from .oss import transfer_url, build_oss_key | ||
| 25 | from .lyric import strip_timestamps | ||
| 26 | |||
| 27 | logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s') | ||
| 28 | log = logging.getLogger(__name__) | ||
| 29 | |||
| 30 | |||
| 31 | def _safe_transfer(url, oss_key, bucket, base_url): | ||
| 32 | try: | ||
| 33 | return transfer_url(url, oss_key, bucket, base_url) | ||
| 34 | except Exception as e: | ||
| 35 | log.warning("OSS transfer failed for %s: %s", url, e) | ||
| 36 | return url # 失败时保留原 URL,不阻断流程 | ||
| 37 | |||
| 38 | |||
| 39 | def _process_qq(hk_row: dict, pr: dict, spider_conn, pg_cur, bucket, base_url): | ||
| 40 | mid = pr['platform_unique_key'] | ||
| 41 | songs_map = fetch_qq_songs(spider_conn, [mid]) | ||
| 42 | if mid not in songs_map: | ||
| 43 | return | ||
| 44 | sp = songs_map[mid] | ||
| 45 | song_id_int = sp['id'] | ||
| 46 | |||
| 47 | singers_map = fetch_qq_singers(spider_conn, [song_id_int]) | ||
| 48 | singer_list = singers_map.get(song_id_int, []) | ||
| 49 | |||
| 50 | # OSS 转移 | ||
| 51 | audio_url = _safe_transfer( | ||
| 52 | hk_row['audio_url'], | ||
| 53 | build_oss_key('qq', 'audio', mid + '.mp3'), | ||
| 54 | bucket, base_url | ||
| 55 | ) | ||
| 56 | cover_url = _safe_transfer( | ||
| 57 | hk_row.get('cover_url') or sp.get('cover', ''), | ||
| 58 | build_oss_key('qq', 'cover', str(song_id_int) + '.jpg'), | ||
| 59 | bucket, base_url | ||
| 60 | ) | ||
| 61 | album_cover = '' | ||
| 62 | if sp.get('album_id') and sp.get('album_cover'): | ||
| 63 | album_cover = _safe_transfer( | ||
| 64 | sp['album_cover'], | ||
| 65 | build_oss_key('qq', 'album', str(sp['album_id']) + '.jpg'), | ||
| 66 | bucket, base_url | ||
| 67 | ) | ||
| 68 | |||
| 69 | # 歌手头像转移 + 写入 singers | ||
| 70 | singer_rows = [] | ||
| 71 | for sg in singer_list: | ||
| 72 | avatar = _safe_transfer( | ||
| 73 | sg.get('avatar', ''), | ||
| 74 | build_oss_key('qq', 'singer', sg['mid'] + '.jpg'), | ||
| 75 | bucket, base_url | ||
| 76 | ) | ||
| 77 | singer_rows.append({**sg, 'id': sg['singer_id'], 'avatar': avatar}) | ||
| 78 | upsert_qq_singers(pg_cur, singer_rows) | ||
| 79 | |||
| 80 | # singers JSONB | ||
| 81 | singers_json = json.dumps([{ | ||
| 82 | 'name': sg['name'], | ||
| 83 | 'singer_id': sg['singer_id'], | ||
| 84 | 'platform_singer_id': sg['mid'], | ||
| 85 | } for sg in singer_list], ensure_ascii=False) | ||
| 86 | |||
| 87 | # 写入 album | ||
| 88 | album_id = None | ||
| 89 | if sp.get('album_id'): | ||
| 90 | album_id = sp['album_id'] | ||
| 91 | upsert_qq_albums(pg_cur, [{ | ||
| 92 | 'id': sp['album_id'], 'mid': sp.get('album_mid') or '', | ||
| 93 | 'cover': album_cover, 'title': sp.get('album_title') or '', | ||
| 94 | 'intro': sp.get('album_intro'), 'type': sp.get('album_type') or '', | ||
| 95 | 'company_id': sp.get('company_id') or 0, 'company': sp.get('company') or '', | ||
| 96 | 'is_owner': sp.get('is_owner') or 0, 'published_at': sp.get('album_published_at'), | ||
| 97 | }]) | ||
| 98 | |||
| 99 | # 写入 song | ||
| 100 | platform_song_id = int(pr['platform_mid']) if pr.get('platform_mid') else song_id_int | ||
| 101 | song_uuid = str(uuid.uuid4()) | ||
| 102 | upsert_qq_songs(pg_cur, [{ | ||
| 103 | 'song_uuid': song_uuid, | ||
| 104 | 'platform_song_id': platform_song_id, | ||
| 105 | 'mid': mid, | ||
| 106 | 'album_id': album_id, | ||
| 107 | 'cover': cover_url, | ||
| 108 | 'title': sp.get('title', hk_row['name']), | ||
| 109 | 'name': hk_row['name'], | ||
| 110 | 'duration': sp.get('duration') or hk_row.get('song_time') or 0, | ||
| 111 | 'lyric': strip_timestamps(sp.get('lyric')), | ||
| 112 | 'composer_name': sp.get('composer_name') or hk_row.get('composer'), | ||
| 113 | 'lyricist_name': sp.get('lyricist_name') or hk_row.get('lyricist'), | ||
| 114 | 'url': audio_url, | ||
| 115 | 'lyric_url': hk_row.get('lyrics_url'), | ||
| 116 | 'platform_index_url': sp.get('platform_index_url') or f'https://y.qq.com/n/ryqq/songDetail/{mid}', | ||
| 117 | 'published_at': sp.get('published_at') or hk_row.get('issue_time'), | ||
| 118 | 'singers_json': singers_json, | ||
| 119 | }]) | ||
| 120 | # 查询实际 UUID(ON CONFLICT DO NOTHING 时使用已有 UUID) | ||
| 121 | pg_cur.execute('SELECT id FROM crawler_qqmusic_songs WHERE platform_song_id = %s', (platform_song_id,)) | ||
| 122 | row = pg_cur.fetchone() | ||
| 123 | if row: | ||
| 124 | song_uuid = str(row[0]) | ||
| 125 | |||
| 126 | # singer_songs / singer_albums | ||
| 127 | upsert_qq_singer_songs(pg_cur, [(sg['singer_id'], song_uuid) for sg in singer_list]) | ||
| 128 | if album_id: | ||
| 129 | upsert_qq_singer_albums(pg_cur, [(sg['singer_id'], album_id) for sg in singer_list]) | ||
| 130 | return {'platform': 'qq', 'platform_song_id': platform_song_id, 'mid': mid, 'title': hk_row['name']} | ||
| 131 | |||
| 132 | |||
| 133 | def _process_kugou(hk_row: dict, pr: dict, spider_conn, pg_cur, bucket, base_url): | ||
| 134 | song_id = int(pr['platform_unique_key']) | ||
| 135 | songs_map = fetch_kugou_songs(spider_conn, [song_id]) | ||
| 136 | if song_id not in songs_map: | ||
| 137 | return | ||
| 138 | sp = songs_map[song_id] | ||
| 139 | |||
| 140 | singers_map = fetch_kugou_singers(spider_conn, [song_id]) | ||
| 141 | singer_list = singers_map.get(song_id, []) | ||
| 142 | |||
| 143 | audio_url = _safe_transfer( | ||
| 144 | hk_row['audio_url'], | ||
| 145 | build_oss_key('kugou', 'audio', str(song_id) + '.mp3'), | ||
| 146 | bucket, base_url | ||
| 147 | ) | ||
| 148 | cover_url = _safe_transfer( | ||
| 149 | hk_row.get('cover_url') or sp.get('cover', ''), | ||
| 150 | build_oss_key('kugou', 'cover', str(song_id) + '.jpg'), | ||
| 151 | bucket, base_url | ||
| 152 | ) | ||
| 153 | album_cover = '' | ||
| 154 | if sp.get('album_id') and sp.get('album_cover'): | ||
| 155 | album_cover = _safe_transfer( | ||
| 156 | sp['album_cover'], | ||
| 157 | build_oss_key('kugou', 'album', str(sp['album_id']) + '.jpg'), | ||
| 158 | bucket, base_url | ||
| 159 | ) | ||
| 160 | |||
| 161 | singer_rows = [] | ||
| 162 | for sg in singer_list: | ||
| 163 | avatar = _safe_transfer( | ||
| 164 | sg.get('avatar', ''), | ||
| 165 | build_oss_key('kugou', 'singer', str(sg['singer_id']) + '.jpg'), | ||
| 166 | bucket, base_url | ||
| 167 | ) | ||
| 168 | singer_rows.append({**sg, 'id': sg['singer_id'], 'avatar': avatar}) | ||
| 169 | upsert_kugou_singers(pg_cur, singer_rows) | ||
| 170 | |||
| 171 | singers_json = json.dumps([{ | ||
| 172 | 'name': sg['name'], | ||
| 173 | 'singer_id': sg['singer_id'], | ||
| 174 | 'platform_singer_id': str(sg['singer_id']), | ||
| 175 | } for sg in singer_list], ensure_ascii=False) | ||
| 176 | |||
| 177 | album_id = None | ||
| 178 | if sp.get('album_id'): | ||
| 179 | album_id = sp['album_id'] | ||
| 180 | upsert_kugou_albums(pg_cur, [{ | ||
| 181 | 'id': sp['album_id'], 'cover': album_cover, | ||
| 182 | 'title': sp.get('album_title') or '', 'intro': sp.get('album_intro'), | ||
| 183 | 'type': sp.get('album_type') or '', 'company_id': sp.get('company_id') or 0, | ||
| 184 | 'company': sp.get('company') or '', 'is_owner': sp.get('is_owner') or 0, | ||
| 185 | 'published_at': sp.get('album_published_at'), | ||
| 186 | }]) | ||
| 187 | |||
| 188 | song_uuid = str(uuid.uuid4()) | ||
| 189 | upsert_kugou_songs(pg_cur, [{ | ||
| 190 | 'song_uuid': song_uuid, | ||
| 191 | 'platform_song_id': song_id, | ||
| 192 | 'hash': sp.get('hid', pr.get('platform_mid', '')), | ||
| 193 | 'album_audio_id': sp.get('album_audio_id') or pr.get('album_audio_id') or 0, | ||
| 194 | 'album_id': album_id, | ||
| 195 | 'cover': cover_url, | ||
| 196 | 'title': sp.get('title', hk_row['name']), | ||
| 197 | 'name': hk_row['name'], | ||
| 198 | 'duration': sp.get('duration') or hk_row.get('song_time') or 0, | ||
| 199 | 'lyric': strip_timestamps(sp.get('lyric')), | ||
| 200 | 'composer_name': sp.get('composer_name') or hk_row.get('composer'), | ||
| 201 | 'lyricist_name': sp.get('lyricist_name') or hk_row.get('lyricist'), | ||
| 202 | 'url': audio_url, | ||
| 203 | 'lyric_url': hk_row.get('lyrics_url'), | ||
| 204 | 'platform_index_url': sp.get('platform_index_url') or f'http://www.kugou.com/song/#hash={sp.get("hid") or pr.get("platform_mid", "")}', | ||
| 205 | 'published_at': sp.get('published_at') or hk_row.get('issue_time'), | ||
| 206 | 'singers_json': singers_json, | ||
| 207 | }]) | ||
| 208 | # 查询实际 UUID(ON CONFLICT DO NOTHING 时使用已有 UUID) | ||
| 209 | pg_cur.execute('SELECT id FROM crawler_kugou_songs WHERE platform_song_id = %s', (song_id,)) | ||
| 210 | row = pg_cur.fetchone() | ||
| 211 | if row: | ||
| 212 | song_uuid = str(row[0]) | ||
| 213 | |||
| 214 | upsert_kugou_singer_songs(pg_cur, [(sg['singer_id'], song_uuid) for sg in singer_list]) | ||
| 215 | if album_id: | ||
| 216 | upsert_kugou_singer_albums(pg_cur, [(sg['singer_id'], album_id) for sg in singer_list]) | ||
| 217 | return {'platform': 'kugou', 'platform_song_id': song_id, 'hash': sp.get('hid', ''), 'title': hk_row['name']} | ||
| 218 | |||
| 219 | |||
| 220 | def _process_netease(hk_row: dict, pr: dict, spider_conn, pg_cur, bucket, base_url): | ||
| 221 | song_id = int(pr['platform_unique_key']) | ||
| 222 | songs_map = fetch_netease_songs(spider_conn, [song_id]) | ||
| 223 | if song_id not in songs_map: | ||
| 224 | return | ||
| 225 | sp = songs_map[song_id] | ||
| 226 | |||
| 227 | singers_map = fetch_netease_singers(spider_conn, [song_id]) | ||
| 228 | singer_list = singers_map.get(song_id, []) | ||
| 229 | |||
| 230 | audio_url = _safe_transfer( | ||
| 231 | hk_row['audio_url'], | ||
| 232 | build_oss_key('netease', 'audio', str(song_id) + '.mp3'), | ||
| 233 | bucket, base_url | ||
| 234 | ) | ||
| 235 | cover_url = _safe_transfer( | ||
| 236 | hk_row.get('cover_url') or sp.get('cover', ''), | ||
| 237 | build_oss_key('netease', 'cover', str(song_id) + '.jpg'), | ||
| 238 | bucket, base_url | ||
| 239 | ) | ||
| 240 | album_cover = '' | ||
| 241 | if sp.get('album_id') and sp.get('album_cover'): | ||
| 242 | album_cover = _safe_transfer( | ||
| 243 | sp['album_cover'], | ||
| 244 | build_oss_key('netease', 'album', str(sp['album_id']) + '.jpg'), | ||
| 245 | bucket, base_url | ||
| 246 | ) | ||
| 247 | |||
| 248 | singer_rows = [] | ||
| 249 | for sg in singer_list: | ||
| 250 | avatar = _safe_transfer( | ||
| 251 | sg.get('avatar', ''), | ||
| 252 | build_oss_key('netease', 'singer', str(sg['singer_id']) + '.jpg'), | ||
| 253 | bucket, base_url | ||
| 254 | ) | ||
| 255 | singer_rows.append({**sg, 'id': sg['singer_id'], 'avatar': avatar}) | ||
| 256 | upsert_netease_singers(pg_cur, singer_rows) | ||
| 257 | |||
| 258 | singers_json = json.dumps([{ | ||
| 259 | 'name': sg['name'], | ||
| 260 | 'singer_id': sg['singer_id'], | ||
| 261 | 'platform_singer_id': str(sg['singer_id']), | ||
| 262 | } for sg in singer_list], ensure_ascii=False) | ||
| 263 | |||
| 264 | album_id = None | ||
| 265 | if sp.get('album_id'): | ||
| 266 | album_id = sp['album_id'] | ||
| 267 | upsert_netease_albums(pg_cur, [{ | ||
| 268 | 'id': sp['album_id'], 'cover': album_cover, | ||
| 269 | 'title': sp.get('album_title') or '', 'intro': sp.get('album_intro'), | ||
| 270 | 'type': sp.get('album_type') or '', 'company_id': sp.get('company_id') or 0, | ||
| 271 | 'company': sp.get('company') or '', 'is_owner': sp.get('is_owner') or 0, | ||
| 272 | 'published_at': sp.get('album_published_at'), | ||
| 273 | }]) | ||
| 274 | |||
| 275 | song_uuid = str(uuid.uuid4()) | ||
| 276 | upsert_netease_songs(pg_cur, [{ | ||
| 277 | 'song_uuid': song_uuid, | ||
| 278 | 'platform_song_id': song_id, | ||
| 279 | 'album_id': album_id, | ||
| 280 | 'cover': cover_url, | ||
| 281 | 'title': sp.get('title', hk_row['name']), | ||
| 282 | 'name': hk_row['name'], | ||
| 283 | 'duration': sp.get('duration') or hk_row.get('song_time') or 0, | ||
| 284 | 'lyric': strip_timestamps(sp.get('lyric')), | ||
| 285 | 'composer_name': sp.get('composer_name') or hk_row.get('composer'), | ||
| 286 | 'lyricist_name': sp.get('lyricist_name') or hk_row.get('lyricist'), | ||
| 287 | 'url': audio_url, | ||
| 288 | 'lyric_url': hk_row.get('lyrics_url'), | ||
| 289 | 'platform_index_url': sp.get('platform_index_url') or f'https://music.163.com/#/song?id={song_id}', | ||
| 290 | 'published_at': sp.get('published_at') or hk_row.get('issue_time'), | ||
| 291 | 'singers_json': singers_json, | ||
| 292 | }]) | ||
| 293 | # 查询实际 UUID(ON CONFLICT DO NOTHING 时使用已有 UUID) | ||
| 294 | pg_cur.execute('SELECT id FROM crawler_netease_songs WHERE platform_song_id = %s', (song_id,)) | ||
| 295 | row = pg_cur.fetchone() | ||
| 296 | if row: | ||
| 297 | song_uuid = str(row[0]) | ||
| 298 | |||
| 299 | upsert_netease_singer_songs(pg_cur, [(sg['singer_id'], song_uuid) for sg in singer_list]) | ||
| 300 | if album_id: | ||
| 301 | upsert_netease_singer_albums(pg_cur, [(sg['singer_id'], album_id) for sg in singer_list]) | ||
| 302 | return {'platform': 'netease', 'platform_song_id': song_id, 'title': hk_row['name']} | ||
| 303 | |||
| 304 | |||
| 305 | _PROCESSORS = { | ||
| 306 | PLATFORM_QQ: _process_qq, | ||
| 307 | PLATFORM_KUGOU: _process_kugou, | ||
| 308 | PLATFORM_NETEASE: _process_netease, | ||
| 309 | } | ||
| 310 | |||
| 311 | |||
| 312 | DEFAULT_STATE_FILE = Path('output/etl_to_crawler_state.json') | ||
| 313 | |||
| 314 | |||
| 315 | def _load_state(path: Path) -> dict: | ||
| 316 | if not path.exists(): | ||
| 317 | return {} | ||
| 318 | with path.open('r', encoding='utf-8') as f: | ||
| 319 | return json.load(f) | ||
| 320 | |||
| 321 | |||
| 322 | def _save_state(path: Path, state: dict) -> None: | ||
| 323 | path.parent.mkdir(parents=True, exist_ok=True) | ||
| 324 | with path.open('w', encoding='utf-8') as f: | ||
| 325 | json.dump(state, f, ensure_ascii=False, indent=2, sort_keys=True) | ||
| 326 | f.write('\n') | ||
| 327 | |||
| 328 | |||
| 329 | def _state_last_id(state: dict, key: str) -> int: | ||
| 330 | value = state.get(key, {}).get('last_hk_songs_id', 0) | ||
| 331 | return int(value or 0) | ||
| 332 | |||
| 333 | |||
| 334 | def run( | ||
| 335 | platforms: list[str], | ||
| 336 | max_batches: int | None = None, | ||
| 337 | resume: bool = False, | ||
| 338 | state_file: str | Path = DEFAULT_STATE_FILE, | ||
| 339 | state_key: str = 'all', | ||
| 340 | start_after_id: int | None = None, | ||
| 341 | reset_state: bool = False, | ||
| 342 | ) -> None: | ||
| 343 | hk_conn = get_hk_songs_conn() | ||
| 344 | src_conn = get_source_conn() | ||
| 345 | spider_conn = get_spider_conn() | ||
| 346 | pg_conn = get_pg_conn() | ||
| 347 | bucket = get_oss_bucket() | ||
| 348 | base_url = OSS_CONFIG['base_url'] | ||
| 349 | state_path = Path(state_file) | ||
| 350 | state = _load_state(state_path) if resume and not reset_state else {} | ||
| 351 | resume_start_id = 0 | ||
| 352 | if resume: | ||
| 353 | resume_start_id = _state_last_id(state, state_key) | ||
| 354 | if start_after_id is not None: | ||
| 355 | resume_start_id = start_after_id | ||
| 356 | |||
| 357 | total_ok = total_err = 0 | ||
| 358 | imported: list[dict] = [] | ||
| 359 | |||
| 360 | try: | ||
| 361 | for i, batch in enumerate(tqdm( | ||
| 362 | iter_hk_songs_batches(hk_conn, BATCH_SIZE, start_after_id=resume_start_id), | ||
| 363 | desc='batches', | ||
| 364 | )): | ||
| 365 | if max_batches is not None and i >= max_batches: | ||
| 366 | break | ||
| 367 | song_ids = [int(r['source_song_id']) for r in batch if r.get('source_song_id')] | ||
| 368 | platform_records = fetch_platform_records(src_conn, song_ids) | ||
| 369 | |||
| 370 | # index platform records by source_song_id | ||
| 371 | pr_by_song: dict[int, list] = {} | ||
| 372 | for pr in platform_records: | ||
| 373 | if pr['platform'] in platforms: | ||
| 374 | pr_by_song.setdefault(int(pr['source_song_id']), []).append(pr) | ||
| 375 | |||
| 376 | with pg_conn.cursor() as pg_cur: | ||
| 377 | for hk_row in batch: | ||
| 378 | src_id = int(hk_row['source_song_id']) if hk_row.get('source_song_id') else None | ||
| 379 | if not src_id or src_id not in pr_by_song: | ||
| 380 | continue | ||
| 381 | primary_record = select_primary_record(pr_by_song[src_id]) | ||
| 382 | wrote_yinyan_record = False | ||
| 383 | for pr in pr_by_song[src_id]: | ||
| 384 | processor = _PROCESSORS.get(pr['platform']) | ||
| 385 | if not processor: | ||
| 386 | continue | ||
| 387 | pg_cur.execute('SAVEPOINT sp_song') | ||
| 388 | try: | ||
| 389 | result = processor(hk_row, pr, spider_conn, pg_cur, bucket, base_url) | ||
| 390 | if result and primary_record and not wrote_yinyan_record: | ||
| 391 | upsert_yinyan_song_records(pg_cur, [(src_id, int(primary_record['record_id']))]) | ||
| 392 | wrote_yinyan_record = True | ||
| 393 | pg_cur.execute('RELEASE SAVEPOINT sp_song') | ||
| 394 | total_ok += 1 | ||
| 395 | if result: | ||
| 396 | imported.append(result) | ||
| 397 | except Exception as e: | ||
| 398 | pg_cur.execute('ROLLBACK TO SAVEPOINT sp_song') | ||
| 399 | pg_cur.execute('RELEASE SAVEPOINT sp_song') | ||
| 400 | log.error("Error processing song %s platform %s: %s", | ||
| 401 | hk_row.get('name'), pr['platform'], e) | ||
| 402 | total_err += 1 | ||
| 403 | pg_conn.commit() | ||
| 404 | if resume: | ||
| 405 | state.setdefault(state_key, {})['last_hk_songs_id'] = int(batch[-1]['id']) | ||
| 406 | _save_state(state_path, state) | ||
| 407 | |||
| 408 | finally: | ||
| 409 | hk_conn.close() | ||
| 410 | src_conn.close() | ||
| 411 | spider_conn.close() | ||
| 412 | pg_conn.close() | ||
| 413 | |||
| 414 | log.info("Done. OK=%d ERR=%d", total_ok, total_err) | ||
| 415 | if imported: | ||
| 416 | print(f"\n导入记录(共 {len(imported)} 条):") | ||
| 417 | for r in imported: | ||
| 418 | p = r['platform'] | ||
| 419 | if p == 'qq': | ||
| 420 | print(f" [QQ] platform_song_id={r['platform_song_id']} mid={r['mid']} title={r['title']}") | ||
| 421 | elif p == 'kugou': | ||
| 422 | print(f" [酷狗] platform_song_id={r['platform_song_id']} hash={r['hash']} title={r['title']}") | ||
| 423 | elif p == 'netease': | ||
| 424 | print(f" [网易] platform_song_id={r['platform_song_id']} title={r['title']}") |
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
| 1 | import uuid | ||
| 2 | |||
| 3 | |||
| 4 | def upsert_yinyan_song_records(cur, pairs: list[tuple[int, int]]) -> None: | ||
| 5 | """pairs: [(source_song_id, hk_music_record_id), ...]""" | ||
| 6 | if not pairs: | ||
| 7 | return | ||
| 8 | cur.executemany( | ||
| 9 | "DELETE FROM yinyan_song_records WHERE song_id = %s", | ||
| 10 | [(song_id,) for song_id, _ in pairs], | ||
| 11 | ) | ||
| 12 | cur.executemany( | ||
| 13 | """ | ||
| 14 | INSERT INTO yinyan_song_records (song_id, record_id) | ||
| 15 | VALUES (%s, %s) | ||
| 16 | """, | ||
| 17 | pairs, | ||
| 18 | ) | ||
| 19 | |||
| 20 | |||
| 21 | # ─── QQ Music ──────────────────────────────────────────────────────────────── | ||
| 22 | |||
| 23 | def upsert_qq_singers(cur, singers: list[dict]) -> None: | ||
| 24 | if not singers: | ||
| 25 | return | ||
| 26 | sql = """ | ||
| 27 | INSERT INTO crawler_qqmusic_singers | ||
| 28 | (id, mid, name, avatar, sex, area, "index", intro, home_url, created_at, updated_at) | ||
| 29 | VALUES (%s, %s, %s, %s, %s::singer_sex, %s::singer_area, %s::singer_index, %s, %s, NOW(), NOW()) | ||
| 30 | ON CONFLICT (id) DO NOTHING | ||
| 31 | """ | ||
| 32 | rows = [( | ||
| 33 | s['id'], s['mid'], s['name'], s.get('avatar', ''), | ||
| 34 | s.get('sex') or 'U', s.get('area') or '其他', s.get('index') or '#', | ||
| 35 | s.get('intro'), s.get('home_url'), | ||
| 36 | ) for s in singers] | ||
| 37 | cur.executemany(sql, rows) | ||
| 38 | |||
| 39 | |||
| 40 | def upsert_qq_albums(cur, albums: list[dict]) -> None: | ||
| 41 | if not albums: | ||
| 42 | return | ||
| 43 | sql = """ | ||
| 44 | INSERT INTO crawler_qqmusic_albums | ||
| 45 | (id, mid, cover, title, intro, type, company_id, company, is_owner, published_at, created_at, updated_at) | ||
| 46 | VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW(), NOW()) | ||
| 47 | ON CONFLICT (id) DO NOTHING | ||
| 48 | """ | ||
| 49 | rows = [( | ||
| 50 | a['id'], a.get('mid', ''), a.get('cover', ''), a.get('title', ''), | ||
| 51 | a.get('intro'), a.get('type', ''), a.get('company_id', 0) or 0, | ||
| 52 | a.get('company', ''), a.get('is_owner', 0) or 0, a.get('published_at'), | ||
| 53 | ) for a in albums] | ||
| 54 | cur.executemany(sql, rows) | ||
| 55 | |||
| 56 | |||
| 57 | def upsert_qq_songs(cur, songs: list[dict]) -> None: | ||
| 58 | if not songs: | ||
| 59 | return | ||
| 60 | sql = """ | ||
| 61 | INSERT INTO crawler_qqmusic_songs | ||
| 62 | (id, platform_song_id, mid, album_id, cover, title, name, duration, | ||
| 63 | lyric, composer_name, lyricist_name, url, lyric_url, | ||
| 64 | platform_index_url, published_at, singers, status, created_at, updated_at) | ||
| 65 | VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb, 0, NOW(), NOW()) | ||
| 66 | ON CONFLICT (platform_song_id) DO NOTHING | ||
| 67 | """ | ||
| 68 | rows = [( | ||
| 69 | s['song_uuid'], s['platform_song_id'], s['mid'], s.get('album_id'), | ||
| 70 | s.get('cover', ''), s.get('title', ''), s.get('name', ''), s.get('duration', 0) or 0, | ||
| 71 | s.get('lyric'), s.get('composer_name'), s.get('lyricist_name'), | ||
| 72 | s.get('url', ''), s.get('lyric_url'), | ||
| 73 | s.get('platform_index_url'), s.get('published_at'), s.get('singers_json', '[]'), | ||
| 74 | ) for s in songs] | ||
| 75 | cur.executemany(sql, rows) | ||
| 76 | |||
| 77 | |||
| 78 | def upsert_qq_singer_songs(cur, pairs: list[tuple]) -> None: | ||
| 79 | """pairs: [(singer_id, song_uuid), ...]""" | ||
| 80 | if not pairs: | ||
| 81 | return | ||
| 82 | sql = """ | ||
| 83 | INSERT INTO crawler_qqmusic_singer_songs (id, singer_id, song_id) | ||
| 84 | VALUES (%s, %s, %s) | ||
| 85 | ON CONFLICT (singer_id, song_id) DO NOTHING | ||
| 86 | """ | ||
| 87 | rows = [(str(uuid.uuid4()), singer_id, song_uuid) for singer_id, song_uuid in pairs] | ||
| 88 | cur.executemany(sql, rows) | ||
| 89 | |||
| 90 | |||
| 91 | def upsert_qq_singer_albums(cur, pairs: list[tuple]) -> None: | ||
| 92 | """pairs: [(singer_id, album_id), ...]""" | ||
| 93 | if not pairs: | ||
| 94 | return | ||
| 95 | sql = """ | ||
| 96 | INSERT INTO crawler_qqmusic_singer_albums (id, singer_id, album_id) | ||
| 97 | VALUES (%s, %s, %s) | ||
| 98 | ON CONFLICT (singer_id, album_id) DO NOTHING | ||
| 99 | """ | ||
| 100 | rows = [(str(uuid.uuid4()), singer_id, album_id) for singer_id, album_id in pairs] | ||
| 101 | cur.executemany(sql, rows) | ||
| 102 | |||
| 103 | |||
| 104 | # ─── Kugou ─────────────────────────────────────────────────────────────────── | ||
| 105 | |||
| 106 | def upsert_kugou_singers(cur, singers: list[dict]) -> None: | ||
| 107 | if not singers: | ||
| 108 | return | ||
| 109 | sql = """ | ||
| 110 | INSERT INTO crawler_kugou_singers | ||
| 111 | (id, name, avatar, sex, area, "index", intro, home_url, created_at, updated_at) | ||
| 112 | VALUES (%s, %s, %s, %s::kugou_singer_sex, %s::kugou_singer_area, %s::kugou_singer_index, %s, %s, NOW(), NOW()) | ||
| 113 | ON CONFLICT (id) DO NOTHING | ||
| 114 | """ | ||
| 115 | rows = [( | ||
| 116 | s['id'], s['name'], s.get('avatar', ''), | ||
| 117 | s.get('sex') or 'U', s.get('area') or '其他', s.get('index') or '#', | ||
| 118 | s.get('intro'), s.get('home_url', ''), | ||
| 119 | ) for s in singers] | ||
| 120 | cur.executemany(sql, rows) | ||
| 121 | |||
| 122 | |||
| 123 | def upsert_kugou_albums(cur, albums: list[dict]) -> None: | ||
| 124 | if not albums: | ||
| 125 | return | ||
| 126 | sql = """ | ||
| 127 | INSERT INTO crawler_kugou_albums | ||
| 128 | (id, cover, title, intro, type, company_id, company, is_owner, published_at, created_at, updated_at) | ||
| 129 | VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, NOW(), NOW()) | ||
| 130 | ON CONFLICT (id) DO NOTHING | ||
| 131 | """ | ||
| 132 | rows = [( | ||
| 133 | a['id'], a.get('cover', ''), a.get('title', ''), a.get('intro'), | ||
| 134 | a.get('type', ''), a.get('company_id', 0) or 0, | ||
| 135 | a.get('company', ''), a.get('is_owner', 0) or 0, a.get('published_at'), | ||
| 136 | ) for a in albums] | ||
| 137 | cur.executemany(sql, rows) | ||
| 138 | |||
| 139 | |||
| 140 | def upsert_kugou_songs(cur, songs: list[dict]) -> None: | ||
| 141 | if not songs: | ||
| 142 | return | ||
| 143 | sql = """ | ||
| 144 | INSERT INTO crawler_kugou_songs | ||
| 145 | (id, platform_song_id, hash, album_audio_id, album_id, cover, title, name, duration, | ||
| 146 | lyric, composer_name, lyricist_name, url, lyric_url, | ||
| 147 | platform_index_url, published_at, singers, status, created_at, updated_at) | ||
| 148 | VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb, 0, NOW(), NOW()) | ||
| 149 | ON CONFLICT (platform_song_id) DO NOTHING | ||
| 150 | """ | ||
| 151 | rows = [( | ||
| 152 | s['song_uuid'], s['platform_song_id'], s.get('hash', ''), | ||
| 153 | s.get('album_audio_id', 0) or 0, s.get('album_id'), | ||
| 154 | s.get('cover', ''), s.get('title', ''), s.get('name', ''), s.get('duration', 0) or 0, | ||
| 155 | s.get('lyric'), s.get('composer_name'), s.get('lyricist_name'), | ||
| 156 | s.get('url', ''), s.get('lyric_url'), | ||
| 157 | s.get('platform_index_url'), s.get('published_at'), s.get('singers_json', '[]'), | ||
| 158 | ) for s in songs] | ||
| 159 | cur.executemany(sql, rows) | ||
| 160 | |||
| 161 | |||
| 162 | def upsert_kugou_singer_songs(cur, pairs: list[tuple]) -> None: | ||
| 163 | if not pairs: | ||
| 164 | return | ||
| 165 | sql = """ | ||
| 166 | INSERT INTO crawler_kugou_singer_songs (id, singer_id, song_id) | ||
| 167 | VALUES (%s, %s, %s) | ||
| 168 | ON CONFLICT (singer_id, song_id) DO NOTHING | ||
| 169 | """ | ||
| 170 | cur.executemany(sql, [(str(uuid.uuid4()), s, sg) for s, sg in pairs]) | ||
| 171 | |||
| 172 | |||
| 173 | def upsert_kugou_singer_albums(cur, pairs: list[tuple]) -> None: | ||
| 174 | if not pairs: | ||
| 175 | return | ||
| 176 | sql = """ | ||
| 177 | INSERT INTO crawler_kugou_singer_albums (id, singer_id, album_id) | ||
| 178 | VALUES (%s, %s, %s) | ||
| 179 | ON CONFLICT (singer_id, album_id) DO NOTHING | ||
| 180 | """ | ||
| 181 | cur.executemany(sql, [(str(uuid.uuid4()), s, a) for s, a in pairs]) | ||
| 182 | |||
| 183 | |||
| 184 | # ─── Netease ───────────────────────────────────────────────────────────────── | ||
| 185 | |||
| 186 | def upsert_netease_singers(cur, singers: list[dict]) -> None: | ||
| 187 | if not singers: | ||
| 188 | return | ||
| 189 | sql = """ | ||
| 190 | INSERT INTO crawler_netease_singers | ||
| 191 | (id, name, avatar, sex, area, "index", intro, home_url, created_at, updated_at) | ||
| 192 | VALUES (%s, %s, %s, %s::netease_singer_sex, %s::netease_singer_area, %s::netease_singer_index, %s, %s, NOW(), NOW()) | ||
| 193 | ON CONFLICT (id) DO NOTHING | ||
| 194 | """ | ||
| 195 | rows = [( | ||
| 196 | s['id'], s['name'], s.get('avatar', ''), | ||
| 197 | s.get('sex') or 'U', s.get('area') or '其他', s.get('index') or '#', | ||
| 198 | s.get('intro'), s.get('home_url'), | ||
| 199 | ) for s in singers] | ||
| 200 | cur.executemany(sql, rows) | ||
| 201 | |||
| 202 | |||
| 203 | def upsert_netease_albums(cur, albums: list[dict]) -> None: | ||
| 204 | if not albums: | ||
| 205 | return | ||
| 206 | sql = """ | ||
| 207 | INSERT INTO crawler_netease_albums | ||
| 208 | (id, cover, title, intro, type, company_id, company, is_owner, published_at, created_at, updated_at) | ||
| 209 | VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, NOW(), NOW()) | ||
| 210 | ON CONFLICT (id) DO NOTHING | ||
| 211 | """ | ||
| 212 | rows = [( | ||
| 213 | a['id'], a.get('cover', ''), a.get('title', ''), a.get('intro'), | ||
| 214 | a.get('type', ''), a.get('company_id', 0) or 0, | ||
| 215 | a.get('company', ''), a.get('is_owner', 0) or 0, a.get('published_at'), | ||
| 216 | ) for a in albums] | ||
| 217 | cur.executemany(sql, rows) | ||
| 218 | |||
| 219 | |||
| 220 | def upsert_netease_songs(cur, songs: list[dict]) -> None: | ||
| 221 | if not songs: | ||
| 222 | return | ||
| 223 | sql = """ | ||
| 224 | INSERT INTO crawler_netease_songs | ||
| 225 | (id, platform_song_id, album_id, cover, title, name, duration, | ||
| 226 | lyric, composer_name, lyricist_name, url, lyric_url, | ||
| 227 | platform_index_url, published_at, singers, status, created_at, updated_at) | ||
| 228 | VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb, 0, NOW(), NOW()) | ||
| 229 | ON CONFLICT (platform_song_id) DO NOTHING | ||
| 230 | """ | ||
| 231 | rows = [( | ||
| 232 | s['song_uuid'], s['platform_song_id'], s.get('album_id'), | ||
| 233 | s.get('cover', ''), s.get('title', ''), s.get('name', ''), s.get('duration', 0) or 0, | ||
| 234 | s.get('lyric'), s.get('composer_name'), s.get('lyricist_name'), | ||
| 235 | s.get('url', ''), s.get('lyric_url'), | ||
| 236 | s.get('platform_index_url'), s.get('published_at'), s.get('singers_json', '[]'), | ||
| 237 | ) for s in songs] | ||
| 238 | cur.executemany(sql, rows) | ||
| 239 | |||
| 240 | |||
| 241 | def upsert_netease_singer_songs(cur, pairs: list[tuple]) -> None: | ||
| 242 | if not pairs: | ||
| 243 | return | ||
| 244 | sql = """ | ||
| 245 | INSERT INTO crawler_netease_singer_songs (id, singer_id, song_id) | ||
| 246 | VALUES (%s, %s, %s) | ||
| 247 | ON CONFLICT (singer_id, song_id) DO NOTHING | ||
| 248 | """ | ||
| 249 | cur.executemany(sql, [(str(uuid.uuid4()), s, sg) for s, sg in pairs]) | ||
| 250 | |||
| 251 | |||
| 252 | def upsert_netease_singer_albums(cur, pairs: list[tuple]) -> None: | ||
| 253 | if not pairs: | ||
| 254 | return | ||
| 255 | sql = """ | ||
| 256 | INSERT INTO crawler_netease_singer_albums (id, singer_id, album_id) | ||
| 257 | VALUES (%s, %s, %s) | ||
| 258 | ON CONFLICT (singer_id, album_id) DO NOTHING | ||
| 259 | """ | ||
| 260 | cur.executemany(sql, [(str(uuid.uuid4()), s, a) for s, a in pairs]) |
| 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