@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.
- package/README.md +41 -11
- package/dist/columns.d.ts +64 -0
- package/dist/columns.js +68 -0
- package/dist/conventions.d.ts +107 -15
- package/dist/conventions.js +126 -15
- package/dist/errors.d.ts +30 -0
- package/dist/errors.js +30 -0
- package/dist/families/emuge.d.ts +185 -0
- package/dist/families/emuge.js +163 -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 +158 -3
- package/dist/node/main.js +0 -0
- package/dist/records.d.ts +214 -12
- package/dist/records.js +140 -5
- package/dist/registry.d.ts +56 -1
- package/dist/registry.js +79 -2
- 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 +49 -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 +230 -0
- package/dist/vendors/emuge/records.js +543 -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/family.d.ts +119 -0
- package/dist/vendors/kennametal/family.js +155 -0
- package/dist/vendors/kennametal/index.d.ts +1 -0
- package/dist/vendors/kennametal/index.js +1 -0
- package/dist/vendors/kennametal/records.d.ts +29 -4
- package/dist/vendors/kennametal/records.js +93 -26
- package/dist/vendors/kennametal/scrape.d.ts +25 -2
- package/dist/vendors/kennametal/scrape.js +28 -3
- 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,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;
|