@toolpath/tool-scraper 0.1.0 → 2.0.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 (68) hide show
  1. package/README.md +41 -11
  2. package/dist/columns.d.ts +62 -0
  3. package/dist/columns.js +62 -0
  4. package/dist/conventions.d.ts +89 -15
  5. package/dist/conventions.js +108 -15
  6. package/dist/families/emuge.d.ts +183 -0
  7. package/dist/families/emuge.js +159 -0
  8. package/dist/families/harvey.d.ts +1782 -0
  9. package/dist/families/harvey.js +1328 -0
  10. package/dist/families/index.js +5 -2
  11. package/dist/families/kennametal.d.ts +21 -0
  12. package/dist/families/kennametal.js +10 -0
  13. package/dist/families/maritool.d.ts +120 -0
  14. package/dist/families/maritool.js +175 -0
  15. package/dist/family.d.ts +35 -1
  16. package/dist/family.js +28 -0
  17. package/dist/identity.d.ts +18 -0
  18. package/dist/identity.js +46 -0
  19. package/dist/index.d.ts +16 -0
  20. package/dist/index.js +16 -0
  21. package/dist/measure.d.ts +112 -0
  22. package/dist/measure.js +130 -0
  23. package/dist/node/cli.d.ts +3 -0
  24. package/dist/node/cli.js +152 -2
  25. package/dist/node/main.js +0 -0
  26. package/dist/records.d.ts +169 -12
  27. package/dist/records.js +116 -5
  28. package/dist/registry.d.ts +31 -1
  29. package/dist/registry.js +40 -1
  30. package/dist/scrape.d.ts +15 -0
  31. package/dist/scrape.js +24 -0
  32. package/dist/vendors/destinytool/records.d.ts +13 -3
  33. package/dist/vendors/destinytool/records.js +41 -37
  34. package/dist/vendors/emuge/index.d.ts +17 -0
  35. package/dist/vendors/emuge/index.js +17 -0
  36. package/dist/vendors/emuge/records.d.ts +150 -0
  37. package/dist/vendors/emuge/records.js +375 -0
  38. package/dist/vendors/emuge/scrape.d.ts +227 -0
  39. package/dist/vendors/emuge/scrape.js +358 -0
  40. package/dist/vendors/emuge/value.d.ts +75 -0
  41. package/dist/vendors/emuge/value.js +116 -0
  42. package/dist/vendors/harvey/catalog.d.ts +53 -0
  43. package/dist/vendors/harvey/catalog.js +120 -0
  44. package/dist/vendors/harvey/header.d.ts +89 -0
  45. package/dist/vendors/harvey/header.js +185 -0
  46. package/dist/vendors/harvey/index.d.ts +21 -0
  47. package/dist/vendors/harvey/index.js +21 -0
  48. package/dist/vendors/harvey/lexicon.d.ts +73 -0
  49. package/dist/vendors/harvey/lexicon.js +126 -0
  50. package/dist/vendors/harvey/literal.d.ts +68 -0
  51. package/dist/vendors/harvey/literal.js +214 -0
  52. package/dist/vendors/harvey/records.d.ts +79 -0
  53. package/dist/vendors/harvey/records.js +163 -0
  54. package/dist/vendors/harvey/scrape.d.ts +187 -0
  55. package/dist/vendors/harvey/scrape.js +483 -0
  56. package/dist/vendors/harvey/value.d.ts +74 -0
  57. package/dist/vendors/harvey/value.js +119 -0
  58. package/dist/vendors/kennametal/records.d.ts +13 -4
  59. package/dist/vendors/kennametal/records.js +61 -26
  60. package/dist/vendors/maritool/catalog.d.ts +81 -0
  61. package/dist/vendors/maritool/catalog.js +132 -0
  62. package/dist/vendors/maritool/index.d.ts +10 -0
  63. package/dist/vendors/maritool/index.js +10 -0
  64. package/dist/vendors/maritool/scrape.d.ts +297 -0
  65. package/dist/vendors/maritool/scrape.js +593 -0
  66. package/dist/vendors/regofix/scrape.d.ts +8 -11
  67. package/dist/vendors/regofix/scrape.js +21 -36
  68. package/package.json +31 -7
