@toolpath/tool-scraper 2.1.0 → 2.3.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 (43) hide show
  1. package/dist/conventions.d.ts +48 -2
  2. package/dist/conventions.js +41 -0
  3. package/dist/family.d.ts +16 -2
  4. package/dist/holding.d.ts +396 -0
  5. package/dist/holding.js +360 -0
  6. package/dist/index.d.ts +23 -13
  7. package/dist/index.js +23 -13
  8. package/dist/measure.d.ts +14 -10
  9. package/dist/measure.js +14 -13
  10. package/dist/node/cad-mirror.d.ts +58 -1
  11. package/dist/node/cad-mirror.js +56 -8
  12. package/dist/node/cli.d.ts +4 -1
  13. package/dist/node/cli.js +192 -18
  14. package/dist/node/holder-import.d.ts +223 -0
  15. package/dist/node/holder-import.js +379 -0
  16. package/dist/node/index.d.ts +1 -0
  17. package/dist/node/index.js +1 -0
  18. package/dist/node/paths.d.ts +16 -0
  19. package/dist/node/paths.js +20 -0
  20. package/dist/profiles.d.ts +275 -0
  21. package/dist/profiles.js +295 -0
  22. package/dist/provenance.d.ts +9 -1
  23. package/dist/provenance.js +9 -1
  24. package/dist/records.d.ts +60 -20
  25. package/dist/records.js +31 -19
  26. package/dist/registry.d.ts +61 -5
  27. package/dist/registry.js +109 -6
  28. package/dist/vendors/kennametal/holding.d.ts +35 -0
  29. package/dist/vendors/kennametal/holding.js +112 -0
  30. package/dist/vendors/kennametal/index.d.ts +1 -0
  31. package/dist/vendors/kennametal/index.js +1 -0
  32. package/dist/vendors/maritool/holding.d.ts +79 -0
  33. package/dist/vendors/maritool/holding.js +164 -0
  34. package/dist/vendors/maritool/index.d.ts +1 -0
  35. package/dist/vendors/maritool/index.js +1 -0
  36. package/dist/vendors/maritool/scrape.d.ts +37 -13
  37. package/dist/vendors/maritool/scrape.js +53 -14
  38. package/dist/vendors/regofix/holding.d.ts +35 -0
  39. package/dist/vendors/regofix/holding.js +108 -0
  40. package/dist/vendors/regofix/index.d.ts +1 -0
  41. package/dist/vendors/regofix/index.js +1 -0
  42. package/dist/vendors/regofix/scrape.js +2 -2
  43. package/package.json +3 -2
