captchakraken 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +54 -0
  2. package/dist/index.d.ts +56 -0
  3. package/dist/index.js +43 -0
  4. package/dist/playwright-types.d.ts +119 -0
  5. package/dist/playwright-types.js +25 -0
  6. package/dist/puppeteer-adapter.d.ts +70 -0
  7. package/dist/puppeteer-adapter.js +96 -0
  8. package/dist/solver.d.ts +264 -0
  9. package/dist/solver.js +1922 -0
  10. package/dist/token-usage.d.ts +15 -0
  11. package/dist/token-usage.js +102 -0
  12. package/dist/types.d.ts +248 -0
  13. package/dist/types.js +2 -0
  14. package/package.json +49 -0
  15. package/python/Dockerfile +41 -0
  16. package/python/README.md +71 -0
  17. package/python/examples/README.md +68 -0
  18. package/python/examples/_harness.py +158 -0
  19. package/python/examples/demoHcaptcha.py +20 -0
  20. package/python/examples/demoRecaptcha.py +17 -0
  21. package/python/pyproject.toml +79 -0
  22. package/python/src/captchakraken/__init__.py +61 -0
  23. package/python/src/captchakraken/action_types.py +56 -0
  24. package/python/src/captchakraken/cli.py +656 -0
  25. package/python/src/captchakraken/config.py +78 -0
  26. package/python/src/captchakraken/image_processor.py +244 -0
  27. package/python/src/captchakraken/overlay.py +520 -0
  28. package/python/src/captchakraken/planner.py +408 -0
  29. package/python/src/captchakraken/planner_types.py +74 -0
  30. package/python/src/captchakraken/server_manager.py +290 -0
  31. package/python/src/captchakraken/solver.py +434 -0
  32. package/python/src/captchakraken/timing.py +42 -0
  33. package/python/src/captchakraken/tool_calls/find_checkbox.py +72 -0
  34. package/python/src/captchakraken/tool_calls/find_grid.py +1762 -0
  35. package/python/src/captchakraken/tool_calls/move_indicator.py +431 -0
  36. package/scripts/copy-python.mjs +29 -0
  37. package/scripts/setup-python.js +104 -0
