@toolpath/tool-scraper 0.1.0 → 2.1.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.
Files changed (76) hide show
  1. package/README.md +41 -11
  2. package/dist/columns.d.ts +64 -0
  3. package/dist/columns.js +68 -0
  4. package/dist/conventions.d.ts +107 -15
  5. package/dist/conventions.js +126 -15
  6. package/dist/errors.d.ts +30 -0
  7. package/dist/errors.js +30 -0
  8. package/dist/families/emuge.d.ts +185 -0
  9. package/dist/families/emuge.js +163 -0
  10. package/dist/families/harvey.d.ts +1782 -0
  11. package/dist/families/harvey.js +1328 -0
  12. package/dist/families/index.js +5 -2
  13. package/dist/families/kennametal.d.ts +21 -0
  14. package/dist/families/kennametal.js +10 -0
  15. package/dist/families/maritool.d.ts +120 -0
  16. package/dist/families/maritool.js +175 -0
  17. package/dist/family.d.ts +35 -1
  18. package/dist/family.js +28 -0
  19. package/dist/identity.d.ts +18 -0
  20. package/dist/identity.js +46 -0
  21. package/dist/index.d.ts +16 -0
  22. package/dist/index.js +16 -0
  23. package/dist/measure.d.ts +112 -0
  24. package/dist/measure.js +130 -0
  25. package/dist/node/cli.d.ts +3 -0
  26. package/dist/node/cli.js +158 -3
  27. package/dist/node/main.js +0 -0
  28. package/dist/records.d.ts +214 -12
  29. package/dist/records.js +140 -5
  30. package/dist/registry.d.ts +56 -1
  31. package/dist/registry.js +79 -2
  32. package/dist/scrape.d.ts +15 -0
  33. package/dist/scrape.js +24 -0
  34. package/dist/vendors/destinytool/records.d.ts +13 -3
  35. package/dist/vendors/destinytool/records.js +49 -37
  36. package/dist/vendors/emuge/index.d.ts +17 -0
  37. package/dist/vendors/emuge/index.js +17 -0
  38. package/dist/vendors/emuge/records.d.ts +230 -0
  39. package/dist/vendors/emuge/records.js +543 -0
  40. package/dist/vendors/emuge/scrape.d.ts +227 -0
  41. package/dist/vendors/emuge/scrape.js +358 -0
  42. package/dist/vendors/emuge/value.d.ts +75 -0
  43. package/dist/vendors/emuge/value.js +116 -0
  44. package/dist/vendors/harvey/catalog.d.ts +53 -0
  45. package/dist/vendors/harvey/catalog.js +120 -0
  46. package/dist/vendors/harvey/header.d.ts +89 -0
  47. package/dist/vendors/harvey/header.js +185 -0
  48. package/dist/vendors/harvey/index.d.ts +21 -0
  49. package/dist/vendors/harvey/index.js +21 -0
  50. package/dist/vendors/harvey/lexicon.d.ts +73 -0
  51. package/dist/vendors/harvey/lexicon.js +126 -0
  52. package/dist/vendors/harvey/literal.d.ts +68 -0
  53. package/dist/vendors/harvey/literal.js +214 -0
  54. package/dist/vendors/harvey/records.d.ts +79 -0
  55. package/dist/vendors/harvey/records.js +163 -0
  56. package/dist/vendors/harvey/scrape.d.ts +187 -0
  57. package/dist/vendors/harvey/scrape.js +483 -0
  58. package/dist/vendors/harvey/value.d.ts +74 -0
  59. package/dist/vendors/harvey/value.js +119 -0
  60. package/dist/vendors/kennametal/family.d.ts +119 -0
  61. package/dist/vendors/kennametal/family.js +155 -0
  62. package/dist/vendors/kennametal/index.d.ts +1 -0
  63. package/dist/vendors/kennametal/index.js +1 -0
  64. package/dist/vendors/kennametal/records.d.ts +29 -4
  65. package/dist/vendors/kennametal/records.js +93 -26
  66. package/dist/vendors/kennametal/scrape.d.ts +25 -2
  67. package/dist/vendors/kennametal/scrape.js +28 -3
  68. package/dist/vendors/maritool/catalog.d.ts +81 -0
  69. package/dist/vendors/maritool/catalog.js +132 -0
  70. package/dist/vendors/maritool/index.d.ts +10 -0
  71. package/dist/vendors/maritool/index.js +10 -0
  72. package/dist/vendors/maritool/scrape.d.ts +297 -0
  73. package/dist/vendors/maritool/scrape.js +593 -0
  74. package/dist/vendors/regofix/scrape.d.ts +8 -11
  75. package/dist/vendors/regofix/scrape.js +21 -36
  76. package/package.json +31 -7
