@opencraw/captcha-tesseract 0.0.3 → 0.0.5

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
@@ -23,7 +23,7 @@ import { tesseractReader } from '@opencraw/captcha-tesseract'
23
23
 
24
24
  export const captchaSolvers = [
25
25
  tesseractReader({
26
- charset: 'A-Za-z0-9', // the characters the site uses: a list with ranges, or /[A-HJ-NP-Z2-9]/
26
+ charset: '1-9A-HJ-NP-Zabdefghnqrtu', // the characters the site draws: a list with ranges, or /[A-HJ-NP-Z2-9]/
27
27
  length: 6, // a read of any other length is refreshed, never submitted
28
28
  caseSensitive: true,
29
29
  audit: (read) => console.log(read.outcome, read.text, read.confidence),
@@ -63,7 +63,11 @@ Then name it in a form captcha step, placed after the steps that fill the form:
63
63
  6. **Check the read.** Only characters from the charset are kept. The read must be exactly `length` characters,
64
64
  and every character must be read with at least `minConfidence`. The least sure character decides, because
65
65
  Tesseract's whole-word confidence is too noisy for six loose characters.
66
- 7. **Refresh or fill.**
66
+ 7. **Cross-check it** (`crossCheck`, on by default). Tesseract reads a line with context, and it can bend a
67
+ character to fit its neighbours: a `4` after lowercase letters comes out `a` or `d`, at 90% confidence or more,
68
+ so no confidence threshold catches it. The reader pulls the characters apart (a white gap between each run of
69
+ inked columns) and reads the image again. The read passes only when both reads agree.
70
+ 8. **Refresh or fill.**
67
71
  - A read that fails the check makes the reader click `refresh` and read again, up to `refreshes` times. It
68
72
  never submits it. This is the only check possible before the form is posted, and it is free: refreshing
69
73
  posts no form and risks no lockout. A last character cut off by the image's edge is the usual failure.
@@ -80,7 +84,8 @@ verdict back to the reader, which passes it to `audit`.
80
84
  | `charset` | `A-Za-z0-9` | The characters the captcha uses: a list with `a-z` ranges, or a one-character RegExp class. |
81
85
  | `length` | any | The code's length, or `[min, max]`. |
82
86
  | `caseSensitive` | `false` | Whether `a` and `A` differ. When `false`, each letter allows its other case too. |
83
- | `minConfidence` | 50 | The least confidence (0–100) every character must have. |
87
+ | `minConfidence` | 30 | The least confidence (0–100) every character must have. A floor against junk reads: on real images the cross-check, not the confidence, catches the wrong reads. |
88
+ | `crossCheck` | `true` | Read the image a second time with the characters spaced apart, and submit only when both reads agree. |
84
89
  | `refreshes` | 5 | Reads (each after a refresh) before an attempt gives up. |
85
90
  | `preprocess` | | `{ scale: 3, threshold: 'otsu', invert: 'auto', median: false, padding: 10 }` |
86
91
  | `pageSegMode` | 7 | Tesseract page segmentation: `7` one line, `8` one word, `13` a raw line. `7` measured best. |
@@ -94,7 +99,7 @@ verdict back to the reader, which passes it to `audit`.
94
99
  ## The audit
95
100
 
96
101
  Each read reaches `audit` once, with:
97
- - `text` (filtered) and `raw` (as Tesseract read it);
102
+ - `text` (filtered) and `raw` (as Tesseract read it), and `second` (the cross-check's read, when one was made);
98
103
  - `confidence` (the least sure character) and `symbols` (each character with its confidence and box);
99
104
  - `image` and `cleaned` (PNG buffers of the captured image and of what Tesseract saw);
100
105
  - `url`, `attempt`, `read` (the read within the attempt), `durationMs`;
@@ -109,22 +114,31 @@ working reader, run the built-in `manual` solver headed: a person types the code
109
114
 
110
115
  ## Accuracy
111
116
 
112
- These figures were measured when the package was written.
113
-
114
- **The e2e corpus:** 6-character mixed-case codes in a bold sans-serif, 140×40, drawn like the Vahan report's.
115
- Over 80 codes:
116
- - 78% read right;
117
- - 10% refused and refreshed;
118
- - 13% read wrong and submitted, so the site refuses them and the engine tries again.
119
-
120
- Of the reads that were submitted, 86% were right. With 5 attempts, a captcha is left unsolved in fewer than one
121
- run in 10,000.
122
-
123
- **Real Vahan images:** of 5, 4 were read right, including one whose last character is clipped. The fifth was
124
- read with a doubled letter (7 characters), so it was refused and refreshed, never submitted.
125
-
126
- **The misses** are the letters mixed case makes ambiguous: `k`/`K`, `j`/`J`, `8`/`B`, `q`/`g`. A site with an
127
- unambiguous charset (no look-alikes, one case) reads better.
117
+ **Real Vahan images** (`e2e/corpus/vahan`): 100 captchas from the public report, collected on 2026-09-26 and
118
+ labelled by hand. A read is submitted **right**, submitted **wrong** (the site refuses it; a form post and a page
119
+ load lost), or **refused** (refreshed for free, never submitted):
120
+
121
+ | Settings | Right | Wrong | Refused |
122
+ |---|---|---|---|
123
+ | The site's charset, cross-check (the defaults otherwise) | 73 | 1 | 26 |
124
+ | The site's charset, no cross-check | 78 | 6 | 16 |
125
+ | `A-Za-z0-9`, no cross-check | 71 | 12 | 17 |
126
+
127
+ - **Name the charset the site really uses.** In 600 labelled characters, 16 never appear: `0 I O` and the
128
+ lowercase letters that look like capitals or like other glyphs (`c i j k l m o p s v w x y z`). Allowing
129
+ them doubles the wrong reads: `1` read as `l`, `J` as `j`.
130
+ - **Keep the cross-check.** It turns 5 of the 6 remaining wrong reads into refreshes. The one left is an image
131
+ whose last character is cut off by its edge. A captcha takes 1.35 reads per submitted code on average, and
132
+ 1.01 posts per solved one.
133
+ - **`minConfidence` barely matters.** The wrong reads are sure ones (90% and more). From 0 to 50 the threshold
134
+ only refuses right reads (75 right at 0, 73 at 30, 70 at 50), and catches none of the wrong ones once the
135
+ cross-check is on.
136
+ - **Clipped images** (16 of the 100, the last character cut off by the image's edge) are refused or read right,
137
+ except that one.
138
+
139
+ **The synthetic e2e corpus:** 6-character mixed-case codes drawn like the Vahan report's, but with every letter
140
+ of both cases. Over 80 codes, with the cross-check, 75% are right, 16% refused, 9% wrong. The misses are the
141
+ letters mixed case makes ambiguous (`j`/`J`, `s`/`S`, `y`/`Y`): both reads agree on the wrong case.
128
142
 
129
143
  Solving a site's captcha can break its terms of service. Read OpenCraw's [captcha guide](../../docs/recipes/captcha.md)
130
144
  first.
package/dist/index.esm.js CHANGED
@@ -59,8 +59,13 @@ function expandRanges(list) {
59
59
  return characters;
60
60
  }
61
61
 
62
- const WHITE = 255;
62
+ const WHITE$1 = 255;
63
63
  const BLACK = 0;
64
+ /** PNG output for Tesseract, not for storage: no row filter search and light compression, many times faster to write. */
65
+ const FAST_PNG = {
66
+ filterType: 0,
67
+ deflateLevel: 1
68
+ };
64
69
  /**
65
70
  * Cleans a captcha image for OCR: grayscale, black ink on white (a threshold
66
71
  * picked by Otsu's method unless given, turned the right way round), an
@@ -85,7 +90,7 @@ function cleanImage(png, options = {}) {
85
90
  const inkShare = ink.reduce((sum, value) => sum + value, 0) / ink.length;
86
91
  if (options.invert === true || (options.invert ?? 'auto') === 'auto' && inkShare > 0.5) ink = ink.map(value => 1 - value);
87
92
  if (options.median === true) ink = medianOf(ink, width, height);
88
- return PNG.sync.write(render(ink, width, height, padding));
93
+ return PNG.sync.write(render(ink, width, height, padding), FAST_PNG);
89
94
  }
90
95
  /**
91
96
  * The threshold that best splits the luminances in two groups (Otsu): the
@@ -125,7 +130,7 @@ function luminanceOf(rgba, pixels) {
125
130
  const offset = pixel * 4;
126
131
  const alpha = rgba[offset + 3] / 255;
127
132
  // A transparent pixel is the white page behind it.
128
- const blend = channel => channel * alpha + WHITE * (1 - alpha);
133
+ const blend = channel => channel * alpha + WHITE$1 * (1 - alpha);
129
134
  const red = blend(rgba[offset]);
130
135
  const green = blend(rgba[offset + 1]);
131
136
  const blue = blend(rgba[offset + 2]);
@@ -158,7 +163,7 @@ function render(ink, width, height, padding) {
158
163
  width: outWidth,
159
164
  height: height + padding * 2
160
165
  });
161
- out.data.fill(WHITE);
166
+ out.data.fill(WHITE$1);
162
167
  for (let y = 0; y < height; y += 1) {
163
168
  for (let x = 0; x < width; x += 1) {
164
169
  const start = ((y + padding) * outWidth + x + padding) * 4;
@@ -189,6 +194,62 @@ function upscale(luminance, width, height, scale) {
189
194
  return scaled;
190
195
  }
191
196
 
197
+ const WHITE = 255;
198
+ const INK_UNDER = 128;
199
+ /**
200
+ * Pulls the glyphs of a cleaned image apart: each run of columns holding ink
201
+ * (a character, or characters that touch) is laid out again with `gap` white
202
+ * columns between runs. Tesseract reads a line with context and can bend a
203
+ * character to fit its neighbours (a `4` after lowercase letters read as
204
+ * `a`); spaced apart, each is read more on its own shape.
205
+ *
206
+ * Like reading a word letter by letter with a finger under each one.
207
+ *
208
+ * @param png - A cleaned image (black ink on white), as PNG bytes.
209
+ * @param gap - White columns before, between and after the runs.
210
+ * @returns The respaced image, as PNG bytes; the same height.
211
+ */
212
+ function respaceGlyphs(png, gap) {
213
+ const image = PNG.sync.read(png);
214
+ const runs = inkRuns(image);
215
+ const width = runs.reduce((total, [start, end]) => total + (end - start) + gap, gap);
216
+ const out = new PNG({
217
+ width,
218
+ height: image.height
219
+ });
220
+ out.data.fill(WHITE);
221
+ let at = gap;
222
+ for (const [start, end] of runs) {
223
+ for (let y = 0; y < image.height; y += 1) {
224
+ const from = (y * image.width + start) * 4;
225
+ image.data.copy(out.data, (y * width + at) * 4, from, from + (end - start) * 4);
226
+ }
227
+ at += end - start + gap;
228
+ }
229
+ return PNG.sync.write(out, FAST_PNG);
230
+ }
231
+ /** The column ranges `[start, end)` that hold ink, left to right. */
232
+ function inkRuns(image) {
233
+ const runs = [];
234
+ let start = -1;
235
+ for (let x = 0; x < image.width; x += 1) {
236
+ const ink = hasInk(image, x);
237
+ if (ink && start < 0) start = x;
238
+ if (!ink && start >= 0) {
239
+ runs.push([start, x]);
240
+ start = -1;
241
+ }
242
+ }
243
+ if (start >= 0) runs.push([start, image.width]);
244
+ return runs;
245
+ }
246
+ function hasInk(image, x) {
247
+ for (let y = 0; y < image.height; y += 1) {
248
+ if (image.data[(y * image.width + x) * 4] < INK_UNDER) return true;
249
+ }
250
+ return false;
251
+ }
252
+
192
253
  /** Letters whose small and capital forms have the same shape: only their height tells them apart. */
193
254
  const SAME_SHAPE = new Set('cosuvwxz');
194
255
  /** Letters whose small form has a tail under the line, and whose capital does not. */
@@ -353,8 +414,10 @@ function symbolsOf(blocks) {
353
414
  }
354
415
 
355
416
  const DEFAULT_REFRESHES = 5;
356
- const DEFAULT_MIN_CONFIDENCE = 50;
417
+ const DEFAULT_MIN_CONFIDENCE = 30;
357
418
  const DEFAULT_TIMEOUT_MS = 60_000;
419
+ /** White columns (of the cleaned image) between glyphs for the cross-check read. */
420
+ const CROSS_CHECK_GAP = 10;
358
421
  /** An image counts as settled once its `src` stayed the same this long. */
359
422
  const SETTLE_MS = 300;
360
423
  const SETTLE_LIMIT_MS = 5000;
@@ -393,22 +456,53 @@ function tesseractReader(options = {}) {
393
456
  context?.log('warn', `audit failed: ${error instanceof Error ? error.message : String(error)}`);
394
457
  }
395
458
  };
396
- const read = async png => {
397
- const started = Date.now();
398
- const cleaned = cleanImage(png, options.preprocess);
459
+ const crossCheck = options.crossCheck ?? true;
460
+ /** One Tesseract read of a cleaned image, judged. */
461
+ const readOnce = async cleaned => {
399
462
  const {
400
463
  raw,
401
464
  symbols: read
402
465
  } = await engine.read(cleaned);
403
466
  // Tesseract sees one line with no size reference: the case of same-shape letters is put right by their size.
404
467
  const symbols = caseSensitive ? fixLetterCase(read, characters) : read;
405
- const judged = judgeRead(caseSensitive ? symbols.map(symbol => symbol.text).join('') : raw, symbols, rules);
406
468
  return {
407
- ...judged,
469
+ raw,
470
+ symbols,
471
+ judged: judgeRead(caseSensitive ? symbols.map(symbol => symbol.text).join('') : raw, symbols, rules)
472
+ };
473
+ };
474
+ const read = async png => {
475
+ const started = Date.now();
476
+ const cleaned = cleanImage(png, options.preprocess);
477
+ const {
478
+ raw,
479
+ symbols,
480
+ judged
481
+ } = await readOnce(cleaned);
482
+ const done = {
408
483
  raw,
409
484
  symbols,
410
485
  image: png,
411
- cleaned,
486
+ cleaned
487
+ };
488
+ if (!crossCheck || judged.problem !== undefined) return {
489
+ ...judged,
490
+ ...done,
491
+ durationMs: Date.now() - started
492
+ };
493
+ const {
494
+ judged: second
495
+ } = await readOnce(respaceGlyphs(cleaned, CROSS_CHECK_GAP));
496
+ const confidence = Math.min(judged.confidence, second.confidence);
497
+ const problem = second.text === judged.text ? second.problem : `a second read, with the characters spaced apart, gave "${second.text}"`;
498
+ return {
499
+ text: judged.text,
500
+ confidence,
501
+ second: second.text,
502
+ ...(problem !== undefined && {
503
+ problem
504
+ }),
505
+ ...done,
412
506
  durationMs: Date.now() - started
413
507
  };
414
508
  };
@@ -11,6 +11,11 @@ export interface CleanupOptions {
11
11
  /** White margin around the text, in output pixels: Tesseract misses glyphs touching the edge. Default 10. */
12
12
  padding?: number;
13
13
  }
14
+ /** PNG output for Tesseract, not for storage: no row filter search and light compression, many times faster to write. */
15
+ export declare const FAST_PNG: {
16
+ filterType: number;
17
+ deflateLevel: number;
18
+ };
14
19
  /**
15
20
  * Cleans a captcha image for OCR: grayscale, black ink on white (a threshold
16
21
  * picked by Otsu's method unless given, turned the right way round), an
@@ -1,3 +1,4 @@
1
1
  export { cleanImage, otsuThreshold } from './clean-image.algorithm.js';
2
2
  export type { CleanupOptions } from './clean-image.algorithm.js';
3
+ export { respaceGlyphs } from './respace-glyphs.algorithm.js';
3
4
  //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Pulls the glyphs of a cleaned image apart: each run of columns holding ink
3
+ * (a character, or characters that touch) is laid out again with `gap` white
4
+ * columns between runs. Tesseract reads a line with context and can bend a
5
+ * character to fit its neighbours (a `4` after lowercase letters read as
6
+ * `a`); spaced apart, each is read more on its own shape.
7
+ *
8
+ * Like reading a word letter by letter with a finger under each one.
9
+ *
10
+ * @param png - A cleaned image (black ink on white), as PNG bytes.
11
+ * @param gap - White columns before, between and after the runs.
12
+ * @returns The respaced image, as PNG bytes; the same height.
13
+ */
14
+ export declare function respaceGlyphs(png: Buffer, gap: number): Buffer;
15
+ //# sourceMappingURL=respace-glyphs.algorithm.d.ts.map
@@ -58,7 +58,9 @@ export interface ImageRead {
58
58
  /** The least sure character's confidence (0–100): whole-word confidence is too noisy on six loose characters. */
59
59
  confidence: number;
60
60
  symbols: ReadSymbol[];
61
- /** Why the read is not good enough to submit (a wrong length, a low confidence), when it is not. */
61
+ /** The cross-check's read, with the characters spaced apart, when one was made (the first read passed its checks). */
62
+ second?: string;
63
+ /** Why the read is not good enough to submit (a wrong length, a low confidence, a cross-check that disagreed), when it is not. */
62
64
  problem?: string;
63
65
  /** The image as captured, and as Tesseract saw it after cleanup (PNG). */
64
66
  image: Buffer;
@@ -85,10 +87,17 @@ export interface TesseractReaderOptions {
85
87
  length?: number | [number, number];
86
88
  /** Whether `a` and `A` differ. Default `false`: each letter allows its other case too. */
87
89
  caseSensitive?: boolean;
88
- /** The least confidence (0–100) every character must have. Default 50. */
90
+ /** The least confidence (0–100) every character must have. Default 30. */
89
91
  minConfidence?: number;
90
92
  /** Reads (after refreshing the image) before an attempt gives up. Default 5. */
91
93
  refreshes?: number;
94
+ /**
95
+ * Reads the code a second time with its characters spaced apart, and submits only when both reads agree.
96
+ * Tesseract reads a line with context and can bend a character to fit its neighbours (a `4` after lowercase
97
+ * letters read as `a`); read apart, it rarely makes the same mistake. Default `true`: on 100 real captchas it cut
98
+ * wrong submissions from 6 to 1, for a few more (free) refreshes and a second read of each passing image.
99
+ */
100
+ crossCheck?: boolean;
92
101
  /** Image cleanup before OCR. */
93
102
  preprocess?: CleanupOptions;
94
103
  /** Tesseract's page segmentation mode: 7 one line (default), 8 one word, 13 a raw line. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opencraw/captcha-tesseract",
3
- "version": "0.0.3",
3
+ "version": "0.0.5",
4
4
  "type": "module",
5
5
  "main": "./dist/index.esm.js",
6
6
  "module": "./dist/index.esm.js",