@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
- * @param url - The page to probe.
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 html = response.body.kind === 'html' ? response.body.html : '';
357
- const observed = options.browser ? await observeBrowserJson(url, options, lease) : [];
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(html),
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
- * @param url - The page to probe.
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.0",
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.0",
27
+ "@opencraw/core": "^0.1.1",
28
28
  "playwright": "^1.63.0"
29
29
  },
30
30
  "publishConfig": {