@toolpath/tool-scraper 2.3.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 +19 -8
- package/dist/families/emuge.d.ts +51 -3
- package/dist/families/emuge.js +105 -15
- package/dist/families/kennametal.d.ts +291 -2
- package/dist/families/kennametal.js +221 -22
- package/dist/families/maritool.d.ts +31 -0
- package/dist/families/maritool.js +31 -0
- package/dist/family.d.ts +28 -2
- package/dist/holding.d.ts +61 -3
- package/dist/holding.js +42 -1
- package/dist/identity.d.ts +1 -1
- package/dist/identity.js +3 -6
- package/dist/node/cli.js +46 -1
- package/dist/records.d.ts +29 -2
- package/dist/records.js +13 -1
- package/dist/vendors/emuge/records.d.ts +10 -0
- package/dist/vendors/emuge/records.js +12 -0
- 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/dist/vendors/kennametal/records.js +1 -0
- package/package.json +2 -2
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;
|
package/dist/records.d.ts
CHANGED
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
* always a copy is not a second measurement. An adapter that could supply them
|
|
43
43
|
* separately could supply a tool that claims a holder it does not have.
|
|
44
44
|
*/
|
|
45
|
-
import { type UnitSystem } from '@toolpath/tool-support';
|
|
45
|
+
import { type ThreadMethod, type UnitSystem } from '@toolpath/tool-support';
|
|
46
46
|
import { type BrandName } from './identity.js';
|
|
47
47
|
import type { FactSource } from './provenance.js';
|
|
48
48
|
/**
|
|
@@ -69,6 +69,16 @@ export interface GeometryField {
|
|
|
69
69
|
*/
|
|
70
70
|
iso: string | null;
|
|
71
71
|
}
|
|
72
|
+
/**
|
|
73
|
+
* How a tap makes its thread.
|
|
74
|
+
*
|
|
75
|
+
* `@toolpath/tool-support`'s, re-exported under the name this package reads it
|
|
76
|
+
* by — the move `conventions.ts` makes for `UnitSystem` and `provenance.ts` for
|
|
77
|
+
* `PROVENANCE`. A scrape originates the fact and the domain owns the
|
|
78
|
+
* vocabulary, and two declarations of the same two strings is the drift that
|
|
79
|
+
* rule exists to prevent.
|
|
80
|
+
*/
|
|
81
|
+
export type { ThreadMethod } from '@toolpath/tool-support';
|
|
72
82
|
/** The kinds of cutting tool this package maps. */
|
|
73
83
|
export type ToolKind = 'drill' | 'tap' | 'endmill';
|
|
74
84
|
/**
|
|
@@ -447,6 +457,23 @@ export interface ToolRecord {
|
|
|
447
457
|
* a PCD tool.
|
|
448
458
|
*/
|
|
449
459
|
readonly nonFerrous: boolean | null;
|
|
460
|
+
/**
|
|
461
|
+
* Taps only, and `null` on every other kind because the question does not
|
|
462
|
+
* apply — the shape {@link ToolRecord.nonFerrous} already keeps for drills.
|
|
463
|
+
*
|
|
464
|
+
* **Not a default, and not derivable from anything else on the record.** A
|
|
465
|
+
* former and a cut tap of the same size share their `DC`, `TP`, `SFDM`,
|
|
466
|
+
* `OAL` and `LCF`; what separates them is that one displaces material and the
|
|
467
|
+
* other removes it, which changes the hole a shop drills first and the feed
|
|
468
|
+
* it runs. Guessing `cutting` because most of a catalog is would ship a
|
|
469
|
+
* former with a cut tap's drill size.
|
|
470
|
+
*
|
|
471
|
+
* It is a per-family fact rather than a column, and each of the five
|
|
472
|
+
* declarations cites the vendor's own index — see `families/kennametal.ts`
|
|
473
|
+
* and `families/emuge.ts`. The invariant below is what stops a sixth tap
|
|
474
|
+
* family arriving without one.
|
|
475
|
+
*/
|
|
476
|
+
readonly threadMethod: ThreadMethod | null;
|
|
450
477
|
}
|
|
451
478
|
/**
|
|
452
479
|
* Build a {@link ToolRecord}: mint its guid, default the fields that have a
|
|
@@ -473,7 +500,7 @@ export interface ToolRecord {
|
|
|
473
500
|
* interchange value, and a mapper that mutated one would be reaching back
|
|
474
501
|
* across the seam this type exists to draw.
|
|
475
502
|
*/
|
|
476
|
-
export declare function toolRecord(fields: Omit<ToolRecord, 'guid' | 'materialGroups' | 'materialGroupsSource' | 'nonFerrous' | 'productLine'> & Partial<Pick<ToolRecord, 'materialGroups' | 'materialGroupsSource' | 'nonFerrous' | 'productLine'>>): ToolRecord;
|
|
503
|
+
export declare function toolRecord(fields: Omit<ToolRecord, 'guid' | 'materialGroups' | 'materialGroupsSource' | 'nonFerrous' | 'productLine' | 'threadMethod'> & Partial<Pick<ToolRecord, 'materialGroups' | 'materialGroupsSource' | 'nonFerrous' | 'productLine' | 'threadMethod'>>): ToolRecord;
|
|
477
504
|
/**
|
|
478
505
|
* A family's canonical-field → CSV-column-label mapping, validated.
|
|
479
506
|
*
|
package/dist/records.js
CHANGED
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
* always a copy is not a second measurement. An adapter that could supply them
|
|
43
43
|
* separately could supply a tool that claims a holder it does not have.
|
|
44
44
|
*/
|
|
45
|
-
import { GEOMETRY_FIELDS as DICTIONARY } from '@toolpath/tool-support';
|
|
45
|
+
import { GEOMETRY_FIELDS as DICTIONARY, } from '@toolpath/tool-support';
|
|
46
46
|
import { dimensionalColumn } from './conventions.js';
|
|
47
47
|
import { ScraperConfigError } from './errors.js';
|
|
48
48
|
import { recordGuid } from './identity.js';
|
|
@@ -315,9 +315,21 @@ export function toolRecord(fields) {
|
|
|
315
315
|
`and materialGroupsSource is ${JSON.stringify(source)} — ` +
|
|
316
316
|
`groups are ${JSON.stringify(UNSPECIFIED)} exactly when there are none`);
|
|
317
317
|
}
|
|
318
|
+
// A tap says how it makes its thread and nothing else does. Both halves are
|
|
319
|
+
// load-bearing: a tap record with no method is a family that never declared
|
|
320
|
+
// the fact, and a method on a drill is a mapper that copied a line from the
|
|
321
|
+
// tap one. Neither can be recovered downstream from what is left on the
|
|
322
|
+
// record, because the geometry of a former and a cut tap is the same
|
|
323
|
+
// geometry.
|
|
324
|
+
const method = fields.threadMethod ?? null;
|
|
325
|
+
if ((fields.kind === 'tap') !== (method !== null)) {
|
|
326
|
+
throw new ScraperConfigError(fields.materialNumber, `a ${fields.kind} record states threadMethod ${JSON.stringify(method)} — ` +
|
|
327
|
+
`a tap says how it makes its thread and no other kind does`);
|
|
328
|
+
}
|
|
318
329
|
checkGeometry(fields.kind, fields.materialNumber, fields.geometry);
|
|
319
330
|
return Object.freeze({
|
|
320
331
|
...fields,
|
|
332
|
+
threadMethod: method,
|
|
321
333
|
guid: recordGuid(fields.brand, fields.materialNumber),
|
|
322
334
|
geometry: Object.freeze({ ...fields.geometry }),
|
|
323
335
|
materialGroups: groups === null ? null : Object.freeze([...groups]),
|
|
@@ -155,6 +155,16 @@ export declare const PRODUCT_LINE_COLUMNS: Readonly<Record<string, string>>;
|
|
|
155
155
|
* vendor's marketing rather than a hole here: `SPEED`, `FK`, `GAL`, `GG` and
|
|
156
156
|
* `TILEG` are real lines with no `/a/` page on the US storefront, so the
|
|
157
157
|
* honest answer is the vendor's own code until one appears.
|
|
158
|
+
*
|
|
159
|
+
* **`FG02` has no entry at all, on purpose.** Cold-forming taps index by the
|
|
160
|
+
* same eight geometry codes `FG01` uses — `AL`, `GAL`, `H`, `MULTI`, `SPEED`,
|
|
161
|
+
* `STEEL`, `VA`, `Z` — and mean different products by them: a `Z`-geometry
|
|
162
|
+
* former is InnoForm, not the `Rekord B-Z Taps` the `FG01` table would name it.
|
|
163
|
+
* Borrowing that table would put a cutting tap's product line on a forming tap,
|
|
164
|
+
* which is the one error this category split exists to make impossible. So the
|
|
165
|
+
* codes pass through verbatim, which is what the paragraph above already says a
|
|
166
|
+
* code with no article page does, and naming them from the vendor's own
|
|
167
|
+
* cold-forming pages is the follow-up.
|
|
158
168
|
*/
|
|
159
169
|
export declare const PRODUCT_LINES: Readonly<Record<string, Readonly<Record<string, string>>>>;
|
|
160
170
|
/**
|
|
@@ -172,6 +172,7 @@ export const PRODUCT_LINE_COLUMNS = {
|
|
|
172
172
|
FF01: 'product line',
|
|
173
173
|
FB01: 'Geometry',
|
|
174
174
|
FG01: 'Geometry',
|
|
175
|
+
FG02: 'Geometry',
|
|
175
176
|
};
|
|
176
177
|
/**
|
|
177
178
|
* A category's product-line codes onto the vendor's own name for each.
|
|
@@ -194,6 +195,16 @@ export const PRODUCT_LINE_COLUMNS = {
|
|
|
194
195
|
* vendor's marketing rather than a hole here: `SPEED`, `FK`, `GAL`, `GG` and
|
|
195
196
|
* `TILEG` are real lines with no `/a/` page on the US storefront, so the
|
|
196
197
|
* honest answer is the vendor's own code until one appears.
|
|
198
|
+
*
|
|
199
|
+
* **`FG02` has no entry at all, on purpose.** Cold-forming taps index by the
|
|
200
|
+
* same eight geometry codes `FG01` uses — `AL`, `GAL`, `H`, `MULTI`, `SPEED`,
|
|
201
|
+
* `STEEL`, `VA`, `Z` — and mean different products by them: a `Z`-geometry
|
|
202
|
+
* former is InnoForm, not the `Rekord B-Z Taps` the `FG01` table would name it.
|
|
203
|
+
* Borrowing that table would put a cutting tap's product line on a forming tap,
|
|
204
|
+
* which is the one error this category split exists to make impossible. So the
|
|
205
|
+
* codes pass through verbatim, which is what the paragraph above already says a
|
|
206
|
+
* code with no article page does, and naming them from the vendor's own
|
|
207
|
+
* cold-forming pages is the follow-up.
|
|
197
208
|
*/
|
|
198
209
|
export const PRODUCT_LINES = {
|
|
199
210
|
// `/us/en/multi-drill/a/MultiDRILL`, `/us/en/steeldrill/a/SteelDrill`,
|
|
@@ -527,6 +538,7 @@ export function tapRecord(row, family, columns, options = {}) {
|
|
|
527
538
|
...common(row, family, what, warn),
|
|
528
539
|
kind: 'tap',
|
|
529
540
|
unit,
|
|
541
|
+
threadMethod: fact(family, 'threadMethod', family.threadMethod),
|
|
530
542
|
geometry: {
|
|
531
543
|
DC: required(row, columns, 'DC', unit, what, opts),
|
|
532
544
|
TP: required(row, columns, 'TP', unit, what, opts),
|
|
@@ -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
|
+
}
|