@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
@@ -31,6 +31,9 @@
31
31
  */
32
32
  import { ScraperConfigError } from '../errors.js';
33
33
  import { FAMILIES as DESTINYTOOL } from './destinytool.js';
34
+ import { FAMILIES as EMUGE } from './emuge.js';
35
+ import { FAMILIES as HARVEY } from './harvey.js';
36
+ import { HOLDER_FAMILIES as MARITOOL_HOLDERS } from './maritool.js';
34
37
  import { COLLET_FAMILIES as KM_COLLETS, FAMILIES as KENNAMETAL, HOLDER_FAMILIES as KM_HOLDERS, } from './kennametal.js';
35
38
  import { COLLET_FAMILIES as RF_COLLETS, HOLDER_FAMILIES as RF_HOLDERS } from './regofix.js';
36
39
  /**
@@ -57,7 +60,7 @@ function merge(...tables) {
57
60
  return merged;
58
61
  }
59
62
  /** Every cutting-tool family, keyed by the CSV it is scraped into. */
60
- export const FAMILIES = merge(KENNAMETAL, DESTINYTOOL);
63
+ export const FAMILIES = merge(KENNAMETAL, DESTINYTOOL, HARVEY, EMUGE);
61
64
  /**
62
65
  * Every toolholding family — holders, and the collets that go in them.
63
66
  *
@@ -66,7 +69,7 @@ export const FAMILIES = merge(KENNAMETAL, DESTINYTOOL);
66
69
  * states a taper and a clamping mode; a collet states a series and a capacity
67
70
  * band) and a scrape of one is not a scrape of the other.
68
71
  */
69
- export const HOLDER_FAMILIES = merge(KM_HOLDERS, RF_HOLDERS);
72
+ export const HOLDER_FAMILIES = merge(KM_HOLDERS, RF_HOLDERS, MARITOOL_HOLDERS);
70
73
  export const COLLET_FAMILIES = merge(KM_COLLETS, RF_COLLETS);
