迁移虎牙验证码核心逻辑
This commit is contained in:
@@ -0,0 +1,388 @@
|
||||
"""虎牙登录风控验证闭环。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import io
|
||||
import json
|
||||
import random
|
||||
import time
|
||||
from collections.abc import Mapping
|
||||
from http.cookies import SimpleCookie
|
||||
from urllib.parse import parse_qs, quote, urlparse
|
||||
|
||||
import cv2
|
||||
import execjs
|
||||
import numpy as np
|
||||
import requests
|
||||
from PIL import Image
|
||||
from loguru import logger
|
||||
|
||||
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):
|
||||
"""虎牙风控验证失败。"""
|
||||
|
||||
|
||||
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),
|
||||
):
|
||||
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 = None
|
||||
|
||||
@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)
|
||||
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)
|
||||
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) -> dict:
|
||||
payload = {
|
||||
"urlParamMap": self.url_param_map,
|
||||
"wupData": "",
|
||||
"page": self._auth_page_url(),
|
||||
"behavior": "%5B%5D",
|
||||
"info": self.sdid,
|
||||
}
|
||||
payload.update(extra)
|
||||
return {"appId": "5002", "data": payload}
|
||||
|
||||
@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")
|
||||
|
||||
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):
|
||||
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:
|
||||
return int((opencv_x + yolo_x) / 2)
|
||||
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)
|
||||
behavior = quote(
|
||||
json.dumps([{
|
||||
"action": "mousedown",
|
||||
"id": "",
|
||||
"txt": "",
|
||||
"clientX": track_points[0][0],
|
||||
"clientY": track_points[0][1],
|
||||
"clientWidth": 350,
|
||||
"clientHeight": 268,
|
||||
"isTrusted": True,
|
||||
"timeStamp": 1829,
|
||||
"nowTime": int(time.time() * 1000),
|
||||
}], separators=(",", ":")),
|
||||
safe="~()*!.'",
|
||||
)
|
||||
payload = self._build_common_payload({"behavior": behavior, "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": "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],
|
||||
})
|
||||
|
||||
behavior = quote(json.dumps(behavior_list, separators=(",", ":")), safe="~()*!.'")
|
||||
payload = self._build_common_payload({"behavior": behavior, "act": act, "ver": 5})
|
||||
return self._submit_verify(url, payload)
|
||||
|
||||
def _submit_verify(self, url: str, payload: dict) -> dict:
|
||||
body = json.dumps(payload, separators=(",", ":"))
|
||||
result = self._request_json("post", url, params={"lock": "true"}, data=body)
|
||||
return result.get("data") or result
|
||||
|
||||
def _solve_current_strategy(self) -> dict:
|
||||
"""提交当前 strategy_url 对应的验证。"""
|
||||
config_payload = self._build_common_payload({"cTuIndex": 0})
|
||||
config = self._request_json("post", "https://udbrtt.huya.com/auth/client/config3", json=config_payload)
|
||||
try:
|
||||
gurl = config["data"]["gurl"]
|
||||
vurl = config["data"]["vurl"]
|
||||
dx_js = config["data"]["js"]
|
||||
except KeyError as exc:
|
||||
raise HuyaVerificationError(f"config3 取参失败: {config}") from exc
|
||||
|
||||
self._compile_js(dx_js)
|
||||
point = self._get_track_point(gurl, config_payload)
|
||||
logger.debug(f"虎牙验证识别结果: {point}")
|
||||
if isinstance(point, list):
|
||||
return self._send_click_verify(vurl, point)
|
||||
return self._send_slider_verify(vurl, int(point))
|
||||
|
||||
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,
|
||||
) -> dict:
|
||||
"""函数式入口,便于登录流程直接调用。"""
|
||||
solver = HuyaVerificationSolver(
|
||||
cookie=cookie,
|
||||
ua=ua,
|
||||
sdid=sdid,
|
||||
session=session,
|
||||
proxies=proxies,
|
||||
)
|
||||
return solver.solve(challenge_payload)
|
||||
Reference in New Issue
Block a user