@toolpath/tool-scraper 2.0.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.
package/dist/columns.d.ts CHANGED
@@ -20,9 +20,11 @@
20
20
  * - a column the family maps to nothing is `undefined`, not an error — a family
21
21
  * with no neck column is a plain tool, and that is the mapper's fallback to
22
22
  * make;
23
- * - a **required** field with no reading refuses the row, naming the canonical
24
- * field and quoting the cell, because a tool with no cutting diameter is not
25
- * a part;
23
+ * - a **required** field with no reading refuses the row with an
24
+ * `errors.IncompletePartError`, naming the canonical field and quoting the
25
+ * cell, because a tool with no cutting diameter is not a part — and that is
26
+ * the one refusal `registry.toRecords` skips past rather than failing the
27
+ * whole family on;
26
28
  * - an **optional** one answers null and lets the mapper decide.
27
29
  *
28
30
  * `vendors/destinytool/records.ts` keeps its own `required` and is not wired
package/dist/columns.js CHANGED
@@ -20,9 +20,11 @@
20
20
  * - a column the family maps to nothing is `undefined`, not an error — a family
21
21
  * with no neck column is a plain tool, and that is the mapper's fallback to
22
22
  * make;
23
- * - a **required** field with no reading refuses the row, naming the canonical
24
- * field and quoting the cell, because a tool with no cutting diameter is not
25
- * a part;
23
+ * - a **required** field with no reading refuses the row with an
24
+ * `errors.IncompletePartError`, naming the canonical field and quoting the
25
+ * cell, because a tool with no cutting diameter is not a part — and that is
26
+ * the one refusal `registry.toRecords` skips past rather than failing the
27
+ * whole family on;
26
28
  * - an **optional** one answers null and lets the mapper decide.
27
29
  *
28
30
  * `vendors/destinytool/records.ts` keeps its own `required` and is not wired
@@ -31,7 +33,7 @@
31
33
  * has no `optional` at all. Forcing it into this shape would change how it
32
34
  * refuses, which is the one thing `measure.ts` says is a vendor's own call.
33
35
  */
34
- import { VendorResponseError } from './errors.js';
36
+ import { IncompletePartError } from './errors.js';
35
37
  /**
36
38
  * The three readers, over one vendor's `read`.
37
39
  *
@@ -54,7 +56,11 @@ export function columnReaders(read) {
54
56
  const raw = cell(row, columns, canonical, unit);
55
57
  const value = raw === undefined ? null : read(raw, unit, what, options.warn);
56
58
  if (value === null) {
57
- throw new VendorResponseError(what, `publishes no ${canonical} its cell is ${JSON.stringify(raw ?? '')}`);
59
+ // `IncompletePartError` and not the general vendor fault: this is the
60
+ // one refusal `registry.toRecords` skips past, because a single part the
61
+ // vendor left a cell blank on must not end a family's conversion. See
62
+ // that type for why the others still must not be skipped.
63
+ throw new IncompletePartError(what, `publishes no ${canonical} — its cell is ${JSON.stringify(raw ?? '')}`);
58
64
  }
59
65
  return value;
60
66
  };
@@ -97,6 +97,24 @@ export declare const CAD_DXF_COLUMN = "CAD_DXF_URL";
97
97
  * `records.ToolRecord.description`, which states the same rule for the record.
98
98
  */
99
99
  export declare const DESCRIPTION_COLUMN = "Description";
100
+ /**
101
+ * The CSV column holding the vendor's own full name for the family a part is
102
+ * in — `KenCut™ FF • HPFT • Square End • 6 Flutes • Plain Shank • Inch`.
103
+ *
104
+ * Constant down a family's whole table, and that is what makes it a *family*
105
+ * title rather than a {@link DESCRIPTION_COLUMN}: it names the group, not the
106
+ * part. Kennametal and WIDIA publish one as the `h1` of a family page and it
107
+ * reaches the CSV whole, while `records.ToolRecord.productLine` keeps only its
108
+ * leading segment — the rest is the vendor's own wording for the shape, the
109
+ * flute count, the shank and the unit, and a receipt that dropped it would be
110
+ * throwing away published text to save a column.
111
+ *
112
+ * Vendor-neutral and here rather than in the Kennametal adapter for the reason
113
+ * {@link CAD_COLUMN} is: a second vendor that publishes a family title writes
114
+ * this column rather than inventing another. Nothing forces one to — a vendor
115
+ * whose parts carry no family title simply has no such column.
116
+ */
117
+ export declare const FAMILY_TITLE_COLUMN = "Family Title";
100
118
  /**
101
119
  * The CSV column saying how a holder seats in the spindle: `taper` or `face`.
102
120
  *
@@ -98,6 +98,24 @@ export const CAD_DXF_COLUMN = 'CAD_DXF_URL';
98
98
  * `records.ToolRecord.description`, which states the same rule for the record.
99
99
  */