71
74
  /**
72
75
  * Every family this package knows, by CSV name — tools and toolholding alike.
@@ -230,6 +230,13 @@ export declare const FAMILIES: {
230
230
  readonly checked: "2026-08-08";
231
231
  readonly by: "JG";
232
232
  };
233
+ readonly coolantThrough: {
234
+ readonly value: false;
235
+ readonly source: "assumed";
236
+ readonly note: "HSS taps; no coolant-through variant appears in the family's variant table, and the table publishes no coolant column";
237
+ readonly checked: "2026-08-29";
238
+ readonly by: "JG";
239
+ };
233
240
  };
234
241
  };
235
242
  readonly 'khsst_hand_metric_plug.csv': {
@@ -251,6 +258,13 @@ export declare const FAMILIES: {
251
258
  readonly checked: "2026-08-08";
252
259
  readonly by: "JG";
253
260
  };
261
+ readonly coolantThrough: {
262
+ readonly value: false;
263
+ readonly source: "assumed";
264
+ readonly note: "HSS taps; no coolant-through variant appears in the family's variant table, and the table publishes no coolant column";
265
+ readonly checked: "2026-08-29";
266
+ readonly by: "JG";
267
+ };
254
268
  };
255
269
  };
256
270
  readonly 'spiral_point_metric_plug.csv': {
@@ -272,6 +286,13 @@ export declare const FAMILIES: {
272
286
  readonly checked: "2026-08-08";
273
287
  readonly by: "JG";
274
288
  };
289
+ readonly coolantThrough: {
290
+ readonly value: false;
291
+ readonly source: "assumed";
292
+ readonly note: "HSS taps; no coolant-through variant appears in the family's variant table, and the table publishes no coolant column";
293
+ readonly checked: "2026-08-29";
294
+ readonly by: "JG";
295
+ };
275
296
  };
276
297
  };
277
298
  readonly 'gomill_pro_radiused_4fl_necked_metric.csv': {
@@ -66,6 +66,13 @@ const PCD_SUBSTRATE = {
66
66
  source: 'vendor-stated',
67
67
  cite: 'grade KD1415 is PCD — the cutting material, not the carbide body it is brazed to',
68
68
  };
69
+ const NO_COOLANT_THROUGH_TAP = {
70
+ value: false,
71
+ source: 'assumed',
72
+ note: "HSS taps; no coolant-through variant appears in the family's variant table, and the table publishes no coolant column",
73
+ checked: '2026-08-29',
74
+ by: 'JG',
75
+ };
69
76
  const HSS_ASSUMED = {
70
77
  value: 'hss',
71
78
  source: 'assumed',
@@ -299,6 +306,7 @@ export const FAMILIES = {
299
306
  columns: { SFDM: 'D', OAL: 'L', LCF: 'L3', TP: 'Thread Pitch' },
300
307
  facts: {
301
308
  bmc: HSS_ASSUMED,
309
+ coolantThrough: NO_COOLANT_THROUGH_TAP,
302
310
  },
303
311
  },
304
312
  'khsst_hand_metric_plug.csv': {
@@ -309,6 +317,7 @@ export const FAMILIES = {
309
317
  columns: { SFDM: 'D', OAL: 'L', LCF: 'L3', TP: 'Thread Pitch' },
310
318
  facts: {
311
319
  bmc: HSS_ASSUMED,
320
+ coolantThrough: NO_COOLANT_THROUGH_TAP,
312
321
  },
313
322
  },
314
323
  'spiral_point_metric_plug.csv': {
@@ -319,6 +328,7 @@ export const FAMILIES = {
319
328
  columns: { SFDM: 'D', OAL: 'L', LCF: 'L3', TP: 'Thread Pitch' },
320
329
  facts: {
321
330
  bmc: HSS_ASSUMED,
331
+ coolantThrough: NO_COOLANT_THROUGH_TAP,
322
332
  },
323
333
  },
324
334
  'gomill_pro_radiused_4fl_necked_metric.csv': {
@@ -0,0 +1,120 @@
1
+ /**
2
+ * MariTool's families — toolholding only, and one CSV per spindle taper.
3
+ *
4
+ * There is no `FAMILIES` table here: MariTool publishes no cutting tools, so
5
+ * this vendor never goes through a column map and binds no record mapper —
6
+ * the REGO-FIX case. What it publishes is ER collet chucks, shrink-fit holders
7
+ * and hydraulic chucks in five tapers.
8
+ *
9
+ * ## The scrape target is a list of leaf categories
10
+ *
11
+ * MariTool's tree is two to four levels deep and **no root carries products
12
+ * itself**, so there is no single page to name. Each family below states the
13
+ * leaf cPaths it is scraped from, in {@link LEAVES}, and that list is config
14
+ * rather than something a scrape re-derives: a walk of the five roots is 199
15
+ * requests to rediscover 41 paths that are already written down. The walk is
16
+ * kept — `vendors/maritool/catalog.ts`, reachable as `maritool --catalog` —
17
+ * because it is how this list was built and how it gets rechecked.
18
+ *
19
+ * **A leaf carries the clamping mode and the style with it.** Each CSV is one
20
+ * taper and mixes all three holder styles, so neither can be a family fact;
21
+ * and MariTool's own name for the leaf is what classifies the part, so the
22
+ * column is vendor-stated rather than a coinage here. `ER Collet Chucks` is
23
+ * `collet` / `er-collet-chuck`, `Shrink Fit Holders` is `shrink` /
24
+ * `shrink-fit`, `Hydraulic Chucks` is `hydraulic` / `hydraulic-chuck`.
25
+ *
26
+ * **Three of the leaves are named `Collet Chucks` rather than `ER Collet
27
+ * Chucks`, and all three are ER** — `c23_24_45` (CAT50 ER32), `c23_24_429_430`
28
+ * (dual-contact CAT50 ER32) and `c23_46_1811_1812` (HSK63F ER20), verified
29
+ * from the part numbers on each page. That is the reason this is a list and
30
+ * not a name filter: a rule matching `ER Collet Chucks` drops all three
31
+ * silently.
32
+ *
33
+ * ## Not a fact between them
34
+ *
35
+ * No family below declares one, and every candidate is a column instead:
36
+ *
37
+ * - **`taper` and `contact`** — MariTool states the interface per part in its
38
+ * `Taper` cell, in both plain and dual-contact forms, and the HSK family
39
+ * holds nine sizes in one CSV. A family constant would mask a scrape that
40
+ * lost the column.
41
+ * - **`clamping` and `style`** — three styles per CSV, from the leaf.
42
+ * - **`unit`** — `Gage Length` is metric on some parts and imperial on others
43
+ * inside one file *and* inside one category page, so there is no
44
+ * family-level answer to declare. The scraper promotes an `L1_in`/`L1_mm`
45
+ * pair with one cell filled instead.
46
+ *
47
+ * ## What is not scraped
48
+ *
49
+ * **BT50 and ISO30 produce no CSV.** MariTool's only BT50 category is end mill
50
+ * holders, which is out of scope; ISO30 has three ER parts and none of them
51
+ * publishes a spec table, so the family would be a receipt of nothing.
52
+ *
53
+ * Mega Grip, SK, TG, end mill and shell mill holders, slitting-saw and hob
54
+ * arbors, threaded-body and DSF/MCS modulars, drill chucks, floating tap
55
+ * holders, boring-head adapters, calibration tooling and coolant tubes are all
56
+ * out of scope as well — 730 further parts under `c23`. Adding one is a
57
+ * decision, not a widening of this table.
58
+ */
59
+ /**
60
+ * One leaf to scrape, and how MariTool classifies what is in it.
61
+ *
62
+ * Declared here rather than imported from `vendors/maritool/scrape.ts`,
63
+ * which declares a structurally identical `LeafTarget`: `families/` is config
64
+ * and must not import an adapter — `tests/vendor-boundary.test.ts` refuses it,
65
+ * and the table is read by every test, none of which should drag a vendor's
66
+ * scraper in behind it.
67
+ */
68
+ export interface Leaf {
69
+ /** MariTool's own category path, e.g. `23_25_42`. */
70
+ readonly cPath: string;
71
+ /** How a holder in this leaf grips: `collet`, `shrink`, `hydraulic`. */
72
+ readonly clamping: string;
73
+ /** The product style, as MariTool names the leaf. */
74
+ readonly style: string;
75
+ }
76
+ /**
77
+ * Which leaf categories each family is scraped from, by CSV name.
78
+ *
79
+ * Keyed the same as {@link HOLDER_FAMILIES}, which
80
+ * `tests/maritool.test.ts` holds the two tables to — a family with no leaves
81
+ * would scrape nothing and report it as an empty catalog.
82
+ *
83
+ * The plain and dual-contact leaves of one style sit next to each other on
84
+ * purpose: they share no `products_id` at all, so a dual-contact holder is a
85
+ * distinct part rather than a re-listing, and both belong in the taper's CSV.
86
+ */
87
+ export declare const LEAVES: {
88
+ readonly 'maritool_cat40_holders.csv': readonly [Leaf, Leaf, Leaf, Leaf, Leaf, Leaf];
89
+ readonly 'maritool_cat50_holders.csv': readonly [Leaf, Leaf, Leaf, Leaf, Leaf, Leaf];
90
+ readonly 'maritool_bt30_holders.csv': readonly [Leaf, Leaf, Leaf, Leaf, Leaf, Leaf, Leaf, Leaf];
91
+ readonly 'maritool_bt40_holders.csv': readonly [Leaf, Leaf, Leaf, Leaf, Leaf, Leaf];
92
+ readonly 'maritool_hsk_holders.csv': readonly [Leaf, Leaf, Leaf, Leaf, Leaf, Leaf, Leaf, Leaf, Leaf, Leaf, Leaf, Leaf, Leaf, Leaf, Leaf];
93
+ };
94
+ export declare const HOLDER_FAMILIES: {
95
+ readonly 'maritool_cat40_holders.csv': {
96
+ readonly catalogName: "MariTool CAT40 Toolholders";
97
+ readonly rows: 217;
98
+ readonly brand: "maritool";
99
+ };
100
+ readonly 'maritool_cat50_holders.csv': {
101
+ readonly catalogName: "MariTool CAT50 Toolholders";
102
+ readonly rows: 9;
103
+ readonly brand: "maritool";
104
+ };
105
+ readonly 'maritool_bt30_holders.csv': {
106
+ readonly catalogName: "MariTool BT30 Toolholders";
107
+ readonly rows: 164;
108
+ readonly brand: "maritool";
109
+ };
110
+ readonly 'maritool_bt40_holders.csv': {
111
+ readonly catalogName: "MariTool BT40 Toolholders";
112
+ readonly rows: 67;
113
+ readonly brand: "maritool";
114
+ };
115
+ readonly 'maritool_hsk_holders.csv': {
116
+ readonly catalogName: "MariTool HSK Toolholders";
117
+ readonly rows: 70;
118
+ readonly brand: "maritool";
119
+ };
120
+ };
@@ -0,0 +1,175 @@
1
+ /**
2
+ * MariTool's families — toolholding only, and one CSV per spindle taper.
3
+ *
4
+ * There is no `FAMILIES` table here: MariTool publishes no cutting tools, so
5
+ * this vendor never goes through a column map and binds no record mapper —
6
+ * the REGO-FIX case. What it publishes is ER collet chucks, shrink-fit holders
7
+ * and hydraulic chucks in five tapers.
8
+ *
9
+ * ## The scrape target is a list of leaf categories
10
+ *
11
+ * MariTool's tree is two to four levels deep and **no root carries products
12
+ * itself**, so there is no single page to name. Each family below states the
13
+ * leaf cPaths it is scraped from, in {@link LEAVES}, and that list is config
14
+ * rather than something a scrape re-derives: a walk of the five roots is 199
15
+ * requests to rediscover 41 paths that are already written down. The walk is
16
+ * kept — `vendors/maritool/catalog.ts`, reachable as `maritool --catalog` —
17
+ * because it is how this list was built and how it gets rechecked.
18
+ *
19
+ * **A leaf carries the clamping mode and the style with it.** Each CSV is one
20
+ * taper and mixes all three holder styles, so neither can be a family fact;
21
+ * and MariTool's own name for the leaf is what classifies the part, so the
22
+ * column is vendor-stated rather than a coinage here. `ER Collet Chucks` is
23
+ * `collet` / `er-collet-chuck`, `Shrink Fit Holders` is `shrink` /
24
+ * `shrink-fit`, `Hydraulic Chucks` is `hydraulic` / `hydraulic-chuck`.
25
+ *
26
+ * **Three of the leaves are named `Collet Chucks` rather than `ER Collet
27
+ * Chucks`, and all three are ER** — `c23_24_45` (CAT50 ER32), `c23_24_429_430`
28
+ * (dual-contact CAT50 ER32) and `c23_46_1811_1812` (HSK63F ER20), verified
29
+ * from the part numbers on each page. That is the reason this is a list and
30
+ * not a name filter: a rule matching `ER Collet Chucks` drops all three
31
+ * silently.
32
+ *
33
+ * ## Not a fact between them
34
+ *
35
+ * No family below declares one, and every candidate is a column instead:
36
+ *
37
+ * - **`taper` and `contact`** — MariTool states the interface per part in its
38
+ * `Taper` cell, in both plain and dual-contact forms, and the HSK family
39
+ * holds nine sizes in one CSV. A family constant would mask a scrape that
40
+ * lost the column.
41
+ * - **`clamping` and `style`** — three styles per CSV, from the leaf.
42
+ * - **`unit`** — `Gage Length` is metric on some parts and imperial on others
43
+ * inside one file *and* inside one category page, so there is no
44
+ * family-level answer to declare. The scraper promotes an `L1_in`/`L1_mm`
45
+ * pair with one cell filled instead.
46
+ *
47
+ * ## What is not scraped
48
+ *
49
+ * **BT50 and ISO30 produce no CSV.** MariTool's only BT50 category is end mill
50
+ * holders, which is out of scope; ISO30 has three ER parts and none of them
51
+ * publishes a spec table, so the family would be a receipt of nothing.
52
+ *
53
+ * Mega Grip, SK, TG, end mill and shell mill holders, slitting-saw and hob
54
+ * arbors, threaded-body and DSF/MCS modulars, drill chucks, floating tap
55
+ * holders, boring-head adapters, calibration tooling and coolant tubes are all
56
+ * out of scope as well — 730 further parts under `c23`. Adding one is a
57
+ * decision, not a widening of this table.
58
+ */
59
+ const collet = (cPath) => ({ cPath, clamping: 'collet', style: 'er-collet-chuck' });
60
+ const shrink = (cPath) => ({ cPath, clamping: 'shrink', style: 'shrink-fit' });
61
+ const hydraulic = (cPath) => ({
62
+ cPath,
63
+ clamping: 'hydraulic',
64
+ style: 'hydraulic-chuck',
65
+ });
66
+ /**
67
+ * Which leaf categories each family is scraped from, by CSV name.
68
+ *
69
+ * Keyed the same as {@link HOLDER_FAMILIES}, which
70
+ * `tests/maritool.test.ts` holds the two tables to — a family with no leaves
71
+ * would scrape nothing and report it as an empty catalog.
72
+ *
73
+ * The plain and dual-contact leaves of one style sit next to each other on
74
+ * purpose: they share no `products_id` at all, so a dual-contact holder is a
75
+ * distinct part rather than a re-listing, and both belong in the taper's CSV.
76
+ */
77
+ export const LEAVES = {
78
+ 'maritool_cat40_holders.csv': [
79
+ collet('23_25_42'),
80
+ collet('23_25_432_433'),
81
+ shrink('23_25_503'),
82
+ shrink('23_25_432_524'),
83
+ hydraulic('23_25_929'),
84
+ hydraulic('23_25_432_930'),
85
+ ],
86
+ 'maritool_cat50_holders.csv': [
87
+ collet('23_24_45'),
88
+ collet('23_24_429_430'),
89
+ shrink('23_24_1978'),
90
+ shrink('23_24_429_1979'),
91
+ hydraulic('23_24_957'),
92
+ hydraulic('23_24_429_1512'),
93
+ ],
94
+ 'maritool_bt30_holders.csv': [
95
+ collet('23_33_35'),
96
+ collet('23_33_444_445'),
97
+ collet('23_33_269_270'),
98
+ shrink('23_33_515'),
99
+ shrink('23_33_444_559'),
100
+ shrink('23_33_269_1575'),
101
+ hydraulic('23_33_911'),
102
+ hydraulic('23_33_444_912'),
103
+ ],
104
+ 'maritool_bt40_holders.csv': [
105
+ collet('23_26_36'),
106
+ collet('23_26_480_481'),
107
+ shrink('23_26_1640'),
108
+ shrink('23_26_480_1641'),
109
+ hydraulic('23_26_1199'),
110
+ hydraulic('23_26_480_1409'),
111
+ ],
112
+ // Fifteen leaves for seventy parts, because HSK is nine sizes rather than
113
+ // one taper and MariTool files each size's styles under it separately. The
114
+ // sizes in scope are HSK25E, HSK40E, HSK50A, HSK50E, HSK63A, HSK63F, HSK80F
115
+ // and HSK100A; the `Taper` column is what tells them apart in the file.
116
+ 'maritool_hsk_holders.csv': [
117
+ collet('23_46_943_1494'), // HSK63A
118
+ collet('23_46_1523_1543'), // HSK50A
119
+ collet('23_46_1552_1558'), // HSK40E
120
+ collet('23_46_1591_1592'), // HSK25E
121
+ collet('23_46_972_1605'), // HSK50E
122
+ collet('23_46_1811_1812'), // HSK63F — the leaf named "Collet Chuck Holders"
123
+ shrink('23_46_943_947'), // HSK63A
124
+ shrink('23_46_972_1495'), // HSK50E
125
+ shrink('23_46_1523_1542'), // HSK50A
126
+ shrink('23_46_1552_1553'), // HSK40E
127
+ shrink('23_46_1513_1903'), // HSK100A
128
+ shrink('23_46_1841_1844'), // HSK80F
129
+ hydraulic('23_46_943_946'), // HSK63A
130
+ hydraulic('23_46_1513_1534'), // HSK100A
131
+ hydraulic('23_46_972_973'), // HSK50E
132
+ ],
133
+ };
134
+ export const HOLDER_FAMILIES = {
135
+ // ── MariTool toolholding (JG 2026-08-29) ───────────────────────────────
136
+ // Five families, 529 parts across 41 leaves, and **no facts on any of
137
+ // them**. Every per-family constant the other toolholding vendors declare is
138
+ // a column here, for the reasons in this module's docstring; a fact stating
139
+ // one would be a constant the vendor's own table contradicts row by row.
140
+ //
141
+ // `rows` is the sum of what each leaf's own `(of N products)` line says,
142
+ // which is the independent restatement `receipts.checkRows` needs: every
143
+ // other count is computed from the same file it is checking, so a scrape
144
+ // that silently lost rows agrees with itself.
145
+ 'maritool_cat40_holders.csv': {
146
+ catalogName: 'MariTool CAT40 Toolholders',
147
+ rows: 217,
148
+ brand: 'maritool',
149
+ },
150
+ // 11 parts in the vendor's six leaves and 9 rows here: `CAT50-ER32-3.0` and
151
+ // `CAT50-ER32-4.0` publish no `Product Specifications` table at all — they
152
+ // state their geometry as prose in a `Product Info` bullet list — so they
153
+ // have no gage length and are skipped by the scraper with a message. They
154
+ // are the only two such parts in scope.
155
+ 'maritool_cat50_holders.csv': {
156
+ catalogName: 'MariTool CAT50 Toolholders',
157
+ rows: 9,
158
+ brand: 'maritool',
159
+ },
160
+ 'maritool_bt30_holders.csv': {
161
+ catalogName: 'MariTool BT30 Toolholders',
162
+ rows: 164,
163
+ brand: 'maritool',
164
+ },
165
+ 'maritool_bt40_holders.csv': {
166
+ catalogName: 'MariTool BT40 Toolholders',
167
+ rows: 67,
168
+ brand: 'maritool',
169
+ },
170
+ 'maritool_hsk_holders.csv': {
171
+ catalogName: 'MariTool HSK Toolholders',
172
+ rows: 70,
173
+ brand: 'maritool',
174
+ },
175
+ };
package/dist/family.d.ts CHANGED
@@ -24,7 +24,7 @@ import type { MapperOptions, ScrapedRow } from './scrape.js';
24
24
  /**
25
25
  * The per-family constants a fact can carry, and their types.
26
26
  *
27
- * Ten keys, which is the whole vocabulary the catalog uses. Naming them rather
27
+ * Eleven keys, which is the whole vocabulary the catalog uses. Naming them rather
28
28
  * than accepting any string is what lets a mapper read `family.coolantThrough`
29
29
  * as a `boolean` instead of casting an `unknown` out of a bag — and what makes
30
30
  * a fact whose value is the wrong type a compile error where the family is
@@ -35,6 +35,18 @@ export interface FamilyFacts {
35
35
  unit?: UnitSystem;
36
36
  /** Cutting-material code — `carbide`, `hss`, `diamond`. */
