crawlforge-extractors 1.6.0 → 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 +1 -1
- package/src/embeddedState.js +43 -12
- package/src/templates.js +78 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "crawlforge-extractors",
|
|
3
|
-
"version": "1.6.
|
|
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",
|
package/src/embeddedState.js
CHANGED
|
@@ -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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
-
|
|
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
|
@@ -274,6 +274,66 @@ 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
|
+
/**
|
|
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
|
+
function amazonCurrency($, price) {
|
|
308
|
+
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])/);
|
|
310
|
+
if (code) return code[1];
|
|
311
|
+
if (price.includes('₹')) return 'INR';
|
|
312
|
+
if (price.includes('€')) return 'EUR';
|
|
313
|
+
if (price.includes('£')) return 'GBP';
|
|
314
|
+
if (price.includes('¥') || price.includes('¥')) return 'JPY';
|
|
315
|
+
if (price.includes('R$')) return 'BRL';
|
|
316
|
+
if (price.includes('zł')) return 'PLN';
|
|
317
|
+
if (price.includes('₺')) return 'TRY';
|
|
318
|
+
if (price.includes('$')) {
|
|
319
|
+
const host = (attr($, 'link[rel="canonical"]', 'href') || '').match(/^https?:\/\/([^/]+)/)?.[1] || '';
|
|
320
|
+
if (/\.ca$/.test(host)) return 'CAD';
|
|
321
|
+
if (/\.com\.au$/.test(host)) return 'AUD';
|
|
322
|
+
if (/\.com\.mx$/.test(host)) return 'MXN';
|
|
323
|
+
if (/\.sg$/.test(host)) return 'SGD';
|
|
324
|
+
return 'USD';
|
|
325
|
+
}
|
|
326
|
+
return null;
|
|
327
|
+
}
|
|
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
|
+
|
|
277
337
|
function amazonRating(value) {
|
|
278
338
|
const match = tidy(value)?.match(/([\d.]+)/);
|
|
279
339
|
return match ? Number.parseFloat(match[1]) : null;
|
|
@@ -542,7 +602,10 @@ export const TEMPLATES = [
|
|
|
542
602
|
id: 'amazon-product',
|
|
543
603
|
name: 'Amazon Product',
|
|
544
604
|
description: 'Scrape an Amazon product page for title, price, rating, reviews, ASIN, and description.',
|
|
545
|
-
|
|
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,
|
|
546
609
|
extract($) {
|
|
547
610
|
// Amazon serves its robot check as HTTP 200: a "Continue shopping" page
|
|
548
611
|
// whose only form posts to /errors/validateCaptcha. Every selector below
|
|
@@ -562,12 +625,17 @@ export const TEMPLATES = [
|
|
|
562
625
|
.map(fullSizeImage)
|
|
563
626
|
.filter(Boolean);
|
|
564
627
|
|
|
628
|
+
const price = amazonPrice($) || text($, '#priceblock_ourprice') || text($, '#priceblock_dealprice');
|
|
629
|
+
|
|
565
630
|
return {
|
|
566
631
|
title: tidy(text($, '#productTitle')),
|
|
567
|
-
price
|
|
632
|
+
price,
|
|
568
633
|
// Amazon ships no priceCurrency meta tag — the ISO code is a hidden
|
|
569
|
-
// field on the add-to-cart form.
|
|
570
|
-
currency:
|
|
634
|
+
// field on the add-to-cart form, and off the price where there is none.
|
|
635
|
+
currency:
|
|
636
|
+
attr($, 'input[name*="currencyCode"]', 'value') ||
|
|
637
|
+
attr($, 'meta[itemprop="priceCurrency"]', 'content') ||
|
|
638
|
+
amazonCurrency($, price),
|
|
571
639
|
rating: amazonRating(attr($, '#acrPopover', 'title') || text($, '#averageCustomerReviews .a-icon-alt')),
|
|
572
640
|
review_count: amazonCount(text($, '#acrCustomerReviewText') || text($, '[data-hook="total-review-count"]')),
|
|
573
641
|
asin: text($, 'input#ASIN') || attr($, 'input[name="ASIN"]', 'value'),
|
|
@@ -729,13 +797,16 @@ export const TEMPLATES = [
|
|
|
729
797
|
title: $titleLink.text().trim(),
|
|
730
798
|
url: safeHref($titleLink.attr('href')),
|
|
731
799
|
site: $row.find('.sitebit a').text().trim() || null,
|
|
732
|
-
|
|
800
|
+
// "1 point" on a fresh story and "3 points" on the rest — strip both.
|
|
801
|
+
score: $score.text().replace(/\s*points?$/, '').trim() || null,
|
|
733
802
|
author: $subtext.find('.hnuser').text().trim() || null,
|
|
734
803
|
// ".age a" wraps the relative age string ("3 hours ago"); its href is the item permalink.
|
|
735
804
|
posted: $subtext.find('.age a').text().trim() || null,
|
|
736
805
|
// The comments link is also an item?id= link, so exclude the age anchor.
|
|
737
|
-
//
|
|
738
|
-
|
|
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())
|
|
739
810
|
});
|
|
740
811
|
});
|
|
741
812
|
return { stories: stories.slice(0, 30), scraped_at: new Date().toISOString() };
|