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.
@@ -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: $('title').text().trim(),
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
- * Content-Length is checked up front; actual bytes read are counted as a
27
- * backstop for servers that omit or lie about it.
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
- const reader = response.body.getReader();
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
- html = await response.text();
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 > SHOWN_TEXT_BUDGET) return main;
85
- return `${main}\n\n${textContent}`;
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
- $('title').text().trim() ||
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&quot; 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
+ }