37
37
  bmc?: string;
38
+ /**
39
+ * The end profile, as the vendor names it — `Ball`, `Square`,
40
+ * `Corner Radius`.
41
+ *
42
+ * A per-family constant here rather than a per-row derivation because some
43
+ * vendors state it once for a whole product line and never in the variant
44
+ * table: Harvey Tool's 52 product pages each publish one profile in the page
45
+ * title and no profile column, where Destiny Tool publishes an `endStyle`
46
+ * per row and needs none of this. It is what tells a mapper that a family
47
+ * with no corner-radius column has `RE = DC / 2` rather than `RE = 0`.
48
+ */
49
+ profile?: string;
38
50
  coolantThrough?: boolean;
39
51
  flutes?: number;
40
52
  /** Degrees included. */
@@ -125,6 +137,28 @@ export type BoundToolholding = ToolholdingDefinition & FamilyFacts;
125
137
  * stays one route parameter.
126
138
  */
127
139
  export declare function familyId(cfg: FamilyDefinition | BoundFamily): string;
140
+ /**
141
+ * A per-family constant a mapper cannot proceed without.
142
+ *
143
+ * The projection is what makes this readable — a mapper says `family.unit` and
144
+ * never learns about provenance — but a projected key is still optional on the
145
+ * type, because {@link FamilyFacts} is one vocabulary shared by four vendors
146
+ * and a tap states no `unit`. So the check is per read, and it belongs here
147
+ * rather than in one adapter: it lived in `vendors/kennametal/records.ts` until
148
+ * 2026-08-29, which is why the other two adapters each invented their own
149
+ * weaker answer instead — `family.unit!`, `family.bmc ?? ''`,
150
+ * `family.coolantThrough ?? false`, a hardcoded `'inches'`.
151
+ *
152
+ * **Every one of those defaults is a claim the family never made.** A missing
153
+ * `coolantThrough` becoming `false` ships a through-coolant drill with its
154
+ * coolant presets dropped; a missing `bmc` becoming `''` ships a tool with no
155
+ * cutting material at all. Refusing names the family and the key, at the one
156
+ * place that can fix it — the config table.
157
+ *
158
+ * Same move `scrape.pause` and `conventions.CAD_COLUMN` already made, and for
159
+ * the same reason: a core concern does not live in one manufacturer's folder.
160
+ */
161
+ export declare function fact<T>(family: BoundFamily, key: string, value: T | undefined): T;
128
162
  /** The brand that published a family, defaulting as the catalog does. */
