@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/README.md CHANGED
@@ -17,26 +17,41 @@ pnpm add @toolpath/tool-scraper
17
17
 
18
18
  ## Vendors
19
19
 
20
- | Vendor | Transport | What it publishes |
21
- | ------------------ | ------------------------------------------------ | --------------------- |
22
- | Kennametal / WIDIA | AEM variant-table GET, parsed with `htmlparser2` | tools and toolholding |
23
- | REGO-FIX | Elasticsearch proxy POST + per-part DIN 4000 XML | toolholding |
24
- | Destiny Tool | Firestore REST, paginated | solid end mills |
20
+ | Vendor | Transport | What it publishes |
21
+ | ------------------ | ------------------------------------------------------- | ------------------------------------ |
22
+ | Kennametal / WIDIA | AEM variant-table GET, parsed with `htmlparser2` | tools and toolholding |
23
+ | REGO-FIX | Elasticsearch proxy POST + per-part DIN 4000 XML | toolholding |
24
+ | Destiny Tool | Firestore REST, paginated | solid end mills |
25
+ | Harvey Tool | inline JS literal on a product page, plus its `<thead>` | miniature end mills, keyseat cutters |
26
+ | MariTool | osCommerce category listings, then one page per part | toolholding |
27
+ | EMUGE-FRANKEN | SAP Commerce JSON API: grouped, variant, batched detail | end mills, twist drills, taps |
25
28
 
26
29
  ## Two entry points
27
30
 
28
- **`@toolpath/tool-scraper` returns records.** Every scrape hands back rows and enough provenance to
29
- say where they came from; nothing in it touches the filesystem, so a backend can embed it and do
30
- what it likes with the result.
31
+ **`@toolpath/tool-scraper` returns records.** Every scrape hands back the vendor's own rows and
32
+ enough provenance to say where they came from; `toRecords` turns one family's scrape into
33
+ `ToolRecord[]` canonical ISO 13399 geometry, one shape whatever the vendor. Nothing in either
34
+ touches the filesystem, so a backend can embed it and do what it likes with the result.
31
35
 
32
36
  ```ts
33
- import { createFetcher } from '@toolpath/tool-scraper'
37
+ import { createFetcher, type ToolRecord } from '@toolpath/tool-scraper'
38
+ import { toRecords } from '@toolpath/tool-scraper/registry'
34
39
  import { scrapeFamily } from '@toolpath/tool-scraper/vendors/kennametal'
35
40
 
36
41
  const fetcher = createFetcher() // or your own: retries, proxy, rate limits
37
- const { header, rows, source } = await scrapeFamily(fetcher, '100003658')
42
+ const scrape = await scrapeFamily(fetcher, '100003658')
43
+
44
+ const records: ToolRecord[] = toRecords('godrill_3xd_metric.csv', scrape)
45
+ // { brand: 'kennametal', guid: '…', geometry: { DC: 10, OAL: 89, … },
46
+ // materialGroups: ['P', 'N'], materialGroupsSource: 'vendor-stated', … }
38
47
  ```
39
48
 
49
+ `toRecords` is on the `./registry` subpath because it is the one place that knows both the family
50
+ table and the vendor adapters; the main entry point deliberately imports no vendor. It checks the
51
+ scrape's header for the identity and mapped columns before it maps a single row, so a re-scrape
52
+ whose part-number column was renamed fails by name instead of minting every guid off an empty
53
+ string.
54
+
40
55
  The transport is a parameter, not a module global. Supply your own `Fetcher` and the vendor
41
56
  adapters read through it — which is also how every test in this package runs without a network.
42
57
 
@@ -57,6 +72,11 @@ toolpath-scrape kennametal 100003658 "$TOOLPATH_SCRAPE_ROOT/kennametal/csv/godri
57
72
  toolpath-scrape materials godrill_3xd_metric.csv
58
73
  toolpath-scrape regofix holders "$TOOLPATH_SCRAPE_ROOT/regofix/csv/regofix_bt30_pg_holders.csv"
59
74
  toolpath-scrape destinytool "$TOOLPATH_SCRAPE_ROOT/destinytool/csv/destinytool_end_mills_inch.csv"