@@ -0,0 +1,483 @@
1
+ /**
2
+ * One Harvey Tool product page -> one row per orderable part.
3
+ *
4
+ * The page is server-rendered HTML with its variant data inlined as a
5
+ * JavaScript literal; `docs/HARVEY_PRODUCT_TABLE.md` records how that was found
6
+ * and what was tried first. This module is the join between the two halves — a
7
+ * `<thead>` in the DOM and a `tableData<N>` in a `<script>` — plus the one
8
+ * structural thing Harvey's table does that no other vendor here does.
9
+ *
10
+ * ## One HTML row is up to nine orderable parts
11
+ *
12
+ * The table is a matrix. The `a*` cells are the geometry, shared; after them
13
+ * comes a coating × flute grid where each coating is a `colspan`-ed header and
14
+ * every non-empty cell under it is a different tool number at the same geometry.
15
+ * **5,033 HTML rows become 12,799 CSV rows**, and that explosion is the whole
16
+ * risk in this adapter: a coating or flute count read off the wrong column
17
+ * produces a CSV that is the right length and wrong throughout.
18
+ *
19
+ * ## Harvey checks it for us, 5,033 times per scrape
20
+ *
21
+ * Every row carries an `atc` cell whose `j` is the add-to-cart payload, and that
22
+ * payload independently lists every tool number on the row **in the same order
23
+ * as the non-empty tool-number cells**:
24
+ *
25
+ * ```json
26
+ * [{"T":"690508","C":"690508","Q":"1"},{"T":"679608","C":"679608","Q":"1"}]
27
+ * ```
28
+ *
29
+ * So the explosion is checked against the vendor's own list on every row rather
30
+ * than once against a hand count, and a grid that has shifted by one column
31
+ * cannot pass. That check is {@link checkCartPayload}, and it is the reason the
32
+ * declared `rows` counts in `families/harvey.ts` being seeded rather than
33
+ * hand-counted is survivable.
34
+ *
35
+ * ## Two columns are synthesised, and neither has a cell to copy
36
+ *
37
+ * `Coating` is a fact about which *header* a tool number sits under, and
38
+ * `FLUTES` is too on the matrix tables. Both are encoded in column position and
39
+ * nowhere else, which is why they are built here rather than lifted, and why the
40
+ * coating vocabulary is a closed list in `lexicon.ts`.
41
+ *
42
+ * `FLUTES` is one column either way — synthesised from the sub-label on a matrix
43
+ * table and lifted from the row's own column on a `TOOL #` one — so the vendor's
44
+ * `FLUTES` column is taken out of the geometry list rather than written twice.
45
+ *
46
+ * Everything else in the CSV is the vendor's own display string, verbatim —
47
+ * `.250 (1/4)` reaches the file as `.250 (1/4)`. `value.ts` is what resolves one
48
+ * when a record is built.
49
+ */
50
+ import { CAD_COLUMN, CAD_DXF_COLUMN, DESCRIPTION_COLUMN, dimensionalColumn, } from '../../conventions.js';
51
+ import { VendorResponseError } from '../../errors.js';
52
+ import { consoleWarn } from '../../scrape.js';
53
+ import { flatHeader, isJunkLabel } from './header.js';
54
+ import { readLiteral } from './literal.js';
55
+ import { parseValue } from './value.js';
56
+ import { CART_LABEL, FLUTES_LABEL, PRICE_LABEL, TOOL_NUMBER_LABEL, checkCoating, flutesInLabel, isDimensional, } from './lexicon.js';
57
+ export const BASE = 'https://www.harveytool.com';
58
+ /** `cols1`..`cols10` and `tableData1`..`tableData10` exist on every page. */
59
+ export const MAX_TABLES = 10;
60
+ /** The CSV columns this adapter synthesises or lifts from the page itself. */
61
+ export const TOOL_NUMBER_COLUMN = 'Tool #';
62
+ export const COATING_COLUMN = 'Coating';
63
+ export const FLUTES_COLUMN = 'FLUTES';
64
+ export const PRICE_COLUMN = 'PRICE_USD';
65
+ /**
66
+ * The suffix an unlabelled annotation column takes, after the column it
67
+ * annotates.
68
+ *
69
+ * Harvey gives these no header at all — see `docs/HARVEY_PRODUCT_TABLE.md` §5.3
70
+ * — so a name has to be supplied, and naming one for what it annotates is the
71
+ * only honest option available. Dropping them instead would lose a published
72
+ * column; leaving them unnamed would collide with the column they follow.
73
+ */
74
+ export const RATIO_SUFFIX = 'RATIO';
75
+ /**
76
+ * The suffix an unlabelled column takes when it is *not* a ratio.
77
+ *
78
+ * Four such columns exist, on two families, and Harvey renders them in white
79
+ * text: two carry the badge `LONG` on a handful of rows and two are entirely
80
+ * empty. Naming them `RATIO` would be a claim about their contents that is
81
+ * false, so which suffix a column gets is decided from the whole page's data
82
+ * rather than from its header — see {@link ratioColumnKeys}.
83
+ */
84
+ export const NOTE_SUFFIX = 'NOTE';
85
+ /** True where the cell carries a link, i.e. where it names a real part. */
86
+ const LINKED = /<a\b[^>]*\bhref\s*=/i;
87
+ /** `<a href="...">14916</a>` -> `14916`. These cells carry no entities. */
88
+ export function cellText(html) {
89
+ return (html ?? '')
90
+ .replace(/<[^>]*>/g, '')
91
+ .replace(/\s+/g, ' ')
92
+ .trim();
93
+ }
94
+ /** `$148.40 ` -> `148.40`. The column already says which currency. */
95
+ export function priceOf(html) {
96
+ return cellText(html).replace(/^\$/, '').trim();
97
+ }
98
+ /**
99
+ * The CSV column each geometry column is written under.
100
+ *
101
+ * Three cases, and the second and third are the ones worth knowing:
102
+ *
103
+ * 1. **A label of its own** keeps it.
104
+ * 2. **A repeated label with distinct meaningful sub-labels** takes the
105
+ * sub-label — `Interference Depth At Wall Angle*` is six real columns
106
+ * headed `0°` through `4°`, not one column and five annotations.
107
+ * 3. **A repeated label with a junk sub-label**, or a column whose top label is
108
+ * junk outright, is the vendor's ratio annotation of the column before it and
109
+ * takes {@link RATIO_SUFFIX}.
110
+ */
111
+ function geometryColumns(family, keys, header, unit, ratioKeys, warn) {
112
+ const positions = keys
113
+ .map((key, index) => ({ key, head: header[index] }))
114
+ .filter(({ key, head }) => key !== 'atc' && /^a\d+$/.test(key) && head.top !== CART_LABEL)
115
+ // The flute count leaves the geometry list and comes back as one
116
+ // synthesised `FLUTES` column, because half these tables state it in a
117
+ // column and half in a coating group's sub-label. Keeping both would put
118
+ // two columns called `FLUTES` in one CSV.
119
+ .filter(({ head }) => head.top !== FLUTES_LABEL);
120
+ const byLabel = new Map();
121
+ for (const { head } of positions) {
122
+ if (isJunkLabel(head.top))
123
+ continue;
124
+ byLabel.set(head.top, [...(byLabel.get(head.top) ?? []), head]);
125
+ }
126
+ /** True where every occurrence of this label has its own real sub-label. */
127
+ const subLabelled = new Map();
128
+ for (const [label, heads] of byLabel) {
129
+ const subs = heads.map((h) => h.sub ?? '');
130
+ subLabelled.set(label, heads.length > 1 && subs.every((s) => !isJunkLabel(s)) && new Set(subs).size === subs.length);
131
+ }
132
+ const columns = [];
133
+ const seen = new Set();
134
+ for (const { key, head } of positions) {
135
+ let label;
136
+ let dimensional;
137
+ if (isJunkLabel(head.top)) {
138
+ const previous = columns[columns.length - 1];
139
+ if (previous === undefined) {
140
+ throw new VendorResponseError(family, `opens with an unlabelled column (${key}) — there is nothing for it to annotate`);
141
+ }
142
+ label = `${previous.label} ${ratioKeys.has(key) ? RATIO_SUFFIX : NOTE_SUFFIX}`;
143
+ dimensional = false;
144
+ warn(` WARNING: ${family}: column ${key} has no header of its own; ` +
145
+ `written as ${JSON.stringify(label)}`);
146
+ }
147
+ else if (subLabelled.get(head.top) === true) {
148
+ label = `${head.top} ${head.sub}`;
149
+ dimensional = isDimensional(family, head.top);
150
+ }
151
+ else if (seen.has(head.top)) {
152
+ label = `${head.top} ${RATIO_SUFFIX}`;
153
+ dimensional = false;
154
+ }
155
+ else {
156
+ label = head.top;
157
+ dimensional = isDimensional(family, head.top);
158
+ }
159
+ seen.add(head.top);
160
+ const column = dimensional ? dimensionalColumn(label, unit) : label;
161
+ if (columns.some((c) => c.column === column)) {
162
+ throw new VendorResponseError(family, `has two columns both written as ${JSON.stringify(column)} — the ` +
163
+ `header no longer tells them apart`);
164
+ }
165
+ columns.push({ key, column, label });
166
+ }
167
+ return columns;
168
+ }
169
+ /**
170
+ * The coating groups of one table, with each part column's flute count.
171
+ *
172
+ * A group runs from its first tool-number column to its price column. Every
173
+ * group on all 80 tables has exactly one price and it comes last, so a group
174
+ * that reaches its end without one is refused rather than given a blank price:
175
+ * the grid would have shifted, and every part in it would be reading a
176
+ * neighbour's cell.
177
+ */
178
+ function coatingGroups(family, keys, header, warn) {
179
+ const groups = [];
180
+ let coating = null;
181
+ let parts = [];
182
+ for (const [index, key] of keys.entries()) {
183
+ const head = header[index];
184
+ const sub = head.sub ?? '';
185
+ if (/^s\d+$/.test(key)) {
186
+ if (coating === null) {
187
+ coating = checkCoating(family, head.top);
188
+ parts = [];
189
+ }
190
+ else if (coating !== head.top) {
191
+ throw new VendorResponseError(family, `starts coating group ${JSON.stringify(head.top)} before ` +
192
+ `${JSON.stringify(coating)} has a ${PRICE_LABEL} column`);
193
+ }
194
+ parts.push({ key, flutes: sub === TOOL_NUMBER_LABEL ? null : flutesInLabel(sub) });
195
+ continue;
196
+ }
197
+ if (/^p\d+$/.test(key)) {
198
+ if (coating === null) {
199
+ throw new VendorResponseError(family, `has a price column (${key}) under no coating group`);
200
+ }
201
+ if (sub !== PRICE_LABEL) {
202
+ throw new VendorResponseError(family, `has a price column (${key}) sub-headed ${JSON.stringify(sub)} rather ` +
203
+ `than ${JSON.stringify(PRICE_LABEL)}`);
204
+ }
205
+ groups.push({ coating, parts, priceKey: key });
206
+ coating = null;
207
+ }
208
+ }
209
+ if (coating !== null) {
210
+ throw new VendorResponseError(family, `leaves coating group ${JSON.stringify(coating)} without a price column`);
211
+ }
212
+ return fillBlankFluteLabels(family, groups, warn);
213
+ }
214
+ /**
215
+ * Fill a flute sub-label Harvey rendered as `&nbsp;` from a sibling group.
216
+ *
217
+ * Two tables in the catalog do this — `EndMill-008` table 2 and `EndMill-018`
218
+ * table 2 — and in both the neighbouring coating groups label the same slot
219
+ * correctly. A sibling qualifies only when it is the same width *and* agrees on
220
+ * every other slot, so the fill is a copy of a row that is demonstrably the same
221
+ * shape rather than of whichever group happened to be nearby. Two siblings that
222
+ * disagree is a hard failure: there is no way to pick, and picking would put a
223
+ * flute count on a real part on no evidence.
224
+ */
225
+ function fillBlankFluteLabels(family, groups, warn) {
226
+ return groups.map((group) => {
227
+ // All stated is the matrix pattern with nothing missing; none stated is the
228
+ // `TOOL #` pattern, where the row's own FLUTES column is the source and a
229
+ // null here is not a gap. Only a mixture is a blank label to fill.
230
+ const stated = group.parts.filter((p) => p.flutes !== null).length;
231
+ if (stated === 0 || stated === group.parts.length)
232
+ return group;
233
+ const parts = group.parts.map((part, slot) => {
234
+ if (part.flutes !== null)
235
+ return part;
236
+ const candidates = new Set();
237
+ for (const other of groups) {
238
+ if (other === group || other.parts.length !== group.parts.length)
239
+ continue;
240
+ const agrees = group.parts.every((p, i) => i === slot || p.flutes === null || p.flutes === other.parts[i]?.flutes);
241
+ const found = other.parts[slot]?.flutes;
242
+ if (agrees && found != null)
243
+ candidates.add(found);
244
+ }
245
+ if (candidates.size !== 1) {
246
+ throw new VendorResponseError(family, `has a blank flute label in the ${JSON.stringify(group.coating)} group ` +
247
+ `at slot ${slot}, and ${candidates.size === 0 ? 'no' : 'more than one'} ` +
248
+ `sibling group of the same shape states one`);
249
+ }
250
+ const flutes = [...candidates][0];
251
+ warn(` WARNING: ${family}: the ${JSON.stringify(group.coating)} group's flute ` +
252
+ `label at slot ${slot} is blank; read as ${flutes} from a sibling group`);
253
+ return { ...part, flutes };
254
+ });
255
+ return { ...group, parts };
256
+ });
257
+ }
258
+ /** Read one table's plan off its `cols<N>` keys and its flattened header. */
259
+ export function planTable(family, keys, header, unit, options = {}) {
260
+ const { warn = consoleWarn, ratioKeys = new Set() } = options;
261
+ if (keys.length !== header.length) {
262
+ throw new VendorResponseError(family, `declares ${keys.length} data columns but its header flattens to ` +
263
+ `${header.length} — the two are positional and no longer line up`);
264
+ }
265
+ const geometry = geometryColumns(family, keys, header, unit, ratioKeys, warn);
266
+ const groups = coatingGroups(family, keys, header, warn);
267
+ if (groups.length === 0) {
268
+ throw new VendorResponseError(family, 'publishes no coating group — there are no parts to read');
269
+ }
270
+ const flutesIndex = keys.findIndex((key, i) => /^a\d+$/.test(key) && header[i].top === FLUTES_LABEL);
271
+ return {
272
+ geometry,
273
+ flutesKey: flutesIndex === -1 ? null : keys[flutesIndex],
274
+ matrix: groups.some((g) => g.parts.some((p) => p.flutes !== null)),
275
+ groups,
276
+ };
277
+ }
278
+ /**
279
+ * Every part the row's own add-to-cart payload lists, in cell order.
280
+ *
281
+ * `T` and `C` differ on the 36 parts whose printed number carries a footnote
282
+ * marker: `C` keeps it, `T` does not. Both are read, and each does a different
283
+ * job — `C` is what the cell says and is therefore what the cell can be checked
284
+ * against, `T` is Harvey's own clean part number and is therefore what the CSV
285
+ * records. Deriving the second from the first with a regex would work today and
286
+ * be this package's guess rather than the vendor's statement.
287
+ *
288
+ * `Q` is `"1"` on all 12,799 entries and is not read.
289
+ */
290
+ export function cartEntries(family, row) {
291
+ const payload = row['atc']?.j;
292
+ if (typeof payload !== 'string' || payload === '') {
293
+ throw new VendorResponseError(family, 'has a row with no add-to-cart payload to check against');
294
+ }
295
+ let parsed;
296
+ try {
297
+ parsed = JSON.parse(payload);
298
+ }
299
+ catch (error) {
300
+ throw new VendorResponseError(family, `has an unreadable add-to-cart payload: ${error.message}`);
301
+ }
302
+ return parsed.map((entry) => ({ number: entry.T ?? '', printed: entry.C ?? entry.T ?? '' }));
303
+ }
304
+ /** Refuse an explosion the vendor's own payload does not agree with. */
305
+ export function checkCartPayload(family, printed, cart) {
306
+ if (printed.length === cart.length && printed.every((t, i) => t === cart[i].printed))
307
+ return;
308
+ throw new VendorResponseError(family, `read [${printed.join(', ')}] off a row whose own add-to-cart payload ` +
309
+ `lists [${cart.map((entry) => entry.printed).join(', ')}] — the coating ` +
310
+ `grid has shifted`);
311
+ }
312
+ /**
313
+ * The unlabelled columns of a page whose cells are all ratio annotations.
314
+ *
315
+ * Decided across the whole page rather than per table, because the tables merge
316
+ * into one CSV and a column cannot be named two things. `EndMill-006` is why: a
317
+ * column is `LONG` on four rows of its first table and empty throughout its
318
+ * second.
319
+ */
320
+ export function ratioColumnKeys(tables) {
321
+ const unlabelled = new Set();
322
+ const other = new Set();
323
+ for (const table of tables) {
324
+ table.keys.forEach((key, index) => {
325
+ const head = table.header[index];
326
+ if (head === undefined || !/^a\d+$/.test(key) || !isJunkLabel(head.top))
327
+ return;
328
+ unlabelled.add(key);
329
+ for (const row of table.rows) {
330
+ const text = cellText(row[key]?.d);
331
+ if (text !== '' && parseValue(text).ratio === null)
332
+ other.add(key);
333
+ }
334
+ });
335
+ }
336
+ for (const key of other)
337
+ unlabelled.delete(key);
338
+ return unlabelled;
339
+ }
340
+ function readPage(html, what) {
341
+ const model = readLiteral(html, 'viewModel')?.simFileViewModel;
342
+ const productCode = model?.productCode;
343
+ if (!productCode) {
344
+ throw new VendorResponseError(what, 'has no viewModel.simFileViewModel.productCode');
345
+ }
346
+ const cad = new Map();
347
+ for (const variant of model.variantSimFileViewModel ?? []) {
348
+ if (variant.variantName)
349
+ cad.set(variant.variantName, variant);
350
+ }
351
+ // Table ids drop the `HT-` the product code carries: `HT-Harvey-EndMill-008`
352
+ // heads a table with `id="Harvey-EndMill-008_1"`.
353
+ const prefix = productCode.replace(/^HT-/, '');
354
+ const tables = [];
355
+ for (let n = 1; n <= MAX_TABLES; n++) {
356
+ const keys = (readLiteral(html, `cols${n}`) ?? [])
357
+ .map((column) => column.data ?? '')
358
+ .filter(Boolean);
359
+ if (keys.length === 0)
360
+ continue;
361
+ const rows = readLiteral(html, `tableData${n}`) ?? [];
362
+ tables.push({ keys, rows, header: flatHeader(html, `${prefix}_${n}`) });
363
+ }
364
+ if (tables.length === 0) {
365
+ throw new VendorResponseError(what, 'declares no populated table — the page changed shape');
366
+ }
367
+ return { productCode, productTitle: model.productTitle ?? '', cad, tables };
368
+ }
369
+ /**
370
+ * One product page's HTML -> one row per orderable part.
371
+ *
372
+ * Pure, and separate from {@link scrapeProduct} so the whole matrix explosion is
373
+ * testable from a fixture string with no fetcher anywhere near it.
374
+ *
375
+ * A page's tables differ only in published tolerance — which is dropped — so
376
+ * they merge into one CSV. Their column labels are required to agree, because
377
+ * two tables that no longer publish the same columns is a page that has been
378
+ * restructured, and merging them would interleave two different geometries under
379
+ * one header.
380
+ */
381
+ export function parseProductPage(html, source, options) {
382
+ const { unit, warn = consoleWarn } = options;
383
+ const page = readPage(html, source);
384
+ const family = page.productCode;
385
+ const ratioKeys = ratioColumnKeys(page.tables);
386
+ const plans = page.tables.map((table) => planTable(family, table.keys, table.header, unit, { warn, ratioKeys }));
387
+ const geometry = plans[0].geometry.map((column) => column.column);
388
+ for (const plan of plans.slice(1)) {
389
+ const other = plan.geometry.map((column) => column.column);
390
+ if (other.length !== geometry.length || other.some((c, i) => c !== geometry[i])) {
391
+ throw new VendorResponseError(family, `publishes [${geometry.join(', ')}] on one table and [${other.join(', ')}] ` +
392
+ `on another — the page's tables no longer share a header`);
393
+ }
394
+ }
395
+ const flutes = plans.some((plan) => plan.matrix || plan.flutesKey !== null);
396
+ const header = [
397
+ TOOL_NUMBER_COLUMN,
398
+ DESCRIPTION_COLUMN,
399
+ COATING_COLUMN,
400
+ ...(flutes ? [FLUTES_COLUMN] : []),
401
+ ...geometry,
402
+ PRICE_COLUMN,
403
+ CAD_COLUMN,
404
+ CAD_DXF_COLUMN,
405
+ ];
406
+ const rows = [];
407
+ page.tables.forEach((table, index) => {
408
+ const plan = plans[index];
409
+ for (const source_ of table.rows) {
410
+ const shared = {};
411
+ for (const column of plan.geometry) {
412
+ shared[column.column] = cellText(source_[column.key]?.d);
413
+ }
414
+ const rowFlutes = plan.flutesKey === null ? '' : cellText(source_[plan.flutesKey]?.d);
415
+ // Two lists, and the split is the point. `printed` is every non-empty
416
+ // tool-number cell, in order, and is what the row's own add-to-cart
417
+ // payload is checked against. `parts` is the subset that is a real part.
418
+ const printed = [];
419
+ const parts = [];
420
+ for (const group of plan.groups) {
421
+ const price = priceOf(source_[group.priceKey]?.d);
422
+ for (const part of group.parts) {
423
+ const cell = source_[part.key]?.d ?? '';
424
+ const text = cellText(cell);
425
+ if (text === '')
426
+ continue;
427
+ const slot = printed.length;
428
+ printed.push(text);
429
+ if (!LINKED.test(cell)) {
430
+ // 26 cells across two families carry the marketing string
431
+ // `25x Diameter!` in a tool-number column, rendered in red, with no
432
+ // link and no CAD model — and Harvey's own cart payload lists them
433
+ // as though they were orderable. The link is the structural
434
+ // difference; a rule that matched the text would miss the next one.
435
+ warn(` WARNING: ${family}: the ${JSON.stringify(group.coating)} column ` +
436
+ `holds ${JSON.stringify(text)} with no product link — not a part`);
437
+ continue;
438
+ }
439
+ // The sub-label is per part and the FLUTES column is per row, so the
440
+ // sub-label wins where both exist. They agree on all four tables that
441
+ // publish both; a disagreement is Harvey having changed one of them.
442
+ const stated = part.flutes === null ? null : String(part.flutes);
443
+ if (stated !== null && rowFlutes !== '' && stated !== rowFlutes) {
444
+ warn(` WARNING: ${family}: ${text} sits under a ${stated}-flute header ` +
445
+ `on a row whose FLUTES column says ${rowFlutes}`);
446
+ }
447
+ parts.push({ slot, coating: group.coating, price, flutes: stated ?? rowFlutes });
448
+ }
449
+ }
450
+ const cart = cartEntries(family, source_);
451
+ checkCartPayload(family, printed, cart);
452
+ for (const part of parts) {
453
+ const toolNumber = cart[part.slot].number;
454
+ const variant = page.cad.get(toolNumber);
455
+ rows.push({
456
+ ...shared,
457
+ [TOOL_NUMBER_COLUMN]: toolNumber,
458
+ [DESCRIPTION_COLUMN]: page.productTitle,
459
+ [COATING_COLUMN]: part.coating,
460
+ ...(flutes ? { [FLUTES_COLUMN]: part.flutes } : {}),
461
+ [PRICE_COLUMN]: part.price,
462
+ [CAD_COLUMN]: variant?.variantStepFileLink ?? '',
463
+ [CAD_DXF_COLUMN]: variant?.variantDxfFileLink ?? '',
464
+ });
465
+ }
466
+ }
467
+ });
468
+ return { header, rows, source, familyCode: page.productCode };
469
+ }
470
+ /** One product page's URL. */
471
+ export function productUrl(path) {
472
+ return BASE + path;
473
+ }
474
+ /**
475
+ * Scrape one product page.
476
+ *
477
+ * The one network call in this module, and the seam every test replaces —
478
+ * everything below it parses a string.
479
+ */
480
+ export async function scrapeProduct(fetcher, path, options) {
481
+ const url = productUrl(path);
482
+ return parseProductPage(await fetcher.text(url), url, options);
483
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * One display cell -> a number, or an honest reason there is none.
3
+ *
4
+ * Harvey publishes values the way a printed catalog does, because the table is
5
+ * a printed catalog: `.1250 (1/8)` is a decimal with the fraction a machinist
6
+ * orders by beside it, `1-1/2` is a mixed number, `3 mm` is a metric shank on
7
+ * an imperial page, and `-` means the column does not apply to that row.
8
+ *
9
+ * **One rule covers the whole grammar: the value is the leading token, and
10
+ * anything in parentheses is Harvey's own equivalent annotation.** Every shape
11
+ * measured across all 12,799 parts is in `docs/HARVEY_PRODUCT_TABLE.md` §4;
12
+ * this module is that section as code.
13
+ *
14
+ * ## Read `d`, never `v`
15
+ *
16
+ * Each cell also carries `v`, which looks pre-parsed and is the wrong field: it
17
+ * changes unit basis *within a single row*. On the metric ball page, `v` is the
18
+ * inch equivalent for the cutter diameter and the raw millimetre number for the
19
+ * length of cut. Anything reading it gets a clean conversion with wrong numbers
20
+ * in it — the exact failure `conventions.UNIT_SUFFIX` exists to prevent.
21
+ *
22
+ * ## A stated unit wins over the column's
23
+ *
24
+ * 46 cells across six otherwise-imperial families state a metric value outright
25
+ * — `3 mm` in a `SHANK DIAMETER` column on an inch page, a metric-shank tool
26
+ * listed among imperial ones. {@link dimension} converts those into the family's
27
+ * declared unit and warns, naming the part. It does **not** treat a
28
+ * parenthesised `(3 mm)` that way: that is an annotation on an inch value, and
29
+ * converting it would turn `.1181 in` into `3 in`.
30
+ */
31
+ import type { UnitSystem } from '../../conventions.js';
32
+ import { type Warn } from '../../scrape.js';
33
+ /** What one display cell says. */
34
+ export interface HarveyValue {
35
+ /** The leading numeric token, in whatever {@link stated} says. */
36
+ readonly value: number | null;
37
+ /** The unit the token states outright. Null where it states none. */
38
+ readonly stated: UnitSystem | null;
39
+ /** True where the token is an angle in degrees rather than a length. */
40
+ readonly degrees: boolean;
41
+ /** Harvey's parenthesised equivalent, verbatim — `1/8`, `3 mm`, `N.P.T.`. */
42
+ readonly annotation: string | null;
43
+ /** The multiplier a `(1.5x)` cell states, and nothing else. */
44
+ readonly ratio: number | null;
45
+ /** Text with no numeric reading at all: `I`, `II`, `III`, `LONG`. */
46
+ readonly code: string | null;
47
+ }
48
+ /**
49
+ * One cell's display text, read.
50
+ *
51
+ * An empty cell and a `-` both come back as {@link NOTHING}. They mean slightly
52
+ * different things to a reader of the catalog — "nothing published" and "does
53
+ * not apply" — and the same thing to anything building a record, which is that
54
+ * there is no number here and 0 is not a substitute for one.
55
+ */
56
+ export declare function parseValue(display: string): HarveyValue;
57
+ /**
58
+ * One cell as a length in `unit`, or null where it publishes none.
59
+ *
60
+ * `measure.asLength` makes both calls — convert-and-warn a cell whose stated
61
+ * unit disagrees with the family's, refuse an angle in a dimensional column —
62
+ * because they are the same two calls for every vendor and were a verbatim copy
63
+ * here and in `vendors/emuge/value.ts` until 2026-09-01. What is Harvey's is
64
+ * above: that `degrees` is a field of its own rather than a member of
65
+ * `stated`, and that a parenthesised `(3 mm)` is an annotation on an inch value
66
+ * and never the value itself.
67
+ *
68
+ * The 46 cells this converts are metric-shank tools listed among imperial ones
69
+ * — real parts somebody can order, which is why they are converted rather than
70
+ * dropped.
71
+ */
72
+ export declare function dimension(display: string, unit: UnitSystem, what: string, warn?: Warn): number | null;
73
+ /** One cell as a whole count — a flute or tooth number. Null where blank. */
74
+ export declare function count(display: string): number | null;
@@ -0,0 +1,119 @@
1
+ /**
2
+ * One display cell -> a number, or an honest reason there is none.
3
+ *
4
+ * Harvey publishes values the way a printed catalog does, because the table is
5
+ * a printed catalog: `.1250 (1/8)` is a decimal with the fraction a machinist
6
+ * orders by beside it, `1-1/2` is a mixed number, `3 mm` is a metric shank on
7
+ * an imperial page, and `-` means the column does not apply to that row.
8
+ *
9
+ * **One rule covers the whole grammar: the value is the leading token, and
10
+ * anything in parentheses is Harvey's own equivalent annotation.** Every shape
11
+ * measured across all 12,799 parts is in `docs/HARVEY_PRODUCT_TABLE.md` §4;
12
+ * this module is that section as code.
13
+ *
14
+ * ## Read `d`, never `v`
15
+ *
16
+ * Each cell also carries `v`, which looks pre-parsed and is the wrong field: it
17
+ * changes unit basis *within a single row*. On the metric ball page, `v` is the
18
+ * inch equivalent for the cutter diameter and the raw millimetre number for the
19
+ * length of cut. Anything reading it gets a clean conversion with wrong numbers
20
+ * in it — the exact failure `conventions.UNIT_SUFFIX` exists to prevent.
21
+ *
22
+ * ## A stated unit wins over the column's
23
+ *
24
+ * 46 cells across six otherwise-imperial families state a metric value outright
25
+ * — `3 mm` in a `SHANK DIAMETER` column on an inch page, a metric-shank tool
26
+ * listed among imperial ones. {@link dimension} converts those into the family's
27
+ * declared unit and warns, naming the part. It does **not** treat a
28
+ * parenthesised `(3 mm)` that way: that is an annotation on an inch value, and
29
+ * converting it would turn `.1181 in` into `3 in`.
30
+ */
31
+ import { asCount, asLength, fractionValue } from '../../measure.js';
32
+ import { consoleWarn } from '../../scrape.js';
33
+ /**
34
+ * Footnote references Harvey appends to a value or a tool number. 62 cells
35
+ * carry one; each points at a note printed under the table, and none of them is
36
+ * part of the number.
37
+ */
38
+ const FOOTNOTE = /[*!†]+$/;
39
+ /** Harvey's own equivalent, in parentheses at the end: `(1/8)`, `(3 mm)`. */
40
+ const ANNOTATION = /\s*\(([^)]*)\)\s*$/;
41
+ /** `(1.5x)`, `(30x)` — the vendor's reach- or length-to-diameter ratio. */
42
+ const RATIO = /^(\d*\.?\d+)x$/;
43
+ /**
44
+ * A mixed number, a simple fraction or a decimal, with an optional unit.
45
+ *
46
+ * The alternation is ordered longest-first on purpose: `\d*\.?\d+` alone
47
+ * matches the `3` of `3-3/4` and would leave the rest unread, which parses a
48
+ * 3.75-inch tool as a 3-inch one.
49
+ */
50
+ const NUMBER = /^(\d+-\d+\/\d+|\d*\.?\d+\/\d+|\d*\.?\d+)\s*(mm|°)?$/;
51
+ const NOTHING = {
52
+ value: null,
53
+ stated: null,
54
+ degrees: false,
55
+ annotation: null,
56
+ ratio: null,
57
+ code: null,
58
+ };
59
+ /**
60
+ * One cell's display text, read.
61
+ *
62
+ * An empty cell and a `-` both come back as {@link NOTHING}. They mean slightly
63
+ * different things to a reader of the catalog — "nothing published" and "does
64
+ * not apply" — and the same thing to anything building a record, which is that
65
+ * there is no number here and 0 is not a substitute for one.
66
+ */
67
+ export function parseValue(display) {
68
+ const text = display.trim().replace(FOOTNOTE, '').trim();
69
+ if (text === '' || text === '-')
70
+ return NOTHING;
71
+ const annotated = ANNOTATION.exec(text);
72
+ const annotation = annotated?.[1]?.trim() ?? null;
73
+ const head = annotated ? text.slice(0, annotated.index).trim() : text;
74
+ if (head === '') {
75
+ const ratio = annotation === null ? null : RATIO.exec(annotation);
76
+ if (ratio)
77
+ return { ...NOTHING, ratio: Number(ratio[1]) };
78
+ return { ...NOTHING, annotation };
79
+ }
80
+ const number = NUMBER.exec(head);
81
+ // `measure.fractionValue` is stricter than {@link NUMBER} in one place — it
82
+ // refuses a division by zero — so a token that matched here can still have no
83
+ // reading, and that is a code cell rather than a number.
84
+ const value = number === null ? null : fractionValue(number[1]);
85
+ if (number === null || value === null)
86
+ return { ...NOTHING, annotation, code: head };
87
+ const unit = number[2];
88
+ return {
89
+ value,
90
+ stated: unit === 'mm' ? 'millimeters' : null,
91
+ degrees: unit === '°',
92
+ annotation,
93
+ ratio: null,
94
+ code: null,
95
+ };
96
+ }
97
+ /**
98
+ * One cell as a length in `unit`, or null where it publishes none.
99
+ *
100
+ * `measure.asLength` makes both calls — convert-and-warn a cell whose stated
101
+ * unit disagrees with the family's, refuse an angle in a dimensional column —
102
+ * because they are the same two calls for every vendor and were a verbatim copy
103
+ * here and in `vendors/emuge/value.ts` until 2026-09-01. What is Harvey's is
104
+ * above: that `degrees` is a field of its own rather than a member of
105
+ * `stated`, and that a parenthesised `(3 mm)` is an annotation on an inch value
106
+ * and never the value itself.
107
+ *
108
+ * The 46 cells this converts are metric-shank tools listed among imperial ones
109
+ * — real parts somebody can order, which is why they are converted rather than
110
+ * dropped.
111
+ */
112
+ export function dimension(display, unit, what, warn = consoleWarn) {
113
+ const { value, stated, degrees } = parseValue(display);
114
+ return asLength({ value, stated: degrees ? 'degrees' : stated }, display, unit, what, warn);
115
+ }
116
+ /** One cell as a whole count — a flute or tooth number. Null where blank. */
117
+ export function count(display) {
118
+ return asCount(parseValue(display));
119
+ }