129
163
  export declare function familyBrand(cfg: CommonDefinition | BoundFamily | BoundToolholding): BrandName;
130
164
  export {};
package/dist/family.js CHANGED
@@ -16,6 +16,7 @@
16
16
  * says `family.pointAngle` and never learns about provenance — but there is
17
17
  * only ever one authored copy to drift from.
18
18
  */
19
+ import { ScraperConfigError } from './errors.js';
19
20
  /**
20
21
  * A family's id: `<brand>:<vendor-local id>`.
21
22
  *
@@ -32,6 +33,33 @@
32
33
  export function familyId(cfg) {
33
34
  return `${cfg.brand ?? 'kennametal'}:${cfg.id}`;
34
35
  }
36
+ /**
37
+ * A per-family constant a mapper cannot proceed without.
38
+ *
39
+ * The projection is what makes this readable — a mapper says `family.unit` and
40
+ * never learns about provenance — but a projected key is still optional on the
41
+ * type, because {@link FamilyFacts} is one vocabulary shared by four vendors
42
+ * and a tap states no `unit`. So the check is per read, and it belongs here
43
+ * rather than in one adapter: it lived in `vendors/kennametal/records.ts` until
44
+ * 2026-08-29, which is why the other two adapters each invented their own
45
+ * weaker answer instead — `family.unit!`, `family.bmc ?? ''`,
46
+ * `family.coolantThrough ?? false`, a hardcoded `'inches'`.
47
+ *
48
+ * **Every one of those defaults is a claim the family never made.** A missing
49
+ * `coolantThrough` becoming `false` ships a through-coolant drill with its
50
+ * coolant presets dropped; a missing `bmc` becoming `''` ships a tool with no
51
+ * cutting material at all. Refusing names the family and the key, at the one
52
+ * place that can fix it — the config table.
53
+ *
54
+ * Same move `scrape.pause` and `conventions.CAD_COLUMN` already made, and for
55
+ * the same reason: a core concern does not live in one manufacturer's folder.
56
+ */
57
+ export function fact(family, key, value) {
58
+ if (value === undefined) {
59
+ throw new ScraperConfigError(family.id, `a ${family.kind} family must state ${key} as a fact`);
60
+ }
61
+ return value;
62
+ }
35
63
  /** The brand that published a family, defaulting as the catalog does. */
