@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.
Files changed (68) hide show
  1. package/README.md +41 -11
  2. package/dist/columns.d.ts +62 -0
  3. package/dist/columns.js +62 -0
  4. package/dist/conventions.d.ts +89 -15
  5. package/dist/conventions.js +108 -15
  6. package/dist/families/emuge.d.ts +183 -0
  7. package/dist/families/emuge.js +159 -0
  8. package/dist/families/harvey.d.ts +1782 -0
  9. package/dist/families/harvey.js +1328 -0
  10. package/dist/families/index.js +5 -2
  11. package/dist/families/kennametal.d.ts +21 -0
  12. package/dist/families/kennametal.js +10 -0
  13. package/dist/families/maritool.d.ts +120 -0
  14. package/dist/families/maritool.js +175 -0
  15. package/dist/family.d.ts +35 -1
  16. package/dist/family.js +28 -0
  17. package/dist/identity.d.ts +18 -0
  18. package/dist/identity.js +46 -0
  19. package/dist/index.d.ts +16 -0
  20. package/dist/index.js +16 -0
  21. package/dist/measure.d.ts +112 -0
  22. package/dist/measure.js +130 -0
  23. package/dist/node/cli.d.ts +3 -0
  24. package/dist/node/cli.js +152 -2
  25. package/dist/node/main.js +0 -0
  26. package/dist/records.d.ts +169 -12
  27. package/dist/records.js +116 -5
  28. package/dist/registry.d.ts +31 -1
  29. package/dist/registry.js +40 -1
  30. package/dist/scrape.d.ts +15 -0
  31. package/dist/scrape.js +24 -0
  32. package/dist/vendors/destinytool/records.d.ts +13 -3
  33. package/dist/vendors/destinytool/records.js +41 -37
  34. package/dist/vendors/emuge/index.d.ts +17 -0
  35. package/dist/vendors/emuge/index.js +17 -0
  36. package/dist/vendors/emuge/records.d.ts +150 -0
  37. package/dist/vendors/emuge/records.js +375 -0
  38. package/dist/vendors/emuge/scrape.d.ts +227 -0
  39. package/dist/vendors/emuge/scrape.js +358 -0
  40. package/dist/vendors/emuge/value.d.ts +75 -0
  41. package/dist/vendors/emuge/value.js +116 -0
  42. package/dist/vendors/harvey/catalog.d.ts +53 -0
  43. package/dist/vendors/harvey/catalog.js +120 -0
  44. package/dist/vendors/harvey/header.d.ts +89 -0
  45. package/dist/vendors/harvey/header.js +185 -0
  46. package/dist/vendors/harvey/index.d.ts +21 -0
  47. package/dist/vendors/harvey/index.js +21 -0
  48. package/dist/vendors/harvey/lexicon.d.ts +73 -0
  49. package/dist/vendors/harvey/lexicon.js +126 -0
  50. package/dist/vendors/harvey/literal.d.ts +68 -0
  51. package/dist/vendors/harvey/literal.js +214 -0
  52. package/dist/vendors/harvey/records.d.ts +79 -0
  53. package/dist/vendors/harvey/records.js +163 -0
  54. package/dist/vendors/harvey/scrape.d.ts +187 -0
  55. package/dist/vendors/harvey/scrape.js +483 -0
  56. package/dist/vendors/harvey/value.d.ts +74 -0
  57. package/dist/vendors/harvey/value.js +119 -0
  58. package/dist/vendors/kennametal/records.d.ts +13 -4
  59. package/dist/vendors/kennametal/records.js +61 -26
  60. package/dist/vendors/maritool/catalog.d.ts +81 -0
  61. package/dist/vendors/maritool/catalog.js +132 -0
  62. package/dist/vendors/maritool/index.d.ts +10 -0
  63. package/dist/vendors/maritool/index.js +10 -0
  64. package/dist/vendors/maritool/scrape.d.ts +297 -0
  65. package/dist/vendors/maritool/scrape.js +593 -0
  66. package/dist/vendors/regofix/scrape.d.ts +8 -11
  67. package/dist/vendors/regofix/scrape.js +21 -36
  68. package/package.json +31 -7
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Reading a machinist's number, and the one constant between the two systems.
3
+ *
4
+ * Three vendors publish dimensions the way a printed catalog does — `.250`,
5
+ * `3/4`, `1-1/2` — and until this module each adapter had its own reader for
6
+ * that grammar. They disagreed on real inputs: REGO-FIX's `parseSize` handled
7
+ * no mixed number at all, so `1-1/2` reached `Number()` as `NaN` and was
8
+ * refused; Destiny Tool's `parseFractionInches` branched on a `.` first, so
9
+ * `1.5-1/2` was refused there and read as 2 by Harvey's. And they disagreed on
10
+ * *how* they refused — two threw `RangeError`, one answered `null`.
11
+ *
12
+ * That is the same shape `fetch.createFetcher` and `scrape.pause` were pulled
13
+ * out for, and the same argument: four copies of four lines is not expensive,
14
+ * four copies of the **decisions** in them is, because the decisions are the
15
+ * part that has to be consistent. A dimension string is the last thing in this
16
+ * package that should mean two things.
17
+ *
18
+ * ## The reader answers `null`; the caller decides what that costs
19
+ *
20
+ * {@link fractionValue} is the primitive, and it never throws: whether an
21
+ * unreadable cell is a skipped row, a warning or a refusal is a vendor's call,
22
+ * and every adapter here already makes it differently for good reasons. Harvey
23
+ * skips a `-` cell because the column does not apply to that row; REGO-FIX
24
+ * refuses, because a collet with no size is not a part. Pushing that decision
25
+ * into the reader would take it away from the module that knows.
26
+ *
27
+ * ## The two decisions about a *read* cell live here too
28
+ *
29
+ * {@link fractionValue} answers what a token says. {@link asLength} and
30
+ * {@link asCount} answer what that costs, and they are core for the same reason
31
+ * the grammar is: a cell stating `mm` in a family published in inches is
32
+ * converted-and-warned, and a cell stating an angle in a length column is
33
+ * refused, and those two calls have to be the same call whoever is being
34
+ * scraped. They were a verbatim copy in `vendors/harvey/value.ts` and
35
+ * `vendors/emuge/value.ts` — down to the wording of both warnings — which is
36
+ * two operators reading the same sentence out of two files that were free to
37
+ * drift apart.
38
+ *
39
+ * A vendor's own reader still owns the **grammar**, because that genuinely
40
+ * differs: Harvey writes a mixed number `1-1/2` and annotates it `(1/8)`, EMUGE
41
+ * writes `1 1/2` and admits no hyphen at all. What it hands here is
42
+ * {@link Measured}, which is the part they have in common.
43
+ *
44
+ * **`MM_PER_INCH` lives here and not in a vendor.** It was declared and
45
+ * exported twice — `vendors/harvey/value.ts` and `vendors/regofix/scrape.ts`
46
+ * — so `@toolpath/tool-scraper/vendors/harvey` and `.../vendors/regofix` each
47
+ * published their own copy of 25.4. That is exactly what `conventions.CAD_COLUMN`
48
+ * was moved up for, and what `tests/vendor-boundary.test.ts` now refuses by name.
49
+ */
50
+ import type { UnitSystem } from './conventions.js';
51
+ import { type Warn } from './scrape.js';
52
+ /** Exact by definition: the inch has been 25.4 mm since 1959. */
53
+ export declare const MM_PER_INCH = 25.4;
54
+ /**
55
+ * `.250` -> 0.25, `3/4` -> 0.75, `1-1/2` -> 1.5, `2` -> 2.
56
+ *
57
+ * Null where the token is not one of those four shapes — including a division
58
+ * by zero, which arrives finite-looking as `Infinity` and would otherwise
59
+ * travel into a row as a dimension.
60
+ *
61
+ * Unit-free on purpose: what system the number is in is the caller's, from the
62
+ * family's declared `unit` or from a unit the cell states outright. A reader
63
+ * that guessed would be the mistake `conventions.UNIT_SUFFIX` exists to prevent.
64
+ */
65
+ export declare function fractionValue(token: string): number | null;
66
+ /** `value`, converted from `from` to `to`. A no-op when they agree. */
67
+ export declare function convertLength(value: number, from: UnitSystem, to: UnitSystem): number;
68
+ /**
69
+ * What a value states about itself: a length system, or degrees.
70
+ *
71
+ * Degrees are inside this union rather than beside it because a cell states one
72
+ * thing about itself — "this is an angle" competes with "this is millimetres"
73
+ * and is never both. `vendors/harvey/value.ts` carries the same fact as a
74
+ * separate `degrees` boolean, which is that vendor's published shape and stays.
75
+ */
76
+ export type StatedUnit = UnitSystem | 'degrees';
77
+ /**
78
+ * One cell as a vendor's own reader left it.
79
+ *
80
+ * The part every vendor's reader has in common, and all {@link asLength} and
81
+ * {@link asCount} need. A reader is free to answer more — Harvey's also carries
82
+ * the parenthesised annotation, the `(1.5x)` ratio and the non-numeric code —
83
+ * and anything with these two fields is accepted here.
84
+ */
85
+ export interface Measured {
86
+ /** The number the cell states, in {@link Measured.stated}. Null where none. */
87
+ readonly value: number | null;
88
+ /** The unit the cell names outright. Null where it names none. */
89
+ readonly stated: StatedUnit | null;
90
+ }
91
+ /**
92
+ * A read cell as a length in `unit`, or null where it publishes none.
93
+ *
94
+ * A cell that states its own unit and disagrees with the family's is
95
+ * **converted and warned about** rather than refused: the value is right, the
96
+ * column's suffix is right, and the parts this happens on are real tools
97
+ * somebody can order. Refusing would drop them; trusting the column's unit
98
+ * would publish a 3-inch shank.
99
+ *
100
+ * An angle reaching a dimensional column is **refused**, because that is a
101
+ * header that has moved rather than a value that is odd.
102
+ *
103
+ * `display` is passed beside `parsed` only so the warnings can quote the cell
104
+ * the vendor really wrote, which is the part an operator needs to go look at.
105
+ */
106
+ export declare function asLength(parsed: Measured, display: string, unit: UnitSystem, what: string, warn?: Warn): number | null;
107
+ /**
108
+ * A read cell as a whole count — a flute or tooth number. Null where there is
109
+ * none, and null for a fraction, which is a column that has moved rather than a
110
+ * tool with two and a half flutes.
111
+ */
112
+ export declare function asCount(parsed: Measured): number | null;
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Reading a machinist's number, and the one constant between the two systems.
3
+ *
4
+ * Three vendors publish dimensions the way a printed catalog does — `.250`,
5
+ * `3/4`, `1-1/2` — and until this module each adapter had its own reader for
6
+ * that grammar. They disagreed on real inputs: REGO-FIX's `parseSize` handled
7
+ * no mixed number at all, so `1-1/2` reached `Number()` as `NaN` and was
8
+ * refused; Destiny Tool's `parseFractionInches` branched on a `.` first, so
9
+ * `1.5-1/2` was refused there and read as 2 by Harvey's. And they disagreed on
10
+ * *how* they refused — two threw `RangeError`, one answered `null`.
11
+ *
12
+ * That is the same shape `fetch.createFetcher` and `scrape.pause` were pulled
13
+ * out for, and the same argument: four copies of four lines is not expensive,
14
+ * four copies of the **decisions** in them is, because the decisions are the
15
+ * part that has to be consistent. A dimension string is the last thing in this
16
+ * package that should mean two things.
17
+ *
18
+ * ## The reader answers `null`; the caller decides what that costs
19
+ *
20
+ * {@link fractionValue} is the primitive, and it never throws: whether an
21
+ * unreadable cell is a skipped row, a warning or a refusal is a vendor's call,
22
+ * and every adapter here already makes it differently for good reasons. Harvey
23
+ * skips a `-` cell because the column does not apply to that row; REGO-FIX
24
+ * refuses, because a collet with no size is not a part. Pushing that decision
25
+ * into the reader would take it away from the module that knows.
26
+ *
27
+ * ## The two decisions about a *read* cell live here too
28
+ *
29
+ * {@link fractionValue} answers what a token says. {@link asLength} and
30
+ * {@link asCount} answer what that costs, and they are core for the same reason
31
+ * the grammar is: a cell stating `mm` in a family published in inches is
32
+ * converted-and-warned, and a cell stating an angle in a length column is
33
+ * refused, and those two calls have to be the same call whoever is being
34
+ * scraped. They were a verbatim copy in `vendors/harvey/value.ts` and
35
+ * `vendors/emuge/value.ts` — down to the wording of both warnings — which is
36
+ * two operators reading the same sentence out of two files that were free to
37
+ * drift apart.
38
+ *
39
+ * A vendor's own reader still owns the **grammar**, because that genuinely
40
+ * differs: Harvey writes a mixed number `1-1/2` and annotates it `(1/8)`, EMUGE
41
+ * writes `1 1/2` and admits no hyphen at all. What it hands here is
42
+ * {@link Measured}, which is the part they have in common.
43
+ *
44
+ * **`MM_PER_INCH` lives here and not in a vendor.** It was declared and
45
+ * exported twice — `vendors/harvey/value.ts` and `vendors/regofix/scrape.ts`
46
+ * — so `@toolpath/tool-scraper/vendors/harvey` and `.../vendors/regofix` each
47
+ * published their own copy of 25.4. That is exactly what `conventions.CAD_COLUMN`
48
+ * was moved up for, and what `tests/vendor-boundary.test.ts` now refuses by name.
49
+ */
50
+ import { consoleWarn } from './scrape.js';
51
+ /** Exact by definition: the inch has been 25.4 mm since 1959. */
52
+ export const MM_PER_INCH = 25.4;
53
+ /**
54
+ * A decimal, a simple fraction or a mixed number — and nothing else.
55
+ *
56
+ * Deliberately strict, because it is used without a pre-guard. The whole-number
57
+ * part of a mixed number is `\d+` rather than a decimal so that a **range** like
58
+ * `.035-.040` — which Destiny Tool really does publish, in description text —
59
+ * cannot parse as `0.035 + 0.040`. A range is not a measurement, and reading one
60
+ * as a sum is the kind of quiet wrong number this package exists to avoid.
61
+ */
62
+ const NUMERIC = /^(?:(\d+)-)?(\d*\.?\d+)(?:\/(\d*\.?\d+))?$/;
63
+ /**
64
+ * `.250` -> 0.25, `3/4` -> 0.75, `1-1/2` -> 1.5, `2` -> 2.
65
+ *
66
+ * Null where the token is not one of those four shapes — including a division
67
+ * by zero, which arrives finite-looking as `Infinity` and would otherwise
68
+ * travel into a row as a dimension.
69
+ *
70
+ * Unit-free on purpose: what system the number is in is the caller's, from the
71
+ * family's declared `unit` or from a unit the cell states outright. A reader
72
+ * that guessed would be the mistake `conventions.UNIT_SUFFIX` exists to prevent.
73
+ */
74
+ export function fractionValue(token) {
75
+ const parsed = NUMERIC.exec(token.trim());
76
+ if (parsed === null)
77
+ return null;
78
+ const [, whole, numerator, denominator] = parsed;
79
+ const part = denominator === undefined ? Number(numerator) : Number(numerator) / Number(denominator);
80
+ const value = (whole === undefined ? 0 : Number(whole)) + part;
81
+ return Number.isFinite(value) ? value : null;
82
+ }
83
+ /** `value`, converted from `from` to `to`. A no-op when they agree. */
84
+ export function convertLength(value, from, to) {
85
+ if (from === to)
86
+ return value;
87
+ return to === 'inches' ? value / MM_PER_INCH : value * MM_PER_INCH;
88
+ }
89
+ /**
90
+ * A read cell as a length in `unit`, or null where it publishes none.
91
+ *
92
+ * A cell that states its own unit and disagrees with the family's is
93
+ * **converted and warned about** rather than refused: the value is right, the
94
+ * column's suffix is right, and the parts this happens on are real tools
95
+ * somebody can order. Refusing would drop them; trusting the column's unit
96
+ * would publish a 3-inch shank.
97
+ *
98
+ * An angle reaching a dimensional column is **refused**, because that is a
99
+ * header that has moved rather than a value that is odd.
100
+ *
101
+ * `display` is passed beside `parsed` only so the warnings can quote the cell
102
+ * the vendor really wrote, which is the part an operator needs to go look at.
103
+ */
104
+ export function asLength(parsed, display, unit, what, warn = consoleWarn) {
105
+ const { value, stated } = parsed;
106
+ if (value === null)
107
+ return null;
108
+ if (stated === 'degrees') {
109
+ warn(` WARNING: ${what}: ${JSON.stringify(display)} is an angle in a column ` +
110
+ `read as a length — skipped`);
111
+ return null;
112
+ }
113
+ if (stated !== null && stated !== unit) {
114
+ warn(` WARNING: ${what}: ${JSON.stringify(display)} states ${stated} in a ` +
115
+ `family published in ${unit} — converted`);
116
+ return convertLength(value, stated, unit);
117
+ }
118
+ return value;
119
+ }
120
+ /**
121
+ * A read cell as a whole count — a flute or tooth number. Null where there is
122
+ * none, and null for a fraction, which is a column that has moved rather than a
123
+ * tool with two and a half flutes.
124
+ */
125
+ export function asCount(parsed) {
126
+ const { value } = parsed;
127
+ if (value === null || !Number.isInteger(value))
128
+ return null;
129
+ return value;
130
+ }
@@ -5,6 +5,9 @@
5
5
  * toolpath-scrape kennametal family page -> CSV
