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,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
|
||||
@@ -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}
|
||||
@@ -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")}
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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 {})}
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user