@toolpath/tool-scraper 2.0.0 → 2.2.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 (54) hide show
  1. package/dist/columns.d.ts +5 -3
  2. package/dist/columns.js +11 -5
  3. package/dist/conventions.d.ts +54 -0
  4. package/dist/conventions.js +59 -0
  5. package/dist/errors.d.ts +30 -0
  6. package/dist/errors.js +30 -0
  7. package/dist/families/emuge.d.ts +3 -1
  8. package/dist/families/emuge.js +8 -4
  9. package/dist/family.d.ts +16 -2
  10. package/dist/holding.d.ts +396 -0
  11. package/dist/holding.js +360 -0
  12. package/dist/index.d.ts +23 -13
  13. package/dist/index.js +23 -13
  14. package/dist/node/cad-mirror.d.ts +58 -1
  15. package/dist/node/cad-mirror.js +56 -8
  16. package/dist/node/cli.d.ts +4 -1
  17. package/dist/node/cli.js +198 -19
  18. package/dist/node/holder-import.d.ts +223 -0
  19. package/dist/node/holder-import.js +379 -0
  20. package/dist/node/index.d.ts +1 -0
  21. package/dist/node/index.js +1 -0
  22. package/dist/node/paths.d.ts +16 -0
  23. package/dist/node/paths.js +20 -0
  24. package/dist/profiles.d.ts +265 -0
  25. package/dist/profiles.js +295 -0
  26. package/dist/records.d.ts +57 -12
  27. package/dist/records.js +37 -13
  28. package/dist/registry.d.ts +86 -5
  29. package/dist/registry.js +149 -8
  30. package/dist/vendors/destinytool/records.js +8 -0
  31. package/dist/vendors/emuge/records.d.ts +83 -3
  32. package/dist/vendors/emuge/records.js +184 -16
  33. package/dist/vendors/kennametal/family.d.ts +119 -0
  34. package/dist/vendors/kennametal/family.js +155 -0
  35. package/dist/vendors/kennametal/holding.d.ts +35 -0
  36. package/dist/vendors/kennametal/holding.js +112 -0
  37. package/dist/vendors/kennametal/index.d.ts +2 -0
  38. package/dist/vendors/kennametal/index.js +2 -0
  39. package/dist/vendors/kennametal/records.d.ts +16 -0
  40. package/dist/vendors/kennametal/records.js +32 -0
  41. package/dist/vendors/kennametal/scrape.d.ts +25 -2
  42. package/dist/vendors/kennametal/scrape.js +28 -3
  43. package/dist/vendors/maritool/holding.d.ts +79 -0
  44. package/dist/vendors/maritool/holding.js +164 -0
  45. package/dist/vendors/maritool/index.d.ts +1 -0
  46. package/dist/vendors/maritool/index.js +1 -0
  47. package/dist/vendors/maritool/scrape.d.ts +37 -13
  48. package/dist/vendors/maritool/scrape.js +53 -14
  49. package/dist/vendors/regofix/holding.d.ts +35 -0
  50. package/dist/vendors/regofix/holding.js +108 -0
  51. package/dist/vendors/regofix/index.d.ts +1 -0
  52. package/dist/vendors/regofix/index.js +1 -0
  53. package/dist/vendors/regofix/scrape.js +2 -2
  54. package/package.json +1 -1
