@toolpath/tool-scraper 0.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 (73) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +98 -0
  3. package/dist/conventions.d.ts +124 -0
  4. package/dist/conventions.js +143 -0
  5. package/dist/errors.d.ts +46 -0
  6. package/dist/errors.js +53 -0
  7. package/dist/families/destinytool.d.ts +49 -0
  8. package/dist/families/destinytool.js +55 -0
  9. package/dist/families/index.d.ts +59 -0
  10. package/dist/families/index.js +91 -0
  11. package/dist/families/kennametal.d.ts +757 -0
  12. package/dist/families/kennametal.js +660 -0
  13. package/dist/families/regofix.d.ts +185 -0
  14. package/dist/families/regofix.js +250 -0
  15. package/dist/family.d.ts +130 -0
  16. package/dist/family.js +38 -0
  17. package/dist/fetch.d.ts +98 -0
  18. package/dist/fetch.js +116 -0
  19. package/dist/identity.d.ts +133 -0
  20. package/dist/identity.js +118 -0
  21. package/dist/index.d.ts +31 -0
  22. package/dist/index.js +31 -0
  23. package/dist/node/cad-mirror.d.ts +55 -0
  24. package/dist/node/cad-mirror.js +89 -0
  25. package/dist/node/cli.d.ts +35 -0
  26. package/dist/node/cli.js +340 -0
  27. package/dist/node/csv.d.ts +47 -0
  28. package/dist/node/csv.js +123 -0
  29. package/dist/node/index.d.ts +16 -0
  30. package/dist/node/index.js +16 -0
  31. package/dist/node/main.d.ts +13 -0
  32. package/dist/node/main.js +14 -0
  33. package/dist/node/paths.d.ts +60 -0
  34. package/dist/node/paths.js +80 -0
  35. package/dist/node/receipts.d.ts +100 -0
  36. package/dist/node/receipts.js +107 -0
  37. package/dist/order.d.ts +10 -0
  38. package/dist/order.js +12 -0
  39. package/dist/provenance.d.ts +125 -0
  40. package/dist/provenance.js +133 -0
  41. package/dist/records.d.ts +305 -0
  42. package/dist/records.js +297 -0
  43. package/dist/registry.d.ts +63 -0
  44. package/dist/registry.js +145 -0
  45. package/dist/scrape.d.ts +70 -0
  46. package/dist/scrape.js +37 -0
  47. package/dist/thread.d.ts +48 -0
  48. package/dist/thread.js +98 -0
  49. package/dist/uuid5.d.ts +31 -0
  50. package/dist/uuid5.js +64 -0
  51. package/dist/vendors/destinytool/index.d.ts +11 -0
  52. package/dist/vendors/destinytool/index.js +11 -0
  53. package/dist/vendors/destinytool/records.d.ts +118 -0
  54. package/dist/vendors/destinytool/records.js +266 -0
  55. package/dist/vendors/destinytool/scrape.d.ts +108 -0
  56. package/dist/vendors/destinytool/scrape.js +192 -0
  57. package/dist/vendors/kennametal/cad.d.ts +87 -0
  58. package/dist/vendors/kennametal/cad.js +119 -0
  59. package/dist/vendors/kennametal/index.d.ts +21 -0
  60. package/dist/vendors/kennametal/index.js +21 -0
  61. package/dist/vendors/kennametal/materials.d.ts +143 -0
  62. package/dist/vendors/kennametal/materials.js +200 -0
  63. package/dist/vendors/kennametal/records.d.ts +88 -0
  64. package/dist/vendors/kennametal/records.js +241 -0
  65. package/dist/vendors/kennametal/scrape.d.ts +111 -0
  66. package/dist/vendors/kennametal/scrape.js +226 -0
  67. package/dist/vendors/kennametal/thread-column.d.ts +28 -0
  68. package/dist/vendors/kennametal/thread-column.js +41 -0
  69. package/dist/vendors/regofix/index.d.ts +8 -0
  70. package/dist/vendors/regofix/index.js +8 -0
  71. package/dist/vendors/regofix/scrape.d.ts +237 -0
  72. package/dist/vendors/regofix/scrape.js +521 -0
  73. package/package.json +76 -0