36
64
  export function familyBrand(cfg) {
37
65
  return cfg.brand ?? 'kennametal';
@@ -77,6 +77,24 @@ export declare const BRANDS: {
77
77
  readonly vendor: "Destiny Tool";
78
78
  readonly productLink: "https://destinytool.com/products?search={material}";
79
79
  };
80
+ readonly harvey: {
81
+ readonly host: "harveytool.com";
82
+ readonly home: "https://www.harveytool.com";
83
+ readonly vendor: "Harvey Tool";
84
+ readonly productLink: "https://www.harveytool.com/products/tool-details-{material}";
85
+ };
86
+ readonly maritool: {
87
+ readonly host: "maritool.com";
88
+ readonly home: "https://www.maritool.com";
89
+ readonly vendor: "MariTool";
90
+ readonly productLink: "https://www.maritool.com/advanced_search_result.php?keywords={material}";
91
+ };
92
+ readonly emuge: {
93
+ readonly host: "www.emuge-franken-group.com";
94
+ readonly home: "https://www.emuge-franken-group.com";
95
+ readonly vendor: "EMUGE-FRANKEN";
96
+ readonly productLink: "https://www.emuge-franken-group.com/us/en/p/{material}";
97
+ };
80
98
  };
81
99
  /**
82
100
  * The brands this package knows, as a type.
package/dist/identity.js CHANGED
@@ -65,6 +65,52 @@ export const BRANDS = {
65
65
  vendor: 'Destiny Tool',
66
66
  productLink: 'https://destinytool.com/products?search={material}',
67
67
  },
68
+ // Harvey Tool is the first brand here whose per-part link is a real page the
69
+ // vendor already publishes: every tool number in a product table is rendered
70
+ // as `<a href="/products/tool-details-14916">`, so the template below is the
71
+ // vendor's own URL rather than a search standing in for one. The 26 parts
72
+ // whose table cell carries no link are the exception, and the link is still
73
+ // the right thing to offer for them — see `docs/HARVEY_PRODUCT_TABLE.md` §3.
74
+ harvey: {
75
+ host: 'harveytool.com',
76
+ home: 'https://www.harveytool.com',
77
+ vendor: 'Harvey Tool',
78
+ productLink: 'https://www.harveytool.com/products/tool-details-{material}',
79
+ },
80
+ // MariTool is an osCommerce-family storefront, and its per-part page is
81
+ // addressable three ways: `product_info.php?products_id=100`, the
82
+ // SEO-rewritten `/p100/<slug>/product_info.html`, and a keyword search. The
83
+ // first two are keyed on an internal store id rather than on the part
84
+ // number, and that id is the one thing about a MariTool part that is not
85
+ // stable — a re-created product changes it. So the link is a search for the
86
+ // part number, the same call REGO-FIX and Destiny Tool got, and here it is a
87
+ // search the vendor really answers: `advanced_search_result.php?keywords=`
88
+ // is the storefront's own search endpoint and a part number matches exactly
89
+ // one product (JG 2026-08-29).
90
+ //
91
+ // No `node`: that key is Kennametal's AEM platform and nothing else.
92
+ maritool: {
93
+ host: 'maritool.com',
94
+ home: 'https://www.maritool.com',
95
+ vendor: 'MariTool',
96
+ productLink: 'https://www.maritool.com/advanced_search_result.php?keywords={material}',
97
+ },
98
+ // EMUGE-FRANKEN is a SAP Commerce storefront whose pages carry no product
99
+ // data at all — the Vue front end reads a JSON API, and that API is what the
100
+ // adapter talks to. See `docs/EMUGE_FRANKEN_COMMERCE_API.md`.
101
+ //
102
+ // The per-part link is the vendor's own: every variant record answers with
103
+ // `url: "/us/en/p/<18-digit material number>"`, so this is that page rather
104
+ // than a search standing in for one. `www.emuge-franken-group.com` is the
105
+ // group site serving every region; `/us/en/` is the US storefront the
106
+ // `emugefrankenUSA` base site the scrape reads corresponds to, so the link
107
+ // and the data are the same catalog (JG 2026-09-01).
108
+ emuge: {
109
+ host: 'www.emuge-franken-group.com',
110
+ home: 'https://www.emuge-franken-group.com',
111
+ vendor: 'EMUGE-FRANKEN',
112
+ productLink: 'https://www.emuge-franken-group.com/us/en/p/{material}',
113
+ },
68
114
  };
69
115
  /**
70
116
  * The two brands on Kennametal's AEM platform.
package/dist/index.d.ts CHANGED
@@ -19,12 +19,28 @@
19
19
  * that goes beside one, is `@toolpath/tool-scraper/node` — a separate entry
20
20
  * point, because a backend embedding this wants the data and a maintainer
21
21
  * running the CLI wants the file, and only one of those two needs `fs`.
22
+ *
23
+ * ## Cutting tools become records; toolholding is rows
24
+ *
25
+ * **`ToolRecord` is the uniform output for cutting tools, and only for those.**
26
+ * `registry.toRecords` maps one cutting-tool family's scrape onto it, through
27
+ * the adapter its brand binds. Holders and collets have no record type and no
28
+ * mapper: REGO-FIX and MariTool ship toolholding and nothing else, so a scrape
29
+ * of either ends at rows and a receipt, and a consumer that wants a holder
30
+ * reads the CSV's own columns — the shared ones are named in `conventions`.
31
+ *
32
+ * That is a real gap rather than a design: `identity.recordGuid` already exists
33
+ * so that a holder and a tool can be minted into one guid space, and nothing
34
+ * mints a holder yet. Until something does, `toRecords` is the tool half and
35
+ * the receipt is the whole of the toolholding half.
22
36
  */
