crawlforge-extractors 1.6.2 → 1.6.3

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 +91 -28
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "crawlforge-extractors",
3
- "version": "1.6.2",
3
+ "version": "1.6.3",
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
@@ -273,7 +273,80 @@ function amazonByline($) {
273
273
  return raw;
274
274
  }
275
275
 
276
- /** "4.7 out of 5 stars" → 4.7 */
276
+ /**
277
+ * The visible price. Amazon renders it twice: an offscreen string for screen
278
+ * readers and a whole/decimal/fraction triplet for the eye — and a product
279
+ * page carries dozens of such blocks that are not this product's price at
280
+ * all. Reading the first `.a-price` on the page returned, on amazon.de/.it/
281
+ * .co.jp book pages served without a buy box, the price of the first item in
282
+ * the "similar products" carousel ("52,13USD" for a book listed from 30,57
283
+ * USD), and on amazon.nl/.co.uk/.com.au it returned null because the buy
284
+ * box's offscreen span is a blank " " (all observed 2026-09-04). So the block
285
+ * is chosen by where it sits: the buy box first (Amazon's own priceToPay
286
+ * class, or the corePrice/apex containers), then any block outside the
287
+ * regions that always show OTHER products (carousels, bundles, the comparison
288
+ * table, the other-sellers link) or a struck-through list price, then the
289
+ * legacy priceblock ids, and last the selected format swatch — a book without
290
+ * a buy box shows "ab 30,57 USD" / "da 42,16 €" there, and the qualifier is
291
+ * kept because that is a from-price, not the price.
292
+ *
293
+ * Within a block: the offscreen string when it is there; when it is blank,
294
+ * the triplet is rebuilt as symbol + whole + separator + fraction, with the
295
+ * symbol on the side the markup puts it ("€44,85", "39.24£" never occurs but
296
+ * "32,89€" does). On amazon.com.au the a-price-decimal span can be EMPTY, so
297
+ * the offscreen string reads "$1105" for A$11.05 — whole "11" + fraction "05"
298
+ * with no separator, a price 100× too high that every downstream guard
299
+ * accepts. When the offscreen digits are exactly whole+fraction and nothing
300
+ * separates the fraction, the separator is put back: "." unless the
301
+ * marketplace writes its decimals with a comma.
302
+ */
303
+ const AMAZON_OTHER_PRODUCT_PRICE =
304
+ '[id^="sims-"], [id^="sp_"], .a-carousel, [data-a-carousel-options], #HLCXComparisonTable, ' +
305
+ '#olpLinkWidget_feature_div, #dynamic-aod-ingress-box, [id*="sponsored"], [class*="fbt"], ' +
306
+ '#twister-plus-tool-tip, #twisterPlusPriceSubtotalWWDesktop_feature_div, .a-text-price';
307
+ const AMAZON_BUY_BOX_PRICE =
308
+ '.priceToPay, .apexPriceToPay, #corePrice_feature_div *, #corePriceDisplay_desktop_feature_div *, ' +
309
+ '#apex_desktop *, #corePrice_desktop *';
310
+
311
+ function amazonDecimalSeparator($) {
312
+ const host = (attr($, 'link[rel="canonical"]', 'href') || '').match(/^https?:\/\/([^/]+)/)?.[1] || '';
313
+ return /\.(de|fr|es|it|nl|se|pl|com\.br|com\.tr|com\.be)$/.test(host) ? ',' : '.';
314
+ }
315
+
316
+ function amazonBlockPrice($, block, separator) {
317
+ const $block = $(block);
318
+ const offscreen = tidy($block.find('.a-offscreen').first().text());
319
+ const whole = ($block.find('.a-price-whole').first().text() || '').replace(/\D/g, '');
320
+ const fraction = ($block.find('.a-price-fraction').first().text() || '').replace(/\D/g, '');
321
+ if (offscreen) {
322
+ if (!whole || !fraction) return offscreen;
323
+ if (offscreen.replace(/\D/g, '') !== whole + fraction) return offscreen;
324
+ if (new RegExp(`[.,]${fraction}(?!\\d)`).test(offscreen)) return offscreen;
325
+ return offscreen.replace(new RegExp(`(\\d)(${fraction})(?!\\d)`), `$1${separator}$2`);
326
+ }
327
+ if (!whole) return null;
328
+ const amount = fraction ? `${whole}${separator}${fraction}` : whole;
329
+ const symbol = tidy($block.find('.a-price-symbol').first().text()) || '';
330
+ const symbolFirst = $block.find('.a-price-symbol, .a-price-whole').first().hasClass('a-price-symbol');
331
+ return symbolFirst ? `${symbol}${amount}` : `${amount}${symbol}`;
332
+ }
333
+
334
+ function amazonPrice($) {
335
+ const separator = amazonDecimalSeparator($);
336
+ const own = $('.a-price').filter((_, el) => $(el).closest(AMAZON_OTHER_PRODUCT_PRICE).length === 0);
337
+ for (const pool of [own.filter(AMAZON_BUY_BOX_PRICE), own]) {
338
+ for (const block of pool.toArray()) {
339
+ const price = amazonBlockPrice($, block, separator);
340
+ if (price) return price;
341
+ }
342
+ }
343
+ return (
344
+ text($, '#priceblock_ourprice') ||
345
+ text($, '#priceblock_dealprice') ||
346
+ tidy(text($, '#tmmSwatches .swatchElement.selected .slot-price'))
347
+ );
348
+ }
349
+
277
350
  /**
278
351
  * ISO 4217 code for an Amazon price string. The add-to-cart form's hidden
279
352
  * currencyCode input is absent on pages Amazon serves without a buy box
@@ -281,29 +354,6 @@ function amazonByline($) {
281
354
  * off the price itself: an explicit code ("52,02USD"), else the symbol. A
282
355
  * bare "$" belongs to several marketplaces, told apart by the canonical host.
283
356
  */
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
357
  function amazonCurrency($, price) {
308
358
  if (!price) return null;
309
359
  const code = price.match(/(USD|EUR|GBP|INR|JPY|CAD|AUD|MXN|BRL|SGD|AED|SAR|PLN|TRY)(?![A-Z])/);
@@ -334,9 +384,20 @@ function hnCommentCount(label) {
334
384
  return match ? match[1] : value;
335
385
  }
336
386
 
387
+ /**
388
+ * "4.7 out of 5 stars" → 4.7. The first number is not the rating everywhere:
389
+ * amazon.nl writes "4,8 van 5 sterren" (a comma decimal, read as 4) and
390
+ * amazon.co.jp "5つ星のうち4.7" (the scale comes first, read as 5) — both
391
+ * plausible wrong numbers, observed 2026-09-04. Every number is read, comma
392
+ * decimals included, and when one of two is the 5-star scale the other one is
393
+ * the rating.
394
+ */
337
395
  function amazonRating(value) {
338
- const match = tidy(value)?.match(/([\d.]+)/);
339
- return match ? Number.parseFloat(match[1]) : null;
396
+ const numbers = (tidy(value)?.match(/\d+(?:[.,]\d+)?/g) || [])
397
+ .map((n) => Number.parseFloat(n.replace(',', '.')));
398
+ if (numbers.length === 0) return null;
399
+ if (numbers.length === 1) return numbers[0];
400
+ return numbers.find((n) => n !== 5) ?? 5;
340
401
  }
341
402
 
342
403
  /** Both "(198,594)" and "198,594 global ratings" mean 198594. */
@@ -625,7 +686,7 @@ export const TEMPLATES = [
625
686
  .map(fullSizeImage)
626
687
  .filter(Boolean);
627
688
 
628
- const price = amazonPrice($) || text($, '#priceblock_ourprice') || text($, '#priceblock_dealprice');
689
+ const price = amazonPrice($);
629
690
 
630
691
  return {
631
692
  title: tidy(text($, '#productTitle')),
@@ -783,7 +844,9 @@ export const TEMPLATES = [
783
844
  id: 'hacker-news-front-page',
784
845
  name: 'Hacker News Front Page',
785
846
  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,
847
+ // The same story table serves /newest, /front, /best, /ask, /show, /jobs
848
+ // and /active, and every one of them pages with ?p=N.
849
+ targetPattern: /news\.ycombinator\.com(?:\/(?:news|newest|front|best|ask|show|jobs|active))?\/?(?:[?#].*)?$/i,
787
850
  extract($) {
788
851
  const stories = [];
789
852
  $('tr.athing').each((_, el) => {