fiptcha 0.1.4 → 0.1.6

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
@@ -56,6 +56,9 @@ Pass one of these values as `captchaType`:
56
56
  | `hcaptcha` | Interacts with the widget and solves supported image-grid challenges locally when required. |
57
57
  | `turnstile` | Performs active-browser interaction and waits for a token. It does not use the image-grid vision path. |
58
58
 
59
+ > [!WARNING]
60
+ > **3×3 image-grid stability is currently not good.** Dynamic 3×3 challenges, particularly those that replace tiles after a selection, may be unreliable. 4×4 static image-grid challenges are currently the more stable path.
61
+
59
62
  Fiptcha cannot guarantee that every challenge will be solvable. CAPTCHA providers can change their UI, challenge format, and risk checks independently of the visible widget.
60
63
 
61
64
  ## `solveLocalCaptcha(page, options)`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fiptcha",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
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",
@@ -8,7 +8,9 @@ const PROVIDERS = Object.freeze({
8
8
  cells: '.rc-imageselect-tile',
9
9
  submit: '#recaptcha-verify-button',
10
10
  noMatch: '#recaptcha-verify-button',
11
- error: '.rc-imageselect-error-select-more, .rc-imageselect-error-dynamic-more, .rc-imageselect-incorrect-response',
11
+ // `error-dynamic-more` is also shown while replacement tiles are still
12
+ // expected. Treating it as a rejection aborts valid dynamic challenges.
13
+ error: '.rc-imageselect-error-select-more, .rc-imageselect-incorrect-response',
12
14
  cleanPrompt(text) {
13
15
  return text.replace(/select all (images|squares) with/ig, '')
14
16
  .replace(/click verify once there are none left/ig, '')
@@ -16,10 +18,12 @@ const PROVIDERS = Object.freeze({
16
18
  }
17
19
  }),
18
20
  hcaptcha: Object.freeze({
19
- framePatterns: ['hcaptcha.com/captcha', 'newassets.hcaptcha.com/captcha'],
20
- instruction: '.prompt-text, .challenge-header .prompt-text, h2',
21
- grid: '.task-grid',
22
- cells: '.task-grid .task-image',
21
+ // Enterprise integrations may serve the iframe from a first-party
22
+ // asset host, so do not require an hcaptcha.com hostname.
23
+ framePatterns: ['hcaptcha.com', 'hcaptcha.html', 'hcaptcha-checkbox.html', 'frame=challenge', 'frame=checkbox'],
24
+ instruction: '.prompt-text, .challenge-header .prompt-text, .challenge-header h2, h2',
25
+ grid: '.task-grid, [class*="task-grid"]',
26
+ cells: '.task-grid .task-image, [class*="task-grid"] [class*="task-image"]',
23
27
  submit: '.button-submit, button[type="submit"]',
24
28
  noMatch: '.button-submit, button[type="submit"]',
25
29
  error: '.error-text, .challenge-error',
@@ -32,7 +36,8 @@ const PROVIDERS = Object.freeze({
32
36
 
33
37
  async function findChallengeFrame(page, adapter) {
34
38
  for (const frame of page.frames()) {
35
- if (!adapter.framePatterns.some((pattern) => frame.url().includes(pattern))) continue;
39
+ const url = String(frame.url?.() || '').toLowerCase();
40
+ if (!adapter.framePatterns.some((pattern) => url.includes(pattern))) continue;
36
41
  if (await frame.locator(adapter.grid).first().isVisible({ timeout: 250 }).catch(() => false)) return frame;
37
42
  }
38
43
  return null;
@@ -81,18 +86,32 @@ function mapDetectionsToCells(detections, gridBox, cellBoxes, imageSize = null)
81
86
  for (const detection of detections) {
82
87
  const box = normalizeBox(detection);
83
88
  if (!box) continue;
84
- const centerX = gridBox.x + (((box.xmin + box.xmax) / 2) * scaleX);
85
- const centerY = gridBox.y + (((box.ymin + box.ymax) / 2) * scaleY);
86
- const index = cellBoxes.findIndex((cell) => cell
87
- && centerX >= cell.x && centerX <= cell.x + cell.width
88
- && centerY >= cell.y && centerY <= cell.y + cell.height);
89
- if (index >= 0) indexes.add(index);
89
+ const projected = {
90
+ left: gridBox.x + (box.xmin * scaleX),
91
+ top: gridBox.y + (box.ymin * scaleY),
92
+ right: gridBox.x + (box.xmax * scaleX),
93
+ bottom: gridBox.y + (box.ymax * scaleY)
94
+ };
95
+ const centerX = (projected.left + projected.right) / 2;
96
+ const centerY = (projected.top + projected.bottom) / 2;
97
+ cellBoxes.forEach((cell, index) => {
98
+ if (!cell) return;
99
+ const intersectionWidth = Math.max(0, Math.min(projected.right, cell.x + cell.width) - Math.max(projected.left, cell.x));
100
+ const intersectionHeight = Math.max(0, Math.min(projected.bottom, cell.y + cell.height) - Math.max(projected.top, cell.y));
101
+ const coverage = (intersectionWidth * intersectionHeight) / Math.max(1, cell.width * cell.height);
102
+ const containsCenter = centerX >= cell.x && centerX <= cell.x + cell.width
103
+ && centerY >= cell.y && centerY <= cell.y + cell.height;
104
+ // reCAPTCHA asks for every square containing part of an object, not
105
+ // merely the square containing the detector box's center.
106
+ if (containsCenter || coverage >= 0.08) indexes.add(index);
107
+ });
90
108
  }
91
109
  return [...indexes];
92
110
  }
93
111
 
94
112
  async function classifyGrid(frame, adapter, cells, label, seenTiles) {
95
113
  const modelLabel = normalizeModelLabel(label);
114
+ const mapped = new Set();
96
115
  const grid = frame.locator(adapter.grid).first();
97
116
  const gridBox = await grid.boundingBox().catch(() => null);
98
117
  if (gridBox) {
@@ -100,18 +119,7 @@ async function classifyGrid(frame, adapter, cells, label, seenTiles) {
100
119
  const detections = await captchaModelManager.detect(image, modelLabel);
101
120
  const cellBoxes = [];
102
121
  for (let index = 0; index < await cells.count(); index += 1) cellBoxes.push(await cells.nth(index).boundingBox().catch(() => null));
103
- const mapped = mapDetectionsToCells(detections, gridBox, cellBoxes, pngDimensions(image));
104
- if (mapped.length) {
105
- const changed = [];
106
- for (const index of mapped) {
107
- const image = await cells.nth(index).screenshot({ type: 'png' });
108
- const fingerprint = checksumBuffer(image);
109
- if (seenTiles.get(index) === fingerprint) continue;
110
- seenTiles.set(index, fingerprint);
111
- changed.push(index);
112
- }
113
- return changed;
114
- }
122
+ for (const index of mapDetectionsToCells(detections, gridBox, cellBoxes, pngDimensions(image))) mapped.add(index);
115
123
  }
116
124
 
117
125
  const selected = [];
@@ -121,11 +129,98 @@ async function classifyGrid(frame, adapter, cells, label, seenTiles) {
121
129
  const fingerprint = checksumBuffer(image);
122
130
  if (seenTiles.get(index) === fingerprint) continue;
123
131
  seenTiles.set(index, fingerprint);
124
- if ((await captchaModelManager.detect(image, modelLabel)).length) selected.push(index);
132
+ // A whole-grid pass is fast and preserves objects spanning cell edges,
133
+ // while a tile pass retains detail on dense 4x4 grids. Fuse both: a
134
+ // partial grid result must not suppress tile-level matches elsewhere.
135
+ if (mapped.has(index) || (await captchaModelManager.detect(image, modelLabel)).length) selected.push(index);
125
136
  }
126
137
  return selected;
127
138
  }
128
139
 
140
+ async function rememberClickedTile(cells, index, seenTiles, beforeFingerprint = null) {
141
+ // Clicking a CAPTCHA tile changes its rendered appearance (selection overlay,
142
+ // border, checkmark, etc.). Remember that post-click state immediately so the
143
+ // next sweep does not mistake the selection UI itself for a replacement image
144
+ // and toggle an already-correct tile back off. A genuinely replaced tile will
145
+ // still produce a different fingerprint on the next sweep.
146
+ const cell = cells.nth(index);
147
+ const currentFingerprint = await tileFingerprint(cell);
148
+ // A fast replacement may already be present by the time click() resolves.
149
+ // Do not mark that unseen image as processed.
150
+ if (beforeFingerprint && currentFingerprint && currentFingerprint !== beforeFingerprint) return;
151
+ const image = await cell.screenshot({ type: 'png' }).catch(() => null);
152
+ if (image) seenTiles.set(index, checksumBuffer(image));
153
+ }
154
+
155
+ async function tileFingerprint(cell) {
156
+ const source = typeof cell.evaluate === 'function' ? await cell.evaluate((element) => {
157
+ const images = [...element.querySelectorAll('img')];
158
+ if (images.length) return images.map((image) => [image.currentSrc, image.src, image.style.transform].join('|')).join('\n');
159
+ const styled = [element, ...element.querySelectorAll('*')]
160
+ .map((candidate) => getComputedStyle(candidate).backgroundImage)
161
+ .filter((value) => value && value !== 'none');
162
+ return styled.join('\n');
163
+ }).catch(() => '') : '';
164
+ if (source) return checksumBuffer(Buffer.from(source));
165
+ const image = await cell.screenshot({ type: 'png' }).catch(() => null);
166
+ return image ? checksumBuffer(image) : null;
167
+ }
168
+
169
+ async function tileFingerprints(cells, indexes) {
170
+ const fingerprints = new Map();
171
+ for (const index of indexes) {
172
+ const fingerprint = await tileFingerprint(cells.nth(index));
173
+ if (fingerprint) fingerprints.set(index, fingerprint);
174
+ }
175
+ return fingerprints;
176
+ }
177
+
178
+ async function waitForTileReplacement(page, cells, before, deadline, timeout = 2500) {
179
+ const replacementDeadline = Math.min(deadline, Date.now() + timeout);
180
+ const pending = new Map(before);
181
+ let changed = false;
182
+ while (Date.now() < replacementDeadline) {
183
+ for (const [index, fingerprint] of pending) {
184
+ const current = await tileFingerprint(cells.nth(index));
185
+ if (current && current !== fingerprint) {
186
+ pending.delete(index);
187
+ changed = true;
188
+ }
189
+ }
190
+ if (!pending.size) {
191
+ // Let the replacement animation finish before inference.
192
+ await page.waitForTimeout(Math.min(250, Math.max(0, deadline - Date.now())));
193
+ return true;
194
+ }
195
+ await page.waitForTimeout(Math.min(100, Math.max(1, replacementDeadline - Date.now())));
196
+ }
197
+ return changed;
198
+ }
199
+
200
+ async function challengeSignature(frame, adapter) {
201
+ const grid = frame?.locator(adapter.grid).first();
202
+ const image = await grid?.screenshot({ type: 'png' }).catch(() => null);
203
+ if (!image) return null;
204
+ const prompt = normalizePrompt(adapter, await visibleText(frame, adapter.instruction));
205
+ const count = await frame.locator(adapter.cells).count().catch(() => 0);
206
+ return `${prompt}\n${count}\n${checksumBuffer(image)}`;
207
+ }
208
+
209
+ async function waitForSubmission(page, adapter, captchaType, previousSignature, deadline, waitForToken) {
210
+ const transitionDeadline = Math.min(deadline, Date.now() + 5000);
211
+ while (Date.now() < transitionDeadline) {
212
+ const token = await waitForToken(page, captchaType, 0);
213
+ if (token) return { token, transitioned: false };
214
+ const frame = await findChallengeFrame(page, adapter);
215
+ if (frame) {
216
+ const signature = await challengeSignature(frame, adapter);
217
+ if (previousSignature && signature && signature !== previousSignature) return { token: null, transitioned: true };
218
+ }
219
+ await page.waitForTimeout(Math.min(125, Math.max(1, transitionDeadline - Date.now())));
220
+ }
221
+ return { token: null, transitioned: false };
222
+ }
223
+
129
224
  async function visibleText(frame, selector) {
130
225
  const matches = frame.locator(selector);
131
226
  for (let index = 0; index < await matches.count().catch(() => 0); index += 1) {
@@ -141,21 +236,24 @@ async function solveImageGrid(page, { captchaType, deadline, waitForToken, logs
141
236
  if (!adapter) throw new Error(`No image-grid adapter for ${captchaType}`);
142
237
  const seenTiles = new Map();
143
238
  let previousPrompt = '';
144
- for (let round = 0; round < 6 && Date.now() < deadline; round += 1) {
239
+ for (let round = 0; round < 12 && Date.now() < deadline; round += 1) {
145
240
  const frame = await findChallengeFrame(page, adapter);
146
241
  if (!frame) throw new Error(`${captchaType} image challenge frame disappeared`);
147
242
  const rejection = await visibleText(frame, adapter.error);
148
243
  if (rejection && round > 0) {
149
244
  throw new Error(`${captchaType} rejected the previous selection: ${rejection.replace(/\s+/g, ' ').trim()}`);
150
245
  }
151
- const prompt = normalizePrompt(adapter, await visibleText(frame, adapter.instruction));
246
+ const instruction = await visibleText(frame, adapter.instruction);
247
+ const prompt = normalizePrompt(adapter, instruction);
248
+ const dynamic = captchaType === 'recaptcha_v2'
249
+ && /(?:none left|new images|replaced)/i.test(instruction);
152
250
  if (!prompt) throw new Error(`${captchaType} solver could not read the image challenge instruction`);
153
251
  if (prompt !== previousPrompt) {
154
252
  seenTiles.clear();
155
253
  previousPrompt = prompt;
156
254
  }
157
255
  let totalSelections = 0;
158
- for (let sweep = 0; sweep < 4 && Date.now() < deadline; sweep += 1) {
256
+ for (let sweep = 0; sweep < 12 && Date.now() < deadline; sweep += 1) {
159
257
  const cells = frame.locator(adapter.cells);
160
258
  const count = await cells.count();
161
259
  if (![9, 16].includes(count)) throw new Error(`Unsupported ${captchaType} grid size (${count} cells)`);
@@ -163,18 +261,25 @@ async function solveImageGrid(page, { captchaType, deadline, waitForToken, logs
163
261
  // Grid cells live inside a provider iframe. Let Playwright target the
164
262
  // element in that frame instead of sending a page-level mouse click:
165
263
  // the latter can miss the cell when iframe coordinates change.
166
- for (const index of selected) await cells.nth(index).click({ timeout: 2000 });
264
+ const before = await tileFingerprints(cells, selected);
265
+ for (const index of selected) {
266
+ await cells.nth(index).click({ timeout: 2000 });
267
+ await rememberClickedTile(cells, index, seenTiles, before.get(index));
268
+ }
167
269
  totalSelections += selected.length;
168
270
  if (!selected.length) break;
169
- await page.waitForTimeout(600);
271
+ // Dynamic 3x3 challenges replace selected tiles. Static and 4x4
272
+ // challenges do not, so stop sweeping once no replacement occurs.
273
+ if (!dynamic || !await waitForTileReplacement(page, cells, before, deadline)) break;
170
274
  }
171
275
  logs.push(`Local ${captchaType} grid round ${round + 1}: selected ${totalSelections} cells for "${prompt}"`);
276
+ const signature = await challengeSignature(frame, adapter);
172
277
  await frame.locator(totalSelections ? adapter.submit : adapter.noMatch).first().click({ timeout: 2000 });
173
- const token = await waitForToken(page, captchaType, Math.min(2500, Math.max(0, deadline - Date.now())));
174
- if (token) return token;
175
- await page.waitForTimeout(350);
278
+ const result = await waitForSubmission(page, adapter, captchaType, signature, deadline, waitForToken);
279
+ if (result.token) return result.token;
280
+ if (!result.transitioned) await page.waitForTimeout(Math.min(350, Math.max(0, deadline - Date.now())));
176
281
  }
177
282
  return null;
178
283
  }
179
284
 
180
- module.exports = { PROVIDERS, normalizePrompt, normalizeModelLabel, normalizeBox, pngDimensions, mapDetectionsToCells, classifyGrid, visibleText, solveImageGrid };
285
+ module.exports = { PROVIDERS, normalizePrompt, normalizeModelLabel, normalizeBox, pngDimensions, mapDetectionsToCells, classifyGrid, rememberClickedTile, visibleText, tileFingerprint, tileFingerprints, waitForTileReplacement, challengeSignature, waitForSubmission, solveImageGrid };
@@ -14,8 +14,8 @@ const WIDGET_INTERACTIONS = Object.freeze({
14
14
  selectors: ['#recaptcha-anchor', '[role="checkbox"]']
15
15
  }),
16
16
  hcaptcha: Object.freeze({
17
- framePatterns: ['hcaptcha.com/captcha', 'newassets.hcaptcha.com/captcha'],
18
- selectors: ['#checkbox', '[role="checkbox"]', 'input[type="checkbox"]', 'label']
17
+ framePatterns: ['hcaptcha.com', 'hcaptcha.html', 'hcaptcha-checkbox.html', 'frame=checkbox'],
18
+ selectors: ['#checkbox', '.check', '#anchor', '[role="checkbox"]', 'input[type="checkbox"]', 'label']
19
19
  }),
20
20
  turnstile: Object.freeze({
21
21
  framePatterns: ['challenges.cloudflare.com'],
@@ -23,6 +23,11 @@ const WIDGET_INTERACTIONS = Object.freeze({
23
23
  })
24
24
  });
25
25
 
26
+ function frameMatches(frame, patterns) {
27
+ const url = String(frame?.url?.() || '').toLowerCase();
28
+ return patterns.some((pattern) => url.includes(pattern));
29
+ }
30
+
26
31
  async function readToken(page, captchaType) {
27
32
  return page.evaluate(({ type, selectors }) => {
28
33
  for (const selector of selectors[type] || []) {
@@ -63,7 +68,7 @@ async function waitForTokenOrChallenge(page, captchaType, timeout) {
63
68
  if (token) return token;
64
69
  if (adapter) {
65
70
  for (const frame of page.frames?.() || []) {
66
- if (!adapter.framePatterns.some((pattern) => frame.url().includes(pattern))) continue;
71
+ if (!frameMatches(frame, adapter.framePatterns)) continue;
67
72
  if (await frame.locator(adapter.grid).first().isVisible({ timeout: 100 }).catch(() => false)) return null;
68
73
  }
69
74
  }
@@ -80,8 +85,7 @@ async function clickCheckbox(page, captchaType, timeout = 10_000) {
80
85
 
81
86
  do {
82
87
  for (const frame of page.frames?.() || []) {
83
- const frameUrl = String(frame.url?.() || '');
84
- if (!interaction.framePatterns.some((pattern) => frameUrl.includes(pattern))) continue;
88
+ if (!frameMatches(frame, interaction.framePatterns)) continue;
85
89
 
86
90
  for (const selector of interaction.selectors) {
87
91
  const matches = frame.locator(selector);
@@ -112,13 +116,17 @@ async function clickCheckbox(page, captchaType, timeout = 10_000) {
112
116
  async function executeHcaptchaWidget(page) {
113
117
  return page.evaluate(() => {
114
118
  if (typeof globalThis.hcaptcha?.execute !== 'function') return false;
115
- try {
116
- const pending = globalThis.hcaptcha.execute();
117
- if (pending && typeof pending.catch === 'function') pending.catch(() => {});
118
- return true;
119
- } catch {
120
- return false;
119
+ const ids = [...document.querySelectorAll('[data-hcaptcha-widget-id]')]
120
+ .map((element) => element.getAttribute('data-hcaptcha-widget-id'))
121
+ .filter(Boolean);
122
+ for (const id of [...new Set([...ids, null])]) {
123
+ try {
124
+ const pending = id === null ? globalThis.hcaptcha.execute() : globalThis.hcaptcha.execute(id);
125
+ if (pending && typeof pending.catch === 'function') pending.catch(() => {});
126
+ return true;
127
+ } catch { /* try the default/next widget */ }
121
128
  }
129
+ return false;
122
130
  }).catch(() => false);
123
131
  }
124
132
 
@@ -133,7 +141,10 @@ async function solveLocalCaptcha(page, { captchaType, timeout = 60_000, logs = [
133
141
  : null;
134
142
  let token = await readToken(page, captchaType);
135
143
  if (!token) {
136
- const clickTimeout = Math.min(10_000, Math.max(0, deadline - Date.now()));
144
+ // Invisible hCaptcha has no checkbox; do not spend ten seconds looking
145
+ // for one before invoking its documented execute path.
146
+ const interactionTimeout = captchaType === 'hcaptcha' ? 2500 : 10_000;
147
+ const clickTimeout = Math.min(interactionTimeout, Math.max(0, deadline - Date.now()));
137
148
  const clicked = await clickCheckbox(page, captchaType, clickTimeout)
138
149
  .catch((error) => {
139
150
  logs.push(`Local ${captchaType} checkbox click failed: ${error.message}`);
@@ -159,7 +170,7 @@ async function solveLocalCaptcha(page, { captchaType, timeout = 60_000, logs = [
159
170
  let challengeVisible = false;
160
171
  while (!challengeVisible && Date.now() < frameDeadline) {
161
172
  for (const frame of page.frames?.() || []) {
162
- if (!adapter.framePatterns.some((pattern) => frame.url().includes(pattern))) continue;
173
+ if (!frameMatches(frame, adapter.framePatterns)) continue;
163
174
  challengeVisible = await frame.locator(adapter.grid).first().isVisible({ timeout: 100 }).catch(() => false);
164
175
  if (challengeVisible) break;
165
176
  }
@@ -185,4 +196,4 @@ async function solveLocalCaptcha(page, { captchaType, timeout = 60_000, logs = [
185
196
  };
186
197
  }
187
198
 
188
- module.exports = { TOKEN_SELECTORS, WIDGET_INTERACTIONS, readToken, waitForToken, waitForTokenOrChallenge, clickCheckbox, executeHcaptchaWidget, solveLocalCaptcha };
199
+ module.exports = { TOKEN_SELECTORS, WIDGET_INTERACTIONS, frameMatches, readToken, waitForToken, waitForTokenOrChallenge, clickCheckbox, executeHcaptchaWidget, solveLocalCaptcha };