Compare commits

...
7 Commits
169 changed files with 2607 additions and 1599 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"
+8 -13
View File
@@ -2,21 +2,16 @@
## Ruff lint 清理 ## Ruff lint 清理
记录日期:2026-08-30 状态:已完成
`uv run ruff check .` 当前发现 1263 条 lint 诊断。该事项暂不在本次处理,后续按批次清理并逐批运行 pytest、Pyright 和 Ruff 检查。 完成日期:2026-08-31
建议顺序: 本轮完成了 Ruff lint 全量清理,包括未使用导入与变量、导入排序、类型注解现代化、FastAPI 依赖声明、异常处理标注、时区处理及其他规则。FastAPI 路由参数中的依赖调用通过 `web/backend/**/*.py` 的 B008 配置例外保留框架惯用写法;非 FastAPI 的可变默认对象已改为函数内惰性初始化。
1. `F401``F841`:未使用导入和变量。 最终基线:
2. `BLE001``S110``S112`:异常处理质量。
3. `B008`:FastAPI 依赖声明模式,区分真实问题与框架惯用写法。
4. `I001`:导入排序。
5. `UP045``UP007``UP017``UP035``UP006`:类型注解和 Python 版本语法现代化。
当前基线: - Ruff lint0 条
- Ruff format208 个文件全部通过
- Ruff lint1263 条 - pytest105 passed
- Ruff format207 个文件全部通过
- pytest104 passed
- Pyright0 errors / 0 warnings - Pyright0 errors / 0 warnings
- Python compileall:通过
+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
+10 -10
View File
@@ -1,11 +1,11 @@
"""斗鱼登录模块""" """斗鱼登录模块"""
from .login import DouyuLogin, CredentialError
from .login_api import LoginAPIStrategy
from .login_api_wgapi import WgapiLoginAPI
from .login_api_iframe import IframeLoginAPI
from .email_verifier import EmailVerifier
from .activity_client import DouyuActivityClient, DouyuActivityError from .activity_client import DouyuActivityClient, DouyuActivityError
from .email_verifier import EmailVerifier
from .login import CredentialError, DouyuLogin
from .login_api import LoginAPIStrategy
from .login_api_iframe import IframeLoginAPI
from .login_api_wgapi import WgapiLoginAPI
from .recharge_api import ( from .recharge_api import (
FishFinRechargeClient, FishFinRechargeClient,
FishFinRechargeConfig, FishFinRechargeConfig,
@@ -14,16 +14,16 @@ from .recharge_api import (
) )
__all__ = [ __all__ = [
"DouyuLogin",
"CredentialError", "CredentialError",
"LoginAPIStrategy",
"WgapiLoginAPI",
"IframeLoginAPI",
"EmailVerifier",
"DouyuActivityClient", "DouyuActivityClient",
"DouyuActivityError", "DouyuActivityError",
"DouyuLogin",
"EmailVerifier",
"FishFinRechargeClient", "FishFinRechargeClient",
"FishFinRechargeConfig", "FishFinRechargeConfig",
"FishFinRechargeConfigError", "FishFinRechargeConfigError",
"FishFinRechargeError", "FishFinRechargeError",
"IframeLoginAPI",
"LoginAPIStrategy",
"WgapiLoginAPI",
] ]
+1 -3
View File
@@ -18,7 +18,6 @@ from .cookie_utils import (
normalize_douyu_cookie, normalize_douyu_cookie,
) )
PC_UA = ( PC_UA = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) " "AppleWebKit/537.36 (KHTML, like Gecko) "
@@ -1446,8 +1445,7 @@ class DouyuActivityClient:
if not match: if not match:
raise DouyuActivityError(f"响应中找不到 var {varname}=: {text[:200]}") raise DouyuActivityError(f"响应中找不到 var {varname}=: {text[:200]}")
chunk = text[match.end() :].strip() chunk = text[match.end() :].strip()
if chunk.endswith(";"): chunk = chunk.removesuffix(";")
chunk = chunk[:-1]
try: try:
return json.loads(chunk) return json.loads(chunk)
except json.JSONDecodeError as exc: except json.JSONDecodeError as exc:
+1 -1
View File
@@ -44,7 +44,7 @@ class CookieEnricher:
self.generate_acf_ccn_cookie() self.generate_acf_ccn_cookie()
logger.info("补CK完成") logger.info("补CK完成")
return return
except Exception as e: except Exception as e: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
last_error = e last_error = e
logger.warning(f"补CK失败 {attempt}/{max_attempts}: {e}") logger.warning(f"补CK失败 {attempt}/{max_attempts}: {e}")
if attempt < max_attempts: if attempt < max_attempts:
+2 -4
View File
@@ -2,11 +2,9 @@
import base64 import base64
import hashlib import hashlib
from Crypto.PublicKey import RSA
from Crypto.Cipher import PKCS1_v1_5
from Crypto.Cipher import AES
from Crypto.Cipher import ARC4
from Crypto.Cipher import AES, ARC4, PKCS1_v1_5
from Crypto.PublicKey import RSA
# 斗鱼RSA公钥(从JS中提取) # 斗鱼RSA公钥(从JS中提取)
DOUYU_RSA_PUBLIC_KEY = """-----BEGIN PUBLIC KEY----- DOUYU_RSA_PUBLIC_KEY = """-----BEGIN PUBLIC KEY-----
+28 -27
View File
@@ -5,12 +5,10 @@ import os
import re import re
import threading import threading
import time import time
from datetime import datetime, timedelta from datetime import UTC, datetime, timedelta
from typing import Optional
from loguru import logger
import requests import requests
from loguru import logger
# Roundcube Webmail 地址。可通过环境变量覆盖。空值时回退到默认。 # Roundcube Webmail 地址。可通过环境变量覆盖。空值时回退到默认。
ROUNDCUBE_URL = os.getenv("MAIL_ROUNDCUBE_URL") or "http://111.229.206.54:8000/" ROUNDCUBE_URL = os.getenv("MAIL_ROUNDCUBE_URL") or "http://111.229.206.54:8000/"
@@ -40,7 +38,7 @@ class EmailVerifier:
max_messages: int = 20, max_messages: int = 20,
use_ssl: bool = False, use_ssl: bool = False,
roundcube_url: str = "", roundcube_url: str = "",
backup_passwords: Optional[list[str]] = None, backup_passwords: list[str] | None = None,
): ):
# 兼容旧参数名 # 兼容旧参数名
self.imap_server = imap_server self.imap_server = imap_server
@@ -54,14 +52,13 @@ class EmailVerifier:
) )
# Roundcube 会话(懒初始化) # Roundcube 会话(懒初始化)
self._rc_session: Optional[requests.Session] = None self._rc_session: requests.Session | None = None
self._rc_token: Optional[str] = None self._rc_token: str | None = None
self._rc_logged_in = False self._rc_logged_in = False
self._rc_login_error = "" self._rc_login_error = ""
def connect(self) -> None: def connect(self) -> None:
"""兼容接口""" """兼容接口"""
pass
def disconnect(self) -> None: def disconnect(self) -> None:
"""关闭 Roundcube 会话""" """关闭 Roundcube 会话"""
@@ -71,8 +68,8 @@ class EmailVerifier:
f"{self.roundcube_url}?_task=logout", f"{self.roundcube_url}?_task=logout",
timeout=5, timeout=5,
) )
except Exception: except Exception as exc: # noqa: BLE001
pass logger.debug(f"Roundcube logout failed: {exc}")
self._rc_session = None self._rc_session = None
self._rc_logged_in = False self._rc_logged_in = False
@@ -179,7 +176,7 @@ class EmailVerifier:
logger.debug(f"Roundcube: {password_name}登录成功 ({self.username})") logger.debug(f"Roundcube: {password_name}登录成功 ({self.username})")
return True, False return True, False
except Exception as e: except Exception as e: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
logger.warning(f"Roundcube: 登录异常: {e}") logger.warning(f"Roundcube: 登录异常: {e}")
return False, False return False, False
@@ -233,13 +230,13 @@ class EmailVerifier:
return messages return messages
except Exception as e: except Exception as e: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
logger.warning(f"Roundcube: 获取邮件列表失败: {e}") logger.warning(f"Roundcube: 获取邮件列表失败: {e}")
return [] return []
# ── Roundcube 读取邮件内容 ───────────────────────────── # ── Roundcube 读取邮件内容 ─────────────────────────────
def _rc_fetch_email_body(self, uid: int) -> Optional[str]: def _rc_fetch_email_body(self, uid: int) -> str | None:
"""通过 Roundcube 读取指定 UID 邮件的正文""" """通过 Roundcube 读取指定 UID 邮件的正文"""
if not self._ensure_roundcube_session(): if not self._ensure_roundcube_session():
if self._rc_login_error: if self._rc_login_error:
@@ -255,14 +252,14 @@ class EmailVerifier:
) )
return resp.text return resp.text
except Exception as e: except Exception as e: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
logger.warning(f"Roundcube: 读取邮件 {uid} 失败: {e}") logger.warning(f"Roundcube: 读取邮件 {uid} 失败: {e}")
return None return None
# ── Roundcube 日期解析 ───────────────────────────────── # ── Roundcube 日期解析 ─────────────────────────────────
@staticmethod @staticmethod
def _parse_rc_date(date_str: str) -> Optional[datetime]: def _parse_rc_date(date_str: str) -> datetime | None:
""" """
解析 Roundcube 返回的日期字符串为 datetime。 解析 Roundcube 返回的日期字符串为 datetime。
@@ -273,7 +270,7 @@ class EmailVerifier:
- "2026-06-20" → 直接解析 - "2026-06-20" → 直接解析
- "06-20" → 今年的该日期 - "06-20" → 今年的该日期
""" """
now = datetime.now() now = datetime.now(UTC)
# 今天 # 今天
if date_str.startswith("今天"): if date_str.startswith("今天"):
@@ -325,32 +322,36 @@ class EmailVerifier:
# 日期格式 2026-06-20 # 日期格式 2026-06-20
try: try:
return datetime.strptime(date_str.strip(), "%Y-%m-%d") return datetime.strptime(date_str.strip(), "%Y-%m-%d").replace(tzinfo=UTC)
except ValueError: except ValueError:
pass pass
# 日期格式 2026-06-20 14:30 # 日期格式 2026-06-20 14:30
try: try:
return datetime.strptime(date_str.strip(), "%Y-%m-%d %H:%M") return datetime.strptime(date_str.strip(), "%Y-%m-%d %H:%M").replace(
tzinfo=UTC
)
except ValueError: except ValueError:
pass pass
# 日期格式 2026-06-20 14:30:00 # 日期格式 2026-06-20 14:30:00
try: try:
return datetime.strptime(date_str.strip(), "%Y-%m-%d %H:%M:%S") return datetime.strptime(date_str.strip(), "%Y-%m-%d %H:%M:%S").replace(
tzinfo=UTC
)
except ValueError: except ValueError:
pass pass
# 日期格式 06-20 # 日期格式 06-20
try: try:
dt = datetime.strptime(date_str.strip(), "%m-%d") dt = datetime.strptime(date_str.strip(), "%m-%d").replace(tzinfo=UTC)
return dt.replace(year=now.year) return dt.replace(year=now.year)
except ValueError: except ValueError:
pass pass
# 日期格式 06-20 14:30 # 日期格式 06-20 14:30
try: try:
dt = datetime.strptime(date_str.strip(), "%m-%d %H:%M") dt = datetime.strptime(date_str.strip(), "%m-%d %H:%M").replace(tzinfo=UTC)
return dt.replace(year=now.year) return dt.replace(year=now.year)
except ValueError: except ValueError:
pass pass
@@ -363,9 +364,9 @@ class EmailVerifier:
self, self,
max_wait: int = 60, max_wait: int = 60,
interval: int = 3, interval: int = 3,
after_timestamp: Optional[float] = None, after_timestamp: float | None = None,
allow_old_seconds: int = 15, allow_old_seconds: int = 15,
stop_event: Optional[threading.Event] = None, stop_event: threading.Event | None = None,
) -> str: ) -> str:
""" """
轮询获取斗鱼验证码。 轮询获取斗鱼验证码。
@@ -398,7 +399,7 @@ class EmailVerifier:
return code return code
except EmailLoginError: except EmailLoginError:
raise raise
except Exception as e: except Exception as e: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
last_error = str(e) last_error = str(e)
logger.warning(f"Roundcube 读邮件异常: {e}") logger.warning(f"Roundcube 读邮件异常: {e}")
@@ -417,9 +418,9 @@ class EmailVerifier:
def _fetch_code_via_roundcube( def _fetch_code_via_roundcube(
self, self,
after_timestamp: Optional[float] = None, after_timestamp: float | None = None,
allow_old_seconds: int = 15, allow_old_seconds: int = 15,
) -> Optional[str]: ) -> str | None:
"""通过 Roundcube API 获取最新斗鱼验证码""" """通过 Roundcube API 获取最新斗鱼验证码"""
messages = self._rc_fetch_mail_list() messages = self._rc_fetch_mail_list()
@@ -465,7 +466,7 @@ class EmailVerifier:
# ── 通用工具方法 ─────────────────────────────────────── # ── 通用工具方法 ───────────────────────────────────────
def _extract_verification_code(self, text: str) -> Optional[str]: def _extract_verification_code(self, text: str) -> str | None:
"""从文本中提取6位验证码""" """从文本中提取6位验证码"""
# 清理HTML标签和实体 # 清理HTML标签和实体
text = html.unescape(text) text = html.unescape(text)
+30 -28
View File
@@ -4,28 +4,30 @@ import json
import re import re
import threading import threading
import time import time
import requests from collections.abc import Mapping
from typing import Mapping, Optional, Protocol, Tuple from typing import Protocol
from urllib.parse import urlsplit, urlunsplit from urllib.parse import urlsplit, urlunsplit
import requests
from loguru import logger from loguru import logger
from core.geetest.common.network import (
get_c_s,
get_js_address,
req_fullpage_validate,
)
from core.geetest.v3_slide.solver import (
_generate_seed,
get_w1,
get_w2,
)
from .cookie_enricher import CookieEnricher from .cookie_enricher import CookieEnricher
from .email_verifier import EmailLoginError, EmailVerifier from .email_verifier import EmailLoginError, EmailVerifier
from .login_api import LoginAPIStrategy from .login_api import LoginAPIStrategy
from .login_api_wgapi import WgapiLoginAPI from .login_api_wgapi import WgapiLoginAPI
from .proxy_fetcher import ProxyFetcher from .proxy_fetcher import ProxyFetcher
from core.geetest.v3_slide.solver import (
_generate_seed,
get_w1,
get_w2,
)
from core.geetest.common.network import (
get_js_address,
get_c_s,
req_fullpage_validate,
)
# ── 全局极验并发限制:同一时刻最多2个线程做极验验证 ── # ── 全局极验并发限制:同一时刻最多2个线程做极验验证 ──
_geetest_semaphore = threading.Semaphore(2) _geetest_semaphore = threading.Semaphore(2)
@@ -95,14 +97,14 @@ class DouyuLogin:
def __init__( def __init__(
self, self,
account: AccountLike, account: AccountLike,
proxy: Optional[str | Mapping[str, str]] = None, proxy: str | Mapping[str, str] | None = None,
proxy_api_url: Optional[str] = None, proxy_api_url: str | None = None,
timeout: tuple[float, float] = REQUEST_TIMEOUT, timeout: tuple[float, float] = REQUEST_TIMEOUT,
max_login_retries: int = 0, max_login_retries: int = 0,
max_total_time: float = 0, max_total_time: float = 0,
proxy_fetcher: Optional[ProxyFetcher] = None, proxy_fetcher: ProxyFetcher | None = None,
stop_event: Optional[threading.Event] = None, stop_event: threading.Event | None = None,
api_strategy: Optional[LoginAPIStrategy] = None, api_strategy: LoginAPIStrategy | None = None,
): ):
self.account = account self.account = account
self.proxy = proxy self.proxy = proxy
@@ -117,7 +119,7 @@ class DouyuLogin:
ProxyFetcher(api_url=proxy_api_url) if proxy_api_url else None ProxyFetcher(api_url=proxy_api_url) if proxy_api_url else None
) )
self._current_proxy_url: Optional[str] = None self._current_proxy_url: str | None = None
self._cookie_enrich_error = "" self._cookie_enrich_error = ""
self._static_retry_count = 0 self._static_retry_count = 0
self._setup_session() self._setup_session()
@@ -396,7 +398,7 @@ class DouyuLogin:
return LoginResult( return LoginResult(
success=False, message=str(e), code="email_login_failed" success=False, message=str(e), code="email_login_failed"
) )
except Exception as e: except Exception as e: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
elapsed = time.monotonic() - start_time elapsed = time.monotonic() - start_time
if self.max_login_retries > 0: if self.max_login_retries > 0:
logger.error( logger.error(
@@ -473,7 +475,7 @@ class DouyuLogin:
return LoginResult( return LoginResult(
success=False, message=str(e), code="email_login_failed" success=False, message=str(e), code="email_login_failed"
) )
except Exception as e: except Exception as e: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
elapsed = time.monotonic() - start_time elapsed = time.monotonic() - start_time
if self.max_login_retries > 0: if self.max_login_retries > 0:
logger.error( logger.error(
@@ -565,7 +567,7 @@ class DouyuLogin:
) )
return True return True
def _first_login(self) -> Tuple[str, str, str, dict]: def _first_login(self) -> tuple[str, str, str, dict]:
""" """
第一次登录,获取极验参数 第一次登录,获取极验参数
@@ -608,7 +610,7 @@ class DouyuLogin:
def _solve_geetest( def _solve_geetest(
self, gt: str, challenge: str, deadline: float = 0 self, gt: str, challenge: str, deadline: float = 0
) -> Tuple[str, str]: ) -> tuple[str, str]:
""" """
解决极验 fullpage 验证(最多3次尝试,失败直接抛异常回到login换新代理) 解决极验 fullpage 验证(最多3次尝试,失败直接抛异常回到login换新代理)
@@ -635,7 +637,7 @@ class DouyuLogin:
def _solve_geetest_inner( def _solve_geetest_inner(
self, gt: str, challenge: str, deadline: float = 0 self, gt: str, challenge: str, deadline: float = 0
) -> Tuple[str, str]: ) -> tuple[str, str]:
"""极验验证内部实现:最多3次尝试,失败直接抛异常。""" """极验验证内部实现:最多3次尝试,失败直接抛异常。"""
_MAX_ATTEMPTS = 3 _MAX_ATTEMPTS = 3
@@ -652,7 +654,7 @@ class DouyuLogin:
# 超时兜底 # 超时兜底
if deadline and time.monotonic() > deadline: if deadline and time.monotonic() > deadline:
raise ValueError(f"极验验证超时(登录整体时间耗尽)") raise ValueError("极验验证超时(登录整体时间耗尽)")
try: try:
logger.info(f"极验验证尝试 {attempt}/{_MAX_ATTEMPTS}") logger.info(f"极验验证尝试 {attempt}/{_MAX_ATTEMPTS}")
@@ -825,7 +827,7 @@ class DouyuLogin:
logger.info("验证邮件已发送") logger.info("验证邮件已发送")
def _get_email_code(self, after_timestamp: Optional[float] = None) -> str: def _get_email_code(self, after_timestamp: float | None = None) -> str:
"""获取邮箱验证码""" """获取邮箱验证码"""
verifier = EmailVerifier( verifier = EmailVerifier(
imap_server=self.account.email_imap_server, imap_server=self.account.email_imap_server,
@@ -899,7 +901,7 @@ class DouyuLogin:
if response2.status_code == 200: if response2.status_code == 200:
logger.info("WebLogin成功") logger.info("WebLogin成功")
except Exception as e: except Exception as e: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
logger.warning(f"WebLogin请求失败(不影响登录): {e}") logger.warning(f"WebLogin请求失败(不影响登录): {e}")
# 登录成功后补齐 Web 侧 Cookie。补 CK 失败只影响完整度,不回滚已成功的登录态。 # 登录成功后补齐 Web 侧 Cookie。补 CK 失败只影响完整度,不回滚已成功的登录态。
@@ -913,7 +915,7 @@ class DouyuLogin:
).enrich_with_retry() ).enrich_with_retry()
except InterruptedError: except InterruptedError:
raise raise
except Exception as e: except Exception as e: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
self._cookie_enrich_error = self._truncate_error(str(e), 160) self._cookie_enrich_error = self._truncate_error(str(e), 160)
logger.warning( logger.warning(
f"补CK最终失败,本次仍按登录成功保存基础CK: {self._cookie_enrich_error}" f"补CK最终失败,本次仍按登录成功保存基础CK: {self._cookie_enrich_error}"
+1 -2
View File
@@ -1,7 +1,6 @@
"""登录接口策略基类""" """登录接口策略基类"""
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import Tuple
class LoginAPIStrategy(ABC): class LoginAPIStrategy(ABC):
@@ -78,7 +77,7 @@ class LoginAPIStrategy(ABC):
"""构建提交验证码参数""" """构建提交验证码参数"""
... ...
def extract_geetest_params(self, payload: dict) -> Tuple[str, str, str]: def extract_geetest_params(self, payload: dict) -> tuple[str, str, str]:
"""从第一次登录响应提取极验参数 """从第一次登录响应提取极验参数
Args: Args:
+1
View File
@@ -10,6 +10,7 @@
import threading import threading
import time import time
from loguru import logger from loguru import logger
from .proxy_resolver import ProxyResolver from .proxy_resolver import ProxyResolver
+1 -2
View File
@@ -2,10 +2,9 @@
import json import json
import re import re
from typing import Optional
def parse_proxy_response(text: str) -> tuple[list[str], Optional[str]]: def parse_proxy_response(text: str) -> tuple[list[str], str | None]:
""" """
解析代理 API 响应,支持 JSON 格式与旧版纯文本格式。 解析代理 API 响应,支持 JSON 格式与旧版纯文本格式。
+4 -4
View File
@@ -6,14 +6,14 @@
3. 在 _PLATFORM_CREDENTIAL_FIELDS 中定义凭据字段 3. 在 _PLATFORM_CREDENTIAL_FIELDS 中定义凭据字段
""" """
from typing import Any, Optional, Type, cast from typing import Any, cast
from .base import BaseWhitelistAdapter, get_exit_ip_via_proxy from .base import BaseWhitelistAdapter
from .xiequ import XiequAdapter from .xiequ import XiequAdapter
from .xkdaili import XkdailiAdapter from .xkdaili import XkdailiAdapter
# ── 平台注册表 ── # ── 平台注册表 ──
_PLATFORM_REGISTRY: dict[str, Type[BaseWhitelistAdapter]] = { _PLATFORM_REGISTRY: dict[str, type[BaseWhitelistAdapter]] = {
"xiequ": XiequAdapter, "xiequ": XiequAdapter,
"xkdaili": XkdailiAdapter, "xkdaili": XkdailiAdapter,
} }
@@ -49,7 +49,7 @@ _PLATFORM_LABELS: dict[str, str] = {
} }
def create_adapter(platform: str, credentials: dict) -> Optional[BaseWhitelistAdapter]: def create_adapter(platform: str, credentials: dict) -> BaseWhitelistAdapter | None:
"""根据平台标识符和凭据创建适配器实例。""" """根据平台标识符和凭据创建适配器实例。"""
cls = _PLATFORM_REGISTRY.get(platform) cls = _PLATFORM_REGISTRY.get(platform)
if not cls: if not cls:
+8 -7
View File
@@ -8,7 +8,6 @@ import re
import threading import threading
import time import time
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import Optional
import requests import requests
from loguru import logger from loguru import logger
@@ -138,18 +137,18 @@ class BaseWhitelistAdapter(ABC):
return False, f"白名单添加失败,API响应: {resp}" return False, f"白名单添加失败,API响应: {resp}"
except Exception as e: except Exception as e: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
msg = f"白名单同步失败: {e}" msg = f"白名单同步失败: {e}"
logger.error(msg) logger.error(msg)
return False, msg return False, msg
@staticmethod @staticmethod
def get_local_exit_ip() -> Optional[str]: def get_local_exit_ip() -> str | None:
"""获取本机当前公网出口IP(不走代理)。""" """获取本机当前公网出口IP(不走代理)。"""
return _get_local_exit_ip() return _get_local_exit_ip()
def _get_local_exit_ip() -> Optional[str]: def _get_local_exit_ip() -> str | None:
"""获取本机当前公网出口IP(不走代理)。""" """获取本机当前公网出口IP(不走代理)。"""
targets = [ targets = [
"https://myip.ipip.net", "https://myip.ipip.net",
@@ -166,12 +165,13 @@ def _get_local_exit_ip() -> Optional[str]:
match = re.search(r"(\d{1,3}(?:\.\d{1,3}){3})", text) match = re.search(r"(\d{1,3}(?:\.\d{1,3}){3})", text)
if match: if match:
return match.group(1) return match.group(1)
except Exception: except Exception as exc: # noqa: BLE001
logger.debug(f"本机出口 IP 查询失败: {url}: {exc}")
continue continue
return None return None
def get_exit_ip_via_proxy(proxy: str) -> Optional[str]: def get_exit_ip_via_proxy(proxy: str) -> str | None:
"""通过代理获取出口IP。""" """通过代理获取出口IP。"""
targets = [ targets = [
"https://qifu-api.baidubce.com/ip/local/geo/v1/district", "https://qifu-api.baidubce.com/ip/local/geo/v1/district",
@@ -198,6 +198,7 @@ def get_exit_ip_via_proxy(proxy: str) -> Optional[str]:
match = re.search(r"(\d{1,3}(?:\.\d{1,3}){3})", text) match = re.search(r"(\d{1,3}(?:\.\d{1,3}){3})", text)
if match: if match:
return match.group(1) return match.group(1)
except Exception: except Exception as exc: # noqa: BLE001
logger.debug(f"代理出口 IP 查询失败: {url}: {exc}")
continue continue
return None return None
+4 -4
View File
@@ -75,7 +75,7 @@ class XiequAdapter(BaseWhitelistAdapter):
if r.get("IP") or r.get("ip") if r.get("IP") or r.get("ip")
] ]
except Exception as e: except Exception as e: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
logger.error(f"获取白名单失败: {e}") logger.error(f"获取白名单失败: {e}")
return [] return []
@@ -120,7 +120,7 @@ class XiequAdapter(BaseWhitelistAdapter):
logger.warning(f"白名单添加结果: {text}") logger.warning(f"白名单添加结果: {text}")
return False, text return False, text
except Exception as e: except Exception as e: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
logger.error(f"添加白名单失败: {e}") logger.error(f"添加白名单失败: {e}")
return False, str(e) return False, str(e)
@@ -141,7 +141,7 @@ class XiequAdapter(BaseWhitelistAdapter):
logger.warning(f"白名单删除结果: {text}") logger.warning(f"白名单删除结果: {text}")
return False, text return False, text
except Exception as e: except Exception as e: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
logger.error(f"删除白名单失败: {e}") logger.error(f"删除白名单失败: {e}")
return False, str(e) return False, str(e)
@@ -160,7 +160,7 @@ class XiequAdapter(BaseWhitelistAdapter):
logger.info(msg) logger.info(msg)
return True, msg return True, msg
except Exception as e: except Exception as e: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
msg = f"连接失败: {e}" msg = f"连接失败: {e}"
logger.error(msg) logger.error(msg)
return False, msg return False, msg
+3 -3
View File
@@ -115,7 +115,7 @@ class XkdailiAdapter(BaseWhitelistAdapter):
logger.warning(f"星空白名单添加失败: {msg}") logger.warning(f"星空白名单添加失败: {msg}")
return ok, msg return ok, msg
except Exception as e: except Exception as e: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
logger.error(f"星空添加白名单失败: {e}") logger.error(f"星空添加白名单失败: {e}")
return False, str(e) return False, str(e)
@@ -136,7 +136,7 @@ class XkdailiAdapter(BaseWhitelistAdapter):
logger.warning(f"星空白名单删除失败: {msg}") logger.warning(f"星空白名单删除失败: {msg}")
return ok, msg return ok, msg
except Exception as e: except Exception as e: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
logger.error(f"星空删除白名单失败: {e}") logger.error(f"星空删除白名单失败: {e}")
return False, str(e) return False, str(e)
@@ -159,5 +159,5 @@ class XkdailiAdapter(BaseWhitelistAdapter):
# 其他错误(如无效IP),说明认证通过了 # 其他错误(如无效IP),说明认证通过了
return True, f"连接成功,API可访问: {msg}" return True, f"连接成功,API可访问: {msg}"
except Exception as e: except Exception as e: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
return False, f"连接失败: {e}" return False, f"连接失败: {e}"
+11 -10
View File
@@ -2,7 +2,8 @@
import threading import threading
import time import time
from typing import Callable, Optional, Protocol from collections.abc import Callable
from typing import Protocol
import requests import requests
from loguru import logger from loguru import logger
@@ -19,7 +20,7 @@ class WhitelistSyncer(Protocol):
def sync_ip(self, ip: str) -> tuple[bool, str]: ... def sync_ip(self, ip: str) -> tuple[bool, str]: ...
def get_local_exit_ip(self) -> Optional[str]: ... def get_local_exit_ip(self) -> str | None: ...
class ProxyResolver: class ProxyResolver:
@@ -28,11 +29,11 @@ class ProxyResolver:
def __init__( def __init__(
self, self,
api_url: str, api_url: str,
whitelist_syncer: Optional[WhitelistSyncer] = None, whitelist_syncer: WhitelistSyncer | None = None,
log_func: Optional[LogFunc] = None, log_func: LogFunc | None = None,
sync_local_exit_ip: bool = False, sync_local_exit_ip: bool = False,
sync_whitelist_once: bool = True, sync_whitelist_once: bool = True,
stop_event: Optional[threading.Event] = None, stop_event: threading.Event | None = None,
): ):
self.api_url = api_url self.api_url = api_url
self.whitelist_syncer = whitelist_syncer self.whitelist_syncer = whitelist_syncer
@@ -40,7 +41,7 @@ class ProxyResolver:
self.sync_local_exit_ip = sync_local_exit_ip self.sync_local_exit_ip = sync_local_exit_ip
self.sync_whitelist_once = sync_whitelist_once self.sync_whitelist_once = sync_whitelist_once
self.stop_event = stop_event self.stop_event = stop_event
self._last_synced_ip: Optional[str] = None self._last_synced_ip: str | None = None
self._has_synced_whitelist = False self._has_synced_whitelist = False
def _is_stopped(self) -> bool: def _is_stopped(self) -> bool:
@@ -95,7 +96,7 @@ class ProxyResolver:
self, self,
max_attempts: int = 4, max_attempts: int = 4,
return_all: bool = False, return_all: bool = False,
) -> tuple[Optional[str | list[str]], str]: ) -> tuple[str | list[str] | None, str]:
""" """
获取并验证代理。 获取并验证代理。
@@ -166,7 +167,7 @@ class ProxyResolver:
f"代理预检 {attempt}/{max_attempts}: {last_error}: {text[:80]}", f"代理预检 {attempt}/{max_attempts}: {last_error}: {text[:80]}",
) )
except Exception as exc: except Exception as exc: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
last_error = f"代理API请求失败: {exc}" last_error = f"代理API请求失败: {exc}"
self._log("warning", f"代理预检 {attempt}/{max_attempts}: {last_error}") self._log("warning", f"代理预检 {attempt}/{max_attempts}: {last_error}")
@@ -178,8 +179,8 @@ def resolve_working_proxy(
whitelist_platform: str = "xiequ", whitelist_platform: str = "xiequ",
whitelist_credentials: dict | None = None, whitelist_credentials: dict | None = None,
max_attempts: int = 4, max_attempts: int = 4,
log_func: Optional[LogFunc] = None, log_func: LogFunc | None = None,
) -> tuple[Optional[str], str]: ) -> tuple[str | None, str]:
"""从代理 API 获取可用代理,自动处理白名单同步。""" """从代理 API 获取可用代理,自动处理白名单同步。"""
syncer = ( syncer = (
DouyuWhitelistSyncer( DouyuWhitelistSyncer(
+6 -5
View File
@@ -1,7 +1,6 @@
"""代理可用性验证。""" """代理可用性验证。"""
from concurrent.futures import ThreadPoolExecutor, as_completed from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import Optional
import requests import requests
from loguru import logger from loguru import logger
@@ -28,7 +27,7 @@ def verify_proxy_url(proxy_url: str, timeout: tuple = (3, 5)) -> tuple[bool, str
) )
response.raise_for_status() response.raise_for_status()
return True, "代理可用 → 斗鱼可达" return True, "代理可用 → 斗鱼可达"
except Exception as exc: except Exception as exc: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
err_msg = str(exc) err_msg = str(exc)
if "Tunnel connection failed" in err_msg or "503" in err_msg: if "Tunnel connection failed" in err_msg or "503" in err_msg:
detail = "代理拒绝连接(白名单可能未生效)" detail = "代理拒绝连接(白名单可能未生效)"
@@ -45,7 +44,7 @@ def verify_proxies_concurrent(
timeout: tuple = (5, 8), timeout: tuple = (5, 8),
max_workers: int = 5, max_workers: int = 5,
return_all: bool = False, return_all: bool = False,
) -> tuple[Optional[str | list[str]], str]: ) -> tuple[str | list[str] | None, str]:
""" """
并发验证多个代理 URL。 并发验证多个代理 URL。
@@ -76,7 +75,8 @@ def verify_proxies_concurrent(
ok, _ = future.result() ok, _ = future.result()
if ok: if ok:
available.append(future_map[future]) available.append(future_map[future])
except Exception: except Exception as exc: # noqa: BLE001
logger.debug(f"代理并发验证失败: {future_map[future]}: {exc}")
continue continue
if available: if available:
logger.success( logger.success(
@@ -100,7 +100,8 @@ def verify_proxies_concurrent(
if item != future: if item != future:
item.cancel() item.cancel()
return proxy_url, msg return proxy_url, msg
except Exception: except Exception as exc: # noqa: BLE001
logger.debug(f"代理并发验证失败: {proxy_url}: {exc}")
continue continue
return None, f"{len(proxy_urls)} 个代理均不可用" return None, f"{len(proxy_urls)} 个代理均不可用"
+3 -5
View File
@@ -1,7 +1,5 @@
"""代理模块使用的白名单适配器。""" """代理模块使用的白名单适配器。"""
from typing import Optional
from .proxy_platforms import create_adapter from .proxy_platforms import create_adapter
from .proxy_platforms.base import BaseWhitelistAdapter, _get_local_exit_ip from .proxy_platforms.base import BaseWhitelistAdapter, _get_local_exit_ip
@@ -17,7 +15,7 @@ class DouyuWhitelistSyncer:
def __init__( def __init__(
self, self,
platform: str = "xiequ", platform: str = "xiequ",
credentials: Optional[dict] = None, credentials: dict | None = None,
uid: str = "", uid: str = "",
ukey: str = "", ukey: str = "",
): ):
@@ -28,7 +26,7 @@ class DouyuWhitelistSyncer:
self.platform = platform self.platform = platform
self.credentials = credentials or {} self.credentials = credentials or {}
self._adapter: Optional[BaseWhitelistAdapter] = None self._adapter: BaseWhitelistAdapter | None = None
if self.credentials: if self.credentials:
self._adapter = create_adapter(platform, self.credentials) self._adapter = create_adapter(platform, self.credentials)
@@ -38,5 +36,5 @@ class DouyuWhitelistSyncer:
return False, "未配置白名单凭据" return False, "未配置白名单凭据"
return self._adapter.sync_ip(ip) return self._adapter.sync_ip(ip)
def get_local_exit_ip(self) -> Optional[str]: def get_local_exit_ip(self) -> str | None:
return _get_local_exit_ip() return _get_local_exit_ip()
+7 -6
View File
@@ -6,9 +6,10 @@ import hashlib
import json import json
import os import os
import time import time
from collections.abc import Callable, Mapping
from dataclasses import dataclass from dataclasses import dataclass
from decimal import Decimal, InvalidOperation from decimal import Decimal, InvalidOperation
from typing import Any, Callable, Mapping from typing import Any
import requests import requests
@@ -34,7 +35,7 @@ class FishFinRechargeConfig:
debug: bool = False debug: bool = False
@classmethod @classmethod
def from_env(cls) -> "FishFinRechargeConfig": def from_env(cls) -> FishFinRechargeConfig:
"""从环境变量读取配置,不在代码或数据库中保存商户密钥。""" """从环境变量读取配置,不在代码或数据库中保存商户密钥。"""
timeout = float(os.getenv("FISH_FIN_RECHARGE_TIMEOUT", "20")) timeout = float(os.getenv("FISH_FIN_RECHARGE_TIMEOUT", "20"))
return cls( return cls(
@@ -189,7 +190,7 @@ class FishFinRechargeClient:
"sign_params": sign_params, "sign_params": sign_params,
# 不把可重放签名原文或 AppSecret 写入日志,只记录待签名串摘要。 # 不把可重放签名原文或 AppSecret 写入日志,只记录待签名串摘要。
"sign_source_digest": hashlib.sha256( "sign_source_digest": hashlib.sha256(
f"{sign_query}{method.upper()}".encode("utf-8") f"{sign_query}{method.upper()}".encode()
).hexdigest()[:12], ).hexdigest()[:12],
} }
) )
@@ -240,7 +241,7 @@ class FishFinRechargeClient:
return payload return payload
@staticmethod @staticmethod
def _amount(value: Decimal | int | float | str) -> str: def _amount(value: Decimal | float | str) -> str:
"""规范化金额,避免浮点数表达式进入签名或订单请求。""" """规范化金额,避免浮点数表达式进入签名或订单请求。"""
try: try:
price = Decimal(str(value)) price = Decimal(str(value))
@@ -251,7 +252,7 @@ class FishFinRechargeClient:
return format(price.normalize(), "f") return format(price.normalize(), "f")
@classmethod @classmethod
def _json_amount(cls, value: Decimal | int | float | str) -> int | float: def _json_amount(cls, value: Decimal | float | str) -> int | float:
"""按文档以 JSON 数字发送金额,整数不附带无意义的小数位。""" """按文档以 JSON 数字发送金额,整数不附带无意义的小数位。"""
amount_text = cls._amount(value) amount_text = cls._amount(value)
return int(amount_text) if "." not in amount_text else float(amount_text) return int(amount_text) if "." not in amount_text else float(amount_text)
@@ -267,7 +268,7 @@ class FishFinRechargeClient:
self, self,
*, *,
buy_num: int, buy_num: int,
pay_amount: Decimal | int | float | str, pay_amount: Decimal | float | str,
out_order_id: str, out_order_id: str,
product_id: str, product_id: str,
recharge_arg: list[dict[str, Any]], recharge_arg: list[dict[str, Any]],
+3 -138
View File
@@ -1,8 +1,9 @@
import random
import hashlib import hashlib
import random
from typing import Any from typing import Any
from Crypto.Cipher import AES, PKCS1_v1_5
from Crypto.PublicKey import RSA from Crypto.PublicKey import RSA
from Crypto.Cipher import PKCS1_v1_5, AES
# 随机产生4个字符组成的字符串 # 随机产生4个字符组成的字符串
@@ -247,141 +248,5 @@ def simple_md5(message: str) -> str:
def verify_result(msg): def verify_result(msg):
return hashlib.md5(msg.encode()).hexdigest() return hashlib.md5(msg.encode()).hexdigest()
# 轮移位常量
shifts = [
7,
12,
17,
22,
7,
12,
17,
22,
7,
12,
17,
22,
7,
12,
17,
22,
5,
9,
14,
20,
5,
9,
14,
20,
5,
9,
14,
20,
5,
9,
14,
20,
4,
11,
16,
23,
4,
11,
16,
23,
4,
11,
16,
23,
4,
11,
16,
23,
6,
10,
15,
21,
6,
10,
15,
21,
6,
10,
15,
21,
6,
10,
15,
21,
]
# K常数(与JavaScript版本中的常数对应)
K = [
0xD76AA478,
0xE8C7B756,
0x242070DB,
0xC1BDCEEE,
0xF57C0FAF,
0x4787C62A,
0xA8304613,
0xFD469501,
0x698098D8,
0x8B44F7AF,
0xFFFF5BB1,
0x895CD7BE,
0x6B901122,
0xFD987193,
0xA679438E,
0x49B40821,
0xF61E2562,
0xC040B340,
0x265E5A51,
0xE9B6C7AA,
0xD62F105D,
0x02441453,
0xD8A1E681,
0xE7D3FBC8,
0x21E1CDE6,
0xC33707D6,
0xF4D50D87,
0x455A14ED,
0xA9E3E905,
0xFCEFA3F8,
0x676F02D9,
0x8D2A4C8A,
0xFFFA3942,
0x8771F681,
0x6D9D6122,
0xFDE5380C,
0xA4BEEA44,
0x4BDECFA9,
0xF6BB4B60,
0xBEBFBC70,
0x289B7EC6,
0xEAA127FA,
0xD4EF3085,
0x04881D05,
0xD9D4D039,
0xE6DB99E5,
0x1FA27CF8,
0xC4AC5665,
0xF4292244,
0x432AFF97,
0xAB9423A7,
0xFC93A039,
0x655B59C3,
0x8F0CCC92,
0xFFEFF47D,
0x85845DD1,
0x6FA87E4F,
0xFE2CE6E0,
0xA3014314,
0x4E0811A1,
0xF7537E82,
0xBD3AF235,
0x2AD7D2BB,
0xEB86D391,
]
# 实际实现... # 实际实现...
return verify_result(message) return verify_result(message)
+2 -4
View File
@@ -1,8 +1,8 @@
import requests
import cv2 import cv2
import numpy as np import numpy as np
from PIL import Image import requests
from loguru import logger from loguru import logger
from PIL import Image
REQUEST_TIMEOUT = (3.05, 12) REQUEST_TIMEOUT = (3.05, 12)
@@ -103,9 +103,7 @@ def restore_geetest_image(input_path: str, output_path: str) -> None:
# 打开混淆图像 # 打开混淆图像
img = Image.open(input_path) img = Image.open(input_path)
new_img = Image.new("RGB", (260, 160)) new_img = Image.new("RGB", (260, 160))
r = 160
for _ in range(len(Ut)): for _ in range(len(Ut)):
a = r / 2
c = Ut[_] % 26 * 12 + 1 c = Ut[_] % 26 * 12 + 1
u = 80 if Ut[_] > 25 else 0 u = 80 if Ut[_] > 25 else 0
l = img.crop(box=(c, u, c + 10, u + 80)) l = img.crop(box=(c, u, c + 10, u + 80))
+20 -19
View File
@@ -1,8 +1,9 @@
import time
import requests
import json import json
import re import re
from typing import Mapping, Optional, Tuple import time
from collections.abc import Mapping
import requests
from loguru import logger from loguru import logger
REQUEST_TIMEOUT = (10, 30) REQUEST_TIMEOUT = (10, 30)
@@ -12,9 +13,9 @@ PASSPORT_REFERER = "https://passport.douyu.com/"
def _get( def _get(
url: str, url: str,
*, *,
params: Optional[dict] = None, params: dict | None = None,
headers: Optional[dict] = None, headers: dict | None = None,
proxies: Optional[Mapping[str, str]] = None, proxies: Mapping[str, str] | None = None,
) -> requests.Response: ) -> requests.Response:
"""发送极验 GET 请求,确保使用同一个代理出口。""" """发送极验 GET 请求,确保使用同一个代理出口。"""
return requests.get( return requests.get(
@@ -55,7 +56,7 @@ def _parse_json_response(response: requests.Response, source: str) -> dict:
raise ValueError(f"{source} 返回的不是有效 JSON: {preview}") from exc raise ValueError(f"{source} 返回的不是有效 JSON: {preview}") from exc
def get_challenge_gt_bak() -> Tuple[str, str]: def get_challenge_gt_bak() -> tuple[str, str]:
headers = { headers = {
"accept": "application/json, text/javascript, */*; q=0.01", "accept": "application/json, text/javascript, */*; q=0.01",
"accept-language": "zh-CN,zh;q=0.9", "accept-language": "zh-CN,zh;q=0.9",
@@ -72,7 +73,7 @@ def get_challenge_gt_bak() -> Tuple[str, str]:
} }
params = { params = {
"t": str(int(round(time.time() * 1000))), "t": str(round(time.time() * 1000)),
} }
response = requests.get( response = requests.get(
@@ -85,7 +86,7 @@ def get_challenge_gt_bak() -> Tuple[str, str]:
return data["gt"], data["challenge"] return data["gt"], data["challenge"]
def get_challenge_gt() -> Tuple[str, str]: def get_challenge_gt() -> tuple[str, str]:
headers = { headers = {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36", "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36",
"Accept": "application/json, text/javascript, */*; q=0.01", "Accept": "application/json, text/javascript, */*; q=0.01",
@@ -116,7 +117,7 @@ def get_challenge_gt() -> Tuple[str, str]:
raise ValueError(f"斗鱼登录接口返回中缺少极验参数: {preview}") from exc raise ValueError(f"斗鱼登录接口返回中缺少极验参数: {preview}") from exc
def get_js_address(gt: str, proxies: Optional[Mapping[str, str]] = None) -> dict: def get_js_address(gt: str, proxies: Mapping[str, str] | None = None) -> dict:
headers = { headers = {
"accept": "*/*", "accept": "*/*",
"accept-language": "zh-CN,zh;q=0.9", "accept-language": "zh-CN,zh;q=0.9",
@@ -132,7 +133,7 @@ def get_js_address(gt: str, proxies: Optional[Mapping[str, str]] = None) -> dict
params = { params = {
"gt": gt, "gt": gt,
"callback": "geetest_" + str(int(round(time.time() * 1000))), "callback": "geetest_" + str(round(time.time() * 1000)),
} }
response = _get( response = _get(
@@ -149,8 +150,8 @@ def get_c_s(
gt: str, gt: str,
challenge: str, challenge: str,
w: str, w: str,
proxies: Optional[Mapping[str, str]] = None, proxies: Mapping[str, str] | None = None,
) -> Tuple[list[int], str]: ) -> tuple[list[int], str]:
headers = { headers = {
"accept": "*/*", "accept": "*/*",
"accept-language": "zh-CN,zh;q=0.9", "accept-language": "zh-CN,zh;q=0.9",
@@ -172,7 +173,7 @@ def get_c_s(
+ "&lang=zh-cn&pt=0&client_type=web&w=" + "&lang=zh-cn&pt=0&client_type=web&w="
+ w + w
+ "&callback=geetest_" + "&callback=geetest_"
+ str(int(round(time.time() * 1000))), + str(round(time.time() * 1000)),
headers=headers, headers=headers,
proxies=proxies, proxies=proxies,
) )
@@ -184,7 +185,7 @@ def req_fullpage_validate(
gt: str, gt: str,
challenge: str, challenge: str,
w: str, w: str,
proxies: Optional[Mapping[str, str]] = None, proxies: Mapping[str, str] | None = None,
) -> dict: ) -> dict:
"""HAR 中的 fullpage 最终校验,成功后直接返回 validate。""" """HAR 中的 fullpage 最终校验,成功后直接返回 validate。"""
headers = { headers = {
@@ -209,7 +210,7 @@ def req_fullpage_validate(
+ "&lang=zh-cn&pt=0&client_type=web&w=" + "&lang=zh-cn&pt=0&client_type=web&w="
+ w + w
+ "&callback=geetest_" + "&callback=geetest_"
+ str(int(round(time.time() * 1000))), + str(round(time.time() * 1000)),
headers=headers, headers=headers,
proxies=proxies, proxies=proxies,
) )
@@ -240,7 +241,7 @@ def req_slide(gt: str, challenge: str, w2: str) -> None:
+ "&lang=zh-cn&pt=0&client_type=web&w=" + "&lang=zh-cn&pt=0&client_type=web&w="
+ w2 + w2
+ "&callback=geetest_" + "&callback=geetest_"
+ str(int(round(time.time() * 1000))), + str(round(time.time() * 1000)),
headers=headers, headers=headers,
timeout=REQUEST_TIMEOUT, timeout=REQUEST_TIMEOUT,
) )
@@ -276,7 +277,7 @@ def get_picture(gt: str, challenge: str) -> tuple[str, str, list[int], str, str,
"isPC": "true", "isPC": "true",
"autoReset": "true", "autoReset": "true",
"width": "100%", "width": "100%",
"callback": "geetest_" + str(int(round(time.time() * 1000))), "callback": "geetest_" + str(round(time.time() * 1000)),
} }
response = requests.get( response = requests.get(
@@ -342,7 +343,7 @@ def req_end(gt: str, challenge: str, w: str) -> dict:
+ "&lang=zh-cn&%24_BCm=0&client_type=web&w=" + "&lang=zh-cn&%24_BCm=0&client_type=web&w="
+ w + w
+ "&callback=geetest_" + "&callback=geetest_"
+ str(int(round(time.time() * 1000))), + str(round(time.time() * 1000)),
headers=headers, headers=headers,
timeout=REQUEST_TIMEOUT, timeout=REQUEST_TIMEOUT,
) )
+2 -3
View File
@@ -1,9 +1,8 @@
import time
import random import random
from typing import Optional import time
def generate_fake_performance_timing(base_time: Optional[int] = None) -> dict[str, int]: def generate_fake_performance_timing(base_time: int | None = None) -> dict[str, int]:
""" """
生成伪造的浏览器性能时间戳数据 生成伪造的浏览器性能时间戳数据
+4 -4
View File
@@ -1,6 +1,6 @@
import random
import math import math
from typing import Any, Optional import random
from typing import Any
# 生成类人的鼠标轨迹 # 生成类人的鼠标轨迹
@@ -91,7 +91,7 @@ def generate_realistic_trajectory(
# 处理原始轨迹数组 # 处理原始轨迹数组
def process_mouse_trajectory( def process_mouse_trajectory(
events: list[Any], max_records: Optional[int] = None events: list[Any], max_records: int | None = None
) -> dict[str, Any]: ) -> dict[str, Any]:
""" """
处理鼠标/触摸轨迹数据,将绝对坐标转换为相对坐标和时间差 处理鼠标/触摸轨迹数据,将绝对坐标转换为相对坐标和时间差
@@ -592,7 +592,7 @@ def H(t: int, e: str) -> str:
n = 36 * r[0] + r[1] n = 36 * r[0] + r[1]
# 计算目标值 # 计算目标值
a = round(t) + n a = (t) + n
# 构建字符池 # 构建字符池
_ = [[], [], [], [], []] _ = [[], [], [], [], []]
+19 -17
View File
@@ -1,35 +1,37 @@
import time
import random
import json import json
import random
import time
from loguru import logger from loguru import logger
from core.geetest.common.trajectory import (
generate_realistic_trajectory,
process_mouse_trajectory,
compress_trajectory,
TrajectoryEncoder,
H,
)
from core.geetest.common.crypto import ( from core.geetest.common.crypto import (
four_random_chart,
RSA_jiami_r,
AES_O, AES_O,
geetest_base64_encode, RSA_jiami_r,
encrypt_string, encrypt_string,
four_random_chart,
geetest_base64_encode,
simple_md5, simple_md5,
) )
from core.geetest.common.imaging import download_picture from core.geetest.common.imaging import download_picture
from core.geetest.common.network import ( from core.geetest.common.network import (
get_c_s,
get_challenge_gt, get_challenge_gt,
get_js_address, get_js_address,
get_c_s,
req_slide,
get_picture, get_picture,
req_end, req_end,
req_slide,
) )
from core.geetest.common.performance import ( from core.geetest.common.performance import (
generate_fake_performance_timing, generate_fake_performance_timing,
get_slide_track, get_slide_track,
) )
from core.geetest.common.trajectory import (
H,
TrajectoryEncoder,
compress_trajectory,
generate_realistic_trajectory,
process_mouse_trajectory,
)
def _generate_seed() -> str: def _generate_seed() -> str:
@@ -83,7 +85,7 @@ def get_w2(gt: str, challenge: str, c: list[int], s: str, str_16: str) -> str:
"u": fake_timing["loadEventEnd"], "u": fake_timing["loadEventEnd"],
} }
first_time = int(round(time.time() * 1000)) # 伪造脚本开始运行时间 first_time = round(time.time() * 1000) # 伪造脚本开始运行时间
guiji_yuanshu_shuzu = generate_realistic_trajectory( guiji_yuanshu_shuzu = generate_realistic_trajectory(
start_x=random.randint(400, 600), # 起始位置随机 start_x=random.randint(400, 600), # 起始位置随机
@@ -97,7 +99,7 @@ def get_w2(gt: str, challenge: str, c: list[int], s: str, str_16: str) -> str:
compressed = compress_trajectory(trajectory) compressed = compress_trajectory(trajectory)
tt = encrypt_string(compressed, c, s) tt = encrypt_string(compressed, c, s)
passtime = str(int(round(time.time() * 1000)) - first_time) passtime = str(round(time.time() * 1000) - first_time)
rp = simple_md5(gt + challenge + passtime) rp = simple_md5(gt + challenge + passtime)
@@ -217,4 +219,4 @@ def run_solver() -> None:
w3 = get_w3(str_16, challenge, hkjl, c, s, gt) w3 = get_w3(str_16, challenge, hkjl, c, s, gt)
# 最后的验证 # 最后的验证
message = req_end(gt, challenge, w3) req_end(gt, challenge, w3)
+7 -7
View File
@@ -33,23 +33,23 @@ if TYPE_CHECKING:
) )
__all__ = [ __all__ = [
"HuyaHttpClient",
"HuyaWssClient",
"GetUserScoreReq", "GetUserScoreReq",
"GetUserScoreResp", "GetUserScoreResp",
"HuyaAppLoginError",
"HuyaAppPasswordLogin",
"HuyaAppQrAuthRequiredError",
"HuyaCredentialError", "HuyaCredentialError",
"HuyaHttpClient",
"HuyaLoginError", "HuyaLoginError",
"HuyaLoginResult", "HuyaLoginResult",
"HuyaPasswordLogin", "HuyaPasswordLogin",
"HuyaAppLoginError",
"HuyaAppQrAuthRequiredError",
"HuyaAppPasswordLogin",
"HuyaSmsCodeResult", "HuyaSmsCodeResult",
"HuyaSmsLogin", "HuyaSmsLogin",
"HuyaVerificationError", "HuyaVerificationError",
"HuyaVerificationSolver", "HuyaVerificationSolver",
"login_huya_password", "HuyaWssClient",
"login_huya_app_password", "login_huya_app_password",
"login_huya_password",
"login_huya_sms", "login_huya_sms",
"send_huya_sms_code", "send_huya_sms_code",
"solve_huya_verification", "solve_huya_verification",
@@ -98,8 +98,8 @@ def __getattr__(name: str):
}: }:
from .app_login import ( from .app_login import (
HuyaAppLoginError, HuyaAppLoginError,
HuyaAppQrAuthRequiredError,
HuyaAppPasswordLogin, HuyaAppPasswordLogin,
HuyaAppQrAuthRequiredError,
login_huya_app_password, login_huya_app_password,
) )
+10 -8
View File
@@ -35,13 +35,13 @@ import os
import sys import sys
import time import time
from .app_login import HuyaAppPasswordLogin
from .device_profile import ( from .device_profile import (
HDID32, # 登录帧 t1.t0 32hex 常量 (R15 公式结果, k1 来源见 R39; device_profile.py)
_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)
) )
from .app_login import HuyaAppPasswordLogin
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -69,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:
@@ -83,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
@@ -103,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]}... "
@@ -125,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 {
+3 -3
View File
@@ -306,7 +306,7 @@ class UserPrizeRecordItem(TafStruct):
self.exchangeDate = ins.read_int64(21, default=self.exchangeDate) self.exchangeDate = ins.read_int64(21, default=self.exchangeDate)
while True: while True:
pos = ins.buf.tell() pos = ins.buf.tell()
tag, dtype = ins.read_head() _tag, dtype = ins.read_head()
if dtype == TafType.STRUCT_END: if dtype == TafType.STRUCT_END:
ins.buf.seek(pos) ins.buf.seek(pos)
return return
@@ -406,7 +406,7 @@ class ActTaskPrizeInfo(TafStruct):
self.extra = ins.read_map(12) self.extra = ins.read_map(12)
while True: while True:
pos = ins.buf.tell() pos = ins.buf.tell()
tag, dtype = ins.read_head() _tag, dtype = ins.read_head()
if dtype == TafType.STRUCT_END: if dtype == TafType.STRUCT_END:
ins.buf.seek(pos) ins.buf.seek(pos)
return return
@@ -494,7 +494,7 @@ class ActTaskDetailItem(TafStruct):
self.endTime = ins.read_string(26, default=self.endTime) self.endTime = ins.read_string(26, default=self.endTime)
while True: while True:
pos = ins.buf.tell() pos = ins.buf.tell()
tag, dtype = ins.read_head() _tag, dtype = ins.read_head()
if dtype == TafType.STRUCT_END: if dtype == TafType.STRUCT_END:
ins.buf.seek(pos) ins.buf.seek(pos)
return return
+101 -52
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 密码登录,返回原始响应字节。
@@ -130,9 +119,9 @@ def wup_password_login_raw(
固定 action/device_id。风控重试调用方应显式复用同一注册结果。 固定 action/device_id。风控重试调用方应显式复用同一注册结果。
注册链失败抛 ``HuyaAppLoginError``,绝不静默回退旧固定值。 注册链失败抛 ``HuyaAppLoginError``,绝不静默回退旧固定值。
""" """
uid_str = account[3:] if account.startswith("hy_") else account 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,
@@ -232,7 +236,7 @@ def solve_safe_auth(
result = solver.solve(risk_url) result = solver.solve(risk_url)
except HuyaQrAuthRequiredError: except HuyaQrAuthRequiredError:
raise raise
except Exception as exc: except Exception as exc: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
last_err = exc last_err = exc
logger.warning(f"[safe_auth] 第 {attempt + 1} 次过验异常: {exc}") logger.warning(f"[safe_auth] 第 {attempt + 1} 次过验异常: {exc}")
time.sleep(1.0) time.sleep(1.0)
@@ -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(滑块)"
@@ -304,12 +319,23 @@ def login_cred_with_flow(
logger.info(f"[huya-app] 第 {rnd + 1} 轮触发安全验证: {kind}") logger.info(f"[huya-app] 第 {rnd + 1} 轮触发安全验证: {kind}")
if "qr_auth" in risk_url: if "qr_auth" in risk_url:
raise HuyaAppQrAuthRequiredError( raise HuyaAppQrAuthRequiredError(
f"该账号 App 渠道要求扫码验证(qr_auth),请先在手机虎牙 App 上正常登录一次建立设备信任。" "该账号 App 渠道要求扫码验证(qr_auth),请先在手机虎牙 App 上正常登录一次建立设备信任。"
) )
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
) )
@@ -425,8 +453,8 @@ class HuyaAppPasswordLogin:
from .device_profile import record_login from .device_profile import record_login
record_login(self.username, result.success, result.message) record_login(self.username, result.success, result.message)
except Exception: # 元数据记录失败不影响登录结果 except Exception as exc: # noqa: BLE001 # 元数据记录失败不影响登录结果
pass logger.debug(f"登录元数据记录失败: {exc}")
return result return result
def _login_impl(self) -> HuyaLoginResult: def _login_impl(self) -> HuyaLoginResult:
@@ -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(
@@ -450,7 +479,7 @@ class HuyaAppPasswordLogin:
message=str(exc), message=str(exc),
code="QR_AUTH_REQUIRED", code="QR_AUTH_REQUIRED",
) )
except Exception as exc: except Exception as exc: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
return HuyaLoginResult( return HuyaLoginResult(
success=False, success=False,
message=f"App 登录凭证获取失败: {exc}", message=f"App 登录凭证获取失败: {exc}",
@@ -461,24 +490,29 @@ 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,
except Exception as exc: 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( return HuyaLoginResult(
success=False, success=False,
message=f"证书铸造/信封补丁失败: {exc}", message=f"证书铸造/信封补丁失败: {exc}",
@@ -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
@@ -603,7 +652,7 @@ class HuyaAppPasswordLogin:
sdid=sdid, sdid=sdid,
context=pc.context, context=pc.context,
) )
except Exception as exc: except Exception as exc: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
return HuyaLoginResult( return HuyaLoginResult(
success=False, success=False,
message=f"扫码绑定兑换 Cookie 失败: {exc}", message=f"扫码绑定兑换 Cookie 失败: {exc}",
+4 -4
View File
@@ -6,7 +6,7 @@ import threading
import time import time
from collections.abc import Mapping from collections.abc import Mapping
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from datetime import UTC, datetime
from core.sms_provider import SmsLine, SmsProviderClient from core.sms_provider import SmsLine, SmsProviderClient
@@ -76,7 +76,7 @@ def register_huya_with_sms_line(
sms_url=item.url, sms_url=item.url,
) )
sent_at = datetime.now() sent_at = datetime.now(UTC)
try: try:
code_result = send_huya_sms_code(phone=phone, proxies=proxies) code_result = send_huya_sms_code(phone=phone, proxies=proxies)
except HuyaLoginError as exc: except HuyaLoginError as exc:
@@ -89,7 +89,7 @@ def register_huya_with_sms_line(
normalized_phone=normalized_phone, normalized_phone=normalized_phone,
sms_url=item.url, sms_url=item.url,
) )
except Exception as exc: except Exception as exc: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
return HuyaAutoRegisterResult( return HuyaAutoRegisterResult(
phone=phone, phone=phone,
provider=item.provider, provider=item.provider,
@@ -155,7 +155,7 @@ def register_huya_with_sms_line(
sms_url=item.url, sms_url=item.url,
attempts=attempts, attempts=attempts,
) )
except Exception as exc: except Exception as exc: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
return HuyaAutoRegisterResult( return HuyaAutoRegisterResult(
phone=phone, phone=phone,
provider=item.provider, provider=item.provider,
-1
View File
@@ -5,7 +5,6 @@
from __future__ import annotations from __future__ import annotations
import base64
import os import os
import struct import struct
+2 -3
View File
@@ -10,7 +10,7 @@ import time
import uuid import uuid
from collections.abc import Mapping from collections.abc import Mapping
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from datetime import UTC, datetime
from urllib.parse import quote, urlsplit, urlunsplit from urllib.parse import quote, urlsplit, urlunsplit
import requests import requests
@@ -30,7 +30,6 @@ from .login import (
generate_request_id, generate_request_id,
) )
CHANGE_PASSWORD_VERSION = "2.5" CHANGE_PASSWORD_VERSION = "2.5"
CHANGE_PASSWORD_CHECK_URI = "60011" CHANGE_PASSWORD_CHECK_URI = "60011"
CHANGE_PASSWORD_SEND_SMS_URI = "60003" CHANGE_PASSWORD_SEND_SMS_URI = "60003"
@@ -497,7 +496,7 @@ def change_huya_password_with_sms_line(
) -> HuyaChangePasswordResult: ) -> HuyaChangePasswordResult:
"""使用同一手机号接码链接完成改密短信验证。""" """使用同一手机号接码链接完成改密短信验证。"""
changer = HuyaPasswordChanger(uid=uid, cookie=cookie, proxies=proxies) changer = HuyaPasswordChanger(uid=uid, cookie=cookie, proxies=proxies)
sent_at = datetime.now() sent_at = datetime.now(UTC)
code_result = changer.send_code() code_result = changer.send_code()
if not code_result.success or not code_result.session_data: if not code_result.success or not code_result.session_data:
return HuyaChangePasswordResult( return HuyaChangePasswordResult(
-1
View File
@@ -4,7 +4,6 @@ from __future__ import annotations
from collections.abc import Iterable, Mapping from collections.abc import Iterable, Mapping
import requests
from requests.cookies import RequestsCookieJar from requests.cookies import RequestsCookieJar
+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)
+40 -11
View File
@@ -19,13 +19,17 @@ 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
from loguru import logger
ROOT = Path(__file__).resolve().parent.parent.parent 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)),
@@ -49,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 "")
@@ -105,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())
@@ -119,13 +142,13 @@ def _load_db() -> dict:
if PRIMARY_PROFILE_DB.exists(): if PRIMARY_PROFILE_DB.exists():
try: try:
return json.loads(PRIMARY_PROFILE_DB.read_text("utf-8")) return json.loads(PRIMARY_PROFILE_DB.read_text("utf-8"))
except Exception: except Exception as exc: # noqa: BLE001
pass 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: except Exception as exc: # noqa: BLE001
pass logger.debug(f"读取显式迁移画像库失败: {exc}")
return {} return {}
@@ -135,8 +158,8 @@ def _save_db(db: dict) -> None:
PRIMARY_PROFILE_DB.write_text( PRIMARY_PROFILE_DB.write_text(
json.dumps(db, indent=2, ensure_ascii=False), encoding="utf-8" json.dumps(db, indent=2, ensure_ascii=False), encoding="utf-8"
) )
except Exception: except Exception as exc: # noqa: BLE001
pass logger.debug(f"保存设备画像库失败: {exc}")
def _enrich_profile(profile: dict) -> tuple[dict, bool]: def _enrich_profile(profile: dict) -> tuple[dict, bool]:
@@ -164,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(
+135 -21
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,13 +11,16 @@ import json
import struct import struct
from pathlib import Path from pathlib import Path
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
MAP, LIST = 0x08, 0x09 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"
@@ -116,27 +120,109 @@ class Envelope:
self._parse() self._parse()
@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
/ "evidence"
/ "cert_keycap.json"
)
if candidate.exists():
try:
return cls._load_from_path(candidate)
except Exception:
pass
return cls(base64.b64decode(DEFAULT_QURL_B64))
@classmethod @classmethod
def _load_from_path(cls, p: Path) -> "Envelope": 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": if p.suffix == ".json":
j = json.loads(p.read_text("utf-8")) j = json.loads(p.read_text("utf-8"))
q = next( q = next(
@@ -172,7 +258,6 @@ class Envelope:
assert (h & 0x0F) == MAP assert (h & 0x0F) == MAP
cnt, p = _read_len_int(d, p) cnt, p = _read_len_int(d, p)
for _ in range(cnt): for _ in range(cnt):
kh = d[p]
p += 1 p += 1
kln = d[p] kln = d[p]
p += 1 p += 1
@@ -236,7 +321,7 @@ class Envelope:
assert self.uid_off is not None assert self.uid_off is not None
return struct.unpack_from(">Q", self.raw, self.uid_off)[0] return struct.unpack_from(">Q", self.raw, self.uid_off)[0]
def patch_uid(self, uid: int) -> "Envelope": def patch_uid(self, uid: int) -> Envelope:
assert self.uid_off is not None assert self.uid_off is not None
struct.pack_into(">Q", self.raw, self.uid_off, uid) struct.pack_into(">Q", self.raw, self.uid_off, uid)
return self return self
@@ -246,7 +331,7 @@ class Envelope:
assert self.cert_off is not None assert self.cert_off is not None
return bytes(self.raw[self.cert_off : self.cert_off + self.cert_len]) return bytes(self.raw[self.cert_off : self.cert_off + self.cert_len])
def patch_cert(self, cert: bytes) -> "Envelope": def patch_cert(self, cert: bytes) -> Envelope:
assert self.cert_off is not None assert self.cert_off is not None
b64 = base64.b64encode(cert) b64 = base64.b64encode(cert)
if len(b64) != self.cert_len: if len(b64) != self.cert_len:
@@ -256,7 +341,8 @@ class Envelope:
self.raw[self.cert_off : self.cert_off + self.cert_len] = b64 self.raw[self.cert_off : self.cert_off + self.cert_len] = b64
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
@@ -267,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 = {};
+15 -17
View File
@@ -3,6 +3,9 @@ TAF/WUP 帧解码器 — 将二进制帧转为可读摘要,用于日志输出
""" """
from typing import Any, cast from typing import Any, cast
from loguru import logger
from .taf_protocol import TafInputStream, TafType from .taf_protocol import TafInputStream, TafType
from .wup_protocol import normalize_wup_payload from .wup_protocol import normalize_wup_payload
@@ -100,13 +103,13 @@ def _decode_taf_struct(ins: TafInputStream, depth: int = 0) -> dict:
if depth < 5: if depth < 5:
try: try:
val = _decode_taf_value(ins, dtype, depth) val = _decode_taf_value(ins, dtype, depth)
except Exception: except Exception: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
val = f"<decode_err:0x{dtype:02x}>" val = f"<decode_err:0x{dtype:02x}>"
else: else:
val = f"<...>" val = "<...>"
try: try:
ins.skip_field(dtype) ins.skip_field(dtype)
except Exception: except Exception: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
break break
key = f"tag{tag}" key = f"tag{tag}"
if key in fields: if key in fields:
@@ -152,13 +155,7 @@ def _decode_wup_body(body: bytes) -> dict:
if tag > 10: if tag > 10:
break break
ins.read_head() ins.read_head()
if tag == 1: if tag == 1 or tag in (2, 3) or tag == 4:
if dtype != TafType.ZERO:
ins._read_int_value(dtype)
elif tag in (2, 3):
if dtype != TafType.ZERO:
ins._read_int_value(dtype)
elif tag == 4:
if dtype != TafType.ZERO: if dtype != TafType.ZERO:
ins._read_int_value(dtype) ins._read_int_value(dtype)
elif tag == 5: elif tag == 5:
@@ -200,19 +197,20 @@ def _decode_wup_body(body: bytes) -> dict:
if v: if v:
try: try:
tins = TafInputStream(v) tins = TafInputStream(v)
ttag, tdt = tins.peek_head() _ttag, tdt = tins.peek_head()
if tdt == TafType.STRUCT_BEGIN: if tdt == TafType.STRUCT_BEGIN:
tins.read_head() tins.read_head()
result[k] = _decode_taf_struct(tins) result[k] = _decode_taf_struct(tins)
else: else:
result[k] = f"<{len(v)}B>" result[k] = f"<{len(v)}B>"
except Exception: except Exception as exc: # noqa: BLE001
logger.debug(f"TAF 嵌套结构解码失败: {exc}")
result[k] = f"<{len(v)}B>" result[k] = f"<{len(v)}B>"
else: else:
result[k] = _decode_taf_value(sins, vt) result[k] = _decode_taf_value(sins, vt)
except Exception: except Exception as exc: # noqa: BLE001
pass logger.debug(f"TAF 字段解码失败: {exc}")
except Exception as e: except Exception as e: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
result["err"] = str(e) result["err"] = str(e)
return result return result
@@ -291,7 +289,7 @@ def format_wss_log(body: bytes, cmd: int, seq: int, direction: str) -> str:
try: try:
ins = TafInputStream(clean) ins = TafInputStream(clean)
# 看第一个 head # 看第一个 head
tag, dtype = ins.peek_head() _tag, dtype = ins.peek_head()
if dtype == TafType.STRUCT_BEGIN: if dtype == TafType.STRUCT_BEGIN:
ins.read_head() ins.read_head()
fields = _decode_taf_struct(ins) fields = _decode_taf_struct(ins)
@@ -319,6 +317,6 @@ def format_wss_log(body: bytes, cmd: int, seq: int, direction: str) -> str:
if fields: if fields:
return f"{prefix} {cmd_name} {_fmt_fields(cast(dict[str, Any], _truncate(fields)))}" return f"{prefix} {cmd_name} {_fmt_fields(cast(dict[str, Any], _truncate(fields)))}"
return f"{prefix} {cmd_name}" return f"{prefix} {cmd_name}"
except Exception: except Exception: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
cmd_name = CMD_NAMES.get(cmd, f"0x{cmd:02x}") cmd_name = CMD_NAMES.get(cmd, f"0x{cmd:02x}")
return f"{prefix} {cmd_name} ({len(body)}B)" return f"{prefix} {cmd_name} ({len(body)}B)"
+14 -14
View File
@@ -11,13 +11,13 @@ import base64
import hashlib import hashlib
import json import json
import random import random
import struct
import urllib.parse import urllib.parse
import urllib.request import urllib.request
from typing import Any, Optional, Callable from collections.abc import Callable
from typing import Any
from .cookie_utils import cookie_pairs, normalize_cookie_pairs, normalize_huya_cookie from .cookie_utils import cookie_pairs, normalize_cookie_pairs, normalize_huya_cookie
from .taf_protocol import TafOutputStream, TafInputStream, TafType, TafStruct from .taf_protocol import TafInputStream, TafOutputStream, TafStruct
from .wup_protocol import WupRequest, WupResponse from .wup_protocol import WupRequest, WupResponse
CDNWS_HOST = "cdnws.api.huya.com" CDNWS_HOST = "cdnws.api.huya.com"
@@ -92,7 +92,7 @@ class WSConnectParaInfo(TafStruct):
def _gen_trace_id() -> str: def _gen_trace_id() -> str:
"""生成 sTraceId (格式 hex8:hex8:0:0HAR 实证)""" """生成 sTraceId (格式 hex8:hex8:0:0HAR 实证)"""
h = "%016x" % random.getrandbits(64) h = f"{random.getrandbits(64):016x}"
return f"{h}:{h}:0:0" return f"{h}:{h}:0:0"
@@ -294,7 +294,7 @@ class HuyaHttpClient:
import gzip import gzip
resp_data = gzip.decompress(resp_data) resp_data = gzip.decompress(resp_data)
except Exception as e: except Exception as e: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
self.logger(f"[HTTP] ❌ 请求失败: {type(e).__name__}: {e}") self.logger(f"[HTTP] ❌ 请求失败: {type(e).__name__}: {e}")
return None return None
@@ -611,13 +611,13 @@ class HuyaHttpClient:
or resp.headers.get("Content-Encoding") == "gzip" or resp.headers.get("Content-Encoding") == "gzip"
): ):
resp_data = gzip.decompress(resp_data) resp_data = gzip.decompress(resp_data)
except Exception as e: except Exception as e: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
self.logger(f"[LIVELINK] ❌ 小程序码请求失败: {type(e).__name__}: {e}") self.logger(f"[LIVELINK] ❌ 小程序码请求失败: {type(e).__name__}: {e}")
return None return None
try: try:
data = json.loads(resp_data.decode("utf-8", "replace")) data = json.loads(resp_data.decode("utf-8", "replace"))
except Exception as e: except Exception as e: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
self.logger(f"[LIVELINK] ❌ 小程序码响应解析失败: {type(e).__name__}: {e}") self.logger(f"[LIVELINK] ❌ 小程序码响应解析失败: {type(e).__name__}: {e}")
return None return None
@@ -679,13 +679,13 @@ class HuyaHttpClient:
or resp.headers.get("Content-Encoding") == "gzip" or resp.headers.get("Content-Encoding") == "gzip"
): ):
resp_data = gzip.decompress(resp_data) resp_data = gzip.decompress(resp_data)
except Exception as e: except Exception as e: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
self.logger(f"[LIVELINK] ❌ 二维码状态请求失败: {type(e).__name__}: {e}") self.logger(f"[LIVELINK] ❌ 二维码状态请求失败: {type(e).__name__}: {e}")
return None return None
try: try:
data = json.loads(resp_data.decode("utf-8", "replace")) data = json.loads(resp_data.decode("utf-8", "replace"))
except Exception as e: except Exception as e: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
self.logger( self.logger(
f"[LIVELINK] ❌ 二维码状态响应解析失败: {type(e).__name__}: {e}" f"[LIVELINK] ❌ 二维码状态响应解析失败: {type(e).__name__}: {e}"
) )
@@ -849,11 +849,11 @@ class HuyaHttpClient:
): ):
"""order_type=None 时自动尝试从 1 到 10 找到有效值""" """order_type=None 时自动尝试从 1 到 10 找到有效值"""
from .shop_structs import ( from .shop_structs import (
CreateOrderReqV5, CreateOrderAccountParam,
CreateOrderRsp,
CreateOrderExtraParam, CreateOrderExtraParam,
CreateOrderPromotionParam, CreateOrderPromotionParam,
CreateOrderAccountParam, CreateOrderReqV5,
CreateOrderRsp,
) )
# 如果指定了具体值,直接试 # 如果指定了具体值,直接试
@@ -951,7 +951,7 @@ class HuyaHttpClient:
baseinfo = self._generate_rpc_baseinfo(uid, guid, cookie) baseinfo = self._generate_rpc_baseinfo(uid, guid, cookie)
url = f"https://{CDNWS_HOST}/?baseinfo={baseinfo}" url = f"https://{CDNWS_HOST}/?baseinfo={baseinfo}"
self.logger(f"[HTTP] POST shopMiddleUI.payOrderSubmitV5") self.logger("[HTTP] POST shopMiddleUI.payOrderSubmitV5")
self.logger(f"[HTTP] 发送 hex前60: {wup_data[:60].hex()}") self.logger(f"[HTTP] 发送 hex前60: {wup_data[:60].hex()}")
req = urllib.request.Request( req = urllib.request.Request(
@@ -972,7 +972,7 @@ class HuyaHttpClient:
import gzip import gzip
resp_data = gzip.decompress(resp_data) resp_data = gzip.decompress(resp_data)
except Exception as e: except Exception as e: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
self.logger(f"[HTTP] ❌ payOrderSubmitV5 失败: {e}") self.logger(f"[HTTP] ❌ payOrderSubmitV5 失败: {e}")
return None return None
+3 -5
View File
@@ -14,12 +14,11 @@ from http.cookies import SimpleCookie
from urllib.parse import quote, urlsplit, urlunsplit from urllib.parse import quote, urlsplit, urlunsplit
import requests import requests
from requests.cookies import RequestsCookieJar
from loguru import logger from loguru import logger
from requests.cookies import RequestsCookieJar
from .cookie_utils import normalize_huya_cookie from .cookie_utils import normalize_huya_cookie
APP_ID = "5002" APP_ID = "5002"
APP_VERSION = "2.6" APP_VERSION = "2.6"
APP_SIGN = "1ce3bf682483d03f146f58232ec10635" APP_SIGN = "1ce3bf682483d03f146f58232ec10635"
@@ -101,7 +100,7 @@ def generate_context(device_id: str | None = None, mid: str | None = None) -> st
def generate_request_id() -> str: def generate_request_id() -> str:
"""生成 requestId,形态参考旧实现的日内毫秒数。""" """生成 requestId,形态参考旧实现的日内毫秒数。"""
now = dt.datetime.now(dt.timezone.utc) now = dt.datetime.now(dt.UTC)
midnight = now.replace(hour=0, minute=0, second=0, microsecond=0) midnight = now.replace(hour=0, minute=0, second=0, microsecond=0)
return str(int((now - midnight).total_seconds() * 1000)) return str(int((now - midnight).total_seconds() * 1000))
@@ -381,8 +380,7 @@ class HuyaPasswordLogin:
last_exc: Exception | None = None last_exc: Exception | None = None
for attempt in range(max_ip_retries + 1): for attempt in range(max_ip_retries + 1):
if attempt > 0: if attempt > 0 and not self._swap_proxy():
if not self._swap_proxy():
logger.warning("无可用代理可切换,停止换 IP 重试") logger.warning("无可用代理可切换,停止换 IP 重试")
break break
try: try:
+15 -16
View File
@@ -5,8 +5,7 @@
来源m-shop.yaoguo.com jce/ShopFacade.jsapi/orderui.tsapi/mall/PlayMallNewHome.ts 来源m-shop.yaoguo.com jce/ShopFacade.jsapi/orderui.tsapi/mall/PlayMallNewHome.ts
""" """
from typing import List, Dict, Optional from .taf_protocol import TafInputStream, TafOutputStream, TafStruct, TafType
from .taf_protocol import TafOutputStream, TafInputStream, TafStruct, TafType
class OrderType: class OrderType:
@@ -68,7 +67,7 @@ def _skip_to_struct_end(ins: TafInputStream):
"""跳过当前结构里未解析的尾部字段,停在 STRUCT_END 前。""" """跳过当前结构里未解析的尾部字段,停在 STRUCT_END 前。"""
while True: while True:
pos = ins.buf.tell() pos = ins.buf.tell()
tag, dtype = ins.read_head() _tag, dtype = ins.read_head()
if dtype == TafType.STRUCT_END: if dtype == TafType.STRUCT_END:
ins.buf.seek(pos) ins.buf.seek(pos)
return return
@@ -387,7 +386,7 @@ class GoodsPriceInfo(TafStruct):
self.spuId: str = "" self.spuId: str = ""
self.minPrice: int = 0 self.minPrice: int = 0
self.maxPrice: int = 0 self.maxPrice: int = 0
self.skuMap: Dict[int, GoodsSkuItem] = {} self.skuMap: dict[int, GoodsSkuItem] = {}
self.stock: int = 0 self.stock: int = 0
self.buyLimit: int = 0 self.buyLimit: int = 0
self.price: int = 0 self.price: int = 0
@@ -427,7 +426,7 @@ class GoodsPriceInfo(TafStruct):
def first_sku_id(self) -> int: def first_sku_id(self) -> int:
if not self.skuMap: if not self.skuMap:
return 0 return 0
return sorted(self.skuMap.keys())[0] return min(self.skuMap.keys())
@property @property
def sku_list(self) -> list[dict]: def sku_list(self) -> list[dict]:
@@ -500,7 +499,7 @@ class OrderListGoodsDetail(TafStruct):
self.buyerUid: int = 0 # tag 18 self.buyerUid: int = 0 # tag 18
self.virtualType: int = 0 # tag 19 self.virtualType: int = 0 # tag 19
self.quantity: int = 0 # tag 20 self.quantity: int = 0 # tag 20
self.shopInfo: Optional[OrderListShopInfo] = None # tag 21 self.shopInfo: OrderListShopInfo | None = None # tag 21
self.points: int = 0 # tag 23 self.points: int = 0 # tag 23
def read_from(self, ins: TafInputStream): def read_from(self, ins: TafInputStream):
@@ -544,7 +543,7 @@ class OrderListItem(TafStruct):
self.totalPrice: int = 0 # tag 12 分 self.totalPrice: int = 0 # tag 12 分
self.createTime: int = 0 # tag 14 毫秒时间戳 self.createTime: int = 0 # tag 14 毫秒时间戳
self.payTime: int = 0 # tag 15 毫秒时间戳 self.payTime: int = 0 # tag 15 毫秒时间戳
self.goodsDetail: Optional[OrderListGoodsDetail] = None # tag 16 self.goodsDetail: OrderListGoodsDetail | None = None # tag 16
def read_from(self, ins: TafInputStream): def read_from(self, ins: TafInputStream):
self.bizOrderId = ins.read_string(0, default=self.bizOrderId) self.bizOrderId = ins.read_string(0, default=self.bizOrderId)
@@ -611,7 +610,7 @@ class QueryUserOrderListRsp(TafStruct):
self.code: int = 0 self.code: int = 0
self.message: str = "" self.message: str = ""
self.totalCount: int = 0 self.totalCount: int = 0
self.orders: List[OrderListItem] = [] self.orders: list[OrderListItem] = []
@staticmethod @staticmethod
def _read_order_item(ins: TafInputStream, _tag: int): def _read_order_item(ins: TafInputStream, _tag: int):
@@ -692,9 +691,9 @@ class CreateOrderPromotionParam(TafStruct):
class CreateOrderAccountParam(TafStruct): class CreateOrderAccountParam(TafStruct):
def __init__(self): def __init__(self):
self.payoutTypeList: List[int] = [] # tag 0 Vector<INT32> self.payoutTypeList: list[int] = [] # tag 0 Vector<INT32>
self.payoutChargeAmount: int = 0 # tag 1 self.payoutChargeAmount: int = 0 # tag 1
self.cancelPayoutTypeList: List[int] = [] # tag 2 self.cancelPayoutTypeList: list[int] = [] # tag 2
self.recycleSupplierId: int = 0 # tag 3 self.recycleSupplierId: int = 0 # tag 3
self.claimPrice: int = 0 # tag 4 self.claimPrice: int = 0 # tag 4
@@ -739,20 +738,20 @@ class CreateOrderReqV5(TafStruct):
self.gameId: str = "" # tag 8 self.gameId: str = "" # tag 8
self.orderId: int = 0 # tag 9 self.orderId: int = 0 # tag 9
self.src: int = 0 # tag 10 self.src: int = 0 # tag 10
self.couponUserIds: List[int] = [] # tag 11 Vector<INT64> self.couponUserIds: list[int] = [] # tag 11 Vector<INT64>
self.orderType: int = 0 # tag 12 self.orderType: int = 0 # tag 12
self.extraParam: Optional[CreateOrderExtraParam] = None # tag 13 self.extraParam: CreateOrderExtraParam | None = None # tag 13
self.scene: int = 0 # tag 14 self.scene: int = 0 # tag 14
self.promotionItems: List = [] # tag 15 Vector<PromotionItem> self.promotionItems: list = [] # tag 15 Vector<PromotionItem>
self.sourceId: str = "" # tag 16 self.sourceId: str = "" # tag 16
self.env: Dict[str, str] = {} # tag 17 Map<STRING,STRING> self.env: dict[str, str] = {} # tag 17 Map<STRING,STRING>
self.orderScene: int = 0 # tag 18 self.orderScene: int = 0 # tag 18
self.watchWord: str = "" # tag 19 self.watchWord: str = "" # tag 19
self.marketingChannel: str = "" # tag 20 self.marketingChannel: str = "" # tag 20
self.promotionParam: Optional[CreateOrderPromotionParam] = None # tag 21 self.promotionParam: CreateOrderPromotionParam | None = None # tag 21
self.externalTraceKey: str = "" # tag 22 self.externalTraceKey: str = "" # tag 22
self.kefuUid: int = 0 # tag 23 self.kefuUid: int = 0 # tag 23
self.accountParam: Optional[CreateOrderAccountParam] = None # tag 24 self.accountParam: CreateOrderAccountParam | None = None # tag 24
self.parentOrderId: int = 0 # tag 25 self.parentOrderId: int = 0 # tag 25
self.vendorAccountType: str = "" # tag 26 self.vendorAccountType: str = "" # tag 26
self.vendorAccountVal: str = "" # tag 27 self.vendorAccountVal: str = "" # tag 27
+1 -2
View File
@@ -29,7 +29,6 @@ from .login import (
generate_request_id, generate_request_id,
) )
SMS_CODE_URI = "60027" SMS_CODE_URI = "60027"
SMS_LOGIN_URI = "60025" SMS_LOGIN_URI = "60025"
SMS_CODE_URL = "https://udblgn.huya.com/web/v2/smsCode" SMS_CODE_URL = "https://udblgn.huya.com/web/v2/smsCode"
@@ -604,7 +603,7 @@ class HuyaSmsLogin:
phone: str = "", phone: str = "",
proxies: Mapping[str, str] | None = None, proxies: Mapping[str, str] | None = None,
timeout: tuple[float, float] | None = None, timeout: tuple[float, float] | None = None,
) -> "HuyaSmsLogin": ) -> HuyaSmsLogin:
"""从发码阶段返回的 state 恢复短信登录会话。""" """从发码阶段返回的 state 恢复短信登录会话。"""
try: try:
raw = base64.urlsafe_b64decode(state.encode("ascii")) raw = base64.urlsafe_b64decode(state.encode("ascii"))
+13 -13
View File
@@ -9,9 +9,9 @@
0x0c ZERO 0x0d SIMPLE_LIST 0x0c ZERO 0x0d SIMPLE_LIST
""" """
import struct
import io import io
from typing import Any, Dict, List, Optional, Tuple import struct
from typing import Any
class TafType: class TafType:
@@ -136,7 +136,7 @@ class TafOutputStream:
# ---- Map ---- # ---- Map ----
def write_map( def write_map(
self, tag: int, value: Dict[Any, Any], key_writer=None, val_writer=None self, tag: int, value: dict[Any, Any], key_writer=None, val_writer=None
): ):
self.write_head(tag, TafType.MAP) self.write_head(tag, TafType.MAP)
self.write_int32(0, len(value)) self.write_int32(0, len(value))
@@ -151,7 +151,7 @@ class TafOutputStream:
self._write_any(1, v) self._write_any(1, v)
# ---- List ---- # ---- List ----
def write_list(self, tag: int, value: List[Any], item_writer=None): def write_list(self, tag: int, value: list[Any], item_writer=None):
self.write_head(tag, TafType.LIST) self.write_head(tag, TafType.LIST)
self.write_int32(0, len(value)) self.write_int32(0, len(value))
for item in value: for item in value:
@@ -192,7 +192,7 @@ class TafInputStream:
def __init__(self, data: bytes): def __init__(self, data: bytes):
self.buf = io.BytesIO(data) self.buf = io.BytesIO(data)
def peek_head(self) -> Tuple[int, int]: def peek_head(self) -> tuple[int, int]:
"""读取 head 但不消费(用于探测)""" """读取 head 但不消费(用于探测)"""
pos = self.buf.tell() pos = self.buf.tell()
try: try:
@@ -200,7 +200,7 @@ class TafInputStream:
finally: finally:
self.buf.seek(pos) self.buf.seek(pos)
def read_head(self) -> Tuple[int, int]: def read_head(self) -> tuple[int, int]:
"""返回 (tag, type)""" """返回 (tag, type)"""
data = self.buf.read(1) data = self.buf.read(1)
if not data: if not data:
@@ -255,7 +255,7 @@ class TafInputStream:
def _read_int_len(self) -> int: def _read_int_len(self) -> int:
"""读 map/list 长度(int32 带优化)""" """读 map/list 长度(int32 带优化)"""
tag, dtype = self.read_head() _tag, dtype = self.read_head()
return self._read_int_value(dtype) return self._read_int_value(dtype)
def _read_int_value(self, dtype: int) -> int: def _read_int_value(self, dtype: int) -> int:
@@ -273,7 +273,7 @@ class TafInputStream:
def _skip_struct(self): def _skip_struct(self):
while True: while True:
tag, dtype = self.read_head() _tag, dtype = self.read_head()
if dtype == TafType.STRUCT_END: if dtype == TafType.STRUCT_END:
break break
self.skip_field(dtype) self.skip_field(dtype)
@@ -293,7 +293,7 @@ class TafInputStream:
self.skip_field(it) self.skip_field(it)
# ---- 带跳过策略的字段读取:找到 tag,否则返回默认 ---- # ---- 带跳过策略的字段读取:找到 tag,否则返回默认 ----
def _find_tag(self, target_tag: int, required: bool) -> Optional[Tuple[int, int]]: def _find_tag(self, target_tag: int, required: bool) -> tuple[int, int] | None:
"""逐个读 head,tag 相等则返回,tag 超过则回退并返回 None""" """逐个读 head,tag 相等则返回,tag 超过则回退并返回 None"""
while True: while True:
pos = self.buf.tell() pos = self.buf.tell()
@@ -402,7 +402,7 @@ class TafInputStream:
# ---- 复合类型 ---- # ---- 复合类型 ----
def read_map( def read_map(
self, tag: int, required: bool = False, key_reader=None, val_reader=None self, tag: int, required: bool = False, key_reader=None, val_reader=None
) -> Dict: ) -> dict:
found = self._find_tag(tag, required) found = self._find_tag(tag, required)
if not found: if not found:
return {} return {}
@@ -418,7 +418,7 @@ class TafInputStream:
result[k] = v result[k] = v
return result return result
def read_list(self, tag: int, required: bool = False, item_reader=None) -> List: def read_list(self, tag: int, required: bool = False, item_reader=None) -> list:
found = self._find_tag(tag, required) found = self._find_tag(tag, required)
if not found: if not found:
return [] return []
@@ -441,7 +441,7 @@ class TafInputStream:
obj = struct_class() obj = struct_class()
obj.read_from(self) obj.read_from(self)
# 消费 STRUCT_END # 消费 STRUCT_END
t, dt = self.read_head() _t, dt = self.read_head()
if dt != TafType.STRUCT_END: if dt != TafType.STRUCT_END:
raise ValueError(f"期望 STRUCT_END, 实际 0x{dt:02x}") raise ValueError(f"期望 STRUCT_END, 实际 0x{dt:02x}")
return obj return obj
@@ -486,7 +486,7 @@ class TafStruct:
def read_from(self, ins: TafInputStream): def read_from(self, ins: TafInputStream):
raise NotImplementedError raise NotImplementedError
def to_dict(self) -> Dict[str, Any]: def to_dict(self) -> dict[str, Any]:
"""调试用:转字典""" """调试用:转字典"""
return {k: v for k, v in self.__dict__.items() if not k.startswith("_")} return {k: v for k, v in self.__dict__.items() if not k.startswith("_")}
+1 -2
View File
@@ -13,7 +13,6 @@ import onnxruntime as ort
from PIL import Image from PIL import Image
from scipy.optimize import linear_sum_assignment from scipy.optimize import linear_sum_assignment
MODEL_DIR = Path(__file__).resolve().parent / "models" MODEL_DIR = Path(__file__).resolve().parent / "models"
@@ -262,7 +261,7 @@ class HuyaCaptchaOcr:
target["cropped_image"], target["cropped_image"],
char["cropped_image"], char["cropped_image"],
) )
except Exception: except Exception: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
score_matrix[target_index][char_index] = 1e6 score_matrix[target_index][char_index] = 1e6
row_ind, col_ind = linear_sum_assignment(score_matrix) row_ind, col_ind = linear_sum_assignment(score_matrix)
+4 -5
View File
@@ -17,13 +17,12 @@ import cv2
import execjs import execjs
import numpy as np import numpy as np
import requests import requests
from PIL import Image
from loguru import logger from loguru import logger
from PIL import Image
from .ocr import HuyaCaptchaOcr, default_ocr from .ocr import HuyaCaptchaOcr, default_ocr
from .track import format_track, generate_slide_track from .track import format_track, generate_slide_track
DEFAULT_UA = ( DEFAULT_UA = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) " "AppleWebKit/537.36 (KHTML, like Gecko) "
@@ -138,7 +137,7 @@ class HuyaVerificationSolver:
max_width = max(bg.shape[1], tip.shape[1]) max_width = max(bg.shape[1], tip.shape[1])
def pad_right(img, target_width): def pad_right(img, target_width):
height, width = img.shape[:2] _height, width = img.shape[:2]
if width >= target_width: if width >= target_width:
return img return img
return cv2.copyMakeBorder( return cv2.copyMakeBorder(
@@ -300,8 +299,8 @@ class HuyaVerificationSolver:
"虎牙登录风控strategys完整结构: {}", "虎牙登录风控strategys完整结构: {}",
json.dumps(strategies, ensure_ascii=False)[:1200], json.dumps(strategies, ensure_ascii=False)[:1200],
) )
except Exception: # noqa: BLE001 except Exception as exc: # noqa: BLE001
pass logger.debug(f"风控策略日志序列化失败: {exc}")
strategy_url_lower = strategy_url.lower() strategy_url_lower = strategy_url.lower()
# 判定依据是 URL 路径,不是 strategy 数值。 # 判定依据是 URL 路径,不是 strategy 数值。
# 实测(2026-08-25): strategy=64 时 pt_auth.html 是滑块、qr_auth.html 才是扫码, # 实测(2026-08-25): strategy=64 时 pt_auth.html 是滑块、qr_auth.html 才是扫码,
+26 -24
View File
@@ -21,14 +21,14 @@ import re
import struct import struct
import time import time
from collections import deque from collections import deque
from typing import Any, Optional, Callable, cast from collections.abc import Callable
from typing import Any, cast
import websockets import websockets
from .frame_decoder import _decode_taf_struct, _truncate, format_wss_log
from .taf_protocol import TafInputStream, TafOutputStream, TafStruct, TafType
from .wup_protocol import WupRequest, WupResponse from .wup_protocol import WupRequest, WupResponse
from .taf_protocol import TafOutputStream, TafInputStream, TafType, TafStruct
from .frame_decoder import format_wss_log, _decode_taf_struct, _truncate
SHOP_WS_HOST = "77bc035c-ws.va.huya.com" SHOP_WS_HOST = "77bc035c-ws.va.huya.com"
# 商城端点 baseinfo (conn4, h5_/index.html) — 商城业务 shopMiddleUI 走此通道 # 商城端点 baseinfo (conn4, h5_/index.html) — 商城业务 shopMiddleUI 走此通道
@@ -76,7 +76,7 @@ class WssMessage:
def decode(cls, data: bytes) -> "WssMessage": def decode(cls, data: bytes) -> "WssMessage":
if len(data) < 6: if len(data) < 6:
raise ValueError(f"消息太短: {len(data)} bytes") raise ValueError(f"消息太短: {len(data)} bytes")
version, command = struct.unpack(">BB", data[0:2]) _version, command = struct.unpack(">BB", data[0:2])
sequence = struct.unpack(">I", data[2:6])[0] sequence = struct.unpack(">I", data[2:6])[0]
body = data[6:] body = data[6:]
return cls(command=command, sequence=sequence, body=body) return cls(command=command, sequence=sequence, body=body)
@@ -225,7 +225,7 @@ class HuyaWssClient:
), ),
timeout=timeout, timeout=timeout,
) )
except asyncio.TimeoutError: except TimeoutError:
self.logger(f"[WSS] 连接超时({timeout}s") self.logger(f"[WSS] 连接超时({timeout}s")
raise raise
except Exception as e: except Exception as e:
@@ -256,7 +256,7 @@ class HuyaWssClient:
format_wss_log(msg.body, msg.command, msg.sequence, "") format_wss_log(msg.body, msg.command, msg.sequence, "")
) )
await self._handle_message(msg) await self._handle_message(msg)
except Exception as e: except Exception as e: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
self.logger( self.logger(
f"[WSS] 解析消息失败: {e} raw_hex={raw_bytes[:50].hex()}" f"[WSS] 解析消息失败: {e} raw_hex={raw_bytes[:50].hex()}"
) )
@@ -264,7 +264,7 @@ class HuyaWssClient:
pass pass
except websockets.exceptions.ConnectionClosed as e: except websockets.exceptions.ConnectionClosed as e:
self.logger(f"[WSS] 连接关闭: code={e.code} reason={e.reason}") self.logger(f"[WSS] 连接关闭: code={e.code} reason={e.reason}")
except Exception as e: except Exception as e: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
self.logger(f"[WSS] 接收循环异常: {type(e).__name__}: {e}") self.logger(f"[WSS] 接收循环异常: {type(e).__name__}: {e}")
async def _handle_message(self, msg: WssMessage): async def _handle_message(self, msg: WssMessage):
@@ -289,7 +289,7 @@ class HuyaWssClient:
await self.send_heartbeat() await self.send_heartbeat()
except asyncio.CancelledError: except asyncio.CancelledError:
pass pass
except Exception as e: except Exception as e: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
self.logger(f"[WSS] 心跳循环异常: {e}") self.logger(f"[WSS] 心跳循环异常: {e}")
async def send_heartbeat(self): async def send_heartbeat(self):
@@ -376,7 +376,7 @@ class HuyaWssClient:
try: try:
body = await asyncio.wait_for(future, timeout) body = await asyncio.wait_for(future, timeout)
except asyncio.TimeoutError: except TimeoutError:
if future in self.rpc_queue: if future in self.rpc_queue:
self.rpc_queue.remove(future) self.rpc_queue.remove(future)
self.logger("[✗] wsLaunch 超时") self.logger("[✗] wsLaunch 超时")
@@ -399,7 +399,7 @@ class HuyaWssClient:
) )
return return
ins = TafInputStream(treq) ins = TafInputStream(treq)
tag, dtype = ins.peek_head() _tag, dtype = ins.peek_head()
if dtype != 0x0A: # STRUCT_BEGIN if dtype != 0x0A: # STRUCT_BEGIN
self.logger(f"[RPC] wsLaunch tRsp 非结构体 dtype=0x{dtype:02x}") self.logger(f"[RPC] wsLaunch tRsp 非结构体 dtype=0x{dtype:02x}")
return return
@@ -432,7 +432,7 @@ class HuyaWssClient:
self.logger( self.logger(
f"[RPC] wsLaunch 解析: guid={self._launch_guid} ip={self._launch_ip}" f"[RPC] wsLaunch 解析: guid={self._launch_guid} ip={self._launch_ip}"
) )
except Exception as e: except Exception as e: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
self.logger(f"[RPC] wsLaunch 响应解析失败: {e}") self.logger(f"[RPC] wsLaunch 响应解析失败: {e}")
@staticmethod @staticmethod
@@ -526,7 +526,7 @@ class HuyaWssClient:
try: try:
body = await asyncio.wait_for(future, timeout) body = await asyncio.wait_for(future, timeout)
except asyncio.TimeoutError: except TimeoutError:
if future in self.rpc_queue: if future in self.rpc_queue:
self.rpc_queue.remove(future) self.rpc_queue.remove(future)
self.logger("[✗] getConfig 超时") self.logger("[✗] getConfig 超时")
@@ -596,7 +596,7 @@ class HuyaWssClient:
try: try:
body = await asyncio.wait_for(future, timeout) body = await asyncio.wait_for(future, timeout)
except asyncio.TimeoutError: except TimeoutError:
if future in self.rpc_queue: if future in self.rpc_queue:
self.rpc_queue.remove(future) self.rpc_queue.remove(future)
self.logger(f"[✗] {service}.{method} 超时") self.logger(f"[✗] {service}.{method} 超时")
@@ -610,15 +610,17 @@ class HuyaWssClient:
if data and isinstance(data, bytes) and len(data) > 0: if data and isinstance(data, bytes) and len(data) > 0:
try: try:
ins = TafInputStream(data) ins = TafInputStream(data)
tag, dtype = ins.peek_head() _tag, dtype = ins.peek_head()
if dtype == TafType.STRUCT_BEGIN: if dtype == TafType.STRUCT_BEGIN:
ins.read_head() ins.read_head()
decoded = _decode_taf_struct(ins) decoded = _decode_taf_struct(ins)
self.logger( self.logger(
f"[←] {service}.{method} {key}: {_truncate(decoded)}" f"[←] {service}.{method} {key}: {_truncate(decoded)}"
) )
except Exception: except Exception as exc: # noqa: BLE001
pass self.logger(
f"[debug] WUP 响应字段解码失败: {service}.{method}.{key}: {exc}"
)
if rsp_class is None: if rsp_class is None:
return body return body
@@ -696,11 +698,11 @@ class HuyaWssClient:
order_type: int = 6, order_type: int = 6,
): ):
from .shop_structs import ( from .shop_structs import (
CreateOrderReqV5, CreateOrderAccountParam,
CreateOrderRsp,
CreateOrderExtraParam, CreateOrderExtraParam,
CreateOrderPromotionParam, CreateOrderPromotionParam,
CreateOrderAccountParam, CreateOrderReqV5,
CreateOrderRsp,
) )
req = CreateOrderReqV5() req = CreateOrderReqV5()
@@ -781,7 +783,7 @@ class HuyaWssClient:
try: try:
body = await asyncio.wait_for(future, 15.0) body = await asyncio.wait_for(future, 15.0)
except asyncio.TimeoutError: except TimeoutError:
if future in self.rpc_queue: if future in self.rpc_queue:
self.rpc_queue.remove(future) self.rpc_queue.remove(future)
self.logger("[✗] payOrderSubmitV5 超时") self.logger("[✗] payOrderSubmitV5 超时")
@@ -794,13 +796,13 @@ class HuyaWssClient:
if data and isinstance(data, bytes) and len(data) > 0: if data and isinstance(data, bytes) and len(data) > 0:
try: try:
ins = TafInputStream(data) ins = TafInputStream(data)
tag, dtype = ins.peek_head() _tag, dtype = ins.peek_head()
if dtype == TafType.STRUCT_BEGIN: if dtype == TafType.STRUCT_BEGIN:
ins.read_head() ins.read_head()
decoded = _decode_taf_struct(ins) decoded = _decode_taf_struct(ins)
self.logger(f"[←] payOrderSubmitV5 {key}: {_truncate(decoded)}") self.logger(f"[←] payOrderSubmitV5 {key}: {_truncate(decoded)}")
except Exception: except Exception as exc: # noqa: BLE001
pass self.logger(f"[debug] WUP 支付响应字段解码失败: {key}: {exc}")
result = wup_resp.readStruct("tRsp", PayOrderRes) result = wup_resp.readStruct("tRsp", PayOrderRes)
if result is None: if result is None:
result = wup_resp.readStruct("tResp", PayOrderRes) result = wup_resp.readStruct("tResp", PayOrderRes)
+10 -5
View File
@@ -7,9 +7,10 @@ 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, Dict from typing import Any
# TAF 类型标签 # TAF 类型标签
INT8, INT16, INT32, INT64 = 0x00, 0x01, 0x02, 0x03 INT8, INT16, INT32, INT64 = 0x00, 0x01, 0x02, 0x03
@@ -97,7 +98,7 @@ class _Writer:
def _build_meta_json(session: int, trace_id: str) -> str: def _build_meta_json(session: int, trace_id: str) -> str:
"""构造 _wup_data.t0.t2 元数据 JSON。""" """构造 _wup_data.t0.t2 元数据 JSON。"""
meta: Dict[str, Any] = { meta: dict[str, Any] = {
"associationId": 8193, "associationId": 8193,
"funcName": "hypasswordLogin", "funcName": "hypasswordLogin",
"group": 1, "group": 1,
@@ -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
@@ -167,7 +172,7 @@ def _build_wup_data(
session: int, session: int,
trace_id: str, trace_id: str,
user_action_json: str, user_action_json: str,
device_info: Dict[str, str], device_info: dict[str, str],
) -> None: ) -> None:
"""编码 _wup_data struct。""" """编码 _wup_data struct。"""
meta_json = _build_meta_json(session, trace_id) meta_json = _build_meta_json(session, trace_id)
@@ -235,7 +240,7 @@ def build_password_login_wup(
session: int, session: int,
trace_id: str, trace_id: str,
user_action_json: str, user_action_json: str,
device_info: Dict[str, str], device_info: dict[str, str],
) -> bytes: ) -> bytes:
"""构造密码登录的 WUP TAF 请求体。""" """构造密码登录的 WUP TAF 请求体。"""
wd = _Writer() wd = _Writer()
+13 -12
View File
@@ -11,8 +11,9 @@ Wup 包结构:
""" """
import struct import struct
from typing import Any, Dict, Optional from typing import Any
from .taf_protocol import TafOutputStream, TafInputStream, TafType, TafStruct
from .taf_protocol import TafInputStream, TafOutputStream, TafStruct, TafType
class WupRequest: class WupRequest:
@@ -27,9 +28,9 @@ class WupRequest:
self.sFuncName: str = "" # tag 6 self.sFuncName: str = "" # tag 6
self.sBuffer: bytes = b"" # tag 7 self.sBuffer: bytes = b"" # tag 7
self.iTimeout: int = 3000 # tag 8 self.iTimeout: int = 3000 # tag 8
self.context: Dict[str, str] = {} # tag 9 self.context: dict[str, str] = {} # tag 9
self.status: Dict[str, str] = {} # tag 10 self.status: dict[str, str] = {} # tag 10
self.newdata: Dict[str, bytes] = {} self.newdata: dict[str, bytes] = {}
def setServant(self, name: str): def setServant(self, name: str):
self.sServantName = name self.sServantName = name
@@ -141,9 +142,9 @@ class WupResponse:
self.sFuncName: str = "" self.sFuncName: str = ""
self.sBuffer: bytes = b"" self.sBuffer: bytes = b""
self.iTimeout: int = 0 self.iTimeout: int = 0
self.context: Dict[str, str] = {} self.context: dict[str, str] = {}
self.status: Dict[str, str] = {} self.status: dict[str, str] = {}
self.newdata: Dict[str, bytes] = {} self.newdata: dict[str, bytes] = {}
def decode(self, data: bytes): def decode(self, data: bytes):
"""解码响应(不包含长度前缀;若含前缀会自动跳过)""" """解码响应(不包含长度前缀;若含前缀会自动跳过)"""
@@ -210,7 +211,7 @@ class WupResponse:
_, vt = ins.read_head() _, vt = ins.read_head()
val = _read_bytes_value(ins, vt) val = _read_bytes_value(ins, vt)
self.newdata[key] = val self.newdata[key] = val
except Exception as e: except Exception as e: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
print(f"[WupResponse] 解析 newdata 失败: {e}") print(f"[WupResponse] 解析 newdata 失败: {e}")
def readStruct(self, key: str, struct_class=None): def readStruct(self, key: str, struct_class=None):
@@ -235,13 +236,13 @@ class WupResponse:
ins = TafInputStream(data) ins = TafInputStream(data)
# newdata 里的结构体以 STRUCT_BEGIN 开头 # newdata 里的结构体以 STRUCT_BEGIN 开头
try: try:
tag, dtype = ins.peek_head() _tag, dtype = ins.peek_head()
if dtype == TafType.STRUCT_BEGIN: if dtype == TafType.STRUCT_BEGIN:
ins.read_head() # 消费 STRUCT_BEGIN ins.read_head() # 消费 STRUCT_BEGIN
obj = struct_class() obj = struct_class()
obj.read_from(ins) obj.read_from(ins)
return obj return obj
except Exception as e: except Exception as e: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
print(f"[WupResponse] 解析 {struct_class.__name__} 失败: {e}") print(f"[WupResponse] 解析 {struct_class.__name__} 失败: {e}")
return None return None
@@ -269,7 +270,7 @@ def _read_bytes_value(ins: TafInputStream, dtype: int) -> bytes:
return ins.buf.read(length) return ins.buf.read(length)
def _read_map_value(ins: TafInputStream, dtype: int) -> Dict: def _read_map_value(ins: TafInputStream, dtype: int) -> dict:
if dtype != TafType.MAP: if dtype != TafType.MAP:
return {} return {}
count = ins._read_int_len() count = ins._read_int_len()
+2 -3
View File
@@ -5,12 +5,11 @@ from __future__ import annotations
import json import json
import re import re
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from datetime import UTC, datetime
from urllib.parse import urlparse from urllib.parse import urlparse
import requests import requests
CODE_PATTERN = re.compile( CODE_PATTERN = re.compile(
r"(?:verification\s+code|验证码|校验码|动态码|安全码)\D{0,20}(\d{4,8})", r"(?:verification\s+code|验证码|校验码|动态码|安全码)\D{0,20}(\d{4,8})",
re.IGNORECASE, re.IGNORECASE,
@@ -99,7 +98,7 @@ def _parse_sms8_time(value: str) -> datetime | None:
return None return None
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y/%m/%d %H:%M:%S"): for fmt in ("%Y-%m-%d %H:%M:%S", "%Y/%m/%d %H:%M:%S"):
try: try:
return datetime.strptime(text, fmt) return datetime.strptime(text, fmt).replace(tzinfo=UTC)
except ValueError: except ValueError:
continue continue
return None return None
+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)
结尾: 结尾:
+5 -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"
@@ -38,6 +38,10 @@ packages = ["core", "utils", "web"]
[tool.pytest.ini_options] [tool.pytest.ini_options]
testpaths = ["tests"] testpaths = ["tests"]
[tool.ruff.lint.per-file-ignores]
# FastAPI evaluates dependency metadata in route parameter defaults by design.
"web/backend/**/*.py" = ["B008"]
[dependency-groups] [dependency-groups]
dev = [ dev = [
"pyright>=1.1.411", "pyright>=1.1.411",
+2 -3
View File
@@ -14,7 +14,6 @@ from sqlalchemy import JSON, MetaData, create_engine, func, inspect, select
from sqlalchemy.engine import Connection, Engine from sqlalchemy.engine import Connection, Engine
from sqlalchemy.schema import Table from sqlalchemy.schema import Table
PROJECT_ROOT = Path(__file__).resolve().parents[1] PROJECT_ROOT = Path(__file__).resolve().parents[1]
DEFAULT_SOURCE = PROJECT_ROOT / "data" / "web.db" DEFAULT_SOURCE = PROJECT_ROOT / "data" / "web.db"
IGNORED_SOURCE_TABLES = {"alembic_version"} IGNORED_SOURCE_TABLES = {"alembic_version"}
@@ -147,7 +146,7 @@ def main() -> int:
if target_url: if target_url:
os.environ["DATABASE_URL"] = target_url os.environ["DATABASE_URL"] = target_url
from web.backend import models # noqa: F401 from web.backend import models # noqa: F401
from web.backend.database import Base, DATABASE_URL, run_migrations from web.backend.database import DATABASE_URL, Base, run_migrations
target_url = target_url or DATABASE_URL target_url = target_url or DATABASE_URL
if not target_url.startswith("mysql+"): if not target_url.startswith("mysql+"):
@@ -226,6 +225,6 @@ def main() -> int:
if __name__ == "__main__": if __name__ == "__main__":
try: try:
sys.exit(main()) sys.exit(main())
except Exception as exc: except Exception as exc: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
print(f"迁移失败:{exc}", file=sys.stderr) print(f"迁移失败:{exc}", file=sys.stderr)
sys.exit(1) sys.exit(1)
+20 -16
View File
@@ -22,6 +22,7 @@ from __future__ import annotations
import argparse import argparse
import json import json
import logging
import os import os
import re import re
import secrets import secrets
@@ -34,19 +35,22 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parent ROOT = Path(__file__).resolve().parent
sys.path.insert(0, str(ROOT)) sys.path.insert(0, str(ROOT))
from pyvm.algorithm import build_plaintext, generate_encrypt_msg_offline # noqa: E402 from pyvm.algorithm import generate_encrypt_msg_offline
from pyvm.mall import MallSession, generate_encrypt_msg as mall_generate # noqa: E402 from pyvm.login_profile import midas_login_params
from pyvm.session import SessionState, load_session # noqa: E402 from pyvm.mall import MallSession
from pyvm.login_profile import midas_login_params # noqa: E402 from pyvm.mall import generate_encrypt_msg as mall_generate
from pyvm.payment_errors import describe_payment_failure # noqa: E402 from pyvm.payment_errors import describe_payment_failure
from pyvm.protocol import ( # noqa: E402 from pyvm.protocol import (
GOODS_USER_AGENT,
MALL_API_URL, MALL_API_URL,
MALL_USER_AGENT, MALL_USER_AGENT,
GOODS_USER_AGENT,
PAY_APPID, PAY_APPID,
validate_goods_materials, validate_goods_materials,
validate_mall_materials, validate_mall_materials,
) )
from pyvm.session import SessionState, load_session
logger = logging.getLogger(__name__)
REPLAY = ROOT / "replay" REPLAY = ROOT / "replay"
DEFAULT_APPID = PAY_APPID DEFAULT_APPID = PAY_APPID
@@ -102,7 +106,8 @@ def _load_cap(path: Path) -> dict:
d = attempt() d = attempt()
if isinstance(d, dict) and "C" in d: if isinstance(d, dict) and "C" in d:
return d return d
except Exception: except Exception as exc: # noqa: BLE001
logger.debug("deepCap 候选格式解析失败: %s", exc)
continue continue
raise ValueError(f"无法解析 deepCap 文件: {path}(前 80 字符: {raw[:80]!r})") raise ValueError(f"无法解析 deepCap 文件: {path}(前 80 字符: {raw[:80]!r})")
@@ -121,8 +126,8 @@ def parse_plaintext(path: str | Path) -> dict:
d = json.loads(raw) d = json.loads(raw)
if isinstance(d, dict): if isinstance(d, dict):
return {str(k): str(v) for k, v in d.items()} return {str(k): str(v) for k, v in d.items()}
except Exception: except Exception as exc: # noqa: BLE001
pass logger.debug("订单响应 JSON 解析失败: %s", exc)
fields: dict[str, str] = {} fields: dict[str, str] = {}
for kv in raw.split("&"): for kv in raw.split("&"):
k, _, v = kv.partition("=") k, _, v = kv.partition("=")
@@ -179,7 +184,6 @@ def _gen_with_session(st: SessionState, order: dict) -> str:
def cmd_gen(args) -> int: def cmd_gen(args) -> int:
st = load_session(args.session) st = load_session(args.session)
order = load_order(args.order) order = load_order(args.order)
params = {k: order.get(k, "") for k in ORDER_FIELDS}
hex_msg = _gen_with_session(st, order) hex_msg = _gen_with_session(st, order)
out = Path(args.output) if args.output else ROOT / "config" / "encrypt_msg.txt" out = Path(args.output) if args.output else ROOT / "config" / "encrypt_msg.txt"
out.parent.mkdir(parents=True, exist_ok=True) out.parent.mkdir(parents=True, exist_ok=True)
@@ -249,7 +253,7 @@ def cmd_submit(args) -> int:
return 0 return 0
print(f"❌ web_save ret:{ret}({js.get('err_code', '')})—— 见 case 踩坑记录") print(f"❌ web_save ret:{ret}({js.get('err_code', '')})—— 见 case 踩坑记录")
return 1 return 1
except Exception: except Exception: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
return 1 return 1
@@ -399,8 +403,8 @@ def cmd_mall_submit(args) -> int:
js = json.loads(raw) js = json.loads(raw)
if isinstance(js, dict): if isinstance(js, dict):
ret = js.get("ret", js.get("result_code", js.get("code"))) ret = js.get("ret", js.get("result_code", js.get("code")))
except Exception: except Exception as exc: # noqa: BLE001
pass logger.debug("支付响应 JSON 解析失败: %s", exc)
ok = ret in (0, "0") ok = ret in (0, "0")
if ok: if ok:
_write_private_text(out, raw) _write_private_text(out, raw)
@@ -999,8 +1003,8 @@ def cmd_mall_pay(args) -> int:
print("\n ══ 微信扫码支付(终端二维码)══") print("\n ══ 微信扫码支付(终端二维码)══")
try: try:
qr.terminal(compact=False) qr.terminal(compact=False)
except Exception: # noqa: BLE001 except Exception as exc: # noqa: BLE001
pass logger.debug("终端二维码输出失败: %s", exc)
return 0 return 0
@@ -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,
+10 -8
View File
@@ -13,22 +13,24 @@ from .algorithm import (
generate_encrypt_msg, generate_encrypt_msg,
generate_encrypt_msg_offline, generate_encrypt_msg_offline,
) )
from .goods import GoodsSession, generate_encrypt_msg as goods_generate from .goods import GoodsSession
from .mall import MallSession, generate_encrypt_msg as mall_generate from .goods import generate_encrypt_msg as goods_generate
from .mall import MallSession
from .mall import generate_encrypt_msg as mall_generate
from .pagedoo_vm import PagedooVM, run_frame from .pagedoo_vm import PagedooVM, run_frame
from .session import SessionState, load_session from .session import SessionState, load_session
__all__ = [ __all__ = [
"GoodsSession",
"MallSession",
"PagedooVM",
"SessionState",
"build_plaintext", "build_plaintext",
"decode_d", "decode_d",
"generate_encrypt_msg", "generate_encrypt_msg",
"generate_encrypt_msg_offline", "generate_encrypt_msg_offline",
"GoodsSession",
"goods_generate", "goods_generate",
"MallSession",
"mall_generate",
"PagedooVM",
"run_frame",
"SessionState",
"load_session", "load_session",
"mall_generate",
"run_frame",
] ]
+20 -25
View File
@@ -13,9 +13,11 @@
""" """
from __future__ import annotations from __future__ import annotations
import itertools import itertools
import json import json
import math import math
import random
import urllib.parse import urllib.parse
from pathlib import Path from pathlib import Path
@@ -39,7 +41,7 @@ def _ic(x):
try: try:
f = float(x) f = float(x)
return 0 if math.isnan(f) else int(f) return 0 if math.isnan(f) else int(f)
except Exception: except Exception: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
return 0 return 0
@@ -78,7 +80,7 @@ def js_typeof(v):
return "number" return "number"
if isinstance(v, str): if isinstance(v, str):
return "string" return "string"
if isinstance(v, JSFunction) or isinstance(v, HostFunction): if isinstance(v, (JSFunction, HostFunction)):
return "function" return "function"
return "object" return "object"
@@ -98,7 +100,7 @@ def js_truthy(v):
def _nan_ok(x): def _nan_ok(x):
try: try:
return not (isinstance(x, float) and math.isnan(x)) return not (isinstance(x, float) and math.isnan(x))
except Exception: except Exception: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
return True return True
@@ -206,7 +208,7 @@ def js_num(v):
return v return v
try: try:
return float(v) return float(v)
except Exception: except Exception: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
return float("nan") return float("nan")
@@ -225,12 +227,12 @@ def js_eq(a, b):
if isinstance(a, (int, float)) and isinstance(b, str): if isinstance(a, (int, float)) and isinstance(b, str):
try: try:
return a == float(b) return a == float(b)
except Exception: except Exception: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
return False return False
if isinstance(b, (int, float)) and isinstance(a, str): if isinstance(b, (int, float)) and isinstance(a, str):
try: try:
return float(a) == b return float(a) == b
except Exception: except Exception: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
return False return False
return a == b return a == b
@@ -354,9 +356,7 @@ def js_set(obj, key, val):
pass pass
if isinstance(obj, str): if isinstance(obj, str):
raise TypeError("Cannot assign to read only property") raise TypeError("Cannot assign to read only property")
cur = getattr( cur = getattr(__import__("pyvm.algorithm", fromlist=["x"]).VM, "_last_u", None)
getattr(__import__("pyvm.algorithm", fromlist=["x"]), "VM"), "_last_u", None
)
raise TypeError( raise TypeError(
f"invalid set target obj={type(obj).__name__} key={key!r} val={type(val).__name__} u={cur}" f"invalid set target obj={type(obj).__name__} key={key!r} val={type(val).__name__} u={cur}"
) )
@@ -418,7 +418,7 @@ class Window:
class JSFunction: class JSFunction:
"""VM 函数(解释器实例工厂返回的 h)。""" """VM 函数(解释器实例工厂返回的 h)。"""
__slots__ = ("entry", "args", "s", "n", "t", "vm", "name") __slots__ = ("args", "entry", "n", "name", "s", "t", "vm")
def __init__(self, vm, entry, args, s, n, t): def __init__(self, vm, entry, args, s, n, t):
self.vm = vm self.vm = vm
@@ -455,7 +455,7 @@ def js_call(fn, this, args):
return fn.fn(this, *args) return fn.fn(this, *args)
if callable(fn): if callable(fn):
return fn(this, *args) return fn(this, *args)
raise TypeError(f"{str(fn)} is not a function u={getattr(VM, '_last_u', None)}") raise TypeError(f"{fn!s} is not a function u={getattr(VM, '_last_u', None)}")
def js_apply(fn, this, args): def js_apply(fn, this, args):
@@ -735,7 +735,7 @@ def h_parsefloat(this, s):
def h_isnan(this, x): def h_isnan(this, x):
try: try:
return math.isnan(float(x)) return math.isnan(float(x))
except Exception: except Exception: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
return True return True
@@ -841,7 +841,6 @@ class VM:
def run(self, fn: JSFunction, call_args): def run(self, fn: JSFunction, call_args):
"""执行一个 VM 函数实例(h)。fn 来自 make() 或 op87/58 创建的闭包。""" """执行一个 VM 函数实例(h)。fn 来自 make() 或 op87/58 创建的闭包。"""
self.inst_id += 1 self.inst_id += 1
iid = self.inst_id
if self.use_init_c and self.init_c is not None: if self.use_init_c and self.init_c is not None:
C = self.init_c C = self.init_c
self.use_init_c = False self.use_init_c = False
@@ -854,7 +853,6 @@ class VM:
d = [] # 异常续延栈 d = [] # 异常续延栈
l = UNDEF # 最近异常(op12 读取) l = UNDEF # 最近异常(op12 读取)
o = self.o o = self.o
cap = None # 外部捕获回调(验证用)
if not hasattr(self, "_all_trace"): if not hasattr(self, "_all_trace"):
self._all_trace = [] self._all_trace = []
while True: while True:
@@ -897,7 +895,7 @@ class VM:
for x in v: for x in v:
try: try:
parts.append("%02x" % (int(x) & 255)) parts.append("%02x" % (int(x) & 255))
except Exception: except Exception: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
parts.append("??") parts.append("??")
self.out_hex = "".join(parts) self.out_hex = "".join(parts)
# 仅供离线排查 webSave 输出缓冲何时变化,生产路径默认不启用。 # 仅供离线排查 webSave 输出缓冲何时变化,生产路径默认不启用。
@@ -1528,9 +1526,7 @@ class VM:
if not ( if not (
isinstance(fn, (JSFunction, HostFunction)) or callable(fn) isinstance(fn, (JSFunction, HostFunction)) or callable(fn)
): ):
raise RuntimeError( raise RuntimeError(f"op97 non-function at u={u} fn={fn!r}")
"op97 non-function at u=%s fn=%r" % (u, fn)
)
js_set(C, dest, js_apply(fn, thisv, f)) js_set(C, dest, js_apply(fn, thisv, f))
elif op == 98: elif op == 98:
a = o[u + 1] a = o[u + 1]
@@ -1648,10 +1644,10 @@ class VM:
u += 1 u += 1
js_set(C, a, None) js_set(C, a, None)
else: else:
raise RuntimeError("unknown opcode %s at %s" % (op, u)) raise RuntimeError(f"unknown opcode {op} at {u}")
except _VMThrow as e: except _VMThrow as e:
if not d: if not d:
raise RuntimeError("VM uncaught throw: %s" % (e.value,)) raise RuntimeError(f"VM uncaught throw: {e.value}")
l = e.value l = e.value
u = d.pop() u = d.pop()
continue continue
@@ -1659,9 +1655,8 @@ class VM:
if not d: if not d:
raise raise
if not isinstance(d, list): if not isinstance(d, list):
raise RuntimeError( raise TypeError(
"d corrupted: %r (type %s) at trace %s" f"d corrupted: {d!r} (type {type(d).__name__}) at trace {self._trace[-3:]}"
% (d, type(d).__name__, self._trace[-3:])
) )
l = e l = e
u = d.pop() u = d.pop()
@@ -1747,14 +1742,14 @@ def decode_d(v):
vs = p[ci + 1 :] vs = p[ci + 1 :]
try: try:
obj.set(key, decode_d(json.loads(vs))) obj.set(key, decode_d(json.loads(vs)))
except Exception: except Exception: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
obj.set(key, decode_d(vs)) obj.set(key, decode_d(vs))
return obj return obj
if v.lstrip("-").isdigit(): if v.lstrip("-").isdigit():
return int(v) return int(v)
try: try:
return float(v) return float(v)
except Exception: except Exception: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
return v return v
+2 -3
View File
@@ -12,7 +12,6 @@ from __future__ import annotations
import json import json
from pathlib import Path from pathlib import Path
from typing import Any
from .algorithm import generate_encrypt_msg_offline from .algorithm import generate_encrypt_msg_offline
@@ -64,7 +63,7 @@ class GoodsSession:
raise ValueError("key16/key1 应为 16 字节") raise ValueError("key16/key1 应为 16 字节")
@classmethod @classmethod
def from_session_state(cls, path: str | Path) -> "GoodsSession": def from_session_state(cls, path: str | Path) -> GoodsSession:
"""从 scripts/capture-session.mjs 生成的 session-state.json 加载。""" """从 scripts/capture-session.mjs 生成的 session-state.json 加载。"""
d = json.loads(Path(path).read_text(encoding="utf-8")) d = json.loads(Path(path).read_text(encoding="utf-8"))
return cls( return cls(
@@ -85,7 +84,7 @@ class GoodsSession:
} }
@classmethod @classmethod
def from_json(cls, d: dict) -> "GoodsSession": def from_json(cls, d: dict) -> GoodsSession:
return cls( return cls(
d["xmidas_ops"], d["xmidas_ops"],
d["key16"], d["key16"],
+8 -5
View File
@@ -19,7 +19,6 @@ import copy
import json import json
import re import re
from pathlib import Path from pathlib import Path
from typing import Any
from .algorithm import UNDEF, JSObject, Window from .algorithm import UNDEF, JSObject, Window
from .pagedoo_vm import PagedooVM from .pagedoo_vm import PagedooVM
@@ -73,12 +72,16 @@ class MallSession:
f"mall xMidasOps 应为 59620(非 goods 59640),实际 {len(self.xmidas_ops)}" f"mall xMidasOps 应为 59620(非 goods 59640),实际 {len(self.xmidas_ops)}"
) )
mid = self.transform_input[10] mid = self.transform_input[10]
if isinstance(mid, list) and mid and isinstance(mid[0], list): if (
if len(mid[0]) != 624: isinstance(mid, list)
and mid
and isinstance(mid[0], list)
and len(mid[0]) != 624
):
raise ValueError(f"624B 中间态长度 != 624: {len(mid[0])}") raise ValueError(f"624B 中间态长度 != 624: {len(mid[0])}")
@classmethod @classmethod
def from_capture_file(cls, frames_jsonl: str | Path) -> "MallSession": def from_capture_file(cls, frames_jsonl: str | Path) -> MallSession:
"""从捕获的 frames.jsonl 提取同会话 transform_input + xMidasOps。 """从捕获的 frames.jsonl 提取同会话 transform_input + xMidasOps。
frames.jsonl scripts/capture-mall-session.mjs 实时落盘: frames.jsonl scripts/capture-mall-session.mjs 实时落盘:
@@ -127,7 +130,7 @@ class MallSession:
} }
@classmethod @classmethod
def from_json(cls, d: dict) -> "MallSession": def from_json(cls, d: dict) -> MallSession:
return cls(d["transform_input"], d["xmidas_ops"]) return cls(d["transform_input"], d["xmidas_ops"])
@@ -38,13 +38,13 @@ def get_official_orders(cookies: dict[str, str], count: int = 20) -> dict[str, A
except ValueError as exc: except ValueError as exc:
raise RuntimeError("订单状态查询返回非 JSON") from exc raise RuntimeError("订单状态查询返回非 JSON") from exc
if not isinstance(document, dict): if not isinstance(document, dict):
raise RuntimeError("订单状态查询响应格式异常") raise TypeError("订单状态查询响应格式异常")
if document.get("ret_code") not in (None, 0, "0"): if document.get("ret_code") not in (None, 0, "0"):
raise RuntimeError( raise RuntimeError(
f"订单状态查询失败: {document.get('ret_code')} {document.get('ret_msg', '')}" f"订单状态查询失败: {document.get('ret_code')} {document.get('ret_msg', '')}"
) )
if not isinstance(document.get("list", []), list): if not isinstance(document.get("list", []), list):
raise RuntimeError("订单状态查询响应缺少 list") raise TypeError("订单状态查询响应缺少 list")
return document return document
+46 -55
View File
@@ -14,55 +14,55 @@ from __future__ import annotations
import json import json
import math import math
import urllib.parse
from pathlib import Path from pathlib import Path
from .algorithm import ( from .algorithm import (
JSDate,
UNDEF, UNDEF,
HostFunction,
JSDate,
JSFunction, JSFunction,
JSObject, JSObject,
HostFunction, Window,
_ic, _ic,
i32,
u32,
ushr,
shl,
shr,
js_typeof,
js_truthy,
js_str,
js_add,
js_num,
js_eq,
js_streq,
js_index,
js_set,
js_del,
js_keys,
js_call,
js_apply,
js_new,
h_math_floor,
h_math_round,
h_math_ceil,
h_math_min,
h_math_max,
h_math_abs,
h_math_pow,
h_math_sqrt,
h_parseint,
h_parsefloat,
h_isnan,
h_encodeuri,
h_encodeuricomponent,
h_decodeuri, h_decodeuri,
h_decodeuricomponent, h_decodeuricomponent,
h_string_fromcharcode, h_encodeuri,
h_encodeuricomponent,
h_isnan,
h_math_abs,
h_math_ceil,
h_math_floor,
h_math_max,
h_math_min,
h_math_pow,
h_math_round,
h_math_sqrt,
h_new_date, h_new_date,
h_parsefloat,
h_parseint,
h_string_fromcharcode,
i32,
js_add,
js_apply,
js_call,
js_del,
js_eq,
js_index,
js_keys,
js_new,
js_num,
js_set,
js_str,
js_truthy,
js_typeof,
shl,
shr,
ushr,
) )
__all__ = ["PagedooVM", "run_frame", "REPLAY"] REPLAY = Path(__file__).resolve().parent.parent / "replay"
__all__ = ["REPLAY", "PagedooVM", "run_frame"]
# ---------------------------------------------------------------- 辅助 # ---------------------------------------------------------------- 辅助
@@ -71,28 +71,28 @@ __all__ = ["PagedooVM", "run_frame", "REPLAY"]
def _cmp_lt(a, b): def _cmp_lt(a, b):
try: try:
return a < b return a < b
except Exception: except Exception: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
return False return False
def _cmp_le(a, b): def _cmp_le(a, b):
try: try:
return a <= b return a <= b
except Exception: except Exception: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
return False return False
def _cmp_gt(a, b): def _cmp_gt(a, b):
try: try:
return a > b return a > b
except Exception: except Exception: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
return False return False
def _cmp_ge(a, b): def _cmp_ge(a, b):
try: try:
return a >= b return a >= b
except Exception: except Exception: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
return False return False
@@ -127,8 +127,8 @@ def h_arr_slice(this, a=None, b=None):
a = 0 a = 0
if b is None or b is UNDEF: if b is None or b is UNDEF:
b = n b = n
a = int(a) if a == a else 0 a = int(a) if not isinstance(a, float) or not math.isnan(a) else 0
b = int(b) if b == b else n b = int(b) if not isinstance(b, float) or not math.isnan(b) else n
if a < 0: if a < 0:
a = max(0, n + a) a = max(0, n + a)
if b < 0: if b < 0:
@@ -308,8 +308,7 @@ def _pg_index(obj, key):
return HostFunction( return HostFunction(
lambda this: this.t if isinstance(this, JSDate) else obj.t, "valueOf" lambda this: this.t if isinstance(this, JSDate) else obj.t, "valueOf"
) )
if isinstance(obj, str): if isinstance(obj, str) and isinstance(key, str):
if isinstance(key, str):
if key == "length": if key == "length":
return len(obj) return len(obj)
if key == "charCodeAt": if key == "charCodeAt":
@@ -491,15 +490,7 @@ class PagedooVM:
and len(self._host_log) < 2000 and len(self._host_log) < 2000
): ):
# 记录调用目标(简化) # 记录调用目标(简化)
_tgt = ( _tgt = o[u + 2]
o[u + 2]
if op in (4, 11, 44, 50)
else (
o[u + 2]
if op in (0, 18, 23, 26, 43, 48, 84, 107)
else o[u + 2]
)
)
try: try:
_tv = C[_tgt] if 0 <= _tgt < len(C) else UNDEF _tv = C[_tgt] if 0 <= _tgt < len(C) else UNDEF
_tr = ( _tr = (
@@ -510,7 +501,7 @@ class PagedooVM:
else repr(_tv)[:30] else repr(_tv)[:30]
) )
self._host_log.append((op, u, _tr)) self._host_log.append((op, u, _tr))
except Exception: except Exception: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
self._host_log.append((op, u, "?")) self._host_log.append((op, u, "?"))
if ( if (
getattr(self, "_trace", None) is not None getattr(self, "_trace", None) is not None
+2 -3
View File
@@ -10,7 +10,6 @@ import json
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
PAY_APPID = "1450243039" PAY_APPID = "1450243039"
PAY_GOODS_URL = "https://pay.qq.com/midas/minipay_v2/views/cpay/goods.shtml" PAY_GOODS_URL = "https://pay.qq.com/midas/minipay_v2/views/cpay/goods.shtml"
PAY_SAVE_URL = f"https://api.unipay.qq.com/v1/r/{PAY_APPID}/web_save" PAY_SAVE_URL = f"https://api.unipay.qq.com/v1/r/{PAY_APPID}/web_save"
@@ -87,7 +86,7 @@ def validate_goods_materials(
def validate_mall_materials(transform_fixed: dict[str, Any]) -> None: def validate_mall_materials(transform_fixed: dict[str, Any]) -> None:
"""校验 mall 固定槽模板;动态槽仍由当前会话的 GetPayToken 填充。""" """校验 mall 固定槽模板;动态槽仍由当前会话的 GetPayToken 填充。"""
if not isinstance(transform_fixed, dict): if not isinstance(transform_fixed, dict):
raise ValueError("mall transform-fixed 必须是对象") raise TypeError("mall transform-fixed 必须是对象")
required = { required = {
str(index) for index in (1, 2, 3, 4, 5, 7, 8, 9, 11, 12, 13, 14, 15, 16, 17) str(index) for index in (1, 2, 3, 4, 5, 7, 8, 9, 11, 12, 13, 14, 15, 16, 17)
} }
@@ -96,7 +95,7 @@ def validate_mall_materials(transform_fixed: dict[str, Any]) -> None:
raise ValueError(f"mall transform-fixed 缺少槽: {', '.join(missing)}") raise ValueError(f"mall transform-fixed 缺少槽: {', '.join(missing)}")
for index in required: for index in required:
if not isinstance(transform_fixed[index], list): if not isinstance(transform_fixed[index], list):
raise ValueError(f"mall transform-fixed 槽 {index} 不是数组") raise TypeError(f"mall transform-fixed 槽 {index} 不是数组")
def goods_material_diagnostics( def goods_material_diagnostics(
+1 -1
View File
@@ -68,7 +68,7 @@ class SessionState:
} }
@classmethod @classmethod
def from_json(cls, d: dict) -> "SessionState": def from_json(cls, d: dict) -> SessionState:
return cls( return cls(
xmidas_ops=list(d.get("xmidas_ops", [])), xmidas_ops=list(d.get("xmidas_ops", [])),
key16=list(d.get("key16", [])), key16=list(d.get("key16", [])),
+5 -6
View File
@@ -31,10 +31,10 @@ from pyvm.algorithm import (
build_plaintext, build_plaintext,
derive_key1_from_key16, derive_key1_from_key16,
generate_encrypt_msg_offline, generate_encrypt_msg_offline,
) # noqa: E402 )
from pyvm.login_profile import midas_login_params # noqa: E402 from pyvm.login_profile import midas_login_params
from pyvm.payment_errors import describe_payment_failure # noqa: E402 from pyvm.payment_errors import describe_payment_failure
from pyvm.protocol import ( # noqa: E402 from pyvm.protocol import (
DEFAULT_ORDER_PF, DEFAULT_ORDER_PF,
GOODS_USER_AGENT, GOODS_USER_AGENT,
PAY_APPID, PAY_APPID,
@@ -450,7 +450,6 @@ def cmd_check_only(session_path: Path, out_dir: Path) -> int:
from pyvm.order_status import ( from pyvm.order_status import (
completion_summary, completion_summary,
get_official_orders, get_official_orders,
is_finished,
) )
session = load_json(session_path) session = load_json(session_path)
@@ -697,7 +696,7 @@ def main() -> int:
out_dir, fields, int(fp.get("device_fp_length", 0)), plaintext_length out_dir, fields, int(fp.get("device_fp_length", 0)), plaintext_length
) )
body = urllib.parse.urlencode(fields) body = urllib.parse.urlencode(fields)
from pyvm.order_status import order_completion_states, get_official_orders from pyvm.order_status import get_official_orders, order_completion_states
# 尽量在发起支付前建立基线,缩小极快支付造成的检测窗口;失败不阻塞支付,付款码生成后重试。 # 尽量在发起支付前建立基线,缩小极快支付造成的检测窗口;失败不阻塞支付,付款码生成后重试。
baseline_document = None baseline_document = None
+3 -3
View File
@@ -17,8 +17,8 @@ from pathlib import Path
from urllib.error import HTTPError from urllib.error import HTTPError
from urllib.parse import urlencode from urllib.parse import urlencode
from urllib.request import ( from urllib.request import (
HTTPRedirectHandler,
HTTPCookieProcessor, HTTPCookieProcessor,
HTTPRedirectHandler,
Request, Request,
build_opener, build_opener,
) )
@@ -43,7 +43,7 @@ USER_AGENT = (
class NoRedirect(HTTPRedirectHandler): class NoRedirect(HTTPRedirectHandler):
"""Keep OAuth redirects visible while CookieJar receives Set-Cookie headers.""" """Keep OAuth redirects visible while CookieJar receives Set-Cookie headers."""
def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: D401 def redirect_request(self, req, fp, code, msg, headers, newurl):
return None return None
@@ -118,7 +118,7 @@ def g_tk(p_skey: str) -> int:
def parse_poll(body: str) -> tuple[int, str]: def parse_poll(body: str) -> tuple[int, str]:
match = re.search(r"ptuiCB\((.*)\)", body, re.S) match = re.search(r"ptuiCB\((.*)\)", body, re.DOTALL)
if not match: if not match:
raise ValueError("QQ 二维码轮询响应缺少 ptuiCB") raise ValueError("QQ 二维码轮询响应缺少 ptuiCB")
values = re.findall(r"'([^']*)'", match.group(1)) values = re.findall(r"'([^']*)'", match.group(1))
+3 -3
View File
@@ -20,8 +20,8 @@ from pathlib import Path
from urllib.error import HTTPError from urllib.error import HTTPError
from urllib.parse import urlencode from urllib.parse import urlencode
from urllib.request import ( from urllib.request import (
HTTPRedirectHandler,
HTTPCookieProcessor, HTTPCookieProcessor,
HTTPRedirectHandler,
Request, Request,
build_opener, build_opener,
) )
@@ -42,7 +42,7 @@ USER_AGENT = (
class NoRedirect(HTTPRedirectHandler): class NoRedirect(HTTPRedirectHandler):
"""保留 OAuth 回调的 302 和 Set-Cookie。""" """保留 OAuth 回调的 302 和 Set-Cookie。"""
def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: D401 def redirect_request(self, req, fp, code, msg, headers, newurl):
return None return None
@@ -88,7 +88,7 @@ def extract_uuid(page: str) -> str:
r"connect/qrcode/([A-Za-z0-9_-]{8,64})", r"connect/qrcode/([A-Za-z0-9_-]{8,64})",
r"uuid=([A-Za-z0-9_-]{8,64})", r"uuid=([A-Za-z0-9_-]{8,64})",
): ):
match = re.search(pattern, page, re.I) match = re.search(pattern, page, re.IGNORECASE)
if match: if match:
return match.group(1) return match.group(1)
raise ValueError("微信授权页未找到二维码 UUID") raise ValueError("微信授权页未找到二维码 UUID")
+1 -1
View File
@@ -18,7 +18,7 @@ from curl_cffi import requests
ROOT = Path(__file__).resolve().parent.parent ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT)) sys.path.insert(0, str(ROOT))
from pyvm.login_profile import midas_login_params # noqa: E402 from pyvm.login_profile import midas_login_params
YYB_APP_ID = 52575843 YYB_APP_ID = 52575843
SOURCE_ID = "24292013" SOURCE_ID = "24292013"
+9 -5
View File
@@ -32,7 +32,7 @@ WORKER_KEY = os.environ.get("YYB_WORKER_KEY", "")
_jobs: dict[str, dict] = {} _jobs: dict[str, dict] = {}
_lock = threading.Lock() _lock = threading.Lock()
from pyvm.payment_errors import describe_payment_failure # noqa: E402 from pyvm.payment_errors import describe_payment_failure
def _payment_failure_message(job: dict, phase: str, fallback: str) -> str: def _payment_failure_message(job: dict, phase: str, fallback: str) -> str:
@@ -81,13 +81,16 @@ def _safe_log(job: dict, line: str) -> None:
clean = re.sub(r"HOLD_[A-Za-z0-9_-]+", "[订单已隐藏]", clean) clean = re.sub(r"HOLD_[A-Za-z0-9_-]+", "[订单已隐藏]", clean)
clean = re.sub(r"(订单\s*[:]\s*)\S+", r"\1[订单已隐藏]", clean) clean = re.sub(r"(订单\s*[:]\s*)\S+", r"\1[订单已隐藏]", clean)
clean = re.sub( clean = re.sub(
r"(?:token|openid|openkey|cookie)=\S+", "[敏感字段已隐藏]", clean, flags=re.I r"(?:token|openid|openkey|cookie)=\S+",
"[敏感字段已隐藏]",
clean,
flags=re.IGNORECASE,
) )
clean = re.sub( clean = re.sub(
r"(?:pay_token|web_token|anti_token|session_id|sessionid)\s*[:= ]\s*\S+", r"(?:pay_token|web_token|anti_token|session_id|sessionid)\s*[:= ]\s*\S+",
"[敏感字段已隐藏]", "[敏感字段已隐藏]",
clean, clean,
flags=re.I, flags=re.IGNORECASE,
) )
timestamp = time.strftime("%H:%M:%S") timestamp = time.strftime("%H:%M:%S")
with _lock: with _lock:
@@ -282,6 +285,7 @@ def _check_payment_once(job_id: str) -> int:
text=True, text=True,
env=environment, env=environment,
timeout=90, timeout=90,
check=False,
) )
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
_safe_log( _safe_log(
@@ -523,7 +527,7 @@ class Handler(BaseHTTPRequestHandler):
value = self.headers.get("Authorization", "") value = self.headers.get("Authorization", "")
return value == f"Bearer {WORKER_KEY}" return value == f"Bearer {WORKER_KEY}"
def do_POST(self) -> None: # noqa: N802 def do_POST(self) -> None:
if not self._authorized(): if not self._authorized():
return self._json(401, {"detail": "未授权"}) return self._json(401, {"detail": "未授权"})
path = urlparse(self.path).path.strip("/").split("/") path = urlparse(self.path).path.strip("/").split("/")
@@ -687,7 +691,7 @@ class Handler(BaseHTTPRequestHandler):
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
return self._json(500, {"detail": str(exc)}) return self._json(500, {"detail": str(exc)})
def do_GET(self) -> None: # noqa: N802 def do_GET(self) -> None:
if urlparse(self.path).path == "/health": if urlparse(self.path).path == "/health":
return self._json(200, {"status": "ok"}) return self._json(200, {"status": "ok"})
if not self._authorized(): if not self._authorized():
@@ -8,7 +8,6 @@ import unittest
from pathlib import Path from pathlib import Path
from urllib.parse import parse_qs from urllib.parse import parse_qs
_SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "jsdom-pay.py" _SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "jsdom-pay.py"
_SPEC = importlib.util.spec_from_file_location("yyb_jsdom_pay", _SCRIPT) _SPEC = importlib.util.spec_from_file_location("yyb_jsdom_pay", _SCRIPT)
assert _SPEC and _SPEC.loader assert _SPEC and _SPEC.loader
@@ -16,8 +15,8 @@ _MODULE = importlib.util.module_from_spec(_SPEC)
sys.modules[_SPEC.name] = _MODULE sys.modules[_SPEC.name] = _MODULE
_SPEC.loader.exec_module(_MODULE) _SPEC.loader.exec_module(_MODULE)
from pyvm.payment_errors import describe_payment_failure
from pyvm.algorithm import derive_key1_from_key16, generate_encrypt_msg_offline from pyvm.algorithm import derive_key1_from_key16, generate_encrypt_msg_offline
from pyvm.payment_errors import describe_payment_failure
from pyvm.protocol import goods_material_diagnostics, validate_goods_materials from pyvm.protocol import goods_material_diagnostics, validate_goods_materials
+1 -2
View File
@@ -1,8 +1,7 @@
import pytest import pytest
from fastapi import HTTPException
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from fastapi import HTTPException
from web.backend.database import Base from web.backend.database import Base
from web.backend.models import Account, User from web.backend.models import Account, User
+1 -2
View File
@@ -1,9 +1,8 @@
"""充值审计日志的权限、查询和脱敏测试。""" """充值审计日志的权限、查询和脱敏测试。"""
import pytest
import json import json
import pytest
from fastapi import HTTPException from fastapi import HTTPException
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
+2 -2
View File
@@ -1,4 +1,4 @@
from datetime import datetime, timedelta, timezone from datetime import UTC, datetime, timedelta
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -17,7 +17,7 @@ class TestCustomCookieOrder:
self.db.add(self.user) self.db.add(self.user)
self.db.flush() self.db.flush()
now = datetime.now(timezone.utc) now = datetime.now(UTC)
accounts = { accounts = {
name: Account(username=name, password="p", email="e", email_password="ep") name: Account(username=name, password="p", email="e", email_password="ep")
for name in ("account-a", "account-b", "account-c") for name in ("account-a", "account-b", "account-c")
+7 -7
View File
@@ -1,18 +1,13 @@
import pytest
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import patch
from typing import cast from typing import cast
from unittest.mock import patch
import pytest
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from web.backend.database import Base from web.backend.database import Base
from web.backend.models import Account, LoginTask, ProxyConfig, User from web.backend.models import Account, LoginTask, ProxyConfig, User
from web.backend.services.login_service import (
LoginBatchRunner,
cleanup_orphan_relogin_tasks,
)
from web.backend.services.cookie_check_service import check_douyu_cookie
from web.backend.routers.cookies import ( from web.backend.routers.cookies import (
check_cookie_operations, check_cookie_operations,
get_cookie, get_cookie,
@@ -21,6 +16,11 @@ from web.backend.routers.cookies import (
list_cookies, list_cookies,
relogin_invalid_cookie_operations, relogin_invalid_cookie_operations,
) )
from web.backend.services.cookie_check_service import check_douyu_cookie
from web.backend.services.login_service import (
LoginBatchRunner,
cleanup_orphan_relogin_tasks,
)
class TestCookieOperation: class TestCookieOperation:
+2 -2
View File
@@ -1,9 +1,9 @@
"""精英手册兑换:csrf 复用、新链路接口与浏览器状态机路由的测试。""" """精英手册兑换:csrf 复用、新链路接口与浏览器状态机路由的测试。"""
import pytest
from unittest.mock import Mock, call from unittest.mock import Mock, call
import pytest
from core.douyu.activity_client import DouyuActivityClient, DouyuActivityError from core.douyu.activity_client import DouyuActivityClient, DouyuActivityError
+1 -1
View File
@@ -1,6 +1,6 @@
import pytest
from unittest.mock import Mock from unittest.mock import Mock
import pytest
import requests import requests
from core.douyu.activity_client import DouyuActivityClient, DouyuActivityError from core.douyu.activity_client import DouyuActivityClient, DouyuActivityError
+2 -2
View File
@@ -7,13 +7,13 @@ from unittest.mock import ANY, AsyncMock, Mock, patch
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from core.douyu import FishFinRechargeClient, FishFinRechargeConfig
from web.backend.database import Base from web.backend.database import Base
from web.backend.models import Account, AuditLog, DouyuTask, LoginTask, User from web.backend.models import Account, AuditLog, DouyuTask, LoginTask, User
from web.backend.routers.douyu import get_recharge_channel, supplier_recharge_callback from web.backend.routers.douyu import get_recharge_channel, supplier_recharge_callback
from web.backend.services.audit_service import record_audit
from web.backend.services.douyu_runner import DouyuBatchRunner from web.backend.services.douyu_runner import DouyuBatchRunner
from web.backend.services.douyu_service import ensure_douyu_config from web.backend.services.douyu_service import ensure_douyu_config
from web.backend.services.audit_service import record_audit
from core.douyu import FishFinRechargeClient, FishFinRechargeConfig
class TestDouyuGoldRechargeChannel: class TestDouyuGoldRechargeChannel:
+1 -1
View File
@@ -1,6 +1,6 @@
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import Mock
from typing import Any, cast from typing import Any, cast
from unittest.mock import Mock
from core.douyu.login import DouyuLogin from core.douyu.login import DouyuLogin
from core.douyu.login_api_wgapi import WgapiLoginAPI from core.douyu.login_api_wgapi import WgapiLoginAPI
+2 -2
View File
@@ -3,12 +3,12 @@
from unittest.mock import Mock from unittest.mock import Mock
from core.douyu.activity_client import DouyuActivityClient from core.douyu.activity_client import DouyuActivityClient
from web.backend.models import ProxyConfig
from web.backend.services.douyu_runner import DouyuBatchRunner from web.backend.services.douyu_runner import DouyuBatchRunner
from web.backend.services.douyu_runner_core import ( from web.backend.services.douyu_runner_core import (
DouyuBatchRunnerCore,
DOUYU_PROXY_TASK_TYPES, DOUYU_PROXY_TASK_TYPES,
DouyuBatchRunnerCore,
) )
from web.backend.models import ProxyConfig
def _cfg(**overrides) -> ProxyConfig: def _cfg(**overrides) -> ProxyConfig:
-1
View File
@@ -1,5 +1,4 @@
import pytest import pytest
from sqlalchemy import create_engine, event from sqlalchemy import create_engine, event
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
+2 -1
View File
@@ -1,9 +1,10 @@
import pytest
import json import json
import re import re
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import Mock from unittest.mock import Mock
import pytest
from core.douyu.activity_client import DouyuActivityClient, DouyuActivityError from core.douyu.activity_client import DouyuActivityClient, DouyuActivityError
+2 -2
View File
@@ -1,9 +1,9 @@
"""鱼翅直充供应商 API 客户端测试。""" """鱼翅直充供应商 API 客户端测试。"""
import pytest
from unittest.mock import Mock from unittest.mock import Mock
import pytest
from core.douyu.recharge_api import ( from core.douyu.recharge_api import (
FishFinRechargeClient, FishFinRechargeClient,
FishFinRechargeConfig, FishFinRechargeConfig,
+159 -26
View File
@@ -1,46 +1,42 @@
"""虎牙 App 密码登录及相关组件测试。""" """虎牙 App 密码登录及相关组件测试。"""
import pytest import json
import base64
import os import os
import struct import struct
from unittest.mock import patch, MagicMock from unittest.mock import MagicMock, patch
import pytest
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from core.huya import ( from core.huya import (
HuyaAppLoginError, HuyaAppLoginError,
HuyaAppPasswordLogin,
HuyaAppQrAuthRequiredError,
login_huya_app_password,
login_huya_password,
)
from core.huya.app_login import (
DEFAULT_GOLDEN_DEV,
login_cred_with_flow,
wup_password_login_raw,
) )
from core.huya.app_login import 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 User, HuyaAccount from web.backend.models import HuyaAccount, User
from web.backend.schemas import (
HuyaAppPasswordLoginRequest,
HuyaPasswordLoginSelectedRequest,
)
from web.backend.routers.huya import ( from web.backend.routers.huya import (
app_password_login_account, app_password_login_account,
app_password_login_selected_accounts, app_password_login_selected_accounts,
) )
from web.backend.schemas import (
HuyaAppPasswordLoginRequest,
HuyaPasswordLoginSelectedRequest,
)
class TestHuyaAppLogin: class TestHuyaAppLogin:
@@ -97,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"
@@ -105,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(
@@ -128,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):
@@ -172,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):
"""注册失败必须抛错终止,禁止静默回退旧链(不发任何登录请求)。""" """注册失败必须抛错终止,禁止静默回退旧链(不发任何登录请求)。"""
@@ -182,19 +262,72 @@ class TestHuyaAppLogin:
side_effect=DfpRegistrationError("注册链超时"), side_effect=DfpRegistrationError("注册链超时"),
) as m_reg, ) as m_reg,
patch("core.huya.app_login.requests.post") as m_post, patch("core.huya.app_login.requests.post") as m_post,
pytest.raises(HuyaAppLoginError),
): ):
with pytest.raises(HuyaAppLoginError):
wup_password_login_raw("300023887", "pw") wup_password_login_raw("300023887", "pw")
m_reg.assert_called_once() m_reg.assert_called_once()
m_post.assert_not_called() m_post.assert_not_called()
def test_login_cred_flow_registration_failure_is_explicit(self): def test_login_cred_flow_registration_failure_is_explicit(self):
"""login_cred_with_flow 注册失败同样包装为 HuyaAppLoginError 显式失败。""" """login_cred_with_flow 注册失败同样包装为 HuyaAppLoginError 显式失败。"""
with patch( with (
patch(
"core.huya.app_login.register_device", "core.huya.app_login.register_device",
side_effect=DfpRegistrationError("注册链 HTTP 500"), side_effect=DfpRegistrationError("注册链 HTTP 500"),
),
pytest.raises(HuyaAppLoginError, match="注册失败"),
):
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="账号或密码错误"),
): ):
with pytest.raises(HuyaAppLoginError, match="注册失败"):
login_cred_with_flow("300023887", "pw") login_cred_with_flow("300023887", "pw")
def test_router_functions(self): def test_router_functions(self):
+5 -4
View File
@@ -3,13 +3,13 @@
import gzip import gzip
import logging import logging
import tempfile import tempfile
from datetime import datetime, timedelta from datetime import UTC, datetime, timedelta
from pathlib import Path from pathlib import Path
from utils.logger import ( from utils.logger import (
_parse_size,
_SensitiveDataFilter, _SensitiveDataFilter,
_SizeAndDayRotatingFileHandler, _SizeAndDayRotatingFileHandler,
_parse_size,
) )
@@ -40,7 +40,8 @@ class TestLogger:
archives = list(Path(tmpdir).glob("app-2026-08-28.log.*.gz")) archives = list(Path(tmpdir).glob("app-2026-08-28.log.*.gz"))
assert len(archives) == 1 assert len(archives) == 1
archived_content = gzip.open(archives[0], "rt", encoding="utf-8").read() with gzip.open(archives[0], "rt", encoding="utf-8") as archive:
archived_content = archive.read()
current_content = log_path.read_text(encoding="utf-8") current_content = log_path.read_text(encoding="utf-8")
combined = archived_content + current_content combined = archived_content + current_content
assert "super-secret" not in combined assert "super-secret" not in combined
@@ -50,7 +51,7 @@ class TestLogger:
def test_daily_log_switches_to_a_new_dated_file(self): def test_daily_log_switches_to_a_new_dated_file(self):
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
today = datetime.now().date() today = datetime.now(UTC).date()
old_path = ( old_path = (
Path(tmpdir) / f"app-{(today - timedelta(days=1)).isoformat()}.log" Path(tmpdir) / f"app-{(today - timedelta(days=1)).isoformat()}.log"
) )
+1 -1
View File
@@ -68,8 +68,8 @@ class TestMigrationSmoke:
def test_full_chain_applies_on_fresh_sqlite_and_matches_models(self): def test_full_chain_applies_on_fresh_sqlite_and_matches_models(self):
"""空库执行整条迁移链,校验表/列/索引与模型元数据对齐。""" """空库执行整条迁移链,校验表/列/索引与模型元数据对齐。"""
from web.backend.database import Base
import web.backend.database as database_module import web.backend.database as database_module
from web.backend.database import Base
original_url = database_module.DATABASE_URL original_url = database_module.DATABASE_URL
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
+13 -12
View File
@@ -8,13 +8,12 @@ import os
import re import re
import shutil import shutil
import sys import sys
from datetime import datetime, timedelta from datetime import UTC, datetime, timedelta
from logging.handlers import BaseRotatingHandler from logging.handlers import BaseRotatingHandler
from pathlib import Path from pathlib import Path
from loguru import logger from loguru import logger
_LOG_LEVELS = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"} _LOG_LEVELS = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}
_DEFAULT_RETENTION_DAYS = 14 _DEFAULT_RETENTION_DAYS = 14
_DEFAULT_ROTATION_SIZE = 50 * 1024 * 1024 _DEFAULT_ROTATION_SIZE = 50 * 1024 * 1024
@@ -36,7 +35,9 @@ def _parse_positive_int(value: str | None, default: int) -> int:
def _parse_size(value: str | None, default: int = _DEFAULT_ROTATION_SIZE) -> int: def _parse_size(value: str | None, default: int = _DEFAULT_ROTATION_SIZE) -> int:
"""解析 50M、1GiB 等易读大小;非法值保持安全默认值。""" """解析 50M、1GiB 等易读大小;非法值保持安全默认值。"""
matched = re.fullmatch(r"\s*(\d+)\s*([kmgt]?i?b?)?\s*", str(value or ""), re.I) matched = re.fullmatch(
r"\s*(\d+)\s*([kmgt]?i?b?)?\s*", str(value or ""), re.IGNORECASE
)
if not matched: if not matched:
return default return default
amount = int(matched.group(1)) amount = int(matched.group(1))
@@ -102,7 +103,7 @@ class _SizeAndDayRotatingFileHandler(BaseRotatingHandler):
super().__init__(str(filename), "a", encoding="utf-8", delay=True) super().__init__(str(filename), "a", encoding="utf-8", delay=True)
self.max_bytes = max_bytes self.max_bytes = max_bytes
self.retention_days = retention_days self.retention_days = retention_days
self._active_day = datetime.now().date() self._active_day = datetime.now(UTC).date()
path = Path(filename) path = Path(filename)
self._log_dir = path.parent self._log_dir = path.parent
self._suffix = path.suffix self._suffix = path.suffix
@@ -113,8 +114,8 @@ class _SizeAndDayRotatingFileHandler(BaseRotatingHandler):
self._log_dir / f"{self._filename_prefix}-{day.isoformat()}{self._suffix}" self._log_dir / f"{self._filename_prefix}-{day.isoformat()}{self._suffix}"
) )
def shouldRollover(self, record: logging.LogRecord) -> bool: # noqa: N802 def shouldRollover(self, record: logging.LogRecord) -> bool:
if datetime.now().date() != self._active_day: if datetime.now(UTC).date() != self._active_day:
return True return True
if self.stream is None: if self.stream is None:
self.stream = self._open() self.stream = self._open()
@@ -122,12 +123,12 @@ class _SizeAndDayRotatingFileHandler(BaseRotatingHandler):
message = f"{self.format(record)}\n" message = f"{self.format(record)}\n"
return self.stream.tell() + len(message.encode("utf-8")) >= self.max_bytes return self.stream.tell() + len(message.encode("utf-8")) >= self.max_bytes
def doRollover(self) -> None: # noqa: N802 def doRollover(self) -> None:
if self.stream is not None: if self.stream is not None:
self.stream.close() self.stream.close()
self.stream = None self.stream = None
source = Path(self.baseFilename) source = Path(self.baseFilename)
current_day = datetime.now().date() current_day = datetime.now(UTC).date()
if current_day != self._active_day: if current_day != self._active_day:
# 每日文件本身已带日期,跨日时直接切换到新文件,无需再移动旧文件。 # 每日文件本身已带日期,跨日时直接切换到新文件,无需再移动旧文件。
self.baseFilename = os.fspath(self._path_for_day(current_day).resolve()) self.baseFilename = os.fspath(self._path_for_day(current_day).resolve())
@@ -135,7 +136,7 @@ class _SizeAndDayRotatingFileHandler(BaseRotatingHandler):
self._delete_expired_archives() self._delete_expired_archives()
return return
if source.exists() and source.stat().st_size: if source.exists() and source.stat().st_size:
stamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") stamp = datetime.now(UTC).strftime("%Y-%m-%d_%H-%M-%S")
archive = source.with_name(f"{source.name}.{stamp}.{os.getpid()}.gz") archive = source.with_name(f"{source.name}.{stamp}.{os.getpid()}.gz")
sequence = 1 sequence = 1
while archive.exists(): while archive.exists():
@@ -149,10 +150,10 @@ class _SizeAndDayRotatingFileHandler(BaseRotatingHandler):
self._delete_expired_archives() self._delete_expired_archives()
def _delete_expired_archives(self) -> None: def _delete_expired_archives(self) -> None:
cutoff = datetime.now() - timedelta(days=self.retention_days) cutoff = datetime.now(UTC) - timedelta(days=self.retention_days)
for archive in self._log_dir.glob(f"{self._filename_prefix}-*.log*.gz"): for archive in self._log_dir.glob(f"{self._filename_prefix}-*.log*.gz"):
try: try:
if datetime.fromtimestamp(archive.stat().st_mtime) < cutoff: if datetime.fromtimestamp(archive.stat().st_mtime, UTC) < cutoff:
archive.unlink() archive.unlink()
except OSError: except OSError:
continue continue
@@ -227,7 +228,7 @@ def setup_logger(
if log_file: if log_file:
file_path = Path(log_file).expanduser() file_path = Path(log_file).expanduser()
elif log_dir: elif log_dir:
file_path = Path(log_dir).expanduser() / f"app-{datetime.now():%Y-%m-%d}.log" file_path = Path(log_dir).expanduser() / f"app-{datetime.now(UTC):%Y-%m-%d}.log"
else: else:
return return
file_path.parent.mkdir(parents=True, exist_ok=True) file_path.parent.mkdir(parents=True, exist_ok=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"
+3 -4
View File
@@ -10,11 +10,10 @@ from dataclasses import dataclass
from functools import lru_cache from functools import lru_cache
from Crypto.Cipher import AES from Crypto.Cipher import AES
from loguru import logger
from sqlalchemy import text from sqlalchemy import text
from sqlalchemy.engine import Engine from sqlalchemy.engine import Engine
from sqlalchemy.types import Text, TypeDecorator from sqlalchemy.types import Text, TypeDecorator
from loguru import logger
_PREFIX = "enc:v1:" _PREFIX = "enc:v1:"
_FALLBACK_SECRET = "douyu-login-py-dev-storage-key-change-me" _FALLBACK_SECRET = "douyu-login-py-dev-storage-key-change-me"
@@ -110,7 +109,7 @@ def decrypt_value(value: str | None) -> str | None:
for candidate in _key_candidates(): for candidate in _key_candidates():
try: try:
return _decrypt_with_candidate(value, candidate) return _decrypt_with_candidate(value, candidate)
except Exception as exc: except Exception as exc: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
last_error = exc last_error = exc
raise ValueError( raise ValueError(
"敏感字段解密失败,请确认 APP_ENCRYPTION_KEY 是否正确" "敏感字段解密失败,请确认 APP_ENCRYPTION_KEY 是否正确"
@@ -125,7 +124,7 @@ def decrypt_value_with_key_name(value: str) -> tuple[str, str]:
for candidate in _key_candidates(): for candidate in _key_candidates():
try: try:
return _decrypt_with_candidate(value, candidate), candidate.name return _decrypt_with_candidate(value, candidate), candidate.name
except Exception as exc: except Exception as exc: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
last_error = exc last_error = exc
raise ValueError( raise ValueError(
"敏感字段解密失败,请确认 APP_ENCRYPTION_KEY 是否正确" "敏感字段解密失败,请确认 APP_ENCRYPTION_KEY 是否正确"
+2 -1
View File
@@ -5,7 +5,7 @@ from pathlib import Path
from urllib.parse import quote_plus from urllib.parse import quote_plus
from sqlalchemy import create_engine, event from sqlalchemy import create_engine, event
from sqlalchemy.orm import sessionmaker, declarative_base from sqlalchemy.orm import declarative_base, sessionmaker
PROJECT_ROOT = Path(__file__).resolve().parents[2] PROJECT_ROOT = Path(__file__).resolve().parents[2]
@@ -147,6 +147,7 @@ def _seed():
def _encrypt_existing_sensitive_data(): def _encrypt_existing_sensitive_data():
"""启动时把历史明文敏感数据迁移为密文。""" """启动时把历史明文敏感数据迁移为密文。"""
from loguru import logger from loguru import logger
from .crypto_storage import encrypt_existing_sensitive_data from .crypto_storage import encrypt_existing_sensitive_data
changed = encrypt_existing_sensitive_data(engine) changed = encrypt_existing_sensitive_data(engine)
+6 -7
View File
@@ -1,15 +1,14 @@
"""FastAPI 依赖注入""" """FastAPI 依赖注入"""
from typing import Optional from fastapi import Depends, HTTPException, Request, WebSocket, status
from fastapi import Depends, HTTPException, Request, status, WebSocket
from fastapi.security import OAuth2PasswordBearer from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.orm import Session
from jose import JWTError from jose import JWTError
from sqlalchemy.orm import Session
from .database import get_db, SessionLocal from .database import SessionLocal, get_db
from .security import decode_access_token
from .models import User from .models import User
from .permissions import get_user_permissions from .permissions import get_user_permissions
from .security import decode_access_token
# auto_error=False: 允许 token 为空(后续从 cookie 读取) # auto_error=False: 允许 token 为空(后续从 cookie 读取)
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login", auto_error=False) oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login", auto_error=False)
@@ -17,7 +16,7 @@ oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login", auto_error=Fals
def get_current_user( def get_current_user(
request: Request, request: Request,
token: Optional[str] = Depends(oauth2_scheme), token: str | None = Depends(oauth2_scheme),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> User: ) -> User:
credentials_exc = HTTPException( credentials_exc = HTTPException(
@@ -61,7 +60,7 @@ def require_permission(permission: str):
return checker return checker
def authenticate_websocket(websocket: WebSocket) -> Optional[User]: def authenticate_websocket(websocket: WebSocket) -> User | None:
"""WebSocket 认证:从 cookie 或 query param token 中验证用户身份。 """WebSocket 认证:从 cookie 或 query param token 中验证用户身份。
Returns: Returns:
+15 -13
View File
@@ -1,34 +1,36 @@
"""FastAPI 入口""" """FastAPI 入口"""
import os import os
from pathlib import Path
import uvicorn
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path
import uvicorn
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware from fastapi.middleware.gzip import GZipMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from starlette.middleware.base import BaseHTTPMiddleware from starlette.middleware.base import BaseHTTPMiddleware
from utils import setup_logger
from .database import init_db from .database import init_db
from .routers import ( from .routers import (
auth,
users,
accounts,
account_check, account_check,
accounts,
audit,
auth,
cookies,
dashboard, dashboard,
douyu,
huya,
login, login,
proxy, proxy,
cookies, users,
huya,
douyu,
yyb, yyb,
audit,
) )
from .schemas import AppInfo from .schemas import AppInfo
from .version import get_app_version from .version import get_app_version
from utils import setup_logger
@asynccontextmanager @asynccontextmanager
@@ -45,9 +47,9 @@ async def lifespan(app: FastAPI):
from loguru import logger from loguru import logger
from .database import SessionLocal from .database import SessionLocal
from .services.login_service import cleanup_orphan_relogin_tasks
from .services.huya_service import cleanup_orphan_huya_tasks
from .services.douyu_service import cleanup_orphan_douyu_tasks from .services.douyu_service import cleanup_orphan_douyu_tasks
from .services.huya_service import cleanup_orphan_huya_tasks
from .services.login_service import cleanup_orphan_relogin_tasks
db = SessionLocal() db = SessionLocal()
try: try:
+4 -4
View File
@@ -1,9 +1,9 @@
"""Alembic 迁移环境。""" """Alembic 迁移环境。"""
from logging.config import fileConfig
from pathlib import Path
import os import os
import sys import sys
from logging.config import fileConfig
from pathlib import Path
from alembic import context from alembic import context
from sqlalchemy import engine_from_config, pool from sqlalchemy import engine_from_config, pool
@@ -12,8 +12,8 @@ ROOT_DIR = Path(__file__).resolve().parents[3]
if str(ROOT_DIR) not in sys.path: if str(ROOT_DIR) not in sys.path:
sys.path.insert(0, str(ROOT_DIR)) sys.path.insert(0, str(ROOT_DIR))
from web.backend.database import Base, DATABASE_URL # noqa: E402 from web.backend import models # noqa: F401
from web.backend import models # noqa: F401,E402 from web.backend.database import DATABASE_URL, Base
config = context.config config = context.config
config.set_main_option("sqlalchemy.url", DATABASE_URL) config.set_main_option("sqlalchemy.url", DATABASE_URL)
@@ -5,15 +5,15 @@ Revises:
Create Date: 2026-06-23 Create Date: 2026-06-23
""" """
from typing import Sequence, Union from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa import sqlalchemy as sa
from alembic import op
revision: str = "20260623_0001" revision: str = "20260623_0001"
down_revision: Union[str, None] = None down_revision: str | None = None
branch_labels: Union[str, Sequence[str], None] = None branch_labels: str | Sequence[str] | None = None
depends_on: Union[str, Sequence[str], None] = None depends_on: str | Sequence[str] | None = None
def _has_table(bind, table_name: str) -> bool: def _has_table(bind, table_name: str) -> bool:

Some files were not shown because too many files have changed in this diff Show More