@opencraw/core 0.1.1 → 0.1.3

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.
Files changed (65) hide show
  1. package/README.md +4 -2
  2. package/dist/index.esm.js +3010 -638
  3. package/dist/src/access/access-profile.contract.d.ts +4 -0
  4. package/dist/src/access/index.d.ts +1 -1
  5. package/dist/src/api-steps/extract-from-document.use-case.d.ts +16 -3
  6. package/dist/src/api-steps/index.d.ts +1 -1
  7. package/dist/src/api-steps/send-request.use-case.d.ts +5 -4
  8. package/dist/src/browser-session/browser-profile.store.d.ts +52 -0
  9. package/dist/src/browser-session/browser.client.d.ts +8 -0
  10. package/dist/src/browser-session/index.d.ts +1 -0
  11. package/dist/src/captcha/captcha-budget.model.d.ts +21 -0
  12. package/dist/src/captcha/captcha-detection.client.d.ts +28 -0
  13. package/dist/src/captcha/captcha-guard.use-case.d.ts +64 -0
  14. package/dist/src/captcha/captcha-solver-registry.store.d.ts +19 -0
  15. package/dist/src/captcha/captcha-solver.contract.d.ts +47 -0
  16. package/dist/src/captcha/captcha.error.d.ts +13 -0
  17. package/dist/src/captcha/index.d.ts +10 -0
  18. package/dist/src/captcha/resolve-captcha.use-case.d.ts +40 -0
  19. package/dist/src/crawl-events/crawl-event.contract.d.ts +48 -0
  20. package/dist/src/crawl-execution/bootstrap-session.use-case.d.ts +27 -3
  21. package/dist/src/crawl-execution/crawl-options.config.d.ts +30 -0
  22. package/dist/src/crawl-execution/crawl-report.model.d.ts +6 -0
  23. package/dist/src/crawl-execution/create-crawler.use-case.d.ts +2 -1
  24. package/dist/src/crawl-execution/rotating-runner.use-case.d.ts +9 -0
  25. package/dist/src/crawl-execution/run-crawl.use-case.d.ts +6 -2
  26. package/dist/src/crawl-execution/run-input-recipe.use-case.d.ts +14 -3
  27. package/dist/src/deck-document/deck-document.model.d.ts +58 -0
  28. package/dist/src/deck-document/deck-table.algorithm.d.ts +35 -0
  29. package/dist/src/deck-document/index.d.ts +6 -0
  30. package/dist/src/deck-document/read-pptx.client.d.ts +16 -0
  31. package/dist/src/extraction-scope/extraction-scope.model.d.ts +3 -1
  32. package/dist/src/http-session/http-response.contract.d.ts +13 -1
  33. package/dist/src/http-session/text-decoding.algorithm.d.ts +35 -0
  34. package/dist/src/index.d.ts +13 -4
  35. package/dist/src/markdown-document/index.d.ts +3 -0
  36. package/dist/src/markdown-document/read-markdown.client.d.ts +29 -0
  37. package/dist/src/pdf-document/index.d.ts +1 -1
  38. package/dist/src/pdf-document/row-assembly.algorithm.d.ts +10 -1
  39. package/dist/src/recipe-loading/recipe-binding.validator.d.ts +3 -1
  40. package/dist/src/recipe-schema/index.d.ts +3 -3
  41. package/dist/src/recipe-schema/input-recipe.contract.d.ts +57 -4
  42. package/dist/src/recipe-schema/recipe-kind.enum.d.ts +3 -2
  43. package/dist/src/recipe-schema/step.contract.d.ts +46 -2
  44. package/dist/src/record-sink/dedupe.policy.d.ts +19 -7
  45. package/dist/src/record-sink/index.d.ts +1 -0
  46. package/dist/src/selection/index.d.ts +1 -1
  47. package/dist/src/selection/json-text.algorithm.d.ts +30 -3
  48. package/dist/src/step-flow/for-each.use-case.d.ts +4 -2
  49. package/dist/src/step-flow/host-throttle.policy.d.ts +49 -0
  50. package/dist/src/step-flow/index.d.ts +5 -0
  51. package/dist/src/step-flow/run-gate.policy.d.ts +12 -1
  52. package/dist/src/step-flow/step-runner.contract.d.ts +13 -0
  53. package/dist/src/step-flow/transport-retry.policy.d.ts +76 -0
  54. package/dist/src/web-steps/navigate.use-case.d.ts +3 -2
  55. package/dist/src/web-steps/run-web-step.use-case.d.ts +18 -3
  56. package/dist/src/workbook-document/csv-parser.algorithm.d.ts +26 -0
  57. package/dist/src/workbook-document/csv-workbook.mapper.d.ts +24 -0
  58. package/dist/src/workbook-document/grid-table.algorithm.d.ts +53 -0
  59. package/dist/src/workbook-document/html-tables.mapper.d.ts +14 -0
  60. package/dist/src/workbook-document/index.d.ts +9 -0
  61. package/dist/src/workbook-document/read-xlsx.client.d.ts +18 -0
  62. package/dist/src/workbook-document/workbook-document.model.d.ts +51 -0
  63. package/dist/src/yaml-document/index.d.ts +3 -0
  64. package/dist/src/yaml-document/read-yaml.client.d.ts +25 -0
  65. package/package.json +16 -2
package/dist/index.esm.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import { randomInt, randomUUID } from 'node:crypto';
3
- import { readFile, mkdir, writeFile, stat, readdir } from 'node:fs/promises';
4
- import { resolve as resolve$1, dirname, extname, join as join$1 } from 'node:path';
3
+ import { readFile, mkdir, writeFile, rm, stat, readdir } from 'node:fs/promises';
4
+ import { resolve as resolve$1, join as join$1, dirname, extname } from 'node:path';
5
5
  import { firefox, webkit, chromium, request } from 'playwright';
6
6
  import { createWriteStream } from 'node:fs';
7
7
  import { once } from 'node:events';
@@ -67,10 +67,20 @@ const pluginSchema = z.strictObject({
67
67
  options: z.record(z.string(), z.unknown()).optional()
68
68
  });
69
69
  const accessProfileSchema = z.union([directSchema, proxySchema, poolSchema, cdpSchema, pluginSchema]);
70
+ const hostRuleSchema = z.strictObject({
71
+ delayMs: z.int().nonnegative().optional(),
72
+ concurrency: z.int().min(1).max(256).optional()
73
+ });
74
+ const throttleConfigSchema = z.strictObject({
75
+ delayMs: z.int().nonnegative().optional(),
76
+ concurrency: z.int().min(1).max(256).optional(),
77
+ domains: z.record(z.string().regex(/^[\w.-]+$/, 'a domain such as example.com'), hostRuleSchema).optional()
78
+ });
70
79
  const accessConfigSchema = z.strictObject({
71
80
  $schema: z.string().optional(),
72
81
  profiles: z.record(z.string().regex(/^[\w-]+$/, 'a profile name is letters, digits, hyphens and underscores'), accessProfileSchema),
73
- default: z.string().optional()
82
+ default: z.string().optional(),
83
+ throttle: throttleConfigSchema.optional()
74
84
  }).refine(config => config.default === undefined || Object.hasOwn(config.profiles, config.default), {
75
85
  message: 'default names a profile that does not exist',
76
86
  path: ['default']
@@ -1179,6 +1189,17 @@ async function blockResources(context, types) {
1179
1189
  await route.continue();
1180
1190
  });
1181
1191
  }
1192
+ /**
1193
+ * What a context gets after it opened: the cookies to add and the resource
1194
+ * types to skip.
1195
+ *
1196
+ * @param context - The context.
1197
+ * @param options - The session options.
1198
+ */
1199
+ async function applySessionExtras(context, options) {
1200
+ if (options.cookies !== undefined && options.cookies.length > 0) await context.addCookies(options.cookies);
1201
+ if (options.blockResources !== undefined && options.blockResources.length > 0) await blockResources(context, new Set(options.blockResources));
1202
+ }
1182
1203
  /** A launched browser; sessions are opened from it and closed independently. */
1183
1204
  class BrowserClient {
1184
1205
  browser;
@@ -1238,8 +1259,7 @@ class BrowserClient {
1238
1259
  };
1239
1260
  const context = await this.browser.newContext(contextOptions);
1240
1261
  if (this.config.timeoutMs !== undefined) context.setDefaultTimeout(this.config.timeoutMs);
1241
- if (options.cookies !== undefined && options.cookies.length > 0) await context.addCookies(options.cookies);
1242
- if (options.blockResources !== undefined && options.blockResources.length > 0) await blockResources(context, new Set(options.blockResources));
1262
+ await applySessionExtras(context, options);
1243
1263
  const page = await context.newPage();
1244
1264
  return new BrowserSession(context, page);
1245
1265
  }
@@ -1248,470 +1268,252 @@ class BrowserClient {
1248
1268
  }
1249
1269
  }
1250
1270
 
