@toolpath/tool-scraper 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +98 -0
  3. package/dist/conventions.d.ts +124 -0
  4. package/dist/conventions.js +143 -0
  5. package/dist/errors.d.ts +46 -0
  6. package/dist/errors.js +53 -0
  7. package/dist/families/destinytool.d.ts +49 -0
  8. package/dist/families/destinytool.js +55 -0
  9. package/dist/families/index.d.ts +59 -0
  10. package/dist/families/index.js +91 -0
  11. package/dist/families/kennametal.d.ts +757 -0
  12. package/dist/families/kennametal.js +660 -0
  13. package/dist/families/regofix.d.ts +185 -0
  14. package/dist/families/regofix.js +250 -0
  15. package/dist/family.d.ts +130 -0
  16. package/dist/family.js +38 -0
  17. package/dist/fetch.d.ts +98 -0
  18. package/dist/fetch.js +116 -0
  19. package/dist/identity.d.ts +133 -0
  20. package/dist/identity.js +118 -0
  21. package/dist/index.d.ts +31 -0
  22. package/dist/index.js +31 -0
  23. package/dist/node/cad-mirror.d.ts +55 -0
  24. package/dist/node/cad-mirror.js +89 -0
  25. package/dist/node/cli.d.ts +35 -0
  26. package/dist/node/cli.js +340 -0
  27. package/dist/node/csv.d.ts +47 -0
  28. package/dist/node/csv.js +123 -0
  29. package/dist/node/index.d.ts +16 -0
  30. package/dist/node/index.js +16 -0
  31. package/dist/node/main.d.ts +13 -0
  32. package/dist/node/main.js +14 -0
  33. package/dist/node/paths.d.ts +60 -0
  34. package/dist/node/paths.js +80 -0
  35. package/dist/node/receipts.d.ts +100 -0
  36. package/dist/node/receipts.js +107 -0
  37. package/dist/order.d.ts +10 -0
  38. package/dist/order.js +12 -0
  39. package/dist/provenance.d.ts +125 -0
  40. package/dist/provenance.js +133 -0
  41. package/dist/records.d.ts +305 -0
  42. package/dist/records.js +297 -0
  43. package/dist/registry.d.ts +63 -0
  44. package/dist/registry.js +145 -0
  45. package/dist/scrape.d.ts +70 -0
  46. package/dist/scrape.js +37 -0
  47. package/dist/thread.d.ts +48 -0
  48. package/dist/thread.js +98 -0
  49. package/dist/uuid5.d.ts +31 -0
  50. package/dist/uuid5.js +64 -0
  51. package/dist/vendors/destinytool/index.d.ts +11 -0
  52. package/dist/vendors/destinytool/index.js +11 -0
  53. package/dist/vendors/destinytool/records.d.ts +118 -0
  54. package/dist/vendors/destinytool/records.js +266 -0
  55. package/dist/vendors/destinytool/scrape.d.ts +108 -0
  56. package/dist/vendors/destinytool/scrape.js +192 -0
  57. package/dist/vendors/kennametal/cad.d.ts +87 -0
  58. package/dist/vendors/kennametal/cad.js +119 -0
  59. package/dist/vendors/kennametal/index.d.ts +21 -0
  60. package/dist/vendors/kennametal/index.js +21 -0
  61. package/dist/vendors/kennametal/materials.d.ts +143 -0
  62. package/dist/vendors/kennametal/materials.js +200 -0
  63. package/dist/vendors/kennametal/records.d.ts +88 -0
  64. package/dist/vendors/kennametal/records.js +241 -0
  65. package/dist/vendors/kennametal/scrape.d.ts +111 -0
  66. package/dist/vendors/kennametal/scrape.js +226 -0
  67. package/dist/vendors/kennametal/thread-column.d.ts +28 -0
  68. package/dist/vendors/kennametal/thread-column.js +41 -0
  69. package/dist/vendors/regofix/index.d.ts +8 -0
  70. package/dist/vendors/regofix/index.js +8 -0
  71. package/dist/vendors/regofix/scrape.d.ts +237 -0
  72. package/dist/vendors/regofix/scrape.js +521 -0
  73. package/package.json +76 -0
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Vendor CAD model URLs: material number in, a static STEP link out.
3
+ *
4
+ * Kennametal's product pages don't host their CAD models — a third party does
5
+ * (CDS Visual, on `product-config.net`), and the page reaches it in one of two
6
+ * ways. For an *assembly* it POSTs a job, polls a batch, and gets back a
7
+ * transient generated ZIP. For a **single part with no child components** —
8
+ * every holder in this catalog — it takes a different branch entirely and asks
9
+ * for a pre-built static file:
10
+ *
11
+ * ```
12
+ * GET https://www.product-config.net/catalog3/cad?d=kennametal&id=<material>
13
+ * ```
14
+ *
15
+ * That returns `staticURLs`, a map of format key -> permanent CloudFront URL,
16
+ * and the files behind it are ordinary objects with a `Last-Modified` in 2024.
17
+ * The response also states `authenticatedDownload: false`, which is what makes
18
+ * a direct link viable: no login, no session, no token.
19
+ *
20
+ * `docs/KENNAMETAL_CAD_API.md` documents the endpoint and the format keys.
21
+ * This module scrapes one of them — `stp-lwm`, the lightweight STEP, which is
22
+ * the collision model to give a CAM package as holder geometry.
23
+ *
24
+ * ## Why the download half is not in this module
25
+ *
26
+ * Mirroring every STEP file onto disk is a maintainer's batch job — it takes
27
+ * an output directory, writes ~54 KB per part with a rate-limit pause between,
28
+ * and has no return value a caller wants. It lives in `node/cad-mirror.ts` and
29
+ * is reachable only from the CLI.
30
+ *
31
+ * What a backend consuming this package wants is the permanent URL, which is
32
+ * what {@link lightweightStepUrl} and {@link annotateCadUrls} give it — to
33
+ * link to, or to fetch on demand, rather than to bulk-mirror. The seam was
34
+ * already here: the annotate step writes a URL precisely so that downloading
35
+ * is a separate, later, optional step.
36
+ */
37
+ import { CAD_COLUMN } from '../../conventions.js';
38
+ import { statusOf } from '../../fetch.js';
39
+ import { REQUEST_DELAY_MS, pause } from '../../scrape.js';
40
+ export const CAD_API = 'https://www.product-config.net/catalog3/cad?d=kennametal&id={material}';
41
+ /**
42
+ * The `staticURLs` key for the lightweight STEP — CDS calls it LWM, the vendor
43
+ * UI calls it "3D Anti Collision Model", and it is the simplified solid rather
44
+ * than the full graphical model (`stp-gtm`).
45
+ *
46
+ * The column it is written to is `conventions.CAD_COLUMN`, shared with every
47
+ * other vendor's scraper because a consumer reads exactly one. What is
48
+ * Kennametal-specific is *which* of CDS Visual's formats fills it — that is
49
+ * this constant, and it stays here.
50
+ */
51
+ export const LIGHTWEIGHT_STEP = 'stp-lwm';
52
+ /** The CAD metadata for one material number. */
53
+ export async function fetchCad(fetcher, material) {
54
+ return fetcher.json(CAD_API.replace('{material}', material));
55
+ }
56
+ /**
57
+ * The lightweight STEP URL from a CAD payload, or null when there is none.
58
+ *
59
+ * Null is a real state and not an error: the vendor's own UI carries a "we do
60
+ * not have any CAD models available for download" case, and a holder without a
61
+ * published model is a holder this package should say nothing about rather
62
+ * than offer a dead link for. All twenty holders scraped so far do have one,
63
+ * which is exactly why the absent case needs a test rather than a reassuring
64
+ * assumption.
65
+ */
66
+ export function lightweightStepUrl(payload) {
67
+ if (!payload.cadAvailable)
68
+ return null;
69
+ const url = (payload.staticURLs ?? {})[LIGHTWEIGHT_STEP];
70
+ return typeof url === 'string' && url ? url : null;
71
+ }
72
+ /**
73
+ * {@link fetchCad}, with a 404 read as "the vendor publishes none".
74
+ *
75
+ * The docstring below promises a row whose lookup finds no model keeps an
76
+ * empty cell and is never dropped. That held only for the `cadAvailable:
77
+ * false` payload — a 404 threw out of the loop and abandoned the whole file
78
+ * part-annotated, past `main()`'s catch and onto a stack trace. Any other
79
+ * status is still a failed request and still stops the run.
80
+ *
81
+ * Duck-typed through `statusOf` rather than `instanceof`, so a caller's own
82
+ * {@link Fetcher} gets the same handling — the same call `vendors/regofix`
83
+ * makes for the holders whose DIN 4000 document does not exist.
84
+ */
85
+ async function cadFor(fetcher, material) {
86
+ try {
87
+ return await fetchCad(fetcher, material);
88
+ }
89
+ catch (error) {
90
+ if (statusOf(error) === 404)
91
+ return { cadAvailable: false };
92
+ throw error;
93
+ }
94
+ }
95
+ /**
96
+ * Add (or refresh) the CAD model column on a toolholding scrape.
97
+ *
98
+ * Safe to re-run, like the thread-pitch and material-group steps: an existing
99
+ * column is rebuilt rather than duplicated. A row whose lookup finds no model
100
+ * keeps an empty cell; the row is never dropped, because the holder still
101
+ * exists.
102
+ */
103
+ export async function annotateCadUrls(fetcher, scrape, delayMs = REQUEST_DELAY_MS) {
104
+ if (scrape.rows.length === 0)
105
+ return { scrape, found: 0 };
106
+ const column = CAD_COLUMN;
107
+ const header = scrape.header.includes(column) ? [...scrape.header] : [...scrape.header, column];
108
+ let found = 0;
109
+ const rows = [];
110
+ for (const [index, row] of scrape.rows.entries()) {
111
+ if (index)
112
+ await pause(delayMs);
113
+ const url = lightweightStepUrl(await cadFor(fetcher, row['Material Number'] ?? ''));
114
+ if (url)
115
+ found += 1;
116
+ rows.push({ ...row, [column]: url ?? '' });
117
+ }
118
+ return { scrape: { ...scrape, header, rows }, found };
119
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Kennametal and WIDIA — one adapter, because they are one platform.
3
+ *
4
+ * Both run the same AEM/Hybris component on the same URL shape; only the host,
5
+ * the component node name and the vendor string differ, and `identity.BRANDS`
6
+ * is where that is recorded. Treating them as two adapters would duplicate
7
+ * every module here to encode one differing node name.
8
+ *
9
+ * **Their data directories are still separate**, and the distinction is worth
10
+ * holding on to: an adapter is a fact about *code*, a receipt is a fact about
11
+ * *who published it*. So WIDIA's scraped tables live under WIDIA's own brand
12
+ * even though this module is what scraped them, and a CSV is resolved through
13
+ * its family's `brand` rather than through the adapter that wrote it. A future
14
+ * brand on this same platform is a `BRANDS` entry and a data directory, and no
15
+ * code here at all.
16
+ */
17
+ export * from './cad.js';
18
+ export * from './materials.js';
19
+ export * from './records.js';
20
+ export * from './scrape.js';
21
+ export * from './thread-column.js';
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Kennametal and WIDIA — one adapter, because they are one platform.
3
+ *
4
+ * Both run the same AEM/Hybris component on the same URL shape; only the host,
5
+ * the component node name and the vendor string differ, and `identity.BRANDS`
6
+ * is where that is recorded. Treating them as two adapters would duplicate
7
+ * every module here to encode one differing node name.
8
+ *
9
+ * **Their data directories are still separate**, and the distinction is worth
10
+ * holding on to: an adapter is a fact about *code*, a receipt is a fact about
11
+ * *who published it*. So WIDIA's scraped tables live under WIDIA's own brand
12
+ * even though this module is what scraped them, and a CSV is resolved through
13
+ * its family's `brand` rather than through the adapter that wrote it. A future
14
+ * brand on this same platform is a `BRANDS` entry and a data directory, and no
15
+ * code here at all.
16
+ */
17
+ export * from './cad.js';
18
+ export * from './materials.js';
19
+ export * from './records.js';
20
+ export * from './scrape.js';
21
+ export * from './thread-column.js';
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Which ISO workpiece materials a tool is indexed for, from the search facet.
3
+ *
4
+ * The variant table this package scrapes carries no material column: it states
5
+ * geometry and the carbide *grade* (KCU20, KCPM15), and a grade is what the
6
+ * tool is made of, not what it is meant to cut. The applicability lives in two
7
+ * other places on the same pages, and this module reads the machine-readable
8
+ * one.
9
+ *
10
+ * **The source is the `workpieceMaterialDetail` search facet**, the panel of
11
+ * P/M/K/N/S/H/C checkboxes on a category listing. It is a Solr facet over the
12
+ * same index the variants endpoint queries, so appending it to that endpoint's
13
+ * `query` parameter filters the family's table down to the variants indexed
14
+ * for one group — no second endpoint, no second parser. Sweeping the 32 groups
15
+ * and collecting which variants come back gives every tool its own list.
16
+ *
17
+ * **It is enforced, which is what makes an empty answer meaningful.** An
18
+ * unknown group value returns zero rows rather than being ignored, so "this
19
+ * family matched nothing under N1" is a statement about the vendor's index and
20
+ * not about a typo. (An unknown *facet name* is ignored, which is why the name
21
+ * here is a constant and not a caller's argument.) Verified JG 2026-08-05.
22
+ *
23
+ * **What this is not.** The family pages also publish an Application Data
24
+ * table: per ISO subgroup, with ap/ae limits, a cutting-speed range and feed
25
+ * per tooth by diameter. That is the engineering data and it is richer, and on
26
+ * two of the six endmill families it *disagrees* with this facet — MaxiMet's
27
+ * table lists N5 (plastics) where the facet stops at N4, and both GOmill
28
+ * families' facets claim all 28 groups including H3/H4 where the metric
29
+ * family's own table stops at H2. The facet is the marketing-side index and is
30
+ * the broader of the two. It is what this module scrapes because it is
31
+ * per-variant and structured; the application table is a separate scrape that
32
+ * has not been written. Don't read a group here as a vendor speed-and-feed
33
+ * recommendation.
34
+ *
35
+ * Taps carry no material indexing at all — all three tap families return zero
36
+ * rows for all 32 groups. That is a vendor gap, not a scrape failure, and it
37
+ * is pinned in the tests so that a vendor who starts publishing them fails a
38
+ * test rather than going unnoticed.
39
+ */
40
+ import type { Fetcher } from '../../fetch.js';
41
+ import type { AemBrandName } from '../../identity.js';
42
+ import { type ScrapeResult } from '../../scrape.js';
43
+ /**
44
+ * The facet this module queries. A constant rather than a parameter because a
45
+ * misspelled facet *name* is silently ignored by the endpoint and would report
46
+ * every group as matching every tool.
47
+ */
48
+ export declare const FACET = "workpieceMaterialDetail";
49
+ /**
50
+ * The column {@link addMaterialGroups} writes, holding one row's groups
51
+ * space-separated in {@link MATERIAL_GROUPS} order.
52
+ */
53
+ export declare const MATERIALS_COLUMN = "Material Groups";
54
+ /**
55
+ * The vendor's ISO 513 workpiece groups, in the order the facet panel lists
56
+ * them — letter by machining class, then subgroup by increasing difficulty.
57
+ * Read off the facet panel of a Kennametal category listing (JG 2026-08-05);
58
+ * the titles and hardness bands that go with each code are a *display* concern
59
+ * and belong with whatever displays them, not here.
60
+ *
61
+ * This list is the sweep vocabulary, so a group the vendor adds later is
62
+ * invisible until it is added here. That is the safe direction to fail: a
63
+ * missing group under-reports a tool rather than inventing an application for
64
+ * it.
65
+ */
66
+ export declare const MATERIAL_GROUPS: readonly ["P0", "P1", "P2", "P3", "P4", "P5", "P6", "M1", "M2", "M3", "K1", "K2", "K3", "N1", "N2", "N3", "N4", "N5", "N6", "N7", "S1", "S2", "S3", "S4", "H1", "H2", "H3", "H4", "C1", "C2", "C3", "C4"];
67
+ /**
68
+ * ISO 513's main groups — the letters, in the vendor's own panel order, which
69
+ * happens to be the standard's own order too. Re-exported from
70
+ * `records.ISO_MATERIAL_GROUPS` rather than redefined here — see that
71
+ * constant's docstring for why the ordering is a domain fact and not a
72
+ * Kennametal one.
73
+ */
74
+ export declare const ISO_CLASSES: readonly ["P", "M", "K", "N", "S", "H", "C"];
75
+ /**
76
+ * The Material Numbers in one family that are indexed for one group.
77
+ *
78
+ * The only network call here. An empty set is the ordinary answer for a group
79
+ * the family isn't rated for.
80
+ */
81
+ export declare function materialsInGroup(fetcher: Fetcher, code: string, group: string, brand?: AemBrandName): Promise<Set<string>>;
82
+ /** Options for a material sweep. */
83
+ export interface SweepOptions {
84
+ brand?: AemBrandName;
85
+ groups?: readonly string[];
86
+ delayMs?: number;
87
+ }
88
+ /**
89
+ * Sweep every group and invert the result: material number -> its groups.
90
+ *
91
+ * Values keep {@link MATERIAL_GROUPS} order rather than being sorted, so the
92
+ * written column reads P before M before K the way the vendor's own panel
93
+ * does, and so re-running produces a byte-identical result.
94
+ *
95
+ * **Per material number, not per family**, even though all eleven families
96
+ * scraped so far answer uniformly — every group either matched every variant
97
+ * or none of them. A single family-wide list would be a claim this sweep
98
+ * cannot actually support, and it would go from true to false silently the
99
+ * first time a vendor splits a family by size.
100
+ */
101
+ export declare function groupsByMaterial(fetcher: Fetcher, code: string, options?: SweepOptions): Promise<Map<string, string[]>>;
102
+ /** What {@link addMaterialGroups} answers with. */
103
+ export interface MaterialSweep {
104
+ scrape: ScrapeResult;
105
+ /**
106
+ * How many rows got at least one group — deliberately not the row count, so
107
+ * a sweep that found nothing reads as `0 of 259` at the call site rather
108
+ * than as success. Zero is nonetheless a legitimate result for a tap family;
109
+ * the caller reports the number and does not judge it.
110
+ */
111
+ matched: number;
112
+ }
113
+ /**
114
+ * Add (or refresh) the material-groups column on a family scrape.
115
+ *
116
+ * Safe to re-run, like {@link addThreadPitch} and the CAD annotation: an
117
+ * existing column is rebuilt rather than duplicated.
118
+ */
119
+ export declare function addMaterialGroups(scrape: ScrapeResult, found: Map<string, string[]>): MaterialSweep;
120
+ /**
121
+ * The column back into a list.
122
+ *
123
+ * Unknown codes are dropped rather than passed through: this column is
124
+ * generated, so anything not in {@link MATERIAL_GROUPS} came from a hand-edit,
125
+ * and a made-up group would reach a catalog's filter panel as a value no
126
+ * control could ever offer.
127
+ */
128
+ export declare function parseMaterialGroups(cell: string | undefined): string[];
129
+ /**
130
+ * The column collapsed to ISO 513 main groups — `P`, `M`, `K`, …
131
+ *
132
+ * **The subgroup is scraped and then deliberately dropped here.** The facet
133
+ * publishes 32 subgroups and the CSV keeps all of them, because the CSV is the
134
+ * record of what the vendor said; but P0 through P6 is a hardness band within
135
+ * steel, and the question a catalog answers is "does this end mill cut steel".
136
+ * Keeping the band would mean labelling 28 codes whose distinctions (`S3`
137
+ * nickel alloys versus `S4` titanium) matter enormously to a cut and not at
138
+ * all to picking a tool off a shelf.
139
+ *
140
+ * Collapsing here rather than at the scrape is what keeps that reversible:
141
+ * re-deriving subgroups is an edit here, not a re-scrape.
142
+ */
143
+ export declare function materialClasses(cell: string | undefined): string[];
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Which ISO workpiece materials a tool is indexed for, from the search facet.
3
+ *
4
+ * The variant table this package scrapes carries no material column: it states
5
+ * geometry and the carbide *grade* (KCU20, KCPM15), and a grade is what the
6
+ * tool is made of, not what it is meant to cut. The applicability lives in two
7
+ * other places on the same pages, and this module reads the machine-readable
8
+ * one.
9
+ *
10
+ * **The source is the `workpieceMaterialDetail` search facet**, the panel of
11
+ * P/M/K/N/S/H/C checkboxes on a category listing. It is a Solr facet over the
12
+ * same index the variants endpoint queries, so appending it to that endpoint's
13
+ * `query` parameter filters the family's table down to the variants indexed
14
+ * for one group — no second endpoint, no second parser. Sweeping the 32 groups
15
+ * and collecting which variants come back gives every tool its own list.
16
+ *
17
+ * **It is enforced, which is what makes an empty answer meaningful.** An
18
+ * unknown group value returns zero rows rather than being ignored, so "this
19
+ * family matched nothing under N1" is a statement about the vendor's index and
20
+ * not about a typo. (An unknown *facet name* is ignored, which is why the name
21
+ * here is a constant and not a caller's argument.) Verified JG 2026-08-05.
22
+ *
23
+ * **What this is not.** The family pages also publish an Application Data
24
+ * table: per ISO subgroup, with ap/ae limits, a cutting-speed range and feed
25
+ * per tooth by diameter. That is the engineering data and it is richer, and on
26
+ * two of the six endmill families it *disagrees* with this facet — MaxiMet's
27
+ * table lists N5 (plastics) where the facet stops at N4, and both GOmill
28
+ * families' facets claim all 28 groups including H3/H4 where the metric
29
+ * family's own table stops at H2. The facet is the marketing-side index and is
30
+ * the broader of the two. It is what this module scrapes because it is
31
+ * per-variant and structured; the application table is a separate scrape that
32
+ * has not been written. Don't read a group here as a vendor speed-and-feed
33
+ * recommendation.
34
+ *
35
+ * Taps carry no material indexing at all — all three tap families return zero
36
+ * rows for all 32 groups. That is a vendor gap, not a scrape failure, and it
37
+ * is pinned in the tests so that a vendor who starts publishing them fails a
38
+ * test rather than going unnoticed.
39
+ */
40
+ import { ISO_MATERIAL_GROUPS } from '../../records.js';
41
+ import { REQUEST_DELAY_MS, pause } from '../../scrape.js';
42
+ import { ACTIVE_ONLY, fetchVariants, parseVariantTable } from './scrape.js';
43
+ /**
44
+ * The facet this module queries. A constant rather than a parameter because a
45
+ * misspelled facet *name* is silently ignored by the endpoint and would report
46
+ * every group as matching every tool.
47
+ */
48
+ export const FACET = 'workpieceMaterialDetail';
49
+ /**
50
+ * The column {@link addMaterialGroups} writes, holding one row's groups
51
+ * space-separated in {@link MATERIAL_GROUPS} order.
52
+ */
53
+ export const MATERIALS_COLUMN = 'Material Groups';
54
+ /**
55
+ * The vendor's ISO 513 workpiece groups, in the order the facet panel lists
56
+ * them — letter by machining class, then subgroup by increasing difficulty.
57
+ * Read off the facet panel of a Kennametal category listing (JG 2026-08-05);
58
+ * the titles and hardness bands that go with each code are a *display* concern
59
+ * and belong with whatever displays them, not here.
60
+ *
61
+ * This list is the sweep vocabulary, so a group the vendor adds later is
62
+ * invisible until it is added here. That is the safe direction to fail: a
63
+ * missing group under-reports a tool rather than inventing an application for
64
+ * it.
65
+ */
66
+ export const MATERIAL_GROUPS = [
67
+ 'P0',
68
+ 'P1',
69
+ 'P2',
70
+ 'P3',
71
+ 'P4',
72
+ 'P5',
73
+ 'P6',
74
+ 'M1',
75
+ 'M2',
76
+ 'M3',
77
+ 'K1',
78
+ 'K2',
79
+ 'K3',
80
+ 'N1',
81
+ 'N2',
82
+ 'N3',
83
+ 'N4',
84
+ 'N5',
85
+ 'N6',
86
+ 'N7',
87
+ 'S1',
88
+ 'S2',
89
+ 'S3',
90
+ 'S4',
91
+ 'H1',
92
+ 'H2',
93
+ 'H3',
94
+ 'H4',
95
+ 'C1',
96
+ 'C2',
97
+ 'C3',
98
+ 'C4',
99
+ ];
100
+ /**
101
+ * ISO 513's main groups — the letters, in the vendor's own panel order, which
102
+ * happens to be the standard's own order too. Re-exported from
103
+ * `records.ISO_MATERIAL_GROUPS` rather than redefined here — see that
104
+ * constant's docstring for why the ordering is a domain fact and not a
105
+ * Kennametal one.
106
+ */
107
+ export const ISO_CLASSES = ISO_MATERIAL_GROUPS;
108
+ /**
109
+ * The Material Numbers in one family that are indexed for one group.
110
+ *
111
+ * The only network call here. An empty set is the ordinary answer for a group
112
+ * the family isn't rated for.
113
+ */
114
+ export async function materialsInGroup(fetcher, code, group, brand = 'kennametal') {
115
+ const html = await fetchVariants(fetcher, code, brand, `${ACTIVE_ONLY}:${FACET}:${group}`);
116
+ const { rows } = parseVariantTable(html);
117
+ return new Set(rows.map((row) => row[1]?.[0] ?? ''));
118
+ }
119
+ /**
120
+ * Sweep every group and invert the result: material number -> its groups.
121
+ *
122
+ * Values keep {@link MATERIAL_GROUPS} order rather than being sorted, so the
123
+ * written column reads P before M before K the way the vendor's own panel
124
+ * does, and so re-running produces a byte-identical result.
125
+ *
126
+ * **Per material number, not per family**, even though all eleven families
127
+ * scraped so far answer uniformly — every group either matched every variant
128
+ * or none of them. A single family-wide list would be a claim this sweep
129
+ * cannot actually support, and it would go from true to false silently the
130
+ * first time a vendor splits a family by size.
131
+ */
132
+ export async function groupsByMaterial(fetcher, code, options = {}) {
133
+ const { brand = 'kennametal', groups = MATERIAL_GROUPS, delayMs = REQUEST_DELAY_MS } = options;
134
+ const found = new Map();
135
+ for (const [index, group] of groups.entries()) {
136
+ if (index)
137
+ await pause(delayMs);
138
+ for (const material of await materialsInGroup(fetcher, code, group, brand)) {
139
+ const existing = found.get(material);
140
+ if (existing)
141
+ existing.push(group);
142
+ else
143
+ found.set(material, [group]);
144
+ }
145
+ }
146
+ return found;
147
+ }
148
+ /**
149
+ * Add (or refresh) the material-groups column on a family scrape.
150
+ *
151
+ * Safe to re-run, like {@link addThreadPitch} and the CAD annotation: an
152
+ * existing column is rebuilt rather than duplicated.
153
+ */
154
+ export function addMaterialGroups(scrape, found) {
155
+ if (scrape.rows.length === 0)
156
+ return { scrape, matched: 0 };
157
+ const header = scrape.header.includes(MATERIALS_COLUMN)
158
+ ? [...scrape.header]
159
+ : [...scrape.header, MATERIALS_COLUMN];
160
+ let matched = 0;
161
+ const rows = scrape.rows.map((row) => {
162
+ const groups = found.get(row['Material Number'] ?? '') ?? [];
163
+ if (groups.length > 0)
164
+ matched += 1;
165
+ return { ...row, [MATERIALS_COLUMN]: groups.join(' ') };
166
+ });
167
+ return { scrape: { ...scrape, header, rows }, matched };
168
+ }
169
+ /**
170
+ * The column back into a list.
171
+ *
172
+ * Unknown codes are dropped rather than passed through: this column is
173
+ * generated, so anything not in {@link MATERIAL_GROUPS} came from a hand-edit,
174
+ * and a made-up group would reach a catalog's filter panel as a value no
175
+ * control could ever offer.
176
+ */
177
+ export function parseMaterialGroups(cell) {
178
+ if (!cell)
179
+ return [];
180
+ const present = new Set(cell.split(/\s+/).filter(Boolean));
181
+ return MATERIAL_GROUPS.filter((group) => present.has(group));
182
+ }
183
+ /**
184
+ * The column collapsed to ISO 513 main groups — `P`, `M`, `K`, …
185
+ *
186
+ * **The subgroup is scraped and then deliberately dropped here.** The facet
187
+ * publishes 32 subgroups and the CSV keeps all of them, because the CSV is the
188
+ * record of what the vendor said; but P0 through P6 is a hardness band within
189
+ * steel, and the question a catalog answers is "does this end mill cut steel".
190
+ * Keeping the band would mean labelling 28 codes whose distinctions (`S3`
191
+ * nickel alloys versus `S4` titanium) matter enormously to a cut and not at
192
+ * all to picking a tool off a shelf.
193
+ *
194
+ * Collapsing here rather than at the scrape is what keeps that reversible:
195
+ * re-deriving subgroups is an edit here, not a re-scrape.
196
+ */
197
+ export function materialClasses(cell) {
198
+ const present = new Set(parseMaterialGroups(cell).map((g) => g[0]));
199
+ return ISO_CLASSES.filter((iso) => present.has(iso));
200
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Kennametal rows -> {@link ToolRecord}. The adapter half of the record seam.
3
+ *
4
+ * Everything here knows Kennametal's column vocabulary and nothing here knows
5
+ * what a record becomes. The reverse is true of whatever consumes one: it
6
+ * knows no vendor at all.
7
+ *
8
+ * What stayed on this side of the line, and why each is a *vendor* fact rather
9
+ * than a domain one:
10
+ *
11
+ * - **Which column holds a canonical field.** Declared per family as
12
+ * `columns: { DC: 'D1', … }` and resolved through `ColumnMap`, which appends
13
+ * the unit suffix. A vendor with different labels declares different labels.
14
+ * - **Where the identity strings live.** `Material Number` and `ISO Catalog
15
+ * Number` are Kennametal's header text — the labels
16
+ * `conventions.IDENTITY_COLUMNS` took as the convention because this vendor
17
+ * was first, and which Destiny Tool then did not follow.
18
+ * - **Which column is the grade.** A drill and an end mill carry a carbide
19
+ * `Grade`; a tap has no carbide grade and carries `Coating`, the surface
20
+ * treatment, in the record's `grade` field. That is Kennametal's table
21
+ * shape, not a rule about taps everywhere.
22
+ * - **That a tap's unit system is per row.** `Thread System` is a constant tag
23
+ * column this package appends at scrape time, and a metric and an inch tap
24
+ * can sit in one family — so a tap's `unit` is read per row where a drill's
25
+ * and an end mill's come from config.
26
+ * - **The optional columns.** `Re`, `L3` and `D3` are present on some families
27
+ * and absent on others, and the *absence* means something specific: no
28
+ * corner radius is a square end (RE 0); no `L3` on a plain-shank tool means
29
+ * AP1MAX is the shoulder length too; no `D3` means the shoulder is the
30
+ * cutting diameter. Those three fallbacks are Kennametal-table facts and
31
+ * they stay here.
32
+ *
33
+ * What deliberately stays out: whether a corner radius makes a tool a bull
34
+ * nose, `LB` and `assemblyGaugeLength` being `OAL` on a bare tool, and every
35
+ * cutting preset. Those are true of a tool, not of a table.
36
+ */
37
+ import { type BoundFamily, type RecordMappers } from '../../family.js';
38
+ import { type ColumnMap, type ToolRecord } from '../../records.js';
39
+ import type { ScrapedRow } from '../../scrape.js';
40
+ /**
41
+ * Kennametal's identity columns. Named here rather than inline so a table that
42
+ * renames one fails in a single place.
43
+ */
44
+ export declare const MATERIAL_NUMBER = "Material Number";
45
+ export declare const CATALOG_NUMBER = "ISO Catalog Number";
46
+ /**
47
+ * A drill, in the family's native unit system per its `unit` fact.
48
+ *
49
+ * Every drill table publishes both unit columns, so `unit` is config and never
50
+ * inferred: it decides which column is read and what a machinist is shown.
51
+ * Getting it wrong converts cleanly and prints 5.9531 mm where the part
52
+ * ordered is a 15/64 in KenDrill TXD.
53
+ *
54
+ * L4 (max drilling depth) and L5 (point length) are mapped by no family and
55
+ * reach no record — but L5 is not inert, because it is what pins `pointAngle`
56
+ * on a family whose table states none. See `families/kennametal.ts`.
57
+ */
58
+ export declare function drillRecord(row: ScrapedRow, family: BoundFamily, columns: ColumnMap): ToolRecord;
59
+ /**
60
+ * A tap, in **its own** native unit system rather than the family's.
61
+ *
62
+ * `Thread System` is a constant column the scraper tags on, because the table
63
+ * does not state it; metric taps read the mm columns and inch taps the inch
64
+ * ones. `Thread Pitch` is already native-unit — derived from `D1-TDZ` by
65
+ * `thread-column.addThreadPitch` — which is why `TP` is dimensional but
66
+ * unsuffixed.
67
+ *
68
+ * `DC` is **derived, not read**: a tap table publishes a thread designation
69
+ * (`#2-56`, `M6 X 1`) and no major-diameter column, so the major diameter is
70
+ * parsed out of the designation. That is arithmetic over a standard, which is
71
+ * why `threadMajorDiameter` sits in the core.
72
+ */
73
+ export declare function tapRecord(row: ScrapedRow, family: BoundFamily, columns: ColumnMap): ToolRecord;
74
+ /**
75
+ * A solid end mill, native unit per the family's `unit` fact.
76
+ *
77
+ * Three optional columns, and each absence carries a meaning this table
78
+ * assigns rather than one the domain does:
79
+ *
80
+ * - **no `Re`** → a square-end family, corner radius 0;
81
+ * - **no `L3`** → nothing below the flutes to reach past, so the maximum flute
82
+ * length is the shoulder length too (the WIDIA VariMill tables);
83
+ * - **no `D3`** → a plain shank, so the shoulder is the cutting diameter.
84
+ *
85
+ * Whether a radius makes it a bull nose is a consumer's call, not this one.
86
+ */
87
+ export declare function endmillRecord(row: ScrapedRow, family: BoundFamily, columns: ColumnMap): ToolRecord;
88
+ export declare const RECORD_MAPPERS: RecordMappers;