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.
- package/AGENTS.md +5 -9
- package/LICENSE +165 -145
- package/NOTICE +4 -4
- package/README.md +10 -11
- package/dist/cli-invocation.d.ts +6 -3
- package/dist/cli-invocation.js +26 -10
- package/dist/humanize.d.ts +37 -1
- package/dist/humanize.js +143 -9
- package/dist/index.d.ts +13 -12
- package/dist/index.js +25 -33
- package/dist/kinds.d.ts +15 -2
- package/dist/kinds.js +12 -4
- package/dist/playwright-types.d.ts +3 -0
- package/dist/selectors.d.ts +96 -11
- package/dist/selectors.js +226 -112
- package/dist/solver.d.ts +93 -13
- package/dist/solver.js +579 -208
- package/dist/types.d.ts +53 -8
- package/dist/verdicts.d.ts +3 -0
- package/dist/verdicts.js +6 -0
- package/package.json +4 -4
- package/python/AGENTS.md +7 -11
- package/python/LICENSE +165 -145
- package/python/NOTICE +4 -4
- package/python/README.md +10 -9
- package/python/pyproject.toml +27 -13
- package/python/src/captchakraken/__init__.py +43 -46
- package/python/src/captchakraken/action_types.py +32 -1
- package/python/src/captchakraken/captchakraken.config.json +416 -0
- package/python/src/captchakraken/cli.py +66 -30
- package/python/src/captchakraken/clipvideo.py +258 -0
- package/python/src/captchakraken/humanize.py +141 -10
- package/python/src/captchakraken/image_processor.py +3 -7
- package/python/src/captchakraken/keyframes.py +96 -14
- package/python/src/captchakraken/kinds.py +14 -2
- package/python/src/captchakraken/models.json +28 -3
- package/python/src/captchakraken/page_solver.py +621 -259
- package/python/src/captchakraken/planner.py +298 -47
- package/python/src/captchakraken/pointer/__init__.py +20 -0
- package/python/src/captchakraken/pointer/detector.py +333 -0
- package/python/src/captchakraken/pointer/elements.py +273 -0
- package/python/src/captchakraken/pointer/heads.py +163 -0
- package/python/src/captchakraken/pointer/layout.py +319 -0
- package/python/src/captchakraken/pointer/marks.py +184 -0
- package/python/src/captchakraken/pointer/server.py +146 -0
- package/python/src/captchakraken/pointer/service.py +553 -0
- package/python/src/captchakraken/pointer/vllm_plugin.py +109 -0
- package/python/src/captchakraken/prompts.py +111 -34
- package/python/src/captchakraken/selectors.py +224 -131
- package/python/src/captchakraken/server_manager.py +18 -0
- package/python/src/captchakraken/solver.py +169 -88
- package/python/src/captchakraken/tool_calls/board_painted.py +11 -6
- package/python/src/captchakraken/tool_calls/fill_tracker.py +152 -0
- package/python/src/captchakraken/tool_calls/find_checkbox.py +14 -59
- package/python/src/captchakraken/tool_calls/find_grid.py +273 -1191
- package/python/src/captchakraken/tool_calls/slide_tracker.py +1102 -0
- package/python/src/captchakraken/verdicts.py +16 -0
- package/dist/slide-geometry.d.ts +0 -6
- package/dist/slide-geometry.js +0 -38
- 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
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
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
|
|
262
|
-
grid_template=spec
|
|
263
|
-
video_template=spec
|
|
264
|
-
text_template=spec
|
|
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
|
|
2
|
-
|
|
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
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
165
|
-
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
"
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
)
|
|
194
|
-
|
|
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
|
|