hazo_scrape 1.6.0 → 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,32 @@
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
+
3
30
  ## 1.6.0 — 2026-07-25
4
31
 
5
32
  ### Added — `parseNumber` tokenizes a trailing bare cent marker
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,7 +25,9 @@ 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
  };
@@ -36,8 +38,9 @@ export type GridSelector = {
36
38
  * Computershare-style IR pages (e.g. JB Hi-Fi's dividend history). The header
37
39
  * comes from `headerSelector`'s first row; data rows are gathered from that
38
40
  * table plus its immediately-following sibling <table>s that share its column
39
- * count (stopping at the first sibling that doesn't), so an unrelated later
40
- * table can't leak in.
41
+ * 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.
41
44
  */
42
45
  export type CombineSelector = {
43
46
  /** Selector matching the ONE table whose first row supplies the shared headers. */
@@ -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;;;;;;;;;GASG;AACH,MAAM,MAAM,eAAe,GAAG;IAC5B,mFAAmF;IACnF,cAAc,EAAE,MAAM,CAAC;IACvB;;;;;OAKG;IACH,aAAa,CAAC,EAAE,OAAO,CAAC;CACzB,CAAC;AAEF,MAAM,MAAM,mBAAmB,GAAG;IAChC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,iGAAiG;IACjG,IAAI,CAAC,EAAE,YAAY,CAAC;IACpB;;;;OAIG;IACH,OAAO,CAAC,EAAE,eAAe,CAAC;IAC1B;;;;;;OAMG;IACH,mBAAmB,CAAC,EAAE,MAAM,EAAE,CAAC;CAChC,CAAC;AAgQF,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,mBAAmB,GAAG,kBAAkB,CAkCzF"}
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"}
@@ -80,6 +80,34 @@ function scoreTable(tableSel) {
80
80
  const consistencyRatio = numDataRows > 0 ? numConsistent / numDataRows : 0;
81
81
  return numHeaderCells * 2 + (hasTh ? 5 : 0) + numDataRows * 3 + consistencyRatio * 10;
82
82
  }
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
+ }
83
111
  // Read the data rows (everything after the header row) of one real <table>,
84
112
  // aligning each cell against `headers` (for the repeated-in-cell-label strip)
85
113
  // and removing `ignore` descendants first. Shared by readTable and combine
@@ -97,18 +125,35 @@ function readTableDataRows(rows, headers, ignore, startIndex) {
97
125
  if (rowSel.children('td').length === 0)
98
126
  continue;
99
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
+ }
100
145
  const cells = [];
101
- for (let j = 0; j < cellEls.length; j++) {
146
+ for (let j = offset; j < cellEls.length; j++) {
102
147
  let text = readCellText(cellEls.eq(j), ignore);
103
148
  // Responsive-table pattern: some IR pages repeat the column header
104
149
  // INSIDE each cell as a (visually-hidden on desktop) label, e.g. NAB's
105
150
  // "<div>Payment date</div><div>2 July 2026</div>", whose .text()
106
151
  // collapses to "Payment date2 July 2026". Strip a leading copy of this
107
152
  // cell's own column header so downstream number/date parsing sees just
108
- // the value. Guarded to an exact non-empty header prefix that leaves a
109
- // non-empty remainder, so a legitimate value is never truncated.
110
- const header = headers[j];
111
- 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)) {
112
157
  text = text.slice(header.length).trim();
113
158
  }
114
159
  cells.push(text);
@@ -158,9 +203,15 @@ function readCombined($, combine, ignore) {
158
203
  // Following-sibling tables that share the header's column count; stop at the
159
204
  // first sibling that doesn't (or a non-table sibling) so an unrelated later
160
205
  // table with a coincidentally-equal width can't leak in.
161
- const siblings = headerTable.nextAll('table');
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();
162
211
  for (let i = 0; i < siblings.length; i++) {
163
212
  const sib = siblings.eq(i);
213
+ if (!sib.is('table'))
214
+ break;
164
215
  const sibRows = tableRows(sib);
165
216
  if (sibRows.length === 0)
166
217
  break;
@@ -208,8 +259,12 @@ function readGridContainer(container, grid, ignore) {
208
259
  const rowSel = allRows.eq(i);
209
260
  if (grid.headerRow != null && rowSel.is(grid.headerRow))
210
261
  continue; // never treat a header row as data
211
- if (grid.headerRow == null && firstRowIsHeader && i === 0)
212
- continue; // first row already consumed as header
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;
213
268
  const cells = gridRowCells(rowSel, grid.cell, ignore);
214
269
  if (cells.length === 0)
215
270
  continue;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hazo_scrape",
3
- "version": "1.6.0",
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
  },