100
100
  export const DESCRIPTION_COLUMN = 'Description';
101
+ /**
102
+ * The CSV column holding the vendor's own full name for the family a part is
103
+ * in — `KenCut™ FF • HPFT • Square End • 6 Flutes • Plain Shank • Inch`.
104
+ *
105
+ * Constant down a family's whole table, and that is what makes it a *family*
106
+ * title rather than a {@link DESCRIPTION_COLUMN}: it names the group, not the
107
+ * part. Kennametal and WIDIA publish one as the `h1` of a family page and it
108
+ * reaches the CSV whole, while `records.ToolRecord.productLine` keeps only its
109
+ * leading segment — the rest is the vendor's own wording for the shape, the
110
+ * flute count, the shank and the unit, and a receipt that dropped it would be
111
+ * throwing away published text to save a column.
112
+ *
113
+ * Vendor-neutral and here rather than in the Kennametal adapter for the reason
114
+ * {@link CAD_COLUMN} is: a second vendor that publishes a family title writes
115
+ * this column rather than inventing another. Nothing forces one to — a vendor
116
+ * whose parts carry no family title simply has no such column.
117
+ */
118
+ export const FAMILY_TITLE_COLUMN = 'Family Title';
101
119
  /**
102
120
  * The CSV column saying how a holder seats in the spindle: `taper` or `face`.
103
121
  *
package/dist/errors.d.ts CHANGED
@@ -10,6 +10,9 @@
10
10
  * cannot read.** The variants endpoint changed shape, a scrape's row count
11
11
  * disagrees with the declared one, a DIN 4000 code that should be pinned is
12
12
  * absent. The catalog is fine; the world moved.
13
+ * - {@link IncompletePartError} — **one part is missing a measurement it
14
+ * cannot be a part without.** A narrower case of the second, and the only
15
+ * one `registry.toRecords` may skip past: see below.
13
16
  *
14
17
  * They throw rather than exiting the process: a Node backend imports this, and
15
18
  * a mapped column that moved must not take down somebody's request handler.
@@ -43,4 +46,31 @@ export declare class ScraperConfigError extends ScraperError {
43
46
  */
44
47
  export declare class VendorResponseError extends ScraperError {
45
48
  }
49
+ /**
50
+ * One part does not publish a dimension its kind requires.
51
+ *
52
+ * **The only failure a whole family survives.** `registry.toRecords` maps a
53
+ * family's rows together, so before this type existed every refusal was
54
+ * equally fatal: one part with an unpublished cell ended the conversion and
55
+ * took every other row with it. EMUGE-FRANKEN omits `overall length l₁` on
56
+ * roughly 175 of its 7,021 end mill variants, and both end mill families
57
+ * produced nothing at all because of them.
58
+ *
59
+ * It is a distinct type rather than a flag on {@link VendorResponseError}
60
+ * because the two must not be skipped alike. A cutting material this package
61
+ * has no word for, a point-angle column a family stopped mapping, a variants
62
+ * table that changed shape — those are the vendor's vocabulary or this
63
+ * package's map having moved, and skipping past them quietly is how a scraper
64
+ * starts publishing a catalog nobody checked. Only `columns.required` raises
65
+ * this one, and only for a cell the vendor left unpublished.
66
+ *
67
+ * **It is not a licence to relax a kind's contract.** `records.RECORD_GEOMETRY`
68
+ * still says an end mill always has an `OAL`, and that stays true of every
69
+ * record this package emits: a part without one does not become a record with
70
+ * a hole in it, it becomes no record and a warning. Where a *vendor* genuinely
71
+ * never publishes a field, the answer is still `sometimes` — that is what a
72
+ * drill's `SIG` is, and why one is a contract decision and the other is not.
73
+ */
74
+ export declare class IncompletePartError extends VendorResponseError {
75
+ }
46
76
  export {};
