crawlforge-extractors 1.6.1 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "crawlforge-extractors",
3
- "version": "1.6.1",
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",
@@ -23,7 +23,11 @@ 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
- { name: 'preloaded_state', variable: '__PRELOADED_STATE__' }
26
+ { name: 'preloaded_state', variable: '__PRELOADED_STATE__' },
27
+ // nytimes.com ships its whole front page as window.__preloadedData; with
28
+ // only the four names above, a 1.1 MB page surfaced nothing but its
29
+ // <script type="application/json"> blocks (R15, 2026-09-04).
30
+ { name: 'preloaded_data', variable: '__preloadedData' }
27
31
  ];
28
32
 
29
33
  // Script bodies cannot contain a literal "</script", so a non-greedy match is
@@ -43,6 +47,33 @@ const NEXT_F_PUSH_RE = /self\.__next_f\.push\(\s*\[\s*1\s*,\s*/g;
43
47
  const APOLLO_TRANSPORT_RE =
44
48
  /Symbol\.for\(\s*["']ApolloSSRDataTransport["']\s*\)\s*\]\s*\?\?=\s*\[\s*\]\s*\)\s*\.push\(\s*/g;
45
49
 
50
+ // Bare `undefined` is the one JS-only token serializers emit inside otherwise
51
+ // valid JSON (Apollo for still-streaming fields, nytimes' __preloadedData).
52
+ // The lookbehind/lookahead pin it to a value position, so a string containing
53
+ // the word is left alone.
54
+ const BARE_UNDEFINED_RE = /(?<=[:,[])\s*undefined\s*(?=[,}\]])/g;
55
+
56
+ /**
57
+ * JSON.parse a literal, healing bare `undefined` values to null when a
58
+ * strict parse fails. Returns parsed:undefined when the literal is not JSON
59
+ * even after healing.
60
+ * @param {string} literal
61
+ * @returns {{ parsed: unknown, healed: number }}
62
+ */
63
+ function parseJsonHealingUndefined(literal) {
64
+ try {
65
+ return { parsed: JSON.parse(literal), healed: 0 };
66
+ } catch {
67
+ const healed = (literal.match(BARE_UNDEFINED_RE) || []).length;
68
+ if (healed === 0) return { parsed: undefined, healed: 0 };
69
+ try {
70
+ return { parsed: JSON.parse(literal.replace(BARE_UNDEFINED_RE, 'null')), healed };
71
+ } catch {
72
+ return { parsed: undefined, healed: 0 };
73
+ }
74
+ }
75
+ }
76
+
46
77
  /**
47
78
  * Read an HTML attribute out of a raw tag's attribute string.
48
79
  * @param {string} attrs
@@ -219,11 +250,9 @@ export function extractApolloTransport(html) {
219
250
  const literal = readBracketedLiteral(html, APOLLO_TRANSPORT_RE.lastIndex);
220
251
  if (!literal) continue;
221
252
  APOLLO_TRANSPORT_RE.lastIndex += literal.length;
222
- try {
223
- pushes.push(JSON.parse(literal.replace(/(?<=[:,[])\s*undefined\s*(?=[,}\]])/g, 'null')));
224
- } catch {
225
- // Not JSON we can heal — skip this push, keep the rest.
226
- }
253
+ // Not JSON we can heal — skip this push, keep the rest.
254
+ const { parsed } = parseJsonHealingUndefined(literal);
255
+ if (parsed !== undefined) pushes.push(parsed);
227
256
  }
228
257
  return pushes;
229
258
  }
@@ -313,12 +342,12 @@ export function extractEmbeddedState(rawHtml) {
313
342
  const valueStart = assignment.index + assignment[0].length;
314
343
  const literal = readBracketedLiteral(html, valueStart);
315
344
  let parsed;
345
+ let healed = 0;
316
346
  if (literal !== null) {
317
- try {
318
- parsed = JSON.parse(literal);
319
- } catch {
320
- parsed = undefined;
321
- }
347
+ // nytimes.com's __preloadedData is JSON except for bare `undefined`
348
+ // values (81 of them on the front page, 2026-09-04) — the same shape
349
+ // the Apollo transport heals, so heal it the same way.
350
+ ({ parsed, healed } = parseJsonHealingUndefined(literal));
322
351
  }
323
352
 
324
353
  if (parsed === undefined) {
@@ -341,7 +370,9 @@ export function extractEmbeddedState(rawHtml) {
341
370
  }
342
371
 
343
372
  data[name] = parsed;
344
- found.push({ name, variable, bytes: serializedBytes(parsed) });
373
+ const entry = { name, variable, bytes: serializedBytes(parsed) };
374
+ if (healed > 0) entry.note = `${healed} bare undefined value(s) read as null`;
375
+ found.push(entry);
345
376
  }
346
377
 
347
378
  if (jsonScripts.length > 0) {
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
@@ -303,9 +376,28 @@ function amazonCurrency($, price) {
303
376
  return null;
304
377
  }
305
378
 
379
+ function hnCommentCount(label) {
380
+ const value = (label || '').trim();
381
+ if (!value) return null;
382
+ if (/^discuss$/i.test(value)) return '0';
383
+ const match = value.match(/^(\d+)\s*comments?$/i);
384
+ return match ? match[1] : value;
385
+ }
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
+ */
306
395
  function amazonRating(value) {
307
- const match = tidy(value)?.match(/([\d.]+)/);
308
- 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;
309
401
  }
310
402
 
311
403
  /** Both "(198,594)" and "198,594 global ratings" mean 198594. */
@@ -571,7 +663,10 @@ export const TEMPLATES = [
571
663
  id: 'amazon-product',
572
664
  name: 'Amazon Product',
573
665
  description: 'Scrape an Amazon product page for title, price, rating, reviews, ASIN, and description.',
574
- targetPattern: /amazon\.(com|co\.uk|de|fr|jp|ca|com\.au)/i,
666
+ // Every marketplace the extractor handles. "jp" alone never matched
667
+ // amazon.co.jp, and .es/.in/.it worked by explicit id while template:"auto"
668
+ // refused them (R15, 2026-09-04).
669
+ targetPattern: /amazon\.(com|co\.uk|co\.jp|de|fr|es|it|nl|se|pl|ca|in|sg|ae|sa|eg|com\.au|com\.br|com\.mx|com\.be|com\.tr)/i,
575
670
  extract($) {
576
671
  // Amazon serves its robot check as HTTP 200: a "Continue shopping" page
577
672
  // whose only form posts to /errors/validateCaptcha. Every selector below
@@ -591,7 +686,7 @@ export const TEMPLATES = [
591
686
  .map(fullSizeImage)
592
687
  .filter(Boolean);
593
688
 
594
- const price = text($, '.a-price .a-offscreen') || text($, '#priceblock_ourprice') || text($, '#priceblock_dealprice');
689
+ const price = amazonPrice($);
595
690
 
596
691
  return {
597
692
  title: tidy(text($, '#productTitle')),
@@ -749,7 +844,9 @@ export const TEMPLATES = [
749
844
  id: 'hacker-news-front-page',
750
845
  name: 'Hacker News Front Page',
751
846
  description: 'Scrape the Hacker News front page for a list of stories with title, URL, score, and comment count.',
752
- 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,
753
850
  extract($) {
754
851
  const stories = [];
755
852
  $('tr.athing').each((_, el) => {
@@ -769,8 +866,10 @@ export const TEMPLATES = [
769
866
  // ".age a" wraps the relative age string ("3 hours ago"); its href is the item permalink.
770
867
  posted: $subtext.find('.age a').text().trim() || null,
771
868
  // The comments link is also an item?id= link, so exclude the age anchor.
772
- // Job posts have no comments link at all -> null.
773
- comments: $subtext.find('a[href*="item"]').not('.age a').last().text().trim() || null
869
+ // "1053 comments", "1 comment" or "discuss" (none yet) become one
870
+ // shape, a bare count, like score above. Job posts have no comments
871
+ // link at all -> null.
872
+ comments: hnCommentCount($subtext.find('a[href*="item"]').not('.age a').last().text())
774
873
  });
775
874
  });
776
875
  return { stories: stories.slice(0, 30), scraped_at: new Date().toISOString() };