@toolpath/tool-scraper 2.3.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -17,14 +17,14 @@ 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 |
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 |
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 cutting and forming |
28
28
 
29
29
  ## Two entry points
30
30
 
@@ -77,6 +77,7 @@ toolpath-scrape harvey --catalog # what the four category trees
77
77
  toolpath-scrape maritool maritool_cat40_holders.csv # its leaf categories come from its config
78
78
  toolpath-scrape maritool --catalog # what the five taper trees hold today
79
79
  toolpath-scrape emuge emuge_drills.csv # its category and unit come from its config
80
+ toolpath-scrape emuge emuge_form_taps.csv # FG02, the cold-forming taps
80
81
  ```
81
82
 
82
83
  `toolpath-scrape --help` lists the rest.
@@ -99,6 +100,15 @@ here. Every Harvey record is `unspecified`: Harvey's material index is published
99
100
  than in a variant table, and a scrape cannot reach it — see
100
101
  [`docs/HARVEY_PRODUCT_TABLE.md`](docs/HARVEY_PRODUCT_TABLE.md) §1.5.1.
101
102
 
103
+ `threadMethod` is `cutting` or `forming` on a tap and `null` on everything else,
104
+ because the question does not apply to a drill. It is the one field that
105
+ separates a thread former from a cut tap: the two are the same `DC`, `TP`,
106
+ `SFDM`, `OAL` and `LCF`, often the same substrate and coating, and a shop drills
107
+ a larger hole before one than before the other. Neither vendor publishes it in a
108
+ variant table — Kennametal states it in a `newTapType` facet and EMUGE in the
109
+ split between its `FG01` and `FG02` categories — so it is a per-family fact, and
110
+ each family cites the index it was read off.
111
+
102
112
  Every per-family constant no vendor table states carries its provenance — whether it was
103
113
  vendor-stated, derived or assumed, and by whom on what date. The types enforce it: an assumed fact
104
114
  without a note, a date and initials does not compile.
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * EMUGE-FRANKEN's families.
3
3
  *
4
- * Four, one per catalog category crossed with the unit system the category is
4
+ * Five, one per catalog category crossed with the unit system the category is
5
5
  * published in. That is coarser than the vendor's own marketing, which splits
6
6
  * end mills fifteen ways by product line — TOP-Cut, Hard-Cut, Alu-Cut — and it
7
7
  * is deliberate: EMUGE states the product line, the cutting material, the
@@ -12,8 +12,22 @@
12
12
  * `PRODUCT_LINE_COLUMNS` reads one per part onto `ToolRecord.productLine`, from
13
13
  * a column every scrape already writes.
14
14
  *
15
- * So the only fact three of these four state is `unit`, and the fourth adds the
16
- * two a drill record cannot be built without.
15
+ * So the only fact the two milling families state is `unit`; the drill family
16
+ * adds the two a drill record cannot be built without, and each tap family adds
17
+ * how its taps make a thread — see `MACHINE_TAP` and `COLD_FORMING_TAP`.
18
+ *
19
+ * ## The tapping split, which is not a unit split
20
+ *
21
+ * Tapping is the one category EMUGE publishes as **two**: `FG01`, which it
22
+ * titles `Machine taps`, and `FG02`, `Cold forming tap`. They are the same
23
+ * three calls against the same column labels and differ only in what the tools
24
+ * do — one cuts the thread away, the other displaces material into it — so
25
+ * they are two families and not two adapters, and the category is what each
26
+ * one's `threadMethod` fact cites.
27
+ *
28
+ * `FG02` went unscraped until 2026-09-07, which meant `emuge_taps.csv` was the
29
+ * whole of this package's tapping corpus and every row in it was a cutting tap
30
+ * with nothing recording that it was.
17
31
  *
18
32
  * ## `rows`
19
33
  *
@@ -78,6 +92,9 @@ export declare const SCRAPE_TARGETS: {
78
92
  readonly 'emuge_taps.csv': {
79
93
  readonly category: "FG01";
80
94
  };
95
+ readonly 'emuge_form_taps.csv': {
96
+ readonly category: "FG02";
97
+ };
81
98
  };
82
99
  export declare const FAMILIES: {
83
100
  readonly 'emuge_end_mills_inch.csv': {
@@ -180,6 +197,37 @@ export declare const FAMILIES: {
180
197
  readonly source: "vendor-stated";
181
198
  readonly cite: "every tap dimension is published in millimetres whatever the thread standard — a `#4-40 UNC` tap states `nominal diameter d₁ [mm]` as `2.845 mm`, `pitch [mm]` as `0.635 mm`, and its shank and lengths in `mm` — with `thread symbol`, `nominal size` and `threads per inch` carrying the inch designation beside them";
182
199
  };
