@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.
- package/README.md +41 -11
- package/dist/columns.d.ts +64 -0
- package/dist/columns.js +68 -0
- package/dist/conventions.d.ts +107 -15
- package/dist/conventions.js +126 -15
- package/dist/errors.d.ts +30 -0
- package/dist/errors.js +30 -0
- package/dist/families/emuge.d.ts +185 -0
- package/dist/families/emuge.js +163 -0
- package/dist/families/harvey.d.ts +1782 -0
- package/dist/families/harvey.js +1328 -0
- package/dist/families/index.js +5 -2
- package/dist/families/kennametal.d.ts +21 -0
- package/dist/families/kennametal.js +10 -0
- package/dist/families/maritool.d.ts +120 -0
- package/dist/families/maritool.js +175 -0
- package/dist/family.d.ts +35 -1
- package/dist/family.js +28 -0
- package/dist/identity.d.ts +18 -0
- package/dist/identity.js +46 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +16 -0
- package/dist/measure.d.ts +112 -0
- package/dist/measure.js +130 -0
- package/dist/node/cli.d.ts +3 -0
- package/dist/node/cli.js +158 -3
- package/dist/node/main.js +0 -0
- package/dist/records.d.ts +214 -12
- package/dist/records.js +140 -5
- package/dist/registry.d.ts +56 -1
- package/dist/registry.js +79 -2
- package/dist/scrape.d.ts +15 -0
- package/dist/scrape.js +24 -0
- package/dist/vendors/destinytool/records.d.ts +13 -3
- package/dist/vendors/destinytool/records.js +49 -37
- package/dist/vendors/emuge/index.d.ts +17 -0
- package/dist/vendors/emuge/index.js +17 -0
- package/dist/vendors/emuge/records.d.ts +230 -0
- package/dist/vendors/emuge/records.js +543 -0
- package/dist/vendors/emuge/scrape.d.ts +227 -0
- package/dist/vendors/emuge/scrape.js +358 -0
- package/dist/vendors/emuge/value.d.ts +75 -0
- package/dist/vendors/emuge/value.js +116 -0
- package/dist/vendors/harvey/catalog.d.ts +53 -0
- package/dist/vendors/harvey/catalog.js +120 -0
- package/dist/vendors/harvey/header.d.ts +89 -0
- package/dist/vendors/harvey/header.js +185 -0
- package/dist/vendors/harvey/index.d.ts +21 -0
- package/dist/vendors/harvey/index.js +21 -0
- package/dist/vendors/harvey/lexicon.d.ts +73 -0
- package/dist/vendors/harvey/lexicon.js +126 -0
- package/dist/vendors/harvey/literal.d.ts +68 -0
- package/dist/vendors/harvey/literal.js +214 -0
- package/dist/vendors/harvey/records.d.ts +79 -0
- package/dist/vendors/harvey/records.js +163 -0
- package/dist/vendors/harvey/scrape.d.ts +187 -0
- package/dist/vendors/harvey/scrape.js +483 -0
- package/dist/vendors/harvey/value.d.ts +74 -0
- package/dist/vendors/harvey/value.js +119 -0
- package/dist/vendors/kennametal/family.d.ts +119 -0
- package/dist/vendors/kennametal/family.js +155 -0
- package/dist/vendors/kennametal/index.d.ts +1 -0
- package/dist/vendors/kennametal/index.js +1 -0
- package/dist/vendors/kennametal/records.d.ts +29 -4
- package/dist/vendors/kennametal/records.js +93 -26
- package/dist/vendors/kennametal/scrape.d.ts +25 -2
- package/dist/vendors/kennametal/scrape.js +28 -3
- package/dist/vendors/maritool/catalog.d.ts +81 -0
- package/dist/vendors/maritool/catalog.js +132 -0
- package/dist/vendors/maritool/index.d.ts +10 -0
- package/dist/vendors/maritool/index.js +10 -0
- package/dist/vendors/maritool/scrape.d.ts +297 -0
- package/dist/vendors/maritool/scrape.js +593 -0
- package/dist/vendors/regofix/scrape.d.ts +8 -11
- package/dist/vendors/regofix/scrape.js +21 -36
- 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
|
+
};
|