@opencraw/cli 0.1.3 → 0.1.5

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
@@ -54,6 +54,33 @@ opencraw run recipes/ --out out/products.jsonl --trace
54
54
  opencraw run recipes/movie.output.json recipes/tmdb.input.json --dry-run
55
55
  ```
56
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
+
57
84
  ### `probe`
58
85
 
59
86
  Fetches a page and reports where its data lives: JSON-LD blocks, inline JSON objects, `.json` URLs
@@ -72,7 +99,10 @@ rows, and every row that looks like a table header, with the `selector` a `table
72
99
  also names the encoding and the delimiter it detected; for a presentation, its charts and the slides whose
73
100
  text boxes look like a table. JSON, JSON Lines and YAML show their structure instead, and every list of
74
101
  records with the `jsonpath` that walks it. An HTML page also lists its tables' header rows, and Markdown
75
- (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.
76
106
 
77
107
  ```sh
78
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).
@@ -25,11 +27,18 @@ Options for run
25
27
  recipes whose limits.retry says nothing. Default 3; 1 turns retrying off.
26
28
  --profiles <dir> Where recipes' session.browserProfile profiles live (or OPENCRAW_PROFILES;
27
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.
28
32
  --host-delay <ms> At least this long between two requests to one site, across every recipe.
29
33
  --host-concurrency <n>
30
34
  At most this many requests to one site in flight, across every recipe.
31
35
  Both override the access config's "throttle" defaults.
32
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).
41
+
33
42
  Options for probe
34
43
  --browser Also render the page in a browser and list the JSON it fetches.
35
44
 
@@ -77,6 +86,18 @@ const OPTIONS = {
77
86
  'parallel': {
78
87
  type: 'string'
79
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
+ },
80
101
  'host-delay': {
81
102
  type: 'string'
82
103
  },
@@ -164,9 +185,23 @@ function parseArguments(argv, env = {}) {
164
185
  paths: rest
165
186
  };
166
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
+ }
167
200
  case 'run':
168
201
  {
169
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)');
170
205
  if (values.resume === true && values.append !== true) throw new Error('--resume needs --append (and --out)');
171
206
  if ((values.append === true || values.resume === true) && values.out === undefined) throw new Error('--append and --resume need --out');
172
207
  return {
@@ -182,6 +217,8 @@ function parseArguments(argv, env = {}) {
182
217
  profiles: values.profiles ?? nonEmpty(env.OPENCRAW_PROFILES),
183
218
  retries: integer(values.retries, '--retries', 1),
184
219
  parallel: integer(values.parallel, '--parallel', 1),
220
+ diff: values.diff,
221
+ changes: values.changes,
185
222
  throttle: {
186
223
  delayMs: integer(values['host-delay'], '--host-delay', 0),
187
224
  concurrency: integer(values['host-concurrency'], '--host-concurrency', 1)
@@ -211,10 +248,78 @@ function integer(value, name, minimum) {
211
248
  if (!Number.isSafeInteger(number) || number < minimum) throw new Error(`${name} needs a whole number of at least ${minimum}, not "${value}"`);
212
249
  return number;
213
250
  }
251
+ function list(value) {
252
+ return value === undefined ? [] : value.split(',').map(entry => entry.trim()).filter(entry => entry !== '');
253
+ }
214
254
  function nonEmpty(value) {
215
255
  return value === '' ? undefined : value;
216
256
  }
217
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
+
218
323
  /**
219
324
  * The access config a command runs with: the file `--access` (or
220
325
  * `OPENCRAW_ACCESS`) names, with `--access-profile` as its default.
@@ -593,10 +698,10 @@ const SECTION = /<section data-heading="([^"]*)" data-level="(\d)">/g;
593
698
  const FRONT_MATTER = /<script type="application\/json" data-front-matter>([\s\S]*?)<\/script>/;
594
699
  /**
595
700
  * Summarises an HTML document for a `table` extract, and, when it is rendered
596
- * Markdown, its outline and front matter.
701
+ * Markdown or a Word document, its outline (and Markdown's front matter).
597
702
  *
598
703
  * @param html - The document.
599
- * @param markdown - Whether it was rendered from Markdown.
704
+ * @param markdown - Whether it was rendered from Markdown or Word, and so has sections.
600
705
  * @returns The findings.
601
706
  */
602
707
  function describeHtml(html, markdown) {
@@ -632,13 +737,13 @@ function unescape(text) {
632
737
  }
633
738
 
634
739
  /** How deep a probe describes a JSON document. */
635
- const DEPTH = 4;
740
+ const DEPTH$1 = 4;
636
741
  /** Keys shown per object, at most. */
637
742
  const KEY_LIMIT = 25;
638
743
  /** Entries of a record list whose keys are compared. */
639
744
  const SAMPLE = 50;
640
745
  /** A sample value longer than this is cut. */
641
- const SAMPLE_CHARS = 40;
746
+ const SAMPLE_CHARS$1 = 40;
642
747
  /**
643
748
  * Summarises JSON data for someone writing a recipe: its shape, and where its
644
749
  * record lists are, so a `jsonpath` selector can be read off the result.
@@ -650,7 +755,7 @@ const SAMPLE_CHARS = 40;
650
755
  function describeJson(data, format) {
651
756
  const tree = [];
652
757
  const lists = [];
653
- walk(data, '$', 0, tree, lists);
758
+ walk$1(data, '$', 0, tree, lists);
654
759
  return {
655
760
  format,
656
761
  type: typeOf(data),
@@ -658,7 +763,7 @@ function describeJson(data, format) {
658
763
  lists: lists.sort((first, second) => second.length - first.length)
659
764
  };
660
765
  }
661
- function walk(value, path, depth, tree, lists) {
766
+ function walk$1(value, path, depth, tree, lists) {
662
767
  if (Array.isArray(value)) {
663
768
  const objects = value.filter(entry => isObject(entry));
664
769
  if (objects.length >= 2 && objects.length === value.length) lists.push({
@@ -667,13 +772,13 @@ function walk(value, path, depth, tree, lists) {
667
772
  keys: sharedKeys(objects.slice(0, SAMPLE))
668
773
  });
669
774
  if (depth > 0) tree.push(`${path} ${typeOf(value)}`);
670
- 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);
671
776
  return;
672
777
  }
673
778
  if (isObject(value)) {
674
779
  if (depth > 0) tree.push(`${path} object`);
675
- if (depth >= DEPTH) return;
676
- 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);
677
782
  return;
678
783
  }
679
784
  tree.push(`${path} ${typeOf(value)}${sample(value)}`);
@@ -689,7 +794,7 @@ function typeOf(value) {
689
794
  function sample(value) {
690
795
  if (value === null || value === undefined) return '';
691
796
  const text = JSON.stringify(value);
692
- 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}`;
693
798
  }
