@toolpath/tool-scraper 2.0.0 → 2.2.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/dist/columns.d.ts +5 -3
- package/dist/columns.js +11 -5
- package/dist/conventions.d.ts +54 -0
- package/dist/conventions.js +59 -0
- package/dist/errors.d.ts +30 -0
- package/dist/errors.js +30 -0
- package/dist/families/emuge.d.ts +3 -1
- package/dist/families/emuge.js +8 -4
- package/dist/family.d.ts +16 -2
- package/dist/holding.d.ts +396 -0
- package/dist/holding.js +360 -0
- package/dist/index.d.ts +23 -13
- package/dist/index.js +23 -13
- package/dist/node/cad-mirror.d.ts +58 -1
- package/dist/node/cad-mirror.js +56 -8
- package/dist/node/cli.d.ts +4 -1
- package/dist/node/cli.js +198 -19
- package/dist/node/holder-import.d.ts +223 -0
- package/dist/node/holder-import.js +379 -0
- package/dist/node/index.d.ts +1 -0
- package/dist/node/index.js +1 -0
- package/dist/node/paths.d.ts +16 -0
- package/dist/node/paths.js +20 -0
- package/dist/profiles.d.ts +265 -0
- package/dist/profiles.js +295 -0
- package/dist/records.d.ts +57 -12
- package/dist/records.js +37 -13
- package/dist/registry.d.ts +86 -5
- package/dist/registry.js +149 -8
- package/dist/vendors/destinytool/records.js +8 -0
- package/dist/vendors/emuge/records.d.ts +83 -3
- package/dist/vendors/emuge/records.js +184 -16
- package/dist/vendors/kennametal/family.d.ts +119 -0
- package/dist/vendors/kennametal/family.js +155 -0
- package/dist/vendors/kennametal/holding.d.ts +35 -0
- package/dist/vendors/kennametal/holding.js +112 -0
- package/dist/vendors/kennametal/index.d.ts +2 -0
- package/dist/vendors/kennametal/index.js +2 -0
- package/dist/vendors/kennametal/records.d.ts +16 -0
- package/dist/vendors/kennametal/records.js +32 -0
- package/dist/vendors/kennametal/scrape.d.ts +25 -2
- package/dist/vendors/kennametal/scrape.js +28 -3
- package/dist/vendors/maritool/holding.d.ts +79 -0
- package/dist/vendors/maritool/holding.js +164 -0
- package/dist/vendors/maritool/index.d.ts +1 -0
- package/dist/vendors/maritool/index.js +1 -0
- package/dist/vendors/maritool/scrape.d.ts +37 -13
- package/dist/vendors/maritool/scrape.js +53 -14
- package/dist/vendors/regofix/holding.d.ts +35 -0
- package/dist/vendors/regofix/holding.js +108 -0
- package/dist/vendors/regofix/index.d.ts +1 -0
- package/dist/vendors/regofix/index.js +1 -0
- package/dist/vendors/regofix/scrape.js +2 -2
- package/package.json +1 -1
|
@@ -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
|
+
}
|
package/dist/node/index.d.ts
CHANGED
package/dist/node/index.js
CHANGED
package/dist/node/paths.d.ts
CHANGED
|
@@ -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
|
*
|
package/dist/node/paths.js
CHANGED
|
@@ -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
|
*
|