@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/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
+ }