虎牙登录: 接入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:
@@ -27,3 +27,4 @@ data/web.db
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
.reasonix/
|
.reasonix/
|
||||||
.tmp_reverse/
|
.tmp_reverse/
|
||||||
|
apks/
|
||||||
|
|||||||
@@ -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: 虎牙应用 id,App 场景 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")
|
||||||
@@ -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
@@ -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
@@ -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,
|
||||||
)
|
)
|
||||||
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 ""
|
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}")
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user