@opencraw/captcha-tesseract 0.0.4 → 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 +34 -20
- package/dist/index.esm.js +105 -11
- package/dist/src/image-cleanup/clean-image.algorithm.d.ts +5 -0
- package/dist/src/image-cleanup/index.d.ts +1 -0
- package/dist/src/image-cleanup/respace-glyphs.algorithm.d.ts +15 -0
- package/dist/src/reader/reader.contract.d.ts +11 -2
- package/package.json +1 -1
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: '
|
|
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. **
|
|
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` |
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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 =
|
|
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
|
|
397
|
-
|
|
398
|
-
|
|
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
|
-
|
|
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
|
|
@@ -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
|
-
/**
|
|
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
|
|
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. */
|