captchakraken 2.3.0 → 2.6.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/LICENSE +47 -7
- package/README.md +24 -4
- package/dist/cli-invocation.d.ts +46 -0
- package/dist/cli-invocation.js +68 -0
- package/dist/errors.d.ts +45 -0
- package/dist/errors.js +74 -0
- package/dist/index.d.ts +50 -0
- package/dist/index.js +52 -1
- package/dist/limits.d.ts +51 -0
- package/dist/limits.js +54 -0
- package/dist/model-name.d.ts +23 -0
- package/dist/model-name.js +70 -0
- package/dist/playwright-types.d.ts +28 -0
- package/dist/puppeteer-adapter.d.ts +11 -0
- package/dist/puppeteer-adapter.js +23 -0
- package/dist/python-command.d.ts +50 -0
- package/dist/python-command.js +60 -0
- package/dist/slide-geometry.d.ts +33 -0
- package/dist/slide-geometry.js +64 -0
- package/dist/solver.d.ts +214 -12
- package/dist/solver.js +1297 -165
- package/dist/trajectory.d.ts +31 -0
- package/dist/trajectory.js +162 -0
- package/dist/types.d.ts +128 -11
- package/dist/watcher.d.ts +99 -0
- package/dist/watcher.js +129 -0
- package/package.json +2 -5
- package/python/LICENSE +47 -7
- package/python/README.md +25 -8
- package/python/examples/README.md +36 -0
- package/python/examples/_harness.py +182 -94
- package/python/examples/demoHcaptcha.py +2 -1
- package/python/examples/demoRecaptcha.py +2 -1
- package/python/examples/watch_playwright.py +56 -0
- package/python/examples/with_playwright.py +43 -0
- package/python/pyproject.toml +4 -3
- package/python/src/captchakraken/__init__.py +5 -3
- package/python/src/captchakraken/action_types.py +24 -3
- package/python/src/captchakraken/cli.py +224 -2
- package/python/src/captchakraken/config.py +119 -17
- package/python/src/captchakraken/errors.py +195 -0
- package/python/src/captchakraken/keyframes.py +690 -0
- package/python/src/captchakraken/models.json +120 -0
- package/python/src/captchakraken/page_solver.py +1465 -102
- package/python/src/captchakraken/pinned_model.json +5 -7
- package/python/src/captchakraken/planner.py +328 -73
- package/python/src/captchakraken/prompts.py +485 -0
- package/python/src/captchakraken/solver.py +182 -11
- package/python/src/captchakraken/timing.py +70 -0
- package/python/src/captchakraken/tool_calls/find_grid.py +367 -52
- package/python/src/captchakraken/tool_calls/track_piece.py +96 -0
- package/python/src/captchakraken/trajectory.py +2 -7
- package/python/src/captchakraken/watcher.py +193 -0
- package/python/src/captchakraken/planner_types.py +0 -74
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.getBundledCliRoot = getBundledCliRoot;
|
|
7
|
+
exports.resolveLoraName = resolveLoraName;
|
|
8
|
+
/**
|
|
9
|
+
* Which served LoRA name this client asks for — the SAME answer the Python
|
|
10
|
+
* client's `config.lora_name()` gives, and for the same reasons.
|
|
11
|
+
*
|
|
12
|
+
* This has to be shared, not re-decided per port. The name is not just routing:
|
|
13
|
+
* the Python CLI feeds it to `prompts.resolve()`, which maps it through
|
|
14
|
+
* models.json to a PROMPT GENERATION. A port that picks a different name sends
|
|
15
|
+
* a different generation of prompts to the same weights, and nothing errors for
|
|
16
|
+
* any family both generations have a prompt for — it just answers worse. That
|
|
17
|
+
* is the failure models.json exists to prevent, and the JS port shipped it:
|
|
18
|
+
* a hardcoded `'captcha'` (CaptchaKraken_v1.1, generation 1) against the
|
|
19
|
+
* generation-2 adapter `latest` has named since v1.2.
|
|
20
|
+
*
|
|
21
|
+
* Precedence mirrors config.py exactly:
|
|
22
|
+
* 1. CAPTCHA_LORA_NAME — an explicit pin always wins, and pinning is opt-in.
|
|
23
|
+
* 2. models.json's `latest` entry, so the default model and the default
|
|
24
|
+
* prompt generation move forward together and cannot get out of step.
|
|
25
|
+
* 3. pinned_model.json, so a hand-edited or older manifest still resolves.
|
|
26
|
+
*/
|
|
27
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
28
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
29
|
+
/**
|
|
30
|
+
* Where the bundled Python engine lives — the copy of `models.json` this client
|
|
31
|
+
* actually answers from.
|
|
32
|
+
*
|
|
33
|
+
* Lives HERE rather than in solver.ts because the registry lookup below is its
|
|
34
|
+
* only remaining caller-visible use, and a second copy of this path logic is
|
|
35
|
+
* how the two ports drifted apart in the first place. solver.ts imports it.
|
|
36
|
+
*
|
|
37
|
+
* When installed from npm this file is in `<pkgRoot>/dist` (compiled) or
|
|
38
|
+
* `<pkgRoot>/src` (dev), and published packages bundle the engine at
|
|
39
|
+
* `<pkgRoot>/python` (copied in by scripts/copy-python.mjs at build time). In
|
|
40
|
+
* the source monorepo it instead lives at the sibling `../python`.
|
|
41
|
+
*/
|
|
42
|
+
function getBundledCliRoot() {
|
|
43
|
+
const bundled = node_path_1.default.resolve(__dirname, '..', 'python');
|
|
44
|
+
if (node_fs_1.default.existsSync(bundled))
|
|
45
|
+
return bundled;
|
|
46
|
+
return node_path_1.default.resolve(__dirname, '..', '..', 'python');
|
|
47
|
+
}
|
|
48
|
+
function readJson(cliRoot, name) {
|
|
49
|
+
return JSON.parse(node_fs_1.default.readFileSync(node_path_1.default.join(cliRoot, 'src', 'captchakraken', name), 'utf-8'));
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* `cliRoot` is optional so a caller that only wants to REPORT the name — a test
|
|
53
|
+
* harness recording which adapter it drove — can ask without reconstructing the
|
|
54
|
+
* engine's layout. Re-deriving it is the same mistake one level down.
|
|
55
|
+
*/
|
|
56
|
+
function resolveLoraName({ cliRoot = getBundledCliRoot(), env = process.env } = {}) {
|
|
57
|
+
if (env.CAPTCHA_LORA_NAME)
|
|
58
|
+
return env.CAPTCHA_LORA_NAME;
|
|
59
|
+
try {
|
|
60
|
+
const reg = readJson(cliRoot, 'models.json');
|
|
61
|
+
const name = reg?.models?.[reg?.latest]?.lora_name;
|
|
62
|
+
if (typeof name === 'string' && name)
|
|
63
|
+
return name;
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
// A missing or broken registry falls through to the pin, never throws —
|
|
67
|
+
// same contract as config.py's `_registry_default`.
|
|
68
|
+
}
|
|
69
|
+
return readJson(cliRoot, 'pinned_model.json').lora_name;
|
|
70
|
+
}
|
|
@@ -59,6 +59,13 @@ export interface PlaywrightElementHandle {
|
|
|
59
59
|
isVisible(): Promise<boolean>;
|
|
60
60
|
/** The element's text content, or null. */
|
|
61
61
|
textContent(): Promise<string | null>;
|
|
62
|
+
/**
|
|
63
|
+
* First matching element WITHIN this element's subtree, or null. The vendors
|
|
64
|
+
* that render into the host page rather than an iframe (GeeTest, Yidun,
|
|
65
|
+
* BotDetect, …) have no Frame to scope against, so the widget element is the
|
|
66
|
+
* boundary that keeps a generic selector off the rest of the document.
|
|
67
|
+
*/
|
|
68
|
+
$(selector: string): Promise<PlaywrightElementHandle | null>;
|
|
62
69
|
}
|
|
63
70
|
/**
|
|
64
71
|
* Structural subset of Playwright's `Frame` (an iframe's content document).
|
|
@@ -96,6 +103,19 @@ export interface PlaywrightPage {
|
|
|
96
103
|
clickCount?: number;
|
|
97
104
|
}): Promise<void>;
|
|
98
105
|
};
|
|
106
|
+
/**
|
|
107
|
+
* Key input. Used to type a distorted-text captcha's answer character by
|
|
108
|
+
* character — `fill()` would set the value with no keystrokes at all, and
|
|
109
|
+
* these are the vendors that score typing cadence.
|
|
110
|
+
*/
|
|
111
|
+
keyboard: {
|
|
112
|
+
type(text: string, options?: {
|
|
113
|
+
delay?: number;
|
|
114
|
+
}): Promise<void>;
|
|
115
|
+
press(key: string, options?: {
|
|
116
|
+
delay?: number;
|
|
117
|
+
}): Promise<void>;
|
|
118
|
+
};
|
|
99
119
|
/** Sleep `timeout` ms (Playwright's own timer). */
|
|
100
120
|
waitForTimeout(timeout: number): Promise<void>;
|
|
101
121
|
/** Wait for a selector to reach the given state; resolves to the handle (or null). */
|
|
@@ -109,6 +129,14 @@ export interface PlaywrightPage {
|
|
|
109
129
|
$(selector: string): Promise<PlaywrightElementHandle | null>;
|
|
110
130
|
/** All matching element handles. */
|
|
111
131
|
$$(selector: string): Promise<PlaywrightElementHandle[]>;
|
|
132
|
+
/**
|
|
133
|
+
* Whether the page has been closed. OPTIONAL because it is the one member
|
|
134
|
+
* here the solver itself never needs — the auto-solve watcher uses it to end
|
|
135
|
+
* its loop when the caller closes the browser out from under it, and falls
|
|
136
|
+
* back to treating the page as open when a launcher does not expose it.
|
|
137
|
+
* Playwright and Puppeteer both do.
|
|
138
|
+
*/
|
|
139
|
+
isClosed?(): boolean;
|
|
112
140
|
/** Run `pageFunction` against the first matching element, in the page context. */
|
|
113
141
|
$eval<R>(selector: string, pageFunction: (element: Element) => R, arg?: any): Promise<R>;
|
|
114
142
|
}
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* - `handle.getAttribute(name)` → `handle.evaluate((el,n)=>el.getAttribute(n), name)`
|
|
15
15
|
* - `handle.textContent()` → `handle.evaluate(el=>el.textContent)`
|
|
16
16
|
* - `handle.scrollIntoViewIfNeeded()`→ `handle.scrollIntoView()`
|
|
17
|
+
* - `page.isClosed()` → same, but must be forwarded explicitly
|
|
17
18
|
* Everything else (`$`, `$$`, `$eval`, `waitForFunction`, `mouse.*`,
|
|
18
19
|
* `screenshot({path})`, `boundingBox`, `contentFrame`, `isVisible`) is
|
|
19
20
|
* call-compatible and passed straight through.
|
|
@@ -42,6 +43,7 @@ interface PuppeteerElementHandle {
|
|
|
42
43
|
scrollIntoView(): Promise<void>;
|
|
43
44
|
isVisible(): Promise<boolean>;
|
|
44
45
|
evaluate(pageFunction: (el: any, ...args: any[]) => any, ...args: any[]): Promise<any>;
|
|
46
|
+
$(selector: string): Promise<PuppeteerElementHandle | null>;
|
|
45
47
|
}
|
|
46
48
|
interface PuppeteerFrame {
|
|
47
49
|
$(selector: string): Promise<PuppeteerElementHandle | null>;
|
|
@@ -56,11 +58,20 @@ interface PuppeteerPage {
|
|
|
56
58
|
down(options?: any): Promise<void>;
|
|
57
59
|
up(options?: any): Promise<void>;
|
|
58
60
|
};
|
|
61
|
+
keyboard: {
|
|
62
|
+
type(text: string, options?: {
|
|
63
|
+
delay?: number;
|
|
64
|
+
}): Promise<void>;
|
|
65
|
+
press(key: string, options?: any): Promise<void>;
|
|
66
|
+
down(key: string, options?: any): Promise<void>;
|
|
67
|
+
up(key: string): Promise<void>;
|
|
68
|
+
};
|
|
59
69
|
waitForSelector(selector: string, options?: any): Promise<PuppeteerElementHandle | null>;
|
|
60
70
|
viewport(): ViewportSize | null;
|
|
61
71
|
$(selector: string): Promise<PuppeteerElementHandle | null>;
|
|
62
72
|
$$(selector: string): Promise<PuppeteerElementHandle[]>;
|
|
63
73
|
$eval(selector: string, pageFunction: (element: Element) => any, ...args: any[]): Promise<any>;
|
|
74
|
+
isClosed(): boolean;
|
|
64
75
|
}
|
|
65
76
|
/**
|
|
66
77
|
* Wrap a Puppeteer `Page` so it satisfies the solver's structural Playwright
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
* - `handle.getAttribute(name)` → `handle.evaluate((el,n)=>el.getAttribute(n), name)`
|
|
16
16
|
* - `handle.textContent()` → `handle.evaluate(el=>el.textContent)`
|
|
17
17
|
* - `handle.scrollIntoViewIfNeeded()`→ `handle.scrollIntoView()`
|
|
18
|
+
* - `page.isClosed()` → same, but must be forwarded explicitly
|
|
18
19
|
* Everything else (`$`, `$$`, `$eval`, `waitForFunction`, `mouse.*`,
|
|
19
20
|
* `screenshot({path})`, `boundingBox`, `contentFrame`, `isVisible`) is
|
|
20
21
|
* call-compatible and passed straight through.
|
|
@@ -62,6 +63,7 @@ function wrapHandle(h) {
|
|
|
62
63
|
getAttribute: (name) => h.evaluate((el, n) => el.getAttribute(n), name),
|
|
63
64
|
isVisible: () => h.isVisible(),
|
|
64
65
|
textContent: () => h.evaluate((el) => el.textContent),
|
|
66
|
+
$: async (selector) => wrapHandle(await h.$(selector)),
|
|
65
67
|
};
|
|
66
68
|
}
|
|
67
69
|
function wrapFrame(f) {
|
|
@@ -86,11 +88,32 @@ function fromPuppeteer(page) {
|
|
|
86
88
|
down: (options) => page.mouse.down(options),
|
|
87
89
|
up: (options) => page.mouse.up(options),
|
|
88
90
|
},
|
|
91
|
+
keyboard: {
|
|
92
|
+
type: (text, options) => page.keyboard.type(text, options),
|
|
93
|
+
// Puppeteer has no combo syntax: 'Control+A' must be held, pressed,
|
|
94
|
+
// released. Playwright accepts the combo string directly, which is why
|
|
95
|
+
// the solver speaks Playwright and this adapter translates.
|
|
96
|
+
press: async (key) => {
|
|
97
|
+
const parts = key.split('+');
|
|
98
|
+
const target = parts.pop();
|
|
99
|
+
for (const mod of parts)
|
|
100
|
+
await page.keyboard.down(mod);
|
|
101
|
+
await page.keyboard.press(target);
|
|
102
|
+
for (const mod of parts.reverse())
|
|
103
|
+
await page.keyboard.up(mod);
|
|
104
|
+
},
|
|
105
|
+
},
|
|
89
106
|
waitForTimeout: (timeout) => new Promise((resolve) => setTimeout(resolve, timeout)),
|
|
90
107
|
waitForSelector: async (selector, options) => wrapHandle(await page.waitForSelector(selector, toPuppeteerSelectorOptions(options))),
|
|
91
108
|
viewportSize: () => page.viewport(),
|
|
92
109
|
$: async (selector) => wrapHandle(await page.$(selector)),
|
|
93
110
|
$$: async (selector) => (await page.$$(selector)).map((h) => wrapHandle(h)).filter(Boolean),
|
|
94
111
|
$eval: (selector, pageFunction, arg) => page.$eval(selector, pageFunction, arg),
|
|
112
|
+
// Same name and shape on both libraries, but it still has to be forwarded:
|
|
113
|
+
// the wrapper is a fresh object literal, so anything not listed here is
|
|
114
|
+
// simply absent. The auto-solve watcher reads it to end its loop when the
|
|
115
|
+
// caller closes the browser, and an undefined `isClosed` reads as "still
|
|
116
|
+
// open" — a watcher that polls a dead page forever.
|
|
117
|
+
isClosed: () => page.isClosed(),
|
|
95
118
|
};
|
|
96
119
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which interpreter runs the bundled CaptchaKraken CLI.
|
|
3
|
+
*
|
|
4
|
+
* This used to be `pythonCommand = 'python'`, inline in solver.ts. On
|
|
5
|
+
* Debian/Ubuntu — the most common Linux deployment target, and what CI runs —
|
|
6
|
+
* there is no `python` on PATH at all, only `python3`. So every JS solve died
|
|
7
|
+
* with `/bin/sh: 1: python: not found` before it ever reached the model, unless
|
|
8
|
+
* a `.venv` happened to exist next to the package. The type doc claimed the
|
|
9
|
+
* default was "'python' or 'python3'"; the code never tried `python3`.
|
|
10
|
+
*
|
|
11
|
+
* That is a straight violation of the rule that the Python and JS clients
|
|
12
|
+
* behave identically: the Python client obviously ran, so the divergence looked
|
|
13
|
+
* like a model or endpoint problem rather than a missing binary.
|
|
14
|
+
*
|
|
15
|
+
* Kept in its own module, with the probe injected, so the resolution order is
|
|
16
|
+
* testable without spawning anything.
|
|
17
|
+
*/
|
|
18
|
+
/** Env var that overrides everything below it. */
|
|
19
|
+
export declare const PYTHON_ENV_VAR = "CAPTCHA_KRAKEN_PYTHON";
|
|
20
|
+
/**
|
|
21
|
+
* Candidates tried in order when nothing is configured.
|
|
22
|
+
*
|
|
23
|
+
* `python3` FIRST. On Debian-family systems `python` is absent; where both
|
|
24
|
+
* exist they are the same interpreter in practice, and PEP 394 makes `python3`
|
|
25
|
+
* the portable spelling. `python` stays as the fallback for Windows and for
|
|
26
|
+
* venvs that only expose the unsuffixed name.
|
|
27
|
+
*/
|
|
28
|
+
export declare const PYTHON_CANDIDATES: readonly ["python3", "python"];
|
|
29
|
+
export interface ResolveOptions {
|
|
30
|
+
/** `config.pythonCommand`, if the caller set one. Wins outright. */
|
|
31
|
+
configured?: string;
|
|
32
|
+
/** The venv interpreter, when one was found next to the CLI. Wins over probing. */
|
|
33
|
+
venvPython?: string | null;
|
|
34
|
+
/** Process env, injected for testability. */
|
|
35
|
+
env?: NodeJS.ProcessEnv;
|
|
36
|
+
/** Returns true if the command is runnable. Injected so tests never spawn. */
|
|
37
|
+
exists?: (command: string) => boolean;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Resolve the interpreter, most specific first:
|
|
41
|
+
*
|
|
42
|
+
* 1. an explicitly configured `pythonCommand`
|
|
43
|
+
* 2. `CAPTCHAKRAKEN_PYTHON`
|
|
44
|
+
* 3. the venv interpreter beside the bundled CLI
|
|
45
|
+
* 4. the first of `python3`, `python` that actually exists
|
|
46
|
+
*
|
|
47
|
+
* Falls back to `python3` rather than `python` when nothing can be probed: on
|
|
48
|
+
* the platform where the two differ, `python3` is the one that is present.
|
|
49
|
+
*/
|
|
50
|
+
export declare function resolvePythonCommand(options?: ResolveOptions): string;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Which interpreter runs the bundled CaptchaKraken CLI.
|
|
4
|
+
*
|
|
5
|
+
* This used to be `pythonCommand = 'python'`, inline in solver.ts. On
|
|
6
|
+
* Debian/Ubuntu — the most common Linux deployment target, and what CI runs —
|
|
7
|
+
* there is no `python` on PATH at all, only `python3`. So every JS solve died
|
|
8
|
+
* with `/bin/sh: 1: python: not found` before it ever reached the model, unless
|
|
9
|
+
* a `.venv` happened to exist next to the package. The type doc claimed the
|
|
10
|
+
* default was "'python' or 'python3'"; the code never tried `python3`.
|
|
11
|
+
*
|
|
12
|
+
* That is a straight violation of the rule that the Python and JS clients
|
|
13
|
+
* behave identically: the Python client obviously ran, so the divergence looked
|
|
14
|
+
* like a model or endpoint problem rather than a missing binary.
|
|
15
|
+
*
|
|
16
|
+
* Kept in its own module, with the probe injected, so the resolution order is
|
|
17
|
+
* testable without spawning anything.
|
|
18
|
+
*/
|
|
19
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
20
|
+
exports.PYTHON_CANDIDATES = exports.PYTHON_ENV_VAR = void 0;
|
|
21
|
+
exports.resolvePythonCommand = resolvePythonCommand;
|
|
22
|
+
/** Env var that overrides everything below it. */
|
|
23
|
+
exports.PYTHON_ENV_VAR = 'CAPTCHA_KRAKEN_PYTHON';
|
|
24
|
+
/**
|
|
25
|
+
* Candidates tried in order when nothing is configured.
|
|
26
|
+
*
|
|
27
|
+
* `python3` FIRST. On Debian-family systems `python` is absent; where both
|
|
28
|
+
* exist they are the same interpreter in practice, and PEP 394 makes `python3`
|
|
29
|
+
* the portable spelling. `python` stays as the fallback for Windows and for
|
|
30
|
+
* venvs that only expose the unsuffixed name.
|
|
31
|
+
*/
|
|
32
|
+
exports.PYTHON_CANDIDATES = ['python3', 'python'];
|
|
33
|
+
/**
|
|
34
|
+
* Resolve the interpreter, most specific first:
|
|
35
|
+
*
|
|
36
|
+
* 1. an explicitly configured `pythonCommand`
|
|
37
|
+
* 2. `CAPTCHAKRAKEN_PYTHON`
|
|
38
|
+
* 3. the venv interpreter beside the bundled CLI
|
|
39
|
+
* 4. the first of `python3`, `python` that actually exists
|
|
40
|
+
*
|
|
41
|
+
* Falls back to `python3` rather than `python` when nothing can be probed: on
|
|
42
|
+
* the platform where the two differ, `python3` is the one that is present.
|
|
43
|
+
*/
|
|
44
|
+
function resolvePythonCommand(options = {}) {
|
|
45
|
+
const { configured, venvPython, env = process.env, exists } = options;
|
|
46
|
+
if (configured)
|
|
47
|
+
return configured;
|
|
48
|
+
const fromEnv = env[exports.PYTHON_ENV_VAR];
|
|
49
|
+
if (fromEnv)
|
|
50
|
+
return fromEnv;
|
|
51
|
+
if (venvPython)
|
|
52
|
+
return venvPython;
|
|
53
|
+
if (exists) {
|
|
54
|
+
for (const candidate of exports.PYTHON_CANDIDATES) {
|
|
55
|
+
if (exists(candidate))
|
|
56
|
+
return candidate;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return exports.PYTHON_CANDIDATES[0];
|
|
60
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The algebra behind a puzzle-piece slider's closed loop.
|
|
3
|
+
*
|
|
4
|
+
* Split out of solver.ts because it is the one part of this feature that is
|
|
5
|
+
* pure arithmetic, duplicated across the two ports, and impossible to notice
|
|
6
|
+
* drifting: a wrong ratio here does not throw, it just releases the piece in the
|
|
7
|
+
* wrong place, and the puzzle fails the way an unsolved puzzle fails. Keeping it
|
|
8
|
+
* dependency-free means it can be unit-tested with `node --test` alongside
|
|
9
|
+
* limits.ts, against the same cases as
|
|
10
|
+
* `PageSolver._solve_slide_geometry` in the Python port.
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* Piece width and handle-to-piece travel ratio, from probe measurements.
|
|
14
|
+
*
|
|
15
|
+
* Each measurement is `[handle offset, width of what changed]`, where the width
|
|
16
|
+
* spans the piece's ORIGINAL left edge to its CURRENT right edge — so
|
|
17
|
+
* `width = pieceWidth + ratio x offset`. Two of them determine both unknowns.
|
|
18
|
+
*
|
|
19
|
+
* With only one usable measurement the system is underdetermined, so `ratio` is
|
|
20
|
+
* ASSUMED to be 1 — true of every vendor observed, and the assumption is stated
|
|
21
|
+
* here rather than buried as a default. A ratio solved from implausible
|
|
22
|
+
* measurements (a redraw, a piece that hit the wall between probes) is rejected
|
|
23
|
+
* the same way: better a 1:1 guess that overshoots and gets corrected than a
|
|
24
|
+
* ratio of 0.02 that sends the handle off the track and, under camoufox, into a
|
|
25
|
+
* mouse move that never returns.
|
|
26
|
+
*
|
|
27
|
+
* `pieceWidth: null` means "could not measure" — the caller falls back to
|
|
28
|
+
* steering by the handle's own travel.
|
|
29
|
+
*/
|
|
30
|
+
export declare function solveSlideGeometry(widths: Array<[number, number]>, widgetWidth: number): {
|
|
31
|
+
pieceWidth: number | null;
|
|
32
|
+
ratio: number;
|
|
33
|
+
};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* The algebra behind a puzzle-piece slider's closed loop.
|
|
4
|
+
*
|
|
5
|
+
* Split out of solver.ts because it is the one part of this feature that is
|
|
6
|
+
* pure arithmetic, duplicated across the two ports, and impossible to notice
|
|
7
|
+
* drifting: a wrong ratio here does not throw, it just releases the piece in the
|
|
8
|
+
* wrong place, and the puzzle fails the way an unsolved puzzle fails. Keeping it
|
|
9
|
+
* dependency-free means it can be unit-tested with `node --test` alongside
|
|
10
|
+
* limits.ts, against the same cases as
|
|
11
|
+
* `PageSolver._solve_slide_geometry` in the Python port.
|
|
12
|
+
*/
|
|
13
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
14
|
+
exports.solveSlideGeometry = solveSlideGeometry;
|
|
15
|
+
/**
|
|
16
|
+
* Piece width and handle-to-piece travel ratio, from probe measurements.
|
|
17
|
+
*
|
|
18
|
+
* Each measurement is `[handle offset, width of what changed]`, where the width
|
|
19
|
+
* spans the piece's ORIGINAL left edge to its CURRENT right edge — so
|
|
20
|
+
* `width = pieceWidth + ratio x offset`. Two of them determine both unknowns.
|
|
21
|
+
*
|
|
22
|
+
* With only one usable measurement the system is underdetermined, so `ratio` is
|
|
23
|
+
* ASSUMED to be 1 — true of every vendor observed, and the assumption is stated
|
|
24
|
+
* here rather than buried as a default. A ratio solved from implausible
|
|
25
|
+
* measurements (a redraw, a piece that hit the wall between probes) is rejected
|
|
26
|
+
* the same way: better a 1:1 guess that overshoots and gets corrected than a
|
|
27
|
+
* ratio of 0.02 that sends the handle off the track and, under camoufox, into a
|
|
28
|
+
* mouse move that never returns.
|
|
29
|
+
*
|
|
30
|
+
* `pieceWidth: null` means "could not measure" — the caller falls back to
|
|
31
|
+
* steering by the handle's own travel.
|
|
32
|
+
*/
|
|
33
|
+
function solveSlideGeometry(widths, widgetWidth) {
|
|
34
|
+
if (!widths.length)
|
|
35
|
+
return { pieceWidth: null, ratio: 1 };
|
|
36
|
+
let ratio = 1;
|
|
37
|
+
let pieceWidth = null;
|
|
38
|
+
if (widths.length >= 2) {
|
|
39
|
+
const [o1, w1] = widths[0];
|
|
40
|
+
const [o2, w2] = widths[widths.length - 1];
|
|
41
|
+
if (o2 !== o1) {
|
|
42
|
+
const candidate = (w2 - w1) / (o2 - o1);
|
|
43
|
+
if (candidate >= MIN_RATIO && candidate <= MAX_RATIO) {
|
|
44
|
+
ratio = candidate;
|
|
45
|
+
pieceWidth = w1 - ratio * o1;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (pieceWidth === null) {
|
|
50
|
+
const [o, w] = widths[widths.length - 1];
|
|
51
|
+
pieceWidth = w - ratio * o;
|
|
52
|
+
}
|
|
53
|
+
// A piece narrower than a few pixels, or wider than half the widget, is a
|
|
54
|
+
// measurement of something else — a wholesale redraw, a fade, a spinner.
|
|
55
|
+
if (pieceWidth < MIN_PIECE_PX || pieceWidth > widgetWidth * MAX_PIECE_FRACTION) {
|
|
56
|
+
return { pieceWidth: null, ratio };
|
|
57
|
+
}
|
|
58
|
+
return { pieceWidth, ratio };
|
|
59
|
+
}
|
|
60
|
+
// Keep these identical to the literals in page_solver.py::_solve_slide_geometry.
|
|
61
|
+
const MIN_RATIO = 0.2;
|
|
62
|
+
const MAX_RATIO = 3.0;
|
|
63
|
+
const MIN_PIECE_PX = 3.0;
|
|
64
|
+
const MAX_PIECE_FRACTION = 0.6;
|