@@ -0,0 +1,379 @@
1
+ /**
2
+ * Measuring a mirrored holder through the Toolpath Engine API.
3
+ *
4
+ * The step between `node/cad-mirror.ts`, which puts a vendor's STEP file on
5
+ * disk, and `profiles.ts`, which turns a measurement into a drawing's
6
+ * silhouette. Five calls per holder: create, upload, queue, poll, read.
7
+ *
8
+ * **This is what deleted the Fusion dependency.** The pipeline this replaces
9
+ * could only measure a holder on a machine running Fusion 360 with an MCP
10
+ * bridge attached to it — roughly 1,800 lines of Python driving a GUI
11
+ * application. The same numbers now come back in about two seconds over HTTP,
12
+ * to nanometre agreement, from something that can run in CI.
13
+ *
14
+ * ## Why it is in `node/` and not in the library half
15
+ *
16
+ * The same three reasons `cad-mirror.ts` gives for itself, and they all hold
17
+ * here: it reads a mirrored binary off disk, it is a batch job with pacing
18
+ * rather than a request-scoped call, and it is a maintainer's command rather
19
+ * than something a backend embeds.
20
+ *
21
+ * ## Why it does not go through `fetch.Fetcher`
22
+ *
23
+ * `Fetcher` is four GET-and-decode shapes for anonymous vendor endpoints. This
24
+ * needs a bearer token, a PUT of raw bytes to a presigned URL and a PATCH with
25
+ * an idempotency header, and widening a public interface every consumer may
26
+ * have implemented — to serve one maintainer's command — is a break bought for
27
+ * nothing. So the transport is small and local, and injectable the same way
28
+ * `fetch.FetcherOptions.fetch` is, which is what lets a test drive the whole
29
+ * five-call flow with no stack behind it.
30
+ *
31
+ * **`@toolpath/api` is deliberately not a dependency.** This package takes one
32
+ * runtime dependency today and a second is a decision every consumer inherits;
33
+ * the generated SDK is pinned to `openapi/openapi.json` at v1.1.0, which has no
34
+ * holder routes at all, so it could not make these calls until that pin moves.
35
+ *
36
+ * ## What refuses, and what is survivable
37
+ *
38
+ * The split is `holding.ts`'s, for the same reason: a failed **job** is one
39
+ * holder's model the kernel could not read, so it is an `IncompletePartError`
40
+ * and {@link measureFamily} warns and drops it — losing a 431-holder batch to
41
+ * one bad STEP file is not a trade worth making. Anything else — a transport
42
+ * failure, a non-2xx, a response whose shape this cannot read — is a
43
+ * `VendorResponseError` and stops the run.
44
+ */
45
+ import { readFileSync } from 'node:fs';
46
+ import { join } from 'node:path';
47
+ import { IncompletePartError, VendorResponseError } from '../errors.js';
48
+ import { TAPER_FAMILIES } from '../profiles.js';
49
+ import { REQUEST_DELAY_MS, consoleWarn, pause } from '../scrape.js';
50
+ import { stepFileName } from './cad-mirror.js';
51
+ /** Where the holder routes are. Set it to `http://localhost:4000` for development. */
52
+ export const API_URL_ENV = 'TOOLPATH_API_URL';
53
+ /**
54
+ * The bearer token, **from the environment only**.
55
+ *
56
+ * Never a flag, never a file path this package reads, never logged, never
57
+ * written into a receipt and never echoed in an error — an API key that reaches
58
+ * a terminal reaches a shell history and a CI log with it.
59
+ */
60
+ export const API_KEY_ENV = 'TOOLPATH_API_KEY';
61
+ /** Where the API is when nothing says otherwise. */
62
+ export const DEFAULT_API_URL = 'https://api.toolpath.com';
63
+ /**
64
+ * The import options this pipeline measures with.
65
+ *
66
+ * `tolerance` matches the reference implementation's, and there is deliberately
67
+ * no segment budget behind it: a real BT30-ER16 is 69 segments at 0.01 mm and
68
+ * still 51 at 1.0 mm, because those are grooves and thread reliefs rather than
69
+ * sampling noise, and relaxing toward a budget inflates the holder until the
70
+ * flange-to-taper step is swallowed and the cone stops being detectable.
71
+ *
72
+ * **`fillBays` is off, and that is the fork.** Raising each solid's enclosed
73
+ * bays to their brims — the V-flange groove, the thread relief — is right for a
74
+ * collision envelope and wrong for a drawing: the groove and the relief are
75
+ * what a machinist looks for, so the literal silhouette is the honest thing to
76
+ * store. The consumer here is `@toolpath/tool-drawing`, so it is off, and the
77
+ * option is recorded in the document so nothing downstream has to guess which
78
+ * it got. A second run with it on, for a Fusion collision library, is a later
79
+ * optional step rather than a fork in this code.
80
+ *
81
+ * `flipped` is an override for a holder the automatic orientation reads
82
+ * backwards, not a setting: the automatic pass reads the 7:24 taper and got
83
+ * every validated holder right.
84
+ */
85
+ export const DEFAULT_IMPORT_OPTIONS = {
86
+ tolerance: 0.05,
87
+ fillBays: false,
88
+ flipped: false,
89
+ };
90
+ /** Milliseconds between polls of an import job. Imports finish in about two seconds. */
91
+ export const POLL_INTERVAL_MS = 500;
92
+ /** Milliseconds before an import job that never settles is abandoned. */
93
+ export const POLL_TIMEOUT_MS = 120_000;
94
+ /**
95
+ * How many times one rate-limited request is retried before the run gives up.
96
+ *
97
+ * Generous on purpose: the window the API is asking the client to wait out is
98
+ * measured in tens of seconds, and the alternative to waiting is abandoning a
99
+ * batch that has already paid for every holder before this one.
100
+ */
101
+ export const RATE_LIMIT_ATTEMPTS = 6;
102
+ /** What to wait when the API names no `Retry-After` of its own, in milliseconds. */
103
+ export const RATE_LIMIT_BACKOFF_MS = 2_000;
104
+ /**
105
+ * How long to wait out a 429, preferring the API's own answer.
106
+ *
107
+ * `Retry-After` is in seconds and is the only number that knows when the
108
+ * window rolls, so it wins wherever it parses. A header that is absent, empty,
109
+ * or an HTTP-date rather than a delay yields `NaN`, and the fallback grows with
110
+ * the attempt so a client that cannot read the header still backs off.
111
+ */
112
+ export function retryAfterMs(response, attempt) {
113
+ const stated = Number(response.headers.get('retry-after'));
114
+ return Number.isFinite(stated) && stated > 0 ? stated * 1_000 : RATE_LIMIT_BACKOFF_MS * attempt;
115
+ }
116
+ /** The resolved API base URL, without its trailing slash. */
117
+ export function apiUrl() {
118
+ return (process.env[API_URL_ENV] || DEFAULT_API_URL).replace(/\/+$/, '');
119
+ }
120
+ /**
121
+ * One line naming the resolved API base URL and how it was resolved.
122
+ *
123
+ * Printed by every command that measures, for the reason `paths.describeRoot`
124
+ * is printed by every command that scrapes: production is still Engine API
125
+ * v1.1.0 and carries none of these routes, so a run that went somewhere
126
+ * surprising should say so on the way rather than be discovered in a 404.
127
+ */
128
+ export function describeApi() {
129
+ const how = process.env[API_URL_ENV] ? 'set' : 'default';
130
+ return `holder API: ${apiUrl()} (${API_URL_ENV} ${how})`;
131
+ }
132
+ /**
133
+ * The transport, authenticated and timed out.
134
+ *
135
+ * Refuses at construction when no key is set rather than at the first request:
136
+ * a batch that authenticated per holder would fail on holder one after mirroring
137
+ * the whole family, and the message would be about a 401 instead of about an
138
+ * unset variable.
139
+ */
140
+ export function createHolderApi(options = {}) {
141
+ const { baseUrl = apiUrl(), apiKey = process.env[API_KEY_ENV] ?? '', fetch: send = globalThis.fetch, timeoutMs = 60_000, pollIntervalMs = POLL_INTERVAL_MS, pollTimeoutMs = POLL_TIMEOUT_MS, rateLimitAttempts = RATE_LIMIT_ATTEMPTS, } = options;
142
+ if (!apiKey) {
143
+ throw new VendorResponseError(API_KEY_ENV, 'is not set — the holder routes are authenticated, and this package ' +
144
+ 'reads the key from the environment and from nowhere else');
145
+ }
146
+ async function request(url, init) {
147
+ for (let attempt = 1;; attempt += 1) {
148
+ const response = await send(url, { ...init, signal: AbortSignal.timeout(timeoutMs) });
149
+ if (response.ok) {
150
+ return response;
151
+ }
152
+ // A 429 is the API scheduling this client, not refusing it. Measuring is
153
+ // the most request-hungry thing this package does — five calls per holder
154
+ // and a poll until the job settles — so a family of any size reaches the
155
+ // per-key window, and a run that treated that as fatal died partway
156
+ // through a 217-holder batch with 47 holders left unmeasured. Every other
157
+ // non-2xx still stops the run.
158
+ if (response.status === 429 && attempt <= rateLimitAttempts) {
159
+ await pause(retryAfterMs(response, attempt));
160
+ continue;
161
+ }
162
+ // The status and the path, never the body: an error body from an
163
+ // authenticated endpoint is the one place a token could be echoed back.
164
+ throw new VendorResponseError(`${init.method ?? 'GET'} ${new URL(url).pathname}`, `the holder API answered ${response.status}`);
165
+ }
166
+ }
167
+ return {
168
+ pollIntervalMs,
169
+ pollTimeoutMs,
170
+ async call(method, path, headers = {}) {
171
+ const response = await request(`${baseUrl}${path}`, {
172
+ method,
173
+ headers: { Authorization: `Bearer ${apiKey}`, ...headers },
174
+ });
175
+ return (await response.json());
176
+ },
177
+ async put(url, body) {
178
+ await request(url, { method: 'PUT', body });
179
+ },
180
+ };
181
+ }
182
+ /** `?tolerance=…&fillBays=…&flipped=…`, in the order the API documents them. */
183
+ function importQuery(options) {
184
+ return (`?tolerance=${options.tolerance}` +
185
+ `&fillBays=${options.fillBays}` +
186
+ `&flipped=${options.flipped}`);
187
+ }
188
+ /**
189
+ * A key that stops one retried queue call dispatching a second import.
190
+ *
191
+ * **Keyed on the holder the API just created, not on the catalog number**, and
192
+ * the distinction is the whole correctness of this function. The API binds a
193
+ * key to the holder it first saw and refuses a later request that reuses the
194
+ * key for a different one — `idempotency_key_reused`, 409. {@link measureHolder}
195
+ * creates a *fresh* holder on every call, so a key derived from the catalog
196
+ * number is the same string naming a different holder on the second run: the
197
+ * second measurement of any family 409s on its first part, permanently, for
198
+ * that organisation. That is what this was doing until 2026-09-02.
199
+ *
200
+ * So the scope this can honestly promise is **one run**: a `PATCH` retried
201
+ * after a transport blip replays the job it already dispatched instead of
202
+ * queueing a second. It cannot make re-running an interrupted family free — the
203
+ * earlier docstring claimed that, and the API's own dedupe rule makes it
204
+ * unreachable, because there is no way to ask for the holder a previous run
205
+ * created. Resuming cheaply is `profiles.ts`'s job, by not re-measuring what
206
+ * the store already holds.
207
+ *
208
+ * The options stay in the key: the same holder imported at two tolerances is
209
+ * two different measurements and must not deduplicate to one.
210
+ */
211
+ export function idempotencyKey(holderId, options) {
212
+ return `${holderId} ${options.tolerance} ${options.fillBays} ${options.flipped}`.replaceAll(/[^\x20-\x7e]/g, '-');
213
+ }
214
+ /** A field of a JSON response, named so a shape change says which one moved. */
215
+ function field(body, key, what) {
216
+ const value = body?.[key];
217
+ if (value === undefined) {
218
+ throw new VendorResponseError(what, `the holder API response carries no ${key}`);
219
+ }
220
+ return value;
221
+ }
222
+ function stringField(body, key, what) {
223
+ const value = field(body, key, what);
224
+ if (typeof value !== 'string') {
225
+ throw new VendorResponseError(what, `${key} is ${typeof value}, expected a string`);
226
+ }
227
+ return value;
228
+ }
229
+ function numberOrNull(body, key, what) {
230
+ const value = field(body, key, what);
231
+ if (value !== null && typeof value !== 'number') {
232
+ throw new VendorResponseError(what, `${key} is ${typeof value}, expected a number or null`);
233
+ }
234
+ return value;
235
+ }
236
+ /**
237
+ * A `HolderResponse` as the fields a profile needs, and nothing else.
238
+ *
239
+ * **The one place the API's shape is read**, so a route that changes fails here
240
+ * naming the field rather than as an undefined three transforms downstream. The
241
+ * quality signals it drops — `axisAreaFraction`, `faceCount`, `sampleCount` —
242
+ * are read and reported by {@link measureFamily} rather than carried, because
243
+ * they say something about a run and nothing about the shape of the holder.
244
+ */
245
+ export function parseHolderResponse(body, part) {
246
+ const what = part.catalogNumber;
247
+ const layers = field(body, 'layers', what);
248
+ if (!Array.isArray(layers)) {
249
+ throw new VendorResponseError(what, 'layers is not an array');
250
+ }
251
+ const taperFamily = field(body, 'taperFamily', what);
252
+ if (taperFamily !== null && !TAPER_FAMILIES.includes(taperFamily)) {
253
+ throw new VendorResponseError(what, `taperFamily is ${JSON.stringify(taperFamily)} (known: ${TAPER_FAMILIES.join(', ')})`);
254
+ }
255
+ const options = field(body, 'options', what);
256
+ return {
257
+ brand: part.brand,
258
+ catalogNumber: part.catalogNumber,
259
+ layers: layers.map((layer, index) => cone(layer, `${what} layer ${index}`)),
260
+ gaugeLength: numberOrNull(body, 'gaugeLength', what),
261
+ sizeClass: numberOrNull(body, 'sizeClass', what),
262
+ taperFamily: taperFamily,
263
+ kernelVersion: stringField(body, 'kernelVersion', what),
264
+ options: {
265
+ tolerance: numberOrNull(options, 'tolerance', what) ?? 0,
266
+ fillBays: field(options, 'fillBays', what) === true,
267
+ flipped: field(options, 'flipped', what) === true,
268
+ },
269
+ };
270
+ }
271
+ function cone(layer, what) {
272
+ const read = (key) => {
273
+ const value = field(layer, key, what);
274
+ if (typeof value !== 'number') {
275
+ throw new VendorResponseError(what, `${key} is ${typeof value}, expected a number`);
276
+ }
277
+ return value;
278
+ };
279
+ return {
280
+ thickness: read('thickness'),
281
+ bottomDiameter: read('bottomDiameter'),
282
+ topDiameter: read('topDiameter'),
283
+ };
284
+ }
285
+ /**
286
+ * One mirrored STEP file, measured.
287
+ *
288
+ * The five calls of the contract, in order, with the poll in the middle: create
289
+ * a holder and take its presigned upload URL, PUT the bytes, queue the import,
290
+ * wait for the job, read the result.
291
+ *
292
+ * Polling rather than the job's SSE stream, because a batch of 431 wants a
293
+ * simple loop and every validated import settled in about two seconds.
294
+ */
295
+ export async function measureHolder(api, part, step, options = DEFAULT_IMPORT_OPTIONS) {
296
+ const what = part.catalogNumber;
297
+ const file = stepFileName(part.catalogNumber);
298
+ const created = await api.call('POST', `/v1/holders?filename=${encodeURIComponent(file)}`);
299
+ const holderId = stringField(created, 'holderId', what);
300
+ await api.put(stringField(created, 'uploadUrl', what), step);
301
+ const queued = await api.call('PATCH', `/v1/holders/${holderId}${importQuery(options)}`, { 'Idempotency-Key': idempotencyKey(holderId, options) });
302
+ const jobId = stringField(queued, 'jobId', what);
303
+ await awaitJob(api, jobId, what);
304
+ return parseHolderResponse(await api.call('GET', `/v1/holders/${holderId}?jobId=${jobId}`), part);
305
+ }
306
+ /**
307
+ * Wait for one import job to settle.
308
+ *
309
+ * A `failed` job is an `IncompletePartError` — one holder whose model the
310
+ * kernel could not read, which {@link measureFamily} warns about and drops. A
311
+ * job that never settles is a `VendorResponseError`, because a batch that hung
312
+ * on holder one is a stack that is not working rather than a holder that is not
313
+ * measurable.
314
+ */
315
+ async function awaitJob(api, jobId, what) {
316
+ const deadline = Date.now() + api.pollTimeoutMs;
317
+ for (;;) {
318
+ const job = await api.call('GET', `/v1/jobs/${jobId}`);
319
+ const status = stringField(job, 'status', what);
320
+ if (status === 'succeeded')
321
+ return;
322
+ if (status === 'failed') {
323
+ const error = job.error;
324
+ throw new IncompletePartError(what, `the holder import failed: ${typeof error === 'string' && error ? error : 'no reason given'}`);
325
+ }
326
+ if (Date.now() >= deadline) {
327
+ throw new VendorResponseError(what, `the holder import was still ${status} after ${Math.round(api.pollTimeoutMs / 1000)}s`);
328
+ }
329
+ await pause(api.pollIntervalMs);
330
+ }
331
+ }
332
+ /** One holder's mirrored STEP file, or null where the vendor published none. */
333
+ export function readMirroredStep(stepRoot, catalogNumber) {
334
+ try {
335
+ return readFileSync(join(stepRoot, stepFileName(catalogNumber)));
336
+ }
337
+ catch (error) {
338
+ if (error.code === 'ENOENT')
339
+ return null;
340
+ throw error;
341
+ }
342
+ }
343
+ /**
344
+ * Every holder of one family that has a mirrored model, measured, paced.
345
+ *
346
+ * **A holder with no mirrored file is counted, not failed.** MariTool publishes
347
+ * no STEP model for about a third of its parts and none at all for its CAT50
348
+ * line, so an absent file is the ordinary case rather than a fault, and
349
+ * `cad-mirror.cadCoverage` is what says how many there will be before any of
350
+ * this runs.
351
+ *
352
+ * The pace is the mirror's, and for the same reason: this is a maintainer's
353
+ * batch against a service, and 431 imports arriving as fast as a loop can issue
354
+ * them is a different kind of request than one holder being measured.
355
+ */
356
+ export async function measureFamily(api, holders, stepRoot, options = DEFAULT_IMPORT_OPTIONS, delayMs = REQUEST_DELAY_MS, warn = consoleWarn) {
357
+ const measured = [];
358
+ const unmirrored = [];
359
+ const failed = [];
360
+ for (const holder of holders) {
361
+ const step = readMirroredStep(stepRoot, holder.catalogNumber);
362
+ if (step === null) {
363
+ unmirrored.push(holder.catalogNumber);
364
+ continue;
365
+ }
366
+ if (measured.length + failed.length > 0)
367
+ await pause(delayMs);
368
+ try {
369
+ measured.push(await measureHolder(api, holder, step, options));
370
+ }
371
+ catch (error) {
372
+ if (!(error instanceof IncompletePartError))
373
+ throw error;
374
+ warn(` WARNING: ${error.message} — no profile written for it`);
375
+ failed.push(holder.catalogNumber);
376
+ }
377
+ }
378
+ return { measured, unmirrored, failed };
379
+ }
@@ -12,5 +12,6 @@
12
12
  export * from './cad-mirror.js';
