Files
2026-08-31 10:55:44 +08:00

580 lines
23 KiB
Python

"""虎牙登录风控验证闭环。"""
from __future__ import annotations
import base64
import io
import json
import random
import time
import uuid
from collections.abc import Mapping
from http.cookies import SimpleCookie
from typing import Any, cast
from urllib.parse import parse_qs, quote, urlparse
import cv2
import execjs
import numpy as np
import requests
from loguru import logger
from PIL import Image
from .ocr import HuyaCaptchaOcr, default_ocr
from .track import format_track, generate_slide_track
DEFAULT_UA = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/120.0.0.0 Safari/537.36"
)
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]:
"""兼容 dict 和 Cookie 字符串。"""
if not cookie:
return {}
if isinstance(cookie, str):
parsed = SimpleCookie()
parsed.load(cookie)
return {key: morsel.value for key, morsel in parsed.items()}
return {str(key): str(value) for key, value in cookie.items()}
class HuyaVerificationSolver:
"""处理虎牙 returnCode=10030/10039 后的滑块或点选验证。"""
def __init__(
self,
cookie: Mapping[str, str] | str | None = None,
ua: str = DEFAULT_UA,
sdid: str = "",
session: requests.Session | None = None,
ocr: HuyaCaptchaOcr | None = None,
proxies: Mapping[str, str] | None = None,
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.ua = ua or DEFAULT_UA
self.sdid = sdid or self.cookie.get("sdid", "")
self.session = session or requests.Session()
self.session.trust_env = False
if self.cookie:
self.session.cookies.update(self.cookie)
if proxies:
self.session.proxies = dict(proxies)
self.session.headers.update(
{
"Accept-Language": "zh-CN,zh;q=0.9",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"Origin": "https://aq.huya.com",
"Pragma": "no-cache",
"Referer": "https://aq.huya.com/",
"User-Agent": self.ua,
"accept": "application/json, text/plain, */*",
"content-type": "application/json",
}
)
self.timeout = timeout
self.ocr = ocr or default_ocr()
self.huyapk = ""
self.scene_type = ""
self.code = ""
self.url_param_map: dict[str, str] = {}
self.strategy_url = ""
self.js_ctx: Any = None
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
def _auth_page_url() -> str:
"""生成 auth 页面地址,形式参考易语言实现。"""
return f"https://aq.huya.com/p/udb_login.html?v={str(int(time.time() * 1000))[:8]}00000"
@staticmethod
def _decode_image(base64_string: str | None) -> bytes:
if not base64_string:
raise HuyaVerificationError("验证码图片为空")
if ";base64," in base64_string:
base64_string = base64_string.split(";base64,")[-1]
return base64.b64decode(base64_string)
@staticmethod
def _vertical_concat_align_left(
bg_bytes: bytes, tip_bytes: bytes, image_format: str = "JPEG"
) -> bytes:
bg = cv2.imdecode(np.frombuffer(bg_bytes, np.uint8), cv2.IMREAD_COLOR)
tip = cv2.imdecode(np.frombuffer(tip_bytes, np.uint8), cv2.IMREAD_COLOR)
if bg is None or tip is None:
raise HuyaVerificationError("验证码图片解码失败")
max_width = max(bg.shape[1], tip.shape[1])
def pad_right(img, target_width):
_height, width = img.shape[:2]
if width >= target_width:
return img
return cv2.copyMakeBorder(
img,
0,
0,
0,
target_width - width,
cv2.BORDER_CONSTANT,
value=(255, 255, 255),
)
concat_img = np.vstack([pad_right(tip, max_width), pad_right(bg, max_width)])
success, buffer = cv2.imencode(f".{image_format.lower()}", concat_img)
if not success:
raise HuyaVerificationError("点选验证码图片编码失败")
return buffer.tobytes()
@staticmethod
def _remove_black_borders(image: Image.Image) -> tuple[Image.Image, int]:
if image.mode != "RGBA":
image = image.convert("RGBA")
bbox = image.getbbox()
if not bbox:
return image, 0
cropped_height = bbox[3] - bbox[1]
black_border_height = image.height - cropped_height
return image.crop(bbox), black_border_height
@classmethod
def _composite_slider_images(
cls, bg_bytes: bytes, tip_bytes: bytes, y_position: int
) -> bytes:
bg = Image.open(io.BytesIO(bg_bytes)).convert("RGB")
tip = Image.open(io.BytesIO(tip_bytes)).convert("RGBA")
tip, black_border_height = cls._remove_black_borders(tip)
adjusted_y = y_position + int(black_border_height / 2) + 1
bg.paste(tip, (10, adjusted_y), tip)
output = io.BytesIO()
bg.save(output, format="PNG")
return output.getvalue()
@staticmethod
def _opencv_slider_x(target_bytes: bytes, background_bytes: bytes) -> int:
target = cv2.imdecode(
np.frombuffer(target_bytes, np.uint8), cv2.IMREAD_ANYCOLOR
)
background = cv2.imdecode(
np.frombuffer(background_bytes, np.uint8), cv2.IMREAD_ANYCOLOR
)
if target is None or background is None:
raise HuyaVerificationError("滑块图片解码失败")
target = cv2.cvtColor(cv2.Canny(target, 100, 200), cv2.COLOR_GRAY2RGB)
background = cv2.cvtColor(cv2.Canny(background, 100, 200), cv2.COLOR_GRAY2RGB)
result = cv2.matchTemplate(background, target, cv2.TM_CCOEFF_NORMED)
_, _, _, max_loc = cv2.minMaxLoc(result)
return int(max_loc[0])
def _request_json(self, method: str, url: str, **kwargs) -> dict:
response = self.session.request(method, url, timeout=self.timeout, **kwargs)
response.raise_for_status()
try:
return response.json()
except json.JSONDecodeError as exc:
raise HuyaVerificationError(
f"虎牙验证接口返回不是 JSON: {response.text[:200]}"
) from exc
def _compile_js(self, dx_js: str) -> None:
js_data = """
window = globalThis;
$$self_js$$
function get_regoin_arr(segment, huyapk) {
return JSON.parse(atob(window.UdbCipher.decrypt(segment, huyapk))).ord
}
function get_act(act_data, huyapk) {
act_data = encodeURI(JSON.stringify(act_data))
return window.UdbCipher.encrypt(btoa(act_data), huyapk)
}
"""
self.js_ctx = execjs.compile(js_data.replace("$$self_js$$", dx_js))
def _build_common_payload(
self, extra: dict, info_override: str | None = None
) -> dict:
payload = {
"urlParamMap": self.url_param_map,
"wupData": "",
"page": self.page_url,
"behavior": self._encode_behavior(),
"info": info_override if info_override is not None else self.sdid,
}
payload.update(extra)
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:
"""编码浏览器行为,失败重试时会携带前一次点击记录。"""
return quote(
json.dumps(self.behavior_events, separators=(",", ":")), safe="~()*!.'"
)
@staticmethod
def _extract_strategy_url(challenge_payload: dict | str) -> str:
"""兼容完整响应、data 子对象、data URL 字符串三种输入。"""
if isinstance(challenge_payload, str):
return challenge_payload
data = challenge_payload.get("data")
if isinstance(data, str) and data.startswith("http"):
return data
if isinstance(data, str):
try:
data = json.loads(data)
except json.JSONDecodeError:
data = {}
strategy_root = data if isinstance(data, dict) else challenge_payload
try:
return strategy_root["strategys"][0]["data"]
except (KeyError, IndexError, TypeError) as exc:
raise HuyaVerificationError("虎牙响应中没有 strategys[0].data") from exc
def _parse_strategy(self, challenge_payload: dict | str) -> None:
strategy_url = self._extract_strategy_url(challenge_payload)
query_params = parse_qs(urlparse(strategy_url).query)
self.url_param_map = {key: value[0] for key, value in query_params.items()}
self.scene_type = self.url_param_map.get("sceneType", "")
self.strategy_url = strategy_url
if not self.url_param_map.get("param"):
raise HuyaVerificationError("虎牙验证链接缺少 param")
logger.debug(
"虎牙验证参数: sceneType={}, mobile={}, param长度={}",
self.scene_type,
self.url_param_map.get("mobile", ""),
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 as exc: # noqa: BLE001
logger.debug(f"风控策略日志序列化失败: {exc}")
strategy_url_lower = strategy_url.lower()
# 判定依据是 URL 路径,不是 strategy 数值。
# 实测(2026-08-25): strategy=64 时 pt_auth.html 是滑块、qr_auth.html 才是扫码,
# 旧的 `strategy==64 → 扫码` 硬编码会把可自动化的滑块误杀成死路。
is_qr_auth = "qr_auth" in strategy_url_lower
if is_qr_auth and "dx_auth" not in strategy_url_lower:
raise HuyaQrAuthRequiredError(
"虎牙风控要求扫码验证(qr_auth),需已登录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 = []
def _restore_slider_background(
self, bg_bytes: bytes, segment: str, huyapk: str
) -> bytes:
recover_list = self.js_ctx.call("get_regoin_arr", segment, huyapk)
image = Image.open(io.BytesIO(bg_bytes)).convert("RGB")
width, height = image.size
captcha = Image.new("RGB", (width, height))
for index, region_index in enumerate(recover_list):
region = image.crop((region_index * 26, 0, region_index * 26 + 26, 180))
captcha.paste(region, (index * 26, 0, index * 26 + 26, 180))
output = io.BytesIO()
captcha.save(output, format="JPEG")
return output.getvalue()
def _get_track_point(self, url: str, payload: dict):
logger.debug("虎牙验证获取图片: {}", url)
result = self._request_json("post", url, json=payload)
if result.get("returnCode") != 0 or not result.get("data", {}).get("code"):
raise HuyaVerificationError(f"获取验证码图片失败: {result}")
data = result["data"]
self.code = data["code"]
self.huyapk = data["huyapk"]
segment = data.get("segment")
if segment:
logger.info("虎牙验证类型: 滑块")
bg_bytes = self._decode_image(data.get("dtuStr"))
tip_bytes = self._decode_image(data.get("puzzleStr"))
restored_bg = self._restore_slider_background(
bg_bytes, segment, self.huyapk
)
composite = self._composite_slider_images(
restored_bg, tip_bytes, int(data.get("location_y") or 0)
)
slider_box = self.ocr.ocr_slider_box(composite)
yolo_x = int(slider_box[0] - 13)
opencv_x = self._opencv_slider_x(tip_bytes, restored_bg)
if abs(opencv_x - yolo_x) < 10:
logger.debug("虎牙滑块识别距离: yolo={}, opencv={}", yolo_x, opencv_x)
return int((opencv_x + yolo_x) / 2)
logger.debug(
"虎牙滑块识别距离: yolo={}, opencv={}, 使用yolo", yolo_x, opencv_x
)
return yolo_x
logger.info("虎牙验证类型: 点选")
bg_bytes = self._decode_image(data.get("dTuStr"))
tip_bytes = self._decode_image(data.get("hZiStr"))
merged = self._vertical_concat_align_left(bg_bytes, tip_bytes)
points = self.ocr.ocr_click_points(merged)
return [[int(x), int(y) - 44] for x, y in points]
def _send_slider_verify(self, url: str, point: int) -> dict:
track_points = generate_slide_track(point)
act_data = {
"point": int(point),
"travel": format_track(track_points),
"code": self.code,
"sceneType": self.scene_type,
"endTime": int(track_points[-1][-1]) - int(track_points[0][-1]),
}
act = self.js_ctx.call("get_act", act_data, self.huyapk)
self.behavior_events.append(
{
"action": self._pointer_action("mousedown"),
"id": "",
"txt": "",
"clientX": track_points[0][0],
"clientY": track_points[0][1],
"clientWidth": 350,
"clientHeight": 268,
"isTrusted": True,
"timeStamp": random.randint(1800, 5500),
"nowTime": int(time.time() * 1000),
}
)
payload = self._build_common_payload({"act": act, "ver": 5})
return self._submit_verify(url, payload)
def _send_click_verify(self, url: str, points: list[list[int]]) -> dict:
if not points:
raise HuyaVerificationError("点选坐标识别为空")
start_time = int(time.time() * 1000) - random.randint(5000, 10000)
click_data = []
current_time = start_time
for point in points:
current_time += random.randint(1000, 2000)
click_data.append([int(point[0]), int(point[1]), current_time])
interval_time_list = [item[-1] for item in click_data]
act_data = {
"clickData": ",".join(f"{x}_{y}" for x, y in points),
"intervalTime": "_".join(map(str, interval_time_list)),
"endTime": interval_time_list[-1] - interval_time_list[0],
"sceneType": self.scene_type,
"code": self.code,
}
act = self.js_ctx.call("get_act", act_data, self.huyapk)
txt_list = ["", "1", "1\\n2"]
behavior_list = []
for index, item in enumerate(click_data):
behavior_list.append(
{
"action": self._pointer_action("mousedown"),
"id": "",
"txt": txt_list[index] if index < len(txt_list) else "",
"clientX": item[0],
"clientY": item[1],
"clientWidth": 350,
"clientHeight": 268,
"isTrusted": True,
"timeStamp": random.randint(2000, 5000),
"nowTime": item[2],
}
)
self.behavior_events.extend(behavior_list)
payload = self._build_common_payload({"act": act, "ver": 5})
return self._submit_verify(url, payload)
def _submit_verify(self, url: str, payload: dict) -> dict:
logger.debug("虎牙验证提交: {}", url)
body = json.dumps(payload, separators=(",", ":"))
result = self._request_json("post", url, params={"lock": "true"}, data=body)
data = result.get("data") or result
if isinstance(data, dict):
logger.debug(
"虎牙验证提交结果: authId={}, nextAuthUrl={}",
bool(data.get("authId")),
bool(data.get("nextAuthUrl")),
)
return data
def _solve_current_strategy(self) -> dict:
"""提交当前 strategy_url 对应的验证。"""
config_payload = self._build_common_payload({"cTuIndex": 0})
config = {}
gurl = ""
vurl = ""
dx_js = ""
for index in range(2):
logger.debug("虎牙验证获取config3 ({}/2)", index + 1)
config = self._request_json(
"post",
"https://udbrtt.huya.com/auth/client/config3",
json=config_payload,
)
raw_data = config.get("data")
data = cast(dict[str, Any], raw_data) if isinstance(raw_data, dict) else {}
gurl = str(data.get("gurl") or "")
vurl = str(data.get("vurl") or "")
dx_js = str(data.get("js") or "")
logger.debug(
"虎牙config3返回: returnCode={}, desc={}, gurl={}, vurl={}, js长度={}",
config.get("returnCode"),
config.get("description") or config.get("message") or "",
bool(gurl),
bool(vurl),
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:
break
if index == 0 and self.url_param_map.get("sceneType") != "4":
self.url_param_map["sceneType"] = "4"
self.scene_type = "4"
config_payload = self._build_common_payload({"cTuIndex": 0})
logger.debug("虎牙config3取参不完整,改用旧流程sceneType=4重试")
time.sleep(0.3)
if not gurl or not vurl or not dx_js:
data_summary = {
k: str(v)[:80] for k, v in (data or {}).items() if k != "js"
}
raise HuyaNoSessionError(
"config3未下发验证会话: "
f"returnCode={config.get('returnCode')}, "
f"description={config.get('description') or config.get('message') or ''}, "
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)
point_payload = self._build_common_payload(
{"ver": 5}, info_override=self._csid() if self.use_touch_events else None
)
last_result: dict = {}
for index in range(3):
if index:
time.sleep(0.4)
point_payload = self._build_common_payload(
{"ver": 5},
info_override=self._csid() if self.use_touch_events else None,
)
logger.info("虎牙验证失败后重试: {}/3", index + 1)
point = self._get_track_point(gurl, point_payload)
logger.debug(f"虎牙验证识别结果: {point}")
if isinstance(point, list):
last_result = self._send_click_verify(vurl, point)
else:
last_result = self._send_slider_verify(vurl, int(point))
if isinstance(last_result, dict) and (
last_result.get("authId") or last_result.get("nextAuthUrl")
):
return last_result
logger.info(
"虎牙验证提交未通过: returnCode={}, message={} ({}/3)",
last_result.get("returnCode") if isinstance(last_result, dict) else "",
last_result.get("message") if isinstance(last_result, dict) else "",
index + 1,
)
return last_result
def solve(self, challenge_payload: dict | str) -> dict:
"""提交虎牙风控验证,返回 vurl 响应中的 data。"""
self._parse_strategy(challenge_payload)
result = self._solve_current_strategy()
next_auth_url = result.get("nextAuthUrl") if isinstance(result, dict) else None
if next_auth_url:
logger.info("虎牙滑块后继续点选验证")
self._parse_strategy(next_auth_url)
result = self._solve_current_strategy()
return result
def solve_huya_verification(
challenge_payload: dict | str,
cookie: Mapping[str, str] | str | None = None,
ua: str = DEFAULT_UA,
sdid: str = "",
session: requests.Session | None = None,
proxies: Mapping[str, str] | None = None,
app_id: str = "5002",
page_url: str | None = None,
use_touch_events: bool = False,
) -> dict:
"""函数式入口,便于登录流程直接调用。"""
solver = HuyaVerificationSolver(
cookie=cookie,
ua=ua,
sdid=sdid,
session=session,
proxies=proxies,
app_id=app_id,
page_url=page_url,
use_touch_events=use_touch_events,
)
return solver.solve(challenge_payload)