captchakraken 3.2.0 → 4.0.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 (60) hide show
  1. package/AGENTS.md +5 -9
  2. package/LICENSE +165 -145
  3. package/NOTICE +4 -4
  4. package/README.md +10 -11
  5. package/dist/cli-invocation.d.ts +6 -3
  6. package/dist/cli-invocation.js +26 -10
  7. package/dist/humanize.d.ts +37 -1
  8. package/dist/humanize.js +143 -9
  9. package/dist/index.d.ts +13 -12
  10. package/dist/index.js +25 -33
  11. package/dist/kinds.d.ts +15 -2
  12. package/dist/kinds.js +12 -4
  13. package/dist/playwright-types.d.ts +3 -0
  14. package/dist/selectors.d.ts +96 -11
  15. package/dist/selectors.js +226 -112
  16. package/dist/solver.d.ts +93 -13
  17. package/dist/solver.js +579 -208
  18. package/dist/types.d.ts +53 -8
  19. package/dist/verdicts.d.ts +3 -0
  20. package/dist/verdicts.js +6 -0
  21. package/package.json +4 -4
  22. package/python/AGENTS.md +7 -11
  23. package/python/LICENSE +165 -145
  24. package/python/NOTICE +4 -4
  25. package/python/README.md +10 -9
  26. package/python/pyproject.toml +27 -13
  27. package/python/src/captchakraken/__init__.py +43 -46
  28. package/python/src/captchakraken/action_types.py +32 -1
  29. package/python/src/captchakraken/captchakraken.config.json +416 -0
  30. package/python/src/captchakraken/cli.py +66 -30
  31. package/python/src/captchakraken/clipvideo.py +258 -0
  32. package/python/src/captchakraken/humanize.py +141 -10
  33. package/python/src/captchakraken/image_processor.py +3 -7
  34. package/python/src/captchakraken/keyframes.py +96 -14
  35. package/python/src/captchakraken/kinds.py +14 -2
  36. package/python/src/captchakraken/models.json +28 -3
  37. package/python/src/captchakraken/page_solver.py +621 -259
  38. package/python/src/captchakraken/planner.py +298 -47
  39. package/python/src/captchakraken/pointer/__init__.py +20 -0
  40. package/python/src/captchakraken/pointer/detector.py +333 -0
  41. package/python/src/captchakraken/pointer/elements.py +273 -0
  42. package/python/src/captchakraken/pointer/heads.py +163 -0
  43. package/python/src/captchakraken/pointer/layout.py +319 -0
  44. package/python/src/captchakraken/pointer/marks.py +184 -0
  45. package/python/src/captchakraken/pointer/server.py +146 -0
  46. package/python/src/captchakraken/pointer/service.py +553 -0
  47. package/python/src/captchakraken/pointer/vllm_plugin.py +109 -0
  48. package/python/src/captchakraken/prompts.py +111 -34
  49. package/python/src/captchakraken/selectors.py +224 -131
  50. package/python/src/captchakraken/server_manager.py +18 -0
  51. package/python/src/captchakraken/solver.py +169 -88
  52. package/python/src/captchakraken/tool_calls/board_painted.py +11 -6
  53. package/python/src/captchakraken/tool_calls/fill_tracker.py +152 -0
  54. package/python/src/captchakraken/tool_calls/find_checkbox.py +14 -59
  55. package/python/src/captchakraken/tool_calls/find_grid.py +273 -1191
  56. package/python/src/captchakraken/tool_calls/slide_tracker.py +1102 -0
  57. package/python/src/captchakraken/verdicts.py +16 -0
  58. package/dist/slide-geometry.d.ts +0 -6
  59. package/dist/slide-geometry.js +0 -38
  60. package/python/src/captchakraken/tool_calls/track_piece.py +0 -120
@@ -15,6 +15,10 @@ _DISABLE_FETCH_ENV = "CAPTCHA_PROMPTS_NO_FETCH"
15
15
 
16
16
  LATEST_PROMPT_VERSION = "2"
17
17
 
18
+ # The generations that show the model an animated captcha as one video (`clipvideo.py`) and read the moment it
19
+ # acts on as `"t"`, in seconds. Every earlier generation sends numbered keyframe stills and reads `"frame"`.
20
+ VIDEO_GENERATIONS = ("3", "4")
21
+
18
22
 
19
23
  # Pure literals only, no f-strings, joins or constant references: the training repo's release parity gate reads
20
24
  # this dict by AST without importing the package, and the gate going quiet is how prompt drift shipped once.
@@ -135,6 +139,63 @@ BUILTIN_PROMPTS = {
135
139
  "}"
136
140
  ),
137
141
  },
