@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
package/dist/scrape.js CHANGED
@@ -13,6 +13,30 @@
13
13
  export const consoleWarn = (message) => {
14
14
  console.warn(message);
15
15
  };
16
+ /**
17
+ * Rows to the header they imply: the union of their keys, in first-seen order.
18
+ *
19
+ * A union rather than the first row's keys, and the reason is the same for both
20
+ * vendors that build a `ScrapeResult` this way. A REGO-FIX collet family is
21
+ * mixed-unit — `D1_mm` on its metric rows and `D1_in` on its inch ones — and a
22
+ * MariTool CSV mixes three holder styles, so which spec keys a part publishes is
23
+ * a function of its style. Keying off row one drops whichever came second.
24
+ *
25
+ * It lived in both adapters, byte for byte, until 2026-08-29. Building the
26
+ * header of a {@link ScrapeResult} is this module's business rather than any
27
+ * manufacturer's — the same call `pause` and `conventions.CAD_COLUMN` already
28
+ * got, and the one `tests/vendor-boundary.test.ts` now makes automatically.
29
+ */
30
+ export function unionHeader(rows) {
31
+ const header = [];
32
+ for (const row of rows) {
33
+ for (const key of Object.keys(row)) {
34
+ if (!header.includes(key))
35
+ header.push(key);
36
+ }
37
+ }
38
+ return header;
39
+ }
16
40
  /**
17
41
  * Milliseconds between requests, wherever a scrape or a mirror loops.
18
42
  *
@@ -94,6 +94,13 @@ export declare function shoulderDiameter(description: string, dc: number): numbe
94
94
  * column when populated, or a fallback keyed on flute count when it is not
95
95
  * (blank on 423 of 3,898 rows, 2026-08-19).
96
96
  *
97
+ * **The two answers are labelled, not blended.** A populated cell is
98
+ * `vendor-stated` and the flute-count fallback is `derived`, so a consumer that
99
+ * will not route a cut off this package's arithmetic can filter on the source
100
+ * rather than having to know which rows Destiny Tool left blank. Neither is
101
+ * ever `null`: the fallback covers every blank cell, so this vendor always has
102
+ * an answer.
103
+ *
97
104
  * The fallback is not a new rule invented for this vendor — it is the split
98
105
  * cutting-data presets are routed by downstream (≤3 flutes non-ferrous, >3
99
106
  * ferrous), applied here to the material-groups facet instead. Real vendor
@@ -109,10 +116,13 @@ export declare function shoulderDiameter(description: string, dc: number): numbe
109
116
  * array and a tool's own list from another has no way to notice the two
110
117
  * disagree.
111
118
  */
112
- export declare function materialGroups(row: ScrapedRow, flutes: number): string[];
119
+ export declare function materialGroups(row: ScrapedRow, flutes: number): Pick<ToolRecord, 'materialGroups' | 'materialGroupsSource'>;
113
120
  /**
114
- * A solid end mill, always in inches Destiny Tool publishes no metric line
115
- * (the `unit` fact on the family).
121
+ * A solid end mill, in the family's declared unit.
122
+ *
123
+ * Which is inches on the one family there is — Destiny Tool publishes no metric
124
+ * line — but read from the `unit` fact rather than hardcoded, so the fact stays
125
+ * the single authored copy the way it is for every other vendor here.
116
126
  */
117
127
  export declare function endmillRecord(row: ScrapedRow, family: BoundFamily, columns: ColumnMap, options?: MapperOptions): ToolRecord;
118
128
  export declare const RECORD_MAPPERS: RecordMappers;
@@ -23,7 +23,8 @@
23
23
  * row.
24
24
  */
25
25
  import { VendorResponseError } from '../../errors.js';
26
- import { familyBrand } from '../../family.js';
26
+ import { fractionValue } from '../../measure.js';
27
+ import { fact, familyBrand } from '../../family.js';
27
28
  import { BRANDS } from '../../identity.js';
28
29
  import { ISO_MATERIAL_GROUPS, toolRecord, } from '../../records.js';
29
30
  import { consoleWarn } from '../../scrape.js';
