captchakraken 2.3.0 → 2.6.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/LICENSE +47 -7
- package/README.md +24 -4
- package/dist/cli-invocation.d.ts +46 -0
- package/dist/cli-invocation.js +68 -0
- package/dist/errors.d.ts +45 -0
- package/dist/errors.js +74 -0
- package/dist/index.d.ts +50 -0
- package/dist/index.js +52 -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 +214 -12
- package/dist/solver.js +1297 -165
- package/dist/trajectory.d.ts +31 -0
- package/dist/trajectory.js +162 -0
- package/dist/types.d.ts +128 -11
- package/dist/watcher.d.ts +99 -0
- package/dist/watcher.js +129 -0
- package/package.json +2 -5
- package/python/LICENSE +47 -7
- package/python/README.md +25 -8
- 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 +4 -3
- package/python/src/captchakraken/__init__.py +5 -3
- package/python/src/captchakraken/action_types.py +24 -3
- package/python/src/captchakraken/cli.py +224 -2
- package/python/src/captchakraken/config.py +119 -17
- package/python/src/captchakraken/errors.py +195 -0
- package/python/src/captchakraken/keyframes.py +690 -0
- package/python/src/captchakraken/models.json +120 -0
- package/python/src/captchakraken/page_solver.py +1465 -102
- package/python/src/captchakraken/pinned_model.json +5 -7
- package/python/src/captchakraken/planner.py +328 -73
- 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
package/python/pyproject.toml
CHANGED
|
@@ -4,13 +4,14 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "captchakraken"
|
|
7
|
-
version = "2.
|
|
7
|
+
version = "2.6.0"
|
|
8
8
|
description = "Self-hosted captcha solver: OpenCV grid detection + a fine-tuned Qwen3.5-9B vision LoRA served 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
17
|
keywords = ["captcha", "recaptcha", "hcaptcha", "vllm", "qwen", "computer-vision", "automation"]
|
|
@@ -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,7 +68,9 @@ import json
|
|
|
55
68
|
import os
|
|
56
69
|
import subprocess
|
|
57
70
|
import sys
|
|
71
|
+
from typing import Optional
|
|
58
72
|
|
|
73
|
+
from .errors import CaptchaKrakenAPIError
|
|
59
74
|
from .solver import CaptchaSolver, UnsupportedCaptchaError
|
|
60
75
|
from .timing import timed
|
|
61
76
|
|
|
@@ -380,6 +395,8 @@ def _handle_serve() -> bool:
|
|
|
380
395
|
grid-cell-states {a, b} -> states | {grid: null}
|
|
381
396
|
grid-cell-states-fixed {a, b, grid_boxes} -> states
|
|
382
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}
|
|
383
400
|
|
|
384
401
|
Unknown cmd / malformed line -> an {ok:false} response (the process keeps
|
|
385
402
|
running). EOF on stdin ends the loop cleanly. All heavy detection delegates
|
|
@@ -413,6 +430,18 @@ def _handle_serve() -> bool:
|
|
|
413
430
|
threshold = float(req.get("threshold", 0.005))
|
|
414
431
|
moved = ImageProcessor.detect_movement(req["a"], req["b"], threshold)
|
|
415
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"))
|
|
416
445
|
raise ValueError(f"unknown cmd: {cmd!r}")
|
|
417
446
|
|
|
418
447
|
# Signal readiness so the JS side knows imports are done before it polls.
|
|
@@ -435,6 +464,172 @@ def _handle_serve() -> bool:
|
|
|
435
464
|
return True
|
|
436
465
|
|
|
437
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
|
+
|
|
438
633
|
def _handle_tool_commands() -> bool:
|
|
439
634
|
if len(sys.argv) <= 1:
|
|
440
635
|
return False
|
|
@@ -640,6 +835,12 @@ def main():
|
|
|
640
835
|
return
|
|
641
836
|
if _handle_tool_commands():
|
|
642
837
|
return
|
|
838
|
+
if _handle_solve_animated():
|
|
839
|
+
return
|
|
840
|
+
if _handle_match_region():
|
|
841
|
+
return
|
|
842
|
+
if _handle_track_piece():
|
|
843
|
+
return
|
|
643
844
|
|
|
644
845
|
parser = argparse.ArgumentParser(description="CaptchaKraken v2 (vLLM)")
|
|
645
846
|
parser.add_argument("image_path", help="Path to the captcha image or video")
|
|
@@ -678,6 +879,15 @@ def main():
|
|
|
678
879
|
"images'). Switches the grid prompt to a more aggressive variant that "
|
|
679
880
|
"instructs the LoRA to look at the full grid for tiles it missed.",
|
|
680
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
|
+
)
|
|
681
891
|
|
|
682
892
|
args = parser.parse_args()
|
|
683
893
|
|
|
@@ -692,6 +902,7 @@ def main():
|
|
|
692
902
|
args.image_path,
|
|
693
903
|
puzzle_source=args.puzzle_source,
|
|
694
904
|
retry_mode=args.retry_mode,
|
|
905
|
+
text_mode=args.text_mode,
|
|
695
906
|
)
|
|
696
907
|
|
|
697
908
|
if isinstance(result, list):
|
|
@@ -707,6 +918,17 @@ def main():
|
|
|
707
918
|
# checkboxes. Emit a clean error with no traceback.
|
|
708
919
|
print(json.dumps({"error": str(e), "unsupported": True}), file=sys.stderr)
|
|
709
920
|
sys.exit(2)
|
|
921
|
+
except CaptchaKrakenAPIError as e:
|
|
922
|
+
# The hosted API refused us and told us why. That is an answer, not a
|
|
923
|
+
# crash, so no traceback: a stack trace here buries the one line the
|
|
924
|
+
# user needs ("out of credits, top up at …") under frames from a module
|
|
925
|
+
# they have never opened.
|
|
926
|
+
#
|
|
927
|
+
# Exit 3, distinct from 1 (genuine failure) and 2 (unsupported puzzle),
|
|
928
|
+
# and the payload carries the machine-readable fields so the JS driver
|
|
929
|
+
# can rebuild the error rather than regex it out of prose.
|
|
930
|
+
print(json.dumps(e.to_payload()), file=sys.stderr)
|
|
931
|
+
sys.exit(3)
|
|
710
932
|
except Exception as e:
|
|
711
933
|
import traceback
|
|
712
934
|
|
|
@@ -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
|
|
|
@@ -47,8 +47,31 @@ def pinned() -> Dict[str, Any]:
|
|
|
47
47
|
|
|
48
48
|
# ── Inference endpoint ──────────────────────────────────────────────────────
|
|
49
49
|
def base_url() -> str:
|
|
50
|
-
"""OpenAI-compatible endpoint, e.g. http://localhost:8000/v1.
|
|
51
|
-
|
|
50
|
+
"""OpenAI-compatible endpoint, e.g. http://localhost:8000/v1.
|
|
51
|
+
|
|
52
|
+
Precedence mirrors `api_key()` exactly — env, then the credentials file,
|
|
53
|
+
then the local default — because the two values are set together and a
|
|
54
|
+
reader who has learned one ordering should not have to learn a second.
|
|
55
|
+
|
|
56
|
+
1. VLLM_BASE_URL. An explicit override always wins; self-hosters and the
|
|
57
|
+
dev environment depend on this and must not be silently redirected.
|
|
58
|
+
2. The credentials file. Written by the MCP signup flow alongside the key,
|
|
59
|
+
so onboarding configures the endpoint in the same step and a hosted
|
|
60
|
+
user needs no env plumbing at all.
|
|
61
|
+
3. localhost. Unchanged, and still correct: someone with no credentials
|
|
62
|
+
file is by definition not a hosted user, so the self-hosted default is
|
|
63
|
+
the right guess for them.
|
|
64
|
+
|
|
65
|
+
The bug this closes: a camoufox user who signed up through the MCP had a
|
|
66
|
+
valid key and no endpoint, so every solve dialled a local port with nothing
|
|
67
|
+
behind it and failed with connection-refused — a message that says nothing
|
|
68
|
+
about the fact that their requests were meant to go to api.captchakraken.com.
|
|
69
|
+
"""
|
|
70
|
+
return (
|
|
71
|
+
os.getenv("VLLM_BASE_URL")
|
|
72
|
+
or _base_url_from_credentials_file()
|
|
73
|
+
or f"http://localhost:{port()}/v1"
|
|
74
|
+
)
|
|
52
75
|
|
|
53
76
|
|
|
54
77
|
def state_dir() -> Path:
|
|
@@ -60,38 +83,83 @@ def credentials_path() -> Path:
|
|
|
60
83
|
return state_dir() / "credentials"
|
|
61
84
|
|
|
62
85
|
|
|
63
|
-
|
|
64
|
-
|
|
86
|
+
# Names the credentials file may use for each value. Two spellings each, because
|
|
87
|
+
# the file doubles as something a user can `source` — the VLLM_* names are the
|
|
88
|
+
# env vars this module already honours, and the CAPTCHA_KRAKEN_* names are what
|
|
89
|
+
# the product calls them. Accepting both costs nothing and spares anyone the
|
|
90
|
+
# discovery that they picked the wrong one.
|
|
91
|
+
_KEY_NAMES = ("CAPTCHA_KRAKEN_API_KEY", "VLLM_API_KEY")
|
|
92
|
+
_BASE_URL_NAMES = ("CAPTCHA_KRAKEN_BASE_URL", "VLLM_BASE_URL")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _read_credentials_file() -> Dict[str, str]:
|
|
96
|
+
"""Parse the file the MCP server's signup flow writes.
|
|
65
97
|
|
|
66
98
|
Hosted-API onboarding writes the key to a 0600 file rather than returning it
|
|
67
99
|
through the agent transcript, so it never lands in an LLM context window.
|
|
68
100
|
Reading it here is what lets a solve authenticate with NO env vars set.
|
|
69
101
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
102
|
+
Returns a mapping of recognised name -> value. The special key ``""`` holds a
|
|
103
|
+
BARE TOKEN — a file containing nothing but `ck_live_…`, which is what the
|
|
104
|
+
original single-value format was and what a user hand-writing this file is
|
|
105
|
+
most likely to produce. Dropping that would silently break every existing
|
|
106
|
+
credentials file, so it is still accepted and still means "the API key".
|
|
107
|
+
|
|
108
|
+
Deliberately tolerant of quoting and comments: the file is written by a
|
|
109
|
+
*separate* tool, and a format mismatch here surfaces as a baffling 401
|
|
110
|
+
rather than an obvious parse error. Returns {} whenever the file is missing
|
|
111
|
+
or unreadable — the self-hosted case, which must stay silent.
|
|
74
112
|
"""
|
|
75
113
|
try:
|
|
76
114
|
text = credentials_path().read_text(encoding="utf-8")
|
|
77
115
|
except OSError:
|
|
78
|
-
return
|
|
116
|
+
return {}
|
|
79
117
|
|
|
118
|
+
found: Dict[str, str] = {}
|
|
80
119
|
for line in text.splitlines():
|
|
81
120
|
line = line.strip()
|
|
82
121
|
if not line or line.startswith("#"):
|
|
83
122
|
continue
|
|
84
123
|
if "=" in line:
|
|
85
124
|
name, _, value = line.partition("=")
|
|
125
|
+
name = name.strip()
|
|
86
126
|
# An env-file with unrelated keys must not yield a bogus token.
|
|
87
|
-
if name
|
|
127
|
+
if name not in _KEY_NAMES and name not in _BASE_URL_NAMES:
|
|
88
128
|
continue
|
|
89
|
-
|
|
90
|
-
|
|
129
|
+
found.setdefault(name, value.strip().strip("'\""))
|
|
130
|
+
elif "" not in found:
|
|
131
|
+
# A bare token. Only the first such line counts; later prose in a
|
|
132
|
+
# hand-edited file must not overwrite the credential.
|
|
133
|
+
found[""] = line.strip("'\"")
|
|
134
|
+
|
|
135
|
+
return found
|
|
136
|
+
|
|
91
137
|
|
|
138
|
+
def _first_present(values: Dict[str, str], names) -> str:
|
|
139
|
+
"""First non-empty value among `names`, in the order given."""
|
|
140
|
+
for name in names:
|
|
141
|
+
value = values.get(name, "").strip()
|
|
142
|
+
if value:
|
|
143
|
+
return value
|
|
92
144
|
return ""
|
|
93
145
|
|
|
94
146
|
|
|
147
|
+
def _key_from_credentials_file() -> str:
|
|
148
|
+
"""The API key from the credentials file, or "" if there isn't one."""
|
|
149
|
+
values = _read_credentials_file()
|
|
150
|
+
return _first_present(values, _KEY_NAMES) or values.get("", "").strip()
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _base_url_from_credentials_file() -> str:
|
|
154
|
+
"""The endpoint from the credentials file, or "" if it names none.
|
|
155
|
+
|
|
156
|
+
A bare-token file deliberately yields nothing here: it carries no endpoint,
|
|
157
|
+
and inventing the hosted one for it would silently redirect a self-hoster
|
|
158
|
+
who wrote a token into that file by hand.
|
|
159
|
+
"""
|
|
160
|
+
return _first_present(_read_credentials_file(), _BASE_URL_NAMES)
|
|
161
|
+
|
|
162
|
+
|
|
95
163
|
def api_key() -> str:
|
|
96
164
|
"""Bearer token, in precedence order.
|
|
97
165
|
|
|
@@ -108,24 +176,58 @@ def api_key() -> str:
|
|
|
108
176
|
|
|
109
177
|
|
|
110
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
|
+
|
|
111
207
|
def base_model() -> str:
|
|
112
|
-
return os.getenv("CAPTCHA_BASE_MODEL"
|
|
208
|
+
return os.getenv("CAPTCHA_BASE_MODEL") or _registry_default("base_model") \
|
|
209
|
+
or pinned()["base_model"]
|
|
113
210
|
|
|
114
211
|
|
|
115
212
|
def lora_adapter() -> str:
|
|
116
213
|
"""HF repo id or local path of the captcha adapter served on top of the base."""
|
|
117
|
-
|
|
214
|
+
from . import prompts
|
|
215
|
+
|
|
216
|
+
return (os.getenv("CAPTCHA_LORA_ADAPTER") or prompts.latest_model()
|
|
217
|
+
or pinned()["lora_adapter"])
|
|
118
218
|
|
|
119
219
|
|
|
120
220
|
def lora_revision() -> str:
|
|
121
221
|
"""Git revision of the adapter repo to serve — pinning this is what makes
|
|
122
222
|
'the adapter this release was tested against' a reproducible statement."""
|
|
123
|
-
return os.getenv("CAPTCHA_LORA_REVISION"
|
|
223
|
+
return os.getenv("CAPTCHA_LORA_REVISION") or _registry_default("lora_revision") \
|
|
224
|
+
or pinned()["lora_revision"]
|
|
124
225
|
|
|
125
226
|
|
|
126
227
|
def lora_name() -> str:
|
|
127
228
|
"""The served LoRA name the client sends as the `model` field."""
|
|
128
|
-
return os.getenv("CAPTCHA_LORA_NAME"
|
|
229
|
+
return os.getenv("CAPTCHA_LORA_NAME") or _registry_default("lora_name") \
|
|
230
|
+
or pinned()["lora_name"]
|
|
129
231
|
|
|
130
232
|
|
|
131
233
|
# ── Local vLLM server knobs ─────────────────────────────────────────────────
|