@opencraw/cli 0.1.0
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 +82 -0
- package/bin/opencraw.mjs +4 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.esm.js +699 -0
- package/dist/src/access/index.d.ts +2 -0
- package/dist/src/access/resolve-access.use-case.d.ts +12 -0
- package/dist/src/arguments/command.contract.d.ts +44 -0
- package/dist/src/arguments/index.d.ts +3 -0
- package/dist/src/arguments/parse-arguments.algorithm.d.ts +12 -0
- package/dist/src/hooks-module/index.d.ts +3 -0
- package/dist/src/hooks-module/load-hooks.repository.d.ts +20 -0
- package/dist/src/index.d.ts +14 -0
- package/dist/src/main.d.ts +12 -0
- package/dist/src/probe/find-data.algorithm.d.ts +36 -0
- package/dist/src/probe/index.d.ts +6 -0
- package/dist/src/probe/probe-page.use-case.d.ts +39 -0
- package/dist/src/probe/probe-report.mapper.d.ts +12 -0
- package/dist/src/recipe-files/index.d.ts +3 -0
- package/dist/src/recipe-files/read-recipe-files.repository.d.ts +26 -0
- package/dist/src/run/index.d.ts +3 -0
- package/dist/src/run/load-for-run.use-case.d.ts +13 -0
- package/dist/src/run/run-recipes.use-case.d.ts +17 -0
- package/dist/src/terminal/index.d.ts +3 -0
- package/dist/src/terminal/terminal.client.d.ts +4 -0
- package/dist/src/terminal/terminal.contract.d.ts +6 -0
- package/dist/src/validate/index.d.ts +2 -0
- package/dist/src/validate/validate-recipes.use-case.d.ts +10 -0
- package/package.json +85 -0
package/README.md
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# @opencraw/cli
|
|
2
|
+
|
|
3
|
+
Command-line tools for [`@opencraw/core`](../core): probe a page for scrapable data, validate recipes,
|
|
4
|
+
and run a crawl from the terminal.
|
|
5
|
+
|
|
6
|
+
## Install
|
|
7
|
+
|
|
8
|
+
```sh
|
|
9
|
+
npm install -g @opencraw/cli
|
|
10
|
+
# or, inside this workspace: node packages/cli/bin/opencraw.mjs <command>
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Commands
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
opencraw validate <recipe files or directories...>
|
|
17
|
+
opencraw run <recipe files or directories...> [options]
|
|
18
|
+
opencraw probe <url> [options]
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
### `validate`
|
|
22
|
+
|
|
23
|
+
Loads every recipe file (or every `.json` and `.jsonl` file in a directory; a `.jsonl` file holds one
|
|
24
|
+
recipe per line), parses it against its schema and binds
|
|
25
|
+
the input recipes to the output recipe, printing every problem with its JSON path. Exit code 1 if anything
|
|
26
|
+
fails.
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
opencraw validate recipes/
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### `run`
|
|
33
|
+
|
|
34
|
+
Crawls the recipes at the given paths (files or directories; exactly one output recipe, any number of
|
|
35
|
+
input recipes) and reports what happened.
|
|
36
|
+
|
|
37
|
+
| Flag | Meaning |
|
|
38
|
+
|---|---|
|
|
39
|
+
| `--out <file>` | Write records to this JSON Lines file. Without it, records print to stdout as JSON Lines. |
|
|
40
|
+
| `--append` | Keep what `--out` holds and add to it; each line carries `_key`. |
|
|
41
|
+
| `--resume` | Skip records `--out` already has (needs `--append`). |
|
|
42
|
+
| `--only <id>` | Run only this input recipe; repeat for more than one. |
|
|
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
|
+
| `--trace` | Print the crawl trace to stderr. |
|
|
45
|
+
| `--headed` | Show the browser instead of running headless. |
|
|
46
|
+
| `--hooks <file>` | A JavaScript module whose default export is `{ name: function }`: the hooks the recipes call (or `OPENCRAW_HOOKS`). It runs as your code; load only files you trust. See [§6 of the authoring guide](../../docs/recipes/authoring.md#6-hooks). |
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
opencraw run recipes/ --out out/products.jsonl --trace
|
|
50
|
+
opencraw run recipes/movie.output.json recipes/tmdb.input.json --dry-run
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### `probe`
|
|
54
|
+
|
|
55
|
+
Fetches a page and reports where its data lives: JSON-LD blocks, inline JSON objects, `.json` URLs
|
|
56
|
+
referenced in the markup, script hosts, and links that look like an API. With `--browser`, it also renders
|
|
57
|
+
the page in a browser and lists the JSON responses it observes while the page settles — useful for
|
|
58
|
+
endpoints only a script fetches after load.
|
|
59
|
+
|
|
60
|
+
```sh
|
|
61
|
+
opencraw probe https://example.com/product/1
|
|
62
|
+
opencraw probe https://example.com/configurator --browser
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Use it before writing an input recipe, to find the shape a site's data actually takes (§9 of
|
|
66
|
+
[the authoring guide](../../docs/recipes/authoring.md)).
|
|
67
|
+
|
|
68
|
+
### Options common to `run` and `probe`
|
|
69
|
+
|
|
70
|
+
| Flag | Meaning |
|
|
71
|
+
|---|---|
|
|
72
|
+
| `--browser-path <path>` | A browser binary other than the one Playwright installed (or `OPENCRAW_CHROMIUM`). |
|
|
73
|
+
| `--insecure-tls` | Accept an intercepting proxy's certificate (or `OPENCRAW_INSECURE_TLS=1`). |
|
|
74
|
+
| `--access <file>` | An access config: proxy profiles, with credentials as `{{env.NAME}}` (or `OPENCRAW_ACCESS`). See [access.md](../../docs/recipes/access.md). |
|
|
75
|
+
| `--access-profile <name>` | The profile used by recipes that name none, overriding the config's `default`. `probe` uses it for its fetch. |
|
|
76
|
+
| `--user-agent <ua>` | The user agent to send. |
|
|
77
|
+
|
|
78
|
+
## Building
|
|
79
|
+
|
|
80
|
+
Run `nx build @opencraw/cli` to build this project, `nx test @opencraw/cli` for its unit tests, and
|
|
81
|
+
`nx run @opencraw/cli:e2e` for the spawn-driven suite against the fixture shop `@opencraw/core` ships
|
|
82
|
+
(needs a browser: `npm run playwright:install` once, or `OPENCRAW_CHROMIUM=/path/to/chrome`).
|
package/bin/opencraw.mjs
ADDED
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./src/index.js";
|
|
@@ -0,0 +1,699 @@
|
|
|
1
|
+
import { parseArgs } from 'node:util';
|
|
2
|
+
import { loadAccessConfig, AccessBroker, HttpClient, BrowserClient, readRecipeSource, parseOutputRecipe, parseInputRecipe, bindRecipeSet, memorySink, jsonLinesSink, createCrawler, RecipeSet, RecipeValidationError, RecipeBindingError, traceLine } from '@opencraw/core';
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { resolve } from 'node:path';
|
|
5
|
+
import { pathToFileURL } from 'node:url';
|
|
6
|
+
|
|
7
|
+
const USAGE = `opencraw <command> [options]
|
|
8
|
+
|
|
9
|
+
Commands
|
|
10
|
+
validate <recipe files or directories...> Parse and bind the recipes; list every problem with its path.
|
|
11
|
+
run <recipe files or directories...> Crawl. Records go to --out as JSON Lines, or to stdout.
|
|
12
|
+
probe <url> Fetch a page and report where its data lives.
|
|
13
|
+
|
|
14
|
+
Options for run
|
|
15
|
+
--out <file> Write records to this JSON Lines file (default: stdout).
|
|
16
|
+
--append Keep what --out holds and add to it (each line carries _key).
|
|
17
|
+
--resume Skip records --out already has (needs --append).
|
|
18
|
+
--only <id> Run only this input recipe (repeatable).
|
|
19
|
+
--dry-run One record per input, printed with the scope it was mapped from.
|
|
20
|
+
--trace Print the crawl trace to stderr.
|
|
21
|
+
--headed Show the browser.
|
|
22
|
+
--hooks <file> A JavaScript module whose default export is { name: function }: the hooks
|
|
23
|
+
the recipes call (or OPENCRAW_HOOKS). It runs as your code: trust it.
|
|
24
|
+
|
|
25
|
+
Options for probe
|
|
26
|
+
--browser Also render the page in a browser and list the JSON it fetches.
|
|
27
|
+
|
|
28
|
+
Options for both
|
|
29
|
+
--browser-path <p> A browser binary other than the one Playwright installed (or OPENCRAW_CHROMIUM).
|
|
30
|
+
--insecure-tls Accept an intercepting proxy's certificate (or OPENCRAW_INSECURE_TLS=1).
|
|
31
|
+
--user-agent <ua> The user agent to send.
|
|
32
|
+
--access <file> An access config: proxy profiles, credentials as {{env.NAME}} (or OPENCRAW_ACCESS).
|
|
33
|
+
--access-profile <name>
|
|
34
|
+
The access profile to use when a recipe names none.
|
|
35
|
+
--help, --version`;
|
|
36
|
+
const OPTIONS = {
|
|
37
|
+
'out': {
|
|
38
|
+
type: 'string'
|
|
39
|
+
},
|
|
40
|
+
'append': {
|
|
41
|
+
type: 'boolean'
|
|
42
|
+
},
|
|
43
|
+
'resume': {
|
|
44
|
+
type: 'boolean'
|
|
45
|
+
},
|
|
46
|
+
'only': {
|
|
47
|
+
type: 'string',
|
|
48
|
+
multiple: true
|
|
49
|
+
},
|
|
50
|
+
'dry-run': {
|
|
51
|
+
type: 'boolean'
|
|
52
|
+
},
|
|
53
|
+
'trace': {
|
|
54
|
+
type: 'boolean'
|
|
55
|
+
},
|
|
56
|
+
'headed': {
|
|
57
|
+
type: 'boolean'
|
|
58
|
+
},
|
|
59
|
+
'hooks': {
|
|
60
|
+
type: 'string'
|
|
61
|
+
},
|
|
62
|
+
'browser': {
|
|
63
|
+
type: 'boolean'
|
|
64
|
+
},
|
|
65
|
+
'browser-path': {
|
|
66
|
+
type: 'string'
|
|
67
|
+
},
|
|
68
|
+
'insecure-tls': {
|
|
69
|
+
type: 'boolean'
|
|
70
|
+
},
|
|
71
|
+
'user-agent': {
|
|
72
|
+
type: 'string'
|
|
73
|
+
},
|
|
74
|
+
'access': {
|
|
75
|
+
type: 'string'
|
|
76
|
+
},
|
|
77
|
+
'access-profile': {
|
|
78
|
+
type: 'string'
|
|
79
|
+
},
|
|
80
|
+
'help': {
|
|
81
|
+
type: 'boolean',
|
|
82
|
+
short: 'h'
|
|
83
|
+
},
|
|
84
|
+
'version': {
|
|
85
|
+
type: 'boolean',
|
|
86
|
+
short: 'v'
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
/**
|
|
90
|
+
* Turns argv into a command.
|
|
91
|
+
*
|
|
92
|
+
* @param argv - The arguments after the program name.
|
|
93
|
+
* @param env - The environment, for the defaults some options have.
|
|
94
|
+
* @returns The command.
|
|
95
|
+
* @throws Error with a usage message on bad input.
|
|
96
|
+
*/
|
|
97
|
+
function parseArguments(argv, env = {}) {
|
|
98
|
+
const {
|
|
99
|
+
values,
|
|
100
|
+
positionals
|
|
101
|
+
} = parseArgs({
|
|
102
|
+
args: [...argv],
|
|
103
|
+
options: OPTIONS,
|
|
104
|
+
allowPositionals: true,
|
|
105
|
+
strict: true
|
|
106
|
+
});
|
|
107
|
+
if (values.help === true) return {
|
|
108
|
+
name: 'help'
|
|
109
|
+
};
|
|
110
|
+
if (values.version === true) return {
|
|
111
|
+
name: 'version'
|
|
112
|
+
};
|
|
113
|
+
const [name, ...rest] = positionals;
|
|
114
|
+
const options = {
|
|
115
|
+
browserPath: values['browser-path'] ?? env.OPENCRAW_CHROMIUM,
|
|
116
|
+
insecureTls: values['insecure-tls'] === true || env.OPENCRAW_INSECURE_TLS === '1',
|
|
117
|
+
userAgent: values['user-agent'],
|
|
118
|
+
access: values.access ?? (env.OPENCRAW_ACCESS === '' ? undefined : env.OPENCRAW_ACCESS),
|
|
119
|
+
accessProfile: values['access-profile']
|
|
120
|
+
};
|
|
121
|
+
switch (name) {
|
|
122
|
+
case undefined:
|
|
123
|
+
{
|
|
124
|
+
return {
|
|
125
|
+
name: 'help'
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
case 'validate':
|
|
129
|
+
{
|
|
130
|
+
if (rest.length === 0) throw new Error('validate needs at least one recipe file or directory');
|
|
131
|
+
return {
|
|
132
|
+
name: 'validate',
|
|
133
|
+
paths: rest
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
case 'run':
|
|
137
|
+
{
|
|
138
|
+
if (rest.length === 0) throw new Error('run needs at least one recipe file or directory');
|
|
139
|
+
if (values.resume === true && values.append !== true) throw new Error('--resume needs --append (and --out)');
|
|
140
|
+
if ((values.append === true || values.resume === true) && values.out === undefined) throw new Error('--append and --resume need --out');
|
|
141
|
+
return {
|
|
142
|
+
name: 'run',
|
|
143
|
+
paths: rest,
|
|
144
|
+
out: values.out,
|
|
145
|
+
append: values.append === true,
|
|
146
|
+
resume: values.resume === true,
|
|
147
|
+
trace: values.trace === true,
|
|
148
|
+
dryRun: values['dry-run'] === true,
|
|
149
|
+
only: values.only ?? [],
|
|
150
|
+
headed: values.headed === true,
|
|
151
|
+
hooks: values.hooks ?? (env.OPENCRAW_HOOKS === '' ? undefined : env.OPENCRAW_HOOKS),
|
|
152
|
+
options
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
case 'probe':
|
|
156
|
+
{
|
|
157
|
+
if (rest.length !== 1) throw new Error('probe needs exactly one URL');
|
|
158
|
+
return {
|
|
159
|
+
name: 'probe',
|
|
160
|
+
url: rest[0],
|
|
161
|
+
browser: values.browser === true,
|
|
162
|
+
options
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
default:
|
|
166
|
+
{
|
|
167
|
+
throw new Error(`unknown command "${name}"`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The access config a command runs with: the file `--access` (or
|
|
174
|
+
* `OPENCRAW_ACCESS`) names, with `--access-profile` as its default.
|
|
175
|
+
*
|
|
176
|
+
* @param options - The command's shared options.
|
|
177
|
+
* @returns The config, or `undefined` for direct access.
|
|
178
|
+
* @throws Error when a profile is asked for without a config, or is not in it.
|
|
179
|
+
*/
|
|
180
|
+
async function resolveAccess(options) {
|
|
181
|
+
if (options.access === undefined) {
|
|
182
|
+
if (options.accessProfile !== undefined) throw new Error('--access-profile needs an access config: --access <file> or OPENCRAW_ACCESS');
|
|
183
|
+
return undefined;
|
|
184
|
+
}
|
|
185
|
+
const config = await loadAccessConfig(options.access);
|
|
186
|
+
if (options.accessProfile === undefined) return config;
|
|
187
|
+
if (!Object.hasOwn(config.profiles, options.accessProfile)) {
|
|
188
|
+
throw new Error(`access profile "${options.accessProfile}" is not in ${options.access} (profiles: ${Object.keys(config.profiles).join(', ') || 'none'})`);
|
|
189
|
+
}
|
|
190
|
+
return {
|
|
191
|
+
...config,
|
|
192
|
+
default: options.accessProfile
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Finds where a fetched page's data lives: JSON-LD blocks, inline JSON objects
|
|
198
|
+
* big enough to matter, URLs that look like a data endpoint, script hosts and
|
|
199
|
+
* links whose path suggests an API. Pattern-matching only; it never fetches.
|
|
200
|
+
*/
|
|
201
|
+
const MIN_INLINE_SIZE = 200;
|
|
202
|
+
const JSON_LD = /<script[^\s>]*\s+type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi;
|
|
203
|
+
const INLINE_SCRIPT = /<script(?![^>]*\ssrc=)(?![^>]*\stype=["']application\/ld\+json["'])[^>]*>([\s\S]*?)<\/script>/gi;
|
|
204
|
+
const DATA_ATTR = /data-[\w-]+=["'](\{[\s\S]*?\})["']/g;
|
|
205
|
+
const SCRIPT_SRC = /<script[^\s>]*\s+src=["']([^"']+)["']/gi;
|
|
206
|
+
const HREF_OR_JSON_URL = /["'](https?:\/\/[^"'\s]+\.json[^"'\s]*|\/[^"'\s]*\.json[^"'\s]*)["']/g;
|
|
207
|
+
const LINK = /<a[^\s>]*\s+href=["']([^"']+)["']/gi;
|
|
208
|
+
/**
|
|
209
|
+
* Scans a page's HTML for data-shaped things.
|
|
210
|
+
*
|
|
211
|
+
* @param html - The document.
|
|
212
|
+
* @returns What was found, each list capped at a handful of entries.
|
|
213
|
+
*/
|
|
214
|
+
function findData(html) {
|
|
215
|
+
return {
|
|
216
|
+
jsonLd: jsonLdBlocks(html),
|
|
217
|
+
inlineJson: inlineJsonBlocks(html),
|
|
218
|
+
jsonUrls: unique(matchesOf(HREF_OR_JSON_URL, html)).slice(0, 10),
|
|
219
|
+
scriptHosts: scriptHosts(html),
|
|
220
|
+
apiLinks: apiLinks(html)
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
function jsonLdBlocks(html) {
|
|
224
|
+
const findings = [];
|
|
225
|
+
for (const text of matchesOf(JSON_LD, html)) {
|
|
226
|
+
const parsed = tryParse(text);
|
|
227
|
+
if (parsed === undefined) continue;
|
|
228
|
+
findings.push(...entriesOf(parsed));
|
|
229
|
+
}
|
|
230
|
+
return findings.slice(0, 10);
|
|
231
|
+
}
|
|
232
|
+
/** Every plain object in a JSON-LD block (it may be one object or an array of them), as a finding. */
|
|
233
|
+
function entriesOf(parsed) {
|
|
234
|
+
const list = Array.isArray(parsed) ? parsed : [parsed];
|
|
235
|
+
return list.flatMap(entry => {
|
|
236
|
+
if (typeof entry !== 'object' || entry === null) return [];
|
|
237
|
+
const record = entry;
|
|
238
|
+
const type = record['@type'];
|
|
239
|
+
return [{
|
|
240
|
+
types: Array.isArray(type) ? type.join(',') : String(type ?? '?'),
|
|
241
|
+
keys: Object.keys(record).length
|
|
242
|
+
}];
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
function inlineJsonBlocks(html) {
|
|
246
|
+
const findings = [];
|
|
247
|
+
for (const text of matchesOf(INLINE_SCRIPT, html)) findings.push(...objectsIn(text, 'inline script'));
|
|
248
|
+
for (const text of matchesOf(DATA_ATTR, html, 1)) findings.push(...objectsIn(text, 'data-* attribute'));
|
|
249
|
+
return findings.filter(finding => finding.size >= MIN_INLINE_SIZE).sort((a, b) => b.size - a.size).slice(0, 10);
|
|
250
|
+
}
|
|
251
|
+
/** Every `{...}` in a text that parses as JSON, by scanning for balanced braces (regex cannot nest). */
|
|
252
|
+
function objectsIn(text, where) {
|
|
253
|
+
const found = [];
|
|
254
|
+
for (let at = text.indexOf('{'); at !== -1; at = text.indexOf('{', at + 1)) {
|
|
255
|
+
const end = matchingBrace(text, at);
|
|
256
|
+
if (end === -1) continue;
|
|
257
|
+
const candidate = text.slice(at, end + 1);
|
|
258
|
+
const parsed = tryParse(candidate);
|
|
259
|
+
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
|
|
260
|
+
found.push({
|
|
261
|
+
where,
|
|
262
|
+
keys: Object.keys(parsed),
|
|
263
|
+
size: candidate.length
|
|
264
|
+
});
|
|
265
|
+
at = end;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return found;
|
|
269
|
+
}
|
|
270
|
+
function matchingBrace(text, open) {
|
|
271
|
+
let depth = 0;
|
|
272
|
+
for (let at = open; at < text.length; at += 1) {
|
|
273
|
+
if (text[at] === '{') depth += 1;else if (text[at] === '}') {
|
|
274
|
+
depth -= 1;
|
|
275
|
+
if (depth === 0) return at;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return -1;
|
|
279
|
+
}
|
|
280
|
+
function scriptHosts(html) {
|
|
281
|
+
const hosts = matchesOf(SCRIPT_SRC, html).map(src => {
|
|
282
|
+
try {
|
|
283
|
+
return new URL(src).host;
|
|
284
|
+
} catch {
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
}).filter(host => host !== undefined);
|
|
288
|
+
return unique(hosts).slice(0, 10);
|
|
289
|
+
}
|
|
290
|
+
function apiLinks(html) {
|
|
291
|
+
return unique(matchesOf(LINK, html).filter(href => /\bapi\b/i.test(href))).slice(0, 10);
|
|
292
|
+
}
|
|
293
|
+
function matchesOf(pattern, text, group = 1) {
|
|
294
|
+
return Array.from(text.matchAll(pattern), match => match[group]);
|
|
295
|
+
}
|
|
296
|
+
function unique(values) {
|
|
297
|
+
return [...new Set(values)];
|
|
298
|
+
}
|
|
299
|
+
function tryParse(text) {
|
|
300
|
+
try {
|
|
301
|
+
return JSON.parse(text.trim());
|
|
302
|
+
} catch {
|
|
303
|
+
return undefined;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Renders a probe's findings as text.
|
|
309
|
+
*
|
|
310
|
+
* @param url - The URL probed.
|
|
311
|
+
* @param status - The HTTP status.
|
|
312
|
+
* @param findings - What `findData` found.
|
|
313
|
+
* @param observed - JSON responses a browser saw, when `--browser` was used.
|
|
314
|
+
* @returns The report.
|
|
315
|
+
*/
|
|
316
|
+
function probeReport(url, status, findings, observed = []) {
|
|
317
|
+
const sections = [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}`)) : ''];
|
|
318
|
+
return [`${url} (HTTP ${status})`, '', ...sections].filter(line => line !== '').join('\n');
|
|
319
|
+
}
|
|
320
|
+
function section(title, rows) {
|
|
321
|
+
if (rows.length === 0) return '';
|
|
322
|
+
return [`${title}:`, ...rows, ''].join('\n');
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
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';
|
|
326
|
+
const OBSERVE_MS = 4000;
|
|
327
|
+
/**
|
|
328
|
+
* Fetches a page and finds where its data lives, through the access profile the
|
|
329
|
+
* options name (direct without one). With `browser: true` it also
|
|
330
|
+
* renders the page and lists the JSON responses seen while it settles, which
|
|
331
|
+
* finds endpoints a plain fetch of the initial HTML cannot. No `Terminal`
|
|
332
|
+
* involved: the cli's `probePage` and `@opencraw/mcp`'s probe tool both
|
|
333
|
+
* build on this, one printing the result, the other returning it as data.
|
|
334
|
+
*
|
|
335
|
+
* @param url - The page to probe.
|
|
336
|
+
* @param options - Browser path, TLS and user agent, plus whether to render.
|
|
337
|
+
* @returns What was found.
|
|
338
|
+
* @throws Error when the fetch itself fails.
|
|
339
|
+
*/
|
|
340
|
+
async function probeUrl(url, options) {
|
|
341
|
+
const lease = await new AccessBroker(await resolveAccess(options)).lease({
|
|
342
|
+
recipeId: 'probe'
|
|
343
|
+
});
|
|
344
|
+
if (lease.cdp !== undefined) throw new Error(`access profile "${lease.profile}" is a remote browser; probe fetches over HTTP and needs a proxy profile`);
|
|
345
|
+
const client = await HttpClient.open({
|
|
346
|
+
userAgent: options.userAgent ?? BROWSER_USER_AGENT,
|
|
347
|
+
ignoreHTTPSErrors: options.insecureTls || lease.ignoreHTTPSErrors === true,
|
|
348
|
+
proxy: lease.proxy,
|
|
349
|
+
headers: lease.headers
|
|
350
|
+
});
|
|
351
|
+
try {
|
|
352
|
+
const response = await client.send({
|
|
353
|
+
url,
|
|
354
|
+
as: 'html'
|
|
355
|
+
});
|
|
356
|
+
const html = response.body.kind === 'html' ? response.body.html : '';
|
|
357
|
+
const observed = options.browser ? await observeBrowserJson(url, options, lease) : [];
|
|
358
|
+
return {
|
|
359
|
+
url: response.url,
|
|
360
|
+
status: response.status,
|
|
361
|
+
findings: findData(html),
|
|
362
|
+
observed
|
|
363
|
+
};
|
|
364
|
+
} finally {
|
|
365
|
+
await client.dispose();
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* Fetches a page and reports where its data lives, to a `Terminal`.
|
|
370
|
+
*
|
|
371
|
+
* @param url - The page to probe.
|
|
372
|
+
* @param options - Browser path, TLS and user agent, plus whether to render.
|
|
373
|
+
* @param terminal - Where the report goes.
|
|
374
|
+
* @returns The exit code: 0, or 1 on a fetch failure.
|
|
375
|
+
*/
|
|
376
|
+
async function probePage(url, options, terminal) {
|
|
377
|
+
try {
|
|
378
|
+
const result = await probeUrl(url, options);
|
|
379
|
+
terminal.out(probeReport(result.url, result.status, result.findings, result.observed));
|
|
380
|
+
return 0;
|
|
381
|
+
} catch (error) {
|
|
382
|
+
terminal.err(`probe failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
383
|
+
return 1;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
async function observeBrowserJson(url, options, lease) {
|
|
387
|
+
const browser = await BrowserClient.launch({
|
|
388
|
+
executablePath: options.browserPath,
|
|
389
|
+
ignoreHTTPSErrors: options.insecureTls
|
|
390
|
+
});
|
|
391
|
+
try {
|
|
392
|
+
const session = await browser.newSession({
|
|
393
|
+
userAgent: options.userAgent,
|
|
394
|
+
proxy: lease.proxy,
|
|
395
|
+
headers: lease.headers,
|
|
396
|
+
ignoreHTTPSErrors: lease.ignoreHTTPSErrors
|
|
397
|
+
});
|
|
398
|
+
const seen = [];
|
|
399
|
+
session.page.on('response', response => {
|
|
400
|
+
if ((response.headers()['content-type'] ?? '').includes('json')) seen.push(`${response.status()} ${response.url()}`);
|
|
401
|
+
});
|
|
402
|
+
try {
|
|
403
|
+
await session.page.goto(url, {
|
|
404
|
+
waitUntil: 'networkidle'
|
|
405
|
+
});
|
|
406
|
+
} catch {
|
|
407
|
+
// a page that never idles (long-poll, websocket) still yields whatever the browser observed
|
|
408
|
+
}
|
|
409
|
+
await session.page.waitForTimeout(OBSERVE_MS);
|
|
410
|
+
await session.close();
|
|
411
|
+
return [...new Set(seen)].slice(0, 15);
|
|
412
|
+
} finally {
|
|
413
|
+
await browser.close();
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Loads the hooks a recipe's `hook` steps and transforms call, from a
|
|
419
|
+
* JavaScript module: its default export when that is an object of functions
|
|
420
|
+
* (`export default { positive: v => v > 0 }`), else its named function exports
|
|
421
|
+
* (`export function positive (v) { ... }`).
|
|
422
|
+
*
|
|
423
|
+
* The module runs with the caller's privileges, like any code the caller
|
|
424
|
+
* imports: point this only at a file you trust.
|
|
425
|
+
*
|
|
426
|
+
* @param path - A `.mjs` / `.js` / `.cjs` file.
|
|
427
|
+
* @param importModule - How to import it; `import()` unless a test says otherwise.
|
|
428
|
+
* @returns The hooks by name.
|
|
429
|
+
* @throws Error naming the file when it cannot be imported, exports no hooks,
|
|
430
|
+
* or exports something under a hook's name that is not a function.
|
|
431
|
+
*/
|
|
432
|
+
async function loadHooks(path, importModule = importByUrl) {
|
|
433
|
+
const url = pathToFileURL(resolve(path)).href;
|
|
434
|
+
let exported;
|
|
435
|
+
try {
|
|
436
|
+
exported = await importModule(url);
|
|
437
|
+
} catch (error) {
|
|
438
|
+
throw new Error(`${path}: cannot load hooks (${error.message})`, {
|
|
439
|
+
cause: error
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
const candidates = hookExports(exported);
|
|
443
|
+
const entries = Object.entries(candidates);
|
|
444
|
+
if (entries.length === 0) throw new Error(`${path}: exports no hooks (export default { name: function } or named functions)`);
|
|
445
|
+
const hooks = {};
|
|
446
|
+
for (const [name, value] of entries) {
|
|
447
|
+
if (typeof value !== 'function') throw new Error(`${path}: hook "${name}" is ${value === null ? 'null' : typeof value}, not a function`);
|
|
448
|
+
hooks[name] = value;
|
|
449
|
+
}
|
|
450
|
+
return hooks;
|
|
451
|
+
}
|
|
452
|
+
async function importByUrl(url) {
|
|
453
|
+
return await import(url);
|
|
454
|
+
}
|
|
455
|
+
function hookExports(exported) {
|
|
456
|
+
const fallback = exported.default;
|
|
457
|
+
if (typeof fallback === 'object' && fallback !== null && !Array.isArray(fallback)) return fallback;
|
|
458
|
+
const named = Object.entries(exported).filter(([name]) => name !== 'default' && name !== 'module.exports');
|
|
459
|
+
return Object.fromEntries(named);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Reads recipes, expanding directories to their `.json` and `.jsonl` files
|
|
464
|
+
* (sorted, not recursive), and sorts them by `kind`. The command line passes
|
|
465
|
+
* paths; the MCP server also passes recipes it was handed inline.
|
|
466
|
+
*
|
|
467
|
+
* @param sources - Files or directories, or any other recipe source core reads.
|
|
468
|
+
* @returns The decoded recipes.
|
|
469
|
+
* @throws Error naming a file that is not JSON or JSON Lines.
|
|
470
|
+
*/
|
|
471
|
+
async function readRecipeFiles(sources) {
|
|
472
|
+
const result = {
|
|
473
|
+
outputs: [],
|
|
474
|
+
inputs: [],
|
|
475
|
+
others: []
|
|
476
|
+
};
|
|
477
|
+
const documents = await readRecipeSource(sources);
|
|
478
|
+
for (const document of documents) {
|
|
479
|
+
const kind = document.content?.kind;
|
|
480
|
+
if (kind === 'output') result.outputs.push({
|
|
481
|
+
path: document.source,
|
|
482
|
+
recipe: document.content
|
|
483
|
+
});else if (kind === 'input') result.inputs.push({
|
|
484
|
+
path: document.source,
|
|
485
|
+
recipe: document.content
|
|
486
|
+
});else result.others.push(document.source);
|
|
487
|
+
}
|
|
488
|
+
return result;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Loads and binds the recipes `run` needs, filtered to the input ids in `only`
|
|
493
|
+
* when given.
|
|
494
|
+
*
|
|
495
|
+
* @param sources - Recipe files or directories (or recipes the MCP server was handed inline).
|
|
496
|
+
* @param only - Input recipe ids to keep; empty keeps all.
|
|
497
|
+
* @returns The bound set.
|
|
498
|
+
* @throws Error when there is not exactly one output recipe.
|
|
499
|
+
* @throws RecipeValidationError, RecipeBindingError on a bad recipe.
|
|
500
|
+
*/
|
|
501
|
+
async function loadForRun(sources, only) {
|
|
502
|
+
const files = await readRecipeFiles(sources);
|
|
503
|
+
if (files.outputs.length !== 1) throw new Error(`expected exactly one output recipe, found ${files.outputs.length}`);
|
|
504
|
+
const output = parseOutputRecipe(files.outputs[0].recipe, files.outputs[0].path);
|
|
505
|
+
const inputs = files.inputs.map(file => parseInputRecipe(file.recipe, file.path)).filter(input => only.length === 0 || only.includes(input.id));
|
|
506
|
+
return bindRecipeSet(output, inputs);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Crawls the recipes at `paths` and reports what happened.
|
|
511
|
+
*
|
|
512
|
+
* `--dry-run` caps every input at one record, attaches the scope each record
|
|
513
|
+
* was mapped from, and prints them side by side instead of writing them, so a
|
|
514
|
+
* recipe under construction can be checked without a full run.
|
|
515
|
+
*
|
|
516
|
+
* @param command - The parsed `run` command.
|
|
517
|
+
* @param terminal - Where records, the trace and errors go.
|
|
518
|
+
* @returns The exit code: 0, or 1 on a load failure or a recipe error.
|
|
519
|
+
*/
|
|
520
|
+
async function runRecipes(command, terminal) {
|
|
521
|
+
let set;
|
|
522
|
+
try {
|
|
523
|
+
set = await loadForRun(command.paths, command.only);
|
|
524
|
+
} catch (error) {
|
|
525
|
+
for (const line of loadErrorLines(error)) terminal.err(line);
|
|
526
|
+
return 1;
|
|
527
|
+
}
|
|
528
|
+
if (set.inputs.length === 0) {
|
|
529
|
+
terminal.err(command.only.length > 0 ? `no input recipe matches --only ${command.only.join(', ')}` : 'no input recipes found');
|
|
530
|
+
return 1;
|
|
531
|
+
}
|
|
532
|
+
let access;
|
|
533
|
+
let hooks;
|
|
534
|
+
try {
|
|
535
|
+
access = await resolveAccess(command.options);
|
|
536
|
+
hooks = command.hooks === undefined ? undefined : await loadHooks(command.hooks);
|
|
537
|
+
} catch (error) {
|
|
538
|
+
terminal.err(error instanceof Error ? error.message : String(error));
|
|
539
|
+
return 1;
|
|
540
|
+
}
|
|
541
|
+
const sink = command.dryRun || command.out === undefined ? memorySink() : jsonLinesSink(command.out, {
|
|
542
|
+
append: command.append
|
|
543
|
+
});
|
|
544
|
+
const crawler = createCrawler({
|
|
545
|
+
sink,
|
|
546
|
+
access,
|
|
547
|
+
hooks,
|
|
548
|
+
resume: command.resume,
|
|
549
|
+
debug: command.dryRun,
|
|
550
|
+
browser: {
|
|
551
|
+
headless: !command.headed,
|
|
552
|
+
executablePath: command.options.browserPath,
|
|
553
|
+
ignoreHTTPSErrors: command.options.insecureTls
|
|
554
|
+
},
|
|
555
|
+
onEvent: event => onEvent(event, command, terminal)
|
|
556
|
+
});
|
|
557
|
+
try {
|
|
558
|
+
const inputs = command.dryRun ? set.inputs.map(input => ({
|
|
559
|
+
...input,
|
|
560
|
+
limits: {
|
|
561
|
+
...input.limits,
|
|
562
|
+
maxRecords: 1
|
|
563
|
+
}
|
|
564
|
+
})) : set.inputs;
|
|
565
|
+
const report = await crawler.run(new RecipeSet(set.output, inputs));
|
|
566
|
+
if (command.out === undefined && !command.dryRun) for (const record of sink.records) terminal.out(JSON.stringify(record.data));
|
|
567
|
+
for (const recipe of report.recipes) {
|
|
568
|
+
terminal.err(`${recipe.recipeId}: ${recipe.emitted} emitted, ${recipe.rejected} rejected, ${recipe.duplicates} duplicates, ${recipe.skipped} skipped, ${recipe.pages} pages, ${recipe.durationMs} ms${recipe.error === undefined ? '' : `, stopped: ${recipe.error}`}`);
|
|
569
|
+
}
|
|
570
|
+
if (!command.dryRun) terminal.err(`${report.records} records${report.sink.location === undefined ? '' : ` written to ${report.sink.location}`}`);
|
|
571
|
+
return report.recipes.some(recipe => recipe.error !== undefined) ? 1 : 0;
|
|
572
|
+
} finally {
|
|
573
|
+
await crawler.close();
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
function onEvent(event, command, terminal) {
|
|
577
|
+
if (command.trace) {
|
|
578
|
+
const line = traceLine(event);
|
|
579
|
+
if (line !== undefined) terminal.err(line);
|
|
580
|
+
}
|
|
581
|
+
if (command.dryRun && event.type === 'record:emit') {
|
|
582
|
+
terminal.out(`--- ${event.recipeId} ---`);
|
|
583
|
+
terminal.out(`scope: ${JSON.stringify(event.scope, null, 2)}`);
|
|
584
|
+
terminal.out(`record: ${JSON.stringify(event.data, null, 2)}`);
|
|
585
|
+
}
|
|
586
|
+
if (command.dryRun && event.type === 'record:reject') terminal.out(`--- ${event.recipeId}: rejected (${event.field}: ${event.reason}) ---\nscope: ${JSON.stringify(event.scope, null, 2)}`);
|
|
587
|
+
}
|
|
588
|
+
function loadErrorLines(error) {
|
|
589
|
+
if (error instanceof RecipeValidationError) return error.issues.map(issue => `${error.source}: ${issue.path === '' ? '(root)' : issue.path}: ${issue.message}`);
|
|
590
|
+
if (error instanceof RecipeBindingError) return error.issues.map(issue => `${issue.recipeId}: ${issue.path}: ${issue.message}`);
|
|
591
|
+
return [error instanceof Error ? error.message : String(error)];
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* Loads and binds recipe files, printing every problem with its JSON path.
|
|
596
|
+
*
|
|
597
|
+
* @param paths - Recipe files or directories.
|
|
598
|
+
* @param terminal - Where results go.
|
|
599
|
+
* @returns The exit code: 0 when the recipes bind cleanly, 1 otherwise.
|
|
600
|
+
*/
|
|
601
|
+
async function validateRecipes(paths, terminal) {
|
|
602
|
+
const files = await readRecipeFiles(paths);
|
|
603
|
+
for (const path of files.others) terminal.err(`${path}: not a recipe ("kind" is missing or not "input"/"output")`);
|
|
604
|
+
if (files.outputs.length === 0) {
|
|
605
|
+
terminal.err('no output recipe found');
|
|
606
|
+
return 1;
|
|
607
|
+
}
|
|
608
|
+
if (files.outputs.length > 1) {
|
|
609
|
+
terminal.err(`more than one output recipe: ${files.outputs.map(file => file.path).join(', ')}`);
|
|
610
|
+
return 1;
|
|
611
|
+
}
|
|
612
|
+
let ok = true;
|
|
613
|
+
const output = parseOne(files.outputs[0].path, files.outputs[0].recipe, parseOutputRecipe, terminal);
|
|
614
|
+
const inputs = files.inputs.flatMap(file => {
|
|
615
|
+
const parsed = parseOne(file.path, file.recipe, parseInputRecipe, terminal);
|
|
616
|
+
return parsed === undefined ? [] : [parsed];
|
|
617
|
+
});
|
|
618
|
+
ok &&= output !== undefined && inputs.length === files.inputs.length;
|
|
619
|
+
if (output === undefined) return 1;
|
|
620
|
+
try {
|
|
621
|
+
const set = bindRecipeSet(output, inputs);
|
|
622
|
+
terminal.out(`ok: ${set.output.id} <- ${set.inputs.map(input => input.id).join(', ') || '(no inputs)'}`);
|
|
623
|
+
} catch (error) {
|
|
624
|
+
ok = false;
|
|
625
|
+
if (error instanceof RecipeBindingError) for (const issue of error.issues) terminal.err(`${issue.recipeId}: ${issue.path}: ${issue.message}`);else terminal.err(String(error));
|
|
626
|
+
}
|
|
627
|
+
return ok ? 0 : 1;
|
|
628
|
+
}
|
|
629
|
+
function parseOne(path, recipe, parse, terminal) {
|
|
630
|
+
try {
|
|
631
|
+
return parse(recipe, path);
|
|
632
|
+
} catch (error) {
|
|
633
|
+
if (error instanceof RecipeValidationError) for (const issue of error.issues) terminal.err(`${path}: ${issue.path}: ${issue.message}`);else terminal.err(`${path}: ${String(error)}`);
|
|
634
|
+
return undefined;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
639
|
+
/**
|
|
640
|
+
* Runs one cli invocation: parses argv, dispatches to a use case, writes
|
|
641
|
+
* through `terminal`. Never throws; a bad command or a use case failure both
|
|
642
|
+
* become a non-zero return.
|
|
643
|
+
*
|
|
644
|
+
* @param argv - Arguments after the program name.
|
|
645
|
+
* @param terminal - Where output goes.
|
|
646
|
+
* @returns The process exit code.
|
|
647
|
+
*/
|
|
648
|
+
async function main(argv, terminal) {
|
|
649
|
+
let command;
|
|
650
|
+
try {
|
|
651
|
+
command = parseArguments(argv, process.env);
|
|
652
|
+
} catch (error) {
|
|
653
|
+
terminal.err(error instanceof Error ? error.message : String(error));
|
|
654
|
+
terminal.err(USAGE);
|
|
655
|
+
return 1;
|
|
656
|
+
}
|
|
657
|
+
switch (command.name) {
|
|
658
|
+
case 'help':
|
|
659
|
+
{
|
|
660
|
+
terminal.out(USAGE);
|
|
661
|
+
return 0;
|
|
662
|
+
}
|
|
663
|
+
case 'version':
|
|
664
|
+
{
|
|
665
|
+
terminal.out(VERSION);
|
|
666
|
+
return 0;
|
|
667
|
+
}
|
|
668
|
+
case 'validate':
|
|
669
|
+
{
|
|
670
|
+
return validateRecipes(command.paths, terminal);
|
|
671
|
+
}
|
|
672
|
+
case 'run':
|
|
673
|
+
{
|
|
674
|
+
return runRecipes(command, terminal);
|
|
675
|
+
}
|
|
676
|
+
case 'probe':
|
|
677
|
+
{
|
|
678
|
+
return probePage(command.url, {
|
|
679
|
+
browser: command.browser,
|
|
680
|
+
...command.options
|
|
681
|
+
}, terminal);
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/** The process's stdout and stderr. */
|
|
687
|
+
function processTerminal() {
|
|
688
|
+
return {
|
|
689
|
+
out: line => {
|
|
690
|
+
process.stdout.write(`${line}\n`);
|
|
691
|
+
},
|
|
692
|
+
err: line => {
|
|
693
|
+
process.stderr.write(`${line}\n`);
|
|
694
|
+
}
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
export { USAGE, findData, loadForRun, loadHooks, main, parseArguments, probeReport, probeUrl, processTerminal, readRecipeFiles, resolveAccess };
|
|
699
|
+
//# sourceMappingURL=index.esm.js.map
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { AccessConfig } from '@opencraw/core';
|
|
2
|
+
import type { CommonOptions } from '../arguments/index.js';
|
|
3
|
+
/**
|
|
4
|
+
* The access config a command runs with: the file `--access` (or
|
|
5
|
+
* `OPENCRAW_ACCESS`) names, with `--access-profile` as its default.
|
|
6
|
+
*
|
|
7
|
+
* @param options - The command's shared options.
|
|
8
|
+
* @returns The config, or `undefined` for direct access.
|
|
9
|
+
* @throws Error when a profile is asked for without a config, or is not in it.
|
|
10
|
+
*/
|
|
11
|
+
export declare function resolveAccess(options: CommonOptions): Promise<AccessConfig | undefined>;
|
|
12
|
+
//# sourceMappingURL=resolve-access.use-case.d.ts.map
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/** Options every command shares. */
|
|
2
|
+
export interface CommonOptions {
|
|
3
|
+
/** A browser binary other than the one Playwright installed. */
|
|
4
|
+
browserPath?: string;
|
|
5
|
+
/** Accept an intercepting proxy's certificate. */
|
|
6
|
+
insecureTls: boolean;
|
|
7
|
+
/** A user agent for requests and the browser. */
|
|
8
|
+
userAgent?: string;
|
|
9
|
+
/** An access config file (proxy profiles); `OPENCRAW_ACCESS` when not given. */
|
|
10
|
+
access?: string;
|
|
11
|
+
/** The access profile to use by default, overriding the config's `default`. */
|
|
12
|
+
accessProfile?: string;
|
|
13
|
+
}
|
|
14
|
+
export type Command = {
|
|
15
|
+
name: 'help';
|
|
16
|
+
} | {
|
|
17
|
+
name: 'version';
|
|
18
|
+
} | {
|
|
19
|
+
name: 'validate';
|
|
20
|
+
paths: string[];
|
|
21
|
+
} | {
|
|
22
|
+
name: 'run';
|
|
23
|
+
paths: string[];
|
|
24
|
+
/** A JSON Lines file; records go to stdout without it. */
|
|
25
|
+
out?: string;
|
|
26
|
+
append: boolean;
|
|
27
|
+
resume: boolean;
|
|
28
|
+
trace: boolean;
|
|
29
|
+
/** One record per input, with the scope it was mapped from. */
|
|
30
|
+
dryRun: boolean;
|
|
31
|
+
/** Run only the input recipes with these ids. */
|
|
32
|
+
only: string[];
|
|
33
|
+
/** A JavaScript module exporting the hooks the recipes call; `OPENCRAW_HOOKS` when not given. */
|
|
34
|
+
hooks?: string;
|
|
35
|
+
headed: boolean;
|
|
36
|
+
options: CommonOptions;
|
|
37
|
+
} | {
|
|
38
|
+
name: 'probe';
|
|
39
|
+
url: string;
|
|
40
|
+
/** Also render the page in a browser and watch the JSON it fetches. */
|
|
41
|
+
browser: boolean;
|
|
42
|
+
options: CommonOptions;
|
|
43
|
+
};
|
|
44
|
+
//# sourceMappingURL=command.contract.d.ts.map
|
|
@@ -0,0 +1,12 @@
|
|
|
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 --hooks <file> A JavaScript module whose default export is { name: function }: the hooks\n the recipes call (or OPENCRAW_HOOKS). It runs as your code: trust it.\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 --help, --version";
|
|
3
|
+
/**
|
|
4
|
+
* Turns argv into a command.
|
|
5
|
+
*
|
|
6
|
+
* @param argv - The arguments after the program name.
|
|
7
|
+
* @param env - The environment, for the defaults some options have.
|
|
8
|
+
* @returns The command.
|
|
9
|
+
* @throws Error with a usage message on bad input.
|
|
10
|
+
*/
|
|
11
|
+
export declare function parseArguments(argv: readonly string[], env?: Record<string, string | undefined>): Command;
|
|
12
|
+
//# sourceMappingURL=parse-arguments.algorithm.d.ts.map
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { HookMap } from '@opencraw/core';
|
|
2
|
+
/**
|
|
3
|
+
* Loads the hooks a recipe's `hook` steps and transforms call, from a
|
|
4
|
+
* JavaScript module: its default export when that is an object of functions
|
|
5
|
+
* (`export default { positive: v => v > 0 }`), else its named function exports
|
|
6
|
+
* (`export function positive (v) { ... }`).
|
|
7
|
+
*
|
|
8
|
+
* The module runs with the caller's privileges, like any code the caller
|
|
9
|
+
* imports: point this only at a file you trust.
|
|
10
|
+
*
|
|
11
|
+
* @param path - A `.mjs` / `.js` / `.cjs` file.
|
|
12
|
+
* @param importModule - How to import it; `import()` unless a test says otherwise.
|
|
13
|
+
* @returns The hooks by name.
|
|
14
|
+
* @throws Error naming the file when it cannot be imported, exports no hooks,
|
|
15
|
+
* or exports something under a hook's name that is not a function.
|
|
16
|
+
*/
|
|
17
|
+
export declare function loadHooks(path: string, importModule?: ModuleImporter): Promise<HookMap>;
|
|
18
|
+
/** Imports a module by URL, returning its namespace. */
|
|
19
|
+
export type ModuleImporter = (url: string) => Promise<Record<string, unknown>>;
|
|
20
|
+
//# sourceMappingURL=load-hooks.repository.d.ts.map
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export { main } from './main.js';
|
|
2
|
+
export { parseArguments, USAGE } from './arguments/index.js';
|
|
3
|
+
export type { Command } from './arguments/index.js';
|
|
4
|
+
export { processTerminal } from './terminal/index.js';
|
|
5
|
+
export type { Terminal } from './terminal/index.js';
|
|
6
|
+
export { findData, probeReport, probeUrl } from './probe/index.js';
|
|
7
|
+
export type { ProbeFindings, ProbeResult } from './probe/index.js';
|
|
8
|
+
export { readRecipeFiles } from './recipe-files/index.js';
|
|
9
|
+
export type { RecipeFiles } from './recipe-files/index.js';
|
|
10
|
+
export { loadForRun } from './run/index.js';
|
|
11
|
+
export type { CommonOptions } from './arguments/index.js';
|
|
12
|
+
export { resolveAccess } from './access/index.js';
|
|
13
|
+
export { loadHooks } from './hooks-module/index.js';
|
|
14
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Terminal } from './terminal/index.js';
|
|
2
|
+
/**
|
|
3
|
+
* Runs one cli invocation: parses argv, dispatches to a use case, writes
|
|
4
|
+
* through `terminal`. Never throws; a bad command or a use case failure both
|
|
5
|
+
* become a non-zero return.
|
|
6
|
+
*
|
|
7
|
+
* @param argv - Arguments after the program name.
|
|
8
|
+
* @param terminal - Where output goes.
|
|
9
|
+
* @returns The process exit code.
|
|
10
|
+
*/
|
|
11
|
+
export declare function main(argv: readonly string[], terminal: Terminal): Promise<number>;
|
|
12
|
+
//# sourceMappingURL=main.d.ts.map
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Finds where a fetched page's data lives: JSON-LD blocks, inline JSON objects
|
|
3
|
+
* big enough to matter, URLs that look like a data endpoint, script hosts and
|
|
4
|
+
* links whose path suggests an API. Pattern-matching only; it never fetches.
|
|
5
|
+
*/
|
|
6
|
+
export interface JsonLdFinding {
|
|
7
|
+
/** The `@type` value(s), or `'?'` when the block has none. */
|
|
8
|
+
types: string;
|
|
9
|
+
/** How many top-level keys the block has. */
|
|
10
|
+
keys: number;
|
|
11
|
+
}
|
|
12
|
+
export interface InlineJsonFinding {
|
|
13
|
+
/** Where it was found: a `<script>` with no `type`, or an attribute. */
|
|
14
|
+
where: string;
|
|
15
|
+
keys: string[];
|
|
16
|
+
/** Serialized size, for ranking. */
|
|
17
|
+
size: number;
|
|
18
|
+
}
|
|
19
|
+
export interface ProbeFindings {
|
|
20
|
+
jsonLd: JsonLdFinding[];
|
|
21
|
+
inlineJson: InlineJsonFinding[];
|
|
22
|
+
/** URLs referenced in the page whose path ends in `.json`. */
|
|
23
|
+
jsonUrls: string[];
|
|
24
|
+
/** Distinct hosts the page loads a `<script src>` from. */
|
|
25
|
+
scriptHosts: string[];
|
|
26
|
+
/** Links whose path contains `api`. */
|
|
27
|
+
apiLinks: string[];
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Scans a page's HTML for data-shaped things.
|
|
31
|
+
*
|
|
32
|
+
* @param html - The document.
|
|
33
|
+
* @returns What was found, each list capped at a handful of entries.
|
|
34
|
+
*/
|
|
35
|
+
export declare function findData(html: string): ProbeFindings;
|
|
36
|
+
//# sourceMappingURL=find-data.algorithm.d.ts.map
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { probePage, probeUrl } from './probe-page.use-case.js';
|
|
2
|
+
export type { ProbeResult } from './probe-page.use-case.js';
|
|
3
|
+
export { findData } from './find-data.algorithm.js';
|
|
4
|
+
export type { ProbeFindings, JsonLdFinding, InlineJsonFinding } from './find-data.algorithm.js';
|
|
5
|
+
export { probeReport } from './probe-report.mapper.js';
|
|
6
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { CommonOptions } from '../arguments/index.js';
|
|
2
|
+
import type { Terminal } from '../terminal/index.js';
|
|
3
|
+
import { findData } from './find-data.algorithm.js';
|
|
4
|
+
/** What fetching and, optionally, rendering a page found. */
|
|
5
|
+
export interface ProbeResult {
|
|
6
|
+
url: string;
|
|
7
|
+
status: number;
|
|
8
|
+
findings: ReturnType<typeof findData>;
|
|
9
|
+
/** JSON responses observed while the page rendered; empty unless `options.browser` was set. */
|
|
10
|
+
observed: string[];
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Fetches a page and finds where its data lives, through the access profile the
|
|
14
|
+
* options name (direct without one). With `browser: true` it also
|
|
15
|
+
* renders the page and lists the JSON responses seen while it settles, which
|
|
16
|
+
* finds endpoints a plain fetch of the initial HTML cannot. No `Terminal`
|
|
17
|
+
* involved: the cli's `probePage` and `@opencraw/mcp`'s probe tool both
|
|
18
|
+
* build on this, one printing the result, the other returning it as data.
|
|
19
|
+
*
|
|
20
|
+
* @param url - The page to probe.
|
|
21
|
+
* @param options - Browser path, TLS and user agent, plus whether to render.
|
|
22
|
+
* @returns What was found.
|
|
23
|
+
* @throws Error when the fetch itself fails.
|
|
24
|
+
*/
|
|
25
|
+
export declare function probeUrl(url: string, options: {
|
|
26
|
+
browser: boolean;
|
|
27
|
+
} & CommonOptions): Promise<ProbeResult>;
|
|
28
|
+
/**
|
|
29
|
+
* Fetches a page and reports where its data lives, to a `Terminal`.
|
|
30
|
+
*
|
|
31
|
+
* @param url - The page to probe.
|
|
32
|
+
* @param options - Browser path, TLS and user agent, plus whether to render.
|
|
33
|
+
* @param terminal - Where the report goes.
|
|
34
|
+
* @returns The exit code: 0, or 1 on a fetch failure.
|
|
35
|
+
*/
|
|
36
|
+
export declare function probePage(url: string, options: {
|
|
37
|
+
browser: boolean;
|
|
38
|
+
} & CommonOptions, terminal: Terminal): Promise<number>;
|
|
39
|
+
//# sourceMappingURL=probe-page.use-case.d.ts.map
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { ProbeFindings } from './find-data.algorithm.js';
|
|
2
|
+
/**
|
|
3
|
+
* Renders a probe's findings as text.
|
|
4
|
+
*
|
|
5
|
+
* @param url - The URL probed.
|
|
6
|
+
* @param status - The HTTP status.
|
|
7
|
+
* @param findings - What `findData` found.
|
|
8
|
+
* @param observed - JSON responses a browser saw, when `--browser` was used.
|
|
9
|
+
* @returns The report.
|
|
10
|
+
*/
|
|
11
|
+
export declare function probeReport(url: string, status: number, findings: ProbeFindings, observed?: string[]): string;
|
|
12
|
+
//# sourceMappingURL=probe-report.mapper.d.ts.map
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { RecipeSource } from '@opencraw/core';
|
|
2
|
+
/** Decoded recipes, split by their `kind`. */
|
|
3
|
+
export interface RecipeFiles {
|
|
4
|
+
/** `path` is the file, `path:line` in a JSON Lines file, or a label (`recipes[0]`) for one from memory. */
|
|
5
|
+
outputs: {
|
|
6
|
+
path: string;
|
|
7
|
+
recipe: unknown;
|
|
8
|
+
}[];
|
|
9
|
+
inputs: {
|
|
10
|
+
path: string;
|
|
11
|
+
recipe: unknown;
|
|
12
|
+
}[];
|
|
13
|
+
/** Documents that are neither; listed so a typo in `kind` does not pass silently. */
|
|
14
|
+
others: string[];
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Reads recipes, expanding directories to their `.json` and `.jsonl` files
|
|
18
|
+
* (sorted, not recursive), and sorts them by `kind`. The command line passes
|
|
19
|
+
* paths; the MCP server also passes recipes it was handed inline.
|
|
20
|
+
*
|
|
21
|
+
* @param sources - Files or directories, or any other recipe source core reads.
|
|
22
|
+
* @returns The decoded recipes.
|
|
23
|
+
* @throws Error naming a file that is not JSON or JSON Lines.
|
|
24
|
+
*/
|
|
25
|
+
export declare function readRecipeFiles(sources: RecipeSource): Promise<RecipeFiles>;
|
|
26
|
+
//# sourceMappingURL=read-recipe-files.repository.d.ts.map
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { RecipeSet, RecipeSource } from '@opencraw/core';
|
|
2
|
+
/**
|
|
3
|
+
* Loads and binds the recipes `run` needs, filtered to the input ids in `only`
|
|
4
|
+
* when given.
|
|
5
|
+
*
|
|
6
|
+
* @param sources - Recipe files or directories (or recipes the MCP server was handed inline).
|
|
7
|
+
* @param only - Input recipe ids to keep; empty keeps all.
|
|
8
|
+
* @returns The bound set.
|
|
9
|
+
* @throws Error when there is not exactly one output recipe.
|
|
10
|
+
* @throws RecipeValidationError, RecipeBindingError on a bad recipe.
|
|
11
|
+
*/
|
|
12
|
+
export declare function loadForRun(sources: RecipeSource, only: readonly string[]): Promise<RecipeSet>;
|
|
13
|
+
//# sourceMappingURL=load-for-run.use-case.d.ts.map
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Command } from '../arguments/index.js';
|
|
2
|
+
import type { Terminal } from '../terminal/index.js';
|
|
3
|
+
/**
|
|
4
|
+
* Crawls the recipes at `paths` and reports what happened.
|
|
5
|
+
*
|
|
6
|
+
* `--dry-run` caps every input at one record, attaches the scope each record
|
|
7
|
+
* was mapped from, and prints them side by side instead of writing them, so a
|
|
8
|
+
* recipe under construction can be checked without a full run.
|
|
9
|
+
*
|
|
10
|
+
* @param command - The parsed `run` command.
|
|
11
|
+
* @param terminal - Where records, the trace and errors go.
|
|
12
|
+
* @returns The exit code: 0, or 1 on a load failure or a recipe error.
|
|
13
|
+
*/
|
|
14
|
+
export declare function runRecipes(command: Extract<Command, {
|
|
15
|
+
name: 'run';
|
|
16
|
+
}>, terminal: Terminal): Promise<number>;
|
|
17
|
+
//# sourceMappingURL=run-recipes.use-case.d.ts.map
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Terminal } from '../terminal/index.js';
|
|
2
|
+
/**
|
|
3
|
+
* Loads and binds recipe files, printing every problem with its JSON path.
|
|
4
|
+
*
|
|
5
|
+
* @param paths - Recipe files or directories.
|
|
6
|
+
* @param terminal - Where results go.
|
|
7
|
+
* @returns The exit code: 0 when the recipes bind cleanly, 1 otherwise.
|
|
8
|
+
*/
|
|
9
|
+
export declare function validateRecipes(paths: readonly string[], terminal: Terminal): Promise<number>;
|
|
10
|
+
//# sourceMappingURL=validate-recipes.use-case.d.ts.map
|
package/package.json
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@opencraw/cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "./dist/index.esm.js",
|
|
6
|
+
"module": "./dist/index.esm.js",
|
|
7
|
+
"types": "./dist/src/index.d.ts",
|
|
8
|
+
"bin": {
|
|
9
|
+
"opencraw": "./bin/opencraw.mjs"
|
|
10
|
+
},
|
|
11
|
+
"exports": {
|
|
12
|
+
"./package.json": "./package.json",
|
|
13
|
+
".": {
|
|
14
|
+
"types": "./dist/src/index.d.ts",
|
|
15
|
+
"import": "./dist/index.esm.js",
|
|
16
|
+
"default": "./dist/index.esm.js"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"dist",
|
|
21
|
+
"bin",
|
|
22
|
+
"!**/*.tsbuildinfo",
|
|
23
|
+
"!**/*.d.ts.map",
|
|
24
|
+
"!**/*.js.map"
|
|
25
|
+
],
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"@opencraw/core": "^0.1.0",
|
|
28
|
+
"playwright": "^1.63.0"
|
|
29
|
+
},
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public"
|
|
32
|
+
},
|
|
33
|
+
"description": "Command-line tools for OpenCraw: probe a page for scrapable data, validate recipes, run a crawl.",
|
|
34
|
+
"license": "MIT",
|
|
35
|
+
"repository": {
|
|
36
|
+
"type": "git",
|
|
37
|
+
"url": "git+https://github.com/russoedu/open.craw.git",
|
|
38
|
+
"directory": "packages/cli"
|
|
39
|
+
},
|
|
40
|
+
"keywords": [
|
|
41
|
+
"crawler",
|
|
42
|
+
"scraper",
|
|
43
|
+
"cli",
|
|
44
|
+
"recipe"
|
|
45
|
+
],
|
|
46
|
+
"engines": {
|
|
47
|
+
"node": ">=22"
|
|
48
|
+
},
|
|
49
|
+
"sideEffects": false,
|
|
50
|
+
"nx": {
|
|
51
|
+
"targets": {
|
|
52
|
+
"typecheck": {
|
|
53
|
+
"dependsOn": [
|
|
54
|
+
"^build"
|
|
55
|
+
]
|
|
56
|
+
},
|
|
57
|
+
"lint": {
|
|
58
|
+
"dependsOn": [
|
|
59
|
+
"^build"
|
|
60
|
+
]
|
|
61
|
+
},
|
|
62
|
+
"e2e": {
|
|
63
|
+
"executor": "nx:run-commands",
|
|
64
|
+
"dependsOn": [
|
|
65
|
+
"build",
|
|
66
|
+
{
|
|
67
|
+
"projects": [
|
|
68
|
+
"@opencraw/core"
|
|
69
|
+
],
|
|
70
|
+
"target": "e2e"
|
|
71
|
+
}
|
|
72
|
+
],
|
|
73
|
+
"inputs": [
|
|
74
|
+
"default",
|
|
75
|
+
"^production",
|
|
76
|
+
"{workspaceRoot}/packages/core/e2e/**"
|
|
77
|
+
],
|
|
78
|
+
"options": {
|
|
79
|
+
"command": "tsc -p tsconfig.e2e.json && jest --config jest.e2e.config.cts",
|
|
80
|
+
"cwd": "packages/cli"
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|