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