@toolpath/tool-scraper 0.1.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/README.md +41 -11
  2. package/dist/columns.d.ts +62 -0
  3. package/dist/columns.js +62 -0
  4. package/dist/conventions.d.ts +89 -15
  5. package/dist/conventions.js +108 -15
  6. package/dist/families/emuge.d.ts +183 -0
  7. package/dist/families/emuge.js +159 -0
  8. package/dist/families/harvey.d.ts +1782 -0
  9. package/dist/families/harvey.js +1328 -0
  10. package/dist/families/index.js +5 -2
  11. package/dist/families/kennametal.d.ts +21 -0
  12. package/dist/families/kennametal.js +10 -0
  13. package/dist/families/maritool.d.ts +120 -0
  14. package/dist/families/maritool.js +175 -0
  15. package/dist/family.d.ts +35 -1
  16. package/dist/family.js +28 -0
  17. package/dist/identity.d.ts +18 -0
  18. package/dist/identity.js +46 -0
  19. package/dist/index.d.ts +16 -0
  20. package/dist/index.js +16 -0
  21. package/dist/measure.d.ts +112 -0
  22. package/dist/measure.js +130 -0
  23. package/dist/node/cli.d.ts +3 -0
  24. package/dist/node/cli.js +152 -2
  25. package/dist/node/main.js +0 -0
  26. package/dist/records.d.ts +169 -12
  27. package/dist/records.js +116 -5
  28. package/dist/registry.d.ts +31 -1
  29. package/dist/registry.js +40 -1
  30. package/dist/scrape.d.ts +15 -0
  31. package/dist/scrape.js +24 -0
  32. package/dist/vendors/destinytool/records.d.ts +13 -3
  33. package/dist/vendors/destinytool/records.js +41 -37
  34. package/dist/vendors/emuge/index.d.ts +17 -0
  35. package/dist/vendors/emuge/index.js +17 -0
  36. package/dist/vendors/emuge/records.d.ts +150 -0
  37. package/dist/vendors/emuge/records.js +375 -0
  38. package/dist/vendors/emuge/scrape.d.ts +227 -0
  39. package/dist/vendors/emuge/scrape.js +358 -0
  40. package/dist/vendors/emuge/value.d.ts +75 -0
  41. package/dist/vendors/emuge/value.js +116 -0
  42. package/dist/vendors/harvey/catalog.d.ts +53 -0
  43. package/dist/vendors/harvey/catalog.js +120 -0
  44. package/dist/vendors/harvey/header.d.ts +89 -0
  45. package/dist/vendors/harvey/header.js +185 -0
  46. package/dist/vendors/harvey/index.d.ts +21 -0
  47. package/dist/vendors/harvey/index.js +21 -0
  48. package/dist/vendors/harvey/lexicon.d.ts +73 -0
  49. package/dist/vendors/harvey/lexicon.js +126 -0
  50. package/dist/vendors/harvey/literal.d.ts +68 -0
  51. package/dist/vendors/harvey/literal.js +214 -0
  52. package/dist/vendors/harvey/records.d.ts +79 -0
  53. package/dist/vendors/harvey/records.js +163 -0
  54. package/dist/vendors/harvey/scrape.d.ts +187 -0
  55. package/dist/vendors/harvey/scrape.js +483 -0
  56. package/dist/vendors/harvey/value.d.ts +74 -0
  57. package/dist/vendors/harvey/value.js +119 -0
  58. package/dist/vendors/kennametal/records.d.ts +13 -4
  59. package/dist/vendors/kennametal/records.js +61 -26
  60. package/dist/vendors/maritool/catalog.d.ts +81 -0
  61. package/dist/vendors/maritool/catalog.js +132 -0
  62. package/dist/vendors/maritool/index.d.ts +10 -0
  63. package/dist/vendors/maritool/index.js +10 -0
  64. package/dist/vendors/maritool/scrape.d.ts +297 -0
  65. package/dist/vendors/maritool/scrape.js +593 -0
  66. package/dist/vendors/regofix/scrape.d.ts +8 -11
  67. package/dist/vendors/regofix/scrape.js +21 -36
  68. 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,41 @@ 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
