Conversation
Two answer families the model was already trained to produce but the
driver silently discarded. `ActionPlanner._normalize_pixel` dropped both:
a `{"action": "type", "text": ...}` answer carries no coordinate for any
branch to key off, and the drag branch required both endpoints, so the
sourceless drag the slider prompt explicitly asks for parsed to nothing.
Text: the driver decides from the DOM, not the picture — a visible text
box selects the distorted-text prompt and skips grid detection, since
BotDetect's boxed glyphs are the very lattice find_grid looks for. Typed
character by character with jittered gaps after a real pointer move and
click; fill() would set the value with no keystrokes, and these vendors
score cadence.
Sliders: closed-loop rather than calculated. The model gives the gap
centre but not the handle-travel ratio, which vendors vary deliberately.
The driver nudges twice by known amounts and measures union(before,
after) to solve for piece_width and ratio, then steers the remainder.
The button is never released until the piece is home — on these puzzles
releasing is the submit.
New surface: TypeAction (JS), nullable DragAction.source_bounding_box
(null = slider), TEXT_INPUT/SLIDER_HANDLE/DRAGGABLE_PIECE selectors,
slide_* knobs on PageSolverConfig, tool_calls/track_piece.py, and CLI
track-piece (with a serve mode, since it runs several times per drag).
prompts.py gains the generation-2 `text` family.
Requires a generation-2 model; generation 1 is never asked for either
answer and now reports UnsupportedCaptchaError naming that reason.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A cell mid-replacement (big check over the photo, fading to white, or a new photo fading in) is on its way to showing something else, so it must not be clicked however well it matches. An already-selected cell still counts. Generation 2 is unpublished — no models.json entry names it — so its text is still free to move. It freezes the moment a model registers against it. test_move_indicator: follow the recaptcha_grid_3x3 overlays into their standard/ variant subdirectory. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`solve_captcha` forwarded **kwargs to the CaptchaSolver CONSTRUCTOR rather than to solve(), so `text_mode=True` raised TypeError on the shipped entry point. Tier 2 could not ask the text families for an answer at all — no DOM, so nothing else set the flag — and the three text types sat unmeasured while looking like ordinary low scorers. Forward the solve-time arguments explicitly instead. A unit test on a mock missed this because the mock accepted anything; test_solver_contract now binds the real signature with inspect. page_solver: a typed answer needs its own submit. `typed` is tracked alongside `slid`, and verify-button discovery widens from the frame to the enclosing scope ONLY when something was typed — widening it for every puzzle let a solve press the host page's own submit mid-run. `_get_verify_button`'s xpath goes relative (`.//button`) so it resolves against an ElementHandle. Also drops hcaptcha_block_cover from the type taxonomies, which removes the n=1 entry the contract test flagged as a permanent-0% scoring hole. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
find_grid finds any lattice, and _is_real_grid only rejected the flat-band false positives. GeeTest's iconcrush board cleared every check (mean per-cell stddev ~52) and was routed to _solve_grid on every real capture, so the model was asked to 'choose the cell numbers that match the description' on a board whose answer is tapping two tiles to swap them. It replied [3, 6, 9], that was re-encoded as clicks on three cell centres and graded against a two-click gold: 0.089, the lowest score in the Tier 2 table, for a question we never asked. Reject on the BACKGROUND rather than the content: independent photographic tiles have their own median colours, a sprite board shares one board colour behind every cell. Real captures measure 0.0-0.8 for iconcrush/gobang against 14.9-93.0 for the five true grid families, so the 6.0 cut sits in an 18x gap. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Eight vendors render into the HOST PAGE rather than a vendor iframe (GeeTest,
Yidun, Tencent, Yandex, Lemin, Prosopo, MTCaptcha, BotDetect), so
content_frame() is None for every one of them. The Verify button was looked up
only inside that frame, so on those widgets it was never even SEARCHED FOR: the
solver clicked the right tiles, or dropped the piece in the right hole, and then
re-solved a puzzle it had already answered until the loop cap or the deadline.
Traced on the Tier 3 fixtures against the pinned model:
prosopo_grid_3x3 loop 1 clicks four tiles, loop 2 the model answers `done`
because everything matching is already selected. `done`
performs no action, so the caller's "performed no
interactions" guard fired and aborted a puzzle whose only
remaining step was to press the button.
lemin_cropped drags the piece into the gap on loop 1, then nudges it a
pixel a round for nine more rounds, because nothing ever
tells the widget to grade it.
Three changes, one per link in that chain:
- Look the button up in `scope` when there is no content frame. This was gated
on `typed` for fear of turning up the submit of the FORM the captcha guards;
`scope` is the widget container and the xpaths are relative, so that button is
out of reach by construction. What the gate actually did was make every
non-typed inline puzzle unsubmittable.
- A PLACED PIECE submits. A drag with a source has no count to reach that could
auto-submit it and no release being graded, and unlike a click round it is
never followed by a `done`. A click round is deliberately still not
auto-submitted: those boards re-round, and submitting a half-made selection
spends the attempt.
- The press counts as an interaction. Reporting false after submitting re-arms
the guard one loop later, which is the abort prosopo hit.
The TypeScript port needed all three plus two of its own: its submit was gated
on `frame` being non-null, so no inline widget could ever submit there even
after typing (the Python port got that fix, TS did not), and getVerifyButton's
xpaths were document-rooted, which does not resolve against an element handle.
prosopo_grid_3x3 now solves in 13.6s where it aborted at 14.3s. lemin_cropped
now presses Verify every round and the widget rejects it — its remaining
failure is the answer, not the driver.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The sliderless branch of executeSlide moved to the target's X while HOLDING THE PIECE'S OWN Y. On a rail that is right and not even a choice — the handle only travels horizontally — but Lemin's "cropped" has no rail: the piece is carried freely onto the photo, and its slot is 250 px up the card. So the piece slid along the tray and was released there, and every attempt submitted a piece that had never left it. Reachable only from a SOURCELESS drag, which lemin_cropped no longer emits, but every model published before that data change still does and the shipped client has to keep working with them. Both ports, same one-line shape. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The submit policy named four one-shot cases (hCaptcha, reCAPTCHA 4x4, a typed
code, a placed piece) and excluded click rounds, on the reasoning that those
boards fade and re-round so a half-made selection would spend the attempt.
They do not. A board that grades itself mid-selection draws NO submit control —
it verifies on the Nth tile, or on the swap, or on release — so `verify_button`
is None there and nothing is pressed either way. The one family that genuinely
fades and re-rounds is the reCAPTCHA 3x3, which never reaches this code: it
returns to its own driver several hundred lines earlier.
What the exclusion actually bought was an extra model call per puzzle, spent
asking a board we had already answered correctly whether it was `done`, purely
so the press had a round to happen on. Measured on prosopo_grid_3x3: 6.2 s of a
13.8 s solve, on a selection that scored 1.0 on the first call.
prosopo_grid_3x3 python 12.1-14.4s -> 9.6s / 7.7s
geetest_v4_nine python -> 7.9s / 5.4s
yidun_iconclick python 10 loops -> solved, 11.3s
yidun_pictureclick python 10 loops -> solved, 12.1s
The rule is now smaller than the list it replaces: press the widget's own
submit control whenever we put an answer into it, or when we had nothing to do
and want the round to advance. Two exclusions — a completed slide has already
submitted, and a round that only waited has answered nothing. hCaptcha and the
4x4 are ordinary click rounds and fall out of it.
What keeps the press ours to make is not the answer's shape but that discovery
is relative to `scope`, the vendor's own widget container: the host page's form
submit is unreachable by construction. That invariant now has its own test,
replacing the two that pinned the exclusion.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every UnsupportedCaptchaError became "the rendered puzzle is not a supported
grid or checkbox (likely an hCaptcha click/drag puzzle)" — including the ones
that already named both the cause and the fix:
prompt generation 1 has no distorted-text prompt — model was trained
before the text family existed. Use a model on generation 2 or later.
The gate reported the guess. It is wrong (the puzzle is a text captcha, not an
hCaptcha click puzzle) and it points at the solver when the answer is the
model's vintage. Both ports.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
js/src/solver.ts has imported './trajectory.js' since 8b09790, but the file was never added — so `dev` has not compiled for three commits: src/solver.ts(14,37): error TS2307: Cannot find module './trajectory.js' Add it, and drop the cursory-ts dependency the import replaced. Both drivers also stop grinding against a frame that is being torn down: - the response token is read FIRST and unconditionally. It is a hidden field on the page, not in the widget, so gating it on the anchor being visible meant the one signal that was already true went unread exactly when it mattered — hCaptcha keeps its overlay up for a couple of seconds after the winning submit. Turnstile's field is read too; detect_captcha already used it to decide a widget was UNSOLVED. - "prompt painted" no longer counts as a next round. The frame must have changed since the submit, which is already snapshotted at submit time. - the ~21s of readiness waits are followed by one more solved-check before spending an inference, the most expensive step in the loop. Examples take a URL argument and default humanize OFF; composing camoufox's humanize with the solver's own 60-point trajectory measured 82.1s against 13.4s on one geetest_v4_slide solve. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ehind
Retract the published accuracy figures. They were measured 2026-07-27 under an
eval split that let hand-labelled captures reach training, so they scored
memorisation alongside skill — by an amount that was largest exactly where the
headline number was highest. The 4x4 read 97.0%; on the split that holds every
real capture out it reads 0.2021. Both README and docs/performance.md now say
what happened and what will be measured instead, rather than restating figures
we no longer stand behind. The overlay ablation keeps its finding — a 4x4
without cell numbers solves ZERO boards — but stops quoting the retracted
absolutes as its baseline.
List the vendors the driver has been solving without saying so: GeeTest v3/v4,
Yidun, Lemin, Prosopo, Tencent, and the distorted-text families (BotDetect,
MTCaptcha, Yandex). Tier 3 drives all of them to completion in both ports;
geetest_v4_gobang and geetest_v4_iconcrush do not, and are named as weak.
The logo replaces the emoji, as a <picture> so it survives dark mode — the
artwork is near-black and would have vanished into a dark background. Both
variants are generated from the same source by overriding --ck-ink/--ck-field.
Also:
- the reproduce-it-yourself block pointed at tests/live-solve in a repo the
reader cannot clone. It now uses the examples in THIS repo, with the cd and
the two install lines a first-timer needs.
- drop planner_types.py: superseded by action_types.py (ids vs bounding
boxes), imported by nothing, exported by nothing.
- `v1-old-architecture` has not existed on the remote for some time; two
places sent readers to it.
- .env.example still called the hosted API "coming soon".
- the roadmap listed drag-and-drop as planned two rows under shipping it.
- keyframes.py described a fixed 4 s burst; Source.burst_duration_ms has
made clip length per-vendor. Patched in both parity-locked copies.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The adapters train with MIN_PIXELS=200704 (448², exported by the finetune repo's scripts/train_unified.sh), so anything smaller is enlarged before the ViT sees it. Inference did none of that: vLLM runs with no --mm-processor-kwargs and this client re-encoded the file byte-for-byte, so a small captcha arrived at a geometry the model was never tuned on. It fails as plausible-but-wrong coordinates, never as an error. On real geetest_v3_slide captures (277x285 = 78,945 px) predictions landed 80-105 px from the hand label at native size and 1-4 px away once upscaled. The affected types scored 0.000-0.119 while types whose captures clear the floor scored 0.704 — the split follows image size, not puzzle difficulty. A floor, not a resize: images already above it are passed through unchanged, so the types that were never broken pay nothing. Unconditional rather than keyed to a model generation, because the deployed v1.1 adapter improves under it too (mean error ~40 px native vs ~4 px upscaled). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A gutter is traced once per seed row, so `_merge_lines` collapses ~12 duplicates of it. `_pick` kept `max(group, key=support)` — the LONGEST member, which is systematically the trace seeded at the gutter's EDGE: long, but running along tile content rather than the separator. It then re-centred `midline_pos` to the support-weighted mean and kept that edge trace's `color_lab` and `color_std`, throwing away the on-centre traces entirely. So every colour gate downstream judged the grid by a line that was not on it. Measured on hcaptcha_images_ice_cream4: the kept trace sat 11 px off the gutter on a pale watermark that exists at some x and not others, carrying color_std 2.80, while the on-centre traces of the same gutter measured 0.55-0.80. Mean gutter std came out 2.69 against CLEAN_GUTTER_STD 2.3, which withheld the pale-tile relaxation in `_cells_have_content` and rejected a correct 3x3 that was the only candidate. Ordered by cleanliness rather than distance to centre — a member a hair nearer the centre can still be the dirtier read, and preferring it swapped a 0.00 trace for a 3.43 one on recaptcha_1774954808237_rhitt and lost a grid that detected fine. Restricted to members of comparable support so a three-sample fragment cannot win on a trivially perfect std. Measured over the full 50,420-image corpus, baseline vs this: real grid 1,396 98.71% -> 99.07% (+5, none lost) synthetic grid 6,000 95.18% -> 96.45% (+76) real non-grid 1,318 5.61% -> 5.77% (+2, both GeeTest board games) synthetic non-grid 41,706 3.32% -> 3.32% (flat) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…v1.1 SOLVE CAPTCHAS AS THEY APPEAR `solver.watch(page)` (TS) and `PageSolver.watch(page)` (Python) install a watcher that probes the page and solves any challenge that becomes visible, so a script no longer has to know where a captcha might interrupt it. It injects NOTHING into the page. The obvious build — a MutationObserver signalling out through an exposed binding — reacts faster and is the one design that cannot be stealthy everywhere, because a binding is a function on `window` and an observer is script a vendor can enumerate. Instead it drives the existing detectCaptcha() from the driver side on a timer: no new detection surface on any launcher, and under camoufox those DOM reads land in the sandboxed Juggler world for free, since that is camoufox's default for all Playwright evaluation (`main_world_eval` / an "mw:" prefix are the opt-OUT). The cost is reaction time bounded by interval_ms rather than by the mutation. Python blocks (`run()`) where TypeScript returns a handle, and adds `poll_once()` for callers with their own loop. That asymmetry is Playwright's: a sync handle is bound to the greenlet that created it, so a worker thread cannot drive the page and a background watcher is not available to it. THE PUPPETEER ADAPTER WAS NEVER ACTUALLY VERIFIED Its header claimed it was "verified against Puppeteer 24.x" while nothing in the package touched it — no test, and Tier 3 drives camoufox on both ports. It now has unit tests for every API delta it bridges, plus browser-compat.test.ts, which launches real Puppeteer and real Playwright and drives every member of the structural page surface through them. Both were run: 26/26 adapter methods against Puppeteer 24.43.1, and the full surface against Playwright 1.62.1 with no adapter at all. Skipped when those libraries are absent — the package still ships with zero browser dependencies. That verification found a real gap: fromPuppeteer did not forward `isClosed`, which the watcher reads to end its loop. A Puppeteer-driven watcher would have polled a closed page forever. `npm test` no longer names each test file. It compiles tsconfig.test.json, so a new test cannot be silently left out of the run — which had already happened. MODEL: v1.2 IS PUBLIC, AND IS NOW THE DEFAULT CaptchaKraken-Lora-v1.2 is published under Source-Available License v1.1. It is the first generation-2 model, so `latest` and pinned_model.json move to it together — the registry entry that names the weights is the same one that declares prompt_version 2, which is what keeps the pair from drifting. The serving alias moves to `captcha-v12`, which the fleet already serves alongside `captcha`. prompt_sha256 is re-pinned to the generation-2 templates. LICENSE v1.1 Adds 3(d): shipping the solver as a built-in captcha feature of a stealth or antidetect browser distributed to third parties needs a commercial license. Using it with one stays explicitly permitted (2(c)). Copyright holder is CaptchaKraken LLC across all four LICENSE copies, and the remaining JobHarvest references are gone — CaptchaKraken and JobHarvest are separate entities. Also in this release (in flight before it): per-model pixel_budget, so an adapter is served at the MIN_PIXELS/MAX_PIXELS band it trained under; and the MCP server tolerates a control plane older than migration 0008.
…hat is not a constant
Five examples, each run end to end against the real captcha-v12 adapter before
being committed — against tests/fixtures reCAPTCHA 3x3, and additionally
hCaptcha 3x3 property and geetest_v4_slide through the Puppeteer path:
withPlaywright.ts vanilla Playwright, no adapter 2 added lines
withPuppeteer.ts Puppeteer via fromPuppeteer(page) 2 added lines
watchPlaywright.ts the auto-solver
with_playwright.py vanilla Playwright, no adapter 2 added lines
watch_playwright.py the auto-solver
Two things the first draft got wrong, which is the reason for running them:
* top-level await. This package is CommonJS and tsx transpiles these to CJS,
where top-level await is a syntax error. They use `async function main()`,
which runs unchanged under either module system.
* the bundled Python engine. `npm run build` recopies python/ over
js/python/, wiping the venv that scripts/setup-python.js creates in
postinstall — so the CLI fell back to a bare `python3` with no pydantic. A
user installing from npm never sees this; anyone running examples from a
built checkout does.
There is no Puppeteer path in Python, and the docs now say so with the reason
rather than leaving it to be discovered: pyppeteer last released in February
2024 and pins urllib3 <2.0.0 against this package's own requests dependency, and
it is async-only against a synchronous driver. Two independent blockers.
Playwright is the Python answer; patchright and camoufox duck-type identically.
PIXEL BUDGET
test_an_image_above_the_floor_is_left_alone asserted against the module constant
P.MIN_PIXELS while the planner resolves a budget PER MODEL. Those agreed only
while the pinned model declared no budget of its own. They no longer do: v1.2 is
pinned and declares a flat 720 squared band, so "above the floor" stopped
meaning "untouched" for it — by design, since a flat band exists to give the
adapter exactly one geometry.
Split into the three things that were being conflated: the open-band rule
(pass through byte-for-byte, driven with an explicit budget), the flat-band rule
(normalise everything), and the planner's own resolution, which reads the pinned
model's band and so legitimately moves when the pin does.
The flat-band ceiling is asserted as `(w-1)*(h-1) <= maximum`, not `w*h`. The
floor branch scales with ceil() on both dimensions on purpose, so on a flat band
it necessarily overshoots by up to one row and column — 400x580 is sent as
598x867 = 518,466 against a stated 518,400. That is 0.013% over and inside the
same ViT patch grid; asserting equality would be asserting that ceil() does not
round up.
They import 'captchakraken' the way real code does, which resolves from npm for a user and from nothing inside this repo. Both footguns I hit running them are written down: the self-link, and that npm run build wipes the bundled python venv so a solve dies on a missing pydantic.
…de installer A `Page` outlives navigation and so does the watcher holding it: install once and it keeps probing across every goto, quiet when there is nothing to solve and re-armed after each solve. Nothing pinned that, and it is the claim the whole per-page API rests on. Now tested in both ports: two clean navigations solve nothing, a challenge on the third is solved, and a fourth proves it is still armed rather than having fired once. That measurement is also why the browser-wide `attach()` this started as is not here. It was built, and it worked live on a Playwright Browser, a Playwright BrowserContext and a Puppeteer Browser — then the navigation test showed the case motivating it was already covered. A challenge on request 40 of a scrape arrives on the SAME page object the run started with, so `watch(page)` handles it in one line. What a browser-wide installer uniquely adds, once navigation is covered, is pages the SITE opens: popups and target=_blank. Captchas there are rare, and the price was a background sweep per browser, a lifecycle callers must remember to stop, and an unattended process that solves and bills whatever it finds on any tab. That last one lands on us as refunds rather than on the user as a bug. Worth adding later on evidence, at the context level where Playwright has a real `page` event; not worth carrying on speculation. A shared/static solver was never an option and is worth recording as the reason: CaptchaKrakenSolver holds ~15 mutable per-solve fields and takes no lock. `solveSessionId` is minted at the top of solve() and nulled in its finally, which is what groups a multi-round 3x3 into ONE billable attempt; two pages through one instance either merge sessions or clear each other's mid-flight. The same finally calls teardownCvWorker(), killing the shared CV child process under the other page's solve. And lastMousePosition is the cursor continuity of one page — sharing it teleports the cursor between tabs, which is the exact signal the humanised trajectory exists to avoid. The navigation test waits on the solve COUNT rather than sleeping a fixed span. `node --test` runs files in parallel, so several Chromium launches compete for CPU: the first version passed in isolation and failed in the suite.
GeeTest's svg variant went 0 for 16 live. Two bugs stacked, and each hid the other. THE SETTLE PROBE IS SHORTER THAN THE DWELL waitForElementSettled calls a challenge static after settleFrames (2) still polls at settlePollMs (220ms) — about 440ms of stillness. That board advances through screens of fresh glyphs and HOLDS each one for p50 1.5s, p75 2.0s, max 2.7s (measured over 81 clips; see sources.py). It clears a 440ms bar trivially, so it was read as a picture and answered from whichever screen we caught, and the click landed after that screen was gone. THE CACHE THEN MADE IT PERMANENT The answer cache keys on the screenshot's bytes and reasoned "identical pixels mean the page has not changed, so reuse the answer". On a cycling board the pixels come back around, so later rounds hashed the same and REPLAYED the answer that had just failed, without asking the model: 81 solve loops, 12 model calls, 69 cache hits, 0 solves. The invariant it was missing is that every answer getSolution returns is executed — there is no speculative call. So an identical screenshot cannot mean "nothing changed, reuse it". It means the answer already ran and moved nothing, which is the one case where replaying it is certainly wrong. The saving is kept (a hit still costs no inference) and the hit is now treated as EVIDENCE that the still reading was wrong; the round after it records a burst instead. reCAPTCHA is excluded — its dynamic 3x3 replaces tiles in place, has its own fade-gated driver, and its grids are never animated. THE BURST WAS FREEZING WHAT IT CAME TO RECORD With the escalation in, the burst still sliced to `1 keyframe (mode=static)`. recordKeyframeBurst screenshotted with animations: 'disabled', which is correct everywhere else in this file — it is how you get a stable still — and exactly wrong here, because it FREEZES infinite animations. Forty frames of the same picture. hCaptcha's animated challenges hid it by animating in canvas, which that flag does not touch; the svg board animates in CSS, which it does. Live after both fixes: 0/16 -> 5/6, bursts slicing to 2-3 real keyframes. The Python driver has no answer cache, so only the JS port needed the first change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A reCAPTCHA solve measured 48.9s. The actual work took eight. Three bugs, all of them the same shape — waiting on a challenge the vendor had already torn down — and two of them were fixed once already, elsewhere in this file. 1. SIX ELEMENT SCREENSHOTS WITH NO TIMEOUT Playwright defaults to 30s and waits for the element to be visible and stable first. On a closing iframe that runs to the full default. waitForElementSettled already carries this fix and a comment saying it "made a multi-round solve take ~115s"; waitForGridCellsLoaded and the whole grid path were written in the same shape without it. A poll loop hides it, too: its `while (Date.now() - start < timeout)` is consulted only BETWEEN iterations, so one hung screenshot sails past the 8s cap it looks protected by. screenshot-timeouts.test.ts now reads the source and refuses a bare one. 2. THE GRID DRIVER NEVER RECORDED THE FRAME IT SUBMITTED The one-shot path snapshots lastSubmitFrameHash on submit; the reCAPTCHA grid driver clicked Verify without it. That hash is what tells the next attempt "the board on screen is the one you already answered". 3. AND THE reCAPTCHA BRANCH OF isChallengeFreshlyRendered NEVER CHECKED IT The hCaptcha branch does, with a comment describing this exact failure: firing on the closing frame "committing the solver to ~21s of readiness waits and a full inference against a dying frame". reCAPTCHA leaves its answered board up while it verifies and keeps the instructions visible, so "instructions are showing" reported a fresh round that was never coming. Together: the post-submit solved-poll broke on its first tick, the solver re-detected the dying bframe, and every screenshot against it ran to a timeout — 40 seconds, on every multi-round reCAPTCHA, paid by every customer. Measured live on the demo page, same harness, same model: before 48.9s median, 24/35 solved after 7.8s median (3x3) / 10.8s (4x4), 8/8 solved The solver also now asks the vendor whether the captcha is already solved before starting another detect pass, which is the cheapest question available and was not being asked. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two reCAPTCHA/hCaptcha misreads that both cost a solve without erroring. reCAPTCHA answers a click one of exactly two ways: a small blue chip in the tile's top-left (the photo was KEPT, the selection is the answer) or the photo dissolving under a centred check (the tile is being SWAPPED). We only looked for the swap, so a chipped board burned rounds waiting for a refresh that was never coming. `_watch_clicked_tiles` now reports both and submits on the chip. The chip is tested FIRST: it zooms the photo, which reads as `changing` on the same frame, so testing the swap first makes every chipped board look like a swapping one. hCaptcha's selected-state badge was matched by counting teal pixels and white pixels anywhere in the same corner — satisfied by blue sky with a white pole in it, 74 phantom selections over 3051 corners of boards with nothing selected. A phantom drops the tile from the model's answer and it is never clicked. The counts stay as a cheap gate; the verdict is now that the white BELONGS TO the teal mark, within 2px of the largest blob's convex hull. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`latest` moves to CaptchaKraken-Lora-v1.2 (generation-2 prompts), and setup.sh and .env.example follow it. Those two defaults MUST match the registry: the env file setup.sh writes overrides the client's own defaults, so an adapter that is not registered resolves to generation-1 prompts against generation-2 weights, silently, on every puzzle. test_setup_sh_model_ids.py pins the match. v1.2's pixel_budget becomes a flat 720^2 (floor == ceiling), which is a MEASURED serving value rather than the training floor. Sweeping 448/576/640/704/720/736/ 768/896/1024^2 over the full 1458-sample Tier 2 put the plateau at 704-736^2: overall 0.6115 -> 0.6412, reCAPTCHA 3x3 exact 0.648 -> 0.715, against run-to-run noise of +/-0.001. 640^2 saturates the ViT's 48x48 position grid exactly and scores WORSE, so this is not a case of staying inside the grid. Adds AGENTS.md and docs/hosted-api.md, and stops telling a user with no GPU to watch the repo for a hosted API that now exists. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ms, and the v1.2 client defaults Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The docs described one lineup of models named for ocean depth, which read as a
capability ladder and hid the thing a downloader most needs to know: v1.1 was
trained on reCAPTCHA and hCaptcha ALONE. It does not score badly on a GeeTest
slider or a typed-text captcha — it was never shown one and will not attempt it.
So the models are now presented as two releases, each carrying the same weights
in three shapes (LoRA + Twilight FP8 + Sunlight AWQ):
v1.2 44 puzzle types across 10 vendors, animated challenges, typed text.
Prompt generation 2, needs captchakraken >= 2.5.0.
v1.1 reCAPTCHA and hCaptcha only. Generation 1. Still published.
The hosted API is described as answering with Twilight v1.2 — the same LoRA on
the same base, merged — rather than "the production adapter", which named
nothing a reader could go and look at.
Registers both v1.2 merges in models.json, which is what publishing them means:
a merge that resolved to a different prompt generation or pixel budget than the
adapter it was made from would be the 2026-07-18 regression with extra steps.
Both carry prompt_version 2 and the measured flat 720^2 band. Parity gate passes
at 7 registered models.
Abyss is unchanged: in training, never published, hosted-only when it lands.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The v1.2 cards inherited v1.1's figures. Built and weighed: Twilight 13 GB (not ~14), Sunlight 11 GB (not ~9.1). Sunlight's minimum moves 11 -> ~14 GB with it. Sunlight being LARGER than its v1.1 counterpart at the same 4-bit scheme is not a mistake and the card says so: only the language-model linears are quantized, and this generation's vision tower stays bf16 deliberately. Quantizing the half of the model that reads the picture is not a size win worth making. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A solve-timing pass over both ports. Nothing here changes what the model answers; it changes how much of a solve is spent waiting. GeeTest ships its submit as `<div class="geetest_submit geetest_disable">OK`, invisible to both shapes the button finder knew — a bare div carries no role="button" and "OK" is on none of the four word lists. A GeeTest board does not grade until you press it, so the loop re-read the same unchanged panel and re-answered it identically until the round cap: ordered icon-click scored 0/31 and 0/13 while the model was answering correctly. Matched by class, not by the word, because `geetest_submit_tips` sits beside it and also reads "OK". The rest is dead time. A readiness gate with nothing to check returned "not ready" and polled out its full window (24.0s of a 45.2s solve). An `even` clip waited 6s per click for a state the slicer had already reported never recurs — and all 116 real clips are `even`. The Python burst recorded with animations disabled, which freezes infinite ones, so every CSS-animated vendor was filmed frozen. The verdict window spent 2500ms on every wrong answer to catch a success that measured p50 360ms / max 528ms. And a solve that repeats itself now stops instead of letting the clock do it: at temperature 0 an identical answer means an identical picture, and the previous one already ran and moved nothing. Measured on recaptcha_grid_4x4, nine identical click sets ending at 66.1s; it now ends at round 4. Both ports, per CLAUDE.md 1c — the timing half had landed in Python only, and Tier 3 gates on (puzzle type, port) pairs. Two bugs found finishing it: - Dropping max_solve_loops 10 -> 6 made NoCaptchaFoundError unreachable. The render-wait cap was a flat 6 and a render wait consumes an attempt, so the loop ran out first and a reCAPTCHA v3 / invisible page reported "still detected after 6 solve loops" instead. Tied to the loop count in both ports. - Three test helpers built a PageSolver with __new__ and hand-listed the attributes __init__ sets, so every attribute added since broke them with an AttributeError raised nowhere near the behaviour under test. They call the real __init__ now. The lineup is stated where it was wrong, too: the MCP listing derived "downloadable" from the `hosted` flag, so it announced the unreleased model as what the hosted API runs and reported the model actually taking requests as "reserved, not uploaded yet". Both flags are read for what they say, and Abyss is named as the Qwen3.8-27B it will be, against the 9B everything public is. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
publish.yml runs on a push to main and publishes each package at the version in its own manifest, skipping one already on the registry — so a merge that does not move these numbers lands the code and ships nothing. The client is a minor: three new config options (maxNoProgressRounds, postSolveOutcomePollMs, hcaptchaImagesTimeoutMs) and changed defaults for two that already existed (overallSolveTimeoutMs 120s -> 45s, maxSolveLoops 10 -> 6). Additive API, changed behaviour. The MCP is on its own version line and is deliberately not locked to the client's number, but its model listing changed this release, so it gets 0.1.1 — without it the fix sits on main and never reaches anyone. __version__ was still "2.3.0", three releases behind pyproject. Nothing reads it, which is exactly why it drifted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`solver.ts` defaulted the model to the literal `'captcha'` in both getSolution and getAnimatedSolution. That is the served alias of CaptchaKraken_v1.1 — a PROMPT GENERATION 1 model — while the Python client resolves models.json's `latest` and has been getting `captcha-v12` (generation 2) since v1.2 was registered on 2026-08-12. The name is not just routing. This port answers by shelling out to the Python CLI and passing it as `--model`, and `prompts.resolve()` maps that name through models.json to a prompt generation. So every JS solve sent generation-1 prompts to generation-2 weights: silent for every family generation 1 still has a prompt for, and a hard error for the two it does not. Tier 3 on 2026-08-20 lost 22 of this port's 44 puzzle types to it — 19 to `prompt generation 1 has no animated-puzzle prompt` and 3 (botdetect_text, mtcaptcha_text, yandex_text) to the distorted-text equivalent surfacing as UNSUPPORTED_CAPTCHA — against a Python port that scored 37/44 on the same commit. That asymmetry is what gave it away. model-name.ts mirrors config.py's precedence exactly (CAPTCHA_LORA_NAME, then `latest`, then pinned_model.json) so the two ports cannot drift again, and is exported so a reporter can ask rather than re-derive. Driving the six worst-affected types locally against the primary GPU after this change: js and python now agree pair for pair, 10/12. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This repo ships the DRIVER. A pull request here cannot change the model, so gating it on a Tier 2 solve-rate measurement gated the one thing the PR could not move — the same reasoning that took Tier 2 off the private repo's own PR path. What a PR here CAN break is driving: a selector that stopped matching, a click landing off-widget, or one port asking for a different adapter than the other. That last one is not hypothetical — it shipped, and on 2026-08-20 it cost the JS port 22 of its 44 puzzle types while every check on this repo stayed green. Tier 3 is the only gate that drives both shipped ports end to end through a real browser. The status context moves with it: `tier2/solver-gate` -> `tier3/driver-gate`, so a branch protection rule pinned to the old name cannot be silently satisfied by a different measurement. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`gh workflow run` resolves the target repo's default branch over GraphQL
before dispatching, purely to choose a ref. That lookup reads repository
METADATA, so the convenience costs a permission the dispatch itself does not
need — and PRIVATE_DISPATCH_TOKEN, scoped to `Actions: write` on the private
repo, failed on precisely that:
unable to determine default branch for JWriter20/CaptchaKrakenFinetune:
GraphQL: Resource not accessible by personal access token
(repository.defaultBranchRef)
Calling the dispatch endpoint directly needs only Actions: write. Widening
the token to satisfy a lookup we do not want would have been the wrong half
of the trade — this token lives in a PUBLIC repo.
ref=main is now explicit, which is also the more honest statement:
workflow_dispatch can only ever target a workflow file on the default
branch, so it is a fact about the dispatch rather than something to
rediscover at run time.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Owner
Author
✓ Tier 3 driver-gate — passAggregate: 0.773 · 3/10 families passing · 20 pair(s) not solved
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A solve-timing release. Nothing here changes what the model answers; it changes how much of a solve is spent waiting for things that cannot happen.
Fixed
<div class="geetest_submit geetest_disable">OK</div>— invisible to both shapes the button finder knew, because a bare div carries norole="button"and "OK" is on none of the four word lists. A GeeTest board does not grade until you press it, so the loop re-read the same unchanged panel and re-answered it identically until the round cap: ordered icon-click scored 0/31 and 0/13 while the model was answering correctly. A driver that discards a right answer is indistinguishable from a model that cannot solve the puzzle, which is how this hid. Matched by class, not by the word —geetest_submit_tipssits beside it, also reads "OK", and does nothing when pressed.evenclip no longer waits 6s per click for a state the slicer already reported never recurs. All 116 real clips areeven;cyclehas never fired on real footage.animations="disabled"freezes infinite animations, so GeeTest's svg board came back as 40 copies of one picture and honestly sliced tomode=static. The TS port has passedallowsince it hit this first.maxSolveLoops6 theNoCaptchaFoundErrorbranch became unreachable — turning the benign answer for a reCAPTCHA v3 / invisible page into a hard error for anyone catching it.Changed
Added
maxNoProgressRounds/max_no_progress_rounds, default 2). At temperature 0 an identical answer means an identical picture, and every answer this driver produces is executed — so the previous one already ran and moved nothing. Measured onrecaptcha_grid_4x4: nine identical click sets, each clicked, each rejected, ending at 66.1s. Now ends at round 4. 2 rather than 1 because the first repeat escalates to a recording, which is the one recovery still worth trying.CAPTCHA_TIMINGS=1(Python). "The solve took 77s" is not actionable; "the settle monitor spent 31s of it" is.Both ports, per CLAUDE.md 1c — the timing half had landed in Python only, and Tier 3 gates on (puzzle type, port) pairs.
Also
The MCP model listing derived "downloadable" from the
hostedflag, so it was wrong in both directions at once: it announced the unreleased model as "what the hosted API runs" and reported the model actually taking requests as "reserved, not uploaded yet". Both flags are now read for what they say. Abyss is named as the Qwen3.8-27B it will be, against the Qwen3.5-9B everything public is.Versions
captchakraken2.5.0 → 2.6.0 (npm + PyPI),captchakraken-mcp0.1.0 → 0.1.1. Merging publishes all three; the MCP needed its own bump or the listing fix would sit on main and never reach anyone.Verification
Python client 404 passed, JS client 85 passed, both typecheck clean, prompt-parity gate green. Tier 2/3 have not run locally — that is what this PR is for.
🤖 Generated with Claude Code