@@ -86,31 +87,18 @@ export function parseFractionInches(text) {
86
87
  const s = text.trim().replace(/"+$/, '');
87
88
  if (!s)
88
89
  throw new RangeError(`empty dimension: ${JSON.stringify(text)}`);
89
- const value = s.includes('.')
90
- ? Number(s)
91
- : s.includes('-')
92
- ? mixed(s)
93
- : s.includes('/')
94
- ? fraction(s)
95
- : Number(s);
96
- if (!Number.isFinite(value)) {
90
+ // The trailing quote is the only thing Destiny Tool puts around a dimension
91
+ // that `measure.fractionValue` does not read; the grammar itself is the one
92
+ // every vendor here publishes, and it is read in one place.
93
+ const value = fractionValue(s);
94
+ if (value === null) {
97
95
  throw new RangeError(`unrecognized dimension: ${JSON.stringify(text)}`);
98
96
  }
99
97
  return value;
100
98
  }
101
- /** `1-1/2` — a whole number and a simple fraction. */
102
- function mixed(s) {
103
- const cut = s.indexOf('-');
104
- return Number(s.slice(0, cut)) + fraction(s.slice(cut + 1));
105
- }
106
- /** `3/4`. */
107
- function fraction(s) {
108
- const [num, den] = s.split('/');
109
- return Number(num) / Number(den);
110
- }
111
99
  /** A dimension the kind requires, parsed as an inch fraction. */
112
- function required(row, columns, canonical, what) {
113
- const column = columns.column(canonical, 'inches');
100
+ function required(row, columns, canonical, unit, what) {
101
+ const column = columns.column(canonical, unit);
114
102
  const raw = column === null ? undefined : row[column];
115
103
  if (raw === undefined || raw.trim() === '') {
116
104
  throw new VendorResponseError(what, `no value for ${canonical} in column ${JSON.stringify(column)}`);
@@ -190,6 +178,13 @@ export function shoulderDiameter(description, dc) {
190
178
  * column when populated, or a fallback keyed on flute count when it is not
191
179
  * (blank on 423 of 3,898 rows, 2026-08-19).
192
180
  *
181
+ * **The two answers are labelled, not blended.** A populated cell is
182
+ * `vendor-stated` and the flute-count fallback is `derived`, so a consumer that
183
+ * will not route a cut off this package's arithmetic can filter on the source
184
+ * rather than having to know which rows Destiny Tool left blank. Neither is
185
+ * ever `null`: the fallback covers every blank cell, so this vendor always has
186
+ * an answer.
187
+ *
193
188
  * The fallback is not a new rule invented for this vendor — it is the split
194
189
  * cutting-data presets are routed by downstream (≤3 flutes non-ferrous, >3
195
190
  * ferrous), applied here to the material-groups facet instead. Real vendor
@@ -209,23 +204,31 @@ export function materialGroups(row, flutes) {
209
204
  const cell = row['isoMaterialGroups'] ?? '';
210
205
  if (cell.trim()) {
211
206
  const present = new Set(cell.split(/\s+/).filter(Boolean));
212
- return ISO_MATERIAL_GROUPS.filter((group) => present.has(group));
207
+ return {
208
+ materialGroups: ISO_MATERIAL_GROUPS.filter((group) => present.has(group)),
209
+ materialGroupsSource: 'vendor-stated',
210
+ };
213
211
  }
214
- if (flutes <= NON_FERROUS_MAX_FLUTES)
215
- return ['N'];
216
- return ['P', 'M', 'K', 'S', 'H'];
212
+ return {
213
+ materialGroups: flutes <= NON_FERROUS_MAX_FLUTES ? ['N'] : ['P', 'M', 'K', 'S', 'H'],
214
+ materialGroupsSource: 'derived',
215
+ };
217
216
  }
218
217
  /**
219
- * A solid end mill, always in inches Destiny Tool publishes no metric line
220
- * (the `unit` fact on the family).
218
+ * A solid end mill, in the family's declared unit.
219
+ *
220
+ * Which is inches on the one family there is — Destiny Tool publishes no metric
221
+ * line — but read from the `unit` fact rather than hardcoded, so the fact stays
222
+ * the single authored copy the way it is for every other vendor here.
221
223
  */
222
224
  export function endmillRecord(row, family, columns, options = {}) {
225
+ const unit = fact(family, 'unit', family.unit);
223
226
  const what = row[ITEM_NUMBER] ?? '';
224
227
  const description = row['description'] ?? '';
225
- const dc = required(row, columns, 'DC', what);
226
- const fluteLength = required(row, columns, 'LCF', what);
227
- const oal = required(row, columns, 'OAL', what);
228
- const radColumn = columns.column('RE', 'inches');
228
+ const dc = required(row, columns, 'DC', unit, what);
229
+ const fluteLength = required(row, columns, 'LCF', unit, what);
230
+ const oal = required(row, columns, 'OAL', unit, what);
231
+ const radColumn = columns.column('RE', unit);
229
232
  const radRaw = radColumn === null ? undefined : row[radColumn];
230
233
  const radCell = radRaw && radRaw.trim() ? parseFractionInches(radRaw) : null;
231
234
  // Refused rather than allowed through as NaN: `materialGroups` reads it, and
@@ -237,17 +240,26 @@ export function endmillRecord(row, family, columns, options = {}) {
237
240
  throw new VendorResponseError(what, `no integer in column "flutes"`);
238
241
  }
239
242
  return toolRecord({
243
+ brand: familyBrand(family),
240
244
  vendor: BRANDS[familyBrand(family)].vendor,
241
245
  materialNumber: what,
242
246
  catalogNumber: what,
243
247
  description,
244
248
  kind: 'endmill',
245
- unit: 'inches',
246
- substrate: (row['material'] || (family.bmc ?? '')).toLowerCase(),
247
- // No carbide grade is published; the coating id fills GRADE instead.
248
- grade: row['coatingId'] ?? '',
249
- materialGroups: materialGroups(row, flutes),
250
- coolantThrough: family.coolantThrough ?? false,
249
+ unit,
250
+ // `series` is the vendor's own product-line name and the one column here
251
+ // that names one `Viper`, `Raptor`, `DiamondBack`, and `viper-mini` and
252
+ // `python` in the vendor's own lower case, which stays exactly as
253
+ // published for the reason `records.ToolRecord.productLine` gives. It has
254
+ // been scraped since the adapter was written and read by nothing until
255
+ // now; the description cannot stand in for it, because Raptor calls itself
256
+ // `DVH` there and DiamondBack calls itself `DBACK RGHR`.
257
+ productLine: row['series']?.trim() || null,
258
+ substrate: (row['material'] || fact(family, 'bmc', family.bmc)).toLowerCase(),
259
+ // No carbide grade is published; the coating id is what there is.
260
+ coating: row['coatingId'] ?? '',
261
+ ...materialGroups(row, flutes),
262
+ coolantThrough: fact(family, 'coolantThrough', family.coolantThrough),
251
263
  geometry: {
252
264
  DC: dc,
253
265
  RE: cornerRadius(description, row['endStyle'] ?? '', what, dc, radCell,
@@ -0,0 +1,17 @@
1
+ /**
2
+ * EMUGE-FRANKEN — end mills, twist drills and taps, from a SAP Commerce API.
3
+ *
4
+ * Not a catalog to parse: the storefront renders its listings in the browser,
5
+ * so the "scrape" is a JSON client against the same unauthenticated API the
6
+ * site's own Vue front end reads. See `scrape.ts` for the three calls it takes
7
+ * and why, `value.ts` for the grammar of a vendor value string, and
8
+ * `records.ts` for what the vendor states that no other vendor here does — a
9
+ * point angle per drill, an ISO 513 rating per part — and the one thing it
10
+ * states nowhere at all, which is a tap's flute count.
11
+ *
12
+ * `value.ts` is pure and holds the parsing risk; `scrape.ts` is the only module
13
+ * that reads through a `Fetcher`.
14
+ */
15
+ export * from './records.js';
16
+ export * from './scrape.js';
17
+ export * from './value.js';
@@ -0,0 +1,17 @@
1
+ /**
2
+ * EMUGE-FRANKEN — end mills, twist drills and taps, from a SAP Commerce API.
3
+ *
4
+ * Not a catalog to parse: the storefront renders its listings in the browser,
5
+ * so the "scrape" is a JSON client against the same unauthenticated API the
6
+ * site's own Vue front end reads. See `scrape.ts` for the three calls it takes
7
+ * and why, `value.ts` for the grammar of a vendor value string, and
8
+ * `records.ts` for what the vendor states that no other vendor here does — a
9
+ * point angle per drill, an ISO 513 rating per part — and the one thing it
10
+ * states nowhere at all, which is a tap's flute count.
11
+ *
12
+ * `value.ts` is pure and holds the parsing risk; `scrape.ts` is the only module
13
+ * that reads through a `Fetcher`.
14
+ */
15
+ export * from './records.js';
16
+ export * from './scrape.js';
17
+ export * from './value.js';
@@ -0,0 +1,230 @@
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 { type BoundFamily, type RecordMappers } from '../../family.js';
50
+ import { type ColumnMap, type ToolRecord } from '../../records.js';
51
+ import { type MapperOptions, type ScrapedRow } from '../../scrape.js';
52
+ /** The cutting-material property, spelled the same way in all three categories. */
53
+ export declare const SUBSTRATE_COLUMN = "Cutting material";
54
+ /**
55
+ * The coating property, which is **not** spelled the same way.
56
+ *
57
+ * `coating` on a milling part and `Coating` on a drill or a tap, in the same
58
+ * API, on the same day. Both are read and the first non-empty one wins, because
59
+ * relabelling one onto the other in the CSV would be this adapter deciding what
60
+ * the vendor meant — see `scrape.ts`.
61
+ */
62
+ export declare const COATING_COLUMNS: readonly ["coating", "Coating"];
63
+ /**
64
+ * Whether a part is through-coolant, by the vendor's own word for it.
65
+ *
66
+ * Three columns and three vocabularies, one per category, each a closed facet
67
+ * this package read off the vendor's own index on 2026-09-01 — so a value
68
+ * absent from here is a vocabulary that changed rather than a part that is
69
+ * odd, and {@link coolantThrough} refuses it by naming the table to add to.
70
+ *
71
+ * ## The facet does not cover the whole of milling
72
+ *
73
+ * The values below account for 6,862 milling variants, 2,670 drilling and
74
+ * 11,566 tapping. Drilling and tapping are the whole category — the same two
75
+ * numbers `docs/EMUGE_FRANKEN_COMMERCE_API.md` §4 gives — and milling is
76
+ * **159 short** of `FF01`'s 7,021, which is also `families/emuge.ts`'s two row
77
+ * counts added up. So 159 milling variants carry no `internal coolant supply`
78
+ * value at all.
79
+ *
80
+ * That is a gap in the vendor's index rather than a vocabulary that moved, and
81
+ * it is why {@link coolantThrough} separates the two cases: an unrecognised
82
+ * *value* still refuses, and a column nobody filled warns and records `false`.
83
+ * They were one case until 2026-09-01, and because `registry.toRecords` maps
84
+ * its rows, a single such part threw and took the whole family's conversion
85
+ * with it — 159 unindexed variants for two dead end mill CSVs.
86
+ */
87
+ export declare const COOLANT_COLUMNS: Readonly<Record<string, Readonly<Record<string, boolean>>>>;
88
+ /**
89
+ * The vendor's cutting materials, onto the package's own vocabulary.
90
+ *
91
+ * `FamilyFacts.bmc` names a material class — `carbide`, `hss`, `diamond` — and
92
+ * EMUGE's index names an alloy and a production route: HSS, HSSE and HSSE-PM
93
+ * are three grades of high-speed steel and all three are `hss`. `PCD` is
94
+ * `diamond`, which is the call `families/kennametal.ts` already made for its
95
+ * PCD drills: the word names the cutting material rather than the body it is
96
+ * brazed to.
97
+ *
98
+ * `cbn` and `ceramic` keep the vendor's own name, lowercased, because the
99
+ * package's three words have no counterpart for either and inventing one would
100
+ * be worse than recording what is standard. Closed, and it throws on anything
101
+ * else — the seven values below are the whole of the vendor's `HYB_AAM_MAT`
102
+ * facet across all three categories (2026-09-01).
103
+ */
104
+ export declare const SUBSTRATES: Readonly<Record<string, string>>;
105
+ /**
106
+ * The column each EMUGE-FRANKEN category states its product line in, keyed by
107
+ * the vendor's own category code.
108
+ *
109
+ * **One column per category, and each is a facet that partitions its category
110
+ * exactly.** Checked against the vendor's own index on 2026-09-01, at group
111
+ * level rather than by summing counts:
112
+ *
113
+ * | Category | Facet | Values | Groups it covers | Groups in two values |
114
+ * | -------- | -------------------- | ------ | ---------------- | -------------------- |
115
+ * | `FF01` | `AMM_PROG_LINIE` | 15 | 554 of 554 | 2 |
116
+ * | `FB01` | `HYB_BAM_SB_GT` | 4 | 17 of 17 | 0 |
117
+ * | `FG01` | `HYB_BAM_SB_GT` | 17 | 414 of 414 | 0 |
118
+ *
119
+ * That is what makes a product line here a **read and not an arbitration**.
120
+ * EMUGE's marketing publishes 43 overlapping product-family pages — a tap is
121
+ * simultaneously "Rekord B-Z Taps", "Enorm Z Taps" and "Left Hand Taps" — and
122
+ * choosing between those would be this package inventing a rule the vendor
123
+ * never stated. The facets above are the vendor's own partition of the same
124
+ * catalog, so there is nothing to choose.
125
+ *
126
+ * **The two milling groups that fall in two values cost nothing**, because the
127
+ * milling column is read per part: `H300024` and `H300025` each hold both
128
+ * `FRANKEN TiNox-Cut` and `FRANKEN TiNox-Cut VAR` variants, and the per-part
129
+ * detail record states which is which. A rule that tagged a whole group would
130
+ * have had to pick.
131
+ *
132
+ * **No request is added for any of this.** `Geometry` is on the grouped product
133
+ * and `product line` on the per-part detail, so `scrape.ts` already writes both
134
+ * into every row — see its three-call note.
135
+ */
136
+ export declare const PRODUCT_LINE_COLUMNS: Readonly<Record<string, string>>;
137
+ /**
138
+ * A category's product-line codes onto the vendor's own name for each.
139
+ *
140
+ * **Both sides are EMUGE's.** The key is the value its `HYB_BAM_SB_GT` facet
141
+ * publishes and the name is the title of the vendor's own product-family
142
+ * article page for it, read on 2026-09-01 and cited per entry. Nothing here is
143
+ * this package's wording, which is the whole condition on a table like this —
144
+ * the same rule {@link SUBSTRATES} keeps, one level less strict because that
145
+ * one maps onto a vocabulary this package owns and this one does not.
146
+ *
147
+ * **A category whose facet already names the line has no entry.** `FF01`'s
148
+ * values are `FRANKEN TOP-Cut`, `FRANKEN Hard-Cut`, `FRANKEN Alu-Cut` — the
149
+ * vendor's marketing names already — so milling passes through verbatim and
150
+ * appears nowhere below. Drilling and tapping index by a geometry code
151
+ * (`MULTI`, `Z`, `VA`) that matches nothing EMUGE sells under that name, which
152
+ * is the only reason this table exists.
153
+ *
154
+ * **A code with no article page keeps the code**, and that is a gap in the
155
+ * vendor's marketing rather than a hole here: `SPEED`, `FK`, `GAL`, `GG` and
156
+ * `TILEG` are real lines with no `/a/` page on the US storefront, so the
157
+ * honest answer is the vendor's own code until one appears.
158
+ */
159
+ export declare const PRODUCT_LINES: Readonly<Record<string, Readonly<Record<string, string>>>>;
160
+ /**
161
+ * The product line this row states, or null where the vendor states none.
162
+ *
163
+ * Three answers and they are different things. A family whose category has no
164
+ * entry in {@link PRODUCT_LINE_COLUMNS} is a category this adapter has not
165
+ * looked at, and it answers null rather than guessing at which of the row's
166
+ * columns is the line. A column the vendor left empty is null too — the same
167
+ * silence `angle` treats as an omission rather than a fault. A *value* is
168
+ * mapped through {@link PRODUCT_LINES} where its category has a table and
169
+ * passed through verbatim where it does not, which is the milling case.
170
+ *
171
+ * It does not throw on an unknown code, and that is the difference between
172
+ * this and `substrate`: a cutting material this package cannot map would put a
173
+ * wrong word in `ToolRecord.substrate`, where an unmapped product-line code is
174
+ * the vendor's own code and readable as one. A new EMUGE geometry showing up
175
+ * as `SPEED` is a name to improve, not a record to refuse.
176
+ */
177
+ export declare function productLine(row: ScrapedRow, family: BoundFamily): string | null;
178
+ /**
179
+ * The flute count EMUGE publishes where it has none: 64 end mill variants, on
180
+ * 2026-09-01. A sentinel and not a number, so it is refused rather than read.
181
+ */
182
+ export declare const NO_FLUTE_COUNT = 999;
183
+ /**
184
+ * A solid end mill, in the family's declared unit.
185
+ *
186
+ * The two shoulder fields fall back the way every end mill mapper here does: a
187
+ * family with no neck column is a plain tool whose usable length below the
188
+ * shank is its flute length and whose shoulder is its cutting diameter. EMUGE
189
+ * publishes `neck length l₃` and `neck diameter Ød₃` on the necked lines and
190
+ * neither on the plain ones, in the same family, so the fallback is per row
191
+ * rather than per family.
192
+ *
193
+ * A missing `radius r₁` is a square end and 0 is the right answer — the reason
194
+ * `RE` is optional on the end mill contract.
195
+ */
196
+ export declare function endmillRecord(row: ScrapedRow, family: BoundFamily, columns: ColumnMap, options?: MapperOptions): ToolRecord;
197
+ /**
198
+ * A twist drill, in millimetres — the only system EMUGE publishes its drill
199
+ * lengths in.
200
+ *
201
+ * `SIG` is a **mapped column**, which no other drill family in this package
202
+ * manages: the per-part detail record states the point angle outright, so
203
+ * nothing here is derived from a point length or assumed from a product line.
204
+ * It is also the one geometry key this record may not carry — one variant's
205
+ * cell is empty, and {@link angle} says what that costs. `NOF` is the one
206
+ * thing that is a fact, and `nonFerrous` with it — neither has a default
207
+ * anywhere, by design.
208
+ */
209
+ export declare function drillRecord(row: ScrapedRow, family: BoundFamily, columns: ColumnMap, options?: MapperOptions): ToolRecord;
210
+ /**
211
+ * A tap, in millimetres — including an inch-thread one.
212
+ *
213
+ * That is what the vendor published rather than a conversion this package made:
214
+ * a `#4-40 UNC` tap's major diameter is stated as `2.845 mm` and its shank,
215
+ * overall length and cutting-edge length are millimetres too. So the family
216
+ * declares `unit: millimeters` and there is no per-row thread system to read —
217
+ * the shape Kennametal needs, where one family holds both and `Thread System`
218
+ * is a column the scrape tags on.
219
+ *
220
+ * `DC` is **read, not derived**. Kennametal parses a major diameter out of the
221
+ * thread designation because its tap tables publish no diameter column; EMUGE
222
+ * publishes one, and reading the vendor's own number is always the better of
223
+ * the two. The designation is still on the CSV as `dimensionFeatureValue`, and
224
+ * `thread symbol` and `threads per inch` beside it.
225
+ *
226
+ * `NOF` is absent, and its absence is the vendor's silence — see the module
227
+ * docstring and `records.RECORD_GEOMETRY`.
228
+ */
229
+ export declare function tapRecord(row: ScrapedRow, family: BoundFamily, columns: ColumnMap, options?: MapperOptions): ToolRecord;
230
+ export declare const RECORD_MAPPERS: RecordMappers;