@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,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Destiny Tool rows -> {@link ToolRecord}.
|
|
3
|
+
*
|
|
4
|
+
* Destiny Tool publishes exactly one identifier (`itemNumber`), no carbide
|
|
5
|
+
* grade, no structured shank column, and dimensions as fractional-inch
|
|
6
|
+
* **strings** rather than decimal columns — the closest precedent in this
|
|
7
|
+
* package is `thread.threadMajorDiameter`'s designation parsing, not any other
|
|
8
|
+
* vendor's dimension reader, all of which already publish decimals.
|
|
9
|
+
*
|
|
10
|
+
* **Three geometry fields are derived from the free-text `description` rather
|
|
11
|
+
* than a column, because no column exists for them**: a shank diameter from a
|
|
12
|
+
* "SHK" annotation, a corner radius from a "RAD" annotation when the vendor's
|
|
13
|
+
* own `rad` cell is blank, and a neck diameter from a "NECK" annotation. The
|
|
14
|
+
* latter two were found scraping the real collection 2026-08-19. All three
|
|
15
|
+
* follow the same shape: real vendor data (a populated column) wins when
|
|
16
|
+
* present, and the description is read only when the column is not — absence
|
|
17
|
+
* is a stated fact, not a gap to fill.
|
|
18
|
+
*
|
|
19
|
+
* **A per-record derivation is not a `Fact`.** These three are arithmetic over
|
|
20
|
+
* a row, so they belong in code with their evidence beside them; a fact is a
|
|
21
|
+
* per-family constant nothing in the table states, and putting one of these
|
|
22
|
+
* there would claim a whole family's provenance for a value that varies row by
|
|
23
|
+
* row.
|
|
24
|
+
*/
|
|
25
|
+
import { VendorResponseError } from '../../errors.js';
|
|
26
|
+
import { familyBrand } from '../../family.js';
|
|
27
|
+
import { BRANDS } from '../../identity.js';
|
|
28
|
+
import { ISO_MATERIAL_GROUPS, toolRecord, } from '../../records.js';
|
|
29
|
+
import { consoleWarn } from '../../scrape.js';
|
|
30
|
+
/**
|
|
31
|
+
* Destiny Tool's one identifier. There is no second catalog number the way
|
|
32
|
+
* Kennametal publishes an ISO number alongside a material number — the item
|
|
33
|
+
* number fills both roles on a record.
|
|
34
|
+
*/
|
|
35
|
+
export const ITEM_NUMBER = 'itemNumber';
|
|
36
|
+
/**
|
|
37
|
+
* `"1/8 SHK"`, `'1/4" SHK'` — a shank diameter, stated only when it differs
|
|
38
|
+
* from the cutting diameter (a necked or reduced-shank tool). Every value
|
|
39
|
+
* observed across the real scrape (2026-08-19, 642 of 3,898 rows) is a simple
|
|
40
|
+
* fraction, optionally quoted; {@link parseFractionInches} also handles the
|
|
41
|
+
* decimal and mixed-number forms `cutDia`/`loc`/`oal`/`rad` use, since a
|
|
42
|
+
* future SKU stating a shank that way is not implausible.
|
|
43
|
+
*/
|
|
44
|
+
const SHANK = /([\d.\-/"]+)\s*SHK/i;
|
|
45
|
+
/**
|
|
46
|
+
* `".035-.040 RAD"` — a corner-radius *range*, read only as a fallback when
|
|
47
|
+
* the vendor's own `rad` cell is blank. See {@link cornerRadius}.
|
|
48
|
+
*/
|
|
49
|
+
const RAD_RANGE = /([\d.]+)-([\d.]+)\s*RAD/i;
|
|
50
|
+
/**
|
|
51
|
+
* `".090 RAD"` — a single corner-radius value, same fallback role.
|
|
52
|
+
*
|
|
53
|
+
* **No word-boundary anchor before the capture group.** A boundary sits
|
|
54
|
+
* between a non-word `.` and a word digit, so `\b[\d.]+` on `.090 RAD` starts
|
|
55
|
+
* matching at the `0` and drops the leading dot — `090` parses as 90, not
|
|
56
|
+
* 0.09, and that silently produced a 90-inch corner radius the first time this
|
|
57
|
+
* ran against the real scrape (2026-08-19). The character class itself already
|
|
58
|
+
* excludes the comma and space that precede every real match, so nothing
|
|
59
|
+
* anchors the start position but the class.
|
|
60
|
+
*
|
|
61
|
+
* `(?<!-)` keeps this from matching the upper bound of a range as if it were a
|
|
62
|
+
* lone value when both patterns are tried against the same string; the caller
|
|
63
|
+
* tries {@link RAD_RANGE} first regardless, so this is a belt-and-suspenders
|
|
64
|
+
* guard rather than the thing doing the exclusion.
|
|
65
|
+
*/
|
|
66
|
+
const RAD_SINGLE = /(?<!-)([\d.]+)\s*RAD/i;
|
|
67
|
+
/**
|
|
68
|
+
* `".074 NECK"` — a neck (shoulder) diameter, stated only on necked tools (171
|
|
69
|
+
* of 3,898 rows, 2026-08-19). Always a plain decimal in the scraped data; no
|
|
70
|
+
* fraction or mixed-number form has been observed.
|
|
71
|
+
*/
|
|
72
|
+
const NECK = /([\d.]+)\s*NECK/i;
|
|
73
|
+
/**
|
|
74
|
+
* Flute counts at or below this route to the non-ferrous material-group
|
|
75
|
+
* fallback — see {@link materialGroups}.
|
|
76
|
+
*/
|
|
77
|
+
export const NON_FERROUS_MAX_FLUTES = 3;
|
|
78
|
+
/**
|
|
79
|
+
* A Destiny Tool dimension string, in inches.
|
|
80
|
+
*
|
|
81
|
+
* Every form seen across the real scrape (2026-08-19): a decimal (`.093`), a
|
|
82
|
+
* bare or quoted whole number (`1`, `1"`), a simple fraction (`3/4`), or a
|
|
83
|
+
* mixed number, quoted or not (`1-1/2`, `1-1/2"`).
|
|
84
|
+
*/
|
|
85
|
+
export function parseFractionInches(text) {
|
|
86
|
+
const s = text.trim().replace(/"+$/, '');
|
|
87
|
+
if (!s)
|
|
88
|
+
throw new RangeError(`empty dimension: ${JSON.stringify(text)}`);
|
|
89
|
+
const value = s.includes('.')
|
|
90
|
+
? Number(s)
|
|
91
|
+
: s.includes('-')
|
|
92
|
+
? mixed(s)
|
|
93
|
+
: s.includes('/')
|
|
94
|
+
? fraction(s)
|
|
95
|
+
: Number(s);
|
|
96
|
+
if (!Number.isFinite(value)) {
|
|
97
|
+
throw new RangeError(`unrecognized dimension: ${JSON.stringify(text)}`);
|
|
98
|
+
}
|
|
99
|
+
return value;
|
|
100
|
+
}
|
|
101
|
+
/** `1-1/2` — a whole number and a simple fraction. */
|
|
102
|
+
function mixed(s) {
|
|
103
|
+
const cut = s.indexOf('-');
|
|
104
|
+
return Number(s.slice(0, cut)) + fraction(s.slice(cut + 1));
|
|
105
|
+
}
|
|
106
|
+
/** `3/4`. */
|
|
107
|
+
function fraction(s) {
|
|
108
|
+
const [num, den] = s.split('/');
|
|
109
|
+
return Number(num) / Number(den);
|
|
110
|
+
}
|
|
111
|
+
/** A dimension the kind requires, parsed as an inch fraction. */
|
|
112
|
+
function required(row, columns, canonical, what) {
|
|
113
|
+
const column = columns.column(canonical, 'inches');
|
|
114
|
+
const raw = column === null ? undefined : row[column];
|
|
115
|
+
if (raw === undefined || raw.trim() === '') {
|
|
116
|
+
throw new VendorResponseError(what, `no value for ${canonical} in column ${JSON.stringify(column)}`);
|
|
117
|
+
}
|
|
118
|
+
return parseFractionInches(raw);
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* The shank diameter: parsed off a "SHK" annotation when the tool is necked or
|
|
122
|
+
* reduced-shank, or the cutting diameter otherwise.
|
|
123
|
+
*
|
|
124
|
+
* Destiny Tool has no structured shank column at all — unlike Kennametal,
|
|
125
|
+
* where an absent `D` column would be a scrape bug, here a shank equal to the
|
|
126
|
+
* cut diameter is simply never stated in the vendor's own text either (checked
|
|
127
|
+
* over the full scrape, 2026-08-19).
|
|
128
|
+
*/
|
|
129
|
+
export function shankDiameter(description, dc) {
|
|
130
|
+
const match = SHANK.exec(description);
|
|
131
|
+
return match?.[1] ? parseFractionInches(match[1]) : dc;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* The corner radius, in priority order.
|
|
135
|
+
*
|
|
136
|
+
* 1. The vendor's own `rad` cell, when populated — real data wins, and this is
|
|
137
|
+
* trusted outright the way every scraped column in this package is.
|
|
138
|
+
* 2. `DC / 2` on a `Ball` end mill, which Destiny Tool publishes with no
|
|
139
|
+
* radius column at all for that style (checked 2026-08-19).
|
|
140
|
+
* 3. The description's own "RAD" annotation, for the 123 of 3,898 rows found
|
|
141
|
+
* 2026-08-19 where `endStyle` is `"Corner Radius"` but the `rad` cell is
|
|
142
|
+
* blank and the text states one anyway. A range like `".035-.040 RAD"`
|
|
143
|
+
* resolves to its **upper** bound: across the 370 rows that state a range
|
|
144
|
+
* and also publish a populated `rad` cell, the cell equals the upper bound
|
|
145
|
+
* 352 times (95%) and the lower bound 18 times, so the upper bound is the
|
|
146
|
+
* better-corroborated guess for the rows where only the range is available.
|
|
147
|
+
*
|
|
148
|
+
* **Recovered from text, so it is checked rather than trusted outright.**
|
|
149
|
+
* `V33220R093` states `"0.93 RAD"` where its two siblings (identical
|
|
150
|
+
* geometry, different coating) both say `".093 RAD"` and the item number's
|
|
151
|
+
* own `093` suffix agrees with them — a vendor typo missing a leading zero,
|
|
152
|
+
* found running this against the real scrape. A value that would make the
|
|
153
|
+
* tool geometrically impossible (2×RE > DC) is not used; the row falls
|
|
154
|
+
* through to 4 instead, with a warning.
|
|
155
|
+
* 4. `0` — a real square end — when nothing states one (2 of 3,898 rows), or
|
|
156
|
+
* when 3 recovered a value this package will not ship.
|
|
157
|
+
*/
|
|
158
|
+
export function cornerRadius(description, endStyle, what, dc, radCell, warn = consoleWarn) {
|
|
159
|
+
if (radCell !== null)
|
|
160
|
+
return radCell;
|
|
161
|
+
if (endStyle === 'Ball')
|
|
162
|
+
return dc / 2;
|
|
163
|
+
const range = RAD_RANGE.exec(description);
|
|
164
|
+
const single = range ? null : RAD_SINGLE.exec(description);
|
|
165
|
+
const recovered = range?.[2] ? Number(range[2]) : single?.[1] ? Number(single[1]) : null;
|
|
166
|
+
if (recovered === null)
|
|
167
|
+
return 0;
|
|
168
|
+
if (recovered * 2 > dc) {
|
|
169
|
+
warn(` WARNING: ${what}: description states a corner radius of ` +
|
|
170
|
+
`${recovered}in, which exceeds half the ${dc}in cutting diameter — ` +
|
|
171
|
+
`likely a vendor typo; shipped as a flat end mill instead`);
|
|
172
|
+
return 0;
|
|
173
|
+
}
|
|
174
|
+
return recovered;
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* The neck (shoulder) diameter: parsed off a "NECK" annotation when the tool
|
|
178
|
+
* is necked, or the cutting diameter otherwise — a plain-shank tool below the
|
|
179
|
+
* flutes, the same convention a family with no neck column at all uses.
|
|
180
|
+
* Destiny Tool never publishes a structured neck column; the description
|
|
181
|
+
* states one on 171 of 3,898 rows (2026-08-19) and this reads it rather than
|
|
182
|
+
* defaulting every row to plain-shank.
|
|
183
|
+
*/
|
|
184
|
+
export function shoulderDiameter(description, dc) {
|
|
185
|
+
const match = NECK.exec(description);
|
|
186
|
+
return match?.[1] ? Number(match[1]) : dc;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* The ISO workpiece-material groups: the vendor's own `isoMaterialGroups`
|
|
190
|
+
* column when populated, or a fallback keyed on flute count when it is not
|
|
191
|
+
* (blank on 423 of 3,898 rows, 2026-08-19).
|
|
192
|
+
*
|
|
193
|
+
* The fallback is not a new rule invented for this vendor — it is the split
|
|
194
|
+
* cutting-data presets are routed by downstream (≤3 flutes non-ferrous, >3
|
|
195
|
+
* ferrous), applied here to the material-groups facet instead. Real vendor
|
|
196
|
+
* data wins when present: the full scrape shows 92 ≤3-flute rows whose stated
|
|
197
|
+
* groups are not exactly `['N']` and 168 >3-flute rows whose stated groups
|
|
198
|
+
* include `N`, so this is deliberately a fallback for the blank cells and not
|
|
199
|
+
* a correction of the populated ones.
|
|
200
|
+
*
|
|
201
|
+
* **The populated cell is reordered onto `ISO_MATERIAL_GROUPS`, not passed
|
|
202
|
+
* through in Destiny Tool's own order.** Its `isoMaterialGroups` array comes
|
|
203
|
+
* back as e.g. `['M', 'P', 'S']` — alphabetical-ish, not the ISO 513 sequence
|
|
204
|
+
* every other list agrees on — and a consumer that renders a facet from one
|
|
205
|
+
* array and a tool's own list from another has no way to notice the two
|
|
206
|
+
* disagree.
|
|
207
|
+
*/
|
|
208
|
+
export function materialGroups(row, flutes) {
|
|
209
|
+
const cell = row['isoMaterialGroups'] ?? '';
|
|
210
|
+
if (cell.trim()) {
|
|
211
|
+
const present = new Set(cell.split(/\s+/).filter(Boolean));
|
|
212
|
+
return ISO_MATERIAL_GROUPS.filter((group) => present.has(group));
|
|
213
|
+
}
|
|
214
|
+
if (flutes <= NON_FERROUS_MAX_FLUTES)
|
|
215
|
+
return ['N'];
|
|
216
|
+
return ['P', 'M', 'K', 'S', 'H'];
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* A solid end mill, always in inches — Destiny Tool publishes no metric line
|
|
220
|
+
* (the `unit` fact on the family).
|
|
221
|
+
*/
|
|
222
|
+
export function endmillRecord(row, family, columns, options = {}) {
|
|
223
|
+
const what = row[ITEM_NUMBER] ?? '';
|
|
224
|
+
const description = row['description'] ?? '';
|
|
225
|
+
const dc = required(row, columns, 'DC', what);
|
|
226
|
+
const fluteLength = required(row, columns, 'LCF', what);
|
|
227
|
+
const oal = required(row, columns, 'OAL', what);
|
|
228
|
+
const radColumn = columns.column('RE', 'inches');
|
|
229
|
+
const radRaw = radColumn === null ? undefined : row[radColumn];
|
|
230
|
+
const radCell = radRaw && radRaw.trim() ? parseFractionInches(radRaw) : null;
|
|
231
|
+
// Refused rather than allowed through as NaN: `materialGroups` reads it, and
|
|
232
|
+
// `NaN <= 3` is false, so a blank cell would silently classify the tool as
|
|
233
|
+
// ferrous P/M/K/S/H on no evidence. Kennametal's `count()` refuses the same
|
|
234
|
+
// shape for the same reason.
|
|
235
|
+
const flutes = Number.parseInt(row['flutes'] ?? '', 10);
|
|
236
|
+
if (!Number.isInteger(flutes)) {
|
|
237
|
+
throw new VendorResponseError(what, `no integer in column "flutes"`);
|
|
238
|
+
}
|
|
239
|
+
return toolRecord({
|
|
240
|
+
vendor: BRANDS[familyBrand(family)].vendor,
|
|
241
|
+
materialNumber: what,
|
|
242
|
+
catalogNumber: what,
|
|
243
|
+
description,
|
|
244
|
+
kind: 'endmill',
|
|
245
|
+
unit: 'inches',
|
|
246
|
+
substrate: (row['material'] || (family.bmc ?? '')).toLowerCase(),
|
|
247
|
+
// No carbide grade is published; the coating id fills GRADE instead.
|
|
248
|
+
grade: row['coatingId'] ?? '',
|
|
249
|
+
materialGroups: materialGroups(row, flutes),
|
|
250
|
+
coolantThrough: family.coolantThrough ?? false,
|
|
251
|
+
geometry: {
|
|
252
|
+
DC: dc,
|
|
253
|
+
RE: cornerRadius(description, row['endStyle'] ?? '', what, dc, radCell,
|
|
254
|
+
// `cornerRadius` owns the fallback; defaulting here too would be two
|
|
255
|
+
// layers deciding the same thing.
|
|
256
|
+
options.warn),
|
|
257
|
+
SFDM: shankDiameter(description, dc),
|
|
258
|
+
OAL: oal,
|
|
259
|
+
LCF: fluteLength,
|
|
260
|
+
'shoulder-length': fluteLength,
|
|
261
|
+
'shoulder-diameter': shoulderDiameter(description, dc),
|
|
262
|
+
NOF: flutes,
|
|
263
|
+
},
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
export const RECORD_MAPPERS = { endmill: endmillRecord };
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Destiny Tool's Firestore REST API -> End Mill rows.
|
|
3
|
+
*
|
|
4
|
+
* Not a page to parse: destinytool.com is a Next.js SPA built on Firebase
|
|
5
|
+
* Studio with no product data anywhere in its HTML. Every product lives in one
|
|
6
|
+
* Firestore collection, `products`, in project `studio-6030841929-4a1a2`, and
|
|
7
|
+
* the only transport is Firestore's own REST document API — unauthenticated
|
|
8
|
+
* reads against it work today (confirmed 2026-08-19):
|
|
9
|
+
*
|
|
10
|
+
* ```
|
|
11
|
+
* GET https://firestore.googleapis.com/v1/projects/{project}/databases/
|
|
12
|
+
* (default)/documents/products
|
|
13
|
+
* ?pageSize=300&pageToken=<token>&mask.fieldPaths=<field>&...
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* `documents.list` supports no server-side filter — that needs the separate
|
|
17
|
+
* `:runQuery` structured-query endpoint instead — so this pages through the
|
|
18
|
+
* **whole** collection (4,309 documents as of 2026-08-19) and narrows to
|
|
19
|
+
* `type == 'End Mill'` after decoding. `pageToken` is opaque and
|
|
20
|
+
* random-looking; nothing about pagination here assumes an order, so a page
|
|
21
|
+
* with a token but zero documents still stops the walk rather than looping.
|
|
22
|
+
*
|
|
23
|
+
* ## Column naming, and where it differs from every other vendor here
|
|
24
|
+
*
|
|
25
|
+
* Firestore field names carry no unit suffix — `cutDia`, not `cutDia_in` — so
|
|
26
|
+
* the four dimensional fields (`cutDia`, `loc`, `oal`, `rad`) are written
|
|
27
|
+
* **with** an `_in` suffix appended here, to fit `conventions.UNIT_SUFFIX` —
|
|
28
|
+
* the rule every other family's CSV already follows (`D1_mm`/`D1_in` on a
|
|
29
|
+
* Kennametal table). There is no `_mm` half to publish — Destiny Tool states
|
|
30
|
+
* every dimension in US customary fractional inches, see the `unit` fact on
|
|
31
|
+
* `families/destinytool` — so only the `_in` column exists.
|
|
32
|
+
*
|
|
33
|
+
* **This vendor is also the one that broke the identity convention**, and the
|
|
34
|
+
* break is visible in the header this module writes: `itemNumber` where every
|
|
35
|
+
* other CSV says `Material Number`. It is recorded in
|
|
36
|
+
* `conventions.IDENTITY_DEVIATIONS` rather than corrected, because the CSV is
|
|
37
|
+
* the receipt, and relabelling a vendor's own field in it would put a lie in
|
|
38
|
+
* the file whose job is to record what the vendor published.
|
|
39
|
+
*
|
|
40
|
+
* Every other field here keeps its Firestore name verbatim — no relabelling
|
|
41
|
+
* step, because the field *is* the vendor's own label.
|
|
42
|
+
*/
|
|
43
|
+
import type { Fetcher } from '../../fetch.js';
|
|
44
|
+
import type { ScrapeResult, ScrapedRow } from '../../scrape.js';
|
|
45
|
+
export declare const PROJECT = "studio-6030841929-4a1a2";
|
|
46
|
+
export declare const DOCUMENTS_URL: string;
|
|
47
|
+
/**
|
|
48
|
+
* Fields pulled from the `products` collection — everything `records.ts`
|
|
49
|
+
* reads, plus `series` and `angle` for the record (unused today, but the CSV
|
|
50
|
+
* is the receipt of what the vendor published, same as REGO-FIX's unmapped
|
|
51
|
+
* `DIN_*` columns).
|
|
52
|
+
*/
|
|
53
|
+
export declare const FIELDS: readonly ["itemNumber", "type", "description", "series", "cutDia", "loc", "oal", "rad", "flutes", "endStyle", "angle", "material", "isoMaterialGroups", "coatingId"];
|
|
54
|
+
/**
|
|
55
|
+
* The dimensional subset of {@link FIELDS} — see the module docstring for why
|
|
56
|
+
* these get an `_in` suffix and the rest do not.
|
|
57
|
+
*/
|
|
58
|
+
export declare const DIMENSIONAL_FIELDS: ReadonlySet<string>;
|
|
59
|
+
export declare const PAGE_SIZE = 300;
|
|
60
|
+
/** A decoded Firestore field value. */
|
|
61
|
+
export type FirestoreValue = string | number | boolean | null | FirestoreValue[];
|
|
62
|
+
/** The CSV column a Firestore field is written under. */
|
|
63
|
+
export declare function columnFor(field: string): string;
|
|
64
|
+
/** The CSV header this scrape writes, positional. */
|
|
65
|
+
export declare const HEADER: readonly string[];
|
|
66
|
+
/** The `documents.list` URL for one page. */
|
|
67
|
+
export declare function pageUrl(token: string | null): string;
|
|
68
|
+
/**
|
|
69
|
+
* One Firestore `Value` -> a plain value.
|
|
70
|
+
*
|
|
71
|
+
* The REST API wraps every field in a type tag (`{"stringValue": "..."}`,
|
|
72
|
+
* `{"arrayValue": {"values": [...]}}`) so that a document can be typed without
|
|
73
|
+
* a schema; nothing downstream of this function should have to know that shape.
|
|
74
|
+
*/
|
|
75
|
+
export declare function decodeValue(value: Record<string, unknown>): FirestoreValue;
|
|
76
|
+
/** One `documents.list` entry -> its fields, decoded and flattened. */
|
|
77
|
+
export declare function decodeDocument(document: {
|
|
78
|
+
fields?: Record<string, Record<string, unknown>>;
|
|
79
|
+
}): Record<string, FirestoreValue>;
|
|
80
|
+
/**
|
|
81
|
+
* Every document in the `products` collection, decoded, unfiltered.
|
|
82
|
+
*
|
|
83
|
+
* One request per {@link PAGE_SIZE} documents. `documents.list` returns rows in
|
|
84
|
+
* document-ID order, and Firestore auto-IDs are random, so nothing about
|
|
85
|
+
* pagination here can be an artifact of insertion order.
|
|
86
|
+
*/
|
|
87
|
+
export declare function fetchProducts(fetcher: Fetcher): Promise<Record<string, FirestoreValue>[]>;
|
|
88
|
+
/**
|
|
89
|
+
* A decoded Firestore value as a CSV cell.
|
|
90
|
+
*
|
|
91
|
+
* `isoMaterialGroups` is the one array field here; it is written
|
|
92
|
+
* space-separated, which is the multi-value convention every vendor's CSV
|
|
93
|
+
* follows (`Material Groups` on a Kennametal table) — `records.ts` reads it
|
|
94
|
+
* back by splitting on whitespace.
|
|
95
|
+
*/
|
|
96
|
+
export declare function csvCell(value: FirestoreValue | undefined): string;
|
|
97
|
+
/** One decoded product as a row under this scrape's own column labels. */
|
|
98
|
+
export declare function toRow(product: Record<string, FirestoreValue>): ScrapedRow;
|
|
99
|
+
/**
|
|
100
|
+
* Every `End Mill` row, sorted by item number.
|
|
101
|
+
*
|
|
102
|
+
* Filtering to `type == 'End Mill'` happens here rather than server-side —
|
|
103
|
+
* `documents.list` has no filter parameter — so the whole collection is
|
|
104
|
+
* fetched and narrowed after decoding. A collection with zero matching rows is
|
|
105
|
+
* refused rather than returned empty: it is the difference between "the vendor
|
|
106
|
+
* published nothing" and "this broke."
|
|
107
|
+
*/
|
|
108
|
+
export declare function scrapeEndMills(fetcher: Fetcher): Promise<ScrapeResult>;
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Destiny Tool's Firestore REST API -> End Mill rows.
|
|
3
|
+
*
|
|
4
|
+
* Not a page to parse: destinytool.com is a Next.js SPA built on Firebase
|
|
5
|
+
* Studio with no product data anywhere in its HTML. Every product lives in one
|
|
6
|
+
* Firestore collection, `products`, in project `studio-6030841929-4a1a2`, and
|
|
7
|
+
* the only transport is Firestore's own REST document API — unauthenticated
|
|
8
|
+
* reads against it work today (confirmed 2026-08-19):
|
|
9
|
+
*
|
|
10
|
+
* ```
|
|
11
|
+
* GET https://firestore.googleapis.com/v1/projects/{project}/databases/
|
|
12
|
+
* (default)/documents/products
|
|
13
|
+
* ?pageSize=300&pageToken=<token>&mask.fieldPaths=<field>&...
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* `documents.list` supports no server-side filter — that needs the separate
|
|
17
|
+
* `:runQuery` structured-query endpoint instead — so this pages through the
|
|
18
|
+
* **whole** collection (4,309 documents as of 2026-08-19) and narrows to
|
|
19
|
+
* `type == 'End Mill'` after decoding. `pageToken` is opaque and
|
|
20
|
+
* random-looking; nothing about pagination here assumes an order, so a page
|
|
21
|
+
* with a token but zero documents still stops the walk rather than looping.
|
|
22
|
+
*
|
|
23
|
+
* ## Column naming, and where it differs from every other vendor here
|
|
24
|
+
*
|
|
25
|
+
* Firestore field names carry no unit suffix — `cutDia`, not `cutDia_in` — so
|
|
26
|
+
* the four dimensional fields (`cutDia`, `loc`, `oal`, `rad`) are written
|
|
27
|
+
* **with** an `_in` suffix appended here, to fit `conventions.UNIT_SUFFIX` —
|
|
28
|
+
* the rule every other family's CSV already follows (`D1_mm`/`D1_in` on a
|
|
29
|
+
* Kennametal table). There is no `_mm` half to publish — Destiny Tool states
|
|
30
|
+
* every dimension in US customary fractional inches, see the `unit` fact on
|
|
31
|
+
* `families/destinytool` — so only the `_in` column exists.
|
|
32
|
+
*
|
|
33
|
+
* **This vendor is also the one that broke the identity convention**, and the
|
|
34
|
+
* break is visible in the header this module writes: `itemNumber` where every
|
|
35
|
+
* other CSV says `Material Number`. It is recorded in
|
|
36
|
+
* `conventions.IDENTITY_DEVIATIONS` rather than corrected, because the CSV is
|
|
37
|
+
* the receipt, and relabelling a vendor's own field in it would put a lie in
|
|
38
|
+
* the file whose job is to record what the vendor published.
|
|
39
|
+
*
|
|
40
|
+
* Every other field here keeps its Firestore name verbatim — no relabelling
|
|
41
|
+
* step, because the field *is* the vendor's own label.
|
|
42
|
+
*/
|
|
43
|
+
import { VendorResponseError } from '../../errors.js';
|
|
44
|
+
import { compare } from '../../order.js';
|
|
45
|
+
export const PROJECT = 'studio-6030841929-4a1a2';
|
|
46
|
+
export const DOCUMENTS_URL = `https://firestore.googleapis.com/v1/projects/${PROJECT}/databases/` +
|
|
47
|
+
`(default)/documents/products`;
|
|
48
|
+
/**
|
|
49
|
+
* Fields pulled from the `products` collection — everything `records.ts`
|
|
50
|
+
* reads, plus `series` and `angle` for the record (unused today, but the CSV
|
|
51
|
+
* is the receipt of what the vendor published, same as REGO-FIX's unmapped
|
|
52
|
+
* `DIN_*` columns).
|
|
53
|
+
*/
|
|
54
|
+
export const FIELDS = [
|
|
55
|
+
'itemNumber',
|
|
56
|
+
'type',
|
|
57
|
+
'description',
|
|
58
|
+
'series',
|
|
59
|
+
'cutDia',
|
|
60
|
+
'loc',
|
|
61
|
+
'oal',
|
|
62
|
+
'rad',
|
|
63
|
+
'flutes',
|
|
64
|
+
'endStyle',
|
|
65
|
+
'angle',
|
|
66
|
+
'material',
|
|
67
|
+
'isoMaterialGroups',
|
|
68
|
+
'coatingId',
|
|
69
|
+
];
|
|
70
|
+
/**
|
|
71
|
+
* The dimensional subset of {@link FIELDS} — see the module docstring for why
|
|
72
|
+
* these get an `_in` suffix and the rest do not.
|
|
73
|
+
*/
|
|
74
|
+
export const DIMENSIONAL_FIELDS = new Set(['cutDia', 'loc', 'oal', 'rad']);
|
|
75
|
+
export const PAGE_SIZE = 300;
|
|
76
|
+
/** The tool type this scrape narrows to. */
|
|
77
|
+
const END_MILL = 'End Mill';
|
|
78
|
+
/** The CSV column a Firestore field is written under. */
|
|
79
|
+
export function columnFor(field) {
|
|
80
|
+
return DIMENSIONAL_FIELDS.has(field) ? `${field}_in` : field;
|
|
81
|
+
}
|
|
82
|
+
/** The CSV header this scrape writes, positional. */
|
|
83
|
+
export const HEADER = FIELDS.map(columnFor);
|
|
84
|
+
/** The `documents.list` URL for one page. */
|
|
85
|
+
export function pageUrl(token) {
|
|
86
|
+
const params = new URLSearchParams();
|
|
87
|
+
params.set('pageSize', String(PAGE_SIZE));
|
|
88
|
+
for (const field of FIELDS)
|
|
89
|
+
params.append('mask.fieldPaths', field);
|
|
90
|
+
if (token)
|
|
91
|
+
params.append('pageToken', token);
|
|
92
|
+
return `${DOCUMENTS_URL}?${params.toString()}`;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* One Firestore `Value` -> a plain value.
|
|
96
|
+
*
|
|
97
|
+
* The REST API wraps every field in a type tag (`{"stringValue": "..."}`,
|
|
98
|
+
* `{"arrayValue": {"values": [...]}}`) so that a document can be typed without
|
|
99
|
+
* a schema; nothing downstream of this function should have to know that shape.
|
|
100
|
+
*/
|
|
101
|
+
export function decodeValue(value) {
|
|
102
|
+
if ('stringValue' in value)
|
|
103
|
+
return value['stringValue'];
|
|
104
|
+
if ('integerValue' in value) {
|
|
105
|
+
return Number.parseInt(value['integerValue'], 10);
|
|
106
|
+
}
|
|
107
|
+
if ('doubleValue' in value)
|
|
108
|
+
return value['doubleValue'];
|
|
109
|
+
if ('booleanValue' in value)
|
|
110
|
+
return value['booleanValue'];
|
|
111
|
+
if ('nullValue' in value)
|
|
112
|
+
return null;
|
|
113
|
+
if ('arrayValue' in value) {
|
|
114
|
+
const array = value['arrayValue'];
|
|
115
|
+
return (array.values ?? []).map(decodeValue);
|
|
116
|
+
}
|
|
117
|
+
throw new VendorResponseError(DOCUMENTS_URL, `unrecognized Firestore value shape: ${Object.keys(value).sort().join(', ')}`);
|
|
118
|
+
}
|
|
119
|
+
/** One `documents.list` entry -> its fields, decoded and flattened. */
|
|
120
|
+
export function decodeDocument(document) {
|
|
121
|
+
const decoded = {};
|
|
122
|
+
for (const [key, value] of Object.entries(document.fields ?? {})) {
|
|
123
|
+
decoded[key] = decodeValue(value);
|
|
124
|
+
}
|
|
125
|
+
return decoded;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Every document in the `products` collection, decoded, unfiltered.
|
|
129
|
+
*
|
|
130
|
+
* One request per {@link PAGE_SIZE} documents. `documents.list` returns rows in
|
|
131
|
+
* document-ID order, and Firestore auto-IDs are random, so nothing about
|
|
132
|
+
* pagination here can be an artifact of insertion order.
|
|
133
|
+
*/
|
|
134
|
+
export async function fetchProducts(fetcher) {
|
|
135
|
+
const documents = [];
|
|
136
|
+
let token = null;
|
|
137
|
+
for (;;) {
|
|
138
|
+
const page = await fetcher.json(pageUrl(token));
|
|
139
|
+
const docs = page.documents ?? [];
|
|
140
|
+
documents.push(...docs.map(decodeDocument));
|
|
141
|
+
token = page.nextPageToken ?? null;
|
|
142
|
+
if (!token || docs.length === 0)
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
return documents;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* A decoded Firestore value as a CSV cell.
|
|
149
|
+
*
|
|
150
|
+
* `isoMaterialGroups` is the one array field here; it is written
|
|
151
|
+
* space-separated, which is the multi-value convention every vendor's CSV
|
|
152
|
+
* follows (`Material Groups` on a Kennametal table) — `records.ts` reads it
|
|
153
|
+
* back by splitting on whitespace.
|
|
154
|
+
*/
|
|
155
|
+
export function csvCell(value) {
|
|
156
|
+
if (value === undefined || value === null)
|
|
157
|
+
return '';
|
|
158
|
+
if (Array.isArray(value))
|
|
159
|
+
return value.map((v) => String(v)).join(' ');
|
|
160
|
+
return String(value);
|
|
161
|
+
}
|
|
162
|
+
/** One decoded product as a row under this scrape's own column labels. */
|
|
163
|
+
export function toRow(product) {
|
|
164
|
+
const row = {};
|
|
165
|
+
for (const field of FIELDS)
|
|
166
|
+
row[columnFor(field)] = csvCell(product[field]);
|
|
167
|
+
return row;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Every `End Mill` row, sorted by item number.
|
|
171
|
+
*
|
|
172
|
+
* Filtering to `type == 'End Mill'` happens here rather than server-side —
|
|
173
|
+
* `documents.list` has no filter parameter — so the whole collection is
|
|
174
|
+
* fetched and narrowed after decoding. A collection with zero matching rows is
|
|
175
|
+
* refused rather than returned empty: it is the difference between "the vendor
|
|
176
|
+
* published nothing" and "this broke."
|
|
177
|
+
*/
|
|
178
|
+
export async function scrapeEndMills(fetcher) {
|
|
179
|
+
const products = await fetchProducts(fetcher);
|
|
180
|
+
const matching = products.filter((p) => p['type'] === END_MILL);
|
|
181
|
+
if (matching.length === 0) {
|
|
182
|
+
throw new VendorResponseError(DOCUMENTS_URL, `no End Mill rows among ${products.length} products — the schema or ` +
|
|
183
|
+
`the type label changed`);
|
|
184
|
+
}
|
|
185
|
+
matching.sort((a, b) => compare(String(a['itemNumber']), String(b['itemNumber'])));
|
|
186
|
+
return {
|
|
187
|
+
header: HEADER,
|
|
188
|
+
rows: matching.map(toRow),
|
|
189
|
+
source: DOCUMENTS_URL,
|
|
190
|
+
familyCode: null,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vendor CAD model URLs: material number in, a static STEP link out.
|
|
3
|
+
*
|
|
4
|
+
* Kennametal's product pages don't host their CAD models — a third party does
|
|
5
|
+
* (CDS Visual, on `product-config.net`), and the page reaches it in one of two
|
|
6
|
+
* ways. For an *assembly* it POSTs a job, polls a batch, and gets back a
|
|
7
|
+
* transient generated ZIP. For a **single part with no child components** —
|
|
8
|
+
* every holder in this catalog — it takes a different branch entirely and asks
|
|
9
|
+
* for a pre-built static file:
|
|
10
|
+
*
|
|
11
|
+
* ```
|
|
12
|
+
* GET https://www.product-config.net/catalog3/cad?d=kennametal&id=<material>
|
|
13
|
+
* ```
|
|
14
|
+
*
|
|
15
|
+
* That returns `staticURLs`, a map of format key -> permanent CloudFront URL,
|
|
16
|
+
* and the files behind it are ordinary objects with a `Last-Modified` in 2024.
|
|
17
|
+
* The response also states `authenticatedDownload: false`, which is what makes
|
|
18
|
+
* a direct link viable: no login, no session, no token.
|
|
19
|
+
*
|
|
20
|
+
* `docs/KENNAMETAL_CAD_API.md` documents the endpoint and the format keys.
|
|
21
|
+
* This module scrapes one of them — `stp-lwm`, the lightweight STEP, which is
|
|
22
|
+
* the collision model to give a CAM package as holder geometry.
|
|
23
|
+
*
|
|
24
|
+
* ## Why the download half is not in this module
|
|
25
|
+
*
|
|
26
|
+
* Mirroring every STEP file onto disk is a maintainer's batch job — it takes
|
|
27
|
+
* an output directory, writes ~54 KB per part with a rate-limit pause between,
|
|
28
|
+
* and has no return value a caller wants. It lives in `node/cad-mirror.ts` and
|
|
29
|
+
* is reachable only from the CLI.
|
|
30
|
+
*
|
|
31
|
+
* What a backend consuming this package wants is the permanent URL, which is
|
|
32
|
+
* what {@link lightweightStepUrl} and {@link annotateCadUrls} give it — to
|
|
33
|
+
* link to, or to fetch on demand, rather than to bulk-mirror. The seam was
|
|
34
|
+
* already here: the annotate step writes a URL precisely so that downloading
|
|
35
|
+
* is a separate, later, optional step.
|
|
36
|
+
*/
|
|
37
|
+
import { type Fetcher } from '../../fetch.js';
|
|
38
|
+
import { type ScrapeResult } from '../../scrape.js';
|
|
39
|
+
export declare const CAD_API = "https://www.product-config.net/catalog3/cad?d=kennametal&id={material}";
|
|
40
|
+
/**
|
|
41
|
+
* The `staticURLs` key for the lightweight STEP — CDS calls it LWM, the vendor
|
|
42
|
+
* UI calls it "3D Anti Collision Model", and it is the simplified solid rather
|
|
43
|
+
* than the full graphical model (`stp-gtm`).
|
|
44
|
+
*
|
|
45
|
+
* The column it is written to is `conventions.CAD_COLUMN`, shared with every
|
|
46
|
+
* other vendor's scraper because a consumer reads exactly one. What is
|
|
47
|
+
* Kennametal-specific is *which* of CDS Visual's formats fills it — that is
|
|
48
|
+
* this constant, and it stays here.
|
|
49
|
+
*/
|
|
50
|
+
export declare const LIGHTWEIGHT_STEP = "stp-lwm";
|
|
51
|
+
/** The subset of the CDS payload this module reads. */
|
|
52
|
+
export interface CadPayload {
|
|
53
|
+
cadAvailable?: boolean;
|
|
54
|
+
staticURLs?: Record<string, unknown>;
|
|
55
|
+
}
|
|
56
|
+
/** The CAD metadata for one material number. */
|
|
57
|
+
export declare function fetchCad(fetcher: Fetcher, material: string): Promise<CadPayload>;
|
|
58
|
+
/**
|
|
59
|
+
* The lightweight STEP URL from a CAD payload, or null when there is none.
|
|
60
|
+
*
|
|
61
|
+
* Null is a real state and not an error: the vendor's own UI carries a "we do
|
|
62
|
+
* not have any CAD models available for download" case, and a holder without a
|
|
63
|
+
* published model is a holder this package should say nothing about rather
|
|
64
|
+
* than offer a dead link for. All twenty holders scraped so far do have one,
|
|
65
|
+
* which is exactly why the absent case needs a test rather than a reassuring
|
|
66
|
+
* assumption.
|
|
67
|
+
*/
|
|
68
|
+
export declare function lightweightStepUrl(payload: CadPayload): string | null;
|
|
69
|
+
/** What {@link annotateCadUrls} answers with. */
|
|
70
|
+
export interface CadAnnotation {
|
|
71
|
+
scrape: ScrapeResult;
|
|
72
|
+
/**
|
|
73
|
+
* How many rows got a URL — deliberately not the row count, so a run that
|
|
74
|
+
* silently found nothing reads as `0 of 12` at the call site instead of as
|
|
75
|
+
* success.
|
|
76
|
+
*/
|
|
77
|
+
found: number;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Add (or refresh) the CAD model column on a toolholding scrape.
|
|
81
|
+
*
|
|
82
|
+
* Safe to re-run, like the thread-pitch and material-group steps: an existing
|
|
83
|
+
* column is rebuilt rather than duplicated. A row whose lookup finds no model
|
|
84
|
+
* keeps an empty cell; the row is never dropped, because the holder still
|
|
85
|
+
* exists.
|
|
86
|
+
*/
|
|
87
|
+
export declare function annotateCadUrls(fetcher: Fetcher, scrape: ScrapeResult, delayMs?: number): Promise<CadAnnotation>;
|