@toolpath/tool-scraper 0.1.0 → 2.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 (76) hide show
  1. package/README.md +41 -11
  2. package/dist/columns.d.ts +64 -0
  3. package/dist/columns.js +68 -0
  4. package/dist/conventions.d.ts +107 -15
  5. package/dist/conventions.js +126 -15
  6. package/dist/errors.d.ts +30 -0
  7. package/dist/errors.js +30 -0
  8. package/dist/families/emuge.d.ts +185 -0
  9. package/dist/families/emuge.js +163 -0
  10. package/dist/families/harvey.d.ts +1782 -0
  11. package/dist/families/harvey.js +1328 -0
  12. package/dist/families/index.js +5 -2
  13. package/dist/families/kennametal.d.ts +21 -0
  14. package/dist/families/kennametal.js +10 -0
  15. package/dist/families/maritool.d.ts +120 -0
  16. package/dist/families/maritool.js +175 -0
  17. package/dist/family.d.ts +35 -1
  18. package/dist/family.js +28 -0
  19. package/dist/identity.d.ts +18 -0
  20. package/dist/identity.js +46 -0
  21. package/dist/index.d.ts +16 -0
  22. package/dist/index.js +16 -0
  23. package/dist/measure.d.ts +112 -0
  24. package/dist/measure.js +130 -0
  25. package/dist/node/cli.d.ts +3 -0
  26. package/dist/node/cli.js +158 -3
  27. package/dist/node/main.js +0 -0
  28. package/dist/records.d.ts +214 -12
  29. package/dist/records.js +140 -5
  30. package/dist/registry.d.ts +56 -1
  31. package/dist/registry.js +79 -2
  32. package/dist/scrape.d.ts +15 -0
  33. package/dist/scrape.js +24 -0
  34. package/dist/vendors/destinytool/records.d.ts +13 -3
  35. package/dist/vendors/destinytool/records.js +49 -37
  36. package/dist/vendors/emuge/index.d.ts +17 -0
  37. package/dist/vendors/emuge/index.js +17 -0
  38. package/dist/vendors/emuge/records.d.ts +230 -0
  39. package/dist/vendors/emuge/records.js +543 -0
  40. package/dist/vendors/emuge/scrape.d.ts +227 -0
  41. package/dist/vendors/emuge/scrape.js +358 -0
  42. package/dist/vendors/emuge/value.d.ts +75 -0
  43. package/dist/vendors/emuge/value.js +116 -0
  44. package/dist/vendors/harvey/catalog.d.ts +53 -0
  45. package/dist/vendors/harvey/catalog.js +120 -0
  46. package/dist/vendors/harvey/header.d.ts +89 -0
  47. package/dist/vendors/harvey/header.js +185 -0
  48. package/dist/vendors/harvey/index.d.ts +21 -0
  49. package/dist/vendors/harvey/index.js +21 -0
  50. package/dist/vendors/harvey/lexicon.d.ts +73 -0
  51. package/dist/vendors/harvey/lexicon.js +126 -0
  52. package/dist/vendors/harvey/literal.d.ts +68 -0
  53. package/dist/vendors/harvey/literal.js +214 -0
  54. package/dist/vendors/harvey/records.d.ts +79 -0
  55. package/dist/vendors/harvey/records.js +163 -0
  56. package/dist/vendors/harvey/scrape.d.ts +187 -0
  57. package/dist/vendors/harvey/scrape.js +483 -0
  58. package/dist/vendors/harvey/value.d.ts +74 -0
  59. package/dist/vendors/harvey/value.js +119 -0
  60. package/dist/vendors/kennametal/family.d.ts +119 -0
  61. package/dist/vendors/kennametal/family.js +155 -0
  62. package/dist/vendors/kennametal/index.d.ts +1 -0
  63. package/dist/vendors/kennametal/index.js +1 -0
  64. package/dist/vendors/kennametal/records.d.ts +29 -4
  65. package/dist/vendors/kennametal/records.js +93 -26
  66. package/dist/vendors/kennametal/scrape.d.ts +25 -2
  67. package/dist/vendors/kennametal/scrape.js +28 -3
  68. package/dist/vendors/maritool/catalog.d.ts +81 -0
  69. package/dist/vendors/maritool/catalog.js +132 -0
  70. package/dist/vendors/maritool/index.d.ts +10 -0
  71. package/dist/vendors/maritool/index.js +10 -0
  72. package/dist/vendors/maritool/scrape.d.ts +297 -0
  73. package/dist/vendors/maritool/scrape.js +593 -0
  74. package/dist/vendors/regofix/scrape.d.ts +8 -11
  75. package/dist/vendors/regofix/scrape.js +21 -36
  76. package/package.json +31 -7
