@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,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>;