做了什么 - 确认 collect 的加密算法就是教科书 XTEA (32 轮, delta=0x9E3779B9), 并证明 crypto.py 原有的"带加盐"写法只是把两个密钥字折叠: K' = [k0, k1+salt1, k2+salt2, k3] - tdc_worker.js 新增 captureKey 模式: 正则改写 VM 的 ADD/XOR opcode 为带日志版本, 用反向引用自动识别栈变量名 (样本=g/雨云实时=j/1ms=M, 版本无关); 匹配数不为 1 时显式报错, 不静默失败 - 密钥反推: (sum + K[j]) 是一次 ADD, 两操作数必有一个等于 r*DELTA, 另一个就是密钥字; 再对 Top-N 候选做 4! 排列 + 明文内容特征验证 - 新增 recover_payload_template / generate_collect_from_template: 运行时标定一次明文模板, 之后轨迹生成/组包/加密/编码全部在 Python - 新增 tcaptcha/proxy.py 代理提取, client 支持 --proxy-api 遇 12 自动换 IP 为什么 - 服务端只认 collect 的解密结果, 而密钥是每构建随机的; 把算法做成纯 Python 后, 只有"取密钥"依赖 Node, 抗发版能力最强 怎么验证的 - 纯 Python XTEA 加密结果与真实 tdc.js 产物前 48 字节逐字节一致 - 密钥按构建固定的证据: 同一脚本 3 次运行密文首块均为 f6a24e3e4fb5dd2b - 三份脚本 (样本/雨云实时/1ms实时) 均成功复原密钥 - 雨云 aid 端到端: 纯 Python 出包 -> errorCode=0 + 真实 ticket - 回归测试: tcaptcha/tests/test_xtea_key.py 11 项断言全过, 文字点选求解 8/8; 新增 tcaptcha/selftest.py 一键复验 已知未解 - 1ms aid=195672344 在答案正确时仍返回 errorCode=12; 已排除密钥、 遥测引擎、出口 IP (4 个住宅代理)、短时频控、静默窗口
523 lines
23 KiB
Python
523 lines
23 KiB
Python
"""
|
|
腾讯天御点选验证码纯协议客户端 (Tencent Turing Captcha Protocol Client)
|
|
|
|
覆盖腾讯天御 `cap_union_new` 协议族的完整纯协议闭环:
|
|
|
|
1. GET /cap_union_prehandle 获取会话 sess / PoW 题目 / 题型 / 首道题目
|
|
2. 本地求解 PoW (MD5 哈希碰撞)
|
|
3. GET <tdc_path> 拉取当前会话动态混淆的 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 time
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import Dict, List, Optional, Sequence, Tuple
|
|
|
|
import httpx
|
|
|
|
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
|
|
|
|
|
|
class TencentCaptchaClient:
|
|
BASE_URL = "https://turing.captcha.qcloud.com"
|
|
DEFAULT_AID = "2039519451"
|
|
|
|
def __init__(
|
|
self,
|
|
aid: str = DEFAULT_AID,
|
|
solver=None,
|
|
text_solver: Optional[TextClickSolver] = None,
|
|
timeout: float = 15.0,
|
|
user_agent: Optional[str] = None,
|
|
entry_url: str = "https://app.rainyun.com/account/reward/earn#",
|
|
referer: Optional[str] = None,
|
|
wb: str = "2",
|
|
js: str = "/tcaptcha-frame.ba387dd1.js",
|
|
accept_kinds: Sequence[str] = (KIND_TEXT_CLICK, KIND_ICON_CLICK),
|
|
min_posterior: float = 0.95,
|
|
pow_timeout: int = 4000000,
|
|
inter_attempt_delay: float = 0.25,
|
|
telemetry_mode: str = "python_keyed",
|
|
proxy: Optional[str] = None,
|
|
):
|
|
"""
|
|
telemetry_mode 决定 collect/eks 怎么来 (见 tcaptcha.crypto.generate_telemetry):
|
|
"python_keyed" (默认) Node **只**从本次构建的 tdc.js 里提取 XTEA 密钥,
|
|
collect 的加密与组装全部由纯 Python 完成 (标准 XTEA)
|
|
"python" 纯 Python + crypto.py 内置固定密钥 (无 Node 依赖, 兼容宽松 aid)
|
|
"node" 在 Node VM 沙箱里整体执行真实 tdc.js 产出 collect/eks
|
|
"browser" 在真实 Chrome 里执行真实 tdc.js (CDP 驱动, 环境最真)
|
|
"auto" / "auto_keyed" 多后端逐级回退
|
|
|
|
实测结论 (2026-10-11):
|
|
* 雨云 aid=2039519451 用 "python" 提交正确答案 → errorCode=0 + 真实 ticket,
|
|
证明服务端不校验 collect 与 eks 的同源性, 纯 Python 交付形态依然成立。
|
|
* 1ms.run aid=195672344 答题正确时, "python"/"node"/"browser" 三种遥测
|
|
在服务端风控阶段一律返回 errorCode=12 —— 与该 aid 的风控策略有关,
|
|
不是遥测引擎的问题。详见 har/1ms_dianxuan/ANALYSIS.md。
|
|
"""
|
|
self.aid = str(aid)
|
|
self.timeout = timeout
|
|
self.user_agent = user_agent or DEFAULT_UA
|
|
self.entry_url = entry_url
|
|
self.referer = referer or entry_url
|
|
self.wb = str(wb)
|
|
self.js = js
|
|
self.accept_kinds = tuple(accept_kinds)
|
|
self.min_posterior = min_posterior
|
|
self.pow_timeout = pow_timeout
|
|
# 抽题/投票之间的最小间隔, 用于规避腾讯 errorCode 12 (f_limit)
|
|
self.inter_attempt_delay = inter_attempt_delay
|
|
# 遥测(collect/eks)生成方式: node = 在沙箱里跑真实 tdc.js (推荐); python = 纯算法还原
|
|
self.telemetry_mode = telemetry_mode
|
|
# 出口代理: 腾讯天御按 IP 做滚动风控, 触发 errorCode=12 时换 IP 即可恢复
|
|
self.proxy = proxy
|
|
|
|
# 图标题型求解器 (惰性加载, 避免只跑文字题型时白加载 56MB 权重)
|
|
self._icon_solver = solver
|
|
self._text_solver = text_solver
|
|
|
|
self.http_client = httpx.Client(
|
|
base_url=self.BASE_URL,
|
|
trust_env=False, # 屏蔽环境代理干扰
|
|
proxy=self.proxy,
|
|
headers={
|
|
"User-Agent": self.user_agent,
|
|
"Referer": self.referer,
|
|
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
|
|
},
|
|
timeout=self.timeout,
|
|
follow_redirects=True,
|
|
)
|
|
|
|
# --------------------------------------------------------------- helpers
|
|
@property
|
|
def text_solver(self) -> TextClickSolver:
|
|
if self._text_solver is None:
|
|
logger.info("初始化文字点选求解器 (ddddocr + PP-OCRv4 集成)...")
|
|
self._text_solver = TextClickSolver()
|
|
return self._text_solver
|
|
|
|
@property
|
|
def icon_solver(self):
|
|
if self._icon_solver is None:
|
|
from tcaptcha.solver import CaptchaSolver
|
|
|
|
logger.info("初始化图标点选求解器 (YOLOv8 + ResNet18)...")
|
|
self._icon_solver = CaptchaSolver()
|
|
return self._icon_solver
|
|
|
|
def _solve_pow(self, prefix: str, target_md5: str) -> Tuple[str, int]:
|
|
"""求解 Proof of Work 哈希碰撞"""
|
|
t0 = time.time()
|
|
for i in range(self.pow_timeout):
|
|
candidate = f"{prefix}{i}"
|
|
if hashlib.md5(candidate.encode("utf-8")).hexdigest() == target_md5:
|
|
calc_time = max(1, round((time.time() - t0) * 1000))
|
|
return candidate, calc_time
|
|
raise RuntimeError(f"PoW MD5 collision search failed for prefix {prefix}")
|
|
|
|
# ------------------------------------------------------------- 1. prehandle
|
|
def prehandle(self, tkid: str) -> Tuple[Challenge, dict, str]:
|
|
"""GET /cap_union_prehandle —— 返回 (题目, comm_captcha_cfg, sid)"""
|
|
cb_name = f"_aq_{random.randint(100000, 999999)}"
|
|
ua_b64 = base64.b64encode(self.user_agent.encode()).decode()
|
|
params = {
|
|
"aid": self.aid,
|
|
"protocol": "https",
|
|
"accver": "1",
|
|
"showtype": "popup",
|
|
"ua": ua_b64,
|
|
"noheader": "1",
|
|
"fb": "1",
|
|
"aged": "0",
|
|
"enableAged": "0",
|
|
"enableDarkMode": "0",
|
|
"grayscale": "1",
|
|
"clientype": "2",
|
|
"cap_cd": "",
|
|
"uid": "",
|
|
"lang": "zh-cn",
|
|
"entry_url": self.entry_url,
|
|
"elder_captcha": "0",
|
|
"js": self.js,
|
|
"login_appid": "",
|
|
"wb": self.wb,
|
|
"tkid": tkid,
|
|
"subsid": str(random.randint(1, 5)),
|
|
"callback": cb_name,
|
|
"sess": "",
|
|
}
|
|
resp = self.http_client.get("/cap_union_prehandle", params=params,
|
|
headers={"Referer": self.referer})
|
|
match = re.search(rf"{re.escape(cb_name)}\((.*)\)\s*$", resp.text, re.S)
|
|
if not match:
|
|
raise RuntimeError(f"prehandle 回调解析失败: {resp.text[:200]}")
|
|
data = json.loads(match.group(1))
|
|
dyn = (data.get("data") or {}).get("dyn_show_info") or {}
|
|
sub = str(data.get("subcapclass", ""))
|
|
challenge = Challenge(
|
|
sess=data.get("sess", ""),
|
|
instruction=dyn.get("instruction", ""),
|
|
img_url=((dyn.get("bg_elem_cfg") or {}).get("img_url") or ""),
|
|
kind=classify_challenge(dyn, sub),
|
|
subcapclass=sub,
|
|
click_cfg=(dyn.get("bg_elem_cfg") or {}).get("click_cfg"),
|
|
raw=dyn,
|
|
)
|
|
return challenge, (data.get("data") or {}).get("comm_captcha_cfg", {}) or {}, data.get("sid", "")
|
|
|
|
# --------------------------------------------------------------- 2. getsig
|
|
def getsig(self, sess: str, tkid: str) -> Tuple[Challenge, str]:
|
|
"""POST /cap_union_new_getsig —— 在同一会话内刷新题目"""
|
|
resp = self.http_client.post(
|
|
"/cap_union_new_getsig",
|
|
data={"sess": sess, "tkid": tkid},
|
|
headers={"Content-Type": "application/x-www-form-urlencoded",
|
|
"Origin": "https://turing.captcha.gtimg.com",
|
|
"Referer": "https://turing.captcha.gtimg.com/"},
|
|
)
|
|
payload = resp.json()
|
|
if str(payload.get("ret")) != "0":
|
|
raise RuntimeError(f"getsig 返回异常: {payload}")
|
|
dyn = payload.get("data") or {}
|
|
new_sess = payload.get("sess") or sess
|
|
challenge = Challenge(
|
|
sess=new_sess,
|
|
instruction=dyn.get("instruction", ""),
|
|
img_url=((dyn.get("bg_elem_cfg") or {}).get("img_url") or ""),
|
|
kind=classify_challenge(dyn),
|
|
click_cfg=(dyn.get("bg_elem_cfg") or {}).get("click_cfg"),
|
|
raw=dyn,
|
|
)
|
|
return challenge, new_sess
|
|
|
|
# ------------------------------------------------------------ 3. download
|
|
def fetch_tdc(self, tdc_path: str) -> str:
|
|
resp = self.http_client.get(tdc_path,
|
|
headers={"Referer": "https://turing.captcha.gtimg.com/"})
|
|
return resp.text
|
|
|
|
def fetch_image(self, img_url: str, img_index: Optional[int] = None) -> bytes:
|
|
if img_index is not None:
|
|
img_url = re.sub(r"img_index=\d+", f"img_index={img_index}", img_url)
|
|
resp = self.http_client.get(
|
|
img_url,
|
|
headers={"Referer": "https://turing.captcha.gtimg.com/",
|
|
"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8"},
|
|
)
|
|
return resp.content
|
|
|
|
# ------------------------------------------------------------------ solve
|
|
def solve(self, max_retries: int = 3, max_refreshes: int = 6) -> Dict:
|
|
"""
|
|
端到端解题并提交验证。
|
|
|
|
返回: {success, ticket, randstr, elapsed_ms, points, kind, instruction,
|
|
raw_response, error}
|
|
"""
|
|
start_time = time.time()
|
|
tkid = str(random.randint(100000000, 999999999))
|
|
sess: Optional[str] = None
|
|
eks: Optional[str] = None
|
|
tdc_code: Optional[str] = None
|
|
pow_answer: Optional[str] = None
|
|
pow_calc_time: Optional[int] = None
|
|
comm_cfg: Dict = {}
|
|
challenge: Optional[Challenge] = None
|
|
last_raw: Dict = {}
|
|
last_error = "未完成任何一次验证提交"
|
|
|
|
attempts = 0
|
|
refreshes = 0
|
|
prehandles = 0
|
|
while attempts < max_retries and refreshes < max_refreshes:
|
|
try:
|
|
# ---- 拿题: 首选 prehandle, 之后用 getsig 在同会话内刷新
|
|
if challenge is None:
|
|
challenge, comm_cfg, _sid = self.prehandle(tkid)
|
|
prehandles += 1
|
|
logger.info("prehandle -> subcapclass=%s kind=%s instruction=%r",
|
|
challenge.subcapclass, challenge.kind, challenge.instruction)
|
|
pow_cfg = comm_cfg.get("pow_cfg") or {}
|
|
if pow_cfg.get("prefix") and pow_cfg.get("md5"):
|
|
pow_answer, pow_calc_time = self._solve_pow(pow_cfg["prefix"],
|
|
pow_cfg["md5"])
|
|
logger.info("PoW 命中 %s (%dms)", pow_answer, pow_calc_time)
|
|
tdc_code = self.fetch_tdc(comm_cfg.get("tdc_path", ""))
|
|
eks = extract_eks(tdc_code)
|
|
if not eks:
|
|
logger.warning("未从 tdc.js 中提取到 eks, 继续尝试")
|
|
else:
|
|
challenge, sess = self.getsig(sess or challenge.sess, tkid)
|
|
logger.info("getsig -> kind=%s instruction=%r",
|
|
challenge.kind, challenge.instruction)
|
|
|
|
if challenge.kind not in self.accept_kinds:
|
|
# 题型在会话内是固定的 (getsig 不会换题型), 必须重新 prehandle 抽题
|
|
logger.info("题型 %s 不在支持范围 %s, 重新 prehandle 抽题 (已抽 %d 次)",
|
|
challenge.kind, list(self.accept_kinds), prehandles)
|
|
refreshes += 1
|
|
challenge = None
|
|
sess = None
|
|
eks = None
|
|
pow_answer = None
|
|
if self.inter_attempt_delay and refreshes < max_refreshes:
|
|
time.sleep(self.inter_attempt_delay)
|
|
continue
|
|
|
|
# ---- 视觉求解
|
|
points: List[Tuple[int, int]]
|
|
if challenge.kind == KIND_TEXT_CLICK:
|
|
bg = self.fetch_image(challenge.img_url, img_index=1)
|
|
result = self.text_solver.solve_ex(bg, challenge.instruction)
|
|
logger.info("文字点选求解: %s", result.describe())
|
|
if not result.ok:
|
|
logger.warning("文字点选求解失败, 刷新题目")
|
|
refreshes += 1
|
|
continue
|
|
if result.posterior < self.min_posterior:
|
|
logger.warning("求解置信度过低 (%.3f < %.2f), 不提交, 刷新题目",
|
|
result.posterior, self.min_posterior)
|
|
refreshes += 1
|
|
continue
|
|
points = result.points
|
|
else: # KIND_ICON_CLICK
|
|
bg = self.fetch_image(challenge.img_url, img_index=1)
|
|
prompt = self.fetch_image(challenge.img_url, img_index=0)
|
|
if not prompt:
|
|
logger.warning("图标题型未取到提示图, 刷新题目")
|
|
refreshes += 1
|
|
continue
|
|
points = self.icon_solver.solve(bg, prompt)
|
|
if len(points) < 3:
|
|
logger.warning("图标点选求解失败, 刷新题目")
|
|
refreshes += 1
|
|
continue
|
|
|
|
# ---- 组包并提交
|
|
attempts += 1
|
|
ans_list = [{"elem_id": i + 1, "type": "DynAnswerType_POS",
|
|
"data": f"{p[0]},{p[1]}"} for i, p in enumerate(points)]
|
|
ans_str = json.dumps(ans_list, separators=(",", ":"))
|
|
telemetry = generate_telemetry(
|
|
tdc_code or "", points, aid=self.aid, user_agent=self.user_agent,
|
|
referrer=self.referer, mode=self.telemetry_mode,
|
|
)
|
|
logger.info("遥测密文来源=%s collect_len=%s eks_len=%s",
|
|
telemetry.get("source"), len(telemetry["collect"]),
|
|
len(telemetry.get("eks", "")))
|
|
verify_body = {
|
|
"ans": ans_str,
|
|
"collect": telemetry["collect"],
|
|
"tlg": telemetry["tlg"],
|
|
"eks": telemetry.get("eks") or eks or "",
|
|
"sess": challenge.sess,
|
|
"tkid": tkid,
|
|
"pow_answer": pow_answer or "",
|
|
"pow_calc_time": str(pow_calc_time or 1),
|
|
}
|
|
verify_resp = self.http_client.post(
|
|
"/cap_union_new_verify",
|
|
data=verify_body,
|
|
headers={
|
|
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
|
|
"Accept": "application/json, text/javascript, */*; q=0.01",
|
|
"Origin": "https://turing.captcha.gtimg.com",
|
|
"Referer": "https://turing.captcha.gtimg.com/",
|
|
},
|
|
)
|
|
v_json = verify_resp.json()
|
|
last_raw = v_json
|
|
elapsed = int((time.time() - start_time) * 1000)
|
|
|
|
if str(v_json.get("errorCode")) == "0":
|
|
logger.info("✅ 验证通过! randstr=%s", v_json.get("randstr"))
|
|
return {
|
|
"success": True,
|
|
"ticket": v_json.get("ticket", ""),
|
|
"randstr": v_json.get("randstr", ""),
|
|
"elapsed_ms": elapsed,
|
|
"points": points,
|
|
"kind": challenge.kind,
|
|
"instruction": challenge.instruction,
|
|
"raw_response": v_json,
|
|
"error": "",
|
|
}
|
|
|
|
err_code = str(v_json.get("errorCode"))
|
|
if err_code == "12":
|
|
last_error = ("触发腾讯接口频控限制 (errorCode: 12, f_limit), "
|
|
"建议降低请求频率或更换出口 IP")
|
|
logger.error(last_error)
|
|
break
|
|
if err_code in ("50", "9"):
|
|
last_error = f"坐标未命中 (errorCode: {err_code})"
|
|
else:
|
|
last_error = (f"验证失败 (errorCode: {err_code}, "
|
|
f"msg: {v_json.get('errMessage', '')})")
|
|
logger.warning(last_error)
|
|
|
|
# 服务端返回新 sess 时沿用, 否则回到 prehandle
|
|
new_sess = v_json.get("sess")
|
|
if new_sess:
|
|
challenge, sess = self.getsig(new_sess, tkid)
|
|
logger.info("verify 后刷新 -> kind=%s instruction=%r",
|
|
challenge.kind, challenge.instruction)
|
|
else:
|
|
challenge = None
|
|
sess = None
|
|
eks = None
|
|
pow_answer = None
|
|
except Exception as exc: # 网络/解析异常: 丢弃当前会话重来
|
|
last_error = f"请求异常: {exc}"
|
|
logger.error("第 %d 次尝试异常: %s", attempts + 1, exc)
|
|
challenge = None
|
|
sess = None
|
|
eks = None
|
|
pow_answer = None
|
|
|
|
elapsed = int((time.time() - start_time) * 1000)
|
|
return {
|
|
"success": False,
|
|
"ticket": "",
|
|
"randstr": "",
|
|
"elapsed_ms": elapsed,
|
|
"points": [],
|
|
"kind": challenge.kind if challenge else "",
|
|
"instruction": challenge.instruction if challenge else "",
|
|
"raw_response": last_raw,
|
|
"error": last_error,
|
|
}
|
|
|
|
def close(self):
|
|
self.http_client.close()
|
|
|
|
|
|
def main():
|
|
import argparse
|
|
|
|
parser = argparse.ArgumentParser(description="Tencent Captcha Pure Protocol Solver Client")
|
|
parser.add_argument("--aid", default="195672344", help="验证码 AppID (默认毫秒 1ms.run)")
|
|
parser.add_argument("--entry-url",
|
|
default="https://1ms.run/console/personal/benefits/check-in")
|
|
parser.add_argument("--wb", default="1")
|
|
parser.add_argument("--retries", type=int, default=3)
|
|
parser.add_argument("--telemetry", default="python",
|
|
choices=["python", "node", "browser", "auto"])
|
|
parser.add_argument("--proxy", default=None, help="出口代理, 如 http://1.2.3.4:8080")
|
|
parser.add_argument("--proxy-api", default=None,
|
|
help="代理提取 API; 出现 errorCode=12 时自动换 IP 重试")
|
|
args = parser.parse_args()
|
|
|
|
proxy = args.proxy
|
|
if not proxy and args.proxy_api:
|
|
from tcaptcha.proxy import fetch_proxy
|
|
|
|
proxy = fetch_proxy(args.proxy_api)
|
|
print(f"[*] 从代理 API 提取到: {proxy}")
|
|
|
|
client = TencentCaptchaClient(aid=args.aid, entry_url=args.entry_url,
|
|
referer="https://1ms.run/", wb=args.wb,
|
|
proxy=proxy, telemetry_mode=args.telemetry)
|
|
print(f"[*] 正在发起腾讯天御验证码纯协议识别 (AppID: {args.aid}, proxy={proxy})...")
|
|
result = client.solve(max_retries=args.retries)
|
|
|
|
# errorCode=12 (f_limit) 是 IP 级滚动风控: 换一个出口 IP 再试一次
|
|
if not result["success"] and "12" in str(result.get("error", "")) and args.proxy_api:
|
|
print("[*] 触发 f_limit, 换 IP 重试...")
|
|
client.close()
|
|
proxy = fetch_proxy(args.proxy_api)
|
|
print(f"[*] 新代理: {proxy}")
|
|
client = TencentCaptchaClient(aid=args.aid, entry_url=args.entry_url,
|
|
referer="https://1ms.run/", wb=args.wb,
|
|
proxy=proxy, telemetry_mode=args.telemetry)
|
|
result = client.solve(max_retries=args.retries)
|
|
|
|
if result["success"]:
|
|
print("\n" + "=" * 50)
|
|
print(" 🎉 验证码纯协议求解成功!")
|
|
print("=" * 50)
|
|
print(f"耗时: {result['elapsed_ms']} ms")
|
|
print(f"题型: {result['kind']} 题目: {result['instruction']!r}")
|
|
print(f"点击坐标: {result['points']}")
|
|
print(f"randstr: {result['randstr']}")
|
|
print(f"ticket: {result['ticket']}")
|
|
print("=" * 50)
|
|
else:
|
|
print(f"\n❌ 验证失败: {result['error']}")
|
|
sys.exit(1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|