@toolpath/tool-scraper 0.1.0 → 2.0.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 +62 -0
- package/dist/columns.js +62 -0
- package/dist/conventions.d.ts +89 -15
- package/dist/conventions.js +108 -15
- package/dist/families/emuge.d.ts +183 -0
- package/dist/families/emuge.js +159 -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 +152 -2
- package/dist/node/main.js +0 -0
- package/dist/records.d.ts +169 -12
- package/dist/records.js +116 -5
- package/dist/registry.d.ts +31 -1
- package/dist/registry.js +40 -1
- 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 +41 -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 +150 -0
- package/dist/vendors/emuge/records.js +375 -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/records.d.ts +13 -4
- package/dist/vendors/kennametal/records.js +61 -26
- 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,375 @@
|
|
|
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.
|
|
18
|
+
* - **A per-part ISO 513 index.** `applicationMaterials` returns the vendor's
|
|
19
|
+
* own P/M/K/N/S/H rating for each part, which fills `materialGroups` as
|
|
20
|
+
* `vendor-stated`.
|
|
21
|
+
* - **A description for every part.** The grouped product's `productListInfo`
|
|
22
|
+
* is one sentence of the vendor's own prose per product line, so every kind
|
|
23
|
+
* here fills `description` from `conventions.DESCRIPTION_COLUMN` — including
|
|
24
|
+
* the tap, where Kennametal has to fall back to the thread designation
|
|
25
|
+
* because it publishes no such text. EMUGE's designation is on the CSV as
|
|
26
|
+
* `dimensionFeatureValue` and its thread is on the record as `TP`.
|
|
27
|
+
*
|
|
28
|
+
* ## What it does not publish
|
|
29
|
+
*
|
|
30
|
+
* **No flute count on a drill or a tap.** Not on the grouped product, the
|
|
31
|
+
* variant listing, the per-part detail or any facet. A drill's comes from the
|
|
32
|
+
* family's `flutes` fact — all seventeen drill groups state
|
|
33
|
+
* `Specification: Twist drill` — and a tap's is simply absent, which is why
|
|
34
|
+
* `records.RECORD_GEOMETRY.tap` lists `NOF` under `sometimes`. EMUGE's own tap
|
|
35
|
+
* families run 2, 3 and 4 flutes across a size range, so no per-family constant
|
|
36
|
+
* could be true of every row, and 0 is not a substitute for a number nobody
|
|
37
|
+
* stated.
|
|
38
|
+
*
|
|
39
|
+
* **No CAD a scrape can reach.** The STEP, DXF and DIN 4000 documents are
|
|
40
|
+
* published behind a login — `anonymousAccess: false`, with both URL fields
|
|
41
|
+
* null — so neither `conventions.CAD_COLUMN` nor `CAD_DXF_COLUMN` is written.
|
|
42
|
+
* See `docs/EMUGE_FRANKEN_COMMERCE_API.md`.
|
|
43
|
+
*/
|
|
44
|
+
import { columnReaders } from '../../columns.js';
|
|
45
|
+
import { DESCRIPTION_COLUMN } from '../../conventions.js';
|
|
46
|
+
import { VendorResponseError } from '../../errors.js';
|
|
47
|
+
import { fact, familyBrand } from '../../family.js';
|
|
48
|
+
import { BRANDS } from '../../identity.js';
|
|
49
|
+
import { ISO_MATERIAL_GROUPS, toolRecord, UNSPECIFIED, } from '../../records.js';
|
|
50
|
+
import { consoleWarn } from '../../scrape.js';
|
|
51
|
+
import { APPLICATION_MATERIALS_COLUMN, CATALOG_NUMBER_COLUMN, MATERIAL_NUMBER_COLUMN, } from './scrape.js';
|
|
52
|
+
import { measureIn, parseMeasure, wholeCount } from './value.js';
|
|
53
|
+
/** The cutting-material property, spelled the same way in all three categories. */
|
|
54
|
+
export const SUBSTRATE_COLUMN = 'Cutting material';
|
|
55
|
+
/**
|
|
56
|
+
* The coating property, which is **not** spelled the same way.
|
|
57
|
+
*
|
|
58
|
+
* `coating` on a milling part and `Coating` on a drill or a tap, in the same
|
|
59
|
+
* API, on the same day. Both are read and the first non-empty one wins, because
|
|
60
|
+
* relabelling one onto the other in the CSV would be this adapter deciding what
|
|
61
|
+
* the vendor meant — see `scrape.ts`.
|
|
62
|
+
*/
|
|
63
|
+
export const COATING_COLUMNS = ['coating', 'Coating'];
|
|
64
|
+
/**
|
|
65
|
+
* Whether a part is through-coolant, by the vendor's own word for it.
|
|
66
|
+
*
|
|
67
|
+
* Three columns and three vocabularies, one per category, each a closed facet
|
|
68
|
+
* this package read off the vendor's own index on 2026-09-01 — so a value
|
|
69
|
+
* absent from here is a vocabulary that changed rather than a part that is
|
|
70
|
+
* odd, and {@link coolantThrough} refuses it by naming the table to add to.
|
|
71
|
+
*
|
|
72
|
+
* ## The facet does not cover the whole of milling
|
|
73
|
+
*
|
|
74
|
+
* The values below account for 6,862 milling variants, 2,670 drilling and
|
|
75
|
+
* 11,566 tapping. Drilling and tapping are the whole category — the same two
|
|
76
|
+
* numbers `docs/EMUGE_FRANKEN_COMMERCE_API.md` §4 gives — and milling is
|
|
77
|
+
* **159 short** of `FF01`'s 7,021, which is also `families/emuge.ts`'s two row
|
|
78
|
+
* counts added up. So 159 milling variants carry no `internal coolant supply`
|
|
79
|
+
* value at all.
|
|
80
|
+
*
|
|
81
|
+
* That is a gap in the vendor's index rather than a vocabulary that moved, and
|
|
82
|
+
* it is why {@link coolantThrough} separates the two cases: an unrecognised
|
|
83
|
+
* *value* still refuses, and a column nobody filled warns and records `false`.
|
|
84
|
+
* They were one case until 2026-09-01, and because `registry.toRecords` maps
|
|
85
|
+
* its rows, a single such part threw and took the whole family's conversion
|
|
86
|
+
* with it — 159 unindexed variants for two dead end mill CSVs.
|
|
87
|
+
*/
|
|
88
|
+
export const COOLANT_COLUMNS = {
|
|
89
|
+
// `HYB_AMM_KMIZU`, milling.
|
|
90
|
+
'internal coolant supply': {
|
|
91
|
+
'ICA - Axial hole': true,
|
|
92
|
+
'ICR - Radial hole': true,
|
|
93
|
+
'ICRA - Radial & Axial holes': true,
|
|
94
|
+
'Without internal cooling': false,
|
|
95
|
+
},
|
|
96
|
+
// `HYB_TAM_IKZ`, drilling. Note that "external coolant supply" is a real
|
|
97
|
+
// statement rather than a blank: it is how EMUGE says a drill has no through
|
|
98
|
+
// hole, and 164 of its 2,670 drill variants are it.
|
|
99
|
+
'Coolant supply': {
|
|
100
|
+
'internal coolant supply': true,
|
|
101
|
+
'external coolant supply': false,
|
|
102
|
+
},
|
|
103
|
+
// `HYB_BAM_IKZ`, tapping.
|
|
104
|
+
'coolant supply': {
|
|
105
|
+
'IKZ - Axial hole': true,
|
|
106
|
+
'IKZN - Radial hole': true,
|
|
107
|
+
Without: false,
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
/**
|
|
111
|
+
* The vendor's cutting materials, onto the package's own vocabulary.
|
|
112
|
+
*
|
|
113
|
+
* `FamilyFacts.bmc` names a material class — `carbide`, `hss`, `diamond` — and
|
|
114
|
+
* EMUGE's index names an alloy and a production route: HSS, HSSE and HSSE-PM
|
|
115
|
+
* are three grades of high-speed steel and all three are `hss`. `PCD` is
|
|
116
|
+
* `diamond`, which is the call `families/kennametal.ts` already made for its
|
|
117
|
+
* PCD drills: the word names the cutting material rather than the body it is
|
|
118
|
+
* brazed to.
|
|
119
|
+
*
|
|
120
|
+
* `cbn` and `ceramic` keep the vendor's own name, lowercased, because the
|
|
121
|
+
* package's three words have no counterpart for either and inventing one would
|
|
122
|
+
* be worse than recording what is standard. Closed, and it throws on anything
|
|
123
|
+
* else — the seven values below are the whole of the vendor's `HYB_AAM_MAT`
|
|
124
|
+
* facet across all three categories (2026-09-01).
|
|
125
|
+
*/
|
|
126
|
+
export const SUBSTRATES = {
|
|
127
|
+
carbide: 'carbide',
|
|
128
|
+
HSS: 'hss',
|
|
129
|
+
HSSE: 'hss',
|
|
130
|
+
'HSSE-PM': 'hss',
|
|
131
|
+
PCD: 'diamond',
|
|
132
|
+
CBN: 'cbn',
|
|
133
|
+
ceramic: 'ceramic',
|
|
134
|
+
};
|
|
135
|
+
/**
|
|
136
|
+
* The flute count EMUGE publishes where it has none: 64 end mill variants, on
|
|
137
|
+
* 2026-09-01. A sentinel and not a number, so it is refused rather than read.
|
|
138
|
+
*/
|
|
139
|
+
export const NO_FLUTE_COUNT = 999;
|
|
140
|
+
/**
|
|
141
|
+
* The three column readers, over this vendor's grammar.
|
|
142
|
+
*
|
|
143
|
+
* `measureIn` is the only EMUGE-specific half; everything either side of it —
|
|
144
|
+
* an unmapped column answering undefined, a required field refusing the row —
|
|
145
|
+
* is `columns.columnReaders`, shared with Harvey Tool's mapper.
|
|
146
|
+
*/
|
|
147
|
+
const { cell, required, optional } = columnReaders(measureIn);
|
|
148
|
+
/**
|
|
149
|
+
* An angle in degrees — the drill's point angle.
|
|
150
|
+
*
|
|
151
|
+
* Read through {@link parseMeasure} rather than {@link measureIn}, which
|
|
152
|
+
* refuses degrees on purpose: a length column stating an angle is a property
|
|
153
|
+
* that has moved. Here degrees are what the column is for.
|
|
154
|
+
*/
|
|
155
|
+
function angle(row, columns, unit, what) {
|
|
156
|
+
const raw = cell(row, columns, 'SIG', unit);
|
|
157
|
+
const { value, stated } = parseMeasure(raw ?? '');
|
|
158
|
+
if (value === null || stated === 'inches' || stated === 'millimeters') {
|
|
159
|
+
throw new VendorResponseError(what, `publishes no point angle — its cell is ${JSON.stringify(raw ?? '')}`);
|
|
160
|
+
}
|
|
161
|
+
return value;
|
|
162
|
+
}
|
|
163
|
+
/** The vendor's own coating string, `''` where neither spelling carries one. */
|
|
164
|
+
function coating(row) {
|
|
165
|
+
for (const column of COATING_COLUMNS) {
|
|
166
|
+
const value = row[column];
|
|
167
|
+
if (value !== undefined && value !== '')
|
|
168
|
+
return value;
|
|
169
|
+
}
|
|
170
|
+
return '';
|
|
171
|
+
}
|
|
172
|
+
/** The cutting material, refused rather than guessed when it is a new word. */
|
|
173
|
+
function substrate(row, what) {
|
|
174
|
+
const stated = row[SUBSTRATE_COLUMN] ?? '';
|
|
175
|
+
const mapped = SUBSTRATES[stated];
|
|
176
|
+
if (mapped === undefined) {
|
|
177
|
+
throw new VendorResponseError(what, `cutting material ${JSON.stringify(stated)} is not one of ` +
|
|
178
|
+
`${Object.keys(SUBSTRATES).sort().join(', ')} — add it to SUBSTRATES`);
|
|
179
|
+
}
|
|
180
|
+
return mapped;
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Whether the part takes coolant through it, in whichever word its category
|
|
184
|
+
* uses.
|
|
185
|
+
*
|
|
186
|
+
* **A word the category's vocabulary does not have refuses the row**, because
|
|
187
|
+
* that is EMUGE's own closed facet having changed under this package, and
|
|
188
|
+
* guessing what a new value means is how a scraper becomes a place tool data is
|
|
189
|
+
* authored by hand.
|
|
190
|
+
*
|
|
191
|
+
* **A category whose column nobody filled warns and answers `false`**, which is
|
|
192
|
+
* a different thing: the vendor rated this part for nothing rather than for
|
|
193
|
+
* something new. 159 milling variants are in that position — see
|
|
194
|
+
* {@link COOLANT_COLUMNS}. `false` is not a claim that the tool has no through
|
|
195
|
+
* hole so much as the absence of the vendor's claim that it has one, and it is
|
|
196
|
+
* the only answer available: `ToolRecord.coolantThrough` is a boolean with no
|
|
197
|
+
* third state, and `true` would be the fabrication.
|
|
198
|
+
*/
|
|
199
|
+
function coolantThrough(row, what, warn) {
|
|
200
|
+
for (const [column, vocabulary] of Object.entries(COOLANT_COLUMNS)) {
|
|
201
|
+
const stated = row[column];
|
|
202
|
+
if (stated === undefined || stated === '')
|
|
203
|
+
continue;
|
|
204
|
+
const value = vocabulary[stated];
|
|
205
|
+
if (value === undefined) {
|
|
206
|
+
throw new VendorResponseError(what, `${column} is ${JSON.stringify(stated)}, which is not one of ` +
|
|
207
|
+
`${Object.keys(vocabulary).sort().join(', ')} — add it to COOLANT_COLUMNS`);
|
|
208
|
+
}
|
|
209
|
+
return value;
|
|
210
|
+
}
|
|
211
|
+
warn(` WARNING: ${what}: none of ${Object.keys(COOLANT_COLUMNS).join(', ')} is ` +
|
|
212
|
+
`filled — the vendor's index rates it for no coolant supply, recorded as false`);
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* The workpiece-material groups, and how they were arrived at.
|
|
217
|
+
*
|
|
218
|
+
* Three states, and the column carries two of them: a cell the vendor's index
|
|
219
|
+
* filled, and an empty one meaning it rates this part for nothing. The third —
|
|
220
|
+
* **we have no evidence** — is the row whose per-part detail request answered
|
|
221
|
+
* nothing, which `scrape.ts` warns about and leaves the key off entirely.
|
|
222
|
+
*
|
|
223
|
+
* That last distinction lives only in the in-memory scrape: `node/csv.parseCsv`
|
|
224
|
+
* fills `''` under every header column, so a CSV round-trip flattens an absent
|
|
225
|
+
* key into an empty one. Which is why the missing detail is warned about when
|
|
226
|
+
* it happens rather than inferred from the file afterwards.
|
|
227
|
+
*
|
|
228
|
+
* Reordered onto {@link ISO_MATERIAL_GROUPS} rather than passed through in the
|
|
229
|
+
* vendor's order — a consumer that renders a facet from one order and a tool's
|
|
230
|
+
* own list from another has no way to notice the two disagree.
|
|
231
|
+
*/
|
|
232
|
+
function materialGroups(row) {
|
|
233
|
+
const cellText = row[APPLICATION_MATERIALS_COLUMN];
|
|
234
|
+
if (cellText === undefined) {
|
|
235
|
+
return { materialGroups: null, materialGroupsSource: UNSPECIFIED };
|
|
236
|
+
}
|
|
237
|
+
const stated = new Set(cellText.split(/\s+/).filter((code) => code !== ''));
|
|
238
|
+
return {
|
|
239
|
+
materialGroups: ISO_MATERIAL_GROUPS.filter((group) => stated.has(group)),
|
|
240
|
+
materialGroupsSource: 'vendor-stated',
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
/** Everything a record of any kind here shares. */
|
|
244
|
+
function common(row, family, what, warn) {
|
|
245
|
+
return {
|
|
246
|
+
brand: familyBrand(family),
|
|
247
|
+
vendor: BRANDS[familyBrand(family)].vendor,
|
|
248
|
+
materialNumber: what,
|
|
249
|
+
catalogNumber: row[CATALOG_NUMBER_COLUMN] ?? '',
|
|
250
|
+
description: row[DESCRIPTION_COLUMN] ?? '',
|
|
251
|
+
substrate: substrate(row, what),
|
|
252
|
+
coating: coating(row),
|
|
253
|
+
coolantThrough: coolantThrough(row, what, warn),
|
|
254
|
+
...materialGroups(row),
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
/** The material number a record is minted from, refusing a row without one. */
|
|
258
|
+
function partNumber(row, family) {
|
|
259
|
+
const what = row[MATERIAL_NUMBER_COLUMN] ?? '';
|
|
260
|
+
if (what === '') {
|
|
261
|
+
throw new VendorResponseError(family.id, `has a row with no ${MATERIAL_NUMBER_COLUMN}`);
|
|
262
|
+
}
|
|
263
|
+
return what;
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* A solid end mill, in the family's declared unit.
|
|
267
|
+
*
|
|
268
|
+
* The two shoulder fields fall back the way every end mill mapper here does: a
|
|
269
|
+
* family with no neck column is a plain tool whose usable length below the
|
|
270
|
+
* shank is its flute length and whose shoulder is its cutting diameter. EMUGE
|
|
271
|
+
* publishes `neck length l₃` and `neck diameter Ød₃` on the necked lines and
|
|
272
|
+
* neither on the plain ones, in the same family, so the fallback is per row
|
|
273
|
+
* rather than per family.
|
|
274
|
+
*
|
|
275
|
+
* A missing `radius r₁` is a square end and 0 is the right answer — the reason
|
|
276
|
+
* `RE` is optional on the end mill contract.
|
|
277
|
+
*/
|
|
278
|
+
export function endmillRecord(row, family, columns, options = {}) {
|
|
279
|
+
const warn = options.warn ?? consoleWarn;
|
|
280
|
+
const opts = { warn };
|
|
281
|
+
const unit = fact(family, 'unit', family.unit);
|
|
282
|
+
const what = partNumber(row, family);
|
|
283
|
+
const dc = required(row, columns, 'DC', unit, what, opts);
|
|
284
|
+
const fluteLength = required(row, columns, 'LCF', unit, what, opts);
|
|
285
|
+
const geometry = {
|
|
286
|
+
DC: dc,
|
|
287
|
+
RE: optional(row, columns, 'RE', unit, what, opts) ?? 0,
|
|
288
|
+
SFDM: required(row, columns, 'SFDM', unit, what, opts),
|
|
289
|
+
OAL: required(row, columns, 'OAL', unit, what, opts),
|
|
290
|
+
LCF: fluteLength,
|
|
291
|
+
'shoulder-length': optional(row, columns, 'shoulder-length', unit, what, opts) ?? fluteLength,
|
|
292
|
+
'shoulder-diameter': optional(row, columns, 'shoulder-diameter', unit, what, opts) ?? dc,
|
|
293
|
+
};
|
|
294
|
+
const raw = cell(row, columns, 'NOF', unit);
|
|
295
|
+
const nof = raw === undefined ? null : wholeCount(raw);
|
|
296
|
+
if (nof === NO_FLUTE_COUNT) {
|
|
297
|
+
warn(` WARNING: ${what}: the vendor's flute count is ${NO_FLUTE_COUNT} — omitted`);
|
|
298
|
+
}
|
|
299
|
+
else if (nof !== null) {
|
|
300
|
+
geometry.NOF = nof;
|
|
301
|
+
}
|
|
302
|
+
return toolRecord({ ...common(row, family, what, warn), kind: 'endmill', unit, geometry });
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* A twist drill, in millimetres — the only system EMUGE publishes its drill
|
|
306
|
+
* lengths in.
|
|
307
|
+
*
|
|
308
|
+
* `SIG` is a **mapped column**, which no other drill family in this package
|
|
309
|
+
* manages: the per-part detail record states the point angle outright, so
|
|
310
|
+
* nothing here is derived from a point length or assumed from a product line.
|
|
311
|
+
* `NOF` is the one thing that is a fact, and `nonFerrous` with it — neither has
|
|
312
|
+
* a default anywhere, by design.
|
|
313
|
+
*/
|
|
314
|
+
export function drillRecord(row, family, columns, options = {}) {
|
|
315
|
+
const warn = options.warn ?? consoleWarn;
|
|
316
|
+
const opts = { warn };
|
|
317
|
+
const unit = fact(family, 'unit', family.unit);
|
|
318
|
+
const what = partNumber(row, family);
|
|
319
|
+
return toolRecord({
|
|
320
|
+
...common(row, family, what, warn),
|
|
321
|
+
kind: 'drill',
|
|
322
|
+
unit,
|
|
323
|
+
nonFerrous: fact(family, 'nonFerrous', family.nonFerrous),
|
|
324
|
+
geometry: {
|
|
325
|
+
DC: required(row, columns, 'DC', unit, what, opts),
|
|
326
|
+
SFDM: required(row, columns, 'SFDM', unit, what, opts),
|
|
327
|
+
OAL: required(row, columns, 'OAL', unit, what, opts),
|
|
328
|
+
LCF: required(row, columns, 'LCF', unit, what, opts),
|
|
329
|
+
NOF: fact(family, 'flutes', family.flutes),
|
|
330
|
+
SIG: angle(row, columns, unit, what),
|
|
331
|
+
},
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* A tap, in millimetres — including an inch-thread one.
|
|
336
|
+
*
|
|
337
|
+
* That is what the vendor published rather than a conversion this package made:
|
|
338
|
+
* a `#4-40 UNC` tap's major diameter is stated as `2.845 mm` and its shank,
|
|
339
|
+
* overall length and cutting-edge length are millimetres too. So the family
|
|
340
|
+
* declares `unit: millimeters` and there is no per-row thread system to read —
|
|
341
|
+
* the shape Kennametal needs, where one family holds both and `Thread System`
|
|
342
|
+
* is a column the scrape tags on.
|
|
343
|
+
*
|
|
344
|
+
* `DC` is **read, not derived**. Kennametal parses a major diameter out of the
|
|
345
|
+
* thread designation because its tap tables publish no diameter column; EMUGE
|
|
346
|
+
* publishes one, and reading the vendor's own number is always the better of
|
|
347
|
+
* the two. The designation is still on the CSV as `dimensionFeatureValue`, and
|
|
348
|
+
* `thread symbol` and `threads per inch` beside it.
|
|
349
|
+
*
|
|
350
|
+
* `NOF` is absent, and its absence is the vendor's silence — see the module
|
|
351
|
+
* docstring and `records.RECORD_GEOMETRY`.
|
|
352
|
+
*/
|
|
353
|
+
export function tapRecord(row, family, columns, options = {}) {
|
|
354
|
+
const warn = options.warn ?? consoleWarn;
|
|
355
|
+
const opts = { warn };
|
|
356
|
+
const unit = fact(family, 'unit', family.unit);
|
|
357
|
+
const what = partNumber(row, family);
|
|
358
|
+
return toolRecord({
|
|
359
|
+
...common(row, family, what, warn),
|
|
360
|
+
kind: 'tap',
|
|
361
|
+
unit,
|
|
362
|
+
geometry: {
|
|
363
|
+
DC: required(row, columns, 'DC', unit, what, opts),
|
|
364
|
+
TP: required(row, columns, 'TP', unit, what, opts),
|
|
365
|
+
SFDM: required(row, columns, 'SFDM', unit, what, opts),
|
|
366
|
+
OAL: required(row, columns, 'OAL', unit, what, opts),
|
|
367
|
+
LCF: required(row, columns, 'LCF', unit, what, opts),
|
|
368
|
+
},
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
export const RECORD_MAPPERS = {
|
|
372
|
+
drill: drillRecord,
|
|
373
|
+
tap: tapRecord,
|
|
374
|
+
endmill: endmillRecord,
|
|
375
|
+
};
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EMUGE-FRANKEN's SAP Commerce API -> cutting-tool rows.
|
|
3
|
+
*
|
|
4
|
+
* Nothing here parses HTML, because there is none to parse: the storefront is
|
|
5
|
+
* SAP Commerce (Hybris) behind a Vue front end, and a category page
|
|
6
|
+
* server-renders a `<title>`, a base64 CMS blob and an empty
|
|
7
|
+
* `<category-detail-page>` element. The data comes from the JSON API that
|
|
8
|
+
* element's component reads. `docs/EMUGE_FRANKEN_COMMERCE_API.md` records how
|
|
9
|
+
* the endpoint was found, what it answers with, and what was tried first.
|
|
10
|
+
*
|
|
11
|
+
* ```
|
|
12
|
+
* GET {BASE}/search/products?query=<facet query>¤tPage=&pageSize=
|
|
13
|
+
* &searchQueryContext=KLAMMER_GROUPING -- grouped products
|
|
14
|
+
* &searchQueryContext=VARIANT_SEARCH -- one group's orderable parts
|
|
15
|
+
* GET {BASE}/products?productCodes=<up to 30>&fields=FULL
|
|
16
|
+
* ```
|
|
17
|
+
*
|
|
18
|
+
* ## Three calls, because the fields are in three places
|
|
19
|
+
*
|
|
20
|
+
* A *klammer* product is EMUGE's grouping — `H301025`, "Solid Carbide End Mill
|
|
21
|
+
* TOP-Cut VAR" — and its variants are the parts somebody orders. Neither one
|
|
22
|
+
* alone carries a record:
|
|
23
|
+
*
|
|
24
|
+
* - the **grouped listing** states the product line, the category and the
|
|
25
|
+
* version, and no dimensions;
|
|
26
|
+
* - the **variant listing** states the material number, the catalog number and
|
|
27
|
+
* every dimension, under `mainDrawing.technicalDetails`;
|
|
28
|
+
* - the **per-part detail** states what appears in neither — the end mill's
|
|
29
|
+
* flute count, the drill's point angle, the tap's thread symbol and pitch,
|
|
30
|
+
* and `applicationMaterials`, which is the vendor's own ISO 513 index.
|
|
31
|
+
*
|
|
32
|
+
* The third is the reason a scrape is not two calls. It is batched 30 codes at
|
|
33
|
+
* a time, so it costs one request per 30 parts rather than one per part.
|
|
34
|
+
*
|
|
35
|
+
* ## Two buckets of property, and only one of them gets a unit suffix
|
|
36
|
+
*
|
|
37
|
+
* `mainDrawing.technicalDetails` is the dimension table — that is the bucket
|
|
38
|
+
* whose columns carry `_mm`/`_in` from the family's declared unit. Everything
|
|
39
|
+
* in the flat `technicalDetails` keeps its bare label, which is what puts a
|
|
40
|
+
* tap's `pitch [mm]` in a column called `pitch`: `records.DIMENSIONAL_COLUMNS`
|
|
41
|
+
* excludes `TP` from unit pairing, so the core reads that column by its bare
|
|
42
|
+
* label, and a suffix here would name a column nothing scraped.
|
|
43
|
+
*
|
|
44
|
+
* Values are written **verbatim** — `1 1/2 "`, `3 mm`, `140 deg` — the call
|
|
45
|
+
* `vendors/harvey/scrape.ts` makes and for the same reason: the CSV is the
|
|
46
|
+
* receipt, and EMUGE's own fractional inches and stated units are part of what
|
|
47
|
+
* it published. `records.ts` is where a cell becomes a number, through
|
|
48
|
+
* `value.ts`.
|
|
49
|
+
*
|
|
50
|
+
* ## What the vendor gets wrong
|
|
51
|
+
*
|
|
52
|
+
* Warned about, never corrected — two disagreeing vendor cells cannot say which
|
|
53
|
+
* one is wrong, and a scraper that picks becomes a place tool data is authored
|
|
54
|
+
* by hand.
|
|
55
|
+
*
|
|
56
|
+
* - **`number of flutes Z` is `999` on 64 end mill variants** (2026-09-01), a
|
|
57
|
+
* sentinel rather than a count. `records.ts` refuses it; a CSV cell keeps it,
|
|
58
|
+
* because that is what the vendor published.
|
|
59
|
+
* - **`name` comes back German on the US English storefront** — "TOP-Cut VAR
|
|
60
|
+
* HM-Schaftfräser / lang Typ N ALCR" with `lang=en&country=US`. Recorded as
|
|
61
|
+
* sent.
|
|
62
|
+
* - **The same property is spelled two ways across categories**: `coating` on
|
|
63
|
+
* an end mill and `Coating` on a tap, `Cutting material` on both. Two
|
|
64
|
+
* columns, because relabelling one onto the other would be this adapter
|
|
65
|
+
* deciding what the vendor meant.
|
|
66
|
+
* - **The same measurement is published under two unit tags** — the drill
|
|
67
|
+
* detail record carries `nominal diameter d₁ [in]` beside the millimetre one.
|
|
68
|
+
* `value.bareLabel` strips the tag, so both want one column; {@link put} lets
|
|
69
|
+
* the later one win and says so, rather than dropping a number out of the
|
|
70
|
+
* receipt in silence.
|
|
71
|
+
*/
|
|
72
|
+
import { type UnitSystem } from '../../conventions.js';
|
|
73
|
+
import type { Fetcher } from '../../fetch.js';
|
|
74
|
+
import { type ScrapeResult, type ScrapedRow, type Warn } from '../../scrape.js';
|
|
75
|
+
/**
|
|
76
|
+
* The `emugefrankenUSA` base site, which is the US storefront.
|
|
77
|
+
*
|
|
78
|
+
* Both halves are stated in `window.appConfig` on every page of the site
|
|
79
|
+
* (`apiNodeUrl` and `basesiteId`), and the `/api/v2/{basesiteId}` shape is what
|
|
80
|
+
* the front end's own client builds.
|
|
81
|
+
*/
|
|
82
|
+
export declare const BASE = "https://api.emuge-franken-group.com/api/v2/emugefrankenUSA";
|
|
83
|
+
/** Answered in full at 500; the largest group seen holds 214 variants. */
|
|
84
|
+
export declare const SEARCH_PAGE_SIZE = 500;
|
|
85
|
+
/**
|
|
86
|
+
* Codes per `productCodes=` request.
|
|
87
|
+
*
|
|
88
|
+
* Thirty is what the storefront's own product-comparison call uses, and it is
|
|
89
|
+
* answered whole — 30 codes in, 30 records out. Raising it is a request-volume
|
|
90
|
+
* decision about somebody else's server, so it stays where the vendor put it.
|
|
91
|
+
*/
|
|
92
|
+
export declare const DETAIL_BATCH = 30;
|
|
93
|
+
/** The CSV columns this adapter names, as against the vendor's own labels. */
|
|
94
|
+
export declare const MATERIAL_NUMBER_COLUMN = "Material Number";
|
|
95
|
+
export declare const CATALOG_NUMBER_COLUMN = "ISO Catalog Number";
|
|
96
|
+
export declare const GROUP_COLUMN = "klammerProductCode";
|
|
97
|
+
export declare const DIMENSION_FEATURE_COLUMN = "dimensionFeatureValue";
|
|
98
|
+
export declare const NAME_COLUMN = "name";
|
|
99
|
+
export declare const APPLICATION_MATERIALS_COLUMN = "applicationMaterials";
|
|
100
|
+
/**
|
|
101
|
+
* One family's scrape target: a category, optionally narrowed by one facet.
|
|
102
|
+
*
|
|
103
|
+
* The facet is how the two end mill families are split — EMUGE indexes every
|
|
104
|
+
* milling variant under `AMM_EINHS` (`AMM_EINHS_Z` inch, `AMM_EINHS_M` metric)
|
|
105
|
+
* — so a family declares one unit and scrapes only the parts published in it.
|
|
106
|
+
*/
|
|
107
|
+
export interface EmugeTarget {
|
|
108
|
+
/** The vendor's category code: `FF01` end mills, `FB01` drills, `FG01` taps. */
|
|
109
|
+
readonly category: string;
|
|
110
|
+
/** A facet code and value, both the vendor's own. */
|
|
111
|
+
readonly facet?: {
|
|
112
|
+
readonly code: string;
|
|
113
|
+
readonly value: string;
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
/** What a scrape accepts. */
|
|
117
|
+
export interface EmugeOptions {
|
|
118
|
+
/** The unit system the family declares — decides the dimensional suffix. */
|
|
119
|
+
readonly unit: UnitSystem;
|
|
120
|
+
readonly warn?: Warn;
|
|
121
|
+
/** Milliseconds between requests. Zero in tests; the shared delay otherwise. */
|
|
122
|
+
readonly delayMs?: number;
|
|
123
|
+
}
|
|
124
|
+
/** One `{ property, value }` pair, as the API writes them. */
|
|
125
|
+
interface Detail {
|
|
126
|
+
property?: string;
|
|
127
|
+
value?: string;
|
|
128
|
+
}
|
|
129
|
+
/** A grouped product, as `KLAMMER_GROUPING` answers. */
|
|
130
|
+
interface GroupedProduct {
|
|
131
|
+
code?: string;
|
|
132
|
+
productListInfo?: string;
|
|
133
|
+
/** How many orderable parts the vendor says this group has. */
|
|
134
|
+
numberOfMaterials?: number;
|
|
135
|
+
technicalDetails?: Detail[];
|
|
136
|
+
}
|
|
137
|
+
/** One orderable part, as `VARIANT_SEARCH` answers. */
|
|
138
|
+
interface VariantProduct {
|
|
139
|
+
code?: string;
|
|
140
|
+
articleCode?: string;
|
|
141
|
+
name?: string;
|
|
142
|
+
dimensionFeatureValue?: string;
|
|
143
|
+
/**
|
|
144
|
+
* True on the grouped product itself, which the variant listing returns
|
|
145
|
+
* alongside its variants — see {@link fetchGroupVariants}.
|
|
146
|
+
*/
|
|
147
|
+
klammerProduct?: boolean;
|
|
148
|
+
mainDrawing?: {
|
|
149
|
+
technicalDetails?: Detail[];
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
/** One part's full record, as `productCodes=&fields=FULL` answers. */
|
|
153
|
+
interface ProductDetail {
|
|
154
|
+
code?: string;
|
|
155
|
+
technicalDetails?: Detail[];
|
|
156
|
+
applicationMaterials?: {
|
|
157
|
+
code?: string;
|
|
158
|
+
}[];
|
|
159
|
+
}
|
|
160
|
+
/** The facet query for a target's grouped products. */
|
|
161
|
+
export declare function groupQuery(target: EmugeTarget): string;
|
|
162
|
+
/** The facet query for one group's variants. */
|
|
163
|
+
export declare function variantQuery(klammerCode: string): string;
|
|
164
|
+
/**
|
|
165
|
+
* One `/search/products` URL.
|
|
166
|
+
*
|
|
167
|
+
* `lang` and `country` are the front end's own defaults for this base site and
|
|
168
|
+
* are what make the property labels English; without them the API answers in
|
|
169
|
+
* German.
|
|
170
|
+
*/
|
|
171
|
+
export declare function searchUrl(query: string, context: string, page: number, sort?: string): string;
|
|
172
|
+
/** The batched per-part detail URL. */
|
|
173
|
+
export declare function detailUrl(codes: readonly string[]): string;
|
|
174
|
+
/** Every grouped product under one target. */
|
|
175
|
+
export declare function fetchGroups(fetcher: Fetcher, target: EmugeTarget, delayMs?: number): Promise<GroupedProduct[]>;
|
|
176
|
+
/**
|
|
177
|
+
* Every orderable part in one group.
|
|
178
|
+
*
|
|
179
|
+
* `sort=prod-detail-variant` is the vendor's own variant order — the sequence
|
|
180
|
+
* its product page lists sizes in — so a re-scrape diffs against the last one
|
|
181
|
+
* rather than against a relevance ranking that moved.
|
|
182
|
+
*
|
|
183
|
+
* **The grouped product comes back inside its own variant listing**, last, and
|
|
184
|
+
* it is not a part: `klammerProduct` is true on it, it carries the base article
|
|
185
|
+
* code with no size suffix (`TA219744` against the variants' `TA219744.0300`),
|
|
186
|
+
* and it has no `mainDrawing.technicalDetails` at all. `KLAMMER_GROUPING`
|
|
187
|
+
* queries filter it out with `:klammerProduct:false` and `VARIANT_SEARCH` ones
|
|
188
|
+
* do not, so the flag is read here instead — the vendor's own discriminator
|
|
189
|
+
* rather than a guess from the missing dimensions.
|
|
190
|
+
*
|
|
191
|
+
* It cost exactly one bogus row per group, which is the kind of thing only a
|
|
192
|
+
* real scrape finds: the first drill run wrote 2,687 rows where the family
|
|
193
|
+
* declares 2,670, and `node/receipts.checkRows` is what said so (JG 2026-09-01).
|
|
194
|
+
*/
|
|
195
|
+
export declare function fetchGroupVariants(fetcher: Fetcher, klammerCode: string, delayMs?: number): Promise<VariantProduct[]>;
|
|
196
|
+
/**
|
|
197
|
+
* The full record for each of `codes`, by code, in batches of
|
|
198
|
+
* {@link DETAIL_BATCH}.
|
|
199
|
+
*
|
|
200
|
+
* A code the vendor answers nothing for is warned about and left out of the
|
|
201
|
+
* map; the row is still written, without the fields only this call carries. A
|
|
202
|
+
* dropped row would lose a part somebody can order over a field that is
|
|
203
|
+
* missing rather than wrong.
|
|
204
|
+
*/
|
|
205
|
+
export declare function fetchDetails(fetcher: Fetcher, codes: readonly string[], delayMs?: number, warn?: Warn): Promise<Map<string, ProductDetail>>;
|
|
206
|
+
/**
|
|
207
|
+
* One orderable part as a row.
|
|
208
|
+
*
|
|
209
|
+
* The three property sources are written in the order they may override each
|
|
210
|
+
* other: the group's, then the part's own, then its dimension table. Group and
|
|
211
|
+
* part overlap — both state the coating and the cutting material — and the
|
|
212
|
+
* part's is the more specific of the two.
|
|
213
|
+
*/
|
|
214
|
+
export declare function variantRow(group: GroupedProduct, variant: VariantProduct, detail: ProductDetail | undefined, unit: UnitSystem, warn?: Warn): ScrapedRow;
|
|
215
|
+
/**
|
|
216
|
+
* One category — optionally one facet of it — as rows, one per orderable part.
|
|
217
|
+
*
|
|
218
|
+
* Paced between every request it makes, so unlike the Harvey command there is
|
|
219
|
+
* nothing for a caller to pace between families.
|
|
220
|
+
*
|
|
221
|
+
* A target that yields no rows is refused rather than returned empty: a facet
|
|
222
|
+
* value the vendor retired answers exactly like a category that was
|
|
223
|
+
* discontinued, and only one of those is a scrape this package should write a
|
|
224
|
+
* receipt for.
|
|
225
|
+
*/
|
|
226
|
+
export declare function scrapeCategory(fetcher: Fetcher, target: EmugeTarget, options: EmugeOptions): Promise<ScrapeResult>;
|
|
227
|
+
export {};
|