@opencraw/core 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 +1 -0
- package/dist/index.esm.js +532 -12
- package/dist/src/api-steps/extract-from-document.use-case.d.ts +2 -1
- package/dist/src/api-steps/send-request.use-case.d.ts +1 -1
- package/dist/src/crawl-events/crawl-event.contract.d.ts +1 -0
- package/dist/src/crawl-execution/crawl-report.model.d.ts +2 -0
- package/dist/src/extraction-scope/extraction-scope.model.d.ts +2 -1
- package/dist/src/http-session/http-response.contract.d.ts +2 -1
- package/dist/src/index.d.ts +2 -0
- package/dist/src/pdf-document/index.d.ts +7 -0
- package/dist/src/pdf-document/pdf-document.model.d.ts +51 -0
- package/dist/src/pdf-document/pdf-table.algorithm.d.ts +43 -0
- package/dist/src/pdf-document/read-pdf.client.d.ts +18 -0
- package/dist/src/pdf-document/row-assembly.algorithm.d.ts +17 -0
- package/dist/src/recipe-schema/recipe-kind.enum.d.ts +5 -2
- package/dist/src/recipe-schema/step.contract.d.ts +7 -1
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -20,6 +20,7 @@ npx playwright install chromium # web recipes and browser bootstraps only
|
|
|
20
20
|
| `loadAccessConfig(path)`, `AccessBroker`, `ACCESS_PRESETS` | Access configs: load and validate one, lease a profile outside a crawl (the cli's `probe` does), list the provider presets. |
|
|
21
21
|
| `crawler.run(set)` | Runs every input recipe in sequence; returns a `CrawlReport`. |
|
|
22
22
|
| `crawler.close()` | Closes the browser, if one was launched. |
|
|
23
|
+
| `readPdf(bytes)`, `findTables(pdf, query)`, `pdfText(pdf)` | The PDF reader and table extractor `request as: "pdf"` and `extract kind: "table"` use, for tooling. |
|
|
23
24
|
| `HttpClient`, `BrowserClient` | The same clients the engine's runners use, for tooling built on top of `@opencraw/core` (`@opencraw/cli`'s `probe` command uses both). |
|
|
24
25
|
| `parseInputRecipe`, `parseOutputRecipe`, `inputRecipeJsonSchema`, `outputRecipeJsonSchema`, `accessConfigJsonSchema` | The contracts, for tooling. |
|
|
25
26
|
|
package/dist/index.esm.js
CHANGED
|
@@ -7,6 +7,7 @@ import { createWriteStream } from 'node:fs';
|
|
|
7
7
|
import { once } from 'node:events';
|
|
8
8
|
import { JSONPath } from 'jsonpath-plus';
|
|
9
9
|
import { load } from 'cheerio';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
10
11
|
|
|
11
12
|
/** Resource types a page may skip loading, to save bandwidth on per-GB proxies. */
|
|
12
13
|
const BLOCKABLE_RESOURCES = ['image', 'media', 'font', 'stylesheet', 'script', 'texttrack', 'xhr', 'fetch', 'eventsource', 'websocket', 'manifest', 'other'];
|
|
@@ -1292,7 +1293,7 @@ function traceLine(event) {
|
|
|
1292
1293
|
}
|
|
1293
1294
|
case 'recipe:finish':
|
|
1294
1295
|
{
|
|
1295
|
-
return `■ ${event.recipeId}: ${event.emitted} emitted, ${event.rejected} rejected, ${event.duplicates} duplicates, ${event.skipped > 0 ? `${event.skipped} skipped, ` : ''}${event.pages} pages, ${event.durationMs} ms${event.error === undefined ? '' : `\n ✖ stopped: ${event.error}`}`;
|
|
1296
|
+
return `■ ${event.recipeId}: ${event.emitted} emitted, ${event.rejected} rejected, ${event.duplicates} duplicates, ${event.skipped > 0 ? `${event.skipped} skipped, ` : ''}${(event.stepsSkipped ?? 0) > 0 ? `${event.stepsSkipped} steps skipped, ` : ''}${event.pages} pages, ${event.durationMs} ms${event.error === undefined ? '' : `\n ✖ stopped: ${event.error}`}`;
|
|
1296
1297
|
}
|
|
1297
1298
|
case 'access:lease':
|
|
1298
1299
|
{
|
|
@@ -2106,10 +2107,444 @@ async function detectBlock(response, rule = DEFAULT_BLOCK_RULE) {
|
|
|
2106
2107
|
return undefined;
|
|
2107
2108
|
}
|
|
2108
2109
|
|
|
2110
|
+
/** Runs closer than this share of the font size join into one cell. */
|
|
2111
|
+
const JOIN_GAP = 0.35;
|
|
2112
|
+
/** Runs further apart than this share of the font size get a space between them when joined. */
|
|
2113
|
+
const SPACE_GAP = 0.1;
|
|
2114
|
+
/** Runs on baselines closer than this share of the font size are on one line. */
|
|
2115
|
+
const SAME_BASELINE = 0.2;
|
|
2116
|
+
/** A cell joins a row when this share of its height overlaps the row. */
|
|
2117
|
+
const ROW_OVERLAP = 0.4;
|
|
2118
|
+
/**
|
|
2119
|
+
* Turns a page's text runs into rows of cells, top to bottom.
|
|
2120
|
+
*
|
|
2121
|
+
* Runs on one baseline that nearly touch become one cell. Cells whose vertical
|
|
2122
|
+
* extents overlap become one row, even when their baselines differ: a table
|
|
2123
|
+
* that centres its cells vertically puts a one-line value a few points above
|
|
2124
|
+
* or below its two-line label, and a row built from equal baselines would pair
|
|
2125
|
+
* the value with the wrong label.
|
|
2126
|
+
*
|
|
2127
|
+
* @param runs - The page's text runs, in any order.
|
|
2128
|
+
* @returns The rows.
|
|
2129
|
+
*/
|
|
2130
|
+
function assembleRows(runs) {
|
|
2131
|
+
const cells = joinCells(runs);
|
|
2132
|
+
const ordered = [...cells].sort((a, b) => middle(b) - middle(a) || a.x - b.x);
|
|
2133
|
+
const rows = [];
|
|
2134
|
+
let top = 0;
|
|
2135
|
+
let bottom = 0;
|
|
2136
|
+
for (const cell of ordered) {
|
|
2137
|
+
const current = rows.at(-1);
|
|
2138
|
+
const overlap = Math.min(cell.y + cell.height, top) - Math.max(cell.y, bottom);
|
|
2139
|
+
if (current !== undefined && overlap >= ROW_OVERLAP * cell.height) {
|
|
2140
|
+
current.push(cell);
|
|
2141
|
+
top = Math.max(top, cell.y + cell.height);
|
|
2142
|
+
bottom = Math.min(bottom, cell.y);
|
|
2143
|
+
} else {
|
|
2144
|
+
rows.push([cell]);
|
|
2145
|
+
top = cell.y + cell.height;
|
|
2146
|
+
bottom = cell.y;
|
|
2147
|
+
}
|
|
2148
|
+
}
|
|
2149
|
+
return rows.map(row => rowOf(row));
|
|
2150
|
+
}
|
|
2151
|
+
function joinCells(runs) {
|
|
2152
|
+
// Whitespace runs are gaps, not text: pdf.js emits the space between two
|
|
2153
|
+
// table columns as one wide " ", which would bridge the columns.
|
|
2154
|
+
// Left to right within a line, lines top to bottom: a run may sit a fraction of a point off its neighbours' baseline.
|
|
2155
|
+
const ordered = runs.filter(run => run.text.trim() !== '').sort((a, b) => Math.abs(a.y - b.y) <= SAME_BASELINE * Math.max(a.height, b.height, 1) ? a.x - b.x : b.y - a.y);
|
|
2156
|
+
const cells = [];
|
|
2157
|
+
for (const run of ordered) {
|
|
2158
|
+
const previous = lastOf(cells, cell => Math.abs(cell.y - run.y) <= SAME_BASELINE * Math.max(cell.height, run.height, 1));
|
|
2159
|
+
const size = Math.max(run.height, previous?.height ?? 0, 1);
|
|
2160
|
+
const gap = previous === undefined ? Infinity : run.x - (previous.x + previous.width);
|
|
2161
|
+
if (previous !== undefined && gap <= JOIN_GAP * size && gap > -size) {
|
|
2162
|
+
const space = gap > SPACE_GAP * size && !/\s$/.test(previous.text) && !/^\s/.test(run.text) ? ' ' : '';
|
|
2163
|
+
previous.text += space + run.text;
|
|
2164
|
+
previous.width = run.x + run.width - previous.x;
|
|
2165
|
+
previous.height = Math.max(previous.height, run.height);
|
|
2166
|
+
} else {
|
|
2167
|
+
cells.push({
|
|
2168
|
+
...run
|
|
2169
|
+
});
|
|
2170
|
+
}
|
|
2171
|
+
}
|
|
2172
|
+
return cells.map(cell => ({
|
|
2173
|
+
...cell,
|
|
2174
|
+
text: cell.text.trim()
|
|
2175
|
+
})).filter(cell => cell.text !== '');
|
|
2176
|
+
}
|
|
2177
|
+
function rowOf(cells) {
|
|
2178
|
+
const ordered = [...cells].sort((a, b) => a.x - b.x || b.y - a.y);
|
|
2179
|
+
return {
|
|
2180
|
+
top: Math.max(...ordered.map(cell => cell.y + cell.height)),
|
|
2181
|
+
bottom: Math.min(...ordered.map(cell => cell.y)),
|
|
2182
|
+
cells: ordered,
|
|
2183
|
+
text: ordered.map(cell => cell.text).join('\t')
|
|
2184
|
+
};
|
|
2185
|
+
}
|
|
2186
|
+
function middle(cell) {
|
|
2187
|
+
return cell.y + cell.height / 2;
|
|
2188
|
+
}
|
|
2189
|
+
/** `Array#findLast`, which the es2022 library does not declare. */
|
|
2190
|
+
function lastOf(items, test) {
|
|
2191
|
+
for (let index = items.length - 1; index >= 0; index -= 1) if (test(items[index])) return items[index];
|
|
2192
|
+
return undefined;
|
|
2193
|
+
}
|
|
2194
|
+
|
|
2195
|
+
/** A PDF that cannot be read: not a PDF, encrypted, or with no text to read. */
|
|
2196
|
+
class PdfReadError extends Error {
|
|
2197
|
+
name = 'PdfReadError';
|
|
2198
|
+
}
|
|
2199
|
+
/**
|
|
2200
|
+
* Reads a PDF's text layer into rows of positioned cells, with pdf.js. pdf.js
|
|
2201
|
+
* is imported on first use, so recipes that never read a PDF never load it.
|
|
2202
|
+
* Only text is read: no page is rendered, no script runs, no font is loaded.
|
|
2203
|
+
*
|
|
2204
|
+
* @param bytes - The file.
|
|
2205
|
+
* @param source - Where it came from, for messages.
|
|
2206
|
+
* @returns The document.
|
|
2207
|
+
* @throws PdfReadError when the bytes are not a readable PDF, or no page has a
|
|
2208
|
+
* text layer (a scan: OCR is not supported).
|
|
2209
|
+
*/
|
|
2210
|
+
async function readPdf(bytes, source = 'PDF') {
|
|
2211
|
+
const pdfjs = await import('pdfjs-dist/legacy/build/pdf.mjs');
|
|
2212
|
+
// pdf.js takes ownership of the buffer it is given, and refuses a Node
|
|
2213
|
+
// Buffer: hand it a plain copy.
|
|
2214
|
+
const task = pdfjs.getDocument({
|
|
2215
|
+
data: new Uint8Array(bytes),
|
|
2216
|
+
verbosity: 0,
|
|
2217
|
+
disableFontFace: true,
|
|
2218
|
+
useSystemFonts: false,
|
|
2219
|
+
stopAtErrors: true
|
|
2220
|
+
});
|
|
2221
|
+
let loaded;
|
|
2222
|
+
try {
|
|
2223
|
+
loaded = await task.promise;
|
|
2224
|
+
} catch (error) {
|
|
2225
|
+
throw new PdfReadError(`${source}: not a readable PDF (${error.message})`, {
|
|
2226
|
+
cause: error
|
|
2227
|
+
});
|
|
2228
|
+
}
|
|
2229
|
+
try {
|
|
2230
|
+
const pages = [];
|
|
2231
|
+
for (let number = 1; number <= loaded.numPages; number += 1) {
|
|
2232
|
+
const page = await loaded.getPage(number);
|
|
2233
|
+
const {
|
|
2234
|
+
width,
|
|
2235
|
+
height
|
|
2236
|
+
} = page.getViewport({
|
|
2237
|
+
scale: 1
|
|
2238
|
+
});
|
|
2239
|
+
const content = await page.getTextContent();
|
|
2240
|
+
const runs = content.items.flatMap(item => 'str' in item ? [runOf(item)] : []);
|
|
2241
|
+
pages.push({
|
|
2242
|
+
number,
|
|
2243
|
+
width,
|
|
2244
|
+
height,
|
|
2245
|
+
rows: assembleRows(runs)
|
|
2246
|
+
});
|
|
2247
|
+
}
|
|
2248
|
+
if (pages.every(page => page.rows.length === 0)) throw new PdfReadError(`${source}: no page has a text layer (a scanned PDF? OCR is not supported)`);
|
|
2249
|
+
return {
|
|
2250
|
+
kind: 'pdf',
|
|
2251
|
+
pages
|
|
2252
|
+
};
|
|
2253
|
+
} finally {
|
|
2254
|
+
await task.destroy();
|
|
2255
|
+
}
|
|
2256
|
+
}
|
|
2257
|
+
function runOf(item) {
|
|
2258
|
+
const [a, b, c, d, x, y] = item.transform;
|
|
2259
|
+
return {
|
|
2260
|
+
x,
|
|
2261
|
+
y,
|
|
2262
|
+
width: item.width,
|
|
2263
|
+
height: item.height > 0 ? item.height : Math.hypot(c, d) || Math.hypot(a, b),
|
|
2264
|
+
text: item.str
|
|
2265
|
+
};
|
|
2266
|
+
}
|
|
2267
|
+
|
|
2268
|
+
/**
|
|
2269
|
+
* The text a `regex` extract reads: one line per row (cells separated by a
|
|
2270
|
+
* tab), pages separated by a blank line.
|
|
2271
|
+
*
|
|
2272
|
+
* @param document - The PDF.
|
|
2273
|
+
* @returns The text.
|
|
2274
|
+
*/
|
|
2275
|
+
function pdfText(document) {
|
|
2276
|
+
return document.pages.map(page => page.rows.map(row => row.text).join('\n')).join('\n\n');
|
|
2277
|
+
}
|
|
2278
|
+
/**
|
|
2279
|
+
* Whether a value bound in scope is a read PDF (so `extract … from` can take it).
|
|
2280
|
+
*
|
|
2281
|
+
* @param value - Anything.
|
|
2282
|
+
* @returns Whether it is a {@link PdfDocument}.
|
|
2283
|
+
*/
|
|
2284
|
+
function isPdfDocument(value) {
|
|
2285
|
+
return typeof value === 'object' && value !== null && value.kind === 'pdf' && Array.isArray(value.pages);
|
|
2286
|
+
}
|
|
2287
|
+
|
|
2288
|
+
/**
|
|
2289
|
+
* Finds every table whose header row matches, and reads its rows by column.
|
|
2290
|
+
*
|
|
2291
|
+
* Columns come from the body, not the header: a header is often centred over
|
|
2292
|
+
* a column whose cells are left-aligned, so the header's position says little
|
|
2293
|
+
* about where the column starts. The left edges of the body cells cluster into
|
|
2294
|
+
* bands; each band belongs to the header cell that overlaps it most, or the
|
|
2295
|
+
* nearest one when none does. A header spanning two columns reads both.
|
|
2296
|
+
*
|
|
2297
|
+
* A cell wrapped over several lines (a long name, a note, a list of versions)
|
|
2298
|
+
* spreads one row over several lines, its values often centred beside it:
|
|
2299
|
+
* the lines are regrouped into rows around the lines that carry values.
|
|
2300
|
+
*
|
|
2301
|
+
* @param document - The PDF.
|
|
2302
|
+
* @param query - Which tables, and how to name their columns.
|
|
2303
|
+
* @returns The tables, in page order.
|
|
2304
|
+
*/
|
|
2305
|
+
function findTables(document, query) {
|
|
2306
|
+
const tables = [];
|
|
2307
|
+
for (const page of document.pages) {
|
|
2308
|
+
const starts = page.rows.flatMap((row, index) => query.header.test(plain(row)) ? [index] : []);
|
|
2309
|
+
for (const [position, start] of starts.entries()) {
|
|
2310
|
+
const body = bodyOf(page.rows.slice(start + 1, starts[position + 1] ?? page.rows.length), query.until);
|
|
2311
|
+
tables.push(readTable(page.number, page.rows[start], body, query));
|
|
2312
|
+
}
|
|
2313
|
+
}
|
|
2314
|
+
return tables;
|
|
2315
|
+
}
|
|
2316
|
+
/** The rows under a header, up to the first one `until` matches. */
|
|
2317
|
+
function bodyOf(rows, until) {
|
|
2318
|
+
const end = until === undefined ? -1 : rows.findIndex(row => until.test(plain(row)));
|
|
2319
|
+
return end === -1 ? [...rows] : rows.slice(0, end);
|
|
2320
|
+
}
|
|
2321
|
+
function readTable(page, headerRow, body, query) {
|
|
2322
|
+
const headers = headerCells(headerRow);
|
|
2323
|
+
const bands = bandsOf(body, headers);
|
|
2324
|
+
const lines = body.map(row => ({
|
|
2325
|
+
row,
|
|
2326
|
+
values: valuesOf(row, bands, headers.length)
|
|
2327
|
+
}));
|
|
2328
|
+
const groups = groupLines(lines, query.align ?? 'auto');
|
|
2329
|
+
return {
|
|
2330
|
+
page,
|
|
2331
|
+
title: headers[0]?.text ?? '',
|
|
2332
|
+
header: headers.map(header => header.text),
|
|
2333
|
+
rows: groups.map(group => named(joinLines(group, headers.length), headers, query.columns))
|
|
2334
|
+
};
|
|
2335
|
+
}
|
|
2336
|
+
/**
|
|
2337
|
+
* Groups the body's lines into table rows. A line anchors a row when it has a
|
|
2338
|
+
* name (the first column) and a value, or, without a name, a value in the
|
|
2339
|
+
* first value column: the middle line of a name wrapped over several lines,
|
|
2340
|
+
* the values centred beside it. Every other line (a wrapped name, a wrapped
|
|
2341
|
+
* note, a list of versions over two lines) joins an anchor:
|
|
2342
|
+
*
|
|
2343
|
+
* - `top`: the nearest anchor above it; `bottom`: the nearest below;
|
|
2344
|
+
* - `center`: a wrapped name splits evenly around its anchor (as many lines
|
|
2345
|
+
* below as above), and other lines join the nearest anchor;
|
|
2346
|
+
* - `auto`: `center` when some line carries values but no name (only a
|
|
2347
|
+
* centred table does that), else the nearest anchor, a tie going to the one
|
|
2348
|
+
* below (a wrapped cell's first line comes before its row).
|
|
2349
|
+
*/
|
|
2350
|
+
function groupLines(lines, align) {
|
|
2351
|
+
const named = line => line.values[0] !== '';
|
|
2352
|
+
const valued = line => line.values.slice(1).some(value => value !== '');
|
|
2353
|
+
let anchors = lines.filter(line => named(line) && valued(line) || !named(line) && (line.values[1] ?? '') !== '');
|
|
2354
|
+
if (anchors.length === 0) anchors = lines.filter(line => named(line));
|
|
2355
|
+
if (anchors.length === 0) return [];
|
|
2356
|
+
const groups = new Map(anchors.map(anchor => [anchor, [anchor]]));
|
|
2357
|
+
const centred = align === 'center' || align === 'auto' && anchors.some(anchor => !named(anchor));
|
|
2358
|
+
if (centred) splitNamesEvenly(lines, anchors, named, groups);
|
|
2359
|
+
const placed = new Set();
|
|
2360
|
+
for (const members of groups.values()) for (const line of members) placed.add(line);
|
|
2361
|
+
for (const line of lines) if (!placed.has(line)) groups.get(ownerOf(line, anchors, align))?.push(line);
|
|
2362
|
+
// An anchor that ended up with no name at all is a value spilling out of the row next to it.
|
|
2363
|
+
const nameless = anchors.filter(anchor => (groups.get(anchor) ?? []).every(line => !named(line)));
|
|
2364
|
+
const kept = anchors.filter(anchor => !nameless.includes(anchor));
|
|
2365
|
+
if (kept.length === 0) return [];
|
|
2366
|
+
for (const anchor of nameless) groups.get(nearest(anchor, kept))?.push(...(groups.get(anchor) ?? []));
|
|
2367
|
+
return kept.map(anchor => groups.get(anchor) ?? []);
|
|
2368
|
+
}
|
|
2369
|
+
/**
|
|
2370
|
+
* Hands the wrapped name lines between two anchors out evenly: the upper
|
|
2371
|
+
* anchor takes as many lines below it as it took above it, the lower one the
|
|
2372
|
+
* rest. Lines are in page order, top to bottom.
|
|
2373
|
+
*/
|
|
2374
|
+
function splitNamesEvenly(lines, anchors, named, groups) {
|
|
2375
|
+
const positions = anchors.map(anchor => lines.indexOf(anchor));
|
|
2376
|
+
const namesBetween = (from, to) => lines.slice(from, to).filter(line => named(line) && !groups.has(line));
|
|
2377
|
+
let above = namesBetween(0, positions[0]);
|
|
2378
|
+
groups.get(anchors[0])?.push(...above);
|
|
2379
|
+
for (const [index, anchor] of anchors.entries()) {
|
|
2380
|
+
const next = anchors[index + 1];
|
|
2381
|
+
const run = namesBetween(positions[index] + 1, next === undefined ? lines.length : positions[index + 1]);
|
|
2382
|
+
const taken = next === undefined ? run.length : Math.min(run.length, above.length);
|
|
2383
|
+
groups.get(anchor)?.push(...run.slice(0, taken));
|
|
2384
|
+
above = run.slice(taken);
|
|
2385
|
+
if (next !== undefined) groups.get(next)?.push(...above);
|
|
2386
|
+
}
|
|
2387
|
+
}
|
|
2388
|
+
function ownerOf(line, anchors, align) {
|
|
2389
|
+
if (align === 'top') return lastOf(anchors, anchor => anchor.row.bottom >= line.row.bottom) ?? anchors[0];
|
|
2390
|
+
if (align === 'bottom') return anchors.find(anchor => anchor.row.bottom <= line.row.bottom) ?? anchors.at(-1) ?? anchors[0];
|
|
2391
|
+
return nearest(line, anchors);
|
|
2392
|
+
}
|
|
2393
|
+
/** A row's text per column: its lines top to bottom, each column's pieces joined by spaces. */
|
|
2394
|
+
function joinLines(group, width) {
|
|
2395
|
+
const ordered = [...group].sort((a, b) => b.row.top - a.row.top);
|
|
2396
|
+
return Array.from({
|
|
2397
|
+
length: width
|
|
2398
|
+
}, (_, column) => ordered.map(line => line.values[column]).filter(value => value !== '').join(' '));
|
|
2399
|
+
}
|
|
2400
|
+
/** Header cells that overlap horizontally (a header on two lines) are one header. */
|
|
2401
|
+
function headerCells(row) {
|
|
2402
|
+
const merged = [];
|
|
2403
|
+
for (const cell of row.cells) {
|
|
2404
|
+
const previous = merged.at(-1);
|
|
2405
|
+
if (previous !== undefined && cell.x < previous.x + previous.width) {
|
|
2406
|
+
previous.text = `${previous.text} ${cell.text}`;
|
|
2407
|
+
previous.width = Math.max(previous.x + previous.width, cell.x + cell.width) - previous.x;
|
|
2408
|
+
} else {
|
|
2409
|
+
merged.push({
|
|
2410
|
+
...cell
|
|
2411
|
+
});
|
|
2412
|
+
}
|
|
2413
|
+
}
|
|
2414
|
+
return merged;
|
|
2415
|
+
}
|
|
2416
|
+
function bandsOf(body, headers) {
|
|
2417
|
+
const cells = body.flatMap(row => row.cells);
|
|
2418
|
+
if (cells.length === 0 || headers.length === 0) return [];
|
|
2419
|
+
const tolerance = Math.max(3, median(cells.map(cell => cell.height)) * 0.6);
|
|
2420
|
+
const edges = cells.map(cell => cell.x).sort((a, b) => a - b);
|
|
2421
|
+
const starts = [];
|
|
2422
|
+
for (const edge of edges) if (starts.length === 0 || edge - (starts.at(-1) ?? 0) > tolerance) starts.push(edge);
|
|
2423
|
+
const spans = starts.map((start, index) => {
|
|
2424
|
+
const next = starts[index + 1] ?? Infinity;
|
|
2425
|
+
// A band spans what its cells cover, not the gap up to the next band.
|
|
2426
|
+
const right = Math.max(...cells.filter(cell => cell.x >= start - 0.5 && cell.x < next - 0.5).map(cell => cell.x + cell.width));
|
|
2427
|
+
return {
|
|
2428
|
+
start,
|
|
2429
|
+
end: Math.min(next, right)
|
|
2430
|
+
};
|
|
2431
|
+
});
|
|
2432
|
+
const columns = assignColumns(spans, headers);
|
|
2433
|
+
return spans.map((span, index) => ({
|
|
2434
|
+
start: span.start,
|
|
2435
|
+
column: columns[index]
|
|
2436
|
+
}));
|
|
2437
|
+
}
|
|
2438
|
+
/**
|
|
2439
|
+
* Maps bands to headers, left to right: columns never cross, so the mapping
|
|
2440
|
+
* is monotone. Among monotone mappings it first uses as many headers as it
|
|
2441
|
+
* can (a table with as many bands as headers maps one to one), then prefers
|
|
2442
|
+
* the one where bands overlap their header most, or sit nearest to it. The
|
|
2443
|
+
* leftover choice is which neighbouring bands share a header: one header
|
|
2444
|
+
* over two columns, or a column whose cells start at two edges.
|
|
2445
|
+
*
|
|
2446
|
+
* @param spans - The bands, left to right.
|
|
2447
|
+
* @param headers - The header cells, left to right.
|
|
2448
|
+
* @returns The header index of each band.
|
|
2449
|
+
*/
|
|
2450
|
+
function assignColumns(spans, headers) {
|
|
2451
|
+
const affinity = (span, header) => {
|
|
2452
|
+
const overlap = overlapOf(header, span);
|
|
2453
|
+
return overlap > 0 ? overlap : -Math.max(0, header.x - span.end, span.start - (header.x + header.width));
|
|
2454
|
+
};
|
|
2455
|
+
const better = (a, b) => b === undefined || a.used > b.used || a.used === b.used && a.affinity > b.affinity;
|
|
2456
|
+
const table = [headers.map(header => ({
|
|
2457
|
+
used: 1,
|
|
2458
|
+
affinity: affinity(spans[0], header),
|
|
2459
|
+
previous: -1
|
|
2460
|
+
}))];
|
|
2461
|
+
for (const span of spans.slice(1)) {
|
|
2462
|
+
const last = table.at(-1) ?? [];
|
|
2463
|
+
table.push(headers.map((header, column) => {
|
|
2464
|
+
let best;
|
|
2465
|
+
for (let from = 0; from <= column; from += 1) {
|
|
2466
|
+
const candidate = {
|
|
2467
|
+
used: last[from].used + (from === column ? 0 : 1),
|
|
2468
|
+
affinity: last[from].affinity + affinity(span, header),
|
|
2469
|
+
previous: from
|
|
2470
|
+
};
|
|
2471
|
+
if (better(candidate, best)) best = candidate;
|
|
2472
|
+
}
|
|
2473
|
+
return best ?? {
|
|
2474
|
+
used: 0,
|
|
2475
|
+
affinity: -Infinity,
|
|
2476
|
+
previous: 0
|
|
2477
|
+
};
|
|
2478
|
+
}));
|
|
2479
|
+
}
|
|
2480
|
+
const last = table.at(-1) ?? [];
|
|
2481
|
+
let column = last.reduce((best, score, index) => better(score, last[best]) ? index : best, 0);
|
|
2482
|
+
const columns = [];
|
|
2483
|
+
for (let index = table.length - 1; index >= 0; index -= 1) {
|
|
2484
|
+
columns.unshift(column);
|
|
2485
|
+
column = table[index][column].previous;
|
|
2486
|
+
}
|
|
2487
|
+
return columns;
|
|
2488
|
+
}
|
|
2489
|
+
function valuesOf(row, bands, width) {
|
|
2490
|
+
const values = Array.from({
|
|
2491
|
+
length: width
|
|
2492
|
+
}, () => '');
|
|
2493
|
+
const ordered = [...row.cells].sort((a, b) => b.y - a.y || a.x - b.x);
|
|
2494
|
+
for (const cell of ordered) {
|
|
2495
|
+
const band = lastOf(bands, candidate => candidate.start <= cell.x + 0.5) ?? bands[0];
|
|
2496
|
+
if (band === undefined) continue;
|
|
2497
|
+
values[band.column] = joinText(values[band.column], cell.text);
|
|
2498
|
+
}
|
|
2499
|
+
return values;
|
|
2500
|
+
}
|
|
2501
|
+
function named(values, headers, columns) {
|
|
2502
|
+
if (columns === undefined) return Object.fromEntries(headers.map((header, index) => [header.text, values[index]]));
|
|
2503
|
+
const record = {};
|
|
2504
|
+
for (const [key, pattern] of Object.entries(columns)) {
|
|
2505
|
+
const index = headers.findIndex(header => pattern.test(header.text));
|
|
2506
|
+
if (index !== -1) record[key] = values[index];
|
|
2507
|
+
}
|
|
2508
|
+
return record;
|
|
2509
|
+
}
|
|
2510
|
+
/** Distances closer than this, in points, are a tie. */
|
|
2511
|
+
const TIE = 1;
|
|
2512
|
+
/**
|
|
2513
|
+
* The anchor a line belongs to: the nearest by vertical gap. On a tie (evenly
|
|
2514
|
+
* spaced lines) the anchor below wins: text reads top down, so a wrapped
|
|
2515
|
+
* cell's first line comes before the row it belongs to.
|
|
2516
|
+
*/
|
|
2517
|
+
function nearest(line, candidates) {
|
|
2518
|
+
const row = line.row;
|
|
2519
|
+
const gap = candidate => Math.max(0, candidate.bottom - row.top, row.bottom - candidate.top);
|
|
2520
|
+
const [first, ...rest] = candidates;
|
|
2521
|
+
if (first === undefined) throw new Error('no row to attach a line to');
|
|
2522
|
+
let best = first;
|
|
2523
|
+
for (const candidate of rest) {
|
|
2524
|
+
const difference = gap(candidate.row) - gap(best.row);
|
|
2525
|
+
if (difference < -TIE || Math.abs(difference) <= TIE && candidate.row.bottom < best.row.bottom) best = candidate;
|
|
2526
|
+
}
|
|
2527
|
+
return best;
|
|
2528
|
+
}
|
|
2529
|
+
function overlapOf(header, band) {
|
|
2530
|
+
return Math.max(0, Math.min(header.x + header.width, band.end) - Math.max(header.x, band.start));
|
|
2531
|
+
}
|
|
2532
|
+
function plain(row) {
|
|
2533
|
+
return row.cells.map(cell => cell.text).join(' ');
|
|
2534
|
+
}
|
|
2535
|
+
function joinText(first, second) {
|
|
2536
|
+
return first === '' ? second : second === '' ? first : `${first} ${second}`;
|
|
2537
|
+
}
|
|
2538
|
+
function median(values) {
|
|
2539
|
+
const ordered = [...values].sort((a, b) => a - b);
|
|
2540
|
+
return ordered[Math.floor(ordered.length / 2)] ?? 0;
|
|
2541
|
+
}
|
|
2542
|
+
|
|
2109
2543
|
/**
|
|
2110
2544
|
* Runs an `extract` step against a static document: the value bound under
|
|
2111
2545
|
* `from`, else the scope's current document. `css` reads HTML, `jsonpath`
|
|
2112
|
-
* reads JSON
|
|
2546
|
+
* reads JSON (or a read PDF's rows), `table` reads a PDF's tables, `regex`
|
|
2547
|
+
* reads any document as text; `xpath` needs a live page and is refused here.
|
|
2113
2548
|
*
|
|
2114
2549
|
* A `jsonpath` extract whose `from` is text parses that text as JSON, and a
|
|
2115
2550
|
* list of texts (every `<script type="application/ld+json">` of a page) becomes
|
|
@@ -2128,8 +2563,14 @@ function extractFromDocument(step, scope) {
|
|
|
2128
2563
|
switch (step.kind) {
|
|
2129
2564
|
case 'jsonpath':
|
|
2130
2565
|
{
|
|
2131
|
-
if (document.kind !== 'json') throw new Error(`jsonpath needs a JSON document; the current document is ${document.kind}`);
|
|
2132
|
-
values = selectJson(document.data, selector).map(node => takeFromJson(node, take));
|
|
2566
|
+
if (document.kind !== 'json' && document.kind !== 'pdf') throw new Error(`jsonpath needs a JSON document; the current document is ${document.kind}`);
|
|
2567
|
+
values = selectJson(document.kind === 'pdf' ? document : document.data, selector).map(node => takeFromJson(node, take));
|
|
2568
|
+
break;
|
|
2569
|
+
}
|
|
2570
|
+
case 'table':
|
|
2571
|
+
{
|
|
2572
|
+
if (document.kind !== 'pdf') throw new Error(`table reads a PDF; the current document is ${document.kind} (request it with "as": "pdf")`);
|
|
2573
|
+
values = findTables(document, tableQuery(step, selector));
|
|
2133
2574
|
break;
|
|
2134
2575
|
}
|
|
2135
2576
|
case 'css':
|
|
@@ -2166,10 +2607,34 @@ function extractFromDocument(step, scope) {
|
|
|
2166
2607
|
function renderSelector(selector, scope) {
|
|
2167
2608
|
return hasPlaceholder(selector) ? renderText(selector, path => scope.lookup(path)) : selector;
|
|
2168
2609
|
}
|
|
2169
|
-
/**
|
|
2610
|
+
/**
|
|
2611
|
+
* A table extract's query: the selector matches the header row, the other
|
|
2612
|
+
* patterns come from the step; all case-insensitive, since PDFs capitalise
|
|
2613
|
+
* headings freely.
|
|
2614
|
+
*/
|
|
2615
|
+
function tableQuery(step, selector) {
|
|
2616
|
+
const columns = step.columns === undefined ? undefined : Object.fromEntries(Object.entries(step.columns).map(([key, pattern]) => [key, patternOf(pattern, `columns.${key}`)]));
|
|
2617
|
+
return {
|
|
2618
|
+
header: patternOf(selector, 'selector'),
|
|
2619
|
+
until: step.until === undefined ? undefined : patternOf(step.until, 'until'),
|
|
2620
|
+
columns,
|
|
2621
|
+
align: step.align
|
|
2622
|
+
};
|
|
2623
|
+
}
|
|
2624
|
+
function patternOf(source, where) {
|
|
2625
|
+
try {
|
|
2626
|
+
return new RegExp(source, 'i');
|
|
2627
|
+
} catch (error) {
|
|
2628
|
+
throw new Error(`${where}: invalid pattern ${source} (${error.message})`, {
|
|
2629
|
+
cause: error
|
|
2630
|
+
});
|
|
2631
|
+
}
|
|
2632
|
+
}
|
|
2633
|
+
/** The text a regex extract reads: markup, text, a PDF's rows, or JSON re-serialised (a list of texts joined by newlines). */
|
|
2170
2634
|
function textOf$1(document) {
|
|
2171
2635
|
if (document.kind === 'html') return document.html;
|
|
2172
2636
|
if (document.kind === 'text') return document.text;
|
|
2637
|
+
if (document.kind === 'pdf') return pdfText(document);
|
|
2173
2638
|
if (Array.isArray(document.data) && document.data.every(entry => typeof entry === 'string')) return document.data.join('\n');
|
|
2174
2639
|
return typeof document.data === 'string' ? document.data : JSON.stringify(document.data);
|
|
2175
2640
|
}
|
|
@@ -2181,6 +2646,8 @@ function documentFor(step, scope) {
|
|
|
2181
2646
|
}
|
|
2182
2647
|
const source = scope.get(step.from);
|
|
2183
2648
|
if (source === undefined) throw new Error(`"${step.from}" is not bound`);
|
|
2649
|
+
if (isPdfDocument(source)) return source;
|
|
2650
|
+
if (step.kind === 'table') throw new Error(`"${step.from}" is not a PDF; request it with "as": "pdf"`);
|
|
2184
2651
|
if (step.kind === 'regex') {
|
|
2185
2652
|
if (typeof source === 'string') return {
|
|
2186
2653
|
kind: 'text',
|
|
@@ -2263,6 +2730,7 @@ class HttpClient {
|
|
|
2263
2730
|
* @throws HttpError for a 4xx or 5xx status.
|
|
2264
2731
|
*/
|
|
2265
2732
|
async send(httpRequest) {
|
|
2733
|
+
if (httpRequest.url.startsWith('file:')) return readLocalFile(httpRequest);
|
|
2266
2734
|
const response = await this.context.fetch(httpRequest.url, {
|
|
2267
2735
|
method: httpRequest.method ?? (httpRequest.body === undefined ? 'GET' : 'POST'),
|
|
2268
2736
|
params: httpRequest.query,
|
|
@@ -2290,7 +2758,25 @@ class HttpClient {
|
|
|
2290
2758
|
}
|
|
2291
2759
|
async function readBody(response, as) {
|
|
2292
2760
|
const kind = as ?? kindFromContentType(response.headers()['content-type'] ?? '');
|
|
2293
|
-
|
|
2761
|
+
return parseBody(kind, await response.body(), response.url());
|
|
2762
|
+
}
|
|
2763
|
+
/**
|
|
2764
|
+
* A `file:` URL, read from disk: a PDF or JSON a recipe gets from a folder
|
|
2765
|
+
* instead of a server. The kind is `as`, else the file extension.
|
|
2766
|
+
*/
|
|
2767
|
+
async function readLocalFile(httpRequest) {
|
|
2768
|
+
const path = fileURLToPath(httpRequest.url);
|
|
2769
|
+
const bytes = await readFile(path);
|
|
2770
|
+
return {
|
|
2771
|
+
status: 200,
|
|
2772
|
+
url: httpRequest.url,
|
|
2773
|
+
headers: {},
|
|
2774
|
+
body: await parseBody(httpRequest.as ?? kindFromExtension(extname(path)), bytes, httpRequest.url)
|
|
2775
|
+
};
|
|
2776
|
+
}
|
|
2777
|
+
async function parseBody(kind, bytes, url) {
|
|
2778
|
+
if (kind === 'pdf') return readPdf(bytes, url);
|
|
2779
|
+
const text = new TextDecoder().decode(bytes);
|
|
2294
2780
|
if (kind === 'json') {
|
|
2295
2781
|
try {
|
|
2296
2782
|
return {
|
|
@@ -2298,7 +2784,7 @@ async function readBody(response, as) {
|
|
|
2298
2784
|
data: JSON.parse(text)
|
|
2299
2785
|
};
|
|
2300
2786
|
} catch (error) {
|
|
2301
|
-
throw new Error(`${
|
|
2787
|
+
throw new Error(`${url}: body is not JSON (${error.message})`, {
|
|
2302
2788
|
cause: error
|
|
2303
2789
|
});
|
|
2304
2790
|
}
|
|
@@ -2314,9 +2800,20 @@ async function readBody(response, as) {
|
|
|
2314
2800
|
function kindFromContentType(contentType) {
|
|
2315
2801
|
const type = contentType.toLowerCase();
|
|
2316
2802
|
if (type.includes('json')) return 'json';
|
|
2803
|
+
if (type.includes('pdf')) return 'pdf';
|
|
2317
2804
|
if (type.includes('html') || type.includes('xml')) return 'html';
|
|
2318
2805
|
return 'text';
|
|
2319
2806
|
}
|
|
2807
|
+
function kindFromExtension(extension) {
|
|
2808
|
+
const kinds = {
|
|
2809
|
+
'.json': 'json',
|
|
2810
|
+
'.pdf': 'pdf',
|
|
2811
|
+
'.html': 'html',
|
|
2812
|
+
'.htm': 'html',
|
|
2813
|
+
'.xml': 'html'
|
|
2814
|
+
};
|
|
2815
|
+
return kinds[extension.toLowerCase()] ?? 'text';
|
|
2816
|
+
}
|
|
2320
2817
|
|
|
2321
2818
|
/**
|
|
2322
2819
|
* Sends a `request` step: renders its templates, waits for the gate's throttle,
|
|
@@ -2384,6 +2881,7 @@ async function sendRequest(step, scope, client, recipe, gate, events) {
|
|
|
2384
2881
|
}
|
|
2385
2882
|
function bodyText(body) {
|
|
2386
2883
|
if (body.kind === 'json') return JSON.stringify(body.data);
|
|
2884
|
+
if (body.kind === 'pdf') return pdfText(body);
|
|
2387
2885
|
return body.kind === 'html' ? body.html : body.text;
|
|
2388
2886
|
}
|
|
2389
2887
|
function renderMap(map, lookup) {
|
|
@@ -2404,9 +2902,10 @@ function resolveUrl(target, base) {
|
|
|
2404
2902
|
throw new Error(`"${target}" is not a URL${base === undefined || base === '' ? ' and no page is known to resolve it against' : ` and cannot be resolved against ${base}`}`);
|
|
2405
2903
|
}
|
|
2406
2904
|
}
|
|
2407
|
-
/** What a step id holds for a document: parsed JSON, or the markup / text. */
|
|
2905
|
+
/** What a step id holds for a document: parsed JSON, the read PDF, or the markup / text. */
|
|
2408
2906
|
function documentValue(body) {
|
|
2409
2907
|
if (body.kind === 'json') return body.data;
|
|
2908
|
+
if (body.kind === 'pdf') return body;
|
|
2410
2909
|
return body.kind === 'html' ? body.html : body.text;
|
|
2411
2910
|
}
|
|
2412
2911
|
|
|
@@ -3489,6 +3988,7 @@ async function extractFromPage(step, page, scope) {
|
|
|
3489
3988
|
extractFromDocument(step, scope);
|
|
3490
3989
|
return;
|
|
3491
3990
|
}
|
|
3991
|
+
if (step.kind === 'table') throw new Error('table reads a PDF: fetch it with a request step in api mode, or extract "from" a PDF bound earlier');
|
|
3492
3992
|
const rendered = renderSelector(step.selector, scope);
|
|
3493
3993
|
const take = step.take ?? 'text';
|
|
3494
3994
|
const raw = step.kind === 'regex' ? selectRegex(await page.content(), rendered) : await page.locator(step.kind === 'xpath' ? `xpath=${rendered}` : rendered).evaluateAll(readAll, take);
|
|
@@ -4051,6 +4551,7 @@ async function runInputRecipe(input, output, deps) {
|
|
|
4051
4551
|
rejected: 0,
|
|
4052
4552
|
duplicates: 0,
|
|
4053
4553
|
skipped: 0,
|
|
4554
|
+
stepsSkipped: 0,
|
|
4054
4555
|
pages: 0,
|
|
4055
4556
|
durationMs: 0
|
|
4056
4557
|
};
|
|
@@ -4061,6 +4562,7 @@ async function runInputRecipe(input, output, deps) {
|
|
|
4061
4562
|
let chain = Promise.resolve();
|
|
4062
4563
|
const unsubscribe = deps.events.subscribe(event => {
|
|
4063
4564
|
if (event.type === 'page:visit' && event.recipeId === input.id) report.pages += 1;
|
|
4565
|
+
if (event.type === 'step:skip' && event.recipeId === input.id) report.stepsSkipped += 1;
|
|
4064
4566
|
});
|
|
4065
4567
|
deps.events.emit({
|
|
4066
4568
|
type: 'recipe:start',
|
|
@@ -4118,6 +4620,7 @@ async function runInputRecipe(input, output, deps) {
|
|
|
4118
4620
|
rejected: report.rejected,
|
|
4119
4621
|
duplicates: report.duplicates,
|
|
4120
4622
|
skipped: report.skipped,
|
|
4623
|
+
stepsSkipped: report.stepsSkipped,
|
|
4121
4624
|
pages: report.pages,
|
|
4122
4625
|
durationMs: report.durationMs,
|
|
4123
4626
|
error: report.error
|
|
@@ -4371,10 +4874,12 @@ function createCrawler(options = {}) {
|
|
|
4371
4874
|
|
|
4372
4875
|
/** The closed vocabularies a recipe file can use. Each is a `readonly` tuple so zod and TypeScript share it. */
|
|
4373
4876
|
const CRAWL_MODES = ['web', 'api'];
|
|
4374
|
-
const SELECTOR_KINDS = ['css', 'xpath', 'jsonpath', 'regex'];
|
|
4877
|
+
const SELECTOR_KINDS = ['css', 'xpath', 'jsonpath', 'regex', 'table'];
|
|
4375
4878
|
/** `take` also accepts `attr:<name>`, which is validated by pattern rather than listed. */
|
|
4376
4879
|
const TAKE_KINDS = ['text', 'html', 'value', 'json'];
|
|
4377
|
-
const BODY_KINDS = ['json', 'html', 'text'];
|
|
4880
|
+
const BODY_KINDS = ['json', 'html', 'text', 'pdf'];
|
|
4881
|
+
/** How a PDF table aligns a row's values against a cell wrapped over several lines. */
|
|
4882
|
+
const TABLE_ALIGNS = ['auto', 'top', 'center', 'bottom'];
|
|
4378
4883
|
const FIELD_TYPES = ['string', 'number', 'integer', 'boolean', 'date', 'datetime', 'currency', 'url', 'enum', 'array', 'object', 'json'];
|
|
4379
4884
|
const MISSING_POLICIES = ['fail', 'skip-record', 'null', 'default'];
|
|
4380
4885
|
const RECIPE_MISSING_POLICIES = ['fail', 'skip-record', 'null'];
|
|
@@ -4551,6 +5056,7 @@ const requestStep = z.strictObject({
|
|
|
4551
5056
|
body: z.unknown().optional(),
|
|
4552
5057
|
as: z.enum(BODY_KINDS).optional()
|
|
4553
5058
|
});
|
|
5059
|
+
const tableOnly = ['columns', 'until', 'align'];
|
|
4554
5060
|
const extractStep = z.strictObject({
|
|
4555
5061
|
...base,
|
|
4556
5062
|
type: z.literal('extract'),
|
|
@@ -4558,7 +5064,20 @@ const extractStep = z.strictObject({
|
|
|
4558
5064
|
kind: z.enum(SELECTOR_KINDS),
|
|
4559
5065
|
take: takeKindSchema.optional(),
|
|
4560
5066
|
many: z.boolean().optional(),
|
|
4561
|
-
from: stepId.optional()
|
|
5067
|
+
from: stepId.optional(),
|
|
5068
|
+
columns: stringMap.optional(),
|
|
5069
|
+
until: z.string().min(1).optional(),
|
|
5070
|
+
align: z.enum(TABLE_ALIGNS).optional()
|
|
5071
|
+
}).check(context => {
|
|
5072
|
+
if (context.value.kind === 'table') return;
|
|
5073
|
+
for (const key of tableOnly) {
|
|
5074
|
+
if (context.value[key] !== undefined) context.issues.push({
|
|
5075
|
+
code: 'custom',
|
|
5076
|
+
input: context.value,
|
|
5077
|
+
path: [key],
|
|
5078
|
+
message: `"${key}" belongs to kind "table"`
|
|
5079
|
+
});
|
|
5080
|
+
}
|
|
4562
5081
|
});
|
|
4563
5082
|
const assignStep = z.strictObject({
|
|
4564
5083
|
...base,
|
|
@@ -5136,6 +5655,7 @@ function walkStep(step, at, mode, known, report, state) {
|
|
|
5136
5655
|
known.add(step.id);
|
|
5137
5656
|
}
|
|
5138
5657
|
if (step.type === 'extract' && step.from !== undefined && !known.has(step.from)) report(`${at}.from`, `"${step.from}" is not a known id`);
|
|
5658
|
+
if (mode === 'web' && state.bootstrap !== true && step.type === 'extract' && step.kind === 'table' && step.from === undefined) report(at, 'a "table" extract reads a PDF, which a web page is not: fetch the PDF with a request step in an api recipe, or extract "from" a PDF bound earlier');
|
|
5139
5659
|
if (step.type === 'collect' && !known.has(step.into)) report(`${at}.into`, `"${step.into}" is not a known id: set it to [] before the loop that collects into it`);
|
|
5140
5660
|
const nested = () => ({
|
|
5141
5661
|
...state,
|
|
@@ -5298,5 +5818,5 @@ function isSameOutput(document, output) {
|
|
|
5298
5818
|
return recipeKindOf(document.content) === 'output' && document.content.id === output.id;
|
|
5299
5819
|
}
|
|
5300
5820
|
|
|
5301
|
-
export { ACCESS_PRESETS, AccessBroker, AccessConfigError, BrowserClient, BrowserSession, HttpClient, HttpError, MappingFailedError, RecipeBindingError, RecipeSet, RecipeValidationError, RecordRejectedError, StepFailure, TransformError, UnknownHookError, accessConfigJsonSchema, accessConfigSchema, bindRecipeSet, createCrawler, inputRecipeJsonSchema, inputRecipeSchema, jsonLinesSink, loadAccessConfig, loadRecipeSet, loadRecipes, memorySink, outputRecipeJsonSchema, outputRecipeSchema, parseInputRecipe, parseOutputRecipe, readRecipeSource, traceLine, tryParseJson, validateBinding };
|
|
5821
|
+
export { ACCESS_PRESETS, AccessBroker, AccessConfigError, BrowserClient, BrowserSession, HttpClient, HttpError, MappingFailedError, PdfReadError, RecipeBindingError, RecipeSet, RecipeValidationError, RecordRejectedError, StepFailure, TransformError, UnknownHookError, accessConfigJsonSchema, accessConfigSchema, bindRecipeSet, createCrawler, findTables, inputRecipeJsonSchema, inputRecipeSchema, jsonLinesSink, loadAccessConfig, loadRecipeSet, loadRecipes, memorySink, outputRecipeJsonSchema, outputRecipeSchema, parseInputRecipe, parseOutputRecipe, pdfText, readPdf, readRecipeSource, traceLine, tryParseJson, validateBinding };
|
|
5302
5822
|
//# sourceMappingURL=index.esm.js.map
|
|
@@ -3,7 +3,8 @@ import type { ExtractStep } from '../recipe-schema/index.js';
|
|
|
3
3
|
/**
|
|
4
4
|
* Runs an `extract` step against a static document: the value bound under
|
|
5
5
|
* `from`, else the scope's current document. `css` reads HTML, `jsonpath`
|
|
6
|
-
* reads JSON
|
|
6
|
+
* reads JSON (or a read PDF's rows), `table` reads a PDF's tables, `regex`
|
|
7
|
+
* reads any document as text; `xpath` needs a live page and is refused here.
|
|
7
8
|
*
|
|
8
9
|
* A `jsonpath` extract whose `from` is text parses that text as JSON, and a
|
|
9
10
|
* list of texts (every `<script type="application/ld+json">` of a page) becomes
|
|
@@ -17,6 +17,6 @@ import type { RunGate } from '../step-flow/index.js';
|
|
|
17
17
|
* @throws BlockedError when the response is a block; HttpError for any other 4xx/5xx.
|
|
18
18
|
*/
|
|
19
19
|
export declare function sendRequest(step: RequestStep, scope: ExtractionScope, client: HttpSender, recipe: InputRecipe, gate: RunGate, events: EventBus): Promise<void>;
|
|
20
|
-
/** What a step id holds for a document: parsed JSON, or the markup / text. */
|
|
20
|
+
/** What a step id holds for a document: parsed JSON, the read PDF, or the markup / text. */
|
|
21
21
|
export declare function documentValue(body: HttpBody): unknown;
|
|
22
22
|
//# sourceMappingURL=send-request.use-case.d.ts.map
|
|
@@ -8,6 +8,8 @@ export interface RecipeReport {
|
|
|
8
8
|
duplicates: number;
|
|
9
9
|
/** Records a resumed run found in the sink already. */
|
|
10
10
|
skipped: number;
|
|
11
|
+
/** Steps whose `onError: skip` policy swallowed a failure: a check that found nothing, a value a page lacked. */
|
|
12
|
+
stepsSkipped: number;
|
|
11
13
|
pages: number;
|
|
12
14
|
durationMs: number;
|
|
13
15
|
/** Set when the recipe stopped on a failure. */
|
|
@@ -7,8 +7,9 @@
|
|
|
7
7
|
* Page state (the current URL, page number and the document `extract` reads by
|
|
8
8
|
* default) is scope state too, bound in the innermost scope that navigated.
|
|
9
9
|
*/
|
|
10
|
+
import type { PdfDocument } from '../pdf-document/index.js';
|
|
10
11
|
/** A fetched or rendered document a later `extract` can read. */
|
|
11
|
-
export type ScopeDocument = {
|
|
12
|
+
export type ScopeDocument = PdfDocument | {
|
|
12
13
|
kind: 'json';
|
|
13
14
|
data: unknown;
|
|
14
15
|
} | {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { PdfDocument } from '../pdf-document/index.js';
|
|
1
2
|
import type { BodyKind, HttpMethod } from '../recipe-schema/index.js';
|
|
2
3
|
/** One HTTP request as the api runner sends it, templates already rendered. */
|
|
3
4
|
export interface HttpRequest {
|
|
@@ -11,7 +12,7 @@ export interface HttpRequest {
|
|
|
11
12
|
timeoutMs?: number;
|
|
12
13
|
}
|
|
13
14
|
/** A parsed response body. Structurally the same as a scope document, on purpose. */
|
|
14
|
-
export type HttpBody = {
|
|
15
|
+
export type HttpBody = PdfDocument | {
|
|
15
16
|
kind: 'json';
|
|
16
17
|
data: unknown;
|
|
17
18
|
} | {
|
package/dist/src/index.d.ts
CHANGED
|
@@ -19,6 +19,8 @@ export { BrowserClient, BrowserSession } from './browser-session/index.js';
|
|
|
19
19
|
export { HttpClient, HttpError } from './http-session/index.js';
|
|
20
20
|
export type { HttpClientOptions, HttpRequest, HttpResponse, HttpBody } from './http-session/index.js';
|
|
21
21
|
export { tryParseJson } from './selection/index.js';
|
|
22
|
+
export { readPdf, PdfReadError, findTables, pdfText } from './pdf-document/index.js';
|
|
23
|
+
export type { PdfDocument, PdfPage, PdfRow, PdfCell, PdfTable, TableQuery, TableAlign } from './pdf-document/index.js';
|
|
22
24
|
export { StepFailure } from './step-flow/index.js';
|
|
23
25
|
export { TransformError } from './transformation/index.js';
|
|
24
26
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { readPdf, PdfReadError } from './read-pdf.client.js';
|
|
2
|
+
export { pdfText, isPdfDocument } from './pdf-document.model.js';
|
|
3
|
+
export type { PdfDocument, PdfPage, PdfRow, PdfCell, PositionedText } from './pdf-document.model.js';
|
|
4
|
+
export { assembleRows } from './row-assembly.algorithm.js';
|
|
5
|
+
export { findTables } from './pdf-table.algorithm.js';
|
|
6
|
+
export type { PdfTable, TableQuery, TableAlign } from './pdf-table.algorithm.js';
|
|
7
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/** A run of text at a position on a page, in PDF points, y growing upwards from the bottom edge. */
|
|
2
|
+
export interface PositionedText {
|
|
3
|
+
x: number;
|
|
4
|
+
/** The baseline. */
|
|
5
|
+
y: number;
|
|
6
|
+
width: number;
|
|
7
|
+
/** The font size: the text spans roughly `y` to `y + height`. */
|
|
8
|
+
height: number;
|
|
9
|
+
text: string;
|
|
10
|
+
}
|
|
11
|
+
/** One or more text runs that sit side by side on one baseline: a table cell, a label, a value. */
|
|
12
|
+
export type PdfCell = PositionedText;
|
|
13
|
+
/** Cells whose vertical extents overlap: one visual line, which in a table is one row. */
|
|
14
|
+
export interface PdfRow {
|
|
15
|
+
/** The top of the highest cell. */
|
|
16
|
+
top: number;
|
|
17
|
+
/** The baseline of the lowest cell. */
|
|
18
|
+
bottom: number;
|
|
19
|
+
/** The cells, left to right. */
|
|
20
|
+
cells: PdfCell[];
|
|
21
|
+
/** The cells' texts joined by a tab, left to right. */
|
|
22
|
+
text: string;
|
|
23
|
+
}
|
|
24
|
+
export interface PdfPage {
|
|
25
|
+
number: number;
|
|
26
|
+
width: number;
|
|
27
|
+
height: number;
|
|
28
|
+
/** Top to bottom. Empty on a page with no text layer (a scan). */
|
|
29
|
+
rows: PdfRow[];
|
|
30
|
+
}
|
|
31
|
+
/** A PDF read into rows of positioned text: what `extract` works on. */
|
|
32
|
+
export interface PdfDocument {
|
|
33
|
+
kind: 'pdf';
|
|
34
|
+
pages: PdfPage[];
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* The text a `regex` extract reads: one line per row (cells separated by a
|
|
38
|
+
* tab), pages separated by a blank line.
|
|
39
|
+
*
|
|
40
|
+
* @param document - The PDF.
|
|
41
|
+
* @returns The text.
|
|
42
|
+
*/
|
|
43
|
+
export declare function pdfText(document: PdfDocument): string;
|
|
44
|
+
/**
|
|
45
|
+
* Whether a value bound in scope is a read PDF (so `extract … from` can take it).
|
|
46
|
+
*
|
|
47
|
+
* @param value - Anything.
|
|
48
|
+
* @returns Whether it is a {@link PdfDocument}.
|
|
49
|
+
*/
|
|
50
|
+
export declare function isPdfDocument(value: unknown): value is PdfDocument;
|
|
51
|
+
//# sourceMappingURL=pdf-document.model.d.ts.map
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { PdfDocument } from './pdf-document.model.js';
|
|
2
|
+
/** What a table extract looks for. */
|
|
3
|
+
export interface TableQuery {
|
|
4
|
+
/** Matches a table's header row, its cells joined by spaces (`^MODELLI`). */
|
|
5
|
+
header: RegExp;
|
|
6
|
+
/** Matches the row that ends a table (`^NOTA BENE`); a table also ends at the next header or the page's end. */
|
|
7
|
+
until?: RegExp;
|
|
8
|
+
/** Output key -> a pattern for the header cell of that column; unmatched columns are dropped. Without it, the header texts are the keys. */
|
|
9
|
+
columns?: Record<string, RegExp>;
|
|
10
|
+
/** How the table aligns a row's values against a cell wrapped over several lines; `auto` (the default) infers it. */
|
|
11
|
+
align?: TableAlign;
|
|
12
|
+
}
|
|
13
|
+
/** Where a row's values sit against a cell wrapped over several lines. */
|
|
14
|
+
export type TableAlign = 'auto' | 'top' | 'center' | 'bottom';
|
|
15
|
+
/** One table found in a PDF. */
|
|
16
|
+
export interface PdfTable {
|
|
17
|
+
page: number;
|
|
18
|
+
/** The first header cell: the table's name when it has one (`MODELLI FIAT`). */
|
|
19
|
+
title: string;
|
|
20
|
+
/** The header cells, left to right. */
|
|
21
|
+
header: string[];
|
|
22
|
+
/** One object per row, keyed by column; a cell's lines are joined by spaces. */
|
|
23
|
+
rows: Record<string, string>[];
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Finds every table whose header row matches, and reads its rows by column.
|
|
27
|
+
*
|
|
28
|
+
* Columns come from the body, not the header: a header is often centred over
|
|
29
|
+
* a column whose cells are left-aligned, so the header's position says little
|
|
30
|
+
* about where the column starts. The left edges of the body cells cluster into
|
|
31
|
+
* bands; each band belongs to the header cell that overlaps it most, or the
|
|
32
|
+
* nearest one when none does. A header spanning two columns reads both.
|
|
33
|
+
*
|
|
34
|
+
* A cell wrapped over several lines (a long name, a note, a list of versions)
|
|
35
|
+
* spreads one row over several lines, its values often centred beside it:
|
|
36
|
+
* the lines are regrouped into rows around the lines that carry values.
|
|
37
|
+
*
|
|
38
|
+
* @param document - The PDF.
|
|
39
|
+
* @param query - Which tables, and how to name their columns.
|
|
40
|
+
* @returns The tables, in page order.
|
|
41
|
+
*/
|
|
42
|
+
export declare function findTables(document: PdfDocument, query: TableQuery): PdfTable[];
|
|
43
|
+
//# sourceMappingURL=pdf-table.algorithm.d.ts.map
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { PdfDocument } from './pdf-document.model.js';
|
|
2
|
+
/** A PDF that cannot be read: not a PDF, encrypted, or with no text to read. */
|
|
3
|
+
export declare class PdfReadError extends Error {
|
|
4
|
+
readonly name = "PdfReadError";
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Reads a PDF's text layer into rows of positioned cells, with pdf.js. pdf.js
|
|
8
|
+
* is imported on first use, so recipes that never read a PDF never load it.
|
|
9
|
+
* Only text is read: no page is rendered, no script runs, no font is loaded.
|
|
10
|
+
*
|
|
11
|
+
* @param bytes - The file.
|
|
12
|
+
* @param source - Where it came from, for messages.
|
|
13
|
+
* @returns The document.
|
|
14
|
+
* @throws PdfReadError when the bytes are not a readable PDF, or no page has a
|
|
15
|
+
* text layer (a scan: OCR is not supported).
|
|
16
|
+
*/
|
|
17
|
+
export declare function readPdf(bytes: Uint8Array, source?: string): Promise<PdfDocument>;
|
|
18
|
+
//# sourceMappingURL=read-pdf.client.d.ts.map
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { PdfRow, PositionedText } from './pdf-document.model.js';
|
|
2
|
+
/**
|
|
3
|
+
* Turns a page's text runs into rows of cells, top to bottom.
|
|
4
|
+
*
|
|
5
|
+
* Runs on one baseline that nearly touch become one cell. Cells whose vertical
|
|
6
|
+
* extents overlap become one row, even when their baselines differ: a table
|
|
7
|
+
* that centres its cells vertically puts a one-line value a few points above
|
|
8
|
+
* or below its two-line label, and a row built from equal baselines would pair
|
|
9
|
+
* the value with the wrong label.
|
|
10
|
+
*
|
|
11
|
+
* @param runs - The page's text runs, in any order.
|
|
12
|
+
* @returns The rows.
|
|
13
|
+
*/
|
|
14
|
+
export declare function assembleRows(runs: readonly PositionedText[]): PdfRow[];
|
|
15
|
+
/** `Array#findLast`, which the es2022 library does not declare. */
|
|
16
|
+
export declare function lastOf<T>(items: readonly T[], test: (item: T) => boolean): T | undefined;
|
|
17
|
+
//# sourceMappingURL=row-assembly.algorithm.d.ts.map
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
/** The closed vocabularies a recipe file can use. Each is a `readonly` tuple so zod and TypeScript share it. */
|
|
2
2
|
export declare const RECIPE_KINDS: readonly ["input", "output"];
|
|
3
3
|
export declare const CRAWL_MODES: readonly ["web", "api"];
|
|
4
|
-
export declare const SELECTOR_KINDS: readonly ["css", "xpath", "jsonpath", "regex"];
|
|
4
|
+
export declare const SELECTOR_KINDS: readonly ["css", "xpath", "jsonpath", "regex", "table"];
|
|
5
5
|
/** `take` also accepts `attr:<name>`, which is validated by pattern rather than listed. */
|
|
6
6
|
export declare const TAKE_KINDS: readonly ["text", "html", "value", "json"];
|
|
7
|
-
export declare const BODY_KINDS: readonly ["json", "html", "text"];
|
|
7
|
+
export declare const BODY_KINDS: readonly ["json", "html", "text", "pdf"];
|
|
8
|
+
/** How a PDF table aligns a row's values against a cell wrapped over several lines. */
|
|
9
|
+
export declare const TABLE_ALIGNS: readonly ["auto", "top", "center", "bottom"];
|
|
8
10
|
export declare const FIELD_TYPES: readonly ["string", "number", "integer", "boolean", "date", "datetime", "currency", "url", "enum", "array", "object", "json"];
|
|
9
11
|
export declare const MISSING_POLICIES: readonly ["fail", "skip-record", "null", "default"];
|
|
10
12
|
export declare const RECIPE_MISSING_POLICIES: readonly ["fail", "skip-record", "null"];
|
|
@@ -21,6 +23,7 @@ export type RecipeKind = typeof RECIPE_KINDS[number];
|
|
|
21
23
|
export type CrawlMode = typeof CRAWL_MODES[number];
|
|
22
24
|
export type SelectorKind = typeof SELECTOR_KINDS[number];
|
|
23
25
|
export type BodyKind = typeof BODY_KINDS[number];
|
|
26
|
+
export type TableAlign = typeof TABLE_ALIGNS[number];
|
|
24
27
|
export type FieldType = typeof FIELD_TYPES[number];
|
|
25
28
|
export type MissingPolicy = typeof MISSING_POLICIES[number];
|
|
26
29
|
export type RecipeMissingPolicy = typeof RECIPE_MISSING_POLICIES[number];
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { TAKE_KINDS } from './recipe-kind.enum.js';
|
|
3
|
-
import type { BodyKind, HttpMethod, SelectorKind, WaitUntil } from './recipe-kind.enum.js';
|
|
3
|
+
import type { BodyKind, HttpMethod, SelectorKind, TableAlign, WaitUntil } from './recipe-kind.enum.js';
|
|
4
4
|
/** What to do when a step fails. Resolved step -> recipe -> `fail`. */
|
|
5
5
|
export type ErrorPolicy = {
|
|
6
6
|
policy: 'fail';
|
|
@@ -95,6 +95,12 @@ export interface ExtractStep extends StepBaseFields {
|
|
|
95
95
|
many?: boolean;
|
|
96
96
|
/** Id of a document or fragment to read instead of the current document. */
|
|
97
97
|
from?: string;
|
|
98
|
+
/** `table` only: output key -> a pattern (case-insensitive) for that column's header cell. */
|
|
99
|
+
columns?: Record<string, string>;
|
|
100
|
+
/** `table` only: a pattern (case-insensitive) for the row that ends a table. */
|
|
101
|
+
until?: string;
|
|
102
|
+
/** `table` only: how a row's values sit against a cell wrapped over several lines; default `auto`. */
|
|
103
|
+
align?: TableAlign;
|
|
98
104
|
}
|
|
99
105
|
export interface SetStep extends StepBaseFields {
|
|
100
106
|
type: 'set';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opencraw/core",
|
|
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",
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
"cheerio": "^1.2.0",
|
|
24
24
|
"domhandler": "^6.0.1",
|
|
25
25
|
"jsonpath-plus": "^10.4.0",
|
|
26
|
+
"pdfjs-dist": "^6.3.289",
|
|
26
27
|
"playwright": "^1.63.0",
|
|
27
28
|
"zod": "^4.6.5"
|
|
28
29
|
},
|