@opencraw/core 0.1.2 → 0.1.4

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 (195) hide show
  1. package/README.md +1 -1
  2. package/dist/access/access-broker.use-case.d.ts +43 -0
  3. package/dist/access/access-config.error.d.ts +5 -0
  4. package/dist/access/access-config.repository.d.ts +11 -0
  5. package/dist/access/access-json-schema.mapper.d.ts +8 -0
  6. package/dist/access/access-plugin.contract.d.ts +48 -0
  7. package/dist/access/access-preset.store.d.ts +19 -0
  8. package/dist/access/access-profile.contract.d.ts +95 -0
  9. package/dist/access/index.d.ts +11 -0
  10. package/dist/access/render-access.mapper.d.ts +41 -0
  11. package/dist/access/session-id.algorithm.d.ts +8 -0
  12. package/dist/api-steps/extract-from-document.use-case.d.ts +41 -0
  13. package/dist/api-steps/index.d.ts +4 -0
  14. package/dist/api-steps/run-api-step.use-case.d.ts +20 -0
  15. package/dist/api-steps/send-request.use-case.d.ts +23 -0
  16. package/dist/browser-session/browser-profile.store.d.ts +52 -0
  17. package/dist/browser-session/browser-session.config.d.ts +21 -0
  18. package/dist/browser-session/browser.client.d.ts +75 -0
  19. package/dist/browser-session/index.d.ts +6 -0
  20. package/dist/captcha/captcha-budget.model.d.ts +21 -0
  21. package/dist/captcha/captcha-detection.client.d.ts +28 -0
  22. package/dist/captcha/captcha-guard.use-case.d.ts +64 -0
  23. package/dist/captcha/captcha-solver-registry.store.d.ts +19 -0
  24. package/dist/captcha/captcha-solver.contract.d.ts +47 -0
  25. package/dist/captcha/captcha.error.d.ts +13 -0
  26. package/dist/captcha/index.d.ts +10 -0
  27. package/dist/captcha/resolve-captcha.use-case.d.ts +40 -0
  28. package/dist/crawl-events/crawl-event.contract.d.ts +169 -0
  29. package/dist/crawl-events/event-bus.store.d.ts +10 -0
  30. package/dist/crawl-events/index.d.ts +4 -0
  31. package/dist/crawl-events/trace-line.mapper.d.ts +14 -0
  32. package/dist/crawl-execution/bootstrap-session.use-case.d.ts +79 -0
  33. package/dist/crawl-execution/crawl-options.config.d.ts +67 -0
  34. package/dist/crawl-execution/crawl-report.model.d.ts +33 -0
  35. package/dist/crawl-execution/create-crawler.use-case.d.ts +20 -0
  36. package/dist/crawl-execution/index.d.ts +8 -0
  37. package/dist/crawl-execution/rotating-runner.use-case.d.ts +66 -0
  38. package/dist/crawl-execution/run-crawl.use-case.d.ts +17 -0
  39. package/dist/crawl-execution/run-input-recipe.use-case.d.ts +51 -0
  40. package/dist/deck-document/deck-document.model.d.ts +58 -0
  41. package/dist/deck-document/deck-table.algorithm.d.ts +35 -0
  42. package/dist/deck-document/index.d.ts +6 -0
  43. package/dist/deck-document/read-pptx.client.d.ts +16 -0
  44. package/dist/docx-document/index.d.ts +2 -0
  45. package/dist/docx-document/read-docx-html.client.d.ts +23 -0
  46. package/dist/extraction-scope/extraction-scope.model.d.ts +77 -0
  47. package/dist/extraction-scope/index.d.ts +5 -0
  48. package/dist/extraction-scope/live-element.model.d.ts +28 -0
  49. package/dist/hooks/hook-registry.store.d.ts +16 -0
  50. package/dist/hooks/hook.contract.d.ts +17 -0
  51. package/dist/hooks/hook.error.d.ts +7 -0
  52. package/dist/hooks/index.d.ts +4 -0
  53. package/dist/http-session/http-response.contract.d.ts +58 -0
  54. package/dist/http-session/http.client.d.ts +42 -0
  55. package/dist/http-session/index.d.ts +5 -0
  56. package/dist/http-session/text-decoding.algorithm.d.ts +35 -0
  57. package/dist/index.esm.js +1410 -95
  58. package/dist/markdown-document/index.d.ts +3 -0
  59. package/dist/markdown-document/read-markdown.client.d.ts +39 -0
  60. package/dist/output-mapping/coerce-field.mapper.d.ts +19 -0
  61. package/dist/output-mapping/generated-field.mapper.d.ts +16 -0
  62. package/dist/output-mapping/index.d.ts +9 -0
  63. package/dist/output-mapping/map-record.use-case.d.ts +27 -0
  64. package/dist/output-mapping/mapping.error.d.ts +17 -0
  65. package/dist/output-mapping/missing-value.policy.d.ts +14 -0
  66. package/dist/output-mapping/output-field.validator.d.ts +10 -0
  67. package/dist/output-mapping/output-record.model.d.ts +20 -0
  68. package/dist/pdf-document/index.d.ts +7 -0
  69. package/dist/pdf-document/pdf-document.model.d.ts +51 -0
  70. package/dist/pdf-document/pdf-table.algorithm.d.ts +43 -0
  71. package/dist/pdf-document/read-pdf.client.d.ts +18 -0
  72. package/dist/pdf-document/row-assembly.algorithm.d.ts +26 -0
  73. package/dist/recipe-loading/index.d.ts +9 -0
  74. package/dist/recipe-loading/load-recipe-set.use-case.d.ts +45 -0
  75. package/dist/recipe-loading/read-recipe-source.use-case.d.ts +14 -0
  76. package/dist/recipe-loading/recipe-binding.error.d.ts +14 -0
  77. package/dist/recipe-loading/recipe-binding.validator.d.ts +34 -0
  78. package/dist/recipe-loading/recipe-file.repository.d.ts +11 -0
  79. package/dist/recipe-loading/recipe-set.model.d.ts +12 -0
  80. package/dist/recipe-loading/recipe-source.contract.d.ts +28 -0
  81. package/dist/recipe-loading/recipe-text.mapper.d.ts +14 -0
  82. package/dist/recipe-schema/index.d.ts +15 -0
  83. package/dist/recipe-schema/input-recipe.contract.d.ts +156 -0
  84. package/dist/recipe-schema/json-schema.mapper.d.ts +15 -0
  85. package/dist/recipe-schema/output-recipe.contract.d.ts +47 -0
  86. package/dist/recipe-schema/recipe-kind.enum.d.ts +35 -0
  87. package/dist/recipe-schema/recipe-validation.error.d.ts +17 -0
  88. package/dist/recipe-schema/recipe.validator.d.ts +28 -0
  89. package/dist/recipe-schema/step.contract.d.ts +206 -0
  90. package/dist/recipe-schema/transform-rule.contract.d.ts +120 -0
  91. package/dist/record-diff/index.d.ts +4 -0
  92. package/dist/record-diff/record-diff.algorithm.d.ts +82 -0
  93. package/dist/record-diff/records-file.repository.d.ts +10 -0
  94. package/dist/record-sink/dedupe.policy.d.ts +29 -0
  95. package/dist/record-sink/index.d.ts +9 -0
  96. package/dist/record-sink/json-lines-sink.repository.d.ts +19 -0
  97. package/dist/record-sink/memory-sink.repository.d.ts +9 -0
  98. package/dist/record-sink/record-sink.contract.d.ts +17 -0
  99. package/dist/selection/html-selector.algorithm.d.ts +19 -0
  100. package/dist/selection/index.d.ts +8 -0
  101. package/dist/selection/json-path.algorithm.d.ts +9 -0
  102. package/dist/selection/json-text.algorithm.d.ts +58 -0
  103. package/dist/selection/regex.algorithm.d.ts +12 -0
  104. package/dist/selection/take-value.mapper.d.ts +27 -0
  105. package/dist/src/access/access-profile.contract.d.ts +4 -0
  106. package/dist/src/access/index.d.ts +1 -1
  107. package/dist/src/api-steps/extract-from-document.use-case.d.ts +5 -5
  108. package/dist/src/api-steps/send-request.use-case.d.ts +3 -2
  109. package/dist/src/browser-session/browser-profile.store.d.ts +52 -0
  110. package/dist/src/browser-session/browser.client.d.ts +8 -0
  111. package/dist/src/browser-session/index.d.ts +1 -0
  112. package/dist/src/crawl-events/crawl-event.contract.d.ts +8 -0
  113. package/dist/src/crawl-execution/bootstrap-session.use-case.d.ts +23 -2
  114. package/dist/src/crawl-execution/crawl-options.config.d.ts +27 -0
  115. package/dist/src/crawl-execution/rotating-runner.use-case.d.ts +9 -0
  116. package/dist/src/crawl-execution/run-crawl.use-case.d.ts +6 -2
  117. package/dist/src/crawl-execution/run-input-recipe.use-case.d.ts +11 -3
  118. package/dist/src/docx-document/index.d.ts +2 -0
  119. package/dist/src/docx-document/read-docx-html.client.d.ts +23 -0
  120. package/dist/src/extraction-scope/extraction-scope.model.d.ts +2 -1
  121. package/dist/src/http-session/http-response.contract.d.ts +2 -1
  122. package/dist/src/index.d.ts +9 -4
  123. package/dist/src/markdown-document/index.d.ts +1 -1
  124. package/dist/src/markdown-document/read-markdown.client.d.ts +10 -0
  125. package/dist/src/recipe-schema/index.d.ts +2 -2
  126. package/dist/src/recipe-schema/input-recipe.contract.d.ts +24 -1
  127. package/dist/src/recipe-schema/recipe-kind.enum.d.ts +1 -1
  128. package/dist/src/recipe-schema/step.contract.d.ts +4 -0
  129. package/dist/src/record-diff/index.d.ts +4 -0
  130. package/dist/src/record-diff/record-diff.algorithm.d.ts +82 -0
  131. package/dist/src/record-diff/records-file.repository.d.ts +10 -0
  132. package/dist/src/record-sink/dedupe.policy.d.ts +19 -7
  133. package/dist/src/record-sink/index.d.ts +1 -0
  134. package/dist/src/selection/html-selector.algorithm.d.ts +2 -1
  135. package/dist/src/step-flow/for-each.use-case.d.ts +4 -2
  136. package/dist/src/step-flow/host-throttle.policy.d.ts +49 -0
  137. package/dist/src/step-flow/index.d.ts +5 -0
  138. package/dist/src/step-flow/run-gate.policy.d.ts +12 -1
  139. package/dist/src/step-flow/step-runner.contract.d.ts +13 -0
  140. package/dist/src/step-flow/transport-retry.policy.d.ts +76 -0
  141. package/dist/src/web-steps/navigate.use-case.d.ts +3 -2
  142. package/dist/src/web-steps/run-web-step.use-case.d.ts +8 -1
  143. package/dist/src/xml-document/index.d.ts +6 -0
  144. package/dist/src/xml-document/xml-document.model.d.ts +13 -0
  145. package/dist/src/xml-document/xml-parser.client.d.ts +49 -0
  146. package/dist/src/xml-document/xpath.algorithm.d.ts +30 -0
  147. package/dist/step-flow/block-rule.policy.d.ts +21 -0
  148. package/dist/step-flow/blocked.error.d.ts +9 -0
  149. package/dist/step-flow/for-each.use-case.d.ts +20 -0
  150. package/dist/step-flow/host-throttle.policy.d.ts +49 -0
  151. package/dist/step-flow/index.d.ts +15 -0
  152. package/dist/step-flow/paginate.use-case.d.ts +17 -0
  153. package/dist/step-flow/retry.policy.d.ts +21 -0
  154. package/dist/step-flow/run-gate.policy.d.ts +50 -0
  155. package/dist/step-flow/run-steps.use-case.d.ts +39 -0
  156. package/dist/step-flow/step-failure.error.d.ts +14 -0
  157. package/dist/step-flow/step-runner.contract.d.ts +52 -0
  158. package/dist/step-flow/transport-retry.policy.d.ts +76 -0
  159. package/dist/template/expression.algorithm.d.ts +53 -0
  160. package/dist/template/index.d.ts +7 -0
  161. package/dist/template/template.algorithm.d.ts +53 -0
  162. package/dist/template/value-path.algorithm.d.ts +29 -0
  163. package/dist/template/value-text.algorithm.d.ts +18 -0
  164. package/dist/transformation/apply-transform-chain.use-case.d.ts +17 -0
  165. package/dist/transformation/collection.algorithm.d.ts +15 -0
  166. package/dist/transformation/currency.algorithm.d.ts +15 -0
  167. package/dist/transformation/date.algorithm.d.ts +13 -0
  168. package/dist/transformation/index.d.ts +10 -0
  169. package/dist/transformation/lookup.algorithm.d.ts +24 -0
  170. package/dist/transformation/number.algorithm.d.ts +30 -0
  171. package/dist/transformation/string.algorithm.d.ts +18 -0
  172. package/dist/transformation/transform-registry.store.d.ts +36 -0
  173. package/dist/transformation/transform.error.d.ts +8 -0
  174. package/dist/transformation/url.algorithm.d.ts +20 -0
  175. package/dist/web-steps/evaluate-script.use-case.d.ts +10 -0
  176. package/dist/web-steps/extract-from-page.use-case.d.ts +10 -0
  177. package/dist/web-steps/index.d.ts +2 -0
  178. package/dist/web-steps/interact.use-case.d.ts +36 -0
  179. package/dist/web-steps/navigate.use-case.d.ts +15 -0
  180. package/dist/web-steps/run-web-step.use-case.d.ts +37 -0
  181. package/dist/web-steps/snapshot-elements.use-case.d.ts +13 -0
  182. package/dist/workbook-document/csv-parser.algorithm.d.ts +26 -0
  183. package/dist/workbook-document/csv-workbook.mapper.d.ts +24 -0
  184. package/dist/workbook-document/grid-table.algorithm.d.ts +53 -0
  185. package/dist/workbook-document/html-tables.mapper.d.ts +14 -0
  186. package/dist/workbook-document/index.d.ts +9 -0
  187. package/dist/workbook-document/read-xlsx.client.d.ts +18 -0
  188. package/dist/workbook-document/workbook-document.model.d.ts +51 -0
  189. package/dist/xml-document/index.d.ts +6 -0
  190. package/dist/xml-document/xml-document.model.d.ts +13 -0
  191. package/dist/xml-document/xml-parser.client.d.ts +49 -0
  192. package/dist/xml-document/xpath.algorithm.d.ts +30 -0
  193. package/dist/yaml-document/index.d.ts +3 -0
  194. package/dist/yaml-document/read-yaml.client.d.ts +25 -0
  195. package/package.json +4 -2
