@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.
@@ -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';
2
+ //# sourceMappingURL=index.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,4 @@
1
+ export { cleanImage, otsuThreshold } from './clean-image.algorithm';
2
+ export type { CleanupOptions } from './clean-image.algorithm';
3
+ export { respaceGlyphs } from './respace-glyphs.algorithm';
4
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Pulls the glyphs of a cleaned image apart: each run of columns holding ink
3
+ * (a character, or characters that touch) is laid out again with `gap` white
4
+ * columns between runs. Tesseract reads a line with context and can bend a
5
+ * character to fit its neighbours (a `4` after lowercase letters read as
6
+ * `a`); spaced apart, each is read more on its own shape.
7
+ *
8
+ * Like reading a word letter by letter with a finger under each one.
9
+ *
10
+ * @param png - A cleaned image (black ink on white), as PNG bytes.
11
+ * @param gap - White columns before, between and after the runs.
12
+ * @returns The respaced image, as PNG bytes; the same height.
13
+ */
14
+ export declare function respaceGlyphs(png: Buffer, gap: number): Buffer;
15
+ //# sourceMappingURL=respace-glyphs.algorithm.d.ts.map
package/dist/index.d.ts CHANGED
@@ -1 +1,6 @@
1
- export * from "./src/index.js";
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
- 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
- */
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 === '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()]));
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 !== undefined && chars[index + 1] === '-') {
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
- const WHITE$1 = 255;
63
- const BLACK = 0;
64
- /** PNG output for Tesseract, not for storage: no row filter search and light compression, many times faster to write. */
65
- const FAST_PNG = {
66
- filterType: 0,
67
- deflateLevel: 1
68
- };
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 === undefined || options.threshold === 'otsu' ? otsuThreshold(luminance) : 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 ?? 'auto') === 'auto' && inkShare > 0.5) ink = ink.map(value => 1 - value);
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
- // A transparent pixel is the white page behind it.
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
- width: outWidth,
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
- const WHITE = 255;
198
- const INK_UNDER = 128;
199
- /**
200
- * Pulls the glyphs of a cleaned image apart: each run of columns holding ink
201
- * (a character, or characters that touch) is laid out again with `gap` white
202
- * columns between runs. Tesseract reads a line with context and can bend a
203
- * character to fit its neighbours (a `4` after lowercase letters read as
204
- * `a`); spaced apart, each is read more on its own shape.
205
- *
206
- * Like reading a word letter by letter with a finger under each one.
207
- *
208
- * @param png - A cleaned image (black ink on white), as PNG bytes.
209
- * @param gap - White columns before, between and after the runs.
210
- * @returns The respaced image, as PNG bytes; the same height.
211
- */
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 = PNG.sync.read(png);
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 PNG({
217
- width,
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 PNG.sync.write(out, FAST_PNG);
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
- /** Letters whose small and capital forms have the same shape: only their height tells them apart. */
254
- const SAME_SHAPE = new Set('cosuvwxz');
255
- /** Letters whose small form has a tail under the line, and whose capital does not. */
256
- const TAILED = new Set('py');
257
- /** Small letters that reach under the line: not used to find it. */
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 !== undefined);
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
- // Either case of a tailed letter may be misread, so neither helps find the line.
283
- const baseline = median(boxed.filter(symbol => !DESCENDERS.has(symbol.text.toLowerCase())).map(symbol => symbol.box?.y1 ?? 0));
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 === undefined || !characters.includes(lower) || !characters.includes(upper)) return symbol;
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
- ...symbol,
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 === undefined ? 0 : symbol.box.y1 - symbol.box.y0;
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 === 'number' ? [rules.length, rules.length] : rules.length ?? [1, Infinity];
332
- if (text.length < shortest || text.length > longest) return {
333
- text,
334
- confidence,
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
- /** The bundled English model: the small LSTM one, which is what a line of loose characters needs. */
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 require = createRequire(import.meta.url);
351
- return join(dirname(require.resolve('@tesseract.js-data/eng/package.json')), '4.0.0_best_int');
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
- langPath: this.options.langPath ?? bundledLangPath(),
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
- data
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 = undefined;
403
- if (worker === undefined) return;
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
- const DEFAULT_REFRESHES = 5;
417
- const DEFAULT_MIN_CONFIDENCE = 30;
418
- const DEFAULT_TIMEOUT_MS = 60_000;
419
- /** White columns (of the cleaned image) between glyphs for the cross-check read. */
420
- const CROSS_CHECK_GAP = 10;
421
- /** An image counts as settled once its `src` stayed the same this long. */
422
- const SETTLE_MS = 300;
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 ?? 'A-Za-z0-9', caseSensitive);
438
- const rules = {
439
- characters,
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
- /** The read an attempt submitted, waiting for the page's verdict. */
451
- const submitted = new WeakMap();
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?.(read);
283
+ await options.audit?.(read2);
455
284
  } catch (error) {
456
- context?.log('warn', `audit failed: ${error instanceof Error ? error.message : String(error)}`);
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
- /** One Tesseract read of a cleaned image, judged. */
461
- const readOnce = async cleaned => {
462
- const {
463
- raw,
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
- raw,
479
- symbols,
480
- judged
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
- page,
512
- signal
513
- } = context;
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
- ...result,
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('debug', `read "${result.text}" (${result.confidence}%)`);
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
- ...record,
545
- reason: problem
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 ?? 'tesseract',
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 === undefined) return;
336
+ if (record === void 0) return;
564
337
  submitted.delete(challenge);
565
- await audit(verdict.status === 'solved' ? {
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('src');
586
- for (;;) {
346
+ let source = await image.getAttribute("src");
347
+ for (; ; ) {
587
348
  await page.waitForTimeout(SETTLE_MS);
588
- const now = await image.getAttribute('src');
589
- if (Date.now() >= deadline || now === source && (await image.isVisible())) return;
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
- export { cleanImage, expandCharset, judgeRead, keepCharset, otsuThreshold, tesseractReader };
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.45",
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
- "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
- }
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
  }