fiptcha 0.1.1 → 0.1.3

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/README.md CHANGED
@@ -187,43 +187,13 @@ Your application should remain responsible for:
187
187
 
188
188
  Fiptcha is responsible for the CAPTCHA-specific runtime: token detection, widget interaction, grid solving, model selection, model downloads, resource checks, and optional companion runtimes.
189
189
 
190
- ## Exported helpers
191
-
192
- The package root re-exports the public exports from these modules:
193
-
194
- | Module | Purpose |
195
- | --- | --- |
196
- | `captcha-local-solver` | High-level widget/token handling and `solveLocalCaptcha`. |
197
- | `captcha-grid-solver` | Image-grid solving and provider adapters. |
198
- | `captcha-interceptor` | Challenge interception helpers. |
199
- | `captcha-resources` | Host/container memory and resource detection. |
200
- | `captcha-model-manager` | Model selection, readiness, and lifecycle state. |
201
- | `captcha-model-downloader` | Model download and verification. |
202
- | `captcha-model-manifest` | Model metadata and requirements. |
203
- | `captcha-companion-client` | Client for the optional companion process. |
204
- | `captcha-mlx-runtime` | Apple Silicon MLX runtime support. |
205
- | `captcha-benchmark` | Benchmarking helpers. |
206
-
207
- For example, lower-level token helpers are also available:
208
-
209
- ```js
210
- const {
211
- readToken,
212
- waitForToken,
213
- clickCheckbox,
214
- solveLocalCaptcha
215
- } = require('fiptcha');
216
- ```
217
-
218
- The high-level solver is the recommended integration boundary. Lower-level exports are useful for custom integrations, but their API should be treated as pre-stable until Fiptcha reaches a stable standalone API release.
219
-
220
190
  ## Models and memory requirements
221
191
 
222
- Fiptcha does not bundle large model weights into the npm tarball. Required assets are acquired through the model-management layer when needed.
192
+ Fiptcha does not bundle large model weights into the npm package. Required assets are acquired when needed.
223
193
 
224
- The runtime chooses an appropriate backend based on available resources. In particular, a provider-advertised **2 GB** machine is interpreted using decimal sizing (2,000,000,000 bytes, about 1907 MiB) rather than incorrectly requiring 2048 MiB.
194
+ The runtime chooses an appropriate backend based on available resources. A provider-advertised **2 GB** machine is interpreted using decimal sizing (2,000,000,000 bytes, about 1907 MiB) rather than requiring 2048 MiB.
225
195
 
226
- If your application runs in an ephemeral container, persist the model/cache directory used by your deployment so models do not need to be downloaded again on every container recreation.
196
+ If your application runs in an ephemeral container, persist the model/cache directory so models do not need to be downloaded again on every container recreation.
227
197
 
228
198
  ## Companion runtime
229
199
 
@@ -247,13 +217,11 @@ Start the Docker-oriented path:
247
217
  npm run companion:start:docker