+ * Both `sometimes` entries today are a flute count nobody publishes:
240
+ *
241
+ * - the **end mill's**, for Harvey's two deburring families — they publish
242
+ * right- and left-hand tooth counts and no flute count, so there is nothing
243
+ * to read and 0 is not a substitute;
244
+ * - the **tap's**, for EMUGE-FRANKEN, which states no flute count anywhere a
245
+ * scrape can reach — not on the grouped product, the variant listing, the
246
+ * per-part detail record or any facet — while its own tap families run 2, 3
247
+ * and 4 flutes across their size range, so no per-family constant could be
248
+ * true of every row. Kennametal's taps publish a `Z` column and keep filling
249
+ * it: `sometimes` permits the key, it does not forbid it.
250
+ */
251
+ export declare const RECORD_GEOMETRY: Record<ToolKind, {
252
+ readonly always: readonly GeometryName[];
253
+ readonly sometimes: readonly GeometryName[];
254
+ }>;
189
255
  /**
190
256
  * One orderable cutting tool, in canonical fields, ready for the core.
191
257
  *
@@ -193,23 +259,103 @@ export declare const DIMENSIONAL_COLUMNS: ReadonlySet<GeometryName>;
193
259
  * it and hands it over, and a mapper that mutated one would be reaching back
194
260
  * across the seam this type exists to draw.
195
261
  *
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.
262
+ * `geometry` holds {@link GEOMETRY_FIELDS} names in `unit`.
263
+ *
264
+ * ## The workpiece-material groups have three states, not two
265
+ *
266
+ * They were `string[]` until this contract, and an empty array carried two
267
+ * incompatible claims at once. Kennametal's 129 taps are empty because the
268
+ * vendor's own index rates them for nothing; every Harvey part was empty
269
+ * because Harvey publishes no index a scrape can reach, and a Kennametal family
270
+ * whose material sweep was never run was empty for a third reason. A consumer
271
+ * reading the first as "not rated for steel" is right and reading the second
272
+ * the same way is wrong, and nothing in the record told the two apart.
273
+ *
274
+ * So:
275
+ *
276
+ * - **`null`, labelled {@link UNSPECIFIED}** — we do not know what this tool is
277
+ * for. Not indexed, not published, or not swept. Says nothing about what it
278
+ * cuts, and is not a claim that it cuts nothing.
279
+ * - **`[]`** — the vendor's index exists and rates this part for nothing.
280
+ * - **non-empty** — rated, in {@link ISO_MATERIAL_GROUPS} order.
281
+ *
282
+ * {@link ToolRecord.materialGroupsSource} is never absent: it is `unspecified`
283
+ * in the first case and, in the other two, how the answer was arrived at in the
284
+ * same vocabulary a family fact uses — `vendor-stated` where the vendor's own
285
+ * index said so, `derived` where this package computed it. A consumer that will
286
+ * not act on a guess filters on it, and one that will not show an unknown as an
287
+ * empty facet filters on it too.
200
288
  */
