@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 +35 -1
- package/dist/index.esm.js +300 -15
- package/dist/src/arguments/command.contract.d.ts +25 -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
|
@@ -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('
|
|
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 =>
|
|
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
|
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": {
|