做了什么 - 确认 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 个住宅代理)、短时频控、静默窗口
322 lines
14 KiB
Python
322 lines
14 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""
|
||
腾讯天御『文字点选』验证码求解器 (Tencent Turing Text-Click Solver)
|
||
|
||
适用题型: subcapclass = 2404, click_cfg.data_type = ["DynAnswerType_POS"] 且提示为
|
||
纯文字指令, 例如: "请依次点击:虫 呈 般"
|
||
|
||
与 tcaptcha.solver.CaptchaSolver (图标点选 2408: 提示是 img_index=0 的图标图) 的区别:
|
||
* 本题型 **没有提示图** (img_index=0 返回 0 字节)
|
||
* 背景图里只出现与提示完全相同的 3 个汉字 (随机位置), 不含额外干扰字
|
||
* 部分题目会做 **斜切(italic)变形**, 但实测不影响颜色分割
|
||
|
||
算法管线:
|
||
1. HSV 颜色分割 (腾讯点选汉字固定为橙黄色, H≈20 S≈185 V≈228) 提取字形掩膜
|
||
2. 形态学闭运算 + 膨胀连通同一汉字的多个偏旁, 连通域得到汉字包围盒
|
||
3. 掩膜 -> 白底黑字 -> 放大, 交给 OCR 集成(ddddocr + PP-OCRv4)做 CTC 前向
|
||
4. 取 CTC 每一时间步上目标字的概率最大值 -> (字形, 目标字) 相似度矩阵
|
||
5. 对 3! 个全排列求最优指派, 并用归一化后验概率给出本次求解的置信度
|
||
(置信度低的题目直接刷新会话, 避免提交明显不可靠的答案)
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import io
|
||
import itertools
|
||
import logging
|
||
import re
|
||
from typing import Dict, List, Optional, Sequence, Tuple
|
||
|
||
import cv2
|
||
import numpy as np
|
||
from PIL import Image
|
||
|
||
from .ocr_engines import EnsembleOcr
|
||
|
||
logger = logging.getLogger("tcaptcha_glyph")
|
||
|
||
# 腾讯点选汉字颜色 (实测 H≈20.5 S≈200 V≈232)。
|
||
# 关键是 **V(亮度)** 而不是色相: 暖色草地/沙地的 H 会落在 24~28、S 也不低,
|
||
# 但 V 只有 120~156, 而汉字 V≈230。早期版本用 V>=145 会把草地并进字形掩膜,
|
||
# 导致 3 个字被背景吞成 1~2 个候选 (见图 proxy/fail_2、fail_3)。
|
||
HSV_LOWER = (12, 145, 190)
|
||
HSV_UPPER = (34, 255, 255)
|
||
|
||
MIN_GLYPH_AREA = 700 # 单个汉字掩膜最小像素数
|
||
MERGE_KERNEL = 13 # 膨胀核, 用于把偏旁/离散笔画合并为同一汉字
|
||
SCALE = 3 # OCR 输入放大倍数
|
||
PAD = 24 # OCR 输入白边
|
||
|
||
_CJK_RE = re.compile(r"[\u4e00-\u9fff]")
|
||
# 滑块/图标题型的模板串关键词: 出现即说明提示里没有目标汉字
|
||
_NON_TARGET_HINTS = ("滑块", "拖动", "拼图", "请依次", "点击", "完成")
|
||
|
||
|
||
def parse_instruction(instruction: str) -> List[str]:
|
||
"""从 '请依次点击:虫 呈 般' 中解析出目标汉字序列 ['虫','呈','般']
|
||
|
||
注意: 图标点选 (2408) 与滑块 (2402/2401) 的 instruction 只有 '请依次点击:' /
|
||
'拖动下方滑块完成拼图' 这类无目标文字的模板串, 此时必须返回空列表, 否则会把
|
||
'请依次点击' 本身当成目标字。
|
||
"""
|
||
if not instruction:
|
||
return []
|
||
parts = re.split(r"[::]", instruction, maxsplit=1)
|
||
if len(parts) == 2:
|
||
return _CJK_RE.findall(parts[1])
|
||
# 没有分隔符: 只有当整体就是一个短目标字序列 (如 '虫 呈 般') 时才接受
|
||
if any(h in instruction for h in _NON_TARGET_HINTS):
|
||
return []
|
||
chars = _CJK_RE.findall(instruction)
|
||
# 文字点选恒为 3~4 个目标字, 超过该数量必然是模板句
|
||
return chars if 0 < len(chars) <= 4 else []
|
||
|
||
|
||
class Glyph:
|
||
"""一个已定位的候选汉字字形"""
|
||
|
||
__slots__ = ("x0", "y0", "x1", "y1", "cx", "cy", "mask", "ocr_text", "confidence")
|
||
|
||
def __init__(self, x0: int, y0: int, x1: int, y1: int, mask: np.ndarray):
|
||
self.x0, self.y0, self.x1, self.y1 = x0, y0, x1, y1
|
||
self.cx = (x0 + x1) // 2
|
||
self.cy = (y0 + y1) // 2
|
||
self.mask = mask
|
||
self.ocr_text = ""
|
||
self.confidence = 0.0
|
||
|
||
@property
|
||
def point(self) -> Tuple[int, int]:
|
||
return self.cx, self.cy
|
||
|
||
def gray_image(self, thick: int = 0, scale: int = SCALE) -> np.ndarray:
|
||
"""掩膜 -> 白底黑字灰度图 -> 放大 (thick>0 时加粗笔画, 用于多尺度集成)"""
|
||
m = self.mask
|
||
if thick:
|
||
m = cv2.dilate(m, np.ones((3, 3), np.uint8), iterations=thick)
|
||
gray = 255 - m
|
||
h, w = gray.shape
|
||
side = max(h, w) + PAD
|
||
canvas = np.full((side, side), 255, np.uint8)
|
||
canvas[(side - h) // 2:(side - h) // 2 + h, (side - w) // 2:(side - w) // 2 + w] = gray
|
||
return cv2.resize(canvas, (side * scale, side * scale), interpolation=cv2.INTER_LINEAR)
|
||
|
||
# 兼容旧接口
|
||
def ocr_image(self, thick: int = 0, scale: int = SCALE) -> Image.Image:
|
||
return Image.fromarray(self.gray_image(thick=thick, scale=scale))
|
||
|
||
|
||
class SolveResult:
|
||
"""一次求解结果"""
|
||
|
||
__slots__ = ("points", "targets", "glyphs", "score_matrix", "posterior", "assignments")
|
||
|
||
def __init__(self, points, targets, glyphs, score_matrix, posterior, assignments):
|
||
self.points = points
|
||
self.targets = targets
|
||
self.glyphs = glyphs
|
||
self.score_matrix = score_matrix
|
||
self.posterior = posterior
|
||
self.assignments = assignments
|
||
|
||
@property
|
||
def ok(self) -> bool:
|
||
return len(self.points) == len(self.targets) and len(self.points) > 0
|
||
|
||
def describe(self) -> str:
|
||
detail = ", ".join(
|
||
f"{t}<-{self.glyphs[g].ocr_text or '?'}@{self.glyphs[g].point}"
|
||
f"({self.score_matrix[g, ti]:.3f})"
|
||
for ti, (t, g) in enumerate(zip(self.targets, self.assignments))
|
||
)
|
||
return f"posterior={self.posterior:.3f} [{detail}]"
|
||
|
||
|
||
class TextClickSolver:
|
||
"""腾讯天御文字点选求解器 (颜色分割 + OCR 集成概率指派)"""
|
||
|
||
def __init__(self, recognizer: Optional[EnsembleOcr] = None, min_area: int = MIN_GLYPH_AREA,
|
||
variants: Sequence[Tuple[int, int]] = ((0, SCALE), (1, SCALE)),
|
||
min_glyph_score: float = 0.02):
|
||
self.min_area = min_area
|
||
self.variants = tuple(variants)
|
||
# 颜色分割会在照片的暖色区域误检出少量"伪字形", 用识别得分把它们剔除
|
||
self.min_glyph_score = min_glyph_score
|
||
self._recognizer = recognizer
|
||
|
||
@property
|
||
def recognizer(self) -> EnsembleOcr:
|
||
if self._recognizer is None:
|
||
self._recognizer = EnsembleOcr.default()
|
||
return self._recognizer
|
||
|
||
# ------------------------------------------------------------- detection
|
||
@staticmethod
|
||
def _mask_of(bgr: np.ndarray) -> np.ndarray:
|
||
hsv = cv2.cvtColor(bgr, cv2.COLOR_BGR2HSV)
|
||
mask = cv2.inRange(hsv, np.array(HSV_LOWER, np.uint8), np.array(HSV_UPPER, np.uint8))
|
||
# 闭运算填掉字内高光空洞
|
||
return cv2.morphologyEx(mask, cv2.MORPH_CLOSE,
|
||
cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5)))
|
||
|
||
def detect_glyphs(self, image: bytes | np.ndarray | Image.Image) -> List[Glyph]:
|
||
"""颜色分割定位背景图中的全部汉字"""
|
||
bgr = self._to_bgr(image)
|
||
mask = self._mask_of(bgr)
|
||
dilated = cv2.dilate(mask, cv2.getStructuringElement(cv2.MORPH_ELLIPSE,
|
||
(MERGE_KERNEL, MERGE_KERNEL)))
|
||
count, labels, stats, _ = cv2.connectedComponentsWithStats(dilated)
|
||
|
||
glyphs: List[Glyph] = []
|
||
for i in range(1, count):
|
||
if stats[i, cv2.CC_STAT_AREA] < self.min_area:
|
||
continue
|
||
sub = ((labels == i) & (mask > 0))
|
||
ys, xs = np.nonzero(sub)
|
||
if len(xs) == 0:
|
||
continue
|
||
x0, y0, x1, y1 = int(xs.min()), int(ys.min()), int(xs.max()), int(ys.max())
|
||
w, h = x1 - x0 + 1, y1 - y0 + 1
|
||
# 单字包围盒合理性: 过扁/过瘦/过小的多半是背景误检
|
||
if not (0.35 <= w / h <= 2.8) or w < 24 or h < 24:
|
||
continue
|
||
glyphs.append(Glyph(x0, y0, x1, y1,
|
||
(sub[y0:y1 + 1, x0:x1 + 1] * 255).astype(np.uint8)))
|
||
# 阅读顺序排序 (先上后下, 再左后右) —— 仅用于结果稳定
|
||
glyphs.sort(key=lambda g: (g.y0 // 64, g.x0))
|
||
return glyphs
|
||
|
||
# ---------------------------------------------------------- recognition
|
||
def char_scores(self, glyph: Glyph, targets: Sequence[str]) -> Dict[str, float]:
|
||
"""对单个字形, 返回每个目标字的集成概率 (多预处理变体取最大)"""
|
||
acc = {t: 0.0 for t in targets}
|
||
best_text, best_conf = "", 0.0
|
||
for thick, scale in self.variants:
|
||
gray = glyph.gray_image(thick=thick, scale=scale)
|
||
scores = self.recognizer.scores(gray, targets)
|
||
for t, v in scores.items():
|
||
acc[t] = max(acc[t], v)
|
||
# 用集成里最强单引擎的读数做可读性展示
|
||
try:
|
||
engine = self.recognizer.engines[-1]
|
||
chars, probs = engine.probabilities(glyph.gray_image())
|
||
idx = probs.argmax(axis=1)
|
||
seq, prev = [], -1
|
||
for i in idx:
|
||
if i != 0 and i != prev and i < len(chars):
|
||
seq.append(chars[i])
|
||
prev = i
|
||
best_text = "".join(seq)
|
||
best_conf = float(probs.max(axis=1).mean())
|
||
except Exception: # 展示信息失败不影响求解
|
||
pass
|
||
glyph.ocr_text, glyph.confidence = best_text, best_conf
|
||
return acc
|
||
|
||
# ----------------------------------------------------------------- solve
|
||
def solve(self, image: bytes | np.ndarray | Image.Image, instruction: str,
|
||
retries: int = 1) -> List[Tuple[int, int]]:
|
||
"""返回提示顺序的像素点击坐标; 失败返回空列表 (兼容旧接口)"""
|
||
res = self.solve_ex(image, instruction, retries=retries)
|
||
return res.points if res.ok else []
|
||
|
||
def solve_ex(self, image: bytes | np.ndarray | Image.Image, instruction: str,
|
||
retries: int = 1) -> SolveResult:
|
||
"""完整求解, 附带置信度(归一化后验概率)与命中明细"""
|
||
targets = parse_instruction(instruction)
|
||
empty = SolveResult([], targets, [], np.zeros((0, 0)), 0.0, [])
|
||
if len(targets) < 2:
|
||
logger.warning("提示语解析失败: %r", instruction)
|
||
return empty
|
||
|
||
for attempt in range(retries):
|
||
glyphs = self.detect_glyphs(image)
|
||
if len(glyphs) < len(targets):
|
||
logger.warning("第 %d 次: 检出字形 %d 个 < 目标 %d 个",
|
||
attempt + 1, len(glyphs), len(targets))
|
||
if attempt + 1 < retries:
|
||
continue
|
||
return empty
|
||
if len(glyphs) > len(targets):
|
||
logger.info("检出字形 %d 个 (目标 %d 个), 进行全局指派", len(glyphs), len(targets))
|
||
|
||
matrix = np.array([[self.char_scores(g, targets)[t] for t in targets]
|
||
for g in glyphs], dtype=np.float64) # (n, m)
|
||
|
||
# 剔除伪字形: 任何目标字都识别不出来的候选不参与指派 (不影响后验置信度)
|
||
row_max = matrix.max(axis=1) if matrix.size else np.zeros(0)
|
||
strong = [i for i in range(len(glyphs)) if row_max[i] >= self.min_glyph_score]
|
||
if len(strong) < len(targets):
|
||
# 得分整体偏低(生僻字/罕见字形)时退化为取得分最高的 m 个
|
||
order = np.argsort(-row_max)[:len(targets)]
|
||
strong = sorted(int(i) for i in order)
|
||
logger.info("可用强字形不足, 退化为得分 Top-%d 候选", len(targets))
|
||
dropped = [i for i in range(len(glyphs)) if i not in strong]
|
||
if dropped:
|
||
logger.info("剔除伪字形 %s", [glyphs[i].point for i in dropped])
|
||
glyphs = [glyphs[i] for i in strong]
|
||
matrix = matrix[strong]
|
||
|
||
# 每个字形对目标字的归一化分布 (尺度无关), 避免某个字形整体概率偏低时被忽略
|
||
eps = 1e-9
|
||
row_sums = matrix.sum(axis=1, keepdims=True)
|
||
norm = (matrix + eps) / (row_sums + eps * matrix.shape[1])
|
||
|
||
best_perm, best_score, second_score = None, -1.0, 0.0
|
||
perms = list(itertools.permutations(range(len(glyphs)), len(targets)))
|
||
probs = []
|
||
for perm in perms:
|
||
p = 1.0
|
||
for t_idx, g_idx in enumerate(perm):
|
||
p *= float(norm[g_idx, t_idx])
|
||
probs.append(p)
|
||
if p > best_score:
|
||
second_score = best_score
|
||
best_score, best_perm = p, perm
|
||
elif p > second_score:
|
||
second_score = p
|
||
total = float(sum(probs)) or 1.0
|
||
posterior = float(best_score / total)
|
||
|
||
if best_perm is None:
|
||
return empty
|
||
logger.info("题目 %s -> %s", "".join(targets),
|
||
", ".join(f"{targets[t]}<-{glyphs[g].ocr_text or '?'}@{glyphs[g].point}"
|
||
f"({matrix[g, t]:.4f})" for t, g in enumerate(best_perm)))
|
||
logger.info(" 后验置信度=%.4f (次优/最优=%.4f)", posterior,
|
||
(second_score / best_score) if best_score > 0 else 1.0)
|
||
|
||
result = SolveResult([glyphs[g].point for g in best_perm], targets, glyphs,
|
||
matrix, posterior, list(best_perm))
|
||
if best_score <= 0:
|
||
logger.warning("所有候选得分均为 0, 判定求解失败")
|
||
return SolveResult([], targets, glyphs, matrix, 0.0, list(best_perm))
|
||
return result
|
||
return empty
|
||
|
||
# ------------------------------------------------------------- utilities
|
||
@staticmethod
|
||
def _to_bgr(image: bytes | np.ndarray | Image.Image) -> np.ndarray:
|
||
if isinstance(image, np.ndarray):
|
||
return image
|
||
if isinstance(image, Image.Image):
|
||
return cv2.cvtColor(np.array(image.convert("RGB")), cv2.COLOR_RGB2BGR)
|
||
arr = np.frombuffer(image, np.uint8)
|
||
bgr = cv2.imdecode(arr, cv2.IMREAD_COLOR)
|
||
if bgr is None:
|
||
bgr = cv2.cvtColor(np.array(Image.open(io.BytesIO(image)).convert("RGB")),
|
||
cv2.COLOR_RGB2BGR)
|
||
return bgr
|
||
|
||
|
||
if __name__ == "__main__":
|
||
import sys
|
||
|
||
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||
solver = TextClickSolver()
|
||
img_path = sys.argv[1] if len(sys.argv) > 1 else "har/1ms_dianxuan/bg_3.jpg"
|
||
instr = sys.argv[2] if len(sys.argv) > 2 else "请依次点击:虫 呈 般"
|
||
res = solver.solve_ex(open(img_path, "rb").read(), instr)
|
||
print("points:", res.points)
|
||
print("confidence:", res.posterior)
|