@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 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`).
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ import { main, processTerminal } from '../dist/index.esm.js'
3
+
4
+ process.exitCode = await main(process.argv.slice(2), processTerminal())
@@ -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,2 @@
1
+ export { resolveAccess } from './resolve-access.use-case.js';
2
+ //# sourceMappingURL=index.d.ts.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,3 @@
1
+ export type { Command, CommonOptions } from './command.contract.js';
2
+ export { parseArguments, USAGE } from './parse-arguments.algorithm.js';
3
+ //# sourceMappingURL=index.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,3 @@
1
+ export { loadHooks } from './load-hooks.repository.js';
2
+ export type { ModuleImporter } from './load-hooks.repository.js';
3
+ //# sourceMappingURL=index.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,3 @@
1
+ export { readRecipeFiles } from './read-recipe-files.repository.js';
2
+ export type { RecipeFiles } from './read-recipe-files.repository.js';
3
+ //# sourceMappingURL=index.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,3 @@
1
+ export { runRecipes } from './run-recipes.use-case.js';
2
+ export { loadForRun } from './load-for-run.use-case.js';
3
+ //# sourceMappingURL=index.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,3 @@
1
+ export type { Terminal } from './terminal.contract.js';
2
+ export { processTerminal } from './terminal.client.js';
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,4 @@
1
+ import type { Terminal } from './terminal.contract.js';
2
+ /** The process's stdout and stderr. */
3
+ export declare function processTerminal(): Terminal;
4
+ //# sourceMappingURL=terminal.client.d.ts.map
@@ -0,0 +1,6 @@
1
+ /** Where a command writes: results to `out`, diagnostics and progress to `err`. Tests capture both. */
2
+ export interface Terminal {
3
+ out: (line: string) => void;
4
+ err: (line: string) => void;
5
+ }
6
+ //# sourceMappingURL=terminal.contract.d.ts.map
@@ -0,0 +1,2 @@
1
+ export { validateRecipes } from './validate-recipes.use-case.js';
2
+ //# sourceMappingURL=index.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
+ }