@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
package/dist/records.d.ts
CHANGED
|
@@ -43,6 +43,8 @@
|
|
|
43
43
|
* separately could supply a tool that claims a holder it does not have.
|
|
44
44
|
*/
|
|
45
45
|
import { type UnitSystem } from './conventions.js';
|
|
46
|
+
import { type BrandName } from './identity.js';
|
|
47
|
+
import type { FactSource } from './provenance.js';
|
|
46
48
|
/** The kinds of cutting tool this package maps. */
|
|
47
49
|
export type ToolKind = 'drill' | 'tap' | 'endmill';
|
|
48
50
|
/**
|
|
@@ -56,6 +58,35 @@ export type ToolKind = 'drill' | 'tap' | 'endmill';
|
|
|
56
58
|
* and a tool's own list from another has no way to notice the two disagree.
|
|
57
59
|
*/
|
|
58
60
|
export declare const ISO_MATERIAL_GROUPS: readonly ["P", "M", "K", "N", "S", "H", "C"];
|
|
61
|
+
/**
|
|
62
|
+
* The material-groups label for **we do not know what this tool is for**.
|
|
63
|
+
*
|
|
64
|
+
* Not an ISO 513 group and deliberately not one of {@link ISO_MATERIAL_GROUPS}:
|
|
65
|
+
* it is a statement about this package's evidence, not about the tool. It sits
|
|
66
|
+
* in {@link ToolRecord.materialGroupsSource} rather than in the group list,
|
|
67
|
+
* because a consumer filtering a catalog down to "cuts steel" must not have to
|
|
68
|
+
* know that one of the letters is not a letter.
|
|
69
|
+
*
|
|
70
|
+
* **Why a label and not a bare `null`.** The absence was expressible before —
|
|
71
|
+
* `materialGroups === null` — and an absence is the one thing a reader has to
|
|
72
|
+
* interpret. Every Harvey part is here, and so is every Kennametal family whose
|
|
73
|
+
* material sweep has not been run: two different reasons for the same honest
|
|
74
|
+
* answer, and neither is "rated for nothing". A named value says that out loud
|
|
75
|
+
* in a UI, a log line and a filter, which `null` never does.
|
|
76
|
+
*/
|
|
77
|
+
export declare const UNSPECIFIED = "unspecified";
|
|
78
|
+
/**
|
|
79
|
+
* How a record's material groups were arrived at.
|
|
80
|
+
*
|
|
81
|
+
* A fact's own three sources plus {@link UNSPECIFIED}, which provenance has no
|
|
82
|
+
* word for because a `Fact` only exists once somebody has stated something. A
|
|
83
|
+
* record exists either way, so it needs the fourth.
|
|
84
|
+
*
|
|
85
|
+
* Only `vendor-stated`, `derived` and `unspecified` occur today; `assumed` is
|
|
86
|
+
* reachable the moment a family fact supplies the groups, and is kept in the
|
|
87
|
+
* type so that the vocabularies cannot drift apart.
|
|
88
|
+
*/
|
|
89
|
+
export type MaterialGroupsSource = FactSource | typeof UNSPECIFIED;
|
|
59
90
|
/** One canonical geometry name: what it measures, and whose name it is. */
|
|
60
91
|
export interface GeometryField {
|
|
61
92
|
/**
|
|
@@ -186,6 +217,55 @@ export declare const DIMENSIONAL: ReadonlySet<GeometryName>;
|
|
|
186
217
|
* suffix would look for a column that was never scraped.
|
|
187
218
|
*/
|
|
188
219
|
export declare const DIMENSIONAL_COLUMNS: ReadonlySet<GeometryName>;
|
|
220
|
+
/**
|
|
221
|
+
* What geometry a **record** of each kind carries, as against what a family
|
|
222
|
+
* must map.
|
|
223
|
+
*
|
|
224
|
+
* {@link REQUIRED_GEOMETRY} is about columns: it refuses a family whose config
|
|
225
|
+
* maps no `LCF`. This is about the record that comes out the other end, and the
|
|
226
|
+
* two genuinely differ — a Kennametal drill's `NOF` and `SIG` come from facts
|
|
227
|
+
* and not from any column, so they can never appear in a column map and are
|
|
228
|
+
* always on the record.
|
|
229
|
+
*
|
|
230
|
+
* **`sometimes` is the point of the table.** An absent key was the one thing in
|
|
231
|
+
* a record a reader had to interpret: `geometry.NOF === undefined` means "Harvey
|
|
232
|
+
* publishes no flute count for this family" on an end mill and "not part of the
|
|
233
|
+
* contract" on a drill, and nothing said which. That is exactly the ambiguity
|
|
234
|
+
* {@link UNSPECIFIED} exists to remove from {@link ToolRecord.materialGroups},
|
|
235
|
+
* and the record shipped both encodings at once. Now the absence is declared:
|
|
236
|
+
* a key in `sometimes` may be missing and its absence is the vendor's silence;
|
|
237
|
+
* a key in neither list is not part of that kind's record at all.
|
|
238
|
+
*
|
|
239
|
+
* Every `sometimes` entry today is one vendor publishing nothing where another
|
|
240
|
+
* publishes a number. `sometimes` permits the key, it does not forbid it — the
|
|
241
|
+
* vendors that state these keep filling them.
|
|
242
|
+
*
|
|
243
|
+
* - the **end mill's `NOF`**, for Harvey's two deburring families — they
|
|
244
|
+
* publish right- and left-hand tooth counts and no flute count, so there is
|
|
245
|
+
* nothing to read and 0 is not a substitute;
|
|
246
|
+
* - the **tap's `NOF`**, for EMUGE-FRANKEN, which states no flute count
|
|
247
|
+
* anywhere a scrape can reach — not on the grouped product, the variant
|
|
248
|
+
* listing, the per-part detail record or any facet — while its own tap
|
|
249
|
+
* families run 2, 3 and 4 flutes across their size range, so no per-family
|
|
250
|
+
* constant could be true of every row. Kennametal's taps publish a `Z`
|
|
251
|
+
* column and keep filling it;
|
|
252
|
+
* - the **drill's `SIG`**, for the one EMUGE-FRANKEN drill whose point-angle
|
|
253
|
+
* cell is empty. This one is a hole in a column the vendor otherwise fills,
|
|
254
|
+
* not a column it never had, and it is the reason the key had to move: the
|
|
255
|
+
* EMUGE drill family reads `SIG` from a column rather than from a fact, so
|
|
256
|
+
* a single blank cell refused the row, and `registry.toRecords` maps a
|
|
257
|
+
* family's rows together — 2,669 drills were lost to the 2,670th. Every
|
|
258
|
+
* Kennametal drill supplies `SIG` from a fact and none can be absent.
|
|
259
|
+
*
|
|
260
|
+
* A point angle is not a field to guess at. It sets the drill tip's length, so
|
|
261
|
+
* a CAM system that assumed 140 deg for a part ground at 118 would cut a hole
|
|
262
|
+
* to the wrong depth — an absence a consumer must check for is recoverable and
|
|
263
|
+
* a plausible wrong number is not.
|
|
264
|
+
*/
|
|
265
|
+
export declare const RECORD_GEOMETRY: Record<ToolKind, {
|
|
266
|
+
readonly always: readonly GeometryName[];
|
|
267
|
+
readonly sometimes: readonly GeometryName[];
|
|
268
|
+
}>;
|
|
189
269
|
/**
|
|
190
270
|
* One orderable cutting tool, in canonical fields, ready for the core.
|
|
191
271
|
*
|
|
@@ -193,23 +273,134 @@ export declare const DIMENSIONAL_COLUMNS: ReadonlySet<GeometryName>;
|
|
|
193
273
|
* it and hands it over, and a mapper that mutated one would be reaching back
|
|
194
274
|
* across the seam this type exists to draw.
|
|
195
275
|
*
|
|
196
|
-
* `geometry` holds {@link GEOMETRY_FIELDS} names in `unit`.
|
|
197
|
-
*
|
|
198
|
-
* material
|
|
199
|
-
*
|
|
276
|
+
* `geometry` holds {@link GEOMETRY_FIELDS} names in `unit`.
|
|
277
|
+
*
|
|
278
|
+
* ## The workpiece-material groups have three states, not two
|
|
279
|
+
*
|
|
280
|
+
* They were `string[]` until this contract, and an empty array carried two
|
|
281
|
+
* incompatible claims at once. Kennametal's 129 taps are empty because the
|
|
282
|
+
* vendor's own index rates them for nothing; every Harvey part was empty
|
|
283
|
+
* because Harvey publishes no index a scrape can reach, and a Kennametal family
|
|
284
|
+
* whose material sweep was never run was empty for a third reason. A consumer
|
|
285
|
+
* reading the first as "not rated for steel" is right and reading the second
|
|
286
|
+
* the same way is wrong, and nothing in the record told the two apart.
|
|
287
|
+
*
|
|
288
|
+
* So:
|
|
289
|
+
*
|
|
290
|
+
* - **`null`, labelled {@link UNSPECIFIED}** — we do not know what this tool is
|
|
291
|
+
* for. Not indexed, not published, or not swept. Says nothing about what it
|
|
292
|
+
* cuts, and is not a claim that it cuts nothing.
|
|
293
|
+
* - **`[]`** — the vendor's index exists and rates this part for nothing.
|
|
294
|
+
* - **non-empty** — rated, in {@link ISO_MATERIAL_GROUPS} order.
|
|
295
|
+
*
|
|
296
|
+
* {@link ToolRecord.materialGroupsSource} is never absent: it is `unspecified`
|
|
297
|
+
* in the first case and, in the other two, how the answer was arrived at in the
|
|
298
|
+
* same vocabulary a family fact uses — `vendor-stated` where the vendor's own
|
|
299
|
+
* index said so, `derived` where this package computed it. A consumer that will
|
|
300
|
+
* not act on a guess filters on it, and one that will not show an unknown as an
|
|
301
|
+
* empty facet filters on it too.
|
|
200
302
|
*/
|
|
201
303
|
export interface ToolRecord {
|
|
304
|
+
/**
|
|
305
|
+
* The brand key the record was minted under — `identity.BRANDS`'s own key,
|
|
306
|
+
* not the display name.
|
|
307
|
+
*
|
|
308
|
+
* Here because {@link ToolRecord.guid} is the join key for every downstream
|
|
309
|
+
* consumer and it is minted in this brand's namespace: without the key on
|
|
310
|
+
* the record, the guid is underivable from the record and `vendor` is a
|
|
311
|
+
* display string nothing can look a namespace up by.
|
|
312
|
+
*/
|
|
313
|
+
readonly brand: BrandName;
|
|
314
|
+
/** What this brand's records call the vendor — `identity.BRANDS[brand].vendor`. */
|
|
202
315
|
readonly vendor: string;
|
|
316
|
+
/**
|
|
317
|
+
* `identity.recordGuid(brand, materialNumber)`, minted by {@link toolRecord}
|
|
318
|
+
* rather than by an adapter: one mint path is what makes a guid collision
|
|
319
|
+
* across brands structurally impossible instead of merely unlikely.
|
|
320
|
+
*/
|
|
321
|
+
readonly guid: string;
|
|
203
322
|
readonly materialNumber: string;
|
|
204
323
|
readonly catalogNumber: string;
|
|
324
|
+
/**
|
|
325
|
+
* The vendor's own free text about this part, verbatim — `''` where the
|
|
326
|
+
* vendor publishes none.
|
|
327
|
+
*
|
|
328
|
+
* **Never a copy of another field on this record.** It was
|
|
329
|
+
* `row['ISO Catalog Number']` on every Kennametal drill and end mill until
|
|
330
|
+
* 2026-08-29, which put the catalog number in two fields and told a consumer
|
|
331
|
+
* nothing it did not already have: a search index built on it matched a part
|
|
332
|
+
* number and no words. Kennametal publishes no description column, so the
|
|
333
|
+
* honest answer is the empty string — the same rule {@link ToolRecord.coating}
|
|
334
|
+
* already states for a table that publishes no coating.
|
|
335
|
+
*
|
|
336
|
+
* **It may be per product line rather than per part.** Harvey states one
|
|
337
|
+
* title for a whole page and no per-part text, so every record of a Harvey
|
|
338
|
+
* family carries that family's title. That is what the vendor published; a
|
|
339
|
+
* consumer that needs a per-part string has `catalogNumber`.
|
|
340
|
+
*/
|
|
205
341
|
readonly description: string;
|
|
342
|
+
/**
|
|
343
|
+
* The vendor's own name for the product line this part belongs to —
|
|
344
|
+
* `FRANKEN TOP-Cut`, `MultiDRILL`, `KenCut™ FF`, `Viper` — or `null` where
|
|
345
|
+
* the vendor names none.
|
|
346
|
+
*
|
|
347
|
+
* **Null is the vendor's silence, not an empty name**, the same three-state
|
|
348
|
+
* reasoning {@link ToolRecord.materialGroups} makes with {@link UNSPECIFIED}
|
|
349
|
+
* and for the same reason: a consumer faceting a catalog by product line has
|
|
350
|
+
* to be able to tell "EMUGE calls this Alu-Cut" from "nobody has decided what
|
|
351
|
+
* Harvey's line is called", and `''` collapses the two.
|
|
352
|
+
*
|
|
353
|
+
* **Verbatim, and never inferred** — the {@link ToolRecord.coating} rule.
|
|
354
|
+
* EMUGE's own index says `FRANKEN Expert` for the eight end mills its
|
|
355
|
+
* marketing calls "Cut & Form", and Destiny Tool ships `viper-mini` and
|
|
356
|
+
* `python` beside `Viper` and `Raptor`; case-folding either here would be
|
|
357
|
+
* this package authoring a vendor's catalog. The one thing an adapter may do
|
|
358
|
+
* is map a vendor's *code* onto that same vendor's *own* published name for
|
|
359
|
+
* it — see `vendors/emuge/records.ts`'s `PRODUCT_LINES`, where both sides of
|
|
360
|
+
* every entry are EMUGE's and the article page each name came from is cited.
|
|
361
|
+
*
|
|
362
|
+
* **Where it comes from is a fact about the vendor's data, not a convention.**
|
|
363
|
+
* Three sources are in use and each is the only one its vendor offers: a
|
|
364
|
+
* scraped column read per part (EMUGE, Destiny Tool), a page title fetched
|
|
365
|
+
* per family (Kennametal, WIDIA), and nothing at all (Harvey Tool, whose
|
|
366
|
+
* product-line title is already this record's `description` — a second copy
|
|
367
|
+
* of one string is the thing that field's own docstring refuses).
|
|
368
|
+
*
|
|
369
|
+
* **Never a copy of another field on this record**, for the reason
|
|
370
|
+
* {@link ToolRecord.description} states it.
|
|
371
|
+
*/
|
|
372
|
+
readonly productLine: string | null;
|
|
206
373
|
readonly kind: ToolKind;
|
|
207
374
|
readonly unit: UnitSystem;
|
|
208
375
|
readonly substrate: string;
|
|
209
|
-
|
|
210
|
-
|
|
376
|
+
/**
|
|
377
|
+
* The vendor's own coating string, `''` where the table publishes none.
|
|
378
|
+
*
|
|
379
|
+
* Raw, and never inferred from: `AlTiN COATED`, `TiN` and a Destiny Tool
|
|
380
|
+
* coating id are three vendors' vocabularies, and mapping them onto a shared
|
|
381
|
+
* one here would be this package inventing a standard rather than recording
|
|
382
|
+
* what was published.
|
|
383
|
+
*/
|
|
384
|
+
readonly coating: string;
|
|
385
|
+
/**
|
|
386
|
+
* The canonical geometry, in {@link ToolRecord.unit}.
|
|
387
|
+
*
|
|
388
|
+
* Numbers only. It was `number | boolean` until 2026-08-29 and no adapter has
|
|
389
|
+
* ever put a boolean in one — {@link GEOMETRY_FIELDS} defines no boolean
|
|
390
|
+
* field — so the width bought nothing and cost every consumer a narrowing
|
|
391
|
+
* before it could do arithmetic on `geometry.DC`.
|
|
392
|
+
*
|
|
393
|
+
* **Which keys are present is stated per kind, not left to the mapper**: see
|
|
394
|
+
* {@link RECORD_GEOMETRY}. An absent key is a claim, and it is the vendor's
|
|
395
|
+
* silence rather than a gap — the same distinction
|
|
396
|
+
* {@link ToolRecord.materialGroups} draws with {@link UNSPECIFIED}.
|
|
397
|
+
*/
|
|
398
|
+
readonly geometry: Readonly<Partial<Record<GeometryName, number>>>;
|
|
211
399
|
readonly coolantThrough: boolean;
|
|
212
|
-
|
|
400
|
+
/** ISO 513 main groups in {@link ISO_MATERIAL_GROUPS} order — see above. */
|
|
401
|
+
readonly materialGroups: readonly string[] | null;
|
|
402
|
+
/** How the groups were arrived at. {@link UNSPECIFIED} exactly when they are null. */
|
|
403
|
+
readonly materialGroupsSource: MaterialGroupsSource;
|
|
213
404
|
/**
|
|
214
405
|
* Drills only, and deliberately `null` elsewhere rather than `false`: it
|
|
215
406
|
* drops the two ferrous presets downstream, and a default would ship them on
|
|
@@ -218,20 +409,31 @@ export interface ToolRecord {
|
|
|
218
409
|
readonly nonFerrous: boolean | null;
|
|
219
410
|
}
|
|
220
411
|
/**
|
|
221
|
-
* Build a {@link ToolRecord},
|
|
222
|
-
* absence.
|
|
412
|
+
* Build a {@link ToolRecord}: mint its guid, default the fields that have a
|
|
413
|
+
* meaningful absence, and refuse the one state that cannot be true.
|
|
223
414
|
*
|
|
224
415
|
* An interface cannot carry a default, and requiring every adapter to write
|
|
225
|
-
* `materialGroups:
|
|
416
|
+
* `materialGroups: null` would put the decision back in the three places least
|
|
226
417
|
* able to notice it was wrong. They stay **required on the type** so a
|
|
227
418
|
* consumer reading a record never handles `undefined` — only the construction
|
|
228
|
-
* is optional.
|
|
419
|
+
* is optional. The default is `null`/{@link UNSPECIFIED} and not `[]`, because
|
|
420
|
+
* an adapter that says nothing about workpiece materials has produced no
|
|
421
|
+
* evidence, which is the one thing the three-state rule exists to distinguish.
|
|
422
|
+
*
|
|
423
|
+
* `guid` is not an input at all. Every adapter minting it would be three
|
|
424
|
+
* copies of `recordGuid(brand, materialNumber)` to drift, on the value that is
|
|
425
|
+
* the join key for every downstream consumer.
|
|
426
|
+
*
|
|
427
|
+
* The geometry is checked against {@link RECORD_GEOMETRY} before anything is
|
|
428
|
+
* built, so a kind's shape is one table rather than a convention three mappers
|
|
429
|
+
* each keep separately. That is the same move the material-groups invariant
|
|
430
|
+
* below makes: the factory is where a record's shape can be refused once.
|
|
229
431
|
*
|
|
230
432
|
* The result is frozen, geometry and material groups included: a record is an
|
|
231
433
|
* interchange value, and a mapper that mutated one would be reaching back
|
|
232
434
|
* across the seam this type exists to draw.
|
|
233
435
|
*/
|
|
234
|
-
export declare function toolRecord(fields: Omit<ToolRecord, 'materialGroups' | 'nonFerrous'> & Partial<Pick<ToolRecord, 'materialGroups' | 'nonFerrous'>>): ToolRecord;
|
|
436
|
+
export declare function toolRecord(fields: Omit<ToolRecord, 'guid' | 'materialGroups' | 'materialGroupsSource' | 'nonFerrous' | 'productLine'> & Partial<Pick<ToolRecord, 'materialGroups' | 'materialGroupsSource' | 'nonFerrous' | 'productLine'>>): ToolRecord;
|
|
235
437
|
/**
|
|
236
438
|
* A family's canonical-field → CSV-column-label mapping, validated.
|
|
237
439
|
*
|
package/dist/records.js
CHANGED
|
@@ -44,6 +44,7 @@
|
|
|
44
44
|
*/
|
|
45
45
|
import { dimensionalColumn } from './conventions.js';
|
|
46
46
|
import { ScraperConfigError } from './errors.js';
|
|
47
|
+
import { recordGuid } from './identity.js';
|
|
47
48
|
/**
|
|
48
49
|
* ISO 513's main workpiece-material groups, in the order every vendor's
|
|
49
50
|
* material groups must agree on — **core, not a Kennametal fact**, the same
|
|
@@ -55,6 +56,23 @@ import { ScraperConfigError } from './errors.js';
|
|
|
55
56
|
* and a tool's own list from another has no way to notice the two disagree.
|
|
56
57
|
*/
|
|
57
58
|
export const ISO_MATERIAL_GROUPS = ['P', 'M', 'K', 'N', 'S', 'H', 'C'];
|
|
59
|
+
/**
|
|
60
|
+
* The material-groups label for **we do not know what this tool is for**.
|
|
61
|
+
*
|
|
62
|
+
* Not an ISO 513 group and deliberately not one of {@link ISO_MATERIAL_GROUPS}:
|
|
63
|
+
* it is a statement about this package's evidence, not about the tool. It sits
|
|
64
|
+
* in {@link ToolRecord.materialGroupsSource} rather than in the group list,
|
|
65
|
+
* because a consumer filtering a catalog down to "cuts steel" must not have to
|
|
66
|
+
* know that one of the letters is not a letter.
|
|
67
|
+
*
|
|
68
|
+
* **Why a label and not a bare `null`.** The absence was expressible before —
|
|
69
|
+
* `materialGroups === null` — and an absence is the one thing a reader has to
|
|
70
|
+
* interpret. Every Harvey part is here, and so is every Kennametal family whose
|
|
71
|
+
* material sweep has not been run: two different reasons for the same honest
|
|
72
|
+
* answer, and neither is "rated for nothing". A named value says that out loud
|
|
73
|
+
* in a UI, a log line and a filter, which `null` never does.
|
|
74
|
+
*/
|
|
75
|
+
export const UNSPECIFIED = 'unspecified';
|
|
58
76
|
/**
|
|
59
77
|
* Canonical geometry fields an adapter may supply, and what each means.
|
|
60
78
|
*
|
|
@@ -158,25 +176,142 @@ export const DIMENSIONAL = new Set([
|
|
|
158
176
|
*/
|
|
159
177
|
export const DIMENSIONAL_COLUMNS = new Set([...DIMENSIONAL].filter((name) => name !== 'TP'));
|
|
160
178
|
/**
|
|
161
|
-
*
|
|
162
|
-
*
|
|
179
|
+
* What geometry a **record** of each kind carries, as against what a family
|
|
180
|
+
* must map.
|
|
181
|
+
*
|
|
182
|
+
* {@link REQUIRED_GEOMETRY} is about columns: it refuses a family whose config
|
|
183
|
+
* maps no `LCF`. This is about the record that comes out the other end, and the
|
|
184
|
+
* two genuinely differ — a Kennametal drill's `NOF` and `SIG` come from facts
|
|
185
|
+
* and not from any column, so they can never appear in a column map and are
|
|
186
|
+
* always on the record.
|
|
187
|
+
*
|
|
188
|
+
* **`sometimes` is the point of the table.** An absent key was the one thing in
|
|
189
|
+
* a record a reader had to interpret: `geometry.NOF === undefined` means "Harvey
|
|
190
|
+
* publishes no flute count for this family" on an end mill and "not part of the
|
|
191
|
+
* contract" on a drill, and nothing said which. That is exactly the ambiguity
|
|
192
|
+
* {@link UNSPECIFIED} exists to remove from {@link ToolRecord.materialGroups},
|
|
193
|
+
* and the record shipped both encodings at once. Now the absence is declared:
|
|
194
|
+
* a key in `sometimes` may be missing and its absence is the vendor's silence;
|
|
195
|
+
* a key in neither list is not part of that kind's record at all.
|
|
196
|
+
*
|
|
197
|
+
* Every `sometimes` entry today is one vendor publishing nothing where another
|
|
198
|
+
* publishes a number. `sometimes` permits the key, it does not forbid it — the
|
|
199
|
+
* vendors that state these keep filling them.
|
|
200
|
+
*
|
|
201
|
+
* - the **end mill's `NOF`**, for Harvey's two deburring families — they
|
|
202
|
+
* publish right- and left-hand tooth counts and no flute count, so there is
|
|
203
|
+
* nothing to read and 0 is not a substitute;
|
|
204
|
+
* - the **tap's `NOF`**, for EMUGE-FRANKEN, which states no flute count
|
|
205
|
+
* anywhere a scrape can reach — not on the grouped product, the variant
|
|
206
|
+
* listing, the per-part detail record or any facet — while its own tap
|
|
207
|
+
* families run 2, 3 and 4 flutes across their size range, so no per-family
|
|
208
|
+
* constant could be true of every row. Kennametal's taps publish a `Z`
|
|
209
|
+
* column and keep filling it;
|
|
210
|
+
* - the **drill's `SIG`**, for the one EMUGE-FRANKEN drill whose point-angle
|
|
211
|
+
* cell is empty. This one is a hole in a column the vendor otherwise fills,
|
|
212
|
+
* not a column it never had, and it is the reason the key had to move: the
|
|
213
|
+
* EMUGE drill family reads `SIG` from a column rather than from a fact, so
|
|
214
|
+
* a single blank cell refused the row, and `registry.toRecords` maps a
|
|
215
|
+
* family's rows together — 2,669 drills were lost to the 2,670th. Every
|
|
216
|
+
* Kennametal drill supplies `SIG` from a fact and none can be absent.
|
|
217
|
+
*
|
|
218
|
+
* A point angle is not a field to guess at. It sets the drill tip's length, so
|
|
219
|
+
* a CAM system that assumed 140 deg for a part ground at 118 would cut a hole
|
|
220
|
+
* to the wrong depth — an absence a consumer must check for is recoverable and
|
|
221
|
+
* a plausible wrong number is not.
|
|
222
|
+
*/
|
|
223
|
+
export const RECORD_GEOMETRY = {
|
|
224
|
+
drill: {
|
|
225
|
+
always: ['DC', 'SFDM', 'OAL', 'LCF', 'NOF'],
|
|
226
|
+
sometimes: ['SIG'],
|
|
227
|
+
},
|
|
228
|
+
tap: {
|
|
229
|
+
always: ['DC', 'TP', 'SFDM', 'OAL', 'LCF'],
|
|
230
|
+
sometimes: ['NOF'],
|
|
231
|
+
},
|
|
232
|
+
endmill: {
|
|
233
|
+
always: ['DC', 'RE', 'SFDM', 'OAL', 'LCF', 'shoulder-length', 'shoulder-diameter'],
|
|
234
|
+
sometimes: ['NOF'],
|
|
235
|
+
},
|
|
236
|
+
};
|
|
237
|
+
/**
|
|
238
|
+
* Refuse a record whose geometry does not match its kind's declared shape.
|
|
239
|
+
*
|
|
240
|
+
* Two failures, and the second is the one worth having: a key the kind does not
|
|
241
|
+
* declare at all means a mapper is writing a measurement into a record nothing
|
|
242
|
+
* downstream expects to find there, which is invisible until a consumer does
|
|
243
|
+
* not read it.
|
|
244
|
+
*/
|
|
245
|
+
function checkGeometry(kind, what, geometry) {
|
|
246
|
+
const { always, sometimes } = RECORD_GEOMETRY[kind];
|
|
247
|
+
const present = new Set(Object.keys(geometry));
|
|
248
|
+
const missing = always.filter((name) => !present.has(name));
|
|
249
|
+
if (missing.length > 0) {
|
|
250
|
+
throw new ScraperConfigError(what, `a ${kind} record carries ${missing.join(', ')} and this one does not — ` +
|
|
251
|
+
`a field a kind always has is not a field a mapper may skip`);
|
|
252
|
+
}
|
|
253
|
+
const declared = new Set([...always, ...sometimes]);
|
|
254
|
+
const extra = [...present].filter((name) => !declared.has(name)).sort();
|
|
255
|
+
if (extra.length > 0) {
|
|
256
|
+
throw new ScraperConfigError(what, `a ${kind} record does not carry ${extra.join(', ')} — ` +
|
|
257
|
+
`add it to RECORD_GEOMETRY before a mapper writes one`);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Build a {@link ToolRecord}: mint its guid, default the fields that have a
|
|
262
|
+
* meaningful absence, and refuse the one state that cannot be true.
|
|
163
263
|
*
|
|
164
264
|
* An interface cannot carry a default, and requiring every adapter to write
|
|
165
|
-
* `materialGroups:
|
|
265
|
+
* `materialGroups: null` would put the decision back in the three places least
|
|
166
266
|
* able to notice it was wrong. They stay **required on the type** so a
|
|
167
267
|
* consumer reading a record never handles `undefined` — only the construction
|
|
168
|
-
* is optional.
|
|
268
|
+
* is optional. The default is `null`/{@link UNSPECIFIED} and not `[]`, because
|
|
269
|
+
* an adapter that says nothing about workpiece materials has produced no
|
|
270
|
+
* evidence, which is the one thing the three-state rule exists to distinguish.
|
|
271
|
+
*
|
|
272
|
+
* `guid` is not an input at all. Every adapter minting it would be three
|
|
273
|
+
* copies of `recordGuid(brand, materialNumber)` to drift, on the value that is
|
|
274
|
+
* the join key for every downstream consumer.
|
|
275
|
+
*
|
|
276
|
+
* The geometry is checked against {@link RECORD_GEOMETRY} before anything is
|
|
277
|
+
* built, so a kind's shape is one table rather than a convention three mappers
|
|
278
|
+
* each keep separately. That is the same move the material-groups invariant
|
|
279
|
+
* below makes: the factory is where a record's shape can be refused once.
|
|
169
280
|
*
|
|
170
281
|
* The result is frozen, geometry and material groups included: a record is an
|
|
171
282
|
* interchange value, and a mapper that mutated one would be reaching back
|
|
172
283
|
* across the seam this type exists to draw.
|
|
173
284
|
*/
|
|
174
285
|
export function toolRecord(fields) {
|
|
286
|
+
const groups = fields.materialGroups ?? null;
|
|
287
|
+
const source = fields.materialGroupsSource ?? UNSPECIFIED;
|
|
288
|
+
// An adapter that has nothing to say about the product line says nothing;
|
|
289
|
+
// `''` would be a name and there is no nameless line. Optional here and
|
|
290
|
+
// required on the type for the reason stated above — a consumer reading a
|
|
291
|
+
// record never handles `undefined`.
|
|
292
|
+
const line = fields.productLine ?? null;
|
|
293
|
+
if (line === '') {
|
|
294
|
+
throw new ScraperConfigError(fields.materialNumber, `productLine is the empty string — a vendor that names no product line ` +
|
|
295
|
+
`is null, which is the state a consumer can tell from a name`);
|
|
296
|
+
}
|
|
297
|
+
// The invariant is what keeps the three states three: groups labelled
|
|
298
|
+
// `unspecified` are groups nobody stated, and an attributed source with no
|
|
299
|
+
// groups attributes nothing. Either would land as a record whose material
|
|
300
|
+
// index reads as the opposite of what the adapter meant.
|
|
301
|
+
if ((groups === null) !== (source === UNSPECIFIED)) {
|
|
302
|
+
throw new ScraperConfigError(fields.materialNumber, `materialGroups is ${groups === null ? 'null' : JSON.stringify([...groups])} ` +
|
|
303
|
+
`and materialGroupsSource is ${JSON.stringify(source)} — ` +
|
|
304
|
+
`groups are ${JSON.stringify(UNSPECIFIED)} exactly when there are none`);
|
|
305
|
+
}
|
|
306
|
+
checkGeometry(fields.kind, fields.materialNumber, fields.geometry);
|
|
175
307
|
return Object.freeze({
|
|
176
308
|
...fields,
|
|
309
|
+
guid: recordGuid(fields.brand, fields.materialNumber),
|
|
177
310
|
geometry: Object.freeze({ ...fields.geometry }),
|
|
178
|
-
materialGroups: Object.freeze([...
|
|
311
|
+
materialGroups: groups === null ? null : Object.freeze([...groups]),
|
|
312
|
+
materialGroupsSource: source,
|
|
179
313
|
nonFerrous: fields.nonFerrous ?? null,
|
|
314
|
+
productLine: line,
|
|
180
315
|
});
|
|
181
316
|
}
|
|
182
317
|
/**
|
package/dist/registry.d.ts
CHANGED
|
@@ -26,7 +26,9 @@
|
|
|
26
26
|
* key. What changes is only *when* — first use rather than import — and every
|
|
27
27
|
* entry point into this package goes through here.
|
|
28
28
|
*/
|
|
29
|
-
import type
|
|
29
|
+
import { type BoundFamily, type BoundToolholding, type RecordMappers } from './family.js';
|
|
30
|
+
import { type ToolRecord } from './records.js';
|
|
31
|
+
import { type MapperOptions, type ScrapeResult } from './scrape.js';
|
|
30
32
|
/**
|
|
31
33
|
* Brand -> its row-to-record mappers, by tool kind.
|
|
32
34
|
*
|
|
@@ -53,6 +55,59 @@ export declare function boundFamilies(): Map<string, BoundFamily>;
|
|
|
53
55
|
export declare function boundToolholding(): Map<string, BoundToolholding>;
|
|
54
56
|
/** One bound cutting-tool family by CSV name. */
|
|
55
57
|
export declare function boundFamily(name: string): BoundFamily;
|
|
58
|
+
/**
|
|
59
|
+
* One family's scrape, as {@link ToolRecord}s — the package's uniform output.
|
|
60
|
+
*
|
|
61
|
+
* **This is what a consumer wants and what nothing shipped until now.** Every
|
|
62
|
+
* CLI command ends at a vendor-labelled CSV, and a CSV is the receipt: four
|
|
63
|
+
* vendors, four column vocabularies, and a `D1_mm` that means one thing in
|
|
64
|
+
* Kennametal's table and another in ISO 13399's dictionary. The adapters that
|
|
65
|
+
* resolve that have been here the whole time and only the tests called them.
|
|
66
|
+
*
|
|
67
|
+
* It lives in the registry rather than beside `toolRecord` because it needs
|
|
68
|
+
* both halves — the config table and the vendor mappers — and the main entry
|
|
69
|
+
* point deliberately imports no vendor. Reach it through the `./registry`
|
|
70
|
+
* subpath.
|
|
71
|
+
*
|
|
72
|
+
* The two checks run **before the first row**, in the order a failure is
|
|
73
|
+
* cheapest to read:
|
|
74
|
+
*
|
|
75
|
+
* 1. {@link checkIdentityColumns} — a re-scrape whose part-number column was
|
|
76
|
+
* renamed still parses, still has the right row count, and mints every guid
|
|
77
|
+
* off an empty string.
|
|
78
|
+
* 2. {@link checkColumnsExist} — a mapped column the CSV does not carry names
|
|
79
|
+
* the family and the field here, instead of naming one row out of ninety-three
|
|
80
|
+
* from inside a mapper.
|
|
81
|
+
*
|
|
82
|
+
* `familyName` is the CSV filename the catalog is keyed by
|
|
83
|
+
* (`'harvey_endmill_025.csv'`), which is what `boundFamily` takes.
|
|
84
|
+
*
|
|
85
|
+
* ## One incomplete part does not end the family
|
|
86
|
+
*
|
|
87
|
+
* The rows are mapped together, so until 2026-09-01 every refusal was equally
|
|
88
|
+
* fatal — and the refusals are not equal. A part the vendor left a required
|
|
89
|
+
* cell blank on is one bad row among thousands of good ones; EMUGE-FRANKEN
|
|
90
|
+
* omits `overall length l₁` on roughly 175 of its 7,021 end mill variants, and
|
|
91
|
+
* both end mill families converted to nothing at all because of them.
|
|
92
|
+
*
|
|
93
|
+
* So an {@link IncompletePartError} is warned about and the row is dropped.
|
|
94
|
+
* **Nothing else is.** A cutting material with no mapping, a column a family
|
|
95
|
+
* stopped mapping, a response that changed shape — those say the vendor's
|
|
96
|
+
* vocabulary or this package's catalog has moved, and a scraper that skipped
|
|
97
|
+
* quietly past them would publish a catalog nobody checked. `columns.required`
|
|
98
|
+
* is the only place that raises the skippable one.
|
|
99
|
+
*
|
|
100
|
+
* A dropped row is **not** a relaxed contract. `records.RECORD_GEOMETRY` still
|
|
101
|
+
* says an end mill always has an `OAL`, and every record returned here still
|
|
102
|
+
* has one: the part without it becomes no record rather than a record with a
|
|
103
|
+
* hole. Where a vendor genuinely never publishes a field, `sometimes` is still
|
|
104
|
+
* the answer — a drill's `SIG` is that, and it stays that.
|
|
105
|
+
*
|
|
106
|
+
* The count of what was dropped is not returned. A caller that needs it has
|
|
107
|
+
* the row count it passed in and the length it got back, and the warnings name
|
|
108
|
+
* every part by number.
|
|
109
|
+
*/
|
|
110
|
+
export declare function toRecords(familyName: string, scrape: ScrapeResult, options?: MapperOptions): ToolRecord[];
|
|
56
111
|
/**
|
|
57
112
|
* Forget what has been bound.
|
|
58
113
|
*
|
package/dist/registry.js
CHANGED
|
@@ -26,11 +26,16 @@
|
|
|
26
26
|
* key. What changes is only *when* — first use rather than import — and every
|
|
27
27
|
* entry point into this package goes through here.
|
|
28
28
|
*/
|
|
29
|
+
import { checkIdentityColumns } from './conventions.js';
|
|
30
|
+
import { familyBrand, } from './family.js';
|
|
29
31
|
import { COLLET_FAMILIES, FAMILIES, HOLDER_FAMILIES } from './families/index.js';
|
|
30
|
-
import { ScraperConfigError } from './errors.js';
|
|
32
|
+
import { IncompletePartError, ScraperConfigError } from './errors.js';
|
|
31
33
|
import { checkFact } from './provenance.js';
|
|
32
|
-
import { checkColumnMap } from './records.js';
|
|
34
|
+
import { checkColumnMap, checkColumnsExist } from './records.js';
|
|
35
|
+
import { consoleWarn } from './scrape.js';
|
|
33
36
|
import { RECORD_MAPPERS as DESTINYTOOL } from './vendors/destinytool/records.js';
|
|
37
|
+
import { RECORD_MAPPERS as EMUGE } from './vendors/emuge/records.js';
|
|
38
|
+
import { RECORD_MAPPERS as HARVEY } from './vendors/harvey/records.js';
|
|
34
39
|
import { RECORD_MAPPERS as KENNAMETAL } from './vendors/kennametal/records.js';
|
|
35
40
|
/**
|
|
36
41
|
* Brand -> its row-to-record mappers, by tool kind.
|
|
@@ -45,6 +50,8 @@ export const ADAPTERS = {
|
|
|
45
50
|
kennametal: KENNAMETAL,
|
|
46
51
|
widia: KENNAMETAL,
|
|
47
52
|
destinytool: DESTINYTOOL,
|
|
53
|
+
harvey: HARVEY,
|
|
54
|
+
emuge: EMUGE,
|
|
48
55
|
};
|
|
49
56
|
/**
|
|
50
57
|
* Check every fact of one family and project its value onto the config under
|
|
@@ -132,6 +139,76 @@ export function boundFamily(name) {
|
|
|
132
139
|
}
|
|
133
140
|
return cfg;
|
|
134
141
|
}
|
|
142
|
+
/**
|
|
143
|
+
* One family's scrape, as {@link ToolRecord}s — the package's uniform output.
|
|
144
|
+
*
|
|
145
|
+
* **This is what a consumer wants and what nothing shipped until now.** Every
|
|
146
|
+
* CLI command ends at a vendor-labelled CSV, and a CSV is the receipt: four
|
|
147
|
+
* vendors, four column vocabularies, and a `D1_mm` that means one thing in
|
|
148
|
+
* Kennametal's table and another in ISO 13399's dictionary. The adapters that
|
|
149
|
+
* resolve that have been here the whole time and only the tests called them.
|
|
150
|
+
*
|
|
151
|
+
* It lives in the registry rather than beside `toolRecord` because it needs
|
|
152
|
+
* both halves — the config table and the vendor mappers — and the main entry
|
|
153
|
+
* point deliberately imports no vendor. Reach it through the `./registry`
|
|
154
|
+
* subpath.
|
|
155
|
+
*
|
|
156
|
+
* The two checks run **before the first row**, in the order a failure is
|
|
157
|
+
* cheapest to read:
|
|
158
|
+
*
|
|
159
|
+
* 1. {@link checkIdentityColumns} — a re-scrape whose part-number column was
|
|
160
|
+
* renamed still parses, still has the right row count, and mints every guid
|
|
161
|
+
* off an empty string.
|
|
162
|
+
* 2. {@link checkColumnsExist} — a mapped column the CSV does not carry names
|
|
163
|
+
* the family and the field here, instead of naming one row out of ninety-three
|
|
164
|
+
* from inside a mapper.
|
|
165
|
+
*
|
|
166
|
+
* `familyName` is the CSV filename the catalog is keyed by
|
|
167
|
+
* (`'harvey_endmill_025.csv'`), which is what `boundFamily` takes.
|
|
168
|
+
*
|
|
169
|
+
* ## One incomplete part does not end the family
|
|
170
|
+
*
|
|
171
|
+
* The rows are mapped together, so until 2026-09-01 every refusal was equally
|
|
172
|
+
* fatal — and the refusals are not equal. A part the vendor left a required
|
|
173
|
+
* cell blank on is one bad row among thousands of good ones; EMUGE-FRANKEN
|
|
174
|
+
* omits `overall length l₁` on roughly 175 of its 7,021 end mill variants, and
|
|
175
|
+
* both end mill families converted to nothing at all because of them.
|
|
176
|
+
*
|
|
177
|
+
* So an {@link IncompletePartError} is warned about and the row is dropped.
|
|
178
|
+
* **Nothing else is.** A cutting material with no mapping, a column a family
|
|
179
|
+
* stopped mapping, a response that changed shape — those say the vendor's
|
|
180
|
+
* vocabulary or this package's catalog has moved, and a scraper that skipped
|
|
181
|
+
* quietly past them would publish a catalog nobody checked. `columns.required`
|
|
182
|
+
* is the only place that raises the skippable one.
|
|
183
|
+
*
|
|
184
|
+
* A dropped row is **not** a relaxed contract. `records.RECORD_GEOMETRY` still
|
|
185
|
+
* says an end mill always has an `OAL`, and every record returned here still
|
|
186
|
+
* has one: the part without it becomes no record rather than a record with a
|
|
187
|
+
* hole. Where a vendor genuinely never publishes a field, `sometimes` is still
|
|
188
|
+
* the answer — a drill's `SIG` is that, and it stays that.
|
|
189
|
+
*
|
|
190
|
+
* The count of what was dropped is not returned. A caller that needs it has
|
|
191
|
+
* the row count it passed in and the length it got back, and the warnings name
|
|
192
|
+
* every part by number.
|
|
193
|
+
*/
|
|
194
|
+
export function toRecords(familyName, scrape, options) {
|
|
195
|
+
const cfg = boundFamily(familyName);
|
|
196
|
+
const warn = options?.warn ?? consoleWarn;
|
|
197
|
+
checkIdentityColumns(familyBrand(cfg), scrape.header);
|
|
198
|
+
checkColumnsExist(familyName, cfg, scrape.header);
|
|
199
|
+
const records = [];
|
|
200
|
+
for (const row of scrape.rows) {
|
|
201
|
+
try {
|
|
202
|
+
records.push(cfg.records(row, cfg, cfg.columns, options));
|
|
203
|
+
}
|
|
204
|
+
catch (error) {
|
|
205
|
+
if (!(error instanceof IncompletePartError))
|
|
206
|
+
throw error;
|
|
207
|
+
warn(` WARNING: ${error.message} — no record written for it`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return records;
|
|
211
|
+
}
|
|
135
212
|
/**
|
|
136
213
|
* Forget what has been bound.
|
|
137
214
|
*
|
package/dist/scrape.d.ts
CHANGED
|
@@ -49,6 +49,21 @@ export interface MapperOptions {
|
|
|
49
49
|
}
|
|
50
50
|
/** The default: stderr, where a message nobody injected a sink for belongs. */
|
|
51
51
|
export declare const consoleWarn: Warn;
|
|
52
|
+
/**
|
|
53
|
+
* Rows to the header they imply: the union of their keys, in first-seen order.
|
|
54
|
+
*
|
|
55
|
+
* A union rather than the first row's keys, and the reason is the same for both
|
|
56
|
+
* vendors that build a `ScrapeResult` this way. A REGO-FIX collet family is
|
|
57
|
+
* mixed-unit — `D1_mm` on its metric rows and `D1_in` on its inch ones — and a
|
|
58
|
+
* MariTool CSV mixes three holder styles, so which spec keys a part publishes is
|
|
59
|
+
* a function of its style. Keying off row one drops whichever came second.
|
|
60
|
+
*
|
|
61
|
+
* It lived in both adapters, byte for byte, until 2026-08-29. Building the
|
|
62
|
+
* header of a {@link ScrapeResult} is this module's business rather than any
|
|
63
|
+
* manufacturer's — the same call `pause` and `conventions.CAD_COLUMN` already
|
|
64
|
+
* got, and the one `tests/vendor-boundary.test.ts` now makes automatically.
|
|
65
|
+
*/
|
|
66
|
+
export declare function unionHeader(rows: readonly ScrapedRow[]): string[];
|
|
52
67
|
/**
|
|
53
68
|
* Milliseconds between requests, wherever a scrape or a mirror loops.
|
|
54
69
|
*
|