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

+21
View File
@@ -0,0 +1,21 @@
"""账号登录模块(对应原 sdk/auth/ 与各平台 login/auth 文件)。
每个模块导出一组 async 函数,路由层只依赖这些签名:
wy:qr_key / qr_check / cellphone_login / login_by_cookie / status
kg:login_by_token / qr_key / qr_check / send_captcha / cellphone_login /
password_login / verify_user_info / status / refresh_login
tx:qr_show / qr_check / check_vip / refresh_login
lr:login / status
bili:create_qr_login / check_qr_login / status
约定:
* 成功直接返回 dict(由路由包装成 { code: 0, data })
* 可预期的业务失败抛 ApiError(401/422/502, msg)(路由原样转发状态码)
"""
from __future__ import annotations
__all__ = ["bili", "kg", "lr", "tx", "wy"]
from app.sdk.auth import bili, kg, lr, tx, wy
+223
View File
@@ -0,0 +1,223 @@
"""哔哩哔哩扫码登录(对应 sdk/bili/login.ts,上游 bilibili/login.go + account.go 的 IsVipAccount)。
接口:
* GET passport.bilibili.com/x/passport-login/web/qrcode/generate → data.{url, qrcode_key}
* GET passport.bilibili.com/x/passport-login/web/qrcode/poll?qrcode_key= → data.code
0 成功 / 86038 过期 / 86090 已扫码待确认 / 86101 未扫码
成功时登录态 Cookie 在 poll 响应的 Set-Cookie 与 data.url 的 crossDomain 查询串里(两处都收集)。
凭据落盘到 auth_store 的 ``bili`` 记录(cookie / refreshToken / 账号资料),与 kg/wy 一致;
上游 ``New(cookie)`` 的实例化语义由 ``get_credentials('bili')`` 承担(见 platforms/bili/util.py)。
状态码沿用本仓库约定:800 过期 / 801 等待扫码 / 802 已扫码待确认 / 803 登录成功。
"""
from __future__ import annotations
import random
import time
from typing import Any
from urllib.parse import parse_qs, urlsplit
from app.http.api_error import ApiError
from app.sdk.auth_store import get_credentials, set_credentials
from app.sdk.common.qrcode import to_data_url
from app.sdk.common.request import http_fetch
from app.sdk.platforms.bili.util import REFERER, UA, as_dict
QR_GENERATE_API = "https://passport.bilibili.com/x/passport-login/web/qrcode/generate"
QR_POLL_API = "https://passport.bilibili.com/x/passport-login/web/qrcode/poll"
#: 上游 utils.WithRandomIPHeader:随机国内 IP,规避部分区域限制
_CHINA_IP_FIRST = [
36,
42,
58,
60,
101,
106,
110,
111,
112,
113,
114,
115,
116,
117,
118,
119,
120,
121,
122,
123,
124,
125,
171,
175,
180,
182,
183,
210,
211,
218,
219,
220,
221,
222,
223,
]
_CROSS_DOMAIN_KEYS = ("DedeUserID", "DedeUserID__ckMd5", "SESSDATA", "bili_jct", "sid")
def _random_china_ip() -> str:
first = random.choice(_CHINA_IP_FIRST)
return f"{first}.{random.randint(1, 254)}.{random.randint(1, 254)}.{random.randint(1, 254)}"
def _qr_headers() -> dict[str, str]:
ip = _random_china_ip()
return {"User-Agent": UA, "Referer": REFERER, "X-Forwarded-For": ip, "X-Real-IP": ip}
def _cookie_map(cookie_lines: list[str]) -> dict[str, str]:
jar: dict[str, str] = {}
for value in cookie_lines:
pair = str(value).split(";")[0].strip()
idx = pair.find("=")
if idx > 0:
jar[pair[:idx].strip()] = pair[idx + 1 :].strip()
return jar
def _cookie_map_from_cross_domain(cross_url: str) -> dict[str, str]:
"""crossDomain 回跳地址的查询串里也带 SESSDATA 等(无 cookie 包时后备)。"""
jar: dict[str, str] = {}
try:
query = parse_qs(urlsplit(str(cross_url or "")).query)
except ValueError:
return jar
for name in _CROSS_DOMAIN_KEYS:
values = query.get(name)
if values:
jar[name] = values[0]
return jar
def _cookie_string(jar: dict[str, str]) -> str:
return "; ".join(f"{key}={value}" for key, value in jar.items())
async def create_qr_login() -> dict[str, Any]:
"""创建扫码登录会话:返回 ``{key, qrurl, qrimg, expiresAt}``(qrimg 可直接展示)。"""
resp = await http_fetch(QR_GENERATE_API, headers=_qr_headers(), timeout=15_000)
body = resp.body
if resp.status_code != 200 or not isinstance(body, dict):
raise RuntimeError(f"获取B站登录二维码失败(HTTP {resp.status_code})")
data = as_dict(body.get("data"))
key = str(data.get("qrcode_key") or "").strip()
if body.get("code") != 0 or not key:
message = body.get("message")
raise RuntimeError(f"获取B站登录二维码失败({body.get('code')}{f': {message}' if message else ''})")
url = str(data.get("url") or "")
qrimg = ""
try:
qrimg = to_data_url(url)
except Exception: # noqa: BLE001 - 二维码渲染失败不影响 key/url 返回
qrimg = ""
return {
"key": key,
"qrurl": url,
"qrimg": qrimg,
# 上游 QRLoginSession.ExpiresAt:3 分钟有效期
"expiresAt": int(time.time()) + 180,
}
async def _fetch_profile(cookie: str) -> dict[str, Any]:
"""拉取账号资料(nav;失败不阻断登录)。"""
try:
resp = await http_fetch(
"https://api.bilibili.com/x/web-interface/nav",
headers={"User-Agent": UA, "Referer": REFERER, "Cookie": cookie},
timeout=15_000,
)
body = as_dict(resp.body)
data = as_dict(body.get("data"))
return {
"mid": data.get("mid"),
"nickname": data.get("uname"),
"vipType": int(data.get("vipType") or 0),
"vipStatus": int(data.get("vipStatus") or 0),
}
except Exception: # noqa: BLE001 - 资料失败不阻断登录
return {"mid": None, "nickname": None, "vipType": 0, "vipStatus": 0}
async def _poll(key: str) -> tuple[int, list[str], Any]:
"""轮询请求(http_fetch 的 set_cookies 保留全部 Set-Cookie,登录凭据靠它取)。"""
url = f"{QR_POLL_API}?qrcode_key={key}"
resp = await http_fetch(url, headers={"User-Agent": UA, "Referer": REFERER}, timeout=15_000)
return resp.status_code, resp.set_cookies, resp.body
async def check_qr_login(key: str) -> dict[str, Any]:
"""轮询扫码状态。
返回 code:800 二维码过期 / 801 等待扫码 / 802 已扫码待确认 / 803 登录成功(凭据已落盘)。
"""
qrcode_key = str(key or "").strip()
if not qrcode_key:
raise RuntimeError("B站扫码 key 为空")
status, cookie_lines, body = await _poll(qrcode_key)
if status != 200:
raise ApiError(502, f"轮询B站扫码状态失败(HTTP {status})")
data = as_dict(body.get("data")) if isinstance(body, dict) else {}
code = int(data.get("code") or 0)
if code == 0:
jar = {**_cookie_map_from_cross_domain(data.get("url") or ""), **_cookie_map(cookie_lines)}
cookie = _cookie_string(jar)
if not cookie:
raise RuntimeError("B站登录成功但未获取到 Cookie,请重试")
profile = await _fetch_profile(cookie)
set_credentials(
"bili",
{
"cookie": cookie,
"refreshToken": str(data.get("refresh_token") or ""),
"mid": profile["mid"],
"nickname": profile["nickname"],
"vipType": profile["vipType"],
"vipStatus": profile["vipStatus"],
},
)
from app.sdk.apis.bili import reset_vip_cache
reset_vip_cache()
return {"code": 803, "message": "登录成功", "status": "success", "profile": profile}
status_map: dict[int, tuple[int, str, str]] = {
86038: (800, "二维码已过期", "expired"),
86090: (802, "已扫码,待确认", "scanned"),
86101: (801, "等待扫码", "waiting"),
}
if code in status_map:
mapped, message, state = status_map[code]
return {"code": mapped, "message": message, "status": state}
message = data.get("message") or f"未知状态 {code}"
return {"code": 800, "message": str(message), "status": "failed"}
def status() -> dict[str, Any]:
"""当前登录态(凭据存在性 + 落盘时保存的账号资料)。"""
cred = get_credentials("bili") or {}
if cred.get("cookie"):
return {
"logged": True,
"nickname": cred.get("nickname"),
"mid": cred.get("mid"),
"vipType": cred.get("vipType") or 0,
"updatedAt": cred.get("updatedAt"),
}
return {"logged": False}
+686
View File
@@ -0,0 +1,686 @@
"""酷狗登录(对应 sdk/auth/kg.ts)—— token 绑定 / 扫码 / 短信验证码 / 账号密码。
与 MakcRe/KuGouMusicApi 协议对齐:
* 扫码走 login-user.kugou.com 的 web 签名通道;
* 短信走 android 签名 + AES/RSA;
* 账密走 /v9/login_by_pwd,触发 20028 风控时返回验证码挑战(ssa-code 事件 +
sid/edt 行为指纹,指纹由 kg_simulate.py 服务端生成,无需浏览器 WASM);
* token 也可从酷狗 App 抓包获取(login.token)后经 /login/kg 绑定。
设备指纹(guid/mid/dfid)与取流共用 platforms/kg/util.py 的实现(落盘 _kg_device)。
"""
from __future__ import annotations
import base64
import json
import time
from typing import Any
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from app.http.api_error import ApiError
from app.sdk.auth.kg_simulate import generate_simulate
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, to_md5
from app.sdk.platforms.kg.util import (
ANDROID_SALT,
WEB_SALT,
android_sign,
encode_uri_component,
get_device,
pkcs7_pad,
pkcs7_unpad,
random_string,
)
APPID = 1005
CLIENTVER = 11430
#: 登录专用域按参考实现固定 clientver 20489(qr/短信接口实测通过的版本)
LOGIN_CLIENTVER = 20489
SRC_APPID = 2919
QR_URL_PREFIX = "https://h5.kugou.com/apps/loginQRCode/html/index.html"
PUBLIC_KEY = """-----BEGIN PUBLIC KEY-----
MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDIAG7QOELSYoIJvTFJhMpe1s/gbjDJX51HBNnEl5HXqTW6lQ7LC8jr9fWZTwusknp+sVGzwd40MwP6U5yDE27M/X1+UR4tvOGOqp94TJtQ1EPnWGWXngpeIW5GxoQGao1rmYWAu6oi1z9XkChrsUdC6DJE5E221wf/4WLFxwAtRQIDAQAB
-----END PUBLIC KEY-----"""
#: 固定串派生 token 续期密钥(AES-256-CBC key / iv)
AES_KEY_SEED = "90b8382a1bb4ccdcf063102053fd75b8"
AES_IV = "f063102053fd75b8"
# login_by_pwd 的设备校验常量(与参考实现一致,疑似风控指纹固定值)
PWD_T1 = (
"562a6f12a6e803453647d16a08f5f0c2ff7eee692cba2ab74cc4c8ab47fc467561a7c6b586ce7dc46a63613b246737c0"
"3a1dc8f8d162d8ce1d2c71893d19f1d4b797685a4c6d3d81341cbde65e488c4829a9b4d42ef2df470eb102979fa5ad"
"cdd9b4eecfea8b909ff7599abeb49867640f10c3c70fc444effca9d15db44a9a6c907731e2bb0f22cd9b3536380169"
"995693e5f0e2424e3378097d3813186e3fe96bbe7023808a0981b4e2b6135a76faac"
)
PWD_T2 = (
"31c4daf4cf480169ccea1cb7d4a209295865a9d2b788510301694db229b87807469ea0d41b4d4b9173c2151da7294a"
"eebfc9738df154bbdf11a4e117bb5dff6a3af8ce5ce333e681c1f29a44038f27567d58992eb81283e080778ac77db14"
"00fdf49b7cf7e26be2e5af4da7830cc3be4"
)
PWD_T3 = "MCwwLDAsMCwwLDAsMCwwLDA="
PWD_CLIENTVER = 11440
VERIFY_VTYPES = {
16: "QQ 绑定验证",
18: "微信绑定验证",
22: "活体认证",
23: "滑块/图形验证码",
32: "手机短信验证码",
34: "人脸核身",
}
VERIFY_URL_TYPES = {
"KGCodeGT": "极验",
"KGCodeTX": "腾讯",
"KGCodeKG": "酷狗旋转",
"KGCodeKG2": "酷狗滑块",
"KGCodeSM": "数美",
"KGCodeYD": "易盾",
}
_ANDROID_UA = "Android15-1070-11083-46-0-DiscoveryDRADProtocol-wifi"
_public_key = None
def _load_public_key() -> Any:
global _public_key
if _public_key is None:
_public_key = serialization.load_pem_public_key(PUBLIC_KEY.encode())
return _public_key
def _js_json(data: Any) -> str:
return json.dumps(data, ensure_ascii=False, separators=(",", ":"))
def _rsa_pkcs1_hex(data: dict[str, Any]) -> str:
"""RSA PKCS#1 v1.5 加密(register_dev 的 p / 登录的 pk 之外的默认形态)。"""
return _load_public_key().encrypt(_js_json(data).encode(), padding.PKCS1v15()).hex()
def _rsa_no_padding_hex(data: dict[str, Any], *, pad_left: bool) -> str:
"""RSA 裸填充加密(128 字节;verify 左补零,登录/续期右补零,与参考实现一致)。"""
payload = _js_json(data).encode()
if len(payload) > 128:
raise RuntimeError("RSA 负载超长")
filler = b"\x00" * (128 - len(payload))
padded = filler + payload if pad_left else payload + filler
numbers = _load_public_key().public_numbers()
cipher = pow(int.from_bytes(padded, "big"), numbers.e, numbers.n).to_bytes(128, "big")
return cipher.hex()
def _aes_cbc_encrypt_hex(data: str, key: str, iv: str) -> str:
cipher = Cipher(algorithms.AES(key.encode()), modes.CBC(iv.encode()))
encryptor = cipher.encryptor()
return (encryptor.update(pkcs7_pad(data.encode())) + encryptor.finalize()).hex()
def _aes_encrypt_random(data: str | dict[str, Any]) -> dict[str, str]:
"""随机密钥 AES-256-CBC:key=md5(rand16),iv=key[16:32]。"""
temp_key = random_string(16)
key_hex = to_md5(temp_key)
text = data if isinstance(data, str) else _js_json(data)
return {"str": _aes_cbc_encrypt_hex(text, key_hex, key_hex[16:32]), "key": temp_key}
def _aes_decrypt_hex(hex_text: str, temp_key: str) -> Any:
key_hex = to_md5(temp_key)
decryptor = Cipher(algorithms.AES(key_hex.encode()), modes.CBC(key_hex[16:32].encode())).decryptor()
out = decryptor.update(bytes.fromhex(hex_text)) + decryptor.finalize()
text = pkcs7_unpad(out).decode("utf-8", errors="replace")
try:
return json.loads(text)
except ValueError:
return text
def _playlist_aes_encrypt(data: dict[str, Any]) -> dict[str, str]:
key = random_string(6)
key_hex = to_md5(key)
cipher = Cipher(algorithms.AES(key_hex[:16].encode()), modes.CBC(key_hex[16:32].encode()))
encryptor = cipher.encryptor()
payload = encryptor.update(pkcs7_pad(_js_json(data).encode())) + encryptor.finalize()
return {"key": key, "str": base64.b64encode(payload).decode()}
def _playlist_aes_decrypt(raw_base64: str, key: str) -> Any:
key_hex = to_md5(key)
decryptor = Cipher(algorithms.AES(key_hex[:16].encode()), modes.CBC(key_hex[16:32].encode())).decryptor()
out = decryptor.update(base64.b64decode(raw_base64)) + decryptor.finalize()
text = pkcs7_unpad(out).decode("utf-8", errors="replace")
try:
return json.loads(text)
except ValueError:
return text
def _int_or_zero(value: Any) -> int:
try:
return int(value)
except (TypeError, ValueError):
return 0
def _gateway_headers(params: dict[str, Any], router: str | None, extra: dict[str, str] | None) -> dict[str, str]:
headers = {
"dfid": str(params["dfid"]),
"mid": str(params["mid"]),
"clienttime": str(params["clienttime"]),
"kg-rc": "1",
"kg-thash": "5d816a0",
"kg-rec": "1",
"kg-rf": "B9EDA08A64250DEFFBCADDEE00F8F25F",
"User-Agent": _ANDROID_UA,
}
if router:
headers["x-router"] = router
if extra:
headers.update(extra)
return headers
async def gateway_request(
url: str,
*,
params: dict[str, Any] | None = None,
data: Any = None,
router: str | None = None,
host: str = "https://gateway.kugou.com",
headers: dict[str, str] | None = None,
with_headers: bool = False,
) -> Any:
"""网关移动端签名请求(默认 gateway.kugou.com + x-router,可 host 直连登录专用域)。"""
dev = get_device()
all_params: dict[str, Any] = {
"dfid": dev["dfid"],
"mid": dev["mid"],
"uuid": "-",
"appid": APPID,
"clientver": CLIENTVER,
"clienttime": int(time.time()),
}
if params:
all_params.update(params)
signature = android_sign(all_params, _js_json(data) if data is not None else "")
query = "&".join(f"{key}={encode_uri_component(value)}" for key, value in all_params.items())
kwargs: dict[str, Any] = {
"method": "POST",
"headers": _gateway_headers(all_params, router, headers),
"timeout": 15_000,
}
if data is not None:
kwargs["body"] = data
resp = await http_fetch(f"{host}{url}?{query}&signature={signature}", **kwargs)
body = as_dict(resp.body)
if with_headers:
return body, resp.headers
return body
def _web_sign(params: dict[str, Any]) -> str:
items = sorted(f"{key}={value}" for key, value in params.items())
return to_md5(f"{WEB_SALT}{''.join(items)}{WEB_SALT}")
async def _login_user_get(url: str, extra_params: dict[str, Any] | None = None) -> dict[str, Any]:
"""login-user.kugou.com GET 请求(web 签名,与取流共用同一设备指纹)。"""
dev = get_device()
params: dict[str, Any] = {
"dfid": dev["dfid"],
"mid": dev["mid"],
"uuid": "-",
"appid": APPID,
"clientver": LOGIN_CLIENTVER,
"clienttime": int(time.time()),
}
if extra_params:
params.update(extra_params)
query = "&".join(f"{key}={encode_uri_component(value)}" for key, value in params.items())
resp = await http_fetch(
f"https://login-user.kugou.com{url}?{query}&signature={_web_sign(params)}",
headers=_gateway_headers(params, None, None),
timeout=15_000,
)
body = as_dict(resp.body)
if body.get("status") != 1:
raise ApiError(502, str(body.get("error") or "酷狗登录服务请求失败"))
return body
async def verify_token(token: str, userid: str | int) -> dict[str, Any]:
"""验证 token 并返回账号资料(usercenter /v3/get_my_info,p = RSA 裸填充左补零)。"""
unix_sec = int(time.time())
p = _rsa_no_padding_hex({"token": token, "clienttime": unix_sec}, pad_left=True).upper()
body = await gateway_request(
"/v3/get_my_info",
router="usercenter.kugou.com",
params={"plat": 1},
data={"visit_time": unix_sec, "usertype": 1, "p": p, "userid": _int_or_zero(userid)},
)
data = as_dict(body.get("data"))
if body.get("status") != 1 or not data:
raise ApiError(401, str(body.get("error") or "token 无效或已过期"))
return {
"userid": data.get("userid") or _int_or_zero(userid),
"nickname": data.get("nickname") or data.get("username") or None,
"vip": bool(data["vip"]) if data.get("vip") else None,
}
async def login_by_token(token: str, userid: str | int) -> dict[str, Any]:
"""绑定并验证 kg token。"""
profile = await verify_token(token, userid)
set_credentials("kg", {"token": token, "userid": profile["userid"], "nickname": profile["nickname"]})
return profile
async def _apply_kg_login(token: str, userid: Any) -> dict[str, Any]:
"""保存登录凭据并拉取账号资料(扫码/短信登录共用;资料校验失败不阻断登录)。"""
try:
profile = await verify_token(token, userid)
set_credentials("kg", {"token": token, "userid": profile["userid"], "nickname": profile["nickname"]})
return profile
except Exception: # noqa: BLE001 - 资料校验失败不影响登录本身
uid = _int_or_zero(userid)
set_credentials("kg", {"token": token, "userid": uid})
return {"userid": uid, "nickname": None, "vip": None}
# ---- 扫码登录 ----
async def qr_key() -> dict[str, Any]:
"""创建扫码登录 key 与二维码(qrimg 为可直接展示的 data URL)。"""
body = await _login_user_get(
"/v2/qrcode",
{
"appid": 1001,
"type": 1,
"plat": 4,
"qrcode_txt": f"{QR_URL_PREFIX}?appid={APPID}&",
"srcappid": SRC_APPID,
},
)
data = as_dict(body.get("data"))
key = data.get("qrcode")
if not key:
raise RuntimeError("获取二维码 key 失败")
qrurl = f"{QR_URL_PREFIX}?qrcode={key}"
qrimg = str(data.get("qrcode_img") or "")
if not qrimg:
from app.sdk.common.qrcode import to_data_url
try:
qrimg = to_data_url(qrurl)
except Exception: # noqa: BLE001 - 二维码渲染失败不影响 key/url 返回
qrimg = ""
return {"key": key, "qrurl": qrurl, "qrimg": qrimg}
async def qr_check(key: str) -> dict[str, Any]:
"""轮询扫码状态(酷狗原生 0/1/2/4 → 800/801/802/803)。"""
body = await _login_user_get(
"/v2/get_userinfo_qrcode",
{"plat": 4, "appid": APPID, "srcappid": SRC_APPID, "qrcode": key},
)
data = as_dict(body.get("data"))
qr_status = data.get("status")
if qr_status == 4:
token = data.get("token")
if not token:
raise RuntimeError("登录响应中缺少 token")
return {"code": 803, "message": "登录成功", "profile": await _apply_kg_login(token, data.get("userid"))}
mapping: dict[Any, tuple[int, str]] = {0: (800, "二维码已过期"), 1: (801, "等待扫码"), 2: (802, "已扫码,待确认")}
code, message = mapping.get(qr_status, (qr_status, f"未知状态 {qr_status}"))
return {"code": code, "message": message}
# ---- 手机验证码登录 ----
def _sign_login_key(clienttime_ms: int) -> str:
return to_md5(f"{APPID}{ANDROID_SALT}{LOGIN_CLIENTVER}{clienttime_ms}")
async def send_captcha(mobile: str) -> dict[str, Any]:
"""发送短信验证码(businessid 5 = 登录场景)。"""
body = await gateway_request(
"/v7/send_mobile_code",
router="login.user.kugou.com",
params={"clientver": LOGIN_CLIENTVER},
data={"businessid": 5, "mobile": str(mobile), "plat": 3},
)
if body.get("status") != 1:
raise ApiError(502, str(body.get("error") or body.get("msg") or "验证码发送失败"))
return {"sent": True}
async def cellphone_login(mobile: str, code: str) -> dict[str, Any]:
"""手机验证码登录(mobile + code,成功后自动保存凭据并返回账号资料)。"""
clienttime_ms = int(time.time() * 1000)
enc = _aes_encrypt_random({"mobile": str(mobile), "code": str(code)})
body = await gateway_request(
"/v7/login_by_verifycode",
host="https://loginserviceretry.kugou.com",
params={"clientver": LOGIN_CLIENTVER},
data={
"plat": 1,
"support_multi": 1,
"t1": 0,
"t2": 0,
"clienttime_ms": clienttime_ms,
"mobile": f"{str(mobile)[:2]}*****{str(mobile)[10:11]}",
"key": _sign_login_key(clienttime_ms),
"pk": _rsa_no_padding_hex({"clienttime_ms": clienttime_ms, "key": enc["key"]}, pad_left=False).upper(),
"params": enc["str"],
"t3": "MCwwLDAsMCwwLDAsMCwwLDA=",
},
headers={"User-Agent": "Android16-1070-11440-130-0-LOGIN-wifi", "support-calm": "1"},
)
if body.get("status") != 1:
raise ApiError(401, str(body.get("error") or f"登录失败(code {body.get('error_code', '未知')})"))
data = as_dict(body.get("data"))
decrypted = _aes_decrypt_hex(str(data["secu_params"]), enc["key"]) if data.get("secu_params") else None
token = decrypted.get("token") if isinstance(decrypted, dict) else decrypted
if not token:
raise RuntimeError("登录响应解密失败")
userid = decrypted.get("userid") if isinstance(decrypted, dict) else None
return await _apply_kg_login(str(token), userid or data.get("userid") or 0)
# ---- token 自动续期 ----
async def refresh_login() -> dict[str, Any]:
"""刷新 kg token(login_by_token),成功后写回凭据存储。"""
cred = get_credentials("kg") or {}
if not cred.get("token") or str(cred.get("userid") or "") in ("", "0"):
return {"skipped": True}
clienttime = int(time.time())
p3 = _aes_cbc_encrypt_hex(
_js_json({"clienttime": clienttime, "token": cred["token"]}), to_md5(AES_KEY_SEED), AES_IV
)
enc = _aes_encrypt_random({})
pk = _rsa_no_padding_hex({"clienttime_ms": clienttime * 1000, "key": enc["key"]}, pad_left=False)
body = await gateway_request(
"/v5/login_by_token",
router="login.user.kugou.com",
params={"clientver": 20349},
data={
"p3": p3,
"params": enc["str"],
"userid": _int_or_zero(cred.get("userid")),
"need_toneinfo": 1,
"clienttime_ms": clienttime * 1000,
"dfid": "-",
"dev": "SDY-AN00",
"plat": 1,
"pk": pk,
"t1": "0",
"gitversion": "a23c277",
"t2": "0",
"t3": "MCwwLDEsMSwwLDYsMSw2LDA=",
},
headers={
"kg-thash": "6a6a1ba",
"User-Agent": "Android12-AndroidPhone-20349-201-0-ting#958959317/661004247-LOGIN-wifi",
"support-calm": "1",
},
)
data = as_dict(body.get("data"))
if body.get("error_code") != 0 or not data.get("secu_params"):
error = body.get("error")
raise RuntimeError(f"刷新失败(code {body.get('error_code')}{f': {error}' if error else ''})")
decrypted = _aes_decrypt_hex(str(data["secu_params"]), enc["key"])
if not isinstance(decrypted, dict) or not decrypted.get("token"):
raise RuntimeError("刷新响应解密失败")
set_credentials("kg", {"token": decrypted["token"]})
return {"refreshed": True}
# ---- 设备 dfid 正式注册 ----
async def register_dfid() -> dict[str, Any]:
"""注册正式 dfid(幂等:已注册直接返回;失败抛错由调用方决定是否容忍)。
随机 dfid 匿名取流易撞 20028 验证;注册后的 dfid 是官方客户端形态,可显著降低风控。
协议:设备画像 → AES-128-CBC(随机 6 位 key) → base64 作 body;
p = RSA-PKCS1({aes:key, uid, token});响应为二进制密文,按 base64 解码后同密钥解密。
"""
dev = get_device()
if dev.get("dfidRegistered"):
return dev
cred = get_credentials("kg") or {}
userid = _int_or_zero(cred.get("userid"))
device_map = {
"availableRamSize": 4983533568,
"availableRomSize": 48114719,
"availableSDSize": 48114717,
"basebandVer": "",
"batteryLevel": 100,
"batteryStatus": 3,
"brand": "Redmi",
"buildSerial": "unknown",
"device": "marble",
"imei": dev["guid"],
"imsi": "",
"manufacturer": "Xiaomi",
"uuid": dev["guid"],
"accelerometer": False,
"accelerometerValue": "",
"gravity": False,
"gravityValue": "",
"gyroscope": False,
"gyroscopeValue": "",
"light": False,
"lightValue": "",
"magnetic": False,
"magneticValue": "",
"orientation": False,
"orientationValue": "",
"pressure": False,
"pressureValue": "",
"step_counter": False,
"step_counterValue": "",
"temperature": False,
"temperatureValue": "",
}
enc = _playlist_aes_encrypt(device_map)
p = _rsa_pkcs1_hex({"aes": enc["key"], "uid": userid, "token": cred.get("token") or ""})
params: dict[str, Any] = {
"dfid": dev["dfid"],
"mid": dev["mid"],
"uuid": "-",
"appid": APPID,
"clientver": CLIENTVER,
"clienttime": int(time.time()),
"part": 1,
"platid": 1,
"p": p,
}
params["signature"] = android_sign(params, enc["str"])
query = "&".join(f"{key}={encode_uri_component(value)}" for key, value in params.items())
resp = await http_fetch(
f"https://userservice.kugou.com/risk/v2/r_register_dev?{query}",
method="POST",
headers={
"dfid": str(dev["dfid"]),
"mid": str(dev["mid"]),
"clienttime": str(params["clienttime"]),
"User-Agent": _ANDROID_UA,
},
body=enc["str"],
timeout=15_000,
)
raw_b64 = (
base64.b64encode(resp.raw).decode()
if resp.raw
else base64.b64encode(_js_json(resp.body or "").encode()).decode()
)
body = _playlist_aes_decrypt(raw_b64, enc["key"])
body = as_dict(body)
data = as_dict(body.get("data"))
if body.get("status") != 1 or not data.get("dfid"):
raise RuntimeError(f"dfid 注册失败(status {body.get('status', '未知')} {body.get('error', '')})".strip())
updated = {**dev, "dfid": str(data["dfid"]), "dfidRegistered": True}
set_credentials("_kg_device", updated)
return updated
# ---- 账号密码登录 + 20028 验证码挑战 ----
async def get_verify_info(eventid: str) -> dict[str, Any]:
"""查询验证事件详情(/verifyservice/v3/get_verify_info)。"""
cred = get_credentials("kg") or {}
body = await gateway_request(
"/verifyservice/v3/get_verify_info",
data={
"eventid": eventid,
"userid": _int_or_zero(cred.get("userid")),
"platid": 2,
"rtype": 1,
"wasm": 1,
"i": "",
"sid": "",
"edt": "",
},
)
if body.get("status") != 1 and body.get("error_code") != 0:
raise RuntimeError(str(body.get("error") or f"查询验证信息失败(code {body.get('error_code', '未知')})"))
data = body.get("data")
return data if isinstance(data, dict) else {}
async def verify_user_info(
eventid: str,
v_type: int,
verifycode: str,
sid: str | None = None,
edt: str | None = None,
) -> dict[str, Any]:
"""提交验证结果(/v4/verify_user_info,verifycode 为 KGCodeXX|{json} 或短信码)。"""
dev = get_device()
cred = get_credentials("kg") or {}
userid = _int_or_zero(cred.get("userid"))
fingerprint = {"sid": sid, "edt": edt} if sid and edt else generate_simulate(dev["mid"], userid, dev["dfid"])
data_map: dict[str, Any] = {
"eventid": eventid,
"userid": userid,
"platid": 2,
"v_type": _int_or_zero(v_type) or 23,
"wasm": 1,
"i": "",
"sid": fingerprint["sid"],
"edt": fingerprint["edt"],
}
enc = _aes_encrypt_random({"code": verifycode} if data_map["v_type"] == 32 else {})
if data_map["v_type"] == 32:
# 手机短信验证码:明文 code + 密文 params 均携带
data_map.update({"code": verifycode, "verifycode": verifycode})
data_map["pk"] = _rsa_no_padding_hex({"key": enc["key"]}, pad_left=False)
else:
# 滑块等图形验证码:KGCodeXX|{ticket,...} 明文上送
data_map.update({"verifycode": verifycode})
data_map["pk"] = _rsa_no_padding_hex({"key": enc["key"]}, pad_left=False)
data_map["params"] = enc["str"]
body = await gateway_request(
"/v4/verify_user_info",
host="https://verifyservice.kugou.com",
params={"clientver": 11510},
data=data_map,
)
if body.get("status") != 1:
raise ApiError(401, str(body.get("error") or f"验证提交失败(code {body.get('error_code', '未知')})"))
return {"verified": True}
async def password_login(username: str, password: str, code: str | None = None) -> dict[str, Any]:
"""账号密码登录(/v9/login_by_pwd);命中 20028 风控返回验证码挑战。"""
# 账密登录风控最敏感,先确保 dfid 已正式注册(失败容忍,随机 dfid 继续尝试)
try:
await register_dfid()
except Exception: # noqa: BLE001 - 注册失败不阻断登录
pass
clienttime_ms = int(time.time() * 1000)
enc = _aes_encrypt_random({"pwd": str(password), "code": str(code or ""), "clienttime_ms": clienttime_ms})
body, headers = await gateway_request(
"/v9/login_by_pwd",
router="login.user.kugou.com",
params={"clientver": PWD_CLIENTVER},
data={
"plat": 1,
"support_multi": 1,
"clienttime_ms": clienttime_ms,
"t1": PWD_T1,
"t2": PWD_T2,
"t3": PWD_T3,
"dev": "SDY-AN00",
"username": str(username),
"params": enc["str"],
"pk": _rsa_no_padding_hex({"clienttime_ms": clienttime_ms, "key": enc["key"]}, pad_left=False).upper(),
},
headers={"User-Agent": f"Android16-1070-{PWD_CLIENTVER}-130-0-LOGIN-wifi", "support-calm": "1"},
with_headers=True,
)
data = as_dict(body.get("data"))
if body.get("status") == 1 and data.get("secu_params"):
decrypted = _aes_decrypt_hex(str(data["secu_params"]), enc["key"])
token = decrypted.get("token") if isinstance(decrypted, dict) else decrypted
if not token:
raise RuntimeError("登录响应解密失败")
userid = decrypted.get("userid") if isinstance(decrypted, dict) else None
return await _apply_kg_login(str(token), userid or data.get("userid") or 0)
if body.get("error_code") == 20028:
eventid = str(headers.get("ssa-code") or body.get("ssaCode") or "")
if not eventid:
raise RuntimeError("触发风控验证但未返回事件 id(ssa-code),请改用扫码或短信登录")
dev = get_device()
cred = get_credentials("kg") or {}
fingerprint = generate_simulate(dev["mid"], _int_or_zero(cred.get("userid")), dev["dfid"])
try:
info = await get_verify_info(eventid)
except Exception: # noqa: BLE001 - 挑战详情失败时按默认类型返回
info = {}
v_type = _int_or_zero(info.get("v_type")) or 23
url = str(info.get("url") or "")
provider = next((key for key in VERIFY_URL_TYPES if key in url), "")
challenge: dict[str, Any] = {
"eventid": eventid,
"v_type": v_type,
"vTypeLabel": VERIFY_VTYPES.get(v_type, f"未知类型 {v_type}"),
"url": url or None,
"provider": VERIFY_URL_TYPES[provider] if provider else None,
"txappid": info.get("txappid"),
"sessionid": info.get("sessionid"),
"sid": fingerprint["sid"],
"edt": fingerprint["edt"],
}
return challenge
raise ApiError(401, str(body.get("error") or f"登录失败(code {body.get('error_code', '未知')})"))
async def status() -> dict[str, Any] | None:
"""当前 kg 登录资料(未登录返回 None)。"""
cred = get_credentials("kg") or {}
if not cred.get("token"):
return None
return {"userid": cred.get("userid"), "nickname": cred.get("nickname")}
+161
View File
@@ -0,0 +1,161 @@
"""酷狗行为指纹模拟(对应 sdk/auth/kgSimulate.ts)—— sid/edt 生成。
移植自 MakcRe/KuGouMusicApi util/generate_simulate.js,用途:20028 二次验证时替代浏览器 WASM 上报行为数据。
协议:明文 = mid=..;userid=..;dfid=..;webgl=..;webdriver=0;ts=..;data=<事件流>
* edt = AES-128-CBC(明文, key=md5(rand16)[0:16], iv='kugousecurity123') → Base64
* sid = RSA-OAEP-SHA256(key) → Base64(公钥取自酷狗 WASM 二进制)
事件流:贝塞尔曲线鼠标轨迹(type 3) + 滚动(type 5) + 窗口(type 6),条目间以 : 分隔,
每条事件后跟一条哨兵记录(时间差字段替换为接近 0xFFFFFFFF 的随机值)。
"""
from __future__ import annotations
import base64
import math
import random
import time
from typing import Any
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from app.sdk.common.utils import to_md5
from app.sdk.platforms.kg.util import pkcs7_pad, random_string
SIM_PUBLIC_KEY = """-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAoW2+Ylo8ALePSQTP0xBF
lFmEOHvBD9tS+s7DBlfKEu3RzzvZTaX1JtYbX4+AVUqj6ARz8IM+CKByqGFvbHN/
W64XxNI+q7z36ajCL3VTJ2W5G9MCJitc6oGbire4NQfhaEq0nC+hxBWQvCbIFflA
2ItrLUbSU7z1bHA/a+jlQm4OWvY+IKnTryOJTPuT1yNOVjbJ8wBLKy2DgQr9pPqW
PmEQtGpR5IM9V8Kao6PaSdKYOWGbX3i2+RzIKhvZUxxtJwdVbqPlDPlW9h4/xIBc
56Lgvr4aIl8nFtwbj4UJVUTFuGrs0tY9H/tXvZ22dUCKuGxW/gW7ZF+gXz6vHtYa
rQIDAQAB
-----END PUBLIC KEY-----"""
SIM_IV = b"kugousecurity123"
def _ri(low: int, high: int) -> int:
"""JS ``Math.floor(Math.random() * (max - min + 1)) + min``。"""
return math.floor(random.random() * (high - low + 1)) + low
def _round(value: float) -> int:
"""JS Math.round(.5 向上取整,不是 Python 的银行家舍入)。"""
return math.floor(value + 0.5)
_sentinel = 0xFFFFFFFF - _ri(0, 20)
def _f3(t: int | str, i: int, x: int, y: int) -> str:
return f"3,{t},{i},{x},{y}"
def _f5(t: int | str, i: int) -> str:
return f"5,{t},{i}"
def _f6(t: int | str, i: int, x: int, y: int) -> str:
return f"6,{t},{i},{x},{y}"
def _bezier_path(sx: float, sy: float, ex: float, ey: float, n: int) -> list[dict[str, float]]:
"""三阶贝塞尔鼠标轨迹:随机控制点 + 起步抖动大、后程收敛。"""
c1x = sx + (ex - sx) * 0.3 + _ri(-80, 80)
c1y = sy + (ey - sy) * 0.2 + _ri(-60, 60)
c2x = sx + (ex - sx) * 0.7 + _ri(-60, 60)
c2y = sy + (ey - sy) * 0.8 + _ri(-40, 40)
points: list[dict[str, float]] = []
for i in range(n + 1):
t = i / n
u = 1 - t
x = u * u * u * sx + 3 * u * u * t * c1x + 3 * u * t * t * c2x + t * t * t * ex
y = u * u * u * sy + 3 * u * u * t * c1y + 3 * u * t * t * c2y + t * t * t * ey
jitter = max(0.5, 3 - t * 2.5)
points.append(
{
"x": x + (random.random() - 0.5) * jitter,
"y": y + (random.random() - 0.5) * jitter,
}
)
return points
def _generate_edt_data(start_x: int, start_y: int, end_x: int, end_y: int, mouse_points: int) -> str:
entries: list[str] = []
ts = 0
ei = 0
entries.extend([_f5(0, 0), _f5(_sentinel, 0), _f5(0, 0), _f5(_sentinel, 0)])
ts += _ri(5, 20)
entries.extend([_f6(ts, ei, 750, 500), _f6(_sentinel, ei, 750, 500)])
ei += 1
for _ in range(3):
ts += _ri(80, 600)
entries.extend([_f5(ts, ei), _f5(_sentinel, ei)])
ei += 1
path = _bezier_path(start_x, start_y, end_x, end_y, mouse_points)
si = 0
for i, point in enumerate(path):
ts += _ri(8, 50)
rx = _round(point["x"])
ry = _round(point["y"])
entries.extend([_f3(ts, si, rx, ry), _f3(_sentinel, si, rx, ry)])
if i > 0 and i % 12 == 0:
ts += _ri(20, 60)
entries.extend([_f5(ts, ei), _f5(_sentinel, ei)])
ei += 1
si = (si + 1) % 2
ts += _ri(5, 30)
entries.extend(
[
_f3(ts, 1, _round(end_x + _ri(-5, 5)), _round(end_y + _ri(-5, 5))),
_f3(_sentinel, 1, _round(end_x), _round(end_y)),
]
)
return ":".join(entries)
_public_key = None
def _load_public_key() -> Any:
global _public_key
if _public_key is None:
_public_key = serialization.load_pem_public_key(SIM_PUBLIC_KEY.encode())
return _public_key
def generate_simulate(mid: str | int, userid: str | int, dfid: str, webgl_hash: str = "") -> dict[str, str]:
"""生成模拟行为指纹(sid/edt),验证接口 /v4/verify_user_info 原样上送。"""
global _sentinel
_sentinel = 0xFFFFFFFF - _ri(0, 20)
key = to_md5(random_string(16))[:16]
points = _ri(30, 60)
start_x = _ri(200, 600)
start_y = _ri(200, 500)
end_x = _ri(500, 700)
end_y = _ri(80, 150)
webgl = webgl_hash or to_md5(random_string(32))
edt_data = _generate_edt_data(start_x, start_y, end_x, end_y, points)
plaintext = (
f"mid={mid or 0};userid={userid or 0};dfid={dfid or 0};webgl={webgl};"
f"webdriver=0;ts={int(time.time() * 1000)};data={edt_data}"
)
cipher = Cipher(algorithms.AES(key.encode()), modes.CBC(SIM_IV))
encryptor = cipher.encryptor()
edt = base64.b64encode(encryptor.update(pkcs7_pad(plaintext.encode())) + encryptor.finalize()).decode()
sid = base64.b64encode(
_load_public_key().encrypt(
key.encode(),
padding.OAEP(mgf=padding.MGF1(algorithm=hashes.SHA256()), algorithm=hashes.SHA256(), label=None),
)
).decode()
return {"edt": edt, "sid": sid}
+59
View File
@@ -0,0 +1,59 @@
"""懒人听书登录(对应 sdk/lr/auth.ts,web 通道 m.lrts.me/ajax/logon)。
参考社区下载器(yoval/lrts 等):POST form {account, pwd: base64(password)},
成功响应 json.phone 非空,Set-Cookie 种下会话;取流接口必须携带该会话
(免费章节匿名也返回"收费章节未购买",2026-10-02 实测)。
"""
from __future__ import annotations
import base64
from typing import Any
from urllib.parse import quote
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
UA_MI = (
"Mozilla/5.0 (Linux; U; Android 4.1.1; zh-cn; MI2 Build/JRO03L) AppleWebKit/534.30 "
"(KHTML, like Gecko) Version/4.0 Mobile Safari/534.30 XiaoMi/MiuiBrowser/1.0"
)
LOGON_URL = "https://m.lrts.me/ajax/logon"
async def login(account: str, password: str) -> dict[str, Any]:
"""账号密码登录(成功保存会话 Cookie)。"""
form = f"account={quote(str(account), safe='')}&pwd={base64.b64encode(str(password).encode()).decode()}"
resp = await http_fetch(
LOGON_URL,
method="POST",
headers={
"User-Agent": UA_MI,
"Referer": "https://m.lrts.me/",
"Content-Type": "application/x-www-form-urlencoded",
},
body=form,
timeout=15_000,
)
body = resp.body if isinstance(resp.body, dict) else {}
if resp.status_code != 200 or not body.get("phone"):
raise ApiError(401, str(body.get("msg") or "登录失败(账号或密码错误)"))
cookie = "; ".join(part for part in (item.split(";")[0].strip() for item in resp.set_cookies) if part)
if not cookie:
raise RuntimeError("登录成功但未返回会话 Cookie")
set_credentials("lr", {"cookie": cookie, "phone": str(body["phone"])})
return {"logged": True, "phone": str(body["phone"])}
def get_lr_cookie() -> str:
"""登录会话 Cookie(未登录返回空串)。"""
return str((get_credentials("lr") or {}).get("cookie") or "")
def status() -> dict[str, Any]:
"""登录状态。"""
cred = get_credentials("lr") or {}
if cred.get("cookie"):
return {"logged": True, "phone": cred.get("phone"), "updatedAt": cred.get("updatedAt")}
return {"logged": False}
+382
View File
@@ -0,0 +1,382 @@
"""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 {})}
+166
View File
@@ -0,0 +1,166 @@
"""网易云登录(对应 sdk/auth/wy.ts)—— 扫码 / 手机号 / cookie 绑定,weapi 加密请求。
参考实现:NeteaseCloudMusicApi(login_qr_key / login_qr_check / login_cellphone / login_status)。
二维码接口只提供明文 api 形态(weapi 形态已 404);其余登录接口走 weapi。
"""
from __future__ import annotations
import hashlib
import re
from typing import Any
from app.http.api_error import ApiError
from app.sdk.auth_store import get_wy_cookie, set_credentials
from app.sdk.common.qrcode import to_data_url
from app.sdk.common.request import http_fetch
from app.sdk.common.utils import as_dict
from app.sdk.platforms.wy.utils.crypto import weapi
UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
async def _request_weapi(path: str, data: Any, cookie: str | None = None) -> tuple[Any, list[str]]:
ck = get_wy_cookie() if cookie is None else cookie
headers = {
"User-Agent": UA,
"origin": "https://music.163.com",
"Referer": "https://music.163.com/",
}
if ck:
headers["cookie"] = ck
resp = await http_fetch(
f"https://music.163.com/weapi{path}",
method="POST",
headers=headers,
form=weapi(data),
timeout=15_000,
)
if resp.status_code != 200:
raise RuntimeError("网易请求失败")
return resp.body, resp.set_cookies
async def _request_api(path: str, data: Any) -> tuple[Any, list[str]]:
"""明文 api 请求(二维码接口只提供该形态)。"""
resp = await http_fetch(
f"https://music.163.com{path}",
method="POST",
headers={"User-Agent": UA, "origin": "https://music.163.com", "Referer": "https://music.163.com/"},
form=data,
timeout=15_000,
)
if resp.status_code != 200:
raise RuntimeError("网易请求失败")
return resp.body, resp.set_cookies
def _extract_login_cookie(set_cookie: list[str]) -> tuple[str, str]:
"""从 Set-Cookie 数组提取登录 cookie 串与 MUSIC_U。"""
joined = "; ".join(part for part in (item.split(";")[0].strip() for item in set_cookie) if part)
match = re.search(r"MUSIC_U=([^;]+)", joined)
return joined, match.group(1) if match else ""
async def _fetch_profile() -> dict[str, Any]:
"""登录成功后拉取账号资料。"""
body, _ = await _request_weapi("/api/w/nuser/account/get", {})
body = as_dict(body)
account = as_dict(body.get("account"))
profile = as_dict(body.get("profile"))
return {
"userid": account.get("id"),
"nickname": profile.get("nickname"),
"avatar": profile.get("avatarUrl"),
"vipType": profile.get("vipType"),
}
async def _apply_login(set_cookie: list[str]) -> dict[str, Any]:
"""应用登录 cookie(成功后调用)。"""
cookie, music_u = _extract_login_cookie(set_cookie)
if not music_u:
raise RuntimeError("登录响应中缺少 MUSIC_U")
profile = await _fetch_profile()
set_credentials("wy", {"cookie": cookie, "musicU": music_u, **profile})
return profile
async def qr_key() -> dict[str, Any]:
"""创建扫码登录 key 与二维码内容。"""
body, _ = await _request_api("/api/login/qrcode/unikey", {"type": 3})
body = as_dict(body)
if body.get("code") != 200 or not body.get("unikey"):
raise RuntimeError("获取二维码 key 失败")
key = str(body["unikey"])
qrurl = f"https://music.163.com/login?codekey={key}"
qrimg = ""
try:
qrimg = to_data_url(qrurl)
except Exception: # noqa: BLE001 - 二维码渲染失败不影响 key/url 返回
qrimg = ""
return {"key": key, "qrurl": qrurl, "qrimg": qrimg}
async def qr_check(key: str) -> dict[str, Any]:
"""轮询扫码状态:800 过期 / 801 等待 / 802 已扫码 / 803 已授权(完成登录)。"""
body, set_cookie = await _request_api("/api/login/qrcode/client/login", {"key": key, "type": 3})
body = as_dict(body)
code = body.get("code")
if code == 803:
profile = await _apply_login(set_cookie)
return {"code": 803, "message": "登录成功", "profile": profile}
messages: dict[Any, str] = {800: "二维码已过期", 801: "等待扫码", 802: "已扫码,待确认"}
message = messages.get(code) or body.get("message") or "未知状态"
return {"code": code, "message": message}
async def cellphone_login(
phone: str, password: str | None = None, captcha: str | None = None, countrycode: str = "86"
) -> dict[str, Any]:
"""手机号登录(密码明文传入,本地 MD5 后提交;或直接传验证码 captcha)。"""
data: dict[str, Any] = {
"type": "1",
"https": "true",
"phone": phone,
"countrycode": countrycode,
"remember": "true",
}
if captcha:
data["captcha"] = captcha
else:
data["password"] = hashlib.md5(str(password or "").encode()).hexdigest()
body, set_cookie = await _request_weapi("/api/w/login/cellphone", data)
body = as_dict(body)
if body.get("code") != 200:
raise ApiError(401, str(body.get("message") or body.get("msg") or "登录失败"))
return await _apply_login(set_cookie)
async def login_by_cookie(cookie_input: str) -> dict[str, Any]:
"""手动绑定 cookie(须含 MUSIC_U);绑定前用 account/get 实测校验。"""
cookie = str(cookie_input or "").strip()
match = re.search(r"MUSIC_U=([^;\s]+)", cookie)
if not match:
raise ApiError(422, "cookie 需包含 MUSIC_U 字段")
body, _ = await _request_weapi("/api/w/nuser/account/get", {}, cookie)
body = as_dict(body)
account = as_dict(body.get("account"))
if not account.get("id"):
raise ApiError(401, "cookie 无效或已过期(MUSIC_U 校验未通过)")
profile_body = as_dict(body.get("profile"))
profile = {
"userid": account.get("id"),
"nickname": profile_body.get("nickname"),
"avatar": profile_body.get("avatarUrl"),
"vipType": profile_body.get("vipType"),
}
set_credentials("wy", {"cookie": cookie, "musicU": match.group(1), **profile})
return profile
async def status() -> dict[str, Any] | None:
"""当前账号资料(未登录返回 None)。"""
if not get_wy_cookie():
return None
return await _fetch_profile()