crawlforge-mcp-server 6.18.0 → 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/package.json +2 -2
- package/server.js +2 -2
- package/src/core/ActionExecutor.js +53 -0
- package/src/core/AgentOrchestrator.js +58 -3
- 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/skills/agent-skills/crawlforge-batch-automation/references/actions.md +1 -1
- 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/crawl/crawlDeep.js +16 -5
- package/src/tools/crawl/mapSite.js +49 -41
- package/src/tools/extract/_fetchAndParse.js +9 -4
- 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 +17 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "crawlforge-mcp-server",
|
|
3
|
-
"version": "6.18.
|
|
3
|
+
"version": "6.18.1",
|
|
4
4
|
"mcpName": "io.github.mysleekdesigns/crawlforge-mcp-server",
|
|
5
5
|
"description": "CrawlForge MCP Server - Professional Model Context Protocol server with 31 web scraping, crawling, deep-research, and autonomous-extraction tools. Returns clean Markdown and structured JSON for Claude, Cursor, and any MCP client. Defaults to local Ollama for LLM extraction (no API key needed); OpenAI/Anthropic available as opt-in. Includes a unified multi-format scrape tool, an autonomous agent, pre-built site templates, and Camoufox stealth browsing.",
|
|
6
6
|
"main": "server.js",
|
|
@@ -121,7 +121,7 @@
|
|
|
121
121
|
"cheerio": "^1.1.2",
|
|
122
122
|
"commander": "^14.0.3",
|
|
123
123
|
"compromise": "^14.14.4",
|
|
124
|
-
"crawlforge-extractors": "^1.
|
|
124
|
+
"crawlforge-extractors": "^1.15.0",
|
|
125
125
|
"diff": "^9.0.0",
|
|
126
126
|
"dotenv": "^17.2.1",
|
|
127
127
|
"franc": "^6.2.0",
|
package/server.js
CHANGED
|
@@ -113,7 +113,7 @@ if (configErrors.length > 0 && config.server.nodeEnv === 'production') {
|
|
|
113
113
|
// Create the server
|
|
114
114
|
const server = new McpServer({
|
|
115
115
|
name: "crawlforge",
|
|
116
|
-
version: "6.18.
|
|
116
|
+
version: "6.18.1",
|
|
117
117
|
description: "Production-ready MCP server with 31 web scraping, crawling, and content processing tools. Features MCP Resources (crawlforge://), Prompts, Sampling fallback, Elicitation, stealth browsing, stateful browser sessions with element refs, deep research, structured extraction, embedded JavaScript state extraction, real Google SERP rank tracking, Reddit search via community archives, change tracking, local-LLM extraction via Ollama, unified multi-format scrape, and autonomous agent tool.",
|
|
118
118
|
homepage: "https://www.crawlforge.dev",
|
|
119
119
|
icon: "https://www.crawlforge.dev/icon.png",
|
|
@@ -1031,7 +1031,7 @@ registerToolIfEnabled("scrape_with_actions", {
|
|
|
1031
1031
|
fields: z.array(z.object({
|
|
1032
1032
|
selector: z.string(),
|
|
1033
1033
|
value: z.string(),
|
|
1034
|
-
type: z.enum(['text', 'select', 'checkbox', 'radio', 'file']).default('text'),
|
|
1034
|
+
type: z.enum(['text', 'select', 'checkbox', 'radio', 'file']).default('text').describe("How the field is filled: text types the value, select picks the option by value or label, checkbox/radio check the input (a selector matching a group checks the member whose value attribute equals value; a checkbox with value \"false\" is unchecked). file is not supported and fails that action"),
|
|
1035
1035
|
waitAfter: z.number().min(0).max(5000).default(100)
|
|
1036
1036
|
})),
|
|
1037
1037
|
submitSelector: z.string().optional(),
|
|
@@ -129,6 +129,17 @@ const SelectActionSchema = BaseActionSchema.extend({
|
|
|
129
129
|
message: 'Select action requires value or values'
|
|
130
130
|
});
|
|
131
131
|
|
|
132
|
+
// Not on any tool's public action list: scrape_with_actions' formAutoFill emits
|
|
133
|
+
// it for a field declared `type: "checkbox"` or `type: "radio"`. `fieldType`
|
|
134
|
+
// is a plain string so that a field type nothing here can fill ("file")
|
|
135
|
+
// arrives as an action that fails, instead of being typed into as text.
|
|
136
|
+
const CheckActionSchema = BaseActionSchema.extend({
|
|
137
|
+
type: z.literal('check'),
|
|
138
|
+
selector: z.string(),
|
|
139
|
+
value: z.string(),
|
|
140
|
+
fieldType: z.string()
|
|
141
|
+
});
|
|
142
|
+
|
|
132
143
|
const HoverActionSchema = BaseActionSchema.extend({
|
|
133
144
|
type: z.literal('hover'),
|
|
134
145
|
selector: z.string(),
|
|
@@ -175,6 +186,7 @@ const ActionSchema = z.union([
|
|
|
175
186
|
PressActionSchema,
|
|
176
187
|
ScrollActionSchema,
|
|
177
188
|
SelectActionSchema,
|
|
189
|
+
CheckActionSchema,
|
|
178
190
|
HoverActionSchema,
|
|
179
191
|
NavigateActionSchema,
|
|
180
192
|
ScreenshotActionSchema,
|
|
@@ -1029,6 +1041,8 @@ export class ActionExecutor extends EventEmitter {
|
|
|
1029
1041
|
return await this.executeScrollAction(page, action);
|
|
1030
1042
|
case 'select':
|
|
1031
1043
|
return await this.executeSelectAction(page, action);
|
|
1044
|
+
case 'check':
|
|
1045
|
+
return await this.executeCheckAction(page, action);
|
|
1032
1046
|
case 'hover':
|
|
1033
1047
|
return await this.executeHoverAction(page, action);
|
|
1034
1048
|
case 'navigate':
|
|
@@ -1309,6 +1323,45 @@ export class ActionExecutor extends EventEmitter {
|
|
|
1309
1323
|
};
|
|
1310
1324
|
}
|
|
1311
1325
|
|
|
1326
|
+
/**
|
|
1327
|
+
* Execute check action — a formAutoFill checkbox or radio field.
|
|
1328
|
+
*
|
|
1329
|
+
* A selector that names a whole group (`input[name="size"]`) is narrowed to
|
|
1330
|
+
* the member whose `value` attribute is the field's value, the way the form
|
|
1331
|
+
* itself pairs name and value; `.first()` would check whichever came first.
|
|
1332
|
+
* A selector that names one input checks that input, except a checkbox with
|
|
1333
|
+
* value "false", which is unchecked.
|
|
1334
|
+
* @param {Page} page - Playwright page
|
|
1335
|
+
* @param {Object} action - Check action
|
|
1336
|
+
* @returns {Promise<Object>} Check result
|
|
1337
|
+
*/
|
|
1338
|
+
async executeCheckAction(page, action) {
|
|
1339
|
+
if (action.fieldType !== 'checkbox' && action.fieldType !== 'radio') {
|
|
1340
|
+
throw new Error(
|
|
1341
|
+
`formAutoFill field type "${action.fieldType}" is not supported ` +
|
|
1342
|
+
'(supported: text, select, checkbox, radio)'
|
|
1343
|
+
);
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
const timeout = this.actionTimeout(action);
|
|
1347
|
+
const matches = page.locator(this.resolveSelector(page, action.selector));
|
|
1348
|
+
await matches.first().waitFor({ state: 'attached', timeout });
|
|
1349
|
+
|
|
1350
|
+
const isGroup = (await matches.count()) > 1;
|
|
1351
|
+
const target = isGroup
|
|
1352
|
+
? matches.and(page.locator(`[value=${JSON.stringify(action.value)}]`)).first()
|
|
1353
|
+
: matches.first();
|
|
1354
|
+
const checked = isGroup || action.fieldType === 'radio' || action.value !== 'false';
|
|
1355
|
+
|
|
1356
|
+
await target.setChecked(checked, { timeout });
|
|
1357
|
+
|
|
1358
|
+
return {
|
|
1359
|
+
selector: action.selector,
|
|
1360
|
+
value: action.value,
|
|
1361
|
+
checked
|
|
1362
|
+
};
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1312
1365
|
/**
|
|
1313
1366
|
* Execute hover action
|
|
1314
1367
|
* @param {Page} page - Playwright page
|
|
@@ -172,6 +172,40 @@ export function isCurrentStateTask(prompt) {
|
|
|
172
172
|
return CURRENT_STATE_RE.test(prompt || '');
|
|
173
173
|
}
|
|
174
174
|
|
|
175
|
+
/**
|
|
176
|
+
* The names a prompt uses: words carrying a capital letter that is not just
|
|
177
|
+
* the capital opening a sentence ("TypeScript", "Hacker", "News", "NASA"; not
|
|
178
|
+
* "What"). These are what the task is about, so a planned query that drops
|
|
179
|
+
* one has lost its subject.
|
|
180
|
+
*/
|
|
181
|
+
export function namedTerms(prompt) {
|
|
182
|
+
const words = (prompt || '').split(/\s+/);
|
|
183
|
+
const terms = [];
|
|
184
|
+
words.forEach((raw, i) => {
|
|
185
|
+
if (raw.includes('://')) return;
|
|
186
|
+
const word = raw.replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu, '').replace(/['’]s$/, '');
|
|
187
|
+
if (word.length < 2) return;
|
|
188
|
+
const opensSentence = i === 0 || /[.?!:]$/.test(words[i - 1]);
|
|
189
|
+
if (!/\p{Lu}/u.test(opensSentence ? word.slice(1) : word)) return;
|
|
190
|
+
if (!terms.some(t => t.toLowerCase() === word.toLowerCase())) terms.push(word);
|
|
191
|
+
});
|
|
192
|
+
return terms;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* A task scoped to its seed URLs: the caller passed `urls` and the prompt
|
|
197
|
+
* points at them ("this page", "these URLs", "the given site"). The answer is
|
|
198
|
+
* then on those pages and nowhere else, so GATHER runs no web search — search
|
|
199
|
+
* results for the task's words are other people's pages. Asked how many books
|
|
200
|
+
* "this page" lists, the agent searched "count books on webpage" and merged
|
|
201
|
+
* Reddit threads about word counts into the evidence (R24, 2026-10-03).
|
|
202
|
+
*/
|
|
203
|
+
const SEED_SCOPE_RE = /\b(?:this|these|the (?:given|provided|above|following|seed))\s+(?:web\s?)?(?:pages?|urls?|sites?|websites?|links?|documents?|articles?)\b/i;
|
|
204
|
+
|
|
205
|
+
export function isSeedScopedTask(prompt, seedUrls) {
|
|
206
|
+
return Array.isArray(seedUrls) && seedUrls.length > 0 && SEED_SCOPE_RE.test(prompt || '');
|
|
207
|
+
}
|
|
208
|
+
|
|
175
209
|
// ── Orchestrator ──────────────────────────────────────────────────────────────
|
|
176
210
|
|
|
177
211
|
export class AgentOrchestrator {
|
|
@@ -358,6 +392,17 @@ export class AgentOrchestrator {
|
|
|
358
392
|
);
|
|
359
393
|
}
|
|
360
394
|
// A current-state plan is told to make its first query the bare entity
|
|
395
|
+
// name, and for a prompt naming two things a small model can keep the
|
|
396
|
+
// wrong one: "latest stable version of the TypeScript npm package" was
|
|
397
|
+
// searched as the single word "npm" (R24, 2026-10-03). The first query
|
|
398
|
+
// carries every name the prompt uses; what the planner wrote stays as the
|
|
399
|
+
// qualifier ("TypeScript npm").
|
|
400
|
+
if (currentState) {
|
|
401
|
+
const first = searchQueries[0].toLowerCase();
|
|
402
|
+
const dropped = namedTerms(prompt).filter(t => !first.includes(t.toLowerCase()));
|
|
403
|
+
if (dropped.length > 0) searchQueries[0] = `${dropped.join(' ')} ${searchQueries[0]}`;
|
|
404
|
+
}
|
|
405
|
+
// A current-state plan is told to make its first query the bare entity
|
|
361
406
|
// name, and a small model often stops there. The bare query surfaces the
|
|
362
407
|
// live front page, which rarely states the fact asked for: "what does
|
|
363
408
|
// Southwest charge for a first checked bag" fetched southwest.com's home,
|
|
@@ -397,7 +442,7 @@ export class AgentOrchestrator {
|
|
|
397
442
|
/** url -> { title, text }: the engine's excerpt, evidence of last resort for a page that cannot be fetched. */
|
|
398
443
|
const excerpts = new Map();
|
|
399
444
|
|
|
400
|
-
if (urlQueue.length < capUrls) {
|
|
445
|
+
if (urlQueue.length < capUrls && !isSeedScopedTask(prompt, seedUrls)) {
|
|
401
446
|
try {
|
|
402
447
|
const searchTool = await this._getSearchTool();
|
|
403
448
|
for (const q of searchQueries) {
|
|
@@ -607,14 +652,24 @@ export class AgentOrchestrator {
|
|
|
607
652
|
schema,
|
|
608
653
|
provider: 'auto'
|
|
609
654
|
});
|
|
655
|
+
// A required field left null is an incomplete answer, not a
|
|
656
|
+
// structured success (R24, 2026-10-03).
|
|
657
|
+
const missingRequired = result.success && Array.isArray(schema.required)
|
|
658
|
+
? schema.required.filter(key => result.data?.[key] == null || result.data[key] === '')
|
|
659
|
+
: [];
|
|
660
|
+
const missingReason = missingRequired.length > 0
|
|
661
|
+
? `Required ${missingRequired.length === 1 ? 'field' : 'fields'} ${missingRequired.map(k => `"${k}"`).join(', ')} ` +
|
|
662
|
+
`${missingRequired.length === 1 ? 'is' : 'are'} null: the fetched sources do not state ${missingRequired.length === 1 ? 'it' : 'them'}.`
|
|
663
|
+
: undefined;
|
|
664
|
+
if (missingReason) warnings.push(missingReason);
|
|
610
665
|
return {
|
|
611
666
|
success: result.success,
|
|
612
667
|
answer: result.success ? result.data : null,
|
|
613
668
|
structured: true,
|
|
614
669
|
search_results: searchResults,
|
|
615
670
|
evidence: evidence.map(e => (e.via ? { url: e.url, via: e.via } : { url: e.url })),
|
|
616
|
-
degraded: !result.success,
|
|
617
|
-
reason: result.success ?
|
|
671
|
+
degraded: !result.success || missingRequired.length > 0,
|
|
672
|
+
reason: result.success ? missingReason : result.error,
|
|
618
673
|
steps: step,
|
|
619
674
|
urls_fetched: urlsFetched,
|
|
620
675
|
stealth_retries: usage.escalations,
|
|
@@ -219,6 +219,12 @@ export class BFSCrawler {
|
|
|
219
219
|
return;
|
|
220
220
|
}
|
|
221
221
|
this.visited.set(normalizedUrl, url);
|
|
222
|
+
// Budget spent: drop what is still waiting. Each of those tasks would only
|
|
223
|
+
// return at the cap check above, but the queue releases 10 a second, so a
|
|
224
|
+
// seed with 2,290 links held a max_pages:3 crawl open for 232 s (R24).
|
|
225
|
+
if (this.visited.size >= this.maxPages) {
|
|
226
|
+
this.queue.clear();
|
|
227
|
+
}
|
|
222
228
|
|
|
223
229
|
try {
|
|
224
230
|
// Check cache first
|
|
@@ -318,6 +324,7 @@ export class BFSCrawler {
|
|
|
318
324
|
url,
|
|
319
325
|
depth,
|
|
320
326
|
error: error.message,
|
|
327
|
+
...(error.code ? { code: error.code } : {}),
|
|
321
328
|
timestamp: new Date().toISOString()
|
|
322
329
|
});
|
|
323
330
|
}
|
|
@@ -77,6 +77,9 @@ export class QueueManager {
|
|
|
77
77
|
getStats() {
|
|
78
78
|
return {
|
|
79
79
|
...this.stats,
|
|
80
|
+
// Read live: the 'active' event fires when a task starts, never when the
|
|
81
|
+
// last one ends, so the stored value stayed at 8 on a finished crawl.
|
|
82
|
+
active: this.queue.pending,
|
|
80
83
|
size: this.queue.size,
|
|
81
84
|
pending: this.queue.pending,
|
|
82
85
|
isPaused: this.queue.isPaused
|
|
@@ -181,6 +181,7 @@ const mapSiteShape = {
|
|
|
181
181
|
url: z.string().optional(),
|
|
182
182
|
score: z.number().optional()
|
|
183
183
|
}).passthrough()).optional().describe('Present only when the `search` param was set'),
|
|
184
|
+
warnings: z.array(z.string()).optional().describe('Present when the map is partial, e.g. the start page could not be read and the URLs come from the sitemap only'),
|
|
184
185
|
_cost: costShape
|
|
185
186
|
};
|
|
186
187
|
|
|
@@ -378,16 +379,17 @@ const crawlDeepShape = {
|
|
|
378
379
|
error: z.string().optional(),
|
|
379
380
|
url: z.string().optional(),
|
|
380
381
|
crawl_depth: z.number().optional(),
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
382
|
+
pages_attempted: z.number().optional().describe('URLs the crawl tried: pages_crawled + error_count'),
|
|
383
|
+
pages_crawled: z.number().optional().describe('Pages fetched and returned in results'),
|
|
384
|
+
pages_found: z.number().optional().describe('Same as pages_crawled; kept for existing clients'),
|
|
385
|
+
error_count: z.number().optional().describe('URLs that failed; one entry each in errors[]'),
|
|
384
386
|
duration_ms: z.number().optional(),
|
|
385
387
|
pages_per_second: z.number().optional(),
|
|
386
388
|
results: z.array(crawlDeepPageShape).optional(),
|
|
387
389
|
errors: z.array(z.unknown()).optional(),
|
|
388
390
|
stats: z.unknown().optional(),
|
|
389
391
|
site_structure: z.object({
|
|
390
|
-
total_pages: z.number().optional(),
|
|
392
|
+
total_pages: z.number().optional().describe('Equals pages_crawled; failed URLs are not counted'),
|
|
391
393
|
depth_distribution: z.record(z.number()).optional()
|
|
392
394
|
.describe('Pages per crawl depth (links from the start URL)'),
|
|
393
395
|
path_depth_distribution: z.record(z.number()).optional()
|
|
@@ -154,7 +154,7 @@ main frame only — elements inside iframes and shadow DOM get no refs.
|
|
|
154
154
|
| `formats` | `["json"]` | `markdown`, `html`, `json`, `text`, `screenshots`. |
|
|
155
155
|
| `captureIntermediateStates` | `false` | Snapshot after each action. |
|
|
156
156
|
| `captureScreenshots` | `true` | Screenshot during execution. |
|
|
157
|
-
| `formAutoFill` | — | Declarative form fill: `fields[]` + `submitSelector`. |
|
|
157
|
+
| `formAutoFill` | — | Declarative form fill: `fields[]` + `submitSelector`. Each field's `type` picks how it is filled: `text` (default) types, `select` picks the option by value or label, `checkbox`/`radio` check the input (a group selector such as `input[name=size]` checks the member whose value attribute equals `value`). `file` is not supported and fails that action. |
|
|
158
158
|
| `browserOptions` | — | `headless`, `userAgent`, `viewportWidth/Height`, `timeout`. |
|
|
159
159
|
| `continueOnActionError` | `false` | Keep going if one action fails. |
|
|
160
160
|
| `maxRetries` | `0` | 0–3 whole-chain retries on failure. A retry re-navigates to the starting URL and replays every action; each run is reported under `attempts[]`. |
|
|
@@ -184,6 +184,18 @@ function withJsResult(result) {
|
|
|
184
184
|
return { ...result, jsResult: result.result.result };
|
|
185
185
|
}
|
|
186
186
|
|
|
187
|
+
/**
|
|
188
|
+
* A screenshot action's image travels once, in `screenshots[]`, which the
|
|
189
|
+
* server publishes as crawlforge://screenshot/{actionId} — the action's id.
|
|
190
|
+
* Its copy in the action result is replaced by that URI, as scrape_with_actions
|
|
191
|
+
* does: left in, the same base64 came back inline beside the resource (R24).
|
|
192
|
+
*/
|
|
193
|
+
function withoutScreenshotData(result) {
|
|
194
|
+
if (result?.type !== 'screenshot' || typeof result.result?.data !== 'string') return result;
|
|
195
|
+
const { data, ...rest } = result.result;
|
|
196
|
+
return { ...result, result: { ...rest, resourceUri: `crawlforge://screenshot/${result.id}` } };
|
|
197
|
+
}
|
|
198
|
+
|
|
187
199
|
/**
|
|
188
200
|
* The gate's warnings — a respect_robots override, a crawl-delay note — are
|
|
189
201
|
* what the shared parameter description promises the caller gets back
|
|
@@ -517,7 +529,7 @@ export class BrowserSessionTool {
|
|
|
517
529
|
: {}),
|
|
518
530
|
...gateWarningFields(session.page),
|
|
519
531
|
error: result.error,
|
|
520
|
-
actionResults: result.results.map(withJsResult),
|
|
532
|
+
actionResults: result.results.map(withJsResult).map(withoutScreenshotData),
|
|
521
533
|
screenshots: result.screenshots,
|
|
522
534
|
...(result.capturedStates.length > 0 ? { capturedStates: result.capturedStates } : {}),
|
|
523
535
|
stats: result.stats
|
|
@@ -593,7 +593,10 @@ export class ScrapeWithActionsTool extends EventEmitter {
|
|
|
593
593
|
actionResults,
|
|
594
594
|
attempt: chainResult.attempt ?? attempts.length,
|
|
595
595
|
attempts,
|
|
596
|
-
|
|
596
|
+
// The chain that was run — the caller's actions plus the fills, waits
|
|
597
|
+
// and submit formAutoFill adds — so it is the denominator of the three
|
|
598
|
+
// counts below; counting only the caller's own read "1 of 11".
|
|
599
|
+
totalActions: actionChain.length,
|
|
597
600
|
successfulActions: actionResults.filter(r => r.success).length,
|
|
598
601
|
failedActions: actionResults.filter(r => !r.success).length,
|
|
599
602
|
actionsExecuted: actionResults.length, // Total executed (for validation)
|
|
@@ -635,10 +638,18 @@ export class ScrapeWithActionsTool extends EventEmitter {
|
|
|
635
638
|
if (Array.isArray(formAutoFill.fields)) {
|
|
636
639
|
// Structured shape: { fields: [{selector, value, type, waitAfter}], submitSelector, waitAfterSubmit }
|
|
637
640
|
for (const field of formAutoFill.fields) {
|
|
641
|
+
// Each field type is filled the way that control is: typing into a
|
|
642
|
+
// radio, a checkbox or a <select> "succeeds" and changes nothing, so
|
|
643
|
+
// the form was posted without them and nothing failed (R24). `check`
|
|
644
|
+
// also carries the types nothing can fill ("file"), which it refuses.
|
|
645
|
+
const fill = field.type === 'text'
|
|
646
|
+
? { type: 'type', text: field.value }
|
|
647
|
+
: field.type === 'select'
|
|
648
|
+
? { type: 'select', value: field.value }
|
|
649
|
+
: { type: 'check', value: field.value, fieldType: field.type };
|
|
638
650
|
fillActions.push({
|
|
639
|
-
|
|
651
|
+
...fill,
|
|
640
652
|
selector: field.selector,
|
|
641
|
-
text: field.value,
|
|
642
653
|
description: `Auto-fill field: ${field.selector}`,
|
|
643
654
|
continueOnError: true,
|
|
644
655
|
retries: 1
|
|
@@ -14,6 +14,7 @@ import { fetchResigned } from '../../../utils/resignedFetch.js';
|
|
|
14
14
|
import { htmlToMarkdown } from '../../../utils/htmlToMarkdown.js';
|
|
15
15
|
import { elementText } from '../../../utils/elementText.js';
|
|
16
16
|
import { pageTitle } from '../../../utils/pageTitle.js';
|
|
17
|
+
import { classifyContentType, unsupportedContentTypeError } from '../../extract/_fetchAndParse.js';
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* Fetch a URL with AbortController timeout (SSRF-guarded + per-host throttled).
|
|
@@ -41,6 +42,13 @@ export async function fetchUrl(url, options = {}) {
|
|
|
41
42
|
if (response.status === 429 || response.status === 503) {
|
|
42
43
|
noteRetryAfter(url, response.headers?.get?.('retry-after'));
|
|
43
44
|
}
|
|
45
|
+
// A PDF decoded as text came back success:true with "%PDF-1.4 …" as the
|
|
46
|
+
// page (R24 1.5). Refused before the body is read, with the wording
|
|
47
|
+
// `scrape` gives the same URL; an error status keeps its own message.
|
|
48
|
+
const contentType = response.headers?.get?.('content-type') || null;
|
|
49
|
+
if (response.ok && classifyContentType(contentType) === 'binary') {
|
|
50
|
+
throw unsupportedContentTypeError(contentType);
|
|
51
|
+
}
|
|
44
52
|
// crawlforge-extractors' readBody: same size cap, but decoded with the
|
|
45
53
|
// body's real charset — the local reader this replaces decoded everything
|
|
46
54
|
// as UTF-8, which turned lua.org's ISO-8859-1 "português" into "portugu�s".
|
|
@@ -168,6 +168,8 @@ export function buildRecordedEntry(action, timestampMsSinceStart) {
|
|
|
168
168
|
if (action.duration !== undefined) entry.duration = action.duration;
|
|
169
169
|
if (action.url !== undefined) entry.url = action.url;
|
|
170
170
|
if (action.value !== undefined) entry.value = action.value;
|
|
171
|
+
// check (a formAutoFill checkbox/radio field) does not replay without it
|
|
172
|
+
if (action.fieldType !== undefined) entry.fieldType = action.fieldType;
|
|
171
173
|
if (action.direction !== undefined) entry.direction = action.direction;
|
|
172
174
|
if (action.distance !== undefined) entry.distance = action.distance;
|
|
173
175
|
// scroll: absolute scroll-to coordinates
|
|
@@ -261,19 +261,30 @@ export class CrawlDeepTool {
|
|
|
261
261
|
});
|
|
262
262
|
const duration = Date.now() - startTime;
|
|
263
263
|
|
|
264
|
+
// The counters, each defined once: a URL the crawl tried either
|
|
265
|
+
// produced a page (results) or an error (errors), so
|
|
266
|
+
// pages_attempted = pages_crawled + error_count. pages_crawled used to
|
|
267
|
+
// count attempts, which read "3 crawled, 1 found, 2 errors" (R24).
|
|
268
|
+
const succeeded = results.results.length;
|
|
269
|
+
const failed = results.errors.length;
|
|
270
|
+
|
|
264
271
|
// Process and format results
|
|
265
272
|
const response = {
|
|
266
273
|
url: validated.url,
|
|
267
274
|
crawl_depth: effectiveMaxDepth,
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
275
|
+
pages_attempted: succeeded + failed,
|
|
276
|
+
pages_crawled: succeeded,
|
|
277
|
+
pages_found: succeeded,
|
|
278
|
+
error_count: failed,
|
|
271
279
|
duration_ms: duration,
|
|
272
|
-
|
|
280
|
+
// A seed refused before any I/O finishes in 0 ms; x / 0 is Infinity,
|
|
281
|
+
// which the output schema rejects as "expected number".
|
|
282
|
+
pages_per_second: succeeded / (Math.max(duration, 1) / 1000),
|
|
273
283
|
results: this.formatResults(results.results, validated.extract_content, validated.content_max_length),
|
|
274
284
|
errors: results.errors,
|
|
275
285
|
stats: results.stats,
|
|
276
|
-
|
|
286
|
+
// Pages only: a URL that failed is in errors[], not in the site's structure.
|
|
287
|
+
site_structure: this.analyzeSiteStructure(results.results.map(page => page.url), results.results),
|
|
277
288
|
domain_filter_config: domainFilter ? domainFilter.exportConfig() : null,
|
|
278
289
|
link_analysis: results.linkAnalysis,
|
|
279
290
|
session: sessionContext
|
|
@@ -48,6 +48,9 @@ export function searchScore(url, search) {
|
|
|
48
48
|
return terms.filter((t) => haystack.includes(t)).length;
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
// A gate refusal is the answer to the request, not a page we failed to read.
|
|
52
|
+
const REFUSAL_CODES = new Set(['ROBOTS_DISALLOWED', 'HOST_BLOCKED', 'USE_REDDIT_SEARCH', 'SSRF_BLOCKED']);
|
|
53
|
+
|
|
51
54
|
// Lazy singleton — avoids creating a CacheManager timer per request
|
|
52
55
|
let _ranker = null;
|
|
53
56
|
function getRanker() {
|
|
@@ -159,13 +162,24 @@ export class MapSiteTool {
|
|
|
159
162
|
sitemapUrls.forEach(url => urls.add(normalizeUrl(url)));
|
|
160
163
|
}
|
|
161
164
|
|
|
162
|
-
// Fetch and parse the main page for additional URLs
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
165
|
+
// Fetch and parse the main page for additional URLs. A start page that
|
|
166
|
+
// cannot be read is an error when the sitemap gave nothing either: an
|
|
167
|
+
// unresolvable host used to return a success with total_urls: 0 (R24).
|
|
168
|
+
try {
|
|
169
|
+
const pageUrls = await this.fetchPageUrls(validated.url, domainFilter, identity);
|
|
170
|
+
pageUrls.forEach(url => {
|
|
171
|
+
if (urls.size < poolLimit) {
|
|
172
|
+
urls.add(normalizeUrl(url));
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
} catch (error) {
|
|
176
|
+
if (REFUSAL_CODES.has(error.code) || error.message.startsWith('SSRF Protection:')) throw error;
|
|
177
|
+
const reason = error.cause?.message || error.message;
|
|
178
|
+
if (urls.size === 0) {
|
|
179
|
+
throw new Error(`${validated.url} could not be read (${reason}) and no sitemap was found`);
|
|
167
180
|
}
|
|
168
|
-
|
|
181
|
+
warnings.push(`The start page could not be read (${reason}); the URLs come from the sitemap only.`);
|
|
182
|
+
}
|
|
169
183
|
|
|
170
184
|
let pool = Array.from(urls);
|
|
171
185
|
if (scopePath) {
|
|
@@ -304,46 +318,40 @@ export class MapSiteTool {
|
|
|
304
318
|
}
|
|
305
319
|
|
|
306
320
|
async fetchPageUrls(url, domainFilter = null, identity = {}) {
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
}
|
|
321
|
+
// Throws when the page cannot be read — a gate or SSRF refusal, a network
|
|
322
|
+
// failure, a non-2xx status. execute() decides which of those end the call.
|
|
323
|
+
const response = await this.fetchWithTimeout(url, identity);
|
|
324
|
+
if (!response.ok) {
|
|
325
|
+
throw new Error(`HTTP ${response.status}`);
|
|
326
|
+
}
|
|
312
327
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
}
|
|
328
|
+
const html = await response.text();
|
|
329
|
+
const $ = load(html);
|
|
330
|
+
const urls = new Set();
|
|
331
|
+
const baseUrl = getBaseUrl(url);
|
|
332
|
+
|
|
333
|
+
// Extract all links
|
|
334
|
+
$('a[href]').each((_, element) => {
|
|
335
|
+
const href = $(element).attr('href');
|
|
336
|
+
if (href && !href.startsWith('#') && !href.startsWith('javascript:')) {
|
|
337
|
+
try {
|
|
338
|
+
const absoluteUrl = new URL(href, url);
|
|
339
|
+
// Only include URLs from the same domain
|
|
340
|
+
if (absoluteUrl.origin === new URL(baseUrl).origin) {
|
|
341
|
+
const urlString = absoluteUrl.toString();
|
|
342
|
+
|
|
343
|
+
// Apply domain filter if provided
|
|
344
|
+
if (!domainFilter || domainFilter.isAllowed(urlString).allowed) {
|
|
345
|
+
urls.add(urlString);
|
|
332
346
|
}
|
|
333
|
-
} catch {
|
|
334
|
-
// Invalid URL, skip
|
|
335
347
|
}
|
|
348
|
+
} catch {
|
|
349
|
+
// Invalid URL, skip
|
|
336
350
|
}
|
|
337
|
-
}
|
|
351
|
+
}
|
|
352
|
+
});
|
|
338
353
|
|
|
339
|
-
|
|
340
|
-
} catch (error) {
|
|
341
|
-
// A gate refusal is the answer to the request, not a page we failed to
|
|
342
|
-
// read: surface it instead of returning an emptier map than the caller
|
|
343
|
-
// would notice.
|
|
344
|
-
if (error.code === 'ROBOTS_DISALLOWED' || error.code === 'HOST_BLOCKED' || error.code === 'USE_REDDIT_SEARCH') throw error;
|
|
345
|
-
return [];
|
|
346
|
-
}
|
|
354
|
+
return Array.from(urls);
|
|
347
355
|
}
|
|
348
356
|
|
|
349
357
|
async fetchMetadata(urls, metadataMap, identity = {}) {
|
|
@@ -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 (
|
|
@@ -146,9 +153,7 @@ export async function fetchAndParse(url, options = {}) {
|
|
|
146
153
|
}
|
|
147
154
|
|
|
148
155
|
if (classification === 'binary') {
|
|
149
|
-
throw
|
|
150
|
-
`Unsupported content type "${contentType}" — this looks like binary content, not HTML/text. Use process_document for PDFs/documents/binary files.`
|
|
151
|
-
);
|
|
156
|
+
throw unsupportedContentTypeError(contentType);
|
|
152
157
|
}
|
|
153
158
|
|
|
154
159
|
const html = await readTextWithSizeCap(response);
|
|
@@ -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)")
|
package/src/utils/fetchLadder.js
CHANGED
|
@@ -91,6 +91,21 @@ function retryAfterMs(response) {
|
|
|
91
91
|
return parseRetryAfter(raw);
|
|
92
92
|
}
|
|
93
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
|
+
|
|
94
109
|
/**
|
|
95
110
|
* The plain fetch, retried once on a 429/503 whose Retry-After is at most
|
|
96
111
|
* RETRY_AFTER_MAX_MS. fetchWithTimeout has already recorded the Retry-After
|
|
@@ -98,11 +113,11 @@ function retryAfterMs(response) {
|
|
|
98
113
|
* per-host throttle — so the wait happens once, there.
|
|
99
114
|
*/
|
|
100
115
|
async function plainFetch(url, options) {
|
|
101
|
-
const response = await
|
|
116
|
+
const response = await fetchOnce(url, options);
|
|
102
117
|
if (response.status !== 429 && response.status !== 503) return response;
|
|
103
118
|
const waitMs = retryAfterMs(response);
|
|
104
119
|
if (waitMs === null || waitMs > RETRY_AFTER_MAX_MS) return response;
|
|
105
|
-
return
|
|
120
|
+
return fetchOnce(url, options);
|
|
106
121
|
}
|
|
107
122
|
|
|
108
123
|
/**
|