"""虎牙验证码全要素数据捕获与落盘模块。 每次触发验证码(滑块 pt_auth / 点选 dx_auth)时,将图片、提示语、识别结果、 请求元数据及提交响应完整落盘保存到 data/captcha_captures/,便于分析与排查。 """ from __future__ import annotations import datetime as dt import json from pathlib import Path from typing import Any import cv2 import numpy as np from loguru import logger PROJECT_ROOT = Path(__file__).resolve().parents[3] DEFAULT_CAPTURE_DIR = PROJECT_ROOT / "data" / "captcha_captures" class CaptchaCaptureRecord: """单次验证码捕获会话。""" def __init__( self, auth_type: str, account: str = "", page_url: str = "", scene_type: str = "", output_base_dir: Path | None = None, ): self.auth_type = auth_type self.account = account self.page_url = page_url self.scene_type = scene_type self.output_base_dir = Path(output_base_dir or DEFAULT_CAPTURE_DIR) self.created_at = dt.datetime.now(dt.timezone(dt.timedelta(hours=8))) self.session_id = self.created_at.strftime("%Y%m%d_%H%M%S") self.target_dir: Path | None = None self.meta: dict[str, Any] = { "timestamp": self.created_at.isoformat(), "account": account, "auth_type": auth_type, "auth_name": ( "点选验证 (dx_auth)" if "dx" in auth_type else ( "旋转验证 (rotate_auth)" if "rotate" in auth_type or "xz" in auth_type else ( "短信二次验证 (sms_auth)" if "sms" in auth_type else ( "扫码验证 (qr_auth)" if "qr" in auth_type else "滑块验证 (pt_auth)" ) ) ) ), "page_url": page_url, "scene_type": scene_type, "prompts": {}, "images": {}, "solution": {}, "submit_result": {}, } def _ensure_target_dir(self, code: str = "") -> Path: if self.target_dir is None: short_code = code[:8] if code else "sample" dir_name = f"{self.session_id}_{self.auth_type}_{short_code}" self.target_dir = self.output_base_dir / dir_name self.target_dir.mkdir(parents=True, exist_ok=True) return self.target_dir def capture_slider_data( self, *, code: str, dtu_bytes: bytes, puzzle_bytes: bytes, restored_bg_bytes: bytes | None = None, composite_bytes: bytes | None = None, point_x: int | None = None, raw_data: dict[str, Any] | None = None, ) -> Path: """捕获滑块验证码的所有图片和元数据。""" folder = self._ensure_target_dir(code) # 1. 提示语与服务端描述 raw = raw_data or {} self.meta["code"] = code self.meta["prompts"] = { "title": "安全验证 (滑块拼图)", "default_instruction": "拖动下方滑块完成拼图", "location_y": raw.get("location_y"), "description": raw.get("description") or raw.get("message") or "", "tip": raw.get("tip") or "", } # 2. 保存原始与合成图片 images_info = {} if dtu_bytes: (folder / "bg_scrambled.jpg").write_bytes(dtu_bytes) images_info["bg_scrambled"] = "bg_scrambled.jpg" if puzzle_bytes: (folder / "puzzle.png").write_bytes(puzzle_bytes) images_info["puzzle"] = "puzzle.png" if restored_bg_bytes: (folder / "bg_restored.jpg").write_bytes(restored_bg_bytes) images_info["bg_restored"] = "bg_restored.jpg" if composite_bytes: (folder / "composite.png").write_bytes(composite_bytes) images_info["composite"] = "composite.png" # 3. 绘制识别标注图(在还原后的背景图上标注缺口 X 坐标与 Y 坐标) if restored_bg_bytes and point_x is not None: try: bg_arr = cv2.imdecode( np.frombuffer(restored_bg_bytes, np.uint8), cv2.IMREAD_COLOR ) if bg_arr is not None: loc_y = int(raw.get("location_y") or 0) cv2.rectangle( bg_arr, (point_x, loc_y), (point_x + 50, loc_y + 50), (0, 0, 255), 2, ) cv2.putText( bg_arr, f"X={point_x}", (max(0, point_x - 10), max(20, loc_y - 10)), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 255), 2, ) cv2.imwrite(str(folder / "annotated.jpg"), bg_arr) images_info["annotated"] = "annotated.jpg" except Exception as exc: # noqa: BLE001 logger.debug(f"[captcha_capture] 标注滑块图异常: {exc}") self.meta["images"] = images_info if point_x is not None: self.meta["solution"] = {"type": "slider", "distance_x": point_x} if raw_data: clean_raw = { k: v for k, v in raw_data.items() if not str(v).startswith("data:image") } (folder / "raw_data.json").write_text( json.dumps(clean_raw, ensure_ascii=False, indent=2), encoding="utf-8" ) self._flush_meta() self._log_summary() return folder def capture_rotate_data( self, *, code: str, dtu_bytes: bytes, puzzle_bytes: bytes, restored_bg_bytes: bytes | None = None, angle: float | None = None, raw_data: dict[str, Any] | None = None, ) -> Path: """捕获旋转验证码的所有图片和元数据。""" folder = self._ensure_target_dir(code) raw = raw_data or {} self.meta["code"] = code self.meta["prompts"] = { "title": "安全验证 (旋转验证 rotate_auth)", "default_instruction": "请拖动滑块,将图片旋转至正确位置", "description": raw.get("description") or raw.get("message") or "", "tip": raw.get("tip") or "", } images_info = {} if dtu_bytes: (folder / "bg_scrambled.jpg").write_bytes(dtu_bytes) images_info["bg_scrambled"] = "bg_scrambled.jpg" if puzzle_bytes: (folder / "puzzle.png").write_bytes(puzzle_bytes) images_info["puzzle"] = "puzzle.png" if restored_bg_bytes: (folder / "bg_restored.jpg").write_bytes(restored_bg_bytes) images_info["bg_restored"] = "bg_restored.jpg" # 绘制旋转合成标注图 if restored_bg_bytes and puzzle_bytes and angle is not None: try: bg = cv2.imdecode( np.frombuffer(restored_bg_bytes, np.uint8), cv2.IMREAD_COLOR ) puzzle_raw = cv2.imdecode( np.frombuffer(puzzle_bytes, np.uint8), cv2.IMREAD_UNCHANGED ) if bg is not None and puzzle_raw is not None: M = cv2.getRotationMatrix2D((90.0, 90.0), -float(angle), 1.0) rotated = cv2.warpAffine(puzzle_raw, M, (180, 180)) alpha = rotated[:, :, 3] / 255.0 composite = bg.copy() for c in range(3): composite[0:180, 105:285, c] = ( rotated[:, :, c] * alpha + composite[0:180, 105:285, c] * (1 - alpha) ).astype(np.uint8) cv2.putText( composite, f"Angle={int(angle)} deg", (15, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 255, 0), 2, ) success, buffer = cv2.imencode(".jpg", composite) if success: (folder / "annotated.jpg").write_bytes(buffer.tobytes()) images_info["annotated"] = "annotated.jpg" except Exception as exc: # noqa: BLE001 logger.debug(f"[captcha_capture] 标注旋转图异常: {exc}") self.meta["images"] = images_info if angle is not None: self.meta["solution"] = {"type": "rotate", "angle": int(angle)} if raw_data: clean_raw = { k: v for k, v in raw_data.items() if not str(v).startswith("data:image") } (folder / "raw_data.json").write_text( json.dumps(clean_raw, ensure_ascii=False, indent=2), encoding="utf-8", ) self._flush_meta() self._log_summary() return folder def capture_click_data( self, *, code: str, dtu_bytes: bytes, hzi_bytes: bytes, points: list[list[int]] | None = None, raw_data: dict[str, Any] | None = None, ) -> Path: """捕获文字/图标点选验证码的所有图片和元数据。""" folder = self._ensure_target_dir(code) raw = raw_data or {} self.meta["code"] = code question_text = str(raw.get("question") or "") if not question_text: question_text = "按顺序点击图中对应内容" word_size = raw.get("wordSize") self.meta["prompts"] = { "title": str(raw.get("title") or "安全验证 (点选验证 dx_auth)"), "question": question_text, "word_size": word_size, "tu_ming": raw.get("tuMing"), "description": raw.get("description") or raw.get("message") or "", } images_info = {} if dtu_bytes: (folder / "bg_main.jpg").write_bytes(dtu_bytes) images_info["bg_main"] = "bg_main.jpg" if hzi_bytes: (folder / "tip_text.png").write_bytes(hzi_bytes) images_info["tip_text"] = "tip_text.png" # 绘制点选标注预览图 if dtu_bytes and points: try: bg_arr = cv2.imdecode( np.frombuffer(dtu_bytes, np.uint8), cv2.IMREAD_COLOR ) if bg_arr is not None: for idx, (px, py) in enumerate(points): # 绘制点击圆圈 cv2.circle(bg_arr, (int(px), int(py)), 14, (0, 0, 255), 2) cv2.circle(bg_arr, (int(px), int(py)), 4, (0, 255, 0), -1) # 标注点击序号 1, 2, 3... cv2.putText( bg_arr, str(idx + 1), (int(px) - 5, int(py) - 18), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 0, 255), 2, ) cv2.imwrite(str(folder / "annotated.jpg"), bg_arr) images_info["annotated"] = "annotated.jpg" except Exception as exc: # noqa: BLE001 logger.debug(f"[captcha_capture] 标注点选图异常: {exc}") self.meta["images"] = images_info if points is not None: self.meta["solution"] = {"type": "click", "points": points} if raw_data: clean_raw = { k: v for k, v in raw_data.items() if not str(v).startswith("data:image") } (folder / "raw_data.json").write_text( json.dumps(clean_raw, ensure_ascii=False, indent=2), encoding="utf-8" ) self._flush_meta() self._log_summary() return folder def capture_sms_auth_data( self, *, mobile: str = "", page_url: str = "", scene_type: str = "", step: int = 1, raw_params: dict[str, Any] | None = None, ) -> Path: """捕获短信风控验证拦截数据并落盘。""" folder = self._ensure_target_dir(code="sms_auth") self.meta["auth_type"] = "sms_auth" self.meta["auth_name"] = "短信二次验证 (sms_auth)" self.meta["page_url"] = page_url self.meta["scene_type"] = scene_type self.meta["mobile"] = mobile self.meta["step"] = step self.meta["prompts"] = { "title": "安全验证 (短信二次验证 sms_auth)", "instruction": f"前置图形验证码已通过,虎牙触发第 {step} 级短信安全验证,需向手机 {mobile} 发送验证码", "mobile": mobile, } self.meta["submit_result"] = { "status": "sms_auth_required", "mobile": mobile, "nextAuthUrl": page_url, } if raw_params: (folder / "params.json").write_text( json.dumps(raw_params, ensure_ascii=False, indent=2), encoding="utf-8" ) self._flush_meta() logger.warning( f"\n┌── [虎牙风控拦截捕获] ──────────────────────────────────────────\n" f"│ 类型: 短信二次验证 (sms_auth) (sceneType: {self.scene_type or scene_type or '4'})\n" f"│ 账号: {self.account or '未指定'}\n" f"│ 阶段: 第 {step} 阶段(前置滑块/点选已成功通过)\n" f"│ 手机: {mobile or '未知'}\n" f"│ 提示: 虎牙官方下发短信接码页面,协议自动化无法跳过,需人工接码\n" f"│ 页面: {page_url[:80]}...\n" f"│ 存储目录: {self.target_dir}\n" f"└─────────────────────────────────────────────────────────────" ) return folder def capture_qr_auth_data( self, *, mobile: str = "", page_url: str = "", scene_type: str = "", step: int = 1, raw_params: dict[str, Any] | None = None, ) -> Path: """捕获扫码风控验证拦截数据并落盘。""" folder = self._ensure_target_dir(code="qr_auth") self.meta["auth_type"] = "qr_auth" self.meta["auth_name"] = "扫码二次验证 (qr_auth)" self.meta["page_url"] = page_url self.meta["scene_type"] = scene_type self.meta["mobile"] = mobile self.meta["step"] = step self.meta["prompts"] = { "title": "安全验证 (扫码二次验证 qr_auth)", "instruction": f"虎牙触发第 {step} 级扫码安全验证,需在手机 App 上确认", "mobile": mobile, } self.meta["submit_result"] = { "status": "qr_auth_required", "nextAuthUrl": page_url, } if raw_params: (folder / "params.json").write_text( json.dumps(raw_params, ensure_ascii=False, indent=2), encoding="utf-8" ) self._flush_meta() logger.warning( f"\n┌── [虎牙风控拦截捕获] ──────────────────────────────────────────\n" f"│ 类型: 扫码二次验证 (qr_auth) (sceneType: {self.scene_type or scene_type or '4'})\n" f"│ 账号: {self.account or '未指定'}\n" f"│ 阶段: 第 {step} 阶段\n" f"│ 提示: 虎牙要求在手机 App 上扫码确认或使用常用设备登录\n" f"│ 页面: {page_url[:80]}...\n" f"│ 存储目录: {self.target_dir}\n" f"└─────────────────────────────────────────────────────────────" ) return folder def record_submit_result(self, result: dict[str, Any]) -> None: """记录验证码提交验证结果(authId、是否通过、是否流转下一阶段等)。""" has_auth = bool(result.get("authId")) has_next = bool(result.get("nextAuthUrl")) status = ( "auth_success" if has_auth else ("passed_to_next_auth" if has_next else "failed") ) self.meta["submit_result"] = { "status": status, "success": has_auth or has_next, "authId": result.get("authId"), "nextAuthUrl": result.get("nextAuthUrl"), "returnCode": result.get("returnCode"), "message": result.get("message") or result.get("description") or "", } self._flush_meta() def _flush_meta(self) -> None: if self.target_dir: (self.target_dir / "meta.json").write_text( json.dumps(self.meta, ensure_ascii=False, indent=2), encoding="utf-8" ) def _log_summary(self) -> None: prompts = self.meta.get("prompts") or {} title = prompts.get("title") or "安全验证" question = ( prompts.get("question") or prompts.get("default_instruction") or "按顺序点击" ) word_size = prompts.get("word_size") word_info = f", 目标字数: {word_size}" if word_size else "" img_list = ", ".join(self.meta.get("images", {}).values()) logger.info( f"\n┌── [虎牙验证码捕获] ──────────────────────────────────────────\n" f"│ 类型: {self.meta.get('auth_name')} (sceneType: {self.scene_type or '默认'})\n" f"│ 账号: {self.account or '未指定'}\n" f"│ 标题: {title}\n" f"│ 提示语: {question}{word_info}\n" f"│ 存储目录: {self.target_dir}\n" f"│ 保存图片: {img_list}\n" f"└─────────────────────────────────────────────────────────────" )