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,520 @@
1
+ import argparse
2
+ import json
3
+ import math
4
+ import sys
5
+ import numpy as np
6
+ import cv2
7
+ from typing import Any, Dict
8
+
9
+ from PIL import Image, ImageDraw, ImageFont, ImageFilter
10
+
11
+ # Font cache to prevent repeated font loading
12
+ _FONT_CACHE: Dict[tuple, Any] = {}
13
+
14
+ _FONT_PATHS = [
15
+ "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
16
+ "/usr/share/fonts/TTF/DejaVuSans-Bold.ttf",
17
+ "/System/Library/Fonts/Arial.ttf",
18
+ "C:\\Windows\\Fonts\\arial.ttf",
19
+ "arial.ttf",
20
+ "Arial Bold.ttf",
21
+ "/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
22
+ ]
23
+
24
+
25
+ def get_cross_platform_font(font_size: int):
26
+ cache_key = ("system_font", font_size)
27
+ if cache_key in _FONT_CACHE:
28
+ return _FONT_CACHE[cache_key]
29
+
30
+ font = None
31
+ for font_path in _FONT_PATHS:
32
+ try:
33
+ font = ImageFont.truetype(font_path, font_size)
34
+ break
35
+ except OSError:
36
+ continue
37
+
38
+ _FONT_CACHE[cache_key] = font
39
+ return font
40
+
41
+
42
+ def hex_to_rgba(hex_color, alpha=180):
43
+ hex_color = hex_color.lstrip('#')
44
+ if len(hex_color) == 6:
45
+ rgb = tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4))
46
+ else:
47
+ rgb = (255, 0, 0) # Fallback
48
+ return rgb + (alpha,)
49
+
50
+ def draw_enhanced_bounding_box(
51
+ draw,
52
+ bbox,
53
+ text=None,
54
+ number=None,
55
+ image_size=None,
56
+ color="#C0392B",
57
+ label_position="top-left",
58
+ box_style: str = "thin",
59
+ ):
60
+ x1, y1, x2, y2 = bbox
61
+ # color is passed as argument
62
+
63
+ # Box style
64
+ # - dashed: good for multi-object overlays (keeps boxes distinct)
65
+ # - solid: match the 10% grid overlay aesthetic (green + black underlay)
66
+ shadow_color = (0, 0, 0, 210) # strong underlay for contrast
67
+
68
+ if box_style == "solid":
69
+ line_width = 2
70
+ shadow_width = line_width + 2
71
+ # Underlay + main stroke to match the solid green/black grid overlay style
72
+ draw.rectangle([x1, y1, x2, y2], outline=shadow_color, width=shadow_width)
73
+ draw.rectangle([x1, y1, x2, y2], outline=color, width=line_width)
74
+ elif box_style == "thin":
75
+ line_width = 2
76
+ # Simple solid line, no shadow for maximum "thinness"
77
+ draw.rectangle([x1, y1, x2, y2], outline=color, width=line_width)
78
+ else:
79
+ # Dashed box (default)
80
+ dash_length = 10
81
+ gap_length = 5
82
+ line_width = 5
83
+ shadow_width = line_width + 3
84
+
85
+ def draw_dashed_line(start_x, start_y, end_x, end_y):
86
+ # Ensure we always draw from smaller to larger coordinate
87
+ if start_x == end_x: # Vertical
88
+ y_min, y_max = min(start_y, end_y), max(start_y, end_y)
89
+ y = y_min
90
+ while y < y_max:
91
+ dash_end = min(y + dash_length, y_max)
92
+ # Underlay + main stroke makes the dash read clearly on any background.
93
+ draw.line([(start_x, y), (start_x, dash_end)], fill=shadow_color, width=shadow_width)
94
+ draw.line([(start_x, y), (start_x, dash_end)], fill=color, width=line_width)
95
+ y += dash_length + gap_length
96
+ else: # Horizontal
97
+ x_min, x_max = min(start_x, end_x), max(start_x, end_x)
98
+ x = x_min
99
+ while x < x_max:
100
+ dash_end = min(x + dash_length, x_max)
101
+ draw.line([(x, start_y), (dash_end, start_y)], fill=shadow_color, width=shadow_width)
102
+ draw.line([(x, start_y), (dash_end, start_y)], fill=color, width=line_width)
103
+ x += dash_length + gap_length
104
+
105
+ draw_dashed_line(x1, y1, x2, y1)
106
+ draw_dashed_line(x2, y1, x2, y2)
107
+ draw_dashed_line(x2, y2, x1, y2)
108
+ draw_dashed_line(x1, y2, x1, y1)
109
+
110
+ label_text = ""
111
+ if number is not None:
112
+ label_text = str(number)
113
+ if text:
114
+ if label_text:
115
+ label_text += " " + text
116
+ else:
117
+ label_text = text
118
+
119
+ if label_text and image_size:
120
+ img_width, img_height = image_size
121
+
122
+ # Scale font based on image width (slightly larger for readability)
123
+ # Bump size a bit to make numbered overlays easier to read on captcha tiles.
124
+ base_font_size = max(14, min(48, int(img_width * 0.035)))
125
+ font = get_cross_platform_font(base_font_size)
126
+
127
+ # Get text size
128
+ if font:
129
+ bbox_text = draw.textbbox((0, 0), label_text, font=font)
130
+ else:
131
+ bbox_text = draw.textbbox((0, 0), label_text)
132
+
133
+ text_width = bbox_text[2] - bbox_text[0]
134
+ text_height = bbox_text[3] - bbox_text[1]
135
+
136
+ padding = 2 # Minimal padding
137
+
138
+ container_width = text_width + padding * 2
139
+ container_height = text_height + padding * 2
140
+
141
+ # Position logic
142
+ if label_position == "center":
143
+ # Center of the box
144
+ box_cx = (x1 + x2) / 2
145
+ box_cy = (y1 + y2) / 2
146
+
147
+ bg_x1 = box_cx - container_width / 2
148
+ bg_y1 = box_cy - container_height / 2
149
+ bg_x2 = box_cx + container_width / 2
150
+ bg_y2 = box_cy + container_height / 2
151
+
152
+ elif label_position == "bottom-right":
153
+ # Bottom Right of the box (inside)
154
+ bg_x2 = x2 - 4
155
+ bg_y2 = y2 - 4
156
+ bg_x1 = bg_x2 - container_width
157
+ bg_y1 = bg_y2 - container_height
158
+
159
+ elif label_position == "bottom-left":
160
+ # Bottom Left of the box (inside)
161
+ bg_x1 = x1 + 4
162
+ bg_y2 = y2 - 4
163
+ bg_x2 = bg_x1 + container_width
164
+ bg_y1 = bg_y2 - container_height
165
+
166
+ elif label_position == "top-right":
167
+ # Top Right of the box (inside)
168
+ bg_x2 = x2 - 4
169
+ bg_y1 = y1 + 4
170
+ bg_x1 = bg_x2 - container_width
171
+ bg_y2 = bg_y1 + container_height
172
+
173
+ else: # Default to top-left
174
+ bg_x1 = x1 + 4
175
+ bg_y1 = y1 + 4
176
+ bg_x2 = bg_x1 + container_width
177
+ bg_y2 = bg_y1 + container_height
178
+
179
+ # Text position
180
+ text_x = bg_x1 + (container_width - text_width) // 2
181
+ text_y = bg_y1 + (container_height - text_height) // 2 - bbox_text[1]
182
+
183
+ # Boundary checks (keep label inside image)
184
+ if bg_x1 < 0:
185
+ offset = -bg_x1
186
+ bg_x1 += offset
187
+ bg_x2 += offset
188
+ text_x += offset
189
+ if bg_y1 < 0:
190
+ offset = -bg_y1
191
+ bg_y1 += offset
192
+ bg_y2 += offset
193
+ text_y += offset
194
+ if bg_x2 > img_width:
195
+ offset = bg_x2 - img_width
196
+ bg_x1 -= offset
197
+ bg_x2 -= offset
198
+ text_x -= offset
199
+ if bg_y2 > img_height:
200
+ offset = bg_y2 - img_height
201
+ bg_y1 -= offset
202
+ bg_y2 -= offset
203
+ text_y -= offset
204
+
205
+ # Draw background and text
206
+ # Use the box color for the label background (e.g. red for high visibility)
207
+ fill_color = hex_to_rgba(color, alpha=200)
208
+
209
+ # Match label outline to the box color (instead of hardcoded white)
210
+ draw.rectangle([bg_x1, bg_y1, bg_x2, bg_y2], fill=fill_color, outline=color, width=2)
211
+ # Slight stroke helps keep text readable on busy backgrounds even at small sizes
212
+ draw.text(
213
+ (text_x, text_y),
214
+ label_text,
215
+ fill="white",
216
+ font=font,
217
+ stroke_width=2,
218
+ stroke_fill="black",
219
+ )
220
+
221
+
222
+ def draw_red_border(draw, bbox, width=3):
223
+ """Draw a solid red border around a bounding box."""
224
+ x1, y1, x2, y2 = bbox
225
+ color = "#FF0000"
226
+ draw.rectangle([x1, y1, x2, y2], outline=color, width=width)
227
+
228
+
229
+ def draw_arrow(draw, start, end, color="#FF0000", width=4):
230
+ """Draw an arrow from start to end (high visibility for model feedback)."""
231
+ x1, y1 = start
232
+ x2, y2 = end
233
+
234
+ draw.line([start, end], fill=color, width=width)
235
+
236
+ # Arrow head
237
+ angle = math.atan2(y2 - y1, x2 - x1)
238
+ arrow_len = 20
239
+ angle_offset = math.pi / 6 # 30 degrees
240
+
241
+ p1 = (x2 - arrow_len * math.cos(angle - angle_offset), y2 - arrow_len * math.sin(angle - angle_offset))
242
+ p2 = (x2 - arrow_len * math.cos(angle + angle_offset), y2 - arrow_len * math.sin(angle + angle_offset))
243
+
244
+ draw.polygon([end, p1, p2], fill=color)
245
+
246
+
247
+ def add_overlays_to_image(image_path: str, boxes: list[dict], output_path: str = None, label_position="top-left"):
248
+ """
249
+ Load an image, draw bounding boxes and numbered labels, and save it.
250
+
251
+ Args:
252
+ image_path: Path to source image
253
+ boxes: list of dicts with:
254
+ 'bbox': [x1, y1, x2, y2] (normalized) OR [x, y, w, h] (pixels)
255
+ 'text': str (optional)
256
+ 'number': int/str (optional)
257
+ 'color': str (optional)
258
+ output_path: Path to save result (defaults to overwriting image_path)
259
+ label_position: "top-left", "bottom-right", "center"
260
+ """
261
+ try:
262
+ with Image.open(image_path) as img:
263
+ img = img.convert("RGBA")
264
+ draw = ImageDraw.Draw(img)
265
+ width, height = img.size
266
+
267
+ for box in boxes:
268
+ bbox = box["bbox"]
269
+
270
+ # Check for normalized coordinates (all <= 1.0)
271
+ # Note: This heuristic might fail for very small images or 1x1 pixel crops,
272
+ # but valid for standard captcha images.
273
+ is_normalized = all(x <= 1.0 for x in bbox)
274
+
275
+ if is_normalized:
276
+ # Assume [x_min, y_min, x_max, y_max] for normalized
277
+ x1 = bbox[0] * width
278
+ y1 = bbox[1] * height
279
+ x2 = bbox[2] * width
280
+ y2 = bbox[3] * height
281
+ else:
282
+ # Assume [x, y, w, h] for pixels (legacy support)
283
+ x, y, w, h = bbox
284
+ x1, y1, x2, y2 = x, y, x + w, y + h
285
+
286
+ bbox_coords = (x1, y1, x2, y2)
287
+ text = box.get("text")
288
+ number = box.get("number")
289
+ color = box.get("color", "#FF6B6B")
290
+ box_style = box.get("box_style") or box.get("style") or "thin"
291
+
292
+ draw_enhanced_bounding_box(
293
+ draw,
294
+ bbox_coords,
295
+ text=text,
296
+ number=number,
297
+ image_size=img.size,
298
+ color=color,
299
+ label_position=label_position,
300
+ box_style=box_style,
301
+ )
302
+
303
+ img = img.convert("RGB")
304
+ save_path = output_path if output_path else image_path
305
+ img.save(save_path)
306
+
307
+ except Exception as e:
308
+ print(f"Error adding overlays: {e}", file=sys.stderr)
309
+ raise
310
+
311
+
312
+ def draw_grid_overlay(draw, image_size, step=0.1):
313
+ """Draw a 10% grid overlay with labels (more readable)."""
314
+ width, height = image_size
315
+ line_color = "#00FF00" # Bright green
316
+ text_color = line_color
317
+ # Darker background + stronger opacity to keep labels readable on bright images
318
+ bg_color = (0, 0, 0, 230)
319
+ # Make labels more prominent
320
+ font_size = max(14, min(32, int(width * 0.04)))
321
+ font = get_cross_platform_font(font_size)
322
+ # Thicker lines for a more pronounced overlay
323
+ line_width = 3
324
+ # Stronger underlay to keep grid visible over busy backgrounds
325
+ shadow_color = (0, 0, 0, 210)
326
+ label_outline_width = 3
327
+ label_pad = 8
328
+
329
+ # Draw vertical lines and labels
330
+ for i in range(1, int(1 / step)):
331
+ x = int(i * step * width)
332
+ # Add a subtle dark underlay to increase contrast on bright/green backgrounds
333
+ draw.line([(x, 0), (x, height)], fill=shadow_color, width=line_width + 4)
334
+ draw.line([(x, 0), (x, height)], fill=line_color, width=line_width)
335
+ label = f"{int(i * step * 100)}%"
336
+ if font:
337
+ bbox = draw.textbbox((0, 0), label, font=font)
338
+ else:
339
+ bbox = draw.textbbox((0, 0), label)
340
+ w = bbox[2] - bbox[0]
341
+ h = bbox[3] - bbox[1]
342
+ # Top label background
343
+ draw.rectangle(
344
+ [x + 2, 2, x + 2 + w + label_pad, 2 + h + label_pad],
345
+ fill=bg_color,
346
+ outline=line_color,
347
+ width=label_outline_width,
348
+ )
349
+ draw.text(
350
+ (x + 2 + label_pad // 2, 2 + label_pad // 2),
351
+ label,
352
+ fill=text_color,
353
+ font=font,
354
+ stroke_width=2,
355
+ stroke_fill="black",
356
+ )
357
+
358
+ # Draw horizontal lines and labels
359
+ for i in range(1, int(1 / step)):
360
+ y = int(i * step * height)
361
+ draw.line([(0, y), (width, y)], fill=shadow_color, width=line_width + 4)
362
+ draw.line([(0, y), (width, y)], fill=line_color, width=line_width)
363
+ label = f"{int(i * step * 100)}%"
364
+ if font:
365
+ bbox = draw.textbbox((0, 0), label, font=font)
366
+ else:
367
+ bbox = draw.textbbox((0, 0), label)
368
+ w = bbox[2] - bbox[0]
369
+ h = bbox[3] - bbox[1]
370
+ # Left label background
371
+ draw.rectangle(
372
+ [2, y + 2, 2 + w + label_pad, y + 2 + h + label_pad],
373
+ fill=bg_color,
374
+ outline=line_color,
375
+ width=label_outline_width,
376
+ )
377
+ draw.text(
378
+ (2 + label_pad // 2, y + 2 + label_pad // 2),
379
+ label,
380
+ fill=text_color,
381
+ font=font,
382
+ stroke_width=2,
383
+ stroke_fill="black",
384
+ )
385
+
386
+
387
+ def draw_edge_rulers(draw, image_size, step=0.1, tick_length=8):
388
+ """Draw subtle ruler tick marks along image edges at percentage intervals.
389
+ Provides spatial reference without covering captcha content."""
390
+ width, height = image_size
391
+ tick_color = (180, 180, 180, 160) # Semi-transparent gray
392
+ label_color = (150, 150, 150, 200)
393
+ font = get_cross_platform_font(max(9, min(14, int(width * 0.02))))
394
+
395
+ for i in range(1, int(1 / step)):
396
+ pct = i * step
397
+ x = int(pct * width)
398
+ y = int(pct * height)
399
+
400
+ # Top and bottom edge ticks
401
+ draw.line([(x, 0), (x, tick_length)], fill=tick_color, width=1)
402
+ draw.line([(x, height - tick_length), (x, height)], fill=tick_color, width=1)
403
+ # Left and right edge ticks
404
+ draw.line([(0, y), (tick_length, y)], fill=tick_color, width=1)
405
+ draw.line([(width - tick_length, y), (width, y)], fill=tick_color, width=1)
406
+
407
+ # Small percentage labels at top and left edges only
408
+ label = f"{int(pct * 100)}"
409
+ if font:
410
+ draw.text((x + 2, 1), label, fill=label_color, font=font)
411
+ draw.text((1, y + 2), label, fill=label_color, font=font)
412
+
413
+
414
+ def add_drag_overlay(
415
+ image_path: str,
416
+ source_bbox: list[float],
417
+ target_bbox: list[float] = None,
418
+ target_center: tuple = None,
419
+ show_grid: bool = False,
420
+ foreground_image: Image.Image = None,
421
+ mask_points: list[list[float]] = None,
422
+ ):
423
+ """
424
+ Add drag-and-drop visualization.
425
+ - THIN GREEN box: The item being dragged at its current location.
426
+ """
427
+ try:
428
+ # Load image with PIL
429
+ with Image.open(image_path) as img:
430
+ img = img.convert("RGBA")
431
+
432
+ x1, y1, x2, y2 = map(int, source_bbox)
433
+ # Clamp
434
+ w, h = img.size
435
+ x1 = max(0, x1); y1 = max(0, y1)
436
+ x2 = min(w, x2); y2 = min(h, y2)
437
+
438
+ # 1. Prepare Background - use cv2.inpaint for clean source removal
439
+ bg_rgb = np.array(img.convert("RGB"))
440
+ bg_bgr = cv2.cvtColor(bg_rgb, cv2.COLOR_RGB2BGR)
441
+
442
+ if mask_points:
443
+ inpaint_mask = np.zeros(bg_bgr.shape[:2], dtype=np.uint8)
444
+ pts = np.array([(int(p[0] * w), int(p[1] * h)) for p in mask_points], dtype=np.int32)
445
+ cv2.fillPoly(inpaint_mask, [pts], 255)
446
+ else:
447
+ inpaint_mask = np.zeros(bg_bgr.shape[:2], dtype=np.uint8)
448
+ inpaint_mask[y1:y2, x1:x2] = 255
449
+
450
+ kernel = np.ones((5, 5), np.uint8)
451
+ dilated_mask = cv2.dilate(inpaint_mask, kernel, iterations=2)
452
+ bg_inpainted = cv2.inpaint(bg_bgr, dilated_mask, 3, cv2.INPAINT_TELEA)
453
+ bg_inpainted_rgb = cv2.cvtColor(bg_inpainted, cv2.COLOR_BGR2RGB)
454
+ background = Image.fromarray(bg_inpainted_rgb).convert("RGBA")
455
+
456
+ # 2. Dim background slightly
457
+ dim_overlay = Image.new("RGBA", img.size, (0, 0, 0, 40))
458
+ background = Image.alpha_composite(background, dim_overlay)
459
+
460
+ # 3. Prepare Object
461
+ if foreground_image:
462
+ object_crop = foreground_image.resize((x2-x1, y2-y1))
463
+ elif mask_points:
464
+ # Create masked crop using mask_points
465
+ mask = Image.new("L", (w, h), 0)
466
+ draw_mask = ImageDraw.Draw(mask)
467
+ pts = [(p[0] * w, p[1] * h) for p in mask_points]
468
+ draw_mask.polygon(pts, fill=255)
469
+
470
+ object_rgba = img.convert("RGBA")
471
+ object_rgba.putalpha(mask)
472
+ object_crop = object_rgba.crop((x1, y1, x2, y2))
473
+ else:
474
+ object_crop = img.crop((x1, y1, x2, y2))
475
+
476
+ # 4. Paste at Target
477
+ if target_center:
478
+ tx, ty = target_center
479
+ else:
480
+ tx, ty = (x1 + x2) / 2, (y1 + y2) / 2
481
+
482
+ cw, ch = object_crop.size
483
+ paste_x = int(tx - cw / 2)
484
+ paste_y = int(ty - ch / 2)
485
+
486
+ background.alpha_composite(object_crop, (paste_x, paste_y))
487
+
488
+ # 5. Draw Overlays
489
+ draw = ImageDraw.Draw(background)
490
+ if show_grid:
491
+ draw_grid_overlay(draw, background.size, step=0.1)
492
+
493
+ # Subtle edge ruler marks for spatial reference
494
+ draw_edge_rulers(draw, background.size)
495
+
496
+ # THIN GREEN BOX around the item being dragged (no labels, no source box, no arrow)
497
+ draw_enhanced_bounding_box(draw, [paste_x, paste_y, paste_x + cw, paste_y + ch], color="#00FF00", box_style="thin", image_size=background.size)
498
+
499
+ background = background.convert("RGB")
500
+ background.save(image_path)
501
+
502
+ except Exception as e:
503
+ print(f"Error adding drag overlays: {e}", file=sys.stderr)
504
+ raise
505
+
506
+
507
+ if __name__ == "__main__":
508
+ parser = argparse.ArgumentParser(description="Add overlays to an image")
509
+ parser.add_argument("image_path", help="Path to the image to modify")
510
+ parser.add_argument("boxes_json", help="JSON string containing list of boxes with 'bbox' and 'text'")
511
+
512
+ args = parser.parse_args()
513
+
514
+ try:
515
+ boxes = json.loads(args.boxes_json)
516
+ add_overlays_to_image(args.image_path, boxes)
517
+ print(json.dumps({"status": "success"}))
518
+ except Exception as e:
519
+ print(json.dumps({"status": "error", "message": str(e)}))
520
+ sys.exit(1)