248
218
  ```
249
219
 
250
- The companion is optional. Ordinary Node.js integrations do not need to make it part of their architecture unless they use that runtime path.
220
+ The companion is optional. Ordinary Node.js integrations do not need it unless they use that runtime path.
251
221
 
252
222
  ## Apple Silicon / MLX
253
223
 
254
- Fiptcha contains the MLX worker and runtime integration used for compatible Apple Silicon environments.
255
-
256
- The MLX path is optional. Consumers that do not use MLX can ignore the bundled worker and companion requirements.
224
+ Fiptcha supports an optional MLX runtime for compatible Apple Silicon environments. Consumers that do not use MLX can ignore it.
257
225
 
258
226
  ## Inspect the runtime environment
259
227
 
@@ -265,43 +233,6 @@ npm run probe
265
233
 
266
234
  This is useful in containers and VMs where cgroup/container limits may differ from the physical host's reported memory.
267
235
 
268
- ## Test Fiptcha
269
-
270
- ```bash
271
- npm test
272
- ```
273
-
274
- The smoke suite checks the extracted runtime and includes regression coverage for resource selection, including the 2 GB memory threshold.
275
-
276
- ## Check what npm will publish
277
-
278
- Before publishing a release:
279
-
280
- ```bash
281
- npm pack --dry-run
282
- ```
283
-
284
- The package intentionally publishes the runtime source, entry point, companion scripts, README, and license—not downloaded model weights.
285
-
286
- ## Release process
287
-
288
- Fiptcha publishes to npm as:
289
-
290
- ```text
291
- fiptcha
292
- ```
293
-
294
- The repository uses npm Trusted Publishing through GitHub Actions. Releases are published without a long-lived npm token.
295
-
296
- To release a new version:
297
-
298
- 1. Update `version` in `package.json`.
299
- 2. Merge the change to `main`.
300
- 3. Create and publish a GitHub Release whose tag matches the package version, for example `v0.2.0`.
301
- 4. The release workflow runs the tests and publishes the package to npm through OIDC Trusted Publishing.
302
-
303
- Do not manually add an `NPM_TOKEN` secret for the normal release path.
304
-
305
236
  ## Using Fiptcha from another project
306
237
 
307
238
  Install it normally:
@@ -325,12 +256,6 @@ async function solve(page) {
325
256
 
326
257
  No Figranium task schema, server, UI, database, scheduler, authentication system, or storage layer is required.
327
258
 
328
- ## Using Fiptcha in Figranium
329
-
330
- Figranium consumes the published `fiptcha` package and keeps only Figranium-specific integration code in the main application.
331
-
332
- The standalone Fiptcha repository is the canonical implementation of the local CAPTCHA runtime. Fixes to model handling, resource detection, challenge interaction, or companion behavior should be made here rather than copied back into Figranium.
333
-
334
259
  ## Troubleshooting
335
260
 
336
261
  ### `Local solver does not support ...`
@@ -359,26 +284,6 @@ Increase the solve timeout and collect the `logs` array. Also verify that the ch
359
284
 
360
285
  Persist the model/cache storage used by the runtime instead of recreating it with every ephemeral container.
361
286
 
362
- ### A lower-level helper changed between releases
363
-
364
- Prefer `solveLocalCaptcha` for application integrations. Until the low-level API is declared stable, pin an exact Fiptcha version if you depend directly on internal/model/grid helpers.
365
-
366
- ## Package contents
367
-
368
- The npm package contains:
369
-
370
- ```text
371
- index.js
372
- src/
373
- scripts/captcha-mlx-worker.py
374
- scripts/captcha-companion-requirements.txt
375
- scripts/captcha-container-probe.js
376
- scripts/install-captcha-companion.js
377
- scripts/captcha-companion.js
378
- README.md
379
- LICENSE
380
- ```
381
-
382
287
  ## License
383
288
 
384
289
  Apache-2.0.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fiptcha",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Local CAPTCHA solving for browser automation. Built for Figranium, usable anywhere.",
5
5
  "main": "index.js",
6
6
  "license": "Apache-2.0",
@@ -1,4 +1,5 @@
1
1
  const { captchaModelManager, checksumBuffer } = require('./captcha-model-manager');
2
+ const { clickVisibleTarget } = require('./captcha-pointer');
2
3
 
3
4
  const PROVIDERS = Object.freeze({
4
5
  recaptcha_v2: Object.freeze({
@@ -42,6 +43,17 @@ function normalizePrompt(adapter, text) {
42
43
  return adapter.cleanPrompt(String(text || '')).replace(/\s+/g, ' ').replace(/[.!]+$/, '').trim();
43
44
  }
44
45
 
46
+ function normalizeModelLabel(label) {
47
+ return String(label || '').replace(/\b[a-z]+\b/gi, (word) => {
48
+ const lower = word.toLowerCase();
49
+ if (lower === 'buses') return 'bus';
50
+ if (lower.endsWith('ies')) return `${lower.slice(0, -3)}y`;
51
+ if (/(?:ches|shes|xes|zes)$/.test(lower)) return lower.slice(0, -2);
52
+ if (lower.endsWith('s') && !lower.endsWith('ss') && !lower.endsWith('us')) return lower.slice(0, -1);
53
+ return lower;
54
+ });
55
+ }
56
+
45
57
  function normalizeBox(detection) {
46
58
  const box = detection?.box || detection?.bbox;
47
59
  if (Array.isArray(box) && box.length >= 4) {
@@ -81,16 +93,16 @@ function mapDetectionsToCells(detections, gridBox, cellBoxes, imageSize = null)
81
93
  }
82
94
 
83
95
  async function classifyGrid(frame, adapter, cells, label, seenTiles) {
96
+ const modelLabel = normalizeModelLabel(label);
84
97
  const grid = frame.locator(adapter.grid).first();
85
98
  const gridBox = await grid.boundingBox().catch(() => null);
86
99
  if (gridBox) {
87
100
  const image = await grid.screenshot({ type: 'png' });
88
- const detections = await captchaModelManager.detect(image, label);
89
- if (detections.length === 0) return [];
101
+ const detections = await captchaModelManager.detect(image, modelLabel);
90
102
  const cellBoxes = [];
91
103
  for (let index = 0; index < await cells.count(); index += 1) cellBoxes.push(await cells.nth(index).boundingBox().catch(() => null));
92
104
  const mapped = mapDetectionsToCells(detections, gridBox, cellBoxes, pngDimensions(image));
93
- if (mapped.length || detections.every((item) => normalizeBox(item))) {
105
+ if (mapped.length) {
94
106
  const changed = [];
95
107
  for (const index of mapped) {
96
108
  const image = await cells.nth(index).screenshot({ type: 'png' });
@@ -110,13 +122,19 @@ async function classifyGrid(frame, adapter, cells, label, seenTiles) {
110
122
  const fingerprint = checksumBuffer(image);
111
123
  if (seenTiles.get(index) === fingerprint) continue;
112
124
  seenTiles.set(index, fingerprint);
113
- if ((await captchaModelManager.detect(image, label)).length) selected.push(index);
125
+ if ((await captchaModelManager.detect(image, modelLabel)).length) selected.push(index);
114
126
  }
115
127
  return selected;
116
128
  }
117
129
 
118
130
  async function visibleText(frame, selector) {
119
- return frame.locator(selector).first().innerText({ timeout: 1500 }).catch(() => '');
131
+ const matches = frame.locator(selector);
132
+ for (let index = 0; index < await matches.count().catch(() => 0); index += 1) {
133
+ const locator = matches.nth(index);
134
+ if (!await locator.isVisible({ timeout: 250 }).catch(() => false)) continue;
135
+ return locator.innerText({ timeout: 1500 }).catch(() => '');
136
+ }
137
+ return '';
120
138
  }
121
139
 
122
140
  async function solveImageGrid(page, { captchaType, deadline, waitForToken, logs = [] }) {
@@ -128,7 +146,9 @@ async function solveImageGrid(page, { captchaType, deadline, waitForToken, logs
128
146
  const frame = await findChallengeFrame(page, adapter);
129
147
  if (!frame) throw new Error(`${captchaType} image challenge frame disappeared`);
130
148
  const rejection = await visibleText(frame, adapter.error);
131
- if (rejection) throw new Error(`${captchaType} rejected the previous selection: ${rejection.replace(/\s+/g, ' ').trim()}`);
149
+ if (rejection && round > 0) {
150
+ throw new Error(`${captchaType} rejected the previous selection: ${rejection.replace(/\s+/g, ' ').trim()}`);
151
+ }
132
152
  const prompt = normalizePrompt(adapter, await visibleText(frame, adapter.instruction));
133
153
  if (!prompt) throw new Error(`${captchaType} solver could not read the image challenge instruction`);
134
154
  if (prompt !== previousPrompt) {
@@ -141,13 +161,17 @@ async function solveImageGrid(page, { captchaType, deadline, waitForToken, logs
141
161
  const count = await cells.count();
142
162
  if (![9, 16].includes(count)) throw new Error(`Unsupported ${captchaType} grid size (${count} cells)`);
143
163
  const selected = await classifyGrid(frame, adapter, cells, prompt, seenTiles);
144
- for (const index of selected) await cells.nth(index).click({ timeout: 2000 });
164
+ for (const index of selected) {
165
+ const clicked = await clickVisibleTarget(page, cells.nth(index));
166
+ if (!clicked) throw new Error(`${captchaType} grid tile was outside the viewport`);
167
+ }
145
168
  totalSelections += selected.length;
146
169
  if (!selected.length) break;
147
170
  await page.waitForTimeout(600);
148
171
  }
149
172
  logs.push(`Local ${captchaType} grid round ${round + 1}: selected ${totalSelections} cells for "${prompt}"`);
150
- await frame.locator(totalSelections ? adapter.submit : adapter.noMatch).first().click({ timeout: 2000 });
173
+ const submitted = await clickVisibleTarget(page, frame.locator(totalSelections ? adapter.submit : adapter.noMatch).first());
174
+ if (!submitted) throw new Error(`${captchaType} verification button was outside the viewport`);
151
175
  const token = await waitForToken(page, captchaType, Math.min(2500, Math.max(0, deadline - Date.now())));
152
176
  if (token) return token;
153
177
  await page.waitForTimeout(350);
@@ -155,4 +179,4 @@ async function solveImageGrid(page, { captchaType, deadline, waitForToken, logs
155
179
  return null;
156
180
  }
157
181
 
158
- module.exports = { PROVIDERS, normalizePrompt, normalizeBox, pngDimensions, mapDetectionsToCells, solveImageGrid };
182
+ module.exports = { PROVIDERS, normalizePrompt, normalizeModelLabel, normalizeBox, pngDimensions, mapDetectionsToCells, classifyGrid, visibleText, solveImageGrid };
@@ -1,5 +1,6 @@
1
1
  const { captchaModelManager } = require('./captcha-model-manager');
2
2
  const { solveImageGrid, PROVIDERS } = require('./captcha-grid-solver');
3
+ const { clickVisibleTarget } = require('./captcha-pointer');
3
4
 
4
5
  const TOKEN_SELECTORS = Object.freeze({
5
6
  recaptcha_v2: ['#g-recaptcha-response', 'textarea[name="g-recaptcha-response"]'],
@@ -95,8 +96,7 @@ async function clickCheckbox(page, captchaType, timeout = 10_000) {
95
96
  }).catch(() => true);
96
97
  if (!pointerReady) continue;
97
98
 
98
- const clickTimeout = Math.max(1, Math.min(2000, deadline - Date.now()));
99
- const clicked = await checkbox.click({ timeout: clickTimeout }).then(() => true, () => false);
99
+ const clicked = await clickVisibleTarget(page, checkbox).catch(() => false);
100
100
  if (clicked) return true;
101
101
  }
102
102
  }
@@ -0,0 +1,31 @@
1
+ async function viewportSize(page) {
2
+ const configured = page.viewportSize?.();
3
+ if (configured?.width && configured?.height) return configured;
4
+ return page.evaluate(() => ({ width: window.innerWidth, height: window.innerHeight })).catch(() => null);
5
+ }
6
+
7
+ function isInsideViewport(box, viewport) {
8
+ if (!box || !viewport) return false;
9
+ const x = box.x + (box.width / 2);
10
+ const y = box.y + (box.height / 2);
11
+ return Number.isFinite(x) && Number.isFinite(y)
12
+ && x >= 0 && y >= 0 && x <= viewport.width && y <= viewport.height;
13
+ }
14
+
15
+ /**
16
+ * Dispatch a trusted pointer click without Playwright's scroll-into-view step.
17
+ * CAPTCHA widgets should already be visible to a human; scrolling the page to
18
+ * satisfy a locator can move or invalidate the challenge between tile clicks.
19
+ */
20
+ async function clickVisibleTarget(page, locator) {
21
+ if (!page?.mouse?.click) return false;
22
+ const [box, viewport] = await Promise.all([
23
+ locator.boundingBox().catch(() => null),
24
+ viewportSize(page)
25
+ ]);
26
+ if (!isInsideViewport(box, viewport)) return false;
27
+ await page.mouse.click(box.x + (box.width / 2), box.y + (box.height / 2));
28
+ return true;
29
+ }
30
+
31
+ module.exports = { clickVisibleTarget, isInsideViewport };