@toolpath/tool-scraper 0.1.0 → 2.1.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.
Files changed (76) hide show
  1. package/README.md +41 -11
  2. package/dist/columns.d.ts +64 -0
  3. package/dist/columns.js +68 -0
  4. package/dist/conventions.d.ts +107 -15
  5. package/dist/conventions.js +126 -15
  6. package/dist/errors.d.ts +30 -0
  7. package/dist/errors.js +30 -0
  8. package/dist/families/emuge.d.ts +185 -0
  9. package/dist/families/emuge.js +163 -0
  10. package/dist/families/harvey.d.ts +1782 -0
  11. package/dist/families/harvey.js +1328 -0
  12. package/dist/families/index.js +5 -2
  13. package/dist/families/kennametal.d.ts +21 -0
  14. package/dist/families/kennametal.js +10 -0
  15. package/dist/families/maritool.d.ts +120 -0
  16. package/dist/families/maritool.js +175 -0
  17. package/dist/family.d.ts +35 -1
  18. package/dist/family.js +28 -0
  19. package/dist/identity.d.ts +18 -0
  20. package/dist/identity.js +46 -0
  21. package/dist/index.d.ts +16 -0
  22. package/dist/index.js +16 -0
  23. package/dist/measure.d.ts +112 -0
  24. package/dist/measure.js +130 -0
  25. package/dist/node/cli.d.ts +3 -0
  26. package/dist/node/cli.js +158 -3
  27. package/dist/node/main.js +0 -0
  28. package/dist/records.d.ts +214 -12
  29. package/dist/records.js +140 -5
  30. package/dist/registry.d.ts +56 -1
  31. package/dist/registry.js +79 -2
  32. package/dist/scrape.d.ts +15 -0
  33. package/dist/scrape.js +24 -0
  34. package/dist/vendors/destinytool/records.d.ts +13 -3
  35. package/dist/vendors/destinytool/records.js +49 -37
  36. package/dist/vendors/emuge/index.d.ts +17 -0
  37. package/dist/vendors/emuge/index.js +17 -0
  38. package/dist/vendors/emuge/records.d.ts +230 -0
  39. package/dist/vendors/emuge/records.js +543 -0
  40. package/dist/vendors/emuge/scrape.d.ts +227 -0
  41. package/dist/vendors/emuge/scrape.js +358 -0
  42. package/dist/vendors/emuge/value.d.ts +75 -0
  43. package/dist/vendors/emuge/value.js +116 -0
  44. package/dist/vendors/harvey/catalog.d.ts +53 -0
  45. package/dist/vendors/harvey/catalog.js +120 -0
  46. package/dist/vendors/harvey/header.d.ts +89 -0
  47. package/dist/vendors/harvey/header.js +185 -0
  48. package/dist/vendors/harvey/index.d.ts +21 -0
  49. package/dist/vendors/harvey/index.js +21 -0
  50. package/dist/vendors/harvey/lexicon.d.ts +73 -0
  51. package/dist/vendors/harvey/lexicon.js +126 -0
  52. package/dist/vendors/harvey/literal.d.ts +68 -0
  53. package/dist/vendors/harvey/literal.js +214 -0
  54. package/dist/vendors/harvey/records.d.ts +79 -0
  55. package/dist/vendors/harvey/records.js +163 -0
  56. package/dist/vendors/harvey/scrape.d.ts +187 -0
  57. package/dist/vendors/harvey/scrape.js +483 -0
  58. package/dist/vendors/harvey/value.d.ts +74 -0
  59. package/dist/vendors/harvey/value.js +119 -0
  60. package/dist/vendors/kennametal/family.d.ts +119 -0
  61. package/dist/vendors/kennametal/family.js +155 -0
  62. package/dist/vendors/kennametal/index.d.ts +1 -0
  63. package/dist/vendors/kennametal/index.js +1 -0
  64. package/dist/vendors/kennametal/records.d.ts +29 -4
  65. package/dist/vendors/kennametal/records.js +93 -26
  66. package/dist/vendors/kennametal/scrape.d.ts +25 -2
  67. package/dist/vendors/kennametal/scrape.js +28 -3
  68. package/dist/vendors/maritool/catalog.d.ts +81 -0
  69. package/dist/vendors/maritool/catalog.js +132 -0
  70. package/dist/vendors/maritool/index.d.ts +10 -0
  71. package/dist/vendors/maritool/index.js +10 -0
  72. package/dist/vendors/maritool/scrape.d.ts +297 -0
  73. package/dist/vendors/maritool/scrape.js +593 -0
  74. package/dist/vendors/regofix/scrape.d.ts +8 -11
  75. package/dist/vendors/regofix/scrape.js +21 -36
  76. package/package.json +31 -7
