crawlforge-mcp-server 6.17.1 → 6.18.1
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/README.md +2 -2
- package/package.json +2 -2
- package/server.js +18 -9
- package/src/core/ActionExecutor.js +57 -1
- package/src/core/AgentOrchestrator.js +58 -3
- package/src/core/AuthManager.js +12 -0
- package/src/core/crawlers/BFSCrawler.js +7 -0
- package/src/core/queue/QueueManager.js +3 -0
- package/src/schemas/toolOutputSchemas.js +6 -4
- package/src/server/fallbackHints.js +4 -4
- package/src/server/inlineThreshold.js +76 -10
- package/src/skills/agent-skills/crawlforge-batch-automation/references/actions.md +1 -1
- package/src/skills/agent-skills/crawlforge-getting-started/references/credits.md +2 -2
- package/src/skills/agent-skills/crawlforge-web-scraping/SKILL.md +4 -0
- package/src/skills/agent-skills/crawlforge-web-scraping/references/tool-reference.md +16 -2
- package/src/tools/advanced/BrowserSessionTool.js +13 -1
- package/src/tools/advanced/ScrapeWithActionsTool.js +14 -3
- package/src/tools/advanced/batchScrape/worker.js +8 -0
- package/src/tools/advanced/scrapeWithActions/recorder.js +2 -0
- package/src/tools/basic/_targetFailure.js +54 -0
- package/src/tools/basic/extractLinks.js +62 -42
- package/src/tools/basic/extractText.js +79 -14
- package/src/tools/crawl/crawlDeep.js +16 -5
- package/src/tools/crawl/mapSite.js +49 -41
- package/src/tools/extract/_fetchAndParse.js +15 -34
- package/src/tools/scrape/unifiedScrape.js +14 -2
- package/src/tools/tracking/trackChanges/index.js +33 -6
- package/src/tools/tracking/trackChanges/schema.js +2 -2
- package/src/utils/fetchLadder.js +243 -0
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { load } from 'cheerio';
|
|
14
|
-
import { readBody } from 'crawlforge-extractors';
|
|
14
|
+
import { readBody, flattenText } from 'crawlforge-extractors';
|
|
15
15
|
import { safeFetch } from '../../utils/ssrfGuard.js';
|
|
16
16
|
import { config } from '../../constants/config.js';
|
|
17
17
|
import { noteRetryAfter } from '../../utils/hostRateLimiter.js';
|
|
@@ -58,6 +58,13 @@ async function readTextWithSizeCap(response) {
|
|
|
58
58
|
return readBody(response, { maxBytes: maxBodySize });
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
/** The refusal for a binary response; batch_scrape gives the same one (R24 1.5). */
|
|
62
|
+
export function unsupportedContentTypeError(contentType) {
|
|
63
|
+
return new Error(
|
|
64
|
+
`Unsupported content type "${contentType}" — this looks like binary content, not HTML/text. Use process_document for PDFs/documents/binary files.`
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
61
68
|
/**
|
|
62
69
|
* Classify a Content-Type header for the purposes of HTML parsing.
|
|
63
70
|
* Missing header is treated as 'html' (permissive default — many servers,
|
|
@@ -65,7 +72,7 @@ async function readTextWithSizeCap(response) {
|
|
|
65
72
|
* @param {string|null} contentType
|
|
66
73
|
* @returns {'html'|'text'|'binary'}
|
|
67
74
|
*/
|
|
68
|
-
function classifyContentType(contentType) {
|
|
75
|
+
export function classifyContentType(contentType) {
|
|
69
76
|
if (!contentType) return 'html';
|
|
70
77
|
const type = contentType.split(';')[0].trim().toLowerCase();
|
|
71
78
|
if (
|
|
@@ -83,35 +90,11 @@ function classifyContentType(contentType) {
|
|
|
83
90
|
}
|
|
84
91
|
|
|
85
92
|
/**
|
|
86
|
-
* Flatten a parsed document's <body> to text
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
* table rows / list items together ("1.Story title329 points") and starves
|
|
90
|
-
* downstream LLM extraction (AgentOrchestrator, extractStructured,
|
|
91
|
-
* extractWithLlm) of any structure to parse. Works on a detached clone so the
|
|
92
|
-
* caller's $ tree is untouched — unifiedScrape reuses $ for other formats.
|
|
93
|
-
*
|
|
94
|
-
* @param {import('cheerio').CheerioAPI} $
|
|
95
|
-
* @returns {string}
|
|
93
|
+
* Flatten a parsed document's <body> to text, one line per block element.
|
|
94
|
+
* The flattener moved to crawlforge-extractors (`flattenText`) so the REST
|
|
95
|
+
* extract_text route shares it; this name stays for extractStructured.
|
|
96
96
|
*/
|
|
97
|
-
export
|
|
98
|
-
// Block boundaries are marked with a U+E000 private-use sentinel so the
|
|
99
|
-
// HTML source's own insignificant newlines can be collapsed to spaces
|
|
100
|
-
// first, and only the sentinels become line breaks. (NUL won't survive:
|
|
101
|
-
// .after()/.replaceWith() parse their argument as HTML and the parser
|
|
102
|
-
// strips NUL; U+E000 passes through and never occurs in real page text.)
|
|
103
|
-
const $body = $('body').clone();
|
|
104
|
-
$body.find('br').replaceWith('\uE000');
|
|
105
|
-
$body.find('td, th').after(' ');
|
|
106
|
-
$body
|
|
107
|
-
.find('p, div, li, tr, h1, h2, h3, h4, h5, h6, blockquote, pre, table, ul, ol, dl, section, article, header, footer')
|
|
108
|
-
.after('\uE000');
|
|
109
|
-
return $body
|
|
110
|
-
.text()
|
|
111
|
-
.replace(/\s+/g, ' ')
|
|
112
|
-
.replace(/ ?(?:\uE000 ?)+/g, '\n')
|
|
113
|
-
.trim();
|
|
114
|
-
}
|
|
97
|
+
export { flattenText as flattenBodyText };
|
|
115
98
|
|
|
116
99
|
/**
|
|
117
100
|
* Fetch a URL and return parsed HTML via Cheerio.
|
|
@@ -170,9 +153,7 @@ export async function fetchAndParse(url, options = {}) {
|
|
|
170
153
|
}
|
|
171
154
|
|
|
172
155
|
if (classification === 'binary') {
|
|
173
|
-
throw
|
|
174
|
-
`Unsupported content type "${contentType}" — this looks like binary content, not HTML/text. Use process_document for PDFs/documents/binary files.`
|
|
175
|
-
);
|
|
156
|
+
throw unsupportedContentTypeError(contentType);
|
|
176
157
|
}
|
|
177
158
|
|
|
178
159
|
const html = await readTextWithSizeCap(response);
|
|
@@ -190,7 +171,7 @@ export async function fetchAndParse(url, options = {}) {
|
|
|
190
171
|
$(stripTags.join(', ')).remove();
|
|
191
172
|
}
|
|
192
173
|
|
|
193
|
-
const textContent =
|
|
174
|
+
const textContent = flattenText($);
|
|
194
175
|
|
|
195
176
|
return { html, $, textContent, finalUrl: response.url, warnings, status: response.status };
|
|
196
177
|
}
|
|
@@ -699,16 +699,19 @@ export class UnifiedScrapeTool {
|
|
|
699
699
|
case 'text':
|
|
700
700
|
try {
|
|
701
701
|
const { load } = await import('cheerio');
|
|
702
|
+
// cheerio parses a <noscript> body as raw text, so left in it
|
|
703
|
+
// reaches `text` as literal tags (lemonde.fr's challenge page,
|
|
704
|
+
// 2026-10-03); extract_text and the markdown format drop it too.
|
|
702
705
|
if (onlyMainContent) {
|
|
703
706
|
// Plain text from Readability main content via cheerio
|
|
704
707
|
const $main = load(getMainHtml());
|
|
705
|
-
$main('script, style').remove();
|
|
708
|
+
$main('script, style, noscript').remove();
|
|
706
709
|
content.text = extractBlockText($main);
|
|
707
710
|
} else {
|
|
708
711
|
// Strip script/style on a clone, not the shared $, so other
|
|
709
712
|
// formats reading $ later aren't affected by format ordering.
|
|
710
713
|
const $clone = load($.html());
|
|
711
|
-
$clone('script, style').remove();
|
|
714
|
+
$clone('script, style, noscript').remove();
|
|
712
715
|
content.text = extractBlockText($clone);
|
|
713
716
|
}
|
|
714
717
|
} catch (err) {
|
|
@@ -790,6 +793,15 @@ export class UnifiedScrapeTool {
|
|
|
790
793
|
if (queryScoped && !formats.includes('markdown')) {
|
|
791
794
|
warnings.push('offsets index the "markdown" format of this call (same onlyMainContent); add "markdown" to formats to quote with a locator');
|
|
792
795
|
}
|
|
796
|
+
// A success with nothing in it must say so: the caller otherwise cannot
|
|
797
|
+
// tell an empty page from a scrape that lost the content.
|
|
798
|
+
if (typeof content.markdown === 'string' && content.markdown.trim() === '' && !warnings.some((w) => w.startsWith('markdown:'))) {
|
|
799
|
+
warnings.push(
|
|
800
|
+
'markdown: the page produced no markdown' +
|
|
801
|
+
(onlyMainContent ? '; retry with onlyMainContent:false if main-content extraction dropped it, or' : ';') +
|
|
802
|
+
' use escalate:true if the page is rendered by JavaScript'
|
|
803
|
+
);
|
|
804
|
+
}
|
|
793
805
|
if (!queryScoped && typeof content.markdown === 'string' && content.markdown.length > OVERSIZED_MARKDOWN_CHARS) {
|
|
794
806
|
warnings.push(`markdown: ${content.markdown.length} characters; ask for {type:"highlights", query} (1 extra credit, no model) to get only the matching sentences, table rows and code blocks`);
|
|
795
807
|
}
|
|
@@ -308,6 +308,15 @@ export class TrackChangesTool extends EventEmitter {
|
|
|
308
308
|
async setupMonitoring(params) {
|
|
309
309
|
const { url, monitoringOptions, trackingOptions, storageOptions, notificationOptions } = params;
|
|
310
310
|
|
|
311
|
+
// enabled:false turns the URL's polling monitor off and starts nothing.
|
|
312
|
+
if (monitoringOptions.enabled === false) {
|
|
313
|
+
return {
|
|
314
|
+
success: true, operation: 'monitor', url,
|
|
315
|
+
monitoring: { enabled: false, stopped: this.stopMonitoring(url) },
|
|
316
|
+
timestamp: Date.now()
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
311
320
|
if (this.activeMonitors.has(url)) {
|
|
312
321
|
clearInterval(this.activeMonitors.get(url).timer);
|
|
313
322
|
}
|
|
@@ -498,6 +507,15 @@ export class TrackChangesTool extends EventEmitter {
|
|
|
498
507
|
async stopScheduledMonitor(params) {
|
|
499
508
|
const { url, scheduledMonitorOptions } = params;
|
|
500
509
|
const monitorId = scheduledMonitorOptions?.monitorId;
|
|
510
|
+
// A polling monitor (operation:"monitor") is listed as poll:<url>.
|
|
511
|
+
if (monitorId?.startsWith('poll:')) {
|
|
512
|
+
const stopped = this.stopMonitoring(monitorId.slice(5));
|
|
513
|
+
return {
|
|
514
|
+
success: stopped, operation: 'stop_scheduled_monitor', monitorId, stopped,
|
|
515
|
+
...(stopped ? {} : { error: `No polling monitor found with id ${monitorId}` }),
|
|
516
|
+
timestamp: Date.now()
|
|
517
|
+
};
|
|
518
|
+
}
|
|
501
519
|
if (monitorId) {
|
|
502
520
|
if (!this.monitorStore._loaded) await this.monitorStore.load();
|
|
503
521
|
if (this.monitorStore.get(monitorId)) {
|
|
@@ -517,6 +535,7 @@ export class TrackChangesTool extends EventEmitter {
|
|
|
517
535
|
}
|
|
518
536
|
if (!url) throw new Error('stop_scheduled_monitor requires a url or scheduledMonitorOptions.monitorId');
|
|
519
537
|
const result = await this.scheduler.stopByUrl(url);
|
|
538
|
+
const stoppedPolling = this.stopMonitoring(url) ? 1 : 0;
|
|
520
539
|
// Only a hosted monitor whose every target is this URL — never a
|
|
521
540
|
// multi-target monitor that merely includes it.
|
|
522
541
|
let stoppedHosted = 0;
|
|
@@ -533,7 +552,7 @@ export class TrackChangesTool extends EventEmitter {
|
|
|
533
552
|
hostedError = error.message;
|
|
534
553
|
}
|
|
535
554
|
return {
|
|
536
|
-
success: true, operation: 'stop_scheduled_monitor', url, stoppedMonitors: result.stopped, stoppedHosted,
|
|
555
|
+
success: true, operation: 'stop_scheduled_monitor', url, stoppedMonitors: result.stopped, stoppedPolling, stoppedHosted,
|
|
537
556
|
...(hostedError ? { hostedError } : {}),
|
|
538
557
|
timestamp: Date.now()
|
|
539
558
|
};
|
|
@@ -541,7 +560,10 @@ export class TrackChangesTool extends EventEmitter {
|
|
|
541
560
|
|
|
542
561
|
async listScheduledMonitors() {
|
|
543
562
|
if (!this.monitorStore._loaded) await this.monitorStore.load();
|
|
544
|
-
const local =
|
|
563
|
+
const local = [
|
|
564
|
+
...this._pollingMonitors().map((m) => ({ ...m, hosted: false })),
|
|
565
|
+
...this.scheduler.list().map((m) => ({ ...m, hosted: false }))
|
|
566
|
+
];
|
|
545
567
|
// The local list never fails because the website is unreachable.
|
|
546
568
|
let hosted = [];
|
|
547
569
|
let hostedError = null;
|
|
@@ -560,16 +582,21 @@ export class TrackChangesTool extends EventEmitter {
|
|
|
560
582
|
};
|
|
561
583
|
}
|
|
562
584
|
|
|
585
|
+
/** The in-process polling monitors operation:"monitor" started, as the dashboard and the list show them. */
|
|
586
|
+
_pollingMonitors() {
|
|
587
|
+
return Array.from(this.activeMonitors.entries()).map(([url, m]) => ({
|
|
588
|
+
id: `poll:${url}`, url, kind: 'polling', status: 'active',
|
|
589
|
+
interval: m.options?.interval ?? null, stats: m.stats
|
|
590
|
+
}));
|
|
591
|
+
}
|
|
592
|
+
|
|
563
593
|
async getMonitoringDashboard(params) {
|
|
564
594
|
// The schema defaults every flag to true, but only inside a dashboardOptions
|
|
565
595
|
// object the caller supplied; an omitted object must mean the same thing.
|
|
566
596
|
const dashboardOptions = { includeRecentAlerts: true, includeTrends: true, includeMonitorStatus: true, ...(params.dashboardOptions || {}) };
|
|
567
597
|
const dashboard = this.changeTracker.getMonitoringDashboard();
|
|
568
598
|
// The tracker only knows tracked pages; the monitors that poll them live here.
|
|
569
|
-
const polling =
|
|
570
|
-
id: `poll:${url}`, url, kind: 'polling', status: 'active',
|
|
571
|
-
interval: m.options?.interval ?? null, stats: m.stats
|
|
572
|
-
}));
|
|
599
|
+
const polling = this._pollingMonitors();
|
|
573
600
|
if (!this.monitorStore._loaded) await this.monitorStore.load();
|
|
574
601
|
const scheduled = this.scheduler.list().map(m => ({
|
|
575
602
|
id: m.id, url: m.url, kind: 'scheduled',
|
|
@@ -57,7 +57,7 @@ export const TRACK_CHANGES_INPUT_SHAPE = {
|
|
|
57
57
|
}).optional().prefault({}).describe("Options for how changes are tracked and compared"),
|
|
58
58
|
|
|
59
59
|
monitoringOptions: z.object({
|
|
60
|
-
enabled: z.boolean().default(false),
|
|
60
|
+
enabled: z.boolean().default(true).describe("operation:\"monitor\" starts polling the URL; false stops the URL's polling monitor and starts nothing"),
|
|
61
61
|
interval: z.number().min(60000).max(24 * 60 * 60 * 1000).default(300000),
|
|
62
62
|
maxRetries: z.number().min(0).max(5).default(3),
|
|
63
63
|
retryDelay: z.number().min(1000).max(60000).default(5000),
|
|
@@ -113,7 +113,7 @@ export const TRACK_CHANGES_INPUT_SHAPE = {
|
|
|
113
113
|
enabled: z.boolean().default(true),
|
|
114
114
|
interval: z.number().min(60000).optional().describe("Polling interval in ms (default 1h)"),
|
|
115
115
|
goal: z.string().optional().describe("Plain-English alert goal; an LLM judges whether a change matches (degrades to threshold if no LLM)"),
|
|
116
|
-
monitorId: z.string().optional().describe("Monitor id for stop_scheduled_monitor"),
|
|
116
|
+
monitorId: z.string().optional().describe("Monitor id for stop_scheduled_monitor, as list_scheduled_monitors shows it (poll:<url> for a polling monitor)"),
|
|
117
117
|
notificationThreshold: z.enum(['minor', 'moderate', 'major', 'critical']).optional(),
|
|
118
118
|
hosted: z.boolean().default(false).describe("Run the monitor on CrawlForge's servers: it fires from the hosted scheduler whether or not this process is alive and sends email and signed webhooks. Each check bills 3 credits per compared target from the account; blocked and errored targets are free. Default false = local, in-process."),
|
|
119
119
|
name: z.string().min(1).max(80).optional().describe("Display name for a hosted monitor (default: the URL host)")
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fetchLadder — scrape's fetch ladder for extract_text and extract_links (fix
|
|
3
|
+
* plan Phase E2). Same rules and wording as the website's
|
|
4
|
+
* src/lib/tools/fetch-ladder.ts — keep the two in step.
|
|
5
|
+
*
|
|
6
|
+
* 1. A plain fetch (15 s), retried once when a 429/503 asks for at most
|
|
7
|
+
* 10 s with Retry-After.
|
|
8
|
+
* 2. A 2xx body that is not text (a PDF, an image, an archive) stops here as
|
|
9
|
+
* UNSUPPORTED_CONTENT_TYPE; it never escalates.
|
|
10
|
+
* 3. Only with escalate:true, and only when a browser could change the
|
|
11
|
+
* outcome (aBrowserMightPass, or a 444 refusal): the injected stealth
|
|
12
|
+
* stage — server.js `stealthEscalation`, the one scrape and
|
|
13
|
+
* extract_embedded_state run, behind the same compliance gate (impit
|
|
14
|
+
* first under "auto", then the browser).
|
|
15
|
+
* 4. Otherwise E1's verdict stands unchanged (targetFailure).
|
|
16
|
+
*
|
|
17
|
+
* scrape and extract_embedded_state keep their own copies of steps 3–4; this
|
|
18
|
+
* reuses their exported pieces rather than moving them.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { load } from 'cheerio';
|
|
22
|
+
import { z } from 'zod';
|
|
23
|
+
import { fetchWithTimeout } from '../tools/basic/_fetch.js';
|
|
24
|
+
import { plainVerdict, targetFailure } from '../tools/basic/_targetFailure.js';
|
|
25
|
+
import { stealthDocumentVerdict } from './stealthVerdict.js';
|
|
26
|
+
import { parseRetryAfter } from './hostRateLimiter.js';
|
|
27
|
+
import { setActualCost } from '../server/requestContext.js';
|
|
28
|
+
import { SCRAPE_ESCALATION_SHAPE, SCRAPE_ESCALATION_CREDITS, aBrowserMightPass } from '../tools/scrape/escalation.js';
|
|
29
|
+
|
|
30
|
+
/** extract_text's and extract_links's table price; escalation adds SCRAPE_ESCALATION_CREDITS. */
|
|
31
|
+
const EXTRACT_BASE_CREDITS = 1;
|
|
32
|
+
|
|
33
|
+
export const LADDER_TIMEOUT_MS = 15000;
|
|
34
|
+
|
|
35
|
+
/** A Retry-After longer than this is not waited out inside one call. */
|
|
36
|
+
export const RETRY_AFTER_MAX_MS = 10000;
|
|
37
|
+
|
|
38
|
+
export const CLIENT_RENDERED_WARNING = 'page renders client-side; use scrape';
|
|
39
|
+
|
|
40
|
+
/** The two public fields both tools take, in tools/list order. */
|
|
41
|
+
export const EXTRACT_ESCALATION_SHAPE = {
|
|
42
|
+
escalate: z.boolean().optional().default(false).describe('When the plain fetch comes back blocked (403/429/444/challenge page/empty shell), re-read the page once in the stealth browser and extract from the rendered document. Under escalate_engine "auto" a Chrome TLS handshake with the honest CrawlForge User-Agent (impit) is tried first, and the browser runs only when that does not get the page. A 404 or 5xx never escalates. Projected at 1+5; the actual charge stays at 1 when the plain fetch succeeded. Default: false'),
|
|
43
|
+
escalate_engine: SCRAPE_ESCALATION_SHAPE.escalate_engine
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// Bodies these tools read. A missing Content-Type is read as HTML.
|
|
47
|
+
const TEXTUAL_TYPE = /^(?:text\/[\w.+-]+|application\/(?:xhtml\+xml|xml|json)|application\/[\w.+-]+\+(?:xml|json))$/;
|
|
48
|
+
const JSON_TYPE = /^application\/(?:[\w.+-]+\+)?json$/;
|
|
49
|
+
|
|
50
|
+
/** The media type without parameters, lowercased; '' when absent. */
|
|
51
|
+
function mediaType(response) {
|
|
52
|
+
return (response.headers?.get?.('content-type') ?? '').split(';')[0].trim().toLowerCase();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Whether a media type is JSON (application/json or a +json suffix). */
|
|
56
|
+
export function isJsonType(type) {
|
|
57
|
+
return JSON_TYPE.test(type);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// A page that says it needs JavaScript (scrapingcourse.com's
|
|
61
|
+
// /javascript-rendering serves "Enable JavaScript to see products" over empty
|
|
62
|
+
// product cards, with no mount point to find).
|
|
63
|
+
// No \b before the verbs: cheerio's text() joins <h1>Challenge</h1><p>Enable…
|
|
64
|
+
// into one word.
|
|
65
|
+
const JS_REQUIRED = /(?:enable|requires?|turn on)\s+javascript\b|\bjavascript\s+(?:is\s+)?(?:required|disabled)\b/i;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* A client-rendered shell: under 200 characters of visible text, and either an
|
|
69
|
+
* empty <div id="root">, "app" or "__next" for the framework to mount into or
|
|
70
|
+
* text saying the page needs JavaScript.
|
|
71
|
+
* @param {string} html
|
|
72
|
+
*/
|
|
73
|
+
export function isClientRenderedShell(html) {
|
|
74
|
+
const $ = load(html);
|
|
75
|
+
$('script, style, noscript').remove();
|
|
76
|
+
const text = $('body').text().replace(/\s+/g, ' ').trim();
|
|
77
|
+
if (text.length >= 200) return false;
|
|
78
|
+
if (JS_REQUIRED.test(text)) return true;
|
|
79
|
+
return $('div#root, div#app, div#__next').toArray().some((el) => $(el).text().trim() === '');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Retry-After in ms, or null when the header is absent or unreadable.
|
|
84
|
+
* @param {Response} response
|
|
85
|
+
*/
|
|
86
|
+
function retryAfterMs(response) {
|
|
87
|
+
const header = response.headers?.get?.('retry-after');
|
|
88
|
+
if (header == null) return null;
|
|
89
|
+
const raw = String(header).trim();
|
|
90
|
+
if (!/^\d+$/.test(raw) && Number.isNaN(Date.parse(raw))) return null;
|
|
91
|
+
return parseRetryAfter(raw);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* fetchWithTimeout, with undici's bare "fetch failed" (DNS, refused, reset)
|
|
96
|
+
* given the URL and the reason it keeps in `cause`, as the REST route's
|
|
97
|
+
* FETCH_FAILED "Could not reach <url>" does.
|
|
98
|
+
*/
|
|
99
|
+
async function fetchOnce(url, options) {
|
|
100
|
+
try {
|
|
101
|
+
return await fetchWithTimeout(url, options);
|
|
102
|
+
} catch (error) {
|
|
103
|
+
if (!(error instanceof TypeError) || error.message !== 'fetch failed') throw error;
|
|
104
|
+
const reason = error.cause?.message || error.cause?.code;
|
|
105
|
+
throw new Error(`Could not reach ${url}${reason ? ` (${reason})` : ''}`, { cause: error });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The plain fetch, retried once on a 429/503 whose Retry-After is at most
|
|
111
|
+
* RETRY_AFTER_MAX_MS. fetchWithTimeout has already recorded the Retry-After
|
|
112
|
+
* (noteRetryAfter), and the second request's gate waits it out in the
|
|
113
|
+
* per-host throttle — so the wait happens once, there.
|
|
114
|
+
*/
|
|
115
|
+
async function plainFetch(url, options) {
|
|
116
|
+
const response = await fetchOnce(url, options);
|
|
117
|
+
if (response.status !== 429 && response.status !== 503) return response;
|
|
118
|
+
const waitMs = retryAfterMs(response);
|
|
119
|
+
if (waitMs === null || waitMs > RETRY_AFTER_MAX_MS) return response;
|
|
120
|
+
return fetchOnce(url, options);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* @param {string} url
|
|
125
|
+
* @param {{ tool: 'extract_text'|'extract_links', userAgent?: string, respectRobots?: boolean,
|
|
126
|
+
* escalate?: boolean, escalateEngine?: string,
|
|
127
|
+
* escalateFetch?: (args: { url: string, engine: string, respectRobots?: boolean }) => Promise<object> }} options
|
|
128
|
+
* @returns {Promise<
|
|
129
|
+
* { unsupported: string } |
|
|
130
|
+
* { error: string, url: string, status?: number|null, blocked?: object, warnings: string[], fields: object } |
|
|
131
|
+
* { html: string, url: string, type: string, warnings: string[], fields: object }>}
|
|
132
|
+
* `fields` carries `escalated` (only when escalate was asked for), `stealth`
|
|
133
|
+
* (when the stage ran) and `rendered: false` (a client-rendered shell)
|
|
134
|
+
*/
|
|
135
|
+
export async function fetchLadder(url, { tool, userAgent, respectRobots, escalate = false, escalateEngine = 'auto', escalateFetch } = {}) {
|
|
136
|
+
let escalationRan = false;
|
|
137
|
+
try {
|
|
138
|
+
const response = await plainFetch(url, { timeout: LADDER_TIMEOUT_MS, userAgent, respectRobots, tool });
|
|
139
|
+
const type = mediaType(response);
|
|
140
|
+
if (response.ok && type && !TEXTUAL_TYPE.test(type)) return { unsupported: type };
|
|
141
|
+
|
|
142
|
+
const warnings = [...(response._warnings ?? [])];
|
|
143
|
+
// Reported only to a caller who asked to escalate.
|
|
144
|
+
const fields = escalate ? { escalated: false } : {};
|
|
145
|
+
const verdict = plainVerdict(response, url);
|
|
146
|
+
const failed = verdict ? !verdict.success : !response.ok;
|
|
147
|
+
|
|
148
|
+
// A 404 or a 5xx never escalates (aBrowserMightPass); a 444 is a refusal
|
|
149
|
+
// of our network, which a browser on another identity can pass. A host
|
|
150
|
+
// whose Retry-After is still open (over 10 s, or repeated on the retry)
|
|
151
|
+
// asked us to back off, and a stealth request now would override it (G6).
|
|
152
|
+
const backingOff = (response.status === 429 || response.status === 503) && retryAfterMs(response) !== null;
|
|
153
|
+
if (escalate && failed && !backingOff && (aBrowserMightPass(verdict, response.status) || response.status === 444)) {
|
|
154
|
+
const vendorDetected = verdict?.blocked?.vendor ?? null;
|
|
155
|
+
if (!escalateFetch) {
|
|
156
|
+
warnings.push('escalate: no stealth stage is wired into this server build');
|
|
157
|
+
} else {
|
|
158
|
+
try {
|
|
159
|
+
const stealth = await escalateFetch({ url: response.url || url, engine: escalateEngine, respectRobots });
|
|
160
|
+
escalationRan = true;
|
|
161
|
+
const engine = stealth.engine || escalateEngine;
|
|
162
|
+
// The stage's gate repeats the plain fetch's robots notes; say each once.
|
|
163
|
+
for (const w of stealth.warnings ?? []) if (!warnings.includes(w)) warnings.push(w);
|
|
164
|
+
const html = stealth.html || '';
|
|
165
|
+
const finalUrl = stealth.url || response.url || url;
|
|
166
|
+
const status = stealth.status ?? null;
|
|
167
|
+
const rendered = stealthDocumentVerdict(
|
|
168
|
+
{ url: finalUrl, title: stealth.title || '', text: stealth.text || '', html, status },
|
|
169
|
+
{ waitedMs: stealth.gracedMs || 0, fetcher: 'the stealth browser', rendered: true, contentReturned: false }
|
|
170
|
+
);
|
|
171
|
+
warnings.push(
|
|
172
|
+
`escalate: the plain fetch ${vendorDetected ? `was blocked by ${vendorDetected}` : 'did not return the page'}; ` +
|
|
173
|
+
`${engine === 'impit' ? 'a Chrome TLS handshake (impit, honest User-Agent)' : `the ${engine} stealth browser`} ${rendered.success ? 'returned it' : 'did not get it either'}`
|
|
174
|
+
);
|
|
175
|
+
fields.escalated = true;
|
|
176
|
+
fields.stealth = { engine, vendor_detected: vendorDetected };
|
|
177
|
+
if (!rendered.success) {
|
|
178
|
+
return { error: rendered.error, url: finalUrl, status, ...(rendered.blocked ? { blocked: rendered.blocked } : {}), warnings, fields };
|
|
179
|
+
}
|
|
180
|
+
return { html, url: finalUrl, type: 'text/html', warnings, fields };
|
|
181
|
+
} catch (err) {
|
|
182
|
+
// A robots refusal inside the gate stamps markPreflightRefusal, and
|
|
183
|
+
// withAuth then bills the whole call zero: nothing was rendered.
|
|
184
|
+
warnings.push(`escalate: the stealth retry did not run — ${err.message}`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const failure = targetFailure(response, url, verdict);
|
|
190
|
+
if (failure) {
|
|
191
|
+
return { error: failure, url: response.url || url, status: response.status, ...(verdict?.blocked ? { blocked: verdict.blocked } : {}), warnings, fields };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const html = response._body ?? '';
|
|
195
|
+
if (!isJsonType(type) && isClientRenderedShell(html)) {
|
|
196
|
+
warnings.push(CLIENT_RENDERED_WARNING);
|
|
197
|
+
fields.rendered = false;
|
|
198
|
+
}
|
|
199
|
+
return { html, url: response.url || url, type, warnings, fields };
|
|
200
|
+
} finally {
|
|
201
|
+
// The projection is 1+5 when escalating; the stage's 5 is owed only if it ran.
|
|
202
|
+
if (escalate) setActualCost(EXTRACT_BASE_CREDITS + (escalationRan ? SCRAPE_ESCALATION_CREDITS : 0));
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* The error result for a ladder that did not return a page. Unescalated, the
|
|
208
|
+
* E1 text error; with escalate asked for, a JSON body carrying `escalated`,
|
|
209
|
+
* so withAuth's hint does not send the caller back to the stealth browser.
|
|
210
|
+
* @param {string} tool
|
|
211
|
+
* @param {string} prefix e.g. 'Failed to extract text: '
|
|
212
|
+
* @param {object} ladder fetchLadder's result
|
|
213
|
+
*/
|
|
214
|
+
export function ladderErrorResult(tool, prefix, ladder) {
|
|
215
|
+
if (ladder.unsupported) {
|
|
216
|
+
return {
|
|
217
|
+
content: [{
|
|
218
|
+
type: 'text',
|
|
219
|
+
text: `${prefix}UNSUPPORTED_CONTENT_TYPE: Unsupported content type ${ladder.unsupported}: ${tool} reads ${tool === 'extract_links' ? 'HTML' : 'HTML and text'}\n` +
|
|
220
|
+
'Next step: process_document reads PDF and DOCX documents from a URL (2 credits); an image or an archive has no text to extract.'
|
|
221
|
+
}],
|
|
222
|
+
isError: true
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
if (!('escalated' in ladder.fields)) {
|
|
226
|
+
return { content: [{ type: 'text', text: `${prefix}${ladder.error}` }], isError: true };
|
|
227
|
+
}
|
|
228
|
+
return {
|
|
229
|
+
content: [{
|
|
230
|
+
type: 'text',
|
|
231
|
+
text: JSON.stringify({
|
|
232
|
+
success: false,
|
|
233
|
+
url: ladder.url,
|
|
234
|
+
...(typeof ladder.status === 'number' ? { status: ladder.status } : {}),
|
|
235
|
+
error: ladder.error,
|
|
236
|
+
...(ladder.blocked ? { blocked: ladder.blocked } : {}),
|
|
237
|
+
...ladder.fields,
|
|
238
|
+
...(ladder.warnings.length > 0 ? { warnings: ladder.warnings } : {})
|
|
239
|
+
}, null, 2)
|
|
240
|
+
}],
|
|
241
|
+
isError: true
|
|
242
|
+
};
|
|
243
|
+
}
|