虎牙登录: 接入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
+1
View File
@@ -27,3 +27,4 @@ data/web.db
.DS_Store .DS_Store
.reasonix/ .reasonix/
.tmp_reverse/ .tmp_reverse/
apks/
+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);
+27 -3
View File
@@ -22,6 +22,12 @@ 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"
# 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" LCID = "2052"
PASSWORD_LOGIN_URI = "30001" PASSWORD_LOGIN_URI = "30001"
DF_TOKEN_URL = "https://df.huya.com/web/df/token" 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.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.middle_url = f"https://udblgn.huya.com/web/middle/{APP_VERSION}/{self.exchange}/https/{self.device_id}"
self.sdid = "" self.sdid = ""
self.sdid_source = ""
self.session = session or requests.Session() self.session = session or requests.Session()
self.session.trust_env = False self.session.trust_env = False
@@ -199,7 +206,20 @@ class HuyaPasswordLogin:
}) })
def prepare_device(self) -> str: 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_payload = {"encryptVersion": "1.0.1", "fingerprintVersion": "1.2.41"}
token_res = self._request_json("post", DF_TOKEN_URL, "获取虎牙 df token", json=token_payload) token_res = self._request_json("post", DF_TOKEN_URL, "获取虎牙 df token", json=token_payload)
token = token_res.get("data", {}).get("token") token = token_res.get("data", {}).get("token")
@@ -258,8 +278,8 @@ class HuyaPasswordLogin:
) )
def _solve_verification(self, payload: dict) -> str: def _solve_verification(self, payload: dict) -> str:
"""处理 10030/10039 风控并返回 authId。""" """处理 10030/10039 风控并返回 authId;未下发图形验证时返回空串"""
from .verification import HuyaVerificationSolver from .verification import HuyaNoSessionError, HuyaVerificationSolver
solver = HuyaVerificationSolver( solver = HuyaVerificationSolver(
cookie=self.session.cookies.get_dict(), cookie=self.session.cookies.get_dict(),
@@ -269,7 +289,11 @@ class HuyaPasswordLogin:
proxies=self.session.proxies, proxies=self.session.proxies,
timeout=self.timeout, timeout=self.timeout,
) )
try:
verify_data = solver.solve(payload) 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 "" auth_id = verify_data.get("authId") if isinstance(verify_data, dict) else ""
if not auth_id: if not auth_id:
raise HuyaLoginError(f"虎牙风控验证未返回 authId: {verify_data}") 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__ = [ __all__ = [
"HuyaNoSessionError",
"HuyaQrAuthRequiredError",
"HuyaSmsAuthRequiredError",
"HuyaVerificationError", "HuyaVerificationError",
"HuyaVerificationSolver", "HuyaVerificationSolver",
"solve_huya_verification", "solve_huya_verification",
+79 -11
View File
@@ -7,6 +7,7 @@ import io
import json import json
import random import random
import time import time
import uuid
from collections.abc import Mapping from collections.abc import Mapping
from http.cookies import SimpleCookie from http.cookies import SimpleCookie
from urllib.parse import parse_qs, quote, urlparse 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]: def _cookie_mapping(cookie: Mapping[str, str] | str | None) -> dict[str, str]:
"""兼容 dict 和 Cookie 字符串。""" """兼容 dict 和 Cookie 字符串。"""
if not cookie: if not cookie:
@@ -56,6 +73,9 @@ class HuyaVerificationSolver:
ocr: HuyaCaptchaOcr | None = None, ocr: HuyaCaptchaOcr | None = None,
proxies: Mapping[str, str] | None = None, proxies: Mapping[str, str] | None = None,
timeout: tuple[float, float] = (8, 20), 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.cookie = _cookie_mapping(cookie)
self.ua = ua or DEFAULT_UA self.ua = ua or DEFAULT_UA
@@ -87,6 +107,9 @@ class HuyaVerificationSolver:
self.strategy_url = "" self.strategy_url = ""
self.js_ctx = None self.js_ctx = None
self.behavior_events: list[dict] = [] 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 @staticmethod
def _auth_page_url() -> str: def _auth_page_url() -> str:
@@ -182,16 +205,26 @@ class HuyaVerificationSolver:
""" """
self.js_ctx = execjs.compile(js_data.replace("$$self_js$$", dx_js)) 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 = { payload = {
"urlParamMap": self.url_param_map, "urlParamMap": self.url_param_map,
"wupData": "", "wupData": "",
"page": self._auth_page_url(), "page": self.page_url,
"behavior": self._encode_behavior(), "behavior": self._encode_behavior(),
"info": self.sdid, "info": info_override if info_override is not None else self.sdid,
} }
payload.update(extra) 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: def _encode_behavior(self) -> str:
"""编码浏览器行为,失败重试时会携带前一次点击记录。""" """编码浏览器行为,失败重试时会携带前一次点击记录。"""
@@ -233,6 +266,31 @@ class HuyaVerificationSolver:
self.url_param_map.get("mobile", ""), self.url_param_map.get("mobile", ""),
len(self.url_param_map.get("param", "")), 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 = [] self.behavior_events = []
def _restore_slider_background(self, bg_bytes: bytes, segment: str, huyapk: str) -> bytes: 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) act = self.js_ctx.call("get_act", act_data, self.huyapk)
self.behavior_events.append({ self.behavior_events.append({
"action": "mousedown", "action": self._pointer_action("mousedown"),
"id": "", "id": "",
"txt": "", "txt": "",
"clientX": track_points[0][0], "clientX": track_points[0][0],
@@ -329,7 +387,7 @@ class HuyaVerificationSolver:
behavior_list = [] behavior_list = []
for index, item in enumerate(click_data): for index, item in enumerate(click_data):
behavior_list.append({ behavior_list.append({
"action": "mousedown", "action": self._pointer_action("mousedown"),
"id": "", "id": "",
"txt": txt_list[index] if index < len(txt_list) else "", "txt": txt_list[index] if index < len(txt_list) else "",
"clientX": item[0], "clientX": item[0],
@@ -380,6 +438,8 @@ class HuyaVerificationSolver:
bool(vurl), bool(vurl),
len(dx_js), 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: if gurl and vurl and dx_js:
break break
if index == 0 and self.url_param_map.get("sceneType") != "4": if index == 0 and self.url_param_map.get("sceneType") != "4":
@@ -390,20 +450,22 @@ class HuyaVerificationSolver:
time.sleep(0.3) time.sleep(0.3)
if not gurl or not vurl or not dx_js: if not gurl or not vurl or not dx_js:
raise HuyaVerificationError( data_summary = {k: str(v)[:80] for k, v in (data or {}).items() if k != "js"}
"config3取参失败: " raise HuyaNoSessionError(
"config3未下发验证会话: "
f"returnCode={config.get('returnCode')}, " f"returnCode={config.get('returnCode')}, "
f"description={config.get('description') or config.get('message') or ''}, " 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) 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 = {} last_result: dict = {}
for index in range(3): for index in range(3):
if index: if index:
time.sleep(0.4) 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) logger.info("虎牙验证失败后重试: {}/3", index + 1)
point = self._get_track_point(gurl, point_payload) point = self._get_track_point(gurl, point_payload)
@@ -445,6 +507,9 @@ def solve_huya_verification(
sdid: str = "", sdid: str = "",
session: requests.Session | None = None, session: requests.Session | None = None,
proxies: Mapping[str, str] | None = None, proxies: Mapping[str, str] | None = None,
app_id: str = "5002",
page_url: str | None = None,
use_touch_events: bool = False,
) -> dict: ) -> dict:
"""函数式入口,便于登录流程直接调用。""" """函数式入口,便于登录流程直接调用。"""
solver = HuyaVerificationSolver( solver = HuyaVerificationSolver(
@@ -453,5 +518,8 @@ def solve_huya_verification(
sdid=sdid, sdid=sdid,
session=session, session=session,
proxies=proxies, proxies=proxies,
app_id=app_id,
page_url=page_url,
use_touch_events=use_touch_events,
) )
return solver.solve(challenge_payload) return solver.solve(challenge_payload)