@opencraw/core 0.1.3 → 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 (170) hide show
  1. package/dist/access/access-broker.use-case.d.ts +43 -0
  2. package/dist/access/access-config.error.d.ts +5 -0
  3. package/dist/access/access-config.repository.d.ts +11 -0
  4. package/dist/access/access-json-schema.mapper.d.ts +8 -0
  5. package/dist/access/access-plugin.contract.d.ts +48 -0
  6. package/dist/access/access-preset.store.d.ts +19 -0
  7. package/dist/access/access-profile.contract.d.ts +95 -0
  8. package/dist/access/index.d.ts +11 -0
  9. package/dist/access/render-access.mapper.d.ts +41 -0
  10. package/dist/access/session-id.algorithm.d.ts +8 -0
  11. package/dist/api-steps/extract-from-document.use-case.d.ts +41 -0
  12. package/dist/api-steps/index.d.ts +4 -0
  13. package/dist/api-steps/run-api-step.use-case.d.ts +20 -0
  14. package/dist/api-steps/send-request.use-case.d.ts +23 -0
  15. package/dist/browser-session/browser-profile.store.d.ts +52 -0
  16. package/dist/browser-session/browser-session.config.d.ts +21 -0
  17. package/dist/browser-session/browser.client.d.ts +75 -0
  18. package/dist/browser-session/index.d.ts +6 -0
  19. package/dist/captcha/captcha-budget.model.d.ts +21 -0
  20. package/dist/captcha/captcha-detection.client.d.ts +28 -0
  21. package/dist/captcha/captcha-guard.use-case.d.ts +64 -0
  22. package/dist/captcha/captcha-solver-registry.store.d.ts +19 -0
  23. package/dist/captcha/captcha-solver.contract.d.ts +47 -0
  24. package/dist/captcha/captcha.error.d.ts +13 -0
  25. package/dist/captcha/index.d.ts +10 -0
  26. package/dist/captcha/resolve-captcha.use-case.d.ts +40 -0
  27. package/dist/crawl-events/crawl-event.contract.d.ts +169 -0
  28. package/dist/crawl-events/event-bus.store.d.ts +10 -0
  29. package/dist/crawl-events/index.d.ts +4 -0
  30. package/dist/crawl-events/trace-line.mapper.d.ts +14 -0
  31. package/dist/crawl-execution/bootstrap-session.use-case.d.ts +79 -0
  32. package/dist/crawl-execution/crawl-options.config.d.ts +67 -0
  33. package/dist/crawl-execution/crawl-report.model.d.ts +33 -0
  34. package/dist/crawl-execution/create-crawler.use-case.d.ts +20 -0
  35. package/dist/crawl-execution/index.d.ts +8 -0
  36. package/dist/crawl-execution/rotating-runner.use-case.d.ts +66 -0
  37. package/dist/crawl-execution/run-crawl.use-case.d.ts +17 -0
  38. package/dist/crawl-execution/run-input-recipe.use-case.d.ts +51 -0
  39. package/dist/deck-document/deck-document.model.d.ts +58 -0
  40. package/dist/deck-document/deck-table.algorithm.d.ts +35 -0
  41. package/dist/deck-document/index.d.ts +6 -0
  42. package/dist/deck-document/read-pptx.client.d.ts +16 -0
  43. package/dist/docx-document/index.d.ts +2 -0
  44. package/dist/docx-document/read-docx-html.client.d.ts +23 -0
  45. package/dist/extraction-scope/extraction-scope.model.d.ts +77 -0
  46. package/dist/extraction-scope/index.d.ts +5 -0
  47. package/dist/extraction-scope/live-element.model.d.ts +28 -0
  48. package/dist/hooks/hook-registry.store.d.ts +16 -0
  49. package/dist/hooks/hook.contract.d.ts +17 -0
  50. package/dist/hooks/hook.error.d.ts +7 -0
  51. package/dist/hooks/index.d.ts +4 -0
  52. package/dist/http-session/http-response.contract.d.ts +58 -0
  53. package/dist/http-session/http.client.d.ts +42 -0
  54. package/dist/http-session/index.d.ts +5 -0
  55. package/dist/http-session/text-decoding.algorithm.d.ts +35 -0
  56. package/dist/index.esm.js +649 -25
  57. package/dist/markdown-document/index.d.ts +3 -0
  58. package/dist/markdown-document/read-markdown.client.d.ts +39 -0
  59. package/dist/output-mapping/coerce-field.mapper.d.ts +19 -0
  60. package/dist/output-mapping/generated-field.mapper.d.ts +16 -0
  61. package/dist/output-mapping/index.d.ts +9 -0
  62. package/dist/output-mapping/map-record.use-case.d.ts +27 -0
  63. package/dist/output-mapping/mapping.error.d.ts +17 -0
  64. package/dist/output-mapping/missing-value.policy.d.ts +14 -0
  65. package/dist/output-mapping/output-field.validator.d.ts +10 -0
  66. package/dist/output-mapping/output-record.model.d.ts +20 -0
  67. package/dist/pdf-document/index.d.ts +7 -0
  68. package/dist/pdf-document/pdf-document.model.d.ts +51 -0
  69. package/dist/pdf-document/pdf-table.algorithm.d.ts +43 -0
  70. package/dist/pdf-document/read-pdf.client.d.ts +18 -0
  71. package/dist/pdf-document/row-assembly.algorithm.d.ts +26 -0
  72. package/dist/recipe-loading/index.d.ts +9 -0
  73. package/dist/recipe-loading/load-recipe-set.use-case.d.ts +45 -0
  74. package/dist/recipe-loading/read-recipe-source.use-case.d.ts +14 -0
  75. package/dist/recipe-loading/recipe-binding.error.d.ts +14 -0
  76. package/dist/recipe-loading/recipe-binding.validator.d.ts +34 -0
  77. package/dist/recipe-loading/recipe-file.repository.d.ts +11 -0
  78. package/dist/recipe-loading/recipe-set.model.d.ts +12 -0
  79. package/dist/recipe-loading/recipe-source.contract.d.ts +28 -0
  80. package/dist/recipe-loading/recipe-text.mapper.d.ts +14 -0
  81. package/dist/recipe-schema/index.d.ts +15 -0
  82. package/dist/recipe-schema/input-recipe.contract.d.ts +156 -0
  83. package/dist/recipe-schema/json-schema.mapper.d.ts +15 -0
  84. package/dist/recipe-schema/output-recipe.contract.d.ts +47 -0
  85. package/dist/recipe-schema/recipe-kind.enum.d.ts +35 -0
  86. package/dist/recipe-schema/recipe-validation.error.d.ts +17 -0
  87. package/dist/recipe-schema/recipe.validator.d.ts +28 -0
  88. package/dist/recipe-schema/step.contract.d.ts +206 -0
  89. package/dist/recipe-schema/transform-rule.contract.d.ts +120 -0
  90. package/dist/record-diff/index.d.ts +4 -0
  91. package/dist/record-diff/record-diff.algorithm.d.ts +82 -0
  92. package/dist/record-diff/records-file.repository.d.ts +10 -0
  93. package/dist/record-sink/dedupe.policy.d.ts +29 -0
  94. package/dist/record-sink/index.d.ts +9 -0
  95. package/dist/record-sink/json-lines-sink.repository.d.ts +19 -0
  96. package/dist/record-sink/memory-sink.repository.d.ts +9 -0
  97. package/dist/record-sink/record-sink.contract.d.ts +17 -0
  98. package/dist/selection/html-selector.algorithm.d.ts +19 -0
  99. package/dist/selection/index.d.ts +8 -0
  100. package/dist/selection/json-path.algorithm.d.ts +9 -0
  101. package/dist/selection/json-text.algorithm.d.ts +58 -0
  102. package/dist/selection/regex.algorithm.d.ts +12 -0
  103. package/dist/selection/take-value.mapper.d.ts +27 -0
  104. package/dist/src/api-steps/extract-from-document.use-case.d.ts +5 -5
  105. package/dist/src/docx-document/index.d.ts +2 -0
  106. package/dist/src/docx-document/read-docx-html.client.d.ts +23 -0
  107. package/dist/src/extraction-scope/extraction-scope.model.d.ts +2 -1
  108. package/dist/src/http-session/http-response.contract.d.ts +2 -1
  109. package/dist/src/index.d.ts +4 -0
  110. package/dist/src/markdown-document/index.d.ts +1 -1
  111. package/dist/src/markdown-document/read-markdown.client.d.ts +10 -0
  112. package/dist/src/recipe-schema/recipe-kind.enum.d.ts +1 -1
  113. package/dist/src/recipe-schema/step.contract.d.ts +4 -0
  114. package/dist/src/record-diff/index.d.ts +4 -0
  115. package/dist/src/record-diff/record-diff.algorithm.d.ts +82 -0
  116. package/dist/src/record-diff/records-file.repository.d.ts +10 -0
  117. package/dist/src/selection/html-selector.algorithm.d.ts +2 -1
  118. package/dist/src/xml-document/index.d.ts +6 -0
  119. package/dist/src/xml-document/xml-document.model.d.ts +13 -0
  120. package/dist/src/xml-document/xml-parser.client.d.ts +49 -0
  121. package/dist/src/xml-document/xpath.algorithm.d.ts +30 -0
  122. package/dist/step-flow/block-rule.policy.d.ts +21 -0
  123. package/dist/step-flow/blocked.error.d.ts +9 -0
  124. package/dist/step-flow/for-each.use-case.d.ts +20 -0
  125. package/dist/step-flow/host-throttle.policy.d.ts +49 -0
  126. package/dist/step-flow/index.d.ts +15 -0
  127. package/dist/step-flow/paginate.use-case.d.ts +17 -0
  128. package/dist/step-flow/retry.policy.d.ts +21 -0
  129. package/dist/step-flow/run-gate.policy.d.ts +50 -0
  130. package/dist/step-flow/run-steps.use-case.d.ts +39 -0
  131. package/dist/step-flow/step-failure.error.d.ts +14 -0
  132. package/dist/step-flow/step-runner.contract.d.ts +52 -0
  133. package/dist/step-flow/transport-retry.policy.d.ts +76 -0
  134. package/dist/template/expression.algorithm.d.ts +53 -0
  135. package/dist/template/index.d.ts +7 -0
  136. package/dist/template/template.algorithm.d.ts +53 -0
  137. package/dist/template/value-path.algorithm.d.ts +29 -0
  138. package/dist/template/value-text.algorithm.d.ts +18 -0
  139. package/dist/transformation/apply-transform-chain.use-case.d.ts +17 -0
  140. package/dist/transformation/collection.algorithm.d.ts +15 -0
  141. package/dist/transformation/currency.algorithm.d.ts +15 -0
  142. package/dist/transformation/date.algorithm.d.ts +13 -0
  143. package/dist/transformation/index.d.ts +10 -0
  144. package/dist/transformation/lookup.algorithm.d.ts +24 -0
  145. package/dist/transformation/number.algorithm.d.ts +30 -0
  146. package/dist/transformation/string.algorithm.d.ts +18 -0
  147. package/dist/transformation/transform-registry.store.d.ts +36 -0
  148. package/dist/transformation/transform.error.d.ts +8 -0
  149. package/dist/transformation/url.algorithm.d.ts +20 -0
  150. package/dist/web-steps/evaluate-script.use-case.d.ts +10 -0
  151. package/dist/web-steps/extract-from-page.use-case.d.ts +10 -0
  152. package/dist/web-steps/index.d.ts +2 -0
  153. package/dist/web-steps/interact.use-case.d.ts +36 -0
  154. package/dist/web-steps/navigate.use-case.d.ts +15 -0
  155. package/dist/web-steps/run-web-step.use-case.d.ts +37 -0
  156. package/dist/web-steps/snapshot-elements.use-case.d.ts +13 -0
  157. package/dist/workbook-document/csv-parser.algorithm.d.ts +26 -0
  158. package/dist/workbook-document/csv-workbook.mapper.d.ts +24 -0
  159. package/dist/workbook-document/grid-table.algorithm.d.ts +53 -0
  160. package/dist/workbook-document/html-tables.mapper.d.ts +14 -0
  161. package/dist/workbook-document/index.d.ts +9 -0
  162. package/dist/workbook-document/read-xlsx.client.d.ts +18 -0
  163. package/dist/workbook-document/workbook-document.model.d.ts +51 -0
  164. package/dist/xml-document/index.d.ts +6 -0
  165. package/dist/xml-document/xml-document.model.d.ts +13 -0
  166. package/dist/xml-document/xml-parser.client.d.ts +49 -0
  167. package/dist/xml-document/xpath.algorithm.d.ts +30 -0
  168. package/dist/yaml-document/index.d.ts +3 -0
  169. package/dist/yaml-document/read-yaml.client.d.ts +25 -0
  170. package/package.json +4 -2