1251
- /** Fans crawl events out to listeners. A listener that throws never breaks the crawl. */
1252
- class EventBus {
1253
- listeners = new Set();
1254
- constructor(listener) {
1255
- if (listener !== undefined) this.listeners.add(listener);
1271
+ /** The file that marks a profile as in use, holding the owning process id. */
1272
+ const LOCK_FILE = '.opencraw.lock';
1273
+ /** A browser profile name: it becomes a directory, so no separators or dots. */
1274
+ const BROWSER_PROFILE_NAME = /^[\w-]+$/;
1275
+ /**
1276
+ * Browser profiles that persist between runs: each is a directory of a real
1277
+ * browser's user data (cookies, local storage, IndexedDB, cache, service
1278
+ * workers), so a login, a consent choice or a site's trust in a returning
1279
+ * visitor carries over to the next run. The browser equivalent of a user who
1280
+ * never clears their history.
1281
+ *
1282
+ * A profile directory can be open in one browser at a time. Within this
1283
+ * crawler, a second use waits for the first to close; the same owner (one
1284
+ * recipe run reopening after a rotation) takes it over instead. Another
1285
+ * crawler holding it, in this process or another, is reported, not waited
1286
+ * for: a lock file in the profile names the process, and one left by a
1287
+ * process that died is taken over. (Chromium's own profile lock is not
1288
+ * enough: headless builds do not take it.)
1289
+ */
1290
+ class BrowserProfiles {
1291
+ directory;
1292
+ config;
1293
+ held = new Map();
1294
+ waiting = new Map();
1295
+ /**
1296
+ * @param directory - Where the profiles live, one subdirectory each.
1297
+ * @param config - The crawler's browser settings (type, binary, headless, timeouts).
1298
+ */
1299
+ constructor(directory, config = {}) {
1300
+ this.directory = directory;
1301
+ this.config = config;
1256
1302
  }
1257
- /** @returns A function that removes the listener. */
1258
- subscribe(listener) {
1259
- this.listeners.add(listener);
1260
- return () => {
1261
- this.listeners.delete(listener);
1262
- };
1303
+ async take(name, owner) {
1304
+ for (;;) {
1305
+ const holder = this.held.get(name);
1306
+ if (holder === undefined) return;
1307
+ if (holder.owner === owner) {
1308
+ await holder.session.close();
1309
+ continue;
1310
+ }
1311
+ await new Promise(resolve => {
1312
+ const queue = this.waiting.get(name) ?? [];
1313
+ queue.push(resolve);
1314
+ this.waiting.set(name, queue);
1315
+ });
1316
+ }
1263
1317
  }
1264
- emit(input) {
1265
- const event = {
1266
- ...input,
1267
- at: new Date().toISOString()
1268
- };
1269
- for (const listener of this.listeners) {
1318
+ free(name) {
1319
+ this.held.delete(name);
1320
+ this.waiting.get(name)?.shift()?.();
1321
+ }
1322
+ launch(path, options) {
1323
+ const type = this.config.browserType ?? DEFAULT_BROWSER_CONFIG.browserType;
1324
+ const launcher = type === 'firefox' ? firefox : type === 'webkit' ? webkit : chromium;
1325
+ return launcher.launchPersistentContext(path, {
1326
+ headless: this.config.headless ?? DEFAULT_BROWSER_CONFIG.headless,
1327
+ slowMo: this.config.slowMo,
1328
+ executablePath: this.config.executablePath,
1329
+ proxy: options.proxy ?? this.config.proxy,
1330
+ extraHTTPHeaders: options.headers,
1331
+ userAgent: options.userAgent,
1332
+ viewport: options.viewport,
1333
+ ignoreHTTPSErrors: this.config.ignoreHTTPSErrors === true || options.ignoreHTTPSErrors === true
1334
+ });
1335
+ }
1336
+ /**
1337
+ * The profile's directory.
1338
+ *
1339
+ * @param name - A profile name.
1340
+ * @returns The absolute path.
1341
+ */
1342
+ pathOf(name) {
1343
+ if (!BROWSER_PROFILE_NAME.test(name)) throw new Error(`browser profile "${name}": a name is letters, digits, hyphens and underscores`);
1344
+ return resolve$1(this.directory, name);
1345
+ }
1346
+ /**
1347
+ * Opens a profile in its own browser, waiting while another run of this
1348
+ * crawler uses it.
1349
+ *
1350
+ * @param name - The profile.
1351
+ * @param options - Proxy, headers, viewport, cookies to add. `storageState` is ignored: the profile has its own.
1352
+ * @param owner - Who opens it; the same owner reopening closes its previous session first.
1353
+ * @returns The session; closing it frees the profile.
1354
+ */
1355
+ async open(name, options, owner) {
1356
+ const path = this.pathOf(name);
1357
+ await this.take(name, owner);
1358
+ let context;
1359
+ let unlock = unlocked;
1360
+ try {
1361
+ await mkdir(path, {
1362
+ recursive: true
1363
+ });
1364
+ unlock = await lockProfile(path, name);
1365
+ context = await this.launch(path, options);
1366
+ } catch (error) {
1367
+ await unlock();
1368
+ this.free(name);
1369
+ const message = error instanceof Error ? error.message : String(error);
1370
+ if (/ProcessSingleton|SingletonLock|already in use/i.test(message)) throw new Error(`browser profile "${name}" is open in another browser (${path}); close it or use another profile`, {
1371
+ cause: error
1372
+ });
1373
+ throw error;
1374
+ }
1375
+ if (this.config.timeoutMs !== undefined) context.setDefaultTimeout(this.config.timeoutMs);
1376
+ await applySessionExtras(context, options);
1377
+ const page = context.pages()[0] ?? (await context.newPage());
1378
+ let closed = false;
1379
+ const session = new BrowserSession(context, page, async () => {
1380
+ if (closed) return;
1381
+ closed = true;
1270
1382
  try {
1271
- listener(event);
1272
- } catch {
1273
- // A faulty listener is the caller's problem, not the crawl's.
1383
+ await context.close();
1384
+ } finally {
1385
+ await unlock();
1386
+ this.free(name);
1274
1387
  }
1275
- }
1388
+ });
1389
+ this.held.set(name, {
1390
+ owner,
1391
+ session
1392
+ });
1393
+ return session;
1276
1394
  }
1277
1395
  }
1278
-
1279
1396
  /**
1280
- * One line of a crawl trace: the route a recipe takes (pages visited, steps
1281
- * run, records produced, policies fired), indented by how deep in the step
1282
- * tree the event happened. `step:start` yields nothing; `step:finish` carries
1283
- * the duration, so every step prints once.
1397
+ * Marks a profile as used by this process, refusing one a live process
1398
+ * holds and taking over one a dead process left behind.
1284
1399
  *
1285
- * @param event - Any crawl event.
1286
- * @returns The line, or `undefined` for events a trace does not show.
1400
+ * @param path - The profile directory.
1401
+ * @param name - The profile name, for the message.
1402
+ * @returns The unlock.
1403
+ * @throws Error when another crawler holds the profile.
1287
1404
  */
1288
- function traceLine(event) {
1289
- switch (event.type) {
1290
- case 'recipe:start':
1291
- {
1292
- return `▶ ${event.recipeId} (${event.mode})`;
1293
- }
1294
- case 'recipe:finish':
1295
- {
1296
- return `■ ${event.recipeId}: ${event.emitted} emitted, ${event.rejected} rejected, ${event.duplicates} duplicates, ${event.skipped > 0 ? `${event.skipped} skipped, ` : ''}${(event.stepsSkipped ?? 0) > 0 ? `${event.stepsSkipped} steps skipped, ` : ''}${event.pages} pages, ${event.durationMs} ms${event.error === undefined ? '' : `\n ✖ stopped: ${event.error}`}`;
1297
- }
1298
- case 'access:lease':
1299
- {
1300
- return `${indent(1)}⇄ access ${event.profile} (${event.kind}${event.server === undefined ? '' : ` ${event.server}`}${event.session === undefined ? '' : `, session ${event.session}`})`;
1301
- }
1302
- case 'access:blocked':
1303
- {
1304
- return `${indent(1)}⛔ blocked ${event.url}: ${event.reason}`;
1305
- }
1306
- case 'access:rotate':
1307
- {
1308
- return `${indent(1)}↻ new access lease (attempt ${event.attempt})`;
1309
- }
1310
- case 'page:visit':
1311
- {
1312
- return `${indent(1)}⇢ page ${event.number} ${event.url}${event.status === undefined || event.status >= 200 && event.status < 300 ? '' : ` [${event.status}]`}`;
1313
- }
1314
- case 'step:start':
1315
- {
1316
- return undefined;
1317
- }
1318
- case 'step:finish':
1319
- {
1320
- return `${indent(depthOf$1(event.path))}· ${stepLabel(event)} ${event.durationMs} ms`;
1321
- }
1322
- case 'step:retry':
1323
- {
1324
- return `${indent(depthOf$1(event.path))}↻ ${stepLabel(event)} retry ${event.attempt}: ${event.error}`;
1325
- }
1326
- case 'step:skip':
1327
- {
1328
- return `${indent(depthOf$1(event.path))}↷ ${stepLabel(event)} skipped: ${event.error}`;
1329
- }
1330
- case 'step:branch':
1331
- {
1332
- return `${indent(depthOf$1(event.path))}⑂ ${event.path} ${event.branch}`;
1333
- }
1334
- case 'record:emit':
1335
- {
1336
- return `${indent(1)}✚ record ${event.key ?? '(no key)'}`;
1337
- }
1338
- case 'record:reject':
1339
- {
1340
- return `${indent(1)}✖ record rejected: ${event.field}: ${event.reason}`;
1341
- }
1342
- case 'record:duplicate':
1343
- {
1344
- return `${indent(1)}≡ duplicate ${event.key}`;
1345
- }
1346
- case 'record:skipped':
1347
- {
1348
- return `${indent(1)}⤼ skipped ${event.key}`;
1349
- }
1350
- case 'warning':
1351
- {
1352
- return `${indent(1)}! ${event.message}`;
1353
- }
1354
- case 'error':
1355
- {
1356
- return `${indent(1)}✖ ${event.message}`;
1357
- }
1405
+ async function lockProfile(path, name) {
1406
+ const file = join$1(path, LOCK_FILE);
1407
+ for (let attempt = 0; attempt < 2; attempt += 1) {
1408
+ try {
1409
+ await writeFile(file, String(process.pid), {
1410
+ flag: 'wx'
1411
+ });
1412
+ return async () => {
1413
+ await rm(file, {
1414
+ force: true
1415
+ });
1416
+ };
1417
+ } catch (error) {
1418
+ if (error.code !== 'EEXIST') throw error;
1419
+ const holder = Number(await readHolder(file));
1420
+ if (Number.isSafeInteger(holder) && holder > 0 && isAlive(holder)) throw new Error(`browser profile "${name}" is open in another browser (${path}, process ${holder}); close it or use another profile`, {
1421
+ cause: error
1422
+ });
1423
+ await rm(file, {
1424
+ force: true
1425
+ });
1426
+ }
1358
1427
  }
1428
+ throw new Error(`browser profile "${name}": cannot lock ${path}`);
1359
1429
  }
1360
- /** How deep a step path such as `steps.8.steps.2` or `steps.1.else.0` sits: one level per nested `steps` or `else`. */
1361
- function depthOf$1(path) {
1362
- return path.split('.').filter(segment => ['steps', 'else'].includes(segment)).length;
1363
- }
1364
- function stepLabel(event) {
1365
- return `${event.path} ${event.stepType}${event.stepId === undefined ? '' : ` ${event.stepId}`}`;
1430
+ /** The unlock of a profile that was never locked. */
1431
+ async function unlocked() {}
1432
+ async function readHolder(file) {
1433
+ try {
1434
+ return await readFile(file, 'utf8');
1435
+ } catch {
1436
+ return '';
1437
+ }
1366
1438
  }
1367
- function indent(depth) {
1368
- return ' '.repeat(depth);
1439
+ function isAlive(pid) {
1440
+ try {
1441
+ process.kill(pid, 0);
1442
+ return true;
1443
+ } catch (error) {
1444
+ return error.code === 'EPERM';
1445
+ }
1369
1446
  }
1370
1447
 
1371
- /** A recipe named a hook nothing registered. */
1372
- class UnknownHookError extends Error {
1373
- hookName;
1374
- name = 'UnknownHookError';
1375
- constructor(hookName, known) {
1376
- super(`unknown hook "${hookName}"${known.length === 0 ? ' (no hooks registered)' : `; registered: ${known.join(', ')}`}`);
1377
- this.hookName = hookName;
1448
+ /**
1449
+ * Disposes a runner, ignoring a failure: a tab whose browser already went
1450
+ * away (a rotation, a crash) has nothing left to close.
1451
+ *
1452
+ * @param runner - The runner.
1453
+ */
1454
+ async function disposeQuietly(runner) {
1455
+ try {
1456
+ await runner.dispose();
1457
+ } catch {
1458
+ // already gone
1378
1459
  }
1379
1460
  }
1380
1461
 
1381
- /** The hooks a crawler was created with, resolved by name at run time. */
1382
- class HookRegistry {
1383
- hooks = {};
1384
- constructor(hooks = {}) {
1385
- for (const [name, hook] of Object.entries(hooks)) this.register(name, hook);
1386
- }
1387
- register(name, hook) {
1388
- this.hooks[name] = hook;
1389
- }
1390
- has(name) {
1391
- return Object.hasOwn(this.hooks, name);
1392
- }
1393
- names() {
1394
- return Object.keys(this.hooks);
1395
- }
1396
- /**
1397
- * @param name - The name a recipe used.
1398
- * @returns The hook.
1399
- * @throws UnknownHookError when nothing was registered under that name.
1400
- */
1401
- resolve(name) {
1402
- if (!this.has(name)) throw new UnknownHookError(name, this.names());
1403
- return this.hooks[name];
1462
+ /**
1463
+ * Runs a body once per item of a list (`over`), or once per live element
1464
+ * matching `selector`, each in a fresh child scope with the item bound under
1465
+ * `as`; emits a record per iteration when asked.
1466
+ *
1467
+ * With a concurrent gate, iterations of a list run as permits allow and
1468
+ * records come out in completion order; without one, in list order. In web
1469
+ * mode each parallel iteration runs in a tab of its own (`runner.fork`); a
1470
+ * loop over live elements stays sequential, since its elements live on one page.
1471
+ *
1472
+ * @param step - The forEach step.
1473
+ * @param scope - The scope the list lives in.
1474
+ * @param walk - Runs a step list; also carries the emit callback.
1475
+ * @returns `stop` when the crawl reached its record limit.
1476
+ */
1477
+ async function runForEach(step, scope, walk) {
1478
+ const items = await itemsOf$1(step, scope, walk);
1479
+ const gate = walk.gate;
1480
+ if (gate?.concurrent === true && step.selector === undefined && (walk.recipe.mode === 'api' || walk.runner.fork !== undefined)) return runPooled(step, scope, walk, items, gate);
1481
+ for (const item of items) {
1482
+ if ((await runIteration(step, scope, walk, item)) === 'stop') return 'stop';
1404
1483
  }
1484
+ return 'continue';
1405
1485
  }
1406
-
1407
- /** @returns A new in-memory sink. */
1408
- function memorySink() {
1409
- const records = [];
1410
- return {
1411
- records,
1412
- open: async () => {},
1413
- write: async record => {
1414
- records.push(record);
1415
- },
1416
- has: async key => records.some(record => record.key === key),
1417
- close: async () => ({
1418
- written: records.length
1419
- })
1420
- };
1486
+ async function runIteration(step, scope, walk, item, overrides) {
1487
+ const child = scope.child();
1488
+ child.set(step.as, item);
1489
+ const outcome = await walk.runSteps(step.steps, child, `${walk.path}.steps`, overrides);
1490
+ if (outcome === 'stop' || step.emit === undefined) return outcome;
1491
+ return walk.onEmit(child, step.emit === true ? undefined : step.emit.output);
1421
1492
  }
1422
-
1423
1493
  /**
1424
- * A sink that writes one JSON object per line to a file (JSON Lines). Each line
1425
- * is the record's `data` plus a `_source` member (and `_key` in append mode).
1426
- *
1427
- * @param path - The file to write; created with its directories, truncated on open unless `append`.
1428
- * @param options - Append mode.
1429
- * @returns The sink.
1430
- */
1431
- function jsonLinesSink(path, options = {}) {
1432
- const target = resolve$1(path);
1433
- const append = options.append === true;
1434
- const keys = new Set();
1435
- let stream;
1436
- let written = 0;
1437
- return {
1438
- async open() {
1439
- await mkdir(dirname(target), {
1440
- recursive: true
1441
- });
1442
- const existing = append ? await existingKeys(target) : [];
1443
- for (const key of existing) keys.add(key);
1444
- stream = createWriteStream(target, {
1445
- encoding: 'utf8',
1446
- flags: append ? 'a' : 'w'
1447
- });
1448
- await once(stream, 'open');
1449
- },
1450
- async write(record) {
1451
- if (stream === undefined) throw new Error('jsonLinesSink: write before open');
1452
- const line = `${JSON.stringify({
1453
- ...record.data,
1454
- _source: record.source,
1455
- ...(append && record.key !== null && {
1456
- _key: record.key
1457
- })
1458
- })}\n`;
1459
- if (!stream.write(line)) await once(stream, 'drain');
1460
- if (record.key !== null) keys.add(record.key);
1461
- written += 1;
1462
- },
1463
- async close() {
1464
- if (stream !== undefined) {
1465
- stream.end();
1466
- await once(stream, 'finish');
1467
- stream = undefined;
1468
- }
1469
- return {
1470
- written,
1471
- location: target
1472
- };
1473
- },
1474
- async has(key) {
1475
- return keys.has(key);
1476
- }
1477
- };
1478
- }
1479
- /** The `_key` of every line already in the file; none when the file does not exist. */
1480
- async function existingKeys(target) {
1481
- let content;
1482
- try {
1483
- content = await readFile(target, 'utf8');
1484
- } catch {
1485
- return [];
1486
- }
1487
- return content.split('\n').flatMap(line => {
1488
- if (line.trim() === '') return [];
1489
- try {
1490
- const key = JSON.parse(line)._key;
1491
- return typeof key === 'string' ? [key] : [];
1492
- } catch {
1493
- return [];
1494
- }
1495
- });
1496
- }
1497
-
1498
- /** Drops records whose key was already seen. First record wins; keyless records always pass. */
1499
- class DedupePolicy {
1500
- scope;
1501
- seen = new Set();
1502
- constructor(scope = 'run') {
1503
- this.scope = scope;
1504
- }
1505
- /** Called when an input recipe starts; forgets keys under `recipe` scope. */
1506
- startRecipe() {
1507
- if (this.scope === 'recipe') this.seen = new Set();
1508
- }
1509
- /**
1510
- * @param record - A validated record.
1511
- * @returns `true` when the record repeats an earlier key and must be dropped.
1512
- */
1513
- isDuplicate(record) {
1514
- if (this.scope === 'off' || record.key === null) return false;
1515
- if (this.seen.has(record.key)) return true;
1516
- this.seen.add(record.key);
1517
- return false;
1518
- }
1519
- }
1520
-
1521
- /**
1522
- * Evaluates a JSONPath expression on a decoded JSON document.
1523
- *
1524
- * @param document - The JSON value.
1525
- * @param path - A JSONPath such as `$.items[*].url`.
1526
- * @returns Every match, in document order.
1527
- */
1528
- function selectJson(document, path) {
1529
- return JSONPath({
1530
- path,
1531
- json: document,
1532
- wrap: true,
1533
- resultType: 'value'
1534
- });
1535
- }
1536
-
1537
- /**
1538
- * Evaluates a regular expression on text: the extract kind for values that
1539
- * live in inline scripts, attributes or prose rather than in elements or JSON.
1540
- *
1541
- * @param text - The document text.
1542
- * @param pattern - A regular expression source; group 1 is returned when the
1543
- * pattern has a capturing group, else the whole match.
1544
- * @returns Every match, in document order.
1545
- * @throws When the pattern is not a valid regular expression.
1546
- */
1547
- function selectRegex(text, pattern) {
1548
- let expression;
1549
- try {
1550
- expression = new RegExp(pattern, 'gs');
1551
- } catch (error) {
1552
- throw new Error(`invalid pattern ${pattern}: ${error.message}`, {
1553
- cause: error
1554
- });
1555
- }
1556
- const values = Array.from(text.matchAll(expression), match => match[1] ?? match[0]);
1557
- return values;
1558
- }
1559
-
1560
- const DOCUMENT = /^\s*(?:<!doctype|<html)/i;
1561
- /**
1562
- * Runs a CSS selector on static HTML. A whole document is parsed as one; anything
1563
- * else (a table row, a list item taken with `take: "html"`) is parsed as a
1564
- * fragment, so cells and rows outside a table survive instead of being dropped.
1565
- *
1566
- * @param html - The markup (a whole document or a fragment).
1567
- * @param selector - A CSS selector.
1568
- * @returns Every match, in document order.
1569
- */
1570
- function selectHtml(html, selector) {
1571
- const api = DOCUMENT.test(html) ? load(html) : load(html, undefined, false);
1572
- return api(selector).map((_, element) => ({
1573
- api,
1574
- element: api(element)
1575
- })).toArray();
1576
- }
1577
-
1578
- /**
1579
- * The value of an HTML match.
1580
- *
1581
- * @param match - A selected element.
1582
- * @param take - What to take; `text` collapses whitespace.
1583
- * @returns The value; `undefined` for a missing attribute.
1584
- */
1585
- function takeFromHtml(match, take) {
1586
- if (take === 'text') return collapse(match.element.text());
1587
- if (take === 'html') return match.element.html() ?? '';
1588
- if (take === 'value') return match.element.val() ?? match.element.attr('value');
1589
- if (take === 'json') return match.api.html(match.element);
1590
- return match.element.attr(take.slice('attr:'.length));
1591
- }
1592
- /**
1593
- * The value of a JSON match.
1594
- *
1595
- * @param node - A JSONPath result.
1596
- * @param take - `json` keeps the node; `text` stringifies scalars.
1597
- * @returns The value.
1598
- */
1599
- function takeFromJson(node, take) {
1600
- if (take === 'json') return node;
1601
- if (node === null || node === undefined) return undefined;
1602
- if (typeof node === 'object') return JSON.stringify(node);
1603
- return String(node);
1604
- }
1605
- /**
1606
- * Text as a human reads it: runs of whitespace collapsed, ends trimmed.
1607
- *
1608
- * @param text - Raw text content.
1609
- * @returns The collapsed text.
1610
- */
1611
- function collapse(text) {
1612
- return text.replaceAll(/\s+/g, ' ').trim();
1613
- }
1614
-
1615
- /**
1616
- * JSON that arrives as text: a `<script type="application/ld+json">` body, a
1617
- * `data-*` attribute, a fetched document read as text. Sites wrap inline JSON
1618
- * in comment guards, which are stripped before parsing.
1619
- */
1620
- /** Comment guards sites wrap inline JSON-LD in: a CDATA marker inside a block comment, or an HTML comment. */
1621
- const GUARDS = /^\s*(?:\/\*\s*<!\[CDATA\[\s*\*\/|<!\[CDATA\[|<!--)\s*|\s*(?:\/\*\s*\]\]>\s*\*\/|\]\]>|-->)\s*$/g;
1622
- /**
1623
- * Parses text as JSON, guards stripped.
1624
- *
1625
- * @param text - The text.
1626
- * @returns The value, or `undefined` when it is not JSON.
1627
- */
1628
- function tryParseJson(text) {
1629
- try {
1630
- return JSON.parse(text.replaceAll(GUARDS, ''));
1631
- } catch {
1632
- return undefined;
1633
- }
1634
- }
1635
- /**
1636
- * Parses text that must be JSON.
1637
- *
1638
- * @param text - The text.
1639
- * @param id - What the text is, for the error.
1640
- * @returns The value.
1641
- * @throws Error when it is not JSON.
1642
- */
1643
- function parseJsonText(text, id) {
1644
- const parsed = tryParseJson(text);
1645
- if (parsed === undefined) throw new Error(`"${id}" is text but not JSON`);
1646
- return parsed;
1647
- }
1648
- /**
1649
- * The data a value holds, whatever shape it arrived in: JSON text is parsed, a
1650
- * list of texts becomes the list of its parsable entries, and an entry that
1651
- * parses to a list is spliced in. Data that is not text is kept as is.
1652
- *
1653
- * @param value - A bound value: data, text, or a list of either.
1654
- * @returns A list of items.
1655
- */
1656
- function dataItemsOf(value) {
1657
- if (typeof value === 'string') return itemsOf$1(tryParseJson(value));
1658
- if (Array.isArray(value)) return value.flatMap(entry => typeof entry === 'string' ? itemsOf$1(tryParseJson(entry)) : [entry]);
1659
- return itemsOf$1(value);
1660
- }
1661
- function itemsOf$1(parsed) {
1662
- if (parsed === undefined || parsed === null) return [];
1663
- return Array.isArray(parsed) ? parsed : [parsed];
1664
- }
1665
-
1666
- /**
1667
- * Runs a body once per item of a list (`over`), or once per live element
1668
- * matching `selector`, each in a fresh child scope with the item bound under
1669
- * `as`; emits a record per iteration when asked.
1670
- *
1671
- * With a concurrent gate, iterations run as permits allow and records come
1672
- * out in completion order; without one, in list order.
1673
- *
1674
- * @param step - The forEach step.
1675
- * @param scope - The scope the list lives in.
1676
- * @param walk - Runs a step list; also carries the emit callback.
1677
- * @returns `stop` when the crawl reached its record limit.
1678
- */
1679
- async function runForEach(step, scope, walk) {
1680
- const items = await itemsOf(step, scope, walk);
1681
- const gate = walk.gate;
1682
- if (gate?.concurrent === true) return runPooled(step, scope, walk, items, gate);
1683
- for (const item of items) {
1684
- if ((await runIteration(step, scope, walk, item)) === 'stop') return 'stop';
1685
- }
1686
- return 'continue';
1687
- }
1688
- async function runIteration(step, scope, walk, item, overrides) {
1689
- const child = scope.child();
1690
- child.set(step.as, item);
1691
- const outcome = await walk.runSteps(step.steps, child, `${walk.path}.steps`, overrides);
1692
- if (outcome === 'stop' || step.emit === undefined) return outcome;
1693
- return walk.onEmit(child, step.emit === true ? undefined : step.emit.output);
1694
- }
1695
- /**
1696
- * Starts iterations as the gate hands out permits. A `stop` or a failure stops
1697
- * new iterations; the ones in flight finish first, so the runner is never
1698
- * disposed under them. The first failure is rethrown afterwards.
1494
+ * Starts iterations as the gate hands out permits. A `stop` or a failure stops
1495
+ * new iterations; the ones in flight finish first, so the runner is never
1496
+ * disposed under them. The first failure is rethrown afterwards.
1699
1497
  */
1700
1498
  async function runPooled(step, scope, walk, items, gate) {
1701
1499
  let stopped = false;
1702
1500
  let failure;
1703
1501
  const tasks = [];
1704
- const overrides = {
1705
- gate: gate.nested()
1706
- };
1502
+ const nested = gate.nested();
1707
1503
  const iterate = async (item, release) => {
1504
+ let runner;
1708
1505
  try {
1709
- if ((await runIteration(step, scope, walk, item, overrides)) === 'stop') stopped = true;
1506
+ runner = walk.runner.fork === undefined ? walk.runner : await walk.runner.fork();
1507
+ if ((await runIteration(step, scope, walk, item, {
1508
+ gate: nested,
1509
+ runner
1510
+ })) === 'stop') stopped = true;
1710
1511
  } catch (error) {
1711
1512
  failure ??= {
1712
1513
  error
1713
1514
  };
1714
1515
  } finally {
1516
+ if (runner !== undefined && runner !== walk.runner) await disposeQuietly(runner);
1715
1517
  release();
1716
1518
  }
1717
1519
  };
@@ -1728,7 +1530,7 @@ async function runPooled(step, scope, walk, items, gate) {
1728
1530
  if (failure !== undefined) throw failure.error;
1729
1531
  return stopped ? 'stop' : 'continue';
1730
1532
  }
1731
- async function itemsOf(step, scope, walk) {
1533
+ async function itemsOf$1(step, scope, walk) {
1732
1534
  if (step.selector !== undefined) {
1733
1535
  if (walk.runner.elements === undefined) throw new Error('forEach over selector iterates live elements and needs a browser; this recipe runs in api mode');
1734
1536
  return walk.runner.elements(renderText(step.selector, path => scope.lookup(path)), scope);
@@ -2019,6 +1821,7 @@ function logThrough(walk) {
2019
1821
  class RunGate {
2020
1822
  permits;
2021
1823
  minIntervalMs;
1824
+ hosts;
2022
1825
  shared;
2023
1826
  inFlight = 0;
2024
1827
  waiting = [];
@@ -2026,11 +1829,13 @@ class RunGate {
2026
1829
  /**
2027
1830
  * @param permits - Iterations allowed in flight; 1 is sequential.
2028
1831
  * @param minIntervalMs - Minimum time between two request starts across the run.
1832
+ * @param hosts - The crawler's per-site throttle, shared with every other recipe.
2029
1833
  * @param shared - The throttle state to share (internal: `nested` gates keep their parent's).
2030
1834
  */
2031
- constructor(permits, minIntervalMs, shared) {
1835
+ constructor(permits, minIntervalMs, hosts, shared) {
2032
1836
  this.permits = permits;
2033
1837
  this.minIntervalMs = minIntervalMs;
1838
+ this.hosts = hosts;
2034
1839
  this.shared = shared;
2035
1840
  }
2036
1841
  /** Whether this gate lets more than one iteration run at once. */
@@ -2054,57 +1859,1353 @@ class RunGate {
2054
1859
  this.inFlight -= 1;
2055
1860
  this.waiting.shift()?.();
2056
1861
  };
2057
- }
2058
- /**
2059
- * Waits until a request may start: `minIntervalMs` after the previous start,
2060
- * whichever loop started it. Returns at once when the interval has passed.
2061
- */
2062
- async throttle() {
2063
- const state = this.shared ?? this;
2064
- if (state.minIntervalMs <= 0) return;
2065
- const now = Date.now();
2066
- const at = Math.max(now, state.lastStart + state.minIntervalMs);
2067
- state.lastStart = at;
2068
- await sleep(at - now);
2069
- }
2070
- /** The gate for a body running inside an iteration that holds a permit: sequential, same throttle. */
2071
- nested() {
2072
- return new RunGate(1, this.minIntervalMs, this.shared ?? this);
1862
+ }
1863
+ /**
1864
+ * Waits until a request may start: `minIntervalMs` after the previous start,
1865
+ * whichever loop started it. Returns at once when the interval has passed.
1866
+ */
1867
+ async throttle() {
1868
+ const state = this.shared ?? this;
1869
+ if (state.minIntervalMs <= 0) return;
1870
+ const now = Date.now();
1871
+ const at = Math.max(now, state.lastStart + state.minIntervalMs);
1872
+ state.lastStart = at;
1873
+ await sleep(at - now);
1874
+ }
1875
+ /**
1876
+ * Waits until a request to `url` may start: the recipe's interval, then its
1877
+ * site's turn in the crawler's per-site throttle.
1878
+ *
1879
+ * @param url - Where the request goes.
1880
+ * @returns The release of the site's lane: call it once the response arrived or the request failed.
1881
+ */
1882
+ async request(url) {
1883
+ await this.throttle();
1884
+ return this.hosts === undefined ? noop$1 : this.hosts.slot(url);
1885
+ }
1886
+ /** The gate for a body running inside an iteration that holds a permit: sequential, same throttle. */
1887
+ nested() {
1888
+ return new RunGate(1, this.minIntervalMs, this.hosts, this.shared ?? this);
1889
+ }
1890
+ }
1891
+ function noop$1() {}
1892
+
1893
+ /**
1894
+ * Spaces and bounds requests per site, shared by every recipe of a crawler,
1895
+ * so two recipes (or two parallel iterations) that hit one site add up to one
1896
+ * polite client rather than two. A recipe's own `limits.delayMs` still applies
1897
+ * on top, per recipe.
1898
+ *
1899
+ * Like a single-lane bridge with a traffic light: whoever arrives waits for
1900
+ * the car ahead to be far enough, and for a free lane.
1901
+ */
1902
+ class HostThrottle {
1903
+ config;
1904
+ buckets = new Map();
1905
+ domains;
1906
+ constructor(config = {}) {
1907
+ this.config = config;
1908
+ this.domains = Object.entries(config.domains ?? {}).map(([domain, rule]) => [domain.toLowerCase().replace(/^\.+/, ''), rule]).sort((first, second) => second[0].length - first[0].length);
1909
+ }
1910
+ bucketFor(url, always = false) {
1911
+ const host = hostOf(url);
1912
+ if (host === undefined) return undefined;
1913
+ const match = this.domains.find(([domain]) => host === domain || host.endsWith(`.${domain}`));
1914
+ const key = match?.[0] ?? host;
1915
+ let bucket = this.buckets.get(key);
1916
+ if (bucket === undefined) {
1917
+ const rule = {
1918
+ ...pick(this.config),
1919
+ ...match?.[1]
1920
+ };
1921
+ if (!always && !hasLimit(rule)) return undefined;
1922
+ bucket = {
1923
+ rule,
1924
+ inFlight: 0,
1925
+ waiting: [],
1926
+ lastStart: Promise.resolve(-Infinity),
1927
+ pausedTo: 0
1928
+ };
1929
+ this.buckets.set(key, bucket);
1930
+ }
1931
+ return bucket;
1932
+ }
1933
+ /** Whether any rule can hold a request back. */
1934
+ get active() {
1935
+ return hasLimit(this.config) || this.domains.some(([, rule]) => hasLimit(rule)) || this.buckets.size > 0;
1936
+ }
1937
+ /**
1938
+ * Waits until a request to `url` may start, then holds one of its site's
1939
+ * lanes until the returned release is called.
1940
+ *
1941
+ * @param url - Where the request goes; anything but `http(s):` passes at once.
1942
+ * @returns The release: call it once, when the response arrived or the request failed.
1943
+ */
1944
+ async slot(url) {
1945
+ const bucket = this.bucketFor(url);
1946
+ if (bucket === undefined) return noop;
1947
+ const concurrency = bucket.rule.concurrency ?? Infinity;
1948
+ if (bucket.inFlight >= concurrency) await new Promise(resolve => {
1949
+ bucket.waiting.push(resolve);
1950
+ });
1951
+ bucket.inFlight += 1;
1952
+ const previous = bucket.lastStart;
1953
+ const turn = (async () => {
1954
+ const after = await previous;
1955
+ // A pause may arrive while waiting (a Retry-After), so check it again after each sleep.
1956
+ for (;;) {
1957
+ const now = Date.now();
1958
+ const at = Math.max(now, after + (bucket.rule.delayMs ?? 0), bucket.pausedTo);
1959
+ if (at <= now) return now;
1960
+ await sleep(at - now);
1961
+ }
1962
+ })();
1963
+ bucket.lastStart = turn;
1964
+ await turn;
1965
+ let released = false;
1966
+ return () => {
1967
+ if (released) return;
1968
+ released = true;
1969
+ bucket.inFlight -= 1;
1970
+ bucket.waiting.shift()?.();
1971
+ };
1972
+ }
1973
+ /**
1974
+ * Holds every request to the site of `url` back until `untilMs` (a `Retry-After`).
1975
+ *
1976
+ * @param url - A URL of the site.
1977
+ * @param untilMs - An epoch time.
1978
+ */
1979
+ pause(url, untilMs) {
1980
+ const bucket = this.bucketFor(url, true);
1981
+ if (bucket !== undefined) bucket.pausedTo = Math.max(bucket.pausedTo, untilMs);
1982
+ }
1983
+ }
1984
+ function noop() {}
1985
+ function hostOf(url) {
1986
+ try {
1987
+ const parsed = new URL(url);
1988
+ return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? parsed.hostname.toLowerCase() : undefined;
1989
+ } catch {
1990
+ return undefined;
1991
+ }
1992
+ }
1993
+ function pick(config) {
1994
+ return {
1995
+ delayMs: config.delayMs,
1996
+ concurrency: config.concurrency
1997
+ };
1998
+ }
1999
+ function hasLimit(rule) {
2000
+ return (rule.delayMs ?? 0) > 0 || rule.concurrency !== undefined;
2001
+ }
2002
+
2003
+ /** Statuses a server uses for "not now": timeout, too early, too many requests, and the 5xx that pass. */
2004
+ const RETRY_STATUSES = [408, 425, 429, 500, 502, 503, 504];
2005
+ /** Three tries, one second then two apart, never a wait over 30 seconds. */
2006
+ const DEFAULT_RETRY_RULE = {
2007
+ attempts: 3,
2008
+ backoffMs: 1000,
2009
+ maxDelayMs: 30_000,
2010
+ statuses: [...RETRY_STATUSES]
2011
+ };
2012
+ /**
2013
+ * Errors that say the connection failed rather than the site answered:
2014
+ * resets, refusals, timeouts, a DNS lookup that could not run, a proxy that
2015
+ * dropped the tunnel. A name that does not resolve at all (`ENOTFOUND`,
2016
+ * `ERR_NAME_NOT_RESOLVED`) is not here: retrying a typo only wastes time.
2017
+ */
2018
+ const TRANSIENT_ERROR = /ECONNRESET|ECONNREFUSED|ETIMEDOUT|EPIPE|EAI_AGAIN|ENETUNREACH|EHOSTUNREACH|socket hang up|net::ERR_(?:CONNECTION_(?:RESET|REFUSED|CLOSED|ABORTED|TIMED_OUT)|TIMED_OUT|EMPTY_RESPONSE|NETWORK_CHANGED|INTERNET_DISCONNECTED|PROXY_CONNECTION_FAILED|TUNNEL_CONNECTION_FAILED|HTTP2_PROTOCOL_ERROR|NETWORK_IO_SUSPENDED)|NS_ERROR_NET_(?:RESET|INTERRUPT|TIMEOUT)|Timeout \d+ms exceeded/;
2019
+ /**
2020
+ * The retry rule a recipe runs with: its `limits.retry` over the crawler's
2021
+ * default over `DEFAULT_RETRY_RULE`.
2022
+ *
2023
+ * @param own - The recipe's `limits.retry`.
2024
+ * @param crawler - The crawler's `retry` option.
2025
+ * @returns The rule.
2026
+ */
2027
+ function resolveRetryRule(own, crawler) {
2028
+ return {
2029
+ ...DEFAULT_RETRY_RULE,
2030
+ ...definedOf(crawler),
2031
+ ...definedOf(own)
2032
+ };
2033
+ }
2034
+ /**
2035
+ * Whether an error is a connection failure worth another try.
2036
+ *
2037
+ * @param error - What the request threw.
2038
+ * @returns The reason, or `undefined`.
2039
+ */
2040
+ function transientError(error) {
2041
+ const message = error instanceof Error ? error.message : String(error);
2042
+ const match = TRANSIENT_ERROR.exec(message);
2043
+ return match === null ? undefined : {
2044
+ reason: match[0]
2045
+ };
2046
+ }
2047
+ /**
2048
+ * How long to wait before attempt `attempt + 1`: the server's `Retry-After`
2049
+ * when it gave one, else `backoffMs` doubling per attempt with a little
2050
+ * jitter; `undefined` when the server asks for longer than `maxDelayMs` (it
2051
+ * means "come back much later", which a crawl cannot wait for).
2052
+ *
2053
+ * @param rule - The retry rule.
2054
+ * @param attempt - The attempt that just failed, from 1.
2055
+ * @param retryAfter - The `Retry-After` header: seconds, or an HTTP date.
2056
+ * @param now - The current time, for dates.
2057
+ * @returns Milliseconds, or `undefined` for no retry.
2058
+ */
2059
+ function retryDelay(rule, attempt, retryAfter, now = Date.now()) {
2060
+ const asked = retryAfterMs(retryAfter, now);
2061
+ if (asked !== undefined) return asked > rule.maxDelayMs ? undefined : asked;
2062
+ const base = rule.backoffMs * 2 ** (attempt - 1);
2063
+ const jittered = base * (0.75 + Math.random() * 0.5);
2064
+ return Math.min(Math.round(jittered), rule.maxDelayMs);
2065
+ }
2066
+ /**
2067
+ * Sends a request through the gate (the recipe's rate, the site's lane), and
2068
+ * sends it again after a pause while it fails in a passing way, up to
2069
+ * `rule.attempts` tries in all. A `Retry-After` holds back every request to
2070
+ * that site, not only this one. Each retry is reported as `request:retry`.
2071
+ *
2072
+ * Like redialling a busy number: wait a moment, dial again, give up after a
2073
+ * few tries; and if the other end said "call back in a minute", wait that minute.
2074
+ *
2075
+ * @param url - Where the request goes.
2076
+ * @param attempt - How to send it and how to judge the outcome.
2077
+ * @param context - The recipe, gate, events and rule.
2078
+ * @returns What the last try gave.
2079
+ * @throws What the last try threw.
2080
+ */
2081
+ async function withTransportRetry(url, attempt, context) {
2082
+ const {
2083
+ rule
2084
+ } = context;
2085
+ for (let tries = 1;; tries += 1) {
2086
+ const release = await context.gate.request(url);
2087
+ let outcome;
2088
+ try {
2089
+ outcome = {
2090
+ value: await attempt.run()
2091
+ };
2092
+ } catch (error) {
2093
+ outcome = {
2094
+ error
2095
+ };
2096
+ } finally {
2097
+ release();
2098
+ }
2099
+ const transient = tries < rule.attempts ? attempt.problem(outcome) : undefined;
2100
+ const delay = transient === undefined ? undefined : retryDelay(rule, tries, transient.retryAfter);
2101
+ if (transient === undefined || delay === undefined) {
2102
+ if ('error' in outcome) throw outcome.error;
2103
+ return outcome.value;
2104
+ }
2105
+ if (transient.retryAfter !== undefined) context.gate.hosts?.pause(url, Date.now() + delay);
2106
+ context.events.emit({
2107
+ type: 'request:retry',
2108
+ recipeId: context.recipeId,
2109
+ url,
2110
+ attempt: tries + 1,
2111
+ reason: transient.reason,
2112
+ delayMs: delay
2113
+ });
2114
+ await sleep(delay);
2115
+ }
2116
+ }
2117
+ function retryAfterMs(header, now) {
2118
+ if (header === undefined || header.trim() === '') return undefined;
2119
+ const seconds = Number(header.trim());
2120
+ if (Number.isFinite(seconds)) return Math.max(0, Math.round(seconds * 1000));
2121
+ const date = Date.parse(header);
2122
+ return Number.isNaN(date) ? undefined : Math.max(0, date - now);
2123
+ }
2124
+ function definedOf(rule) {
2125
+ return Object.fromEntries(Object.entries(rule ?? {}).filter(([, value]) => value !== undefined));
2126
+ }
2127
+
2128
+ /** A block unless a recipe says otherwise: forbidden, rate limited, or an AWS WAF challenge (IMDb answers 202 with it). */
2129
+ const DEFAULT_BLOCK_RULE = {
2130
+ status: [403, 429],
2131
+ header: {
2132
+ 'x-amzn-waf-action': 'challenge'
2133
+ }
2134
+ };
2135
+ /**
2136
+ * Whether a response is a block.
2137
+ *
2138
+ * @param response - What came back.
2139
+ * @param rule - The recipe's `session.blockedWhen`; `DEFAULT_BLOCK_RULE` when omitted.
2140
+ * @returns The error to throw, or `undefined` when the response is not a block.
2141
+ */
2142
+ async function detectBlock(response, rule = DEFAULT_BLOCK_RULE) {
2143
+ if (rule.status?.includes(response.status) === true) return new BlockedError(response.url, response.status, `HTTP ${response.status}`);
2144
+ const headers = Object.entries(rule.header ?? {});
2145
+ for (const [name, pattern] of headers) {
2146
+ const value = response.headers[name.toLowerCase()];
2147
+ if (value !== undefined && new RegExp(pattern, 'i').test(value)) return new BlockedError(response.url, response.status, `${name.toLowerCase()}: ${value}`);
2148
+ }
2149
+ if (rule.text !== undefined && response.text !== undefined) {
2150
+ let body = '';
2151
+ try {
2152
+ body = await response.text();
2153
+ } catch {
2154
+ // a body that cannot be read (a redirect, a download) cannot match
2155
+ }
2156
+ const pattern = new RegExp(rule.text, 'i');
2157
+ if (pattern.test(body)) return new BlockedError(response.url, response.status, `body matches /${rule.text}/i`);
2158
+ }
2159
+ return undefined;
2160
+ }
2161
+
2162
+ /**
2163
+ * A challenge the engine could not get past: the solver failed, the page did
2164
+ * not confirm it, or the budget ran out. It is a block, so a recipe with
2165
+ * `onBlock.rotate` retries the step on a new access lease (a new IP often
2166
+ * means an easier challenge, or none), then the step's error policy applies.
2167
+ */
2168
+ class CaptchaError extends BlockedError {
2169
+ kind;
2170
+ attempts;
2171
+ constructor(url, kind, attempts, reason) {
2172
+ super(url, 0, `captcha (${kind}) ${reason}`);
2173
+ this.kind = kind;
2174
+ this.attempts = attempts;
2175
+ }
2176
+ }
2177
+
2178
+ /** The captcha solvers a crawler was given, by name. */
2179
+ class CaptchaSolverRegistry {
2180
+ solvers = new Map();
2181
+ order = [];
2182
+ constructor(solvers = []) {
2183
+ for (const solver of solvers) {
2184
+ if (this.solvers.has(solver.name)) throw new Error(`two captcha solvers are named "${solver.name}"`);
2185
+ this.solvers.set(solver.name, solver);
2186
+ this.order.push(solver.name);
2187
+ }
2188
+ }
2189
+ /** The registered names. */
2190
+ get names() {
2191
+ return [...this.order];
2192
+ }
2193
+ has(name) {
2194
+ return this.solvers.has(name);
2195
+ }
2196
+ /**
2197
+ * The solver of that name.
2198
+ *
2199
+ * @param name - As a recipe names it.
2200
+ * @returns The solver.
2201
+ * @throws Error naming what is registered when it is not.
2202
+ */
2203
+ resolve(name) {
2204
+ const solver = this.solvers.get(name);
2205
+ if (solver === undefined) throw new Error(`captcha solver "${name}" is not registered (registered: ${this.names.join(', ') || 'none'}); give it to createCrawler({ captchaSolvers }) or export it from the plugins module`);
2206
+ return solver;
2207
+ }
2208
+ }
2209
+
2210
+ /** Default solves a recipe run may spend. */
2211
+ const DEFAULT_MAX_SOLVES = 10;
2212
+ /**
2213
+ * The solves a recipe run may still spend. Every solve costs money: a detector
2214
+ * that matches the wrong element would drain a balance without it. Shared by
2215
+ * every runner of the run, rotations included.
2216
+ */
2217
+ class CaptchaBudget {
2218
+ max;
2219
+ used = 0;
2220
+ constructor(max = DEFAULT_MAX_SOLVES) {
2221
+ this.max = max;
2222
+ }
2223
+ /** Solves spent so far. */
2224
+ get spent() {
2225
+ return this.used;
2226
+ }
2227
+ /**
2228
+ * Spends one solve.
2229
+ *
2230
+ * @returns Whether one was left.
2231
+ */
2232
+ take() {
2233
+ if (this.used >= this.max) return false;
2234
+ this.used += 1;
2235
+ return true;
2236
+ }
2237
+ }
2238
+
2239
+ /** The widgets detection looks for when a recipe names none: reCAPTCHA v2, hCaptcha and Turnstile, as a container or as their iframe. */
2240
+ const DEFAULT_CAPTCHA_SELECTOR = ['.g-recaptcha', 'iframe[src*="recaptcha/api2/anchor"]', 'iframe[src*="recaptcha/enterprise/anchor"]', '.h-captcha', 'iframe[src*="hcaptcha.com"]', '.cf-turnstile', 'iframe[src*="challenges.cloudflare.com"]'].join(', ');
2241
+ /** reCAPTCHA v3 runs without a widget: its script is loaded with the site key as `render`. */
2242
+ const RECAPTCHA_V3_SCRIPT = 'script[src*="recaptcha/api.js?render="], script[src*="recaptcha/enterprise.js?render="]';
2243
+ /** Matches past this many are not looked at: a page does not show more challenges than that. */
2244
+ const MAX_CANDIDATES = 10;
2245
+ /**
2246
+ * The first visible challenge on the page, if any. An element counts only
2247
+ * when visible: sites keep hidden widgets around after a solve, and an
2248
+ * invisible reCAPTCHA shows nothing until it challenges.
2249
+ *
2250
+ * @param page - The live page.
2251
+ * @param selector - Where challenges are; `DEFAULT_CAPTCHA_SELECTOR` when omitted.
2252
+ * @param options - `v3`: also report a reCAPTCHA v3 script (a `captcha` step asks for it; the automatic checks do not, since v3 never blocks a page by itself).
2253
+ * @returns The challenge, or `undefined`.
2254
+ */
2255
+ async function detectChallenge(page, selector = DEFAULT_CAPTCHA_SELECTOR, options = {}) {
2256
+ const matches = page.locator(selector);
2257
+ const count = Math.min(await matches.count(), MAX_CANDIDATES);
2258
+ for (let index = 0; index < count; index += 1) {
2259
+ const element = matches.nth(index);
2260
+ if (!(await element.isVisible())) continue;
2261
+ const facts = await element.evaluate(readWidget);
2262
+ return challengeOf(facts, page.url(), `${selector} >> nth=${index}`);
2263
+ }
2264
+ if (options.v3 !== true) return undefined;
2265
+ const script = page.locator(RECAPTCHA_V3_SCRIPT).first();
2266
+ if ((await script.count()) === 0) return undefined;
2267
+ const src = (await script.getAttribute('src')) ?? '';
2268
+ const siteKey = new URL(src, page.url()).searchParams.get('render') ?? undefined;
2269
+ return {
2270
+ kind: 'recaptcha-v3',
2271
+ url: page.url(),
2272
+ ...(siteKey !== undefined && siteKey !== 'explicit' && {
2273
+ siteKey
2274
+ })
2275
+ };
2276
+ }
2277
+ /**
2278
+ * Whether the page is clear of challenges, tolerating a page that is
2279
+ * navigating (a solve often submits a form): an evaluation cut short by the
2280
+ * navigation counts as not clear yet.
2281
+ *
2282
+ * @param page - The live page.
2283
+ * @param selector - Where challenges are.
2284
+ * @returns Whether no challenge is visible.
2285
+ */
2286
+ async function isClear(page, selector) {
2287
+ try {
2288
+ return (await detectChallenge(page, selector)) === undefined;
2289
+ } catch {
2290
+ return false;
2291
+ }
2292
+ }
2293
+ /** Runs inside the page. Keep it self-contained; it is serialised. */
2294
+ function readWidget(element) {
2295
+ const source = element.getAttribute('src') ?? element.querySelector('iframe')?.getAttribute('src') ?? '';
2296
+ return {
2297
+ tag: element.tagName.toLowerCase(),
2298
+ className: element.getAttribute('class') ?? '',
2299
+ src: source,
2300
+ siteKey: element.dataset.sitekey,
2301
+ action: element.dataset.action
2302
+ };
2303
+ }
2304
+ function challengeOf(facts, url, selector) {
2305
+ const siteKey = facts.siteKey ?? siteKeyIn(facts.src, url);
2306
+ return {
2307
+ kind: kindOf(facts),
2308
+ url,
2309
+ selector,
2310
+ ...(siteKey !== undefined && {
2311
+ siteKey
2312
+ }),
2313
+ ...(facts.action !== undefined && {
2314
+ action: facts.action
2315
+ })
2316
+ };
2317
+ }
2318
+ function kindOf(facts) {
2319
+ const hint = `${facts.className} ${facts.src}`.toLowerCase();
2320
+ if (hint.includes('recaptcha')) return 'recaptcha-v2';
2321
+ if (hint.includes('hcaptcha')) return 'hcaptcha';
2322
+ if (hint.includes('turnstile') || hint.includes('challenges.cloudflare.com')) return 'turnstile';
2323
+ return facts.tag === 'img' || facts.tag === 'canvas' ? 'image' : 'unknown';
2324
+ }
2325
+ /** A widget iframe carries its site key in the query: `k` (reCAPTCHA) or `sitekey` (hCaptcha). */
2326
+ function siteKeyIn(src, base) {
2327
+ if (src === '') return undefined;
2328
+ try {
2329
+ const parameters = new URL(src, base).searchParams;
2330
+ const hashParameters = new URLSearchParams(new URL(src, base).hash.slice(1));
2331
+ return parameters.get('k') ?? parameters.get('sitekey') ?? hashParameters.get('sitekey') ?? undefined;
2332
+ } catch {
2333
+ return undefined;
2334
+ }
2335
+ }
2336
+
2337
+ /** Default solves tried per challenge. */
2338
+ const DEFAULT_CAPTCHA_ATTEMPTS = 3;
2339
+ /** Default time one solve may take: token services take 10 to 60 seconds. */
2340
+ const DEFAULT_CAPTCHA_TIMEOUT_MS = 120_000;
2341
+ /** How long the page has to confirm a solve. */
2342
+ const VERIFY_TIMEOUT_MS = 10_000;
2343
+ const VERIFY_POLL_MS = 250;
2344
+ /**
2345
+ * Gets past one challenge: asks the solver, then checks the page (a solver's
2346
+ * `solved` is a claim; the challenge must be gone and/or the `verify` element
2347
+ * must appear), and tries again with what the page shows next, up to
2348
+ * `attempts`. Each try spends one solve of the run's budget.
2349
+ *
2350
+ * A failed try re-detects the challenge (a widget re-renders after a wrong
2351
+ * answer). When it is gone without the page confirming, the page is reloaded
2352
+ * for a fresh one; when a reload shows none, there is nothing left to solve.
2353
+ *
2354
+ * @param plan - The challenge, the solver and the limits.
2355
+ * @throws CaptchaError when every try failed, or the budget is spent.
2356
+ */
2357
+ async function resolveCaptcha(plan) {
2358
+ const {
2359
+ recipeId,
2360
+ page,
2361
+ solver,
2362
+ events,
2363
+ budget
2364
+ } = plan;
2365
+ let challenge = plan.challenge;
2366
+ let reason = 'no attempt ran';
2367
+ for (let attempt = 1; attempt <= plan.attempts; attempt += 1) {
2368
+ if (!budget.take()) {
2369
+ events.emit({
2370
+ type: 'captcha:budget',
2371
+ recipeId,
2372
+ url: challenge.url,
2373
+ kind: challenge.kind,
2374
+ max: budget.max
2375
+ });
2376
+ throw new CaptchaError(challenge.url, challenge.kind, attempt - 1, `left unsolved: the run's ${budget.max} solves are spent (session.captcha.maxSolves)`);
2377
+ }
2378
+ events.emit({
2379
+ type: 'captcha:solve',
2380
+ recipeId,
2381
+ url: challenge.url,
2382
+ kind: challenge.kind,
2383
+ solver: solver.name,
2384
+ attempt
2385
+ });
2386
+ const started = Date.now();
2387
+ const outcome = await solveOnce(plan, challenge, attempt);
2388
+ if (outcome.status === 'solved' && (await confirmed(page, challenge, plan))) {
2389
+ events.emit({
2390
+ type: 'captcha:solved',
2391
+ recipeId,
2392
+ url: challenge.url,
2393
+ kind: challenge.kind,
2394
+ solver: solver.name,
2395
+ attempt,
2396
+ durationMs: Date.now() - started
2397
+ });
2398
+ return;
2399
+ }
2400
+ reason = outcome.status === 'failed' ? outcome.reason : 'the page still shows the challenge';
2401
+ events.emit({
2402
+ type: 'captcha:failed',
2403
+ recipeId,
2404
+ url: challenge.url,
2405
+ kind: challenge.kind,
2406
+ solver: solver.name,
2407
+ attempt,
2408
+ reason
2409
+ });
2410
+ if (attempt === plan.attempts) break;
2411
+ const next = await nextChallenge(page, challenge, plan.selector);
2412
+ if (next === undefined) return;
2413
+ challenge = next;
2414
+ }
2415
+ throw new CaptchaError(challenge.url, challenge.kind, plan.attempts, `not solved after ${plan.attempts} attempt${plan.attempts === 1 ? '' : 's'}: ${reason}`);
2416
+ }
2417
+ /** Runs the solver once, bounded by the timeout; a throw or a malformed answer is a failure. */
2418
+ async function solveOnce(plan, challenge, attempt) {
2419
+ const controller = new AbortController();
2420
+ let timer;
2421
+ const timeout = new Promise(resolve => {
2422
+ timer = setTimeout(() => {
2423
+ controller.abort();
2424
+ resolve({
2425
+ status: 'failed',
2426
+ reason: `the solver took longer than ${plan.timeoutMs} ms`
2427
+ });
2428
+ }, plan.timeoutMs);
2429
+ });
2430
+ const log = (level, message, meta) => {
2431
+ plan.events.emit({
2432
+ type: level === 'error' ? 'error' : 'warning',
2433
+ recipeId: plan.recipeId,
2434
+ message: `[${plan.solver.name}] ${message}`,
2435
+ meta
2436
+ });
2437
+ };
2438
+ const solving = (async () => {
2439
+ try {
2440
+ return outcomeOf(await plan.solver.solve(challenge, {
2441
+ page: plan.page,
2442
+ lease: plan.lease,
2443
+ attempt,
2444
+ signal: controller.signal,
2445
+ log
2446
+ }));
2447
+ } catch (error) {
2448
+ return {
2449
+ status: 'failed',
2450
+ reason: error instanceof Error ? error.message : String(error)
2451
+ };
2452
+ }
2453
+ })();
2454
+ try {
2455
+ return await Promise.race([solving, timeout]);
2456
+ } finally {
2457
+ clearTimeout(timer);
2458
+ }
2459
+ }
2460
+ function outcomeOf(value) {
2461
+ if (typeof value !== 'object' || value === null) return {
2462
+ status: 'failed',
2463
+ reason: 'the solver returned no outcome'
2464
+ };
2465
+ const outcome = value;
2466
+ if (outcome.status === 'solved') return {
2467
+ status: 'solved'
2468
+ };
2469
+ return {
2470
+ status: 'failed',
2471
+ reason: typeof outcome.reason === 'string' ? outcome.reason : 'the solver reported a failure'
2472
+ };
2473
+ }
2474
+ /**
2475
+ * Whether the page confirms the solve: the challenge is gone (unless
2476
+ * `verify.gone` is `false`, or the challenge has no widget, as with reCAPTCHA
2477
+ * v3) and the `verify.selector` element is visible, within ten seconds.
2478
+ */
2479
+ async function confirmed(page, challenge, plan) {
2480
+ const needGone = plan.verify?.gone !== false && challenge.selector !== undefined;
2481
+ const shown = plan.verify?.selector;
2482
+ if (!needGone && shown === undefined) return true;
2483
+ const deadline = Date.now() + VERIFY_TIMEOUT_MS;
2484
+ for (;;) {
2485
+ const gone = !needGone || (await isClear(page, plan.selector));
2486
+ const visible = shown === undefined || (await isVisible(page, shown));
2487
+ if (gone && visible) return true;
2488
+ if (Date.now() >= deadline) return false;
2489
+ await page.waitForTimeout(VERIFY_POLL_MS);
2490
+ }
2491
+ }
2492
+ async function isVisible(page, selector) {
2493
+ try {
2494
+ return await page.locator(selector).first().isVisible();
2495
+ } catch {
2496
+ return false;
2497
+ }
2498
+ }
2499
+ /** The challenge to try next: what the page shows now, else what a reload shows, else none. A widgetless challenge is tried as it is. */
2500
+ async function nextChallenge(page, previous, selector) {
2501
+ if (previous.selector === undefined) return previous;
2502
+ const current = await detectChallenge(page, selector);
2503
+ if (current !== undefined) return current;
2504
+ await page.reload();
2505
+ return detectChallenge(page, selector);
2506
+ }
2507
+
2508
+ /**
2509
+ * Where a web runner meets captchas: after each navigation, click and key
2510
+ * press (`session.captcha`), on a block page (`onBlock.solve`), and at a
2511
+ * `captcha` step.
2512
+ */
2513
+ class CaptchaGuard {
2514
+ options;
2515
+ constructor(options) {
2516
+ this.options = options;
2517
+ }
2518
+ settings() {
2519
+ const captcha = this.options.recipe.session?.captcha;
2520
+ if (captcha === undefined) return undefined;
2521
+ return {
2522
+ solver: captcha.solver,
2523
+ selector: captcha.detect?.selector ?? DEFAULT_CAPTCHA_SELECTOR,
2524
+ verify: captcha.verify,
2525
+ attempts: captcha.attempts ?? DEFAULT_CAPTCHA_ATTEMPTS,
2526
+ timeoutMs: captcha.timeoutMs ?? DEFAULT_CAPTCHA_TIMEOUT_MS
2527
+ };
2528
+ }
2529
+ async solve(page, challenge, settings) {
2530
+ const {
2531
+ recipe,
2532
+ events,
2533
+ solvers,
2534
+ budget,
2535
+ lease
2536
+ } = this.options;
2537
+ events.emit({
2538
+ type: 'captcha:detected',
2539
+ recipeId: recipe.id,
2540
+ url: challenge.url,
2541
+ kind: challenge.kind,
2542
+ siteKey: challenge.siteKey
2543
+ });
2544
+ await resolveCaptcha({
2545
+ recipeId: recipe.id,
2546
+ page,
2547
+ challenge,
2548
+ solver: solvers.resolve(settings.solver),
2549
+ selector: settings.selector,
2550
+ verify: settings.verify,
2551
+ attempts: settings.attempts,
2552
+ timeoutMs: settings.timeoutMs,
2553
+ budget,
2554
+ events,
2555
+ lease
2556
+ });
2557
+ }
2558
+ /** Whether a block page is searched for a challenge before the block counts. */
2559
+ get solvesBlocks() {
2560
+ const session = this.options.recipe.session;
2561
+ return session?.onBlock?.solve === true && session.captcha !== undefined;
2562
+ }
2563
+ /**
2564
+ * The automatic check: with `session.captcha`, solves the challenge the page
2565
+ * shows, if any. Without it, nothing is looked for.
2566
+ *
2567
+ * @param page - The live page.
2568
+ * @throws CaptchaError when the challenge could not be solved.
2569
+ */
2570
+ async check(page) {
2571
+ const settings = this.settings();
2572
+ if (settings === undefined) return;
2573
+ const challenge = await detectChallenge(page, settings.selector);
2574
+ if (challenge !== undefined) await this.solve(page, challenge, settings);
2575
+ }
2576
+ /**
2577
+ * A block page under `onBlock.solve`: solves the challenge it shows. A block
2578
+ * without a challenge stays a block.
2579
+ *
2580
+ * @param page - The page showing the block.
2581
+ * @param blocked - The block.
2582
+ * @throws BlockedError (`blocked`) when the page shows no challenge; CaptchaError when it could not be solved.
2583
+ */
2584
+ async solveBlock(page, blocked) {
2585
+ const settings = this.settings();
2586
+ if (settings === undefined) throw blocked;
2587
+ const challenge = await detectChallenge(page, settings.selector);
2588
+ if (challenge === undefined) throw blocked;
2589
+ await this.solve(page, challenge, settings);
2590
+ }
2591
+ /**
2592
+ * A `captcha` step: solves the challenge the page shows, reCAPTCHA v3
2593
+ * included; a page without one is fine.
2594
+ *
2595
+ * @param page - The live page.
2596
+ * @param step - The step.
2597
+ * @throws CaptchaError when the challenge could not be solved.
2598
+ */
2599
+ async step(page, step) {
2600
+ const base = this.settings();
2601
+ const solver = step.solver ?? base?.solver;
2602
+ if (solver === undefined) throw new Error('a captcha step needs a solver: name one ("solver") or add session.captcha');
2603
+ const settings = {
2604
+ solver,
2605
+ selector: step.selector ?? base?.selector ?? DEFAULT_CAPTCHA_SELECTOR,
2606
+ verify: step.verify ?? base?.verify,
2607
+ attempts: step.attempts ?? base?.attempts ?? DEFAULT_CAPTCHA_ATTEMPTS,
2608
+ timeoutMs: step.timeoutMs ?? base?.timeoutMs ?? DEFAULT_CAPTCHA_TIMEOUT_MS
2609
+ };
2610
+ const challenge = await detectChallenge(page, settings.selector, {
2611
+ v3: true
2612
+ });
2613
+ if (challenge !== undefined) await this.solve(page, challenge, settings);
2614
+ }
2615
+ }
2616
+ /**
2617
+ * Every solver name a recipe uses (`session.captcha` and its `captcha`
2618
+ * steps, the bootstrap's included), to check them before the run starts.
2619
+ *
2620
+ * @param recipe - The input recipe.
2621
+ * @returns The names, without repeats.
2622
+ */
2623
+ function captchaSolverNames(recipe) {
2624
+ const names = new Set();
2625
+ const fallback = recipe.session?.captcha?.solver;
2626
+ if (fallback !== undefined) names.add(fallback);
2627
+ const visit = steps => {
2628
+ for (const step of steps) {
2629
+ if (step.type === 'captcha') names.add(step.solver ?? fallback ?? '');
2630
+ if ('steps' in step) visit(step.steps);
2631
+ if (step.type === 'if') visit(step.else ?? []);
2632
+ }
2633
+ };
2634
+ visit(recipe.steps);
2635
+ visit(recipe.session?.bootstrap?.steps ?? []);
2636
+ names.delete('');
2637
+ return [...names];
2638
+ }
2639
+
2640
+ /** Fans crawl events out to listeners. A listener that throws never breaks the crawl. */
2641
+ class EventBus {
2642
+ listeners = new Set();
2643
+ constructor(listener) {
2644
+ if (listener !== undefined) this.listeners.add(listener);
2645
+ }
2646
+ /** @returns A function that removes the listener. */
2647
+ subscribe(listener) {
2648
+ this.listeners.add(listener);
2649
+ return () => {
2650
+ this.listeners.delete(listener);
2651
+ };
2652
+ }
2653
+ emit(input) {
2654
+ const event = {
2655
+ ...input,
2656
+ at: new Date().toISOString()
2657
+ };
2658
+ for (const listener of this.listeners) {
2659
+ try {
2660
+ listener(event);
2661
+ } catch {
2662
+ // A faulty listener is the caller's problem, not the crawl's.
2663
+ }
2664
+ }
2665
+ }
2666
+ }
2667
+
2668
+ /**
2669
+ * One line of a crawl trace: the route a recipe takes (pages visited, steps
2670
+ * run, records produced, policies fired), indented by how deep in the step
2671
+ * tree the event happened. `step:start` yields nothing; `step:finish` carries
2672
+ * the duration, so every step prints once.
2673
+ *
2674
+ * @param event - Any crawl event.
2675
+ * @returns The line, or `undefined` for events a trace does not show.
2676
+ */
2677
+ function traceLine(event) {
2678
+ switch (event.type) {
2679
+ case 'recipe:start':
2680
+ {
2681
+ return `▶ ${event.recipeId} (${event.mode})`;
2682
+ }
2683
+ case 'recipe:finish':
2684
+ {
2685
+ return `■ ${event.recipeId}: ${event.emitted} emitted, ${event.rejected} rejected, ${event.duplicates} duplicates, ${event.skipped > 0 ? `${event.skipped} skipped, ` : ''}${(event.stepsSkipped ?? 0) > 0 ? `${event.stepsSkipped} steps skipped, ` : ''}${event.pages} pages, ${event.durationMs} ms${event.error === undefined ? '' : `\n ✖ stopped: ${event.error}`}`;
2686
+ }
2687
+ case 'access:lease':
2688
+ {
2689
+ return `${indent(1)}⇄ access ${event.profile} (${event.kind}${event.server === undefined ? '' : ` ${event.server}`}${event.session === undefined ? '' : `, session ${event.session}`})`;
2690
+ }
2691
+ case 'access:blocked':
2692
+ {
2693
+ return `${indent(1)}⛔ blocked ${event.url}: ${event.reason}`;
2694
+ }
2695
+ case 'access:rotate':
2696
+ {
2697
+ return `${indent(1)}↻ new access lease (attempt ${event.attempt})`;
2698
+ }
2699
+ case 'request:retry':
2700
+ {
2701
+ return `${indent(1)}↺ ${event.url}: ${event.reason}, try ${event.attempt} in ${event.delayMs} ms`;
2702
+ }
2703
+ case 'captcha:detected':
2704
+ {
2705
+ return `${indent(1)}⚿ captcha ${event.kind} on ${event.url}`;
2706
+ }
2707
+ case 'captcha:solve':
2708
+ {
2709
+ return undefined;
2710
+ }
2711
+ case 'captcha:solved':
2712
+ {
2713
+ return `${indent(1)}✓ captcha solved by ${event.solver} (attempt ${event.attempt}, ${event.durationMs} ms)`;
2714
+ }
2715
+ case 'captcha:failed':
2716
+ {
2717
+ return `${indent(1)}✗ captcha attempt ${event.attempt} failed: ${event.reason}`;
2718
+ }
2719
+ case 'captcha:budget':
2720
+ {
2721
+ return `${indent(1)}⛔ captcha left unsolved: the run's ${event.max} solves are spent`;
2722
+ }
2723
+ case 'page:visit':
2724
+ {
2725
+ return `${indent(1)}⇢ page ${event.number} ${event.url}${event.status === undefined || event.status >= 200 && event.status < 300 ? '' : ` [${event.status}]`}`;
2726
+ }
2727
+ case 'step:start':
2728
+ {
2729
+ return undefined;
2730
+ }
2731
+ case 'step:finish':
2732
+ {
2733
+ return `${indent(depthOf$1(event.path))}· ${stepLabel(event)} ${event.durationMs} ms`;
2734
+ }
2735
+ case 'step:retry':
2736
+ {
2737
+ return `${indent(depthOf$1(event.path))}↻ ${stepLabel(event)} retry ${event.attempt}: ${event.error}`;
2738
+ }
2739
+ case 'step:skip':
2740
+ {
2741
+ return `${indent(depthOf$1(event.path))}↷ ${stepLabel(event)} skipped: ${event.error}`;
2742
+ }
2743
+ case 'step:branch':
2744
+ {
2745
+ return `${indent(depthOf$1(event.path))}⑂ ${event.path} ${event.branch}`;
2746
+ }
2747
+ case 'record:emit':
2748
+ {
2749
+ return `${indent(1)}✚ record ${event.key ?? '(no key)'}`;
2750
+ }
2751
+ case 'record:reject':
2752
+ {
2753
+ return `${indent(1)}✖ record rejected: ${event.field}: ${event.reason}`;
2754
+ }
2755
+ case 'record:duplicate':
2756
+ {
2757
+ return `${indent(1)}≡ duplicate ${event.key}`;
2758
+ }
2759
+ case 'record:skipped':
2760
+ {
2761
+ return `${indent(1)}⤼ skipped ${event.key}`;
2762
+ }
2763
+ case 'warning':
2764
+ {
2765
+ return `${indent(1)}! ${event.message}`;
2766
+ }
2767
+ case 'error':
2768
+ {
2769
+ return `${indent(1)}✖ ${event.message}`;
2770
+ }
2771
+ }
2772
+ }
2773
+ /** How deep a step path such as `steps.8.steps.2` or `steps.1.else.0` sits: one level per nested `steps` or `else`. */
2774
+ function depthOf$1(path) {
2775
+ return path.split('.').filter(segment => ['steps', 'else'].includes(segment)).length;
2776
+ }
2777
+ function stepLabel(event) {
2778
+ return `${event.path} ${event.stepType}${event.stepId === undefined ? '' : ` ${event.stepId}`}`;
2779
+ }
2780
+ function indent(depth) {
2781
+ return ' '.repeat(depth);
2782
+ }
2783
+
2784
+ /** A recipe named a hook nothing registered. */
2785
+ class UnknownHookError extends Error {
2786
+ hookName;
2787
+ name = 'UnknownHookError';
2788
+ constructor(hookName, known) {
2789
+ super(`unknown hook "${hookName}"${known.length === 0 ? ' (no hooks registered)' : `; registered: ${known.join(', ')}`}`);
2790
+ this.hookName = hookName;
2791
+ }
2792
+ }
2793
+
2794
+ /** The hooks a crawler was created with, resolved by name at run time. */
2795
+ class HookRegistry {
2796
+ hooks = {};
2797
+ constructor(hooks = {}) {
2798
+ for (const [name, hook] of Object.entries(hooks)) this.register(name, hook);
2799
+ }
2800
+ register(name, hook) {
2801
+ this.hooks[name] = hook;
2802
+ }
2803
+ has(name) {
2804
+ return Object.hasOwn(this.hooks, name);
2805
+ }
2806
+ names() {
2807
+ return Object.keys(this.hooks);
2808
+ }
2809
+ /**
2810
+ * @param name - The name a recipe used.
2811
+ * @returns The hook.
2812
+ * @throws UnknownHookError when nothing was registered under that name.
2813
+ */
2814
+ resolve(name) {
2815
+ if (!this.has(name)) throw new UnknownHookError(name, this.names());
2816
+ return this.hooks[name];
2817
+ }
2818
+ }
2819
+
2820
+ /** @returns A new in-memory sink. */
2821
+ function memorySink() {
2822
+ const records = [];
2823
+ return {
2824
+ records,
2825
+ open: async () => {},
2826
+ write: async record => {
2827
+ records.push(record);
2828
+ },
2829
+ has: async key => records.some(record => record.key === key),
2830
+ close: async () => ({
2831
+ written: records.length
2832
+ })
2833
+ };
2834
+ }
2835
+
2836
+ /**
2837
+ * A sink that writes one JSON object per line to a file (JSON Lines). Each line
2838
+ * is the record's `data` plus a `_source` member (and `_key` in append mode).
2839
+ *
2840
+ * @param path - The file to write; created with its directories, truncated on open unless `append`.
2841
+ * @param options - Append mode.
2842
+ * @returns The sink.
2843
+ */
2844
+ function jsonLinesSink(path, options = {}) {
2845
+ const target = resolve$1(path);
2846
+ const append = options.append === true;
2847
+ const keys = new Set();
2848
+ let stream;
2849
+ let written = 0;
2850
+ return {
2851
+ async open() {
2852
+ await mkdir(dirname(target), {
2853
+ recursive: true
2854
+ });
2855
+ const existing = append ? await existingKeys(target) : [];
2856
+ for (const key of existing) keys.add(key);
2857
+ stream = createWriteStream(target, {
2858
+ encoding: 'utf8',
2859
+ flags: append ? 'a' : 'w'
2860
+ });
2861
+ await once(stream, 'open');
2862
+ },
2863
+ async write(record) {
2864
+ if (stream === undefined) throw new Error('jsonLinesSink: write before open');
2865
+ const line = `${JSON.stringify({
2866
+ ...record.data,
2867
+ _source: record.source,
2868
+ ...(append && record.key !== null && {
2869
+ _key: record.key
2870
+ })
2871
+ })}\n`;
2872
+ if (!stream.write(line)) await once(stream, 'drain');
2873
+ if (record.key !== null) keys.add(record.key);
2874
+ written += 1;
2875
+ },
2876
+ async close() {
2877
+ if (stream !== undefined) {
2878
+ stream.end();
2879
+ await once(stream, 'finish');
2880
+ stream = undefined;
2881
+ }
2882
+ return {
2883
+ written,
2884
+ location: target
2885
+ };
2886
+ },
2887
+ async has(key) {
2888
+ return keys.has(key);
2889
+ }
2890
+ };
2891
+ }
2892
+ /** The `_key` of every line already in the file; none when the file does not exist. */
2893
+ async function existingKeys(target) {
2894
+ let content;
2895
+ try {
2896
+ content = await readFile(target, 'utf8');
2897
+ } catch {
2898
+ return [];
2899
+ }
2900
+ return content.split('\n').flatMap(line => {
2901
+ if (line.trim() === '') return [];
2902
+ try {
2903
+ const key = JSON.parse(line)._key;
2904
+ return typeof key === 'string' ? [key] : [];
2905
+ } catch {
2906
+ return [];
2907
+ }
2908
+ });
2909
+ }
2910
+
2911
+ /**
2912
+ * Drops records whose key was already seen. First record wins; keyless
2913
+ * records always pass. Recipes running in parallel each get their own view:
2914
+ * under `recipe` scope they never see each other's keys, under `run` scope
2915
+ * they share them (and whichever emits a key first keeps it).
2916
+ */
2917
+ class DedupePolicy {
2918
+ scope;
2919
+ shared = new Set();
2920
+ constructor(scope = 'run') {
2921
+ this.scope = scope;
2922
+ }
2923
+ /**
2924
+ * The de-duplication one input recipe run uses.
2925
+ *
2926
+ * @returns Its view: keys shared with the run, its own, or none checked.
2927
+ */
2928
+ forRecipe() {
2929
+ if (this.scope === 'off') return {
2930
+ isDuplicate: () => false
2931
+ };
2932
+ const seen = this.scope === 'run' ? this.shared : new Set();
2933
+ return {
2934
+ isDuplicate: record => {
2935
+ if (record.key === null) return false;
2936
+ if (seen.has(record.key)) return true;
2937
+ seen.add(record.key);
2938
+ return false;
2939
+ }
2940
+ };
2941
+ }
2942
+ }
2943
+
2944
+ /**
2945
+ * Evaluates a JSONPath expression on a decoded JSON document.
2946
+ *
2947
+ * @param document - The JSON value.
2948
+ * @param path - A JSONPath such as `$.items[*].url`.
2949
+ * @returns Every match, in document order.
2950
+ */
2951
+ function selectJson(document, path) {
2952
+ return JSONPath({
2953
+ path,
2954
+ json: document,
2955
+ wrap: true,
2956
+ resultType: 'value'
2957
+ });
2958
+ }
2959
+
2960
+ /**
2961
+ * Evaluates a regular expression on text: the extract kind for values that
2962
+ * live in inline scripts, attributes or prose rather than in elements or JSON.
2963
+ *
2964
+ * @param text - The document text.
2965
+ * @param pattern - A regular expression source; group 1 is returned when the
2966
+ * pattern has a capturing group, else the whole match.
2967
+ * @returns Every match, in document order.
2968
+ * @throws When the pattern is not a valid regular expression.
2969
+ */
2970
+ function selectRegex(text, pattern) {
2971
+ let expression;
2972
+ try {
2973
+ expression = new RegExp(pattern, 'gs');
2974
+ } catch (error) {
2975
+ throw new Error(`invalid pattern ${pattern}: ${error.message}`, {
2976
+ cause: error
2977
+ });
2978
+ }
2979
+ const values = Array.from(text.matchAll(expression), match => match[1] ?? match[0]);
2980
+ return values;
2981
+ }
2982
+
2983
+ const DOCUMENT = /^\s*(?:<!doctype|<html)/i;
2984
+ /**
2985
+ * Runs a CSS selector on static HTML. A whole document is parsed as one; anything
2986
+ * else (a table row, a list item taken with `take: "html"`) is parsed as a
2987
+ * fragment, so cells and rows outside a table survive instead of being dropped.
2988
+ *
2989
+ * @param html - The markup (a whole document or a fragment).
2990
+ * @param selector - A CSS selector.
2991
+ * @returns Every match, in document order.
2992
+ */
2993
+ function selectHtml(html, selector) {
2994
+ const api = DOCUMENT.test(html) ? load(html) : load(html, undefined, false);
2995
+ return api(selector).map((_, element) => ({
2996
+ api,
2997
+ element: api(element)
2998
+ })).toArray();
2999
+ }
3000
+
3001
+ /**
3002
+ * The value of an HTML match.
3003
+ *
3004
+ * @param match - A selected element.
3005
+ * @param take - What to take; `text` collapses whitespace.
3006
+ * @returns The value; `undefined` for a missing attribute.
3007
+ */
3008
+ function takeFromHtml(match, take) {
3009
+ if (take === 'text') return collapse(match.element.text());
3010
+ if (take === 'html') return match.element.html() ?? '';
3011
+ if (take === 'value') return match.element.val() ?? match.element.attr('value');
3012
+ if (take === 'json') return match.api.html(match.element);
3013
+ return match.element.attr(take.slice('attr:'.length));
3014
+ }
3015
+ /**
3016
+ * The value of a JSON match.
3017
+ *
3018
+ * @param node - A JSONPath result.
3019
+ * @param take - `json` keeps the node; `text` stringifies scalars.
3020
+ * @returns The value.
3021
+ */
3022
+ function takeFromJson(node, take) {
3023
+ if (take === 'json') return node;
3024
+ if (node === null || node === undefined) return undefined;
3025
+ if (typeof node === 'object') return JSON.stringify(node);
3026
+ return String(node);
3027
+ }
3028
+ /**
3029
+ * Text as a human reads it: runs of whitespace collapsed, ends trimmed.
3030
+ *
3031
+ * @param text - Raw text content.
3032
+ * @returns The collapsed text.
3033
+ */
3034
+ function collapse(text) {
3035
+ return text.replaceAll(/\s+/g, ' ').trim();
3036
+ }
3037
+
3038
+ /**
3039
+ * JSON that arrives as text: a `<script type="application/ld+json">` body, a
3040
+ * `data-*` attribute, a fetched document read as text. Sites wrap JSON in
3041
+ * things that are not JSON: comment guards around inline JSON-LD, prefixes
3042
+ * that stop a page from loading an API as a script, a JSONP callback, an
3043
+ * assignment in an inline script. Those wrappers are removed, but only after
3044
+ * the text failed to parse as it is, and what is left must still be strict
3045
+ * JSON: nothing is evaluated.
3046
+ */
3047
+ /** Comment guards sites wrap inline JSON-LD in: a CDATA marker inside a block comment, or an HTML comment. */
3048
+ const GUARDS = /^\s*(?:\/\*\s*<!\[CDATA\[\s*\*\/|<!\[CDATA\[|<!--)\s*|\s*(?:\/\*\s*\]\]>\s*\*\/|\]\]>|-->)\s*$/g;
3049
+ /** Anti-hijacking prefixes: `)]}'` (with or without a comma), `while(1);`, `for(;;);`. */
3050
+ const XSSI_PREFIX = /^\s*(?:\)\]\}'\s*,?|while\s*\(\s*1\s*\)\s*;|for\s*\(\s*;\s*;\s*\)\s*;)/;
3051
+ /** A JSONP call: `callback({...});`, the callback an identifier path. */
3052
+ const JSONP = /^\s*[$A-Z_][\w$]*(?:\.[$A-Z_][\w$]*)*\s*\(([\s\S]*)\)\s*(?:;\s*)?$/i;
3053
+ /** An assignment in an inline script: `window.__STATE__ = {...};`, with `var`, `let` or `const` or none. */
3054
+ const ASSIGNMENT = /^\s*(?:(?:var|let|const)\s+)?[$A-Z_a-z][\w$]*(?:\.[$A-Z_a-z][\w$]*|\[["'][^"']*["']\])*\s*=([\s\S]*)$/;
3055
+ /**
3056
+ * Parses text as JSON, or as JSON inside one of the wrappers sites put around
3057
+ * it: comment guards, an anti-hijacking prefix, a JSONP call, an assignment.
3058
+ * Valid JSON is always read as it is; a wrapper is only removed when that
3059
+ * fails.
3060
+ *
3061
+ * @param text - The text.
3062
+ * @returns The value, or the error the text as it is gave.
3063
+ */
3064
+ function parseJsonLike(text) {
3065
+ const direct = parseJson$1(text);
3066
+ if ('value' in direct) return direct;
3067
+ const unguarded = text.replaceAll(GUARDS, '');
3068
+ const assigned = ASSIGNMENT.exec(unguarded)?.[1].trim().replace(/;$/, '');
3069
+ const candidates = [unguarded, unguarded.replace(XSSI_PREFIX, ''), JSONP.exec(unguarded)?.[1], assigned];
3070
+ for (const candidate of candidates) {
3071
+ if (candidate === undefined || candidate === text) continue;
3072
+ const parsed = parseJson$1(candidate);
3073
+ if ('value' in parsed) return parsed;
3074
+ }
3075
+ return direct;
3076
+ }
3077
+ /**
3078
+ * Parses JSON Lines (NDJSON): one JSON value per non-blank line.
3079
+ *
3080
+ * @param text - The text.
3081
+ * @param source - Where it came from, for the error.
3082
+ * @returns The values, in order.
3083
+ * @throws Error naming the source and the line that does not parse.
3084
+ */
3085
+ function parseJsonLines(text, source) {
3086
+ const values = [];
3087
+ for (const [index, line] of text.split(/\r?\n/).entries()) {
3088
+ if (line.trim() === '') continue;
3089
+ const parsed = parseJson$1(line);
3090
+ if ('error' in parsed) throw new Error(`${source}: line ${index + 1} is not JSON (${parsed.error.message})`, {
3091
+ cause: parsed.error
3092
+ });
3093
+ values.push(parsed.value);
3094
+ }
3095
+ return values;
3096
+ }
3097
+ /**
3098
+ * Parses text as JSON, wrappers removed (see {@link parseJsonLike}).
3099
+ *
3100
+ * @param text - The text.
3101
+ * @returns The value, or `undefined` when it is not JSON.
3102
+ */
3103
+ function tryParseJson(text) {
3104
+ const parsed = parseJsonLike(text);
3105
+ return 'value' in parsed ? parsed.value : undefined;
3106
+ }
3107
+ /**
3108
+ * Parses text that must be JSON.
3109
+ *
3110
+ * @param text - The text.
3111
+ * @param id - What the text is, for the error.
3112
+ * @returns The value.
3113
+ * @throws Error when it is not JSON.
3114
+ */
3115
+ function parseJsonText(text, id) {
3116
+ const parsed = tryParseJson(text);
3117
+ if (parsed === undefined) throw new Error(`"${id}" is text but not JSON`);
3118
+ return parsed;
3119
+ }
3120
+ /**
3121
+ * The data a value holds, whatever shape it arrived in: JSON text is parsed, a
3122
+ * list of texts becomes the list of its parsable entries, and an entry that
3123
+ * parses to a list is spliced in. Data that is not text is kept as is.
3124
+ *
3125
+ * @param value - A bound value: data, text, or a list of either.
3126
+ * @returns A list of items.
3127
+ */
3128
+ function dataItemsOf(value) {
3129
+ if (typeof value === 'string') return itemsOf(tryParseJson(value));
3130
+ if (Array.isArray(value)) return value.flatMap(entry => typeof entry === 'string' ? itemsOf(tryParseJson(entry)) : [entry]);
3131
+ return itemsOf(value);
3132
+ }
3133
+ function itemsOf(parsed) {
3134
+ if (parsed === undefined || parsed === null) return [];
3135
+ return Array.isArray(parsed) ? parsed : [parsed];
3136
+ }
3137
+ function parseJson$1(text) {
3138
+ try {
3139
+ return {
3140
+ value: JSON.parse(text)
3141
+ };
3142
+ } catch (error) {
3143
+ return {
3144
+ error: error
3145
+ };
2073
3146
  }
2074
3147
  }
2075
3148
 
2076
- /** A block unless a recipe says otherwise: forbidden, rate limited, or an AWS WAF challenge (IMDb answers 202 with it). */
2077
- const DEFAULT_BLOCK_RULE = {
2078
- status: [403, 429],
2079
- header: {
2080
- 'x-amzn-waf-action': 'challenge'
2081
- }
2082
- };
2083
3149
  /**
2084
- * Whether a response is a block.
3150
+ * The text a `regex` extract reads: per visible slide, its title, its text
3151
+ * boxes in reading order, its tables' rows (cells separated by a tab) and its
3152
+ * notes after `Notes:`; slides separated by a blank line.
2085
3153
  *
2086
- * @param response - What came back.
2087
- * @param rule - The recipe's `session.blockedWhen`; `DEFAULT_BLOCK_RULE` when omitted.
2088
- * @returns The error to throw, or `undefined` when the response is not a block.
3154
+ * @param document - The deck.
3155
+ * @returns The text.
2089
3156
  */
2090
- async function detectBlock(response, rule = DEFAULT_BLOCK_RULE) {
2091
- if (rule.status?.includes(response.status) === true) return new BlockedError(response.url, response.status, `HTTP ${response.status}`);
2092
- const headers = Object.entries(rule.header ?? {});
2093
- for (const [name, pattern] of headers) {
2094
- const value = response.headers[name.toLowerCase()];
2095
- if (value !== undefined && new RegExp(pattern, 'i').test(value)) return new BlockedError(response.url, response.status, `${name.toLowerCase()}: ${value}`);
2096
- }
2097
- if (rule.text !== undefined && response.text !== undefined) {
2098
- let body = '';
2099
- try {
2100
- body = await response.text();
2101
- } catch {
2102
- // a body that cannot be read (a redirect, a download) cannot match
2103
- }
2104
- const pattern = new RegExp(rule.text, 'i');
2105
- if (pattern.test(body)) return new BlockedError(response.url, response.status, `body matches /${rule.text}/i`);
3157
+ function deckText(document) {
3158
+ return document.slides.filter(slide => !slide.hidden).map(slide => [...slide.shapes.map(shape => shape.text), ...slide.tables.flatMap(table => table.rows.map(row => row.map(String).join('\t'))), ...(slide.notes === '' ? [] : [`Notes: ${slide.notes}`])].join('\n')).join('\n\n');
3159
+ }
3160
+ /**
3161
+ * Whether a value bound in scope is a read deck (so `extract … from` can take it).
3162
+ *
3163
+ * @param value - Anything.
3164
+ * @returns Whether it is a {@link DeckDocument}.
3165
+ */
3166
+ function isDeckDocument(value) {
3167
+ return typeof value === 'object' && value !== null && value.kind === 'deck' && Array.isArray(value.slides);
3168
+ }
3169
+
3170
+ /**
3171
+ * Reads a `.pptx` presentation into a deck document, through
3172
+ * `@opencraw/office-reader`: every slide's text boxes with their positions,
3173
+ * its tables with their merged cells, its charts' cached data and its notes.
3174
+ * The reader is imported on first use, so recipes that never read a
3175
+ * presentation never load it.
3176
+ *
3177
+ * @param bytes - The file.
3178
+ * @param source - Where it came from, for messages.
3179
+ * @returns The deck.
3180
+ * @throws Error naming the source, and saying what to do, for a file that is
3181
+ * not a readable presentation (a legacy `.ppt`, a password-protected file, an `.odp`…).
3182
+ */
3183
+ async function readPptxDeck(bytes, source) {
3184
+ const {
3185
+ readPptx,
3186
+ OfficeReadError
3187
+ } = await import('@opencraw/office-reader/pptx');
3188
+ try {
3189
+ const deck = await readPptx(bytes);
3190
+ return {
3191
+ kind: 'deck',
3192
+ width: deck.width,
3193
+ height: deck.height,
3194
+ slides: deck.slides.map(slide => ({
3195
+ ...slide,
3196
+ tables: slide.tables.map(table => ({
3197
+ name: table.name,
3198
+ rows: table.rows,
3199
+ merges: table.merges
3200
+ }))
3201
+ }))
3202
+ };
3203
+ } catch (error) {
3204
+ if (error instanceof OfficeReadError) throw new Error(`${source}: ${error.message}`, {
3205
+ cause: error
3206
+ });
3207
+ throw error;
2106
3208
  }
2107
- return undefined;
2108
3209
  }
2109
3210
 
2110
3211
  /** Runs closer than this share of the font size join into one cell. */
@@ -2128,7 +3229,17 @@ const ROW_OVERLAP = 0.4;
2128
3229
  * @returns The rows.
2129
3230
  */
2130
3231
  function assembleRows(runs) {
2131
- const cells = joinCells(runs);
3232
+ return rowsOfCells(joinCells(runs));
3233
+ }
3234
+ /**
3235
+ * Groups finished cells into rows, top to bottom: cells whose vertical extents
3236
+ * overlap share a row. For cells that need no joining, such as a slide's text
3237
+ * boxes, each already a cell.
3238
+ *
3239
+ * @param cells - The cells, in any order.
3240
+ * @returns The rows.
3241
+ */
3242
+ function rowsOfCells(cells) {
2132
3243
  const ordered = [...cells].sort((a, b) => middle(b) - middle(a) || a.x - b.x);
2133
3244
  const rows = [];
2134
3245
  let top = 0;
@@ -2305,7 +3416,7 @@ function isPdfDocument(value) {
2305
3416
  function findTables(document, query) {
2306
3417
  const tables = [];
2307
3418
  for (const page of document.pages) {
2308
- const starts = page.rows.flatMap((row, index) => query.header.test(plain(row)) ? [index] : []);
3419
+ const starts = page.rows.flatMap((row, index) => query.header.test(plain$1(row)) ? [index] : []);
2309
3420
  for (const [position, start] of starts.entries()) {
2310
3421
  const body = bodyOf(page.rows.slice(start + 1, starts[position + 1] ?? page.rows.length), query.until);
2311
3422
  tables.push(readTable(page.number, page.rows[start], body, query));
@@ -2315,7 +3426,7 @@ function findTables(document, query) {
2315
3426
  }
2316
3427
  /** The rows under a header, up to the first one `until` matches. */
2317
3428
  function bodyOf(rows, until) {
2318
- const end = until === undefined ? -1 : rows.findIndex(row => until.test(plain(row)));
3429
+ const end = until === undefined ? -1 : rows.findIndex(row => until.test(plain$1(row)));
2319
3430
  return end === -1 ? [...rows] : rows.slice(0, end);
2320
3431
  }
2321
3432
  function readTable(page, headerRow, body, query) {
@@ -2330,7 +3441,7 @@ function readTable(page, headerRow, body, query) {
2330
3441
  page,
2331
3442
  title: headers[0]?.text ?? '',
2332
3443
  header: headers.map(header => header.text),
2333
- rows: groups.map(group => named(joinLines(group, headers.length), headers, query.columns))
3444
+ rows: groups.map(group => named$1(joinLines(group, headers.length), headers, query.columns))
2334
3445
  };
2335
3446
  }
2336
3447
  /**
@@ -2498,53 +3609,585 @@ function valuesOf(row, bands, width) {
2498
3609
  }
2499
3610
  return values;
2500
3611
  }
2501
- function named(values, headers, columns) {
2502
- if (columns === undefined) return Object.fromEntries(headers.map((header, index) => [header.text, values[index]]));
2503
- const record = {};
2504
- for (const [key, pattern] of Object.entries(columns)) {
2505
- const index = headers.findIndex(header => pattern.test(header.text));
2506
- if (index !== -1) record[key] = values[index];
2507
- }
2508
- return record;
3612
+ function named$1(values, headers, columns) {
3613
+ if (columns === undefined) return Object.fromEntries(headers.map((header, index) => [header.text, values[index]]));
3614
+ const record = {};
3615
+ for (const [key, pattern] of Object.entries(columns)) {
3616
+ const index = headers.findIndex(header => pattern.test(header.text));
3617
+ if (index !== -1) record[key] = values[index];
3618
+ }
3619
+ return record;
3620
+ }
3621
+ /** Distances closer than this, in points, are a tie. */
3622
+ const TIE = 1;
3623
+ /**
3624
+ * The anchor a line belongs to: the nearest by vertical gap. On a tie (evenly
3625
+ * spaced lines) the anchor below wins: text reads top down, so a wrapped
3626
+ * cell's first line comes before the row it belongs to.
3627
+ */
3628
+ function nearest(line, candidates) {
3629
+ const row = line.row;
3630
+ const gap = candidate => Math.max(0, candidate.bottom - row.top, row.bottom - candidate.top);
3631
+ const [first, ...rest] = candidates;
3632
+ if (first === undefined) throw new Error('no row to attach a line to');
3633
+ let best = first;
3634
+ for (const candidate of rest) {
3635
+ const difference = gap(candidate.row) - gap(best.row);
3636
+ if (difference < -TIE || Math.abs(difference) <= TIE && candidate.row.bottom < best.row.bottom) best = candidate;
3637
+ }
3638
+ return best;
3639
+ }
3640
+ function overlapOf(header, band) {
3641
+ return Math.max(0, Math.min(header.x + header.width, band.end) - Math.max(header.x, band.start));
3642
+ }
3643
+ function plain$1(row) {
3644
+ return row.cells.map(cell => cell.text).join(' ');
3645
+ }
3646
+ function joinText(first, second) {
3647
+ return first === '' ? second : second === '' ? first : `${first} ${second}`;
3648
+ }
3649
+ function median(values) {
3650
+ const ordered = [...values].sort((a, b) => a - b);
3651
+ return ordered[Math.floor(ordered.length / 2)] ?? 0;
3652
+ }
3653
+
3654
+ /**
3655
+ * The text a `regex` extract reads: the visible rows of the visible sheets,
3656
+ * cells separated by a tab, sheets separated by a blank line.
3657
+ *
3658
+ * @param document - The workbook.
3659
+ * @returns The text.
3660
+ */
3661
+ function workbookText(document) {
3662
+ return document.sheets.filter(sheet => sheet.hidden !== true).map(sheet => visibleRows(sheet).map(row => row.map(String).join('\t')).join('\n')).join('\n\n');
3663
+ }
3664
+ /**
3665
+ * Whether a value bound in scope is a read workbook (so `extract … from` can take it).
3666
+ *
3667
+ * @param value - Anything.
3668
+ * @returns Whether it is a {@link WorkbookDocument}.
3669
+ */
3670
+ function isWorkbookDocument(value) {
3671
+ return typeof value === 'object' && value !== null && value.kind === 'workbook' && Array.isArray(value.sheets);
3672
+ }
3673
+ function visibleRows(sheet) {
3674
+ if (sheet.hiddenRows === undefined || sheet.hiddenRows.length === 0) return sheet.rows;
3675
+ const hidden = new Set(sheet.hiddenRows);
3676
+ return sheet.rows.filter((_row, index) => !hidden.has(index));
3677
+ }
3678
+
3679
+ /** The delimiters detection chooses between, in order of preference on a tie. */
3680
+ const CSV_DELIMITERS = [',', ';', '\t', '|'];
3681
+ /** How much of a file delimiter detection looks at. */
3682
+ const SAMPLE_CHARS = 64 * 1024;
3683
+ /** How many lines of the sample delimiter detection scores. */
3684
+ const SAMPLE_LINES = 100;
3685
+ /**
3686
+ * Parses CSV text (RFC 4180, tolerant): a field in double quotes may hold the
3687
+ * delimiter, line breaks and `""` for a quote; a quote inside an unquoted
3688
+ * field is taken literally (`1.0 Hybrid "Cross"`); CRLF, LF and CR all end a
3689
+ * record. Rows are kept as read: ragged rows stay ragged, nothing is trimmed.
3690
+ *
3691
+ * @param text - The decoded file.
3692
+ * @param delimiter - One character.
3693
+ * @returns The rows; a trailing empty line adds no row.
3694
+ */
3695
+ function parseCsv(text, delimiter) {
3696
+ const rows = [];
3697
+ let row = [];
3698
+ let field = '';
3699
+ let index = 0;
3700
+ while (index < text.length) {
3701
+ const char = text[index];
3702
+ if (char === '"' && field === '') {
3703
+ const quoted = readQuoted(text, index + 1);
3704
+ field = quoted.value;
3705
+ index = quoted.next;
3706
+ } else if (char === delimiter) {
3707
+ row.push(field);
3708
+ field = '';
3709
+ index += 1;
3710
+ } else if (char === '\n' || char === '\r') {
3711
+ row.push(field);
3712
+ rows.push(row);
3713
+ row = [];
3714
+ field = '';
3715
+ index += char === '\r' && text[index + 1] === '\n' ? 2 : 1;
3716
+ } else {
3717
+ const end = plainEnd(text, index + 1, delimiter);
3718
+ field += text.slice(index, end);
3719
+ index = end;
3720
+ }
3721
+ }
3722
+ if (field !== '' || row.length > 0) {
3723
+ row.push(field);
3724
+ rows.push(row);
3725
+ }
3726
+ return rows;
3727
+ }
3728
+ /**
3729
+ * Chooses the delimiter of a CSV: the candidate whose field count (above one)
3730
+ * is the most consistent over the first lines, so a title line or two above
3731
+ * the header does not mislead it. A file of one column gets `,`.
3732
+ *
3733
+ * `;` with decimal commas (`Panda;15.950,00`), the usual European export,
3734
+ * scores `;`: a comma split gives rows of uneven width.
3735
+ *
3736
+ * @param text - The decoded file.
3737
+ * @returns The delimiter.
3738
+ */
3739
+ function detectDelimiter(text) {
3740
+ const sample = text.slice(0, SAMPLE_CHARS);
3741
+ const truncated = text.length > SAMPLE_CHARS;
3742
+ let best = {
3743
+ delimiter: ',',
3744
+ score: 0
3745
+ };
3746
+ for (const delimiter of CSV_DELIMITERS) {
3747
+ const rows = parseCsv(sample, delimiter).slice(0, SAMPLE_LINES).filter(row => row.length > 1 || row[0] !== '');
3748
+ // The sample may cut the last line short.
3749
+ if (truncated && rows.length > 1) rows.pop();
3750
+ const score = consistency(rows);
3751
+ if (score > best.score) best = {
3752
+ delimiter,
3753
+ score
3754
+ };
3755
+ }
3756
+ return best.delimiter;
3757
+ }
3758
+ /** The share of rows with the most common width above one, with that width breaking ties; 0 when no row splits. */
3759
+ function consistency(rows) {
3760
+ const counts = new Map();
3761
+ for (const row of rows) counts.set(row.length, (counts.get(row.length) ?? 0) + 1);
3762
+ let width = 1;
3763
+ let agreeing = 0;
3764
+ for (const [length, count] of counts) {
3765
+ if (!(length > 1 && (count > agreeing || count === agreeing && length > width))) {
3766
+ continue;
3767
+ }
3768
+ width = length;
3769
+ agreeing = count;
3770
+ }
3771
+ return width > 1 ? agreeing / rows.length * 1000 + width : 0;
3772
+ }
3773
+ /** Reads a quoted field starting after its opening quote. */
3774
+ function readQuoted(text, start) {
3775
+ let value = '';
3776
+ let index = start;
3777
+ for (;;) {
3778
+ const quote = text.indexOf('"', index);
3779
+ if (quote === -1) return {
3780
+ value: value + text.slice(index),
3781
+ next: text.length
3782
+ };
3783
+ value += text.slice(index, quote);
3784
+ if (text[quote + 1] !== '"') return {
3785
+ value,
3786
+ next: quote + 1
3787
+ };
3788
+ value += '"';
3789
+ index = quote + 2;
3790
+ }
3791
+ }
3792
+ /** Where a run of plain characters (no delimiter, no line break) ends. */
3793
+ function plainEnd(text, start, delimiter) {
3794
+ const stops = new Set([delimiter, '\n', '\r']);
3795
+ let index = start;
3796
+ while (index < text.length) {
3797
+ if (stops.has(text[index])) break;
3798
+ index += 1;
3799
+ }
3800
+ return index;
3801
+ }
3802
+
3803
+ /**
3804
+ * Reads decoded CSV text into a workbook of one sheet, named after the file.
3805
+ *
3806
+ * @param text - The decoded file.
3807
+ * @param options - The sheet name, the encoding it was decoded from (for a
3808
+ * probe to report) and a delimiter; without one it is detected.
3809
+ * @returns The workbook.
3810
+ * @throws Error when the delimiter given is not one character.
3811
+ */
3812
+ function csvWorkbook(text, options) {
3813
+ if (options.delimiter !== undefined && [...options.delimiter].length !== 1) throw new Error(`a CSV delimiter is one character; got "${options.delimiter}"`);
3814
+ const delimiter = options.delimiter ?? detectDelimiter(text);
3815
+ return {
3816
+ kind: 'workbook',
3817
+ sheets: [{
3818
+ name: options.name,
3819
+ rows: parseCsv(text, delimiter)
3820
+ }],
3821
+ csv: {
3822
+ encoding: options.encoding,
3823
+ delimiter
3824
+ }
3825
+ };
3826
+ }
3827
+ /**
3828
+ * The name a CSV's sheet takes: the file name without its extension
3829
+ * (`…/prezzo_alle_8.csv` → `prezzo_alle_8`), else `csv`.
3830
+ *
3831
+ * @param url - Where the file came from.
3832
+ * @returns The name.
3833
+ */
3834
+ function sheetNameOf(url) {
3835
+ let path;
3836
+ try {
3837
+ path = new URL(url).pathname;
3838
+ } catch {
3839
+ path = url;
3840
+ }
3841
+ const file = path.split('/').at(-1) ?? '';
3842
+ let name;
3843
+ try {
3844
+ name = decodeURIComponent(file);
3845
+ } catch {
3846
+ name = file;
3847
+ }
3848
+ name = name.replace(/\.[^.]*$/, '');
3849
+ return name === '' ? 'csv' : name;
3850
+ }
3851
+
3852
+ /**
3853
+ * Reads an `.xlsx` workbook into a workbook document, through
3854
+ * `@opencraw/office-reader`: every worksheet's cells, with hidden sheets,
3855
+ * hidden rows and merged ranges. Numbers and booleans keep their type (a
3856
+ * cell's `13955.625` is unambiguous; as text, a locale guess could read it as
3857
+ * thirteen million), dates become ISO text, errors their text, empty cells
3858
+ * `''`. Formulas give their cached value. The reader is imported on first use,
3859
+ * so recipes that never read a spreadsheet never load it.
3860
+ *
3861
+ * @param bytes - The file.
3862
+ * @param source - Where it came from, for messages.
3863
+ * @returns The workbook.
3864
+ * @throws Error naming the source, and saying what to do, for a file that is
3865
+ * not a readable workbook (a legacy `.xls`, a password-protected file, an `.ods`…).
3866
+ */
3867
+ async function readXlsxWorkbook(bytes, source) {
3868
+ const {
3869
+ readXlsx,
3870
+ OfficeReadError
3871
+ } = await import('@opencraw/office-reader/xlsx');
3872
+ try {
3873
+ const book = await readXlsx(bytes);
3874
+ return {
3875
+ kind: 'workbook',
3876
+ sheets: book.sheets.map(sheet => ({
3877
+ name: sheet.name,
3878
+ rows: sheet.rows.map(row => row.map(cell => workbookCell(cell))),
3879
+ hidden: sheet.hidden,
3880
+ hiddenRows: sheet.hiddenRows,
3881
+ merges: sheet.merges
3882
+ }))
3883
+ };
3884
+ } catch (error) {
3885
+ if (error instanceof OfficeReadError) throw new Error(`${source}: ${error.message}`, {
3886
+ cause: error
3887
+ });
3888
+ throw error;
3889
+ }
3890
+ }
3891
+ /** A typed spreadsheet value as a workbook cell. */
3892
+ function workbookCell(value) {
3893
+ if (value === null) return '';
3894
+ if (value instanceof Date) return isoText(value);
3895
+ if (typeof value === 'object') return value.error;
3896
+ return value;
3897
+ }
3898
+ /** A date as ISO text: the day alone at midnight, the time alone for a time of day (Excel's day zero, 1899), both otherwise. */
3899
+ function isoText(date) {
3900
+ const iso = date.toISOString();
3901
+ if (date.getUTCFullYear() < 1900) return iso.slice(11, 19);
3902
+ return iso.slice(11, 19) === '00:00:00' ? iso.slice(0, 10) : iso.slice(0, 19);
3903
+ }
3904
+
3905
+ /**
3906
+ * Finds every table whose header row matches, in every sheet the query
3907
+ * selects, and reads its rows by column. Unlike a PDF, a grid needs no
3908
+ * geometry: column *i* of a row belongs to header *i*.
3909
+ *
3910
+ * Merged ranges are filled first (the file stores their value in the top-left
3911
+ * cell only), so a brand merged down its models' rows reads on every row, and
3912
+ * a group header merged across its sub-columns names each of them. Empty rows
3913
+ * are skipped.
3914
+ *
3915
+ * @param document - The workbook.
3916
+ * @param query - Which tables, and how to name their columns.
3917
+ * @returns The tables, sheet by sheet, top to bottom.
3918
+ */
3919
+ function findGridTables(document, query) {
3920
+ const tables = [];
3921
+ for (const sheet of document.sheets) {
3922
+ if (sheet.hidden === true && query.includeHidden !== true) continue;
3923
+ if (query.sheet !== undefined && !query.sheet.test(sheet.name)) continue;
3924
+ tables.push(...sheetTables(sheet, query));
3925
+ }
3926
+ return tables;
3927
+ }
3928
+ /**
3929
+ * Fills blank cells in the given columns with the value of the row above,
3930
+ * within one table: pivot exports write a group's name on its first row only.
3931
+ *
3932
+ * @param rows - The table's rows, in order.
3933
+ * @param keys - The columns to fill.
3934
+ * @returns The rows, filled (new objects; the input is not changed).
3935
+ */
3936
+ function fillDown(rows, keys) {
3937
+ const last = new Map();
3938
+ return rows.map(row => {
3939
+ const filled = {
3940
+ ...row
3941
+ };
3942
+ for (const key of keys) {
3943
+ const value = filled[key];
3944
+ if (value === undefined || value === '') {
3945
+ const above = last.get(key);
3946
+ if (above !== undefined) filled[key] = above;
3947
+ } else {
3948
+ last.set(key, value);
3949
+ }
3950
+ }
3951
+ return filled;
3952
+ });
3953
+ }
3954
+ function sheetTables(sheet, query) {
3955
+ const hidden = new Set(query.includeHidden === true ? [] : sheet.hiddenRows);
3956
+ const visible = sheet.rows.flatMap((_row, index) => hidden.has(index) ? [] : [index]);
3957
+ const grid = filledGrid(sheet);
3958
+ const plainRows = new Map(visible.map(index => [index, plain(sheet.rows[index])]));
3959
+ const starts = visible.filter(index => plainRows.get(index) !== '' && query.header.test(plainRows.get(index) ?? ''));
3960
+ const headerRows = query.headerRows ?? 1;
3961
+ return starts.map((start, position) => {
3962
+ const at = visible.indexOf(start);
3963
+ const headerIndexes = visible.slice(at, at + headerRows);
3964
+ const next = starts[position + 1] ?? Infinity;
3965
+ const body = [];
3966
+ const below = visible.slice(at + headerRows);
3967
+ for (const index of below) {
3968
+ if (index >= next) break;
3969
+ if (query.until?.test(plainRows.get(index) ?? '') === true) break;
3970
+ if (plainRows.get(index) !== '') body.push(index);
3971
+ }
3972
+ const columns = columnsOf(grid, headerIndexes, body);
3973
+ const rows = body.map(index => named(grid[index] ?? [], columns, query.columns));
3974
+ return {
3975
+ sheet: sheet.name,
3976
+ title: sheet.rows[start].map(text => clean(text)).find(text => text !== '') ?? '',
3977
+ header: columns.map(column => column.key),
3978
+ rows: query.fillDown === undefined ? rows : fillDown(rows, query.fillDown)
3979
+ };
3980
+ });
3981
+ }
3982
+ /**
3983
+ * The table's columns: each column's key joins the distinct texts its header
3984
+ * rows hold (`Insgesamt` over `August 2026` gives `Insgesamt August 2026`). A
3985
+ * column with no header text but data below is keyed by its letter (`A`); one
3986
+ * with neither is dropped. A key seen before gets a counter (`Price 2`).
3987
+ */
3988
+ function columnsOf(grid, headerIndexes, body) {
3989
+ const width = Math.max(0, ...[...headerIndexes, ...body].map(index => grid[index]?.length ?? 0));
3990
+ const seen = new Map();
3991
+ const columns = [];
3992
+ for (let index = 0; index < width; index += 1) {
3993
+ const parts = [];
3994
+ for (const row of headerIndexes) {
3995
+ const text = clean(grid[row]?.[index] ?? '');
3996
+ if (text !== '' && !parts.includes(text)) parts.push(text);
3997
+ }
3998
+ const hasData = body.some(row => clean(grid[row]?.[index] ?? '') !== '');
3999
+ if (!hasData && parts.length === 0) continue;
4000
+ const base = parts.length === 0 ? columnLetter(index) : parts.join(' ');
4001
+ const count = (seen.get(base) ?? 0) + 1;
4002
+ seen.set(base, count);
4003
+ columns.push({
4004
+ index,
4005
+ key: count === 1 ? base : `${base} ${count}`
4006
+ });
4007
+ }
4008
+ return columns;
4009
+ }
4010
+ function named(row, columns, patterns) {
4011
+ const value = column => {
4012
+ const cell = row[column.index] ?? '';
4013
+ return typeof cell === 'string' ? cell.trim() : cell;
4014
+ };
4015
+ if (patterns === undefined) return Object.fromEntries(columns.map(column => [column.key, value(column)]));
4016
+ const record = {};
4017
+ for (const [key, pattern] of Object.entries(patterns)) {
4018
+ const column = columns.find(candidate => pattern.test(candidate.key));
4019
+ if (column !== undefined) record[key] = value(column);
4020
+ }
4021
+ return record;
4022
+ }
4023
+ /** The sheet's rows with every merged range's value copied into the cells it covers. */
4024
+ function filledGrid(sheet) {
4025
+ if (sheet.merges === undefined || sheet.merges.length === 0) return sheet.rows;
4026
+ const grid = sheet.rows.map(row => [...row]);
4027
+ for (const reference of sheet.merges) {
4028
+ const range = rangeOf(reference);
4029
+ if (range === undefined) continue;
4030
+ const value = sheet.rows[range.top]?.[range.left] ?? '';
4031
+ for (let row = range.top; row <= range.bottom; row += 1) {
4032
+ grid[row] ??= [];
4033
+ for (let column = range.left; column <= range.right; column += 1) grid[row][column] = value;
4034
+ }
4035
+ }
4036
+ return grid;
4037
+ }
4038
+ /** `B10:B13` as 0-based bounds; `undefined` for anything else. */
4039
+ function rangeOf(reference) {
4040
+ const [from, to = from] = reference.split(':', 2);
4041
+ const start = cellOf(from);
4042
+ const end = cellOf(to);
4043
+ if (start === undefined || end === undefined) return undefined;
4044
+ return {
4045
+ top: Math.min(start.row, end.row),
4046
+ left: Math.min(start.column, end.column),
4047
+ bottom: Math.max(start.row, end.row),
4048
+ right: Math.max(start.column, end.column)
4049
+ };
4050
+ }
4051
+ function cellOf(reference) {
4052
+ const match = /^\$?([A-Z]+)\$?(\d+)$/i.exec(reference.trim());
4053
+ if (match === null) return undefined;
4054
+ const letters = match[1].toUpperCase();
4055
+ let column = 0;
4056
+ for (const char of letters) column = column * 26 + (char.codePointAt(0) ?? 64) - 64;
4057
+ return {
4058
+ row: Number(match[2]) - 1,
4059
+ column: column - 1
4060
+ };
4061
+ }
4062
+ /** `0` → `A`, `25` → `Z`, `26` → `AA`. */
4063
+ function columnLetter(index) {
4064
+ let letters = '';
4065
+ for (let rest = index + 1; rest > 0; rest = Math.floor((rest - 1) / 26)) letters = String.fromCodePoint(65 + (rest - 1) % 26) + letters;
4066
+ return letters;
2509
4067
  }
2510
- /** Distances closer than this, in points, are a tie. */
2511
- const TIE = 1;
4068
+ /** A row as a header pattern sees it: its non-empty cells, whitespace collapsed, joined by spaces. */
4069
+ function plain(row) {
4070
+ return (row ?? []).map(text => clean(text)).filter(text => text !== '').join(' ');
4071
+ }
4072
+ function clean(cell) {
4073
+ return String(cell).replaceAll(/\s+/g, ' ').trim();
4074
+ }
4075
+
2512
4076
  /**
2513
- * The anchor a line belongs to: the nearest by vertical gap. On a tie (evenly
2514
- * spaced lines) the anchor below wins: text reads top down, so a wrapped
2515
- * cell's first line comes before the row it belongs to.
4077
+ * Every `<table>` of an HTML document as a sheet (`table 1`, `table 2`…, in
4078
+ * document order), so the workbook table reader works on web pages and
4079
+ * rendered Markdown: rows in order (`thead`, `tbody`, `tfoot` alike), `th` and
4080
+ * `td` alike, cell text with whitespace collapsed, `colspan` and `rowspan` as
4081
+ * merged ranges. A table inside a table is a sheet of its own, and its rows
4082
+ * are not its parent's.
4083
+ *
4084
+ * @param html - The document.
4085
+ * @returns The tables.
2516
4086
  */
2517
- function nearest(line, candidates) {
2518
- const row = line.row;
2519
- const gap = candidate => Math.max(0, candidate.bottom - row.top, row.bottom - candidate.top);
2520
- const [first, ...rest] = candidates;
2521
- if (first === undefined) throw new Error('no row to attach a line to');
2522
- let best = first;
2523
- for (const candidate of rest) {
2524
- const difference = gap(candidate.row) - gap(best.row);
2525
- if (difference < -TIE || Math.abs(difference) <= TIE && candidate.row.bottom < best.row.bottom) best = candidate;
2526
- }
2527
- return best;
2528
- }
2529
- function overlapOf(header, band) {
2530
- return Math.max(0, Math.min(header.x + header.width, band.end) - Math.max(header.x, band.start));
4087
+ function htmlTableSheets(html) {
4088
+ const $ = load(html);
4089
+ const tables = $('table').get();
4090
+ return tables.map((table, index) => {
4091
+ const all = $(table).find('tr').get();
4092
+ const rows = all.filter(row => $(row).closest('table').get(0) === table);
4093
+ const grid = [];
4094
+ const merges = [];
4095
+ for (const [rowIndex, row] of rows.entries()) {
4096
+ grid[rowIndex] ??= [];
4097
+ let column = 0;
4098
+ const cells = $(row).children('th, td').get();
4099
+ for (const cell of cells) {
4100
+ while (grid[rowIndex][column] !== undefined) column += 1;
4101
+ const columnSpan = span($(cell).attr('colspan'));
4102
+ const rowSpan = span($(cell).attr('rowspan'));
4103
+ for (let down = 0; down < rowSpan; down += 1) {
4104
+ grid[rowIndex + down] ??= [];
4105
+ for (let across = 0; across < columnSpan; across += 1) grid[rowIndex + down][column + across] = '';
4106
+ }
4107
+ grid[rowIndex][column] = $(cell).text().replaceAll(/\s+/g, ' ').trim();
4108
+ if (columnSpan > 1 || rowSpan > 1) merges.push(`${letter(column)}${rowIndex + 1}:${letter(column + columnSpan - 1)}${rowIndex + rowSpan}`);
4109
+ column += columnSpan;
4110
+ }
4111
+ }
4112
+ return {
4113
+ name: `table ${index + 1}`,
4114
+ rows: grid.slice(0, rows.length).map(row => Array.from(row, cell => cell ?? '')),
4115
+ merges
4116
+ };
4117
+ });
2531
4118
  }
2532
- function plain(row) {
2533
- return row.cells.map(cell => cell.text).join(' ');
4119
+ function span(value) {
4120
+ const number = Math.trunc(Number(value ?? '1'));
4121
+ return Number.isFinite(number) && number > 0 ? Math.min(number, 1000) : 1;
2534
4122
  }
2535
- function joinText(first, second) {
2536
- return first === '' ? second : second === '' ? first : `${first} ${second}`;
4123
+ function letter(index) {
4124
+ let letters = '';
4125
+ for (let rest = index + 1; rest > 0; rest = Math.floor((rest - 1) / 26)) letters = String.fromCodePoint(65 + (rest - 1) % 26) + letters;
4126
+ return letters;
2537
4127
  }
2538
- function median(values) {
2539
- const ordered = [...values].sort((a, b) => a - b);
2540
- return ordered[Math.floor(ordered.length / 2)] ?? 0;
4128
+
4129
+ /**
4130
+ * Finds tables in a deck: native tables through the workbook table reader
4131
+ * (merged cells filled, a header over several rows joined), or, with
4132
+ * `shapes`, text boxes laid out as a table through the PDF table reader (a box
4133
+ * is a cell, boxes whose heights overlap a row, columns from where the body's
4134
+ * boxes start). Hidden slides are skipped unless `includeHidden`.
4135
+ *
4136
+ * @param document - The deck.
4137
+ * @param query - Which tables, on which slides, and how to name their columns.
4138
+ * @returns The tables, slide by slide.
4139
+ */
4140
+ function findDeckTables(document, query) {
4141
+ const slides = document.slides.filter(slide => (query.includeHidden === true || !slide.hidden) && (query.slide === undefined || query.slide.test(slide.title ?? '')));
4142
+ if (query.shapes === true) return shapeTables(document, slides, query);
4143
+ return slides.flatMap(slide => findGridTables({
4144
+ sheets: slide.tables
4145
+ }, query).map(table => ({
4146
+ slide: slide.number,
4147
+ slideTitle: slide.title ?? '',
4148
+ title: table.title,
4149
+ header: table.header,
4150
+ rows: table.rows
4151
+ })));
4152
+ }
4153
+ /** Text boxes as a PDF of one page per slide, y flipped (PDF counts from the bottom), each box one cell. */
4154
+ function shapeTables(document, slides, query) {
4155
+ const pdf = {
4156
+ pages: slides.map(slide => ({
4157
+ number: slide.number,
4158
+ width: document.width,
4159
+ height: document.height,
4160
+ rows: rowsOfCells(slide.shapes.map(shape => ({
4161
+ x: shape.x,
4162
+ y: document.height - shape.y - shape.height,
4163
+ width: shape.width,
4164
+ height: shape.height,
4165
+ text: shape.text.replaceAll(/\s+/g, ' ').trim()
4166
+ })))
4167
+ }))
4168
+ };
4169
+ const titles = new Map(slides.map(slide => [slide.number, slide.title ?? '']));
4170
+ return findTables(pdf, {
4171
+ header: query.header,
4172
+ until: query.until,
4173
+ columns: query.columns,
4174
+ align: query.align
4175
+ }).map(table => ({
4176
+ slide: table.page,
4177
+ slideTitle: titles.get(table.page) ?? '',
4178
+ title: table.title,
4179
+ header: table.header,
4180
+ rows: query.fillDown === undefined ? table.rows : fillDown(table.rows, query.fillDown)
4181
+ }));
2541
4182
  }
2542
4183
 
2543
4184
  /**
2544
4185
  * Runs an `extract` step against a static document: the value bound under
2545
4186
  * `from`, else the scope's current document. `css` reads HTML, `jsonpath`
2546
- * reads JSON (or a read PDF's rows), `table` reads a PDF's tables, `regex`
2547
- * reads any document as text; `xpath` needs a live page and is refused here.
4187
+ * reads JSON (or a read PDF, workbook or deck as data), `table` reads the
4188
+ * tables of a PDF, a workbook (a spreadsheet, a CSV), a deck (a presentation)
4189
+ * or HTML (its `<table>`s), `regex` reads any document as text; `xpath` needs
4190
+ * a live page and is refused here.
2548
4191
  *
2549
4192
  * A `jsonpath` extract whose `from` is text parses that text as JSON, and a
2550
4193
  * list of texts (every `<script type="application/ld+json">` of a page) becomes
@@ -2563,19 +4206,18 @@ function extractFromDocument(step, scope) {
2563
4206
  switch (step.kind) {
2564
4207
  case 'jsonpath':
2565
4208
  {
2566
- if (document.kind !== 'json' && document.kind !== 'pdf') throw new Error(`jsonpath needs a JSON document; the current document is ${document.kind}`);
2567
- values = selectJson(document.kind === 'pdf' ? document : document.data, selector).map(node => takeFromJson(node, take));
4209
+ if (document.kind === 'html' || document.kind === 'text') throw new Error(`jsonpath needs a JSON document; the current document is ${document.kind}`);
4210
+ values = selectJson(document.kind === 'json' ? document.data : document, selector).map(node => takeFromJson(node, take));
2568
4211
  break;
2569
4212
  }
2570
4213
  case 'table':
2571
4214
  {
2572
- if (document.kind !== 'pdf') throw new Error(`table reads a PDF; the current document is ${document.kind} (request it with "as": "pdf")`);
2573
- values = findTables(document, tableQuery(step, selector));
4215
+ values = readTables(document, step, selector);
2574
4216
  break;
2575
4217
  }
2576
4218
  case 'css':
2577
4219
  {
2578
- if (document.kind !== 'html') throw new Error(`css needs an HTML document; the current document is ${document.kind}`);
4220
+ if (document.kind !== 'html') throw new Error(`css needs an HTML document; the current document is ${document.kind}${['workbook', 'pdf', 'deck'].includes(document.kind) ? ' (read it with kind "table", "regex" or "jsonpath")' : ''}`);
2579
4221
  values = selectHtml(document.html, selector).map(match => takeFromHtml(match, take));
2580
4222
  break;
2581
4223
  }
@@ -2607,20 +4249,82 @@ function extractFromDocument(step, scope) {
2607
4249
  function renderSelector(selector, scope) {
2608
4250
  return hasPlaceholder(selector) ? renderText(selector, path => scope.lookup(path)) : selector;
2609
4251
  }
4252
+ /**
4253
+ * The tables a `table` extract finds in a document: a PDF's, a workbook's, a
4254
+ * deck's, or an HTML document's `<table>`s (a fetched page, rendered Markdown,
4255
+ * a live page's content).
4256
+ *
4257
+ * @param document - The document.
4258
+ * @param step - The extract step.
4259
+ * @param scope - Where its selector renders.
4260
+ * @returns The tables.
4261
+ */
4262
+ function tablesIn(document, step, scope) {
4263
+ return readTables(document, step, renderSelector(step.selector, scope));
4264
+ }
4265
+ function readTables(document, step, selector) {
4266
+ const query = tableQuery(step, selector);
4267
+ if (document.kind === 'html') {
4268
+ refuseOptions(step, ['sheet', 'slide', 'shapes'], 'workbooks and decks', 'HTML');
4269
+ return findGridTables({
4270
+ sheets: htmlTableSheets(document.html)
4271
+ }, {
4272
+ ...query,
4273
+ headerRows: step.headerRows,
4274
+ fillDown: step.fillDown
4275
+ });
4276
+ }
4277
+ if (document.kind === 'workbook') {
4278
+ refuseOptions(step, ['slide', 'shapes'], 'decks (presentations)', 'a workbook');
4279
+ return findGridTables(document, {
4280
+ ...query,
4281
+ sheet: optionalPattern(step.sheet, 'sheet'),
4282
+ headerRows: step.headerRows,
4283
+ fillDown: step.fillDown,
4284
+ includeHidden: step.includeHidden
4285
+ });
4286
+ }
4287
+ if (document.kind === 'deck') {
4288
+ refuseOptions(step, ['sheet'], 'workbooks (spreadsheets, CSV)', 'a deck');
4289
+ return findDeckTables(document, {
4290
+ ...query,
4291
+ slide: optionalPattern(step.slide, 'slide'),
4292
+ shapes: step.shapes,
4293
+ headerRows: step.headerRows,
4294
+ fillDown: step.fillDown,
4295
+ includeHidden: step.includeHidden
4296
+ });
4297
+ }
4298
+ if (document.kind !== 'pdf') throw new Error(`table reads a PDF, a workbook (a spreadsheet, a CSV), a deck (a presentation) or HTML tables; the current document is ${document.kind} (request it with "as": "pdf", "csv", "xlsx", "pptx" or "html")`);
4299
+ refuseOptions(step, ['sheet', 'headerRows', 'includeHidden', 'slide', 'shapes'], 'workbooks and decks', 'a PDF');
4300
+ const tables = findTables(document, query);
4301
+ return step.fillDown === undefined ? tables : tables.map(table => ({
4302
+ ...table,
4303
+ rows: fillDown(table.rows, step.fillDown ?? [])
4304
+ }));
4305
+ }
2610
4306
  /**
2611
4307
  * A table extract's query: the selector matches the header row, the other
2612
- * patterns come from the step; all case-insensitive, since PDFs capitalise
2613
- * headings freely.
4308
+ * patterns come from the step; all case-insensitive, since PDFs and
4309
+ * spreadsheets capitalise headings freely.
2614
4310
  */
2615
4311
  function tableQuery(step, selector) {
2616
4312
  const columns = step.columns === undefined ? undefined : Object.fromEntries(Object.entries(step.columns).map(([key, pattern]) => [key, patternOf(pattern, `columns.${key}`)]));
2617
4313
  return {
2618
4314
  header: patternOf(selector, 'selector'),
2619
- until: step.until === undefined ? undefined : patternOf(step.until, 'until'),
4315
+ until: optionalPattern(step.until, 'until'),
2620
4316
  columns,
2621
4317
  align: step.align
2622
4318
  };
2623
4319
  }
4320
+ function refuseOptions(step, options, reads, current) {
4321
+ for (const option of options) {
4322
+ if (step[option] !== undefined) throw new Error(`"${option}" reads ${reads}; the current document is ${current}`);
4323
+ }
4324
+ }
4325
+ function optionalPattern(source, where) {
4326
+ return source === undefined ? undefined : patternOf(source, where);
4327
+ }
2624
4328
  function patternOf(source, where) {
2625
4329
  try {
2626
4330
  return new RegExp(source, 'i');
@@ -2630,11 +4334,13 @@ function patternOf(source, where) {
2630
4334
  });
2631
4335
  }
2632
4336
  }
2633
- /** The text a regex extract reads: markup, text, a PDF's rows, or JSON re-serialised (a list of texts joined by newlines). */
4337
+ /** The text a regex extract reads: markup, text, a PDF's or a workbook's rows, or JSON re-serialised (a list of texts joined by newlines). */
2634
4338
  function textOf$1(document) {
2635
4339
  if (document.kind === 'html') return document.html;
2636
4340
  if (document.kind === 'text') return document.text;
2637
4341
  if (document.kind === 'pdf') return pdfText(document);
4342
+ if (document.kind === 'workbook') return workbookText(document);
4343
+ if (document.kind === 'deck') return deckText(document);
2638
4344
  if (Array.isArray(document.data) && document.data.every(entry => typeof entry === 'string')) return document.data.join('\n');
2639
4345
  return typeof document.data === 'string' ? document.data : JSON.stringify(document.data);
2640
4346
  }
@@ -2646,8 +4352,8 @@ function documentFor(step, scope) {
2646
4352
  }
2647
4353
  const source = scope.get(step.from);
2648
4354
  if (source === undefined) throw new Error(`"${step.from}" is not bound`);
2649
- if (isPdfDocument(source)) return source;
2650
- if (step.kind === 'table') throw new Error(`"${step.from}" is not a PDF; request it with "as": "pdf"`);
4355
+ if (isPdfDocument(source) || isWorkbookDocument(source) || isDeckDocument(source)) return source;
4356
+ if (step.kind === 'table') throw new Error(`"${step.from}" is not a PDF, a workbook or a deck; request it with "as": "pdf", "csv", "xlsx" or "pptx"`);
2651
4357
  if (step.kind === 'regex') {
2652
4358
  if (typeof source === 'string') return {
2653
4359
  kind: 'text',
@@ -2683,6 +4389,135 @@ function documentFor(step, scope) {
2683
4389
  };
2684
4390
  }
2685
4391
 
4392
+ /** Aliases one document may expand: a "billion laughs" document needs far more. */
4393
+ const MAX_ALIASES = 100;
4394
+ /**
4395
+ * Parses YAML with the `yaml` package, imported on first use. The version is
4396
+ * pinned to YAML 1.2 (core schema) whatever the document declares: under a
4397
+ * `%YAML 1.1` directive, `NO` would read as `false` and `0123` as octal `83`.
4398
+ * Merge keys (`<<: *base`) are applied, duplicate keys are an error, aliases
4399
+ * are capped, and custom tags never build values: nothing in the text runs.
4400
+ *
4401
+ * @param text - The YAML.
4402
+ * @param source - Where it came from, for messages.
4403
+ * @param scalars - `typed` (default), or `text` to keep every scalar as written (`0123` stays `"0123"`).
4404
+ * @returns The data, the number of documents and the warnings.
4405
+ * @throws Error naming the source, with the line and column, for YAML that does not parse.
4406
+ */
4407
+ async function readYaml(text, source, scalars = 'typed') {
4408
+ const {
4409
+ parseAllDocuments
4410
+ } = await import('yaml');
4411
+ const parsed = parseAllDocuments(text, {
4412
+ version: '1.2',
4413
+ schema: scalars === 'text' ? 'failsafe' : 'core',
4414
+ merge: true,
4415
+ uniqueKeys: true,
4416
+ prettyErrors: true
4417
+ });
4418
+ const documents = Array.isArray(parsed) ? parsed : [parsed];
4419
+ const warnings = [];
4420
+ const values = [];
4421
+ for (const document of documents) {
4422
+ const [error] = document.errors;
4423
+ if (error !== undefined) throw new Error(`${source}: not YAML (${error.message.split('\n', 1)[0]})`, {
4424
+ cause: error
4425
+ });
4426
+ warnings.push(...document.warnings.map(warning => warning.message.split('\n', 1)[0]));
4427
+ try {
4428
+ values.push(document.toJS({
4429
+ maxAliasCount: MAX_ALIASES
4430
+ }));
4431
+ } catch (error) {
4432
+ throw new Error(`${source}: ${error.message}`, {
4433
+ cause: error
4434
+ });
4435
+ }
4436
+ }
4437
+ return {
4438
+ data: values.length === 1 ? values[0] : values,
4439
+ documents: values.length,
4440
+ warnings
4441
+ };
4442
+ }
4443
+
4444
+ /** A leading `---` block of YAML. */
4445
+ const FRONT_MATTER = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/;
4446
+ /**
4447
+ * Renders Markdown (GitHub-flavoured: tables, task lists, strikethrough,
4448
+ * autolinks) to HTML with `marked`, imported on first use, so every `css`
4449
+ * selector works on it:
4450
+ *
4451
+ * - each heading and everything up to the next heading of the same or a higher
4452
+ * level is wrapped in `<section data-heading="…" data-level="…">`, sections
4453
+ * nesting, so "the table under *Prezzi*" is one selector;
4454
+ * - headings get slug ids (`<h2 id="prezzi">`);
4455
+ * - a leading `---` YAML block is parsed (YAML 1.2, as `as: "yaml"` reads it)
4456
+ * and put in the head as `<script type="application/json" data-front-matter>`.
4457
+ *
4458
+ * Raw HTML in the Markdown is kept: it is data, parsed by cheerio, never run.
4459
+ *
4460
+ * @param text - The Markdown.
4461
+ * @param source - Where it came from, for messages.
4462
+ * @returns The HTML, the front matter's data and the YAML parser's warnings.
4463
+ * @throws Error naming the source when the front matter is not YAML.
4464
+ */
4465
+ async function readMarkdown(text, source) {
4466
+ const matter = FRONT_MATTER.exec(text);
4467
+ const body = matter === null ? text : text.slice(matter[0].length);
4468
+ const front = matter === null ? undefined : await readYaml(matter[1], `${source} front matter`);
4469
+ const {
4470
+ marked
4471
+ } = await import('marked');
4472
+ const rendered = marked.parse(body, {
4473
+ gfm: true,
4474
+ async: false
4475
+ });
4476
+ const sections = sectioned(rendered);
4477
+ const head = front === undefined ? '' : `<script type="application/json" data-front-matter>${JSON.stringify(front.data ?? null).replaceAll('<', '<')}</script>`;
4478
+ return {
4479
+ html: `<!doctype html><html><head>${head}</head><body>${sections}</body></html>`,
4480
+ frontMatter: front?.data,
4481
+ warnings: front?.warnings ?? []
4482
+ };
4483
+ }
4484
+ /** Wraps each heading and what follows it, up to the next heading of the same or a higher level, in a section. */
4485
+ function sectioned(html) {
4486
+ const $ = load(html, null, false);
4487
+ const open = [];
4488
+ const used = new Map();
4489
+ let out = '';
4490
+ const nodes = $.root().contents().toArray();
4491
+ for (const node of nodes) {
4492
+ const level = node.type === 'tag' ? /^h([1-6])$/.exec(node.name)?.[1] : undefined;
4493
+ if (level === undefined) {
4494
+ out += $.html(node);
4495
+ continue;
4496
+ }
4497
+ const depth = Number(level);
4498
+ while (open.length > 0 && (open.at(-1) ?? 0) >= depth) {
4499
+ open.pop();
4500
+ out += '</section>';
4501
+ }
4502
+ const heading = $(node);
4503
+ const text = heading.text().replaceAll(/\s+/g, ' ').trim();
4504
+ heading.attr('id', uniqueSlug(text, used));
4505
+ out += `<section data-heading="${escapeAttribute(text)}" data-level="${depth}">${$.html(node)}`;
4506
+ open.push(depth);
4507
+ }
4508
+ return out + '</section>'.repeat(open.length);
4509
+ }
4510
+ /** GitHub's heading ids: lower case, spaces to hyphens, punctuation dropped, a counter for repeats. */
4511
+ function uniqueSlug(text, used) {
4512
+ const slug = text.toLowerCase().replaceAll(/[^\p{L}\p{N}\s-]/gu, '').trim().replaceAll(/\s/g, '-');
4513
+ const count = used.get(slug) ?? 0;
4514
+ used.set(slug, count + 1);
4515
+ return count === 0 ? slug : `${slug}-${count}`;
4516
+ }
4517
+ function escapeAttribute(text) {
4518
+ return text.replaceAll('&', '&amp;').replaceAll('"', '&quot;').replaceAll('<', '&lt;');
4519
+ }
4520
+
2686
4521
  /** A response with a 4xx or 5xx status. */
2687
4522
  class HttpError extends Error {
2688
4523
  status;
@@ -2699,6 +4534,82 @@ class HttpError extends Error {
2699
4534
  }
2700
4535
  }
2701
4536
 
4537
+ /**
4538
+ * Decodes a body, in this order: a byte-order mark (UTF-8, UTF-16 LE/BE; Excel's
4539
+ * "Unicode text" export is UTF-16 LE), the encoding a recipe asks for, the
4540
+ * charset the server declares, strict UTF-8, and Windows-1252 (a superset of
4541
+ * Latin-1) for text that is not UTF-8.
4542
+ *
4543
+ * The Windows-1252 fallback is only taken when the text holds no valid UTF-8
4544
+ * beyond ASCII: a UTF-8 page with one stray byte keeps its accents, with a
4545
+ * replacement character for the stray byte, instead of turning every accent
4546
+ * into mojibake.
4547
+ *
4548
+ * @param bytes - The body.
4549
+ * @param options - `encoding`: the recipe's choice, a WHATWG label (wins over
4550
+ * the charset, not over a BOM); `charset`: from the content type (ignored when
4551
+ * not a known label).
4552
+ * @returns The text, without its BOM, and the encoding used.
4553
+ * @throws Error when `encoding` is not a known label.
4554
+ */
4555
+ function decodeText(bytes, options = {}) {
4556
+ const bom = bomOf(bytes);
4557
+ if (bom !== undefined) return decodeWith(new TextDecoder(bom), bytes);
4558
+ if (options.encoding !== undefined) {
4559
+ const decoder = decoderFor(options.encoding);
4560
+ if (decoder === undefined) throw new Error(`"${options.encoding}" is not an encoding this runtime knows (try utf8, windows-1252, iso-8859-15, utf-16le, shift_jis…)`);
4561
+ return decodeWith(decoder, bytes);
4562
+ }
4563
+ const declared = options.charset === undefined ? undefined : decoderFor(options.charset);
4564
+ if (declared !== undefined) return decodeWith(declared, bytes);
4565
+ try {
4566
+ return decodeWith(new TextDecoder('utf-8', {
4567
+ fatal: true
4568
+ }), bytes);
4569
+ } catch {
4570
+ const lenient = decodeWith(new TextDecoder('utf-8'), bytes);
4571
+ return hasNonAsciiText(lenient.text) ? lenient : decodeWith(new TextDecoder('windows-1252'), bytes);
4572
+ }
4573
+ }
4574
+ /** Decodes, naming the encoding by its canonical WHATWG name (`utf-8`, `windows-1252`, `utf-16le`). */
4575
+ function decodeWith(decoder, bytes) {
4576
+ return {
4577
+ text: decoder.decode(bytes),
4578
+ encoding: decoder.encoding
4579
+ };
4580
+ }
4581
+ /**
4582
+ * The charset a content type declares (`text/csv; charset=ISO-8859-1`).
4583
+ *
4584
+ * @param contentType - The header value.
4585
+ * @returns The charset, or `undefined`.
4586
+ */
4587
+ function charsetOf(contentType) {
4588
+ const match = /;\s*charset\s*=\s*"?([^";\s]+)"?/i.exec(contentType);
4589
+ return match?.[1];
4590
+ }
4591
+ function bomOf(bytes) {
4592
+ if (bytes[0] === 0xEF && bytes[1] === 0xBB && bytes[2] === 0xBF) return 'utf8';
4593
+ if (bytes[0] === 0xFF && bytes[1] === 0xFE) return 'utf-16le';
4594
+ if (bytes[0] === 0xFE && bytes[1] === 0xFF) return 'utf-16be';
4595
+ return undefined;
4596
+ }
4597
+ /** Whether the text holds a character beyond ASCII other than the replacement character: valid UTF-8 was seen. */
4598
+ function hasNonAsciiText(text) {
4599
+ for (const char of text) {
4600
+ const code = char.codePointAt(0) ?? 0;
4601
+ if (code !== 0xFF_FD && code > 0x7F) return true;
4602
+ }
4603
+ return false;
4604
+ }
4605
+ function decoderFor(label) {
4606
+ try {
4607
+ return new TextDecoder(label.trim());
4608
+ } catch {
4609
+ return undefined;
4610
+ }
4611
+ }
4612
+
2702
4613
  /**
2703
4614
  * HTTP through Playwright's request context: cookies, redirects and storage
2704
4615
  * state behave exactly as they do in the browser, so a session captured by a
@@ -2738,12 +4649,20 @@ class HttpClient {
2738
4649
  data: httpRequest.body,
2739
4650
  timeout: httpRequest.timeoutMs ?? this.timeoutMs
2740
4651
  });
2741
- const body = await readBody(response, httpRequest.as);
4652
+ const {
4653
+ body,
4654
+ warnings,
4655
+ format
4656
+ } = await readBody(response, httpRequest);
2742
4657
  const result = {
2743
4658
  status: response.status(),
2744
4659
  url: response.url(),
2745
4660
  headers: response.headers(),
2746
- body
4661
+ body,
4662
+ format,
4663
+ ...(warnings.length > 0 && {
4664
+ warnings
4665
+ })
2747
4666
  };
2748
4667
  if (response.status() >= 400) throw new HttpError(response.status(), response.url(), body, response.headers());
2749
4668
  return result;
@@ -2756,40 +4675,107 @@ class HttpClient {
2756
4675
  return this.context.dispose();
2757
4676
  }
2758
4677
  }
2759
- async function readBody(response, as) {
2760
- const kind = as ?? kindFromContentType(response.headers()['content-type'] ?? '');
2761
- return parseBody(kind, await response.body(), response.url());
4678
+ async function readBody(response, httpRequest) {
4679
+ const contentType = response.headers()['content-type'] ?? '';
4680
+ const format = httpRequest.as ?? formatFromContentType(contentType);
4681
+ return parseBody(format, await response.body(), response.url(), {
4682
+ ...httpRequest,
4683
+ charset: charsetOf(contentType)
4684
+ });
2762
4685
  }
2763
4686
  /**
2764
- * A `file:` URL, read from disk: a PDF or JSON a recipe gets from a folder
2765
- * instead of a server. The kind is `as`, else the file extension.
4687
+ * A `file:` URL, read from disk: a PDF, spreadsheet, presentation, CSV, YAML
4688
+ * or JSON a recipe gets from a folder instead of a server. The format is `as`,
4689
+ * else the file extension.
2766
4690
  */
2767
4691
  async function readLocalFile(httpRequest) {
2768
4692
  const path = fileURLToPath(httpRequest.url);
2769
4693
  const bytes = await readFile(path);
4694
+ const {
4695
+ body,
4696
+ warnings,
4697
+ format
4698
+ } = await parseBody(httpRequest.as ?? formatFromExtension(extname(path)), bytes, httpRequest.url, httpRequest);
2770
4699
  return {
2771
4700
  status: 200,
2772
4701
  url: httpRequest.url,
2773
4702
  headers: {},
2774
- body: await parseBody(httpRequest.as ?? kindFromExtension(extname(path)), bytes, httpRequest.url)
4703
+ body,
4704
+ format,
4705
+ ...(warnings.length > 0 && {
4706
+ warnings
4707
+ })
2775
4708
  };
2776
4709
  }
2777
- async function parseBody(kind, bytes, url) {
2778
- if (kind === 'pdf') return readPdf(bytes, url);
2779
- const text = new TextDecoder().decode(bytes);
2780
- if (kind === 'json') {
2781
- try {
2782
- return {
4710
+ async function parseBody(format, bytes, url, reading) {
4711
+ if (format === 'yaml') {
4712
+ const {
4713
+ text
4714
+ } = decodeText(bytes, reading);
4715
+ const {
4716
+ data,
4717
+ warnings
4718
+ } = await readYaml(text, url, reading.scalars);
4719
+ return {
4720
+ body: {
2783
4721
  kind: 'json',
2784
- data: JSON.parse(text)
2785
- };
2786
- } catch (error) {
2787
- throw new Error(`${url}: body is not JSON (${error.message})`, {
2788
- cause: error
2789
- });
2790
- }
4722
+ data
4723
+ },
4724
+ warnings,
4725
+ format
4726
+ };
4727
+ }
4728
+ if (format === 'markdown') {
4729
+ const {
4730
+ text
4731
+ } = decodeText(bytes, reading);
4732
+ const {
4733
+ html,
4734
+ warnings
4735
+ } = await readMarkdown(text, url);
4736
+ return {
4737
+ body: {
4738
+ kind: 'html',
4739
+ html
4740
+ },
4741
+ warnings,
4742
+ format
4743
+ };
4744
+ }
4745
+ return {
4746
+ body: await parseFormat(format, bytes, url, reading),
4747
+ warnings: [],
4748
+ format
4749
+ };
4750
+ }
4751
+ async function parseFormat(format, bytes, url, reading) {
4752
+ if (format === 'pdf') return readPdf(bytes, url);
4753
+ if (format === 'xlsx') return readXlsxWorkbook(bytes, url);
4754
+ if (format === 'pptx') return readPptxDeck(bytes, url);
4755
+ const {
4756
+ text,
4757
+ encoding
4758
+ } = decodeText(bytes, reading);
4759
+ if (format === 'csv') return csvWorkbook(text, {
4760
+ name: sheetNameOf(url),
4761
+ encoding,
4762
+ delimiter: reading.delimiter
4763
+ });
4764
+ if (format === 'jsonl') return {
4765
+ kind: 'json',
4766
+ data: parseJsonLines(text, url)
4767
+ };
4768
+ if (format === 'json') {
4769
+ const parsed = parseJsonLike(text);
4770
+ if ('error' in parsed) throw new Error(`${url}: body is not JSON (${parsed.error.message})${looksLikeJsonLines(text) ? '; it looks like JSON Lines: read it with "as": "jsonl"' : ''}`, {
4771
+ cause: parsed.error
4772
+ });
4773
+ return {
4774
+ kind: 'json',
4775
+ data: parsed.value
4776
+ };
2791
4777
  }
2792
- return kind === 'html' ? {
4778
+ return format === 'html' ? {
2793
4779
  kind: 'html',
2794
4780
  html: text
2795
4781
  } : {
@@ -2797,28 +4783,61 @@ async function parseBody(kind, bytes, url) {
2797
4783
  text
2798
4784
  };
2799
4785
  }
2800
- function kindFromContentType(contentType) {
2801
- const type = contentType.toLowerCase();
4786
+ /** Several lines, the first of them JSON on its own. */
4787
+ function looksLikeJsonLines(text) {
4788
+ const lines = text.split(/\r?\n/).filter(line => line.trim() !== '');
4789
+ if (lines.length < 2) return false;
4790
+ const first = parseJsonLike(lines[0]);
4791
+ return 'value' in first;
4792
+ }
4793
+ function formatFromContentType(contentType) {
4794
+ const type = contentType.toLowerCase().split(';', 1)[0].trim();
4795
+ if (CSV_TYPES.has(type)) return 'csv';
4796
+ if (JSON_LINES_TYPES.has(type)) return 'jsonl';
4797
+ // A legacy .xls or .ppt goes to the Office reader too, which says what to do with it.
4798
+ if (type.includes('spreadsheetml') || type.startsWith('application/vnd.ms-excel')) return 'xlsx';
4799
+ if (type.includes('presentationml') || type.startsWith('application/vnd.ms-powerpoint')) return 'pptx';
4800
+ if (YAML_TYPES.has(type)) return 'yaml';
4801
+ if (type === 'text/markdown' || type === 'text/x-markdown') return 'markdown';
2802
4802
  if (type.includes('json')) return 'json';
2803
4803
  if (type.includes('pdf')) return 'pdf';
2804
4804
  if (type.includes('html') || type.includes('xml')) return 'html';
2805
4805
  return 'text';
2806
4806
  }
2807
- function kindFromExtension(extension) {
2808
- const kinds = {
4807
+ const JSON_LINES_TYPES = new Set(['application/x-ndjson', 'application/ndjson', 'application/jsonl', 'application/x-jsonlines', 'application/jsonlines']);
4808
+ const YAML_TYPES = new Set(['application/yaml', 'application/x-yaml', 'text/yaml', 'text/x-yaml']);
4809
+ const CSV_TYPES = new Set(['text/csv', 'application/csv', 'text/x-csv', 'application/x-csv', 'text/comma-separated-values', 'text/tab-separated-values']);
4810
+ function formatFromExtension(extension) {
4811
+ const formats = {
2809
4812
  '.json': 'json',
4813
+ '.jsonl': 'jsonl',
4814
+ '.ndjson': 'jsonl',
2810
4815
  '.pdf': 'pdf',
4816
+ '.csv': 'csv',
4817
+ '.tsv': 'csv',
4818
+ '.xlsx': 'xlsx',
4819
+ '.xlsm': 'xlsx',
4820
+ '.xls': 'xlsx',
4821
+ '.pptx': 'pptx',
4822
+ '.pptm': 'pptx',
4823
+ '.ppsx': 'pptx',
4824
+ '.ppt': 'pptx',
4825
+ '.yaml': 'yaml',
4826
+ '.yml': 'yaml',
4827
+ '.md': 'markdown',
4828
+ '.markdown': 'markdown',
2811
4829
  '.html': 'html',
2812
4830
  '.htm': 'html',
2813
4831
  '.xml': 'html'
2814
4832
  };
2815
- return kinds[extension.toLowerCase()] ?? 'text';
4833
+ return formats[extension.toLowerCase()] ?? 'text';
2816
4834
  }
2817
4835
 
2818
4836
  /**
2819
4837
  * Sends a `request` step: renders its templates, waits for the gate's throttle,
2820
- * sends, checks the response against the recipe's block rule, then binds it as
2821
- * the scope's current document (and under the step id).
4838
+ * sends (again, after a pause, while it fails in passing: `limits.retry`),
4839
+ * checks the response against the recipe's block rule, then binds it as the
4840
+ * scope's current document (and under the step id).
2822
4841
  *
2823
4842
  * @param step - The request step.
2824
4843
  * @param scope - The scope to render in and bind into.
@@ -2826,22 +4845,34 @@ function kindFromExtension(extension) {
2826
4845
  * @param recipe - The recipe: its limits, block rule and id.
2827
4846
  * @param gate - Spaces request starts by `delayMs`.
2828
4847
  * @param events - Where to report the visit.
2829
- * @throws BlockedError when the response is a block; HttpError for any other 4xx/5xx.
4848
+ * @throws BlockedError when the response is a block, or a captcha page under `session.captcha`; HttpError for any other 4xx/5xx.
2830
4849
  */
2831
4850
  async function sendRequest(step, scope, client, recipe, gate, events) {
2832
4851
  const lookup = path => scope.lookup(path);
2833
4852
  const url = resolveUrl(renderText(step.url, lookup), scope.pageState?.url);
2834
- await gate.throttle();
4853
+ const request = {
4854
+ method: step.method,
4855
+ url,
4856
+ query: step.query === undefined ? undefined : renderMap(step.query, lookup),
4857
+ headers: step.headers === undefined ? undefined : renderMap(step.headers, lookup),
4858
+ body: renderDeep(step.body, lookup),
4859
+ as: step.as,
4860
+ encoding: step.encoding,
4861
+ delimiter: step.delimiter,
4862
+ scalars: step.scalars,
4863
+ timeoutMs: recipe.limits?.timeoutMs
4864
+ };
4865
+ const rule = resolveRetryRule(recipe.limits?.retry);
2835
4866
  let response;
2836
4867
  try {
2837
- response = await client.send({
2838
- method: step.method,
2839
- url,
2840
- query: step.query === undefined ? undefined : renderMap(step.query, lookup),
2841
- headers: step.headers === undefined ? undefined : renderMap(step.headers, lookup),
2842
- body: renderDeep(step.body, lookup),
2843
- as: step.as,
2844
- timeoutMs: recipe.limits?.timeoutMs
4868
+ response = await withTransportRetry(url, {
4869
+ run: () => client.send(request),
4870
+ problem: outcome => 'error' in outcome ? problemOf(outcome.error, rule.statuses) : undefined
4871
+ }, {
4872
+ recipeId: recipe.id,
4873
+ gate,
4874
+ events,
4875
+ rule
2845
4876
  });
2846
4877
  } catch (error) {
2847
4878
  if (!(error instanceof HttpError)) throw error;
@@ -2866,6 +4897,15 @@ async function sendRequest(step, scope, client, recipe, gate, events) {
2866
4897
  number: scope.pageState?.number ?? 1,
2867
4898
  status: response.status
2868
4899
  });
4900
+ const warnings = response.warnings ?? [];
4901
+ for (const warning of warnings) events.emit({
4902
+ type: 'warning',
4903
+ recipeId: recipe.id,
4904
+ message: `${response.url}: ${warning}`,
4905
+ meta: {
4906
+ url: response.url
4907
+ }
4908
+ });
2869
4909
  const blocked = await detectBlock({
2870
4910
  url: response.url,
2871
4911
  status: response.status,
@@ -2873,15 +4913,30 @@ async function sendRequest(step, scope, client, recipe, gate, events) {
2873
4913
  text: async () => bodyText(response.body)
2874
4914
  }, recipe.session?.blockedWhen);
2875
4915
  if (blocked !== undefined) throw blocked;
4916
+ if (recipe.session?.captcha !== undefined && response.body.kind === 'html' && CAPTCHA_MARKUP.test(response.body.html)) {
4917
+ throw new BlockedError(response.url, response.status, 'the page shows a captcha, which is solved on a live page: run this recipe in web mode, or get past it in session.bootstrap');
4918
+ }
2876
4919
  scope.setPage({
2877
4920
  url: response.url,
2878
4921
  document: response.body
2879
4922
  });
2880
4923
  if (step.id !== undefined) scope.set(step.id, documentValue(response.body));
2881
4924
  }
4925
+ /** A retry status (with the server's `Retry-After`), or a connection that failed. */
4926
+ function problemOf(error, statuses) {
4927
+ if (error instanceof HttpError) return statuses.includes(error.status) ? {
4928
+ reason: `HTTP ${error.status}`,
4929
+ retryAfter: error.headers['retry-after']
4930
+ } : undefined;
4931
+ return transientError(error);
4932
+ }
4933
+ /** The class names of the widgets `session.captcha` solves. Checked only when a recipe declares it. */
4934
+ const CAPTCHA_MARKUP = /\b(?:g-recaptcha|h-captcha|cf-turnstile)\b/;
2882
4935
  function bodyText(body) {
2883
4936
  if (body.kind === 'json') return JSON.stringify(body.data);
2884
4937
  if (body.kind === 'pdf') return pdfText(body);
4938
+ if (body.kind === 'workbook') return workbookText(body);
4939
+ if (body.kind === 'deck') return deckText(body);
2885
4940
  return body.kind === 'html' ? body.html : body.text;
2886
4941
  }
2887
4942
  function renderMap(map, lookup) {
@@ -2902,11 +4957,11 @@ function resolveUrl(target, base) {
2902
4957
  throw new Error(`"${target}" is not a URL${base === undefined || base === '' ? ' and no page is known to resolve it against' : ` and cannot be resolved against ${base}`}`);
2903
4958
  }
2904
4959
  }
2905
- /** What a step id holds for a document: parsed JSON, the read PDF, or the markup / text. */
4960
+ /** What a step id holds for a document: parsed JSON, the read PDF, workbook or deck, or the markup / text. */
2906
4961
  function documentValue(body) {
2907
4962
  if (body.kind === 'json') return body.data;
2908
- if (body.kind === 'pdf') return body;
2909
- return body.kind === 'html' ? body.html : body.text;
4963
+ if (body.kind === 'html') return body.html;
4964
+ return body.kind === 'text' ? body.text : body;
2910
4965
  }
2911
4966
 
2912
4967
  /** Runs api-mode leaf steps against an HTTP sender. */
@@ -3988,11 +6043,10 @@ async function extractFromPage(step, page, scope) {
3988
6043
  extractFromDocument(step, scope);
3989
6044
  return;
3990
6045
  }
3991
- if (step.kind === 'table') throw new Error('table reads a PDF: fetch it with a request step in api mode, or extract "from" a PDF bound earlier');
3992
- const rendered = renderSelector(step.selector, scope);
3993
- const take = step.take ?? 'text';
3994
- const raw = step.kind === 'regex' ? selectRegex(await page.content(), rendered) : await page.locator(step.kind === 'xpath' ? `xpath=${rendered}` : rendered).evaluateAll(readAll, take);
3995
- const values = take === 'text' ? raw.map(value => typeof value === 'string' ? collapse(value) : value) : raw;
6046
+ const values = step.kind === 'table' ? tablesIn({
6047
+ kind: 'html',
6048
+ html: await page.content()
6049
+ }, step, scope) : await readPage(step, page, scope);
3996
6050
  if (step.many === true) {
3997
6051
  if (step.id !== undefined) scope.set(step.id, values);
3998
6052
  return;
@@ -4000,6 +6054,13 @@ async function extractFromPage(step, page, scope) {
4000
6054
  if (values.length === 0) throw new NoMatchError(step.selector);
4001
6055
  if (step.id !== undefined) scope.set(step.id, values[0]);
4002
6056
  }
6057
+ /** A css, xpath or regex extract on the live page. */
6058
+ async function readPage(step, page, scope) {
6059
+ const rendered = renderSelector(step.selector, scope);
6060
+ const take = step.take ?? 'text';
6061
+ const raw = step.kind === 'regex' ? selectRegex(await page.content(), rendered) : await page.locator(step.kind === 'xpath' ? `xpath=${rendered}` : rendered).evaluateAll(readAll, take);
6062
+ return take === 'text' ? raw.map(value => typeof value === 'string' ? collapse(value) : value) : raw;
6063
+ }
4003
6064
  /** Runs inside the page: one value per matched element. Keep it self-contained; it is serialised. */
4004
6065
  function readAll(elements, take) {
4005
6066
  return elements.map(element => {
@@ -4151,18 +6212,34 @@ function readElements(elements) {
4151
6212
 
4152
6213
  /**
4153
6214
  * Runs a `goto` step: renders the URL (relative to the current page), waits for
4154
- * the gate's throttle (`delayMs`), navigates, records the page's real URL in the
4155
- * scope, and checks the response against the recipe's block rule.
6215
+ * the gate's throttle (`delayMs`), navigates (again, after a pause, while it
6216
+ * fails in passing: `limits.retry`), records the page's real URL in the scope,
6217
+ * and checks the response against the recipe's block rule.
4156
6218
  *
4157
6219
  * @throws BlockedError when the response is a block.
4158
6220
  */
4159
6221
  async function navigate(step, page, scope, recipe, gate, events) {
4160
6222
  const target = renderText(step.url, path => scope.lookup(path));
4161
6223
  const url = new URL(target, scope.pageState?.url ?? page.url()).href;
4162
- await gate.throttle();
4163
- const response = await page.goto(url, {
4164
- waitUntil: step.waitUntil,
4165
- timeout: recipe.limits?.timeoutMs
6224
+ const rule = resolveRetryRule(recipe.limits?.retry);
6225
+ const response = await withTransportRetry(url, {
6226
+ run: () => page.goto(url, {
6227
+ waitUntil: step.waitUntil,
6228
+ timeout: recipe.limits?.timeoutMs
6229
+ }),
6230
+ problem: outcome => {
6231
+ if ('error' in outcome) return transientError(outcome.error);
6232
+ const status = outcome.value?.status();
6233
+ return status !== undefined && rule.statuses.includes(status) ? {
6234
+ reason: `HTTP ${status}`,
6235
+ retryAfter: outcome.value?.headers()['retry-after']
6236
+ } : undefined;
6237
+ }
6238
+ }, {
6239
+ recipeId: recipe.id,
6240
+ gate,
6241
+ events,
6242
+ rule
4166
6243
  });
4167
6244
  scope.setPage({
4168
6245
  url: page.url()
@@ -4185,18 +6262,26 @@ async function navigate(step, page, scope, recipe, gate, events) {
4185
6262
  }
4186
6263
 
4187
6264
  const NEXT_LINK_TIMEOUT_MS = 2000;
4188
- /** Runs web-mode leaf steps on a browser page. */
6265
+ /** Steps after which a page may show a new captcha (`session.captcha`). */
6266
+ const CHALLENGING_STEPS = new Set(['click', 'press']);
6267
+ /**
6268
+ * Runs web-mode leaf steps on a browser page. With a captcha guard, a page a
6269
+ * navigation, click or key press leads to is checked for a challenge, solved
6270
+ * before the next step runs.
6271
+ */
4189
6272
  class WebStepRunner {
4190
6273
  session;
4191
6274
  recipe;
4192
6275
  events;
4193
6276
  gate;
6277
+ captcha;
4194
6278
  page;
4195
- constructor(session, recipe, events, gate = new RunGate(1, recipe.limits?.delayMs ?? 0)) {
6279
+ constructor(session, recipe, events, gate = new RunGate(1, recipe.limits?.delayMs ?? 0), captcha) {
4196
6280
  this.session = session;
4197
6281
  this.recipe = recipe;
4198
6282
  this.events = events;
4199
6283
  this.gate = gate;
6284
+ this.captcha = captcha;
4200
6285
  this.page = session.page;
4201
6286
  }
4202
6287
  /** Clicks and key presses can navigate; keep `page.url` honest after every leaf step. */
@@ -4206,11 +6291,29 @@ class WebStepRunner {
4206
6291
  url
4207
6292
  });
4208
6293
  }
6294
+ /** Navigates; a block page showing a captcha is solved under `onBlock.solve`, and a page reached is checked for one. */
6295
+ async visit(step, scope) {
6296
+ try {
6297
+ await navigate(step, this.page, scope, this.recipe, this.gate, this.events);
6298
+ } catch (error) {
6299
+ if (!(error instanceof BlockedError) || this.captcha?.solvesBlocks !== true) throw error;
6300
+ await this.captcha.solveBlock(this.page, error);
6301
+ return;
6302
+ }
6303
+ await this.captcha?.check(this.page);
6304
+ }
4209
6305
  async runLeaf(step, scope) {
4210
6306
  switch (step.type) {
4211
6307
  case 'goto':
4212
6308
  {
4213
- return navigate(step, this.page, scope, this.recipe, this.gate, this.events);
6309
+ await this.visit(step, scope);
6310
+ break;
6311
+ }
6312
+ case 'captcha':
6313
+ {
6314
+ if (this.captcha === undefined) throw new Error('a captcha step needs a crawler with captcha solvers');
6315
+ await this.captcha.step(this.page, step);
6316
+ break;
4214
6317
  }
4215
6318
  case 'click':
4216
6319
  {
@@ -4262,6 +6365,7 @@ class WebStepRunner {
4262
6365
  throw new Error(`"${step.type}" is an api step; this recipe runs in web mode`);
4263
6366
  }
4264
6367
  }
6368
+ if (CHALLENGING_STEPS.has(step.type)) await this.captcha?.check(this.page);
4265
6369
  this.trackUrl(scope);
4266
6370
  }
4267
6371
  async nextPage(next, scope) {
@@ -4269,9 +6373,10 @@ class WebStepRunner {
4269
6373
  if ('url' in next) {
4270
6374
  const target = renderText(next.url, path => scope.lookup(path));
4271
6375
  if (target === '') return null;
4272
- await navigate({
6376
+ await this.visit({
6377
+ type: 'goto',
4273
6378
  url: target
4274
- }, this.page, scope, this.recipe, this.gate, this.events);
6379
+ }, scope);
4275
6380
  return {
4276
6381
  kind: 'url',
4277
6382
  url: this.page.url()
@@ -4284,8 +6389,13 @@ class WebStepRunner {
4284
6389
  const link = this.page.locator(next.selector).first();
4285
6390
  if (!(await appears(link, NEXT_LINK_TIMEOUT_MS))) return null;
4286
6391
  const before = this.page.url();
4287
- await link.click();
4288
- await this.page.waitForLoadState();
6392
+ const release = await this.gate.request(before);
6393
+ try {
6394
+ await link.click();
6395
+ await this.page.waitForLoadState();
6396
+ } finally {
6397
+ release();
6398
+ }
4289
6399
  if (this.page.url() === before) await this.page.waitForTimeout(NEXT_LINK_TIMEOUT_MS / 4);
4290
6400
  this.events.emit({
4291
6401
  type: 'page:visit',
@@ -4293,11 +6403,29 @@ class WebStepRunner {
4293
6403
  url: this.page.url(),
4294
6404
  number: (scope.pageState?.number ?? 1) + 1
4295
6405
  });
6406
+ await this.captcha?.check(this.page);
4296
6407
  return {
4297
6408
  kind: 'url',
4298
6409
  url: this.page.url()
4299
6410
  };
4300
6411
  }
6412
+ /**
6413
+ * A runner on a new tab of the same context, for one parallel iteration:
6414
+ * it shares cookies, the gate and the captcha guard; disposing it closes the tab only.
6415
+ *
6416
+ * @returns The forked runner.
6417
+ */
6418
+ async fork() {
6419
+ const {
6420
+ context
6421
+ } = this.session;
6422
+ const page = await context.newPage();
6423
+ const viewport = this.recipe.session?.viewport;
6424
+ if (viewport !== undefined) await page.setViewportSize(viewport);
6425
+ return new WebStepRunner(new BrowserSession(context, page, async () => {
6426
+ await page.close();
6427
+ }), this.recipe, this.events, this.gate, this.captcha);
6428
+ }
4301
6429
  async elements(selector) {
4302
6430
  return snapshotElements(selector, this.page);
4303
6431
  }
@@ -4334,15 +6462,30 @@ function accessOptions(lease, headers) {
4334
6462
  * The bootstrap runs through the same access lease as the crawl that follows,
4335
6463
  * so a login and the requests that use its cookies come from one IP.
4336
6464
  *
6465
+ * With `session.browserProfile`, the bootstrap runs in that profile, and
6466
+ * without a bootstrap the profile's own cookies and storage are the state: an
6467
+ * api recipe picks up a login a browser left in the profile.
6468
+ *
4337
6469
  * @param recipe - The input recipe.
4338
6470
  * @param deps - Browser, hooks, events.
4339
6471
  * @param lease - The recipe run's access; direct when omitted.
6472
+ * @param captcha - Solves the bootstrap's captchas (a login form's).
6473
+ * @param owner - The recipe run, which a browser profile is held by.
4340
6474
  * @returns The state, or `undefined` when the recipe declares none.
4341
6475
  */
4342
- async function resolveStorageState(recipe, deps, lease) {
6476
+ async function resolveStorageState(recipe, deps, lease, captcha, owner = {}) {
4343
6477
  const saved = await readSavedState(recipe, deps);
6478
+ if (saved !== undefined) return saved;
4344
6479
  const session = recipe.session;
4345
- if (saved !== undefined || session?.bootstrap === undefined) return saved;
6480
+ if (session?.browserProfile !== undefined) {
6481
+ const browserSession = await openBrowserProfile(recipe, deps, lease, owner);
6482
+ try {
6483
+ return session.bootstrap === undefined ? await browserSession.storageState() : await runBootstrap(recipe, browserSession, deps, captcha);
6484
+ } finally {
6485
+ await browserSession.close();
6486
+ }
6487
+ }
6488
+ if (session?.bootstrap === undefined) return undefined;
4346
6489
  const browser = await deps.browser();
4347
6490
  const browserSession = await browser.newSession({
4348
6491
  cookies: session.cookies,
@@ -4351,11 +6494,32 @@ async function resolveStorageState(recipe, deps, lease) {
4351
6494
  ...accessOptions(lease, session.headers)
4352
6495
  });
4353
6496
  try {
4354
- return await runBootstrap(recipe, browserSession, deps);
6497
+ return await runBootstrap(recipe, browserSession, deps, captcha);
4355
6498
  } finally {
4356
6499
  await browserSession.close();
4357
6500
  }
4358
6501
  }
6502
+ /**
6503
+ * Opens the recipe's `session.browserProfile` with its session options and
6504
+ * the lease's proxy.
6505
+ *
6506
+ * @param recipe - A recipe with `session.browserProfile`.
6507
+ * @param deps - For `profiles`.
6508
+ * @param lease - The access lease.
6509
+ * @param owner - The recipe run.
6510
+ * @returns The session in the profile.
6511
+ */
6512
+ async function openBrowserProfile(recipe, deps, lease, owner) {
6513
+ const session = recipe.session;
6514
+ const name = session?.browserProfile ?? '';
6515
+ if (deps.profiles === undefined) throw new Error(`recipe "${recipe.id}" uses browser profile "${name}", but this crawler has no profiles directory (CrawlOptions.profilesDir)`);
6516
+ return deps.profiles.open(name, {
6517
+ cookies: session?.cookies,
6518
+ userAgent: session?.userAgent,
6519
+ viewport: session?.viewport,
6520
+ ...accessOptions(lease, session?.headers)
6521
+ }, owner);
6522
+ }
4359
6523
  /**
4360
6524
  * The storage state saved by an earlier bootstrap (`session.storageStatePath`), if the recipe names one.
4361
6525
  *
@@ -4376,15 +6540,16 @@ async function readSavedState(recipe, deps) {
4376
6540
  * @param recipe - An input recipe with `session.bootstrap`.
4377
6541
  * @param browserSession - Where the steps run.
4378
6542
  * @param deps - Hooks, events, `storageStateDir`.
6543
+ * @param captcha - Solves the bootstrap's captchas.
4379
6544
  * @returns The kept state.
4380
6545
  */
4381
- async function runBootstrap(recipe, browserSession, deps) {
6546
+ async function runBootstrap(recipe, browserSession, deps, captcha) {
4382
6547
  const bootstrap = recipe.session?.bootstrap;
4383
6548
  if (bootstrap === undefined) return {
4384
6549
  cookies: [],
4385
6550
  origins: []
4386
6551
  };
4387
- const runner = new WebStepRunner(browserSession, recipe, deps.events);
6552
+ const runner = new WebStepRunner(browserSession, recipe, deps.events, new RunGate(1, recipe.limits?.delayMs ?? 0, deps.hosts), captcha);
4388
6553
  const scope = new ExtractionScope();
4389
6554
  scope.set('vars', recipe.vars ?? {});
4390
6555
  scope.set('start', {
@@ -4489,6 +6654,66 @@ class RotatingRunner {
4489
6654
  throw error;
4490
6655
  }
4491
6656
  }
6657
+ /**
6658
+ * A runner for one parallel iteration, forked from whichever runner is
6659
+ * current when it runs a step: after a rotation it forks again from the new
6660
+ * one, since the old context is gone (or going). Blocks are noted and
6661
+ * rotated like the main runner's.
6662
+ *
6663
+ * @returns The iteration's runner.
6664
+ */
6665
+ async fork() {
6666
+ // `own`: a tab this iteration opened and must close; an api runner is shared and never disposed here.
6667
+ let forked;
6668
+ const disposeForked = async () => {
6669
+ if (forked?.own === true) await disposeQuietly(forked.runner);
6670
+ forked = undefined;
6671
+ };
6672
+ const current = async () => {
6673
+ if (forked?.generation === this.generation) return forked.runner;
6674
+ await disposeForked();
6675
+ const inner = this.inner;
6676
+ forked = inner.fork === undefined ? {
6677
+ generation: this.generation,
6678
+ runner: inner,
6679
+ own: false
6680
+ } : {
6681
+ generation: this.generation,
6682
+ runner: await inner.fork(),
6683
+ own: true
6684
+ };
6685
+ return forked.runner;
6686
+ };
6687
+ return {
6688
+ runLeaf: async (step, scope) => {
6689
+ const generation = this.generation;
6690
+ try {
6691
+ const runner = await current();
6692
+ await runner.runLeaf(step, scope);
6693
+ } catch (error) {
6694
+ this.note(error, generation);
6695
+ throw error;
6696
+ }
6697
+ },
6698
+ nextPage: async (next, scope) => {
6699
+ const generation = this.generation;
6700
+ try {
6701
+ const runner = await current();
6702
+ return await runner.nextPage(next, scope);
6703
+ } catch (error) {
6704
+ this.note(error, generation);
6705
+ throw error;
6706
+ }
6707
+ },
6708
+ elements: async (selector, scope) => {
6709
+ const runner = await current();
6710
+ if (runner.elements === undefined) throw new Error('forEach over selector iterates live elements and needs a browser; this recipe runs in api mode');
6711
+ return runner.elements(selector, scope);
6712
+ },
6713
+ rotate: error => this.rotate(error),
6714
+ dispose: disposeForked
6715
+ };
6716
+ }
4492
6717
  async elements(selector, scope) {
4493
6718
  if (this.inner.elements === undefined) throw new Error('forEach over selector iterates live elements and needs a browser; this recipe runs in api mode');
4494
6719
  return this.inner.elements(selector, scope);
@@ -4537,12 +6762,19 @@ class RotatingRunner {
4537
6762
  * the sink sees one record at a time and `maxRecords` is exact: once reached,
4538
6763
  * every later emit returns `stop` before mapping.
4539
6764
  *
4540
- * @param input - The input recipe.
6765
+ * @param recipe - The input recipe.
4541
6766
  * @param output - The output recipe it feeds.
4542
6767
  * @param deps - Shared browser, hooks, events, sink and de-duplication.
4543
6768
  * @returns What happened.
4544
6769
  */
4545
- async function runInputRecipe(input, output, deps) {
6770
+ async function runInputRecipe(recipe, output, deps) {
6771
+ const input = {
6772
+ ...recipe,
6773
+ limits: {
6774
+ ...recipe.limits,
6775
+ retry: resolveRetryRule(recipe.limits?.retry, deps.retry)
6776
+ }
6777
+ };
4546
6778
  const started = Date.now();
4547
6779
  const report = {
4548
6780
  recipeId: input.id,
@@ -4555,29 +6787,44 @@ async function runInputRecipe(input, output, deps) {
4555
6787
  pages: 0,
4556
6788
  durationMs: 0
4557
6789
  };
6790
+ const captchas = {
6791
+ detected: 0,
6792
+ solved: 0,
6793
+ failed: 0
6794
+ };
4558
6795
  const limits = input.limits ?? {};
4559
- // A web recipe drives one page, so only api mode runs iterations in parallel.
4560
- const gate = new RunGate(input.mode === 'web' ? 1 : limits.concurrency ?? 1, limits.delayMs ?? 0);
6796
+ // Parallel iterations: requests in api mode, tabs of the recipe's context in web mode.
6797
+ const gate = new RunGate(limits.concurrency ?? 1, limits.delayMs ?? 0, deps.hosts);
4561
6798
  let stopped = false;
4562
6799
  let chain = Promise.resolve();
4563
6800
  const unsubscribe = deps.events.subscribe(event => {
4564
6801
  if (event.type === 'page:visit' && event.recipeId === input.id) report.pages += 1;
4565
6802
  if (event.type === 'step:skip' && event.recipeId === input.id) report.stepsSkipped += 1;
6803
+ if (event.type === 'captcha:detected' && event.recipeId === input.id) captchas.detected += 1;
6804
+ if (event.type === 'captcha:solved' && event.recipeId === input.id) captchas.solved += 1;
6805
+ if (event.type === 'captcha:failed' && event.recipeId === input.id) captchas.failed += 1;
4566
6806
  });
4567
6807
  deps.events.emit({
4568
6808
  type: 'recipe:start',
4569
6809
  recipeId: input.id,
4570
6810
  mode: input.mode
4571
6811
  });
4572
- deps.dedupe.startRecipe();
6812
+ const dedupe = deps.dedupe.forRecipe();
4573
6813
  let runner;
4574
6814
  try {
4575
6815
  const onBlock = input.session?.onBlock;
6816
+ const solvers = deps.captchaSolvers ?? new CaptchaSolverRegistry();
6817
+ for (const name of captchaSolverNames(input)) solvers.resolve(name);
6818
+ const context = {
6819
+ gate,
6820
+ solvers,
6821
+ budget: new CaptchaBudget(input.session?.captcha?.maxSolves ?? DEFAULT_MAX_SOLVES)
6822
+ };
4576
6823
  runner = await RotatingRunner.open({
4577
6824
  recipe: input,
4578
6825
  events: deps.events,
4579
6826
  maxRotations: onBlock?.rotate === true ? onBlock.attempts ?? 2 : 0,
4580
- open: attempt => openLeased(input, deps, gate, attempt)
6827
+ open: attempt => openLeased(input, deps, context, attempt)
4581
6828
  });
4582
6829
  for (const point of input.start) {
4583
6830
  const scope = new ExtractionScope();
@@ -4612,6 +6859,7 @@ async function runInputRecipe(input, output, deps) {
4612
6859
  } finally {
4613
6860
  await runner?.dispose();
4614
6861
  unsubscribe();
6862
+ if (captchas.detected > 0) report.captchas = captchas;
4615
6863
  report.durationMs = Date.now() - started;
4616
6864
  deps.events.emit({
4617
6865
  type: 'recipe:finish',
@@ -4669,7 +6917,7 @@ async function runInputRecipe(input, output, deps) {
4669
6917
  url,
4670
6918
  key: record.key
4671
6919
  });
4672
- } else if (deps.dedupe.isDuplicate(record)) {
6920
+ } else if (dedupe.isDuplicate(record)) {
4673
6921
  report.duplicates += 1;
4674
6922
  deps.events.emit({
4675
6923
  type: 'record:duplicate',
@@ -4737,11 +6985,11 @@ async function leaseAccess(input, deps, attempt) {
4737
6985
  return lease;
4738
6986
  }
4739
6987
  /** A lease and a runner opened on it; the lease is released again when opening fails. */
4740
- async function openLeased(input, deps, gate, attempt) {
6988
+ async function openLeased(input, deps, context, attempt) {
4741
6989
  const lease = await leaseAccess(input, deps, attempt);
4742
6990
  try {
4743
6991
  return {
4744
- runner: await openRunner(input, deps, gate, lease),
6992
+ runner: await openRunner(input, deps, context, lease),
4745
6993
  lease
4746
6994
  };
4747
6995
  } catch (error) {
@@ -4749,9 +6997,20 @@ async function openLeased(input, deps, gate, attempt) {
4749
6997
  throw error;
4750
6998
  }
4751
6999
  }
4752
- async function openRunner(input, deps, gate, lease) {
4753
- if (lease.cdp !== undefined) return openRemoteRunner(input, deps, gate, lease, lease.cdp);
4754
- const storageState = await resolveStorageState(input, deps, lease);
7000
+ async function openRunner(input, deps, context, lease) {
7001
+ const {
7002
+ gate
7003
+ } = context;
7004
+ const captcha = new CaptchaGuard({
7005
+ recipe: input,
7006
+ events: deps.events,
7007
+ solvers: context.solvers,
7008
+ budget: context.budget,
7009
+ lease
7010
+ });
7011
+ if (lease.cdp !== undefined) return openRemoteRunner(input, deps, context, lease, lease.cdp);
7012
+ if (input.mode === 'web' && input.session?.browserProfile !== undefined) return openProfileRunner(input, deps, context, lease, captcha);
7013
+ const storageState = await resolveStorageState(input, deps, lease, captcha, context);
4755
7014
  const session = input.session;
4756
7015
  const access = accessOptions(lease, session?.headers);
4757
7016
  if (input.mode === 'web') {
@@ -4763,7 +7022,7 @@ async function openRunner(input, deps, gate, lease) {
4763
7022
  viewport: session?.viewport,
4764
7023
  ...access
4765
7024
  });
4766
- return new WebStepRunner(browserSession, input, deps.events, gate);
7025
+ return new WebStepRunner(browserSession, input, deps.events, gate, captcha);
4767
7026
  }
4768
7027
  const client = await HttpClient.open({
4769
7028
  storageState,
@@ -4775,13 +7034,43 @@ async function openRunner(input, deps, gate, lease) {
4775
7034
  });
4776
7035
  return new ApiStepRunner(client, input, deps.events, gate);
4777
7036
  }
7037
+ /**
7038
+ * A web runner in a persistent browser profile. The bootstrap runs in the
7039
+ * same browser as the crawl, and what both leave behind (cookies, storage)
7040
+ * stays in the profile for the next run.
7041
+ */
7042
+ async function openProfileRunner(input, deps, context, lease, captcha) {
7043
+ const saved = await readSavedState(input, deps);
7044
+ const browserSession = await openBrowserProfile(saved === undefined ? input : {
7045
+ ...input,
7046
+ session: {
7047
+ ...input.session,
7048
+ cookies: [...saved.cookies, ...(input.session?.cookies ?? [])]
7049
+ }
7050
+ }, deps, lease, context);
7051
+ try {
7052
+ if (saved === undefined && input.session?.bootstrap !== undefined) await runBootstrap(input, browserSession, deps, captcha);
7053
+ } catch (error) {
7054
+ await browserSession.close();
7055
+ throw error;
7056
+ }
7057
+ return new WebStepRunner(browserSession, input, deps.events, context.gate, captcha);
7058
+ }
4778
7059
  /**
4779
7060
  * A web runner in a remote browser. The bootstrap runs in the same remote
4780
7061
  * session as the crawl: providers tie the IP and fingerprint to the
4781
7062
  * connection, so a login in one connection would not carry to another.
4782
7063
  */
4783
- async function openRemoteRunner(input, deps, gate, lease, cdp) {
7064
+ async function openRemoteRunner(input, deps, context, lease, cdp) {
7065
+ const captcha = new CaptchaGuard({
7066
+ recipe: input,
7067
+ events: deps.events,
7068
+ solvers: context.solvers,
7069
+ budget: context.budget,
7070
+ lease
7071
+ });
4784
7072
  if (input.mode === 'api') throw new AccessConfigError(`recipe "${input.id}" runs in api mode, but access profile "${lease.profile}" is a remote browser; api recipes need a proxy profile`);
7073
+ if (input.session?.browserProfile !== undefined) throw new AccessConfigError(`recipe "${input.id}" uses browser profile "${input.session.browserProfile}", which needs a local browser, but access profile "${lease.profile}" is a remote browser`);
4785
7074
  const session = input.session;
4786
7075
  const storageState = await readSavedState(input, deps);
4787
7076
  const browserSession = await BrowserClient.connectOverCDP(cdp, {
@@ -4791,36 +7080,51 @@ async function openRemoteRunner(input, deps, gate, lease, cdp) {
4791
7080
  ...accessOptions(lease, session?.headers)
4792
7081
  }, input.limits?.timeoutMs);
4793
7082
  try {
4794
- if (storageState === undefined && session?.bootstrap !== undefined) await runBootstrap(input, browserSession, deps);
7083
+ if (storageState === undefined && session?.bootstrap !== undefined) await runBootstrap(input, browserSession, deps, captcha);
4795
7084
  } catch (error) {
4796
7085
  await browserSession.close();
4797
7086
  throw error;
4798
7087
  }
4799
- return new WebStepRunner(browserSession, input, deps.events, gate);
7088
+ return new WebStepRunner(browserSession, input, deps.events, context.gate, captcha);
4800
7089
  }
4801
7090
 
4802
7091
  /**
4803
- * Runs every input recipe of a set, one after another, into one sink.
7092
+ * Runs every input recipe of a set into one sink, `parallel` at a time
7093
+ * (default one after another). Reports come back in the set's order whatever
7094
+ * order the recipes finish in. Under `onRecipeError: 'stop'`, a failed recipe
7095
+ * stops the ones not started yet; those already running finish.
4804
7096
  *
4805
7097
  * @param set - The bound recipes.
4806
7098
  * @param deps - Shared browser, hooks, events, sink and de-duplication.
4807
7099
  * @param onRecipeError - Whether a failed recipe stops the run.
7100
+ * @param parallel - How many input recipes run at once.
4808
7101
  * @returns The report.
4809
7102
  */
4810
- async function runCrawl(set, deps, onRecipeError) {
7103
+ async function runCrawl(set, deps, onRecipeError, parallel = 1) {
4811
7104
  const started = Date.now();
4812
7105
  await deps.sink.open(set.output);
4813
- const recipes = [];
7106
+ const reports = [];
4814
7107
  let sink;
4815
7108
  try {
4816
- for (const input of set.inputs) {
4817
- const report = await runInputRecipe(input, set.output, deps);
4818
- recipes.push(report);
4819
- if (onRecipeError === 'stop' && report.error !== undefined) break;
4820
- }
7109
+ let next = 0;
7110
+ let stopped = false;
7111
+ const lane = async () => {
7112
+ while (!stopped && next < set.inputs.length) {
7113
+ const index = next;
7114
+ next += 1;
7115
+ const report = await runInputRecipe(set.inputs[index], set.output, deps);
7116
+ reports[index] = report;
7117
+ if (onRecipeError === 'stop' && report.error !== undefined) stopped = true;
7118
+ }
7119
+ };
7120
+ const lanes = Math.max(1, Math.min(parallel, set.inputs.length));
7121
+ await Promise.all(Array.from({
7122
+ length: lanes
7123
+ }, lane));
4821
7124
  } finally {
4822
7125
  sink = await deps.sink.close();
4823
7126
  }
7127
+ const recipes = reports.filter(report => report !== undefined);
4824
7128
  return {
4825
7129
  outputId: set.output.id,
4826
7130
  recipes,
@@ -4834,15 +7138,19 @@ async function runCrawl(set, deps, onRecipeError) {
4834
7138
  * Creates a crawler. The browser is launched lazily, on the first recipe or
4835
7139
  * bootstrap that needs it, and shared by every run until `close`.
4836
7140
  *
4837
- * @param options - Hooks, sink, events, browser settings, access, policies.
7141
+ * @param options - Hooks, sink, events, browser settings, access, captcha solvers, policies.
4838
7142
  * @returns The crawler.
4839
7143
  * @throws AccessConfigError when the access config cannot work.
7144
+ * @throws Error when two captcha solvers share a name.
4840
7145
  */
4841
7146
  function createCrawler(options = {}) {
4842
7147
  const sink = options.sink ?? memorySink();
4843
7148
  if (options.resume === true && sink.has === undefined) throw new Error('resume needs a sink that can tell which keys it has (jsonLinesSink with append, memorySink, or a custom sink with `has`)');
4844
7149
  const access = new AccessBroker(options.access, options.accessPlugins);
4845
7150
  const hooks = new HookRegistry(options.hooks);
7151
+ const captchaSolvers = new CaptchaSolverRegistry(options.captchaSolvers);
7152
+ const hosts = new HostThrottle(options.throttle ?? options.access?.throttle);
7153
+ const profiles = new BrowserProfiles(options.profilesDir ?? resolve$1(options.storageStateDir ?? '.', '.opencraw', 'profiles'), options.browser);
4846
7154
  const events = new EventBus(options.onEvent);
4847
7155
  let browser;
4848
7156
  const launch = () => {
@@ -4860,8 +7168,12 @@ function createCrawler(options = {}) {
4860
7168
  resume: options.resume === true,
4861
7169
  debug: options.debug === true,
4862
7170
  access,
7171
+ captchaSolvers,
7172
+ hosts,
7173
+ profiles,
7174
+ retry: options.retry,
4863
7175
  ignoreHTTPSErrors: options.browser?.ignoreHTTPSErrors
4864
- }, options.onRecipeError ?? 'continue'),
7176
+ }, options.onRecipeError ?? 'continue', options.parallel ?? 1),
4865
7177
  async close() {
4866
7178
  const launched = browser;
4867
7179
  browser = undefined;
@@ -4877,7 +7189,8 @@ const CRAWL_MODES = ['web', 'api'];
4877
7189
  const SELECTOR_KINDS = ['css', 'xpath', 'jsonpath', 'regex', 'table'];
4878
7190
  /** `take` also accepts `attr:<name>`, which is validated by pattern rather than listed. */
4879
7191
  const TAKE_KINDS = ['text', 'html', 'value', 'json'];
4880
- const BODY_KINDS = ['json', 'html', 'text', 'pdf'];
7192
+ const BODY_KINDS = ['json', 'jsonl', 'html', 'text', 'pdf', 'csv', 'xlsx', 'pptx', 'yaml', 'markdown'];
7193
+ const YAML_SCALARS = ['typed', 'text'];
4881
7194
  /** How a PDF table aligns a row's values against a cell wrapped over several lines. */
4882
7195
  const TABLE_ALIGNS = ['auto', 'top', 'center', 'bottom'];
4883
7196
  const FIELD_TYPES = ['string', 'number', 'integer', 'boolean', 'date', 'datetime', 'currency', 'url', 'enum', 'array', 'object', 'json'];
@@ -4889,7 +7202,7 @@ const KEEP_KINDS = ['cookies', 'localStorage'];
4889
7202
  const WAIT_UNTIL = ['load', 'domcontentloaded', 'networkidle', 'commit'];
4890
7203
  const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD'];
4891
7204
  /** Steps that only make sense with a live browser page. */
4892
- const WEB_ONLY_STEPS = ['goto', 'click', 'fill', 'press', 'select', 'scroll', 'wait', 'evaluate', 'screenshot'];
7205
+ const WEB_ONLY_STEPS = ['goto', 'click', 'fill', 'press', 'select', 'scroll', 'wait', 'evaluate', 'screenshot', 'captcha'];
4893
7206
  /** Steps that only make sense against an HTTP request context. */
4894
7207
  const API_ONLY_STEPS = ['request'];
4895
7208
 
@@ -5054,9 +7367,18 @@ const requestStep = z.strictObject({
5054
7367
  query: stringMap.optional(),
5055
7368
  headers: stringMap.optional(),
5056
7369
  body: z.unknown().optional(),
5057
- as: z.enum(BODY_KINDS).optional()
7370
+ as: z.enum(BODY_KINDS).optional(),
7371
+ encoding: z.string().min(1).optional(),
7372
+ delimiter: z.string().length(1).optional(),
7373
+ scalars: z.enum(YAML_SCALARS).optional()
7374
+ }).refine(step => step.delimiter === undefined || step.as === undefined || step.as === 'csv', {
7375
+ message: '"delimiter" reads CSV only: drop it or set "as": "csv"',
7376
+ path: ['delimiter']
7377
+ }).refine(step => step.scalars === undefined || step.as === undefined || step.as === 'yaml', {
7378
+ message: '"scalars" reads YAML only: drop it or set "as": "yaml"',
7379
+ path: ['scalars']
5058
7380
  });
5059
- const tableOnly = ['columns', 'until', 'align'];
7381
+ const tableOnly = ['columns', 'until', 'align', 'sheet', 'headerRows', 'fillDown', 'includeHidden', 'slide', 'shapes'];
5060
7382
  const extractStep = z.strictObject({
5061
7383
  ...base,
5062
7384
  type: z.literal('extract'),
@@ -5067,7 +7389,13 @@ const extractStep = z.strictObject({
5067
7389
  from: stepId.optional(),
5068
7390
  columns: stringMap.optional(),
5069
7391
  until: z.string().min(1).optional(),
5070
- align: z.enum(TABLE_ALIGNS).optional()
7392
+ align: z.enum(TABLE_ALIGNS).optional(),
7393
+ sheet: z.string().min(1).optional(),
7394
+ headerRows: z.int().min(1).optional(),
7395
+ fillDown: z.array(z.string().min(1)).min(1).optional(),
7396
+ includeHidden: z.boolean().optional(),
7397
+ slide: z.string().min(1).optional(),
7398
+ shapes: z.boolean().optional()
5071
7399
  }).check(context => {
5072
7400
  if (context.value.kind === 'table') return;
5073
7401
  for (const key of tableOnly) {
@@ -5101,6 +7429,19 @@ const hookStep = z.strictObject({
5101
7429
  name: z.string().min(1),
5102
7430
  args: z.record(z.string(), z.unknown()).optional()
5103
7431
  });
7432
+ const captchaCheckSchema = z.strictObject({
7433
+ gone: z.boolean().optional(),
7434
+ selector: z.string().min(1).optional()
7435
+ });
7436
+ const captchaStep = z.strictObject({
7437
+ ...base,
7438
+ type: z.literal('captcha'),
7439
+ solver: z.string().min(1).optional(),
7440
+ selector: z.string().min(1).optional(),
7441
+ verify: captchaCheckSchema.optional(),
7442
+ attempts: z.int().min(1).max(10).optional(),
7443
+ timeoutMs: z.int().min(1000).optional()
7444
+ });
5104
7445
  const emitFlag = z.union([z.literal(true), z.strictObject({
5105
7446
  output: z.string().min(1)
5106
7447
  })]);
@@ -5129,7 +7470,7 @@ const paginateStep = z.strictObject({
5129
7470
  maxPages: z.int().min(1).optional(),
5130
7471
  steps
5131
7472
  });
5132
- const stepSchema = z.discriminatedUnion('type', [gotoStep, clickStep, fillStep, pressStep, selectStep, scrollStep, waitStep, evaluateStep, screenshotStep, requestStep, extractStep, assignStep, collectStep, emitStep, hookStep, forEachStep, ifStep, paginateStep]);
7473
+ const stepSchema = z.discriminatedUnion('type', [gotoStep, clickStep, fillStep, pressStep, selectStep, scrollStep, waitStep, evaluateStep, screenshotStep, requestStep, extractStep, assignStep, collectStep, emitStep, hookStep, forEachStep, ifStep, paginateStep, captchaStep]);
5133
7474
 
5134
7475
  const args = z.record(z.string(), z.unknown());
5135
7476
  const stringList = z.array(z.string());
@@ -5277,9 +7618,20 @@ const blockRuleSchema = z.strictObject({
5277
7618
  text: regexSource.optional()
5278
7619
  });
5279
7620
  const blockRotationSchema = z.strictObject({
5280
- rotate: z.boolean(),
7621
+ rotate: z.boolean().optional(),
7622
+ solve: z.boolean().optional(),
5281
7623
  attempts: z.int().min(1).max(10).optional()
5282
7624
  });
7625
+ const captchaSettingsSchema = z.strictObject({
7626
+ solver: z.string().min(1),
7627
+ detect: z.strictObject({
7628
+ selector: z.string().min(1)
7629
+ }).optional(),
7630
+ verify: captchaCheckSchema.optional(),
7631
+ attempts: z.int().min(1).max(10).optional(),
7632
+ timeoutMs: z.int().min(1000).optional(),
7633
+ maxSolves: z.int().nonnegative().optional()
7634
+ });
5283
7635
  const sessionSpecSchema = z.strictObject({
5284
7636
  headers: z.record(z.string(), z.string()).optional(),
5285
7637
  cookies: z.array(cookieSchema).optional(),
@@ -5292,13 +7644,22 @@ const sessionSpecSchema = z.strictObject({
5292
7644
  bootstrap: bootstrapSchema.optional(),
5293
7645
  access: sessionAccessSchema.optional(),
5294
7646
  blockedWhen: blockRuleSchema.optional(),
5295
- onBlock: blockRotationSchema.optional()
7647
+ onBlock: blockRotationSchema.optional(),
7648
+ captcha: captchaSettingsSchema.optional(),
7649
+ browserProfile: z.string().regex(/^[\w-]+$/, 'a browser profile name is letters, digits, hyphens and underscores').optional()
7650
+ });
7651
+ const retryRuleSchema = z.strictObject({
7652
+ attempts: z.int().min(1).max(10).optional(),
7653
+ backoffMs: z.int().nonnegative().optional(),
7654
+ maxDelayMs: z.int().nonnegative().optional(),
7655
+ statuses: z.array(z.int().min(400).max(599)).optional()
5296
7656
  });
5297
7657
  const limitsSchema = z.strictObject({
5298
7658
  maxRecords: z.int().positive().optional(),
5299
7659
  delayMs: z.int().nonnegative().optional(),
5300
7660
  timeoutMs: z.int().positive().optional(),
5301
- concurrency: z.int().min(1).max(64).optional()
7661
+ concurrency: z.int().min(1).max(64).optional(),
7662
+ retry: retryRuleSchema.optional()
5302
7663
  });
5303
7664
  const inputRecipeSchema = z.strictObject({
5304
7665
  $schema: z.string().optional(),
@@ -5595,7 +7956,9 @@ const API_ONLY = new Set(API_ONLY_STEPS);
5595
7956
  * - web-only steps appear only in web recipes or inside a bootstrap, api-only
5596
7957
  * steps only in api recipes, and `next.selector` only in web mode;
5597
7958
  * - exactly one emitting construct exists on any path (the two branches of an
5598
- * `if` are separate paths).
7959
+ * `if` are separate paths);
7960
+ * - a `captcha` step, and `onBlock.solve`, have a solver: their own or
7961
+ * `session.captcha.solver`.
5599
7962
  *
5600
7963
  * @param input - A parsed input recipe.
5601
7964
  * @param output - The parsed output recipe it names.
@@ -5613,17 +7976,22 @@ function validateBinding(input, output) {
5613
7976
  const known = new Set(RESERVED);
5614
7977
  const varNames = [input.vars ?? {}, ...input.start.map(point => point.vars ?? {})].flatMap(record => Object.keys(record));
5615
7978
  for (const name of varNames) known.add(name);
7979
+ const solver = input.session?.captcha !== undefined;
5616
7980
  walkSteps(input.steps, 'steps', input.mode, known, report, {
5617
7981
  emitting: false,
5618
- ids: new Set()
7982
+ ids: new Set(),
7983
+ solver
5619
7984
  });
5620
7985
  if (input.session?.bootstrap !== undefined) {
5621
7986
  walkSteps(input.session.bootstrap.steps, 'session.bootstrap.steps', 'web', new Set(RESERVED), report, {
5622
7987
  emitting: false,
5623
7988
  ids: new Set(),
5624
- bootstrap: true
7989
+ bootstrap: true,
7990
+ solver
5625
7991
  });
5626
7992
  }
7993
+ if (!solver && input.session?.onBlock?.solve === true) report('session.onBlock.solve', 'solving a block needs a solver: add session.captcha');
7994
+ if (input.mode === 'api' && input.session?.onBlock?.solve === true) report('session.onBlock.solve', 'captchas are solved on a live page; this recipe runs in api mode (solve them in session.bootstrap)');
5627
7995
  for (const [target, rule] of Object.entries(input.mapping)) {
5628
7996
  const field = fieldAt(output.fields, target);
5629
7997
  if (field === undefined) {
@@ -5655,7 +8023,11 @@ function walkStep(step, at, mode, known, report, state) {
5655
8023
  known.add(step.id);
5656
8024
  }
5657
8025
  if (step.type === 'extract' && step.from !== undefined && !known.has(step.from)) report(`${at}.from`, `"${step.from}" is not a known id`);
5658
- if (mode === 'web' && state.bootstrap !== true && step.type === 'extract' && step.kind === 'table' && step.from === undefined) report(at, 'a "table" extract reads a PDF, which a web page is not: fetch the PDF with a request step in an api recipe, or extract "from" a PDF bound earlier');
8026
+ if (mode === 'web' && state.bootstrap !== true && step.type === 'extract' && step.kind === 'table' && step.from === undefined) {
8027
+ const foreign = ['sheet', 'slide', 'shapes', 'align', 'includeHidden'].filter(option => step[option] !== undefined);
8028
+ if (foreign.length > 0) report(at, `a "table" extract on a web page reads its HTML tables; ${foreign.map(option => `"${option}"`).join(', ')} belong to PDFs, workbooks or decks (fetch one with a request step in an api recipe)`);
8029
+ }
8030
+ if (step.type === 'captcha' && step.solver === undefined && !state.solver) report(at, 'a captcha step needs a solver: name one ("solver") or add session.captcha');
5659
8031
  if (step.type === 'collect' && !known.has(step.into)) report(`${at}.into`, `"${step.into}" is not a known id: set it to [] before the loop that collects into it`);
5660
8032
  const nested = () => ({
5661
8033
  ...state,
@@ -5818,5 +8190,5 @@ function isSameOutput(document, output) {
5818
8190
  return recipeKindOf(document.content) === 'output' && document.content.id === output.id;
5819
8191
  }
5820
8192
 
5821
- export { ACCESS_PRESETS, AccessBroker, AccessConfigError, BrowserClient, BrowserSession, HttpClient, HttpError, MappingFailedError, PdfReadError, RecipeBindingError, RecipeSet, RecipeValidationError, RecordRejectedError, StepFailure, TransformError, UnknownHookError, accessConfigJsonSchema, accessConfigSchema, bindRecipeSet, createCrawler, findTables, inputRecipeJsonSchema, inputRecipeSchema, jsonLinesSink, loadAccessConfig, loadRecipeSet, loadRecipes, memorySink, outputRecipeJsonSchema, outputRecipeSchema, parseInputRecipe, parseOutputRecipe, pdfText, readPdf, readRecipeSource, traceLine, tryParseJson, validateBinding };
8193
+ export { ACCESS_PRESETS, AccessBroker, AccessConfigError, BrowserClient, BrowserSession, CaptchaError, DEFAULT_CAPTCHA_SELECTOR, DEFAULT_RETRY_RULE, HostThrottle, HttpClient, HttpError, MappingFailedError, PdfReadError, RecipeBindingError, RecipeSet, RecipeValidationError, RecordRejectedError, StepFailure, TransformError, UnknownHookError, accessConfigJsonSchema, accessConfigSchema, bindRecipeSet, createCrawler, csvWorkbook, deckText, detectChallenge, detectDelimiter, fillDown, findDeckTables, findGridTables, findTables, htmlTableSheets, inputRecipeJsonSchema, inputRecipeSchema, isDeckDocument, isWorkbookDocument, jsonLinesSink, loadAccessConfig, loadRecipeSet, loadRecipes, memorySink, outputRecipeJsonSchema, outputRecipeSchema, parseCsv, parseInputRecipe, parseOutputRecipe, pdfText, readMarkdown, readPdf, readRecipeSource, readYaml, retryRuleSchema, throttleConfigSchema, traceLine, tryParseJson, validateBinding, workbookText };
5822
8194
  //# sourceMappingURL=index.esm.js.map