captchakraken 2.6.0 → 2.8.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.
Files changed (36) hide show
  1. package/README.md +126 -16
  2. package/dist/cli-invocation.d.ts +2 -0
  3. package/dist/cli-invocation.js +5 -1
  4. package/dist/errors.d.ts +1 -1
  5. package/dist/humanize.d.ts +377 -0
  6. package/dist/humanize.js +716 -0
  7. package/dist/index.d.ts +33 -0
  8. package/dist/index.js +44 -1
  9. package/dist/playwright-types.d.ts +18 -0
  10. package/dist/solver.d.ts +115 -5
  11. package/dist/solver.js +583 -190
  12. package/dist/timing.d.ts +48 -0
  13. package/dist/timing.js +103 -0
  14. package/dist/trajectory.d.ts +27 -0
  15. package/dist/trajectory.js +104 -0
  16. package/dist/types.d.ts +129 -0
  17. package/package.json +11 -3
  18. package/python/README.md +76 -5
  19. package/python/pyproject.toml +21 -4
  20. package/python/src/captchakraken/__init__.py +36 -1
  21. package/python/src/captchakraken/cli.py +62 -4
  22. package/python/src/captchakraken/config.py +28 -2
  23. package/python/src/captchakraken/errors.py +24 -0
  24. package/python/src/captchakraken/humanize.py +929 -0
  25. package/python/src/captchakraken/keyframes.py +99 -0
  26. package/python/src/captchakraken/models.json +149 -13
  27. package/python/src/captchakraken/page_solver.py +588 -291
  28. package/python/src/captchakraken/pinned_model.json +3 -2
  29. package/python/src/captchakraken/planner.py +118 -9
  30. package/python/src/captchakraken/prompts.py +182 -0
  31. package/python/src/captchakraken/server_manager.py +9 -4
  32. package/python/src/captchakraken/solver.py +97 -57
  33. package/python/src/captchakraken/timing.py +8 -3
  34. package/python/src/captchakraken/tool_calls/find_grid.py +357 -20
  35. package/python/src/captchakraken/trajectory.py +117 -0
  36. package/python/src/captchakraken/updater.py +62 -1
@@ -32,6 +32,27 @@ from .action_types import (
32
32
  from .image_processor import ImageProcessor
33
33
  from .overlay import add_overlays_to_image
34
34
 
35
+ # The hosted API's refusal type. Outside the try below on purpose: `errors`
36
+ # imports nothing but `typing`, and docs/hosted-api.md tells every caller to
37
+ # branch on its `.code` — a recipe that has to work in both ports, and in a
38
+ # minimal install that has no serving stack.
39
+ from .errors import CaptchaKrakenAPIError
40
+
41
+ # How gestures are PERFORMED — mouse, touch, or nothing. Deliberately outside
42
+ # the try below: `humanize` imports only `trajectory`, so it has no dependency
43
+ # floor at all, and a caller writing a custom Humanizer must be able to import
44
+ # the base class without the serving stack installed.
45
+ from .humanize import (
46
+ AppiumTouchBackend,
47
+ CdpTouchBackend,
48
+ Humanizer,
49
+ MobileHumanizer,
50
+ MouseHumanizer,
51
+ NullHumanizer,
52
+ TouchBackend,
53
+ TouchscreenTouchBackend,
54
+ )
55
+
35
56
  # The planner (requests) and solver (torch/vllm/transformers) pull in the heavy
36
57
  # serving stack. Keep them optional so leaf modules — e.g. tool_calls.find_grid,
37
58
  # which needs only cv2 + numpy + pillow — can be imported in a minimal env (CI's
@@ -70,6 +91,20 @@ __all__ = [
70
91
  "TypeAction",
71
92
  "WaitAction",
72
93
  "add_overlays_to_image",
94
+ "CaptchaKrakenAPIError",
95
+ "Humanizer",
96
+ "MouseHumanizer",
97
+ "MobileHumanizer",
98
+ "NullHumanizer",
99
+ "TouchBackend",
100
+ "CdpTouchBackend",
101
+ "AppiumTouchBackend",
102
+ "TouchscreenTouchBackend",
73
103
  ]
74
104
 
75
- __version__ = "2.6.0"
105
+ # KEEP IN STEP with python/pyproject.toml and js/package.json — the two ports
106
+ # ship together (rule 1c) and this is the only one importable code can read.
107
+ # It said 2.6.0 while the wheel on PyPI said 2.6.1, so anyone gating on the
108
+ # runtime attribute saw a version that had not been current for weeks.
109
+ # tests/test_public_contract.py compares all three.
110
+ __version__ = "2.8.0"
@@ -505,6 +505,8 @@ def _handle_solve_animated() -> bool:
505
505
  "rate the training data was collected at).")
506
506
  parser.add_argument("--model", default=None)
507
507
  parser.add_argument("--api-key", default=None)
