Files
MusicServer/app/sdk/auth/tx.py
T
snowy-yang dfc06bf4de 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 路由验证通过
2026-10-23 21:55:45 +08:00

383 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""QQ 音乐凭据(对应 sdk/auth/tx.ts)—— cookie 解析、扫码登录、VIP 检测与登录态续期。
续期走 musicu.fcg 未签名通道(与 apis/tx.py 一致);Login/VipLogin 模块若拒绝未签名请求
则以 code != 0 失败,此时保留原凭据并抛错,由调用方记录日志。
续期条件:仅浏览器 cookie(无 openId 等抓包字段)时只带 musicid/musickey 尝试,失败率较高;
完整凭据(/login/tx 可选传入 openId/accessToken/refreshToken/refreshKey)最可靠。
扫码登录(参考 sansenjian/qq-music-api):ptlogin 扫码 → check_sig →
graph.qq.com OAuth authorize 换 code → QQConnectLogin.LoginServer/QQLogin 换凭据,
成功响应体一般携带 refresh_key 等完整字段,可走自动续期。
"""
from __future__ import annotations
import base64
import random
import re
import time
from typing import Any
from urllib.parse import urlencode
from app.http.api_error import ApiError
from app.sdk.auth_store import get_credentials, set_credentials
from app.sdk.common.request import http_fetch
from app.sdk.common.utils import as_dict
HEADERS = {
"User-Agent": "Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; WOW64; Trident/5.0)",
"Referer": "https://y.qq.com/",
}
def parse_cookie(cookie: str) -> dict[str, str]:
"""解析 cookie 中的关键字段(uin / qm_keyst 即 musickey / tmeLoginType)。"""
text = str(cookie or "")
uin = re.search(r"uin=(\d+)", text)
musickey = re.search(r"qm_keyst=([^;]+)", text)
login_type = re.search(r"tmeLoginType=(\d+)", text)
return {
"uin": uin.group(1) if uin else "0",
"musickey": musickey.group(1) if musickey else "",
"tmeLoginType": login_type.group(1) if login_type else "",
}
async def _musicu_request(data: dict[str, Any], cred: dict[str, Any] | None) -> dict[str, Any]:
headers = dict(HEADERS)
cookie = str((cred or {}).get("cookie") or "")
if cookie:
headers["cookie"] = cookie
resp = await http_fetch(
"https://u.y.qq.com/cgi-bin/musicu.fcg",
method="POST",
headers=headers,
body=data,
timeout=15_000,
)
return resp.body if isinstance(resp.body, dict) else {}
async def check_vip() -> str | None:
"""检测当前账号 VIP 等级(vip_login_base),结果写入凭据存储。
成功返回 'svip' / 'vip' / 'normal';请求失败返回 None(不改动已存值)。
"""
cred = get_credentials("tx")
if not cred or not cred.get("cookie"):
return None
uin = parse_cookie(str(cred["cookie"]))["uin"]
body = await _musicu_request(
{
"comm": {"ct": "19", "cv": "1859", "uin": uin},
"req": {"module": "VipLogin.VipLoginInter", "method": "vip_login_base", "param": {}},
},
cred,
)
req = as_dict(body.get("req"))
if req.get("code") != 0:
return None
data = as_dict(req.get("data"))
identity = as_dict(data.get("identity"))
if identity.get("HugeVip"):
vip_type = "svip"
elif identity.get("LMFlag"):
vip_type = "vip"
else:
vip_type = "normal"
set_credentials("tx", {"vipType": vip_type})
return vip_type
async def refresh_login() -> dict[str, Any]:
"""续期 tx 登录态(music.login.LoginServer),成功后写回新 musickey 并顺带检测 VIP。"""
cred = get_credentials("tx")
if not cred or not cred.get("cookie"):
return {"skipped": True}
parsed = parse_cookie(str(cred["cookie"]))
uin, musickey = parsed["uin"], parsed["musickey"]
if not musickey:
raise RuntimeError("cookie 缺少 qm_keyst 字段")
param: dict[str, Any] = {
"musicid": int(uin) if uin.isdigit() else 0,
"musickey": musickey,
"loginMode": 2,
"expired_in": 0,
}
# App 抓包的完整凭据(存在才携带)
if cred.get("openId"):
param["openid"] = cred["openId"]
if cred.get("accessToken"):
param["access_token"] = cred["accessToken"]
if cred.get("refreshToken"):
param["refresh_token"] = cred["refreshToken"]
if cred.get("refreshKey"):
param["refresh_key"] = cred["refreshKey"]
body = await _musicu_request(
{
"comm": {"ct": "19", "cv": "1859", "uin": uin},
"req": {"module": "music.login.LoginServer", "method": "Login", "param": param},
},
cred,
)
req = as_dict(body.get("req"))
data = as_dict(req.get("data"))
if req.get("code") != 0 or not data.get("musickey"):
msg = f": {req['msg']}" if req.get("msg") else ""
raise RuntimeError(f"刷新失败(code {req.get('code')}{msg})")
# musickey 可能含 $ 等字符:用函数替换,避免字符串替换里的分组转义被展开
cookie = re.sub(r"qm_keyst=[^;]*", lambda _match: f"qm_keyst={data['musickey']}", str(cred["cookie"]))
updated: dict[str, Any] = {"cookie": cookie, "uin": str(data.get("musicid") or uin)}
if data.get("openid"):
updated["openId"] = str(data["openid"])
if data.get("access_token"):
updated["accessToken"] = str(data["access_token"])
if data.get("refresh_key"):
updated["refreshKey"] = str(data["refresh_key"])
set_credentials("tx", updated)
try:
vip_type = await check_vip()
except Exception: # noqa: BLE001 - VIP 检测失败不影响续期结果
vip_type = None
return {"refreshed": True, **({"vipType": vip_type} if vip_type else {})}
# ---------------- 扫码登录 ----------------
def _int32(value: int) -> int:
"""JS 位运算的 ToInt32 语义。"""
value &= 0xFFFFFFFF
return value - 0x100000000 if value >= 0x80000000 else value
def _hash33(text: str) -> int:
"""ptqrtoken:hash33(qrsig)。"""
hashed = 0
for char in text:
shifted = _int32(_int32(hashed) << 5)
hashed = _int32(hashed + shifted + ord(char))
return hashed & 0x7FFFFFFF
def _get_gtk(skey: str) -> int:
"""g_tk:p_skey 的 5381 哈希。"""
hashed = 5381
for char in skey:
shifted = _int32(_int32(hashed) << 5)
hashed = _int32(hashed + shifted + ord(char))
return hashed & 0x7FFFFFFF
def _random_guid() -> str:
def repl(match: re.Match[str]) -> str:
value = int(random.random() * 16) | 0
if match.group(0) == "y":
value = (value & 0x3) | 0x8
return format(value, "X")
return re.sub(r"[xy]", repl, "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx")
class _CookieJar:
"""登录链路共享的 cookie 罐:同名后到优先,存「name=value」对。"""
def __init__(self) -> None:
self._jar: dict[str, str] = {}
def merge(self, set_cookies: list[str] | None) -> None:
for item in set_cookies or []:
pair = str(item).split(";")[0]
name = pair.split("=")[0].strip() if "=" in pair else ""
if name:
self._jar[name] = pair
def get(self, name: str) -> str:
pair = self._jar.get(name)
return "=".join(pair.split("=")[1:]) if pair else ""
def entries(self) -> list[tuple[str, str]]:
return list(self._jar.items())
def header(self) -> str:
return "; ".join(self._jar.values())
class _PtloginError(RuntimeError):
def __init__(self, status_code: int, message: str) -> None:
super().__init__(message)
self.status_code = status_code
async def _ptlogin_request(path: str, query: dict[str, str], cookie: str) -> Any:
url = f"https://ssl.ptlogin2.qq.com/{path}?{urlencode(query)}"
kwargs: dict[str, Any] = {"timeout": 15_000}
if cookie:
kwargs["headers"] = {"cookie": cookie}
resp = await http_fetch(url, **kwargs)
if resp.status_code != 200:
raise _PtloginError(resp.status_code, f"ptlogin {path} 失败(HTTP {resp.status_code})")
return resp
async def qr_show() -> dict[str, Any]:
"""创建扫码登录二维码(客户端扫码后轮询 qr_check),qrsig 需原样回传。"""
resp = await http_fetch(
"https://ssl.ptlogin2.qq.com/ptqrshow?appid=716027609&e=2&l=M&s=3&d=72&v=4"
f"&t={random.random()}&daid=383&pt_3rd_aid=100497308"
"&u1=https%3A%2F%2Fgraph.qq.com%2Foauth2.0%2Flogin_jump",
timeout=15_000,
)
qrsig = ""
for item in resp.set_cookies:
match = re.search(r"qrsig=([^;]+)", item)
if match:
qrsig = match.group(1)
break
if not qrsig:
raise RuntimeError("获取二维码失败(响应缺少 qrsig)")
return {"qrimg": f"data:image/png;base64,{base64.b64encode(resp.raw).decode()}", "qrsig": qrsig}
async def qr_check(qrsig: str) -> dict[str, Any]:
"""轮询扫码状态:800 过期 / 801 等待 / 802 已扫码 / 803 成功(凭据已保存)。"""
if not qrsig:
raise ApiError(422, "缺少 qrsig")
jar = _CookieJar()
jar.merge([f"qrsig={qrsig}"])
# 1. 轮询扫码状态:0 成功 / 65 已失效 / 66 未扫描 / 67 已扫描 / 68 授权中
# qrsig 无效/已被 ptlogin 下线时返回 403,按二维码过期处理
try:
poll = await _ptlogin_request(
"ptqrlogin",
{
"u1": "https://graph.qq.com/oauth2.0/login_jump",
"ptqrtoken": str(_hash33(qrsig)),
"ptredirect": "0",
"h": "1",
"t": "1",
"g": "1",
"from_ui": "1",
"ptlang": "2052",
"action": f"0-0-{int(time.time() * 1000)}",
"js_ver": "23111510",
"js_type": "1",
"pt_uistyle": "40",
"aid": "716027609",
"daid": "383",
"pt_3rd_aid": "100497308",
"o1vId": _random_guid(),
},
jar.header(),
)
except _PtloginError as err:
if err.status_code == 403:
return {"code": 800, "message": "二维码已失效"}
raise
jar.merge(poll.set_cookies)
text = poll.body if isinstance(poll.body, str) else ""
match = re.search(r"ptuiCB\('(\d+)','[^']*','([^']*)'", text)
state = match.group(1) if match else ""
if state != "0":
states = {
"65": (800, "二维码已失效"),
"66": (801, "等待扫码"),
"67": (802, "已扫码,待确认"),
"68": (802, "授权中"),
}
code, message = states.get(state, (801, "等待扫码"))
return {"code": code, "message": message}
# 2. check_sig 换 p_skey(g_tk 需要)
check_sig_url = match.group(2) if match else ""
if not check_sig_url.startswith("http"):
raise RuntimeError("登录检查失败(未返回 check_sig 地址)")
check_sig = await http_fetch(check_sig_url, headers={"cookie": jar.header()}, timeout=15_000)
jar.merge(check_sig.set_cookies)
p_skey = jar.get("p_skey")
if not p_skey:
raise RuntimeError("登录检查失败(响应缺少 p_skey)")
# 3. OAuth authorize 换 code(appid 100497308 为 QQ 音乐开放平台)
authorize = await http_fetch(
"https://graph.qq.com/oauth2.0/authorize",
method="POST",
headers={"Referer": "https://graph.qq.com/oauth2.0/authorize", "cookie": jar.header()},
form={
"response_type": "code",
"client_id": "100497308",
"redirect_uri": "https://y.qq.com/portal/wx_redirect.html?login_type=1&surl=https://y.qq.com/",
"scope": "get_user_info,get_app_friends",
"state": "state",
"switch": "",
"from_ptlogin": "1",
"src": "1",
"update_auth": "1",
"openapi": "1010_1030",
"g_tk": str(_get_gtk(p_skey)),
"auth_time": str(int(time.time() * 1000)),
"ui": _random_guid(),
},
timeout=15_000,
)
jar.merge(authorize.set_cookies)
location = authorize.headers.get("location", "")
code_match = re.search(r"[?&]code=([^&]+)", location)
if not code_match:
raise RuntimeError("OAuth 授权失败(未取得 code)")
code = code_match.group(1)
# 4. QQLogin 换凭据:响应体 req.data 携带 musickey/musicid(及 openid 等续期字段)
login = await http_fetch(
"https://u.y.qq.com/cgi-bin/musicu.fcg",
method="POST",
headers={**HEADERS, "cookie": jar.header()},
body={
"comm": {"g_tk": _get_gtk(p_skey), "platform": "yqq", "ct": 24, "cv": 0},
"req": {"module": "QQConnectLogin.LoginServer", "method": "QQLogin", "param": {"code": code}},
},
timeout=15_000,
)
jar.merge(login.set_cookies)
body = as_dict(login.body)
req = as_dict(body.get("req"))
data = as_dict(req.get("data"))
musickey = str(data.get("musickey") or jar.get("qm_keyst") or "")
if not musickey:
raise RuntimeError("登录失败(未取得 musickey)")
# ptlogin 域的 uin 可能带 o 前缀(如 o0123456),统一剥掉
uin = re.sub(r"^o0*", "", str(data.get("musicid") or jar.get("uin") or "")) or "0"
# 最终 cookie:uin/qm_keyst 用凭据值放最前,其余链路 cookie 原样保留(排除 ptlogin 域噪声)
pairs = [f"uin={uin}", f"qm_keyst={musickey}"]
if data.get("encryptUin"):
pairs.append(f"euin={data['encryptUin']}")
skip = {"qrsig", "ptui_loginuin", "uin", "qm_keyst", "euin"}
for name, pair in jar.entries():
if name not in skip:
pairs.append(pair)
updated: dict[str, Any] = {"cookie": "; ".join(pairs), "uin": uin}
if data.get("openid"):
updated["openId"] = str(data["openid"])
if data.get("access_token"):
updated["accessToken"] = str(data["access_token"])
if data.get("refresh_token"):
updated["refreshToken"] = str(data["refresh_token"])
if data.get("refresh_key"):
updated["refreshKey"] = str(data["refresh_key"])
set_credentials("tx", updated)
try:
vip_type = await check_vip()
except Exception: # noqa: BLE001 - VIP 检测失败不影响登录结果
vip_type = None
return {"code": 803, "message": "登录成功", "uin": uin, **({"vipType": vip_type} if vip_type else {})}