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