142
+ "3": {
143
+ "general": (
144
+ "Your task is to solve the captcha. Read its instruction, usually at the top, and decide "
145
+ "what a person would do to solve it.\n\n"
146
+ "You are given either one screenshot or a video. Each moment of a video is labelled with "
147
+ "its time, like <1.3 seconds>. In a video the answer may depend on how things move or "
148
+ "change: what grows or shrinks, which way something turns, what appears, disappears or "
149
+ "moves, and in what order.\n\n"
150
+ "Some screenshots have numbered boxes drawn over the things you can interact with — "
151
+ "tiles, buttons, pieces, handles. The boxes and numbers are ours, not part of the "
152
+ "puzzle. When something you act on has a box, also give its number as an id. Not "
153
+ "everything is boxed and many screenshots have no boxes at all: a point is always "
154
+ "required, an id never is.\n\n"
155
+ "Points are on a 0–1000 scale of the image: (0, 0) is the top-left corner, (1000, 1000) "
156
+ "the bottom-right.\n\n"
157
+ "Choose ONE action:\n\n"
158
+ "CLICK one or more things:\n"
159
+ " {\"action\": \"click\", \"points\": [[x, y], ...], \"ids\": [n, ...]}\n"
160
+ " When the instruction asks for every match, a tile that is already selected (a small "
161
+ "checkmark, border or highlight) still counts; a tile being replaced (a large checkmark, a "
162
+ "picture fading out or in) does not. If nothing matches, give empty points.\n\n"
163
+ "DRAG pieces, one drag per piece:\n"
164
+ " {\"action\": \"drag\", \"drags\": [{\"from\": [x, y], \"to\": [x, y], \"from_id\": n, "
165
+ "\"to_id\": n}, ...]}\n\n"
166
+ "SLIDE a slider: press its handle and move it until the part of the picture it moves "
167
+ "lines up with where it belongs — a jigsaw piece over its slot, a turned picture upright, "
168
+ "a shifted strip continuing the rest:\n"
169
+ " {\"action\": \"slide\", \"handle\": [x, y], \"from\": [x, y], \"to\": [x, y]}\n"
170
+ " \"handle\" is what you press, \"from\" a point on the part that moves, \"to\" where that "
171
+ "point belongs. When the piece itself is what you press, \"handle\" and \"from\" are the "
172
+ "same point.\n\n"
173
+ "PRESS AND HOLD a button. Holding fills something — a bar, a ring, the button itself — and "
174
+ "you let go where the fill should stop: a line or mark it must reach, else its far end:\n"
175
+ " {\"action\": \"hold\", \"press\": [x, y], \"from\": [x, y], \"to\": [x, y]}\n"
176
+ " \"press\" is what you hold, \"from\" where the fill starts, \"to\" where to let go.\n\n"
177
+ "TYPE the characters shown, exactly as printed — keep their case, add no spaces; "
178
+ "strike-throughs, dots and background texture are not characters:\n"
179
+ " {\"action\": \"type\", \"text\": \"...\"}\n\n"
180
+ "REFRESH instead of solving if the puzzle looks impossible, or would take a reasonable "
181
+ "person more than a minute. Click the control that loads a new puzzle, usually a "
182
+ "circular arrow (empty points if there is none):\n"
183
+ " {\"action\": \"refresh\", \"points\": [[x, y]]}\n\n"
184
+ "When there are several clicks or drags, list them in the order they must be done. If "
185
+ "the order does not matter, list them in increasing order: by number when the things are "
186
+ "numbered, otherwise top to bottom, and left to right within a row.\n\n"
187
+ "For a video, put \"t\" first: the time of the moment where your answer is clearest, in "
188
+ "seconds exactly as it is labelled, so the solver can wait for that moment. Leave it out "
189
+ "if the answer is the same throughout.\n\n"
190
+ "Leave out any key you have no value for. Respond ONLY with the JSON."
191
+ ),
192
+ },
193
+ # Generation 4 is the pointer (`captchakraken.pointer`): the board goes to the server's /pointer endpoint,
194
+ # which numbers its elements with the element detector, builds the prompt itself and reads the answer from one
195
+ # prefill. The client sends no prompt text, so there is none to drift.
196
+ "4": {
197
+ "pointer": True,
198
+ },
138
199
  }
139
200
 
140
201
 
@@ -213,43 +274,52 @@ def requires_auth(model: Optional[str]) -> bool:
213
274
 
214
275
  @dataclass
215
276
  class PromptSet:
277
+ """One generation's prompts. Generations 1 and 2 ask a prompt per family; generation 3 asks `general`, one
278
+ prompt for every puzzle, and has no family templates at all."""
216
279
 
217
280
  version: str
218
- action_prompt: str
219
- grid_template: str
281
+ action_prompt: Optional[str]
282
+ grid_template: Optional[str]
220
283
  video_template: Optional[str]
221
284
  text_template: Optional[str]
222
- grid_by_type: Dict[str, str]
223
285
  source: str
286
+ general: Optional[str] = None
287
+ sends_video: bool = False
288
+ #: Asked at /pointer with the bare board; the server owns the prompt (generation 4).
289
+ pointer: bool = False
224
290
 
225
- def grid_prompt(self, *, rows: int, cols: int, grid_hint: str = "",
226
- puzzle_type: Optional[str] = None) -> str:
227
- if puzzle_type and puzzle_type in self.grid_by_type:
228
- return self.grid_by_type[puzzle_type]
229
- return self.grid_template.format(rows=rows, cols=cols,
230
- total=rows * cols, grid_hint=grid_hint)
231
-
232
- def video_prompt(self, n_keyframes: int) -> str:
233
- n = int(n_keyframes)
291
+ def _family(self, template: Optional[str], family: str) -> str:
292
+ if template is None:
293
+ raise ValueError(
294
+ f"prompt generation {self.version} has no {family} prompt"
295
+ + (" — it asks one general prompt for every puzzle" if self.general else
296
+ f" — model was trained before the {family} family existed. Use a model on "
297
+ "generation 2 or later."))
298
+ return template
299
+
300
+ def pixel_prompt(self) -> str:
301
+ return self._family(self.action_prompt, "click-and-drag")
302
+
303
+ def grid_prompt(self, *, rows: int, cols: int, grid_hint: str = "") -> str:
304
+ return self._family(self.grid_template, "grid").format(rows=rows, cols=cols,
305
+ total=rows * cols, grid_hint=grid_hint)
306
+
307
+ def video_prompt(self, n_keyframes: Optional[int] = None) -> str:
308
+ """The animated prompt. A video generation asks its one prompt over the clip; a keyframe generation's
309
+ text states how many stills it sent."""
310
+ if self.sends_video:
311
+ return self.general if self.general is not None else self._family(self.video_template, "animated-puzzle")
312
+ if self.general is not None:
313
+ # Never trained on keyframes: sending them under the general prompt would answer silently worse.
314
+ raise ValueError(f"prompt generation {self.version} was never trained on keyframe stills")
315
+ n = int(n_keyframes or 0)
234
316
  if n < 1:
