crawlforge-mcp-server 6.4.0 → 6.5.0
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 +1 -1
- package/package.json +2 -1
- package/server.js +8 -4
- package/src/core/ActionExecutor.js +9 -1
- package/src/core/ChangeTracker.js +25 -8
- package/src/core/llm/LLMManager.js +86 -6
- package/src/core/processing/PDFProcessor.js +3 -1
- package/src/server/fallbackHints.js +3 -0
- package/src/server/inlineThreshold.js +31 -1
- package/src/server/requestContext.js +1 -1
- package/src/tools/crawl/mapSite.js +81 -7
- package/src/tools/extract/extractEmbeddedState.js +18 -2
- package/src/tools/extract/extractStructured.js +4 -0
- package/src/tools/extract/processDocument.js +94 -1
- package/src/tools/scrape/_brandingExtractor.js +23 -5
- package/src/tools/scrape/unifiedScrape.js +8 -1
- package/src/tools/search/redditSearch.js +24 -17
- package/src/utils/hiddenContent.js +67 -2
- package/src/utils/redditHosts.js +123 -0
- package/src/utils/robotsGate.js +27 -3
package/README.md
CHANGED
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
| | **CrawlForge MCP** | Firecrawl | Raw scraping API |
|
|
49
49
|
|---|:---:|:---:|:---:|
|
|
50
50
|
| Native MCP server | ✅ 30 tools | ✅ | ❌ |
|
|
51
|
-
| Free tier | ✅ 1,000 credits,
|
|
51
|
+
| Free tier | ✅ 1,000 credits, one-time, never expire | Limited | Varies |
|
|
52
52
|
| Self-hosted / local LLM extraction (Ollama) | ✅ default, $0/token | ❌ | ❌ |
|
|
53
53
|
| Autonomous agent (no URLs needed) | ✅ `agent` | ✅ | ❌ |
|
|
54
54
|
| Deep research with source verification | ✅ `deep_research` | Partial | ❌ |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "crawlforge-mcp-server",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.5.0",
|
|
4
4
|
"mcpName": "io.github.mysleekdesigns/crawlforge-mcp-server",
|
|
5
5
|
"description": "CrawlForge MCP Server - Professional Model Context Protocol server with 30 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",
|
|
@@ -115,6 +115,7 @@
|
|
|
115
115
|
"@modelcontextprotocol/node": "^2.0.0",
|
|
116
116
|
"@modelcontextprotocol/server": "^2.0.0",
|
|
117
117
|
"@mozilla/readability": "^0.6.0",
|
|
118
|
+
"mammoth": "^1.12.2",
|
|
118
119
|
"cheerio": "^1.1.2",
|
|
119
120
|
"commander": "^14.0.3",
|
|
120
121
|
"compromise": "^14.14.4",
|
package/server.js
CHANGED
|
@@ -107,7 +107,7 @@ if (configErrors.length > 0 && config.server.nodeEnv === 'production') {
|
|
|
107
107
|
// Create the server
|
|
108
108
|
const server = new McpServer({
|
|
109
109
|
name: "crawlforge",
|
|
110
|
-
version: "6.
|
|
110
|
+
version: "6.5.0",
|
|
111
111
|
description: "Production-ready MCP server with 30 web scraping, crawling, and content processing tools. Features MCP Resources (crawlforge://), Prompts, Sampling fallback, Elicitation, stealth browsing, 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.",
|
|
112
112
|
homepage: "https://www.crawlforge.dev",
|
|
113
113
|
icon: "https://www.crawlforge.dev/icon.png",
|
|
@@ -541,7 +541,7 @@ registerToolIfEnabled("serp_rank", {
|
|
|
541
541
|
|
|
542
542
|
// Tool: reddit_search — search Reddit posts/comments or read a full thread (via community archives)
|
|
543
543
|
registerToolIfEnabled("reddit_search", {
|
|
544
|
-
description: "Use this to search Reddit posts or comments, or read a full comment thread - reddit.com blocks direct scraping, so this reads the Arctic Shift community archive instead (free, no Reddit credentials). Modes: 'posts' (default) and 'comments' search; 'thread' returns a post plus its nested comment tree by link_id. A subreddit/author-scoped search queries the archive directly. A keyword search across ALL of Reddit finds posts with a site-restricted web search and then reads those posts from the archive, because Arctic Shift can only keyword-search within a scope; results come back as real archive rows, ordered by search relevance. An unscoped COMMENT search discovers posts the same way and then searches each post's comments for the keywords. A scoped comment search Arctic Shift times out on is retried over narrower windows (7d, 3d, 1d) and reports window_applied. Not for reddit.com URLs via scrape or fetch_url (blocked) - use mode:'thread' with the post's link_id. Cost: 5 credits. Example: reddit_search({query: \"best mechanical keyboard\", subreddit: \"MechanicalKeyboards\", limit: 10})",
|
|
544
|
+
description: "Use this to search Reddit posts or comments, or read a full comment thread - reddit.com blocks direct scraping, so this reads the Arctic Shift community archive instead (free, no Reddit credentials). Modes: 'posts' (default) and 'comments' search; 'thread' returns a post plus its nested comment tree by link_id. A subreddit/author-scoped search queries the archive directly. A keyword search across ALL of Reddit finds posts with a site-restricted web search and then reads those posts from the archive, because Arctic Shift can only keyword-search within a scope; results come back as real archive rows, ordered by search relevance. An unscoped COMMENT search discovers posts the same way and then searches each post's comments for the keywords. A scoped comment search Arctic Shift times out on is retried over narrower windows (7d, 3d, 1d) and reports window_applied. Arctic Shift is tried first and the PullPush archive second for posts/comments searches (fallback_used says so; PullPush has refused automated clients since August 2026). Not for reddit.com URLs via scrape or fetch_url (blocked) - use mode:'thread' with the post's link_id. Cost: 5 credits. Example: reddit_search({query: \"best mechanical keyboard\", subreddit: \"MechanicalKeyboards\", limit: 10})",
|
|
545
545
|
annotations: { title: "Reddit Search", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
546
546
|
inputSchema: {
|
|
547
547
|
query: z.string().optional().describe("Keyword search. Posts: matches title+selftext; comments: matches body. Supports \"quoted phrases\", OR, -exclusion"),
|
|
@@ -680,7 +680,7 @@ registerToolIfEnabled("extract_content", {
|
|
|
680
680
|
|
|
681
681
|
// Tool: process_document
|
|
682
682
|
registerToolIfEnabled("process_document", {
|
|
683
|
-
description: "Use this to extract text from a PDF URL or file - research papers, contracts, reports. Returns structured sections, metadata, and word count. Not for ordinary web pages (scrape), though an HTML URL is accepted. Cost: 2 credits. Example: process_document({source: \"https://example.com/report.pdf\", sourceType: \"pdf_url\"})",
|
|
683
|
+
description: "Use this to extract text from a PDF or DOCX URL or file - research papers, contracts, reports. The body decides how it is read: a PDF or Word document served under sourceType \"url\" still reaches its parser, and a body this tool cannot read (an image, an archive) is refused by name. Returns structured sections, metadata, and word count. Not for ordinary web pages (scrape), though an HTML URL is accepted. Cost: 2 credits. Example: process_document({source: \"https://example.com/report.pdf\", sourceType: \"pdf_url\"})",
|
|
684
684
|
annotations: { title: "Process Document", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
685
685
|
inputSchema: {
|
|
686
686
|
source: z.string().describe("Document source - URL or file path"),
|
|
@@ -991,11 +991,15 @@ registerToolIfEnabled("scrape_with_actions", {
|
|
|
991
991
|
|
|
992
992
|
// Publish captured screenshots as crawlforge://screenshot/{actionId}
|
|
993
993
|
// resources (the documented contract) and annotate each with its URI.
|
|
994
|
+
// The base64 `data` is dropped once stored, as the stealth_mode block
|
|
995
|
+
// below already does: a failed chain's error screenshot came back inline
|
|
996
|
+
// as 1.7 MB of base64 beside a 40-char markdown (R21, 2026-09-09).
|
|
994
997
|
if (Array.isArray(result.screenshots)) {
|
|
995
998
|
result.screenshots = result.screenshots.map((shot) => {
|
|
996
999
|
if (shot?.actionId && shot?.data) {
|
|
997
1000
|
resourceRegistry.storeScreenshot(shot.actionId, shot.data);
|
|
998
|
-
|
|
1001
|
+
const { data, ...rest } = shot;
|
|
1002
|
+
return { ...rest, resourceUri: `crawlforge://screenshot/${shot.actionId}` };
|
|
999
1003
|
}
|
|
1000
1004
|
return shot;
|
|
1001
1005
|
});
|
|
@@ -314,7 +314,15 @@ export class ActionExecutor extends EventEmitter {
|
|
|
314
314
|
fullPage: true,
|
|
315
315
|
description: 'Error screenshot'
|
|
316
316
|
});
|
|
317
|
-
|
|
317
|
+
// An actionId is what lets the server publish the shot as a
|
|
318
|
+
// crawlforge://screenshot/{actionId} resource and drop the base64
|
|
319
|
+
// from the result; without one a failed chain shipped 1.7 MB of
|
|
320
|
+
// PNG inline (R21, 2026-09-09).
|
|
321
|
+
executionContext.screenshots.push({
|
|
322
|
+
...errorScreenshot,
|
|
323
|
+
actionId: this.generateActionId(),
|
|
324
|
+
error: true
|
|
325
|
+
});
|
|
318
326
|
} catch (screenshotError) {
|
|
319
327
|
this.log('warn', 'Failed to capture error screenshot: ' + screenshotError.message);
|
|
320
328
|
}
|
|
@@ -720,14 +720,16 @@ export class ChangeTracker extends EventEmitter {
|
|
|
720
720
|
significanceScore += Math.min(totalElements * 0.05, 1) *
|
|
721
721
|
(weights.additions + weights.removals + weights.modifications);
|
|
722
722
|
|
|
723
|
-
// Text changes impact
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
723
|
+
// Text changes impact. textChanges holds diff GROUPS — {type:'word_diff',
|
|
724
|
+
// changes:[{added, removed, value}]} — not flat parts, so reading
|
|
725
|
+
// `change.added.length` off a group was always 0 and this term never
|
|
726
|
+
// fired: a feed that grew by a whole record scored on similarity alone.
|
|
727
|
+
// The USGS all-hour earthquake feed gained an event (622 words, 84%
|
|
728
|
+
// similar) and compare reported hasChanges:false, "No significant
|
|
729
|
+
// changes detected" (R21, 2026-09-09).
|
|
730
|
+
const changedChars = this.changedTextChars(changeAnalysis.textChanges);
|
|
731
|
+
if (changedChars > 0) {
|
|
732
|
+
significanceScore += Math.min(changedChars / 1000, 1) * weights.textChanges;
|
|
731
733
|
}
|
|
732
734
|
|
|
733
735
|
// Determine significance level
|
|
@@ -1317,6 +1319,21 @@ export class ChangeTracker extends EventEmitter {
|
|
|
1317
1319
|
);
|
|
1318
1320
|
}
|
|
1319
1321
|
|
|
1322
|
+
/**
|
|
1323
|
+
* Characters added or removed at the text level, for significance scoring.
|
|
1324
|
+
* Same word-diff-then-line-diff choice as countTextChanges, and for the
|
|
1325
|
+
* same reason: the two describe one edit.
|
|
1326
|
+
*/
|
|
1327
|
+
changedTextChars(textChanges = []) {
|
|
1328
|
+
const diff = textChanges.find(c => c.type === 'word_diff')
|
|
1329
|
+
|| textChanges.find(c => c.type === 'line_diff');
|
|
1330
|
+
if (!diff) return 0;
|
|
1331
|
+
return diff.changes.reduce(
|
|
1332
|
+
(chars, part) => chars + ((part.added || part.removed) && typeof part.value === 'string' ? part.value.length : 0),
|
|
1333
|
+
0
|
|
1334
|
+
);
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1320
1337
|
generateChangeSummary(changeAnalysis, significance) {
|
|
1321
1338
|
const { addedElements, removedElements, modifiedElements, similarity } = changeAnalysis;
|
|
1322
1339
|
|
|
@@ -27,6 +27,70 @@ function endsMidJson(text) {
|
|
|
27
27
|
return !trimmed.endsWith('}') && !trimmed.endsWith(']');
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Recover the complete rows of a JSON response the model stopped writing.
|
|
32
|
+
*
|
|
33
|
+
* Walks the text tracking strings and the bracket stack, remembers the end of
|
|
34
|
+
* every complete element written directly inside an array, cuts there and
|
|
35
|
+
* closes what is still open. Returns null when no element completed, so a
|
|
36
|
+
* caller can still fall back. A schema asking for a table of rows routinely
|
|
37
|
+
* overruns a small model's output budget — the ECB key-rates table came back
|
|
38
|
+
* cut off at 1,800 and again at 3,600 tokens and the whole extraction failed,
|
|
39
|
+
* although dozens of rows were complete (R21, 2026-09-09).
|
|
40
|
+
*
|
|
41
|
+
* @param {string} text
|
|
42
|
+
* @returns {{ data: unknown, rows: number }|null}
|
|
43
|
+
*/
|
|
44
|
+
export function salvageTruncatedJson(text) {
|
|
45
|
+
const src = text.trim();
|
|
46
|
+
if (!src.startsWith('{') && !src.startsWith('[')) return null;
|
|
47
|
+
const stack = [];
|
|
48
|
+
let inString = false;
|
|
49
|
+
let cut = -1;
|
|
50
|
+
for (let i = 0; i < src.length; i++) {
|
|
51
|
+
const ch = src[i];
|
|
52
|
+
if (inString) {
|
|
53
|
+
if (ch === '\\') i++;
|
|
54
|
+
else if (ch === '"') {
|
|
55
|
+
inString = false;
|
|
56
|
+
// A string written directly into an array is a complete element.
|
|
57
|
+
if (stack[stack.length - 1] === '[') cut = i + 1;
|
|
58
|
+
}
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (ch === '"') inString = true;
|
|
62
|
+
else if (ch === '{' || ch === '[') stack.push(ch);
|
|
63
|
+
else if (ch === '}' || ch === ']') {
|
|
64
|
+
stack.pop();
|
|
65
|
+
if (stack[stack.length - 1] === '[') cut = i + 1;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (cut < 0) return null;
|
|
69
|
+
// Close everything still open at the cut, innermost first.
|
|
70
|
+
const open = [];
|
|
71
|
+
inString = false;
|
|
72
|
+
for (let i = 0; i < cut; i++) {
|
|
73
|
+
const ch = src[i];
|
|
74
|
+
if (inString) { if (ch === '\\') i++; else if (ch === '"') inString = false; continue; }
|
|
75
|
+
if (ch === '"') inString = true;
|
|
76
|
+
else if (ch === '{' || ch === '[') open.push(ch);
|
|
77
|
+
else if (ch === '}' || ch === ']') open.pop();
|
|
78
|
+
}
|
|
79
|
+
const closers = open.reverse().map((c) => (c === '{' ? '}' : ']')).join('');
|
|
80
|
+
let data;
|
|
81
|
+
try {
|
|
82
|
+
data = JSON.parse(src.slice(0, cut) + closers);
|
|
83
|
+
} catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
let rows = 0;
|
|
87
|
+
(function count(v) {
|
|
88
|
+
if (Array.isArray(v)) { rows += v.length; v.forEach(count); }
|
|
89
|
+
else if (v && typeof v === 'object') Object.values(v).forEach(count);
|
|
90
|
+
})(data);
|
|
91
|
+
return rows > 0 ? { data, rows } : null;
|
|
92
|
+
}
|
|
93
|
+
|
|
30
94
|
/**
|
|
31
95
|
* LLM Manager
|
|
32
96
|
* Manages multiple LLM providers and provides unified interface
|
|
@@ -987,12 +1051,25 @@ Extract the data and return valid JSON:`;
|
|
|
987
1051
|
try {
|
|
988
1052
|
parsed = JSON.parse(cleaned);
|
|
989
1053
|
} catch (parseError) {
|
|
1054
|
+
if (!endsMidJson(cleaned)) throw parseError;
|
|
990
1055
|
// Name the cause when the JSON simply stops: "unexpected end of
|
|
991
1056
|
// input at position 2608" tells a caller nothing they can act on,
|
|
992
1057
|
// whereas "cut off at the 1000-token limit" points at the schema.
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
1058
|
+
const cutOff = `model response was cut off at the ${budget}-token output limit (${cleaned.length} chars) — the schema asks for more rows than fit`;
|
|
1059
|
+
// The doubled budget was the retry; when that is cut off too, the
|
|
1060
|
+
// rows it did complete are worth more than a fallback that has none.
|
|
1061
|
+
const salvaged = attempt > 0 ? salvageTruncatedJson(cleaned) : null;
|
|
1062
|
+
if (!salvaged) throw new Error(cutOff);
|
|
1063
|
+
const validation = this.validateAgainstSchema(salvaged.data, schema);
|
|
1064
|
+
this.logger.warn('LLM structured extraction salvaged a cut-off response', { budget, rows: salvaged.rows });
|
|
1065
|
+
return {
|
|
1066
|
+
data: salvaged.data,
|
|
1067
|
+
method: 'llm',
|
|
1068
|
+
valid: validation.valid,
|
|
1069
|
+
validationErrors: validation.errors,
|
|
1070
|
+
partial: true,
|
|
1071
|
+
warning: `${cutOff}; kept the ${salvaged.rows} complete row(s) it had written. Ask for fewer rows (a prompt naming the rows you need, or a narrower schema) to get the rest.`
|
|
1072
|
+
};
|
|
996
1073
|
}
|
|
997
1074
|
|
|
998
1075
|
const validation = this.validateAgainstSchema(parsed, schema);
|
|
@@ -1016,7 +1093,7 @@ Extract the data and return valid JSON:`;
|
|
|
1016
1093
|
// Report which path produced the data. Callers previously labelled this
|
|
1017
1094
|
// result "llm", so a failed LLM call was returned as a high-confidence
|
|
1018
1095
|
// LLM extraction.
|
|
1019
|
-
return { ...this.fallbackStructuredExtraction(content, schema), error: lastError.message };
|
|
1096
|
+
return { ...this.fallbackStructuredExtraction(content, schema, lastError.message), error: lastError.message };
|
|
1020
1097
|
}
|
|
1021
1098
|
|
|
1022
1099
|
/**
|
|
@@ -1036,7 +1113,7 @@ Extract the data and return valid JSON:`;
|
|
|
1036
1113
|
/**
|
|
1037
1114
|
* Fallback structured extraction without LLM — keyword/regex matching for primitives
|
|
1038
1115
|
*/
|
|
1039
|
-
fallbackStructuredExtraction(content, schema) {
|
|
1116
|
+
fallbackStructuredExtraction(content, schema, reason = 'no LLM provider available') {
|
|
1040
1117
|
const extracted = {};
|
|
1041
1118
|
const properties = schema.properties || {};
|
|
1042
1119
|
|
|
@@ -1065,7 +1142,10 @@ Extract the data and return valid JSON:`;
|
|
|
1065
1142
|
data: extracted,
|
|
1066
1143
|
method: 'keyword_fallback',
|
|
1067
1144
|
valid: false,
|
|
1068
|
-
|
|
1145
|
+
// Name the real reason: this fallback also runs after an LLM attempt
|
|
1146
|
+
// that failed, and "no LLM provider available" beside a working Ollama
|
|
1147
|
+
// sent a caller looking at the wrong thing (R21, 2026-09-09).
|
|
1148
|
+
validationErrors: [`Used fallback extraction — ${reason}`]
|
|
1069
1149
|
};
|
|
1070
1150
|
}
|
|
1071
1151
|
|
|
@@ -12,7 +12,9 @@ import { config } from '../../constants/config.js';
|
|
|
12
12
|
import { identityHeaders } from '../../utils/fetchIdentity.js';
|
|
13
13
|
|
|
14
14
|
const PDFProcessorSchema = z.object({
|
|
15
|
-
source:
|
|
15
|
+
// A Buffer is the 'buffer' source: process_document hands over a body it
|
|
16
|
+
// has already fetched and sniffed as a PDF (R21, 2026-09-09).
|
|
17
|
+
source: z.union([z.string().min(1), z.instanceof(Buffer)]),
|
|
16
18
|
sourceType: z.enum(['url', 'file', 'buffer']).default('url'),
|
|
17
19
|
options: z.object({
|
|
18
20
|
extractMetadata: z.boolean().default(true),
|
|
@@ -80,6 +80,9 @@ export function appendFallbackHint(toolName, result) {
|
|
|
80
80
|
// plain text
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
// An error that already names its next step — a reddit.com refusal points
|
|
84
|
+
// at reddit_search — keeps it: the tool's generic hint would contradict it.
|
|
85
|
+
if (parsed?.next_step || /\bNext step:/.test(first.text)) return result;
|
|
83
86
|
const hint = hintFor(toolName, parsed);
|
|
84
87
|
if (!hint || first.text.includes(hint)) return result;
|
|
85
88
|
if (parsed) {
|
|
@@ -72,6 +72,27 @@ export function resultTextView(resultObject, textPaths = []) {
|
|
|
72
72
|
return { view: 'json', view_path: null, text: JSON.stringify(resultObject, null, 2) };
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
/**
|
|
76
|
+
* The non-text fields of `content` small enough to stay inline: everything
|
|
77
|
+
* except the text views (markdown, text, html, …) up to a quarter of the
|
|
78
|
+
* inline budget each. Null when there is nothing to keep.
|
|
79
|
+
*/
|
|
80
|
+
export function keepSmallContentFields(content, textPaths = [], maxInline = DEFAULT_MAX_INLINE_CHARS) {
|
|
81
|
+
if (!content || typeof content !== 'object' || Array.isArray(content)) return null;
|
|
82
|
+
const textLeaves = new Set(
|
|
83
|
+
textPaths.filter((p) => p.startsWith('content.')).map((p) => p.slice('content.'.length))
|
|
84
|
+
);
|
|
85
|
+
const cap = Math.max(1000, Math.floor(maxInline / 4));
|
|
86
|
+
const kept = {};
|
|
87
|
+
for (const [key, value] of Object.entries(content)) {
|
|
88
|
+
if (textLeaves.has(key) || value === undefined) continue;
|
|
89
|
+
if (typeof value === 'string' && value.length > cap) continue;
|
|
90
|
+
if (typeof value === 'object' && value !== null && JSON.stringify(value).length > cap) continue;
|
|
91
|
+
kept[key] = value;
|
|
92
|
+
}
|
|
93
|
+
return Object.keys(kept).length > 0 ? kept : null;
|
|
94
|
+
}
|
|
95
|
+
|
|
75
96
|
function warningsOf(resultObject) {
|
|
76
97
|
return Array.isArray(resultObject.warnings) ? resultObject.warnings.filter((w) => typeof w === 'string') : [];
|
|
77
98
|
}
|
|
@@ -129,7 +150,9 @@ export function applyInlineThreshold(toolName, resultObject, params, { store, en
|
|
|
129
150
|
}
|
|
130
151
|
|
|
131
152
|
const preview = text.slice(0, maxInline);
|
|
132
|
-
const
|
|
153
|
+
const keptFields = keepSmallContentFields(resultObject.content, config.textPaths, maxInline);
|
|
154
|
+
const keptDesc = keptFields ? `; content.${Object.keys(keptFields).join(', content.')} kept inline` : '';
|
|
155
|
+
const hint = `Result is ${json.length} chars as JSON, over the inline limit of ${maxInline}; preview holds the first ${preview.length} chars of ${viewDesc} (${text.length} chars in total)${keptDesc} and the full result is kept for 1 hour under result_handle ${handle}: ${readWith}.`;
|
|
133
156
|
|
|
134
157
|
const shaped = {};
|
|
135
158
|
for (const [key, value] of Object.entries(resultObject)) {
|
|
@@ -143,6 +166,13 @@ export function applyInlineThreshold(toolName, resultObject, params, { store, en
|
|
|
143
166
|
if (resultObject.redaction && typeof resultObject.redaction === 'object') {
|
|
144
167
|
shaped.redaction = resultObject.redaction;
|
|
145
168
|
}
|
|
169
|
+
// The query-scoped formats live beside the page text under `content`
|
|
170
|
+
// (highlights, answer, json, metadata, links). They are the small, exact
|
|
171
|
+
// answer the caller paid for, and truncating the markdown must not drop
|
|
172
|
+
// them: an nhs.uk scrape with highlights and a question came back as a
|
|
173
|
+
// markdown preview and nothing else (R21, 2026-09-09). Keep every
|
|
174
|
+
// non-text `content` field that fits a quarter of the inline budget.
|
|
175
|
+
if (keptFields) shaped.content = keptFields;
|
|
146
176
|
Object.assign(shaped, {
|
|
147
177
|
preview,
|
|
148
178
|
result_handle: handle,
|
|
@@ -36,7 +36,7 @@ export function isInternalRequest() {
|
|
|
36
36
|
* reaches withAuth. It also survives both routes a refusal can take — thrown,
|
|
37
37
|
* or swallowed into an isError result.
|
|
38
38
|
*
|
|
39
|
-
* @param {string} code 'ROBOTS_DISALLOWED' | 'HOST_BLOCKED'
|
|
39
|
+
* @param {string} code 'ROBOTS_DISALLOWED' | 'HOST_BLOCKED' | 'USE_REDDIT_SEARCH'
|
|
40
40
|
*/
|
|
41
41
|
export function markPreflightRefusal(code) {
|
|
42
42
|
const store = requestContext.getStore();
|
|
@@ -10,6 +10,44 @@ import { CRAWLFORGE_USER_AGENT } from '../../utils/fetchIdentity.js';
|
|
|
10
10
|
import { preflightFetch } from '../../utils/robotsGate.js';
|
|
11
11
|
import { pageTitle } from '../../utils/pageTitle.js';
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* The path prefix a seed URL asks for: nps.gov/yell/ means the Yellowstone
|
|
15
|
+
* subtree, not the first 200 URLs of the park service's site-wide sitemap
|
|
16
|
+
* (R21, 2026-09-09: map_site returned Abraham Lincoln Birthplace pages for it).
|
|
17
|
+
* A page URL scopes to its directory; the site root scopes to nothing.
|
|
18
|
+
* @param {string} url
|
|
19
|
+
* @returns {string|null} e.g. "/yell/", or null for the root
|
|
20
|
+
*/
|
|
21
|
+
export function scopePathOf(url) {
|
|
22
|
+
try {
|
|
23
|
+
const { pathname } = new URL(url);
|
|
24
|
+
const dir = pathname.endsWith('/') ? pathname : pathname.slice(0, pathname.lastIndexOf('/') + 1);
|
|
25
|
+
return dir && dir !== '/' ? dir : null;
|
|
26
|
+
} catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* How many of a search's terms a URL's own path and query contain — the
|
|
33
|
+
* relevance signal the generic ranker lacks when every candidate is a bare
|
|
34
|
+
* URL (it scored 0.19 for all 200 nps.gov URLs of a "fees" search, none of
|
|
35
|
+
* which mentioned fees).
|
|
36
|
+
* @param {string} url
|
|
37
|
+
* @param {string} search
|
|
38
|
+
* @returns {number}
|
|
39
|
+
*/
|
|
40
|
+
export function searchScore(url, search) {
|
|
41
|
+
const terms = [...new Set(String(search || '').toLowerCase().split(/[^\p{L}\p{N}]+/u).filter((t) => t.length >= 2))];
|
|
42
|
+
if (terms.length === 0) return 0;
|
|
43
|
+
let haystack = url.toLowerCase();
|
|
44
|
+
try {
|
|
45
|
+
const { pathname, search: query } = new URL(url);
|
|
46
|
+
haystack = decodeURIComponent(pathname + query).toLowerCase();
|
|
47
|
+
} catch { /* keep the raw url */ }
|
|
48
|
+
return terms.filter((t) => haystack.includes(t)).length;
|
|
49
|
+
}
|
|
50
|
+
|
|
13
51
|
// Lazy singleton — avoids creating a CacheManager timer per request
|
|
14
52
|
let _ranker = null;
|
|
15
53
|
function getRanker() {
|
|
@@ -107,22 +145,47 @@ export class MapSiteTool {
|
|
|
107
145
|
}
|
|
108
146
|
}
|
|
109
147
|
|
|
148
|
+
// A seed with a path asks for that subtree, and a search needs a pool
|
|
149
|
+
// wider than max_urls to rank — otherwise the cut falls before the
|
|
150
|
+
// relevant URLs ever enter (the sitemap head is alphabetical).
|
|
151
|
+
const scopePath = scopePathOf(validated.url);
|
|
152
|
+
const widen = scopePath || validated.search;
|
|
153
|
+
const poolLimit = widen ? Math.min(10000, Math.max(validated.max_urls * 10, 2000)) : validated.max_urls;
|
|
154
|
+
const warnings = [];
|
|
155
|
+
|
|
110
156
|
// Try to fetch sitemap first
|
|
111
157
|
if (validated.include_sitemap) {
|
|
112
|
-
const sitemapUrls = await this.fetchSitemapUrls(baseUrl, domainFilter,
|
|
158
|
+
const sitemapUrls = await this.fetchSitemapUrls(baseUrl, domainFilter, poolLimit, scopePath);
|
|
113
159
|
sitemapUrls.forEach(url => urls.add(normalizeUrl(url)));
|
|
114
160
|
}
|
|
115
161
|
|
|
116
162
|
// Fetch and parse the main page for additional URLs
|
|
117
163
|
const pageUrls = await this.fetchPageUrls(validated.url, domainFilter, identity);
|
|
118
164
|
pageUrls.forEach(url => {
|
|
119
|
-
if (urls.size <
|
|
165
|
+
if (urls.size < poolLimit) {
|
|
120
166
|
urls.add(normalizeUrl(url));
|
|
121
167
|
}
|
|
122
168
|
});
|
|
123
169
|
|
|
170
|
+
let pool = Array.from(urls);
|
|
171
|
+
if (scopePath) {
|
|
172
|
+
const inScope = pool.filter((u) => { try { return new URL(u).pathname.startsWith(scopePath); } catch { return false; } });
|
|
173
|
+
if (inScope.length > 0) {
|
|
174
|
+
pool = inScope;
|
|
175
|
+
} else {
|
|
176
|
+
warnings.push(`No URL under ${scopePath} was found in the sitemap or on the page; the whole site is listed instead.`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (validated.search) {
|
|
180
|
+
// Stable: relevance first, discovery order among equals.
|
|
181
|
+
pool = pool
|
|
182
|
+
.map((url, i) => ({ url, i, score: searchScore(url, validated.search) }))
|
|
183
|
+
.sort((a, b) => b.score - a.score || a.i - b.i)
|
|
184
|
+
.map((x) => x.url);
|
|
185
|
+
}
|
|
186
|
+
|
|
124
187
|
// Convert to array and limit
|
|
125
|
-
const urlArray =
|
|
188
|
+
const urlArray = pool.slice(0, validated.max_urls);
|
|
126
189
|
|
|
127
190
|
// Fetch metadata if requested
|
|
128
191
|
if (validated.include_metadata) {
|
|
@@ -142,7 +205,9 @@ export class MapSiteTool {
|
|
|
142
205
|
site_map: this.generateSiteMap(urlArray),
|
|
143
206
|
statistics: this.generateStatistics(urlArray),
|
|
144
207
|
domain_filter_config: domainFilter ? domainFilter.exportConfig() : null,
|
|
145
|
-
filter_stats: domainFilter ? domainFilter.getStats() : null
|
|
208
|
+
filter_stats: domainFilter ? domainFilter.getStats() : null,
|
|
209
|
+
...(scopePath ? { scope: scopePath } : {}),
|
|
210
|
+
...(warnings.length ? { warnings } : {})
|
|
146
211
|
};
|
|
147
212
|
|
|
148
213
|
// Optional: rank URLs by relevance to a search string
|
|
@@ -157,7 +222,11 @@ export class MapSiteTool {
|
|
|
157
222
|
return { link: url, title, snippet: '' };
|
|
158
223
|
});
|
|
159
224
|
const ranked = await getRanker().rankResults(rankerInput, validated.search);
|
|
160
|
-
|
|
225
|
+
// The ranker's score is flat across bare URLs; the path-term count
|
|
226
|
+
// is what separates /yell/planyourvisit/fees.htm from the rest.
|
|
227
|
+
result.ranked_urls = ranked
|
|
228
|
+
.map(r => ({ url: r.link, score: Number(((r.finalScore ?? 0) + searchScore(r.link, validated.search)).toFixed(3)) }))
|
|
229
|
+
.sort((a, b) => b.score - a.score);
|
|
161
230
|
} catch {
|
|
162
231
|
// ranking is best-effort; don't fail the whole call
|
|
163
232
|
result.ranked_urls = urlArray.map(u => ({ url: u, score: 0 }));
|
|
@@ -193,7 +262,11 @@ export class MapSiteTool {
|
|
|
193
262
|
});
|
|
194
263
|
}
|
|
195
264
|
|
|
196
|
-
async fetchSitemapUrls(baseUrl, domainFilter = null, maxUrls = Infinity) {
|
|
265
|
+
async fetchSitemapUrls(baseUrl, domainFilter = null, maxUrls = Infinity, scopePath = null) {
|
|
266
|
+
const inScope = (url) => {
|
|
267
|
+
if (!scopePath) return true;
|
|
268
|
+
try { return new URL(url).pathname.startsWith(scopePath); } catch { return false; }
|
|
269
|
+
};
|
|
197
270
|
// Discover sitemaps via robots.txt and common paths, then parse with full
|
|
198
271
|
// SitemapParser support (sitemap-index recursion, gzip, CDATA/entities).
|
|
199
272
|
const discovered = await this.sitemapParser.discoverSitemaps(baseUrl, {
|
|
@@ -212,6 +285,7 @@ export class MapSiteTool {
|
|
|
212
285
|
if (parsed.success) {
|
|
213
286
|
for (const entry of parsed.urls) {
|
|
214
287
|
const url = entry.loc || entry;
|
|
288
|
+
if (!inScope(url)) continue;
|
|
215
289
|
if (!domainFilter || domainFilter.isAllowed(url).allowed) {
|
|
216
290
|
urls.add(url);
|
|
217
291
|
}
|
|
@@ -267,7 +341,7 @@ export class MapSiteTool {
|
|
|
267
341
|
// A gate refusal is the answer to the request, not a page we failed to
|
|
268
342
|
// read: surface it instead of returning an emptier map than the caller
|
|
269
343
|
// would notice.
|
|
270
|
-
if (error.code === 'ROBOTS_DISALLOWED' || error.code === 'HOST_BLOCKED') throw error;
|
|
344
|
+
if (error.code === 'ROBOTS_DISALLOWED' || error.code === 'HOST_BLOCKED' || error.code === 'USE_REDDIT_SEARCH') throw error;
|
|
271
345
|
return [];
|
|
272
346
|
}
|
|
273
347
|
}
|
|
@@ -41,7 +41,23 @@ export async function extractEmbeddedStateHandler({ url, path, user_agent, respe
|
|
|
41
41
|
);
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
|
|
44
|
+
// A path is naturally written against the payload ("props.pageProps"),
|
|
45
|
+
// not against this tool's envelope ("next_data.props.pageProps"). When
|
|
46
|
+
// the page carries exactly one payload and the path's root is not one of
|
|
47
|
+
// the envelope keys, read it inside that payload and say so (R21,
|
|
48
|
+
// 2026-09-09: four Next.js pages in a row failed on the bare path).
|
|
49
|
+
let effectivePath = path;
|
|
50
|
+
if (path && state.found.length === 1) {
|
|
51
|
+
const root = path.split(/[.[]/)[0];
|
|
52
|
+
if (root && !(root in state.data)) {
|
|
53
|
+
effectivePath = `${state.found[0].name}.${path}`;
|
|
54
|
+
warnings.push(
|
|
55
|
+
`path "${path}" was read as "${effectivePath}": "${state.found[0].name}" is the only payload on this page, so the path is resolved inside it.`
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const data = effectivePath ? selectJsonPath(state.data, effectivePath) : state.data;
|
|
45
61
|
const bytes = Buffer.byteLength(JSON.stringify(data) ?? '');
|
|
46
62
|
|
|
47
63
|
if (!path && bytes > LARGE_RESULT_BYTES) {
|
|
@@ -57,7 +73,7 @@ export async function extractEmbeddedStateHandler({ url, path, user_agent, respe
|
|
|
57
73
|
text: JSON.stringify({
|
|
58
74
|
url: finalUrl,
|
|
59
75
|
found: state.found,
|
|
60
|
-
path:
|
|
76
|
+
path: effectivePath || null,
|
|
61
77
|
bytes,
|
|
62
78
|
data,
|
|
63
79
|
warnings
|
|
@@ -258,6 +258,9 @@ export class ExtractStructuredTool {
|
|
|
258
258
|
if (result?.method === 'llm') {
|
|
259
259
|
extractionResult = result;
|
|
260
260
|
extractionMethod = 'llm';
|
|
261
|
+
// A salvaged cut-off response: the rows are real, the caller must
|
|
262
|
+
// know the list is not the whole table (R21, 2026-09-09).
|
|
263
|
+
if (result.warning) warnings.push(result.warning);
|
|
261
264
|
} else {
|
|
262
265
|
llmErrorMessage = result?.error || 'LLM did not return usable JSON';
|
|
263
266
|
}
|
|
@@ -433,6 +436,7 @@ export class ExtractStructuredTool {
|
|
|
433
436
|
},
|
|
434
437
|
extractionNotes,
|
|
435
438
|
provenance,
|
|
439
|
+
...(extractionResult.partial ? { partial: true } : {}),
|
|
436
440
|
...(warnings?.length ? { warnings } : {})
|
|
437
441
|
};
|
|
438
442
|
|
|
@@ -118,6 +118,57 @@ const ProcessDocumentResult = z.object({
|
|
|
118
118
|
error: z.string().optional()
|
|
119
119
|
});
|
|
120
120
|
|
|
121
|
+
/**
|
|
122
|
+
* What a fetched body is, from its Content-Type, magic bytes and URL:
|
|
123
|
+
* 'pdf', 'docx', 'binary' (something this tool cannot read) or 'html'
|
|
124
|
+
* (HTML, XML, JSON and plain text all go through the page pipeline).
|
|
125
|
+
* @param {string} contentType
|
|
126
|
+
* @param {Buffer} buffer
|
|
127
|
+
* @param {string} [url]
|
|
128
|
+
* @returns {'pdf'|'docx'|'binary'|'html'}
|
|
129
|
+
*/
|
|
130
|
+
export function sniffDocumentKind(contentType = '', buffer, url = '') {
|
|
131
|
+
const type = String(contentType).split(';')[0].trim().toLowerCase();
|
|
132
|
+
const head = buffer.subarray(0, 8).toString('latin1');
|
|
133
|
+
let path = String(url).toLowerCase();
|
|
134
|
+
try { path = new URL(url).pathname.toLowerCase(); } catch { /* keep the raw string */ }
|
|
135
|
+
|
|
136
|
+
if (type === 'application/pdf' || head.startsWith('%PDF')) return 'pdf';
|
|
137
|
+
|
|
138
|
+
const zip = head.startsWith('PK\u0003\u0004');
|
|
139
|
+
if (
|
|
140
|
+
type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' ||
|
|
141
|
+
(zip && (path.endsWith('.docx') || buffer.includes('word/document.xml')))
|
|
142
|
+
) return 'docx';
|
|
143
|
+
|
|
144
|
+
if (zip) return 'binary';
|
|
145
|
+
if (/^(image|audio|video|font)\//.test(type)) return 'binary';
|
|
146
|
+
if (/^application\/(x-)?(zip|gzip|tar|7z|rar|msword|vnd\.)/.test(type)) return 'binary';
|
|
147
|
+
const textual = type.startsWith('text/') || /html|xml|json|javascript/.test(type);
|
|
148
|
+
if (!textual && buffer.subarray(0, 512).includes(0)) return 'binary';
|
|
149
|
+
return 'html';
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Decode a text body by its Content-Type charset, else the <meta charset>
|
|
154
|
+
* it declares, else UTF-8.
|
|
155
|
+
* @param {Buffer} buffer
|
|
156
|
+
* @param {string} contentType
|
|
157
|
+
* @returns {string}
|
|
158
|
+
*/
|
|
159
|
+
export function decodeTextBody(buffer, contentType = '') {
|
|
160
|
+
let charset = (/charset=["']?([\w-]+)/i.exec(contentType) || [])[1];
|
|
161
|
+
if (!charset) {
|
|
162
|
+
const head = buffer.subarray(0, 4096).toString('latin1');
|
|
163
|
+
charset = (/<meta[^>]+charset=["']?([\w-]+)/i.exec(head) || [])[1];
|
|
164
|
+
}
|
|
165
|
+
try {
|
|
166
|
+
return new TextDecoder(charset || 'utf-8').decode(buffer);
|
|
167
|
+
} catch {
|
|
168
|
+
return buffer.toString('utf8');
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
121
172
|
export class ProcessDocumentTool {
|
|
122
173
|
constructor() {
|
|
123
174
|
this.pdfProcessor = new PDFProcessor();
|
|
@@ -340,7 +391,30 @@ export class ProcessDocumentTool {
|
|
|
340
391
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
341
392
|
}
|
|
342
393
|
|
|
343
|
-
|
|
394
|
+
// What came back decides how it is read, not the sourceType the caller
|
|
395
|
+
// guessed: a .docx fetched as 'url' was run through the HTML pipeline
|
|
396
|
+
// and returned its ZIP bytes as page text, success:true (R21,
|
|
397
|
+
// 2026-09-09). PDFs reach their parser the same way.
|
|
398
|
+
const contentType = response.headers.get('content-type') || '';
|
|
399
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
400
|
+
const kind = sniffDocumentKind(contentType, buffer, source);
|
|
401
|
+
if (kind === 'pdf') {
|
|
402
|
+
result.documentType = 'pdf';
|
|
403
|
+
await this.processPDFDocument(result, buffer, 'pdf_buffer', options, identity);
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
if (kind === 'docx') {
|
|
407
|
+
result.documentType = 'docx';
|
|
408
|
+
await this.processDocxBuffer(result, buffer, options);
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
if (kind === 'binary') {
|
|
412
|
+
throw new Error(
|
|
413
|
+
`${contentType.split(';')[0] || 'This'} content is not a document this tool reads: ` +
|
|
414
|
+
'process_document reads PDF, DOCX, HTML and plain text.'
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
html = decodeTextBody(buffer, contentType);
|
|
344
418
|
pageTitle = this.extractTitleFromHTML(html);
|
|
345
419
|
}
|
|
346
420
|
|
|
@@ -349,6 +423,25 @@ export class ProcessDocumentTool {
|
|
|
349
423
|
await this.processFetchedHtml(result, html, source, options);
|
|
350
424
|
}
|
|
351
425
|
|
|
426
|
+
/**
|
|
427
|
+
* Read a Word document: its text (and markdown when asked) via mammoth.
|
|
428
|
+
* @param {Object} result - Result object to populate
|
|
429
|
+
* @param {Buffer} buffer - the .docx bytes
|
|
430
|
+
* @param {Object} options - Processing options
|
|
431
|
+
*/
|
|
432
|
+
async processDocxBuffer(result, buffer, options) {
|
|
433
|
+
const mammoth = (await import('mammoth')).default;
|
|
434
|
+
const raw = await mammoth.extractRawText({ buffer });
|
|
435
|
+
result.content = { text: (raw.value || '').trim() };
|
|
436
|
+
if (options.outputFormat === 'markdown') {
|
|
437
|
+
const md = await mammoth.convertToMarkdown({ buffer });
|
|
438
|
+
result.content.markdown = md.value || '';
|
|
439
|
+
}
|
|
440
|
+
result.title = null;
|
|
441
|
+
const notes = (raw.messages || []).map((m) => m.message).filter(Boolean);
|
|
442
|
+
if (notes.length > 0) result.warnings = [...(result.warnings || []), ...notes.slice(0, 5)];
|
|
443
|
+
}
|
|
444
|
+
|
|
352
445
|
/**
|
|
353
446
|
* Process a local non-PDF file (sourceType 'file'): read it from disk and
|
|
354
447
|
* run it through the same content-processing pipeline used for web pages.
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import { safeFetch } from '../../utils/ssrfGuard.js';
|
|
18
|
+
import { mediaAppliesToScreen } from '../../utils/hiddenContent.js';
|
|
18
19
|
|
|
19
20
|
const GENERIC_FAMILIES = new Set([
|
|
20
21
|
'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy', 'system-ui',
|
|
@@ -123,19 +124,36 @@ export async function collectCssSources($, pageUrl, opts) {
|
|
|
123
124
|
let inlineStyleEls = 0;
|
|
124
125
|
let cssText = '';
|
|
125
126
|
|
|
127
|
+
// Print (and other non-screen) stylesheets never paint a screen render, so
|
|
128
|
+
// neither branding nor the hidden-content pass may read them. `media:
|
|
129
|
+
// 'unconditional-screen'` also drops sheets gated on a media query.
|
|
130
|
+
const unconditional = opts.media === 'unconditional-screen';
|
|
131
|
+
const applies = (el) => mediaAppliesToScreen($(el).attr('media'), { unconditional });
|
|
132
|
+
|
|
126
133
|
$('style').each((_, el) => {
|
|
134
|
+
if (!applies(el)) return;
|
|
127
135
|
const t = $(el).html();
|
|
128
136
|
if (t) { cssText += '\n' + t; styleBlocks++; }
|
|
129
137
|
});
|
|
130
138
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
139
|
+
// Inline style="" attributes are folded in as universal rules so branding
|
|
140
|
+
// can mine their colours and fonts. They are NOT visibility rules: one
|
|
141
|
+
// element carrying style="display:none" became `*{display:none}`, and the
|
|
142
|
+
// hidden-content pass then deleted every element small enough to pass its
|
|
143
|
+
// bulk guard — irs.gov's tax-bracket page came back as its header (R21,
|
|
144
|
+
// 2026-09-09). That pass reads inline styles per element itself, so it
|
|
145
|
+
// asks for `inlineStyles: false`.
|
|
146
|
+
if (opts.inlineStyles !== false) {
|
|
147
|
+
$('[style]').each((_, el) => {
|
|
148
|
+
const t = $(el).attr('style');
|
|
149
|
+
if (t) { cssText += '\n*{' + t + '}'; inlineStyleEls++; }
|
|
150
|
+
});
|
|
151
|
+
}
|
|
135
152
|
|
|
136
153
|
if (opts.fetchLinkedCss) {
|
|
137
154
|
const hrefs = [];
|
|
138
155
|
$('link[rel~="stylesheet"][href]').each((_, el) => {
|
|
156
|
+
if (!applies(el)) return;
|
|
139
157
|
const href = $(el).attr('href');
|
|
140
158
|
if (href) hrefs.push(resolveUrl(href, pageUrl));
|
|
141
159
|
});
|
|
@@ -404,7 +422,7 @@ function extractTokens(cssText, cssVariables) {
|
|
|
404
422
|
* Extract the full branding object from a loaded cheerio $.
|
|
405
423
|
* @param {import('cheerio').CheerioAPI} $
|
|
406
424
|
* @param {string} pageUrl
|
|
407
|
-
* @param {{ fetchLinkedCss?: boolean, maxStylesheets?: number, perFileTimeoutMs?: number, timeoutMs?: number, overallTimeoutMs?: number, stylesheetConcurrency?: number }} [opts]
|
|
425
|
+
* @param {{ fetchLinkedCss?: boolean, maxStylesheets?: number, perFileTimeoutMs?: number, timeoutMs?: number, overallTimeoutMs?: number, stylesheetConcurrency?: number, media?: 'screen'|'unconditional-screen', inlineStyles?: boolean }} [opts]
|
|
408
426
|
* @returns {Promise<object>}
|
|
409
427
|
*/
|
|
410
428
|
export async function extractBranding($, pageUrl, opts = {}) {
|
|
@@ -542,7 +542,14 @@ export class UnifiedScrapeTool {
|
|
|
542
542
|
// Themes split visibility rules across many component sheets — the
|
|
543
543
|
// rule hiding Shopify's sold-out badge sits at index 12 of 38 on a
|
|
544
544
|
// stock Dawn storefront, so a cap of 10 silently misses it.
|
|
545
|
-
maxStylesheets: 20
|
|
545
|
+
maxStylesheets: 20,
|
|
546
|
+
// Only sheets a screen render applies unconditionally: a
|
|
547
|
+
// media="print" sheet hid every screen element of irs.gov's
|
|
548
|
+
// tax-bracket page and left the print logo (R21, 2026-09-09).
|
|
549
|
+
media: 'unconditional-screen',
|
|
550
|
+
// stripHiddenFromDom reads style="" per element; folded into
|
|
551
|
+
// universal rules they hid the whole page (R21).
|
|
552
|
+
inlineStyles: false
|
|
546
553
|
});
|
|
547
554
|
css = collected.cssText || '';
|
|
548
555
|
}
|
|
@@ -15,11 +15,14 @@
|
|
|
15
15
|
* cross-subreddit full-text search, but has known post-2023 archive gaps
|
|
16
16
|
* and recurring outages.
|
|
17
17
|
*
|
|
18
|
-
* Routing:
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
18
|
+
* Routing: Arctic Shift first, PullPush second. A scoped search queries
|
|
19
|
+
* Arctic Shift and, if that fails, PullPush. An unscoped keyword search finds
|
|
20
|
+
* posts through a site-restricted web search and reads them — or searches
|
|
21
|
+
* their comments — in Arctic Shift by id, then falls back to PullPush's own
|
|
22
|
+
* full-text search. Thread mode is Arctic Shift only. PullPush has refused
|
|
23
|
+
* automated clients since August 2026, so the fallback usually reports that
|
|
24
|
+
* refusal; it stays second for whenever it answers again. Both archives are
|
|
25
|
+
* free and need no credentials.
|
|
23
26
|
*
|
|
24
27
|
* Optional official-API path: if the user sets REDDIT_CLIENT_ID and
|
|
25
28
|
* REDDIT_CLIENT_SECRET (their own Reddit app), posts/thread requests can read
|
|
@@ -101,9 +104,8 @@ export class RedditSearchTool {
|
|
|
101
104
|
// Lazily constructed on first official-path use; overridable for tests.
|
|
102
105
|
this._officialAdapter = options.officialAdapter || null;
|
|
103
106
|
|
|
104
|
-
// Web discovery serves the one shape
|
|
105
|
-
// across all of Reddit
|
|
106
|
-
// and PullPush stopped serving automated clients in August 2026.
|
|
107
|
+
// Web discovery serves the one shape Arctic Shift cannot: a keyword search
|
|
108
|
+
// across all of Reddit, which it requires a subreddit or author scope for.
|
|
107
109
|
this.searchAdapter = options.searchAdapter || null;
|
|
108
110
|
this.searchApiKey = options.searchApiKey || null;
|
|
109
111
|
this.searchApiBaseUrl = options.searchApiBaseUrl || null;
|
|
@@ -179,13 +181,16 @@ export class RedditSearchTool {
|
|
|
179
181
|
order = ['pullpush'];
|
|
180
182
|
} else {
|
|
181
183
|
// auto: prefer the user's own official API (live, authoritative) when it
|
|
182
|
-
// can serve this request, then
|
|
183
|
-
// PullPush
|
|
184
|
-
// 429 "This website does not
|
|
185
|
-
//
|
|
184
|
+
// can serve this request, then the community archives — Arctic Shift
|
|
185
|
+
// first, PullPush second (owner's call, 2026-09-10). PullPush has refused
|
|
186
|
+
// automated clients since August 2026 — 429 "This website does not
|
|
187
|
+
// provide free scraping resources for agents", or a Cloudflare 403
|
|
188
|
+
// challenge — so the fallback is one un-retried request that usually
|
|
189
|
+
// reports that refusal after the real Arctic Shift error, and answers
|
|
190
|
+
// when PullPush is serving again. Thread mode has no second source.
|
|
186
191
|
const archives = v.mode === 'thread' ? ['arctic_shift']
|
|
187
|
-
: arcticPossible ? ['arctic_shift']
|
|
188
|
-
: discoveryPossible ? ['web_discovery']
|
|
192
|
+
: arcticPossible ? ['arctic_shift', 'pullpush']
|
|
193
|
+
: discoveryPossible ? ['web_discovery', 'pullpush']
|
|
189
194
|
: [];
|
|
190
195
|
order = officialPossible ? ['reddit_api', ...archives] : archives;
|
|
191
196
|
}
|
|
@@ -252,7 +257,7 @@ export class RedditSearchTool {
|
|
|
252
257
|
? 'Reddit-wide comment search: posts were found with a site-restricted web search, then each post\'s comments were searched for the keywords in the Arctic Shift archive, in post relevance order.'
|
|
253
258
|
: 'Reddit-wide keyword search: posts were found with a site-restricted web search, then read from the Arctic Shift archive by ID.',
|
|
254
259
|
'Results are ordered by web-search relevance, not by score or date.',
|
|
255
|
-
'Arctic Shift cannot keyword-search across all of Reddit
|
|
260
|
+
'Arctic Shift cannot keyword-search across all of Reddit — scope the search to a subreddit or author to query the archive directly.',
|
|
256
261
|
];
|
|
257
262
|
if (v.after || v.before) {
|
|
258
263
|
// Silently dropping a date filter would return results the caller
|
|
@@ -489,12 +494,14 @@ export class RedditSearchTool {
|
|
|
489
494
|
if (response.status === 429) {
|
|
490
495
|
const reset = response.headers?.get?.('x-ratelimit-reset');
|
|
491
496
|
// PullPush's 429 body states its actual policy ("does not provide free
|
|
492
|
-
// scraping resources for agents...") — pass that through verbatim.
|
|
497
|
+
// scraping resources for agents...") — pass that through verbatim. That
|
|
498
|
+
// policy refusal is not a transient throttle: retrying it only spends the
|
|
499
|
+
// delay, which matters now that PullPush is the automatic second source.
|
|
493
500
|
let detail = '';
|
|
494
501
|
try { detail = (await response.json())?.error ?? ''; } catch { /* no body */ }
|
|
495
502
|
throw Object.assign(
|
|
496
503
|
new Error(`rate limited (429)${reset ? `, retry in ${reset}s` : ''}${detail ? ` — ${detail}` : ''}`),
|
|
497
|
-
{ retryable:
|
|
504
|
+
{ retryable: !/does not provide free scraping/i.test(detail) },
|
|
498
505
|
);
|
|
499
506
|
}
|
|
500
507
|
if (!response.ok) {
|
|
@@ -83,6 +83,38 @@ function stripConditionalBlocks(css) {
|
|
|
83
83
|
return out;
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
/**
|
|
87
|
+
* Split a selector list on its top-level commas only. A naive split cut
|
|
88
|
+
* irs.gov's `.callout:has(> .a, .b, p, ul, h2, h3, table)` into bare `p`,
|
|
89
|
+
* `ul`, `h2`, `h3` and `table` rules carrying its `display:none !important`,
|
|
90
|
+
* and the stripper then deleted the article body (R21, 2026-09-09).
|
|
91
|
+
* @param {string} list
|
|
92
|
+
* @returns {string[]}
|
|
93
|
+
*/
|
|
94
|
+
export function splitSelectorList(list) {
|
|
95
|
+
const out = [];
|
|
96
|
+
let depth = 0;
|
|
97
|
+
let quote = null;
|
|
98
|
+
let start = 0;
|
|
99
|
+
for (let i = 0; i < list.length; i++) {
|
|
100
|
+
const ch = list[i];
|
|
101
|
+
if (quote) {
|
|
102
|
+
if (ch === '\\') i++;
|
|
103
|
+
else if (ch === quote) quote = null;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (ch === '"' || ch === "'") quote = ch;
|
|
107
|
+
else if (ch === '(' || ch === '[') depth++;
|
|
108
|
+
else if (ch === ')' || ch === ']') depth = Math.max(0, depth - 1);
|
|
109
|
+
else if (ch === ',' && depth === 0) {
|
|
110
|
+
out.push(list.slice(start, i));
|
|
111
|
+
start = i + 1;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
out.push(list.slice(start));
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
|
|
86
118
|
/** True when cheerio cannot meaningfully evaluate the selector. */
|
|
87
119
|
function isUnsupportedSelector(selector) {
|
|
88
120
|
return (
|
|
@@ -91,7 +123,7 @@ function isUnsupportedSelector(selector) {
|
|
|
91
123
|
// case and these rules would hide content every real visitor sees.
|
|
92
124
|
/(^|[\s.#\[])no-js(\b|[.\[])/.test(selector) ||
|
|
93
125
|
selector.includes('::') ||
|
|
94
|
-
/:(hover|focus|focus-within|focus-visible|active|target|checked|disabled|placeholder|before|after|root|host|where|is|not\()/i.test(selector) ||
|
|
126
|
+
/:(hover|focus|focus-within|focus-visible|active|target|checked|disabled|placeholder|before|after|root|host|where|is|not\(|has\()/i.test(selector) ||
|
|
95
127
|
selector.includes('@') ||
|
|
96
128
|
selector.length === 0
|
|
97
129
|
);
|
|
@@ -199,7 +231,7 @@ export function collectVisibilitySelectors(css) {
|
|
|
199
231
|
|
|
200
232
|
const important = /!\s*important/i.test(body);
|
|
201
233
|
|
|
202
|
-
for (const raw of selectorList
|
|
234
|
+
for (const raw of splitSelectorList(selectorList)) {
|
|
203
235
|
const selector = raw.trim();
|
|
204
236
|
if (isUnsupportedSelector(selector) || NEVER_REMOVE.has(selector)) continue;
|
|
205
237
|
const entry = { selector, spec: specificity(selector), order, important };
|
|
@@ -249,12 +281,45 @@ function renderedSize($, el) {
|
|
|
249
281
|
export function inlineStyleText($) {
|
|
250
282
|
const parts = [];
|
|
251
283
|
$('style').each((_, el) => {
|
|
284
|
+
// A <style media="print"> block is only consulted when printing.
|
|
285
|
+
if (!mediaAppliesToScreen($(el).attr('media'), { unconditional: true })) return;
|
|
252
286
|
const text = $(el).html();
|
|
253
287
|
if (text) parts.push(text);
|
|
254
288
|
});
|
|
255
289
|
return parts.join('\n');
|
|
256
290
|
}
|
|
257
291
|
|
|
292
|
+
/**
|
|
293
|
+
* Whether a stylesheet's `media` attribute applies to a screen render.
|
|
294
|
+
*
|
|
295
|
+
* `<link rel="stylesheet" media="print">` is consulted only when printing.
|
|
296
|
+
* irs.gov's tax-bracket page ships one that hides every screen element and
|
|
297
|
+
* shows a print-only logo; read as screen CSS it emptied the page to that logo
|
|
298
|
+
* (R21, 2026-09-09). Absent or empty, `all`, `screen` and `not print` apply.
|
|
299
|
+
* `print`, `speech` and the other non-screen types, and `not screen`/`not all`,
|
|
300
|
+
* do not. With `unconditional: true` a media QUERY (`screen and (max-width:
|
|
301
|
+
* 600px)`, a bare `(prefers-color-scheme: dark)`) does not apply either — the
|
|
302
|
+
* hidden-content pass ignores @media blocks for the same reason.
|
|
303
|
+
*
|
|
304
|
+
* @param {string|undefined} media - the attribute value
|
|
305
|
+
* @param {{ unconditional?: boolean }} [options]
|
|
306
|
+
* @returns {boolean}
|
|
307
|
+
*/
|
|
308
|
+
export function mediaAppliesToScreen(media, { unconditional = false } = {}) {
|
|
309
|
+
const value = (media || '').trim().toLowerCase();
|
|
310
|
+
if (!value) return true;
|
|
311
|
+
return value.split(',').some((part) => {
|
|
312
|
+
const query = part.trim();
|
|
313
|
+
if (!query) return false;
|
|
314
|
+
if (unconditional && query.includes('(')) return false;
|
|
315
|
+
if (query.startsWith('(')) return true; // a bare feature query: type "all"
|
|
316
|
+
const m = query.match(/^(?:(not|only)\s+)?([a-z-]+)/);
|
|
317
|
+
if (!m) return true;
|
|
318
|
+
const screenType = m[2] === 'all' || m[2] === 'screen';
|
|
319
|
+
return m[1] === 'not' ? !screenType : screenType;
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
|
|
258
323
|
/**
|
|
259
324
|
* Remove browser-invisible content from a cheerio document, in place.
|
|
260
325
|
*
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* redditHosts — reddit.com is never fetched.
|
|
3
|
+
*
|
|
4
|
+
* reddit.com refuses every non-browser client (403, and the stealth browsers
|
|
5
|
+
* too — the block is IP/TLS-reputation based), so a scrape or fetch_url
|
|
6
|
+
* against it always fails and leaves the caller guessing. Reddit is served by
|
|
7
|
+
* reddit_search, which reads the same posts and comments from the Arctic
|
|
8
|
+
* Shift community archive (PullPush second). A reddit.com target is therefore
|
|
9
|
+
* refused before any network work, with the reddit_search call that gets the
|
|
10
|
+
* same data spelled out — derived from the URL where the URL says enough.
|
|
11
|
+
*
|
|
12
|
+
* Runs inside the pre-fetch gate (robotsGate.js), so every fetching tool and
|
|
13
|
+
* both browser paths get it without knowing. Not overridable: respect_robots
|
|
14
|
+
* is about robots.txt, and fetching reddit.com fails whatever the caller sends.
|
|
15
|
+
*
|
|
16
|
+
* Mirrors the website's `src/lib/tools/reddit-hosts.ts` — same rule, same
|
|
17
|
+
* message — so a reddit.com URL is answered identically on both surfaces.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export class UseRedditSearchError extends Error {
|
|
21
|
+
constructor(url) {
|
|
22
|
+
const call = redditSearchCallFor(url);
|
|
23
|
+
const nextStep = Object.keys(call).length
|
|
24
|
+
? `reddit_search(${JSON.stringify(call)})`
|
|
25
|
+
: 'reddit_search with a query, subreddit or author — or mode "thread" with a post\'s link_id';
|
|
26
|
+
super(
|
|
27
|
+
`${hostOf(url)} is not fetched: reddit.com refuses every non-browser client, stealth browsers included, ` +
|
|
28
|
+
`so this call would fail. Reddit is served by reddit_search (5 credits), which reads the same posts ` +
|
|
29
|
+
`and comments from the Arctic Shift community archive. Next step: ${nextStep}`
|
|
30
|
+
);
|
|
31
|
+
this.name = 'UseRedditSearchError';
|
|
32
|
+
this.code = 'USE_REDDIT_SEARCH';
|
|
33
|
+
this.url = url;
|
|
34
|
+
this.redditSearchCall = call;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The hostname of a URL, lowercased, or null if it will not parse. */
|
|
39
|
+
function hostOf(url) {
|
|
40
|
+
try {
|
|
41
|
+
return new URL(url).hostname.toLowerCase().replace(/\.$/, '');
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* True for reddit.com and its subdomains (www, old, new, np, sh …) and for the
|
|
49
|
+
* bare redd.it short-link host. The media hosts (i.redd.it, v.redd.it,
|
|
50
|
+
* preview.redd.it) serve files to any client and are left alone.
|
|
51
|
+
* @param {string} url
|
|
52
|
+
*/
|
|
53
|
+
export function isRedditUrl(url) {
|
|
54
|
+
const host = hostOf(url);
|
|
55
|
+
if (!host) return false;
|
|
56
|
+
return host === 'reddit.com' || host.endsWith('.reddit.com') || host === 'redd.it';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The reddit_search call that answers a reddit.com URL, or {} when the URL
|
|
61
|
+
* names nothing reddit_search can be pointed at (the front page, a wiki, a
|
|
62
|
+
* settings page).
|
|
63
|
+
*
|
|
64
|
+
* /r/{sub}/comments/{id}/…, /comments/{id}, /gallery/{id}, redd.it/{id}
|
|
65
|
+
* → mode "thread", link_id
|
|
66
|
+
* /r/{sub}/search?q=… → query scoped to the subreddit
|
|
67
|
+
* /search?q=… → an unscoped query
|
|
68
|
+
* /r/{sub}[/new|/top|…] → the subreddit's posts
|
|
69
|
+
* /user/{name} or /u/{name}[/comments] → the author's posts (or comments)
|
|
70
|
+
* ?type=comment → mode "comments"
|
|
71
|
+
* @param {string} url
|
|
72
|
+
* @returns {{ mode?: string, query?: string, subreddit?: string, author?: string, link_id?: string }}
|
|
73
|
+
*/
|
|
74
|
+
export function redditSearchCallFor(url) {
|
|
75
|
+
let parsed;
|
|
76
|
+
try {
|
|
77
|
+
parsed = new URL(url);
|
|
78
|
+
} catch {
|
|
79
|
+
return {};
|
|
80
|
+
}
|
|
81
|
+
const segments = parsed.pathname
|
|
82
|
+
.split('/')
|
|
83
|
+
.filter(Boolean)
|
|
84
|
+
.map((segment) => {
|
|
85
|
+
try {
|
|
86
|
+
return decodeURIComponent(segment);
|
|
87
|
+
} catch {
|
|
88
|
+
return segment;
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
if (parsed.hostname.toLowerCase() === 'redd.it') {
|
|
93
|
+
return segments[0] ? { mode: 'thread', link_id: segments[0] } : {};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// A post's id follows "comments" or "gallery" wherever it sits in the path;
|
|
97
|
+
// a permalink to one comment still reads as its thread.
|
|
98
|
+
const idAt = segments.findIndex((segment) => segment === 'comments' || segment === 'gallery');
|
|
99
|
+
if (idAt !== -1 && segments[idAt + 1]) {
|
|
100
|
+
return { mode: 'thread', link_id: segments[idAt + 1] };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const call = {};
|
|
104
|
+
if (segments[0] === 'r' && segments[1]) call.subreddit = segments[1];
|
|
105
|
+
if ((segments[0] === 'user' || segments[0] === 'u') && segments[1]) {
|
|
106
|
+
call.author = segments[1];
|
|
107
|
+
if (segments[2] === 'comments') call.mode = 'comments';
|
|
108
|
+
}
|
|
109
|
+
const query = parsed.searchParams.get('q')?.trim();
|
|
110
|
+
if (query) call.query = query;
|
|
111
|
+
if (parsed.searchParams.get('type') === 'comment') call.mode = 'comments';
|
|
112
|
+
return call;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Throw UseRedditSearchError for a reddit.com target. Call before any network
|
|
117
|
+
* work — the point is that reddit.com never gets a request, not even for its
|
|
118
|
+
* robots.txt.
|
|
119
|
+
* @param {string} url
|
|
120
|
+
*/
|
|
121
|
+
export function assertNotRedditUrl(url) {
|
|
122
|
+
if (isRedditUrl(url)) throw new UseRedditSearchError(url);
|
|
123
|
+
}
|
package/src/utils/robotsGate.js
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
|
|
20
20
|
import { RobotsChecker } from './robotsChecker.js';
|
|
21
21
|
import { assertHostAllowed } from './hostBlocklist.js';
|
|
22
|
+
import { assertNotRedditUrl } from './redditHosts.js';
|
|
22
23
|
import { identityHeaders, resolveUserAgent } from './fetchIdentity.js';
|
|
23
24
|
import { throttleHost } from './hostRateLimiter.js';
|
|
24
25
|
import { recordComplianceEvent, apiKeyId } from './complianceAudit.js';
|
|
@@ -75,8 +76,13 @@ export async function robotsPreflight(url, options = {}) {
|
|
|
75
76
|
// costs the caller nothing: we refused, we fetched nothing.
|
|
76
77
|
try {
|
|
77
78
|
assertHostAllowed(url);
|
|
79
|
+
// reddit.com refuses every non-browser client, so it is never fetched and
|
|
80
|
+
// the caller is pointed at reddit_search instead. Also not overridable.
|
|
81
|
+
assertNotRedditUrl(url);
|
|
78
82
|
} catch (error) {
|
|
79
|
-
if (error?.code === 'HOST_BLOCKED'
|
|
83
|
+
if (error?.code === 'HOST_BLOCKED' || error?.code === 'USE_REDDIT_SEARCH') {
|
|
84
|
+
markPreflightRefusal(error.code);
|
|
85
|
+
}
|
|
80
86
|
throw error;
|
|
81
87
|
}
|
|
82
88
|
|
|
@@ -178,11 +184,29 @@ export async function preflightFetch(url, options = {}) {
|
|
|
178
184
|
return {
|
|
179
185
|
headers: outboundHeaders(decision.userAgent, signature),
|
|
180
186
|
userAgent: decision.userAgent,
|
|
181
|
-
warnings: decision.warnings,
|
|
187
|
+
warnings: [...decision.warnings, ...crawlDelayWarning(url, decision.crawlDelayMs)],
|
|
182
188
|
overridden: decision.overridden
|
|
183
189
|
};
|
|
184
190
|
}
|
|
185
191
|
|
|
192
|
+
/**
|
|
193
|
+
* Name a long Crawl-delay, so a slow multi-page call reads as compliance
|
|
194
|
+
* rather than a stall: eff.org asks every agent for 30 s, and a 10-page
|
|
195
|
+
* llms.txt run took 13 minutes with nothing in the response saying why (R21,
|
|
196
|
+
* 2026-09-09).
|
|
197
|
+
* @param {string} url
|
|
198
|
+
* @param {number} crawlDelayMs
|
|
199
|
+
* @returns {string[]}
|
|
200
|
+
*/
|
|
201
|
+
function crawlDelayWarning(url, crawlDelayMs) {
|
|
202
|
+
if (!(crawlDelayMs >= 5000)) return [];
|
|
203
|
+
let host = url;
|
|
204
|
+
try { host = new URL(url).host; } catch { /* keep the raw url */ }
|
|
205
|
+
return [
|
|
206
|
+
`robots.txt on ${host} asks for a ${Math.round(crawlDelayMs / 1000)} s crawl delay; requests to it are spaced by that much, so a multi-page call takes about that long per page.`
|
|
207
|
+
];
|
|
208
|
+
}
|
|
209
|
+
|
|
186
210
|
/**
|
|
187
211
|
* The gate for browser paths. Same decision as {@link preflightFetch}, minus
|
|
188
212
|
* the identity and signature headers — those belong on an HTTP fetch, not on a
|
|
@@ -213,7 +237,7 @@ export async function browserPreflight(url, options = {}) {
|
|
|
213
237
|
}
|
|
214
238
|
|
|
215
239
|
await throttleHost(url, { crawlDelayMs: decision.crawlDelayMs });
|
|
216
|
-
return decision.warnings;
|
|
240
|
+
return [...decision.warnings, ...crawlDelayWarning(url, decision.crawlDelayMs)];
|
|
217
241
|
}
|
|
218
242
|
|
|
219
243
|
/** Test/diagnostic hook: drop every cached robots.txt. */
|