@toolpath/tool-scraper 0.1.0 → 2.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 (76) hide show
  1. package/README.md +41 -11
  2. package/dist/columns.d.ts +64 -0
  3. package/dist/columns.js +68 -0
  4. package/dist/conventions.d.ts +107 -15
  5. package/dist/conventions.js +126 -15
  6. package/dist/errors.d.ts +30 -0
  7. package/dist/errors.js +30 -0
  8. package/dist/families/emuge.d.ts +185 -0
  9. package/dist/families/emuge.js +163 -0
  10. package/dist/families/harvey.d.ts +1782 -0
  11. package/dist/families/harvey.js +1328 -0
  12. package/dist/families/index.js +5 -2
  13. package/dist/families/kennametal.d.ts +21 -0
  14. package/dist/families/kennametal.js +10 -0
  15. package/dist/families/maritool.d.ts +120 -0
  16. package/dist/families/maritool.js +175 -0
  17. package/dist/family.d.ts +35 -1
  18. package/dist/family.js +28 -0
  19. package/dist/identity.d.ts +18 -0
  20. package/dist/identity.js +46 -0
  21. package/dist/index.d.ts +16 -0
  22. package/dist/index.js +16 -0
  23. package/dist/measure.d.ts +112 -0
  24. package/dist/measure.js +130 -0
  25. package/dist/node/cli.d.ts +3 -0
  26. package/dist/node/cli.js +158 -3
  27. package/dist/node/main.js +0 -0
  28. package/dist/records.d.ts +214 -12
  29. package/dist/records.js +140 -5
  30. package/dist/registry.d.ts +56 -1
  31. package/dist/registry.js +79 -2
  32. package/dist/scrape.d.ts +15 -0
  33. package/dist/scrape.js +24 -0
  34. package/dist/vendors/destinytool/records.d.ts +13 -3
  35. package/dist/vendors/destinytool/records.js +49 -37
  36. package/dist/vendors/emuge/index.d.ts +17 -0
  37. package/dist/vendors/emuge/index.js +17 -0
  38. package/dist/vendors/emuge/records.d.ts +230 -0
  39. package/dist/vendors/emuge/records.js +543 -0
  40. package/dist/vendors/emuge/scrape.d.ts +227 -0
  41. package/dist/vendors/emuge/scrape.js +358 -0
  42. package/dist/vendors/emuge/value.d.ts +75 -0
  43. package/dist/vendors/emuge/value.js +116 -0
  44. package/dist/vendors/harvey/catalog.d.ts +53 -0
  45. package/dist/vendors/harvey/catalog.js +120 -0
  46. package/dist/vendors/harvey/header.d.ts +89 -0
  47. package/dist/vendors/harvey/header.js +185 -0
  48. package/dist/vendors/harvey/index.d.ts +21 -0
  49. package/dist/vendors/harvey/index.js +21 -0
  50. package/dist/vendors/harvey/lexicon.d.ts +73 -0
  51. package/dist/vendors/harvey/lexicon.js +126 -0
  52. package/dist/vendors/harvey/literal.d.ts +68 -0
  53. package/dist/vendors/harvey/literal.js +214 -0
  54. package/dist/vendors/harvey/records.d.ts +79 -0
  55. package/dist/vendors/harvey/records.js +163 -0
  56. package/dist/vendors/harvey/scrape.d.ts +187 -0
  57. package/dist/vendors/harvey/scrape.js +483 -0
  58. package/dist/vendors/harvey/value.d.ts +74 -0
  59. package/dist/vendors/harvey/value.js +119 -0
  60. package/dist/vendors/kennametal/family.d.ts +119 -0
  61. package/dist/vendors/kennametal/family.js +155 -0
  62. package/dist/vendors/kennametal/index.d.ts +1 -0
  63. package/dist/vendors/kennametal/index.js +1 -0
  64. package/dist/vendors/kennametal/records.d.ts +29 -4
  65. package/dist/vendors/kennametal/records.js +93 -26
  66. package/dist/vendors/kennametal/scrape.d.ts +25 -2
  67. package/dist/vendors/kennametal/scrape.js +28 -3
  68. package/dist/vendors/maritool/catalog.d.ts +81 -0
  69. package/dist/vendors/maritool/catalog.js +132 -0
  70. package/dist/vendors/maritool/index.d.ts +10 -0
  71. package/dist/vendors/maritool/index.js +10 -0
  72. package/dist/vendors/maritool/scrape.d.ts +297 -0
  73. package/dist/vendors/maritool/scrape.js +593 -0
  74. package/dist/vendors/regofix/scrape.d.ts +8 -11
  75. package/dist/vendors/regofix/scrape.js +21 -36
  76. package/package.json +31 -7
