@toolpath/tool-scraper 0.1.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -11
- package/dist/columns.d.ts +62 -0
- package/dist/columns.js +62 -0
- package/dist/conventions.d.ts +89 -15
- package/dist/conventions.js +108 -15
- package/dist/families/emuge.d.ts +183 -0
- package/dist/families/emuge.js +159 -0
- package/dist/families/harvey.d.ts +1782 -0
- package/dist/families/harvey.js +1328 -0
- package/dist/families/index.js +5 -2
- package/dist/families/kennametal.d.ts +21 -0
- package/dist/families/kennametal.js +10 -0
- package/dist/families/maritool.d.ts +120 -0
- package/dist/families/maritool.js +175 -0
- package/dist/family.d.ts +35 -1
- package/dist/family.js +28 -0
- package/dist/identity.d.ts +18 -0
- package/dist/identity.js +46 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +16 -0
- package/dist/measure.d.ts +112 -0
- package/dist/measure.js +130 -0
- package/dist/node/cli.d.ts +3 -0
- package/dist/node/cli.js +152 -2
- package/dist/node/main.js +0 -0
- package/dist/records.d.ts +169 -12
- package/dist/records.js +116 -5
- package/dist/registry.d.ts +31 -1
- package/dist/registry.js +40 -1
- package/dist/scrape.d.ts +15 -0
- package/dist/scrape.js +24 -0
- package/dist/vendors/destinytool/records.d.ts +13 -3
- package/dist/vendors/destinytool/records.js +41 -37
- package/dist/vendors/emuge/index.d.ts +17 -0
- package/dist/vendors/emuge/index.js +17 -0
- package/dist/vendors/emuge/records.d.ts +150 -0
- package/dist/vendors/emuge/records.js +375 -0
- package/dist/vendors/emuge/scrape.d.ts +227 -0
- package/dist/vendors/emuge/scrape.js +358 -0
- package/dist/vendors/emuge/value.d.ts +75 -0
- package/dist/vendors/emuge/value.js +116 -0
- package/dist/vendors/harvey/catalog.d.ts +53 -0
- package/dist/vendors/harvey/catalog.js +120 -0
- package/dist/vendors/harvey/header.d.ts +89 -0
- package/dist/vendors/harvey/header.js +185 -0
- package/dist/vendors/harvey/index.d.ts +21 -0
- package/dist/vendors/harvey/index.js +21 -0
- package/dist/vendors/harvey/lexicon.d.ts +73 -0
- package/dist/vendors/harvey/lexicon.js +126 -0
- package/dist/vendors/harvey/literal.d.ts +68 -0
- package/dist/vendors/harvey/literal.js +214 -0
- package/dist/vendors/harvey/records.d.ts +79 -0
- package/dist/vendors/harvey/records.js +163 -0
- package/dist/vendors/harvey/scrape.d.ts +187 -0
- package/dist/vendors/harvey/scrape.js +483 -0
- package/dist/vendors/harvey/value.d.ts +74 -0
- package/dist/vendors/harvey/value.js +119 -0
- package/dist/vendors/kennametal/records.d.ts +13 -4
- package/dist/vendors/kennametal/records.js +61 -26
- package/dist/vendors/maritool/catalog.d.ts +81 -0
- package/dist/vendors/maritool/catalog.js +132 -0
- package/dist/vendors/maritool/index.d.ts +10 -0
- package/dist/vendors/maritool/index.js +10 -0
- package/dist/vendors/maritool/scrape.d.ts +297 -0
- package/dist/vendors/maritool/scrape.js +593 -0
- package/dist/vendors/regofix/scrape.d.ts +8 -11
- package/dist/vendors/regofix/scrape.js +21 -36
- package/package.json +31 -7
|
@@ -0,0 +1,593 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MariTool category listings -> toolholding rows.
|
|
3
|
+
*
|
|
4
|
+
* The fifth vendor and the first whose transport needed no discovery at all:
|
|
5
|
+
* `www.maritool.com` is an osCommerce-family storefront that server-renders
|
|
6
|
+
* everything, so the whole transport is a paced `GET`. There is no JSON API,
|
|
7
|
+
* no sitemap and no application bundle to read — see
|
|
8
|
+
* `docs/MARITOOL_CATALOG.md` for what was tried and what the tree looks like.
|
|
9
|
+
*
|
|
10
|
+
* ## Two sources, and both are needed
|
|
11
|
+
*
|
|
12
|
+
* **The roster** is a leaf category's listing page. Every row carries the
|
|
13
|
+
* part number (`Part#:`), the store's `products_id`, the product name and the
|
|
14
|
+
* CAD assets, and the page states its own row total — `(of 51 products)` —
|
|
15
|
+
* which is the second opinion `receipts.checkRows` gets for free from a
|
|
16
|
+
* hand-counted `rows` and which nothing here computes from the rows it just
|
|
17
|
+
* collected.
|
|
18
|
+
*
|
|
19
|
+
* **The geometry** is a `Product Specifications` name/value table on each
|
|
20
|
+
* part's own page, so this is one request per part. The table's labels are
|
|
21
|
+
* MariTool's own and are carried into the CSV verbatim, which is the standing
|
|
22
|
+
* rule; the header is their union in first-seen order, because which keys a
|
|
23
|
+
* part publishes is a function of its style. A shrink-fit holder states
|
|
24
|
+
* `Shank Size` and `Nose Diameter`, a collet chuck states `Collet Size` and
|
|
25
|
+
* `Collet Grip Range`, and a hydraulic chuck states `Hydraulic Type`.
|
|
26
|
+
*
|
|
27
|
+
* ## What the vendor gets wrong
|
|
28
|
+
*
|
|
29
|
+
* Four faults found on 2026-08-29, all reported as warnings rather than
|
|
30
|
+
* fixed — two disagreeing vendor cells cannot say which one is wrong, and a
|
|
31
|
+
* scraper that corrects one becomes a place tool data is authored by hand.
|
|
32
|
+
* This is the same call `vendors/regofix/scrape.ts` made on its three.
|
|
33
|
+
*
|
|
34
|
+
* 1. **`BT40-ER32-60` publishes no `Taper` row at all**, alone among the 529
|
|
35
|
+
* parts in scope. Its row is kept with `taper` and `contact` empty rather
|
|
36
|
+
* than dropped or filled in from its part number: the CSV is a receipt, and
|
|
37
|
+
* the hole is what the vendor published.
|
|
38
|
+
* 2. **`Collet Size` carries a collet *nut* designation on two parts.**
|
|
39
|
+
* `CAT40-ER25-3.0MD` and `BT30-ER25-60M` both state `ER25M`, and `ER25M` is
|
|
40
|
+
* not a collet series — `HSK40E-ER16-3.0M` puts exactly that shape of value
|
|
41
|
+
* in its own `Collet Nut` cell, which is the column it belongs in. The
|
|
42
|
+
* string is written into `CST` as designated, so it joins to no collet
|
|
43
|
+
* family, and it is warned about. Widening it to `ER25` would offer a
|
|
44
|
+
* machinist a collet that may not seat.
|
|
45
|
+
* 3. **`Collet Size` is spaced inconsistently within one style** — `ER 11`
|
|
46
|
+
* and `ER11` are both published. {@link colletSeries} closes the space,
|
|
47
|
+
* because `CST` is a join key and two spellings of one series join to
|
|
48
|
+
* nothing.
|
|
49
|
+
* 4. **Two parts publish no `Product Specifications` table**, both in the
|
|
50
|
+
* CAT50 `Collet Chucks` leaf. They state their geometry as English prose in
|
|
51
|
+
* a `Product Info` bullet list instead — *"Gage length is 100mm (3.93
|
|
52
|
+
* inch)"* — and that is a sentence, not a table. They are skipped with a
|
|
53
|
+
* named warning; a regex over the prose would be this package authoring
|
|
54
|
+
* tool data by hand.
|
|
55
|
+
*
|
|
56
|
+
* ## Where the units are
|
|
57
|
+
*
|
|
58
|
+
* `Gage Length` is metric on some parts and imperial on others **inside one
|
|
59
|
+
* CSV, and inside one category page**: `HSK40E-ER11-40` gages `40mm` where
|
|
60
|
+
* `HSK40E-ER16-3.0M` gages `3.0` inches, and both are rows of
|
|
61
|
+
* `c23_46_1552_1558`. So no MariTool family declares a `unit`, and the gage
|
|
62
|
+
* length is promoted into an `L1_in`/`L1_mm` pair with exactly one cell filled
|
|
63
|
+
* — the shape `regofix.colletRow` already gives `Square_in`/`Square_mm`.
|
|
64
|
+
*
|
|
65
|
+
* **Nothing is converted.** The vendor's own imperial conversion is unusable
|
|
66
|
+
* (its prose calls 40 mm "1.6 inches" where the figure is 1.5748), and
|
|
67
|
+
* computing one here would put a number in the file that the vendor never
|
|
68
|
+
* published. The raw `Gage Length` cell is kept beside the pair.
|
|
69
|
+
*
|
|
70
|
+
* Every other measured cell — `Shank Size`, `Nose Diameter`, `Collet Grip
|
|
71
|
+
* Range` — stays verbatim under MariTool's own label, unsuffixed and
|
|
72
|
+
* unpromoted. That is deliberate: `conventions.dimensionalColumn` takes its
|
|
73
|
+
* suffix from a family's declared `unit`, and these families have none to give
|
|
74
|
+
* it. An adapter choosing `_in` for those cells itself is exactly the mistake
|
|
75
|
+
* a declared `unit` exists to prevent, and `HSK40E-SF.125-45` is the row that
|
|
76
|
+
* proves it — gaged in millimetres, bored in inches.
|
|
77
|
+
*/
|
|
78
|
+
import { Parser } from 'htmlparser2';
|
|
79
|
+
import { CAD_COLUMN, CAD_DXF_COLUMN, COLLET_SERIES_COLUMN, CONTACT_COLUMN, DESCRIPTION_COLUMN, GAGE_COLUMNS, } from '../../conventions.js';
|
|
80
|
+
import { VendorResponseError } from '../../errors.js';
|
|
81
|
+
import { compare } from '../../order.js';
|
|
82
|
+
import { consoleWarn, pause, REQUEST_DELAY_MS, unionHeader, } from '../../scrape.js';
|
|
83
|
+
export const BASE = 'https://www.maritool.com';
|
|
84
|
+
/**
|
|
85
|
+
* The CSV columns this adapter builds rather than lifts from a spec table.
|
|
86
|
+
*
|
|
87
|
+
* `products_id` is the store's internal id and an ordinary column: a
|
|
88
|
+
* re-created product would change it, and every guid minted off it with it —
|
|
89
|
+
* which is why identity is the part number. It is carried anyway because it is
|
|
90
|
+
* what a re-fetch of one part is addressed by.
|
|
91
|
+
*/
|
|
92
|
+
export const MATERIAL_COLUMN = 'Material Number';
|
|
93
|
+
export const STORE_ID_COLUMN = 'products_id';
|
|
94
|
+
export const TAPER_COLUMN = 'taper';
|
|
95
|
+
export const CLAMPING_COLUMN = 'clamping';
|
|
96
|
+
export const STYLE_COLUMN = 'style';
|
|
97
|
+
/** MariTool's own label for the cell `conventions.GAGE_COLUMNS` is promoted from. */
|
|
98
|
+
export const GAGE_LABEL = 'Gage Length';
|
|
99
|
+
/** MariTool's own label for the cell {@link COLLET_SERIES_COLUMN} comes from. */
|
|
100
|
+
export const COLLET_SIZE_LABEL = 'Collet Size';
|
|
101
|
+
/** MariTool's own label for the cell {@link TAPER_FORMS} is looked up by. */
|
|
102
|
+
export const TAPER_LABEL = 'Taper';
|
|
103
|
+
/**
|
|
104
|
+
* Every `Taper` cell this package is willing to read, and what it means.
|
|
105
|
+
*
|
|
106
|
+
* A closed table rather than a pattern, and keyed on the cell upper-cased
|
|
107
|
+
* because MariTool publishes the dual-contact suffix in two casings
|
|
108
|
+
* (`CAT50 DUAL CONTACT` and `CAT50 Dual Contact` are both in the catalog). An
|
|
109
|
+
* unlisted value throws naming itself: a spindle interface this package cannot
|
|
110
|
+
* name is a guess about which machine a holder fits, which is the call
|
|
111
|
+
* `regofix.CONTACT_BY_FORM` made on `BT-OM 30`.
|
|
112
|
+
*
|
|
113
|
+
* **`contact` is `face` on every HSK size, and MariTool does not say so.** It
|
|
114
|
+
* is the interface's definition rather than a property of the part: an HSK
|
|
115
|
+
* shank is a hollow taper that seats on the flange face at the same time as
|
|
116
|
+
* the cone, which is what DIN 69893 / ISO 12164 specify and what the `A`, `E`
|
|
117
|
+
* and `F` forms vary the flange of rather than the contact. MariTool marks
|
|
118
|
+
* `DUAL CONTACT` only on its 7/24 tapers, where it is an option, and there is
|
|
119
|
+
* nothing to mark on an HSK because there is no single-contact HSK to
|
|
120
|
+
* distinguish it from.
|
|
121
|
+
*/
|
|
122
|
+
export const TAPER_FORMS = {
|
|
123
|
+
BT30: { taper: 'BT30', contact: 'taper' },
|
|
124
|
+
'BT30 DUAL CONTACT': { taper: 'BT30', contact: 'face' },
|
|
125
|
+
BT40: { taper: 'BT40', contact: 'taper' },
|
|
126
|
+
'BT40 DUAL CONTACT': { taper: 'BT40', contact: 'face' },
|
|
127
|
+
CAT40: { taper: 'CAT40', contact: 'taper' },
|
|
128
|
+
'CAT40 DUAL CONTACT': { taper: 'CAT40', contact: 'face' },
|
|
129
|
+
CAT50: { taper: 'CAT50', contact: 'taper' },
|
|
130
|
+
'CAT50 DUAL CONTACT': { taper: 'CAT50', contact: 'face' },
|
|
131
|
+
HSK25E: { taper: 'HSK25E', contact: 'face' },
|
|
132
|
+
HSK40E: { taper: 'HSK40E', contact: 'face' },
|
|
133
|
+
HSK50A: { taper: 'HSK50A', contact: 'face' },
|
|
134
|
+
HSK50E: { taper: 'HSK50E', contact: 'face' },
|
|
135
|
+
HSK63A: { taper: 'HSK63A', contact: 'face' },
|
|
136
|
+
HSK63F: { taper: 'HSK63F', contact: 'face' },
|
|
137
|
+
HSK80F: { taper: 'HSK80F', contact: 'face' },
|
|
138
|
+
HSK100A: { taper: 'HSK100A', contact: 'face' },
|
|
139
|
+
};
|
|
140
|
+
/** A collet series `CST` can join a collet family on: `ER11`, `ER32`. */
|
|
141
|
+
const COLLET_SERIES = /^ER\d+$/;
|
|
142
|
+
/**
|
|
143
|
+
* A `Gage Length` cell: a number, an optional unit, an optional nose form.
|
|
144
|
+
*
|
|
145
|
+
* All five shapes MariTool publishes, and the last two are the ones a simpler
|
|
146
|
+
* pattern misses: `3.0`, `40mm`, `3.5"`, `7.8 Inches`, `120mm Tapered`. The
|
|
147
|
+
* qualifier alternatives — `Tapered`, `Slim`, `Slim Nose`, `Slim Tapered` —
|
|
148
|
+
* are captured only so they can be dropped; see {@link parseGageLength}.
|
|
149
|
+
*
|
|
150
|
+
* `inches` leads the unit alternation because a regex alternation is ordered
|
|
151
|
+
* and `in` would otherwise match the first two letters of `Inches` and leave
|
|
152
|
+
* `ches` as a nose form.
|
|
153
|
+
*/
|
|
154
|
+
const GAGE_CELL = /^(\d+(?:\.\d+)?)\s*(inches|inch|in|mm|")?\s*(.*?)$/i;
|
|
155
|
+
/** `(of 51 products)` — the vendor's own count of a leaf's roster. */
|
|
156
|
+
const ROSTER_TOTAL = /\(of\s*<b>\s*(\d+)\s*<\/b>\s*products\)/i;
|
|
157
|
+
/**
|
|
158
|
+
* `.../p341/CAT40-ER11-2.5-.../product_info.html` — the store id is `341`.
|
|
159
|
+
*
|
|
160
|
+
* **The slug can hold a slash**, so this cannot require one path segment
|
|
161
|
+
* between the id and the filename: MariTool builds the slug out of the product
|
|
162
|
+
* name and does not escape it, which puts
|
|
163
|
+
* `.../p29006/CAT50-3/4-TAPERED-NOSE-SHRINK-FIT-TOOL-HOLDER-.750-5.0/product_info.html`
|
|
164
|
+
* in the catalog. A single-segment pattern matches neither of the two CAT50
|
|
165
|
+
* shrink-fit holders and leaves their rows with no page to fetch.
|
|
166
|
+
*/
|
|
167
|
+
const PRODUCT_PATH = /\/p(\d+)\/[^?#]*\/product_info\.html$/;
|
|
168
|
+
/** The `<p>` a listing row states its part number on. */
|
|
169
|
+
const PART_NUMBER_LINE = /^Part#:\s*(.+)$/;
|
|
170
|
+
/** The product page's header over its downloads block. */
|
|
171
|
+
const DOWNLOADS_HEADER = /^Available Downloads for\s+(.+)$/;
|
|
172
|
+
/**
|
|
173
|
+
* A leaf category's listing page.
|
|
174
|
+
*
|
|
175
|
+
* The slug in a rewritten URL is ignored by the store, so this uses the
|
|
176
|
+
* platform's own unrewritten form and invents no slug: three URL forms reach
|
|
177
|
+
* the same page and only this one is derivable from a cPath alone.
|
|
178
|
+
*/
|
|
179
|
+
export function categoryUrl(cPath, page = 1) {
|
|
180
|
+
const url = `${BASE}/index.php?cPath=${cPath}`;
|
|
181
|
+
return page > 1 ? `${url}&page=${page}` : url;
|
|
182
|
+
}
|
|
183
|
+
/** Whitespace collapsed, the way a browser renders a run of it. */
|
|
184
|
+
function squash(text) {
|
|
185
|
+
return text.replace(/\s+/g, ' ').trim();
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* One listing page's roster row total and its rendered rows.
|
|
189
|
+
*
|
|
190
|
+
* The rows are read off `<tr class="product-info">`, which is the vendor's own
|
|
191
|
+
* marker for one, and the store id off the product link rather than off the
|
|
192
|
+
* add-to-cart form's hidden input: the link is the first thing in the row and
|
|
193
|
+
* is also what the next request needs.
|
|
194
|
+
*/
|
|
195
|
+
export function parseListing(html) {
|
|
196
|
+
const total = ROSTER_TOTAL.exec(html);
|
|
197
|
+
if (total === null) {
|
|
198
|
+
throw new VendorResponseError('listing page', 'states no "(of N products)" count — the page changed shape, and that ' +
|
|
199
|
+
'count is the only independent check on the rows collected');
|
|
200
|
+
}
|
|
201
|
+
const rows = [];
|
|
202
|
+
let row = null;
|
|
203
|
+
// A depth counter rather than a flag: a row holds nested tables, and a flag
|
|
204
|
+
// would clear on the first inner `</tr>`.
|
|
205
|
+
let depth = 0;
|
|
206
|
+
// Where the text handler is currently writing, if anywhere.
|
|
207
|
+
let sink = null;
|
|
208
|
+
let text = '';
|
|
209
|
+
const parser = new Parser({
|
|
210
|
+
onopentag: (tag, attribs) => {
|
|
211
|
+
const classes = (attribs['class'] ?? '').split(/\s+/);
|
|
212
|
+
if (tag === 'tr' && classes.includes('product-info') && depth === 0) {
|
|
213
|
+
row = { productsId: '', partNumber: '', name: '', productUrl: '', assets: {} };
|
|
214
|
+
depth = 1;
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
if (row === null)
|
|
218
|
+
return;
|
|
219
|
+
if (tag === 'tr')
|
|
220
|
+
depth++;
|
|
221
|
+
if (tag === 'a') {
|
|
222
|
+
const href = attribs['href'] ?? '';
|
|
223
|
+
const path = PRODUCT_PATH.exec(href);
|
|
224
|
+
if (path !== null) {
|
|
225
|
+
// The row links its part twice — once round the thumbnail and once
|
|
226
|
+
// round the name — and only the second carries text. Both give the
|
|
227
|
+
// same id, so the first wins and the second is where the name is.
|
|
228
|
+
if (row.productsId === '') {
|
|
229
|
+
row.productsId = path[1] ?? '';
|
|
230
|
+
row.productUrl = href;
|
|
231
|
+
}
|
|
232
|
+
else if (row.name === '') {
|
|
233
|
+
sink = 'name';
|
|
234
|
+
text = '';
|
|
235
|
+
}
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
if (classes.includes('asset-code')) {
|
|
239
|
+
sink = { asset: href };
|
|
240
|
+
text = '';
|
|
241
|
+
}
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
if (tag === 'p') {
|
|
245
|
+
sink = 'part';
|
|
246
|
+
text = '';
|
|
247
|
+
}
|
|
248
|
+
},
|
|
249
|
+
ontext: (chunk) => {
|
|
250
|
+
if (sink !== null)
|
|
251
|
+
text += chunk;
|
|
252
|
+
},
|
|
253
|
+
onclosetag: (tag) => {
|
|
254
|
+
if (row === null)
|
|
255
|
+
return;
|
|
256
|
+
if (sink !== null && (tag === 'a' || tag === 'p')) {
|
|
257
|
+
const value = squash(text);
|
|
258
|
+
if (sink === 'name') {
|
|
259
|
+
row.name = value;
|
|
260
|
+
}
|
|
261
|
+
else if (sink === 'part') {
|
|
262
|
+
const stated = PART_NUMBER_LINE.exec(value);
|
|
263
|
+
// Every row states one, but the same `<p>` shape also holds
|
|
264
|
+
// `Brand:` and `Available Downloads`, so it is matched rather than
|
|
265
|
+
// taken positionally.
|
|
266
|
+
if (stated !== null && row.partNumber === '')
|
|
267
|
+
row.partNumber = stated[1] ?? '';
|
|
268
|
+
}
|
|
269
|
+
else {
|
|
270
|
+
// The link's own text is the vendor's code for the format.
|
|
271
|
+
row.assets[value.toUpperCase()] = sink.asset;
|
|
272
|
+
}
|
|
273
|
+
sink = null;
|
|
274
|
+
}
|
|
275
|
+
if (tag === 'tr') {
|
|
276
|
+
depth--;
|
|
277
|
+
if (depth === 0) {
|
|
278
|
+
// A row this cannot read is a changed page, not a part with less
|
|
279
|
+
// data: without a part number it has no identity and without a
|
|
280
|
+
// link it has no geometry, and passing either on as an empty
|
|
281
|
+
// string reaches the network as a request for nothing.
|
|
282
|
+
if (row.partNumber === '' || row.productUrl === '') {
|
|
283
|
+
throw new VendorResponseError(row.productsId === '' ? 'a listing row' : `products_id ${row.productsId}`, `states no ${row.partNumber === '' ? 'Part# line' : 'product link'} — ` +
|
|
284
|
+
`the listing changed shape`);
|
|
285
|
+
}
|
|
286
|
+
rows.push(row);
|
|
287
|
+
row = null;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
},
|
|
291
|
+
}, { decodeEntities: true });
|
|
292
|
+
parser.write(html);
|
|
293
|
+
parser.end();
|
|
294
|
+
return { total: Number(total[1]), rows };
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Every part in one leaf category, paged until the vendor's own count is met.
|
|
298
|
+
*
|
|
299
|
+
* **The count is the gate, and it is the vendor's rather than ours.** A roster
|
|
300
|
+
* that stopped a page early is the failure this package is built to notice,
|
|
301
|
+
* and it cannot be noticed by counting the rows that were collected — that
|
|
302
|
+
* number agrees with itself. `(of N products)` is a second opinion the store
|
|
303
|
+
* computes from its own database, which is the check `regofix.search` gets
|
|
304
|
+
* free from `hits.total`.
|
|
305
|
+
*
|
|
306
|
+
* A page that adds no new row stops the walk rather than looping: a paging
|
|
307
|
+
* parameter the store has stopped honouring answers with page 1 forever.
|
|
308
|
+
*/
|
|
309
|
+
export async function roster(fetcher, cPath, options = {}) {
|
|
310
|
+
const { delayMs = REQUEST_DELAY_MS } = options;
|
|
311
|
+
const rows = [];
|
|
312
|
+
const seen = new Set();
|
|
313
|
+
let total = 0;
|
|
314
|
+
for (let page = 1; page === 1 || rows.length < total; page++) {
|
|
315
|
+
if (page > 1)
|
|
316
|
+
await pause(delayMs);
|
|
317
|
+
const listing = parseListing(await fetcher.text(categoryUrl(cPath, page)));
|
|
318
|
+
if (page === 1)
|
|
319
|
+
total = listing.total;
|
|
320
|
+
const before = rows.length;
|
|
321
|
+
for (const row of listing.rows) {
|
|
322
|
+
// Within one leaf a repeated id is the store serving a page twice, not
|
|
323
|
+
// the cross-leaf duplication `scrapeHolders` dedupes.
|
|
324
|
+
if (seen.has(row.productsId))
|
|
325
|
+
continue;
|
|
326
|
+
seen.add(row.productsId);
|
|
327
|
+
rows.push(row);
|
|
328
|
+
}
|
|
329
|
+
if (rows.length === before)
|
|
330
|
+
break;
|
|
331
|
+
}
|
|
332
|
+
if (rows.length !== total) {
|
|
333
|
+
throw new VendorResponseError(`c${cPath}`, `the listing says ${total} products and paging collected ${rows.length} — ` +
|
|
334
|
+
`a roster that lost rows agrees with every count computed from itself, ` +
|
|
335
|
+
`so this is the one that has to refuse`);
|
|
336
|
+
}
|
|
337
|
+
return rows;
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* One product page's `Product Specifications` table and downloads header.
|
|
341
|
+
*
|
|
342
|
+
* Both are found by their own `<div class="header">` rather than positionally,
|
|
343
|
+
* because a page carries several such blocks and which ones are present varies
|
|
344
|
+
* — 2 of the 529 parts in scope publish no spec table and roughly one in four
|
|
345
|
+
* publishes no downloads.
|
|
346
|
+
*
|
|
347
|
+
* An empty `specs` is a real state and is not an error here: the caller is
|
|
348
|
+
* what decides that a part with no geometry is skipped rather than written
|
|
349
|
+
* with holes.
|
|
350
|
+
*/
|
|
351
|
+
export function parseProduct(html) {
|
|
352
|
+
const specs = {};
|
|
353
|
+
let statedPartNumber = null;
|
|
354
|
+
/** Which `<div class="header">` block the parser is currently under. */
|
|
355
|
+
let section = '';
|
|
356
|
+
let inHeader = false;
|
|
357
|
+
let cells = null;
|
|
358
|
+
let text = '';
|
|
359
|
+
let capturing = false;
|
|
360
|
+
const parser = new Parser({
|
|
361
|
+
onopentag: (tag, attribs) => {
|
|
362
|
+
const classes = (attribs['class'] ?? '').split(/\s+/);
|
|
363
|
+
if (tag === 'div' && classes.includes('header')) {
|
|
364
|
+
inHeader = true;
|
|
365
|
+
capturing = true;
|
|
366
|
+
text = '';
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
if (section !== 'Product Specifications')
|
|
370
|
+
return;
|
|
371
|
+
if (tag === 'tr')
|
|
372
|
+
cells = [];
|
|
373
|
+
if (tag === 'td') {
|
|
374
|
+
capturing = true;
|
|
375
|
+
text = '';
|
|
376
|
+
}
|
|
377
|
+
},
|
|
378
|
+
ontext: (chunk) => {
|
|
379
|
+
if (capturing)
|
|
380
|
+
text += chunk;
|
|
381
|
+
},
|
|
382
|
+
onclosetag: (tag) => {
|
|
383
|
+
if (inHeader && tag === 'div') {
|
|
384
|
+
section = squash(text);
|
|
385
|
+
const stated = DOWNLOADS_HEADER.exec(section);
|
|
386
|
+
if (stated !== null)
|
|
387
|
+
statedPartNumber = stated[1] ?? null;
|
|
388
|
+
inHeader = false;
|
|
389
|
+
capturing = false;
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
if (section !== 'Product Specifications')
|
|
393
|
+
return;
|
|
394
|
+
if (tag === 'td' && cells !== null) {
|
|
395
|
+
// ` ` decodes to U+00A0, which `\s` matches but a naive trim
|
|
396
|
+
// does not — the label cell is `<b>Balance Spec: </b>`.
|
|
397
|
+
cells.push(squash(text.replace(/ /g, ' ')));
|
|
398
|
+
capturing = false;
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
if (tag === 'tr' && cells !== null) {
|
|
402
|
+
const [label, value] = cells;
|
|
403
|
+
if (label !== undefined && value !== undefined && label.endsWith(':')) {
|
|
404
|
+
specs[label.slice(0, -1).trim()] = value;
|
|
405
|
+
}
|
|
406
|
+
cells = null;
|
|
407
|
+
}
|
|
408
|
+
},
|
|
409
|
+
}, { decodeEntities: true });
|
|
410
|
+
parser.write(html);
|
|
411
|
+
parser.end();
|
|
412
|
+
return { specs, statedPartNumber };
|
|
413
|
+
}
|
|
414
|
+
/** One part's page, fetched through the seam every transport here uses. */
|
|
415
|
+
export async function fetchProduct(fetcher, url) {
|
|
416
|
+
return parseProduct(await fetcher.text(url));
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* A `Gage Length` cell as a number and the unit system it is stated in.
|
|
420
|
+
*
|
|
421
|
+
* **A bare number is inches.** That is the vendor's convention rather than
|
|
422
|
+
* this package guessing: MariTool marks every metric cell `mm` and marks
|
|
423
|
+
* nothing on an imperial one — 363 of the 473 in-scope cells sampled are bare
|
|
424
|
+
* or carry an inch mark, and a gage length of "3.0" millimetres is not a
|
|
425
|
+
* holder. The part number says nothing about it either way: `HSK40E-ER11-40`
|
|
426
|
+
* is millimetres and `HSK40E-ER16-3.0M` is inches, where that `M` is a mini
|
|
427
|
+
* nut.
|
|
428
|
+
*
|
|
429
|
+
* **The nose form is parsed off and given no column.** `120mm Tapered` is 120
|
|
430
|
+
* millimetres, and `Tapered` is not a data type — it is not lost, because the
|
|
431
|
+
* vendor states it in the product name too (*"BT40 ER11 120mm Tapered Nose
|
|
432
|
+
* Collet Chuck Tool Holder"*), and the row carries that name verbatim in
|
|
433
|
+
* `Description`. The raw cell stays in the CSV as well.
|
|
434
|
+
*
|
|
435
|
+
* Refused rather than returned as `NaN`, the rule `regofix.parseSize` holds: a
|
|
436
|
+
* cell this cannot read would otherwise travel into a row as a gage length.
|
|
437
|
+
*/
|
|
438
|
+
export function parseGageLength(cell) {
|
|
439
|
+
const parsed = GAGE_CELL.exec(squash(cell));
|
|
440
|
+
const value = Number(parsed?.[1]);
|
|
441
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
442
|
+
throw new RangeError(`unrecognized gage length: ${JSON.stringify(cell)}`);
|
|
443
|
+
}
|
|
444
|
+
const unit = (parsed?.[2] ?? '').toLowerCase() === 'mm' ? 'millimeters' : 'inches';
|
|
445
|
+
return { value, unit };
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* A `Collet Size` cell as the series `CST` joins a collet family on.
|
|
449
|
+
*
|
|
450
|
+
* Only the spacing is closed. MariTool publishes `ER 11` and `ER11` within one
|
|
451
|
+
* style, and two spellings of one series join to nothing — `CST` is the key
|
|
452
|
+
* `families/kennametal.ts` states the holder-to-collet join against, and the
|
|
453
|
+
* collet side of it spells the series without a space.
|
|
454
|
+
*
|
|
455
|
+
* Nothing else is normalised. `ER25M` is written through as designated even
|
|
456
|
+
* though no such collet series exists, because the alternative is to decide on
|
|
457
|
+
* the vendor's behalf that its `M` is the mini nut its `Collet Nut` column
|
|
458
|
+
* carries elsewhere — see this module's docstring. {@link holderRow} warns.
|
|
459
|
+
*/
|
|
460
|
+
export function colletSeries(cell) {
|
|
461
|
+
return cell.replace(/\s+/g, '');
|
|
462
|
+
}
|
|
463
|
+
/**
|
|
464
|
+
* One listing row plus its spec table -> one CSV row.
|
|
465
|
+
*
|
|
466
|
+
* The vendor's own labels are carried verbatim and in the order the page
|
|
467
|
+
* states them; what this adds in front of them is the four axes the catalog
|
|
468
|
+
* asks a holder about — `taper`, `contact`, `clamping`, `style` — plus the
|
|
469
|
+
* collet series and the promoted gage length.
|
|
470
|
+
*
|
|
471
|
+
* **No bore column.** A collet chuck grips through a collet and a shrink-fit
|
|
472
|
+
* holder's bore is stated under MariTool's own `Shank Size` label; promoting
|
|
473
|
+
* either to `D1` would make a collet-clamping holder claim a clamping capacity
|
|
474
|
+
* of its own, which is the reason `regofix.holderRow` has no `D1` either. For
|
|
475
|
+
* the same reason `Collet Grip Range` is carried verbatim and never becomes
|
|
476
|
+
* `CCCN`/`CCCX`: it is the ER series' range restated on the holder's page, a
|
|
477
|
+
* pure function of `Collet Size` across every part sampled, and a real one
|
|
478
|
+
* comes from a collet family joined on `CST`.
|
|
479
|
+
*/
|
|
480
|
+
export function holderRow(leaf, listing, specs, warn = consoleWarn) {
|
|
481
|
+
const where = `${listing.partNumber} (${listing.name})`;
|
|
482
|
+
const row = {
|
|
483
|
+
[MATERIAL_COLUMN]: listing.partNumber,
|
|
484
|
+
[STORE_ID_COLUMN]: listing.productsId,
|
|
485
|
+
[DESCRIPTION_COLUMN]: listing.name,
|
|
486
|
+
[TAPER_COLUMN]: '',
|
|
487
|
+
[CONTACT_COLUMN]: '',
|
|
488
|
+
[CLAMPING_COLUMN]: leaf.clamping,
|
|
489
|
+
[STYLE_COLUMN]: leaf.style,
|
|
490
|
+
};
|
|
491
|
+
const stated = squash(specs[TAPER_LABEL] ?? '');
|
|
492
|
+
if (stated === '') {
|
|
493
|
+
// One part in the catalog, and its row is kept rather than dropped: the
|
|
494
|
+
// hole is what the vendor published, and a taper inferred from the part
|
|
495
|
+
// number would be this package authoring the one column it exists to read.
|
|
496
|
+
warn(` WARNING: ${where}: its spec table states no Taper — the columns are left empty`);
|
|
497
|
+
}
|
|
498
|
+
else {
|
|
499
|
+
const form = TAPER_FORMS[stated.toUpperCase()];
|
|
500
|
+
if (form === undefined) {
|
|
501
|
+
throw new VendorResponseError(where, `Taper ${JSON.stringify(stated)} is not a spindle interface this ` +
|
|
502
|
+
`package knows — add it to TAPER_FORMS once it is clear which ` +
|
|
503
|
+
`machine it fits`);
|
|
504
|
+
}
|
|
505
|
+
row[TAPER_COLUMN] = form.taper;
|
|
506
|
+
row[CONTACT_COLUMN] = form.contact;
|
|
507
|
+
}
|
|
508
|
+
const size = specs[COLLET_SIZE_LABEL];
|
|
509
|
+
if (size !== undefined && size !== '') {
|
|
510
|
+
const series = colletSeries(size);
|
|
511
|
+
row[COLLET_SERIES_COLUMN] = series;
|
|
512
|
+
if (!COLLET_SERIES.test(series)) {
|
|
513
|
+
warn(` WARNING: ${where}: Collet Size ${JSON.stringify(size)} is not a ` +
|
|
514
|
+
`collet series — it is written into CST as designated and joins to ` +
|
|
515
|
+
`no collet`);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
row[GAGE_COLUMNS.inches] = '';
|
|
519
|
+
row[GAGE_COLUMNS.millimeters] = '';
|
|
520
|
+
const gage = specs[GAGE_LABEL];
|
|
521
|
+
if (gage !== undefined && gage !== '') {
|
|
522
|
+
const { value, unit } = parseGageLength(gage);
|
|
523
|
+
row[GAGE_COLUMNS[unit]] = String(value);
|
|
524
|
+
}
|
|
525
|
+
for (const [label, value] of Object.entries(specs)) {
|
|
526
|
+
// A vendor label that lands on a column built above would silently replace
|
|
527
|
+
// it — a `Description` spec row would overwrite the product name, and the
|
|
528
|
+
// row would still look complete. None collides today; this is what says so
|
|
529
|
+
// if one starts to.
|
|
530
|
+
if (Object.hasOwn(row, label)) {
|
|
531
|
+
warn(` WARNING: ${where}: the spec table publishes a ${JSON.stringify(label)} ` +
|
|
532
|
+
`row, which is already a column this scraper builds — the vendor's ` +
|
|
533
|
+
`value is dropped`);
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
536
|
+
row[label] = value;
|
|
537
|
+
}
|
|
538
|
+
row[CAD_COLUMN] = listing.assets['STP'] ?? '';
|
|
539
|
+
row[CAD_DXF_COLUMN] = listing.assets['DXF'] ?? '';
|
|
540
|
+
return row;
|
|
541
|
+
}
|
|
542
|
+
/**
|
|
543
|
+
* Every in-scope holder under `leaves`, one CSV's worth.
|
|
544
|
+
*
|
|
545
|
+
* One request per leaf page for the roster, then one per part for its
|
|
546
|
+
* geometry, paced by the package's shared politeness delay throughout.
|
|
547
|
+
*
|
|
548
|
+
* **Deduped by `products_id` across leaves.** MariTool lists a handful of
|
|
549
|
+
* parts under two leaves, and a part is one row however many places the store
|
|
550
|
+
* files it. The first leaf to reach it wins, which is what makes the row
|
|
551
|
+
* order below independent of which duplicate was found.
|
|
552
|
+
*/
|
|
553
|
+
export async function scrapeHolders(fetcher, leaves, options = {}) {
|
|
554
|
+
const { warn = consoleWarn, delayMs = REQUEST_DELAY_MS } = options;
|
|
555
|
+
const rows = [];
|
|
556
|
+
const seen = new Set();
|
|
557
|
+
for (const leaf of leaves) {
|
|
558
|
+
for (const listing of await roster(fetcher, leaf.cPath, options)) {
|
|
559
|
+
if (seen.has(listing.productsId)) {
|
|
560
|
+
warn(` DUPLICATE ${listing.partNumber}: also listed under c${leaf.cPath} — ` +
|
|
561
|
+
`the first leaf it was found in is the one carried`);
|
|
562
|
+
continue;
|
|
563
|
+
}
|
|
564
|
+
seen.add(listing.productsId);
|
|
565
|
+
await pause(delayMs);
|
|
566
|
+
const { specs, statedPartNumber } = await fetchProduct(fetcher, listing.productUrl);
|
|
567
|
+
if (Object.keys(specs).length === 0) {
|
|
568
|
+
warn(` SKIPPED ${listing.partNumber} (${listing.name}): the vendor ` +
|
|
569
|
+
`publishes no Product Specifications table, so it has no geometry`);
|
|
570
|
+
continue;
|
|
571
|
+
}
|
|
572
|
+
if (statedPartNumber !== null && statedPartNumber !== listing.partNumber) {
|
|
573
|
+
warn(` WARNING: ${listing.partNumber}: its downloads header calls it ` +
|
|
574
|
+
`${statedPartNumber} — the listing's part number is used`);
|
|
575
|
+
}
|
|
576
|
+
rows.push(holderRow(leaf, listing, specs, warn));
|
|
577
|
+
}
|
|
578
|
+
await pause(delayMs);
|
|
579
|
+
}
|
|
580
|
+
if (rows.length === 0) {
|
|
581
|
+
throw new VendorResponseError(BASE, 'the scrape produced no rows');
|
|
582
|
+
}
|
|
583
|
+
rows.sort((a, b) => compare(a[MATERIAL_COLUMN] ?? '', b[MATERIAL_COLUMN] ?? ''));
|
|
584
|
+
return {
|
|
585
|
+
header: unionHeader(rows),
|
|
586
|
+
rows,
|
|
587
|
+
source: categoryUrl(leaves[0]?.cPath ?? ''),
|
|
588
|
+
// MariTool's cPath is its own code for a category, and a family is scraped
|
|
589
|
+
// from several — so the receipt records all of them, space-separated, and
|
|
590
|
+
// `source` is the request to re-issue first.
|
|
591
|
+
familyCode: leaves.map((leaf) => leaf.cPath).join(' '),
|
|
592
|
+
};
|
|
593
|
+
}
|
|
@@ -55,7 +55,6 @@
|
|
|
55
55
|
*/
|
|
56
56
|
import { type Fetcher } from '../../fetch.js';
|
|
57
57
|
import { type ScrapeResult, type ScrapedRow, type Warn } from '../../scrape.js';
|
|
58
|
-
export declare const MM_PER_INCH = 25.4;
|
|
59
58
|
/**
|
|
60
59
|
* The Searchkit proxy the ProductFinder posts its Elasticsearch queries to.
|
|
61
60
|
* Discovered by reading the app bundle, which constructs
|
|
@@ -158,7 +157,14 @@ export declare function fetchDin4000(fetcher: Fetcher, sku: string): Promise<Rec
|
|
|
158
157
|
* designation says.
|
|
159
158
|
*/
|
|
160
159
|
export declare function plain(value: string | number | null | undefined): string;
|
|
161
|
-
/**
|
|
160
|
+
/**
|
|
161
|
+
* `1/4` or `3.5` — the vendor prints both, and both are exact here.
|
|
162
|
+
*
|
|
163
|
+
* The grammar is `measure.fractionValue`'s; what stays here is the refusal.
|
|
164
|
+
* `3/` divides by zero and an absent regex capture arrives as `''`, and both
|
|
165
|
+
* would otherwise travel into a row as a nominal size — as would `0`, which
|
|
166
|
+
* reads as a number and is not a collet this vendor makes.
|
|
167
|
+
*/
|
|
162
168
|
export declare function parseSize(size: string): number;
|
|
163
169
|
/**
|
|
164
170
|
* The part's STEP model, absolute, or empty when none is published.
|
|
@@ -204,15 +210,6 @@ export declare function holderRow(source: Source, properties: Record<string, str
|
|
|
204
210
|
* Kennametal's contradictory unit cells are kept: it is what the vendor said.
|
|
205
211
|
*/
|
|
206
212
|
export declare function colletRow(source: Source, warn?: Warn): ScrapedRow;
|
|
207
|
-
/**
|
|
208
|
-
* Rows to a result whose header is the union of their keys, in first-seen
|
|
209
|
-
* order.
|
|
210
|
-
*
|
|
211
|
-
* A union rather than the first row's keys: a mixed-unit collet family has
|
|
212
|
-
* `D1_mm` on its metric rows and `D1_in` on its inch ones, and keying off row
|
|
213
|
-
* one would drop whichever came second.
|
|
214
|
-
*/
|
|
215
|
-
export declare function unionHeader(rows: readonly ScrapedRow[]): string[];
|
|
216
213
|
/** Options every REGO-FIX scrape accepts. */
|
|
217
214
|
export interface RegofixOptions {
|
|
218
215
|
warn?: Warn;
|