235
317
  raise ValueError(f"a keyframe request needs at least one frame, got {n_keyframes}")
236
- if self.video_template is None:
237
- raise ValueError(
238
- f"prompt generation {self.version} has no animated-puzzle prompt — "
239
- f"model was trained before the video family existed. Use a model on "
240
- f"generation 2 or later for animated captchas."
241
- )
242
318
  listing = ", ".join(f"frame {i}" for i in range(1, n + 1))
243
- return self.video_template.format(n=n, listing=listing)
319
+ return self._family(self.video_template, "animated-puzzle").format(n=n, listing=listing)
244
320
 
245
321
  def text_prompt(self) -> str:
246
- if self.text_template is None:
247
- raise ValueError(
248
- f"prompt generation {self.version} has no distorted-text prompt — "
249
- f"model was trained before the text family existed. Use a model on "
250
- f"generation 2 or later for BotDetect/MTCaptcha/Yandex text captchas."
251
- )
252
- return self.text_template
322
+ return self._family(self.text_template, "distorted-text")
253
323
 
254
324
 
255
325
  def builtin(version: str) -> Optional[PromptSet]:
@@ -258,12 +328,14 @@ def builtin(version: str) -> Optional[PromptSet]:
258
328
  return None
259
329
  return PromptSet(
260
330
  version=str(version),
261
- action_prompt=spec["action_pixel"],
262
- grid_template=spec["grid"],
263
- video_template=spec["video"],
264
- text_template=spec["text"],
265
- grid_by_type={},
331
+ action_prompt=spec.get("action_pixel"),
332
+ grid_template=spec.get("grid"),
333
+ video_template=spec.get("video"),
334
+ text_template=spec.get("text"),
266
335
  source=f"built-in v{version}",
336
+ general=spec.get("general"),
337
+ sends_video=str(version) in VIDEO_GENERATIONS,
338
+ pointer=bool(spec.get("pointer")),
267
339
  )
268
340
 
269
341
 
@@ -271,16 +343,17 @@ def _from_doc(doc: Dict[str, Any], source: str) -> PromptSet:
271
343
  version = str(doc.get("prompt_version", LATEST_PROMPT_VERSION))
272
344
  base = builtin(version) or builtin(LATEST_PROMPT_VERSION)
273
345
  templates = doc.get("templates") or {}
274
- grid_by_type = {k.split("grid::", 1)[1]: v for k, v in templates.items()
275
- if k.startswith("grid::") and isinstance(v, str)}
276
346
  return PromptSet(
277
347
  version=version,
278
348
  action_prompt=templates.get("action_pixel") or base.action_prompt,
279
349
  grid_template=templates.get("grid") or base.grid_template,
280
350
  video_template=templates.get("video") or base.video_template,
281
351
  text_template=templates.get("text") or base.text_template,
282
- grid_by_type=grid_by_type,
283
352
  source=source,
353
+ general=templates.get("general") or base.general,
354
+ # From the generation, never from the document: a template can be overridden, the way it is asked cannot.
355
+ sends_video=base.sends_video,
356
+ pointer=base.pointer,
284
357
  )
285
358
 
286
359
 
@@ -401,6 +474,10 @@ def experts(model: Optional[str]) -> Dict[PromptFamily, str]:
401
474
  mapping = entry.get("experts")
402
475
  if not isinstance(mapping, dict):
403
476
  return {}
477
+ if (builtin(str(entry.get("prompt_version", ""))) or builtin(LATEST_PROMPT_VERSION)).general is not None:
478
+ _warn(f"model {repo_id!r} is on prompt generation {entry.get('prompt_version')}, which is one model "
479
+ "asked one prompt; its `experts` are never routed to.")
480
+ return {}
404
481
  out: Dict[PromptFamily, str] = {}
405
482
  for family in PromptFamily:
406
483
  name = mapping.get(family)
@@ -1,10 +1,15 @@
1
- """Every selector the driver knows, keyed by vendor. Mirrored in js/src/selectors.ts; keep both in the same order,
2
- because detection takes the first visible match in table order."""
1
+ """Every selector the driver knows, keyed by vendor, read from `captchakraken.config.json` at the repository root.
2
+
3
+ The JS port reads the same file (js/src/selectors.ts). Detection takes the first visible match in table order, so the
4
+ file's order is the detection order."""
3
5
 
4
6
  from __future__ import annotations
5
7
 
8
+ import json
9
+ import os
6
10
  from dataclasses import dataclass, fields
7
- from typing import Mapping, NamedTuple, Optional, Sequence, Tuple
11
+ from pathlib import Path
12
+ from typing import Any, Dict, Mapping, NamedTuple, Optional, Sequence, Tuple, Union
8
13
 
