hazo_scrape 1.0.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/CHANGE_LOG.md ADDED
@@ -0,0 +1,5 @@
1
+ # hazo_scrape — Change Log
2
+
3
+ ## 1.0.0 — 2026-07-17
4
+
5
+ - Initial release.
package/README.md ADDED
@@ -0,0 +1,145 @@
1
+ # hazo_scrape
2
+
3
+ Generic source-agnostic web scraping engine with a network-free parse core.
4
+
5
+ `hazo_scrape/parse` maps HTML/IR table columns to logical fields by matching
6
+ **header text**, not column position — so it survives a source reordering or
7
+ adding columns. It has no network dependency (no fetch, no crawler) and no
8
+ domain knowledge (no currency, no franking rules): you hand it HTML you
9
+ already fetched plus a keyword map describing the fields you want, and it
10
+ tells you which header(s) matched each field, flags anything ambiguous, and
11
+ parses each matched cell's raw text into a number, an ISO date, or trimmed
12
+ text. Because it's pure and network-free, it's fully unit-testable against
13
+ static HTML fixtures.
14
+
15
+ ## Installation
16
+
17
+ ```bash
18
+ npm install hazo_scrape
19
+ ```
20
+
21
+ ## Quick start
22
+
23
+ ```ts
24
+ import { extractTable, mapColumns, mapRows, type ColumnKeywords } from 'hazo_scrape/parse';
25
+
26
+ const html = `
27
+ <table>
28
+ <tr><th>Ex-Dividend Date</th><th>Amount</th><th>Franking</th></tr>
29
+ <tr><td>5 Mar 2026</td><td>$0.45</td><td>100%</td></tr>
30
+ </table>
31
+ `;
32
+
33
+ // 1. Pull the best-guess data table out of the page (or pass { select } to
34
+ // target a specific element).
35
+ const table = extractTable(html); // { headers, rows, warning? }
36
+
37
+ // 2. Declare the fields you care about and how to recognise their header.
38
+ const keywords: ColumnKeywords = {
39
+ exDate: { keywords: ['ex-dividend', 'ex date'], type: 'date' },
40
+ amount: { keywords: ['amount', 'dividend'], type: 'number' },
41
+ franking: { keywords: ['franking'], type: 'text' },
42
+ };
43
+
44
+ // 3. Map headers -> candidate columns. dateGuard keeps a "date"-looking
45
+ // header (e.g. "Ex-Dividend Date") from also matching a non-date key
46
+ // (e.g. "amount", even though the header contains "dividend").
47
+ const map = mapColumns(table.headers, keywords, { dateGuard: true, required: ['amount'] });
48
+
49
+ if (!map.ok) {
50
+ throw new Error(`Missing required column(s): ${map.unmatched.join(', ')}`);
51
+ }
52
+ if (map.ambiguous.length > 0) {
53
+ console.warn('Ambiguous columns, caller must disambiguate:', map.ambiguous);
54
+ }
55
+
56
+ // 4. Join the map back onto the row data and parse each matched cell.
57
+ const rows = mapRows(table, map, keywords);
58
+ console.log(rows[0].amount[0].value); // 0.45
59
+ console.log(rows[0].exDate[0].value); // "2026-03-05"
60
+ ```
61
+
62
+ ## API
63
+
64
+ ### `extractTable(html: string, opts?: { select?: string }): { headers: string[]; rows: string[][]; warning?: string }`
65
+
66
+ Parses network-free HTML and returns the best-guess data table as raw header
67
+ and row strings. Pass `opts.select` (a CSS selector) to target a specific
68
+ `<table>` instead of letting the built-in scoring heuristic pick one. A
69
+ `warning` is returned (never thrown) when no table-like element is found, or
70
+ when `select` matches nothing.
71
+
72
+ ### `mapColumns(headers: string[], keywords: ColumnKeywords, opts?: { dateGuard?: boolean; required?: string[] }): ColumnMap`
73
+
74
+ Matches each header against every key's `keywords` (case-insensitive
75
+ substring match) and returns **all** matching headers per key — never just
76
+ the first. `opts.dateGuard` drops date-looking headers from any key whose
77
+ `type !== 'date'`. `opts.required` lists keys that must have at least one
78
+ candidate for `map.ok` to be `true`.
79
+
80
+ ### `mapRows(table: { headers; rows }, map: ColumnMap, keywords: ColumnKeywords): MappedRow[]`
81
+
82
+ Joins a `ColumnMap` back onto `table.rows`, producing one `MappedRow` per data
83
+ row. Each matched key holds an array of `Cell`s (one per candidate header),
84
+ each parsed according to that key's declared `type`.
85
+
86
+ ### `parseNumber(raw: string): number | null`
87
+
88
+ Strips currency symbols, 3-letter currency codes, thousands-separator commas,
89
+ and `%`, then parses what remains as a number. Returns `null` when nothing
90
+ parseable is left. Does **not** convert cents to dollars — domain scaling is
91
+ the caller's job.
92
+
93
+ ### `parseDate(raw: string, opts?: { formats?: string[] }): string | null`
94
+
95
+ Parses a handful of common date text formats (ISO, `D Mon YYYY`, `Mon D,
96
+ YYYY`, and `D/M/Y` slash dates) into an ISO `yyyy-mm-dd` string. Returns
97
+ `null` — never a guess — for anything unrecognised or genuinely ambiguous
98
+ (e.g. `05/03/2026` with no `opts.formats` hint and both components `<= 12`).
99
+
100
+ ### Types
101
+
102
+ ```ts
103
+ type ColumnSpec = { keywords: string[]; type: 'number' | 'date' | 'text' };
104
+ type ColumnKeywords = Record<string, ColumnSpec>;
105
+ type Candidate = { header: string; index: number };
106
+ type ColumnMap = {
107
+ ok: boolean; // false when any required key has zero candidates
108
+ matched: Record<string, Candidate[]>; // every match, in header order, one entry per key
109
+ unmatched: string[]; // headers that matched no key at all
110
+ ambiguous: string[]; // keys with 2+ candidates — reported, never resolved
111
+ headers: string[];
112
+ };
113
+ type Cell = { header: string; index: number; raw: string; value: number | string | null };
114
+ type MappedRow = Record<string, Cell[]>;
115
+ ```
116
+
117
+ ## Design
118
+
119
+ - **One-to-many, never resolved.** Every header matching a key is returned as
120
+ a candidate; if a table has both "Dividend (USD)" and "Dividend (AUD)",
121
+ both show up under `amount` and the key is reported in `ambiguous`. The
122
+ engine will not guess which one is "right" — the caller (e.g. an
123
+ `/aud/i` rule for a specific stock) makes that call.
124
+ - **`dateGuard` is generic, not hardcoded.** Rather than special-casing
125
+ header text like "exDate", it uses each key's declared `type`: any header
126
+ that reads as a date is only eligible for keys typed `'date'`.
127
+ - **Required, not scored.** `mapColumns` doesn't guess at "good enough" —
128
+ the caller declares which keys are `required`, and `ok` reflects exactly
129
+ that.
130
+ - **Domain interpretation is the caller's job.** Cents-to-dollars scaling,
131
+ "Unfranked" -> `0`, currency selection, and similar business rules are
132
+ deliberately outside this engine. `parseNumber` and `parseDate` return the
133
+ literal parsed value (or `null`) and nothing else.
134
+
135
+ ## Tailwind v4 (`@source` required)
136
+
137
+ If this package renders UI, add the following to your app's CSS entry:
138
+
139
+ ```css
140
+ @source "../node_modules/hazo_scrape/dist";
141
+ ```
142
+
143
+ ## License
144
+
145
+ MIT
@@ -0,0 +1,17 @@
1
+ # hazo_scrape — Setup Checklist
2
+
3
+ Follow these steps when adding hazo_scrape to a consuming application.
4
+
5
+ ## 1. Install the package
6
+
7
+ ```bash
8
+ npm install hazo_scrape
9
+ ```
10
+
11
+ ## 2. Configure
12
+
13
+ <!-- TODO: list required env vars or config file entries -->
14
+
15
+ ## 3. Verify
16
+
17
+ <!-- TODO: add a smoke-test or health-check step -->
@@ -0,0 +1,2 @@
1
+ export * from './lib/index.js';
2
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAGA,cAAc,gBAAgB,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ // hazo_scrape/src/index.ts — Server entry point
2
+ // Import hazo_core via peer dep: import { HazoError } from 'hazo_core';
3
+ export * from './lib/index.js';
@@ -0,0 +1,2 @@
1
+ export * from '../parse/index.js';
2
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/lib/index.ts"],"names":[],"mappings":"AACA,cAAc,mBAAmB,CAAC"}
@@ -0,0 +1,2 @@
1
+ // hazo_scrape/src/lib/index.ts — Core library exports
2
+ export * from '../parse/index.js';
@@ -0,0 +1,10 @@
1
+ export type ExtractTableResult = {
2
+ headers: string[];
3
+ rows: string[][];
4
+ warning?: string;
5
+ };
6
+ export type ExtractTableOptions = {
7
+ select?: string;
8
+ };
9
+ export declare function extractTable(html: string, opts?: ExtractTableOptions): ExtractTableResult;
10
+ //# sourceMappingURL=extract_table.d.ts.map
@@ -0,0 +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,MAAM,MAAM,mBAAmB,GAAG;IAChC,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB,CAAC;AAwGF,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,mBAAmB,GAAG,kBAAkB,CAyBzF"}
@@ -0,0 +1,134 @@
1
+ // hazo_scrape/src/parse/extract_table.ts — pure, network-free HTML table extraction
2
+ //
3
+ // Port of the TABLE-LOCATION spirit of the divscraper prototype's
4
+ // findDividendTable/parseDividendTable (see dividends.mjs), but this engine is
5
+ // generic: it has NO access to domain column keywords (no headerMap), so it
6
+ // cannot score a table by "how many logical dividend columns did we match".
7
+ // Column meaning is decided later, by a caller with a keyword map (see
8
+ // map_columns.ts) — this module only decides which table looks like table
9
+ // DATA and what its raw cells say.
10
+ //
11
+ // TYPING NOTE: this file deliberately never imports a node type (e.g.
12
+ // `Element`) from the standalone `domhandler` package. The workspace root
13
+ // hoists an older `domhandler` than the one cheerio bundles internally, and
14
+ // naming that mismatched type here breaks structural assignability against
15
+ // cheerio's own `Cheerio<Element>` values. Instead every helper takes a
16
+ // `Cheerio<any>` selection and indexes with `.eq()` rather than unwrapping to
17
+ // a raw node, so TypeScript never has to compare two differently-sourced
18
+ // node types against each other.
19
+ import { load } from 'cheerio';
20
+ const NO_TABLE_WARNING = 'No table-like element found on the page (it may be JavaScript-rendered — a static HTML fetch will not see content injected by client-side JS).';
21
+ // Collapse all whitespace (including &nbsp; -> U+00A0, decoded by cheerio's
22
+ // .text()) into single spaces and trim. Real IR tables routinely pad cells
23
+ // with &nbsp; and embedded newlines; left alone these corrupt comparisons and
24
+ // downstream number/date parsing. JS's `\s` already matches U+00A0.
25
+ function normalizeCell(raw) {
26
+ return raw.replace(/\s+/g, ' ').trim();
27
+ }
28
+ // Rows that belong directly to THIS table — not to any table nested inside a
29
+ // cell. `tr` may be a direct child of <table>, or a child of a direct
30
+ // <thead>/<tbody>/<tfoot> child. Collecting via `.children()` at each level
31
+ // (rather than `.find('tr')`) prevents a nested table's rows leaking in.
32
+ // `.add()` merges and re-sorts in document order, so the header (first row)
33
+ // is always correct regardless of whether it lives in a <thead> or a bare
34
+ // leading <tr>.
35
+ function tableRows(tableSel) {
36
+ const direct = tableSel.children('tr');
37
+ const sectioned = tableSel.children('thead, tbody, tfoot').children('tr');
38
+ return direct.add(sectioned);
39
+ }
40
+ // Generic "table-ness" score — no domain keywords available here.
41
+ // +2 per header-ish cell (th/td) in the first row — wider tables read more like data grids
42
+ // +5 flat bonus if the header row uses <th> — semantic markup is a strong signal
43
+ // +3 per data row (rows after the header with >=1 <td>) — more rows = more likely the real data
44
+ // +10 * consistencyRatio, where consistencyRatio is the fraction of data
45
+ // rows whose cell count matches the header's cell count — a real data
46
+ // table has a stable column count; a layout/nav table used for markup
47
+ // rarely does.
48
+ // A table with zero rows scores 0 and is never selected over one with rows.
49
+ // Ties keep the first table encountered (strict `>` when updating best).
50
+ function scoreTable(tableSel) {
51
+ const rows = tableRows(tableSel);
52
+ if (rows.length === 0)
53
+ return 0;
54
+ const headerCells = rows.eq(0).children('th, td');
55
+ const numHeaderCells = headerCells.length;
56
+ const hasTh = headerCells.filter('th').length > 0;
57
+ const dataRowsSel = rows.slice(1);
58
+ let numDataRows = 0;
59
+ let numConsistent = 0;
60
+ for (let i = 0; i < dataRowsSel.length; i++) {
61
+ const tdCount = dataRowsSel.eq(i).children('td').length;
62
+ if (tdCount === 0)
63
+ continue;
64
+ numDataRows += 1;
65
+ if (tdCount === numHeaderCells)
66
+ numConsistent += 1;
67
+ }
68
+ const consistencyRatio = numDataRows > 0 ? numConsistent / numDataRows : 0;
69
+ return numHeaderCells * 2 + (hasTh ? 5 : 0) + numDataRows * 3 + consistencyRatio * 10;
70
+ }
71
+ function readTable(tableSel) {
72
+ const rows = tableRows(tableSel);
73
+ if (rows.length === 0)
74
+ return { headers: [], rows: [] };
75
+ const headerCells = rows.eq(0).children('th, td');
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);
81
+ const dataRows = [];
82
+ for (let i = 0; i < dataRowsSel.length; i++) {
83
+ const tds = dataRowsSel.eq(i).children('td');
84
+ if (tds.length === 0)
85
+ continue; // skip non-data rows (prototype behaviour)
86
+ const cells = [];
87
+ for (let j = 0; j < tds.length; j++) {
88
+ cells.push(normalizeCell(tds.eq(j).text()));
89
+ }
90
+ dataRows.push(cells);
91
+ }
92
+ return { headers, rows: dataRows };
93
+ }
94
+ function pickBestTable($) {
95
+ const tables = $('table');
96
+ let best = null;
97
+ let bestScore = -1;
98
+ for (let i = 0; i < tables.length; i++) {
99
+ const tableSel = tables.eq(i);
100
+ const rows = tableRows(tableSel);
101
+ if (rows.length === 0)
102
+ continue; // never selectable — no header even
103
+ const score = scoreTable(tableSel);
104
+ // Strict `>` only — ties keep the first table encountered.
105
+ if (best === null || score > bestScore) {
106
+ best = tableSel;
107
+ bestScore = score;
108
+ }
109
+ }
110
+ return best;
111
+ }
112
+ export function extractTable(html, opts) {
113
+ const $ = load(html);
114
+ if (opts?.select != null) {
115
+ const matched = $(opts.select);
116
+ if (matched.length === 0) {
117
+ return {
118
+ headers: [],
119
+ rows: [],
120
+ warning: `The selector "${opts.select}" matched no element on the page.`,
121
+ };
122
+ }
123
+ const { headers, rows } = readTable(matched.eq(0));
124
+ if (headers.length === 0 && rows.length === 0) {
125
+ return { headers, rows, warning: NO_TABLE_WARNING };
126
+ }
127
+ return { headers, rows };
128
+ }
129
+ const best = pickBestTable($);
130
+ if (best === null) {
131
+ return { headers: [], rows: [], warning: NO_TABLE_WARNING };
132
+ }
133
+ return readTable(best);
134
+ }
@@ -0,0 +1,7 @@
1
+ export * from './types.js';
2
+ export * from './parse_number.js';
3
+ export * from './parse_date.js';
4
+ export * from './extract_table.js';
5
+ export * from './map_columns.js';
6
+ export * from './map_rows.js';
7
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/parse/index.ts"],"names":[],"mappings":"AAEA,cAAc,YAAY,CAAC;AAC3B,cAAc,mBAAmB,CAAC;AAClC,cAAc,iBAAiB,CAAC;AAChC,cAAc,oBAAoB,CAAC;AACnC,cAAc,kBAAkB,CAAC;AACjC,cAAc,eAAe,CAAC"}
@@ -0,0 +1,7 @@
1
+ // hazo_scrape/src/parse/index.ts — public surface of the pure, network-free parse layer
2
+ export * from './types.js';
3
+ export * from './parse_number.js';
4
+ export * from './parse_date.js';
5
+ export * from './extract_table.js';
6
+ export * from './map_columns.js';
7
+ export * from './map_rows.js';
@@ -0,0 +1,6 @@
1
+ import type { ColumnKeywords, ColumnMap } from './types.js';
2
+ export declare function mapColumns(headers: string[], keywords: ColumnKeywords, opts?: {
3
+ dateGuard?: boolean;
4
+ required?: string[];
5
+ }): ColumnMap;
6
+ //# sourceMappingURL=map_columns.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"map_columns.d.ts","sourceRoot":"","sources":["../../src/parse/map_columns.ts"],"names":[],"mappings":"AAqCA,OAAO,KAAK,EAAE,cAAc,EAAE,SAAS,EAAa,MAAM,YAAY,CAAC;AAEvE,wBAAgB,UAAU,CACxB,OAAO,EAAE,MAAM,EAAE,EACjB,QAAQ,EAAE,cAAc,EACxB,IAAI,CAAC,EAAE;IAAE,SAAS,CAAC,EAAE,OAAO,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAA;CAAE,GAClD,SAAS,CAmCX"}
@@ -0,0 +1,66 @@
1
+ // hazo_scrape/src/parse/map_columns.ts — pure, network-free header-to-column mapping
2
+ //
3
+ // Generalises the divscraper prototype's `mapHeaders` (dividends.mjs, line 150),
4
+ // fixing three bugs baked into that prototype's "first match wins" design:
5
+ //
6
+ // 1. ONE-TO-MANY. The prototype's `if (idx[key] != null) continue;` is exactly
7
+ // the RIO USD-over-AUD bug: a table with BOTH "Dividend (USD)" and
8
+ // "Dividend (AUD)" silently took whichever came first. Here every matching
9
+ // header is returned as a candidate, in header order; a key with 2+
10
+ // candidates is reported in `ambiguous`. This engine NEVER resolves
11
+ // ambiguity — the caller (or a human) must.
12
+ //
13
+ // 2. GENERIC dateGuard VIA `type`. The prototype hardcodes
14
+ // `key !== 'exDate' && key !== 'payDate'`. Here the rule is driven by the
15
+ // caller-declared `ColumnSpec.type`: when `opts.dateGuard` is on, a header
16
+ // matching "date" is dropped from the candidate list of any key whose
17
+ // `type !== 'date'`. So "Ex-Dividend Date" can never become an `amount`
18
+ // candidate even though it contains the keyword "dividend".
19
+ //
20
+ // 3. CALLER-DECLARED `required` — replaces a magic score threshold. `ok` is
21
+ // false only when some key listed in `opts.required` has zero candidates.
22
+ // If `required` is omitted, `ok` is always true.
23
+ //
24
+ // Matching rule (ported from prototype line 161): a header matches a key if
25
+ // the header text, lowercased and trimmed, INCLUDES any of that key's
26
+ // keywords (substring match, not equality). A header may match multiple keys
27
+ // — that is expected and not an error.
28
+ //
29
+ // `matched` ALWAYS contains one entry per key in `keywords`, even when a key
30
+ // has zero candidates (e.g. `franking` on a BHP-style table with no franking
31
+ // column) — the array is simply empty. This lets a caller check
32
+ // `map.matched.franking.length === 0` without an `in` check.
33
+ //
34
+ // `unmatched` holds ORIGINAL-CASE header text for headers that matched NO key
35
+ // at all. It is about HEADERS, not about keys with zero candidates — those
36
+ // live in `matched[key] === []`, not in `unmatched`.
37
+ export function mapColumns(headers, keywords, opts) {
38
+ const dateGuard = opts?.dateGuard ?? false;
39
+ const required = opts?.required ?? [];
40
+ const matched = {};
41
+ for (const key of Object.keys(keywords))
42
+ matched[key] = [];
43
+ const unmatched = [];
44
+ headers.forEach((header, index) => {
45
+ const text = header.trim().toLowerCase();
46
+ const isDateHeader = text.includes('date');
47
+ let matchedAny = false;
48
+ for (const [key, spec] of Object.entries(keywords)) {
49
+ // Guard: a header that reads as a date column may only be claimed by a
50
+ // date-typed key, even if it also contains another key's keyword
51
+ // (e.g. "Ex-Dividend Date" contains "dividend").
52
+ if (dateGuard && isDateHeader && spec.type !== 'date')
53
+ continue;
54
+ const isMatch = spec.keywords.some((kw) => text.includes(kw.toLowerCase()));
55
+ if (isMatch) {
56
+ matched[key].push({ header, index });
57
+ matchedAny = true;
58
+ }
59
+ }
60
+ if (!matchedAny)
61
+ unmatched.push(header);
62
+ });
63
+ const ambiguous = Object.keys(keywords).filter((key) => matched[key].length >= 2);
64
+ const ok = required.every((key) => (matched[key]?.length ?? 0) > 0);
65
+ return { ok, matched, unmatched, ambiguous, headers };
66
+ }
@@ -0,0 +1,6 @@
1
+ import type { ColumnKeywords, ColumnMap, MappedRow } from './types.js';
2
+ export declare function mapRows(table: {
3
+ headers: string[];
4
+ rows: string[][];
5
+ }, map: ColumnMap, keywords: ColumnKeywords): MappedRow[];
6
+ //# sourceMappingURL=map_rows.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"map_rows.d.ts","sourceRoot":"","sources":["../../src/parse/map_rows.ts"],"names":[],"mappings":"AAkBA,OAAO,KAAK,EAAQ,cAAc,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAW7E,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,GACvB,SAAS,EAAE,CAuBb"}
@@ -0,0 +1,47 @@
1
+ // hazo_scrape/src/parse/map_rows.ts — pure, network-free join of a ColumnMap onto table rows
2
+ //
3
+ // mapColumns decides WHICH header(s) mean what; this module reads the actual
4
+ // raw string out of each data row for every matched candidate and parses it
5
+ // per the key's declared ColumnSpec.type, producing the approved one-to-many
6
+ // row shape: Record<string, Cell[]>.
7
+ //
8
+ // Iteration is driven off `map.matched` — that is the authority on what
9
+ // matched (mapColumns guarantees every key in `keywords` is present, even
10
+ // with an empty array). `keywords` is only consulted for `.type`. If a key
11
+ // present in `map.matched` is somehow absent from `keywords` (should not
12
+ // happen in practice since both are keyed off the same caller-declared
13
+ // keyword map), its type defensively falls back to 'text'.
14
+ //
15
+ // Ragged rows (shorter than a candidate's index) never throw: the raw string
16
+ // is treated as '' and value is computed from that empty string (null for
17
+ // number/date, null for text after trim). Rows are taken as-is, never padded.
18
+ import { parseNumber } from './parse_number.js';
19
+ import { parseDate } from './parse_date.js';
20
+ function cellValue(type, raw) {
21
+ if (type === 'number')
22
+ return parseNumber(raw);
23
+ if (type === 'date')
24
+ return parseDate(raw);
25
+ const trimmed = raw.trim();
26
+ return trimmed === '' ? null : trimmed;
27
+ }
28
+ export function mapRows(table, map, keywords) {
29
+ const keys = Object.keys(map.matched);
30
+ return table.rows.map((row) => {
31
+ const mappedRow = {};
32
+ for (const key of keys) {
33
+ const candidates = map.matched[key] ?? [];
34
+ const type = keywords[key]?.type ?? 'text';
35
+ mappedRow[key] = candidates.map((candidate) => {
36
+ const raw = row[candidate.index] ?? '';
37
+ return {
38
+ header: candidate.header,
39
+ index: candidate.index,
40
+ raw,
41
+ value: cellValue(type, raw),
42
+ };
43
+ });
44
+ }
45
+ return mappedRow;
46
+ });
47
+ }
@@ -0,0 +1,4 @@
1
+ export declare function parseDate(raw: string, opts?: {
2
+ formats?: string[];
3
+ }): string | null;
4
+ //# sourceMappingURL=parse_date.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"parse_date.d.ts","sourceRoot":"","sources":["../../src/parse/parse_date.ts"],"names":[],"mappings":"AA6EA,wBAAgB,SAAS,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE;IAAE,OAAO,CAAC,EAAE,MAAM,EAAE,CAAA;CAAE,GAAG,MAAM,GAAG,IAAI,CAoCnF"}
@@ -0,0 +1,112 @@
1
+ // hazo_scrape/src/parse/parse_date.ts — pure, network-free date-cell parsing
2
+ //
3
+ // Always returns ISO "yyyy-mm-dd" or null. Never returns the raw input.
4
+ //
5
+ // DEVIATION from the divscraper prototype (dividends.mjs parseDate):
6
+ // - unrecognised input returns null, never the raw string.
7
+ // - ambiguous slash dates (e.g. "05/03/2026") return null rather than
8
+ // blindly assuming day-first. Slash dates are only resolved without a
9
+ // hint when one ordering is calendar-impossible (a component > 12), or
10
+ // when the caller supplies `opts.formats` to disambiguate.
11
+ const MONTHS = {
12
+ jan: 1,
13
+ feb: 2,
14
+ mar: 3,
15
+ apr: 4,
16
+ may: 5,
17
+ jun: 6,
18
+ jul: 7,
19
+ aug: 8,
20
+ sep: 9,
21
+ oct: 10,
22
+ nov: 11,
23
+ dec: 12,
24
+ };
25
+ function isLeapYear(year) {
26
+ return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
27
+ }
28
+ function daysInMonth(year, month) {
29
+ const lengths = [31, isLeapYear(year) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
30
+ return lengths[month - 1] ?? 0;
31
+ }
32
+ function toIsoIfValid(year, month, day) {
33
+ if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day))
34
+ return null;
35
+ if (month < 1 || month > 12)
36
+ return null;
37
+ if (day < 1)
38
+ return null;
39
+ if (day > daysInMonth(year, month))
40
+ return null;
41
+ const y = String(year).padStart(4, '0');
42
+ const mo = String(month).padStart(2, '0');
43
+ const d = String(day).padStart(2, '0');
44
+ return `${y}-${mo}-${d}`;
45
+ }
46
+ function monthFromName(name) {
47
+ const key = name.toLowerCase().slice(0, 3);
48
+ return MONTHS[key] ?? null;
49
+ }
50
+ function pickSlashHint(formats) {
51
+ if (!formats)
52
+ return undefined;
53
+ for (const f of formats) {
54
+ if (f === 'DD/MM/YYYY' || f === 'MM/DD/YYYY')
55
+ return f;
56
+ }
57
+ return undefined;
58
+ }
59
+ function resolveSlashDate(a, b, year, formats) {
60
+ const hint = pickSlashHint(formats);
61
+ if (hint === 'DD/MM/YYYY')
62
+ return toIsoIfValid(year, b, a);
63
+ if (hint === 'MM/DD/YYYY')
64
+ return toIsoIfValid(year, a, b);
65
+ // No hint: only resolve when the ordering is unambiguous, i.e. exactly one
66
+ // of the two components is impossible as a month (> 12). Never guess.
67
+ const aCanBeMonth = a <= 12;
68
+ const bCanBeMonth = b <= 12;
69
+ if (aCanBeMonth && bCanBeMonth)
70
+ return null; // ambiguous — never guess
71
+ if (!aCanBeMonth && bCanBeMonth)
72
+ return toIsoIfValid(year, b, a); // a is day, b is month
73
+ if (aCanBeMonth && !bCanBeMonth)
74
+ return toIsoIfValid(year, a, b); // b is day, a is month
75
+ return null; // neither can be a month — not a valid date
76
+ }
77
+ export function parseDate(raw, opts) {
78
+ if (raw == null)
79
+ return null;
80
+ const s = String(raw).trim();
81
+ if (s === '')
82
+ return null;
83
+ // ISO: yyyy-mm-dd
84
+ let m = s.match(/^(\d{4})-(\d{2})-(\d{2})$/);
85
+ if (m) {
86
+ return toIsoIfValid(Number(m[1]), Number(m[2]), Number(m[3]));
87
+ }
88
+ // "D Mon YYYY" / "D Month YYYY", e.g. "5 Mar 2026", "18 November 2025"
89
+ m = s.match(/^(\d{1,2})\s+([A-Za-z]{3,})\s+(\d{4})$/);
90
+ if (m) {
91
+ const month = monthFromName(m[2]);
92
+ if (month == null)
93
+ return null;
94
+ return toIsoIfValid(Number(m[3]), month, Number(m[1]));
95
+ }
96
+ // "Mon D, YYYY" / "Month D YYYY", comma optional, e.g. "Mar 5, 2026"
97
+ m = s.match(/^([A-Za-z]{3,})\s+(\d{1,2}),?\s+(\d{4})$/);
98
+ if (m) {
99
+ const month = monthFromName(m[1]);
100
+ if (month == null)
101
+ return null;
102
+ return toIsoIfValid(Number(m[3]), month, Number(m[2]));
103
+ }
104
+ // Slash dates: D/M/Y or M/D/Y — ambiguous without a hint or a proof by
105
+ // calendar impossibility (see resolveSlashDate). Two-digit years are
106
+ // intentionally rejected: the year group below requires exactly 4 digits.
107
+ m = s.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/);
108
+ if (m) {
109
+ return resolveSlashDate(Number(m[1]), Number(m[2]), Number(m[3]), opts?.formats);
110
+ }
111
+ return null;
112
+ }
@@ -0,0 +1,2 @@
1
+ export declare function parseNumber(raw: string): number | null;
2
+ //# sourceMappingURL=parse_number.d.ts.map
@@ -0,0 +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,CAqBtD"}
@@ -0,0 +1,31 @@
1
+ // hazo_scrape/src/parse/parse_number.ts — pure, network-free numeric-cell parsing
2
+ //
3
+ // Strips currency symbols, currency codes, thousands separators, "%" and
4
+ // whitespace, then parses what remains as a plain JS number.
5
+ //
6
+ // DEVIATION from the divscraper prototype (dividends.mjs parseAmount/parseFranking):
7
+ // this function does NOT apply any domain-specific reinterpretation of the
8
+ // number. In particular:
9
+ // - no cents->dollars conversion ("looksLikeCents" heuristic) — "280" stays 280.
10
+ // - no franking-specific text mapping ("Unfranked" -> 0, "Fully franked" -> 100).
11
+ // Anything with no parseable digits returns null. Domain interpretation belongs
12
+ // in the caller, not in this generic engine.
13
+ export function parseNumber(raw) {
14
+ if (raw == null)
15
+ return null;
16
+ let s = String(raw).trim();
17
+ if (s === '')
18
+ return null;
19
+ // Strip currency symbols (e.g. $, £, €, ¥, ¢, ₹, ₩, ...).
20
+ s = s.replace(/\p{Sc}/gu, '');
21
+ // Strip 3-letter uppercase currency codes (e.g. AUD, USD, EUR).
22
+ s = s.replace(/\b[A-Z]{3}\b/g, '');
23
+ // Strip thousands separators and percent signs.
24
+ s = s.replace(/,/g, '');
25
+ s = s.replace(/%/g, '');
26
+ s = s.trim();
27
+ if (s === '')
28
+ return null;
29
+ const num = Number(s);
30
+ return Number.isFinite(num) ? num : null;
31
+ }
@@ -0,0 +1,24 @@
1
+ export type ColumnSpec = {
2
+ keywords: string[];
3
+ type: 'number' | 'date' | 'text';
4
+ };
5
+ export type ColumnKeywords = Record<string, ColumnSpec>;
6
+ export type Candidate = {
7
+ header: string;
8
+ index: number;
9
+ };
10
+ export type ColumnMap = {
11
+ ok: boolean;
12
+ matched: Record<string, Candidate[]>;
13
+ unmatched: string[];
14
+ ambiguous: string[];
15
+ headers: string[];
16
+ };
17
+ export type Cell = {
18
+ header: string;
19
+ index: number;
20
+ raw: string;
21
+ value: number | string | null;
22
+ };
23
+ export type MappedRow = Record<string, Cell[]>;
24
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/parse/types.ts"],"names":[],"mappings":"AAEA,MAAM,MAAM,UAAU,GAAG;IAAE,QAAQ,EAAE,MAAM,EAAE,CAAC;IAAC,IAAI,EAAE,QAAQ,GAAG,MAAM,GAAG,MAAM,CAAA;CAAE,CAAC;AAClF,MAAM,MAAM,cAAc,GAAG,MAAM,CAAC,MAAM,EAAE,UAAU,CAAC,CAAC;AACxD,MAAM,MAAM,SAAS,GAAG;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC;AAC1D,MAAM,MAAM,SAAS,GAAG;IACtB,EAAE,EAAE,OAAO,CAAC;IACZ,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,SAAS,EAAE,CAAC,CAAC;IACrC,SAAS,EAAE,MAAM,EAAE,CAAC;IACpB,SAAS,EAAE,MAAM,EAAE,CAAC;IACpB,OAAO,EAAE,MAAM,EAAE,CAAC;CACnB,CAAC;AACF,MAAM,MAAM,IAAI,GAAG;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAA;CAAE,CAAC;AACjG,MAAM,MAAM,SAAS,GAAG,MAAM,CAAC,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC"}
@@ -0,0 +1,2 @@
1
+ // hazo_scrape/src/parse/types.ts — public types for the pure, network-free parse layer
2
+ export {};
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "hazo_scrape",
3
+ "version": "1.0.1",
4
+ "description": "Generic source-agnostic web scraping engine with a network-free parse core.",
5
+ "type": "module",
6
+ "module": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ },
13
+ "./parse": {
14
+ "types": "./dist/parse/index.d.ts",
15
+ "import": "./dist/parse/index.js"
16
+ },
17
+ "./package.json": "./package.json"
18
+ },
19
+ "files": [
20
+ "dist",
21
+ "README.md",
22
+ "CHANGE_LOG.md",
23
+ "SETUP_CHECKLIST.md"
24
+ ],
25
+ "scripts": {
26
+ "build": "tsc -p tsconfig.build.json",
27
+ "type-check": "tsc --noEmit",
28
+ "lint": "tsc --noEmit",
29
+ "test": "node --experimental-vm-modules ../node_modules/jest/bin/jest.js --config jest.config.cjs",
30
+ "dev:test-app": "npm run build && cd test-app && npm run dev",
31
+ "build:test-app": "npm run build && cd test-app && npm run build"
32
+ },
33
+ "peerDependencies": {
34
+ "hazo_core": "^1.0.1",
35
+ "react": "^18.0.0 || ^19.0.0",
36
+ "react-dom": "^18.0.0 || ^19.0.0"
37
+ },
38
+ "peerDependenciesMeta": {
39
+ "react": {
40
+ "optional": true
41
+ },
42
+ "react-dom": {
43
+ "optional": true
44
+ }
45
+ },
46
+ "devDependencies": {
47
+ "@types/jest": "^30.0.0",
48
+ "@types/node": "^22.10.0",
49
+ "jest": "^30.2.0",
50
+ "jest-environment-node": "^30.2.0",
51
+ "ts-jest": "^29.4.5",
52
+ "typescript": "^5.7.2"
53
+ },
54
+ "keywords": [],
55
+ "author": "Pubs Abayasiri",
56
+ "license": "MIT",
57
+ "dependencies": {
58
+ "cheerio": "^1.2.0"
59
+ }
60
+ }