508
+ parser.add_argument("--expert", default=None,
509
+ choices=["pixel", "grid", "video", "text"])
508
510
  args = parser.parse_args(sys.argv[2:])
509
511
 
510
512
  frames = _frames_in(args.frames_dir)
@@ -527,7 +529,8 @@ def _handle_solve_animated() -> bool:
527
529
  paths = [str(os.path.abspath(p))
528
530
  for p in write_keyframes(kfset, kf_dir, stem="challenge")]
529
531
 
530
- solver = CaptchaSolver(model=args.model, api_key=args.api_key)
532
+ solver = CaptchaSolver(model=args.model, api_key=args.api_key,
533
+ expert=args.expert)
531
534
  result = solver.solve_keyframes(paths)
532
535
 
533
536
  action_data = [a.model_dump() for a in result]
@@ -630,6 +633,44 @@ def _handle_match_region() -> bool:
630
633
  return True
631
634
 
632
635
 
636
+ def _handle_report_outcome() -> bool:
637
+ """`captchakraken report-outcome <session-id> solved|failed`
638
+
639
+ Tell the hosted API whether the widget accepted this solve. The one fact the
640
+ server cannot see for itself: a wrong answer arrives there as a 200 with
641
+ well-formed JSON in it, and only the driver watched the widget.
642
+
643
+ THIS EXISTS FOR THE TypeScript PORT. The Python driver calls
644
+ `planner.report_outcome` in-process at the end of `PageSolver.solve`; the TS
645
+ driver spawns the CLI, which is the same subprocess boundary every other
646
+ model call already crosses. One implementation of the endpoint, the
647
+ credential and the extra headers, rather than a second copy in TypeScript
648
+ that agrees with this one until one of them is edited — the drift
649
+ `model-name.ts` already exists to prevent, on the same seam.
650
+
651
+ ALWAYS EXITS 0. The caller is a `finally` block in a driver whose solve has
652
+ already finished, so there is no outcome here worth failing over, and a
653
+ non-zero exit would make a self-hosted user's logs report an error on every
654
+ solve. What happened is on stdout for anyone who wants it.
655
+ """
656
+ if len(sys.argv) <= 1 or sys.argv[1] != "report-outcome":
657
+ return False
658
+ if len(sys.argv) < 4 or sys.argv[3] not in ("solved", "failed"):
659
+ print(json.dumps({
660
+ "error": "Usage: captchakraken report-outcome <session-id> solved|failed"
661
+ }), file=sys.stderr)
662
+ sys.exit(1)
663
+ delivered = False
664
+ try:
665
+ from .planner import ActionPlanner
666
+ delivered = ActionPlanner().report_outcome(sys.argv[2], sys.argv[3] == "solved")
667
+ except Exception as exc: # noqa: BLE001 — see the docstring
668
+ print(json.dumps({"reported": False, "error": str(exc)}))
669
+ return True
670
+ print(json.dumps({"reported": bool(delivered)}))
671
+ return True
672
+
673
+
633
674
  def _handle_tool_commands() -> bool:
634
675
  if len(sys.argv) <= 1:
635
676
  return False
@@ -841,6 +882,8 @@ def main():
841
882
  return
842
883
  if _handle_track_piece():
843
884
  return
885
+ if _handle_report_outcome():
886
+ return
844
887
 
845
888
  parser = argparse.ArgumentParser(description="CaptchaKraken v2 (vLLM)")
846
889
  parser.add_argument("image_path", help="Path to the captcha image or video")
@@ -867,8 +910,11 @@ def main():
867
910
  "--puzzle-source",
868
911
  default="unknown",
869
912
  choices=["hcaptcha", "recaptcha", "unknown"],
870
- help="Vendor hint from the Playwright wrapper. hCaptcha skips grid detection "
871
- "(find_grid false-positives on the header/footer bands of click puzzles).",
913
+ help="Vendor hint from the Playwright wrapper. Constrains which grid "
914
+ "shapes a detection may be solved as: hCaptcha ships only a 3x3, so a "
915
+ "16-cell lattice on one is a false positive (find_grid latches onto the "
916
+ "header/footer bands of click puzzles). An unrecognised or absent hint "
917
+ "allows every shape, which is what GeeTest and Prosopo boards report.",
872
918
  )
873
919
  parser.add_argument(
874
920
  "--retry-mode",
@@ -879,6 +925,17 @@ def main():
879
925
  "images'). Switches the grid prompt to a more aggressive variant that "
880
926
  "instructs the LoRA to look at the full grid for tiles it missed.",
881
927
  )