6
6
  * toolpath-scrape regofix ProductFinder index -> toolholding CSV
7
7
  * toolpath-scrape destinytool Firestore products -> End Mill CSV
8
+ * toolpath-scrape harvey one product page -> CSV
9
+ * toolpath-scrape maritool leaf categories -> toolholding CSV
10
+ * toolpath-scrape emuge one catalog category -> CSV
8
11
  * toolpath-scrape thread-pitch add the derived Thread Pitch column
9
12
  * toolpath-scrape cad add the vendor CAD model column
10
13
  * toolpath-scrape materials add the ISO workpiece-group column
package/dist/node/cli.js CHANGED
@@ -5,6 +5,9 @@
5
5
  * toolpath-scrape kennametal family page -> CSV
6
6
  * toolpath-scrape regofix ProductFinder index -> toolholding CSV
7
7
  * toolpath-scrape destinytool Firestore products -> End Mill CSV
8
+ * toolpath-scrape harvey one product page -> CSV
9
+ * toolpath-scrape maritool leaf categories -> toolholding CSV
10
+ * toolpath-scrape emuge one catalog category -> CSV
8
11
  * toolpath-scrape thread-pitch add the derived Thread Pitch column
9
12
  * toolpath-scrape cad add the vendor CAD model column
10
13
  * toolpath-scrape materials add the ISO workpiece-group column
