@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/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 || record.nominal > record.clampMax)) {
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
- clampMin: published(dim(row, 'CCCN', unit), what, 'CCCN clamping minimum'),
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. */
@@ -15,6 +15,7 @@
15
15
  * code here at all.
16
16
  */
17
17
  export * from './cad.js';
18
+ export * from './catalog.js';
18
19
  export * from './family.js';
19
20
  export * from './holding.js';
20
21
  export * from './materials.js';
@@ -15,6 +15,7 @@
15
15
  * code here at all.
16
16
  */
17
17
  export * from './cad.js';
18
+ export * from './catalog.js';
18
19
  export * from './family.js';
19
20
  export * from './holding.js';
20
21
  export * from './materials.js';