9
14
  from .kinds import FrameRole, RecaptchaBanner, Vendor
10
15
 
@@ -18,6 +23,9 @@ class VendorSelectors:
18
23
  challenge: Sequence[str] = ()
19
24
  # On the host page: the checkbox. Detected while `response` is empty and `checked` is not showing.
20
25
  checkbox: Sequence[str] = ()
26
+ # On the host page: a frame the vendor draws its other frames inside. Every host-page selector also reads the
27
+ # inside of a visible one.
28
+ enclave: Sequence[str] = ()
21
29
  # On the host page: inline widget shapes, detected last. Iframe selectors go first; a class that lives inside the
22
30
  # frame document (.CheckboxCaptcha, .mtcap) only ever matches an inline embed.
23
31
  widget: Sequence[str] = ()
@@ -27,6 +35,7 @@ class VendorSelectors:
27
35
  # Inside the checkbox frame: the box shows accepted. Demo pages do not always fill `response`.
28
36
  checked: Optional[str] = None
29
37
  # Inside the checkbox frame: the box itself, clicked from the DOM. A vendor without one is found on a photograph.
38
+ # An in-page checkbox that names one is a button with no tick box to find, and is clicked where it is.
30
39
  box: Sequence[str] = ()
31
40
  # On the host page: the vendor painted its success state. Visibility is part of the test (see TRIBAL_KNOWLEDGE.md).
32
41
  accepted: Optional[str] = None
@@ -48,121 +57,157 @@ class VendorSelectors:
48
57
  slider_handle: Sequence[str] = ()
49
58
  # Inside the widget: the piece, measured to steer the slide and dragged directly when there is no track.
50
59
  piece: Sequence[str] = ()
60
+ # Inside the widget: the control that deals a new puzzle, pressed when the model answers `refresh`.
61
+ refresh: Sequence[str] = ()
62
+ # A press-and-hold that completes on its own once full, and resets when let go before it has: the fill reaching
63
+ # the answer's `to` is not a release, and the hold lasts until the widget says it is done.
64
+ hold_completes: bool = False
51
65
 
52
66
 