37
+ export * from './columns.js';
23
38
  export * from './conventions.js';
24
39
  export * from './errors.js';
25
40
  export * from './family.js';
26
41
  export * from './fetch.js';
27
42
  export * from './identity.js';
43
+ export * from './measure.js';
28
44
  export * from './provenance.js';
29
45
  export * from './records.js';
30
46
  export * from './scrape.js';
package/dist/index.js CHANGED
@@ -19,12 +19,28 @@
19
19
  * that goes beside one, is `@toolpath/tool-scraper/node` — a separate entry
20
20
  * point, because a backend embedding this wants the data and a maintainer
21
21
  * running the CLI wants the file, and only one of those two needs `fs`.
22
+ *
23
+ * ## Cutting tools become records; toolholding is rows
24
+ *
25
+ * **`ToolRecord` is the uniform output for cutting tools, and only for those.**
26
+ * `registry.toRecords` maps one cutting-tool family's scrape onto it, through
27
+ * the adapter its brand binds. Holders and collets have no record type and no
28
+ * mapper: REGO-FIX and MariTool ship toolholding and nothing else, so a scrape
29
+ * of either ends at rows and a receipt, and a consumer that wants a holder
30
+ * reads the CSV's own columns — the shared ones are named in `conventions`.
31
+ *
32
+ * That is a real gap rather than a design: `identity.recordGuid` already exists
33
+ * so that a holder and a tool can be minted into one guid space, and nothing
34
+ * mints a holder yet. Until something does, `toRecords` is the tool half and
35
+ * the receipt is the whole of the toolholding half.
22
36
  */
37
+ export * from './columns.js';
23
38
  export * from './conventions.js';
24
39
  export * from './errors.js';
25
40
  export * from './family.js';
26
41
  export * from './fetch.js';
27
42
  export * from './identity.js';
43
+ export * from './measure.js';
28
44
  export * from './provenance.js';
29
45
  export * from './records.js';
30
46
  export * from './scrape.js';