fix(huya): 一号一设备补完 — hydevice 设备参数跟随账号画像 + 画像一致性字段下沉

用户实弹测试暴露两个缺口:
1. Hebe/GUID32 全空: 登录路径走 get_profile, 未经过 account_env 补字段
   → _enrich_profile 下沉到 get_profile (幂等, 存量账号自动补齐)
2. sdid 前缀跨账号同源 (0UnHUgv0_qmfD4KAKlwzh...): env.js 是静态设备仿真
   (写死 M2102J2SC), 状态目录隔离只改了'存储', 没改'采集输入'
   → env.js makeEnv(overrides) 支持按画像覆盖 UA/屏幕/机型/Android版本;
     runner 接收 device.json; get_huya_sdid(device_hint=画像);
     下次登录每账号 hydevice 采集输入=各自绑定机型 → sdid 彻底分家
This commit is contained in:
yml2213
2026-08-29 16:51:41 +08:00
parent fda3d5257b
commit 4f099cc196
5 changed files with 91 additions and 13 deletions
+2 -1
View File
@@ -448,7 +448,8 @@ class HuyaAppPasswordLogin:
# 一号一设备: sdid 状态按账号隔离 (默认全局目录会让所有账号共享 # 一号一设备: sdid 状态按账号隔离 (默认全局目录会让所有账号共享
# 同一份 hydevice 设备状态, 服务端可跨账号关联 — 见 R40 缺口修复) # 同一份 hydevice 设备状态, 服务端可跨账号关联 — 见 R40 缺口修复)
sdid_obj = get_huya_sdid(allow_fallback=True, sdid_obj = get_huya_sdid(allow_fallback=True,
state_dir=account_state_dir(self.username)) state_dir=account_state_dir(self.username),
device_hint=self.device_info)
sdid = sdid_obj.sdid if sdid_obj else "" sdid = sdid_obj.sdid if sdid_obj else ""
pc = QrRole(pc=True, sdid=sdid, proxies=self.proxies) pc = QrRole(pc=True, sdid=sdid, proxies=self.proxies)
ph = QrRole(pc=False, sdid=sdid, proxies=self.proxies) ph = QrRole(pc=False, sdid=sdid, proxies=self.proxies)
+41 -1
View File
@@ -9,6 +9,7 @@ from __future__ import annotations
import shutil import shutil
import subprocess import subprocess
import tempfile import tempfile
import json
from collections.abc import Mapping from collections.abc import Mapping
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
@@ -52,7 +53,11 @@ HDID_PREFIX = "__HDID__"
def _run_node_runner(state_dir: Path, app_id: str, timeout: tuple[float, float]) -> tuple[str, str]: def _run_node_runner(state_dir: Path, app_id: str, timeout: tuple[float, float]) -> tuple[str, str]:
"""调用 node runner,返回 (sdid, hdid)。""" """调用 node runner,返回 (sdid, hdid)。
若 state_dir/device.json 存在 (账号画像派生的设备覆盖参数), runner 会以该
设备身份运行 hydevice → 不同账号的 sdid/40hex hdid 不再同源 (一号一设备)。
"""
node_bin = shutil.which("node") node_bin = shutil.which("node")
if not node_bin: if not node_bin:
raise HuyaFingerprintError("未找到 node 可执行文件(hydevice.js 需要 JS 引擎)") raise HuyaFingerprintError("未找到 node 可执行文件(hydevice.js 需要 JS 引擎)")
@@ -102,6 +107,37 @@ def _fallback_sdid(session: requests.Session, timeout) -> str:
return collect_res.json().get("data", {}).get("sdid", "") return collect_res.json().get("data", {}).get("sdid", "")
def write_device_hint(state_dir: str | Path, device_info: Mapping) -> Path:
"""把账号画像写成 hydevice 设备覆盖参数 (env.js overrides)。
来源: 画像 model/screen(格式"model,sdkVer,androidVer")/vendor + app 版本。
每账号独立 → hydevice 采集输入不同 → 服务端 sdid 不再同前缀。
"""
state_dir = Path(state_dir)
state_dir.mkdir(parents=True, exist_ok=True)
screen = str(device_info.get("screen", ""))
parts = screen.split(",")
android_ver = parts[2] if len(parts) >= 3 else "11"
ua = (
f"Mozilla/5.0 (Linux; Android {android_ver}; {device_info.get('model', '')} "
"Build/RKQ1.200826.002; wv) AppleWebKit/537.36 (KHTML, like Gecko) "
"Version/4.0 Chrome/149.0.7827.159 Mobile Safari/537.36 "
f"huya adr/13.4.22/{device_info.get('vendor', 'android')}/30"
)
hint = {
"model": device_info.get("model", ""),
"vendor": device_info.get("vendor", "android"),
"androidVer": android_ver,
"appVer": str(device_info.get("app_version", "13.4.22")),
"userAgent": ua,
"screenWidth": int(device_info.get("width", 1080) or 1080),
"screenHeight": int(device_info.get("height", 2120) or 2120),
}
path = state_dir / "device.json"
path.write_text(json.dumps(hint, ensure_ascii=False), encoding="utf-8")
return path
def get_huya_sdid( def get_huya_sdid(
app_id: str = "5008", app_id: str = "5008",
state_dir: str | Path | None = None, state_dir: str | Path | None = None,
@@ -109,6 +145,7 @@ def get_huya_sdid(
proxies: Mapping | None = None, proxies: Mapping | None = None,
timeout: tuple[float, float] = DEFAULT_TIMEOUT, timeout: tuple[float, float] = DEFAULT_TIMEOUT,
allow_fallback: bool = True, allow_fallback: bool = True,
device_hint: Mapping | None = None,
) -> HuyaSdidResult: ) -> HuyaSdidResult:
"""获取虎牙高信任 sdid;node 链路失败时可选降级为低信任指纹。 """获取虎牙高信任 sdid;node 链路失败时可选降级为低信任指纹。
@@ -125,6 +162,9 @@ def get_huya_sdid(
if state_dir is None: if state_dir is None:
state_dir = Path(tempfile.gettempdir()) / "huya_fp_state" state_dir = Path(tempfile.gettempdir()) / "huya_fp_state"
state_dir = Path(state_dir) state_dir = Path(state_dir)
if device_hint:
# 一号一设备: 账号画像 → hydevice 设备覆盖 (UA/屏幕/机型)
write_device_hint(state_dir, device_hint)
try: try:
sdid, hdid = _run_node_runner(state_dir, app_id, timeout) sdid, hdid = _run_node_runner(state_dir, app_id, timeout)
if sdid: if sdid:
+25 -3
View File
@@ -100,12 +100,34 @@ def _save_db(db: dict) -> None:
pass pass
def _enrich_profile(profile: dict) -> dict:
"""补齐 dfp 一致性字段 (幂等): guid32 / Hebe_D1-D5。
来源: R36 解密的真实 dfp 指纹 JSON 结构 (deviceinfo.guid + deviceinfo.Hebe_D1-D5)。
一号一设备: 每账号独立生成后终身不变, 供后续真实载荷构建与 GUI 设备绑定页展示。
"""
changed = False
if not profile.get("guid32"):
profile["guid32"] = hashlib.sha256(os.urandom(16) + b"guid").hexdigest()
changed = True
hebe = profile.get("hebe") or {}
if len(hebe) < 5:
profile["hebe"] = {f"Hebe_D{i}": hashlib.sha256(os.urandom(16) + f"hebe{i}".encode()).hexdigest()
for i in range(1, 6)}
changed = True
return profile if changed else profile
def get_profile(account: str, force_new: bool = False) -> dict: def get_profile(account: str, force_new: bool = False) -> dict:
"""按账号获取或创建画像(幂等:同账号复用同一套)。""" """按账号获取或创建画像(幂等:同账号复用同一套, 自动补齐缺失一致性字段)。"""
db = _load_db() db = _load_db()
if not force_new and account in db: if not force_new and account in db:
return db[account] enriched = _enrich_profile(db[account])
p = generate_profile() if enriched is not db[account]:
db[account] = enriched
_save_db(db)
return enriched
p = _enrich_profile(generate_profile())
db[account] = p db[account] = p
_save_db(db) _save_db(db)
return p return p
+19 -7
View File
@@ -1,17 +1,29 @@
// Android WebView (M2102J2SC, 小米, Android 11) 环境仿真 // Android WebView 环境仿真 (默认 M2102J2SC; 可传设备覆盖参数实现一号一设备)
function makeEnv() { // overrides = {model, androidVer, vendor, appVer, screenWidth, screenHeight}
function makeEnv(overrides) {
const ov = overrides || {};
const MODEL = ov.model || 'M2102J2SC';
const ANDROID_VER = ov.androidVer || '11';
const VENDOR = ov.vendor || 'xiaomi';
const APP_VER = ov.appVer || '13.4.22';
const UA = ov.userAgent || ('Mozilla/5.0 (Linux; Android ' + ANDROID_VER + '; ' + MODEL +
' 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/' + APP_VER + '/' + VENDOR + '/30');
// 视口按物理分辨率/3 仿真 (393x851 ≈ 1080x2120 / 2.75)
const VW = ov.screenWidth ? Math.round(ov.screenWidth / 2.75) : 393;
const VH = ov.screenHeight ? Math.round(ov.screenHeight / 2.75) : 851;
const W = globalThis; const W = globalThis;
W.screen = {width:393, height:851, availWidth:393, availHeight:851, W.screen = {width:VW, height:VH, availWidth:VW, availHeight:VH,
colorDepth:24, pixelDepth:24, availLeft:0, availTop:0, orientation:{type:'portrait-primary', angle:0}}; colorDepth:24, pixelDepth:24, availLeft:0, availTop:0, orientation:{type:'portrait-primary', angle:0}};
W.devicePixelRatio = 2.75; W.devicePixelRatio = 2.75;
W.innerWidth = 393; W.innerHeight = 737; W.outerWidth = 393; W.outerHeight = 851; W.innerWidth = VW; W.innerHeight = Math.max(VH - 114, 500); W.outerWidth = VW; W.outerHeight = VH;
W.location = {href:'https://aq.huya.com/p/safe_auth/pt_auth.html', protocol:'https:', host:'aq.huya.com', 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:'', hostname:'aq.huya.com', port:'', pathname:'/p/safe_auth/pt_auth.html', search:'', hash:'',
origin:'https://aq.huya.com', toString(){return this.href}}; origin:'https://aq.huya.com', toString(){return this.href}};
W.history = {length:2, state:null, pushState(){}, replaceState(){}, back(){}, go(){}}; W.history = {length:2, state:null, pushState(){}, replaceState(){}, back(){}, go(){}};
const navProps = { 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', userAgent: UA,
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', appName:'Netscape', appVersion: UA.replace(/^Mozilla\//, ''),
platform:'Linux armv8l', language:'zh-CN', languages:['zh-CN','zh','en'], platform:'Linux armv8l', language:'zh-CN', languages:['zh-CN','zh','en'],
cookieEnabled:true, doNotTrack:null, maxTouchPoints:5, hardwareConcurrency:8, cookieEnabled:true, doNotTrack:null, maxTouchPoints:5, hardwareConcurrency:8,
deviceMemory:8, vendor:'Google Inc.', productSub:'20030107', webdriver:false, deviceMemory:8, vendor:'Google Inc.', productSub:'20030107', webdriver:false,
@@ -19,7 +31,7 @@ function makeEnv() {
getBattery(){return Promise.resolve({charging:true, level:0.9})}, 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'}], userAgentData: {brands:[{brand:'Chromium',version:'149'},{brand:'Google Chrome',version:'149'},{brand:'Not?A_Brand',version:'24'}],
mobile:true, platform:'Android', mobile:true, platform:'Android',
getHighEntropyValues(){return Promise.resolve({architecture:'arm', bitness:'64', model:'M2102J2SC', platformVersion:'11.0.0', uaFullVersion:'149.0.7827.159'})}}, getHighEntropyValues(){return Promise.resolve({architecture:'arm', bitness:'64', model:MODEL, platformVersion:ANDROID_VER+'.0.0', uaFullVersion:'149.0.7827.159'})}},
connection: {effectiveType:'4g', downlink:10, rtt:50, saveData:false}, connection: {effectiveType:'4g', downlink:10, rtt:50, saveData:false},
plugins: {length:0, item(){return null}, namedItem(){return null}}, plugins: {length:0, item(){return null}, namedItem(){return null}},
mimeTypes: {length:0}, mimeTypes: {length:0},
+4 -1
View File
@@ -1,11 +1,14 @@
// hydevice 指纹运行器: 仿真 Android WebView 环境 -> 真实请求 df/token + df/collect -> 输出 sdid // hydevice 指纹运行器: 仿真 Android WebView 环境 -> 真实请求 df/token + df/collect -> 输出 sdid
// argv: [stateDir, deviceJson] — deviceJson 为账号画像派生的设备覆盖参数 (一号一设备)
process.on('uncaughtException', e => { console.error('[uncaught]', String(e && e.message).slice(0, 120)); }); process.on('uncaughtException', e => { console.error('[uncaught]', String(e && e.message).slice(0, 120)); });
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const https = require('https'); const https = require('https');
globalThis.window = globalThis; globalThis.window = globalThis;
require(path.join(__dirname, 'env.js')).makeEnv(); let _deviceOverrides = null;
try { _deviceOverrides = JSON.parse(fs.readFileSync(process.argv[3] || '', 'utf8')); } catch (e) {}
require(path.join(__dirname, 'env.js')).makeEnv(_deviceOverrides);
// ---- localStorage 持久化(设备稳定性) ---- // ---- localStorage 持久化(设备稳定性) ----
const stateDir = process.argv[2] || '.'; const stateDir = process.argv[2] || '.';