53
- SELECTORS: Mapping[Vendor, VendorSelectors] = {
54
- Vendor.RECAPTCHA: VendorSelectors(
55
- hosts=("google.com/recaptcha", "recaptcha.net"),
56
- challenge=('iframe[src*="recaptcha/api2/bframe"]',),
57
- checkbox=('iframe[src*="recaptcha/api2/anchor"]:not([src*="size=invisible"])',),
58
- response='[name="g-recaptcha-response"]',
59
- checked=".recaptcha-checkbox-checked",
60
- box=("#recaptcha-anchor",),
61
- fresh=".rc-imageselect-instructions, #rc-imageselect",
62
- submit=("#recaptcha-verify-button",),
63
- banners=(
64
- (".rc-imageselect-error-select-more", RecaptchaBanner.SELECT_MORE),
65
- (".rc-imageselect-error-dynamic-more", RecaptchaBanner.DYNAMIC_MORE),
66
- (".rc-imageselect-incorrect-response", RecaptchaBanner.REJECTED),
67
- ),
68
- blocked=(".rc-doscaptcha-header-text",),
69
- passive=('iframe[src*="size=invisible"]',),
70
- ),
71
- # Keyed on the `hcaptcha` substring, not the apex host: challenges are served off newassets.hcaptcha.com.
72
- Vendor.HCAPTCHA: VendorSelectors(
73
- hosts=("hcaptcha.com",),
74
- challenge=('iframe[src*="hcaptcha"][src*="frame=challenge"]',),
75
- checkbox=('iframe[src*="hcaptcha"][src*="frame=checkbox"]',),
76
- response='[name="h-captcha-response"]',
77
- checked='#checkbox[aria-checked="true"]',
78
- box=("#checkbox",),
79
- fresh=".prompt-text",
80
- images=(".task-image .image", ".task .image", ".challenge-example img", ".image-wrapper img"),
81
- submit=(".button-submit",),
82
- ),
83
- Vendor.TURNSTILE: VendorSelectors(
84
- hosts=("challenges.cloudflare.com",),
85
- checkbox=('iframe[src*="challenges.cloudflare.com"]', ".cf-turnstile"),
86
- response='[name="cf-turnstile-response"]',
87
- ),
88
- Vendor.GEETEST: VendorSelectors(
89
- hosts=("geetest.com",),
90
- widget=(".geetest_box", ".geetest_panel_box", ".geetest_popup_window", ".geetest_widget"),
91
- accepted=(".geetest_result_tips.geetest_success, .geetest_captcha.geetest_success, "
92
- ".geetest_captcha.geetest_lock_success"),
93
- submit=(".geetest_submit",),
94
- slider_handle=(".geetest_slider_button", ".geetest_btn", ".geetest_slider .geetest_arrow"),
95
- piece=(".geetest_slice",),
96
- ),
97
- # Both in-page and iframe shapes stay (Tencent moved in-host on 2026-08-11; see TRIBAL_KNOWLEDGE.md), and the iframe
98
- # id is prefix-anchored: `[id*=tcaptcha]` also matched MTCaptcha's iframe and hid that `.mtcap` matched nothing.
99
- Vendor.TENCENT: VendorSelectors(
100
- hosts=("captcha.gtimg.com", "captcha.qcloud.com"),
101
- widget=("#tcaptcha_transform_dy", "#tCaptchaDyContent", ".tencent-captcha-dy__content", "iframe#tcaptcha_iframe_dy",
102
- 'iframe[id^="tcaptcha"]', 'iframe[src*="captcha.gtimg.com"]', 'iframe[src*="captcha.qq.com"]'),
103
- slider_handle=(".tencent-captcha-dy__slider-block", "#tcaptcha_drag_thumb", ".tc-slider-normal", "[id*=slideBlock]"),
104
- piece=(".tencent-captcha-dy__fg-item",),
105
- ),
106
- Vendor.YIDUN: VendorSelectors(
107
- hosts=("dun.163.com", "cstaticdun.126.net", "necaptcha.nosdn.127.net"),
108
- widget=(".yidun_panel", ".yidun"),
109
- slider_handle=(".yidun_slider", ".yidun_jigsaw"),
110
- piece=(".yidun_jigsaw",),
111
- ),
112
- Vendor.YANDEX: VendorSelectors(
113
- hosts=("smartcaptcha.yandexcloud.net",),
114
- widget=('iframe[src*="smartcaptcha.yandexcloud.net/advanced"]', 'iframe[src*="smartcaptcha.yandexcloud.net"]',
115
- ".CheckboxCaptcha"),
116
- text_input=(".AdvancedCaptcha-Input input", "input.Textinput-Control", 'input[name="rep"]'),
117
- ),
118
- Vendor.LEMIN: VendorSelectors(
119
- hosts=("leminnow.com",),
120
- widget=("#lemin-cropped-captcha", ".lemin-captcha-popup"),
121
- slider_handle=(".lemin-slider-handle", "#lemin-cropped-captcha .slider"),
122
- piece=(".lemin-cropped-puzzle-piece", "#lemin-cropped-captcha canvas + canvas"),
123
- ),
124
- Vendor.PROSOPO: VendorSelectors(
125
- hosts=("prosopo.io",),
126
- widget=(".prosopo-modalInner", ".procaptcha-checkbox"),
127
- ),
128
- Vendor.MTCAPTCHA: VendorSelectors(
129
- hosts=("mtcaptcha.com",),
130
- widget=('iframe[src*="service.mtcaptcha.com"]', 'iframe[id^="mtcaptcha-iframe"]', ".mtcaptcha", ".mtcap"),
131
- text_input=("input.mtcap-inputtext", ".mtcap input[type=text]"),
132
- ),
133
- Vendor.BOTDETECT: VendorSelectors(
134
- hosts=(),
135
- widget=(".BDC_CaptchaDiv",),
136
- text_input=("input[id*=captchaCode]", "input#captchaCode", "input[id*=validateCaptcha]",
137
- ".BDC_CaptchaDiv input[type=text]"),
138
- ),
139
- Vendor.UNKNOWN: VendorSelectors(hosts=()),
140
- }
141
- assert set(SELECTORS) == set(Vendor), "every vendor names its selectors"
142
-
143
- VENDORS: Sequence[Tuple[Vendor, VendorSelectors]] = tuple(SELECTORS.items())
144
-
145
-
146
- def _of_every_vendor(key: str) -> Sequence[str]:
147
- assert key in {f.name for f in fields(VendorSelectors)}
148
- return tuple(s for _, v in VENDORS for s in getattr(v, key))
67
+ #: The default config: the repository's own `captchakraken.config.json` when this package runs from a checkout, so an
68
+ #: edit there is what the solver reads, else the copy shipped inside the package (a wheel has no repository root).
69
+ _SHIPPED_CONFIG = Path(__file__).with_name("captchakraken.config.json")
70
+ _CHECKOUT_CONFIG = Path(__file__).resolve().parents[3] / "captchakraken.config.json"
71
+ DEFAULT_CONFIG_PATH: Path = _CHECKOUT_CONFIG if _CHECKOUT_CONFIG.is_file() else _SHIPPED_CONFIG
149
72
 
73
+ ConfigSource = Union[str, "os.PathLike[str]", Mapping[str, Any]]
150
74
 