package/dist/index.esm.js CHANGED
@@ -7,6 +7,9 @@ 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. */
@@ -2988,10 +2991,13 @@ const DOCUMENT = /^\s*(?:<!doctype|<html)/i;
2988
2991
  *
2989
2992
  * @param html - The markup (a whole document or a fragment).
2990
2993
  * @param selector - A CSS selector.
2994
+ * @param xml - Read the markup as XML: names keep their case, `take: "html"` gives XML.
2991
2995
  * @returns Every match, in document order.
2992
2996
  */
2993
- function selectHtml(html, selector) {
2994
- 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);
2995
3001
  return api(selector).map((_, element) => ({
2996
3002
  api,
2997
3003
  element: api(element)
@@ -4038,8 +4044,8 @@ function filledGrid(sheet) {
4038
4044
  /** `B10:B13` as 0-based bounds; `undefined` for anything else. */
4039
4045
  function rangeOf(reference) {
4040
4046
  const [from, to = from] = reference.split(':', 2);
4041
- const start = cellOf(from);
4042
- const end = cellOf(to);
4047
+ const start = cellOf$1(from);
4048
+ const end = cellOf$1(to);
4043
4049
  if (start === undefined || end === undefined) return undefined;
4044
4050
  return {
4045
4051
  top: Math.min(start.row, end.row),
@@ -4048,7 +4054,7 @@ function rangeOf(reference) {
4048
4054
  right: Math.max(start.column, end.column)
4049
4055
  };
4050
4056
  }
4051
- function cellOf(reference) {
4057
+ function cellOf$1(reference) {
4052
4058
  const match = /^\$?([A-Z]+)\$?(\d+)$/i.exec(reference.trim());
4053
4059
  if (match === null) return undefined;
4054
4060
  const letters = match[1].toUpperCase();
@@ -4181,13 +4187,194 @@ function shapeTables(document, slides, query) {
4181
4187
  }));
4182
4188
  }
4183
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
+
4184
4371
  /**
4185
4372
  * Runs an `extract` step against a static document: the value bound under
4186
- * `from`, else the scope's current document. `css` reads HTML, `jsonpath`
4187
- * reads JSON (or a read PDF, workbook or deck as data), `table` reads the
4188
- * tables of a PDF, a workbook (a spreadsheet, a CSV), a deck (a presentation)
4189
- * or HTML (its `<table>`s), `regex` reads any document as text; `xpath` needs
4190
- * a live page and is refused here.
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.
4191
4378
  *
4192
4379
  * A `jsonpath` extract whose `from` is text parses that text as JSON, and a
4193
4380
  * list of texts (every `<script type="application/ld+json">` of a page) becomes
@@ -4206,6 +4393,7 @@ function extractFromDocument(step, scope) {
4206
4393
  switch (step.kind) {
4207
4394
  case 'jsonpath':
4208
4395
  {
4396
+ if (document.kind === 'xml') throw new Error('jsonpath needs a JSON document; the current document is xml (read it with kind "xpath")');
4209
4397
  if (document.kind === 'html' || document.kind === 'text') throw new Error(`jsonpath needs a JSON document; the current document is ${document.kind}`);
4210
4398
  values = selectJson(document.kind === 'json' ? document.data : document, selector).map(node => takeFromJson(node, take));
4211
4399
  break;
@@ -4217,10 +4405,22 @@ function extractFromDocument(step, scope) {
4217
4405
  }
4218
4406
  case 'css':
4219
4407
  {
4408
+ if (document.kind === 'xml') {
4409
+ values = selectHtml(document.xml, selector, true).map(match => takeFromHtml(match, take));
4410
+ break;
4411
+ }
4220
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")' : ''}`);
4221
4413
  values = selectHtml(document.html, selector).map(match => takeFromHtml(match, take));
4222
4414
  break;
4223
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
+ }
4224
4424
  case 'regex':
4225
4425
  {
4226
4426
  values = selectRegex(textOf$1(document), selector);
@@ -4228,7 +4428,7 @@ function extractFromDocument(step, scope) {
4228
4428
  }
4229
4429
  default:
4230
4430
  {
4231
- 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)}`);
4232
4432
  }
4233
4433
  }
4234
4434
  if (step.many === true) {
@@ -4334,9 +4534,32 @@ function patternOf(source, where) {
4334
4534
  });
4335
4535
  }
4336
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
+ }
4337
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). */
4338
4560
  function textOf$1(document) {
4339
4561
  if (document.kind === 'html') return document.html;
4562
+ if (document.kind === 'xml') return document.xml;
4340
4563
  if (document.kind === 'text') return document.text;
4341
4564
  if (document.kind === 'pdf') return pdfText(document);
4342
4565
  if (document.kind === 'workbook') return workbookText(document);
@@ -4352,7 +4575,11 @@ function documentFor(step, scope) {
4352
4575
  }
4353
4576
  const source = scope.get(step.from);
4354
4577
  if (source === undefined) throw new Error(`"${step.from}" is not bound`);
4355
- 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
+ }
4356
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"`);
4357
4584
  if (step.kind === 'regex') {
4358
4585
  if (typeof source === 'string') return {
@@ -4441,6 +4668,12 @@ async function readYaml(text, source, scalars = 'typed') {
4441
4668
  };
4442
4669
  }
4443
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);
4444
4677
  /** A leading `---` block of YAML. */
4445
4678
  const FRONT_MATTER = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/;
4446
4679
  /**
@@ -4474,14 +4707,22 @@ async function readMarkdown(text, source) {
4474
4707
  async: false
4475
4708
  });
4476
4709
  const sections = sectioned(rendered);
4477
- 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>`;
4478
4711
  return {
4479
4712
  html: `<!doctype html><html><head>${head}</head><body>${sections}</body></html>`,
4480
4713
  frontMatter: front?.data,
4481
4714
  warnings: front?.warnings ?? []
4482
4715
  };
4483
4716
  }
4484
- /** 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
+ */
4485
4726
  function sectioned(html) {
4486
4727
  const $ = load(html, null, false);
4487
4728
  const open = [];
@@ -4502,7 +4743,7 @@ function sectioned(html) {
4502
4743
  const heading = $(node);
4503
4744
  const text = heading.text().replaceAll(/\s+/g, ' ').trim();
4504
4745
  heading.attr('id', uniqueSlug(text, used));
4505
- out += `<section data-heading="${escapeAttribute(text)}" data-level="${depth}">${$.html(node)}`;
4746
+ out += `<section data-heading="${escapeAttribute$1(text)}" data-level="${depth}">${$.html(node)}`;
4506
4747
  open.push(depth);
4507
4748
  }
4508
4749
  return out + '</section>'.repeat(open.length);
@@ -4514,10 +4755,165 @@ function uniqueSlug(text, used) {
4514
4755
  used.set(slug, count + 1);
4515
4756
  return count === 0 ? slug : `${slug}-${count}`;
4516
4757
  }
4517
- function escapeAttribute(text) {
4758
+ function escapeAttribute$1(text) {
4518
4759
  return text.replaceAll('&', '&amp;').replaceAll('"', '&quot;').replaceAll('<', '&lt;');
4519
4760
  }
4520
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
+
4521
4917
  /** A response with a 4xx or 5xx status. */
4522
4918
  class HttpError extends Error {
4523
4919
  status;
@@ -4677,7 +5073,7 @@ class HttpClient {
4677
5073
  }
4678
5074
  async function readBody(response, httpRequest) {
4679
5075
  const contentType = response.headers()['content-type'] ?? '';
4680
- const format = httpRequest.as ?? formatFromContentType(contentType);
5076
+ const format = httpRequest.as ?? formatFromContentType(contentType, response.url());
4681
5077
  return parseBody(format, await response.body(), response.url(), {
4682
5078
  ...httpRequest,
4683
5079
  charset: charsetOf(contentType)
@@ -4695,7 +5091,7 @@ async function readLocalFile(httpRequest) {
4695
5091
  body,
4696
5092
  warnings,
4697
5093
  format
4698
- } = await parseBody(httpRequest.as ?? formatFromExtension(extname(path)), bytes, httpRequest.url, httpRequest);
5094
+ } = await parseBody(httpRequest.as ?? formatFromExtension(path), bytes, httpRequest.url, httpRequest);
4699
5095
  return {
4700
5096
  status: 200,
4701
5097
  url: httpRequest.url,
@@ -4725,6 +5121,35 @@ async function parseBody(format, bytes, url, reading) {
4725
5121
  format
4726
5122
  };
4727
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
+ };
4728
5153
  if (format === 'markdown') {
4729
5154
  const {
4730
5155
  text
@@ -4790,24 +5215,31 @@ function looksLikeJsonLines(text) {
4790
5215
  const first = parseJsonLike(lines[0]);
4791
5216
  return 'value' in first;
4792
5217
  }
4793
- function formatFromContentType(contentType) {
5218
+ function formatFromContentType(contentType, url) {
4794
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';
4795
5222
  if (CSV_TYPES.has(type)) return 'csv';
4796
5223
  if (JSON_LINES_TYPES.has(type)) return 'jsonl';
4797
5224
  // A legacy .xls or .ppt goes to the Office reader too, which says what to do with it.
4798
5225
  if (type.includes('spreadsheetml') || type.startsWith('application/vnd.ms-excel')) return 'xlsx';
4799
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';
4800
5228
  if (YAML_TYPES.has(type)) return 'yaml';
4801
5229
  if (type === 'text/markdown' || type === 'text/x-markdown') return 'markdown';
4802
5230
  if (type.includes('json')) return 'json';
4803
5231
  if (type.includes('pdf')) return 'pdf';
4804
- if (type.includes('html') || type.includes('xml')) return 'html';
5232
+ if (type.includes('html')) return 'html';
5233
+ if (type.includes('xml')) return 'xml';
4805
5234
  return 'text';
4806
5235
  }
4807
5236
  const JSON_LINES_TYPES = new Set(['application/x-ndjson', 'application/ndjson', 'application/jsonl', 'application/x-jsonlines', 'application/jsonlines']);
4808
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']);
4809
5239
  const CSV_TYPES = new Set(['text/csv', 'application/csv', 'text/x-csv', 'application/x-csv', 'text/comma-separated-values', 'text/tab-separated-values']);
4810
- function formatFromExtension(extension) {
5240
+ function formatFromExtension(path) {
5241
+ if (/\.xml\.gz$/i.test(path)) return 'xml';
5242
+ const extension = extname(path);
4811
5243
  const formats = {
4812
5244
  '.json': 'json',
4813
5245
  '.jsonl': 'jsonl',
@@ -4822,13 +5254,21 @@ function formatFromExtension(extension) {
4822
5254
  '.pptm': 'pptx',
4823
5255
  '.ppsx': 'pptx',
4824
5256
  '.ppt': 'pptx',
5257
+ '.docx': 'docx',
5258
+ '.docm': 'docx',
5259
+ '.dotx': 'docx',
5260
+ '.doc': 'docx',
4825
5261
  '.yaml': 'yaml',
4826
5262
  '.yml': 'yaml',
4827
5263
  '.md': 'markdown',
4828
5264
  '.markdown': 'markdown',
4829
5265
  '.html': 'html',
4830
5266
  '.htm': 'html',
4831
- '.xml': 'html'
5267
+ '.xml': 'xml',
5268
+ '.rss': 'xml',
5269
+ '.atom': 'xml',
5270
+ '.kml': 'xml',
5271
+ '.gpx': 'xml'
4832
5272
  };
4833
5273
  return formats[extension.toLowerCase()] ?? 'text';
4834
5274
  }
@@ -4937,6 +5377,7 @@ function bodyText(body) {
4937
5377
  if (body.kind === 'pdf') return pdfText(body);
4938
5378
  if (body.kind === 'workbook') return workbookText(body);
4939
5379
  if (body.kind === 'deck') return deckText(body);
5380
+ if (body.kind === 'xml') return body.xml;
4940
5381
  return body.kind === 'html' ? body.html : body.text;
4941
5382
  }
4942
5383
  function renderMap(map, lookup) {
@@ -7189,7 +7630,7 @@ const CRAWL_MODES = ['web', 'api'];
7189
7630
  const SELECTOR_KINDS = ['css', 'xpath', 'jsonpath', 'regex', 'table'];
7190
7631
  /** `take` also accepts `attr:<name>`, which is validated by pattern rather than listed. */
7191
7632
  const TAKE_KINDS = ['text', 'html', 'value', 'json'];
7192
- 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'];
7193
7634
  const YAML_SCALARS = ['typed', 'text'];
7194
7635
  /** How a PDF table aligns a row's values against a cell wrapped over several lines. */
7195
7636
  const TABLE_ALIGNS = ['auto', 'top', 'center', 'bottom'];
@@ -7395,8 +7836,20 @@ const extractStep = z.strictObject({
7395
7836
  fillDown: z.array(z.string().min(1)).min(1).optional(),
7396
7837
  includeHidden: z.boolean().optional(),
7397
7838
  slide: z.string().min(1).optional(),
7398
- 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()
7399
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
+ }
7400
7853
  if (context.value.kind === 'table') return;
7401
7854
  for (const key of tableOnly) {
7402
7855
  if (context.value[key] !== undefined) context.issues.push({
@@ -8190,5 +8643,176 @@ function isSameOutput(document, output) {
8190
8643
  return recipeKindOf(document.content) === 'output' && document.content.id === output.id;
8191
8644
  }
8192
8645
 
8193
- export { ACCESS_PRESETS, AccessBroker, AccessConfigError, BrowserClient, BrowserSession, CaptchaError, DEFAULT_CAPTCHA_SELECTOR, DEFAULT_RETRY_RULE, HostThrottle, HttpClient, HttpError, MappingFailedError, PdfReadError, RecipeBindingError, RecipeSet, RecipeValidationError, RecordRejectedError, StepFailure, TransformError, UnknownHookError, accessConfigJsonSchema, accessConfigSchema, bindRecipeSet, createCrawler, csvWorkbook, deckText, detectChallenge, detectDelimiter, fillDown, findDeckTables, findGridTables, findTables, htmlTableSheets, inputRecipeJsonSchema, inputRecipeSchema, isDeckDocument, isWorkbookDocument, jsonLinesSink, loadAccessConfig, loadRecipeSet, loadRecipes, memorySink, outputRecipeJsonSchema, outputRecipeSchema, parseCsv, parseInputRecipe, parseOutputRecipe, pdfText, readMarkdown, readPdf, readRecipeSource, readYaml, retryRuleSchema, throttleConfigSchema, traceLine, tryParseJson, validateBinding, workbookText };
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 };
8194
8818
  //# sourceMappingURL=index.esm.js.map