@@ -0,0 +1,593 @@
1
+ /**
2
+ * MariTool category listings -> toolholding rows.
3
+ *
4
+ * The fifth vendor and the first whose transport needed no discovery at all:
5
+ * `www.maritool.com` is an osCommerce-family storefront that server-renders
6
+ * everything, so the whole transport is a paced `GET`. There is no JSON API,
7
+ * no sitemap and no application bundle to read — see
8
+ * `docs/MARITOOL_CATALOG.md` for what was tried and what the tree looks like.
9
+ *
10
+ * ## Two sources, and both are needed
11
+ *
12
+ * **The roster** is a leaf category's listing page. Every row carries the
13
+ * part number (`Part#:`), the store's `products_id`, the product name and the
14
+ * CAD assets, and the page states its own row total — `(of 51 products)` —
15
+ * which is the second opinion `receipts.checkRows` gets for free from a
16
+ * hand-counted `rows` and which nothing here computes from the rows it just
17
+ * collected.
18
+ *
19
+ * **The geometry** is a `Product Specifications` name/value table on each
20
+ * part's own page, so this is one request per part. The table's labels are
21
+ * MariTool's own and are carried into the CSV verbatim, which is the standing
22
+ * rule; the header is their union in first-seen order, because which keys a
23
+ * part publishes is a function of its style. A shrink-fit holder states
24
+ * `Shank Size` and `Nose Diameter`, a collet chuck states `Collet Size` and
25
+ * `Collet Grip Range`, and a hydraulic chuck states `Hydraulic Type`.
26
+ *
27
+ * ## What the vendor gets wrong
28
+ *
29
+ * Four faults found on 2026-08-29, all reported as warnings rather than
30
+ * fixed — two disagreeing vendor cells cannot say which one is wrong, and a
31
+ * scraper that corrects one becomes a place tool data is authored by hand.
32
+ * This is the same call `vendors/regofix/scrape.ts` made on its three.
33
+ *
34
+ * 1. **`BT40-ER32-60` publishes no `Taper` row at all**, alone among the 529
35
+ * parts in scope. Its row is kept with `taper` and `contact` empty rather
36
+ * than dropped or filled in from its part number: the CSV is a receipt, and
37
+ * the hole is what the vendor published.
38
+ * 2. **`Collet Size` carries a collet *nut* designation on two parts.**
39
+ * `CAT40-ER25-3.0MD` and `BT30-ER25-60M` both state `ER25M`, and `ER25M` is
40
+ * not a collet series — `HSK40E-ER16-3.0M` puts exactly that shape of value
41
+ * in its own `Collet Nut` cell, which is the column it belongs in. The
42
+ * string is written into `CST` as designated, so it joins to no collet
43
+ * family, and it is warned about. Widening it to `ER25` would offer a
44
+ * machinist a collet that may not seat.
45
+ * 3. **`Collet Size` is spaced inconsistently within one style** — `ER 11`
46
+ * and `ER11` are both published. {@link colletSeries} closes the space,
47
+ * because `CST` is a join key and two spellings of one series join to
48
+ * nothing.
49
+ * 4. **Two parts publish no `Product Specifications` table**, both in the
50
+ * CAT50 `Collet Chucks` leaf. They state their geometry as English prose in
51
+ * a `Product Info` bullet list instead — *"Gage length is 100mm (3.93
52
+ * inch)"* — and that is a sentence, not a table. They are skipped with a
53
+ * named warning; a regex over the prose would be this package authoring
54
+ * tool data by hand.
55
+ *
56
+ * ## Where the units are
57
+ *
58
+ * `Gage Length` is metric on some parts and imperial on others **inside one
59
+ * CSV, and inside one category page**: `HSK40E-ER11-40` gages `40mm` where
60
+ * `HSK40E-ER16-3.0M` gages `3.0` inches, and both are rows of
61
+ * `c23_46_1552_1558`. So no MariTool family declares a `unit`, and the gage
62
+ * length is promoted into an `L1_in`/`L1_mm` pair with exactly one cell filled
63
+ * — the shape `regofix.colletRow` already gives `Square_in`/`Square_mm`.
64
+ *
65
+ * **Nothing is converted.** The vendor's own imperial conversion is unusable
66
+ * (its prose calls 40 mm "1.6 inches" where the figure is 1.5748), and
67
+ * computing one here would put a number in the file that the vendor never
68
+ * published. The raw `Gage Length` cell is kept beside the pair.
69
+ *
70
+ * Every other measured cell — `Shank Size`, `Nose Diameter`, `Collet Grip
71
+ * Range` — stays verbatim under MariTool's own label, unsuffixed and
72
+ * unpromoted. That is deliberate: `conventions.dimensionalColumn` takes its
73
+ * suffix from a family's declared `unit`, and these families have none to give
74
+ * it. An adapter choosing `_in` for those cells itself is exactly the mistake
75
+ * a declared `unit` exists to prevent, and `HSK40E-SF.125-45` is the row that
76
+ * proves it — gaged in millimetres, bored in inches.
77
+ */
78
+ import { Parser } from 'htmlparser2';
79
+ import { CAD_COLUMN, CAD_DXF_COLUMN, COLLET_SERIES_COLUMN, CONTACT_COLUMN, DESCRIPTION_COLUMN, GAGE_COLUMNS, } from '../../conventions.js';
80
+ import { VendorResponseError } from '../../errors.js';
81
+ import { compare } from '../../order.js';
82
+ import { consoleWarn, pause, REQUEST_DELAY_MS, unionHeader, } from '../../scrape.js';
83
+ export const BASE = 'https://www.maritool.com';
84
+ /**
85
+ * The CSV columns this adapter builds rather than lifts from a spec table.
86
+ *
87
+ * `products_id` is the store's internal id and an ordinary column: a
88
+ * re-created product would change it, and every guid minted off it with it —
89
+ * which is why identity is the part number. It is carried anyway because it is
90
+ * what a re-fetch of one part is addressed by.
91
+ */
92
+ export const MATERIAL_COLUMN = 'Material Number';
93
+ export const STORE_ID_COLUMN = 'products_id';
94
+ export const TAPER_COLUMN = 'taper';
95
+ export const CLAMPING_COLUMN = 'clamping';
96
+ export const STYLE_COLUMN = 'style';
97
+ /** MariTool's own label for the cell `conventions.GAGE_COLUMNS` is promoted from. */
98
+ export const GAGE_LABEL = 'Gage Length';
99
+ /** MariTool's own label for the cell {@link COLLET_SERIES_COLUMN} comes from. */
100
+ export const COLLET_SIZE_LABEL = 'Collet Size';
101
+ /** MariTool's own label for the cell {@link TAPER_FORMS} is looked up by. */
102
+ export const TAPER_LABEL = 'Taper';
103
+ /**
104
+ * Every `Taper` cell this package is willing to read, and what it means.
105
+ *
106
+ * A closed table rather than a pattern, and keyed on the cell upper-cased
107
+ * because MariTool publishes the dual-contact suffix in two casings
108
+ * (`CAT50 DUAL CONTACT` and `CAT50 Dual Contact` are both in the catalog). An
109
+ * unlisted value throws naming itself: a spindle interface this package cannot
110
+ * name is a guess about which machine a holder fits, which is the call
111
+ * `regofix.CONTACT_BY_FORM` made on `BT-OM 30`.
112
+ *
113
+ * **`contact` is `face` on every HSK size, and MariTool does not say so.** It
114
+ * is the interface's definition rather than a property of the part: an HSK
115
+ * shank is a hollow taper that seats on the flange face at the same time as
116
+ * the cone, which is what DIN 69893 / ISO 12164 specify and what the `A`, `E`
117
+ * and `F` forms vary the flange of rather than the contact. MariTool marks
118
+ * `DUAL CONTACT` only on its 7/24 tapers, where it is an option, and there is
119
+ * nothing to mark on an HSK because there is no single-contact HSK to
120
+ * distinguish it from.
121
+ */
122
+ export const TAPER_FORMS = {
123
+ BT30: { taper: 'BT30', contact: 'taper' },
124
+ 'BT30 DUAL CONTACT': { taper: 'BT30', contact: 'face' },
125
+ BT40: { taper: 'BT40', contact: 'taper' },
126
+ 'BT40 DUAL CONTACT': { taper: 'BT40', contact: 'face' },
127
+ CAT40: { taper: 'CAT40', contact: 'taper' },
128
+ 'CAT40 DUAL CONTACT': { taper: 'CAT40', contact: 'face' },
129
+ CAT50: { taper: 'CAT50', contact: 'taper' },
130
+ 'CAT50 DUAL CONTACT': { taper: 'CAT50', contact: 'face' },
131
+ HSK25E: { taper: 'HSK25E', contact: 'face' },
132
+ HSK40E: { taper: 'HSK40E', contact: 'face' },
133
+ HSK50A: { taper: 'HSK50A', contact: 'face' },
134
+ HSK50E: { taper: 'HSK50E', contact: 'face' },
135
+ HSK63A: { taper: 'HSK63A', contact: 'face' },
136
+ HSK63F: { taper: 'HSK63F', contact: 'face' },
137
+ HSK80F: { taper: 'HSK80F', contact: 'face' },
138
+ HSK100A: { taper: 'HSK100A', contact: 'face' },
139
+ };
140
+ /** A collet series `CST` can join a collet family on: `ER11`, `ER32`. */
141
+ const COLLET_SERIES = /^ER\d+$/;
142
+ /**
143
+ * A `Gage Length` cell: a number, an optional unit, an optional nose form.
144
+ *
145
+ * All five shapes MariTool publishes, and the last two are the ones a simpler
146
+ * pattern misses: `3.0`, `40mm`, `3.5"`, `7.8 Inches`, `120mm Tapered`. The
147
+ * qualifier alternatives — `Tapered`, `Slim`, `Slim Nose`, `Slim Tapered` —
148
+ * are captured only so they can be dropped; see {@link parseGageLength}.
149
+ *
150
+ * `inches` leads the unit alternation because a regex alternation is ordered
151
+ * and `in` would otherwise match the first two letters of `Inches` and leave
152
+ * `ches` as a nose form.
153
+ */
154
+ const GAGE_CELL = /^(\d+(?:\.\d+)?)\s*(inches|inch|in|mm|")?\s*(.*?)$/i;
155
+ /** `(of 51 products)` — the vendor's own count of a leaf's roster. */
156
+ const ROSTER_TOTAL = /\(of\s*<b>\s*(\d+)\s*<\/b>\s*products\)/i;
157
+ /**
158
+ * `.../p341/CAT40-ER11-2.5-.../product_info.html` — the store id is `341`.
159
+ *
160
+ * **The slug can hold a slash**, so this cannot require one path segment
161
+ * between the id and the filename: MariTool builds the slug out of the product
162
+ * name and does not escape it, which puts
163
+ * `.../p29006/CAT50-3/4-TAPERED-NOSE-SHRINK-FIT-TOOL-HOLDER-.750-5.0/product_info.html`
164
+ * in the catalog. A single-segment pattern matches neither of the two CAT50
165
+ * shrink-fit holders and leaves their rows with no page to fetch.
166
+ */
167
+ const PRODUCT_PATH = /\/p(\d+)\/[^?#]*\/product_info\.html$/;
168
+ /** The `<p>` a listing row states its part number on. */
169
+ const PART_NUMBER_LINE = /^Part#:\s*(.+)$/;
170
+ /** The product page's header over its downloads block. */
171
+ const DOWNLOADS_HEADER = /^Available Downloads for\s+(.+)$/;
172
+ /**
173
+ * A leaf category's listing page.
174
+ *
175
+ * The slug in a rewritten URL is ignored by the store, so this uses the
176
+ * platform's own unrewritten form and invents no slug: three URL forms reach
177
+ * the same page and only this one is derivable from a cPath alone.
178
+ */
179
+ export function categoryUrl(cPath, page = 1) {
180
+ const url = `${BASE}/index.php?cPath=${cPath}`;
181
+ return page > 1 ? `${url}&page=${page}` : url;
182
+ }
183
+ /** Whitespace collapsed, the way a browser renders a run of it. */
184
+ function squash(text) {
185
+ return text.replace(/\s+/g, ' ').trim();
186
+ }
187
+ /**
188
+ * One listing page's roster row total and its rendered rows.
189
+ *
190
+ * The rows are read off `<tr class="product-info">`, which is the vendor's own
191
+ * marker for one, and the store id off the product link rather than off the
192
+ * add-to-cart form's hidden input: the link is the first thing in the row and
193
+ * is also what the next request needs.
194
+ */
195
+ export function parseListing(html) {
196
+ const total = ROSTER_TOTAL.exec(html);
197
+ if (total === null) {
198
+ throw new VendorResponseError('listing page', 'states no "(of N products)" count — the page changed shape, and that ' +
199
+ 'count is the only independent check on the rows collected');
200
+ }
201
+ const rows = [];
202
+ let row = null;
203
+ // A depth counter rather than a flag: a row holds nested tables, and a flag
204
+ // would clear on the first inner `</tr>`.
205
+ let depth = 0;
206
+ // Where the text handler is currently writing, if anywhere.
207
+ let sink = null;
208
+ let text = '';
209
+ const parser = new Parser({
210
+ onopentag: (tag, attribs) => {
211
+ const classes = (attribs['class'] ?? '').split(/\s+/);
212
+ if (tag === 'tr' && classes.includes('product-info') && depth === 0) {
213
+ row = { productsId: '', partNumber: '', name: '', productUrl: '', assets: {} };
214
+ depth = 1;
215
+ return;
216
+ }
217
+ if (row === null)
218
+ return;
219
+ if (tag === 'tr')
220
+ depth++;
221
+ if (tag === 'a') {
222
+ const href = attribs['href'] ?? '';
223
+ const path = PRODUCT_PATH.exec(href);
224
+ if (path !== null) {
225
+ // The row links its part twice — once round the thumbnail and once
226
+ // round the name — and only the second carries text. Both give the
227
+ // same id, so the first wins and the second is where the name is.
228
+ if (row.productsId === '') {
229
+ row.productsId = path[1] ?? '';
230
+ row.productUrl = href;
231
+ }
232
+ else if (row.name === '') {
233
+ sink = 'name';
234
+ text = '';
235
+ }
236
+ return;
237
+ }
238
+ if (classes.includes('asset-code')) {
239
+ sink = { asset: href };
240
+ text = '';
241
+ }
242
+ return;
243
+ }
244
+ if (tag === 'p') {
245
+ sink = 'part';
246
+ text = '';
247
+ }
248
+ },
249
+ ontext: (chunk) => {
250
+ if (sink !== null)
251
+ text += chunk;
252
+ },
253
+ onclosetag: (tag) => {
254
+ if (row === null)
255
+ return;
256
+ if (sink !== null && (tag === 'a' || tag === 'p')) {
257
+ const value = squash(text);
258
+ if (sink === 'name') {
259
+ row.name = value;
260
+ }
261
+ else if (sink === 'part') {
262
+ const stated = PART_NUMBER_LINE.exec(value);
263
+ // Every row states one, but the same `<p>` shape also holds
264
+ // `Brand:` and `Available Downloads`, so it is matched rather than
265
+ // taken positionally.
266
+ if (stated !== null && row.partNumber === '')
267
+ row.partNumber = stated[1] ?? '';
268
+ }
269
+ else {
270
+ // The link's own text is the vendor's code for the format.
271
+ row.assets[value.toUpperCase()] = sink.asset;
272
+ }
273
+ sink = null;
274
+ }
275
+ if (tag === 'tr') {
276
+ depth--;
277
+ if (depth === 0) {
278
+ // A row this cannot read is a changed page, not a part with less
279
+ // data: without a part number it has no identity and without a
280
+ // link it has no geometry, and passing either on as an empty
281
+ // string reaches the network as a request for nothing.
282
+ if (row.partNumber === '' || row.productUrl === '') {
283
+ throw new VendorResponseError(row.productsId === '' ? 'a listing row' : `products_id ${row.productsId}`, `states no ${row.partNumber === '' ? 'Part# line' : 'product link'} — ` +
284
+ `the listing changed shape`);
285
+ }
286
+ rows.push(row);
287
+ row = null;
288
+ }
289
+ }
290
+ },
291
+ }, { decodeEntities: true });
292
+ parser.write(html);
293
+ parser.end();
294
+ return { total: Number(total[1]), rows };
295
+ }
296
+ /**
297
+ * Every part in one leaf category, paged until the vendor's own count is met.
298
+ *
299
+ * **The count is the gate, and it is the vendor's rather than ours.** A roster
300
+ * that stopped a page early is the failure this package is built to notice,
301
+ * and it cannot be noticed by counting the rows that were collected — that
302
+ * number agrees with itself. `(of N products)` is a second opinion the store
303
+ * computes from its own database, which is the check `regofix.search` gets
304
+ * free from `hits.total`.
305
+ *
306
+ * A page that adds no new row stops the walk rather than looping: a paging
307
+ * parameter the store has stopped honouring answers with page 1 forever.
308
+ */
309
+ export async function roster(fetcher, cPath, options = {}) {
310
+ const { delayMs = REQUEST_DELAY_MS } = options;
311
+ const rows = [];
312
+ const seen = new Set();
313
+ let total = 0;
314
+ for (let page = 1; page === 1 || rows.length < total; page++) {
315
+ if (page > 1)
316
+ await pause(delayMs);
317
+ const listing = parseListing(await fetcher.text(categoryUrl(cPath, page)));
318
+ if (page === 1)
319
+ total = listing.total;
320
+ const before = rows.length;
321
+ for (const row of listing.rows) {
322
+ // Within one leaf a repeated id is the store serving a page twice, not
323
+ // the cross-leaf duplication `scrapeHolders` dedupes.
324
+ if (seen.has(row.productsId))
325
+ continue;
326
+ seen.add(row.productsId);
327
+ rows.push(row);
328
+ }
329
+ if (rows.length === before)
330
+ break;
331
+ }
332
+ if (rows.length !== total) {
333
+ throw new VendorResponseError(`c${cPath}`, `the listing says ${total} products and paging collected ${rows.length} — ` +
334
+ `a roster that lost rows agrees with every count computed from itself, ` +
335
+ `so this is the one that has to refuse`);
336
+ }
337
+ return rows;
338
+ }
339
+ /**
340
+ * One product page's `Product Specifications` table and downloads header.
341
+ *
342
+ * Both are found by their own `<div class="header">` rather than positionally,
343
+ * because a page carries several such blocks and which ones are present varies
344
+ * — 2 of the 529 parts in scope publish no spec table and roughly one in four
345
+ * publishes no downloads.
346
+ *
347
+ * An empty `specs` is a real state and is not an error here: the caller is
348
+ * what decides that a part with no geometry is skipped rather than written
349
+ * with holes.
350
+ */
351
+ export function parseProduct(html) {
352
+ const specs = {};
353
+ let statedPartNumber = null;
354
+ /** Which `<div class="header">` block the parser is currently under. */
355
+ let section = '';
356
+ let inHeader = false;
357
+ let cells = null;
358
+ let text = '';
359
+ let capturing = false;
360
+ const parser = new Parser({
361
+ onopentag: (tag, attribs) => {
362
+ const classes = (attribs['class'] ?? '').split(/\s+/);
363
+ if (tag === 'div' && classes.includes('header')) {
364
+ inHeader = true;
365
+ capturing = true;
366
+ text = '';
367
+ return;
368
+ }
369
+ if (section !== 'Product Specifications')
370
+ return;
371
+ if (tag === 'tr')
372
+ cells = [];
373
+ if (tag === 'td') {
374
+ capturing = true;
375
+ text = '';
376
+ }
377
+ },
378
+ ontext: (chunk) => {
379
+ if (capturing)
380
+ text += chunk;
381
+ },
382
+ onclosetag: (tag) => {
383
+ if (inHeader && tag === 'div') {
384
+ section = squash(text);
385
+ const stated = DOWNLOADS_HEADER.exec(section);
386
+ if (stated !== null)
387
+ statedPartNumber = stated[1] ?? null;
388
+ inHeader = false;
389
+ capturing = false;
390
+ return;
391
+ }
392
+ if (section !== 'Product Specifications')
393
+ return;
394
+ if (tag === 'td' && cells !== null) {
395
+ // `&nbsp;` decodes to U+00A0, which `\s` matches but a naive trim
396
+ // does not — the label cell is `<b>Balance Spec:&nbsp;</b>`.
397
+ cells.push(squash(text.replace(/ /g, ' ')));
398
+ capturing = false;
399
+ return;
400
+ }
401
+ if (tag === 'tr' && cells !== null) {
402
+ const [label, value] = cells;
403
+ if (label !== undefined && value !== undefined && label.endsWith(':')) {
404
+ specs[label.slice(0, -1).trim()] = value;
405
+ }
406
+ cells = null;
407
+ }
408
+ },
409
+ }, { decodeEntities: true });
410
+ parser.write(html);
411
+ parser.end();
412
+ return { specs, statedPartNumber };
413
+ }
414
+ /** One part's page, fetched through the seam every transport here uses. */
415
+ export async function fetchProduct(fetcher, url) {
416
+ return parseProduct(await fetcher.text(url));
417
+ }
418
+ /**
419
+ * A `Gage Length` cell as a number and the unit system it is stated in.
420
+ *
421
+ * **A bare number is inches.** That is the vendor's convention rather than
422
+ * this package guessing: MariTool marks every metric cell `mm` and marks
423
+ * nothing on an imperial one — 363 of the 473 in-scope cells sampled are bare
424
+ * or carry an inch mark, and a gage length of "3.0" millimetres is not a
425
+ * holder. The part number says nothing about it either way: `HSK40E-ER11-40`
426
+ * is millimetres and `HSK40E-ER16-3.0M` is inches, where that `M` is a mini
427
+ * nut.
428
+ *
429
+ * **The nose form is parsed off and given no column.** `120mm Tapered` is 120
430
+ * millimetres, and `Tapered` is not a data type — it is not lost, because the
431
+ * vendor states it in the product name too (*"BT40 ER11 120mm Tapered Nose
432
+ * Collet Chuck Tool Holder"*), and the row carries that name verbatim in
433
+ * `Description`. The raw cell stays in the CSV as well.
434
+ *
435
+ * Refused rather than returned as `NaN`, the rule `regofix.parseSize` holds: a
436
+ * cell this cannot read would otherwise travel into a row as a gage length.
437
+ */
438
+ export function parseGageLength(cell) {
439
+ const parsed = GAGE_CELL.exec(squash(cell));
440
+ const value = Number(parsed?.[1]);
441
+ if (!Number.isFinite(value) || value <= 0) {
442
+ throw new RangeError(`unrecognized gage length: ${JSON.stringify(cell)}`);
443
+ }
444
+ const unit = (parsed?.[2] ?? '').toLowerCase() === 'mm' ? 'millimeters' : 'inches';
445
+ return { value, unit };
446
+ }
447
+ /**
448
+ * A `Collet Size` cell as the series `CST` joins a collet family on.
449
+ *
450
+ * Only the spacing is closed. MariTool publishes `ER 11` and `ER11` within one
451
+ * style, and two spellings of one series join to nothing — `CST` is the key
452
+ * `families/kennametal.ts` states the holder-to-collet join against, and the
453
+ * collet side of it spells the series without a space.
454
+ *
455
+ * Nothing else is normalised. `ER25M` is written through as designated even
456
+ * though no such collet series exists, because the alternative is to decide on
457
+ * the vendor's behalf that its `M` is the mini nut its `Collet Nut` column
458
+ * carries elsewhere — see this module's docstring. {@link holderRow} warns.
459
+ */
460
+ export function colletSeries(cell) {
461
+ return cell.replace(/\s+/g, '');
462
+ }
463
+ /**
464
+ * One listing row plus its spec table -> one CSV row.
465
+ *
466
+ * The vendor's own labels are carried verbatim and in the order the page
467
+ * states them; what this adds in front of them is the four axes the catalog
468
+ * asks a holder about — `taper`, `contact`, `clamping`, `style` — plus the
469
+ * collet series and the promoted gage length.
470
+ *
471
+ * **No bore column.** A collet chuck grips through a collet and a shrink-fit
472
+ * holder's bore is stated under MariTool's own `Shank Size` label; promoting
473
+ * either to `D1` would make a collet-clamping holder claim a clamping capacity
474
+ * of its own, which is the reason `regofix.holderRow` has no `D1` either. For
475
+ * the same reason `Collet Grip Range` is carried verbatim and never becomes
476
+ * `CCCN`/`CCCX`: it is the ER series' range restated on the holder's page, a
477
+ * pure function of `Collet Size` across every part sampled, and a real one
478
+ * comes from a collet family joined on `CST`.
479
+ */
480
+ export function holderRow(leaf, listing, specs, warn = consoleWarn) {
481
+ const where = `${listing.partNumber} (${listing.name})`;
482
+ const row = {
483
+ [MATERIAL_COLUMN]: listing.partNumber,
484
+ [STORE_ID_COLUMN]: listing.productsId,
485
+ [DESCRIPTION_COLUMN]: listing.name,
486
+ [TAPER_COLUMN]: '',
487
+ [CONTACT_COLUMN]: '',
488
+ [CLAMPING_COLUMN]: leaf.clamping,
489
+ [STYLE_COLUMN]: leaf.style,
490
+ };
491
+ const stated = squash(specs[TAPER_LABEL] ?? '');
492
+ if (stated === '') {
493
+ // One part in the catalog, and its row is kept rather than dropped: the
494
+ // hole is what the vendor published, and a taper inferred from the part
495
+ // number would be this package authoring the one column it exists to read.
496
+ warn(` WARNING: ${where}: its spec table states no Taper — the columns are left empty`);
497
+ }
498
+ else {
499
+ const form = TAPER_FORMS[stated.toUpperCase()];
500
+ if (form === undefined) {
501
+ throw new VendorResponseError(where, `Taper ${JSON.stringify(stated)} is not a spindle interface this ` +
502
+ `package knows — add it to TAPER_FORMS once it is clear which ` +
503
+ `machine it fits`);
504
+ }
505
+ row[TAPER_COLUMN] = form.taper;
506
+ row[CONTACT_COLUMN] = form.contact;
507
+ }
508
+ const size = specs[COLLET_SIZE_LABEL];
509
+ if (size !== undefined && size !== '') {
510
+ const series = colletSeries(size);
511
+ row[COLLET_SERIES_COLUMN] = series;
512
+ if (!COLLET_SERIES.test(series)) {
513
+ warn(` WARNING: ${where}: Collet Size ${JSON.stringify(size)} is not a ` +
514
+ `collet series — it is written into CST as designated and joins to ` +
515
+ `no collet`);
516
+ }
517
+ }
518
+ row[GAGE_COLUMNS.inches] = '';
519
+ row[GAGE_COLUMNS.millimeters] = '';
520
+ const gage = specs[GAGE_LABEL];
521
+ if (gage !== undefined && gage !== '') {
522
+ const { value, unit } = parseGageLength(gage);
523
+ row[GAGE_COLUMNS[unit]] = String(value);
524
+ }
525
+ for (const [label, value] of Object.entries(specs)) {
526
+ // A vendor label that lands on a column built above would silently replace
527
+ // it — a `Description` spec row would overwrite the product name, and the
528
+ // row would still look complete. None collides today; this is what says so
529
+ // if one starts to.
530
+ if (Object.hasOwn(row, label)) {
531
+ warn(` WARNING: ${where}: the spec table publishes a ${JSON.stringify(label)} ` +
532
+ `row, which is already a column this scraper builds — the vendor's ` +
533
+ `value is dropped`);
534
+ continue;
535
+ }
536
+ row[label] = value;
537
+ }
538
+ row[CAD_COLUMN] = listing.assets['STP'] ?? '';
539
+ row[CAD_DXF_COLUMN] = listing.assets['DXF'] ?? '';
540
+ return row;
541
+ }
542
+ /**
543
+ * Every in-scope holder under `leaves`, one CSV's worth.
544
+ *
545
+ * One request per leaf page for the roster, then one per part for its
546
+ * geometry, paced by the package's shared politeness delay throughout.
547
+ *
548
+ * **Deduped by `products_id` across leaves.** MariTool lists a handful of
549
+ * parts under two leaves, and a part is one row however many places the store
550
+ * files it. The first leaf to reach it wins, which is what makes the row
551
+ * order below independent of which duplicate was found.
552
+ */
553
+ export async function scrapeHolders(fetcher, leaves, options = {}) {
554
+ const { warn = consoleWarn, delayMs = REQUEST_DELAY_MS } = options;
555
+ const rows = [];
556
+ const seen = new Set();
557
+ for (const leaf of leaves) {
558
+ for (const listing of await roster(fetcher, leaf.cPath, options)) {
559
+ if (seen.has(listing.productsId)) {
560
+ warn(` DUPLICATE ${listing.partNumber}: also listed under c${leaf.cPath} — ` +
561
+ `the first leaf it was found in is the one carried`);
562
+ continue;
563
+ }
564
+ seen.add(listing.productsId);
565
+ await pause(delayMs);
566
+ const { specs, statedPartNumber } = await fetchProduct(fetcher, listing.productUrl);
567
+ if (Object.keys(specs).length === 0) {
568
+ warn(` SKIPPED ${listing.partNumber} (${listing.name}): the vendor ` +
569
+ `publishes no Product Specifications table, so it has no geometry`);
570
+ continue;
571
+ }
572
+ if (statedPartNumber !== null && statedPartNumber !== listing.partNumber) {
573
+ warn(` WARNING: ${listing.partNumber}: its downloads header calls it ` +
574
+ `${statedPartNumber} — the listing's part number is used`);
575
+ }
576
+ rows.push(holderRow(leaf, listing, specs, warn));
577
+ }
578
+ await pause(delayMs);
579
+ }
580
+ if (rows.length === 0) {
581
+ throw new VendorResponseError(BASE, 'the scrape produced no rows');
582
+ }
583
+ rows.sort((a, b) => compare(a[MATERIAL_COLUMN] ?? '', b[MATERIAL_COLUMN] ?? ''));
584
+ return {
585
+ header: unionHeader(rows),
586
+ rows,
587
+ source: categoryUrl(leaves[0]?.cPath ?? ''),
588
+ // MariTool's cPath is its own code for a category, and a family is scraped
589
+ // from several — so the receipt records all of them, space-separated, and
590
+ // `source` is the request to re-issue first.
591
+ familyCode: leaves.map((leaf) => leaf.cPath).join(' '),
592
+ };
593
+ }
@@ -55,7 +55,6 @@
55
55
  */
56
56
  import { type Fetcher } from '../../fetch.js';
57
57
  import { type ScrapeResult, type ScrapedRow, type Warn } from '../../scrape.js';
58
- export declare const MM_PER_INCH = 25.4;
59
58
  /**
60
59
  * The Searchkit proxy the ProductFinder posts its Elasticsearch queries to.
61
60
  * Discovered by reading the app bundle, which constructs
@@ -158,7 +157,14 @@ export declare function fetchDin4000(fetcher: Fetcher, sku: string): Promise<Rec
158
157
  * designation says.
159
158
  */
160
159
  export declare function plain(value: string | number | null | undefined): string;
161
- /** `1/4` or `3.5` — the vendor prints both, and both are exact here. */
160
+ /**
161
+ * `1/4` or `3.5` — the vendor prints both, and both are exact here.
162
+ *
163
+ * The grammar is `measure.fractionValue`'s; what stays here is the refusal.
164
+ * `3/` divides by zero and an absent regex capture arrives as `''`, and both
165
+ * would otherwise travel into a row as a nominal size — as would `0`, which
166
+ * reads as a number and is not a collet this vendor makes.
167
+ */
162
168
  export declare function parseSize(size: string): number;
163
169
  /**
164
170
  * The part's STEP model, absolute, or empty when none is published.
@@ -204,15 +210,6 @@ export declare function holderRow(source: Source, properties: Record<string, str
204
210
  * Kennametal's contradictory unit cells are kept: it is what the vendor said.
205
211
  */
206
212
  export declare function colletRow(source: Source, warn?: Warn): ScrapedRow;
207
- /**
208
- * Rows to a result whose header is the union of their keys, in first-seen
209
- * order.
210
- *
211
- * A union rather than the first row's keys: a mixed-unit collet family has
212
- * `D1_mm` on its metric rows and `D1_in` on its inch ones, and keying off row
213
- * one would drop whichever came second.
214
- */
215
- export declare function unionHeader(rows: readonly ScrapedRow[]): string[];
216
213
  /** Options every REGO-FIX scrape accepts. */
217
214
  export interface RegofixOptions {
218
215
  warn?: Warn;