crawlforge-extractors 1.6.0 → 1.6.1

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 (2) hide show
  1. package/package.json +1 -1
  2. package/src/templates.js +39 -4
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "crawlforge-extractors",
3
- "version": "1.6.0",
3
+ "version": "1.6.1",
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/templates.js CHANGED
@@ -274,6 +274,35 @@ function amazonByline($) {
274
274
  }
275
275
 
276
276
  /** "4.7 out of 5 stars" → 4.7 */
277
+ /**
278
+ * ISO 4217 code for an Amazon price string. The add-to-cart form's hidden
279
+ * currencyCode input is absent on pages Amazon serves without a buy box
280
+ * (amazon.de and amazon.in book pages, R14 2026-09-03), so the code is read
281
+ * off the price itself: an explicit code ("52,02USD"), else the symbol. A
282
+ * bare "$" belongs to several marketplaces, told apart by the canonical host.
283
+ */
284
+ function amazonCurrency($, price) {
285
+ if (!price) return null;
286
+ const code = price.match(/(USD|EUR|GBP|INR|JPY|CAD|AUD|MXN|BRL|SGD|AED|SAR|PLN|TRY)(?![A-Z])/);
287
+ if (code) return code[1];
288
+ if (price.includes('₹')) return 'INR';
289
+ if (price.includes('€')) return 'EUR';
290
+ if (price.includes('£')) return 'GBP';
291
+ if (price.includes('¥') || price.includes('¥')) return 'JPY';
292
+ if (price.includes('R$')) return 'BRL';
293
+ if (price.includes('zł')) return 'PLN';
294
+ if (price.includes('₺')) return 'TRY';
295
+ if (price.includes('$')) {
296
+ const host = (attr($, 'link[rel="canonical"]', 'href') || '').match(/^https?:\/\/([^/]+)/)?.[1] || '';
297
+ if (/\.ca$/.test(host)) return 'CAD';
298
+ if (/\.com\.au$/.test(host)) return 'AUD';
299
+ if (/\.com\.mx$/.test(host)) return 'MXN';
300
+ if (/\.sg$/.test(host)) return 'SGD';
301
+ return 'USD';
302
+ }
303
+ return null;
304
+ }
305
+
277
306
  function amazonRating(value) {
278
307
  const match = tidy(value)?.match(/([\d.]+)/);
279
308
  return match ? Number.parseFloat(match[1]) : null;
@@ -562,12 +591,17 @@ export const TEMPLATES = [
562
591
  .map(fullSizeImage)
563
592
  .filter(Boolean);
564
593
 
594
+ const price = text($, '.a-price .a-offscreen') || text($, '#priceblock_ourprice') || text($, '#priceblock_dealprice');
595
+
565
596
  return {
566
597
  title: tidy(text($, '#productTitle')),
567
- price: text($, '.a-price .a-offscreen') || text($, '#priceblock_ourprice') || text($, '#priceblock_dealprice'),
598
+ price,
568
599
  // Amazon ships no priceCurrency meta tag — the ISO code is a hidden
569
- // field on the add-to-cart form.
570
- currency: attr($, 'input[name*="currencyCode"]', 'value') || attr($, 'meta[itemprop="priceCurrency"]', 'content'),
600
+ // field on the add-to-cart form, and off the price where there is none.
601
+ currency:
602
+ attr($, 'input[name*="currencyCode"]', 'value') ||
603
+ attr($, 'meta[itemprop="priceCurrency"]', 'content') ||
604
+ amazonCurrency($, price),
571
605
  rating: amazonRating(attr($, '#acrPopover', 'title') || text($, '#averageCustomerReviews .a-icon-alt')),
572
606
  review_count: amazonCount(text($, '#acrCustomerReviewText') || text($, '[data-hook="total-review-count"]')),
573
607
  asin: text($, 'input#ASIN') || attr($, 'input[name="ASIN"]', 'value'),
@@ -729,7 +763,8 @@ export const TEMPLATES = [
729
763
  title: $titleLink.text().trim(),
730
764
  url: safeHref($titleLink.attr('href')),
731
765
  site: $row.find('.sitebit a').text().trim() || null,
732
- score: $score.text().replace(' points', '').trim() || null,
766
+ // "1 point" on a fresh story and "3 points" on the rest — strip both.
767
+ score: $score.text().replace(/\s*points?$/, '').trim() || null,
733
768
  author: $subtext.find('.hnuser').text().trim() || null,
734
769
  // ".age a" wraps the relative age string ("3 hours ago"); its href is the item permalink.
735
770
  posted: $subtext.find('.age a').text().trim() || null,