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:
snowy-yang committed 2026-10-23 21:55:45 +08:00
1 parent 800871ef6e
commit dfc06bf4de
182 files changed
+26620

No files matched your search

+292
View File
@@ -0,0 +1,292 @@
"""QQ音乐个人歌单(移植自 sdk/tx/userPlaylist.ts)。
需登录 cookie(auth_store.get_tx_auth 返回的 cookie,须含 uin 类字段):
未登录 / cookie 里取不到 uin 时**抛出清晰错误**,不静默返回空列表(与原文一致)。
三路数据源,结果按 id 去重:
1) fcg_user_created_diss → 自建歌单
2) fcg_get_profile_order_asset reqtype=3 → 收藏歌单
3) fcg_get_profile_order_asset reqtype=1 → 「我喜欢的歌曲」虚拟歌单摘要(有歌才附上)
虚拟条目 id 固定为 `profile:favorites`(与原文一致),其歌曲需凭 cookie 走同一接口拉取。
"""
from __future__ import annotations
from typing import Any
from urllib.parse import urlencode
from app.sdk.auth_store import get_tx_auth
from app.sdk.common.request import http_fetch
from app.sdk.common.utils import format_play_count
_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
FAVORITE_ID = "profile:favorites"
FAVORITE_NAME = "我喜欢的歌曲"
_CREATED_URL = "https://c.y.qq.com/rsc/fcgi-bin/fcg_user_created_diss"
_PROFILE_ASSET_URL = "https://c.y.qq.com/fav/fcgi-bin/fcg_get_profile_order_asset.fcg"
# 上游 normalizeQQUIN 的候选键,按序取第一个非空
_UIN_KEYS = (
"wxuin",
"uin",
"ptui_loginuin",
"luin",
"pt2gguin",
"superuin",
"p_uin",
"musicid",
"userid",
)
def cookie_value(cookie: str, key: str) -> str:
for part in cookie.split(";"):
name, sep, value = part.strip().partition("=")
if sep and name.strip() == key:
return value.strip()
return ""
def normalize_qq_uin(cookie: str) -> str:
"""cookie → uin(去前导 `o` 与 `0`,与原文 normalizeQQUIN 一致)。"""
raw = ""
for key in _UIN_KEYS:
value = cookie_value(cookie, key)
if value:
raw = value
break
return raw.removeprefix("o").lstrip("0").strip()
def _playlist_link(playlist_id: str) -> str:
return f"https://y.qq.com/n/ryqq/playlist/{playlist_id}"
def _normalize_cover(cover: Any) -> str:
value = str(cover or "").strip()
if value.startswith("//"):
return f"https:{value}"
if value.startswith("http://"):
return value.replace("http://", "https://", 1)
return value
def _make_summary(
playlist_id: str,
name: str,
*,
cover: Any = "",
track_count: Any = 0,
play_count: Any = 0,
creator: Any = "",
description: Any = "",
link: str | None = None,
) -> dict[str, Any]:
count = int(play_count or 0)
return {
"id": playlist_id,
"name": name,
"cover": _normalize_cover(cover),
"trackCount": int(track_count or 0),
"playCount": count,
"play_count": format_play_count(count),
"creator": str(creator or ""),
"description": str(description or ""),
"link": link or _playlist_link(playlist_id),
"source": "tx",
}
def _profile_asset_url(uin: str, reqtype: str, page: int, limit: int) -> str:
offset = (max(1, page) - 1) * limit
query = urlencode(
{
"format": "json",
"inCharset": "utf8",
"outCharset": "utf-8",
"platform": "yqq.json",
"needNewCode": "0",
"loginUin": uin,
"hostUin": "0",
"notice": "0",
"g_tk": "5381",
"ct": "20",
"cid": "205360956",
"userid": uin,
"reqtype": reqtype,
"sin": str(offset),
"ein": str(offset + limit - 1),
}
)
return f"{_PROFILE_ASSET_URL}?{query}"
async def _fetch_json(url: str, cookie: str) -> dict[str, Any]:
resp = await http_fetch(
url,
headers={"User-Agent": _UA, "Referer": "https://y.qq.com/", "Cookie": cookie},
timeout=15000,
)
body = resp.body if isinstance(resp.body, dict) else None
if resp.status_code != 200 or body is None:
raise RuntimeError("tx 个人歌单接口请求失败")
return body
async def _fetch_created(cookie: str, uin: str, page: int, limit: int) -> list[dict[str, Any]]:
"""自建歌单(fcg_user_created_diss)。id 优先级:dissid > tid > profile:dir:<dirid>。"""
query = urlencode(
{
"hostuin": uin,
"sin": str((page - 1) * limit),
"size": str(limit),
"format": "json",
"inCharset": "utf8",
"outCharset": "utf-8",
}
)
body = await _fetch_json(f"{_CREATED_URL}?{query}", cookie)
if body.get("code") != 0:
raise RuntimeError(f"tx 自建歌单获取失败: {body.get('message') or ''} (code {body.get('code')})")
data = body.get("data") or {}
rows: list[dict[str, Any]] = []
for item in data.get("disslist") or []:
if not isinstance(item, dict):
continue
diss_id = int(item.get("dissid") or 0)
tid = int(item.get("tid") or 0)
dir_id = int(item.get("dirid") or 0)
if diss_id > 0:
playlist_id = str(diss_id)
elif tid > 0:
playlist_id = str(tid)
elif dir_id > 0:
playlist_id = f"profile:dir:{dir_id}"
else:
playlist_id = ""
name = str(item.get("diss_name") or item.get("title") or "").strip()
if not playlist_id or not name:
continue
rows.append(
_make_summary(
playlist_id,
name,
cover=item.get("diss_cover") or item.get("cover"),
track_count=item.get("song_count") or item.get("song_num") or item.get("song_cnt") or 0,
play_count=item.get("listen_num") or item.get("visitnum") or 0,
creator=uin,
description=item.get("diss_desc") or item.get("desc") or "",
)
)
for item in data.get("list") or []:
if not isinstance(item, dict):
continue
playlist_id = str(item.get("dissid") or "").strip()
name = str(item.get("dissname") or "").strip()
if not playlist_id or not name:
continue
rows.append(
_make_summary(
playlist_id,
name,
cover=item.get("imgurl"),
track_count=item.get("song_count") or item.get("song_num") or 0,
play_count=item.get("listennum") or 0,
creator=uin,
description=item.get("introduction") or "",
)
)
return rows
async def _fetch_collected(cookie: str, uin: str, page: int, limit: int) -> list[dict[str, Any]]:
"""收藏歌单(fcg_get_profile_order_asset reqtype=3)。code 非 0 时返回空列表(同原文)。"""
body = await _fetch_json(_profile_asset_url(uin, "3", page, limit), cookie)
if body.get("code") != 0:
return []
rows: list[dict[str, Any]] = []
for item in (body.get("data") or {}).get("cdlist") or []:
if not isinstance(item, dict):
continue
diss_id = int(item.get("dissid") or 0)
name = str(item.get("dissname") or "").strip()
if diss_id <= 0 or not name:
continue
rows.append(
_make_summary(
str(diss_id),
name,
cover=item.get("logo"),
track_count=item.get("songnum") or 0,
play_count=item.get("listennum") or 0,
creator=item.get("nickname") or item.get("uin") or uin,
)
)
return rows
async def _fetch_favorite_summary(cookie: str, uin: str) -> dict[str, Any] | None:
"""「我喜欢的歌曲」摘要(reqtype=1,limit=1)。总数为 0 时返回 None。"""
body = await _fetch_json(_profile_asset_url(uin, "1", 1, 1), cookie)
if body.get("code") != 0:
return None
data = body.get("data") or {}
total = int(data.get("totalsong") or 0)
if total <= 0:
return None
first = ((data.get("songlist") or [{}])[0] or {}).get("data") or {}
album_mid = first.get("albummid") if isinstance(first, dict) else None
return _make_summary(
FAVORITE_ID,
FAVORITE_NAME,
cover=f"https://y.gtimg.cn/music/photo_new/T002R300x300M000{album_mid}.jpg" if album_mid else "",
track_count=total,
creator=uin,
description="QQ音乐我喜欢的歌曲",
link="https://y.qq.com/n/ryqq/profile",
)
async def get_list(page: int = 1, limit: int = 30) -> dict[str, Any]:
"""个人歌单列表。未登录 / 缺 uin 直接抛错,不返回空列表。"""
auth = get_tx_auth()
cookie = str((auth or {}).get("cookie") or "").strip()
if not cookie:
raise RuntimeError("tx 个人歌单需要登录 cookie(请先登录 QQ音乐)")
uin = normalize_qq_uin(cookie)
if not uin:
raise RuntimeError("tx 个人歌单需要 cookie 中的 uin(当前 cookie 不含 uin/wxuin 等字段)")
safe_page = max(1, int(page or 1))
safe_limit = int(limit or 30)
if safe_limit <= 0:
safe_limit = 30
if safe_limit > 100:
safe_limit = 100
rows: list[dict[str, Any]] = []
seen: set[str] = set()
def add(summary: dict[str, Any] | None) -> None:
if not summary:
return
playlist_id = summary.get("id")
if not playlist_id or not summary.get("name") or playlist_id in seen:
return
seen.add(playlist_id)
rows.append(summary)
try:
add(await _fetch_favorite_summary(cookie, uin))
except (RuntimeError, OSError, TimeoutError):
# 摘要失败不阻塞列表(与上游一致)
pass
for item in await _fetch_created(cookie, uin, safe_page, safe_limit):
add(item)
for item in await _fetch_collected(cookie, uin, safe_page, safe_limit):
add(item)
return {"list": rows, "page": safe_page, "limit": safe_limit, "total": len(rows), "source": "tx"}