151
- class WidgetProbe(NamedTuple):
152
- vendor: Vendor
153
- role: FrameRole
154
- selector: str
75
+ _FIELDS = {f.name: f for f in fields(VendorSelectors)}
76
+ _ONE = {"response", "checked", "accepted", "fresh"}
77
+ _GENERIC = ("submit_text", "text_input", "slider_handle", "piece")
78
+
79
+
80
+ class SelectorConfigError(ValueError):
81
+ """A selector config the solver cannot use; the message names the file and the key."""
82
+
83
+
84
+ def _read(source: ConfigSource) -> Tuple[str, Mapping[str, Any]]:
85
+ if isinstance(source, Mapping):
86
+ return "selectors", source
87
+ path = Path(source)
88
+ try:
89
+ doc = json.loads(path.read_text(encoding="utf-8"))
90
+ except (OSError, ValueError) as e:
91
+ raise SelectorConfigError(f"{path}: cannot be read as JSON ({e})") from None
92
+ if not isinstance(doc, Mapping):
93
+ raise SelectorConfigError(f"{path}: the top level must be an object")
94
+ return str(path), doc
95
+
96
+
97
+ def _strings(where: str, value: Any) -> Tuple[str, ...]:
98
+ if not isinstance(value, list) or not all(isinstance(v, str) for v in value):
99
+ raise SelectorConfigError(f"{where}: must be a list of strings")
100
+ return tuple(value)
101
+
102
+
103
+ def _entry(where: str, raw: Any, hosts_required: bool = True) -> VendorSelectors:
104
+ if not isinstance(raw, Mapping):
105
+ raise SelectorConfigError(f"{where}: must be an object")
106
+ kw: Dict[str, Any] = {}
107
+ for key, value in raw.items():
108
+ at = f"{where}.{key}"
109
+ if key == "note" or key.startswith("_"):
110
+ continue
111
+ if key not in _FIELDS:
112
+ raise SelectorConfigError(f"{at}: not a selector field (one of {', '.join(_FIELDS)})")
113
+ if key in _ONE:
114
+ if value is not None and not isinstance(value, str):
115
+ raise SelectorConfigError(f"{at}: must be one selector string")
116
+ kw[key] = value
117
+ elif key == "hold_completes":
118
+ if not isinstance(value, bool):
119
+ raise SelectorConfigError(f"{at}: must be true or false")
120
+ kw[key] = value
121
+ elif key == "banners":
122
+ if not isinstance(value, list):
123
+ raise SelectorConfigError(f"{at}: must be a list of [selector, verdict] pairs")
124
+ pairs = []
125
+ for i, pair in enumerate(value):
126
+ if not (isinstance(pair, list) and len(pair) == 2 and isinstance(pair[0], str)):
127
+ raise SelectorConfigError(f"{at}[{i}]: must be a [selector, verdict] pair")
128
+ try:
129
+ pairs.append((pair[0], RecaptchaBanner(pair[1])))
130
+ except ValueError:
131
+ raise SelectorConfigError(
132
+ f"{at}[{i}]: verdict {pair[1]!r} is not one of {[b.value for b in RecaptchaBanner]}") from None
133
+ kw[key] = tuple(pairs)
134
+ else:
135
+ kw[key] = _strings(at, value)
136
+ if "hosts" not in kw:
137
+ if hosts_required:
138
+ raise SelectorConfigError(f"{where}.hosts: missing (an empty list when the vendor has no host of its own)")
139
+ kw["hosts"] = ()
140
+ return VendorSelectors(**kw)
141
+
142
+
143
+ #: The element detector a config may choose (see `pointer.detector`).
144
+ DETECTOR_CHOICES = ("auto", "standard", "lite")
145
+
146
+
147
+ class SelectorConfig(NamedTuple):
148
+ """A loaded config: the caller's own entry, every vendor's in detection order, the generic fallbacks, and which
149
+ element detector runs."""
150
+ custom: Optional[VendorSelectors]
151
+ vendors: Mapping[Vendor, VendorSelectors]
152
+ generic: Mapping[str, Tuple[str, ...]]
153
+ detector: str = "auto"
155
154
 
156
155
 
157
- # Every host-page selector with what it names, in detection order: open challenges, then checkboxes, then inline widgets.
158
- WIDGET_PROBES: Sequence[WidgetProbe] = tuple(
159
- WidgetProbe(vendor, role, selector)
160
- for role, key in ((FrameRole.CHALLENGE, "challenge"), (FrameRole.CHECKBOX, "checkbox"), (FrameRole.UNKNOWN, "widget"))
161
- for vendor, s in VENDORS
162
- for selector in getattr(s, key))
156
+ def _parse(name: str, doc: Mapping[str, Any], base: Optional[SelectorConfig]) -> SelectorConfig:
157
+ for key in doc:
158
+ if not key.startswith("_") and key not in ("detector", "custom", "vendors", "generic"):
159
+ raise SelectorConfigError(f"{name}: {key}: not a config key (detector, custom, vendors, generic)")
160
+ detector = base.detector if base else "auto"
161
+ if "detector" in doc:
162
+ if not isinstance(doc["detector"], str) or doc["detector"].strip().lower() not in DETECTOR_CHOICES:
163
+ raise SelectorConfigError(f"{name}: detector: {doc['detector']!r} is not one of {', '.join(DETECTOR_CHOICES)}")
164
+ detector = doc["detector"].strip().lower()
165
+ custom = base.custom if base else None
166
+ if "custom" in doc:
167
+ custom = None if doc["custom"] is None else _entry(f"{name}: custom", doc["custom"], hosts_required=False)
168
+ raw_vendors = doc.get("vendors", {})
169
+ if not isinstance(raw_vendors, Mapping):
170
+ raise SelectorConfigError(f"{name}: vendors: must be an object keyed by vendor")
171
+ vendors: Dict[Vendor, VendorSelectors] = dict(base.vendors) if base else {}
172
+ for key, raw in raw_vendors.items():
173
+ if key.startswith("_"):
174
+ continue
175
+ try:
176
+ vendor = Vendor(key)
177
+ except ValueError:
178
+ raise SelectorConfigError(
179
+ f"{name}: vendors.{key}: not a vendor this solver knows; a captcha of your own goes under `custom`") from None
180
+ vendors[vendor] = _entry(f"{name}: vendors.{key}", raw)
181
+ if base is None and set(vendors) != set(Vendor):
182
+ missing = sorted(v.value for v in set(Vendor) - set(vendors))
183
+ raise SelectorConfigError(f"{name}: vendors: every vendor must have an entry; missing {missing}")
184
+ raw_generic = doc.get("generic", {})
185
+ if not isinstance(raw_generic, Mapping):
186
+ raise SelectorConfigError(f"{name}: generic: must be an object")
187
+ generic: Dict[str, Tuple[str, ...]] = dict(base.generic) if base else {}
188
+ for key, value in raw_generic.items():
189
+ if key.startswith("_"):
190
+ continue
191
+ if key not in _GENERIC:
192
+ raise SelectorConfigError(f"{name}: generic.{key}: not a generic list (one of {', '.join(_GENERIC)})")
193
+ generic[key] = _strings(f"{name}: generic.{key}", value)
194
+ if base is None and set(generic) != set(_GENERIC):
195
+ raise SelectorConfigError(f"{name}: generic: missing {sorted(set(_GENERIC) - set(generic))}")
196
+ return SelectorConfig(custom, vendors, generic, detector)
163
197
 
