captchakraken 3.0.0 → 3.2.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 +4 -4
- package/LICENSE +1 -1
- package/README.md +10 -13
- package/dist/index.d.ts +1 -0
- package/dist/kinds.d.ts +8 -0
- package/dist/kinds.js +3 -1
- package/dist/model-name.d.ts +6 -0
- package/dist/model-name.js +28 -0
- package/dist/playwright-types.d.ts +18 -0
- package/dist/png.d.ts +16 -0
- package/dist/png.js +119 -0
- package/dist/puppeteer-adapter.d.ts +7 -1
- package/dist/puppeteer-adapter.js +6 -0
- package/dist/selectors.d.ts +6 -0
- package/dist/selectors.js +4 -0
- package/dist/solver.d.ts +118 -5
- package/dist/solver.js +840 -143
- package/dist/types.d.ts +12 -5
- package/dist/verdicts.d.ts +45 -0
- package/dist/verdicts.js +125 -0
- package/package.json +7 -7
- package/python/AGENTS.md +13 -8
- package/python/LICENSE +1 -1
- package/python/README.md +10 -13
- package/python/pyproject.toml +6 -6
- package/python/src/captchakraken/__init__.py +5 -1
- package/python/src/captchakraken/cli.py +7 -1
- package/python/src/captchakraken/kinds.py +9 -0
- package/python/src/captchakraken/page_solver.py +651 -137
- package/python/src/captchakraken/planner.py +68 -12
- package/python/src/captchakraken/prompts.py +24 -0
- package/python/src/captchakraken/selectors.py +10 -0
- package/python/src/captchakraken/tool_calls/find_checkbox.py +4 -0
- package/python/src/captchakraken/tool_calls/find_grid.py +22 -0
- package/python/src/captchakraken/verdicts.py +154 -0
- package/python/Dockerfile +0 -41
- package/python/examples/README.md +0 -114
- package/python/examples/_harness.py +0 -177
- package/python/examples/demoHcaptcha.py +0 -8
- package/python/examples/demoRecaptcha.py +0 -8
- package/python/examples/watch_playwright.py +0 -31
- package/python/examples/with_local_gguf.py +0 -28
- package/python/examples/with_playwright.py +0 -24
- package/scripts/copy-python.mjs +0 -29
package/dist/solver.js
CHANGED
|
@@ -33,9 +33,10 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
-
exports.CaptchaKrakenSolver = exports.SOLVE_DEFAULTS = void 0;
|
|
36
|
+
exports.CaptchaKrakenSolver = exports.SOLVE_DEFAULTS = exports.INPUT_SETTLE_MS = void 0;
|
|
37
37
|
exports.answerNeedsElementBox = answerNeedsElementBox;
|
|
38
38
|
exports.isStaleHandleError = isStaleHandleError;
|
|
39
|
+
exports.isClosedTargetError = isClosedTargetError;
|
|
39
40
|
exports.burstHangDeadlineMs = burstHangDeadlineMs;
|
|
40
41
|
const watcher_1 = require("./watcher");
|
|
41
42
|
const humanize_js_1 = require("./humanize.js");
|
|
@@ -46,7 +47,9 @@ const path = __importStar(require("path"));
|
|
|
46
47
|
const os = __importStar(require("os"));
|
|
47
48
|
const crypto_1 = require("crypto");
|
|
48
49
|
const timing_1 = require("./timing");
|
|
50
|
+
const png_1 = require("./png");
|
|
49
51
|
const kinds_1 = require("./kinds");
|
|
52
|
+
const verdicts_1 = require("./verdicts");
|
|
50
53
|
const token_usage_1 = require("./token-usage");
|
|
51
54
|
const errors_1 = require("./errors");
|
|
52
55
|
const limits_1 = require("./limits");
|
|
@@ -56,6 +59,10 @@ const slide_geometry_1 = require("./slide-geometry");
|
|
|
56
59
|
const model_name_1 = require("./model-name");
|
|
57
60
|
const selectors_1 = require("./selectors");
|
|
58
61
|
const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
|
|
62
|
+
/** The ceiling on one ask, matching the Python planner's own default. */
|
|
63
|
+
const CLI_ASK_TIMEOUT_MS = 120000;
|
|
64
|
+
/** And the floor, so a nearly-spent budget still gets a real attempt rather than a certain timeout. */
|
|
65
|
+
const CLI_ASK_MIN_TIMEOUT_MS = 10000;
|
|
59
66
|
const log = (message, ...args) => console.log(`[Solver] ${message}`, ...args);
|
|
60
67
|
const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
|
61
68
|
const tmp = (prefix) => path.join(os.tmpdir(), `${prefix}_${Date.now()}_${Math.floor(Math.random() * 1e9)}.png`);
|
|
@@ -135,8 +142,14 @@ function answerNeedsElementBox(actions) {
|
|
|
135
142
|
}
|
|
136
143
|
/** The widget moved on under us (hCaptcha swapped rounds, GeeTest closed on accept): re-detect, do not fail. */
|
|
137
144
|
function isStaleHandleError(message) {
|
|
138
|
-
return /Timeout .*exceeded|not visible|not attached|detached|
|
|
145
|
+
return /Timeout .*exceeded|not visible|not attached|detached|bounding box of captcha element/i.test(message);
|
|
139
146
|
}
|
|
147
|
+
/** A closed target is not a stale handle: retried as one, a dead page was re-detected until the loops ran out. */
|
|
148
|
+
function isClosedTargetError(message) {
|
|
149
|
+
return /Target (?:page, context or browser )?(?:has been )?closed|Session closed/i.test(message);
|
|
150
|
+
}
|
|
151
|
+
/** How often a page that has not drawn its widget yet is looked at again. */
|
|
152
|
+
const DETECTION_POLL_MS = 250;
|
|
140
153
|
// Frame-diff thresholds; two screens of one board differ by ~0.0056, a different board by ~0.77.
|
|
141
154
|
const NOT_THIS_BOARD_DIFF = 0.5;
|
|
142
155
|
// Tighter than staleFrameDiffThreshold (0.02), which was blind to GeeTest svg; 0.001 is the noise floor.
|
|
@@ -144,9 +157,48 @@ const MOVED_DURING_INFERENCE_DIFF = 0.002;
|
|
|
144
157
|
// hCaptcha odd-animal showed 38 screens in 4s with no repeat; past DEFAULT_MAX_KEYFRAMES a keyframe answer cannot describe the motion.
|
|
145
158
|
const BURST_ANIMATED_SCREENS = 6;
|
|
146
159
|
const NOT_THIS_BOARD_POLLS = 3;
|
|
160
|
+
// How long a control we hovered, pressed or typed into takes to stop repainting in reaction. A board read inside
|
|
161
|
+
// this window is wearing our own feedback, which is not a screen it ever showed by itself.
|
|
162
|
+
exports.INPUT_SETTLE_MS = 400;
|
|
163
|
+
/** `viewportSize()` is null on a context launched without one (camoufox), where only the window knows. */
|
|
164
|
+
async function viewportOf(page) {
|
|
165
|
+
const view = page.viewportSize() ?? await page.evaluate?.(() => ({ width: window.innerWidth, height: window.innerHeight }));
|
|
166
|
+
if (!view)
|
|
167
|
+
throw new Error('the page reports no viewport size and cannot evaluate one');
|
|
168
|
+
return view;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Does a viewport capture speak the bounding box's coordinates? Not on a page laid out wider than the device and
|
|
172
|
+
* zoomed out to fit, which is every mobile layout of a desktop page: there the box is in layout pixels and the
|
|
173
|
+
* capture is not.
|
|
174
|
+
*/
|
|
175
|
+
async function layoutIsTheViewport(page, view) {
|
|
176
|
+
const frame = await page.evaluate?.(() => ({
|
|
177
|
+
width: window.innerWidth,
|
|
178
|
+
scale: window.visualViewport ? window.visualViewport.scale : 1,
|
|
179
|
+
}));
|
|
180
|
+
if (!frame)
|
|
181
|
+
throw new Error('the page cannot evaluate its own layout width');
|
|
182
|
+
return Math.abs((frame.scale ?? 1) - 1) < 1e-3 && Math.abs(frame.width - view.width) <= 1;
|
|
183
|
+
}
|
|
184
|
+
const within = (r, view) => r.x >= 0 && r.y >= 0 && r.x + r.width <= view.width && r.y + r.height <= view.height;
|
|
185
|
+
/** A point just outside the board, past the edge nearest the pointer, that is still inside the viewport. */
|
|
186
|
+
function offBoardPoint(box, at, view) {
|
|
187
|
+
const [x, y] = at;
|
|
188
|
+
const gap = 24 + Math.random() * 40;
|
|
189
|
+
const right = box.x + box.width;
|
|
190
|
+
const bottom = box.y + box.height;
|
|
191
|
+
const exits = [
|
|
192
|
+
[x - box.x, [box.x - gap, y]], [right - x, [right + gap, y]], [y - box.y, [x, box.y - gap]], [bottom - y, [x, bottom + gap]],
|
|
193
|
+
];
|
|
194
|
+
return exits.sort((a, b) => a[0] - b[0]).map(([, p]) => p)
|
|
195
|
+
.find(([px, py]) => px >= 0 && px < view.width && py >= 0 && py < view.height) ?? null;
|
|
196
|
+
}
|
|
147
197
|
exports.SOLVE_DEFAULTS = {
|
|
148
198
|
maxSolveLoops: 6,
|
|
149
199
|
overallSolveTimeoutMs: 45000,
|
|
200
|
+
// A page just navigated has often not drawn its widget; solving straight after `domcontentloaded` failed 3/3 on a demo page.
|
|
201
|
+
detectionTimeoutMs: 15000,
|
|
150
202
|
// 9s holds a 3-screen GeeTest svg cycle at 2.7s a screen; 6s gave up one screen short. videoBudgetMs derives from it on both ports.
|
|
151
203
|
keyframeWaitTimeoutMs: 9000,
|
|
152
204
|
};
|
|
@@ -154,10 +206,23 @@ exports.SOLVE_DEFAULTS = {
|
|
|
154
206
|
function burstHangDeadlineMs(cfg) {
|
|
155
207
|
return 3 * (cfg.videoBurstMaxMs ?? 12000) + 5000;
|
|
156
208
|
}
|
|
209
|
+
/** The page's host alone: a path or query can carry a user's own data. */
|
|
210
|
+
function hostname(page) {
|
|
211
|
+
try {
|
|
212
|
+
return new URL(page.url?.() ?? '').hostname.toLowerCase() || null;
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
157
218
|
class CaptchaKrakenSolver {
|
|
158
219
|
ph(name, fn) {
|
|
159
220
|
return this.budget ? this.budget.phase(name, fn) : fn();
|
|
160
221
|
}
|
|
222
|
+
/** A pointer gesture, timed as MOUSE and remembered: see `INPUT_SETTLE_MS`. */
|
|
223
|
+
gesture(fn) {
|
|
224
|
+
return this.ph(kinds_1.Phase.MOUSE, fn).finally(() => { this.lastInputAt = Date.now(); });
|
|
225
|
+
}
|
|
161
226
|
constructor(config = {}) {
|
|
162
227
|
this.videoBudgetMs = 0;
|
|
163
228
|
this.videoBudgetGranted = false;
|
|
@@ -171,7 +236,9 @@ class CaptchaKrakenSolver {
|
|
|
171
236
|
/** Answers keyed by screenshot hash; a hit means the answer already ran and changed nothing. */
|
|
172
237
|
this.solutionCache = new Map();
|
|
173
238
|
this.repeatedAnswerSeen = false;
|
|
174
|
-
|
|
239
|
+
this.knownAnimated = false;
|
|
240
|
+
this.animatedProbeDone = false;
|
|
241
|
+
/** The one recording for the animated board on screen, and its answer until the widget refuses it. */
|
|
175
242
|
this.animatedPlan = null;
|
|
176
243
|
this.keyframeMode = null;
|
|
177
244
|
this.keyframeSteadyScreens = 0;
|
|
@@ -181,13 +248,34 @@ class CaptchaKrakenSolver {
|
|
|
181
248
|
this.resampleLevel = 0;
|
|
182
249
|
/** The recording `classifyByRecording` started and left running for the branch below to finish or drop. */
|
|
183
250
|
this.pendingBurst = null;
|
|
251
|
+
/**
|
|
252
|
+
* The camera on the animated board in front of us, running across rounds until that board is gone.
|
|
253
|
+
*
|
|
254
|
+
* Distinct from `pendingBurst`, which is the speculative film one round starts and the same round spends.
|
|
255
|
+
* This one outlives a rejected answer on purpose: it is the only thing that can give the next ask a
|
|
256
|
+
* different question to answer, since the answer itself is deterministic at temperature 0.
|
|
257
|
+
*/
|
|
258
|
+
this.animatedFilm = null;
|
|
184
259
|
this.lastBurstFps = null;
|
|
185
260
|
this.actedOnBoard = false;
|
|
186
261
|
this.lastSubmitFrameHash = null;
|
|
187
262
|
this.solveSessionId = null;
|
|
263
|
+
/** Which vendor and site this solve is for, sent to the hosted API; the site is a hostname only. */
|
|
264
|
+
this.solveVendor = null;
|
|
265
|
+
this.solveSite = null;
|
|
266
|
+
/** The host the widget itself was served from (its iframe's); null for an inline widget. */
|
|
267
|
+
this.solveWidgetHost = null;
|
|
268
|
+
this.legacyModelWarned = false;
|
|
269
|
+
this.verdicts = null;
|
|
270
|
+
/** False once a self-hosted endpoint has answered 404: it has no outcome route to report to. */
|
|
271
|
+
this.outcomeSupported = true;
|
|
188
272
|
this.cliCache = null;
|
|
189
273
|
this.loraNameCache = null;
|
|
190
274
|
this.budget = null;
|
|
275
|
+
/** The page being solved: every capture is taken of its viewport. Set by `solve`. */
|
|
276
|
+
this.page = null;
|
|
277
|
+
/** When our last gesture ended; the board's reaction to it is never read as the board's own motion. */
|
|
278
|
+
this.lastInputAt = -Infinity;
|
|
191
279
|
this.config = config;
|
|
192
280
|
this.human = (0, humanize_js_1.resolveHumanizer)({ ...config, startingMousePosition: config.startingMousePosition ?? { x: 100, y: 100 } });
|
|
193
281
|
}
|
|
@@ -197,14 +285,73 @@ class CaptchaKrakenSolver {
|
|
|
197
285
|
set lastMousePosition(v) {
|
|
198
286
|
this.human.at = [v.x, v.y];
|
|
199
287
|
}
|
|
200
|
-
/**
|
|
201
|
-
|
|
202
|
-
|
|
288
|
+
/**
|
|
289
|
+
* Every capture the driver takes: the whole viewport, cropped here to the element.
|
|
290
|
+
*
|
|
291
|
+
* Never a clipped capture. An element screenshot, or any `clip`, makes a headed Chromium repaint the page at the
|
|
292
|
+
* clip's size for that frame, which the user watches as the page flashing and jumping; a viewport capture
|
|
293
|
+
* repaints nothing, and measured twice as fast. `disabled` freezes CSS animation, which once sliced GeeTest svg to
|
|
294
|
+
* a static clip; callers filming motion pass `allow`.
|
|
295
|
+
*/
|
|
296
|
+
async shot(el, p, timeout = 2500, animations = 'disabled') {
|
|
297
|
+
const page = this.page;
|
|
298
|
+
const view = await viewportOf(page);
|
|
299
|
+
const rect = (await layoutIsTheViewport(page, view)) ? await this.rectInView(el, view) : null;
|
|
300
|
+
if (!rect) {
|
|
301
|
+
// Bigger than the viewport, or a zoomed-out mobile layout: no viewport capture holds it in its own
|
|
302
|
+
// coordinates, so the element photographs itself.
|
|
303
|
+
await el.screenshot({ path: p, timeout, animations });
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
fs.writeFileSync(p, (0, png_1.cropPng)(await page.screenshot({ timeout, animations }), rect, view.width));
|
|
307
|
+
}
|
|
308
|
+
async rectOf(el) {
|
|
309
|
+
const box = await el.boundingBox();
|
|
310
|
+
const rect = box ? (0, png_1.enclosingRect)(box) : null;
|
|
311
|
+
if (!rect || rect.width <= 0 || rect.height <= 0)
|
|
312
|
+
throw new Error('the element is not visible: it has no bounding box to photograph');
|
|
313
|
+
return rect;
|
|
314
|
+
}
|
|
315
|
+
/** Where the element sits in the viewport, scrolled in when it is merely off screen; null when it cannot fit. */
|
|
316
|
+
async rectInView(el, view) {
|
|
317
|
+
let rect = await this.rectOf(el);
|
|
318
|
+
if (rect.width > view.width || rect.height > view.height)
|
|
319
|
+
return null;
|
|
320
|
+
if (!within(rect, view)) {
|
|
321
|
+
await el.scrollIntoViewIfNeeded({ timeout: 2000 });
|
|
322
|
+
rect = await this.rectOf(el);
|
|
323
|
+
}
|
|
324
|
+
return within(rect, view) ? rect : null;
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Take the pointer off the board and let our own feedback fade before the board is judged still or animated.
|
|
328
|
+
*
|
|
329
|
+
* A hovered or pressed control repaints, and a board wearing our highlight read as motion: a still board was
|
|
330
|
+
* filmed as animated because of the Verify button under the cursor.
|
|
331
|
+
*/
|
|
332
|
+
async stepOffTheBoard(page, el) {
|
|
333
|
+
// Only a board we pointed at wears our feedback. One the vendor dealt under a resting pointer has nothing of
|
|
334
|
+
// ours to fade, and stepping off it cost every round its own gesture and settle window.
|
|
335
|
+
if (!this.actedOnBoard)
|
|
336
|
+
return;
|
|
337
|
+
const box = await el.boundingBox();
|
|
338
|
+
const [x, y] = this.human.at;
|
|
339
|
+
if (box && this.human.hovers && x >= box.x && x <= box.x + box.width && y >= box.y && y <= box.y + box.height) {
|
|
340
|
+
const spot = offBoardPoint(box, [x, y], await viewportOf(page));
|
|
341
|
+
if (spot)
|
|
342
|
+
await this.performSmoothMove(page, ...spot);
|
|
343
|
+
}
|
|
344
|
+
await delay(this.lastInputAt + exports.INPUT_SETTLE_MS - Date.now());
|
|
203
345
|
}
|
|
204
346
|
async solve(page) {
|
|
205
347
|
// One session id per solve groups its inference rounds into one billable attempt.
|
|
206
348
|
this.solveSessionId = (0, crypto_1.randomUUID)();
|
|
349
|
+
this.page = page;
|
|
350
|
+
this.solveVendor = null;
|
|
351
|
+
this.solveSite = hostname(page);
|
|
352
|
+
this.solveWidgetHost = null;
|
|
207
353
|
this.budget = new timing_1.PhaseBudget();
|
|
354
|
+
this.verdicts = null;
|
|
208
355
|
let solvedForReport = false;
|
|
209
356
|
try {
|
|
210
357
|
const result = await this.solveImpl(page);
|
|
@@ -216,48 +363,86 @@ class CaptchaKrakenSolver {
|
|
|
216
363
|
finally {
|
|
217
364
|
if ((0, timing_1.timingsEnabled)())
|
|
218
365
|
console.error(this.budget.report());
|
|
366
|
+
// The camera outlives a round by design, so it has to be ended by the thing that outlives the solve.
|
|
367
|
+
await this.stopAnimatedFilm();
|
|
219
368
|
this.teardownCvWorker();
|
|
220
369
|
this.cvWorkerReady = null;
|
|
221
|
-
|
|
370
|
+
// Set by solveImpl, which the narrowing from the reset above cannot see.
|
|
371
|
+
const verdicts = this.verdicts;
|
|
372
|
+
await verdicts?.close();
|
|
373
|
+
// The vendor's own verdict when it was readable; the DOM's done-signal only when it was not.
|
|
374
|
+
const said = verdicts?.decisive() ?? null;
|
|
375
|
+
void this.reportOutcome(this.solveSessionId, said === null ? solvedForReport : said === kinds_1.Verdict.ACCEPTED);
|
|
222
376
|
this.solveSessionId = null;
|
|
223
377
|
}
|
|
224
378
|
}
|
|
225
|
-
/** Tell the hosted API whether the widget accepted, through the CLI
|
|
379
|
+
/** Tell the hosted API whether the widget accepted, through the CLI. The solve never awaits it; the promise is for callers that must know it finished. */
|
|
226
380
|
reportOutcome(sessionId, solved) {
|
|
227
381
|
// The opt-out is honoured here too: otherwise every solve spawns a process just to be told 404 by a local vLLM.
|
|
228
|
-
if (!sessionId || process.env.CAPTCHA_REPORT_OUTCOME === '0')
|
|
229
|
-
return;
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
382
|
+
if (!sessionId || !this.outcomeSupported || process.env.CAPTCHA_REPORT_OUTCOME === '0')
|
|
383
|
+
return Promise.resolve();
|
|
384
|
+
return new Promise((resolve) => {
|
|
385
|
+
try {
|
|
386
|
+
const { cliRoot, py } = this.resolveCli();
|
|
387
|
+
const child = (0, child_process_1.spawn)(py, ['-m', 'captchakraken.cli', 'report-outcome', sessionId, solved ? kinds_1.Outcome.SOLVED : kinds_1.Outcome.FAILED], { cwd: cliRoot, env: cliEnv(cliRoot, this.routingEnv()), stdio: ['ignore', 'pipe', 'pipe'] });
|
|
388
|
+
let stdout = '';
|
|
389
|
+
child.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
|
|
390
|
+
// The engine words its own warnings; a hosted refusal must be as visible here as it is from Python.
|
|
391
|
+
child.stderr.on('data', (chunk) => process.stderr.write(chunk));
|
|
392
|
+
child.on('error', () => resolve());
|
|
393
|
+
child.on('close', () => {
|
|
394
|
+
try {
|
|
395
|
+
if (JSON.parse(stdout.trim()).supported === false)
|
|
396
|
+
this.outcomeSupported = false;
|
|
397
|
+
}
|
|
398
|
+
catch { /* nothing parseable to learn from */ }
|
|
399
|
+
resolve();
|
|
400
|
+
});
|
|
401
|
+
// Never holds the process open: the solve's result is already decided.
|
|
402
|
+
child.unref();
|
|
403
|
+
[child.stdout, child.stderr].forEach((stream) => stream.unref?.());
|
|
404
|
+
}
|
|
405
|
+
catch {
|
|
406
|
+
resolve(); // a missing engine is loud everywhere else
|
|
407
|
+
}
|
|
408
|
+
});
|
|
237
409
|
}
|
|
238
410
|
/** Solve captchas on `page` as they appear, until `stop()`. Injects nothing into the page. */
|
|
239
411
|
watch(page, options = {}) {
|
|
240
412
|
return (0, watcher_1.watchPage)(this, page, options);
|
|
241
413
|
}
|
|
414
|
+
/**
|
|
415
|
+
* Give up only when the vendor refuses to serve us, or when every one of `maxSolveLoops` has been spent. Any other
|
|
416
|
+
* failed round — a stale handle, an unusable or repeated answer, a board that would not film — counts one loop,
|
|
417
|
+
* pauses, and goes again.
|
|
418
|
+
*/
|
|
242
419
|
async solveImpl(page) {
|
|
243
420
|
const cfg = this.config;
|
|
244
421
|
const maxSolveLoops = cfg.maxSolveLoops ?? exports.SOLVE_DEFAULTS.maxSolveLoops;
|
|
245
422
|
const overallSolveTimeoutMs = cfg.overallSolveTimeoutMs ?? exports.SOLVE_DEFAULTS.overallSolveTimeoutMs;
|
|
246
|
-
const
|
|
423
|
+
const detectionTimeoutMs = cfg.detectionTimeoutMs ?? exports.SOLVE_DEFAULTS.detectionTimeoutMs;
|
|
424
|
+
let start = Date.now();
|
|
247
425
|
const cumulativeTokenUsage = [];
|
|
248
426
|
this.stepIndex = 0;
|
|
249
427
|
this.solveStartMs = start;
|
|
250
428
|
await this.human.reset(page);
|
|
429
|
+
await this.stopAnimatedFilm();
|
|
251
430
|
this.resetSolveState();
|
|
252
|
-
const
|
|
431
|
+
const verdicts = this.verdicts = new verdicts_1.VerdictLog(page);
|
|
432
|
+
const done = () => ({ isSolved: true, finalMousePosition: this.lastMousePosition,
|
|
433
|
+
tokenUsage: (0, token_usage_1.aggregateTokenUsage)(cumulativeTokenUsage), verdicts: [...verdicts.verdicts] });
|
|
434
|
+
const blocked = (how) => new Error(`The vendor refused to serve this client (${how}); no further round can succeed.`);
|
|
435
|
+
let lastFailure = null;
|
|
436
|
+
// Count the loop. Only a widget caught mid-transition is worth waiting out; a refused or repeated answer changed
|
|
437
|
+
// nothing on the page, and pausing after it only spends the solve's budget.
|
|
438
|
+
const again = async (message, { unsupported = false, inTransition = false } = {}) => {
|
|
439
|
+
lastFailure = { message, unsupported };
|
|
440
|
+
console.log(`${message}; counting this loop and going again.`);
|
|
441
|
+
if (inTransition)
|
|
442
|
+
await delay(cfg.staleElementBackoffMs ?? 900);
|
|
443
|
+
};
|
|
253
444
|
let pendingRetryMode = null;
|
|
254
|
-
let alreadyRetriedRecaptchaError = false;
|
|
255
|
-
let unsupportedRetries = 0;
|
|
256
|
-
let staleElementRetries = 0;
|
|
257
445
|
let hasInteracted = false;
|
|
258
|
-
let renderWaits = 0;
|
|
259
|
-
// Strictly fewer than the loops, else the reCAPTCHA v3 "no interactive widget" branch never gets a turn.
|
|
260
|
-
const MAX_RENDER_WAITS = Math.min(6, maxSolveLoops - 1);
|
|
261
446
|
for (let attempt = 1; attempt <= maxSolveLoops; attempt++) {
|
|
262
447
|
const budgetMs = overallSolveTimeoutMs + this.videoBudgetMs;
|
|
263
448
|
this.solveDeadlineAt = start + budgetMs;
|
|
@@ -265,23 +450,38 @@ class CaptchaKrakenSolver {
|
|
|
265
450
|
throw new Error(`Captcha solve timed out after ${budgetMs}ms (attempt ${attempt}/${maxSolveLoops})`
|
|
266
451
|
+ (this.videoBudgetMs ? `, including ${this.videoBudgetMs}ms granted for recording an animated challenge` : '') + '.');
|
|
267
452
|
}
|
|
453
|
+
const said = await this.vendorVerdict();
|
|
454
|
+
if (said === kinds_1.Verdict.ACCEPTED) {
|
|
455
|
+
console.log('[verdict] the vendor accepted the answer; finishing.');
|
|
456
|
+
return done();
|
|
457
|
+
}
|
|
458
|
+
if (said === kinds_1.Verdict.BLOCKED)
|
|
459
|
+
throw blocked('its server answered 429');
|
|
268
460
|
if (hasInteracted && await this.isCaptchaSolved(page)) {
|
|
269
461
|
console.log('Vendor reports solved; returning without another detect pass.');
|
|
270
462
|
return done();
|
|
271
463
|
}
|
|
272
|
-
|
|
464
|
+
let widget = await this.ph(kinds_1.Phase.DETECT, () => this.detectCaptcha(page));
|
|
273
465
|
if (!widget) {
|
|
274
|
-
|
|
466
|
+
// A widget that vanished right after the vendor refused the answer was not solved.
|
|
467
|
+
if (hasInteracted && verdicts.decisive() !== kinds_1.Verdict.REJECTED) {
|
|
275
468
|
console.log('No supported captcha found (post-interaction); considering solved.');
|
|
276
469
|
return done();
|
|
277
470
|
}
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
471
|
+
const waitedFrom = Date.now();
|
|
472
|
+
widget = await this.ph(kinds_1.Phase.DETECT, () => this.awaitWidget(page, detectionTimeoutMs));
|
|
473
|
+
// The budget is for solving; the page drawing its widget is not charged to it.
|
|
474
|
+
start += Date.now() - waitedFrom;
|
|
475
|
+
this.solveDeadlineAt = start + budgetMs;
|
|
476
|
+
if (!widget) {
|
|
477
|
+
if (await this.isCaptchaSolved(page)) {
|
|
478
|
+
console.log('Captcha already satisfied; nothing to solve.');
|
|
479
|
+
return done();
|
|
480
|
+
}
|
|
481
|
+
if (hasInteracted)
|
|
482
|
+
throw new Error(`The vendor rejected the last answer and its widget did not come back within ${detectionTimeoutMs}ms.`);
|
|
483
|
+
throw new Error(await this.noWidgetMessage(page, detectionTimeoutMs));
|
|
283
484
|
}
|
|
284
|
-
throw new Error(await this.noWidgetMessage(page));
|
|
285
485
|
}
|
|
286
486
|
console.log(`\n--- Captcha Solve Loop ${attempt}/${maxSolveLoops} ---`);
|
|
287
487
|
const retryModeThisLoop = pendingRetryMode;
|
|
@@ -292,49 +492,65 @@ class CaptchaKrakenSolver {
|
|
|
292
492
|
({ didInteract, tokenUsage } = await this.solveSingle(page, widget, attempt, retryModeThisLoop));
|
|
293
493
|
}
|
|
294
494
|
catch (e) {
|
|
295
|
-
|
|
296
|
-
throw new Error(`Animated challenge could not be solved: ${e.message ?? 'recording failed'}`);
|
|
495
|
+
const emsg = String((e && e.message) || e);
|
|
297
496
|
if (e?.unsupported) {
|
|
298
|
-
//
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
const again = await this.detectCaptcha(page);
|
|
302
|
-
if (again && await this.ph(kinds_1.Phase.SETTLE, () => this.waitForElementSettled(again.el)) === kinds_1.SettleVerdict.ANIMATED && cfg.videoSolveEnabled === false) {
|
|
303
|
-
throw new Error('Animated/video challenge detected — the puzzle never settles and videoSolveEnabled is off.');
|
|
304
|
-
}
|
|
305
|
-
console.log(`"unsupported" mid-solve; settled and retrying (${unsupportedRetries}/${cfg.maxUnsupportedReSolves ?? 3}).`);
|
|
306
|
-
continue;
|
|
307
|
-
}
|
|
308
|
-
throw new Error(`Cannot solve this kind of captcha — ${e?.message ?? e}`);
|
|
497
|
+
// The model had nothing usable for this board. The next round re-asks, often of a board the vendor has replaced.
|
|
498
|
+
await again(`Cannot solve this kind of captcha — ${emsg}`, { unsupported: true });
|
|
499
|
+
continue;
|
|
309
500
|
}
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
501
|
+
if (e?.animated) {
|
|
502
|
+
if (cfg.videoSolveEnabled === false)
|
|
503
|
+
throw new Error(`Animated challenge could not be solved: ${emsg}`);
|
|
504
|
+
// A widget that will not screenshot is usually one that is closing, because the answer was accepted: measured, a
|
|
505
|
+
// vendor that deals several boards lost four attempts to exactly this, each after its first board came back graded solved.
|
|
506
|
+
if (hasInteracted && await this.isCaptchaSolved(page)) {
|
|
507
|
+
console.log('nothing left to film because the board was accepted; finishing.');
|
|
508
|
+
return done();
|
|
509
|
+
}
|
|
510
|
+
await again(emsg, { inTransition: true });
|
|
315
511
|
continue;
|
|
316
512
|
}
|
|
317
|
-
|
|
513
|
+
const closed = isClosedTargetError(emsg);
|
|
514
|
+
if (!closed && !isStaleHandleError(emsg))
|
|
515
|
+
throw e;
|
|
516
|
+
// The handle most often went stale because the answer was accepted.
|
|
517
|
+
if (hasInteracted && await this.isCaptchaSolved(page).catch(() => false)) {
|
|
518
|
+
console.log('Vendor reports solved; finishing.');
|
|
519
|
+
return done();
|
|
520
|
+
}
|
|
521
|
+
if (closed) {
|
|
522
|
+
throw new Error('The page, context or browser closed mid-solve' + (hasInteracted
|
|
523
|
+
? ', after the answer had been submitted but before the vendor\'s verdict could be read — the solve may in fact have succeeded.'
|
|
524
|
+
: '.'));
|
|
525
|
+
}
|
|
526
|
+
await again(`the challenge handle went stale (${emsg.split('\n')[0]})`, { inTransition: true });
|
|
527
|
+
continue;
|
|
318
528
|
}
|
|
319
529
|
hasInteracted = hasInteracted || didInteract;
|
|
320
|
-
if (this.noProgressRounds >= (cfg.maxNoProgressRounds ?? 2)) {
|
|
321
|
-
throw new Error(`No progress: the model returned the same answer ${this.noProgressRounds + 1} times running and the challenge is still up (attempt ${attempt}/${maxSolveLoops}). Total usage: ${JSON.stringify((0, token_usage_1.aggregateTokenUsage)(cumulativeTokenUsage))}`);
|
|
322
|
-
}
|
|
323
|
-
renderWaits = 0;
|
|
324
530
|
cumulativeTokenUsage.push(...tokenUsage);
|
|
325
531
|
// One polled wait per round, not a flat sleep: the sleep observed nothing and cost 1200-1500ms a round, and
|
|
326
|
-
// hCaptcha keeps its iframe visible ~2s while verifying, which read as a fresh puzzle and burned ~18s.
|
|
327
|
-
|
|
532
|
+
// hCaptcha keeps its iframe visible ~2s while verifying, which read as a fresh puzzle and burned ~18s. A round that
|
|
533
|
+
// performed nothing gave the page nothing to react to, so it looks once and goes on: waiting there cost every
|
|
534
|
+
// refused or repeated answer the full window.
|
|
535
|
+
const settleMs = didInteract ? (cfg.postSolveOutcomeTimeoutMs ?? 1000) : 0;
|
|
328
536
|
const deadline = Date.now() + settleMs;
|
|
329
537
|
const verdictT0 = Date.now();
|
|
330
538
|
let solved = false;
|
|
539
|
+
let roundSaid = null;
|
|
331
540
|
let widgetGone = 0;
|
|
332
|
-
|
|
541
|
+
let looked = false;
|
|
542
|
+
while (!looked || Date.now() < deadline) {
|
|
543
|
+
looked = true;
|
|
544
|
+
roundSaid = (await this.vendorVerdict()) ?? roundSaid;
|
|
545
|
+
if (roundSaid === kinds_1.Verdict.ACCEPTED || roundSaid === kinds_1.Verdict.BLOCKED) {
|
|
546
|
+
solved = roundSaid === kinds_1.Verdict.ACCEPTED;
|
|
547
|
+
break;
|
|
548
|
+
}
|
|
333
549
|
if (await this.isCaptchaSolved(page)) {
|
|
334
550
|
solved = true;
|
|
335
551
|
break;
|
|
336
552
|
}
|
|
337
|
-
widgetGone = (await this.detectCaptcha(page)) ?
|
|
553
|
+
widgetGone = roundSaid !== kinds_1.Verdict.REJECTED && !(await this.detectCaptcha(page)) ? widgetGone + 1 : 0;
|
|
338
554
|
if (widgetGone >= 2) {
|
|
339
555
|
solved = true;
|
|
340
556
|
break;
|
|
@@ -342,6 +558,16 @@ class CaptchaKrakenSolver {
|
|
|
342
558
|
if (await this.isChallengeFreshlyRendered(page)) {
|
|
343
559
|
this.resampleLevel = 0;
|
|
344
560
|
this.actedOnBoard = false;
|
|
561
|
+
// ONE FILM, ONE BOARD — enforced where the board actually changes. The recorded answer
|
|
562
|
+
// describes the board it was cut from, so replaying it here presses that board's
|
|
563
|
+
// coordinates onto this one: live, a keyframe answer from an earlier board landed on
|
|
564
|
+
// hCaptcha's reference photo and then pressed Skip, six rounds running.
|
|
565
|
+
await this.stopAnimatedFilm();
|
|
566
|
+
this.knownAnimated = false;
|
|
567
|
+
// Its own second look, but only once IT fails: what armed it was evidence about the previous board,
|
|
568
|
+
// and left set it filmed every still board after a solve's first miss.
|
|
569
|
+
this.repeatedAnswerSeen = false;
|
|
570
|
+
this.animatedProbeDone = false;
|
|
345
571
|
break;
|
|
346
572
|
}
|
|
347
573
|
await delay(cfg.postSolveOutcomePollMs ?? 75);
|
|
@@ -351,21 +577,83 @@ class CaptchaKrakenSolver {
|
|
|
351
577
|
console.log(`[verdict] success signal arrived after ${Date.now() - verdictT0}ms`);
|
|
352
578
|
return done();
|
|
353
579
|
}
|
|
580
|
+
if (roundSaid === kinds_1.Verdict.BLOCKED)
|
|
581
|
+
throw blocked('its server answered 429');
|
|
582
|
+
if (await this.isBlocked(page))
|
|
583
|
+
throw blocked('it is showing its try-again-later screen');
|
|
584
|
+
if (roundSaid === kinds_1.Verdict.REJECTED)
|
|
585
|
+
console.log(`[verdict] the vendor rejected the answer after ${Date.now() - verdictT0}ms`);
|
|
354
586
|
if (this.bannerIsFatalAfterRetry(await this.bannerKind(page))) {
|
|
355
|
-
|
|
356
|
-
throw new Error(`reCAPTCHA still showing the under-selection error after retry; aborting (model unable to identify the missed tile). Total usage: ${JSON.stringify((0, token_usage_1.aggregateTokenUsage)(cumulativeTokenUsage))}`);
|
|
357
|
-
}
|
|
358
|
-
console.log('reCAPTCHA returned under-selection error; retrying with missed-tiles prompt.');
|
|
587
|
+
console.log('reCAPTCHA under-selection error; the next round asks for the missed tiles.');
|
|
359
588
|
pendingRetryMode = kinds_1.RetryMode.MISSED_TILES;
|
|
360
|
-
alreadyRetriedRecaptchaError = true;
|
|
361
589
|
}
|
|
362
|
-
if (!(await this.detectCaptcha(page)))
|
|
590
|
+
if (roundSaid !== kinds_1.Verdict.REJECTED && !(await this.detectCaptcha(page)))
|
|
363
591
|
return done();
|
|
364
|
-
if (
|
|
365
|
-
|
|
592
|
+
if (this.noProgressRounds) {
|
|
593
|
+
const stuck = `No progress: the model returned the same answer ${this.noProgressRounds + 1} times running`;
|
|
594
|
+
// A board answered the same way after every resample is one the model cannot read: measured, no solve ever
|
|
595
|
+
// followed a third identical answer, and each further round cost the board 3-7s.
|
|
596
|
+
if (this.noProgressRounds >= (cfg.maxNoProgressRounds ?? 2))
|
|
597
|
+
throw new Error(`${stuck}.`);
|
|
598
|
+
await again(stuck);
|
|
366
599
|
}
|
|
600
|
+
else if (!didInteract) {
|
|
601
|
+
// An answer with nothing to execute is not proof the page is stuck; on an animated board it is what a still
|
|
602
|
+
// expert returns when the board is not a still, so the next round takes a second look.
|
|
603
|
+
if (cfg.videoSolveEnabled !== false)
|
|
604
|
+
this.repeatedAnswerSeen = true; // what arms the second look
|
|
605
|
+
await again('The solver performed no interactions this round');
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
const last = lastFailure;
|
|
609
|
+
const usage = ` Total usage: ${JSON.stringify((0, token_usage_1.aggregateTokenUsage)(cumulativeTokenUsage))}`;
|
|
610
|
+
if (last?.unsupported)
|
|
611
|
+
throw new Error(`${last.message} (after ${maxSolveLoops} solve loops).${usage}`);
|
|
612
|
+
throw new Error(`Captcha still detected after ${maxSolveLoops} solve loops${last ? `; the last failed round: ${last.message}` : ''}.${usage}`);
|
|
613
|
+
}
|
|
614
|
+
/** What the vendor's server said since the last look: BLOCKED outranks everything, else the latest answer. */
|
|
615
|
+
async vendorVerdict() {
|
|
616
|
+
const fresh = (await this.verdicts?.fresh() ?? []).map((v) => v.verdict).filter((v) => v !== kinds_1.Verdict.NEW_CHALLENGE);
|
|
617
|
+
return fresh.includes(kinds_1.Verdict.BLOCKED) ? kinds_1.Verdict.BLOCKED : fresh[fresh.length - 1] ?? null;
|
|
618
|
+
}
|
|
619
|
+
/**
|
|
620
|
+
* A page that has not drawn its widget yet is looked at again until `detectionTimeoutMs`. Solving straight after
|
|
621
|
+
* `domcontentloaded` failed 3/3 on a demo page whose frames arrived a moment later.
|
|
622
|
+
*/
|
|
623
|
+
async awaitWidget(page, timeoutMs) {
|
|
624
|
+
const started = Date.now();
|
|
625
|
+
let widget = null;
|
|
626
|
+
while (!widget && Date.now() - started < timeoutMs && !(await this.isCaptchaSolved(page))) {
|
|
627
|
+
await delay(DETECTION_POLL_MS);
|
|
628
|
+
widget = await this.detectCaptcha(page);
|
|
367
629
|
}
|
|
368
|
-
|
|
630
|
+
return widget;
|
|
631
|
+
}
|
|
632
|
+
/** The vendor showing its refusal screen: rate-limited or flagged, with no board to answer. */
|
|
633
|
+
async isBlocked(page) {
|
|
634
|
+
try {
|
|
635
|
+
const probes = selectors_1.WIDGET_PROBES.filter((p) => p.role === kinds_1.FrameRole.CHALLENGE && selectors_1.SELECTORS[p.vendor].blocked);
|
|
636
|
+
const shown = await Promise.all(probes.map(async (p) => {
|
|
637
|
+
const [at] = await visible(page, [p.selector]);
|
|
638
|
+
const frame = at && await frameOf(at);
|
|
639
|
+
return !!frame && (await Promise.all((selectors_1.SELECTORS[p.vendor].blocked ?? []).map((sel) => this.visibleWithText(frame, sel)))).some(Boolean);
|
|
640
|
+
}));
|
|
641
|
+
return shown.some(Boolean);
|
|
642
|
+
}
|
|
643
|
+
catch {
|
|
644
|
+
return false;
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
/** The env the engine reads its routing headers from: session, and the vendor and site once they are known. */
|
|
648
|
+
routingEnv() {
|
|
649
|
+
return {
|
|
650
|
+
...(this.solveSessionId ? { CAPTCHA_KRAKEN_SESSION: this.solveSessionId } : {}),
|
|
651
|
+
...(this.solveVendor ? { CAPTCHA_KRAKEN_VENDOR: this.solveVendor } : {}),
|
|
652
|
+
...(this.solveSite ? { CAPTCHA_KRAKEN_SITE: this.solveSite } : {}),
|
|
653
|
+
...(this.solveWidgetHost ? { CAPTCHA_KRAKEN_WIDGET_HOST: this.solveWidgetHost } : {}),
|
|
654
|
+
// This driver warns about a legacy model name itself, once; the engine it starts per round stays quiet.
|
|
655
|
+
CAPTCHA_KRAKEN_MODEL_WARNING: '0',
|
|
656
|
+
};
|
|
369
657
|
}
|
|
370
658
|
/** Fire the optional onStep observer with a fresh screenshot; best-effort, never fails the solve. */
|
|
371
659
|
async emitStep(captchaElement, stage, label, puzzleSource, frameRole, attempt, meta) {
|
|
@@ -391,12 +679,14 @@ class CaptchaKrakenSolver {
|
|
|
391
679
|
async solveSingle(page, widget, attempt, retryMode = null) {
|
|
392
680
|
const cfg = this.config;
|
|
393
681
|
const { el: captchaElement, vendor: puzzleSource, role: frameRole } = widget;
|
|
682
|
+
this.solveVendor = puzzleSource;
|
|
394
683
|
const frame = await captchaElement.contentFrame();
|
|
684
|
+
this.solveWidgetHost = frame ? hostname(frame) : null;
|
|
395
685
|
const scope = frame ?? widget.at;
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
686
|
+
if (frameRole === kinds_1.FrameRole.CHECKBOX) {
|
|
687
|
+
await this.clickCheckbox(page, widget, frame);
|
|
688
|
+
return { didInteract: true, tokenUsage: [] };
|
|
689
|
+
}
|
|
400
690
|
if (frame && frameRole === kinds_1.FrameRole.CHALLENGE && selectors_1.SELECTORS[puzzleSource].images) {
|
|
401
691
|
if (this.lastSubmitFrameHash) {
|
|
402
692
|
await this.ph(kinds_1.Phase.AWAIT_NEXT_ROUND, () => this.waitForChangeSince(captchaElement, this.lastSubmitFrameHash));
|
|
@@ -404,8 +694,23 @@ class CaptchaKrakenSolver {
|
|
|
404
694
|
}
|
|
405
695
|
await this.ph(kinds_1.Phase.HCAPTCHA_IMAGES, () => this.waitForBoardImages(frame, selectors_1.SELECTORS[puzzleSource]));
|
|
406
696
|
}
|
|
407
|
-
//
|
|
408
|
-
|
|
697
|
+
// THE TWO QUESTIONS BELOW ARE PUT TO A PAINTED BOARD. Which expert answers the round is read out of the
|
|
698
|
+
// DOM and whether the board cycles is read off its motion, and a widget that has not drawn yet has no
|
|
699
|
+
// text box to find and no motion but its own arrival.
|
|
700
|
+
//
|
|
701
|
+
// A reCAPTCHA board is asked NEITHER — it is never typed and never filmed — and it waits for its board
|
|
702
|
+
// at the grid gate below, on the cells themselves. Waiting here as well put 0.8-1.8s in front of that
|
|
703
|
+
// gate on every dynamic round, on the family with the tightest per-board budget.
|
|
704
|
+
const painted = puzzleSource === kinds_1.Vendor.RECAPTCHA ? null
|
|
705
|
+
: await this.ph(kinds_1.Phase.BOARD_PAINT, () => this.waitForBoardPainted(captchaElement));
|
|
706
|
+
// Only the DOM can tell a typed captcha from a click puzzle; hCaptcha and reCAPTCHA never type.
|
|
707
|
+
const textMode = !VENDORS_WITH_BESPOKE_HANDLING.has(puzzleSource) && (await this.answerBox(scope, widget.at)) !== null;
|
|
708
|
+
if (textMode)
|
|
709
|
+
console.log('Widget has a text box; solving as a distorted-text captcha.');
|
|
710
|
+
// A reCAPTCHA board is read by its grid below, not filmed.
|
|
711
|
+
const filmable = puzzleSource !== kinds_1.Vendor.RECAPTCHA && !textMode;
|
|
712
|
+
if (filmable && !this.knownAnimated)
|
|
713
|
+
await this.stepOffTheBoard(page, captchaElement);
|
|
409
714
|
let isAnimated = false;
|
|
410
715
|
if (filmable && cfg.videoSolveEnabled === false) {
|
|
411
716
|
if (await this.ph(kinds_1.Phase.SETTLE, () => this.waitForElementSettled(captchaElement)) === kinds_1.SettleVerdict.ANIMATED) {
|
|
@@ -423,9 +728,15 @@ class CaptchaKrakenSolver {
|
|
|
423
728
|
await this.releasePendingBurst();
|
|
424
729
|
return { didInteract: false, tokenUsage: [] };
|
|
425
730
|
}
|
|
426
|
-
|
|
427
|
-
|
|
731
|
+
// 'settled' is not proof of static: a repeated answer buys ONE recording to find out, and the clip that
|
|
732
|
+
// recording brings back is what decides — `knownAnimated` is set from it below, not from here.
|
|
733
|
+
let probing = false;
|
|
734
|
+
if (!isAnimated && this.knownAnimated)
|
|
428
735
|
isAnimated = true;
|
|
736
|
+
else if (!isAnimated && this.shouldRetryAsAnimated(puzzleSource)) {
|
|
737
|
+
console.log('[animated] a second look at a board that did not solve as a still — recording it');
|
|
738
|
+
this.animatedProbeDone = true;
|
|
739
|
+
isAnimated = probing = true;
|
|
429
740
|
}
|
|
430
741
|
let establishedGridSize = null;
|
|
431
742
|
// The challenge frame only: on the anchor this wasted an 8s grid-load timeout plus a find-grid subprocess.
|
|
@@ -441,10 +752,15 @@ class CaptchaKrakenSolver {
|
|
|
441
752
|
}
|
|
442
753
|
establishedGridSize = grid?.size ?? null;
|
|
443
754
|
}
|
|
444
|
-
|
|
445
|
-
|
|
755
|
+
// The board a reCAPTCHA round photographs, for the rounds that do not take the grid path above.
|
|
756
|
+
if (puzzleSource === kinds_1.Vendor.RECAPTCHA) {
|
|
757
|
+
await this.ph(kinds_1.Phase.BOARD_PAINT, () => this.waitForBoardPainted(captchaElement));
|
|
758
|
+
}
|
|
759
|
+
// The classifier watched a board that had already painted, so its last frame is that board at rest —
|
|
760
|
+
// unless it never painted at all, where a fresh photograph is the better chance of catching one.
|
|
446
761
|
const screenshotPath = tmp('captcha');
|
|
447
|
-
const settledFrame = (isAnimated || painted
|
|
762
|
+
const settledFrame = (isAnimated || painted?.verdict !== kinds_1.PaintVerdict.PAINTED)
|
|
763
|
+
? null : (this.pendingBurst?.stableFrame() ?? null);
|
|
448
764
|
if (settledFrame && fs.existsSync(settledFrame)) {
|
|
449
765
|
fs.copyFileSync(settledFrame, screenshotPath);
|
|
450
766
|
}
|
|
@@ -460,24 +776,75 @@ class CaptchaKrakenSolver {
|
|
|
460
776
|
let allTokenUsage = [];
|
|
461
777
|
let burstDir = null;
|
|
462
778
|
try {
|
|
463
|
-
let response;
|
|
779
|
+
let response = null;
|
|
464
780
|
const ask = (imagePath) => this.getSolution(imagePath, puzzleSource, retryMode, textMode);
|
|
465
781
|
const askAnimated = () => this.ph(kinds_1.Phase.INFERENCE, () => this.withIdleWander(page, captchaElement, () => this.getAnimatedSolution(burstDir)));
|
|
466
782
|
if (isAnimated) {
|
|
467
|
-
|
|
783
|
+
// The camera was paused while the last answer was performed; from here the board is its own again.
|
|
784
|
+
this.animatedFilm?.resume();
|
|
785
|
+
if (this.animatedPlan?.response) {
|
|
468
786
|
burstDir = this.animatedPlan.burstDir;
|
|
469
787
|
response = this.animatedPlan.response;
|
|
470
788
|
console.log('[animated] reusing the recorded answer — same board, same screens');
|
|
471
789
|
}
|
|
472
|
-
else {
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
790
|
+
else if (this.animatedPlan) {
|
|
791
|
+
// Drop the ANSWER, keep the FRAMES — and the frames have grown. The camera did not stop when the
|
|
792
|
+
// last answer was sent, so re-slicing now covers a round more of the board than the ask that was
|
|
793
|
+
// refused. Re-asking the SAME frames would put the same question up and get the same answer back,
|
|
794
|
+
// which is the loop this invariant exists to break.
|
|
795
|
+
//
|
|
796
|
+
// But it has to be the SAME BOARD. The wait below is what decides: it ends the moment a screen
|
|
797
|
+
// from before our answer comes back — usually one frame — and otherwise runs until the board that
|
|
798
|
+
// IS there has shown itself, and the slice is cut to that. Skipping it re-asked over a film that
|
|
799
|
+
// held nothing of the current board at all.
|
|
800
|
+
const film = this.animatedFilm;
|
|
801
|
+
let grown = null;
|
|
802
|
+
if (film) {
|
|
803
|
+
await this.ph(kinds_1.Phase.BURST, () => film.settledOrCycled());
|
|
804
|
+
grown = await film.snapshot();
|
|
805
|
+
}
|
|
806
|
+
if (grown)
|
|
807
|
+
rmdir(this.animatedPlan.burstDir);
|
|
808
|
+
burstDir = grown?.dir ?? this.animatedPlan.burstDir;
|
|
809
|
+
console.log(grown
|
|
810
|
+
? '[animated] the last answer was refused — re-asking on the film as it stands now'
|
|
811
|
+
: '[animated] the last answer was refused — re-asking on the frames already recorded');
|
|
476
812
|
response = await askAnimated();
|
|
477
813
|
this.animatedPlan = { burstDir, response };
|
|
478
814
|
}
|
|
815
|
+
else {
|
|
816
|
+
const rec = this.animatedFilm ?? this.pendingBurst ?? this.startKeyframeBurst(captchaElement, true);
|
|
817
|
+
this.pendingBurst = null;
|
|
818
|
+
// First slice of a board waits for it to show a cycle or settle; a later one does not, because the
|
|
819
|
+
// camera has been running through the whole previous round and already holds more than that.
|
|
820
|
+
const first = this.animatedFilm !== rec;
|
|
821
|
+
if (first)
|
|
822
|
+
await this.ph(kinds_1.Phase.BURST, () => rec.settledOrCycled());
|
|
823
|
+
const film = await rec.snapshot();
|
|
824
|
+
if (probing)
|
|
825
|
+
this.knownAnimated = film.moved;
|
|
826
|
+
if (!probing || film.moved) {
|
|
827
|
+
this.animatedFilm = rec;
|
|
828
|
+
burstDir = film.dir;
|
|
829
|
+
response = await askAnimated();
|
|
830
|
+
this.animatedPlan = { burstDir, response };
|
|
831
|
+
}
|
|
832
|
+
else {
|
|
833
|
+
// The clip answers the question the probe asked: a board that never moved is a still, and the
|
|
834
|
+
// video expert can only answer a still with a frame number the widget will not take.
|
|
835
|
+
console.log('[animated] the recording shows a still board; solving it as a still');
|
|
836
|
+
const rested = rec.stableFrame();
|
|
837
|
+
if (rested && fs.existsSync(rested))
|
|
838
|
+
fs.copyFileSync(rested, screenshotPath);
|
|
839
|
+
rmdir(film.dir);
|
|
840
|
+
if (this.animatedFilm === rec)
|
|
841
|
+
this.animatedFilm = null;
|
|
842
|
+
await rec.abandon();
|
|
843
|
+
isAnimated = false;
|
|
844
|
+
}
|
|
845
|
+
}
|
|
479
846
|
}
|
|
480
|
-
|
|
847
|
+
if (!response && this.shouldSpeculate(puzzleSource, textMode)) {
|
|
481
848
|
// Ask the still and film at once. No idle wander: a moving cursor reads as new screens.
|
|
482
849
|
const rec = this.pendingBurst ?? this.startKeyframeBurst(captchaElement);
|
|
483
850
|
this.pendingBurst = null;
|
|
@@ -508,14 +875,13 @@ class CaptchaKrakenSolver {
|
|
|
508
875
|
}
|
|
509
876
|
else {
|
|
510
877
|
console.log('[animated] the widget moved while the model was reading it — dropping the still answer and finishing the recording.');
|
|
511
|
-
isAnimated = true;
|
|
512
|
-
this.
|
|
513
|
-
burstDir = await this.ph(kinds_1.Phase.BURST, () => rec.finish());
|
|
878
|
+
isAnimated = this.knownAnimated = true;
|
|
879
|
+
burstDir = (await this.ph(kinds_1.Phase.BURST, () => rec.finish())).dir;
|
|
514
880
|
response = await askAnimated();
|
|
515
881
|
this.animatedPlan = { burstDir, response };
|
|
516
882
|
}
|
|
517
883
|
}
|
|
518
|
-
else {
|
|
884
|
+
else if (!response) {
|
|
519
885
|
response = await this.ph(kinds_1.Phase.INFERENCE, () => this.solveFrameFreshnessGuarded(captchaElement, screenshotPath, (imagePath) => this.withIdleWander(page, captchaElement, () => ask(imagePath))));
|
|
520
886
|
}
|
|
521
887
|
const actionList = Array.isArray(response.actions) ? response.actions : [response.actions];
|
|
@@ -533,9 +899,13 @@ class CaptchaKrakenSolver {
|
|
|
533
899
|
console.log('[answer] ' + JSON.stringify({ actions: actionList }));
|
|
534
900
|
// A repeated answer is not re-performed: the widget already refused it, and every extra press is
|
|
535
901
|
// behaviour a vendor scores. Re-asking with a fresh sample or a recording is the round's only move.
|
|
902
|
+
// `noteAnswer` drops the refused animated answer on its way through, so the next round re-asks.
|
|
536
903
|
if (this.noteAnswer(actionList, retryMode))
|
|
537
904
|
return { didInteract: false, tokenUsage: allTokenUsage };
|
|
538
905
|
console.log(`Executing ${actionList.length} actions.`);
|
|
906
|
+
// Stop filming before we touch it. Everything from here to the vendor's verdict is our own answer
|
|
907
|
+
// landing, and a frame of the board wearing our clicks is not a screen the board ever showed.
|
|
908
|
+
this.animatedFilm?.pause();
|
|
539
909
|
for (const action of actionList) {
|
|
540
910
|
if (action.action === kinds_1.ActionKind.CLICK) {
|
|
541
911
|
const c = action;
|
|
@@ -605,6 +975,77 @@ class CaptchaKrakenSolver {
|
|
|
605
975
|
}
|
|
606
976
|
return { didInteract: performedAction, tokenUsage: allTokenUsage };
|
|
607
977
|
}
|
|
978
|
+
/**
|
|
979
|
+
* Tick the box and wait for what it opens. A checkbox is not a board: it is never polled, filmed or asked about.
|
|
980
|
+
*
|
|
981
|
+
* Treated as a board it cost 70 screenshots and three model calls in one measured solve, two of them answering the
|
|
982
|
+
* checkbox picture with a drag.
|
|
983
|
+
*/
|
|
984
|
+
async clickCheckbox(page, widget, frame) {
|
|
985
|
+
const box = await this.checkboxBox(frame, widget.vendor);
|
|
986
|
+
if (box)
|
|
987
|
+
await this.move(page, box);
|
|
988
|
+
else
|
|
989
|
+
await this.performSmoothMove(page, ...await this.findCheckboxOnScreen(widget.el));
|
|
990
|
+
await this.human.pause(kinds_1.PauseKind.BETWEEN);
|
|
991
|
+
await this.gesture(() => this.human.click(page, this.human.at));
|
|
992
|
+
await this.awaitWhatTheCheckboxOpened(page);
|
|
993
|
+
}
|
|
994
|
+
/**
|
|
995
|
+
* The box in a checkbox frame once the frame has drawn it; null when the DOM cannot reach it. Turnstile's box is in
|
|
996
|
+
* a closed shadow root, and a frame can refuse access; both are found on screen instead.
|
|
997
|
+
*/
|
|
998
|
+
async checkboxBox(frame, vendor) {
|
|
999
|
+
const box = selectors_1.SELECTORS[vendor].box;
|
|
1000
|
+
if (!frame || !box?.length)
|
|
1001
|
+
return null;
|
|
1002
|
+
try {
|
|
1003
|
+
return await frame.waitForSelector(box.join(', '), { state: 'visible', timeout: this.config.detectionTimeoutMs ?? exports.SOLVE_DEFAULTS.detectionTimeoutMs });
|
|
1004
|
+
}
|
|
1005
|
+
catch (e) {
|
|
1006
|
+
console.log(`[checkbox] the box is out of the DOM's reach (${String(e?.message ?? e).split('\n')[0]}); finding it on screen`);
|
|
1007
|
+
return null;
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
/** One capture and OpenCV: a point inside the tick box, in page coordinates. No model is asked. */
|
|
1011
|
+
async findCheckboxOnScreen(el) {
|
|
1012
|
+
const box = await el.boundingBox();
|
|
1013
|
+
if (!box)
|
|
1014
|
+
throw new Error('Could not get bounding box of captcha element');
|
|
1015
|
+
const shot = tmp('checkbox');
|
|
1016
|
+
let found;
|
|
1017
|
+
let scale;
|
|
1018
|
+
try {
|
|
1019
|
+
await this.ph(kinds_1.Phase.SCREENSHOT, () => this.shot(el, shot, this.config.elementScreenshotTimeoutMs ?? 8000));
|
|
1020
|
+
found = await this.runCvTool('find-checkbox', { image: shot }, ['find-checkbox', shot]);
|
|
1021
|
+
scale = this.shotScale(shot, box.width);
|
|
1022
|
+
}
|
|
1023
|
+
finally {
|
|
1024
|
+
unlink(shot);
|
|
1025
|
+
}
|
|
1026
|
+
if (!found) {
|
|
1027
|
+
const e = new Error('UNSUPPORTED_CAPTCHA: the checkbox widget shows no tick box to click');
|
|
1028
|
+
e.unsupported = true;
|
|
1029
|
+
throw e;
|
|
1030
|
+
}
|
|
1031
|
+
const [x, y, w, h] = found.map((v) => v / scale);
|
|
1032
|
+
return [box.x + x + w * (0.3 + Math.random() * 0.4), box.y + y + h * (0.3 + Math.random() * 0.4)];
|
|
1033
|
+
}
|
|
1034
|
+
/**
|
|
1035
|
+
* A clicked checkbox passes or opens a challenge, and both show in the DOM.
|
|
1036
|
+
* Without this wait the next round found the same checkbox still up and clicked it again.
|
|
1037
|
+
*/
|
|
1038
|
+
async awaitWhatTheCheckboxOpened(page) {
|
|
1039
|
+
const deadline = Date.now() + (this.config.postSubmitChangeTimeoutMs ?? 4000);
|
|
1040
|
+
await this.ph(kinds_1.Phase.AWAIT_NEXT_ROUND, async () => {
|
|
1041
|
+
while (Date.now() < deadline && !(await this.isCaptchaSolved(page))) {
|
|
1042
|
+
const widget = await this.detectCaptcha(page);
|
|
1043
|
+
if (widget?.role !== kinds_1.FrameRole.CHECKBOX)
|
|
1044
|
+
return;
|
|
1045
|
+
await delay(this.config.postSolveOutcomePollMs ?? 75);
|
|
1046
|
+
}
|
|
1047
|
+
});
|
|
1048
|
+
}
|
|
608
1049
|
getVerifyButton(scope) {
|
|
609
1050
|
return this.findControl(scope, selectors_1.SUBMIT_SELECTORS);
|
|
610
1051
|
}
|
|
@@ -718,13 +1159,27 @@ class CaptchaKrakenSolver {
|
|
|
718
1159
|
const blob = names.join(' ');
|
|
719
1160
|
return selectors_1.VENDORS.filter(([, s]) => s.hosts.some((h) => blob.includes(h))).map(([vendor]) => vendor);
|
|
720
1161
|
}
|
|
721
|
-
|
|
722
|
-
|
|
1162
|
+
/** Vendors showing a frame no selector names. Only asked once detection has found nothing, and a passive frame (an invisible badge) is not a widget, so what is left is markup the table no longer matches. */
|
|
1163
|
+
async unmatchedVendorFrames(page) {
|
|
1164
|
+
const counts = await Promise.all(selectors_1.VENDORS.map(async ([vendor, s]) => {
|
|
1165
|
+
if (!s.hosts.length)
|
|
1166
|
+
return null;
|
|
1167
|
+
const [frames, passive] = await Promise.all([visible(page, s.hosts.map((h) => `iframe[src*="${h}"]`)), visible(page, s.passive ?? [])]);
|
|
1168
|
+
return frames.length > passive.length ? vendor : null;
|
|
1169
|
+
}));
|
|
1170
|
+
return counts.filter((v) => v !== null);
|
|
1171
|
+
}
|
|
1172
|
+
async noWidgetMessage(page, waitedMs) {
|
|
1173
|
+
const base = `No interactive captcha widget detected within ${waitedMs}ms`;
|
|
723
1174
|
const loaded = await this.vendorsOnTheWire(page);
|
|
724
1175
|
if (!loaded.length) {
|
|
725
|
-
return `${base} (no vendor captcha code loaded on this page — likely reCAPTCHA v3 / invisible, or a click-triggered challenge that has not been triggered)
|
|
1176
|
+
return `${base} (no vendor captcha code loaded on this page — likely reCAPTCHA v3 / invisible, or a click-triggered challenge that has not been triggered).`;
|
|
1177
|
+
}
|
|
1178
|
+
const framed = await this.unmatchedVendorFrames(page);
|
|
1179
|
+
if (framed.length) {
|
|
1180
|
+
return `${base}, BUT ${framed.join('/')} is showing a frame that matches nothing in SELECTORS. The vendor's markup has changed — the table needs re-measuring against the vendor's current markup, in both solver ports.`;
|
|
726
1181
|
}
|
|
727
|
-
return `${base}
|
|
1182
|
+
return `${base}; ${loaded.join('/')} code is loaded but drew no widget — the challenge may be invisible or not triggered yet, or an in-page widget's markup has changed.`;
|
|
728
1183
|
}
|
|
729
1184
|
/**
|
|
730
1185
|
* Is a widget in the DOM at all, rendered or not? Invisible reCAPTCHA is excluded by its selector. The inline vendors
|
|
@@ -759,9 +1214,13 @@ class CaptchaKrakenSolver {
|
|
|
759
1214
|
/** The `--model` argv, or nothing: without VLLM_BASE_URL the CLI can see a credentials file this process cannot. */
|
|
760
1215
|
modelName(cliRoot) {
|
|
761
1216
|
const explicit = this.config.model ?? process.env.CAPTCHA_LORA_NAME;
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
1217
|
+
const name = explicit || (process.env.VLLM_BASE_URL ? this.loraName(cliRoot) : undefined);
|
|
1218
|
+
if (name && !this.legacyModelWarned && (0, model_name_1.isHostedEndpoint)(process.env.VLLM_BASE_URL)
|
|
1219
|
+
&& (0, model_name_1.isLegacyHostedName)(name, cliRoot)) {
|
|
1220
|
+
this.legacyModelWarned = true;
|
|
1221
|
+
console.error(`[captchakraken] ${model_name_1.LEGACY_MODEL_WARNING.replace('{model}', name)}`);
|
|
1222
|
+
}
|
|
1223
|
+
return name;
|
|
765
1224
|
}
|
|
766
1225
|
resolveCli() {
|
|
767
1226
|
if (this.cliCache)
|
|
@@ -775,6 +1234,18 @@ class CaptchaKrakenSolver {
|
|
|
775
1234
|
this.cliCache = { cliRoot, py };
|
|
776
1235
|
return this.cliCache;
|
|
777
1236
|
}
|
|
1237
|
+
/** What is LEFT of the solve, as a bound on one ask.
|
|
1238
|
+
*
|
|
1239
|
+
* The CLI is a child process and the solve deadline is only read between steps, so nothing stops an
|
|
1240
|
+
* ask already in flight — measured against the hosted endpoint, one hung for ~124s inside a 45s
|
|
1241
|
+
* budget and the attempt ran 143s. Never below the floor: a keyframe ask carries six images, and a
|
|
1242
|
+
* timeout shorter than the work turns a busy endpoint into a guaranteed failure. */
|
|
1243
|
+
askTimeoutMs() {
|
|
1244
|
+
const ceiling = CLI_ASK_TIMEOUT_MS;
|
|
1245
|
+
if (!this.solveDeadlineAt)
|
|
1246
|
+
return ceiling;
|
|
1247
|
+
return Math.max(CLI_ASK_MIN_TIMEOUT_MS, Math.min(ceiling, this.solveDeadlineAt - Date.now()));
|
|
1248
|
+
}
|
|
778
1249
|
/** One-shot CLI tool call; `{}` on any failure so polling callers keep going. */
|
|
779
1250
|
async runCliTool(args) {
|
|
780
1251
|
try {
|
|
@@ -1140,20 +1611,32 @@ class CaptchaKrakenSolver {
|
|
|
1140
1611
|
}
|
|
1141
1612
|
return { didInteract: performedAction, tokenUsage: allTokenUsage };
|
|
1142
1613
|
}
|
|
1143
|
-
async recordKeyframeBurst(captchaElement) {
|
|
1144
|
-
return this.startKeyframeBurst(captchaElement).finish();
|
|
1145
|
-
}
|
|
1146
1614
|
/**
|
|
1147
1615
|
* Start filming now and decide later what it was for: the classifier, the speculative film, or the
|
|
1148
1616
|
* recording an animated answer is sliced from. Every window is wall-clock. A cycle is a screen that
|
|
1149
1617
|
* comes back; a board that shows nothing new for a floor window has settled. There is deliberately no
|
|
1150
1618
|
* "enough screens, stop" exit: measured on number_with_highest_value_video it failed every seed either way.
|
|
1619
|
+
*
|
|
1620
|
+
* `continuous` keeps the camera rolling past the first answer, for the whole solve. A one-shot burst can
|
|
1621
|
+
* only ever show the model the screens that happened to fall inside its window — measured, a 4s burst
|
|
1622
|
+
* against a 5.3s cycle showed two screens of three, and when the answer lived on the third there was no
|
|
1623
|
+
* second chance, because the reused answer is identical by construction. Filming on means every later
|
|
1624
|
+
* round slices a STRICTLY LONGER film: more repetitions for `_detect_cycle` to confirm against, and, on a
|
|
1625
|
+
* board that never repeats, six evenly-spread picks across the whole solve instead of across four seconds.
|
|
1626
|
+
*
|
|
1627
|
+
* Longer, but never of TWO boards. A vendor that deals a fresh puzzle on a miss has replaced everything
|
|
1628
|
+
* filmed so far, and keyframes cut across both states describe neither. `resume` cuts the film where our
|
|
1629
|
+
* answer landed; a screen from before the cut coming back is what un-cuts it. See `snapshot`.
|
|
1151
1630
|
*/
|
|
1152
|
-
startKeyframeBurst(captchaElement) {
|
|
1631
|
+
startKeyframeBurst(captchaElement, continuous = false) {
|
|
1153
1632
|
const cfg = this.config;
|
|
1154
1633
|
const fps = Math.max(1, cfg.videoBurstFps ?? 10);
|
|
1155
1634
|
const floorMs = cfg.videoBurstDurationMs ?? 4000;
|
|
1156
|
-
|
|
1635
|
+
// A continuous film ends with the solve, not on its own clock. The ceiling stays as a safety net so a
|
|
1636
|
+
// widget that never stops changing cannot fill a disk; it is not the working limit.
|
|
1637
|
+
const ceilingMs = continuous
|
|
1638
|
+
? Math.max(floorMs, cfg.videoFilmMaxMs ?? 120000)
|
|
1639
|
+
: Math.max(floorMs, cfg.videoBurstMaxMs ?? 12000);
|
|
1157
1640
|
const intervalMs = 1000 / fps;
|
|
1158
1641
|
const t0 = Date.now();
|
|
1159
1642
|
let nextAt = t0;
|
|
@@ -1163,58 +1646,107 @@ class CaptchaKrakenSolver {
|
|
|
1163
1646
|
const order = [];
|
|
1164
1647
|
let captured = 0;
|
|
1165
1648
|
let lastDigest = null;
|
|
1649
|
+
let lastNewAt = t0;
|
|
1166
1650
|
let lastNewMs = 0;
|
|
1167
1651
|
let lastChangeMs = 0;
|
|
1652
|
+
// WHERE THE BOARD ON SCREEN STARTED. A film can only describe ONE board, and a vendor that refuses an
|
|
1653
|
+
// answer sometimes deals a fresh puzzle rather than the same one again. The boundary is where OUR ANSWER
|
|
1654
|
+
// LANDED — `pause` — because that is the moment a board can be replaced; `sawPreCut` is the proof it was
|
|
1655
|
+
// not, a screen from before that boundary coming back. `filmStart` is the committed one: once a film has
|
|
1656
|
+
// been cut, the footage before the cut is gone for good and a later repeat cannot resurrect it.
|
|
1657
|
+
let filmStart = 0;
|
|
1658
|
+
let filmT0 = t0;
|
|
1659
|
+
let cutAt = 0;
|
|
1660
|
+
let cutT0 = t0;
|
|
1661
|
+
let sawPreCut = false;
|
|
1662
|
+
// A cycle closed among screens first seen SINCE the cut: the board on screen now repeats, and it is not
|
|
1663
|
+
// repeating anything the film already held. That, and only that, is evidence of a replacement — a board
|
|
1664
|
+
// that never repeats a screen at all cannot produce the proof either way, and is not a new board.
|
|
1665
|
+
let segCycled = false;
|
|
1666
|
+
const firstSeen = new Map();
|
|
1168
1667
|
let lastFrame = null;
|
|
1169
1668
|
let cycleClosed = false;
|
|
1170
1669
|
let stopped = false;
|
|
1171
1670
|
let runToEnd = false;
|
|
1671
|
+
let paused = false;
|
|
1672
|
+
let hangAt = hangDeadline;
|
|
1673
|
+
// Names are contiguous across a pause and across a dropped frame, because the slicer sorts by name and
|
|
1674
|
+
// spaces what it finds evenly. A gap in the numbering would tell it the board held a screen it never held.
|
|
1675
|
+
let seq = 0;
|
|
1172
1676
|
const loop = (async () => {
|
|
1173
1677
|
for (let i = 0; elapsed() < ceilingMs && !stopped; i++) {
|
|
1174
|
-
if (
|
|
1678
|
+
if (paused) {
|
|
1679
|
+
// The camera stays alive but films nothing: the next frames would be OUR answer landing, and a
|
|
1680
|
+
// board carrying our own clicks is not a screen the board ever showed on its own.
|
|
1681
|
+
await delay(intervalMs);
|
|
1682
|
+
nextAt = Date.now();
|
|
1683
|
+
hangAt = Date.now() + burstHangDeadlineMs(cfg);
|
|
1684
|
+
continue;
|
|
1685
|
+
}
|
|
1686
|
+
if (Date.now() > hangAt) {
|
|
1175
1687
|
console.warn(`[animated] the recording stalled: ${captured} frames in ${burstHangDeadlineMs(cfg)}ms — the widget is not screenshotting`);
|
|
1176
1688
|
break;
|
|
1177
1689
|
}
|
|
1178
|
-
const frame = path.join(dir, `frame_${String(
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
captured++;
|
|
1182
|
-
lastFrame = frame;
|
|
1690
|
+
const frame = path.join(dir, `frame_${String(seq).padStart(4, '0')}.png`); // zero-padded: the slicer sorts by name
|
|
1691
|
+
// A frame taken while the board still wears our last gesture is not a screen it showed by itself.
|
|
1692
|
+
if (Date.now() - this.lastInputAt >= exports.INPUT_SETTLE_MS)
|
|
1183
1693
|
try {
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1694
|
+
await this.shot(captchaElement, frame, cfg.elementScreenshotTimeoutMs ?? 8000, 'allow');
|
|
1695
|
+
captured++;
|
|
1696
|
+
seq++;
|
|
1697
|
+
lastFrame = frame;
|
|
1698
|
+
try {
|
|
1699
|
+
const d = sha1(frame);
|
|
1700
|
+
if (d !== lastDigest) {
|
|
1701
|
+
lastChangeMs = elapsed();
|
|
1702
|
+
const seen = firstSeen.get(d);
|
|
1703
|
+
if (seen === undefined) {
|
|
1704
|
+
firstSeen.set(d, seq - 1);
|
|
1705
|
+
order.push(d);
|
|
1706
|
+
lastNewAt = Date.now();
|
|
1707
|
+
lastNewMs = elapsed();
|
|
1708
|
+
}
|
|
1709
|
+
else {
|
|
1710
|
+
if (order.length >= 2)
|
|
1711
|
+
cycleClosed = true;
|
|
1712
|
+
if (seen >= cutAt)
|
|
1713
|
+
segCycled = true;
|
|
1714
|
+
else if (seen >= filmStart)
|
|
1715
|
+
sawPreCut = true;
|
|
1716
|
+
}
|
|
1717
|
+
lastDigest = d;
|
|
1192
1718
|
}
|
|
1193
|
-
lastDigest = d;
|
|
1194
1719
|
}
|
|
1720
|
+
catch { /* no digest, no early stop */ }
|
|
1195
1721
|
}
|
|
1196
|
-
catch { /*
|
|
1197
|
-
|
|
1198
|
-
|
|
1722
|
+
catch { /* a dropped frame costs a sample, not the recording */ }
|
|
1723
|
+
// A continuous film reaches these same conditions and keeps rolling: they are what makes a SNAPSHOT
|
|
1724
|
+
// ready to slice, not what makes the recording over. `settledOrCycled()` reads them without stopping.
|
|
1199
1725
|
const elapsedMs = elapsed();
|
|
1200
|
-
if (runToEnd && elapsedMs >= floorMs &&
|
|
1726
|
+
if (!continuous && runToEnd && elapsedMs >= floorMs && Date.now() - lastNewAt >= floorMs) {
|
|
1201
1727
|
console.log(`[animated] no new screen for ${(floorMs / 1000).toFixed(1)}s (${order.length} seen) — the board has settled; stopping the burst`);
|
|
1202
1728
|
break;
|
|
1203
1729
|
}
|
|
1204
|
-
if (runToEnd && cycleClosed && elapsedMs >= floorMs) {
|
|
1730
|
+
if (!continuous && runToEnd && cycleClosed && elapsedMs >= floorMs) {
|
|
1205
1731
|
console.log(`[animated] cycle closed after ${(elapsedMs / 1000).toFixed(1)}s (${order.length} screens); stopping the burst`);
|
|
1206
1732
|
break;
|
|
1207
1733
|
}
|
|
1208
1734
|
// Sleep to a fixed grid, not `interval - work`: per-frame overshoot would otherwise accumulate and a
|
|
1209
1735
|
// loaded runner films fewer frames than the floor window holds. A stalled frame skips, not bunches.
|
|
1736
|
+
// End on the frame that would land past the ceiling rather than declining to sleep for it: declining
|
|
1737
|
+
// left the loop spinning at whatever rate the screenshots came back, filming junk into the tail of
|
|
1738
|
+
// the clip (measured on the python twin: 2487 frames in a 150ms window).
|
|
1210
1739
|
nextAt = Math.max(nextAt + intervalMs, Date.now());
|
|
1740
|
+
if (nextAt - t0 >= ceilingMs)
|
|
1741
|
+
break;
|
|
1211
1742
|
const wait = nextAt - Date.now();
|
|
1212
|
-
if (wait > 0
|
|
1743
|
+
if (wait > 0)
|
|
1213
1744
|
await delay(wait);
|
|
1214
1745
|
}
|
|
1215
1746
|
})();
|
|
1216
1747
|
let ended = false;
|
|
1217
|
-
|
|
1748
|
+
let endedAtMs = 0;
|
|
1749
|
+
loop.then(() => { ended = true; endedAtMs = elapsed(); }, () => { ended = true; endedAtMs = elapsed(); });
|
|
1218
1750
|
return {
|
|
1219
1751
|
moved: () => cycleClosed,
|
|
1220
1752
|
screensSeen: () => order.length,
|
|
@@ -1246,7 +1778,7 @@ class CaptchaKrakenSolver {
|
|
|
1246
1778
|
while (!cycleClosed && !ended && !stopped) {
|
|
1247
1779
|
const elapsedMs = elapsed();
|
|
1248
1780
|
if (elapsedMs >= floorMs) {
|
|
1249
|
-
if (
|
|
1781
|
+
if (Date.now() - lastNewAt >= floorMs) {
|
|
1250
1782
|
why = 'no new screen for a full floor window — it moved once and settled';
|
|
1251
1783
|
break;
|
|
1252
1784
|
}
|
|
@@ -1258,12 +1790,134 @@ class CaptchaKrakenSolver {
|
|
|
1258
1790
|
}
|
|
1259
1791
|
await delay(intervalMs);
|
|
1260
1792
|
}
|
|
1793
|
+
// A RECORDING THAT HAS ENDED STILL ANSWERS THE QUESTION, by the same rule and off its own clock
|
|
1794
|
+
// rather than the wall's. The loop above only reads a film that is still rolling, so an ask that
|
|
1795
|
+
// outlived the recording turned every finished film into "still" — and a board that cycles, filmed
|
|
1796
|
+
// for its whole window, was then answered from one screen, clicked, and refused.
|
|
1797
|
+
if (!cycleClosed && !animating && ended && order.length > BURST_ANIMATED_SCREENS
|
|
1798
|
+
&& endedAtMs - lastNewMs < floorMs) {
|
|
1799
|
+
animating = true;
|
|
1800
|
+
why = `${order.length} screens and still arriving when the recording ended`;
|
|
1801
|
+
}
|
|
1261
1802
|
const moved = cycleClosed || animating;
|
|
1262
1803
|
if (!moved && (ended || stopped))
|
|
1263
1804
|
why = ended ? 'the recording ended' : 'abandoned';
|
|
1264
1805
|
console.log(`[animated] burst verdict after ${(elapsed() / 1000).toFixed(1)}s: ${moved ? 'ANIMATED' : 'still'} (${order.length} screens; ${why})`);
|
|
1265
1806
|
return moved;
|
|
1266
1807
|
},
|
|
1808
|
+
/**
|
|
1809
|
+
* Resolve once the film holds something worth slicing: a closed cycle, or a board that has shown
|
|
1810
|
+
* nothing new for a floor window. The same two conditions `finish()` stops on — read, not acted on,
|
|
1811
|
+
* so a continuous film can be sliced repeatedly without ever being ended.
|
|
1812
|
+
*/
|
|
1813
|
+
settledOrCycled: async () => {
|
|
1814
|
+
runToEnd = true;
|
|
1815
|
+
// HOW LONG TO WAIT BEFORE SLICING, which is NOT how long the camera may run. Those are different
|
|
1816
|
+
// quantities and conflating them cost a board 121.9s against a 20s gate ceiling: a film that never
|
|
1817
|
+
// cycles and never settles held the first slice until the CAMERA's ceiling. The wait keeps the
|
|
1818
|
+
// burst ceiling it always had; the camera keeps running afterwards regardless.
|
|
1819
|
+
const waitUntil = Math.max(floorMs, cfg.videoBurstMaxMs ?? 12000);
|
|
1820
|
+
// Nothing has been answered on this board yet, so there is a cycle to go and find.
|
|
1821
|
+
const firstSlice = cutAt === filmStart;
|
|
1822
|
+
while (!ended && !stopped) {
|
|
1823
|
+
// A screen from before our answer came back: the board was NOT replaced, so the film already in
|
|
1824
|
+
// hand describes the board that is on screen and there is nothing left to wait for. This is the
|
|
1825
|
+
// common case and it costs a frame, not a window.
|
|
1826
|
+
if (sawPreCut)
|
|
1827
|
+
return;
|
|
1828
|
+
const segMs = Date.now() - cutT0;
|
|
1829
|
+
if (segMs >= floorMs) {
|
|
1830
|
+
if (segCycled)
|
|
1831
|
+
return;
|
|
1832
|
+
if (Date.now() - lastNewAt >= floorMs)
|
|
1833
|
+
return;
|
|
1834
|
+
// A board that never repeated a screen BEFORE our answer will not start now: there is no cycle
|
|
1835
|
+
// to wait for and no replacement it could prove, so the film in hand is everything there is to
|
|
1836
|
+
// know and waiting out the ceiling every round is pure cost. Measured: one such board took
|
|
1837
|
+
// 110.3s that way, against a 49s gate ceiling.
|
|
1838
|
+
if (!firstSlice && !cycleClosed)
|
|
1839
|
+
return;
|
|
1840
|
+
}
|
|
1841
|
+
if (segMs >= waitUntil) {
|
|
1842
|
+
console.log(`[animated] no cycle and no settle in ${(segMs / 1000).toFixed(1)}s — slicing what the film holds`);
|
|
1843
|
+
return;
|
|
1844
|
+
}
|
|
1845
|
+
await delay(intervalMs);
|
|
1846
|
+
}
|
|
1847
|
+
},
|
|
1848
|
+
/**
|
|
1849
|
+
* Our answer is about to land, so this frame is the boundary: everything after it is whatever board
|
|
1850
|
+
* the vendor leaves behind, which may not be the one filmed up to here. Only a screen from before the
|
|
1851
|
+
* boundary coming back proves it is still the same board — see `snapshot`.
|
|
1852
|
+
*/
|
|
1853
|
+
pause: () => {
|
|
1854
|
+
paused = true;
|
|
1855
|
+
cutAt = seq;
|
|
1856
|
+
sawPreCut = false;
|
|
1857
|
+
segCycled = false;
|
|
1858
|
+
},
|
|
1859
|
+
resume: () => {
|
|
1860
|
+
paused = false;
|
|
1861
|
+
nextAt = Date.now();
|
|
1862
|
+
cutT0 = Date.now();
|
|
1863
|
+
lastNewAt = Date.now();
|
|
1864
|
+
hangAt = Date.now() + burstHangDeadlineMs(cfg);
|
|
1865
|
+
},
|
|
1866
|
+
/**
|
|
1867
|
+
* A stable copy of everything filmed so far, WITHOUT ending the recording.
|
|
1868
|
+
*
|
|
1869
|
+
* Hardlinked rather than copied: the slicer wants a directory it can sort by name and read to the end,
|
|
1870
|
+
* and handing it the live one would have it read a directory being written underneath it — a frame
|
|
1871
|
+
* half-flushed reads as a corrupt PNG, and a frame arriving mid-sort shifts every index after it.
|
|
1872
|
+
* Links are near-free on the same filesystem, which is why this can be afforded once a round.
|
|
1873
|
+
*/
|
|
1874
|
+
snapshot: async () => {
|
|
1875
|
+
if (cfg.videoSolveEnabled !== false && !this.videoBudgetGranted) {
|
|
1876
|
+
this.videoBudgetGranted = true;
|
|
1877
|
+
this.videoBudgetMs = (cfg.videoBurstMaxMs ?? 12000) + (cfg.keyframeWaitTimeoutMs ?? exports.SOLVE_DEFAULTS.keyframeWaitTimeoutMs) + (cfg.videoExtraInferenceMs ?? 8000);
|
|
1878
|
+
}
|
|
1879
|
+
const names = fs.readdirSync(dir).filter((f) => f.endsWith('.png')).sort();
|
|
1880
|
+
if (!names.length) {
|
|
1881
|
+
const e = new Error('ANIMATED_CHALLENGE: could not record the animated challenge (no frame screenshotted).');
|
|
1882
|
+
e.animated = true;
|
|
1883
|
+
// Not a verdict about the board — a still photographs fine. A widget that would not screenshot for a
|
|
1884
|
+
// whole window is usually closing because the answer was accepted, so the loop asks before it counts
|
|
1885
|
+
// the round. A board proven animated keeps that verdict, and is filmed again rather than read as a still.
|
|
1886
|
+
throw e;
|
|
1887
|
+
}
|
|
1888
|
+
const out = fs.mkdtempSync(path.join(os.tmpdir(), 'ck_slice_'));
|
|
1889
|
+
// ONE FILM, ONE BOARD. Frames from a board the vendor has already replaced name screens that no
|
|
1890
|
+
// longer exist: the model answers with one, the widget never shows it, and the click waits out
|
|
1891
|
+
// `keyframeWaitTimeoutMs` for a picture that is gone. So a film whose board was replaced is read
|
|
1892
|
+
// from the cut; one whose screens kept coming back is read whole, which is the point of filming on.
|
|
1893
|
+
// The LAST name can be the frame currently being written. Dropping it costs one sample and removes
|
|
1894
|
+
// the only torn read this can have.
|
|
1895
|
+
// Cut ONLY on proof of a replacement: this board repeats, and nothing it repeats was in the film.
|
|
1896
|
+
// Defaulting the other way threw away the whole film on every board that never repeats a screen —
|
|
1897
|
+
// exactly the boards a film running the whole solve exists to accumulate.
|
|
1898
|
+
let from = (!sawPreCut && segCycled) ? cutAt : filmStart;
|
|
1899
|
+
if (names.length - 1 - from < 1)
|
|
1900
|
+
from = filmStart;
|
|
1901
|
+
// Committed: a board that was replaced stays replaced, so a repeat two rounds later cannot pull its
|
|
1902
|
+
// screens back into the film.
|
|
1903
|
+
if (from !== filmStart) {
|
|
1904
|
+
filmStart = from;
|
|
1905
|
+
filmT0 = cutT0;
|
|
1906
|
+
}
|
|
1907
|
+
for (const n of names.slice(from, -1)) {
|
|
1908
|
+
try {
|
|
1909
|
+
fs.linkSync(path.join(dir, n), path.join(out, n));
|
|
1910
|
+
}
|
|
1911
|
+
catch {
|
|
1912
|
+
fs.copyFileSync(path.join(dir, n), path.join(out, n));
|
|
1913
|
+
}
|
|
1914
|
+
}
|
|
1915
|
+
const burstMs = Math.max(1, Date.now() - filmT0);
|
|
1916
|
+
this.lastBurstFps = (names.length - 1 - from) / (burstMs / 1000);
|
|
1917
|
+
const animating = order.length > BURST_ANIMATED_SCREENS && Date.now() - lastNewAt < floorMs;
|
|
1918
|
+
console.log(`[animated] sliced ${names.length - 1 - from} of ${captured} frames filmed over ${(burstMs / 1000).toFixed(1)}s (${order.length} screens)${from ? ' since the board was replaced' : ''} -> ${out}`);
|
|
1919
|
+
return { dir: out, moved: cycleClosed || sawPreCut || animating };
|
|
1920
|
+
},
|
|
1267
1921
|
abandon: async () => {
|
|
1268
1922
|
stopped = true;
|
|
1269
1923
|
try {
|
|
@@ -1287,12 +1941,16 @@ class CaptchaKrakenSolver {
|
|
|
1287
1941
|
rmdir(dir);
|
|
1288
1942
|
const e = new Error('ANIMATED_CHALLENGE: could not record the animated challenge (no frame screenshotted).');
|
|
1289
1943
|
e.animated = true;
|
|
1944
|
+
// Not a verdict about the board — a still photographs fine. A widget that would not screenshot for a
|
|
1945
|
+
// whole window is usually closing because the answer was accepted, so the loop asks before it counts
|
|
1946
|
+
// the round. A board proven animated keeps that verdict, and is filmed again rather than read as a still.
|
|
1290
1947
|
throw e;
|
|
1291
1948
|
}
|
|
1292
1949
|
const burstMs = Math.max(1, elapsed());
|
|
1293
1950
|
this.lastBurstFps = captured / (burstMs / 1000);
|
|
1951
|
+
const animating = order.length > BURST_ANIMATED_SCREENS && Date.now() - lastNewAt < floorMs;
|
|
1294
1952
|
console.log(`[animated] recorded ${captured} frames in ${(burstMs / 1000).toFixed(1)}s (${this.lastBurstFps.toFixed(1)}fps) -> ${dir}`);
|
|
1295
|
-
return dir;
|
|
1953
|
+
return { dir, moved: cycleClosed || animating };
|
|
1296
1954
|
},
|
|
1297
1955
|
};
|
|
1298
1956
|
}
|
|
@@ -1304,7 +1962,7 @@ class CaptchaKrakenSolver {
|
|
|
1304
1962
|
const args = ['-m', 'captchakraken.cli', 'solve-animated', '--frames-dir', framesDir,
|
|
1305
1963
|
'--fps', String(this.lastBurstFps ?? this.config.videoBurstFps ?? 10), ...(m ? ['--model', m] : [])];
|
|
1306
1964
|
try {
|
|
1307
|
-
const { stdout, stderr } = await execFileAsync(py, args, { cwd: cliRoot, env: this.solveEnvironment(cliRoot, apiKey), maxBuffer: 10 * 1024 * 1024 });
|
|
1965
|
+
const { stdout, stderr } = await execFileAsync(py, args, { cwd: cliRoot, env: this.solveEnvironment(cliRoot, apiKey), maxBuffer: 10 * 1024 * 1024, timeout: this.askTimeoutMs(), killSignal: 'SIGKILL' });
|
|
1308
1966
|
if (stderr)
|
|
1309
1967
|
console.error('CaptchaKraken CLI stderr:', stderr);
|
|
1310
1968
|
const parsed = JSON.parse(stdout.trim());
|
|
@@ -1319,9 +1977,9 @@ class CaptchaKrakenSolver {
|
|
|
1319
1977
|
throw this.cliError(error, 'Cannot solve this animated captcha', 'Failed to execute the animated captcha solver');
|
|
1320
1978
|
}
|
|
1321
1979
|
}
|
|
1322
|
-
/** The
|
|
1980
|
+
/** The routing values and the credential travel in the environment, never in argv. */
|
|
1323
1981
|
solveEnvironment(cliRoot, apiKey) {
|
|
1324
|
-
return (0, cli_invocation_1.solveEnv)(cliEnv(cliRoot, this.
|
|
1982
|
+
return (0, cli_invocation_1.solveEnv)(cliEnv(cliRoot, this.routingEnv()), apiKey, this.resampleLevel);
|
|
1325
1983
|
}
|
|
1326
1984
|
/** Exit 2 is an unsupported puzzle, exit 3 the hosted API's own refusal; both are relayed, not reworded. */
|
|
1327
1985
|
cliError(error, unsupportedMessage, failMessage) {
|
|
@@ -1391,6 +2049,7 @@ class CaptchaKrakenSolver {
|
|
|
1391
2049
|
if (polls >= NOT_THIS_BOARD_POLLS && best > NOT_THIS_BOARD_DIFF) {
|
|
1392
2050
|
console.log(`[animated] the widget no longer resembles the recorded board (best diff=${best.toFixed(4)} over ${polls} polls); not waiting out the budget`);
|
|
1393
2051
|
this.discardAnimatedPlan();
|
|
2052
|
+
await this.stopAnimatedFilm();
|
|
1394
2053
|
return false;
|
|
1395
2054
|
}
|
|
1396
2055
|
}
|
|
@@ -1402,12 +2061,15 @@ class CaptchaKrakenSolver {
|
|
|
1402
2061
|
unlink(probe);
|
|
1403
2062
|
}
|
|
1404
2063
|
this.discardAnimatedPlan();
|
|
2064
|
+
await this.stopAnimatedFilm();
|
|
1405
2065
|
console.log(`[animated] widget never matched the chosen keyframe within ${timeout}ms (closest diff=${best.toFixed(4)}); clicking on the model's coordinates anyway, and recording afresh next round`);
|
|
1406
2066
|
return false;
|
|
1407
2067
|
}
|
|
1408
2068
|
resetSolveState() {
|
|
1409
2069
|
this.solutionCache.clear();
|
|
1410
2070
|
this.repeatedAnswerSeen = false;
|
|
2071
|
+
this.knownAnimated = false;
|
|
2072
|
+
this.animatedProbeDone = false;
|
|
1411
2073
|
this.discardAnimatedPlan();
|
|
1412
2074
|
this.lastSubmitFrameHash = null;
|
|
1413
2075
|
this.keyframeMode = null;
|
|
@@ -1430,13 +2092,21 @@ class CaptchaKrakenSolver {
|
|
|
1430
2092
|
return null;
|
|
1431
2093
|
}
|
|
1432
2094
|
}
|
|
2095
|
+
/** The recording still stands; dropping only the refused answer makes the retry an inference, not a burst. */
|
|
2096
|
+
invalidateAnimatedAnswer() {
|
|
2097
|
+
const plan = this.animatedPlan;
|
|
2098
|
+
if (plan?.response)
|
|
2099
|
+
this.animatedPlan = { burstDir: plan.burstDir, response: null };
|
|
2100
|
+
}
|
|
1433
2101
|
/** True when this answer already ran and changed nothing: resample, and let the recording path have a go. */
|
|
1434
2102
|
noteAnswer(actions, retryMode) {
|
|
1435
2103
|
const sig = CaptchaKrakenSolver.answerSignature(actions, retryMode);
|
|
1436
2104
|
if (sig !== null && sig === this.lastAnswerSig) {
|
|
1437
2105
|
this.noProgressRounds++;
|
|
1438
|
-
console.log(`[no-progress] the model returned the same answer again (${this.noProgressRounds}
|
|
2106
|
+
console.log(`[no-progress] the model returned the same answer again (${this.noProgressRounds} in a row) — the previous one already ran and changed nothing`);
|
|
1439
2107
|
this.resampleLevel++;
|
|
2108
|
+
// A raised sample never reaches the wire while the cached animated answer stands in front of it.
|
|
2109
|
+
this.invalidateAnimatedAnswer();
|
|
1440
2110
|
this.repeatedAnswerSeen = true;
|
|
1441
2111
|
return true;
|
|
1442
2112
|
}
|
|
@@ -1456,12 +2126,30 @@ class CaptchaKrakenSolver {
|
|
|
1456
2126
|
this.solutionCache.set(cacheKey, fresh);
|
|
1457
2127
|
return fresh;
|
|
1458
2128
|
}
|
|
2129
|
+
/**
|
|
2130
|
+
* Drop the ANSWER, keep the camera.
|
|
2131
|
+
*
|
|
2132
|
+
* The two are deliberately separable. An answer the widget refused is spent — reusing it just presses the
|
|
2133
|
+
* same wrong thing until the no-progress fence trips, three rounds in, with half the budget unspent. The
|
|
2134
|
+
* film is the opposite: every second it keeps running makes the next slice a better question. So a
|
|
2135
|
+
* refusal discards this and leaves `animatedFilm` alone; only a board that is GONE stops the camera.
|
|
2136
|
+
*/
|
|
1459
2137
|
discardAnimatedPlan() {
|
|
1460
2138
|
const dir = this.animatedPlan?.burstDir;
|
|
1461
2139
|
this.animatedPlan = null;
|
|
1462
2140
|
if (dir && fs.existsSync(dir))
|
|
1463
2141
|
rmdir(dir);
|
|
1464
2142
|
}
|
|
2143
|
+
/** The board is gone: its film can never describe the next one, so end it and free the frames. */
|
|
2144
|
+
async stopAnimatedFilm() {
|
|
2145
|
+
const film = this.animatedFilm;
|
|
2146
|
+
this.animatedFilm = null;
|
|
2147
|
+
// The plan's slice goes too. This runs from the solve's `finally`, so on a thrown solve it is the only
|
|
2148
|
+
// thing that will, and a slice directory left behind is frames on what is a tmpfs on plenty of boxes.
|
|
2149
|
+
this.discardAnimatedPlan();
|
|
2150
|
+
if (film)
|
|
2151
|
+
await film.abandon();
|
|
2152
|
+
}
|
|
1465
2153
|
/** Static or animated, decided by filming once. The film stays running for the branch that uses it. */
|
|
1466
2154
|
async classifyByRecording(el) {
|
|
1467
2155
|
// Only on an untouched board: after a click the film cannot tell the board's own motion from our feedback.
|
|
@@ -1493,8 +2181,10 @@ class CaptchaKrakenSolver {
|
|
|
1493
2181
|
return false;
|
|
1494
2182
|
return true;
|
|
1495
2183
|
}
|
|
2184
|
+
/** One second look per solve: a repeated answer arms it, and the recording it takes spends it. */
|
|
1496
2185
|
shouldRetryAsAnimated(puzzleSource) {
|
|
1497
|
-
return this.repeatedAnswerSeen &&
|
|
2186
|
+
return this.repeatedAnswerSeen && !this.animatedProbeDone
|
|
2187
|
+
&& puzzleSource !== kinds_1.Vendor.RECAPTCHA && this.config.videoSolveEnabled !== false;
|
|
1498
2188
|
}
|
|
1499
2189
|
async getSolution(imagePath, puzzleSource = kinds_1.Vendor.UNKNOWN, retryMode = null, textMode = false) {
|
|
1500
2190
|
let cacheKey = null;
|
|
@@ -1514,7 +2204,7 @@ class CaptchaKrakenSolver {
|
|
|
1514
2204
|
const args = (0, cli_invocation_1.buildSolveArgs)({ imagePath, model: this.modelName(cliRoot), puzzleSource, retryMode, textMode, expert: this.config.expert });
|
|
1515
2205
|
console.log(`Executing CaptchaKraken CLI: ${(0, cli_invocation_1.redactCommand)([py, ...args].join(' '), apiKey)}`);
|
|
1516
2206
|
try {
|
|
1517
|
-
const { stdout, stderr } = await execFileAsync(py, args, { cwd: cliRoot, env: this.solveEnvironment(cliRoot, apiKey), maxBuffer: 10 * 1024 * 1024 });
|
|
2207
|
+
const { stdout, stderr } = await execFileAsync(py, args, { cwd: cliRoot, env: this.solveEnvironment(cliRoot, apiKey), maxBuffer: 10 * 1024 * 1024, timeout: this.askTimeoutMs(), killSignal: 'SIGKILL' });
|
|
1518
2208
|
console.log('CaptchaKraken CLI stdout:', stdout);
|
|
1519
2209
|
if (stderr)
|
|
1520
2210
|
console.error('CaptchaKraken CLI stderr:', stderr);
|
|
@@ -1560,11 +2250,15 @@ class CaptchaKrakenSolver {
|
|
|
1560
2250
|
haveAnchor = true;
|
|
1561
2251
|
}
|
|
1562
2252
|
catch { /* no anchor, no movement check */ }
|
|
2253
|
+
// The idle wander hovers the board while the model reads it, and a board reacting to our pointer has not moved
|
|
2254
|
+
// by itself: once we have touched it since the anchor, a change is no evidence that it cycles.
|
|
2255
|
+
const anchoredAt = Date.now();
|
|
2256
|
+
const untouched = () => this.lastInputAt < anchoredAt;
|
|
1563
2257
|
let response = await runQuery(currentPath);
|
|
1564
2258
|
mergedUsage.push(...response.token_usage);
|
|
1565
2259
|
if (!enabled)
|
|
1566
2260
|
return response;
|
|
1567
|
-
if (!this.actedOnBoard && !this.repeatedAnswerSeen && haveAnchor
|
|
2261
|
+
if (!this.actedOnBoard && !this.repeatedAnswerSeen && haveAnchor && untouched()
|
|
1568
2262
|
&& await this.captchaFrameChangedSince(captchaElement, liveAnchor, MOVED_DURING_INFERENCE_DIFF, 'allow')) {
|
|
1569
2263
|
this.repeatedAnswerSeen = true;
|
|
1570
2264
|
console.log('[freshness] the widget moved while the model was reading it, with nothing clicked — recording it rather than answering another still.');
|
|
@@ -1576,7 +2270,7 @@ class CaptchaKrakenSolver {
|
|
|
1576
2270
|
for (let i = 0; i < maxReSolves; i++) {
|
|
1577
2271
|
if (!(await this.captchaFrameChangedSince(captchaElement, currentPath, threshold)))
|
|
1578
2272
|
break;
|
|
1579
|
-
if (++changedDuringInference >= 2 && !this.actedOnBoard) {
|
|
2273
|
+
if (++changedDuringInference >= 2 && !this.actedOnBoard && untouched()) {
|
|
1580
2274
|
this.repeatedAnswerSeen = true;
|
|
1581
2275
|
console.log('[freshness] the frame changed twice during inference with nothing clicked — this board cycles; recording it rather than re-solving a screen that has gone.');
|
|
1582
2276
|
return { actions: response.actions, token_usage: mergedUsage };
|
|
@@ -1753,10 +2447,10 @@ class CaptchaKrakenSolver {
|
|
|
1753
2447
|
}
|
|
1754
2448
|
async moveAndClick(page, element) {
|
|
1755
2449
|
await this.move(page, element);
|
|
1756
|
-
await this.
|
|
2450
|
+
await this.gesture(() => this.human.click(page, this.human.at));
|
|
1757
2451
|
}
|
|
1758
2452
|
async performSmoothMove(page, x, y) {
|
|
1759
|
-
await this.
|
|
2453
|
+
await this.gesture(() => this.human.move(page, [x, y]));
|
|
1760
2454
|
}
|
|
1761
2455
|
/** Element-relative click point: a random spot inside the box, inset 10% off its border. */
|
|
1762
2456
|
clickPointFor(action, elementBox) {
|
|
@@ -1778,10 +2472,10 @@ class CaptchaKrakenSolver {
|
|
|
1778
2472
|
return;
|
|
1779
2473
|
}
|
|
1780
2474
|
const at = [elementBox.x + rel[0], elementBox.y + rel[1]];
|
|
1781
|
-
await this.
|
|
2475
|
+
await this.gesture(() => this.human.move(page, at));
|
|
1782
2476
|
await this.waitForKeyframe(element, awaitKeyframe, rel[0] / elementBox.width, rel[1] / elementBox.height);
|
|
1783
2477
|
this.actedOnBoard = true;
|
|
1784
|
-
await this.
|
|
2478
|
+
await this.gesture(() => this.human.click(page, at));
|
|
1785
2479
|
}
|
|
1786
2480
|
async executeClick(page, _element, action, elementBox) {
|
|
1787
2481
|
this.actedOnBoard = true;
|
|
@@ -1790,12 +2484,12 @@ class CaptchaKrakenSolver {
|
|
|
1790
2484
|
console.warn('Click action received without coordinates or bounding box', action);
|
|
1791
2485
|
return;
|
|
1792
2486
|
}
|
|
1793
|
-
await this.
|
|
2487
|
+
await this.gesture(() => this.human.click(page, [elementBox.x + rel[0], elementBox.y + rel[1]]));
|
|
1794
2488
|
}
|
|
1795
2489
|
async executeDrag(page, _element, action, elementBox) {
|
|
1796
2490
|
this.actedOnBoard = true;
|
|
1797
2491
|
const center = (bbox) => [elementBox.x + ((bbox[0] + bbox[2]) / 2) * elementBox.width, elementBox.y + ((bbox[1] + bbox[3]) / 2) * elementBox.height];
|
|
1798
|
-
await this.
|
|
2492
|
+
await this.gesture(() => this.human.drag(page, center(action.source_bounding_box), center(action.target_bounding_box)));
|
|
1799
2493
|
}
|
|
1800
2494
|
/**
|
|
1801
2495
|
* The slider piece's box: the first visible match small enough to be a piece. Runs during detection, so it must
|
|
@@ -1830,7 +2524,9 @@ class CaptchaKrakenSolver {
|
|
|
1830
2524
|
return false;
|
|
1831
2525
|
}
|
|
1832
2526
|
await this.moveAndClick(page, field);
|
|
1833
|
-
|
|
2527
|
+
const typed = await this.human.typeText(page, field, text);
|
|
2528
|
+
this.lastInputAt = Date.now();
|
|
2529
|
+
if (!typed)
|
|
1834
2530
|
return false;
|
|
1835
2531
|
console.log(`Typed ${text.length} character(s) into the captcha field.`);
|
|
1836
2532
|
return true;
|
|
@@ -1865,7 +2561,7 @@ class CaptchaKrakenSolver {
|
|
|
1865
2561
|
}
|
|
1866
2562
|
const targetY = ((tb[1] + tb[3]) / 2) * elementBox.height;
|
|
1867
2563
|
console.log('No slider track; dragging the piece to the slot directly.');
|
|
1868
|
-
await this.human.drag(page, [box.x + box.width / 2, box.y + box.height / 2], [elementBox.x + targetX, elementBox.y + targetY]);
|
|
2564
|
+
await this.gesture(() => this.human.drag(page, [box.x + box.width / 2, box.y + box.height / 2], [elementBox.x + targetX, elementBox.y + targetY]));
|
|
1869
2565
|
return true;
|
|
1870
2566
|
}
|
|
1871
2567
|
const hbox = await handle.boundingBox();
|
|
@@ -1938,6 +2634,7 @@ class CaptchaKrakenSolver {
|
|
|
1938
2634
|
await this.human.release(page);
|
|
1939
2635
|
}
|
|
1940
2636
|
catch { /* the page may have navigated */ }
|
|
2637
|
+
this.lastInputAt = Date.now();
|
|
1941
2638
|
for (const s of shots)
|
|
1942
2639
|
unlink(s);
|
|
1943
2640
|
}
|