@@ -23,14 +26,15 @@
23
26
  * or an assembly catalog is a different product, and none of it is in this
24
27
  * tree.
25
28
  */
26
- import { readFileSync, writeFileSync } from 'node:fs';
27
- import { basename } from 'node:path';
29
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
30
+ import { basename, dirname } from 'node:path';
28
31
  import { ScraperConfigError, VendorResponseError } from '../errors.js';
29
32
  import { familyBrand } from '../family.js';
30
33
  import { ALL_FAMILIES, FAMILIES, HOLDER_FAMILIES, familyConfig } from '../families/index.js';
31
34
  import { createFetcher } from '../fetch.js';
32
35
  import { AEM_BRANDS } from '../identity.js';
33
36
  import { boundFamily } from '../registry.js';
37
+ import { pause, REQUEST_DELAY_MS } from '../scrape.js';
34
38
  import { mirrorFamilySteps } from './cad-mirror.js';
35
39
  import { parseCsv, toCsv } from './csv.js';
36
40
  import { describeRoot, familyCsv, stepDir } from './paths.js';
@@ -40,7 +44,15 @@ import { annotateCadUrls } from '../vendors/kennametal/cad.js';
40
44
  import { addMaterialGroups, groupsByMaterial } from '../vendors/kennametal/materials.js';