package/dist/index.esm.js CHANGED
@@ -1,12 +1,15 @@
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';
8
8
  import { JSONPath } from 'jsonpath-plus';
9
9
  import { load } from 'cheerio';
10
+ import { gunzipSync } from 'node:zlib';
11
+ import { DOMParser, DOMImplementation, XMLSerializer } from '@xmldom/xmldom';
12
+ import { useNamespaces } from 'xpath';
10
13
  import { fileURLToPath } from 'node:url';
11
14
 
12
15
  /** Resource types a page may skip loading, to save bandwidth on per-GB proxies. */
@@ -67,10 +70,20 @@ const pluginSchema = z.strictObject({
67
70
  options: z.record(z.string(), z.unknown()).optional()
68
71
  });
69
72
  const accessProfileSchema = z.union([directSchema, proxySchema, poolSchema, cdpSchema, pluginSchema]);
73
+ const hostRuleSchema = z.strictObject({
74
+ delayMs: z.int().nonnegative().optional(),
75
+ concurrency: z.int().min(1).max(256).optional()
76
+ });
77
+ const throttleConfigSchema = z.strictObject({
78
+ delayMs: z.int().nonnegative().optional(),
79
+ concurrency: z.int().min(1).max(256).optional(),
80
+ domains: z.record(z.string().regex(/^[\w.-]+$/, 'a domain such as example.com'), hostRuleSchema).optional()
81
+ });
70
82
  const accessConfigSchema = z.strictObject({
71
83
  $schema: z.string().optional(),
72
84
  profiles: z.record(z.string().regex(/^[\w-]+$/, 'a profile name is letters, digits, hyphens and underscores'), accessProfileSchema),
73
- default: z.string().optional()
85
+ default: z.string().optional(),
86
+ throttle: throttleConfigSchema.optional()
74
87
  }).refine(config => config.default === undefined || Object.hasOwn(config.profiles, config.default), {
75
88
  message: 'default names a profile that does not exist',
76
89
  path: ['default']
@@ -1179,6 +1192,17 @@ async function blockResources(context, types) {
1179
1192
  await route.continue();
1180
1193
  });
1181
1194
  }
1195
+ /**
1196
+ * What a context gets after it opened: the cookies to add and the resource
1197
+ * types to skip.
1198
+ *
1199
+ * @param context - The context.
1200
+ * @param options - The session options.
1201
+ */
1202
+ async function applySessionExtras(context, options) {
1203
+ if (options.cookies !== undefined && options.cookies.length > 0) await context.addCookies(options.cookies);
1204
+ if (options.blockResources !== undefined && options.blockResources.length > 0) await blockResources(context, new Set(options.blockResources));
1205
+ }
1182
1206
  /** A launched browser; sessions are opened from it and closed independently. */
1183
1207
  class BrowserClient {
1184
1208
  browser;
@@ -1238,8 +1262,7 @@ class BrowserClient {
1238
1262
  };
1239
1263
  const context = await this.browser.newContext(contextOptions);
1240
1264
  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));
1265
+ await applySessionExtras(context, options);
1243
1266
  const page = await context.newPage();
1244
1267
  return new BrowserSession(context, page);
1245
1268
  }
@@ -1248,13 +1271,206 @@ class BrowserClient {
1248
1271
  }
1249
1272
  }
1250
1273
 
1274
+ /** The file that marks a profile as in use, holding the owning process id. */
1275
+ const LOCK_FILE = '.opencraw.lock';
1276
+ /** A browser profile name: it becomes a directory, so no separators or dots. */
1277
+ const BROWSER_PROFILE_NAME = /^[\w-]+$/;
1278
+ /**
1279
+ * Browser profiles that persist between runs: each is a directory of a real
1280
+ * browser's user data (cookies, local storage, IndexedDB, cache, service
1281
+ * workers), so a login, a consent choice or a site's trust in a returning
1282
+ * visitor carries over to the next run. The browser equivalent of a user who
1283
+ * never clears their history.
1284
+ *
1285
+ * A profile directory can be open in one browser at a time. Within this
1286
+ * crawler, a second use waits for the first to close; the same owner (one
1287
+ * recipe run reopening after a rotation) takes it over instead. Another
1288
+ * crawler holding it, in this process or another, is reported, not waited
1289
+ * for: a lock file in the profile names the process, and one left by a
1290
+ * process that died is taken over. (Chromium's own profile lock is not
1291
+ * enough: headless builds do not take it.)
1292
+ */
1293
+ class BrowserProfiles {
1294
+ directory;
1295
+ config;
1296
+ held = new Map();
1297
+ waiting = new Map();
1298
+ /**
1299
+ * @param directory - Where the profiles live, one subdirectory each.
1300
+ * @param config - The crawler's browser settings (type, binary, headless, timeouts).
1301
+ */
1302
+ constructor(directory, config = {}) {
1303
+ this.directory = directory;
1304
+ this.config = config;
1305
+ }
1306
+ async take(name, owner) {
1307
+ for (;;) {
1308
+ const holder = this.held.get(name);
1309
+ if (holder === undefined) return;
1310
+ if (holder.owner === owner) {
1311
+ await holder.session.close();
1312
+ continue;
1313
+ }
1314
+ await new Promise(resolve => {
1315
+ const queue = this.waiting.get(name) ?? [];
1316
+ queue.push(resolve);
1317
+ this.waiting.set(name, queue);
1318
+ });
1319
+ }
1320
+ }
1321
+ free(name) {
1322
+ this.held.delete(name);
1323
+ this.waiting.get(name)?.shift()?.();
1324
+ }
1325
+ launch(path, options) {
1326
+ const type = this.config.browserType ?? DEFAULT_BROWSER_CONFIG.browserType;
1327
+ const launcher = type === 'firefox' ? firefox : type === 'webkit' ? webkit : chromium;
1328
+ return launcher.launchPersistentContext(path, {
1329
+ headless: this.config.headless ?? DEFAULT_BROWSER_CONFIG.headless,
1330
+ slowMo: this.config.slowMo,
1331
+ executablePath: this.config.executablePath,
1332
+ proxy: options.proxy ?? this.config.proxy,
1333
+ extraHTTPHeaders: options.headers,
1334
+ userAgent: options.userAgent,
1335
+ viewport: options.viewport,
1336
+ ignoreHTTPSErrors: this.config.ignoreHTTPSErrors === true || options.ignoreHTTPSErrors === true
1337
+ });
1338
+ }
1339
+ /**
1340
+ * The profile's directory.
1341
+ *
1342
+ * @param name - A profile name.
1343
+ * @returns The absolute path.
1344
+ */
1345
+ pathOf(name) {
1346
+ if (!BROWSER_PROFILE_NAME.test(name)) throw new Error(`browser profile "${name}": a name is letters, digits, hyphens and underscores`);
1347
+ return resolve$1(this.directory, name);
1348
+ }
1349
+ /**
1350
+ * Opens a profile in its own browser, waiting while another run of this
1351
+ * crawler uses it.
1352
+ *
1353
+ * @param name - The profile.
1354
+ * @param options - Proxy, headers, viewport, cookies to add. `storageState` is ignored: the profile has its own.
1355
+ * @param owner - Who opens it; the same owner reopening closes its previous session first.
1356
+ * @returns The session; closing it frees the profile.
1357
+ */
1358
+ async open(name, options, owner) {
1359
+ const path = this.pathOf(name);
1360
+ await this.take(name, owner);
1361
+ let context;
1362
+ let unlock = unlocked;
1363
+ try {
1364
+ await mkdir(path, {
1365
+ recursive: true
1366
+ });
1367
+ unlock = await lockProfile(path, name);
1368
+ context = await this.launch(path, options);
1369
+ } catch (error) {
1370
+ await unlock();
1371
+ this.free(name);
1372
+ const message = error instanceof Error ? error.message : String(error);
1373
+ 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`, {
1374
+ cause: error
1375
+ });
1376
+ throw error;
1377
+ }
1378
+ if (this.config.timeoutMs !== undefined) context.setDefaultTimeout(this.config.timeoutMs);
1379
+ await applySessionExtras(context, options);
1380
+ const page = context.pages()[0] ?? (await context.newPage());
1381
+ let closed = false;
1382
+ const session = new BrowserSession(context, page, async () => {
1383
+ if (closed) return;
1384
+ closed = true;
1385
+ try {
1386
+ await context.close();
1387
+ } finally {
1388
+ await unlock();
1389
+ this.free(name);
1390
+ }
1391
+ });
1392
+ this.held.set(name, {
1393
+ owner,
1394
+ session
1395
+ });
1396
+ return session;
1397
+ }
1398
+ }
1399
+ /**
1400
+ * Marks a profile as used by this process, refusing one a live process
1401
+ * holds and taking over one a dead process left behind.
1402
+ *
1403
+ * @param path - The profile directory.
1404
+ * @param name - The profile name, for the message.
1405
+ * @returns The unlock.
1406
+ * @throws Error when another crawler holds the profile.
1407
+ */
1408
+ async function lockProfile(path, name) {
1409
+ const file = join$1(path, LOCK_FILE);
1410
+ for (let attempt = 0; attempt < 2; attempt += 1) {
1411
+ try {
1412
+ await writeFile(file, String(process.pid), {
1413
+ flag: 'wx'
1414
+ });
1415
+ return async () => {
1416
+ await rm(file, {
1417
+ force: true
1418
+ });
1419
+ };
1420
+ } catch (error) {
1421
+ if (error.code !== 'EEXIST') throw error;
1422
+ const holder = Number(await readHolder(file));
1423
+ 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`, {
1424
+ cause: error
1425
+ });
1426
+ await rm(file, {
1427
+ force: true
1428
+ });
1429
+ }
1430
+ }
1431
+ throw new Error(`browser profile "${name}": cannot lock ${path}`);
1432
+ }
1433
+ /** The unlock of a profile that was never locked. */
1434
+ async function unlocked() {}
1435
+ async function readHolder(file) {
1436
+ try {
1437
+ return await readFile(file, 'utf8');
1438
+ } catch {
1439
+ return '';
1440
+ }
1441
+ }
1442
+ function isAlive(pid) {
1443
+ try {
1444
+ process.kill(pid, 0);
1445
+ return true;
1446
+ } catch (error) {
1447
+ return error.code === 'EPERM';
1448
+ }
1449
+ }
1450
+
1451
+ /**
1452
+ * Disposes a runner, ignoring a failure: a tab whose browser already went
1453
+ * away (a rotation, a crash) has nothing left to close.
1454
+ *
1455
+ * @param runner - The runner.
1456
+ */
1457
+ async function disposeQuietly(runner) {
1458
+ try {
1459
+ await runner.dispose();
1460
+ } catch {
1461
+ // already gone
1462
+ }
1463
+ }
1464
+
1251
1465
  /**
1252
1466
  * Runs a body once per item of a list (`over`), or once per live element
1253
1467
  * matching `selector`, each in a fresh child scope with the item bound under
1254
1468
  * `as`; emits a record per iteration when asked.
1255
1469
  *
1256
- * With a concurrent gate, iterations run as permits allow and records come
1257
- * out in completion order; without one, in list order.
1470
+ * With a concurrent gate, iterations of a list run as permits allow and
1471
+ * records come out in completion order; without one, in list order. In web
1472
+ * mode each parallel iteration runs in a tab of its own (`runner.fork`); a
1473
+ * loop over live elements stays sequential, since its elements live on one page.
1258
1474
  *
1259
1475
  * @param step - The forEach step.
1260
1476
  * @param scope - The scope the list lives in.
@@ -1264,7 +1480,7 @@ class BrowserClient {
1264
1480
  async function runForEach(step, scope, walk) {
1265
1481
  const items = await itemsOf$1(step, scope, walk);
1266
1482
  const gate = walk.gate;
1267
- if (gate?.concurrent === true) return runPooled(step, scope, walk, items, gate);
1483
+ if (gate?.concurrent === true && step.selector === undefined && (walk.recipe.mode === 'api' || walk.runner.fork !== undefined)) return runPooled(step, scope, walk, items, gate);
1268
1484
  for (const item of items) {
1269
1485
  if ((await runIteration(step, scope, walk, item)) === 'stop') return 'stop';
1270
1486
  }
@@ -1286,17 +1502,21 @@ async function runPooled(step, scope, walk, items, gate) {
1286
1502
  let stopped = false;
1287
1503
  let failure;
1288
1504
  const tasks = [];
1289
- const overrides = {
1290
- gate: gate.nested()
1291
- };
1505
+ const nested = gate.nested();
1292
1506
  const iterate = async (item, release) => {
1507
+ let runner;
1293
1508
  try {
1294
- if ((await runIteration(step, scope, walk, item, overrides)) === 'stop') stopped = true;
1509
+ runner = walk.runner.fork === undefined ? walk.runner : await walk.runner.fork();
1510
+ if ((await runIteration(step, scope, walk, item, {
1511
+ gate: nested,
1512
+ runner
1513
+ })) === 'stop') stopped = true;
1295
1514
  } catch (error) {
1296
1515
  failure ??= {
1297
1516
  error
1298
1517
  };
1299
1518
  } finally {
1519
+ if (runner !== undefined && runner !== walk.runner) await disposeQuietly(runner);
1300
1520
  release();
1301
1521
  }
1302
1522
  };
@@ -1604,6 +1824,7 @@ function logThrough(walk) {
1604
1824
  class RunGate {
1605
1825
  permits;
1606
1826
  minIntervalMs;
1827
+ hosts;
1607
1828
  shared;
1608
1829
  inFlight = 0;
1609
1830
  waiting = [];
@@ -1611,11 +1832,13 @@ class RunGate {
1611
1832
  /**
1612
1833
  * @param permits - Iterations allowed in flight; 1 is sequential.
1613
1834
  * @param minIntervalMs - Minimum time between two request starts across the run.
1835
+ * @param hosts - The crawler's per-site throttle, shared with every other recipe.
1614
1836
  * @param shared - The throttle state to share (internal: `nested` gates keep their parent's).
1615
1837
  */
