@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/registry.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which adapter serves which family — the composition root.
|
|
3
|
+
*
|
|
4
|
+
* `families/` is the config table and `vendors/` are the adapters; this is the
|
|
5
|
+
* one module that knows both, and it exists so that neither has to. Putting
|
|
6
|
+
* the binding in the config table made the table import a manufacturer, which
|
|
7
|
+
* `tests/vendor-boundary.test.ts` refuses for good reason: the table is read
|
|
8
|
+
* by every test, and none of those should drag a vendor's scraper in behind it.
|
|
9
|
+
*
|
|
10
|
+
* **This is the seam a per-vendor distribution would replace with an
|
|
11
|
+
* entry-point registry.** When a vendor package can be installed from outside
|
|
12
|
+
* the tree, the {@link ADAPTERS} table below becomes a lookup over those and
|
|
13
|
+
* nothing else here changes shape. Until then one repository holds every
|
|
14
|
+
* adapter, which is what lets one commit change the record contract and every
|
|
15
|
+
* adapter together.
|
|
16
|
+
*
|
|
17
|
+
* ## Binding is lazy, not an import side effect
|
|
18
|
+
*
|
|
19
|
+
* A mapping fault has to surface as an error naming the family, not as a
|
|
20
|
+
* missing-key fault from inside a mapper on row 1 of a scrape that already
|
|
21
|
+
* ran. Binding at import would buy that and charge for it: a consumer that
|
|
22
|
+
* imports one helper should not pay to validate a catalog it never reads.
|
|
23
|
+
*
|
|
24
|
+
* So it is memoised instead: the first call to {@link boundFamilies} validates
|
|
25
|
+
* every family and every fact, and the failure still names the family and the
|
|
26
|
+
* key. What changes is only *when* — first use rather than import — and every
|
|
27
|
+
* entry point into this package goes through here.
|
|
28
|
+
*/
|
|
29
|
+
import { COLLET_FAMILIES, FAMILIES, HOLDER_FAMILIES } from './families/index.js';
|
|
30
|
+
import { ScraperConfigError } from './errors.js';
|
|
31
|
+
import { checkFact } from './provenance.js';
|
|
32
|
+
import { checkColumnMap } from './records.js';
|
|
33
|
+
import { RECORD_MAPPERS as DESTINYTOOL } from './vendors/destinytool/records.js';
|
|
34
|
+
import { RECORD_MAPPERS as KENNAMETAL } from './vendors/kennametal/records.js';
|
|
35
|
+
/**
|
|
36
|
+
* Brand -> its row-to-record mappers, by tool kind.
|
|
37
|
+
*
|
|
38
|
+
* One entry serves two brands: Kennametal and WIDIA are the same AEM platform
|
|
39
|
+
* and the same table vocabulary, so one adapter covers both, exactly as one
|
|
40
|
+
* scraper does. A brand absent from here can still be scraped — REGO-FIX ships
|
|
41
|
+
* toolholding and no cutting tools, and only cutting tools go through a column
|
|
42
|
+
* map.
|
|
43
|
+
*/
|
|
44
|
+
export const ADAPTERS = {
|
|
45
|
+
kennametal: KENNAMETAL,
|
|
46
|
+
widia: KENNAMETAL,
|
|
47
|
+
destinytool: DESTINYTOOL,
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* Check every fact of one family and project its value onto the config under
|
|
51
|
+
* its own key.
|
|
52
|
+
*
|
|
53
|
+
* **This is what stops a fact being a second source of truth.** The fact is
|
|
54
|
+
* the only authored copy; the plain key is a projection with one owner.
|
|
55
|
+
* Readers keep saying `family.unit` and never learn about provenance, which is
|
|
56
|
+
* right — provenance is evidence for a person and a gate, not an input to
|
|
57
|
+
* arithmetic.
|
|
58
|
+
*
|
|
59
|
+
* `FamilyDefinition` declares no constant keys, so a family that also set the
|
|
60
|
+
* plain key is a compile error and there is nothing left to check at runtime.
|
|
61
|
+
*/
|
|
62
|
+
function project(table, name, cfg) {
|
|
63
|
+
const facts = cfg.facts ?? {};
|
|
64
|
+
const projected = { ...cfg };
|
|
65
|
+
for (const [key, fact] of Object.entries(facts)) {
|
|
66
|
+
checkFact(`${table} ${name}`, key, fact);
|
|
67
|
+
projected[key] = fact.value;
|
|
68
|
+
}
|
|
69
|
+
return projected;
|
|
70
|
+
}
|
|
71
|
+
let families = null;
|
|
72
|
+
let toolholding = null;
|
|
73
|
+
/**
|
|
74
|
+
* Every cutting-tool family, validated and bound to its record mapper.
|
|
75
|
+
*
|
|
76
|
+
* Memoised, so calling it twice is free and a fault is reported once.
|
|
77
|
+
*/
|
|
78
|
+
export function boundFamilies() {
|
|
79
|
+
if (families !== null)
|
|
80
|
+
return families;
|
|
81
|
+
const bound = new Map();
|
|
82
|
+
for (const [name, cfg] of Object.entries(FAMILIES)) {
|
|
83
|
+
const brand = cfg.brand ?? 'kennametal';
|
|
84
|
+
const mappers = ADAPTERS[brand];
|
|
85
|
+
if (mappers === undefined) {
|
|
86
|
+
throw new ScraperConfigError(name, `brand ${JSON.stringify(brand)} has no record adapter — a vendor ` +
|
|
87
|
+
`that ships cutting tools needs one ` +
|
|
88
|
+
`(known: ${Object.keys(ADAPTERS).sort().join(', ')})`);
|
|
89
|
+
}
|
|
90
|
+
const records = mappers[cfg.kind];
|
|
91
|
+
if (records === undefined) {
|
|
92
|
+
throw new ScraperConfigError(name, `brand ${JSON.stringify(brand)} has no ${cfg.kind} mapper ` +
|
|
93
|
+
`(it maps: ${Object.keys(mappers).sort().join(', ')})`);
|
|
94
|
+
}
|
|
95
|
+
const projected = project('tool', name, cfg);
|
|
96
|
+
bound.set(name, {
|
|
97
|
+
...projected,
|
|
98
|
+
columns: checkColumnMap(name, cfg.kind, cfg.columns),
|
|
99
|
+
records,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
families = bound;
|
|
103
|
+
return bound;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Every holder and collet family, with its facts checked and projected.
|
|
107
|
+
*
|
|
108
|
+
* They bind no adapter — only cutting tools go through a column map — but
|
|
109
|
+
* their facts pass the same gate: a taper or a clamping mode is a per-family
|
|
110
|
+
* constant no variant table states, exactly like a drill's flute count.
|
|
111
|
+
*/
|
|
112
|
+
export function boundToolholding() {
|
|
113
|
+
if (toolholding !== null)
|
|
114
|
+
return toolholding;
|
|
115
|
+
const bound = new Map();
|
|
116
|
+
for (const [table, families_] of [
|
|
117
|
+
['holder', HOLDER_FAMILIES],
|
|
118
|
+
['collet', COLLET_FAMILIES],
|
|
119
|
+
]) {
|
|
120
|
+
for (const [name, cfg] of Object.entries(families_)) {
|
|
121
|
+
bound.set(name, project(table, name, cfg));
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
toolholding = bound;
|
|
125
|
+
return bound;
|
|
126
|
+
}
|
|
127
|
+
/** One bound cutting-tool family by CSV name. */
|
|
128
|
+
export function boundFamily(name) {
|
|
129
|
+
const cfg = boundFamilies().get(name);
|
|
130
|
+
if (cfg === undefined) {
|
|
131
|
+
throw new ScraperConfigError(name, `unknown cutting-tool family (known: ` + `${[...boundFamilies().keys()].sort().join(', ')})`);
|
|
132
|
+
}
|
|
133
|
+
return cfg;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Forget what has been bound.
|
|
137
|
+
*
|
|
138
|
+
* For tests that put a family in front of the gate. Nothing in a running
|
|
139
|
+
* scrape should need it — the tables are module constants, so a second bind
|
|
140
|
+
* would produce the same answer.
|
|
141
|
+
*/
|
|
142
|
+
export function resetBindings() {
|
|
143
|
+
families = null;
|
|
144
|
+
toolholding = null;
|
|
145
|
+
}
|
package/dist/scrape.d.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a scrape hands back.
|
|
3
|
+
*
|
|
4
|
+
* Rows rather than a written file and a count: a Node backend embedding this
|
|
5
|
+
* wants the rows, and writing a CSV is one of several things it might then do
|
|
6
|
+
* with them. `@toolpath/tool-scraper/node` is what turns one into a CSV and a
|
|
7
|
+
* receipt beside it. The two pieces of provenance a receipt needs —
|
|
8
|
+
* where the rows came from, and under which vendor family code — travel with
|
|
9
|
+
* the rows rather than being reconstructed by the caller, because finding the
|
|
10
|
+
* request again is the most expensive part of adding a vendor.
|
|
11
|
+
*/
|
|
12
|
+
/** One scraped row: the vendor's own column labels, and its own strings. */
|
|
13
|
+
export type ScrapedRow = Readonly<Record<string, string>>;
|
|
14
|
+
/** One family's scrape: the rows, and enough provenance to write a receipt. */
|
|
15
|
+
export interface ScrapeResult {
|
|
16
|
+
/**
|
|
17
|
+
* The CSV column order, positional.
|
|
18
|
+
*
|
|
19
|
+
* Kept beside the rows rather than derived from them: a row whose cell is
|
|
20
|
+
* empty still has to occupy its column, and deriving the header from the
|
|
21
|
+
* first row's keys would drop a column the vendor left blank on that row.
|
|
22
|
+
*/
|
|
23
|
+
readonly header: readonly string[];
|
|
24
|
+
readonly rows: readonly ScrapedRow[];
|
|
25
|
+
/**
|
|
26
|
+
* The URL the rows came from. A request, not a page: this is the thing to
|
|
27
|
+
* re-issue when a column changes shape.
|
|
28
|
+
*/
|
|
29
|
+
readonly source: string;
|
|
30
|
+
/**
|
|
31
|
+
* The vendor's own family code where there is one. REGO-FIX and Destiny Tool
|
|
32
|
+
* have none — their scrape target is a set of index filters — so it is null
|
|
33
|
+
* rather than an empty string, which would read as a code the vendor left
|
|
34
|
+
* blank.
|
|
35
|
+
*/
|
|
36
|
+
readonly familyCode: string | null;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Where a scraper reports something it could not resolve but did not fail on.
|
|
40
|
+
*
|
|
41
|
+
* Injected rather than printed: a backend wants these in its own log, and a
|
|
42
|
+
* CLI wants them on stderr. Defaults to `console.warn`, so a call site that
|
|
43
|
+
* does not care passes nothing.
|
|
44
|
+
*/
|
|
45
|
+
export type Warn = (message: string) => void;
|
|
46
|
+
/** Options every record mapper accepts. */
|
|
47
|
+
export interface MapperOptions {
|
|
48
|
+
warn?: Warn;
|
|
49
|
+
}
|
|
50
|
+
/** The default: stderr, where a message nobody injected a sink for belongs. */
|
|
51
|
+
export declare const consoleWarn: Warn;
|
|
52
|
+
/**
|
|
53
|
+
* Milliseconds between requests, wherever a scrape or a mirror loops.
|
|
54
|
+
*
|
|
55
|
+
* Politeness, not rate-limit avoidance, and one number rather than a copy per
|
|
56
|
+
* loop: the Kennametal CAD annotate step, its material sweep and the
|
|
57
|
+
* vendor-neutral STEP mirror all wanted the same 400 ms, and three constants
|
|
58
|
+
* saying so drifted apart the moment one of them was tuned.
|
|
59
|
+
*/
|
|
60
|
+
export declare const REQUEST_DELAY_MS = 400;
|
|
61
|
+
/**
|
|
62
|
+
* Wait between requests, skipped entirely at zero.
|
|
63
|
+
*
|
|
64
|
+
* Politeness, not rate-limit avoidance. It lived in the Kennametal adapter
|
|
65
|
+
* because that is the only vendor whose scrape loops — and the vendor-boundary
|
|
66
|
+
* test is what moved it: `node/cad-mirror.ts` needed it too, and a core module
|
|
67
|
+
* reaching into one manufacturer for a three-line helper is exactly the leak
|
|
68
|
+
* that rule exists to catch. Same call `conventions.CAD_COLUMN` got.
|
|
69
|
+
*/
|
|
70
|
+
export declare function pause(ms: number): Promise<void>;
|
package/dist/scrape.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a scrape hands back.
|
|
3
|
+
*
|
|
4
|
+
* Rows rather than a written file and a count: a Node backend embedding this
|
|
5
|
+
* wants the rows, and writing a CSV is one of several things it might then do
|
|
6
|
+
* with them. `@toolpath/tool-scraper/node` is what turns one into a CSV and a
|
|
7
|
+
* receipt beside it. The two pieces of provenance a receipt needs —
|
|
8
|
+
* where the rows came from, and under which vendor family code — travel with
|
|
9
|
+
* the rows rather than being reconstructed by the caller, because finding the
|
|
10
|
+
* request again is the most expensive part of adding a vendor.
|
|
11
|
+
*/
|
|
12
|
+
/** The default: stderr, where a message nobody injected a sink for belongs. */
|
|
13
|
+
export const consoleWarn = (message) => {
|
|
14
|
+
console.warn(message);
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* Milliseconds between requests, wherever a scrape or a mirror loops.
|
|
18
|
+
*
|
|
19
|
+
* Politeness, not rate-limit avoidance, and one number rather than a copy per
|
|
20
|
+
* loop: the Kennametal CAD annotate step, its material sweep and the
|
|
21
|
+
* vendor-neutral STEP mirror all wanted the same 400 ms, and three constants
|
|
22
|
+
* saying so drifted apart the moment one of them was tuned.
|
|
23
|
+
*/
|
|
24
|
+
export const REQUEST_DELAY_MS = 400;
|
|
25
|
+
/**
|
|
26
|
+
* Wait between requests, skipped entirely at zero.
|
|
27
|
+
*
|
|
28
|
+
* Politeness, not rate-limit avoidance. It lived in the Kennametal adapter
|
|
29
|
+
* because that is the only vendor whose scrape loops — and the vendor-boundary
|
|
30
|
+
* test is what moved it: `node/cad-mirror.ts` needed it too, and a core module
|
|
31
|
+
* reaching into one manufacturer for a three-line helper is exactly the leak
|
|
32
|
+
* that rule exists to catch. Same call `conventions.CAD_COLUMN` got.
|
|
33
|
+
*/
|
|
34
|
+
export async function pause(ms) {
|
|
35
|
+
if (ms > 0)
|
|
36
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
37
|
+
}
|
package/dist/thread.d.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thread designations in, native-unit numbers out.
|
|
3
|
+
*
|
|
4
|
+
* **Core, not an adapter**, and the distinction is the one this package draws
|
|
5
|
+
* everywhere: a thread designation is a *standard* — `#2-56` is a UTS thread
|
|
6
|
+
* and `M6 X 1` an ISO metric one whoever prints it — so parsing one is domain
|
|
7
|
+
* arithmetic that every vendor's taps need and none of them owns. What is
|
|
8
|
+
* Kennametal's is that its tables publish the designation in a column called
|
|
9
|
+
* `D1-TDZ` and publish no pitch at all, and that fact lives in
|
|
10
|
+
* `vendors/kennametal/thread-column.ts` with the CSV step that fixes it.
|
|
11
|
+
*
|
|
12
|
+
* Native units throughout — millimetres for metric threads, inches for inch
|
|
13
|
+
* threads. A tap's system is a per-row fact, so the caller passes it in rather
|
|
14
|
+
* than this module assuming a family's.
|
|
15
|
+
*
|
|
16
|
+
* ## Rounding is pinned, not incidental
|
|
17
|
+
*
|
|
18
|
+
* Python's `round` is half-to-even over a float; JavaScript has no direct
|
|
19
|
+
* equivalent. Both functions below round the way the Python did, and
|
|
20
|
+
* `tests/thread.test.ts` checks all 2,232 designations this package can
|
|
21
|
+
* plausibly meet against output generated by it. Within the ranges that occur
|
|
22
|
+
* — machine screw numbers 0–14, 4 to 80 threads per inch — no value lands on
|
|
23
|
+
* an exact half, so `Math.round` and half-to-even cannot disagree; the vectors
|
|
24
|
+
* are what makes that a checked fact rather than an argument.
|
|
25
|
+
*/
|
|
26
|
+
/** Which standard a designation is written in, and therefore its unit. */
|
|
27
|
+
export type ThreadSystem = 'metric' | 'inch';
|
|
28
|
+
/**
|
|
29
|
+
* Major diameter from a thread designation string, in the thread system's
|
|
30
|
+
* native unit (mm for metric, inches for inch).
|
|
31
|
+
*
|
|
32
|
+
* Formats seen in the scraped tables: `M10X1.5`, `#2-56`, `#0 - 80`,
|
|
33
|
+
* `1/4 - 20`, `5/16-18`.
|
|
34
|
+
*/
|
|
35
|
+
export declare function threadMajorDiameter(tdz: string, threadSystem: ThreadSystem): number;
|
|
36
|
+
/**
|
|
37
|
+
* Pitch from a thread designation, in the thread system's native unit.
|
|
38
|
+
*
|
|
39
|
+
* ```
|
|
40
|
+
* metric: 'M6X1' -> '1' (mm, the value after the X)
|
|
41
|
+
* inch: '#4-40' -> '0.025' (in, 1 / threads-per-inch)
|
|
42
|
+
* '1/4 - 20' -> '0.05'
|
|
43
|
+
* ```
|
|
44
|
+
*
|
|
45
|
+
* Returned as the string that goes into the CSV, so re-running the step is a
|
|
46
|
+
* no-op rather than a float round-trip that rewrites `1` as `1.0` in every row.
|
|
47
|
+
*/
|
|
48
|
+
export declare function threadPitch(designation: string, threadSystem: ThreadSystem): string;
|
package/dist/thread.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thread designations in, native-unit numbers out.
|
|
3
|
+
*
|
|
4
|
+
* **Core, not an adapter**, and the distinction is the one this package draws
|
|
5
|
+
* everywhere: a thread designation is a *standard* — `#2-56` is a UTS thread
|
|
6
|
+
* and `M6 X 1` an ISO metric one whoever prints it — so parsing one is domain
|
|
7
|
+
* arithmetic that every vendor's taps need and none of them owns. What is
|
|
8
|
+
* Kennametal's is that its tables publish the designation in a column called
|
|
9
|
+
* `D1-TDZ` and publish no pitch at all, and that fact lives in
|
|
10
|
+
* `vendors/kennametal/thread-column.ts` with the CSV step that fixes it.
|
|
11
|
+
*
|
|
12
|
+
* Native units throughout — millimetres for metric threads, inches for inch
|
|
13
|
+
* threads. A tap's system is a per-row fact, so the caller passes it in rather
|
|
14
|
+
* than this module assuming a family's.
|
|
15
|
+
*
|
|
16
|
+
* ## Rounding is pinned, not incidental
|
|
17
|
+
*
|
|
18
|
+
* Python's `round` is half-to-even over a float; JavaScript has no direct
|
|
19
|
+
* equivalent. Both functions below round the way the Python did, and
|
|
20
|
+
* `tests/thread.test.ts` checks all 2,232 designations this package can
|
|
21
|
+
* plausibly meet against output generated by it. Within the ranges that occur
|
|
22
|
+
* — machine screw numbers 0–14, 4 to 80 threads per inch — no value lands on
|
|
23
|
+
* an exact half, so `Math.round` and half-to-even cannot disagree; the vectors
|
|
24
|
+
* are what makes that a checked fact rather than an argument.
|
|
25
|
+
*/
|
|
26
|
+
/** `M10X1.5` — the capital `X` is required, as the Python's pattern was. */
|
|
27
|
+
const METRIC_MAJOR = /^M([0-9]+(?:\.[0-9]+)?)X[0-9.]+$/;
|
|
28
|
+
/** `M6 x 1`, `M6X1` — pitch parsing accepts either case, major diameter does not. */
|
|
29
|
+
const METRIC_PITCH = /[xX]\s*([\d.]+)\s*$/;
|
|
30
|
+
/** `#4-40`, `1/4 - 20` — the trailing number is threads per inch. */
|
|
31
|
+
const INCH_PITCH = /-\s*([\d.]+)\s*$/;
|
|
32
|
+
/** Round to `places` decimals the way Python's `round(x, places)` does here. */
|
|
33
|
+
function round(value, places) {
|
|
34
|
+
const scale = 10 ** places;
|
|
35
|
+
return Math.round(value * scale) / scale;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Major diameter from a thread designation string, in the thread system's
|
|
39
|
+
* native unit (mm for metric, inches for inch).
|
|
40
|
+
*
|
|
41
|
+
* Formats seen in the scraped tables: `M10X1.5`, `#2-56`, `#0 - 80`,
|
|
42
|
+
* `1/4 - 20`, `5/16-18`.
|
|
43
|
+
*/
|
|
44
|
+
export function threadMajorDiameter(tdz, threadSystem) {
|
|
45
|
+
const s = tdz.replaceAll(' ', '');
|
|
46
|
+
if (threadSystem === 'metric') {
|
|
47
|
+
const match = METRIC_MAJOR.exec(s);
|
|
48
|
+
if (!match?.[1]) {
|
|
49
|
+
throw new RangeError(`unrecognized metric thread designation: ${tdz}`);
|
|
50
|
+
}
|
|
51
|
+
return Number(match[1]);
|
|
52
|
+
}
|
|
53
|
+
const size = s.split('-', 1)[0] ?? '';
|
|
54
|
+
if (size.startsWith('#')) {
|
|
55
|
+
// ANSI machine-screw numbers: major dia = 0.060 + 0.013 * N inches
|
|
56
|
+
const n = Number.parseInt(size.slice(1), 10);
|
|
57
|
+
if (!Number.isInteger(n)) {
|
|
58
|
+
throw new RangeError(`unrecognized inch thread designation: ${tdz}`);
|
|
59
|
+
}
|
|
60
|
+
return round(0.06 + 0.013 * n, 4);
|
|
61
|
+
}
|
|
62
|
+
if (size.includes('/')) {
|
|
63
|
+
const [num, den] = size.split('/');
|
|
64
|
+
const value = Number(num) / Number(den);
|
|
65
|
+
// A designation like `1/` parses to NaN, which would otherwise travel into
|
|
66
|
+
// a record as a diameter. Every other unreadable shape here throws.
|
|
67
|
+
if (!Number.isFinite(value)) {
|
|
68
|
+
throw new RangeError(`unrecognized inch thread designation: ${tdz}`);
|
|
69
|
+
}
|
|
70
|
+
return value;
|
|
71
|
+
}
|
|
72
|
+
throw new RangeError(`unrecognized inch thread designation: ${tdz}`);
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Pitch from a thread designation, in the thread system's native unit.
|
|
76
|
+
*
|
|
77
|
+
* ```
|
|
78
|
+
* metric: 'M6X1' -> '1' (mm, the value after the X)
|
|
79
|
+
* inch: '#4-40' -> '0.025' (in, 1 / threads-per-inch)
|
|
80
|
+
* '1/4 - 20' -> '0.05'
|
|
81
|
+
* ```
|
|
82
|
+
*
|
|
83
|
+
* Returned as the string that goes into the CSV, so re-running the step is a
|
|
84
|
+
* no-op rather than a float round-trip that rewrites `1` as `1.0` in every row.
|
|
85
|
+
*/
|
|
86
|
+
export function threadPitch(designation, threadSystem) {
|
|
87
|
+
if (threadSystem === 'metric') {
|
|
88
|
+
const match = METRIC_PITCH.exec(designation);
|
|
89
|
+
if (match?.[1])
|
|
90
|
+
return match[1];
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
const match = INCH_PITCH.exec(designation);
|
|
94
|
+
if (match?.[1])
|
|
95
|
+
return String(round(1 / Number(match[1]), 6));
|
|
96
|
+
}
|
|
97
|
+
throw new RangeError(`cannot parse pitch from ${designation} (${threadSystem})`);
|
|
98
|
+
}
|
package/dist/uuid5.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 4122 name-based UUIDs, version 5.
|
|
3
|
+
*
|
|
4
|
+
* Python's `uuid` module ships this; Node's does not — `node:crypto` offers
|
|
5
|
+
* `randomUUID` and nothing else — so the eleven lines are here rather than as
|
|
6
|
+
* a dependency. It is SHA-1 over the namespace's 16 bytes followed by the
|
|
7
|
+
* name's UTF-8 bytes, truncated to 16, with the version and variant bits
|
|
8
|
+
* overwritten.
|
|
9
|
+
*
|
|
10
|
+
* **This has to agree with Python's `uuid.uuid5` byte for byte.** Tool guids
|
|
11
|
+
* are the join key every downstream consumer of a scrape uses, so a value that
|
|
12
|
+
* differs by one bit is not a formatting difference, it is a catalog that no
|
|
13
|
+
* longer matches the one scraped last month. `tests/identity.test.ts` pins the
|
|
14
|
+
* output against values generated by the Python this was ported from, for the
|
|
15
|
+
* ordinary case and for the two that would catch an encoding slip — a name
|
|
16
|
+
* with non-ASCII in it, and the empty name.
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* RFC 4122's namespace for URLs.
|
|
20
|
+
*
|
|
21
|
+
* The only namespace this package seeds from: every brand's namespace is
|
|
22
|
+
* itself a v5 UUID of that brand's home page under this one.
|
|
23
|
+
*/
|
|
24
|
+
export declare const NAMESPACE_URL = "6ba7b811-9dad-11d1-80b4-00c04fd430c8";
|
|
25
|
+
/**
|
|
26
|
+
* The version 5 UUID of `name` within `namespace`.
|
|
27
|
+
*
|
|
28
|
+
* Deterministic across machines and runs, which is the whole point — see the
|
|
29
|
+
* module docstring.
|
|
30
|
+
*/
|
|
31
|
+
export declare function uuid5(namespace: string, name: string): string;
|
package/dist/uuid5.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 4122 name-based UUIDs, version 5.
|
|
3
|
+
*
|
|
4
|
+
* Python's `uuid` module ships this; Node's does not — `node:crypto` offers
|
|
5
|
+
* `randomUUID` and nothing else — so the eleven lines are here rather than as
|
|
6
|
+
* a dependency. It is SHA-1 over the namespace's 16 bytes followed by the
|
|
7
|
+
* name's UTF-8 bytes, truncated to 16, with the version and variant bits
|
|
8
|
+
* overwritten.
|
|
9
|
+
*
|
|
10
|
+
* **This has to agree with Python's `uuid.uuid5` byte for byte.** Tool guids
|
|
11
|
+
* are the join key every downstream consumer of a scrape uses, so a value that
|
|
12
|
+
* differs by one bit is not a formatting difference, it is a catalog that no
|
|
13
|
+
* longer matches the one scraped last month. `tests/identity.test.ts` pins the
|
|
14
|
+
* output against values generated by the Python this was ported from, for the
|
|
15
|
+
* ordinary case and for the two that would catch an encoding slip — a name
|
|
16
|
+
* with non-ASCII in it, and the empty name.
|
|
17
|
+
*/
|
|
18
|
+
import { createHash } from 'node:crypto';
|
|
19
|
+
/**
|
|
20
|
+
* RFC 4122's namespace for URLs.
|
|
21
|
+
*
|
|
22
|
+
* The only namespace this package seeds from: every brand's namespace is
|
|
23
|
+
* itself a v5 UUID of that brand's home page under this one.
|
|
24
|
+
*/
|
|
25
|
+
export const NAMESPACE_URL = '6ba7b811-9dad-11d1-80b4-00c04fd430c8';
|
|
26
|
+
/** The 16 bytes a hyphenated UUID string denotes. */
|
|
27
|
+
function bytesOf(uuid) {
|
|
28
|
+
const hex = uuid.replaceAll('-', '');
|
|
29
|
+
if (hex.length !== 32 || !/^[0-9a-fA-F]{32}$/.test(hex)) {
|
|
30
|
+
throw new TypeError(`not a UUID: ${uuid}`);
|
|
31
|
+
}
|
|
32
|
+
return Buffer.from(hex, 'hex');
|
|
33
|
+
}
|
|
34
|
+
/** The canonical 8-4-4-4-12 rendering of 16 bytes. */
|
|
35
|
+
function format(bytes) {
|
|
36
|
+
const hex = bytes.toString('hex');
|
|
37
|
+
return [
|
|
38
|
+
hex.slice(0, 8),
|
|
39
|
+
hex.slice(8, 12),
|
|
40
|
+
hex.slice(12, 16),
|
|
41
|
+
hex.slice(16, 20),
|
|
42
|
+
hex.slice(20, 32),
|
|
43
|
+
].join('-');
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The version 5 UUID of `name` within `namespace`.
|
|
47
|
+
*
|
|
48
|
+
* Deterministic across machines and runs, which is the whole point — see the
|
|
49
|
+
* module docstring.
|
|
50
|
+
*/
|
|
51
|
+
export function uuid5(namespace, name) {
|
|
52
|
+
const digest = createHash('sha1')
|
|
53
|
+
.update(bytesOf(namespace))
|
|
54
|
+
.update(Buffer.from(name, 'utf8'))
|
|
55
|
+
.digest();
|
|
56
|
+
const bytes = digest.subarray(0, 16);
|
|
57
|
+
// Version 5 in the high nibble of octet 6, and the RFC 4122 variant in the
|
|
58
|
+
// top two bits of octet 8. `writeUInt8`/`readUInt8` rather than index
|
|
59
|
+
// assignment so the arithmetic is on `number` and not on `number |
|
|
60
|
+
// undefined` under `noUncheckedIndexedAccess`.
|
|
61
|
+
bytes.writeUInt8((bytes.readUInt8(6) & 0x0f) | 0x50, 6);
|
|
62
|
+
bytes.writeUInt8((bytes.readUInt8(8) & 0x3f) | 0x80, 8);
|
|
63
|
+
return format(bytes);
|
|
64
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Destiny Tool — solid end mills, sold direct from a live Firestore database.
|
|
3
|
+
*
|
|
4
|
+
* Not a server-rendered vendor catalog: the storefront is a Next.js SPA built
|
|
5
|
+
* on Firebase Studio with no product data in the HTML, so the "scrape" is a
|
|
6
|
+
* paginated Firestore REST client rather than an HTML table parser. See
|
|
7
|
+
* `scrape.ts` for the endpoint and `records.ts` for what the adapter derives
|
|
8
|
+
* from free-text descriptions that the vendor's structured fields leave blank.
|
|
9
|
+
*/
|
|
10
|
+
export * from './records.js';
|
|
11
|
+
export * from './scrape.js';
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Destiny Tool — solid end mills, sold direct from a live Firestore database.
|
|
3
|
+
*
|
|
4
|
+
* Not a server-rendered vendor catalog: the storefront is a Next.js SPA built
|
|
5
|
+
* on Firebase Studio with no product data in the HTML, so the "scrape" is a
|
|
6
|
+
* paginated Firestore REST client rather than an HTML table parser. See
|
|
7
|
+
* `scrape.ts` for the endpoint and `records.ts` for what the adapter derives
|
|
8
|
+
* from free-text descriptions that the vendor's structured fields leave blank.
|
|
9
|
+
*/
|
|
10
|
+
export * from './records.js';
|
|
11
|
+
export * from './scrape.js';
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Destiny Tool rows -> {@link ToolRecord}.
|
|
3
|
+
*
|
|
4
|
+
* Destiny Tool publishes exactly one identifier (`itemNumber`), no carbide
|
|
5
|
+
* grade, no structured shank column, and dimensions as fractional-inch
|
|
6
|
+
* **strings** rather than decimal columns — the closest precedent in this
|
|
7
|
+
* package is `thread.threadMajorDiameter`'s designation parsing, not any other
|
|
8
|
+
* vendor's dimension reader, all of which already publish decimals.
|
|
9
|
+
*
|
|
10
|
+
* **Three geometry fields are derived from the free-text `description` rather
|
|
11
|
+
* than a column, because no column exists for them**: a shank diameter from a
|
|
12
|
+
* "SHK" annotation, a corner radius from a "RAD" annotation when the vendor's
|
|
13
|
+
* own `rad` cell is blank, and a neck diameter from a "NECK" annotation. The
|
|
14
|
+
* latter two were found scraping the real collection 2026-08-19. All three
|
|
15
|
+
* follow the same shape: real vendor data (a populated column) wins when
|
|
16
|
+
* present, and the description is read only when the column is not — absence
|
|
17
|
+
* is a stated fact, not a gap to fill.
|
|
18
|
+
*
|
|
19
|
+
* **A per-record derivation is not a `Fact`.** These three are arithmetic over
|
|
20
|
+
* a row, so they belong in code with their evidence beside them; a fact is a
|
|
21
|
+
* per-family constant nothing in the table states, and putting one of these
|
|
22
|
+
* there would claim a whole family's provenance for a value that varies row by
|
|
23
|
+
* row.
|
|
24
|
+
*/
|
|
25
|
+
import { type BoundFamily, type RecordMappers } from '../../family.js';
|
|
26
|
+
import { type ColumnMap, type ToolRecord } from '../../records.js';
|
|
27
|
+
import { type MapperOptions, type ScrapedRow } from '../../scrape.js';
|
|
28
|
+
/**
|
|
29
|
+
* Destiny Tool's one identifier. There is no second catalog number the way
|
|
30
|
+
* Kennametal publishes an ISO number alongside a material number — the item
|
|
31
|
+
* number fills both roles on a record.
|
|
32
|
+
*/
|
|
33
|
+
export declare const ITEM_NUMBER = "itemNumber";
|
|
34
|
+
/**
|
|
35
|
+
* Flute counts at or below this route to the non-ferrous material-group
|
|
36
|
+
* fallback — see {@link materialGroups}.
|
|
37
|
+
*/
|
|
38
|
+
export declare const NON_FERROUS_MAX_FLUTES = 3;
|
|
39
|
+
/**
|
|
40
|
+
* A Destiny Tool dimension string, in inches.
|
|
41
|
+
*
|
|
42
|
+
* Every form seen across the real scrape (2026-08-19): a decimal (`.093`), a
|
|
43
|
+
* bare or quoted whole number (`1`, `1"`), a simple fraction (`3/4`), or a
|
|
44
|
+
* mixed number, quoted or not (`1-1/2`, `1-1/2"`).
|
|
45
|
+
*/
|
|
46
|
+
export declare function parseFractionInches(text: string): number;
|
|
47
|
+
/**
|
|
48
|
+
* The shank diameter: parsed off a "SHK" annotation when the tool is necked or
|
|
49
|
+
* reduced-shank, or the cutting diameter otherwise.
|
|
50
|
+
*
|
|
51
|
+
* Destiny Tool has no structured shank column at all — unlike Kennametal,
|
|
52
|
+
* where an absent `D` column would be a scrape bug, here a shank equal to the
|
|
53
|
+
* cut diameter is simply never stated in the vendor's own text either (checked
|
|
54
|
+
* over the full scrape, 2026-08-19).
|
|
55
|
+
*/
|
|
56
|
+
export declare function shankDiameter(description: string, dc: number): number;
|
|
57
|
+
/**
|
|
58
|
+
* The corner radius, in priority order.
|
|
59
|
+
*
|
|
60
|
+
* 1. The vendor's own `rad` cell, when populated — real data wins, and this is
|
|
61
|
+
* trusted outright the way every scraped column in this package is.
|
|
62
|
+
* 2. `DC / 2` on a `Ball` end mill, which Destiny Tool publishes with no
|
|
63
|
+
* radius column at all for that style (checked 2026-08-19).
|
|
64
|
+
* 3. The description's own "RAD" annotation, for the 123 of 3,898 rows found
|
|
65
|
+
* 2026-08-19 where `endStyle` is `"Corner Radius"` but the `rad` cell is
|
|
66
|
+
* blank and the text states one anyway. A range like `".035-.040 RAD"`
|
|
67
|
+
* resolves to its **upper** bound: across the 370 rows that state a range
|
|
68
|
+
* and also publish a populated `rad` cell, the cell equals the upper bound
|
|
69
|
+
* 352 times (95%) and the lower bound 18 times, so the upper bound is the
|
|
70
|
+
* better-corroborated guess for the rows where only the range is available.
|
|
71
|
+
*
|
|
72
|
+
* **Recovered from text, so it is checked rather than trusted outright.**
|
|
73
|
+
* `V33220R093` states `"0.93 RAD"` where its two siblings (identical
|
|
74
|
+
* geometry, different coating) both say `".093 RAD"` and the item number's
|
|
75
|
+
* own `093` suffix agrees with them — a vendor typo missing a leading zero,
|
|
76
|
+
* found running this against the real scrape. A value that would make the
|
|
77
|
+
* tool geometrically impossible (2×RE > DC) is not used; the row falls
|
|
78
|
+
* through to 4 instead, with a warning.
|
|
79
|
+
* 4. `0` — a real square end — when nothing states one (2 of 3,898 rows), or
|
|
80
|
+
* when 3 recovered a value this package will not ship.
|
|
81
|
+
*/
|
|
82
|
+
export declare function cornerRadius(description: string, endStyle: string, what: string, dc: number, radCell: number | null, warn?: import("../../scrape.js").Warn): number;
|
|
83
|
+
/**
|
|
84
|
+
* The neck (shoulder) diameter: parsed off a "NECK" annotation when the tool
|
|
85
|
+
* is necked, or the cutting diameter otherwise — a plain-shank tool below the
|
|
86
|
+
* flutes, the same convention a family with no neck column at all uses.
|
|
87
|
+
* Destiny Tool never publishes a structured neck column; the description
|
|
88
|
+
* states one on 171 of 3,898 rows (2026-08-19) and this reads it rather than
|
|
89
|
+
* defaulting every row to plain-shank.
|
|
90
|
+
*/
|
|
91
|
+
export declare function shoulderDiameter(description: string, dc: number): number;
|
|
92
|
+
/**
|
|
93
|
+
* The ISO workpiece-material groups: the vendor's own `isoMaterialGroups`
|
|
94
|
+
* column when populated, or a fallback keyed on flute count when it is not
|
|
95
|
+
* (blank on 423 of 3,898 rows, 2026-08-19).
|
|
96
|
+
*
|
|
97
|
+
* The fallback is not a new rule invented for this vendor — it is the split
|
|
98
|
+
* cutting-data presets are routed by downstream (≤3 flutes non-ferrous, >3
|
|
99
|
+
* ferrous), applied here to the material-groups facet instead. Real vendor
|
|
100
|
+
* data wins when present: the full scrape shows 92 ≤3-flute rows whose stated
|
|
101
|
+
* groups are not exactly `['N']` and 168 >3-flute rows whose stated groups
|
|
102
|
+
* include `N`, so this is deliberately a fallback for the blank cells and not
|
|
103
|
+
* a correction of the populated ones.
|
|
104
|
+
*
|
|
105
|
+
* **The populated cell is reordered onto `ISO_MATERIAL_GROUPS`, not passed
|
|
106
|
+
* through in Destiny Tool's own order.** Its `isoMaterialGroups` array comes
|
|
107
|
+
* back as e.g. `['M', 'P', 'S']` — alphabetical-ish, not the ISO 513 sequence
|
|
108
|
+
* every other list agrees on — and a consumer that renders a facet from one
|
|
109
|
+
* array and a tool's own list from another has no way to notice the two
|
|
110
|
+
* disagree.
|
|
111
|
+
*/
|
|
112
|
+
export declare function materialGroups(row: ScrapedRow, flutes: number): string[];
|
|
113
|
+
/**
|
|
114
|
+
* A solid end mill, always in inches — Destiny Tool publishes no metric line
|
|
115
|
+
* (the `unit` fact on the family).
|
|
116
|
+
*/
|
|
117
|
+
export declare function endmillRecord(row: ScrapedRow, family: BoundFamily, columns: ColumnMap, options?: MapperOptions): ToolRecord;
|
|
118
|
+
export declare const RECORD_MAPPERS: RecordMappers;
|