201
289
  export interface ToolRecord {
290
+ /**
291
+ * The brand key the record was minted under — `identity.BRANDS`'s own key,
292
+ * not the display name.
293
+ *
294
+ * Here because {@link ToolRecord.guid} is the join key for every downstream
295
+ * consumer and it is minted in this brand's namespace: without the key on
296
+ * the record, the guid is underivable from the record and `vendor` is a
297
+ * display string nothing can look a namespace up by.
298
+ */
299
+ readonly brand: BrandName;
300
+ /** What this brand's records call the vendor — `identity.BRANDS[brand].vendor`. */
202
301
  readonly vendor: string;
302
+ /**
303
+ * `identity.recordGuid(brand, materialNumber)`, minted by {@link toolRecord}
304
+ * rather than by an adapter: one mint path is what makes a guid collision
305
+ * across brands structurally impossible instead of merely unlikely.
306
+ */
307
+ readonly guid: string;
203
308
  readonly materialNumber: string;
204
309
  readonly catalogNumber: string;
310
+ /**
311
+ * The vendor's own free text about this part, verbatim — `''` where the
312
+ * vendor publishes none.
313
+ *
314
+ * **Never a copy of another field on this record.** It was
315
+ * `row['ISO Catalog Number']` on every Kennametal drill and end mill until
316
+ * 2026-08-29, which put the catalog number in two fields and told a consumer
317
+ * nothing it did not already have: a search index built on it matched a part
318
+ * number and no words. Kennametal publishes no description column, so the
319
+ * honest answer is the empty string — the same rule {@link ToolRecord.coating}
320
+ * already states for a table that publishes no coating.
321
+ *
322
+ * **It may be per product line rather than per part.** Harvey states one
323
+ * title for a whole page and no per-part text, so every record of a Harvey
324
+ * family carries that family's title. That is what the vendor published; a
325
+ * consumer that needs a per-part string has `catalogNumber`.
326
+ */
205
327
  readonly description: string;
206
328
  readonly kind: ToolKind;
207
329
  readonly unit: UnitSystem;
208
330
  readonly substrate: string;
209
- readonly grade: string;
210
- readonly geometry: Readonly<Partial<Record<GeometryName, number | boolean>>>;
331
+ /**
332
+ * The vendor's own coating string, `''` where the table publishes none.
333
+ *
334
+ * Raw, and never inferred from: `AlTiN COATED`, `TiN` and a Destiny Tool
335
+ * coating id are three vendors' vocabularies, and mapping them onto a shared
336
+ * one here would be this package inventing a standard rather than recording
337
+ * what was published.
338
+ */
339
+ readonly coating: string;
340
+ /**
341
+ * The canonical geometry, in {@link ToolRecord.unit}.
342
+ *
343
+ * Numbers only. It was `number | boolean` until 2026-08-29 and no adapter has
344
+ * ever put a boolean in one — {@link GEOMETRY_FIELDS} defines no boolean
345
+ * field — so the width bought nothing and cost every consumer a narrowing
346
+ * before it could do arithmetic on `geometry.DC`.
347
+ *
348
+ * **Which keys are present is stated per kind, not left to the mapper**: see
349
+ * {@link RECORD_GEOMETRY}. An absent key is a claim, and it is the vendor's
350
+ * silence rather than a gap — the same distinction
351
+ * {@link ToolRecord.materialGroups} draws with {@link UNSPECIFIED}.
352
+ */
353
+ readonly geometry: Readonly<Partial<Record<GeometryName, number>>>;
211
354
  readonly coolantThrough: boolean;
212
- readonly materialGroups: readonly string[];
355
+ /** ISO 513 main groups in {@link ISO_MATERIAL_GROUPS} order — see above. */
356
+ readonly materialGroups: readonly string[] | null;
357
+ /** How the groups were arrived at. {@link UNSPECIFIED} exactly when they are null. */
358
+ readonly materialGroupsSource: MaterialGroupsSource;
213
359
  /**
214
360
  * Drills only, and deliberately `null` elsewhere rather than `false`: it
215
361
  * drops the two ferrous presets downstream, and a default would ship them on
@@ -218,20 +364,31 @@ export interface ToolRecord {
218
364
  readonly nonFerrous: boolean | null;
219
365
  }
220
366
  /**
221
- * Build a {@link ToolRecord}, defaulting the two fields that have a meaningful
222
- * absence.
367
+ * Build a {@link ToolRecord}: mint its guid, default the fields that have a
368
+ * meaningful absence, and refuse the one state that cannot be true.
223
369
  *
224
370
  * An interface cannot carry a default, and requiring every adapter to write
225
- * `materialGroups: []` would put the decision back in the three places least
371
+ * `materialGroups: null` would put the decision back in the three places least
226
372
  * able to notice it was wrong. They stay **required on the type** so a
227
373
  * consumer reading a record never handles `undefined` — only the construction
228
- * is optional.
374
+ * is optional. The default is `null`/{@link UNSPECIFIED} and not `[]`, because
375
+ * an adapter that says nothing about workpiece materials has produced no
376
+ * evidence, which is the one thing the three-state rule exists to distinguish.
377
+ *
378
+ * `guid` is not an input at all. Every adapter minting it would be three
379
+ * copies of `recordGuid(brand, materialNumber)` to drift, on the value that is
380
+ * the join key for every downstream consumer.
381
+ *
382
+ * The geometry is checked against {@link RECORD_GEOMETRY} before anything is
383
+ * built, so a kind's shape is one table rather than a convention three mappers
384
+ * each keep separately. That is the same move the material-groups invariant
385
+ * below makes: the factory is where a record's shape can be refused once.
229
386
  *
230
387
  * The result is frozen, geometry and material groups included: a record is an
231
388
  * interchange value, and a mapper that mutated one would be reaching back
232
389
  * across the seam this type exists to draw.
233
390
  */
234
- export declare function toolRecord(fields: Omit<ToolRecord, 'materialGroups' | 'nonFerrous'> & Partial<Pick<ToolRecord, 'materialGroups' | 'nonFerrous'>>): ToolRecord;
391
+ export declare function toolRecord(fields: Omit<ToolRecord, 'guid' | 'materialGroups' | 'materialGroupsSource' | 'nonFerrous'> & Partial<Pick<ToolRecord, 'materialGroups' | 'materialGroupsSource' | 'nonFerrous'>>): ToolRecord;
235
392
  /**
236
393
  * A family's canonical-field → CSV-column-label mapping, validated.
237
394
  *
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,24 +176,117 @@ 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
+ * 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.
208
+ */
209
+ export const RECORD_GEOMETRY = {
210
+ drill: {
211
+ always: ['DC', 'SFDM', 'OAL', 'LCF', 'NOF', 'SIG'],
212
+ sometimes: [],
213
+ },
214
+ tap: {
215
+ always: ['DC', 'TP', 'SFDM', 'OAL', 'LCF'],
216
+ sometimes: ['NOF'],
217
+ },
218
+ endmill: {
219
+ always: ['DC', 'RE', 'SFDM', 'OAL', 'LCF', 'shoulder-length', 'shoulder-diameter'],
220
+ sometimes: ['NOF'],
221
+ },
222
+ };
223
+ /**
224
+ * Refuse a record whose geometry does not match its kind's declared shape.
225
+ *
226
+ * Two failures, and the second is the one worth having: a key the kind does not
227
+ * declare at all means a mapper is writing a measurement into a record nothing
228
+ * downstream expects to find there, which is invisible until a consumer does
229
+ * not read it.
230
+ */
231
+ function checkGeometry(kind, what, geometry) {
232
+ const { always, sometimes } = RECORD_GEOMETRY[kind];
233
+ const present = new Set(Object.keys(geometry));
234
+ const missing = always.filter((name) => !present.has(name));
235
+ if (missing.length > 0) {
236
+ throw new ScraperConfigError(what, `a ${kind} record carries ${missing.join(', ')} and this one does not — ` +
237
+ `a field a kind always has is not a field a mapper may skip`);
238
+ }
239
+ const declared = new Set([...always, ...sometimes]);
240
+ const extra = [...present].filter((name) => !declared.has(name)).sort();
241
+ if (extra.length > 0) {
242
+ throw new ScraperConfigError(what, `a ${kind} record does not carry ${extra.join(', ')} — ` +
243
+ `add it to RECORD_GEOMETRY before a mapper writes one`);
244
+ }
245
+ }
246
+ /**
247
+ * Build a {@link ToolRecord}: mint its guid, default the fields that have a
248
+ * meaningful absence, and refuse the one state that cannot be true.
163
249
  *
164
250
  * An interface cannot carry a default, and requiring every adapter to write
165
- * `materialGroups: []` would put the decision back in the three places least
251
+ * `materialGroups: null` would put the decision back in the three places least
166
252
  * able to notice it was wrong. They stay **required on the type** so a
167
253
  * consumer reading a record never handles `undefined` — only the construction
168
- * is optional.
254
+ * is optional. The default is `null`/{@link UNSPECIFIED} and not `[]`, because
255
+ * an adapter that says nothing about workpiece materials has produced no
256
+ * evidence, which is the one thing the three-state rule exists to distinguish.
257
+ *
258
+ * `guid` is not an input at all. Every adapter minting it would be three
259
+ * copies of `recordGuid(brand, materialNumber)` to drift, on the value that is
260
+ * the join key for every downstream consumer.
261
+ *
262
+ * The geometry is checked against {@link RECORD_GEOMETRY} before anything is
263
+ * built, so a kind's shape is one table rather than a convention three mappers
264
+ * each keep separately. That is the same move the material-groups invariant
265
+ * below makes: the factory is where a record's shape can be refused once.
169
266
  *
170
267
  * The result is frozen, geometry and material groups included: a record is an
171
268
  * interchange value, and a mapper that mutated one would be reaching back
172
269
  * across the seam this type exists to draw.
173
270
  */
174
271
  export function toolRecord(fields) {
272
+ const groups = fields.materialGroups ?? null;
273
+ const source = fields.materialGroupsSource ?? UNSPECIFIED;
274
+ // The invariant is what keeps the three states three: groups labelled
275
+ // `unspecified` are groups nobody stated, and an attributed source with no
276
+ // groups attributes nothing. Either would land as a record whose material
277
+ // index reads as the opposite of what the adapter meant.
278
+ if ((groups === null) !== (source === UNSPECIFIED)) {
279
+ throw new ScraperConfigError(fields.materialNumber, `materialGroups is ${groups === null ? 'null' : JSON.stringify([...groups])} ` +
280
+ `and materialGroupsSource is ${JSON.stringify(source)} — ` +
281
+ `groups are ${JSON.stringify(UNSPECIFIED)} exactly when there are none`);
282
+ }
283
+ checkGeometry(fields.kind, fields.materialNumber, fields.geometry);
175
284
  return Object.freeze({
176
285
  ...fields,
286
+ guid: recordGuid(fields.brand, fields.materialNumber),
177
287
  geometry: Object.freeze({ ...fields.geometry }),
178
- materialGroups: Object.freeze([...(fields.materialGroups ?? [])]),
288
+ materialGroups: groups === null ? null : Object.freeze([...groups]),
289
+ materialGroupsSource: source,
179
290
  nonFerrous: fields.nonFerrous ?? null,
180
291
  });
181
292
  }
@@ -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, ScrapeResult } from './scrape.js';
30
32
  /**
31
33
  * Brand -> its row-to-record mappers, by tool kind.
32
34
  *
@@ -53,6 +55,34 @@ 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
+ export declare function toRecords(familyName: string, scrape: ScrapeResult, options?: MapperOptions): ToolRecord[];
56
86
  /**
57
87
  * Forget what has been bound.
58
88
  *
package/dist/registry.js CHANGED
@@ -26,11 +26,15 @@
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
32
  import { ScraperConfigError } from './errors.js';
31
33
  import { checkFact } from './provenance.js';
32
- import { checkColumnMap } from './records.js';
34
+ import { checkColumnMap, checkColumnsExist } from './records.js';
33
35
  import { RECORD_MAPPERS as DESTINYTOOL } from './vendors/destinytool/records.js';
36
+ import { RECORD_MAPPERS as EMUGE } from './vendors/emuge/records.js';
37
+ import { RECORD_MAPPERS as HARVEY } from './vendors/harvey/records.js';
34
38
  import { RECORD_MAPPERS as KENNAMETAL } from './vendors/kennametal/records.js';
35
39
  /**
36
40
  * Brand -> its row-to-record mappers, by tool kind.
@@ -45,6 +49,8 @@ export const ADAPTERS = {
45
49
  kennametal: KENNAMETAL,
46
50
  widia: KENNAMETAL,
47
51
  destinytool: DESTINYTOOL,
52
+ harvey: HARVEY,
53
+ emuge: EMUGE,
48
54
  };
49
55
  /**
50
56
  * Check every fact of one family and project its value onto the config under
@@ -132,6 +138,39 @@ export function boundFamily(name) {
132
138
  }
133
139
  return cfg;
134
140
  }
141
+ /**
142
+ * One family's scrape, as {@link ToolRecord}s — the package's uniform output.
143
+ *
144
+ * **This is what a consumer wants and what nothing shipped until now.** Every
145
+ * CLI command ends at a vendor-labelled CSV, and a CSV is the receipt: four
146
+ * vendors, four column vocabularies, and a `D1_mm` that means one thing in
147
+ * Kennametal's table and another in ISO 13399's dictionary. The adapters that
148
+ * resolve that have been here the whole time and only the tests called them.
149
+ *
150
+ * It lives in the registry rather than beside `toolRecord` because it needs
151
+ * both halves — the config table and the vendor mappers — and the main entry
152
+ * point deliberately imports no vendor. Reach it through the `./registry`
153
+ * subpath.
154
+ *
155
+ * The two checks run **before the first row**, in the order a failure is
156
+ * cheapest to read:
157
+ *
158
+ * 1. {@link checkIdentityColumns} — a re-scrape whose part-number column was
159
+ * renamed still parses, still has the right row count, and mints every guid
160
+ * off an empty string.
161
+ * 2. {@link checkColumnsExist} — a mapped column the CSV does not carry names
162
+ * the family and the field here, instead of naming one row out of ninety-three
163
+ * from inside a mapper.
164
+ *
165
+ * `familyName` is the CSV filename the catalog is keyed by
166
+ * (`'harvey_endmill_025.csv'`), which is what `boundFamily` takes.
167
+ */
168
+ export function toRecords(familyName, scrape, options) {
169
+ const cfg = boundFamily(familyName);
170
+ checkIdentityColumns(familyBrand(cfg), scrape.header);
171
+ checkColumnsExist(familyName, cfg, scrape.header);
172
+ return scrape.rows.map((row) => cfg.records(row, cfg, cfg.columns, options));
173
+ }
135
174
  /**
136
175
  * Forget what has been bound.
137
176
  *
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
  *
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;