""" 腾讯天御点选验证码纯协议客户端 (Tencent Turing Captcha Protocol Client) 覆盖腾讯天御 `cap_union_new` 协议族的完整纯协议闭环: 1. GET /cap_union_prehandle 获取会话 sess / PoW 题目 / 题型 / 首道题目 2. 本地求解 PoW (MD5 哈希碰撞) 3. GET 拉取当前会话动态混淆的 tdc.js, 提取会话密钥 eks 4. GET /cap_union_new_getcapbysig 下载 672x480 背景图 (img_index=1); 图标题型额外下载 img_index=0 提示图 5. 视觉求解: * 文字点选 (subcapclass 2404) -> tcaptcha.glyph_solver.TextClickSolver * 图标点选 (subcapclass 2408) -> tcaptcha.solver.CaptchaSolver (YOLO+ResNet18) 6. 生成行为遥测密文 collect (纯 Python 还原的自定义 XTEA + 贝塞尔轨迹) 7. POST /cap_union_new_verify 提交 ans/collect/eks/sess/pow_answer 8. 失败 → POST /cap_union_new_getsig 在同一会话内刷新下一道题并重试 题型识别不依赖 subcapclass 数字 (getsig 不返回该字段), 而是根据 instruction + bg_elem_cfg.click_cfg 推断, 详见 classify_challenge()。 """ import base64 import hashlib import json import logging import random import re import sys import tempfile import time from dataclasses import dataclass, field from pathlib import Path from typing import Dict, List, Optional, Sequence, Tuple import httpx from urllib.parse import urlsplit PROJECT_ROOT = Path(__file__).resolve().parent.parent if str(PROJECT_ROOT) not in sys.path: sys.path.insert(0, str(PROJECT_ROOT)) from tcaptcha.crypto import extract_eks, generate_telemetry from tcaptcha.glyph_solver import TextClickSolver, parse_instruction logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") logger = logging.getLogger("tcaptcha_client") DEFAULT_UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/155.0.0.0 Safari/537.36") # 题型 KIND_TEXT_CLICK = "text_click" # 2404 文字点选 KIND_ICON_CLICK = "icon_click" # 2408 图标点选 (需要提示图 img_index=0) KIND_SLIDER = "slider" # 2402 滑块拼图 (本客户端未实现) KIND_UNKNOWN = "unknown" @dataclass class Challenge: """一道待求解的验证码题目""" sess: str instruction: str = "" img_url: str = "" kind: str = KIND_UNKNOWN subcapclass: str = "" click_cfg: Optional[dict] = None raw: dict = field(default_factory=dict) @property def targets(self) -> List[str]: return parse_instruction(self.instruction) def classify_challenge(dyn_show_info: dict, subcapclass: str = "") -> str: """根据题目配置推断题型 (getsig 不返回 subcapclass, 只能靠结构判断)""" instruction = (dyn_show_info or {}).get("instruction", "") or "" click_cfg = ((dyn_show_info or {}).get("bg_elem_cfg") or {}).get("click_cfg") or {} data_types = click_cfg.get("data_type") or [] if "DynAnswerType_POS" in data_types or "DynAnswerType_POINT" in data_types: # 有点选坐标配置: 有目标文字 => 文字点选; 无目标文字 => 图标点选(提示走提示图) return KIND_TEXT_CLICK if parse_instruction(instruction) else KIND_ICON_CLICK if "滑块" in instruction or "拖动" in instruction or subcapclass == "2402": return KIND_SLIDER if subcapclass == "2404": return KIND_TEXT_CLICK if subcapclass == "2408": return KIND_ICON_CLICK return KIND_UNKNOWN def _site_origin(url: str) -> str: """取 `scheme://host[:port]/` —— 页内