@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/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,62 @@
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, naming the canonical
24
+ * field and quoting the cell, because a tool with no cutting diameter is not
25
+ * a part;
26
+ * - an **optional** one answers null and lets the mapper decide.
27
+ *
28
+ * `vendors/destinytool/records.ts` keeps its own `required` and is not wired
29
+ * through here: it reads a dimension out of free text rather than out of a
30
+ * cell, throws `RangeError` from its own parser rather than answering null, and
31
+ * has no `optional` at all. Forcing it into this shape would change how it
32
+ * refuses, which is the one thing `measure.ts` says is a vendor's own call.
33
+ */
34
+ import type { UnitSystem } from './conventions.js';
35
+ import type { ColumnMap, GeometryName } from './records.js';
36
+ import type { MapperOptions, ScrapedRow, Warn } from './scrape.js';
37
+ /**
38
+ * How one vendor turns its own display cell into a length in `unit`.
39
+ *
40
+ * The signature `vendors/harvey/value.ts`'s `dimension` and
41
+ * `vendors/emuge/value.ts`'s `measureIn` already have.
42
+ */
43
+ export type LengthReader = (display: string, unit: UnitSystem, what: string, warn?: Warn) => number | null;
44
+ /** The three readers a display-string mapper needs, bound to one grammar. */
45
+ export interface ColumnReaders {
46
+ /** One canonical field's cell, or undefined where the family maps none. */
47
+ cell(row: ScrapedRow, columns: ColumnMap, canonical: GeometryName, unit: UnitSystem): string | undefined;
48
+ /** A dimension the kind requires, refusing a row the vendor left blank. */
49
+ required(row: ScrapedRow, columns: ColumnMap, canonical: GeometryName, unit: UnitSystem, what: string, options: MapperOptions): number;
50
+ /** A dimension the contract does not require. Null where there is none. */
51
+ optional(row: ScrapedRow, columns: ColumnMap, canonical: GeometryName, unit: UnitSystem, what: string, options: MapperOptions): number | null;
52
+ }
53
+ /**
54
+ * The three readers, over one vendor's `read`.
55
+ *
56
+ * `columns` is the caller's map and not `family.columns`. They are the same
57
+ * object through `registry.toRecords` — but `RecordMapper` passes one as an
58
+ * argument, `registry` validates *that* one with `checkColumnsExist`, and a
59
+ * mapper reading a different reference is validating one map and reading
60
+ * another.
61
+ */
62
+ export declare function columnReaders(read: LengthReader): ColumnReaders;
@@ -0,0 +1,62 @@
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, naming the canonical
24
+ * field and quoting the cell, because a tool with no cutting diameter is not
25
+ * a part;
26
+ * - an **optional** one answers null and lets the mapper decide.
27
+ *
28
+ * `vendors/destinytool/records.ts` keeps its own `required` and is not wired
29
+ * through here: it reads a dimension out of free text rather than out of a
30
+ * cell, throws `RangeError` from its own parser rather than answering null, and
31
+ * has no `optional` at all. Forcing it into this shape would change how it
32
+ * refuses, which is the one thing `measure.ts` says is a vendor's own call.
33
+ */
34
+ import { VendorResponseError } from './errors.js';
35
+ /**
36
+ * The three readers, over one vendor's `read`.
37
+ *
38
+ * `columns` is the caller's map and not `family.columns`. They are the same
39
+ * object through `registry.toRecords` — but `RecordMapper` passes one as an
40
+ * argument, `registry` validates *that* one with `checkColumnsExist`, and a
41
+ * mapper reading a different reference is validating one map and reading
42
+ * another.
43
+ */
44
+ export function columnReaders(read) {
45
+ const cell = (row, columns, canonical, unit) => {
46
+ const column = columns.column(canonical, unit);
47
+ return column === null ? undefined : row[column];
48
+ };
49
+ const optional = (row, columns, canonical, unit, what, options) => {
50
+ const raw = cell(row, columns, canonical, unit);
51
+ return raw === undefined ? null : read(raw, unit, what, options.warn);
52
+ };
53
+ const required = (row, columns, canonical, unit, what, options) => {
54
+ const raw = cell(row, columns, canonical, unit);
55
+ const value = raw === undefined ? null : read(raw, unit, what, options.warn);
56
+ if (value === null) {
57
+ throw new VendorResponseError(what, `publishes no ${canonical} — its cell is ${JSON.stringify(raw ?? '')}`);
58
+ }
59
+ return value;
60
+ };
61
+ return { cell, required, optional };
62
+ }
@@ -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,69 @@ 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 saying how a holder seats in the spindle: `taper` or `face`.
102
+ *
103
+ * `face` is dual contact — the flange face seats at the same time as the cone.
104
+ * REGO-FIX resolves it from its own `form_name` and MariTool from its `Taper`
105
+ * cell, which is the right shape: **how** a vendor states it is that vendor's
106
+ * business, and what the column is called is not.
107
+ */
108
+ export declare const CONTACT_COLUMN = "contact";
109
+ /**
110
+ * The CSV column naming the collet series a holder accepts — `ER16`, `PG25`.
111
+ *
112
+ * The join key between a holder family and a collet family, which is exactly
113
+ * why it cannot be spelled twice: `families/kennametal.ts` states the join
114
+ * against this column, and two spellings of it join to nothing. Both vendors
115
+ * that publish it close the vendor's own spacing before writing it here, for
116
+ * the same reason.
117
+ */
118
+ export declare const COLLET_SERIES_COLUMN = "CST";
119
+ /**
120
+ * The CSV columns carrying a holder's gage length, one per unit system.
121
+ *
122
+ * A **pair** with exactly one cell filled, rather than one column and a unit
123
+ * tag, because a single catalog page can publish both: MariTool gages
124
+ * `HSK40E-ER11-40` in millimetres and `HSK40E-ER16-3.0M` in inches on one
125
+ * listing. Nothing is converted between them — the vendor's own imperial
126
+ * conversion is unusable and computing one here would put a number in the file
127
+ * the vendor never published.
128
+ *
129
+ * REGO-FIX fills only the millimetre cell, because its DIN 4000 documents are
130
+ * metric throughout; the pair is still the shape, so the two vendors' holder
131
+ * CSVs answer the same question with the same columns.
132
+ */
133
+ export declare const GAGE_COLUMNS: Record<UnitSystem, string>;
66
134
  /**
67
135
  * The prefix an unmapped vendor code keeps, so it cannot read as a dimension.
68
136
  *
@@ -85,13 +153,19 @@ export declare const IDENTITY_COLUMNS: readonly ["Material Number", "ISO Catalog
85
153
  * Where a vendor's CSV does not use {@link IDENTITY_COLUMNS}, and what it uses
86
154
  * instead.
87
155
  *
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.
156
+ * **Three entries, and they are a record of drift rather than a licence.**
157
+ * REGO-FIX adopted Kennametal's identity labels; Destiny Tool passes
158
+ * Firestore's own `itemNumber` straight through and publishes no catalog
159
+ * designation at all — the convention was real but informal, and it eroded the
160
+ * first time a vendor did not resemble the first two. Writing the deviation
161
+ * down is what makes the next vendor's drift a decision somebody made rather
162
+ * than a thing that happened.
163
+ *
164
+ * Harvey Tool and MariTool are the honest kind, and they are now the majority:
165
+ * both genuinely publish one identifier per part, so their entries record a
166
+ * fact about the vendor rather than a shortcut taken here. Two of the two
167
+ * vendors added since the convention was written have needed one, which says
168
+ * the two-column shape is Kennametal's rather than the industry's.
95
169
  */
