@opencraw/cli 0.1.0 → 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 +17 -1
- package/dist/index.esm.js +542 -66
- package/dist/src/arguments/command.contract.d.ts +2 -2
- package/dist/src/arguments/parse-arguments.algorithm.d.ts +1 -1
- package/dist/src/hooks-module/index.d.ts +2 -2
- package/dist/src/hooks-module/load-hooks.repository.d.ts +34 -7
- package/dist/src/index.d.ts +2 -1
- package/dist/src/probe/deck-findings.mapper.d.ts +46 -0
- package/dist/src/probe/html-findings.mapper.d.ts +28 -0
- package/dist/src/probe/index.d.ts +11 -1
- package/dist/src/probe/json-findings.mapper.d.ts +31 -0
- package/dist/src/probe/pdf-findings.mapper.d.ts +26 -0
- package/dist/src/probe/probe-page.use-case.d.ts +19 -1
- package/dist/src/probe/probe-report.mapper.d.ts +44 -1
- package/dist/src/probe/workbook-findings.mapper.d.ts +43 -0
- package/package.json +2 -2
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
|
-
| `--
|
|
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,6 +62,22 @@ opencraw probe https://example.com/product/1
|
|
|
62
62
|
opencraw probe https://example.com/configurator --browser
|
|
63
63
|
```
|
|
64
64
|
|
|
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.
|
|
72
|
+
|
|
73
|
+
```sh
|
|
74
|
+
opencraw probe https://example.com/price-list.pdf
|
|
75
|
+
opencraw probe ./sheets/september.pdf
|
|
76
|
+
opencraw probe ./exports/listino.csv
|
|
77
|
+
opencraw probe ./exports/incentivi.xlsx
|
|
78
|
+
opencraw probe ./decks/incentivi.pptx
|
|
79
|
+
```
|
|
80
|
+
|
|
65
81
|
Use it before writing an input recipe, to find the shape a site's data actually takes (§9 of
|
|
66
82
|
[the authoring guide](../../docs/recipes/authoring.md)).
|
|
67
83
|
|
package/dist/index.esm.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { parseArgs } from 'node:util';
|
|
2
|
-
import { loadAccessConfig, AccessBroker, HttpClient, BrowserClient, readRecipeSource, parseOutputRecipe, parseInputRecipe, bindRecipeSet, memorySink, jsonLinesSink, createCrawler, RecipeSet, RecipeValidationError, RecipeBindingError, traceLine } from '@opencraw/core';
|
|
3
|
-
import { readFileSync } from 'node:fs';
|
|
4
2
|
import { resolve } from 'node:path';
|
|
5
3
|
import { pathToFileURL } from 'node:url';
|
|
4
|
+
import { loadAccessConfig, htmlTableSheets, AccessBroker, HttpClient, BrowserClient, readRecipeSource, parseOutputRecipe, parseInputRecipe, bindRecipeSet, memorySink, jsonLinesSink, createCrawler, RecipeSet, RecipeValidationError, RecipeBindingError, traceLine } from '@opencraw/core';
|
|
5
|
+
import { readFileSync } from 'node:fs';
|
|
6
6
|
|
|
7
7
|
const USAGE = `opencraw <command> [options]
|
|
8
8
|
|
|
@@ -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,302 @@ 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('"', '"').replaceAll('<', '<').replaceAll('&', '&');
|
|
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
|
+
|
|
667
|
+
/** Rows shown in a probe, at most. */
|
|
668
|
+
const ROW_LIMIT = 80;
|
|
669
|
+
/**
|
|
670
|
+
* Summarises a PDF for someone writing a recipe: what the rows look like, and
|
|
671
|
+
* where the tables start, so a `table` extract's selector and columns can be
|
|
672
|
+
* read off the result instead of guessed.
|
|
673
|
+
*
|
|
674
|
+
* @param document - The read PDF.
|
|
675
|
+
* @returns The findings.
|
|
676
|
+
*/
|
|
677
|
+
function describePdf(document) {
|
|
678
|
+
const rows = document.pages.flatMap(page => page.rows.map((row, index) => ({
|
|
679
|
+
page: page.number,
|
|
680
|
+
row,
|
|
681
|
+
next: page.rows.slice(index + 1, index + 3)
|
|
682
|
+
})));
|
|
683
|
+
const headers = rows.filter(({
|
|
684
|
+
row,
|
|
685
|
+
next
|
|
686
|
+
}) => row.cells.length >= 2 && row.cells.every(cell => !/\d/.test(cell.text)) && next.some(candidate => dataRow(candidate)));
|
|
687
|
+
return {
|
|
688
|
+
pages: document.pages.length,
|
|
689
|
+
rows: rows.slice(0, ROW_LIMIT).map(({
|
|
690
|
+
page,
|
|
691
|
+
row
|
|
692
|
+
}) => ({
|
|
693
|
+
page,
|
|
694
|
+
text: row.cells.map(cell => cell.text).join(' | ')
|
|
695
|
+
})),
|
|
696
|
+
headers: headers.map(({
|
|
697
|
+
page,
|
|
698
|
+
row
|
|
699
|
+
}) => ({
|
|
700
|
+
page,
|
|
701
|
+
text: row.cells.map(cell => cell.text).join(' | '),
|
|
702
|
+
selector: `^${escape(row.cells[0].text)}`
|
|
703
|
+
}))
|
|
704
|
+
};
|
|
705
|
+
}
|
|
706
|
+
/** A row of at least two cells, one with a digit: data, not a heading. */
|
|
707
|
+
function dataRow(row) {
|
|
708
|
+
return row.cells.length >= 2 && row.cells.some(cell => /\d/.test(cell.text));
|
|
709
|
+
}
|
|
710
|
+
function escape(text) {
|
|
711
|
+
return text.replaceAll(/[$()*+.?[\\\]^{|}]/g, String.raw`\$&`);
|
|
712
|
+
}
|
|
713
|
+
|
|
307
714
|
/**
|
|
308
715
|
* Renders a probe's findings as text.
|
|
309
716
|
*
|
|
@@ -311,12 +718,73 @@ function tryParse(text) {
|
|
|
311
718
|
* @param status - The HTTP status.
|
|
312
719
|
* @param findings - What `findData` found.
|
|
313
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.
|
|
314
722
|
* @returns The report.
|
|
315
723
|
*/
|
|
316
|
-
function probeReport(url, status, findings, observed = []) {
|
|
317
|
-
const sections = [section('JSON-LD blocks', findings.jsonLd.map(block => ` type=${block.types}, ${block.keys} keys`)), section('Inline JSON (candidates)', findings.inlineJson.map(block => ` ${block.where}, ${block.size} chars, keys: ${block.keys.slice(0, 8).join(', ')}`)), section('.json URLs referenced', findings.jsonUrls.map(url_ => ` ${url_}`)), section('Script hosts', findings.scriptHosts.map(host => ` ${host}`)), section('Links that look like an API', findings.apiLinks.map(link => ` ${link}`)), observed.length > 0 ? section('JSON responses observed in the browser', observed.map(entry => ` ${entry}`)) : ''];
|
|
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}`)) : ''];
|
|
318
726
|
return [`${url} (HTTP ${status})`, '', ...sections].filter(line => line !== '').join('\n');
|
|
319
727
|
}
|
|
728
|
+
/**
|
|
729
|
+
* Renders what a probe found in a PDF as text.
|
|
730
|
+
*
|
|
731
|
+
* @param url - The URL or file probed.
|
|
732
|
+
* @param pdf - What `describePdf` found.
|
|
733
|
+
* @returns The report.
|
|
734
|
+
*/
|
|
735
|
+
function pdfReport(url, pdf) {
|
|
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');
|
|
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
|
+
}
|
|
320
788
|
function section(title, rows) {
|
|
321
789
|
if (rows.length === 0) return '';
|
|
322
790
|
return [`${title}:`, ...rows, ''].join('\n');
|
|
@@ -332,13 +800,17 @@ const OBSERVE_MS = 4000;
|
|
|
332
800
|
* involved: the cli's `probePage` and `@opencraw/mcp`'s probe tool both
|
|
333
801
|
* build on this, one printing the result, the other returning it as data.
|
|
334
802
|
*
|
|
335
|
-
*
|
|
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.
|
|
805
|
+
*
|
|
806
|
+
* @param url - The page to probe, or a local file path.
|
|
336
807
|
* @param options - Browser path, TLS and user agent, plus whether to render.
|
|
337
808
|
* @returns What was found.
|
|
338
809
|
* @throws Error when the fetch itself fails.
|
|
339
810
|
*/
|
|
340
811
|
async function probeUrl(url, options) {
|
|
341
|
-
const
|
|
812
|
+
const plugins = options.plugins === undefined ? undefined : await loadPlugins(options.plugins);
|
|
813
|
+
const lease = await new AccessBroker(await resolveAccess(options), plugins?.accessPlugins).lease({
|
|
342
814
|
recipeId: 'probe'
|
|
343
815
|
});
|
|
344
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`);
|
|
@@ -349,17 +821,57 @@ async function probeUrl(url, options) {
|
|
|
349
821
|
headers: lease.headers
|
|
350
822
|
});
|
|
351
823
|
try {
|
|
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);
|
|
352
827
|
const response = await client.send({
|
|
353
|
-
url,
|
|
354
|
-
|
|
828
|
+
url: target,
|
|
829
|
+
...(markdown && {
|
|
830
|
+
as: 'markdown'
|
|
831
|
+
})
|
|
355
832
|
});
|
|
356
|
-
const
|
|
357
|
-
|
|
833
|
+
const {
|
|
834
|
+
body
|
|
835
|
+
} = response;
|
|
836
|
+
if (body.kind === 'pdf') return {
|
|
837
|
+
url: response.url,
|
|
838
|
+
status: response.status,
|
|
839
|
+
findings: findData(''),
|
|
840
|
+
observed: [],
|
|
841
|
+
pdf: describePdf(body)
|
|
842
|
+
};
|
|
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;
|
|
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
|
+
} : {};
|
|
358
869
|
return {
|
|
359
870
|
url: response.url,
|
|
360
871
|
status: response.status,
|
|
361
|
-
findings: findData(
|
|
362
|
-
observed
|
|
872
|
+
findings: findData(text),
|
|
873
|
+
observed,
|
|
874
|
+
...html
|
|
363
875
|
};
|
|
364
876
|
} finally {
|
|
365
877
|
await client.dispose();
|
|
@@ -376,7 +888,7 @@ async function probeUrl(url, options) {
|
|
|
376
888
|
async function probePage(url, options, terminal) {
|
|
377
889
|
try {
|
|
378
890
|
const result = await probeUrl(url, options);
|
|
379
|
-
terminal.out(
|
|
891
|
+
terminal.out(reportOf(result));
|
|
380
892
|
return 0;
|
|
381
893
|
} catch (error) {
|
|
382
894
|
terminal.err(`probe failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -413,50 +925,12 @@ async function observeBrowserJson(url, options, lease) {
|
|
|
413
925
|
await browser.close();
|
|
414
926
|
}
|
|
415
927
|
}
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
*
|
|
423
|
-
* The module runs with the caller's privileges, like any code the caller
|
|
424
|
-
* imports: point this only at a file you trust.
|
|
425
|
-
*
|
|
426
|
-
* @param path - A `.mjs` / `.js` / `.cjs` file.
|
|
427
|
-
* @param importModule - How to import it; `import()` unless a test says otherwise.
|
|
428
|
-
* @returns The hooks by name.
|
|
429
|
-
* @throws Error naming the file when it cannot be imported, exports no hooks,
|
|
430
|
-
* or exports something under a hook's name that is not a function.
|
|
431
|
-
*/
|
|
432
|
-
async function loadHooks(path, importModule = importByUrl) {
|
|
433
|
-
const url = pathToFileURL(resolve(path)).href;
|
|
434
|
-
let exported;
|
|
435
|
-
try {
|
|
436
|
-
exported = await importModule(url);
|
|
437
|
-
} catch (error) {
|
|
438
|
-
throw new Error(`${path}: cannot load hooks (${error.message})`, {
|
|
439
|
-
cause: error
|
|
440
|
-
});
|
|
441
|
-
}
|
|
442
|
-
const candidates = hookExports(exported);
|
|
443
|
-
const entries = Object.entries(candidates);
|
|
444
|
-
if (entries.length === 0) throw new Error(`${path}: exports no hooks (export default { name: function } or named functions)`);
|
|
445
|
-
const hooks = {};
|
|
446
|
-
for (const [name, value] of entries) {
|
|
447
|
-
if (typeof value !== 'function') throw new Error(`${path}: hook "${name}" is ${value === null ? 'null' : typeof value}, not a function`);
|
|
448
|
-
hooks[name] = value;
|
|
449
|
-
}
|
|
450
|
-
return hooks;
|
|
451
|
-
}
|
|
452
|
-
async function importByUrl(url) {
|
|
453
|
-
return await import(url);
|
|
454
|
-
}
|
|
455
|
-
function hookExports(exported) {
|
|
456
|
-
const fallback = exported.default;
|
|
457
|
-
if (typeof fallback === 'object' && fallback !== null && !Array.isArray(fallback)) return fallback;
|
|
458
|
-
const named = Object.entries(exported).filter(([name]) => name !== 'default' && name !== 'module.exports');
|
|
459
|
-
return Object.fromEntries(named);
|
|
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);
|
|
460
934
|
}
|
|
461
935
|
|
|
462
936
|
/**
|
|
@@ -530,10 +1004,10 @@ async function runRecipes(command, terminal) {
|
|
|
530
1004
|
return 1;
|
|
531
1005
|
}
|
|
532
1006
|
let access;
|
|
533
|
-
let
|
|
1007
|
+
let plugins;
|
|
534
1008
|
try {
|
|
535
1009
|
access = await resolveAccess(command.options);
|
|
536
|
-
|
|
1010
|
+
plugins = command.options.plugins === undefined ? undefined : await loadPlugins(command.options.plugins);
|
|
537
1011
|
} catch (error) {
|
|
538
1012
|
terminal.err(error instanceof Error ? error.message : String(error));
|
|
539
1013
|
return 1;
|
|
@@ -544,7 +1018,9 @@ async function runRecipes(command, terminal) {
|
|
|
544
1018
|
const crawler = createCrawler({
|
|
545
1019
|
sink,
|
|
546
1020
|
access,
|
|
547
|
-
hooks,
|
|
1021
|
+
hooks: plugins?.hooks,
|
|
1022
|
+
accessPlugins: plugins?.accessPlugins,
|
|
1023
|
+
captchaSolvers: plugins?.captchaSolvers,
|
|
548
1024
|
resume: command.resume,
|
|
549
1025
|
debug: command.dryRun,
|
|
550
1026
|
browser: {
|
|
@@ -565,7 +1041,7 @@ async function runRecipes(command, terminal) {
|
|
|
565
1041
|
const report = await crawler.run(new RecipeSet(set.output, inputs));
|
|
566
1042
|
if (command.out === undefined && !command.dryRun) for (const record of sink.records) terminal.out(JSON.stringify(record.data));
|
|
567
1043
|
for (const recipe of report.recipes) {
|
|
568
|
-
terminal.err(`${recipe.recipeId}: ${recipe.emitted} emitted, ${recipe.rejected} rejected, ${recipe.duplicates} duplicates, ${recipe.skipped} skipped, ${recipe.pages} pages, ${recipe.durationMs} ms${recipe.error === undefined ? '' : `, stopped: ${recipe.error}`}`);
|
|
1044
|
+
terminal.err(`${recipe.recipeId}: ${recipe.emitted} emitted, ${recipe.rejected} rejected, ${recipe.duplicates} duplicates, ${recipe.skipped} skipped, ${recipe.stepsSkipped > 0 ? `${recipe.stepsSkipped} steps skipped (see --trace), ` : ''}${recipe.pages} pages, ${recipe.durationMs} ms${recipe.error === undefined ? '' : `, stopped: ${recipe.error}`}`);
|
|
569
1045
|
}
|
|
570
1046
|
if (!command.dryRun) terminal.err(`${report.records} records${report.sink.location === undefined ? '' : ` written to ${report.sink.location}`}`);
|
|
571
1047
|
return report.recipes.some(recipe => recipe.error !== undefined) ? 1 : 0;
|
|
@@ -695,5 +1171,5 @@ function processTerminal() {
|
|
|
695
1171
|
};
|
|
696
1172
|
}
|
|
697
1173
|
|
|
698
|
-
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 };
|
|
699
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
|
|
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
|
|
4
|
-
*
|
|
5
|
-
* (`
|
|
6
|
-
*
|
|
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
|
|
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. */
|
package/dist/src/index.d.ts
CHANGED
|
@@ -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,5 +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 } from './probe-report.mapper.js';
|
|
5
|
+
export { probeReport, pdfReport, workbookReport, deckReport, jsonReport } from './probe-report.mapper.js';
|
|
6
|
+
export { describePdf } from './pdf-findings.mapper.js';
|
|
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';
|
|
6
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
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { PdfDocument } from '@opencraw/core';
|
|
2
|
+
/** What a probe shows of a PDF: its rows, and the rows that look like table headers. */
|
|
3
|
+
export interface PdfFindings {
|
|
4
|
+
pages: number;
|
|
5
|
+
/** The first rows, cells separated by " | ". */
|
|
6
|
+
rows: {
|
|
7
|
+
page: number;
|
|
8
|
+
text: string;
|
|
9
|
+
}[];
|
|
10
|
+
/** Rows of text cells (no digit) with a row of data (a digit) right under them: likely table headers, with a selector that finds them. */
|
|
11
|
+
headers: {
|
|
12
|
+
page: number;
|
|
13
|
+
text: string;
|
|
14
|
+
selector: string;
|
|
15
|
+
}[];
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Summarises a PDF for someone writing a recipe: what the rows look like, and
|
|
19
|
+
* where the tables start, so a `table` extract's selector and columns can be
|
|
20
|
+
* read off the result instead of guessed.
|
|
21
|
+
*
|
|
22
|
+
* @param document - The read PDF.
|
|
23
|
+
* @returns The findings.
|
|
24
|
+
*/
|
|
25
|
+
export declare function describePdf(document: PdfDocument): PdfFindings;
|
|
26
|
+
//# sourceMappingURL=pdf-findings.mapper.d.ts.map
|
|
@@ -1,6 +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';
|
|
7
|
+
import type { PdfFindings } from './pdf-findings.mapper.js';
|
|
8
|
+
import type { WorkbookFindings } from './workbook-findings.mapper.js';
|
|
4
9
|
/** What fetching and, optionally, rendering a page found. */
|
|
5
10
|
export interface ProbeResult {
|
|
6
11
|
url: string;
|
|
@@ -8,6 +13,16 @@ export interface ProbeResult {
|
|
|
8
13
|
findings: ReturnType<typeof findData>;
|
|
9
14
|
/** JSON responses observed while the page rendered; empty unless `options.browser` was set. */
|
|
10
15
|
observed: string[];
|
|
16
|
+
/** Present when the URL is a PDF: its rows and likely table headers. */
|
|
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;
|
|
11
26
|
}
|
|
12
27
|
/**
|
|
13
28
|
* Fetches a page and finds where its data lives, through the access profile the
|
|
@@ -17,7 +32,10 @@ export interface ProbeResult {
|
|
|
17
32
|
* involved: the cli's `probePage` and `@opencraw/mcp`'s probe tool both
|
|
18
33
|
* build on this, one printing the result, the other returning it as data.
|
|
19
34
|
*
|
|
20
|
-
*
|
|
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.
|
|
37
|
+
*
|
|
38
|
+
* @param url - The page to probe, or a local file path.
|
|
21
39
|
* @param options - Browser path, TLS and user agent, plus whether to render.
|
|
22
40
|
* @returns What was found.
|
|
23
41
|
* @throws Error when the fetch itself fails.
|
|
@@ -1,4 +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';
|
|
5
|
+
import type { PdfFindings } from './pdf-findings.mapper.js';
|
|
6
|
+
import type { WorkbookFindings } from './workbook-findings.mapper.js';
|
|
2
7
|
/**
|
|
3
8
|
* Renders a probe's findings as text.
|
|
4
9
|
*
|
|
@@ -6,7 +11,45 @@ import type { ProbeFindings } from './find-data.algorithm.js';
|
|
|
6
11
|
* @param status - The HTTP status.
|
|
7
12
|
* @param findings - What `findData` found.
|
|
8
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.
|
|
9
15
|
* @returns The report.
|
|
10
16
|
*/
|
|
11
|
-
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;
|
|
18
|
+
/**
|
|
19
|
+
* Renders what a probe found in a PDF as text.
|
|
20
|
+
*
|
|
21
|
+
* @param url - The URL or file probed.
|
|
22
|
+
* @param pdf - What `describePdf` found.
|
|
23
|
+
* @returns The report.
|
|
24
|
+
*/
|
|
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;
|
|
12
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.
|
|
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.
|
|
27
|
+
"@opencraw/core": "^0.1.2",
|
|
28
28
|
"playwright": "^1.63.0"
|
|
29
29
|
},
|
|
30
30
|
"publishConfig": {
|