虎牙登录: 接入hydevice高信任设备指纹与App场景验证支持

- 新增 fingerprint/: node仿真Android WebView运行官方hydevice.js(含WASM加密), 产出与真机同构的高信任sdid
- 新增 device_fingerprint.py: py编排node runner获取sdid, 失败自动降级旧无指纹流程
- login.py: prepare_device优先高信任指纹; 增加App场景常量(appId=5008, appSign=md5(appId+verCode+appKey)[:8], 逆向自APK UdbNetHelper)
- solver: 支持appId/page_url/use_touch_events参数化(App模式); get3使用csid会话id; 识别qr_auth扫码(冷却)与dx_auth短信策略并抛专用异常
This commit is contained in:
yml2213
2026-08-23 17:32:34 +08:00
parent bf2f78aa70
commit 06a0a1e7d3
8 changed files with 438 additions and 16 deletions
+131
View File
@@ -0,0 +1,131 @@
"""虎牙设备指纹:通过 node 运行 hydevice.js 生成高信任 sdid。
流程与真机 App WebView 一致:
df/token -> hydevice 采集+WASM 加密 -> df/collect -> sdid
"""
from __future__ import annotations
import shutil
import subprocess
import tempfile
from collections.abc import Mapping
from dataclasses import dataclass
from pathlib import Path
import requests
from loguru import logger
FINGERPRINT_DIR = Path(__file__).parent / "fingerprint"
RUNNER_JS = FINGERPRINT_DIR / "runner.js"
SDID_PREFIX = "__SDID__"
DEFAULT_TIMEOUT = (8, 40)
class HuyaFingerprintError(RuntimeError):
"""虎牙设备指纹生成失败。"""
@dataclass
class HuyaSdidResult:
"""sdid 获取结果。"""
sdid: str = ""
source: str = ""
message: str = ""
def _run_node_runner(state_dir: Path, app_id: str, timeout: tuple[float, float]) -> str:
"""调用 node runner,返回 sdid。"""
node_bin = shutil.which("node")
if not node_bin:
raise HuyaFingerprintError("未找到 node 可执行文件(hydevice.js 需要 JS 引擎)")
connect_timeout, read_timeout = timeout
total_timeout = int(connect_timeout + read_timeout + 10)
try:
proc = subprocess.run(
[node_bin, str(RUNNER_JS), str(state_dir), app_id],
capture_output=True,
text=True,
timeout=total_timeout,
cwd=str(FINGERPRINT_DIR),
)
except subprocess.TimeoutExpired as exc:
raise HuyaFingerprintError(f"hydevice runner 超时({total_timeout}s)") from exc
for line in (proc.stdout or "").splitlines():
line = line.strip()
if line.startswith(SDID_PREFIX) and len(line) > len(SDID_PREFIX) + 20:
return line[len(SDID_PREFIX):]
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}")
def _fallback_sdid(session: requests.Session, timeout) -> str:
"""旧版降级:仅 token+collect,无指纹(低信任)。"""
token_url = "https://df.huya.com/web/df/token"
collect_url = "https://df.huya.com/web/df/collect"
token_res = session.post(
token_url,
json={"encryptVersion": "1.0.1", "fingerprintVersion": "1.2.45"},
timeout=timeout,
)
token_res.raise_for_status()
token = token_res.json().get("data", {}).get("token", "")
if not token:
raise HuyaFingerprintError(f"获取虎牙 df token 失败: {token_res.text[:120]}")
collect_res = session.post(collect_url, json={"token": token}, timeout=timeout)
collect_res.raise_for_status()
return collect_res.json().get("data", {}).get("sdid", "")
def get_huya_sdid(
app_id: str = "5008",
state_dir: str | Path | None = None,
session: requests.Session | None = None,
proxies: Mapping | None = None,
timeout: tuple[float, float] = DEFAULT_TIMEOUT,
allow_fallback: bool = True,
) -> HuyaSdidResult:
"""获取虎牙高信任 sdid;node 链路失败时可选降级为低信任指纹。
Args:
app_id: 虎牙应用 idApp 场景 5008。
state_dir: localStorage 持久化目录(保持设备一致性),None 用临时目录。
allow_fallback: node 失败时是否降级为旧版无指纹方式。
Returns:
HuyaSdidResult(sdid, source, message)source 为 fingerprint/fallback/none。
"""
del session, proxies # node runner 自行发请求;保留参数便于未来代理透传
if state_dir is None:
state_dir = Path(tempfile.gettempdir()) / "huya_fp_state"
state_dir = Path(state_dir)
try:
sdid = _run_node_runner(state_dir, app_id, timeout)
if sdid:
logger.debug("虎牙设备指纹成功(node): sdid={}...", sdid[:24])
return HuyaSdidResult(sdid=sdid, source="fingerprint")
except HuyaFingerprintError as exc:
logger.warning("虎牙 hydevice 指纹失败: {}", exc)
if not allow_fallback:
raise
if allow_fallback:
try:
with requests.Session() as s:
s.trust_env = False
sdid = _fallback_sdid(s, timeout)
if sdid:
logger.debug("虎牙设备指纹降级(无指纹): sdid={}...", sdid[:24])
return HuyaSdidResult(
sdid=sdid,
source="fallback",
message="低信任指纹(降级)",
)
except Exception as exc: # noqa: BLE001
logger.warning("虎牙降级指纹也失败: {}", exc)
return HuyaSdidResult(source="none", message="未能获取 sdid")
+107
View File
@@ -0,0 +1,107 @@
// Android WebView (M2102J2SC, 小米, Android 11) 环境仿真
function makeEnv() {
const W = globalThis;
W.screen = {width:393, height:851, availWidth:393, availHeight:851,
colorDepth:24, pixelDepth:24, availLeft:0, availTop:0, orientation:{type:'portrait-primary', angle:0}};
W.devicePixelRatio = 2.75;
W.innerWidth = 393; W.innerHeight = 737; W.outerWidth = 393; W.outerHeight = 851;
W.location = {href:'https://aq.huya.com/p/safe_auth/pt_auth.html', protocol:'https:', host:'aq.huya.com',
hostname:'aq.huya.com', port:'', pathname:'/p/safe_auth/pt_auth.html', search:'', hash:'',
origin:'https://aq.huya.com', toString(){return this.href}};
W.history = {length:2, state:null, pushState(){}, replaceState(){}, back(){}, go(){}};
const navProps = {
userAgent:'Mozilla/5.0 (Linux; Android 11; M2102J2SC Build/RKQ1.200826.002; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/149.0.7827.159 Mobile Safari/537.36 huya adr/13.4.22/xiaomi/30',
appName:'Netscape', appVersion:'5.0 (Linux; Android 11; M2102J2SC Build/RKQ1.200826.002; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/149.0.7827.159 Mobile Safari/537.36',
platform:'Linux armv8l', language:'zh-CN', languages:['zh-CN','zh','en'],
cookieEnabled:true, doNotTrack:null, maxTouchPoints:5, hardwareConcurrency:8,
deviceMemory:8, vendor:'Google Inc.', productSub:'20030107', webdriver:false,
pdfViewerEnabled:false, javaEnabled(){return false},
getBattery(){return Promise.resolve({charging:true, level:0.9})},
userAgentData: {brands:[{brand:'Chromium',version:'149'},{brand:'Google Chrome',version:'149'},{brand:'Not?A_Brand',version:'24'}],
mobile:true, platform:'Android',
getHighEntropyValues(){return Promise.resolve({architecture:'arm', bitness:'64', model:'M2102J2SC', platformVersion:'11.0.0', uaFullVersion:'149.0.7827.159'})}},
connection: {effectiveType:'4g', downlink:10, rtt:50, saveData:false},
plugins: {length:0, item(){return null}, namedItem(){return null}},
mimeTypes: {length:0},
};
for (const [k,v] of Object.entries(navProps)) {
try { Object.defineProperty(W.navigator, k, {value:v, configurable:true}); } catch(e){ try{W.navigator[k]=v}catch(_){} }
}
// canvas 2d/webgl 最小仿真
const glConsts = {RENDERER:0x1F01, VENDOR:0x1F00, VERSION:0x1F02, SHADING_LANGUAGE_VERSION:0x8B8C,
MAX_TEXTURE_SIZE:0x0D33, MAX_RENDERBUFFER_SIZE:0x84E8, MAX_VIEWPORT_DIMS:0x0D3A};
function fakeGL(){
return new Proxy({
getExtension(){ return {UNMASKED_VENDOR_WEBGL:0x9245, UNMASKED_RENDERER_WEBGL:0x9246}; },
getParameter(p){
if (p===0x9245) return 'Qualcomm';
if (p===0x9246) return 'Adreno (TM) 650';
if (p===glConsts.MAX_TEXTURE_SIZE) return 16384;
if (p===glConsts.MAX_VIEWPORT_DIMS) return new Int32Array([16384,16384]);
return 'WebKit WebGL';
},
getContextAttributes(){return {alpha:true, antialias:true, depth:true};},
createBuffer(){return {}}, bindBuffer(){}, bufferData(){}, createProgram(){return {}},
attachShader(){}, linkProgram(){}, useProgram(){}, createShader(){return {}},
shaderSource(){}, compileShader(){}, getShaderParameter(){return true},
getProgramParameter(){return true}, getSupportedExtensions(){return ['WEBGL_debug_renderer_info']},
readPixels(x,y,w,h,t,f,buf){ if(buf) buf.fill(128); },
}, { get(t,p){ if(p in t) return t[p]; if(typeof p==='string' && p.startsWith('on')) return null; return undefined; } });
}
const ctx2d = new Proxy({
fillRect(){}, clearRect(){}, getImageData(x,y,w,h){return {data:new Uint8ClampedArray(w*h*4).fill(128), width:w, height:h}},
putImageData(){}, createImageData(w,h){return {data:new Uint8ClampedArray(w*h*4), width:w, height:h}},
setTransform(){}, transform(){}, translate(){}, scale(){}, rotate(){}, save(){}, restore(){},
beginPath(){}, closePath(){}, moveTo(){}, lineTo(){}, stroke(){}, fill(){}, arc(){}, rect(){}, clip(){},
createLinearGradient(){return {addColorStop(){}}}, createRadialGradient(){return {addColorStop(){}}},
fillText(){}, strokeText(){}, measureText(t){return {width:String(t).length*7}},
isPointInPath(){return false}, drawImage(){}, arcTo(){}, bezierCurveTo(){}, quadraticCurveTo(){},
createPattern(){return {}}, setLineDash(){}, getLineDash(){return []},
}, { get(t,p){ if(p in t) return t[p]; if(p==='canvas') return {}; return undefined; } });
const canvasProto = {
getContext(type){
if (type === '2d') return ctx2d;
if (type === 'webgl' || type === 'webgl2' || type === 'experimental-webgl') return fakeGL();
return null;
},
toDataURL(){ return 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; },
toBlob(cb){ cb && cb(null); },
addEventListener(){}, removeEventListener(){}, setAttribute(){}, getBoundingClientRect(){return {left:0,top:0,width:300,height:150}},
style:{}, width:300, height:150,
};
W.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}} });
return el; },
createEvent(){return {initEvent(){}}},
createElementNS(ns, tag){ return this.createElement(tag); },
createTextNode(t){return {textContent:t}},
getElementById(){return null}, querySelector(){return null}, querySelectorAll(){return []},
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',
addEventListener(){}, removeEventListener(){},
};
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)}}}};
W.performance = W.performance || {now:()=>Date.now(), timeOrigin:Date.now(), timing:{navigationStart:Date.now()}, getEntriesByType(){return []}, mark(){}, measure(){}};
W.AudioContext = class { constructor(){this.sampleRate=48000; this.destination={}} createOscillator(){return {type:'',frequency:{value:0,setValueAtTime(){}},connect(){},start(){},stop(){}}} createDynamicsCompressor(){return {threshold:{value:0},knee:{value:0},ratio:{value:0},attack:{value:0},release:{value:0},connect(){}}} createAnalyser(){return {frequencyBinCount:1024,getFloatFrequencyData(a){a&&a.fill(-100)}}} createGain(){return {gain:{value:1,setValueAtTime(){}},connect(){}}} createScriptProcessor(){return {connect(){},onaudioprocess:null}} decodeAudioData(){return Promise.resolve({getChannelData(){return new Float32Array(5000)}})} resume(){return Promise.resolve()} close(){return Promise.resolve()} };
W.webkitAudioContext = W.AudioContext;
W.OfflineAudioContext = W.AudioContext;
W.WebGLRenderingContext = {RENDERER:glConsts.RENDERER, VENDOR:glConsts.VENDOR, VERSION:glConsts.VERSION, SHADING_LANGUAGE_VERSION:glConsts.SHADING_LANGUAGE_VERSION, MAX_TEXTURE_SIZE:glConsts.MAX_TEXTURE_SIZE};
W.RTCPeerConnection = class { createDataChannel(){return {}} createOffer(){return Promise.resolve({})} setLocalDescription(){return Promise.resolve()} close(){} };
W.webkitRTCPeerConnection = W.RTCPeerConnection;
W.Notification = {permission:'default'};
W.permissions = {query(){return Promise.resolve({state:'prompt'})}};
W.matchMedia = () => ({matches:false, media:'', addListener(){}, removeListener(){}, addEventListener(){}, removeEventListener(){}});
W.getComputedStyle = () => new Proxy({getPropertyValue:()=>''}, {get(t,p){ if(p in t) return t[p]; return ()=>''; }});
W.requestAnimationFrame = cb => setTimeout(()=>cb(Date.now()), 16);
W.cancelAnimationFrame = clearTimeout;
W.crypto = require('crypto').webcrypto;
W.TextEncoder = require('util').TextEncoder;
W.TextDecoder = require('util').TextDecoder;
}
module.exports = { makeEnv };
File diff suppressed because one or more lines are too long
+80
View File
@@ -0,0 +1,80 @@
// hydevice 指纹运行器: 仿真 Android WebView 环境 -> 真实请求 df/token + df/collect -> 输出 sdid
process.on('uncaughtException', e => { console.error('[uncaught]', String(e && e.message).slice(0, 120)); });
const fs = require('fs');
const path = require('path');
const https = require('https');
globalThis.window = globalThis;
require(path.join(__dirname, 'env.js')).makeEnv();
// ---- localStorage 持久化(设备稳定性) ----
const stateDir = process.argv[2] || '.';
try { fs.mkdirSync(stateDir, {recursive: true}); } catch (e) {}
const lsFile = path.join(stateDir, 'localstorage.json');
let _ls = {};
try { _ls = JSON.parse(fs.readFileSync(lsFile, 'utf8')); } catch (e) {}
globalThis.localStorage = {
getItem: k => (k in _ls ? _ls[k] : null),
setItem: (k, v) => { _ls[k] = String(v); try { fs.writeFileSync(lsFile, JSON.stringify(_ls)); } catch (e) {} },
removeItem: k => { delete _ls[k]; try { fs.writeFileSync(lsFile, JSON.stringify(_ls)); } catch (e) {} },
clear: () => { _ls = {}; try { fs.writeFileSync(lsFile, JSON.stringify(_ls)); } catch (e) {} },
key: i => Object.keys(_ls)[i] || null,
get length() { return Object.keys(_ls).length; },
};
globalThis.sessionStorage = {getItem: () => null, setItem(){}, removeItem(){}, clear(){}, key: () => null, get length(){ return 0; }};
// ---- 真实 XHR ----
function xhr(url, body, headers) {
return new Promise((resolve) => {
const u = new URL(url);
const data = body == null ? null : Buffer.from(String(body), 'utf8');
const req = https.request({
hostname: u.hostname, port: 443, path: u.pathname + u.search, method: 'POST',
headers: Object.assign({
'accept': 'application/json, text/javascript, */*; q=0.01',
'content-type': 'application/json',
'origin': 'https://aq.huya.com',
'referer': 'https://aq.huya.com/',
'user-agent': globalThis.navigator.userAgent,
'accept-language': 'zh-CN,zh;q=0.9',
}, headers || {}),
timeout: 15000,
}, res => {
let chunks = [];
res.on('data', c => chunks.push(c));
res.on('end', () => resolve({status: res.statusCode, text: Buffer.concat(chunks).toString('utf8')}));
});
req.on('timeout', () => { req.destroy(); resolve({status: 0, text: ''}); });
req.on('error', () => resolve({status: 0, text: ''}));
if (data) req.write(data);
req.end();
});
}
globalThis.XMLHttpRequest = class {
open(m, u){ this._m = m; this._u = u; this._h = {}; }
setRequestHeader(k, v){ this._h[k] = v; }
getAllResponseHeaders(){ return ''; }
addEventListener(t, f){ if (t === 'load' || t === 'readystatechange') this._on = f; }
send(d){
xhr(this._u, d, this._h).then(r => {
this.status = r.status; this.readyState = 4;
this.responseText = r.text; this.response = r.text;
const done = () => {
if (String(this._u).includes('/collect')) {
try {
const j = JSON.parse(r.text);
if (j && j.data && j.data.sdid) { console.log('__SDID__' + j.data.sdid); setTimeout(() => process.exit(0), 200); }
} catch (e) {}
}
try { this.onreadystatechange && this.onreadystatechange(); } catch (e) {}
try { this._on && this._on(); } catch (e) {}
try { this.onload && this.onload(); } catch (e) {}
};
setTimeout(done, 30);
});
}
};
eval(fs.readFileSync(path.join(__dirname, 'hydevice-prod-1.2.45-min.js'), 'utf8'));
try { globalThis.initFingerprint(process.argv[3] || '5008'); } catch (e) { console.error('[init]', e.message.slice(0, 120)); }
setTimeout(() => { console.error('__TIMEOUT__'); process.exit(2); }, 25000);
+28 -4
View File
@@ -22,6 +22,12 @@ from .cookie_utils import normalize_huya_cookie
APP_ID = "5002"
APP_VERSION = "2.6"
APP_SIGN = "1ce3bf682483d03f146f58232ec10635"
# App 场景(appId=5008): 风控走短信验证(dx_auth)。
# appSign 算法(逆向自 APK com/duowan/kiwi/base/login UdbNetHelper):
# md5(f"{appId}{versionCode}{appKey}")[:8], versionCode=115315(13.4.22),
# appKey 来自 APK 资源 R.string.cgs。
APP_ID_MOBILE = "5008"
APP_SIGN_MOBILE = "60576904"
LCID = "2052"
PASSWORD_LOGIN_URI = "30001"
DF_TOKEN_URL = "https://df.huya.com/web/df/token"
@@ -141,6 +147,7 @@ class HuyaPasswordLogin:
self.context = generate_context(self.device_id, self.mid)
self.middle_url = f"https://udblgn.huya.com/web/middle/{APP_VERSION}/{self.exchange}/https/{self.device_id}"
self.sdid = ""
self.sdid_source = ""
self.session = session or requests.Session()
self.session.trust_env = False
@@ -199,7 +206,20 @@ class HuyaPasswordLogin:
})
def prepare_device(self) -> str:
"""获取虎牙风控 sdid。"""
"""获取虎牙风控 sdid(优先 hydevice 高信任指纹,失败降级旧版)"""
from .device_fingerprint import get_huya_sdid
try:
result = get_huya_sdid(app_id="5008", timeout=self.timeout)
if result.sdid:
self.sdid = result.sdid
self.sdid_source = result.source
logger.debug("虎牙 sdid 来源: {}", result.source)
return self.sdid
logger.warning("虎牙高信任指纹未返回 sdid: {},降级旧流程", result.message)
except Exception as exc: # noqa: BLE001
logger.warning("虎牙高信任指纹异常: {},降级旧流程", exc)
token_payload = {"encryptVersion": "1.0.1", "fingerprintVersion": "1.2.41"}
token_res = self._request_json("post", DF_TOKEN_URL, "获取虎牙 df token", json=token_payload)
token = token_res.get("data", {}).get("token")
@@ -258,8 +278,8 @@ class HuyaPasswordLogin:
)
def _solve_verification(self, payload: dict) -> str:
"""处理 10030/10039 风控并返回 authId。"""
from .verification import HuyaVerificationSolver
"""处理 10030/10039 风控并返回 authId;未下发图形验证时返回空串"""
from .verification import HuyaNoSessionError, HuyaVerificationSolver
solver = HuyaVerificationSolver(
cookie=self.session.cookies.get_dict(),
@@ -269,7 +289,11 @@ class HuyaPasswordLogin:
proxies=self.session.proxies,
timeout=self.timeout,
)
verify_data = solver.solve(payload)
try:
verify_data = solver.solve(payload)
except HuyaNoSessionError as exc:
logger.warning("虎牙未下发图形验证({}),将直接重试登录", exc)
return ""
auth_id = verify_data.get("authId") if isinstance(verify_data, dict) else ""
if not auth_id:
raise HuyaLoginError(f"虎牙风控验证未返回 authId: {verify_data}")
+11 -1
View File
@@ -1,8 +1,18 @@
"""虎牙风控验证求解器。"""
from .solver import HuyaVerificationError, HuyaVerificationSolver, solve_huya_verification
from .solver import (
HuyaNoSessionError,
HuyaQrAuthRequiredError,
HuyaSmsAuthRequiredError,
HuyaVerificationError,
HuyaVerificationSolver,
solve_huya_verification,
)
__all__ = [
"HuyaNoSessionError",
"HuyaQrAuthRequiredError",
"HuyaSmsAuthRequiredError",
"HuyaVerificationError",
"HuyaVerificationSolver",
"solve_huya_verification",
+79 -11
View File
@@ -7,6 +7,7 @@ import io
import json
import random
import time
import uuid
from collections.abc import Mapping
from http.cookies import SimpleCookie
from urllib.parse import parse_qs, quote, urlparse
@@ -33,6 +34,22 @@ class HuyaVerificationError(RuntimeError):
"""虎牙风控验证失败。"""
class HuyaNoSessionError(HuyaVerificationError):
"""config3 未下发图形验证会话(策略不匹配或无需滑块)。"""
class HuyaQrAuthRequiredError(HuyaVerificationError):
"""风控策略为扫码验证(qr_auth),需已登录设备确认,无法自动化。"""
class HuyaSmsAuthRequiredError(HuyaVerificationError):
"""风控策略为短信验证(dx_auth),需接码/收码后提交。"""
def __init__(self, challenge_payload: dict | str, message: str = ""):
self.challenge_payload = challenge_payload
super().__init__(message or "虎牙风控要求短信验证")
def _cookie_mapping(cookie: Mapping[str, str] | str | None) -> dict[str, str]:
"""兼容 dict 和 Cookie 字符串。"""
if not cookie:
@@ -56,6 +73,9 @@ class HuyaVerificationSolver:
ocr: HuyaCaptchaOcr | None = None,
proxies: Mapping[str, str] | None = None,
timeout: tuple[float, float] = (8, 20),
app_id: str = "5002",
page_url: str | None = None,
use_touch_events: bool = False,
):
self.cookie = _cookie_mapping(cookie)
self.ua = ua or DEFAULT_UA
@@ -87,6 +107,9 @@ class HuyaVerificationSolver:
self.strategy_url = ""
self.js_ctx = None
self.behavior_events: list[dict] = []
self.app_id = app_id
self.page_url = page_url or self._auth_page_url()
self.use_touch_events = use_touch_events
@staticmethod
def _auth_page_url() -> str:
@@ -182,16 +205,26 @@ class HuyaVerificationSolver:
"""
self.js_ctx = execjs.compile(js_data.replace("$$self_js$$", dx_js))
def _build_common_payload(self, extra: dict) -> dict:
def _build_common_payload(self, extra: dict, info_override: str | None = None) -> dict:
payload = {
"urlParamMap": self.url_param_map,
"wupData": "",
"page": self._auth_page_url(),
"page": self.page_url,
"behavior": self._encode_behavior(),
"info": self.sdid,
"info": info_override if info_override is not None else self.sdid,
}
payload.update(extra)
return {"appId": "5002", "data": payload}
return {"appId": self.app_id, "data": payload}
def _csid(self) -> str:
"""App WebView 场景 get3 使用 csid_ 前缀会话 id。"""
return f"csid_{uuid.uuid4().hex}"
def _pointer_action(self, action: str) -> str:
"""web 用 mouse 事件,App WebView 场景用 touch 事件。"""
if not self.use_touch_events:
return action
return {"mousedown": "touchstart", "mousemove": "touchmove", "mouseup": "touchend"}.get(action, action)
def _encode_behavior(self) -> str:
"""编码浏览器行为,失败重试时会携带前一次点击记录。"""
@@ -233,6 +266,31 @@ class HuyaVerificationSolver:
self.url_param_map.get("mobile", ""),
len(self.url_param_map.get("param", "")),
)
strategies = []
if isinstance(challenge_payload, dict):
strategies = (challenge_payload.get("data") or {}).get("strategys") or []
if strategies:
try:
logger.warning(
"虎牙登录风控strategys完整结构: {}",
json.dumps(strategies, ensure_ascii=False)[:1200],
)
except Exception: # noqa: BLE001
pass
strategy_url_lower = strategy_url.lower()
is_qr_auth = "qr_auth" in strategy_url_lower or any(
isinstance(s, dict) and str(s.get("strategy")) == "64" for s in strategies
)
if is_qr_auth and "dx_auth" not in strategy_url_lower:
raise HuyaQrAuthRequiredError(
"虎牙风控要求扫码验证(qr_auth/strategy=64),需已登录App设备确认,"
f"账号: {self.url_param_map.get('mobile', '?')}"
)
if "dx_auth" in strategy_url_lower:
raise HuyaSmsAuthRequiredError(
challenge_payload,
f"虎牙风控要求短信验证(dx_auth),手机号: {self.url_param_map.get('mobile', '?')}",
)
self.behavior_events = []
def _restore_slider_background(self, bg_bytes: bytes, segment: str, huyapk: str) -> bytes:
@@ -290,7 +348,7 @@ class HuyaVerificationSolver:
}
act = self.js_ctx.call("get_act", act_data, self.huyapk)
self.behavior_events.append({
"action": "mousedown",
"action": self._pointer_action("mousedown"),
"id": "",
"txt": "",
"clientX": track_points[0][0],
@@ -329,7 +387,7 @@ class HuyaVerificationSolver:
behavior_list = []
for index, item in enumerate(click_data):
behavior_list.append({
"action": "mousedown",
"action": self._pointer_action("mousedown"),
"id": "",
"txt": txt_list[index] if index < len(txt_list) else "",
"clientX": item[0],
@@ -380,6 +438,8 @@ class HuyaVerificationSolver:
bool(vurl),
len(dx_js),
)
if not (gurl and vurl):
logger.warning("虎牙config3未下发验证会话, 完整响应: {}", json.dumps(config, ensure_ascii=False)[:1500])
if gurl and vurl and dx_js:
break
if index == 0 and self.url_param_map.get("sceneType") != "4":
@@ -390,20 +450,22 @@ class HuyaVerificationSolver:
time.sleep(0.3)
if not gurl or not vurl or not dx_js:
raise HuyaVerificationError(
"config3取参失败: "
data_summary = {k: str(v)[:80] for k, v in (data or {}).items() if k != "js"}
raise HuyaNoSessionError(
"config3未下发验证会话: "
f"returnCode={config.get('returnCode')}, "
f"description={config.get('description') or config.get('message') or ''}, "
f"gurl={bool(gurl)}, vurl={bool(vurl)}, js={bool(dx_js)}"
f"gurl={bool(gurl)}, vurl={bool(vurl)}, js={bool(dx_js)}, "
f"data={json.dumps(data_summary, ensure_ascii=False)[:600]}"
)
self._compile_js(dx_js)
point_payload = self._build_common_payload({"ver": 5})
point_payload = self._build_common_payload({"ver": 5}, info_override=self._csid() if self.use_touch_events else None)
last_result: dict = {}
for index in range(3):
if index:
time.sleep(0.4)
point_payload = self._build_common_payload({"ver": 5})
point_payload = self._build_common_payload({"ver": 5}, info_override=self._csid() if self.use_touch_events else None)
logger.info("虎牙验证失败后重试: {}/3", index + 1)
point = self._get_track_point(gurl, point_payload)
@@ -445,6 +507,9 @@ def solve_huya_verification(
sdid: str = "",
session: requests.Session | None = None,
proxies: Mapping[str, str] | None = None,
app_id: str = "5002",
page_url: str | None = None,
use_touch_events: bool = False,
) -> dict:
"""函数式入口,便于登录流程直接调用。"""
solver = HuyaVerificationSolver(
@@ -453,5 +518,8 @@ def solve_huya_verification(
sdid=sdid,
session=session,
proxies=proxies,
app_id=app_id,
page_url=page_url,
use_touch_events=use_touch_events,
)
return solver.solve(challenge_payload)