@@ -0,0 +1,119 @@
1
+ /**
2
+ * A family page's own title, for every brand on Kennametal's AEM platform.
3
+ *
4
+ * `scrape.ts` reads the variant *table* that a family page renders; this reads
5
+ * the heading above it. They are two requests against the same family because
6
+ * they are two different AEM resources — the table is a component node's
7
+ * `.variants.<code>.html` selector and the title is the page — and the second
8
+ * is what nothing else in this package publishes:
9
+ *
10
+ * ```html
11
+ * <h1>KenCut™ FF • HPFT • Square End • 6 Flutes • Plain Shank • Inch</h1>
12
+ * ```
13
+ *
14
+ * ## The slug does not matter
15
+ *
16
+ * The vendor's own links are `fam.<slug>.<code>.html`, and AEM resolves the
17
+ * page off the numeric code alone — `fam.x.100003783.html` answers 200 with
18
+ * exactly the page above. That is what makes this reachable from a
19
+ * `familyCode` and nothing else: `families/kennametal.ts` records the code
20
+ * because the variants endpoint needs it, and no family in it carries a slug.
21
+ *
22
+ * **`fam.<code>.html` — the code with no slug segment at all — 301s**, so the
23
+ * placeholder is load-bearing rather than decoration. {@link FAMILY_SLUG} is
24
+ * what fills it.
25
+ *
26
+ * ## Why the page is trusted to be the right one
27
+ *
28
+ * A URL built from a code that no longer names a family would answer *some*
29
+ * page, and a title read off it would be silently wrong for a whole table. So
30
+ * the page's own `data-product-code` is checked against the code that was
31
+ * asked for, and a mismatch throws. It is the same argument
32
+ * `scrape.parseVariantTable` makes for telling the vendor's no-results notice
33
+ * apart from a response that changed shape: a scrape that reports the wrong
34
+ * answer confidently is worse than one that stops.
35
+ *
36
+ * ## What the title is, and what only part of it is
37
+ *
38
+ * The whole `h1` is the family's name and reaches the CSV whole, under
39
+ * `conventions.FAMILY_TITLE_COLUMN`. Its **leading `•` segment** is the
40
+ * product line — `KenCut™ FF`, `HARVI™ I TE`, `VariMill™ Chip Splitters` —
41
+ * and that is the part `records.ToolRecord.productLine` keeps. Everything
42
+ * after it describes the shape, the flute count, the shank and the unit, which
43
+ * are already canonical geometry on the record.
44
+ *
45
+ * The split is the vendor's own punctuation and not a guess at where a name
46
+ * ends: every family title on both hosts is `•`-separated, and a title with no
47
+ * separator is its own product line.
48
+ */
49
+ import type { Fetcher } from '../../fetch.js';
50
+ import { type AemBrandName } from '../../identity.js';
51
+ /**
52
+ * The URL shape of a family page.
53
+ *
54
+ * `{host}` and `{code}` are filled; `{slug}` is {@link FAMILY_SLUG}. Stated as
55
+ * one template beside `scrape.BASE` so that the two URLs this adapter builds
56
+ * are readable together.
57
+ */
58
+ export declare const FAMILY_PAGE = "https://www.{host}/us/en/products/fam.{slug}.{code}.html";
59
+ /**
60
+ * What goes where the vendor writes a human-readable slug.
61
+ *
62
+ * AEM ignores it — see the module note — and this package has no slug to put
63
+ * there, because a `familyCode` is the only handle `families/kennametal.ts`
64
+ * records. A word rather than a single letter so that a request showing up in
65
+ * somebody's log says what it is.
66
+ */
67
+ export declare const FAMILY_SLUG = "family";
68
+ /** The vendor's own separator between the parts of a family title. */
69
+ export declare const TITLE_SEPARATOR = "\u2022";
70
+ /** One family page's URL. */
71
+ export declare function familyPageUrl(code: string, brand?: AemBrandName): string;
72
+ /** What {@link parseFamilyPage} reads off a family page. */
73
+ export interface FamilyPage {
74
+ /** The `h1`, collapsed. */
75
+ readonly title: string;
76
+ /** The code the page states for itself, or null where it states none. */
77
+ readonly code: string | null;
78
+ }
79
+ /**
80
+ * The family title and the page's own code.
81
+ *
82
+ * `htmlparser2` rather than a regex for the same reason `scrape.TableParser`
83
+ * uses it: these pages carry `&deg;`, `&Oslash;` and `&trade;` in their text,
84
+ * and a `KenCut&trade; FF` reaching the CSV as eleven characters is the bug
85
+ * `decodeEntities` exists to prevent. The `™` in every title above is exactly
86
+ * that entity.
87
+ *
88
+ * The **first** `h1` is taken. The pages carry one, and reading the first is
89
+ * what makes a second one added below the fold somebody else's problem rather
90
+ * than a title that changes under a re-scrape.
91
+ */
92
+ export declare function parseFamilyPage(html: string): FamilyPage;
93
+ /**
94
+ * A family title's leading segment — the product line.
95
+ *
96
+ * `''` in and `''` out, which is the one case the caller has to keep: a page
97
+ * with no `h1` states no line, and {@link fetchFamily} answers null for it
98
+ * rather than an empty name. See `records.ToolRecord.productLine`.
99
+ */
100
+ export declare function productLineOf(title: string): string;
101
+ /** A family's title and product line, or nulls where the page states none. */
102
+ export interface Family {
103
+ readonly title: string;
104
+ readonly productLine: string | null;
105
+ }
106
+ /**
107
+ * One family page, read.
108
+ *
109
+ * The one network call in this module — the seam a test replaces, exactly as
110
+ * `scrape.fetchVariants` is.
111
+ *
112
+ * A page that answers with no `h1` is **not** an error: it is a family the
113
+ * vendor publishes without a heading, and the table below it is still a table
114
+ * of real parts. It comes back with an empty title and a null line, and the
115
+ * rows are written without either column rather than the scrape stopping. A
116
+ * page whose `data-product-code` names a *different* family is the other case
117
+ * and throws — see the module note.
118
+ */
119
+ export declare function fetchFamily(fetcher: Fetcher, code: string, brand?: AemBrandName): Promise<Family>;
@@ -0,0 +1,155 @@
1
+ /**
2
+ * A family page's own title, for every brand on Kennametal's AEM platform.
3
+ *
4
+ * `scrape.ts` reads the variant *table* that a family page renders; this reads
5
+ * the heading above it. They are two requests against the same family because
6
+ * they are two different AEM resources — the table is a component node's
7
+ * `.variants.<code>.html` selector and the title is the page — and the second
8
+ * is what nothing else in this package publishes:
9
+ *
10
+ * ```html
11
+ * <h1>KenCut™ FF • HPFT • Square End • 6 Flutes • Plain Shank • Inch</h1>
12
+ * ```
13
+ *
14
+ * ## The slug does not matter
15
+ *
16
+ * The vendor's own links are `fam.<slug>.<code>.html`, and AEM resolves the
17
+ * page off the numeric code alone — `fam.x.100003783.html` answers 200 with
18
+ * exactly the page above. That is what makes this reachable from a
19
+ * `familyCode` and nothing else: `families/kennametal.ts` records the code
20
+ * because the variants endpoint needs it, and no family in it carries a slug.
21
+ *
22
+ * **`fam.<code>.html` — the code with no slug segment at all — 301s**, so the
23
+ * placeholder is load-bearing rather than decoration. {@link FAMILY_SLUG} is
24
+ * what fills it.
25
+ *
26
+ * ## Why the page is trusted to be the right one
27
+ *
28
+ * A URL built from a code that no longer names a family would answer *some*
29
+ * page, and a title read off it would be silently wrong for a whole table. So
30
+ * the page's own `data-product-code` is checked against the code that was
31
+ * asked for, and a mismatch throws. It is the same argument
32
+ * `scrape.parseVariantTable` makes for telling the vendor's no-results notice
33
+ * apart from a response that changed shape: a scrape that reports the wrong
34
+ * answer confidently is worse than one that stops.
35
+ *
36
+ * ## What the title is, and what only part of it is
37
+ *
38
+ * The whole `h1` is the family's name and reaches the CSV whole, under
39
+ * `conventions.FAMILY_TITLE_COLUMN`. Its **leading `•` segment** is the
40
+ * product line — `KenCut™ FF`, `HARVI™ I TE`, `VariMill™ Chip Splitters` —
41
+ * and that is the part `records.ToolRecord.productLine` keeps. Everything
42
+ * after it describes the shape, the flute count, the shank and the unit, which
43
+ * are already canonical geometry on the record.
44
+ *
45
+ * The split is the vendor's own punctuation and not a guess at where a name
46
+ * ends: every family title on both hosts is `•`-separated, and a title with no
47
+ * separator is its own product line.
48
+ */
49
+ import { Parser } from 'htmlparser2';
50
+ import { VendorResponseError } from '../../errors.js';
51
+ import { BRANDS } from '../../identity.js';
52
+ /**
53
+ * The URL shape of a family page.
54
+ *
55
+ * `{host}` and `{code}` are filled; `{slug}` is {@link FAMILY_SLUG}. Stated as
56
+ * one template beside `scrape.BASE` so that the two URLs this adapter builds
57
+ * are readable together.
58
+ */
59
+ export const FAMILY_PAGE = 'https://www.{host}/us/en/products/fam.{slug}.{code}.html';
60
+ /**
61
+ * What goes where the vendor writes a human-readable slug.
62
+ *
63
+ * AEM ignores it — see the module note — and this package has no slug to put
64
+ * there, because a `familyCode` is the only handle `families/kennametal.ts`
65
+ * records. A word rather than a single letter so that a request showing up in
66
+ * somebody's log says what it is.
67
+ */
68
+ export const FAMILY_SLUG = 'family';
69
+ /** The vendor's own separator between the parts of a family title. */
70
+ export const TITLE_SEPARATOR = '•';
71
+ /** The attribute a family page states its own code in. */
72
+ const PRODUCT_CODE_ATTR = 'data-product-code';
73
+ /** One family page's URL. */
74
+ export function familyPageUrl(code, brand = 'kennametal') {
75
+ const { host } = BRANDS[brand];
76
+ return FAMILY_PAGE.replace('{host}', host).replace('{slug}', FAMILY_SLUG).replace('{code}', code);
77
+ }
78
+ /**
79
+ * The family title and the page's own code.
80
+ *
81
+ * `htmlparser2` rather than a regex for the same reason `scrape.TableParser`
82
+ * uses it: these pages carry `&deg;`, `&Oslash;` and `&trade;` in their text,
83
+ * and a `KenCut&trade; FF` reaching the CSV as eleven characters is the bug
84
+ * `decodeEntities` exists to prevent. The `™` in every title above is exactly
85
+ * that entity.
86
+ *
87
+ * The **first** `h1` is taken. The pages carry one, and reading the first is
88
+ * what makes a second one added below the fold somebody else's problem rather
89
+ * than a title that changes under a re-scrape.
90
+ */
91
+ export function parseFamilyPage(html) {
92
+ let title = null;
93
+ let depth = 0;
94
+ let text = '';
95
+ let code = null;
96
+ const parser = new Parser({
97
+ onopentag: (tag, attribs) => {
98
+ if (tag === 'h1' && title === null)
99
+ depth += 1;
100
+ const stated = attribs[PRODUCT_CODE_ATTR];
101
+ if (code === null && stated !== undefined && stated !== '')
102
+ code = stated;
103
+ },
104
+ ontext: (chunk) => {
105
+ if (depth > 0)
106
+ text += chunk;
107
+ },
108
+ onclosetag: (tag) => {
109
+ if (tag === 'h1' && depth > 0) {
110
+ depth -= 1;
111
+ if (depth === 0) {
112
+ title = text.split(/\s+/).filter(Boolean).join(' ');
113
+ text = '';
114
+ }
115
+ }
116
+ },
117
+ }, { decodeEntities: true });
118
+ parser.write(html);
119
+ parser.end();
120
+ return { title: title ?? '', code };
121
+ }
122
+ /**
123
+ * A family title's leading segment — the product line.
124
+ *
125
+ * `''` in and `''` out, which is the one case the caller has to keep: a page
126
+ * with no `h1` states no line, and {@link fetchFamily} answers null for it
127
+ * rather than an empty name. See `records.ToolRecord.productLine`.
128
+ */
129
+ export function productLineOf(title) {
130
+ return (title.split(TITLE_SEPARATOR)[0] ?? '').trim();
131
+ }
132
+ /**
133
+ * One family page, read.
134
+ *
135
+ * The one network call in this module — the seam a test replaces, exactly as
136
+ * `scrape.fetchVariants` is.
137
+ *
138
+ * A page that answers with no `h1` is **not** an error: it is a family the
139
+ * vendor publishes without a heading, and the table below it is still a table
140
+ * of real parts. It comes back with an empty title and a null line, and the
141
+ * rows are written without either column rather than the scrape stopping. A
142
+ * page whose `data-product-code` names a *different* family is the other case
143
+ * and throws — see the module note.
144
+ */
145
+ export async function fetchFamily(fetcher, code, brand = 'kennametal') {
146
+ const url = familyPageUrl(code, brand);
147
+ const { title, code: stated } = parseFamilyPage(await fetcher.text(url));
148
+ if (stated !== null && stated !== code) {
149
+ throw new VendorResponseError(url, `is the page for ${JSON.stringify(stated)} and not ${JSON.stringify(code)} — ` +
150
+ `the family code was retired or redirected, and its title would name the ` +
151
+ `wrong product line for every row of the table`);
152
+ }
153
+ const line = productLineOf(title);
154
+ return { title, productLine: line === '' ? null : line };
155
+ }
@@ -15,6 +15,7 @@
15
15
  * code here at all.
