新增虎牙短信滑块自动验证

This commit is contained in:
yml
2026-11-14 02:18:38 +08:00
parent 2c2dc26a93
commit 3ea5dd5ce4
4 changed files with 281 additions and 16 deletions
@@ -400,20 +400,56 @@ export class HuyaNativeConnector {
await sms.bootstrapMiddle()
const device = sms.device()
const behavior = smsBehavior()
const response = await sms.call('/web/v2/smsCode', '60027', {
let response = await sms.call('/web/v2/smsCode', '60027', {
phone,
behavior: behavior.behavior,
page: behavior.page,
})
logWarn('huya/sms/raw-response', '虎牙短信发码原始响应', response)
const returnCode = extractInt(response.returnCode)
const sessionData = readString(response.data?.sessionData)
const challenge =
returnCode === 10030 || returnCode === 10039 ? await sms.prepareChallenge(response) : null
if (returnCode !== 0 && returnCode !== 10030 && returnCode !== 10039) {
throw createHttpError('虎牙短信验证码发送失败,请稍后重试', {
statusCode: 502,
errorCode: 'huya_sms_send_failed',
let challenge: HuyaSmsChallenge | null = null
let sessionData = ''
for (let autoAttempt = 0; ; autoAttempt += 1) {
logWarn('huya/sms/raw-response', '虎牙短信发码原始响应', response)
const returnCode = extractInt(response.returnCode)
if (returnCode === 0) {
sessionData = readString(response.data?.sessionData)
challenge = null
break
}
if (returnCode !== 10030 && returnCode !== 10039) {
throw createHttpError('虎牙短信验证码发送失败,请稍后重试', {
statusCode: 502,
errorCode: 'huya_sms_send_failed',
})
}
challenge = await sms.prepareChallenge(response)
if (challenge.type !== 'slider' || autoAttempt >= 2) break
const detection = await sms.detectSliderDistance()
if (!detection) {
logWarn('huya/sms/slider-auto', '虎牙滑块自动识别置信度不足,转客户手动验证', {
autoAttempt: autoAttempt + 1,
})
break
}
try {
await sms.solveChallenge({ distance: detection.distance })
logWarn('huya/sms/slider-auto', '虎牙滑块自动验证成功,重试发码', {
autoAttempt: autoAttempt + 1,
distance: detection.distance,
score: Number(detection.score.toFixed(4)),
})
} catch (error) {
logWarn('huya/sms/slider-auto', '虎牙滑块自动验证未通过,转客户手动验证', {
autoAttempt: autoAttempt + 1,
distance: detection.distance,
score: Number(detection.score.toFixed(4)),
message: error instanceof Error ? error.message : String(error),
})
break
}
response = await sms.call('/web/v2/smsCode', '60027', {
phone,
behavior: behavior.behavior,
page: behavior.page,
})
}
if (!challenge && !sessionData) {
@@ -6,6 +6,7 @@ import sharp from 'sharp'
import { buildHuyaLoginContext, ensureHuyaWebCookieIdentity, x64hash128 } from './cookie.js'
import { HuyaNativeConnector } from './huya-native-connector.js'
import { detectHuyaSliderDistance } from './huya-sms-role.js'
import { hasExistingHuyaBinding, parseBindStruct } from './huya-rpc.js'
const BIND_RESP_HEX =
@@ -20,6 +21,126 @@ test('QIMEI x64hash128 与 SDK 向量一致', () => {
assert.equal(x64hash128('abc', 31), '31844fd43df93041e52bcddd13312671')
})
test('虎牙滑块自动识别按图片边缘定位缺口', async () => {
const background = await sharp(
Buffer.from(
'<svg width="350" height="180"><rect width="350" height="180" fill="#dbeafe"/><circle cx="205" cy="95" r="24" fill="none" stroke="#111827" stroke-width="5"/><path d="M20 20 L100 30" stroke="#f97316" stroke-width="8"/></svg>',
),
)
.jpeg()
.toBuffer()
const puzzle = await sharp(
Buffer.from(
'<svg width="60" height="60"><circle cx="30" cy="30" r="24" fill="none" stroke="#111827" stroke-width="5"/></svg>',
),
)
.png()
.toBuffer()
const detected = await detectHuyaSliderDistance({
image: `data:image/jpeg;base64,${background.toString('base64')}`,
puzzleImage: `data:image/png;base64,${puzzle.toString('base64')}`,
puzzleY: 65,
puzzleWidth: 60,
})
assert.ok(detected)
assert.ok(Math.abs(detected.distance - 175) <= 2)
assert.ok(detected.score > 0.7)
})
test('虎牙短信发码自动通过滑块并继续发送验证码', async () => {
const background = await sharp(
Buffer.from(
'<svg width="350" height="180"><rect width="350" height="180" fill="#dbeafe"/><circle cx="205" cy="95" r="24" fill="none" stroke="#111827" stroke-width="5"/><path d="M20 20 L100 30" stroke="#f97316" stroke-width="8"/></svg>',
),
)
.jpeg()
.toBuffer()
const puzzle = await sharp(
Buffer.from(
'<svg width="60" height="60"><circle cx="30" cy="30" r="24" fill="none" stroke="#111827" stroke-width="5"/></svg>',
),
)
.png()
.toBuffer()
let smsCodeCalls = 0
let verifyCalled = false
const fetchImpl = (async (urlInput: string | URL | Request) => {
const url = String(urlInput)
if (url.endsWith('/web/df/token')) {
return new Response(JSON.stringify({ data: { token: 'auto-token' } }), { status: 200 })
}
if (url.endsWith('/web/df/collect')) {
return new Response(JSON.stringify({ data: { sdid: 'auto-sdid', hdid: 'auto-hdid' } }), {
status: 200,
})
}
if (url.includes('/web/middle/')) return new Response('<html></html>', { status: 200 })
if (url.endsWith('/web/v2/smsCode')) {
smsCodeCalls += 1
if (smsCodeCalls === 1) {
return new Response(
JSON.stringify({
returnCode: 10030,
data: {
strategys: [
{
data: 'https://aq.huya.com/p/safe_auth/pt_auth.html?param=auto-param&sceneType=4',
},
],
},
}),
{ status: 200 },
)
}
return new Response(
JSON.stringify({ returnCode: 0, data: { sessionData: 'auto-sms-session' } }),
{ status: 200 },
)
}
if (url.endsWith('/auth/client/config3')) {
return new Response(
JSON.stringify({
data: {
gurl: 'https://udbrtt.huya.com/auth/auto/get',
vurl: 'https://udbrtt.huya.com/auth/auto/verify',
js: 'window.UdbCipher={encrypt(){return [1,2,3]},decrypt(){return "{}"}}',
},
}),
{ status: 200 },
)
}
if (url.endsWith('/auth/auto/get')) {
return new Response(
JSON.stringify({
data: {
code: 'auto-code',
huyapk: 'auto-key',
dtuStr: background.toString('base64'),
puzzleStr: puzzle.toString('base64'),
location_y: 65,
},
}),
{ status: 200 },
)
}
if (url.includes('/auth/auto/verify')) {
verifyCalled = true
return new Response(JSON.stringify({ data: { authId: 'auto-auth-id' } }), { status: 200 })
}
throw new Error(`unexpected request: ${url}`)
}) as typeof fetch
const connector = new HuyaNativeConnector({ fetchImpl })
const result = await connector.sendSmsCode({
taskId: 5,
orderNo: 'ORDER-AUTO-SLIDER',
mobile: '13800138000',
})
assert.equal(result.challenge, null)
assert.equal(result.session.status, 'waiting_code')
assert.equal(smsCodeCalls, 2)
assert.equal(verifyCalled, true)
})
test('登录 context 使用网页设备 Cookie 的 guiddata、yamid 和 guid', () => {
const identity = ensureHuyaWebCookieIdentity({
cookie:
@@ -85,6 +85,12 @@ export type HuyaChallengeSolution = {
points?: unknown
}
export type HuyaSliderDetection = {
distance: number
score: number
y: number
}
type ChallengeState = {
strategyUrl: string
page: string
@@ -184,6 +190,96 @@ function generateTrack(distance: number): number[][] {
return points
}
function edgeMap(data: Buffer, width: number, height: number, channels: number): Float32Array {
const gray = new Float32Array(width * height)
const at = (index: number) => gray[index] ?? 0
for (let index = 0, offset = 0; index < gray.length; index += 1, offset += channels) {
gray[index] =
0.299 * Number(data[offset] || 0) +
0.587 * Number(data[offset + 1] || 0) +
0.114 * Number(data[offset + 2] || 0)
}
const edges = new Float32Array(width * height)
for (let y = 1; y < height - 1; y += 1) {
for (let x = 1; x < width - 1; x += 1) {
const index = y * width + x
const gx =
-at(index - width - 1) -
2 * at(index - 1) -
at(index + width - 1) +
at(index - width + 1) +
2 * at(index + 1) +
at(index + width + 1)
const gy =
-at(index - width - 1) -
2 * at(index - width) -
at(index - width + 1) +
at(index + width - 1) +
2 * at(index + width) +
at(index + width + 1)
edges[index] = Math.hypot(gx, gy)
}
}
return edges
}
export async function detectHuyaSliderDistance(input: {
image: string
puzzleImage: string
puzzleY: number
puzzleWidth: number
}): Promise<HuyaSliderDetection | null> {
const background = await sharp(imageBuffer(input.image))
.grayscale()
.raw()
.toBuffer({ resolveWithObject: true })
const puzzle = await sharp(imageBuffer(input.puzzleImage))
.ensureAlpha()
.raw()
.toBuffer({ resolveWithObject: true })
if (background.info.width <= puzzle.info.width || background.info.height <= puzzle.info.height) {
return null
}
const backgroundEdges = edgeMap(background.data, background.info.width, background.info.height, 1)
const puzzleEdges = edgeMap(puzzle.data, puzzle.info.width, puzzle.info.height, 4)
const alpha = new Float32Array(puzzle.info.width * puzzle.info.height)
for (let index = 0; index < alpha.length; index += 1) {
alpha[index] = Number(puzzle.data[index * 4 + 3] || 0) / 255
}
const yCenter = Math.max(
0,
Math.min(background.info.height - puzzle.info.height, Math.round(input.puzzleY)),
)
const yStart = Math.max(1, yCenter - 8)
const yEnd = Math.min(background.info.height - puzzle.info.height - 1, yCenter + 8)
let best: HuyaSliderDetection | null = null
for (let x = 1; x <= background.info.width - puzzle.info.width - 1; x += 1) {
for (let y = yStart; y <= yEnd; y += 1) {
let dot = 0
let puzzleEnergy = 0
let backgroundEnergy = 0
for (let pieceY = 0; pieceY < puzzle.info.height; pieceY += 1) {
for (let pieceX = 0; pieceX < puzzle.info.width; pieceX += 1) {
const pieceIndex = pieceY * puzzle.info.width + pieceX
const mask = alpha[pieceIndex] ?? 0
if (mask <= 0.2) continue
const pieceEdge = (puzzleEdges[pieceIndex] ?? 0) * mask
const backgroundEdge =
backgroundEdges[(y + pieceY) * background.info.width + x + pieceX] ?? 0
dot += pieceEdge * backgroundEdge
puzzleEnergy += pieceEdge * pieceEdge
backgroundEnergy += backgroundEdge * backgroundEdge
}
}
if (puzzleEnergy <= 0 || backgroundEnergy <= 0) continue
const score = dot / Math.sqrt(puzzleEnergy * backgroundEnergy)
if (!best || score > best.score) best = { distance: x, score, y }
}
}
return best && best.score >= 0.7 ? best : null
}
function challengeTypeFromUrl(url: string): HuyaVerificationChallenge['type'] | 'qr' | 'sms' {
const lower = url.toLowerCase()
if (lower.includes('rotate_auth') || lower.includes('/auth/xz/')) return 'rotate'
@@ -490,6 +586,17 @@ export class HuyaSmsRole {
this.challengeState = null
}
async detectSliderDistance(): Promise<HuyaSliderDetection | null> {
const challenge = this.challengeState?.challenge
if (!challenge || challenge.type !== 'slider') return null
return detectHuyaSliderDistance({
image: challenge.image,
puzzleImage: challenge.puzzleImage,
puzzleY: challenge.puzzleY,
puzzleWidth: challenge.puzzleWidth,
})
}
async verifyCookie(): Promise<void> {
const response = await this.fetchImpl(`${UDB_BASE}/web/cookie/verify`, {
method: 'POST',
@@ -3,7 +3,7 @@ type: 权威架构与实施计划
description: 订单、内部 SKU、履约路由、履约执行、商品、成本与财务的唯一权威口径及重构计划。
tags: [订单, 内部SKU, 履约, 商品, 成本, 财务, 迁移]
status: authoritative
version: 2.14
version: 2.15
updated_at: 2026-11-14
---
@@ -316,11 +316,12 @@ manual fulfillment
“发送验证码”后触发,页面不得在加载、轮询或切换 Tab 时自动发码;短信登录会话有效期 `5` 分钟。
登录成功后的 Connector 内存会话有效期 `24` 小时,账号级 CK 由内部凭证表保存并通过健康检查判定有效性;
浏览器一次性注入票据 `2` 分钟且消费后立即失效。重新登录前必须撤销旧 Connector 会话。
虎牙发码触发图形安全验证时,应由客户在 order_site 页面完成:`pt_auth` 使用滑块拼图,
`dx_auth` 使用按序点选,`rotate_auth` 使用旋转调整。服务端只接收客户最终位置、角度或点击坐标,
生成对应交互轨迹并重试发码,不自动识别、伪造或绕过平台风控。发码原始响应必须记录到集成日志,
但日志系统必须继续屏蔽 Cookie、令牌、验证码等敏感字段。`qr_auth` 和二次短信验证必须明确提示,
不得伪装成可完成的图形验证。
虎牙发码触发图形安全验证时,`pt_auth` 滑块允许服务端基于背景与拼图的边缘模板匹配自动定位缺口,
自动生成交互轨迹并在验证通过后重试发码;自动识别置信度不足、提交失败或连续重试失败时必须
回退到客户手动滑块,不能阻塞登录。`dx_auth` 点选和 `rotate_auth` 旋转仍由客户在 order_site
页面完成,服务端接收客户最终角度或点击坐标后生成对应交互轨迹。所有自动化都只复现正常浏览器交互,
不绕过平台风控。发码与挑战原始响应必须记录到集成日志,但日志系统必须继续屏蔽 Cookie、令牌、
验证码等敏感字段。`qr_auth` 和二次短信验证必须明确提示,不得伪装成可完成的图形验证。
- 虎牙客户登录成功后,无论账号此前是否已有角色,都立即保存 CK 并进入等待客服处理。
角色绑定、换绑和角色状态查询在后续独立流程实现,不能阻塞当前 CK 获取。
- 浏览器扩展复用 live-hub-py 的 `web/extension/huya-exchange-helper`,不新增第二个扩展;