@@ -0,0 +1,521 @@
1
+ /**
2
+ * REGO-FIX powRgrip product index -> toolholding rows.
3
+ *
4
+ * Nothing here is shared with any other adapter beyond `conventions` —
5
+ * REGO-FIX is a Drupal site, not Kennametal's AEM platform, so the transport,
6
+ * the identity fields and the dimension source are all different. See
7
+ * `docs/REGOFIX_PRODUCTFINDER_API.md` for how the endpoint was found.
8
+ *
9
+ * ## Two sources, and both are needed
10
+ *
11
+ * **The roster** comes from the ProductFinder's Elasticsearch proxy: one POST
12
+ * returns every variant of a group with its part number, its designation, its
13
+ * PG series and its CAD links. It carries no geometry beyond the projection
14
+ * length.
15
+ *
16
+ * **The geometry** comes from a per-part DIN 4000 XML on the vendor's CDN,
17
+ * linked from each hit. Three of its codes are pinned to a meaning by
18
+ * REGO-FIX's own published tables and the rest are not:
19
+ *
20
+ * - **`B3`** — projection from the flange face. The `L` column of the BT/PG
21
+ * table in the PG product catalog, row for row.
22
+ * - **`B4`** — gage length. `B4 - B3 == 48.4` on every row, and 48.4 mm is
23
+ * BT 30's gauge-line-to-flange distance in the vendor's own standards table.
24
+ * - **`A1`** — diameter at the collet end. The `D` column of that same BT/PG
25
+ * table, row for row.
26
+ *
27
+ * `A2`, `B1`, `B2` and `B3_WOA` are carried into the CSV verbatim under their
28
+ * raw DIN codes and are **not** promoted onto a record. Nothing available here
29
+ * says what they measure, and the standing rule is to leave a vendor code
30
+ * unlabelled rather than guess at it (JG 2026-08-07). `conventions.DIN_PREFIX`
31
+ * is what keeps them from reading as promoted dimensions: a column named
32
+ * `A2_mm` would sit in the CSV looking exactly like `L1_mm`, which is mapped.
33
+ *
34
+ * There is a lead on pinning them honestly. REGO-FIX publishes
35
+ * `DXF_ISO13399/DXF` and `DXF_ISO13399/PDF` beside the `XML_DIN4000/XML` this
36
+ * module reads, so the standard this package's canonical names come from is
37
+ * already in the vendor's own source material.
38
+ *
39
+ * `A4` is 46 on every BT 30 holder — the flange diameter, a property of the
40
+ * taper and not of the part — so it is checked rather than stored.
41
+ *
42
+ * ## What the vendor gets wrong
43
+ *
44
+ * Three faults found on 2026-08-07, all reported as warnings rather than
45
+ * fixed: two disagreeing vendor cells cannot say which one is wrong, and a
46
+ * scraper that corrects one becomes a place tool data is authored by hand.
47
+ *
48
+ * 1. `4130.70646`'s XML states its own part number as `4130.71646`.
49
+ * 2. `J22`, the XML's own product-line label, says "PG-SG secuRgrip
50
+ * Werkzeughalter" on several plain BT 30 holders that are not secuRgrip
51
+ * parts. It is never read.
52
+ * 3. `o_mm` on a tapping collet repeats the previous row's value twice
53
+ * (`1715.08215` and `1725.08215`). This is why the nominal size is parsed
54
+ * from the vendor's own designation and `o_mm` is only ever a cross-check.
55
+ */
56
+ import { CAD_COLUMN, DIN_PREFIX } from '../../conventions.js';
57
+ import { VendorResponseError } from '../../errors.js';
58
+ import { statusOf } from '../../fetch.js';
59
+ import { consoleWarn } from '../../scrape.js';
60
+ export const MM_PER_INCH = 25.4;
61
+ /**
62
+ * The Searchkit proxy the ProductFinder posts its Elasticsearch queries to.
63
+ * Discovered by reading the app bundle, which constructs
64
+ * `SearchkitManager(origin + '/' + lang, {searchUrlPath: '/elastic/post'})`.
65
+ */
66
+ export const SEARCH_URL = 'https://us.rego-fix.com/en/elastic/post';
67
+ /**
68
+ * Where a part's DIN 4000 XML lives. The filename is the part number with its
69
+ * dot removed, which is also `field_sku_ngram`.
70
+ */
71
+ export const DIN4000_URL = 'https://static.rego-fix.com/sites/default/files/products/' + 'XML_DIN4000/XML/{sku}.xml';
72
+ /**
73
+ * Gauge line to flange face, JIS B 6339 / MAS 403 size 30, as published in the
74
+ * vendor's own interface table (PG product catalog, "BT MAS 403":
75
+ * `BT 30 | 31.75 | 46 | 2 | 48.4 | 20 | M 12`). Used to *verify* that `B4` is
76
+ * the gage length rather than to compute one — both numbers are scraped, and
77
+ * their difference is what identifies the code.
78
+ */
79
+ export const BT30_GAUGE_TO_FLANGE = 48.4;
80
+ /** Flange diameter of a BT 30 taper, from the same row. `A4` on every holder. */
81
+ export const BT30_FLANGE_DIAMETER = 46.0;
82
+ /**
83
+ * DIN 4000 codes carried into the CSV verbatim because nothing here says what
84
+ * they measure. Written out rather than "everything else" so that a code the
85
+ * vendor adds later shows up as an unhandled key instead of silently appearing
86
+ * as a column.
87
+ */
88
+ export const UNPINNED_DIN_CODES = ['A2', 'B1', 'B2', 'B3_WOA'];
89
+ const PROPERTY = /<PropertyName source="din_mk">([^<]*)<\/PropertyName>\s*<Value>([^<]*)<\/Value>/g;
90
+ /**
91
+ * `PG 25 Ø 3.5 mm`, `PG 15-CF Ø 1/4"`, `PGST 25 Ø 16.0 mm`,
92
+ * `PG 15-TAP Ø 0.141" x 0.110"`, `PG 15-TAP Ø 3.5 x 2.7 mm`.
93
+ *
94
+ * Two things here are easy to get wrong and both were, on the first pass. The
95
+ * series alternation is `PG(?:ST)?` and not `PGST?`, which is "PGS" followed
96
+ * by an optional T and matches none of the 293 plain PG collets. And **an inch
97
+ * designation marks every number while a metric one marks only the last**: a
98
+ * tapping collet is `Ø 0.141" x 0.110"` but `Ø 3.5 x 2.7 mm`, so a pattern
99
+ * that demands a unit after the first number reads no metric tapping collet at
100
+ * all.
101
+ */
102
+ const COLLET_TITLE = /^(?<series>PG(?:ST)? ?\d+)(?<variant>-[A-Z-]+)? *Ø *(?<size>[\d./]+)(?<sizeUnit>"?)(?: *x *(?<square>[\d./]+)"?)?(?<metric> *mm)?$/;
103
+ /** `BT 30 / PG 25 x 075`, `BT+ 30 / PG 15 x 070 H`. */
104
+ const HOLDER_TITLE = /^(?<taper>BT\+? ?\d+)(?<taperVariant>-[A-Z]+)? *\/ *(?<series>PG ?\d+) *x *(?<projection>\d+)/;
105
+ /**
106
+ * `form_name`, the vendor's own field, mapped to this catalog's `contact`
107
+ * axis. `Plus +` is REGO-FIX's designation for the dual-contact shank that
108
+ * seats on the spindle face as well as the cone — the same distinction
109
+ * Kennametal sells as BTKV. It is a **scraped fact here**, not family config,
110
+ * because REGO-FIX publishes both forms in one product group:
111
+ * `BT 30 / PG 25 x 080 H` and `BT+ 30 / PG 25 x 080 H` are two rows of one
112
+ * table.
113
+ *
114
+ * There is no default. A third form is a stop-and-ask, and `BT-OM 30` is
115
+ * already sitting in that table undefined — nothing on the vendor's site or in
116
+ * its catalog says what OM designates, so its three parts are deliberately not
117
+ * scraped (JG 2026-08-07).
118
+ */
119
+ export const CONTACT_BY_FORM = {
120
+ Standard: 'taper',
121
+ 'Plus +': 'face',
122
+ };
123
+ /**
124
+ * The taper designations this package scrapes, and what they mean.
125
+ *
126
+ * `BT-OM 30` is published in the same product group and is **not** here: the
127
+ * family page, the product catalog and the ProductFinder all print the token
128
+ * and none of them says what OM designates, so recording a spindle interface
129
+ * for it would be a guess about which machine a holder fits (JG 2026-08-07).
130
+ * Its three parts are a stop-and-ask, not an omission to fix silently.
131
+ */
132
+ export const SCRAPED_TAPERS = ['BT 30', 'BT+ 30'];
133
+ /**
134
+ * Every `_source` matching an AND of term filters, newest index first.
135
+ *
136
+ * One request: the index holds 4142 products in total and the largest group
137
+ * asked for here is 321, so there is nothing to page. `size` is an explicit
138
+ * ceiling rather than a page length, and going over it throws — a silently
139
+ * truncated roster is the failure this whole package is built to notice.
140
+ */
141
+ export async function search(fetcher, filters, size = 500) {
142
+ const query = {
143
+ bool: {
144
+ filter: Object.entries(filters).map(([k, v]) => ({ term: { [k]: v } })),
145
+ },
146
+ };
147
+ const payload = await fetcher.postJson(SEARCH_URL, { size, query });
148
+ const hits = payload.hits;
149
+ if (hits === undefined) {
150
+ throw new VendorResponseError(SEARCH_URL, `response carries no "hits" — the proxy changed shape ` +
151
+ `(keys: ${Object.keys(payload).sort().join(', ')})`);
152
+ }
153
+ // Elasticsearch 6 answers with a bare number and 7+ with `{value, relation}`
154
+ // unless `rest_total_hits_as_int` is set. Reading only the number would make
155
+ // this guard a silent no-op the day the proxy is upgraded — which is exactly
156
+ // the truncated roster it exists to refuse.
157
+ const total = typeof hits.total === 'object' ? hits.total.value : hits.total;
158
+ if (total > size) {
159
+ throw new VendorResponseError(JSON.stringify(filters), `${total} products but only ${size} requested — raise \`size\` ` +
160
+ `rather than shipping a truncated roster`);
161
+ }
162
+ return hits.hits.map((hit) => hit._source);
163
+ }
164
+ /**
165
+ * A field of an Elasticsearch `_source`, which stores every value as a list
166
+ * even when there is exactly one.
167
+ *
168
+ * Missing and empty are both null: `o_inch` is absent on a metric collet,
169
+ * which is the vendor saying it is metric rather than a gap.
170
+ */
171
+ export function one(source, field) {
172
+ const values = source[field];
173
+ if (!Array.isArray(values) || values.length === 0)
174
+ return null;
175
+ const value = values[0];
176
+ return typeof value === 'string' || typeof value === 'number' ? value : null;
177
+ }
178
+ /** `one`, as the string every caller wanted it as. */
179
+ function text(source, field) {
180
+ const value = one(source, field);
181
+ return value === null ? '' : String(value);
182
+ }
183
+ /**
184
+ * DIN 4000 property codes to their values, empty ones dropped.
185
+ *
186
+ * The document repeats a `<PropertyName>`/`<Value>` pair per property and
187
+ * states most of them empty, so dropping blanks is what makes "the vendor
188
+ * published this" and "the vendor published a hole" different states.
189
+ *
190
+ * Throws on a document with no properties at all rather than returning an
191
+ * empty map, for the reason `kennametal.parseVariantTable` throws on an
192
+ * unparseable response: reporting a changed format as no data looks exactly
193
+ * like a discontinued part.
194
+ */
195
+ export function parseDin4000(xml) {
196
+ const properties = {};
197
+ let found = false;
198
+ for (const match of xml.matchAll(PROPERTY)) {
199
+ found = true;
200
+ const [, name, value] = match;
201
+ if (name && value?.trim())
202
+ properties[name] = value.trim();
203
+ }
204
+ if (!found) {
205
+ throw new VendorResponseError('DIN 4000 document', 'carries no din_mk properties — the format changed shape');
206
+ }
207
+ return properties;
208
+ }
209
+ /**
210
+ * One part's DIN 4000 properties, or null when the vendor publishes none.
211
+ *
212
+ * Null is a real state — two of the BT+ 30 holders have DXF and PDF but no XML
213
+ * — and it is distinguished from a failed request, which throws. A holder with
214
+ * no XML has no gage length and cannot be converted, so it is the caller that
215
+ * decides what to do about it.
216
+ */
217
+ export async function fetchDin4000(fetcher, sku) {
218
+ try {
219
+ const xml = await fetcher.text(DIN4000_URL.replace('{sku}', sku.replaceAll('.', '')));
220
+ return parseDin4000(xml);
221
+ }
222
+ catch (error) {
223
+ if (statusOf(error) === 404)
224
+ return null;
225
+ throw error;
226
+ }
227
+ }
228
+ /** One of the three DIN codes this package is willing to map, as a number. */
229
+ function pinned(properties, code, sku) {
230
+ const raw = properties[code];
231
+ if (!raw) {
232
+ throw new VendorResponseError(sku, `DIN 4000 document publishes no ${code}`);
233
+ }
234
+ const value = Number(raw);
235
+ if (!Number.isFinite(value)) {
236
+ throw new VendorResponseError(sku, `DIN 4000 ${code} is ${JSON.stringify(raw)}, not a number`);
237
+ }
238
+ return value;
239
+ }
240
+ /**
241
+ * A number as the vendor would print it: no trailing `.0` on an integer.
242
+ *
243
+ * The CSV is read back as a number, so this only decides what a human and a
244
+ * git diff see — and `10` rather than `10.0` is what the vendor's own
245
+ * designation says.
246
+ */
247
+ export function plain(value) {
248
+ if (value === null || value === undefined)
249
+ return '';
250
+ return String(value);
251
+ }
252
+ /** `1/4` or `3.5` — the vendor prints both, and both are exact here. */
253
+ export function parseSize(size) {
254
+ const value = size.includes('/')
255
+ ? Number(size.split('/')[0]) / Number(size.split('/')[1])
256
+ : Number(size);
257
+ // `3/` divides by zero and reaches here as Infinity, and an absent regex
258
+ // capture as `''` -> 0; both would travel into a row as a nominal size. Neither is a collet this
259
+ // vendor makes, so an unreadable size is refused rather than carried.
260
+ if (!Number.isFinite(value) || value <= 0) {
261
+ throw new RangeError(`unrecognized size: ${JSON.stringify(size)}`);
262
+ }
263
+ return value;
264
+ }
265
+ /** Round to `places` decimals, as Python's `round(x, places)` does here. */
266
+ function round(value, places) {
267
+ const scale = 10 ** places;
268
+ return Math.round(value * scale) / scale;
269
+ }
270
+ /**
271
+ * The part's STEP model, absolute, or empty when none is published.
272
+ *
273
+ * The index gives protocol-relative CDN URLs, and `conventions.CAD_COLUMN`
274
+ * holds a URL a consumer can fetch — so the scheme is added here rather than
275
+ * left for every reader to guess at.
276
+ */
277
+ export function cadUrl(source) {
278
+ const urls = source['field_technical_drawings_url'];
279
+ if (!Array.isArray(urls))
280
+ return '';
281
+ for (const url of urls) {
282
+ if (typeof url === 'string' && url.endsWith('.stp')) {
283
+ return url.startsWith('//') ? `https:${url}` : url;
284
+ }
285
+ }
286
+ return '';
287
+ }
288
+ /**
289
+ * One search hit plus its DIN 4000 properties -> one row.
290
+ *
291
+ * Column names are the shared toolholding vocabulary rather than REGO-FIX's
292
+ * own, so a consumer reads a REGO-FIX holder exactly as it reads a Kennametal
293
+ * one — `L1` is the gage length whoever published it. The DIN codes those came
294
+ * from are in this module's docstring; the ones that stay unmapped keep their
295
+ * raw code behind `conventions.DIN_PREFIX`.
296
+ *
297
+ * **This is the one place a REGO-FIX label is rewritten, and it is a
298
+ * holder-geometry label rather than an identity or a dimension code.** The
299
+ * identity columns are Kennametal's, adopted here because this vendor came
300
+ * second — see `conventions.IDENTITY_COLUMNS`.
301
+ *
302
+ * `D1` is deliberately absent. A powRgrip holder clamps through a collet, and
303
+ * a collet-clamping holder that also carried a bore would be claiming two ways
304
+ * of gripping one tool.
305
+ */
306
+ export function holderRow(source, properties, warn = consoleWarn) {
307
+ const title = text(source, 'title');
308
+ const sku = text(source, 'field_sku_fulltext');
309
+ const parsed = HOLDER_TITLE.exec(title)?.groups;
310
+ if (!parsed) {
311
+ throw new VendorResponseError(sku, `cannot read a taper and series off ${JSON.stringify(title)}`);
312
+ }
313
+ const form = text(source, 'form_name');
314
+ const contact = CONTACT_BY_FORM[form];
315
+ if (contact === undefined) {
316
+ throw new VendorResponseError(`${sku} (${title})`, `form_name ${JSON.stringify(form)} is not a contact mode this package ` +
317
+ `knows — add it to CONTACT_BY_FORM once the vendor says what it ` +
318
+ `designates`);
319
+ }
320
+ const gauge = pinned(properties, 'B4', sku);
321
+ const projection = pinned(properties, 'B3', sku);
322
+ if (Math.abs(gauge - projection - BT30_GAUGE_TO_FLANGE) > 1e-9) {
323
+ throw new VendorResponseError(`${sku} (${title})`, `B4 - B3 is ${gauge - projection}, not the ${BT30_GAUGE_TO_FLANGE} mm ` +
324
+ `this taper puts between its gauge line and its flange — B4 is not ` +
325
+ `the gage length here`);
326
+ }
327
+ const flange = pinned(properties, 'A4', sku);
328
+ if (Math.abs(flange - BT30_FLANGE_DIAMETER) > 1e-9) {
329
+ throw new VendorResponseError(`${sku} (${title})`, `A4 is ${flange}, not the ${BT30_FLANGE_DIAMETER} mm flange of a BT 30 taper`);
330
+ }
331
+ const stated = properties['J21'];
332
+ if (stated !== undefined && stated !== sku) {
333
+ warn(` WARNING: ${sku} (${title}): its DIN 4000 document calls itself ` +
334
+ `${stated} — the index part number is used`);
335
+ }
336
+ const row = {
337
+ 'Material Number': sku,
338
+ 'ISO Catalog Number': title,
339
+ CST: (parsed['series'] ?? '').replaceAll(' ', ''),
340
+ contact,
341
+ L1_mm: plain(gauge),
342
+ D2_mm: plain(pinned(properties, 'A1', sku)),
343
+ B3_mm: plain(projection),
344
+ [CAD_COLUMN]: cadUrl(source),
345
+ };
346
+ for (const code of UNPINNED_DIN_CODES) {
347
+ row[`${DIN_PREFIX}${code}`] = properties[code] ?? '';
348
+ }
349
+ return row;
350
+ }
351
+ /**
352
+ * Report where the index's `o_mm` contradicts the vendor's designation.
353
+ *
354
+ * Reports, never gates: two disagreeing vendor cells cannot say which one is
355
+ * wrong, and correcting one here would make this module a place tool data is
356
+ * authored by hand.
357
+ *
358
+ * **The tolerance is the vendor's own printed precision, not a feel.** `o_mm`
359
+ * is stated to two decimals, so half a unit in its last place — 0.005 mm — is
360
+ * exactly how far it may legitimately sit from the exact size. The four
361
+ * disagreements in the catalog today are 0.01, 0.01, 0.12 and 1.48 mm; the
362
+ * last two are tapping collets whose `o_mm` repeats the previous row's value
363
+ * outright.
364
+ */
365
+ function crossCheckOmm(row, nominalMm, warn) {
366
+ const stated = row['o_mm'];
367
+ if (!stated)
368
+ return;
369
+ if (Math.abs(Number(stated) - nominalMm) > 0.005 + 1e-9) {
370
+ warn(` WARNING: ${row['Material Number']} (${row['ISO Catalog Number']}): ` +
371
+ `the index says o_mm = ${stated} where the designation is ` +
372
+ `${nominalMm} mm; the designation is used`);
373
+ }
374
+ }
375
+ /**
376
+ * One search hit -> one row, with the nominal size read off the vendor's own
377
+ * designation.
378
+ *
379
+ * **The size comes from the title, not from `o_mm`.** `o_mm` is rounded to two
380
+ * decimals, which puts a 1/8 in collet at 3.18 mm where the part is 3.175 —
381
+ * five microns out, against the two-micron tolerance a fit test sizes its
382
+ * equality to, so every inch collet would have failed to match its own shank
383
+ * size. It is also wrong outright on two tapping collets, where it repeats the
384
+ * previous row's value. The title states the vendor's designation exactly
385
+ * (`Ø 1/4"`, `Ø 3.5 mm`) and says which unit system it is in, so it is both
386
+ * more precise and the only source here that carries a unit at all.
387
+ *
388
+ * `o_mm` is kept as a cross-check column rather than dropped, the same way
389
+ * Kennametal's contradictory unit cells are kept: it is what the vendor said.
390
+ */
391
+ export function colletRow(source, warn = consoleWarn) {
392
+ const title = text(source, 'title');
393
+ const sku = text(source, 'field_sku_fulltext');
394
+ const parsed = COLLET_TITLE.exec(title)?.groups;
395
+ if (!parsed) {
396
+ throw new VendorResponseError(sku, `cannot read a size off ${JSON.stringify(title)}`);
397
+ }
398
+ const inches = parsed['sizeUnit'] === '"';
399
+ // Exactly one unit marker, or the designation does not state a system. Both
400
+ // would mean a title like `Ø 1/4" mm`; neither means the vendor printed a
401
+ // bare number, and this catalog does not guess a unit system.
402
+ if (inches === Boolean(parsed['metric'])) {
403
+ throw new VendorResponseError(sku, `${JSON.stringify(title)} states ${inches ? 'two unit systems' : 'none'}`);
404
+ }
405
+ const nominal = parseSize(parsed['size'] ?? '');
406
+ const unit = inches ? 'inches' : 'millimeters';
407
+ const nominalMm = inches ? round(nominal * MM_PER_INCH, 6) : nominal;
408
+ // A powRgrip collet clamps one size to h6 (h9 on the turning and tapping
409
+ // lines) rather than closing over a range, so its capacity is its nominal
410
+ // diameter at both ends. That is the vendor's `Clamping range or tolerance`
411
+ // row in the PG catalog's collet matrix, and it is the same shape as
412
+ // Kennametal's sealed coolant-through collets, where CCCX == CCCN == D1 — a
413
+ // zero-width range is still a range.
414
+ const row = {
415
+ 'Material Number': sku,
416
+ 'ISO Catalog Number': title,
417
+ 'Collet Series': (parsed['series'] ?? '').replaceAll(' ', ''),
418
+ unit,
419
+ o_mm: plain(one(source, 'o_mm')),
420
+ Square_mm: '',
421
+ Square_in: '',
422
+ };
423
+ // The native cell is what a machinist ordered; the millimetre cell is what
424
+ // fit arithmetic compares. On a metric collet they are the same cell, so
425
+ // only an inch one gets a projection — and that projection is exact, because
426
+ // the designation is a fraction rather than a printed decimal.
427
+ for (const label of ['D1', 'CCCN', 'CCCX']) {
428
+ row[inches ? `${label}_in` : `${label}_mm`] = plain(nominal);
429
+ if (inches)
430
+ row[`${label}_mm`] = plain(nominalMm);
431
+ }
432
+ if (parsed['square']) {
433
+ const square = parseSize(parsed['square']);
434
+ row[inches ? 'Square_in' : 'Square_mm'] = plain(square);
435
+ // Projected for the same reason `D1` is: an inch tapping collet whose
436
+ // square sits only in `Square_in` is invisible to mm-side fit arithmetic.
437
+ if (inches)
438
+ row['Square_mm'] = plain(round(square * MM_PER_INCH, 6));
439
+ }
440
+ crossCheckOmm(row, nominalMm, warn);
441
+ return row;
442
+ }
443
+ /**
444
+ * Rows to a result whose header is the union of their keys, in first-seen
445
+ * order.
446
+ *
447
+ * A union rather than the first row's keys: a mixed-unit collet family has
448
+ * `D1_mm` on its metric rows and `D1_in` on its inch ones, and keying off row
449
+ * one would drop whichever came second.
450
+ */
451
+ export function unionHeader(rows) {
452
+ const header = [];
453
+ for (const row of rows) {
454
+ for (const key of Object.keys(row)) {
455
+ if (!header.includes(key))
456
+ header.push(key);
457
+ }
458
+ }
459
+ return header;
460
+ }
461
+ /**
462
+ * Every powRgrip holder of `group` whose taper is in {@link SCRAPED_TAPERS}.
463
+ *
464
+ * Two requests' worth of work per part — the roster is one POST, then one DIN
465
+ * 4000 document each. A part the vendor publishes no XML for is dropped with a
466
+ * message rather than written with holes: a gage length is required, and a
467
+ * holder without one fails conversion anyway.
468
+ */
469
+ export async function scrapeHolders(fetcher, group = 'BT/PG', category = 'BT', options = {}) {
470
+ const warn = options.warn ?? consoleWarn;
471
+ const sources = await search(fetcher, {
472
+ system_name: 'powRgrip',
473
+ type: 'toolholders',
474
+ product_category_name: category,
475
+ product_group_name: group,
476
+ });
477
+ const wanted = sources
478
+ .filter((s) => SCRAPED_TAPERS.some((taper) => text(s, 'title').startsWith(taper)))
479
+ .sort((a, b) => text(a, 'field_sku_fulltext').localeCompare(text(b, 'field_sku_fulltext'), 'en'));
480
+ const rows = [];
481
+ for (const source of wanted) {
482
+ const sku = text(source, 'field_sku_fulltext');
483
+ const properties = await fetchDin4000(fetcher, sku);
484
+ if (properties === null) {
485
+ warn(` SKIPPED ${sku} (${text(source, 'title')}): the vendor publishes ` +
486
+ `no DIN 4000 document, so it has no gage length`);
487
+ continue;
488
+ }
489
+ rows.push(holderRow(source, properties, warn));
490
+ }
491
+ return finish(rows, SEARCH_URL);
492
+ }
493
+ /**
494
+ * Every powRgrip collet of one product group, in the given PG sizes.
495
+ *
496
+ * `sizes` are the vendor's `norm_size` values — the PG series numbers a BT 30
497
+ * holder can take. It is an argument rather than "all of them" because the
498
+ * sizes are what tie a collet family to the holders in this catalog: PG 32 and
499
+ * PG 48 collets exist and no BT 30 holder accepts one.
500
+ */
501
+ export async function scrapeCollets(fetcher, group, sizes, options = {}) {
502
+ const sources = await search(fetcher, {
503
+ system_name: 'powRgrip',
504
+ type: 'collets',
505
+ product_group_name: group,
506
+ });
507
+ const wanted = sources
508
+ .filter((s) => sizes.includes(String(one(s, 'norm_size'))))
509
+ .sort((a, b) => text(a, 'field_sku_fulltext').localeCompare(text(b, 'field_sku_fulltext'), 'en'));
510
+ return finish(
511
+ // `options.warn` unguarded: `colletRow` owns the fallback, and defaulting
512
+ // it here too would be two layers deciding the same thing.
513
+ wanted.map((s) => colletRow(s, options.warn)), SEARCH_URL);
514
+ }
515
+ /** A scrape that produced no rows is a broken one, not an empty family. */
516
+ function finish(rows, source) {
517
+ if (rows.length === 0) {
518
+ throw new VendorResponseError(source, 'the scrape produced no rows');
519
+ }
520
+ return { header: unionHeader(rows), rows, source, familyCode: null };
521
+ }
package/package.json ADDED
@@ -0,0 +1,76 @@
1
+ {
2
+ "name": "@toolpath/tool-scraper",
3
+ "version": "0.1.0",
4
+ "description": "Scrape cutting-tool geometry from vendor catalogs into records and CSVs",
5
+ "license": "MIT",
6
+ "engines": {
7
+ "node": ">=20"
8
+ },
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "https://github.com/toolpath/ui-packages.git",
12
+ "directory": "packages/tool-scraper"
13
+ },
14
+ "homepage": "https://developers.toolpath.com",
15
+ "bugs": {
16
+ "url": "https://github.com/toolpath/ui-packages/issues"
17
+ },
18
+ "publishConfig": {
19
+ "access": "public",
20
+ "registry": "https://registry.npmjs.org"
21
+ },
22
+ "type": "module",
23
+ "main": "./dist/index.js",
24
+ "types": "./dist/index.d.ts",
25
+ "exports": {
26
+ ".": {
27
+ "types": "./dist/index.d.ts",
28
+ "import": "./dist/index.js"
29
+ },
30
+ "./node": {
31
+ "types": "./dist/node/index.d.ts",
32
+ "import": "./dist/node/index.js"
33
+ },
34
+ "./vendors/kennametal": {
35
+ "types": "./dist/vendors/kennametal/index.d.ts",
36
+ "import": "./dist/vendors/kennametal/index.js"
37
+ },
38
+ "./vendors/regofix": {
39
+ "types": "./dist/vendors/regofix/index.d.ts",
40
+ "import": "./dist/vendors/regofix/index.js"
41
+ },
42
+ "./vendors/destinytool": {
43
+ "types": "./dist/vendors/destinytool/index.d.ts",
44
+ "import": "./dist/vendors/destinytool/index.js"
45
+ },
46
+ "./registry": {
47
+ "types": "./dist/registry.d.ts",
48
+ "import": "./dist/registry.js"
49
+ },
50
+ "./families": {
51
+ "types": "./dist/families/index.d.ts",
52
+ "import": "./dist/families/index.js"
53
+ }
54
+ },
55
+ "files": [
56
+ "dist",
57
+ "LICENSE",
58
+ "README.md"
59
+ ],
60
+ "scripts": {
61
+ "build": "tsc -p tsconfig.build.json",
62
+ "check-types": "tsc --noEmit",
63
+ "test": "vitest run"
64
+ },
65
+ "dependencies": {
66
+ "htmlparser2": "12.0.0"
67
+ },
68
+ "devDependencies": {
69
+ "@types/node": "24.10.1",
70
+ "typescript": "5.9.3",
71
+ "vitest": "4.1.10"
72
+ },
73
+ "bin": {
74
+ "toolpath-scrape": "./dist/node/main.js"
75
+ }
76
+ }