16
16
  */
17
17
  export * from './cad.js';
18
+ export * from './family.js';
18
19
  export * from './materials.js';
19
20
  export * from './records.js';
20
21
  export * from './scrape.js';
@@ -15,6 +15,7 @@
15
15
  * code here at all.
16
16
  */
17
17
  export * from './cad.js';
18
+ export * from './family.js';
18
19
  export * from './materials.js';
19
20
  export * from './records.js';
20
21
  export * from './scrape.js';
@@ -15,10 +15,11 @@
15
15
  * Number` are Kennametal's header text — the labels
16
16
  * `conventions.IDENTITY_COLUMNS` took as the convention because this vendor
17
17
  * was first, and which Destiny Tool then did not follow.
18
- * - **Which column is the grade.** A drill and an end mill carry a carbide
19
- * `Grade`; a tap has no carbide grade and carries `Coating`, the surface
20
- * treatment, in the record's `grade` field. That is Kennametal's table
21
- * shape, not a rule about taps everywhere.
18
+ * - **Which column is the coating.** `Coating` on every table that publishes
19
+ * one, which is every tap table and some of the others. The carbide `Grade`
20
+ * a drill and an end mill table also carry reaches no record: it is what the
21
+ * tool is made of in Kennametal's own vocabulary, nothing downstream reads
22
+ * it, and `substrate` already carries the cutting material as a fact.
22
23
  * - **That a tap's unit system is per row.** `Thread System` is a constant tag
23
24
  * column this package appends at scrape time, and a metric and an inch tap
24
25
  * can sit in one family — so a tap's `unit` is read per row where a drill's
@@ -43,6 +44,30 @@ import type { ScrapedRow } from '../../scrape.js';
43
44
  */
44
45
  export declare const MATERIAL_NUMBER = "Material Number";
45
46
  export declare const CATALOG_NUMBER = "ISO Catalog Number";
47
+ /**
48
+ * The surface-treatment column, on the tables that publish one.
49
+ *
50
+ * Every tap table does; a drill or an end mill table may not, which is why the
51
+ * mappers read it with a `?? ''` rather than through a required column — an
52
+ * absent coating column is a table that states no coating, not a scrape fault.
53
+ */
54
+ export declare const COATING = "Coating";
55
+ /**
56
+ * The column {@link productLine} reads, and the one this adapter names rather
57
+ * than the vendor.
58
+ *
59
+ * `conventions.FAMILY_TITLE_COLUMN` is vendor-neutral because a second vendor
60
+ * publishing a family title should write the same column; this one is not,
61
+ * because it holds a *segment* of that title that this adapter chose to split
62
+ * out. A vendor whose product line arrives some other way has no business
63
+ * writing here — EMUGE reads its own `product line` and `Geometry` columns and
64
+ * never sees this one.
65
+ *
66
+ * Both columns are written by `scrape.scrapeFamily` under `familyTitle`, and
67
+ * a CSV scraped before that option existed has neither — which is why
68
+ * {@link productLine} reads it as absent rather than required.
69
+ */
70
+ export declare const PRODUCT_LINE_COLUMN = "Product Line";
46
71
  /**
47
72
  * A drill, in the family's native unit system per its `unit` fact.
48
73
  *
@@ -15,10 +15,11 @@
15
15
  * Number` are Kennametal's header text — the labels
