@opencraw/captcha-tesseract 0.0.45 → 0.0.47
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/dist/charset/charset.algorithm.d.ts +27 -0
- package/dist/charset/index.d.ts +2 -0
- package/dist/image-cleanup/clean-image.algorithm.d.ts +39 -0
- package/dist/image-cleanup/index.d.ts +4 -0
- package/dist/image-cleanup/respace-glyphs.algorithm.d.ts +15 -0
- package/dist/index.d.ts +6 -1
- package/dist/index.esm.js +117 -350
- package/dist/reader/index.d.ts +4 -0
- package/dist/reader/letter-case.algorithm.d.ts +19 -0
- package/dist/reader/read-check.policy.d.ts +25 -0
- package/dist/reader/reader.contract.d.ts +123 -0
- package/dist/reader/tesseract-engine.client.d.ts +32 -0
- package/dist/reader/tesseract-reader.use-case.d.ts +14 -0
- package/package.json +11 -25
|
@@ -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,39 @@
|
|
|
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
|
+
/** 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
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* Cleans a captcha image for OCR: grayscale, black ink on white (a threshold
|
|
21
|
+
* picked by Otsu's method unless given, turned the right way round), an
|
|
22
|
+
* optional median filter, scaled up with hard edges, and a white margin.
|
|
23
|
+
*
|
|
24
|
+
* Like photocopying a smudged note at high contrast before reading it.
|
|
25
|
+
*
|
|
26
|
+
* @param png - The image, as PNG bytes (a screenshot).
|
|
27
|
+
* @param options - The cleanup settings.
|
|
28
|
+
* @returns The cleaned image, as PNG bytes.
|
|
29
|
+
*/
|
|
30
|
+
export declare function cleanImage(png: Buffer, options?: CleanupOptions): Buffer;
|
|
31
|
+
/**
|
|
32
|
+
* The threshold that best splits the luminances in two groups (Otsu): the
|
|
33
|
+
* value maximising the variance between ink and ground.
|
|
34
|
+
*
|
|
35
|
+
* @param luminance - One 0–255 value per pixel.
|
|
36
|
+
* @returns The threshold: values at or under it are ink.
|
|
37
|
+
*/
|
|
38
|
+
export declare function otsuThreshold(luminance: Uint8Array): number;
|
|
39
|
+
//# sourceMappingURL=clean-image.algorithm.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
|
package/dist/index.d.ts
CHANGED
|
@@ -1 +1,6 @@
|
|
|
1
|
-
export
|
|
1
|
+
export { tesseractReader, judgeRead } from './reader';
|
|
2
|
+
export type { ChallengeLike, ImageRead, ReadSymbol, SolveContextLike, TesseractRead, TesseractReader, TesseractReaderOptions } from './reader';
|
|
3
|
+
export { expandCharset, keepCharset } from './charset';
|
|
4
|
+
export { cleanImage, otsuThreshold } from './image-cleanup';
|
|
5
|
+
export type { CleanupOptions } from './image-cleanup';
|
|
6
|
+
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.esm.js
CHANGED
|
@@ -1,44 +1,13 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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
|
-
*/
|
|
1
|
+
// src/charset/charset.algorithm.ts
|
|
2
|
+
var PRINTABLE = Array.from({ length: 126 - 33 + 1 }, (_, index) => String.fromCodePoint(33 + index));
|
|
26
3
|
function expandCharset(charset, caseSensitive) {
|
|
27
|
-
const listed = typeof charset ===
|
|
28
|
-
const characters = new Set(caseSensitive ? listed : listed.flatMap(character => [character, character.toLowerCase(), character.toUpperCase()]));
|
|
4
|
+
const listed = typeof charset === "string" ? expandRanges(charset) : PRINTABLE.filter((character) => new RegExp(charset.source, charset.flags.replaceAll("g", "")).test(character));
|
|
5
|
+
const characters = new Set(caseSensitive ? listed : listed.flatMap((character) => [character, character.toLowerCase(), character.toUpperCase()]));
|
|
29
6
|
if (characters.size === 0) throw new Error(`the charset ${String(charset)} allows no character`);
|
|
30
|
-
return [...characters].join(
|
|
7
|
+
return [...characters].join("");
|
|
31
8
|
}
|
|
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
9
|
function keepCharset(text, characters) {
|
|
41
|
-
return [...text].filter(character => characters.includes(character)).join(
|
|
10
|
+
return [...text].filter((character) => characters.includes(character)).join("");
|
|
42
11
|
}
|
|
43
12
|
function expandRanges(list) {
|
|
44
13
|
const characters = [];
|
|
@@ -46,7 +15,7 @@ function expandRanges(list) {
|
|
|
46
15
|
for (let index = 0; index < chars.length; index += 1) {
|
|
47
16
|
const from = chars[index];
|
|
48
17
|
const to = chars[index + 2];
|
|
49
|
-
if (to !==
|
|
18
|
+
if (to !== void 0 && chars[index + 1] === "-") {
|
|
50
19
|
const start = from.codePointAt(0) ?? 0;
|
|
51
20
|
const end = to.codePointAt(0) ?? 0;
|
|
52
21
|
if (end < start) throw new Error(`the charset range ${from}-${to} runs backwards`);
|
|
@@ -59,50 +28,27 @@ function expandRanges(list) {
|
|
|
59
28
|
return characters;
|
|
60
29
|
}
|
|
61
30
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
deflateLevel: 1
|
|
68
|
-
};
|
|
69
|
-
/**
|
|
70
|
-
* Cleans a captcha image for OCR: grayscale, black ink on white (a threshold
|
|
71
|
-
* picked by Otsu's method unless given, turned the right way round), an
|
|
72
|
-
* optional median filter, scaled up with hard edges, and a white margin.
|
|
73
|
-
*
|
|
74
|
-
* Like photocopying a smudged note at high contrast before reading it.
|
|
75
|
-
*
|
|
76
|
-
* @param png - The image, as PNG bytes (a screenshot).
|
|
77
|
-
* @param options - The cleanup settings.
|
|
78
|
-
* @returns The cleaned image, as PNG bytes.
|
|
79
|
-
*/
|
|
31
|
+
// src/image-cleanup/clean-image.algorithm.ts
|
|
32
|
+
import { PNG } from "pngjs";
|
|
33
|
+
var WHITE = 255;
|
|
34
|
+
var BLACK = 0;
|
|
35
|
+
var FAST_PNG = { filterType: 0, deflateLevel: 1 };
|
|
80
36
|
function cleanImage(png, options = {}) {
|
|
81
37
|
const image = PNG.sync.read(png);
|
|
82
38
|
const scale = Math.max(1, Math.round(options.scale ?? 3));
|
|
83
39
|
const padding = Math.max(0, Math.round(options.padding ?? 10));
|
|
84
|
-
// Scaled up smoothly before thresholding: anti-aliased edges become smooth curves, not staircases (an 8 stays an 8).
|
|
85
40
|
const width = image.width * scale;
|
|
86
41
|
const height = image.height * scale;
|
|
87
42
|
const luminance = upscale(luminanceOf(image.data, image.width * image.height), image.width, image.height, scale);
|
|
88
|
-
const threshold = options.threshold ===
|
|
89
|
-
let ink = luminance.map(value => value <= threshold ? 1 : 0);
|
|
43
|
+
const threshold = options.threshold === void 0 || options.threshold === "otsu" ? otsuThreshold(luminance) : options.threshold;
|
|
44
|
+
let ink = luminance.map((value) => value <= threshold ? 1 : 0);
|
|
90
45
|
const inkShare = ink.reduce((sum, value) => sum + value, 0) / ink.length;
|
|
91
|
-
if (options.invert === true || (options.invert ??
|
|
46
|
+
if (options.invert === true || (options.invert ?? "auto") === "auto" && inkShare > 0.5) ink = ink.map((value) => 1 - value);
|
|
92
47
|
if (options.median === true) ink = medianOf(ink, width, height);
|
|
93
48
|
return PNG.sync.write(render(ink, width, height, padding), FAST_PNG);
|
|
94
49
|
}
|
|
95
|
-
/**
|
|
96
|
-
* The threshold that best splits the luminances in two groups (Otsu): the
|
|
97
|
-
* value maximising the variance between ink and ground.
|
|
98
|
-
*
|
|
99
|
-
* @param luminance - One 0–255 value per pixel.
|
|
100
|
-
* @returns The threshold: values at or under it are ink.
|
|
101
|
-
*/
|
|
102
50
|
function otsuThreshold(luminance) {
|
|
103
|
-
const histogram = Array.from({
|
|
104
|
-
length: 256
|
|
105
|
-
}, () => 0);
|
|
51
|
+
const histogram = Array.from({ length: 256 }, () => 0);
|
|
106
52
|
for (const value of luminance) histogram[value] += 1;
|
|
107
53
|
const total = luminance.length;
|
|
108
54
|
const sum = histogram.reduce((accumulator, count, value) => accumulator + value * count, 0);
|
|
@@ -129,8 +75,7 @@ function luminanceOf(rgba, pixels) {
|
|
|
129
75
|
for (let pixel = 0; pixel < pixels; pixel += 1) {
|
|
130
76
|
const offset = pixel * 4;
|
|
131
77
|
const alpha = rgba[offset + 3] / 255;
|
|
132
|
-
|
|
133
|
-
const blend = channel => channel * alpha + WHITE$1 * (1 - alpha);
|
|
78
|
+
const blend = (channel) => channel * alpha + WHITE * (1 - alpha);
|
|
134
79
|
const red = blend(rgba[offset]);
|
|
135
80
|
const green = blend(rgba[offset + 1]);
|
|
136
81
|
const blue = blend(rgba[offset + 2]);
|
|
@@ -145,7 +90,6 @@ function medianOf(ink, width, height) {
|
|
|
145
90
|
}
|
|
146
91
|
return filtered;
|
|
147
92
|
}
|
|
148
|
-
/** 1 when most of the pixel's 3×3 neighbourhood (inside the image) is ink. */
|
|
149
93
|
function majorityAround(ink, width, height, x, y) {
|
|
150
94
|
let votes = 0;
|
|
151
95
|
let seen = 0;
|
|
@@ -159,11 +103,8 @@ function majorityAround(ink, width, height, x, y) {
|
|
|
159
103
|
}
|
|
160
104
|
function render(ink, width, height, padding) {
|
|
161
105
|
const outWidth = width + padding * 2;
|
|
162
|
-
const out = new PNG({
|
|
163
|
-
|
|
164
|
-
height: height + padding * 2
|
|
165
|
-
});
|
|
166
|
-
out.data.fill(WHITE$1);
|
|
106
|
+
const out = new PNG({ width: outWidth, height: height + padding * 2 });
|
|
107
|
+
out.data.fill(WHITE);
|
|
167
108
|
for (let y = 0; y < height; y += 1) {
|
|
168
109
|
for (let x = 0; x < width; x += 1) {
|
|
169
110
|
const start = ((y + padding) * outWidth + x + padding) * 4;
|
|
@@ -172,7 +113,6 @@ function render(ink, width, height, padding) {
|
|
|
172
113
|
}
|
|
173
114
|
return out;
|
|
174
115
|
}
|
|
175
|
-
/** Bilinear upscaling of a luminance map. */
|
|
176
116
|
function upscale(luminance, width, height, scale) {
|
|
177
117
|
if (scale === 1) return luminance;
|
|
178
118
|
const outWidth = width * scale;
|
|
@@ -194,30 +134,16 @@ function upscale(luminance, width, height, scale) {
|
|
|
194
134
|
return scaled;
|
|
195
135
|
}
|
|
196
136
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
*/
|
|
137
|
+
// src/image-cleanup/respace-glyphs.algorithm.ts
|
|
138
|
+
import { PNG as PNG2 } from "pngjs";
|
|
139
|
+
var WHITE2 = 255;
|
|
140
|
+
var INK_UNDER = 128;
|
|
212
141
|
function respaceGlyphs(png, gap) {
|
|
213
|
-
const image =
|
|
142
|
+
const image = PNG2.sync.read(png);
|
|
214
143
|
const runs = inkRuns(image);
|
|
215
144
|
const width = runs.reduce((total, [start, end]) => total + (end - start) + gap, gap);
|
|
216
|
-
const out = new
|
|
217
|
-
|
|
218
|
-
height: image.height
|
|
219
|
-
});
|
|
220
|
-
out.data.fill(WHITE);
|
|
145
|
+
const out = new PNG2({ width, height: image.height });
|
|
146
|
+
out.data.fill(WHITE2);
|
|
221
147
|
let at = gap;
|
|
222
148
|
for (const [start, end] of runs) {
|
|
223
149
|
for (let y = 0; y < image.height; y += 1) {
|
|
@@ -226,9 +152,8 @@ function respaceGlyphs(png, gap) {
|
|
|
226
152
|
}
|
|
227
153
|
at += end - start + gap;
|
|
228
154
|
}
|
|
229
|
-
return
|
|
155
|
+
return PNG2.sync.write(out, FAST_PNG);
|
|
230
156
|
}
|
|
231
|
-
/** The column ranges `[start, end)` that hold ink, left to right. */
|
|
232
157
|
function inkRuns(image) {
|
|
233
158
|
const runs = [];
|
|
234
159
|
let start = -1;
|
|
@@ -250,129 +175,67 @@ function hasInk(image, x) {
|
|
|
250
175
|
return false;
|
|
251
176
|
}
|
|
252
177
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
const DESCENDERS = new Set('gjpqy');
|
|
259
|
-
/** Characters as tall as a capital: digits and capitals. */
|
|
260
|
-
const CAPITAL_HEIGHT = /^[\dA-Z]$/;
|
|
261
|
-
/**
|
|
262
|
-
* Puts right the case of letters that Tesseract, reading one line with no
|
|
263
|
-
* reference, cannot tell by shape: `c`/`C`, `o`/`O`, `s`/`S`, `v`/`V`, `w`/`W`,
|
|
264
|
-
* `x`/`X`, `z`/`Z` by their height against the line's capitals and digits,
|
|
265
|
-
* `p`/`P` and `y`/`Y` by a tail under the line. (`j`/`J` are left: many
|
|
266
|
-
* fonts drop the capital under the line too.) Only letters whose two cases
|
|
267
|
-
* the charset both allows are changed; a line with no capital or digit to
|
|
268
|
-
* measure against is left as read.
|
|
269
|
-
*
|
|
270
|
-
* Like telling `o` from `O` in a handwritten code by comparing it with the `8`
|
|
271
|
-
* next to it.
|
|
272
|
-
*
|
|
273
|
-
* @param symbols - The characters read, with their boxes.
|
|
274
|
-
* @param characters - The charset.
|
|
275
|
-
* @returns The characters, with their case put right.
|
|
276
|
-
*/
|
|
178
|
+
// src/reader/letter-case.algorithm.ts
|
|
179
|
+
var SAME_SHAPE = new Set("cosuvwxz");
|
|
180
|
+
var TAILED = new Set("py");
|
|
181
|
+
var DESCENDERS = new Set("gjpqy");
|
|
182
|
+
var CAPITAL_HEIGHT = /^[\dA-Z]$/;
|
|
277
183
|
function fixLetterCase(symbols, characters) {
|
|
278
|
-
const boxed = symbols.filter(symbol => symbol.box !==
|
|
279
|
-
const capitals = boxed.filter(symbol => CAPITAL_HEIGHT.test(symbol.text) && !SAME_SHAPE.has(symbol.text.toLowerCase()) && !TAILED.has(symbol.text.toLowerCase()));
|
|
184
|
+
const boxed = symbols.filter((symbol) => symbol.box !== void 0);
|
|
185
|
+
const capitals = boxed.filter((symbol) => CAPITAL_HEIGHT.test(symbol.text) && !SAME_SHAPE.has(symbol.text.toLowerCase()) && !TAILED.has(symbol.text.toLowerCase()));
|
|
280
186
|
if (capitals.length === 0) return [...symbols];
|
|
281
|
-
const capital = median(capitals.map(symbol => heightOf(symbol)));
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
return symbols.map(symbol => {
|
|
187
|
+
const capital = median(capitals.map((symbol) => heightOf(symbol)));
|
|
188
|
+
const baseline = median(boxed.filter((symbol) => !DESCENDERS.has(symbol.text.toLowerCase())).map((symbol) => symbol.box?.y1 ?? 0));
|
|
189
|
+
return symbols.map((symbol) => {
|
|
285
190
|
const lower = symbol.text.toLowerCase();
|
|
286
191
|
const upper = symbol.text.toUpperCase();
|
|
287
|
-
if (lower === upper || symbol.box ===
|
|
192
|
+
if (lower === upper || symbol.box === void 0 || !characters.includes(lower) || !characters.includes(upper)) return symbol;
|
|
288
193
|
if (SAME_SHAPE.has(lower)) {
|
|
289
194
|
const ratio = heightOf(symbol) / capital;
|
|
290
|
-
if (ratio >= 0.86) return {
|
|
291
|
-
|
|
292
|
-
text: upper
|
|
293
|
-
};
|
|
294
|
-
if (ratio <= 0.8) return {
|
|
295
|
-
...symbol,
|
|
296
|
-
text: lower
|
|
297
|
-
};
|
|
195
|
+
if (ratio >= 0.86) return { ...symbol, text: upper };
|
|
196
|
+
if (ratio <= 0.8) return { ...symbol, text: lower };
|
|
298
197
|
}
|
|
299
|
-
if (TAILED.has(lower)) return {
|
|
300
|
-
...symbol,
|
|
301
|
-
text: symbol.box.y1 > baseline + capital * 0.12 ? lower : upper
|
|
302
|
-
};
|
|
198
|
+
if (TAILED.has(lower)) return { ...symbol, text: symbol.box.y1 > baseline + capital * 0.12 ? lower : upper };
|
|
303
199
|
return symbol;
|
|
304
200
|
});
|
|
305
201
|
}
|
|
306
202
|
function heightOf(symbol) {
|
|
307
|
-
return symbol.box ===
|
|
203
|
+
return symbol.box === void 0 ? 0 : symbol.box.y1 - symbol.box.y0;
|
|
308
204
|
}
|
|
309
205
|
function median(values) {
|
|
310
|
-
// A copy, sorted in place: the package targets ES2022, which has no toSorted.
|
|
311
206
|
const sorted = Float64Array.from(values).sort();
|
|
312
207
|
return sorted[Math.floor(sorted.length / 2)] ?? 0;
|
|
313
208
|
}
|
|
314
209
|
|
|
315
|
-
|
|
316
|
-
* Judges a read before anything is submitted: kept to the charset, it must
|
|
317
|
-
* have the code's length, and every character must be sure enough. This is
|
|
318
|
-
* the only check possible before posting the form, and it is free: a read
|
|
319
|
-
* that fails it is refreshed, never sent. A clipped last character is the
|
|
320
|
-
* usual failure: Tesseract reads it short, or unsure.
|
|
321
|
-
*
|
|
322
|
-
* @param raw - Tesseract's text.
|
|
323
|
-
* @param symbols - Its characters with their confidence.
|
|
324
|
-
* @param rules - The charset, the length, the least confidence.
|
|
325
|
-
* @returns The filtered text, its least confidence, and the problem, if any.
|
|
326
|
-
*/
|
|
210
|
+
// src/reader/read-check.policy.ts
|
|
327
211
|
function judgeRead(raw, symbols, rules) {
|
|
328
212
|
const text = keepCharset(raw, rules.characters);
|
|
329
|
-
const kept = symbols.filter(symbol => keepCharset(symbol.text, rules.characters) !==
|
|
330
|
-
const confidence = kept.length === 0 ? 0 : Math.round(Math.min(...kept.map(symbol => symbol.confidence)));
|
|
331
|
-
const [shortest, longest] = typeof rules.length ===
|
|
332
|
-
if (text.length < shortest || text.length > longest) return {
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
problem: `read ${text.length} characters ("${text}"), expected ${shortest === longest ? shortest : `${shortest} to ${longest}`}`
|
|
336
|
-
};
|
|
337
|
-
if (confidence < rules.minConfidence) return {
|
|
338
|
-
text,
|
|
339
|
-
confidence,
|
|
340
|
-
problem: `a character read at ${confidence}% confidence, under ${rules.minConfidence}%`
|
|
341
|
-
};
|
|
342
|
-
return {
|
|
343
|
-
text,
|
|
344
|
-
confidence
|
|
345
|
-
};
|
|
213
|
+
const kept = symbols.filter((symbol) => keepCharset(symbol.text, rules.characters) !== "");
|
|
214
|
+
const confidence = kept.length === 0 ? 0 : Math.round(Math.min(...kept.map((symbol) => symbol.confidence)));
|
|
215
|
+
const [shortest, longest] = typeof rules.length === "number" ? [rules.length, rules.length] : rules.length ?? [1, Infinity];
|
|
216
|
+
if (text.length < shortest || text.length > longest) return { text, confidence, problem: `read ${text.length} characters ("${text}"), expected ${shortest === longest ? shortest : `${shortest} to ${longest}`}` };
|
|
217
|
+
if (confidence < rules.minConfidence) return { text, confidence, problem: `a character read at ${confidence}% confidence, under ${rules.minConfidence}%` };
|
|
218
|
+
return { text, confidence };
|
|
346
219
|
}
|
|
347
220
|
|
|
348
|
-
|
|
221
|
+
// src/reader/tesseract-engine.client.ts
|
|
222
|
+
import { createRequire } from "node:module";
|
|
223
|
+
import { dirname, join } from "node:path";
|
|
224
|
+
import { createWorker, OEM } from "tesseract.js";
|
|
349
225
|
function bundledLangPath() {
|
|
350
|
-
const
|
|
351
|
-
return join(dirname(
|
|
226
|
+
const require2 = createRequire(import.meta.url);
|
|
227
|
+
return join(dirname(require2.resolve("@tesseract.js-data/eng/package.json")), "4.0.0_best_int");
|
|
352
228
|
}
|
|
353
|
-
|
|
354
|
-
* One Tesseract worker (a thread running Tesseract in WebAssembly), started
|
|
355
|
-
* on the first read and reused: reads queue on it. The language data comes
|
|
356
|
-
* from disk, never from a CDN, and nothing is cached in the working
|
|
357
|
-
* directory.
|
|
358
|
-
*/
|
|
359
|
-
class TesseractEngine {
|
|
360
|
-
options;
|
|
361
|
-
worker;
|
|
229
|
+
var TesseractEngine = class {
|
|
362
230
|
constructor(options) {
|
|
363
231
|
this.options = options;
|
|
364
232
|
}
|
|
233
|
+
options;
|
|
234
|
+
worker;
|
|
365
235
|
start() {
|
|
366
236
|
this.worker ??= (async () => {
|
|
367
|
-
const worker = await createWorker(this.options.lang, OEM.LSTM_ONLY, {
|
|
368
|
-
|
|
369
|
-
gzip: true,
|
|
370
|
-
cacheMethod: 'none'
|
|
371
|
-
});
|
|
372
|
-
await worker.setParameters({
|
|
373
|
-
tessedit_char_whitelist: this.options.whitelist,
|
|
374
|
-
tessedit_pageseg_mode: String(this.options.pageSegMode)
|
|
375
|
-
});
|
|
237
|
+
const worker = await createWorker(this.options.lang, OEM.LSTM_ONLY, { langPath: this.options.langPath ?? bundledLangPath(), gzip: true, cacheMethod: "none" });
|
|
238
|
+
await worker.setParameters({ tessedit_char_whitelist: this.options.whitelist, tessedit_pageseg_mode: String(this.options.pageSegMode) });
|
|
376
239
|
return worker;
|
|
377
240
|
})();
|
|
378
241
|
return this.worker;
|
|
@@ -385,211 +248,115 @@ class TesseractEngine {
|
|
|
385
248
|
*/
|
|
386
249
|
async read(png) {
|
|
387
250
|
const worker = await this.start();
|
|
388
|
-
const {
|
|
389
|
-
|
|
390
|
-
} = await worker.recognize(png, {}, {
|
|
391
|
-
text: true,
|
|
392
|
-
blocks: true
|
|
393
|
-
});
|
|
394
|
-
return {
|
|
395
|
-
raw: data.text.trim(),
|
|
396
|
-
symbols: symbolsOf(data.blocks ?? [])
|
|
397
|
-
};
|
|
251
|
+
const { data } = await worker.recognize(png, {}, { text: true, blocks: true });
|
|
252
|
+
return { raw: data.text.trim(), symbols: symbolsOf(data.blocks ?? []) };
|
|
398
253
|
}
|
|
399
254
|
/** Stops the worker thread, if it started. */
|
|
400
255
|
async close() {
|
|
401
256
|
const worker = this.worker;
|
|
402
|
-
this.worker =
|
|
403
|
-
if (worker ===
|
|
257
|
+
this.worker = void 0;
|
|
258
|
+
if (worker === void 0) return;
|
|
404
259
|
const started = await worker;
|
|
405
260
|
await started.terminate();
|
|
406
261
|
}
|
|
407
|
-
}
|
|
262
|
+
};
|
|
408
263
|
function symbolsOf(blocks) {
|
|
409
|
-
return blocks.flatMap(block => block.paragraphs.flatMap(paragraph => paragraph.lines.flatMap(line => line.words.flatMap(word => word.symbols.map(symbol => ({
|
|
410
|
-
text: symbol.text,
|
|
411
|
-
confidence: symbol.confidence,
|
|
412
|
-
box: symbol.bbox
|
|
413
|
-
}))))));
|
|
264
|
+
return blocks.flatMap((block) => block.paragraphs.flatMap((paragraph) => paragraph.lines.flatMap((line) => line.words.flatMap((word) => word.symbols.map((symbol) => ({ text: symbol.text, confidence: symbol.confidence, box: symbol.bbox }))))));
|
|
414
265
|
}
|
|
415
266
|
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
const SETTLE_LIMIT_MS = 5000;
|
|
424
|
-
/**
|
|
425
|
-
* A captcha reader for OpenCraw's form captchas: it reads the code in the
|
|
426
|
-
* challenge's image with Tesseract, refreshes the image while the read is not
|
|
427
|
-
* good enough (wrong length, an unsure character, and no form is posted for
|
|
428
|
-
* that), fills the challenge's answer field, and returns `solved`. The engine
|
|
429
|
-
* posts the form, checks the page and tells the verdict, which goes to
|
|
430
|
-
* `audit` with the read.
|
|
431
|
-
*
|
|
432
|
-
* @param options - The charset, the length, the checks, the cleanup, the audit.
|
|
433
|
-
* @returns The reader, to register as a captcha solver.
|
|
434
|
-
*/
|
|
267
|
+
// src/reader/tesseract-reader.use-case.ts
|
|
268
|
+
var DEFAULT_REFRESHES = 5;
|
|
269
|
+
var DEFAULT_MIN_CONFIDENCE = 30;
|
|
270
|
+
var DEFAULT_TIMEOUT_MS = 6e4;
|
|
271
|
+
var CROSS_CHECK_GAP = 10;
|
|
272
|
+
var SETTLE_MS = 300;
|
|
273
|
+
var SETTLE_LIMIT_MS = 5e3;
|
|
435
274
|
function tesseractReader(options = {}) {
|
|
436
275
|
const caseSensitive = options.caseSensitive ?? false;
|
|
437
|
-
const characters = expandCharset(options.charset ??
|
|
438
|
-
const rules = {
|
|
439
|
-
|
|
440
|
-
length: options.length,
|
|
441
|
-
minConfidence: options.minConfidence ?? DEFAULT_MIN_CONFIDENCE
|
|
442
|
-
};
|
|
443
|
-
const engine = new TesseractEngine({
|
|
444
|
-
lang: options.lang ?? 'eng',
|
|
445
|
-
langPath: options.langPath,
|
|
446
|
-
whitelist: characters,
|
|
447
|
-
pageSegMode: options.pageSegMode ?? 7
|
|
448
|
-
});
|
|
276
|
+
const characters = expandCharset(options.charset ?? "A-Za-z0-9", caseSensitive);
|
|
277
|
+
const rules = { characters, length: options.length, minConfidence: options.minConfidence ?? DEFAULT_MIN_CONFIDENCE };
|
|
278
|
+
const engine = new TesseractEngine({ lang: options.lang ?? "eng", langPath: options.langPath, whitelist: characters, pageSegMode: options.pageSegMode ?? 7 });
|
|
449
279
|
const refreshes = options.refreshes ?? DEFAULT_REFRESHES;
|
|
450
|
-
|
|
451
|
-
const
|
|
452
|
-
const audit = async (read, context) => {
|
|
280
|
+
const submitted = /* @__PURE__ */ new WeakMap();
|
|
281
|
+
const audit = async (read2, context) => {
|
|
453
282
|
try {
|
|
454
|
-
await options.audit?.(
|
|
283
|
+
await options.audit?.(read2);
|
|
455
284
|
} catch (error) {
|
|
456
|
-
context?.log(
|
|
285
|
+
context?.log("warn", `audit failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
457
286
|
}
|
|
458
287
|
};
|
|
459
288
|
const crossCheck = options.crossCheck ?? true;
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
const
|
|
463
|
-
|
|
464
|
-
symbols: read
|
|
465
|
-
} = await engine.read(cleaned);
|
|
466
|
-
// Tesseract sees one line with no size reference: the case of same-shape letters is put right by their size.
|
|
467
|
-
const symbols = caseSensitive ? fixLetterCase(read, characters) : read;
|
|
468
|
-
return {
|
|
469
|
-
raw,
|
|
470
|
-
symbols,
|
|
471
|
-
judged: judgeRead(caseSensitive ? symbols.map(symbol => symbol.text).join('') : raw, symbols, rules)
|
|
472
|
-
};
|
|
289
|
+
const readOnce = async (cleaned) => {
|
|
290
|
+
const { raw, symbols: read2 } = await engine.read(cleaned);
|
|
291
|
+
const symbols = caseSensitive ? fixLetterCase(read2, characters) : read2;
|
|
292
|
+
return { raw, symbols, judged: judgeRead(caseSensitive ? symbols.map((symbol) => symbol.text).join("") : raw, symbols, rules) };
|
|
473
293
|
};
|
|
474
|
-
const read = async png => {
|
|
294
|
+
const read = async (png) => {
|
|
475
295
|
const started = Date.now();
|
|
476
296
|
const cleaned = cleanImage(png, options.preprocess);
|
|
477
|
-
const {
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
} = await readOnce(cleaned);
|
|
482
|
-
const done = {
|
|
483
|
-
raw,
|
|
484
|
-
symbols,
|
|
485
|
-
image: png,
|
|
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));
|
|
297
|
+
const { raw, symbols, judged } = await readOnce(cleaned);
|
|
298
|
+
const done = { raw, symbols, image: png, cleaned };
|
|
299
|
+
if (!crossCheck || judged.problem !== void 0) return { ...judged, ...done, durationMs: Date.now() - started };
|
|
300
|
+
const { judged: second } = await readOnce(respaceGlyphs(cleaned, CROSS_CHECK_GAP));
|
|
496
301
|
const confidence = Math.min(judged.confidence, second.confidence);
|
|
497
302
|
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,
|
|
506
|
-
durationMs: Date.now() - started
|
|
507
|
-
};
|
|
303
|
+
return { text: judged.text, confidence, second: second.text, ...problem !== void 0 && { problem }, ...done, durationMs: Date.now() - started };
|
|
508
304
|
};
|
|
509
305
|
const solve = async (challenge, context) => {
|
|
510
|
-
const {
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
if (challenge.selector === undefined) return {
|
|
515
|
-
status: 'failed',
|
|
516
|
-
reason: `a ${challenge.kind} challenge has no image to read`
|
|
517
|
-
};
|
|
518
|
-
if (challenge.field === undefined) return {
|
|
519
|
-
status: 'failed',
|
|
520
|
-
reason: 'the captcha step names no field for the answer'
|
|
521
|
-
};
|
|
522
|
-
let problem = 'no read';
|
|
306
|
+
const { page, signal } = context;
|
|
307
|
+
if (challenge.selector === void 0) return { status: "failed", reason: `a ${challenge.kind} challenge has no image to read` };
|
|
308
|
+
if (challenge.field === void 0) return { status: "failed", reason: "the captcha step names no field for the answer" };
|
|
309
|
+
let problem = "no read";
|
|
523
310
|
for (let attempt = 1; attempt <= refreshes; attempt += 1) {
|
|
524
311
|
if (signal.aborted) break;
|
|
525
312
|
await settle(page, challenge.selector);
|
|
526
313
|
const result = await read(await page.locator(challenge.selector).first().screenshot());
|
|
527
|
-
const record = {
|
|
528
|
-
|
|
529
|
-
url: challenge.url,
|
|
530
|
-
attempt: context.attempt,
|
|
531
|
-
read: attempt,
|
|
532
|
-
outcome: 'refreshed'
|
|
533
|
-
};
|
|
534
|
-
if (result.problem === undefined) {
|
|
314
|
+
const record = { ...result, url: challenge.url, attempt: context.attempt, read: attempt, outcome: "refreshed" };
|
|
315
|
+
if (result.problem === void 0) {
|
|
535
316
|
await page.locator(challenge.field).first().fill(result.text);
|
|
536
317
|
submitted.set(challenge, record);
|
|
537
|
-
context.log(
|
|
538
|
-
return {
|
|
539
|
-
status: 'solved'
|
|
540
|
-
};
|
|
318
|
+
context.log("debug", `read "${result.text}" (${result.confidence}%)`);
|
|
319
|
+
return { status: "solved" };
|
|
541
320
|
}
|
|
542
321
|
problem = result.problem;
|
|
543
|
-
await audit({
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
}, context);
|
|
547
|
-
if (attempt === refreshes || challenge.refresh === undefined) break;
|
|
548
|
-
context.log('debug', `refreshing the image: ${problem}`);
|
|
322
|
+
await audit({ ...record, reason: problem }, context);
|
|
323
|
+
if (attempt === refreshes || challenge.refresh === void 0) break;
|
|
324
|
+
context.log("debug", `refreshing the image: ${problem}`);
|
|
549
325
|
await page.locator(challenge.refresh).first().click();
|
|
550
326
|
}
|
|
551
|
-
return {
|
|
552
|
-
status: 'failed',
|
|
553
|
-
reason: `no good read after ${refreshes} image${refreshes === 1 ? '' : 's'}: ${problem}`
|
|
554
|
-
};
|
|
327
|
+
return { status: "failed", reason: `no good read after ${refreshes} image${refreshes === 1 ? "" : "s"}: ${problem}` };
|
|
555
328
|
};
|
|
556
329
|
return {
|
|
557
|
-
name: options.name ??
|
|
330
|
+
name: options.name ?? "tesseract",
|
|
558
331
|
timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
559
332
|
solve,
|
|
560
333
|
read,
|
|
561
334
|
async verdict(challenge, verdict) {
|
|
562
335
|
const record = submitted.get(challenge);
|
|
563
|
-
if (record ===
|
|
336
|
+
if (record === void 0) return;
|
|
564
337
|
submitted.delete(challenge);
|
|
565
|
-
await audit(verdict.status ===
|
|
566
|
-
...record,
|
|
567
|
-
outcome: 'solved'
|
|
568
|
-
} : {
|
|
569
|
-
...record,
|
|
570
|
-
outcome: 'rejected',
|
|
571
|
-
reason: verdict.reason
|
|
572
|
-
});
|
|
338
|
+
await audit(verdict.status === "solved" ? { ...record, outcome: "solved" } : { ...record, outcome: "rejected", reason: verdict.reason });
|
|
573
339
|
},
|
|
574
340
|
close: () => engine.close()
|
|
575
341
|
};
|
|
576
342
|
}
|
|
577
|
-
/**
|
|
578
|
-
* Waits for the image to settle: its `src` unchanged for a moment and the
|
|
579
|
-
* element visible. Changing a filter can make a page draw a new captcha in
|
|
580
|
-
* the background; reading the old one would submit a stale code.
|
|
581
|
-
*/
|
|
582
343
|
async function settle(page, selector) {
|
|
583
344
|
const image = page.locator(selector).first();
|
|
584
345
|
const deadline = Date.now() + SETTLE_LIMIT_MS;
|
|
585
|
-
let source = await image.getAttribute(
|
|
586
|
-
for (
|
|
346
|
+
let source = await image.getAttribute("src");
|
|
347
|
+
for (; ; ) {
|
|
587
348
|
await page.waitForTimeout(SETTLE_MS);
|
|
588
|
-
const now = await image.getAttribute(
|
|
589
|
-
if (Date.now() >= deadline || now === source &&
|
|
349
|
+
const now = await image.getAttribute("src");
|
|
350
|
+
if (Date.now() >= deadline || now === source && await image.isVisible()) return;
|
|
590
351
|
source = now;
|
|
591
352
|
}
|
|
592
353
|
}
|
|
593
|
-
|
|
594
|
-
|
|
354
|
+
export {
|
|
355
|
+
cleanImage,
|
|
356
|
+
expandCharset,
|
|
357
|
+
judgeRead,
|
|
358
|
+
keepCharset,
|
|
359
|
+
otsuThreshold,
|
|
360
|
+
tesseractReader
|
|
361
|
+
};
|
|
595
362
|
//# sourceMappingURL=index.esm.js.map
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { tesseractReader } from './tesseract-reader.use-case';
|
|
2
|
+
export { judgeRead } from './read-check.policy';
|
|
3
|
+
export type { ChallengeLike, ImageRead, ReadSymbol, SolveContextLike, TesseractRead, TesseractReader, TesseractReaderOptions } from './reader.contract';
|
|
4
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { ReadSymbol } from './reader.contract';
|
|
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';
|
|
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,123 @@
|
|
|
1
|
+
import type { CleanupOptions } from '../image-cleanup';
|
|
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
|
+
/** 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. */
|
|
64
|
+
problem?: string;
|
|
65
|
+
/** The image as captured, and as Tesseract saw it after cleanup (PNG). */
|
|
66
|
+
image: Buffer;
|
|
67
|
+
cleaned: Buffer;
|
|
68
|
+
durationMs: number;
|
|
69
|
+
}
|
|
70
|
+
/** One read as the audit sees it, with how it ended. */
|
|
71
|
+
export interface TesseractRead extends ImageRead {
|
|
72
|
+
url: string;
|
|
73
|
+
/** The engine's attempt, from 1. */
|
|
74
|
+
attempt: number;
|
|
75
|
+
/** The read within the attempt, from 1: each refresh adds one. */
|
|
76
|
+
read: number;
|
|
77
|
+
/** `refreshed`: not good enough, never submitted. `rejected` / `solved`: submitted, and what the page said. */
|
|
78
|
+
outcome: 'refreshed' | 'rejected' | 'solved';
|
|
79
|
+
reason?: string;
|
|
80
|
+
}
|
|
81
|
+
export interface TesseractReaderOptions {
|
|
82
|
+
/** The name recipes use. Default `tesseract`. */
|
|
83
|
+
name?: string;
|
|
84
|
+
/** The characters the captcha uses: a list with ranges (`'A-Za-z0-9'`, the default) or a one-character RegExp class. */
|
|
85
|
+
charset?: string | RegExp;
|
|
86
|
+
/** The code's length, or its range. A read of another length is refreshed, never submitted. */
|
|
87
|
+
length?: number | [number, number];
|
|
88
|
+
/** Whether `a` and `A` differ. Default `false`: each letter allows its other case too. */
|
|
89
|
+
caseSensitive?: boolean;
|
|
90
|
+
/** The least confidence (0–100) every character must have. Default 30. */
|
|
91
|
+
minConfidence?: number;
|
|
92
|
+
/** Reads (after refreshing the image) before an attempt gives up. Default 5. */
|
|
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;
|
|
101
|
+
/** Image cleanup before OCR. */
|
|
102
|
+
preprocess?: CleanupOptions;
|
|
103
|
+
/** Tesseract's page segmentation mode: 7 one line (default), 8 one word, 13 a raw line. */
|
|
104
|
+
pageSegMode?: 7 | 8 | 13;
|
|
105
|
+
/** Tesseract language (default `eng`) and where its `.traineddata.gz` is (default: the bundled English model). */
|
|
106
|
+
lang?: string;
|
|
107
|
+
langPath?: string;
|
|
108
|
+
/** Called for every read; a throw is logged and ignored. */
|
|
109
|
+
audit?: (read: TesseractRead) => Promise<void> | void;
|
|
110
|
+
/** How long one attempt may take, refreshes included, when the recipe does not say. Default 60000. */
|
|
111
|
+
timeoutMs?: number;
|
|
112
|
+
}
|
|
113
|
+
/** The reader: an OpenCraw captcha solver, and `read` for reading an image directly. */
|
|
114
|
+
export interface TesseractReader {
|
|
115
|
+
readonly name: string;
|
|
116
|
+
readonly timeoutMs: number;
|
|
117
|
+
solve(challenge: ChallengeLike, context: SolveContextLike): Promise<SolveOutcome>;
|
|
118
|
+
verdict(challenge: ChallengeLike, verdict: SolveVerdict): Promise<void>;
|
|
119
|
+
close(): Promise<void>;
|
|
120
|
+
/** Reads a captcha image (PNG bytes) and judges the read, without a page. */
|
|
121
|
+
read(png: Buffer): Promise<ImageRead>;
|
|
122
|
+
}
|
|
123
|
+
//# sourceMappingURL=reader.contract.d.ts.map
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { ReadSymbol } from './reader.contract';
|
|
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';
|
|
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
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opencraw/captcha-tesseract",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.47",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.esm.js",
|
|
6
6
|
"module": "./dist/index.esm.js",
|
|
@@ -46,29 +46,15 @@
|
|
|
46
46
|
"node": ">=20"
|
|
47
47
|
},
|
|
48
48
|
"sideEffects": false,
|
|
49
|
-
"
|
|
50
|
-
"
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
}
|
|
49
|
+
"scripts": {
|
|
50
|
+
"build": "esbuild src/index.ts --bundle --packages=external --format=esm --outfile=dist/index.esm.js --sourcemap && tsc -p tsconfig.lib.json --emitDeclarationOnly --declaration --declarationDir dist/src --tsBuildInfoFile dist/src/tsconfig.lib.tsbuildinfo && node ../../declaration-specifiers.mjs dist/src",
|
|
51
|
+
"e2e": "tsc -p tsconfig.e2e.json && jest --config jest.e2e.config.cts",
|
|
52
|
+
"lint": "eslint .",
|
|
53
|
+
"test": "jest",
|
|
54
|
+
"typecheck": "tsc --build tsconfig.json --emitDeclarationOnly"
|
|
55
|
+
},
|
|
56
|
+
"devDependencies": {
|
|
57
|
+
"@opencraw/core": "^0.3.0",
|
|
58
|
+
"playwright": "^1.64.1"
|
|
73
59
|
}
|
|
74
60
|
}
|