虎牙网页Cookie来源驱动合并与WSS传输门禁

This commit is contained in:
yml2213
2026-09-01 19:17:32 +08:00
parent 8e3c0e69bc
commit 48c37bb8d7
16 changed files with 840 additions and 57 deletions
+173 -5
View File
@@ -37,7 +37,7 @@ import requests
from loguru import logger
from .cert_forge import build_p1, forge_cert
from .cookie_utils import normalize_huya_cookie
from .cookie_utils import cookie_pairs, normalize_cookie_pairs
from .device_fingerprint import account_state_dir, get_huya_sdid, reset_account_state
from .device_profile import get_profile, mobile_user_agent
from .dfp_register import DfpRegistrationError, register_device
@@ -62,6 +62,18 @@ APP_UA_MOBILE = (
"Chrome/149.0.7827.159 Mobile Safari/537.36 huya adr/13.4.22/xiaomi/30"
)
# 9.1 网页入口请求中稳定出现的设备态字段。它们必须来自真实 WebView/浏览器
# 运行态或本次请求的 Set-Cookie,不能由 App 凭据推导或随机生成。
WEB_DEVICE_COOKIE_KEYS = (
"guid",
"udb_guiddata",
"udb_deviceid",
"game_did",
"_qimei_uuid42",
"udb_anobiztoken",
"__yamid_new",
)
SessionAssets = tuple[dict, str, str]
RISK_URL_RE = re.compile(rb"https://aq\.huya\.com/p/safe_auth/[^\x00-\x20\"'\\<>]+")
@@ -457,6 +469,110 @@ class HuyaAppPasswordLogin:
logger.debug(f"登录元数据记录失败: {exc}")
return result
@staticmethod
def _missing_web_device_cookie(cookie: str) -> tuple[str, ...]:
"""返回网页设备态缺失字段名,不暴露任何 Cookie 值。"""
present = {key for key, value in cookie_pairs(cookie) if value}
return tuple(key for key in WEB_DEVICE_COOKIE_KEYS if key not in present)
@staticmethod
def _merge_web_cookie(
session: requests.Session,
biztoken: str,
sdid: str,
hdid: str,
device_info: Mapping[str, object],
*,
cred: bytes | str = b"",
uid: int = 0,
username: str = "",
include_transient: bool = True,
) -> str:
"""合并真实登录凭据与已有网页 Cookie。
抓包中的设备/统计 Cookie 由浏览器或设备指纹链产生,不能用随机值
冒充。认证字段来自本次登录或指纹响应并覆盖旧值;其他字段只有在
Session 或 ``device_info`` 明确提供时才保留,缺失就不写入。
"""
jar = session.cookies
if hasattr(jar, "items"):
pairs = [
(str(key), "" if value is None else str(value))
for key, value in jar.items()
]
else:
pairs = [
(str(getattr(cookie, "name", "")), str(getattr(cookie, "value", "")))
for cookie in jar
if getattr(cookie, "name", "")
]
def set_value(key: str, value: object, *, overwrite: bool = False):
value = "" if value is None else str(value).strip()
if not value:
return
if overwrite:
pairs[:] = [
(item_key, item_value)
for item_key, item_value in pairs
if item_key != key
]
if not any(item_key == key for item_key, _item_value in pairs):
pairs.append((key, value))
raw_cred = (
base64.urlsafe_b64encode(cred).decode("ascii").rstrip("=")
if isinstance(cred, bytes)
else str(cred or "")
)
# 本次登录/指纹响应明确返回的字段,必须覆盖旧 Session 值。
set_value("udb_cred", raw_cred, overwrite=True)
if uid:
set_value("udb_uid", int(uid), overwrite=True)
set_value("yyuid", int(uid), overwrite=True)
set_value("udb_biztoken", biztoken, overwrite=True)
set_value("udb_version", "1.0", overwrite=True)
set_value("udb_origin", "0", overwrite=True)
set_value("udb_status", "1", overwrite=True)
set_value("sdid", sdid, overwrite=True)
set_value("hdid", hdid, overwrite=True)
# 手机号不是网页 passport。只有明确的 hy_账号或 Session 现有值可用。
if username.startswith("hy_"):
set_value("udb_passport", username, overwrite=True)
set_value("username", username, overwrite=True)
# 设备/统计字段不推导、不随机生成;仅转发已知真实值。
optional_keys = (
"guid", "udb_guiddata", "__yamid_new",
"game_did", "_qimei_uuid42", "_qimei_fingerprint", "_qimei_h38",
"udb_anouid", "udb_anobiztoken", "__yasmid", "__yamid_tt1",
"SoundValue", "alphaValue", "isInLiveRoom", "udb_deviceid",
"udb_passdata", "_rep_cnt", "Hm_lvt_51700b6c722f5bb4cf39906a596ea41f",
"HMACCOUNT", "udb_appid", "rep_cnt", "udb_accdata", "h_unt",
"__yaoldyyuid", "_yasids", "huya_flash_rep_cnt", "huyasp_rep_cnt",
"huya_hd_rep_cnt", "Hm_lpvt_51700b6c722f5bb4cf39906a596ea41f",
"huya_web_rep_cnt",
)
for key in optional_keys:
explicit = device_info.get(key)
set_value(key, explicit, overwrite=explicit not in (None, ""))
web_guiddata = str(
device_info.get("web_guiddata") or device_info.get("udb_guiddata") or ""
)
if web_guiddata:
set_value("udb_guiddata", web_guiddata, overwrite=True)
web_device_id = str(device_info.get("web_device_id") or "")
if re.fullmatch(r"w_\d{19}", web_device_id):
set_value("udb_deviceid", web_device_id, overwrite=True)
if not include_transient:
pairs = [
(key, value)
for key, value in pairs
if key != "web_qrlogin_confirm_id"
]
return normalize_cookie_pairs(pairs)
def _login_impl(self) -> HuyaLoginResult:
"""登录主体 (原 login)。"""
acct = self.username
@@ -536,6 +652,21 @@ class HuyaAppPasswordLogin:
f"账号设备指纹获取失败(必须为 hydevice): {detail}"
)
sdid = sdid_obj.sdid
# hydevice may write additional browser cookies through its
# document.cookie bridge. Forward only the values actually
# observed from the runner; never synthesize missing fields.
observed_cookie_keys = sorted(sdid_obj.cookies or {})
logger.info(
"[huya-app] hydevice 实际 Cookie 输出字段: {}",
", ".join(observed_cookie_keys) if observed_cookie_keys else "",
)
merge_device_info = dict(self.device_info)
for key, value in (sdid_obj.cookies or {}).items():
if key in WEB_DEVICE_COOKIE_KEYS or key in {
"udb_appid", "__yasmid", "__yamid_tt1", "udb_anouid",
"udb_anobiztoken", "udb_deviceid",
}:
merge_device_info.setdefault(key, value)
logger.info("[huya-app] cred 已获取,开始二维码绑定流程")
pc = QrRole(pc=True, sdid=sdid, proxies=self.proxies)
ph = QrRole(
@@ -621,7 +752,21 @@ class HuyaAppPasswordLogin:
code="BIZTOKEN_TIMEOUT",
)
# 5) POST /web/cookie/verify 兑换 Cookie
# 5) 先把扫码返回的网页 token 和设备态写回 verify Session。
# verify 响应本身没有 Set-Cookie;浏览器是在请求前就已准备好这些字段。
merged_before_verify = self._merge_web_cookie(
pc.s,
biztoken,
sdid,
sdid_obj.hdid,
merge_device_info,
cred=cred,
uid=uid,
username=acct,
)
pc.s.cookies.clear()
for key, value in cookie_pairs(merged_before_verify):
pc.s.cookies.set(key, value)
verify_resp = pc.s.post(
"https://udblgn.huya.com/web/cookie/verify",
json={"appId": 5002},
@@ -634,8 +779,31 @@ class HuyaAppPasswordLogin:
code="VERIFY_FAILED",
)
cookie_str = normalize_huya_cookie(pc.s.cookies)
if "udb_cred" not in cookie_str and "yyuid" not in cookie_str:
cookie_str = self._merge_web_cookie(
pc.s,
biztoken,
sdid,
sdid_obj.hdid,
merge_device_info,
cred=cred,
uid=uid,
username=acct,
include_transient=False,
)
missing_web = self._missing_web_device_cookie(cookie_str)
if missing_web:
missing_text = ", ".join(missing_web)
logger.error(
f"[huya-app] 账号 {acct} 登录凭据有效,但网页设备态不完整,缺少: {missing_text}"
)
return HuyaLoginResult(
success=False,
message=f"COOKIE_INCOMPLETE: 缺少网页设备态({missing_text}),请在真实虎牙网页/WebView完成一次访问后重试",
code="COOKIE_INCOMPLETE",
sdid=sdid,
context=pc.context,
)
if "udb_cred" not in cookie_str or "yyuid" not in cookie_str:
return HuyaLoginResult(
success=False,
message="Cookie 兑换完成但缺失关键凭据 (udb_cred/yyuid)",
@@ -643,7 +811,7 @@ class HuyaAppPasswordLogin:
)
logger.info(
f"[huya-app] 账号 {acct} 登录成功,获取完整 Cookie ({len(cookie_str)}B)"
f"[huya-app] 账号 {acct} 登录成功,获取网页 Cookie ({len(cookie_str)}B)"
)
return HuyaLoginResult(
success=True,
+18 -6
View File
@@ -22,6 +22,7 @@ from .device_profile import canonical_account_key, mobile_user_agent
FINGERPRINT_DIR = Path(__file__).parent / "fingerprint"
RUNNER_JS = FINGERPRINT_DIR / "runner.js"
SDID_PREFIX = "__SDID__"
COOKIE_PREFIX = "__COOKIES__"
# 每账号独立的 hydevice localStorage 状态目录 (一号一设备的关键):
# 默认全局临时目录会让所有账号共用同一份设备采集状态 → sdid/40hex hdid 同源,
@@ -78,6 +79,9 @@ class HuyaSdidResult:
hdid: str = "" # df/collect 同响应下发的 40hex 设备ID; 与 WUP 登录的 32hex hdid 非同一体系(见 docs/HUYA_APP_OVERVIEW.md §二)
source: str = ""
message: str = ""
# Cookie values actually written by the hydevice runtime. This is kept
# separate from sdid/hdid because the latter are response values.
cookies: dict[str, str] | None = None
HDID_PREFIX = "__HDID__"
@@ -85,8 +89,8 @@ HDID_PREFIX = "__HDID__"
def _run_node_runner(
state_dir: Path, app_id: str, timeout: tuple[float, float]
) -> tuple[str, str]:
"""调用 node runner,返回 (sdid, hdid)。
) -> tuple[str, str, dict[str, str]]:
"""调用 node runner,返回 (sdid, hdid, cookies)。
若 state_dir/device.json 存在 (账号画像派生的设备覆盖参数), runner 会以该
设备身份运行 hydevice → 不同账号的 sdid/40hex hdid 不再同源 (一号一设备)。
@@ -109,15 +113,21 @@ def _run_node_runner(
except subprocess.TimeoutExpired as exc:
raise HuyaFingerprintError(f"hydevice runner 超时({total_timeout}s)") from exc
sdid, hdid = "", ""
sdid, hdid, cookies = "", "", {}
for line in (proc.stdout or "").splitlines():
line = line.strip()
if line.startswith(SDID_PREFIX) and len(line) > len(SDID_PREFIX) + 20:
sdid = line[len(SDID_PREFIX) :]
if line.startswith(HDID_PREFIX) and len(line) > len(HDID_PREFIX) + 20:
hdid = line[len(HDID_PREFIX) :]
if line.startswith(COOKIE_PREFIX):
raw = line[len(COOKIE_PREFIX) :]
for item in raw.split(";"):
key, sep, value = item.strip().partition("=")
if sep and key and value:
cookies[key] = value
if sdid:
return sdid, hdid
return sdid, hdid, cookies
stderr_tail = (proc.stderr or "").strip().splitlines()
detail = stderr_tail[-1][:120] if stderr_tail else f"exit={proc.returncode}"
raise HuyaFingerprintError(f"hydevice runner 未返回 sdid: {detail}")
@@ -195,12 +205,14 @@ def get_huya_sdid(
# 一号一设备: 账号画像 → hydevice 设备覆盖 (UA/屏幕/机型)
write_device_hint(state_dir, device_hint)
try:
sdid, hdid = _run_node_runner(state_dir, app_id, timeout)
sdid, hdid, cookies = _run_node_runner(state_dir, app_id, timeout)
if sdid:
logger.debug(
"虎牙设备指纹成功(node): sdid={}... hdid={}...", sdid[:24], hdid[:10]
)
return HuyaSdidResult(sdid=sdid, hdid=hdid, source="fingerprint")
return HuyaSdidResult(
sdid=sdid, hdid=hdid, source="fingerprint", cookies=cookies
)
except HuyaFingerprintError as exc:
logger.warning("虎牙 hydevice 指纹失败: {}", exc)
if not allow_fallback:
+16 -2
View File
@@ -85,7 +85,10 @@ function makeEnv(overrides) {
addEventListener(){}, removeEventListener(){}, setAttribute(){}, getBoundingClientRect(){return {left:0,top:0,width:300,height:150}},
style:{}, width:300, height:150,
};
W.document = {
// hydevice writes browser device state through document.cookie. Keep a small
// in-memory jar so Node has the same read/append semantics as a WebView.
const cookieJar = {};
const document = {
createElement(tag){ const el = Object.assign({}, canvasProto, {tagName:String(tag||'div').toUpperCase(), style:{}, children:[],
setAttribute(){}, getAttribute(){return null}, appendChild(c){this.children.push(c)}, removeChild(){}, addEventListener(){}, removeEventListener(){},
classList:{add(){},remove(){},contains(){return false}}, attachShadow(){return {appendChild(){}}}, getBoundingClientRect(){return {left:0,top:0,right:100,bottom:30,width:100,height:30}} });
@@ -97,9 +100,20 @@ function makeEnv(overrides) {
getElementsByTagName(t){ if(String(t).toLowerCase()==='head') return [this.head]; return []; },
documentElement:{setAttribute(){}, getAttribute(){return null}, style:{}},
head:{appendChild(){}}, body:{appendChild(){}},
cookie:'', referrer:'', title:'pt_auth', domain:'aq.huya.com', readyState:'complete', visibilityState:'visible',
referrer:'', title:'pt_auth', domain:'aq.huya.com', readyState:'complete', visibilityState:'visible',
addEventListener(){}, removeEventListener(){},
};
Object.defineProperty(document, 'cookie', {
configurable: true,
get(){ return Object.entries(cookieJar).map(([k,v]) => `${k}=${v}`).join('; '); },
set(value){
const first = String(value || '').split(';', 1)[0];
const index = first.indexOf('=');
if (index > 0) cookieJar[first.slice(0, index).trim()] = first.slice(index + 1).trim();
},
});
W.__huyaCookieJar = cookieJar;
W.document = document;
W.localStorage = (()=>{const m={}; return {getItem:k=>m[k]??null,setItem:(k,v)=>m[k]=String(v),removeItem:k=>delete m[k],clear:()=>{},key:i=>Object.keys(m)[i]??null,get length(){return Object.keys(m).length}}})();
W.sessionStorage = (()=>{const m={}; return {getItem:k=>m[k]??null,setItem:(k,v)=>m[k]=String(v),removeItem:k=>delete m[k],clear:()=>{},key:i=>null,get length(){return 0}}})();
W.indexedDB = {open(){return {onsuccess:null,onerror:null,onupgradeneeded:null,result:{objectStoreNames:{contains(){return false}},createObjectStore(){return {createIndex(){}}}},set onsuccess(f){setTimeout(()=>{},0)}}}};
+3
View File
@@ -75,6 +75,9 @@ globalThis.XMLHttpRequest = class {
if (j && j.data) {
if (j.data.sdid) console.log('__SDID__' + j.data.sdid);
if (j.data.hdid) console.log('__HDID__' + j.data.hdid);
if (globalThis.document && globalThis.document.cookie) {
console.log('__COOKIES__' + globalThis.document.cookie);
}
setTimeout(() => process.exit(0), 200);
}
} catch (e) {}
+26 -11
View File
@@ -248,6 +248,8 @@ class HuyaWssClient:
"Chrome/149.0.0.0 Safari/537.36"
)
headers = {"Accept-Language": "zh-CN,zh;q=0.9"}
if cookie:
headers["Cookie"] = cookie
try:
self.ws = await asyncio.wait_for(
@@ -384,8 +386,11 @@ class HuyaWssClient:
if launch_rsp is None:
self.logger("[WSS] 活动 wsLaunch 无响应")
return False
await self.send_auth(cookie, sequence=SEQ_ACTIVITY_BIZ)
config_rsp = await self.call_get_config_activity(uid, cookie)
await self.send_auth(uid, cookie, sequence=SEQ_ACTIVITY_BIZ)
# 9.1 抓包:getConfig 使用 wsLaunch 响应返回的新 guid;业务 UserId 仍为空 guid。
config_rsp = await self.call_get_config_activity(
uid, cookie, guid=self._launch_guid
)
if config_rsp is None:
self.logger("[WSS] 活动 getConfig 无响应,继续发送 confirm")
await self.send_confirm()
@@ -427,17 +432,20 @@ class HuyaWssClient:
self._parse_launch_response(response)
return response
async def call_get_config_activity(self, uid: int, cookie: str, timeout: float = 10.0):
user = self._build_activity_user(uid, cookie)
async def call_get_config_activity(
self, uid: int, cookie: str, timeout: float = 10.0, guid: str = ""
):
user = self._build_activity_user(uid, cookie, guid=guid)
req = _ActivityConfigReq(user)
return await self.call_rpc("mobileui", "getConfig", req, None, timeout=timeout)
@staticmethod
def _build_activity_user(uid: int, cookie: str):
def _build_activity_user(uid: int, cookie: str, guid: str = ""):
from .activity_structs import ActivityUserId
user = ActivityUserId()
user.lUid = int(uid or 0)
user.sGuid = guid or ""
user.sHuYaUA = WSS_COOKIE_UA
user.sCookie = HuyaWssClient._normalize_biz_cookie(cookie)
return user
@@ -559,19 +567,26 @@ class HuyaWssClient:
return body[5 : 5 + wup_len]
return body
async def send_auth(self, cookie: str, sequence: int = SEQ_WSLAUNCH):
"""cmd 0x0a AUTH — 发送 cookie"""
ua = "webh5&0.0.1&websocket&&diypc_52775"
auth_text = f"huya_ua={ua}; {cookie}"
async def send_auth(self, uid: int, cookie: str, sequence: int = SEQ_WSLAUNCH):
"""cmd 0x0a AUTH — 发送 TAF 认证结构(与 9.1 活动帧一致)。"""
os = TafOutputStream()
os.write_int64(0, int(uid or 0))
os.write_string(1, WSS_COOKIE_UA)
os.write_string(2, HuyaWssClient._normalize_biz_cookie(cookie))
os.write_int32(3, 0)
os.write_int8(4, 1)
os.write_string(5, "HUYA&ZH&2052")
os.write_string(6, "")
auth_body = os.get_bytes() + TAIL_BYTES
msg = WssMessage(
command=WssCommand.AUTH,
sequence=sequence,
body=auth_text.encode("utf-8") + TAIL_BYTES,
body=auth_body,
)
await self.ws.send(msg.encode())
self.logger(
format_wss_log(
auth_text.encode("utf-8") + TAIL_BYTES,
auth_body,
WssCommand.AUTH,
sequence,
"",