75
+ toolpath-scrape harvey harvey_endmill_008.csv # the page and the unit come from its config
76
+ toolpath-scrape harvey --catalog # what the four category trees link to today
77
+ toolpath-scrape maritool maritool_cat40_holders.csv # its leaf categories come from its config
78
+ toolpath-scrape maritool --catalog # what the five taper trees hold today
79
+ toolpath-scrape emuge emuge_drills.csv # its category and unit come from its config
60
80
  ```
61
81
 
62
82
  `toolpath-scrape --help` lists the rest.
@@ -71,6 +91,14 @@ definition and names the three that are Autodesk's rather than the standard's.
71
91
  Vendor CSVs keep the **vendor's** own column labels. Nothing reads a vendor's CSV but that vendor's
72
92
  adapter, and `conventions.ts` holds the short list of rules that do hold across all of them.
73
93
 
94
+ `materialGroups` has three states, and they are different claims: `null` labelled `unspecified` is
95
+ "we do not know what this tool is for" — not indexed, not published, or not swept — `[]` is a vendor
96
+ index that rates the part for nothing, and a non-empty list is a rating. `materialGroupsSource` is
97
+ never absent: it is `unspecified`, or it says whether the rating was `vendor-stated` or `derived`
98
+ here. Every Harvey record is `unspecified`: Harvey's material index is published per part rather
99
+ than in a variant table, and a scrape cannot reach it — see
100
+ [`docs/HARVEY_PRODUCT_TABLE.md`](docs/HARVEY_PRODUCT_TABLE.md) §1.5.1.
101
+
74
102
  Every per-family constant no vendor table states carries its provenance — whether it was
75
103
  vendor-stated, derived or assumed, and by whom on what date. The types enforce it: an assumed fact
76
104
  without a note, a date and initials does not compile.
@@ -80,7 +108,9 @@ without a note, a date and initials does not compile.
80
108
  - [`docs/ADDING-A-VENDOR.md`](docs/ADDING-A-VENDOR.md) — the runbook.
81
109
  - [`docs/KENNAMETAL_CAD_API.md`](docs/KENNAMETAL_CAD_API.md),
82
110
  [`docs/KENNAMETAL_SPEEDFEED_API.md`](docs/KENNAMETAL_SPEEDFEED_API.md),
83
- [`docs/REGOFIX_PRODUCTFINDER_API.md`](docs/REGOFIX_PRODUCTFINDER_API.md) — how each endpoint was
111
+ [`docs/REGOFIX_PRODUCTFINDER_API.md`](docs/REGOFIX_PRODUCTFINDER_API.md),
112
+ [`docs/HARVEY_PRODUCT_TABLE.md`](docs/HARVEY_PRODUCT_TABLE.md),
113
+ [`docs/MARITOOL_CATALOG.md`](docs/MARITOOL_CATALOG.md) — how each endpoint or table was
84
114
  found, and the dead ends tried first.
85
115
  - [`../../docs/TOOL-SCRAPER-PLAN.md`](../../docs/TOOL-SCRAPER-PLAN.md) — the structure, the evidence
86
116
  behind it, and what has landed.
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Reading one canonical field out of a scraped row, for every vendor whose CSV
3
+ * holds display strings rather than numbers.
4
+ *
5
+ * Two vendors publish a receipt of the vendor's own text — Harvey Tool and
6
+ * EMUGE-FRANKEN — and a mapper for either one needs the same three steps
7
+ * between a `GeometryName` and a number: ask the family's `ColumnMap` which
8
+ * column that field is in, read the cell, and decide what an unreadable one
9
+ * costs. Those three were a verbatim copy in both mappers, down to the wording
10
+ * of the refusal, which is the same argument `measure.ts` makes one level down:
11
+ * two copies of four lines is cheap and two copies of the **decision** is not.
12
+ *
13
+ * ## What is shared and what is not
14
+ *
15
+ * The vendor supplies the grammar, as a {@link LengthReader} — Harvey's
16
+ * `dimension` reads `.250 (1/4)` and a `-` that means "does not apply", EMUGE's
17
+ * `measureIn` reads `1 1/2 "` and refuses a range. What this module owns is
18
+ * everything either side of that call:
19
+ *
20
+ * - a column the family maps to nothing is `undefined`, not an error — a family
21
+ * with no neck column is a plain tool, and that is the mapper's fallback to
22
+ * make;
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;
28
+ * - an **optional** one answers null and lets the mapper decide.
29
+ *
30
+ * `vendors/destinytool/records.ts` keeps its own `required` and is not wired
31
+ * through here: it reads a dimension out of free text rather than out of a
32
+ * cell, throws `RangeError` from its own parser rather than answering null, and
33
+ * has no `optional` at all. Forcing it into this shape would change how it
34
+ * refuses, which is the one thing `measure.ts` says is a vendor's own call.
35
+ */
36
+ import type { UnitSystem } from './conventions.js';
37
+ import type { ColumnMap, GeometryName } from './records.js';
38
+ import type { MapperOptions, ScrapedRow, Warn } from './scrape.js';
39
+ /**
40
+ * How one vendor turns its own display cell into a length in `unit`.
41
+ *
42
+ * The signature `vendors/harvey/value.ts`'s `dimension` and
43
+ * `vendors/emuge/value.ts`'s `measureIn` already have.
44
+ */
45
+ export type LengthReader = (display: string, unit: UnitSystem, what: string, warn?: Warn) => number | null;
46
+ /** The three readers a display-string mapper needs, bound to one grammar. */
47
+ export interface ColumnReaders {
48
+ /** One canonical field's cell, or undefined where the family maps none. */
49
+ cell(row: ScrapedRow, columns: ColumnMap, canonical: GeometryName, unit: UnitSystem): string | undefined;
50
+ /** A dimension the kind requires, refusing a row the vendor left blank. */
51
+ required(row: ScrapedRow, columns: ColumnMap, canonical: GeometryName, unit: UnitSystem, what: string, options: MapperOptions): number;
52
+ /** A dimension the contract does not require. Null where there is none. */
53
+ optional(row: ScrapedRow, columns: ColumnMap, canonical: GeometryName, unit: UnitSystem, what: string, options: MapperOptions): number | null;
54
+ }
55
+ /**
56
+ * The three readers, over one vendor's `read`.
57
+ *
58
+ * `columns` is the caller's map and not `family.columns`. They are the same
59
+ * object through `registry.toRecords` — but `RecordMapper` passes one as an
60
+ * argument, `registry` validates *that* one with `checkColumnsExist`, and a
61
+ * mapper reading a different reference is validating one map and reading
62
+ * another.
63
+ */
64
+ export declare function columnReaders(read: LengthReader): ColumnReaders;
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Reading one canonical field out of a scraped row, for every vendor whose CSV
3
+ * holds display strings rather than numbers.
4
+ *
5
+ * Two vendors publish a receipt of the vendor's own text — Harvey Tool and
6
+ * EMUGE-FRANKEN — and a mapper for either one needs the same three steps
7
+ * between a `GeometryName` and a number: ask the family's `ColumnMap` which
8
+ * column that field is in, read the cell, and decide what an unreadable one
9
+ * costs. Those three were a verbatim copy in both mappers, down to the wording
10
+ * of the refusal, which is the same argument `measure.ts` makes one level down:
11
+ * two copies of four lines is cheap and two copies of the **decision** is not.
12
+ *
13
+ * ## What is shared and what is not
14
+ *
15
+ * The vendor supplies the grammar, as a {@link LengthReader} — Harvey's
16
+ * `dimension` reads `.250 (1/4)` and a `-` that means "does not apply", EMUGE's
17
+ * `measureIn` reads `1 1/2 "` and refuses a range. What this module owns is
18
+ * everything either side of that call:
19
+ *
20
+ * - a column the family maps to nothing is `undefined`, not an error — a family
21
+ * with no neck column is a plain tool, and that is the mapper's fallback to
22
+ * make;
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;
28
+ * - an **optional** one answers null and lets the mapper decide.
29
+ *
30
+ * `vendors/destinytool/records.ts` keeps its own `required` and is not wired
31
+ * through here: it reads a dimension out of free text rather than out of a
32
+ * cell, throws `RangeError` from its own parser rather than answering null, and
33
+ * has no `optional` at all. Forcing it into this shape would change how it
34
+ * refuses, which is the one thing `measure.ts` says is a vendor's own call.
35
+ */
36
+ import { IncompletePartError } from './errors.js';
37
+ /**
38
+ * The three readers, over one vendor's `read`.
39
+ *
40
+ * `columns` is the caller's map and not `family.columns`. They are the same
41
+ * object through `registry.toRecords` — but `RecordMapper` passes one as an
42
+ * argument, `registry` validates *that* one with `checkColumnsExist`, and a
43
+ * mapper reading a different reference is validating one map and reading
44
+ * another.
45
+ */
46
+ export function columnReaders(read) {
47
+ const cell = (row, columns, canonical, unit) => {
48
+ const column = columns.column(canonical, unit);
49
+ return column === null ? undefined : row[column];
50
+ };
51
+ const optional = (row, columns, canonical, unit, what, options) => {
52
+ const raw = cell(row, columns, canonical, unit);
53
+ return raw === undefined ? null : read(raw, unit, what, options.warn);
54
+ };
55
+ const required = (row, columns, canonical, unit, what, options) => {
56
+ const raw = cell(row, columns, canonical, unit);
57
+ const value = raw === undefined ? null : read(raw, unit, what, options.warn);
58
+ if (value === null) {
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 ?? '')}`);
64
+ }
65
+ return value;
66
+ };
67
+ return { cell, required, optional };
68
+ }
@@ -12,14 +12,19 @@
12
12
  * *across* the CSVs anyway, and the reason to make them explicit is that
13
13
  * vendor #3 already drifted from one:
14
14
  *
15
- * | Convention | Held by |
16
- * | ------------------------------------------------- | ------------------------- |
17
- * | `_mm`/`_in` carries the unit on a dimension | all three |
18
- * | Multi-value cells are space-separated | all three |
19
- * | One row per orderable part | all three |
20
- * | `CAD_STEP_URL` names a CAD model where one exists | Kennametal, REGO-FIX |
21
- * | Unmapped vendor codes keep a `DIN_` prefix | REGO-FIX; rule is general |
22
- * | The identity columns | **broken** see below |
15
+ * | Convention | Held by |
16
+ * | -------------------------------------------------- | ------------------------- |
17
+ * | `_mm`/`_in` carries the unit on a dimension | all five |
18
+ * | Multi-value cells are space-separated | all five |
19
+ * | One row per orderable part | all five |
20
+ * | `CAD_STEP_URL` names a CAD model where one exists | Kennametal, REGO-FIX |
21
+ * | `CAD_DXF_URL` names a 2D profile where one exists | Harvey, MariTool |
22
+ * | `Description` carries the vendor's own free text | Harvey, MariTool |
23
+ * | `contact` says how a holder seats | REGO-FIX, MariTool |
24
+ * | `CST` names the collet series a holder takes | REGO-FIX, MariTool |
25
+ * | `L1_in`/`L1_mm` carry a holder's gage length | REGO-FIX, MariTool |
26
+ * | Unmapped vendor codes keep a `DIN_` prefix | REGO-FIX; rule is general |
27
+ * | The identity columns | **broken** — see below |
23
28
  *
24
29
  * Identity and units are the two worth enforcing; the rest are advisory, and
25
30
  * are here so that "advisory" is a decision on the page rather than an
@@ -63,6 +68,87 @@ export declare const UNIT_SUFFIX: Record<UnitSystem, string>;
63
68
  * neither owns it — the leak the vendor-boundary test exists to catch.
64
69
  */
65
70
  export declare const CAD_COLUMN = "CAD_STEP_URL";
71
+ /**
72
+ * The CSV column holding a part's downloadable 2D DXF profile.
73
+ *
74
+ * A second column rather than a second thing written into {@link CAD_COLUMN},
75
+ * because a DXF is not a STEP model: one is a flat profile a machinist prints
76
+ * or traces, the other is the solid a CAM system imports. Harvey Tool publishes
77
+ * a DXF for 12,773 of its 12,799 parts and a STEP for none of them, so writing
78
+ * its link into `CAD_STEP_URL` would repeat exactly the mistake the
79
+ * `CAD_STP_LWM` -> `CAD_STEP_URL` rename fixed on 2026-08-08 — a column name
80
+ * that is a claim about the data, and false.
81
+ *
82
+ * Vendor-neutral and beside `CAD_COLUMN` for the same reason that one is: a
83
+ * format is not one manufacturer's fact, and the second vendor to publish a DXF
84
+ * must write it into this column rather than inventing another.
85
+ */
86
+ export declare const CAD_DXF_COLUMN = "CAD_DXF_URL";
87
+ /**
88
+ * The CSV column holding the vendor's own free text about one part.
89
+ *
90
+ * Harvey Tool writes a product title here and MariTool a product name; both are
91
+ * the vendor's own prose rather than a designation this package composed. It is
92
+ * here and not in either adapter for the reason {@link CAD_COLUMN} is: two of
93
+ * them write it and neither owns it.
94
+ *
95
+ * **Not every vendor publishes one, and `''` is the answer where none does** —
96
+ * a description that restates the catalog number is not a description. See
97
+ * `records.ToolRecord.description`, which states the same rule for the record.
98
+ */
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";
118
+ /**
119
+ * The CSV column saying how a holder seats in the spindle: `taper` or `face`.
120
+ *
121
+ * `face` is dual contact — the flange face seats at the same time as the cone.
122
+ * REGO-FIX resolves it from its own `form_name` and MariTool from its `Taper`
123
+ * cell, which is the right shape: **how** a vendor states it is that vendor's
124
+ * business, and what the column is called is not.
125
+ */
126
+ export declare const CONTACT_COLUMN = "contact";
127
+ /**
128
+ * The CSV column naming the collet series a holder accepts — `ER16`, `PG25`.
129
+ *
130
+ * The join key between a holder family and a collet family, which is exactly
131
+ * why it cannot be spelled twice: `families/kennametal.ts` states the join
132
+ * against this column, and two spellings of it join to nothing. Both vendors
133
+ * that publish it close the vendor's own spacing before writing it here, for
134
+ * the same reason.
135
+ */
136
+ export declare const COLLET_SERIES_COLUMN = "CST";
137
+ /**
138
+ * The CSV columns carrying a holder's gage length, one per unit system.
139
+ *
140
+ * A **pair** with exactly one cell filled, rather than one column and a unit
141
+ * tag, because a single catalog page can publish both: MariTool gages
142
+ * `HSK40E-ER11-40` in millimetres and `HSK40E-ER16-3.0M` in inches on one
143
+ * listing. Nothing is converted between them — the vendor's own imperial
144
+ * conversion is unusable and computing one here would put a number in the file
145
+ * the vendor never published.
146
+ *
147
+ * REGO-FIX fills only the millimetre cell, because its DIN 4000 documents are
148
+ * metric throughout; the pair is still the shape, so the two vendors' holder
149
+ * CSVs answer the same question with the same columns.
150
+ */
151
+ export declare const GAGE_COLUMNS: Record<UnitSystem, string>;
66
152
  /**
67
153
  * The prefix an unmapped vendor code keeps, so it cannot read as a dimension.
68
154
  *
@@ -85,13 +171,19 @@ export declare const IDENTITY_COLUMNS: readonly ["Material Number", "ISO Catalog
85
171
  * Where a vendor's CSV does not use {@link IDENTITY_COLUMNS}, and what it uses
86
172
  * instead.
87
173
  *
88
- * **One entry, and it is a record of drift rather than a licence.** REGO-FIX
89
- * adopted Kennametal's identity labels; Destiny Tool passes Firestore's own
90
- * `itemNumber` straight through and publishes no catalog designation at all —
91
- * the convention was real but informal, and it eroded the first time a vendor
92
- * did not resemble the first two. Writing the deviation down is what makes the
93
- * fourth vendor's drift a decision somebody made rather than a thing that
94
- * happened.
174
+ * **Three entries, and they are a record of drift rather than a licence.**
175
+ * REGO-FIX adopted Kennametal's identity labels; Destiny Tool passes
176
+ * Firestore's own `itemNumber` straight through and publishes no catalog
177
+ * designation at all — the convention was real but informal, and it eroded the
178
+ * first time a vendor did not resemble the first two. Writing the deviation
179
+ * down is what makes the next vendor's drift a decision somebody made rather
180
+ * than a thing that happened.
181
+ *
182
+ * Harvey Tool and MariTool are the honest kind, and they are now the majority:
183
+ * both genuinely publish one identifier per part, so their entries record a
184
+ * fact about the vendor rather than a shortcut taken here. Two of the two
185
+ * vendors added since the convention was written have needed one, which says
186
+ * the two-column shape is Kennametal's rather than the industry's.
95
187
  */
96
188
  export declare const IDENTITY_DEVIATIONS: Partial<Record<BrandName, readonly string[]>>;
97
189
  /**
@@ -12,14 +12,19 @@
12
12
  * *across* the CSVs anyway, and the reason to make them explicit is that
13
13
  * vendor #3 already drifted from one:
14
14
  *
15
- * | Convention | Held by |
16
- * | ------------------------------------------------- | ------------------------- |
17
- * | `_mm`/`_in` carries the unit on a dimension | all three |
18
- * | Multi-value cells are space-separated | all three |
19
- * | One row per orderable part | all three |
20
- * | `CAD_STEP_URL` names a CAD model where one exists | Kennametal, REGO-FIX |
21
- * | Unmapped vendor codes keep a `DIN_` prefix | REGO-FIX; rule is general |
22
- * | The identity columns | **broken** see below |
15
+ * | Convention | Held by |
16
+ * | -------------------------------------------------- | ------------------------- |
17
+ * | `_mm`/`_in` carries the unit on a dimension | all five |
18
+ * | Multi-value cells are space-separated | all five |
19
+ * | One row per orderable part | all five |
20
+ * | `CAD_STEP_URL` names a CAD model where one exists | Kennametal, REGO-FIX |
21
+ * | `CAD_DXF_URL` names a 2D profile where one exists | Harvey, MariTool |
22
+ * | `Description` carries the vendor's own free text | Harvey, MariTool |
23
+ * | `contact` says how a holder seats | REGO-FIX, MariTool |
24
+ * | `CST` names the collet series a holder takes | REGO-FIX, MariTool |
25
+ * | `L1_in`/`L1_mm` carry a holder's gage length | REGO-FIX, MariTool |
26
+ * | Unmapped vendor codes keep a `DIN_` prefix | REGO-FIX; rule is general |
27
+ * | The identity columns | **broken** — see below |
23
28
  *
24
29
  * Identity and units are the two worth enforcing; the rest are advisory, and
25
30
  * are here so that "advisory" is a decision on the page rather than an
@@ -64,6 +69,90 @@ export const UNIT_SUFFIX = {
64
69
  * neither owns it — the leak the vendor-boundary test exists to catch.
65
70
  */
66
71
  export const CAD_COLUMN = 'CAD_STEP_URL';
72
+ /**
73
+ * The CSV column holding a part's downloadable 2D DXF profile.
74
+ *
75
+ * A second column rather than a second thing written into {@link CAD_COLUMN},
76
+ * because a DXF is not a STEP model: one is a flat profile a machinist prints
77
+ * or traces, the other is the solid a CAM system imports. Harvey Tool publishes
78
+ * a DXF for 12,773 of its 12,799 parts and a STEP for none of them, so writing
79
+ * its link into `CAD_STEP_URL` would repeat exactly the mistake the
80
+ * `CAD_STP_LWM` -> `CAD_STEP_URL` rename fixed on 2026-08-08 — a column name
81
+ * that is a claim about the data, and false.
82
+ *
83
+ * Vendor-neutral and beside `CAD_COLUMN` for the same reason that one is: a
84
+ * format is not one manufacturer's fact, and the second vendor to publish a DXF
85
+ * must write it into this column rather than inventing another.
86
+ */
87
+ export const CAD_DXF_COLUMN = 'CAD_DXF_URL';
88
+ /**
89
+ * The CSV column holding the vendor's own free text about one part.
90
+ *
91
+ * Harvey Tool writes a product title here and MariTool a product name; both are
92
+ * the vendor's own prose rather than a designation this package composed. It is
93
+ * here and not in either adapter for the reason {@link CAD_COLUMN} is: two of
94
+ * them write it and neither owns it.
95
+ *
96
+ * **Not every vendor publishes one, and `''` is the answer where none does** —
97
+ * a description that restates the catalog number is not a description. See
98
+ * `records.ToolRecord.description`, which states the same rule for the record.
99
+ */
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';
119
+ /**
120
+ * The CSV column saying how a holder seats in the spindle: `taper` or `face`.
121
+ *
122
+ * `face` is dual contact — the flange face seats at the same time as the cone.
123
+ * REGO-FIX resolves it from its own `form_name` and MariTool from its `Taper`
124
+ * cell, which is the right shape: **how** a vendor states it is that vendor's
125
+ * business, and what the column is called is not.
126
+ */
127
+ export const CONTACT_COLUMN = 'contact';
128
+ /**
129
+ * The CSV column naming the collet series a holder accepts — `ER16`, `PG25`.
130
+ *
131
+ * The join key between a holder family and a collet family, which is exactly
132
+ * why it cannot be spelled twice: `families/kennametal.ts` states the join
133
+ * against this column, and two spellings of it join to nothing. Both vendors
134
+ * that publish it close the vendor's own spacing before writing it here, for
135
+ * the same reason.
136
+ */
137
+ export const COLLET_SERIES_COLUMN = 'CST';
138
+ /**
139
+ * The CSV columns carrying a holder's gage length, one per unit system.
140
+ *
141
+ * A **pair** with exactly one cell filled, rather than one column and a unit
142
+ * tag, because a single catalog page can publish both: MariTool gages
143
+ * `HSK40E-ER11-40` in millimetres and `HSK40E-ER16-3.0M` in inches on one
144
+ * listing. Nothing is converted between them — the vendor's own imperial
145
+ * conversion is unusable and computing one here would put a number in the file
146
+ * the vendor never published.
147
+ *
148
+ * REGO-FIX fills only the millimetre cell, because its DIN 4000 documents are
149
+ * metric throughout; the pair is still the shape, so the two vendors' holder
150
+ * CSVs answer the same question with the same columns.
151
+ */
152
+ export const GAGE_COLUMNS = {
153
+ inches: 'L1_in',
154
+ millimeters: 'L1_mm',
155
+ };
67
156
  /**
68
157
  * The prefix an unmapped vendor code keeps, so it cannot read as a dimension.
69
158
  *
@@ -86,16 +175,38 @@ export const IDENTITY_COLUMNS = ['Material Number', 'ISO Catalog Number'];
86
175
  * Where a vendor's CSV does not use {@link IDENTITY_COLUMNS}, and what it uses
87
176
  * instead.
88
177
  *
89
- * **One entry, and it is a record of drift rather than a licence.** REGO-FIX
90
- * adopted Kennametal's identity labels; Destiny Tool passes Firestore's own
91
- * `itemNumber` straight through and publishes no catalog designation at all —
92
- * the convention was real but informal, and it eroded the first time a vendor
93
- * did not resemble the first two. Writing the deviation down is what makes the
94
- * fourth vendor's drift a decision somebody made rather than a thing that
95
- * happened.
178
+ * **Three entries, and they are a record of drift rather than a licence.**
179
+ * REGO-FIX adopted Kennametal's identity labels; Destiny Tool passes
180
+ * Firestore's own `itemNumber` straight through and publishes no catalog
181
+ * designation at all — the convention was real but informal, and it eroded the
182
+ * first time a vendor did not resemble the first two. Writing the deviation
183
+ * down is what makes the next vendor's drift a decision somebody made rather
184
+ * than a thing that happened.
185
+ *
186
+ * Harvey Tool and MariTool are the honest kind, and they are now the majority:
187
+ * both genuinely publish one identifier per part, so their entries record a
188
+ * fact about the vendor rather than a shortcut taken here. Two of the two
189
+ * vendors added since the convention was written have needed one, which says
190
+ * the two-column shape is Kennametal's rather than the industry's.
96
191
  */
97
192
  export const IDENTITY_DEVIATIONS = {
98
193
  destinytool: ['itemNumber'],
194
+ // Harvey Tool publishes exactly one number per part — the `Tool #` its own
195
+ // table column is headed with, which is also the segment of its per-part URL
196
+ // — and no second catalog designation anywhere on a product page or a part
197
+ // page. Inventing an `ISO Catalog Number` to satisfy the convention would put
198
+ // a column in the CSV that the vendor does not publish, which is the one
199
+ // thing a receipt must not do.
200
+ harvey: ['Tool #'],
201
+ // MariTool publishes one number per part — the `Part#` line its own listing
202
+ // rows are headed with, which is also what its `Available Downloads for …`
203
+ // header restates and what its search endpoint matches on — and no second
204
+ // catalog designation anywhere on a category page or a product page. The
205
+ // store's `products_id` is not that second number: it is an internal id a
206
+ // re-created product would change, and every guid minted off it with it.
207
+ // Same call as Harvey's, and the honest kind: the entry records a fact about
208
+ // the vendor rather than a shortcut taken here.
209
+ maritool: ['Material Number'],
99
210
  };
100
211
  /**
101
212
  * A vendor's bare column label, suffixed for `unit`.
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
+ }