captchakraken 2.4.0 → 2.6.1
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/LICENSE +47 -7
- package/README.md +60 -13
- package/dist/cli-invocation.d.ts +46 -0
- package/dist/cli-invocation.js +68 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.js +29 -1
- package/dist/limits.d.ts +51 -0
- package/dist/limits.js +54 -0
- package/dist/model-name.d.ts +23 -0
- package/dist/model-name.js +70 -0
- package/dist/playwright-types.d.ts +28 -0
- package/dist/puppeteer-adapter.d.ts +11 -0
- package/dist/puppeteer-adapter.js +23 -0
- package/dist/python-command.d.ts +50 -0
- package/dist/python-command.js +60 -0
- package/dist/slide-geometry.d.ts +33 -0
- package/dist/slide-geometry.js +64 -0
- package/dist/solver.d.ts +217 -12
- package/dist/solver.js +1332 -172
- package/dist/trajectory.d.ts +31 -0
- package/dist/trajectory.js +162 -0
- package/dist/types.d.ts +158 -11
- package/dist/watcher.d.ts +99 -0
- package/dist/watcher.js +129 -0
- package/package.json +12 -7
- package/python/LICENSE +47 -7
- package/python/README.md +58 -13
- package/python/examples/README.md +36 -0
- package/python/examples/_harness.py +182 -94
- package/python/examples/demoHcaptcha.py +2 -1
- package/python/examples/demoRecaptcha.py +2 -1
- package/python/examples/watch_playwright.py +56 -0
- package/python/examples/with_playwright.py +43 -0
- package/python/pyproject.toml +24 -6
- package/python/src/captchakraken/__init__.py +5 -3
- package/python/src/captchakraken/action_types.py +24 -3
- package/python/src/captchakraken/cli.py +212 -2
- package/python/src/captchakraken/config.py +39 -5
- package/python/src/captchakraken/keyframes.py +690 -0
- package/python/src/captchakraken/models.json +120 -0
- package/python/src/captchakraken/page_solver.py +1559 -105
- package/python/src/captchakraken/pinned_model.json +5 -7
- package/python/src/captchakraken/planner.py +253 -59
- package/python/src/captchakraken/prompts.py +485 -0
- package/python/src/captchakraken/solver.py +182 -11
- package/python/src/captchakraken/timing.py +70 -0
- package/python/src/captchakraken/tool_calls/find_grid.py +367 -52
- package/python/src/captchakraken/tool_calls/track_piece.py +96 -0
- package/python/src/captchakraken/trajectory.py +2 -7
- package/python/src/captchakraken/watcher.py +193 -0
- package/python/src/captchakraken/planner_types.py +0 -74
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The auto-solver: install once, and captchas are solved as they appear.
|
|
3
|
+
|
|
4
|
+
`solve(page)` handles whatever is on the page right now. `watch(page)` is for
|
|
5
|
+
when you do not know WHERE in a script a challenge will interrupt you.
|
|
6
|
+
|
|
7
|
+
python examples/watch_playwright.py [url] [seconds]
|
|
8
|
+
|
|
9
|
+
TWO SHAPES, because this port is synchronous:
|
|
10
|
+
|
|
11
|
+
solver.watch(page).run() # blocking — hold this page clean
|
|
12
|
+
w = solver.watch(page) # cooperative — you own the cadence
|
|
13
|
+
while working():
|
|
14
|
+
w.poll_once()
|
|
15
|
+
|
|
16
|
+
The TypeScript twin returns a background handle instead. That difference is
|
|
17
|
+
Playwright's, not ours: a sync handle is bound to the greenlet that created it,
|
|
18
|
+
so a worker thread cannot drive the page.
|
|
19
|
+
|
|
20
|
+
It injects nothing into the page on any launcher. Under camoufox its DOM reads
|
|
21
|
+
run in the isolated Juggler world by default.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import sys
|
|
25
|
+
|
|
26
|
+
from playwright.sync_api import sync_playwright
|
|
27
|
+
|
|
28
|
+
from captchakraken import PageSolver
|
|
29
|
+
|
|
30
|
+
URL = sys.argv[1] if len(sys.argv) > 1 else "https://www.google.com/recaptcha/api2/demo"
|
|
31
|
+
SECONDS = int(sys.argv[2]) if len(sys.argv) > 2 else 30
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def main() -> int:
|
|
35
|
+
with sync_playwright() as p:
|
|
36
|
+
browser = p.chromium.launch(headless=True)
|
|
37
|
+
page = browser.new_page()
|
|
38
|
+
|
|
39
|
+
# ── install once, then let it work ───────────────────────────────────
|
|
40
|
+
watcher = PageSolver().watch(
|
|
41
|
+
page,
|
|
42
|
+
on_solved=lambda r: print("✅ solved one:", r.is_solved),
|
|
43
|
+
on_error=lambda e: print("solve failed:", e),
|
|
44
|
+
)
|
|
45
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
page.goto(URL)
|
|
48
|
+
watcher.run(timeout_ms=SECONDS * 1000) # blocks; solves as they appear
|
|
49
|
+
|
|
50
|
+
print(f"stopped after {watcher.solves} solve(s)")
|
|
51
|
+
browser.close()
|
|
52
|
+
return 0 if watcher.solves else 1
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
if __name__ == "__main__":
|
|
56
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Vanilla Playwright + CaptchaKraken.
|
|
3
|
+
|
|
4
|
+
No adapter, and no browser dependency in the package: the driver duck-types the
|
|
5
|
+
Playwright surface, so a real `sync_playwright` page satisfies it as-is — as do
|
|
6
|
+
patchright's and camoufox's.
|
|
7
|
+
|
|
8
|
+
pip install captchakraken playwright && playwright install chromium
|
|
9
|
+
export VLLM_BASE_URL=https://api.captchakraken.com/v1
|
|
10
|
+
export CAPTCHA_KRAKEN_API_KEY=ck_live_...
|
|
11
|
+
python examples/with_playwright.py [url]
|
|
12
|
+
|
|
13
|
+
SYNC, NOT ASYNC. `PageSolver` mirrors the synchronous Playwright API. A sync
|
|
14
|
+
handle cannot be driven from inside an event loop, so `async_playwright` pages
|
|
15
|
+
cannot be solved — use `sync_playwright`, as here.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import sys
|
|
19
|
+
|
|
20
|
+
from playwright.sync_api import sync_playwright
|
|
21
|
+
|
|
22
|
+
from captchakraken import PageSolver
|
|
23
|
+
|
|
24
|
+
URL = sys.argv[1] if len(sys.argv) > 1 else "https://www.google.com/recaptcha/api2/demo"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def main() -> int:
|
|
28
|
+
with sync_playwright() as p:
|
|
29
|
+
browser = p.chromium.launch(headless=True)
|
|
30
|
+
page = browser.new_page()
|
|
31
|
+
page.goto(URL)
|
|
32
|
+
|
|
33
|
+
# ── the whole integration: construct, solve ──────────────────────────
|
|
34
|
+
result = PageSolver().solve(page)
|
|
35
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
print("✅ solved" if result.is_solved else "❌ not solved")
|
|
38
|
+
browser.close()
|
|
39
|
+
return 0 if result.is_solved else 1
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
if __name__ == "__main__":
|
|
43
|
+
raise SystemExit(main())
|
package/python/pyproject.toml
CHANGED
|
@@ -4,21 +4,36 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "captchakraken"
|
|
7
|
-
version = "2.
|
|
8
|
-
description = "
|
|
7
|
+
version = "2.6.1"
|
|
8
|
+
description = "Captcha-solving engine and CLI: OpenCV grid detection plus a fine-tuned Qwen3.5-9B vision model, for image grids, click and drag puzzles, sliders, distorted text and animated challenges. Hosted API or self-hosted on vLLM."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
11
11
|
# Source-available, NOT GPL. The CaptchaKraken Source-Available License permits
|
|
12
|
-
# commercial use as an internal component but prohibits selling the solve itself
|
|
13
|
-
#
|
|
12
|
+
# commercial use as an internal component but prohibits selling the solve itself,
|
|
13
|
+
# thin-wrapper resale, and bundling it into a stealth browser someone else ships
|
|
14
|
+
# — see LICENSE. (GPL-3.0 would wrongly permit all three.)
|
|
14
15
|
license = { file = "LICENSE" }
|
|
15
16
|
authors = [{ name = "Jake Writer" }]
|
|
16
|
-
keywords = [
|
|
17
|
+
keywords = [
|
|
18
|
+
"captcha", "captcha-solver", "recaptcha", "hcaptcha", "geetest", "turnstile",
|
|
19
|
+
"yidun", "tencent-captcha", "playwright", "browser-automation", "scraping",
|
|
20
|
+
"anti-bot", "vllm", "qwen", "computer-vision", "ocr",
|
|
21
|
+
]
|
|
17
22
|
classifiers = [
|
|
23
|
+
"Development Status :: 5 - Production/Stable",
|
|
24
|
+
"Intended Audience :: Developers",
|
|
18
25
|
"Programming Language :: Python :: 3",
|
|
26
|
+
"Programming Language :: Python :: 3.10",
|
|
27
|
+
"Programming Language :: Python :: 3.11",
|
|
28
|
+
"Programming Language :: Python :: 3.12",
|
|
29
|
+
"Programming Language :: Python :: 3.13",
|
|
19
30
|
"Operating System :: OS Independent",
|
|
20
31
|
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
32
|
+
"Topic :: Scientific/Engineering :: Image Recognition",
|
|
33
|
+
"Topic :: Internet :: WWW/HTTP :: Browsers",
|
|
34
|
+
"Topic :: Software Development :: Testing",
|
|
21
35
|
"License :: Other/Proprietary License",
|
|
36
|
+
"Typing :: Typed",
|
|
22
37
|
]
|
|
23
38
|
|
|
24
39
|
# Core = the lightweight CLIENT: OpenCV grid detection + the HTTP planner that
|
|
@@ -54,8 +69,11 @@ dev = [
|
|
|
54
69
|
captchakraken = "captchakraken.cli:main"
|
|
55
70
|
|
|
56
71
|
[project.urls]
|
|
57
|
-
Homepage = "https://
|
|
72
|
+
Homepage = "https://captchakraken.com"
|
|
73
|
+
Documentation = "https://github.com/JWriter20/CaptchaKraken#readme"
|
|
74
|
+
Source = "https://github.com/JWriter20/CaptchaKraken"
|
|
58
75
|
Issues = "https://github.com/JWriter20/CaptchaKraken/issues"
|
|
76
|
+
Changelog = "https://github.com/JWriter20/CaptchaKraken/blob/main/CHANGELOG.md"
|
|
59
77
|
|
|
60
78
|
[tool.hatch.build.targets.wheel]
|
|
61
79
|
packages = ["src/captchakraken"]
|
|
@@ -9,8 +9,7 @@ Usage:
|
|
|
9
9
|
|
|
10
10
|
Model/endpoint defaults live in `captchakraken.config` and are fully
|
|
11
11
|
env-overridable (VLLM_BASE_URL, CAPTCHA_LORA_ADAPTER, …); the solver itself is
|
|
12
|
-
model-agnostic.
|
|
13
|
-
detect/segment/drag-refine) lives on the `v1-old-architecture` branch.
|
|
12
|
+
model-agnostic.
|
|
14
13
|
"""
|
|
15
14
|
|
|
16
15
|
from pathlib import Path
|
|
@@ -46,11 +45,13 @@ try: # pragma: no cover - exercised only when the serving stack is installed
|
|
|
46
45
|
# caller supplies the Playwright-compatible page (see page_solver's module
|
|
47
46
|
# docstring on why we duck-type rather than import one).
|
|
48
47
|
from .page_solver import PageSolver, SolveResult, solve_captcha_on_page
|
|
48
|
+
from .watcher import CaptchaWatcher
|
|
49
49
|
except ModuleNotFoundError:
|
|
50
50
|
ActionPlanner = None # type: ignore[assignment,misc]
|
|
51
51
|
CaptchaSolver = None # type: ignore[assignment,misc]
|
|
52
52
|
solve_captcha = None # type: ignore[assignment]
|
|
53
53
|
PageSolver = None # type: ignore[assignment,misc]
|
|
54
|
+
CaptchaWatcher = None # type: ignore[assignment,misc]
|
|
54
55
|
SolveResult = None # type: ignore[assignment,misc]
|
|
55
56
|
solve_captcha_on_page = None # type: ignore[assignment]
|
|
56
57
|
|
|
@@ -58,6 +59,7 @@ __all__ = [
|
|
|
58
59
|
"CaptchaSolver",
|
|
59
60
|
"solve_captcha",
|
|
60
61
|
"PageSolver",
|
|
62
|
+
"CaptchaWatcher",
|
|
61
63
|
"SolveResult",
|
|
62
64
|
"solve_captcha_on_page",
|
|
63
65
|
"ActionPlanner",
|
|
@@ -70,4 +72,4 @@ __all__ = [
|
|
|
70
72
|
"add_overlays_to_image",
|
|
71
73
|
]
|
|
72
74
|
|
|
73
|
-
__version__ = "2.
|
|
75
|
+
__version__ = "2.6.0"
|
|
@@ -22,6 +22,22 @@ class BoundingBox(RootModel):
|
|
|
22
22
|
|
|
23
23
|
class Action(BaseModel):
|
|
24
24
|
action: str
|
|
25
|
+
# ANIMATED CHALLENGES ONLY. Path to the keyframe the model chose to act on, and
|
|
26
|
+
# its 1-based number in the set that was sent.
|
|
27
|
+
#
|
|
28
|
+
# Why an action carries a picture: on an animated puzzle the target is only
|
|
29
|
+
# present part of the time, so these coordinates are correct only while the
|
|
30
|
+
# widget looks the way it did in that keyframe. The driver therefore holds the
|
|
31
|
+
# mouse until the live neighbourhood around the click point matches the same
|
|
32
|
+
# neighbourhood of this file, then clicks. Without it the click fires on
|
|
33
|
+
# whatever happens to be on screen, which for a fading sprite is usually
|
|
34
|
+
# background.
|
|
35
|
+
#
|
|
36
|
+
# Absent on every still puzzle, where there is no moment to wait for. Both are
|
|
37
|
+
# set together or not at all — a number with no image cannot be waited on, and
|
|
38
|
+
# an image with no number cannot be reported.
|
|
39
|
+
await_keyframe: Optional[str] = None
|
|
40
|
+
frame: Optional[int] = None
|
|
25
41
|
|
|
26
42
|
|
|
27
43
|
class ClickAction(Action):
|
|
@@ -31,15 +47,20 @@ class ClickAction(Action):
|
|
|
31
47
|
|
|
32
48
|
class DragAction(Action):
|
|
33
49
|
action: Literal["drag"]
|
|
34
|
-
|
|
35
|
-
|
|
50
|
+
# Optional, not merely defaulted: a PUZZLE-PIECE SLIDER is carried as a drag
|
|
51
|
+
# with source_bounding_box explicitly None, and pydantic validates a value
|
|
52
|
+
# that is passed even when it equals the default. Declaring these as bare
|
|
53
|
+
# `BoundingBox = None` made constructing a slide raise ValidationError —
|
|
54
|
+
# every slide puzzle, at the moment of acting on a correct answer.
|
|
55
|
+
source_bounding_box: Optional[BoundingBox] = None # [x1, y1, x2, y2] in percentages
|
|
56
|
+
target_bounding_box: Optional[BoundingBox] = None # [x1, y1, x2, y2] in percentages
|
|
36
57
|
|
|
37
58
|
|
|
38
59
|
class TypeAction(Action):
|
|
39
60
|
"""Type text into an input."""
|
|
40
61
|
action: Literal["type"]
|
|
41
62
|
text: str
|
|
42
|
-
target_bounding_box: BoundingBox = None # [x1, y1, x2, y2] in percentages
|
|
63
|
+
target_bounding_box: Optional[BoundingBox] = None # [x1, y1, x2, y2] in percentages
|
|
43
64
|
|
|
44
65
|
|
|
45
66
|
class WaitAction(Action):
|
|
@@ -3,12 +3,25 @@ CaptchaKraken CLI (v2).
|
|
|
3
3
|
|
|
4
4
|
Modes:
|
|
5
5
|
captchakraken image.png [model_name] [api_provider] [api_key]
|
|
6
|
-
Solve a captcha
|
|
6
|
+
Solve a captcha from ONE still image. `api_provider` is kept for v1
|
|
7
7
|
compat; only `captchaKrakenApi` is supported and is the default.
|
|
8
8
|
|
|
9
|
+
captchakraken solve-animated --frames-dir DIR [--fps N] [--model M] [--api-key K]
|
|
10
|
+
Solve an ANIMATED challenge from a recorded burst. DIR holds one
|
|
11
|
+
zero-padded PNG per frame; the burst is sliced into keyframes (written to
|
|
12
|
+
DIR/keyframes/) and sent to the model as one multi-image request. Prints
|
|
13
|
+
{"actions": [...], "keyframes": [...], "keyframe_mode": ...}. Every action
|
|
14
|
+
carries `await_keyframe` — the still the driver must see on screen before
|
|
15
|
+
acting — and `frame`, its number. Poll for that state with `match-region`.
|
|
16
|
+
|
|
17
|
+
captchakraken match-region ref.png live.png cx cy [tolerance]
|
|
18
|
+
Does `live` look like `ref` around the 0-1 point (cx, cy)? The wait gate
|
|
19
|
+
behind an animated click -> {"match": bool, "diff": float}. Also a `serve`
|
|
20
|
+
cmd (`match-region`), which is what a driver polling every ~120ms wants.
|
|
21
|
+
|
|
9
22
|
captchakraken check-movement img1.png img2.png [threshold]
|
|
10
23
|
captchakraken check-movement-batch threshold img1 img2 [img3 ...]
|
|
11
|
-
Frame-diff helpers used by the Playwright lib's
|
|
24
|
+
Frame-diff helpers used by the Playwright lib's settle monitor.
|
|
12
25
|
|
|
13
26
|
captchakraken find-grid image.png
|
|
14
27
|
captchakraken detect-selected image.png
|
|
@@ -55,6 +68,7 @@ import json
|
|
|
55
68
|
import os
|
|
56
69
|
import subprocess
|
|
57
70
|
import sys
|
|
71
|
+
from typing import Optional
|
|
58
72
|
|
|
59
73
|
from .errors import CaptchaKrakenAPIError
|
|
60
74
|
from .solver import CaptchaSolver, UnsupportedCaptchaError
|
|
@@ -381,6 +395,8 @@ def _handle_serve() -> bool:
|
|
|
381
395
|
grid-cell-states {a, b} -> states | {grid: null}
|
|
382
396
|
grid-cell-states-fixed {a, b, grid_boxes} -> states
|
|
383
397
|
check-movement {a, b, threshold?} -> {has_movement: bool}
|
|
398
|
+
match-region {ref, live, cx, cy, ...} -> {match, diff, ...}
|
|
399
|
+
track-piece {before, after, exclude?} -> {bbox, moved}
|
|
384
400
|
|
|
385
401
|
Unknown cmd / malformed line -> an {ok:false} response (the process keeps
|
|
386
402
|
running). EOF on stdin ends the loop cleanly. All heavy detection delegates
|
|
@@ -414,6 +430,18 @@ def _handle_serve() -> bool:
|
|
|
414
430
|
threshold = float(req.get("threshold", 0.005))
|
|
415
431
|
moved = ImageProcessor.detect_movement(req["a"], req["b"], threshold)
|
|
416
432
|
return {"has_movement": bool(moved)}
|
|
433
|
+
if cmd == "match-region":
|
|
434
|
+
# Animated wait gate: is the widget back in the state the model
|
|
435
|
+
# answered about? Polled every ~120ms while a click is held, so it
|
|
436
|
+
# belongs on the worker rather than paying a process spawn per poll.
|
|
437
|
+
return _match_region(req["ref"], req["live"],
|
|
438
|
+
float(req["cx"]), float(req["cy"]),
|
|
439
|
+
req.get("tolerance"))
|
|
440
|
+
if cmd == "track-piece":
|
|
441
|
+
# Slider closed loop: where has the piece got to? Called several
|
|
442
|
+
# times per drag WITH THE MOUSE HELD DOWN, so a process spawn per
|
|
443
|
+
# reading would stretch the drag into something no human hand does.
|
|
444
|
+
return _track_piece(req["before"], req["after"], req.get("exclude"))
|
|
417
445
|
raise ValueError(f"unknown cmd: {cmd!r}")
|
|
418
446
|
|
|
419
447
|
# Signal readiness so the JS side knows imports are done before it polls.
|
|
@@ -436,6 +464,172 @@ def _handle_serve() -> bool:
|
|
|
436
464
|
return True
|
|
437
465
|
|
|
438
466
|
|
|
467
|
+
def _frames_in(frames_dir: str) -> list:
|
|
468
|
+
"""The recorded burst's frames in capture order.
|
|
469
|
+
|
|
470
|
+
Sorted by NAME, which is why the recorder must zero-pad: `frame_9.png` sorts
|
|
471
|
+
after `frame_10.png`, and a shuffled burst destroys the temporal structure the
|
|
472
|
+
cycle detector reads. `_handle_solve_animated` rejects an unsorted-looking set
|
|
473
|
+
rather than silently slicing noise.
|
|
474
|
+
"""
|
|
475
|
+
import glob
|
|
476
|
+
|
|
477
|
+
return sorted(
|
|
478
|
+
p for ext in ("png", "jpg", "jpeg")
|
|
479
|
+
for p in glob.glob(os.path.join(frames_dir, f"*.{ext}"))
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def _handle_solve_animated() -> bool:
|
|
484
|
+
"""`captchakraken solve-animated --frames-dir DIR [--fps N] [...]`
|
|
485
|
+
|
|
486
|
+
The animated counterpart of the default solve command, for the TypeScript
|
|
487
|
+
driver: TS owns the browser, so IT records the burst (one zero-padded PNG per
|
|
488
|
+
frame into a directory it owns) and this slices the burst into keyframes, sends
|
|
489
|
+
them to the model as one multi-image request, and prints the actions.
|
|
490
|
+
|
|
491
|
+
Each action carries `await_keyframe`, an absolute path to the still the driver
|
|
492
|
+
must see on screen before it acts. Those files are written into
|
|
493
|
+
`DIR/keyframes/`, i.e. inside the caller's own directory — the CLI never leaves
|
|
494
|
+
temp files behind for the caller to guess at, and the caller cleans up when it
|
|
495
|
+
cleans up the burst. Poll for the state with `match-region`.
|
|
496
|
+
"""
|
|
497
|
+
if len(sys.argv) <= 1 or sys.argv[1] != "solve-animated":
|
|
498
|
+
return False
|
|
499
|
+
|
|
500
|
+
parser = argparse.ArgumentParser(prog="captchakraken solve-animated")
|
|
501
|
+
parser.add_argument("--frames-dir", required=True,
|
|
502
|
+
help="Directory of the recorded burst (zero-padded PNGs).")
|
|
503
|
+
parser.add_argument("--fps", type=float, default=10.0,
|
|
504
|
+
help="Frame rate the burst was recorded at (default 10, the "
|
|
505
|
+
"rate the training data was collected at).")
|
|
506
|
+
parser.add_argument("--model", default=None)
|
|
507
|
+
parser.add_argument("--api-key", default=None)
|
|
508
|
+
args = parser.parse_args(sys.argv[2:])
|
|
509
|
+
|
|
510
|
+
frames = _frames_in(args.frames_dir)
|
|
511
|
+
if not frames:
|
|
512
|
+
print(json.dumps({"error": f"no frames in {args.frames_dir}"}), file=sys.stderr)
|
|
513
|
+
sys.exit(1)
|
|
514
|
+
|
|
515
|
+
try:
|
|
516
|
+
import cv2
|
|
517
|
+
|
|
518
|
+
from .keyframes import extract_keyframes, write_keyframes
|
|
519
|
+
|
|
520
|
+
with timed("cli.total"):
|
|
521
|
+
imgs = [im for im in (cv2.imread(p) for p in frames) if im is not None]
|
|
522
|
+
if not imgs:
|
|
523
|
+
print(json.dumps({"error": "no frame decoded"}), file=sys.stderr)
|
|
524
|
+
sys.exit(1)
|
|
525
|
+
kfset = extract_keyframes(imgs, fps=float(args.fps))
|
|
526
|
+
kf_dir = os.path.join(args.frames_dir, "keyframes")
|
|
527
|
+
paths = [str(os.path.abspath(p))
|
|
528
|
+
for p in write_keyframes(kfset, kf_dir, stem="challenge")]
|
|
529
|
+
|
|
530
|
+
solver = CaptchaSolver(model=args.model, api_key=args.api_key)
|
|
531
|
+
result = solver.solve_keyframes(paths)
|
|
532
|
+
|
|
533
|
+
action_data = [a.model_dump() for a in result]
|
|
534
|
+
print(json.dumps({
|
|
535
|
+
"actions": action_data,
|
|
536
|
+
"token_usage": solver.planner.token_usage,
|
|
537
|
+
# Reported so the driver can log what it actually sent, and so a
|
|
538
|
+
# surprising slicing (6 frames collapsing to 1) is visible in the
|
|
539
|
+
# solve log rather than only inferable from the answer.
|
|
540
|
+
"keyframes": paths,
|
|
541
|
+
"keyframe_mode": kfset.mode,
|
|
542
|
+
"source_frames": len(imgs),
|
|
543
|
+
}))
|
|
544
|
+
except UnsupportedCaptchaError as e:
|
|
545
|
+
print(json.dumps({"error": str(e), "unsupported": True}), file=sys.stderr)
|
|
546
|
+
sys.exit(2)
|
|
547
|
+
except CaptchaKrakenAPIError as e:
|
|
548
|
+
print(json.dumps(e.to_payload()), file=sys.stderr)
|
|
549
|
+
sys.exit(3)
|
|
550
|
+
except Exception as e: # noqa: BLE001
|
|
551
|
+
import traceback
|
|
552
|
+
|
|
553
|
+
traceback.print_exc()
|
|
554
|
+
print(json.dumps({"error": str(e)}), file=sys.stderr)
|
|
555
|
+
sys.exit(1)
|
|
556
|
+
return True
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def _match_region(ref: str, live: str, cx: float, cy: float,
|
|
560
|
+
tolerance: Optional[float] = None) -> dict:
|
|
561
|
+
"""Does `live` look like `ref` around the 0–1 point (cx, cy)?
|
|
562
|
+
|
|
563
|
+
The wait-for-state gate behind an animated click. Compares only the
|
|
564
|
+
neighbourhood of the action point, with the same box and metric the training
|
|
565
|
+
label's frame was chosen with (`keyframes.region_box` / `region_diff_ratio`) —
|
|
566
|
+
local rather than whole-frame because everything else in these puzzles is also
|
|
567
|
+
moving, so a whole-frame match would need every unrelated sprite to align too
|
|
568
|
+
and would essentially never open.
|
|
569
|
+
"""
|
|
570
|
+
import cv2
|
|
571
|
+
|
|
572
|
+
from .keyframes import MATCH_REGION_TOLERANCE, region_box, region_diff_ratio
|
|
573
|
+
|
|
574
|
+
tol = MATCH_REGION_TOLERANCE if tolerance is None else float(tolerance)
|
|
575
|
+
a, b = cv2.imread(ref), cv2.imread(live)
|
|
576
|
+
if a is None or b is None:
|
|
577
|
+
return {"match": False, "diff": 1.0, "error": "unreadable image"}
|
|
578
|
+
box = region_box(a.shape[1::-1], (cx, cy))
|
|
579
|
+
d = region_diff_ratio(a, b, box)
|
|
580
|
+
return {"match": bool(d <= tol), "diff": float(d), "tolerance": tol}
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def _track_piece(before: str, after: str, exclude=None) -> dict:
|
|
584
|
+
"""Where did the puzzle piece get to? See tool_calls/track_piece.py."""
|
|
585
|
+
from .tool_calls.track_piece import changed_bbox
|
|
586
|
+
|
|
587
|
+
bbox = changed_bbox(before, after, exclude)
|
|
588
|
+
return {"bbox": bbox, "moved": bbox is not None}
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def _handle_track_piece() -> bool:
|
|
592
|
+
"""`captchakraken track-piece before.png after.png [exclude_json]`
|
|
593
|
+
|
|
594
|
+
`exclude_json` is `[x1, y1, x2, y2]` in pixels — the slider handle, which is
|
|
595
|
+
moving too and would otherwise be measured instead of the piece.
|
|
596
|
+
|
|
597
|
+
Also available over the persistent worker as cmd `track-piece`, which is
|
|
598
|
+
what the driver should use: this runs mid-drag, several times, with the
|
|
599
|
+
mouse button held.
|
|
600
|
+
"""
|
|
601
|
+
if len(sys.argv) <= 1 or sys.argv[1] != "track-piece":
|
|
602
|
+
return False
|
|
603
|
+
if len(sys.argv) < 4:
|
|
604
|
+
print(json.dumps({
|
|
605
|
+
"error": "Usage: captchakraken track-piece before.png after.png [exclude_json]"
|
|
606
|
+
}), file=sys.stderr)
|
|
607
|
+
sys.exit(1)
|
|
608
|
+
exclude = json.loads(sys.argv[4]) if len(sys.argv) > 4 else None
|
|
609
|
+
print(json.dumps(_track_piece(sys.argv[2], sys.argv[3], exclude)))
|
|
610
|
+
return True
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def _handle_match_region() -> bool:
|
|
614
|
+
"""`captchakraken match-region ref.png live.png cx cy [tolerance]`
|
|
615
|
+
|
|
616
|
+
cx/cy are 0–1 fractions of the reference image (the centre of the action).
|
|
617
|
+
Also available over the persistent worker as cmd `match-region`, which is what
|
|
618
|
+
the driver should use — this is polled every ~120 ms while waiting.
|
|
619
|
+
"""
|
|
620
|
+
if len(sys.argv) <= 1 or sys.argv[1] != "match-region":
|
|
621
|
+
return False
|
|
622
|
+
if len(sys.argv) < 6:
|
|
623
|
+
print(json.dumps({
|
|
624
|
+
"error": "Usage: captchakraken match-region ref.png live.png cx cy [tolerance]"
|
|
625
|
+
}), file=sys.stderr)
|
|
626
|
+
sys.exit(1)
|
|
627
|
+
tol = float(sys.argv[6]) if len(sys.argv) > 6 else None
|
|
628
|
+
print(json.dumps(_match_region(sys.argv[2], sys.argv[3],
|
|
629
|
+
float(sys.argv[4]), float(sys.argv[5]), tol)))
|
|
630
|
+
return True
|
|
631
|
+
|
|
632
|
+
|
|
439
633
|
def _handle_tool_commands() -> bool:
|
|
440
634
|
if len(sys.argv) <= 1:
|
|
441
635
|
return False
|
|
@@ -641,6 +835,12 @@ def main():
|
|
|
641
835
|
return
|
|
642
836
|
if _handle_tool_commands():
|
|
643
837
|
return
|
|
838
|
+
if _handle_solve_animated():
|
|
839
|
+
return
|
|
840
|
+
if _handle_match_region():
|
|
841
|
+
return
|
|
842
|
+
if _handle_track_piece():
|
|
843
|
+
return
|
|
644
844
|
|
|
645
845
|
parser = argparse.ArgumentParser(description="CaptchaKraken v2 (vLLM)")
|
|
646
846
|
parser.add_argument("image_path", help="Path to the captcha image or video")
|
|
@@ -679,6 +879,15 @@ def main():
|
|
|
679
879
|
"images'). Switches the grid prompt to a more aggressive variant that "
|
|
680
880
|
"instructs the LoRA to look at the full grid for tiles it missed.",
|
|
681
881
|
)
|
|
882
|
+
parser.add_argument(
|
|
883
|
+
"--text-mode",
|
|
884
|
+
action="store_true",
|
|
885
|
+
help="The widget has a text box, so the answer is a string to type rather "
|
|
886
|
+
"than a place to click. Sends the distorted-text prompt and skips grid "
|
|
887
|
+
"detection. Set by the Playwright wrapper from the DOM — the picture "
|
|
888
|
+
"alone does not distinguish a BotDetect code from an hCaptcha "
|
|
889
|
+
"'click the matching letter'.",
|
|
890
|
+
)
|
|
682
891
|
|
|
683
892
|
args = parser.parse_args()
|
|
684
893
|
|
|
@@ -693,6 +902,7 @@ def main():
|
|
|
693
902
|
args.image_path,
|
|
694
903
|
puzzle_source=args.puzzle_source,
|
|
695
904
|
retry_mode=args.retry_mode,
|
|
905
|
+
text_mode=args.text_mode,
|
|
696
906
|
)
|
|
697
907
|
|
|
698
908
|
if isinstance(result, list):
|
|
@@ -27,7 +27,7 @@ import json
|
|
|
27
27
|
import os
|
|
28
28
|
from functools import lru_cache
|
|
29
29
|
from pathlib import Path
|
|
30
|
-
from typing import Any, Dict
|
|
30
|
+
from typing import Any, Dict, Optional
|
|
31
31
|
|
|
32
32
|
_PINNED_MODEL_PATH = Path(__file__).with_name("pinned_model.json")
|
|
33
33
|
|
|
@@ -176,24 +176,58 @@ def api_key() -> str:
|
|
|
176
176
|
|
|
177
177
|
|
|
178
178
|
# ── Model identity (all overridable — the CLI itself is model-agnostic) ──────
|
|
179
|
+
#
|
|
180
|
+
# Precedence, for every value below:
|
|
181
|
+
#
|
|
182
|
+
# 1. The environment. An explicit override always wins — this is how you PIN
|
|
183
|
+
# to a specific model, and pinning is opt-in by design.
|
|
184
|
+
# 2. models.json's `latest` entry. The default moves forward with the
|
|
185
|
+
# registry, so "the default download" and "the default prompt" advance
|
|
186
|
+
# together and cannot get out of step: prompts.resolve() reads the SAME
|
|
187
|
+
# entry to decide which prompt generation to send.
|
|
188
|
+
# 3. pinned_model.json. The record of what this release was validated
|
|
189
|
+
# against, kept as the fallback so an older checkout or a hand-edited
|
|
190
|
+
# manifest still resolves.
|
|
191
|
+
#
|
|
192
|
+
# Order 2-before-3 is the behaviour change: `latest` leads. Today both name
|
|
193
|
+
# CaptchaKraken_v1.1, so nothing moves until a newer model is registered — at
|
|
194
|
+
# which point that IS the intent.
|
|
195
|
+
def _registry_default(field: str) -> Optional[str]:
|
|
196
|
+
"""`field` from the registry's `latest` model entry, or None."""
|
|
197
|
+
try:
|
|
198
|
+
from . import prompts # local: prompts imports nothing from config
|
|
199
|
+
|
|
200
|
+
entry = prompts.registered_models().get(prompts.latest_model() or "")
|
|
201
|
+
value = (entry or {}).get(field)
|
|
202
|
+
return value if isinstance(value, str) else None
|
|
203
|
+
except Exception: # noqa: BLE001 — a broken registry falls back, never raises
|
|
204
|
+
return None
|
|
205
|
+
|
|
206
|
+
|
|
179
207
|
def base_model() -> str:
|
|
180
|
-
return os.getenv("CAPTCHA_BASE_MODEL"
|
|
208
|
+
return os.getenv("CAPTCHA_BASE_MODEL") or _registry_default("base_model") \
|
|
209
|
+
or pinned()["base_model"]
|
|
181
210
|
|
|
182
211
|
|
|
183
212
|
def lora_adapter() -> str:
|
|
184
213
|
"""HF repo id or local path of the captcha adapter served on top of the base."""
|
|
185
|
-
|
|
214
|
+
from . import prompts
|
|
215
|
+
|
|
216
|
+
return (os.getenv("CAPTCHA_LORA_ADAPTER") or prompts.latest_model()
|
|
217
|
+
or pinned()["lora_adapter"])
|
|
186
218
|
|
|
187
219
|
|
|
188
220
|
def lora_revision() -> str:
|
|
189
221
|
"""Git revision of the adapter repo to serve — pinning this is what makes
|
|
190
222
|
'the adapter this release was tested against' a reproducible statement."""
|
|
191
|
-
return os.getenv("CAPTCHA_LORA_REVISION"
|
|
223
|
+
return os.getenv("CAPTCHA_LORA_REVISION") or _registry_default("lora_revision") \
|
|
224
|
+
or pinned()["lora_revision"]
|
|
192
225
|
|
|
193
226
|
|
|
194
227
|
def lora_name() -> str:
|
|
195
228
|
"""The served LoRA name the client sends as the `model` field."""
|
|
196
|
-
return os.getenv("CAPTCHA_LORA_NAME"
|
|
229
|
+
return os.getenv("CAPTCHA_LORA_NAME") or _registry_default("lora_name") \
|
|
230
|
+
or pinned()["lora_name"]
|
|
197
231
|
|
|
198
232
|
|
|
199
233
|
# ── Local vLLM server knobs ─────────────────────────────────────────────────
|