@toolpath/tool-scraper 2.0.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.
@@ -24,8 +24,12 @@
24
24
  * characters.
25
25
  */
26
26
  import { Parser } from 'htmlparser2';
27
+ import { FAMILY_TITLE_COLUMN } from '../../conventions.js';
27
28
  import { VendorResponseError } from '../../errors.js';
28
29
  import { BRANDS } from '../../identity.js';
30
+ import { pause, REQUEST_DELAY_MS } from '../../scrape.js';
31
+ import { fetchFamily } from './family.js';
32
+ import { PRODUCT_LINE_COLUMN } from './records.js';
29
33
  export const BASE = 'https://www.{host}/us/en/products/fam/_jcr_content/root/' +
30
34
  'responsivegrid/{node}.variants.{code}.html' +
31
35
  '?query={query}&uom=metric';
@@ -194,16 +198,37 @@ export function columnNames(header) {
194
198
  * `tags` is a sequence of `[name, value]` pairs appended to every row as
195
199
  * constant columns — used to tag facts the table doesn't state, e.g. the
196
200
  * thread system on a tap family.
201
+ *
202
+ * `options.familyTitle` adds two more of exactly that kind. They are tags
203
+ * rather than parsed columns because that is what they are: one string per
204
+ * family, constant down its whole table, which is the case the `tags` seam was
205
+ * built for.
197
206
  */
198
- export async function scrapeFamily(fetcher, code, brand = 'kennametal', tags = []) {
207
+ export async function scrapeFamily(fetcher, code, brand = 'kennametal', tags = [], options = {}) {
199
208
  const url = variantsUrl(code, brand);
200
209
  const { header, rows: dataRows } = parseVariantTable(await fetcher.text(url));
201
210
  if (header === null) {
202
211
  throw new VendorResponseError(`family ${code}`, 'the vendor returned no variants');
203
212
  }
213
+ // After the table, so a family the vendor no longer publishes fails on the
214
+ // table it has no rows for rather than on a title nobody would have read.
215
+ const titled = [];
216
+ if (options.familyTitle === true) {
217
+ await pause(options.delayMs ?? REQUEST_DELAY_MS);
218
+ const { title, productLine } = await fetchFamily(fetcher, code, brand);
219
+ // A page with no heading writes no columns, rather than a column of empty
220
+ // strings that reads as a vendor stating an empty name. `unionHeader` is
221
+ // not in play here — this header is positional — so an absent tag is
222
+ // simply a narrower CSV.
223
+ if (title !== '')
224
+ titled.push([FAMILY_TITLE_COLUMN, title]);
225
+ if (productLine !== null)
226
+ titled.push([PRODUCT_LINE_COLUMN, productLine]);
227
+ }
204
228
  const names = columnNames(header);
205
229
  const kept = names.filter((name) => name !== null);
206
- const csvHeader = [...kept, ...tags.map(([name]) => name)];
230
+ const allTags = [...tags, ...titled];
231
+ const csvHeader = [...kept, ...allTags.map(([name]) => name)];
207
232
  const rows = dataRows.map((row) => {
208
233
  // `dataRows` is filtered to rows exactly as long as the header, and
209
234
  // `names` has one entry per header cell, so a length mismatch here means
@@ -218,7 +243,7 @@ export async function scrapeFamily(fetcher, code, brand = 'kennametal', tags = [
218
243
  if (name !== null)
219
244
  out[name] = row[index]?.[0] ?? '';
220
245
  });
221
- for (const [name, value] of tags)
246
+ for (const [name, value] of allTags)
222
247
  out[name] = value;
223
248
  return out;
224
249
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@toolpath/tool-scraper",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "Scrape cutting-tool geometry from vendor catalogs into records and CSVs",
5
5
  "license": "MIT",
6
6
  "engines": {