@@ -0,0 +1,408 @@
1
+ """
2
+ ActionPlanner (v2) — talks to local vLLM serving the `captcha` LoRA over the
3
+ OpenAI-compatible /v1/chat/completions endpoint.
4
+
5
+ v1 supported transformers/vllm-local/gemini/openrouter and a whole tool-using
6
+ planner with detect/segment/drag-refine; that code is preserved on the
7
+ `v1-old-architecture` branch.
8
+ """
9
+
10
+ import base64
11
+ import json
12
+ import os
13
+ import sys
14
+ from mimetypes import guess_type
15
+ from typing import Any, Dict, List, Optional
16
+
17
+ import requests
18
+
19
+ from . import config
20
+ from .server_manager import ensure_server
21
+ from .timing import timed
22
+
23
+ DEBUG = os.getenv("CAPTCHA_DEBUG", "0") == "1"
24
+
25
+
26
+ # Matches the training-distribution grid prompts in
27
+ # cleanSamples/test/test_solutions.json -> grade.synthesize_instruction.
28
+ # Drifting from these costs measurable accuracy.
29
+ SELECT_GRID_PROMPT = """Solve the captcha grid by choosing the cell numbers that match the description from the captcha image prompt.
30
+
31
+ Grid: {rows}x{cols} ({total} cells)
32
+ {grid_hint}
33
+
34
+ If no tiles match the description (e.g., they have all been cleared or none were present), return an empty list for target_ids: [].
35
+
36
+ Return JSON Array: [list of cell numbers (1-{total})]"""
37
+
38
+
39
+ # Non-grid click/drag puzzles. MUST stay byte-identical to
40
+ # src/synthetic/reasoning/instructions.py::ACTION_INSTRUCTION in the finetune
41
+ # repo — that is the exact prompt the LoRA was trained (and graded) on. Drift
42
+ # here silently degrades every click/drag puzzle. Coordinates come back on a
43
+ # 0–1000 scale; the solver converts them to 0–1 bboxes.
44
+ PIXEL_ACTION_PROMPT = (
45
+ "Your task is to solve the captcha. Read the instruction at the top of the image carefully.\n\n"
46
+ "Look at the puzzle and decide what action solves it. All coordinates you return must be on a "
47
+ "normalized 0–1000 image scale (top-left = (0, 0), bottom-right = (1000, 1000)).\n\n"
48
+ "Choose ONE response:\n\n"
49
+ "FOR CLICK PUZZLES:\n"
50
+ " Identify every position you need to click and emit them as a list of points:\n"
51
+ " → \"action\": { \"action\": \"click\", \"points\": [[x1, y1], [x2, y2], ...] }\n\n"
52
+ "FOR DRAG PUZZLES:\n"
53
+ " Drag ONE item at a time. The source position is the centroid of the piece you are picking up; "
54
+ "the destination position is where it should end up. If multiple drags are needed, drag the topmost "
55
+ "item first.\n"
56
+ " → \"output\": [{ \"Action\": \"simulate_drag\", "
57
+ "\"SourceDescription\": \"...\", \"SourcePosition\": { \"x\": 1-1000, \"y\": 1-1000 }, "
58
+ "\"DestinationDescription\": \"...\", \"EstimatedPosition\": { \"x\": 1-1000, \"y\": 1-1000 } }]\n\n"
59
+ "Respond ONLY with JSON:\n"
60
+ "{\n"
61
+ " \"action\": { ... }\n"
62
+ " // OR \"output\": [ ... ]\n"
63
+ "}"
64
+ )
65
+
66
+
67
+ class ActionPlanner:
68
+ """Thin client for the vLLM `captcha` LoRA."""
69
+
70
+ def __init__(
71
+ self,
72
+ model: Optional[str] = None,
73
+ debug_callback: Optional[Any] = None,
74
+ base_url: Optional[str] = None,
75
+ api_key: Optional[str] = None,
76
+ **_: Any,
77
+ ):
78
+ self.debug_callback = debug_callback
79
+ self.token_usage: List[Dict[str, Any]] = []
80
+
81
+ # All model/endpoint defaults come from `config` (env-overridable), so
82
+ # the planner itself is model-agnostic — swap models via env, not code.
83
+ self.model = model or config.lora_name()
84
+ self.base_url = base_url or config.base_url()
85
+ self.api_key = api_key or config.api_key()
86
+ # Auto-start a local vLLM server on the first request if one isn't up
87
+ # (no-op for a healthy or remote endpoint). Guarded so we only try once.
88
+ self._server_ensured = False
89
+
90
+ def _log(self, message: str) -> None:
91
+ if DEBUG:
92
+ print(f"[Planner] {message}", file=sys.stderr)
93
+ if self.debug_callback:
94
+ self.debug_callback(f"[Planner] {message}")
95
+
96
+ def _chat_with_image(self, prompt: str, image_path: str, max_tokens: int = 512) -> str:
97
+ with open(image_path, "rb") as f:
98
+ b64 = base64.b64encode(f.read()).decode()
99
+ mime, _ = guess_type(image_path)
100
+ if mime is None:
101
+ mime = "image/png"
102
+
103
+ messages = [
104
+ {
105
+ "role": "system",
106
+ "content": "You are an expert captcha solver. Respond ONLY with the JSON action.",
107
+ },
108
+ {
109
+ "role": "user",
110
+ "content": [
111
+ {"type": "image_url", "image_url": {"url": f"data:{mime};base64,{b64}"}},
112
+ {"type": "text", "text": prompt},
113
+ ],
114
+ },
115
+ ]
116
+
117
+ payload = {
118
+ "model": self.model,
119
+ "messages": messages,
120
+ "temperature": 0,
121
+ "max_tokens": max_tokens,
122
+ # Qwen3.5's reasoning otherwise eats the token budget. `/no_think`
123
+ # in the prompt alone is unreliable; disabling at the chat-template
124
+ # level is the documented way.
125
+ "chat_template_kwargs": {"enable_thinking": False},
126
+ }
127
+
128
+ headers = {
129
+ "Authorization": f"Bearer {self.api_key}",
130
+ "Content-Type": "application/json",
131
+ }
132
+
133
+ # Hands-off server: before the very first request, make sure something
134
+ # is listening (auto-start a local vLLM if needed; no-op if it's already
135
+ # up or the endpoint is remote). Best-effort — a failure here surfaces as
136
+ # a normal connection error on the request below.
137
+ if not self._server_ensured:
138
+ try:
139
+ ensure_server(self.base_url)
140
+ except Exception as e: # noqa: BLE001 — don't mask the real request error
141
+ self._log(f"ensure_server: {e}")
142
+ self._server_ensured = True
143
+
144
+ url = f"{self.base_url}/chat/completions"
145
+ self._log(f"POST {url} model={self.model} max_tokens={max_tokens}")
146
+
147
+ with timed("planner.chat"):
148
+ resp = requests.post(url, headers=headers, json=payload, timeout=120)
149
+
150
+ # Surface auth / server errors with the actual body instead of letting
151
+ # resp.json() blow up with a cryptic "Expecting value: line 1 column 1"
152
+ # on a non-JSON response (e.g. a 401 {"error":"Unauthorized"} or an
153
+ # HTML error page). 401/403 almost always means the bearer token
154
+ # (CAPTCHA_KRAKEN_API_KEY) didn't reach this process.
155
+ if not resp.ok:
156
+ body = (resp.text or "")[:300]
157
+ hint = ""
158
+ if resp.status_code in (401, 403):
159
+ hint = (
160
+ " — check CAPTCHA_KRAKEN_API_KEY is set and forwarded to the CLI"
161
+ )
162
+ raise RuntimeError(
163
+ f"vLLM {resp.status_code} {resp.reason} at {url}{hint}. Body: {body}"
164
+ )
165
+
166
+ try:
167
+ data = resp.json()
168
+ except ValueError:
169
+ body = (resp.text or "")[:300]
170
+ raise RuntimeError(
171
+ f"vLLM returned a non-JSON body from {url} (is the server up and "
172
+ f"is VLLM_BASE_URL correct?). Body: {body}"
173
+ )
174
+
175
+ if data.get("usage"):
176
+ self.token_usage.append(data["usage"])
177
+
178
+ content = data["choices"][0]["message"].get("content") or ""
179
+ self._log(f"Raw content: {content[:300]}")
180
+ return content
181
+
182
+ @staticmethod
183
+ def _parse_json(text: str) -> Any:
184
+ text = (text or "").strip()
185
+ if "```json" in text:
186
+ text = text.split("```json", 1)[1].split("```", 1)[0]
187
+ elif "```" in text:
188
+ text = text.split("```", 1)[1].split("```", 1)[0]
189
+
190
+ start_obj = text.find("{")
191
+ start_list = text.find("[")
192
+ if start_list != -1 and (start_obj == -1 or start_list < start_obj):
193
+ start = start_list
194
+ end = text.rfind("]") + 1
195
+ elif start_obj != -1:
196
+ start = start_obj
197
+ end = text.rfind("}") + 1
198
+ else:
199
+ return None
200
+
201
+ try:
202
+ return json.loads(text[start:end])
203
+ except json.JSONDecodeError:
204
+ # The model sometimes truncates (hit max_tokens) or over-nests its
205
+ # pretty-printed JSON, leaving brackets unclosed. Repair by balancing
206
+ # from the first opener to the end of the raw text.
207
+ repaired = ActionPlanner._balance_json(text[start:])
208
+ if repaired is not None:
209
+ try:
210
+ return json.loads(repaired)
211
+ except json.JSONDecodeError:
212
+ return None
213
+ return None
214
+
215
+ @staticmethod
216
+ def _balance_json(text: str) -> Optional[str]:
217
+ """Close any brackets/braces the model left open (string-aware), so a
218
+ truncated ``{"action": {"points": [[1,2],`` still parses. Returns None if
219
+ there was nothing to balance."""
220
+ stack: List[str] = []
221
+ in_str = False
222
+ esc = False
223
+ for ch in text:
224
+ if in_str:
225
+ if esc:
226
+ esc = False
227
+ elif ch == "\\":
228
+ esc = True
229
+ elif ch == '"':
230
+ in_str = False
231
+ continue
232
+ if ch == '"':
233
+ in_str = True
234
+ elif ch in "{[":
235
+ stack.append(ch)
236
+ elif ch == "}":
237
+ if stack and stack[-1] == "{":
238
+ stack.pop()
239
+ elif ch == "]":
240
+ if stack and stack[-1] == "[":
241
+ stack.pop()
242
+ if not stack and not in_str:
243
+ return None
244
+ out = text
245
+ if in_str:
246
+ out += '"'
247
+ # Drop a dangling comma / partial token before the closers.
248
+ out = out.rstrip().rstrip(",")
249
+ for opener in reversed(stack):
250
+ out += "}" if opener == "{" else "]"
251
+ return out
252
+
253
+ def get_grid_selection(
254
+ self,
255
+ image_path: str,
256
+ rows: int,
257
+ cols: int,
258
+ retry_mode: Optional[str] = None,
259
+ ) -> List[int]:
260
+ """Return the list of 1-indexed cells the model wants to click.
261
+
262
+ retry_mode == "missed-tiles": the previous submission was rejected
263
+ by the captcha vendor with an under-selection error. Append an
264
+ explicit recovery instruction that tells the model the FULL grid
265
+ contains at least one matching tile it didn't pick last time. This
266
+ nudges it off the "I already covered everything" attractor.
267
+ """
268
+ total = rows * cols
269
+ if rows == 4 and cols == 4:
270
+ grid_hint = "Hint: Single large image split into tiles. Select ALL parts."
271
+ else:
272
+ grid_hint = "Hint: Separate images. Select only clear matches."
273
+
274
+ prompt = SELECT_GRID_PROMPT.format(
275
+ rows=rows, cols=cols, total=total, grid_hint=grid_hint
276
+ )
277
+ if retry_mode == "missed-tiles":
278
+ prompt = (
279
+ prompt
280
+ + "\n\nIMPORTANT: A previous submission was rejected because not all "
281
+ "matching tiles were selected. Re-examine EVERY cell in the grid "
282
+ "carefully. There is at least one more matching tile you missed. "
283
+ "Return the complete list of cell numbers that match the description, "
284
+ "including any matches you may have overlooked."
285
+ )
286
+ raw = self._chat_with_image(prompt, image_path, max_tokens=128)
287
+ data = self._parse_json(raw)
288
+
289
+ if isinstance(data, list):
290
+ ids = data
291
+ elif isinstance(data, dict):
292
+ ids = data.get("target_ids") or data.get("action", {}).get("target_ids") or []
293
+ else:
294
+ ids = []
295
+
296
+ out: List[int] = []
297
+ for v in ids:
298
+ try:
299
+ iv = int(v)
300
+ if 1 <= iv <= total:
301
+ out.append(iv)
302
+ except (TypeError, ValueError):
303
+ continue
304
+ self._log(f"grid selection -> {out}")
305
+ return out
306
+
307
+ def get_pixel_actions(self, image_path: str) -> List[Dict[str, Any]]:
308
+ """Solve a non-grid click/drag puzzle.
309
+
310
+ Sends the trained action prompt + image, parses the model's JSON, and
311
+ returns a list of normalized actions with all coordinates on a 0–1
312
+ scale:
313
+
314
+ {"kind": "click", "points": [(x, y), ...]}
315
+ {"kind": "drag", "src": (x, y), "dst": (x, y)}
316
+
317
+ Returns [] if the model produced nothing usable. The solver turns these
318
+ into ClickAction / DragAction bboxes.
319
+ """
320
+ raw = self._chat_with_image(PIXEL_ACTION_PROMPT, image_path, max_tokens=512)
321
+ data = self._parse_json(raw)
322
+ actions = self._normalize_pixel(data)
323
+ self._log(f"pixel actions -> {actions}")
324
+ return actions
325
+
326
+ @staticmethod
327
+ def _normalize_pixel(data: Any) -> List[Dict[str, Any]]:
328
+ """Map the model's 0–1000 click/drag JSON to 0–1 normalized actions.
329
+
330
+ Tolerant of the two trained shapes plus a few near-misses:
331
+ click: {"action": {"action": "click", "points": [[x, y], ...]}}
332
+ or top-level {"points": [...]} / {"action": {"points": [...]}}
333
+ drag: {"output": [{"Action": "simulate_drag",
334
+ "SourcePosition": {x, y},
335
+ "EstimatedPosition": {x, y}}, ...]}
336
+ """
337
+ def norm_xy(x: Any, y: Any) -> Optional[tuple]:
338
+ try:
339
+ fx, fy = float(x) / 1000.0, float(y) / 1000.0
340
+ except (TypeError, ValueError):
341
+ return None
342
+ if not (0.0 <= fx <= 1.0 and 0.0 <= fy <= 1.0):
343
+ # Some outputs use 0–1 already; accept those too.
344
+ if 0.0 <= float(x) <= 1.0 and 0.0 <= float(y) <= 1.0:
345
+ fx, fy = float(x), float(y)
346
+ else:
347
+ fx, fy = min(max(fx, 0.0), 1.0), min(max(fy, 0.0), 1.0)
348
+ return (fx, fy)
349
+
350
+ out: List[Dict[str, Any]] = []
351
+ if not isinstance(data, dict):
352
+ return out
353
+
354
+ # ---- drag: {"output": [ {simulate_drag ...}, ... ]} ----
355
+ drags = data.get("output")
356
+ if isinstance(drags, list) and drags:
357
+ for d in drags:
358
+ if not isinstance(d, dict):
359
+ continue
360
+ sp = d.get("SourcePosition") or {}
361
+ ep = d.get("EstimatedPosition") or d.get("DestinationPosition") or {}
362
+ src = norm_xy(sp.get("x"), sp.get("y")) if isinstance(sp, dict) else None
363
+ dst = norm_xy(ep.get("x"), ep.get("y")) if isinstance(ep, dict) else None
364
+ if src and dst:
365
+ out.append({"kind": "drag", "src": src, "dst": dst})
366
+ if out:
367
+ return out
368
+
369
+ # ---- click: {"action": {"action":"click","points":[...]}} ----
370
+ action = data.get("action")
371
+ # The model sometimes over-wraps: {"action": {"action": {"action":
372
+ # "click", "points":[...]}}}. Peel nested "action" dicts until we reach
373
+ # the one carrying points/coords (bounded so we never loop forever).
374
+ for _ in range(4):
375
+ if (
376
+ isinstance(action, dict)
377
+ and action.get("points") is None
378
+ and action.get("source") is None
379
+ and isinstance(action.get("action"), dict)
380
+ ):
381
+ action = action["action"]
382
+ else:
383
+ break
384
+ points = None
385
+ if isinstance(action, dict):
386
+ points = action.get("points")
387
+ # single drag emitted under "action"
388
+ if points is None and action.get("action") == "drag":
389
+ src = norm_xy(*(action.get("source") or (None, None)))
390
+ dst = norm_xy(*(action.get("target") or (None, None)))
391
+ if src and dst:
392
+ return [{"kind": "drag", "src": src, "dst": dst}]
393
+ if points is None:
394
+ points = data.get("points")
395
+ if isinstance(points, list) and points:
396
+ pts = []
397
+ for p in points:
398
+ if isinstance(p, (list, tuple)) and len(p) >= 2:
399
+ xy = norm_xy(p[0], p[1])
400
+ elif isinstance(p, dict):
401
+ xy = norm_xy(p.get("x"), p.get("y"))
402
+ else:
403
+ xy = None
404
+ if xy:
405
+ pts.append(xy)
406
+ if pts:
407
+ out.append({"kind": "click", "points": pts})
408
+ return out
@@ -0,0 +1,74 @@
1
+ from typing import List, Literal, Optional, Union, Dict
2
+ from pydantic import BaseModel, Field, model_validator
3
+
4
+
5
+ class PlannerAction(BaseModel):
6
+ goal: Optional[str] = Field(None, description="Description of the visual goal")
7
+
8
+
9
+ class PlannerClickAction(PlannerAction):
10
+ action: Literal["click"]
11
+ target_ids: List[int] = Field(description="List of object IDs or grid cell numbers to click")
12
+
13
+
14
+ class PlannerDragAction(PlannerAction):
15
+ action: Literal["drag"]
16
+ source_id: int = Field(description="Object ID of item to drag")
17
+ target_id: int = Field(description="Object ID of destination")
18
+
19
+
20
+ class PlannerTypeAction(PlannerAction):
21
+ action: Literal["type"]
22
+ text: str = Field(description="Text to type into the input")
23
+ target_id: int = Field(description="Object ID of the input field")
24
+
25
+
26
+ class PlannerWaitAction(PlannerAction):
27
+ action: Literal["wait"]
28
+ duration_ms: int = Field(default=500, description="Duration to wait in milliseconds")
29
+
30
+
31
+ class PlannerDoneAction(PlannerAction):
32
+ action: Literal["done"]
33
+
34
+
35
+ class DragGuess(BaseModel):
36
+ Action: Literal["simulate_drag"]
37
+ SourceID: int
38
+ DestinationDescription: str
39
+ EstimatedPosition: Dict[str, int] = Field(description="{'x': 1-1000, 'y': 1-1000}")
40
+
41
+
42
+ class DragRefineResult(BaseModel):
43
+ SourceId: int
44
+ estimatedVerticalDistanceFromTarget: int = Field(description="negative = above, positive = below")
45
+ estimatedHorizontalDistanceFromTarget: int = Field(description="negative = left, positive = right")
46
+
47
+
48
+ class PlannerDragRefineAction(PlannerAction):
49
+ # This is for Step 3: Verification and adjustment
50
+ output: List[DragRefineResult]
51
+
52
+
53
+ class PlannerToolCall(BaseModel):
54
+ name: Literal["detect", "simulate_drag"]
55
+ args: dict
56
+
57
+
58
+ class PlannerPlan(BaseModel):
59
+ goal: Optional[str] = None
60
+ tool_calls: Optional[List[PlannerToolCall]] = None
61
+ # Step 1: Detect all draggable items
62
+ objectDescription: Optional[str] = None
63
+ # Step 2: Initial guess (general LoRa) uses output
64
+ output: Optional[List[DragGuess]] = None
65
+ # We allow the planner to return either a direct action or tool calls or new output format
66
+ action: Optional[Union[PlannerClickAction, PlannerDragAction, PlannerTypeAction, PlannerWaitAction, PlannerDoneAction]] = None
67
+
68
+ @model_validator(mode="after")
69
+ def check_exclusive(self) -> "PlannerPlan":
70
+ # At least one must be provided
71
+ if not any([self.action, self.tool_calls, self.output, self.objectDescription]):
72
+ raise ValueError("Provide EITHER 'action', 'tool_calls', 'output', OR 'objectDescription'.")
73
+ return self
74
+