@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/records.d.ts CHANGED
@@ -43,6 +43,8 @@
43
43
  * separately could supply a tool that claims a holder it does not have.
44
44
  */
45
45
  import { type UnitSystem } from './conventions.js';
46
+ import { type BrandName } from './identity.js';
47
+ import type { FactSource } from './provenance.js';
46
48
  /** The kinds of cutting tool this package maps. */
47
49
  export type ToolKind = 'drill' | 'tap' | 'endmill';
48
50
  /**
@@ -56,6 +58,35 @@ export type ToolKind = 'drill' | 'tap' | 'endmill';
56
58
  * and a tool's own list from another has no way to notice the two disagree.
57
59
  */
58
60
  export declare const ISO_MATERIAL_GROUPS: readonly ["P", "M", "K", "N", "S", "H", "C"];
61
+ /**
62
+ * The material-groups label for **we do not know what this tool is for**.
63
+ *
64
+ * Not an ISO 513 group and deliberately not one of {@link ISO_MATERIAL_GROUPS}:
65
+ * it is a statement about this package's evidence, not about the tool. It sits
66
+ * in {@link ToolRecord.materialGroupsSource} rather than in the group list,
67
+ * because a consumer filtering a catalog down to "cuts steel" must not have to
68
+ * know that one of the letters is not a letter.
69
+ *
70
+ * **Why a label and not a bare `null`.** The absence was expressible before —
71
+ * `materialGroups === null` — and an absence is the one thing a reader has to
72
+ * interpret. Every Harvey part is here, and so is every Kennametal family whose
73
+ * material sweep has not been run: two different reasons for the same honest
74
+ * answer, and neither is "rated for nothing". A named value says that out loud
75
+ * in a UI, a log line and a filter, which `null` never does.
76
+ */
77
+ export declare const UNSPECIFIED = "unspecified";
78
+ /**
79
+ * How a record's material groups were arrived at.
80
+ *
81
+ * A fact's own three sources plus {@link UNSPECIFIED}, which provenance has no
82
+ * word for because a `Fact` only exists once somebody has stated something. A
83
+ * record exists either way, so it needs the fourth.
84
+ *
85
+ * Only `vendor-stated`, `derived` and `unspecified` occur today; `assumed` is
86
+ * reachable the moment a family fact supplies the groups, and is kept in the
87
+ * type so that the vocabularies cannot drift apart.
88
+ */
89
+ export type MaterialGroupsSource = FactSource | typeof UNSPECIFIED;
59
90
  /** One canonical geometry name: what it measures, and whose name it is. */
