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