@opencraw/cli 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -43,7 +43,11 @@ input recipes) and reports what happened.
43
43
  | `--dry-run` | One record per input recipe, printed with the scope it was mapped from — for checking a recipe under construction without a full run. |
44
44
  | `--trace` | Print the crawl trace to stderr. |
45
45
  | `--headed` | Show the browser instead of running headless. |
46
- | `--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). |
46
+ | `--parallel <n>` | Run this many input recipes at once (default 1). Iterations inside a recipe follow its `limits.concurrency`. See [§3.9 of the authoring guide](../../docs/recipes/authoring.md#39-concurrency). |
47
+ | `--retries <n>` | Tries per request that fails in passing (a dropped connection, a timeout, a 429, a 5xx), for recipes whose `limits.retry` says nothing. Default 3; `1` turns retrying off. See [§7 of the authoring guide](../../docs/recipes/authoring.md#7-policies-what-happens-when-something-is-missing-or-fails). |
48
+ | `--profiles <dir>` | Where the persistent browser profiles of `session.browserProfile` live (or `OPENCRAW_PROFILES`; default `.opencraw/profiles`). See [access.md](../../docs/recipes/access.md#persistent-browser-profiles). |
49
+ | `--host-delay <ms>`, `--host-concurrency <n>` | Per-site politeness across every recipe: at least `ms` between two requests to one site, at most `n` in flight. They override the access config's `throttle` defaults. See [access.md](../../docs/recipes/access.md#throttling-per-site). |
50
+ | `--plugins <file>` (or `--hooks`) | A plugins module: named exports `hooks` (the recipes' hook steps and transforms), `accessPlugins` (for `{ kind: "plugin" }` access profiles) and `captchaSolvers`. A module whose default export is `{ name: function }` is read as hooks alone. `OPENCRAW_PLUGINS` / `OPENCRAW_HOOKS` when not given; `probe` reads it too. It runs as your code; load only files you trust. See [§6 of the authoring guide](../../docs/recipes/authoring.md#6-hooks). |
47
51
 
48
52
  ```sh
49
53
  opencraw run recipes/ --out out/products.jsonl --trace
@@ -62,12 +66,20 @@ opencraw probe https://example.com/product/1
62
66
  opencraw probe https://example.com/configurator --browser
63
67
  ```
64
68
 
65
- A PDF (a URL served as `application/pdf`, or a local `.pdf` path) is read instead: `probe` lists its first
66
- rows and every row that looks like a table header, with the `selector` a `table` extract needs.
69
+ A PDF, a spreadsheet, a CSV or a presentation (a URL served with its content type, or a local `.pdf`,
70
+ `.xlsx`, `.csv`, `.tsv` or `.pptx` path) is read instead: `probe` lists its pages, sheets or slides, its first
71
+ rows, and every row that looks like a table header, with the `selector` a `table` extract needs. For a CSV it
72
+ also names the encoding and the delimiter it detected; for a presentation, its charts and the slides whose
73
+ text boxes look like a table. JSON, JSON Lines and YAML show their structure instead, and every list of
74
+ records with the `jsonpath` that walks it. An HTML page also lists its tables' header rows, and Markdown
75
+ (a `.md` URL is read as Markdown even when served as `text/plain`) its sections and front matter keys.
67
76
 
68
77
  ```sh
69
78
  opencraw probe https://example.com/price-list.pdf
70
79
  opencraw probe ./sheets/september.pdf
80
+ opencraw probe ./exports/listino.csv
81
+ opencraw probe ./exports/incentivi.xlsx
82
+ opencraw probe ./decks/incentivi.pptx
71
83
  ```
72
84
 
73
85
  Use it before writing an input recipe, to find the shape a site's data actually takes (§9 of
package/dist/index.esm.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { parseArgs } from 'node:util';
2
2
  import { resolve } from 'node:path';
3
3
  import { pathToFileURL } from 'node:url';
4
- import { loadAccessConfig, AccessBroker, HttpClient, BrowserClient, readRecipeSource, parseOutputRecipe, parseInputRecipe, bindRecipeSet, memorySink, jsonLinesSink, createCrawler, RecipeSet, RecipeValidationError, RecipeBindingError, traceLine } from '@opencraw/core';
4
+ import { loadAccessConfig, htmlTableSheets, AccessBroker, HttpClient, BrowserClient, readRecipeSource, parseOutputRecipe, parseInputRecipe, bindRecipeSet, memorySink, jsonLinesSink, createCrawler, RecipeSet, RecipeValidationError, RecipeBindingError, traceLine } from '@opencraw/core';
5
5
  import { readFileSync } from 'node:fs';
6
6
 
7
7
  const USAGE = `opencraw <command> [options]
@@ -19,8 +19,16 @@ Options for run
19
19
  --dry-run One record per input, printed with the scope it was mapped from.
20
20
  --trace Print the crawl trace to stderr.
21
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.
22
+ --parallel <n> Run this many input recipes at once (default 1). Iterations inside a recipe
23
+ follow its limits.concurrency.
24
+ --retries <n> Tries per request that fails in passing (connection, timeout, 429, 5xx), for
25
+ recipes whose limits.retry says nothing. Default 3; 1 turns retrying off.
26
+ --profiles <dir> Where recipes' session.browserProfile profiles live (or OPENCRAW_PROFILES;
27
+ default .opencraw/profiles).
28
+ --host-delay <ms> At least this long between two requests to one site, across every recipe.
29
+ --host-concurrency <n>
30
+ At most this many requests to one site in flight, across every recipe.
31
+ Both override the access config's "throttle" defaults.
24
32
 
25
33
  Options for probe
26
34
  --browser Also render the page in a browser and list the JSON it fetches.
@@ -32,6 +40,10 @@ Options for both
32
40
  --access <file> An access config: proxy profiles, credentials as {{env.NAME}} (or OPENCRAW_ACCESS).
33
41
  --access-profile <name>
34
42
  The access profile to use when a recipe names none.