@@ -0,0 +1,73 @@
1
+ /**
2
+ * What Harvey's own column labels mean, and which of them carry a unit.
3
+ *
4
+ * 50 distinct header shapes across the 52 product pages collapse to the table
5
+ * below, because the variance is in *spelling* rather than in vocabulary:
6
+ * `CUTTER DIA.` and `CUTTER DIAMETER` are the same column, and the keyseat
7
+ * families call the length of cut `CUTTER WIDTH`.
8
+ *
9
+ * **This module does not map a label to a canonical ISO name.** That mapping is
10
+ * per family and lives in `families/harvey.ts`, where it belongs: a family
11
+ * declares which of *its* labels is `LCF`, and the core appends the unit suffix.
12
+ * What is left over — is this label one this package has seen, and does its
13
+ * column carry a length — is the same on every page, and is here.
14
+ *
15
+ * ## An unknown label is a hard failure
16
+ *
17
+ * A label nothing here recognises stops the scrape naming the family. The
18
+ * alternative is a column that reaches the CSV under whatever Harvey renamed it
19
+ * to, silently no longer matching the family's column map, and a family whose
20
+ * `DC` is quietly absent from every record. The cost of the strict rule is that
21
+ * a vendor's copy edit breaks a scrape; the cost of the loose one is that it
22
+ * does not.
23
+ */
24
+ /**
25
+ * What a column holds, which is only ever asked so that a length gets a `_mm` /
26
+ * `_in` suffix and a tooth count does not.
27
+ *
28
+ * `code` is for a column whose values are not numbers at all — the keyseat
29
+ * `TYPE`, whose `I`/`II`/`III` nobody at Harvey has yet explained.
30
+ */
31
+ export type LabelKind = 'dimension' | 'count' | 'angle' | 'code';
32
+ /**
33
+ * Every geometry top label the 52 product pages publish, and what it holds.
34
+ *
35
+ * Written out rather than pattern-matched. A regex over "anything containing
36
+ * DIA" would have swallowed `RADIAL DOC` and `NECK DIA.` into the same rule and
37
+ * quietly given one of them the other's meaning, and the whole point of the
38
+ * strict check below is that a label this package has not seen gets looked at
39
+ * by a person.
40
+ */
41
+ export declare const LABELS: Readonly<Record<string, LabelKind>>;
42
+ /** The top label of the column holding the add-to-cart control. Dropped. */
43
+ export declare const CART_LABEL = "Add to Cart";
44
+ /** The top label of the column stating a row's flute count, where there is one. */
45
+ export declare const FLUTES_LABEL = "FLUTES";
46
+ /** The sub-label a coating group uses when the row states the flute count. */
47
+ export declare const TOOL_NUMBER_LABEL = "TOOL #";
48
+ /** The sub-label ending every coating group. */
49
+ export declare const PRICE_LABEL = "PRICE";
50
+ /**
51
+ * The eight coating names Harvey publishes across the whole catalog.
52
+ *
53
+ * A closed list, checked, because the coating is *synthesised* from a column's
54
+ * position rather than lifted from a cell — nothing in a row says which coating
55
+ * a tool number belongs to. A ninth coating is Harvey extending its catalog and
56
+ * is worth a person looking; a coating group whose header stopped being a
57
+ * coating is the grid having shifted, and that is the failure this catches.
58
+ */
59
+ export declare const COATINGS: readonly string[];
60
+ /** The flute count a group sub-label states, or null where it states none. */
61
+ export declare function flutesInLabel(sub: string): number | null;
62
+ /**
63
+ * What the column under `label` holds, refusing a label nothing here knows.
64
+ *
65
+ * `family` is in the message because that is the one thing the person reading
66
+ * the failure needs and cannot work out from the label: 52 pages publish these
67
+ * labels, and only one of them changed.
68
+ */
69
+ export declare function labelKind(family: string, label: string): LabelKind;
70
+ /** Whether the column under `label` gets a `_mm` / `_in` suffix. */
71
+ export declare function isDimensional(family: string, label: string): boolean;
72
+ /** Refuse a coating group header that is not one of the eight. */
73
+ export declare function checkCoating(family: string, coating: string): string;
@@ -0,0 +1,126 @@
1
+ /**
2
+ * What Harvey's own column labels mean, and which of them carry a unit.
3
+ *
4
+ * 50 distinct header shapes across the 52 product pages collapse to the table
5
+ * below, because the variance is in *spelling* rather than in vocabulary:
6
+ * `CUTTER DIA.` and `CUTTER DIAMETER` are the same column, and the keyseat
7
+ * families call the length of cut `CUTTER WIDTH`.
8
+ *
9
+ * **This module does not map a label to a canonical ISO name.** That mapping is
10
+ * per family and lives in `families/harvey.ts`, where it belongs: a family
11
+ * declares which of *its* labels is `LCF`, and the core appends the unit suffix.
12
+ * What is left over — is this label one this package has seen, and does its
13
+ * column carry a length — is the same on every page, and is here.
14
+ *
15
+ * ## An unknown label is a hard failure
16
+ *
17
+ * A label nothing here recognises stops the scrape naming the family. The
18
+ * alternative is a column that reaches the CSV under whatever Harvey renamed it
19
+ * to, silently no longer matching the family's column map, and a family whose
20
+ * `DC` is quietly absent from every record. The cost of the strict rule is that
21
+ * a vendor's copy edit breaks a scrape; the cost of the loose one is that it
22
+ * does not.
23
+ */
24
+ import { ScraperConfigError } from '../../errors.js';
25
+ /**
26
+ * Every geometry top label the 52 product pages publish, and what it holds.
27
+ *
28
+ * Written out rather than pattern-matched. A regex over "anything containing
29
+ * DIA" would have swallowed `RADIAL DOC` and `NECK DIA.` into the same rule and
30
+ * quietly given one of them the other's meaning, and the whole point of the
31
+ * strict check below is that a label this package has not seen gets looked at
32
+ * by a person.
33
+ */
34
+ export const LABELS = {
35
+ // Mapped to canonical names by each family's own column map.
36
+ 'CUTTER DIA.': 'dimension',
37
+ 'CUTTER DIAMETER': 'dimension',
38
+ LOC: 'dimension',
39
+ 'LENGTH OF CUT': 'dimension',
40
+ 'CUTTER WIDTH': 'dimension',
41
+ 'SHANK DIA.': 'dimension',
42
+ 'SHANK DIAMETER': 'dimension',
43
+ OAL: 'dimension',
44
+ 'OVERALL LENGTH': 'dimension',
45
+ 'CORNER RADIUS': 'dimension',
46
+ RADIUS: 'dimension',
47
+ 'NECK DIA.': 'dimension',
48
+ 'NECK LENGTH': 'dimension',
49
+ 'OVERALL REACH': 'dimension',
50
+ FLUTES: 'count',
51
+ // Kept verbatim and mapped to nothing. Each is either a measurement this
52
+ // package has no canonical name for, or one whose meaning Harvey has not
53
+ // stated — see `docs/HARVEY_PRODUCT_TABLE.md` §5.1 and §6.
54
+ 'RADIAL DOC*': 'dimension',
55
+ 'Radial DOC*': 'dimension',
56
+ 'Radial DOC**': 'dimension',
57
+ 'Interference Depth At Wall Angle*': 'dimension',
58
+ 'RIGHT HAND TEETH': 'count',
59
+ 'LEFT HAND TEETH': 'count',
60
+ 'ANGLE PER SIDE': 'angle',
61
+ 'EFFECTIVE WALL ANGLE*': 'angle',
62
+ 'EFF WALL ANGLE': 'angle',
63
+ TYPE: 'code',
64
+ };
65
+ /** The top label of the column holding the add-to-cart control. Dropped. */
66
+ export const CART_LABEL = 'Add to Cart';
67
+ /** The top label of the column stating a row's flute count, where there is one. */
68
+ export const FLUTES_LABEL = 'FLUTES';
69
+ /** The sub-label a coating group uses when the row states the flute count. */
70
+ export const TOOL_NUMBER_LABEL = 'TOOL #';
71
+ /** The sub-label ending every coating group. */
72
+ export const PRICE_LABEL = 'PRICE';
73
+ /** `2 FL`, `4FL`, `5 FL` — a per-part flute count in a group's sub-label. */
74
+ const FLUTE_LABEL = /^(\d+)\s*FL$/i;
75
+ /**
76
+ * The eight coating names Harvey publishes across the whole catalog.
77
+ *
78
+ * A closed list, checked, because the coating is *synthesised* from a column's
79
+ * position rather than lifted from a cell — nothing in a row says which coating
80
+ * a tool number belongs to. A ninth coating is Harvey extending its catalog and
81
+ * is worth a person looking; a coating group whose header stopped being a
82
+ * coating is the grid having shifted, and that is the failure this catches.
83
+ */
84
+ export const COATINGS = [
85
+ 'UNCOATED',
86
+ 'AlTiN COATED',
87
+ 'AMORPHOUS DIAMOND COATED',
88
+ 'AlTiN NANO COATED',
89
+ 'Ti NANO COATED',
90
+ 'TiB2 COATED',
91
+ 'BALL END UNCOATED',
92
+ 'AlTiN NANO BALL END COATED',
93
+ ];
94
+ /** The flute count a group sub-label states, or null where it states none. */
95
+ export function flutesInLabel(sub) {
96
+ const match = FLUTE_LABEL.exec(sub);
97
+ return match ? Number.parseInt(match[1], 10) : null;
98
+ }
99
+ /**
100
+ * What the column under `label` holds, refusing a label nothing here knows.
101
+ *
102
+ * `family` is in the message because that is the one thing the person reading
103
+ * the failure needs and cannot work out from the label: 52 pages publish these
104
+ * labels, and only one of them changed.
105
+ */
106
+ export function labelKind(family, label) {
107
+ const kind = LABELS[label];
108
+ if (kind === undefined) {
109
+ throw new ScraperConfigError(family, `publishes a column headed ${JSON.stringify(label)}, which this adapter ` +
110
+ `does not know — add it to vendors/harvey/lexicon.ts with what it ` +
111
+ `holds, and to the family's column map if it is a canonical field`);
112
+ }
113
+ return kind;
114
+ }
115
+ /** Whether the column under `label` gets a `_mm` / `_in` suffix. */
116
+ export function isDimensional(family, label) {
117
+ return labelKind(family, label) === 'dimension';
118
+ }
119
+ /** Refuse a coating group header that is not one of the eight. */
120
+ export function checkCoating(family, coating) {
121
+ if (!COATINGS.includes(coating)) {
122
+ throw new ScraperConfigError(family, `has a tool-number group headed ${JSON.stringify(coating)}, which is not ` +
123
+ `one of the coatings this adapter knows (${COATINGS.join(', ')})`);
124
+ }
125
+ return coating;
126
+ }
@@ -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;