虎牙网页Cookie来源驱动合并与WSS传输门禁
This commit is contained in:
+173
-5
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)}}}};
|
||||
|
||||
@@ -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
@@ -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,
|
||||
"发",
|
||||
|
||||
Reference in New Issue
Block a user