@toolpath/tool-scraper 0.1.0 → 2.0.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 +62 -0
- package/dist/columns.js +62 -0
- package/dist/conventions.d.ts +89 -15
- package/dist/conventions.js +108 -15
- package/dist/families/emuge.d.ts +183 -0
- package/dist/families/emuge.js +159 -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 +152 -2
- package/dist/node/main.js +0 -0
- package/dist/records.d.ts +169 -12
- package/dist/records.js +116 -5
- package/dist/registry.d.ts +31 -1
- package/dist/registry.js +40 -1
- 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 +41 -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 +150 -0
- package/dist/vendors/emuge/records.js +375 -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/records.d.ts +13 -4
- package/dist/vendors/kennametal/records.js +61 -26
- 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,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One Harvey Tool product page -> one row per orderable part.
|
|
3
|
+
*
|
|
4
|
+
* The page is server-rendered HTML with its variant data inlined as a
|
|
5
|
+
* JavaScript literal; `docs/HARVEY_PRODUCT_TABLE.md` records how that was found
|
|
6
|
+
* and what was tried first. This module is the join between the two halves — a
|
|
7
|
+
* `<thead>` in the DOM and a `tableData<N>` in a `<script>` — plus the one
|
|
8
|
+
* structural thing Harvey's table does that no other vendor here does.
|
|
9
|
+
*
|
|
10
|
+
* ## One HTML row is up to nine orderable parts
|
|
11
|
+
*
|
|
12
|
+
* The table is a matrix. The `a*` cells are the geometry, shared; after them
|
|
13
|
+
* comes a coating × flute grid where each coating is a `colspan`-ed header and
|
|
14
|
+
* every non-empty cell under it is a different tool number at the same geometry.
|
|
15
|
+
* **5,033 HTML rows become 12,799 CSV rows**, and that explosion is the whole
|
|
16
|
+
* risk in this adapter: a coating or flute count read off the wrong column
|
|
17
|
+
* produces a CSV that is the right length and wrong throughout.
|
|
18
|
+
*
|
|
19
|
+
* ## Harvey checks it for us, 5,033 times per scrape
|
|
20
|
+
*
|
|
21
|
+
* Every row carries an `atc` cell whose `j` is the add-to-cart payload, and that
|
|
22
|
+
* payload independently lists every tool number on the row **in the same order
|
|
23
|
+
* as the non-empty tool-number cells**:
|
|
24
|
+
*
|
|
25
|
+
* ```json
|
|
26
|
+
* [{"T":"690508","C":"690508","Q":"1"},{"T":"679608","C":"679608","Q":"1"}]
|
|
27
|
+
* ```
|
|
28
|
+
*
|
|
29
|
+
* So the explosion is checked against the vendor's own list on every row rather
|
|
30
|
+
* than once against a hand count, and a grid that has shifted by one column
|
|
31
|
+
* cannot pass. That check is {@link checkCartPayload}, and it is the reason the
|
|
32
|
+
* declared `rows` counts in `families/harvey.ts` being seeded rather than
|
|
33
|
+
* hand-counted is survivable.
|
|
34
|
+
*
|
|
35
|
+
* ## Two columns are synthesised, and neither has a cell to copy
|
|
36
|
+
*
|
|
37
|
+
* `Coating` is a fact about which *header* a tool number sits under, and
|
|
38
|
+
* `FLUTES` is too on the matrix tables. Both are encoded in column position and
|
|
39
|
+
* nowhere else, which is why they are built here rather than lifted, and why the
|
|
40
|
+
* coating vocabulary is a closed list in `lexicon.ts`.
|
|
41
|
+
*
|
|
42
|
+
* `FLUTES` is one column either way — synthesised from the sub-label on a matrix
|
|
43
|
+
* table and lifted from the row's own column on a `TOOL #` one — so the vendor's
|
|
44
|
+
* `FLUTES` column is taken out of the geometry list rather than written twice.
|
|
45
|
+
*
|
|
46
|
+
* Everything else in the CSV is the vendor's own display string, verbatim —
|
|
47
|
+
* `.250 (1/4)` reaches the file as `.250 (1/4)`. `value.ts` is what resolves one
|
|
48
|
+
* when a record is built.
|
|
49
|
+
*/
|
|
50
|
+
import { type UnitSystem } from '../../conventions.js';
|
|
51
|
+
import type { Fetcher } from '../../fetch.js';
|
|
52
|
+
import { type ScrapeResult, type Warn } from '../../scrape.js';
|
|
53
|
+
import { type HeaderColumn } from './header.js';
|
|
54
|
+
export declare const BASE = "https://www.harveytool.com";
|
|
55
|
+
/** `cols1`..`cols10` and `tableData1`..`tableData10` exist on every page. */
|
|
56
|
+
export declare const MAX_TABLES = 10;
|
|
57
|
+
/** The CSV columns this adapter synthesises or lifts from the page itself. */
|
|
58
|
+
export declare const TOOL_NUMBER_COLUMN = "Tool #";
|
|
59
|
+
export declare const COATING_COLUMN = "Coating";
|
|
60
|
+
export declare const FLUTES_COLUMN = "FLUTES";
|
|
61
|
+
export declare const PRICE_COLUMN = "PRICE_USD";
|
|
62
|
+
/**
|
|
63
|
+
* The suffix an unlabelled annotation column takes, after the column it
|
|
64
|
+
* annotates.
|
|
65
|
+
*
|
|
66
|
+
* Harvey gives these no header at all — see `docs/HARVEY_PRODUCT_TABLE.md` §5.3
|
|
67
|
+
* — so a name has to be supplied, and naming one for what it annotates is the
|
|
68
|
+
* only honest option available. Dropping them instead would lose a published
|
|
69
|
+
* column; leaving them unnamed would collide with the column they follow.
|
|
70
|
+
*/
|
|
71
|
+
export declare const RATIO_SUFFIX = "RATIO";
|
|
72
|
+
/**
|
|
73
|
+
* The suffix an unlabelled column takes when it is *not* a ratio.
|
|
74
|
+
*
|
|
75
|
+
* Four such columns exist, on two families, and Harvey renders them in white
|
|
76
|
+
* text: two carry the badge `LONG` on a handful of rows and two are entirely
|
|
77
|
+
* empty. Naming them `RATIO` would be a claim about their contents that is
|
|
78
|
+
* false, so which suffix a column gets is decided from the whole page's data
|
|
79
|
+
* rather than from its header — see {@link ratioColumnKeys}.
|
|
80
|
+
*/
|
|
81
|
+
export declare const NOTE_SUFFIX = "NOTE";
|
|
82
|
+
/** One cell of a `tableData<N>` row. `d` is the display HTML — the value. */
|
|
83
|
+
interface Cell {
|
|
84
|
+
d?: string | null;
|
|
85
|
+
j?: string | null;
|
|
86
|
+
}
|
|
87
|
+
type DataRow = Record<string, Cell | undefined>;
|
|
88
|
+
/** One geometry column: which key holds it, and what the CSV calls it. */
|
|
89
|
+
interface GeometryColumn {
|
|
90
|
+
key: string;
|
|
91
|
+
/** The CSV column, unit suffix already applied where the label carries one. */
|
|
92
|
+
column: string;
|
|
93
|
+
/** Harvey's own top label, before disambiguation. */
|
|
94
|
+
label: string;
|
|
95
|
+
}
|
|
96
|
+
/** One coating group: its tool-number columns, and the price they share. */
|
|
97
|
+
interface CoatingGroup {
|
|
98
|
+
coating: string;
|
|
99
|
+
parts: {
|
|
100
|
+
key: string;
|
|
101
|
+
flutes: number | null;
|
|
102
|
+
}[];
|
|
103
|
+
/** Every group on all 80 tables has exactly one, and it is last. */
|
|
104
|
+
priceKey: string;
|
|
105
|
+
}
|
|
106
|
+
/** One table's shape, worked out once and reused for all of its rows. */
|
|
107
|
+
interface TablePlan {
|
|
108
|
+
geometry: GeometryColumn[];
|
|
109
|
+
/** The `FLUTES` column's key, where the table publishes one. */
|
|
110
|
+
flutesKey: string | null;
|
|
111
|
+
/** True where any group states a flute count in its own sub-label. */
|
|
112
|
+
matrix: boolean;
|
|
113
|
+
groups: CoatingGroup[];
|
|
114
|
+
}
|
|
115
|
+
/** `<a href="...">14916</a>` -> `14916`. These cells carry no entities. */
|
|
116
|
+
export declare function cellText(html: string | null | undefined): string;
|
|
117
|
+
/** `$148.40 ` -> `148.40`. The column already says which currency. */
|
|
118
|
+
export declare function priceOf(html: string | null | undefined): string;
|
|
119
|
+
/** Read one table's plan off its `cols<N>` keys and its flattened header. */
|
|
120
|
+
export declare function planTable(family: string, keys: string[], header: HeaderColumn[], unit: UnitSystem, options?: {
|
|
121
|
+
warn?: Warn;
|
|
122
|
+
ratioKeys?: ReadonlySet<string>;
|
|
123
|
+
}): TablePlan;
|
|
124
|
+
/** One entry of a row's add-to-cart payload. */
|
|
125
|
+
export interface CartEntry {
|
|
126
|
+
/** The part number with no footnote marker — what the CSV records. */
|
|
127
|
+
number: string;
|
|
128
|
+
/** The same string as printed in the cell, footnote marker and all. */
|
|
129
|
+
printed: string;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Every part the row's own add-to-cart payload lists, in cell order.
|
|
133
|
+
*
|
|
134
|
+
* `T` and `C` differ on the 36 parts whose printed number carries a footnote
|
|
135
|
+
* marker: `C` keeps it, `T` does not. Both are read, and each does a different
|
|
136
|
+
* job — `C` is what the cell says and is therefore what the cell can be checked
|
|
137
|
+
* against, `T` is Harvey's own clean part number and is therefore what the CSV
|
|
138
|
+
* records. Deriving the second from the first with a regex would work today and
|
|
139
|
+
* be this package's guess rather than the vendor's statement.
|
|
140
|
+
*
|
|
141
|
+
* `Q` is `"1"` on all 12,799 entries and is not read.
|
|
142
|
+
*/
|
|
143
|
+
export declare function cartEntries(family: string, row: DataRow): CartEntry[];
|
|
144
|
+
/** Refuse an explosion the vendor's own payload does not agree with. */
|
|
145
|
+
export declare function checkCartPayload(family: string, printed: string[], cart: CartEntry[]): void;
|
|
146
|
+
/**
|
|
147
|
+
* The unlabelled columns of a page whose cells are all ratio annotations.
|
|
148
|
+
*
|
|
149
|
+
* Decided across the whole page rather than per table, because the tables merge
|
|
150
|
+
* into one CSV and a column cannot be named two things. `EndMill-006` is why: a
|
|
151
|
+
* column is `LONG` on four rows of its first table and empty throughout its
|
|
152
|
+
* second.
|
|
153
|
+
*/
|
|
154
|
+
export declare function ratioColumnKeys(tables: {
|
|
155
|
+
keys: string[];
|
|
156
|
+
rows: DataRow[];
|
|
157
|
+
header: HeaderColumn[];
|
|
158
|
+
}[]): Set<string>;
|
|
159
|
+
/** Options a page scrape accepts. */
|
|
160
|
+
export interface ScrapeOptions {
|
|
161
|
+
/** The unit system the family is published in. Decides every `_mm`/`_in`. */
|
|
162
|
+
unit: UnitSystem;
|
|
163
|
+
warn?: Warn;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* One product page's HTML -> one row per orderable part.
|
|
167
|
+
*
|
|
168
|
+
* Pure, and separate from {@link scrapeProduct} so the whole matrix explosion is
|
|
169
|
+
* testable from a fixture string with no fetcher anywhere near it.
|
|
170
|
+
*
|
|
171
|
+
* A page's tables differ only in published tolerance — which is dropped — so
|
|
172
|
+
* they merge into one CSV. Their column labels are required to agree, because
|
|
173
|
+
* two tables that no longer publish the same columns is a page that has been
|
|
174
|
+
* restructured, and merging them would interleave two different geometries under
|
|
175
|
+
* one header.
|
|
176
|
+
*/
|
|
177
|
+
export declare function parseProductPage(html: string, source: string, options: ScrapeOptions): ScrapeResult;
|
|
178
|
+
/** One product page's URL. */
|
|
179
|
+
export declare function productUrl(path: string): string;
|
|
180
|
+
/**
|
|
181
|
+
* Scrape one product page.
|
|
182
|
+
*
|
|
183
|
+
* The one network call in this module, and the seam every test replaces —
|
|
184
|
+
* everything below it parses a string.
|
|
185
|
+
*/
|
|
186
|
+
export declare function scrapeProduct(fetcher: Fetcher, path: string, options: ScrapeOptions): Promise<ScrapeResult>;
|
|
187
|
+
export {};
|
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One Harvey Tool product page -> one row per orderable part.
|
|
3
|
+
*
|
|
4
|
+
* The page is server-rendered HTML with its variant data inlined as a
|
|
5
|
+
* JavaScript literal; `docs/HARVEY_PRODUCT_TABLE.md` records how that was found
|
|
6
|
+
* and what was tried first. This module is the join between the two halves — a
|
|
7
|
+
* `<thead>` in the DOM and a `tableData<N>` in a `<script>` — plus the one
|
|
8
|
+
* structural thing Harvey's table does that no other vendor here does.
|
|
9
|
+
*
|
|
10
|
+
* ## One HTML row is up to nine orderable parts
|
|
11
|
+
*
|
|
12
|
+
* The table is a matrix. The `a*` cells are the geometry, shared; after them
|
|
13
|
+
* comes a coating × flute grid where each coating is a `colspan`-ed header and
|
|
14
|
+
* every non-empty cell under it is a different tool number at the same geometry.
|
|
15
|
+
* **5,033 HTML rows become 12,799 CSV rows**, and that explosion is the whole
|
|
16
|
+
* risk in this adapter: a coating or flute count read off the wrong column
|
|
17
|
+
* produces a CSV that is the right length and wrong throughout.
|
|
18
|
+
*
|
|
19
|
+
* ## Harvey checks it for us, 5,033 times per scrape
|
|
20
|
+
*
|
|
21
|
+
* Every row carries an `atc` cell whose `j` is the add-to-cart payload, and that
|
|
22
|
+
* payload independently lists every tool number on the row **in the same order
|
|
23
|
+
* as the non-empty tool-number cells**:
|
|
24
|
+
*
|
|
25
|
+
* ```json
|
|
26
|
+
* [{"T":"690508","C":"690508","Q":"1"},{"T":"679608","C":"679608","Q":"1"}]
|
|
27
|
+
* ```
|
|
28
|
+
*
|
|
29
|
+
* So the explosion is checked against the vendor's own list on every row rather
|
|
30
|
+
* than once against a hand count, and a grid that has shifted by one column
|
|
31
|
+
* cannot pass. That check is {@link checkCartPayload}, and it is the reason the
|
|
32
|
+
* declared `rows` counts in `families/harvey.ts` being seeded rather than
|
|
33
|
+
* hand-counted is survivable.
|
|
34
|
+
*
|
|
35
|
+
* ## Two columns are synthesised, and neither has a cell to copy
|
|
36
|
+
*
|
|
37
|
+
* `Coating` is a fact about which *header* a tool number sits under, and
|
|
38
|
+
* `FLUTES` is too on the matrix tables. Both are encoded in column position and
|
|
39
|
+
* nowhere else, which is why they are built here rather than lifted, and why the
|
|
40
|
+
* coating vocabulary is a closed list in `lexicon.ts`.
|
|
41
|
+
*
|
|
42
|
+
* `FLUTES` is one column either way — synthesised from the sub-label on a matrix
|
|
43
|
+
* table and lifted from the row's own column on a `TOOL #` one — so the vendor's
|
|
44
|
+
* `FLUTES` column is taken out of the geometry list rather than written twice.
|
|
45
|
+
*
|
|
46
|
+
* Everything else in the CSV is the vendor's own display string, verbatim —
|
|
47
|
+
* `.250 (1/4)` reaches the file as `.250 (1/4)`. `value.ts` is what resolves one
|
|
48
|
+
* when a record is built.
|
|
49
|
+
*/
|
|
50
|
+
import { CAD_COLUMN, CAD_DXF_COLUMN, DESCRIPTION_COLUMN, dimensionalColumn, } from '../../conventions.js';
|
|
51
|
+
import { VendorResponseError } from '../../errors.js';
|
|
52
|
+
import { consoleWarn } from '../../scrape.js';
|
|
53
|
+
import { flatHeader, isJunkLabel } from './header.js';
|
|
54
|
+
import { readLiteral } from './literal.js';
|
|
55
|
+
import { parseValue } from './value.js';
|
|
56
|
+
import { CART_LABEL, FLUTES_LABEL, PRICE_LABEL, TOOL_NUMBER_LABEL, checkCoating, flutesInLabel, isDimensional, } from './lexicon.js';
|
|
57
|
+
export const BASE = 'https://www.harveytool.com';
|
|
58
|
+
/** `cols1`..`cols10` and `tableData1`..`tableData10` exist on every page. */
|
|
59
|
+
export const MAX_TABLES = 10;
|
|
60
|
+
/** The CSV columns this adapter synthesises or lifts from the page itself. */
|
|
61
|
+
export const TOOL_NUMBER_COLUMN = 'Tool #';
|
|
62
|
+
export const COATING_COLUMN = 'Coating';
|
|
63
|
+
export const FLUTES_COLUMN = 'FLUTES';
|
|
64
|
+
export const PRICE_COLUMN = 'PRICE_USD';
|
|
65
|
+
/**
|
|
66
|
+
* The suffix an unlabelled annotation column takes, after the column it
|
|
67
|
+
* annotates.
|
|
68
|
+
*
|
|
69
|
+
* Harvey gives these no header at all — see `docs/HARVEY_PRODUCT_TABLE.md` §5.3
|
|
70
|
+
* — so a name has to be supplied, and naming one for what it annotates is the
|
|
71
|
+
* only honest option available. Dropping them instead would lose a published
|
|
72
|
+
* column; leaving them unnamed would collide with the column they follow.
|
|
73
|
+
*/
|
|
74
|
+
export const RATIO_SUFFIX = 'RATIO';
|
|
75
|
+
/**
|
|
76
|
+
* The suffix an unlabelled column takes when it is *not* a ratio.
|
|
77
|
+
*
|
|
78
|
+
* Four such columns exist, on two families, and Harvey renders them in white
|
|
79
|
+
* text: two carry the badge `LONG` on a handful of rows and two are entirely
|
|
80
|
+
* empty. Naming them `RATIO` would be a claim about their contents that is
|
|
81
|
+
* false, so which suffix a column gets is decided from the whole page's data
|
|
82
|
+
* rather than from its header — see {@link ratioColumnKeys}.
|
|
83
|
+
*/
|
|
84
|
+
export const NOTE_SUFFIX = 'NOTE';
|
|
85
|
+
/** True where the cell carries a link, i.e. where it names a real part. */
|
|
86
|
+
const LINKED = /<a\b[^>]*\bhref\s*=/i;
|
|
87
|
+
/** `<a href="...">14916</a>` -> `14916`. These cells carry no entities. */
|
|
88
|
+
export function cellText(html) {
|
|
89
|
+
return (html ?? '')
|
|
90
|
+
.replace(/<[^>]*>/g, '')
|
|
91
|
+
.replace(/\s+/g, ' ')
|
|
92
|
+
.trim();
|
|
93
|
+
}
|
|
94
|
+
/** `$148.40 ` -> `148.40`. The column already says which currency. */
|
|
95
|
+
export function priceOf(html) {
|
|
96
|
+
return cellText(html).replace(/^\$/, '').trim();
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* The CSV column each geometry column is written under.
|
|
100
|
+
*
|
|
101
|
+
* Three cases, and the second and third are the ones worth knowing:
|
|
102
|
+
*
|
|
103
|
+
* 1. **A label of its own** keeps it.
|
|
104
|
+
* 2. **A repeated label with distinct meaningful sub-labels** takes the
|
|
105
|
+
* sub-label — `Interference Depth At Wall Angle*` is six real columns
|
|
106
|
+
* headed `0°` through `4°`, not one column and five annotations.
|
|
107
|
+
* 3. **A repeated label with a junk sub-label**, or a column whose top label is
|
|
108
|
+
* junk outright, is the vendor's ratio annotation of the column before it and
|
|
109
|
+
* takes {@link RATIO_SUFFIX}.
|
|
110
|
+
*/
|
|
111
|
+
function geometryColumns(family, keys, header, unit, ratioKeys, warn) {
|
|
112
|
+
const positions = keys
|
|
113
|
+
.map((key, index) => ({ key, head: header[index] }))
|
|
114
|
+
.filter(({ key, head }) => key !== 'atc' && /^a\d+$/.test(key) && head.top !== CART_LABEL)
|
|
115
|
+
// The flute count leaves the geometry list and comes back as one
|
|
116
|
+
// synthesised `FLUTES` column, because half these tables state it in a
|
|
117
|
+
// column and half in a coating group's sub-label. Keeping both would put
|
|
118
|
+
// two columns called `FLUTES` in one CSV.
|
|
119
|
+
.filter(({ head }) => head.top !== FLUTES_LABEL);
|
|
120
|
+
const byLabel = new Map();
|
|
121
|
+
for (const { head } of positions) {
|
|
122
|
+
if (isJunkLabel(head.top))
|
|
123
|
+
continue;
|
|
124
|
+
byLabel.set(head.top, [...(byLabel.get(head.top) ?? []), head]);
|
|
125
|
+
}
|
|
126
|
+
/** True where every occurrence of this label has its own real sub-label. */
|
|
127
|
+
const subLabelled = new Map();
|
|
128
|
+
for (const [label, heads] of byLabel) {
|
|
129
|
+
const subs = heads.map((h) => h.sub ?? '');
|
|
130
|
+
subLabelled.set(label, heads.length > 1 && subs.every((s) => !isJunkLabel(s)) && new Set(subs).size === subs.length);
|
|
131
|
+
}
|
|
132
|
+
const columns = [];
|
|
133
|
+
const seen = new Set();
|
|
134
|
+
for (const { key, head } of positions) {
|
|
135
|
+
let label;
|
|
136
|
+
let dimensional;
|
|
137
|
+
if (isJunkLabel(head.top)) {
|
|
138
|
+
const previous = columns[columns.length - 1];
|
|
139
|
+
if (previous === undefined) {
|
|
140
|
+
throw new VendorResponseError(family, `opens with an unlabelled column (${key}) — there is nothing for it to annotate`);
|
|
141
|
+
}
|
|
142
|
+
label = `${previous.label} ${ratioKeys.has(key) ? RATIO_SUFFIX : NOTE_SUFFIX}`;
|
|
143
|
+
dimensional = false;
|
|
144
|
+
warn(` WARNING: ${family}: column ${key} has no header of its own; ` +
|
|
145
|
+
`written as ${JSON.stringify(label)}`);
|
|
146
|
+
}
|
|
147
|
+
else if (subLabelled.get(head.top) === true) {
|
|
148
|
+
label = `${head.top} ${head.sub}`;
|
|
149
|
+
dimensional = isDimensional(family, head.top);
|
|
150
|
+
}
|
|
151
|
+
else if (seen.has(head.top)) {
|
|
152
|
+
label = `${head.top} ${RATIO_SUFFIX}`;
|
|
153
|
+
dimensional = false;
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
label = head.top;
|
|
157
|
+
dimensional = isDimensional(family, head.top);
|
|
158
|
+
}
|
|
159
|
+
seen.add(head.top);
|
|
160
|
+
const column = dimensional ? dimensionalColumn(label, unit) : label;
|
|
161
|
+
if (columns.some((c) => c.column === column)) {
|
|
162
|
+
throw new VendorResponseError(family, `has two columns both written as ${JSON.stringify(column)} — the ` +
|
|
163
|
+
`header no longer tells them apart`);
|
|
164
|
+
}
|
|
165
|
+
columns.push({ key, column, label });
|
|
166
|
+
}
|
|
167
|
+
return columns;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* The coating groups of one table, with each part column's flute count.
|
|
171
|
+
*
|
|
172
|
+
* A group runs from its first tool-number column to its price column. Every
|
|
173
|
+
* group on all 80 tables has exactly one price and it comes last, so a group
|
|
174
|
+
* that reaches its end without one is refused rather than given a blank price:
|
|
175
|
+
* the grid would have shifted, and every part in it would be reading a
|
|
176
|
+
* neighbour's cell.
|
|
177
|
+
*/
|
|
178
|
+
function coatingGroups(family, keys, header, warn) {
|
|
179
|
+
const groups = [];
|
|
180
|
+
let coating = null;
|
|
181
|
+
let parts = [];
|
|
182
|
+
for (const [index, key] of keys.entries()) {
|
|
183
|
+
const head = header[index];
|
|
184
|
+
const sub = head.sub ?? '';
|
|
185
|
+
if (/^s\d+$/.test(key)) {
|
|
186
|
+
if (coating === null) {
|
|
187
|
+
coating = checkCoating(family, head.top);
|
|
188
|
+
parts = [];
|
|
189
|
+
}
|
|
190
|
+
else if (coating !== head.top) {
|
|
191
|
+
throw new VendorResponseError(family, `starts coating group ${JSON.stringify(head.top)} before ` +
|
|
192
|
+
`${JSON.stringify(coating)} has a ${PRICE_LABEL} column`);
|
|
193
|
+
}
|
|
194
|
+
parts.push({ key, flutes: sub === TOOL_NUMBER_LABEL ? null : flutesInLabel(sub) });
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
if (/^p\d+$/.test(key)) {
|
|
198
|
+
if (coating === null) {
|
|
199
|
+
throw new VendorResponseError(family, `has a price column (${key}) under no coating group`);
|
|
200
|
+
}
|
|
201
|
+
if (sub !== PRICE_LABEL) {
|
|
202
|
+
throw new VendorResponseError(family, `has a price column (${key}) sub-headed ${JSON.stringify(sub)} rather ` +
|
|
203
|
+
`than ${JSON.stringify(PRICE_LABEL)}`);
|
|
204
|
+
}
|
|
205
|
+
groups.push({ coating, parts, priceKey: key });
|
|
206
|
+
coating = null;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
if (coating !== null) {
|
|
210
|
+
throw new VendorResponseError(family, `leaves coating group ${JSON.stringify(coating)} without a price column`);
|
|
211
|
+
}
|
|
212
|
+
return fillBlankFluteLabels(family, groups, warn);
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Fill a flute sub-label Harvey rendered as ` ` from a sibling group.
|
|
216
|
+
*
|
|
217
|
+
* Two tables in the catalog do this — `EndMill-008` table 2 and `EndMill-018`
|
|
218
|
+
* table 2 — and in both the neighbouring coating groups label the same slot
|
|
219
|
+
* correctly. A sibling qualifies only when it is the same width *and* agrees on
|
|
220
|
+
* every other slot, so the fill is a copy of a row that is demonstrably the same
|
|
221
|
+
* shape rather than of whichever group happened to be nearby. Two siblings that
|
|
222
|
+
* disagree is a hard failure: there is no way to pick, and picking would put a
|
|
223
|
+
* flute count on a real part on no evidence.
|
|
224
|
+
*/
|
|
225
|
+
function fillBlankFluteLabels(family, groups, warn) {
|
|
226
|
+
return groups.map((group) => {
|
|
227
|
+
// All stated is the matrix pattern with nothing missing; none stated is the
|
|
228
|
+
// `TOOL #` pattern, where the row's own FLUTES column is the source and a
|
|
229
|
+
// null here is not a gap. Only a mixture is a blank label to fill.
|
|
230
|
+
const stated = group.parts.filter((p) => p.flutes !== null).length;
|
|
231
|
+
if (stated === 0 || stated === group.parts.length)
|
|
232
|
+
return group;
|
|
233
|
+
const parts = group.parts.map((part, slot) => {
|
|
234
|
+
if (part.flutes !== null)
|
|
235
|
+
return part;
|
|
236
|
+
const candidates = new Set();
|
|
237
|
+
for (const other of groups) {
|
|
238
|
+
if (other === group || other.parts.length !== group.parts.length)
|
|
239
|
+
continue;
|
|
240
|
+
const agrees = group.parts.every((p, i) => i === slot || p.flutes === null || p.flutes === other.parts[i]?.flutes);
|
|
241
|
+
const found = other.parts[slot]?.flutes;
|
|
242
|
+
if (agrees && found != null)
|
|
243
|
+
candidates.add(found);
|
|
244
|
+
}
|
|
245
|
+
if (candidates.size !== 1) {
|
|
246
|
+
throw new VendorResponseError(family, `has a blank flute label in the ${JSON.stringify(group.coating)} group ` +
|
|
247
|
+
`at slot ${slot}, and ${candidates.size === 0 ? 'no' : 'more than one'} ` +
|
|
248
|
+
`sibling group of the same shape states one`);
|
|
249
|
+
}
|
|
250
|
+
const flutes = [...candidates][0];
|
|
251
|
+
warn(` WARNING: ${family}: the ${JSON.stringify(group.coating)} group's flute ` +
|
|
252
|
+
`label at slot ${slot} is blank; read as ${flutes} from a sibling group`);
|
|
253
|
+
return { ...part, flutes };
|
|
254
|
+
});
|
|
255
|
+
return { ...group, parts };
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
/** Read one table's plan off its `cols<N>` keys and its flattened header. */
|
|
259
|
+
export function planTable(family, keys, header, unit, options = {}) {
|
|
260
|
+
const { warn = consoleWarn, ratioKeys = new Set() } = options;
|
|
261
|
+
if (keys.length !== header.length) {
|
|
262
|
+
throw new VendorResponseError(family, `declares ${keys.length} data columns but its header flattens to ` +
|
|
263
|
+
`${header.length} — the two are positional and no longer line up`);
|
|
264
|
+
}
|
|
265
|
+
const geometry = geometryColumns(family, keys, header, unit, ratioKeys, warn);
|
|
266
|
+
const groups = coatingGroups(family, keys, header, warn);
|
|
267
|
+
if (groups.length === 0) {
|
|
268
|
+
throw new VendorResponseError(family, 'publishes no coating group — there are no parts to read');
|
|
269
|
+
}
|
|
270
|
+
const flutesIndex = keys.findIndex((key, i) => /^a\d+$/.test(key) && header[i].top === FLUTES_LABEL);
|
|
271
|
+
return {
|
|
272
|
+
geometry,
|
|
273
|
+
flutesKey: flutesIndex === -1 ? null : keys[flutesIndex],
|
|
274
|
+
matrix: groups.some((g) => g.parts.some((p) => p.flutes !== null)),
|
|
275
|
+
groups,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Every part the row's own add-to-cart payload lists, in cell order.
|
|
280
|
+
*
|
|
281
|
+
* `T` and `C` differ on the 36 parts whose printed number carries a footnote
|
|
282
|
+
* marker: `C` keeps it, `T` does not. Both are read, and each does a different
|
|
283
|
+
* job — `C` is what the cell says and is therefore what the cell can be checked
|
|
284
|
+
* against, `T` is Harvey's own clean part number and is therefore what the CSV
|
|
285
|
+
* records. Deriving the second from the first with a regex would work today and
|
|
286
|
+
* be this package's guess rather than the vendor's statement.
|
|
287
|
+
*
|
|
288
|
+
* `Q` is `"1"` on all 12,799 entries and is not read.
|
|
289
|
+
*/
|
|
290
|
+
export function cartEntries(family, row) {
|
|
291
|
+
const payload = row['atc']?.j;
|
|
292
|
+
if (typeof payload !== 'string' || payload === '') {
|
|
293
|
+
throw new VendorResponseError(family, 'has a row with no add-to-cart payload to check against');
|
|
294
|
+
}
|
|
295
|
+
let parsed;
|
|
296
|
+
try {
|
|
297
|
+
parsed = JSON.parse(payload);
|
|
298
|
+
}
|
|
299
|
+
catch (error) {
|
|
300
|
+
throw new VendorResponseError(family, `has an unreadable add-to-cart payload: ${error.message}`);
|
|
301
|
+
}
|
|
302
|
+
return parsed.map((entry) => ({ number: entry.T ?? '', printed: entry.C ?? entry.T ?? '' }));
|
|
303
|
+
}
|
|
304
|
+
/** Refuse an explosion the vendor's own payload does not agree with. */
|
|
305
|
+
export function checkCartPayload(family, printed, cart) {
|
|
306
|
+
if (printed.length === cart.length && printed.every((t, i) => t === cart[i].printed))
|
|
307
|
+
return;
|
|
308
|
+
throw new VendorResponseError(family, `read [${printed.join(', ')}] off a row whose own add-to-cart payload ` +
|
|
309
|
+
`lists [${cart.map((entry) => entry.printed).join(', ')}] — the coating ` +
|
|
310
|
+
`grid has shifted`);
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* The unlabelled columns of a page whose cells are all ratio annotations.
|
|
314
|
+
*
|
|
315
|
+
* Decided across the whole page rather than per table, because the tables merge
|
|
316
|
+
* into one CSV and a column cannot be named two things. `EndMill-006` is why: a
|
|
317
|
+
* column is `LONG` on four rows of its first table and empty throughout its
|
|
318
|
+
* second.
|
|
319
|
+
*/
|
|
320
|
+
export function ratioColumnKeys(tables) {
|
|
321
|
+
const unlabelled = new Set();
|
|
322
|
+
const other = new Set();
|
|
323
|
+
for (const table of tables) {
|
|
324
|
+
table.keys.forEach((key, index) => {
|
|
325
|
+
const head = table.header[index];
|
|
326
|
+
if (head === undefined || !/^a\d+$/.test(key) || !isJunkLabel(head.top))
|
|
327
|
+
return;
|
|
328
|
+
unlabelled.add(key);
|
|
329
|
+
for (const row of table.rows) {
|
|
330
|
+
const text = cellText(row[key]?.d);
|
|
331
|
+
if (text !== '' && parseValue(text).ratio === null)
|
|
332
|
+
other.add(key);
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
for (const key of other)
|
|
337
|
+
unlabelled.delete(key);
|
|
338
|
+
return unlabelled;
|
|
339
|
+
}
|
|
340
|
+
function readPage(html, what) {
|
|
341
|
+
const model = readLiteral(html, 'viewModel')?.simFileViewModel;
|
|
342
|
+
const productCode = model?.productCode;
|
|
343
|
+
if (!productCode) {
|
|
344
|
+
throw new VendorResponseError(what, 'has no viewModel.simFileViewModel.productCode');
|
|
345
|
+
}
|
|
346
|
+
const cad = new Map();
|
|
347
|
+
for (const variant of model.variantSimFileViewModel ?? []) {
|
|
348
|
+
if (variant.variantName)
|
|
349
|
+
cad.set(variant.variantName, variant);
|
|
350
|
+
}
|
|
351
|
+
// Table ids drop the `HT-` the product code carries: `HT-Harvey-EndMill-008`
|
|
352
|
+
// heads a table with `id="Harvey-EndMill-008_1"`.
|
|
353
|
+
const prefix = productCode.replace(/^HT-/, '');
|
|
354
|
+
const tables = [];
|
|
355
|
+
for (let n = 1; n <= MAX_TABLES; n++) {
|
|
356
|
+
const keys = (readLiteral(html, `cols${n}`) ?? [])
|
|
357
|
+
.map((column) => column.data ?? '')
|
|
358
|
+
.filter(Boolean);
|
|
359
|
+
if (keys.length === 0)
|
|
360
|
+
continue;
|
|
361
|
+
const rows = readLiteral(html, `tableData${n}`) ?? [];
|
|
362
|
+
tables.push({ keys, rows, header: flatHeader(html, `${prefix}_${n}`) });
|
|
363
|
+
}
|
|
364
|
+
if (tables.length === 0) {
|
|
365
|
+
throw new VendorResponseError(what, 'declares no populated table — the page changed shape');
|
|
366
|
+
}
|
|
367
|
+
return { productCode, productTitle: model.productTitle ?? '', cad, tables };
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* One product page's HTML -> one row per orderable part.
|
|
371
|
+
*
|
|
372
|
+
* Pure, and separate from {@link scrapeProduct} so the whole matrix explosion is
|
|
373
|
+
* testable from a fixture string with no fetcher anywhere near it.
|
|
374
|
+
*
|
|
375
|
+
* A page's tables differ only in published tolerance — which is dropped — so
|
|
376
|
+
* they merge into one CSV. Their column labels are required to agree, because
|
|
377
|
+
* two tables that no longer publish the same columns is a page that has been
|
|
378
|
+
* restructured, and merging them would interleave two different geometries under
|
|
379
|
+
* one header.
|
|
380
|
+
*/
|
|
381
|
+
export function parseProductPage(html, source, options) {
|
|
382
|
+
const { unit, warn = consoleWarn } = options;
|
|
383
|
+
const page = readPage(html, source);
|
|
384
|
+
const family = page.productCode;
|
|
385
|
+
const ratioKeys = ratioColumnKeys(page.tables);
|
|
386
|
+
const plans = page.tables.map((table) => planTable(family, table.keys, table.header, unit, { warn, ratioKeys }));
|
|
387
|
+
const geometry = plans[0].geometry.map((column) => column.column);
|
|
388
|
+
for (const plan of plans.slice(1)) {
|
|
389
|
+
const other = plan.geometry.map((column) => column.column);
|
|
390
|
+
if (other.length !== geometry.length || other.some((c, i) => c !== geometry[i])) {
|
|
391
|
+
throw new VendorResponseError(family, `publishes [${geometry.join(', ')}] on one table and [${other.join(', ')}] ` +
|
|
392
|
+
`on another — the page's tables no longer share a header`);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
const flutes = plans.some((plan) => plan.matrix || plan.flutesKey !== null);
|
|
396
|
+
const header = [
|
|
397
|
+
TOOL_NUMBER_COLUMN,
|
|
398
|
+
DESCRIPTION_COLUMN,
|
|
399
|
+
COATING_COLUMN,
|
|
400
|
+
...(flutes ? [FLUTES_COLUMN] : []),
|
|
401
|
+
...geometry,
|
|
402
|
+
PRICE_COLUMN,
|
|
403
|
+
CAD_COLUMN,
|
|
404
|
+
CAD_DXF_COLUMN,
|
|
405
|
+
];
|
|
406
|
+
const rows = [];
|
|
407
|
+
page.tables.forEach((table, index) => {
|
|
408
|
+
const plan = plans[index];
|
|
409
|
+
for (const source_ of table.rows) {
|
|
410
|
+
const shared = {};
|
|
411
|
+
for (const column of plan.geometry) {
|
|
412
|
+
shared[column.column] = cellText(source_[column.key]?.d);
|
|
413
|
+
}
|
|
414
|
+
const rowFlutes = plan.flutesKey === null ? '' : cellText(source_[plan.flutesKey]?.d);
|
|
415
|
+
// Two lists, and the split is the point. `printed` is every non-empty
|
|
416
|
+
// tool-number cell, in order, and is what the row's own add-to-cart
|
|
417
|
+
// payload is checked against. `parts` is the subset that is a real part.
|
|
418
|
+
const printed = [];
|
|
419
|
+
const parts = [];
|
|
420
|
+
for (const group of plan.groups) {
|
|
421
|
+
const price = priceOf(source_[group.priceKey]?.d);
|
|
422
|
+
for (const part of group.parts) {
|
|
423
|
+
const cell = source_[part.key]?.d ?? '';
|
|
424
|
+
const text = cellText(cell);
|
|
425
|
+
if (text === '')
|
|
426
|
+
continue;
|
|
427
|
+
const slot = printed.length;
|
|
428
|
+
printed.push(text);
|
|
429
|
+
if (!LINKED.test(cell)) {
|
|
430
|
+
// 26 cells across two families carry the marketing string
|
|
431
|
+
// `25x Diameter!` in a tool-number column, rendered in red, with no
|
|
432
|
+
// link and no CAD model — and Harvey's own cart payload lists them
|
|
433
|
+
// as though they were orderable. The link is the structural
|
|
434
|
+
// difference; a rule that matched the text would miss the next one.
|
|
435
|
+
warn(` WARNING: ${family}: the ${JSON.stringify(group.coating)} column ` +
|
|
436
|
+
`holds ${JSON.stringify(text)} with no product link — not a part`);
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
439
|
+
// The sub-label is per part and the FLUTES column is per row, so the
|
|
440
|
+
// sub-label wins where both exist. They agree on all four tables that
|
|
441
|
+
// publish both; a disagreement is Harvey having changed one of them.
|
|
442
|
+
const stated = part.flutes === null ? null : String(part.flutes);
|
|
443
|
+
if (stated !== null && rowFlutes !== '' && stated !== rowFlutes) {
|
|
444
|
+
warn(` WARNING: ${family}: ${text} sits under a ${stated}-flute header ` +
|
|
445
|
+
`on a row whose FLUTES column says ${rowFlutes}`);
|
|
446
|
+
}
|
|
447
|
+
parts.push({ slot, coating: group.coating, price, flutes: stated ?? rowFlutes });
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
const cart = cartEntries(family, source_);
|
|
451
|
+
checkCartPayload(family, printed, cart);
|
|
452
|
+
for (const part of parts) {
|
|
453
|
+
const toolNumber = cart[part.slot].number;
|
|
454
|
+
const variant = page.cad.get(toolNumber);
|
|
455
|
+
rows.push({
|
|
456
|
+
...shared,
|
|
457
|
+
[TOOL_NUMBER_COLUMN]: toolNumber,
|
|
458
|
+
[DESCRIPTION_COLUMN]: page.productTitle,
|
|
459
|
+
[COATING_COLUMN]: part.coating,
|
|
460
|
+
...(flutes ? { [FLUTES_COLUMN]: part.flutes } : {}),
|
|
461
|
+
[PRICE_COLUMN]: part.price,
|
|
462
|
+
[CAD_COLUMN]: variant?.variantStepFileLink ?? '',
|
|
463
|
+
[CAD_DXF_COLUMN]: variant?.variantDxfFileLink ?? '',
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
});
|
|
468
|
+
return { header, rows, source, familyCode: page.productCode };
|
|
469
|
+
}
|
|
470
|
+
/** One product page's URL. */
|
|
471
|
+
export function productUrl(path) {
|
|
472
|
+
return BASE + path;
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* Scrape one product page.
|
|
476
|
+
*
|
|
477
|
+
* The one network call in this module, and the seam every test replaces —
|
|
478
|
+
* everything below it parses a string.
|
|
479
|
+
*/
|
|
480
|
+
export async function scrapeProduct(fetcher, path, options) {
|
|
481
|
+
const url = productUrl(path);
|
|
482
|
+
return parseProductPage(await fetcher.text(url), url, options);
|
|
483
|
+
}
|