@itd-api/captcha 0.1.0

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/index.js ADDED
@@ -0,0 +1,675 @@
1
+ //#region src/errors.ts
2
+ /** Почему не удалось получить токен. */
3
+ const CaptchaFailure = Object.freeze({
4
+ /** Драйвер браузера не установлен. */
5
+ DriverMissing: "driver-missing",
6
+ /** Браузер не запустился: нет исполняемого файла, нет дисплея, отказ песочницы. */
7
+ LaunchFailed: "launch-failed",
8
+ /** Браузер или страница закрылись, пока решался виджет. */
9
+ BrowserClosed: "browser-closed",
10
+ /** Виджет не отдал токен за отведённое время. */
11
+ Timeout: "timeout",
12
+ /** Сам виджет сообщил об ошибке — код лежит в `widgetCode`. */
13
+ WidgetError: "widget-error"
14
+ });
15
+ /**
16
+ * Ошибка получения токена капчи.
17
+ *
18
+ * Причина в {@link CaptchaError.reason} определяет, что с ошибкой делать: отсутствие драйвера
19
+ * чинится установкой, таймаут — повтором, а ошибка виджета повтором не лечится. Поле
20
+ * {@link CaptchaError.type} говорит, чей именно виджет решался, — оно есть не у всех ошибок
21
+ * (запуск браузера происходит до выбора виджета).
22
+ */
23
+ var CaptchaError = class extends Error {
24
+ reason;
25
+ /** Какой провайдер решался в момент ошибки. */
26
+ type;
27
+ /**
28
+ * Код ошибки виджета, если он его сообщил.
29
+ *
30
+ * Для Cloudflare самые частые: `110200` — домен не разрешён для этого ключа, `300***`
31
+ * и `600***` — внутренние сбои виджета, лечатся повтором.
32
+ */
33
+ widgetCode;
34
+ constructor(reason, message, options = {}) {
35
+ super(message);
36
+ this.name = "CaptchaError";
37
+ this.reason = reason;
38
+ this.type = options.type;
39
+ this.widgetCode = options.widgetCode;
40
+ }
41
+ };
42
+ //#endregion
43
+ //#region src/launch.ts
44
+ /**
45
+ * Аргументы запуска Chromium.
46
+ *
47
+ * Подмены `User-Agent` здесь нет намеренно: заявленная версия браузера расходилась бы
48
+ * с реальным движком, а такое расхождение само по себе служит признаком автоматизации.
49
+ * Остаются безопасные общие флаги. Отключение sandbox вынесено в явную настройку:
50
+ * удалённый код виджета исполняется в браузере, и ослаблять его изоляцию по умолчанию нельзя.
51
+ */
52
+ const DEFAULT_ARGS = ["--disable-dev-shm-usage", "--disable-blink-features=AutomationControlled"];
53
+ /**
54
+ * Драйверы в порядке предпочтения.
55
+ *
56
+ * `patchright` впереди намеренно: он ставит собственную сборку Chromium, и та проходит
57
+ * виджет там, где сборка из свежего Playwright уже нет. Если стоит только `playwright`,
58
+ * ничего не меняется — берётся он.
59
+ */
60
+ const DRIVERS = [
61
+ "patchright",
62
+ "playwright",
63
+ "playwright-core"
64
+ ];
65
+ /**
66
+ * Как выглядит имя пакета драйвера.
67
+ *
68
+ * Имя приходит извне и уезжает в динамический импорт, поэтому путями и относительными
69
+ * адресами быть не должно — только имя пакета.
70
+ */
71
+ const DRIVER_NAME = /^(@[a-z0-9][\w.-]*\/)?[a-z0-9][\w.-]*$/i;
72
+ /**
73
+ * Драйверы, которые сами приводят аргументы запуска в порядок.
74
+ *
75
+ * Им ничего не подмешивается: набор флагов у них выверен, а лишний флаг — такой же след,
76
+ * как и недостающий.
77
+ */
78
+ const SELF_TUNED_DRIVERS = /* @__PURE__ */ new Set(["patchright"]);
79
+ function isModuleNotFound(error) {
80
+ const code = error?.code;
81
+ return code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND";
82
+ }
83
+ /**
84
+ * Подключает драйвер браузера.
85
+ *
86
+ * Импорт динамический: драйвер объявлен необязательной одноранговой зависимостью,
87
+ * поэтому его может не быть вовсе. Названный явно берётся один, без перебора: молча
88
+ * подставить вместо него другой значило бы проверить не то, что просили.
89
+ */
90
+ async function loadDriver(requested) {
91
+ const wanted = requested ? [requested] : DRIVERS;
92
+ for (const name of wanted) try {
93
+ return {
94
+ driver: name,
95
+ module: await import(
96
+ /* @vite-ignore */
97
+ name
98
+ )
99
+ };
100
+ } catch (error) {
101
+ if (!isModuleNotFound(error)) throw error;
102
+ }
103
+ throw new CaptchaError(CaptchaFailure.DriverMissing, requested ? `Драйвер ${requested} не установлен.` : "Не найден драйвер браузера. Установите его командой: npm i patchright && npx patchright install chromium. Либо передайте свой запуск браузера через параметр launch.");
104
+ }
105
+ /** Собирает параметры запуска, не поднимая браузер. @internal */
106
+ function resolveLaunchOptions(options, driver = "") {
107
+ return {
108
+ headless: options.headless ?? false,
109
+ args: [
110
+ ...SELF_TUNED_DRIVERS.has(driver) ? [] : DEFAULT_ARGS,
111
+ ...options.disableSandbox ? ["--no-sandbox"] : [],
112
+ ...options.args ?? []
113
+ ],
114
+ ...options.executablePath ? { executablePath: options.executablePath } : {},
115
+ ...options.channel ? { channel: options.channel } : {},
116
+ ...options.proxy ? { proxy: options.proxy } : {}
117
+ };
118
+ }
119
+ /**
120
+ * Поднимает браузер по настройкам.
121
+ *
122
+ * Тем же путём, что и солверы: те же флаги, тот же порядок драйверов. Пригодится, чтобы
123
+ * поднять браузер один раз на несколько токенов и передать его в `browser`, — и чтобы
124
+ * проверять связки драйвера и сборки ровно в том виде, в каком их поднимает пакет.
125
+ */
126
+ async function launchBrowser(options) {
127
+ if (options.launch) return options.launch();
128
+ if (options.driver !== void 0 && !DRIVER_NAME.test(options.driver)) throw new TypeError(`driver должен быть именем пакета, получено: ${options.driver}`);
129
+ const { driver, module: { chromium } } = await loadDriver(options.driver);
130
+ const launchOptions = resolveLaunchOptions(options, driver);
131
+ try {
132
+ return await chromium.launch(launchOptions);
133
+ } catch (error) {
134
+ const hint = launchOptions.headless === false ? " Если это сервер без графической оболочки, запустите процесс через xvfb-run -a." : "";
135
+ throw new CaptchaError(CaptchaFailure.LaunchFailed, `Не удалось запустить браузер: ${error instanceof Error ? error.message : String(error)}.${hint}`);
136
+ }
137
+ }
138
+ //#endregion
139
+ //#region src/options.ts
140
+ /** Базовый URL сайта итд.com. Домен записан в punycode: `итд.com`. */
141
+ const DEFAULT_ORIGIN = "https://xn--d1ah4a.com";
142
+ /**
143
+ * Приводит адрес к корню и проверяет его пригодность.
144
+ *
145
+ * Виджет привязан к домену, а не к пути: адрес приводится к `URL.origin`, чтобы перехват навигации
146
+ * совпал с ним ровно один раз.
147
+ */
148
+ function resolveOrigin(origin, field) {
149
+ let parsed;
150
+ try {
151
+ parsed = new URL(origin);
152
+ } catch {
153
+ throw new TypeError(`${field} должен быть абсолютным URL, получено: ${origin}`);
154
+ }
155
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") throw new TypeError(`${field} должен быть http или https, получено: ${parsed.protocol}`);
156
+ if (parsed.username || parsed.password || parsed.search || parsed.hash) throw new TypeError(`${field} не должен содержать логин, пароль, параметры запроса или фрагмент`);
157
+ return parsed.origin;
158
+ }
159
+ /**
160
+ * Проверяет общие настройки и подставляет умолчания.
161
+ *
162
+ * @throws {TypeError} при некорректных значениях
163
+ */
164
+ function resolveOptions(options) {
165
+ if (typeof options !== "object" || options === null || Array.isArray(options)) throw new TypeError("options должен быть объектом");
166
+ const timeout = options.timeout ?? 6e4;
167
+ const attempts = options.attempts ?? 2;
168
+ const theme = options.theme ?? "auto";
169
+ if (!Number.isFinite(timeout) || timeout <= 0) throw new TypeError(`timeout должен быть положительным числом, получено: ${timeout}`);
170
+ if (!Number.isInteger(attempts) || attempts < 1) throw new TypeError(`attempts должен быть целым числом от 1, получено: ${attempts}`);
171
+ if (theme !== "auto" && theme !== "light" && theme !== "dark") throw new TypeError("theme должен быть 'auto', 'light' или 'dark'");
172
+ for (const [name, value] of [["headless", options.headless], ["disableSandbox", options.disableSandbox]]) if (value !== void 0 && typeof value !== "boolean") throw new TypeError(`${name} должен быть boolean`);
173
+ for (const [name, value] of [["driver", options.driver], ["channel", options.channel]]) if (value !== void 0 && (typeof value !== "string" || value.trim() === "")) throw new TypeError(`${name} должен быть непустой строкой`);
174
+ if (options.logger !== void 0 && typeof options.logger !== "function") throw new TypeError("logger должен быть функцией");
175
+ if (options.contextOptions !== void 0 && (typeof options.contextOptions !== "object" || options.contextOptions === null || Array.isArray(options.contextOptions))) throw new TypeError("contextOptions должен быть объектом");
176
+ if (options.launch !== void 0 && typeof options.launch !== "function") throw new TypeError("launch должен быть функцией");
177
+ if (options.args !== void 0 && (!Array.isArray(options.args) || options.args.some((argument) => typeof argument !== "string"))) throw new TypeError("args должен быть массивом строк");
178
+ return {
179
+ ...options,
180
+ origin: resolveOrigin(options.origin ?? "https://xn--d1ah4a.com", "origin"),
181
+ theme,
182
+ timeout,
183
+ attempts
184
+ };
185
+ }
186
+ /** Требует непустую строку: общая проверка для настроек провайдеров. @internal */
187
+ function requireText(value, field) {
188
+ if (typeof value !== "string" || value.trim() === "") throw new TypeError(`${field} должен быть непустой строкой`);
189
+ return value;
190
+ }
191
+ //#endregion
192
+ //#region src/html.ts
193
+ /**
194
+ * Сборка страницы, на которой живёт виджет.
195
+ *
196
+ * Страница отдаётся вместо настоящей — по адресу целевого сайта, через перехват навигации.
197
+ * Благодаря этому `document.location.origin` для виджета настоящий, и привязка ключа
198
+ * к домену не нарушается, а форма входа и пароль в браузере не участвуют.
199
+ *
200
+ * Каркас общий для всех провайдеров: контейнер `#widget` известного размера, от которого
201
+ * общий цикл отсчитывает координаты клика. Различия провайдеров — в `head` и `body`.
202
+ */
203
+ /** Отступ контейнера от края окна, px. Виджету нужно место, чтобы раскрыть карточку. */
204
+ const WIDGET_MARGIN = 40;
205
+ /**
206
+ * Готовит значение к вставке в инлайновый скрипт или атрибут.
207
+ *
208
+ * Одного `JSON.stringify` мало: кавычки он экранирует, а `<\/script>` — нет, и такая
209
+ * последовательность закрывает тег независимо от того, внутри строки она или нет.
210
+ * Экранированный `<` разбирается движком как обычный символ, но парсер HTML его уже не видит.
211
+ */
212
+ function embed(value) {
213
+ return JSON.stringify(value).replace(/</g, "\\u003c");
214
+ }
215
+ /** Собирает страницу с одним виджетом. */
216
+ function buildWidgetPage(parts) {
217
+ return `<!doctype html>
218
+ <html lang="ru">
219
+ <head>
220
+ <meta charset="utf-8">
221
+ <title>${parts.title}</title>
222
+ <style>
223
+ html, body { margin: 0; padding: 0; background: #fff; }
224
+ #widget {
225
+ width: ${parts.width}px;
226
+ height: ${parts.height}px;
227
+ margin: ${WIDGET_MARGIN}px;${parts.widgetStyle ?? ""}
228
+ }
229
+ </style>
230
+ ${parts.head ?? ""}
231
+ </head>
232
+ <body>
233
+ ${parts.body}
234
+ </body>
235
+ </html>`;
236
+ }
237
+ //#endregion
238
+ //#region src/types.ts
239
+ /** Провайдер капчи — теми же именами, какими его называет сервер итд.com. */
240
+ const CaptchaType = Object.freeze({
241
+ /** Собственная капча ИТД: карточка «Я не робот» в iframe с `captcha.итд.com`. */
242
+ Itd: "itd",
243
+ /** Cloudflare Turnstile. */
244
+ Cloudflare: "cloudflare"
245
+ });
246
+ //#endregion
247
+ //#region src/providers/itd.ts
248
+ /** Базовый адрес виджета собственной капчи ИТД. Домен в punycode: `captcha.итд.com`. */
249
+ const DEFAULT_CAPTCHA_ORIGIN = "https://captcha.xn--d1ah4a.com";
250
+ /**
251
+ * Публичный ключ виджета собственной капчи ИТД.
252
+ *
253
+ * Как и ключ Turnstile, он публичный и привязан к домену итд.com: виджет отдаёт его браузеру
254
+ * каждому посетителю.
255
+ */
256
+ const ITD_CAPTCHA_SITE_KEY = "sk_44d64cf7bf8bc8377f5b";
257
+ /** Назначение токена по умолчанию. Сервер сверяет его с операцией. */
258
+ const DEFAULT_ACTION = "login";
259
+ /** Размеры карточки, px. Их же виджет сообщает сайту через `postMessage`. */
260
+ const WIDTH$1 = 300;
261
+ const HEIGHT$1 = 74;
262
+ /** Насколько правее левого края контейнера находится чекбокс, px. */
263
+ const CHECKBOX_OFFSET_X$1 = 34;
264
+ /**
265
+ * Собирает страницу с одним виджетом собственной капчи ИТД.
266
+ *
267
+ * Виджет — iframe чужого происхождения (`captcha.итд.com`), и наружу он отдаёт токен
268
+ * единственным способом: `postMessage` в родителя. Эта страница ровно так его и слушает —
269
+ * как сам сайт.
270
+ *
271
+ * Слушатель кладёт результат в скрытые поля, а не в переменную `window`: стелс-драйверы вроде
272
+ * `patchright` выполняют `page.evaluate` в изолированном мире, откуда переменные страницы
273
+ * не видны, зато DOM общий.
274
+ *
275
+ * `action` задаёт назначение токена (`login`, `register`, `password_reset`) — сервер сверяет
276
+ * его с операцией, поэтому он не выдумывается, а приходит из настроек обработчика.
277
+ */
278
+ function buildItdCaptchaPage(input) {
279
+ const origin = embed(input.captchaOrigin);
280
+ const src = embed(`${input.captchaOrigin}/widget.html?sitekey=${encodeURIComponent(input.sitekey)}&theme=${encodeURIComponent(input.theme)}&action=${encodeURIComponent(input.action)}`);
281
+ return buildWidgetPage({
282
+ title: "Проверка",
283
+ width: WIDTH$1,
284
+ height: HEIGHT$1,
285
+ widgetStyle: `
286
+ border: 0;
287
+ display: block;`,
288
+ head: `<script>
289
+ window.addEventListener('message', function (event) {
290
+ if (event.origin !== ${origin}) return;
291
+ var data;
292
+ try {
293
+ data = typeof event.data === 'string' ? JSON.parse(event.data) : event.data;
294
+ } catch (e) {
295
+ return;
296
+ }
297
+ if (!data) return;
298
+ var token = document.getElementById('itd-token');
299
+ var error = document.getElementById('itd-error');
300
+ if (data.type === 'token' && data.token) token.value = data.token;
301
+ else if (data.type === 'expired') token.value = '';
302
+ else if (data.type === 'error') error.value = String(data.code || 'error');
303
+ });
304
+ <\/script>`,
305
+ body: `<input type="hidden" id="itd-token">
306
+ <input type="hidden" id="itd-error">
307
+ <iframe id="widget" src=${src} title="Проверка"></iframe>`
308
+ });
309
+ }
310
+ /**
311
+ * Читает результат виджета капчи ИТД.
312
+ *
313
+ * Токен приходит в родителя через `postMessage`, и слушатель страницы кладёт его в скрытое
314
+ * поле. Читаем из DOM, а не из `window`: стелс-драйверы выполняют `evaluate` в изолированном
315
+ * мире, где переменных страницы нет, а DOM общий.
316
+ */
317
+ function readState$1(page) {
318
+ return page.evaluate(() => {
319
+ const token = document.querySelector("#itd-token");
320
+ const error = document.querySelector("#itd-error");
321
+ return {
322
+ token: token?.value || null,
323
+ error: error?.value || null
324
+ };
325
+ });
326
+ }
327
+ /**
328
+ * Создаёт обработчик собственной капчи ИТД.
329
+ *
330
+ * @throws {TypeError} при некорректных настройках
331
+ */
332
+ function itdCaptcha(options = {}) {
333
+ const sitekey = requireText(options.sitekey ?? "sk_44d64cf7bf8bc8377f5b", "sitekey");
334
+ const action = requireText(options.action ?? DEFAULT_ACTION, "action");
335
+ const captchaOrigin = resolveOrigin(options.captchaOrigin ?? "https://captcha.xn--d1ah4a.com", "captchaOrigin");
336
+ return {
337
+ type: CaptchaType.Itd,
338
+ label: "капчи ИТД",
339
+ buildPage: ({ theme }) => buildItdCaptchaPage({
340
+ sitekey,
341
+ theme,
342
+ action,
343
+ captchaOrigin
344
+ }),
345
+ widgetReadySelector: "#widget",
346
+ checkboxOffsetX: CHECKBOX_OFFSET_X$1,
347
+ readState: readState$1,
348
+ isPermanentWidgetError: () => false
349
+ };
350
+ }
351
+ //#endregion
352
+ //#region src/providers/turnstile.ts
353
+ /**
354
+ * Публичный ключ виджета Cloudflare Turnstile на итд.com.
355
+ *
356
+ * Ключ публичный — виджет отдаёт его браузеру каждому посетителю — и привязан к домену
357
+ * итд.com, поэтому больше ни для чего не годится. Держим его прямо в пакете: это его данные,
358
+ * а не пользовательская настройка. Тот же ключ экспортирует `itd-api`.
359
+ */
360
+ const TURNSTILE_SITE_KEY = "0x4AAAAAACHhxczw6fJGwPBg";
361
+ /** Размеры контейнера виджета, px. Столько же он занимает на самом сайте. */
362
+ const WIDTH = 300;
363
+ const HEIGHT = 65;
364
+ /** Насколько правее левого края контейнера находится чекбокс, px. */
365
+ const CHECKBOX_OFFSET_X = 30;
366
+ /** Коды Cloudflare вида `110***` означают, что ключ не разрешён для домена. */
367
+ const DOMAIN_ERROR_PREFIX = "110";
368
+ /**
369
+ * Собирает страницу с одним виджетом Cloudflare Turnstile.
370
+ *
371
+ * Виджет создаётся так же, как это делает сам сайт, — скриптом с `?onload=` и явным
372
+ * `turnstile.render` с одним лишь `sitekey`. Ни `action`, ни `cdata` сайт не передаёт,
373
+ * поэтому их не передаёт и эта страница: лишний параметр попал бы в ответ `siteverify`
374
+ * и мог бы разойтись с тем, что ожидает сервер.
375
+ */
376
+ function buildTurnstilePage(sitekey, theme) {
377
+ const key = embed(sitekey);
378
+ const widgetTheme = embed(theme);
379
+ return buildWidgetPage({
380
+ title: "Turnstile",
381
+ width: WIDTH,
382
+ height: HEIGHT,
383
+ head: `<script>
384
+ window.__itdToken = null;
385
+ window.__itdError = null;
386
+ window.onTurnstileLoad = function () {
387
+ var reset = function () {
388
+ window.__itdToken = null;
389
+ if (widgetId !== undefined) window.turnstile.reset(widgetId);
390
+ };
391
+ var widgetId = window.turnstile.render('#widget', {
392
+ sitekey: ${key},
393
+ theme: ${widgetTheme},
394
+ callback: function (token) { window.__itdToken = token; },
395
+ 'error-callback': function (code) { window.__itdError = String(code || 'unknown'); return true; },
396
+ 'timeout-callback': reset,
397
+ 'expired-callback': reset
398
+ });
399
+ };
400
+ <\/script>
401
+ <script src="https://challenges.cloudflare.com/turnstile/v0/api.js?onload=onTurnstileLoad" async defer><\/script>`,
402
+ body: "<div id=\"widget\"></div>"
403
+ });
404
+ }
405
+ /**
406
+ * Читает результат виджета Turnstile.
407
+ *
408
+ * Источников два. Скрытое поле `cf-turnstile-response` виджет заполняет всегда, а вот
409
+ * `callback` срабатывает не в каждом сценарии: пройдясь сам, виджет успевает убрать iframe,
410
+ * и обработчика можно не дождаться. Поле при этом остаётся заполненным, и лежит оно
411
+ * в нашей странице, а не внутри iframe чужого происхождения, поэтому доступно.
412
+ */
413
+ function readState(page) {
414
+ return page.evaluate(() => {
415
+ const field = document.querySelector("input[name=\"cf-turnstile-response\"]");
416
+ const scope = window;
417
+ return {
418
+ token: scope.__itdToken || field?.value || null,
419
+ error: scope.__itdError ?? null
420
+ };
421
+ });
422
+ }
423
+ /**
424
+ * Создаёт обработчик Cloudflare Turnstile.
425
+ *
426
+ * @throws {TypeError} при некорректных настройках
427
+ */
428
+ function turnstile(options = {}) {
429
+ const sitekey = requireText(options.sitekey ?? "0x4AAAAAACHhxczw6fJGwPBg", "sitekey");
430
+ return {
431
+ type: CaptchaType.Cloudflare,
432
+ label: "Turnstile",
433
+ buildPage: ({ theme }) => buildTurnstilePage(sitekey, theme),
434
+ widgetReadySelector: "#widget > div",
435
+ checkboxOffsetX: CHECKBOX_OFFSET_X,
436
+ readState,
437
+ isPermanentWidgetError: (code) => code.startsWith(DOMAIN_ERROR_PREFIX)
438
+ };
439
+ }
440
+ //#endregion
441
+ //#region src/providers/registry.ts
442
+ /**
443
+ * Встроенные обработчики по имени провайдера.
444
+ *
445
+ * Имена — те же, какими провайдера называет сервер итд.com, поэтому значение из
446
+ * `itd.auth.captchaProvider()` подходит как есть. `turnstile` принимается синонимом
447
+ * `cloudflare`.
448
+ */
449
+ const BUILT_IN = Object.freeze({
450
+ [CaptchaType.Itd]: itdCaptcha,
451
+ [CaptchaType.Cloudflare]: turnstile,
452
+ turnstile
453
+ });
454
+ function isHandler(value) {
455
+ return typeof value === "object" && value !== null && typeof value.type === "string" && typeof value.buildPage === "function" && typeof value.readState === "function";
456
+ }
457
+ /**
458
+ * Находит обработчик по имени провайдера либо принимает готовый.
459
+ *
460
+ * @throws {TypeError} если имя незнакомо или передано не то
461
+ */
462
+ function resolveHandler(target) {
463
+ if (isHandler(target)) return target;
464
+ if (typeof target !== "string" || target.trim() === "") throw new TypeError(`Тип капчи должен быть именем провайдера или объектом CaptchaHandler, получено: ${typeof target}`);
465
+ const factory = BUILT_IN[target];
466
+ if (!factory) throw new TypeError(`Неизвестный тип капчи: ${target}. Известны ${Object.keys(BUILT_IN).join(", ")}. Свой провайдер передайте объектом CaptchaHandler.`);
467
+ return factory();
468
+ }
469
+ //#endregion
470
+ //#region src/runner.ts
471
+ /** Разброс координат клика, px в каждую сторону. */
472
+ const CLICK_JITTER = 4;
473
+ /** Пауза перед первым касанием, мс. */
474
+ const HUMAN_DELAY = [1500, 2500];
475
+ /** Как часто опрашивается состояние виджета, мс. */
476
+ const POLL_INTERVAL = 250;
477
+ /** Сколько ждать между повторными кликами, мс. */
478
+ const CLICK_INTERVAL = 4e3;
479
+ /** Сколько ждать появления виджета, мс. */
480
+ const WIDGET_APPEAR_TIMEOUT = 15e3;
481
+ /** Настройки контекста по умолчанию. Заменяются целиком через `contextOptions`. */
482
+ const DEFAULT_CONTEXT_OPTIONS = {
483
+ locale: "ru-RU",
484
+ viewport: {
485
+ width: 1280,
486
+ height: 800
487
+ }
488
+ };
489
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
490
+ const between = (min, max) => min + Math.random() * (max - min);
491
+ /**
492
+ * Версия браузера для текста ошибки.
493
+ *
494
+ * Сборка Chromium — первое, что стоит проверить при отказе виджета: часть сборок его
495
+ * не проходит. Метода может не быть, если браузер передали свой.
496
+ */
497
+ function browserVersion(browser) {
498
+ try {
499
+ return browser.version?.();
500
+ } catch {
501
+ return;
502
+ }
503
+ }
504
+ /**
505
+ * Кликает по чекбоксу виджета.
506
+ *
507
+ * Чекбокс лежит в iframe чужого происхождения, до его DOM не дотянуться — клик идёт
508
+ * по координатам. Отсчёт ведётся от собственного контейнера известного размера, поэтому
509
+ * попадание не зависит от разметки виджета. Координаты слегка разбрасываются: один и тот же
510
+ * пиксель раз за разом — заметная закономерность.
511
+ */
512
+ async function clickCheckbox(page, offsetX) {
513
+ const box = await (await page.$("#widget"))?.boundingBox();
514
+ if (!box) return false;
515
+ const x = box.x + offsetX + between(-4, CLICK_JITTER);
516
+ const y = box.y + box.height / 2 + between(-4, CLICK_JITTER);
517
+ await page.mouse.move(x, y, { steps: 8 });
518
+ await page.mouse.click(x, y, { delay: between(40, 110) });
519
+ return true;
520
+ }
521
+ /** Ждёт токен, периодически подталкивая виджет кликом. */
522
+ async function waitForToken(page, handler, options, browser) {
523
+ const deadline = Date.now() + options.timeout;
524
+ await page.waitForSelector(handler.widgetReadySelector, {
525
+ timeout: Math.min(WIDGET_APPEAR_TIMEOUT, options.timeout),
526
+ state: "attached"
527
+ }).catch(() => {});
528
+ let nextClickAt = Date.now() + between(HUMAN_DELAY[0], HUMAN_DELAY[1]);
529
+ let lastError;
530
+ while (Date.now() < deadline) {
531
+ const state = await handler.readState(page);
532
+ if (state.token) {
533
+ options.logger?.("токен получен");
534
+ return state.token;
535
+ }
536
+ if (state.error && state.error !== lastError) {
537
+ if (handler.isPermanentWidgetError(state.error)) throw new CaptchaError(CaptchaFailure.WidgetError, `Виджет ${handler.label} отказал для домена ${options.origin} (код ${state.error}): ключ не разрешён для этого домена.`, {
538
+ type: handler.type,
539
+ widgetCode: state.error
540
+ });
541
+ lastError = state.error;
542
+ options.logger?.(`виджет сообщил об ошибке ${state.error}, ждём повтора`);
543
+ }
544
+ if (Date.now() >= nextClickAt && await clickCheckbox(page, handler.checkboxOffsetX)) {
545
+ options.logger?.("клик по чекбоксу");
546
+ nextClickAt = Date.now() + CLICK_INTERVAL;
547
+ }
548
+ await sleep(POLL_INTERVAL);
549
+ }
550
+ const version = browserVersion(browser);
551
+ throw new CaptchaError(CaptchaFailure.Timeout, `Виджет ${handler.label} не отдал токен за ${options.timeout} мс` + (lastError ? `; последняя ошибка виджета — ${lastError}` : "") + (version ? `; браузер — ${version}` : "") + ". Рабочие связки драйвера и браузера перечислены в README пакета.", lastError ? {
552
+ type: handler.type,
553
+ widgetCode: lastError
554
+ } : { type: handler.type });
555
+ }
556
+ async function solveOnce(browser, handler, options) {
557
+ const context = await browser.newContext(options.contextOptions ?? DEFAULT_CONTEXT_OPTIONS);
558
+ try {
559
+ const page = await context.newPage();
560
+ const url = `${options.origin}/`;
561
+ const body = handler.buildPage({ theme: options.theme });
562
+ await page.route(url, (route) => route.fulfill({
563
+ status: 200,
564
+ contentType: "text/html; charset=utf-8",
565
+ body
566
+ }));
567
+ await page.goto(url, { waitUntil: "domcontentloaded" });
568
+ return await waitForToken(page, handler, options, browser);
569
+ } finally {
570
+ await context.close().catch(() => {});
571
+ }
572
+ }
573
+ /** Ошибки настройки: повтор их не исправит. */
574
+ function isPermanent(error) {
575
+ if (!(error instanceof CaptchaError)) return false;
576
+ if (error.reason === CaptchaFailure.DriverMissing) return true;
577
+ if (error.reason === CaptchaFailure.LaunchFailed) return true;
578
+ if (error.reason === CaptchaFailure.BrowserClosed) return true;
579
+ return error.reason === CaptchaFailure.WidgetError;
580
+ }
581
+ /**
582
+ * Как драйверы сообщают, что страницы, контекста или браузера больше нет.
583
+ *
584
+ * Своего типа ошибки у них нет, поэтому остаётся текст сообщения.
585
+ */
586
+ const CLOSED_MARKERS = [
587
+ "has been closed",
588
+ "target closed",
589
+ "browser has been closed",
590
+ "browser has disconnected",
591
+ "session closed"
592
+ ];
593
+ function isClosedError(error) {
594
+ const message = error instanceof Error ? error.message.toLowerCase() : "";
595
+ return CLOSED_MARKERS.some((marker) => message.includes(marker));
596
+ }
597
+ /** Приводит закрытие браузера к {@link CaptchaError}; остальное отдаёт как есть. */
598
+ function toCaptchaError(error, handler) {
599
+ if (!isClosedError(error)) return error;
600
+ return new CaptchaError(CaptchaFailure.BrowserClosed, `Браузер закрылся до того, как виджет ${handler.label} отдал токен.`, { type: handler.type });
601
+ }
602
+ /**
603
+ * Общий путь получения токена: поднимает браузер, решает виджет, закрывает браузер.
604
+ *
605
+ * Всё, что зависит от провайдера, приходит в {@link CaptchaHandler}; здесь остаётся только
606
+ * то, что одинаково для любого виджета.
607
+ *
608
+ * @throws {CaptchaError} если токен получить не удалось
609
+ */
610
+ async function runSolver(handler, options) {
611
+ const browser = options.browser ?? await launchBrowser(options);
612
+ const owned = options.browser === void 0;
613
+ try {
614
+ for (let attempt = 1; attempt <= options.attempts; attempt++) try {
615
+ options.logger?.(`попытка ${attempt} из ${options.attempts}`);
616
+ return await solveOnce(browser, handler, options);
617
+ } catch (raw) {
618
+ const error = toCaptchaError(raw, handler);
619
+ if (isPermanent(error) || attempt === options.attempts) throw error;
620
+ options.logger?.(`попытка ${attempt} не удалась: ${error instanceof Error ? error.message : String(error)}`);
621
+ }
622
+ throw new CaptchaError(CaptchaFailure.Timeout, `Токен ${handler.label} получить не удалось`, { type: handler.type });
623
+ } finally {
624
+ if (owned) await browser.close().catch(() => {});
625
+ }
626
+ }
627
+ //#endregion
628
+ //#region src/solve.ts
629
+ /**
630
+ * Получает один токен капчи указанного типа.
631
+ *
632
+ * Поднимает браузер, решает виджет и закрывает браузер за собой.
633
+ *
634
+ * @example
635
+ * ```ts
636
+ * await solveCaptcha(CaptchaType.Itd);
637
+ * await solveCaptcha(itdCaptcha({ action: 'register' }), { headless: true });
638
+ * await solveCaptcha(myOwnHandler);
639
+ * ```
640
+ *
641
+ * @throws {CaptchaError} если токен получить не удалось
642
+ * @throws {TypeError} при некорректных настройках или незнакомом типе
643
+ */
644
+ async function solveCaptcha(target, options = {}) {
645
+ return runSolver(resolveHandler(target), resolveOptions(options));
646
+ }
647
+ /**
648
+ * Собирает источник токена для клиента `itd-api`.
649
+ *
650
+ * Токен одноразовый и живёт несколько минут, поэтому клиент спрашивает его заново перед
651
+ * каждым входом. Браузер поднимается на время одного вызова и сразу закрывается.
652
+ *
653
+ * @example
654
+ * ```ts
655
+ * import { ItdClient } from 'itd-api';
656
+ * import { FileTokenStorage } from 'itd-api/node';
657
+ * import { createCaptchaSolver } from '@itd-api/captcha';
658
+ *
659
+ * const itd = new ItdClient({
660
+ * storage: new FileTokenStorage('./.itd-session.json'),
661
+ * auth: {
662
+ * email: process.env.ITD_EMAIL!,
663
+ * password: process.env.ITD_PASSWORD!,
664
+ * captcha: createCaptchaSolver(),
665
+ * },
666
+ * });
667
+ * ```
668
+ */
669
+ function createCaptchaSolver(options = {}) {
670
+ return { getToken: (type) => solveCaptcha(type, options) };
671
+ }
672
+ //#endregion
673
+ export { CaptchaError, CaptchaFailure, CaptchaType, DEFAULT_CAPTCHA_ORIGIN, DEFAULT_ORIGIN, ITD_CAPTCHA_SITE_KEY, TURNSTILE_SITE_KEY, createCaptchaSolver, itdCaptcha, launchBrowser, solveCaptcha, turnstile };
674
+
675
+ //# sourceMappingURL=index.js.map