type: 收敛测试 schemas 与协议层类型

This commit is contained in:
yml2213
2026-08-30 20:35:08 +08:00
parent 92dc461e52
commit c891ac982e
26 changed files with 1846 additions and 882 deletions
+39 -2
View File
@@ -1,8 +1,37 @@
"""虎牙协议与业务客户端。"""
from typing import TYPE_CHECKING
from .http_client import HuyaHttpClient
from .wss_client import HuyaWssClient
if TYPE_CHECKING:
from .activity_structs import GetUserScoreReq, GetUserScoreResp
from .app_login import (
HuyaAppLoginError,
HuyaAppPasswordLogin,
HuyaAppQrAuthRequiredError,
login_huya_app_password,
)
from .login import (
HuyaCredentialError,
HuyaLoginError,
HuyaLoginResult,
HuyaPasswordLogin,
login_huya_password,
)
from .sms_login import (
HuyaSmsCodeResult,
HuyaSmsLogin,
login_huya_sms,
send_huya_sms_code,
)
from .verification import (
HuyaVerificationError,
HuyaVerificationSolver,
solve_huya_verification,
)
__all__ = [
"HuyaHttpClient",
"HuyaWssClient",
@@ -103,8 +132,16 @@ def __getattr__(name: str):
}
globals().update(values)
return values[name]
if name in {"HuyaVerificationError", "HuyaVerificationSolver", "solve_huya_verification"}:
from .verification import HuyaVerificationError, HuyaVerificationSolver, solve_huya_verification
if name in {
"HuyaVerificationError",
"HuyaVerificationSolver",
"solve_huya_verification",
}:
from .verification import (
HuyaVerificationError,
HuyaVerificationSolver,
solve_huya_verification,
)
values = {
"HuyaVerificationError": HuyaVerificationError,
+44 -19
View File
@@ -18,6 +18,7 @@
- 滑块 UA / session / traceId 用金样本常量 -> 与随机机型画像不自洽;
- qr_auth(扫码)/dx_auth(短信) 无自动闭环 -> 碰上直接失败(QR_AUTH_REQUIRED)。
"""
from __future__ import annotations
import base64
@@ -176,13 +177,13 @@ def parse_cred(resp: bytes) -> bytes | None:
e = resp.find(b"_wup_header")
if s < 0 or e < 0:
return None
d = resp[s:e - 6]
d = resp[s : e - 6]
m = re.search(rb"\x3d\x00([\x00-\x03])(.)", d)
if not m:
return None
ln = m.group(2)[0]
st = m.start() + 4
cred = d[st:st + ln]
cred = d[st : st + ln]
if len(cred) == 114 and cred[:1] == b"\x0a":
return cred
return None
@@ -209,7 +210,10 @@ def solve_safe_auth(risk_url: str, proxies=None, max_retry: int = 3) -> dict:
HuyaVerificationSolver,
)
q = {k: v[0] for k, v in parse_qs(urlparse(risk_url).query, keep_blank_values=True).items()}
q = {
k: v[0]
for k, v in parse_qs(urlparse(risk_url).query, keep_blank_values=True).items()
}
app_id = str(q.get("appId") or "5002")
last_err: Exception | None = None
for attempt in range(max_retry):
@@ -287,7 +291,11 @@ def login_cred_with_flow(
return cred, uid
risk_url = parse_risk_url(resp)
if risk_url:
kind = "pt_auth(滑块)" if "pt_auth" in risk_url else ("qr_auth(扫码)" if "qr_auth" in risk_url else "未知")
kind = (
"pt_auth(滑块)"
if "pt_auth" in risk_url
else ("qr_auth(扫码)" if "qr_auth" in risk_url else "未知")
)
logger.info(f"[huya-app] 第 {rnd + 1} 轮触发安全验证: {kind}")
if "qr_auth" in risk_url:
raise HuyaAppQrAuthRequiredError(
@@ -317,12 +325,14 @@ class QrRole:
self.context = f"{prefix}-{ctx_hex}-{tail}"
self.page_id = random.randint(40_000_000, 41_000_000)
self.req_counter = random.randint(40_000_000, 41_000_000)
self.s.headers.update({
"User-Agent": UA_PC if pc else APP_UA_MOBILE,
"Origin": UDB_BASE,
"content-type": "application/json;charset=UTF-8",
"Accept": "*/*",
})
self.s.headers.update(
{
"User-Agent": UA_PC if pc else APP_UA_MOBILE,
"Origin": UDB_BASE,
"content-type": "application/json;charset=UTF-8",
"Accept": "*/*",
}
)
def _headers(self, uri: str) -> dict:
mid = "2.6" if self.pc else "2.5"
@@ -334,7 +344,9 @@ class QrRole:
"Referer": f"{UDB_BASE}/web/middle/{mid}/{self.page_id}/https/{self.context.split('-')[1]}",
}
def call(self, path: str, uri: str, data: dict, cookies: dict | None = None) -> dict:
def call(
self, path: str, uri: str, data: dict, cookies: dict | None = None
) -> dict:
envelope = {
"uri": uri,
"version": "2.6" if self.pc else "2.5",
@@ -389,13 +401,16 @@ class HuyaAppPasswordLogin:
self.proxies = dict(proxies) if proxies else None
self.timeout = timeout or (10.0, 25.0)
self.force_new_device = force_new_device
self.device_info = device_info or get_profile(self.username, force_new=force_new_device)
self.device_info = device_info or get_profile(
self.username, force_new=force_new_device
)
def login(self) -> HuyaLoginResult:
"""执行完整 App 登录获取 Cookie 流程 (成功/失败均记录登录时间 → 设备绑定页)。"""
result = self._login_impl()
try:
from .device_profile import record_login
record_login(self.username, result.success, result.message)
except Exception: # 元数据记录失败不影响登录结果
pass
@@ -404,7 +419,9 @@ class HuyaAppPasswordLogin:
def _login_impl(self) -> HuyaLoginResult:
"""登录主体 (原 login)。"""
acct = self.username
logger.info(f"[huya-app] 开始登录账号 {acct} (机型: {self.device_info.get('model')})...")
logger.info(
f"[huya-app] 开始登录账号 {acct} (机型: {self.device_info.get('model')})..."
)
# 1) 获取新鲜 cred 与 真实 uid (自动过 safe_auth 滑块)
try:
@@ -442,7 +459,9 @@ class HuyaAppPasswordLogin:
# 3) 信封补丁
raw = bytearray(env.raw)
raw[env.cert_off:env.cert_off + env.cert_len] = cert.encode("ascii")
if env.cert_off is None or env.uid_off is None:
raise ValueError("信封缺少证书或 uid 偏移")
raw[env.cert_off : env.cert_off + env.cert_len] = cert.encode("ascii")
if env.uid != uid:
struct.pack_into(">Q", raw, env.uid_off, uid)
wup = base64.b64encode(bytes(raw)).decode("ascii")
@@ -457,9 +476,11 @@ class HuyaAppPasswordLogin:
try:
# 一号一设备: sdid 状态按账号隔离 (默认全局目录会让所有账号共享
# 同一份 hydevice 设备状态, 服务端可跨账号关联 — 见 R40 缺口修复)
sdid_obj = get_huya_sdid(allow_fallback=True,
state_dir=account_state_dir(self.username),
device_hint=self.device_info)
sdid_obj = get_huya_sdid(
allow_fallback=True,
state_dir=account_state_dir(self.username),
device_hint=self.device_info,
)
sdid = sdid_obj.sdid if sdid_obj else ""
pc = QrRole(pc=True, sdid=sdid, proxies=self.proxies)
ph = QrRole(pc=False, sdid=sdid, proxies=self.proxies)
@@ -502,7 +523,9 @@ class HuyaAppPasswordLogin:
},
)
if r2.get("returnCode") not in (0, "0", None) and r2.get("returnCode") != 0:
logger.warning(f"[huya-app] bind 返回码: {r2.get('returnCode')} msg: {r2.get('message')}")
logger.warning(
f"[huya-app] bind 返回码: {r2.get('returnCode')} msg: {r2.get('message')}"
)
# 4.3 轮询 tryQrLogin
biztoken = None
@@ -552,7 +575,9 @@ class HuyaAppPasswordLogin:
code="COOKIE_INCOMPLETE",
)
logger.info(f"[huya-app] 账号 {acct} 登录成功,获取完整 Cookie ({len(cookie_str)}B)")
logger.info(
f"[huya-app] 账号 {acct} 登录成功,获取完整 Cookie ({len(cookie_str)}B)"
)
return HuyaLoginResult(
success=True,
cookie=cookie_str,
+79 -45
View File
@@ -75,7 +75,9 @@ def generate_huya_password(prefix: str = "hy", random_length: int = 8) -> str:
"""生成适合虎牙账号使用的随机密码。"""
clean_prefix = "".join(ch for ch in str(prefix or "hy") if ch.isalnum())[:8] or "hy"
alphabet = string.ascii_lowercase + string.digits
suffix = "".join(random.SystemRandom().choice(alphabet) for _ in range(max(6, random_length)))
suffix = "".join(
random.SystemRandom().choice(alphabet) for _ in range(max(6, random_length))
)
return f"{clean_prefix}{suffix}"
@@ -86,13 +88,15 @@ def encode_change_password_behavior(stage: str) -> str:
actions: list[dict] = []
if stage in {"send", "submit"}:
actions.append({
"id": "pmodify.btn.getsms",
"x": random.randint(470, 520),
"y": random.randint(120, 145),
"d": elapsed,
"time": now,
})
actions.append(
{
"id": "pmodify.btn.getsms",
"x": random.randint(470, 520),
"y": random.randint(120, 145),
"d": elapsed,
"time": now,
}
)
if stage == "submit":
for action_id in ("pmodify.input.sms", "pmodify.input.pw", "pmodify.input.sms"):
now += random.randint(600, 3200)
@@ -100,20 +104,24 @@ def encode_change_password_behavior(stage: str) -> str:
actions.append({"id": action_id, "d": elapsed, "time": now})
now += random.randint(800, 2400)
elapsed += random.randint(800, 2400)
actions.append({
"id": "pmodify.btn.submit",
"x": random.randint(395, 445),
"y": random.randint(220, 245),
"d": elapsed,
"time": now,
})
actions.append(
{
"id": "pmodify.btn.submit",
"x": random.randint(395, 445),
"y": random.randint(220, 245),
"d": elapsed,
"time": now,
}
)
value = {
"furl": CHANGE_PASSWORD_FROM_URL,
"curl": CHANGE_PASSWORD_PAGE_URL,
"user_action": actions,
}
return quote(json.dumps(value, separators=(",", ":"), ensure_ascii=False), safe="~()*!.'")
return quote(
json.dumps(value, separators=(",", ":"), ensure_ascii=False), safe="~()*!.'"
)
def _sleep_or_stop(stop_event: threading.Event | None, seconds: float) -> bool:
@@ -132,7 +140,13 @@ def _payload_data(payload: dict) -> dict:
def _session_data(payload: dict, fallback: str = "") -> str:
data = _payload_data(payload)
return str(data.get("sessionData") or data.get("sessiondata") or payload.get("sessionData") or fallback or "")
return str(
data.get("sessionData")
or data.get("sessiondata")
or payload.get("sessionData")
or fallback
or ""
)
class HuyaPasswordChanger:
@@ -174,23 +188,25 @@ class HuyaPasswordChanger:
self._setup_headers()
def _setup_headers(self) -> None:
self.session.headers.update({
"Accept": "application/json, text/plain, */*",
"Accept-Language": "zh-CN,zh;q=0.9",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"Content-Type": "application/json;charset=UTF-8",
"Origin": "https://udbreg.huya.com",
"Pragma": "no-cache",
"Referer": self.middle_url,
"Sec-Fetch-Dest": "empty",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Site": "same-origin",
"User-Agent": self.ua,
"sec-ch-ua": '"Not;A=Brand";v="8", "Chromium";v="150", "Google Chrome";v="150"',
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": '"macOS"',
})
self.session.headers.update(
{
"Accept": "application/json, text/plain, */*",
"Accept-Language": "zh-CN,zh;q=0.9",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"Content-Type": "application/json;charset=UTF-8",
"Origin": "https://udbreg.huya.com",
"Pragma": "no-cache",
"Referer": self.middle_url,
"Sec-Fetch-Dest": "empty",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Site": "same-origin",
"User-Agent": self.ua,
"sec-ch-ua": '"Not;A=Brand";v="8", "Chromium";v="150", "Google Chrome";v="150"',
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": '"macOS"',
}
)
@staticmethod
def _safe_url(url: str) -> str:
@@ -199,7 +215,9 @@ class HuyaPasswordChanger:
def _request_json(self, method: str, url: str, source: str, **kwargs) -> dict:
response = self.session.request(method, url, timeout=self.timeout, **kwargs)
logger.debug(f"{method.upper()} {self._safe_url(url)} -> {response.status_code}")
logger.debug(
f"{method.upper()} {self._safe_url(url)} -> {response.status_code}"
)
response.raise_for_status()
try:
return response.json()
@@ -216,20 +234,26 @@ class HuyaPasswordChanger:
logger.debug(f"GET {self._safe_url(url)} -> {response.status_code}")
except requests.RequestException as exc:
logger.debug(f"虎牙改密 middle 初始化失败: {host}: {exc}")
self.session.headers.update({
"Origin": "https://udbreg.huya.com",
"Referer": self.middle_url,
})
self.session.headers.update(
{
"Origin": "https://udbreg.huya.com",
"Referer": self.middle_url,
}
)
def prepare_device(self) -> str:
"""获取虎牙风控 sdid。"""
token_payload = {"encryptVersion": "1.0.1", "fingerprintVersion": "1.2.41"}
token_res = self._request_json("post", DF_TOKEN_URL, "获取虎牙 df token", json=token_payload)
token_res = self._request_json(
"post", DF_TOKEN_URL, "获取虎牙 df token", json=token_payload
)
token = token_res.get("data", {}).get("token")
if not token:
raise HuyaLoginError(f"获取虎牙 df token 失败: {token_res}")
collect_res = self._request_json("post", DF_COLLECT_URL, "获取虎牙 sdid", json={"token": token})
collect_res = self._request_json(
"post", DF_COLLECT_URL, "获取虎牙 sdid", json={"token": token}
)
self.sdid = collect_res.get("data", {}).get("sdid", "")
if not self.sdid:
raise HuyaLoginError(f"获取虎牙 sdid 失败: {collect_res}")
@@ -240,7 +264,11 @@ class HuyaPasswordChanger:
from .verification import HuyaVerificationSolver
solver = HuyaVerificationSolver(
cookie=self.session.cookies.get_dict(),
cookie={
key: value
for key, value in self.session.cookies.get_dict().items()
if value is not None
},
ua=self.ua,
sdid=self.sdid,
session=self.session,
@@ -402,12 +430,16 @@ class HuyaPasswordChanger:
request_id=self.request_id,
)
def submit_code(self, password: str, sms_code: str, session_data: str) -> HuyaChangePasswordResult:
def submit_code(
self, password: str, sms_code: str, session_data: str
) -> HuyaChangePasswordResult:
"""提交改密短信验证码。"""
if not session_data:
raise HuyaLoginError("缺少改密 sessionData,请先发送改密短信")
payload = self._submit_once(password=password, sms_code=sms_code, session_data=session_data)
payload = self._submit_once(
password=password, sms_code=sms_code, session_data=session_data
)
for index in range(3):
return_code = int(payload.get("returnCode") or 0)
if return_code == 0:
@@ -436,7 +468,9 @@ class HuyaPasswordChanger:
logger.info(f"虎牙短信改密触发风控: {return_code} ({index + 1}/3)")
self._solve_verification(payload)
time.sleep(0.5)
payload = self._submit_once(password=password, sms_code=sms_code, session_data=session_data)
payload = self._submit_once(
password=password, sms_code=sms_code, session_data=session_data
)
return HuyaChangePasswordResult(
success=False,
+36 -21
View File
@@ -1,14 +1,23 @@
"""
TAF/WUP 帧解码器 — 将二进制帧转为可读摘要,用于日志输出
"""
from typing import Any, cast
from .taf_protocol import TafInputStream, TafType
from .wup_protocol import normalize_wup_payload
# cmd 编号 → 名称
CMD_NAMES = {
0x03: "RPC_REQ", 0x04: "RPC_RSP", 0x0a: "AUTH",
0x0b: "PUSH1", 0x10: "HB_SEND", 0x11: "HB_RECV",
0x17: "CONFIRM", 0x18: "PUSH2", 0x21: "REGISTER", 0x22: "CONFIRM_RSP",
0x03: "RPC_REQ",
0x04: "RPC_RSP",
0x0A: "AUTH",
0x0B: "PUSH1",
0x10: "HB_SEND",
0x11: "HB_RECV",
0x17: "CONFIRM",
0x18: "PUSH2",
0x21: "REGISTER",
0x22: "CONFIRM_RSP",
}
# TAIL_BYTES = 2c36004c5c6600
@@ -18,10 +27,14 @@ _TAIL = bytes.fromhex("2c36004c5c6600")
def _strip_tail(body: bytes) -> bytes:
"""裁掉 body 末尾的 TAIL_BYTES"""
if body.endswith(_TAIL):
return body[:-len(_TAIL)]
return body[: -len(_TAIL)]
# 有时 TAIL 前还有 0c (ZERO tag)
if len(body) > 1 and body[-len(_TAIL)-1:-len(_TAIL)] == b'\x0c' and body.endswith(_TAIL):
return body[:-len(_TAIL)-1]
if (
len(body) > 1
and body[-len(_TAIL) - 1 : -len(_TAIL)] == b"\x0c"
and body.endswith(_TAIL)
):
return body[: -len(_TAIL) - 1]
return body
@@ -33,16 +46,18 @@ def _decode_taf_value(ins: TafInputStream, dtype: int, depth: int = 0) -> object
return ins._read_int_value(dtype)
if dtype in (TafType.FLOAT, TafType.DOUBLE):
import struct as _s
if dtype == TafType.FLOAT:
return round(_s.unpack('>f', ins.buf.read(4))[0], 4)
return round(_s.unpack('>d', ins.buf.read(8))[0], 6)
return round(_s.unpack(">f", ins.buf.read(4))[0], 4)
return round(_s.unpack(">d", ins.buf.read(8))[0], 6)
if dtype == TafType.STRING1:
ln = ins.buf.read(1)[0]
return ins.buf.read(ln).decode('utf-8', errors='replace')
return ins.buf.read(ln).decode("utf-8", errors="replace")
if dtype == TafType.STRING4:
import struct as _s
ln = _s.unpack('>I', ins.buf.read(4))[0]
return ins.buf.read(ln).decode('utf-8', errors='replace')
ln = _s.unpack(">I", ins.buf.read(4))[0]
return ins.buf.read(ln).decode("utf-8", errors="replace")
if dtype == TafType.MAP:
cnt = ins._read_int_len()
m = {}
@@ -106,14 +121,14 @@ def _extract_wup(body: bytes) -> bytes:
return body
# 大包格式: [1B prefix][4B wup_len][wup_body][tail]
# prefix 可能是 0x00,必须优先于 4B total_len 判断。
wup_len = int.from_bytes(body[1:5], 'big')
if 5 + wup_len <= len(body) and body[5:7] == b'\x10\x03':
return body[5:5 + wup_len]
total_len = int.from_bytes(body[0:4], 'big')
if 8 <= total_len <= len(body) and body[4:6] == b'\x10\x03':
wup_len = int.from_bytes(body[1:5], "big")
if 5 + wup_len <= len(body) and body[5:7] == b"\x10\x03":
return body[5 : 5 + wup_len]
total_len = int.from_bytes(body[0:4], "big")
if 8 <= total_len <= len(body) and body[4:6] == b"\x10\x03":
return body[:total_len]
if 5 + wup_len <= len(body):
return body[5:5 + wup_len]
return body[5 : 5 + wup_len]
return body
@@ -127,7 +142,7 @@ def _decode_wup_body(body: bytes) -> dict:
ins = TafInputStream(wup)
# 读 WUP 字段 tag1~tag10,读到 tag10 后停止(忽略尾部垃圾)
sBuffer = b''
sBuffer = b""
while True:
try:
tag, dtype = ins.peek_head()
@@ -267,8 +282,8 @@ def format_wss_log(body: bytes, cmd: int, seq: int, direction: str) -> str:
return f"{prefix} {label}"
# AUTH
if cmd == 0x0a:
text = _strip_tail(body).decode('utf-8', errors='replace')
if cmd == 0x0A:
text = _strip_tail(body).decode("utf-8", errors="replace")
return f"{prefix} AUTH {text[:100]}{'...' if len(text) > 100 else ''}"
# REGISTER / CONFIRM / PUSH 等
@@ -302,7 +317,7 @@ def format_wss_log(body: bytes, cmd: int, seq: int, direction: str) -> str:
fields = {}
cmd_name = CMD_NAMES.get(cmd, f"0x{cmd:02x}")
if fields:
return f"{prefix} {cmd_name} {_fmt_fields(_truncate(fields))}"
return f"{prefix} {cmd_name} {_fmt_fields(cast(dict[str, Any], _truncate(fields)))}"
return f"{prefix} {cmd_name}"
except Exception:
cmd_name = CMD_NAMES.get(cmd, f"0x{cmd:02x}")