43
+ --plugins <file> A JavaScript module exporting hooks (the recipes' hook steps and transforms),
44
+ accessPlugins (for { kind: "plugin" } access profiles) and captchaSolvers
45
+ (for session.captcha and captcha steps); --hooks, or OPENCRAW_PLUGINS /
46
+ OPENCRAW_HOOKS. It runs as your code: trust it.
35
47
  --help, --version`;
36
48
  const OPTIONS = {
37
49
  'out': {
@@ -56,9 +68,27 @@ const OPTIONS = {
56
68
  'headed': {
57
69
  type: 'boolean'
58
70
  },
71
+ 'profiles': {
72
+ type: 'string'
73
+ },
74
+ 'retries': {
75
+ type: 'string'
76
+ },
77
+ 'parallel': {
78
+ type: 'string'
79
+ },
80
+ 'host-delay': {
81
+ type: 'string'
82
+ },
83
+ 'host-concurrency': {
84
+ type: 'string'
85
+ },
59
86
  'hooks': {
60
87
  type: 'string'
61
88
  },
89
+ 'plugins': {
90
+ type: 'string'
91
+ },
62
92
  'browser': {
63
93
  type: 'boolean'
64
94
  },
@@ -116,7 +146,8 @@ function parseArguments(argv, env = {}) {
116
146
  insecureTls: values['insecure-tls'] === true || env.OPENCRAW_INSECURE_TLS === '1',
117
147
  userAgent: values['user-agent'],
118
148
  access: values.access ?? (env.OPENCRAW_ACCESS === '' ? undefined : env.OPENCRAW_ACCESS),
119
- accessProfile: values['access-profile']
149
+ accessProfile: values['access-profile'],
150
+ plugins: values.plugins ?? values.hooks ?? nonEmpty(env.OPENCRAW_PLUGINS) ?? nonEmpty(env.OPENCRAW_HOOKS)
120
151
  };
121
152
  switch (name) {
122
153
  case undefined:
@@ -148,7 +179,13 @@ function parseArguments(argv, env = {}) {
148
179
  dryRun: values['dry-run'] === true,
149
180
  only: values.only ?? [],
150
181
  headed: values.headed === true,
151
- hooks: values.hooks ?? (env.OPENCRAW_HOOKS === '' ? undefined : env.OPENCRAW_HOOKS),
182
+ profiles: values.profiles ?? nonEmpty(env.OPENCRAW_PROFILES),
183
+ retries: integer(values.retries, '--retries', 1),
184
+ parallel: integer(values.parallel, '--parallel', 1),
185
+ throttle: {
186
+ delayMs: integer(values['host-delay'], '--host-delay', 0),
187
+ concurrency: integer(values['host-concurrency'], '--host-concurrency', 1)
188
+ },
152
189
  options
153
190
  };
154
191
  }
@@ -168,6 +205,15 @@ function parseArguments(argv, env = {}) {
168
205
  }
169
206
  }
170
207
  }
208
+ function integer(value, name, minimum) {
209
+ if (value === undefined) return undefined;
210
+ const number = Number(value);
211
+ if (!Number.isSafeInteger(number) || number < minimum) throw new Error(`${name} needs a whole number of at least ${minimum}, not "${value}"`);
212
+ return number;
213
+ }
214
+ function nonEmpty(value) {
215
+ return value === '' ? undefined : value;
216
+ }
171
217
 
172
218
  /**
173
219
  * The access config a command runs with: the file `--access` (or
@@ -193,6 +239,109 @@ async function resolveAccess(options) {
193
239
  };
194
240
  }
195
241
 
242
+ /** The named exports that mark a module as a plugins module rather than a bare hooks module. */
243
+ const PLUGIN_EXPORTS = ['hooks', 'accessPlugins', 'captchaSolvers'];
244
+ /**
245
+ * Loads a plugins module: a JavaScript module whose named exports `hooks`
246
+ * (`{ name: function }`), `accessPlugins` (`[{ name, lease }]`) and
247
+ * `captchaSolvers` (`[{ name, solve }]`) give what recipes and access profiles
248
+ * refer to by name:
249
+ *
250
+ * ```js
251
+ * export const hooks = { positive: v => v > 0 }
252
+ * export const accessPlugins = [{ name: 'browserbase', lease: async () => ({ cdp: { endpoint } }) }]
253
+ * export const captchaSolvers = [{ name: 'capsolver', solve: async (challenge, { page }) => ({ status: 'solved' }) }]
254
+ * ```
255
+ *
256
+ * A module with none of those names is a bare hooks module, as before: its
257
+ * default export when that is an object of functions, else its named function
258
+ * exports.
259
+ *
260
+ * The module runs with the caller's privileges, like any code the caller
261
+ * imports: point this only at a file you trust.
262
+ *
263
+ * @param path - A `.mjs` / `.js` / `.cjs` file.
264
+ * @param importModule - How to import it; `import()` unless a test says otherwise.
265
+ * @returns The hooks and plugins.
266
+ * @throws Error naming the file when it cannot be imported, provides nothing,
267
+ * or provides something that is not what its name promises.
268
+ */
269
+ async function loadPlugins(path, importModule = importByUrl) {
270
+ const url = pathToFileURL(resolve(path)).href;
271
+ let exported;
272
+ try {
273
+ exported = await importModule(url);
274
+ } catch (error) {
275
+ throw new Error(`${path}: cannot load hooks (${error.message})`, {
276
+ cause: error
277
+ });
278
+ }
279
+ if (PLUGIN_EXPORTS.every(name => !Object.hasOwn(exported, name))) {
280
+ const hooks = hooksOf(hookExports(exported), path);
281
+ if (Object.keys(hooks).length === 0) throw new Error(`${path}: exports no hooks (export default { name: function } or named functions) and no plugins (export const hooks, accessPlugins, captchaSolvers)`);
282
+ return {
283
+ hooks,
284
+ accessPlugins: [],
285
+ captchaSolvers: []
286
+ };
287
+ }
288
+ return {
289
+ hooks: hooksOf(objectExport(exported.hooks, 'hooks', path), path),
290
+ accessPlugins: pluginsOf(exported.accessPlugins, 'accessPlugins', 'lease', path),
291
+ captchaSolvers: pluginsOf(exported.captchaSolvers, 'captchaSolvers', 'solve', path)
292
+ };
293
+ }
294
+ /**
295
+ * Loads the hooks of a hooks or plugins module (see {@link loadPlugins}).
296
+ *
297
+ * @param path - A `.mjs` / `.js` / `.cjs` file.
298
+ * @param importModule - How to import it.
299
+ * @returns The hooks by name.
300
+ * @throws Error naming the file when it cannot be imported or exports no hooks.
301
+ */
302
+ async function loadHooks(path, importModule = importByUrl) {
303
+ const {
304
+ hooks
305
+ } = await loadPlugins(path, importModule);
306
+ if (Object.keys(hooks).length === 0) throw new Error(`${path}: exports no hooks (export default { name: function } or named functions)`);
307
+ return hooks;
308
+ }
309
+ function hooksOf(candidates, path) {
310
+ const hooks = {};
311
+ for (const [name, value] of Object.entries(candidates)) {
312
+ if (typeof value !== 'function') throw new Error(`${path}: hook "${name}" is ${value === null ? 'null' : typeof value}, not a function`);
313
+ hooks[name] = value;
314
+ }
315
+ return hooks;
316
+ }
317
+ function objectExport(value, name, path) {
318
+ if (value === undefined) return {};
319
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new Error(`${path}: "${name}" must be an object of functions`);
320
+ return value;
321
+ }
322
+ /** A list of `{ name, <method> }` plugins, checked. */
323
+ function pluginsOf(value, exportName, method, path) {
324
+ if (value === undefined) return [];
325
+ if (!Array.isArray(value)) throw new Error(`${path}: "${exportName}" must be an array of { name, ${method} }`);
326
+ const names = new Set();
327
+ for (const [index, plugin] of value.entries()) {
328
+ const entry = plugin;
329
+ if (typeof entry?.name !== 'string' || entry.name === '' || typeof entry[method] !== 'function') throw new Error(`${path}: ${exportName}[${index}] must be { name: string, ${method}: function }`);
330
+ if (names.has(entry.name)) throw new Error(`${path}: ${exportName} names "${entry.name}" twice`);
331
+ names.add(entry.name);
332
+ }
333
+ return value;
334
+ }
335
+ async function importByUrl(url) {
336
+ return await import(url);
337
+ }
338
+ function hookExports(exported) {
339
+ const fallback = exported.default;
340
+ if (typeof fallback === 'object' && fallback !== null && !Array.isArray(fallback)) return fallback;
341
+ const named = Object.entries(exported).filter(([name]) => name !== 'default' && name !== 'module.exports');
342
+ return Object.fromEntries(named);
343
+ }
344
+
196
345
  /**
197
346
  * Finds where a fetched page's data lives: JSON-LD blocks, inline JSON objects
198
347
  * big enough to matter, URLs that look like a data endpoint, script hosts and
@@ -304,6 +453,255 @@ function tryParse(text) {
304
453
  }
305
454
  }
306
455
 
456
+ /** Rows shown per sheet in a probe, at most. */
457
+ const ROW_LIMIT$1 = 20;
458
+ /** How far below a header row a probe looks for the first data row. */
459
+ const LOOKAHEAD = 3;
460
+ /**
461
+ * Summarises a workbook for someone writing a recipe: its sheets, what their
462
+ * rows look like and where the tables start, so a `table` extract's `sheet`,
463
+ * `selector` and columns can be read off the result instead of guessed.
464
+ *
465
+ * @param document - The read workbook.
466
+ * @returns The findings.
467
+ */
468
+ function describeWorkbook(document) {
469
+ const visible = document.sheets.filter(sheet => sheet.hidden !== true);
470
+ return {
471
+ ...(document.csv !== undefined && {
472
+ csv: document.csv
473
+ }),
474
+ sheets: document.sheets.map(sheet => ({
475
+ name: sheet.name,
476
+ hidden: sheet.hidden === true,
477
+ rows: sheet.rows.length,
478
+ columns: Math.max(0, ...sheet.rows.map(row => row.length))
479
+ })),
480
+ rows: visible.flatMap(sheet => filledRows(sheet).slice(0, ROW_LIMIT$1).map(({
481
+ index,
482
+ cells
483
+ }) => ({
484
+ sheet: sheet.name,
485
+ row: index + 1,
486
+ text: cells.join(' | ')
487
+ }))),
488
+ headers: visible.flatMap(sheet => headersOf(sheet))
489
+ };
490
+ }
491
+ function headersOf(sheet) {
492
+ const rows = filledRows(sheet);
493
+ const merged = mergedRows(sheet);
494
+ return rows.flatMap(({
495
+ index,
496
+ cells
497
+ }, position) => {
498
+ if (cells.length < 2 || cells.some(cell => /\d/.test(cell))) return [];
499
+ if (rows.slice(position + 1, position + 1 + LOOKAHEAD).every(row => !(row.cells.length >= 2 && row.cells.some(cell => /\d/.test(cell))))) return [];
500
+ const header = {
501
+ sheet: sheet.name,
502
+ row: index + 1,
503
+ text: cells.join(' | '),
504
+ selector: `^${escape$1(cells[0])}`
505
+ };
506
+ return [merged.has(index) ? {
507
+ ...header,
508
+ hint: 'merged header cells: try "headerRows": 2'
509
+ } : header];
510
+ });
511
+ }
512
+ /** The non-empty rows, as their non-empty cells, whitespace collapsed. */
513
+ function filledRows(sheet) {
514
+ const hidden = new Set(sheet.hiddenRows);
515
+ return sheet.rows.flatMap((row, index) => {
516
+ const cells = row.map(cell => String(cell).replaceAll(/\s+/g, ' ').trim()).filter(cell => cell !== '');
517
+ return cells.length === 0 || hidden.has(index) ? [] : [{
518
+ index,
519
+ cells
520
+ }];
521
+ });
522
+ }
523
+ /** Rows a merged range starts on. */
524
+ function mergedRows(sheet) {
525
+ const rows = new Set();
526
+ const merges = sheet.merges ?? [];
527
+ for (const reference of merges) {
528
+ const match = /^\$?[A-Z]+\$?(\d+)/i.exec(reference);
529
+ if (match !== null) rows.add(Number(match[1]) - 1);
530
+ }
531
+ return rows;
532
+ }
533
+ function escape$1(text) {
534
+ return text.replaceAll(/[$()*+.?[\\\]^{|}]/g, String.raw`\$&`);
535
+ }
536
+
537
+ /** Short text boxes on one slide from which a probe suggests reading them as a table. */
538
+ const GRID_BOXES = 6;
539
+ const SHORT_TEXT = 40;
540
+ /**
541
+ * Summarises a deck for someone writing a recipe: its slides, where the native
542
+ * tables start, what the charts hold, and which slides lay text boxes out as
543
+ * a table.
544
+ *
545
+ * @param document - The read deck.
546
+ * @returns The findings.
547
+ */
548
+ function describeDeck(document) {
549
+ return {
550
+ width: document.width,
551
+ height: document.height,
552
+ slides: document.slides.map(slide => ({
553
+ number: slide.number,
554
+ title: slide.title ?? '',
555
+ hidden: slide.hidden,
556
+ shapes: slide.shapes.length,
557
+ tables: slide.tables.length,
558
+ charts: slide.charts.length
559
+ })),
560
+ headers: document.slides.flatMap(slide => describeWorkbook({
561
+ sheets: slide.tables
562
+ }).headers.map(({
563
+ sheet: _sheet,
564
+ row: _row,
565
+ ...header
566
+ }) => ({
567
+ slide: slide.number,
568
+ ...header
569
+ }))),
570
+ charts: document.slides.flatMap(slide => slide.charts.map(chart => ({
571
+ slide: slide.number,
572
+ type: chart.type,
573
+ ...(chart.title !== undefined && {
574
+ title: chart.title
575
+ }),
576
+ series: chart.series.map(series => ({
577
+ name: series.name,
578
+ points: series.values.length
579
+ }))
580
+ }))),
581
+ grids: document.slides.flatMap(slide => {
582
+ const boxes = slide.shapes.filter(shape => shape.placeholder === undefined && shape.text.length <= SHORT_TEXT).length;
583
+ return boxes >= GRID_BOXES ? [{
584
+ slide: slide.number,
585
+ title: slide.title ?? '',
586
+ boxes
587
+ }] : [];
588
+ })
589
+ };
590
+ }
591
+
592
+ const SECTION = /<section data-heading="([^"]*)" data-level="(\d)">/g;
593
+ const FRONT_MATTER = /<script type="application\/json" data-front-matter>([\s\S]*?)<\/script>/;
594
+ /**
595
+ * Summarises an HTML document for a `table` extract, and, when it is rendered
596
+ * Markdown, its outline and front matter.
597
+ *
598
+ * @param html - The document.
599
+ * @param markdown - Whether it was rendered from Markdown.
600
+ * @returns The findings.
601
+ */
602
+ function describeHtml(html, markdown) {
603
+ const tables = htmlTableSheets(html);
604
+ const headers = describeWorkbook({
605
+ sheets: tables
606
+ }).headers.map(({
607
+ sheet,
608
+ row: _row,
609
+ ...header
610
+ }) => ({
611
+ table: sheet,
612
+ ...header
613
+ }));
614
+ if (!markdown) return {
615
+ tables: headers
616
+ };
617
+ const outline = Array.from(html.matchAll(SECTION), ([, heading, level]) => ({
618
+ level: Number(level),
619
+ heading: unescape(heading),
620
+ selector: `section[data-heading='${unescape(heading).replaceAll("'", String.raw`\'`)}' i]`
621
+ }));
622
+ const front = FRONT_MATTER.exec(html)?.[1];
623
+ const data = front === undefined ? undefined : JSON.parse(front);
624
+ return {
625
+ tables: headers,
626
+ outline,
627
+ frontMatter: typeof data === 'object' && data !== null && !Array.isArray(data) ? Object.keys(data) : []
628
+ };
629
+ }
630
+ function unescape(text) {
631
+ return text.replaceAll('&quot;', '"').replaceAll('&lt;', '<').replaceAll('&amp;', '&');
632
+ }
633
+
634
+ /** How deep a probe describes a JSON document. */
635
+ const DEPTH = 4;
636
+ /** Keys shown per object, at most. */
637
+ const KEY_LIMIT = 25;
638
+ /** Entries of a record list whose keys are compared. */
639
+ const SAMPLE = 50;
640
+ /** A sample value longer than this is cut. */
641
+ const SAMPLE_CHARS = 40;
642
+ /**
643
+ * Summarises JSON data for someone writing a recipe: its shape, and where its
644
+ * record lists are, so a `jsonpath` selector can be read off the result.
645
+ *
646
+ * @param data - The parsed document.
647
+ * @param format - What it was read as (`json`, `jsonl`, `yaml`).
648
+ * @returns The findings.
649
+ */
650
+ function describeJson(data, format) {
651
+ const tree = [];
652
+ const lists = [];
653
+ walk(data, '$', 0, tree, lists);
654
+ return {
655
+ format,
656
+ type: typeOf(data),
657
+ tree,
658
+ lists: lists.sort((first, second) => second.length - first.length)
659
+ };
660
+ }
661
+ function walk(value, path, depth, tree, lists) {
662
+ if (Array.isArray(value)) {
663
+ const objects = value.filter(entry => isObject(entry));
664
+ if (objects.length >= 2 && objects.length === value.length) lists.push({
665
+ path: `${path}[*]`,
666
+ length: value.length,
667
+ keys: sharedKeys(objects.slice(0, SAMPLE))
668
+ });
669
+ if (depth > 0) tree.push(`${path} ${typeOf(value)}`);
670
+ if (depth < DEPTH && value.length > 0) walk(value[0], `${path}[*]`, depth + 1, tree, lists);
671
+ return;
672
+ }
673
+ if (isObject(value)) {
674
+ if (depth > 0) tree.push(`${path} object`);
675
+ if (depth >= DEPTH) return;
676
+ for (const [key, child] of Object.entries(value).slice(0, KEY_LIMIT)) walk(child, childPath(path, key), depth + 1, tree, lists);
677
+ return;
678
+ }
679
+ tree.push(`${path} ${typeOf(value)}${sample(value)}`);
680
+ }
681
+ function typeOf(value) {
682
+ if (Array.isArray(value)) {
683
+ const kinds = [...new Set(value.slice(0, SAMPLE).map(entry => typeOf(entry).split(' ', 1)[0]))];
684
+ return `array (${value.length})${kinds.length === 0 ? '' : ` of ${kinds.join(' | ')}`}`;
685
+ }
686
+ if (value === null) return 'null';
687
+ return typeof value === 'object' ? 'object' : typeof value;
688
+ }
689
+ function sample(value) {
690
+ if (value === null || value === undefined) return '';
691
+ const text = JSON.stringify(value);
692
+ return ` ${text.length > SAMPLE_CHARS ? `${text.slice(0, SAMPLE_CHARS)}…` : text}`;
693
+ }
694
+ function sharedKeys(objects) {
695
+ const [first, ...rest] = objects;
696
+ return Object.keys(first).filter(key => rest.every(object => Object.hasOwn(object, key)));
697
+ }
698
+ function childPath(path, key) {
699
+ return /^[$A-Z_][\w$]*$/i.test(key) ? `${path}.${key}` : `${path}['${key.replaceAll("'", String.raw`\'`)}']`;
700
+ }
701
+ function isObject(value) {
702
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
703
+ }
704
+
307
705
  /** Rows shown in a probe, at most. */
308
706
  const ROW_LIMIT = 80;
309
707
  /**
@@ -358,10 +756,11 @@ function escape(text) {
358
756
  * @param status - The HTTP status.
359
757
  * @param findings - What `findData` found.
360
758
  * @param observed - JSON responses a browser saw, when `--browser` was used.
759
+ * @param html - The tables (and, for Markdown, the outline and front matter) found.
361
760
  * @returns The report.
362
761
  */
363
- function probeReport(url, status, findings, observed = []) {
364
- 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}`)) : ''];
762
+ function probeReport(url, status, findings, observed = [], html) {
763
+ const sections = [section('Markdown front matter (in script[data-front-matter])', html?.frontMatter === undefined || html.frontMatter.length === 0 ? [] : [` keys: ${html.frontMatter.join(', ')}`]), section('Markdown sections (a "css" selector for each)', (html?.outline ?? []).map(entry => ` ${' '.repeat(entry.level - 1)}${entry.selector}`)), section('HTML tables (a "table" extract selector for each)', (html?.tables ?? []).map(table => ` ${table.table} ${table.selector}${table.hint === undefined ? '' : ` (${table.hint})`}\n ${table.text}`)), section('JSON-LD blocks', findings.jsonLd.map(block => ` type=${block.types}, ${block.keys} keys`)), section('Inline JSON (candidates)', findings.inlineJson.map(block => ` ${block.where}, ${block.size} chars, keys: ${block.keys.slice(0, 8).join(', ')}`)), section('.json URLs referenced', findings.jsonUrls.map(url_ => ` ${url_}`)), section('Script hosts', findings.scriptHosts.map(host => ` ${host}`)), section('Links that look like an API', findings.apiLinks.map(link => ` ${link}`)), observed.length > 0 ? section('JSON responses observed in the browser', observed.map(entry => ` ${entry}`)) : ''];
365
764
  return [`${url} (HTTP ${status})`, '', ...sections].filter(line => line !== '').join('\n');
366
765
  }
