@opencraw/cli 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md
CHANGED
|
@@ -62,6 +62,14 @@ opencraw probe https://example.com/product/1
|
|
|
62
62
|
opencraw probe https://example.com/configurator --browser
|
|
63
63
|
```
|
|
64
64
|
|
|
65
|
+
A PDF (a URL served as `application/pdf`, or a local `.pdf` path) is read instead: `probe` lists its first
|
|
66
|
+
rows and every row that looks like a table header, with the `selector` a `table` extract needs.
|
|
67
|
+
|
|
68
|
+
```sh
|
|
69
|
+
opencraw probe https://example.com/price-list.pdf
|
|
70
|
+
opencraw probe ./sheets/september.pdf
|
|
71
|
+
```
|
|
72
|
+
|
|
65
73
|
Use it before writing an input recipe, to find the shape a site's data actually takes (§9 of
|
|
66
74
|
[the authoring guide](../../docs/recipes/authoring.md)).
|
|
67
75
|
|
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, 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
|
|
|
@@ -304,6 +304,53 @@ function tryParse(text) {
|
|
|
304
304
|
}
|
|
305
305
|
}
|
|
306
306
|
|
|
307
|
+
/** Rows shown in a probe, at most. */
|
|
308
|
+
const ROW_LIMIT = 80;
|
|
309
|
+
/**
|
|
310
|
+
* Summarises a PDF for someone writing a recipe: what the rows look like, and
|
|
311
|
+
* where the tables start, so a `table` extract's selector and columns can be
|
|
312
|
+
* read off the result instead of guessed.
|
|
313
|
+
*
|
|
314
|
+
* @param document - The read PDF.
|
|
315
|
+
* @returns The findings.
|
|
316
|
+
*/
|
|
317
|
+
function describePdf(document) {
|
|
318
|
+
const rows = document.pages.flatMap(page => page.rows.map((row, index) => ({
|
|
319
|
+
page: page.number,
|
|
320
|
+
row,
|
|
321
|
+
next: page.rows.slice(index + 1, index + 3)
|
|
322
|
+
})));
|
|
323
|
+
const headers = rows.filter(({
|
|
324
|
+
row,
|
|
325
|
+
next
|
|
326
|
+
}) => row.cells.length >= 2 && row.cells.every(cell => !/\d/.test(cell.text)) && next.some(candidate => dataRow(candidate)));
|
|
327
|
+
return {
|
|
328
|
+
pages: document.pages.length,
|
|
329
|
+
rows: rows.slice(0, ROW_LIMIT).map(({
|
|
330
|
+
page,
|
|
331
|
+
row
|
|
332
|
+
}) => ({
|
|
333
|
+
page,
|
|
334
|
+
text: row.cells.map(cell => cell.text).join(' | ')
|
|
335
|
+
})),
|
|
336
|
+
headers: headers.map(({
|
|
337
|
+
page,
|
|
338
|
+
row
|
|
339
|
+
}) => ({
|
|
340
|
+
page,
|
|
341
|
+
text: row.cells.map(cell => cell.text).join(' | '),
|
|
342
|
+
selector: `^${escape(row.cells[0].text)}`
|
|
343
|
+
}))
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
/** A row of at least two cells, one with a digit: data, not a heading. */
|
|
347
|
+
function dataRow(row) {
|
|
348
|
+
return row.cells.length >= 2 && row.cells.some(cell => /\d/.test(cell.text));
|
|
349
|
+
}
|
|
350
|
+
function escape(text) {
|
|
351
|
+
return text.replaceAll(/[$()*+.?[\\\]^{|}]/g, String.raw`\$&`);
|
|
352
|
+
}
|
|
353
|
+
|
|
307
354
|
/**
|
|
308
355
|
* Renders a probe's findings as text.
|
|
309
356
|
*
|
|
@@ -317,6 +364,16 @@ function probeReport(url, status, findings, observed = []) {
|
|
|
317
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}`)) : ''];
|
|
318
365
|
return [`${url} (HTTP ${status})`, '', ...sections].filter(line => line !== '').join('\n');
|
|
319
366
|
}
|
|
367
|
+
/**
|
|
368
|
+
* Renders what a probe found in a PDF as text.
|
|
369
|
+
*
|
|
370
|
+
* @param url - The URL or file probed.
|
|
371
|
+
* @param pdf - What `describePdf` found.
|
|
372
|
+
* @returns The report.
|
|
373
|
+
*/
|
|
374
|
+
function pdfReport(url, pdf) {
|
|
375
|
+
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
|
+
}
|
|
320
377
|
function section(title, rows) {
|
|
321
378
|
if (rows.length === 0) return '';
|
|
322
379
|
return [`${title}:`, ...rows, ''].join('\n');
|
|
@@ -332,7 +389,10 @@ const OBSERVE_MS = 4000;
|
|
|
332
389
|
* involved: the cli's `probePage` and `@opencraw/mcp`'s probe tool both
|
|
333
390
|
* build on this, one printing the result, the other returning it as data.
|
|
334
391
|
*
|
|
335
|
-
*
|
|
392
|
+
* A PDF (by content type, or a local `.pdf` path) is read instead: its rows,
|
|
393
|
+
* and the rows that look like table headers.
|
|
394
|
+
*
|
|
395
|
+
* @param url - The page to probe, or a local file path.
|
|
336
396
|
* @param options - Browser path, TLS and user agent, plus whether to render.
|
|
337
397
|
* @returns What was found.
|
|
338
398
|
* @throws Error when the fetch itself fails.
|
|
@@ -349,16 +409,26 @@ async function probeUrl(url, options) {
|
|
|
349
409
|
headers: lease.headers
|
|
350
410
|
});
|
|
351
411
|
try {
|
|
412
|
+
const target = /^[a-z][\w+.-]+:/i.test(url) ? url : pathToFileURL(resolve(url)).href;
|
|
352
413
|
const response = await client.send({
|
|
353
|
-
url
|
|
354
|
-
as: 'html'
|
|
414
|
+
url: target
|
|
355
415
|
});
|
|
356
|
-
const
|
|
357
|
-
|
|
416
|
+
const {
|
|
417
|
+
body
|
|
418
|
+
} = response;
|
|
419
|
+
if (body.kind === 'pdf') return {
|
|
420
|
+
url: response.url,
|
|
421
|
+
status: response.status,
|
|
422
|
+
findings: findData(''),
|
|
423
|
+
observed: [],
|
|
424
|
+
pdf: describePdf(body)
|
|
425
|
+
};
|
|
426
|
+
const text = body.kind === 'html' ? body.html : body.kind === 'text' ? body.text : JSON.stringify(body.data);
|
|
427
|
+
const observed = options.browser && !target.startsWith('file:') ? await observeBrowserJson(url, options, lease) : [];
|
|
358
428
|
return {
|
|
359
429
|
url: response.url,
|
|
360
430
|
status: response.status,
|
|
361
|
-
findings: findData(
|
|
431
|
+
findings: findData(text),
|
|
362
432
|
observed
|
|
363
433
|
};
|
|
364
434
|
} finally {
|
|
@@ -376,7 +446,7 @@ async function probeUrl(url, options) {
|
|
|
376
446
|
async function probePage(url, options, terminal) {
|
|
377
447
|
try {
|
|
378
448
|
const result = await probeUrl(url, options);
|
|
379
|
-
terminal.out(probeReport(result.url, result.status, result.findings, result.observed));
|
|
449
|
+
terminal.out(result.pdf === undefined ? probeReport(result.url, result.status, result.findings, result.observed) : pdfReport(result.url, result.pdf));
|
|
380
450
|
return 0;
|
|
381
451
|
} catch (error) {
|
|
382
452
|
terminal.err(`probe failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -565,7 +635,7 @@ async function runRecipes(command, terminal) {
|
|
|
565
635
|
const report = await crawler.run(new RecipeSet(set.output, inputs));
|
|
566
636
|
if (command.out === undefined && !command.dryRun) for (const record of sink.records) terminal.out(JSON.stringify(record.data));
|
|
567
637
|
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}`}`);
|
|
638
|
+
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
639
|
}
|
|
570
640
|
if (!command.dryRun) terminal.err(`${report.records} records${report.sink.location === undefined ? '' : ` written to ${report.sink.location}`}`);
|
|
571
641
|
return report.recipes.some(recipe => recipe.error !== undefined) ? 1 : 0;
|
|
@@ -2,5 +2,7 @@ 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 } from './probe-report.mapper.js';
|
|
6
|
+
export { describePdf } from './pdf-findings.mapper.js';
|
|
7
|
+
export type { PdfFindings } from './pdf-findings.mapper.js';
|
|
6
8
|
//# sourceMappingURL=index.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,7 @@
|
|
|
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 { PdfFindings } from './pdf-findings.mapper.js';
|
|
4
5
|
/** What fetching and, optionally, rendering a page found. */
|
|
5
6
|
export interface ProbeResult {
|
|
6
7
|
url: string;
|
|
@@ -8,6 +9,8 @@ export interface ProbeResult {
|
|
|
8
9
|
findings: ReturnType<typeof findData>;
|
|
9
10
|
/** JSON responses observed while the page rendered; empty unless `options.browser` was set. */
|
|
10
11
|
observed: string[];
|
|
12
|
+
/** Present when the URL is a PDF: its rows and likely table headers. */
|
|
13
|
+
pdf?: PdfFindings;
|
|
11
14
|
}
|
|
12
15
|
/**
|
|
13
16
|
* Fetches a page and finds where its data lives, through the access profile the
|
|
@@ -17,7 +20,10 @@ export interface ProbeResult {
|
|
|
17
20
|
* involved: the cli's `probePage` and `@opencraw/mcp`'s probe tool both
|
|
18
21
|
* build on this, one printing the result, the other returning it as data.
|
|
19
22
|
*
|
|
20
|
-
*
|
|
23
|
+
* A PDF (by content type, or a local `.pdf` path) is read instead: its rows,
|
|
24
|
+
* and the rows that look like table headers.
|
|
25
|
+
*
|
|
26
|
+
* @param url - The page to probe, or a local file path.
|
|
21
27
|
* @param options - Browser path, TLS and user agent, plus whether to render.
|
|
22
28
|
* @returns What was found.
|
|
23
29
|
* @throws Error when the fetch itself fails.
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ProbeFindings } from './find-data.algorithm.js';
|
|
2
|
+
import type { PdfFindings } from './pdf-findings.mapper.js';
|
|
2
3
|
/**
|
|
3
4
|
* Renders a probe's findings as text.
|
|
4
5
|
*
|
|
@@ -9,4 +10,12 @@ import type { ProbeFindings } from './find-data.algorithm.js';
|
|
|
9
10
|
* @returns The report.
|
|
10
11
|
*/
|
|
11
12
|
export declare function probeReport(url: string, status: number, findings: ProbeFindings, observed?: string[]): string;
|
|
13
|
+
/**
|
|
14
|
+
* Renders what a probe found in a PDF as text.
|
|
15
|
+
*
|
|
16
|
+
* @param url - The URL or file probed.
|
|
17
|
+
* @param pdf - What `describePdf` found.
|
|
18
|
+
* @returns The report.
|
|
19
|
+
*/
|
|
20
|
+
export declare function pdfReport(url: string, pdf: PdfFindings): string;
|
|
12
21
|
//# sourceMappingURL=probe-report.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.1",
|
|
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.1",
|
|
28
28
|
"playwright": "^1.63.0"
|
|
29
29
|
},
|
|
30
30
|
"publishConfig": {
|