164
- RESPONSE_SELECTORS: Sequence[str] = tuple(s.response for _, s in VENDORS if s.response)
165
- ACCEPTED_SELECTORS: Sequence[str] = tuple(s.accepted for _, s in VENDORS if s.accepted)
198
+
199
+ def load_config(source: Optional[ConfigSource] = None) -> SelectorConfig:
200
+ """The shipped default, or `source` (a path or a parsed object) laid over it: a key it gives replaces the default's
201
+ `custom`, one vendor's whole entry, or one generic list. Raises SelectorConfigError naming the bad key."""
202
+ default = _parse(*_read(DEFAULT_CONFIG_PATH), base=None)
203
+ if source is None or (not isinstance(source, Mapping) and Path(source) == DEFAULT_CONFIG_PATH):
204
+ return default
205
+ return _parse(*_read(source), base=default)
206
+
207
+
208
+ DEFAULT_CONFIG: SelectorConfig = load_config()
209
+ #: Every vendor's entry, in detection order, as the default config gives it.
210
+ SELECTORS: Mapping[Vendor, VendorSelectors] = DEFAULT_CONFIG.vendors
166
211
 
167
212
  _LOWER = "translate(., 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz')"
168
213
 
@@ -172,23 +217,71 @@ def submit_by_text(text: str) -> str:
172
217
  return f"xpath=.//button[contains({_LOWER}, '{text}')] | .//div[@role=\"button\" and contains({_LOWER}, '{text}')]"
173
218
 
174
219
 