1616
- constructor(permits, minIntervalMs, shared) {
1838
+ constructor(permits, minIntervalMs, hosts, shared) {
1617
1839
  this.permits = permits;
1618
1840
  this.minIntervalMs = minIntervalMs;
1841
+ this.hosts = hosts;
1619
1842
  this.shared = shared;
1620
1843
  }
1621
1844
  /** Whether this gate lets more than one iteration run at once. */
@@ -1652,11 +1875,258 @@ class RunGate {
1652
1875
  state.lastStart = at;
1653
1876
  await sleep(at - now);
1654
1877
  }
1878
+ /**
1879
+ * Waits until a request to `url` may start: the recipe's interval, then its
1880
+ * site's turn in the crawler's per-site throttle.
1881
+ *
1882
+ * @param url - Where the request goes.
1883
+ * @returns The release of the site's lane: call it once the response arrived or the request failed.
1884
+ */
1885
+ async request(url) {
1886
+ await this.throttle();
1887
+ return this.hosts === undefined ? noop$1 : this.hosts.slot(url);
1888
+ }
1655
1889
  /** The gate for a body running inside an iteration that holds a permit: sequential, same throttle. */
1656
1890
  nested() {
1657
- return new RunGate(1, this.minIntervalMs, this.shared ?? this);
1891
+ return new RunGate(1, this.minIntervalMs, this.hosts, this.shared ?? this);
1892
+ }
1893
+ }
1894
+ function noop$1() {}
1895
+
1896
+ /**
1897
+ * Spaces and bounds requests per site, shared by every recipe of a crawler,
1898
+ * so two recipes (or two parallel iterations) that hit one site add up to one
1899
+ * polite client rather than two. A recipe's own `limits.delayMs` still applies
1900
+ * on top, per recipe.
1901
+ *
1902
+ * Like a single-lane bridge with a traffic light: whoever arrives waits for
1903
+ * the car ahead to be far enough, and for a free lane.
1904
+ */
1905
+ class HostThrottle {
1906
+ config;
1907
+ buckets = new Map();
1908
+ domains;
1909
+ constructor(config = {}) {
1910
+ this.config = config;
1911
+ this.domains = Object.entries(config.domains ?? {}).map(([domain, rule]) => [domain.toLowerCase().replace(/^\.+/, ''), rule]).sort((first, second) => second[0].length - first[0].length);
1912
+ }
1913
+ bucketFor(url, always = false) {
1914
+ const host = hostOf(url);
1915
+ if (host === undefined) return undefined;
1916
+ const match = this.domains.find(([domain]) => host === domain || host.endsWith(`.${domain}`));
1917
+ const key = match?.[0] ?? host;
1918
+ let bucket = this.buckets.get(key);
1919
+ if (bucket === undefined) {
1920
+ const rule = {
1921
+ ...pick(this.config),
1922
+ ...match?.[1]
1923
+ };
1924
+ if (!always && !hasLimit(rule)) return undefined;
1925
+ bucket = {
1926
+ rule,
1927
+ inFlight: 0,
1928
+ waiting: [],
1929
+ lastStart: Promise.resolve(-Infinity),
1930
+ pausedTo: 0
1931
+ };
1932
+ this.buckets.set(key, bucket);
1933
+ }
1934
+ return bucket;
1935
+ }
1936
+ /** Whether any rule can hold a request back. */
1937
+ get active() {
1938
+ return hasLimit(this.config) || this.domains.some(([, rule]) => hasLimit(rule)) || this.buckets.size > 0;
1939
+ }
1940
+ /**
1941
+ * Waits until a request to `url` may start, then holds one of its site's
1942
+ * lanes until the returned release is called.
1943
+ *
1944
+ * @param url - Where the request goes; anything but `http(s):` passes at once.
1945
+ * @returns The release: call it once, when the response arrived or the request failed.
1946
+ */
1947
+ async slot(url) {
1948
+ const bucket = this.bucketFor(url);
1949
+ if (bucket === undefined) return noop;
1950
+ const concurrency = bucket.rule.concurrency ?? Infinity;
1951
+ if (bucket.inFlight >= concurrency) await new Promise(resolve => {
1952
+ bucket.waiting.push(resolve);
1953
+ });
1954
+ bucket.inFlight += 1;
1955
+ const previous = bucket.lastStart;
1956
+ const turn = (async () => {
1957
+ const after = await previous;
1958
+ // A pause may arrive while waiting (a Retry-After), so check it again after each sleep.
1959
+ for (;;) {
1960
+ const now = Date.now();
1961
+ const at = Math.max(now, after + (bucket.rule.delayMs ?? 0), bucket.pausedTo);
1962
+ if (at <= now) return now;
1963
+ await sleep(at - now);
1964
+ }
1965
+ })();
1966
+ bucket.lastStart = turn;
1967
+ await turn;
1968
+ let released = false;
1969
+ return () => {
1970
+ if (released) return;
1971
+ released = true;
1972
+ bucket.inFlight -= 1;
1973
+ bucket.waiting.shift()?.();
1974
+ };
1975
+ }
1976
+ /**
1977
+ * Holds every request to the site of `url` back until `untilMs` (a `Retry-After`).
1978
+ *
1979
+ * @param url - A URL of the site.
1980
+ * @param untilMs - An epoch time.
1981
+ */
1982
+ pause(url, untilMs) {
1983
+ const bucket = this.bucketFor(url, true);
1984
+ if (bucket !== undefined) bucket.pausedTo = Math.max(bucket.pausedTo, untilMs);
1985
+ }
1986
+ }
1987
+ function noop() {}
1988
+ function hostOf(url) {
1989
+ try {
1990
+ const parsed = new URL(url);
1991
+ return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? parsed.hostname.toLowerCase() : undefined;
1992
+ } catch {
1993
+ return undefined;
1994
+ }
1995
+ }
1996
+ function pick(config) {
1997
+ return {
1998
+ delayMs: config.delayMs,
1999
+ concurrency: config.concurrency
2000
+ };
2001
+ }
2002
+ function hasLimit(rule) {
2003
+ return (rule.delayMs ?? 0) > 0 || rule.concurrency !== undefined;
2004
+ }
2005
+
2006
+ /** Statuses a server uses for "not now": timeout, too early, too many requests, and the 5xx that pass. */
2007
+ const RETRY_STATUSES = [408, 425, 429, 500, 502, 503, 504];
2008
+ /** Three tries, one second then two apart, never a wait over 30 seconds. */
2009
+ const DEFAULT_RETRY_RULE = {
2010
+ attempts: 3,
2011
+ backoffMs: 1000,
2012
+ maxDelayMs: 30_000,
2013
+ statuses: [...RETRY_STATUSES]
2014
+ };
2015
+ /**
2016
+ * Errors that say the connection failed rather than the site answered:
2017
+ * resets, refusals, timeouts, a DNS lookup that could not run, a proxy that
2018
+ * dropped the tunnel. A name that does not resolve at all (`ENOTFOUND`,
2019
+ * `ERR_NAME_NOT_RESOLVED`) is not here: retrying a typo only wastes time.
2020
+ */
2021
+ 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/;
2022
+ /**
2023
+ * The retry rule a recipe runs with: its `limits.retry` over the crawler's
2024
+ * default over `DEFAULT_RETRY_RULE`.
2025
+ *
2026
+ * @param own - The recipe's `limits.retry`.
2027
+ * @param crawler - The crawler's `retry` option.
2028
+ * @returns The rule.
2029
+ */
2030
+ function resolveRetryRule(own, crawler) {
2031
+ return {
2032
+ ...DEFAULT_RETRY_RULE,
2033
+ ...definedOf(crawler),
2034
+ ...definedOf(own)
2035
+ };
2036
+ }
2037
+ /**
2038
+ * Whether an error is a connection failure worth another try.
2039
+ *
2040
+ * @param error - What the request threw.
2041
+ * @returns The reason, or `undefined`.
2042
+ */
2043
+ function transientError(error) {
2044
+ const message = error instanceof Error ? error.message : String(error);
2045
+ const match = TRANSIENT_ERROR.exec(message);
2046
+ return match === null ? undefined : {
2047
+ reason: match[0]
2048
+ };
2049
+ }
2050
+ /**
2051
+ * How long to wait before attempt `attempt + 1`: the server's `Retry-After`
2052
+ * when it gave one, else `backoffMs` doubling per attempt with a little
2053
+ * jitter; `undefined` when the server asks for longer than `maxDelayMs` (it
2054
+ * means "come back much later", which a crawl cannot wait for).
2055
+ *
2056
+ * @param rule - The retry rule.
2057
+ * @param attempt - The attempt that just failed, from 1.
2058
+ * @param retryAfter - The `Retry-After` header: seconds, or an HTTP date.
2059
+ * @param now - The current time, for dates.
2060
+ * @returns Milliseconds, or `undefined` for no retry.
2061
+ */
2062
+ function retryDelay(rule, attempt, retryAfter, now = Date.now()) {
2063
+ const asked = retryAfterMs(retryAfter, now);
2064
+ if (asked !== undefined) return asked > rule.maxDelayMs ? undefined : asked;
2065
+ const base = rule.backoffMs * 2 ** (attempt - 1);
2066
+ const jittered = base * (0.75 + Math.random() * 0.5);
2067
+ return Math.min(Math.round(jittered), rule.maxDelayMs);
2068
+ }
2069
+ /**
2070
+ * Sends a request through the gate (the recipe's rate, the site's lane), and
2071
+ * sends it again after a pause while it fails in a passing way, up to
2072
+ * `rule.attempts` tries in all. A `Retry-After` holds back every request to
2073
+ * that site, not only this one. Each retry is reported as `request:retry`.
2074
+ *
2075
+ * Like redialling a busy number: wait a moment, dial again, give up after a
2076
+ * few tries; and if the other end said "call back in a minute", wait that minute.
2077
+ *
2078
+ * @param url - Where the request goes.
2079
+ * @param attempt - How to send it and how to judge the outcome.
2080
+ * @param context - The recipe, gate, events and rule.
2081
+ * @returns What the last try gave.
2082
+ * @throws What the last try threw.
2083
+ */
2084
+ async function withTransportRetry(url, attempt, context) {
2085
+ const {
2086
+ rule
2087
+ } = context;
2088
+ for (let tries = 1;; tries += 1) {
2089
+ const release = await context.gate.request(url);
2090
+ let outcome;
2091
+ try {
2092
+ outcome = {
2093
+ value: await attempt.run()
2094
+ };
2095
+ } catch (error) {
2096
+ outcome = {
2097
+ error
2098
+ };
2099
+ } finally {
2100
+ release();
2101
+ }
2102
+ const transient = tries < rule.attempts ? attempt.problem(outcome) : undefined;
2103
+ const delay = transient === undefined ? undefined : retryDelay(rule, tries, transient.retryAfter);
2104
+ if (transient === undefined || delay === undefined) {
2105
+ if ('error' in outcome) throw outcome.error;
2106
+ return outcome.value;
2107
+ }
2108
+ if (transient.retryAfter !== undefined) context.gate.hosts?.pause(url, Date.now() + delay);
2109
+ context.events.emit({
2110
+ type: 'request:retry',
2111
+ recipeId: context.recipeId,
2112
+ url,
2113
+ attempt: tries + 1,
2114
+ reason: transient.reason,
2115
+ delayMs: delay
2116
+ });
2117
+ await sleep(delay);
1658
2118
  }
1659
2119
  }
2120
+ function retryAfterMs(header, now) {
2121
+ if (header === undefined || header.trim() === '') return undefined;
2122
+ const seconds = Number(header.trim());
2123
+ if (Number.isFinite(seconds)) return Math.max(0, Math.round(seconds * 1000));
2124
+ const date = Date.parse(header);
2125
+ return Number.isNaN(date) ? undefined : Math.max(0, date - now);
2126
+ }
2127
+ function definedOf(rule) {
2128
+ return Object.fromEntries(Object.entries(rule ?? {}).filter(([, value]) => value !== undefined));
2129
+ }
1660
2130
 
1661
2131
  /** A block unless a recipe says otherwise: forbidden, rate limited, or an AWS WAF challenge (IMDb answers 202 with it). */
