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 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: string, opts?: { select?: string }): { headers: string[]; rows: string[][]; warning?: string }`
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. 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.
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: { headers; rows }, map: ColumnMap, keywords: ColumnKeywords, opts?: { dateFormats?: string[]; dateExtractLeading?: boolean }): MappedRow[]`
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` and
85
- `opts.dateExtractLeading` are forwarded verbatim to `parseDate`'s `opts.formats`
86
- and `opts.extractLeading` for every date-typed cell; both default off, so
87
- omitting `opts` preserves the exact prior behaviour.
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;;;;OAIG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB,CAAC;AAEF,MAAM,MAAM,mBAAmB,GAAG;IAChC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,iGAAiG;IACjG,IAAI,CAAC,EAAE,YAAY,CAAC;CACrB,CAAC;AAwLF,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,mBAAmB,GAAG,kBAAkB,CA6BzF"}
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
- 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);
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 = 0; i < dataRowsSel.length; i++) {
83
- const rowSel = dataRowsSel.eq(i);
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 = 0; j < cellEls.length; j++) {
95
- let text = normalizeCell(cellEls.eq(j).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. Guarded to an exact non-empty header prefix that leaves a
102
- // non-empty remainder, so a legitimate value is never truncated.
103
- const header = headers[j];
104
- if (header != null && header !== '' && text.length > header.length && text.startsWith(header)) {
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 { headers, rows: dataRows };
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(normalizeCell(cellEls.eq(j).text()));
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
- if (grid.headerRow == null && firstRowIsHeader && i === 0)
146
- continue; // first row already consumed as header
147
- const cells = gridRowCells(rowSel, grid.cell);
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
  }
@@ -2,6 +2,7 @@ import type { ColumnKeywords, ColumnMap, MappedRow } from './types.js';
2
2
  export interface MapRowsOptions {
3
3
  dateFormats?: string[];
4
4
  dateExtractLeading?: boolean;
5
+ dateYearPivot?: number;
5
6
  }
6
7
  export declare function mapRows(table: {
7
8
  headers: string[];
@@ -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;CAC9B;AAmBD,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,CAyBb"}
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"}
@@ -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,5 +1,6 @@
1
1
  export declare function parseDate(raw: string, opts?: {
2
2
  formats?: string[];
3
3
  extractLeading?: boolean;
4
+ yearPivot?: number;
4
5
  }): string | null;
5
6
  //# sourceMappingURL=parse_date.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"parse_date.d.ts","sourceRoot":"","sources":["../../src/parse/parse_date.ts"],"names":[],"mappings":"AAuFA,wBAAgB,SAAS,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE;IAAE,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;IAAC,cAAc,CAAC,EAAE,OAAO,CAAA;CAAE,GAAG,MAAM,GAAG,IAAI,CA4C7G"}
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"}
@@ -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
- m = s.match(re('(\\d{1,2})\\s+([A-Za-z]{3,})\\s+(\\d{4})'));
129
+ // "D Mon YYYY" / "D Month YYYY", e.g. "5 Mar 2026", "18 November 2025".
130
+ // The year is 24 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
- return toIsoIfValid(Number(m[3]), month, Number(m[1]));
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
- return toIsoIfValid(Number(m[3]), month, Number(m[2]));
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,CA4BtD"}
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.5.2",
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.1",
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
  },