crawlforge-extractors 1.6.2 → 1.6.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "crawlforge-extractors",
3
- "version": "1.6.2",
3
+ "version": "1.6.4",
4
4
  "description": "Extraction logic shared by the CrawlForge MCP server and REST API — scrape templates, charset-correct capped body reading, structural fingerprinting, and embedded-state extraction. One implementation, so the two surfaces cannot drift apart.",
5
5
  "type": "module",
6
6
  "main": "./index.js",
package/src/body.js CHANGED
@@ -26,6 +26,8 @@ export class BodyTooLargeError extends Error {
26
26
  * @param {Uint8Array} bytes
27
27
  * @returns {string}
28
28
  */
29
+ export const META_CHARSET_SNIFF_BYTES = 8192;
30
+
29
31
  export function detectCharset(response, bytes) {
30
32
  const contentType = response.headers?.get?.('content-type') || '';
31
33
  const headerMatch = /charset=["']?([\w-]+)/i.exec(contentType);
@@ -33,10 +35,14 @@ export function detectCharset(response, bytes) {
33
35
  return headerMatch[1].trim().toLowerCase();
34
36
  }
35
37
 
36
- // <meta charset> tags must appear within the first 1024 bytes per the
37
- // HTML5 spec's prescan algorithm; ASCII-range bytes decode identically
38
- // under latin1 regardless of the document's real encoding.
39
- const sniffLength = Math.min(bytes.byteLength, 1024);
38
+ // The HTML5 prescan algorithm requires a <meta charset> within the first
39
+ // 1024 bytes, but real pages break the rule: vector.co.jp/magazine/softnews
40
+ // (Shift_JIS, no charset in the Content-Type header) declares it at byte
41
+ // 1293, behind a comment block, and every browser still decodes it
42
+ // correctly. Sniff a full 8 KB, which covers every <head> seen in the
43
+ // wild without decoding the whole body twice. ASCII-range bytes decode
44
+ // identically under latin1 regardless of the document's real encoding.
45
+ const sniffLength = Math.min(bytes.byteLength, META_CHARSET_SNIFF_BYTES);
40
46
  const sniffText = new TextDecoder('latin1').decode(bytes.subarray(0, sniffLength));
41
47
  const metaMatch =
42
48
  /<meta[^>]+charset=["']?([\w-]+)/i.exec(sniffText) ||
@@ -23,6 +23,10 @@ const STATE_VARIABLES = [
23
23
  { name: 'nuxt', variable: '__NUXT__' },
24
24
  { name: 'apollo_state', variable: '__APOLLO_STATE__' },
25
25
  { name: 'initial_state', variable: '__INITIAL_STATE__' },
26
+ // tumblr.com spells it with three underscores and assigns it in bracket
27
+ // notation: window['___INITIAL_STATE___'] = {...} (R17, 2026-09-04). Same
28
+ // key as the two-underscore form; the first one found on a page wins.
29
+ { name: 'initial_state', variable: '___INITIAL_STATE___' },
26
30
  { name: 'preloaded_state', variable: '__PRELOADED_STATE__' },
27
31
  // nytimes.com ships its whole front page as window.__preloadedData; with
28
32
  // only the four names above, a 1.1 MB page surfaced nothing but its
@@ -334,8 +338,13 @@ export function extractEmbeddedState(rawHtml) {
334
338
  }
335
339
 
336
340
  for (const { name, variable } of STATE_VARIABLES) {
341
+ if (data[name] !== undefined) continue;
342
+ // Dot or bracket notation on window/self/globalThis, or a bare/var
343
+ // assignment; \b keeps MY__INITIAL_STATE__ from matching __INITIAL_STATE__.
337
344
  const assignment = html.match(
338
- new RegExp(`(?:window|self|globalThis)?\\.?\\b${variable}\\s*=\\s*`)
345
+ new RegExp(
346
+ `(?:(?:window|self|globalThis)\\s*\\[\\s*(['"])${variable}\\1\\s*\\]|(?:(?:window|self|globalThis)\\.)?\\b${variable})\\s*=\\s*`
347
+ )
339
348
  );
340
349
  if (!assignment) continue;
341
350
 
package/src/templates.js CHANGED
@@ -270,10 +270,96 @@ function amazonByline($) {
270
270
  // continues into "(Author) Format: Hardcover".
271
271
  if (/^by\s/i.test(raw)) return contributor || tidy(raw.replace(/^by\s+/i, '').split('(')[0]);
272
272
 
273
+ // Every other marketplace phrases the book byline in its own language —
274
+ // "Engelska utgåvan av George Orwell (Författare)", "Wydanie: Angielski
275
+ // George Orwell (Autor)", "Édition en Anglais de George Orwell (Auteur)" —
276
+ // and none starts with "by", so the chrome came back whole on amazon.se,
277
+ // .pl, .com.be and .com.tr (R17, 2026-09-04). The author's own link
278
+ // carries the bare name on every marketplace.
279
+ const authorLink = tidy($('#bylineInfo .author a, #bylineInfo a.contributorNameID').first().text());
280
+ if (authorLink && /\(/.test(raw)) return authorLink;
281
+
282
+ // "Marke: Sony", "Marca: Sony", "Marque : Sony" — a localised brand label.
283
+ const labelled = raw.match(/^[\p{L}\s]{2,20}?\s?:\s*(.+)$/u);
284
+ if (labelled && !/\(/.test(raw)) return tidy(labelled[1]);
285
+
273
286
  return raw;
274
287
  }
275
288
 
276
- /** "4.7 out of 5 stars" → 4.7 */
289
+ /**
290
+ * The visible price. Amazon renders it twice: an offscreen string for screen
291
+ * readers and a whole/decimal/fraction triplet for the eye — and a product
292
+ * page carries dozens of such blocks that are not this product's price at
293
+ * all. Reading the first `.a-price` on the page returned, on amazon.de/.it/
294
+ * .co.jp book pages served without a buy box, the price of the first item in
295
+ * the "similar products" carousel ("52,13USD" for a book listed from 30,57
296
+ * USD), and on amazon.nl/.co.uk/.com.au it returned null because the buy
297
+ * box's offscreen span is a blank " " (all observed 2026-09-04). So the block
298
+ * is chosen by where it sits: the buy box first (Amazon's own priceToPay
299
+ * class, or the corePrice/apex containers), then any block outside the
300
+ * regions that always show OTHER products (carousels, bundles, the comparison
301
+ * table, the other-sellers link) or a struck-through list price, then the
302
+ * legacy priceblock ids, and last the selected format swatch — a book without
303
+ * a buy box shows "ab 30,57 USD" / "da 42,16 €" there, and the qualifier is
304
+ * kept because that is a from-price, not the price.
305
+ *
306
+ * Within a block: the offscreen string when it is there; when it is blank,
307
+ * the triplet is rebuilt as symbol + whole + separator + fraction, with the
308
+ * symbol on the side the markup puts it ("€44,85", "39.24£" never occurs but
309
+ * "32,89€" does). On amazon.com.au the a-price-decimal span can be EMPTY, so
310
+ * the offscreen string reads "$1105" for A$11.05 — whole "11" + fraction "05"
311
+ * with no separator, a price 100× too high that every downstream guard
312
+ * accepts. When the offscreen digits are exactly whole+fraction and nothing
313
+ * separates the fraction, the separator is put back: "." unless the
314
+ * marketplace writes its decimals with a comma.
315
+ */
316
+ const AMAZON_OTHER_PRODUCT_PRICE =
317
+ '[id^="sims-"], [id^="sp_"], .a-carousel, [data-a-carousel-options], #HLCXComparisonTable, ' +
318
+ '#olpLinkWidget_feature_div, #dynamic-aod-ingress-box, [id*="sponsored"], [class*="fbt"], ' +
319
+ '#twister-plus-tool-tip, #twisterPlusPriceSubtotalWWDesktop_feature_div, .a-text-price';
320
+ const AMAZON_BUY_BOX_PRICE =
321
+ '.priceToPay, .apexPriceToPay, #corePrice_feature_div *, #corePriceDisplay_desktop_feature_div *, ' +
322
+ '#apex_desktop *, #corePrice_desktop *';
323
+
324
+ function amazonDecimalSeparator($) {
325
+ const host = (attr($, 'link[rel="canonical"]', 'href') || '').match(/^https?:\/\/([^/]+)/)?.[1] || '';
326
+ return /\.(de|fr|es|it|nl|se|pl|com\.br|com\.tr|com\.be)$/.test(host) ? ',' : '.';
327
+ }
328
+
329
+ function amazonBlockPrice($, block, separator) {
330
+ const $block = $(block);
331
+ const offscreen = tidy($block.find('.a-offscreen').first().text());
332
+ const whole = ($block.find('.a-price-whole').first().text() || '').replace(/\D/g, '');
333
+ const fraction = ($block.find('.a-price-fraction').first().text() || '').replace(/\D/g, '');
334
+ if (offscreen) {
335
+ if (!whole || !fraction) return offscreen;
336
+ if (offscreen.replace(/\D/g, '') !== whole + fraction) return offscreen;
337
+ if (new RegExp(`[.,]${fraction}(?!\\d)`).test(offscreen)) return offscreen;
338
+ return offscreen.replace(new RegExp(`(\\d)(${fraction})(?!\\d)`), `$1${separator}$2`);
339
+ }
340
+ if (!whole) return null;
341
+ const amount = fraction ? `${whole}${separator}${fraction}` : whole;
342
+ const symbol = tidy($block.find('.a-price-symbol').first().text()) || '';
343
+ const symbolFirst = $block.find('.a-price-symbol, .a-price-whole').first().hasClass('a-price-symbol');
344
+ return symbolFirst ? `${symbol}${amount}` : `${amount}${symbol}`;
345
+ }
346
+
347
+ function amazonPrice($) {
348
+ const separator = amazonDecimalSeparator($);
349
+ const own = $('.a-price').filter((_, el) => $(el).closest(AMAZON_OTHER_PRODUCT_PRICE).length === 0);
350
+ for (const pool of [own.filter(AMAZON_BUY_BOX_PRICE), own]) {
351
+ for (const block of pool.toArray()) {
352
+ const price = amazonBlockPrice($, block, separator);
353
+ if (price) return price;
354
+ }
355
+ }
356
+ return (
357
+ text($, '#priceblock_ourprice') ||
358
+ text($, '#priceblock_dealprice') ||
359
+ tidy(text($, '#tmmSwatches .swatchElement.selected .slot-price'))
360
+ );
361
+ }
362
+
277
363
  /**
278
364
  * ISO 4217 code for an Amazon price string. The add-to-cart form's hidden
279
365
  * currencyCode input is absent on pages Amazon serves without a buy box
@@ -281,32 +367,9 @@ function amazonByline($) {
281
367
  * off the price itself: an explicit code ("52,02USD"), else the symbol. A
282
368
  * bare "$" belongs to several marketplaces, told apart by the canonical host.
283
369
  */
284
- /**
285
- * The visible price. Amazon renders it twice: an offscreen string for screen
286
- * readers and a whole/decimal/fraction triplet for the eye. On amazon.com.au
287
- * (observed 2026-09-04) the a-price-decimal span is EMPTY, so the offscreen
288
- * string reads "$1105" for A$11.05 — whole "11" + fraction "05" with no
289
- * separator, a price 100× too high that every downstream guard accepts. When
290
- * the offscreen digits are exactly whole+fraction and nothing separates the
291
- * fraction, the separator is put back: "." unless the marketplace writes its
292
- * decimals with a comma.
293
- */
294
- function amazonPrice($) {
295
- const offscreen = text($, '.a-price .a-offscreen');
296
- if (!offscreen) return null;
297
- const whole = (text($, '.a-price .a-price-whole') || '').replace(/\D/g, '');
298
- const fraction = (text($, '.a-price .a-price-fraction') || '').replace(/\D/g, '');
299
- if (!whole || !fraction) return offscreen;
300
- if (offscreen.replace(/\D/g, '') !== whole + fraction) return offscreen;
301
- if (new RegExp(`[.,]${fraction}(?!\\d)`).test(offscreen)) return offscreen;
302
- const host = (attr($, 'link[rel="canonical"]', 'href') || '').match(/^https?:\/\/([^/]+)/)?.[1] || '';
303
- const separator = /\.(de|fr|es|it|nl|se|pl|com\.br|com\.tr|com\.be)$/.test(host) ? ',' : '.';
304
- return offscreen.replace(new RegExp(`(\\d)(${fraction})(?!\\d)`), `$1${separator}$2`);
305
- }
306
-
307
370
  function amazonCurrency($, price) {
308
371
  if (!price) return null;
309
- const code = price.match(/(USD|EUR|GBP|INR|JPY|CAD|AUD|MXN|BRL|SGD|AED|SAR|PLN|TRY)(?![A-Z])/);
372
+ const code = price.match(/(USD|EUR|GBP|INR|JPY|CAD|AUD|MXN|BRL|SGD|AED|SAR|EGP|PLN|TRY|SEK)(?![A-Z])/);
310
373
  if (code) return code[1];
311
374
  if (price.includes('₹')) return 'INR';
312
375
  if (price.includes('€')) return 'EUR';
@@ -314,9 +377,16 @@ function amazonCurrency($, price) {
314
377
  if (price.includes('¥') || price.includes('¥')) return 'JPY';
315
378
  if (price.includes('R$')) return 'BRL';
316
379
  if (price.includes('zł')) return 'PLN';
317
- if (price.includes('₺')) return 'TRY';
380
+ // amazon.com.tr writes "460,67TL" (R17, 2026-09-04); the lira sign is rarer.
381
+ if (price.includes('₺') || /(?<![A-Za-z])TL(?![A-Za-z])/.test(price)) return 'TRY';
382
+ // Arabic-script marketplaces: dirham, Egyptian pound, riyal.
383
+ if (/د\.?إ/.test(price)) return 'AED';
384
+ if (/ج\.?م/.test(price)) return 'EGP';
385
+ if (/ر\.?س|﷼/.test(price)) return 'SAR';
386
+ const host = (attr($, 'link[rel="canonical"]', 'href') || '').match(/^https?:\/\/([^/]+)/)?.[1] || '';
387
+ // "114,30kr" on amazon.se — the only Amazon marketplace priced in kronor.
388
+ if (/(?<![A-Za-z])kr(?![A-Za-z])/i.test(price)) return /\.se$/.test(host) ? 'SEK' : null;
318
389
  if (price.includes('$')) {
319
- const host = (attr($, 'link[rel="canonical"]', 'href') || '').match(/^https?:\/\/([^/]+)/)?.[1] || '';
320
390
  if (/\.ca$/.test(host)) return 'CAD';
321
391
  if (/\.com\.au$/.test(host)) return 'AUD';
322
392
  if (/\.com\.mx$/.test(host)) return 'MXN';
@@ -326,6 +396,15 @@ function amazonCurrency($, price) {
326
396
  return null;
327
397
  }
328
398
 
399
+ function hnAbsoluteUrl(href) {
400
+ if (!href) return href;
401
+ try {
402
+ return new URL(href, 'https://news.ycombinator.com/').href;
403
+ } catch {
404
+ return href;
405
+ }
406
+ }
407
+
329
408
  function hnCommentCount(label) {
330
409
  const value = (label || '').trim();
331
410
  if (!value) return null;
@@ -334,9 +413,20 @@ function hnCommentCount(label) {
334
413
  return match ? match[1] : value;
335
414
  }
336
415
 
416
+ /**
417
+ * "4.7 out of 5 stars" → 4.7. The first number is not the rating everywhere:
418
+ * amazon.nl writes "4,8 van 5 sterren" (a comma decimal, read as 4) and
419
+ * amazon.co.jp "5つ星のうち4.7" (the scale comes first, read as 5) — both
420
+ * plausible wrong numbers, observed 2026-09-04. Every number is read, comma
421
+ * decimals included, and when one of two is the 5-star scale the other one is
422
+ * the rating.
423
+ */
337
424
  function amazonRating(value) {
338
- const match = tidy(value)?.match(/([\d.]+)/);
339
- return match ? Number.parseFloat(match[1]) : null;
425
+ const numbers = (tidy(value)?.match(/\d+(?:[.,]\d+)?/g) || [])
426
+ .map((n) => Number.parseFloat(n.replace(',', '.')));
427
+ if (numbers.length === 0) return null;
428
+ if (numbers.length === 1) return numbers[0];
429
+ return numbers.find((n) => n !== 5) ?? 5;
340
430
  }
341
431
 
342
432
  /** Both "(198,594)" and "198,594 global ratings" mean 198594. */
@@ -625,7 +715,7 @@ export const TEMPLATES = [
625
715
  .map(fullSizeImage)
626
716
  .filter(Boolean);
627
717
 
628
- const price = amazonPrice($) || text($, '#priceblock_ourprice') || text($, '#priceblock_dealprice');
718
+ const price = amazonPrice($);
629
719
 
630
720
  return {
631
721
  title: tidy(text($, '#productTitle')),
@@ -783,7 +873,9 @@ export const TEMPLATES = [
783
873
  id: 'hacker-news-front-page',
784
874
  name: 'Hacker News Front Page',
785
875
  description: 'Scrape the Hacker News front page for a list of stories with title, URL, score, and comment count.',
786
- targetPattern: /news\.ycombinator\.com(\/news)?\/?$/i,
876
+ // The same story table serves /newest, /front, /best, /ask, /show, /jobs
877
+ // and /active, and every one of them pages with ?p=N.
878
+ targetPattern: /news\.ycombinator\.com(?:\/(?:news|newest|front|best|ask|show|jobs|active))?\/?(?:[?#].*)?$/i,
787
879
  extract($) {
788
880
  const stories = [];
789
881
  $('tr.athing').each((_, el) => {
@@ -795,7 +887,9 @@ export const TEMPLATES = [
795
887
  stories.push({
796
888
  id: $row.attr('id'),
797
889
  title: $titleLink.text().trim(),
798
- url: safeHref($titleLink.attr('href')),
890
+ // Text posts (Ask HN, Show HN without a link) carry a relative
891
+ // "item?id=…" href; resolve it so every story url is absolute.
892
+ url: safeHref(hnAbsoluteUrl($titleLink.attr('href'))),
799
893
  site: $row.find('.sitebit a').text().trim() || null,
800
894
  // "1 point" on a fresh story and "3 points" on the rest — strip both.
801
895
  score: $score.text().replace(/\s*points?$/, '').trim() || null,