@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.
Files changed (68) hide show
  1. package/README.md +41 -11
  2. package/dist/columns.d.ts +62 -0
  3. package/dist/columns.js +62 -0
  4. package/dist/conventions.d.ts +89 -15
  5. package/dist/conventions.js +108 -15
  6. package/dist/families/emuge.d.ts +183 -0
  7. package/dist/families/emuge.js +159 -0
  8. package/dist/families/harvey.d.ts +1782 -0
  9. package/dist/families/harvey.js +1328 -0
  10. package/dist/families/index.js +5 -2
  11. package/dist/families/kennametal.d.ts +21 -0
  12. package/dist/families/kennametal.js +10 -0
  13. package/dist/families/maritool.d.ts +120 -0
  14. package/dist/families/maritool.js +175 -0
  15. package/dist/family.d.ts +35 -1
  16. package/dist/family.js +28 -0
  17. package/dist/identity.d.ts +18 -0
  18. package/dist/identity.js +46 -0
  19. package/dist/index.d.ts +16 -0
  20. package/dist/index.js +16 -0
  21. package/dist/measure.d.ts +112 -0
  22. package/dist/measure.js +130 -0
  23. package/dist/node/cli.d.ts +3 -0
  24. package/dist/node/cli.js +152 -2
  25. package/dist/node/main.js +0 -0
  26. package/dist/records.d.ts +169 -12
  27. package/dist/records.js +116 -5
  28. package/dist/registry.d.ts +31 -1
  29. package/dist/registry.js +40 -1
  30. package/dist/scrape.d.ts +15 -0
  31. package/dist/scrape.js +24 -0
  32. package/dist/vendors/destinytool/records.d.ts +13 -3
  33. package/dist/vendors/destinytool/records.js +41 -37
  34. package/dist/vendors/emuge/index.d.ts +17 -0
  35. package/dist/vendors/emuge/index.js +17 -0
  36. package/dist/vendors/emuge/records.d.ts +150 -0
  37. package/dist/vendors/emuge/records.js +375 -0
  38. package/dist/vendors/emuge/scrape.d.ts +227 -0
  39. package/dist/vendors/emuge/scrape.js +358 -0
  40. package/dist/vendors/emuge/value.d.ts +75 -0
  41. package/dist/vendors/emuge/value.js +116 -0
  42. package/dist/vendors/harvey/catalog.d.ts +53 -0
  43. package/dist/vendors/harvey/catalog.js +120 -0
  44. package/dist/vendors/harvey/header.d.ts +89 -0
  45. package/dist/vendors/harvey/header.js +185 -0
  46. package/dist/vendors/harvey/index.d.ts +21 -0
  47. package/dist/vendors/harvey/index.js +21 -0
  48. package/dist/vendors/harvey/lexicon.d.ts +73 -0
  49. package/dist/vendors/harvey/lexicon.js +126 -0
  50. package/dist/vendors/harvey/literal.d.ts +68 -0
  51. package/dist/vendors/harvey/literal.js +214 -0
  52. package/dist/vendors/harvey/records.d.ts +79 -0
  53. package/dist/vendors/harvey/records.js +163 -0
  54. package/dist/vendors/harvey/scrape.d.ts +187 -0
  55. package/dist/vendors/harvey/scrape.js +483 -0
  56. package/dist/vendors/harvey/value.d.ts +74 -0
  57. package/dist/vendors/harvey/value.js +119 -0
  58. package/dist/vendors/kennametal/records.d.ts +13 -4
  59. package/dist/vendors/kennametal/records.js +61 -26
  60. package/dist/vendors/maritool/catalog.d.ts +81 -0
  61. package/dist/vendors/maritool/catalog.js +132 -0
  62. package/dist/vendors/maritool/index.d.ts +10 -0
  63. package/dist/vendors/maritool/index.js +10 -0
  64. package/dist/vendors/maritool/scrape.d.ts +297 -0
  65. package/dist/vendors/maritool/scrape.js +593 -0
  66. package/dist/vendors/regofix/scrape.d.ts +8 -11
  67. package/dist/vendors/regofix/scrape.js +21 -36
  68. 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 };