@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.
- package/README.md +41 -11
- package/dist/columns.d.ts +64 -0
- package/dist/columns.js +68 -0
- package/dist/conventions.d.ts +107 -15
- package/dist/conventions.js +126 -15
- package/dist/errors.d.ts +30 -0
- package/dist/errors.js +30 -0
- package/dist/families/emuge.d.ts +185 -0
- package/dist/families/emuge.js +163 -0
- package/dist/families/harvey.d.ts +1782 -0
- package/dist/families/harvey.js +1328 -0
- package/dist/families/index.js +5 -2
- package/dist/families/kennametal.d.ts +21 -0
- package/dist/families/kennametal.js +10 -0
- package/dist/families/maritool.d.ts +120 -0
- package/dist/families/maritool.js +175 -0
- package/dist/family.d.ts +35 -1
- package/dist/family.js +28 -0
- package/dist/identity.d.ts +18 -0
- package/dist/identity.js +46 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +16 -0
- package/dist/measure.d.ts +112 -0
- package/dist/measure.js +130 -0
- package/dist/node/cli.d.ts +3 -0
- package/dist/node/cli.js +158 -3
- package/dist/node/main.js +0 -0
- package/dist/records.d.ts +214 -12
- package/dist/records.js +140 -5
- package/dist/registry.d.ts +56 -1
- package/dist/registry.js +79 -2
- package/dist/scrape.d.ts +15 -0
- package/dist/scrape.js +24 -0
- package/dist/vendors/destinytool/records.d.ts +13 -3
- package/dist/vendors/destinytool/records.js +49 -37
- package/dist/vendors/emuge/index.d.ts +17 -0
- package/dist/vendors/emuge/index.js +17 -0
- package/dist/vendors/emuge/records.d.ts +230 -0
- package/dist/vendors/emuge/records.js +543 -0
- package/dist/vendors/emuge/scrape.d.ts +227 -0
- package/dist/vendors/emuge/scrape.js +358 -0
- package/dist/vendors/emuge/value.d.ts +75 -0
- package/dist/vendors/emuge/value.js +116 -0
- package/dist/vendors/harvey/catalog.d.ts +53 -0
- package/dist/vendors/harvey/catalog.js +120 -0
- package/dist/vendors/harvey/header.d.ts +89 -0
- package/dist/vendors/harvey/header.js +185 -0
- package/dist/vendors/harvey/index.d.ts +21 -0
- package/dist/vendors/harvey/index.js +21 -0
- package/dist/vendors/harvey/lexicon.d.ts +73 -0
- package/dist/vendors/harvey/lexicon.js +126 -0
- package/dist/vendors/harvey/literal.d.ts +68 -0
- package/dist/vendors/harvey/literal.js +214 -0
- package/dist/vendors/harvey/records.d.ts +79 -0
- package/dist/vendors/harvey/records.js +163 -0
- package/dist/vendors/harvey/scrape.d.ts +187 -0
- package/dist/vendors/harvey/scrape.js +483 -0
- package/dist/vendors/harvey/value.d.ts +74 -0
- package/dist/vendors/harvey/value.js +119 -0
- package/dist/vendors/kennametal/family.d.ts +119 -0
- package/dist/vendors/kennametal/family.js +155 -0
- package/dist/vendors/kennametal/index.d.ts +1 -0
- package/dist/vendors/kennametal/index.js +1 -0
- package/dist/vendors/kennametal/records.d.ts +29 -4
- package/dist/vendors/kennametal/records.js +93 -26
- package/dist/vendors/kennametal/scrape.d.ts +25 -2
- package/dist/vendors/kennametal/scrape.js +28 -3
- package/dist/vendors/maritool/catalog.d.ts +81 -0
- package/dist/vendors/maritool/catalog.js +132 -0
- package/dist/vendors/maritool/index.d.ts +10 -0
- package/dist/vendors/maritool/index.js +10 -0
- package/dist/vendors/maritool/scrape.d.ts +297 -0
- package/dist/vendors/maritool/scrape.js +593 -0
- package/dist/vendors/regofix/scrape.d.ts +8 -11
- package/dist/vendors/regofix/scrape.js +21 -36
- 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 `°`, `Ø` and `™` in their text,
|
|
84
|
+
* and a `KenCut™ 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 `°`, `Ø` and `™` in their text,
|
|
83
|
+
* and a `KenCut™ 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,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
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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 {
|
|
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
|
-
/**
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
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
|
-
|
|
140
|
-
materialGroups
|
|
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
|
|
177
|
-
//
|
|
178
|
-
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
|
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
|
-
|
|
223
|
-
materialGroups
|
|
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
|
|
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
|
|
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
|
|
246
|
+
for (const [name, value] of allTags)
|
|
222
247
|
out[name] = value;
|
|
223
248
|
return out;
|
|
224
249
|
});
|