hazo_scrape 1.4.0 → 1.6.0
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/CHANGE_LOG.md +42 -0
- package/dist/fetch/index.d.ts +6 -0
- package/dist/fetch/index.d.ts.map +1 -1
- package/dist/fetch/index.js +13 -2
- package/dist/fetch/types.d.ts +4 -0
- package/dist/fetch/types.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/parse/extract_table.d.ts +35 -0
- package/dist/parse/extract_table.d.ts.map +1 -1
- package/dist/parse/extract_table.js +95 -25
- package/dist/parse/map_rows.d.ts +1 -0
- package/dist/parse/map_rows.d.ts.map +1 -1
- package/dist/parse/map_rows.js +4 -2
- package/dist/parse/parse_date.d.ts +1 -0
- package/dist/parse/parse_date.d.ts.map +1 -1
- package/dist/parse/parse_date.js +38 -6
- package/dist/parse/parse_number.d.ts.map +1 -1
- package/dist/parse/parse_number.js +16 -0
- package/dist/pdf/extract_table_pdf.d.ts +11 -0
- package/dist/pdf/extract_table_pdf.d.ts.map +1 -0
- package/dist/pdf/extract_table_pdf.js +141 -0
- package/dist/pdf/index.d.ts +2 -0
- package/dist/pdf/index.d.ts.map +1 -0
- package/dist/pdf/index.js +2 -0
- package/package.json +2 -1
package/CHANGE_LOG.md
CHANGED
|
@@ -1,5 +1,47 @@
|
|
|
1
1
|
# hazo_scrape — Change Log
|
|
2
2
|
|
|
3
|
+
## 1.6.0 — 2026-07-25
|
|
4
|
+
|
|
5
|
+
### Added — `parseNumber` tokenizes a trailing bare cent marker
|
|
6
|
+
- `parseNumber('45c')` → `45`, `parseNumber('14.5c')` → `14.5`, `parseNumber('45¢')` → `45`. This is the symbol/abbreviation form of the `cents` word already stripped (many AU/UK IR tables write `45c` rather than `45 cents`), so it is tokenized identically — the cents→dollars **scaling stays the caller's job** (via a domain `amount_unit`). Anchored to a digit immediately before the marker and to end-of-string, so it never touches a `c` inside a word (`recent`) or a mid-string letter. Surfaced by Woolworths (`45c`) and Harvey Norman (`14.5c`) dividend tables.
|
|
7
|
+
|
|
8
|
+
### Added — `parseDate` optional 2-digit-year support (`opts.yearPivot`)
|
|
9
|
+
- `parseDate('10 Mar 26', { yearPivot: 2000 })` → `2026-03-10`. 2-digit years remain **rejected by default** (returning `null`, true to the "never guess the century" contract); a caller that knows the source's convention opts in with `yearPivot`, the base of a sliding 100-year window (`2000` reads `26`→2026/`99`→2099; `1950` reads `26`→2026/`99`→1999). Applies to the named-month formats (`D Mon YY`, `Mon D, YY`); slash dates stay 4-digit-only (a 2-digit slash year compounds day/month AND century ambiguity). Surfaced by Coles, whose live table writes `10 Mar 26`.
|
|
10
|
+
- `mapRows` gains `opts.dateYearPivot?: number`, forwarded verbatim to `parseDate` for every date-typed cell.
|
|
11
|
+
|
|
12
|
+
### Added — `extractTable` `cellIgnoreSelectors` (strip in-cell noise)
|
|
13
|
+
- `extractTable(html, { cellIgnoreSelectors: ['sup'] })` removes matching descendants from every cell (and header) **before** its text is read, so `100<sup>4</sup>` reads as `100`, not `1004`. Off by default (cells read verbatim). Applies to real-table, grid, and combine modes alike; the removal is done on a per-cell clone, never mutating the shared document. Surfaced by Wesfarmers, whose franking cells staple a footnote superscript onto the number.
|
|
14
|
+
|
|
15
|
+
### Added — `extractTable` `combine` mode (header-only table + per-section sibling tables)
|
|
16
|
+
- `extractTable(html, { combine: { headerSelector, dropLabelRows? } })` reconstructs one logical table from a header-only `<table>` followed by one `<table>` per section (e.g. per year), each led by a section-label row — a common Computershare-style IR layout. Headers come from `headerSelector`'s first row; data rows are gathered from that table plus its immediately-following sibling `<table>`s **that share its column count** (stopping at the first that doesn't, so an unrelated later table can't leak in). `dropLabelRows: true` drops bare section-divider rows (only the first cell non-empty). Takes precedence over `select`; `grid` still wins over it. Surfaced by JB Hi-Fi, whose dividend history is a header table plus one table per year.
|
|
17
|
+
|
|
18
|
+
## 1.5.2 — 2026-07-24
|
|
19
|
+
|
|
20
|
+
### Fixed — `extractTableFromPdf` mid-word text-run splits
|
|
21
|
+
- A word a PDF splits across two adjacent text runs (pdfjs reports the second run beginning, within 1px, exactly where the first ended, with no whitespace at either boundary — e.g. `"Septemb"` + `"er"`) is now glued back with **no space** (`"September"`) instead of joined with a space (`"Septemb er"`). Genuinely separate tokens on the same line — which render a visible gap and/or carry a boundary space in the run text — still get their single separating space. Surfaced by BHP's historical dividend PDF, where a split month name (`"4 Septemb er 2020"`) made a record-date cell unparseable. `lineToCells` now decides spacing per-item from each text run's `x + width` and boundary whitespace rather than blindly space-joining.
|
|
22
|
+
|
|
23
|
+
## 1.5.1 — 2026-07-24
|
|
24
|
+
|
|
25
|
+
### Fixed — `extractTableFromPdf` multi-page row merging
|
|
26
|
+
- Visual lines are now clustered **per page** instead of over the concatenation of all pages' text items. Text-item y-coordinates repeat page to page (row N sits at the same y on every page), so the previous cross-page clustering collapsed each page's row N into a single garbled line — a multi-page table (e.g. BHP's 6-page historical dividend list) came out unusable. Column-band detection still spans all items (column geometry is page-independent); page order is preserved; a single-page document is unaffected.
|
|
27
|
+
|
|
28
|
+
## 1.5.0 — 2026-07-24
|
|
29
|
+
|
|
30
|
+
### Added — `extractTableFromPdf` (PDF text-layer → table)
|
|
31
|
+
- `extractTableFromPdf(data, opts?)` reconstructs a `{ headers, rows }` grid — the same `ExtractTableResult` shape `extractTable(html)` produces — from a PDF's positioned text layer, via `pdfjs-dist`. It clusters text items into visual lines by y-position and into columns by x-band, so a caller's existing `mapColumns`/`mapRows` pipeline consumes HTML and PDF tables identically.
|
|
32
|
+
- `opts.columnBands?: number[]` — left x-edges of each column; omit to auto-detect by x-gap (a 25px gap starts a new column).
|
|
33
|
+
- `opts.headerLabels?: string[]` — verbatim headers positionally aligned to the bands; when set, every clustered line is treated as a data row (for PDFs whose header spans multiple visual lines or uses columns no keyword classifier should see).
|
|
34
|
+
- `opts.headerRows?: number` — else merge the first N lines into headers (default 1); ignored when `headerLabels` is set.
|
|
35
|
+
- `opts.mergeContinuationRows?: boolean` — a line whose leftmost band is empty is folded into the previous row (per-band, space-joined) instead of becoming its own row — for PDFs that wrap a cell's overflow (e.g. a dual-currency amount) onto a second line.
|
|
36
|
+
- `opts.rowTolerance?: number` (default 3) and `opts.maxPages?: number` round out the tuning surface.
|
|
37
|
+
- New export surface: `src/pdf/` re-exported from the package root (`.`) — SERVER-ONLY (pulls in `pdfjs-dist`), never from the client-safe `./parse` entry.
|
|
38
|
+
- New dependency: `pdfjs-dist@^4.10.38` (imported from `pdfjs-dist/legacy/build/pdf.mjs`; no worker/font setup needed for text-only extraction).
|
|
39
|
+
|
|
40
|
+
### Added — `fetchPdf` + `FetchOptions.binary` (raw-byte fetch)
|
|
41
|
+
- `fetchPdf(url, opts?)` fetches a PDF (or any binary document) as raw bytes, reusing `fetchDocument`'s robots / rate-limit / retry / proxy machinery. On a 2xx response, `result.bytes` (`Uint8Array`) holds the payload.
|
|
42
|
+
- `FetchOptions` gains `binary?: boolean`; `FetchResult` gains `bytes?: Uint8Array`. When `binary: true`, `fetchDocument` reads the response as bytes instead of text (`body` is `''`) and the response is never written to the on-disk cache.
|
|
43
|
+
- Motivation: `extractTableFromPdf` needs raw bytes, not decoded text, and a caller (e.g. `extractor_asx`) should not have to hand-roll a second fetch path to get them.
|
|
44
|
+
|
|
3
45
|
## 1.4.0 — 2026-07-24
|
|
4
46
|
|
|
5
47
|
### Added — `parseDate` optional `opts.extractLeading` (+ `mapRows` `opts.dateExtractLeading`)
|
package/dist/fetch/index.d.ts
CHANGED
|
@@ -7,4 +7,10 @@ export type { ScrapeTableOptions, TableResult } from './scrape.js';
|
|
|
7
7
|
/** Clears all cached ProxyPools. For tests. */
|
|
8
8
|
export declare function resetProxyPools(): void;
|
|
9
9
|
export declare function fetchDocument(url: string, opts?: FetchOptions): Promise<FetchResult>;
|
|
10
|
+
/**
|
|
11
|
+
* Fetch a PDF (or any binary document) as raw bytes, reusing fetchDocument's
|
|
12
|
+
* robots / rate-limit / retry / proxy machinery. On a 2xx, `result.bytes` holds
|
|
13
|
+
* the payload; binary responses are never cached.
|
|
14
|
+
*/
|
|
15
|
+
export declare function fetchPdf(url: string, opts?: FetchOptions): Promise<FetchResult>;
|
|
10
16
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/fetch/index.ts"],"names":[],"mappings":"AA0BA,OAAO,KAAK,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAE5D,OAAO,EAAE,qBAAqB,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AACtE,OAAO,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAClD,OAAO,EAAE,SAAS,EAAE,mBAAmB,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAC9E,OAAO,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAC1C,YAAY,EAAE,kBAAkB,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAOnE,+CAA+C;AAC/C,wBAAgB,eAAe,IAAI,IAAI,CAEtC;AA0GD,wBAAsB,aAAa,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,GAAE,YAAiB,GAAG,OAAO,CAAC,WAAW,CAAC,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/fetch/index.ts"],"names":[],"mappings":"AA0BA,OAAO,KAAK,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAE5D,OAAO,EAAE,qBAAqB,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AACtE,OAAO,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAClD,OAAO,EAAE,SAAS,EAAE,mBAAmB,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAC9E,OAAO,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAC1C,YAAY,EAAE,kBAAkB,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAOnE,+CAA+C;AAC/C,wBAAgB,eAAe,IAAI,IAAI,CAEtC;AA0GD,wBAAsB,aAAa,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,GAAE,YAAiB,GAAG,OAAO,CAAC,WAAW,CAAC,CAoN9F;AAED;;;;GAIG;AACH,wBAAsB,QAAQ,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,GAAE,YAAiB,GAAG,OAAO,CAAC,WAAW,CAAC,CAEzF"}
|
package/dist/fetch/index.js
CHANGED
|
@@ -251,14 +251,16 @@ export async function fetchDocument(url, opts = {}) {
|
|
|
251
251
|
}
|
|
252
252
|
// Terminal: 2xx/3xx, or a terminal 4xx not in retryOn. Returned as a
|
|
253
253
|
// FetchResult (ok:false for 4xx) — never thrown.
|
|
254
|
-
const
|
|
254
|
+
const isBinary = opts.binary === true;
|
|
255
|
+
const bytes = isBinary ? new Uint8Array(await res.arrayBuffer()) : undefined;
|
|
256
|
+
const body = isBinary ? '' : await res.text();
|
|
255
257
|
const finalUrl = res.url || url;
|
|
256
258
|
const responseHeaders = collectHeaders(res);
|
|
257
259
|
const contentType = res.headers.get('content-type') ?? undefined;
|
|
258
260
|
// [Phase 5] Only 2xx responses are cache-worthy — terminal 4xx/etc are
|
|
259
261
|
// never stored. Written under the same pre-redirect `url`-derived key
|
|
260
262
|
// used for the lookup in step 3.
|
|
261
|
-
if (res.ok && cfg.cache.enabled && cacheKey) {
|
|
263
|
+
if (res.ok && !isBinary && cfg.cache.enabled && cacheKey) {
|
|
262
264
|
await writeCache(cfg.cache, cacheKey, {
|
|
263
265
|
finalUrl,
|
|
264
266
|
status: res.status,
|
|
@@ -277,6 +279,7 @@ export async function fetchDocument(url, opts = {}) {
|
|
|
277
279
|
ok: res.ok,
|
|
278
280
|
headers: responseHeaders,
|
|
279
281
|
body,
|
|
282
|
+
bytes,
|
|
280
283
|
contentType,
|
|
281
284
|
fromCache: false,
|
|
282
285
|
attempts: attempt,
|
|
@@ -324,3 +327,11 @@ export async function fetchDocument(url, opts = {}) {
|
|
|
324
327
|
...(lastError !== undefined ? { cause: lastError } : {}),
|
|
325
328
|
});
|
|
326
329
|
}
|
|
330
|
+
/**
|
|
331
|
+
* Fetch a PDF (or any binary document) as raw bytes, reusing fetchDocument's
|
|
332
|
+
* robots / rate-limit / retry / proxy machinery. On a 2xx, `result.bytes` holds
|
|
333
|
+
* the payload; binary responses are never cached.
|
|
334
|
+
*/
|
|
335
|
+
export async function fetchPdf(url, opts = {}) {
|
|
336
|
+
return fetchDocument(url, { ...opts, binary: true });
|
|
337
|
+
}
|
package/dist/fetch/types.d.ts
CHANGED
|
@@ -37,6 +37,8 @@ export interface FetchOptions {
|
|
|
37
37
|
};
|
|
38
38
|
/** Test seam — passed through to safeFetch's `deps.fetchImpl`. */
|
|
39
39
|
fetchImpl?: typeof fetch;
|
|
40
|
+
/** When true, read the response as raw bytes into `FetchResult.bytes` instead of text. Not cached. */
|
|
41
|
+
binary?: boolean;
|
|
40
42
|
}
|
|
41
43
|
export interface FetchResult {
|
|
42
44
|
url: string;
|
|
@@ -51,5 +53,7 @@ export interface FetchResult {
|
|
|
51
53
|
attempts: number;
|
|
52
54
|
timingMs: number;
|
|
53
55
|
usedProxy?: string;
|
|
56
|
+
/** Raw response bytes — populated only when the request was made with `binary: true`. */
|
|
57
|
+
bytes?: Uint8Array;
|
|
54
58
|
}
|
|
55
59
|
//# sourceMappingURL=types.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/fetch/types.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,aAAa,CAAC;AAKvD,MAAM,MAAM,eAAe,GAAG,mBAAmB,CAAC;AAElD,MAAM,WAAW,YAAY;IAC3B,kFAAkF;IAClF,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC;IAC5B,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACjC,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,KAAK,CAAC,EAAE;QACN,WAAW,CAAC,EAAE,MAAM,CAAC;QACrB,SAAS,CAAC,EAAE,MAAM,CAAC;QACnB,aAAa,CAAC,EAAE,MAAM,CAAC;QACvB,MAAM,CAAC,EAAE,OAAO,CAAC;QACjB,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;KACpB,CAAC;IACF,SAAS,CAAC,EAAE;QACV,aAAa,CAAC,EAAE,MAAM,CAAC;QACvB,OAAO,CAAC,EAAE,OAAO,CAAC;KACnB,CAAC;IACF,KAAK,CAAC,EAAE;QACN,IAAI,CAAC,EAAE,MAAM,EAAE,CAAC;QAChB,QAAQ,CAAC,EAAE,MAAM,CAAC;QAClB,QAAQ,CAAC,EAAE,MAAM,CAAC;QAClB,QAAQ,CAAC,EAAE,aAAa,CAAC;QACzB,UAAU,CAAC,EAAE,MAAM,CAAC;KACrB,CAAC;IACF,KAAK,CAAC,EAAE;QACN,OAAO,CAAC,EAAE,OAAO,CAAC;QAClB,GAAG,CAAC,EAAE,MAAM,CAAC;QACb,UAAU,CAAC,EAAE,MAAM,CAAC;KACrB,CAAC;IACF,MAAM,CAAC,EAAE;QACP,OAAO,CAAC,EAAE,OAAO,CAAC;KACnB,CAAC;IACF,kEAAkE;IAClE,SAAS,CAAC,EAAE,OAAO,KAAK,CAAC;
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/fetch/types.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,aAAa,CAAC;AAKvD,MAAM,MAAM,eAAe,GAAG,mBAAmB,CAAC;AAElD,MAAM,WAAW,YAAY;IAC3B,kFAAkF;IAClF,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC;IAC5B,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACjC,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,KAAK,CAAC,EAAE;QACN,WAAW,CAAC,EAAE,MAAM,CAAC;QACrB,SAAS,CAAC,EAAE,MAAM,CAAC;QACnB,aAAa,CAAC,EAAE,MAAM,CAAC;QACvB,MAAM,CAAC,EAAE,OAAO,CAAC;QACjB,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;KACpB,CAAC;IACF,SAAS,CAAC,EAAE;QACV,aAAa,CAAC,EAAE,MAAM,CAAC;QACvB,OAAO,CAAC,EAAE,OAAO,CAAC;KACnB,CAAC;IACF,KAAK,CAAC,EAAE;QACN,IAAI,CAAC,EAAE,MAAM,EAAE,CAAC;QAChB,QAAQ,CAAC,EAAE,MAAM,CAAC;QAClB,QAAQ,CAAC,EAAE,MAAM,CAAC;QAClB,QAAQ,CAAC,EAAE,aAAa,CAAC;QACzB,UAAU,CAAC,EAAE,MAAM,CAAC;KACrB,CAAC;IACF,KAAK,CAAC,EAAE;QACN,OAAO,CAAC,EAAE,OAAO,CAAC;QAClB,GAAG,CAAC,EAAE,MAAM,CAAC;QACb,UAAU,CAAC,EAAE,MAAM,CAAC;KACrB,CAAC;IACF,MAAM,CAAC,EAAE;QACP,OAAO,CAAC,EAAE,OAAO,CAAC;KACnB,CAAC;IACF,kEAAkE;IAClE,SAAS,CAAC,EAAE,OAAO,KAAK,CAAC;IACzB,sGAAsG;IACtG,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB;AAED,MAAM,WAAW,WAAW;IAC1B,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,MAAM,CAAC;IACnB,EAAE,EAAE,OAAO,CAAC;IACZ,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAChC,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,SAAS,EAAE,OAAO,CAAC;IACnB,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,yFAAyF;IACzF,KAAK,CAAC,EAAE,UAAU,CAAC;CACpB"}
|
package/dist/index.d.ts
CHANGED
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAQA,cAAc,gBAAgB,CAAC;AAC/B,cAAc,kBAAkB,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAQA,cAAc,gBAAgB,CAAC;AAC/B,cAAc,kBAAkB,CAAC;AACjC,cAAc,gBAAgB,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -29,10 +29,45 @@ export type GridSelector = {
|
|
|
29
29
|
*/
|
|
30
30
|
headerRow?: string;
|
|
31
31
|
};
|
|
32
|
+
/**
|
|
33
|
+
* Combine-mode selector — for pages that split one logical table across
|
|
34
|
+
* several sibling <table>s: a header-only <table> followed by one <table>
|
|
35
|
+
* per section (e.g. per year), each led by a section-label row. Common on
|
|
36
|
+
* Computershare-style IR pages (e.g. JB Hi-Fi's dividend history). The header
|
|
37
|
+
* comes from `headerSelector`'s first row; data rows are gathered from that
|
|
38
|
+
* table plus its immediately-following sibling <table>s that share its column
|
|
39
|
+
* count (stopping at the first sibling that doesn't), so an unrelated later
|
|
40
|
+
* table can't leak in.
|
|
41
|
+
*/
|
|
42
|
+
export type CombineSelector = {
|
|
43
|
+
/** Selector matching the ONE table whose first row supplies the shared headers. */
|
|
44
|
+
headerSelector: string;
|
|
45
|
+
/**
|
|
46
|
+
* Drop "section-label" rows — a row in which only the first cell holds text
|
|
47
|
+
* and every other cell is empty (e.g. a bare "2025" year divider). Off by
|
|
48
|
+
* default. Column-mapping/parsing would already null such a row out, but
|
|
49
|
+
* dropping it keeps the raw row set clean and honest.
|
|
50
|
+
*/
|
|
51
|
+
dropLabelRows?: boolean;
|
|
52
|
+
};
|
|
32
53
|
export type ExtractTableOptions = {
|
|
33
54
|
select?: string;
|
|
34
55
|
/** Read a <div>-based pseudo-table instead of a real <table>. Takes precedence over `select`. */
|
|
35
56
|
grid?: GridSelector;
|
|
57
|
+
/**
|
|
58
|
+
* Combine a header-only table with its per-section sibling tables into one
|
|
59
|
+
* table (see CombineSelector). Takes precedence over `select`; `grid` still
|
|
60
|
+
* wins over this.
|
|
61
|
+
*/
|
|
62
|
+
combine?: CombineSelector;
|
|
63
|
+
/**
|
|
64
|
+
* CSS selectors whose matching descendants are removed from every cell
|
|
65
|
+
* BEFORE its text is read — for stripping in-cell noise that corrupts
|
|
66
|
+
* parsing, e.g. `['sup']` to drop footnote-reference superscripts so
|
|
67
|
+
* "100<sup>4</sup>" reads as "100" not "1004". Applies to real-table, grid,
|
|
68
|
+
* and combine modes alike. Omit to read cells verbatim (the default).
|
|
69
|
+
*/
|
|
70
|
+
cellIgnoreSelectors?: string[];
|
|
36
71
|
};
|
|
37
72
|
export declare function extractTable(html: string, opts?: ExtractTableOptions): ExtractTableResult;
|
|
38
73
|
//# sourceMappingURL=extract_table.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"extract_table.d.ts","sourceRoot":"","sources":["../../src/parse/extract_table.ts"],"names":[],"mappings":"AAsBA,MAAM,MAAM,kBAAkB,GAAG;IAC/B,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,IAAI,EAAE,MAAM,EAAE,EAAE,CAAC;IACjB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB,CAAC;AAEF;;;;;GAKG;AACH,MAAM,MAAM,YAAY,GAAG;IACzB;;;;;;OAMG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,0FAA0F;IAC1F,GAAG,EAAE,MAAM,CAAC;IACZ,0DAA0D;IAC1D,IAAI,EAAE,MAAM,CAAC;IACb;;;;OAIG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB,CAAC;AAEF,MAAM,MAAM,mBAAmB,GAAG;IAChC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,iGAAiG;IACjG,IAAI,CAAC,EAAE,YAAY,CAAC;
|
|
1
|
+
{"version":3,"file":"extract_table.d.ts","sourceRoot":"","sources":["../../src/parse/extract_table.ts"],"names":[],"mappings":"AAsBA,MAAM,MAAM,kBAAkB,GAAG;IAC/B,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,IAAI,EAAE,MAAM,EAAE,EAAE,CAAC;IACjB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB,CAAC;AAEF;;;;;GAKG;AACH,MAAM,MAAM,YAAY,GAAG;IACzB;;;;;;OAMG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,0FAA0F;IAC1F,GAAG,EAAE,MAAM,CAAC;IACZ,0DAA0D;IAC1D,IAAI,EAAE,MAAM,CAAC;IACb;;;;OAIG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB,CAAC;AAEF;;;;;;;;;GASG;AACH,MAAM,MAAM,eAAe,GAAG;IAC5B,mFAAmF;IACnF,cAAc,EAAE,MAAM,CAAC;IACvB;;;;;OAKG;IACH,aAAa,CAAC,EAAE,OAAO,CAAC;CACzB,CAAC;AAEF,MAAM,MAAM,mBAAmB,GAAG;IAChC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,iGAAiG;IACjG,IAAI,CAAC,EAAE,YAAY,CAAC;IACpB;;;;OAIG;IACH,OAAO,CAAC,EAAE,eAAe,CAAC;IAC1B;;;;;;OAMG;IACH,mBAAmB,CAAC,EAAE,MAAM,EAAE,CAAC;CAChC,CAAC;AAgQF,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,mBAAmB,GAAG,kBAAkB,CAkCzF"}
|
|
@@ -25,6 +25,18 @@ const NO_TABLE_WARNING = 'No table-like element found on the page (it may be Jav
|
|
|
25
25
|
function normalizeCell(raw) {
|
|
26
26
|
return raw.replace(/\s+/g, ' ').trim();
|
|
27
27
|
}
|
|
28
|
+
// Read a cell's normalized text, optionally removing noise descendants
|
|
29
|
+
// (`ignore`) first — e.g. footnote <sup>s — so they never reach the parsed
|
|
30
|
+
// value. Clones the cell so the removal is local to this read and never
|
|
31
|
+
// mutates the shared document (other cells / re-reads see the original).
|
|
32
|
+
function readCellText(cellSel, ignore) {
|
|
33
|
+
if (ignore && ignore.length > 0) {
|
|
34
|
+
const clone = cellSel.clone();
|
|
35
|
+
clone.find(ignore.join(',')).remove();
|
|
36
|
+
return normalizeCell(clone.text());
|
|
37
|
+
}
|
|
38
|
+
return normalizeCell(cellSel.text());
|
|
39
|
+
}
|
|
28
40
|
// Rows that belong directly to THIS table — not to any table nested inside a
|
|
29
41
|
// cell. `tr` may be a direct child of <table>, or a child of a direct
|
|
30
42
|
// <thead>/<tbody>/<tfoot> child. Collecting via `.children()` at each level
|
|
@@ -68,19 +80,14 @@ function scoreTable(tableSel) {
|
|
|
68
80
|
const consistencyRatio = numDataRows > 0 ? numConsistent / numDataRows : 0;
|
|
69
81
|
return numHeaderCells * 2 + (hasTh ? 5 : 0) + numDataRows * 3 + consistencyRatio * 10;
|
|
70
82
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
const headers = [];
|
|
77
|
-
for (let i = 0; i < headerCells.length; i++) {
|
|
78
|
-
headers.push(normalizeCell(headerCells.eq(i).text()));
|
|
79
|
-
}
|
|
80
|
-
const dataRowsSel = rows.slice(1);
|
|
83
|
+
// Read the data rows (everything after the header row) of one real <table>,
|
|
84
|
+
// aligning each cell against `headers` (for the repeated-in-cell-label strip)
|
|
85
|
+
// and removing `ignore` descendants first. Shared by readTable and combine
|
|
86
|
+
// mode so both handle ragged/label/responsive rows identically.
|
|
87
|
+
function readTableDataRows(rows, headers, ignore, startIndex) {
|
|
81
88
|
const dataRows = [];
|
|
82
|
-
for (let i =
|
|
83
|
-
const rowSel =
|
|
89
|
+
for (let i = startIndex; i < rows.length; i++) {
|
|
90
|
+
const rowSel = rows.eq(i);
|
|
84
91
|
// A genuine data row has at least one <td>; a pure-<th> row (a section
|
|
85
92
|
// divider spanning the table) is skipped — prototype behaviour. But the
|
|
86
93
|
// cells themselves are then read as `th, td`, not `td` alone, so a
|
|
@@ -92,7 +99,7 @@ function readTable(tableSel) {
|
|
|
92
99
|
const cellEls = rowSel.children('th, td');
|
|
93
100
|
const cells = [];
|
|
94
101
|
for (let j = 0; j < cellEls.length; j++) {
|
|
95
|
-
let text =
|
|
102
|
+
let text = readCellText(cellEls.eq(j), ignore);
|
|
96
103
|
// Responsive-table pattern: some IR pages repeat the column header
|
|
97
104
|
// INSIDE each cell as a (visually-hidden on desktop) label, e.g. NAB's
|
|
98
105
|
// "<div>Payment date</div><div>2 July 2026</div>", whose .text()
|
|
@@ -108,21 +115,80 @@ function readTable(tableSel) {
|
|
|
108
115
|
}
|
|
109
116
|
dataRows.push(cells);
|
|
110
117
|
}
|
|
111
|
-
return
|
|
118
|
+
return dataRows;
|
|
119
|
+
}
|
|
120
|
+
function readTable(tableSel, ignore) {
|
|
121
|
+
const rows = tableRows(tableSel);
|
|
122
|
+
if (rows.length === 0)
|
|
123
|
+
return { headers: [], rows: [] };
|
|
124
|
+
const headerCells = rows.eq(0).children('th, td');
|
|
125
|
+
const headers = [];
|
|
126
|
+
for (let i = 0; i < headerCells.length; i++) {
|
|
127
|
+
headers.push(readCellText(headerCells.eq(i), ignore));
|
|
128
|
+
}
|
|
129
|
+
return { headers, rows: readTableDataRows(rows, headers, ignore, 1) };
|
|
130
|
+
}
|
|
131
|
+
// True when a row is a "section-label" row: exactly one non-empty cell, and
|
|
132
|
+
// it's the first — e.g. a bare "2025" year divider between per-year tables.
|
|
133
|
+
function isLabelRow(cells) {
|
|
134
|
+
if (cells.length === 0 || cells[0].trim() === '')
|
|
135
|
+
return false;
|
|
136
|
+
for (let i = 1; i < cells.length; i++) {
|
|
137
|
+
if (cells[i].trim() !== '')
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
// Combine mode — a header-only table plus its per-section sibling tables.
|
|
143
|
+
function readCombined($, combine, ignore) {
|
|
144
|
+
const headerTable = $(combine.headerSelector).first();
|
|
145
|
+
if (headerTable.length === 0) {
|
|
146
|
+
return { headers: [], rows: [], warning: `The selector "${combine.headerSelector}" matched no element on the page.` };
|
|
147
|
+
}
|
|
148
|
+
const headerRows = tableRows(headerTable);
|
|
149
|
+
if (headerRows.length === 0)
|
|
150
|
+
return { headers: [], rows: [], warning: NO_TABLE_WARNING };
|
|
151
|
+
const headerCells = headerRows.eq(0).children('th, td');
|
|
152
|
+
const headers = [];
|
|
153
|
+
for (let i = 0; i < headerCells.length; i++)
|
|
154
|
+
headers.push(readCellText(headerCells.eq(i), ignore));
|
|
155
|
+
const allRows = [];
|
|
156
|
+
// Any data rows in the header table itself (a header-only table has none).
|
|
157
|
+
allRows.push(...readTableDataRows(headerRows, headers, ignore, 1));
|
|
158
|
+
// Following-sibling tables that share the header's column count; stop at the
|
|
159
|
+
// first sibling that doesn't (or a non-table sibling) so an unrelated later
|
|
160
|
+
// table with a coincidentally-equal width can't leak in.
|
|
161
|
+
const siblings = headerTable.nextAll('table');
|
|
162
|
+
for (let i = 0; i < siblings.length; i++) {
|
|
163
|
+
const sib = siblings.eq(i);
|
|
164
|
+
const sibRows = tableRows(sib);
|
|
165
|
+
if (sibRows.length === 0)
|
|
166
|
+
break;
|
|
167
|
+
const firstRowCols = sibRows.eq(0).children('th, td').length;
|
|
168
|
+
if (firstRowCols !== headers.length)
|
|
169
|
+
break;
|
|
170
|
+
// Every row of a section table is data here (its first row is a section
|
|
171
|
+
// label, NOT a repeated header) — read from index 0.
|
|
172
|
+
allRows.push(...readTableDataRows(sibRows, headers, ignore, 0));
|
|
173
|
+
}
|
|
174
|
+
const rows = combine.dropLabelRows ? allRows.filter((cells) => !isLabelRow(cells)) : allRows;
|
|
175
|
+
if (headers.length === 0 && rows.length === 0)
|
|
176
|
+
return { headers, rows, warning: NO_TABLE_WARNING };
|
|
177
|
+
return { headers, rows };
|
|
112
178
|
}
|
|
113
179
|
// Read one cell's normalized text list from a row selection, using a cell
|
|
114
180
|
// sub-selector (div-grid mode — see GridSelector).
|
|
115
|
-
function gridRowCells(rowSel, cellSelector) {
|
|
181
|
+
function gridRowCells(rowSel, cellSelector, ignore) {
|
|
116
182
|
const cellEls = rowSel.find(cellSelector);
|
|
117
183
|
const cells = [];
|
|
118
184
|
for (let j = 0; j < cellEls.length; j++)
|
|
119
|
-
cells.push(
|
|
185
|
+
cells.push(readCellText(cellEls.eq(j), ignore));
|
|
120
186
|
return cells;
|
|
121
187
|
}
|
|
122
188
|
// Read a single div-grid wrapper into the same {headers, rows} shape a real
|
|
123
189
|
// <table> yields, so every downstream stage (mapColumns/mapRows) is oblivious
|
|
124
190
|
// to which markup the data came from.
|
|
125
|
-
function readGridContainer(container, grid) {
|
|
191
|
+
function readGridContainer(container, grid, ignore) {
|
|
126
192
|
const allRows = container.find(grid.row);
|
|
127
193
|
if (allRows.length === 0)
|
|
128
194
|
return { headers: [], rows: [] };
|
|
@@ -131,10 +197,10 @@ function readGridContainer(container, grid) {
|
|
|
131
197
|
if (grid.headerRow != null) {
|
|
132
198
|
const headerRows = allRows.filter(grid.headerRow);
|
|
133
199
|
if (headerRows.length > 0)
|
|
134
|
-
headers = gridRowCells(headerRows.eq(0), grid.cell);
|
|
200
|
+
headers = gridRowCells(headerRows.eq(0), grid.cell, ignore);
|
|
135
201
|
}
|
|
136
202
|
if (headers.length === 0) {
|
|
137
|
-
headers = gridRowCells(allRows.eq(0), grid.cell);
|
|
203
|
+
headers = gridRowCells(allRows.eq(0), grid.cell, ignore);
|
|
138
204
|
firstRowIsHeader = true;
|
|
139
205
|
}
|
|
140
206
|
const dataRows = [];
|
|
@@ -144,14 +210,14 @@ function readGridContainer(container, grid) {
|
|
|
144
210
|
continue; // never treat a header row as data
|
|
145
211
|
if (grid.headerRow == null && firstRowIsHeader && i === 0)
|
|
146
212
|
continue; // first row already consumed as header
|
|
147
|
-
const cells = gridRowCells(rowSel, grid.cell);
|
|
213
|
+
const cells = gridRowCells(rowSel, grid.cell, ignore);
|
|
148
214
|
if (cells.length === 0)
|
|
149
215
|
continue;
|
|
150
216
|
dataRows.push(cells);
|
|
151
217
|
}
|
|
152
218
|
return { headers, rows: dataRows };
|
|
153
219
|
}
|
|
154
|
-
function readGrid($, grid) {
|
|
220
|
+
function readGrid($, grid, ignore) {
|
|
155
221
|
const containers = grid.container != null ? $(grid.container) : $.root();
|
|
156
222
|
if (grid.container != null && containers.length === 0) {
|
|
157
223
|
return { headers: [], rows: [], warning: `The selector "${grid.container}" matched no element on the page.` };
|
|
@@ -161,7 +227,7 @@ function readGrid($, grid) {
|
|
|
161
227
|
// pickBestTable uses for real tables). Ties keep the first encountered.
|
|
162
228
|
let best = null;
|
|
163
229
|
for (let i = 0; i < containers.length; i++) {
|
|
164
|
-
const parsed = readGridContainer(containers.eq(i), grid);
|
|
230
|
+
const parsed = readGridContainer(containers.eq(i), grid, ignore);
|
|
165
231
|
if (best === null || parsed.rows.length > best.rows.length)
|
|
166
232
|
best = parsed;
|
|
167
233
|
}
|
|
@@ -190,8 +256,12 @@ function pickBestTable($) {
|
|
|
190
256
|
}
|
|
191
257
|
export function extractTable(html, opts) {
|
|
192
258
|
const $ = load(html);
|
|
259
|
+
const ignore = opts?.cellIgnoreSelectors;
|
|
193
260
|
if (opts?.grid != null) {
|
|
194
|
-
return readGrid($, opts.grid);
|
|
261
|
+
return readGrid($, opts.grid, ignore);
|
|
262
|
+
}
|
|
263
|
+
if (opts?.combine != null) {
|
|
264
|
+
return readCombined($, opts.combine, ignore);
|
|
195
265
|
}
|
|
196
266
|
if (opts?.select != null) {
|
|
197
267
|
const matched = $(opts.select);
|
|
@@ -202,7 +272,7 @@ export function extractTable(html, opts) {
|
|
|
202
272
|
warning: `The selector "${opts.select}" matched no element on the page.`,
|
|
203
273
|
};
|
|
204
274
|
}
|
|
205
|
-
const { headers, rows } = readTable(matched.eq(0));
|
|
275
|
+
const { headers, rows } = readTable(matched.eq(0), ignore);
|
|
206
276
|
if (headers.length === 0 && rows.length === 0) {
|
|
207
277
|
return { headers, rows, warning: NO_TABLE_WARNING };
|
|
208
278
|
}
|
|
@@ -212,5 +282,5 @@ export function extractTable(html, opts) {
|
|
|
212
282
|
if (best === null) {
|
|
213
283
|
return { headers: [], rows: [], warning: NO_TABLE_WARNING };
|
|
214
284
|
}
|
|
215
|
-
return readTable(best);
|
|
285
|
+
return readTable(best, ignore);
|
|
216
286
|
}
|
package/dist/parse/map_rows.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"map_rows.d.ts","sourceRoot":"","sources":["../../src/parse/map_rows.ts"],"names":[],"mappings":"AA2BA,OAAO,KAAK,EAAQ,cAAc,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAI7E,MAAM,WAAW,cAAc;IAC7B,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;IAMvB,kBAAkB,CAAC,EAAE,OAAO,CAAC;
|
|
1
|
+
{"version":3,"file":"map_rows.d.ts","sourceRoot":"","sources":["../../src/parse/map_rows.ts"],"names":[],"mappings":"AA2BA,OAAO,KAAK,EAAQ,cAAc,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAI7E,MAAM,WAAW,cAAc;IAC7B,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;IAMvB,kBAAkB,CAAC,EAAE,OAAO,CAAC;IAM7B,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAqBD,wBAAgB,OAAO,CACrB,KAAK,EAAE;IAAE,OAAO,EAAE,MAAM,EAAE,CAAC;IAAC,IAAI,EAAE,MAAM,EAAE,EAAE,CAAA;CAAE,EAC9C,GAAG,EAAE,SAAS,EACd,QAAQ,EAAE,cAAc,EACxB,IAAI,CAAC,EAAE,cAAc,GACpB,SAAS,EAAE,CA0Bb"}
|
package/dist/parse/map_rows.js
CHANGED
|
@@ -26,13 +26,14 @@
|
|
|
26
26
|
// the exact prior behavior — this is purely additive.
|
|
27
27
|
import { parseNumber } from './parse_number.js';
|
|
28
28
|
import { parseDate } from './parse_date.js';
|
|
29
|
-
function cellValue(type, raw, dateFormats, dateExtractLeading) {
|
|
29
|
+
function cellValue(type, raw, dateFormats, dateExtractLeading, dateYearPivot) {
|
|
30
30
|
if (type === 'number')
|
|
31
31
|
return parseNumber(raw);
|
|
32
32
|
if (type === 'date') {
|
|
33
33
|
return parseDate(raw, {
|
|
34
34
|
...(dateFormats ? { formats: dateFormats } : {}),
|
|
35
35
|
...(dateExtractLeading ? { extractLeading: true } : {}),
|
|
36
|
+
...(dateYearPivot != null ? { yearPivot: dateYearPivot } : {}),
|
|
36
37
|
});
|
|
37
38
|
}
|
|
38
39
|
const trimmed = raw.trim();
|
|
@@ -42,6 +43,7 @@ export function mapRows(table, map, keywords, opts) {
|
|
|
42
43
|
const keys = Object.keys(map.matched);
|
|
43
44
|
const dateFormats = opts?.dateFormats;
|
|
44
45
|
const dateExtractLeading = opts?.dateExtractLeading;
|
|
46
|
+
const dateYearPivot = opts?.dateYearPivot;
|
|
45
47
|
return table.rows.map((row) => {
|
|
46
48
|
const mappedRow = {};
|
|
47
49
|
for (const key of keys) {
|
|
@@ -53,7 +55,7 @@ export function mapRows(table, map, keywords, opts) {
|
|
|
53
55
|
header: candidate.header,
|
|
54
56
|
index: candidate.index,
|
|
55
57
|
raw,
|
|
56
|
-
value: cellValue(type, raw, dateFormats, dateExtractLeading),
|
|
58
|
+
value: cellValue(type, raw, dateFormats, dateExtractLeading, dateYearPivot),
|
|
57
59
|
};
|
|
58
60
|
});
|
|
59
61
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"parse_date.d.ts","sourceRoot":"","sources":["../../src/parse/parse_date.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"parse_date.d.ts","sourceRoot":"","sources":["../../src/parse/parse_date.ts"],"names":[],"mappings":"AA6GA,wBAAgB,SAAS,CACvB,GAAG,EAAE,MAAM,EACX,IAAI,CAAC,EAAE;IAAE,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;IAAC,cAAc,CAAC,EAAE,OAAO,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAAE,GAC1E,MAAM,GAAG,IAAI,CAoDf"}
|
package/dist/parse/parse_date.js
CHANGED
|
@@ -57,6 +57,29 @@ function monthFromName(name) {
|
|
|
57
57
|
const key = name.toLowerCase().slice(0, 3);
|
|
58
58
|
return MONTHS[key] ?? null;
|
|
59
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* Resolve a year token that may be 2- or 4-digit into a full year.
|
|
62
|
+
*
|
|
63
|
+
* 4-digit years are returned as-is. A 2-digit year is a GUESS (which century?)
|
|
64
|
+
* — so, true to the engine's "never guess" contract, it is only accepted when
|
|
65
|
+
* the caller opts in with `yearPivot`, and returns null otherwise. When opted
|
|
66
|
+
* in, `yearPivot` is the base of a sliding 100-year window: the 2-digit value
|
|
67
|
+
* maps to the single year in `[yearPivot, yearPivot + 99]`. E.g. `yearPivot:
|
|
68
|
+
* 2000` reads "26" -> 2026 and "99" -> 2099; `yearPivot: 1950` reads "26" ->
|
|
69
|
+
* 2026 and "99" -> 1999. A 3-digit token is treated as a literal year (padded
|
|
70
|
+
* on output), matching prior 4-digit-only strictness closely enough for the
|
|
71
|
+
* rare case.
|
|
72
|
+
*/
|
|
73
|
+
function resolveYear(raw, yearPivot) {
|
|
74
|
+
if (raw.length >= 3)
|
|
75
|
+
return Number(raw);
|
|
76
|
+
if (yearPivot == null)
|
|
77
|
+
return null; // 2-digit year without opt-in — never guess
|
|
78
|
+
const yy = Number(raw);
|
|
79
|
+
const base = yearPivot - (yearPivot % 100);
|
|
80
|
+
const candidate = base + yy;
|
|
81
|
+
return candidate < yearPivot ? candidate + 100 : candidate;
|
|
82
|
+
}
|
|
60
83
|
function pickSlashHint(formats) {
|
|
61
84
|
if (!formats)
|
|
62
85
|
return undefined;
|
|
@@ -90,6 +113,7 @@ export function parseDate(raw, opts) {
|
|
|
90
113
|
const s = String(raw).trim();
|
|
91
114
|
if (s === '')
|
|
92
115
|
return null;
|
|
116
|
+
const yearPivot = opts?.yearPivot;
|
|
93
117
|
// End-of-token anchor. Strict mode (default) anchors to end-of-string, so
|
|
94
118
|
// any trailing text fails the match. `extractLeading` relaxes that to "no
|
|
95
119
|
// further word char or slash follows the date" — enough to end the date
|
|
@@ -97,26 +121,34 @@ export function parseDate(raw, opts) {
|
|
|
97
121
|
// without letting a partial number ("2024" out of "20245") slip through.
|
|
98
122
|
const tail = opts?.extractLeading ? '(?![\\w/])' : '$';
|
|
99
123
|
const re = (body) => new RegExp(`^${body}${tail}`);
|
|
100
|
-
// ISO: yyyy-mm-dd
|
|
124
|
+
// ISO: yyyy-mm-dd (always 4-digit year — never abbreviated in ISO form).
|
|
101
125
|
let m = s.match(re('(\\d{4})-(\\d{2})-(\\d{2})'));
|
|
102
126
|
if (m) {
|
|
103
127
|
return toIsoIfValid(Number(m[1]), Number(m[2]), Number(m[3]));
|
|
104
128
|
}
|
|
105
|
-
// "D Mon YYYY" / "D Month YYYY", e.g. "5 Mar 2026", "18 November 2025"
|
|
106
|
-
|
|
129
|
+
// "D Mon YYYY" / "D Month YYYY", e.g. "5 Mar 2026", "18 November 2025".
|
|
130
|
+
// The year is 2–4 digits; a 2-digit year resolves only under `yearPivot`
|
|
131
|
+
// (see resolveYear), otherwise it stays null — preserving strict default.
|
|
132
|
+
m = s.match(re('(\\d{1,2})\\s+([A-Za-z]{3,})\\s+(\\d{2,4})'));
|
|
107
133
|
if (m) {
|
|
108
134
|
const month = monthFromName(m[2]);
|
|
109
135
|
if (month == null)
|
|
110
136
|
return null;
|
|
111
|
-
|
|
137
|
+
const year = resolveYear(m[3], yearPivot);
|
|
138
|
+
if (year == null)
|
|
139
|
+
return null;
|
|
140
|
+
return toIsoIfValid(year, month, Number(m[1]));
|
|
112
141
|
}
|
|
113
142
|
// "Mon D, YYYY" / "Month D YYYY", comma optional, e.g. "Mar 5, 2026"
|
|
114
|
-
m = s.match(re('([A-Za-z]{3,})\\s+(\\d{1,2}),?\\s+(\\d{4})'));
|
|
143
|
+
m = s.match(re('([A-Za-z]{3,})\\s+(\\d{1,2}),?\\s+(\\d{2,4})'));
|
|
115
144
|
if (m) {
|
|
116
145
|
const month = monthFromName(m[1]);
|
|
117
146
|
if (month == null)
|
|
118
147
|
return null;
|
|
119
|
-
|
|
148
|
+
const year = resolveYear(m[3], yearPivot);
|
|
149
|
+
if (year == null)
|
|
150
|
+
return null;
|
|
151
|
+
return toIsoIfValid(year, month, Number(m[2]));
|
|
120
152
|
}
|
|
121
153
|
// Slash dates: D/M/Y or M/D/Y — ambiguous without a hint or a proof by
|
|
122
154
|
// calendar impossibility (see resolveSlashDate). Two-digit years are
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"parse_number.d.ts","sourceRoot":"","sources":["../../src/parse/parse_number.ts"],"names":[],"mappings":"AAaA,wBAAgB,WAAW,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,
|
|
1
|
+
{"version":3,"file":"parse_number.d.ts","sourceRoot":"","sources":["../../src/parse/parse_number.ts"],"names":[],"mappings":"AAaA,wBAAgB,WAAW,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CA8CtD"}
|
|
@@ -29,6 +29,22 @@ export function parseNumber(raw) {
|
|
|
29
29
|
// header's no-cents-conversion note). Word-boundaried so it never touches
|
|
30
30
|
// the digits or an unrelated word like "recent".
|
|
31
31
|
s = s.replace(/\bcents?\b/gi, '');
|
|
32
|
+
// Strip a trailing per-unit annotation — "$2.05/share", "1.2/unit". The
|
|
33
|
+
// slash-plus-word denominator states the amount's unit basis (per share, per
|
|
34
|
+
// security); it's not part of the number. Letters required after the slash,
|
|
35
|
+
// so a bare fraction like "3/4" is left untouched (and then fails to parse,
|
|
36
|
+
// as before). Runs before the bare-cent strip so "2c/share"-style oddities
|
|
37
|
+
// reduce cleanly.
|
|
38
|
+
s = s.replace(/\/[A-Za-z]+\.?$/, '');
|
|
39
|
+
s = s.trim();
|
|
40
|
+
// Strip a trailing bare cent marker — "45c" / "14.5 c" / "45¢". This is the
|
|
41
|
+
// symbol/abbreviation form of the "cents" word stripped above (many AU/UK IR
|
|
42
|
+
// tables write "45c" rather than "45 cents"), so it's tokenized the same way:
|
|
43
|
+
// the SCALING to dollars stays the caller's job (amount_unit). Anchored to a
|
|
44
|
+
// digit immediately before it and to end-of-string, so it only ever removes a
|
|
45
|
+
// genuine trailing unit — never a 'c' inside a word ("recent" ends in 't', and
|
|
46
|
+
// "abc" has no leading digit) and never a mid-string letter.
|
|
47
|
+
s = s.replace(/(\d)\s*[c¢]$/i, '$1');
|
|
32
48
|
s = s.trim();
|
|
33
49
|
if (s === '')
|
|
34
50
|
return null;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ExtractTableResult } from '../parse/extract_table.js';
|
|
2
|
+
export type ExtractTablePdfOptions = {
|
|
3
|
+
headerLabels?: string[];
|
|
4
|
+
headerRows?: number;
|
|
5
|
+
columnBands?: number[];
|
|
6
|
+
mergeContinuationRows?: boolean;
|
|
7
|
+
rowTolerance?: number;
|
|
8
|
+
maxPages?: number;
|
|
9
|
+
};
|
|
10
|
+
export declare function extractTableFromPdf(data: Uint8Array, opts?: ExtractTablePdfOptions): Promise<ExtractTableResult>;
|
|
11
|
+
//# sourceMappingURL=extract_table_pdf.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"extract_table_pdf.d.ts","sourceRoot":"","sources":["../../src/pdf/extract_table_pdf.ts"],"names":[],"mappings":"AAWA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,2BAA2B,CAAC;AAEpE,MAAM,MAAM,sBAAsB,GAAG;IACnC,YAAY,CAAC,EAAE,MAAM,EAAE,CAAC;IACxB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;IACvB,qBAAqB,CAAC,EAAE,OAAO,CAAC;IAChC,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB,CAAC;AA4EF,wBAAsB,mBAAmB,CACvC,IAAI,EAAE,UAAU,EAChB,IAAI,GAAE,sBAA2B,GAChC,OAAO,CAAC,kBAAkB,CAAC,CAwD7B"}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// hazo_scrape/src/pdf/extract_table_pdf.ts — deterministic PDF text-layer → table.
|
|
2
|
+
//
|
|
3
|
+
// Reconstructs a {headers, rows} grid from a PDF's positioned text items by
|
|
4
|
+
// clustering items into visual lines (by y) and columns (by x-band), mirroring
|
|
5
|
+
// the ExtractTableResult shape of extract_table.ts so a caller's
|
|
6
|
+
// mapColumns/mapRows pipeline consumes HTML and PDF tables identically.
|
|
7
|
+
//
|
|
8
|
+
// SERVER-ONLY: pulls in pdfjs-dist. Exported from the package root ('.'),
|
|
9
|
+
// never from the client-safe './parse' entry.
|
|
10
|
+
import { getDocument } from 'pdfjs-dist/legacy/build/pdf.mjs';
|
|
11
|
+
/** Cluster items into visual lines by y within `tol`, ordered top→bottom. */
|
|
12
|
+
function clusterLines(items, tol) {
|
|
13
|
+
const sorted = [...items].sort((a, b) => b.y - a.y);
|
|
14
|
+
const lines = [];
|
|
15
|
+
for (const it of sorted) {
|
|
16
|
+
const line = lines.find((l) => Math.abs(l.y - it.y) <= tol);
|
|
17
|
+
if (line)
|
|
18
|
+
line.items.push(it);
|
|
19
|
+
else
|
|
20
|
+
lines.push({ y: it.y, items: [it] });
|
|
21
|
+
}
|
|
22
|
+
return lines;
|
|
23
|
+
}
|
|
24
|
+
/** Auto-detect column left-edges by cutting the sorted x-starts at the widest gaps. */
|
|
25
|
+
function detectBands(items) {
|
|
26
|
+
const xs = [...new Set(items.map((i) => Math.round(i.x)))].sort((a, b) => a - b);
|
|
27
|
+
if (xs.length === 0)
|
|
28
|
+
return [0];
|
|
29
|
+
const bands = [xs[0]];
|
|
30
|
+
for (let i = 1; i < xs.length; i++) {
|
|
31
|
+
if (xs[i] - xs[i - 1] > 25)
|
|
32
|
+
bands.push(xs[i]); // 25px gap = new column
|
|
33
|
+
}
|
|
34
|
+
return bands;
|
|
35
|
+
}
|
|
36
|
+
/** Assign x to the last band whose left-edge is <= x. */
|
|
37
|
+
function bandIndex(bands, x) {
|
|
38
|
+
let idx = 0;
|
|
39
|
+
for (let i = 0; i < bands.length; i++)
|
|
40
|
+
if (x + 3 >= bands[i])
|
|
41
|
+
idx = i;
|
|
42
|
+
return idx;
|
|
43
|
+
}
|
|
44
|
+
// A word the PDF split across two text runs reports the second run beginning
|
|
45
|
+
// (within GLUE_PX) exactly where the first ended, with NO whitespace at either
|
|
46
|
+
// run's boundary — e.g. "Septemb"|"er". A real inter-word space renders a
|
|
47
|
+
// visible gap (and/or a boundary space in the run text). GLUE_PX is the max
|
|
48
|
+
// horizontal gap still considered "same word".
|
|
49
|
+
const GLUE_PX = 1;
|
|
50
|
+
/**
|
|
51
|
+
* Join a band's items (sorted by x) into one cell string. Consecutive items
|
|
52
|
+
* that are horizontally CONTIGUOUS and carry no whitespace at their shared
|
|
53
|
+
* boundary are glued with no space (they are pieces of one word split across
|
|
54
|
+
* text runs); otherwise a single space separates them. Without this, a
|
|
55
|
+
* mid-word split surfaces as "Septemb er 2020" and fails downstream date/number
|
|
56
|
+
* parsing.
|
|
57
|
+
*/
|
|
58
|
+
function joinCellItems(items) {
|
|
59
|
+
let out = '';
|
|
60
|
+
let prev = null;
|
|
61
|
+
for (const it of items) {
|
|
62
|
+
if (!it.str.trim())
|
|
63
|
+
continue;
|
|
64
|
+
if (prev != null) {
|
|
65
|
+
const contiguous = it.x - (prev.x + prev.w) < GLUE_PX;
|
|
66
|
+
const boundarySpace = /\s$/.test(prev.str) || /^\s/.test(it.str);
|
|
67
|
+
out += contiguous && !boundarySpace ? '' : ' ';
|
|
68
|
+
}
|
|
69
|
+
out += it.str.trim();
|
|
70
|
+
prev = it;
|
|
71
|
+
}
|
|
72
|
+
return out.replace(/\s+/g, ' ').trim();
|
|
73
|
+
}
|
|
74
|
+
/** Concatenate a line's items into one cell per band (items sorted by x). */
|
|
75
|
+
function lineToCells(line, bands) {
|
|
76
|
+
const cells = bands.map(() => []);
|
|
77
|
+
for (const it of [...line.items].sort((a, b) => a.x - b.x)) {
|
|
78
|
+
if (!it.str.trim())
|
|
79
|
+
continue;
|
|
80
|
+
cells[bandIndex(bands, it.x)].push(it);
|
|
81
|
+
}
|
|
82
|
+
return cells.map(joinCellItems);
|
|
83
|
+
}
|
|
84
|
+
export async function extractTableFromPdf(data, opts = {}) {
|
|
85
|
+
const tol = opts.rowTolerance ?? 3;
|
|
86
|
+
const doc = await getDocument({ data, isEvalSupported: false, useSystemFonts: false }).promise;
|
|
87
|
+
const pageCount = opts.maxPages ? Math.min(doc.numPages, opts.maxPages) : doc.numPages;
|
|
88
|
+
const pages = [];
|
|
89
|
+
for (let p = 1; p <= pageCount; p++) {
|
|
90
|
+
const page = await doc.getPage(p);
|
|
91
|
+
const content = await page.getTextContent();
|
|
92
|
+
const items = [];
|
|
93
|
+
for (const it of content.items) {
|
|
94
|
+
if (typeof it.str !== 'string' || !it.str.trim())
|
|
95
|
+
continue;
|
|
96
|
+
items.push({ x: it.transform[4], y: it.transform[5], w: it.width ?? 0, str: it.str });
|
|
97
|
+
}
|
|
98
|
+
pages.push(items);
|
|
99
|
+
}
|
|
100
|
+
// Column geometry is page-independent, so detect bands from every item; but
|
|
101
|
+
// cluster visual lines PER PAGE. Text-item y-coordinates repeat page to page
|
|
102
|
+
// (row N sits at the same y on every page), so clustering the concatenation
|
|
103
|
+
// would merge each page's row N into a single garbled line. Page order is
|
|
104
|
+
// preserved, and a single-page document behaves exactly as before.
|
|
105
|
+
const bands = opts.columnBands ?? detectBands(pages.flat());
|
|
106
|
+
let grid = [];
|
|
107
|
+
for (const items of pages) {
|
|
108
|
+
for (const line of clusterLines(items, tol))
|
|
109
|
+
grid.push(lineToCells(line, bands));
|
|
110
|
+
}
|
|
111
|
+
// Continuation merge: a line whose leftmost cell is empty belongs to the row above.
|
|
112
|
+
if (opts.mergeContinuationRows) {
|
|
113
|
+
const merged = [];
|
|
114
|
+
for (const row of grid) {
|
|
115
|
+
if (merged.length > 0 && row[0].trim() === '') {
|
|
116
|
+
const prev = merged[merged.length - 1];
|
|
117
|
+
for (let i = 0; i < row.length; i++) {
|
|
118
|
+
if (row[i].trim())
|
|
119
|
+
prev[i] = [prev[i], row[i]].filter(Boolean).join(' ').trim();
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
merged.push([...row]);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
grid = merged;
|
|
127
|
+
}
|
|
128
|
+
let headers;
|
|
129
|
+
let rows;
|
|
130
|
+
if (opts.headerLabels) {
|
|
131
|
+
headers = opts.headerLabels;
|
|
132
|
+
rows = grid;
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
const n = Math.max(1, opts.headerRows ?? 1);
|
|
136
|
+
const headerLines = grid.slice(0, n);
|
|
137
|
+
headers = bands.map((_, c) => headerLines.map((l) => l[c] ?? '').join(' ').replace(/\s+/g, ' ').trim());
|
|
138
|
+
rows = grid.slice(n);
|
|
139
|
+
}
|
|
140
|
+
return { headers, rows };
|
|
141
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/pdf/index.ts"],"names":[],"mappings":"AACA,cAAc,wBAAwB,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hazo_scrape",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.0",
|
|
4
4
|
"description": "Generic source-agnostic web scraping engine with a network-free parse core.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"module": "./dist/index.js",
|
|
@@ -62,6 +62,7 @@
|
|
|
62
62
|
"cheerio": "^1.2.0",
|
|
63
63
|
"hazo_config": "^2.4.1",
|
|
64
64
|
"hazo_secure": "^1.4.0",
|
|
65
|
+
"pdfjs-dist": "^4.10.38",
|
|
65
66
|
"robots-parser": "^3.0.1",
|
|
66
67
|
"undici": "^7.28.0"
|
|
67
68
|
}
|