"""QQ音乐歌单广场 + 歌单详情(移植自 sdk/tx/songList.ts)。 一个模块承载三件事(原文也是这样,路由按能力位分别用): * song_list —— 分类标签与分类歌单列表(get_tag / get_hot_tag / get_list) * playlist —— 歌单详情(get_list_detail)与歌单搜索(search) * parse —— 歌单链接 → 数字 id 归一(get_list_id,含 302 短链兜底) 分类歌单固定 36 条一页(limit_list),调用方要的 limit 在 playlist 层再切片。 """ from __future__ import annotations import asyncio import json import re from typing import Any from urllib.parse import quote from app.sdk.common.request import http_fetch from app.sdk.common.utils import date_format, decode_name, format_play_count, format_play_time, format_singer_name from app.sdk.platforms.tx.filter import build_types, song_img LIMIT_LIST = 36 SUCCESS_CODE = 0 SORT_LIST: list[dict[str, Any]] = [ {"name": "最热", "id": 5}, {"name": "最新", "id": 2}, ] _HOT_TAG_HTML = re.compile(r'class="c_bg_link js_tag_item" data-id="\w+">.+?') _HOT_TAG = re.compile(r'data-id="(\w+)">(.+?)') # https://y.qq.com/n/yqq/playlist/7217720898.html # https://i.y.qq.com/n2/m/share/details/taoge.html?...&id=7217720898&ADTAG=qfshare _LIST_DETAIL_LINK = re.compile(r"/playlist/(\d+)") _LIST_DETAIL_LINK2 = re.compile(r"id=(\d+)") _BASE_QUERY = ( "loginUin=0&hostUin=0&format=json&inCharset=utf-8&outCharset=utf-8¬ice=0&platform=wk_v15.json&needNewCode=0" ) TAGS_URL = f"https://u.y.qq.com/cgi-bin/musicu.fcg?{_BASE_QUERY}&data={quote(json.dumps({'tags': {'method': 'get_all_categories', 'param': {'qq': ''}, 'module': 'playlist.PlaylistAllCategoriesServer'}}, ensure_ascii=False, separators=(',', ':')), safe='')}" HOT_TAG_URL = "https://c.y.qq.com/node/pc/wk_v15/category_playlist.html" LIST_DETAIL_URL = ( "https://c.y.qq.com/qzone/fcg-bin/fcg_ucc_getcdinfo_byids_cp.fcg" "?type=1&json=1&utf8=1&onlysong=0&new_format=1&disstid={playlist_id}" "&loginUin=0&hostUin=0&format=json&inCharset=utf8&outCharset=utf-8¬ice=0" "&platform=yqq.json&needNewCode=0" ) SEARCH_URL = ( "http://c.y.qq.com/soso/fcgi-bin/client_music_search_songlist" "?page_no={page}&num_per_page={limit}&format=json&query={query}" "&remoteplace=txt.yqq.playlist&inCharset=utf8&outCharset=utf-8" ) _UA_IE = "Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; WOW64; Trident/5.0)" def get_list_url(sort_id: Any, tag_id: Any, page: int) -> str: """分类歌单列表 URL:tagId 非空走分类内容接口,否则走「全部」广场接口。""" if tag_id: data: dict[str, Any] = { "comm": {"cv": 1602, "ct": 20}, "playlist": { "method": "get_category_content", "param": { "titleid": int(tag_id), "caller": "0", "category_id": int(tag_id), "size": LIMIT_LIST, "page": page - 1, "use_page": 1, }, "module": "playlist.PlayListCategoryServer", }, } else: data = { "comm": {"cv": 1602, "ct": 20}, "playlist": { "method": "get_playlist_by_tag", "param": { "id": 10000000, "sin": LIMIT_LIST * (page - 1), "size": LIMIT_LIST, "order": sort_id, "cur_page": page, }, "module": "playlist.PlayListPlazaServer", }, } payload = json.dumps(data, ensure_ascii=False, separators=(",", ":")) return f"https://u.y.qq.com/cgi-bin/musicu.fcg?{_BASE_QUERY}&data={quote(payload, safe='')}" def filter_tag_info(raw_list: Any) -> list[dict[str, Any]]: return [ { "name": group.get("group_name"), "list": [ { "parent_id": group.get("group_id"), "parent_name": group.get("group_name"), "id": item.get("id"), "name": item.get("name"), "source": "tx", } for item in group.get("v_item") or [] ], } for group in raw_list or [] ] def filter_info_hot_tag(html: Any) -> list[dict[str, Any]]: tags: list[dict[str, Any]] = [] if not isinstance(html, str): return tags for tag_html in _HOT_TAG_HTML.findall(html): result = _HOT_TAG.search(tag_html) if not result: continue try: tag_id = int(result.group(1)) except ValueError: continue tags.append({"id": tag_id, "name": result.group(2), "source": "tx"}) return tags def filter_list(data: dict[str, Any], page: int) -> dict[str, Any]: """「全部」广场分支(get_playlist_by_tag)。""" return { "list": [ { "play_count": format_play_count(item.get("access_num")), "id": str(item.get("tid")), "author": (item.get("creator_info") or {}).get("nick"), "name": item.get("title"), "time": date_format(item["modify_time"] * 1000, "Y-M-D") if item.get("modify_time") else "", "img": item.get("cover_url_medium"), "total": len(item.get("song_ids") or []), "desc": decode_name(item.get("desc")).replace("
", "\n"), "source": "tx", } for item in data.get("v_playlist") or [] ], "total": data.get("total"), "page": page, "limit": LIMIT_LIST, "source": "tx", } def filter_list2(data: dict[str, Any], page: int) -> dict[str, Any]: """分类内容分支(get_category_content)。""" content = data.get("content") or {} rows: list[dict[str, Any]] = [] for item in content.get("v_item") or []: basic = item.get("basic") or {} cover = basic.get("cover") or {} rows.append( { "play_count": format_play_count(basic.get("play_cnt")), "id": str(basic.get("tid")), "author": (basic.get("creator") or {}).get("nick"), "name": basic.get("title"), "img": cover.get("medium_url") or cover.get("default_url"), "desc": decode_name(basic.get("desc")).replace("
", "\n"), "source": "tx", } ) return { "list": rows, "total": content.get("total_cnt"), "page": page, "limit": LIMIT_LIST, "source": "tx", } def filter_list_detail(raw_list: Any) -> list[dict[str, Any]]: """歌单曲目 → SongInfo(比搜索结果多带 lrc/otherSource 两个空字段,与原文一致)。""" songs: list[dict[str, Any]] = [] for item in raw_list or []: file = (item or {}).get("file") or {} types, _types = build_types(file) album = item.get("album") or {} album_name = album.get("name") or "" album_mid = album.get("mid") or "" songs.append( { "singer": format_singer_name(item.get("singer"), "name"), "name": item.get("title"), "albumName": album_name, "albumId": album_mid, "source": "tx", "interval": format_play_time(item.get("interval")), "songId": item.get("id"), "albumMid": album_mid, "strMediaMid": file.get("media_mid"), "songmid": item.get("mid"), "img": song_img("" if album_name in ("", "空") else album_mid, item.get("singer")), "lrc": None, "otherSource": None, "types": types, "_types": _types, "typeUrl": {}, } ) return songs async def get_tag(retry_num: int = 0) -> list[dict[str, Any]]: if retry_num > 2: raise RuntimeError("try max num") resp = await http_fetch(TAGS_URL) body = resp.body if isinstance(resp.body, dict) else {} if body.get("code") != SUCCESS_CODE: return await get_tag(retry_num + 1) return filter_tag_info(((body.get("tags") or {}).get("data") or {}).get("v_group")) async def get_hot_tag(retry_num: int = 0) -> list[dict[str, Any]]: if retry_num > 2: raise RuntimeError("try max num") resp = await http_fetch(HOT_TAG_URL) if resp.status_code != 200: return await get_hot_tag(retry_num + 1) return filter_info_hot_tag(resp.body) async def get_tags() -> dict[str, Any]: """分类标签 + 热门标签。原 TS 用 Promise.all(任一失败整体失败), 这里改为独立兜底:分类页少一个分组比整页 404 更有用。""" tags, hot_tag = await asyncio.gather(get_tag(), get_hot_tag(), return_exceptions=True) return { "tags": [] if isinstance(tags, BaseException) else tags, "hotTag": [] if isinstance(hot_tag, BaseException) else hot_tag, "source": "tx", } async def get_list(sort_id: Any, tag_id: Any, page: int = 1, retry_num: int = 0) -> dict[str, Any]: if retry_num > 2: raise RuntimeError("try max num") resp = await http_fetch(get_list_url(sort_id, tag_id, page)) body = resp.body if isinstance(resp.body, dict) else {} if body.get("code") != SUCCESS_CODE: return await get_list(sort_id, tag_id, page, retry_num + 1) data = (body.get("playlist") or {}).get("data") or {} return filter_list2(data, page) if tag_id else filter_list(data, page) async def handle_parse_id(link: str, retry_num: int = 0) -> str: """短链跟随:请求层不自动跟随重定向,手动读 Location(文案与原文一致:link try max num)。""" if retry_num > 2: raise RuntimeError("link try max num") resp = await http_fetch(link) if resp.status_code > 400: return await handle_parse_id(link, retry_num + 1) return resp.header("location") or link async def get_list_id(playlist_id: str) -> str: """链接/裸 id → 纯数字歌单 id。""" if not re.search(r"[?&:/]", playlist_id): return playlist_id target = playlist_id if not _LIST_DETAIL_LINK.search(target): target = await handle_parse_id(target) result = _LIST_DETAIL_LINK.search(target) if not result: result = _LIST_DETAIL_LINK2.search(target) if not result: raise RuntimeError("failed") return result.group(1) async def get_list_detail(playlist_id: str, retry_num: int = 0) -> dict[str, Any]: """歌单详情(含全部曲目,不分页)。重试文案与原文一致:try max num。""" if retry_num > 2: raise RuntimeError("try max num") bare = await get_list_id(playlist_id) resp = await http_fetch( LIST_DETAIL_URL.format(playlist_id=quote(bare, safe="")), headers={"Origin": "https://y.qq.com", "Referer": f"https://y.qq.com/n/yqq/playsquare/{bare}.html"}, ) body = resp.body if isinstance(resp.body, dict) else {} if body.get("code") != SUCCESS_CODE: return await get_list_detail(bare, retry_num + 1) cdlist = (body.get("cdlist") or [{}])[0] songlist = cdlist.get("songlist") or [] return { "list": filter_list_detail(songlist), "page": 1, "limit": len(songlist) + 1, "total": len(songlist), "source": "tx", "info": { "name": cdlist.get("dissname"), "img": cdlist.get("logo"), "desc": decode_name(cdlist.get("desc")).replace("
", "\n"), "author": cdlist.get("nickname"), "play_count": format_play_count(cdlist.get("visitnum")), }, } async def search(text: str, page: int = 1, limit: int = 20, retry_num: int = 0) -> dict[str, Any]: """歌单搜索。重试上限 5 次(文案与原文一致:max retry)。""" if retry_num > 5: raise RuntimeError("max retry") resp = await http_fetch( SEARCH_URL.format(page=page - 1, limit=limit, query=quote(text, safe="")), headers={"User-Agent": _UA_IE, "Referer": "http://y.qq.com/portal/search.html"}, ) body = resp.body if isinstance(resp.body, dict) else {} if body.get("code") != 0: return await search(text, page, limit, retry_num + 1) data = body.get("data") or {} return { "list": [ { "play_count": format_play_count(item.get("listennum")), "id": str(item.get("dissid")), "author": decode_name((item.get("creator") or {}).get("name")), "name": decode_name(item.get("dissname")), "time": date_format(item.get("createtime"), "Y-M-D"), "img": item.get("imgurl"), "total": item.get("song_count"), "desc": decode_name(decode_name(item.get("introduction"))).replace("
", "\n"), "source": "tx", } for item in data.get("list") or [] ], "limit": limit, "total": data.get("sum"), "source": "tx", } async def get_detail_page_url(playlist_id: str) -> str: return f"https://y.qq.com/n/ryqq/playlist/{await get_list_id(playlist_id)}" sort_list = SORT_LIST