@@ -0,0 +1,543 @@
1
+ /**
2
+ * EMUGE-FRANKEN rows -> {@link ToolRecord}.
3
+ *
4
+ * The CSV a scrape writes holds the vendor's own value strings — `1 1/2 "`,
5
+ * `3 mm`, `140 deg`, `4` — rather than parsed numbers, because the file is the
6
+ * receipt and EMUGE's fractional inches and stated units are part of what it
7
+ * published. So this module is where a cell becomes a number, through
8
+ * `value.ts`. The closest precedent is Harvey Tool's, not Kennametal's, whose
9
+ * columns are already decimals.
10
+ *
11
+ * ## What EMUGE publishes that nobody else here does
12
+ *
13
+ * - **Both identity columns.** The 18-digit SAP material number and the catalog
14
+ * article code, per part, so there is no `conventions.IDENTITY_DEVIATIONS`
15
+ * entry — the first vendor since Kennametal that needs none.
16
+ * - **A point angle, per drill.** Kennametal's drill families assume theirs; the
17
+ * detail record states it, so `SIG` is a mapped column here and no fact. On
18
+ * all but one part: 2,669 of 2,670 drill variants state one, and part
19
+ * `000000000010727800` publishes a single classification feature and no
20
+ * dimensional properties at all — so its row carries no `SIG` **key**, not
21
+ * an empty one. That is why `records.RECORD_GEOMETRY.drill` lists `SIG`
22
+ * under `sometimes`, and why {@link angle} reads the row rather than `cell`.
23
+ * - **A per-part ISO 513 index.** `applicationMaterials` returns the vendor's
24
+ * own P/M/K/N/S/H rating for each part, which fills `materialGroups` as
25
+ * `vendor-stated`.
26
+ * - **A description for every part.** The grouped product's `productListInfo`
27
+ * is one sentence of the vendor's own prose per product line, so every kind
28
+ * here fills `description` from `conventions.DESCRIPTION_COLUMN` — including
29
+ * the tap, where Kennametal has to fall back to the thread designation
30
+ * because it publishes no such text. EMUGE's designation is on the CSV as
31
+ * `dimensionFeatureValue` and its thread is on the record as `TP`.
32
+ *
33
+ * ## What it does not publish
34
+ *
35
+ * **No flute count on a drill or a tap.** Not on the grouped product, the
36
+ * variant listing, the per-part detail or any facet. A drill's comes from the
37
+ * family's `flutes` fact — all seventeen drill groups state
38
+ * `Specification: Twist drill` — and a tap's is simply absent, which is why
39
+ * `records.RECORD_GEOMETRY.tap` lists `NOF` under `sometimes`. EMUGE's own tap
40
+ * families run 2, 3 and 4 flutes across a size range, so no per-family constant
41
+ * could be true of every row, and 0 is not a substitute for a number nobody
42
+ * stated.
43
+ *
44
+ * **No CAD a scrape can reach.** The STEP, DXF and DIN 4000 documents are
45
+ * published behind a login — `anonymousAccess: false`, with both URL fields
46
+ * null — so neither `conventions.CAD_COLUMN` nor `CAD_DXF_COLUMN` is written.
47
+ * See `docs/EMUGE_FRANKEN_COMMERCE_API.md`.
48
+ */
49
+ import { columnReaders } from '../../columns.js';
50
+ import { DESCRIPTION_COLUMN } from '../../conventions.js';
51
+ import { VendorResponseError } from '../../errors.js';
52
+ import { fact, familyBrand } from '../../family.js';
53
+ import { BRANDS } from '../../identity.js';
54
+ import { ISO_MATERIAL_GROUPS, toolRecord, UNSPECIFIED, } from '../../records.js';
55
+ import { consoleWarn } from '../../scrape.js';
56
+ import { APPLICATION_MATERIALS_COLUMN, CATALOG_NUMBER_COLUMN, MATERIAL_NUMBER_COLUMN, } from './scrape.js';
57
+ import { measureIn, parseMeasure, wholeCount } from './value.js';
58
+ /** The cutting-material property, spelled the same way in all three categories. */
59
+ export const SUBSTRATE_COLUMN = 'Cutting material';
60
+ /**
61
+ * The coating property, which is **not** spelled the same way.
62
+ *
63
+ * `coating` on a milling part and `Coating` on a drill or a tap, in the same
64
+ * API, on the same day. Both are read and the first non-empty one wins, because
65
+ * relabelling one onto the other in the CSV would be this adapter deciding what
66
+ * the vendor meant — see `scrape.ts`.
67
+ */
68
+ export const COATING_COLUMNS = ['coating', 'Coating'];
69
+ /**
70
+ * Whether a part is through-coolant, by the vendor's own word for it.
71
+ *
72
+ * Three columns and three vocabularies, one per category, each a closed facet
73
+ * this package read off the vendor's own index on 2026-09-01 — so a value
74
+ * absent from here is a vocabulary that changed rather than a part that is
75
+ * odd, and {@link coolantThrough} refuses it by naming the table to add to.
76
+ *
77
+ * ## The facet does not cover the whole of milling
78
+ *
79
+ * The values below account for 6,862 milling variants, 2,670 drilling and
80
+ * 11,566 tapping. Drilling and tapping are the whole category — the same two
81
+ * numbers `docs/EMUGE_FRANKEN_COMMERCE_API.md` §4 gives — and milling is
82
+ * **159 short** of `FF01`'s 7,021, which is also `families/emuge.ts`'s two row
83
+ * counts added up. So 159 milling variants carry no `internal coolant supply`
84
+ * value at all.
85
+ *
86
+ * That is a gap in the vendor's index rather than a vocabulary that moved, and
87
+ * it is why {@link coolantThrough} separates the two cases: an unrecognised
88
+ * *value* still refuses, and a column nobody filled warns and records `false`.
89
+ * They were one case until 2026-09-01, and because `registry.toRecords` maps
90
+ * its rows, a single such part threw and took the whole family's conversion
91
+ * with it — 159 unindexed variants for two dead end mill CSVs.
92
+ */
93
+ export const COOLANT_COLUMNS = {
94
+ // `HYB_AMM_KMIZU`, milling.
95
+ 'internal coolant supply': {
96
+ 'ICA - Axial hole': true,
97
+ 'ICR - Radial hole': true,
98
+ 'ICRA - Radial & Axial holes': true,
99
+ 'Without internal cooling': false,
100
+ },
101
+ // `HYB_TAM_IKZ`, drilling. Note that "external coolant supply" is a real
102
+ // statement rather than a blank: it is how EMUGE says a drill has no through
103
+ // hole, and 164 of its 2,670 drill variants are it.
104
+ 'Coolant supply': {
105
+ 'internal coolant supply': true,
106
+ 'external coolant supply': false,
107
+ },
108
+ // `HYB_BAM_IKZ`, tapping.
109
+ 'coolant supply': {
110
+ 'IKZ - Axial hole': true,
111
+ 'IKZN - Radial hole': true,
112
+ Without: false,
113
+ },
114
+ };
115
+ /**
116
+ * The vendor's cutting materials, onto the package's own vocabulary.
117
+ *
118
+ * `FamilyFacts.bmc` names a material class — `carbide`, `hss`, `diamond` — and
119
+ * EMUGE's index names an alloy and a production route: HSS, HSSE and HSSE-PM
120
+ * are three grades of high-speed steel and all three are `hss`. `PCD` is
121
+ * `diamond`, which is the call `families/kennametal.ts` already made for its
122
+ * PCD drills: the word names the cutting material rather than the body it is
123
+ * brazed to.
124
+ *
125
+ * `cbn` and `ceramic` keep the vendor's own name, lowercased, because the
126
+ * package's three words have no counterpart for either and inventing one would
127
+ * be worse than recording what is standard. Closed, and it throws on anything
128
+ * else — the seven values below are the whole of the vendor's `HYB_AAM_MAT`
129
+ * facet across all three categories (2026-09-01).
130
+ */
131
+ export const SUBSTRATES = {
132
+ carbide: 'carbide',
133
+ HSS: 'hss',
134
+ HSSE: 'hss',
135
+ 'HSSE-PM': 'hss',
136
+ PCD: 'diamond',
137
+ CBN: 'cbn',
138
+ ceramic: 'ceramic',
139
+ };
140
+ /**
141
+ * The column each EMUGE-FRANKEN category states its product line in, keyed by
142
+ * the vendor's own category code.
143
+ *
144
+ * **One column per category, and each is a facet that partitions its category
145
+ * exactly.** Checked against the vendor's own index on 2026-09-01, at group
146
+ * level rather than by summing counts:
147
+ *
148
+ * | Category | Facet | Values | Groups it covers | Groups in two values |
149
+ * | -------- | -------------------- | ------ | ---------------- | -------------------- |
150
+ * | `FF01` | `AMM_PROG_LINIE` | 15 | 554 of 554 | 2 |
151
+ * | `FB01` | `HYB_BAM_SB_GT` | 4 | 17 of 17 | 0 |
152
+ * | `FG01` | `HYB_BAM_SB_GT` | 17 | 414 of 414 | 0 |
153
+ *
154
+ * That is what makes a product line here a **read and not an arbitration**.
155
+ * EMUGE's marketing publishes 43 overlapping product-family pages — a tap is
156
+ * simultaneously "Rekord B-Z Taps", "Enorm Z Taps" and "Left Hand Taps" — and
157
+ * choosing between those would be this package inventing a rule the vendor
158
+ * never stated. The facets above are the vendor's own partition of the same
159
+ * catalog, so there is nothing to choose.
160
+ *
161
+ * **The two milling groups that fall in two values cost nothing**, because the
162
+ * milling column is read per part: `H300024` and `H300025` each hold both
163
+ * `FRANKEN TiNox-Cut` and `FRANKEN TiNox-Cut VAR` variants, and the per-part
164
+ * detail record states which is which. A rule that tagged a whole group would
165
+ * have had to pick.
166
+ *
167
+ * **No request is added for any of this.** `Geometry` is on the grouped product
168
+ * and `product line` on the per-part detail, so `scrape.ts` already writes both
169
+ * into every row — see its three-call note.
170
+ */
171
+ export const PRODUCT_LINE_COLUMNS = {
172
+ FF01: 'product line',
173
+ FB01: 'Geometry',
174
+ FG01: 'Geometry',
175
+ };
176
+ /**
177
+ * A category's product-line codes onto the vendor's own name for each.
178
+ *
179
+ * **Both sides are EMUGE's.** The key is the value its `HYB_BAM_SB_GT` facet
180
+ * publishes and the name is the title of the vendor's own product-family
181
+ * article page for it, read on 2026-09-01 and cited per entry. Nothing here is
182
+ * this package's wording, which is the whole condition on a table like this —
183
+ * the same rule {@link SUBSTRATES} keeps, one level less strict because that
184
+ * one maps onto a vocabulary this package owns and this one does not.
185
+ *
186
+ * **A category whose facet already names the line has no entry.** `FF01`'s
187
+ * values are `FRANKEN TOP-Cut`, `FRANKEN Hard-Cut`, `FRANKEN Alu-Cut` — the
188
+ * vendor's marketing names already — so milling passes through verbatim and
189
+ * appears nowhere below. Drilling and tapping index by a geometry code
190
+ * (`MULTI`, `Z`, `VA`) that matches nothing EMUGE sells under that name, which
191
+ * is the only reason this table exists.
192
+ *
193
+ * **A code with no article page keeps the code**, and that is a gap in the
194
+ * vendor's marketing rather than a hole here: `SPEED`, `FK`, `GAL`, `GG` and
195
+ * `TILEG` are real lines with no `/a/` page on the US storefront, so the
196
+ * honest answer is the vendor's own code until one appears.
197
+ */
198
+ export const PRODUCT_LINES = {
199
+ // `/us/en/multi-drill/a/MultiDRILL`, `/us/en/steeldrill/a/SteelDrill`,
200
+ // `/us/en/inox/a/Inox`, and
201
+ // `/us/en/ef-va---carbide-drills---stainless-steel-titanium-alloys`.
202
+ //
203
+ // `STEEL` has two article pages — "SteelDrill" and "EF / CARBIDE DRILLS" —
204
+ // that resolve to the identical 8 groups. `SteelDrill` is the one the
205
+ // vendor's own part names use (`SteelDrill SD102-5xD-HA`), which is the
206
+ // tiebreak: a part that calls itself one of the two is evidence and a
207
+ // preference between two pages is not.
208
+ FB01: {
209
+ MULTI: 'MultiDRILL',
210
+ STEEL: 'SteelDrill',
211
+ INOX: 'Inox',
212
+ VA: 'EF-VA / CARBIDE DRILLS',
213
+ },
214
+ // One article page each, all under `/us/en/<slug>/a/<name>`: multitap,
215
+ // va-taps, ti-taps, ni-taps, aero-taps, rekord-b-z-taps, a-h-taps, al-taps,
216
+ // a-gjv-taps, a-hcut-taps, steel-taps.
217
+ //
218
+ // `FK`, `GAL`, `GG`, `SPEED` and `TILEG` are absent on purpose — see above.
219
+ FG01: {
220
+ MULTI: 'MultiTAP™',
221
+ VA: 'VA Taps',
222
+ TI: 'TI-Taps',
223
+ NI: 'Ni Taps',
224
+ AERO: 'AERO Taps',
225
+ Z: 'Rekord B-Z Taps',
226
+ H: 'A-H Taps',
227
+ AL: 'Al Taps',
228
+ GJV: 'A-GJV Taps',
229
+ HCUT: 'A-HCUT Taps',
230
+ STEEL: 'Steel Taps',
231
+ },
232
+ };
233
+ /**
234
+ * The product line this row states, or null where the vendor states none.
235
+ *
236
+ * Three answers and they are different things. A family whose category has no
237
+ * entry in {@link PRODUCT_LINE_COLUMNS} is a category this adapter has not
238
+ * looked at, and it answers null rather than guessing at which of the row's
239
+ * columns is the line. A column the vendor left empty is null too — the same
240
+ * silence `angle` treats as an omission rather than a fault. A *value* is
241
+ * mapped through {@link PRODUCT_LINES} where its category has a table and
242
+ * passed through verbatim where it does not, which is the milling case.
243
+ *
244
+ * It does not throw on an unknown code, and that is the difference between
245
+ * this and `substrate`: a cutting material this package cannot map would put a
246
+ * wrong word in `ToolRecord.substrate`, where an unmapped product-line code is
247
+ * the vendor's own code and readable as one. A new EMUGE geometry showing up
248
+ * as `SPEED` is a name to improve, not a record to refuse.
249
+ */
250
+ export function productLine(row, family) {
251
+ const column = PRODUCT_LINE_COLUMNS[family.familyCode ?? ''];
252
+ if (column === undefined)
253
+ return null;
254
+ const stated = (row[column] ?? '').trim();
255
+ if (stated === '')
256
+ return null;
257
+ return PRODUCT_LINES[family.familyCode ?? '']?.[stated] ?? stated;
258
+ }
259
+ /**
260
+ * The flute count EMUGE publishes where it has none: 64 end mill variants, on
261
+ * 2026-09-01. A sentinel and not a number, so it is refused rather than read.
262
+ */
263
+ export const NO_FLUTE_COUNT = 999;
264
+ /**
265
+ * The three column readers, over this vendor's grammar.
266
+ *
267
+ * `measureIn` is the only EMUGE-specific half; everything either side of it —
268
+ * an unmapped column answering undefined, a required field refusing the row —
269
+ * is `columns.columnReaders`, shared with Harvey Tool's mapper.
270
+ */
271
+ const { cell, required, optional } = columnReaders(measureIn);
272
+ /**
273
+ * An angle in degrees — the drill's point angle — or null where the vendor
274
+ * left the cell empty.
275
+ *
276
+ * Read through {@link parseMeasure} rather than {@link measureIn}, which
277
+ * refuses degrees on purpose: a length column stating an angle is a property
278
+ * that has moved. Here degrees are what the column is for.
279
+ *
280
+ * **An empty cell and an unreadable one are different answers**, the same
281
+ * split {@link coolantThrough} makes one level down. EMUGE fills this column
282
+ * on 2,669 of its 2,670 drill variants and leaves it blank on one, so a blank
283
+ * is the vendor publishing nothing and the row is still a part somebody can
284
+ * order — `records.RECORD_GEOMETRY.drill` lists `SIG` under `sometimes` for
285
+ * it. A cell holding a *value* this cannot read is the other case: a length
286
+ * where an angle belongs is the property having moved, a range has no single
287
+ * reading, and either one is refused rather than dropped quietly.
288
+ *
289
+ * A column the family maps to nothing refuses too, and is a third thing again
290
+ * — this adapter's drill family maps `point angle`, so its absence is that map
291
+ * having changed rather than anything the vendor did. **That is a fact about
292
+ * the map and not about a row**, which is why it is asked of `columns` and not
293
+ * inferred from a missing cell: a part publishing no dimensional properties
294
+ * has no such key either, and reading the two as one refused every drill in
295
+ * the family over one incomplete part.
296
+ * `records.REQUIRED_GEOMETRY` cannot catch it: `SIG` is not listed under its
297
+ * `drill` entry, because Kennametal's drills supply theirs as a fact and map
298
+ * no column at all.
299
+ */
300
+ function angle(row, columns, unit, what, warn) {
301
+ // `cell` cannot be used here. It answers `undefined` for two different
302
+ // things — the family mapping no `SIG` column, and *this row* carrying no
303
+ // such key — and those are the regression and the vendor's silence
304
+ // respectively. Asking the map directly separates them: `column` is null
305
+ // only when the map has nothing, so an absent key is left to be read off the
306
+ // row like any other blank.
307
+ const column = columns.column('SIG', unit);
308
+ if (column === null) {
309
+ throw new VendorResponseError(what, `is a drill whose family maps no point angle column — EMUGE states one ` +
310
+ `per part and this adapter reads it, so a map without it is a regression`);
311
+ }
312
+ const raw = row[column];
313
+ if (raw === undefined || raw.trim() === '') {
314
+ warn(` WARNING: ${what}: the vendor publishes no point angle — omitted`);
315
+ return null;
316
+ }
317
+ const { value, stated } = parseMeasure(raw);
318
+ if (value === null || stated === 'inches' || stated === 'millimeters') {
319
+ throw new VendorResponseError(what, `states a point angle of ${JSON.stringify(raw)}, which is not an angle — ` +
320
+ `an empty cell is the vendor's silence and is omitted, but a value ` +
321
+ `this cannot read is a property that has moved`);
322
+ }
323
+ return value;
324
+ }
325
+ /** The vendor's own coating string, `''` where neither spelling carries one. */
326
+ function coating(row) {
327
+ for (const column of COATING_COLUMNS) {
328
+ const value = row[column];
329
+ if (value !== undefined && value !== '')
330
+ return value;
331
+ }
332
+ return '';
333
+ }
334
+ /** The cutting material, refused rather than guessed when it is a new word. */
335
+ function substrate(row, what) {
336
+ const stated = row[SUBSTRATE_COLUMN] ?? '';
337
+ const mapped = SUBSTRATES[stated];
338
+ if (mapped === undefined) {
339
+ throw new VendorResponseError(what, `cutting material ${JSON.stringify(stated)} is not one of ` +
340
+ `${Object.keys(SUBSTRATES).sort().join(', ')} — add it to SUBSTRATES`);
341
+ }
342
+ return mapped;
343
+ }
344
+ /**
345
+ * Whether the part takes coolant through it, in whichever word its category
346
+ * uses.
347
+ *
348
+ * **A word the category's vocabulary does not have refuses the row**, because
349
+ * that is EMUGE's own closed facet having changed under this package, and
350
+ * guessing what a new value means is how a scraper becomes a place tool data is
351
+ * authored by hand.
352
+ *
353
+ * **A category whose column nobody filled warns and answers `false`**, which is
354
+ * a different thing: the vendor rated this part for nothing rather than for
355
+ * something new. 159 milling variants are in that position — see
356
+ * {@link COOLANT_COLUMNS}. `false` is not a claim that the tool has no through
357
+ * hole so much as the absence of the vendor's claim that it has one, and it is
358
+ * the only answer available: `ToolRecord.coolantThrough` is a boolean with no
359
+ * third state, and `true` would be the fabrication.
360
+ */
361
+ function coolantThrough(row, what, warn) {
362
+ for (const [column, vocabulary] of Object.entries(COOLANT_COLUMNS)) {
363
+ const stated = row[column];
364
+ if (stated === undefined || stated === '')
365
+ continue;
366
+ const value = vocabulary[stated];
367
+ if (value === undefined) {
368
+ throw new VendorResponseError(what, `${column} is ${JSON.stringify(stated)}, which is not one of ` +
369
+ `${Object.keys(vocabulary).sort().join(', ')} — add it to COOLANT_COLUMNS`);
370
+ }
371
+ return value;
372
+ }
373
+ warn(` WARNING: ${what}: none of ${Object.keys(COOLANT_COLUMNS).join(', ')} is ` +
374
+ `filled — the vendor's index rates it for no coolant supply, recorded as false`);
375
+ return false;
376
+ }
377
+ /**
378
+ * The workpiece-material groups, and how they were arrived at.
379
+ *
380
+ * Three states, and the column carries two of them: a cell the vendor's index
381
+ * filled, and an empty one meaning it rates this part for nothing. The third —
382
+ * **we have no evidence** — is the row whose per-part detail request answered
383
+ * nothing, which `scrape.ts` warns about and leaves the key off entirely.
384
+ *
385
+ * That last distinction lives only in the in-memory scrape: `node/csv.parseCsv`
386
+ * fills `''` under every header column, so a CSV round-trip flattens an absent
387
+ * key into an empty one. Which is why the missing detail is warned about when
388
+ * it happens rather than inferred from the file afterwards.
389
+ *
390
+ * Reordered onto {@link ISO_MATERIAL_GROUPS} rather than passed through in the
391
+ * vendor's order — a consumer that renders a facet from one order and a tool's
392
+ * own list from another has no way to notice the two disagree.
393
+ */
394
+ function materialGroups(row) {
395
+ const cellText = row[APPLICATION_MATERIALS_COLUMN];
396
+ if (cellText === undefined) {
397
+ return { materialGroups: null, materialGroupsSource: UNSPECIFIED };
398
+ }
399
+ const stated = new Set(cellText.split(/\s+/).filter((code) => code !== ''));
400
+ return {
401
+ materialGroups: ISO_MATERIAL_GROUPS.filter((group) => stated.has(group)),
402
+ materialGroupsSource: 'vendor-stated',
403
+ };
404
+ }
405
+ /** Everything a record of any kind here shares. */
406
+ function common(row, family, what, warn) {
407
+ return {
408
+ brand: familyBrand(family),
409
+ vendor: BRANDS[familyBrand(family)].vendor,
410
+ materialNumber: what,
411
+ catalogNumber: row[CATALOG_NUMBER_COLUMN] ?? '',
412
+ description: row[DESCRIPTION_COLUMN] ?? '',
413
+ productLine: productLine(row, family),
414
+ substrate: substrate(row, what),
415
+ coating: coating(row),
416
+ coolantThrough: coolantThrough(row, what, warn),
417
+ ...materialGroups(row),
418
+ };
419
+ }
420
+ /** The material number a record is minted from, refusing a row without one. */
421
+ function partNumber(row, family) {
422
+ const what = row[MATERIAL_NUMBER_COLUMN] ?? '';
423
+ if (what === '') {
424
+ throw new VendorResponseError(family.id, `has a row with no ${MATERIAL_NUMBER_COLUMN}`);
425
+ }
426
+ return what;
427
+ }
428
+ /**
429
+ * A solid end mill, in the family's declared unit.
430
+ *
431
+ * The two shoulder fields fall back the way every end mill mapper here does: a
432
+ * family with no neck column is a plain tool whose usable length below the
433
+ * shank is its flute length and whose shoulder is its cutting diameter. EMUGE
434
+ * publishes `neck length l₃` and `neck diameter Ød₃` on the necked lines and
435
+ * neither on the plain ones, in the same family, so the fallback is per row
436
+ * rather than per family.
437
+ *
438
+ * A missing `radius r₁` is a square end and 0 is the right answer — the reason
439
+ * `RE` is optional on the end mill contract.
440
+ */
441
+ export function endmillRecord(row, family, columns, options = {}) {
442
+ const warn = options.warn ?? consoleWarn;
443
+ const opts = { warn };
444
+ const unit = fact(family, 'unit', family.unit);
445
+ const what = partNumber(row, family);
446
+ const dc = required(row, columns, 'DC', unit, what, opts);
447
+ const fluteLength = required(row, columns, 'LCF', unit, what, opts);
448
+ const geometry = {
449
+ DC: dc,
450
+ RE: optional(row, columns, 'RE', unit, what, opts) ?? 0,
451
+ SFDM: required(row, columns, 'SFDM', unit, what, opts),
452
+ OAL: required(row, columns, 'OAL', unit, what, opts),
453
+ LCF: fluteLength,
454
+ 'shoulder-length': optional(row, columns, 'shoulder-length', unit, what, opts) ?? fluteLength,
455
+ 'shoulder-diameter': optional(row, columns, 'shoulder-diameter', unit, what, opts) ?? dc,
456
+ };
457
+ const raw = cell(row, columns, 'NOF', unit);
458
+ const nof = raw === undefined ? null : wholeCount(raw);
459
+ if (nof === NO_FLUTE_COUNT) {
460
+ warn(` WARNING: ${what}: the vendor's flute count is ${NO_FLUTE_COUNT} — omitted`);
461
+ }
462
+ else if (nof !== null) {
463
+ geometry.NOF = nof;
464
+ }
465
+ return toolRecord({ ...common(row, family, what, warn), kind: 'endmill', unit, geometry });
466
+ }
467
+ /**
468
+ * A twist drill, in millimetres — the only system EMUGE publishes its drill
469
+ * lengths in.
470
+ *
471
+ * `SIG` is a **mapped column**, which no other drill family in this package
472
+ * manages: the per-part detail record states the point angle outright, so
473
+ * nothing here is derived from a point length or assumed from a product line.
474
+ * It is also the one geometry key this record may not carry — one variant's
475
+ * cell is empty, and {@link angle} says what that costs. `NOF` is the one
476
+ * thing that is a fact, and `nonFerrous` with it — neither has a default
477
+ * anywhere, by design.
478
+ */
479
+ export function drillRecord(row, family, columns, options = {}) {
480
+ const warn = options.warn ?? consoleWarn;
481
+ const opts = { warn };
482
+ const unit = fact(family, 'unit', family.unit);
483
+ const what = partNumber(row, family);
484
+ const geometry = {
485
+ DC: required(row, columns, 'DC', unit, what, opts),
486
+ SFDM: required(row, columns, 'SFDM', unit, what, opts),
487
+ OAL: required(row, columns, 'OAL', unit, what, opts),
488
+ LCF: required(row, columns, 'LCF', unit, what, opts),
489
+ NOF: fact(family, 'flutes', family.flutes),
490
+ };
491
+ const pointAngle = angle(row, columns, unit, what, warn);
492
+ if (pointAngle !== null)
493
+ geometry.SIG = pointAngle;
494
+ return toolRecord({
495
+ ...common(row, family, what, warn),
496
+ kind: 'drill',
497
+ unit,
498
+ nonFerrous: fact(family, 'nonFerrous', family.nonFerrous),
499
+ geometry,
500
+ });
501
+ }
502
+ /**
503
+ * A tap, in millimetres — including an inch-thread one.
504
+ *
505
+ * That is what the vendor published rather than a conversion this package made:
506
+ * a `#4-40 UNC` tap's major diameter is stated as `2.845 mm` and its shank,
507
+ * overall length and cutting-edge length are millimetres too. So the family
508
+ * declares `unit: millimeters` and there is no per-row thread system to read —
509
+ * the shape Kennametal needs, where one family holds both and `Thread System`
510
+ * is a column the scrape tags on.
511
+ *
512
+ * `DC` is **read, not derived**. Kennametal parses a major diameter out of the
513
+ * thread designation because its tap tables publish no diameter column; EMUGE
514
+ * publishes one, and reading the vendor's own number is always the better of
515
+ * the two. The designation is still on the CSV as `dimensionFeatureValue`, and
516
+ * `thread symbol` and `threads per inch` beside it.
517
+ *
518
+ * `NOF` is absent, and its absence is the vendor's silence — see the module
519
+ * docstring and `records.RECORD_GEOMETRY`.
520
+ */
521
+ export function tapRecord(row, family, columns, options = {}) {
522
+ const warn = options.warn ?? consoleWarn;
523
+ const opts = { warn };
524
+ const unit = fact(family, 'unit', family.unit);
525
+ const what = partNumber(row, family);
526
+ return toolRecord({
527
+ ...common(row, family, what, warn),
528
+ kind: 'tap',
529
+ unit,
530
+ geometry: {
531
+ DC: required(row, columns, 'DC', unit, what, opts),
532
+ TP: required(row, columns, 'TP', unit, what, opts),
533
+ SFDM: required(row, columns, 'SFDM', unit, what, opts),
534
+ OAL: required(row, columns, 'OAL', unit, what, opts),
535
+ LCF: required(row, columns, 'LCF', unit, what, opts),
536
+ },
537
+ });
538
+ }
539
+ export const RECORD_MAPPERS = {
540
+ drill: drillRecord,
541
+ tap: tapRecord,
542
+ endmill: endmillRecord,
543
+ };