feat(etl): 新增 yinyan_song_records 平台回填与歌手优先录音选取
- 在 ETL 入口增加 --backfill-yinyan-platforms 参数,支持回填缺失平台代码 - 实现回填函数 backfill_yinyan_record_platforms,批量更新 platform 为空的记录 - fetch_all_platform_records 支持获取所有录音供歌手数据缺失的回退使用 - 通过 _pick_record_with_singer 函数优先选取有歌手数据的录音 - 初始化函数跳过已存在 song_id 的批次,避免重复处理 - yinyan_song_records 插入时允许更新 platform 字段,平台数据更完整 - upsert_yinyan_song_records 增加 record_id 更新支持,修正推送逻辑 - 所有平台歌曲标题拆分版本号存储 version 字段,支持特殊版本标记 - 在各平台封面缺失时使用歌曲封面回退,保证封面不为空 - spider 模块新增 probe_*_has_singers 函数校验录音是否有关联歌手 - writer 模块完善更新平台代码的操作及相关查询辅助函数 - utils 新增 split_title_version 助手,提取括号中版本信息 - upload_plain_lyric_to_bucket 上传时添加内容类型头,避免文本编码问题 - 测试覆盖添加回填功能及版本号拆分相关逻辑,提高稳定性维护性
Showing
10 changed files
with
248 additions
and
33 deletions
| ... | @@ -43,4 +43,4 @@ PLATFORM_KUGOU = '2' | ... | @@ -43,4 +43,4 @@ PLATFORM_KUGOU = '2' |
| 43 | PLATFORM_NETEASE = '4' | 43 | PLATFORM_NETEASE = '4' |
| 44 | PLATFORMS = [PLATFORM_QQ, PLATFORM_KUGOU, PLATFORM_NETEASE] | 44 | PLATFORMS = [PLATFORM_QQ, PLATFORM_KUGOU, PLATFORM_NETEASE] |
| 45 | 45 | ||
| 46 | BATCH_SIZE = 3 | 46 | BATCH_SIZE = 4 | ... | ... |
| ... | @@ -54,6 +54,9 @@ ORDER BY sar.song_id, | ... | @@ -54,6 +54,9 @@ ORDER BY sar.song_id, |
| 54 | mr.id ASC | 54 | mr.id ASC |
| 55 | """ | 55 | """ |
| 56 | 56 | ||
| 57 | # 同 _PLATFORM_QUERY,但不做 per-platform 去重,保留同平台全部录音供 singer fallback 遍历 | ||
| 58 | _ALL_PLATFORM_RECORDS_QUERY = _PLATFORM_QUERY | ||
| 59 | |||
| 57 | 60 | ||
| 58 | def iter_hk_songs_batches( | 61 | def iter_hk_songs_batches( |
| 59 | conn: pymysql.Connection, | 62 | conn: pymysql.Connection, |
| ... | @@ -100,6 +103,44 @@ def fetch_platform_records(source_conn: pymysql.Connection, song_ids: list[int]) | ... | @@ -100,6 +103,44 @@ def fetch_platform_records(source_conn: pymysql.Connection, song_ids: list[int]) |
| 100 | return select_platform_records(rows) | 103 | return select_platform_records(rows) |
| 101 | 104 | ||
| 102 | 105 | ||
| 106 | def fetch_all_platform_records(source_conn: pymysql.Connection, song_ids: list[int]) -> list[dict]: | ||
| 107 | """返回每首歌的全部录音(不做 per-platform 去重),供 singer fallback 遍历。""" | ||
| 108 | if not song_ids: | ||
| 109 | return [] | ||
| 110 | placeholders = ','.join(['%s'] * len(song_ids)) | ||
| 111 | query = _ALL_PLATFORM_RECORDS_QUERY.format(placeholders=placeholders) | ||
| 112 | with source_conn.cursor() as cur: | ||
| 113 | cur.execute(query, song_ids) | ||
| 114 | rows = cur.fetchall() | ||
| 115 | # 按优先级排序后按 record_id 去重(同一录音可能关联多次) | ||
| 116 | seen_record_ids: set = set() | ||
| 117 | result = [] | ||
| 118 | for row in sorted(rows, key=_record_priority): | ||
| 119 | rid = row['record_id'] | ||
| 120 | if rid not in seen_record_ids: | ||
| 121 | seen_record_ids.add(rid) | ||
| 122 | result.append(row) | ||
| 123 | return result | ||
| 124 | |||
| 125 | |||
| 126 | def fetch_record_platforms(source_conn: pymysql.Connection, record_ids: list[int]) -> dict[int, str]: | ||
| 127 | """按录音 id 查询平台代码,用于回填 yinyan_song_records.platform。""" | ||
| 128 | if not record_ids: | ||
| 129 | return {} | ||
| 130 | placeholders = ','.join(['%s'] * len(record_ids)) | ||
| 131 | query = f""" | ||
| 132 | SELECT id, platform | ||
| 133 | FROM hk_music_record | ||
| 134 | WHERE id IN ({placeholders}) | ||
| 135 | AND platform IN ('1','2','4') | ||
| 136 | AND deleted = 0 | ||
| 137 | """ | ||
| 138 | with source_conn.cursor() as cur: | ||
| 139 | cur.execute(query, record_ids) | ||
| 140 | rows = cur.fetchall() | ||
| 141 | return {int(row['id']): str(row['platform']) for row in rows} | ||
| 142 | |||
| 143 | |||
| 103 | def select_platform_records(rows: list[dict]) -> list[dict]: | 144 | def select_platform_records(rows: list[dict]) -> list[dict]: |
| 104 | # 每个 (source_song_id, platform) 保留一条,用于继续导入多个平台的录音数据。 | 145 | # 每个 (source_song_id, platform) 保留一条,用于继续导入多个平台的录音数据。 |
| 105 | seen = {} | 146 | seen = {} | ... | ... |
This diff is collapsed.
Click to expand it.
| ... | @@ -46,6 +46,18 @@ def fetch_qq_singers(conn: pymysql.Connection, song_ids: list[int]) -> dict[int, | ... | @@ -46,6 +46,18 @@ def fetch_qq_singers(conn: pymysql.Connection, song_ids: list[int]) -> dict[int, |
| 46 | return result | 46 | return result |
| 47 | 47 | ||
| 48 | 48 | ||
| 49 | def probe_qq_has_singers(conn: pymysql.Connection, mid: str) -> bool: | ||
| 50 | """检查 QQ 录音(按 mid)在 spider DB 中是否有歌手关联。""" | ||
| 51 | with conn.cursor() as cur: | ||
| 52 | cur.execute( | ||
| 53 | "SELECT 1 FROM media_tencent_songs s " | ||
| 54 | "JOIN media_tencent_singer_has_songs shs ON shs.song_id = s.id " | ||
| 55 | "WHERE s.mid = %s LIMIT 1", | ||
| 56 | (mid,), | ||
| 57 | ) | ||
| 58 | return cur.fetchone() is not None | ||
| 59 | |||
| 60 | |||
| 49 | # ─── Kugou ─────────────────────────────────────────────────────────────────── | 61 | # ─── Kugou ─────────────────────────────────────────────────────────────────── |
| 50 | 62 | ||
| 51 | _KUGOU_SONGS_SQL = """ | 63 | _KUGOU_SONGS_SQL = """ |
| ... | @@ -81,6 +93,16 @@ def fetch_kugou_singers(conn: pymysql.Connection, song_ids: list[int]) -> dict[i | ... | @@ -81,6 +93,16 @@ def fetch_kugou_singers(conn: pymysql.Connection, song_ids: list[int]) -> dict[i |
| 81 | return result | 93 | return result |
| 82 | 94 | ||
| 83 | 95 | ||
| 96 | def probe_kugou_has_singers(conn: pymysql.Connection, song_id: int) -> bool: | ||
| 97 | """检查酷狗录音在 spider DB 中是否有歌手关联。""" | ||
| 98 | with conn.cursor() as cur: | ||
| 99 | cur.execute( | ||
| 100 | "SELECT 1 FROM media_ku_gou_singer_has_songs WHERE song_id = %s LIMIT 1", | ||
| 101 | (song_id,), | ||
| 102 | ) | ||
| 103 | return cur.fetchone() is not None | ||
| 104 | |||
| 105 | |||
| 84 | # ─── Netease ───────────────────────────────────────────────────────────────── | 106 | # ─── Netease ───────────────────────────────────────────────────────────────── |
| 85 | 107 | ||
| 86 | _NETEASE_SONGS_SQL = """ | 108 | _NETEASE_SONGS_SQL = """ |
| ... | @@ -114,3 +136,13 @@ def fetch_netease_singers(conn: pymysql.Connection, song_ids: list[int]) -> dict | ... | @@ -114,3 +136,13 @@ def fetch_netease_singers(conn: pymysql.Connection, song_ids: list[int]) -> dict |
| 114 | for row in rows: | 136 | for row in rows: |
| 115 | result.setdefault(row['song_id'], []).append(row) | 137 | result.setdefault(row['song_id'], []).append(row) |
| 116 | return result | 138 | return result |
| 139 | |||
| 140 | |||
| 141 | def probe_netease_has_singers(conn: pymysql.Connection, song_id: int) -> bool: | ||
| 142 | """检查网易云录音在 spider DB 中是否有歌手关联。""" | ||
| 143 | with conn.cursor() as cur: | ||
| 144 | cur.execute( | ||
| 145 | "SELECT 1 FROM media_netease_singer_has_songs WHERE song_id = %s LIMIT 1", | ||
| 146 | (song_id,), | ||
| 147 | ) | ||
| 148 | return cur.fetchone() is not None | ... | ... |
| ... | @@ -92,13 +92,29 @@ def extract_plain_lyric(lyric: str) -> str: | ... | @@ -92,13 +92,29 @@ def extract_plain_lyric(lyric: str) -> str: |
| 92 | return "\n".join(lines) | 92 | return "\n".join(lines) |
| 93 | 93 | ||
| 94 | 94 | ||
| 95 | def split_title_version(title: str | None) -> tuple[str, str]: | ||
| 96 | """拆分歌名末尾括号版本信息。 | ||
| 97 | |||
| 98 | 例如:化风行万里 (DJ默涵版) -> (化风行万里, DJ默涵版) | ||
| 99 | """ | ||
| 100 | if not title: | ||
| 101 | return "", "" | ||
| 102 | text = title.strip() | ||
| 103 | match = re.match(r"^(?P<title>.+?)\s*[\((](?P<version>[^()()]+)[\))]\s*$", text) | ||
| 104 | if not match: | ||
| 105 | return text, "" | ||
| 106 | clean_title = match.group("title").strip() | ||
| 107 | version = match.group("version").strip() | ||
| 108 | return clean_title or text, version | ||
| 109 | |||
| 110 | |||
| 95 | def upload_plain_lyric_to_bucket(platform: str, unique_id: str, lyric: str, bucket, base_url: str) -> str: | 111 | def upload_plain_lyric_to_bucket(platform: str, unique_id: str, lyric: str, bucket, base_url: str) -> str: |
| 96 | """将歌词去时间戳后上传到当前 ETL 使用的 OSS bucket""" | 112 | """将歌词去时间戳后上传到当前 ETL 使用的 OSS bucket""" |
| 97 | plain_lyric = extract_plain_lyric(lyric) | 113 | plain_lyric = extract_plain_lyric(lyric) |
| 98 | if not plain_lyric: | 114 | if not plain_lyric: |
| 99 | return "" | 115 | return "" |
| 100 | oss_key = f"crawler/{platform}/lyric/{unique_id}.txt" | 116 | oss_key = f"crawler/{platform}/lyric/{unique_id}.txt" |
| 101 | bucket.put_object(oss_key, plain_lyric.encode("utf-8")) | 117 | bucket.put_object(oss_key, plain_lyric.encode("utf-8"), headers={'Content-Type': 'text/plain; charset=utf-8'}) |
| 102 | return f"{base_url.rstrip('/')}/{oss_key}" | 118 | return f"{base_url.rstrip('/')}/{oss_key}" |
| 103 | 119 | ||
| 104 | 120 | ... | ... |
| ... | @@ -9,12 +9,20 @@ def insert_yinyan_song_records(cur, records: list[dict]) -> None: | ... | @@ -9,12 +9,20 @@ def insert_yinyan_song_records(cur, records: list[dict]) -> None: |
| 9 | """ | 9 | """ |
| 10 | INSERT INTO yinyan_song_records (song_id, record_id, platform, is_yinyan_push) | 10 | INSERT INTO yinyan_song_records (song_id, record_id, platform, is_yinyan_push) |
| 11 | VALUES (%s, %s, %s, FALSE) | 11 | VALUES (%s, %s, %s, FALSE) |
| 12 | ON CONFLICT (song_id, record_id) DO NOTHING | 12 | ON CONFLICT (song_id, record_id) DO UPDATE |
| 13 | SET platform = EXCLUDED.platform | ||
| 14 | WHERE yinyan_song_records.platform IS NULL | ||
| 13 | """, | 15 | """, |
| 14 | [(r['song_id'], r['record_id'], r['platform']) for r in records], | 16 | [(r['song_id'], r['record_id'], r['platform']) for r in records], |
| 15 | ) | 17 | ) |
| 16 | 18 | ||
| 17 | 19 | ||
| 20 | def fetch_existing_yinyan_song_ids(cur) -> set[int]: | ||
| 21 | """返回已完成初始化 platform 的 song_id 集合。""" | ||
| 22 | cur.execute("SELECT DISTINCT song_id FROM yinyan_song_records WHERE platform IS NOT NULL") | ||
| 23 | return {row[0] for row in cur.fetchall()} | ||
| 24 | |||
| 25 | |||
| 18 | def fetch_pending_yinyan_song_records(cur, limit: int) -> list[dict]: | 26 | def fetch_pending_yinyan_song_records(cur, limit: int) -> list[dict]: |
| 19 | cur.execute( | 27 | cur.execute( |
| 20 | """ | 28 | """ |
| ... | @@ -30,8 +38,40 @@ def fetch_pending_yinyan_song_records(cur, limit: int) -> list[dict]: | ... | @@ -30,8 +38,40 @@ def fetch_pending_yinyan_song_records(cur, limit: int) -> list[dict]: |
| 30 | return [{'song_id': row[0], 'record_id': row[1]} for row in rows] | 38 | return [{'song_id': row[0], 'record_id': row[1]} for row in rows] |
| 31 | 39 | ||
| 32 | 40 | ||
| 41 | def fetch_yinyan_records_missing_platform(cur, limit: int) -> list[dict]: | ||
| 42 | cur.execute( | ||
| 43 | """ | ||
| 44 | SELECT song_id, record_id | ||
| 45 | FROM yinyan_song_records | ||
| 46 | WHERE platform IS NULL | ||
| 47 | ORDER BY song_id | ||
| 48 | LIMIT %s | ||
| 49 | """, | ||
| 50 | (limit,), | ||
| 51 | ) | ||
| 52 | rows = cur.fetchall() | ||
| 53 | return [{'song_id': row[0], 'record_id': row[1]} for row in rows] | ||
| 54 | |||
| 55 | |||
| 56 | def update_yinyan_record_platforms(cur, records: list[dict]) -> None: | ||
| 57 | if not records: | ||
| 58 | return | ||
| 59 | cur.executemany( | ||
| 60 | """ | ||
| 61 | UPDATE yinyan_song_records | ||
| 62 | SET platform = %s | ||
| 63 | WHERE song_id = %s | ||
| 64 | AND record_id = %s | ||
| 65 | AND platform IS NULL | ||
| 66 | """, | ||
| 67 | [(r['platform'], r['song_id'], r['record_id']) for r in records], | ||
| 68 | ) | ||
| 69 | |||
| 70 | |||
| 33 | def upsert_yinyan_song_records(cur, records: list[dict]) -> None: | 71 | def upsert_yinyan_song_records(cur, records: list[dict]) -> None: |
| 34 | """Mark pre-initialized yinyan song-record rows as pushed to crawler.""" | 72 | """Mark pre-initialized yinyan song-record rows as pushed to crawler. |
| 73 | Matches only on song_id so singer-fallback can use a different record_id than initialized. | ||
| 74 | """ | ||
| 35 | if not records: | 75 | if not records: |
| 36 | return | 76 | return |
| 37 | cur.executemany( | 77 | cur.executemany( |
| ... | @@ -39,11 +79,12 @@ def upsert_yinyan_song_records(cur, records: list[dict]) -> None: | ... | @@ -39,11 +79,12 @@ def upsert_yinyan_song_records(cur, records: list[dict]) -> None: |
| 39 | UPDATE yinyan_song_records | 79 | UPDATE yinyan_song_records |
| 40 | SET platform = %s, | 80 | SET platform = %s, |
| 41 | platform_song_id = %s, | 81 | platform_song_id = %s, |
| 82 | record_id = %s, | ||
| 42 | is_yinyan_push = TRUE | 83 | is_yinyan_push = TRUE |
| 43 | WHERE song_id = %s AND record_id = %s | 84 | WHERE song_id = %s AND is_yinyan_push = FALSE |
| 44 | """, | 85 | """, |
| 45 | [ | 86 | [ |
| 46 | (r['platform'], r['platform_song_id'], r['song_id'], r['record_id']) | 87 | (r['platform'], r['platform_song_id'], r['record_id'], r['song_id']) |
| 47 | for r in records | 88 | for r in records |
| 48 | ], | 89 | ], |
| 49 | ) | 90 | ) |
| ... | @@ -98,8 +139,8 @@ def upsert_qq_songs(cur, songs: list[dict]) -> None: | ... | @@ -98,8 +139,8 @@ def upsert_qq_songs(cur, songs: list[dict]) -> None: |
| 98 | (id, platform_song_id, mid, album_id, cover, title, name, duration, | 139 | (id, platform_song_id, mid, album_id, cover, title, name, duration, |
| 99 | lyric, composer_name, lyricist_name, url, lyric_url, | 140 | lyric, composer_name, lyricist_name, url, lyric_url, |
| 100 | platform_index_url, published_at, singers, status, created_at, updated_at, | 141 | platform_index_url, published_at, singers, status, created_at, updated_at, |
| 101 | audio_md5, provider_name, crawler_source_data) | 142 | version, audio_md5, provider_name, crawler_source_data) |
| 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) | 143 | VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb, 0, NOW(), NOW(), %s, %s, %s, %s::json) |
| 103 | ON CONFLICT (platform_song_id) DO NOTHING | 144 | ON CONFLICT (platform_song_id) DO NOTHING |
| 104 | """ | 145 | """ |
| 105 | rows = [( | 146 | rows = [( |
| ... | @@ -108,6 +149,7 @@ def upsert_qq_songs(cur, songs: list[dict]) -> None: | ... | @@ -108,6 +149,7 @@ def upsert_qq_songs(cur, songs: list[dict]) -> None: |
| 108 | s.get('lyric'), s.get('composer_name'), s.get('lyricist_name'), | 149 | s.get('lyric'), s.get('composer_name'), s.get('lyricist_name'), |
| 109 | s.get('url', ''), s.get('lyric_url'), | 150 | s.get('url', ''), s.get('lyric_url'), |
| 110 | s.get('platform_index_url'), s.get('published_at'), s.get('singers_json', '[]'), | 151 | s.get('platform_index_url'), s.get('published_at'), s.get('singers_json', '[]'), |
| 152 | s.get('version'), | ||
| 111 | s.get('audio_md5'), | 153 | s.get('audio_md5'), |
| 112 | s.get('provider_name'), s.get('crawler_source_data'), | 154 | s.get('provider_name'), s.get('crawler_source_data'), |
| 113 | ) for s in songs] | 155 | ) for s in songs] |
| ... | @@ -189,8 +231,8 @@ def upsert_kugou_songs(cur, songs: list[dict]) -> None: | ... | @@ -189,8 +231,8 @@ def upsert_kugou_songs(cur, songs: list[dict]) -> None: |
| 189 | (id, platform_song_id, hash, album_audio_id, album_id, cover, title, name, duration, | 231 | (id, platform_song_id, hash, album_audio_id, album_id, cover, title, name, duration, |
| 190 | lyric, composer_name, lyricist_name, url, lyric_url, | 232 | lyric, composer_name, lyricist_name, url, lyric_url, |
| 191 | platform_index_url, published_at, singers, status, created_at, updated_at, | 233 | platform_index_url, published_at, singers, status, created_at, updated_at, |
| 192 | audio_md5, provider_name, crawler_source_data) | 234 | version, audio_md5, provider_name, crawler_source_data) |
| 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) | 235 | 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, %s::json) |
| 194 | ON CONFLICT (platform_song_id) DO NOTHING | 236 | ON CONFLICT (platform_song_id) DO NOTHING |
| 195 | """ | 237 | """ |
| 196 | rows = [( | 238 | rows = [( |
| ... | @@ -200,6 +242,7 @@ def upsert_kugou_songs(cur, songs: list[dict]) -> None: | ... | @@ -200,6 +242,7 @@ def upsert_kugou_songs(cur, songs: list[dict]) -> None: |
| 200 | s.get('lyric'), s.get('composer_name'), s.get('lyricist_name'), | 242 | s.get('lyric'), s.get('composer_name'), s.get('lyricist_name'), |
| 201 | s.get('url', ''), s.get('lyric_url'), | 243 | s.get('url', ''), s.get('lyric_url'), |
| 202 | s.get('platform_index_url'), s.get('published_at'), s.get('singers_json', '[]'), | 244 | s.get('platform_index_url'), s.get('published_at'), s.get('singers_json', '[]'), |
| 245 | s.get('version'), | ||
| 203 | s.get('audio_md5'), | 246 | s.get('audio_md5'), |
| 204 | s.get('provider_name'), s.get('crawler_source_data'), | 247 | s.get('provider_name'), s.get('crawler_source_data'), |
| 205 | ) for s in songs] | 248 | ) for s in songs] |
| ... | @@ -277,8 +320,8 @@ def upsert_netease_songs(cur, songs: list[dict]) -> None: | ... | @@ -277,8 +320,8 @@ def upsert_netease_songs(cur, songs: list[dict]) -> None: |
| 277 | (id, platform_song_id, album_id, cover, title, name, duration, | 320 | (id, platform_song_id, album_id, cover, title, name, duration, |
| 278 | lyric, composer_name, lyricist_name, url, lyric_url, | 321 | lyric, composer_name, lyricist_name, url, lyric_url, |
| 279 | platform_index_url, published_at, album, singers, status, created_at, updated_at, | 322 | platform_index_url, published_at, album, singers, status, created_at, updated_at, |
| 280 | audio_md5, provider_name, crawler_source_data) | 323 | version, audio_md5, provider_name, crawler_source_data) |
| 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) | 324 | 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, %s::json) |
| 282 | ON CONFLICT (platform_song_id) DO NOTHING | 325 | ON CONFLICT (platform_song_id) DO NOTHING |
| 283 | """ | 326 | """ |
| 284 | rows = [( | 327 | rows = [( |
| ... | @@ -287,6 +330,7 @@ def upsert_netease_songs(cur, songs: list[dict]) -> None: | ... | @@ -287,6 +330,7 @@ def upsert_netease_songs(cur, songs: list[dict]) -> None: |
| 287 | s.get('lyric'), s.get('composer_name'), s.get('lyricist_name'), | 330 | s.get('lyric'), s.get('composer_name'), s.get('lyricist_name'), |
| 288 | s.get('url', ''), s.get('lyric_url'), | 331 | s.get('url', ''), s.get('lyric_url'), |
| 289 | s.get('platform_index_url'), s.get('published_at'), s.get('album_json'), s.get('singers_json', '[]'), | 332 | s.get('platform_index_url'), s.get('published_at'), s.get('album_json'), s.get('singers_json', '[]'), |
| 333 | s.get('version'), | ||
| 290 | s.get('audio_md5'), | 334 | s.get('audio_md5'), |
| 291 | s.get('provider_name'), s.get('crawler_source_data'), | 335 | s.get('provider_name'), s.get('crawler_source_data'), |
| 292 | ) for s in songs] | 336 | ) for s in songs] | ... | ... |
| 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 initialize_yinyan_song_records, run | 4 | from etl_to_crawler.runner import backfill_yinyan_record_platforms, initialize_yinyan_song_records, run |
| 5 | 5 | ||
| 6 | PLATFORM_MAP = { | 6 | PLATFORM_MAP = { |
| 7 | 'qq': PLATFORM_QQ, | 7 | 'qq': PLATFORM_QQ, |
| ... | @@ -18,6 +18,8 @@ if __name__ == '__main__': | ... | @@ -18,6 +18,8 @@ if __name__ == '__main__': |
| 18 | help='最多处理多少批次(冒烟测试用)') | 18 | help='最多处理多少批次(冒烟测试用)') |
| 19 | parser.add_argument('--init-yinyan-records', action='store_true', | 19 | parser.add_argument('--init-yinyan-records', action='store_true', |
| 20 | help='只初始化 yinyan_song_records 待导入状态表,不执行 crawler 导入') | 20 | help='只初始化 yinyan_song_records 待导入状态表,不执行 crawler 导入') |
| 21 | parser.add_argument('--backfill-yinyan-platforms', action='store_true', | ||
| 22 | help='只回填 yinyan_song_records 中为空的 platform 平台代码') | ||
| 21 | args = parser.parse_args() | 23 | args = parser.parse_args() |
| 22 | 24 | ||
| 23 | if args.platform == 'all': | 25 | if args.platform == 'all': |
| ... | @@ -26,7 +28,9 @@ if __name__ == '__main__': | ... | @@ -26,7 +28,9 @@ if __name__ == '__main__': |
| 26 | platforms = [PLATFORM_MAP[args.platform]] | 28 | platforms = [PLATFORM_MAP[args.platform]] |
| 27 | 29 | ||
| 28 | print(f"Starting ETL for platforms: {platforms}") | 30 | print(f"Starting ETL for platforms: {platforms}") |
| 29 | if args.init_yinyan_records: | 31 | if args.backfill_yinyan_platforms: |
| 32 | backfill_yinyan_record_platforms(max_batches=args.max_batches) | ||
| 33 | elif args.init_yinyan_records: | ||
| 30 | initialize_yinyan_song_records(platforms, max_batches=args.max_batches) | 34 | initialize_yinyan_song_records(platforms, max_batches=args.max_batches) |
| 31 | else: | 35 | else: |
| 32 | run(platforms, max_batches=args.max_batches) | 36 | run(platforms, max_batches=args.max_batches) | ... | ... |
| ... | @@ -57,7 +57,7 @@ def test_run_imports_all_platform_records_but_writes_one_primary_yinyan_relation | ... | @@ -57,7 +57,7 @@ def test_run_imports_all_platform_records_but_writes_one_primary_yinyan_relation |
| 57 | 'audio_url': 'https://example.com/a.mp3', | 57 | 'audio_url': 'https://example.com/a.mp3', |
| 58 | 'singer': '歌手', | 58 | 'singer': '歌手', |
| 59 | }}) | 59 | }}) |
| 60 | monkeypatch.setattr(runner, 'fetch_platform_records', lambda conn, song_ids: [ | 60 | platform_records = [ |
| 61 | { | 61 | { |
| 62 | 'source_song_id': 10, | 62 | 'source_song_id': 10, |
| 63 | 'record_id': 100, | 63 | 'record_id': 100, |
| ... | @@ -80,7 +80,10 @@ def test_run_imports_all_platform_records_but_writes_one_primary_yinyan_relation | ... | @@ -80,7 +80,10 @@ def test_run_imports_all_platform_records_but_writes_one_primary_yinyan_relation |
| 80 | 'is_high': 0, | 80 | 'is_high': 0, |
| 81 | 'pub_time': '2021-01-01', | 81 | 'pub_time': '2021-01-01', |
| 82 | }, | 82 | }, |
| 83 | ]) | 83 | ] |
| 84 | monkeypatch.setattr(runner, 'fetch_platform_records', lambda conn, song_ids: platform_records) | ||
| 85 | monkeypatch.setattr(runner, 'fetch_all_platform_records', lambda conn, song_ids: platform_records) | ||
| 86 | monkeypatch.setattr(runner, '_pick_record_with_singer', lambda records, spider_conn: records[0]) | ||
| 84 | monkeypatch.setattr(runner, '_PROCESSORS', processors) | 87 | monkeypatch.setattr(runner, '_PROCESSORS', processors) |
| 85 | monkeypatch.setattr(runner, 'upsert_yinyan_song_records', yinyan_writer) | 88 | monkeypatch.setattr(runner, 'upsert_yinyan_song_records', yinyan_writer) |
| 86 | 89 | ||
| ... | @@ -90,9 +93,9 @@ def test_run_imports_all_platform_records_but_writes_one_primary_yinyan_relation | ... | @@ -90,9 +93,9 @@ def test_run_imports_all_platform_records_but_writes_one_primary_yinyan_relation |
| 90 | processors['2'].assert_called_once() | 93 | processors['2'].assert_called_once() |
| 91 | yinyan_writer.assert_called_once_with(pg_conn.cur, [{ | 94 | yinyan_writer.assert_called_once_with(pg_conn.cur, [{ |
| 92 | 'song_id': 10, | 95 | 'song_id': 10, |
| 93 | 'record_id': 200, | 96 | 'record_id': 100, |
| 94 | 'platform': 'kugou', | 97 | 'platform': '1', |
| 95 | 'platform_song_id': 200, | 98 | 'platform_song_id': 100, |
| 96 | }]) | 99 | }]) |
| 97 | assert pg_conn.commits == 1 | 100 | assert pg_conn.commits == 1 |
| 98 | 101 | ||
| ... | @@ -104,6 +107,7 @@ def test_initialize_yinyan_song_records_inserts_primary_records(monkeypatch): | ... | @@ -104,6 +107,7 @@ def test_initialize_yinyan_song_records_inserts_primary_records(monkeypatch): |
| 104 | monkeypatch.setattr(runner, 'get_hk_songs_conn', lambda: _Connection()) | 107 | monkeypatch.setattr(runner, 'get_hk_songs_conn', lambda: _Connection()) |
| 105 | monkeypatch.setattr(runner, 'get_source_conn', lambda: _Connection()) | 108 | monkeypatch.setattr(runner, 'get_source_conn', lambda: _Connection()) |
| 106 | monkeypatch.setattr(runner, 'get_pg_conn', lambda: pg_conn) | 109 | monkeypatch.setattr(runner, 'get_pg_conn', lambda: pg_conn) |
| 110 | monkeypatch.setattr(runner, 'fetch_existing_yinyan_song_ids', lambda cur: set()) | ||
| 107 | 111 | ||
| 108 | monkeypatch.setattr(runner, 'iter_hk_songs_batches', lambda conn, batch_size: [[ | 112 | monkeypatch.setattr(runner, 'iter_hk_songs_batches', lambda conn, batch_size: [[ |
| 109 | {'id': 50, 'source_song_id': 10, 'name': '歌', 'audio_url': 'https://example.com/a.mp3', 'singer': '歌手'}, | 113 | {'id': 50, 'source_song_id': 10, 'name': '歌', 'audio_url': 'https://example.com/a.mp3', 'singer': '歌手'}, |
| ... | @@ -136,7 +140,32 @@ def test_initialize_yinyan_song_records_inserts_primary_records(monkeypatch): | ... | @@ -136,7 +140,32 @@ def test_initialize_yinyan_song_records_inserts_primary_records(monkeypatch): |
| 136 | 140 | ||
| 137 | runner.initialize_yinyan_song_records(['1', '2']) | 141 | runner.initialize_yinyan_song_records(['1', '2']) |
| 138 | 142 | ||
| 139 | assert inserted == [{'song_id': 10, 'record_id': 200}] | 143 | assert inserted == [{'song_id': 10, 'record_id': 200, 'platform': '2'}] |
| 144 | assert pg_conn.commits == 1 | ||
| 145 | |||
| 146 | |||
| 147 | def test_backfill_yinyan_record_platforms_updates_missing_platform_rows(monkeypatch): | ||
| 148 | pg_conn = _PgConnection() | ||
| 149 | updated = [] | ||
| 150 | |||
| 151 | monkeypatch.setattr(runner, 'get_source_conn', lambda: _Connection()) | ||
| 152 | monkeypatch.setattr(runner, 'get_pg_conn', lambda: pg_conn) | ||
| 153 | monkeypatch.setattr(runner, 'fetch_yinyan_records_missing_platform', lambda cur, batch_size: [ | ||
| 154 | {'song_id': 10, 'record_id': 100}, | ||
| 155 | {'song_id': 11, 'record_id': 101}, | ||
| 156 | ] if pg_conn.commits == 0 else []) | ||
| 157 | monkeypatch.setattr(runner, 'fetch_record_platforms', lambda conn, record_ids: { | ||
| 158 | 100: '1', | ||
| 159 | 101: '2', | ||
| 160 | }) | ||
| 161 | monkeypatch.setattr(runner, 'update_yinyan_record_platforms', lambda cur, rows: updated.extend(rows)) | ||
| 162 | |||
| 163 | runner.backfill_yinyan_record_platforms() | ||
| 164 | |||
| 165 | assert updated == [ | ||
| 166 | {'song_id': 10, 'record_id': 100, 'platform': '1'}, | ||
| 167 | {'song_id': 11, 'record_id': 101, 'platform': '2'}, | ||
| 168 | ] | ||
| 140 | assert pg_conn.commits == 1 | 169 | assert pg_conn.commits == 1 |
| 141 | 170 | ||
| 142 | 171 | ||
| ... | @@ -158,7 +187,7 @@ def test_process_netease_builds_album_json_for_song_insert(monkeypatch): | ... | @@ -158,7 +187,7 @@ def test_process_netease_builds_album_json_for_song_insert(monkeypatch): |
| 158 | 'is_owner': 1, | 187 | 'is_owner': 1, |
| 159 | 'album_published_at': '2020-01-01', | 188 | 'album_published_at': '2020-01-01', |
| 160 | 'cover': 'https://example.com/cover.jpg', | 189 | 'cover': 'https://example.com/cover.jpg', |
| 161 | 'title': '录音标题', | 190 | 'title': '化风行万里 (DJ默涵版)', |
| 162 | 'duration': 180, | 191 | 'duration': 180, |
| 163 | 'lyric': '[00:01.00]歌词', | 192 | 'lyric': '[00:01.00]歌词', |
| 164 | 'composer_name': '曲作者', | 193 | 'composer_name': '曲作者', |
| ... | @@ -197,6 +226,8 @@ def test_process_netease_builds_album_json_for_song_insert(monkeypatch): | ... | @@ -197,6 +226,8 @@ def test_process_netease_builds_album_json_for_song_insert(monkeypatch): |
| 197 | assert inserted_songs[0]['album_json'] | 226 | assert inserted_songs[0]['album_json'] |
| 198 | assert '"id": 20' in inserted_songs[0]['album_json'] | 227 | assert '"id": 20' in inserted_songs[0]['album_json'] |
| 199 | assert '"title": "专辑"' in inserted_songs[0]['album_json'] | 228 | assert '"title": "专辑"' in inserted_songs[0]['album_json'] |
| 229 | assert inserted_songs[0]['title'] == '化风行万里' | ||
| 230 | assert inserted_songs[0]['version'] == 'DJ默涵版' | ||
| 200 | 231 | ||
| 201 | 232 | ||
| 202 | def test_process_netease_keeps_timestamped_lyric_and_uploads_plain_lyric(monkeypatch): | 233 | def test_process_netease_keeps_timestamped_lyric_and_uploads_plain_lyric(monkeypatch): |
| ... | @@ -206,7 +237,7 @@ def test_process_netease_keeps_timestamped_lyric_and_uploads_plain_lyric(monkeyp | ... | @@ -206,7 +237,7 @@ def test_process_netease_keeps_timestamped_lyric_and_uploads_plain_lyric(monkeyp |
| 206 | uploaded = {} | 237 | uploaded = {} |
| 207 | 238 | ||
| 208 | class Bucket: | 239 | class Bucket: |
| 209 | def put_object(self, key, body): | 240 | def put_object(self, key, body, headers=None): |
| 210 | uploaded['key'] = key | 241 | uploaded['key'] = key |
| 211 | uploaded['body'] = body | 242 | uploaded['body'] = body |
| 212 | 243 | ... | ... |
tests/test_utils.py
0 → 100644
| 1 | from etl_to_crawler.utils import split_title_version | ||
| 2 | |||
| 3 | |||
| 4 | def test_split_title_version_extracts_parenthesized_suffix(): | ||
| 5 | assert split_title_version("化风行万里 (DJ默涵版)") == ("化风行万里", "DJ默涵版") | ||
| 6 | assert split_title_version("化风行万里(DJ默涵版)") == ("化风行万里", "DJ默涵版") | ||
| 7 | |||
| 8 | |||
| 9 | def test_split_title_version_leaves_plain_title_unchanged(): | ||
| 10 | assert split_title_version("化风行万里") == ("化风行万里", "") | ||
| 11 | assert split_title_version("") == ("", "") |
| 1 | from unittest.mock import MagicMock, call | 1 | from unittest.mock import MagicMock, call |
| 2 | from etl_to_crawler.writer import ( | 2 | from etl_to_crawler.writer import ( |
| 3 | fetch_yinyan_records_missing_platform, | ||
| 3 | insert_yinyan_song_records, | 4 | insert_yinyan_song_records, |
| 5 | update_yinyan_record_platforms, | ||
| 4 | upsert_kugou_albums, | 6 | upsert_kugou_albums, |
| 5 | upsert_kugou_singers, | 7 | upsert_kugou_singers, |
| 6 | upsert_kugou_songs, | 8 | upsert_kugou_songs, |
| ... | @@ -45,32 +47,60 @@ def test_upsert_qq_singer_songs(): | ... | @@ -45,32 +47,60 @@ def test_upsert_qq_singer_songs(): |
| 45 | def test_upsert_yinyan_song_records_marks_existing_relation_as_pushed(): | 47 | def test_upsert_yinyan_song_records_marks_existing_relation_as_pushed(): |
| 46 | cur = MagicMock() | 48 | cur = MagicMock() |
| 47 | upsert_yinyan_song_records(cur, [ | 49 | upsert_yinyan_song_records(cur, [ |
| 48 | {'song_id': 10, 'record_id': 100, 'platform': 'qq', 'platform_song_id': 1000}, | 50 | {'song_id': 10, 'record_id': 100, 'platform': '1', 'platform_song_id': 1000}, |
| 49 | {'song_id': 11, 'record_id': 101, 'platform': 'kugou', 'platform_song_id': 1001}, | 51 | {'song_id': 11, 'record_id': 101, 'platform': '2', 'platform_song_id': 1001}, |
| 50 | ]) | 52 | ]) |
| 51 | 53 | ||
| 52 | sql, rows = cur.executemany.call_args[0] | 54 | sql, rows = cur.executemany.call_args[0] |
| 53 | assert 'UPDATE yinyan_song_records' in sql | 55 | assert 'UPDATE yinyan_song_records' in sql |
| 54 | assert 'platform = %s' in sql | 56 | assert 'platform = %s' in sql |
| 55 | assert 'platform_song_id = %s' in sql | 57 | assert 'platform_song_id = %s' in sql |
| 58 | assert 'record_id = %s' in sql | ||
| 56 | assert 'is_yinyan_push = TRUE' in sql | 59 | assert 'is_yinyan_push = TRUE' in sql |
| 57 | assert 'WHERE song_id = %s AND record_id = %s' in sql | 60 | assert 'WHERE song_id = %s AND is_yinyan_push = FALSE' in sql |
| 58 | assert rows == [('qq', 1000, 10, 100), ('kugou', 1001, 11, 101)] | 61 | assert rows == [('1', 1000, 100, 10), ('2', 1001, 101, 11)] |
| 59 | 62 | ||
| 60 | 63 | ||
| 61 | def test_insert_yinyan_song_records_initializes_unpushed_rows(): | 64 | def test_insert_yinyan_song_records_initializes_unpushed_rows(): |
| 62 | cur = MagicMock() | 65 | cur = MagicMock() |
| 63 | insert_yinyan_song_records(cur, [ | 66 | insert_yinyan_song_records(cur, [ |
| 64 | {'song_id': 10, 'record_id': 100}, | 67 | {'song_id': 10, 'record_id': 100, 'platform': '1'}, |
| 65 | {'song_id': 11, 'record_id': 101}, | 68 | {'song_id': 11, 'record_id': 101, 'platform': '2'}, |
| 66 | ]) | 69 | ]) |
| 67 | 70 | ||
| 68 | sql, rows = cur.executemany.call_args[0] | 71 | sql, rows = cur.executemany.call_args[0] |
| 69 | assert 'INSERT INTO yinyan_song_records' in sql | 72 | assert 'INSERT INTO yinyan_song_records' in sql |
| 73 | assert 'platform' in sql | ||
| 70 | assert 'is_yinyan_push' in sql | 74 | assert 'is_yinyan_push' in sql |
| 71 | assert 'FALSE' in sql | 75 | assert 'FALSE' in sql |
| 72 | assert 'ON CONFLICT (song_id, record_id) DO NOTHING' in sql | 76 | assert 'ON CONFLICT (song_id, record_id) DO UPDATE' in sql |
| 73 | assert rows == [(10, 100), (11, 101)] | 77 | assert 'WHERE yinyan_song_records.platform IS NULL' in sql |
| 78 | assert rows == [(10, 100, '1'), (11, 101, '2')] | ||
| 79 | |||
| 80 | |||
| 81 | def test_fetch_yinyan_records_missing_platform_reads_null_platform_rows(): | ||
| 82 | cur = MagicMock() | ||
| 83 | cur.fetchall.return_value = [(10, 100), (11, 101)] | ||
| 84 | |||
| 85 | rows = fetch_yinyan_records_missing_platform(cur, 500) | ||
| 86 | |||
| 87 | sql, params = cur.execute.call_args[0] | ||
| 88 | assert 'WHERE platform IS NULL' in sql | ||
| 89 | assert params == (500,) | ||
| 90 | assert rows == [{'song_id': 10, 'record_id': 100}, {'song_id': 11, 'record_id': 101}] | ||
| 91 | |||
| 92 | |||
| 93 | def test_update_yinyan_record_platforms_fills_only_null_platform_rows(): | ||
| 94 | cur = MagicMock() | ||
| 95 | update_yinyan_record_platforms(cur, [ | ||
| 96 | {'song_id': 10, 'record_id': 100, 'platform': '1'}, | ||
| 97 | {'song_id': 11, 'record_id': 101, 'platform': '2'}, | ||
| 98 | ]) | ||
| 99 | |||
| 100 | sql, rows = cur.executemany.call_args[0] | ||
| 101 | assert 'SET platform = %s' in sql | ||
| 102 | assert 'AND platform IS NULL' in sql | ||
| 103 | assert rows == [('1', 10, 100), ('2', 11, 101)] | ||
| 74 | 104 | ||
| 75 | 105 | ||
| 76 | def test_upsert_netease_songs_writes_album_json_column(): | 106 | def test_upsert_netease_songs_writes_album_json_column(): |
| ... | @@ -123,13 +153,15 @@ def test_upsert_kugou_songs_writes_provider_and_source_data(): | ... | @@ -123,13 +153,15 @@ def test_upsert_kugou_songs_writes_provider_and_source_data(): |
| 123 | 'provider_name': 'yinyan', | 153 | 'provider_name': 'yinyan', |
| 124 | 'crawler_source_data': '{"id": 200}', | 154 | 'crawler_source_data': '{"id": 200}', |
| 125 | 'audio_md5': 'md5-200', | 155 | 'audio_md5': 'md5-200', |
| 156 | 'version': 'DJ默涵版', | ||
| 126 | }]) | 157 | }]) |
| 127 | 158 | ||
| 128 | sql, rows = cur.executemany.call_args[0] | 159 | sql, rows = cur.executemany.call_args[0] |
| 129 | assert 'audio_md5' in sql | 160 | assert 'audio_md5' in sql |
| 161 | assert 'version' in sql | ||
| 130 | assert 'provider_name, crawler_source_data' in sql | 162 | assert 'provider_name, crawler_source_data' in sql |
| 131 | assert '%s::json' in sql | 163 | assert '%s::json' in sql |
| 132 | assert rows[0][-3:] == ('md5-200', 'yinyan', '{"id": 200}') | 164 | assert rows[0][-4:] == ('DJ默涵版', 'md5-200', 'yinyan', '{"id": 200}') |
| 133 | 165 | ||
| 134 | 166 | ||
| 135 | def test_upsert_kugou_singers_writes_provider_and_source_data(): | 167 | def test_upsert_kugou_singers_writes_provider_and_source_data(): |
| ... | @@ -232,11 +264,13 @@ def test_upsert_qq_entities_write_provider_and_source_data(): | ... | @@ -232,11 +264,13 @@ def test_upsert_qq_entities_write_provider_and_source_data(): |
| 232 | 'provider_name': 'yinyan', | 264 | 'provider_name': 'yinyan', |
| 233 | 'crawler_source_data': '{"id": 200}', | 265 | 'crawler_source_data': '{"id": 200}', |
| 234 | 'audio_md5': 'md5-200', | 266 | 'audio_md5': 'md5-200', |
| 267 | 'version': 'DJ默涵版', | ||
| 235 | }]) | 268 | }]) |
| 236 | sql, rows = cur.executemany.call_args[0] | 269 | sql, rows = cur.executemany.call_args[0] |
| 237 | assert 'audio_md5' in sql | 270 | assert 'audio_md5' in sql |
| 271 | assert 'version' in sql | ||
| 238 | assert 'provider_name, crawler_source_data' in sql | 272 | assert 'provider_name, crawler_source_data' in sql |
| 239 | assert rows[0][-3:] == ('md5-200', 'yinyan', '{"id": 200}') | 273 | assert rows[0][-4:] == ('DJ默涵版', 'md5-200', 'yinyan', '{"id": 200}') |
| 240 | 274 | ||
| 241 | 275 | ||
| 242 | def test_upsert_netease_entities_write_provider_and_source_data(): | 276 | def test_upsert_netease_entities_write_provider_and_source_data(): |
| ... | @@ -296,8 +330,10 @@ def test_upsert_netease_entities_write_provider_and_source_data(): | ... | @@ -296,8 +330,10 @@ def test_upsert_netease_entities_write_provider_and_source_data(): |
| 296 | 'provider_name': 'yinyan', | 330 | 'provider_name': 'yinyan', |
| 297 | 'crawler_source_data': '{"id": 300}', | 331 | 'crawler_source_data': '{"id": 300}', |
| 298 | 'audio_md5': 'md5-300', | 332 | 'audio_md5': 'md5-300', |
| 333 | 'version': 'DJ默涵版', | ||
| 299 | }]) | 334 | }]) |
| 300 | sql, rows = cur.executemany.call_args[0] | 335 | sql, rows = cur.executemany.call_args[0] |
| 301 | assert 'audio_md5' in sql | 336 | assert 'audio_md5' in sql |
| 337 | assert 'version' in sql | ||
| 302 | assert 'provider_name, crawler_source_data' in sql | 338 | assert 'provider_name, crawler_source_data' in sql |
| 303 | assert rows[0][-3:] == ('md5-300', 'yinyan', '{"id": 300}') | 339 | assert rows[0][-4:] == ('DJ默涵版', 'md5-300', 'yinyan', '{"id": 300}') | ... | ... |
-
Please register or sign in to post a comment