@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,163 @@
1
+ /**
2
+ * Harvey rows -> {@link ToolRecord}.
3
+ *
4
+ * The CSV a Harvey scrape writes holds the vendor's own **display strings** —
5
+ * `.250 (1/4)`, `1-1/2`, `3 mm`, `-` — rather than parsed numbers, because the
6
+ * file is the receipt and Harvey's own fractional and metric annotations are
7
+ * part of what it published. So this module is where a cell becomes a number,
8
+ * through `value.ts`; the closest precedent in this package is Destiny Tool's
9
+ * fractional-inch reader, not Kennametal's, whose columns are already decimals.
10
+ *
11
+ * ## What Harvey does not publish
12
+ *
13
+ * **No second identifier.** One `Tool #` per part, which fills both the material
14
+ * number and the catalog number on a record — see
15
+ * `conventions.IDENTITY_DEVIATIONS.harvey`.
16
+ *
17
+ * **No carbide grade.** `substrate` comes from the family's `bmc` fact and the
18
+ * `COATING` column fills `coating`, exactly as Destiny Tool's coating id does.
19
+ *
20
+ * **No workpiece-material index a scrape can reach.** Nothing in a variant
21
+ * table rates a tool to ISO 513 groups, so every Harvey record is labelled
22
+ * `records.UNSPECIFIED` — *we do not know what this tool is for*, which is a
23
+ * different claim from Kennametal's swept taps being rated for nothing.
24
+ *
25
+ * The per-part page does publish one, and it is **per part and not per family**:
26
+ * a 192-request probe on 2026-08-29 found `harvey_endmill_005.csv` splitting by
27
+ * coating column, uncoated tools rated for steel, stainless, cast iron and
28
+ * titanium where the amorphous-diamond-coated tools of the same geometry are
29
+ * rated for aluminium, wood and composites alone. That is correct metallurgy —
30
+ * diamond cannot cut ferrous — and flattening it to one answer per family would
31
+ * put a diamond-coated end mill under steel. So there is no family fact to
32
+ * write, and the two keyseat pages titled for a material class do not get one
33
+ * either: the "For Non - Ferrous Materials" line's own part pages rate it for
34
+ * steel and stainless steel, so the title does not predict the index.
35
+ * `docs/HARVEY_PRODUCT_TABLE.md` §1.5 has the measurement and what reaching it
36
+ * would cost.
37
+ *
38
+ * **No corner radius on a ball nose.** A ball family publishes no radius column
39
+ * at all, so `RE` comes from the family's `profile` fact — see
40
+ * {@link cornerRadius}. That is a per-family constant with provenance rather
41
+ * than a string match on a description, because Harvey states the profile once,
42
+ * in the page title, for the whole product line.
43
+ */
44
+ import { columnReaders } from '../../columns.js';
45
+ import { DESCRIPTION_COLUMN } from '../../conventions.js';
46
+ import { VendorResponseError } from '../../errors.js';
47
+ import { fact, familyBrand } from '../../family.js';
48
+ import { BRANDS } from '../../identity.js';
49
+ import { toolRecord } from '../../records.js';
50
+ import { consoleWarn } from '../../scrape.js';
51
+ import { COATING_COLUMN, TOOL_NUMBER_COLUMN } from './scrape.js';
52
+ import { count, dimension } from './value.js';
53
+ /** The `profile` fact value that means a ball nose. Harvey's own word. */
54
+ export const BALL_PROFILE = 'Ball';
55
+ /**
56
+ * The family's declared unit, refused rather than asserted when it is absent.
57
+ *
58
+ * Every Harvey family declares one — the `harvey` CLI command will not scrape a
59
+ * family without it — so `familyUnits` would return exactly this one and a
60
+ * tap's two-system case cannot arise here. It was `family.unit!` until
61
+ * 2026-08-29, which is the same claim with no check behind it: a family added
62
+ * without the fact would have read `undefined` straight into
63
+ * `dimensionalColumn` and asked the CSV for a column called `CUTTER DIA._`.
64
+ */
65
+ function unitOf(family) {
66
+ return fact(family, 'unit', family.unit);
67
+ }
68
+ /**
69
+ * The three column readers, over this vendor's grammar.
70
+ *
71
+ * `dimension` is the only Harvey-specific half; everything either side of it —
72
+ * an unmapped column answering undefined, a required field refusing the row and
73
+ * quoting the cell — is `columns.columnReaders`, shared with EMUGE-FRANKEN's
74
+ * mapper. All three take the caller's `columns` rather than `family.columns`:
75
+ * they are the same object through `registry.toRecords`, but `registry`
76
+ * validates the argument, and a mapper reading a different reference is
77
+ * validating one map and reading another. This mapper asserted the parameter
78
+ * was unused with an underscore until 2026-08-29.
79
+ */
80
+ const { cell, required, optional } = columnReaders(dimension);
81
+ /**
82
+ * The corner radius, in priority order.
83
+ *
84
+ * 1. The family's own radius column where it has one — `CORNER RADIUS` on the
85
+ * corner-radius lines, `RADIUS` on the full-radius keyseat cutters.
86
+ * 2. `DC / 2` on a ball nose. Harvey publishes no radius column on any of its
87
+ * twelve ball families, and the radius of a ball end *is* half the diameter,
88
+ * so this is arithmetic rather than a guess — the `profile` fact is what says
89
+ * the family is one.
90
+ * 3. `0` — a real square end — otherwise.
91
+ *
92
+ * A mapped column whose cell is blank falls through to 2 or 3 rather than
93
+ * refusing the row: `RE` is optional on the endmill contract precisely because
94
+ * a square-end row's blank radius is an answer.
95
+ */
96
+ export function cornerRadius(row, family, columns, what, dc, options) {
97
+ const stated = optional(row, columns, 'RE', unitOf(family), what, options);
98
+ if (stated !== null)
99
+ return stated;
100
+ return family.profile === BALL_PROFILE ? dc / 2 : 0;
101
+ }
102
+ /**
103
+ * The flute count.
104
+ *
105
+ * One column whichever way the table encoded it: `vendors/harvey/scrape.ts`
106
+ * writes `FLUTES` from the row's own column on a `TOOL #` table and from the
107
+ * coating group's sub-label on a matrix one, so nothing downstream has to know
108
+ * which shape the page used.
109
+ *
110
+ * Null is a real answer on the two deburring families, which publish
111
+ * right- and left-hand tooth counts and no flute count at all.
112
+ */
113
+ export function flutes(row, family, columns) {
114
+ const raw = cell(row, columns, 'NOF', unitOf(family));
115
+ return raw === undefined ? null : count(raw);
116
+ }
117
+ /** One orderable Harvey tool. */
118
+ export function endmillRecord(row, family, columns, options = {}) {
119
+ const warn = options.warn ?? consoleWarn;
120
+ const what = row[TOOL_NUMBER_COLUMN] ?? '';
121
+ if (what === '') {
122
+ throw new VendorResponseError(family.id, `has a row with no ${TOOL_NUMBER_COLUMN}`);
123
+ }
124
+ const opts = { warn };
125
+ const unit = unitOf(family);
126
+ const dc = required(row, columns, 'DC', unit, what, opts);
127
+ const fluteLength = required(row, columns, 'LCF', unit, what, opts);
128
+ // Harvey's reach columns are the distance from the tip to the full shank,
129
+ // which is what `shoulder-length` names. A family with no reach column is a
130
+ // plain tool whose usable length below the shank is its flute length — the
131
+ // same convention Destiny Tool's mapper uses.
132
+ const reach = optional(row, columns, 'shoulder-length', unit, what, opts);
133
+ const neck = optional(row, columns, 'shoulder-diameter', unit, what, opts);
134
+ const geometry = {
135
+ DC: dc,
136
+ RE: cornerRadius(row, family, columns, what, dc, opts),
137
+ SFDM: required(row, columns, 'SFDM', unit, what, opts),
138
+ OAL: required(row, columns, 'OAL', unit, what, opts),
139
+ LCF: fluteLength,
140
+ 'shoulder-length': reach ?? fluteLength,
141
+ 'shoulder-diameter': neck ?? dc,
142
+ };
143
+ const nof = flutes(row, family, columns);
144
+ if (nof !== null)
145
+ geometry.NOF = nof;
146
+ return toolRecord({
147
+ brand: familyBrand(family),
148
+ vendor: BRANDS[familyBrand(family)].vendor,
149
+ materialNumber: what,
150
+ catalogNumber: what,
151
+ description: row[DESCRIPTION_COLUMN] ?? '',
152
+ kind: 'endmill',
153
+ unit,
154
+ substrate: fact(family, 'bmc', family.bmc),
155
+ coating: row[COATING_COLUMN] ?? '',
156
+ // `materialGroups` and its source are left to the factory's `null`: see the
157
+ // module docstring for what a Harvey part page publishes and why none of it
158
+ // can be stated per family.
159
+ coolantThrough: fact(family, 'coolantThrough', family.coolantThrough),
160
+ geometry,
161
+ });
162
+ }
163
+ export const RECORD_MAPPERS = { endmill: endmillRecord };
@@ -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 {};