200
+ readonly threadMethod: {
201
+ readonly value: "cutting";
202
+ readonly source: "vendor-stated";
203
+ readonly cite: "the vendor's own category `FG01`, which it titles `Machine taps`; and independently every one of its 414 grouped products states a `chamfer form` and none states a `lead taper form` (JG 2026-09-07)";
204
+ };
205
+ };
206
+ };
207
+ readonly 'emuge_form_taps.csv': {
208
+ readonly id: "form-taps";
209
+ readonly brand: "emuge";
210
+ readonly kind: "tap";
211
+ readonly familyCode: "FG02";
212
+ readonly columns: {
213
+ readonly DC: "nominal diameter d₁";
214
+ readonly SFDM: "Shank diameter d₂";
215
+ readonly OAL: "Overall length l₁";
216
+ readonly LCF: "length of cutting edge l₂";
217
+ readonly TP: "pitch";
218
+ };
219
+ readonly rows: 1432;
220
+ readonly facts: {
221
+ readonly unit: {
222
+ readonly value: "millimeters";
223
+ readonly source: "vendor-stated";
224
+ readonly cite: "every tap dimension is published in millimetres whatever the thread standard — a `#4-40 UNC` tap states `nominal diameter d₁ [mm]` as `2.845 mm`, `pitch [mm]` as `0.635 mm`, and its shank and lengths in `mm` — with `thread symbol`, `nominal size` and `threads per inch` carrying the inch designation beside them";
225
+ };
226
+ readonly threadMethod: {
227
+ readonly value: "forming";
228
+ readonly source: "vendor-stated";
229
+ readonly cite: "the vendor's own category `FG02`, which it titles `Cold forming tap`; and independently every one of its 137 grouped products states a `lead taper form` and none states a `chamfer form` (JG 2026-09-07)";
230
+ };
183
231
  };
184
232
  };
185
233
  };
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * EMUGE-FRANKEN's families.
3
3
  *
4
- * Four, one per catalog category crossed with the unit system the category is
4
+ * Five, one per catalog category crossed with the unit system the category is
5
5
  * published in. That is coarser than the vendor's own marketing, which splits
6
6
  * end mills fifteen ways by product line — TOP-Cut, Hard-Cut, Alu-Cut — and it
7
7
  * is deliberate: EMUGE states the product line, the cutting material, the
@@ -12,8 +12,22 @@
12
12
  * `PRODUCT_LINE_COLUMNS` reads one per part onto `ToolRecord.productLine`, from
13
13
  * a column every scrape already writes.
14
14
  *
15
- * So the only fact three of these four state is `unit`, and the fourth adds the
16
- * two a drill record cannot be built without.
15
+ * So the only fact the two milling families state is `unit`; the drill family
16
+ * adds the two a drill record cannot be built without, and each tap family adds
17
+ * how its taps make a thread — see `MACHINE_TAP` and `COLD_FORMING_TAP`.
18
+ *
19
+ * ## The tapping split, which is not a unit split
20
+ *
21
+ * Tapping is the one category EMUGE publishes as **two**: `FG01`, which it
22
+ * titles `Machine taps`, and `FG02`, `Cold forming tap`. They are the same
23
+ * three calls against the same column labels and differ only in what the tools
24
+ * do — one cuts the thread away, the other displaces material into it — so
25
+ * they are two families and not two adapters, and the category is what each
26
+ * one's `threadMethod` fact cites.
27
+ *
28
+ * `FG02` went unscraped until 2026-09-07, which meant `emuge_taps.csv` was the
29
+ * whole of this package's tapping corpus and every row in it was a cutting tap
30
+ * with nothing recording that it was.
17
31
  *
18
32
  * ## `rows`
19
33
  *
@@ -53,6 +67,74 @@ export const SCRAPE_TARGETS = {
53
67
  },
54
68
  'emuge_drills.csv': { category: 'FB01' },
55
69
  'emuge_taps.csv': { category: 'FG01' },
