@opencraw/cli 0.1.1 → 0.1.2

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,7 @@ 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
+ | `--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
47
 
48
48
  ```sh
49
49
  opencraw run recipes/ --out out/products.jsonl --trace
@@ -62,12 +62,20 @@ opencraw probe https://example.com/product/1
62
62
  opencraw probe https://example.com/configurator --browser
63
63
  ```
64
64
 
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.
65
+ A PDF, a spreadsheet, a CSV or a presentation (a URL served with its content type, or a local `.pdf`,
66
+ `.xlsx`, `.csv`, `.tsv` or `.pptx` path) is read instead: `probe` lists its pages, sheets or slides, its first
67
+ rows, and every row that looks like a table header, with the `selector` a `table` extract needs. For a CSV it
68
+ also names the encoding and the delimiter it detected; for a presentation, its charts and the slides whose
69
+ text boxes look like a table. JSON, JSON Lines and YAML show their structure instead, and every list of
70
+ records with the `jsonpath` that walks it. An HTML page also lists its tables' header rows, and Markdown
71
+ (a `.md` URL is read as Markdown even when served as `text/plain`) its sections and front matter keys.
67
72
 
68
73
  ```sh
69
74
  opencraw probe https://example.com/price-list.pdf
70
75
  opencraw probe ./sheets/september.pdf
76
+ opencraw probe ./exports/listino.csv
77
+ opencraw probe ./exports/incentivi.xlsx
78
+ opencraw probe ./decks/incentivi.pptx
71
79
  ```
72
80
 
73
81
  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,6 @@ 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.
24
22
 
25
23
  Options for probe
26
24
  --browser Also render the page in a browser and list the JSON it fetches.
@@ -32,6 +30,10 @@ Options for both
32
30
  --access <file> An access config: proxy profiles, credentials as {{env.NAME}} (or OPENCRAW_ACCESS).
33
31
  --access-profile <name>
34
32
  The access profile to use when a recipe names none.
33
+ --plugins <file> A JavaScript module exporting hooks (the recipes' hook steps and transforms),
34
+ accessPlugins (for { kind: "plugin" } access profiles) and captchaSolvers
35
+ (for session.captcha and captcha steps); --hooks, or OPENCRAW_PLUGINS /
36
+ OPENCRAW_HOOKS. It runs as your code: trust it.
35
37
  --help, --version`;
