@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.
- package/LICENSE +21 -0
- package/README.md +98 -0
- package/dist/conventions.d.ts +124 -0
- package/dist/conventions.js +143 -0
- package/dist/errors.d.ts +46 -0
- package/dist/errors.js +53 -0
- package/dist/families/destinytool.d.ts +49 -0
- package/dist/families/destinytool.js +55 -0
- package/dist/families/index.d.ts +59 -0
- package/dist/families/index.js +91 -0
- package/dist/families/kennametal.d.ts +757 -0
- package/dist/families/kennametal.js +660 -0
- package/dist/families/regofix.d.ts +185 -0
- package/dist/families/regofix.js +250 -0
- package/dist/family.d.ts +130 -0
- package/dist/family.js +38 -0
- package/dist/fetch.d.ts +98 -0
- package/dist/fetch.js +116 -0
- package/dist/identity.d.ts +133 -0
- package/dist/identity.js +118 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.js +31 -0
- package/dist/node/cad-mirror.d.ts +55 -0
- package/dist/node/cad-mirror.js +89 -0
- package/dist/node/cli.d.ts +35 -0
- package/dist/node/cli.js +340 -0
- package/dist/node/csv.d.ts +47 -0
- package/dist/node/csv.js +123 -0
- package/dist/node/index.d.ts +16 -0
- package/dist/node/index.js +16 -0
- package/dist/node/main.d.ts +13 -0
- package/dist/node/main.js +14 -0
- package/dist/node/paths.d.ts +60 -0
- package/dist/node/paths.js +80 -0
- package/dist/node/receipts.d.ts +100 -0
- package/dist/node/receipts.js +107 -0
- package/dist/order.d.ts +10 -0
- package/dist/order.js +12 -0
- package/dist/provenance.d.ts +125 -0
- package/dist/provenance.js +133 -0
- package/dist/records.d.ts +305 -0
- package/dist/records.js +297 -0
- package/dist/registry.d.ts +63 -0
- package/dist/registry.js +145 -0
- package/dist/scrape.d.ts +70 -0
- package/dist/scrape.js +37 -0
- package/dist/thread.d.ts +48 -0
- package/dist/thread.js +98 -0
- package/dist/uuid5.d.ts +31 -0
- package/dist/uuid5.js +64 -0
- package/dist/vendors/destinytool/index.d.ts +11 -0
- package/dist/vendors/destinytool/index.js +11 -0
- package/dist/vendors/destinytool/records.d.ts +118 -0
- package/dist/vendors/destinytool/records.js +266 -0
- package/dist/vendors/destinytool/scrape.d.ts +108 -0
- package/dist/vendors/destinytool/scrape.js +192 -0
- package/dist/vendors/kennametal/cad.d.ts +87 -0
- package/dist/vendors/kennametal/cad.js +119 -0
- package/dist/vendors/kennametal/index.d.ts +21 -0
- package/dist/vendors/kennametal/index.js +21 -0
- package/dist/vendors/kennametal/materials.d.ts +143 -0
- package/dist/vendors/kennametal/materials.js +200 -0
- package/dist/vendors/kennametal/records.d.ts +88 -0
- package/dist/vendors/kennametal/records.js +241 -0
- package/dist/vendors/kennametal/scrape.d.ts +111 -0
- package/dist/vendors/kennametal/scrape.js +226 -0
- package/dist/vendors/kennametal/thread-column.d.ts +28 -0
- package/dist/vendors/kennametal/thread-column.js +41 -0
- package/dist/vendors/regofix/index.d.ts +8 -0
- package/dist/vendors/regofix/index.js +8 -0
- package/dist/vendors/regofix/scrape.d.ts +237 -0
- package/dist/vendors/regofix/scrape.js +521 -0
- 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
|
+
}
|