70
+ 'emuge_form_taps.csv': { category: 'FG02' },
71
+ };
72
+ /**
73
+ * Tapping geometry, identical either side of the cutting/forming split.
74
+ *
75
+ * **EMUGE labels a cold-forming tap's lead `length of cutting edge l₂` too**,
76
+ * and it reaches the CSV under that label, because a scraped column keeps the
77
+ * vendor's own name — `conventions.ts` opens on the reason. A former has no
78
+ * cutting edge and the label is wrong about it; renaming it here would swap one
79
+ * vendor's inaccuracy for this package's invention, and the record's `LCF` is
80
+ * the canonical name either way.
81
+ */
82
+ const TAP_COLUMNS = {
83
+ DC: 'nominal diameter d₁',
84
+ SFDM: 'Shank diameter d₂',
85
+ OAL: 'Overall length l₁',
86
+ LCF: 'length of cutting edge l₂',
87
+ TP: 'pitch',
88
+ };
89
+ /**
90
+ * Which of EMUGE's two tap categories a family scrapes, and how it is known.
91
+ *
92
+ * The vendor splits its taps into two categories and scraping either one is
93
+ * already the answer — but the *parts* say so as well, independently, which is
94
+ * what makes these `vendor-stated` rather than a claim about a URL. Every
95
+ * grouped product carries a flat `technicalDetails` list, and:
96
+ *
97
+ * - all 414 `FG01` groups state `chamfer form` and none states `lead taper
98
+ * form`;
99
+ * - all 137 `FG02` groups state `lead taper form` and none states `chamfer
100
+ * form`.
101
+ *
102
+ * Mutually exclusive, both directions, at full coverage (JG 2026-09-07). A
103
+ * chamfer is ground onto a tap that cuts and a lead taper is rolled onto one
104
+ * that forms, so the vendor is naming the same distinction twice.
105
+ * `tests/emuge-corpus.test.ts` re-checks that agreement against a real scrape,
106
+ * which is what keeps these two constants honest without either family reading
107
+ * a column.
108
+ *
109
+ * **Two things that look like this discriminator and are not**, recorded so
110
+ * nobody reaches for them: `Geometry` — the column `vendors/emuge/records.ts`
111
+ * reads as the product line — takes `AL`, `GAL`, `H`, `MULTI`, `SPEED`,
112
+ * `STEEL`, `VA` and `Z` in *both* categories; and the 189 `FG01` variants whose
113
+ * `flute characteristic` is `without` are six EMUGE *Robust* groups, reinforced
114
+ * cutting taps that still state a `chamfer form`.
115
+ */
116
+ /**
117
+ * The unit both tap families are published in.
118
+ *
119
+ * Shared because the vendor's rule is about tapping and not about either
120
+ * category: a `#4-40 UNC` tap states millimetres whichever of the two it sits
121
+ * in, which is the same thing the module note says about there being no unit
122
+ * facet on tapping at all.
123
+ */
124
+ const TAP_MILLIMETERS = {
125
+ value: 'millimeters',
126
+ source: 'vendor-stated',
127
+ cite: 'every tap dimension is published in millimetres whatever the thread standard — a `#4-40 UNC` tap states `nominal diameter d₁ [mm]` as `2.845 mm`, `pitch [mm]` as `0.635 mm`, and its shank and lengths in `mm` — with `thread symbol`, `nominal size` and `threads per inch` carrying the inch designation beside them',
128
+ };
129
+ const MACHINE_TAP = {
130
+ value: 'cutting',
131
+ source: 'vendor-stated',
132
+ cite: "the vendor's own category `FG01`, which it titles `Machine taps`; and independently every one of its 414 grouped products states a `chamfer form` and none states a `lead taper form` (JG 2026-09-07)",
133
+ };
134
+ const COLD_FORMING_TAP = {
135
+ value: 'forming',
136
+ source: 'vendor-stated',
137
+ cite: "the vendor's own category `FG02`, which it titles `Cold forming tap`; and independently every one of its 137 grouped products states a `lead taper form` and none states a `chamfer form` (JG 2026-09-07)",
56
138
  };
57
139
  /** Milling geometry, identical either side of the unit split. */
58
140
  const MILLING_COLUMNS = {
@@ -144,20 +226,28 @@ export const FAMILIES = {
144
226
  // `records.DIMENSIONAL_COLUMNS` excluding it means: the vendor publishes
145
227
  // `pitch [mm]` and this family is millimetres, so the column is already in
146
228
  // the record's native unit.
147
- columns: {
148
- DC: 'nominal diameter d₁',
149
- SFDM: 'Shank diameter d₂',
150
- OAL: 'Overall length l₁',
151
- LCF: 'length of cutting edge l₂',
152
- TP: 'pitch',
153
- },
229
+ columns: TAP_COLUMNS,
154
230
  rows: 11566,
155
231
  facts: {
156
- unit: {
157
- value: 'millimeters',
158
- source: 'vendor-stated',
159
- cite: 'every tap dimension is published in millimetres whatever the thread standard — a `#4-40 UNC` tap states `nominal diameter d₁ [mm]` as `2.845 mm`, `pitch [mm]` as `0.635 mm`, and its shank and lengths in `mm` — with `thread symbol`, `nominal size` and `threads per inch` carrying the inch designation beside them',
160
- },
232
+ unit: TAP_MILLIMETERS,
233
+ threadMethod: MACHINE_TAP,
234
+ },
235
+ },
236
+ // The second half of the vendor's own tapping catalog, and the only forming
237
+ // taps this package reaches. It scrapes the same three calls as `FG01`
238
+ // against the same column labels — see `TAP_COLUMNS` — so it is a family
239
+ // rather than an adapter: what differs is the category, and what the
240
+ // category settles.
241
+ 'emuge_form_taps.csv': {
242
+ id: 'form-taps',
243
+ brand: 'emuge',
244
+ kind: 'tap',
245
+ familyCode: 'FG02',
246
+ columns: TAP_COLUMNS,
247
+ rows: 1432,
248
+ facts: {
249
+ unit: TAP_MILLIMETERS,
250
+ threadMethod: COLD_FORMING_TAP,
161
251
  },
162
252
  },
163
253
  };
@@ -237,6 +237,11 @@ export declare const FAMILIES: {
237
237
  readonly checked: "2026-08-29";
238
238
  readonly by: "JG";
239
239
  };