60
91
  export interface GeometryField {
61
92
  /**
@@ -186,6 +217,55 @@ export declare const DIMENSIONAL: ReadonlySet<GeometryName>;
186
217
  * suffix would look for a column that was never scraped.
187
218
  */
188
219
  export declare const DIMENSIONAL_COLUMNS: ReadonlySet<GeometryName>;
220
+ /**
221
+ * What geometry a **record** of each kind carries, as against what a family
222
+ * must map.
223
+ *
224
+ * {@link REQUIRED_GEOMETRY} is about columns: it refuses a family whose config
225
+ * maps no `LCF`. This is about the record that comes out the other end, and the
226
+ * two genuinely differ — a Kennametal drill's `NOF` and `SIG` come from facts
227
+ * and not from any column, so they can never appear in a column map and are
228
+ * always on the record.
229
+ *
230
+ * **`sometimes` is the point of the table.** An absent key was the one thing in
231
+ * a record a reader had to interpret: `geometry.NOF === undefined` means "Harvey
232
+ * publishes no flute count for this family" on an end mill and "not part of the
233
+ * contract" on a drill, and nothing said which. That is exactly the ambiguity
234
+ * {@link UNSPECIFIED} exists to remove from {@link ToolRecord.materialGroups},
235
+ * and the record shipped both encodings at once. Now the absence is declared:
236
+ * a key in `sometimes` may be missing and its absence is the vendor's silence;
237
+ * a key in neither list is not part of that kind's record at all.
238
+ *
239
+ * Every `sometimes` entry today is one vendor publishing nothing where another
240
+ * publishes a number. `sometimes` permits the key, it does not forbid it — the
241
+ * vendors that state these keep filling them.
242
+ *
243
+ * - the **end mill's `NOF`**, for Harvey's two deburring families — they
244
+ * publish right- and left-hand tooth counts and no flute count, so there is
245
+ * nothing to read and 0 is not a substitute;
246
+ * - the **tap's `NOF`**, for EMUGE-FRANKEN, which states no flute count
247
+ * anywhere a scrape can reach — not on the grouped product, the variant
248
+ * listing, the per-part detail record or any facet — while its own tap
249
+ * families run 2, 3 and 4 flutes across their size range, so no per-family
250
+ * constant could be true of every row. Kennametal's taps publish a `Z`
251
+ * column and keep filling it;
252
+ * - the **drill's `SIG`**, for the one EMUGE-FRANKEN drill whose point-angle
253
+ * cell is empty. This one is a hole in a column the vendor otherwise fills,
254
+ * not a column it never had, and it is the reason the key had to move: the
255
+ * EMUGE drill family reads `SIG` from a column rather than from a fact, so
256
+ * a single blank cell refused the row, and `registry.toRecords` maps a
257
+ * family's rows together — 2,669 drills were lost to the 2,670th. Every
258
+ * Kennametal drill supplies `SIG` from a fact and none can be absent.
259
+ *
260
+ * A point angle is not a field to guess at. It sets the drill tip's length, so
261
+ * a CAM system that assumed 140 deg for a part ground at 118 would cut a hole
262
+ * to the wrong depth — an absence a consumer must check for is recoverable and
263
+ * a plausible wrong number is not.
264
+ */
265
+ export declare const RECORD_GEOMETRY: Record<ToolKind, {
266
+ readonly always: readonly GeometryName[];
267
+ readonly sometimes: readonly GeometryName[];
268
+ }>;
189
269
  /**
190
270
  * One orderable cutting tool, in canonical fields, ready for the core.
191
271
  *
@@ -193,23 +273,134 @@ export declare const DIMENSIONAL_COLUMNS: ReadonlySet<GeometryName>;
193
273
  * it and hands it over, and a mapper that mutated one would be reaching back
194
274
  * across the seam this type exists to draw.
195
275
  *
196
- * `geometry` holds {@link GEOMETRY_FIELDS} names in `unit`. An **empty**
197
- * `materialGroups` is a real answer — Kennametal indexes no tap by workpiece
198
- * material, so all 129 carry none, and reading empty as "unconstrained" would
199
- * put every tap under every material on no evidence.
276
+ * `geometry` holds {@link GEOMETRY_FIELDS} names in `unit`.
277
+ *
278
+ * ## The workpiece-material groups have three states, not two
279
+ *
280
+ * They were `string[]` until this contract, and an empty array carried two
281
+ * incompatible claims at once. Kennametal's 129 taps are empty because the
282
+ * vendor's own index rates them for nothing; every Harvey part was empty
283
+ * because Harvey publishes no index a scrape can reach, and a Kennametal family
284
+ * whose material sweep was never run was empty for a third reason. A consumer
285
+ * reading the first as "not rated for steel" is right and reading the second
286
+ * the same way is wrong, and nothing in the record told the two apart.
287
+ *
288
+ * So:
289
+ *
290
+ * - **`null`, labelled {@link UNSPECIFIED}** — we do not know what this tool is
291
+ * for. Not indexed, not published, or not swept. Says nothing about what it
292
+ * cuts, and is not a claim that it cuts nothing.
293
+ * - **`[]`** — the vendor's index exists and rates this part for nothing.
294
+ * - **non-empty** — rated, in {@link ISO_MATERIAL_GROUPS} order.
295
+ *
296
+ * {@link ToolRecord.materialGroupsSource} is never absent: it is `unspecified`
297
+ * in the first case and, in the other two, how the answer was arrived at in the
298
+ * same vocabulary a family fact uses — `vendor-stated` where the vendor's own
299
+ * index said so, `derived` where this package computed it. A consumer that will
300
+ * not act on a guess filters on it, and one that will not show an unknown as an
301
+ * empty facet filters on it too.
200
302
  */
201
303
  export interface ToolRecord {
304
+ /**
305
+ * The brand key the record was minted under — `identity.BRANDS`'s own key,
306
+ * not the display name.
307
+ *
308
+ * Here because {@link ToolRecord.guid} is the join key for every downstream
309
+ * consumer and it is minted in this brand's namespace: without the key on
310
+ * the record, the guid is underivable from the record and `vendor` is a
311
+ * display string nothing can look a namespace up by.
312
+ */
313
+ readonly brand: BrandName;
314
+ /** What this brand's records call the vendor — `identity.BRANDS[brand].vendor`. */
202
315
  readonly vendor: string;
316
+ /**
317
+ * `identity.recordGuid(brand, materialNumber)`, minted by {@link toolRecord}
318
+ * rather than by an adapter: one mint path is what makes a guid collision
319
+ * across brands structurally impossible instead of merely unlikely.
320
+ */
321
+ readonly guid: string;
203
322
  readonly materialNumber: string;
204
323
  readonly catalogNumber: string;
324
+ /**
325
+ * The vendor's own free text about this part, verbatim — `''` where the
326
+ * vendor publishes none.
327
+ *
328
+ * **Never a copy of another field on this record.** It was
329
+ * `row['ISO Catalog Number']` on every Kennametal drill and end mill until
330
+ * 2026-08-29, which put the catalog number in two fields and told a consumer
331
+ * nothing it did not already have: a search index built on it matched a part
332
+ * number and no words. Kennametal publishes no description column, so the
333
+ * honest answer is the empty string — the same rule {@link ToolRecord.coating}
334
+ * already states for a table that publishes no coating.
335
+ *
336
+ * **It may be per product line rather than per part.** Harvey states one
337
+ * title for a whole page and no per-part text, so every record of a Harvey
338
+ * family carries that family's title. That is what the vendor published; a
339
+ * consumer that needs a per-part string has `catalogNumber`.
340
+ */
205
341
  readonly description: string;
342
+ /**
343
+ * The vendor's own name for the product line this part belongs to —
344
+ * `FRANKEN TOP-Cut`, `MultiDRILL`, `KenCut™ FF`, `Viper` — or `null` where
345
+ * the vendor names none.
346
+ *
347
+ * **Null is the vendor's silence, not an empty name**, the same three-state
348
+ * reasoning {@link ToolRecord.materialGroups} makes with {@link UNSPECIFIED}
349
+ * and for the same reason: a consumer faceting a catalog by product line has
350
+ * to be able to tell "EMUGE calls this Alu-Cut" from "nobody has decided what
351
+ * Harvey's line is called", and `''` collapses the two.
352
+ *
353
+ * **Verbatim, and never inferred** — the {@link ToolRecord.coating} rule.
354
+ * EMUGE's own index says `FRANKEN Expert` for the eight end mills its
355
+ * marketing calls "Cut & Form", and Destiny Tool ships `viper-mini` and
356
+ * `python` beside `Viper` and `Raptor`; case-folding either here would be
357
+ * this package authoring a vendor's catalog. The one thing an adapter may do
358
+ * is map a vendor's *code* onto that same vendor's *own* published name for
359
+ * it — see `vendors/emuge/records.ts`'s `PRODUCT_LINES`, where both sides of
360
+ * every entry are EMUGE's and the article page each name came from is cited.
361
+ *
362
+ * **Where it comes from is a fact about the vendor's data, not a convention.**
363
+ * Three sources are in use and each is the only one its vendor offers: a
364
+ * scraped column read per part (EMUGE, Destiny Tool), a page title fetched
365
+ * per family (Kennametal, WIDIA), and nothing at all (Harvey Tool, whose
366
+ * product-line title is already this record's `description` — a second copy
367
+ * of one string is the thing that field's own docstring refuses).
368
+ *
369
+ * **Never a copy of another field on this record**, for the reason
370
+ * {@link ToolRecord.description} states it.
371
+ */
372
+ readonly productLine: string | null;
206
373
  readonly kind: ToolKind;
207
374
  readonly unit: UnitSystem;
208
375
  readonly substrate: string;
209
- readonly grade: string;
210
- readonly geometry: Readonly<Partial<Record<GeometryName, number | boolean>>>;
376
+ /**
377
+ * The vendor's own coating string, `''` where the table publishes none.
378
+ *
379
+ * Raw, and never inferred from: `AlTiN COATED`, `TiN` and a Destiny Tool
380
+ * coating id are three vendors' vocabularies, and mapping them onto a shared
381
+ * one here would be this package inventing a standard rather than recording
382
+ * what was published.
383
+ */
384
+ readonly coating: string;
385
+ /**
386
+ * The canonical geometry, in {@link ToolRecord.unit}.
387
+ *
388
+ * Numbers only. It was `number | boolean` until 2026-08-29 and no adapter has
389
+ * ever put a boolean in one — {@link GEOMETRY_FIELDS} defines no boolean
390
+ * field — so the width bought nothing and cost every consumer a narrowing
391
+ * before it could do arithmetic on `geometry.DC`.
392
+ *
393
+ * **Which keys are present is stated per kind, not left to the mapper**: see
394
+ * {@link RECORD_GEOMETRY}. An absent key is a claim, and it is the vendor's
395
+ * silence rather than a gap — the same distinction
396
+ * {@link ToolRecord.materialGroups} draws with {@link UNSPECIFIED}.
397
+ */
398
+ readonly geometry: Readonly<Partial<Record<GeometryName, number>>>;
211
399
  readonly coolantThrough: boolean;
212
- readonly materialGroups: readonly string[];
400
+ /** ISO 513 main groups in {@link ISO_MATERIAL_GROUPS} order — see above. */
401
+ readonly materialGroups: readonly string[] | null;
402
+ /** How the groups were arrived at. {@link UNSPECIFIED} exactly when they are null. */
403
+ readonly materialGroupsSource: MaterialGroupsSource;
213
404
  /**
214
405
  * Drills only, and deliberately `null` elsewhere rather than `false`: it
215
406
  * drops the two ferrous presets downstream, and a default would ship them on
@@ -218,20 +409,31 @@ export interface ToolRecord {
218
409
  readonly nonFerrous: boolean | null;
219
410
  }
220
411
  /**
221
- * Build a {@link ToolRecord}, defaulting the two fields that have a meaningful
222
- * absence.
412
+ * Build a {@link ToolRecord}: mint its guid, default the fields that have a
413
+ * meaningful absence, and refuse the one state that cannot be true.
223
414
  *
224
415
  * An interface cannot carry a default, and requiring every adapter to write
225
- * `materialGroups: []` would put the decision back in the three places least
416
+ * `materialGroups: null` would put the decision back in the three places least
226
417
  * able to notice it was wrong. They stay **required on the type** so a
227
418
  * consumer reading a record never handles `undefined` — only the construction
228
- * is optional.
419
+ * is optional. The default is `null`/{@link UNSPECIFIED} and not `[]`, because
420
+ * an adapter that says nothing about workpiece materials has produced no
421
+ * evidence, which is the one thing the three-state rule exists to distinguish.
422
+ *
423
+ * `guid` is not an input at all. Every adapter minting it would be three
424
+ * copies of `recordGuid(brand, materialNumber)` to drift, on the value that is
425
+ * the join key for every downstream consumer.
426
+ *
427
+ * The geometry is checked against {@link RECORD_GEOMETRY} before anything is
428
+ * built, so a kind's shape is one table rather than a convention three mappers
429
+ * each keep separately. That is the same move the material-groups invariant
430
+ * below makes: the factory is where a record's shape can be refused once.
229
431
  *
230
432
  * The result is frozen, geometry and material groups included: a record is an
231
433
  * interchange value, and a mapper that mutated one would be reaching back
232
434
  * across the seam this type exists to draw.
233
435
  */
234
- export declare function toolRecord(fields: Omit<ToolRecord, 'materialGroups' | 'nonFerrous'> & Partial<Pick<ToolRecord, 'materialGroups' | 'nonFerrous'>>): ToolRecord;
436
+ export declare function toolRecord(fields: Omit<ToolRecord, 'guid' | 'materialGroups' | 'materialGroupsSource' | 'nonFerrous' | 'productLine'> & Partial<Pick<ToolRecord, 'materialGroups' | 'materialGroupsSource' | 'nonFerrous' | 'productLine'>>): ToolRecord;
235
437
  /**
236
438
  * A family's canonical-field → CSV-column-label mapping, validated.
237
439
  *
package/dist/records.js CHANGED
@@ -44,6 +44,7 @@
44
44
  */
45
45
  import { dimensionalColumn } from './conventions.js';
46
46
  import { ScraperConfigError } from './errors.js';
47
+ import { recordGuid } from './identity.js';
47
48
  /**
48
49
  * ISO 513's main workpiece-material groups, in the order every vendor's
49
50
  * material groups must agree on — **core, not a Kennametal fact**, the same
@@ -55,6 +56,23 @@ import { ScraperConfigError } from './errors.js';
55
56
  * and a tool's own list from another has no way to notice the two disagree.
56
57
  */
57
58
  export const ISO_MATERIAL_GROUPS = ['P', 'M', 'K', 'N', 'S', 'H', 'C'];
59
+ /**
60
+ * The material-groups label for **we do not know what this tool is for**.
61
+ *
62
+ * Not an ISO 513 group and deliberately not one of {@link ISO_MATERIAL_GROUPS}:
63
+ * it is a statement about this package's evidence, not about the tool. It sits
64
+ * in {@link ToolRecord.materialGroupsSource} rather than in the group list,
65
+ * because a consumer filtering a catalog down to "cuts steel" must not have to
66
+ * know that one of the letters is not a letter.
67
+ *
68
+ * **Why a label and not a bare `null`.** The absence was expressible before —
69
+ * `materialGroups === null` — and an absence is the one thing a reader has to
70
+ * interpret. Every Harvey part is here, and so is every Kennametal family whose
71
+ * material sweep has not been run: two different reasons for the same honest
72
+ * answer, and neither is "rated for nothing". A named value says that out loud
73
+ * in a UI, a log line and a filter, which `null` never does.
74
+ */
75
+ export const UNSPECIFIED = 'unspecified';
58
76
  /**
59
77
  * Canonical geometry fields an adapter may supply, and what each means.
60
78
  *
@@ -158,25 +176,142 @@ export const DIMENSIONAL = new Set([
158
176
  */
159
177
  export const DIMENSIONAL_COLUMNS = new Set([...DIMENSIONAL].filter((name) => name !== 'TP'));
160
178
  /**
161
- * Build a {@link ToolRecord}, defaulting the two fields that have a meaningful
162
- * absence.
179
+ * What geometry a **record** of each kind carries, as against what a family
180
+ * must map.
181
+ *
182
+ * {@link REQUIRED_GEOMETRY} is about columns: it refuses a family whose config
183
+ * maps no `LCF`. This is about the record that comes out the other end, and the
184
+ * two genuinely differ — a Kennametal drill's `NOF` and `SIG` come from facts
185
+ * and not from any column, so they can never appear in a column map and are
186
+ * always on the record.
187
+ *
188
+ * **`sometimes` is the point of the table.** An absent key was the one thing in
189
+ * a record a reader had to interpret: `geometry.NOF === undefined` means "Harvey
190
+ * publishes no flute count for this family" on an end mill and "not part of the
191
+ * contract" on a drill, and nothing said which. That is exactly the ambiguity
192
+ * {@link UNSPECIFIED} exists to remove from {@link ToolRecord.materialGroups},
193
+ * and the record shipped both encodings at once. Now the absence is declared:
194
+ * a key in `sometimes` may be missing and its absence is the vendor's silence;
195
+ * a key in neither list is not part of that kind's record at all.
196
+ *
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.
222
+ */
223
+ export const RECORD_GEOMETRY = {
224
+ drill: {
225
+ always: ['DC', 'SFDM', 'OAL', 'LCF', 'NOF'],
226
+ sometimes: ['SIG'],
227
+ },
228
+ tap: {
229
+ always: ['DC', 'TP', 'SFDM', 'OAL', 'LCF'],
230
+ sometimes: ['NOF'],
231
+ },
232
+ endmill: {
233
+ always: ['DC', 'RE', 'SFDM', 'OAL', 'LCF', 'shoulder-length', 'shoulder-diameter'],
234
+ sometimes: ['NOF'],
235
+ },
236
+ };
237
+ /**
238
+ * Refuse a record whose geometry does not match its kind's declared shape.
239
+ *
240
+ * Two failures, and the second is the one worth having: a key the kind does not
241
+ * declare at all means a mapper is writing a measurement into a record nothing
242
+ * downstream expects to find there, which is invisible until a consumer does
243
+ * not read it.
244
+ */
245
+ function checkGeometry(kind, what, geometry) {
246
+ const { always, sometimes } = RECORD_GEOMETRY[kind];
247
+ const present = new Set(Object.keys(geometry));
248
+ const missing = always.filter((name) => !present.has(name));
249
+ if (missing.length > 0) {
250
+ throw new ScraperConfigError(what, `a ${kind} record carries ${missing.join(', ')} and this one does not — ` +
251
+ `a field a kind always has is not a field a mapper may skip`);
252
+ }
253
+ const declared = new Set([...always, ...sometimes]);
254
+ const extra = [...present].filter((name) => !declared.has(name)).sort();
255
+ if (extra.length > 0) {
256
+ throw new ScraperConfigError(what, `a ${kind} record does not carry ${extra.join(', ')} — ` +
257
+ `add it to RECORD_GEOMETRY before a mapper writes one`);
258
+ }
259
+ }
260
+ /**
261
+ * Build a {@link ToolRecord}: mint its guid, default the fields that have a
262
+ * meaningful absence, and refuse the one state that cannot be true.
163
263
  *
164
264
  * An interface cannot carry a default, and requiring every adapter to write
165
- * `materialGroups: []` would put the decision back in the three places least
265
+ * `materialGroups: null` would put the decision back in the three places least
166
266
  * able to notice it was wrong. They stay **required on the type** so a
167
267
  * consumer reading a record never handles `undefined` — only the construction
168
- * is optional.
268
+ * is optional. The default is `null`/{@link UNSPECIFIED} and not `[]`, because
269
+ * an adapter that says nothing about workpiece materials has produced no
270
+ * evidence, which is the one thing the three-state rule exists to distinguish.
271
+ *
272
+ * `guid` is not an input at all. Every adapter minting it would be three
273
+ * copies of `recordGuid(brand, materialNumber)` to drift, on the value that is
274
+ * the join key for every downstream consumer.
275
+ *
276
+ * The geometry is checked against {@link RECORD_GEOMETRY} before anything is
277
+ * built, so a kind's shape is one table rather than a convention three mappers
278
+ * each keep separately. That is the same move the material-groups invariant
279
+ * below makes: the factory is where a record's shape can be refused once.
169
280
  *
170
281
  * The result is frozen, geometry and material groups included: a record is an
171
282
  * interchange value, and a mapper that mutated one would be reaching back
172
283
  * across the seam this type exists to draw.
173
284
  */
174
285
  export function toolRecord(fields) {
286
+ const groups = fields.materialGroups ?? null;
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
+ }
297
+ // The invariant is what keeps the three states three: groups labelled
298
+ // `unspecified` are groups nobody stated, and an attributed source with no
299
+ // groups attributes nothing. Either would land as a record whose material
300
+ // index reads as the opposite of what the adapter meant.
301
+ if ((groups === null) !== (source === UNSPECIFIED)) {
302
+ throw new ScraperConfigError(fields.materialNumber, `materialGroups is ${groups === null ? 'null' : JSON.stringify([...groups])} ` +
303
+ `and materialGroupsSource is ${JSON.stringify(source)} — ` +
304
+ `groups are ${JSON.stringify(UNSPECIFIED)} exactly when there are none`);
305
+ }
306
+ checkGeometry(fields.kind, fields.materialNumber, fields.geometry);
175
307
  return Object.freeze({
176
308
  ...fields,
309
+ guid: recordGuid(fields.brand, fields.materialNumber),
177
310
  geometry: Object.freeze({ ...fields.geometry }),
178
- materialGroups: Object.freeze([...(fields.materialGroups ?? [])]),
311
+ materialGroups: groups === null ? null : Object.freeze([...groups]),
312
+ materialGroupsSource: source,
179
313
  nonFerrous: fields.nonFerrous ?? null,
314
+ productLine: line,
180
315
  });
181
316
  }
182
317
  /**
@@ -26,7 +26,9 @@
26
26
  * key. What changes is only *when* — first use rather than import — and every
27
27
  * entry point into this package goes through here.
28
28
  */
29
- import type { BoundFamily, BoundToolholding, RecordMappers } from './family.js';
29
+ import { type BoundFamily, type BoundToolholding, type RecordMappers } from './family.js';
30
+ import { type ToolRecord } from './records.js';
31
+ import { type MapperOptions, type ScrapeResult } from './scrape.js';
30
32
  /**
31
33
  * Brand -> its row-to-record mappers, by tool kind.
32
34
  *
@@ -53,6 +55,59 @@ export declare function boundFamilies(): Map<string, BoundFamily>;
53
55
  export declare function boundToolholding(): Map<string, BoundToolholding>;
54
56
  /** One bound cutting-tool family by CSV name. */
55
57
  export declare function boundFamily(name: string): BoundFamily;
58
+ /**
59
+ * One family's scrape, as {@link ToolRecord}s — the package's uniform output.
60
+ *
61
+ * **This is what a consumer wants and what nothing shipped until now.** Every
62
+ * CLI command ends at a vendor-labelled CSV, and a CSV is the receipt: four
63
+ * vendors, four column vocabularies, and a `D1_mm` that means one thing in
64
+ * Kennametal's table and another in ISO 13399's dictionary. The adapters that
65
+ * resolve that have been here the whole time and only the tests called them.
66
+ *
67
+ * It lives in the registry rather than beside `toolRecord` because it needs
68
+ * both halves — the config table and the vendor mappers — and the main entry
69
+ * point deliberately imports no vendor. Reach it through the `./registry`
70
+ * subpath.
71
+ *
72
+ * The two checks run **before the first row**, in the order a failure is
73
+ * cheapest to read:
74
+ *
75
+ * 1. {@link checkIdentityColumns} — a re-scrape whose part-number column was
76
+ * renamed still parses, still has the right row count, and mints every guid
77
+ * off an empty string.
78
+ * 2. {@link checkColumnsExist} — a mapped column the CSV does not carry names
79
+ * the family and the field here, instead of naming one row out of ninety-three
80
+ * from inside a mapper.
81
+ *
82
+ * `familyName` is the CSV filename the catalog is keyed by
83
+ * (`'harvey_endmill_025.csv'`), which is what `boundFamily` takes.
84
+ *
85
+ * ## One incomplete part does not end the family
86
+ *
87
+ * The rows are mapped together, so until 2026-09-01 every refusal was equally
88
+ * fatal — and the refusals are not equal. A part the vendor left a required
89
+ * cell blank on is one bad row among thousands of good ones; EMUGE-FRANKEN
90
+ * omits `overall length l₁` on roughly 175 of its 7,021 end mill variants, and
91
+ * both end mill families converted to nothing at all because of them.
92
+ *
93
+ * So an {@link IncompletePartError} is warned about and the row is dropped.
94
+ * **Nothing else is.** A cutting material with no mapping, a column a family
95
+ * stopped mapping, a response that changed shape — those say the vendor's
96
+ * vocabulary or this package's catalog has moved, and a scraper that skipped
97
+ * quietly past them would publish a catalog nobody checked. `columns.required`
98
+ * is the only place that raises the skippable one.
99
+ *
100
+ * A dropped row is **not** a relaxed contract. `records.RECORD_GEOMETRY` still
101
+ * says an end mill always has an `OAL`, and every record returned here still
102
+ * has one: the part without it becomes no record rather than a record with a
103
+ * hole. Where a vendor genuinely never publishes a field, `sometimes` is still
104
+ * the answer — a drill's `SIG` is that, and it stays that.
105
+ *
106
+ * The count of what was dropped is not returned. A caller that needs it has
107
+ * the row count it passed in and the length it got back, and the warnings name
108
+ * every part by number.
109
+ */
110
+ export declare function toRecords(familyName: string, scrape: ScrapeResult, options?: MapperOptions): ToolRecord[];
56
111
  /**
57
112
  * Forget what has been bound.
58
113
  *
package/dist/registry.js CHANGED
@@ -26,11 +26,16 @@
26
26
  * key. What changes is only *when* — first use rather than import — and every
27
27
  * entry point into this package goes through here.
28
28
  */
29
+ import { checkIdentityColumns } from './conventions.js';
30
+ import { familyBrand, } from './family.js';
29
31
  import { COLLET_FAMILIES, FAMILIES, HOLDER_FAMILIES } from './families/index.js';
30
- import { ScraperConfigError } from './errors.js';
32
+ import { IncompletePartError, ScraperConfigError } from './errors.js';
31
33
  import { checkFact } from './provenance.js';
32
- import { checkColumnMap } from './records.js';
34
+ import { checkColumnMap, checkColumnsExist } from './records.js';
35
+ import { consoleWarn } from './scrape.js';
33
36
  import { RECORD_MAPPERS as DESTINYTOOL } from './vendors/destinytool/records.js';
37
+ import { RECORD_MAPPERS as EMUGE } from './vendors/emuge/records.js';
38
+ import { RECORD_MAPPERS as HARVEY } from './vendors/harvey/records.js';
34
39
  import { RECORD_MAPPERS as KENNAMETAL } from './vendors/kennametal/records.js';
35
40
  /**
36
41
  * Brand -> its row-to-record mappers, by tool kind.
@@ -45,6 +50,8 @@ export const ADAPTERS = {
45
50
  kennametal: KENNAMETAL,
46
51
  widia: KENNAMETAL,
47
52
  destinytool: DESTINYTOOL,
53
+ harvey: HARVEY,
54
+ emuge: EMUGE,
48
55
  };
49
56
  /**
50
57
  * Check every fact of one family and project its value onto the config under
@@ -132,6 +139,76 @@ export function boundFamily(name) {
132
139
  }
133
140
  return cfg;
134
141
  }
142
+ /**
143
+ * One family's scrape, as {@link ToolRecord}s — the package's uniform output.
144
+ *
145
+ * **This is what a consumer wants and what nothing shipped until now.** Every
146
+ * CLI command ends at a vendor-labelled CSV, and a CSV is the receipt: four
147
+ * vendors, four column vocabularies, and a `D1_mm` that means one thing in
148
+ * Kennametal's table and another in ISO 13399's dictionary. The adapters that
149
+ * resolve that have been here the whole time and only the tests called them.
150
+ *
151
+ * It lives in the registry rather than beside `toolRecord` because it needs
152
+ * both halves — the config table and the vendor mappers — and the main entry
153
+ * point deliberately imports no vendor. Reach it through the `./registry`
154
+ * subpath.
155
+ *
156
+ * The two checks run **before the first row**, in the order a failure is
157
+ * cheapest to read:
158
+ *
159
+ * 1. {@link checkIdentityColumns} — a re-scrape whose part-number column was
160
+ * renamed still parses, still has the right row count, and mints every guid
161
+ * off an empty string.
162
+ * 2. {@link checkColumnsExist} — a mapped column the CSV does not carry names
163
+ * the family and the field here, instead of naming one row out of ninety-three
164
+ * from inside a mapper.
165
+ *
166
+ * `familyName` is the CSV filename the catalog is keyed by
167
+ * (`'harvey_endmill_025.csv'`), which is what `boundFamily` takes.
168
+ *
169
+ * ## One incomplete part does not end the family
170
+ *
171
+ * The rows are mapped together, so until 2026-09-01 every refusal was equally
172
+ * fatal — and the refusals are not equal. A part the vendor left a required
173
+ * cell blank on is one bad row among thousands of good ones; EMUGE-FRANKEN
174
+ * omits `overall length l₁` on roughly 175 of its 7,021 end mill variants, and
175
+ * both end mill families converted to nothing at all because of them.
176
+ *
177
+ * So an {@link IncompletePartError} is warned about and the row is dropped.
178
+ * **Nothing else is.** A cutting material with no mapping, a column a family
179
+ * stopped mapping, a response that changed shape — those say the vendor's
180
+ * vocabulary or this package's catalog has moved, and a scraper that skipped
181
+ * quietly past them would publish a catalog nobody checked. `columns.required`
182
+ * is the only place that raises the skippable one.
183
+ *
184
+ * A dropped row is **not** a relaxed contract. `records.RECORD_GEOMETRY` still
185
+ * says an end mill always has an `OAL`, and every record returned here still
186
+ * has one: the part without it becomes no record rather than a record with a
187
+ * hole. Where a vendor genuinely never publishes a field, `sometimes` is still
188
+ * the answer — a drill's `SIG` is that, and it stays that.
189
+ *
190
+ * The count of what was dropped is not returned. A caller that needs it has
191
+ * the row count it passed in and the length it got back, and the warnings name
192
+ * every part by number.
193
+ */
194
+ export function toRecords(familyName, scrape, options) {
195
+ const cfg = boundFamily(familyName);
196
+ const warn = options?.warn ?? consoleWarn;
197
+ checkIdentityColumns(familyBrand(cfg), scrape.header);
198
+ checkColumnsExist(familyName, cfg, scrape.header);
199
+ const records = [];
200
+ for (const row of scrape.rows) {
201
+ try {
202
+ records.push(cfg.records(row, cfg, cfg.columns, options));
203
+ }
204
+ catch (error) {
205
+ if (!(error instanceof IncompletePartError))
206
+ throw error;
207
+ warn(` WARNING: ${error.message} — no record written for it`);
208
+ }
209
+ }
210
+ return records;
211
+ }
135
212
  /**
136
213
  * Forget what has been bound.
137
214
  *
package/dist/scrape.d.ts CHANGED
@@ -49,6 +49,21 @@ export interface MapperOptions {
49
49
  }
50
50
  /** The default: stderr, where a message nobody injected a sink for belongs. */
51
51
  export declare const consoleWarn: Warn;
52
+ /**
53
+ * Rows to the header they imply: the union of their keys, in first-seen order.
54
+ *
55
+ * A union rather than the first row's keys, and the reason is the same for both
56
+ * vendors that build a `ScrapeResult` this way. A REGO-FIX collet family is
57
+ * mixed-unit — `D1_mm` on its metric rows and `D1_in` on its inch ones — and a
58
+ * MariTool CSV mixes three holder styles, so which spec keys a part publishes is
59
+ * a function of its style. Keying off row one drops whichever came second.
60
+ *
61
+ * It lived in both adapters, byte for byte, until 2026-08-29. Building the
62
+ * header of a {@link ScrapeResult} is this module's business rather than any
63
+ * manufacturer's — the same call `pause` and `conventions.CAD_COLUMN` already
64
+ * got, and the one `tests/vendor-boundary.test.ts` now makes automatically.
65
+ */
66
+ export declare function unionHeader(rows: readonly ScrapedRow[]): string[];
52
67
  /**
53
68
  * Milliseconds between requests, wherever a scrape or a mirror loops.
54
69
  *