367
766
  /**
@@ -374,6 +773,56 @@ function probeReport(url, status, findings, observed = []) {
374
773
  function pdfReport(url, pdf) {
375
774
  return [`${url} (PDF, ${pdf.pages} page${pdf.pages === 1 ? '' : 's'})`, '', section('Likely table headers (a "table" extract selector for each)', pdf.headers.map(header => ` p${header.page} ${header.selector}\n ${header.text}`)), section('First rows (cells separated by " | ")', pdf.rows.map(row => ` p${row.page} ${row.text}`))].filter(line => line !== '').join('\n');
376
775
  }
776
+ /**
777
+ * Formats what a probe found in a workbook (a CSV or a spreadsheet): its sheets,
778
+ * the rows that look like table headers with a selector for each, and the first
779
+ * rows.
780
+ *
781
+ * @param url - The workbook's URL.
782
+ * @param workbook - The findings.
783
+ * @returns The report.
784
+ */
785
+ function workbookReport(url, workbook) {
786
+ const read = workbook.csv === undefined ? 'workbook' : `CSV, ${workbook.csv.encoding}, delimited by ${delimiterName(workbook.csv.delimiter)}`;
787
+ return [`${url} (${read})`, '', section('Sheets', workbook.sheets.map(sheet => ` ${sheet.name}${sheet.hidden ? ' (hidden)' : ''}: ${sheet.rows} rows × ${sheet.columns} columns`)), section('Likely table headers (a "table" extract selector for each)', workbook.headers.map(header => ` ${header.sheet} r${header.row} ${header.selector}${header.hint === undefined ? '' : ` (${header.hint})`}\n ${header.text}`)), section('First rows (cells separated by " | ")', workbook.rows.map(row => ` ${row.sheet} r${row.row} ${row.text}`))].filter(line => line !== '').join('\n');
788
+ }
789
+ /**
790
+ * Formats what a probe found in a deck (a presentation): its slides, the native
791
+ * tables' headers with a selector for each, its charts, and the slides whose
792
+ * text boxes look like a table.
793
+ *
794
+ * @param url - The deck's URL.
795
+ * @param deck - The findings.
796
+ * @returns The report.
797
+ */
798
+ function deckReport(url, deck) {
799
+ return [`${url} (presentation, ${deck.slides.length} slide${deck.slides.length === 1 ? '' : 's'}, ${deck.width} × ${deck.height} pt)`, '', section('Slides', deck.slides.map(slide => ` ${slide.number} ${slide.title === '' ? '(no title)' : slide.title}${slide.hidden ? ' (hidden)' : ''}: ${slide.shapes} text boxes, ${slide.tables} tables, ${slide.charts} charts`)), section('Likely table headers (a "table" extract selector for each)', deck.headers.map(header => ` slide ${header.slide} ${header.selector}${header.hint === undefined ? '' : ` (${header.hint})`}\n ${header.text}`)), section('Text boxes laid out as a table (a "table" extract with "shapes": true)', deck.grids.map(grid => ` slide ${grid.slide} ${grid.title}: ${grid.boxes} short boxes`)), section('Charts (read with jsonpath: $.slides[*].charts[*].series[*])', deck.charts.map(chart => ` slide ${chart.slide} ${chart.type}${chart.title === undefined ? '' : ` "${chart.title}"`}: ${chart.series.map(series => `${series.name} (${series.points})`).join(', ')}`))].filter(line => line !== '').join('\n');
800
+ }
801
+ /**
802
+ * Formats what a probe found in JSON data: its record lists with the path a
803
+ * `jsonpath` extract needs, then its structure.
804
+ *
805
+ * @param url - The document's URL.
806
+ * @param json - The findings.
807
+ * @returns The report.
808
+ */
809
+ function jsonReport(url, json) {
810
+ const read = {
811
+ json: 'JSON',
812
+ jsonl: 'JSON Lines',
813
+ yaml: 'YAML'
814
+ }[json.format] ?? json.format;
815
+ return [`${url} (${read}: ${json.type})`, '', section('Record lists (a "jsonpath" selector for each, largest first)', json.lists.map(list => ` ${list.path} ${list.length} entries\n keys: ${list.keys.join(', ')}`)), section('Structure', json.tree.map(line => ` ${line}`))].filter(line => line !== '').join('\n');
816
+ }
817
+ function delimiterName(delimiter) {
818
+ const names = {
819
+ '\t': 'tabs',
820
+ ',': 'commas',
821
+ ';': 'semicolons',
822
+ '|': 'pipes'
823
+ };
824
+ return names[delimiter] ?? `"${delimiter}"`;
825
+ }
377
826
  function section(title, rows) {
378
827
  if (rows.length === 0) return '';
379
828
  return [`${title}:`, ...rows, ''].join('\n');
@@ -389,8 +838,8 @@ const OBSERVE_MS = 4000;
389
838
  * involved: the cli's `probePage` and `@opencraw/mcp`'s probe tool both
390
839
  * build on this, one printing the result, the other returning it as data.
391
840
  *
392
- * A PDF (by content type, or a local `.pdf` path) is read instead: its rows,
393
- * and the rows that look like table headers.
841
+ * A PDF, a spreadsheet, a CSV or a presentation (by content type, or a local
842
+ * path) is read instead: its rows, and the rows that look like table headers.
394
843
  *
395
844
  * @param url - The page to probe, or a local file path.
396
845
  * @param options - Browser path, TLS and user agent, plus whether to render.
@@ -398,7 +847,8 @@ const OBSERVE_MS = 4000;
398
847
  * @throws Error when the fetch itself fails.
399
848
  */
400
849
  async function probeUrl(url, options) {
401
- const lease = await new AccessBroker(await resolveAccess(options)).lease({
850
+ const plugins = options.plugins === undefined ? undefined : await loadPlugins(options.plugins);
851
+ const lease = await new AccessBroker(await resolveAccess(options), plugins?.accessPlugins).lease({
402
852
  recipeId: 'probe'
403
853
  });
404
854
  if (lease.cdp !== undefined) throw new Error(`access profile "${lease.profile}" is a remote browser; probe fetches over HTTP and needs a proxy profile`);
@@ -410,8 +860,13 @@ async function probeUrl(url, options) {
410
860
  });
411
861
  try {
412
862
  const target = /^[a-z][\w+.-]+:/i.test(url) ? url : pathToFileURL(resolve(url)).href;
863
+ // GitHub raw and CDNs serve Markdown as text/plain: a .md URL is read as Markdown.
864
+ const markdown = /\.(?:md|markdown)$/i.test(new URL(target).pathname);
413
865
  const response = await client.send({
414
- url: target
866
+ url: target,
867
+ ...(markdown && {
868
+ as: 'markdown'
869
+ })
415
870
  });
416
871
  const {
417
872
  body
@@ -423,13 +878,38 @@ async function probeUrl(url, options) {
423
878
  observed: [],
424
879
  pdf: describePdf(body)
425
880
  };
426
- const text = body.kind === 'html' ? body.html : body.kind === 'text' ? body.text : JSON.stringify(body.data);
881
+ if (body.kind === 'workbook') return {
882
+ url: response.url,
883
+ status: response.status,
884
+ findings: findData(''),
885
+ observed: [],
886
+ workbook: describeWorkbook(body)
887
+ };
888
+ if (body.kind === 'deck') return {
889
+ url: response.url,
890
+ status: response.status,
891
+ findings: findData(''),
892
+ observed: [],
893
+ deck: describeDeck(body)
894
+ };
895
+ if (body.kind === 'json') return {
896
+ url: response.url,
897
+ status: response.status,
898
+ findings: findData(''),
899
+ observed: [],
900
+ json: describeJson(body.data, response.format ?? 'json')
901
+ };
902
+ const text = body.kind === 'html' ? body.html : body.text;
427
903
  const observed = options.browser && !target.startsWith('file:') ? await observeBrowserJson(url, options, lease) : [];
904
+ const html = body.kind === 'html' ? {
905
+ html: describeHtml(body.html, response.format === 'markdown')
906
+ } : {};
428
907
  return {
429
908
  url: response.url,
430
909
  status: response.status,
431
910
  findings: findData(text),
432
- observed
911
+ observed,
912
+ ...html
433
913
  };
434
914
  } finally {
435
915
  await client.dispose();
@@ -446,7 +926,7 @@ async function probeUrl(url, options) {
446
926
  async function probePage(url, options, terminal) {
447
927
  try {
448
928
  const result = await probeUrl(url, options);
449
- terminal.out(result.pdf === undefined ? probeReport(result.url, result.status, result.findings, result.observed) : pdfReport(result.url, result.pdf));
929
+ terminal.out(reportOf(result));
450
930
  return 0;
451
931
  } catch (error) {
452
932
  terminal.err(`probe failed: ${error instanceof Error ? error.message : String(error)}`);
@@ -483,50 +963,12 @@ async function observeBrowserJson(url, options, lease) {
483
963
  await browser.close();
484
964
  }
485
965
  }
486
-
487
- /**
488
- * Loads the hooks a recipe's `hook` steps and transforms call, from a
489
- * JavaScript module: its default export when that is an object of functions
490
- * (`export default { positive: v => v > 0 }`), else its named function exports
491
- * (`export function positive (v) { ... }`).
492
- *
493
- * The module runs with the caller's privileges, like any code the caller
494
- * imports: point this only at a file you trust.
495
- *
496
- * @param path - A `.mjs` / `.js` / `.cjs` file.
497
- * @param importModule - How to import it; `import()` unless a test says otherwise.
498
- * @returns The hooks by name.
499
- * @throws Error naming the file when it cannot be imported, exports no hooks,
500
- * or exports something under a hook's name that is not a function.
501
- */
502
- async function loadHooks(path, importModule = importByUrl) {
503
- const url = pathToFileURL(resolve(path)).href;
504
- let exported;
505
- try {
506
- exported = await importModule(url);
507
- } catch (error) {
508
- throw new Error(`${path}: cannot load hooks (${error.message})`, {
509
- cause: error
510
- });
511
- }
512
- const candidates = hookExports(exported);
513
- const entries = Object.entries(candidates);
514
- if (entries.length === 0) throw new Error(`${path}: exports no hooks (export default { name: function } or named functions)`);
515
- const hooks = {};
516
- for (const [name, value] of entries) {
517
- if (typeof value !== 'function') throw new Error(`${path}: hook "${name}" is ${value === null ? 'null' : typeof value}, not a function`);
518
- hooks[name] = value;
519
- }
520
- return hooks;
521
- }
522
- async function importByUrl(url) {
523
- return await import(url);
524
- }
525
- function hookExports(exported) {
526
- const fallback = exported.default;
527
- if (typeof fallback === 'object' && fallback !== null && !Array.isArray(fallback)) return fallback;
528
- const named = Object.entries(exported).filter(([name]) => name !== 'default' && name !== 'module.exports');
529
- return Object.fromEntries(named);
966
+ function reportOf(result) {
967
+ if (result.pdf !== undefined) return pdfReport(result.url, result.pdf);
968
+ if (result.workbook !== undefined) return workbookReport(result.url, result.workbook);
969
+ if (result.deck !== undefined) return deckReport(result.url, result.deck);
970
+ if (result.json !== undefined) return jsonReport(result.url, result.json);
971
+ return probeReport(result.url, result.status, result.findings, result.observed, result.html);
530
972
  }
531
973
 
532
974
  /**
@@ -600,10 +1042,10 @@ async function runRecipes(command, terminal) {
600
1042
  return 1;
601
1043
  }
602
1044
  let access;
603
- let hooks;
1045
+ let plugins;
604
1046
  try {
605
1047
  access = await resolveAccess(command.options);
606
- hooks = command.hooks === undefined ? undefined : await loadHooks(command.hooks);
1048
+ plugins = command.options.plugins === undefined ? undefined : await loadPlugins(command.options.plugins);
607
1049
  } catch (error) {
608
1050
  terminal.err(error instanceof Error ? error.message : String(error));
609
1051
  return 1;
@@ -614,7 +1056,15 @@ async function runRecipes(command, terminal) {
614
1056
  const crawler = createCrawler({
615
1057
  sink,
616
1058
  access,
617
- hooks,
1059
+ throttle: throttleFor(command, access?.throttle),
1060
+ profilesDir: command.profiles,
1061
+ parallel: command.parallel,
1062
+ retry: command.retries === undefined ? undefined : {
1063
+ attempts: command.retries
1064
+ },
1065
+ hooks: plugins?.hooks,
1066
+ accessPlugins: plugins?.accessPlugins,
1067
+ captchaSolvers: plugins?.captchaSolvers,
618
1068
  resume: command.resume,
619
1069
  debug: command.dryRun,
620
1070
  browser: {
@@ -643,6 +1093,23 @@ async function runRecipes(command, terminal) {
643
1093
  await crawler.close();
644
1094
  }
645
1095
  }
1096
+ /** The access config's throttle, with `--host-delay` / `--host-concurrency` over its defaults. */
1097
+ function throttleFor(command, fromConfig) {
1098
+ const {
1099
+ delayMs,
1100
+ concurrency
1101
+ } = command.throttle;
1102
+ if (delayMs === undefined && concurrency === undefined) return fromConfig;
1103
+ return {
1104
+ ...fromConfig,
1105
+ ...(delayMs !== undefined && {
1106
+ delayMs
1107
+ }),
1108
+ ...(concurrency !== undefined && {
1109
+ concurrency
1110
+ })
1111
+ };
1112
+ }
646
1113
  function onEvent(event, command, terminal) {
647
1114
  if (command.trace) {
648
1115
  const line = traceLine(event);
@@ -765,5 +1232,5 @@ function processTerminal() {
765
1232
  };
766
1233
  }
767
1234
 
768
- export { USAGE, findData, loadForRun, loadHooks, main, parseArguments, probeReport, probeUrl, processTerminal, readRecipeFiles, resolveAccess };
1235
+ export { USAGE, findData, loadForRun, loadHooks, loadPlugins, main, parseArguments, probeReport, probeUrl, processTerminal, readRecipeFiles, resolveAccess };
769
1236
  //# sourceMappingURL=index.esm.js.map
@@ -10,6 +10,8 @@ export interface CommonOptions {
10
10
  access?: string;
11
11
  /** The access profile to use by default, overriding the config's `default`. */
12
12
  accessProfile?: string;
13
+ /** A plugins module: hooks, access plugins; `--hooks` / `OPENCRAW_PLUGINS` / `OPENCRAW_HOOKS` too. */
14
+ plugins?: string;
13
15
  }
14
16
  export type Command = {
15
17
  name: 'help';
@@ -30,9 +32,18 @@ export type Command = {
30
32
  dryRun: boolean;
31
33
  /** Run only the input recipes with these ids. */
32
34
  only: string[];
33
- /** A JavaScript module exporting the hooks the recipes call; `OPENCRAW_HOOKS` when not given. */
34
- hooks?: string;
35
35
  headed: boolean;
36
+ /** How many input recipes run at once. */
37
+ parallel?: number;
38
+ /** Tries per request for recipes whose `limits.retry` says nothing (1 turns retrying off). */
39
+ retries?: number;
40
+ /** Where `session.browserProfile` profiles live; `OPENCRAW_PROFILES` when not given. */
41
+ profiles?: string;
42
+ /** Per-site politeness across every recipe: overrides the access config's `throttle` defaults. */
43
+ throttle: {
44
+ delayMs?: number;
45
+ concurrency?: number;
46
+ };
36
47
  options: CommonOptions;
37
48
  } | {
38
49
  name: 'probe';
@@ -1,5 +1,5 @@
1
1
  import type { Command } from './command.contract.js';
2
- export declare const USAGE = "opencraw <command> [options]\n\nCommands\n validate <recipe files or directories...> Parse and bind the recipes; list every problem with its path.\n run <recipe files or directories...> Crawl. Records go to --out as JSON Lines, or to stdout.\n probe <url> Fetch a page and report where its data lives.\n\nOptions for run\n --out <file> Write records to this JSON Lines file (default: stdout).\n --append Keep what --out holds and add to it (each line carries _key).\n --resume Skip records --out already has (needs --append).\n --only <id> Run only this input recipe (repeatable).\n --dry-run One record per input, printed with the scope it was mapped from.\n --trace Print the crawl trace to stderr.\n --headed Show the browser.\n --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";
2
+ export declare const USAGE = "opencraw <command> [options]\n\nCommands\n validate <recipe files or directories...> Parse and bind the recipes; list every problem with its path.\n run <recipe files or directories...> Crawl. Records go to --out as JSON Lines, or to stdout.\n probe <url> Fetch a page and report where its data lives.\n\nOptions for run\n --out <file> Write records to this JSON Lines file (default: stdout).\n --append Keep what --out holds and add to it (each line carries _key).\n --resume Skip records --out already has (needs --append).\n --only <id> Run only this input recipe (repeatable).\n --dry-run One record per input, printed with the scope it was mapped from.\n --trace Print the crawl trace to stderr.\n --headed Show the browser.\n --parallel <n> Run this many input recipes at once (default 1). Iterations inside a recipe\n follow its limits.concurrency.\n --retries <n> Tries per request that fails in passing (connection, timeout, 429, 5xx), for\n recipes whose limits.retry says nothing. Default 3; 1 turns retrying off.\n --profiles <dir> Where recipes' session.browserProfile profiles live (or OPENCRAW_PROFILES;\n default .opencraw/profiles).\n --host-delay <ms> At least this long between two requests to one site, across every recipe.\n --host-concurrency <n>\n At most this many requests to one site in flight, across every recipe.\n Both override the access config's \"throttle\" defaults.\n\nOptions for probe\n --browser Also render the page in a browser and list the JSON it fetches.\n\nOptions for both\n --browser-path <p> A browser binary other than the one Playwright installed (or OPENCRAW_CHROMIUM).\n --insecure-tls Accept an intercepting proxy's certificate (or OPENCRAW_INSECURE_TLS=1).\n --user-agent <ua> The user agent to send.\n --access <file> An access config: proxy profiles, credentials as {{env.NAME}} (or OPENCRAW_ACCESS).\n --access-profile <name>\n The access profile to use when a recipe names none.\n --plugins <file> A JavaScript module exporting hooks (the recipes' hook steps and transforms),\n accessPlugins (for { kind: \"plugin\" } access profiles) and captchaSolvers\n (for session.captcha and captcha steps); --hooks, or OPENCRAW_PLUGINS /\n OPENCRAW_HOOKS. It runs as your code: trust it.\n --help, --version";
3
3
  /**
4
4
  * Turns argv into a command.
5
5
  *
@@ -1,3 +1,3 @@
1
- export { loadHooks } from './load-hooks.repository.js';
2
- export type { ModuleImporter } from './load-hooks.repository.js';
1
+ export { loadHooks, loadPlugins } from './load-hooks.repository.js';
2
+ export type { ModuleImporter, PluginModule } from './load-hooks.repository.js';
3
3
  //# sourceMappingURL=index.d.ts.map
@@ -1,18 +1,45 @@
1
- import type { HookMap } from '@opencraw/core';
1
+ import type { AccessPlugin, CaptchaSolver, HookMap } from '@opencraw/core';
2
+ /** What a plugins module provides: hooks for `hook` steps and transforms, and plugins the engine calls. */
3
+ export interface PluginModule {
4
+ hooks: HookMap;
5
+ /** Access plugins: `{ kind: "plugin", name }` profiles in the access config use them. */
6
+ accessPlugins: AccessPlugin[];
7
+ /** Captcha solvers: recipes name them in `session.captcha.solver` and `captcha` steps. */
8
+ captchaSolvers: CaptchaSolver[];
9
+ }
2
10
  /**
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) { ... }`).
11
+ * Loads a plugins module: a JavaScript module whose named exports `hooks`
12
+ * (`{ name: function }`), `accessPlugins` (`[{ name, lease }]`) and
13
+ * `captchaSolvers` (`[{ name, solve }]`) give what recipes and access profiles
14
+ * refer to by name:
15
+ *
16
+ * ```js
17
+ * export const hooks = { positive: v => v > 0 }
18
+ * export const accessPlugins = [{ name: 'browserbase', lease: async () => ({ cdp: { endpoint } }) }]
19
+ * export const captchaSolvers = [{ name: 'capsolver', solve: async (challenge, { page }) => ({ status: 'solved' }) }]
20
+ * ```
21
+ *
22
+ * A module with none of those names is a bare hooks module, as before: its
23
+ * default export when that is an object of functions, else its named function
24
+ * exports.
7
25
  *
8
26
  * The module runs with the caller's privileges, like any code the caller
9
27
  * imports: point this only at a file you trust.
10
28
  *
11
29
  * @param path - A `.mjs` / `.js` / `.cjs` file.
12
30
  * @param importModule - How to import it; `import()` unless a test says otherwise.
31
+ * @returns The hooks and plugins.
32
+ * @throws Error naming the file when it cannot be imported, provides nothing,
33
+ * or provides something that is not what its name promises.
34
+ */
35
+ export declare function loadPlugins(path: string, importModule?: ModuleImporter): Promise<PluginModule>;
36
+ /**
37
+ * Loads the hooks of a hooks or plugins module (see {@link loadPlugins}).
38
+ *
39
+ * @param path - A `.mjs` / `.js` / `.cjs` file.
40
+ * @param importModule - How to import it.
13
41
  * @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.
42
+ * @throws Error naming the file when it cannot be imported or exports no hooks.
16
43
  */
17
44
  export declare function loadHooks(path: string, importModule?: ModuleImporter): Promise<HookMap>;
18
45
  /** Imports a module by URL, returning its namespace. */
@@ -10,5 +10,6 @@ export type { RecipeFiles } from './recipe-files/index.js';
10
10
  export { loadForRun } from './run/index.js';
11
11
  export type { CommonOptions } from './arguments/index.js';
12
12
  export { resolveAccess } from './access/index.js';
13
- export { loadHooks } from './hooks-module/index.js';
13
+ export { loadHooks, loadPlugins } from './hooks-module/index.js';
14
+ export type { PluginModule } from './hooks-module/index.js';
14
15
  //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,46 @@
1
+ import type { DeckDocument } from '@opencraw/core';
2
+ /** What a probe shows of a deck (a presentation). */
3
+ export interface DeckFindings {
4
+ width: number;
5
+ height: number;
6
+ slides: {
7
+ number: number;
8
+ title: string;
9
+ hidden: boolean;
10
+ shapes: number;
11
+ tables: number;
12
+ charts: number;
13
+ }[];
14
+ /** The likely header rows of native tables, with the `slide` and `selector` a `table` extract needs. */
15
+ headers: {
16
+ slide: number;
17
+ text: string;
18
+ selector: string;
19
+ hint?: string;
20
+ }[];
21
+ charts: {
22
+ slide: number;
23
+ type: string;
24
+ title?: string;
25
+ series: {
26
+ name: string;
27
+ points: number;
28
+ }[];
29
+ }[];
30
+ /** Slides whose short text boxes look like a table: read them with `"shapes": true`. */
31
+ grids: {
32
+ slide: number;
33
+ title: string;
34
+ boxes: number;
35
+ }[];
36
+ }
37
+ /**
38
+ * Summarises a deck for someone writing a recipe: its slides, where the native
39
+ * tables start, what the charts hold, and which slides lay text boxes out as
40
+ * a table.
41
+ *
42
+ * @param document - The read deck.
43
+ * @returns The findings.
44
+ */
45
+ export declare function describeDeck(document: DeckDocument): DeckFindings;
46
+ //# sourceMappingURL=deck-findings.mapper.d.ts.map
@@ -0,0 +1,28 @@
1
+ /** What a probe shows of an HTML document's tables, and of rendered Markdown's sections and front matter. */
2
+ export interface HtmlFindings {
3
+ /** The likely header rows of the `<table>`s, with the `selector` a `table` extract needs. */
4
+ tables: {
5
+ table: string;
6
+ text: string;
7
+ selector: string;
8
+ hint?: string;
9
+ }[];
10
+ /** Rendered Markdown: its headings, each with the selector of its section. */
11
+ outline?: {
12
+ level: number;
13
+ heading: string;
14
+ selector: string;
15
+ }[];
16
+ /** Rendered Markdown: the keys of its front matter. */
17
+ frontMatter?: string[];
18
+ }
19
+ /**
20
+ * Summarises an HTML document for a `table` extract, and, when it is rendered
21
+ * Markdown, its outline and front matter.
22
+ *
23
+ * @param html - The document.
24
+ * @param markdown - Whether it was rendered from Markdown.
25
+ * @returns The findings.
26
+ */
27
+ export declare function describeHtml(html: string, markdown: boolean): HtmlFindings;
28
+ //# sourceMappingURL=html-findings.mapper.d.ts.map
@@ -2,7 +2,15 @@ export { probePage, probeUrl } from './probe-page.use-case.js';
2
2
  export type { ProbeResult } from './probe-page.use-case.js';
3
3
  export { findData } from './find-data.algorithm.js';
4
4
  export type { ProbeFindings, JsonLdFinding, InlineJsonFinding } from './find-data.algorithm.js';
5
- export { probeReport, pdfReport } from './probe-report.mapper.js';
5
+ export { probeReport, pdfReport, workbookReport, deckReport, jsonReport } from './probe-report.mapper.js';
6
6
  export { describePdf } from './pdf-findings.mapper.js';
7
7
  export type { PdfFindings } from './pdf-findings.mapper.js';
8
+ export { describeWorkbook } from './workbook-findings.mapper.js';
9
+ export type { WorkbookFindings } from './workbook-findings.mapper.js';
10
+ export { describeDeck } from './deck-findings.mapper.js';
11
+ export type { DeckFindings } from './deck-findings.mapper.js';
12
+ export { describeJson } from './json-findings.mapper.js';
13
+ export { describeHtml } from './html-findings.mapper.js';
14
+ export type { HtmlFindings } from './html-findings.mapper.js';
15
+ export type { JsonFindings } from './json-findings.mapper.js';
8
16
  //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,31 @@
1
+ /** What a probe shows of JSON data (a JSON, JSON Lines or YAML body). */
2
+ export interface JsonFindings {
3
+ /** What the body was read as. */
4
+ format: string;
5
+ /** `object`, `array (n)`, or a scalar type. */
6
+ type: string;
7
+ /**
8
+ * The document's structure down to four levels, one line per node:
9
+ * `$.data.products array (1240) of object`, `$.data.products[*].name string "Pandina"`.
10
+ */
11
+ tree: string[];
12
+ /**
13
+ * Every array of objects with two entries or more, largest first: the
14
+ * `forEach` or `each` a recipe needs, with the keys all its entries share.
15
+ */
16
+ lists: {
17
+ path: string;
18
+ length: number;
19
+ keys: string[];
20
+ }[];
21
+ }
22
+ /**
23
+ * Summarises JSON data for someone writing a recipe: its shape, and where its
24
+ * record lists are, so a `jsonpath` selector can be read off the result.
25
+ *
26
+ * @param data - The parsed document.
27
+ * @param format - What it was read as (`json`, `jsonl`, `yaml`).
28
+ * @returns The findings.
29
+ */
30
+ export declare function describeJson(data: unknown, format: string): JsonFindings;
31
+ //# sourceMappingURL=json-findings.mapper.d.ts.map
@@ -1,7 +1,11 @@
1
1
  import type { CommonOptions } from '../arguments/index.js';
2
2
  import type { Terminal } from '../terminal/index.js';
3
3
  import { findData } from './find-data.algorithm.js';
4
+ import type { DeckFindings } from './deck-findings.mapper.js';
5
+ import type { HtmlFindings } from './html-findings.mapper.js';
6
+ import type { JsonFindings } from './json-findings.mapper.js';
4
7
  import type { PdfFindings } from './pdf-findings.mapper.js';
8
+ import type { WorkbookFindings } from './workbook-findings.mapper.js';
5
9
  /** What fetching and, optionally, rendering a page found. */
6
10
  export interface ProbeResult {
7
11
  url: string;
@@ -11,6 +15,14 @@ export interface ProbeResult {
11
15
  observed: string[];
12
16
  /** Present when the URL is a PDF: its rows and likely table headers. */
13
17
  pdf?: PdfFindings;
18
+ /** Present when the URL is a CSV or a spreadsheet: its sheets, rows and likely table headers. */
19
+ workbook?: WorkbookFindings;
20
+ /** Present when the URL is a presentation: its slides, table headers, charts and text-box grids. */
21
+ deck?: DeckFindings;
22
+ /** Present when the URL is JSON, JSON Lines or YAML: its structure and record lists. */
23
+ json?: JsonFindings;
24
+ /** Present for HTML: its tables' headers; for rendered Markdown also its outline and front matter. */
25
+ html?: HtmlFindings;
14
26
  }
15
27
  /**
16
28
  * Fetches a page and finds where its data lives, through the access profile the
@@ -20,8 +32,8 @@ export interface ProbeResult {
20
32
  * involved: the cli's `probePage` and `@opencraw/mcp`'s probe tool both
21
33
  * build on this, one printing the result, the other returning it as data.
22
34
  *
23
- * A PDF (by content type, or a local `.pdf` path) is read instead: its rows,
24
- * and the rows that look like table headers.
35
+ * A PDF, a spreadsheet, a CSV or a presentation (by content type, or a local
36
+ * path) is read instead: its rows, and the rows that look like table headers.
25
37
  *
26
38
  * @param url - The page to probe, or a local file path.
27
39
  * @param options - Browser path, TLS and user agent, plus whether to render.
@@ -1,5 +1,9 @@
1
1
  import type { ProbeFindings } from './find-data.algorithm.js';
2
+ import type { DeckFindings } from './deck-findings.mapper.js';
3
+ import type { HtmlFindings } from './html-findings.mapper.js';
4
+ import type { JsonFindings } from './json-findings.mapper.js';
2
5
  import type { PdfFindings } from './pdf-findings.mapper.js';
6
+ import type { WorkbookFindings } from './workbook-findings.mapper.js';
3
7
  /**
4
8
  * Renders a probe's findings as text.
5
9
  *
@@ -7,9 +11,10 @@ import type { PdfFindings } from './pdf-findings.mapper.js';
7
11
  * @param status - The HTTP status.
8
12
  * @param findings - What `findData` found.
9
13
  * @param observed - JSON responses a browser saw, when `--browser` was used.
14
+ * @param html - The tables (and, for Markdown, the outline and front matter) found.
10
15
  * @returns The report.
11
16
  */
12
- export declare function probeReport(url: string, status: number, findings: ProbeFindings, observed?: string[]): string;
17
+ export declare function probeReport(url: string, status: number, findings: ProbeFindings, observed?: string[], html?: HtmlFindings): string;
13
18
  /**
14
19
  * Renders what a probe found in a PDF as text.
15
20
  *
@@ -18,4 +23,33 @@ export declare function probeReport(url: string, status: number, findings: Probe
18
23
  * @returns The report.
19
24
  */
20
25
  export declare function pdfReport(url: string, pdf: PdfFindings): string;
26
+ /**
27
+ * Formats what a probe found in a workbook (a CSV or a spreadsheet): its sheets,
28
+ * the rows that look like table headers with a selector for each, and the first
29
+ * rows.
30
+ *
31
+ * @param url - The workbook's URL.
32
+ * @param workbook - The findings.
33
+ * @returns The report.
34
+ */
35
+ export declare function workbookReport(url: string, workbook: WorkbookFindings): string;
36
+ /**
37
+ * Formats what a probe found in a deck (a presentation): its slides, the native
38
+ * tables' headers with a selector for each, its charts, and the slides whose
39
+ * text boxes look like a table.
40
+ *
41
+ * @param url - The deck's URL.
42
+ * @param deck - The findings.
43
+ * @returns The report.
44
+ */
45
+ export declare function deckReport(url: string, deck: DeckFindings): string;
46
+ /**
47
+ * Formats what a probe found in JSON data: its record lists with the path a
48
+ * `jsonpath` extract needs, then its structure.
49
+ *
50
+ * @param url - The document's URL.
51
+ * @param json - The findings.
52
+ * @returns The report.
53
+ */
54
+ export declare function jsonReport(url: string, json: JsonFindings): string;
21
55
  //# sourceMappingURL=probe-report.mapper.d.ts.map
@@ -0,0 +1,43 @@
1
+ import type { WorkbookDocument } from '@opencraw/core';
2
+ /** What a probe shows of a workbook (a spreadsheet or a CSV). */
3
+ export interface WorkbookFindings {
4
+ /** How a CSV was read. */
5
+ csv?: {
6
+ encoding: string;
7
+ delimiter: string;
8
+ };
9
+ sheets: {
10
+ name: string;
11
+ hidden: boolean;
12
+ rows: number;
13
+ columns: number;
14
+ }[];
15
+ /** The first non-empty rows of each visible sheet, cells separated by " | ". */
16
+ rows: {
17
+ sheet: string;
18
+ row: number;
19
+ text: string;
20
+ }[];
21
+ /**
22
+ * Rows of text cells (no digit) with a data row (a digit) just below: likely
23
+ * table headers, with the `sheet` and `selector` a `table` extract needs, and
24
+ * a hint when the header has merged cells (a header over two rows).
25
+ */
26
+ headers: {
27
+ sheet: string;
28
+ row: number;
29
+ text: string;
30
+ selector: string;
31
+ hint?: string;
32
+ }[];
33
+ }
34
+ /**
35
+ * Summarises a workbook for someone writing a recipe: its sheets, what their
36
+ * rows look like and where the tables start, so a `table` extract's `sheet`,
37
+ * `selector` and columns can be read off the result instead of guessed.
38
+ *
39
+ * @param document - The read workbook.
40
+ * @returns The findings.
41
+ */
42
+ export declare function describeWorkbook(document: WorkbookDocument): WorkbookFindings;
43
+ //# sourceMappingURL=workbook-findings.mapper.d.ts.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opencraw/cli",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "type": "module",
5
5
  "main": "./dist/index.esm.js",
6
6
  "module": "./dist/index.esm.js",
@@ -24,7 +24,7 @@
24
24
  "!**/*.js.map"
25
25
  ],
26
26
  "dependencies": {
27
- "@opencraw/core": "^0.1.1",
27
+ "@opencraw/core": "^0.1.3",
28
28
  "playwright": "^1.63.0"
29
29
  },
30
30
  "publishConfig": {