package/dist/errors.js CHANGED
@@ -10,6 +10,9 @@
10
10
  * cannot read.** The variants endpoint changed shape, a scrape's row count
11
11
  * disagrees with the declared one, a DIN 4000 code that should be pinned is
12
12
  * absent. The catalog is fine; the world moved.
13
+ * - {@link IncompletePartError} — **one part is missing a measurement it
14
+ * cannot be a part without.** A narrower case of the second, and the only
15
+ * one `registry.toRecords` may skip past: see below.
13
16
  *
14
17
  * They throw rather than exiting the process: a Node backend imports this, and
15
18
  * a mapped column that moved must not take down somebody's request handler.
@@ -51,3 +54,30 @@ export class ScraperConfigError extends ScraperError {
51
54
  */
52
55
  export class VendorResponseError extends ScraperError {
53
56
  }
57
+ /**
58
+ * One part does not publish a dimension its kind requires.
59
+ *
60
+ * **The only failure a whole family survives.** `registry.toRecords` maps a
61
+ * family's rows together, so before this type existed every refusal was
62
+ * equally fatal: one part with an unpublished cell ended the conversion and
63
+ * took every other row with it. EMUGE-FRANKEN omits `overall length l₁` on
64
+ * roughly 175 of its 7,021 end mill variants, and both end mill families
65
+ * produced nothing at all because of them.
66
+ *
67
+ * It is a distinct type rather than a flag on {@link VendorResponseError}
68
+ * because the two must not be skipped alike. A cutting material this package
69
+ * has no word for, a point-angle column a family stopped mapping, a variants
70
+ * table that changed shape — those are the vendor's vocabulary or this
71
+ * package's map having moved, and skipping past them quietly is how a scraper
72
+ * starts publishing a catalog nobody checked. Only `columns.required` raises
73
+ * this one, and only for a cell the vendor left unpublished.
74
+ *
75
+ * **It is not a licence to relax a kind's contract.** `records.RECORD_GEOMETRY`
76
+ * still says an end mill always has an `OAL`, and that stays true of every
77
+ * record this package emits: a part without one does not become a record with
78
+ * a hole in it, it becomes no record and a warning. Where a *vendor* genuinely
79
+ * never publishes a field, the answer is still `sometimes` — that is what a
80
+ * drill's `SIG` is, and why one is a contract decision and the other is not.
81
+ */
82
+ export class IncompletePartError extends VendorResponseError {
83
+ }
@@ -8,7 +8,9 @@
8
8
  * coating and the coolant supply **per part**, in columns, and a scraped column
9
9
  * beats a family constant. Splitting by product line would turn four counted
10
10
  * row totals into thirty and buy nothing a `product line` column does not
11
- * already carry.
11
+ * already carry — and it is carried: `vendors/emuge/records.ts`'s
12
+ * `PRODUCT_LINE_COLUMNS` reads one per part onto `ToolRecord.productLine`, from
13
+ * a column every scrape already writes.
12
14
  *
13
15
  * So the only fact three of these four state is `unit`, and the fourth adds the
14
16
  * two a drill record cannot be built without.
@@ -8,7 +8,9 @@
8
8
  * coating and the coolant supply **per part**, in columns, and a scraped column
9
9
  * beats a family constant. Splitting by product line would turn four counted
10
10
  * row totals into thirty and buy nothing a `product line` column does not
11
- * already carry.
11
+ * already carry — and it is carried: `vendors/emuge/records.ts`'s
12
+ * `PRODUCT_LINE_COLUMNS` reads one per part onto `ToolRecord.productLine`, from
13
+ * a column every scrape already writes.
12
14
  *
13
15
  * So the only fact three of these four state is `unit`, and the fourth adds the
14
16
  * two a drill record cannot be built without.
@@ -100,9 +102,11 @@ export const FAMILIES = {
100
102
  kind: 'drill',
101
103
  familyCode: 'FB01',
102
104
  // `SIG` is a mapped column and not a fact, which no other drill family in
103
- // this package manages: EMUGE states a point angle on every part's detail
104
- // record. Kennametal's two drill lines assume theirs or derive them from a
105
- // point length, and both say so at length in `families/kennametal.ts`.
105
+ // this package manages: EMUGE states a point angle on the detail record of
106
+ // 2,669 of these 2,670 parts, and leaves the cell empty on the last, so the
107
+ // record may carry no `SIG` see `vendors/emuge/records.ts`'s `angle`.
108
+ // Kennametal's two drill lines assume theirs or derive them from a point
109
+ // length, and both say so at length in `families/kennametal.ts`.
106
110
  columns: {
107
111
  DC: 'nominal diameter d₁',
108
112
  SFDM: 'Shank diameter d₂',
package/dist/node/cli.js CHANGED
@@ -257,7 +257,12 @@ async function kennametal(argv, io, fetcher) {
257
257
  const at = a.indexOf('=');
258
258
  return [a.slice(0, at), a.slice(at + 1)];
259
259
  });
260
- const scrape = await scrapeFamily(fetcher, code, brand, tags);
260
+ // The family page carries the product line, which the variants table does
261
+ // not state anywhere. One extra request per family — see
262
+ // `vendors/kennametal/family.ts`.
263
+ const scrape = await scrapeFamily(fetcher, code, brand, tags, {
264
+ familyTitle: true,
265
+ });
261
266
  wrote(out, brand, scrape, io);
262
267
  return 0;
263
268
  }
package/dist/records.d.ts CHANGED
@@ -236,17 +236,31 @@ export declare const DIMENSIONAL_COLUMNS: ReadonlySet<GeometryName>;
236
236
  * a key in `sometimes` may be missing and its absence is the vendor's silence;
237
237
  * a key in neither list is not part of that kind's record at all.
238
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.
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.
250
264
  */
251
265
  export declare const RECORD_GEOMETRY: Record<ToolKind, {
252
266
  readonly always: readonly GeometryName[];
@@ -325,6 +339,37 @@ export interface ToolRecord {
325
339
  * consumer that needs a per-part string has `catalogNumber`.
326
340
  */
327
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;
328
373
  readonly kind: ToolKind;
329
374
  readonly unit: UnitSystem;
330
375
  readonly substrate: string;
@@ -388,7 +433,7 @@ export interface ToolRecord {
388
433
  * interchange value, and a mapper that mutated one would be reaching back
389
434
  * across the seam this type exists to draw.
390
435
  */
391
- export declare function toolRecord(fields: Omit<ToolRecord, 'guid' | 'materialGroups' | 'materialGroupsSource' | 'nonFerrous'> & Partial<Pick<ToolRecord, 'materialGroups' | 'materialGroupsSource' | 'nonFerrous'>>): ToolRecord;
436
+ export declare function toolRecord(fields: Omit<ToolRecord, 'guid' | 'materialGroups' | 'materialGroupsSource' | 'nonFerrous' | 'productLine'> & Partial<Pick<ToolRecord, 'materialGroups' | 'materialGroupsSource' | 'nonFerrous' | 'productLine'>>): ToolRecord;
392
437
  /**
393
438
  * A family's canonical-field → CSV-column-label mapping, validated.
394
439
  *
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
  /**
@@ -28,7 +28,7 @@
28
28
  */
29
29
  import { type BoundFamily, type BoundToolholding, type RecordMappers } from './family.js';
30
30
  import { type ToolRecord } from './records.js';
31
- import type { MapperOptions, ScrapeResult } from './scrape.js';
31
+ import { type MapperOptions, type ScrapeResult } from './scrape.js';
32
32
  /**
33
33
  * Brand -> its row-to-record mappers, by tool kind.
34
34
  *
@@ -81,6 +81,31 @@ export declare function boundFamily(name: string): BoundFamily;
81
81
  *
82
82
  * `familyName` is the CSV filename the catalog is keyed by
83
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.
84
109
  */
85
110
  export declare function toRecords(familyName: string, scrape: ScrapeResult, options?: MapperOptions): ToolRecord[];
86
111
  /**
package/dist/registry.js CHANGED
@@ -29,9 +29,10 @@
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';
@@ -164,12 +165,49 @@ export function boundFamily(name) {
164
165
  *
165
166
  * `familyName` is the CSV filename the catalog is keyed by
166
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.
167
193
  */
168
194
  export function toRecords(familyName, scrape, options) {
169
195
  const cfg = boundFamily(familyName);
196
+ const warn = options?.warn ?? consoleWarn;
170
197
  checkIdentityColumns(familyBrand(cfg), scrape.header);
171
198
  checkColumnsExist(familyName, cfg, scrape.header);
172
- return scrape.rows.map((row) => cfg.records(row, cfg, cfg.columns, options));
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;
173
211
  }
174
212
  /**
175
213
  * 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
  /**