@opencraw/captcha-tesseract 0.0.2

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 ADDED
@@ -0,0 +1,130 @@
1
+ # @opencraw/captcha-tesseract
2
+
3
+ A captcha reader for [OpenCraw](../../README.md): it reads the code in an image captcha with Tesseract and fills
4
+ the form's answer field. The engine then posts the form, checks the page and retries.
5
+
6
+ It is meant for simple image captchas: a short code drawn in a clean font, like the public Vahan registrations
7
+ report's. Widget captchas (reCAPTCHA, hCaptcha, Turnstile) are behavioural checks with no text to read, and need a
8
+ widget solver instead.
9
+
10
+ ```sh
11
+ npm install @opencraw/captcha-tesseract
12
+ ```
13
+
14
+ Tesseract runs in WebAssembly, and the English model ships in the package, so nothing is downloaded at run time.
15
+
16
+ ## Use
17
+
18
+ Register the reader as a captcha solver:
19
+
20
+ ```js
21
+ // plugins.mjs (opencraw run --plugins plugins.mjs), or createCrawler({ captchaSolvers })
22
+ import { tesseractReader } from '@opencraw/captcha-tesseract'
23
+
24
+ export const captchaSolvers = [
25
+ tesseractReader({
26
+ charset: 'A-Za-z0-9', // the characters the site uses: a list with ranges, or /[A-HJ-NP-Z2-9]/
27
+ length: 6, // a read of any other length is refreshed, never submitted
28
+ caseSensitive: true,
29
+ audit: (read) => console.log(read.outcome, read.text, read.confidence),
30
+ }),
31
+ ]
32
+ ```
33
+
34
+ Then name it in a form captcha step, placed after the steps that fill the form:
35
+
36
+ ```json
37
+ { "type": "captcha", "solver": "tesseract",
38
+ "image": "#captchaImage",
39
+ "refresh": "#captchaImg",
40
+ "field": "#externalCaptcha",
41
+ "submit": [ { "type": "click", "selector": "#applyTrigger" } ],
42
+ "verify": { "selector": "#makerDynamicReportHeader", "failure": "#captchaMsg" },
43
+ "attempts": 5 }
44
+ ```
45
+
46
+ ## What one solve does
47
+
48
+ 1. **Wait for the image to settle.** Changing a filter can make a page draw a new captcha in the background, so the
49
+ reader waits until the image's `src` stops changing.
50
+ 2. **Screenshot the image element.** It never downloads the `src` again: many sites draw a new code on every
51
+ request, so a second download would show a different code from the one on screen.
52
+ 3. **Clean the image.**
53
+ - Scale it up 3× smoothly, before thresholding, so curves stay curves and an `8` stays an `8`.
54
+ - Threshold it to black ink on white. Otsu's method picks the cut, and light-on-dark text is inverted.
55
+ - Pad it with a white margin.
56
+ - Optionally, a median filter removes speckle.
57
+ 4. **Read it with Tesseract,** as one line of text, limited to the `charset`.
58
+ 5. **Fix the case of look-alike letters** (with `caseSensitive`):
59
+ - `c o s u v w x z` are told from their capitals by height, against the capitals and digits on the same line;
60
+ - `p` and `y` by the tail under the line.
61
+
62
+ Tesseract reads one line with no size reference, so it cannot tell `o` from `O` on its own.
63
+ 6. **Check the read.** Only characters from the charset are kept. The read must be exactly `length` characters,
64
+ and every character must be read with at least `minConfidence`. The least sure character decides, because
65
+ Tesseract's whole-word confidence is too noisy for six loose characters.
66
+ 7. **Refresh or fill.**
67
+ - A read that fails the check makes the reader click `refresh` and read again, up to `refreshes` times. It
68
+ never submits it. This is the only check possible before the form is posted, and it is free: refreshing
69
+ posts no form and risks no lockout. A last character cut off by the image's edge is the usual failure.
70
+ - A read that passes goes into the `field`, and the reader returns `solved`.
71
+
72
+ The engine then runs `submit`, reads `verify.selector` (accepted) or `verify.failure` (refused), and gives the
73
+ verdict back to the reader, which passes it to `audit`.
74
+
75
+ ## Options
76
+
77
+ | Option | Default | What it is |
78
+ |---|---|---|
79
+ | `name` | `tesseract` | The name recipes use. Register several readers under different names, one per site. |
80
+ | `charset` | `A-Za-z0-9` | The characters the captcha uses: a list with `a-z` ranges, or a one-character RegExp class. |
81
+ | `length` | any | The code's length, or `[min, max]`. |
82
+ | `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. |
84
+ | `refreshes` | 5 | Reads (each after a refresh) before an attempt gives up. |
85
+ | `preprocess` | | `{ scale: 3, threshold: 'otsu', invert: 'auto', median: false, padding: 10 }` |
86
+ | `pageSegMode` | 7 | Tesseract page segmentation: `7` one line, `8` one word, `13` a raw line. `7` measured best. |
87
+ | `lang`, `langPath` | the bundled English model | Another Tesseract language, and where its `.traineddata.gz` is. |
88
+ | `timeoutMs` | 60000 | Time one attempt may take, refreshes included, when the recipe sets none. |
89
+ | `audit` | | Called for every read. A throw is logged and ignored. |
90
+
91
+ `reader.read(png)` reads a PNG without a page, for measuring a site's captchas offline.
92
+ `reader.close()` stops the Tesseract worker; the crawler calls it when it closes.
93
+
94
+ ## The audit
95
+
96
+ Each read reaches `audit` once, with:
97
+ - `text` (filtered) and `raw` (as Tesseract read it);
98
+ - `confidence` (the least sure character) and `symbols` (each character with its confidence and box);
99
+ - `image` and `cleaned` (PNG buffers of the captured image and of what Tesseract saw);
100
+ - `url`, `attempt`, `read` (the read within the attempt), `durationMs`;
101
+ - `outcome`, plus `reason` when it failed:
102
+ - `refreshed`: failed the check, never submitted;
103
+ - `rejected`: submitted, and the site refused it;
104
+ - `solved`: submitted, and the site accepted it.
105
+
106
+ Every `solved` record pairs an image with its confirmed text. Use them to narrow `charset` to what the site really
107
+ draws, to set `minConfidence`, or later to fine-tune a Tesseract model for the site. To collect them without a
108
+ working reader, run the built-in `manual` solver headed: a person types the codes.
109
+
110
+ ## Accuracy
111
+
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.
128
+
129
+ Solving a site's captcha can break its terms of service. Read OpenCraw's [captcha guide](../../docs/recipes/captcha.md)
130
+ first.
@@ -0,0 +1 @@
1
+ export * from "./src/index.js";
@@ -0,0 +1,501 @@
1
+ import { PNG } from 'pngjs';
2
+ import { createRequire } from 'node:module';
3
+ import { join, dirname } from 'node:path';
4
+ import { createWorker, OEM } from 'tesseract.js';
5
+
6
+ /** The printable ASCII characters a RegExp charset is tested against. */
7
+ const PRINTABLE = Array.from({
8
+ length: 0x7E - 0x21 + 1
9
+ }, (_, index) => String.fromCodePoint(0x21 + index));
10
+ /**
11
+ * The characters a captcha may use, as one string without repeats: what
12
+ * Tesseract is limited to (`tessedit_char_whitelist`) and what a read is
13
+ * filtered through.
14
+ *
15
+ * A string lists characters, with `a-z` style ranges (`'A-Za-z0-9'`,
16
+ * `'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'`); a `-` first or last is itself. A
17
+ * RegExp is a one-character class (`/[A-HJ-NP-Z2-9]/`), tested against every
18
+ * printable ASCII character. Unless `caseSensitive`, each letter brings its
19
+ * other case: a case-blind site draws either.
20
+ *
21
+ * @param charset - The list, or the class.
22
+ * @param caseSensitive - Whether `a` and `A` are different characters.
23
+ * @returns The characters.
24
+ * @throws Error when the charset allows nothing.
25
+ */
26
+ function expandCharset(charset, caseSensitive) {
27
+ const listed = typeof charset === 'string' ? expandRanges(charset) : PRINTABLE.filter(character => new RegExp(charset.source, charset.flags.replaceAll('g', '')).test(character));
28
+ const characters = new Set(caseSensitive ? listed : listed.flatMap(character => [character, character.toLowerCase(), character.toUpperCase()]));
29
+ if (characters.size === 0) throw new Error(`the charset ${String(charset)} allows no character`);
30
+ return [...characters].join('');
31
+ }
32
+ /**
33
+ * A read kept to the charset: characters outside it (a stray `|` for an `l`, a
34
+ * space) are dropped.
35
+ *
36
+ * @param text - What Tesseract read.
37
+ * @param characters - From {@link expandCharset}.
38
+ * @returns The filtered read.
39
+ */
40
+ function keepCharset(text, characters) {
41
+ return [...text].filter(character => characters.includes(character)).join('');
42
+ }
43
+ function expandRanges(list) {
44
+ const characters = [];
45
+ const chars = [...list];
46
+ for (let index = 0; index < chars.length; index += 1) {
47
+ const from = chars[index];
48
+ const to = chars[index + 2];
49
+ if (to !== undefined && chars[index + 1] === '-') {
50
+ const start = from.codePointAt(0) ?? 0;
51
+ const end = to.codePointAt(0) ?? 0;
52
+ if (end < start) throw new Error(`the charset range ${from}-${to} runs backwards`);
53
+ for (let code = start; code <= end; code += 1) characters.push(String.fromCodePoint(code));
54
+ index += 2;
55
+ } else {
56
+ characters.push(from);
57
+ }
58
+ }
59
+ return characters;
60
+ }
61
+
62
+ const WHITE = 255;
63
+ const BLACK = 0;
64
+ /**
65
+ * Cleans a captcha image for OCR: grayscale, black ink on white (a threshold
66
+ * picked by Otsu's method unless given, turned the right way round), an
67
+ * optional median filter, scaled up with hard edges, and a white margin.
68
+ *
69
+ * Like photocopying a smudged note at high contrast before reading it.
70
+ *
71
+ * @param png - The image, as PNG bytes (a screenshot).
72
+ * @param options - The cleanup settings.
73
+ * @returns The cleaned image, as PNG bytes.
74
+ */
75
+ function cleanImage(png, options = {}) {
76
+ const image = PNG.sync.read(png);
77
+ const scale = Math.max(1, Math.round(options.scale ?? 3));
78
+ const padding = Math.max(0, Math.round(options.padding ?? 10));
79
+ // Scaled up smoothly before thresholding: anti-aliased edges become smooth curves, not staircases (an 8 stays an 8).
80
+ const width = image.width * scale;
81
+ const height = image.height * scale;
82
+ const luminance = upscale(luminanceOf(image.data, image.width * image.height), image.width, image.height, scale);
83
+ const threshold = options.threshold === undefined || options.threshold === 'otsu' ? otsuThreshold(luminance) : options.threshold;
84
+ let ink = luminance.map(value => value <= threshold ? 1 : 0);
85
+ const inkShare = ink.reduce((sum, value) => sum + value, 0) / ink.length;
86
+ if (options.invert === true || (options.invert ?? 'auto') === 'auto' && inkShare > 0.5) ink = ink.map(value => 1 - value);
87
+ if (options.median === true) ink = medianOf(ink, width, height);
88
+ return PNG.sync.write(render(ink, width, height, padding));
89
+ }
90
+ /**
91
+ * The threshold that best splits the luminances in two groups (Otsu): the
92
+ * value maximising the variance between ink and ground.
93
+ *
94
+ * @param luminance - One 0–255 value per pixel.
95
+ * @returns The threshold: values at or under it are ink.
96
+ */
97
+ function otsuThreshold(luminance) {
98
+ const histogram = Array.from({
99
+ length: 256
100
+ }, () => 0);
101
+ for (const value of luminance) histogram[value] += 1;
102
+ const total = luminance.length;
103
+ const sum = histogram.reduce((accumulator, count, value) => accumulator + value * count, 0);
104
+ let below = 0;
105
+ let belowSum = 0;
106
+ let best = -1;
107
+ let threshold = 127;
108
+ for (let value = 0; value < 256; value += 1) {
109
+ below += histogram[value];
110
+ if (below === 0) continue;
111
+ const above = total - below;
112
+ if (above === 0) break;
113
+ belowSum += value * histogram[value];
114
+ const between = below * above * (belowSum / below - (sum - belowSum) / above) ** 2;
115
+ if (between > best) {
116
+ best = between;
117
+ threshold = value;
118
+ }
119
+ }
120
+ return threshold;
121
+ }
122
+ function luminanceOf(rgba, pixels) {
123
+ const luminance = new Uint8Array(pixels);
124
+ for (let pixel = 0; pixel < pixels; pixel += 1) {
125
+ const offset = pixel * 4;
126
+ const alpha = rgba[offset + 3] / 255;
127
+ // A transparent pixel is the white page behind it.
128
+ const blend = channel => channel * alpha + WHITE * (1 - alpha);
129
+ const red = blend(rgba[offset]);
130
+ const green = blend(rgba[offset + 1]);
131
+ const blue = blend(rgba[offset + 2]);
132
+ luminance[pixel] = Math.round(0.299 * red + 0.587 * green + 0.114 * blue);
133
+ }
134
+ return luminance;
135
+ }
136
+ function medianOf(ink, width, height) {
137
+ const filtered = new Uint8Array(ink.length);
138
+ for (let y = 0; y < height; y += 1) {
139
+ for (let x = 0; x < width; x += 1) filtered[y * width + x] = majorityAround(ink, width, height, x, y);
140
+ }
141
+ return filtered;
142
+ }
143
+ /** 1 when most of the pixel's 3×3 neighbourhood (inside the image) is ink. */
144
+ function majorityAround(ink, width, height, x, y) {
145
+ let votes = 0;
146
+ let seen = 0;
147
+ for (let ny = Math.max(0, y - 1); ny <= Math.min(height - 1, y + 1); ny += 1) {
148
+ for (let nx = Math.max(0, x - 1); nx <= Math.min(width - 1, x + 1); nx += 1) {
149
+ votes += ink[ny * width + nx];
150
+ seen += 1;
151
+ }
152
+ }
153
+ return votes * 2 > seen ? 1 : 0;
154
+ }
155
+ function render(ink, width, height, padding) {
156
+ const outWidth = width + padding * 2;
157
+ const out = new PNG({
158
+ width: outWidth,
159
+ height: height + padding * 2
160
+ });
161
+ out.data.fill(WHITE);
162
+ for (let y = 0; y < height; y += 1) {
163
+ for (let x = 0; x < width; x += 1) {
164
+ const start = ((y + padding) * outWidth + x + padding) * 4;
165
+ if (ink[y * width + x] === 1) out.data.fill(BLACK, start, start + 3);
166
+ }
167
+ }
168
+ return out;
169
+ }
170
+ /** Bilinear upscaling of a luminance map. */
171
+ function upscale(luminance, width, height, scale) {
172
+ if (scale === 1) return luminance;
173
+ const outWidth = width * scale;
174
+ const scaled = new Uint8Array(outWidth * height * scale);
175
+ const at = (x, y) => luminance[Math.min(height - 1, y) * width + Math.min(width - 1, x)];
176
+ for (let y = 0; y < height * scale; y += 1) {
177
+ const sourceY = Math.max(0, (y + 0.5) / scale - 0.5);
178
+ const top = Math.floor(sourceY);
179
+ const down = sourceY - top;
180
+ for (let x = 0; x < outWidth; x += 1) {
181
+ const sourceX = Math.max(0, (x + 0.5) / scale - 0.5);
182
+ const left = Math.floor(sourceX);
183
+ const right = sourceX - left;
184
+ const upper = at(left, top) * (1 - right) + at(left + 1, top) * right;
185
+ const lower = at(left, top + 1) * (1 - right) + at(left + 1, top + 1) * right;
186
+ scaled[y * outWidth + x] = Math.round(upper * (1 - down) + lower * down);
187
+ }
188
+ }
189
+ return scaled;
190
+ }
191
+
192
+ /** Letters whose small and capital forms have the same shape: only their height tells them apart. */
193
+ const SAME_SHAPE = new Set('cosuvwxz');
194
+ /** Letters whose small form has a tail under the line, and whose capital does not. */
195
+ const TAILED = new Set('py');
196
+ /** Small letters that reach under the line: not used to find it. */
197
+ const DESCENDERS = new Set('gjpqy');
198
+ /** Characters as tall as a capital: digits and capitals. */
199
+ const CAPITAL_HEIGHT = /^[\dA-Z]$/;
200
+ /**
201
+ * Puts right the case of letters that Tesseract, reading one line with no
202
+ * reference, cannot tell by shape: `c`/`C`, `o`/`O`, `s`/`S`, `v`/`V`, `w`/`W`,
203
+ * `x`/`X`, `z`/`Z` by their height against the line's capitals and digits,
204
+ * `p`/`P` and `y`/`Y` by a tail under the line. (`j`/`J` are left: many
205
+ * fonts drop the capital under the line too.) Only letters whose two cases
206
+ * the charset both allows are changed; a line with no capital or digit to
207
+ * measure against is left as read.
208
+ *
209
+ * Like telling `o` from `O` in a handwritten code by comparing it with the `8`
210
+ * next to it.
211
+ *
212
+ * @param symbols - The characters read, with their boxes.
213
+ * @param characters - The charset.
214
+ * @returns The characters, with their case put right.
215
+ */
216
+ function fixLetterCase(symbols, characters) {
217
+ const boxed = symbols.filter(symbol => symbol.box !== undefined);
218
+ const capitals = boxed.filter(symbol => CAPITAL_HEIGHT.test(symbol.text) && !SAME_SHAPE.has(symbol.text.toLowerCase()) && !TAILED.has(symbol.text.toLowerCase()));
219
+ if (capitals.length === 0) return [...symbols];
220
+ const capital = median(capitals.map(symbol => heightOf(symbol)));
221
+ // Either case of a tailed letter may be misread, so neither helps find the line.
222
+ const baseline = median(boxed.filter(symbol => !DESCENDERS.has(symbol.text.toLowerCase())).map(symbol => symbol.box?.y1 ?? 0));
223
+ return symbols.map(symbol => {
224
+ const lower = symbol.text.toLowerCase();
225
+ const upper = symbol.text.toUpperCase();
226
+ if (lower === upper || symbol.box === undefined || !characters.includes(lower) || !characters.includes(upper)) return symbol;
227
+ if (SAME_SHAPE.has(lower)) {
228
+ const ratio = heightOf(symbol) / capital;
229
+ if (ratio >= 0.86) return {
230
+ ...symbol,
231
+ text: upper
232
+ };
233
+ if (ratio <= 0.8) return {
234
+ ...symbol,
235
+ text: lower
236
+ };
237
+ }
238
+ if (TAILED.has(lower)) return {
239
+ ...symbol,
240
+ text: symbol.box.y1 > baseline + capital * 0.12 ? lower : upper
241
+ };
242
+ return symbol;
243
+ });
244
+ }
245
+ function heightOf(symbol) {
246
+ return symbol.box === undefined ? 0 : symbol.box.y1 - symbol.box.y0;
247
+ }
248
+ function median(values) {
249
+ // A copy, sorted in place: the package targets ES2022, which has no toSorted.
250
+ const sorted = Float64Array.from(values).sort();
251
+ return sorted[Math.floor(sorted.length / 2)] ?? 0;
252
+ }
253
+
254
+ /**
255
+ * Judges a read before anything is submitted: kept to the charset, it must
256
+ * have the code's length, and every character must be sure enough. This is
257
+ * the only check possible before posting the form, and it is free: a read
258
+ * that fails it is refreshed, never sent. A clipped last character is the
259
+ * usual failure: Tesseract reads it short, or unsure.
260
+ *
261
+ * @param raw - Tesseract's text.
262
+ * @param symbols - Its characters with their confidence.
263
+ * @param rules - The charset, the length, the least confidence.
264
+ * @returns The filtered text, its least confidence, and the problem, if any.
265
+ */
266
+ function judgeRead(raw, symbols, rules) {
267
+ const text = keepCharset(raw, rules.characters);
268
+ const kept = symbols.filter(symbol => keepCharset(symbol.text, rules.characters) !== '');
269
+ const confidence = kept.length === 0 ? 0 : Math.round(Math.min(...kept.map(symbol => symbol.confidence)));
270
+ const [shortest, longest] = typeof rules.length === 'number' ? [rules.length, rules.length] : rules.length ?? [1, Infinity];
271
+ if (text.length < shortest || text.length > longest) return {
272
+ text,
273
+ confidence,
274
+ problem: `read ${text.length} characters ("${text}"), expected ${shortest === longest ? shortest : `${shortest} to ${longest}`}`
275
+ };
276
+ if (confidence < rules.minConfidence) return {
277
+ text,
278
+ confidence,
279
+ problem: `a character read at ${confidence}% confidence, under ${rules.minConfidence}%`
280
+ };
281
+ return {
282
+ text,
283
+ confidence
284
+ };
285
+ }
286
+
287
+ /** The bundled English model: the small LSTM one, which is what a line of loose characters needs. */
288
+ function bundledLangPath() {
289
+ const require = createRequire(import.meta.url);
290
+ return join(dirname(require.resolve('@tesseract.js-data/eng/package.json')), '4.0.0_best_int');
291
+ }
292
+ /**
293
+ * One Tesseract worker (a thread running Tesseract in WebAssembly), started
294
+ * on the first read and reused: reads queue on it. The language data comes
295
+ * from disk, never from a CDN, and nothing is cached in the working
296
+ * directory.
297
+ */
298
+ class TesseractEngine {
299
+ options;
300
+ worker;
301
+ constructor(options) {
302
+ this.options = options;
303
+ }
304
+ start() {
305
+ this.worker ??= (async () => {
306
+ const worker = await createWorker(this.options.lang, OEM.LSTM_ONLY, {
307
+ langPath: this.options.langPath ?? bundledLangPath(),
308
+ gzip: true,
309
+ cacheMethod: 'none'
310
+ });
311
+ await worker.setParameters({
312
+ tessedit_char_whitelist: this.options.whitelist,
313
+ tessedit_pageseg_mode: String(this.options.pageSegMode)
314
+ });
315
+ return worker;
316
+ })();
317
+ return this.worker;
318
+ }
319
+ /**
320
+ * Reads one image.
321
+ *
322
+ * @param png - The cleaned image.
323
+ * @returns The text and each character with its confidence.
324
+ */
325
+ async read(png) {
326
+ const worker = await this.start();
327
+ const {
328
+ data
329
+ } = await worker.recognize(png, {}, {
330
+ text: true,
331
+ blocks: true
332
+ });
333
+ return {
334
+ raw: data.text.trim(),
335
+ symbols: symbolsOf(data.blocks ?? [])
336
+ };
337
+ }
338
+ /** Stops the worker thread, if it started. */
339
+ async close() {
340
+ const worker = this.worker;
341
+ this.worker = undefined;
342
+ if (worker === undefined) return;
343
+ const started = await worker;
344
+ await started.terminate();
345
+ }
346
+ }
347
+ function symbolsOf(blocks) {
348
+ return blocks.flatMap(block => block.paragraphs.flatMap(paragraph => paragraph.lines.flatMap(line => line.words.flatMap(word => word.symbols.map(symbol => ({
349
+ text: symbol.text,
350
+ confidence: symbol.confidence,
351
+ box: symbol.bbox
352
+ }))))));
353
+ }
354
+
355
+ const DEFAULT_REFRESHES = 5;
356
+ const DEFAULT_MIN_CONFIDENCE = 50;
357
+ const DEFAULT_TIMEOUT_MS = 60_000;
358
+ /** An image counts as settled once its `src` stayed the same this long. */
359
+ const SETTLE_MS = 300;
360
+ const SETTLE_LIMIT_MS = 5000;
361
+ /**
362
+ * A captcha reader for OpenCraw's form captchas: it reads the code in the
363
+ * challenge's image with Tesseract, refreshes the image while the read is not
364
+ * good enough (wrong length, an unsure character, and no form is posted for
365
+ * that), fills the challenge's answer field, and returns `solved`. The engine
366
+ * posts the form, checks the page and tells the verdict, which goes to
367
+ * `audit` with the read.
368
+ *
369
+ * @param options - The charset, the length, the checks, the cleanup, the audit.
370
+ * @returns The reader, to register as a captcha solver.
371
+ */
372
+ function tesseractReader(options = {}) {
373
+ const caseSensitive = options.caseSensitive ?? false;
374
+ const characters = expandCharset(options.charset ?? 'A-Za-z0-9', caseSensitive);
375
+ const rules = {
376
+ characters,
377
+ length: options.length,
378
+ minConfidence: options.minConfidence ?? DEFAULT_MIN_CONFIDENCE
379
+ };
380
+ const engine = new TesseractEngine({
381
+ lang: options.lang ?? 'eng',
382
+ langPath: options.langPath,
383
+ whitelist: characters,
384
+ pageSegMode: options.pageSegMode ?? 7
385
+ });
386
+ const refreshes = options.refreshes ?? DEFAULT_REFRESHES;
387
+ /** The read an attempt submitted, waiting for the page's verdict. */
388
+ const submitted = new WeakMap();
389
+ const audit = async (read, context) => {
390
+ try {
391
+ await options.audit?.(read);
392
+ } catch (error) {
393
+ context?.log('warn', `audit failed: ${error instanceof Error ? error.message : String(error)}`);
394
+ }
395
+ };
396
+ const read = async png => {
397
+ const started = Date.now();
398
+ const cleaned = cleanImage(png, options.preprocess);
399
+ const {
400
+ raw,
401
+ symbols: read
402
+ } = await engine.read(cleaned);
403
+ // Tesseract sees one line with no size reference: the case of same-shape letters is put right by their size.
404
+ const symbols = caseSensitive ? fixLetterCase(read, characters) : read;
405
+ const judged = judgeRead(caseSensitive ? symbols.map(symbol => symbol.text).join('') : raw, symbols, rules);
406
+ return {
407
+ ...judged,
408
+ raw,
409
+ symbols,
410
+ image: png,
411
+ cleaned,
412
+ durationMs: Date.now() - started
413
+ };
414
+ };
415
+ const solve = async (challenge, context) => {
416
+ const {
417
+ page,
418
+ signal
419
+ } = context;
420
+ if (challenge.selector === undefined) return {
421
+ status: 'failed',
422
+ reason: `a ${challenge.kind} challenge has no image to read`
423
+ };
424
+ if (challenge.field === undefined) return {
425
+ status: 'failed',
426
+ reason: 'the captcha step names no field for the answer'
427
+ };
428
+ let problem = 'no read';
429
+ for (let attempt = 1; attempt <= refreshes; attempt += 1) {
430
+ if (signal.aborted) break;
431
+ await settle(page, challenge.selector);
432
+ const result = await read(await page.locator(challenge.selector).first().screenshot());
433
+ const record = {
434
+ ...result,
435
+ url: challenge.url,
436
+ attempt: context.attempt,
437
+ read: attempt,
438
+ outcome: 'refreshed'
439
+ };
440
+ if (result.problem === undefined) {
441
+ await page.locator(challenge.field).first().fill(result.text);
442
+ submitted.set(challenge, record);
443
+ context.log('debug', `read "${result.text}" (${result.confidence}%)`);
444
+ return {
445
+ status: 'solved'
446
+ };
447
+ }
448
+ problem = result.problem;
449
+ await audit({
450
+ ...record,
451
+ reason: problem
452
+ }, context);
453
+ if (attempt === refreshes || challenge.refresh === undefined) break;
454
+ context.log('debug', `refreshing the image: ${problem}`);
455
+ await page.locator(challenge.refresh).first().click();
456
+ }
457
+ return {
458
+ status: 'failed',
459
+ reason: `no good read after ${refreshes} image${refreshes === 1 ? '' : 's'}: ${problem}`
460
+ };
461
+ };
462
+ return {
463
+ name: options.name ?? 'tesseract',
464
+ timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
465
+ solve,
466
+ read,
467
+ async verdict(challenge, verdict) {
468
+ const record = submitted.get(challenge);
469
+ if (record === undefined) return;
470
+ submitted.delete(challenge);
471
+ await audit(verdict.status === 'solved' ? {
472
+ ...record,
473
+ outcome: 'solved'
474
+ } : {
475
+ ...record,
476
+ outcome: 'rejected',
477
+ reason: verdict.reason
478
+ });
479
+ },
480
+ close: () => engine.close()
481
+ };
482
+ }
483
+ /**
484
+ * Waits for the image to settle: its `src` unchanged for a moment and the
485
+ * element visible. Changing a filter can make a page draw a new captcha in
486
+ * the background; reading the old one would submit a stale code.
487
+ */
488
+ async function settle(page, selector) {
489
+ const image = page.locator(selector).first();
490
+ const deadline = Date.now() + SETTLE_LIMIT_MS;
491
+ let source = await image.getAttribute('src');
492
+ for (;;) {
493
+ await page.waitForTimeout(SETTLE_MS);
494
+ const now = await image.getAttribute('src');
495
+ if (Date.now() >= deadline || now === source && (await image.isVisible())) return;
496
+ source = now;
497
+ }
498
+ }
499
+
500
+ export { cleanImage, expandCharset, judgeRead, keepCharset, otsuThreshold, tesseractReader };
501
+ //# sourceMappingURL=index.esm.js.map
@@ -0,0 +1,27 @@
1
+ /**
2
+ * The characters a captcha may use, as one string without repeats: what
3
+ * Tesseract is limited to (`tessedit_char_whitelist`) and what a read is
4
+ * filtered through.
5
+ *
6
+ * A string lists characters, with `a-z` style ranges (`'A-Za-z0-9'`,
7
+ * `'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'`); a `-` first or last is itself. A
8
+ * RegExp is a one-character class (`/[A-HJ-NP-Z2-9]/`), tested against every
9
+ * printable ASCII character. Unless `caseSensitive`, each letter brings its
10
+ * other case: a case-blind site draws either.
11
+ *
12
+ * @param charset - The list, or the class.
13
+ * @param caseSensitive - Whether `a` and `A` are different characters.
14
+ * @returns The characters.
15
+ * @throws Error when the charset allows nothing.
16
+ */
17
+ export declare function expandCharset(charset: string | RegExp, caseSensitive: boolean): string;
18
+ /**
19
+ * A read kept to the charset: characters outside it (a stray `|` for an `l`, a
20
+ * space) are dropped.
21
+ *
22
+ * @param text - What Tesseract read.
23
+ * @param characters - From {@link expandCharset}.
24
+ * @returns The filtered read.
25
+ */
26
+ export declare function keepCharset(text: string, characters: string): string;
27
+ //# sourceMappingURL=charset.algorithm.d.ts.map
@@ -0,0 +1,2 @@
1
+ export { expandCharset, keepCharset } from './charset.algorithm.js';
2
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,34 @@
1
+ /** How a captcha image is cleaned before Tesseract reads it. */
2
+ export interface CleanupOptions {
3
+ /** How many times larger: Tesseract reads glyphs of 20 px and more best. Default 3. */
4
+ scale?: number;
5
+ /** The luminance (0–255) under which a pixel is ink; `'otsu'` (the default) finds it from the image. */
6
+ threshold?: number | 'otsu';
7
+ /** Light text on a dark ground is turned dark on light: `'auto'` (the default) when ink covers most of the image. */
8
+ invert?: boolean | 'auto';
9
+ /** A 3×3 median filter against salt-and-pepper noise, run after thresholding. Default off. */
10
+ median?: boolean;
11
+ /** White margin around the text, in output pixels: Tesseract misses glyphs touching the edge. Default 10. */
12
+ padding?: number;
13
+ }
14
+ /**
15
+ * Cleans a captcha image for OCR: grayscale, black ink on white (a threshold
16
+ * picked by Otsu's method unless given, turned the right way round), an
17
+ * optional median filter, scaled up with hard edges, and a white margin.
18
+ *
19
+ * Like photocopying a smudged note at high contrast before reading it.
20
+ *
21
+ * @param png - The image, as PNG bytes (a screenshot).
22
+ * @param options - The cleanup settings.
23
+ * @returns The cleaned image, as PNG bytes.
24
+ */
25
+ export declare function cleanImage(png: Buffer, options?: CleanupOptions): Buffer;
26
+ /**
27
+ * The threshold that best splits the luminances in two groups (Otsu): the
28
+ * value maximising the variance between ink and ground.
29
+ *
30
+ * @param luminance - One 0–255 value per pixel.
31
+ * @returns The threshold: values at or under it are ink.
32
+ */
33
+ export declare function otsuThreshold(luminance: Uint8Array): number;
34
+ //# sourceMappingURL=clean-image.algorithm.d.ts.map
@@ -0,0 +1,3 @@
1
+ export { cleanImage, otsuThreshold } from './clean-image.algorithm.js';
2
+ export type { CleanupOptions } from './clean-image.algorithm.js';
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,6 @@
1
+ export { tesseractReader, judgeRead } from './reader/index.js';
2
+ export type { ChallengeLike, ImageRead, ReadSymbol, SolveContextLike, TesseractRead, TesseractReader, TesseractReaderOptions } from './reader/index.js';
3
+ export { expandCharset, keepCharset } from './charset/index.js';
4
+ export { cleanImage, otsuThreshold } from './image-cleanup/index.js';
5
+ export type { CleanupOptions } from './image-cleanup/index.js';
6
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,4 @@
1
+ export { tesseractReader } from './tesseract-reader.use-case.js';
2
+ export { judgeRead } from './read-check.policy.js';
3
+ export type { ChallengeLike, ImageRead, ReadSymbol, SolveContextLike, TesseractRead, TesseractReader, TesseractReaderOptions } from './reader.contract.js';
4
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,19 @@
1
+ import type { ReadSymbol } from './reader.contract.js';
2
+ /**
3
+ * Puts right the case of letters that Tesseract, reading one line with no
4
+ * reference, cannot tell by shape: `c`/`C`, `o`/`O`, `s`/`S`, `v`/`V`, `w`/`W`,
5
+ * `x`/`X`, `z`/`Z` by their height against the line's capitals and digits,
6
+ * `p`/`P` and `y`/`Y` by a tail under the line. (`j`/`J` are left: many
7
+ * fonts drop the capital under the line too.) Only letters whose two cases
8
+ * the charset both allows are changed; a line with no capital or digit to
9
+ * measure against is left as read.
10
+ *
11
+ * Like telling `o` from `O` in a handwritten code by comparing it with the `8`
12
+ * next to it.
13
+ *
14
+ * @param symbols - The characters read, with their boxes.
15
+ * @param characters - The charset.
16
+ * @returns The characters, with their case put right.
17
+ */
18
+ export declare function fixLetterCase(symbols: readonly ReadSymbol[], characters: string): ReadSymbol[];
19
+ //# sourceMappingURL=letter-case.algorithm.d.ts.map
@@ -0,0 +1,25 @@
1
+ import type { ReadSymbol } from './reader.contract.js';
2
+ export interface ReadRules {
3
+ characters: string;
4
+ length?: number | [number, number];
5
+ minConfidence: number;
6
+ }
7
+ export interface JudgedRead {
8
+ text: string;
9
+ confidence: number;
10
+ problem?: string;
11
+ }
12
+ /**
13
+ * Judges a read before anything is submitted: kept to the charset, it must
14
+ * have the code's length, and every character must be sure enough. This is
15
+ * the only check possible before posting the form, and it is free: a read
16
+ * that fails it is refreshed, never sent. A clipped last character is the
17
+ * usual failure: Tesseract reads it short, or unsure.
18
+ *
19
+ * @param raw - Tesseract's text.
20
+ * @param symbols - Its characters with their confidence.
21
+ * @param rules - The charset, the length, the least confidence.
22
+ * @returns The filtered text, its least confidence, and the problem, if any.
23
+ */
24
+ export declare function judgeRead(raw: string, symbols: readonly ReadSymbol[], rules: ReadRules): JudgedRead;
25
+ //# sourceMappingURL=read-check.policy.d.ts.map
@@ -0,0 +1,114 @@
1
+ import type { CleanupOptions } from '../image-cleanup/index.js';
2
+ /** An element on the page, as Playwright's `Locator` offers it. */
3
+ export interface ElementHandleLike {
4
+ first: () => ElementHandleLike;
5
+ screenshot: () => Promise<Buffer>;
6
+ click: () => Promise<void>;
7
+ fill: (value: string) => Promise<void>;
8
+ getAttribute: (name: string) => Promise<string | null>;
9
+ isVisible: () => Promise<boolean>;
10
+ }
11
+ export interface PageLike {
12
+ locator: (selector: string) => ElementHandleLike;
13
+ waitForTimeout: (ms: number) => Promise<void>;
14
+ }
15
+ /** The challenge as the engine hands it over: the image's selector, and the form's field and refresh control. */
16
+ export interface ChallengeLike {
17
+ kind: string;
18
+ url: string;
19
+ selector?: string;
20
+ field?: string;
21
+ refresh?: string;
22
+ }
23
+ export interface SolveContextLike {
24
+ page: PageLike;
25
+ attempt: number;
26
+ signal: AbortSignal;
27
+ log: (level: 'debug' | 'info' | 'warn' | 'error', message: string, meta?: Record<string, unknown>) => void;
28
+ }
29
+ export type SolveOutcome = {
30
+ status: 'solved';
31
+ } | {
32
+ status: 'failed';
33
+ reason: string;
34
+ };
35
+ export type SolveVerdict = {
36
+ status: 'solved';
37
+ } | {
38
+ status: 'rejected';
39
+ reason: string;
40
+ };
41
+ /** One character Tesseract read, how sure it was (0–100), and where (pixels of the cleaned image). */
42
+ export interface ReadSymbol {
43
+ text: string;
44
+ confidence: number;
45
+ box?: {
46
+ x0: number;
47
+ y0: number;
48
+ x1: number;
49
+ y1: number;
50
+ };
51
+ }
52
+ /** What one reading of an image gave. */
53
+ export interface ImageRead {
54
+ /** What was read, kept to the charset. */
55
+ text: string;
56
+ /** Tesseract's text before filtering. */
57
+ raw: string;
58
+ /** The least sure character's confidence (0–100): whole-word confidence is too noisy on six loose characters. */
59
+ confidence: number;
60
+ symbols: ReadSymbol[];
61
+ /** Why the read is not good enough to submit (a wrong length, a low confidence), when it is not. */
62
+ problem?: string;
63
+ /** The image as captured, and as Tesseract saw it after cleanup (PNG). */
64
+ image: Buffer;
65
+ cleaned: Buffer;
66
+ durationMs: number;
67
+ }
68
+ /** One read as the audit sees it, with how it ended. */
69
+ export interface TesseractRead extends ImageRead {
70
+ url: string;
71
+ /** The engine's attempt, from 1. */
72
+ attempt: number;
73
+ /** The read within the attempt, from 1: each refresh adds one. */
74
+ read: number;
75
+ /** `refreshed`: not good enough, never submitted. `rejected` / `solved`: submitted, and what the page said. */
76
+ outcome: 'refreshed' | 'rejected' | 'solved';
77
+ reason?: string;
78
+ }
79
+ export interface TesseractReaderOptions {
80
+ /** The name recipes use. Default `tesseract`. */
81
+ name?: string;
82
+ /** The characters the captcha uses: a list with ranges (`'A-Za-z0-9'`, the default) or a one-character RegExp class. */
83
+ charset?: string | RegExp;
84
+ /** The code's length, or its range. A read of another length is refreshed, never submitted. */
85
+ length?: number | [number, number];
86
+ /** Whether `a` and `A` differ. Default `false`: each letter allows its other case too. */
87
+ caseSensitive?: boolean;
88
+ /** The least confidence (0–100) every character must have. Default 50. */
89
+ minConfidence?: number;
90
+ /** Reads (after refreshing the image) before an attempt gives up. Default 5. */
91
+ refreshes?: number;
92
+ /** Image cleanup before OCR. */
93
+ preprocess?: CleanupOptions;
94
+ /** Tesseract's page segmentation mode: 7 one line (default), 8 one word, 13 a raw line. */
95
+ pageSegMode?: 7 | 8 | 13;
96
+ /** Tesseract language (default `eng`) and where its `.traineddata.gz` is (default: the bundled English model). */
97
+ lang?: string;
98
+ langPath?: string;
99
+ /** Called for every read; a throw is logged and ignored. */
100
+ audit?: (read: TesseractRead) => Promise<void> | void;
101
+ /** How long one attempt may take, refreshes included, when the recipe does not say. Default 60000. */
102
+ timeoutMs?: number;
103
+ }
104
+ /** The reader: an OpenCraw captcha solver, and `read` for reading an image directly. */
105
+ export interface TesseractReader {
106
+ readonly name: string;
107
+ readonly timeoutMs: number;
108
+ solve(challenge: ChallengeLike, context: SolveContextLike): Promise<SolveOutcome>;
109
+ verdict(challenge: ChallengeLike, verdict: SolveVerdict): Promise<void>;
110
+ close(): Promise<void>;
111
+ /** Reads a captcha image (PNG bytes) and judges the read, without a page. */
112
+ read(png: Buffer): Promise<ImageRead>;
113
+ }
114
+ //# sourceMappingURL=reader.contract.d.ts.map
@@ -0,0 +1,32 @@
1
+ import type { ReadSymbol } from './reader.contract.js';
2
+ export interface EngineOptions {
3
+ lang: string;
4
+ langPath?: string;
5
+ whitelist: string;
6
+ pageSegMode: 7 | 8 | 13;
7
+ }
8
+ /**
9
+ * One Tesseract worker (a thread running Tesseract in WebAssembly), started
10
+ * on the first read and reused: reads queue on it. The language data comes
11
+ * from disk, never from a CDN, and nothing is cached in the working
12
+ * directory.
13
+ */
14
+ export declare class TesseractEngine {
15
+ private readonly options;
16
+ private worker;
17
+ constructor(options: EngineOptions);
18
+ private start;
19
+ /**
20
+ * Reads one image.
21
+ *
22
+ * @param png - The cleaned image.
23
+ * @returns The text and each character with its confidence.
24
+ */
25
+ read(png: Buffer): Promise<{
26
+ raw: string;
27
+ symbols: ReadSymbol[];
28
+ }>;
29
+ /** Stops the worker thread, if it started. */
30
+ close(): Promise<void>;
31
+ }
32
+ //# sourceMappingURL=tesseract-engine.client.d.ts.map
@@ -0,0 +1,14 @@
1
+ import type { TesseractReader, TesseractReaderOptions } from './reader.contract.js';
2
+ /**
3
+ * A captcha reader for OpenCraw's form captchas: it reads the code in the
4
+ * challenge's image with Tesseract, refreshes the image while the read is not
5
+ * good enough (wrong length, an unsure character, and no form is posted for
6
+ * that), fills the challenge's answer field, and returns `solved`. The engine
7
+ * posts the form, checks the page and tells the verdict, which goes to
8
+ * `audit` with the read.
9
+ *
10
+ * @param options - The charset, the length, the checks, the cleanup, the audit.
11
+ * @returns The reader, to register as a captcha solver.
12
+ */
13
+ export declare function tesseractReader(options?: TesseractReaderOptions): TesseractReader;
14
+ //# sourceMappingURL=tesseract-reader.use-case.d.ts.map
package/package.json ADDED
@@ -0,0 +1,74 @@
1
+ {
2
+ "name": "@opencraw/captcha-tesseract",
3
+ "version": "0.0.2",
4
+ "type": "module",
5
+ "main": "./dist/index.esm.js",
6
+ "module": "./dist/index.esm.js",
7
+ "types": "./dist/src/index.d.ts",
8
+ "exports": {
9
+ "./package.json": "./package.json",
10
+ ".": {
11
+ "types": "./dist/src/index.d.ts",
12
+ "import": "./dist/index.esm.js",
13
+ "default": "./dist/index.esm.js"
14
+ }
15
+ },
16
+ "files": [
17
+ "dist",
18
+ "!**/*.tsbuildinfo",
19
+ "!**/*.d.ts.map",
20
+ "!**/*.js.map"
21
+ ],
22
+ "dependencies": {
23
+ "@tesseract.js-data/eng": "^1.0.0",
24
+ "pngjs": "^7.0.0",
25
+ "tesseract.js": "^7.0.0"
26
+ },
27
+ "publishConfig": {
28
+ "access": "public"
29
+ },
30
+ "description": "An image-captcha reader for OpenCraw's form captchas: Tesseract (WebAssembly, bundled English model, nothing fetched at run time) reads the code, a read of the wrong length or with an unsure character is refreshed instead of submitted, and every read can be audited with the page's verdict.",
31
+ "license": "MIT",
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/russoedu/open.craw.git",
35
+ "directory": "packages/captcha-tesseract"
36
+ },
37
+ "keywords": [
38
+ "captcha",
39
+ "ocr",
40
+ "tesseract",
41
+ "crawler",
42
+ "opencraw",
43
+ "image-captcha"
44
+ ],
45
+ "engines": {
46
+ "node": ">=20"
47
+ },
48
+ "sideEffects": false,
49
+ "nx": {
50
+ "targets": {
51
+ "e2e": {
52
+ "executor": "nx:run-commands",
53
+ "dependsOn": [
54
+ "build",
55
+ {
56
+ "projects": [
57
+ "@opencraw/core"
58
+ ],
59
+ "target": "build"
60
+ }
61
+ ],
62
+ "inputs": [
63
+ "default",
64
+ "^production",
65
+ "{workspaceRoot}/packages/core/e2e/**"
66
+ ],
67
+ "options": {
68
+ "command": "tsc -p tsconfig.e2e.json && jest --config jest.e2e.config.cts",
69
+ "cwd": "packages/captcha-tesseract"
70
+ }
71
+ }
72
+ }
73
+ }
74
+ }