refactor(etl): 优化音频转存与歌词上传流程,支持音频 MD5 计算和歌词换行处理
- 移除 audit_hk_songs_duplicates.py 中删除零时长歌曲的逻辑 - 新增 utils.py 工具模块,提供音频 MD5 计算、歌词处理、OSS 上传等函数 - etl_to_crawler/oss.py 添加 transfer_url_with_md5 函数,用于下载音频、计算 MD5 并上传 OSS - etl_to_crawler/lyric.py 增加 ensure_newlines 函数,确保歌词内容行间带换行符 - etl_to_crawler/runner.py 改进音频转存调用,返回音频 MD5 值 - 通过 safe_upload_lyric 函数上传去时间戳的纯文本歌词,失败时使用原始歌词 URL - etl_to_crawler/writer.py 在歌曲数据写入中新增 audio_md5 字段支持 - 重构 yinyan_song_records 逻辑,新增初始化函数 initialize_yinyan_song_records - run_etl.py 增加 --init-yinyan-records 命令行参数支持仅初始化状态表 - 优化 ETL 运行流程,从预标记状态表中拉取待处理歌曲逐批处理,提高效率 - 新增相关单元测试以覆盖音频 MD5 计算和任务处理逻辑修改
Showing
11 changed files
with
555 additions
and
111 deletions
| ... | @@ -558,52 +558,9 @@ def main() -> int: | ... | @@ -558,52 +558,9 @@ def main() -> int: |
| 558 | help="补处理暂存表中 merge+skipped 记录:existing_into_new 入库+软删旧记录,new_into_existing 补作者合并", | 558 | help="补处理暂存表中 merge+skipped 记录:existing_into_new 入库+软删旧记录,new_into_existing 补作者合并", |
| 559 | ) | 559 | ) |
| 560 | parser.add_argument("--skip-oss", action="store_true", help="--fix-merge 时跳过歌词 OSS 上传,保留原始文本") | 560 | parser.add_argument("--skip-oss", action="store_true", help="--fix-merge 时跳过歌词 OSS 上传,保留原始文本") |
| 561 | parser.add_argument("--dry-run", action="store_true", help="仅打印计划,不写库(配合 --fix-merge / --delete-zero-duration 使用)") | 561 | parser.add_argument("--dry-run", action="store_true", help="仅打印计划,不写库(配合 --fix-merge 使用)") |
| 562 | parser.add_argument( | ||
| 563 | "--delete-zero-duration", | ||
| 564 | action="store_true", | ||
| 565 | help="软删除 song_time=0 的记录(置 deleted='1')", | ||
| 566 | ) | ||
| 567 | args = parser.parse_args() | 562 | args = parser.parse_args() |
| 568 | 563 | ||
| 569 | # --delete-zero-duration 模式 | ||
| 570 | if args.delete_zero_duration: | ||
| 571 | conn = pymysql.connect(**TARGET_DB_CONFIG) | ||
| 572 | try: | ||
| 573 | table = quote_identifier(DEFAULT_TARGET_TABLE) | ||
| 574 | with conn.cursor() as cursor: | ||
| 575 | cursor.execute( | ||
| 576 | f"SELECT id, name, lyricist, composer, song_time " | ||
| 577 | f"FROM {table} WHERE deleted = '0' AND song_time = 0" | ||
| 578 | ) | ||
| 579 | rows = list(cursor.fetchall()) | ||
| 580 | print(f"song_time=0 记录数: {len(rows)}") | ||
| 581 | if args.dry_run: | ||
| 582 | for row in rows[:20]: | ||
| 583 | print(row) | ||
| 584 | if len(rows) > 20: | ||
| 585 | print(f"... {len(rows) - 20} more rows") | ||
| 586 | return 0 | ||
| 587 | if not rows: | ||
| 588 | print("无需处理") | ||
| 589 | return 0 | ||
| 590 | ids = [row["id"] for row in rows] | ||
| 591 | placeholders = ",".join(["%s"] * len(ids)) | ||
| 592 | with conn.cursor() as cursor: | ||
| 593 | cursor.execute( | ||
| 594 | f"UPDATE {table} " | ||
| 595 | f"SET deleted = '1', modify_time = NOW(), " | ||
| 596 | f" off_shelf_remark = 'song_time=0 soft-deleted by audit script' " | ||
| 597 | f"WHERE deleted = '0' AND id IN ({placeholders})", | ||
| 598 | ids, | ||
| 599 | ) | ||
| 600 | affected = cursor.rowcount | ||
| 601 | conn.commit() | ||
| 602 | print(f"soft_deleted={affected}") | ||
| 603 | finally: | ||
| 604 | conn.close() | ||
| 605 | return 0 | ||
| 606 | |||
| 607 | # --fix-merge 模式:连双库,执行合并方向后处理 | 564 | # --fix-merge 模式:连双库,执行合并方向后处理 |
| 608 | if args.fix_merge: | 565 | if args.fix_merge: |
| 609 | target_conn = pymysql.connect(**TARGET_DB_CONFIG) | 566 | target_conn = pymysql.connect(**TARGET_DB_CONFIG) | ... | ... |
| ... | @@ -4,6 +4,16 @@ _TIMESTAMP_RE = re.compile(r'\[\d{2}:\d{2}\.\d{2,3}\]') | ... | @@ -4,6 +4,16 @@ _TIMESTAMP_RE = re.compile(r'\[\d{2}:\d{2}\.\d{2,3}\]') |
| 4 | _META_TAG_RE = re.compile(r'\[[a-zA-Z]+:[^\]]*\]') | 4 | _META_TAG_RE = re.compile(r'\[[a-zA-Z]+:[^\]]*\]') |
| 5 | 5 | ||
| 6 | 6 | ||
| 7 | def ensure_newlines(text: str | None) -> str: | ||
| 8 | """确保歌词每行之间有换行符,保留时间戳等原始内容""" | ||
| 9 | if not text: | ||
| 10 | return '' | ||
| 11 | # 将字面量 \\n / \\r 转为真正换行符 | ||
| 12 | text = text.replace('\\n', '\n').replace('\\r', '') | ||
| 13 | lines = [line.strip() for line in text.splitlines() if line.strip()] | ||
| 14 | return '\n'.join(lines) | ||
| 15 | |||
| 16 | |||
| 7 | def strip_timestamps(text: str | None) -> str: | 17 | def strip_timestamps(text: str | None) -> str: |
| 8 | if not text: | 18 | if not text: |
| 9 | return '' | 19 | return '' | ... | ... |
| 1 | import requests | 1 | import requests |
| 2 | import oss2 | 2 | import oss2 |
| 3 | from urllib.parse import urlparse | 3 | from urllib.parse import urlparse |
| 4 | from .utils import compute_audio_md5 | ||
| 4 | 5 | ||
| 5 | ARCHIVE_DEV_HOST = 'archive-dev.oss-cn-beijing.aliyuncs.com' | 6 | ARCHIVE_DEV_HOST = 'archive-dev.oss-cn-beijing.aliyuncs.com' |
| 6 | 7 | ||
| ... | @@ -21,6 +22,22 @@ def transfer_url(url: str | None, oss_key: str, bucket: oss2.Bucket, base_url: s | ... | @@ -21,6 +22,22 @@ def transfer_url(url: str | None, oss_key: str, bucket: oss2.Bucket, base_url: s |
| 21 | return f"{base_url.rstrip('/')}/{oss_key}" | 22 | return f"{base_url.rstrip('/')}/{oss_key}" |
| 22 | 23 | ||
| 23 | 24 | ||
| 25 | def transfer_url_with_md5(url: str | None, oss_key: str, bucket: oss2.Bucket, base_url: str) -> tuple[str, str]: | ||
| 26 | """ | ||
| 27 | 将音频 URL 转存到 OSS,并基于下载到的音频字节计算 MD5。 | ||
| 28 | 已在目标 OSS 的 URL 无需重新下载,无法可靠计算 MD5,返回空 MD5。 | ||
| 29 | """ | ||
| 30 | if not url: | ||
| 31 | return '', '' | ||
| 32 | if ARCHIVE_DEV_HOST in url: | ||
| 33 | return url, '' | ||
| 34 | resp = requests.get(url, timeout=30) | ||
| 35 | resp.raise_for_status() | ||
| 36 | audio_md5 = compute_audio_md5(resp.content) | ||
| 37 | bucket.put_object(oss_key, resp.content) | ||
| 38 | return f"{base_url.rstrip('/')}/{oss_key}", audio_md5 | ||
| 39 | |||
| 40 | |||
| 24 | def build_oss_key(platform: str, category: str, filename: str) -> str: | 41 | def build_oss_key(platform: str, category: str, filename: str) -> str: |
| 25 | """ | 42 | """ |
| 26 | 构造 archive-dev 内的存储路径。 | 43 | 构造 archive-dev 内的存储路径。 | ... | ... |
| ... | @@ -16,6 +16,18 @@ ORDER BY id | ... | @@ -16,6 +16,18 @@ ORDER BY id |
| 16 | LIMIT %s | 16 | LIMIT %s |
| 17 | """ | 17 | """ |
| 18 | 18 | ||
| 19 | _HK_SONGS_BY_SOURCE_IDS_QUERY = """ | ||
| 20 | SELECT id, name, lyricist, composer, audio_url, lyrics_url, | ||
| 21 | cover_url, singer, issue_time, source_song_id, song_time | ||
| 22 | FROM hk_songs_test | ||
| 23 | WHERE deleted = '0' | ||
| 24 | AND source_song_id IN ({placeholders}) | ||
| 25 | AND name IS NOT NULL AND name != '' | ||
| 26 | AND audio_url IS NOT NULL AND audio_url != '' | ||
| 27 | AND singer IS NOT NULL AND singer != '' | ||
| 28 | ORDER BY id | ||
| 29 | """ | ||
| 30 | |||
| 19 | _PLATFORM_QUERY = """ | 31 | _PLATFORM_QUERY = """ |
| 20 | SELECT | 32 | SELECT |
| 21 | sar.song_id AS source_song_id, | 33 | sar.song_id AS source_song_id, |
| ... | @@ -61,6 +73,21 @@ def iter_hk_songs_batches( | ... | @@ -61,6 +73,21 @@ def iter_hk_songs_batches( |
| 61 | break | 73 | break |
| 62 | 74 | ||
| 63 | 75 | ||
| 76 | def fetch_hk_songs_by_source_ids(conn: pymysql.Connection, source_song_ids: list[int]) -> dict[int, dict]: | ||
| 77 | if not source_song_ids: | ||
| 78 | return {} | ||
| 79 | placeholders = ','.join(['%s'] * len(source_song_ids)) | ||
| 80 | query = _HK_SONGS_BY_SOURCE_IDS_QUERY.format(placeholders=placeholders) | ||
| 81 | with conn.cursor() as cur: | ||
| 82 | cur.execute(query, source_song_ids) | ||
| 83 | rows = cur.fetchall() | ||
| 84 | return { | ||
| 85 | int(row['source_song_id']): row | ||
| 86 | for row in rows | ||
| 87 | if row.get('source_song_id') is not None | ||
| 88 | } | ||
| 89 | |||
| 90 | |||
| 64 | def fetch_platform_records(source_conn: pymysql.Connection, song_ids: list[int]) -> list[dict]: | 91 | def fetch_platform_records(source_conn: pymysql.Connection, song_ids: list[int]) -> list[dict]: |
| 65 | if not song_ids: | 92 | if not song_ids: |
| 66 | return [] | 93 | return [] | ... | ... |
This diff is collapsed.
Click to expand it.
etl_to_crawler/utils.py
0 → 100644
| 1 | """工具函数模块""" | ||
| 2 | import asyncio | ||
| 3 | import hashlib | ||
| 4 | import logging | ||
| 5 | import re | ||
| 6 | from datetime import datetime | ||
| 7 | from urllib.parse import urlparse | ||
| 8 | from zoneinfo import ZoneInfo | ||
| 9 | |||
| 10 | try: | ||
| 11 | import httpx | ||
| 12 | except ModuleNotFoundError: | ||
| 13 | httpx = None | ||
| 14 | |||
| 15 | try: | ||
| 16 | from app.core.config import settings | ||
| 17 | from app.core.oss_client import oss_client | ||
| 18 | except ModuleNotFoundError: | ||
| 19 | settings = None | ||
| 20 | oss_client = None | ||
| 21 | |||
| 22 | CHINA_TZ = ZoneInfo("Asia/Shanghai") | ||
| 23 | logger = logging.getLogger(__name__) | ||
| 24 | |||
| 25 | # OSS 上传默认超时(秒),优先使用全局配置 | ||
| 26 | _OSS_UPLOAD_TIMEOUT = settings.OSS_UPLOAD_TIMEOUT_SECONDS if settings else 30 | ||
| 27 | # 音频下载默认超时(秒),优先使用全局配置 | ||
| 28 | _AUDIO_DOWNLOAD_TIMEOUT = settings.AUDIO_DOWNLOAD_TIMEOUT_SECONDS if settings else 30 | ||
| 29 | # 音频下载最大限制(字节),优先使用全局配置 | ||
| 30 | _MAX_AUDIO_SIZE = settings.MAX_AUDIO_DOWNLOAD_SIZE if settings else 50 * 1024 * 1024 | ||
| 31 | |||
| 32 | # 歌词中常见的词曲作者匹配规则 | ||
| 33 | _LYRICIST_PATTERNS = [ | ||
| 34 | r"作\s*词\s*[::]\s*([^\r\n//]+)", | ||
| 35 | r"词\s*[::]\s*([^\r\n//]+)", | ||
| 36 | r"Lyrics?\s*[::]\s*([^\r\n//]+)", | ||
| 37 | ] | ||
| 38 | _COMPOSER_PATTERNS = [ | ||
| 39 | r"作\s*曲\s*[::]\s*([^\r\n//]+)", | ||
| 40 | r"曲\s*[::]\s*([^\r\n//]+)", | ||
| 41 | r"Composer\s*[::]\s*([^\r\n//]+)", | ||
| 42 | ] | ||
| 43 | |||
| 44 | |||
| 45 | def _search_with_patterns(patterns: list, text: str) -> str: | ||
| 46 | """根据多个正则匹配文本,返回首个命中分组""" | ||
| 47 | if not text: | ||
| 48 | return "" | ||
| 49 | for pattern in patterns: | ||
| 50 | match = re.search(pattern, text) | ||
| 51 | if match: | ||
| 52 | return match.group(1).strip() | ||
| 53 | return "" | ||
| 54 | |||
| 55 | |||
| 56 | def extract_lyricist_composer(lyric: str) -> tuple[str, str]: | ||
| 57 | """从歌词文本中提取词作者和曲作者 | ||
| 58 | |||
| 59 | Returns: | ||
| 60 | (lyricist_name, composer_name) | ||
| 61 | """ | ||
| 62 | lyricist = _search_with_patterns(_LYRICIST_PATTERNS, lyric) | ||
| 63 | composer = _search_with_patterns(_COMPOSER_PATTERNS, lyric) | ||
| 64 | return lyricist, composer | ||
| 65 | |||
| 66 | |||
| 67 | def now_cn() -> datetime: | ||
| 68 | """返回中国时区的当前时间(无时区信息),保留微秒""" | ||
| 69 | return datetime.now(CHINA_TZ).replace(tzinfo=None) | ||
| 70 | |||
| 71 | |||
| 72 | def extract_plain_lyric(lyric: str) -> str: | ||
| 73 | """去除 LRC 歌词中的时间戳和标签,保留纯文本 | ||
| 74 | |||
| 75 | Args: | ||
| 76 | lyric: 原始歌词内容(可能含 [mm:ss.xx] 时间戳和 [ti:xxx] 等标签) | ||
| 77 | |||
| 78 | Returns: | ||
| 79 | 纯文本歌词,行之间用换行符连接 | ||
| 80 | """ | ||
| 81 | if not lyric: | ||
| 82 | return "" | ||
| 83 | lines = [] | ||
| 84 | for line in lyric.splitlines(): | ||
| 85 | # 去除 LRC 时间戳 [mm:ss.xx] 或 [mm:ss.xxx] | ||
| 86 | cleaned = re.sub(r"\[\d{2}:\d{2}(?:\.\d{2,3})?\]", "", line) | ||
| 87 | # 去除标签 [xx:yy] | ||
| 88 | cleaned = re.sub(r"\[[a-zA-Z]+:[^\]]+\]", "", cleaned) | ||
| 89 | cleaned = cleaned.strip() | ||
| 90 | if cleaned: | ||
| 91 | lines.append(cleaned) | ||
| 92 | return "\n".join(lines) | ||
| 93 | |||
| 94 | |||
| 95 | def upload_plain_lyric_to_bucket(platform: str, unique_id: str, lyric: str, bucket, base_url: str) -> str: | ||
| 96 | """将歌词去时间戳后上传到当前 ETL 使用的 OSS bucket""" | ||
| 97 | plain_lyric = extract_plain_lyric(lyric) | ||
| 98 | if not plain_lyric: | ||
| 99 | return "" | ||
| 100 | oss_key = f"crawler/{platform}/lyric/{unique_id}.txt" | ||
| 101 | bucket.put_object(oss_key, plain_lyric.encode("utf-8")) | ||
| 102 | return f"{base_url.rstrip('/')}/{oss_key}" | ||
| 103 | |||
| 104 | |||
| 105 | async def upload_lyric_to_oss(platform: str, unique_id: str, lyric: str) -> str: | ||
| 106 | """将歌词去除时间戳后上传为纯文本文件到 OSS | ||
| 107 | |||
| 108 | Args: | ||
| 109 | platform: 平台标识,如 qqmusic/kugou/kuwo/netease/migu | ||
| 110 | unique_id: 平台唯一标识,如 song_mid/hash/rid 等 | ||
| 111 | lyric: 原始歌词内容 | ||
| 112 | |||
| 113 | Returns: | ||
| 114 | OSS 文件访问 URL,上传失败或歌词为空返回空字符串 | ||
| 115 | """ | ||
| 116 | if not lyric: | ||
| 117 | return "" | ||
| 118 | if oss_client is None: | ||
| 119 | logger.error("OSS client is not configured") | ||
| 120 | return "" | ||
| 121 | plain_lyric = extract_plain_lyric(lyric) | ||
| 122 | if not plain_lyric: | ||
| 123 | return "" | ||
| 124 | oss_key = f"lyrics/{platform}/{unique_id}.txt" | ||
| 125 | try: | ||
| 126 | file_url = await asyncio.wait_for( | ||
| 127 | asyncio.to_thread( | ||
| 128 | oss_client.upload_bytes, | ||
| 129 | plain_lyric.encode("utf-8"), | ||
| 130 | oss_key, | ||
| 131 | "public-read", | ||
| 132 | "text/plain; charset=utf-8", | ||
| 133 | ), | ||
| 134 | timeout=_OSS_UPLOAD_TIMEOUT, | ||
| 135 | ) | ||
| 136 | return file_url or "" | ||
| 137 | except asyncio.TimeoutError: | ||
| 138 | logger.exception(f"上传歌词到 OSS 超时 {_OSS_UPLOAD_TIMEOUT}s {platform}/{unique_id}") | ||
| 139 | return "" | ||
| 140 | except Exception as e: | ||
| 141 | logger.exception(f"上传歌词到 OSS 失败 {platform}/{unique_id}: {e}") | ||
| 142 | return "" | ||
| 143 | |||
| 144 | |||
| 145 | async def download_and_upload_audio( | ||
| 146 | audio_url: str, | ||
| 147 | oss_key: str, | ||
| 148 | *, | ||
| 149 | headers: dict | None = None, | ||
| 150 | allow_external_oss_url: bool = False, | ||
| 151 | ) -> tuple[str, str]: | ||
| 152 | """流式下载音频并上传到 OSS | ||
| 153 | |||
| 154 | 内置大小限制、下载超时、上传超时保护,防止大文件或慢网络拖垮 worker。 | ||
| 155 | |||
| 156 | Args: | ||
| 157 | audio_url: 音频下载地址 | ||
| 158 | oss_key: OSS 对象键,如 songs/qqmusic/xxxx.mp3 | ||
| 159 | headers: 可选的下载请求头 | ||
| 160 | allow_external_oss_url: 若 audio_url 已是当前 OSS 域名下的地址,是否直接透传 | ||
| 161 | |||
| 162 | Returns: | ||
| 163 | (OSS 文件 URL, 音频 MD5),失败返回 ("", "") | ||
| 164 | """ | ||
| 165 | if not audio_url: | ||
| 166 | return "", "" | ||
| 167 | |||
| 168 | if allow_external_oss_url and settings.OSS_FILE_BASE_NAME and audio_url.startswith(settings.OSS_FILE_BASE_NAME): | ||
| 169 | return audio_url, "" | ||
| 170 | |||
| 171 | audio_bytes = bytearray() | ||
| 172 | try: | ||
| 173 | async with httpx.AsyncClient(timeout=_AUDIO_DOWNLOAD_TIMEOUT) as client: | ||
| 174 | async with client.stream("GET", audio_url, headers=headers or {}) as response: | ||
| 175 | response.raise_for_status() | ||
| 176 | content_length = response.headers.get("Content-Length") | ||
| 177 | if content_length and int(content_length) > _MAX_AUDIO_SIZE: | ||
| 178 | logger.warning( | ||
| 179 | f"音频文件过大,跳过: {oss_key}, " | ||
| 180 | f"size={int(content_length) / 1024 / 1024:.2f}MB" | ||
| 181 | ) | ||
| 182 | return "", "" | ||
| 183 | async for chunk in response.aiter_bytes(chunk_size=64 * 1024): | ||
| 184 | audio_bytes.extend(chunk) | ||
| 185 | if len(audio_bytes) > _MAX_AUDIO_SIZE: | ||
| 186 | logger.warning( | ||
| 187 | f"音频下载超过大小限制,跳过: {oss_key}, " | ||
| 188 | f"size>{_MAX_AUDIO_SIZE / 1024 / 1024:.2f}MB" | ||
| 189 | ) | ||
| 190 | return "", "" | ||
| 191 | except Exception as e: | ||
| 192 | logger.error(f"下载音频失败 {oss_key}: {e}") | ||
| 193 | return "", "" | ||
| 194 | |||
| 195 | audio_bytes = bytes(audio_bytes) | ||
| 196 | audio_md5 = compute_audio_md5(audio_bytes) | ||
| 197 | try: | ||
| 198 | file_url = await asyncio.wait_for( | ||
| 199 | asyncio.to_thread(oss_client.upload_bytes, audio_bytes, oss_key), | ||
| 200 | timeout=_OSS_UPLOAD_TIMEOUT, | ||
| 201 | ) | ||
| 202 | except asyncio.TimeoutError: | ||
| 203 | logger.error(f"上传音频到 OSS 超时 {_OSS_UPLOAD_TIMEOUT}s: {oss_key}") | ||
| 204 | return "", "" | ||
| 205 | except Exception as e: | ||
| 206 | logger.error(f"上传音频到 OSS 失败 {oss_key}: {e}") | ||
| 207 | return "", "" | ||
| 208 | return file_url or "", audio_md5 | ||
| 209 | |||
| 210 | |||
| 211 | _CONTENT_TYPE_EXT_MAP = { | ||
| 212 | "image/jpeg": "jpg", | ||
| 213 | "image/jpg": "jpg", | ||
| 214 | "image/png": "png", | ||
| 215 | "image/webp": "webp", | ||
| 216 | "image/gif": "gif", | ||
| 217 | } | ||
| 218 | |||
| 219 | |||
| 220 | def _guess_image_ext(content_type: str, url: str) -> str: | ||
| 221 | """根据 Content-Type 或 URL 路径推断图片扩展名,默认 jpg""" | ||
| 222 | if content_type: | ||
| 223 | mime = content_type.split(";")[0].strip().lower() | ||
| 224 | ext = _CONTENT_TYPE_EXT_MAP.get(mime) | ||
| 225 | if ext: | ||
| 226 | return ext | ||
| 227 | path = urlparse(url).path | ||
| 228 | suffix = path.rsplit(".", 1)[-1].lower() | ||
| 229 | if suffix in {"jpg", "jpeg", "png", "webp", "gif"}: | ||
| 230 | return "jpg" if suffix == "jpeg" else suffix | ||
| 231 | return "jpg" | ||
| 232 | |||
| 233 | |||
| 234 | async def upload_cover_to_oss(platform: str, unique_id: str, image_url: str) -> str: | ||
| 235 | """下载封面图片并上传到 OSS | ||
| 236 | |||
| 237 | Args: | ||
| 238 | platform: 平台标识,如 qqmusic/kugou/kuwo/netease/migu | ||
| 239 | unique_id: 平台唯一标识,如 song_mid/hash/rid 等 | ||
| 240 | image_url: 封面图片原始 URL | ||
| 241 | |||
| 242 | Returns: | ||
| 243 | OSS 文件访问 URL,上传失败或 URL 为空返回空字符串 | ||
| 244 | """ | ||
| 245 | if not image_url: | ||
| 246 | return "" | ||
| 247 | try: | ||
| 248 | async with httpx.AsyncClient(timeout=10, follow_redirects=True) as client: | ||
| 249 | resp = await client.get(image_url, headers={ | ||
| 250 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", | ||
| 251 | "Referer": "https://www.kugou.com/", | ||
| 252 | }) | ||
| 253 | resp.raise_for_status() | ||
| 254 | image_bytes = resp.content | ||
| 255 | content_type = resp.headers.get("content-type", "") | ||
| 256 | except Exception as e: | ||
| 257 | logger.exception(f"下载封面图片失败 {platform}/{unique_id} {image_url}: {e}") | ||
| 258 | return "" | ||
| 259 | |||
| 260 | ext = _guess_image_ext(content_type, image_url) | ||
| 261 | oss_key = f"covers/{platform}/{unique_id}.{ext}" | ||
| 262 | mime = _CONTENT_TYPE_EXT_MAP.get(content_type.split(";")[0].strip().lower(), f"image/{ext}") | ||
| 263 | try: | ||
| 264 | file_url = await asyncio.wait_for( | ||
| 265 | asyncio.to_thread( | ||
| 266 | oss_client.upload_bytes, | ||
| 267 | image_bytes, | ||
| 268 | oss_key, | ||
| 269 | "public-read", | ||
| 270 | mime, | ||
| 271 | ), | ||
| 272 | timeout=_OSS_UPLOAD_TIMEOUT, | ||
| 273 | ) | ||
| 274 | return file_url or "" | ||
| 275 | except asyncio.TimeoutError: | ||
| 276 | logger.exception(f"上传封面到 OSS 超时 {_OSS_UPLOAD_TIMEOUT}s {platform}/{unique_id}") | ||
| 277 | return "" | ||
| 278 | except Exception as e: | ||
| 279 | logger.exception(f"上传封面到 OSS 失败 {platform}/{unique_id}: {e}") | ||
| 280 | return "" | ||
| 281 | |||
| 282 | |||
| 283 | def compute_audio_md5(audio_bytes: bytes) -> str: | ||
| 284 | """计算音频字节流的 MD5 值(32 位小写十六进制字符串) | ||
| 285 | |||
| 286 | Args: | ||
| 287 | audio_bytes: 音频文件字节流 | ||
| 288 | |||
| 289 | Returns: | ||
| 290 | MD5 字符串,输入为空时返回空字符串 | ||
| 291 | """ | ||
| 292 | if not audio_bytes: | ||
| 293 | return "" | ||
| 294 | return hashlib.md5(audio_bytes).hexdigest() |
| 1 | import uuid | 1 | import uuid |
| 2 | 2 | ||
| 3 | 3 | ||
| 4 | def upsert_yinyan_song_records(cur, pairs: list[tuple[int, int]]) -> None: | 4 | def insert_yinyan_song_records(cur, records: list[dict]) -> None: |
| 5 | """pairs: [(source_song_id, hk_music_record_id), ...]""" | 5 | """Initialize yinyan_song_records rows before crawler import.""" |
| 6 | if not pairs: | 6 | if not records: |
| 7 | return | 7 | return |
| 8 | cur.executemany( | 8 | cur.executemany( |
| 9 | "DELETE FROM yinyan_song_records WHERE song_id = %s", | 9 | """ |
| 10 | [(song_id,) for song_id, _ in pairs], | 10 | INSERT INTO yinyan_song_records (song_id, record_id, is_yinyan_push) |
| 11 | VALUES (%s, %s, FALSE) | ||
| 12 | ON CONFLICT (song_id, record_id) DO NOTHING | ||
| 13 | """, | ||
| 14 | [(r['song_id'], r['record_id']) for r in records], | ||
| 11 | ) | 15 | ) |
| 16 | |||
| 17 | |||
| 18 | def fetch_pending_yinyan_song_records(cur, limit: int) -> list[dict]: | ||
| 19 | cur.execute( | ||
| 20 | """ | ||
| 21 | SELECT song_id, record_id | ||
| 22 | FROM yinyan_song_records | ||
| 23 | WHERE is_yinyan_push = FALSE | ||
| 24 | ORDER BY song_id | ||
| 25 | LIMIT %s | ||
| 26 | """, | ||
| 27 | (limit,), | ||
| 28 | ) | ||
| 29 | rows = cur.fetchall() | ||
| 30 | return [{'song_id': row[0], 'record_id': row[1]} for row in rows] | ||
| 31 | |||
| 32 | |||
| 33 | def upsert_yinyan_song_records(cur, records: list[dict]) -> None: | ||
| 34 | """Mark pre-initialized yinyan song-record rows as pushed to crawler.""" | ||
| 35 | if not records: | ||
| 36 | return | ||
| 12 | cur.executemany( | 37 | cur.executemany( |
| 13 | """ | 38 | """ |
| 14 | INSERT INTO yinyan_song_records (song_id, record_id) | 39 | UPDATE yinyan_song_records |
| 15 | VALUES (%s, %s) | 40 | SET platform = %s, |
| 41 | platform_song_id = %s, | ||
| 42 | is_yinyan_push = TRUE | ||
| 43 | WHERE song_id = %s AND record_id = %s | ||
| 16 | """, | 44 | """, |
| 17 | pairs, | 45 | [ |
| 46 | (r['platform'], r['platform_song_id'], r['song_id'], r['record_id']) | ||
| 47 | for r in records | ||
| 48 | ], | ||
| 18 | ) | 49 | ) |
| 19 | 50 | ||
| 20 | 51 | ||
| ... | @@ -67,8 +98,8 @@ def upsert_qq_songs(cur, songs: list[dict]) -> None: | ... | @@ -67,8 +98,8 @@ def upsert_qq_songs(cur, songs: list[dict]) -> None: |
| 67 | (id, platform_song_id, mid, album_id, cover, title, name, duration, | 98 | (id, platform_song_id, mid, album_id, cover, title, name, duration, |
| 68 | lyric, composer_name, lyricist_name, url, lyric_url, | 99 | lyric, composer_name, lyricist_name, url, lyric_url, |
| 69 | platform_index_url, published_at, singers, status, created_at, updated_at, | 100 | platform_index_url, published_at, singers, status, created_at, updated_at, |
| 70 | provider_name, crawler_source_data) | 101 | audio_md5, provider_name, crawler_source_data) |
| 71 | VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb, 0, NOW(), NOW(), %s, %s::json) | 102 | VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb, 0, NOW(), NOW(), %s, %s, %s::json) |
| 72 | ON CONFLICT (platform_song_id) DO NOTHING | 103 | ON CONFLICT (platform_song_id) DO NOTHING |
| 73 | """ | 104 | """ |
| 74 | rows = [( | 105 | rows = [( |
| ... | @@ -77,6 +108,7 @@ def upsert_qq_songs(cur, songs: list[dict]) -> None: | ... | @@ -77,6 +108,7 @@ def upsert_qq_songs(cur, songs: list[dict]) -> None: |
| 77 | s.get('lyric'), s.get('composer_name'), s.get('lyricist_name'), | 108 | s.get('lyric'), s.get('composer_name'), s.get('lyricist_name'), |
| 78 | s.get('url', ''), s.get('lyric_url'), | 109 | s.get('url', ''), s.get('lyric_url'), |
| 79 | s.get('platform_index_url'), s.get('published_at'), s.get('singers_json', '[]'), | 110 | s.get('platform_index_url'), s.get('published_at'), s.get('singers_json', '[]'), |
| 111 | s.get('audio_md5'), | ||
| 80 | s.get('provider_name'), s.get('crawler_source_data'), | 112 | s.get('provider_name'), s.get('crawler_source_data'), |
| 81 | ) for s in songs] | 113 | ) for s in songs] |
| 82 | cur.executemany(sql, rows) | 114 | cur.executemany(sql, rows) |
| ... | @@ -157,8 +189,8 @@ def upsert_kugou_songs(cur, songs: list[dict]) -> None: | ... | @@ -157,8 +189,8 @@ def upsert_kugou_songs(cur, songs: list[dict]) -> None: |
| 157 | (id, platform_song_id, hash, album_audio_id, album_id, cover, title, name, duration, | 189 | (id, platform_song_id, hash, album_audio_id, album_id, cover, title, name, duration, |
| 158 | lyric, composer_name, lyricist_name, url, lyric_url, | 190 | lyric, composer_name, lyricist_name, url, lyric_url, |
| 159 | platform_index_url, published_at, singers, status, created_at, updated_at, | 191 | platform_index_url, published_at, singers, status, created_at, updated_at, |
| 160 | provider_name, crawler_source_data) | 192 | audio_md5, provider_name, crawler_source_data) |
| 161 | VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb, 0, NOW(), NOW(), %s, %s::json) | 193 | VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb, 0, NOW(), NOW(), %s, %s, %s::json) |
| 162 | ON CONFLICT (platform_song_id) DO NOTHING | 194 | ON CONFLICT (platform_song_id) DO NOTHING |
| 163 | """ | 195 | """ |
| 164 | rows = [( | 196 | rows = [( |
| ... | @@ -168,6 +200,7 @@ def upsert_kugou_songs(cur, songs: list[dict]) -> None: | ... | @@ -168,6 +200,7 @@ def upsert_kugou_songs(cur, songs: list[dict]) -> None: |
| 168 | s.get('lyric'), s.get('composer_name'), s.get('lyricist_name'), | 200 | s.get('lyric'), s.get('composer_name'), s.get('lyricist_name'), |
| 169 | s.get('url', ''), s.get('lyric_url'), | 201 | s.get('url', ''), s.get('lyric_url'), |
| 170 | s.get('platform_index_url'), s.get('published_at'), s.get('singers_json', '[]'), | 202 | s.get('platform_index_url'), s.get('published_at'), s.get('singers_json', '[]'), |
| 203 | s.get('audio_md5'), | ||
| 171 | s.get('provider_name'), s.get('crawler_source_data'), | 204 | s.get('provider_name'), s.get('crawler_source_data'), |
| 172 | ) for s in songs] | 205 | ) for s in songs] |
| 173 | cur.executemany(sql, rows) | 206 | cur.executemany(sql, rows) |
| ... | @@ -244,8 +277,8 @@ def upsert_netease_songs(cur, songs: list[dict]) -> None: | ... | @@ -244,8 +277,8 @@ def upsert_netease_songs(cur, songs: list[dict]) -> None: |
| 244 | (id, platform_song_id, album_id, cover, title, name, duration, | 277 | (id, platform_song_id, album_id, cover, title, name, duration, |
| 245 | lyric, composer_name, lyricist_name, url, lyric_url, | 278 | lyric, composer_name, lyricist_name, url, lyric_url, |
| 246 | platform_index_url, published_at, album, singers, status, created_at, updated_at, | 279 | platform_index_url, published_at, album, singers, status, created_at, updated_at, |
| 247 | provider_name, crawler_source_data) | 280 | audio_md5, provider_name, crawler_source_data) |
| 248 | VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s::json, %s::jsonb, 0, NOW(), NOW(), %s, %s::json) | 281 | VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s::json, %s::jsonb, 0, NOW(), NOW(), %s, %s, %s::json) |
| 249 | ON CONFLICT (platform_song_id) DO NOTHING | 282 | ON CONFLICT (platform_song_id) DO NOTHING |
| 250 | """ | 283 | """ |
| 251 | rows = [( | 284 | rows = [( |
| ... | @@ -254,6 +287,7 @@ def upsert_netease_songs(cur, songs: list[dict]) -> None: | ... | @@ -254,6 +287,7 @@ def upsert_netease_songs(cur, songs: list[dict]) -> None: |
| 254 | s.get('lyric'), s.get('composer_name'), s.get('lyricist_name'), | 287 | s.get('lyric'), s.get('composer_name'), s.get('lyricist_name'), |
| 255 | s.get('url', ''), s.get('lyric_url'), | 288 | s.get('url', ''), s.get('lyric_url'), |
| 256 | s.get('platform_index_url'), s.get('published_at'), s.get('album_json'), s.get('singers_json', '[]'), | 289 | s.get('platform_index_url'), s.get('published_at'), s.get('album_json'), s.get('singers_json', '[]'), |
| 290 | s.get('audio_md5'), | ||
| 257 | s.get('provider_name'), s.get('crawler_source_data'), | 291 | s.get('provider_name'), s.get('crawler_source_data'), |
| 258 | ) for s in songs] | 292 | ) for s in songs] |
| 259 | cur.executemany(sql, rows) | 293 | cur.executemany(sql, rows) | ... | ... |
| 1 | #!/usr/bin/env python3 | 1 | #!/usr/bin/env python3 |
| 2 | import argparse | 2 | import argparse |
| 3 | from etl_to_crawler.config import PLATFORM_QQ, PLATFORM_KUGOU, PLATFORM_NETEASE, PLATFORMS | 3 | from etl_to_crawler.config import PLATFORM_QQ, PLATFORM_KUGOU, PLATFORM_NETEASE, PLATFORMS |
| 4 | from etl_to_crawler.runner import run | 4 | from etl_to_crawler.runner import initialize_yinyan_song_records, run |
| 5 | 5 | ||
| 6 | PLATFORM_MAP = { | 6 | PLATFORM_MAP = { |
| 7 | 'qq': PLATFORM_QQ, | 7 | 'qq': PLATFORM_QQ, |
| ... | @@ -16,14 +16,8 @@ if __name__ == '__main__': | ... | @@ -16,14 +16,8 @@ if __name__ == '__main__': |
| 16 | help='要导入的平台(默认 all)') | 16 | help='要导入的平台(默认 all)') |
| 17 | parser.add_argument('--max-batches', type=int, default=None, | 17 | parser.add_argument('--max-batches', type=int, default=None, |
| 18 | help='最多处理多少批次(冒烟测试用)') | 18 | help='最多处理多少批次(冒烟测试用)') |
| 19 | parser.add_argument('--resume', action='store_true', | 19 | parser.add_argument('--init-yinyan-records', action='store_true', |
| 20 | help='启用断点续传,按 hk_songs_test.id 从上次成功提交的批次后继续') | 20 | help='只初始化 yinyan_song_records 待导入状态表,不执行 crawler 导入') |
| 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() | 21 | args = parser.parse_args() |
| 28 | 22 | ||
| 29 | if args.platform == 'all': | 23 | if args.platform == 'all': |
| ... | @@ -32,12 +26,7 @@ if __name__ == '__main__': | ... | @@ -32,12 +26,7 @@ if __name__ == '__main__': |
| 32 | platforms = [PLATFORM_MAP[args.platform]] | 26 | platforms = [PLATFORM_MAP[args.platform]] |
| 33 | 27 | ||
| 34 | print(f"Starting ETL for platforms: {platforms}") | 28 | print(f"Starting ETL for platforms: {platforms}") |
| 35 | run( | 29 | if args.init_yinyan_records: |
| 36 | platforms, | 30 | initialize_yinyan_song_records(platforms, max_batches=args.max_batches) |
| 37 | max_batches=args.max_batches, | 31 | else: |
| 38 | resume=args.resume, | 32 | run(platforms, max_batches=args.max_batches) |
| 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 | ) | ... | ... |
| 1 | from unittest.mock import MagicMock, patch | 1 | from unittest.mock import MagicMock, patch |
| 2 | from etl_to_crawler.oss import transfer_url | 2 | from etl_to_crawler.oss import transfer_url, transfer_url_with_md5 |
| 3 | 3 | ||
| 4 | ARCHIVE_URL = "https://archive-dev.oss-cn-beijing.aliyuncs.com/some/path.mp3" | 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" | 5 | OTHER_URL = "https://hikoon-data-platform.oss-cn-beijing.aliyuncs.com/qq-audio/abc.mp3" |
| ... | @@ -31,3 +31,15 @@ def test_external_url_downloads_and_uploads(): | ... | @@ -31,3 +31,15 @@ def test_external_url_downloads_and_uploads(): |
| 31 | result = transfer_url(OTHER_URL, "crawler/qq/audio/abc.mp3", bucket, BASE_URL) | 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) | 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" | 33 | assert result == f"{BASE_URL}/crawler/qq/audio/abc.mp3" |
| 34 | |||
| 35 | |||
| 36 | def test_transfer_url_with_md5_hashes_downloaded_content_before_upload(): | ||
| 37 | bucket = MagicMock() | ||
| 38 | fake_content = b"audio_bytes" | ||
| 39 | with patch('etl_to_crawler.oss.requests.get') as mock_get: | ||
| 40 | mock_get.return_value.content = fake_content | ||
| 41 | mock_get.return_value.raise_for_status = MagicMock() | ||
| 42 | result, audio_md5 = transfer_url_with_md5(OTHER_URL, "crawler/qq/audio/abc.mp3", bucket, BASE_URL) | ||
| 43 | bucket.put_object.assert_called_once_with("crawler/qq/audio/abc.mp3", fake_content) | ||
| 44 | assert result == f"{BASE_URL}/crawler/qq/audio/abc.mp3" | ||
| 45 | assert audio_md5 == "04d43544b267629d9089eaed3b847a99" | ... | ... |
| ... | @@ -48,12 +48,15 @@ def test_run_imports_all_platform_records_but_writes_one_primary_yinyan_relation | ... | @@ -48,12 +48,15 @@ def test_run_imports_all_platform_records_but_writes_one_primary_yinyan_relation |
| 48 | monkeypatch.setattr(runner, 'get_spider_conn', lambda: _Connection()) | 48 | monkeypatch.setattr(runner, 'get_spider_conn', lambda: _Connection()) |
| 49 | monkeypatch.setattr(runner, 'get_pg_conn', lambda: pg_conn) | 49 | monkeypatch.setattr(runner, 'get_pg_conn', lambda: pg_conn) |
| 50 | monkeypatch.setattr(runner, 'get_oss_bucket', lambda: object()) | 50 | monkeypatch.setattr(runner, 'get_oss_bucket', lambda: object()) |
| 51 | monkeypatch.setattr(runner, 'iter_hk_songs_batches', lambda conn, batch_size, start_after_id=0: [[{ | 51 | monkeypatch.setattr(runner, 'fetch_pending_yinyan_song_records', lambda cur, batch_size: [ |
| 52 | {'song_id': 10, 'record_id': 200}, | ||
| 53 | ] if pg_conn.commits == 0 else []) | ||
| 54 | monkeypatch.setattr(runner, 'fetch_hk_songs_by_source_ids', lambda conn, song_ids: {10: { | ||
| 52 | 'source_song_id': 10, | 55 | 'source_song_id': 10, |
| 53 | 'name': '歌', | 56 | 'name': '歌', |
| 54 | 'audio_url': 'https://example.com/a.mp3', | 57 | 'audio_url': 'https://example.com/a.mp3', |
| 55 | 'singer': '歌手', | 58 | 'singer': '歌手', |
| 56 | }]]) | 59 | }}) |
| 57 | monkeypatch.setattr(runner, 'fetch_platform_records', lambda conn, song_ids: [ | 60 | monkeypatch.setattr(runner, 'fetch_platform_records', lambda conn, song_ids: [ |
| 58 | { | 61 | { |
| 59 | 'source_song_id': 10, | 62 | 'source_song_id': 10, |
| ... | @@ -85,37 +88,56 @@ def test_run_imports_all_platform_records_but_writes_one_primary_yinyan_relation | ... | @@ -85,37 +88,56 @@ def test_run_imports_all_platform_records_but_writes_one_primary_yinyan_relation |
| 85 | 88 | ||
| 86 | processors['1'].assert_called_once() | 89 | processors['1'].assert_called_once() |
| 87 | processors['2'].assert_called_once() | 90 | processors['2'].assert_called_once() |
| 88 | yinyan_writer.assert_called_once_with(pg_conn.cur, [(10, 200)]) | 91 | yinyan_writer.assert_called_once_with(pg_conn.cur, [{ |
| 92 | 'song_id': 10, | ||
| 93 | 'record_id': 200, | ||
| 94 | 'platform': 'kugou', | ||
| 95 | 'platform_song_id': 200, | ||
| 96 | }]) | ||
| 89 | assert pg_conn.commits == 1 | 97 | assert pg_conn.commits == 1 |
| 90 | 98 | ||
| 91 | 99 | ||
| 92 | def test_run_resume_starts_after_saved_id_and_updates_state_after_commit(monkeypatch, tmp_path): | 100 | def test_initialize_yinyan_song_records_inserts_primary_records(monkeypatch): |
| 93 | pg_conn = _PgConnection() | 101 | pg_conn = _PgConnection() |
| 94 | state_file = tmp_path / 'etl_state.json' | 102 | inserted = [] |
| 95 | state_file.write_text('{"all": {"last_hk_songs_id": 40}}', encoding='utf-8') | ||
| 96 | seen_start_ids = [] | ||
| 97 | 103 | ||
| 98 | monkeypatch.setattr(runner, 'get_hk_songs_conn', lambda: _Connection()) | 104 | monkeypatch.setattr(runner, 'get_hk_songs_conn', lambda: _Connection()) |
| 99 | monkeypatch.setattr(runner, 'get_source_conn', lambda: _Connection()) | 105 | 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) | 106 | 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 | 107 | ||
| 111 | monkeypatch.setattr(runner, 'iter_hk_songs_batches', fake_batches) | 108 | monkeypatch.setattr(runner, 'iter_hk_songs_batches', lambda conn, batch_size: [[ |
| 112 | monkeypatch.setattr(runner, 'fetch_platform_records', lambda conn, song_ids: []) | 109 | {'id': 50, 'source_song_id': 10, 'name': '歌', 'audio_url': 'https://example.com/a.mp3', 'singer': '歌手'}, |
| 110 | ]]) | ||
| 111 | monkeypatch.setattr(runner, 'fetch_platform_records', lambda conn, song_ids: [ | ||
| 112 | { | ||
| 113 | 'source_song_id': 10, | ||
| 114 | 'record_id': 100, | ||
| 115 | 'platform': '1', | ||
| 116 | 'platform_unique_key': 'qq-mid', | ||
| 117 | 'platform_mid': '100', | ||
| 118 | 'album_audio_id': None, | ||
| 119 | 'is_main_version': 0, | ||
| 120 | 'is_high': 0, | ||
| 121 | 'pub_time': '2020-01-01', | ||
| 122 | }, | ||
| 123 | { | ||
| 124 | 'source_song_id': 10, | ||
| 125 | 'record_id': 200, | ||
| 126 | 'platform': '2', | ||
| 127 | 'platform_unique_key': '200', | ||
| 128 | 'platform_mid': 'kg-hash', | ||
| 129 | 'album_audio_id': None, | ||
| 130 | 'is_main_version': 1, | ||
| 131 | 'is_high': 0, | ||
| 132 | 'pub_time': '2021-01-01', | ||
| 133 | }, | ||
| 134 | ]) | ||
| 135 | monkeypatch.setattr(runner, 'insert_yinyan_song_records', lambda cur, rows: inserted.extend(rows)) | ||
| 113 | 136 | ||
| 114 | runner.run(['1', '2', '4'], resume=True, state_file=state_file, state_key='all') | 137 | runner.initialize_yinyan_song_records(['1', '2']) |
| 115 | 138 | ||
| 116 | assert seen_start_ids == [40] | 139 | assert inserted == [{'song_id': 10, 'record_id': 200}] |
| 117 | assert pg_conn.commits == 1 | 140 | assert pg_conn.commits == 1 |
| 118 | assert '"last_hk_songs_id": 60' in state_file.read_text(encoding='utf-8') | ||
| 119 | 141 | ||
| 120 | 142 | ||
| 121 | def test_process_netease_builds_album_json_for_song_insert(monkeypatch): | 143 | def test_process_netease_builds_album_json_for_song_insert(monkeypatch): |
| ... | @@ -147,6 +169,7 @@ def test_process_netease_builds_album_json_for_song_insert(monkeypatch): | ... | @@ -147,6 +169,7 @@ def test_process_netease_builds_album_json_for_song_insert(monkeypatch): |
| 147 | }) | 169 | }) |
| 148 | monkeypatch.setattr(runner, 'fetch_netease_singers', lambda conn, song_ids: {}) | 170 | monkeypatch.setattr(runner, 'fetch_netease_singers', lambda conn, song_ids: {}) |
| 149 | monkeypatch.setattr(runner, '_safe_transfer', lambda url, oss_key, bucket, base_url: url) | 171 | monkeypatch.setattr(runner, '_safe_transfer', lambda url, oss_key, bucket, base_url: url) |
| 172 | monkeypatch.setattr(runner, '_safe_transfer_audio', lambda url, oss_key, bucket, base_url: (url, 'audio-md5')) | ||
| 150 | monkeypatch.setattr(runner, 'upsert_netease_singers', lambda cur, singers: None) | 173 | monkeypatch.setattr(runner, 'upsert_netease_singers', lambda cur, singers: None) |
| 151 | monkeypatch.setattr(runner, 'upsert_netease_albums', lambda cur, albums: None) | 174 | monkeypatch.setattr(runner, 'upsert_netease_albums', lambda cur, albums: None) |
| 152 | monkeypatch.setattr(runner, 'upsert_netease_songs', lambda cur, songs: inserted_songs.extend(songs)) | 175 | monkeypatch.setattr(runner, 'upsert_netease_songs', lambda cur, songs: inserted_songs.extend(songs)) |
| ... | @@ -174,3 +197,60 @@ def test_process_netease_builds_album_json_for_song_insert(monkeypatch): | ... | @@ -174,3 +197,60 @@ def test_process_netease_builds_album_json_for_song_insert(monkeypatch): |
| 174 | assert inserted_songs[0]['album_json'] | 197 | assert inserted_songs[0]['album_json'] |
| 175 | assert '"id": 20' in inserted_songs[0]['album_json'] | 198 | assert '"id": 20' in inserted_songs[0]['album_json'] |
| 176 | assert '"title": "专辑"' in inserted_songs[0]['album_json'] | 199 | assert '"title": "专辑"' in inserted_songs[0]['album_json'] |
| 200 | |||
| 201 | |||
| 202 | def test_process_netease_keeps_timestamped_lyric_and_uploads_plain_lyric(monkeypatch): | ||
| 203 | pg_cur = MagicMock() | ||
| 204 | pg_cur.fetchone.return_value = ('song-uuid',) | ||
| 205 | inserted_songs = [] | ||
| 206 | uploaded = {} | ||
| 207 | |||
| 208 | class Bucket: | ||
| 209 | def put_object(self, key, body): | ||
| 210 | uploaded['key'] = key | ||
| 211 | uploaded['body'] = body | ||
| 212 | |||
| 213 | monkeypatch.setattr(runner, 'fetch_netease_songs', lambda conn, song_ids: { | ||
| 214 | 300: { | ||
| 215 | 'id': 300, | ||
| 216 | 'album_id': None, | ||
| 217 | 'cover': 'https://example.com/cover.jpg', | ||
| 218 | 'title': '录音标题', | ||
| 219 | 'duration': 180, | ||
| 220 | 'lyric': '[ti:歌名]\n[00:01.00]第一句\n[00:02.00]第二句', | ||
| 221 | 'composer_name': '曲作者', | ||
| 222 | 'lyricist_name': '词作者', | ||
| 223 | 'platform_index_url': None, | ||
| 224 | 'published_at': '2020-01-02', | ||
| 225 | }, | ||
| 226 | }) | ||
| 227 | monkeypatch.setattr(runner, 'fetch_netease_singers', lambda conn, song_ids: {}) | ||
| 228 | monkeypatch.setattr(runner, '_safe_transfer', lambda url, oss_key, bucket, base_url: url) | ||
| 229 | monkeypatch.setattr(runner, '_safe_transfer_audio', lambda url, oss_key, bucket, base_url: (url, 'audio-md5')) | ||
| 230 | monkeypatch.setattr(runner, 'upsert_netease_singers', lambda cur, singers: None) | ||
| 231 | monkeypatch.setattr(runner, 'upsert_netease_albums', lambda cur, albums: None) | ||
| 232 | monkeypatch.setattr(runner, 'upsert_netease_songs', lambda cur, songs: inserted_songs.extend(songs)) | ||
| 233 | monkeypatch.setattr(runner, 'upsert_netease_singer_songs', lambda cur, pairs: None) | ||
| 234 | |||
| 235 | runner._process_netease( | ||
| 236 | { | ||
| 237 | 'name': '词曲名', | ||
| 238 | 'audio_url': 'https://example.com/audio.mp3', | ||
| 239 | 'lyrics_url': 'https://example.com/original.lrc', | ||
| 240 | 'cover_url': '', | ||
| 241 | 'composer': '词曲曲作者', | ||
| 242 | 'lyricist': '词曲词作者', | ||
| 243 | 'issue_time': '2019-01-01', | ||
| 244 | 'song_time': 120, | ||
| 245 | }, | ||
| 246 | {'platform_unique_key': '300'}, | ||
| 247 | spider_conn=object(), | ||
| 248 | pg_cur=pg_cur, | ||
| 249 | bucket=Bucket(), | ||
| 250 | base_url='https://bucket.example.com', | ||
| 251 | ) | ||
| 252 | |||
| 253 | assert inserted_songs[0]['lyric'] == '[ti:歌名]\n[00:01.00]第一句\n[00:02.00]第二句' | ||
| 254 | assert inserted_songs[0]['audio_md5'] == 'audio-md5' | ||
| 255 | assert inserted_songs[0]['lyric_url'] == 'https://bucket.example.com/crawler/netease/lyric/300.txt' | ||
| 256 | assert uploaded['body'].decode('utf-8') == '第一句\n第二句' | ... | ... |
| 1 | from unittest.mock import MagicMock, call | 1 | from unittest.mock import MagicMock, call |
| 2 | from etl_to_crawler.writer import ( | 2 | from etl_to_crawler.writer import ( |
| 3 | insert_yinyan_song_records, | ||
| 3 | upsert_kugou_albums, | 4 | upsert_kugou_albums, |
| 4 | upsert_kugou_singers, | 5 | upsert_kugou_singers, |
| 5 | upsert_kugou_songs, | 6 | upsert_kugou_songs, |
| ... | @@ -41,18 +42,35 @@ def test_upsert_qq_singer_songs(): | ... | @@ -41,18 +42,35 @@ def test_upsert_qq_singer_songs(): |
| 41 | assert 'crawler_qqmusic_singer_songs' in sql | 42 | assert 'crawler_qqmusic_singer_songs' in sql |
| 42 | 43 | ||
| 43 | 44 | ||
| 44 | def test_upsert_yinyan_song_records_replaces_existing_song_relation(): | 45 | def test_upsert_yinyan_song_records_marks_existing_relation_as_pushed(): |
| 45 | cur = MagicMock() | 46 | cur = MagicMock() |
| 46 | upsert_yinyan_song_records(cur, [(10, 100), (11, 101)]) | 47 | upsert_yinyan_song_records(cur, [ |
| 48 | {'song_id': 10, 'record_id': 100, 'platform': 'qq', 'platform_song_id': 1000}, | ||
| 49 | {'song_id': 11, 'record_id': 101, 'platform': 'kugou', 'platform_song_id': 1001}, | ||
| 50 | ]) | ||
| 47 | 51 | ||
| 48 | assert cur.executemany.call_count == 2 | 52 | sql, rows = cur.executemany.call_args[0] |
| 49 | delete_sql, delete_rows = cur.executemany.call_args_list[0][0] | 53 | assert 'UPDATE yinyan_song_records' in sql |
| 50 | insert_sql, insert_rows = cur.executemany.call_args_list[1][0] | 54 | assert 'platform = %s' in sql |
| 51 | assert 'DELETE FROM yinyan_song_records' in delete_sql | 55 | assert 'platform_song_id = %s' in sql |
| 52 | assert 'WHERE song_id = %s' in delete_sql | 56 | assert 'is_yinyan_push = TRUE' in sql |
| 53 | assert delete_rows == [(10,), (11,)] | 57 | assert 'WHERE song_id = %s AND record_id = %s' in sql |
| 54 | assert 'INSERT INTO yinyan_song_records' in insert_sql | 58 | assert rows == [('qq', 1000, 10, 100), ('kugou', 1001, 11, 101)] |
| 55 | assert insert_rows == [(10, 100), (11, 101)] | 59 | |
| 60 | |||
| 61 | def test_insert_yinyan_song_records_initializes_unpushed_rows(): | ||
| 62 | cur = MagicMock() | ||
| 63 | insert_yinyan_song_records(cur, [ | ||
| 64 | {'song_id': 10, 'record_id': 100}, | ||
| 65 | {'song_id': 11, 'record_id': 101}, | ||
| 66 | ]) | ||
| 67 | |||
| 68 | sql, rows = cur.executemany.call_args[0] | ||
| 69 | assert 'INSERT INTO yinyan_song_records' in sql | ||
| 70 | assert 'is_yinyan_push' in sql | ||
| 71 | assert 'FALSE' in sql | ||
| 72 | assert 'ON CONFLICT (song_id, record_id) DO NOTHING' in sql | ||
| 73 | assert rows == [(10, 100), (11, 101)] | ||
| 56 | 74 | ||
| 57 | 75 | ||
| 58 | def test_upsert_netease_songs_writes_album_json_column(): | 76 | def test_upsert_netease_songs_writes_album_json_column(): |
| ... | @@ -104,12 +122,14 @@ def test_upsert_kugou_songs_writes_provider_and_source_data(): | ... | @@ -104,12 +122,14 @@ def test_upsert_kugou_songs_writes_provider_and_source_data(): |
| 104 | 'singers_json': '[]', | 122 | 'singers_json': '[]', |
| 105 | 'provider_name': 'yinyan', | 123 | 'provider_name': 'yinyan', |
| 106 | 'crawler_source_data': '{"id": 200}', | 124 | 'crawler_source_data': '{"id": 200}', |
| 125 | 'audio_md5': 'md5-200', | ||
| 107 | }]) | 126 | }]) |
| 108 | 127 | ||
| 109 | sql, rows = cur.executemany.call_args[0] | 128 | sql, rows = cur.executemany.call_args[0] |
| 129 | assert 'audio_md5' in sql | ||
| 110 | assert 'provider_name, crawler_source_data' in sql | 130 | assert 'provider_name, crawler_source_data' in sql |
| 111 | assert '%s::json' in sql | 131 | assert '%s::json' in sql |
| 112 | assert rows[0][-2:] == ('yinyan', '{"id": 200}') | 132 | assert rows[0][-3:] == ('md5-200', 'yinyan', '{"id": 200}') |
| 113 | 133 | ||
| 114 | 134 | ||
| 115 | def test_upsert_kugou_singers_writes_provider_and_source_data(): | 135 | def test_upsert_kugou_singers_writes_provider_and_source_data(): |
| ... | @@ -211,10 +231,12 @@ def test_upsert_qq_entities_write_provider_and_source_data(): | ... | @@ -211,10 +231,12 @@ def test_upsert_qq_entities_write_provider_and_source_data(): |
| 211 | 'singers_json': '[]', | 231 | 'singers_json': '[]', |
| 212 | 'provider_name': 'yinyan', | 232 | 'provider_name': 'yinyan', |
| 213 | 'crawler_source_data': '{"id": 200}', | 233 | 'crawler_source_data': '{"id": 200}', |
| 234 | 'audio_md5': 'md5-200', | ||
| 214 | }]) | 235 | }]) |
| 215 | sql, rows = cur.executemany.call_args[0] | 236 | sql, rows = cur.executemany.call_args[0] |
| 237 | assert 'audio_md5' in sql | ||
| 216 | assert 'provider_name, crawler_source_data' in sql | 238 | assert 'provider_name, crawler_source_data' in sql |
| 217 | assert rows[0][-2:] == ('yinyan', '{"id": 200}') | 239 | assert rows[0][-3:] == ('md5-200', 'yinyan', '{"id": 200}') |
| 218 | 240 | ||
| 219 | 241 | ||
| 220 | def test_upsert_netease_entities_write_provider_and_source_data(): | 242 | def test_upsert_netease_entities_write_provider_and_source_data(): |
| ... | @@ -273,7 +295,9 @@ def test_upsert_netease_entities_write_provider_and_source_data(): | ... | @@ -273,7 +295,9 @@ def test_upsert_netease_entities_write_provider_and_source_data(): |
| 273 | 'singers_json': '[]', | 295 | 'singers_json': '[]', |
| 274 | 'provider_name': 'yinyan', | 296 | 'provider_name': 'yinyan', |
| 275 | 'crawler_source_data': '{"id": 300}', | 297 | 'crawler_source_data': '{"id": 300}', |
| 298 | 'audio_md5': 'md5-300', | ||
| 276 | }]) | 299 | }]) |
| 277 | sql, rows = cur.executemany.call_args[0] | 300 | sql, rows = cur.executemany.call_args[0] |
| 301 | assert 'audio_md5' in sql | ||
| 278 | assert 'provider_name, crawler_source_data' in sql | 302 | assert 'provider_name, crawler_source_data' in sql |
| 279 | assert rows[0][-2:] == ('yinyan', '{"id": 300}') | 303 | assert rows[0][-3:] == ('md5-300', 'yinyan', '{"id": 300}') | ... | ... |
-
Please register or sign in to post a comment