hazo_scrape 1.5.2 → 1.6.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,20 @@
1
1
  # hazo_scrape — Change Log
2
2
 
3
+ ## 1.6.0 — 2026-07-25
4
+
5
+ ### Added — `parseNumber` tokenizes a trailing bare cent marker
6
+ - `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.
7
+
8
+ ### Added — `parseDate` optional 2-digit-year support (`opts.yearPivot`)
9
+ - `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`.
10
+ - `mapRows` gains `opts.dateYearPivot?: number`, forwarded verbatim to `parseDate` for every date-typed cell.
11
+
12
+ ### Added — `extractTable` `cellIgnoreSelectors` (strip in-cell noise)
13
+ - `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.
14
+
15
+ ### Added — `extractTable` `combine` mode (header-only table + per-section sibling tables)
16
+ - `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.
17
+
3
18
  ## 1.5.2 — 2026-07-24
4
19
 
5
20
  ### Fixed — `extractTableFromPdf` mid-word text-run splits
@@ -29,10 +29,45 @@ export type GridSelector = {
29
29
  */
30
30
  headerRow?: string;
31
31
  };
32
+ /**
33
+ * Combine-mode selector — for pages that split one logical table across
34
+ * several sibling <table>s: a header-only <table> followed by one <table>
35
+ * per section (e.g. per year), each led by a section-label row. Common on
36
+ * Computershare-style IR pages (e.g. JB Hi-Fi's dividend history). The header
37
+ * comes from `headerSelector`'s first row; data rows are gathered from that
38
+ * 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
+ */
42
+ export type CombineSelector = {
43
+ /** Selector matching the ONE table whose first row supplies the shared headers. */
44
+ headerSelector: string;
45
+ /**
46
+ * Drop "section-label" rows — a row in which only the first cell holds text
47
+ * and every other cell is empty (e.g. a bare "2025" year divider). Off by
48
+ * default. Column-mapping/parsing would already null such a row out, but
49
+ * dropping it keeps the raw row set clean and honest.
50
+ */
51
+ dropLabelRows?: boolean;
52
+ };
32
53
  export type ExtractTableOptions = {
33
54
  select?: string;
34
55
  /** Read a <div>-based pseudo-table instead of a real <table>. Takes precedence over `select`. */
35
56
  grid?: GridSelector;
57
+ /**
58
+ * Combine a header-only table with its per-section sibling tables into one
59
+ * table (see CombineSelector). Takes precedence over `select`; `grid` still
60
+ * wins over this.
61
+ */
62
+ combine?: CombineSelector;
63
+ /**
64
+ * CSS selectors whose matching descendants are removed from every cell
65
+ * BEFORE its text is read — for stripping in-cell noise that corrupts
66
+ * parsing, e.g. `['sup']` to drop footnote-reference superscripts so
67
+ * "100<sup>4</sup>" reads as "100" not "1004". Applies to real-table, grid,
68
+ * and combine modes alike. Omit to read cells verbatim (the default).
69
+ */
70
+ cellIgnoreSelectors?: string[];
36
71
  };
37
72
  export declare function extractTable(html: string, opts?: ExtractTableOptions): ExtractTableResult;
38
73
  //# 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;;;;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"}
@@ -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,14 @@ 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
+ // Read the data rows (everything after the header row) of one real <table>,
84
+ // aligning each cell against `headers` (for the repeated-in-cell-label strip)
85
+ // and removing `ignore` descendants first. Shared by readTable and combine
86
+ // mode so both handle ragged/label/responsive rows identically.
87
+ function readTableDataRows(rows, headers, ignore, startIndex) {
81
88
  const dataRows = [];
82
- for (let i = 0; i < dataRowsSel.length; i++) {
83
- const rowSel = dataRowsSel.eq(i);
89
+ for (let i = startIndex; i < rows.length; i++) {
90
+ const rowSel = rows.eq(i);
84
91
  // A genuine data row has at least one <td>; a pure-<th> row (a section
85
92
  // divider spanning the table) is skipped — prototype behaviour. But the
86
93
  // cells themselves are then read as `th, td`, not `td` alone, so a
@@ -92,7 +99,7 @@ function readTable(tableSel) {
92
99
  const cellEls = rowSel.children('th, td');
93
100
  const cells = [];
94
101
  for (let j = 0; j < cellEls.length; j++) {
95
- let text = normalizeCell(cellEls.eq(j).text());
102
+ let text = readCellText(cellEls.eq(j), ignore);
96
103
  // Responsive-table pattern: some IR pages repeat the column header
97
104
  // INSIDE each cell as a (visually-hidden on desktop) label, e.g. NAB's
98
105
  // "<div>Payment date</div><div>2 July 2026</div>", whose .text()
@@ -108,21 +115,80 @@ function readTable(tableSel) {
108
115
  }
109
116
  dataRows.push(cells);
110
117
  }
111
- return { headers, rows: dataRows };
118
+ return dataRows;
119
+ }
120
+ function readTable(tableSel, ignore) {
121
+ const rows = tableRows(tableSel);
122
+ if (rows.length === 0)
123
+ return { headers: [], rows: [] };
124
+ const headerCells = rows.eq(0).children('th, td');
125
+ const headers = [];
126
+ for (let i = 0; i < headerCells.length; i++) {
127
+ headers.push(readCellText(headerCells.eq(i), ignore));
128
+ }
129
+ return { headers, rows: readTableDataRows(rows, headers, ignore, 1) };
130
+ }
131
+ // True when a row is a "section-label" row: exactly one non-empty cell, and
132
+ // it's the first — e.g. a bare "2025" year divider between per-year tables.
133
+ function isLabelRow(cells) {
134
+ if (cells.length === 0 || cells[0].trim() === '')
135
+ return false;
136
+ for (let i = 1; i < cells.length; i++) {
137
+ if (cells[i].trim() !== '')
138
+ return false;
139
+ }
140
+ return true;
141
+ }
142
+ // Combine mode — a header-only table plus its per-section sibling tables.
143
+ function readCombined($, combine, ignore) {
144
+ const headerTable = $(combine.headerSelector).first();
145
+ if (headerTable.length === 0) {
146
+ return { headers: [], rows: [], warning: `The selector "${combine.headerSelector}" matched no element on the page.` };
147
+ }
148
+ const headerRows = tableRows(headerTable);
149
+ if (headerRows.length === 0)
150
+ return { headers: [], rows: [], warning: NO_TABLE_WARNING };
151
+ const headerCells = headerRows.eq(0).children('th, td');
152
+ const headers = [];
153
+ for (let i = 0; i < headerCells.length; i++)
154
+ headers.push(readCellText(headerCells.eq(i), ignore));
155
+ const allRows = [];
156
+ // Any data rows in the header table itself (a header-only table has none).
157
+ allRows.push(...readTableDataRows(headerRows, headers, ignore, 1));
158
+ // Following-sibling tables that share the header's column count; stop at the
159
+ // first sibling that doesn't (or a non-table sibling) so an unrelated later
160
+ // table with a coincidentally-equal width can't leak in.
161
+ const siblings = headerTable.nextAll('table');
162
+ for (let i = 0; i < siblings.length; i++) {
163
+ const sib = siblings.eq(i);
164
+ const sibRows = tableRows(sib);
165
+ if (sibRows.length === 0)
166
+ break;
167
+ const firstRowCols = sibRows.eq(0).children('th, td').length;
168
+ if (firstRowCols !== headers.length)
169
+ break;
170
+ // Every row of a section table is data here (its first row is a section
171
+ // label, NOT a repeated header) — read from index 0.
172
+ allRows.push(...readTableDataRows(sibRows, headers, ignore, 0));
173
+ }
174
+ const rows = combine.dropLabelRows ? allRows.filter((cells) => !isLabelRow(cells)) : allRows;
175
+ if (headers.length === 0 && rows.length === 0)
176
+ return { headers, rows, warning: NO_TABLE_WARNING };
177
+ return { headers, rows };
112
178
  }
113
179
  // Read one cell's normalized text list from a row selection, using a cell
114
180
  // sub-selector (div-grid mode — see GridSelector).
115
- function gridRowCells(rowSel, cellSelector) {
181
+ function gridRowCells(rowSel, cellSelector, ignore) {
116
182
  const cellEls = rowSel.find(cellSelector);
117
183
  const cells = [];
118
184
  for (let j = 0; j < cellEls.length; j++)
119
- cells.push(normalizeCell(cellEls.eq(j).text()));
185
+ cells.push(readCellText(cellEls.eq(j), ignore));
120
186
  return cells;
121
187
  }
122
188
  // Read a single div-grid wrapper into the same {headers, rows} shape a real
123
189
  // <table> yields, so every downstream stage (mapColumns/mapRows) is oblivious
124
190
  // to which markup the data came from.
125
- function readGridContainer(container, grid) {
191
+ function readGridContainer(container, grid, ignore) {
126
192
  const allRows = container.find(grid.row);
127
193
  if (allRows.length === 0)
128
194
  return { headers: [], rows: [] };
@@ -131,10 +197,10 @@ function readGridContainer(container, grid) {
131
197
  if (grid.headerRow != null) {
132
198
  const headerRows = allRows.filter(grid.headerRow);
133
199
  if (headerRows.length > 0)
134
- headers = gridRowCells(headerRows.eq(0), grid.cell);
200
+ headers = gridRowCells(headerRows.eq(0), grid.cell, ignore);
135
201
  }
136
202
  if (headers.length === 0) {
137
- headers = gridRowCells(allRows.eq(0), grid.cell);
203
+ headers = gridRowCells(allRows.eq(0), grid.cell, ignore);
138
204
  firstRowIsHeader = true;
139
205
  }
140
206
  const dataRows = [];
@@ -144,14 +210,14 @@ function readGridContainer(container, grid) {
144
210
  continue; // never treat a header row as data
145
211
  if (grid.headerRow == null && firstRowIsHeader && i === 0)
146
212
  continue; // first row already consumed as header
147
- const cells = gridRowCells(rowSel, grid.cell);
213
+ const cells = gridRowCells(rowSel, grid.cell, ignore);
148
214
  if (cells.length === 0)
149
215
  continue;
150
216
  dataRows.push(cells);
151
217
  }
152
218
  return { headers, rows: dataRows };
153
219
  }
154
- function readGrid($, grid) {
220
+ function readGrid($, grid, ignore) {
155
221
  const containers = grid.container != null ? $(grid.container) : $.root();
156
222
  if (grid.container != null && containers.length === 0) {
157
223
  return { headers: [], rows: [], warning: `The selector "${grid.container}" matched no element on the page.` };
@@ -161,7 +227,7 @@ function readGrid($, grid) {
161
227
  // pickBestTable uses for real tables). Ties keep the first encountered.
162
228
  let best = null;
163
229
  for (let i = 0; i < containers.length; i++) {
164
- const parsed = readGridContainer(containers.eq(i), grid);
230
+ const parsed = readGridContainer(containers.eq(i), grid, ignore);
165
231
  if (best === null || parsed.rows.length > best.rows.length)
166
232
  best = parsed;
167
233
  }
@@ -190,8 +256,12 @@ function pickBestTable($) {
190
256
  }
191
257
  export function extractTable(html, opts) {
192
258
  const $ = load(html);
259
+ const ignore = opts?.cellIgnoreSelectors;
193
260
  if (opts?.grid != null) {
194
- return readGrid($, opts.grid);
261
+ return readGrid($, opts.grid, ignore);
262
+ }
263
+ if (opts?.combine != null) {
264
+ return readCombined($, opts.combine, ignore);
195
265
  }
196
266
  if (opts?.select != null) {
197
267
  const matched = $(opts.select);
@@ -202,7 +272,7 @@ export function extractTable(html, opts) {
202
272
  warning: `The selector "${opts.select}" matched no element on the page.`,
203
273
  };
204
274
  }
205
- const { headers, rows } = readTable(matched.eq(0));
275
+ const { headers, rows } = readTable(matched.eq(0), ignore);
206
276
  if (headers.length === 0 && rows.length === 0) {
207
277
  return { headers, rows, warning: NO_TABLE_WARNING };
208
278
  }
@@ -212,5 +282,5 @@ export function extractTable(html, opts) {
212
282
  if (best === null) {
213
283
  return { headers: [], rows: [], warning: NO_TABLE_WARNING };
214
284
  }
215
- return readTable(best);
285
+ return readTable(best, ignore);
216
286
  }
@@ -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.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",