@opencraw/cli 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.
- package/README.md +31 -1
- package/dist/index.esm.js +239 -15
- package/dist/src/arguments/command.contract.d.ts +14 -0
- package/dist/src/arguments/parse-arguments.algorithm.d.ts +1 -1
- package/dist/src/diff/diff-files.use-case.d.ts +22 -0
- package/dist/src/diff/diff-report.mapper.d.ts +12 -0
- package/dist/src/diff/index.d.ts +3 -0
- package/dist/src/index.d.ts +1 -0
- package/dist/src/probe/html-findings.mapper.d.ts +2 -2
- package/dist/src/probe/index.d.ts +3 -1
- package/dist/src/probe/probe-page.use-case.d.ts +3 -0
- package/dist/src/probe/probe-report.mapper.d.ts +10 -0
- package/dist/src/probe/xml-findings.mapper.d.ts +30 -0
- package/package.json +2 -2
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('
|
|
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 =>
|
|
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
|
package/dist/src/index.d.ts
CHANGED
|
@@ -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
|
+
"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.
|
|
27
|
+
"@opencraw/core": "^0.1.4",
|
|
28
28
|
"playwright": "^1.63.0"
|
|
29
29
|
},
|
|
30
30
|
"publishConfig": {
|