16
16
  * `conventions.IDENTITY_COLUMNS` took as the convention because this vendor
17
17
  * was first, and which Destiny Tool then did not follow.
18
- * - **Which column is the grade.** A drill and an end mill carry a carbide
19
- * `Grade`; a tap has no carbide grade and carries `Coating`, the surface
20
- * treatment, in the record's `grade` field. That is Kennametal's table
21
- * shape, not a rule about taps everywhere.
18
+ * - **Which column is the coating.** `Coating` on every table that publishes
19
+ * one, which is every tap table and some of the others. The carbide `Grade`
20
+ * a drill and an end mill table also carry reaches no record: it is what the
21
+ * tool is made of in Kennametal's own vocabulary, nothing downstream reads
22
+ * it, and `substrate` already carries the cutting material as a fact.
22
23
  * - **That a tap's unit system is per row.** `Thread System` is a constant tag
23
24
  * column this package appends at scrape time, and a metric and an inch tap
24
25
  * can sit in one family — so a tap's `unit` is read per row where a drill's
@@ -34,10 +35,10 @@
34
35
  * nose, `LB` and `assemblyGaugeLength` being `OAL` on a bare tool, and every
35
36
  * cutting preset. Those are true of a tool, not of a table.
36
37
  */
37
- import { ScraperConfigError, VendorResponseError } from '../../errors.js';
38
- import { familyBrand } from '../../family.js';
38
+ import { VendorResponseError } from '../../errors.js';
39
+ import { fact, familyBrand } from '../../family.js';
39
40
  import { BRANDS } from '../../identity.js';
