@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,483 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One Harvey Tool product page -> one row per orderable part.
|
|
3
|
+
*
|
|
4
|
+
* The page is server-rendered HTML with its variant data inlined as a
|
|
5
|
+
* JavaScript literal; `docs/HARVEY_PRODUCT_TABLE.md` records how that was found
|
|
6
|
+
* and what was tried first. This module is the join between the two halves — a
|
|
7
|
+
* `<thead>` in the DOM and a `tableData<N>` in a `<script>` — plus the one
|
|
8
|
+
* structural thing Harvey's table does that no other vendor here does.
|
|
9
|
+
*
|
|
10
|
+
* ## One HTML row is up to nine orderable parts
|
|
11
|
+
*
|
|
12
|
+
* The table is a matrix. The `a*` cells are the geometry, shared; after them
|
|
13
|
+
* comes a coating × flute grid where each coating is a `colspan`-ed header and
|
|
14
|
+
* every non-empty cell under it is a different tool number at the same geometry.
|
|
15
|
+
* **5,033 HTML rows become 12,799 CSV rows**, and that explosion is the whole
|
|
16
|
+
* risk in this adapter: a coating or flute count read off the wrong column
|
|
17
|
+
* produces a CSV that is the right length and wrong throughout.
|
|
18
|
+
*
|
|
19
|
+
* ## Harvey checks it for us, 5,033 times per scrape
|
|
20
|
+
*
|
|
21
|
+
* Every row carries an `atc` cell whose `j` is the add-to-cart payload, and that
|
|
22
|
+
* payload independently lists every tool number on the row **in the same order
|
|
23
|
+
* as the non-empty tool-number cells**:
|
|
24
|
+
*
|
|
25
|
+
* ```json
|
|
26
|
+
* [{"T":"690508","C":"690508","Q":"1"},{"T":"679608","C":"679608","Q":"1"}]
|
|
27
|
+
* ```
|
|
28
|
+
*
|
|
29
|
+
* So the explosion is checked against the vendor's own list on every row rather
|
|
30
|
+
* than once against a hand count, and a grid that has shifted by one column
|
|
31
|
+
* cannot pass. That check is {@link checkCartPayload}, and it is the reason the
|
|
32
|
+
* declared `rows` counts in `families/harvey.ts` being seeded rather than
|
|
33
|
+
* hand-counted is survivable.
|
|
34
|
+
*
|
|
35
|
+
* ## Two columns are synthesised, and neither has a cell to copy
|
|
36
|
+
*
|
|
37
|
+
* `Coating` is a fact about which *header* a tool number sits under, and
|
|
38
|
+
* `FLUTES` is too on the matrix tables. Both are encoded in column position and
|
|
39
|
+
* nowhere else, which is why they are built here rather than lifted, and why the
|
|
40
|
+
* coating vocabulary is a closed list in `lexicon.ts`.
|
|
41
|
+
*
|
|
42
|
+
* `FLUTES` is one column either way — synthesised from the sub-label on a matrix
|
|
43
|
+
* table and lifted from the row's own column on a `TOOL #` one — so the vendor's
|
|
44
|
+
* `FLUTES` column is taken out of the geometry list rather than written twice.
|
|
45
|
+
*
|
|
46
|
+
* Everything else in the CSV is the vendor's own display string, verbatim —
|
|
47
|
+
* `.250 (1/4)` reaches the file as `.250 (1/4)`. `value.ts` is what resolves one
|
|
48
|
+
* when a record is built.
|
|
49
|
+
*/
|
|
50
|
+
import { CAD_COLUMN, CAD_DXF_COLUMN, DESCRIPTION_COLUMN, dimensionalColumn, } from '../../conventions.js';
|
|
51
|
+
import { VendorResponseError } from '../../errors.js';
|
|
52
|
+
import { consoleWarn } from '../../scrape.js';
|
|
53
|
+
import { flatHeader, isJunkLabel } from './header.js';
|
|
54
|
+
import { readLiteral } from './literal.js';
|
|
55
|
+
import { parseValue } from './value.js';
|
|
56
|
+
import { CART_LABEL, FLUTES_LABEL, PRICE_LABEL, TOOL_NUMBER_LABEL, checkCoating, flutesInLabel, isDimensional, } from './lexicon.js';
|
|
57
|
+
export const BASE = 'https://www.harveytool.com';
|
|
58
|
+
/** `cols1`..`cols10` and `tableData1`..`tableData10` exist on every page. */
|
|
59
|
+
export const MAX_TABLES = 10;
|
|
60
|
+
/** The CSV columns this adapter synthesises or lifts from the page itself. */
|
|
61
|
+
export const TOOL_NUMBER_COLUMN = 'Tool #';
|
|
62
|
+
export const COATING_COLUMN = 'Coating';
|
|
63
|
+
export const FLUTES_COLUMN = 'FLUTES';
|
|
64
|
+
export const PRICE_COLUMN = 'PRICE_USD';
|
|
65
|
+
/**
|
|
66
|
+
* The suffix an unlabelled annotation column takes, after the column it
|
|
67
|
+
* annotates.
|
|
68
|
+
*
|
|
69
|
+
* Harvey gives these no header at all — see `docs/HARVEY_PRODUCT_TABLE.md` §5.3
|
|
70
|
+
* — so a name has to be supplied, and naming one for what it annotates is the
|
|
71
|
+
* only honest option available. Dropping them instead would lose a published
|
|
72
|
+
* column; leaving them unnamed would collide with the column they follow.
|
|
73
|
+
*/
|
|
74
|
+
export const RATIO_SUFFIX = 'RATIO';
|
|
75
|
+
/**
|
|
76
|
+
* The suffix an unlabelled column takes when it is *not* a ratio.
|
|
77
|
+
*
|
|
78
|
+
* Four such columns exist, on two families, and Harvey renders them in white
|
|
79
|
+
* text: two carry the badge `LONG` on a handful of rows and two are entirely
|
|
80
|
+
* empty. Naming them `RATIO` would be a claim about their contents that is
|
|
81
|
+
* false, so which suffix a column gets is decided from the whole page's data
|
|
82
|
+
* rather than from its header — see {@link ratioColumnKeys}.
|
|
83
|
+
*/
|
|
84
|
+
export const NOTE_SUFFIX = 'NOTE';
|
|
85
|
+
/** True where the cell carries a link, i.e. where it names a real part. */
|
|
86
|
+
const LINKED = /<a\b[^>]*\bhref\s*=/i;
|
|
87
|
+
/** `<a href="...">14916</a>` -> `14916`. These cells carry no entities. */
|
|
88
|
+
export function cellText(html) {
|
|
89
|
+
return (html ?? '')
|
|
90
|
+
.replace(/<[^>]*>/g, '')
|
|
91
|
+
.replace(/\s+/g, ' ')
|
|
92
|
+
.trim();
|
|
93
|
+
}
|
|
94
|
+
/** `$148.40 ` -> `148.40`. The column already says which currency. */
|
|
95
|
+
export function priceOf(html) {
|
|
96
|
+
return cellText(html).replace(/^\$/, '').trim();
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* The CSV column each geometry column is written under.
|
|
100
|
+
*
|
|
101
|
+
* Three cases, and the second and third are the ones worth knowing:
|
|
102
|
+
*
|
|
103
|
+
* 1. **A label of its own** keeps it.
|
|
104
|
+
* 2. **A repeated label with distinct meaningful sub-labels** takes the
|
|
105
|
+
* sub-label — `Interference Depth At Wall Angle*` is six real columns
|
|
106
|
+
* headed `0°` through `4°`, not one column and five annotations.
|
|
107
|
+
* 3. **A repeated label with a junk sub-label**, or a column whose top label is
|
|
108
|
+
* junk outright, is the vendor's ratio annotation of the column before it and
|
|
109
|
+
* takes {@link RATIO_SUFFIX}.
|
|
110
|
+
*/
|
|
111
|
+
function geometryColumns(family, keys, header, unit, ratioKeys, warn) {
|
|
112
|
+
const positions = keys
|
|
113
|
+
.map((key, index) => ({ key, head: header[index] }))
|
|
114
|
+
.filter(({ key, head }) => key !== 'atc' && /^a\d+$/.test(key) && head.top !== CART_LABEL)
|
|
115
|
+
// The flute count leaves the geometry list and comes back as one
|
|
116
|
+
// synthesised `FLUTES` column, because half these tables state it in a
|
|
117
|
+
// column and half in a coating group's sub-label. Keeping both would put
|
|
118
|
+
// two columns called `FLUTES` in one CSV.
|
|
119
|
+
.filter(({ head }) => head.top !== FLUTES_LABEL);
|
|
120
|
+
const byLabel = new Map();
|
|
121
|
+
for (const { head } of positions) {
|
|
122
|
+
if (isJunkLabel(head.top))
|
|
123
|
+
continue;
|
|
124
|
+
byLabel.set(head.top, [...(byLabel.get(head.top) ?? []), head]);
|
|
125
|
+
}
|
|
126
|
+
/** True where every occurrence of this label has its own real sub-label. */
|
|
127
|
+
const subLabelled = new Map();
|
|
128
|
+
for (const [label, heads] of byLabel) {
|
|
129
|
+
const subs = heads.map((h) => h.sub ?? '');
|
|
130
|
+
subLabelled.set(label, heads.length > 1 && subs.every((s) => !isJunkLabel(s)) && new Set(subs).size === subs.length);
|
|
131
|
+
}
|
|
132
|
+
const columns = [];
|
|
133
|
+
const seen = new Set();
|
|
134
|
+
for (const { key, head } of positions) {
|
|
135
|
+
let label;
|
|
136
|
+
let dimensional;
|
|
137
|
+
if (isJunkLabel(head.top)) {
|
|
138
|
+
const previous = columns[columns.length - 1];
|
|
139
|
+
if (previous === undefined) {
|
|
140
|
+
throw new VendorResponseError(family, `opens with an unlabelled column (${key}) — there is nothing for it to annotate`);
|
|
141
|
+
}
|
|
142
|
+
label = `${previous.label} ${ratioKeys.has(key) ? RATIO_SUFFIX : NOTE_SUFFIX}`;
|
|
143
|
+
dimensional = false;
|
|
144
|
+
warn(` WARNING: ${family}: column ${key} has no header of its own; ` +
|
|
145
|
+
`written as ${JSON.stringify(label)}`);
|
|
146
|
+
}
|
|
147
|
+
else if (subLabelled.get(head.top) === true) {
|
|
148
|
+
label = `${head.top} ${head.sub}`;
|
|
149
|
+
dimensional = isDimensional(family, head.top);
|
|
150
|
+
}
|
|
151
|
+
else if (seen.has(head.top)) {
|
|
152
|
+
label = `${head.top} ${RATIO_SUFFIX}`;
|
|
153
|
+
dimensional = false;
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
label = head.top;
|
|
157
|
+
dimensional = isDimensional(family, head.top);
|
|
158
|
+
}
|
|
159
|
+
seen.add(head.top);
|
|
160
|
+
const column = dimensional ? dimensionalColumn(label, unit) : label;
|
|
161
|
+
if (columns.some((c) => c.column === column)) {
|
|
162
|
+
throw new VendorResponseError(family, `has two columns both written as ${JSON.stringify(column)} — the ` +
|
|
163
|
+
`header no longer tells them apart`);
|
|
164
|
+
}
|
|
165
|
+
columns.push({ key, column, label });
|
|
166
|
+
}
|
|
167
|
+
return columns;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* The coating groups of one table, with each part column's flute count.
|
|
171
|
+
*
|
|
172
|
+
* A group runs from its first tool-number column to its price column. Every
|
|
173
|
+
* group on all 80 tables has exactly one price and it comes last, so a group
|
|
174
|
+
* that reaches its end without one is refused rather than given a blank price:
|
|
175
|
+
* the grid would have shifted, and every part in it would be reading a
|
|
176
|
+
* neighbour's cell.
|
|
177
|
+
*/
|
|
178
|
+
function coatingGroups(family, keys, header, warn) {
|
|
179
|
+
const groups = [];
|
|
180
|
+
let coating = null;
|
|
181
|
+
let parts = [];
|
|
182
|
+
for (const [index, key] of keys.entries()) {
|
|
183
|
+
const head = header[index];
|
|
184
|
+
const sub = head.sub ?? '';
|
|
185
|
+
if (/^s\d+$/.test(key)) {
|
|
186
|
+
if (coating === null) {
|
|
187
|
+
coating = checkCoating(family, head.top);
|
|
188
|
+
parts = [];
|
|
189
|
+
}
|
|
190
|
+
else if (coating !== head.top) {
|
|
191
|
+
throw new VendorResponseError(family, `starts coating group ${JSON.stringify(head.top)} before ` +
|
|
192
|
+
`${JSON.stringify(coating)} has a ${PRICE_LABEL} column`);
|
|
193
|
+
}
|
|
194
|
+
parts.push({ key, flutes: sub === TOOL_NUMBER_LABEL ? null : flutesInLabel(sub) });
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
if (/^p\d+$/.test(key)) {
|
|
198
|
+
if (coating === null) {
|
|
199
|
+
throw new VendorResponseError(family, `has a price column (${key}) under no coating group`);
|
|
200
|
+
}
|
|
201
|
+
if (sub !== PRICE_LABEL) {
|
|
202
|
+
throw new VendorResponseError(family, `has a price column (${key}) sub-headed ${JSON.stringify(sub)} rather ` +
|
|
203
|
+
`than ${JSON.stringify(PRICE_LABEL)}`);
|
|
204
|
+
}
|
|
205
|
+
groups.push({ coating, parts, priceKey: key });
|
|
206
|
+
coating = null;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
if (coating !== null) {
|
|
210
|
+
throw new VendorResponseError(family, `leaves coating group ${JSON.stringify(coating)} without a price column`);
|
|
211
|
+
}
|
|
212
|
+
return fillBlankFluteLabels(family, groups, warn);
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Fill a flute sub-label Harvey rendered as ` ` from a sibling group.
|
|
216
|
+
*
|
|
217
|
+
* Two tables in the catalog do this — `EndMill-008` table 2 and `EndMill-018`
|
|
218
|
+
* table 2 — and in both the neighbouring coating groups label the same slot
|
|
219
|
+
* correctly. A sibling qualifies only when it is the same width *and* agrees on
|
|
220
|
+
* every other slot, so the fill is a copy of a row that is demonstrably the same
|
|
221
|
+
* shape rather than of whichever group happened to be nearby. Two siblings that
|
|
222
|
+
* disagree is a hard failure: there is no way to pick, and picking would put a
|
|
223
|
+
* flute count on a real part on no evidence.
|
|
224
|
+
*/
|
|
225
|
+
function fillBlankFluteLabels(family, groups, warn) {
|
|
226
|
+
return groups.map((group) => {
|
|
227
|
+
// All stated is the matrix pattern with nothing missing; none stated is the
|
|
228
|
+
// `TOOL #` pattern, where the row's own FLUTES column is the source and a
|
|
229
|
+
// null here is not a gap. Only a mixture is a blank label to fill.
|
|
230
|
+
const stated = group.parts.filter((p) => p.flutes !== null).length;
|
|
231
|
+
if (stated === 0 || stated === group.parts.length)
|
|
232
|
+
return group;
|
|
233
|
+
const parts = group.parts.map((part, slot) => {
|
|
234
|
+
if (part.flutes !== null)
|
|
235
|
+
return part;
|
|
236
|
+
const candidates = new Set();
|
|
237
|
+
for (const other of groups) {
|
|
238
|
+
if (other === group || other.parts.length !== group.parts.length)
|
|
239
|
+
continue;
|
|
240
|
+
const agrees = group.parts.every((p, i) => i === slot || p.flutes === null || p.flutes === other.parts[i]?.flutes);
|
|
241
|
+
const found = other.parts[slot]?.flutes;
|
|
242
|
+
if (agrees && found != null)
|
|
243
|
+
candidates.add(found);
|
|
244
|
+
}
|
|
245
|
+
if (candidates.size !== 1) {
|
|
246
|
+
throw new VendorResponseError(family, `has a blank flute label in the ${JSON.stringify(group.coating)} group ` +
|
|
247
|
+
`at slot ${slot}, and ${candidates.size === 0 ? 'no' : 'more than one'} ` +
|
|
248
|
+
`sibling group of the same shape states one`);
|
|
249
|
+
}
|
|
250
|
+
const flutes = [...candidates][0];
|
|
251
|
+
warn(` WARNING: ${family}: the ${JSON.stringify(group.coating)} group's flute ` +
|
|
252
|
+
`label at slot ${slot} is blank; read as ${flutes} from a sibling group`);
|
|
253
|
+
return { ...part, flutes };
|
|
254
|
+
});
|
|
255
|
+
return { ...group, parts };
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
/** Read one table's plan off its `cols<N>` keys and its flattened header. */
|
|
259
|
+
export function planTable(family, keys, header, unit, options = {}) {
|
|
260
|
+
const { warn = consoleWarn, ratioKeys = new Set() } = options;
|
|
261
|
+
if (keys.length !== header.length) {
|
|
262
|
+
throw new VendorResponseError(family, `declares ${keys.length} data columns but its header flattens to ` +
|
|
263
|
+
`${header.length} — the two are positional and no longer line up`);
|
|
264
|
+
}
|
|
265
|
+
const geometry = geometryColumns(family, keys, header, unit, ratioKeys, warn);
|
|
266
|
+
const groups = coatingGroups(family, keys, header, warn);
|
|
267
|
+
if (groups.length === 0) {
|
|
268
|
+
throw new VendorResponseError(family, 'publishes no coating group — there are no parts to read');
|
|
269
|
+
}
|
|
270
|
+
const flutesIndex = keys.findIndex((key, i) => /^a\d+$/.test(key) && header[i].top === FLUTES_LABEL);
|
|
271
|
+
return {
|
|
272
|
+
geometry,
|
|
273
|
+
flutesKey: flutesIndex === -1 ? null : keys[flutesIndex],
|
|
274
|
+
matrix: groups.some((g) => g.parts.some((p) => p.flutes !== null)),
|
|
275
|
+
groups,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Every part the row's own add-to-cart payload lists, in cell order.
|
|
280
|
+
*
|
|
281
|
+
* `T` and `C` differ on the 36 parts whose printed number carries a footnote
|
|
282
|
+
* marker: `C` keeps it, `T` does not. Both are read, and each does a different
|
|
283
|
+
* job — `C` is what the cell says and is therefore what the cell can be checked
|
|
284
|
+
* against, `T` is Harvey's own clean part number and is therefore what the CSV
|
|
285
|
+
* records. Deriving the second from the first with a regex would work today and
|
|
286
|
+
* be this package's guess rather than the vendor's statement.
|
|
287
|
+
*
|
|
288
|
+
* `Q` is `"1"` on all 12,799 entries and is not read.
|
|
289
|
+
*/
|
|
290
|
+
export function cartEntries(family, row) {
|
|
291
|
+
const payload = row['atc']?.j;
|
|
292
|
+
if (typeof payload !== 'string' || payload === '') {
|
|
293
|
+
throw new VendorResponseError(family, 'has a row with no add-to-cart payload to check against');
|
|
294
|
+
}
|
|
295
|
+
let parsed;
|
|
296
|
+
try {
|
|
297
|
+
parsed = JSON.parse(payload);
|
|
298
|
+
}
|
|
299
|
+
catch (error) {
|
|
300
|
+
throw new VendorResponseError(family, `has an unreadable add-to-cart payload: ${error.message}`);
|
|
301
|
+
}
|
|
302
|
+
return parsed.map((entry) => ({ number: entry.T ?? '', printed: entry.C ?? entry.T ?? '' }));
|
|
303
|
+
}
|
|
304
|
+
/** Refuse an explosion the vendor's own payload does not agree with. */
|
|
305
|
+
export function checkCartPayload(family, printed, cart) {
|
|
306
|
+
if (printed.length === cart.length && printed.every((t, i) => t === cart[i].printed))
|
|
307
|
+
return;
|
|
308
|
+
throw new VendorResponseError(family, `read [${printed.join(', ')}] off a row whose own add-to-cart payload ` +
|
|
309
|
+
`lists [${cart.map((entry) => entry.printed).join(', ')}] — the coating ` +
|
|
310
|
+
`grid has shifted`);
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* The unlabelled columns of a page whose cells are all ratio annotations.
|
|
314
|
+
*
|
|
315
|
+
* Decided across the whole page rather than per table, because the tables merge
|
|
316
|
+
* into one CSV and a column cannot be named two things. `EndMill-006` is why: a
|
|
317
|
+
* column is `LONG` on four rows of its first table and empty throughout its
|
|
318
|
+
* second.
|
|
319
|
+
*/
|
|
320
|
+
export function ratioColumnKeys(tables) {
|
|
321
|
+
const unlabelled = new Set();
|
|
322
|
+
const other = new Set();
|
|
323
|
+
for (const table of tables) {
|
|
324
|
+
table.keys.forEach((key, index) => {
|
|
325
|
+
const head = table.header[index];
|
|
326
|
+
if (head === undefined || !/^a\d+$/.test(key) || !isJunkLabel(head.top))
|
|
327
|
+
return;
|
|
328
|
+
unlabelled.add(key);
|
|
329
|
+
for (const row of table.rows) {
|
|
330
|
+
const text = cellText(row[key]?.d);
|
|
331
|
+
if (text !== '' && parseValue(text).ratio === null)
|
|
332
|
+
other.add(key);
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
for (const key of other)
|
|
337
|
+
unlabelled.delete(key);
|
|
338
|
+
return unlabelled;
|
|
339
|
+
}
|
|
340
|
+
function readPage(html, what) {
|
|
341
|
+
const model = readLiteral(html, 'viewModel')?.simFileViewModel;
|
|
342
|
+
const productCode = model?.productCode;
|
|
343
|
+
if (!productCode) {
|
|
344
|
+
throw new VendorResponseError(what, 'has no viewModel.simFileViewModel.productCode');
|
|
345
|
+
}
|
|
346
|
+
const cad = new Map();
|
|
347
|
+
for (const variant of model.variantSimFileViewModel ?? []) {
|
|
348
|
+
if (variant.variantName)
|
|
349
|
+
cad.set(variant.variantName, variant);
|
|
350
|
+
}
|
|
351
|
+
// Table ids drop the `HT-` the product code carries: `HT-Harvey-EndMill-008`
|
|
352
|
+
// heads a table with `id="Harvey-EndMill-008_1"`.
|
|
353
|
+
const prefix = productCode.replace(/^HT-/, '');
|
|
354
|
+
const tables = [];
|
|
355
|
+
for (let n = 1; n <= MAX_TABLES; n++) {
|
|
356
|
+
const keys = (readLiteral(html, `cols${n}`) ?? [])
|
|
357
|
+
.map((column) => column.data ?? '')
|
|
358
|
+
.filter(Boolean);
|
|
359
|
+
if (keys.length === 0)
|
|
360
|
+
continue;
|
|
361
|
+
const rows = readLiteral(html, `tableData${n}`) ?? [];
|
|
362
|
+
tables.push({ keys, rows, header: flatHeader(html, `${prefix}_${n}`) });
|
|
363
|
+
}
|
|
364
|
+
if (tables.length === 0) {
|
|
365
|
+
throw new VendorResponseError(what, 'declares no populated table — the page changed shape');
|
|
366
|
+
}
|
|
367
|
+
return { productCode, productTitle: model.productTitle ?? '', cad, tables };
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* One product page's HTML -> one row per orderable part.
|
|
371
|
+
*
|
|
372
|
+
* Pure, and separate from {@link scrapeProduct} so the whole matrix explosion is
|
|
373
|
+
* testable from a fixture string with no fetcher anywhere near it.
|
|
374
|
+
*
|
|
375
|
+
* A page's tables differ only in published tolerance — which is dropped — so
|
|
376
|
+
* they merge into one CSV. Their column labels are required to agree, because
|
|
377
|
+
* two tables that no longer publish the same columns is a page that has been
|
|
378
|
+
* restructured, and merging them would interleave two different geometries under
|
|
379
|
+
* one header.
|
|
380
|
+
*/
|
|
381
|
+
export function parseProductPage(html, source, options) {
|
|
382
|
+
const { unit, warn = consoleWarn } = options;
|
|
383
|
+
const page = readPage(html, source);
|
|
384
|
+
const family = page.productCode;
|
|
385
|
+
const ratioKeys = ratioColumnKeys(page.tables);
|
|
386
|
+
const plans = page.tables.map((table) => planTable(family, table.keys, table.header, unit, { warn, ratioKeys }));
|
|
387
|
+
const geometry = plans[0].geometry.map((column) => column.column);
|
|
388
|
+
for (const plan of plans.slice(1)) {
|
|
389
|
+
const other = plan.geometry.map((column) => column.column);
|
|
390
|
+
if (other.length !== geometry.length || other.some((c, i) => c !== geometry[i])) {
|
|
391
|
+
throw new VendorResponseError(family, `publishes [${geometry.join(', ')}] on one table and [${other.join(', ')}] ` +
|
|
392
|
+
`on another — the page's tables no longer share a header`);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
const flutes = plans.some((plan) => plan.matrix || plan.flutesKey !== null);
|
|
396
|
+
const header = [
|
|
397
|
+
TOOL_NUMBER_COLUMN,
|
|
398
|
+
DESCRIPTION_COLUMN,
|
|
399
|
+
COATING_COLUMN,
|
|
400
|
+
...(flutes ? [FLUTES_COLUMN] : []),
|
|
401
|
+
...geometry,
|
|
402
|
+
PRICE_COLUMN,
|
|
403
|
+
CAD_COLUMN,
|
|
404
|
+
CAD_DXF_COLUMN,
|
|
405
|
+
];
|
|
406
|
+
const rows = [];
|
|
407
|
+
page.tables.forEach((table, index) => {
|
|
408
|
+
const plan = plans[index];
|
|
409
|
+
for (const source_ of table.rows) {
|
|
410
|
+
const shared = {};
|
|
411
|
+
for (const column of plan.geometry) {
|
|
412
|
+
shared[column.column] = cellText(source_[column.key]?.d);
|
|
413
|
+
}
|
|
414
|
+
const rowFlutes = plan.flutesKey === null ? '' : cellText(source_[plan.flutesKey]?.d);
|
|
415
|
+
// Two lists, and the split is the point. `printed` is every non-empty
|
|
416
|
+
// tool-number cell, in order, and is what the row's own add-to-cart
|
|
417
|
+
// payload is checked against. `parts` is the subset that is a real part.
|
|
418
|
+
const printed = [];
|
|
419
|
+
const parts = [];
|
|
420
|
+
for (const group of plan.groups) {
|
|
421
|
+
const price = priceOf(source_[group.priceKey]?.d);
|
|
422
|
+
for (const part of group.parts) {
|
|
423
|
+
const cell = source_[part.key]?.d ?? '';
|
|
424
|
+
const text = cellText(cell);
|
|
425
|
+
if (text === '')
|
|
426
|
+
continue;
|
|
427
|
+
const slot = printed.length;
|
|
428
|
+
printed.push(text);
|
|
429
|
+
if (!LINKED.test(cell)) {
|
|
430
|
+
// 26 cells across two families carry the marketing string
|
|
431
|
+
// `25x Diameter!` in a tool-number column, rendered in red, with no
|
|
432
|
+
// link and no CAD model — and Harvey's own cart payload lists them
|
|
433
|
+
// as though they were orderable. The link is the structural
|
|
434
|
+
// difference; a rule that matched the text would miss the next one.
|
|
435
|
+
warn(` WARNING: ${family}: the ${JSON.stringify(group.coating)} column ` +
|
|
436
|
+
`holds ${JSON.stringify(text)} with no product link — not a part`);
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
439
|
+
// The sub-label is per part and the FLUTES column is per row, so the
|
|
440
|
+
// sub-label wins where both exist. They agree on all four tables that
|
|
441
|
+
// publish both; a disagreement is Harvey having changed one of them.
|
|
442
|
+
const stated = part.flutes === null ? null : String(part.flutes);
|
|
443
|
+
if (stated !== null && rowFlutes !== '' && stated !== rowFlutes) {
|
|
444
|
+
warn(` WARNING: ${family}: ${text} sits under a ${stated}-flute header ` +
|
|
445
|
+
`on a row whose FLUTES column says ${rowFlutes}`);
|
|
446
|
+
}
|
|
447
|
+
parts.push({ slot, coating: group.coating, price, flutes: stated ?? rowFlutes });
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
const cart = cartEntries(family, source_);
|
|
451
|
+
checkCartPayload(family, printed, cart);
|
|
452
|
+
for (const part of parts) {
|
|
453
|
+
const toolNumber = cart[part.slot].number;
|
|
454
|
+
const variant = page.cad.get(toolNumber);
|
|
455
|
+
rows.push({
|
|
456
|
+
...shared,
|
|
457
|
+
[TOOL_NUMBER_COLUMN]: toolNumber,
|
|
458
|
+
[DESCRIPTION_COLUMN]: page.productTitle,
|
|
459
|
+
[COATING_COLUMN]: part.coating,
|
|
460
|
+
...(flutes ? { [FLUTES_COLUMN]: part.flutes } : {}),
|
|
461
|
+
[PRICE_COLUMN]: part.price,
|
|
462
|
+
[CAD_COLUMN]: variant?.variantStepFileLink ?? '',
|
|
463
|
+
[CAD_DXF_COLUMN]: variant?.variantDxfFileLink ?? '',
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
});
|
|
468
|
+
return { header, rows, source, familyCode: page.productCode };
|
|
469
|
+
}
|
|
470
|
+
/** One product page's URL. */
|
|
471
|
+
export function productUrl(path) {
|
|
472
|
+
return BASE + path;
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* Scrape one product page.
|
|
476
|
+
*
|
|
477
|
+
* The one network call in this module, and the seam every test replaces —
|
|
478
|
+
* everything below it parses a string.
|
|
479
|
+
*/
|
|
480
|
+
export async function scrapeProduct(fetcher, path, options) {
|
|
481
|
+
const url = productUrl(path);
|
|
482
|
+
return parseProductPage(await fetcher.text(url), url, options);
|
|
483
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One display cell -> a number, or an honest reason there is none.
|
|
3
|
+
*
|
|
4
|
+
* Harvey publishes values the way a printed catalog does, because the table is
|
|
5
|
+
* a printed catalog: `.1250 (1/8)` is a decimal with the fraction a machinist
|
|
6
|
+
* orders by beside it, `1-1/2` is a mixed number, `3 mm` is a metric shank on
|
|
7
|
+
* an imperial page, and `-` means the column does not apply to that row.
|
|
8
|
+
*
|
|
9
|
+
* **One rule covers the whole grammar: the value is the leading token, and
|
|
10
|
+
* anything in parentheses is Harvey's own equivalent annotation.** Every shape
|
|
11
|
+
* measured across all 12,799 parts is in `docs/HARVEY_PRODUCT_TABLE.md` §4;
|
|
12
|
+
* this module is that section as code.
|
|
13
|
+
*
|
|
14
|
+
* ## Read `d`, never `v`
|
|
15
|
+
*
|
|
16
|
+
* Each cell also carries `v`, which looks pre-parsed and is the wrong field: it
|
|
17
|
+
* changes unit basis *within a single row*. On the metric ball page, `v` is the
|
|
18
|
+
* inch equivalent for the cutter diameter and the raw millimetre number for the
|
|
19
|
+
* length of cut. Anything reading it gets a clean conversion with wrong numbers
|
|
20
|
+
* in it — the exact failure `conventions.UNIT_SUFFIX` exists to prevent.
|
|
21
|
+
*
|
|
22
|
+
* ## A stated unit wins over the column's
|
|
23
|
+
*
|
|
24
|
+
* 46 cells across six otherwise-imperial families state a metric value outright
|
|
25
|
+
* — `3 mm` in a `SHANK DIAMETER` column on an inch page, a metric-shank tool
|
|
26
|
+
* listed among imperial ones. {@link dimension} converts those into the family's
|
|
27
|
+
* declared unit and warns, naming the part. It does **not** treat a
|
|
28
|
+
* parenthesised `(3 mm)` that way: that is an annotation on an inch value, and
|
|
29
|
+
* converting it would turn `.1181 in` into `3 in`.
|
|
30
|
+
*/
|
|
31
|
+
import type { UnitSystem } from '../../conventions.js';
|
|
32
|
+
import { type Warn } from '../../scrape.js';
|
|
33
|
+
/** What one display cell says. */
|
|
34
|
+
export interface HarveyValue {
|
|
35
|
+
/** The leading numeric token, in whatever {@link stated} says. */
|
|
36
|
+
readonly value: number | null;
|
|
37
|
+
/** The unit the token states outright. Null where it states none. */
|
|
38
|
+
readonly stated: UnitSystem | null;
|
|
39
|
+
/** True where the token is an angle in degrees rather than a length. */
|
|
40
|
+
readonly degrees: boolean;
|
|
41
|
+
/** Harvey's parenthesised equivalent, verbatim — `1/8`, `3 mm`, `N.P.T.`. */
|
|
42
|
+
readonly annotation: string | null;
|
|
43
|
+
/** The multiplier a `(1.5x)` cell states, and nothing else. */
|
|
44
|
+
readonly ratio: number | null;
|
|
45
|
+
/** Text with no numeric reading at all: `I`, `II`, `III`, `LONG`. */
|
|
46
|
+
readonly code: string | null;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* One cell's display text, read.
|
|
50
|
+
*
|
|
51
|
+
* An empty cell and a `-` both come back as {@link NOTHING}. They mean slightly
|
|
52
|
+
* different things to a reader of the catalog — "nothing published" and "does
|
|
53
|
+
* not apply" — and the same thing to anything building a record, which is that
|
|
54
|
+
* there is no number here and 0 is not a substitute for one.
|
|
55
|
+
*/
|
|
56
|
+
export declare function parseValue(display: string): HarveyValue;
|
|
57
|
+
/**
|
|
58
|
+
* One cell as a length in `unit`, or null where it publishes none.
|
|
59
|
+
*
|
|
60
|
+
* `measure.asLength` makes both calls — convert-and-warn a cell whose stated
|
|
61
|
+
* unit disagrees with the family's, refuse an angle in a dimensional column —
|
|
62
|
+
* because they are the same two calls for every vendor and were a verbatim copy
|
|
63
|
+
* here and in `vendors/emuge/value.ts` until 2026-09-01. What is Harvey's is
|
|
64
|
+
* above: that `degrees` is a field of its own rather than a member of
|
|
65
|
+
* `stated`, and that a parenthesised `(3 mm)` is an annotation on an inch value
|
|
66
|
+
* and never the value itself.
|
|
67
|
+
*
|
|
68
|
+
* The 46 cells this converts are metric-shank tools listed among imperial ones
|
|
69
|
+
* — real parts somebody can order, which is why they are converted rather than
|
|
70
|
+
* dropped.
|
|
71
|
+
*/
|
|
72
|
+
export declare function dimension(display: string, unit: UnitSystem, what: string, warn?: Warn): number | null;
|
|
73
|
+
/** One cell as a whole count — a flute or tooth number. Null where blank. */
|
|
74
|
+
export declare function count(display: string): number | null;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One display cell -> a number, or an honest reason there is none.
|
|
3
|
+
*
|
|
4
|
+
* Harvey publishes values the way a printed catalog does, because the table is
|
|
5
|
+
* a printed catalog: `.1250 (1/8)` is a decimal with the fraction a machinist
|
|
6
|
+
* orders by beside it, `1-1/2` is a mixed number, `3 mm` is a metric shank on
|
|
7
|
+
* an imperial page, and `-` means the column does not apply to that row.
|
|
8
|
+
*
|
|
9
|
+
* **One rule covers the whole grammar: the value is the leading token, and
|
|
10
|
+
* anything in parentheses is Harvey's own equivalent annotation.** Every shape
|
|
11
|
+
* measured across all 12,799 parts is in `docs/HARVEY_PRODUCT_TABLE.md` §4;
|
|
12
|
+
* this module is that section as code.
|
|
13
|
+
*
|
|
14
|
+
* ## Read `d`, never `v`
|
|
15
|
+
*
|
|
16
|
+
* Each cell also carries `v`, which looks pre-parsed and is the wrong field: it
|
|
17
|
+
* changes unit basis *within a single row*. On the metric ball page, `v` is the
|
|
18
|
+
* inch equivalent for the cutter diameter and the raw millimetre number for the
|
|
19
|
+
* length of cut. Anything reading it gets a clean conversion with wrong numbers
|
|
20
|
+
* in it — the exact failure `conventions.UNIT_SUFFIX` exists to prevent.
|
|
21
|
+
*
|
|
22
|
+
* ## A stated unit wins over the column's
|
|
23
|
+
*
|
|
24
|
+
* 46 cells across six otherwise-imperial families state a metric value outright
|
|
25
|
+
* — `3 mm` in a `SHANK DIAMETER` column on an inch page, a metric-shank tool
|
|
26
|
+
* listed among imperial ones. {@link dimension} converts those into the family's
|
|
27
|
+
* declared unit and warns, naming the part. It does **not** treat a
|
|
28
|
+
* parenthesised `(3 mm)` that way: that is an annotation on an inch value, and
|
|
29
|
+
* converting it would turn `.1181 in` into `3 in`.
|
|
30
|
+
*/
|
|
31
|
+
import { asCount, asLength, fractionValue } from '../../measure.js';
|
|
32
|
+
import { consoleWarn } from '../../scrape.js';
|
|
33
|
+
/**
|
|
34
|
+
* Footnote references Harvey appends to a value or a tool number. 62 cells
|
|
35
|
+
* carry one; each points at a note printed under the table, and none of them is
|
|
36
|
+
* part of the number.
|
|
37
|
+
*/
|
|
38
|
+
const FOOTNOTE = /[*!†]+$/;
|
|
39
|
+
/** Harvey's own equivalent, in parentheses at the end: `(1/8)`, `(3 mm)`. */
|
|
40
|
+
const ANNOTATION = /\s*\(([^)]*)\)\s*$/;
|
|
41
|
+
/** `(1.5x)`, `(30x)` — the vendor's reach- or length-to-diameter ratio. */
|
|
42
|
+
const RATIO = /^(\d*\.?\d+)x$/;
|
|
43
|
+
/**
|
|
44
|
+
* A mixed number, a simple fraction or a decimal, with an optional unit.
|
|
45
|
+
*
|
|
46
|
+
* The alternation is ordered longest-first on purpose: `\d*\.?\d+` alone
|
|
47
|
+
* matches the `3` of `3-3/4` and would leave the rest unread, which parses a
|
|
48
|
+
* 3.75-inch tool as a 3-inch one.
|
|
49
|
+
*/
|
|
50
|
+
const NUMBER = /^(\d+-\d+\/\d+|\d*\.?\d+\/\d+|\d*\.?\d+)\s*(mm|°)?$/;
|
|
51
|
+
const NOTHING = {
|
|
52
|
+
value: null,
|
|
53
|
+
stated: null,
|
|
54
|
+
degrees: false,
|
|
55
|
+
annotation: null,
|
|
56
|
+
ratio: null,
|
|
57
|
+
code: null,
|
|
58
|
+
};
|
|
59
|
+
/**
|
|
60
|
+
* One cell's display text, read.
|
|
61
|
+
*
|
|
62
|
+
* An empty cell and a `-` both come back as {@link NOTHING}. They mean slightly
|
|
63
|
+
* different things to a reader of the catalog — "nothing published" and "does
|
|
64
|
+
* not apply" — and the same thing to anything building a record, which is that
|
|
65
|
+
* there is no number here and 0 is not a substitute for one.
|
|
66
|
+
*/
|
|
67
|
+
export function parseValue(display) {
|
|
68
|
+
const text = display.trim().replace(FOOTNOTE, '').trim();
|
|
69
|
+
if (text === '' || text === '-')
|
|
70
|
+
return NOTHING;
|
|
71
|
+
const annotated = ANNOTATION.exec(text);
|
|
72
|
+
const annotation = annotated?.[1]?.trim() ?? null;
|
|
73
|
+
const head = annotated ? text.slice(0, annotated.index).trim() : text;
|
|
74
|
+
if (head === '') {
|
|
75
|
+
const ratio = annotation === null ? null : RATIO.exec(annotation);
|
|
76
|
+
if (ratio)
|
|
77
|
+
return { ...NOTHING, ratio: Number(ratio[1]) };
|
|
78
|
+
return { ...NOTHING, annotation };
|
|
79
|
+
}
|
|
80
|
+
const number = NUMBER.exec(head);
|
|
81
|
+
// `measure.fractionValue` is stricter than {@link NUMBER} in one place — it
|
|
82
|
+
// refuses a division by zero — so a token that matched here can still have no
|
|
83
|
+
// reading, and that is a code cell rather than a number.
|
|
84
|
+
const value = number === null ? null : fractionValue(number[1]);
|
|
85
|
+
if (number === null || value === null)
|
|
86
|
+
return { ...NOTHING, annotation, code: head };
|
|
87
|
+
const unit = number[2];
|
|
88
|
+
return {
|
|
89
|
+
value,
|
|
90
|
+
stated: unit === 'mm' ? 'millimeters' : null,
|
|
91
|
+
degrees: unit === '°',
|
|
92
|
+
annotation,
|
|
93
|
+
ratio: null,
|
|
94
|
+
code: null,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* One cell as a length in `unit`, or null where it publishes none.
|
|
99
|
+
*
|
|
100
|
+
* `measure.asLength` makes both calls — convert-and-warn a cell whose stated
|
|
101
|
+
* unit disagrees with the family's, refuse an angle in a dimensional column —
|
|
102
|
+
* because they are the same two calls for every vendor and were a verbatim copy
|
|
103
|
+
* here and in `vendors/emuge/value.ts` until 2026-09-01. What is Harvey's is
|
|
104
|
+
* above: that `degrees` is a field of its own rather than a member of
|
|
105
|
+
* `stated`, and that a parenthesised `(3 mm)` is an annotation on an inch value
|
|
106
|
+
* and never the value itself.
|
|
107
|
+
*
|
|
108
|
+
* The 46 cells this converts are metric-shank tools listed among imperial ones
|
|
109
|
+
* — real parts somebody can order, which is why they are converted rather than
|
|
110
|
+
* dropped.
|
|
111
|
+
*/
|
|
112
|
+
export function dimension(display, unit, what, warn = consoleWarn) {
|
|
113
|
+
const { value, stated, degrees } = parseValue(display);
|
|
114
|
+
return asLength({ value, stated: degrees ? 'degrees' : stated }, display, unit, what, warn);
|
|
115
|
+
}
|
|
116
|
+
/** One cell as a whole count — a flute or tooth number. Null where blank. */
|
|
117
|
+
export function count(display) {
|
|
118
|
+
return asCount(parseValue(display));
|
|
119
|
+
}
|