@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.
- package/LICENSE +21 -0
- package/README.md +98 -0
- package/dist/conventions.d.ts +124 -0
- package/dist/conventions.js +143 -0
- package/dist/errors.d.ts +46 -0
- package/dist/errors.js +53 -0
- package/dist/families/destinytool.d.ts +49 -0
- package/dist/families/destinytool.js +55 -0
- package/dist/families/index.d.ts +59 -0
- package/dist/families/index.js +91 -0
- package/dist/families/kennametal.d.ts +757 -0
- package/dist/families/kennametal.js +660 -0
- package/dist/families/regofix.d.ts +185 -0
- package/dist/families/regofix.js +250 -0
- package/dist/family.d.ts +130 -0
- package/dist/family.js +38 -0
- package/dist/fetch.d.ts +98 -0
- package/dist/fetch.js +116 -0
- package/dist/identity.d.ts +133 -0
- package/dist/identity.js +118 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.js +31 -0
- package/dist/node/cad-mirror.d.ts +55 -0
- package/dist/node/cad-mirror.js +89 -0
- package/dist/node/cli.d.ts +35 -0
- package/dist/node/cli.js +340 -0
- package/dist/node/csv.d.ts +47 -0
- package/dist/node/csv.js +123 -0
- package/dist/node/index.d.ts +16 -0
- package/dist/node/index.js +16 -0
- package/dist/node/main.d.ts +13 -0
- package/dist/node/main.js +14 -0
- package/dist/node/paths.d.ts +60 -0
- package/dist/node/paths.js +80 -0
- package/dist/node/receipts.d.ts +100 -0
- package/dist/node/receipts.js +107 -0
- package/dist/order.d.ts +10 -0
- package/dist/order.js +12 -0
- package/dist/provenance.d.ts +125 -0
- package/dist/provenance.js +133 -0
- package/dist/records.d.ts +305 -0
- package/dist/records.js +297 -0
- package/dist/registry.d.ts +63 -0
- package/dist/registry.js +145 -0
- package/dist/scrape.d.ts +70 -0
- package/dist/scrape.js +37 -0
- package/dist/thread.d.ts +48 -0
- package/dist/thread.js +98 -0
- package/dist/uuid5.d.ts +31 -0
- package/dist/uuid5.js +64 -0
- package/dist/vendors/destinytool/index.d.ts +11 -0
- package/dist/vendors/destinytool/index.js +11 -0
- package/dist/vendors/destinytool/records.d.ts +118 -0
- package/dist/vendors/destinytool/records.js +266 -0
- package/dist/vendors/destinytool/scrape.d.ts +108 -0
- package/dist/vendors/destinytool/scrape.js +192 -0
- package/dist/vendors/kennametal/cad.d.ts +87 -0
- package/dist/vendors/kennametal/cad.js +119 -0
- package/dist/vendors/kennametal/index.d.ts +21 -0
- package/dist/vendors/kennametal/index.js +21 -0
- package/dist/vendors/kennametal/materials.d.ts +143 -0
- package/dist/vendors/kennametal/materials.js +200 -0
- package/dist/vendors/kennametal/records.d.ts +88 -0
- package/dist/vendors/kennametal/records.js +241 -0
- package/dist/vendors/kennametal/scrape.d.ts +111 -0
- package/dist/vendors/kennametal/scrape.js +226 -0
- package/dist/vendors/kennametal/thread-column.d.ts +28 -0
- package/dist/vendors/kennametal/thread-column.js +41 -0
- package/dist/vendors/regofix/index.d.ts +8 -0
- package/dist/vendors/regofix/index.js +8 -0
- package/dist/vendors/regofix/scrape.d.ts +237 -0
- package/dist/vendors/regofix/scrape.js +521 -0
- 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;
|