@opencraw/cli 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.
package/README.md CHANGED
@@ -43,6 +43,10 @@ input recipes) and reports what happened.
43
43
  | `--dry-run` | One record per input recipe, printed with the scope it was mapped from — for checking a recipe under construction without a full run. |
44
44
  | `--trace` | Print the crawl trace to stderr. |
45
45
  | `--headed` | Show the browser instead of running headless. |
46
+ | `--parallel <n>` | Run this many input recipes at once (default 1). Iterations inside a recipe follow its `limits.concurrency`. See [§3.9 of the authoring guide](../../docs/recipes/authoring.md#39-concurrency). |
47
+ | `--retries <n>` | Tries per request that fails in passing (a dropped connection, a timeout, a 429, a 5xx), for recipes whose `limits.retry` says nothing. Default 3; `1` turns retrying off. See [§7 of the authoring guide](../../docs/recipes/authoring.md#7-policies-what-happens-when-something-is-missing-or-fails). |
48
+ | `--profiles <dir>` | Where the persistent browser profiles of `session.browserProfile` live (or `OPENCRAW_PROFILES`; default `.opencraw/profiles`). See [access.md](../../docs/recipes/access.md#persistent-browser-profiles). |
49
+ | `--host-delay <ms>`, `--host-concurrency <n>` | Per-site politeness across every recipe: at least `ms` between two requests to one site, at most `n` in flight. They override the access config's `throttle` defaults. See [access.md](../../docs/recipes/access.md#throttling-per-site). |
46
50
  | `--plugins <file>` (or `--hooks`) | A plugins module: named exports `hooks` (the recipes' hook steps and transforms), `accessPlugins` (for `{ kind: "plugin" }` access profiles) and `captchaSolvers`. A module whose default export is `{ name: function }` is read as hooks alone. `OPENCRAW_PLUGINS` / `OPENCRAW_HOOKS` when not given; `probe` reads it too. It runs as your code; load only files you trust. See [§6 of the authoring guide](../../docs/recipes/authoring.md#6-hooks). |
47
51
 
48
52
  ```sh
@@ -50,6 +54,33 @@ opencraw run recipes/ --out out/products.jsonl --trace
50
54
  opencraw run recipes/movie.output.json recipes/tmdb.input.json --dry-run
51
55
  ```
52
56
 
57
+ ### `diff`
58
+
59
+ ```sh
60
+ opencraw diff last-week.jsonl today.jsonl --key model,trim --ignore scrapedAt
61
+ opencraw run recipes/ --out today.jsonl --diff last-week.jsonl --changes changes.jsonl
62
+ ```
63
+
64
+ Compares two runs' records **by key**, not line by line, so a reordered file is no change:
65
+
66
+ ```text
67
+ 1 added, 1 removed, 2 changed, 38 unchanged (41 records before, 41 now)
68
+ - 600e · La Prima
69
+ ~ Avenger · Summit price.amount: 24950 → 23950; inStock: true → false
70
+ ~ Pandina · Hybrid title: "Pandina" → "Pandina Hybrid"
71
+ + Grande Panda · Icon
72
+ ```
73
+
74
+ - `diff <previous> <current>`: `--key` names the fields that identify a record (default: each line's `_key`,
75
+ which `--append` writes); `--ignore` leaves fields out; `--changes <file>` also writes every change as a
76
+ JSON line (`{ change, key, before?, after?, fields? }`).
77
+ - `run --diff <previous>` compares the records this run emits with a previous file, taking the key and the
78
+ fields to ignore (`generated: now` / `uuid`) from the output recipe. The file is read before the crawl, so it
79
+ may be `--out` itself: `run … --out prices.jsonl --diff prices.jsonl` keeps one file and reports each change.
80
+ Not with `--resume`, which skips records.
81
+ - A run that holds under half the records the previous one did gets a warning: when the source did not
82
+ shrink, the site changed and the recipe quietly stopped finding everything.
83
+
53
84
  ### `probe`
54
85
 
55
86
  Fetches a page and reports where its data lives: JSON-LD blocks, inline JSON objects, `.json` URLs
@@ -68,7 +99,10 @@ rows, and every row that looks like a table header, with the `selector` a `table
68
99
  also names the encoding and the delimiter it detected; for a presentation, its charts and the slides whose
69
100
  text boxes look like a table. JSON, JSON Lines and YAML show their structure instead, and every list of
70
101
  records with the `jsonpath` that walks it. An HTML page also lists its tables' header rows, and Markdown
71
- (a `.md` URL is read as Markdown even when served as `text/plain`) its sections and front matter keys.
102
+ (a `.md` URL is read as Markdown even when served as `text/plain`) its sections and front matter keys. XML
103
+ (a feed, a sitemap, an export) shows its namespaces with the `namespaces` line an `xpath` extract needs, the
104
+ elements that repeat (its records), and its structure; a sitemap says how many pages it lists. A Word document
105
+ (`.docx`) lists its sections and tables, like Markdown.
72
106
 
73
107
  ```sh
74
108
  opencraw probe https://example.com/price-list.pdf
package/dist/index.esm.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { parseArgs } from 'node:util';
2
+ import { writeFile } from 'node:fs/promises';
3
+ import { readableKey, readRecordsFile, diffRecords, loadAccessConfig, htmlTableSheets, AccessBroker, HttpClient, parseXml, BrowserClient, readRecipeSource, parseOutputRecipe, parseInputRecipe, bindRecipeSet, memorySink, jsonLinesSink, createCrawler, RecipeSet, diffOptionsFor, RecipeValidationError, RecipeBindingError, traceLine } from '@opencraw/core';
2
4
  import { resolve } from 'node:path';
3
5
  import { pathToFileURL } from 'node:url';
4
- import { loadAccessConfig, htmlTableSheets, AccessBroker, HttpClient, BrowserClient, readRecipeSource, parseOutputRecipe, parseInputRecipe, bindRecipeSet, memorySink, jsonLinesSink, createCrawler, RecipeSet, RecipeValidationError, RecipeBindingError, traceLine } from '@opencraw/core';
5
6
  import { readFileSync } from 'node:fs';
6
7
 
7
8
  const USAGE = `opencraw <command> [options]
@@ -10,6 +11,7 @@ Commands
10
11
  validate <recipe files or directories...> Parse and bind the recipes; list every problem with its path.
11
12
  run <recipe files or directories...> Crawl. Records go to --out as JSON Lines, or to stdout.
12
13
  probe <url> Fetch a page and report where its data lives.
14
+ diff <previous.jsonl> <current.jsonl> Compare two runs' records by key: added, removed, changed.
13
15
 
14
16
  Options for run
15
17
  --out <file> Write records to this JSON Lines file (default: stdout).
@@ -19,6 +21,23 @@ Options for run
19
21
  --dry-run One record per input, printed with the scope it was mapped from.
20
22
  --trace Print the crawl trace to stderr.
21
23
  --headed Show the browser.
24
+ --parallel <n> Run this many input recipes at once (default 1). Iterations inside a recipe
25
+ follow its limits.concurrency.
26
+ --retries <n> Tries per request that fails in passing (connection, timeout, 429, 5xx), for
27
+ recipes whose limits.retry says nothing. Default 3; 1 turns retrying off.
28
+ --profiles <dir> Where recipes' session.browserProfile profiles live (or OPENCRAW_PROFILES;
29
+ default .opencraw/profiles).
30
+ --diff <file> Compare this run's records with a previous run's JSON Lines file (it may be
31
+ --out itself: it is read before the crawl). Keys come from the output recipe.
32
+ --host-delay <ms> At least this long between two requests to one site, across every recipe.
33
+ --host-concurrency <n>
34
+ At most this many requests to one site in flight, across every recipe.
35
+ Both override the access config's "throttle" defaults.
36
+
37
+ Options for diff
38
+ --key <a,b> The fields that identify a record (default: each line's _key).
39
+ --ignore <a,b> Fields left out of the comparison (a date the crawl stamps, say).
40
+ --changes <file> Also write every change as a JSON line (run --diff takes it too).
22
41
 
23
42
  Options for probe
24
43
  --browser Also render the page in a browser and list the JSON it fetches.
@@ -58,6 +77,33 @@ const OPTIONS = {
58
77
  'headed': {
59
78
  type: 'boolean'
60
79
  },
80
+ 'profiles': {
81
+ type: 'string'
82
+ },
83
+ 'retries': {
84
+ type: 'string'
85
+ },
86
+ 'parallel': {
87
+ type: 'string'
88
+ },
89
+ 'diff': {
90
+ type: 'string'
91
+ },
92
+ 'changes': {
93
+ type: 'string'
94
+ },
95
+ 'key': {
96
+ type: 'string'
97
+ },
98
+ 'ignore': {
99
+ type: 'string'
100
+ },
101
+ 'host-delay': {
102
+ type: 'string'
103
+ },
104
+ 'host-concurrency': {
105
+ type: 'string'
106
+ },
61
107
  'hooks': {
62
108
  type: 'string'
63
109
  },
@@ -139,9 +185,23 @@ function parseArguments(argv, env = {}) {
139
185
  paths: rest
140
186
  };
141
187
  }
188
+ case 'diff':
189
+ {
190
+ if (rest.length !== 2) throw new Error('diff needs two JSON Lines files: the previous run, then the current one');
191
+ return {
192
+ name: 'diff',
193
+ previous: rest[0],
194
+ current: rest[1],
195
+ key: list(values.key),
196
+ ignore: list(values.ignore),
197
+ changes: values.changes
198
+ };
199
+ }
142
200
  case 'run':
143
201
  {
144
202
  if (rest.length === 0) throw new Error('run needs at least one recipe file or directory');
203
+ if (values.diff !== undefined && values.resume === true) throw new Error('--diff compares the records a run emits, and --resume skips some: use one or the other');
204
+ if (values.changes !== undefined && values.diff === undefined) throw new Error('--changes needs --diff (or the diff command)');
145
205
  if (values.resume === true && values.append !== true) throw new Error('--resume needs --append (and --out)');
146
206
  if ((values.append === true || values.resume === true) && values.out === undefined) throw new Error('--append and --resume need --out');
147
207
  return {
@@ -154,6 +214,15 @@ function parseArguments(argv, env = {}) {
154
214
  dryRun: values['dry-run'] === true,
155
215
  only: values.only ?? [],
156
216
  headed: values.headed === true,
217
+ profiles: values.profiles ?? nonEmpty(env.OPENCRAW_PROFILES),
218
+ retries: integer(values.retries, '--retries', 1),
219
+ parallel: integer(values.parallel, '--parallel', 1),
220
+ diff: values.diff,
221
+ changes: values.changes,
222
+ throttle: {
223
+ delayMs: integer(values['host-delay'], '--host-delay', 0),
224
+ concurrency: integer(values['host-concurrency'], '--host-concurrency', 1)
225
+ },
157
226
  options
158
227
  };
159
228
  }
@@ -173,10 +242,84 @@ function parseArguments(argv, env = {}) {
173
242
  }
174
243
  }
175
244
  }
245
+ function integer(value, name, minimum) {
246
+ if (value === undefined) return undefined;
247
+ const number = Number(value);
248
+ if (!Number.isSafeInteger(number) || number < minimum) throw new Error(`${name} needs a whole number of at least ${minimum}, not "${value}"`);
249
+ return number;
250
+ }
251
+ function list(value) {
252
+ return value === undefined ? [] : value.split(',').map(entry => entry.trim()).filter(entry => entry !== '');
253
+ }
176
254
  function nonEmpty(value) {
177
255
  return value === '' ? undefined : value;
178
256
  }
179
257
 
258
+ /** Changes listed one by one, at most; the rest are counted. */
259
+ const LIST_LIMIT = 50;
260
+ /** A value longer than this is cut in the report. */
261
+ const VALUE_CHARS = 60;
262
+ /**
263
+ * A diff as people read it: the counts, a warning when the run lost most of
264
+ * its records, then one line per change, `-` removed, `~` changed (with each
265
+ * field's before → after), `+` added.
266
+ *
267
+ * @param diff - The diff.
268
+ * @param limit - Changes listed at most.
269
+ * @returns The lines.
270
+ */
271
+ function diffReport(diff, limit = LIST_LIMIT) {
272
+ const lines = [`${diff.added} added, ${diff.removed} removed, ${diff.changed} changed, ${diff.unchanged} unchanged (${diff.counts.previous} records before, ${diff.counts.current} now)`];
273
+ if (diff.shrunk !== undefined) lines.push(`! ${diff.shrunk.current} records where the previous run had ${diff.shrunk.previous}: if the source did not shrink, the site may have changed and the recipe no longer finds everything`);
274
+ if (diff.repeated > 0) lines.push(`! ${diff.repeated} records repeat a key already seen and were not compared: is the key complete?`);
275
+ for (const change of diff.changes.slice(0, limit)) {
276
+ const key = readableKey(change.key);
277
+ if (change.change === 'added') lines.push(`+ ${key}`);else if (change.change === 'removed') lines.push(`- ${key}`);else lines.push(`~ ${key} ${change.fields.map(field => fieldLine(field)).join('; ')}`);
278
+ }
279
+ if (diff.changes.length > limit) lines.push(`… and ${diff.changes.length - limit} more`);
280
+ return lines;
281
+ }
282
+ function fieldLine(change) {
283
+ return `${change.field}: ${shown(change.before)} → ${shown(change.after)}`;
284
+ }
285
+ function shown(value) {
286
+ const text = JSON.stringify(value) ?? 'null';
287
+ return text.length > VALUE_CHARS ? `${text.slice(0, VALUE_CHARS)}…` : text;
288
+ }
289
+
290
+ /**
291
+ * Compares two runs' JSON Lines files and prints what changed; with
292
+ * `--changes`, writes every change as a JSON line too.
293
+ *
294
+ * @param command - The parsed `diff` command.
295
+ * @param terminal - Where the report goes.
296
+ * @returns The exit code: 0, or 1 when a file cannot be read or has no key.
297
+ */
298
+ async function diffFiles(command, terminal) {
299
+ try {
300
+ const [previous, current] = await Promise.all([readRecordsFile(command.previous), readRecordsFile(command.current)]);
301
+ const diff = diffRecords(previous, current, {
302
+ key: command.key.length === 0 ? undefined : command.key,
303
+ ignore: command.ignore
304
+ });
305
+ for (const line of diffReport(diff)) terminal.out(line);
306
+ if (command.changes !== undefined) await writeChanges(command.changes, diff);
307
+ return 0;
308
+ } catch (error) {
309
+ terminal.err(error instanceof Error ? error.message : String(error));
310
+ return 1;
311
+ }
312
+ }
313
+ /**
314
+ * Writes a diff's changes as JSON Lines: `{ change, key, before?, after?, fields? }`.
315
+ *
316
+ * @param path - The file.
317
+ * @param diff - The diff.
318
+ */
319
+ async function writeChanges(path, diff) {
320
+ await writeFile(path, diff.changes.map(change => `${JSON.stringify(change)}\n`).join(''));
321
+ }
322
+
180
323
  /**
181
324
  * The access config a command runs with: the file `--access` (or
182
325
  * `OPENCRAW_ACCESS`) names, with `--access-profile` as its default.
@@ -555,10 +698,10 @@ const SECTION = /<section data-heading="([^"]*)" data-level="(\d)">/g;
555
698
  const FRONT_MATTER = /<script type="application\/json" data-front-matter>([\s\S]*?)<\/script>/;
556
699
  /**
557
700
  * Summarises an HTML document for a `table` extract, and, when it is rendered
558
- * Markdown, its outline and front matter.
701
+ * Markdown or a Word document, its outline (and Markdown's front matter).
559
702
  *
560
703
  * @param html - The document.
561
- * @param markdown - Whether it was rendered from Markdown.
704
+ * @param markdown - Whether it was rendered from Markdown or Word, and so has sections.
562
705
  * @returns The findings.
563
706
  */
564
707
  function describeHtml(html, markdown) {
@@ -594,13 +737,13 @@ function unescape(text) {
594
737
  }
595
738
 
596
739
  /** How deep a probe describes a JSON document. */
597
- const DEPTH = 4;
740
+ const DEPTH$1 = 4;
598
741
  /** Keys shown per object, at most. */
599
742
  const KEY_LIMIT = 25;
600
743
  /** Entries of a record list whose keys are compared. */
601
744
  const SAMPLE = 50;
602
745
  /** A sample value longer than this is cut. */
603
- const SAMPLE_CHARS = 40;
746
+ const SAMPLE_CHARS$1 = 40;
604
747
  /**
605
748
  * Summarises JSON data for someone writing a recipe: its shape, and where its
606
749
  * record lists are, so a `jsonpath` selector can be read off the result.
@@ -612,7 +755,7 @@ const SAMPLE_CHARS = 40;
612
755
  function describeJson(data, format) {
613
756
  const tree = [];
614
757
  const lists = [];
615
- walk(data, '$', 0, tree, lists);
758
+ walk$1(data, '$', 0, tree, lists);
616
759
  return {
617
760
  format,
618
761
  type: typeOf(data),
@@ -620,7 +763,7 @@ function describeJson(data, format) {
620
763
  lists: lists.sort((first, second) => second.length - first.length)
621
764
  };
622
765
  }
623
- function walk(value, path, depth, tree, lists) {
766
+ function walk$1(value, path, depth, tree, lists) {
624
767
  if (Array.isArray(value)) {
625
768
  const objects = value.filter(entry => isObject(entry));
626
769
  if (objects.length >= 2 && objects.length === value.length) lists.push({
@@ -629,13 +772,13 @@ function walk(value, path, depth, tree, lists) {
629
772
  keys: sharedKeys(objects.slice(0, SAMPLE))
630
773
  });
631
774
  if (depth > 0) tree.push(`${path} ${typeOf(value)}`);
632
- if (depth < DEPTH && value.length > 0) walk(value[0], `${path}[*]`, depth + 1, tree, lists);
775
+ if (depth < DEPTH$1 && value.length > 0) walk$1(value[0], `${path}[*]`, depth + 1, tree, lists);
633
776
  return;
634
777
  }
635
778
  if (isObject(value)) {
636
779
  if (depth > 0) tree.push(`${path} object`);
637
- if (depth >= DEPTH) return;
638
- for (const [key, child] of Object.entries(value).slice(0, KEY_LIMIT)) walk(child, childPath(path, key), depth + 1, tree, lists);
780
+ if (depth >= DEPTH$1) return;
781
+ for (const [key, child] of Object.entries(value).slice(0, KEY_LIMIT)) walk$1(child, childPath(path, key), depth + 1, tree, lists);
639
782
  return;
640
783
  }
641
784
  tree.push(`${path} ${typeOf(value)}${sample(value)}`);
@@ -651,7 +794,7 @@ function typeOf(value) {
651
794
  function sample(value) {
652
795
  if (value === null || value === undefined) return '';
653
796
  const text = JSON.stringify(value);
654
- return ` ${text.length > SAMPLE_CHARS ? `${text.slice(0, SAMPLE_CHARS)}…` : text}`;
797
+ return ` ${text.length > SAMPLE_CHARS$1 ? `${text.slice(0, SAMPLE_CHARS$1)}…` : text}`;
655
798
  }
656
799
  function sharedKeys(objects) {
657
800
  const [first, ...rest] = objects;
@@ -722,7 +865,7 @@ function escape(text) {
722
865
  * @returns The report.
723
866
  */
724
867
  function probeReport(url, status, findings, observed = [], html) {
725
- const sections = [section('Markdown front matter (in script[data-front-matter])', html?.frontMatter === undefined || html.frontMatter.length === 0 ? [] : [` keys: ${html.frontMatter.join(', ')}`]), section('Markdown sections (a "css" selector for each)', (html?.outline ?? []).map(entry => ` ${' '.repeat(entry.level - 1)}${entry.selector}`)), section('HTML tables (a "table" extract selector for each)', (html?.tables ?? []).map(table => ` ${table.table} ${table.selector}${table.hint === undefined ? '' : ` (${table.hint})`}\n ${table.text}`)), section('JSON-LD blocks', findings.jsonLd.map(block => ` type=${block.types}, ${block.keys} keys`)), section('Inline JSON (candidates)', findings.inlineJson.map(block => ` ${block.where}, ${block.size} chars, keys: ${block.keys.slice(0, 8).join(', ')}`)), section('.json URLs referenced', findings.jsonUrls.map(url_ => ` ${url_}`)), section('Script hosts', findings.scriptHosts.map(host => ` ${host}`)), section('Links that look like an API', findings.apiLinks.map(link => ` ${link}`)), observed.length > 0 ? section('JSON responses observed in the browser', observed.map(entry => ` ${entry}`)) : ''];
868
+ const sections = [section('Markdown front matter (in script[data-front-matter])', html?.frontMatter === undefined || html.frontMatter.length === 0 ? [] : [` keys: ${html.frontMatter.join(', ')}`]), section('Sections (a "css" selector for each)', (html?.outline ?? []).map(entry => ` ${' '.repeat(entry.level - 1)}${entry.selector}`)), section('HTML tables (a "table" extract selector for each)', (html?.tables ?? []).map(table => ` ${table.table} ${table.selector}${table.hint === undefined ? '' : ` (${table.hint})`}\n ${table.text}`)), section('JSON-LD blocks', findings.jsonLd.map(block => ` type=${block.types}, ${block.keys} keys`)), section('Inline JSON (candidates)', findings.inlineJson.map(block => ` ${block.where}, ${block.size} chars, keys: ${block.keys.slice(0, 8).join(', ')}`)), section('.json URLs referenced', findings.jsonUrls.map(url_ => ` ${url_}`)), section('Script hosts', findings.scriptHosts.map(host => ` ${host}`)), section('Links that look like an API', findings.apiLinks.map(link => ` ${link}`)), observed.length > 0 ? section('JSON responses observed in the browser', observed.map(entry => ` ${entry}`)) : ''];
726
869
  return [`${url} (HTTP ${status})`, '', ...sections].filter(line => line !== '').join('\n');
727
870
  }
728
871
  /**
@@ -776,6 +919,20 @@ function jsonReport(url, json) {
776
919
  }[json.format] ?? json.format;
777
920
  return [`${url} (${read}: ${json.type})`, '', section('Record lists (a "jsonpath" selector for each, largest first)', json.lists.map(list => ` ${list.path} ${list.length} entries\n keys: ${list.keys.join(', ')}`)), section('Structure', json.tree.map(line => ` ${line}`))].filter(line => line !== '').join('\n');
778
921
  }
922
+ /**
923
+ * Formats what a probe found in an XML document: its namespaces and the
924
+ * `namespaces` an `xpath` extract needs, the elements that repeat, and its structure.
925
+ *
926
+ * @param url - The document's URL.
927
+ * @param xml - The findings.
928
+ * @returns The report.
929
+ */
930
+ function xmlReport(url, xml) {
931
+ const defaultNamespace = xml.namespaces[''];
932
+ const namespaceRows = Object.entries(xml.namespaces).map(([prefix, uri]) => ` ${prefix === '' ? '(default)' : prefix} ${uri}`);
933
+ const hint = defaultNamespace === undefined ? [] : ['', ` The elements are in a default namespace: query with "namespaces": { "x": "${defaultNamespace}" } and //x:${xml.root},`, ' or with "ignoreNamespaces": true and plain names, as below.'];
934
+ return [`${url} (XML: ${xml.root}${xml.sitemap === undefined ? '' : `, a sitemap of ${xml.sitemap.locations} ${xml.sitemap.kind === 'urlset' ? 'pages' : 'sitemaps'}`})`, '', section('Namespaces', [...namespaceRows, ...hint]), section('Repeated elements (an "xpath" selector for each, largest first)', xml.lists.map(list => ` ${list.path} ${list.count} entries${list.children.length > 0 ? `\n children: ${list.children.join(', ')}` : ''}`)), section('Structure', xml.tree.map(line => ` ${line}`))].filter(line => line !== '').join('\n');
935
+ }
779
936
  function delimiterName(delimiter) {
780
937
  const names = {
781
938
  '\t': 'tabs',
@@ -790,6 +947,86 @@ function section(title, rows) {
790
947
  return [`${title}:`, ...rows, ''].join('\n');
791
948
  }
792
949
 
950
+ /** How deep a probe describes an XML document. */
951
+ const DEPTH = 5;
952
+ /** Distinct paths shown, at most. */
953
+ const PATH_LIMIT = 60;
954
+ /** A sample value longer than this is cut. */
955
+ const SAMPLE_CHARS = 40;
956
+ const ELEMENT = 1;
957
+ /**
958
+ * Summarises an XML document for someone writing a recipe: its namespaces
959
+ * (and the `namespaces` a query needs), the elements that repeat (the
960
+ * records a `forEach` walks), and its structure.
961
+ *
962
+ * @param document - A parsed document (`parseXml` from the core), read through the W3C DOM properties.
963
+ * @returns The findings.
964
+ */
965
+ function describeXml(document) {
966
+ const root = document.documentElement;
967
+ if (root === null) return {
968
+ root: '',
969
+ namespaces: {},
970
+ lists: [],
971
+ tree: []
972
+ };
973
+ const paths = new Map();
974
+ walk(root, `/${root.localName ?? ''}`, 1, paths);
975
+ const namespaces = Object.fromEntries(Array.from(root.attributes ?? [], attribute => attribute).filter(attribute => attribute.name === 'xmlns' || attribute.prefix === 'xmlns').map(attribute => [attribute.name === 'xmlns' ? '' : attribute.localName, attribute.value]));
976
+ const lists = [...paths].filter(([, info]) => info.maxSiblings >= 2).map(([path, info]) => ({
977
+ path: `//${path.split('/').slice(-2).join('/')}`.replace(/^\/\/\//, '//'),
978
+ count: info.count,
979
+ children: [...info.children]
980
+ })).sort((first, second) => second.count - first.count);
981
+ const tree = [...paths].slice(0, PATH_LIMIT).map(([path, info]) => `${path}${info.count > 1 ? ` ×${info.count}` : ''}${info.attributes.size > 0 ? ` ${[...info.attributes].map(name => `@${name}`).join(' ')}` : ''}${info.sample === undefined ? '' : ` ${JSON.stringify(info.sample)}`}`);
982
+ const name = root.localName ?? '';
983
+ const sitemap = name === 'urlset' || name === 'sitemapindex' ? {
984
+ kind: name,
985
+ locations: paths.get(`/${name}/${name === 'urlset' ? 'url' : 'sitemap'}/loc`)?.count ?? 0
986
+ } : undefined;
987
+ return {
988
+ root: name,
989
+ namespaces,
990
+ lists,
991
+ tree,
992
+ ...(sitemap !== undefined && {
993
+ sitemap
994
+ })
995
+ };
996
+ }
997
+ function walk(element, path, depth, paths) {
998
+ const info = paths.get(path) ?? {
999
+ count: 0,
1000
+ attributes: new Set(),
1001
+ children: new Set(),
1002
+ maxSiblings: 1
1003
+ };
1004
+ paths.set(path, info);
1005
+ info.count += 1;
1006
+ const attributes = element.attributes ?? [];
1007
+ for (const attribute of attributes) {
1008
+ if (attribute.name !== 'xmlns' && attribute.prefix !== 'xmlns') info.attributes.add(attribute.name);
1009
+ }
1010
+ const children = [...element.childNodes].filter(child => child.nodeType === ELEMENT);
1011
+ if (children.length === 0) {
1012
+ const text = (element.textContent ?? '').replaceAll(/\s+/g, ' ').trim();
1013
+ if (text !== '' && info.sample === undefined) info.sample = text.length > SAMPLE_CHARS ? `${text.slice(0, SAMPLE_CHARS)}…` : text;
1014
+ return;
1015
+ }
1016
+ const siblings = new Map();
1017
+ for (const child of children) {
1018
+ const name = child.localName ?? '';
1019
+ info.children.add(name);
1020
+ siblings.set(name, (siblings.get(name) ?? 0) + 1);
1021
+ }
1022
+ for (const child of children) {
1023
+ const childPath = `${path}/${child.localName ?? ''}`;
1024
+ if (depth < DEPTH) walk(child, childPath, depth + 1, paths);
1025
+ const childInfo = paths.get(childPath);
1026
+ if (childInfo !== undefined) childInfo.maxSiblings = Math.max(childInfo.maxSiblings, siblings.get(child.localName ?? '') ?? 1);
1027
+ }
1028
+ }
1029
+
793
1030
  const BROWSER_USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0 Safari/537.36';
794
1031
  const OBSERVE_MS = 4000;
795
1032
  /**
@@ -861,10 +1098,17 @@ async function probeUrl(url, options) {
861
1098
  observed: [],
862
1099
  json: describeJson(body.data, response.format ?? 'json')
863
1100
  };
1101
+ if (body.kind === 'xml') return {
1102
+ url: response.url,
1103
+ status: response.status,
1104
+ findings: findData(''),
1105
+ observed: [],
1106
+ xml: describeXml(parseXml(body.xml, response.url))
1107
+ };
864
1108
  const text = body.kind === 'html' ? body.html : body.text;
865
1109
  const observed = options.browser && !target.startsWith('file:') ? await observeBrowserJson(url, options, lease) : [];
866
1110
  const html = body.kind === 'html' ? {
867
- html: describeHtml(body.html, response.format === 'markdown')
1111
+ html: describeHtml(body.html, response.format === 'markdown' || response.format === 'docx')
868
1112
  } : {};
869
1113
  return {
870
1114
  url: response.url,
@@ -930,6 +1174,7 @@ function reportOf(result) {
930
1174
  if (result.workbook !== undefined) return workbookReport(result.url, result.workbook);
931
1175
  if (result.deck !== undefined) return deckReport(result.url, result.deck);
932
1176
  if (result.json !== undefined) return jsonReport(result.url, result.json);
1177
+ if (result.xml !== undefined) return xmlReport(result.url, result.xml);
933
1178
  return probeReport(result.url, result.status, result.findings, result.observed, result.html);
934
1179
  }
935
1180
 
@@ -1005,9 +1250,13 @@ async function runRecipes(command, terminal) {
1005
1250
  }
1006
1251
  let access;
1007
1252
  let plugins;
1253
+ let previous;
1254
+ const emitted = [];
1008
1255
  try {
1009
1256
  access = await resolveAccess(command.options);
1010
1257
  plugins = command.options.plugins === undefined ? undefined : await loadPlugins(command.options.plugins);
1258
+ // Read before the crawl: --diff may name the very file --out is about to overwrite.
1259
+ previous = command.diff === undefined ? undefined : await readRecordsFile(command.diff);
1011
1260
  } catch (error) {
1012
1261
  terminal.err(error instanceof Error ? error.message : String(error));
1013
1262
  return 1;
@@ -1018,6 +1267,12 @@ async function runRecipes(command, terminal) {
1018
1267
  const crawler = createCrawler({
1019
1268
  sink,
1020
1269
  access,
1270
+ throttle: throttleFor(command, access?.throttle),
1271
+ profilesDir: command.profiles,
1272
+ parallel: command.parallel,
1273
+ retry: command.retries === undefined ? undefined : {
1274
+ attempts: command.retries
1275
+ },
1021
1276
  hooks: plugins?.hooks,
1022
1277
  accessPlugins: plugins?.accessPlugins,
1023
1278
  captchaSolvers: plugins?.captchaSolvers,
@@ -1028,7 +1283,10 @@ async function runRecipes(command, terminal) {
1028
1283
  executablePath: command.options.browserPath,
1029
1284
  ignoreHTTPSErrors: command.options.insecureTls
1030
1285
  },
1031
- onEvent: event => onEvent(event, command, terminal)
1286
+ onEvent: event => {
1287
+ if (previous !== undefined && event.type === 'record:emit') emitted.push(event.data);
1288
+ onEvent(event, command, terminal);
1289
+ }
1032
1290
  });
1033
1291
  try {
1034
1292
  const inputs = command.dryRun ? set.inputs.map(input => ({
@@ -1044,11 +1302,34 @@ async function runRecipes(command, terminal) {
1044
1302
  terminal.err(`${recipe.recipeId}: ${recipe.emitted} emitted, ${recipe.rejected} rejected, ${recipe.duplicates} duplicates, ${recipe.skipped} skipped, ${recipe.stepsSkipped > 0 ? `${recipe.stepsSkipped} steps skipped (see --trace), ` : ''}${recipe.pages} pages, ${recipe.durationMs} ms${recipe.error === undefined ? '' : `, stopped: ${recipe.error}`}`);
1045
1303
  }
1046
1304
  if (!command.dryRun) terminal.err(`${report.records} records${report.sink.location === undefined ? '' : ` written to ${report.sink.location}`}`);
1305
+ if (previous !== undefined) {
1306
+ const diff = diffRecords(previous, emitted, diffOptionsFor(set.output));
1307
+ terminal.err(`compared with ${command.diff ?? ''}:`);
1308
+ for (const line of diffReport(diff)) terminal.err(` ${line}`);
1309
+ if (command.changes !== undefined) await writeChanges(command.changes, diff);
1310
+ }
1047
1311
  return report.recipes.some(recipe => recipe.error !== undefined) ? 1 : 0;
1048
1312
  } finally {
1049
1313
  await crawler.close();
1050
1314
  }
1051
1315
  }
1316
+ /** The access config's throttle, with `--host-delay` / `--host-concurrency` over its defaults. */
1317
+ function throttleFor(command, fromConfig) {
1318
+ const {
1319
+ delayMs,
1320
+ concurrency
1321
+ } = command.throttle;
1322
+ if (delayMs === undefined && concurrency === undefined) return fromConfig;
1323
+ return {
1324
+ ...fromConfig,
1325
+ ...(delayMs !== undefined && {
1326
+ delayMs
1327
+ }),
1328
+ ...(concurrency !== undefined && {
1329
+ concurrency
1330
+ })
1331
+ };
1332
+ }
1052
1333
  function onEvent(event, command, terminal) {
1053
1334
  if (command.trace) {
1054
1335
  const line = traceLine(event);
@@ -1156,6 +1437,10 @@ async function main(argv, terminal) {
1156
1437
  ...command.options
1157
1438
  }, terminal);
1158
1439
  }
1440
+ case 'diff':
1441
+ {
1442
+ return diffFiles(command, terminal);
1443
+ }
1159
1444
  }
1160
1445
  }
1161
1446
 
@@ -1171,5 +1456,5 @@ function processTerminal() {
1171
1456
  };
1172
1457
  }
1173
1458
 
1174
- export { USAGE, findData, loadForRun, loadHooks, loadPlugins, main, parseArguments, probeReport, probeUrl, processTerminal, readRecipeFiles, resolveAccess };
1459
+ export { USAGE, diffReport, findData, loadForRun, loadHooks, loadPlugins, main, parseArguments, probeReport, probeUrl, processTerminal, readRecipeFiles, resolveAccess, writeChanges };
1175
1460
  //# sourceMappingURL=index.esm.js.map
@@ -20,6 +20,16 @@ export type Command = {
20
20
  } | {
21
21
  name: 'validate';
22
22
  paths: string[];
23
+ } | {
24
+ name: 'diff';
25
+ previous: string;
26
+ current: string;
27
+ /** The fields that identify a record; empty uses each line's `_key`. */
28
+ key: string[];
29
+ /** Fields left out of the comparison. */
30
+ ignore: string[];
31
+ /** Where to write the changes as JSON Lines. */
32
+ changes?: string;
23
33
  } | {
24
34
  name: 'run';
25
35
  paths: string[];
@@ -33,6 +43,21 @@ export type Command = {
33
43
  /** Run only the input recipes with these ids. */
34
44
  only: string[];
35
45
  headed: boolean;
46
+ /** A previous run's JSON Lines file to compare this run's records with. */
47
+ diff?: string;
48
+ /** Where to write the changes `diff` finds, as JSON Lines. */
49
+ changes?: string;
50
+ /** How many input recipes run at once. */
51
+ parallel?: number;
52
+ /** Tries per request for recipes whose `limits.retry` says nothing (1 turns retrying off). */
53
+ retries?: number;
54
+ /** Where `session.browserProfile` profiles live; `OPENCRAW_PROFILES` when not given. */
55
+ profiles?: string;
56
+ /** Per-site politeness across every recipe: overrides the access config's `throttle` defaults. */
57
+ throttle: {
58
+ delayMs?: number;
59
+ concurrency?: number;
60
+ };
36
61
  options: CommonOptions;
37
62
  } | {
38
63
  name: 'probe';
@@ -1,5 +1,5 @@
1
1
  import type { Command } from './command.contract.js';
2
- export declare const USAGE = "opencraw <command> [options]\n\nCommands\n validate <recipe files or directories...> Parse and bind the recipes; list every problem with its path.\n run <recipe files or directories...> Crawl. Records go to --out as JSON Lines, or to stdout.\n probe <url> Fetch a page and report where its data lives.\n\nOptions for run\n --out <file> Write records to this JSON Lines file (default: stdout).\n --append Keep what --out holds and add to it (each line carries _key).\n --resume Skip records --out already has (needs --append).\n --only <id> Run only this input recipe (repeatable).\n --dry-run One record per input, printed with the scope it was mapped from.\n --trace Print the crawl trace to stderr.\n --headed Show the browser.\n\nOptions for probe\n --browser Also render the page in a browser and list the JSON it fetches.\n\nOptions for both\n --browser-path <p> A browser binary other than the one Playwright installed (or OPENCRAW_CHROMIUM).\n --insecure-tls Accept an intercepting proxy's certificate (or OPENCRAW_INSECURE_TLS=1).\n --user-agent <ua> The user agent to send.\n --access <file> An access config: proxy profiles, credentials as {{env.NAME}} (or OPENCRAW_ACCESS).\n --access-profile <name>\n The access profile to use when a recipe names none.\n --plugins <file> A JavaScript module exporting hooks (the recipes' hook steps and transforms),\n accessPlugins (for { kind: \"plugin\" } access profiles) and captchaSolvers\n (for session.captcha and captcha steps); --hooks, or OPENCRAW_PLUGINS /\n OPENCRAW_HOOKS. It runs as your code: trust it.\n --help, --version";
2
+ export declare const USAGE = "opencraw <command> [options]\n\nCommands\n validate <recipe files or directories...> Parse and bind the recipes; list every problem with its path.\n run <recipe files or directories...> Crawl. Records go to --out as JSON Lines, or to stdout.\n probe <url> Fetch a page and report where its data lives.\n diff <previous.jsonl> <current.jsonl> Compare two runs' records by key: added, removed, changed.\n\nOptions for run\n --out <file> Write records to this JSON Lines file (default: stdout).\n --append Keep what --out holds and add to it (each line carries _key).\n --resume Skip records --out already has (needs --append).\n --only <id> Run only this input recipe (repeatable).\n --dry-run One record per input, printed with the scope it was mapped from.\n --trace Print the crawl trace to stderr.\n --headed Show the browser.\n --parallel <n> Run this many input recipes at once (default 1). Iterations inside a recipe\n follow its limits.concurrency.\n --retries <n> Tries per request that fails in passing (connection, timeout, 429, 5xx), for\n recipes whose limits.retry says nothing. Default 3; 1 turns retrying off.\n --profiles <dir> Where recipes' session.browserProfile profiles live (or OPENCRAW_PROFILES;\n default .opencraw/profiles).\n --diff <file> Compare this run's records with a previous run's JSON Lines file (it may be\n --out itself: it is read before the crawl). Keys come from the output recipe.\n --host-delay <ms> At least this long between two requests to one site, across every recipe.\n --host-concurrency <n>\n At most this many requests to one site in flight, across every recipe.\n Both override the access config's \"throttle\" defaults.\n\nOptions for diff\n --key <a,b> The fields that identify a record (default: each line's _key).\n --ignore <a,b> Fields left out of the comparison (a date the crawl stamps, say).\n --changes <file> Also write every change as a JSON line (run --diff takes it too).\n\nOptions for probe\n --browser Also render the page in a browser and list the JSON it fetches.\n\nOptions for both\n --browser-path <p> A browser binary other than the one Playwright installed (or OPENCRAW_CHROMIUM).\n --insecure-tls Accept an intercepting proxy's certificate (or OPENCRAW_INSECURE_TLS=1).\n --user-agent <ua> The user agent to send.\n --access <file> An access config: proxy profiles, credentials as {{env.NAME}} (or OPENCRAW_ACCESS).\n --access-profile <name>\n The access profile to use when a recipe names none.\n --plugins <file> A JavaScript module exporting hooks (the recipes' hook steps and transforms),\n accessPlugins (for { kind: \"plugin\" } access profiles) and captchaSolvers\n (for session.captcha and captcha steps); --hooks, or OPENCRAW_PLUGINS /\n OPENCRAW_HOOKS. It runs as your code: trust it.\n --help, --version";
3
3
  /**
4
4
  * Turns argv into a command.
5
5
  *
@@ -0,0 +1,22 @@
1
+ import type { RecordDiff } from '@opencraw/core';
2
+ import type { Command } from '../arguments/index.js';
3
+ import type { Terminal } from '../terminal/index.js';
4
+ /**
5
+ * Compares two runs' JSON Lines files and prints what changed; with
6
+ * `--changes`, writes every change as a JSON line too.
7
+ *
8
+ * @param command - The parsed `diff` command.
9
+ * @param terminal - Where the report goes.
10
+ * @returns The exit code: 0, or 1 when a file cannot be read or has no key.
11
+ */
12
+ export declare function diffFiles(command: Extract<Command, {
13
+ name: 'diff';
14
+ }>, terminal: Terminal): Promise<number>;
15
+ /**
16
+ * Writes a diff's changes as JSON Lines: `{ change, key, before?, after?, fields? }`.
17
+ *
18
+ * @param path - The file.
19
+ * @param diff - The diff.
20
+ */
21
+ export declare function writeChanges(path: string, diff: RecordDiff): Promise<void>;
22
+ //# sourceMappingURL=diff-files.use-case.d.ts.map
@@ -0,0 +1,12 @@
1
+ import type { RecordDiff } from '@opencraw/core';
2
+ /**
3
+ * A diff as people read it: the counts, a warning when the run lost most of
4
+ * its records, then one line per change, `-` removed, `~` changed (with each
5
+ * field's before → after), `+` added.
6
+ *
7
+ * @param diff - The diff.
8
+ * @param limit - Changes listed at most.
9
+ * @returns The lines.
10
+ */
11
+ export declare function diffReport(diff: RecordDiff, limit?: number): string[];
12
+ //# sourceMappingURL=diff-report.mapper.d.ts.map
@@ -0,0 +1,3 @@
1
+ export { diffFiles, writeChanges } from './diff-files.use-case.js';
2
+ export { diffReport } from './diff-report.mapper.js';
3
+ //# sourceMappingURL=index.d.ts.map
@@ -8,6 +8,7 @@ export type { ProbeFindings, ProbeResult } from './probe/index.js';
8
8
  export { readRecipeFiles } from './recipe-files/index.js';
9
9
  export type { RecipeFiles } from './recipe-files/index.js';
10
10
  export { loadForRun } from './run/index.js';
11
+ export { diffReport, writeChanges } from './diff/index.js';
11
12
  export type { CommonOptions } from './arguments/index.js';
12
13
  export { resolveAccess } from './access/index.js';
13
14
  export { loadHooks, loadPlugins } from './hooks-module/index.js';
@@ -18,10 +18,10 @@ export interface HtmlFindings {
18
18
  }
19
19
  /**
20
20
  * Summarises an HTML document for a `table` extract, and, when it is rendered
21
- * Markdown, its outline and front matter.
21
+ * Markdown or a Word document, its outline (and Markdown's front matter).
22
22
  *
23
23
  * @param html - The document.
24
- * @param markdown - Whether it was rendered from Markdown.
24
+ * @param markdown - Whether it was rendered from Markdown or Word, and so has sections.
25
25
  * @returns The findings.
26
26
  */
27
27
  export declare function describeHtml(html: string, markdown: boolean): HtmlFindings;
@@ -2,7 +2,9 @@ export { probePage, probeUrl } from './probe-page.use-case.js';
2
2
  export type { ProbeResult } from './probe-page.use-case.js';
3
3
  export { findData } from './find-data.algorithm.js';
4
4
  export type { ProbeFindings, JsonLdFinding, InlineJsonFinding } from './find-data.algorithm.js';
5
- export { probeReport, pdfReport, workbookReport, deckReport, jsonReport } from './probe-report.mapper.js';
5
+ export { probeReport, pdfReport, workbookReport, deckReport, jsonReport, xmlReport } from './probe-report.mapper.js';
6
+ export { describeXml } from './xml-findings.mapper.js';
7
+ export type { XmlFindings } from './xml-findings.mapper.js';
6
8
  export { describePdf } from './pdf-findings.mapper.js';
7
9
  export type { PdfFindings } from './pdf-findings.mapper.js';
8
10
  export { describeWorkbook } from './workbook-findings.mapper.js';
@@ -5,6 +5,7 @@ import type { DeckFindings } from './deck-findings.mapper.js';
5
5
  import type { HtmlFindings } from './html-findings.mapper.js';
6
6
  import type { JsonFindings } from './json-findings.mapper.js';
7
7
  import type { PdfFindings } from './pdf-findings.mapper.js';
8
+ import type { XmlFindings } from './xml-findings.mapper.js';
8
9
  import type { WorkbookFindings } from './workbook-findings.mapper.js';
9
10
  /** What fetching and, optionally, rendering a page found. */
10
11
  export interface ProbeResult {
@@ -23,6 +24,8 @@ export interface ProbeResult {
23
24
  json?: JsonFindings;
24
25
  /** Present for HTML: its tables' headers; for rendered Markdown also its outline and front matter. */
25
26
  html?: HtmlFindings;
27
+ /** Present for XML: its namespaces, repeated elements and structure. */
28
+ xml?: XmlFindings;
26
29
  }
27
30
  /**
28
31
  * Fetches a page and finds where its data lives, through the access profile the
@@ -2,6 +2,7 @@ import type { ProbeFindings } from './find-data.algorithm.js';
2
2
  import type { DeckFindings } from './deck-findings.mapper.js';
3
3
  import type { HtmlFindings } from './html-findings.mapper.js';
4
4
  import type { JsonFindings } from './json-findings.mapper.js';
5
+ import type { XmlFindings } from './xml-findings.mapper.js';
5
6
  import type { PdfFindings } from './pdf-findings.mapper.js';
6
7
  import type { WorkbookFindings } from './workbook-findings.mapper.js';
7
8
  /**
@@ -52,4 +53,13 @@ export declare function deckReport(url: string, deck: DeckFindings): string;
52
53
  * @returns The report.
53
54
  */
54
55
  export declare function jsonReport(url: string, json: JsonFindings): string;
56
+ /**
57
+ * Formats what a probe found in an XML document: its namespaces and the
58
+ * `namespaces` an `xpath` extract needs, the elements that repeat, and its structure.
59
+ *
60
+ * @param url - The document's URL.
61
+ * @param xml - The findings.
62
+ * @returns The report.
63
+ */
64
+ export declare function xmlReport(url: string, xml: XmlFindings): string;
55
65
  //# sourceMappingURL=probe-report.mapper.d.ts.map
@@ -0,0 +1,30 @@
1
+ /** What a probe shows of an XML document. */
2
+ export interface XmlFindings {
3
+ /** The root element's local name. */
4
+ root: string;
5
+ /** Declared on the root: prefix (`''` for the default namespace) to URI. */
6
+ namespaces: Record<string, string>;
7
+ /** Elements that repeat under one parent, largest first: the records, with the child names they share. */
8
+ lists: {
9
+ path: string;
10
+ count: number;
11
+ children: string[];
12
+ }[];
13
+ /** One line per distinct element path (local names), with its count, attributes and a sample text. */
14
+ tree: string[];
15
+ /** For a sitemap (`urlset`, `sitemapindex`): how many locations it lists. */
16
+ sitemap?: {
17
+ kind: 'urlset' | 'sitemapindex';
18
+ locations: number;
19
+ };
20
+ }
21
+ /**
22
+ * Summarises an XML document for someone writing a recipe: its namespaces
23
+ * (and the `namespaces` a query needs), the elements that repeat (the
24
+ * records a `forEach` walks), and its structure.
25
+ *
26
+ * @param document - A parsed document (`parseXml` from the core), read through the W3C DOM properties.
27
+ * @returns The findings.
28
+ */
29
+ export declare function describeXml(document: unknown): XmlFindings;
30
+ //# sourceMappingURL=xml-findings.mapper.d.ts.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opencraw/cli",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "type": "module",
5
5
  "main": "./dist/index.esm.js",
6
6
  "module": "./dist/index.esm.js",
@@ -24,7 +24,7 @@
24
24
  "!**/*.js.map"
25
25
  ],
26
26
  "dependencies": {
27
- "@opencraw/core": "^0.1.2",
27
+ "@opencraw/core": "^0.1.4",
28
28
  "playwright": "^1.63.0"
29
29
  },
30
30
  "publishConfig": {