175
- # A real Verify/Next/Submit/Skip first; the vendor-named controls are fallbacks.
176
- SUBMIT_SELECTORS: Sequence[str] = (*map(submit_by_text, ("verify", "next", "submit", "skip")), *_of_every_vendor("submit"))
177
-
178
- # Vendor-named first, generic last: the driver takes the first visible match, and a generic selector
179
- # reached before the vendor's own is how a captcha's answer ends up in a login form's username box.
180
- TEXT_INPUT_VENDOR_SELECTORS: Sequence[str] = _of_every_vendor("text_input")
181
- TEXT_INPUT_SELECTORS: Sequence[str] = (
182
- *TEXT_INPUT_VENDOR_SELECTORS,
183
- 'input[name*="captcha" i]', 'input[id*="captcha" i]', 'input[aria-label*="captcha" i]',
184
- 'input[placeholder*="code" i]', 'input[autocomplete="off"][type=text]',
185
- "input[type=text]", "input:not([type])", "input[type=tel]", "textarea",
186
- )
187
- # `[draggable=true]` is absent: HTML5 DnD fires dragstart, not pointermove.
188
- SLIDER_HANDLE_SELECTORS: Sequence[str] = (
189
- *_of_every_vendor("slider_handle"),
190
- '[role="slider"]', "[aria-valuenow]",
191
- '[class*="slider"][class*="btn"]', '[class*="slider"][class*="button"]',
192
- '[class*="slide"][class*="handle"]', '[class*="drag"][class*="thumb"]',
193
- )
194
- PIECE_SELECTORS: Sequence[str] = (*_of_every_vendor("piece"), '[class*="puzzle"][class*="piece"]', '[class*="jigsaw"]')
220
+ class WidgetProbe(NamedTuple):
221
+ vendor: Vendor
222
+ role: FrameRole
223
+ selector: str
224
+
225
+
226
+ _DETECTED_BY = ("challenge", "checkbox", "widget")
227
+
228
+
229
+ class SelectorTable:
230
+ """The table one solver detects and drives with: a selector config's vendors, plus its `custom` entry when it has one.
231
+
232
+ `config` is a path, a parsed object or a loaded SelectorConfig, laid over the shipped default (see `load_config`).
233
+ `custom` (the older `custom_selectors=`) replaces the config's own `custom`. A custom entry is reported as
234
+ `Vendor.UNKNOWN` and searched before the built-in vendors, because the caller wrote it for the page in front of them.
235
+ """
236
+
237
+ def __init__(self, custom: Optional[VendorSelectors] = None,
238
+ config: Union[ConfigSource, SelectorConfig, None] = None) -> None:
239
+ loaded = config if isinstance(config, SelectorConfig) else load_config(config)
240
+ custom = custom if custom is not None else loaded.custom
241
+ if custom is not None and not any(getattr(custom, key) for key in _DETECTED_BY):
242
+ raise SelectorConfigError(
243
+ "custom selectors name nothing to detect: give the entry a `widget`, `challenge` or `checkbox`")
244
+ self.config = loaded
245
+ self.selectors: Mapping[Vendor, VendorSelectors] = (
246
+ {Vendor.UNKNOWN: custom, **{v: s for v, s in loaded.vendors.items() if v != Vendor.UNKNOWN}}
247
+ if custom is not None else loaded.vendors)
248
+ self.vendors: Sequence[Tuple[Vendor, VendorSelectors]] = tuple(self.selectors.items())
249
+ # Every host-page selector with what it names, in detection order: open challenges, then checkboxes, then
250
+ # inline widgets.
251
+ self.widget_probes: Sequence[WidgetProbe] = tuple(
252
+ WidgetProbe(vendor, role, selector)
253
+ for role, key in zip((FrameRole.CHALLENGE, FrameRole.CHECKBOX, FrameRole.UNKNOWN), _DETECTED_BY)
254
+ for vendor, s in self.vendors
255
+ for selector in getattr(s, key))
256
+ self.enclave: Sequence[str] = self._of_every_vendor("enclave")
257
+ self.response: Sequence[str] = tuple(s.response for _, s in self.vendors if s.response)
258
+ self.accepted: Sequence[str] = tuple(s.accepted for _, s in self.vendors if s.accepted)
259
+ # A real Verify/Next/Submit/Skip/Confirm first; the vendor-named controls are fallbacks.
260
+ self.submit: Sequence[str] = (*map(submit_by_text, loaded.generic["submit_text"]),
261
+ *self._of_every_vendor("submit"))
262
+ # Vendor-named first, generic last: the driver takes the first visible match, and a generic selector
263
+ # reached before the vendor's own is how a captcha's answer ends up in a login form's username box.
264
+ self.text_input_vendor: Sequence[str] = self._of_every_vendor("text_input")
265
+ self.text_input: Sequence[str] = (*self.text_input_vendor, *loaded.generic["text_input"])
266
+ # `[draggable=true]` is absent from the generic handles: HTML5 DnD fires dragstart, not pointermove.
267
+ self.slider_handle: Sequence[str] = (*self._of_every_vendor("slider_handle"), *loaded.generic["slider_handle"])
268
+ self.refresh: Sequence[str] = self._of_every_vendor("refresh")
269
+ self.piece: Sequence[str] = (*self._of_every_vendor("piece"), *loaded.generic["piece"])
270
+
271
+ def _of_every_vendor(self, key: str) -> Sequence[str]:
272
+ assert key in {f.name for f in fields(VendorSelectors)}
273
+ return tuple(s for _, v in self.vendors for s in getattr(v, key))
274
+
275
+
276
+ DEFAULT_TABLE = SelectorTable(config=DEFAULT_CONFIG)
277
+ VENDORS = DEFAULT_TABLE.vendors
278
+ WIDGET_PROBES = DEFAULT_TABLE.widget_probes
279
+ ENCLAVE_SELECTORS = DEFAULT_TABLE.enclave
280
+ RESPONSE_SELECTORS = DEFAULT_TABLE.response
281
+ ACCEPTED_SELECTORS = DEFAULT_TABLE.accepted
282
+ SUBMIT_SELECTORS = DEFAULT_TABLE.submit
283
+ TEXT_INPUT_VENDOR_SELECTORS = DEFAULT_TABLE.text_input_vendor
284
+ TEXT_INPUT_SELECTORS = DEFAULT_TABLE.text_input
285
+ SLIDER_HANDLE_SELECTORS = DEFAULT_TABLE.slider_handle
286
+ REFRESH_SELECTORS = DEFAULT_TABLE.refresh
287
+ PIECE_SELECTORS = DEFAULT_TABLE.piece
@@ -1,3 +1,4 @@
1
+ import json
1
2
  import os
2
3
  import shutil
3
4
  import signal
@@ -10,6 +11,7 @@ from urllib.parse import urlparse
10
11
  import requests
11
12
 
12
13
  from . import config
14
+ from .clipvideo import media_io_kwargs
13
15
 
14
16
  STATE_DIR = config.state_dir()
15
17
  LOCK_FILE = STATE_DIR / "vllm.lock"
@@ -67,6 +69,18 @@ def _read_pid() -> "int | None":
67
69
 
68
70
 
69
71
  def build_serve_command() -> "list[str]":
72
+ from . import prompts
73
+
74
+ if prompts.resolve(config.lora_adapter()).pointer:
75
+ detector = os.environ["CAPTCHA_POINTER_DETECTOR"]
76
+ return [
77
+ sys.executable, "-m", "captchakraken.pointer.server",
78
+ "--model", config.base_model(),
79
+ "--detector", detector,
80
+ "--host", os.getenv("CAPTCHA_POINTER_HOST", "127.0.0.1"),
81
+ "--port", str(config.port()),
82
+ "--gpu-memory-utilization", str(config.gpu_memory_utilization()),
83
+ ]
70
84
  return [
71
85
  _vllm_bin() or "vllm", "serve", config.base_model(),
72
86
  "--reasoning-parser", "qwen3",
@@ -78,6 +92,10 @@ def build_serve_command() -> "list[str]":
78
92
  "--trust-remote-code",
79
93
  "--port", str(config.port()),
80
94
  "--lora-modules", f"{config.lora_name()}={config.lora_adapter()}",
95
+ # A video-generation model watches an animation as one JPEG frame sequence; without these vLLM refuses the
96
+ # video or resamples it to its own frame count, and the time the model answers with names the wrong moment.
97
+ "--limit-mm-per-prompt", json.dumps({"image": 8, "video": 1}),
98
+ "--media-io-kwargs", json.dumps(media_io_kwargs()),
81
99
  *config.extra_serve_args(),
82
100
  ]
83
101