hazo_scrape 1.5.2 → 1.6.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 +42 -0
- package/README.md +69 -11
- package/dist/parse/extract_table.d.ts +39 -1
- package/dist/parse/extract_table.d.ts.map +1 -1
- package/dist/parse/extract_table.js +157 -32
- 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/package.json +2 -2
package/CHANGE_LOG.md
CHANGED
|
@@ -1,5 +1,47 @@
|
|
|
1
1
|
# hazo_scrape — Change Log
|
|
2
2
|
|
|
3
|
+
## 1.6.1 — 2026-07-29
|
|
4
|
+
|
|
5
|
+
Four `extractTable` correctness fixes. Kept as a **separate version** rather than folded into the (unpublished) 1.6.0: three of the four regress behaviour that shipped in **1.3.0** and is live on npm, so they deserve their own visible `Fixed` entry attributable to a version consumers can upgrade *to*, instead of being buried inside 1.6.0's `Added` list.
|
|
6
|
+
|
|
7
|
+
### Fixed — grid mode duplicated the header row when `headerRow` matched nothing
|
|
8
|
+
- `extractTable(html, { grid: { …, headerRow } })` with a typo'd/stale `headerRow` selector emitted the header as **data row 0**. The fallback correctly re-read row 0 as the header, but the "skip row 0" guard was gated on `grid.headerRow == null`, so it could never fire once `headerRow` was supplied. A `headerRow` that matches nothing now degrades to exactly the same behaviour as omitting it.
|
|
9
|
+
|
|
10
|
+
### Fixed — the repeated-in-cell-header strip truncated legitimate values
|
|
11
|
+
- The responsive-label strip (1.3.0) only tested `text.startsWith(header)`, which butchered real values: header `2025` + cell `2025-06-30` → `-06-30` (then `parseDate` → `null`); header `Total` + cell `Total dividend` → `dividend`. The strip now requires a genuine **DOM-concatenation seam**, matching the pattern it exists for (`<div>Payment date</div><div>2 July 2026</div>` → `"Payment date2 July 2026"`):
|
|
12
|
+
- the header must **not** be followed by whitespace — whitespace means the cell reads as one natural phrase, not two glued nodes; and
|
|
13
|
+
- the remainder must not continue the header's own token — if the header ends in a digit and the remainder opens with a digit or a numeric joiner (`-` `/` `.` `:`), the "header" is really a prefix of one longer number/date.
|
|
14
|
+
- Everything else (letter → digit, letter → letter with no space) is still stripped, so the NAB responsive case is unchanged.
|
|
15
|
+
|
|
16
|
+
### Fixed — row-header alignment when the header row has NO corner cell
|
|
17
|
+
- 1.3.0's `td` → `th, td` change fixed tables that pair a `<th scope="row">` row label with a corner cell in the header row, but broke the equally common **cornerless** variant: header `<th>Ex date</th><th>Amount</th>` with row `<th scope="row">FY25 Final</th><td>1 Jan 2026</td><td>45c</td>` shifted every column right by one (`Ex date` read as `"FY25 Final"`). Data rows are now reconciled against the header width: when a row is wider than the header and the excess is fully explained by leading `<th>` cells, exactly that many leading row-header cells are dropped. **Both** shapes now map correctly; a merely ragged row (extra `<td>`s, or a leading `<td>`) is untouched.
|
|
18
|
+
|
|
19
|
+
### Fixed — combine mode leaked unrelated tables past a non-table sibling
|
|
20
|
+
- `combine` documented "stopping at the first sibling that doesn't match", but the walk used `nextAll('table')`, which **filters out** non-table siblings rather than stopping at them — so an intervening `<h2>`/`<p>` never triggered the `break` and a later, unrelated same-width table was absorbed as data. The walk now visits every following sibling and breaks on the first that is not a `<table>` (as well as on the first width mismatch), matching the documented contract.
|
|
21
|
+
|
|
22
|
+
### Docs
|
|
23
|
+
- `README.md`'s `extractTable` entry documented only `opts.select`. It now documents `grid`, `combine`, `cellIgnoreSelectors`, precedence between the modes, and row-header reconciliation; `mapRows`/`parseDate` gained the `dateYearPivot`/`yearPivot` options added in 1.6.0.
|
|
24
|
+
|
|
25
|
+
### Known remaining (out of scope, pre-existing)
|
|
26
|
+
- `parseNumber('1.234,56')` (European locale) is misread — only comma-as-thousands is handled.
|
|
27
|
+
- Accounting-style negatives (`(1.23)`) are not recognised as `-1.23`.
|
|
28
|
+
- `extractTableFromPdf` keeps a multi-page PDF's per-page repeated header as data rows.
|
|
29
|
+
|
|
30
|
+
## 1.6.0 — 2026-07-25
|
|
31
|
+
|
|
32
|
+
### Added — `parseNumber` tokenizes a trailing bare cent marker
|
|
33
|
+
- `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.
|
|
34
|
+
|
|
35
|
+
### Added — `parseDate` optional 2-digit-year support (`opts.yearPivot`)
|
|
36
|
+
- `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`.
|
|
37
|
+
- `mapRows` gains `opts.dateYearPivot?: number`, forwarded verbatim to `parseDate` for every date-typed cell.
|
|
38
|
+
|
|
39
|
+
### Added — `extractTable` `cellIgnoreSelectors` (strip in-cell noise)
|
|
40
|
+
- `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.
|
|
41
|
+
|
|
42
|
+
### Added — `extractTable` `combine` mode (header-only table + per-section sibling tables)
|
|
43
|
+
- `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.
|
|
44
|
+
|
|
3
45
|
## 1.5.2 — 2026-07-24
|
|
4
46
|
|
|
5
47
|
### Fixed — `extractTableFromPdf` mid-word text-run splits
|
package/README.md
CHANGED
|
@@ -61,13 +61,54 @@ console.log(rows[0].exDate[0].value); // "2026-03-05"
|
|
|
61
61
|
|
|
62
62
|
## API
|
|
63
63
|
|
|
64
|
-
### `extractTable(html
|
|
64
|
+
### `extractTable(html, opts?): { headers: string[]; rows: string[][]; warning?: string }`
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
extractTable(html: string, opts?: {
|
|
68
|
+
select?: string;
|
|
69
|
+
grid?: { container?: string; row: string; cell: string; headerRow?: string };
|
|
70
|
+
combine?: { headerSelector: string; dropLabelRows?: boolean };
|
|
71
|
+
cellIgnoreSelectors?: string[];
|
|
72
|
+
}): ExtractTableResult
|
|
73
|
+
```
|
|
65
74
|
|
|
66
75
|
Parses network-free HTML and returns the best-guess data table as raw header
|
|
67
|
-
and row strings.
|
|
68
|
-
`<table>`
|
|
69
|
-
|
|
70
|
-
|
|
76
|
+
and row strings. With no options a scoring heuristic picks the most
|
|
77
|
+
data-table-like `<table>` on the page. A `warning` is returned (never thrown)
|
|
78
|
+
when no table-like element is found, or when a selector matches nothing.
|
|
79
|
+
|
|
80
|
+
Precedence: `grid` > `combine` > `select`.
|
|
81
|
+
|
|
82
|
+
- **`select`** — a CSS selector targeting a specific `<table>` instead of
|
|
83
|
+
letting the scoring heuristic choose.
|
|
84
|
+
|
|
85
|
+
- **`grid`** — read a "table" built from styled `<div>`s (CSS grid/flex)
|
|
86
|
+
rather than a real `<table>`; common on IR pages whose real table is
|
|
87
|
+
JS-rendered. `row`/`cell` are required; `container` scopes the search (when
|
|
88
|
+
it matches several grids, the one with the most data rows wins); `headerRow`
|
|
89
|
+
identifies the header among the `row` matches (rows matching it are never
|
|
90
|
+
data). Omit `headerRow` — or give one that matches nothing — and the first
|
|
91
|
+
row is used as the header and excluded from the data.
|
|
92
|
+
|
|
93
|
+
- **`combine`** — reconstruct one logical table from a header-only `<table>`
|
|
94
|
+
followed by one `<table>` per section (e.g. per year), a common
|
|
95
|
+
Computershare-style layout. Headers come from `headerSelector`'s first row;
|
|
96
|
+
data rows are gathered from that table plus each **immediately-following
|
|
97
|
+
sibling**, stopping at the first sibling that is not a `<table>` or whose
|
|
98
|
+
column count differs — so an unrelated later section can't leak in. Set
|
|
99
|
+
`dropLabelRows: true` to drop bare section-divider rows (only the first cell
|
|
100
|
+
non-empty, e.g. a lone `2025`).
|
|
101
|
+
|
|
102
|
+
- **`cellIgnoreSelectors`** — CSS selectors whose matching descendants are
|
|
103
|
+
removed from every cell **and header** before its text is read, e.g.
|
|
104
|
+
`['sup']` so `100<sup>4</sup>` reads as `100`, not `1004`. Off by default
|
|
105
|
+
(cells are read verbatim); applies to all three modes; the removal happens
|
|
106
|
+
on a per-cell clone and never mutates the document.
|
|
107
|
+
|
|
108
|
+
**Row-header columns.** Data cells are read as `th, td`, so an accessible
|
|
109
|
+
table whose first column is a `<th scope="row">` label stays column-aligned.
|
|
110
|
+
When the header row omits the matching corner cell, the leading row-header
|
|
111
|
+
cell(s) are dropped so the row still lines up with the headers.
|
|
71
112
|
|
|
72
113
|
### `mapColumns(headers: string[], keywords: ColumnKeywords, opts?: { dateGuard?: boolean; required?: string[] }): ColumnMap`
|
|
73
114
|
|
|
@@ -77,14 +118,24 @@ the first. `opts.dateGuard` drops date-looking headers from any key whose
|
|
|
77
118
|
`type !== 'date'`. `opts.required` lists keys that must have at least one
|
|
78
119
|
candidate for `map.ok` to be `true`.
|
|
79
120
|
|
|
80
|
-
### `mapRows(table
|
|
121
|
+
### `mapRows(table, map, keywords, opts?): MappedRow[]`
|
|
122
|
+
|
|
123
|
+
```ts
|
|
124
|
+
mapRows(
|
|
125
|
+
table: { headers: string[]; rows: string[][] },
|
|
126
|
+
map: ColumnMap,
|
|
127
|
+
keywords: ColumnKeywords,
|
|
128
|
+
opts?: { dateFormats?: string[]; dateExtractLeading?: boolean; dateYearPivot?: number },
|
|
129
|
+
): MappedRow[]
|
|
130
|
+
```
|
|
81
131
|
|
|
82
132
|
Joins a `ColumnMap` back onto `table.rows`, producing one `MappedRow` per data
|
|
83
133
|
row. Each matched key holds an array of `Cell`s (one per candidate header),
|
|
84
|
-
each parsed according to that key's declared `type`. `opts.dateFormats
|
|
85
|
-
`opts.dateExtractLeading` are forwarded verbatim to
|
|
86
|
-
|
|
87
|
-
omitting `opts` preserves the
|
|
134
|
+
each parsed according to that key's declared `type`. `opts.dateFormats`,
|
|
135
|
+
`opts.dateExtractLeading` and `opts.dateYearPivot` are forwarded verbatim to
|
|
136
|
+
`parseDate`'s `opts.formats`, `opts.extractLeading` and `opts.yearPivot` for
|
|
137
|
+
every date-typed cell; all three default off, so omitting `opts` preserves the
|
|
138
|
+
exact prior behaviour.
|
|
88
139
|
|
|
89
140
|
### `parseNumber(raw: string): number | null`
|
|
90
141
|
|
|
@@ -93,7 +144,7 @@ and `%`, then parses what remains as a number. Returns `null` when nothing
|
|
|
93
144
|
parseable is left. Does **not** convert cents to dollars — domain scaling is
|
|
94
145
|
the caller's job.
|
|
95
146
|
|
|
96
|
-
### `parseDate(raw: string, opts?: { formats?: string[]; extractLeading?: boolean }): string | null`
|
|
147
|
+
### `parseDate(raw: string, opts?: { formats?: string[]; extractLeading?: boolean; yearPivot?: number }): string | null`
|
|
97
148
|
|
|
98
149
|
Parses a handful of common date text formats (ISO, `D Mon YYYY`, `Mon D,
|
|
99
150
|
YYYY`, and `D/M/Y` slash dates) into an ISO `yyyy-mm-dd` string. Returns
|
|
@@ -108,6 +159,13 @@ junk is still rejected (the date must be the first token) and the date itself is
|
|
|
108
159
|
still matched exactly and disambiguated by the same rules — so the "never guess"
|
|
109
160
|
contract holds. For real IR pages that staple a label onto a date cell.
|
|
110
161
|
|
|
162
|
+
`opts.yearPivot` (default off) enables 2-digit years, which are otherwise
|
|
163
|
+
**rejected** (`null`) rather than guessed. It is the base of a sliding 100-year
|
|
164
|
+
window: `parseDate('10 Mar 26', { yearPivot: 2000 })` → `'2026-03-10'`, while
|
|
165
|
+
`yearPivot: 1950` reads `26`→2026 and `99`→1999. Applies to the named-month
|
|
166
|
+
formats only (`D Mon YY`, `Mon D, YY`); slash dates stay 4-digit-only, since a
|
|
167
|
+
2-digit slash year compounds day/month **and** century ambiguity.
|
|
168
|
+
|
|
111
169
|
### Types
|
|
112
170
|
|
|
113
171
|
```ts
|
|
@@ -25,14 +25,52 @@ export type GridSelector = {
|
|
|
25
25
|
/**
|
|
26
26
|
* Selector identifying the header row among the `row` matches. Rows that
|
|
27
27
|
* match it are treated as headers (never data); the first such row supplies
|
|
28
|
-
* the column headers. Omit to treat the first row as the header
|
|
28
|
+
* the column headers. Omit to treat the first row as the header — as does a
|
|
29
|
+
* selector that matches NOTHING (a typo'd/stale selector degrades to the
|
|
30
|
+
* omitted behaviour rather than emitting the header as data row 0).
|
|
29
31
|
*/
|
|
30
32
|
headerRow?: string;
|
|
31
33
|
};
|
|
34
|
+
/**
|
|
35
|
+
* Combine-mode selector — for pages that split one logical table across
|
|
36
|
+
* several sibling <table>s: a header-only <table> followed by one <table>
|
|
37
|
+
* per section (e.g. per year), each led by a section-label row. Common on
|
|
38
|
+
* Computershare-style IR pages (e.g. JB Hi-Fi's dividend history). The header
|
|
39
|
+
* comes from `headerSelector`'s first row; data rows are gathered from that
|
|
40
|
+
* table plus its immediately-following sibling <table>s that share its column
|
|
41
|
+
* count. The walk stops at the first following sibling that is not a <table>
|
|
42
|
+
* (e.g. an <h2> introducing an unrelated section) or whose column count
|
|
43
|
+
* differs, so an unrelated later table can't leak in.
|
|
44
|
+
*/
|
|
45
|
+
export type CombineSelector = {
|
|
46
|
+
/** Selector matching the ONE table whose first row supplies the shared headers. */
|
|
47
|
+
headerSelector: string;
|
|
48
|
+
/**
|
|
49
|
+
* Drop "section-label" rows — a row in which only the first cell holds text
|
|
50
|
+
* and every other cell is empty (e.g. a bare "2025" year divider). Off by
|
|
51
|
+
* default. Column-mapping/parsing would already null such a row out, but
|
|
52
|
+
* dropping it keeps the raw row set clean and honest.
|
|
53
|
+
*/
|
|
54
|
+
dropLabelRows?: boolean;
|
|
55
|
+
};
|
|
32
56
|
export type ExtractTableOptions = {
|
|
33
57
|
select?: string;
|
|
34
58
|
/** Read a <div>-based pseudo-table instead of a real <table>. Takes precedence over `select`. */
|
|
35
59
|
grid?: GridSelector;
|
|
60
|
+
/**
|
|
61
|
+
* Combine a header-only table with its per-section sibling tables into one
|
|
62
|
+
* table (see CombineSelector). Takes precedence over `select`; `grid` still
|
|
63
|
+
* wins over this.
|
|
64
|
+
*/
|
|
65
|
+
combine?: CombineSelector;
|
|
66
|
+
/**
|
|
67
|
+
* CSS selectors whose matching descendants are removed from every cell
|
|
68
|
+
* BEFORE its text is read — for stripping in-cell noise that corrupts
|
|
69
|
+
* parsing, e.g. `['sup']` to drop footnote-reference superscripts so
|
|
70
|
+
* "100<sup>4</sup>" reads as "100" not "1004". Applies to real-table, grid,
|
|
71
|
+
* and combine modes alike. Omit to read cells verbatim (the default).
|
|
72
|
+
*/
|
|
73
|
+
cellIgnoreSelectors?: string[];
|
|
36
74
|
};
|
|
37
75
|
export declare function extractTable(html: string, opts?: ExtractTableOptions): ExtractTableResult;
|
|
38
76
|
//# 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
|
|
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;;;;;;OAMG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB,CAAC;AAEF;;;;;;;;;;GAUG;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;AAkTF,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,42 @@ 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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
83
|
+
// Decide whether a leading copy of `header` inside `text` is a REPEATED DOM
|
|
84
|
+
// LABEL (which must be stripped) or genuinely part of the value (which must
|
|
85
|
+
// not). The responsive-table pattern that motivates the strip glues two sibling
|
|
86
|
+
// nodes together with no separator — `<div>Payment date</div><div>2 July
|
|
87
|
+
// 2026</div>` reads as `"Payment date2 July 2026"` — so the seam is the tell:
|
|
88
|
+
//
|
|
89
|
+
// 1. Whitespace after the header => prose, one natural phrase, not a seam.
|
|
90
|
+
// Header "Total" + "Total dividend" is a value that happens to start with
|
|
91
|
+
// the header word; stripping it would leave the bare word "dividend".
|
|
92
|
+
// 2. The remainder must not continue the header's OWN token. If the header
|
|
93
|
+
// ends in a digit and the remainder opens with a digit or a numeric
|
|
94
|
+
// joiner (`-`, `/`, `.`, `:`), the "header" is really a prefix of one
|
|
95
|
+
// longer number/date — header "2025" + "2025-06-30" would be butchered
|
|
96
|
+
// into "-06-30".
|
|
97
|
+
//
|
|
98
|
+
// Everything else (letter -> digit, letter -> letter with no space) is the
|
|
99
|
+
// concatenation signature the strip exists for and is stripped as before.
|
|
100
|
+
function isRepeatedHeaderLabel(text, header) {
|
|
101
|
+
if (header === '' || text.length <= header.length || !text.startsWith(header))
|
|
102
|
+
return false;
|
|
103
|
+
const rest = text.slice(header.length);
|
|
104
|
+
if (/^\s/.test(rest))
|
|
105
|
+
return false; // (1) prose, not a DOM seam
|
|
106
|
+
const headerEndsDigit = /\d$/.test(header);
|
|
107
|
+
if (headerEndsDigit && /^[\d\-/.:]/.test(rest))
|
|
108
|
+
return false; // (2) same token cut in half
|
|
109
|
+
return rest.trim() !== '';
|
|
110
|
+
}
|
|
111
|
+
// Read the data rows (everything after the header row) of one real <table>,
|
|
112
|
+
// aligning each cell against `headers` (for the repeated-in-cell-label strip)
|
|
113
|
+
// and removing `ignore` descendants first. Shared by readTable and combine
|
|
114
|
+
// mode so both handle ragged/label/responsive rows identically.
|
|
115
|
+
function readTableDataRows(rows, headers, ignore, startIndex) {
|
|
81
116
|
const dataRows = [];
|
|
82
|
-
for (let i =
|
|
83
|
-
const rowSel =
|
|
117
|
+
for (let i = startIndex; i < rows.length; i++) {
|
|
118
|
+
const rowSel = rows.eq(i);
|
|
84
119
|
// A genuine data row has at least one <td>; a pure-<th> row (a section
|
|
85
120
|
// divider spanning the table) is skipped — prototype behaviour. But the
|
|
86
121
|
// cells themselves are then read as `th, td`, not `td` alone, so a
|
|
@@ -90,39 +125,121 @@ function readTable(tableSel) {
|
|
|
90
125
|
if (rowSel.children('td').length === 0)
|
|
91
126
|
continue;
|
|
92
127
|
const cellEls = rowSel.children('th, td');
|
|
128
|
+
// Row-header reconciliation. Two real shapes exist:
|
|
129
|
+
// (a) the header row carries a (usually blank) CORNER cell for the
|
|
130
|
+
// row-label column — widths already agree, keep every cell;
|
|
131
|
+
// (b) the header row OMITS it — the row is then wider than the header
|
|
132
|
+
// and reading the <th> would shift every column right by one.
|
|
133
|
+
// Detect (b) by an excess that is fully explained by leading <th> cells,
|
|
134
|
+
// and drop exactly that many. A row that is merely ragged (excess <td>s,
|
|
135
|
+
// or a leading <td>) is untouched.
|
|
136
|
+
let offset = 0;
|
|
137
|
+
if (headers.length > 0 && cellEls.length > headers.length) {
|
|
138
|
+
let leadingTh = 0;
|
|
139
|
+
while (leadingTh < cellEls.length && cellEls.eq(leadingTh).is('th'))
|
|
140
|
+
leadingTh += 1;
|
|
141
|
+
const excess = cellEls.length - headers.length;
|
|
142
|
+
if (excess <= leadingTh)
|
|
143
|
+
offset = excess;
|
|
144
|
+
}
|
|
93
145
|
const cells = [];
|
|
94
|
-
for (let j =
|
|
95
|
-
let text =
|
|
146
|
+
for (let j = offset; j < cellEls.length; j++) {
|
|
147
|
+
let text = readCellText(cellEls.eq(j), ignore);
|
|
96
148
|
// Responsive-table pattern: some IR pages repeat the column header
|
|
97
149
|
// INSIDE each cell as a (visually-hidden on desktop) label, e.g. NAB's
|
|
98
150
|
// "<div>Payment date</div><div>2 July 2026</div>", whose .text()
|
|
99
151
|
// collapses to "Payment date2 July 2026". Strip a leading copy of this
|
|
100
152
|
// cell's own column header so downstream number/date parsing sees just
|
|
101
|
-
// the value
|
|
102
|
-
//
|
|
103
|
-
const header = headers[j];
|
|
104
|
-
if (header != null &&
|
|
153
|
+
// the value — but only at a genuine DOM seam (see
|
|
154
|
+
// isRepeatedHeaderLabel), so a legitimate value is never truncated.
|
|
155
|
+
const header = headers[j - offset];
|
|
156
|
+
if (header != null && isRepeatedHeaderLabel(text, header)) {
|
|
105
157
|
text = text.slice(header.length).trim();
|
|
106
158
|
}
|
|
107
159
|
cells.push(text);
|
|
108
160
|
}
|
|
109
161
|
dataRows.push(cells);
|
|
110
162
|
}
|
|
111
|
-
return
|
|
163
|
+
return dataRows;
|
|
164
|
+
}
|
|
165
|
+
function readTable(tableSel, ignore) {
|
|
166
|
+
const rows = tableRows(tableSel);
|
|
167
|
+
if (rows.length === 0)
|
|
168
|
+
return { headers: [], rows: [] };
|
|
169
|
+
const headerCells = rows.eq(0).children('th, td');
|
|
170
|
+
const headers = [];
|
|
171
|
+
for (let i = 0; i < headerCells.length; i++) {
|
|
172
|
+
headers.push(readCellText(headerCells.eq(i), ignore));
|
|
173
|
+
}
|
|
174
|
+
return { headers, rows: readTableDataRows(rows, headers, ignore, 1) };
|
|
175
|
+
}
|
|
176
|
+
// True when a row is a "section-label" row: exactly one non-empty cell, and
|
|
177
|
+
// it's the first — e.g. a bare "2025" year divider between per-year tables.
|
|
178
|
+
function isLabelRow(cells) {
|
|
179
|
+
if (cells.length === 0 || cells[0].trim() === '')
|
|
180
|
+
return false;
|
|
181
|
+
for (let i = 1; i < cells.length; i++) {
|
|
182
|
+
if (cells[i].trim() !== '')
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
return true;
|
|
186
|
+
}
|
|
187
|
+
// Combine mode — a header-only table plus its per-section sibling tables.
|
|
188
|
+
function readCombined($, combine, ignore) {
|
|
189
|
+
const headerTable = $(combine.headerSelector).first();
|
|
190
|
+
if (headerTable.length === 0) {
|
|
191
|
+
return { headers: [], rows: [], warning: `The selector "${combine.headerSelector}" matched no element on the page.` };
|
|
192
|
+
}
|
|
193
|
+
const headerRows = tableRows(headerTable);
|
|
194
|
+
if (headerRows.length === 0)
|
|
195
|
+
return { headers: [], rows: [], warning: NO_TABLE_WARNING };
|
|
196
|
+
const headerCells = headerRows.eq(0).children('th, td');
|
|
197
|
+
const headers = [];
|
|
198
|
+
for (let i = 0; i < headerCells.length; i++)
|
|
199
|
+
headers.push(readCellText(headerCells.eq(i), ignore));
|
|
200
|
+
const allRows = [];
|
|
201
|
+
// Any data rows in the header table itself (a header-only table has none).
|
|
202
|
+
allRows.push(...readTableDataRows(headerRows, headers, ignore, 1));
|
|
203
|
+
// Following-sibling tables that share the header's column count; stop at the
|
|
204
|
+
// first sibling that doesn't (or a non-table sibling) so an unrelated later
|
|
205
|
+
// table with a coincidentally-equal width can't leak in.
|
|
206
|
+
// Walk EVERY following sibling, not just the <table> ones: filtering to
|
|
207
|
+
// tables first would silently skip over an intervening <h2>/<p>, so the
|
|
208
|
+
// documented "stop at the first sibling that doesn't match" could never fire
|
|
209
|
+
// and an unrelated later section's table could leak in.
|
|
210
|
+
const siblings = headerTable.nextAll();
|
|
211
|
+
for (let i = 0; i < siblings.length; i++) {
|
|
212
|
+
const sib = siblings.eq(i);
|
|
213
|
+
if (!sib.is('table'))
|
|
214
|
+
break;
|
|
215
|
+
const sibRows = tableRows(sib);
|
|
216
|
+
if (sibRows.length === 0)
|
|
217
|
+
break;
|
|
218
|
+
const firstRowCols = sibRows.eq(0).children('th, td').length;
|
|
219
|
+
if (firstRowCols !== headers.length)
|
|
220
|
+
break;
|
|
221
|
+
// Every row of a section table is data here (its first row is a section
|
|
222
|
+
// label, NOT a repeated header) — read from index 0.
|
|
223
|
+
allRows.push(...readTableDataRows(sibRows, headers, ignore, 0));
|
|
224
|
+
}
|
|
225
|
+
const rows = combine.dropLabelRows ? allRows.filter((cells) => !isLabelRow(cells)) : allRows;
|
|
226
|
+
if (headers.length === 0 && rows.length === 0)
|
|
227
|
+
return { headers, rows, warning: NO_TABLE_WARNING };
|
|
228
|
+
return { headers, rows };
|
|
112
229
|
}
|
|
113
230
|
// Read one cell's normalized text list from a row selection, using a cell
|
|
114
231
|
// sub-selector (div-grid mode — see GridSelector).
|
|
115
|
-
function gridRowCells(rowSel, cellSelector) {
|
|
232
|
+
function gridRowCells(rowSel, cellSelector, ignore) {
|
|
116
233
|
const cellEls = rowSel.find(cellSelector);
|
|
117
234
|
const cells = [];
|
|
118
235
|
for (let j = 0; j < cellEls.length; j++)
|
|
119
|
-
cells.push(
|
|
236
|
+
cells.push(readCellText(cellEls.eq(j), ignore));
|
|
120
237
|
return cells;
|
|
121
238
|
}
|
|
122
239
|
// Read a single div-grid wrapper into the same {headers, rows} shape a real
|
|
123
240
|
// <table> yields, so every downstream stage (mapColumns/mapRows) is oblivious
|
|
124
241
|
// to which markup the data came from.
|
|
125
|
-
function readGridContainer(container, grid) {
|
|
242
|
+
function readGridContainer(container, grid, ignore) {
|
|
126
243
|
const allRows = container.find(grid.row);
|
|
127
244
|
if (allRows.length === 0)
|
|
128
245
|
return { headers: [], rows: [] };
|
|
@@ -131,10 +248,10 @@ function readGridContainer(container, grid) {
|
|
|
131
248
|
if (grid.headerRow != null) {
|
|
132
249
|
const headerRows = allRows.filter(grid.headerRow);
|
|
133
250
|
if (headerRows.length > 0)
|
|
134
|
-
headers = gridRowCells(headerRows.eq(0), grid.cell);
|
|
251
|
+
headers = gridRowCells(headerRows.eq(0), grid.cell, ignore);
|
|
135
252
|
}
|
|
136
253
|
if (headers.length === 0) {
|
|
137
|
-
headers = gridRowCells(allRows.eq(0), grid.cell);
|
|
254
|
+
headers = gridRowCells(allRows.eq(0), grid.cell, ignore);
|
|
138
255
|
firstRowIsHeader = true;
|
|
139
256
|
}
|
|
140
257
|
const dataRows = [];
|
|
@@ -142,16 +259,20 @@ function readGridContainer(container, grid) {
|
|
|
142
259
|
const rowSel = allRows.eq(i);
|
|
143
260
|
if (grid.headerRow != null && rowSel.is(grid.headerRow))
|
|
144
261
|
continue; // never treat a header row as data
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
262
|
+
// First row already consumed as header — either because no headerRow
|
|
263
|
+
// selector was given, OR because one was given and matched nothing (a
|
|
264
|
+
// typo'd/stale selector must degrade to "first row is the header", never
|
|
265
|
+
// silently emit the header as data row 0).
|
|
266
|
+
if (firstRowIsHeader && i === 0)
|
|
267
|
+
continue;
|
|
268
|
+
const cells = gridRowCells(rowSel, grid.cell, ignore);
|
|
148
269
|
if (cells.length === 0)
|
|
149
270
|
continue;
|
|
150
271
|
dataRows.push(cells);
|
|
151
272
|
}
|
|
152
273
|
return { headers, rows: dataRows };
|
|
153
274
|
}
|
|
154
|
-
function readGrid($, grid) {
|
|
275
|
+
function readGrid($, grid, ignore) {
|
|
155
276
|
const containers = grid.container != null ? $(grid.container) : $.root();
|
|
156
277
|
if (grid.container != null && containers.length === 0) {
|
|
157
278
|
return { headers: [], rows: [], warning: `The selector "${grid.container}" matched no element on the page.` };
|
|
@@ -161,7 +282,7 @@ function readGrid($, grid) {
|
|
|
161
282
|
// pickBestTable uses for real tables). Ties keep the first encountered.
|
|
162
283
|
let best = null;
|
|
163
284
|
for (let i = 0; i < containers.length; i++) {
|
|
164
|
-
const parsed = readGridContainer(containers.eq(i), grid);
|
|
285
|
+
const parsed = readGridContainer(containers.eq(i), grid, ignore);
|
|
165
286
|
if (best === null || parsed.rows.length > best.rows.length)
|
|
166
287
|
best = parsed;
|
|
167
288
|
}
|
|
@@ -190,8 +311,12 @@ function pickBestTable($) {
|
|
|
190
311
|
}
|
|
191
312
|
export function extractTable(html, opts) {
|
|
192
313
|
const $ = load(html);
|
|
314
|
+
const ignore = opts?.cellIgnoreSelectors;
|
|
193
315
|
if (opts?.grid != null) {
|
|
194
|
-
return readGrid($, opts.grid);
|
|
316
|
+
return readGrid($, opts.grid, ignore);
|
|
317
|
+
}
|
|
318
|
+
if (opts?.combine != null) {
|
|
319
|
+
return readCombined($, opts.combine, ignore);
|
|
195
320
|
}
|
|
196
321
|
if (opts?.select != null) {
|
|
197
322
|
const matched = $(opts.select);
|
|
@@ -202,7 +327,7 @@ export function extractTable(html, opts) {
|
|
|
202
327
|
warning: `The selector "${opts.select}" matched no element on the page.`,
|
|
203
328
|
};
|
|
204
329
|
}
|
|
205
|
-
const { headers, rows } = readTable(matched.eq(0));
|
|
330
|
+
const { headers, rows } = readTable(matched.eq(0), ignore);
|
|
206
331
|
if (headers.length === 0 && rows.length === 0) {
|
|
207
332
|
return { headers, rows, warning: NO_TABLE_WARNING };
|
|
208
333
|
}
|
|
@@ -212,5 +337,5 @@ export function extractTable(html, opts) {
|
|
|
212
337
|
if (best === null) {
|
|
213
338
|
return { headers: [], rows: [], warning: NO_TABLE_WARNING };
|
|
214
339
|
}
|
|
215
|
-
return readTable(best);
|
|
340
|
+
return readTable(best, ignore);
|
|
216
341
|
}
|
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;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hazo_scrape",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.1",
|
|
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",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"build:test-app": "npm run build && cd test-app && npm run build"
|
|
36
36
|
},
|
|
37
37
|
"peerDependencies": {
|
|
38
|
-
"hazo_core": "^1.0
|
|
38
|
+
"hazo_core": "^1.3.0",
|
|
39
39
|
"react": "^18.0.0 || ^19.0.0",
|
|
40
40
|
"react-dom": "^18.0.0 || ^19.0.0"
|
|
41
41
|
},
|