feat(python): 完成 Python 全量迁移——剩余平台、登录通道与公共层修复
- 新增 5sing/apple/xm/me/qt/lr 平台包与取流实现(含 xm-sign、MissEvan 签名、蜻蜓 HMAC、Apple developer token) - 新增 wy/kg/tx/lr 登录:扫码、手机号、账密(kg 20028 风控指纹)、cookie 绑定、kg/tx 续期 - request 层:保留多值 Set-Cookie;JSON body 紧凑化(修复 kg 网关验签);直连不经系统代理(对齐 Node) - 登录路由统一 Body(embed=True),修复扫码轮询 422;lifespan 接入登录续期调度 - ruff/format/pyright 全绿;14 平台真实联网与 HTTP 路由验证通过
This commit is contained in:
1 parent
800871ef6e
commit
dfc06bf4de
182 files changed
+26620
No files matched your search
@@ -0,0 +1,183 @@
|
||||
"""QQ音乐专辑(移植自 sdk/tx/album.ts)。
|
||||
|
||||
三条路径:
|
||||
* 详情 —— music.musichallAlbum.AlbumSongList/GetAlbumSongList(参数名必须是驼峰
|
||||
`albumMid`,下划线形态会被上游以 104400 拒绝)
|
||||
* 全量曲目 —— 同一接口按 100 一批翻页(上游 fetchAlbumDetail 的曲目段)
|
||||
* 搜索 —— c.y.qq.com 的 search_for_qq_cp `t=8`
|
||||
|
||||
GetAlbumInfo 详情接口已停用:专辑名取自曲目自带的 album 信息,作者由曲目歌手去重合成,
|
||||
封面走平台固定图片 URL 规则。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
|
||||
from app.sdk.common.request import http_fetch
|
||||
from app.sdk.platforms.tx.filter import album_img, music_info_item
|
||||
from app.sdk.platforms.tx.utils import create_musicu_fetch
|
||||
|
||||
_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
|
||||
ALBUM_BATCH = 100
|
||||
|
||||
SEARCH_URL = "http://c.y.qq.com/soso/fcgi-bin/search_for_qq_cp?format=json&p={page}&n={limit}&w={query}&t=8"
|
||||
|
||||
_ALBUM_PATTERNS: tuple[re.Pattern[str], ...] = (
|
||||
re.compile(r"albumDetail/([A-Za-z0-9]+)"),
|
||||
re.compile(r"/album/([A-Za-z0-9]+)"),
|
||||
re.compile(r"albummid=([A-Za-z0-9]+)"),
|
||||
)
|
||||
|
||||
|
||||
def filter_song_list(raw: Any) -> list[dict[str, Any]]:
|
||||
"""GetAlbumSongList 的 songList(每项包一层 {songInfo})→ SongInfo 列表。"""
|
||||
return [music_info_item((item or {}).get("songInfo")) for item in raw or []]
|
||||
|
||||
|
||||
def album_link(mid: str) -> str:
|
||||
return f"https://y.qq.com/n/ryqq/albumDetail/{mid}"
|
||||
|
||||
|
||||
async def fetch_album_songs(album_id: str) -> dict[str, Any]:
|
||||
"""专辑曲目全量(GetAlbumSongList 100 一批翻页)。
|
||||
|
||||
终止条件与原文一致:本批为空、本批不足一批、或已翻到 totalNum 为止。
|
||||
"""
|
||||
album_mid = str(album_id or "").strip()
|
||||
if not album_mid:
|
||||
raise RuntimeError("tx 专辑 id 为空")
|
||||
|
||||
songs: list[dict[str, Any]] = []
|
||||
total_num = 0
|
||||
begin = 0
|
||||
while True:
|
||||
body = await create_musicu_fetch(
|
||||
{
|
||||
"req": {
|
||||
"module": "music.musichallAlbum.AlbumSongList",
|
||||
"method": "GetAlbumSongList",
|
||||
"param": {"albumMid": album_mid, "begin": begin, "num": ALBUM_BATCH, "order": 2},
|
||||
}
|
||||
}
|
||||
)
|
||||
req = body.get("req") or {}
|
||||
if req.get("code") != 0:
|
||||
raise RuntimeError("get album song list faild.")
|
||||
data = req.get("data") or {}
|
||||
batch = data.get("songList") or []
|
||||
if not total_num:
|
||||
total_num = int(data.get("totalNum") or 0)
|
||||
if not batch:
|
||||
break
|
||||
songs.extend(batch)
|
||||
if len(batch) < ALBUM_BATCH:
|
||||
break
|
||||
if total_num > 0 and begin + len(batch) >= total_num:
|
||||
break
|
||||
begin += ALBUM_BATCH
|
||||
|
||||
first: dict[str, Any] = {}
|
||||
if songs:
|
||||
first = (songs[0] or {}).get("songInfo") or {}
|
||||
return {
|
||||
"raw": songs,
|
||||
"songs": filter_song_list(songs),
|
||||
"total": total_num or len(songs),
|
||||
"albumMid": album_mid,
|
||||
"albumName": (first.get("album") or {}).get("name"),
|
||||
}
|
||||
|
||||
|
||||
async def get_album_detail(id: str, page: int = 1, limit: int = 100) -> dict[str, Any]:
|
||||
"""专辑详情 + 曲目(路由优先走这个签名:get_album_detail(id, page, limit))。"""
|
||||
body = await create_musicu_fetch(
|
||||
{
|
||||
"req": {
|
||||
"module": "music.musichallAlbum.AlbumSongList",
|
||||
"method": "GetAlbumSongList",
|
||||
"param": {"albumMid": id, "begin": (page - 1) * limit, "num": limit},
|
||||
}
|
||||
}
|
||||
)
|
||||
req = body.get("req") or {}
|
||||
if req.get("code") != 0:
|
||||
raise RuntimeError("get album song list faild.")
|
||||
|
||||
data = req.get("data") or {}
|
||||
song_list = data.get("songList") or []
|
||||
first: dict[str, Any] = {}
|
||||
if song_list:
|
||||
first = (song_list[0] or {}).get("songInfo") or {}
|
||||
authors: list[str] = []
|
||||
for item in song_list:
|
||||
for singer in ((item or {}).get("songInfo") or {}).get("singer") or []:
|
||||
name = (singer or {}).get("name")
|
||||
if name and name not in authors:
|
||||
authors.append(name)
|
||||
return {
|
||||
"source": "tx",
|
||||
"info": {
|
||||
"name": (first.get("album") or {}).get("name"),
|
||||
"img": album_img(str(id)),
|
||||
"desc": None,
|
||||
"author": "、".join(authors) or None,
|
||||
},
|
||||
"list": filter_song_list(song_list),
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
"total": data.get("totalNum"),
|
||||
}
|
||||
|
||||
|
||||
async def get_album_songs(id: str) -> list[dict[str, Any]]:
|
||||
return (await fetch_album_songs(id))["songs"]
|
||||
|
||||
|
||||
async def search_album(keyword: str, page: int = 1, limit: int = 10) -> dict[str, Any]:
|
||||
"""专辑搜索(上游 SearchAlbum:search_for_qq_cp t=8)。"""
|
||||
resp = await http_fetch(
|
||||
SEARCH_URL.format(page=page, limit=limit, query=quote(str(keyword or ""), safe="")),
|
||||
headers={"User-Agent": _UA, "Referer": "https://y.qq.com/portal/search.html"},
|
||||
timeout=15000,
|
||||
)
|
||||
body = resp.body if isinstance(resp.body, dict) else {}
|
||||
raw_data = body.get("data")
|
||||
album_data: dict[str, Any] = (raw_data.get("album") if isinstance(raw_data, dict) else None) or {}
|
||||
if resp.status_code != 200 or not album_data:
|
||||
raise RuntimeError("tx 专辑搜索失败")
|
||||
|
||||
rows: list[dict[str, Any]] = []
|
||||
for item in album_data.get("list") or []:
|
||||
mid = str((item or {}).get("albumMID") or "").strip()
|
||||
if not mid:
|
||||
continue
|
||||
rows.append(
|
||||
{
|
||||
"id": mid,
|
||||
"name": str(item.get("albumName") or "").strip(),
|
||||
"cover": f"https://y.gtimg.cn/music/photo_new/T002R300x300M000{mid}.jpg",
|
||||
"trackCount": int(item.get("song_count") or item.get("songCount") or 0),
|
||||
"creator": str(item.get("singerName") or "").strip(),
|
||||
"description": "",
|
||||
"link": album_link(mid),
|
||||
"type": "album",
|
||||
"albumId": str(item.get("albumID") or ""),
|
||||
"publishTime": str(item.get("publicTime") or ""),
|
||||
"source": "tx",
|
||||
}
|
||||
)
|
||||
total = int(album_data.get("totalnum") or album_data.get("total") or len(rows)) or len(rows)
|
||||
return {"list": rows, "page": page, "limit": limit, "total": total, "source": "tx"}
|
||||
|
||||
|
||||
async def parse_album_link(link: str) -> dict[str, Any]:
|
||||
"""专辑链接解析(上游 ParseAlbum):命中任一形态即取详情,否则原文案报错。"""
|
||||
raw = str(link or "")
|
||||
for pattern in _ALBUM_PATTERNS:
|
||||
match = pattern.search(raw)
|
||||
if match:
|
||||
return await get_album_detail(match.group(1))
|
||||
raise RuntimeError("无效的QQ音乐专辑链接")
|
||||
Reference in new issue
Block a user