13
13
  export * from './cli.js';
14
14
  export * from './csv.js';
15
+ export * from './holder-import.js';
15
16
  export * from './paths.js';
16
17
  export * from './receipts.js';
@@ -12,5 +12,6 @@
12
12
  export * from './cad-mirror.js';
13
13
  export * from './cli.js';
14
14
  export * from './csv.js';
15
+ export * from './holder-import.js';
15
16
  export * from './paths.js';
16
17
  export * from './receipts.js';
@@ -51,6 +51,22 @@ export declare function csvDir(brand: BrandName): string;
51
51
  * measuring a holder, and only a derived profile is ever meant to leave.
52
52
  */
53
53
  export declare function stepDir(brand: BrandName): string;
54
+ /**
55
+ * One vendor's measured holder profiles, one JSON per holder family.
56
+ *
57
+ * Beside {@link stepDir} rather than under it: a STEP model is the vendor's
58
+ * binary and a profile is this package's own derived measurement, and only the
59
+ * second one is ever meant to leave.
60
+ */
61
+ export declare function profilesDir(brand: BrandName): string;
62
+ /**
63
+ * The merged profiles document, across every vendor.
64
+ *
65
+ * At the root rather than under a brand, because it is the cross-vendor
66
+ * document — keyed by guid, which is the one namespace holders of every brand
67
+ * share.
68
+ */
69
+ export declare function profilesJson(): string;
54
70
  /**
55
71
  * Where one family's CSV lives, resolved through its own brand.
56
72
  *
@@ -69,6 +69,26 @@ export function csvDir(brand) {
69
69
  export function stepDir(brand) {
70
70
  return join(scrapeRoot(), brand, 'step');
71
71
  }
72
+ /**
73
+ * One vendor's measured holder profiles, one JSON per holder family.
74
+ *
75
+ * Beside {@link stepDir} rather than under it: a STEP model is the vendor's
76
+ * binary and a profile is this package's own derived measurement, and only the
77
+ * second one is ever meant to leave.
78
+ */
79
+ export function profilesDir(brand) {
80
+ return join(scrapeRoot(), brand, 'profiles');
81
+ }
82
+ /**
83
+ * The merged profiles document, across every vendor.
84
+ *
85
+ * At the root rather than under a brand, because it is the cross-vendor
86
+ * document — keyed by guid, which is the one namespace holders of every brand
87
+ * share.
88
+ */
89
+ export function profilesJson() {
90
+ return join(scrapeRoot(), 'profiles.json');
91
+ }
72
92
  /**
73
93
  * Where one family's CSV lives, resolved through its own brand.
74
94
  *