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 +5 -0
- package/README.md +145 -0
- package/SETUP_CHECKLIST.md +17 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/lib/index.d.ts +2 -0
- package/dist/lib/index.d.ts.map +1 -0
- package/dist/lib/index.js +2 -0
- package/dist/parse/extract_table.d.ts +10 -0
- package/dist/parse/extract_table.d.ts.map +1 -0
- package/dist/parse/extract_table.js +134 -0
- package/dist/parse/index.d.ts +7 -0
- package/dist/parse/index.d.ts.map +1 -0
- package/dist/parse/index.js +7 -0
- package/dist/parse/map_columns.d.ts +6 -0
- package/dist/parse/map_columns.d.ts.map +1 -0
- package/dist/parse/map_columns.js +66 -0
- package/dist/parse/map_rows.d.ts +6 -0
- package/dist/parse/map_rows.d.ts.map +1 -0
- package/dist/parse/map_rows.js +47 -0
- package/dist/parse/parse_date.d.ts +4 -0
- package/dist/parse/parse_date.d.ts.map +1 -0
- package/dist/parse/parse_date.js +112 -0
- package/dist/parse/parse_number.d.ts +2 -0
- package/dist/parse/parse_number.d.ts.map +1 -0
- package/dist/parse/parse_number.js +31 -0
- package/dist/parse/types.d.ts +24 -0
- package/dist/parse/types.d.ts.map +1 -0
- package/dist/parse/types.js +2 -0
- package/package.json +60 -0
package/CHANGE_LOG.md
ADDED
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 -->
|
package/dist/index.d.ts
ADDED
|
@@ -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 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/lib/index.ts"],"names":[],"mappings":"AACA,cAAc,mBAAmB,CAAC"}
|
|
@@ -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 -> U+00A0, decoded by cheerio's
|
|
22
|
+
// .text()) into single spaces and trim. Real IR tables routinely pad cells
|
|
23
|
+
// with 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 @@
|
|
|
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 @@
|
|
|
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 @@
|
|
|
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 @@
|
|
|
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 @@
|
|
|
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"}
|
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
|
+
}
|