package/dist/records.js CHANGED
@@ -194,22 +194,36 @@ export const DIMENSIONAL_COLUMNS = new Set([...DIMENSIONAL].filter((name) => nam
194
194
  * a key in `sometimes` may be missing and its absence is the vendor's silence;
195
195
  * a key in neither list is not part of that kind's record at all.
196
196
  *
197
- * Both `sometimes` entries today are a flute count nobody publishes:
198
- *
199
- * - the **end mill's**, for Harvey's two deburring families — they publish
200
- * right- and left-hand tooth counts and no flute count, so there is nothing
201
- * to read and 0 is not a substitute;
202
- * - the **tap's**, for EMUGE-FRANKEN, which states no flute count anywhere a
203
- * scrape can reach not on the grouped product, the variant listing, the
204
- * per-part detail record or any facet while its own tap families run 2, 3
205
- * and 4 flutes across their size range, so no per-family constant could be
206
- * true of every row. Kennametal's taps publish a `Z` column and keep filling
207
- * it: `sometimes` permits the key, it does not forbid it.
197
+ * Every `sometimes` entry today is one vendor publishing nothing where another
198
+ * publishes a number. `sometimes` permits the key, it does not forbid it — the
199
+ * vendors that state these keep filling them.
200
+ *
201
+ * - the **end mill's `NOF`**, for Harvey's two deburring families — they
202
+ * publish right- and left-hand tooth counts and no flute count, so there is
203
+ * nothing to read and 0 is not a substitute;
204
+ * - the **tap's `NOF`**, for EMUGE-FRANKEN, which states no flute count
205
+ * anywhere a scrape can reach not on the grouped product, the variant
206
+ * listing, the per-part detail record or any facet while its own tap
207
+ * families run 2, 3 and 4 flutes across their size range, so no per-family
208
+ * constant could be true of every row. Kennametal's taps publish a `Z`
209
+ * column and keep filling it;
210
+ * - the **drill's `SIG`**, for the one EMUGE-FRANKEN drill whose point-angle
211
+ * cell is empty. This one is a hole in a column the vendor otherwise fills,
212
+ * not a column it never had, and it is the reason the key had to move: the
213
+ * EMUGE drill family reads `SIG` from a column rather than from a fact, so
214
+ * a single blank cell refused the row, and `registry.toRecords` maps a
215
+ * family's rows together — 2,669 drills were lost to the 2,670th. Every
216
+ * Kennametal drill supplies `SIG` from a fact and none can be absent.
217
+ *
218
+ * A point angle is not a field to guess at. It sets the drill tip's length, so
219
+ * a CAM system that assumed 140 deg for a part ground at 118 would cut a hole
220
+ * to the wrong depth — an absence a consumer must check for is recoverable and
221
+ * a plausible wrong number is not.
208
222
  */
209
223
  export const RECORD_GEOMETRY = {
210
224
  drill: {
211
- always: ['DC', 'SFDM', 'OAL', 'LCF', 'NOF', 'SIG'],
212
- sometimes: [],
225
+ always: ['DC', 'SFDM', 'OAL', 'LCF', 'NOF'],
226
+ sometimes: ['SIG'],
213
227
  },
214
228
  tap: {
215
229
  always: ['DC', 'TP', 'SFDM', 'OAL', 'LCF'],
@@ -271,6 +285,15 @@ function checkGeometry(kind, what, geometry) {
271
285
  export function toolRecord(fields) {
272
286
  const groups = fields.materialGroups ?? null;
273
287
  const source = fields.materialGroupsSource ?? UNSPECIFIED;
288
+ // An adapter that has nothing to say about the product line says nothing;
289
+ // `''` would be a name and there is no nameless line. Optional here and
290
+ // required on the type for the reason stated above — a consumer reading a
291
+ // record never handles `undefined`.
292
+ const line = fields.productLine ?? null;
293
+ if (line === '') {
294
+ throw new ScraperConfigError(fields.materialNumber, `productLine is the empty string — a vendor that names no product line ` +
295
+ `is null, which is the state a consumer can tell from a name`);
296
+ }
274
297
  // The invariant is what keeps the three states three: groups labelled
275
298
  // `unspecified` are groups nobody stated, and an attributed source with no
276
299
  // groups attributes nothing. Either would land as a record whose material
@@ -288,6 +311,7 @@ export function toolRecord(fields) {
288
311
  materialGroups: groups === null ? null : Object.freeze([...groups]),
289
312
  materialGroupsSource: source,
290
313
  nonFerrous: fields.nonFerrous ?? null,
314
+ productLine: line,
291
315
  });
292
316
  }
293
317
  /**
@@ -27,8 +27,9 @@
27
27
  * entry point into this package goes through here.
28
28
  */
29
29
  import { type BoundFamily, type BoundToolholding, type RecordMappers } from './family.js';
30
+ import type { HoldingMappers, HoldingRecord } from './holding.js';
30
31
  import { type ToolRecord } from './records.js';
31
- import type { MapperOptions, ScrapeResult } from './scrape.js';
32
+ import { type MapperOptions, type ScrapeResult } from './scrape.js';
32
33
  /**
33
34
  * Brand -> its row-to-record mappers, by tool kind.
34
35
  *
@@ -39,6 +40,26 @@ import type { MapperOptions, ScrapeResult } from './scrape.js';
39
40
  * map.
40
41
  */
41
42
  export declare const ADAPTERS: Record<string, RecordMappers>;
43
+ /**
44
+ * Brand -> its toolholding mappers, by the kind of thing they build.
45
+ *
46
+ * The toolholding counterpart of {@link ADAPTERS}, and **partial in both
47
+ * directions on purpose**. A brand absent from here can still be scraped: its
48
+ * families bind, its CSVs are written, and its receipt is checked, exactly as
49
+ * before — what it cannot do is mint records. A brand present with a mapper for
50
+ * only one kind is the same statement one level down; MariTool publishes ER
51
+ * collets that this package does not scrape, so it maps holders and nothing
52
+ * else.
53
+ *
54
+ * That is what makes minting records additive rather than a break. Nothing here
55
+ * changes what a vendor with no entry does today, and the refusal only happens
56
+ * where a caller explicitly asks for records from a family whose brand maps
57
+ * none — {@link toHolding}, which names the brand and what it does map.
58
+ *
59
+ * One entry serves two brands for the reason {@link ADAPTERS} states: Kennametal
60
+ * and WIDIA are the same platform and the same table vocabulary.
61
+ */
62
+ export declare const HOLDING_ADAPTERS: Record<string, HoldingMappers>;
42
63
  /**
43
64
  * Every cutting-tool family, validated and bound to its record mapper.
44
65
  *
@@ -46,13 +67,25 @@ export declare const ADAPTERS: Record<string, RecordMappers>;
46
67
  */
47
68
  export declare function boundFamilies(): Map<string, BoundFamily>;
48
69
  /**
49
- * Every holder and collet family, with its facts checked and projected.
70
+ * Every holder and collet family, with its facts checked and projected, and
71
+ * bound to the mapper its brand supplies for its kind.
72
+ *
73
+ * Their facts pass the same gate cutting-tool families' do: a taper or a
74
+ * clamping mode is a per-family constant no variant table states, exactly like
75
+ * a drill's flute count.
50
76
  *
51
- * They bind no adapter only cutting tools go through a column map — but
52
- * their facts pass the same gate: a taper or a clamping mode is a per-family
53
- * constant no variant table states, exactly like a drill's flute count.
77
+ * **A family whose brand maps nothing binds `undefined` rather than throwing**,
78
+ * which is where this differs from {@link boundFamilies}. A cutting-tool family
79
+ * with no mapper is a catalog fault nothing can be done with it — but a
80
+ * toolholding family with no mapper is the state every one of them was in until
81
+ * records existed, and it still scrapes, writes a CSV and checks a receipt.
82
+ * Refusing at bind time would take that away from every consumer that never
83
+ * asked for a record. {@link toHolding} is where the absence is reported, at
84
+ * the one call that cannot proceed without it.
54
85
  */
55
86
  export declare function boundToolholding(): Map<string, BoundToolholding>;
87
+ /** One bound toolholding family by CSV name. */
88
+ export declare function boundHolding(name: string): BoundToolholding;
56
89
  /** One bound cutting-tool family by CSV name. */
57
90
  export declare function boundFamily(name: string): BoundFamily;
58
91
  /**
@@ -81,8 +114,56 @@ export declare function boundFamily(name: string): BoundFamily;
81
114
  *
82
115
  * `familyName` is the CSV filename the catalog is keyed by
83
116
  * (`'harvey_endmill_025.csv'`), which is what `boundFamily` takes.
117
+ *
118
+ * ## One incomplete part does not end the family
119
+ *
120
+ * The rows are mapped together, so until 2026-09-01 every refusal was equally
121
+ * fatal — and the refusals are not equal. A part the vendor left a required
122
+ * cell blank on is one bad row among thousands of good ones; EMUGE-FRANKEN
123
+ * omits `overall length l₁` on roughly 175 of its 7,021 end mill variants, and
124
+ * both end mill families converted to nothing at all because of them.
125
+ *
126
+ * So an {@link IncompletePartError} is warned about and the row is dropped.
127
+ * **Nothing else is.** A cutting material with no mapping, a column a family
128
+ * stopped mapping, a response that changed shape — those say the vendor's
129
+ * vocabulary or this package's catalog has moved, and a scraper that skipped
130
+ * quietly past them would publish a catalog nobody checked. `columns.required`
131
+ * is the only place that raises the skippable one.
132
+ *
133
+ * A dropped row is **not** a relaxed contract. `records.RECORD_GEOMETRY` still
134
+ * says an end mill always has an `OAL`, and every record returned here still
135
+ * has one: the part without it becomes no record rather than a record with a
136
+ * hole. Where a vendor genuinely never publishes a field, `sometimes` is still
137
+ * the answer — a drill's `SIG` is that, and it stays that.
138
+ *
139
+ * The count of what was dropped is not returned. A caller that needs it has
140
+ * the row count it passed in and the length it got back, and the warnings name
141
+ * every part by number.
84
142
  */
85
143
  export declare function toRecords(familyName: string, scrape: ScrapeResult, options?: MapperOptions): ToolRecord[];
144
+ /**
145
+ * One toolholding family's scrape, as {@link HoldingRecord}s.
146
+ *
147
+ * {@link toRecords}'s counterpart, and deliberately the same shape: the two
148
+ * checks run before the first row, one incomplete part does not end the family,
149
+ * and the count of what was dropped is not returned because the caller has the
150
+ * row count it passed in and the length it got back.
151
+ *
152
+ * **It refuses only where a caller asked for something this package cannot
153
+ * give.** A toolholding family whose brand maps no mapper binds one anyway
154
+ * (see {@link boundToolholding}) and scrapes exactly as it did before; this is
155
+ * the one call that cannot proceed without one, so this is where the absence is
156
+ * named — with the brand and with what that brand does map, the way
157
+ * {@link boundFamilies} names a missing tool mapper.
158
+ *
159
+ * `checkColumnsExist` has no counterpart here: a holder family carries no
160
+ * `ColumnMap`, because the columns a holder publishes are the vendor's own and
161
+ * are read by that vendor's mapper rather than through a canonical name. What
162
+ * does still run is {@link checkIdentityColumns}, which catches the failure that
163
+ * matters most — a re-scrape whose part-number column was renamed still parses,
164
+ * still has the right row count, and mints every guid off an empty string.
165
+ */
166
+ export declare function toHolding(familyName: string, scrape: ScrapeResult, options?: MapperOptions): HoldingRecord[];
86
167
  /**
87
168
  * Forget what has been bound.
88
169
  *
package/dist/registry.js CHANGED
@@ -29,13 +29,17 @@
29
29
  import { checkIdentityColumns } from './conventions.js';
30
30
  import { familyBrand, } from './family.js';
31
31
  import { COLLET_FAMILIES, FAMILIES, HOLDER_FAMILIES } from './families/index.js';
32
- import { ScraperConfigError } from './errors.js';
32
+ import { IncompletePartError, ScraperConfigError } from './errors.js';
33
33
  import { checkFact } from './provenance.js';
34
34
  import { checkColumnMap, checkColumnsExist } from './records.js';
35
+ import { consoleWarn } from './scrape.js';
35
36
  import { RECORD_MAPPERS as DESTINYTOOL } from './vendors/destinytool/records.js';
36
37
  import { RECORD_MAPPERS as EMUGE } from './vendors/emuge/records.js';
37
38
  import { RECORD_MAPPERS as HARVEY } from './vendors/harvey/records.js';
39
+ import { HOLDING_MAPPERS as KM_HOLDING } from './vendors/kennametal/holding.js';
38
40
  import { RECORD_MAPPERS as KENNAMETAL } from './vendors/kennametal/records.js';
41
+ import { HOLDING_MAPPERS as MARITOOL_HOLDING } from './vendors/maritool/holding.js';
42
+ import { HOLDING_MAPPERS as REGOFIX_HOLDING } from './vendors/regofix/holding.js';
39
43
  /**
40
44
  * Brand -> its row-to-record mappers, by tool kind.
41
45
  *
@@ -52,6 +56,31 @@ export const ADAPTERS = {
52
56
  harvey: HARVEY,
53
57
  emuge: EMUGE,
54
58
  };
59
+ /**
60
+ * Brand -> its toolholding mappers, by the kind of thing they build.
61
+ *
62
+ * The toolholding counterpart of {@link ADAPTERS}, and **partial in both
63
+ * directions on purpose**. A brand absent from here can still be scraped: its
64
+ * families bind, its CSVs are written, and its receipt is checked, exactly as
65
+ * before — what it cannot do is mint records. A brand present with a mapper for
66
+ * only one kind is the same statement one level down; MariTool publishes ER
67
+ * collets that this package does not scrape, so it maps holders and nothing
68
+ * else.
69
+ *
70
+ * That is what makes minting records additive rather than a break. Nothing here
71
+ * changes what a vendor with no entry does today, and the refusal only happens
72
+ * where a caller explicitly asks for records from a family whose brand maps
73
+ * none — {@link toHolding}, which names the brand and what it does map.
74
+ *
75
+ * One entry serves two brands for the reason {@link ADAPTERS} states: Kennametal
76
+ * and WIDIA are the same platform and the same table vocabulary.
77
+ */
78
+ export const HOLDING_ADAPTERS = {
79
+ kennametal: KM_HOLDING,
80
+ widia: KM_HOLDING,
81
+ regofix: REGOFIX_HOLDING,
82
+ maritool: MARITOOL_HOLDING,
83
+ };
55
84
  /**
56
85
  * Check every fact of one family and project its value onto the config under
57
86
  * its own key.
@@ -109,27 +138,50 @@ export function boundFamilies() {
109
138
  return bound;
110
139
  }
111
140
  /**
112
- * Every holder and collet family, with its facts checked and projected.
141
+ * Every holder and collet family, with its facts checked and projected, and
142
+ * bound to the mapper its brand supplies for its kind.
113
143
  *
114
- * They bind no adapter only cutting tools go through a column map — but
115
- * their facts pass the same gate: a taper or a clamping mode is a per-family
116
- * constant no variant table states, exactly like a drill's flute count.
144
+ * Their facts pass the same gate cutting-tool families' do: a taper or a
145
+ * clamping mode is a per-family constant no variant table states, exactly like
146
+ * a drill's flute count.
147
+ *
148
+ * **A family whose brand maps nothing binds `undefined` rather than throwing**,
149
+ * which is where this differs from {@link boundFamilies}. A cutting-tool family
150
+ * with no mapper is a catalog fault — nothing can be done with it — but a
151
+ * toolholding family with no mapper is the state every one of them was in until
152
+ * records existed, and it still scrapes, writes a CSV and checks a receipt.
153
+ * Refusing at bind time would take that away from every consumer that never
154
+ * asked for a record. {@link toHolding} is where the absence is reported, at
155
+ * the one call that cannot proceed without it.
117
156
  */
118
157
  export function boundToolholding() {
119
158
  if (toolholding !== null)
120
159
  return toolholding;
121
160
  const bound = new Map();
122
- for (const [table, families_] of [
161
+ for (const [kind, families_] of [
123
162
  ['holder', HOLDER_FAMILIES],
124
163
  ['collet', COLLET_FAMILIES],
125
164
  ]) {
126
165
  for (const [name, cfg] of Object.entries(families_)) {
127
- bound.set(name, project(table, name, cfg));
166
+ const mappers = HOLDING_ADAPTERS[familyBrand(cfg)];
167
+ bound.set(name, {
168
+ ...project(kind, name, cfg),
169
+ kind,
170
+ records: mappers?.[kind],
171
+ });
128
172
  }
129
173
  }
130
174
  toolholding = bound;
131
175
  return bound;
132
176
  }
177
+ /** One bound toolholding family by CSV name. */
178
+ export function boundHolding(name) {
179
+ const cfg = boundToolholding().get(name);
180
+ if (cfg === undefined) {
181
+ throw new ScraperConfigError(name, `unknown toolholding family (known: ${[...boundToolholding().keys()].sort().join(', ')})`);
182
+ }
183
+ return cfg;
184
+ }
133
185
  /** One bound cutting-tool family by CSV name. */
134
186
  export function boundFamily(name) {
135
187
  const cfg = boundFamilies().get(name);
@@ -164,12 +216,101 @@ export function boundFamily(name) {
164
216
  *
165
217
  * `familyName` is the CSV filename the catalog is keyed by
166
218
  * (`'harvey_endmill_025.csv'`), which is what `boundFamily` takes.
219
+ *
220
+ * ## One incomplete part does not end the family
221
+ *
222
+ * The rows are mapped together, so until 2026-09-01 every refusal was equally
223
+ * fatal — and the refusals are not equal. A part the vendor left a required
224
+ * cell blank on is one bad row among thousands of good ones; EMUGE-FRANKEN
225
+ * omits `overall length l₁` on roughly 175 of its 7,021 end mill variants, and
226
+ * both end mill families converted to nothing at all because of them.
227
+ *
228
+ * So an {@link IncompletePartError} is warned about and the row is dropped.
229
+ * **Nothing else is.** A cutting material with no mapping, a column a family
230
+ * stopped mapping, a response that changed shape — those say the vendor's
231
+ * vocabulary or this package's catalog has moved, and a scraper that skipped
232
+ * quietly past them would publish a catalog nobody checked. `columns.required`
233
+ * is the only place that raises the skippable one.
234
+ *
235
+ * A dropped row is **not** a relaxed contract. `records.RECORD_GEOMETRY` still
236
+ * says an end mill always has an `OAL`, and every record returned here still
237
+ * has one: the part without it becomes no record rather than a record with a
238
+ * hole. Where a vendor genuinely never publishes a field, `sometimes` is still
239
+ * the answer — a drill's `SIG` is that, and it stays that.
240
+ *
241
+ * The count of what was dropped is not returned. A caller that needs it has
242
+ * the row count it passed in and the length it got back, and the warnings name
243
+ * every part by number.
167
244
  */
168
245
  export function toRecords(familyName, scrape, options) {
169
246
  const cfg = boundFamily(familyName);
247
+ const warn = options?.warn ?? consoleWarn;
170
248
  checkIdentityColumns(familyBrand(cfg), scrape.header);
171
249
  checkColumnsExist(familyName, cfg, scrape.header);
172
- return scrape.rows.map((row) => cfg.records(row, cfg, cfg.columns, options));
250
+ const records = [];
251
+ for (const row of scrape.rows) {
252
+ try {
253
+ records.push(cfg.records(row, cfg, cfg.columns, options));
254
+ }
255
+ catch (error) {
256
+ if (!(error instanceof IncompletePartError))
257
+ throw error;
258
+ warn(` WARNING: ${error.message} — no record written for it`);
259
+ }
260
+ }
261
+ return records;
262
+ }
263
+ /**
264
+ * One toolholding family's scrape, as {@link HoldingRecord}s.
265
+ *
266
+ * {@link toRecords}'s counterpart, and deliberately the same shape: the two
267
+ * checks run before the first row, one incomplete part does not end the family,
268
+ * and the count of what was dropped is not returned because the caller has the
269
+ * row count it passed in and the length it got back.
270
+ *
271
+ * **It refuses only where a caller asked for something this package cannot
272
+ * give.** A toolholding family whose brand maps no mapper binds one anyway
273
+ * (see {@link boundToolholding}) and scrapes exactly as it did before; this is
274
+ * the one call that cannot proceed without one, so this is where the absence is
275
+ * named — with the brand and with what that brand does map, the way
276
+ * {@link boundFamilies} names a missing tool mapper.
277
+ *
278
+ * `checkColumnsExist` has no counterpart here: a holder family carries no
279
+ * `ColumnMap`, because the columns a holder publishes are the vendor's own and
280
+ * are read by that vendor's mapper rather than through a canonical name. What
281
+ * does still run is {@link checkIdentityColumns}, which catches the failure that
282
+ * matters most — a re-scrape whose part-number column was renamed still parses,
283
+ * still has the right row count, and mints every guid off an empty string.
284
+ */
285
+ export function toHolding(familyName, scrape, options) {
286
+ const cfg = boundHolding(familyName);
287
+ const warn = options?.warn ?? consoleWarn;
288
+ const brand = familyBrand(cfg);
289
+ const mapper = cfg.records;
290
+ if (mapper === undefined) {
291
+ const mappers = HOLDING_ADAPTERS[brand];
292
+ throw new ScraperConfigError(familyName, `brand ${JSON.stringify(brand)} has no ${cfg.kind} mapper ` +
293
+ (mappers === undefined
294
+ ? `— it maps no toolholding at all, so this family ends at rows and ` +
295
+ `a receipt (brands that map some: ` +
296
+ `${Object.keys(HOLDING_ADAPTERS).sort().join(', ')})`
297
+ : `(it maps: ${Object.keys(mappers).sort().join(', ')})`));
298
+ }
299
+ checkIdentityColumns(brand, scrape.header);
300
+ const records = [];
301
+ for (const row of scrape.rows) {
302
+ try {
303
+ records.push(cfg.kind === 'holder'
304
+ ? mapper(row, cfg, options)
305
+ : mapper(row, cfg, options));
306
+ }
307
+ catch (error) {
308
+ if (!(error instanceof IncompletePartError))
309
+ throw error;
310
+ warn(` WARNING: ${error.message} — no record written for it`);
311
+ }
312
+ }
313
+ return records;
173
314
  }
174
315
  /**
175
316
  * Forget what has been bound.
@@ -247,6 +247,14 @@ export function endmillRecord(row, family, columns, options = {}) {
247
247
  description,
248
248
  kind: 'endmill',
249
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,
250
258
  substrate: (row['material'] || fact(family, 'bmc', family.bmc)).toLowerCase(),
251
259
  // No carbide grade is published; the coating id is what there is.
252
260
  coating: row['coatingId'] ?? '',
@@ -14,7 +14,12 @@
14
14
  * article code, per part, so there is no `conventions.IDENTITY_DEVIATIONS`
15
15
  * entry — the first vendor since Kennametal that needs none.
16
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.
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`.
18
23
  * - **A per-part ISO 513 index.** `applicationMaterials` returns the vendor's
19
24
  * own P/M/K/N/S/H rating for each part, which fills `materialGroups` as
20
25
  * `vendor-stated`.
@@ -97,6 +102,79 @@ export declare const COOLANT_COLUMNS: Readonly<Record<string, Readonly<Record<st
97
102
  * facet across all three categories (2026-09-01).
98
103
  */
99
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;
100
178
  /**
101
179
  * The flute count EMUGE publishes where it has none: 64 end mill variants, on
102
180
  * 2026-09-01. A sentinel and not a number, so it is refused rather than read.
@@ -123,8 +201,10 @@ export declare function endmillRecord(row: ScrapedRow, family: BoundFamily, colu
123
201
  * `SIG` is a **mapped column**, which no other drill family in this package
124
202
  * manages: the per-part detail record states the point angle outright, so
125
203
  * nothing here is derived from a point length or assumed from a product line.
126
- * `NOF` is the one thing that is a fact, and `nonFerrous` with it neither has
127
- * a default anywhere, by design.
204
+ * It is also the one geometry key this record may not carryone 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.
128
208
  */
129
209
  export declare function drillRecord(row: ScrapedRow, family: BoundFamily, columns: ColumnMap, options?: MapperOptions): ToolRecord;
130
210
  /**