240
+ readonly threadMethod: {
241
+ readonly value: "cutting";
242
+ readonly source: "vendor-stated";
243
+ readonly cite: "the vendor's own `newTapType` facet on the variants endpoint: every row of this family answers `:relevance:obsoleteFacet:false:newTapType:11-Spiral Point Tap`, and `3-Forming Tap` — the one forming value of the six — returns the no-results notice (JG 2026-09-07)";
244
+ };
240
245
  };
241
246
  };
242
247
  readonly 'khsst_hand_metric_plug.csv': {
@@ -265,6 +270,11 @@ export declare const FAMILIES: {
265
270
  readonly checked: "2026-08-29";
266
271
  readonly by: "JG";
267
272
  };
273
+ readonly threadMethod: {
274
+ readonly value: "cutting";
275
+ readonly source: "vendor-stated";
276
+ readonly cite: "the same `newTapType` facet: every row of this family answers `2-Hand Tap`, and `3-Forming Tap`, `8-Spiral Flute Tap`, `10-Pipe Tap` and `11-Spiral Point Tap` each return the no-results notice (JG 2026-09-07)";
277
+ };
268
278
  };
269
279
  };
270
280
  readonly 'spiral_point_metric_plug.csv': {
@@ -293,6 +303,11 @@ export declare const FAMILIES: {
293
303
  readonly checked: "2026-08-29";
294
304
  readonly by: "JG";
295
305
  };
306
+ readonly threadMethod: {
307
+ readonly value: "cutting";
308
+ readonly source: "vendor-stated";
309
+ readonly cite: "the vendor's own `newTapType` facet on the variants endpoint: every row of this family answers `:relevance:obsoleteFacet:false:newTapType:11-Spiral Point Tap`, and `3-Forming Tap` — the one forming value of the six — returns the no-results notice (JG 2026-09-07)";
310
+ };
296
311
  };
297
312
  };