1662
2132
  const DEFAULT_BLOCK_RULE = {
@@ -2229,6 +2699,10 @@ function traceLine(event) {
2229
2699
  {
2230
2700
  return `${indent(1)}↻ new access lease (attempt ${event.attempt})`;
2231
2701
  }
2702
+ case 'request:retry':
2703
+ {
2704
+ return `${indent(1)}↺ ${event.url}: ${event.reason}, try ${event.attempt} in ${event.delayMs} ms`;
2705
+ }
2232
2706
  case 'captcha:detected':
2233
2707
  {
2234
2708
  return `${indent(1)}⚿ captcha ${event.kind} on ${event.url}`;
@@ -2437,26 +2911,36 @@ async function existingKeys(target) {
2437
2911
  });
2438
2912
  }
2439
2913
 
2440
- /** Drops records whose key was already seen. First record wins; keyless records always pass. */
2914
+ /**
2915
+ * Drops records whose key was already seen. First record wins; keyless
2916
+ * records always pass. Recipes running in parallel each get their own view:
2917
+ * under `recipe` scope they never see each other's keys, under `run` scope
2918
+ * they share them (and whichever emits a key first keeps it).
2919
+ */
2441
2920
  class DedupePolicy {
2442
2921
  scope;
2443
- seen = new Set();
2922
+ shared = new Set();
2444
2923
  constructor(scope = 'run') {
2445
2924
  this.scope = scope;
2446
2925
  }
2447
- /** Called when an input recipe starts; forgets keys under `recipe` scope. */
2448
- startRecipe() {
2449
- if (this.scope === 'recipe') this.seen = new Set();
2450
- }
2451
2926
  /**
2452
- * @param record - A validated record.
2453
- * @returns `true` when the record repeats an earlier key and must be dropped.
2927
+ * The de-duplication one input recipe run uses.
2928
+ *
2929
+ * @returns Its view: keys shared with the run, its own, or none checked.
2454
2930
  */
2455
- isDuplicate(record) {
2456
- if (this.scope === 'off' || record.key === null) return false;
2457
- if (this.seen.has(record.key)) return true;
2458
- this.seen.add(record.key);
2459
- return false;
2931
+ forRecipe() {
2932
+ if (this.scope === 'off') return {
2933
+ isDuplicate: () => false
2934
+ };
2935
+ const seen = this.scope === 'run' ? this.shared : new Set();
2936
+ return {
2937
+ isDuplicate: record => {
2938
+ if (record.key === null) return false;
2939
+ if (seen.has(record.key)) return true;
2940
+ seen.add(record.key);
2941
+ return false;
2942
+ }
2943
+ };
2460
2944
  }
2461
2945
  }
2462
2946
 
@@ -2507,10 +2991,13 @@ const DOCUMENT = /^\s*(?:<!doctype|<html)/i;
2507
2991
  *
2508
2992
  * @param html - The markup (a whole document or a fragment).
2509
2993
  * @param selector - A CSS selector.
2994
+ * @param xml - Read the markup as XML: names keep their case, `take: "html"` gives XML.
2510
2995
  * @returns Every match, in document order.
2511
2996
  */
2512
- function selectHtml(html, selector) {
2513
- const api = DOCUMENT.test(html) ? load(html) : load(html, undefined, false);
2997
+ function selectHtml(html, selector, xml = false) {
2998
+ const api = xml ? load(html, {
2999
+ xml: true
3000
+ }) : DOCUMENT.test(html) ? load(html) : load(html, undefined, false);
2514
3001
  return api(selector).map((_, element) => ({
2515
3002
  api,
2516
3003
  element: api(element)
@@ -3557,8 +4044,8 @@ function filledGrid(sheet) {
3557
4044
  /** `B10:B13` as 0-based bounds; `undefined` for anything else. */
3558
4045
  function rangeOf(reference) {
3559
4046
  const [from, to = from] = reference.split(':', 2);
3560
- const start = cellOf(from);
3561
- const end = cellOf(to);
4047
+ const start = cellOf$1(from);
4048
+ const end = cellOf$1(to);
3562
4049
  if (start === undefined || end === undefined) return undefined;
3563
4050
  return {
3564
4051
  top: Math.min(start.row, end.row),
@@ -3567,7 +4054,7 @@ function rangeOf(reference) {
3567
4054
  right: Math.max(start.column, end.column)
3568
4055
  };
3569
4056
  }
3570
- function cellOf(reference) {
4057
+ function cellOf$1(reference) {
3571
4058
  const match = /^\$?([A-Z]+)\$?(\d+)$/i.exec(reference.trim());
3572
4059
  if (match === null) return undefined;
3573
4060
  const letters = match[1].toUpperCase();
@@ -3700,13 +4187,194 @@ function shapeTables(document, slides, query) {
3700
4187
  }));
3701
4188
  }
3702
4189
 
4190
+ /**
4191
+ * Whether a value is a read XML document.
4192
+ *
4193
+ * @param value - Anything bound in a scope.
4194
+ * @returns `true` for an `XmlDocument`.
4195
+ */
4196
+ function isXmlDocument(value) {
4197
+ return typeof value === 'object' && value !== null && value.kind === 'xml' && typeof value.xml === 'string';
4198
+ }
4199
+
4200
+ /** The documents already parsed, by the text they came from, so a recipe's extracts parse a body once. */
4201
+ const parsed = new Map();
4202
+ const PARSED_LIMIT = 16;
4203
+ /**
4204
+ * Parses XML strictly but safely: a malformed document is an error naming
4205
+ * the URL and the line; entities a DOCTYPE declares are never expanded (no
4206
+ * "billion laughs") and nothing external is ever fetched (no XXE), they stay
4207
+ * as they are.
4208
+ *
4209
+ * @param xml - The document text.
4210
+ * @param where - The URL or id, for messages.
4211
+ * @returns The document.
4212
+ * @throws Error when the text is not well-formed XML.
4213
+ */
4214
+ function parseXml(xml, where) {
4215
+ const cached = parsed.get(xml);
4216
+ if (cached !== undefined) return cached;
4217
+ let document;
4218
+ try {
4219
+ document = new DOMParser({
4220
+ onError: failOnFatal
4221
+ }).parseFromString(xml, 'text/xml');
4222
+ } catch (error) {
4223
+ throw new Error(`${where}: not well-formed XML (${firstLine(error)})`, {
4224
+ cause: error
4225
+ });
4226
+ }
4227
+ if (document.documentElement === null) throw new Error(`${where}: not an XML document (no root element)`);
4228
+ remember(xml, document);
4229
+ return document;
4230
+ }
4231
+ /**
4232
+ * HTML as an XML document, for XPath on fetched pages: parsed the way a
4233
+ * browser parses it (a forgiving HTML5 parser that inserts `<tbody>` and
4234
+ * closes what the page left open), without namespaces, so `//table/tbody/tr`
4235
+ * finds what it finds on the live page.
4236
+ *
4237
+ * @param html - A page or a fragment.
4238
+ * @returns The document.
4239
+ */
4240
+ function htmlAsXml(html) {
4241
+ const cached = parsed.get(html);
4242
+ if (cached !== undefined) return cached;
4243
+ const whole = /^\s*(?:<!doctype|<html)/i.test(html);
4244
+ // A fragment (a row taken with `take: "html"`) keeps its cells, and gets one root to be a document.
4245
+ const xml = whole ? load(html).xml() : `<fragment>${load(html, undefined, false).xml()}</fragment>`;
4246
+ const document = withoutNamespaces(new DOMParser({
4247
+ onError: ignore
4248
+ }).parseFromString(xml, 'text/xml'));
4249
+ remember(html, document);
4250
+ return document;
4251
+ }
4252
+ /**
4253
+ * A copy of a document with every namespace dropped: elements and attributes
4254
+ * keep their local names, `xmlns` declarations go. What `ignoreNamespaces`
4255
+ * queries run on: `//entry/title` instead of `//atom:entry/atom:title`.
4256
+ *
4257
+ * @param document - A parsed document.
4258
+ * @returns The copy.
4259
+ */
4260
+ function withoutNamespaces(document) {
4261
+ const copy = new DOMImplementation().createDocument(null, '', null);
4262
+ const clone = node => {
4263
+ if (node.nodeType === node.ELEMENT_NODE) {
4264
+ const element = node;
4265
+ const target = copy.createElement(element.localName);
4266
+ for (const attribute of element.attributes) {
4267
+ if (attribute.name === 'xmlns' || attribute.prefix === 'xmlns') continue;
4268
+ target.setAttribute(attribute.localName, attribute.value);
4269
+ }
4270
+ for (const child of element.childNodes) {
4271
+ const cloned = clone(child);
4272
+ if (cloned !== undefined) target.insertBefore(cloned, null);
4273
+ }
4274
+ return target;
4275
+ }
4276
+ if (node.nodeType === node.TEXT_NODE || node.nodeType === node.CDATA_SECTION_NODE) return copy.createTextNode(node.nodeValue ?? '');
4277
+ return undefined;
4278
+ };
4279
+ const root = document.documentElement === null ? undefined : clone(document.documentElement);
4280
+ if (root !== undefined) copy.insertBefore(root, null);
4281
+ return copy;
4282
+ }
4283
+ /**
4284
+ * A node as markup: the element with its children (`outer`), or its children only.
4285
+ *
4286
+ * @param node - A node.
4287
+ * @param outer - Whether to include the node itself.
4288
+ * @returns The markup.
4289
+ */
4290
+ function serializeXml(node, outer) {
4291
+ const serializer = new XMLSerializer();
4292
+ if (outer) return serializer.serializeToString(node);
4293
+ return Array.from(node.childNodes, child => serializer.serializeToString(child)).join('');
4294
+ }
4295
+ /**
4296
+ * The text of an XML body, gunzipped first when it is gzip (a `sitemap.xml.gz`).
4297
+ *
4298
+ * @param bytes - The body.
4299
+ * @returns The bytes to decode.
4300
+ */
4301
+ function gunzipIfNeeded(bytes) {
4302
+ return bytes.length > 2 && bytes[0] === 0x1F && bytes[1] === 0x8B ? gunzipSync(bytes) : bytes;
4303
+ }
4304
+ function remember(text, document) {
4305
+ if (parsed.size >= PARSED_LIMIT) parsed.delete(parsed.keys().next().value);
4306
+ parsed.set(text, document);
4307
+ }
4308
+ function failOnFatal(level, message) {
4309
+ if (level === 'fatalError') throw new Error(message);
4310
+ }
4311
+ function ignore() {}
4312
+ function firstLine(error) {
4313
+ return (error instanceof Error ? error.message : String(error)).split('\n', 1)[0];
4314
+ }
4315
+
4316
+ /**
4317
+ * Runs an XPath 1.0 query on a parsed document. Prefixes the root element
4318
+ * declares are known without being listed; a default namespace has no
4319
+ * prefix, so name one in `namespaces` or use `ignoreNamespaces`.
4320
+ *
4321
+ * @param document - The document.
4322
+ * @param expression - The query.
4323
+ * @param options - Namespaces.
4324
+ * @returns Every node selected, or the one value a function returned.
4325
+ * @throws Error naming the query when it does not parse or uses an unknown prefix.
4326
+ */
4327
+ function selectXpath(document, expression, options = {}) {
4328
+ const target = options.ignoreNamespaces === true ? withoutNamespaces(document) : document;
4329
+ const namespaces = {
4330
+ ...declaredPrefixes(target),
4331
+ ...options.namespaces
4332
+ };
4333
+ let result;
4334
+ try {
4335
+ result = useNamespaces(namespaces)(expression, target);
4336
+ } catch (error) {
4337
+ const message = error instanceof Error ? error.message : String(error);
4338
+ const hint = /prefix|namespace|QName/i.test(message) ? ' (declare it in "namespaces", or set "ignoreNamespaces": true)' : '';
4339
+ throw new Error(`xpath ${expression}: ${message}${hint}`, {
4340
+ cause: error
4341
+ });
4342
+ }
4343
+ if (Array.isArray(result)) return result;
4344
+ if (result === null) return [];
4345
+ return [result];
4346
+ }
4347
+ /**
4348
+ * The value of an XPath result.
4349
+ *
4350
+ * @param value - A node or a function result.
4351
+ * @param take - `text` (whitespace collapsed), `html` (the node's inner markup), `json` (its outer markup), `value` (its text as is), `attr:<name>`.
4352
+ * @returns The value; `undefined` for a missing attribute.
4353
+ */
4354
+ function takeFromXml(value, take) {
4355
+ if (typeof value !== 'object') return take === 'text' ? collapse(String(value)) : value;
4356
+ if (take === 'text') return collapse(value.textContent ?? '');
4357
+ if (take === 'value') return value.textContent ?? '';
4358
+ if (take === 'html') return serializeXml(value, false);
4359
+ if (take === 'json') return serializeXml(value, true);
4360
+ if (value.nodeType !== value.ELEMENT_NODE) return undefined;
4361
+ const element = value;
4362
+ const name = take.slice('attr:'.length);
4363
+ return element.hasAttribute(name) ? element.getAttribute(name) : undefined;
4364
+ }
4365
+ function declaredPrefixes(document) {
4366
+ const root = document.documentElement;
4367
+ if (root === null) return {};
4368
+ return Object.fromEntries([...root.attributes].filter(attribute => attribute.prefix === 'xmlns').map(attribute => [attribute.localName, attribute.value]));
4369
+ }
4370
+
3703
4371
  /**
3704
4372
  * Runs an `extract` step against a static document: the value bound under
3705
- * `from`, else the scope's current document. `css` reads HTML, `jsonpath`
3706
- * reads JSON (or a read PDF, workbook or deck as data), `table` reads the
3707
- * tables of a PDF, a workbook (a spreadsheet, a CSV), a deck (a presentation)
3708
- * or HTML (its `<table>`s), `regex` reads any document as text; `xpath` needs
3709
- * a live page and is refused here.
4373
+ * `from`, else the scope's current document. `css` reads HTML (or XML),
4374
+ * `xpath` reads XML (a feed, a sitemap) or HTML parsed as a browser parses
4375
+ * it, `jsonpath` reads JSON (or a read PDF, workbook or deck as data), `table`
4376
+ * reads the tables of a PDF, a workbook (a spreadsheet, a CSV), a deck (a
4377
+ * presentation) or HTML (its `<table>`s), `regex` reads any document as text.
3710
4378
  *
3711
4379
  * A `jsonpath` extract whose `from` is text parses that text as JSON, and a
3712
4380
  * list of texts (every `<script type="application/ld+json">` of a page) becomes
@@ -3725,6 +4393,7 @@ function extractFromDocument(step, scope) {
3725
4393
  switch (step.kind) {
3726
4394
  case 'jsonpath':
3727
4395
  {
4396
+ if (document.kind === 'xml') throw new Error('jsonpath needs a JSON document; the current document is xml (read it with kind "xpath")');
3728
4397
  if (document.kind === 'html' || document.kind === 'text') throw new Error(`jsonpath needs a JSON document; the current document is ${document.kind}`);
3729
4398
  values = selectJson(document.kind === 'json' ? document.data : document, selector).map(node => takeFromJson(node, take));
3730
4399
  break;
@@ -3736,10 +4405,22 @@ function extractFromDocument(step, scope) {
3736
4405
  }
3737
4406
  case 'css':
3738
4407
  {
4408
+ if (document.kind === 'xml') {
4409
+ values = selectHtml(document.xml, selector, true).map(match => takeFromHtml(match, take));
4410
+ break;
4411
+ }
3739
4412
  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")' : ''}`);
3740
4413
  values = selectHtml(document.html, selector).map(match => takeFromHtml(match, take));
3741
4414
  break;
3742
4415
  }
4416
+ case 'xpath':
4417
+ {
4418
+ values = selectXpath(xpathDocument(document), selector, {
4419
+ namespaces: step.namespaces,
4420
+ ignoreNamespaces: step.ignoreNamespaces
4421
+ }).map(node => takeFromXml(node, take));
4422
+ break;
4423
+ }
3743
4424
  case 'regex':
3744
4425
  {
3745
4426
  values = selectRegex(textOf$1(document), selector);
@@ -3747,7 +4428,7 @@ function extractFromDocument(step, scope) {
3747
4428
  }
3748
4429
  default:
3749
4430
  {
3750
- throw new Error('xpath works on a live page only; use css on fetched HTML');
4431
+ throw new Error(`unknown extract kind ${String(step.kind)}`);
3751
4432
  }
3752
4433
  }
3753
4434
  if (step.many === true) {
@@ -3853,9 +4534,32 @@ function patternOf(source, where) {
3853
4534
  });
3854
4535
  }
3855
4536
  }
4537
+ /** What an xpath extract queries: XML as it is, HTML parsed the way a browser would. */
4538
+ function xpathDocument(document) {
4539
+ if (document.kind === 'xml') return parseXml(document.xml, 'the document');
4540
+ if (document.kind === 'html') return htmlAsXml(document.html);
4541
+ if (document.kind === 'text') return parseXml(document.text, 'the document');
4542
+ throw new Error(`xpath reads XML or HTML; the current document is ${document.kind} (read it with kind ${document.kind === 'json' ? '"jsonpath"' : '"table", "regex" or "jsonpath"'})`);
4543
+ }
4544
+ /** Markup bound to an id: XML when it is well-formed (a feed entry taken with `take: "json"`), else HTML. */
4545
+ function markupDocument(markup) {
4546
+ try {
4547
+ parseXml(markup, 'markup');
4548
+ return {
4549
+ kind: 'xml',
4550
+ xml: markup
4551
+ };
4552
+ } catch {
4553
+ return {
4554
+ kind: 'html',
4555
+ html: markup
4556
+ };
4557
+ }
4558
+ }
3856
4559
  /** 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). */
3857
4560
  function textOf$1(document) {
3858
4561
  if (document.kind === 'html') return document.html;
4562
+ if (document.kind === 'xml') return document.xml;
3859
4563
  if (document.kind === 'text') return document.text;
3860
4564
  if (document.kind === 'pdf') return pdfText(document);
3861
4565
  if (document.kind === 'workbook') return workbookText(document);
@@ -3871,7 +4575,11 @@ function documentFor(step, scope) {
3871
4575
  }
3872
4576
  const source = scope.get(step.from);
3873
4577
  if (source === undefined) throw new Error(`"${step.from}" is not bound`);
3874
- if (isPdfDocument(source) || isWorkbookDocument(source) || isDeckDocument(source)) return source;
4578
+ if (isPdfDocument(source) || isWorkbookDocument(source) || isDeckDocument(source) || isXmlDocument(source)) return source;
4579
+ if (step.kind === 'xpath') {
4580
+ if (typeof source !== 'string') throw new Error(`"${step.from}" is not markup; xpath reads XML or HTML text`);
4581
+ return markupDocument(source);
4582
+ }
3875
4583
  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"`);
3876
4584
  if (step.kind === 'regex') {
3877
4585
  if (typeof source === 'string') return {
@@ -3960,6 +4668,12 @@ async function readYaml(text, source, scalars = 'typed') {
3960
4668
  };
3961
4669
  }
3962
4670
 
4671
+ /**
4672
+ * `<` as the JSON escape `\u003c`, so a front matter value holding `</script>`
4673
+ * cannot close its element. Built from char codes: a formatter would turn a
4674
+ * literal escape back into `<`.
4675
+ */
4676
+ const ESCAPED_LESS_THAN = String.fromCodePoint(92, 117, 48, 48, 51, 99);
3963
4677
  /** A leading `---` block of YAML. */
3964
4678
  const FRONT_MATTER = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/;
3965
4679
  /**
@@ -3993,14 +4707,22 @@ async function readMarkdown(text, source) {
3993
4707
  async: false
3994
4708
  });
3995
4709
  const sections = sectioned(rendered);
3996
- const head = front === undefined ? '' : `<script type="application/json" data-front-matter>${JSON.stringify(front.data ?? null).replaceAll('<', '<')}</script>`;
4710
+ const head = front === undefined ? '' : `<script type="application/json" data-front-matter>${JSON.stringify(front.data ?? null).replaceAll('<', () => ESCAPED_LESS_THAN)}</script>`;
3997
4711
  return {
3998
4712
  html: `<!doctype html><html><head>${head}</head><body>${sections}</body></html>`,
3999
4713
  frontMatter: front?.data,
4000
4714
  warnings: front?.warnings ?? []
4001
4715
  };
4002
4716
  }
4003
- /** Wraps each heading and what follows it, up to the next heading of the same or a higher level, in a section. */
4717
+ /**
4718
+ * Wraps each heading and what follows it, up to the next heading of the same
4719
+ * or a higher level, in `<section data-heading="…" data-level="…">`, sections
4720
+ * nesting; headings get slug ids. Rendered Markdown and Word documents both go
4721
+ * through it, so one selector finds "the table under *Prezzi*" in either.
4722
+ *
4723
+ * @param html - Top-level HTML: headings among paragraphs, lists, tables.
4724
+ * @returns The same content, sectioned.
4725
+ */
4004
4726
  function sectioned(html) {
4005
4727
  const $ = load(html, null, false);
4006
4728
  const open = [];
@@ -4021,7 +4743,7 @@ function sectioned(html) {
4021
4743
  const heading = $(node);
4022
4744
  const text = heading.text().replaceAll(/\s+/g, ' ').trim();
4023
4745
  heading.attr('id', uniqueSlug(text, used));
4024
- out += `<section data-heading="${escapeAttribute(text)}" data-level="${depth}">${$.html(node)}`;
4746
+ out += `<section data-heading="${escapeAttribute$1(text)}" data-level="${depth}">${$.html(node)}`;
4025
4747
  open.push(depth);
4026
4748
  }
4027
4749
  return out + '</section>'.repeat(open.length);
@@ -4033,10 +4755,165 @@ function uniqueSlug(text, used) {
4033
4755
  used.set(slug, count + 1);
4034
4756
  return count === 0 ? slug : `${slug}-${count}`;
4035
4757
  }
4036
- function escapeAttribute(text) {
4758
+ function escapeAttribute$1(text) {
4037
4759
  return text.replaceAll('&', '&amp;').replaceAll('"', '&quot;').replaceAll('<', '&lt;');
4038
4760
  }
4039
4761
 
4762
+ /**
4763
+ * Reads a `.docx` Word document into HTML, through `@opencraw/office-reader`,
4764
+ * so every selector that reads a page reads a Word document too:
4765
+ *
4766
+ * - headings become `<h1>`…`<h6>` inside `<section data-heading="…">`, as in
4767
+ * rendered Markdown;
4768
+ * - list items become nested `<ul>` / `<ol>`;
4769
+ * - tables become `<table>`s with their merged cells as `colspan` / `rowspan`,
4770
+ * so a `table` extract reads them like any HTML table;
4771
+ * - a paragraph keeps its style as `data-style` and its links as `<a href>`;
4772
+ * - headers, footers and notes follow the body, in `<header>`, `<footer>`
4773
+ * and `<aside data-part="notes">`; the title goes to `<title>`.
4774
+ *
4775
+ * The reader is imported on first use.
4776
+ *
4777
+ * @param bytes - The file.
4778
+ * @param source - Where it came from, for messages.
4779
+ * @returns The HTML document.
4780
+ * @throws Error naming the source, and saying what to do, for a file that is
4781
+ * not a readable Word document (a legacy `.doc`, a password-protected file, an `.odt`…).
4782
+ */
4783
+ async function readDocxHtml(bytes, source) {
4784
+ const {
4785
+ readDocx,
4786
+ OfficeReadError
4787
+ } = await import('@opencraw/office-reader/docx');
4788
+ let document;
4789
+ try {
4790
+ document = await readDocx(bytes);
4791
+ } catch (error) {
4792
+ if (error instanceof OfficeReadError) throw new Error(`${source}: ${error.message}`, {
4793
+ cause: error
4794
+ });
4795
+ throw error;
4796
+ }
4797
+ const head = document.title === undefined ? '' : `<title>${escapeText(document.title)}</title>`;
4798
+ const headers = document.headers.map(blocks => `<header data-part="header">${blocksHtml(blocks)}</header>`).join('');
4799
+ const footers = document.footers.map(blocks => `<footer data-part="footer">${blocksHtml(blocks)}</footer>`).join('');
4800
+ const notes = document.notes.length === 0 ? '' : `<aside data-part="notes"><ol>${document.notes.map(note => `<li id="${note.kind}-${escapeAttribute(note.id)}" data-kind="${note.kind}">${textHtml(note.text)}</li>`).join('')}</ol></aside>`;
4801
+ return `<!doctype html><html><head>${head}</head><body>${headers}${sectioned(blocksHtml(document.body))}${footers}${notes}</body></html>`;
4802
+ }
4803
+ /** Blocks as HTML: headings, paragraphs, nested lists, tables. */
4804
+ function blocksHtml(blocks) {
4805
+ let html = '';
4806
+ const lists = [];
4807
+ const closeLists = depth => {
4808
+ while (lists.length > depth) {
4809
+ const list = lists.pop();
4810
+ html += `${list?.itemOpen === true ? '</li>' : ''}</${list?.tag ?? 'ul'}>`;
4811
+ }
4812
+ };
4813
+ for (const block of blocks) {
4814
+ if (block.kind === 'table') {
4815
+ closeLists(0);
4816
+ html += tableHtml(block);
4817
+ continue;
4818
+ }
4819
+ if (block.list === undefined) {
4820
+ closeLists(0);
4821
+ html += paragraphHtml(block);
4822
+ continue;
4823
+ }
4824
+ const tag = block.list.ordered ? 'ol' : 'ul';
4825
+ const depth = block.list.level + 1;
4826
+ closeLists(depth);
4827
+ if (lists.length === depth && lists.at(-1)?.tag !== tag) closeLists(depth - 1);
4828
+ while (lists.length < depth) {
4829
+ html += `<${tag}>`;
4830
+ lists.push({
4831
+ tag,
4832
+ itemOpen: false
4833
+ });
4834
+ }
4835
+ const list = lists[depth - 1];
4836
+ html += `${list.itemOpen ? '</li>' : ''}<li${styleAttribute(block)}>${inlineHtml(block)}`;
4837
+ list.itemOpen = true;
4838
+ }
4839
+ closeLists(0);
4840
+ return html;
4841
+ }
4842
+ function paragraphHtml(paragraph) {
4843
+ if (paragraph.heading !== undefined) {
4844
+ const level = Math.min(paragraph.heading, 6);
4845
+ return `<h${level}${styleAttribute(paragraph)}>${inlineHtml(paragraph)}</h${level}>`;
4846
+ }
4847
+ return `<p${styleAttribute(paragraph)}>${inlineHtml(paragraph)}</p>`;
4848
+ }
4849
+ /** A paragraph's text with its links as anchors, in the order they appear. */
4850
+ function inlineHtml(paragraph) {
4851
+ let html = '';
4852
+ let rest = paragraph.text;
4853
+ const links = paragraph.links ?? [];
4854
+ for (const link of links) {
4855
+ const at = rest.indexOf(link.text);
4856
+ if (at === -1) continue;
4857
+ html += `${textHtml(rest.slice(0, at))}<a href="${escapeAttribute(link.href)}">${textHtml(link.text)}</a>`;
4858
+ rest = rest.slice(at + link.text.length);
4859
+ }
4860
+ return html + textHtml(rest);
4861
+ }
4862
+ /** A grid as a table: the top-left cell of a merged range spans it, the cells it covers are left out. */
4863
+ function tableHtml(table) {
4864
+ const spans = new Map();
4865
+ const covered = new Set();
4866
+ for (const range of table.merges) {
4867
+ const [from, to] = range.split(':').map(reference => cellOf(reference));
4868
+ if (from === undefined || to === undefined) continue;
4869
+ spans.set(`${from.row},${from.column}`, {
4870
+ rows: to.row - from.row + 1,
4871
+ columns: to.column - from.column + 1
4872
+ });
4873
+ for (let row = from.row; row <= to.row; row += 1) {
4874
+ for (let column = from.column; column <= to.column; column += 1) {
4875
+ if (row !== from.row || column !== from.column) covered.add(`${row},${column}`);
4876
+ }
4877
+ }
4878
+ }
4879
+ const rows = table.rows.map((cells, row) => {
4880
+ const html = cells.map((text, column) => {
4881
+ const key = `${row},${column}`;
4882
+ if (covered.has(key)) return '';
4883
+ const span = spans.get(key);
4884
+ const attributes = span === undefined ? '' : `${span.rows > 1 ? ` rowspan="${span.rows}"` : ''}${span.columns > 1 ? ` colspan="${span.columns}"` : ''}`;
4885
+ return `<td${attributes}>${textHtml(text)}</td>`;
4886
+ }).join('');
4887
+ return `<tr>${html}</tr>`;
4888
+ }).join('');
4889
+ return `<table data-name="${escapeAttribute(table.name)}">${rows}</table>`;
4890
+ }
4891
+ /** An A1 reference as 0-based row and column. */
4892
+ function cellOf(reference) {
4893
+ const match = /^([A-Z]+)(\d+)$/.exec(reference);
4894
+ if (match === null) return undefined;
4895
+ let column = 0;
4896
+ const letters = match[1];
4897
+ for (const letter of letters) column = column * 26 + (letter.codePointAt(0) ?? 64) - 64;
4898
+ return {
4899
+ row: Number(match[2]) - 1,
4900
+ column: column - 1
4901
+ };
4902
+ }
4903
+ function styleAttribute(paragraph) {
4904
+ return paragraph.style === undefined ? '' : ` data-style="${escapeAttribute(paragraph.style)}"`;
4905
+ }
4906
+ /** Text as HTML: escaped, line breaks as `<br>`. */
4907
+ function textHtml(text) {
4908
+ return escapeText(text).replaceAll('\n', '<br>');
4909
+ }
4910
+ function escapeText(text) {
4911
+ return text.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');
4912
+ }
4913
+ function escapeAttribute(text) {
4914
+ return escapeText(text).replaceAll('"', '&quot;');
4915
+ }
4916
+
4040
4917
  /** A response with a 4xx or 5xx status. */
4041
4918
  class HttpError extends Error {
4042
4919
  status;
@@ -4196,7 +5073,7 @@ class HttpClient {
4196
5073
  }
4197
5074
  async function readBody(response, httpRequest) {
4198
5075
  const contentType = response.headers()['content-type'] ?? '';
4199
- const format = httpRequest.as ?? formatFromContentType(contentType);
5076
+ const format = httpRequest.as ?? formatFromContentType(contentType, response.url());
4200
5077
  return parseBody(format, await response.body(), response.url(), {
4201
5078
  ...httpRequest,
4202
5079
  charset: charsetOf(contentType)
@@ -4214,7 +5091,7 @@ async function readLocalFile(httpRequest) {
4214
5091
  body,
4215
5092
  warnings,
4216
5093
  format
4217
- } = await parseBody(httpRequest.as ?? formatFromExtension(extname(path)), bytes, httpRequest.url, httpRequest);
5094
+ } = await parseBody(httpRequest.as ?? formatFromExtension(path), bytes, httpRequest.url, httpRequest);
4218
5095
  return {
4219
5096
  status: 200,
4220
5097
  url: httpRequest.url,
@@ -4244,6 +5121,35 @@ async function parseBody(format, bytes, url, reading) {
4244
5121
  format
4245
5122
  };
4246
5123
  }
5124
+ if (format === 'xml') {
5125
+ // A sitemap is often served gzipped as a file (`sitemap.xml.gz`), not as a compressed response.
5126
+ const {
5127
+ text
5128
+ } = decodeText(gunzipIfNeeded(bytes), reading);
5129
+ try {
5130
+ parseXml(text, url);
5131
+ } catch (error) {
5132
+ throw new Error(`${error.message}${/^\s*(?:<!doctype html|<html)/i.test(text) ? '; the body is HTML: read it with "as": "html"' : ''}`, {
5133
+ cause: error
5134
+ });
5135
+ }
5136
+ return {
5137
+ body: {
5138
+ kind: 'xml',
5139
+ xml: text
5140
+ },
5141
+ warnings: [],
5142
+ format
5143
+ };
5144
+ }
5145
+ if (format === 'docx') return {
5146
+ body: {
5147
+ kind: 'html',
5148
+ html: await readDocxHtml(bytes, url)
5149
+ },
5150
+ warnings: [],
5151
+ format
5152
+ };
4247
5153
  if (format === 'markdown') {
4248
5154
  const {
4249
5155
  text
@@ -4309,24 +5215,31 @@ function looksLikeJsonLines(text) {
4309
5215
  const first = parseJsonLike(lines[0]);
4310
5216
  return 'value' in first;
4311
5217
  }
4312
- function formatFromContentType(contentType) {
5218
+ function formatFromContentType(contentType, url) {
4313
5219
  const type = contentType.toLowerCase().split(';', 1)[0].trim();
5220
+ // A gzipped sitemap is served as an archive; its name says what is inside.
5221
+ if (GZIP_TYPES.has(type) && /\.xml\.gz$/i.test(new URL(url).pathname)) return 'xml';
4314
5222
  if (CSV_TYPES.has(type)) return 'csv';
4315
5223
  if (JSON_LINES_TYPES.has(type)) return 'jsonl';
4316
5224
  // A legacy .xls or .ppt goes to the Office reader too, which says what to do with it.
4317
5225
  if (type.includes('spreadsheetml') || type.startsWith('application/vnd.ms-excel')) return 'xlsx';
4318
5226
  if (type.includes('presentationml') || type.startsWith('application/vnd.ms-powerpoint')) return 'pptx';
5227
+ if (type === 'application/msword' || type.includes('wordprocessingml') || type.startsWith('application/vnd.ms-word')) return 'docx';
4319
5228
  if (YAML_TYPES.has(type)) return 'yaml';
4320
5229
  if (type === 'text/markdown' || type === 'text/x-markdown') return 'markdown';
4321
5230
  if (type.includes('json')) return 'json';
4322
5231
  if (type.includes('pdf')) return 'pdf';
4323
- if (type.includes('html') || type.includes('xml')) return 'html';
5232
+ if (type.includes('html')) return 'html';
5233
+ if (type.includes('xml')) return 'xml';
4324
5234
  return 'text';
4325
5235
  }
4326
5236
  const JSON_LINES_TYPES = new Set(['application/x-ndjson', 'application/ndjson', 'application/jsonl', 'application/x-jsonlines', 'application/jsonlines']);
4327
5237
  const YAML_TYPES = new Set(['application/yaml', 'application/x-yaml', 'text/yaml', 'text/x-yaml']);
5238
+ const GZIP_TYPES = new Set(['application/gzip', 'application/x-gzip', 'application/octet-stream']);
4328
5239
  const CSV_TYPES = new Set(['text/csv', 'application/csv', 'text/x-csv', 'application/x-csv', 'text/comma-separated-values', 'text/tab-separated-values']);
4329
- function formatFromExtension(extension) {
5240
+ function formatFromExtension(path) {
5241
+ if (/\.xml\.gz$/i.test(path)) return 'xml';
5242
+ const extension = extname(path);
4330
5243
  const formats = {
4331
5244
  '.json': 'json',
4332
5245
  '.jsonl': 'jsonl',
@@ -4341,21 +5254,30 @@ function formatFromExtension(extension) {
4341
5254
  '.pptm': 'pptx',
4342
5255
  '.ppsx': 'pptx',
4343
5256
  '.ppt': 'pptx',
5257
+ '.docx': 'docx',
5258
+ '.docm': 'docx',
5259
+ '.dotx': 'docx',
5260
+ '.doc': 'docx',
4344
5261
  '.yaml': 'yaml',
4345
5262
  '.yml': 'yaml',
4346
5263
  '.md': 'markdown',
4347
5264
  '.markdown': 'markdown',
4348
5265
  '.html': 'html',
4349
5266
  '.htm': 'html',
4350
- '.xml': 'html'
5267
+ '.xml': 'xml',
5268
+ '.rss': 'xml',
5269
+ '.atom': 'xml',
5270
+ '.kml': 'xml',
5271
+ '.gpx': 'xml'
4351
5272
  };
4352
5273
  return formats[extension.toLowerCase()] ?? 'text';
4353
5274
  }
4354
5275
 
4355
5276
  /**
4356
5277
  * Sends a `request` step: renders its templates, waits for the gate's throttle,
4357
- * sends, checks the response against the recipe's block rule, then binds it as
4358
- * the scope's current document (and under the step id).
5278
+ * sends (again, after a pause, while it fails in passing: `limits.retry`),
5279
+ * checks the response against the recipe's block rule, then binds it as the
5280
+ * scope's current document (and under the step id).
4359
5281
  *
4360
5282
  * @param step - The request step.
4361
5283
  * @param scope - The scope to render in and bind into.
@@ -4368,20 +5290,29 @@ function formatFromExtension(extension) {
4368
5290
  async function sendRequest(step, scope, client, recipe, gate, events) {
4369
5291
  const lookup = path => scope.lookup(path);
4370
5292
  const url = resolveUrl(renderText(step.url, lookup), scope.pageState?.url);
4371
- await gate.throttle();
5293
+ const request = {
5294
+ method: step.method,
5295
+ url,
5296
+ query: step.query === undefined ? undefined : renderMap(step.query, lookup),
5297
+ headers: step.headers === undefined ? undefined : renderMap(step.headers, lookup),
5298
+ body: renderDeep(step.body, lookup),
5299
+ as: step.as,
5300
+ encoding: step.encoding,
5301
+ delimiter: step.delimiter,
5302
+ scalars: step.scalars,
5303
+ timeoutMs: recipe.limits?.timeoutMs
5304
+ };
5305
+ const rule = resolveRetryRule(recipe.limits?.retry);
4372
5306
  let response;
4373
5307
  try {
4374
- response = await client.send({
4375
- method: step.method,
4376
- url,
4377
- query: step.query === undefined ? undefined : renderMap(step.query, lookup),
4378
- headers: step.headers === undefined ? undefined : renderMap(step.headers, lookup),
4379
- body: renderDeep(step.body, lookup),
4380
- as: step.as,
4381
- encoding: step.encoding,
4382
- delimiter: step.delimiter,
4383
- scalars: step.scalars,
4384
- timeoutMs: recipe.limits?.timeoutMs
5308
+ response = await withTransportRetry(url, {
5309
+ run: () => client.send(request),
5310
+ problem: outcome => 'error' in outcome ? problemOf(outcome.error, rule.statuses) : undefined
5311
+ }, {
5312
+ recipeId: recipe.id,
5313
+ gate,
5314
+ events,
5315
+ rule
4385
5316
  });
4386
5317
  } catch (error) {
4387
5318
  if (!(error instanceof HttpError)) throw error;
@@ -4431,6 +5362,14 @@ async function sendRequest(step, scope, client, recipe, gate, events) {
4431
5362
  });
4432
5363
  if (step.id !== undefined) scope.set(step.id, documentValue(response.body));
4433
5364
  }
5365
+ /** A retry status (with the server's `Retry-After`), or a connection that failed. */
5366
+ function problemOf(error, statuses) {
5367
+ if (error instanceof HttpError) return statuses.includes(error.status) ? {
5368
+ reason: `HTTP ${error.status}`,
5369
+ retryAfter: error.headers['retry-after']
5370
+ } : undefined;
5371
+ return transientError(error);
5372
+ }
4434
5373
  /** The class names of the widgets `session.captcha` solves. Checked only when a recipe declares it. */
4435
5374
  const CAPTCHA_MARKUP = /\b(?:g-recaptcha|h-captcha|cf-turnstile)\b/;
4436
5375
  function bodyText(body) {
@@ -4438,6 +5377,7 @@ function bodyText(body) {
4438
5377
  if (body.kind === 'pdf') return pdfText(body);
4439
5378
  if (body.kind === 'workbook') return workbookText(body);
4440
5379
  if (body.kind === 'deck') return deckText(body);
5380
+ if (body.kind === 'xml') return body.xml;
4441
5381
  return body.kind === 'html' ? body.html : body.text;
4442
5382
  }
4443
5383
  function renderMap(map, lookup) {
@@ -5713,18 +6653,34 @@ function readElements(elements) {
5713
6653
 
5714
6654
  /**
5715
6655
  * Runs a `goto` step: renders the URL (relative to the current page), waits for
5716
- * the gate's throttle (`delayMs`), navigates, records the page's real URL in the
5717
- * scope, and checks the response against the recipe's block rule.
6656
+ * the gate's throttle (`delayMs`), navigates (again, after a pause, while it
6657
+ * fails in passing: `limits.retry`), records the page's real URL in the scope,
6658
+ * and checks the response against the recipe's block rule.
5718
6659
  *
5719
6660
  * @throws BlockedError when the response is a block.
5720
6661
  */
5721
6662
  async function navigate(step, page, scope, recipe, gate, events) {
5722
6663
  const target = renderText(step.url, path => scope.lookup(path));
5723
6664
  const url = new URL(target, scope.pageState?.url ?? page.url()).href;
5724
- await gate.throttle();
5725
- const response = await page.goto(url, {
5726
- waitUntil: step.waitUntil,
5727
- timeout: recipe.limits?.timeoutMs
6665
+ const rule = resolveRetryRule(recipe.limits?.retry);
6666
+ const response = await withTransportRetry(url, {
6667
+ run: () => page.goto(url, {
6668
+ waitUntil: step.waitUntil,
6669
+ timeout: recipe.limits?.timeoutMs
6670
+ }),
6671
+ problem: outcome => {
6672
+ if ('error' in outcome) return transientError(outcome.error);
6673
+ const status = outcome.value?.status();
6674
+ return status !== undefined && rule.statuses.includes(status) ? {
6675
+ reason: `HTTP ${status}`,
6676
+ retryAfter: outcome.value?.headers()['retry-after']
6677
+ } : undefined;
6678
+ }
6679
+ }, {
6680
+ recipeId: recipe.id,
6681
+ gate,
6682
+ events,
6683
+ rule
5728
6684
  });
5729
6685
  scope.setPage({
5730
6686
  url: page.url()
@@ -5874,8 +6830,13 @@ class WebStepRunner {
5874
6830
  const link = this.page.locator(next.selector).first();
5875
6831
  if (!(await appears(link, NEXT_LINK_TIMEOUT_MS))) return null;
5876
6832
  const before = this.page.url();
5877
- await link.click();
5878
- await this.page.waitForLoadState();
6833
+ const release = await this.gate.request(before);
6834
+ try {
6835
+ await link.click();
6836
+ await this.page.waitForLoadState();
6837
+ } finally {
6838
+ release();
6839
+ }
5879
6840
  if (this.page.url() === before) await this.page.waitForTimeout(NEXT_LINK_TIMEOUT_MS / 4);
5880
6841
  this.events.emit({
5881
6842
  type: 'page:visit',
@@ -5889,6 +6850,23 @@ class WebStepRunner {
5889
6850
  url: this.page.url()
5890
6851
  };
5891
6852
  }
6853
+ /**
6854
+ * A runner on a new tab of the same context, for one parallel iteration:
6855
+ * it shares cookies, the gate and the captcha guard; disposing it closes the tab only.
6856
+ *
6857
+ * @returns The forked runner.
6858
+ */
6859
+ async fork() {
6860
+ const {
6861
+ context
6862
+ } = this.session;
6863
+ const page = await context.newPage();
6864
+ const viewport = this.recipe.session?.viewport;
6865
+ if (viewport !== undefined) await page.setViewportSize(viewport);
6866
+ return new WebStepRunner(new BrowserSession(context, page, async () => {
6867
+ await page.close();
6868
+ }), this.recipe, this.events, this.gate, this.captcha);
6869
+ }
5892
6870
  async elements(selector) {
5893
6871
  return snapshotElements(selector, this.page);
5894
6872
  }
@@ -5925,16 +6903,30 @@ function accessOptions(lease, headers) {
5925
6903
  * The bootstrap runs through the same access lease as the crawl that follows,
5926
6904
  * so a login and the requests that use its cookies come from one IP.
5927
6905
  *
6906
+ * With `session.browserProfile`, the bootstrap runs in that profile, and
6907
+ * without a bootstrap the profile's own cookies and storage are the state: an
6908
+ * api recipe picks up a login a browser left in the profile.
6909
+ *
5928
6910
  * @param recipe - The input recipe.
5929
6911
  * @param deps - Browser, hooks, events.
5930
6912
  * @param lease - The recipe run's access; direct when omitted.
5931
6913
  * @param captcha - Solves the bootstrap's captchas (a login form's).
6914
+ * @param owner - The recipe run, which a browser profile is held by.
5932
6915
  * @returns The state, or `undefined` when the recipe declares none.
5933
6916
  */
5934
- async function resolveStorageState(recipe, deps, lease, captcha) {
6917
+ async function resolveStorageState(recipe, deps, lease, captcha, owner = {}) {
5935
6918
  const saved = await readSavedState(recipe, deps);
6919
+ if (saved !== undefined) return saved;
5936
6920
  const session = recipe.session;
5937
- if (saved !== undefined || session?.bootstrap === undefined) return saved;
6921
+ if (session?.browserProfile !== undefined) {
6922
+ const browserSession = await openBrowserProfile(recipe, deps, lease, owner);
6923
+ try {
6924
+ return session.bootstrap === undefined ? await browserSession.storageState() : await runBootstrap(recipe, browserSession, deps, captcha);
6925
+ } finally {
6926
+ await browserSession.close();
6927
+ }
6928
+ }
6929
+ if (session?.bootstrap === undefined) return undefined;
5938
6930
  const browser = await deps.browser();
5939
6931
  const browserSession = await browser.newSession({
5940
6932
  cookies: session.cookies,
@@ -5948,6 +6940,27 @@ async function resolveStorageState(recipe, deps, lease, captcha) {
5948
6940
  await browserSession.close();
5949
6941
  }
5950
6942
  }
6943
+ /**
6944
+ * Opens the recipe's `session.browserProfile` with its session options and
6945
+ * the lease's proxy.
6946
+ *
6947
+ * @param recipe - A recipe with `session.browserProfile`.
6948
+ * @param deps - For `profiles`.
6949
+ * @param lease - The access lease.
6950
+ * @param owner - The recipe run.
6951
+ * @returns The session in the profile.
6952
+ */
6953
+ async function openBrowserProfile(recipe, deps, lease, owner) {
6954
+ const session = recipe.session;
6955
+ const name = session?.browserProfile ?? '';
6956
+ if (deps.profiles === undefined) throw new Error(`recipe "${recipe.id}" uses browser profile "${name}", but this crawler has no profiles directory (CrawlOptions.profilesDir)`);
6957
+ return deps.profiles.open(name, {
6958
+ cookies: session?.cookies,
6959
+ userAgent: session?.userAgent,
6960
+ viewport: session?.viewport,
6961
+ ...accessOptions(lease, session?.headers)
6962
+ }, owner);
6963
+ }
5951
6964
  /**
5952
6965
  * The storage state saved by an earlier bootstrap (`session.storageStatePath`), if the recipe names one.
5953
6966
  *
@@ -5977,7 +6990,7 @@ async function runBootstrap(recipe, browserSession, deps, captcha) {
5977
6990
  cookies: [],
5978
6991
  origins: []
5979
6992
  };
5980
- const runner = new WebStepRunner(browserSession, recipe, deps.events, undefined, captcha);
6993
+ const runner = new WebStepRunner(browserSession, recipe, deps.events, new RunGate(1, recipe.limits?.delayMs ?? 0, deps.hosts), captcha);
5981
6994
  const scope = new ExtractionScope();
5982
6995
  scope.set('vars', recipe.vars ?? {});
5983
6996
  scope.set('start', {
@@ -6082,6 +7095,66 @@ class RotatingRunner {
6082
7095
  throw error;
6083
7096
  }
6084
7097
  }
7098
+ /**
7099
+ * A runner for one parallel iteration, forked from whichever runner is
7100
+ * current when it runs a step: after a rotation it forks again from the new
7101
+ * one, since the old context is gone (or going). Blocks are noted and
7102
+ * rotated like the main runner's.
7103
+ *
7104
+ * @returns The iteration's runner.
7105
+ */
7106
+ async fork() {
7107
+ // `own`: a tab this iteration opened and must close; an api runner is shared and never disposed here.
7108
+ let forked;
7109
+ const disposeForked = async () => {
7110
+ if (forked?.own === true) await disposeQuietly(forked.runner);
7111
+ forked = undefined;
7112
+ };
7113
+ const current = async () => {
7114
+ if (forked?.generation === this.generation) return forked.runner;
7115
+ await disposeForked();
7116
+ const inner = this.inner;
7117
+ forked = inner.fork === undefined ? {
7118
+ generation: this.generation,
7119
+ runner: inner,
7120
+ own: false
7121
+ } : {
7122
+ generation: this.generation,
7123
+ runner: await inner.fork(),
7124
+ own: true
7125
+ };
7126
+ return forked.runner;
7127
+ };
7128
+ return {
7129
+ runLeaf: async (step, scope) => {
7130
+ const generation = this.generation;
7131
+ try {
7132
+ const runner = await current();
7133
+ await runner.runLeaf(step, scope);
7134
+ } catch (error) {
7135
+ this.note(error, generation);
7136
+ throw error;
7137
+ }
7138
+ },
7139
+ nextPage: async (next, scope) => {
7140
+ const generation = this.generation;
7141
+ try {
7142
+ const runner = await current();
7143
+ return await runner.nextPage(next, scope);
7144
+ } catch (error) {
7145
+ this.note(error, generation);
7146
+ throw error;
7147
+ }
7148
+ },
7149
+ elements: async (selector, scope) => {
7150
+ const runner = await current();
7151
+ if (runner.elements === undefined) throw new Error('forEach over selector iterates live elements and needs a browser; this recipe runs in api mode');
7152
+ return runner.elements(selector, scope);
7153
+ },
7154
+ rotate: error => this.rotate(error),
7155
+ dispose: disposeForked
7156
+ };
7157
+ }
6085
7158
  async elements(selector, scope) {
6086
7159
  if (this.inner.elements === undefined) throw new Error('forEach over selector iterates live elements and needs a browser; this recipe runs in api mode');
6087
7160
  return this.inner.elements(selector, scope);
@@ -6130,12 +7203,19 @@ class RotatingRunner {
6130
7203
  * the sink sees one record at a time and `maxRecords` is exact: once reached,
6131
7204
  * every later emit returns `stop` before mapping.
6132
7205
  *
6133
- * @param input - The input recipe.
7206
+ * @param recipe - The input recipe.
6134
7207
  * @param output - The output recipe it feeds.
6135
7208
  * @param deps - Shared browser, hooks, events, sink and de-duplication.
6136
7209
  * @returns What happened.
6137
7210
  */
6138
- async function runInputRecipe(input, output, deps) {
7211
+ async function runInputRecipe(recipe, output, deps) {
7212
+ const input = {
7213
+ ...recipe,
7214
+ limits: {
7215
+ ...recipe.limits,
7216
+ retry: resolveRetryRule(recipe.limits?.retry, deps.retry)
7217
+ }
7218
+ };
6139
7219
  const started = Date.now();
6140
7220
  const report = {
6141
7221
  recipeId: input.id,
@@ -6154,8 +7234,8 @@ async function runInputRecipe(input, output, deps) {
6154
7234
  failed: 0
6155
7235
  };
6156
7236
  const limits = input.limits ?? {};
6157
- // A web recipe drives one page, so only api mode runs iterations in parallel.
6158
- const gate = new RunGate(input.mode === 'web' ? 1 : limits.concurrency ?? 1, limits.delayMs ?? 0);
7237
+ // Parallel iterations: requests in api mode, tabs of the recipe's context in web mode.
7238
+ const gate = new RunGate(limits.concurrency ?? 1, limits.delayMs ?? 0, deps.hosts);
6159
7239
  let stopped = false;
6160
7240
  let chain = Promise.resolve();
6161
7241
  const unsubscribe = deps.events.subscribe(event => {
@@ -6170,7 +7250,7 @@ async function runInputRecipe(input, output, deps) {
6170
7250
  recipeId: input.id,
6171
7251
  mode: input.mode
6172
7252
  });
6173
- deps.dedupe.startRecipe();
7253
+ const dedupe = deps.dedupe.forRecipe();
6174
7254
  let runner;
6175
7255
  try {
6176
7256
  const onBlock = input.session?.onBlock;
@@ -6278,7 +7358,7 @@ async function runInputRecipe(input, output, deps) {
6278
7358
  url,
6279
7359
  key: record.key
6280
7360
  });
6281
- } else if (deps.dedupe.isDuplicate(record)) {
7361
+ } else if (dedupe.isDuplicate(record)) {
6282
7362
  report.duplicates += 1;
6283
7363
  deps.events.emit({
6284
7364
  type: 'record:duplicate',
@@ -6370,7 +7450,8 @@ async function openRunner(input, deps, context, lease) {
6370
7450
  lease
6371
7451
  });
6372
7452
  if (lease.cdp !== undefined) return openRemoteRunner(input, deps, context, lease, lease.cdp);
6373
- const storageState = await resolveStorageState(input, deps, lease, captcha);
7453
+ if (input.mode === 'web' && input.session?.browserProfile !== undefined) return openProfileRunner(input, deps, context, lease, captcha);
7454
+ const storageState = await resolveStorageState(input, deps, lease, captcha, context);
6374
7455
  const session = input.session;
6375
7456
  const access = accessOptions(lease, session?.headers);
6376
7457
  if (input.mode === 'web') {
@@ -6394,6 +7475,28 @@ async function openRunner(input, deps, context, lease) {
6394
7475
  });
6395
7476
  return new ApiStepRunner(client, input, deps.events, gate);
6396
7477
  }
7478
+ /**
7479
+ * A web runner in a persistent browser profile. The bootstrap runs in the
7480
+ * same browser as the crawl, and what both leave behind (cookies, storage)
7481
+ * stays in the profile for the next run.
7482
+ */
7483
+ async function openProfileRunner(input, deps, context, lease, captcha) {
7484
+ const saved = await readSavedState(input, deps);
7485
+ const browserSession = await openBrowserProfile(saved === undefined ? input : {
7486
+ ...input,
7487
+ session: {
7488
+ ...input.session,
7489
+ cookies: [...saved.cookies, ...(input.session?.cookies ?? [])]
7490
+ }
7491
+ }, deps, lease, context);
7492
+ try {
7493
+ if (saved === undefined && input.session?.bootstrap !== undefined) await runBootstrap(input, browserSession, deps, captcha);
7494
+ } catch (error) {
7495
+ await browserSession.close();
7496
+ throw error;
7497
+ }
7498
+ return new WebStepRunner(browserSession, input, deps.events, context.gate, captcha);
7499
+ }
6397
7500
  /**
6398
7501
  * A web runner in a remote browser. The bootstrap runs in the same remote
6399
7502
  * session as the crawl: providers tie the IP and fingerprint to the
@@ -6408,6 +7511,7 @@ async function openRemoteRunner(input, deps, context, lease, cdp) {
6408
7511
  lease
6409
7512
  });
6410
7513
  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`);
7514
+ 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`);
6411
7515
  const session = input.session;
6412
7516
  const storageState = await readSavedState(input, deps);
6413
7517
  const browserSession = await BrowserClient.connectOverCDP(cdp, {
@@ -6426,27 +7530,42 @@ async function openRemoteRunner(input, deps, context, lease, cdp) {
6426
7530
  }
6427
7531
 
6428
7532
  /**
6429
- * Runs every input recipe of a set, one after another, into one sink.
7533
+ * Runs every input recipe of a set into one sink, `parallel` at a time
7534
+ * (default one after another). Reports come back in the set's order whatever
7535
+ * order the recipes finish in. Under `onRecipeError: 'stop'`, a failed recipe
7536
+ * stops the ones not started yet; those already running finish.
6430
7537
  *
6431
7538
  * @param set - The bound recipes.
6432
7539
  * @param deps - Shared browser, hooks, events, sink and de-duplication.
6433
7540
  * @param onRecipeError - Whether a failed recipe stops the run.
7541
+ * @param parallel - How many input recipes run at once.
6434
7542
  * @returns The report.
6435
7543
  */
6436
- async function runCrawl(set, deps, onRecipeError) {
7544
+ async function runCrawl(set, deps, onRecipeError, parallel = 1) {
6437
7545
  const started = Date.now();
6438
7546
  await deps.sink.open(set.output);
6439
- const recipes = [];
7547
+ const reports = [];
6440
7548
  let sink;
6441
7549
  try {
6442
- for (const input of set.inputs) {
6443
- const report = await runInputRecipe(input, set.output, deps);
6444
- recipes.push(report);
6445
- if (onRecipeError === 'stop' && report.error !== undefined) break;
6446
- }
7550
+ let next = 0;
7551
+ let stopped = false;
7552
+ const lane = async () => {
7553
+ while (!stopped && next < set.inputs.length) {
7554
+ const index = next;
7555
+ next += 1;
7556
+ const report = await runInputRecipe(set.inputs[index], set.output, deps);
7557
+ reports[index] = report;
7558
+ if (onRecipeError === 'stop' && report.error !== undefined) stopped = true;
7559
+ }
7560
+ };
7561
+ const lanes = Math.max(1, Math.min(parallel, set.inputs.length));
7562
+ await Promise.all(Array.from({
7563
+ length: lanes
7564
+ }, lane));
6447
7565
  } finally {
6448
7566
  sink = await deps.sink.close();
6449
7567
  }
7568
+ const recipes = reports.filter(report => report !== undefined);
6450
7569
  return {
6451
7570
  outputId: set.output.id,
6452
7571
  recipes,
@@ -6471,6 +7590,8 @@ function createCrawler(options = {}) {
6471
7590
  const access = new AccessBroker(options.access, options.accessPlugins);
6472
7591
  const hooks = new HookRegistry(options.hooks);
6473
7592
  const captchaSolvers = new CaptchaSolverRegistry(options.captchaSolvers);
7593
+ const hosts = new HostThrottle(options.throttle ?? options.access?.throttle);
7594
+ const profiles = new BrowserProfiles(options.profilesDir ?? resolve$1(options.storageStateDir ?? '.', '.opencraw', 'profiles'), options.browser);
6474
7595
  const events = new EventBus(options.onEvent);
6475
7596
  let browser;
6476
7597
  const launch = () => {
@@ -6489,8 +7610,11 @@ function createCrawler(options = {}) {
6489
7610
  debug: options.debug === true,
6490
7611
  access,
6491
7612
  captchaSolvers,
7613
+ hosts,
7614
+ profiles,
7615
+ retry: options.retry,
6492
7616
  ignoreHTTPSErrors: options.browser?.ignoreHTTPSErrors
6493
- }, options.onRecipeError ?? 'continue'),
7617
+ }, options.onRecipeError ?? 'continue', options.parallel ?? 1),
6494
7618
  async close() {
6495
7619
  const launched = browser;
6496
7620
  browser = undefined;
@@ -6506,7 +7630,7 @@ const CRAWL_MODES = ['web', 'api'];
6506
7630
  const SELECTOR_KINDS = ['css', 'xpath', 'jsonpath', 'regex', 'table'];
6507
7631
  /** `take` also accepts `attr:<name>`, which is validated by pattern rather than listed. */
6508
7632
  const TAKE_KINDS = ['text', 'html', 'value', 'json'];
6509
- const BODY_KINDS = ['json', 'jsonl', 'html', 'text', 'pdf', 'csv', 'xlsx', 'pptx', 'yaml', 'markdown'];
7633
+ const BODY_KINDS = ['json', 'jsonl', 'html', 'text', 'pdf', 'csv', 'xlsx', 'pptx', 'yaml', 'markdown', 'xml', 'docx'];
6510
7634
  const YAML_SCALARS = ['typed', 'text'];
6511
7635
  /** How a PDF table aligns a row's values against a cell wrapped over several lines. */
6512
7636
  const TABLE_ALIGNS = ['auto', 'top', 'center', 'bottom'];
@@ -6712,8 +7836,20 @@ const extractStep = z.strictObject({
6712
7836
  fillDown: z.array(z.string().min(1)).min(1).optional(),
6713
7837
  includeHidden: z.boolean().optional(),
6714
7838
  slide: z.string().min(1).optional(),
6715
- shapes: z.boolean().optional()
7839
+ shapes: z.boolean().optional(),
7840
+ namespaces: z.record(z.string().regex(/^[A-Z_][\w.-]*$/i, 'a namespace prefix such as atom'), z.string().min(1)).optional(),
7841
+ ignoreNamespaces: z.boolean().optional()
6716
7842
  }).check(context => {
7843
+ if (context.value.kind !== 'xpath') {
7844
+ for (const key of ['namespaces', 'ignoreNamespaces']) {
7845
+ if (context.value[key] !== undefined) context.issues.push({
7846
+ code: 'custom',
7847
+ input: context.value,
7848
+ path: [key],
7849
+ message: `"${key}" belongs to kind "xpath"`
7850
+ });
7851
+ }
7852
+ }
6717
7853
  if (context.value.kind === 'table') return;
6718
7854
  for (const key of tableOnly) {
6719
7855
  if (context.value[key] !== undefined) context.issues.push({
@@ -6962,13 +8098,21 @@ const sessionSpecSchema = z.strictObject({
6962
8098
  access: sessionAccessSchema.optional(),
6963
8099
  blockedWhen: blockRuleSchema.optional(),
6964
8100
  onBlock: blockRotationSchema.optional(),
6965
- captcha: captchaSettingsSchema.optional()
8101
+ captcha: captchaSettingsSchema.optional(),
8102
+ browserProfile: z.string().regex(/^[\w-]+$/, 'a browser profile name is letters, digits, hyphens and underscores').optional()
8103
+ });
8104
+ const retryRuleSchema = z.strictObject({
8105
+ attempts: z.int().min(1).max(10).optional(),
8106
+ backoffMs: z.int().nonnegative().optional(),
8107
+ maxDelayMs: z.int().nonnegative().optional(),
8108
+ statuses: z.array(z.int().min(400).max(599)).optional()
6966
8109
  });
6967
8110
  const limitsSchema = z.strictObject({
6968
8111
  maxRecords: z.int().positive().optional(),
6969
8112
  delayMs: z.int().nonnegative().optional(),
6970
8113
  timeoutMs: z.int().positive().optional(),
6971
- concurrency: z.int().min(1).max(64).optional()
8114
+ concurrency: z.int().min(1).max(64).optional(),
8115
+ retry: retryRuleSchema.optional()
6972
8116
  });
6973
8117
  const inputRecipeSchema = z.strictObject({
6974
8118
  $schema: z.string().optional(),
@@ -7499,5 +8643,176 @@ function isSameOutput(document, output) {
7499
8643
  return recipeKindOf(document.content) === 'output' && document.content.id === output.id;
7500
8644
  }
7501
8645
 
7502
- export { ACCESS_PRESETS, AccessBroker, AccessConfigError, BrowserClient, BrowserSession, CaptchaError, DEFAULT_CAPTCHA_SELECTOR, 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, traceLine, tryParseJson, validateBinding, workbookText };
8646
+ const BOOKKEEPING = new Set(['_source', '_key']);
8647
+ /**
8648
+ * Compares two runs' records by key: what was added, what was removed, and
8649
+ * for the records in both, which fields changed, before and after. Like a
8650
+ * `git diff` of a price list, but row by row instead of line by line, so a
8651
+ * reordered file is not a change.
8652
+ *
8653
+ * @param previous - The earlier run's records.
8654
+ * @param current - The later run's records.
8655
+ * @param options - The key fields, the fields to ignore, the shrink threshold.
8656
+ * @returns The differences.
8657
+ * @throws Error when records carry no key: without `key`, every line needs `_key`.
8658
+ */
8659
+ function diffRecords(previous, current, options = {}) {
8660
+ const ignore = new Set([...BOOKKEEPING, ...(options.ignore ?? [])]);
8661
+ const keyOf = keyReader(options.key);
8662
+ let repeated = 0;
8663
+ const index = (records, side) => {
8664
+ const byKey = new Map();
8665
+ for (const [line, record] of records.entries()) {
8666
+ const key = keyOf(record, `${side} record ${line + 1}`);
8667
+ if (byKey.has(key)) repeated += 1;else byKey.set(key, record);
8668
+ }
8669
+ return byKey;
8670
+ };
8671
+ const before = index(previous, 'previous');
8672
+ const after = index(current, 'current');
8673
+ const removed = [];
8674
+ const changed = [];
8675
+ const added = [];
8676
+ let unchanged = 0;
8677
+ for (const [key, record] of before) {
8678
+ const now = after.get(key);
8679
+ if (now === undefined) {
8680
+ removed.push({
8681
+ change: 'removed',
8682
+ key,
8683
+ before: record
8684
+ });
8685
+ continue;
8686
+ }
8687
+ const fields = fieldChanges(record, now, ignore, '');
8688
+ if (fields.length === 0) unchanged += 1;else changed.push({
8689
+ change: 'changed',
8690
+ key,
8691
+ before: record,
8692
+ after: now,
8693
+ fields
8694
+ });
8695
+ }
8696
+ for (const [key, record] of after) {
8697
+ if (!before.has(key)) added.push({
8698
+ change: 'added',
8699
+ key,
8700
+ after: record
8701
+ });
8702
+ }
8703
+ const threshold = options.shrink ?? 0.5;
8704
+ const shrunk = previous.length > 0 && current.length < previous.length * (1 - threshold);
8705
+ return {
8706
+ added: added.length,
8707
+ removed: removed.length,
8708
+ changed: changed.length,
8709
+ unchanged,
8710
+ changes: [...removed, ...changed, ...added],
8711
+ repeated,
8712
+ counts: {
8713
+ previous: previous.length,
8714
+ current: current.length
8715
+ },
8716
+ ...(shrunk && {
8717
+ shrunk: {
8718
+ previous: previous.length,
8719
+ current: current.length
8720
+ }
8721
+ })
8722
+ };
8723
+ }
8724
+ /**
8725
+ * The diff options an output recipe implies: its key fields, and its fields
8726
+ * the engine fills differently every run (`generated: now`, `uuid`).
8727
+ *
8728
+ * @param output - The output recipe.
8729
+ * @returns The key and the fields to ignore.
8730
+ */
8731
+ function diffOptionsFor(output) {
8732
+ const fields = Object.entries(output.fields);
8733
+ return {
8734
+ key: fields.filter(([, field]) => field.key === true).map(([name]) => name),
8735
+ ignore: fields.filter(([, field]) => field.generated === 'now' || field.generated === 'uuid').map(([name]) => name)
8736
+ };
8737
+ }
8738
+ /**
8739
+ * A key as people read it: the key fields' values joined (`Pandina · 1.0 Hybrid`).
8740
+ *
8741
+ * @param key - A record key (a JSON array of the key values).
8742
+ * @returns The readable form.
8743
+ */
8744
+ function readableKey(key) {
8745
+ try {
8746
+ const values = JSON.parse(key);
8747
+ return Array.isArray(values) ? values.map(value => typeof value === 'string' ? value : JSON.stringify(value)).join(' · ') : key;
8748
+ } catch {
8749
+ return key;
8750
+ }
8751
+ }
8752
+ function keyReader(fields) {
8753
+ if (fields !== undefined && fields.length > 0) return record => JSON.stringify(fields.map(field => record[field] ?? null));
8754
+ return storedKey;
8755
+ }
8756
+ /** The `_key` a sink in append mode wrote on the line. */
8757
+ function storedKey(record, where) {
8758
+ const key = record._key;
8759
+ if (typeof key !== 'string') throw new Error(`${where} has no _key: name the fields that identify a record (--key model,version, or the output recipe's key fields)`);
8760
+ return key;
8761
+ }
8762
+ function fieldChanges(before, after, ignore, prefix) {
8763
+ const names = [...new Set([...Object.keys(before), ...Object.keys(after)])];
8764
+ const changes = [];
8765
+ for (const name of names) {
8766
+ const field = `${prefix}${name}`;
8767
+ if (ignore.has(field) || prefix === '' && ignore.has(name)) continue;
8768
+ const was = before[name];
8769
+ const is = after[name];
8770
+ if (isPlainObject(was) && isPlainObject(is)) changes.push(...fieldChanges(was, is, ignore, `${field}.`));else if (!sameValue(was, is)) changes.push({
8771
+ field,
8772
+ before: was ?? null,
8773
+ after: is ?? null
8774
+ });
8775
+ }
8776
+ return changes;
8777
+ }
8778
+ function isPlainObject(value) {
8779
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
8780
+ }
8781
+ /** Equal as JSON, whatever the order of object keys. */
8782
+ function sameValue(first, second) {
8783
+ return stableJson(first ?? null) === stableJson(second ?? null);
8784
+ }
8785
+ function stableJson(value) {
8786
+ if (Array.isArray(value)) return `[${value.map(entry => stableJson(entry)).join(',')}]`;
8787
+ if (isPlainObject(value)) return `{${Object.keys(value).sort((first, second) => first.localeCompare(second)).map(key => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(',')}}`;
8788
+ return JSON.stringify(value) ?? 'null';
8789
+ }
8790
+
8791
+ /**
8792
+ * The records of a JSON Lines file, as a sink wrote them.
8793
+ *
8794
+ * @param path - The file.
8795
+ * @returns One record per non-empty line.
8796
+ * @throws Error naming the file and the line that is not a JSON object.
8797
+ */
8798
+ async function readRecordsFile(path) {
8799
+ const text = await readFile(path, 'utf8');
8800
+ const records = [];
8801
+ for (const [index, line] of text.split(/\r?\n/).entries()) {
8802
+ if (line.trim() === '') continue;
8803
+ let value;
8804
+ try {
8805
+ value = JSON.parse(line);
8806
+ } catch (error) {
8807
+ throw new Error(`${path}:${index + 1}: not JSON (${error.message})`, {
8808
+ cause: error
8809
+ });
8810
+ }
8811
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new Error(`${path}:${index + 1}: not a record (a JSON object)`);
8812
+ records.push(value);
8813
+ }
8814
+ return records;
8815
+ }
8816
+
8817
+ 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, diffOptionsFor, diffRecords, fillDown, findDeckTables, findGridTables, findTables, htmlTableSheets, inputRecipeJsonSchema, inputRecipeSchema, isDeckDocument, isWorkbookDocument, isXmlDocument, jsonLinesSink, loadAccessConfig, loadRecipeSet, loadRecipes, memorySink, outputRecipeJsonSchema, outputRecipeSchema, parseCsv, parseInputRecipe, parseOutputRecipe, parseXml, pdfText, readMarkdown, readPdf, readRecipeSource, readRecordsFile, readYaml, readableKey, retryRuleSchema, selectXpath, takeFromXml, throttleConfigSchema, traceLine, tryParseJson, validateBinding, workbookText };
7503
8818
  //# sourceMappingURL=index.esm.js.map