@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
package/dist/fetch.d.ts
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The polite GET every transport here was copying.
|
|
3
|
+
*
|
|
4
|
+
* Three vendors, three unrelated transports — an AEM variant-table GET, a
|
|
5
|
+
* Firestore REST walk, an Elasticsearch proxy POST — and all three arrived at
|
|
6
|
+
* the same four lines: build a request, set a browser `User-Agent`, send it
|
|
7
|
+
* with a timeout, decode. Four copies of four lines is not expensive; four
|
|
8
|
+
* copies of the *decisions* in them is, because the decisions are the part
|
|
9
|
+
* that has to be consistent. A vendor whose transport needs something else — a
|
|
10
|
+
* session, a retry policy, a browser — keeps it in its own module rather than
|
|
11
|
+
* widening this one.
|
|
12
|
+
*
|
|
13
|
+
* **The `User-Agent` is not evasion.** These are unauthenticated, publicly
|
|
14
|
+
* served endpoints; a default agent gets a 403 from ordinary CDN rules on two
|
|
15
|
+
* of the three hosts, and the request is otherwise exactly what a browser on
|
|
16
|
+
* the vendor's own page makes.
|
|
17
|
+
*
|
|
18
|
+
* ## Why this is an interface and not four exported functions
|
|
19
|
+
*
|
|
20
|
+
* A Node backend embedding this has its own opinions about retries,
|
|
21
|
+
* connection pools, proxies and rate limits, and patching a module global is
|
|
22
|
+
* not how it expresses them.
|
|
23
|
+
*
|
|
24
|
+
* So every scrape takes a {@link Fetcher}. The default is the four lines
|
|
25
|
+
* below; a caller that wants otherwise passes its own, and a test passes a
|
|
26
|
+
* stub with no network behind it. `tests/setup.ts` additionally replaces the
|
|
27
|
+
* global `fetch` with one that throws, so a test that forgets to pass a stub
|
|
28
|
+
* fails loudly instead of quietly paging a vendor's whole catalog.
|
|
29
|
+
*/
|
|
30
|
+
/** What every request from the default fetcher identifies as. */
|
|
31
|
+
export declare const USER_AGENT = "Mozilla/5.0";
|
|
32
|
+
/**
|
|
33
|
+
* Milliseconds. Long, because a vendor's variant table for a 259-row family is
|
|
34
|
+
* a slow render on their side and a timeout would read as a scrape failure.
|
|
35
|
+
*/
|
|
36
|
+
export declare const DEFAULT_TIMEOUT_MS = 60000;
|
|
37
|
+
/**
|
|
38
|
+
* The transport a scrape reads through.
|
|
39
|
+
*
|
|
40
|
+
* Four methods because the four vendors between them need exactly four
|
|
41
|
+
* behaviours, and the differences are deliberate — see {@link createFetcher}.
|
|
42
|
+
*/
|
|
43
|
+
export interface Fetcher {
|
|
44
|
+
/** One GET, verbatim. For anything that is not text — a STEP model. */
|
|
45
|
+
bytes(url: string): Promise<Uint8Array>;
|
|
46
|
+
/** One GET, decoded as UTF-8, replacing undecodable bytes. */
|
|
47
|
+
text(url: string): Promise<string>;
|
|
48
|
+
/** One GET, parsed as JSON. */
|
|
49
|
+
json<T = unknown>(url: string): Promise<T>;
|
|
50
|
+
/** One POST of a JSON body, parsed as JSON. */
|
|
51
|
+
postJson<T = unknown>(url: string, payload: unknown): Promise<T>;
|
|
52
|
+
}
|
|
53
|
+
/** Options the default fetcher accepts. */
|
|
54
|
+
export interface FetcherOptions {
|
|
55
|
+
/** Milliseconds before a request is abandoned. */
|
|
56
|
+
timeoutMs?: number;
|
|
57
|
+
/** What to identify as. */
|
|
58
|
+
userAgent?: string;
|
|
59
|
+
/**
|
|
60
|
+
* The underlying `fetch`. Injectable so a consumer can supply an
|
|
61
|
+
* instrumented or proxied one without reimplementing the decoding rules
|
|
62
|
+
* below, which are the part that has to stay consistent.
|
|
63
|
+
*/
|
|
64
|
+
fetch?: typeof globalThis.fetch;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* A response that is not a 2xx, named so the URL is in the message.
|
|
68
|
+
*
|
|
69
|
+
* Carries the status because one caller needs to tell 404 from everything
|
|
70
|
+
* else: REGO-FIX publishes no DIN 4000 document for two of its BT+ 30 holders,
|
|
71
|
+
* and "the vendor has none" is a real state a scrape skips past, where any
|
|
72
|
+
* other status is a failed request that has to stop the run.
|
|
73
|
+
*/
|
|
74
|
+
export declare class HttpError extends Error {
|
|
75
|
+
readonly status: number;
|
|
76
|
+
readonly url: string;
|
|
77
|
+
constructor(url: string, status: number);
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* The HTTP status behind an error, or null when it carries none.
|
|
81
|
+
*
|
|
82
|
+
* Duck-typed rather than an `instanceof HttpError` check, so a consumer that
|
|
83
|
+
* supplies its own {@link Fetcher} — with its own error type carrying its own
|
|
84
|
+
* `status` — still gets the 404 handling the vendor adapters depend on.
|
|
85
|
+
*/
|
|
86
|
+
export declare function statusOf(error: unknown): number | null;
|
|
87
|
+
/**
|
|
88
|
+
* The default transport: one request, a browser `User-Agent`, and a timeout.
|
|
89
|
+
*
|
|
90
|
+
* The two decoders differ on purpose. `text` replaces an undecodable byte,
|
|
91
|
+
* because these are vendor HTML and XML documents that occasionally carry a
|
|
92
|
+
* stray byte in a description, and refusing the whole 257-row table over one
|
|
93
|
+
* of them would lose the 256 good rows to no purpose. `json` is strict,
|
|
94
|
+
* because a JSON document with an undecodable byte in it is a broken response
|
|
95
|
+
* and replacing the byte would hand the parser a document the server never
|
|
96
|
+
* sent.
|
|
97
|
+
*/
|
|
98
|
+
export declare function createFetcher(options?: FetcherOptions): Fetcher;
|
package/dist/fetch.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The polite GET every transport here was copying.
|
|
3
|
+
*
|
|
4
|
+
* Three vendors, three unrelated transports — an AEM variant-table GET, a
|
|
5
|
+
* Firestore REST walk, an Elasticsearch proxy POST — and all three arrived at
|
|
6
|
+
* the same four lines: build a request, set a browser `User-Agent`, send it
|
|
7
|
+
* with a timeout, decode. Four copies of four lines is not expensive; four
|
|
8
|
+
* copies of the *decisions* in them is, because the decisions are the part
|
|
9
|
+
* that has to be consistent. A vendor whose transport needs something else — a
|
|
10
|
+
* session, a retry policy, a browser — keeps it in its own module rather than
|
|
11
|
+
* widening this one.
|
|
12
|
+
*
|
|
13
|
+
* **The `User-Agent` is not evasion.** These are unauthenticated, publicly
|
|
14
|
+
* served endpoints; a default agent gets a 403 from ordinary CDN rules on two
|
|
15
|
+
* of the three hosts, and the request is otherwise exactly what a browser on
|
|
16
|
+
* the vendor's own page makes.
|
|
17
|
+
*
|
|
18
|
+
* ## Why this is an interface and not four exported functions
|
|
19
|
+
*
|
|
20
|
+
* A Node backend embedding this has its own opinions about retries,
|
|
21
|
+
* connection pools, proxies and rate limits, and patching a module global is
|
|
22
|
+
* not how it expresses them.
|
|
23
|
+
*
|
|
24
|
+
* So every scrape takes a {@link Fetcher}. The default is the four lines
|
|
25
|
+
* below; a caller that wants otherwise passes its own, and a test passes a
|
|
26
|
+
* stub with no network behind it. `tests/setup.ts` additionally replaces the
|
|
27
|
+
* global `fetch` with one that throws, so a test that forgets to pass a stub
|
|
28
|
+
* fails loudly instead of quietly paging a vendor's whole catalog.
|
|
29
|
+
*/
|
|
30
|
+
/** What every request from the default fetcher identifies as. */
|
|
31
|
+
export const USER_AGENT = 'Mozilla/5.0';
|
|
32
|
+
/**
|
|
33
|
+
* Milliseconds. Long, because a vendor's variant table for a 259-row family is
|
|
34
|
+
* a slow render on their side and a timeout would read as a scrape failure.
|
|
35
|
+
*/
|
|
36
|
+
export const DEFAULT_TIMEOUT_MS = 60_000;
|
|
37
|
+
/**
|
|
38
|
+
* A response that is not a 2xx, named so the URL is in the message.
|
|
39
|
+
*
|
|
40
|
+
* Carries the status because one caller needs to tell 404 from everything
|
|
41
|
+
* else: REGO-FIX publishes no DIN 4000 document for two of its BT+ 30 holders,
|
|
42
|
+
* and "the vendor has none" is a real state a scrape skips past, where any
|
|
43
|
+
* other status is a failed request that has to stop the run.
|
|
44
|
+
*/
|
|
45
|
+
export class HttpError extends Error {
|
|
46
|
+
status;
|
|
47
|
+
url;
|
|
48
|
+
constructor(url, status) {
|
|
49
|
+
super(`${url}: the vendor answered ${status}`);
|
|
50
|
+
this.status = status;
|
|
51
|
+
this.url = url;
|
|
52
|
+
Object.setPrototypeOf(this, HttpError.prototype);
|
|
53
|
+
this.name = 'HttpError';
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* The HTTP status behind an error, or null when it carries none.
|
|
58
|
+
*
|
|
59
|
+
* Duck-typed rather than an `instanceof HttpError` check, so a consumer that
|
|
60
|
+
* supplies its own {@link Fetcher} — with its own error type carrying its own
|
|
61
|
+
* `status` — still gets the 404 handling the vendor adapters depend on.
|
|
62
|
+
*/
|
|
63
|
+
export function statusOf(error) {
|
|
64
|
+
const status = error?.status;
|
|
65
|
+
return typeof status === 'number' ? status : null;
|
|
66
|
+
}
|
|
67
|
+
function checkOk(url, response) {
|
|
68
|
+
if (!response.ok)
|
|
69
|
+
throw new HttpError(url, response.status);
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* The default transport: one request, a browser `User-Agent`, and a timeout.
|
|
73
|
+
*
|
|
74
|
+
* The two decoders differ on purpose. `text` replaces an undecodable byte,
|
|
75
|
+
* because these are vendor HTML and XML documents that occasionally carry a
|
|
76
|
+
* stray byte in a description, and refusing the whole 257-row table over one
|
|
77
|
+
* of them would lose the 256 good rows to no purpose. `json` is strict,
|
|
78
|
+
* because a JSON document with an undecodable byte in it is a broken response
|
|
79
|
+
* and replacing the byte would hand the parser a document the server never
|
|
80
|
+
* sent.
|
|
81
|
+
*/
|
|
82
|
+
export function createFetcher(options = {}) {
|
|
83
|
+
const { timeoutMs = DEFAULT_TIMEOUT_MS, userAgent = USER_AGENT, fetch: send = globalThis.fetch, } = options;
|
|
84
|
+
async function request(url, init) {
|
|
85
|
+
const response = await send(url, {
|
|
86
|
+
...init,
|
|
87
|
+
headers: { 'User-Agent': userAgent, ...init?.headers },
|
|
88
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
89
|
+
});
|
|
90
|
+
checkOk(url, response);
|
|
91
|
+
return response;
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
async bytes(url) {
|
|
95
|
+
return new Uint8Array(await (await request(url)).arrayBuffer());
|
|
96
|
+
},
|
|
97
|
+
async text(url) {
|
|
98
|
+
const buffer = await (await request(url)).arrayBuffer();
|
|
99
|
+
return new TextDecoder('utf-8').decode(buffer);
|
|
100
|
+
},
|
|
101
|
+
async json(url) {
|
|
102
|
+
const buffer = await (await request(url)).arrayBuffer();
|
|
103
|
+
const body = new TextDecoder('utf-8', { fatal: true }).decode(buffer);
|
|
104
|
+
return JSON.parse(body);
|
|
105
|
+
},
|
|
106
|
+
async postJson(url, payload) {
|
|
107
|
+
const response = await request(url, {
|
|
108
|
+
method: 'POST',
|
|
109
|
+
headers: { 'Content-Type': 'application/json' },
|
|
110
|
+
body: JSON.stringify(payload),
|
|
111
|
+
});
|
|
112
|
+
const body = new TextDecoder('utf-8', { fatal: true }).decode(await response.arrayBuffer());
|
|
113
|
+
return JSON.parse(body);
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vendor identity: which brands this package knows, and how records are named.
|
|
3
|
+
*
|
|
4
|
+
* Kept apart from the adapters because every one of them needs it — a scraper
|
|
5
|
+
* resolves a brand to a host and, on the two AEM brands, to a component node;
|
|
6
|
+
* record identity resolves the same brand to a `vendor` string, a product URL
|
|
7
|
+
* and the UUID namespace its records are minted under.
|
|
8
|
+
*
|
|
9
|
+
* **Every record's guid is minted in its own brand's namespace.** That is what
|
|
10
|
+
* makes a guid collision across brands structurally impossible rather than
|
|
11
|
+
* merely unlikely: a material number is a vendor-local integer, and nothing
|
|
12
|
+
* reserves `100003658` to Kennametal. One namespace for every brand is
|
|
13
|
+
* harmless only while two brands share a number space by accident.
|
|
14
|
+
*
|
|
15
|
+
* The preset namespace is deliberately absent. Presets are the conversion
|
|
16
|
+
* half, which stays out of this package (see `docs/TOOL-SCRAPER-PLAN.md`,
|
|
17
|
+
* decision 1), and a constant nothing here reads would be a pinned value with
|
|
18
|
+
* nothing pinning it.
|
|
19
|
+
*/
|
|
20
|
+
/** What this package knows about one manufacturer. */
|
|
21
|
+
export interface Brand {
|
|
22
|
+
/** The bare hostname, for a transport that builds its own URLs. */
|
|
23
|
+
host: string;
|
|
24
|
+
/**
|
|
25
|
+
* The brand's home page, and therefore its guid namespace seed.
|
|
26
|
+
*
|
|
27
|
+
* **Stated rather than derived.** `https://www.{host}` is right for the two
|
|
28
|
+
* AEM brands and wrong for a host carrying its own subdomain — it would mint
|
|
29
|
+
* REGO-FIX under the nonsense URL `https://www.us.rego-fix.com`.
|
|
30
|
+
*/
|
|
31
|
+
home: string;
|
|
32
|
+
/** What the `vendor` field of a record from this brand says. */
|
|
33
|
+
vendor: string;
|
|
34
|
+
/** The vendor's own page for one part, with a `{material}` hole. */
|
|
35
|
+
productLink: string;
|
|
36
|
+
/**
|
|
37
|
+
* The AEM component node that serves a family's variant table.
|
|
38
|
+
*
|
|
39
|
+
* **AEM-specific and therefore optional** (2026-08-07). It is a fact about
|
|
40
|
+
* Kennametal's platform and not about vendors in general — REGO-FIX is a
|
|
41
|
+
* Drupal site with an Elasticsearch proxy and has no such node. Read it off
|
|
42
|
+
* a family page's `data-path` attribute when a new brand turns up.
|
|
43
|
+
*/
|
|
44
|
+
node?: string;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* The brands this package can scrape, by their internal key.
|
|
48
|
+
*
|
|
49
|
+
* WIDIA is Kennametal's sister brand on the same AEM/Hybris platform; only the
|
|
50
|
+
* host, the AEM component node and the vendor string differ. Families default
|
|
51
|
+
* to `kennametal` unless their config names a brand.
|
|
52
|
+
*/
|
|
53
|
+
export declare const BRANDS: {
|
|
54
|
+
readonly kennametal: {
|
|
55
|
+
readonly host: "kennametal.com";
|
|
56
|
+
readonly home: "https://www.kennametal.com";
|
|
57
|
+
readonly node: "product_variants";
|
|
58
|
+
readonly vendor: "Kennametal";
|
|
59
|
+
readonly productLink: "https://www.kennametal.com/us/en/products/p.{material}.html";
|
|
60
|
+
};
|
|
61
|
+
readonly widia: {
|
|
62
|
+
readonly host: "widia.com";
|
|
63
|
+
readonly home: "https://www.widia.com";
|
|
64
|
+
readonly node: "product_variants_cop";
|
|
65
|
+
readonly vendor: "WIDIA";
|
|
66
|
+
readonly productLink: "https://www.widia.com/us/en/products/p.{material}.html";
|
|
67
|
+
};
|
|
68
|
+
readonly regofix: {
|
|
69
|
+
readonly host: "us.rego-fix.com";
|
|
70
|
+
readonly home: "https://us.rego-fix.com";
|
|
71
|
+
readonly vendor: "REGO-FIX";
|
|
72
|
+
readonly productLink: "https://us.rego-fix.com/en/productfinder?q={material}";
|
|
73
|
+
};
|
|
74
|
+
readonly destinytool: {
|
|
75
|
+
readonly host: "destinytool.com";
|
|
76
|
+
readonly home: "https://destinytool.com";
|
|
77
|
+
readonly vendor: "Destiny Tool";
|
|
78
|
+
readonly productLink: "https://destinytool.com/products?search={material}";
|
|
79
|
+
};
|
|
80
|
+
};
|
|
81
|
+
/**
|
|
82
|
+
* The brands this package knows, as a type.
|
|
83
|
+
*
|
|
84
|
+
* Derived from {@link BRANDS} rather than listed, so the two cannot disagree,
|
|
85
|
+
* a new brand needs no second edit, and a typo is a compile error rather than
|
|
86
|
+
* a missing key at run time.
|
|
87
|
+
*/
|
|
88
|
+
export type BrandName = keyof typeof BRANDS;
|
|
89
|
+
/**
|
|
90
|
+
* The two brands on Kennametal's AEM platform.
|
|
91
|
+
*
|
|
92
|
+
* A named type because it is a real constraint rather than a convenience: the
|
|
93
|
+
* Kennametal transport reads {@link Brand.node}, and a brand without one
|
|
94
|
+
* simply cannot be passed to it — the scraper's signature says so, and
|
|
95
|
+
* REGO-FIX cannot reach it.
|
|
96
|
+
*
|
|
97
|
+
* The value exists alongside the type because the CLI has to check a *string*
|
|
98
|
+
* off argv against this set — a type cannot do that, and checking against
|
|
99
|
+
* {@link BRANDS} instead let `--brand regofix` through to a URL with
|
|
100
|
+
* `undefined` in its path.
|
|
101
|
+
*/
|
|
102
|
+
export declare const AEM_BRANDS: readonly ["kennametal", "widia"];
|
|
103
|
+
export type AemBrandName = (typeof AEM_BRANDS)[number];
|
|
104
|
+
/**
|
|
105
|
+
* The UUID namespace records of `brand` are minted under.
|
|
106
|
+
*
|
|
107
|
+
* Seeded from the brand's own home page, so a new brand gets a distinct
|
|
108
|
+
* namespace for free. Deterministic across machines and runs, which is the
|
|
109
|
+
* whole point: tool guids are the join key for every downstream consumer of a
|
|
110
|
+
* scrape.
|
|
111
|
+
*
|
|
112
|
+
* Kennametal's value is unchanged from the single namespace this package used
|
|
113
|
+
* before 2026-08-07 — the migration therefore churned WIDIA's six tools and
|
|
114
|
+
* nothing else.
|
|
115
|
+
*/
|
|
116
|
+
export declare function vendorNamespace(brand: BrandName): string;
|
|
117
|
+
/**
|
|
118
|
+
* The vendor's own page for one orderable part.
|
|
119
|
+
*
|
|
120
|
+
* Per-brand rather than one template, because the shape is not shared: the two
|
|
121
|
+
* AEM brands serve a product page per material number, REGO-FIX serves none at
|
|
122
|
+
* all and is linked into its ProductFinder instead. A single format string
|
|
123
|
+
* with a `{host}` hole encoded the AEM path as though it were universal.
|
|
124
|
+
*/
|
|
125
|
+
export declare function productLink(brand: BrandName, material: string): string;
|
|
126
|
+
/**
|
|
127
|
+
* The stable guid for one orderable part, from its vendor material number.
|
|
128
|
+
*
|
|
129
|
+
* One function because tools and toolholding must mint identically: a holder
|
|
130
|
+
* and a tool are different kinds of record but they share a guid space, and a
|
|
131
|
+
* consumer that builds a catalog from both refuses a collision between them.
|
|
132
|
+
*/
|
|
133
|
+
export declare function recordGuid(brand: BrandName, material: string): string;
|
package/dist/identity.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vendor identity: which brands this package knows, and how records are named.
|
|
3
|
+
*
|
|
4
|
+
* Kept apart from the adapters because every one of them needs it — a scraper
|
|
5
|
+
* resolves a brand to a host and, on the two AEM brands, to a component node;
|
|
6
|
+
* record identity resolves the same brand to a `vendor` string, a product URL
|
|
7
|
+
* and the UUID namespace its records are minted under.
|
|
8
|
+
*
|
|
9
|
+
* **Every record's guid is minted in its own brand's namespace.** That is what
|
|
10
|
+
* makes a guid collision across brands structurally impossible rather than
|
|
11
|
+
* merely unlikely: a material number is a vendor-local integer, and nothing
|
|
12
|
+
* reserves `100003658` to Kennametal. One namespace for every brand is
|
|
13
|
+
* harmless only while two brands share a number space by accident.
|
|
14
|
+
*
|
|
15
|
+
* The preset namespace is deliberately absent. Presets are the conversion
|
|
16
|
+
* half, which stays out of this package (see `docs/TOOL-SCRAPER-PLAN.md`,
|
|
17
|
+
* decision 1), and a constant nothing here reads would be a pinned value with
|
|
18
|
+
* nothing pinning it.
|
|
19
|
+
*/
|
|
20
|
+
import { uuid5, NAMESPACE_URL } from './uuid5.js';
|
|
21
|
+
/**
|
|
22
|
+
* The brands this package can scrape, by their internal key.
|
|
23
|
+
*
|
|
24
|
+
* WIDIA is Kennametal's sister brand on the same AEM/Hybris platform; only the
|
|
25
|
+
* host, the AEM component node and the vendor string differ. Families default
|
|
26
|
+
* to `kennametal` unless their config names a brand.
|
|
27
|
+
*/
|
|
28
|
+
export const BRANDS = {
|
|
29
|
+
kennametal: {
|
|
30
|
+
host: 'kennametal.com',
|
|
31
|
+
home: 'https://www.kennametal.com',
|
|
32
|
+
node: 'product_variants',
|
|
33
|
+
vendor: 'Kennametal',
|
|
34
|
+
productLink: 'https://www.kennametal.com/us/en/products/p.{material}.html',
|
|
35
|
+
},
|
|
36
|
+
widia: {
|
|
37
|
+
host: 'widia.com',
|
|
38
|
+
home: 'https://www.widia.com',
|
|
39
|
+
node: 'product_variants_cop',
|
|
40
|
+
vendor: 'WIDIA',
|
|
41
|
+
productLink: 'https://www.widia.com/us/en/products/p.{material}.html',
|
|
42
|
+
},
|
|
43
|
+
// REGO-FIX publishes no per-part page: the Drupal node behind a part
|
|
44
|
+
// redirects to `/products`, and the only place a single part is addressable
|
|
45
|
+
// is the ProductFinder, whose Searchkit `SearchBox` has the default accessor
|
|
46
|
+
// id `q` and queries `field_sku_fulltext` among others (read off
|
|
47
|
+
// `searchkit-starter-app/build/static/js/main.d1ba5577.js`, JG 2026-08-07).
|
|
48
|
+
// So the link is a search for the part number rather than a page about it,
|
|
49
|
+
// which is what the vendor actually offers.
|
|
50
|
+
regofix: {
|
|
51
|
+
host: 'us.rego-fix.com',
|
|
52
|
+
home: 'https://us.rego-fix.com',
|
|
53
|
+
vendor: 'REGO-FIX',
|
|
54
|
+
productLink: 'https://us.rego-fix.com/en/productfinder?q={material}',
|
|
55
|
+
},
|
|
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).
|
|
62
|
+
destinytool: {
|
|
63
|
+
host: 'destinytool.com',
|
|
64
|
+
home: 'https://destinytool.com',
|
|
65
|
+
vendor: 'Destiny Tool',
|
|
66
|
+
productLink: 'https://destinytool.com/products?search={material}',
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
/**
|
|
70
|
+
* The two brands on Kennametal's AEM platform.
|
|
71
|
+
*
|
|
72
|
+
* A named type because it is a real constraint rather than a convenience: the
|
|
73
|
+
* Kennametal transport reads {@link Brand.node}, and a brand without one
|
|
74
|
+
* simply cannot be passed to it — the scraper's signature says so, and
|
|
75
|
+
* REGO-FIX cannot reach it.
|
|
76
|
+
*
|
|
77
|
+
* The value exists alongside the type because the CLI has to check a *string*
|
|
78
|
+
* off argv against this set — a type cannot do that, and checking against
|
|
79
|
+
* {@link BRANDS} instead let `--brand regofix` through to a URL with
|
|
80
|
+
* `undefined` in its path.
|
|
81
|
+
*/
|
|
82
|
+
export const AEM_BRANDS = ['kennametal', 'widia'];
|
|
83
|
+
/**
|
|
84
|
+
* The UUID namespace records of `brand` are minted under.
|
|
85
|
+
*
|
|
86
|
+
* Seeded from the brand's own home page, so a new brand gets a distinct
|
|
87
|
+
* namespace for free. Deterministic across machines and runs, which is the
|
|
88
|
+
* whole point: tool guids are the join key for every downstream consumer of a
|
|
89
|
+
* scrape.
|
|
90
|
+
*
|
|
91
|
+
* Kennametal's value is unchanged from the single namespace this package used
|
|
92
|
+
* before 2026-08-07 — the migration therefore churned WIDIA's six tools and
|
|
93
|
+
* nothing else.
|
|
94
|
+
*/
|
|
95
|
+
export function vendorNamespace(brand) {
|
|
96
|
+
return uuid5(NAMESPACE_URL, BRANDS[brand].home);
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* The vendor's own page for one orderable part.
|
|
100
|
+
*
|
|
101
|
+
* Per-brand rather than one template, because the shape is not shared: the two
|
|
102
|
+
* AEM brands serve a product page per material number, REGO-FIX serves none at
|
|
103
|
+
* all and is linked into its ProductFinder instead. A single format string
|
|
104
|
+
* with a `{host}` hole encoded the AEM path as though it were universal.
|
|
105
|
+
*/
|
|
106
|
+
export function productLink(brand, material) {
|
|
107
|
+
return BRANDS[brand].productLink.replace('{material}', material);
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* The stable guid for one orderable part, from its vendor material number.
|
|
111
|
+
*
|
|
112
|
+
* One function because tools and toolholding must mint identically: a holder
|
|
113
|
+
* and a tool are different kinds of record but they share a guid space, and a
|
|
114
|
+
* consumer that builds a catalog from both refuses a collision between them.
|
|
115
|
+
*/
|
|
116
|
+
export function recordGuid(brand, material) {
|
|
117
|
+
return uuid5(vendorNamespace(brand), material);
|
|
118
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scrape cutting-tool geometry from vendor catalogs into records.
|
|
3
|
+
*
|
|
4
|
+
* A small vendor-neutral core plus one adapter per manufacturer under
|
|
5
|
+
* `vendors/`. The line between them is **what a fact is about**: a module
|
|
6
|
+
* under `vendors/` knows one manufacturer's transport, its column vocabulary
|
|
7
|
+
* or its own dimension codes, and a module beside this one knows the domain —
|
|
8
|
+
* what a tool record is, how a guid is minted, what the ISO workpiece groups
|
|
9
|
+
* are.
|
|
10
|
+
*
|
|
11
|
+
* Two adapters share no code with each other, and
|
|
12
|
+
* `tests/vendor-boundary.test.ts` asserts it from the package tree rather than
|
|
13
|
+
* from a list. What they share is the core, and that sharing is the point: it
|
|
14
|
+
* is what makes two vendors' catalogs comparable.
|
|
15
|
+
*
|
|
16
|
+
* ## This entry point is records, not files
|
|
17
|
+
*
|
|
18
|
+
* Every scrape returns rows. Writing them to a CSV, and the provenance sidecar
|
|
19
|
+
* that goes beside one, is `@toolpath/tool-scraper/node` — a separate entry
|
|
20
|
+
* point, because a backend embedding this wants the data and a maintainer
|
|
21
|
+
* running the CLI wants the file, and only one of those two needs `fs`.
|
|
22
|
+
*/
|
|
23
|
+
export * from './conventions.js';
|
|
24
|
+
export * from './errors.js';
|
|
25
|
+
export * from './family.js';
|
|
26
|
+
export * from './fetch.js';
|
|
27
|
+
export * from './identity.js';
|
|
28
|
+
export * from './provenance.js';
|
|
29
|
+
export * from './records.js';
|
|
30
|
+
export * from './scrape.js';
|
|
31
|
+
export * from './thread.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scrape cutting-tool geometry from vendor catalogs into records.
|
|
3
|
+
*
|
|
4
|
+
* A small vendor-neutral core plus one adapter per manufacturer under
|
|
5
|
+
* `vendors/`. The line between them is **what a fact is about**: a module
|
|
6
|
+
* under `vendors/` knows one manufacturer's transport, its column vocabulary
|
|
7
|
+
* or its own dimension codes, and a module beside this one knows the domain —
|
|
8
|
+
* what a tool record is, how a guid is minted, what the ISO workpiece groups
|
|
9
|
+
* are.
|
|
10
|
+
*
|
|
11
|
+
* Two adapters share no code with each other, and
|
|
12
|
+
* `tests/vendor-boundary.test.ts` asserts it from the package tree rather than
|
|
13
|
+
* from a list. What they share is the core, and that sharing is the point: it
|
|
14
|
+
* is what makes two vendors' catalogs comparable.
|
|
15
|
+
*
|
|
16
|
+
* ## This entry point is records, not files
|
|
17
|
+
*
|
|
18
|
+
* Every scrape returns rows. Writing them to a CSV, and the provenance sidecar
|
|
19
|
+
* that goes beside one, is `@toolpath/tool-scraper/node` — a separate entry
|
|
20
|
+
* point, because a backend embedding this wants the data and a maintainer
|
|
21
|
+
* running the CLI wants the file, and only one of those two needs `fs`.
|
|
22
|
+
*/
|
|
23
|
+
export * from './conventions.js';
|
|
24
|
+
export * from './errors.js';
|
|
25
|
+
export * from './family.js';
|
|
26
|
+
export * from './fetch.js';
|
|
27
|
+
export * from './identity.js';
|
|
28
|
+
export * from './provenance.js';
|
|
29
|
+
export * from './records.js';
|
|
30
|
+
export * from './scrape.js';
|
|
31
|
+
export * from './thread.js';
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The bulk STEP mirror — a maintainer's tool, not part of the library.
|
|
3
|
+
*
|
|
4
|
+
* A vendor's CAD step resolves each part's permanent URL and writes it into
|
|
5
|
+
* `conventions.CAD_COLUMN`. This reads that column back and downloads every
|
|
6
|
+
* file — and it is vendor-neutral for the same reason the column is: two
|
|
7
|
+
* adapters write it and neither owns it.
|
|
8
|
+
*
|
|
9
|
+
* **It is here rather than beside the lookup, and that is a decision.** Three
|
|
10
|
+
* things make it a poor fit for a package a Node backend imports:
|
|
11
|
+
*
|
|
12
|
+
* 1. It is filesystem-bound by nature. There is no sensible "return the data
|
|
13
|
+
* instead" version — a family is roughly a megabyte of STEP, and more at
|
|
14
|
+
* scale.
|
|
15
|
+
* 2. It is a batch job with rate-limit pauses, not a request-scoped call.
|
|
16
|
+
* Eight-plus seconds of serial fetch-and-sleep inside a request handler is
|
|
17
|
+
* the wrong shape; putting it in a worker is the consumer's decision, not
|
|
18
|
+
* this package's.
|
|
19
|
+
* 3. These are the vendor's CAD binaries. The stance is that they are mirrored
|
|
20
|
+
* locally for measuring a holder and never redistributed, and a public
|
|
21
|
+
* module offering a one-call bulk downloader is a different exposure than a
|
|
22
|
+
* command a maintainer runs.
|
|
23
|
+
*
|
|
24
|
+
* What a consumer wants is the URL — to link to, or to fetch one of on demand
|
|
25
|
+
* — which is what the annotate step already gives it. The seam was there
|
|
26
|
+
* before this split: the column exists precisely so downloading is a separate,
|
|
27
|
+
* later, optional step.
|
|
28
|
+
*/
|
|
29
|
+
import type { Fetcher } from '../fetch.js';
|
|
30
|
+
import { type ScrapedRow, type Warn } from '../scrape.js';
|
|
31
|
+
/** One STEP file onto disk. Returns the bytes written. */
|
|
32
|
+
export declare function downloadStep(fetcher: Fetcher, url: string, dest: string): Promise<number>;
|
|
33
|
+
/** One mirrored file. */
|
|
34
|
+
export interface MirroredStep {
|
|
35
|
+
catalogNumber: string;
|
|
36
|
+
bytes: number;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Every STEP model a holder scrape names, into `outDir`, one file per row.
|
|
40
|
+
*
|
|
41
|
+
* Named for the catalog number rather than the material number, because the
|
|
42
|
+
* filename is what a human reads and `BT30ER16060M` says what the part is
|
|
43
|
+
* where `1258023` does not.
|
|
44
|
+
*
|
|
45
|
+
* **`outDir` is a required argument and never inferred.** These files are a
|
|
46
|
+
* local working copy, they are gitignored, and a default that pointed into a
|
|
47
|
+
* tracked directory would be the one mistake that silently commits ~3 MB of
|
|
48
|
+
* vendor binaries.
|
|
49
|
+
*
|
|
50
|
+
* A row with no CAD URL is skipped rather than failed — that is
|
|
51
|
+
* `lightweightStepUrl`'s documented null case arriving here — and a skipped
|
|
52
|
+
* row spends no delay, because the count that matters is downloads and a
|
|
53
|
+
* family that is mostly blank should not sleep its way through the gaps.
|
|
54
|
+
*/
|
|
55
|
+
export declare function mirrorFamilySteps(fetcher: Fetcher, rows: readonly ScrapedRow[], outDir: string, delayMs?: number, warn?: Warn): Promise<MirroredStep[]>;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The bulk STEP mirror — a maintainer's tool, not part of the library.
|
|
3
|
+
*
|
|
4
|
+
* A vendor's CAD step resolves each part's permanent URL and writes it into
|
|
5
|
+
* `conventions.CAD_COLUMN`. This reads that column back and downloads every
|
|
6
|
+
* file — and it is vendor-neutral for the same reason the column is: two
|
|
7
|
+
* adapters write it and neither owns it.
|
|
8
|
+
*
|
|
9
|
+
* **It is here rather than beside the lookup, and that is a decision.** Three
|
|
10
|
+
* things make it a poor fit for a package a Node backend imports:
|
|
11
|
+
*
|
|
12
|
+
* 1. It is filesystem-bound by nature. There is no sensible "return the data
|
|
13
|
+
* instead" version — a family is roughly a megabyte of STEP, and more at
|
|
14
|
+
* scale.
|
|
15
|
+
* 2. It is a batch job with rate-limit pauses, not a request-scoped call.
|
|
16
|
+
* Eight-plus seconds of serial fetch-and-sleep inside a request handler is
|
|
17
|
+
* the wrong shape; putting it in a worker is the consumer's decision, not
|
|
18
|
+
* this package's.
|
|
19
|
+
* 3. These are the vendor's CAD binaries. The stance is that they are mirrored
|
|
20
|
+
* locally for measuring a holder and never redistributed, and a public
|
|
21
|
+
* module offering a one-call bulk downloader is a different exposure than a
|
|
22
|
+
* command a maintainer runs.
|
|
23
|
+
*
|
|
24
|
+
* What a consumer wants is the URL — to link to, or to fetch one of on demand
|
|
25
|
+
* — which is what the annotate step already gives it. The seam was there
|
|
26
|
+
* before this split: the column exists precisely so downloading is a separate,
|
|
27
|
+
* later, optional step.
|
|
28
|
+
*/
|
|
29
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
30
|
+
import { dirname, join } from 'node:path';
|
|
31
|
+
import { CAD_COLUMN } from '../conventions.js';
|
|
32
|
+
import { REQUEST_DELAY_MS, consoleWarn, pause } from '../scrape.js';
|
|
33
|
+
/**
|
|
34
|
+
* A catalog number as one path segment.
|
|
35
|
+
*
|
|
36
|
+
* REGO-FIX's catalog number is the vendor's own title — `BT 30 / PG 25 x 075`
|
|
37
|
+
* — and a separator in it was being honoured as one: `downloadStep` creates
|
|
38
|
+
* the parent directory, so the file landed in a `BT 30 ` subdirectory instead
|
|
39
|
+
* of flat in `outDir`, against what this module promises. The number stays
|
|
40
|
+
* readable, which is the whole reason the file is named for it.
|
|
41
|
+
*/
|
|
42
|
+
function fileName(catalogNumber) {
|
|
43
|
+
return catalogNumber.replaceAll(/[/\\]/g, '-');
|
|
44
|
+
}
|
|
45
|
+
/** One STEP file onto disk. Returns the bytes written. */
|
|
46
|
+
export async function downloadStep(fetcher, url, dest) {
|
|
47
|
+
// Straight to `dest` rather than through a temp file: these are ~54 KB
|
|
48
|
+
// static CDN objects, and a half-written one is caught by whatever tries to
|
|
49
|
+
// import it, not by anything here.
|
|
50
|
+
const data = await fetcher.bytes(url);
|
|
51
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
52
|
+
writeFileSync(dest, data);
|
|
53
|
+
return data.byteLength;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Every STEP model a holder scrape names, into `outDir`, one file per row.
|
|
57
|
+
*
|
|
58
|
+
* Named for the catalog number rather than the material number, because the
|
|
59
|
+
* filename is what a human reads and `BT30ER16060M` says what the part is
|
|
60
|
+
* where `1258023` does not.
|
|
61
|
+
*
|
|
62
|
+
* **`outDir` is a required argument and never inferred.** These files are a
|
|
63
|
+
* local working copy, they are gitignored, and a default that pointed into a
|
|
64
|
+
* tracked directory would be the one mistake that silently commits ~3 MB of
|
|
65
|
+
* vendor binaries.
|
|
66
|
+
*
|
|
67
|
+
* A row with no CAD URL is skipped rather than failed — that is
|
|
68
|
+
* `lightweightStepUrl`'s documented null case arriving here — and a skipped
|
|
69
|
+
* row spends no delay, because the count that matters is downloads and a
|
|
70
|
+
* family that is mostly blank should not sleep its way through the gaps.
|
|
71
|
+
*/
|
|
72
|
+
export async function mirrorFamilySteps(fetcher, rows, outDir, delayMs = REQUEST_DELAY_MS, warn = consoleWarn) {
|
|
73
|
+
const written = [];
|
|
74
|
+
for (const row of rows) {
|
|
75
|
+
const url = (row[CAD_COLUMN] ?? '').trim();
|
|
76
|
+
if (!url)
|
|
77
|
+
continue;
|
|
78
|
+
const catalogNumber = row['ISO Catalog Number'] ?? '';
|
|
79
|
+
if (!catalogNumber) {
|
|
80
|
+
warn(` SKIPPED a row with a CAD URL and no catalog number to name it`);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (written.length > 0)
|
|
84
|
+
await pause(delayMs);
|
|
85
|
+
const bytes = await downloadStep(fetcher, url, join(outDir, `${fileName(catalogNumber)}.stp`));
|
|
86
|
+
written.push({ catalogNumber, bytes });
|
|
87
|
+
}
|
|
88
|
+
return written;
|
|
89
|
+
}
|