41
45
  import { addThreadPitch } from '../vendors/kennametal/thread-column.js';
42
46
  import { scrapeEndMills, DOCUMENTS_URL } from '../vendors/destinytool/scrape.js';
47
+ import { PRODUCT_PAGES } from '../families/harvey.js';
48
+ import { CATEGORY_ROOTS, discoverProducts } from '../vendors/harvey/catalog.js';
49
+ import { scrapeProduct } from '../vendors/harvey/scrape.js';
43
50
  import { SEARCH_URL, scrapeCollets, scrapeHolders } from '../vendors/regofix/scrape.js';
51
+ import { LEAVES as MARITOOL_LEAVES } from '../families/maritool.js';
52
+ import { CATEGORY_ROOTS as MARITOOL_ROOTS, describe as describeCategory, discoverCategories, leavesOf, } from '../vendors/maritool/catalog.js';
53
+ import { scrapeHolders as scrapeMaritoolHolders } from '../vendors/maritool/scrape.js';
54
+ import { SCRAPE_TARGETS as EMUGE_TARGETS } from '../families/emuge.js';
55
+ import { scrapeCategory } from '../vendors/emuge/scrape.js';
44
56
  /**
45
57
  * The PG series a BT 30 holder can take. PG 32 and PG 48 collets exist and no
46
58
  * BT 30 holder in this catalog accepts one, so scraping them would add parts
@@ -67,6 +79,35 @@ const USAGE = `usage: toolpath-scrape <command> [args]
67
79
  Pages the whole Destiny Tool \`products\` Firestore collection and writes
68
80
  every End Mill row.
69
81
 
82
+ harvey FAMILY.csv [more.csv ...]
83
+ One Harvey Tool product page -> a CSV. The page to fetch and the unit
84
+ system come from the family's own config, so neither is typed again.
85
+ One HTML row is up to nine orderable parts; expect more rows out than
86
+ the vendor's table appears to have.
87
+
88
+ harvey --catalog
89
+ Walks the four Harvey category trees and prints every product page it
90
+ finds, one per line. For noticing a page Harvey has added — a scrape
91
+ needs none of it.
92
+
93
+ maritool FAMILY.csv [more.csv ...]
94
+ One MariTool taper -> a CSV. The leaf categories to page through come
95
+ from the family's own config, so none of them is typed again. One
96
+ request per listing page, then one per part for its Product
97
+ Specifications table.
98
+
99
+ maritool --catalog
100
+ Walks the five MariTool taper trees and prints every category it finds,
101
+ one per line, with its product count. For rechecking the leaf cPaths in
102
+ \`families/maritool.ts\` — a scrape needs none of it.
103
+
104
+ emuge FAMILY.csv [more.csv ...]
105
+ One EMUGE-FRANKEN catalog category -> a CSV. The category, the facet
106
+ narrowing it and the unit system all come from the family's own config,
107
+ so none of them is typed again. One request per page of grouped
108
+ products, one per group for its orderable parts, and one per 30 parts
109
+ for the fields only a per-part record carries.
110
+
70
111
  thread-pitch TAP.csv [more.csv ...]
71
112
  Adds a Thread Pitch column derived from D1-TDZ, in place. Safe to re-run.
72
113
 
@@ -158,6 +199,12 @@ export async function run(argv, io = STDOUT, fetcher = createFetcher()) {
158
199
  return regofix(rest, io, fetcher);
159
200
  case 'destinytool':
160
201
  return destinytool(rest, io, fetcher);
202
+ case 'harvey':
203
+ return harvey(rest, io, fetcher);
204
+ case 'maritool':
205
+ return maritool(rest, io, fetcher);
206
+ case 'emuge':
207
+ return emuge(rest, io, fetcher);
161
208
  case 'thread-pitch':
162
209
  return threadPitch(rest, io);
163
210
  case 'cad':
@@ -251,6 +298,109 @@ async function destinytool(argv, io, fetcher) {
251
298
  wrote(argv[0], 'destinytool', { ...scrape, source: DOCUMENTS_URL }, io);
252
299
  return 0;
253
300
  }
301
+ /**
302
+ * One or more Harvey families, or the catalog walk.
303
+ *
304
+ * Paced between pages by the package's shared politeness delay, which the
305
+ * per-page scrape does not do for itself: one family is one request, and a
306
+ * caller scraping one has nothing to wait for.
307
+ */
308
+ async function harvey(argv, io, fetcher) {
309
+ if (argv[0] === '--catalog') {
310
+ const found = await discoverProducts(fetcher, CATEGORY_ROOTS, { warn: io.error });
311
+ for (const path of found)
312
+ io.log(path);
313
+ io.log(`${found.length} product pages under ${CATEGORY_ROOTS.length} category trees`);
314
+ return 0;
315
+ }
316
+ if (argv.length === 0) {
317
+ io.error(USAGE);
318
+ return 2;
319
+ }
320
+ const names = namesIn(argv, PRODUCT_PAGES, 'Harvey family');
321
+ for (const [index, name] of names.entries()) {
322
+ if (index > 0)
323
+ await pause(REQUEST_DELAY_MS);
324
+ const cfg = boundFamily(name);
325
+ // Never undefined: `namesIn` refused anything `PRODUCT_PAGES` does not key,
326
+ // and `tests/harvey-families.test.ts` holds the two tables to the same keys.
327
+ const page = PRODUCT_PAGES[name];
328
+ if (cfg.unit === undefined) {
329
+ throw new ScraperConfigError(name, 'declares no unit — every Harvey family publishes one');
330
+ }
331
+ const scrape = await scrapeProduct(fetcher, page, { unit: cfg.unit, warn: io.error });
332
+ const out = familyCsv(name);
333
+ // This command resolves its own output path rather than taking one, so the
334
+ // brand's directory may not exist yet — unlike `kennametal`, where the path
335
+ // is typed and its directory is the caller's to have made.
336
+ mkdirSync(dirname(out), { recursive: true });
337
+ wrote(out, 'harvey', scrape, io);
338
+ }
339
+ return 0;
340
+ }
341
+ /**
342
+ * One or more MariTool families, or the catalog walk.
343
+ *
344
+ * The scrape paces itself between every request it makes, so unlike `harvey`
345
+ * there is nothing to pace between families here.
346
+ */
347
+ async function maritool(argv, io, fetcher) {
348
+ if (argv[0] === '--catalog') {
349
+ const found = await discoverCategories(fetcher, MARITOOL_ROOTS, { warn: io.error });
350
+ for (const category of found)
351
+ io.log(describeCategory(category));
352
+ const leaves = leavesOf(found);
353
+ io.log(`${found.length} categories under ${MARITOOL_ROOTS.length} taper trees, ` +
354
+ `${leaves.length} of them leaves, ` +
355
+ `${leaves.reduce((sum, leaf) => sum + leaf.products, 0)} products`);
356
+ return 0;
357
+ }
358
+ if (argv.length === 0) {
359
+ io.error(USAGE);
360
+ return 2;
361
+ }
362
+ for (const name of namesIn(argv, MARITOOL_LEAVES, 'MariTool family')) {
363
+ // Never undefined: `namesIn` refused anything `MARITOOL_LEAVES` does not
364
+ // key, and `tests/maritool.test.ts` holds the two tables to the same keys.
365
+ const leaves = MARITOOL_LEAVES[name];
366
+ const scrape = await scrapeMaritoolHolders(fetcher, leaves, { warn: io.error });
367
+ const out = familyCsv(name);
368
+ // This command resolves its own output path rather than taking one, so the
369
+ // brand's directory may not exist yet.
370
+ mkdirSync(dirname(out), { recursive: true });
371
+ wrote(out, 'maritool', scrape, io);
372
+ }
373
+ return 0;
374
+ }
375
+ /**
376
+ * One or more EMUGE-FRANKEN families.
377
+ *
378
+ * The scrape paces itself between every request it makes, so — as with
379
+ * `maritool` and unlike `harvey` — there is nothing to pace between families
380
+ * here.
381
+ */
382
+ async function emuge(argv, io, fetcher) {
383
+ if (argv.length === 0) {
384
+ io.error(USAGE);
385
+ return 2;
386
+ }
387
+ for (const name of namesIn(argv, EMUGE_TARGETS, 'EMUGE-FRANKEN family')) {
388
+ const cfg = boundFamily(name);
389
+ // Never undefined: `namesIn` refused anything `EMUGE_TARGETS` does not key,
390
+ // and `tests/emuge-families.test.ts` holds the two tables to the same keys.
391
+ const target = EMUGE_TARGETS[name];
392
+ if (cfg.unit === undefined) {
393
+ throw new ScraperConfigError(name, 'declares no unit — every EMUGE-FRANKEN family publishes one');
394
+ }
395
+ const scrape = await scrapeCategory(fetcher, target, { unit: cfg.unit, warn: io.error });
396
+ const out = familyCsv(name);
397
+ // This command resolves its own output path rather than taking one, so the
398
+ // brand's directory may not exist yet.
399
+ mkdirSync(dirname(out), { recursive: true });
400
+ wrote(out, 'emuge', scrape, io);
401
+ }
402
+ return 0;
403
+ }
254
404
  function threadPitch(argv, io) {
255
405
  if (argv.length === 0) {
256
406
  io.error(USAGE);
package/dist/node/main.js CHANGED
File without changes