Compare commits

..
4 Commits
23 changed files with 1355 additions and 184 deletions
+293
View File
@@ -0,0 +1,293 @@
diff --git a/core/huya/app_login.py b/core/huya/app_login.py
index afa33a4..86e18a9 100644
--- a/core/huya/app_login.py
+++ b/core/huya/app_login.py
@@ -9,7 +9,8 @@
6. POST /web/cookie/verify 兑换获取全套网页 Cookie
注册链不再重放旧 dfpReport 密文。登录帧的 32hex hdid 仍是服务端硬锚,
-当前继续使用已注册样本;新注册链动态更新的是 safedeviceid 和 device_id。
+登录帧中的 HDID32 是 app 版本级协议常量;二维码信封的 ACTION、设备字段、
+UID、证书和会话元数据均在本次登录中动态生成或由注册链签发。
注册链(``core/huya/dfp_register``)每次登录前执行,失败即抛错终止(``HuyaAppLoginError``),
不读取画像里的旧固定值,也不静默回退旧链。
@@ -35,7 +36,7 @@ from urllib.parse import parse_qs, quote, urlparse
import requests
from loguru import logger
-from .cert_forge import build_p1, decrypt_cert, forge_cert, parse_p1
+from .cert_forge import build_p1, forge_cert
from .cookie_utils import normalize_huya_cookie
from .device_fingerprint import account_state_dir, get_huya_sdid, reset_account_state
from .device_profile import get_profile, mobile_user_agent
@@ -270,7 +271,8 @@ def login_cred_with_flow(
max_rounds: int = 3,
device_info: dict | None = None,
proxies: dict | None = None,
-) -> tuple[bytes, int]:
+ include_device_token: bool = False,
+) -> tuple[bytes, int] | tuple[bytes, int, str]:
"""新注册设备后登录,返回 ``(新鲜 cred, 真实 uid)``。
注册只执行一次;safe_auth 通过后的重发继续使用同一组设备字段。
@@ -305,6 +307,8 @@ def login_cred_with_flow(
)
if cred:
uid = parse_real_uid(resp)
+ if include_device_token:
+ return cred, uid, safedeviceid
return cred, uid
if risk_url:
kind = (
@@ -462,11 +466,12 @@ class HuyaAppPasswordLogin:
# 1) 获取新鲜 cred 与 真实 uid (自动过 safe_auth 滑块)
try:
- cred, uid = login_cred_with_flow(
+ cred, uid, safedeviceid = login_cred_with_flow(
acct,
self.password,
device_info=self.device_info,
proxies=self.proxies,
+ include_device_token=True,
)
except HuyaAppQrAuthRequiredError as exc:
return HuyaLoginResult(
@@ -485,30 +490,27 @@ class HuyaAppPasswordLogin:
# 2) 本地生成 nonce 铸造证书 (P1 指纹与该账号设备画像一致)
try:
- env = Envelope.load()
- orig = base64.b64decode(env.cert_b64)
- f = parse_p1(decrypt_cert(orig))
st = int(time.time() * 1000)
rnd = gen_nonce(uid, K1_DEFAULT, service_time_ms=st, counter=0)
fp_bytes = self.device_info["fingerprint"].encode("ascii")
- p1 = build_p1(f["app_id"], fp_bytes, cred, rnd=rnd)
- cert = base64.b64encode(forge_cert(p1, key_idx=orig[1])).decode()
-
- # 3) 信封补丁
- raw = bytearray(env.raw)
- 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)
- # QR 信封只保留协议结构;不要重放抓包里的旧会话值。
+ p1 = build_p1(b"5008", fp_bytes, cred, rnd=rnd)
+ # 证书头和 app_id 是协议版本常量,不从抓包信封读取。
+ cert = base64.b64encode(forge_cert(p1, key_idx=0x20)).decode()
+
+ # 3) 按当前账号画像和本次注册令牌重编码二维码信封。
qr_session = random.randint(1_000_000, 9_999_999)
qr_trace = (
f"{uuid.uuid4().hex[:16]}-{random.randint(10000, 99999)}-"
f"{time.time_ns():020d}"
)
- env.raw = raw
- env.patch_session(qr_session).patch_meta(qr_session, qr_trace)
+ env = Envelope.build_qr(
+ uid=uid,
+ cert_b64=cert,
+ safedeviceid=safedeviceid,
+ session=qr_session,
+ trace_id=qr_trace,
+ device_info=self.device_info,
+ )
wup = base64.b64encode(bytes(env.raw)).decode("ascii")
except Exception as exc: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
return HuyaLoginResult(
diff --git a/core/huya/envelope_forge.py b/core/huya/envelope_forge.py
index c5d66ab..0d2e8b1 100644
--- a/core/huya/envelope_forge.py
+++ b/core/huya/envelope_forge.py
@@ -1,6 +1,7 @@
"""wupData 信封构造与补丁工具。
-解析与改写 WUP 信封中的 cert、uid、session 等字段。
+生产二维码信封由 :meth:`Envelope.build_qr` 按当前账号状态编码;
+:meth:`Envelope.load` 仅保留协议结构模板兼容测试和离线分析。
"""
from __future__ import annotations
@@ -10,7 +11,8 @@ import json
import struct
from pathlib import Path
-from loguru import logger
+from .taf_protocol import TafOutputStream
+from .wup_protocol import WupRequest
INT8, INT16, INT32, INT64 = 0x00, 0x01, 0x02, 0x03
STRING1, STRING4 = 0x06, 0x07
@@ -119,24 +121,106 @@ class Envelope:
@classmethod
def load(cls, path: str | Path | None = None) -> Envelope:
- """加载信封模板,支持从文件加载或使用内嵌金样本。"""
+ """加载显式模板,或使用内嵌协议结构模板。
+
+ 不自动扫描 ``evidence/``,避免历史抓包成为隐式生产输入。
+ """
if path:
p = Path(path)
if p.exists():
return cls._load_from_path(p)
- # 尝试查找 evidence/cert_keycap.json
- candidate = (
- Path(__file__).resolve().parent.parent.parent
- / "evidence"
- / "cert_keycap.json"
- )
- if candidate.exists():
- try:
- return cls._load_from_path(candidate)
- except Exception as exc: # noqa: BLE001
- logger.debug(f"加载证书信封候选文件失败: {candidate}: {exc}")
return cls(base64.b64decode(PROTOCOL_QURL_TEMPLATE_B64))
+ @classmethod
+ def build_qr(
+ cls,
+ *,
+ uid: int,
+ cert_b64: str,
+ safedeviceid: str,
+ session: int,
+ trace_id: str,
+ device_info: dict[str, str],
+ ) -> Envelope:
+ """按当前账号状态编码二维码绑定信封。
+
+ 生产二维码请求不应从抓包信封复制字段。这里仅复用已知的 TAF/WUP
+ 字段布局;证书、ACTION、UID、会话和全部设备画像都由本次登录提供。
+ """
+ if len(cert_b64) != 260:
+ raise ValueError(f"证书 base64 长度异常: {len(cert_b64)} != 260")
+ if len(safedeviceid) != 180:
+ raise ValueError(f"safedeviceid 长度异常: {len(safedeviceid)} != 180")
+ dev = {k: str(v) for k, v in (device_info or {}).items()}
+ meta = json.dumps(
+ {
+ "associationId": 184549392,
+ "funcName": "",
+ "group": 0,
+ "id": 184549392,
+ "session": int(session),
+ "step": 0,
+ "stillLogin": False,
+ "traceId": str(trace_id),
+ "type": 2,
+ "uid": 0,
+ "userContext": "",
+ },
+ ensure_ascii=False,
+ separators=(",", ":"),
+ )
+ body = TafOutputStream()
+ body.write_struct_begin(0)
+ body.write_struct_begin(0)
+ body.write_int8(0, 0)
+ body.write_string(1, "1.0")
+ body.write_string(2, meta)
+ body.write_string(3, "5008")
+ body.write_int8(4, 3)
+ body.write_string(5, safedeviceid)
+ body.write_string(6, "")
+ body.write_string(7, "")
+ body.write_string(8, "")
+ body.write_string(9, "")
+ body.write_struct_end()
+
+ body.write_struct_begin(1)
+ body.write_string(0, dev.get("hdid", ""))
+ body.write_string(1, dev.get("app_version", "13.4.22"))
+ body.write_string(2, dev.get("sdk_version", "1.0.80138"))
+ body.write_string(3, "")
+ body.write_string(4, dev.get("ip", "127.0.0.1"))
+ body.write_string(5, dev.get("vendor", "android"))
+ body.write_string(6, "")
+ body.write_struct_end()
+
+ body.write_struct_begin(2)
+ body.write_int8(0, 1)
+ body.write_string(1, dev.get("model", ""))
+ body.write_string(2, dev.get("fingerprint", ""))
+ body.write_string(3, dev.get("os", "android"))
+ body.write_string(4, dev.get("screen", ""))
+ body.write_string(6, dev.get("width", "1080"))
+ body.write_string(7, dev.get("height", "2120"))
+ body.write_string(8, dev.get("device_id", ""))
+ body.write_struct_end()
+ body.write_uint64(3, int(uid))
+ body.write_string(4, cert_b64)
+ body.write_string(5, "")
+ body.write_string(6, "")
+ body.write_struct_end()
+
+ req = TafOutputStream()
+ req.write_int32(0, int(session))
+ wup = WupRequest()
+ wup.iTimeout = 0
+ wup.setRequestId(int(session))
+ wup.setServant("huyaudbwebui")
+ wup.setFunc("default")
+ wup.newdata["_wup_data"] = body.get_bytes()
+ wup.newdata["wupudbrequest_v0"] = req.get_bytes()
+ return cls(wup.encode())
+
@classmethod
def _load_from_path(cls, p: Path) -> Envelope:
if p.suffix == ".json":
diff --git a/tests/test_huya_app_login.py b/tests/test_huya_app_login.py
index 3512a14..0c897bb 100644
--- a/tests/test_huya_app_login.py
+++ b/tests/test_huya_app_login.py
@@ -26,6 +26,7 @@ from core.huya.login import HuyaLoginResult
from core.huya.nonce_forge import K1_DEFAULT, gen_nonce
from core.huya.udb_aes import udb_decrypt, udb_encrypt
from core.huya.wup_encoder import _make_name, build_password_login_wup
+from core.huya.wup_protocol import WupResponse
from web.backend.database import Base
from web.backend.models import HuyaAccount, User
from web.backend.routers.huya import (
@@ -102,6 +103,38 @@ class TestHuyaAppLogin:
assert b'"session":7654321' in current
assert (b'"traceId":"' + new_trace.encode() + b'"') in current
+ def test_qr_envelope_is_fully_dynamic(self):
+ profile = {
+ "hdid": "h" * 32,
+ "app_version": "13.4.22",
+ "sdk_version": "1.0.80138",
+ "ip": "10.0.0.8",
+ "vendor": "vivo",
+ "model": "V2370A",
+ "fingerprint": "f" * 40,
+ "os": "android",
+ "screen": "V2370A,34,13",
+ "width": "1080",
+ "height": "2412",
+ "device_id": "d" * 40,
+ }
+ env = Envelope.build_qr(
+ uid=1199664135026,
+ cert_b64="A" * 260,
+ safedeviceid="B" * 180,
+ session=7654321,
+ trace_id="t" * 16 + "-12345-" + "9" * 20,
+ device_info=profile,
+ )
+ wup = WupResponse()
+ wup.decode(bytes(env.raw))
+ body = wup.newdata["_wup_data"]
+ for value in (b"B" * 180, b"h" * 32, b"f" * 40, b"V2370A", b"d" * 40):
+ assert value in body
+ assert b"PQwemAN9NHkZKoMq" not in body
+ assert b"02df398797432eadefcc12767119ad5e80999389" not in body
+ assert b"7c5387e0539c023c31c4ff0e807e7256117385ee" not in body
+
def test_device_profile_generation(self):
p1 = generate_profile()
assert p1["os"] == "android"
@@ -271,10 +304,13 @@ class TestHuyaAppLogin:
patch("core.huya.app_login.solve_safe_auth", return_value={"authId": "id"}),
patch("core.huya.app_login.parse_real_uid", return_value=1199666914671),
):
- cred, uid = login_cred_with_flow("300023887", "pw")
+ cred, uid, safedeviceid = login_cred_with_flow(
+ "300023887", "pw", include_device_token=True
+ )
assert cred == b"c" * 114
assert uid == 1199666914671
+ assert safedeviceid == "A" * 180
assert calls == [assets, assets]
def test_login_cred_flow_maps_invalid_password_response(self):
+385
View File
@@ -0,0 +1,385 @@
"""wupData 信封构造与补丁工具。
生产二维码信封由 :meth:`Envelope.build_qr` 按当前账号状态编码;
:meth:`Envelope.load` 仅保留协议结构模板兼容测试和离线分析。
"""
from __future__ import annotations
import base64
import json
import struct
from pathlib import Path
from .taf_protocol import TafOutputStream
from .wup_protocol import WupRequest
INT8, INT16, INT32, INT64 = 0x00, 0x01, 0x02, 0x03
STRING1, STRING4 = 0x06, 0x07
MAP, LIST = 0x08, 0x09
STRUCT_BEGIN, STRUCT_END = 0x0A, 0x0B
ZERO, SIMPLE_LIST = 0x0C, 0x0D
PROTOCOL_QURL_TEMPLATE_B64 = (
"AAAD5hADLDxCAFpBBVYMaHV5YXVkYndlYnVpZgdkZWZhdWx0fQABA7gIAAIGCV93dXBfZGF0YR0AAQOKCgoMFgMxLjAm"
"ynsiYXNzb2NpYXRpb25JZCI6MTg0NTQ5MzkyLCJmdW5jTmFtZSI6IiIsImdyb3VwIjowLCJpZCI6MTg0NTQ5MzkyLCJz"
"ZXNzaW9uIjo1OTE0ODg1LCJzdGVwIjowLCJzdGlsbExvZ2luIjpmYWxzZSwidHJhY2VJZCI6IjBiOGYwOThmZjY0YTVi"
"ZGMtMjY2OTItODI2MzkzOTQ3ODc2NjM1NTEzNjUiLCJ0eXBlIjoyLCJ1aWQiOjAsInVzZXJDb250ZXh0IjoiIn02BDUw"
"MDhAA1a0UFF3ZW1BTjlOSGtaS29NcVdNUW5WUkl5cHFNVGFRRU9ybVhyMzd4UVZoUVpxclA1aVVLRVExMXh2RTB2cE1n"
"a2xlajIzbEpGYmFHVW5LUEFhYnhWaUF0TnZyTkxBbXhBQzJyRGp6Qy9JSU0vSWFQeTdTcytvQXZqSmltR2pBS2RnVmpr"
"bUhWV2Q2bEw4cUZScU4zWkJMamt4c2xUQjczaXNvallWcjlrSFhWdUh3SkxoM3YzZgB2AIYAlgALGgYgZWQwZGI4MzM0"
"Y2FkZDIzNmMwMGNhZGY3ZTExYWI1YTUWBzEzLjQuMjImCTEuMC44MDEzODYARgkxMjcuMC4wLjFWBnhpYW9taWYACyoA"
"ARYJTTIxMDJKMlNDJigwMmRmMzk4Nzk3NDMyZWFkZWZjYzEyNzY3MTE5YWQ1ZTgwOTk5Mzg5NgdhbmRyb2lkRg9NMjEw"
"MkoyU0MsMzAsMTFmBDEwODB2BDIxMjCGKDdjNTM4N2UwNTM5YzAyM2MzMWM0ZmYwZTgwN2U3MjU2MTE3Mzg1ZWULMwAA"
"ARdRuGVvRwAAAQREQ0JHY0QyN0liSWEvZnZ0UHhNT2xZdGJUY0M3bWRaUlJ3YzIyc2NnQkFyRTJ5eTZwSUdIQjNMK0tr"
"MzVxVS9iaWc1Qk1TVVVSd3gzeS9wWVpWajRjd20rWEg1dnNrakR2SzNhUlhudEJGcURDQUUvaUVIbGs4ZXJ3VUJZdmJM"
"aXlIb0YrSytQam5GTGJRMmlzSHVhcUtqTHAvWmRETDlxSit3VEVSb3h0ZjFuUzlTZ0l2N3lCaVIyMjd4N3F3RjllUTVu"
"ckNaRitnRnczelVZb2N6Uk9jbHE1aXZDclhRZTVSZ3hOYkp5aWQ3ZkZqTVhYYlNQdHBIZ2p0TVJtdWp6RVRvPVYAZgAL"
"BhB3dXB1ZGJyZXF1ZXN0X3YwHQAABQIAWkEFjJgMqAw="
)
def _read_len_int(d: bytes | bytearray, p: int) -> tuple[int, int]:
dt = d[p] & 0x0F
if dt == ZERO:
return 0, p + 1
if dt == INT8:
return struct.unpack_from(">b", d, p + 1)[0], p + 2
if dt == INT16:
return struct.unpack_from(">h", d, p + 1)[0], p + 3
if dt == INT32:
return struct.unpack_from(">i", d, p + 1)[0], p + 5
raise ValueError(f"长度int类型异常 {dt:#x}@{p}")
def _skip_value(d: bytes | bytearray, p: int, dt: int) -> int:
if dt == ZERO:
return p
if dt == INT8:
return p + 1
if dt == INT16:
return p + 2
if dt == INT32:
return p + 4
if dt == INT64:
return p + 8
if dt == STRING1:
return p + 1 + d[p]
if dt == STRING4:
return p + 4 + struct.unpack_from(">i", d, p)[0]
if dt == SIMPLE_LIST:
p += 1
n, p = _read_len_int(d, p)
return p + n
if dt == MAP:
n, p = _read_len_int(d, p)
for _ in range(n):
h = d[p]
p += 1
kd = h & 0x0F
if kd == STRING1:
p += 1 + d[p]
elif kd == STRING4:
p += 4 + struct.unpack_from(">i", d, p)[0]
else:
raise ValueError(f"map key 类型 {kd:#x}")
vh = d[p]
p += 1
p = _skip_value(d, p, vh & 0x0F)
return p
if dt == LIST:
n, p = _read_len_int(d, p)
eh = d[p]
p += 1
edt = eh & 0x0F
for _ in range(n):
p = _skip_value(d, p, edt)
return p
if dt == STRUCT_BEGIN:
while True:
h = d[p]
p += 1
sdt = h & 0x0F
if sdt == STRUCT_END:
break
p = _skip_value(d, p, sdt)
return p
raise ValueError(f"未知类型 {dt:#x}@{p}")
class Envelope:
"""WUP 请求信封结构解析器与补丁器。"""
def __init__(self, raw_bytes: bytes):
self.raw = bytearray(raw_bytes)
self.tag4_span: tuple[int, int] | None = None
self.meta_json_span: tuple[int, int] | None = None
self.uid_off: int | None = None
self.cert_off: int | None = None
self.cert_len: int = 260
self._parse()
@classmethod
def load(cls, path: str | Path | None = None) -> Envelope:
"""加载显式模板,或使用内嵌协议结构模板。
不自动扫描 ``evidence/``,避免历史抓包成为隐式生产输入。
"""
if path:
p = Path(path)
if p.exists():
return cls._load_from_path(p)
return cls(base64.b64decode(PROTOCOL_QURL_TEMPLATE_B64))
@classmethod
def build_qr(
cls,
*,
uid: int,
cert_b64: str,
safedeviceid: str,
session: int,
trace_id: str,
device_info: dict[str, str],
) -> Envelope:
"""按当前账号状态编码二维码绑定信封。
生产二维码请求不应从抓包信封复制字段。这里仅复用已知的 TAF/WUP
字段布局;证书、ACTION、UID、会话和全部设备画像都由本次登录提供。
"""
if len(cert_b64) != 260:
raise ValueError(f"证书 base64 长度异常: {len(cert_b64)} != 260")
if len(safedeviceid) != 180:
raise ValueError(f"safedeviceid 长度异常: {len(safedeviceid)} != 180")
dev = {k: str(v) for k, v in (device_info or {}).items()}
meta = json.dumps(
{
"associationId": 184549392,
"funcName": "",
"group": 0,
"id": 184549392,
"session": int(session),
"step": 0,
"stillLogin": False,
"traceId": str(trace_id),
"type": 2,
"uid": 0,
"userContext": "",
},
ensure_ascii=False,
separators=(",", ":"),
)
body = TafOutputStream()
body.write_struct_begin(0)
body.write_struct_begin(0)
body.write_int8(0, 0)
body.write_string(1, "1.0")
body.write_string(2, meta)
body.write_string(3, "5008")
body.write_int8(4, 3)
body.write_string(5, safedeviceid)
body.write_string(6, "")
body.write_string(7, "")
body.write_string(8, "")
body.write_string(9, "")
body.write_struct_end()
body.write_struct_begin(1)
body.write_string(0, dev.get("hdid", ""))
body.write_string(1, dev.get("app_version", "13.4.22"))
body.write_string(2, dev.get("sdk_version", "1.0.80138"))
body.write_string(3, "")
body.write_string(4, dev.get("ip", "127.0.0.1"))
body.write_string(5, dev.get("vendor", "android"))
body.write_string(6, "")
body.write_struct_end()
body.write_struct_begin(2)
body.write_int8(0, 1)
body.write_string(1, dev.get("model", ""))
body.write_string(2, dev.get("fingerprint", ""))
body.write_string(3, dev.get("os", "android"))
body.write_string(4, dev.get("screen", ""))
body.write_string(6, dev.get("width", "1080"))
body.write_string(7, dev.get("height", "2120"))
body.write_string(8, dev.get("device_id", ""))
body.write_struct_end()
body.write_uint64(3, int(uid))
body.write_string(4, cert_b64)
body.write_string(5, "")
body.write_string(6, "")
body.write_struct_end()
req = TafOutputStream()
req.write_int32(0, int(session))
wup = WupRequest()
wup.iTimeout = 0
wup.setRequestId(int(session))
wup.setServant("huyaudbwebui")
wup.setFunc("default")
wup.newdata["_wup_data"] = body.get_bytes()
wup.newdata["wupudbrequest_v0"] = req.get_bytes()
return cls(wup.encode())
@classmethod
def _load_from_path(cls, p: Path) -> Envelope:
if p.suffix == ".json":
j = json.loads(p.read_text("utf-8"))
q = next(
e["data"] for e in j if e.get("type") == "qurl_done" and e.get("data")
)
return cls(base64.b64decode(q))
return cls(p.read_bytes())
def _parse(self) -> None:
d = self.raw
p = 4
# WUP header
while p < len(d):
h = d[p]
p += 1
tag, dt = (h >> 4) & 0x0F, h & 0x0F
if tag == 4 and dt == INT32:
self.tag4_span = (p, p + 4)
p += 4
elif tag == 7 and dt == SIMPLE_LIST:
p += 1 # 元素类型头
n, p = _read_len_int(d, p)
self._parse_sbuffer(p, n)
break
else:
p = _skip_value(d, p, dt)
def _parse_sbuffer(self, start: int, ln: int) -> None:
d = self.raw
p = start
h = d[p]
p += 1
assert (h & 0x0F) == MAP
cnt, p = _read_len_int(d, p)
for _ in range(cnt):
p += 1
kln = d[p]
p += 1
k = bytes(d[p : p + kln])
p += kln
vh = d[p]
p += 1
if k == b"_wup_data":
assert (vh & 0x0F) == SIMPLE_LIST
p += 1
wup_data_len, p = _read_len_int(d, p)
self._parse_wup_data_struct(p, wup_data_len)
p += wup_data_len
else:
p = _skip_value(d, p, vh & 0x0F)
def _parse_wup_data_struct(self, start: int, ln: int) -> None:
d = self.raw
q = start
assert (d[q] & 0x0F) == STRUCT_BEGIN
q += 1
while q < start + ln:
hh = d[q]
q += 1
tag, dt = (hh >> 4) & 0x0F, hh & 0x0F
if tag == 15:
tag = d[q]
q += 1
if dt == STRUCT_END:
break
if tag == 0 and dt == STRUCT_BEGIN:
while True:
h2 = d[q]
q += 1
tg2, dt2 = (h2 >> 4) & 0x0F, h2 & 0x0F
if tg2 == 15:
tg2 = d[q]
q += 1
if dt2 == STRUCT_END:
break
vs = q
q = _skip_value(d, q, dt2)
if tg2 == 2 and dt2 in (STRING1, STRING4):
off = vs + (4 if dt2 == STRING4 else 1)
self.meta_json_span = (off, q)
elif tag == 3 and dt == INT64:
self.uid_off = q
q += 8
elif tag == 4 and dt == STRING4:
ln_c = struct.unpack_from(">i", d, q)[0]
self.cert_off = q + 4
self.cert_len = ln_c
q = self.cert_off + ln_c
else:
q = _skip_value(d, q, dt)
if self.uid_off is None or self.cert_off is None:
raise ValueError("未在信封中定位到 t3(uid) 或 t4(cert)")
@property
def uid(self) -> int:
assert self.uid_off is not None
return struct.unpack_from(">Q", self.raw, self.uid_off)[0]
def patch_uid(self, uid: int) -> Envelope:
assert self.uid_off is not None
struct.pack_into(">Q", self.raw, self.uid_off, uid)
return self
@property
def cert_b64(self) -> bytes:
assert self.cert_off is not None
return bytes(self.raw[self.cert_off : self.cert_off + self.cert_len])
def patch_cert(self, cert: bytes) -> Envelope:
assert self.cert_off is not None
b64 = base64.b64encode(cert)
if len(b64) != self.cert_len:
raise ValueError(
f"证书b64长度不符: {len(b64)} != 模板 {self.cert_len} (cert {len(cert)}B)"
)
self.raw[self.cert_off : self.cert_off + self.cert_len] = b64
return self
def patch_session(self, session: int) -> Envelope:
"""Patch the outer WUP session and its request copy."""
if self.tag4_span:
struct.pack_into(">I", self.raw, self.tag4_span[0], session & 0xFFFFFFFF)
d = self.raw
i = d.find(b"wupudbrequest_v0")
if i >= 0:
j = i + len(b"wupudbrequest_v0") + 4
if j + 4 <= len(d):
struct.pack_into(">I", d, j, session & 0xFFFFFFFF)
return self
def patch_meta(self, session: int, trace_id: str) -> Envelope:
"""Replace QR metadata values without carrying the capture's old state.
The captured envelope keeps a fixed-size JSON string. Keeping the
replacement the same size lets us update only the value bytes and
preserve all TAF length prefixes and offsets.
"""
if self.meta_json_span is None:
raise ValueError("信封缺少元数据 JSON")
start, end = self.meta_json_span
current = bytes(self.raw[start:end])
try:
meta = json.loads(current.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise ValueError("信封元数据 JSON 无法解析") from exc
meta["session"] = int(session)
meta["traceId"] = str(trace_id)
updated = json.dumps(meta, ensure_ascii=False, separators=(",", ":")).encode(
"utf-8"
)
if len(updated) != len(current):
raise ValueError(
f"信封元数据长度变化: {len(updated)} != {len(current)}; "
"请使用固定长度 session/traceId"
)
self.raw[start:end] = updated
return self
def wup_b64(self) -> str:
return base64.b64encode(bytes(self.raw)).decode()
+2 -2
View File
@@ -13,7 +13,7 @@
## 项目结构 ## 项目结构
``` ```
douyu_login_py/ live-hub-py/
├── core/ # 核心业务逻辑 ├── core/ # 核心业务逻辑
│ ├── models.py # Account, ProxyConfig 数据类 │ ├── models.py # Account, ProxyConfig 数据类
│ ├── douyu/ # 斗鱼登录模块 │ ├── douyu/ # 斗鱼登录模块
@@ -53,7 +53,7 @@ douyu_login_py/
应用宝充值 Worker 已合并到 Web 应用容器中,内部仅监听 `127.0.0.1:8810`,不发布额外端口。任务数据与应用数据一起保存在 `data/yyb-worker-jobs/` 应用宝充值 Worker 已合并到 Web 应用容器中,内部仅监听 `127.0.0.1:8810`,不发布额外端口。任务数据与应用数据一起保存在 `data/yyb-worker-jobs/`
```bash ```bash
cd /opt/douyu_login_py cd /opt/live-hub-py
python3 -c 'import secrets; print(secrets.token_urlsafe(32))' python3 -c 'import secrets; print(secrets.token_urlsafe(32))'
``` ```
Executable
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
SOURCE="${1:?source path required}"
TARGET="${2:?target path required}"
cp "$SOURCE" "$TARGET"
printf 'restored %s from %s\n' "$TARGET" "$SOURCE"
+35
View File
@@ -0,0 +1,35 @@
changed branch/field: Huya App login QR envelope and device-token propagation
MODIFIED_FILE: /Users/yml/codes/live-hub-py/MODIFIED_FILE
DIFF_FILE: /Users/yml/codes/live-hub-py/DIFF_FILE
VERIFICATION.txt: /Users/yml/codes/live-hub-py/VERIFICATION.txt
ROLLBACK.sh: /Users/yml/codes/live-hub-py/ROLLBACK.sh
MODIFIED_FILE original SHA-256 before fixture update: 08bc9d7ca6425fab15af2c6218d2574f7f0180e929fa6ccb78c8b37e6ae8493a
MODIFIED_FILE changed SHA-256: e15f95ce30ca0e0faa6e3c8c6cdf4725e4984434df0f39cf237ee0b7197375b1
BASELINE exact command:
set -e; BASE=/tmp/live-hub-py-baseline.PJsjpJ; git archive HEAD | tar -x -C "$BASE"; .venv/bin/pytest -q "$BASE/tests/test_huya_app_login.py" "$BASE/tests/test_huya_dfp_register.py"
BASELINE literal output/result:
27 passed, 1 warning in 0.62s
BASELINE exit status: 0
MODIFIED exact command:
.venv/bin/pytest -q
MODIFIED literal output/result:
113 passed, 1 warning in 1.13s
MODIFIED exit status: 0
ROLLBACK exact command:
set -e; SRC=/tmp/rollback-source.mKNKoP; TGT=/tmp/rollback-target.hHBqnQ; cp MODIFIED_FILE "$SRC"; printf '\ncorrupt\n' >> "$TGT"; ./ROLLBACK.sh "$SRC" "$TGT"; cmp -s "$SRC" "$TGT"
ROLLBACK literal output/result:
restored /tmp/rollback-target.hHBqnQ from /tmp/rollback-source.mKNKoP
rollback_status=restored
source_sha256=e15f95ce30ca0e0faa6e3c8c6cdf4725e4984434df0f39cf237ee0b7197375b1
target_sha256=e15f95ce30ca0e0faa6e3c8c6cdf4725e4984434df0f39cf237ee0b7197375b1
ROLLBACK exit status: 0
restored behavior/status: target byte-for-byte equals source; MODIFIED_FILE remains changed.
Additional checks:
.venv/bin/python -m ruff check core/huya tests/test_huya_app_login.py -> All checks passed, exit 0
git diff --check -> exit 0
bash -n dev.sh deploy.sh -> exit 0
+9 -6
View File
@@ -39,6 +39,7 @@ from .app_login import HuyaAppPasswordLogin
from .device_profile import ( from .device_profile import (
_load_db, _load_db,
_save_db, _save_db,
canonical_account_key,
get_profile, # 幂等画像: 同账号永远复用同一套 (data/huya_device_profiles.json) get_profile, # 幂等画像: 同账号永远复用同一套 (data/huya_device_profiles.json)
) )
@@ -68,9 +69,10 @@ def get_or_create_env(account: str, force_new: bool = False) -> dict:
(R36 解密真实结构), 每账号独立生成终身复用 → 一号一设备的一致性来源; (R36 解密真实结构), 每账号独立生成终身复用 → 一号一设备的一致性来源;
当前注册链服务端不校验 cw 内容, 此字段为后续真实载荷构建预留 当前注册链服务端不校验 cw 内容, 此字段为后续真实载荷构建预留
""" """
env = get_profile(account, force_new=force_new) key = canonical_account_key(account)
env = get_profile(key, force_new=force_new)
db = _load_db() db = _load_db()
record = dict(db.get(account) or env) record = dict(db.get(key) or env)
changed = False changed = False
# guid32 / hebe: 一号一致字段, 首次生成后终身不变 # guid32 / hebe: 一号一致字段, 首次生成后终身不变
if "guid32" not in record: if "guid32" not in record:
@@ -82,7 +84,7 @@ def get_or_create_env(account: str, force_new: bool = False) -> dict:
} }
changed = True changed = True
if changed: if changed:
db[account] = record db[key] = record
_save_db(db) _save_db(db)
return record return record
@@ -102,7 +104,8 @@ def bind_and_login(
就是本环境的 40hex → 签发的 t2/t5 与环境绑定); safe_auth 滑块过验后的重发 就是本环境的 40hex → 签发的 t2/t5 与环境绑定); safe_auth 滑块过验后的重发
沿用同一组设备字段 (app_login.login_cred_with_flow)。 沿用同一组设备字段 (app_login.login_cred_with_flow)。
""" """
env = get_or_create_env(account, force_new=force_new_device) key = canonical_account_key(account)
env = get_or_create_env(key, force_new=force_new_device)
print( print(
f"[env] {account}{env.get('vendor')}/{env.get('model')} " f"[env] {account}{env.get('vendor')}/{env.get('model')} "
f"fp40={env.get('fingerprint', '')[:12]}... guid32={env.get('guid32', '')[:12]}... " f"fp40={env.get('fingerprint', '')[:12]}... guid32={env.get('guid32', '')[:12]}... "
@@ -124,14 +127,14 @@ def bind_and_login(
# ---- 绑定元数据回写 (令牌不入库: t2/t5 每次登录实时签发, 环境才是长期身份) ---- # ---- 绑定元数据回写 (令牌不入库: t2/t5 每次登录实时签发, 环境才是长期身份) ----
db = _load_db() db = _load_db()
record = dict(db.get(account) or env) record = dict(db.get(key) or env)
record.setdefault("bound_at", int(time.time())) # 首次绑定时间 record.setdefault("bound_at", int(time.time())) # 首次绑定时间
record["last_login"] = { record["last_login"] = {
"ok": result.success, "ok": result.success,
"msg": result.message[:120], "msg": result.message[:120],
"at": int(time.time()), "at": int(time.time()),
} }
db[account] = record db[key] = record
_save_db(db) _save_db(db)
return { return {
+93 -44
View File
@@ -9,7 +9,8 @@
6. POST /web/cookie/verify 兑换获取全套网页 Cookie 6. POST /web/cookie/verify 兑换获取全套网页 Cookie
注册链不再重放旧 dfpReport 密文。登录帧的 32hex hdid 仍是服务端硬锚, 注册链不再重放旧 dfpReport 密文。登录帧的 32hex hdid 仍是服务端硬锚,
当前继续使用已注册样本;新注册链动态更新的是 safedeviceid 和 device_id。 登录帧中的 HDID32 是 app 版本级协议常量;二维码信封的 ACTION、设备字段、
UID、证书和会话元数据均在本次登录中动态生成或由注册链签发。
注册链(``core/huya/dfp_register``)每次登录前执行,失败即抛错终止(``HuyaAppLoginError``), 注册链(``core/huya/dfp_register``)每次登录前执行,失败即抛错终止(``HuyaAppLoginError``),
不读取画像里的旧固定值,也不静默回退旧链。 不读取画像里的旧固定值,也不静默回退旧链。
@@ -35,9 +36,9 @@ from urllib.parse import parse_qs, quote, urlparse
import requests import requests
from loguru import logger from loguru import logger
from .cert_forge import build_p1, decrypt_cert, forge_cert, parse_p1 from .cert_forge import build_p1, forge_cert
from .cookie_utils import normalize_huya_cookie from .cookie_utils import normalize_huya_cookie
from .device_fingerprint import account_state_dir, get_huya_sdid from .device_fingerprint import account_state_dir, get_huya_sdid, reset_account_state
from .device_profile import get_profile, mobile_user_agent from .device_profile import get_profile, mobile_user_agent
from .dfp_register import DfpRegistrationError, register_device from .dfp_register import DfpRegistrationError, register_device
from .envelope_forge import Envelope from .envelope_forge import Envelope
@@ -61,26 +62,13 @@ APP_UA_MOBILE = (
"Chrome/149.0.7827.159 Mobile Safari/537.36 huya adr/13.4.22/xiaomi/30" "Chrome/149.0.7827.159 Mobile Safari/537.36 huya adr/13.4.22/xiaomi/30"
) )
SessionAssets = tuple[dict, str, str]
RISK_URL_RE = re.compile(rb"https://aq\.huya\.com/p/safe_auth/[^\x00-\x20\"'\\<>]+") RISK_URL_RE = re.compile(rb"https://aq\.huya\.com/p/safe_auth/[^\x00-\x20\"'\\<>]+")
_URL_TAIL_KEEP = set( _URL_TAIL_KEEP = set(
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~:/?#[]@!$&'()*+,;=%" "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~:/?#[]@!$&'()*+,;=%"
) )
DEFAULT_GOLDEN_DEV = {
"app_version": "13.4.22",
"sdk_version": "1.0.80138",
"vendor": "xiaomi",
"model": "M2102J2SC",
"os": "android",
"ip": "127.0.0.1",
"fingerprint": "02df398797432eadefcc12767119ad5e80999389",
"screen": "M2102J2SC,30,11",
"width": "1080",
"height": "2120",
"device_id": "7c5387e0539c023c31c4ff0e807e7256117385ee",
"hdid": "ed0db8334cadd236c00cadf7e11ab5a5", # HDID32 登录t1.t0 (勿与GUID32混)
}
class HuyaAppLoginError(HuyaLoginError): class HuyaAppLoginError(HuyaLoginError):
"""虎牙 App 登录失败。""" """虎牙 App 登录失败。"""
@@ -123,6 +111,7 @@ def wup_password_login_raw(
safedeviceid: str | None = None, safedeviceid: str | None = None,
hdid: str | None = None, hdid: str | None = None,
proxies: dict | None = None, proxies: dict | None = None,
session_assets: SessionAssets | None = None,
) -> bytes: ) -> bytes:
"""发送 WUP 密码登录,返回原始响应字节。 """发送 WUP 密码登录,返回原始响应字节。
@@ -132,7 +121,7 @@ def wup_password_login_raw(
""" """
uid_str = account.removeprefix("hy_") uid_str = account.removeprefix("hy_")
dev = dict(device_info) if device_info is not None else get_profile(account) dev = dict(device_info) if device_info is not None else get_profile(account)
mj, ua, _old_sd = _golden_session_assets() mj, ua, _old_sd = session_assets or _golden_session_assets()
if not safedeviceid: if not safedeviceid:
try: try:
_t1, safedeviceid, registered_device_id = register_device( _t1, safedeviceid, registered_device_id = register_device(
@@ -144,11 +133,14 @@ def wup_password_login_raw(
except DfpRegistrationError as exc: except DfpRegistrationError as exc:
raise HuyaAppLoginError(f"新设备注册失败: {exc}") from exc raise HuyaAppLoginError(f"新设备注册失败: {exc}") from exc
dev["device_id"] = registered_device_id dev["device_id"] = registered_device_id
hdid_value = hdid or dev.get("hdid")
if not hdid_value:
raise HuyaAppLoginError("账号设备画像缺少 HDID32,拒绝使用固定金样本")
pkt = build_password_login_wup( pkt = build_password_login_wup(
uid_str, uid_str,
hashlib.sha1(password.encode()).hexdigest(), hashlib.sha1(password.encode()).hexdigest(),
safedeviceid, safedeviceid,
hdid or dev.get("hdid") or "ed0db8334cadd236c00cadf7e11ab5a5", str(hdid_value),
mj["session"], mj["session"],
mj["traceId"], mj["traceId"],
ua, ua,
@@ -202,6 +194,16 @@ def parse_risk_url(resp: bytes) -> str | None:
return (pt or urls)[0] return (pt or urls)[0]
def _response_markers(resp: bytes) -> str:
"""提取 WUP 错误响应中的短 ASCII 字段,便于区分签名和凭据失败。"""
markers = []
for raw in re.findall(rb"[ -~]{4,}", resp):
value = raw.decode("ascii", "ignore")
if value not in markers and len(value) <= 160:
markers.append(value)
return ", ".join(markers[:8])
def solve_safe_auth( def solve_safe_auth(
risk_url: str, risk_url: str,
proxies=None, proxies=None,
@@ -214,6 +216,8 @@ def solve_safe_auth(
HuyaVerificationSolver, HuyaVerificationSolver,
) )
if device_info is None:
raise HuyaAppLoginError("safe_auth 缺少当前账号设备画像")
q = { q = {
k: v[0] k: v[0]
for k, v in parse_qs(urlparse(risk_url).query, keep_blank_values=True).items() for k, v in parse_qs(urlparse(risk_url).query, keep_blank_values=True).items()
@@ -222,7 +226,7 @@ def solve_safe_auth(
last_err: Exception | None = None last_err: Exception | None = None
for attempt in range(max_retry): for attempt in range(max_retry):
solver = HuyaVerificationSolver( solver = HuyaVerificationSolver(
ua=mobile_user_agent(device_info or DEFAULT_GOLDEN_DEV), ua=mobile_user_agent(device_info),
proxies=proxies, proxies=proxies,
app_id=app_id, app_id=app_id,
page_url=risk_url, page_url=risk_url,
@@ -267,7 +271,8 @@ def login_cred_with_flow(
max_rounds: int = 3, max_rounds: int = 3,
device_info: dict | None = None, device_info: dict | None = None,
proxies: dict | None = None, proxies: dict | None = None,
) -> tuple[bytes, int]: include_device_token: bool = False,
) -> tuple[bytes, int] | tuple[bytes, int, str]:
"""新注册设备后登录,返回 ``(新鲜 cred, 真实 uid)``。 """新注册设备后登录,返回 ``(新鲜 cred, 真实 uid)``。
注册只执行一次;safe_auth 通过后的重发继续使用同一组设备字段。 注册只执行一次;safe_auth 通过后的重发继续使用同一组设备字段。
@@ -282,19 +287,29 @@ def login_cred_with_flow(
except DfpRegistrationError as exc: except DfpRegistrationError as exc:
raise HuyaAppLoginError(f"新设备注册失败: {exc}") from exc raise HuyaAppLoginError(f"新设备注册失败: {exc}") from exc
dev["device_id"] = registered_device_id dev["device_id"] = registered_device_id
# 风控验证绑定首次 WUP 请求的 session/traceId;所有重发必须复用它们。
session_assets = _golden_session_assets()
for rnd in range(max_rounds): for rnd in range(max_rounds):
logger.debug(f"[huya-app] WUP 登录请求第 {rnd + 1} 轮...")
resp = wup_password_login_raw( resp = wup_password_login_raw(
account, account,
password, password,
device_info=dev, device_info=dev,
safedeviceid=safedeviceid, safedeviceid=safedeviceid,
proxies=proxies, proxies=proxies,
session_assets=session_assets,
) )
cred = parse_cred(resp) cred = parse_cred(resp)
risk_url = parse_risk_url(resp) if not cred else None
logger.debug(
f"[huya-app] WUP 第 {rnd + 1} 轮响应: {len(resp)}B, "
f"cred={bool(cred)}, risk={bool(risk_url)}"
)
if cred: if cred:
uid = parse_real_uid(resp) uid = parse_real_uid(resp)
if include_device_token:
return cred, uid, safedeviceid
return cred, uid return cred, uid
risk_url = parse_risk_url(resp)
if risk_url: if risk_url:
kind = ( kind = (
"pt_auth(滑块)" "pt_auth(滑块)"
@@ -309,7 +324,18 @@ def login_cred_with_flow(
solve_safe_auth(risk_url, proxies=proxies, device_info=dev) solve_safe_auth(risk_url, proxies=proxies, device_info=dev)
logger.info("[huya-app] safe_auth 滑块过验成功,重发 WUP 登录...") logger.info("[huya-app] safe_auth 滑块过验成功,重发 WUP 登录...")
continue continue
raise HuyaAppLoginError("登录未返回凭据也无风控URL(密码错误或账号状态异常)") markers = _response_markers(resp)
detail = f",服务端字段: {markers}" if markers else ""
logger.warning(f"[huya-app] WUP 响应未包含 cred/风控: {len(resp)}B{detail}")
if "LGN_INFO_INVALID_USER_OR_PASSWORD" in markers:
raise HuyaAppLoginError(
"虎牙账号或密码错误(服务端: LGN_INFO_INVALID_USER_OR_PASSWORD"
)
if "APP_SIGN_NOT_MATCH" in markers:
raise HuyaAppLoginError("虎牙设备签名不匹配(服务端: APP_SIGN_NOT_MATCH")
raise HuyaAppLoginError(
f"登录未返回凭据也无风控URL(响应 {len(resp)}B{detail}"
)
raise HuyaAppLoginError(f"{max_rounds} 轮内未取得登录凭据") raise HuyaAppLoginError(f"{max_rounds} 轮内未取得登录凭据")
@@ -325,6 +351,8 @@ class QrRole:
): ):
self.pc = pc self.pc = pc
self.sdid = sdid self.sdid = sdid
if not pc and device_info is None:
raise HuyaAppLoginError("移动端二维码角色缺少当前账号设备画像")
self.s = requests.Session() self.s = requests.Session()
self.s.trust_env = False self.s.trust_env = False
if proxies: if proxies:
@@ -338,9 +366,7 @@ class QrRole:
self.req_counter = random.randint(40_000_000, 41_000_000) self.req_counter = random.randint(40_000_000, 41_000_000)
self.s.headers.update( self.s.headers.update(
{ {
"User-Agent": UA_PC "User-Agent": UA_PC if pc else mobile_user_agent(device_info),
if pc
else mobile_user_agent(device_info or DEFAULT_GOLDEN_DEV),
"Origin": UDB_BASE, "Origin": UDB_BASE,
"content-type": "application/json;charset=UTF-8", "content-type": "application/json;charset=UTF-8",
"Accept": "*/*", "Accept": "*/*",
@@ -414,6 +440,8 @@ class HuyaAppPasswordLogin:
self.proxies = dict(proxies) if proxies else None self.proxies = dict(proxies) if proxies else None
self.timeout = timeout or (10.0, 25.0) self.timeout = timeout or (10.0, 25.0)
self.force_new_device = force_new_device self.force_new_device = force_new_device
if force_new_device:
reset_account_state(self.username)
self.device_info = device_info or get_profile( self.device_info = device_info or get_profile(
self.username, force_new=force_new_device self.username, force_new=force_new_device
) )
@@ -438,11 +466,12 @@ class HuyaAppPasswordLogin:
# 1) 获取新鲜 cred 与 真实 uid (自动过 safe_auth 滑块) # 1) 获取新鲜 cred 与 真实 uid (自动过 safe_auth 滑块)
try: try:
cred, uid = login_cred_with_flow( cred, uid, safedeviceid = login_cred_with_flow(
acct, acct,
self.password, self.password,
device_info=self.device_info, device_info=self.device_info,
proxies=self.proxies, proxies=self.proxies,
include_device_token=True,
) )
except HuyaAppQrAuthRequiredError as exc: except HuyaAppQrAuthRequiredError as exc:
return HuyaLoginResult( return HuyaLoginResult(
@@ -461,23 +490,28 @@ class HuyaAppPasswordLogin:
# 2) 本地生成 nonce 铸造证书 (P1 指纹与该账号设备画像一致) # 2) 本地生成 nonce 铸造证书 (P1 指纹与该账号设备画像一致)
try: try:
env = Envelope.load()
orig = base64.b64decode(env.cert_b64)
f = parse_p1(decrypt_cert(orig))
st = int(time.time() * 1000) st = int(time.time() * 1000)
rnd = gen_nonce(uid, K1_DEFAULT, service_time_ms=st, counter=0) rnd = gen_nonce(uid, K1_DEFAULT, service_time_ms=st, counter=0)
fp_bytes = self.device_info["fingerprint"].encode("ascii") fp_bytes = self.device_info["fingerprint"].encode("ascii")
p1 = build_p1(f["app_id"], fp_bytes, cred, rnd=rnd) p1 = build_p1(b"5008", fp_bytes, cred, rnd=rnd)
cert = base64.b64encode(forge_cert(p1, key_idx=orig[1])).decode() # 证书头和 app_id 是协议版本常量,不从抓包信封读取。
cert = base64.b64encode(forge_cert(p1, key_idx=0x20)).decode()
# 3) 信封补丁 # 3) 按当前账号画像和本次注册令牌重编码二维码信封。
raw = bytearray(env.raw) qr_session = random.randint(1_000_000, 9_999_999)
if env.cert_off is None or env.uid_off is None: qr_trace = (
raise ValueError("信封缺少证书或 uid 偏移") f"{uuid.uuid4().hex[:16]}-{random.randint(10000, 99999)}-"
raw[env.cert_off : env.cert_off + env.cert_len] = cert.encode("ascii") f"{time.time_ns():020d}"
if env.uid != uid: )
struct.pack_into(">Q", raw, env.uid_off, uid) env = Envelope.build_qr(
wup = base64.b64encode(bytes(raw)).decode("ascii") uid=uid,
cert_b64=cert,
safedeviceid=safedeviceid,
session=qr_session,
trace_id=qr_trace,
device_info=self.device_info,
)
wup = base64.b64encode(bytes(env.raw)).decode("ascii")
except Exception as exc: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底 except Exception as exc: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
return HuyaLoginResult( return HuyaLoginResult(
success=False, success=False,
@@ -490,11 +524,19 @@ class HuyaAppPasswordLogin:
# 一号一设备: sdid 状态按账号隔离 (默认全局目录会让所有账号共享 # 一号一设备: sdid 状态按账号隔离 (默认全局目录会让所有账号共享
# 同一份 hydevice 设备状态, 服务端可跨账号关联 — 见 R40 缺口修复) # 同一份 hydevice 设备状态, 服务端可跨账号关联 — 见 R40 缺口修复)
sdid_obj = get_huya_sdid( sdid_obj = get_huya_sdid(
allow_fallback=True, # App 主链只接受当前账号的 hydevice 高信任结果;旧版
# token+collect 降级没有账号画像,不再静默放行。
allow_fallback=False,
state_dir=account_state_dir(self.username), state_dir=account_state_dir(self.username),
device_hint=self.device_info, device_hint=self.device_info,
) )
sdid = sdid_obj.sdid if sdid_obj else "" if not sdid_obj or not sdid_obj.sdid or sdid_obj.source != "fingerprint":
detail = sdid_obj.message if sdid_obj else "未返回结果"
raise HuyaAppLoginError(
f"账号设备指纹获取失败(必须为 hydevice): {detail}"
)
sdid = sdid_obj.sdid
logger.info("[huya-app] cred 已获取,开始二维码绑定流程")
pc = QrRole(pc=True, sdid=sdid, proxies=self.proxies) pc = QrRole(pc=True, sdid=sdid, proxies=self.proxies)
ph = QrRole( ph = QrRole(
pc=False, pc=False,
@@ -511,6 +553,7 @@ class HuyaAppPasswordLogin:
{"behavior": beh, "type": "", "domainList": "", "page": page}, {"behavior": beh, "type": "", "domainList": "", "page": page},
) )
qrid = (resp.get("data") or {}).get("qrId") qrid = (resp.get("data") or {}).get("qrId")
logger.debug(f"[huya-app] getQrId 响应: qrid={bool(qrid)}")
if not qrid: if not qrid:
return HuyaLoginResult( return HuyaLoginResult(
success=False, success=False,
@@ -540,6 +583,9 @@ class HuyaAppPasswordLogin:
"page": quote(cp, safe=""), "page": quote(cp, safe=""),
}, },
) )
logger.debug(
f"[huya-app] bindQrLoginUser 响应: returnCode={r2.get('returnCode')}"
)
if r2.get("returnCode") not in (0, "0", None) and r2.get("returnCode") != 0: if r2.get("returnCode") not in (0, "0", None) and r2.get("returnCode") != 0:
logger.warning( logger.warning(
f"[huya-app] bind 返回码: {r2.get('returnCode')} msg: {r2.get('message')}" f"[huya-app] bind 返回码: {r2.get('returnCode')} msg: {r2.get('message')}"
@@ -547,7 +593,7 @@ class HuyaAppPasswordLogin:
# 4.3 轮询 tryQrLogin # 4.3 轮询 tryQrLogin
biztoken = None biztoken = None
for _ in range(12): for index in range(12):
rt = pc.call( rt = pc.call(
"/qrLgn/tryQrLogin", "/qrLgn/tryQrLogin",
"70003", "70003",
@@ -560,6 +606,9 @@ class HuyaAppPasswordLogin:
}, },
) )
dt = rt.get("data") or {} dt = rt.get("data") or {}
logger.debug(
f"[huya-app] tryQrLogin 第 {index + 1}/12 轮: stage={dt.get('stage')}"
)
if dt.get("stage") == 2: if dt.get("stage") == 2:
biztoken = dt.get("biztoken") biztoken = dt.get("biztoken")
break break
+28 -2
View File
@@ -17,7 +17,7 @@ from pathlib import Path
import requests import requests
from loguru import logger from loguru import logger
from .device_profile import mobile_user_agent from .device_profile import canonical_account_key, mobile_user_agent
FINGERPRINT_DIR = Path(__file__).parent / "fingerprint" FINGERPRINT_DIR = Path(__file__).parent / "fingerprint"
RUNNER_JS = FINGERPRINT_DIR / "runner.js" RUNNER_JS = FINGERPRINT_DIR / "runner.js"
@@ -31,10 +31,36 @@ FP_STATE_ROOT = Path(__file__).resolve().parents[2] / "data" / "huya_fp_states"
def account_state_dir(account: str) -> Path: def account_state_dir(account: str) -> Path:
"""账号专属指纹状态目录 (持久化, 保证同一账号多次登录是同一台'设备')。""" """账号专属指纹状态目录 (持久化, 保证同一账号多次登录是同一台'设备')。"""
raw = str(account or "anon").strip()
account = canonical_account_key(raw)
safe = "".join( safe = "".join(
c if c.isalnum() or c in "-_." else "_" for c in (account or "anon") c if c.isalnum() or c in "-_." else "_" for c in (account or "anon")
)[:64] )[:64]
return FP_STATE_ROOT / safe target = FP_STATE_ROOT / safe
# Move a pre-rename ``hy_<numeric>`` directory on first access when the
# canonical directory does not exist. If both exist, keep the canonical
# state and leave the legacy directory untouched for manual cleanup.
if raw != account:
legacy_safe = "".join(c if c.isalnum() or c in "-_." else "_" for c in raw)[:64]
legacy = FP_STATE_ROOT / legacy_safe
if not target.exists() and legacy.exists():
try:
target.parent.mkdir(parents=True, exist_ok=True)
legacy.rename(target)
except OSError as exc:
logger.debug("迁移旧虎牙设备状态失败: {}", exc)
return target
def reset_account_state(account: str) -> None:
"""清理账号的 hydevice 持久化状态,供真正的全新设备登录使用。"""
state_dir = account_state_dir(account)
for name in ("localstorage.json", "device.json"):
path = state_dir / name
try:
path.unlink()
except FileNotFoundError:
pass
DEFAULT_TIMEOUT = (8, 40) DEFAULT_TIMEOUT = (8, 40)
+33 -6
View File
@@ -19,6 +19,7 @@ import hashlib
import json import json
import os import os
import random import random
import re
from collections.abc import Mapping from collections.abc import Mapping
from pathlib import Path from pathlib import Path
@@ -28,6 +29,7 @@ ROOT = Path(__file__).resolve().parent.parent.parent
DATA_DIR = ROOT / "data" DATA_DIR = ROOT / "data"
PRIMARY_PROFILE_DB = DATA_DIR / "huya_device_profiles.json" PRIMARY_PROFILE_DB = DATA_DIR / "huya_device_profiles.json"
FALLBACK_PROFILE_DB = ROOT / "evidence" / "device_profiles.json" FALLBACK_PROFILE_DB = ROOT / "evidence" / "device_profiles.json"
ALLOW_LEGACY_PROFILE_IMPORT = os.getenv("HUYA_ALLOW_LEGACY_PROFILE_IMPORT") == "1"
REAL_MODELS = [ REAL_MODELS = [
("xiaomi", "M2102J2SC", "M2102J2SC,30,11", (1080, 2120)), ("xiaomi", "M2102J2SC", "M2102J2SC,30,11", (1080, 2120)),
@@ -51,6 +53,19 @@ APP_VERSION = "13.4.22"
SDK_VERSION = "1.0.80138" SDK_VERSION = "1.0.80138"
def canonical_account_key(account: str) -> str:
"""Return one stable environment key for equivalent Huya account forms.
The WUP protocol keeps the ``hy_`` prefix for Huya IDs, but the device
environment must not split ``300023887`` and ``hy_300023887`` into two
records. Phone numbers and other usernames remain unchanged.
"""
value = str(account or "").strip()
if re.fullmatch(r"hy_\d+", value):
return value[3:]
return value
def mobile_user_agent(device_info: Mapping[str, object]) -> str: def mobile_user_agent(device_info: Mapping[str, object]) -> str:
"""根据统一设备画像生成 App WebView UA。""" """根据统一设备画像生成 App WebView UA。"""
screen = str(device_info.get("screen") or "") screen = str(device_info.get("screen") or "")
@@ -107,8 +122,14 @@ def record_login(account: str, ok: bool, message: str = "") -> None:
""" """
import time as _time import time as _time
key = canonical_account_key(account)
db = _load_db() db = _load_db()
rec = db.get(key)
if rec is None and key != account:
rec = db.get(account) rec = db.get(account)
if rec is not None:
db[key] = rec
del db[account]
if rec is None: if rec is None:
return # 尚无环境的账号 (纯 Cookie 导入) 不生成记录 return # 尚无环境的账号 (纯 Cookie 导入) 不生成记录
now = int(_time.time()) now = int(_time.time())
@@ -123,11 +144,11 @@ def _load_db() -> dict:
return json.loads(PRIMARY_PROFILE_DB.read_text("utf-8")) return json.loads(PRIMARY_PROFILE_DB.read_text("utf-8"))
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
logger.debug(f"读取主设备画像库失败: {exc}") logger.debug(f"读取主设备画像库失败: {exc}")
if FALLBACK_PROFILE_DB.exists(): if ALLOW_LEGACY_PROFILE_IMPORT and FALLBACK_PROFILE_DB.exists():
try: try:
return json.loads(FALLBACK_PROFILE_DB.read_text("utf-8")) return json.loads(FALLBACK_PROFILE_DB.read_text("utf-8"))
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
logger.debug(f"读取备用设备画像库失败: {exc}") logger.debug(f"读取显式迁移画像库失败: {exc}")
return {} return {}
@@ -166,14 +187,20 @@ def _enrich_profile(profile: dict) -> tuple[dict, bool]:
def get_profile(account: str, force_new: bool = False) -> dict: def get_profile(account: str, force_new: bool = False) -> dict:
"""按账号获取或创建画像(幂等:同账号复用同一套, 自动补齐缺失一致性字段)。""" """按账号获取或创建画像(幂等:同账号复用同一套, 自动补齐缺失一致性字段)。"""
key = canonical_account_key(account)
db = _load_db() db = _load_db()
if not force_new and account in db: # Migrate the old prefixed key lazily without discarding its environment.
enriched, changed = _enrich_profile(db[account]) legacy_key = str(account or "").strip()
if key not in db and legacy_key != key and legacy_key in db:
db[key] = db.pop(legacy_key)
_save_db(db)
if not force_new and key in db:
enriched, changed = _enrich_profile(db[key])
if changed: if changed:
db[account] = enriched db[key] = enriched
_save_db(db) _save_db(db)
return enriched return enriched
p, _ = _enrich_profile(generate_profile()) p, _ = _enrich_profile(generate_profile())
db[account] = p db[key] = p
_save_db(db) _save_db(db)
return p return p
+18 -10
View File
@@ -90,7 +90,7 @@ def _build_select_operator_request(
# 仅供无画像的协议级独立调用兜底;生产登录始终传入账号画像。 # 仅供无画像的协议级独立调用兜底;生产登录始终传入账号画像。
"app_version": "13.4.22", "app_version": "13.4.22",
"model": "M2102J2SC", "model": "M2102J2SC",
"fingerprint": fingerprint or "02df398797432eadefcc12767119ad5e80999389", "fingerprint": fingerprint or hashlib.sha1(os.urandom(20)).hexdigest(),
"screen": "M2102J2SC,30,11", "screen": "M2102J2SC,30,11",
} }
if device_info: if device_info:
@@ -248,16 +248,21 @@ def _build_dfp_json_plain(device_info: Mapping[str, str] | None = None) -> bytes
def _select_operator_request(template: bytes, fingerprint: str | None) -> bytes: def _select_operator_request(template: bytes, fingerprint: str | None) -> bytes:
if fingerprint and len(fingerprint) == 40: """Inject a current 40-hex fingerprint into a legacy request shape.
old = b"02df398797432eadefcc12767119ad5e80999389"
index = template.find(old) This compatibility path only operates on the shape supplied by a caller;
if index >= 0: it does not contain or search for a particular captured device value.
return ( """
template[:index] if not fingerprint or len(fingerprint) != 40:
+ fingerprint.encode("ascii")
+ template[index + len(old) :]
)
return template return template
match = re.search(rb"(?<![0-9a-f])[0-9a-f]{40}(?![0-9a-f])", template)
if match is None:
return template
return (
template[: match.start()]
+ fingerprint.encode("ascii")
+ template[match.end() :]
)
def _parse_response(data: bytes) -> tuple[str, str, str]: def _parse_response(data: bytes) -> tuple[str, str, str]:
@@ -284,6 +289,9 @@ def register_device(
"""执行新注册链,返回 ``(t1, safedeviceid, device_id)``。""" """执行新注册链,返回 ``(t1, safedeviceid, device_id)``。"""
chain = _load_chain(fingerprint=fingerprint, device_info=device_info) chain = _load_chain(fingerprint=fingerprint, device_info=device_info)
_post(chain["getDfpConfig"][0], timeout=timeout, proxies=proxies) _post(chain["getDfpConfig"][0], timeout=timeout, proxies=proxies)
# The generated request already contains the current profile fingerprint.
# Keep a generic shape-only compatibility patch for injected test/custom
# templates; no captured device value is embedded in this module.
select_request = _select_operator_request(chain["selectOperator"][0], fingerprint) select_request = _select_operator_request(chain["selectOperator"][0], fingerprint)
_post(select_request, "application/x-wup", timeout, proxies) _post(select_request, "application/x-wup", timeout, proxies)
response = _post( response = _post(
+128 -15
View File
@@ -1,6 +1,7 @@
"""wupData 信封构造与补丁工具。 """wupData 信封构造与补丁工具。
解析与改写 WUP 信封中的 cert、uid、session 等字段。 生产二维码信封由 :meth:`Envelope.build_qr` 按当前账号状态编码;
:meth:`Envelope.load` 仅保留协议结构模板兼容测试和离线分析。
""" """
from __future__ import annotations from __future__ import annotations
@@ -10,7 +11,8 @@ import json
import struct import struct
from pathlib import Path from pathlib import Path
from loguru import logger from .taf_protocol import TafOutputStream
from .wup_protocol import WupRequest
INT8, INT16, INT32, INT64 = 0x00, 0x01, 0x02, 0x03 INT8, INT16, INT32, INT64 = 0x00, 0x01, 0x02, 0x03
STRING1, STRING4 = 0x06, 0x07 STRING1, STRING4 = 0x06, 0x07
@@ -18,7 +20,7 @@ MAP, LIST = 0x08, 0x09
STRUCT_BEGIN, STRUCT_END = 0x0A, 0x0B STRUCT_BEGIN, STRUCT_END = 0x0A, 0x0B
ZERO, SIMPLE_LIST = 0x0C, 0x0D ZERO, SIMPLE_LIST = 0x0C, 0x0D
DEFAULT_QURL_B64 = ( PROTOCOL_QURL_TEMPLATE_B64 = (
"AAAD5hADLDxCAFpBBVYMaHV5YXVkYndlYnVpZgdkZWZhdWx0fQABA7gIAAIGCV93dXBfZGF0YR0AAQOKCgoMFgMxLjAm" "AAAD5hADLDxCAFpBBVYMaHV5YXVkYndlYnVpZgdkZWZhdWx0fQABA7gIAAIGCV93dXBfZGF0YR0AAQOKCgoMFgMxLjAm"
"ynsiYXNzb2NpYXRpb25JZCI6MTg0NTQ5MzkyLCJmdW5jTmFtZSI6IiIsImdyb3VwIjowLCJpZCI6MTg0NTQ5MzkyLCJz" "ynsiYXNzb2NpYXRpb25JZCI6MTg0NTQ5MzkyLCJmdW5jTmFtZSI6IiIsImdyb3VwIjowLCJpZCI6MTg0NTQ5MzkyLCJz"
"ZXNzaW9uIjo1OTE0ODg1LCJzdGVwIjowLCJzdGlsbExvZ2luIjpmYWxzZSwidHJhY2VJZCI6IjBiOGYwOThmZjY0YTVi" "ZXNzaW9uIjo1OTE0ODg1LCJzdGVwIjowLCJzdGlsbExvZ2luIjpmYWxzZSwidHJhY2VJZCI6IjBiOGYwOThmZjY0YTVi"
@@ -119,23 +121,105 @@ class Envelope:
@classmethod @classmethod
def load(cls, path: str | Path | None = None) -> Envelope: def load(cls, path: str | Path | None = None) -> Envelope:
"""加载信封模板,支持从文件加载或使用内嵌金样本。""" """加载显式模板,或使用内嵌协议结构模板。
不自动扫描 ``evidence/``,避免历史抓包成为隐式生产输入。
"""
if path: if path:
p = Path(path) p = Path(path)
if p.exists(): if p.exists():
return cls._load_from_path(p) return cls._load_from_path(p)
# 尝试查找 evidence/cert_keycap.json return cls(base64.b64decode(PROTOCOL_QURL_TEMPLATE_B64))
candidate = (
Path(__file__).resolve().parent.parent.parent @classmethod
/ "evidence" def build_qr(
/ "cert_keycap.json" cls,
*,
uid: int,
cert_b64: str,
safedeviceid: str,
session: int,
trace_id: str,
device_info: dict[str, str],
) -> Envelope:
"""按当前账号状态编码二维码绑定信封。
生产二维码请求不应从抓包信封复制字段。这里仅复用已知的 TAF/WUP
字段布局;证书、ACTION、UID、会话和全部设备画像都由本次登录提供。
"""
if len(cert_b64) != 260:
raise ValueError(f"证书 base64 长度异常: {len(cert_b64)} != 260")
if len(safedeviceid) != 180:
raise ValueError(f"safedeviceid 长度异常: {len(safedeviceid)} != 180")
dev = {k: str(v) for k, v in (device_info or {}).items()}
meta = json.dumps(
{
"associationId": 184549392,
"funcName": "",
"group": 0,
"id": 184549392,
"session": int(session),
"step": 0,
"stillLogin": False,
"traceId": str(trace_id),
"type": 2,
"uid": 0,
"userContext": "",
},
ensure_ascii=False,
separators=(",", ":"),
) )
if candidate.exists(): body = TafOutputStream()
try: body.write_struct_begin(0)
return cls._load_from_path(candidate) body.write_struct_begin(0)
except Exception as exc: # noqa: BLE001 body.write_int8(0, 0)
logger.debug(f"加载证书信封候选文件失败: {candidate}: {exc}") body.write_string(1, "1.0")
return cls(base64.b64decode(DEFAULT_QURL_B64)) body.write_string(2, meta)
body.write_string(3, "5008")
body.write_int8(4, 3)
body.write_string(5, safedeviceid)
body.write_string(6, "")
body.write_string(7, "")
body.write_string(8, "")
body.write_string(9, "")
body.write_struct_end()
body.write_struct_begin(1)
body.write_string(0, dev.get("hdid", ""))
body.write_string(1, dev.get("app_version", "13.4.22"))
body.write_string(2, dev.get("sdk_version", "1.0.80138"))
body.write_string(3, "")
body.write_string(4, dev.get("ip", "127.0.0.1"))
body.write_string(5, dev.get("vendor", "android"))
body.write_string(6, "")
body.write_struct_end()
body.write_struct_begin(2)
body.write_int8(0, 1)
body.write_string(1, dev.get("model", ""))
body.write_string(2, dev.get("fingerprint", ""))
body.write_string(3, dev.get("os", "android"))
body.write_string(4, dev.get("screen", ""))
body.write_string(6, dev.get("width", "1080"))
body.write_string(7, dev.get("height", "2120"))
body.write_string(8, dev.get("device_id", ""))
body.write_struct_end()
body.write_uint64(3, int(uid))
body.write_string(4, cert_b64)
body.write_string(5, "")
body.write_string(6, "")
body.write_struct_end()
req = TafOutputStream()
req.write_int32(0, int(session))
wup = WupRequest()
wup.iTimeout = 0
wup.setRequestId(int(session))
wup.setServant("huyaudbwebui")
wup.setFunc("default")
wup.newdata["_wup_data"] = body.get_bytes()
wup.newdata["wupudbrequest_v0"] = req.get_bytes()
return cls(wup.encode())
@classmethod @classmethod
def _load_from_path(cls, p: Path) -> Envelope: def _load_from_path(cls, p: Path) -> Envelope:
@@ -258,6 +342,7 @@ class Envelope:
return self return self
def patch_session(self, session: int) -> Envelope: def patch_session(self, session: int) -> Envelope:
"""Patch the outer WUP session and its request copy."""
if self.tag4_span: if self.tag4_span:
struct.pack_into(">I", self.raw, self.tag4_span[0], session & 0xFFFFFFFF) struct.pack_into(">I", self.raw, self.tag4_span[0], session & 0xFFFFFFFF)
d = self.raw d = self.raw
@@ -268,5 +353,33 @@ class Envelope:
struct.pack_into(">I", d, j, session & 0xFFFFFFFF) struct.pack_into(">I", d, j, session & 0xFFFFFFFF)
return self return self
def patch_meta(self, session: int, trace_id: str) -> Envelope:
"""Replace QR metadata values without carrying the capture's old state.
The captured envelope keeps a fixed-size JSON string. Keeping the
replacement the same size lets us update only the value bytes and
preserve all TAF length prefixes and offsets.
"""
if self.meta_json_span is None:
raise ValueError("信封缺少元数据 JSON")
start, end = self.meta_json_span
current = bytes(self.raw[start:end])
try:
meta = json.loads(current.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise ValueError("信封元数据 JSON 无法解析") from exc
meta["session"] = int(session)
meta["traceId"] = str(trace_id)
updated = json.dumps(meta, ensure_ascii=False, separators=(",", ":")).encode(
"utf-8"
)
if len(updated) != len(current):
raise ValueError(
f"信封元数据长度变化: {len(updated)} != {len(current)}; "
"请使用固定长度 session/traceId"
)
self.raw[start:end] = updated
return self
def wup_b64(self) -> str: def wup_b64(self) -> str:
return base64.b64encode(bytes(self.raw)).decode() return base64.b64encode(bytes(self.raw)).decode()
+4
View File
@@ -13,6 +13,10 @@ function makeEnv(overrides) {
const VW = ov.screenWidth ? Math.round(ov.screenWidth / 2.75) : 393; const VW = ov.screenWidth ? Math.round(ov.screenWidth / 2.75) : 393;
const VH = ov.screenHeight ? Math.round(ov.screenHeight / 2.75) : 851; const VH = ov.screenHeight ? Math.round(ov.screenHeight / 2.75) : 851;
const W = globalThis; const W = globalThis;
// Node 18/20 do not expose a browser navigator; create one before defining
// the properties consumed by hydevice. Newer Node versions already expose
// a Navigator instance, which is retained.
W.navigator = W.navigator || {};
W.screen = {width:VW, height:VH, availWidth:VW, availHeight:VH, W.screen = {width:VW, height:VH, availWidth:VW, availHeight:VH,
colorDepth:24, pixelDepth:24, availLeft:0, availTop:0, orientation:{type:'portrait-primary', angle:0}}; colorDepth:24, pixelDepth:24, availLeft:0, availTop:0, orientation:{type:'portrait-primary', angle:0}};
W.devicePixelRatio = 2.75; W.devicePixelRatio = 2.75;
+8 -2
View File
@@ -6,12 +6,18 @@ const path = require('path');
const https = require('https'); const https = require('https');
globalThis.window = globalThis; globalThis.window = globalThis;
const stateDir = process.argv[2] || '.';
let _deviceOverrides = null; let _deviceOverrides = null;
try { _deviceOverrides = JSON.parse(fs.readFileSync(process.argv[3] || '', 'utf8')); } catch (e) {} // Prefer an explicitly supplied JSON path; otherwise use the device hint that
// get_huya_sdid writes into the per-account state directory.
try {
const hintPath = process.argv[3] && process.argv[3].endsWith('.json')
? process.argv[3] : path.join(stateDir, 'device.json');
_deviceOverrides = JSON.parse(fs.readFileSync(hintPath, 'utf8'));
} catch (e) {}
require(path.join(__dirname, 'env.js')).makeEnv(_deviceOverrides); require(path.join(__dirname, 'env.js')).makeEnv(_deviceOverrides);
// ---- localStorage 持久化(设备稳定性) ---- // ---- localStorage 持久化(设备稳定性) ----
const stateDir = process.argv[2] || '.';
try { fs.mkdirSync(stateDir, {recursive: true}); } catch (e) {} try { fs.mkdirSync(stateDir, {recursive: true}); } catch (e) {}
const lsFile = path.join(stateDir, 'localstorage.json'); const lsFile = path.join(stateDir, 'localstorage.json');
let _ls = {}; let _ls = {};
+6 -1
View File
@@ -7,6 +7,7 @@ from __future__ import annotations
import json import json
import random import random
import re
import struct import struct
import time as _time import time as _time
from typing import Any from typing import Any
@@ -114,7 +115,11 @@ def _build_meta_json(session: int, trace_id: str) -> str:
def _make_name(uid_str: str) -> str: def _make_name(uid_str: str) -> str:
"""登录名 = "hy_" + 虎牙号""" """构造 App 登录名:手机号原样提交,虎牙号使用 ``hy_`` 前缀"""
# App 协议对手机号和虎牙号使用不同的账号命名空间。手机号登录时
# name 就是 11 位手机号;只有数字虎牙号才需要 hy_ 前缀。
if re.fullmatch(r"1\d{10}", uid_str):
return uid_str
if uid_str.startswith("hy_"): if uid_str.startswith("hy_"):
return uid_str return uid_str
return "hy_" + uid_str return "hy_" + uid_str
+33
View File
@@ -13,6 +13,21 @@ if [ -f "$ROOT_DIR/.env" ]; then
set +a set +a
fi fi
# 使用新的项目标识;可通过环境变量覆盖。
COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-live-hub-py}"
export COMPOSE_PROJECT_NAME
# 迁移期间优先复用旧项目卷,避免目录改名导致创建空数据库。
MYSQL_VOLUME_NAME="${MYSQL_VOLUME_NAME:-}"
MYSQL_VOLUME_EXTERNAL="${MYSQL_VOLUME_EXTERNAL:-false}"
if [ -z "$MYSQL_VOLUME_NAME" ] && command -v docker >/dev/null 2>&1 \
&& docker volume inspect douyu_login_py_mysql-data >/dev/null 2>&1; then
MYSQL_VOLUME_NAME="douyu_login_py_mysql-data"
MYSQL_VOLUME_EXTERNAL="true"
fi
export MYSQL_VOLUME_NAME
export MYSQL_VOLUME_EXTERNAL
APP_PORT="${APP_PORT:-8000}" APP_PORT="${APP_PORT:-8000}"
# ── 工具函数 ────────────────────────────────────────────── # ── 工具函数 ──────────────────────────────────────────────
@@ -27,6 +42,20 @@ detect_compose() {
fi fi
} }
stop_legacy_containers() {
local name project
for name in douyu-login-db douyu-login; do
if ! docker inspect "$name" >/dev/null 2>&1; then
continue
fi
project="$(docker inspect "$name" --format '{{index .Config.Labels "com.docker.compose.project"}}' 2>/dev/null || true)"
if [ -n "$project" ] && [ "$project" != "$COMPOSE_PROJECT_NAME" ]; then
echo "停止旧 Compose 项目容器: $name (项目 $project)"
docker stop "$name" >/dev/null
fi
done
}
# ── 子命令 ────────────────────────────────────────────── # ── 子命令 ──────────────────────────────────────────────
cmd_deploy() { cmd_deploy() {
@@ -65,6 +94,7 @@ cmd_deploy() {
# 构建并启动 # 构建并启动
echo "正在构建应用镜像(首次构建约3-5分钟)..." echo "正在构建应用镜像(首次构建约3-5分钟)..."
stop_legacy_containers
# 生产部署不构建含大型开发依赖的 test 镜像;测试镜像仅在本地按需构建。 # 生产部署不构建含大型开发依赖的 test 镜像;测试镜像仅在本地按需构建。
$COMPOSE build douyu-login $COMPOSE build douyu-login
@@ -190,6 +220,7 @@ cmd_migrate_mysql() {
fi fi
echo "正在启动 MySQL..." echo "正在启动 MySQL..."
stop_legacy_containers
$COMPOSE up -d mysql $COMPOSE up -d mysql
echo "等待 MySQL 就绪..." echo "等待 MySQL 就绪..."
@@ -222,6 +253,7 @@ cmd_migrate() {
fi fi
echo "正在启动 MySQL..." echo "正在启动 MySQL..."
stop_legacy_containers
$COMPOSE up -d mysql $COMPOSE up -d mysql
echo "等待 MySQL 就绪..." echo "等待 MySQL 就绪..."
@@ -261,6 +293,7 @@ cmd_restart() {
echo "❌ 未检测到 docker compose" echo "❌ 未检测到 docker compose"
exit 1 exit 1
fi fi
stop_legacy_containers
# 重建镜像和应用容器,使新迁移文件与入口迁移逻辑都能生效。 # 重建镜像和应用容器,使新迁移文件与入口迁移逻辑都能生效。
$COMPOSE up -d --build --force-recreate douyu-login $COMPOSE up -d --build --force-recreate douyu-login
echo "✅ 服务已重建并重启(已自动检查数据库迁移)" echo "✅ 服务已重建并重启(已自动检查数据库迁移)"
+40 -5
View File
@@ -6,6 +6,15 @@ set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")" && pwd)" ROOT_DIR="$(cd "$(dirname "$0")" && pwd)"
cd "$ROOT_DIR" cd "$ROOT_DIR"
# 目录改名后,旧 shell 可能仍携带旧项目的 VIRTUAL_ENV。
# 开发脚本始终使用当前仓库的环境,不要求每次手工指定或重新激活环境。
unset VIRTUAL_ENV
export UV_PROJECT_ENVIRONMENT="$ROOT_DIR/.venv"
# 使用新的项目标识;可通过环境变量覆盖。
COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-live-hub-py}"
export COMPOSE_PROJECT_NAME
if [ -f "$ROOT_DIR/.env" ]; then if [ -f "$ROOT_DIR/.env" ]; then
set -a set -a
# 调试模式复用 Docker 部署的环境变量,尤其是 JWT 和敏感字段加密密钥。 # 调试模式复用 Docker 部署的环境变量,尤其是 JWT 和敏感字段加密密钥。
@@ -14,6 +23,17 @@ if [ -f "$ROOT_DIR/.env" ]; then
set +a set +a
fi fi
# 迁移期间优先复用旧项目卷,避免目录改名导致创建空数据库。
MYSQL_VOLUME_NAME="${MYSQL_VOLUME_NAME:-}"
MYSQL_VOLUME_EXTERNAL="${MYSQL_VOLUME_EXTERNAL:-false}"
if [ -z "$MYSQL_VOLUME_NAME" ] && command -v docker >/dev/null 2>&1 \
&& docker volume inspect douyu_login_py_mysql-data >/dev/null 2>&1; then
MYSQL_VOLUME_NAME="douyu_login_py_mysql-data"
MYSQL_VOLUME_EXTERNAL="true"
fi
export MYSQL_VOLUME_NAME
export MYSQL_VOLUME_EXTERNAL
BACKEND_HOST="${BACKEND_HOST:-0.0.0.0}" BACKEND_HOST="${BACKEND_HOST:-0.0.0.0}"
# 8800 是项目默认开发端口并由脚本独占;其他显式端口发生冲突时只报错。 # 8800 是项目默认开发端口并由脚本独占;其他显式端口发生冲突时只报错。
BACKEND_PORT_EXPLICIT=false BACKEND_PORT_EXPLICIT=false
@@ -58,22 +78,22 @@ fi
# --group dev 确保 pytest 等 dev 依赖已安装。 # --group dev 确保 pytest 等 dev 依赖已安装。
if [ "${1:-}" = "test" ]; then if [ "${1:-}" = "test" ]; then
shift shift
exec uv run --group dev pytest "$@" exec uv run --group dev python -m pytest "$@"
fi fi
if [ "${1:-}" = "format" ]; then if [ "${1:-}" = "format" ]; then
shift shift
exec uv run --group dev ruff format "$@" exec uv run --group dev python -m ruff format "$@"
fi fi
if [ "${1:-}" = "format-check" ]; then if [ "${1:-}" = "format-check" ]; then
shift shift
exec uv run --group dev ruff format --check "$@" exec uv run --group dev python -m ruff format --check "$@"
fi fi
if [ "${1:-}" = "type-check" ]; then if [ "${1:-}" = "type-check" ]; then
shift shift
exec uv run --group dev pyright "$@" exec uv run --group dev python -m pyright "$@"
fi fi
BACKEND_PID="" BACKEND_PID=""
@@ -121,6 +141,20 @@ detect_compose() {
fi fi
} }
stop_legacy_containers() {
local name project
for name in douyu-login-db douyu-login; do
if ! docker inspect "$name" >/dev/null 2>&1; then
continue
fi
project="$(docker inspect "$name" --format '{{index .Config.Labels "com.docker.compose.project"}}' 2>/dev/null || true)"
if [ -n "$project" ] && [ "$project" != "$COMPOSE_PROJECT_NAME" ]; then
echo "停止旧 Compose 项目容器: $name (项目 $project)"
docker stop "$name" >/dev/null
fi
done
}
detect_backend_proxy_host() { detect_backend_proxy_host() {
if [ "$BACKEND_HOST" != "0.0.0.0" ] && [ "$BACKEND_HOST" != "::" ]; then if [ "$BACKEND_HOST" != "0.0.0.0" ] && [ "$BACKEND_HOST" != "::" ]; then
echo "$BACKEND_HOST" echo "$BACKEND_HOST"
@@ -263,6 +297,7 @@ start_yyb_worker() {
} }
echo "正在启动本地 MySQL..." echo "正在启动本地 MySQL..."
stop_legacy_containers
MYSQL_IMAGE="$MYSQL_IMAGE" \ MYSQL_IMAGE="$MYSQL_IMAGE" \
MYSQL_BIND_HOST="$MYSQL_BIND_HOST" \ MYSQL_BIND_HOST="$MYSQL_BIND_HOST" \
MYSQL_HOST_PORT="$MYSQL_HOST_PORT" \ MYSQL_HOST_PORT="$MYSQL_HOST_PORT" \
@@ -313,7 +348,7 @@ echo ""
export DB_POOL_SIZE DB_MAX_OVERFLOW DB_POOL_TIMEOUT DB_POOL_RECYCLE export DB_POOL_SIZE DB_MAX_OVERFLOW DB_POOL_TIMEOUT DB_POOL_RECYCLE
export YYB_WORKER_KEY LOG_LEVEL LOG_DIR export YYB_WORKER_KEY LOG_LEVEL LOG_DIR
export YYB_WORKER_URL="$DEV_YYB_WORKER_URL" export YYB_WORKER_URL="$DEV_YYB_WORKER_URL"
uv run uvicorn web.backend.main:app \ uv run python -m uvicorn web.backend.main:app \
--host "$BACKEND_HOST" \ --host "$BACKEND_HOST" \
--port "$BACKEND_PORT" \ --port "$BACKEND_PORT" \
--reload \ --reload \
+3 -3
View File
@@ -7,7 +7,6 @@ services:
args: args:
# 服务器默认走阿里云镜像;本地测试设为 false。 # 服务器默认走阿里云镜像;本地测试设为 false。
USE_CHINA_MIRRORS: ${USE_CHINA_MIRRORS:-true} USE_CHINA_MIRRORS: ${USE_CHINA_MIRRORS:-true}
container_name: douyu-login
restart: unless-stopped restart: unless-stopped
ports: ports:
# 保持现有服务端口;可通过 APP_PORT 覆盖。 # 保持现有服务端口;可通过 APP_PORT 覆盖。
@@ -86,12 +85,10 @@ services:
args: args:
USE_CHINA_MIRRORS: ${USE_CHINA_MIRRORS:-true} USE_CHINA_MIRRORS: ${USE_CHINA_MIRRORS:-true}
profiles: ["test"] profiles: ["test"]
container_name: douyu-login-test
entrypoint: ["python", "-m", "pytest"] entrypoint: ["python", "-m", "pytest"]
mysql: mysql:
image: ${MYSQL_IMAGE:-docker.m.daocloud.io/library/mysql:8.4} image: ${MYSQL_IMAGE:-docker.m.daocloud.io/library/mysql:8.4}
container_name: douyu-login-db
restart: unless-stopped restart: unless-stopped
ports: ports:
# 仅绑定宿主机本地地址,供 ./dev.sh 的本地后端连接,不对外网开放。 # 仅绑定宿主机本地地址,供 ./dev.sh 的本地后端连接,不对外网开放。
@@ -121,6 +118,9 @@ services:
volumes: volumes:
mysql-data: mysql-data:
# 目录/项目改名时优先复用旧项目的数据卷;新环境默认使用新项目名卷。
name: ${MYSQL_VOLUME_NAME:-live-hub-py_mysql-data}
external: ${MYSQL_VOLUME_EXTERNAL:-false}
networks: networks:
order-site-net: order-site-net:
@@ -16,7 +16,7 @@
| **msgType** | 4097 (0x1001) = `MsgLogin.mMsgId` | | **msgType** | 4097 (0x1001) = `MsgLogin.mMsgId` |
| **请求大小** | 1356 字节 | | **请求大小** | 1356 字节 |
| **密码** | SHA1(明文) hex,无盐(`772ed992b0e161276f44ec63671e60155c506294` | | **密码** | SHA1(明文) hex,无盐(`772ed992b0e161276f44ec63671e60155c506294` |
| **name** | `hy_300023887``hy_` + 虎牙号) | | **name** | 虎牙号为 `hy_<虎牙号>`;手机号登录时为 11 位手机号原值 |
--- ---
@@ -92,7 +92,7 @@ _wup_data = STRUCT {
tag8 = "7c5387e0539c023c31c4ff0e807e7256117385ee" # 另一设备ID(40 hex) tag8 = "7c5387e0539c023c31c4ff0e807e7256117385ee" # 另一设备ID(40 hex)
} }
field3 = STRUCT { # 登录凭证 field3 = STRUCT { # 登录凭证
tag3 = "hy_300023887" # name (hy_虎牙号) tag3 = "hy_300023887" # name (虎牙号示例;手机号登录时直接放手机号)
tag4 = "772ed992b0e161276f44ec63671e60155c506294" # password = SHA1(明文) tag4 = "772ed992b0e161276f44ec63671e60155c506294" # password = SHA1(明文)
tag5 = LIST["5008"] # appid列表 tag5 = LIST["5008"] # appid列表
tag6 = 1 (INT8) tag6 = 1 (INT8)
@@ -151,7 +151,7 @@ userAction JSON:
7c5387e0539c023c31c4ff0e807e7256117385ee ← 另一个设备ID 7c5387e0539c023c31c4ff0e807e7256117385ee ← 另一个设备ID
登录凭证: 登录凭证:
hy_300023887 ← name hy_300023887 ← name (虎牙号示例;手机号登录时直接放手机号)
772ed992b0e161276f44ec63671e60155c506294 ← password (SHA1) 772ed992b0e161276f44ec63671e60155c506294 ← password (SHA1)
结尾: 结尾:
+1 -1
View File
@@ -1,5 +1,5 @@
[project] [project]
name = "douyu-login-py" name = "live-hub-py"
version = "0.2.1" version = "0.2.1"
description = "直播账号运营 Web 后台" description = "直播账号运营 Web 后台"
readme = "README.md" readme = "README.md"
@@ -1,12 +1,16 @@
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { JSDOM, ResourceLoader } from 'jsdom'; import { JSDOM, ResourceLoader } from 'jsdom';
import { patchEnvironment } from './scripts/jsdom-patch-env.mjs'; import { patchEnvironment } from './scripts/jsdom-patch-env.mjs';
const ROOT = '/Users/yml/codes/douyu_login_py'; // Resolve the repository from this file so the probe works after a checkout
// is renamed or moved to another host.
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..');
const taskId = process.argv[2] || '142265ef3d874ce6'; const taskId = process.argv[2] || '142265ef3d874ce6';
const out = `${ROOT}/data/yyb-worker-dev/${taskId}/jsdom-order`; const out = path.join(ROOT, 'data', 'yyb-worker-dev', taskId, 'jsdom-order');
const goodsUrl = JSON.parse(fs.readFileSync(`${out}/device-fp.json`, 'utf8')).goods_url; const goodsUrl = JSON.parse(fs.readFileSync(path.join(out, 'device-fp.json'), 'utf8')).goods_url;
const pageInfoPath = process.argv[3] || `${out}/web-page-info-response.json`; const pageInfoPath = process.argv[3] || path.join(out, 'web-page-info-response.json');
const pageInfo = fs.existsSync(pageInfoPath) const pageInfo = fs.existsSync(pageInfoPath)
? fs.readFileSync(pageInfoPath, 'utf8') ? fs.readFileSync(pageInfoPath, 'utf8')
: '{"ret":0,"msg":"","info":{}}'; : '{"ret":0,"msg":"","info":{}}';
@@ -14,7 +18,7 @@ const pageInfo = fs.existsSync(pageInfoPath)
const captured = []; const captured = [];
const requests = []; const requests = [];
const pageErrors = []; const pageErrors = [];
const dom = new JSDOM(fs.readFileSync(`${out}/goods.html`, 'utf8'), { const dom = new JSDOM(fs.readFileSync(path.join(out, 'goods.html'), 'utf8'), {
url: goodsUrl, url: goodsUrl,
referrer: 'https://z.iwan.yyb.qq.com/', referrer: 'https://z.iwan.yyb.qq.com/',
pretendToBeVisual: true, pretendToBeVisual: true,
+147 -9
View File
@@ -1,5 +1,6 @@
"""虎牙 App 密码登录及相关组件测试。""" """虎牙 App 密码登录及相关组件测试。"""
import json
import os import os
import struct import struct
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
@@ -11,19 +12,21 @@ from sqlalchemy.orm import sessionmaker
from core.huya import ( from core.huya import (
HuyaAppLoginError, HuyaAppLoginError,
) )
from core.huya.app_login import ( from core.huya.app_login import login_cred_with_flow, wup_password_login_raw
DEFAULT_GOLDEN_DEV,
login_cred_with_flow,
wup_password_login_raw,
)
from core.huya.cert_forge import build_p1, decrypt_cert, forge_cert, parse_p1 from core.huya.cert_forge import build_p1, decrypt_cert, forge_cert, parse_p1
from core.huya.device_profile import generate_profile, get_profile from core.huya.device_fingerprint import account_state_dir, reset_account_state
from core.huya.device_profile import (
canonical_account_key,
generate_profile,
get_profile,
)
from core.huya.dfp_register import DfpRegistrationError from core.huya.dfp_register import DfpRegistrationError
from core.huya.envelope_forge import Envelope from core.huya.envelope_forge import Envelope
from core.huya.login import HuyaLoginResult from core.huya.login import HuyaLoginResult
from core.huya.nonce_forge import K1_DEFAULT, gen_nonce from core.huya.nonce_forge import K1_DEFAULT, gen_nonce
from core.huya.udb_aes import udb_decrypt, udb_encrypt from core.huya.udb_aes import udb_decrypt, udb_encrypt
from core.huya.wup_encoder import build_password_login_wup from core.huya.wup_encoder import _make_name, build_password_login_wup
from core.huya.wup_protocol import WupResponse
from web.backend.database import Base from web.backend.database import Base
from web.backend.models import HuyaAccount, User from web.backend.models import HuyaAccount, User
from web.backend.routers.huya import ( from web.backend.routers.huya import (
@@ -90,6 +93,48 @@ class TestHuyaAppLogin:
wup_b64 = env.wup_b64() wup_b64 = env.wup_b64()
assert len(wup_b64) > 0 assert len(wup_b64) > 0
def test_envelope_metadata_is_fresh(self):
env = Envelope.load()
old = bytes(env.raw[env.meta_json_span[0] : env.meta_json_span[1]])
new_trace = "a" * 16 + "-12345-" + "9" * 20
env.patch_session(7654321).patch_meta(7654321, new_trace)
current = bytes(env.raw[env.meta_json_span[0] : env.meta_json_span[1]])
assert current != old
assert b'"session":7654321' in current
assert (b'"traceId":"' + new_trace.encode() + b'"') in current
def test_qr_envelope_is_fully_dynamic(self):
profile = {
"hdid": "h" * 32,
"app_version": "13.4.22",
"sdk_version": "1.0.80138",
"ip": "10.0.0.8",
"vendor": "vivo",
"model": "V2370A",
"fingerprint": "f" * 40,
"os": "android",
"screen": "V2370A,34,13",
"width": "1080",
"height": "2412",
"device_id": "d" * 40,
}
env = Envelope.build_qr(
uid=1199664135026,
cert_b64="A" * 260,
safedeviceid="B" * 180,
session=7654321,
trace_id="t" * 16 + "-12345-" + "9" * 20,
device_info=profile,
)
wup = WupResponse()
wup.decode(bytes(env.raw))
body = wup.newdata["_wup_data"]
for value in (b"B" * 180, b"h" * 32, b"f" * 40, b"V2370A", b"d" * 40):
assert value in body
assert b"PQwemAN9NHkZKoMq" not in body
assert b"02df398797432eadefcc12767119ad5e80999389" not in body
assert b"7c5387e0539c023c31c4ff0e807e7256117385ee" not in body
def test_device_profile_generation(self): def test_device_profile_generation(self):
p1 = generate_profile() p1 = generate_profile()
assert p1["os"] == "android" assert p1["os"] == "android"
@@ -98,12 +143,46 @@ class TestHuyaAppLogin:
assert p1["hdid"] == "ed0db8334cadd236c00cadf7e11ab5a5" assert p1["hdid"] == "ed0db8334cadd236c00cadf7e11ab5a5"
# 画像不再承载 safedeviceid:该令牌由 dfp_register 注册链每次登录前签发 # 画像不再承载 safedeviceid:该令牌由 dfp_register 注册链每次登录前签发
assert "safedeviceid" not in p1 assert "safedeviceid" not in p1
assert "safedeviceid" not in DEFAULT_GOLDEN_DEV
p2 = get_profile("test_user_account_123") p2 = get_profile("test_user_account_123")
p3 = get_profile("test_user_account_123") p3 = get_profile("test_user_account_123")
assert p2["fingerprint"] == p3["fingerprint"] assert p2["fingerprint"] == p3["fingerprint"]
def test_huya_id_aliases_share_one_profile_key(self, tmp_path, monkeypatch):
profile_db = tmp_path / "profiles.json"
monkeypatch.setattr("core.huya.device_profile.PRIMARY_PROFILE_DB", profile_db)
monkeypatch.setattr(
"core.huya.device_profile.FALLBACK_PROFILE_DB", tmp_path / "missing.json"
)
assert canonical_account_key(" hy_300023887 ") == "300023887"
first = get_profile("300023887")
second = get_profile("hy_300023887")
assert first["fingerprint"] == second["fingerprint"]
assert list(json.loads(profile_db.read_text())) == ["300023887"]
def test_legacy_evidence_profile_is_not_loaded_by_default(
self, tmp_path, monkeypatch
):
primary = tmp_path / "profiles.json"
legacy = tmp_path / "legacy.json"
legacy.write_text(json.dumps({"old": {"fingerprint": "f" * 40}}))
monkeypatch.setattr("core.huya.device_profile.PRIMARY_PROFILE_DB", primary)
monkeypatch.setattr("core.huya.device_profile.FALLBACK_PROFILE_DB", legacy)
profile = get_profile("old")
assert profile["fingerprint"] != "f" * 40
def test_force_new_device_clears_hydevice_state(self, tmp_path, monkeypatch):
monkeypatch.setattr(
"core.huya.device_fingerprint.FP_STATE_ROOT", tmp_path / "fp"
)
state = account_state_dir("hy_test")
state.mkdir(parents=True)
(state / "localstorage.json").write_text("{}", encoding="utf-8")
(state / "device.json").write_text("{}", encoding="utf-8")
reset_account_state("hy_test")
assert not (state / "localstorage.json").exists()
assert not (state / "device.json").exists()
def test_wup_encoder_output(self): def test_wup_encoder_output(self):
dev = generate_profile() dev = generate_profile()
pkt = build_password_login_wup( pkt = build_password_login_wup(
@@ -121,6 +200,12 @@ class TestHuyaAppLogin:
total_len = struct.unpack(">I", pkt[:4])[0] total_len = struct.unpack(">I", pkt[:4])[0]
assert total_len == len(pkt) assert total_len == len(pkt)
def test_app_login_name_keeps_mobile_number(self):
"""App 协议手机号登录名不能套用虎牙号的 hy_ 前缀。"""
assert _make_name("15197635967") == "15197635967"
assert _make_name("300023887") == "hy_300023887"
assert _make_name("hy_300023887") == "hy_300023887"
# ---- 新设备注册链 (core/huya/dfp_register) 生产接入测试 ---- # ---- 新设备注册链 (core/huya/dfp_register) 生产接入测试 ----
def test_wup_login_skips_registration_when_safedeviceid_given(self): def test_wup_login_skips_registration_when_safedeviceid_given(self):
@@ -165,7 +250,9 @@ class TestHuyaAppLogin:
assert captured["args"][2] == new_action assert captured["args"][2] == new_action
assert captured["args"][7]["device_id"] == new_device_id assert captured["args"][7]["device_id"] == new_device_id
# 画像默认值里的旧 device_id 被注册结果覆盖,而非沿用 # 画像默认值里的旧 device_id 被注册结果覆盖,而非沿用
assert captured["args"][7]["device_id"] != DEFAULT_GOLDEN_DEV["device_id"] assert captured["args"][7]["device_id"] != (
"7c5387e0539c023c31c4ff0e807e7256117385ee"
)
def test_wup_login_registration_failure_is_explicit(self): def test_wup_login_registration_failure_is_explicit(self):
"""注册失败必须抛错终止,禁止静默回退旧链(不发任何登录请求)。""" """注册失败必须抛错终止,禁止静默回退旧链(不发任何登录请求)。"""
@@ -192,6 +279,57 @@ class TestHuyaAppLogin:
): ):
login_cred_with_flow("300023887", "pw") login_cred_with_flow("300023887", "pw")
def test_login_cred_flow_reuses_wup_session_after_slider(self):
"""滑块通过后的 WUP 重发必须沿用首次请求的会话上下文。"""
assets = ({"session": 123}, "ua", "")
responses = [b"risk", b"success"]
calls = []
def fake_wup(*args, **kwargs):
calls.append(kwargs["session_assets"])
return responses.pop(0)
with (
patch(
"core.huya.app_login.register_device",
return_value=("a" * 32, "A" * 180, "c" * 40),
),
patch("core.huya.app_login._golden_session_assets", return_value=assets),
patch("core.huya.app_login.wup_password_login_raw", side_effect=fake_wup),
patch("core.huya.app_login.parse_cred", side_effect=[None, b"c" * 114]),
patch(
"core.huya.app_login.parse_risk_url",
return_value="https://aq.huya.com/p/safe_auth/pt_auth.html?param=x",
),
patch("core.huya.app_login.solve_safe_auth", return_value={"authId": "id"}),
patch("core.huya.app_login.parse_real_uid", return_value=1199666914671),
):
cred, uid, safedeviceid = login_cred_with_flow(
"300023887", "pw", include_device_token=True
)
assert cred == b"c" * 114
assert uid == 1199666914671
assert safedeviceid == "A" * 180
assert calls == [assets, assets]
def test_login_cred_flow_maps_invalid_password_response(self):
"""服务端明确返回账号密码错误时,不再显示泛化的无凭据提示。"""
with (
patch(
"core.huya.app_login.register_device",
return_value=("a" * 32, "A" * 180, "c" * 40),
),
patch(
"core.huya.app_login.wup_password_login_raw",
return_value=b"F!LGN_INFO_INVALID_USER_OR_PASSWORDV",
),
patch("core.huya.app_login.parse_cred", return_value=None),
patch("core.huya.app_login.parse_risk_url", return_value=None),
pytest.raises(HuyaAppLoginError, match="账号或密码错误"),
):
login_cred_with_flow("300023887", "pw")
def test_router_functions(self): def test_router_functions(self):
mock_res = HuyaLoginResult( mock_res = HuyaLoginResult(
success=True, success=True,
Generated
+67 -67
View File
@@ -224,73 +224,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/15/ea/81cf3858b256494b31a554cf76bbd345def3ea7e7a1a592cc515633b4e28/curl_cffi-0.16.0-cp313-abi3-android_24_arm64_v8a.whl", hash = "sha256:06b1c7e07af8ff7c4c5ce4086ea89cc582ebff9adff4a37cfffa5f5de5d5b943", size = 8603463, upload-time = "2026-08-01T13:44:55.003Z" }, { url = "https://files.pythonhosted.org/packages/15/ea/81cf3858b256494b31a554cf76bbd345def3ea7e7a1a592cc515633b4e28/curl_cffi-0.16.0-cp313-abi3-android_24_arm64_v8a.whl", hash = "sha256:06b1c7e07af8ff7c4c5ce4086ea89cc582ebff9adff4a37cfffa5f5de5d5b943", size = 8603463, upload-time = "2026-08-01T13:44:55.003Z" },
] ]
[[package]]
name = "douyu-login-py"
version = "0.2.1"
source = { editable = "." }
dependencies = [
{ name = "alembic" },
{ name = "bcrypt" },
{ name = "curl-cffi" },
{ name = "fastapi" },
{ name = "loguru" },
{ name = "numpy" },
{ name = "onnxruntime" },
{ name = "opencv-python-headless" },
{ name = "pillow" },
{ name = "pycryptodome" },
{ name = "pydantic" },
{ name = "pyexecjs" },
{ name = "pymysql" },
{ name = "python-jose", extra = ["cryptography"] },
{ name = "python-multipart" },
{ name = "requests", extra = ["socks"] },
{ name = "scipy" },
{ name = "segno" },
{ name = "sqlalchemy" },
{ name = "uvicorn", extra = ["standard"] },
{ name = "websockets" },
]
[package.dev-dependencies]
dev = [
{ name = "pyright" },
{ name = "pytest" },
{ name = "ruff" },
]
[package.metadata]
requires-dist = [
{ name = "alembic", specifier = ">=1.18.4" },
{ name = "bcrypt", specifier = ">=4.0.0" },
{ name = "curl-cffi", specifier = ">=0.13" },
{ name = "fastapi", specifier = ">=0.110.0" },
{ name = "loguru", specifier = ">=0.7.0" },
{ name = "numpy", specifier = ">=1.24.0" },
{ name = "onnxruntime", specifier = ">=1.18.0" },
{ name = "opencv-python-headless", specifier = ">=4.8.0" },
{ name = "pillow", specifier = ">=10.0.0" },
{ name = "pycryptodome", specifier = ">=3.19.0" },
{ name = "pydantic", specifier = ">=2.0.0" },
{ name = "pyexecjs", specifier = ">=1.5.1" },
{ name = "pymysql", specifier = ">=1.1,<2" },
{ name = "python-jose", extras = ["cryptography"], specifier = ">=3.3.0" },
{ name = "python-multipart", specifier = ">=0.0.9" },
{ name = "requests", extras = ["socks"], specifier = ">=2.31.0" },
{ name = "scipy", specifier = ">=1.13.0" },
{ name = "segno", specifier = ">=1.6" },
{ name = "sqlalchemy", specifier = ">=2.0.0" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.27.0" },
{ name = "websockets", specifier = ">=16.0" },
]
[package.metadata.requires-dev]
dev = [
{ name = "pyright", specifier = ">=1.1.411" },
{ name = "pytest", specifier = ">=9.1.1" },
{ name = "ruff", specifier = ">=0.12.0" },
]
[[package]] [[package]]
name = "ecdsa" name = "ecdsa"
version = "0.19.2" version = "0.19.2"
@@ -385,6 +318,73 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
] ]
[[package]]
name = "live-hub-py"
version = "0.2.1"
source = { editable = "." }
dependencies = [
{ name = "alembic" },
{ name = "bcrypt" },
{ name = "curl-cffi" },
{ name = "fastapi" },
{ name = "loguru" },
{ name = "numpy" },
{ name = "onnxruntime" },
{ name = "opencv-python-headless" },
{ name = "pillow" },
{ name = "pycryptodome" },
{ name = "pydantic" },
{ name = "pyexecjs" },
{ name = "pymysql" },
{ name = "python-jose", extra = ["cryptography"] },
{ name = "python-multipart" },
{ name = "requests", extra = ["socks"] },
{ name = "scipy" },
{ name = "segno" },
{ name = "sqlalchemy" },
{ name = "uvicorn", extra = ["standard"] },
{ name = "websockets" },
]
[package.dev-dependencies]
dev = [
{ name = "pyright" },
{ name = "pytest" },
{ name = "ruff" },
]
[package.metadata]
requires-dist = [
{ name = "alembic", specifier = ">=1.18.4" },
{ name = "bcrypt", specifier = ">=4.0.0" },
{ name = "curl-cffi", specifier = ">=0.13" },
{ name = "fastapi", specifier = ">=0.110.0" },
{ name = "loguru", specifier = ">=0.7.0" },
{ name = "numpy", specifier = ">=1.24.0" },
{ name = "onnxruntime", specifier = ">=1.18.0" },
{ name = "opencv-python-headless", specifier = ">=4.8.0" },
{ name = "pillow", specifier = ">=10.0.0" },
{ name = "pycryptodome", specifier = ">=3.19.0" },
{ name = "pydantic", specifier = ">=2.0.0" },
{ name = "pyexecjs", specifier = ">=1.5.1" },
{ name = "pymysql", specifier = ">=1.1,<2" },
{ name = "python-jose", extras = ["cryptography"], specifier = ">=3.3.0" },
{ name = "python-multipart", specifier = ">=0.0.9" },
{ name = "requests", extras = ["socks"], specifier = ">=2.31.0" },
{ name = "scipy", specifier = ">=1.13.0" },
{ name = "segno", specifier = ">=1.6" },
{ name = "sqlalchemy", specifier = ">=2.0.0" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.27.0" },
{ name = "websockets", specifier = ">=16.0" },
]
[package.metadata.requires-dev]
dev = [
{ name = "pyright", specifier = ">=1.1.411" },
{ name = "pytest", specifier = ">=9.1.1" },
{ name = "ruff", specifier = ">=0.12.0" },
]
[[package]] [[package]]
name = "loguru" name = "loguru"
version = "0.7.3" version = "0.7.3"
+1 -1
View File
@@ -19,6 +19,6 @@ def get_app_version() -> str:
return version return version
try: try:
return package_version("douyu-login-py") return package_version("live-hub-py")
except PackageNotFoundError: except PackageNotFoundError:
return "0.0.0" return "0.0.0"