@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,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;
@@ -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
+ }
@@ -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
+ }