96
170
  export declare const IDENTITY_DEVIATIONS: Partial<Record<BrandName, readonly string[]>>;
97
171
  /**
@@ -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,72 @@ 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 saying how a holder seats in the spindle: `taper` or `face`.
103
+ *
104
+ * `face` is dual contact — the flange face seats at the same time as the cone.
105
+ * REGO-FIX resolves it from its own `form_name` and MariTool from its `Taper`
106
+ * cell, which is the right shape: **how** a vendor states it is that vendor's
107
+ * business, and what the column is called is not.
108
+ */
109
+ export const CONTACT_COLUMN = 'contact';
110
+ /**
111
+ * The CSV column naming the collet series a holder accepts — `ER16`, `PG25`.
112
+ *
113
+ * The join key between a holder family and a collet family, which is exactly
114
+ * why it cannot be spelled twice: `families/kennametal.ts` states the join
115
+ * against this column, and two spellings of it join to nothing. Both vendors
116
+ * that publish it close the vendor's own spacing before writing it here, for
117
+ * the same reason.
118
+ */
119
+ export const COLLET_SERIES_COLUMN = 'CST';
120
+ /**
121
+ * The CSV columns carrying a holder's gage length, one per unit system.
122
+ *
123
+ * A **pair** with exactly one cell filled, rather than one column and a unit
124
+ * tag, because a single catalog page can publish both: MariTool gages
125
+ * `HSK40E-ER11-40` in millimetres and `HSK40E-ER16-3.0M` in inches on one
126
+ * listing. Nothing is converted between them — the vendor's own imperial
127
+ * conversion is unusable and computing one here would put a number in the file
128
+ * the vendor never published.
129
+ *
130
+ * REGO-FIX fills only the millimetre cell, because its DIN 4000 documents are
131
+ * metric throughout; the pair is still the shape, so the two vendors' holder
132
+ * CSVs answer the same question with the same columns.
133
+ */
134
+ export const GAGE_COLUMNS = {
135
+ inches: 'L1_in',
136
+ millimeters: 'L1_mm',
137
+ };
67
138
  /**
68
139
  * The prefix an unmapped vendor code keeps, so it cannot read as a dimension.
69
140
  *
@@ -86,16 +157,38 @@ export const IDENTITY_COLUMNS = ['Material Number', 'ISO Catalog Number'];
86
157
  * Where a vendor's CSV does not use {@link IDENTITY_COLUMNS}, and what it uses
87
158
  * instead.
88
159
  *
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.
160
+ * **Three entries, and they are a record of drift rather than a licence.**
161
+ * REGO-FIX adopted Kennametal's identity labels; Destiny Tool passes
162
+ * Firestore's own `itemNumber` straight through and publishes no catalog
163
+ * designation at all — the convention was real but informal, and it eroded the
164
+ * first time a vendor did not resemble the first two. Writing the deviation
165
+ * down is what makes the next vendor's drift a decision somebody made rather
166
+ * than a thing that happened.
167
+ *
168
+ * Harvey Tool and MariTool are the honest kind, and they are now the majority:
169
+ * both genuinely publish one identifier per part, so their entries record a
170
+ * fact about the vendor rather than a shortcut taken here. Two of the two
171
+ * vendors added since the convention was written have needed one, which says
172
+ * the two-column shape is Kennametal's rather than the industry's.
96
173
  */
97
174
  export const IDENTITY_DEVIATIONS = {
98
175
  destinytool: ['itemNumber'],
176
+ // Harvey Tool publishes exactly one number per part — the `Tool #` its own
177
+ // table column is headed with, which is also the segment of its per-part URL
178
+ // — and no second catalog designation anywhere on a product page or a part
179
+ // page. Inventing an `ISO Catalog Number` to satisfy the convention would put
180
+ // a column in the CSV that the vendor does not publish, which is the one
181
+ // thing a receipt must not do.
182
+ harvey: ['Tool #'],
183
+ // MariTool publishes one number per part — the `Part#` line its own listing
184
+ // rows are headed with, which is also what its `Available Downloads for …`
185
+ // header restates and what its search endpoint matches on — and no second
186
+ // catalog designation anywhere on a category page or a product page. The
187
+ // store's `products_id` is not that second number: it is an internal id a
188
+ // re-created product would change, and every guid minted off it with it.
189
+ // Same call as Harvey's, and the honest kind: the entry records a fact about
190
+ // the vendor rather than a shortcut taken here.
191
+ maritool: ['Material Number'],
99
192
  };
100
193
  /**
101
194
  * A vendor's bare column label, suffixed for `unit`.