@toolpath/tool-scraper 2.4.0 → 2.5.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 +1 -0
- package/dist/families/kennametal.d.ts +276 -2
- package/dist/families/kennametal.js +184 -22
- package/dist/family.d.ts +12 -0
- package/dist/holding.d.ts +61 -3
- package/dist/holding.js +42 -1
- package/dist/node/cli.js +46 -1
- package/dist/vendors/kennametal/catalog.d.ts +168 -0
- package/dist/vendors/kennametal/catalog.js +247 -0
- package/dist/vendors/kennametal/holding.js +50 -4
- package/dist/vendors/kennametal/index.d.ts +1 -0
- package/dist/vendors/kennametal/index.js +1 -0
- package/package.json +2 -2
package/dist/holding.js
CHANGED
|
@@ -163,6 +163,29 @@ function halfUlp(raw) {
|
|
|
163
163
|
const fraction = raw.includes('.') ? raw.slice(raw.indexOf('.') + 1) : '';
|
|
164
164
|
return 0.5 * 10 ** -fraction.length;
|
|
165
165
|
}
|
|
166
|
+
/**
|
|
167
|
+
* How far a collet's *designation* may sit from its measured capacity, as a
|
|
168
|
+
* fraction of that capacity.
|
|
169
|
+
*
|
|
170
|
+
* {@link ColletRecord.nominal} is the size the vendor **designates** the collet
|
|
171
|
+
* by; {@link ColletRecord.clampMin}/{@link ColletRecord.clampMax} is what it
|
|
172
|
+
* measures. They are not two readings of one number, and on a sealed collet
|
|
173
|
+
* they are routinely different: Kennametal's `40ERSS1000` is designated 1 inch
|
|
174
|
+
* and clamps 0.9938, `40ERSS0500` is designated 1/2 and clamps 0.4943. Both
|
|
175
|
+
* unit columns agree on both figures — 25.4 against 25.243 mm, 12.7 against
|
|
176
|
+
* 12.556 — so this is the vendor stating an undersized capacity for a collet it
|
|
177
|
+
* names by the fraction, not a cell in the wrong column.
|
|
178
|
+
*
|
|
179
|
+
* **Relative rather than absolute, because the gap scales with the collet.**
|
|
180
|
+
* Across the 443-part collet corpus fourteen rows sit outside their own band.
|
|
181
|
+
* The widest is `40ERSS0812` at 0.193 mm, which is 0.94 % of its 20.4 mm
|
|
182
|
+
* capacity; the widest by fraction is `40ERSS0500` at 1.14 %. The error this
|
|
183
|
+
* must still refuse is a cell in the wrong unit system, which is 96 % out — the
|
|
184
|
+
* shape `16ERSS0312` has, where `D1`'s metric cell holds the inch value. This
|
|
185
|
+
* sits 4.4x above the first and 19x below the second. A row that lands in
|
|
186
|
+
* between is a finding to investigate rather than a number to widen.
|
|
187
|
+
*/
|
|
188
|
+
export const NOMINAL_SLACK = 0.05;
|
|
166
189
|
/**
|
|
167
190
|
* Report where a vendor's own millimetre and inch cells disagree.
|
|
168
191
|
*
|
|
@@ -298,11 +321,25 @@ export function checkCollet(record) {
|
|
|
298
321
|
// In the native unit, which is the gate with teeth: these are the values a
|
|
299
322
|
// consumer compares, and the contradictory cells this catalog knows about all
|
|
300
323
|
// sit in the column `dim` ignores.
|
|
324
|
+
//
|
|
325
|
+
// The slack is {@link NOMINAL_SLACK}, because a designation is not a
|
|
326
|
+
// measurement — see that constant for the fourteen rows it exists for and the
|
|
327
|
+
// margin either side of it. Unit-free, so it needs no conversion.
|
|
301
328
|
if (record.nominal !== null &&
|
|
302
|
-
(record.nominal < record.clampMin
|
|
329
|
+
(record.nominal < record.clampMin * (1 - NOMINAL_SLACK) ||
|
|
330
|
+
record.nominal > record.clampMax * (1 + NOMINAL_SLACK))) {
|
|
303
331
|
throw new VendorResponseError(what, `nominal ${record.nominal} is outside its own capacity ` +
|
|
304
332
|
`${record.clampMin}-${record.clampMax}`);
|
|
305
333
|
}
|
|
334
|
+
// A tap's square across flats is inscribed in its shank, so it is smaller
|
|
335
|
+
// than the bore that takes it — true on all 96 rows of both Kennametal tap
|
|
336
|
+
// families. A square at or past the clamping diameter is the inch cell in the
|
|
337
|
+
// metric column or two labels swapped, which is the failure `dim`'s native
|
|
338
|
+
// read cannot see and `checkUnitAgreement` only reports.
|
|
339
|
+
if (record.squareSize !== null && record.squareSize >= record.clampMax) {
|
|
340
|
+
throw new VendorResponseError(what, `square size ${record.squareSize} is not smaller than the ` +
|
|
341
|
+
`${record.clampMax} it clamps — a square is inscribed in the shank`);
|
|
342
|
+
}
|
|
306
343
|
}
|
|
307
344
|
/**
|
|
308
345
|
* Build a {@link HolderRecord}: mint its guid, derive its millimetre twins, and
|
|
@@ -354,6 +391,10 @@ export function colletRecord(fields) {
|
|
|
354
391
|
bodyDiameter: fields.bodyDiameter ?? null,
|
|
355
392
|
functionalLength: fields.functionalLength ?? null,
|
|
356
393
|
overallLength: fields.overallLength ?? null,
|
|
394
|
+
clampingLength: fields.clampingLength ?? null,
|
|
395
|
+
clampingLengthMm: millimeters(fields.clampingLength ?? null, fields.unit),
|
|
396
|
+
tapRange: fields.tapRange ?? null,
|
|
397
|
+
squareSize: fields.squareSize ?? null,
|
|
357
398
|
});
|
|
358
399
|
checkCollet(record);
|
|
359
400
|
return record;
|
package/dist/node/cli.js
CHANGED
|
@@ -32,7 +32,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
|
32
32
|
import { basename, dirname, join } from 'node:path';
|
|
33
33
|
import { ScraperConfigError, VendorResponseError } from '../errors.js';
|
|
34
34
|
import { familyBrand } from '../family.js';
|
|
35
|
-
import { ALL_FAMILIES, FAMILIES, HOLDER_FAMILIES, familyConfig } from '../families/index.js';
|
|
35
|
+
import { ALL_FAMILIES, COLLET_FAMILIES, FAMILIES, HOLDER_FAMILIES, familyConfig, } from '../families/index.js';
|
|
36
36
|
import { createFetcher } from '../fetch.js';
|
|
37
37
|
import { buildProfiles } from '../profiles.js';
|
|
38
38
|
import { AEM_BRANDS } from '../identity.js';
|
|
@@ -45,6 +45,7 @@ import { describeRoot, familyCsv, profilesDir, profilesJson, stepDir } from './p
|
|
|
45
45
|
import * as receipts from './receipts.js';
|
|
46
46
|
import { scrapeFamily } from '../vendors/kennametal/scrape.js';
|
|
47
47
|
import { annotateCadUrls } from '../vendors/kennametal/cad.js';
|
|
48
|
+
import { COLLET_CATEGORIES, describeFamily, discoverFamilies, } from '../vendors/kennametal/catalog.js';
|
|
48
49
|
import { addMaterialGroups, groupsByMaterial } from '../vendors/kennametal/materials.js';
|
|
49
50
|
import { addThreadPitch } from '../vendors/kennametal/thread-column.js';
|
|
50
51
|
import { scrapeEndMills, DOCUMENTS_URL } from '../vendors/destinytool/scrape.js';
|
|
@@ -95,6 +96,12 @@ const USAGE = `usage: toolpath-scrape <command> [args]
|
|
|
95
96
|
every row as constant columns, for facts the vendor table does not state
|
|
96
97
|
(e.g. "Thread System=metric").
|
|
97
98
|
|
|
99
|
+
kennametal --collets
|
|
100
|
+
Walks the three ER collet category trees and prints every family they
|
|
101
|
+
link to today — code, slug, category, and the configured CSV that claims
|
|
102
|
+
the code or "(not configured)". For noticing a family Kennametal has
|
|
103
|
+
added, split or retired; a scrape needs none of it.
|
|
104
|
+
|
|
98
105
|
regofix holders OUT.csv
|
|
99
106
|
regofix collets "<PRODUCT GROUP>" OUT.csv
|
|
100
107
|
The REGO-FIX ProductFinder index. \`holders\` takes every powRgrip BT/PG
|
|
@@ -265,6 +272,22 @@ export async function run(argv, io = STDOUT, fetcher = createFetcher(), api) {
|
|
|
265
272
|
return 2;
|
|
266
273
|
}
|
|
267
274
|
}
|
|
275
|
+
/**
|
|
276
|
+
* The configured CSV that claims each `familyCode`, for the walk to reconcile
|
|
277
|
+
* against.
|
|
278
|
+
*
|
|
279
|
+
* Toolholding only. A cutting-tool family states a code too, but the walk this
|
|
280
|
+
* serves covers the collet categories and reporting a drill family's code
|
|
281
|
+
* against a collet listing would be an answer to a question nobody asked.
|
|
282
|
+
*/
|
|
283
|
+
function claimedCodes() {
|
|
284
|
+
const claimed = new Map();
|
|
285
|
+
for (const [name, cfg] of Object.entries({ ...HOLDER_FAMILIES, ...COLLET_FAMILIES })) {
|
|
286
|
+
if (cfg.familyCode !== undefined)
|
|
287
|
+
claimed.set(cfg.familyCode, name);
|
|
288
|
+
}
|
|
289
|
+
return claimed;
|
|
290
|
+
}
|
|
268
291
|
async function kennametal(argv, io, fetcher) {
|
|
269
292
|
const args = [...argv];
|
|
270
293
|
let brand = 'kennametal';
|
|
@@ -285,6 +308,28 @@ async function kennametal(argv, io, fetcher) {
|
|
|
285
308
|
io.error(`unknown brand: ${brand} (known: ${[...AEM_BRANDS].sort().join(', ')})`);
|
|
286
309
|
return 2;
|
|
287
310
|
}
|
|
311
|
+
if (args[0] === '--collets') {
|
|
312
|
+
const claimed = claimedCodes();
|
|
313
|
+
const found = await discoverFamilies(fetcher, COLLET_CATEGORIES, {
|
|
314
|
+
warn: io.error,
|
|
315
|
+
brand: brand,
|
|
316
|
+
});
|
|
317
|
+
let families = 0;
|
|
318
|
+
let missing = 0;
|
|
319
|
+
for (const category of found) {
|
|
320
|
+
io.log(`${category.name}: ${category.total} parts, ${category.families.length} families`);
|
|
321
|
+
for (const family of category.families) {
|
|
322
|
+
const where = claimed.get(family.code) ?? null;
|
|
323
|
+
if (where === null)
|
|
324
|
+
missing += 1;
|
|
325
|
+
families += 1;
|
|
326
|
+
io.log(` ${describeFamily(category, family, where)}`);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
io.log(`${families} families under ${COLLET_CATEGORIES.length} category trees, ` +
|
|
330
|
+
`${missing} not configured`);
|
|
331
|
+
return 0;
|
|
332
|
+
}
|
|
288
333
|
if (args.length < 2) {
|
|
289
334
|
io.error(USAGE);
|
|
290
335
|
return 2;
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The category walk — what Kennametal publishes under a facet, family by family.
|
|
3
|
+
*
|
|
4
|
+
* `scrape.ts` fetches a family once its **code** is known, and until now the
|
|
5
|
+
* only way to learn a code was to open a category page in a browser and read it
|
|
6
|
+
* out of a link. That is why `families/kennametal.ts` carried collet families
|
|
7
|
+
* with no `familyCode` at all: nobody had written the codes down, because there
|
|
8
|
+
* was nothing to write them down from.
|
|
9
|
+
*
|
|
10
|
+
* **Nothing in a scrape uses this.** `families/kennametal.ts` names every family
|
|
11
|
+
* in scope and a scrape fetches exactly those; re-deriving the list at scrape
|
|
12
|
+
* time would put a crawl in front of every run to rediscover something already
|
|
13
|
+
* recorded. It is here for the maintenance question that table cannot answer —
|
|
14
|
+
* *has Kennametal added, split or retired a family?* — and the CLI exposes it as
|
|
15
|
+
* `kennametal --collets`, which reconciles the walk against the recorded codes.
|
|
16
|
+
*
|
|
17
|
+
* ## The endpoint, and how it was found
|
|
18
|
+
*
|
|
19
|
+
* A category page renders its results client-side, so the HTML a browser is
|
|
20
|
+
* served carries facet chrome and no products. The component behind it is a
|
|
21
|
+
* plain AEM GET, read out of the page's own `product-listing.min.js`:
|
|
22
|
+
*
|
|
23
|
+
* ```js
|
|
24
|
+
* url: a + "." + c + ".html?" + h + "query=" + d + "&sort=" + e + "&pageSize=" + f
|
|
25
|
+
* ```
|
|
26
|
+
*
|
|
27
|
+
* where `a` is the component path, `c` is a **page index** and `d` is the same
|
|
28
|
+
* facet string the browser's address bar carries. Three things about it cost
|
|
29
|
+
* time to find and are recorded in `docs/KENNAMETAL_PRODUCT_LISTING.md`:
|
|
30
|
+
*
|
|
31
|
+
* - The public `/us/en/...` path works. The authoring `/content/kennametal/...`
|
|
32
|
+
* path the markup's own `data-path` shows answers 307.
|
|
33
|
+
* - A non-numeric selector (`.results.`, `.products.`) returns the site's 404
|
|
34
|
+
* page **with a 404 status**, so a wrong guess fails rather than parsing to
|
|
35
|
+
* nothing.
|
|
36
|
+
* - `pageSize` counts **families**, not parts. At `pageSize=2` the seven-family
|
|
37
|
+
* coolant-through leaf pages two at a time, which is why {@link colletListingPages}
|
|
38
|
+
* pages at all rather than asking once and trusting it.
|
|
39
|
+
*
|
|
40
|
+
* ## The tree is two shapes, not one
|
|
41
|
+
*
|
|
42
|
+
* A category above the leaves returns **category tiles** — facet links, each
|
|
43
|
+
* extending its parent's query by one `allCategoriesKMT` id — and no families.
|
|
44
|
+
* A leaf returns family links and no tiles. The tap-collet category is a leaf at
|
|
45
|
+
* depth 0 and the standard-collet one is not, so a walk that assumed either
|
|
46
|
+
* shape would find half the catalog.
|
|
47
|
+
*/
|
|
48
|
+
import type { Fetcher } from '../../fetch.js';
|
|
49
|
+
import { type AemBrandName } from '../../identity.js';
|
|
50
|
+
import { type Warn } from '../../scrape.js';
|
|
51
|
+
/**
|
|
52
|
+
* The product-listing component, per category page.
|
|
53
|
+
*
|
|
54
|
+
* A different resource from `scrape.BASE`: that one is the variant table on the
|
|
55
|
+
* shared `products/fam` page, this one hangs off the category's own path. The
|
|
56
|
+
* component node is `product_listing` on both AEM brands.
|
|
57
|
+
*/
|
|
58
|
+
export declare const LISTING_BASE: string;
|
|
59
|
+
/** Where the collet walk starts: the category path, and the three facet queries. */
|
|
60
|
+
export declare const COLLET_CATEGORY = "metalworking-tools/tool-holders-and-adapters/collets-and-sleeves";
|
|
61
|
+
/**
|
|
62
|
+
* The three collet lines this package covers, as the vendor's own facets.
|
|
63
|
+
*
|
|
64
|
+
* Read off the category listing on 2026-09-08. Each is the query string the
|
|
65
|
+
* browser's address bar carries for that line, with `obsoleteFacet:false` — the
|
|
66
|
+
* same active-only scoping `scrape.ACTIVE_ONLY` applies to a family — and the
|
|
67
|
+
* `allCategoriesKMT` chain that names Collets and Sleeves (`42034262`), ER
|
|
68
|
+
* Collets (`42866150`) and then the line.
|
|
69
|
+
*
|
|
70
|
+
* Kennametal's collet catalog is larger: `42034262` also holds sleeves, DA and
|
|
71
|
+
* TG collets and shrink sleeves. These are the three lines asked for, and
|
|
72
|
+
* adding a fourth is a decision rather than a widening of a pattern.
|
|
73
|
+
*/
|
|
74
|
+
export declare const COLLET_CATEGORIES: readonly {
|
|
75
|
+
readonly name: string;
|
|
76
|
+
readonly query: string;
|
|
77
|
+
}[];
|
|
78
|
+
/**
|
|
79
|
+
* How many families a listing page returns.
|
|
80
|
+
*
|
|
81
|
+
* Sixty because that is the largest the vendor's own control offers and every
|
|
82
|
+
* line in scope fits one page at it — so the walk is one request per node in
|
|
83
|
+
* practice, and still correct if a line outgrows it.
|
|
84
|
+
*/
|
|
85
|
+
export declare const LISTING_PAGE_SIZE = 60;
|
|
86
|
+
/** One family a leaf category links to. */
|
|
87
|
+
export interface FamilyLink {
|
|
88
|
+
/** The vendor's numeric family code — what `scrapeFamily` takes. */
|
|
89
|
+
readonly code: string;
|
|
90
|
+
/** The slug in the same link, which is the vendor's own name for the family. */
|
|
91
|
+
readonly slug: string;
|
|
92
|
+
}
|
|
93
|
+
/** One subcategory a listing page offers, as a facet rather than a path. */
|
|
94
|
+
export interface CategoryTile {
|
|
95
|
+
/** The parent's query with one more `allCategoriesKMT` id on the end. */
|
|
96
|
+
readonly query: string;
|
|
97
|
+
/** The vendor's own name for it, off the tile's `data-title`. */
|
|
98
|
+
readonly name: string;
|
|
99
|
+
/** What the tile's `(117)` said. */
|
|
100
|
+
readonly count: number;
|
|
101
|
+
}
|
|
102
|
+
/** What one listing response holds. */
|
|
103
|
+
export interface ColletListing {
|
|
104
|
+
/** `data-totalResults`, or 0 where the response states none. */
|
|
105
|
+
readonly total: number;
|
|
106
|
+
readonly tiles: readonly CategoryTile[];
|
|
107
|
+
readonly families: readonly FamilyLink[];
|
|
108
|
+
}
|
|
109
|
+
/** One page of one category's listing. */
|
|
110
|
+
export declare function listingUrl(query: string, page: number, brand?: AemBrandName, category?: string, size?: number): string;
|
|
111
|
+
/**
|
|
112
|
+
* One listing response: its tiles, its family links and its stated total.
|
|
113
|
+
*
|
|
114
|
+
* The tile's three facts arrive in three places — the query on the anchor, the
|
|
115
|
+
* count in a `<span class="count">` inside it, the name in a `data-title` on a
|
|
116
|
+
* `<div>` inside it — so this tracks the open anchor rather than matching a
|
|
117
|
+
* shape. A page's facet sidebar carries hundreds of other `data-query` anchors;
|
|
118
|
+
* `horizontal-facet` is the class the tiles alone wear, and it is what
|
|
119
|
+
* separates a subcategory from a filter checkbox.
|
|
120
|
+
*/
|
|
121
|
+
export declare function parseColletListing(html: string): ColletListing;
|
|
122
|
+
/**
|
|
123
|
+
* Every page of one collet category, until a page adds no family it had not seen.
|
|
124
|
+
*
|
|
125
|
+
* The vendor states no page count anywhere, and `data-totalResults` counts
|
|
126
|
+
* *parts* while `pageSize` counts *families*, so the two cannot be divided into
|
|
127
|
+
* a page budget. What ends the walk is the page itself: an index past the last
|
|
128
|
+
* one returns the facet chrome with no families in it.
|
|
129
|
+
*
|
|
130
|
+
* Tiles come from the first page only. A category with subcategories publishes
|
|
131
|
+
* them on every page and recursing on a duplicate would re-walk the branch.
|
|
132
|
+
*/
|
|
133
|
+
export declare function colletListingPages(fetcher: Fetcher, query: string, options?: {
|
|
134
|
+
brand?: AemBrandName;
|
|
135
|
+
category?: string;
|
|
136
|
+
delayMs?: number;
|
|
137
|
+
}): Promise<ColletListing>;
|
|
138
|
+
/** One node of the walk: the facet that was asked, and what came back. */
|
|
139
|
+
export interface DiscoveredCategory {
|
|
140
|
+
readonly name: string;
|
|
141
|
+
readonly query: string;
|
|
142
|
+
/** `data-totalResults` — how many parts the vendor counts under this facet. */
|
|
143
|
+
readonly total: number;
|
|
144
|
+
readonly families: readonly FamilyLink[];
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Every family reachable from `roots`, depth first and paced.
|
|
148
|
+
*
|
|
149
|
+
* A tile's query has to **extend** its parent's, which is the same prefix test
|
|
150
|
+
* `vendors/maritool/catalog.ts` applies to a cPath and for the same reason: a
|
|
151
|
+
* listing page renders unrelated facets beside its own subcategories, so taking
|
|
152
|
+
* every `horizontal-facet` anchor would walk out of the branch that was asked
|
|
153
|
+
* for and into the whole catalog.
|
|
154
|
+
*
|
|
155
|
+
* Sequential and paced by the package's shared politeness delay. The three
|
|
156
|
+
* collet lines reach seven nodes, so this is a handful of requests run by hand.
|
|
157
|
+
*/
|
|
158
|
+
export declare function discoverFamilies(fetcher: Fetcher, roots?: readonly {
|
|
159
|
+
name: string;
|
|
160
|
+
query: string;
|
|
161
|
+
}[], options?: {
|
|
162
|
+
warn?: Warn;
|
|
163
|
+
brand?: AemBrandName;
|
|
164
|
+
category?: string;
|
|
165
|
+
delayMs?: number;
|
|
166
|
+
}): Promise<DiscoveredCategory[]>;
|
|
167
|
+
/** One line per family, for a human reading a `--collets` run. */
|
|
168
|
+
export declare function describeFamily(category: DiscoveredCategory, family: FamilyLink, configured: string | null): string;
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The category walk — what Kennametal publishes under a facet, family by family.
|
|
3
|
+
*
|
|
4
|
+
* `scrape.ts` fetches a family once its **code** is known, and until now the
|
|
5
|
+
* only way to learn a code was to open a category page in a browser and read it
|
|
6
|
+
* out of a link. That is why `families/kennametal.ts` carried collet families
|
|
7
|
+
* with no `familyCode` at all: nobody had written the codes down, because there
|
|
8
|
+
* was nothing to write them down from.
|
|
9
|
+
*
|
|
10
|
+
* **Nothing in a scrape uses this.** `families/kennametal.ts` names every family
|
|
11
|
+
* in scope and a scrape fetches exactly those; re-deriving the list at scrape
|
|
12
|
+
* time would put a crawl in front of every run to rediscover something already
|
|
13
|
+
* recorded. It is here for the maintenance question that table cannot answer —
|
|
14
|
+
* *has Kennametal added, split or retired a family?* — and the CLI exposes it as
|
|
15
|
+
* `kennametal --collets`, which reconciles the walk against the recorded codes.
|
|
16
|
+
*
|
|
17
|
+
* ## The endpoint, and how it was found
|
|
18
|
+
*
|
|
19
|
+
* A category page renders its results client-side, so the HTML a browser is
|
|
20
|
+
* served carries facet chrome and no products. The component behind it is a
|
|
21
|
+
* plain AEM GET, read out of the page's own `product-listing.min.js`:
|
|
22
|
+
*
|
|
23
|
+
* ```js
|
|
24
|
+
* url: a + "." + c + ".html?" + h + "query=" + d + "&sort=" + e + "&pageSize=" + f
|
|
25
|
+
* ```
|
|
26
|
+
*
|
|
27
|
+
* where `a` is the component path, `c` is a **page index** and `d` is the same
|
|
28
|
+
* facet string the browser's address bar carries. Three things about it cost
|
|
29
|
+
* time to find and are recorded in `docs/KENNAMETAL_PRODUCT_LISTING.md`:
|
|
30
|
+
*
|
|
31
|
+
* - The public `/us/en/...` path works. The authoring `/content/kennametal/...`
|
|
32
|
+
* path the markup's own `data-path` shows answers 307.
|
|
33
|
+
* - A non-numeric selector (`.results.`, `.products.`) returns the site's 404
|
|
34
|
+
* page **with a 404 status**, so a wrong guess fails rather than parsing to
|
|
35
|
+
* nothing.
|
|
36
|
+
* - `pageSize` counts **families**, not parts. At `pageSize=2` the seven-family
|
|
37
|
+
* coolant-through leaf pages two at a time, which is why {@link colletListingPages}
|
|
38
|
+
* pages at all rather than asking once and trusting it.
|
|
39
|
+
*
|
|
40
|
+
* ## The tree is two shapes, not one
|
|
41
|
+
*
|
|
42
|
+
* A category above the leaves returns **category tiles** — facet links, each
|
|
43
|
+
* extending its parent's query by one `allCategoriesKMT` id — and no families.
|
|
44
|
+
* A leaf returns family links and no tiles. The tap-collet category is a leaf at
|
|
45
|
+
* depth 0 and the standard-collet one is not, so a walk that assumed either
|
|
46
|
+
* shape would find half the catalog.
|
|
47
|
+
*/
|
|
48
|
+
import { Parser } from 'htmlparser2';
|
|
49
|
+
import { BRANDS } from '../../identity.js';
|
|
50
|
+
import { compare } from '../../order.js';
|
|
51
|
+
import { consoleWarn, pause, REQUEST_DELAY_MS } from '../../scrape.js';
|
|
52
|
+
/**
|
|
53
|
+
* The product-listing component, per category page.
|
|
54
|
+
*
|
|
55
|
+
* A different resource from `scrape.BASE`: that one is the variant table on the
|
|
56
|
+
* shared `products/fam` page, this one hangs off the category's own path. The
|
|
57
|
+
* component node is `product_listing` on both AEM brands.
|
|
58
|
+
*/
|
|
59
|
+
export const LISTING_BASE = 'https://www.{host}/us/en/products/{category}/_jcr_content/root/' +
|
|
60
|
+
'responsivegrid/product_listing.{page}.html?query={query}&sort=&pageSize={size}';
|
|
61
|
+
/** Where the collet walk starts: the category path, and the three facet queries. */
|
|
62
|
+
export const COLLET_CATEGORY = 'metalworking-tools/tool-holders-and-adapters/collets-and-sleeves';
|
|
63
|
+
/**
|
|
64
|
+
* The three collet lines this package covers, as the vendor's own facets.
|
|
65
|
+
*
|
|
66
|
+
* Read off the category listing on 2026-09-08. Each is the query string the
|
|
67
|
+
* browser's address bar carries for that line, with `obsoleteFacet:false` — the
|
|
68
|
+
* same active-only scoping `scrape.ACTIVE_ONLY` applies to a family — and the
|
|
69
|
+
* `allCategoriesKMT` chain that names Collets and Sleeves (`42034262`), ER
|
|
70
|
+
* Collets (`42866150`) and then the line.
|
|
71
|
+
*
|
|
72
|
+
* Kennametal's collet catalog is larger: `42034262` also holds sleeves, DA and
|
|
73
|
+
* TG collets and shrink sleeves. These are the three lines asked for, and
|
|
74
|
+
* adding a fourth is a decision rather than a widening of a pattern.
|
|
75
|
+
*/
|
|
76
|
+
export const COLLET_CATEGORIES = [
|
|
77
|
+
{
|
|
78
|
+
name: 'ER Standard Collets',
|
|
79
|
+
query: ':relevance:obsoleteFacet:false:allCategoriesKMT:42034262' +
|
|
80
|
+
':allCategoriesKMT:42866150:allCategoriesKMT:51114268',
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
name: 'ER Coolant Through Collets',
|
|
84
|
+
query: ':relevance:obsoleteFacet:false:allCategoriesKMT:42034262' +
|
|
85
|
+
':allCategoriesKMT:42866150:allCategoriesKMT:109337175',
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
name: 'ER Tap Collets',
|
|
89
|
+
query: ':relevance:obsoleteFacet:false:allCategoriesKMT:42034262' +
|
|
90
|
+
':allCategoriesKMT:42866150:allCategoriesKMT:109337171',
|
|
91
|
+
},
|
|
92
|
+
];
|
|
93
|
+
/**
|
|
94
|
+
* How many families a listing page returns.
|
|
95
|
+
*
|
|
96
|
+
* Sixty because that is the largest the vendor's own control offers and every
|
|
97
|
+
* line in scope fits one page at it — so the walk is one request per node in
|
|
98
|
+
* practice, and still correct if a line outgrows it.
|
|
99
|
+
*/
|
|
100
|
+
export const LISTING_PAGE_SIZE = 60;
|
|
101
|
+
/** `/us/en/products/fam.er-standard-collets-metric.100000478.html` */
|
|
102
|
+
const FAMILY_HREF = /\/products\/fam\.([A-Za-z0-9-]+)\.(\d+)\.html/;
|
|
103
|
+
/** `data-totalResults="224"` — htmlparser2 lower-cases attribute names. */
|
|
104
|
+
const TOTAL = /data-totalresults="(\d+)"/i;
|
|
105
|
+
/** One page of one category's listing. */
|
|
106
|
+
export function listingUrl(query, page, brand = 'kennametal', category = COLLET_CATEGORY, size = LISTING_PAGE_SIZE) {
|
|
107
|
+
return LISTING_BASE.replace('{host}', BRANDS[brand].host)
|
|
108
|
+
.replace('{category}', category)
|
|
109
|
+
.replace('{page}', String(page))
|
|
110
|
+
.replace('{query}', encodeURIComponent(query))
|
|
111
|
+
.replace('{size}', String(size));
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* One listing response: its tiles, its family links and its stated total.
|
|
115
|
+
*
|
|
116
|
+
* The tile's three facts arrive in three places — the query on the anchor, the
|
|
117
|
+
* count in a `<span class="count">` inside it, the name in a `data-title` on a
|
|
118
|
+
* `<div>` inside it — so this tracks the open anchor rather than matching a
|
|
119
|
+
* shape. A page's facet sidebar carries hundreds of other `data-query` anchors;
|
|
120
|
+
* `horizontal-facet` is the class the tiles alone wear, and it is what
|
|
121
|
+
* separates a subcategory from a filter checkbox.
|
|
122
|
+
*/
|
|
123
|
+
export function parseColletListing(html) {
|
|
124
|
+
const tiles = [];
|
|
125
|
+
const families = new Map();
|
|
126
|
+
let open = null;
|
|
127
|
+
let counting = false;
|
|
128
|
+
const parser = new Parser({
|
|
129
|
+
onopentag: (tag, attribs) => {
|
|
130
|
+
if (tag === 'a') {
|
|
131
|
+
const href = attribs['href'] ?? '';
|
|
132
|
+
const found = FAMILY_HREF.exec(href);
|
|
133
|
+
if (found !== null) {
|
|
134
|
+
families.set(found[2], { code: found[2], slug: found[1] });
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
const cls = attribs['class'] ?? '';
|
|
138
|
+
const query = attribs['data-query'];
|
|
139
|
+
if (cls.includes('horizontal-facet') && query !== undefined) {
|
|
140
|
+
open = { query, name: '', count: 0 };
|
|
141
|
+
}
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (open === null)
|
|
145
|
+
return;
|
|
146
|
+
if (tag === 'span' && (attribs['class'] ?? '').includes('count'))
|
|
147
|
+
counting = true;
|
|
148
|
+
const title = attribs['data-title'];
|
|
149
|
+
if (title !== undefined)
|
|
150
|
+
open.name = title;
|
|
151
|
+
},
|
|
152
|
+
ontext: (text) => {
|
|
153
|
+
if (!counting)
|
|
154
|
+
return;
|
|
155
|
+
const digits = /(\d+)/.exec(text);
|
|
156
|
+
if (digits !== null)
|
|
157
|
+
open.count = Number(digits[1]);
|
|
158
|
+
},
|
|
159
|
+
onclosetag: (tag) => {
|
|
160
|
+
if (tag === 'span')
|
|
161
|
+
counting = false;
|
|
162
|
+
if (tag === 'a' && open !== null) {
|
|
163
|
+
tiles.push(open);
|
|
164
|
+
open = null;
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
}, { decodeEntities: true });
|
|
168
|
+
parser.write(html);
|
|
169
|
+
parser.end();
|
|
170
|
+
return {
|
|
171
|
+
total: Number(TOTAL.exec(html)?.[1] ?? 0),
|
|
172
|
+
tiles,
|
|
173
|
+
families: [...families.values()].sort((a, b) => compare(a.code, b.code)),
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Every page of one collet category, until a page adds no family it had not seen.
|
|
178
|
+
*
|
|
179
|
+
* The vendor states no page count anywhere, and `data-totalResults` counts
|
|
180
|
+
* *parts* while `pageSize` counts *families*, so the two cannot be divided into
|
|
181
|
+
* a page budget. What ends the walk is the page itself: an index past the last
|
|
182
|
+
* one returns the facet chrome with no families in it.
|
|
183
|
+
*
|
|
184
|
+
* Tiles come from the first page only. A category with subcategories publishes
|
|
185
|
+
* them on every page and recursing on a duplicate would re-walk the branch.
|
|
186
|
+
*/
|
|
187
|
+
export async function colletListingPages(fetcher, query, options = {}) {
|
|
188
|
+
const { brand = 'kennametal', category = COLLET_CATEGORY, delayMs = REQUEST_DELAY_MS } = options;
|
|
189
|
+
const families = new Map();
|
|
190
|
+
let first = null;
|
|
191
|
+
for (let page = 0;; page += 1) {
|
|
192
|
+
if (page > 0)
|
|
193
|
+
await pause(delayMs);
|
|
194
|
+
const listing = parseColletListing(await fetcher.text(listingUrl(query, page, brand, category)));
|
|
195
|
+
first ??= listing;
|
|
196
|
+
const before = families.size;
|
|
197
|
+
for (const family of listing.families)
|
|
198
|
+
families.set(family.code, family);
|
|
199
|
+
if (families.size === before)
|
|
200
|
+
break;
|
|
201
|
+
}
|
|
202
|
+
return {
|
|
203
|
+
total: first?.total ?? 0,
|
|
204
|
+
tiles: first?.tiles ?? [],
|
|
205
|
+
families: [...families.values()].sort((a, b) => compare(a.code, b.code)),
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Every family reachable from `roots`, depth first and paced.
|
|
210
|
+
*
|
|
211
|
+
* A tile's query has to **extend** its parent's, which is the same prefix test
|
|
212
|
+
* `vendors/maritool/catalog.ts` applies to a cPath and for the same reason: a
|
|
213
|
+
* listing page renders unrelated facets beside its own subcategories, so taking
|
|
214
|
+
* every `horizontal-facet` anchor would walk out of the branch that was asked
|
|
215
|
+
* for and into the whole catalog.
|
|
216
|
+
*
|
|
217
|
+
* Sequential and paced by the package's shared politeness delay. The three
|
|
218
|
+
* collet lines reach seven nodes, so this is a handful of requests run by hand.
|
|
219
|
+
*/
|
|
220
|
+
export async function discoverFamilies(fetcher, roots = COLLET_CATEGORIES, options = {}) {
|
|
221
|
+
const { warn = consoleWarn, delayMs = REQUEST_DELAY_MS, ...where } = options;
|
|
222
|
+
const found = [];
|
|
223
|
+
const seen = new Set();
|
|
224
|
+
const walk = async (name, query) => {
|
|
225
|
+
if (seen.has(query))
|
|
226
|
+
return;
|
|
227
|
+
seen.add(query);
|
|
228
|
+
const listing = await colletListingPages(fetcher, query, { ...where, delayMs });
|
|
229
|
+
found.push({ name, query, total: listing.total, families: listing.families });
|
|
230
|
+
const children = listing.tiles.filter((tile) => tile.query.startsWith(`${query}:`));
|
|
231
|
+
if (children.length === 0 && listing.families.length === 0) {
|
|
232
|
+
warn(` WARNING: ${name} holds neither a subcategory nor a family`);
|
|
233
|
+
}
|
|
234
|
+
for (const tile of children) {
|
|
235
|
+
await pause(delayMs);
|
|
236
|
+
await walk(tile.name, tile.query);
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
for (const root of roots)
|
|
240
|
+
await walk(root.name, root.query);
|
|
241
|
+
return found;
|
|
242
|
+
}
|
|
243
|
+
/** One line per family, for a human reading a `--collets` run. */
|
|
244
|
+
export function describeFamily(category, family, configured) {
|
|
245
|
+
const where = configured ?? '(not configured)';
|
|
246
|
+
return `${family.code}\t${family.slug}\t${category.name}\t${where}`;
|
|
247
|
+
}
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
* and dropped under `records.ToolRecord`'s standing rule: add a field when
|
|
31
31
|
* something displays it, not before.
|
|
32
32
|
*/
|
|
33
|
-
import { CAD_COLUMN, COLLET_DESIGNATION_COLUMN, COLLET_SERIES_COLUMN } from '../../conventions.js';
|
|
33
|
+
import { CAD_COLUMN, COLLET_DESIGNATION_COLUMN, COLLET_SERIES_COLUMN, dimensionalColumn, } from '../../conventions.js';
|
|
34
34
|
import { familyBrand } from '../../family.js';
|
|
35
35
|
import { checkUnitAgreement, clampingMode, colletRecord, contactMode, dim, holderRecord, holdingFact, published, } from '../../holding.js';
|
|
36
36
|
import { consoleWarn } from '../../scrape.js';
|
|
@@ -44,7 +44,19 @@ import { CATALOG_NUMBER, MATERIAL_NUMBER } from './records.js';
|
|
|
44
44
|
* report rather than a gate.
|
|
45
45
|
*/
|
|
46
46
|
const HOLDER_LABELS = ['D1', 'L1', 'L2', 'L9', 'V', 'D2', 'D11'];
|
|
47
|
-
const COLLET_LABELS = ['CCCN', 'CCCX', 'D1', 'BDX', 'LF', 'L'];
|
|
47
|
+
const COLLET_LABELS = ['CCCN', 'CCCX', 'D1', 'BDX', 'LF', 'L', 'L9', 'S10'];
|
|
48
|
+
/**
|
|
49
|
+
* The square drive a tap collet's bore carries, across flats.
|
|
50
|
+
*
|
|
51
|
+
* Kennametal's own label, and it is what tells a tap collet from a round one —
|
|
52
|
+
* see {@link colletCapacity}. Local to this adapter rather than in
|
|
53
|
+
* `conventions.ts` for the reason that module states: a column two vendors
|
|
54
|
+
* write is neutral, and this one is published by Kennametal's tap families and
|
|
55
|
+
* by nothing else in the catalog.
|
|
56
|
+
*/
|
|
57
|
+
const SQUARE_LABEL = 'S10';
|
|
58
|
+
/** The vendor's own designation of the taps a tap collet is for. */
|
|
59
|
+
const TAP_RANGE_LABEL = 'Tap Range';
|
|
48
60
|
/** How a part names itself in a warning or a refusal. */
|
|
49
61
|
function subject(row) {
|
|
50
62
|
return `${row[CATALOG_NUMBER] ?? ''} (${row[MATERIAL_NUMBER] ?? ''})`;
|
|
@@ -85,6 +97,34 @@ function holder(row, family, options = {}) {
|
|
|
85
97
|
cadDxfUrl: null,
|
|
86
98
|
});
|
|
87
99
|
}
|
|
100
|
+
/**
|
|
101
|
+
* A collet's clamping capacity, from whichever pair of columns the family
|
|
102
|
+
* publishes.
|
|
103
|
+
*
|
|
104
|
+
* The ER collet families state `CCCN`/`CCCX` and that is what is read. **The
|
|
105
|
+
* tap families state neither**, and they are not incomplete rows: a square-drive
|
|
106
|
+
* collet holds one exact shank, and `D1` is it — `16ERTC025` publishes 6.477 mm,
|
|
107
|
+
* which is 0.255 in, the ANSI shank of a 1/4-20 tap. So the honest capacity is a
|
|
108
|
+
* zero-width band at `D1`, the same shape a sealed coolant-through collet
|
|
109
|
+
* already carries and `holding.checkCollet` already permits.
|
|
110
|
+
*
|
|
111
|
+
* Keyed on the vendor publishing a **square size** rather than on the family's
|
|
112
|
+
* `style` string: the square is Kennametal saying this part drives a tap, and a
|
|
113
|
+
* style is this package's config. A tap family that started publishing a real
|
|
114
|
+
* band would simply be read from it, which is the right answer either way.
|
|
115
|
+
*/
|
|
116
|
+
function colletCapacity(row, what, unit) {
|
|
117
|
+
const min = dim(row, 'CCCN', unit);
|
|
118
|
+
const max = dim(row, 'CCCX', unit);
|
|
119
|
+
if (min !== null || max !== null || dim(row, SQUARE_LABEL, unit) === null) {
|
|
120
|
+
return {
|
|
121
|
+
clampMin: published(min, what, 'CCCN clamping minimum'),
|
|
122
|
+
clampMax: published(max, what, 'CCCX clamping maximum'),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
const exact = published(dim(row, 'D1', unit), what, 'D1 clamping diameter');
|
|
126
|
+
return { clampMin: exact, clampMax: exact };
|
|
127
|
+
}
|
|
88
128
|
/** One Kennametal or WIDIA collet row -> one {@link ColletRecord}. */
|
|
89
129
|
function collet(row, family, options = {}) {
|
|
90
130
|
const warn = options.warn ?? consoleWarn;
|
|
@@ -101,11 +141,17 @@ function collet(row, family, options = {}) {
|
|
|
101
141
|
series: published(row[COLLET_DESIGNATION_COLUMN], what, 'collet series'),
|
|
102
142
|
style: holdingFact(family, 'style', family.style),
|
|
103
143
|
nominal: dim(row, 'D1', unit),
|
|
104
|
-
|
|
105
|
-
clampMax: published(dim(row, 'CCCX', unit), what, 'CCCX clamping maximum'),
|
|
144
|
+
...colletCapacity(row, what, unit),
|
|
106
145
|
bodyDiameter: dim(row, 'BDX', unit),
|
|
107
146
|
functionalLength: dim(row, 'LF', unit),
|
|
108
147
|
overallLength: dim(row, 'L', unit),
|
|
148
|
+
clampingLength: dim(row, 'L9', unit),
|
|
149
|
+
// Verbatim, in the family's own unit, and never converted: it is two thread
|
|
150
|
+
// designations rather than a dimension. The metric families print it in the
|
|
151
|
+
// metric column and the ANSI ones in the inch column, and a row where the
|
|
152
|
+
// vendor filled neither says nothing rather than the wrong one.
|
|
153
|
+
tapRange: row[dimensionalColumn(TAP_RANGE_LABEL, unit)] || null,
|
|
154
|
+
squareSize: dim(row, SQUARE_LABEL, unit),
|
|
109
155
|
});
|
|
110
156
|
}
|
|
111
157
|
/** The toolholding half of the adapter contract `registry` looks up by brand. */
|