crawlforge-mcp-server 5.6.7 → 5.6.9
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 +2 -2
- package/server.js +11 -18
- package/src/core/ActionExecutor.js +10 -1
- package/src/core/AgentOrchestrator.js +107 -5
- package/src/core/ChangeTracker.js +2 -1
- package/src/core/LLMsTxtAnalyzer.js +2 -1
- package/src/core/LocalizationManager.js +26 -9
- package/src/core/ResearchOrchestrator.js +47 -4
- package/src/core/StealthBrowserManager.js +179 -7
- package/src/core/analysis/ContentAnalyzer.js +34 -3
- package/src/core/analysis/sentenceUtils.js +10 -7
- package/src/core/crawlers/BFSCrawler.js +29 -17
- package/src/core/integrations/PerformanceIntegration.js +2 -1
- package/src/core/processing/ContentProcessor.js +3 -2
- package/src/core/workers/worker.js +2 -1
- package/src/tools/advanced/ScrapeWithActionsTool.js +19 -3
- package/src/tools/advanced/batchScrape/worker.js +4 -3
- package/src/tools/crawl/mapSite.js +2 -1
- package/src/tools/extract/_fetchAndParse.js +11 -26
- package/src/tools/extract/extractContent.js +5 -1
- package/src/tools/extract/extractStructured.js +13 -3
- package/src/tools/scrape/unifiedScrape.js +2 -1
- package/src/tools/search/redditSearch.js +7 -0
- package/src/tools/templates/ScrapeTemplateTool.js +56 -0
- package/src/tools/templates/shopifyJsonLdFallback.js +160 -0
- package/src/utils/challengeDetection.js +11 -0
- package/src/utils/pageTitle.js +18 -0
- package/src/utils/stealthVerdict.js +82 -0
|
@@ -8,6 +8,7 @@ import { ResultRanker } from '../search/ranking/ResultRanker.js';
|
|
|
8
8
|
import { safeFetch } from '../../utils/ssrfGuard.js';
|
|
9
9
|
import { CRAWLFORGE_USER_AGENT } from '../../utils/fetchIdentity.js';
|
|
10
10
|
import { preflightFetch } from '../../utils/robotsGate.js';
|
|
11
|
+
import { pageTitle } from '../../utils/pageTitle.js';
|
|
11
12
|
|
|
12
13
|
// Lazy singleton — avoids creating a CacheManager timer per request
|
|
13
14
|
let _ranker = null;
|
|
@@ -280,7 +281,7 @@ export class MapSiteTool {
|
|
|
280
281
|
const $ = load(html);
|
|
281
282
|
|
|
282
283
|
metadataMap.set(url, {
|
|
283
|
-
title: $
|
|
284
|
+
title: pageTitle($),
|
|
284
285
|
description: $('meta[name="description"]').attr('content') || '',
|
|
285
286
|
keywords: $('meta[name="keywords"]').attr('content') || '',
|
|
286
287
|
h1: $('h1').first().text().trim(),
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { load } from 'cheerio';
|
|
14
|
+
import { readBody } from 'crawlforge-extractors';
|
|
14
15
|
import { safeFetch } from '../../utils/ssrfGuard.js';
|
|
15
16
|
import { config } from '../../constants/config.js';
|
|
16
17
|
import { noteRetryAfter } from '../../utils/hostRateLimiter.js';
|
|
@@ -23,8 +24,15 @@ const DEFAULT_TIMEOUT_MS = 15000;
|
|
|
23
24
|
* the same cap _fetch.js applies to basic tools, so a large/hostile response
|
|
24
25
|
* (multi-hundred-MB file, endpoint streaming zeros) can't be buffered whole
|
|
25
26
|
* into a JS string and handed to cheerio/JSDOM/Turndown downstream.
|
|
26
|
-
*
|
|
27
|
-
*
|
|
27
|
+
*
|
|
28
|
+
* Decoding goes through crawlforge-extractors' readBody, which honours the
|
|
29
|
+
* Content-Type charset and the <meta charset> sniff. The local reader it
|
|
30
|
+
* replaces decoded every body as UTF-8, so scrape, extract_structured,
|
|
31
|
+
* extract_embedded_state, extract_with_llm and agent all returned mojibake
|
|
32
|
+
* for Shift_JIS pages (kakaku.com, vector.co.jp, 2ch.sc — R17, 2026-09-04)
|
|
33
|
+
* while batch_scrape and extract_metadata, already on readBody, read them
|
|
34
|
+
* correctly. Content-Length is checked up front; actual bytes read are
|
|
35
|
+
* counted as a backstop for servers that omit or lie about it.
|
|
28
36
|
* @param {Response} response
|
|
29
37
|
* @returns {Promise<string>}
|
|
30
38
|
*/
|
|
@@ -47,30 +55,7 @@ async function readTextWithSizeCap(response) {
|
|
|
47
55
|
return response.text();
|
|
48
56
|
}
|
|
49
57
|
|
|
50
|
-
|
|
51
|
-
const chunks = [];
|
|
52
|
-
let totalBytes = 0;
|
|
53
|
-
|
|
54
|
-
while (true) {
|
|
55
|
-
const { done, value } = await reader.read();
|
|
56
|
-
if (done) break;
|
|
57
|
-
totalBytes += value.byteLength;
|
|
58
|
-
if (totalBytes > maxBodySize) {
|
|
59
|
-
reader.cancel();
|
|
60
|
-
throw new Error(
|
|
61
|
-
`Response body too large: exceeded limit of ${maxBodySize} bytes`
|
|
62
|
-
);
|
|
63
|
-
}
|
|
64
|
-
chunks.push(value);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
const mergedBytes = new Uint8Array(totalBytes);
|
|
68
|
-
let offset = 0;
|
|
69
|
-
for (const chunk of chunks) {
|
|
70
|
-
mergedBytes.set(chunk, offset);
|
|
71
|
-
offset += chunk.byteLength;
|
|
72
|
-
}
|
|
73
|
-
return new TextDecoder().decode(mergedBytes);
|
|
58
|
+
return readBody(response, { maxBytes: maxBodySize });
|
|
74
59
|
}
|
|
75
60
|
|
|
76
61
|
/**
|
|
@@ -12,6 +12,8 @@ import { isThinMainContent } from '../scrape/_mainContent.js';
|
|
|
12
12
|
import { safeFetch } from '../../utils/ssrfGuard.js';
|
|
13
13
|
import { preflightFetch } from '../../utils/robotsGate.js';
|
|
14
14
|
import { noteRetryAfter } from '../../utils/hostRateLimiter.js';
|
|
15
|
+
import { readBody } from 'crawlforge-extractors';
|
|
16
|
+
import { config as appConfig } from '../../constants/config.js';
|
|
15
17
|
|
|
16
18
|
const ExtractContentSchema = z.object({
|
|
17
19
|
url: z.string().url(),
|
|
@@ -196,7 +198,9 @@ export class ExtractContentTool {
|
|
|
196
198
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
197
199
|
}
|
|
198
200
|
|
|
199
|
-
|
|
201
|
+
// readBody decodes with the body's real charset; response.text() is
|
|
202
|
+
// UTF-8-only and mangled Shift_JIS pages (R17, 2026-09-04).
|
|
203
|
+
html = await readBody(response, { maxBytes: appConfig.fetch.maxBodySize });
|
|
200
204
|
pageTitle = this.extractTitleFromHTML(html);
|
|
201
205
|
}
|
|
202
206
|
}
|
|
@@ -56,6 +56,8 @@ function mainContentText($, html, url) {
|
|
|
56
56
|
|
|
57
57
|
/** Characters of page text the model is shown; about 6k tokens. */
|
|
58
58
|
const SHOWN_TEXT_BUDGET = 24000;
|
|
59
|
+
/** Characters of the whole-page text kept when main content would crowd it out. */
|
|
60
|
+
const PAGE_HEAD_RESERVE = 6000;
|
|
59
61
|
|
|
60
62
|
/**
|
|
61
63
|
* The text the model reads: main content first, then the whole page when both
|
|
@@ -78,11 +80,19 @@ const SHOWN_TEXT_BUDGET = 24000;
|
|
|
78
80
|
* @param {string} textContent - whole-page text from fetchAndParse
|
|
79
81
|
* @returns {string}
|
|
80
82
|
*/
|
|
81
|
-
function shownText($, html, url, textContent) {
|
|
83
|
+
export function shownText($, html, url, textContent) {
|
|
82
84
|
const main = mainContentText($, html, url);
|
|
83
85
|
if (!main) return textContent;
|
|
84
|
-
if (main.length + textContent.length
|
|
85
|
-
|
|
86
|
+
if (main.length + textContent.length <= SHOWN_TEXT_BUDGET) return `${main}\n\n${textContent}`;
|
|
87
|
+
// Over budget: the article still leads, but the head of the whole-page
|
|
88
|
+
// text keeps its reserved slice. That head is the site chrome — the nav
|
|
89
|
+
// that reads "Install (6.3.3)" on swift.org while the main content's table
|
|
90
|
+
// is titled "Previous Releases" and starts at 6.3.2 — and the link-heavy
|
|
91
|
+
// lists Readability drops, such as the version list on gradle.org/releases,
|
|
92
|
+
// where main content alone answered nothing at all (R17, 2026-09-04).
|
|
93
|
+
const head = textContent.slice(0, PAGE_HEAD_RESERVE);
|
|
94
|
+
const mainCut = main.slice(0, Math.max(0, SHOWN_TEXT_BUDGET - head.length - 2));
|
|
95
|
+
return `${mainCut}\n\n${head}`;
|
|
86
96
|
}
|
|
87
97
|
|
|
88
98
|
/**
|
|
@@ -16,6 +16,7 @@ import { extractMainContent, isThinMainContent } from './_mainContent.js';
|
|
|
16
16
|
import { htmlToMarkdown } from '../../utils/htmlToMarkdown.js';
|
|
17
17
|
import { stripHiddenFromDom } from '../../utils/hiddenContent.js';
|
|
18
18
|
import { extractBlockText } from '../basic/extractText.js';
|
|
19
|
+
import { pageTitle } from '../../utils/pageTitle.js';
|
|
19
20
|
|
|
20
21
|
// ── Schema ────────────────────────────────────────────────────────────────────
|
|
21
22
|
|
|
@@ -132,7 +133,7 @@ function extractMetadataFromDom($, pageUrl) {
|
|
|
132
133
|
|
|
133
134
|
const title =
|
|
134
135
|
$('meta[property="og:title"]').attr('content') ||
|
|
135
|
-
$
|
|
136
|
+
pageTitle($) ||
|
|
136
137
|
$('h1').first().text().trim() || '';
|
|
137
138
|
|
|
138
139
|
const ogTags = {};
|
|
@@ -396,6 +396,13 @@ export class RedditSearchTool {
|
|
|
396
396
|
}
|
|
397
397
|
const rows = Array.isArray(data.data) ? data.data : [];
|
|
398
398
|
const results = v.mode === 'posts' ? rows.map(normalizePost) : rows.map(normalizeComment);
|
|
399
|
+
// Arctic Shift ANDs every term: "Toyota Camry 2026 out the door price paid"
|
|
400
|
+
// found nothing in r/askcarsales while "Camry OTD" found eight (R18).
|
|
401
|
+
if (results.length === 0 && v.query && v.query.trim().split(/\s+/).length > 4) {
|
|
402
|
+
notes.push(
|
|
403
|
+
`Arctic Shift matches every word of the query, so a long natural-language query (${v.query.trim().split(/\s+/).length} words) usually finds nothing. Retry with two or three keywords.`
|
|
404
|
+
);
|
|
405
|
+
}
|
|
399
406
|
return {
|
|
400
407
|
source: 'arctic_shift', mode: v.mode,
|
|
401
408
|
query: v.query ?? null, subreddit: subreddit ?? null, author: author ?? null,
|
|
@@ -16,6 +16,7 @@ import { safeFetch } from '../../utils/ssrfGuard.js';
|
|
|
16
16
|
import { preflightFetch } from '../../utils/robotsGate.js';
|
|
17
17
|
import { noteRetryAfter } from '../../utils/hostRateLimiter.js';
|
|
18
18
|
import { markPreflightRefusal } from '../../server/requestContext.js';
|
|
19
|
+
import { shopifyProductFromJsonLd } from './shopifyJsonLdFallback.js';
|
|
19
20
|
|
|
20
21
|
/**
|
|
21
22
|
* A caller mistake caught before anything is fetched: no template matches the
|
|
@@ -144,6 +145,13 @@ export class ScrapeTemplateTool {
|
|
|
144
145
|
if (response.status === 429 || response.status === 503) {
|
|
145
146
|
noteRetryAfter(fetchUrl, response.headers.get('retry-after'));
|
|
146
147
|
}
|
|
148
|
+
// shopify-product reads /products/<handle>.json; a store that refuses
|
|
149
|
+
// that endpoint (gymshark.com: 403) or a handle that no longer exists
|
|
150
|
+
// (allbirds.com: 404 after a redirect to a collection) still has the
|
|
151
|
+
// public product page, whose JSON-LD carries the price (R18).
|
|
152
|
+
if (templateId === 'shopify-product' && url && fetchUrl !== url && [401, 403, 404, 410].includes(response.status)) {
|
|
153
|
+
return await this.shopifyPageFallback({ url, tpl, status: response.status, gate, respect_robots, user_agent, timeout });
|
|
154
|
+
}
|
|
147
155
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
148
156
|
}
|
|
149
157
|
html = await response.text();
|
|
@@ -179,6 +187,54 @@ export class ScrapeTemplateTool {
|
|
|
179
187
|
|
|
180
188
|
return gate.warnings.length > 0 ? { ...result, warnings: gate.warnings } : result;
|
|
181
189
|
}
|
|
190
|
+
/**
|
|
191
|
+
* Read the product page itself when the .json endpoint was refused. The
|
|
192
|
+
* page goes through the same robots gate and SSRF guard as any fetch.
|
|
193
|
+
*/
|
|
194
|
+
async shopifyPageFallback({ url, tpl, status, respect_robots, user_agent, timeout }) {
|
|
195
|
+
const pageGate = await preflightFetch(url, {
|
|
196
|
+
respectRobots: respect_robots,
|
|
197
|
+
userAgent: user_agent,
|
|
198
|
+
tool: 'scrape_template'
|
|
199
|
+
});
|
|
200
|
+
const controller = new AbortController();
|
|
201
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
202
|
+
let response;
|
|
203
|
+
try {
|
|
204
|
+
response = await safeFetch(url, { signal: controller.signal, headers: { ...pageGate.headers } });
|
|
205
|
+
} finally {
|
|
206
|
+
clearTimeout(timeoutId);
|
|
207
|
+
}
|
|
208
|
+
if (!response.ok) {
|
|
209
|
+
throw new Error(
|
|
210
|
+
`HTTP ${status} from the products.json endpoint, and the product page ${url} answered HTTP ${response.status} too.`
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
const finalUrl = response.url || url;
|
|
214
|
+
const html = await response.text();
|
|
215
|
+
const parsed = shopifyProductFromJsonLd(html, finalUrl);
|
|
216
|
+
if (!parsed.found) {
|
|
217
|
+
throw new Error(
|
|
218
|
+
`HTTP ${status} from the products.json endpoint, and ${parsed.reason}` +
|
|
219
|
+
(finalUrl !== url ? ` (the page redirected to ${finalUrl})` : '') + '.'
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
const warnings = [
|
|
223
|
+
`The store answered /products/<handle>.json with HTTP ${status}; this record was read from the product page's schema.org JSON-LD instead. ` +
|
|
224
|
+
'Per-variant inventory, compare-at prices and option names are not in JSON-LD, so those fields are null.',
|
|
225
|
+
...pageGate.warnings
|
|
226
|
+
];
|
|
227
|
+
return {
|
|
228
|
+
template: tpl.id,
|
|
229
|
+
template_name: tpl.name,
|
|
230
|
+
url,
|
|
231
|
+
fetchedUrl: finalUrl,
|
|
232
|
+
data: parsed.data,
|
|
233
|
+
extractedAt: new Date().toISOString(),
|
|
234
|
+
warnings
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
182
238
|
}
|
|
183
239
|
|
|
184
240
|
export default ScrapeTemplateTool;
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* shopifyJsonLdFallback.js — a Shopify product from the storefront's own
|
|
3
|
+
* JSON-LD when /products/<handle>.json is refused.
|
|
4
|
+
*
|
|
5
|
+
* gymshark.com answers the JSON endpoint with 403 while the product page is
|
|
6
|
+
* public and carries a schema.org ProductGroup whose hasVariant Products each
|
|
7
|
+
* carry an Offer (R18, 2026-09-04). allbirds.com redirected a retired handle
|
|
8
|
+
* to a collection page, so the .json URL was a 404 with no product behind it.
|
|
9
|
+
* Both used to surface as a bare "HTTP 403/404" from the template. This reads
|
|
10
|
+
* the page instead and says what it fell back to; per-variant inventory is not
|
|
11
|
+
* in JSON-LD, so `available` is the offer's availability and variants carry
|
|
12
|
+
* no stock count.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { load } from 'cheerio';
|
|
16
|
+
|
|
17
|
+
function asArray(value) {
|
|
18
|
+
if (value == null) return [];
|
|
19
|
+
return Array.isArray(value) ? value : [value];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function flattenLd(node, out = []) {
|
|
23
|
+
if (!node || typeof node !== 'object') return out;
|
|
24
|
+
if (Array.isArray(node)) { node.forEach((n) => flattenLd(n, out)); return out; }
|
|
25
|
+
out.push(node);
|
|
26
|
+
if (node['@graph']) flattenLd(node['@graph'], out);
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const typed = (re) => (node) => asArray(node?.['@type']).some((t) => re.test(String(t)));
|
|
31
|
+
const isProduct = typed(/^product$/i);
|
|
32
|
+
const isProductGroup = typed(/^productgroup$/i);
|
|
33
|
+
|
|
34
|
+
function toNumber(value) {
|
|
35
|
+
if (value == null || value === '') return null;
|
|
36
|
+
const n = Number(String(value).replace(/[^\d.,-]/g, '').replace(/,(?=\d{3}\b)/g, '').replace(',', '.'));
|
|
37
|
+
return Number.isFinite(n) ? n : null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function money(n) {
|
|
41
|
+
return n == null ? null : n.toFixed(2);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Gymshark writes its name as "Arrival 5" Shorts" inside the JSON.
|
|
45
|
+
function decodeEntities(value) {
|
|
46
|
+
if (typeof value !== 'string') return null;
|
|
47
|
+
const text = /&(?:[a-z]+|#\d+|#x[0-9a-f]+);/i.test(value) ? load(`<x>${value}</x>`)('x').text() : value;
|
|
48
|
+
return text.replace(/\s+/g, ' ').trim() || null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function availabilityOf(offer) {
|
|
52
|
+
const a = String(offer?.availability || '');
|
|
53
|
+
if (!a) return null;
|
|
54
|
+
if (/InStock|PreOrder|BackOrder|LimitedAvailability|OnlineOnly/i.test(a)) return true;
|
|
55
|
+
if (/OutOfStock|SoldOut|Discontinued/i.test(a)) return false;
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Offers of a node, an AggregateOffer's own list included. */
|
|
60
|
+
function offersOf(node) {
|
|
61
|
+
return asArray(node?.offers)
|
|
62
|
+
.flatMap((o) => (o && typed(/AggregateOffer/i)(o) && o.offers ? asArray(o.offers) : [o]))
|
|
63
|
+
.filter(Boolean);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function offerPrice(offer) {
|
|
67
|
+
return toNumber(offer.price ?? offer.lowPrice);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @param {string} html - the product page
|
|
72
|
+
* @param {string} url - the product page URL
|
|
73
|
+
* @returns {{ found: boolean, data?: object, reason?: string }}
|
|
74
|
+
*/
|
|
75
|
+
export function shopifyProductFromJsonLd(html, url) {
|
|
76
|
+
const $ = load(html || '');
|
|
77
|
+
const nodes = [];
|
|
78
|
+
$('script[type="application/ld+json"]').each((_, el) => {
|
|
79
|
+
const raw = $(el).html();
|
|
80
|
+
if (!raw) return;
|
|
81
|
+
try { flattenLd(JSON.parse(raw), nodes); } catch { /* one bad block does not lose the rest */ }
|
|
82
|
+
});
|
|
83
|
+
const product = nodes.find(isProductGroup) || nodes.find(isProduct);
|
|
84
|
+
if (!product) {
|
|
85
|
+
return {
|
|
86
|
+
found: false,
|
|
87
|
+
reason: `${url} carries no schema.org Product JSON-LD` +
|
|
88
|
+
(/\/collections\//.test(url) ? ' — the product URL redirected to a collection page, so the product handle no longer exists' : '')
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// A ProductGroup (or a Product with hasVariant) prices each variant on the
|
|
93
|
+
// variant; a plain Product prices its offers directly.
|
|
94
|
+
const variantNodes = asArray(product.hasVariant).filter(isProduct);
|
|
95
|
+
const variants = variantNodes.length > 0
|
|
96
|
+
? variantNodes.map((v) => {
|
|
97
|
+
const offers = offersOf(v);
|
|
98
|
+
const prices = offers.map(offerPrice).filter((n) => n != null);
|
|
99
|
+
const availabilities = offers.map(availabilityOf).filter((a) => a !== null);
|
|
100
|
+
const options = ['size', 'color', 'material', 'pattern'].map((k) => decodeEntities(v[k])).filter(Boolean);
|
|
101
|
+
return {
|
|
102
|
+
id: v.sku || v.mpn || v.productID || null,
|
|
103
|
+
title: decodeEntities(v.name) || options.join(' / ') || v.sku || null,
|
|
104
|
+
price: money(prices.length ? Math.min(...prices) : null),
|
|
105
|
+
compare_at_price: null,
|
|
106
|
+
sku: v.sku || null,
|
|
107
|
+
available: availabilities.length ? availabilities.some(Boolean) : null,
|
|
108
|
+
inventory_quantity: null,
|
|
109
|
+
options
|
|
110
|
+
};
|
|
111
|
+
})
|
|
112
|
+
: offersOf(product).map((o) => ({
|
|
113
|
+
id: o.sku || o.mpn || null,
|
|
114
|
+
title: decodeEntities(o.name) || o.sku || null,
|
|
115
|
+
price: money(offerPrice(o)),
|
|
116
|
+
compare_at_price: null,
|
|
117
|
+
sku: o.sku || null,
|
|
118
|
+
available: availabilityOf(o),
|
|
119
|
+
inventory_quantity: null,
|
|
120
|
+
options: []
|
|
121
|
+
}));
|
|
122
|
+
|
|
123
|
+
const allOffers = variantNodes.length > 0 ? variantNodes.flatMap(offersOf) : offersOf(product);
|
|
124
|
+
const prices = allOffers.map(offerPrice).filter((n) => n != null);
|
|
125
|
+
const highs = allOffers.map((o) => toNumber(o.highPrice)).filter((n) => n != null);
|
|
126
|
+
const priceMin = prices.length ? Math.min(...prices) : null;
|
|
127
|
+
const priceMax = prices.length ? Math.max(...prices, ...highs) : (highs.length ? Math.max(...highs) : null);
|
|
128
|
+
const currency = allOffers.map((o) => o.priceCurrency).find(Boolean) || null;
|
|
129
|
+
const availabilities = allOffers.map(availabilityOf).filter((v) => v !== null);
|
|
130
|
+
const available = availabilities.length ? availabilities.some(Boolean) : null;
|
|
131
|
+
|
|
132
|
+
let handle = null;
|
|
133
|
+
try { handle = (new URL(url).pathname.match(/\/products\/([^/?#]+)/) || [])[1] || null; } catch { /* keep null */ }
|
|
134
|
+
|
|
135
|
+
const brand = product.brand && typeof product.brand === 'object' ? product.brand.name : product.brand;
|
|
136
|
+
const images = asArray(product.image).map((i) => (i && typeof i === 'object' ? i.url || i.contentUrl : i)).filter(Boolean);
|
|
137
|
+
const options = asArray(product.variesBy).map((v) => String(v).split('/').pop().toLowerCase()).filter(Boolean);
|
|
138
|
+
|
|
139
|
+
const data = {
|
|
140
|
+
title: decodeEntities(product.name),
|
|
141
|
+
vendor: decodeEntities(brand),
|
|
142
|
+
product_type: decodeEntities(product.category),
|
|
143
|
+
handle,
|
|
144
|
+
product_id: product.productGroupID || product.productID || product.sku || null,
|
|
145
|
+
price: money(priceMin),
|
|
146
|
+
compare_at_price: null,
|
|
147
|
+
on_sale: null,
|
|
148
|
+
currency,
|
|
149
|
+
price_min: money(priceMin),
|
|
150
|
+
price_max: money(priceMax),
|
|
151
|
+
available,
|
|
152
|
+
variants,
|
|
153
|
+
options,
|
|
154
|
+
description: decodeEntities(product.description),
|
|
155
|
+
images,
|
|
156
|
+
url: product.url || url,
|
|
157
|
+
source: 'json-ld'
|
|
158
|
+
};
|
|
159
|
+
return { found: true, data };
|
|
160
|
+
}
|
|
@@ -40,6 +40,17 @@ const CHALLENGES = [
|
|
|
40
40
|
title: /^access denied/i,
|
|
41
41
|
markers: /errors\.edgesuite\.net/i,
|
|
42
42
|
evidence: 'an Akamai access-denied page'
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
// Vercel's Attack Challenge Mode answers with HTTP 429, an
|
|
46
|
+
// x-vercel-mitigated: challenge header and a JavaScript interstitial
|
|
47
|
+
// titled "Vercel Security Checkpoint" (lesswrong.com, hashicorp.com,
|
|
48
|
+
// bombas.com, R17 2026-09-04). Chromium solves it and reloads; camoufox
|
|
49
|
+
// was left on the interstitial, which then read as a successful scrape.
|
|
50
|
+
vendor: 'vercel',
|
|
51
|
+
title: /^vercel security checkpoint/i,
|
|
52
|
+
markers: /vercel\.link\/security-checkpoint|_vercel\/challenge|x-vercel-challenge-token/i,
|
|
53
|
+
evidence: 'a Vercel Security Checkpoint page'
|
|
43
54
|
}
|
|
44
55
|
];
|
|
45
56
|
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Document title from a cheerio root.
|
|
3
|
+
*
|
|
4
|
+
* A bare `$('title')` also matches inline `<svg><title>` elements in the body,
|
|
5
|
+
* and cheerio's `.text()` concatenates every match: roc-lang.org came back as
|
|
6
|
+
* "Roc — a fast, friendly, functional languageGitHubYouTubeTwitter..." in the
|
|
7
|
+
* R17 sweep (2026-09-04), the same defect extract_metadata fixed for MDN on
|
|
8
|
+
* 2026-08-26. Read the head title first and fall back to the first `<title>`
|
|
9
|
+
* anywhere for fragments without a head.
|
|
10
|
+
*
|
|
11
|
+
* @param {import('cheerio').CheerioAPI} $
|
|
12
|
+
* @returns {string} trimmed title or ''
|
|
13
|
+
*/
|
|
14
|
+
export function pageTitle($) {
|
|
15
|
+
return $('head > title').first().text().trim() || $('title').first().text().trim();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export default pageTitle;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* stealthVerdict.js — decide what a stealth-rendered document is.
|
|
3
|
+
*
|
|
4
|
+
* A browser scrape that reached a document is not a scrape that succeeded.
|
|
5
|
+
* Round 18 (2026-09-04, pricing sweep) returned success:true for Edmunds'
|
|
6
|
+
* "403 - Access Denied" page, Lufthansa's "Page not found", Home Depot's
|
|
7
|
+
* "Error Page" and Hilton's "Something went wrong" — each an HTTP error or a
|
|
8
|
+
* soft block served with a title and a few lines of prose, so the R15
|
|
9
|
+
* challenge-vendor check and the R16 empty-document check both passed it.
|
|
10
|
+
* The HTTP status of the navigation and a short error-titled document are
|
|
11
|
+
* now failures too; the content is still returned so the caller can read
|
|
12
|
+
* what the site served.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { detectChallengePage } from './challengeDetection.js';
|
|
16
|
+
|
|
17
|
+
// A document this short with one of these titles is an error placeholder,
|
|
18
|
+
// not a page. Real pages with these words in a longer title (a news story
|
|
19
|
+
// about an outage) carry far more text than the cap.
|
|
20
|
+
const ERROR_TITLE = /^(?:(?:\d{3}\s*[-–—|:]\s*)?(?:error(?: page)?|access denied|forbidden|(?:page )?not found|service unavailable|internal server error|bad gateway|something went wrong|oops!?[^\n]{0,60}))$/i;
|
|
21
|
+
export const SOFT_ERROR_MAX_CHARS = 1500;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {{ url?: string, title?: string, text?: string, html?: string, status?: number|null }} scraped
|
|
25
|
+
* @param {{ waitedMs?: number, allowEmpty?: boolean }} [options]
|
|
26
|
+
* @returns {{ success: boolean, status: number|null, error?: string, blocked?: { vendor: string, evidence: string } }}
|
|
27
|
+
*/
|
|
28
|
+
export function stealthDocumentVerdict(scraped, { waitedMs = 0, allowEmpty = false } = {}) {
|
|
29
|
+
const status = Number.isInteger(scraped?.status) ? scraped.status : null;
|
|
30
|
+
const url = scraped?.url || '';
|
|
31
|
+
const title = String(scraped?.title || '').trim();
|
|
32
|
+
const text = String(scraped?.text || '').trim();
|
|
33
|
+
|
|
34
|
+
const challenge = detectChallengePage(scraped || {});
|
|
35
|
+
if (challenge) {
|
|
36
|
+
return {
|
|
37
|
+
success: false,
|
|
38
|
+
status,
|
|
39
|
+
blocked: challenge,
|
|
40
|
+
error: `${challenge.vendor} served a challenge page instead of the content (${challenge.evidence}); the stealth browser did not pass it.`
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (status !== null && status >= 400) {
|
|
45
|
+
const why = status === 403
|
|
46
|
+
? 'A 403 with no challenge vendor on the page is an IP-reputation or WAF block; the site will not serve this network.'
|
|
47
|
+
: status === 404
|
|
48
|
+
? 'The site says the URL does not exist — check the path.'
|
|
49
|
+
: status === 429
|
|
50
|
+
? 'The site is rate-limiting this network; wait before retrying.'
|
|
51
|
+
: 'Retry later; the server, not the page, failed.';
|
|
52
|
+
return {
|
|
53
|
+
success: false,
|
|
54
|
+
status,
|
|
55
|
+
error: `HTTP ${status}: ${url} answered with an error page${title ? ` titled "${title}"` : ''}, not the resource; the content returned is that page. ${why}`
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (!title && !text) {
|
|
60
|
+
if (allowEmpty) return { success: true, status };
|
|
61
|
+
return {
|
|
62
|
+
success: false,
|
|
63
|
+
status,
|
|
64
|
+
error:
|
|
65
|
+
`The stealth browser reached ${url} but the document rendered no title and no text` +
|
|
66
|
+
` after ${waitedMs}ms of extra wait (${(scraped?.html || '').length} bytes of HTML).` +
|
|
67
|
+
' A JavaScript-rendered page needs a longer wait_for; an empty document means the server sent nothing to render.'
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (text.length < SOFT_ERROR_MAX_CHARS && ERROR_TITLE.test(title)) {
|
|
72
|
+
return {
|
|
73
|
+
success: false,
|
|
74
|
+
status,
|
|
75
|
+
error:
|
|
76
|
+
`${url} rendered an error page titled "${title}" (${text.length} characters of text) instead of the resource` +
|
|
77
|
+
' — a soft block or an application error. Retry later, or with a longer wait_for if the site paints content after a placeholder.'
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return { success: true, status };
|
|
82
|
+
}
|