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:
@@ -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)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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},
|
||||||
|
|||||||
@@ -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] || '.';
|
||||||
|
|||||||
Reference in New Issue
Block a user