694
799
  function sharedKeys(objects) {
695
800
  const [first, ...rest] = objects;
@@ -760,7 +865,7 @@ function escape(text) {
760
865
  * @returns The report.
761
866
  */
762
867
  function probeReport(url, status, findings, observed = [], html) {
763
- 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}`)) : ''];
764
869
  return [`${url} (HTTP ${status})`, '', ...sections].filter(line => line !== '').join('\n');
765
870
  }
766
871
  /**
@@ -814,6 +919,20 @@ function jsonReport(url, json) {
814
919
  }[json.format] ?? json.format;
815
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');
816
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
+ }
817
936
  function delimiterName(delimiter) {
818
937
  const names = {
819
938
  '\t': 'tabs',
@@ -828,6 +947,86 @@ function section(title, rows) {
828
947
  return [`${title}:`, ...rows, ''].join('\n');
829
948
  }
830
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
+
831
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';
832
1031
  const OBSERVE_MS = 4000;
833
1032
  /**
@@ -899,10 +1098,17 @@ async function probeUrl(url, options) {
899
1098
  observed: [],
900
1099
  json: describeJson(body.data, response.format ?? 'json')
901
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
+ };
902
1108
  const text = body.kind === 'html' ? body.html : body.text;
903
1109
  const observed = options.browser && !target.startsWith('file:') ? await observeBrowserJson(url, options, lease) : [];
904
1110
  const html = body.kind === 'html' ? {
905
- html: describeHtml(body.html, response.format === 'markdown')
1111
+ html: describeHtml(body.html, response.format === 'markdown' || response.format === 'docx')
906
1112
  } : {};
907
1113
  return {
908
1114
  url: response.url,
@@ -968,6 +1174,7 @@ function reportOf(result) {
968
1174
  if (result.workbook !== undefined) return workbookReport(result.url, result.workbook);
969
1175
  if (result.deck !== undefined) return deckReport(result.url, result.deck);
970
1176
  if (result.json !== undefined) return jsonReport(result.url, result.json);
1177
+ if (result.xml !== undefined) return xmlReport(result.url, result.xml);
971
1178
  return probeReport(result.url, result.status, result.findings, result.observed, result.html);
972
1179
  }
973
1180
 
@@ -1043,9 +1250,13 @@ async function runRecipes(command, terminal) {
1043
1250
  }
1044
1251
  let access;
1045
1252
  let plugins;
1253
+ let previous;
1254
+ const emitted = [];
1046
1255
  try {
1047
1256
  access = await resolveAccess(command.options);
1048
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);
1049
1260
  } catch (error) {
1050
1261
  terminal.err(error instanceof Error ? error.message : String(error));
1051
1262
  return 1;
@@ -1072,7 +1283,10 @@ async function runRecipes(command, terminal) {
1072
1283
  executablePath: command.options.browserPath,
1073
1284
  ignoreHTTPSErrors: command.options.insecureTls
1074
1285
  },
1075
- 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
+ }
1076
1290
  });
1077
1291
  try {
1078
1292
  const inputs = command.dryRun ? set.inputs.map(input => ({
@@ -1088,6 +1302,12 @@ async function runRecipes(command, terminal) {
1088
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}`}`);
1089
1303
  }
1090
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
+ }
1091
1311
  return report.recipes.some(recipe => recipe.error !== undefined) ? 1 : 0;
1092
1312
  } finally {
1093
1313
  await crawler.close();
@@ -1217,6 +1437,10 @@ async function main(argv, terminal) {
1217
1437
  ...command.options
1218
1438
  }, terminal);
1219
1439
  }
1440
+ case 'diff':
1441
+ {
1442
+ return diffFiles(command, terminal);
1443
+ }
1220
1444
  }
1221
1445
  }
1222
1446
 
@@ -1232,5 +1456,5 @@ function processTerminal() {
1232
1456
  };
1233
1457
  }
1234
1458
 
1235
- 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 };
1236
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,10 @@ 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;
36
50
  /** How many input recipes run at once. */
37
51
  parallel?: number;
38
52
  /** Tries per request for recipes whose `limits.retry` says nothing (1 turns retrying off). */
@@ -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 --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 --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 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.3",
3
+ "version": "0.1.5",
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.3",
27
+ "@opencraw/core": "^0.1.5",
28
28
  "playwright": "^1.63.0"
29
29
  },
30
30
  "publishConfig": {