36
38
  const OPTIONS = {
37
39
  'out': {
@@ -59,6 +61,9 @@ const OPTIONS = {
59
61
  'hooks': {
60
62
  type: 'string'
61
63
  },
64
+ 'plugins': {
65
+ type: 'string'
66
+ },
62
67
  'browser': {
63
68
  type: 'boolean'
64
69
  },
@@ -116,7 +121,8 @@ function parseArguments(argv, env = {}) {
116
121
  insecureTls: values['insecure-tls'] === true || env.OPENCRAW_INSECURE_TLS === '1',
117
122
  userAgent: values['user-agent'],
118
123
  access: values.access ?? (env.OPENCRAW_ACCESS === '' ? undefined : env.OPENCRAW_ACCESS),
119
- accessProfile: values['access-profile']
124
+ accessProfile: values['access-profile'],
125
+ plugins: values.plugins ?? values.hooks ?? nonEmpty(env.OPENCRAW_PLUGINS) ?? nonEmpty(env.OPENCRAW_HOOKS)
120
126
  };
121
127
  switch (name) {
122
128
  case undefined:
@@ -148,7 +154,6 @@ function parseArguments(argv, env = {}) {
148
154
  dryRun: values['dry-run'] === true,
149
155
  only: values.only ?? [],
150
156
  headed: values.headed === true,
151
- hooks: values.hooks ?? (env.OPENCRAW_HOOKS === '' ? undefined : env.OPENCRAW_HOOKS),
152
157
  options
153
158
  };
154
159
  }
@@ -168,6 +173,9 @@ function parseArguments(argv, env = {}) {
168
173
  }
169
174
  }
170
175
  }
176
+ function nonEmpty(value) {
177
+ return value === '' ? undefined : value;
178
+ }
171
179
 
172
180
  /**
173
181
  * The access config a command runs with: the file `--access` (or
@@ -193,6 +201,109 @@ async function resolveAccess(options) {
193
201
  };
194
202
  }
195
203
 
204
+ /** The named exports that mark a module as a plugins module rather than a bare hooks module. */
205
+ const PLUGIN_EXPORTS = ['hooks', 'accessPlugins', 'captchaSolvers'];
206
+ /**
207
+ * Loads a plugins module: a JavaScript module whose named exports `hooks`
208
+ * (`{ name: function }`), `accessPlugins` (`[{ name, lease }]`) and
209
+ * `captchaSolvers` (`[{ name, solve }]`) give what recipes and access profiles
210
+ * refer to by name:
211
+ *
212
+ * ```js
213
+ * export const hooks = { positive: v => v > 0 }
214
+ * export const accessPlugins = [{ name: 'browserbase', lease: async () => ({ cdp: { endpoint } }) }]
215
+ * export const captchaSolvers = [{ name: 'capsolver', solve: async (challenge, { page }) => ({ status: 'solved' }) }]
216
+ * ```
217
+ *
218
+ * A module with none of those names is a bare hooks module, as before: its
219
+ * default export when that is an object of functions, else its named function
220
+ * exports.
221
+ *
222
+ * The module runs with the caller's privileges, like any code the caller
223
+ * imports: point this only at a file you trust.
224
+ *
225
+ * @param path - A `.mjs` / `.js` / `.cjs` file.
226
+ * @param importModule - How to import it; `import()` unless a test says otherwise.
227
+ * @returns The hooks and plugins.
228
+ * @throws Error naming the file when it cannot be imported, provides nothing,
229
+ * or provides something that is not what its name promises.
230
+ */
231
+ async function loadPlugins(path, importModule = importByUrl) {
232
+ const url = pathToFileURL(resolve(path)).href;
233
+ let exported;
234
+ try {
235
+ exported = await importModule(url);
236
+ } catch (error) {
237
+ throw new Error(`${path}: cannot load hooks (${error.message})`, {
238
+ cause: error
239
+ });
240
+ }
241
+ if (PLUGIN_EXPORTS.every(name => !Object.hasOwn(exported, name))) {
242
+ const hooks = hooksOf(hookExports(exported), path);
243
+ 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)`);
244
+ return {
245
+ hooks,
246
+ accessPlugins: [],
247
+ captchaSolvers: []
248
+ };
249
+ }
250
+ return {
251
+ hooks: hooksOf(objectExport(exported.hooks, 'hooks', path), path),
252
+ accessPlugins: pluginsOf(exported.accessPlugins, 'accessPlugins', 'lease', path),
253
+ captchaSolvers: pluginsOf(exported.captchaSolvers, 'captchaSolvers', 'solve', path)
254
+ };
255
+ }
256
+ /**
257
+ * Loads the hooks of a hooks or plugins module (see {@link loadPlugins}).
258
+ *
259
+ * @param path - A `.mjs` / `.js` / `.cjs` file.
260
+ * @param importModule - How to import it.
261
+ * @returns The hooks by name.
262
+ * @throws Error naming the file when it cannot be imported or exports no hooks.
263
+ */
264
+ async function loadHooks(path, importModule = importByUrl) {
265
+ const {
266
+ hooks
267
+ } = await loadPlugins(path, importModule);
268
+ if (Object.keys(hooks).length === 0) throw new Error(`${path}: exports no hooks (export default { name: function } or named functions)`);
269
+ return hooks;
270
+ }
271
+ function hooksOf(candidates, path) {
272
+ const hooks = {};
273
+ for (const [name, value] of Object.entries(candidates)) {
274
+ if (typeof value !== 'function') throw new Error(`${path}: hook "${name}" is ${value === null ? 'null' : typeof value}, not a function`);
275
+ hooks[name] = value;
276
+ }
277
+ return hooks;
278
+ }
279
+ function objectExport(value, name, path) {
280
+ if (value === undefined) return {};
281
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new Error(`${path}: "${name}" must be an object of functions`);
282
+ return value;
283
+ }
284
+ /** A list of `{ name, <method> }` plugins, checked. */
285
+ function pluginsOf(value, exportName, method, path) {
286
+ if (value === undefined) return [];
287
+ if (!Array.isArray(value)) throw new Error(`${path}: "${exportName}" must be an array of { name, ${method} }`);
288
+ const names = new Set();
289
+ for (const [index, plugin] of value.entries()) {
290
+ const entry = plugin;
291
+ if (typeof entry?.name !== 'string' || entry.name === '' || typeof entry[method] !== 'function') throw new Error(`${path}: ${exportName}[${index}] must be { name: string, ${method}: function }`);
292
+ if (names.has(entry.name)) throw new Error(`${path}: ${exportName} names "${entry.name}" twice`);
293
+ names.add(entry.name);
294
+ }
295
+ return value;
296
+ }
297
+ async function importByUrl(url) {
298
+ return await import(url);
299
+ }
300
+ function hookExports(exported) {
301
+ const fallback = exported.default;
302
+ if (typeof fallback === 'object' && fallback !== null && !Array.isArray(fallback)) return fallback;
303
+ const named = Object.entries(exported).filter(([name]) => name !== 'default' && name !== 'module.exports');
304
+ return Object.fromEntries(named);
305
+ }
306
+
196
307
  /**
197
308
  * Finds where a fetched page's data lives: JSON-LD blocks, inline JSON objects
198
309
  * big enough to matter, URLs that look like a data endpoint, script hosts and
@@ -304,6 +415,255 @@ function tryParse(text) {
304
415
  }
305
416
  }
306
417
 
418
+ /** Rows shown per sheet in a probe, at most. */
419
+ const ROW_LIMIT$1 = 20;
420
+ /** How far below a header row a probe looks for the first data row. */
421
+ const LOOKAHEAD = 3;
422
+ /**
423
+ * Summarises a workbook for someone writing a recipe: its sheets, what their
424
+ * rows look like and where the tables start, so a `table` extract's `sheet`,
425
+ * `selector` and columns can be read off the result instead of guessed.
426
+ *
427
+ * @param document - The read workbook.
428
+ * @returns The findings.
429
+ */
430
+ function describeWorkbook(document) {
431
+ const visible = document.sheets.filter(sheet => sheet.hidden !== true);
432
+ return {
433
+ ...(document.csv !== undefined && {
434
+ csv: document.csv
435
+ }),
436
+ sheets: document.sheets.map(sheet => ({
437
+ name: sheet.name,
438
+ hidden: sheet.hidden === true,
439
+ rows: sheet.rows.length,
440
+ columns: Math.max(0, ...sheet.rows.map(row => row.length))
441
+ })),
442
+ rows: visible.flatMap(sheet => filledRows(sheet).slice(0, ROW_LIMIT$1).map(({
443
+ index,
444
+ cells
445
+ }) => ({
446
+ sheet: sheet.name,
447
+ row: index + 1,
448
+ text: cells.join(' | ')
449
+ }))),
450
+ headers: visible.flatMap(sheet => headersOf(sheet))
451
+ };
452
+ }
453
+ function headersOf(sheet) {
454
+ const rows = filledRows(sheet);
455
+ const merged = mergedRows(sheet);
456
+ return rows.flatMap(({
457
+ index,
458
+ cells
459
+ }, position) => {
460
+ if (cells.length < 2 || cells.some(cell => /\d/.test(cell))) return [];
461
+ if (rows.slice(position + 1, position + 1 + LOOKAHEAD).every(row => !(row.cells.length >= 2 && row.cells.some(cell => /\d/.test(cell))))) return [];
462
+ const header = {
463
+ sheet: sheet.name,
464
+ row: index + 1,
465
+ text: cells.join(' | '),
466
+ selector: `^${escape$1(cells[0])}`
467
+ };
468
+ return [merged.has(index) ? {
469
+ ...header,
470
+ hint: 'merged header cells: try "headerRows": 2'
471
+ } : header];
472
+ });
473
+ }
474
+ /** The non-empty rows, as their non-empty cells, whitespace collapsed. */
475
+ function filledRows(sheet) {
476
+ const hidden = new Set(sheet.hiddenRows);
477
+ return sheet.rows.flatMap((row, index) => {
478
+ const cells = row.map(cell => String(cell).replaceAll(/\s+/g, ' ').trim()).filter(cell => cell !== '');
479
+ return cells.length === 0 || hidden.has(index) ? [] : [{
480
+ index,
481
+ cells
482
+ }];
483
+ });
484
+ }
485
+ /** Rows a merged range starts on. */
486
+ function mergedRows(sheet) {
487
+ const rows = new Set();
488
+ const merges = sheet.merges ?? [];
489
+ for (const reference of merges) {
490
+ const match = /^\$?[A-Z]+\$?(\d+)/i.exec(reference);
491
+ if (match !== null) rows.add(Number(match[1]) - 1);
492
+ }
493
+ return rows;
494
+ }
495
+ function escape$1(text) {
496
+ return text.replaceAll(/[$()*+.?[\\\]^{|}]/g, String.raw`\$&`);
497
+ }
498
+
499
+ /** Short text boxes on one slide from which a probe suggests reading them as a table. */
500
+ const GRID_BOXES = 6;
501
+ const SHORT_TEXT = 40;
502
+ /**
503
+ * Summarises a deck for someone writing a recipe: its slides, where the native
504
+ * tables start, what the charts hold, and which slides lay text boxes out as
505
+ * a table.
506
+ *
507
+ * @param document - The read deck.
508
+ * @returns The findings.
509
+ */
510
+ function describeDeck(document) {
511
+ return {
512
+ width: document.width,
513
+ height: document.height,
514
+ slides: document.slides.map(slide => ({
515
+ number: slide.number,
516
+ title: slide.title ?? '',
517
+ hidden: slide.hidden,
518
+ shapes: slide.shapes.length,
519
+ tables: slide.tables.length,
520
+ charts: slide.charts.length
521
+ })),
522
+ headers: document.slides.flatMap(slide => describeWorkbook({
523
+ sheets: slide.tables
524
+ }).headers.map(({
525
+ sheet: _sheet,
526
+ row: _row,
527
+ ...header
528
+ }) => ({
529
+ slide: slide.number,
530
+ ...header
531
+ }))),
532
+ charts: document.slides.flatMap(slide => slide.charts.map(chart => ({
533
+ slide: slide.number,
534
+ type: chart.type,
535
+ ...(chart.title !== undefined && {
536
+ title: chart.title
537
+ }),
538
+ series: chart.series.map(series => ({
539
+ name: series.name,
540
+ points: series.values.length
541
+ }))
542
+ }))),
543
+ grids: document.slides.flatMap(slide => {
544
+ const boxes = slide.shapes.filter(shape => shape.placeholder === undefined && shape.text.length <= SHORT_TEXT).length;
545
+ return boxes >= GRID_BOXES ? [{
546
+ slide: slide.number,
547
+ title: slide.title ?? '',
548
+ boxes
549
+ }] : [];
550
+ })
551
+ };
552
+ }
553
+
554
+ const SECTION = /<section data-heading="([^"]*)" data-level="(\d)">/g;
555
+ const FRONT_MATTER = /<script type="application\/json" data-front-matter>([\s\S]*?)<\/script>/;
556
+ /**
557
+ * Summarises an HTML document for a `table` extract, and, when it is rendered
558
+ * Markdown, its outline and front matter.
559
+ *
560
+ * @param html - The document.
561
+ * @param markdown - Whether it was rendered from Markdown.
562
+ * @returns The findings.
563
+ */
564
+ function describeHtml(html, markdown) {
565
+ const tables = htmlTableSheets(html);
566
+ const headers = describeWorkbook({
567
+ sheets: tables
568
+ }).headers.map(({
569
+ sheet,
570
+ row: _row,
571
+ ...header
572
+ }) => ({
573
+ table: sheet,
574
+ ...header
575
+ }));
576
+ if (!markdown) return {
577
+ tables: headers
578
+ };
579
+ const outline = Array.from(html.matchAll(SECTION), ([, heading, level]) => ({
580
+ level: Number(level),
581
+ heading: unescape(heading),
582
+ selector: `section[data-heading='${unescape(heading).replaceAll("'", String.raw`\'`)}' i]`
583
+ }));
584
+ const front = FRONT_MATTER.exec(html)?.[1];
585
+ const data = front === undefined ? undefined : JSON.parse(front);
586
+ return {
587
+ tables: headers,
588
+ outline,
589
+ frontMatter: typeof data === 'object' && data !== null && !Array.isArray(data) ? Object.keys(data) : []
590
+ };
591
+ }
592
+ function unescape(text) {
593
+ return text.replaceAll('&quot;', '"').replaceAll('&lt;', '<').replaceAll('&amp;', '&');
594
+ }
595
+
596
+ /** How deep a probe describes a JSON document. */
597
+ const DEPTH = 4;
598
+ /** Keys shown per object, at most. */
599
+ const KEY_LIMIT = 25;
600
+ /** Entries of a record list whose keys are compared. */
601
+ const SAMPLE = 50;
602
+ /** A sample value longer than this is cut. */
603
+ const SAMPLE_CHARS = 40;
604
+ /**
605
+ * Summarises JSON data for someone writing a recipe: its shape, and where its
606
+ * record lists are, so a `jsonpath` selector can be read off the result.
607
+ *
608
+ * @param data - The parsed document.
609
+ * @param format - What it was read as (`json`, `jsonl`, `yaml`).
610
+ * @returns The findings.
611
+ */
612
+ function describeJson(data, format) {
613
+ const tree = [];
614
+ const lists = [];
615
+ walk(data, '$', 0, tree, lists);
616
+ return {
617
+ format,
618
+ type: typeOf(data),
619
+ tree,
620
+ lists: lists.sort((first, second) => second.length - first.length)
621
+ };
622
+ }
623
+ function walk(value, path, depth, tree, lists) {
624
+ if (Array.isArray(value)) {
625
+ const objects = value.filter(entry => isObject(entry));
626
+ if (objects.length >= 2 && objects.length === value.length) lists.push({
627
+ path: `${path}[*]`,
628
+ length: value.length,
629
+ keys: sharedKeys(objects.slice(0, SAMPLE))
630
+ });
631
+ if (depth > 0) tree.push(`${path} ${typeOf(value)}`);
632
+ if (depth < DEPTH && value.length > 0) walk(value[0], `${path}[*]`, depth + 1, tree, lists);
633
+ return;
634
+ }
635
+ if (isObject(value)) {
636
+ if (depth > 0) tree.push(`${path} object`);
637
+ if (depth >= DEPTH) return;
638
+ for (const [key, child] of Object.entries(value).slice(0, KEY_LIMIT)) walk(child, childPath(path, key), depth + 1, tree, lists);
639
+ return;
640
+ }
641
+ tree.push(`${path} ${typeOf(value)}${sample(value)}`);
642
+ }
643
+ function typeOf(value) {
644
+ if (Array.isArray(value)) {
645
+ const kinds = [...new Set(value.slice(0, SAMPLE).map(entry => typeOf(entry).split(' ', 1)[0]))];
646
+ return `array (${value.length})${kinds.length === 0 ? '' : ` of ${kinds.join(' | ')}`}`;
647
+ }
648
+ if (value === null) return 'null';
649
+ return typeof value === 'object' ? 'object' : typeof value;
650
+ }
651
+ function sample(value) {
652
+ if (value === null || value === undefined) return '';
653
+ const text = JSON.stringify(value);
654
+ return ` ${text.length > SAMPLE_CHARS ? `${text.slice(0, SAMPLE_CHARS)}…` : text}`;
655
+ }
656
+ function sharedKeys(objects) {
657
+ const [first, ...rest] = objects;
658
+ return Object.keys(first).filter(key => rest.every(object => Object.hasOwn(object, key)));
659
+ }
660
+ function childPath(path, key) {
661
+ return /^[$A-Z_][\w$]*$/i.test(key) ? `${path}.${key}` : `${path}['${key.replaceAll("'", String.raw`\'`)}']`;
662
+ }
663
+ function isObject(value) {
664
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
665
+ }
666
+
307
667
  /** Rows shown in a probe, at most. */
308
668
  const ROW_LIMIT = 80;
309
669
  /**
@@ -358,10 +718,11 @@ function escape(text) {
358
718
  * @param status - The HTTP status.
359
719
  * @param findings - What `findData` found.
360
720
  * @param observed - JSON responses a browser saw, when `--browser` was used.
721
+ * @param html - The tables (and, for Markdown, the outline and front matter) found.
361
722
  * @returns The report.
362
723
  */
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}`)) : ''];
724
+ function probeReport(url, status, findings, observed = [], html) {
725
+ const sections = [section('Markdown front matter (in script[data-front-matter])', html?.frontMatter === undefined || html.frontMatter.length === 0 ? [] : [` keys: ${html.frontMatter.join(', ')}`]), section('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
726
  return [`${url} (HTTP ${status})`, '', ...sections].filter(line => line !== '').join('\n');
366
727
  }
367
728
  /**
@@ -374,6 +735,56 @@ function probeReport(url, status, findings, observed = []) {
374
735
  function pdfReport(url, pdf) {
375
736
  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
737
  }
738
+ /**
739
+ * Formats what a probe found in a workbook (a CSV or a spreadsheet): its sheets,
740
+ * the rows that look like table headers with a selector for each, and the first
741
+ * rows.
742
+ *
743
+ * @param url - The workbook's URL.
744
+ * @param workbook - The findings.
745
+ * @returns The report.
746
+ */
747
+ function workbookReport(url, workbook) {
748
+ const read = workbook.csv === undefined ? 'workbook' : `CSV, ${workbook.csv.encoding}, delimited by ${delimiterName(workbook.csv.delimiter)}`;
749
+ 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');
750
+ }
751
+ /**
752
+ * Formats what a probe found in a deck (a presentation): its slides, the native
753
+ * tables' headers with a selector for each, its charts, and the slides whose
754
+ * text boxes look like a table.
755
+ *
756
+ * @param url - The deck's URL.
757
+ * @param deck - The findings.
758
+ * @returns The report.
759
+ */
760
+ function deckReport(url, deck) {
761
+ 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');
762
+ }
763
+ /**
764
+ * Formats what a probe found in JSON data: its record lists with the path a
765
+ * `jsonpath` extract needs, then its structure.
766
+ *
767
+ * @param url - The document's URL.
768
+ * @param json - The findings.
769
+ * @returns The report.
770
+ */
771
+ function jsonReport(url, json) {
772
+ const read = {
773
+ json: 'JSON',
774
+ jsonl: 'JSON Lines',
775
+ yaml: 'YAML'
776
+ }[json.format] ?? json.format;
777
+ return [`${url} (${read}: ${json.type})`, '', section('Record lists (a "jsonpath" selector for each, largest first)', json.lists.map(list => ` ${list.path} ${list.length} entries\n keys: ${list.keys.join(', ')}`)), section('Structure', json.tree.map(line => ` ${line}`))].filter(line => line !== '').join('\n');
778
+ }
779
+ function delimiterName(delimiter) {
780
+ const names = {
781
+ '\t': 'tabs',
782
+ ',': 'commas',
783
+ ';': 'semicolons',
784
+ '|': 'pipes'
785
+ };
786
+ return names[delimiter] ?? `"${delimiter}"`;
787
+ }
377
788
  function section(title, rows) {
378
789
  if (rows.length === 0) return '';
379
790
  return [`${title}:`, ...rows, ''].join('\n');
@@ -389,8 +800,8 @@ const OBSERVE_MS = 4000;
389
800
  * involved: the cli's `probePage` and `@opencraw/mcp`'s probe tool both
390
801
  * build on this, one printing the result, the other returning it as data.
391
802
  *
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.
803
+ * A PDF, a spreadsheet, a CSV or a presentation (by content type, or a local
804
+ * path) is read instead: its rows, and the rows that look like table headers.
394
805
  *
395
806
  * @param url - The page to probe, or a local file path.
396
807
  * @param options - Browser path, TLS and user agent, plus whether to render.
@@ -398,7 +809,8 @@ const OBSERVE_MS = 4000;
398
809
  * @throws Error when the fetch itself fails.
399
810
  */
400
811
  async function probeUrl(url, options) {
401
- const lease = await new AccessBroker(await resolveAccess(options)).lease({
812
+ const plugins = options.plugins === undefined ? undefined : await loadPlugins(options.plugins);
813
+ const lease = await new AccessBroker(await resolveAccess(options), plugins?.accessPlugins).lease({
402
814
  recipeId: 'probe'
403
815
  });
404
816
  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 +822,13 @@ async function probeUrl(url, options) {
410
822
  });
411
823
  try {
412
824
  const target = /^[a-z][\w+.-]+:/i.test(url) ? url : pathToFileURL(resolve(url)).href;
825
+ // GitHub raw and CDNs serve Markdown as text/plain: a .md URL is read as Markdown.
826
+ const markdown = /\.(?:md|markdown)$/i.test(new URL(target).pathname);
413
827
  const response = await client.send({
414
- url: target
828
+ url: target,
829
+ ...(markdown && {
830
+ as: 'markdown'
831
+ })
415
832
  });
416
833
  const {
417
834
  body
@@ -423,13 +840,38 @@ async function probeUrl(url, options) {
423
840
  observed: [],
424
841
  pdf: describePdf(body)
425
842
  };
426
- const text = body.kind === 'html' ? body.html : body.kind === 'text' ? body.text : JSON.stringify(body.data);
843
+ if (body.kind === 'workbook') return {
844
+ url: response.url,
845
+ status: response.status,
846
+ findings: findData(''),
847
+ observed: [],
848
+ workbook: describeWorkbook(body)
849
+ };
850
+ if (body.kind === 'deck') return {
851
+ url: response.url,
852
+ status: response.status,
853
+ findings: findData(''),
854
+ observed: [],
855
+ deck: describeDeck(body)
856
+ };
857
+ if (body.kind === 'json') return {
858
+ url: response.url,
859
+ status: response.status,
860
+ findings: findData(''),
861
+ observed: [],
862
+ json: describeJson(body.data, response.format ?? 'json')
863
+ };
864
+ const text = body.kind === 'html' ? body.html : body.text;
427
865
  const observed = options.browser && !target.startsWith('file:') ? await observeBrowserJson(url, options, lease) : [];
866
+ const html = body.kind === 'html' ? {
867
+ html: describeHtml(body.html, response.format === 'markdown')
868
+ } : {};
428
869
  return {
429
870
  url: response.url,
430
871
  status: response.status,
431
872
  findings: findData(text),
432
- observed
873
+ observed,
874
+ ...html
433
875
  };
434
876
  } finally {
435
877
  await client.dispose();
@@ -446,7 +888,7 @@ async function probeUrl(url, options) {
446
888
  async function probePage(url, options, terminal) {
447
889
  try {
448
890
  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));
891
+ terminal.out(reportOf(result));
450
892
  return 0;
451
893
  } catch (error) {
452
894
  terminal.err(`probe failed: ${error instanceof Error ? error.message : String(error)}`);
@@ -483,50 +925,12 @@ async function observeBrowserJson(url, options, lease) {
483
925
  await browser.close();
484
926
  }
485
927
  }
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);
928
+ function reportOf(result) {
929
+ if (result.pdf !== undefined) return pdfReport(result.url, result.pdf);
930
+ if (result.workbook !== undefined) return workbookReport(result.url, result.workbook);
931
+ if (result.deck !== undefined) return deckReport(result.url, result.deck);
932
+ if (result.json !== undefined) return jsonReport(result.url, result.json);
933
+ return probeReport(result.url, result.status, result.findings, result.observed, result.html);
530
934
  }
531
935
 
532
936
  /**
@@ -600,10 +1004,10 @@ async function runRecipes(command, terminal) {
600
1004
  return 1;
601
1005
  }
602
1006
  let access;
603
- let hooks;
1007
+ let plugins;
604
1008
  try {
605
1009
  access = await resolveAccess(command.options);
606
- hooks = command.hooks === undefined ? undefined : await loadHooks(command.hooks);
1010
+ plugins = command.options.plugins === undefined ? undefined : await loadPlugins(command.options.plugins);
607
1011
  } catch (error) {
608
1012
  terminal.err(error instanceof Error ? error.message : String(error));
609
1013
  return 1;
@@ -614,7 +1018,9 @@ async function runRecipes(command, terminal) {
614
1018
  const crawler = createCrawler({
615
1019
  sink,
616
1020
  access,
617
- hooks,
1021
+ hooks: plugins?.hooks,
1022
+ accessPlugins: plugins?.accessPlugins,
1023
+ captchaSolvers: plugins?.captchaSolvers,
618
1024
  resume: command.resume,
619
1025
  debug: command.dryRun,
620
1026
  browser: {
@@ -765,5 +1171,5 @@ function processTerminal() {
765
1171
  };
766
1172
  }
767
1173
 
768
- export { USAGE, findData, loadForRun, loadHooks, main, parseArguments, probeReport, probeUrl, processTerminal, readRecipeFiles, resolveAccess };
1174
+ export { USAGE, findData, loadForRun, loadHooks, loadPlugins, main, parseArguments, probeReport, probeUrl, processTerminal, readRecipeFiles, resolveAccess };
769
1175
  //# 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,8 +32,6 @@ 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
36
  options: CommonOptions;
37
37
  } | {
@@ -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\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.2",
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.2",
28
28
  "playwright": "^1.63.0"
29
29
  },
30
30
  "publishConfig": {