40
- import { toolRecord } from '../../records.js';
41
+ import { toolRecord, UNSPECIFIED, } from '../../records.js';
41
42
  import { threadMajorDiameter } from '../../thread.js';
42
43
  import { MATERIALS_COLUMN, materialClasses } from './materials.js';
43
44
  /**
@@ -46,6 +47,43 @@ import { MATERIALS_COLUMN, materialClasses } from './materials.js';
46
47
  */
47
48
  export const MATERIAL_NUMBER = 'Material Number';
48
49
  export const CATALOG_NUMBER = 'ISO Catalog Number';
50
+ /**
51
+ * The surface-treatment column, on the tables that publish one.
52
+ *
53
+ * Every tap table does; a drill or an end mill table may not, which is why the
54
+ * mappers read it with a `?? ''` rather than through a required column — an
55
+ * absent coating column is a table that states no coating, not a scrape fault.
56
+ */
57
+ export const COATING = 'Coating';
58
+ /**
59
+ * The column {@link productLine} reads, and the one this adapter names rather
60
+ * than the vendor.
61
+ *
62
+ * `conventions.FAMILY_TITLE_COLUMN` is vendor-neutral because a second vendor
63
+ * publishing a family title should write the same column; this one is not,
64
+ * because it holds a *segment* of that title that this adapter chose to split
65
+ * out. A vendor whose product line arrives some other way has no business
66
+ * writing here — EMUGE reads its own `product line` and `Geometry` columns and
67
+ * never sees this one.
68
+ *
69
+ * Both columns are written by `scrape.scrapeFamily` under `familyTitle`, and
70
+ * a CSV scraped before that option existed has neither — which is why
71
+ * {@link productLine} reads it as absent rather than required.
72
+ */
73
+ export const PRODUCT_LINE_COLUMN = 'Product Line';
74
+ /**
75
+ * The vendor's own product line for this row, or null where the CSV has none.
76
+ *
77
+ * **An absent column is not a fault.** It is a table scraped without
78
+ * `scrape.FamilyOptions.familyTitle`, or a family page the vendor publishes
79
+ * with no heading, and both are rows that are otherwise complete. `null` is
80
+ * the answer `records.ToolRecord.productLine` defines for exactly this — see
81
+ * its docstring on why that is not `''`.
82
+ */
83
+ function productLine(row) {
84
+ const stated = (row[PRODUCT_LINE_COLUMN] ?? '').trim();
85
+ return stated === '' ? null : stated;
86
+ }
49
87
  /**
50
88
  * One canonical dimension, or null when this family maps or publishes none.
51
89
  *
@@ -79,12 +117,30 @@ function require_(row, columns, canonical, unit, what) {
79
117
  }
80
118
  return value;
81
119
  }
82
- /** A per-family constant a mapper cannot proceed without. */
83
- function fact(family, key, value) {
84
- if (value === undefined) {
85
- throw new ScraperConfigError(family.id, `a ${family.kind} family must state ${key} as a fact`);
86
- }
87
- return value;
120
+ /**
121
+ * The workpiece-material groups, and how they were arrived at.
122
+ *
123
+ * **The distinction the column cannot make on its own is present versus
124
+ * blank.** The material groups are not scraped with the variant table: they
125
+ * come from a second CLI step (`toolpath-scrape materials`) that sweeps the
126
+ * `workpieceMaterialDetail` facet and writes {@link MATERIALS_COLUMN} into the
127
+ * CSV. A family that step never ran over has **no such column**, and
128
+ * `node/csv.parseCsv` fills `''` only for cells under a column that is in the
129
+ * header — so the cell is `undefined` on an unswept CSV and `''` on a swept
130
+ * one whose part the vendor rates for nothing.
131
+ *
132
+ * Those are different claims and the record now keeps them apart: an
133
+ * `unspecified` label says this package has no evidence, `[]` says the vendor's
134
+ * own index has none. All
135
+ * 129 taps land on the second after a sweep — Kennametal indexes no tap by
136
+ * workpiece material at all — and reading that as "unconstrained" would put
137
+ * every tap under every material.
138
+ */
139
+ function materialGroups(row) {
140
+ const cell = row[MATERIALS_COLUMN];
141
+ if (cell === undefined)
142
+ return { materialGroups: null, materialGroupsSource: UNSPECIFIED };
143
+ return { materialGroups: materialClasses(cell), materialGroupsSource: 'vendor-stated' };
88
144
  }
89
145
  /**
90
146
  * The `Thread System` tag, refusing anything that is not one of the two.
@@ -129,15 +185,20 @@ export function drillRecord(row, family, columns) {
129
185
  const unit = fact(family, 'unit', family.unit);
130
186
  const what = row[MATERIAL_NUMBER] ?? '';
131
187
  return toolRecord({
188
+ brand: familyBrand(family),
132
189
  vendor: BRANDS[familyBrand(family)].vendor,
133
190
  materialNumber: what,
134
191
  catalogNumber: row[CATALOG_NUMBER] ?? '',
135
- description: row[CATALOG_NUMBER] ?? '',
192
+ productLine: productLine(row),
193
+ // Kennametal publishes no description column on any table. The catalog
194
+ // number is already on the record, and repeating it here said nothing —
195
+ // see `records.ToolRecord.description`.
196
+ description: '',
136
197
  kind: 'drill',
137
198
  unit,
138
199
  substrate: fact(family, 'bmc', family.bmc),
139
- grade: row['Grade'] ?? '',
140
- materialGroups: materialClasses(row[MATERIALS_COLUMN]),
200
+ coating: row[COATING] ?? '',
201
+ ...materialGroups(row),
141
202
  coolantThrough: fact(family, 'coolantThrough', family.coolantThrough),
142
203
  nonFerrous: fact(family, 'nonFerrous', family.nonFerrous),
143
204
  geometry: {
@@ -170,19 +231,22 @@ export function tapRecord(row, family, columns) {
170
231
  const tdz = row['D1-TDZ'] ?? '';
171
232
  const what = row[MATERIAL_NUMBER] ?? '';
172
233
  return toolRecord({
234
+ brand: familyBrand(family),
173
235
  vendor: BRANDS[familyBrand(family)].vendor,
174
236
  materialNumber: what,
175
237
  catalogNumber: row[CATALOG_NUMBER] ?? '',
176
- // The designation is part of what a tap *is*, and the catalog number alone
177
- // does not carry the size.
178
- description: `${row[CATALOG_NUMBER] ?? ''} ${tdz}`,
238
+ // The one Kennametal table that publishes per-part text: the thread
239
+ // designation is part of what a tap *is*, and the catalog number does not
240
+ // carry the size. The catalog number no longer leads it — it is already on
241
+ // the record, and a description that restates one is not a description.
242
+ description: tdz,
243
+ productLine: productLine(row),
179
244
  kind: 'tap',
180
245
  unit,
181
246
  substrate: fact(family, 'bmc', family.bmc),
182
- // A tap has no carbide grade; the record's grade carries the coating.
183
- grade: row['Coating'] ?? '',
184
- materialGroups: materialClasses(row[MATERIALS_COLUMN]),
185
- coolantThrough: false,
247
+ coating: row[COATING] ?? '',
248
+ ...materialGroups(row),
249
+ coolantThrough: fact(family, 'coolantThrough', family.coolantThrough),
186
250
  geometry: {
187
251
  DC: threadMajorDiameter(tdz, system),
188
252
  TP: require_(row, columns, 'TP', unit, what),
@@ -212,15 +276,18 @@ export function endmillRecord(row, family, columns) {
212
276
  const dc = require_(row, columns, 'DC', unit, what);
213
277
  const fluteLength = require_(row, columns, 'LCF', unit, what);
214
278
  return toolRecord({
279
+ brand: familyBrand(family),
215
280
  vendor: BRANDS[familyBrand(family)].vendor,
216
281
  materialNumber: what,
217
282
  catalogNumber: row[CATALOG_NUMBER] ?? '',
218
- description: row[CATALOG_NUMBER] ?? '',
283
+ // No description column here either. See the drill mapper.
284
+ description: '',
285
+ productLine: productLine(row),
219
286
  kind: 'endmill',
220
287
  unit,
221
288
  substrate: fact(family, 'bmc', family.bmc),
222
- grade: row['Grade'] ?? '',
223
- materialGroups: materialClasses(row[MATERIALS_COLUMN]),
289
+ coating: row[COATING] ?? '',
290
+ ...materialGroups(row),
224
291
  coolantThrough: fact(family, 'coolantThrough', family.coolantThrough),
225
292
  geometry: {
226
293
  DC: dc,
@@ -25,7 +25,7 @@
25
25
  */
26
26
  import type { Fetcher } from '../../fetch.js';
27
27
  import { type AemBrandName } from '../../identity.js';
28
- import type { ScrapeResult } from '../../scrape.js';
28
+ import { type ScrapeResult } from '../../scrape.js';
29
29
  export declare const BASE: string;
30
30
  /**
31
31
  * The Hybris/Solr facet string that scopes a request to a family's active
@@ -101,11 +101,34 @@ export declare function parseVariantTable(html: string): VariantTable;
101
101
  * data row.
102
102
  */
103
103
  export declare function columnNames(header: readonly Cell[]): (string | null)[];
104
+ /** What {@link scrapeFamily} accepts beyond its tags. */
105
+ export interface FamilyOptions {
106
+ /**
107
+ * Also read the family page's own title, and tag every row with it.
108
+ *
109
+ * **Opt-in, and a second request.** The variants table states no product
110
+ * line — the vendor puts it in the `h1` above the table, which is a
111
+ * different AEM resource — so this is the one thing here that costs a
112
+ * request the table did not. A caller that only wants dimensions should not
113
+ * pay for it, and every existing caller keeps the transport it had.
114
+ *
115
+ * See `family.ts` for what the title is and which part of it becomes
116
+ * `records.ToolRecord.productLine`.
117
+ */
118
+ readonly familyTitle?: boolean;
119
+ /** Milliseconds between the two requests. Zero in tests. */
120
+ readonly delayMs?: number;
121
+ }
104
122
  /**
105
123
  * Scrape one family into rows.
106
124
  *
107
125
  * `tags` is a sequence of `[name, value]` pairs appended to every row as
108
126
  * constant columns — used to tag facts the table doesn't state, e.g. the
109
127
  * thread system on a tap family.
128
+ *
129
+ * `options.familyTitle` adds two more of exactly that kind. They are tags
130
+ * rather than parsed columns because that is what they are: one string per
131
+ * family, constant down its whole table, which is the case the `tags` seam was
132
+ * built for.
110
133
  */
111
- export declare function scrapeFamily(fetcher: Fetcher, code: string, brand?: AemBrandName, tags?: readonly Tag[]): Promise<ScrapeResult>;
134
+ export declare function scrapeFamily(fetcher: Fetcher, code: string, brand?: AemBrandName, tags?: readonly Tag[], options?: FamilyOptions): Promise<ScrapeResult>;
@@ -24,8 +24,12 @@
24
24
  * characters.
25
25
  */
26
26
  import { Parser } from 'htmlparser2';
27
+ import { FAMILY_TITLE_COLUMN } from '../../conventions.js';
27
28
  import { VendorResponseError } from '../../errors.js';
28
29
  import { BRANDS } from '../../identity.js';
30
+ import { pause, REQUEST_DELAY_MS } from '../../scrape.js';
31
+ import { fetchFamily } from './family.js';
32
+ import { PRODUCT_LINE_COLUMN } from './records.js';
29
33
  export const BASE = 'https://www.{host}/us/en/products/fam/_jcr_content/root/' +
30
34
  'responsivegrid/{node}.variants.{code}.html' +
31
35
  '?query={query}&uom=metric';
@@ -194,16 +198,37 @@ export function columnNames(header) {
194
198
  * `tags` is a sequence of `[name, value]` pairs appended to every row as
195
199
  * constant columns — used to tag facts the table doesn't state, e.g. the
196
200
  * thread system on a tap family.
201
+ *
202
+ * `options.familyTitle` adds two more of exactly that kind. They are tags
203
+ * rather than parsed columns because that is what they are: one string per
204
+ * family, constant down its whole table, which is the case the `tags` seam was
205
+ * built for.
197
206
  */
198
- export async function scrapeFamily(fetcher, code, brand = 'kennametal', tags = []) {
207
+ export async function scrapeFamily(fetcher, code, brand = 'kennametal', tags = [], options = {}) {
199
208
  const url = variantsUrl(code, brand);
200
209
  const { header, rows: dataRows } = parseVariantTable(await fetcher.text(url));
201
210
  if (header === null) {
202
211
  throw new VendorResponseError(`family ${code}`, 'the vendor returned no variants');
203
212
  }
213
+ // After the table, so a family the vendor no longer publishes fails on the
214
+ // table it has no rows for rather than on a title nobody would have read.
215
+ const titled = [];
216
+ if (options.familyTitle === true) {
217
+ await pause(options.delayMs ?? REQUEST_DELAY_MS);
218
+ const { title, productLine } = await fetchFamily(fetcher, code, brand);
219
+ // A page with no heading writes no columns, rather than a column of empty
220
+ // strings that reads as a vendor stating an empty name. `unionHeader` is
221
+ // not in play here — this header is positional — so an absent tag is
222
+ // simply a narrower CSV.
223
+ if (title !== '')
224
+ titled.push([FAMILY_TITLE_COLUMN, title]);
225
+ if (productLine !== null)
226
+ titled.push([PRODUCT_LINE_COLUMN, productLine]);
227
+ }
204
228
  const names = columnNames(header);
205
229
  const kept = names.filter((name) => name !== null);
206
- const csvHeader = [...kept, ...tags.map(([name]) => name)];
230
+ const allTags = [...tags, ...titled];
231
+ const csvHeader = [...kept, ...allTags.map(([name]) => name)];
207
232
  const rows = dataRows.map((row) => {
208
233
  // `dataRows` is filtered to rows exactly as long as the header, and
209
234
  // `names` has one entry per header cell, so a length mismatch here means
@@ -218,7 +243,7 @@ export async function scrapeFamily(fetcher, code, brand = 'kennametal', tags = [
218
243
  if (name !== null)
219
244
  out[name] = row[index]?.[0] ?? '';
220
245
  });
221
- for (const [name, value] of tags)
246
+ for (const [name, value] of allTags)
222
247
  out[name] = value;
223
248
  return out;
224
249
  });