928
+ parser.add_argument(
929
+ "--expert",
930
+ default=None,
931
+ choices=["pixel", "grid", "video", "text"],
932
+ help="Force one expert of a ROUTED model (Abyss) instead of letting the "
933
+ "prompt family choose. Routing is automatic and this is the override a "
934
+ "benchmark needs: serve one arm, grade the types it owns, serve the "
935
+ "next. Refused against a model that serves a single adapter, because "
936
+ "quietly measuring the generalist and reporting it as the expert is a "
937
+ "number nobody can catch. Also settable as CAPTCHA_EXPERT.",
938
+ )
882
939
  parser.add_argument(
883
940
  "--text-mode",
884
941
  action="store_true",
@@ -897,7 +954,8 @@ def main():
897
954
 
898
955
  try:
899
956
  with timed("cli.total"):
900
- solver = CaptchaSolver(model=args.model, api_key=args.api_key)
957
+ solver = CaptchaSolver(model=args.model, api_key=args.api_key,
958
+ expert=args.expert)
901
959
  result = solver.solve(
902
960
  args.image_path,
903
961
  puzzle_source=args.puzzle_source,
@@ -193,11 +193,37 @@ def api_key() -> str:
193
193
  # CaptchaKraken_v1.1, so nothing moves until a newer model is registered — at
194
194
  # which point that IS the intent.
195
195
  def _registry_default(field: str) -> Optional[str]:
196
- """`field` from the registry's `latest` model entry, or None."""
196
+ """`field` from the entry of the model this process will actually serve.
197
+
198
+ THE PIN DECIDES THE ENTRY, not just the adapter. This read `latest`
199
+ unconditionally, so `CAPTCHA_LORA_ADAPTER` moved the adapter and nothing
200
+ else: the base weights to load it onto, the served `lora_name` to put in
201
+ the request, and the revision all still came from whichever model happened
202
+ to be `latest`.
203
+
204
+ That was survivable while every registered model was a 9B on one base
205
+ answering to one served name. It stopped being survivable the day the
206
+ registry gained `CaptchaKraken/Abyss-27B` — a Qwen3.8-**27B** — because
207
+ pinning it downloaded a 9B base, tried to load a 27B adapter onto it, and
208
+ sent `captcha-v12` as the model name. Nothing in the client errors on that;
209
+ it errors deep inside vLLM as a shape mismatch, or not at all if the
210
+ endpoint happens to serve something by that name. Same shape for every
211
+ expert arm, whose whole point is a `lora_name` of its own.
212
+
213
+ An UNREGISTERED pin still falls back to `latest`. A self-hoster's own
214
+ adapter is not in our registry and never will be; changing what they
215
+ resolve to would be a break with no benefit.
216
+ """
197
217
  try:
198
218
  from . import prompts # local: prompts imports nothing from config
199
219
 
200
- entry = prompts.registered_models().get(prompts.latest_model() or "")
220
+ models = prompts.registered_models()
221
+ entry = None
222
+ pin = os.getenv("CAPTCHA_LORA_ADAPTER") or os.getenv("CAPTCHA_LORA_NAME")
223
+ if pin:
224
+ entry = models.get(prompts.canonical_model_id(pin) or "")
225
+ if entry is None:
226
+ entry = models.get(prompts.latest_model() or "")
201
227
  value = (entry or {}).get(field)
202
228
  return value if isinstance(value, str) else None
203
229
  except Exception: # noqa: BLE001 — a broken registry falls back, never raises
@@ -149,6 +149,30 @@ def _sentence(code: str, message: str, url: Optional[str], retry: Optional[float
149
149
  "our side, not yours — retry shortly."
150
150
  )
151
151
 
152
+ # The two LICENSED-MODEL refusals. Both are 'you named a model', and they
153
+ # are separate codes because the fix is completely different: one is a
154
+ # licence to obtain, the other is a fleet that has not started serving yet
155
+ # and nothing for the caller to do. Generic prose collapses them into "the
156
+ # solve failed", which is the reading that sends someone to buy a licence
157
+ # they already hold.
158
+ if code == "model_not_licensed":
159
+ return (
160
+ "CaptchaKraken: the model this request named is licensed, and this "
161
+ "account is not licensed for it. The request was refused rather than "
162
+ "answered by a different model — a silent substitution would be a "
163
+ "score you could not explain. Unset CAPTCHA_LORA_NAME (or the "
164
+ f"client's `model`) to use the standard hosted model. {message} "
165
+ f"Licensing: {url or _SUPPORT}."
166
+ )
167
+
168
+ if code == "model_not_serving":
169
+ return (
170
+ "CaptchaKraken: this account IS licensed for the model it named, but "
171
+ "the fleet is not serving it yet. Nothing is wrong with your account "
172
+ "and there is nothing to buy. Unset CAPTCHA_LORA_NAME (or the "
173
+ f"client's `model`) to use the standard hosted model meanwhile. {message}"
174
+ )
175
+
152
176
  # `unrecognized_prompt`, `invalid_request`, and anything added after this was
153
177
  # written. The server's own message is the best thing available, so it is
154
178
  # carried through verbatim rather than replaced with a guess.