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,431 @@
1
+ """Detect the hCaptcha "Move" draggable indicator and the movable object below it.
2
+
3
+ TEAL-BODY-FIRST redesign. hCaptcha drag-style puzzles (connect_path,
4
+ drag_missing_slot, missing_piece, tetris_fit, fish_swim_different,
5
+ drag_numbered_line_pieces, ...) stack one or more draggable CARDS, each topped by
6
+ a dark rounded "Move" pill: a four-arrow glyph (⊹) plus the white word "Move".
7
+
8
+ This module gives two pure-OpenCV steps (no GPU, no network):
9
+
10
+ 1. find_move_indicators(im) -> [x, y, w, h] of every Move pill
11
+ 2. find_movable_content(im, pill) -> the card / object box BELOW one pill
12
+
13
+ Detection strategy (step 1) — TEAL BODY FIRST. The pill BODY is a fixed UI
14
+ element rendered as a flat dark desaturated TEAL: HSV hue ~108 (BGR ~(56,45,38),
15
+ channels ordered B>G>R), saturation ~82, value ~56. This colour is BYTE-STABLE
16
+ and opacity-robust: it stays teal even when the white text is faint, fragmented,
17
+ or sliced by a thin horizontal artifact (the failure mode of a wordmark-first
18
+ detector on pills embedded in a dark side-panel).
19
+
20
+ So we:
21
+
22
+ * Build a mask of pill-teal pixels (hue [103,113], S>=40, V in [35,95]),
23
+ close it into solid horizontal blobs, and take connected components with
24
+ pill GEOMETRY (rounded rectangle: w 50-175, h 16-46, aspect 2.0-7.5, and a
25
+ high-extent FILLED rectangle). This catches pills on light cards AND dark
26
+ panels AND artifact-sliced pills, because the body colour is robust.
27
+ * CONFIRM each teal candidate is a real Move pill by requiring SOME white
28
+ wordmark evidence inside the body (a minimum count of bright low-saturation
29
+ pixels spread horizontally across the interior). Because candidate generation
30
+ is teal-locked, confirmation can be LENIENT on text faintness without
31
+ exploding false positives — the only competitors are textured photographic
32
+ tiles, whose bright pixels are sparse and clustered, not a spread wordmark.
33
+ * UNION with the legacy wordmark-first path (for any pill the teal mask misses)
34
+ and dedup overlapping boxes.
35
+
36
+ Post-filters: a vertical band [0.12H, 0.87H] (excludes the bottom "Skip" button
37
+ and top chrome) and IoU/center dedup. Byte-deterministic per image.
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ from typing import List, Optional
43
+
44
+ import cv2
45
+ import numpy as np
46
+
47
+ # ── Move-pill body colour ────────────────────────────────────────────────────
48
+ # Flat dark desaturated TEAL: HSV hue locked at ~108, BGR ordered B>G>R with a
49
+ # tight characteristic spacing (B-G≈11, G-R≈7). Byte-stable across hundreds of
50
+ # real pill bodies; photographic tiles scatter across hue space and break the
51
+ # B>G>R ordering.
52
+ _PILL_HUE = 108
53
+
54
+ # Geometry / threshold constants (px on the ~520-wide hCaptcha canvas; gates are
55
+ # structural, not absolute coordinates, so they hold at other sizes).
56
+ _BRIGHT_V = 150 # min HSV V for "white ink" (glyph + text)
57
+ _BRIGHT_S = 90 # max HSV S for "white ink"
58
+ _DARK_V = 110 # below this V is "dark pill body"
59
+ _Y_BAND = (0.12, 0.87) # keep pills inside the puzzle body; excludes the bottom
60
+ # "Skip" button and top chrome.
61
+
62
+ # ── teal candidate-generation params ─────────────────────────────────────────
63
+ _TEAL_HUE_LO, _TEAL_HUE_HI = 103, 113 # pill teal hue window (±5 of 108)
64
+ _TEAL_S_MIN = 40 # pill body saturation floor
65
+ _TEAL_V_LO, _TEAL_V_HI = 35, 95 # pill body value window
66
+ _TEAL_W = (50, 175) # pill body width range (px)
67
+ _TEAL_H = (16, 46) # pill body height range (px)
68
+ _TEAL_AR = (2.0, 7.5) # pill aspect ratio (w/h)
69
+ _TEAL_EXTENT = 0.80 # filled-rectangle floor (area / w·h)
70
+
71
+ # ── confirmation params (white wordmark evidence inside the teal body) ───────
72
+ # Measured separation on the corpus:
73
+ # real pills : bright_frac 0.08-0.16, bright_px 160-190, hspan ≥ ~0.5·w
74
+ # (even content-blank/faint/sliced cases clear these)
75
+ # FP teal tiles : bright_frac ≤ 0.04, bright_px ≤ 76, hspan small/fragmented
76
+ # We require a spread (hspan) AND a mass (frac) of bright ink, which lenient on
77
+ # faintness but rejects the sparse, clustered bright pixels of photo texture.
78
+ _CONF_BRIGHT_FRAC = 0.055 # min bright-pixel fraction inside the body
79
+ _CONF_BRIGHT_PX = 80 # min absolute bright-pixel count inside the body
80
+ _CONF_HSPAN = 0.45 # bright ink must span ≥ this fraction of body width
81
+
82
+
83
+ def find_move_indicators(im: np.ndarray) -> List[List[int]]:
84
+ """Return ``[x, y, w, h]`` boxes (pixels) of every hCaptcha "Move" pill in the
85
+ BGR image ``im``. Empty list when there is none (e.g. grid / click puzzles).
86
+ """
87
+ H, W = im.shape[:2]
88
+
89
+ # Teal-body-first candidates (the primary path), UNION the legacy wordmark
90
+ # path (recovers anything the teal mask misses), then dedup.
91
+ cands = _teal_body_pills(im)
92
+ cands.extend(_wordmark_runs(im))
93
+
94
+ y_lo, y_hi = _Y_BAND[0] * H, _Y_BAND[1] * H
95
+ cands = [c for c in cands if y_lo <= c[1] + c[3] / 2 <= y_hi]
96
+
97
+ merged: List[List[int]] = []
98
+ for c in sorted(cands, key=lambda c: -c[2] * c[3]):
99
+ if all(not _centers_overlap(c, m) for m in merged):
100
+ merged.append(c)
101
+ # stable ordering: top-to-bottom, then left-to-right
102
+ merged.sort(key=lambda c: (c[1], c[0]))
103
+ return merged
104
+
105
+
106
+ def find_movable_content(
107
+ im: np.ndarray,
108
+ indicator: List[int],
109
+ *,
110
+ pad: int = 4,
111
+ ) -> Optional[List[int]]:
112
+ """Given one Move pill ``indicator`` (``[x, y, w, h]``), return the bounding
113
+ box ``[x, y, w, h]`` of the movable CARD/object that sits directly BELOW it.
114
+
115
+ The pill caps the top of a draggable card. We walk down from just under the
116
+ pill, gathering the contiguous run of card rows (rows that contain real,
117
+ non-background content) until we hit a gap (the margin before the next card
118
+ or the panel edge), and bound it horizontally to the column the pill heads.
119
+ Pure OpenCV; returns None if no content is found under the pill.
120
+ """
121
+ H, W = im.shape[:2]
122
+ px, py, pw, ph = indicator
123
+ gray = cv2.cvtColor(im, cv2.COLOR_BGR2GRAY)
124
+
125
+ # Horizontal search window: centred on the pill, a bit wider (cards are
126
+ # usually a touch wider than their pill), clamped to the frame.
127
+ cx = px + pw / 2
128
+ half = max(pw, 70) * 0.75
129
+ sx0 = int(max(0, cx - half))
130
+ sx1 = int(min(W, cx + half))
131
+
132
+ # Start just below the pill; scan downward.
133
+ top = min(H - 1, py + ph + 1)
134
+
135
+ # Per-row "has content" signal: a row inside a card has internal structure
136
+ # (edges) and/or a spread of intensities; a margin/gap row is flat.
137
+ band = gray[:, sx0:sx1]
138
+ row_std = band.std(axis=1)
139
+ # Robust background estimate from the rows immediately under the pill margin.
140
+ gap_thresh = max(6.0, float(np.median(row_std)) * 0.5)
141
+
142
+ # Find the contiguous content run starting at/after `top`.
143
+ y = top
144
+ # skip an initial thin margin (up to ~14px) between pill and card body
145
+ skip = 0
146
+ while y < H and row_std[y] < gap_thresh and skip < 16:
147
+ y += 1
148
+ skip += 1
149
+ content_start = y
150
+ run_gap = 0
151
+ last_content = content_start
152
+ while y < H:
153
+ if row_std[y] >= gap_thresh:
154
+ last_content = y
155
+ run_gap = 0
156
+ else:
157
+ run_gap += 1
158
+ if run_gap >= 10: # a real gap ends this card
159
+ break
160
+ y += 1
161
+ if last_content <= content_start:
162
+ return None
163
+
164
+ y0, y1 = content_start, last_content + 1
165
+
166
+ # Tighten horizontally to the actual content columns within the run.
167
+ sub = gray[y0:y1, sx0:sx1]
168
+ col_std = sub.std(axis=0)
169
+ cthr = max(6.0, float(np.median(col_std)) * 0.5)
170
+ cols = np.where(col_std >= cthr)[0]
171
+ if cols.size:
172
+ cx0 = sx0 + int(cols.min())
173
+ cx1 = sx0 + int(cols.max()) + 1
174
+ else:
175
+ cx0, cx1 = sx0, sx1
176
+
177
+ bx = max(0, cx0 - pad)
178
+ by = max(0, y0 - pad)
179
+ bw = min(W - bx, (cx1 - cx0) + 2 * pad)
180
+ bh = min(H - by, (y1 - y0) + 2 * pad)
181
+ if bw < 12 or bh < 12:
182
+ return None
183
+ return [int(bx), int(by), int(bw), int(bh)]
184
+
185
+
186
+ # ── internals ────────────────────────────────────────────────────────────────
187
+
188
+
189
+ def _teal_body_pills(im: np.ndarray) -> List[List[int]]:
190
+ """PRIMARY path: find pills directly by their flat-teal BODY, then confirm
191
+ each with white-wordmark evidence inside.
192
+
193
+ Candidate generation is teal-locked (opacity-robust, survives faint/sliced
194
+ text), so it catches the dark-side-panel pills a wordmark-first detector
195
+ drops. Confirmation is lenient on text faintness because the only things that
196
+ can pass the teal+geometry gate are real pills and the rare photographic tile
197
+ that happens to median teal — and those have sparse, clustered bright pixels,
198
+ not a spread wordmark.
199
+ """
200
+ H, W = im.shape[:2]
201
+ hsv = cv2.cvtColor(im, cv2.COLOR_BGR2HSV)
202
+ Hh = hsv[:, :, 0]
203
+ S = hsv[:, :, 1]
204
+ V = hsv[:, :, 2]
205
+
206
+ mask = (
207
+ (Hh >= _TEAL_HUE_LO)
208
+ & (Hh <= _TEAL_HUE_HI)
209
+ & (S >= _TEAL_S_MIN)
210
+ & (V >= _TEAL_V_LO)
211
+ & (V <= _TEAL_V_HI)
212
+ ).astype(np.uint8)
213
+
214
+ # Close into solid horizontal blobs (bridge the white glyph/text gaps inside
215
+ # the body and any thin artifact slice through it).
216
+ closed = cv2.morphologyEx(
217
+ mask, cv2.MORPH_CLOSE,
218
+ cv2.getStructuringElement(cv2.MORPH_RECT, (15, 3)),
219
+ )
220
+
221
+ n, lbl, stats, _ = cv2.connectedComponentsWithStats(closed)
222
+ out: List[List[int]] = []
223
+ for i in range(1, n):
224
+ x, y, w, h, a = stats[i]
225
+ if not (_TEAL_W[0] <= w <= _TEAL_W[1] and _TEAL_H[0] <= h <= _TEAL_H[1]):
226
+ continue
227
+ ar = w / max(h, 1)
228
+ if not (_TEAL_AR[0] <= ar <= _TEAL_AR[1]):
229
+ continue
230
+ if a / float(w * h) < _TEAL_EXTENT: # must be a FILLED rounded rectangle
231
+ continue
232
+ if not _wordmark_inside(im, x, y, w, h):
233
+ continue
234
+ # Emit a box padded to span the full pill (the teal blob is the inner
235
+ # body; the rounded corners + a little margin extend slightly beyond).
236
+ bx = max(0, x - 4)
237
+ by = max(0, y - 4)
238
+ bw = min(W - bx, w + 8)
239
+ bh = min(H - by, h + 8)
240
+ out.append([int(bx), int(by), int(bw), int(bh)])
241
+ return out
242
+
243
+
244
+ def _wordmark_inside(im: np.ndarray, x: int, y: int, w: int, h: int) -> bool:
245
+ """CONFIRMATION: require white-wordmark evidence inside the teal body.
246
+
247
+ A real pill carries "[glyph] Move" — bright (V>150, S<90) ink spread
248
+ horizontally across the body interior. Photographic teal tiles have only
249
+ sparse, clustered bright pixels. We gate on (a) enough bright mass (count and
250
+ fraction) and (b) horizontal spread, all measured inside the candidate body.
251
+ """
252
+ sub = im[y:y + h, x:x + w]
253
+ if sub.size == 0:
254
+ return False
255
+ hsv = cv2.cvtColor(sub, cv2.COLOR_BGR2HSV)
256
+ bright = (hsv[:, :, 2] > _BRIGHT_V) & (hsv[:, :, 1] < _BRIGHT_S)
257
+ bcount = int(bright.sum())
258
+ if bcount < _CONF_BRIGHT_PX:
259
+ return False
260
+ if bright.mean() < _CONF_BRIGHT_FRAC:
261
+ return False
262
+ cols = np.where(bright.sum(axis=0) > 0)[0]
263
+ if cols.size == 0:
264
+ return False
265
+ span = cols.max() - cols.min() + 1
266
+ if span < _CONF_HSPAN * w:
267
+ return False
268
+ return True
269
+
270
+
271
+ def _wordmark_runs(im: np.ndarray) -> List[List[int]]:
272
+ """LEGACY (union) path: find Move pills by their white "[glyph] Move"
273
+ wordmark, then gate each on a dark pill body, a left glyph cluster, and the
274
+ flat-teal pill body colour. Recovers any pill the teal-body path misses."""
275
+ H, W = im.shape[:2]
276
+ hsv = cv2.cvtColor(im, cv2.COLOR_BGR2HSV)
277
+ V = hsv[:, :, 2]
278
+ S = hsv[:, :, 1]
279
+ bright = ((V > _BRIGHT_V) & (S < _BRIGHT_S)).astype(np.uint8)
280
+
281
+ # Strip large solid bright blobs (cards / shapes / instruction text); keep the
282
+ # thin glyph + letter strokes. This makes the wordmark pop whether the pill is
283
+ # on a light card or inside a dark side-panel.
284
+ op = cv2.morphologyEx(
285
+ bright, cv2.MORPH_OPEN, cv2.getStructuringElement(cv2.MORPH_RECT, (5, 5))
286
+ )
287
+ n, lbl, stats, _ = cv2.connectedComponentsWithStats(op)
288
+ bigsolid = np.zeros_like(bright)
289
+ for i in range(1, n):
290
+ x, y, w, h, a = stats[i]
291
+ if a > 400 and (w > 40 or h > 22):
292
+ bigsolid[lbl == i] = 1
293
+ bigsolid = cv2.dilate(bigsolid, cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3)))
294
+ textonly = cv2.subtract(bright, bigsolid)
295
+
296
+ # Close strokes horizontally into "[glyph] Move" wordmark blobs.
297
+ closed = cv2.morphologyEx(
298
+ textonly * 255,
299
+ cv2.MORPH_CLOSE,
300
+ cv2.getStructuringElement(cv2.MORPH_RECT, (23, 3)),
301
+ )
302
+ nn, ll, ss, _ = cv2.connectedComponentsWithStats(closed)
303
+ out: List[List[int]] = []
304
+ for i in range(1, nn):
305
+ x, y, w, h, a = ss[i]
306
+ ar = w / max(h, 1)
307
+ if not (2.8 < ar < 13 and 5 <= h <= 22 and 30 <= w <= 130):
308
+ continue
309
+ sub = V[y:y + h, x:x + w]
310
+ tmask = textonly[y:y + h, x:x + w] > 0
311
+ body = sub[~tmask]
312
+ if body.size == 0:
313
+ continue
314
+ if np.median(body) > 85: # pill body must be dark
315
+ continue
316
+ if (body < 100).mean() < 0.45: # solidly dark, not a stray run
317
+ continue
318
+ if not _glyph_present(bright, x, y, w, h, H, W):
319
+ continue
320
+ if not _pill_body_match(im, x, y, w, h):
321
+ continue
322
+ bx = max(0, x - 30)
323
+ by = max(0, y - 9)
324
+ bw = min(W - bx, w + 50)
325
+ bh = min(H - by, h + 20)
326
+ out.append([int(bx), int(by), int(bw), int(bh)])
327
+ return out
328
+
329
+
330
+ def _glyph_present(
331
+ bright: np.ndarray, x: int, y: int, w: int, h: int, H: int, W: int
332
+ ) -> bool:
333
+ """The move glyph (four-arrow symbol) is a compact bright cluster immediately
334
+ LEFT of "Move". Require a bright cluster in the left ~40% of the wordmark.
335
+ "FINISH" / "Skip" labels have NO bright pixels there, so they fail.
336
+
337
+ The glyph renders at varying opacity: when crisp it's a tall plus/cross that
338
+ spans most of the text height; when faint only its vertical stroke + arrow
339
+ tips clear the brightness threshold. So accept either a tall-enough cluster
340
+ OR a smaller cluster with enough concentrated bright mass — both clearly
341
+ distinguish a glyph from "no glyph at all"."""
342
+ gy0, gy1 = max(0, y - 3), min(H, y + h + 3)
343
+ gx0, gx1 = max(0, x - 7), min(W, x + w)
344
+ sub = bright[gy0:gy1, gx0:gx1]
345
+ if sub.size == 0:
346
+ return False
347
+ sh, sw = sub.shape
348
+ lw = max(5, int(sw * 0.40))
349
+ left = sub[:, :lw]
350
+ rows = np.where(left.sum(axis=1) > 0)[0]
351
+ if rows.size == 0:
352
+ return False
353
+ vext = rows.max() - rows.min() + 1
354
+ mass = int(left.sum())
355
+ if vext >= 0.35 * sh and mass >= 6:
356
+ return True
357
+ if mass >= 10 and vext >= 0.25 * sh:
358
+ return True
359
+ return False
360
+
361
+
362
+ def _pill_body_match(im: np.ndarray, x: int, y: int, w: int, h: int) -> bool:
363
+ """True iff the dark body inside the wordmark bbox is the flat teal Move pill.
364
+
365
+ Trims padding to a central core, takes the dark (non-text) pixels, and gates
366
+ on: (1) median hue within 4 of the pill teal (108); (2) BGR channel ordering
367
+ B>G>R with the pill's spacing (B-G in [5,15], G-R in [3,13]); (3) saturation
368
+ >= 40; (4) flat fill — grayscale std <= 24 AND hue std <= 14 (a photo tile
369
+ that coincidentally medians teal is still textured, so its hue/gray std blow
370
+ past these). Models the rendered UI element, not coordinates."""
371
+ tx = int(w * 0.15)
372
+ ty = int(h * 0.12)
373
+ x0, x1 = x + tx, x + w - tx
374
+ y0, y1 = y + ty, y + h - ty
375
+ if x1 - x0 < 10 or y1 - y0 < 6:
376
+ x0, x1, y0, y1 = x, x + w, y, y + h
377
+ crop = im[y0:y1, x0:x1]
378
+ if crop.size == 0:
379
+ return False
380
+ hsv = cv2.cvtColor(crop, cv2.COLOR_BGR2HSV)
381
+ Hh = hsv[:, :, 0].astype(np.float32)
382
+ V = hsv[:, :, 2].astype(np.float32)
383
+ S = hsv[:, :, 1].astype(np.float32)
384
+ bright = (V > _BRIGHT_V) & (S < _BRIGHT_S)
385
+ dark = (V < _DARK_V) & (~bright)
386
+ if dark.sum() < 15:
387
+ return False
388
+ px = crop.reshape(-1, 3).astype(np.float32)[dark.reshape(-1)]
389
+ b, g, r = np.median(px, axis=0)
390
+ hue_med = float(np.median(Hh[dark]))
391
+ sat_med = float(np.median(S[dark]))
392
+ if abs(hue_med - _PILL_HUE) > 4: # body must be the pill teal
393
+ return False
394
+ if not (5 <= b - g <= 15 and 3 <= g - r <= 13): # B>G>R, pill channel spacing
395
+ return False
396
+ if sat_med < 40:
397
+ return False
398
+ gray_std = float(px.mean(axis=1).std())
399
+ hue_std = float(Hh[dark].std())
400
+ # Flat fill, not photo texture. The pill body is a single rendered teal, so
401
+ # its hue is essentially constant (hue_std ≤ ~1.5 across every real pill);
402
+ # the corpus's coincidental-teal photo tiles, even when they median teal, are
403
+ # textured and scatter in hue (hue_std 3-44). hue_std ≤ 2 removes all four
404
+ # legacy false positives while keeping every real legacy-only recovery.
405
+ if gray_std > 24 or hue_std > 2.0:
406
+ return False
407
+ return True
408
+
409
+
410
+ def _iou(a: List[int], b: List[int]) -> float:
411
+ ax, ay, aw, ah = a
412
+ bx, by, bw, bh = b
413
+ ix0, iy0 = max(ax, bx), max(ay, by)
414
+ ix1, iy1 = min(ax + aw, bx + bw), min(ay + ah, by + bh)
415
+ iw, ih = max(0, ix1 - ix0), max(0, iy1 - iy0)
416
+ inter = iw * ih
417
+ return 0.0 if inter == 0 else inter / (aw * ah + bw * bh - inter)
418
+
419
+
420
+ def _centers_overlap(a: List[int], b: List[int]) -> bool:
421
+ """Dedup test robust to differently-sized boxes of the same pill: True if
422
+ IoU>=0.3 OR either box's center lies inside the other."""
423
+ if _iou(a, b) >= 0.3:
424
+ return True
425
+ ax, ay, aw, ah = a
426
+ bx, by, bw, bh = b
427
+ acx, acy = ax + aw / 2, ay + ah / 2
428
+ bcx, bcy = bx + bw / 2, by + bh / 2
429
+ return (bx <= acx <= bx + bw and by <= acy <= by + bh) or (
430
+ ax <= bcx <= ax + aw and ay <= bcy <= ay + ah
431
+ )
@@ -0,0 +1,29 @@
1
+ // Bundle the sibling `../python` (the `captchakraken` package) into `./python`
2
+ // so the published npm package is self-contained: the JS solver shells out to
3
+ // this bundled engine for OpenCV grid detection and the vLLM planner.
4
+ //
5
+ // npm can only pack files inside the package root, so the single source of truth
6
+ // at repo-root `python/` is copied in here at build time. Runtime source (src/)
7
+ // + pyproject are what the postinstall `pip install .` needs; tests, caches,
8
+ // venvs and build artifacts are skipped.
9
+ import { cpSync, rmSync, existsSync } from 'node:fs'
10
+ import { dirname, resolve } from 'node:path'
11
+ import { fileURLToPath } from 'node:url'
12
+
13
+ const here = dirname(fileURLToPath(import.meta.url))
14
+ const src = resolve(here, '../../python')
15
+ const dest = resolve(here, '../python')
16
+
17
+ if (!existsSync(src)) {
18
+ console.error(`[copy-python] source engine not found at ${src}`)
19
+ process.exit(1)
20
+ }
21
+
22
+ const SKIP = new Set(['.venv', '__pycache__', 'dist', 'build', '.pytest_cache', '.ruff_cache', 'tests'])
23
+
24
+ rmSync(dest, { recursive: true, force: true })
25
+ cpSync(src, dest, {
26
+ recursive: true,
27
+ filter: (p) => !p.split(/[\\/]/).some((seg) => SKIP.has(seg) || seg.endsWith('.egg-info')),
28
+ })
29
+ console.log('[copy-python] bundled python/ engine -> js/python/')
@@ -0,0 +1,104 @@
1
+ /**
2
+ * postinstall bootstrap: create a local venv under `python/.venv` and install the
3
+ * bundled `captchakraken` package so `python -m captchakraken.cli` works out-of-the-box.
4
+ *
5
+ * Opt out:
6
+ * CAPTCHA_KRAKEN_SKIP_PYTHON_SETUP=1
7
+ *
8
+ * Force a specific system python:
9
+ * CAPTCHA_KRAKEN_PYTHON=/path/to/python3
10
+ */
11
+ const { spawnSync } = require('child_process');
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+
15
+ function run(cmd, args, opts = {}) {
16
+ const res = spawnSync(cmd, args, { stdio: 'inherit', ...opts });
17
+ if (res.error) throw res.error;
18
+ if (res.status !== 0) {
19
+ throw new Error(`Command failed (${res.status}): ${cmd} ${args.join(' ')}`);
20
+ }
21
+ }
22
+
23
+ function exists(p) {
24
+ try {
25
+ fs.accessSync(p);
26
+ return true;
27
+ } catch {
28
+ return false;
29
+ }
30
+ }
31
+
32
+ function venvPython(venvDir) {
33
+ const candidates = [
34
+ path.join(venvDir, 'bin', 'python'),
35
+ path.join(venvDir, 'bin', 'python3'),
36
+ path.join(venvDir, 'Scripts', 'python.exe'),
37
+ path.join(venvDir, 'Scripts', 'python'),
38
+ ];
39
+ for (const c of candidates) if (exists(c)) return c;
40
+ return null;
41
+ }
42
+
43
+ function resolveSystemPython() {
44
+ if (process.env.CAPTCHA_KRAKEN_PYTHON) return process.env.CAPTCHA_KRAKEN_PYTHON;
45
+ // Try python3 first, then python.
46
+ return process.platform === 'win32' ? 'python' : 'python3';
47
+ }
48
+
49
+ function main() {
50
+ if (process.env.CAPTCHA_KRAKEN_SKIP_PYTHON_SETUP === '1') {
51
+ console.log('[CaptchaKraken] Skipping python setup (CAPTCHA_KRAKEN_SKIP_PYTHON_SETUP=1).');
52
+ return;
53
+ }
54
+
55
+ const pkgRoot = path.resolve(__dirname, '..');
56
+ const cliRoot = path.join(pkgRoot, 'python');
57
+ const venvDir = path.join(cliRoot, '.venv');
58
+
59
+ if (!exists(path.join(cliRoot, 'pyproject.toml'))) {
60
+ console.log(`[CaptchaKraken] No bundled python engine found at ${cliRoot}; skipping python setup.`);
61
+ return;
62
+ }
63
+
64
+ let py = venvPython(venvDir);
65
+ if (py) {
66
+ console.log(`[CaptchaKraken] Found existing venv python at ${py}; ensuring package is installed...`);
67
+ } else {
68
+ const sysPy = resolveSystemPython();
69
+ console.log(`[CaptchaKraken] Creating venv at ${venvDir} using ${sysPy}...`);
70
+ run(sysPy, ['-m', 'venv', venvDir], { cwd: cliRoot });
71
+ py = venvPython(venvDir);
72
+ if (!py) {
73
+ throw new Error('[CaptchaKraken] Failed to locate venv python after venv creation.');
74
+ }
75
+ console.log('[CaptchaKraken] Upgrading pip/setuptools/wheel...');
76
+ run(py, ['-m', 'pip', 'install', '--upgrade', 'pip', 'setuptools', 'wheel'], { cwd: cliRoot });
77
+ }
78
+
79
+ // Install the `captchakraken` package with its CORE (lightweight) deps only:
80
+ // OpenCV grid detection + the HTTP planner that talks to a vLLM server. The
81
+ // heavy serving stack (vllm/torch, the `[serve]` extra) is NOT installed here
82
+ // — that's what setup.sh installs for people self-hosting the model.
83
+ console.log('[CaptchaKraken] Installing bundled python package (core deps)...');
84
+ run(py, ['-m', 'pip', 'install', '.'], { cwd: cliRoot });
85
+
86
+ console.log('[CaptchaKraken] Python environment ready.');
87
+ }
88
+
89
+ try {
90
+ main();
91
+ } catch (err) {
92
+ const strict = process.env.CAPTCHA_KRAKEN_PYTHON_SETUP_STRICT === '1';
93
+ console.warn('[CaptchaKraken] Python setup failed during postinstall.');
94
+ console.warn('Reason:', err && err.message ? err.message : String(err));
95
+ console.warn(
96
+ 'You can re-run setup later, or skip it entirely with CAPTCHA_KRAKEN_SKIP_PYTHON_SETUP=1.\n' +
97
+ '- Ensure you have Python 3.10+ installed\n' +
98
+ '- Optionally set CAPTCHA_KRAKEN_PYTHON=/path/to/python3\n' +
99
+ '- If you want the install to fail on setup errors, set CAPTCHA_KRAKEN_PYTHON_SETUP_STRICT=1'
100
+ );
101
+ if (strict) process.exit(1);
102
+ }
103
+
104
+