crawlforge-extractors 1.6.1 → 1.6.2

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.2",
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
@@ -281,6 +281,29 @@ function amazonByline($) {
281
281
  * off the price itself: an explicit code ("52,02USD"), else the symbol. A
282
282
  * bare "$" belongs to several marketplaces, told apart by the canonical host.
283
283
  */
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
+
284
307
  function amazonCurrency($, price) {
285
308
  if (!price) return null;
286
309
  const code = price.match(/(USD|EUR|GBP|INR|JPY|CAD|AUD|MXN|BRL|SGD|AED|SAR|PLN|TRY)(?![A-Z])/);
@@ -303,6 +326,14 @@ function amazonCurrency($, price) {
303
326
  return null;
304
327
  }
305
328
 
329
+ function hnCommentCount(label) {
330
+ const value = (label || '').trim();
331
+ if (!value) return null;
332
+ if (/^discuss$/i.test(value)) return '0';
333
+ const match = value.match(/^(\d+)\s*comments?$/i);
334
+ return match ? match[1] : value;
335
+ }
336
+
306
337
  function amazonRating(value) {
307
338
  const match = tidy(value)?.match(/([\d.]+)/);
308
339
  return match ? Number.parseFloat(match[1]) : null;
@@ -571,7 +602,10 @@ export const TEMPLATES = [
571
602
  id: 'amazon-product',
572
603
  name: 'Amazon Product',
573
604
  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,
605
+ // Every marketplace the extractor handles. "jp" alone never matched
606
+ // amazon.co.jp, and .es/.in/.it worked by explicit id while template:"auto"
607
+ // refused them (R15, 2026-09-04).
608
+ 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
609
  extract($) {
576
610
  // Amazon serves its robot check as HTTP 200: a "Continue shopping" page
577
611
  // whose only form posts to /errors/validateCaptcha. Every selector below
@@ -591,7 +625,7 @@ export const TEMPLATES = [
591
625
  .map(fullSizeImage)
592
626
  .filter(Boolean);
593
627
 
594
- const price = text($, '.a-price .a-offscreen') || text($, '#priceblock_ourprice') || text($, '#priceblock_dealprice');
628
+ const price = amazonPrice($) || text($, '#priceblock_ourprice') || text($, '#priceblock_dealprice');
595
629
 
596
630
  return {
597
631
  title: tidy(text($, '#productTitle')),
@@ -769,8 +803,10 @@ export const TEMPLATES = [
769
803
  // ".age a" wraps the relative age string ("3 hours ago"); its href is the item permalink.
770
804
  posted: $subtext.find('.age a').text().trim() || null,
771
805
  // 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
806
+ // "1053 comments", "1 comment" or "discuss" (none yet) become one
807
+ // shape, a bare count, like score above. Job posts have no comments
808
+ // link at all -> null.
809
+ comments: hnCommentCount($subtext.find('a[href*="item"]').not('.age a').last().text())
774
810
  });
775
811
  });
776
812
  return { stories: stories.slice(0, 30), scraped_at: new Date().toISOString() };