298
313
  readonly 'gomill_pro_radiused_4fl_necked_metric.csv': {
@@ -73,6 +73,40 @@ const NO_COOLANT_THROUGH_TAP = {
73
73
  checked: '2026-08-29',
74
74
  by: 'JG',
75
75
  };
76
+ /**
77
+ * The tap-type facet, and what it settles.
78
+ *
79
+ * **The variant table does not carry it.** All three tap families publish
80
+ * `D1-TDZ`, `Thread Tolerance Class ANSI`, `Tap Pitch Diameter Limit` and
81
+ * `Type of Thread` — the thread's *class*, never how it is produced. What does
82
+ * carry it is the vendor's own `newTapType` Solr facet, and it narrows the same
83
+ * `.variants.<code>.html` endpoint `vendors/kennametal/scrape.ts` already
84
+ * calls: `scrape.ACTIVE_ONLY` is one facet on that query and this is a second,
85
+ * exactly as `vendors/kennametal/materials.ts` appends one.
86
+ *
87
+ * Its vocabulary, read off the threading category listing on 2026-09-07, is
88
+ * `2-Hand Tap`, `3-Forming Tap`, `8-Spiral Flute Tap`, `10-Pipe Tap`,
89
+ * `11-Spiral Point Tap` and `13-Straight Flute Tap`. Exactly one of the six is
90
+ * a forming tap; the other five cut. So a family that answers any value but
91
+ * `3-Forming Tap` is a cutting family, and each of the three below was probed
92
+ * for its own value **and** for `3-Forming Tap`, which returned the vendor's
93
+ * no-results notice every time.
94
+ *
95
+ * That is why these are `vendor-stated` and not `assumed`: the cite is a query
96
+ * anybody can re-run, and the *negative* half of it is the part that matters.
97
+ * A `cite` naming only the CSV's own filename would be this table reading its
98
+ * own name back to itself.
99
+ */
100
+ const SPIRAL_POINT_TAP = {
101
+ value: 'cutting',
102
+ source: 'vendor-stated',
103
+ cite: "the vendor's own `newTapType` facet on the variants endpoint: every row of this family answers `:relevance:obsoleteFacet:false:newTapType:11-Spiral Point Tap`, and `3-Forming Tap` — the one forming value of the six — returns the no-results notice (JG 2026-09-07)",
104
+ };
105
+ const HAND_TAP = {
106
+ value: 'cutting',
107
+ source: 'vendor-stated',
108
+ cite: 'the same `newTapType` facet: every row of this family answers `2-Hand Tap`, and `3-Forming Tap`, `8-Spiral Flute Tap`, `10-Pipe Tap` and `11-Spiral Point Tap` each return the no-results notice (JG 2026-09-07)',
109
+ };
76
110
  const HSS_ASSUMED = {
77
111
  value: 'hss',
78
112
  source: 'assumed',
@@ -307,6 +341,7 @@ export const FAMILIES = {
307
341
  facts: {
308
342
  bmc: HSS_ASSUMED,
309
343
  coolantThrough: NO_COOLANT_THROUGH_TAP,
344
+ threadMethod: SPIRAL_POINT_TAP,
310
345
  },
311
346
  },
312
347
  'khsst_hand_metric_plug.csv': {
@@ -318,6 +353,7 @@ export const FAMILIES = {
318
353
  facts: {
319
354
  bmc: HSS_ASSUMED,
320
355
  coolantThrough: NO_COOLANT_THROUGH_TAP,
356
+ threadMethod: HAND_TAP,
321
357
  },
322
358
  },
323
359
  'spiral_point_metric_plug.csv': {
@@ -329,6 +365,7 @@ export const FAMILIES = {
329
365
  facts: {
330
366
  bmc: HSS_ASSUMED,
331
367
  coolantThrough: NO_COOLANT_THROUGH_TAP,
368
+ threadMethod: SPIRAL_POINT_TAP,
332
369
  },
333
370
  },
334
371
  'gomill_pro_radiused_4fl_necked_metric.csv': {
@@ -46,6 +46,37 @@
46
46
  *
47
47
  * ## What is not scraped
48
48
  *
49
+ * **The taps are not scraped, and the evidence for when they are is here.**
50
+ * MariTool sells taps under `c78_148`, and its tree already answers the one
51
+ * question a tap record needs that no vendor of ours states in a column —
52
+ * whether the tap cuts its thread or forms it. Only the forming branch carries
53
+ * the word, so the leaf name settles it (JG 2026-09-07):
54
+ *
55
+ * | Leaf | cPath | Method |
56
+ * | ----------------------- | ------------------ | --------- |
57
+ * | Thread Forming Taps | `78_148_274` | `forming` |
58
+ * | — Plug Form Tap | `78_148_274_275` | `forming` |
59
+ * | — Bottoming Form Tap | `78_148_274_276` | `forming` |
60
+ * | Spiral Flute Taps | `78_148_149` | `cutting` |
61
+ * | Spiral Point Taps | `78_148_224` | `cutting` |
62
+ * | Taps for Aluminum | `78_148_271` | `cutting` |
63
+ * | Taper Pipe Taps | `78_148_283` | `cutting` |
64
+ * | DIN Length HPT Taps | `78_148_284` | `cutting` |
65
+ *
66
+ * **Checked into a table when it is built, not run as a filter**, for the
67
+ * reason the ER leaves above already give: a rule matching a name drops
68
+ * silently when the vendor renames a category, and three leaves here are
69
+ * already named something other than what they hold. `maritool --catalog` walks
70
+ * the tree, which is how the rule gets re-checked rather than trusted.
71
+ *
72
+ * **What still has to be decided before a tap family lands.** A MariTool tap
73
+ * page publishes `Shank Size`, `Total Length`, `Thread Length` and `Size` —
74
+ * enough for `SFDM`, `OAL`, `LCF` and a designation `thread.ts` can already
75
+ * parse — but a metric tap states `Size: M3` and `Pitch Diameter: 0.5` against
76
+ * a body in inches (`Shank Size: 0.141`, `Total Length: 1 15/16`). No family in
77
+ * this package is mixed that way and `ToolRecord.unit` is one value, so that is
78
+ * a decision on its own merits and not a widening of this table.
79
+ *
49
80
  * **BT50 and ISO30 produce no CSV.** MariTool's only BT50 category is end mill
50
81
  * holders, which is out of scope; ISO30 has three ER parts and none of them
51
82
  * publishes a spec table, so the family would be a receipt of nothing.
@@ -46,6 +46,37 @@
46
46
  *
47
47
  * ## What is not scraped
48
48
  *
49
+ * **The taps are not scraped, and the evidence for when they are is here.**
50
+ * MariTool sells taps under `c78_148`, and its tree already answers the one
51
+ * question a tap record needs that no vendor of ours states in a column —
52
+ * whether the tap cuts its thread or forms it. Only the forming branch carries
53
+ * the word, so the leaf name settles it (JG 2026-09-07):
54
+ *
55
+ * | Leaf | cPath | Method |
56
+ * | ----------------------- | ------------------ | --------- |
57
+ * | Thread Forming Taps | `78_148_274` | `forming` |
58
+ * | — Plug Form Tap | `78_148_274_275` | `forming` |
59
+ * | — Bottoming Form Tap | `78_148_274_276` | `forming` |
60
+ * | Spiral Flute Taps | `78_148_149` | `cutting` |
61
+ * | Spiral Point Taps | `78_148_224` | `cutting` |
62
+ * | Taps for Aluminum | `78_148_271` | `cutting` |
63
+ * | Taper Pipe Taps | `78_148_283` | `cutting` |
64
+ * | DIN Length HPT Taps | `78_148_284` | `cutting` |
65
+ *
66
+ * **Checked into a table when it is built, not run as a filter**, for the
67
+ * reason the ER leaves above already give: a rule matching a name drops
68
+ * silently when the vendor renames a category, and three leaves here are
69
+ * already named something other than what they hold. `maritool --catalog` walks
70
+ * the tree, which is how the rule gets re-checked rather than trusted.
71
+ *
72
+ * **What still has to be decided before a tap family lands.** A MariTool tap
73
+ * page publishes `Shank Size`, `Total Length`, `Thread Length` and `Size` —
74
+ * enough for `SFDM`, `OAL`, `LCF` and a designation `thread.ts` can already
75
+ * parse — but a metric tap states `Size: M3` and `Pitch Diameter: 0.5` against
76
+ * a body in inches (`Shank Size: 0.141`, `Total Length: 1 15/16`). No family in
77
+ * this package is mixed that way and `ToolRecord.unit` is one value, so that is
78
+ * a decision on its own merits and not a widening of this table.
79
+ *
49
80
  * **BT50 and ISO30 produce no CSV.** MariTool's only BT50 category is end mill
50
81
  * holders, which is out of scope; ISO30 has three ER parts and none of them
51
82
  * publishes a spec table, so the family would be a receipt of nothing.
package/dist/family.d.ts CHANGED
@@ -20,12 +20,12 @@ import type { UnitSystem } from './conventions.js';
20
20
  import type { BrandName } from './identity.js';
21
21
  import type { HoldingMapper, ToolholdingKind } from './holding.js';
22
22
  import type { Fact } from './provenance.js';
23
- import type { ColumnMap, ToolKind, ToolRecord } from './records.js';
23
+ import type { ColumnMap, ThreadMethod, ToolKind, ToolRecord } from './records.js';
24
24
  import type { MapperOptions, ScrapedRow } from './scrape.js';
25
25
  /**
26
26
  * The per-family constants a fact can carry, and their types.
27
27
  *
28
- * Eleven keys, which is the whole vocabulary the catalog uses. Naming them rather
28
+ * Twelve keys, which is the whole vocabulary the catalog uses. Naming them rather
29
29
  * than accepting any string is what lets a mapper read `family.coolantThrough`
30
30
  * as a `boolean` instead of casting an `unknown` out of a bag — and what makes
31
31
  * a fact whose value is the wrong type a compile error where the family is
@@ -49,6 +49,20 @@ export interface FamilyFacts {
49
49
  */
50
50
  profile?: string;
51
51
  coolantThrough?: boolean;
52
+ /**
53
+ * How a tap makes its thread — `cutting` or `forming`.
54
+ *
55
+ * A fact and not a column because neither vendor publishes it per part: it is
56
+ * Kennametal's `newTapType` facet, which the variant table does not carry,
57
+ * and EMUGE's category, which partitions its taps into two. Every declaration
58
+ * in `families/` cites the index it was read off, and each is one value for a
59
+ * whole family — checked against the vendor rather than assumed, which is why
60
+ * they are `vendor-stated`.
61
+ *
62
+ * Every `kind: 'tap'` family must state it: `tapRecord` reads it through
63
+ * {@link fact}, and `records.toolRecord` refuses a tap record without one.
64
+ */
65
+ threadMethod?: ThreadMethod;
52
66
  flutes?: number;
53
67
  /** Degrees included. */
54
68
  pointAngle?: number;
@@ -75,7 +75,7 @@ export declare const BRANDS: {
75
75
  readonly host: "destinytool.com";
76
76
  readonly home: "https://destinytool.com";
77
77
  readonly vendor: "Destiny Tool";
78
- readonly productLink: "https://destinytool.com/products?search={material}";
78
+ readonly productLink: "https://destinytool.com/products/{material}";
79
79
  };
80
80
  readonly harvey: {
81
81
  readonly host: "harveytool.com";
package/dist/identity.js CHANGED
@@ -54,16 +54,13 @@ export const BRANDS = {
54
54
  productLink: 'https://us.rego-fix.com/en/productfinder?q={material}',
55
55
  },
56
56
  // Destiny Tool is a Next.js SPA with no product data in the HTML at all — it
57
- // reads live from a Firestore database. Like REGO-FIX there is no per-part
58
- // page to link to, only a client-rendered products list; unlike REGO-FIX's
59
- // ProductFinder, nothing here has confirmed a search query parameter the SPA
60
- // actually reads, so this is a best-effort link to the listing page rather
61
- // than a verified deep link (JG 2026-08-19).
57
+ // reads live from a Firestore database. Its client-rendered product page is
58
+ // addressable by the vendor's item number in the path.
62
59
  destinytool: {
63
60
  host: 'destinytool.com',
64
61
  home: 'https://destinytool.com',
65
62
  vendor: 'Destiny Tool',
66
- productLink: 'https://destinytool.com/products?search={material}',
63
+ productLink: 'https://destinytool.com/products/{material}',
67
64
  },
68
65
  // Harvey Tool is the first brand here whose per-part link is a real page the
69
66
  // vendor already publishes: every tool number in a product table is rendered
package/dist/records.d.ts CHANGED
@@ -42,7 +42,7 @@
42
42
  * always a copy is not a second measurement. An adapter that could supply them
43
43
  * separately could supply a tool that claims a holder it does not have.
44
44
  */
45
- import { type UnitSystem } from '@toolpath/tool-support';
45
+ import { type ThreadMethod, type UnitSystem } from '@toolpath/tool-support';
46
46
  import { type BrandName } from './identity.js';
47
47
  import type { FactSource } from './provenance.js';
48
48
  /**
@@ -69,6 +69,16 @@ export interface GeometryField {
69
69
  */
70
70
  iso: string | null;
71
71
  }
72
+ /**
73
+ * How a tap makes its thread.
74
+ *
75
+ * `@toolpath/tool-support`'s, re-exported under the name this package reads it
76
+ * by — the move `conventions.ts` makes for `UnitSystem` and `provenance.ts` for
77
+ * `PROVENANCE`. A scrape originates the fact and the domain owns the
78
+ * vocabulary, and two declarations of the same two strings is the drift that
79
+ * rule exists to prevent.
80
+ */
81
+ export type { ThreadMethod } from '@toolpath/tool-support';
72
82
  /** The kinds of cutting tool this package maps. */
73
83
  export type ToolKind = 'drill' | 'tap' | 'endmill';
74
84
  /**
@@ -447,6 +457,23 @@ export interface ToolRecord {
447
457
  * a PCD tool.
448
458
  */
449
459
  readonly nonFerrous: boolean | null;
460
+ /**
461
+ * Taps only, and `null` on every other kind because the question does not
462
+ * apply — the shape {@link ToolRecord.nonFerrous} already keeps for drills.
463
+ *
464
+ * **Not a default, and not derivable from anything else on the record.** A
465
+ * former and a cut tap of the same size share their `DC`, `TP`, `SFDM`,
466
+ * `OAL` and `LCF`; what separates them is that one displaces material and the
467
+ * other removes it, which changes the hole a shop drills first and the feed
468
+ * it runs. Guessing `cutting` because most of a catalog is would ship a
469
+ * former with a cut tap's drill size.
470
+ *
471
+ * It is a per-family fact rather than a column, and each of the five
472
+ * declarations cites the vendor's own index — see `families/kennametal.ts`
473
+ * and `families/emuge.ts`. The invariant below is what stops a sixth tap
474
+ * family arriving without one.
475
+ */
476
+ readonly threadMethod: ThreadMethod | null;
450
477
  }
451
478
  /**
452
479
  * Build a {@link ToolRecord}: mint its guid, default the fields that have a
@@ -473,7 +500,7 @@ export interface ToolRecord {
473
500
  * interchange value, and a mapper that mutated one would be reaching back
474
501
  * across the seam this type exists to draw.
475
502
  */
476
- export declare function toolRecord(fields: Omit<ToolRecord, 'guid' | 'materialGroups' | 'materialGroupsSource' | 'nonFerrous' | 'productLine'> & Partial<Pick<ToolRecord, 'materialGroups' | 'materialGroupsSource' | 'nonFerrous' | 'productLine'>>): ToolRecord;
503
+ export declare function toolRecord(fields: Omit<ToolRecord, 'guid' | 'materialGroups' | 'materialGroupsSource' | 'nonFerrous' | 'productLine' | 'threadMethod'> & Partial<Pick<ToolRecord, 'materialGroups' | 'materialGroupsSource' | 'nonFerrous' | 'productLine' | 'threadMethod'>>): ToolRecord;
477
504
  /**
478
505
  * A family's canonical-field → CSV-column-label mapping, validated.
479
506
  *
package/dist/records.js CHANGED
@@ -42,7 +42,7 @@
42
42
  * always a copy is not a second measurement. An adapter that could supply them
43
43
  * separately could supply a tool that claims a holder it does not have.
44
44
  */
45
- import { GEOMETRY_FIELDS as DICTIONARY } from '@toolpath/tool-support';
45
+ import { GEOMETRY_FIELDS as DICTIONARY, } from '@toolpath/tool-support';
46
46
  import { dimensionalColumn } from './conventions.js';
47
47
  import { ScraperConfigError } from './errors.js';
48
48
  import { recordGuid } from './identity.js';
@@ -315,9 +315,21 @@ export function toolRecord(fields) {
315
315
  `and materialGroupsSource is ${JSON.stringify(source)} — ` +
316
316
  `groups are ${JSON.stringify(UNSPECIFIED)} exactly when there are none`);
317
317
  }
318
+ // A tap says how it makes its thread and nothing else does. Both halves are
319
+ // load-bearing: a tap record with no method is a family that never declared
320
+ // the fact, and a method on a drill is a mapper that copied a line from the
321
+ // tap one. Neither can be recovered downstream from what is left on the
322
+ // record, because the geometry of a former and a cut tap is the same
323
+ // geometry.
324
+ const method = fields.threadMethod ?? null;
325
+ if ((fields.kind === 'tap') !== (method !== null)) {
326
+ throw new ScraperConfigError(fields.materialNumber, `a ${fields.kind} record states threadMethod ${JSON.stringify(method)} — ` +
327
+ `a tap says how it makes its thread and no other kind does`);
328
+ }
318
329
  checkGeometry(fields.kind, fields.materialNumber, fields.geometry);
319
330
  return Object.freeze({
320
331
  ...fields,
332
+ threadMethod: method,
321
333
  guid: recordGuid(fields.brand, fields.materialNumber),
322
334
  geometry: Object.freeze({ ...fields.geometry }),
323
335
  materialGroups: groups === null ? null : Object.freeze([...groups]),
@@ -155,6 +155,16 @@ export declare const PRODUCT_LINE_COLUMNS: Readonly<Record<string, string>>;
155
155
  * vendor's marketing rather than a hole here: `SPEED`, `FK`, `GAL`, `GG` and
156
156
  * `TILEG` are real lines with no `/a/` page on the US storefront, so the
157
157
  * honest answer is the vendor's own code until one appears.
158
+ *
159
+ * **`FG02` has no entry at all, on purpose.** Cold-forming taps index by the
160
+ * same eight geometry codes `FG01` uses — `AL`, `GAL`, `H`, `MULTI`, `SPEED`,
161
+ * `STEEL`, `VA`, `Z` — and mean different products by them: a `Z`-geometry
162
+ * former is InnoForm, not the `Rekord B-Z Taps` the `FG01` table would name it.
163
+ * Borrowing that table would put a cutting tap's product line on a forming tap,
164
+ * which is the one error this category split exists to make impossible. So the
165
+ * codes pass through verbatim, which is what the paragraph above already says a
166
+ * code with no article page does, and naming them from the vendor's own
167
+ * cold-forming pages is the follow-up.
158
168
  */
159
169
  export declare const PRODUCT_LINES: Readonly<Record<string, Readonly<Record<string, string>>>>;
160
170
  /**
@@ -172,6 +172,7 @@ export const PRODUCT_LINE_COLUMNS = {
172
172
  FF01: 'product line',
173
173
  FB01: 'Geometry',
174
174
  FG01: 'Geometry',
175
+ FG02: 'Geometry',
175
176
  };
176
177
  /**
177
178
  * A category's product-line codes onto the vendor's own name for each.
@@ -194,6 +195,16 @@ export const PRODUCT_LINE_COLUMNS = {
194
195
  * vendor's marketing rather than a hole here: `SPEED`, `FK`, `GAL`, `GG` and
195
196
  * `TILEG` are real lines with no `/a/` page on the US storefront, so the
196
197
  * honest answer is the vendor's own code until one appears.
198
+ *
199
+ * **`FG02` has no entry at all, on purpose.** Cold-forming taps index by the
200
+ * same eight geometry codes `FG01` uses — `AL`, `GAL`, `H`, `MULTI`, `SPEED`,
201
+ * `STEEL`, `VA`, `Z` — and mean different products by them: a `Z`-geometry
202
+ * former is InnoForm, not the `Rekord B-Z Taps` the `FG01` table would name it.
203
+ * Borrowing that table would put a cutting tap's product line on a forming tap,
204
+ * which is the one error this category split exists to make impossible. So the
205
+ * codes pass through verbatim, which is what the paragraph above already says a
206
+ * code with no article page does, and naming them from the vendor's own
207
+ * cold-forming pages is the follow-up.
197
208
  */
198
209
  export const PRODUCT_LINES = {
199
210
  // `/us/en/multi-drill/a/MultiDRILL`, `/us/en/steeldrill/a/SteelDrill`,
@@ -527,6 +538,7 @@ export function tapRecord(row, family, columns, options = {}) {
527
538
  ...common(row, family, what, warn),
528
539
  kind: 'tap',
529
540
  unit,
541
+ threadMethod: fact(family, 'threadMethod', family.threadMethod),
530
542
  geometry: {
531
543
  DC: required(row, columns, 'DC', unit, what, opts),
532
544
  TP: required(row, columns, 'TP', unit, what, opts),
@@ -247,6 +247,7 @@ export function tapRecord(row, family, columns) {
247
247
  coating: row[COATING] ?? '',
248
248
  ...materialGroups(row),
249
249
  coolantThrough: fact(family, 'coolantThrough', family.coolantThrough),
250
+ threadMethod: fact(family, 'threadMethod', family.threadMethod),
250
251
  geometry: {
251
252
  DC: threadMajorDiameter(tdz, system),
252
253
  TP: require_(row, columns, 'TP', unit, what),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@toolpath/tool-scraper",
3
- "version": "2.3.0",
3
+ "version": "2.4.0",
4
4
  "description": "Scrape cutting-tool geometry from vendor catalogs into records and CSVs",
5
5
  "license": "MIT",
6
6
  "engines": {
@@ -83,7 +83,7 @@
83
83
  ],
84
84
  "dependencies": {
85
85
  "htmlparser2": "12.0.0",
86
- "@toolpath/tool-support": "^0.1.0"
86
+ "@toolpath/tool-support": "^0.2.0"
87
87
  },
88
88
  "devDependencies": {
89
89
  "@types/node": "24.10.1",