hazo_scrape 1.2.0 → 1.4.0

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,29 @@
1
1
  # hazo_scrape — Change Log
2
2
 
3
+ ## 1.4.0 — 2026-07-24
4
+
5
+ ### Added — `parseDate` optional `opts.extractLeading` (+ `mapRows` `opts.dateExtractLeading`)
6
+ - `parseDate(raw, { extractLeading: true })` accepts a date at the START of the cell even when trailing text follows it — e.g. `parseDate('25/06/2024 - special dividend', { formats: ['DD/MM/YYYY'], extractLeading: true })` now returns `'2024-06-25'` (previously `null`, since every date pattern was `^…$`-anchored). Only the leading date token is read; trailing text is ignored, never parsed. Leading junk is still rejected (the date must be the first token), a partial year is guarded against (`'25/06/20245'` → `null`), and the date itself is still matched exactly and disambiguated by the same rules — the "never guess" contract holds. Default off; omitting it preserves the exact prior whole-string behaviour.
7
+ - `mapRows(table, map, keywords, { dateExtractLeading: true })` forwards this to `parseDate` for every date-typed cell (alongside the existing `dateFormats`). Purely additive.
8
+ - Motivation: real IR pages (e.g. WBC's dividend history) staple a label onto a date cell — `"25/06/2024 - special dividend"` — which the strict parser correctly refused, dropping the payment date to `null`. A caller that KNOWS a source does this can now opt in via `SourceRegistryEntry.nuances` without this engine ever guessing.
9
+
10
+ ## 1.3.0 — 2026-07-24
11
+
12
+ ### Added — `extractTable` div-grid mode (`opts.grid`)
13
+ - `extractTable(html, { grid })` reads a "table" built from styled `<div>`s (CSS grid/flex) instead of a real `<table>`. `GridSelector = { container?, row, cell, headerRow? }` — all plain CSS selectors. When `container` matches more than one grid, the wrapper with the most data rows wins (mirrors the existing pickBestTable "the real data grid is the biggest" bias). Motivation: IR pages like CBA's `dividend-information.html` render dividend history as `.complex-table` div-grids that a static fetch sees in markup but `extractTable` (table-only) could not read — surfacing as `0 rows × 0 cols`. Purely additive; omit `grid` for unchanged behavior. Takes precedence over `select`.
14
+
15
+ ### Fixed — `extractTable` row-header alignment + repeated in-cell header labels
16
+ - Data-row cells are now read as `th, td` (not `td` alone), so an accessible table whose first column is a `<th scope="row">` row-label (e.g. NAB's payment-history table) stays column-aligned with its header row instead of shifting every column left by one. Pure-`<th>` section rows (zero `<td>`) are still skipped as before.
17
+ - A leading copy of a cell's own column header baked INTO the cell is stripped (responsive-label pattern: NAB emits `<div>Payment date</div><div>2 July 2026</div>`, so `.text()` collapses to `"Payment date2 July 2026"`). Guarded to an exact non-empty header prefix leaving a non-empty remainder, so a legitimate value is never truncated. Together these were why NAB scraped 90 rows but mapped 0.
18
+
19
+ ### Fixed — `parseNumber` "cents" unit word
20
+ - `parseNumber('85 cents')` now returns `85` (previously `null` — `Number('85 cents')` is `NaN`). Only TOKENIZES the minor-unit word; the cents→dollars SCALING remains the caller's job via a domain `amount_unit` (unchanged, per the file's no-cents-conversion contract). Word-boundaried, so `'recent'` is untouched.
21
+
22
+ ## 1.2.1 — 2026-07-23
23
+
24
+ ### Fixed — republish of 1.2.0's `dist`
25
+ - `1.2.0`'s published tarball had a stale `dist/` — the `mapRows` source fix below was made, but `npm run build` was never re-run before that publish, so the shipped `dist/parse/map_rows.js`/`.d.ts` still only accepted 3 arguments. Caught by `extractor_asx`'s own `tsc --noEmit` failing after bumping the dependency (`Expected 3 arguments, but got 4`). No source changes in this release — just a correct rebuild + republish under a new version (npm doesn't allow overwriting a published version).
26
+
3
27
  ## 1.2.0 — 2026-07-22
4
28
 
5
29
  ### Added — `mapRows` optional `opts.dateFormats`
package/README.md CHANGED
@@ -77,11 +77,14 @@ the first. `opts.dateGuard` drops date-looking headers from any key whose
77
77
  `type !== 'date'`. `opts.required` lists keys that must have at least one
78
78
  candidate for `map.ok` to be `true`.
79
79
 
80
- ### `mapRows(table: { headers; rows }, map: ColumnMap, keywords: ColumnKeywords): MappedRow[]`
80
+ ### `mapRows(table: { headers; rows }, map: ColumnMap, keywords: ColumnKeywords, opts?: { dateFormats?: string[]; dateExtractLeading?: boolean }): MappedRow[]`
81
81
 
82
82
  Joins a `ColumnMap` back onto `table.rows`, producing one `MappedRow` per data
83
83
  row. Each matched key holds an array of `Cell`s (one per candidate header),
84
- each parsed according to that key's declared `type`.
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.
85
88
 
86
89
  ### `parseNumber(raw: string): number | null`
87
90
 
@@ -90,13 +93,21 @@ and `%`, then parses what remains as a number. Returns `null` when nothing
90
93
  parseable is left. Does **not** convert cents to dollars — domain scaling is
91
94
  the caller's job.
92
95
 
93
- ### `parseDate(raw: string, opts?: { formats?: string[] }): string | null`
96
+ ### `parseDate(raw: string, opts?: { formats?: string[]; extractLeading?: boolean }): string | null`
94
97
 
95
98
  Parses a handful of common date text formats (ISO, `D Mon YYYY`, `Mon D,
96
99
  YYYY`, and `D/M/Y` slash dates) into an ISO `yyyy-mm-dd` string. Returns
97
100
  `null` — never a guess — for anything unrecognised or genuinely ambiguous
98
101
  (e.g. `05/03/2026` with no `opts.formats` hint and both components `<= 12`).
99
102
 
103
+ `opts.extractLeading` (default off) accepts a date at the **start** of the cell
104
+ even when trailing text follows it — `parseDate('25/06/2024 - special dividend',
105
+ { formats: ['DD/MM/YYYY'], extractLeading: true })` → `'2024-06-25'`. Only the
106
+ leading date token is read; the trailing text is ignored, never parsed. Leading
107
+ junk is still rejected (the date must be the first token) and the date itself is
108
+ still matched exactly and disambiguated by the same rules — so the "never guess"
109
+ contract holds. For real IR pages that staple a label onto a date cell.
110
+
100
111
  ### Types
101
112
 
102
113
  ```ts
@@ -3,8 +3,36 @@ export type ExtractTableResult = {
3
3
  rows: string[][];
4
4
  warning?: string;
5
5
  };
6
+ /**
7
+ * Div-grid mode selectors — for "tables" built from styled <div>s (CSS
8
+ * grid/flex) instead of a real <table>. A static HTML fetch sees these in
9
+ * the markup even when the page's data table proper is JS-rendered (e.g.
10
+ * CBA's `.complex-table`). All selectors are plain CSS.
11
+ */
12
+ export type GridSelector = {
13
+ /**
14
+ * Element(s) that wrap one grid. When the selector matches MORE than one,
15
+ * the wrapper holding the most data rows wins — mirroring pickBestTable's
16
+ * "the real data grid is the biggest one" bias, so an upcoming-dividend
17
+ * teaser grid never shadows the full history grid. Omit to search the
18
+ * whole document.
19
+ */
20
+ container?: string;
21
+ /** Selector (searched under the container) matching every row — header and data alike. */
22
+ row: string;
23
+ /** Selector (searched under a row) matching each cell. */
24
+ cell: string;
25
+ /**
26
+ * Selector identifying the header row among the `row` matches. Rows that
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.
29
+ */
30
+ headerRow?: string;
31
+ };
6
32
  export type ExtractTableOptions = {
7
33
  select?: string;
34
+ /** Read a <div>-based pseudo-table instead of a real <table>. Takes precedence over `select`. */
35
+ grid?: GridSelector;
8
36
  };
9
37
  export declare function extractTable(html: string, opts?: ExtractTableOptions): ExtractTableResult;
10
38
  //# 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,MAAM,MAAM,mBAAmB,GAAG;IAChC,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB,CAAC;AAwGF,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,mBAAmB,GAAG,kBAAkB,CAyBzF"}
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"}
@@ -80,17 +80,96 @@ function readTable(tableSel) {
80
80
  const dataRowsSel = rows.slice(1);
81
81
  const dataRows = [];
82
82
  for (let i = 0; i < dataRowsSel.length; i++) {
83
- const tds = dataRowsSel.eq(i).children('td');
84
- if (tds.length === 0)
85
- continue; // skip non-data rows (prototype behaviour)
83
+ const rowSel = dataRowsSel.eq(i);
84
+ // A genuine data row has at least one <td>; a pure-<th> row (a section
85
+ // divider spanning the table) is skipped — prototype behaviour. But the
86
+ // cells themselves are then read as `th, td`, not `td` alone, so a
87
+ // row-header cell (<th scope="row">, common in accessible IR tables
88
+ // whose first column labels the row) is kept and the row stays
89
+ // column-aligned with the header row (which is likewise read as th,td).
90
+ if (rowSel.children('td').length === 0)
91
+ continue;
92
+ const cellEls = rowSel.children('th, td');
86
93
  const cells = [];
87
- for (let j = 0; j < tds.length; j++) {
88
- cells.push(normalizeCell(tds.eq(j).text()));
94
+ for (let j = 0; j < cellEls.length; j++) {
95
+ let text = normalizeCell(cellEls.eq(j).text());
96
+ // Responsive-table pattern: some IR pages repeat the column header
97
+ // INSIDE each cell as a (visually-hidden on desktop) label, e.g. NAB's
98
+ // "<div>Payment date</div><div>2 July 2026</div>", whose .text()
99
+ // collapses to "Payment date2 July 2026". Strip a leading copy of this
100
+ // 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)) {
105
+ text = text.slice(header.length).trim();
106
+ }
107
+ cells.push(text);
89
108
  }
90
109
  dataRows.push(cells);
91
110
  }
92
111
  return { headers, rows: dataRows };
93
112
  }
113
+ // Read one cell's normalized text list from a row selection, using a cell
114
+ // sub-selector (div-grid mode — see GridSelector).
115
+ function gridRowCells(rowSel, cellSelector) {
116
+ const cellEls = rowSel.find(cellSelector);
117
+ const cells = [];
118
+ for (let j = 0; j < cellEls.length; j++)
119
+ cells.push(normalizeCell(cellEls.eq(j).text()));
120
+ return cells;
121
+ }
122
+ // Read a single div-grid wrapper into the same {headers, rows} shape a real
123
+ // <table> yields, so every downstream stage (mapColumns/mapRows) is oblivious
124
+ // to which markup the data came from.
125
+ function readGridContainer(container, grid) {
126
+ const allRows = container.find(grid.row);
127
+ if (allRows.length === 0)
128
+ return { headers: [], rows: [] };
129
+ let headers = [];
130
+ let firstRowIsHeader = false;
131
+ if (grid.headerRow != null) {
132
+ const headerRows = allRows.filter(grid.headerRow);
133
+ if (headerRows.length > 0)
134
+ headers = gridRowCells(headerRows.eq(0), grid.cell);
135
+ }
136
+ if (headers.length === 0) {
137
+ headers = gridRowCells(allRows.eq(0), grid.cell);
138
+ firstRowIsHeader = true;
139
+ }
140
+ const dataRows = [];
141
+ for (let i = 0; i < allRows.length; i++) {
142
+ const rowSel = allRows.eq(i);
143
+ if (grid.headerRow != null && rowSel.is(grid.headerRow))
144
+ 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);
148
+ if (cells.length === 0)
149
+ continue;
150
+ dataRows.push(cells);
151
+ }
152
+ return { headers, rows: dataRows };
153
+ }
154
+ function readGrid($, grid) {
155
+ const containers = grid.container != null ? $(grid.container) : $.root();
156
+ if (grid.container != null && containers.length === 0) {
157
+ return { headers: [], rows: [], warning: `The selector "${grid.container}" matched no element on the page.` };
158
+ }
159
+ // When the container selector matches several grids, keep the one with the
160
+ // most data rows (same "the real data is the biggest grid" heuristic
161
+ // pickBestTable uses for real tables). Ties keep the first encountered.
162
+ let best = null;
163
+ for (let i = 0; i < containers.length; i++) {
164
+ const parsed = readGridContainer(containers.eq(i), grid);
165
+ if (best === null || parsed.rows.length > best.rows.length)
166
+ best = parsed;
167
+ }
168
+ if (best === null || (best.headers.length === 0 && best.rows.length === 0)) {
169
+ return { headers: [], rows: [], warning: NO_TABLE_WARNING };
170
+ }
171
+ return best;
172
+ }
94
173
  function pickBestTable($) {
95
174
  const tables = $('table');
96
175
  let best = null;
@@ -111,6 +190,9 @@ function pickBestTable($) {
111
190
  }
112
191
  export function extractTable(html, opts) {
113
192
  const $ = load(html);
193
+ if (opts?.grid != null) {
194
+ return readGrid($, opts.grid);
195
+ }
114
196
  if (opts?.select != null) {
115
197
  const matched = $(opts.select);
116
198
  if (matched.length === 0) {
@@ -1,6 +1,10 @@
1
1
  import type { ColumnKeywords, ColumnMap, MappedRow } from './types.js';
2
+ export interface MapRowsOptions {
3
+ dateFormats?: string[];
4
+ dateExtractLeading?: boolean;
5
+ }
2
6
  export declare function mapRows(table: {
3
7
  headers: string[];
4
8
  rows: string[][];
5
- }, map: ColumnMap, keywords: ColumnKeywords): MappedRow[];
9
+ }, map: ColumnMap, keywords: ColumnKeywords, opts?: MapRowsOptions): MappedRow[];
6
10
  //# sourceMappingURL=map_rows.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"map_rows.d.ts","sourceRoot":"","sources":["../../src/parse/map_rows.ts"],"names":[],"mappings":"AAkBA,OAAO,KAAK,EAAQ,cAAc,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAW7E,wBAAgB,OAAO,CACrB,KAAK,EAAE;IAAE,OAAO,EAAE,MAAM,EAAE,CAAC;IAAC,IAAI,EAAE,MAAM,EAAE,EAAE,CAAA;CAAE,EAC9C,GAAG,EAAE,SAAS,EACd,QAAQ,EAAE,cAAc,GACvB,SAAS,EAAE,CAuBb"}
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"}
@@ -15,18 +15,33 @@
15
15
  // Ragged rows (shorter than a candidate's index) never throw: the raw string
16
16
  // is treated as '' and value is computed from that empty string (null for
17
17
  // number/date, null for text after trim). Rows are taken as-is, never padded.
18
+ //
19
+ // opts.dateFormats (added 2026-07-22): forwarded verbatim to parseDate's
20
+ // `opts.formats` for every date-typed cell. Without it, a genuinely
21
+ // ambiguous slash date (both components <=12, e.g. "09/11/2012") correctly
22
+ // stays null — see parse_date.ts's "never guess" design and this file's own
23
+ // pinned MQG test. A caller that KNOWS the source's date convention (e.g. a
24
+ // SourceRegistry entry's `nuances.date_formats`) can pass it here to resolve
25
+ // what would otherwise be an unresolvable ambiguity. Omitting it preserves
26
+ // the exact prior behavior — this is purely additive.
18
27
  import { parseNumber } from './parse_number.js';
19
28
  import { parseDate } from './parse_date.js';
20
- function cellValue(type, raw) {
29
+ function cellValue(type, raw, dateFormats, dateExtractLeading) {
21
30
  if (type === 'number')
22
31
  return parseNumber(raw);
23
- if (type === 'date')
24
- return parseDate(raw);
32
+ if (type === 'date') {
33
+ return parseDate(raw, {
34
+ ...(dateFormats ? { formats: dateFormats } : {}),
35
+ ...(dateExtractLeading ? { extractLeading: true } : {}),
36
+ });
37
+ }
25
38
  const trimmed = raw.trim();
26
39
  return trimmed === '' ? null : trimmed;
27
40
  }
28
- export function mapRows(table, map, keywords) {
41
+ export function mapRows(table, map, keywords, opts) {
29
42
  const keys = Object.keys(map.matched);
43
+ const dateFormats = opts?.dateFormats;
44
+ const dateExtractLeading = opts?.dateExtractLeading;
30
45
  return table.rows.map((row) => {
31
46
  const mappedRow = {};
32
47
  for (const key of keys) {
@@ -38,7 +53,7 @@ export function mapRows(table, map, keywords) {
38
53
  header: candidate.header,
39
54
  index: candidate.index,
40
55
  raw,
41
- value: cellValue(type, raw),
56
+ value: cellValue(type, raw, dateFormats, dateExtractLeading),
42
57
  };
43
58
  });
44
59
  }
@@ -1,4 +1,5 @@
1
1
  export declare function parseDate(raw: string, opts?: {
2
2
  formats?: string[];
3
+ extractLeading?: boolean;
3
4
  }): string | null;
4
5
  //# 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":"AA6EA,wBAAgB,SAAS,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE;IAAE,OAAO,CAAC,EAAE,MAAM,EAAE,CAAA;CAAE,GAAG,MAAM,GAAG,IAAI,CAoCnF"}
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"}
@@ -8,6 +8,16 @@
8
8
  // blindly assuming day-first. Slash dates are only resolved without a
9
9
  // hint when one ordering is calendar-impossible (a component > 12), or
10
10
  // when the caller supplies `opts.formats` to disambiguate.
11
+ //
12
+ // opts.extractLeading (added 2026-07-24): OFF by default. When true, a date
13
+ // at the START of the cell is accepted even if trailing text follows it
14
+ // (e.g. "25/06/2024 - special dividend" -> "2024-06-25"). Only the leading
15
+ // date token is read; the trailing text is ignored, never parsed. Leading
16
+ // junk is still rejected — the date must be the first token. This preserves
17
+ // the "never guess" contract (the date itself is still matched exactly and
18
+ // still disambiguated by the same rules) while tolerating real IR pages that
19
+ // staple a label onto a date cell. Omitting it preserves exact prior
20
+ // behaviour (a whole-string anchored match) — this is purely additive.
11
21
  const MONTHS = {
12
22
  jan: 1,
13
23
  feb: 2,
@@ -80,13 +90,20 @@ export function parseDate(raw, opts) {
80
90
  const s = String(raw).trim();
81
91
  if (s === '')
82
92
  return null;
93
+ // End-of-token anchor. Strict mode (default) anchors to end-of-string, so
94
+ // any trailing text fails the match. `extractLeading` relaxes that to "no
95
+ // further word char or slash follows the date" — enough to end the date
96
+ // token cleanly (a following space, hyphen-with-space, punctuation, or EOS)
97
+ // without letting a partial number ("2024" out of "20245") slip through.
98
+ const tail = opts?.extractLeading ? '(?![\\w/])' : '$';
99
+ const re = (body) => new RegExp(`^${body}${tail}`);
83
100
  // ISO: yyyy-mm-dd
84
- let m = s.match(/^(\d{4})-(\d{2})-(\d{2})$/);
101
+ let m = s.match(re('(\\d{4})-(\\d{2})-(\\d{2})'));
85
102
  if (m) {
86
103
  return toIsoIfValid(Number(m[1]), Number(m[2]), Number(m[3]));
87
104
  }
88
105
  // "D Mon YYYY" / "D Month YYYY", e.g. "5 Mar 2026", "18 November 2025"
89
- m = s.match(/^(\d{1,2})\s+([A-Za-z]{3,})\s+(\d{4})$/);
106
+ m = s.match(re('(\\d{1,2})\\s+([A-Za-z]{3,})\\s+(\\d{4})'));
90
107
  if (m) {
91
108
  const month = monthFromName(m[2]);
92
109
  if (month == null)
@@ -94,7 +111,7 @@ export function parseDate(raw, opts) {
94
111
  return toIsoIfValid(Number(m[3]), month, Number(m[1]));
95
112
  }
96
113
  // "Mon D, YYYY" / "Month D YYYY", comma optional, e.g. "Mar 5, 2026"
97
- m = s.match(/^([A-Za-z]{3,})\s+(\d{1,2}),?\s+(\d{4})$/);
114
+ m = s.match(re('([A-Za-z]{3,})\\s+(\\d{1,2}),?\\s+(\\d{4})'));
98
115
  if (m) {
99
116
  const month = monthFromName(m[1]);
100
117
  if (month == null)
@@ -104,7 +121,7 @@ export function parseDate(raw, opts) {
104
121
  // Slash dates: D/M/Y or M/D/Y — ambiguous without a hint or a proof by
105
122
  // calendar impossibility (see resolveSlashDate). Two-digit years are
106
123
  // intentionally rejected: the year group below requires exactly 4 digits.
107
- m = s.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/);
124
+ m = s.match(re('(\\d{1,2})\\/(\\d{1,2})\\/(\\d{4})'));
108
125
  if (m) {
109
126
  return resolveSlashDate(Number(m[1]), Number(m[2]), Number(m[3]), opts?.formats);
110
127
  }
@@ -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,CAqBtD"}
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"}
@@ -23,6 +23,12 @@ export function parseNumber(raw) {
23
23
  // Strip thousands separators and percent signs.
24
24
  s = s.replace(/,/g, '');
25
25
  s = s.replace(/%/g, '');
26
+ // Strip a trailing minor-unit word — some IR pages write an amount as
27
+ // "85 cents". This only TOKENIZES ("85 cents" -> "85"); the cents->dollars
28
+ // SCALING remains the caller's job via a domain amount_unit (see the file
29
+ // header's no-cents-conversion note). Word-boundaried so it never touches
30
+ // the digits or an unrelated word like "recent".
31
+ s = s.replace(/\bcents?\b/gi, '');
26
32
  s = s.trim();
27
33
  if (s === '')
28
34
  return null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hazo_scrape",
3
- "version": "1.2.0",
3
+ "version": "1.4.0",
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",