feat(captcha): 增加全题型图片落盘、字形一致性强校验与防秒换拟人交互机制
- 题目图片自动下载: 所有题型原图统一落盘至 scratch/captcha_debug,文字点选自动生成带编号红圈标注图便于肉眼核验 - 字形严格一致性校验: 在 SolveResult 增加 text_match_ok 检查,对图字不符或混淆干扰题直接拒答换题,避免错答引发 errorCode=12 - 防秒换与拟人延时: 遇到非预期题目换题等待 2.8~4.2s (对齐 HAR 真实抓包),求解失败等待 2.5~3.8s,作答前模拟交互 5.5~7.5s - 代理容错增强: 在 OneMsAdapter 捕获代理断流与鉴权异常,支持动态轮换新代理重试
This commit is contained in:
@@ -119,9 +119,29 @@ class SolveResult:
|
||||
self.posterior = posterior
|
||||
self.assignments = assignments
|
||||
|
||||
@property
|
||||
def text_match_ok(self) -> bool:
|
||||
"""检查指派的每个字形直接 OCR 文本是否与目标汉字一致 (杜绝图字不符或混淆干扰字盲猜提交)"""
|
||||
if not self.assignments or len(self.assignments) != len(self.targets):
|
||||
return False
|
||||
for ti, g in enumerate(self.assignments):
|
||||
target_char = self.targets[ti]
|
||||
ocr_char = self.glyphs[g].ocr_text
|
||||
if ocr_char and target_char not in ocr_char:
|
||||
return False
|
||||
return True
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
return len(self.points) == len(self.targets) and len(self.points) > 0
|
||||
return (len(self.points) == len(self.targets)
|
||||
and len(self.points) > 0
|
||||
and self.text_match_ok)
|
||||
|
||||
@property
|
||||
def min_target_score(self) -> float:
|
||||
if not self.assignments or self.score_matrix is None:
|
||||
return 0.0
|
||||
return min(float(self.score_matrix[g, ti]) for ti, g in enumerate(self.assignments))
|
||||
|
||||
def describe(self) -> str:
|
||||
detail = ", ".join(
|
||||
@@ -129,7 +149,8 @@ class SolveResult:
|
||||
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}]"
|
||||
status = "ALL_MATCHED" if self.text_match_ok else "MISMATCH"
|
||||
return f"status={status} posterior={self.posterior:.3f} [{detail}]"
|
||||
|
||||
|
||||
class TextClickSolver:
|
||||
@@ -182,7 +203,31 @@ class TextClickSolver:
|
||||
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
|
||||
|
||||
def detect_glyphs_relaxed(self, image: bytes | np.ndarray | Image.Image) -> List[Glyph]:
|
||||
"""放宽阈值的备用分割, 防止汉字偏暗或面积偏小导致的漏检"""
|
||||
bgr = self._to_bgr(image)
|
||||
hsv = cv2.cvtColor(bgr, cv2.COLOR_BGR2HSV)
|
||||
mask = cv2.inRange(hsv, np.array((8, 85, 135), np.uint8), np.array((45, 255, 255), np.uint8))
|
||||
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5)))
|
||||
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] < 350:
|
||||
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.28 <= w / h <= 3.5) or w < 18 or h < 18:
|
||||
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
|
||||
|
||||
@@ -231,6 +276,8 @@ class TextClickSolver:
|
||||
|
||||
for attempt in range(retries):
|
||||
glyphs = self.detect_glyphs(image)
|
||||
if len(glyphs) < len(targets):
|
||||
glyphs = self.detect_glyphs_relaxed(image)
|
||||
if len(glyphs) < len(targets):
|
||||
logger.warning("第 %d 次: 检出字形 %d 个 < 目标 %d 个",
|
||||
attempt + 1, len(glyphs), len(targets))
|
||||
|
||||
Reference in New Issue
Block a user