@toolpath/tool-scraper 0.1.0 → 2.0.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/README.md +41 -11
- package/dist/columns.d.ts +62 -0
- package/dist/columns.js +62 -0
- package/dist/conventions.d.ts +89 -15
- package/dist/conventions.js +108 -15
- package/dist/families/emuge.d.ts +183 -0
- package/dist/families/emuge.js +159 -0
- package/dist/families/harvey.d.ts +1782 -0
- package/dist/families/harvey.js +1328 -0
- package/dist/families/index.js +5 -2
- package/dist/families/kennametal.d.ts +21 -0
- package/dist/families/kennametal.js +10 -0
- package/dist/families/maritool.d.ts +120 -0
- package/dist/families/maritool.js +175 -0
- package/dist/family.d.ts +35 -1
- package/dist/family.js +28 -0
- package/dist/identity.d.ts +18 -0
- package/dist/identity.js +46 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +16 -0
- package/dist/measure.d.ts +112 -0
- package/dist/measure.js +130 -0
- package/dist/node/cli.d.ts +3 -0
- package/dist/node/cli.js +152 -2
- package/dist/node/main.js +0 -0
- package/dist/records.d.ts +169 -12
- package/dist/records.js +116 -5
- package/dist/registry.d.ts +31 -1
- package/dist/registry.js +40 -1
- package/dist/scrape.d.ts +15 -0
- package/dist/scrape.js +24 -0
- package/dist/vendors/destinytool/records.d.ts +13 -3
- package/dist/vendors/destinytool/records.js +41 -37
- package/dist/vendors/emuge/index.d.ts +17 -0
- package/dist/vendors/emuge/index.js +17 -0
- package/dist/vendors/emuge/records.d.ts +150 -0
- package/dist/vendors/emuge/records.js +375 -0
- package/dist/vendors/emuge/scrape.d.ts +227 -0
- package/dist/vendors/emuge/scrape.js +358 -0
- package/dist/vendors/emuge/value.d.ts +75 -0
- package/dist/vendors/emuge/value.js +116 -0
- package/dist/vendors/harvey/catalog.d.ts +53 -0
- package/dist/vendors/harvey/catalog.js +120 -0
- package/dist/vendors/harvey/header.d.ts +89 -0
- package/dist/vendors/harvey/header.js +185 -0
- package/dist/vendors/harvey/index.d.ts +21 -0
- package/dist/vendors/harvey/index.js +21 -0
- package/dist/vendors/harvey/lexicon.d.ts +73 -0
- package/dist/vendors/harvey/lexicon.js +126 -0
- package/dist/vendors/harvey/literal.d.ts +68 -0
- package/dist/vendors/harvey/literal.js +214 -0
- package/dist/vendors/harvey/records.d.ts +79 -0
- package/dist/vendors/harvey/records.js +163 -0
- package/dist/vendors/harvey/scrape.d.ts +187 -0
- package/dist/vendors/harvey/scrape.js +483 -0
- package/dist/vendors/harvey/value.d.ts +74 -0
- package/dist/vendors/harvey/value.js +119 -0
- package/dist/vendors/kennametal/records.d.ts +13 -4
- package/dist/vendors/kennametal/records.js +61 -26
- package/dist/vendors/maritool/catalog.d.ts +81 -0
- package/dist/vendors/maritool/catalog.js +132 -0
- package/dist/vendors/maritool/index.d.ts +10 -0
- package/dist/vendors/maritool/index.js +10 -0
- package/dist/vendors/maritool/scrape.d.ts +297 -0
- package/dist/vendors/maritool/scrape.js +593 -0
- package/dist/vendors/regofix/scrape.d.ts +8 -11
- package/dist/vendors/regofix/scrape.js +21 -36
- package/package.json +31 -7
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A JavaScript object literal inlined in a page -> JSON. No Harvey knowledge.
|
|
3
|
+
*
|
|
4
|
+
* Harvey Tool serves its whole variant table as `var tableData1 = [{...}]` in a
|
|
5
|
+
* ~557 KB `<script>` block, with bare identifier keys that `JSON.parse` refuses.
|
|
6
|
+
* This module is the smallest thing that turns one into JSON, and it is separate
|
|
7
|
+
* from every other file here because it knows nothing about tools: it takes a
|
|
8
|
+
* document and a variable name and hands back a value.
|
|
9
|
+
*
|
|
10
|
+
* ## The obvious regex is a silent data corruptor
|
|
11
|
+
*
|
|
12
|
+
* The reflex is `/([{,])\s*(\w+)\s*:/` -> `'$1"$2":'` and then `JSON.parse`. It
|
|
13
|
+
* is wrong, and wrong in the worst way — it produces a document that parses.
|
|
14
|
+
*
|
|
15
|
+
* Harvey's cells carry HTML with quotes and colons in them:
|
|
16
|
+
*
|
|
17
|
+
* ```js
|
|
18
|
+
* t:"color:#70C0FF"
|
|
19
|
+
* d:"<a href=\"/products/tool-details-24502\">24502</a>"
|
|
20
|
+
* ```
|
|
21
|
+
*
|
|
22
|
+
* `color:` inside that first string is not an object key, and a `{x:1}` in a
|
|
23
|
+
* product description is not an object. A regex has no way to know, so it
|
|
24
|
+
* rewrites text inside strings and the result is a scrape that still produces
|
|
25
|
+
* rows, still passes a row count, and has wrong data in it.
|
|
26
|
+
*
|
|
27
|
+
* So this walks the source once, tracking whether it is inside a string and
|
|
28
|
+
* whether the last character was an escape, and quotes a bare key only when it
|
|
29
|
+
* is genuinely at an object-key position. That is about sixty lines and it is
|
|
30
|
+
* the difference between a parser and a coincidence.
|
|
31
|
+
*
|
|
32
|
+
* ## Two passes over the same scan
|
|
33
|
+
*
|
|
34
|
+
* {@link findLiteral} locates `var <name> =` by index and then brace-matches
|
|
35
|
+
* forward with the same string-aware rules, so a whole 720 KB document is never
|
|
36
|
+
* handed to a regex engine more than once per variable. 18 MB of HTML goes
|
|
37
|
+
* through this code on a full scrape; a repeated whole-document match is what
|
|
38
|
+
* turns that into minutes of CPU.
|
|
39
|
+
*/
|
|
40
|
+
/**
|
|
41
|
+
* The source text of the bracketed value assigned to `var <name>`.
|
|
42
|
+
*
|
|
43
|
+
* Returns null when the document declares no such variable, which is an
|
|
44
|
+
* ordinary answer: Harvey emits `cols1` through `cols10` on every page and
|
|
45
|
+
* leaves the unused ones empty, and a page with one table simply has no
|
|
46
|
+
* `tableData2`.
|
|
47
|
+
*
|
|
48
|
+
* Throws when the assignment is there but its brackets never balance, because
|
|
49
|
+
* that is a truncated response rather than an absent one, and returning null
|
|
50
|
+
* would report it as a page with fewer tables.
|
|
51
|
+
*/
|
|
52
|
+
export declare function findLiteral(source: string, name: string): string | null;
|
|
53
|
+
/**
|
|
54
|
+
* A JavaScript object literal as JSON text: bare keys quoted, everything else
|
|
55
|
+
* byte for byte.
|
|
56
|
+
*
|
|
57
|
+
* Only *keys* are rewritten. A bare word at a value position — `undefined`, a
|
|
58
|
+
* single-quoted string — is left where it is and `JSON.parse` refuses it, which
|
|
59
|
+
* is the right outcome: the page changed shape, and inventing a reading for a
|
|
60
|
+
* token this has never seen is how a scraper starts authoring data.
|
|
61
|
+
*/
|
|
62
|
+
export declare function toJson(literal: string, what: string): string;
|
|
63
|
+
/**
|
|
64
|
+
* The value of `var <name>` in `source`, parsed.
|
|
65
|
+
*
|
|
66
|
+
* Null where the variable is absent — see {@link findLiteral}.
|
|
67
|
+
*/
|
|
68
|
+
export declare function readLiteral<T>(source: string, name: string): T | null;
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A JavaScript object literal inlined in a page -> JSON. No Harvey knowledge.
|
|
3
|
+
*
|
|
4
|
+
* Harvey Tool serves its whole variant table as `var tableData1 = [{...}]` in a
|
|
5
|
+
* ~557 KB `<script>` block, with bare identifier keys that `JSON.parse` refuses.
|
|
6
|
+
* This module is the smallest thing that turns one into JSON, and it is separate
|
|
7
|
+
* from every other file here because it knows nothing about tools: it takes a
|
|
8
|
+
* document and a variable name and hands back a value.
|
|
9
|
+
*
|
|
10
|
+
* ## The obvious regex is a silent data corruptor
|
|
11
|
+
*
|
|
12
|
+
* The reflex is `/([{,])\s*(\w+)\s*:/` -> `'$1"$2":'` and then `JSON.parse`. It
|
|
13
|
+
* is wrong, and wrong in the worst way — it produces a document that parses.
|
|
14
|
+
*
|
|
15
|
+
* Harvey's cells carry HTML with quotes and colons in them:
|
|
16
|
+
*
|
|
17
|
+
* ```js
|
|
18
|
+
* t:"color:#70C0FF"
|
|
19
|
+
* d:"<a href=\"/products/tool-details-24502\">24502</a>"
|
|
20
|
+
* ```
|
|
21
|
+
*
|
|
22
|
+
* `color:` inside that first string is not an object key, and a `{x:1}` in a
|
|
23
|
+
* product description is not an object. A regex has no way to know, so it
|
|
24
|
+
* rewrites text inside strings and the result is a scrape that still produces
|
|
25
|
+
* rows, still passes a row count, and has wrong data in it.
|
|
26
|
+
*
|
|
27
|
+
* So this walks the source once, tracking whether it is inside a string and
|
|
28
|
+
* whether the last character was an escape, and quotes a bare key only when it
|
|
29
|
+
* is genuinely at an object-key position. That is about sixty lines and it is
|
|
30
|
+
* the difference between a parser and a coincidence.
|
|
31
|
+
*
|
|
32
|
+
* ## Two passes over the same scan
|
|
33
|
+
*
|
|
34
|
+
* {@link findLiteral} locates `var <name> =` by index and then brace-matches
|
|
35
|
+
* forward with the same string-aware rules, so a whole 720 KB document is never
|
|
36
|
+
* handed to a regex engine more than once per variable. 18 MB of HTML goes
|
|
37
|
+
* through this code on a full scrape; a repeated whole-document match is what
|
|
38
|
+
* turns that into minutes of CPU.
|
|
39
|
+
*/
|
|
40
|
+
import { VendorResponseError } from '../../errors.js';
|
|
41
|
+
/** Opening brackets, and the closer each one expects. */
|
|
42
|
+
const CLOSERS = { '{': '}', '[': ']' };
|
|
43
|
+
/** True where `ch` can begin a bare JavaScript identifier. */
|
|
44
|
+
function identifierStart(ch) {
|
|
45
|
+
return /[A-Za-z_$]/.test(ch);
|
|
46
|
+
}
|
|
47
|
+
/** True where `ch` can continue one. */
|
|
48
|
+
function identifierPart(ch) {
|
|
49
|
+
return /[A-Za-z0-9_$]/.test(ch);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The source text of the bracketed value assigned to `var <name>`.
|
|
53
|
+
*
|
|
54
|
+
* Returns null when the document declares no such variable, which is an
|
|
55
|
+
* ordinary answer: Harvey emits `cols1` through `cols10` on every page and
|
|
56
|
+
* leaves the unused ones empty, and a page with one table simply has no
|
|
57
|
+
* `tableData2`.
|
|
58
|
+
*
|
|
59
|
+
* Throws when the assignment is there but its brackets never balance, because
|
|
60
|
+
* that is a truncated response rather than an absent one, and returning null
|
|
61
|
+
* would report it as a page with fewer tables.
|
|
62
|
+
*/
|
|
63
|
+
export function findLiteral(source, name) {
|
|
64
|
+
const declaration = new RegExp(`\\bvar\\s+${name}\\s*=\\s*`, 'g');
|
|
65
|
+
const match = declaration.exec(source);
|
|
66
|
+
if (match === null)
|
|
67
|
+
return null;
|
|
68
|
+
// The regex has already consumed the whitespace after `=`, so the literal
|
|
69
|
+
// starts here or nowhere. Searching forward for the next bracket instead
|
|
70
|
+
// would happily find one inside the *next* variable's string.
|
|
71
|
+
const start = match.index + match[0].length;
|
|
72
|
+
if (source[start] !== '[' && source[start] !== '{') {
|
|
73
|
+
throw new VendorResponseError(name, 'is assigned something that is not an array or object');
|
|
74
|
+
}
|
|
75
|
+
const end = matchBracket(source, start, name);
|
|
76
|
+
return source.slice(start, end + 1);
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* The index of the bracket closing the one at `start`, string-aware.
|
|
80
|
+
*
|
|
81
|
+
* The whole reason this is not a depth counter over the raw text: a `}` inside
|
|
82
|
+
* a `d` cell's HTML would close an object that is still open, and every
|
|
83
|
+
* following column would land one key to the left.
|
|
84
|
+
*/
|
|
85
|
+
function matchBracket(source, start, what) {
|
|
86
|
+
const stack = [];
|
|
87
|
+
let inString = false;
|
|
88
|
+
let escaped = false;
|
|
89
|
+
for (let i = start; i < source.length; i++) {
|
|
90
|
+
const ch = source[i];
|
|
91
|
+
if (inString) {
|
|
92
|
+
if (escaped)
|
|
93
|
+
escaped = false;
|
|
94
|
+
else if (ch === '\\')
|
|
95
|
+
escaped = true;
|
|
96
|
+
else if (ch === '"')
|
|
97
|
+
inString = false;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (ch === '"') {
|
|
101
|
+
inString = true;
|
|
102
|
+
}
|
|
103
|
+
else if (ch === '{' || ch === '[') {
|
|
104
|
+
stack.push(CLOSERS[ch]);
|
|
105
|
+
}
|
|
106
|
+
else if (ch === '}' || ch === ']') {
|
|
107
|
+
const expected = stack.pop();
|
|
108
|
+
if (expected !== ch) {
|
|
109
|
+
throw new VendorResponseError(what, `has ${JSON.stringify(ch)} at offset ${i - start} where ` +
|
|
110
|
+
`${expected === undefined ? 'nothing was open' : JSON.stringify(expected)} was due`);
|
|
111
|
+
}
|
|
112
|
+
if (stack.length === 0)
|
|
113
|
+
return i;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
throw new VendorResponseError(what, 'is not closed before the end of the document — a truncated response');
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* A JavaScript object literal as JSON text: bare keys quoted, everything else
|
|
120
|
+
* byte for byte.
|
|
121
|
+
*
|
|
122
|
+
* Only *keys* are rewritten. A bare word at a value position — `undefined`, a
|
|
123
|
+
* single-quoted string — is left where it is and `JSON.parse` refuses it, which
|
|
124
|
+
* is the right outcome: the page changed shape, and inventing a reading for a
|
|
125
|
+
* token this has never seen is how a scraper starts authoring data.
|
|
126
|
+
*/
|
|
127
|
+
export function toJson(literal, what) {
|
|
128
|
+
const out = [];
|
|
129
|
+
// Whether the innermost bracket is an object, and whether the next token in
|
|
130
|
+
// it is a key. Both are needed: `[{a:1}]` is at key position after `{` but
|
|
131
|
+
// not after `[`, and `{a:[1,2]}` is not at key position after that comma.
|
|
132
|
+
const objects = [];
|
|
133
|
+
let expectKey = false;
|
|
134
|
+
let inString = false;
|
|
135
|
+
let escaped = false;
|
|
136
|
+
for (let i = 0; i < literal.length; i++) {
|
|
137
|
+
const ch = literal[i];
|
|
138
|
+
if (inString) {
|
|
139
|
+
out.push(ch);
|
|
140
|
+
if (escaped)
|
|
141
|
+
escaped = false;
|
|
142
|
+
else if (ch === '\\')
|
|
143
|
+
escaped = true;
|
|
144
|
+
else if (ch === '"')
|
|
145
|
+
inString = false;
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (ch === '"') {
|
|
149
|
+
inString = true;
|
|
150
|
+
expectKey = false;
|
|
151
|
+
out.push(ch);
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (ch === '{' || ch === '[') {
|
|
155
|
+
objects.push(ch === '{');
|
|
156
|
+
expectKey = ch === '{';
|
|
157
|
+
out.push(ch);
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (ch === '}' || ch === ']') {
|
|
161
|
+
objects.pop();
|
|
162
|
+
expectKey = false;
|
|
163
|
+
out.push(ch);
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
if (ch === ',') {
|
|
167
|
+
expectKey = objects[objects.length - 1] === true;
|
|
168
|
+
out.push(ch);
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
if (ch === ':') {
|
|
172
|
+
expectKey = false;
|
|
173
|
+
out.push(ch);
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
if (/\s/.test(ch)) {
|
|
177
|
+
out.push(ch);
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
if (expectKey && identifierStart(ch)) {
|
|
181
|
+
let end = i;
|
|
182
|
+
while (end < literal.length && identifierPart(literal[end]))
|
|
183
|
+
end++;
|
|
184
|
+
out.push(JSON.stringify(literal.slice(i, end)));
|
|
185
|
+
i = end - 1;
|
|
186
|
+
expectKey = false;
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
if (ch === "'") {
|
|
190
|
+
// Never seen on a Harvey page, and re-escaping one into a JSON string is
|
|
191
|
+
// guesswork about what the vendor meant. Refuse loudly instead.
|
|
192
|
+
throw new VendorResponseError(what, `has a single-quoted string at offset ${i}`);
|
|
193
|
+
}
|
|
194
|
+
expectKey = false;
|
|
195
|
+
out.push(ch);
|
|
196
|
+
}
|
|
197
|
+
return out.join('');
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* The value of `var <name>` in `source`, parsed.
|
|
201
|
+
*
|
|
202
|
+
* Null where the variable is absent — see {@link findLiteral}.
|
|
203
|
+
*/
|
|
204
|
+
export function readLiteral(source, name) {
|
|
205
|
+
const literal = findLiteral(source, name);
|
|
206
|
+
if (literal === null)
|
|
207
|
+
return null;
|
|
208
|
+
try {
|
|
209
|
+
return JSON.parse(toJson(literal, name));
|
|
210
|
+
}
|
|
211
|
+
catch (error) {
|
|
212
|
+
throw new VendorResponseError(name, `is not a readable object literal: ${error.message}`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Harvey rows -> {@link ToolRecord}.
|
|
3
|
+
*
|
|
4
|
+
* The CSV a Harvey scrape writes holds the vendor's own **display strings** —
|
|
5
|
+
* `.250 (1/4)`, `1-1/2`, `3 mm`, `-` — rather than parsed numbers, because the
|
|
6
|
+
* file is the receipt and Harvey's own fractional and metric annotations are
|
|
7
|
+
* part of what it published. So this module is where a cell becomes a number,
|
|
8
|
+
* through `value.ts`; the closest precedent in this package is Destiny Tool's
|
|
9
|
+
* fractional-inch reader, not Kennametal's, whose columns are already decimals.
|
|
10
|
+
*
|
|
11
|
+
* ## What Harvey does not publish
|
|
12
|
+
*
|
|
13
|
+
* **No second identifier.** One `Tool #` per part, which fills both the material
|
|
14
|
+
* number and the catalog number on a record — see
|
|
15
|
+
* `conventions.IDENTITY_DEVIATIONS.harvey`.
|
|
16
|
+
*
|
|
17
|
+
* **No carbide grade.** `substrate` comes from the family's `bmc` fact and the
|
|
18
|
+
* `COATING` column fills `coating`, exactly as Destiny Tool's coating id does.
|
|
19
|
+
*
|
|
20
|
+
* **No workpiece-material index a scrape can reach.** Nothing in a variant
|
|
21
|
+
* table rates a tool to ISO 513 groups, so every Harvey record is labelled
|
|
22
|
+
* `records.UNSPECIFIED` — *we do not know what this tool is for*, which is a
|
|
23
|
+
* different claim from Kennametal's swept taps being rated for nothing.
|
|
24
|
+
*
|
|
25
|
+
* The per-part page does publish one, and it is **per part and not per family**:
|
|
26
|
+
* a 192-request probe on 2026-08-29 found `harvey_endmill_005.csv` splitting by
|
|
27
|
+
* coating column, uncoated tools rated for steel, stainless, cast iron and
|
|
28
|
+
* titanium where the amorphous-diamond-coated tools of the same geometry are
|
|
29
|
+
* rated for aluminium, wood and composites alone. That is correct metallurgy —
|
|
30
|
+
* diamond cannot cut ferrous — and flattening it to one answer per family would
|
|
31
|
+
* put a diamond-coated end mill under steel. So there is no family fact to
|
|
32
|
+
* write, and the two keyseat pages titled for a material class do not get one
|
|
33
|
+
* either: the "For Non - Ferrous Materials" line's own part pages rate it for
|
|
34
|
+
* steel and stainless steel, so the title does not predict the index.
|
|
35
|
+
* `docs/HARVEY_PRODUCT_TABLE.md` §1.5 has the measurement and what reaching it
|
|
36
|
+
* would cost.
|
|
37
|
+
*
|
|
38
|
+
* **No corner radius on a ball nose.** A ball family publishes no radius column
|
|
39
|
+
* at all, so `RE` comes from the family's `profile` fact — see
|
|
40
|
+
* {@link cornerRadius}. That is a per-family constant with provenance rather
|
|
41
|
+
* than a string match on a description, because Harvey states the profile once,
|
|
42
|
+
* in the page title, for the whole product line.
|
|
43
|
+
*/
|
|
44
|
+
import { type BoundFamily, type RecordMappers } from '../../family.js';
|
|
45
|
+
import { type ColumnMap, type ToolRecord } from '../../records.js';
|
|
46
|
+
import { type MapperOptions, type ScrapedRow } from '../../scrape.js';
|
|
47
|
+
/** The `profile` fact value that means a ball nose. Harvey's own word. */
|
|
48
|
+
export declare const BALL_PROFILE = "Ball";
|
|
49
|
+
/**
|
|
50
|
+
* The corner radius, in priority order.
|
|
51
|
+
*
|
|
52
|
+
* 1. The family's own radius column where it has one — `CORNER RADIUS` on the
|
|
53
|
+
* corner-radius lines, `RADIUS` on the full-radius keyseat cutters.
|
|
54
|
+
* 2. `DC / 2` on a ball nose. Harvey publishes no radius column on any of its
|
|
55
|
+
* twelve ball families, and the radius of a ball end *is* half the diameter,
|
|
56
|
+
* so this is arithmetic rather than a guess — the `profile` fact is what says
|
|
57
|
+
* the family is one.
|
|
58
|
+
* 3. `0` — a real square end — otherwise.
|
|
59
|
+
*
|
|
60
|
+
* A mapped column whose cell is blank falls through to 2 or 3 rather than
|
|
61
|
+
* refusing the row: `RE` is optional on the endmill contract precisely because
|
|
62
|
+
* a square-end row's blank radius is an answer.
|
|
63
|
+
*/
|
|
64
|
+
export declare function cornerRadius(row: ScrapedRow, family: BoundFamily, columns: ColumnMap, what: string, dc: number, options: MapperOptions): number;
|
|
65
|
+
/**
|
|
66
|
+
* The flute count.
|
|
67
|
+
*
|
|
68
|
+
* One column whichever way the table encoded it: `vendors/harvey/scrape.ts`
|
|
69
|
+
* writes `FLUTES` from the row's own column on a `TOOL #` table and from the
|
|
70
|
+
* coating group's sub-label on a matrix one, so nothing downstream has to know
|
|
71
|
+
* which shape the page used.
|
|
72
|
+
*
|
|
73
|
+
* Null is a real answer on the two deburring families, which publish
|
|
74
|
+
* right- and left-hand tooth counts and no flute count at all.
|
|
75
|
+
*/
|
|
76
|
+
export declare function flutes(row: ScrapedRow, family: BoundFamily, columns: ColumnMap): number | null;
|
|
77
|
+
/** One orderable Harvey tool. */
|
|
78
|
+
export declare function endmillRecord(row: ScrapedRow, family: BoundFamily, columns: ColumnMap, options?: MapperOptions): ToolRecord;
|
|
79
|
+
export declare const RECORD_MAPPERS: RecordMappers;
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Harvey rows -> {@link ToolRecord}.
|
|
3
|
+
*
|
|
4
|
+
* The CSV a Harvey scrape writes holds the vendor's own **display strings** —
|
|
5
|
+
* `.250 (1/4)`, `1-1/2`, `3 mm`, `-` — rather than parsed numbers, because the
|
|
6
|
+
* file is the receipt and Harvey's own fractional and metric annotations are
|
|
7
|
+
* part of what it published. So this module is where a cell becomes a number,
|
|
8
|
+
* through `value.ts`; the closest precedent in this package is Destiny Tool's
|
|
9
|
+
* fractional-inch reader, not Kennametal's, whose columns are already decimals.
|
|
10
|
+
*
|
|
11
|
+
* ## What Harvey does not publish
|
|
12
|
+
*
|
|
13
|
+
* **No second identifier.** One `Tool #` per part, which fills both the material
|
|
14
|
+
* number and the catalog number on a record — see
|
|
15
|
+
* `conventions.IDENTITY_DEVIATIONS.harvey`.
|
|
16
|
+
*
|
|
17
|
+
* **No carbide grade.** `substrate` comes from the family's `bmc` fact and the
|
|
18
|
+
* `COATING` column fills `coating`, exactly as Destiny Tool's coating id does.
|
|
19
|
+
*
|
|
20
|
+
* **No workpiece-material index a scrape can reach.** Nothing in a variant
|
|
21
|
+
* table rates a tool to ISO 513 groups, so every Harvey record is labelled
|
|
22
|
+
* `records.UNSPECIFIED` — *we do not know what this tool is for*, which is a
|
|
23
|
+
* different claim from Kennametal's swept taps being rated for nothing.
|
|
24
|
+
*
|
|
25
|
+
* The per-part page does publish one, and it is **per part and not per family**:
|
|
26
|
+
* a 192-request probe on 2026-08-29 found `harvey_endmill_005.csv` splitting by
|
|
27
|
+
* coating column, uncoated tools rated for steel, stainless, cast iron and
|
|
28
|
+
* titanium where the amorphous-diamond-coated tools of the same geometry are
|
|
29
|
+
* rated for aluminium, wood and composites alone. That is correct metallurgy —
|
|
30
|
+
* diamond cannot cut ferrous — and flattening it to one answer per family would
|
|
31
|
+
* put a diamond-coated end mill under steel. So there is no family fact to
|
|
32
|
+
* write, and the two keyseat pages titled for a material class do not get one
|
|
33
|
+
* either: the "For Non - Ferrous Materials" line's own part pages rate it for
|
|
34
|
+
* steel and stainless steel, so the title does not predict the index.
|
|
35
|
+
* `docs/HARVEY_PRODUCT_TABLE.md` §1.5 has the measurement and what reaching it
|
|
36
|
+
* would cost.
|
|
37
|
+
*
|
|
38
|
+
* **No corner radius on a ball nose.** A ball family publishes no radius column
|
|
39
|
+
* at all, so `RE` comes from the family's `profile` fact — see
|
|
40
|
+
* {@link cornerRadius}. That is a per-family constant with provenance rather
|
|
41
|
+
* than a string match on a description, because Harvey states the profile once,
|
|
42
|
+
* in the page title, for the whole product line.
|
|
43
|
+
*/
|
|
44
|
+
import { columnReaders } from '../../columns.js';
|
|
45
|
+
import { DESCRIPTION_COLUMN } from '../../conventions.js';
|
|
46
|
+
import { VendorResponseError } from '../../errors.js';
|
|
47
|
+
import { fact, familyBrand } from '../../family.js';
|
|
48
|
+
import { BRANDS } from '../../identity.js';
|
|
49
|
+
import { toolRecord } from '../../records.js';
|
|
50
|
+
import { consoleWarn } from '../../scrape.js';
|
|
51
|
+
import { COATING_COLUMN, TOOL_NUMBER_COLUMN } from './scrape.js';
|
|
52
|
+
import { count, dimension } from './value.js';
|
|
53
|
+
/** The `profile` fact value that means a ball nose. Harvey's own word. */
|
|
54
|
+
export const BALL_PROFILE = 'Ball';
|
|
55
|
+
/**
|
|
56
|
+
* The family's declared unit, refused rather than asserted when it is absent.
|
|
57
|
+
*
|
|
58
|
+
* Every Harvey family declares one — the `harvey` CLI command will not scrape a
|
|
59
|
+
* family without it — so `familyUnits` would return exactly this one and a
|
|
60
|
+
* tap's two-system case cannot arise here. It was `family.unit!` until
|
|
61
|
+
* 2026-08-29, which is the same claim with no check behind it: a family added
|
|
62
|
+
* without the fact would have read `undefined` straight into
|
|
63
|
+
* `dimensionalColumn` and asked the CSV for a column called `CUTTER DIA._`.
|
|
64
|
+
*/
|
|
65
|
+
function unitOf(family) {
|
|
66
|
+
return fact(family, 'unit', family.unit);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* The three column readers, over this vendor's grammar.
|
|
70
|
+
*
|
|
71
|
+
* `dimension` is the only Harvey-specific half; everything either side of it —
|
|
72
|
+
* an unmapped column answering undefined, a required field refusing the row and
|
|
73
|
+
* quoting the cell — is `columns.columnReaders`, shared with EMUGE-FRANKEN's
|
|
74
|
+
* mapper. All three take the caller's `columns` rather than `family.columns`:
|
|
75
|
+
* they are the same object through `registry.toRecords`, but `registry`
|
|
76
|
+
* validates the argument, and a mapper reading a different reference is
|
|
77
|
+
* validating one map and reading another. This mapper asserted the parameter
|
|
78
|
+
* was unused with an underscore until 2026-08-29.
|
|
79
|
+
*/
|
|
80
|
+
const { cell, required, optional } = columnReaders(dimension);
|
|
81
|
+
/**
|
|
82
|
+
* The corner radius, in priority order.
|
|
83
|
+
*
|
|
84
|
+
* 1. The family's own radius column where it has one — `CORNER RADIUS` on the
|
|
85
|
+
* corner-radius lines, `RADIUS` on the full-radius keyseat cutters.
|
|
86
|
+
* 2. `DC / 2` on a ball nose. Harvey publishes no radius column on any of its
|
|
87
|
+
* twelve ball families, and the radius of a ball end *is* half the diameter,
|
|
88
|
+
* so this is arithmetic rather than a guess — the `profile` fact is what says
|
|
89
|
+
* the family is one.
|
|
90
|
+
* 3. `0` — a real square end — otherwise.
|
|
91
|
+
*
|
|
92
|
+
* A mapped column whose cell is blank falls through to 2 or 3 rather than
|
|
93
|
+
* refusing the row: `RE` is optional on the endmill contract precisely because
|
|
94
|
+
* a square-end row's blank radius is an answer.
|
|
95
|
+
*/
|
|
96
|
+
export function cornerRadius(row, family, columns, what, dc, options) {
|
|
97
|
+
const stated = optional(row, columns, 'RE', unitOf(family), what, options);
|
|
98
|
+
if (stated !== null)
|
|
99
|
+
return stated;
|
|
100
|
+
return family.profile === BALL_PROFILE ? dc / 2 : 0;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* The flute count.
|
|
104
|
+
*
|
|
105
|
+
* One column whichever way the table encoded it: `vendors/harvey/scrape.ts`
|
|
106
|
+
* writes `FLUTES` from the row's own column on a `TOOL #` table and from the
|
|
107
|
+
* coating group's sub-label on a matrix one, so nothing downstream has to know
|
|
108
|
+
* which shape the page used.
|
|
109
|
+
*
|
|
110
|
+
* Null is a real answer on the two deburring families, which publish
|
|
111
|
+
* right- and left-hand tooth counts and no flute count at all.
|
|
112
|
+
*/
|
|
113
|
+
export function flutes(row, family, columns) {
|
|
114
|
+
const raw = cell(row, columns, 'NOF', unitOf(family));
|
|
115
|
+
return raw === undefined ? null : count(raw);
|
|
116
|
+
}
|
|
117
|
+
/** One orderable Harvey tool. */
|
|
118
|
+
export function endmillRecord(row, family, columns, options = {}) {
|
|
119
|
+
const warn = options.warn ?? consoleWarn;
|
|
120
|
+
const what = row[TOOL_NUMBER_COLUMN] ?? '';
|
|
121
|
+
if (what === '') {
|
|
122
|
+
throw new VendorResponseError(family.id, `has a row with no ${TOOL_NUMBER_COLUMN}`);
|
|
123
|
+
}
|
|
124
|
+
const opts = { warn };
|
|
125
|
+
const unit = unitOf(family);
|
|
126
|
+
const dc = required(row, columns, 'DC', unit, what, opts);
|
|
127
|
+
const fluteLength = required(row, columns, 'LCF', unit, what, opts);
|
|
128
|
+
// Harvey's reach columns are the distance from the tip to the full shank,
|
|
129
|
+
// which is what `shoulder-length` names. A family with no reach column is a
|
|
130
|
+
// plain tool whose usable length below the shank is its flute length — the
|
|
131
|
+
// same convention Destiny Tool's mapper uses.
|
|
132
|
+
const reach = optional(row, columns, 'shoulder-length', unit, what, opts);
|
|
133
|
+
const neck = optional(row, columns, 'shoulder-diameter', unit, what, opts);
|
|
134
|
+
const geometry = {
|
|
135
|
+
DC: dc,
|
|
136
|
+
RE: cornerRadius(row, family, columns, what, dc, opts),
|
|
137
|
+
SFDM: required(row, columns, 'SFDM', unit, what, opts),
|
|
138
|
+
OAL: required(row, columns, 'OAL', unit, what, opts),
|
|
139
|
+
LCF: fluteLength,
|
|
140
|
+
'shoulder-length': reach ?? fluteLength,
|
|
141
|
+
'shoulder-diameter': neck ?? dc,
|
|
142
|
+
};
|
|
143
|
+
const nof = flutes(row, family, columns);
|
|
144
|
+
if (nof !== null)
|
|
145
|
+
geometry.NOF = nof;
|
|
146
|
+
return toolRecord({
|
|
147
|
+
brand: familyBrand(family),
|
|
148
|
+
vendor: BRANDS[familyBrand(family)].vendor,
|
|
149
|
+
materialNumber: what,
|
|
150
|
+
catalogNumber: what,
|
|
151
|
+
description: row[DESCRIPTION_COLUMN] ?? '',
|
|
152
|
+
kind: 'endmill',
|
|
153
|
+
unit,
|
|
154
|
+
substrate: fact(family, 'bmc', family.bmc),
|
|
155
|
+
coating: row[COATING_COLUMN] ?? '',
|
|
156
|
+
// `materialGroups` and its source are left to the factory's `null`: see the
|
|
157
|
+
// module docstring for what a Harvey part page publishes and why none of it
|
|
158
|
+
// can be stated per family.
|
|
159
|
+
coolantThrough: fact(family, 'coolantThrough', family.coolantThrough),
|
|
160
|
+
geometry,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
export const RECORD_MAPPERS = { endmill: endmillRecord };
|