captchakraken 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +54 -0
- package/dist/index.d.ts +56 -0
- package/dist/index.js +43 -0
- package/dist/playwright-types.d.ts +119 -0
- package/dist/playwright-types.js +25 -0
- package/dist/puppeteer-adapter.d.ts +70 -0
- package/dist/puppeteer-adapter.js +96 -0
- package/dist/solver.d.ts +264 -0
- package/dist/solver.js +1922 -0
- package/dist/token-usage.d.ts +15 -0
- package/dist/token-usage.js +102 -0
- package/dist/types.d.ts +248 -0
- package/dist/types.js +2 -0
- package/package.json +49 -0
- package/python/Dockerfile +41 -0
- package/python/README.md +71 -0
- package/python/examples/README.md +68 -0
- package/python/examples/_harness.py +158 -0
- package/python/examples/demoHcaptcha.py +20 -0
- package/python/examples/demoRecaptcha.py +17 -0
- package/python/pyproject.toml +79 -0
- package/python/src/captchakraken/__init__.py +61 -0
- package/python/src/captchakraken/action_types.py +56 -0
- package/python/src/captchakraken/cli.py +656 -0
- package/python/src/captchakraken/config.py +78 -0
- package/python/src/captchakraken/image_processor.py +244 -0
- package/python/src/captchakraken/overlay.py +520 -0
- package/python/src/captchakraken/planner.py +408 -0
- package/python/src/captchakraken/planner_types.py +74 -0
- package/python/src/captchakraken/server_manager.py +290 -0
- package/python/src/captchakraken/solver.py +434 -0
- package/python/src/captchakraken/timing.py +42 -0
- package/python/src/captchakraken/tool_calls/find_checkbox.py +72 -0
- package/python/src/captchakraken/tool_calls/find_grid.py +1762 -0
- package/python/src/captchakraken/tool_calls/move_indicator.py +431 -0
- package/scripts/copy-python.mjs +29 -0
- package/scripts/setup-python.js +104 -0
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CaptchaSolver (v2) — vLLM-backed.
|
|
3
|
+
|
|
4
|
+
Flow:
|
|
5
|
+
1. find_grid → if a grid is detected, draw the numbered overlay, ask the
|
|
6
|
+
`captcha` LoRA which cells to click, return ClickActions with
|
|
7
|
+
per-tile bounding boxes.
|
|
8
|
+
2. find_checkbox on small images → if a lone checkbox is detected, return a
|
|
9
|
+
ClickAction targeting it directly.
|
|
10
|
+
3. Otherwise (click / drag / other still-image puzzle) → route the image to
|
|
11
|
+
the full-puzzle pixel/action path (planner.get_pixel_actions), which the
|
|
12
|
+
LoRA is trained on. Only raise UnsupportedCaptchaError if the model returns
|
|
13
|
+
nothing usable. Video challenges are filtered out upstream by the caller.
|
|
14
|
+
|
|
15
|
+
v1 had a SAM3-backed tool-using planner with detect/segment/drag-refine; it
|
|
16
|
+
lives on the `v1-old-architecture` branch.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import math
|
|
20
|
+
import os
|
|
21
|
+
import shutil
|
|
22
|
+
import sys
|
|
23
|
+
import tempfile
|
|
24
|
+
from datetime import datetime
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any, List, Optional, Tuple, Union
|
|
27
|
+
|
|
28
|
+
from PIL import Image
|
|
29
|
+
|
|
30
|
+
from .action_types import (
|
|
31
|
+
CaptchaAction,
|
|
32
|
+
ClickAction,
|
|
33
|
+
DoneAction,
|
|
34
|
+
DragAction,
|
|
35
|
+
WaitAction,
|
|
36
|
+
)
|
|
37
|
+
from .image_processor import ImageProcessor
|
|
38
|
+
from .planner import ActionPlanner
|
|
39
|
+
from .timing import timed
|
|
40
|
+
from .tool_calls.find_checkbox import find_checkbox
|
|
41
|
+
from .tool_calls.find_grid import (
|
|
42
|
+
detect_selected_cells, find_grid, get_numbered_grid_overlay,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
DEBUG = os.getenv("CAPTCHA_DEBUG", "0") == "1"
|
|
46
|
+
|
|
47
|
+
# Half-size (0–1 fraction) of the click/drag target box built around each point
|
|
48
|
+
# the model returns. The TS solver clicks the box center, so this only sets how
|
|
49
|
+
# much positional slack executeClick has; ~1.2% ≈ ±6px on a 512px challenge.
|
|
50
|
+
_PIXEL_BOX_HALF = float(os.getenv("CAPTCHA_PIXEL_BOX_HALF", "0.012"))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class UnsupportedCaptchaError(Exception):
|
|
54
|
+
"""Raised when the captcha is neither a supported grid nor a checkbox."""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class DebugManager:
|
|
58
|
+
"""Writes per-run artifacts under `latestDebugRun/` when CAPTCHA_DEBUG=1."""
|
|
59
|
+
|
|
60
|
+
def __init__(self, debug_enabled: bool):
|
|
61
|
+
self.enabled = debug_enabled
|
|
62
|
+
self.base_dir = Path("latestDebugRun").resolve()
|
|
63
|
+
self.log_file = self.base_dir / "log.txt"
|
|
64
|
+
if self.enabled:
|
|
65
|
+
self._setup_dir()
|
|
66
|
+
|
|
67
|
+
def _setup_dir(self):
|
|
68
|
+
if self.base_dir.exists():
|
|
69
|
+
try:
|
|
70
|
+
shutil.rmtree(self.base_dir)
|
|
71
|
+
except Exception as e:
|
|
72
|
+
print(f"[DebugManager] Warning: Could not clear debug dir: {e}", file=sys.stderr)
|
|
73
|
+
try:
|
|
74
|
+
self.base_dir.mkdir(parents=True, exist_ok=True)
|
|
75
|
+
with open(self.log_file, "w") as f:
|
|
76
|
+
f.write(f"Debug Run Started: {datetime.now()}\n")
|
|
77
|
+
except Exception as e:
|
|
78
|
+
print(f"[DebugManager] Error creating debug dir: {e}", file=sys.stderr)
|
|
79
|
+
|
|
80
|
+
def log(self, message: str):
|
|
81
|
+
if self.enabled:
|
|
82
|
+
print(f"[DEBUG] {message}", file=sys.stderr)
|
|
83
|
+
try:
|
|
84
|
+
with open(self.log_file, "a") as f:
|
|
85
|
+
f.write(f"[{datetime.now().strftime('%H:%M:%S')}] {message}\n")
|
|
86
|
+
except Exception:
|
|
87
|
+
pass
|
|
88
|
+
elif DEBUG:
|
|
89
|
+
print(f"[Solver] {message}", file=sys.stderr)
|
|
90
|
+
|
|
91
|
+
def save_image(self, image_path: str, name: str) -> Optional[str]:
|
|
92
|
+
if not self.enabled:
|
|
93
|
+
return None
|
|
94
|
+
if not self.base_dir.exists():
|
|
95
|
+
self._setup_dir()
|
|
96
|
+
target = self.base_dir / name
|
|
97
|
+
try:
|
|
98
|
+
shutil.copy2(image_path, target)
|
|
99
|
+
self.log(f"Saved image: {name}")
|
|
100
|
+
return str(target)
|
|
101
|
+
except Exception as e:
|
|
102
|
+
self.log(f"Failed to save image {name}: {e}")
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class CaptchaSolver:
|
|
107
|
+
"""v2 solver: OpenCV grid detection + vLLM `captcha` LoRA."""
|
|
108
|
+
|
|
109
|
+
def __init__(
|
|
110
|
+
self,
|
|
111
|
+
model: Optional[str] = None,
|
|
112
|
+
provider: str = "captchaKrakenApi",
|
|
113
|
+
api_key: Optional[str] = None,
|
|
114
|
+
):
|
|
115
|
+
self.debug = DebugManager(DEBUG)
|
|
116
|
+
# `provider` kept for argv compatibility with the v1 CLI signature.
|
|
117
|
+
if provider not in {"captchaKrakenApi"}:
|
|
118
|
+
self.debug.log(f"Provider {provider!r} ignored; v2 only supports captchaKrakenApi.")
|
|
119
|
+
self.planner = ActionPlanner(
|
|
120
|
+
model=model, api_key=api_key, debug_callback=self.debug.log
|
|
121
|
+
)
|
|
122
|
+
self.image_processor = ImageProcessor(None, self.planner, self.debug)
|
|
123
|
+
self._image_size: Optional[Tuple[int, int]] = None
|
|
124
|
+
self._temp_files: List[str] = []
|
|
125
|
+
|
|
126
|
+
def __del__(self):
|
|
127
|
+
for f in self._temp_files:
|
|
128
|
+
if os.path.exists(f):
|
|
129
|
+
try:
|
|
130
|
+
os.unlink(f)
|
|
131
|
+
except Exception:
|
|
132
|
+
pass
|
|
133
|
+
|
|
134
|
+
# ------------------------------------------------------------------
|
|
135
|
+
# Public entry point
|
|
136
|
+
# ------------------------------------------------------------------
|
|
137
|
+
def solve(
|
|
138
|
+
self,
|
|
139
|
+
media_path: str,
|
|
140
|
+
instruction: str = "",
|
|
141
|
+
puzzle_source: str = "unknown",
|
|
142
|
+
retry_mode: Optional[str] = None,
|
|
143
|
+
) -> Union[CaptchaAction, List[CaptchaAction]]:
|
|
144
|
+
media_path = str(Path(media_path).resolve())
|
|
145
|
+
if not os.path.exists(media_path):
|
|
146
|
+
raise FileNotFoundError(f"Media not found: {media_path}")
|
|
147
|
+
|
|
148
|
+
cv_image_path = self._materialize_image(media_path)
|
|
149
|
+
self.debug.save_image(cv_image_path, "00_base_image.png")
|
|
150
|
+
assert self._image_size is not None
|
|
151
|
+
img_w, img_h = self._image_size
|
|
152
|
+
|
|
153
|
+
with timed("solver.find_grid"):
|
|
154
|
+
grid_boxes = find_grid(cv_image_path)
|
|
155
|
+
if grid_boxes and self._is_real_grid(cv_image_path, grid_boxes):
|
|
156
|
+
self.debug.log(f"Detected grid with {len(grid_boxes)} cells")
|
|
157
|
+
return self._solve_grid(cv_image_path, grid_boxes, retry_mode=retry_mode)
|
|
158
|
+
elif grid_boxes:
|
|
159
|
+
# find_grid latched onto e.g. an hCaptcha click-puzzle's
|
|
160
|
+
# header/footer bands. Reject — only true grids are supported.
|
|
161
|
+
self.debug.log(
|
|
162
|
+
f"find_grid returned {len(grid_boxes)} cells but failed the "
|
|
163
|
+
"real-grid sanity check."
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
if img_h < 400:
|
|
167
|
+
with timed("solver.find_checkbox"):
|
|
168
|
+
checkbox = find_checkbox(cv_image_path)
|
|
169
|
+
if checkbox:
|
|
170
|
+
self.debug.log(f"Detected checkbox at {checkbox}")
|
|
171
|
+
x, y, w, h = checkbox
|
|
172
|
+
return ClickAction(
|
|
173
|
+
action="click",
|
|
174
|
+
target_bounding_boxes=[
|
|
175
|
+
[x / img_w, y / img_h, (x + w) / img_w, (y + h) / img_h]
|
|
176
|
+
],
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
# Not a grid or checkbox → a click/drag/pixel puzzle. The full-puzzle
|
|
180
|
+
# LoRA is trained on all of these, so route the image to the pixel
|
|
181
|
+
# action path rather than bailing. (Video challenges are skipped
|
|
182
|
+
# upstream by the caller before they reach the solver, so anything here
|
|
183
|
+
# is a still-image puzzle worth attempting.)
|
|
184
|
+
actions = self._solve_pixel(cv_image_path)
|
|
185
|
+
if actions:
|
|
186
|
+
return actions
|
|
187
|
+
|
|
188
|
+
# The model returned nothing usable (blank/unrendered frame or a truly
|
|
189
|
+
# un-actionable image). Surface as unsupported so the caller fails fast
|
|
190
|
+
# instead of clicking nothing.
|
|
191
|
+
raise UnsupportedCaptchaError("Cannot solve this kind of captcha")
|
|
192
|
+
|
|
193
|
+
def _solve_pixel(
|
|
194
|
+
self, image_path: str
|
|
195
|
+
) -> List[Union[ClickAction, DragAction]]:
|
|
196
|
+
"""Turn the model's normalized 0–1 click/drag actions into ClickAction /
|
|
197
|
+
DragAction bboxes. Each point becomes a small box centered on it (the TS
|
|
198
|
+
solver clicks the box center)."""
|
|
199
|
+
raw_actions = self.planner.get_pixel_actions(image_path)
|
|
200
|
+
R = _PIXEL_BOX_HALF
|
|
201
|
+
clamp = lambda v: min(max(v, 0.0), 1.0)
|
|
202
|
+
|
|
203
|
+
def box(cx: float, cy: float) -> List[float]:
|
|
204
|
+
return [clamp(cx - R), clamp(cy - R), clamp(cx + R), clamp(cy + R)]
|
|
205
|
+
|
|
206
|
+
out: List[Union[ClickAction, DragAction]] = []
|
|
207
|
+
for a in raw_actions:
|
|
208
|
+
if a.get("kind") == "click":
|
|
209
|
+
boxes = [box(x, y) for (x, y) in a.get("points", [])]
|
|
210
|
+
if boxes:
|
|
211
|
+
out.append(
|
|
212
|
+
ClickAction(action="click", target_bounding_boxes=boxes)
|
|
213
|
+
)
|
|
214
|
+
elif a.get("kind") == "drag":
|
|
215
|
+
sx, sy = a["src"]
|
|
216
|
+
dx, dy = a["dst"]
|
|
217
|
+
out.append(
|
|
218
|
+
DragAction(
|
|
219
|
+
action="drag",
|
|
220
|
+
source_bounding_box=box(sx, sy),
|
|
221
|
+
target_bounding_box=box(dx, dy),
|
|
222
|
+
)
|
|
223
|
+
)
|
|
224
|
+
return out
|
|
225
|
+
|
|
226
|
+
# Back-compat alias.
|
|
227
|
+
def solveVideo(self, *args, **kwargs):
|
|
228
|
+
return self.solve(*args, **kwargs)
|
|
229
|
+
|
|
230
|
+
# ------------------------------------------------------------------
|
|
231
|
+
# Internals
|
|
232
|
+
# ------------------------------------------------------------------
|
|
233
|
+
def _materialize_image(self, media_path: str) -> str:
|
|
234
|
+
"""Return a path to a static PNG (extracting first frame for videos)."""
|
|
235
|
+
is_video = any(media_path.lower().endswith(ext) for ext in [".mp4", ".gif", ".avi", ".webm"])
|
|
236
|
+
if is_video:
|
|
237
|
+
import cv2
|
|
238
|
+
|
|
239
|
+
cap = cv2.VideoCapture(media_path)
|
|
240
|
+
ok, frame = cap.read()
|
|
241
|
+
cap.release()
|
|
242
|
+
if not ok:
|
|
243
|
+
raise ValueError(f"Could not read video frame from {media_path}")
|
|
244
|
+
self._image_size = (frame.shape[1], frame.shape[0])
|
|
245
|
+
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tf:
|
|
246
|
+
cv2.imwrite(tf.name, frame)
|
|
247
|
+
self._temp_files.append(tf.name)
|
|
248
|
+
return tf.name
|
|
249
|
+
|
|
250
|
+
with Image.open(media_path) as img:
|
|
251
|
+
self._image_size = img.size
|
|
252
|
+
return media_path
|
|
253
|
+
|
|
254
|
+
def _is_real_grid(
|
|
255
|
+
self,
|
|
256
|
+
image_path: str,
|
|
257
|
+
grid_boxes: List[Tuple[int, int, int, int]],
|
|
258
|
+
) -> bool:
|
|
259
|
+
"""Reject candidates that look like click-puzzle false positives.
|
|
260
|
+
|
|
261
|
+
A real captcha grid has 9 or 16 photographic tiles. Each tile is
|
|
262
|
+
independent imagery so the per-cell color stddev is *all* high.
|
|
263
|
+
|
|
264
|
+
hCaptcha click puzzles look like a 3-row stack (header band /
|
|
265
|
+
center image / footer band). find_grid sometimes interprets the
|
|
266
|
+
band edges as 2 horizontal grid lines × 2 spurious vertical lines,
|
|
267
|
+
producing 9 "cells" where the top/bottom rows are mostly a single
|
|
268
|
+
flat color (the band). Filter on that.
|
|
269
|
+
"""
|
|
270
|
+
try:
|
|
271
|
+
import cv2
|
|
272
|
+
import numpy as np
|
|
273
|
+
|
|
274
|
+
img = cv2.imread(image_path)
|
|
275
|
+
if img is None:
|
|
276
|
+
return True # Be permissive on read failure.
|
|
277
|
+
|
|
278
|
+
# Per-cell mean color standard deviation. Photo tiles ~ 30-80.
|
|
279
|
+
# Flat color band rows ~ 0-12.
|
|
280
|
+
stds: List[float] = []
|
|
281
|
+
for (x1, y1, x2, y2) in grid_boxes:
|
|
282
|
+
if x2 <= x1 or y2 <= y1:
|
|
283
|
+
continue
|
|
284
|
+
roi = img[y1:y2, x1:x2]
|
|
285
|
+
if roi.size == 0:
|
|
286
|
+
continue
|
|
287
|
+
# mean of per-channel stddev — robust to monochrome cells.
|
|
288
|
+
stds.append(float(np.mean(np.std(roi.reshape(-1, 3), axis=0))))
|
|
289
|
+
|
|
290
|
+
if not stds:
|
|
291
|
+
return False
|
|
292
|
+
|
|
293
|
+
n = len(grid_boxes)
|
|
294
|
+
side = 3 if n == 9 else 4 if n == 16 else int(round(n ** 0.5))
|
|
295
|
+
rows = [stds[i * side:(i + 1) * side] for i in range(side)]
|
|
296
|
+
cols = [stds[i::side] for i in range(side)]
|
|
297
|
+
|
|
298
|
+
self.debug.log(
|
|
299
|
+
f"_is_real_grid: per-cell stddev = {[round(s, 1) for s in stds]}"
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
# If the *whole* image is mostly flat (e.g. screenshotting the
|
|
303
|
+
# tiny hCaptcha anchor iframe with just the "I'm not a robot"
|
|
304
|
+
# text), find_grid hallucinates a 9-cell grid. Reject early.
|
|
305
|
+
import statistics
|
|
306
|
+
if statistics.mean(stds) < 25.0:
|
|
307
|
+
self.debug.log(
|
|
308
|
+
f"_is_real_grid: overall mean stddev "
|
|
309
|
+
f"{statistics.mean(stds):.1f} too low → reject."
|
|
310
|
+
)
|
|
311
|
+
return False
|
|
312
|
+
|
|
313
|
+
FLAT = 20.0
|
|
314
|
+
# Build a side×side flat-mask (1 = flat cell, 0 = rich cell).
|
|
315
|
+
flat_mask = [[1 if stds[r * side + c] < FLAT else 0
|
|
316
|
+
for c in range(side)] for r in range(side)]
|
|
317
|
+
flat_count = sum(sum(row) for row in flat_mask)
|
|
318
|
+
|
|
319
|
+
# Heuristic: in a *real* captcha grid, flat tiles (sky, asphalt)
|
|
320
|
+
# always form a spatially contiguous blob (top-left sky stack,
|
|
321
|
+
# bottom road row, etc.) because the underlying image is a single
|
|
322
|
+
# photo split into tiles. In a click-puzzle false grid, the flat
|
|
323
|
+
# cells are the **left + right margins** of the middle row,
|
|
324
|
+
# separated by the rich photo cell — non-contiguous.
|
|
325
|
+
#
|
|
326
|
+
# Use 4-connected components on flat cells. If we get more than
|
|
327
|
+
# one component with >=1 cell each, it's the click-puzzle pattern.
|
|
328
|
+
def components(mask):
|
|
329
|
+
seen = [[False] * side for _ in range(side)]
|
|
330
|
+
comps = 0
|
|
331
|
+
for r in range(side):
|
|
332
|
+
for c in range(side):
|
|
333
|
+
if mask[r][c] and not seen[r][c]:
|
|
334
|
+
comps += 1
|
|
335
|
+
stack = [(r, c)]
|
|
336
|
+
while stack:
|
|
337
|
+
y, x = stack.pop()
|
|
338
|
+
if seen[y][x] or not mask[y][x]:
|
|
339
|
+
continue
|
|
340
|
+
seen[y][x] = True
|
|
341
|
+
for dy, dx in ((1, 0), (-1, 0), (0, 1), (0, -1)):
|
|
342
|
+
ny, nx = y + dy, x + dx
|
|
343
|
+
if 0 <= ny < side and 0 <= nx < side:
|
|
344
|
+
stack.append((ny, nx))
|
|
345
|
+
return comps
|
|
346
|
+
|
|
347
|
+
if flat_count >= 2 and components(flat_mask) >= 2:
|
|
348
|
+
self.debug.log(
|
|
349
|
+
f"_is_real_grid: flat cells form >=2 disjoint components "
|
|
350
|
+
"→ reject (click-puzzle pattern)."
|
|
351
|
+
)
|
|
352
|
+
return False
|
|
353
|
+
return True
|
|
354
|
+
except Exception as e:
|
|
355
|
+
self.debug.log(f"_is_real_grid check errored ({e}); accepting grid.")
|
|
356
|
+
return True
|
|
357
|
+
|
|
358
|
+
def _solve_grid(
|
|
359
|
+
self,
|
|
360
|
+
image_path: str,
|
|
361
|
+
grid_boxes: List[Tuple[int, int, int, int]],
|
|
362
|
+
retry_mode: Optional[str] = None,
|
|
363
|
+
) -> Union[ClickAction, DoneAction, WaitAction]:
|
|
364
|
+
n = len(grid_boxes)
|
|
365
|
+
if n == 9:
|
|
366
|
+
rows, cols = 3, 3
|
|
367
|
+
elif n == 16:
|
|
368
|
+
rows, cols = 4, 4
|
|
369
|
+
else:
|
|
370
|
+
cols = int(math.sqrt(n))
|
|
371
|
+
rows = math.ceil(n / cols)
|
|
372
|
+
self.debug.log(f"grid {rows}x{cols} ({n} cells)")
|
|
373
|
+
|
|
374
|
+
cv_selected: List[int] = []
|
|
375
|
+
cv_loading: List[int] = []
|
|
376
|
+
try:
|
|
377
|
+
cv_selected, cv_loading = detect_selected_cells(image_path, grid_boxes, self.debug)
|
|
378
|
+
if cv_selected:
|
|
379
|
+
self.debug.log(f"CV: cells already selected -> {cv_selected}")
|
|
380
|
+
if cv_loading:
|
|
381
|
+
self.debug.log(f"CV: cells loading -> {cv_loading}")
|
|
382
|
+
except Exception as e:
|
|
383
|
+
self.debug.log(f"detect_selected_cells failed: {e}")
|
|
384
|
+
|
|
385
|
+
# SINGLE canonical overlay: get_numbered_grid_overlay (RED labels,
|
|
386
|
+
# top-right, ALL cells 1..N) — byte-for-byte the same overlay used to
|
|
387
|
+
# generate the training data (scripts/build_grid_overlays.py) and the
|
|
388
|
+
# offline grader. The model was trained ONLY on this style; any other
|
|
389
|
+
# overlay (e.g. green, or skipping cells) is out-of-distribution and
|
|
390
|
+
# tanks the live solve rate. We number EVERY cell so the model sees the
|
|
391
|
+
# exact grid it trained on; already-selected/loading cells are filtered
|
|
392
|
+
# AFTER the model responds (below), never by renumbering the grid.
|
|
393
|
+
ext = os.path.splitext(image_path)[1] or ".png"
|
|
394
|
+
with tempfile.NamedTemporaryFile(suffix=ext, delete=False) as tf:
|
|
395
|
+
overlay_path = tf.name
|
|
396
|
+
self._temp_files.append(overlay_path)
|
|
397
|
+
get_numbered_grid_overlay(image_path, grid_boxes, output_path=overlay_path)
|
|
398
|
+
self.debug.save_image(overlay_path, "01_grid_overlay.png")
|
|
399
|
+
|
|
400
|
+
with timed("planner.grid"):
|
|
401
|
+
selected = self.planner.get_grid_selection(
|
|
402
|
+
overlay_path, rows=rows, cols=cols, retry_mode=retry_mode,
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
# Map the model's tile IDs to clicks. Skip cells the CV layer already
|
|
406
|
+
# flagged as selected (avoid re-toggling) or still loading.
|
|
407
|
+
final: List[int] = []
|
|
408
|
+
for n in selected:
|
|
409
|
+
try:
|
|
410
|
+
v = int(n)
|
|
411
|
+
except (TypeError, ValueError):
|
|
412
|
+
continue
|
|
413
|
+
if v < 1 or v > len(grid_boxes):
|
|
414
|
+
continue # hallucinated index
|
|
415
|
+
if v in cv_selected or v in cv_loading:
|
|
416
|
+
continue # already selected / not ready — don't re-click
|
|
417
|
+
final.append(v)
|
|
418
|
+
|
|
419
|
+
if not final:
|
|
420
|
+
# Nothing new to click: wait if tiles are still loading, else done.
|
|
421
|
+
if cv_loading:
|
|
422
|
+
return WaitAction(action="wait", duration_ms=1000)
|
|
423
|
+
return DoneAction(action="done")
|
|
424
|
+
|
|
425
|
+
img_w, img_h = self._image_size # type: ignore[misc]
|
|
426
|
+
bboxes: List[List[float]] = []
|
|
427
|
+
for v in final:
|
|
428
|
+
x1, y1, x2, y2 = grid_boxes[v - 1]
|
|
429
|
+
bboxes.append([x1 / img_w, y1 / img_h, x2 / img_w, y2 / img_h])
|
|
430
|
+
return ClickAction(action="click", target_bounding_boxes=bboxes)
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def solve_captcha(media_path: str, instruction: str = "", **kwargs) -> Any:
|
|
434
|
+
return CaptchaSolver(**kwargs).solve(media_path, instruction)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Lightweight timing utilities.
|
|
3
|
+
|
|
4
|
+
Enable with env var:
|
|
5
|
+
CAPTCHA_TIMINGS=1
|
|
6
|
+
|
|
7
|
+
This prints a single-line timing record to stderr for each instrumented step.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
import time
|
|
15
|
+
from contextlib import contextmanager
|
|
16
|
+
from typing import Iterator, Optional
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def timings_enabled() -> bool:
|
|
20
|
+
return os.getenv("CAPTCHA_TIMINGS", "0") == "1"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@contextmanager
|
|
24
|
+
def timed(label: str, extra: Optional[str] = None) -> Iterator[None]:
|
|
25
|
+
"""
|
|
26
|
+
Context manager that prints:
|
|
27
|
+
[TIMING] <label>: <ms>ms (<extra>)
|
|
28
|
+
to stderr when CAPTCHA_TIMINGS=1.
|
|
29
|
+
"""
|
|
30
|
+
if not timings_enabled():
|
|
31
|
+
yield
|
|
32
|
+
return
|
|
33
|
+
|
|
34
|
+
t0 = time.perf_counter()
|
|
35
|
+
try:
|
|
36
|
+
yield
|
|
37
|
+
finally:
|
|
38
|
+
dt_ms = (time.perf_counter() - t0) * 1000.0
|
|
39
|
+
suffix = f" ({extra})" if extra else ""
|
|
40
|
+
print(f"[TIMING] {label}: {dt_ms:.2f}ms{suffix}", file=sys.stderr)
|
|
41
|
+
|
|
42
|
+
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import cv2
|
|
2
|
+
import numpy as np
|
|
3
|
+
from typing import Optional, Tuple
|
|
4
|
+
|
|
5
|
+
def find_checkbox(image_path: str) -> Optional[Tuple[int, int, int, int]]:
|
|
6
|
+
"""
|
|
7
|
+
Detects a checkbox in the image using lightweight computer vision techniques.
|
|
8
|
+
Returns the (x, y, w, h) of the best candidate or None.
|
|
9
|
+
"""
|
|
10
|
+
img = cv2.imread(image_path)
|
|
11
|
+
if img is None:
|
|
12
|
+
return None
|
|
13
|
+
|
|
14
|
+
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
|
|
15
|
+
|
|
16
|
+
# Simple thresholding - efficient and works well for high contrast line art like checkboxes
|
|
17
|
+
_, binary = cv2.threshold(gray, 200, 255, cv2.THRESH_BINARY_INV)
|
|
18
|
+
|
|
19
|
+
contours, _ = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
|
20
|
+
|
|
21
|
+
height, width = img.shape[:2]
|
|
22
|
+
image_area = width * height
|
|
23
|
+
|
|
24
|
+
candidates = []
|
|
25
|
+
|
|
26
|
+
for cnt in contours:
|
|
27
|
+
x, y, w, h = cv2.boundingRect(cnt)
|
|
28
|
+
area = w * h
|
|
29
|
+
aspect_ratio = float(w) / h
|
|
30
|
+
|
|
31
|
+
# Filter based on properties
|
|
32
|
+
|
|
33
|
+
# 1. Aspect ratio should be close to 1 (square)
|
|
34
|
+
if not (0.8 < aspect_ratio < 1.2):
|
|
35
|
+
continue
|
|
36
|
+
|
|
37
|
+
# 2. Minimum absolute size (to avoid noise/text periods)
|
|
38
|
+
if w < 20 or h < 20:
|
|
39
|
+
continue
|
|
40
|
+
|
|
41
|
+
# 3. Area relative to image size
|
|
42
|
+
if not (0.001 * image_area < area < 0.05 * image_area):
|
|
43
|
+
continue
|
|
44
|
+
|
|
45
|
+
# 4. Solidity/Extent
|
|
46
|
+
contour_area = cv2.contourArea(cnt)
|
|
47
|
+
extent = contour_area / area
|
|
48
|
+
if extent < 0.8: # Stricter extent to avoid complex shapes
|
|
49
|
+
continue
|
|
50
|
+
|
|
51
|
+
# 5. Content check - Checkboxes are usually empty (white/solid)
|
|
52
|
+
# Crop margin to avoid border
|
|
53
|
+
roi = gray[y+5:y+h-5, x+5:x+w-5]
|
|
54
|
+
if roi.size > 0:
|
|
55
|
+
std_dev = np.std(roi)
|
|
56
|
+
if std_dev > 35.0: # High variance means complex content (image)
|
|
57
|
+
continue
|
|
58
|
+
|
|
59
|
+
candidates.append((x, y, w, h))
|
|
60
|
+
|
|
61
|
+
# Heuristic: Checkbox captchas typically have 1 single checkbox.
|
|
62
|
+
# If we find many candidates, it's likely a grid or something else.
|
|
63
|
+
if len(candidates) > 2:
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
if not candidates:
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
# Return the largest candidate (most likely the main checkbox)
|
|
70
|
+
best_candidate = max(candidates, key=lambda c: c[2] * c[3])
|
|
71
|
+
return best_candidate
|
|
72
|
+
|