crawlforge-mcp-server 5.2.8 → 5.3.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/CLAUDE.md +13 -1
- package/README.md +9 -9
- package/package.json +2 -2
- package/server.js +175 -26
- package/src/cli/commands/stealth.js +7 -1
- package/src/constants/config.js +2 -1
- package/src/core/ActionExecutor.js +168 -16
- package/src/core/AlertNotificationSystem.js +2 -1
- package/src/core/AuthManager.js +19 -1
- package/src/core/ChangeTracker.js +34 -6
- package/src/core/LLMsTxtAnalyzer.js +94 -12
- package/src/core/LocalizationManager.js +2 -1
- package/src/core/ResearchOrchestrator.js +407 -86
- package/src/core/StealthBrowserManager.js +186 -105
- package/src/core/WebhookDispatcher.js +3 -4
- package/src/core/analysis/ContentAnalyzer.js +41 -15
- package/src/core/analysis/sentenceUtils.js +16 -5
- package/src/core/crawlers/BFSCrawler.js +44 -21
- package/src/core/llm/LLMManager.js +517 -13
- package/src/core/processing/BrowserProcessor.js +27 -0
- package/src/core/processing/ContentProcessor.js +11 -39
- package/src/core/processing/PDFProcessor.js +2 -3
- package/src/core/research/claimFilters.js +235 -0
- package/src/schemas/toolOutputSchemas.js +5 -1
- package/src/security/wave3-security.js +2 -1
- package/src/server/requestContext.js +23 -0
- package/src/server/withAuth.js +21 -5
- package/src/skills/agent-skills/crawlforge-deep-research/SKILL.md +1 -1
- package/src/tools/advanced/ScrapeWithActionsTool.js +49 -1
- package/src/tools/advanced/batchScrape/schema.js +4 -0
- package/src/tools/advanced/batchScrape/worker.js +19 -10
- package/src/tools/basic/_fetch.js +19 -15
- package/src/tools/basic/extractLinks.js +8 -3
- package/src/tools/basic/extractMetadata.js +7 -3
- package/src/tools/basic/extractText.js +8 -3
- package/src/tools/basic/fetchUrl.js +7 -3
- package/src/tools/basic/scrapeStructured.js +76 -3
- package/src/tools/crawl/_sessionContext.js +10 -2
- package/src/tools/crawl/crawlDeep.js +29 -12
- package/src/tools/crawl/mapSite.js +39 -14
- package/src/tools/extract/_fetchAndParse.js +23 -8
- package/src/tools/extract/analyzeContent.js +5 -3
- package/src/tools/extract/extractContent.js +18 -4
- package/src/tools/extract/extractStructured.js +66 -12
- package/src/tools/extract/extractWithLlm.js +51 -4
- package/src/tools/extract/processDocument.js +45 -78
- package/src/tools/extract/summarizeContent.js +35 -1
- package/src/tools/llmstxt/generateLLMsTxt.js +19 -4
- package/src/tools/research/deepResearch.js +2 -1
- package/src/tools/scrape/_brandingExtractor.js +42 -3
- package/src/tools/scrape/_mainContent.js +105 -0
- package/src/tools/scrape/unifiedScrape.js +21 -14
- package/src/tools/search/adapters/redditOfficialApi.js +7 -6
- package/src/tools/search/redditSearch.js +6 -3
- package/src/tools/search/searchWeb.js +26 -3
- package/src/tools/templates/ScrapeTemplateTool.js +17 -6
- package/src/tools/tracking/trackChanges/differ.js +26 -3
- package/src/tools/tracking/trackChanges/index.js +12 -5
- package/src/tools/tracking/trackChanges/notifier.js +3 -1
- package/src/tools/tracking/trackChanges/schema.js +3 -0
- package/src/utils/complianceAudit.js +72 -0
- package/src/utils/contentUtils.js +12 -1
- package/src/utils/domainFilter.js +38 -19
- package/src/utils/fetchIdentity.js +62 -0
- package/src/utils/hostBlocklist.js +81 -0
- package/src/utils/hostRateLimiter.js +101 -2
- package/src/utils/robotsChecker.js +90 -43
- package/src/utils/robotsGate.js +206 -0
- package/src/utils/sitemapParser.js +33 -15
- package/src/utils/ssrfProtection.js +2 -1
- package/src/utils/webBotAuth.js +193 -0
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claim admission filters for deep_research synthesis.
|
|
3
|
+
*
|
|
4
|
+
* Claims are extractive sentences pulled out of source pages by the summarize
|
|
5
|
+
* tool, so whatever the page starts with is a candidate claim. On a live run
|
|
6
|
+
* (2026-08-28, "What anti-bot systems do major websites use in 2026") the top
|
|
7
|
+
* finding was an arXiv front-matter block beginning "DOI: XXXXXXX.XXXXXXX" —
|
|
8
|
+
* document furniture, not a research claim. These predicates reject that class
|
|
9
|
+
* of text before it can become a key finding.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
// Document furniture: DOI stubs, retrieval notes, ACM/arXiv front matter,
|
|
13
|
+
// copyright rows, contact emails. Matched anywhere in the candidate claim.
|
|
14
|
+
const FRONT_MATTER_PATTERNS = [
|
|
15
|
+
/\bdoi\s*:/i,
|
|
16
|
+
/\bdoi\.org\//i,
|
|
17
|
+
/\barxiv\s*:\s*\d/i,
|
|
18
|
+
/\bccs concepts?\b/i,
|
|
19
|
+
/\bacm reference format\b/i,
|
|
20
|
+
/\bretrieved (?:from|on)\b/i,
|
|
21
|
+
/\bissn\b|\bisbn\b/i,
|
|
22
|
+
/\ball rights reserved\b/i,
|
|
23
|
+
/\bcopyright\s*(?:©|\(c\)|\d{4})/i,
|
|
24
|
+
/[\w.+-]+@[\w-]+\.[a-z]{2,}/i,
|
|
25
|
+
// "Permission to make digital or hard copies…" — the ACM licence block.
|
|
26
|
+
/\bpermission to make digital\b/i,
|
|
27
|
+
// Bot-challenge interstitials. A research run on anti-bot systems scores
|
|
28
|
+
// these as highly relevant — they are literally about bot detection — so the
|
|
29
|
+
// relevance gate cannot catch them (live 2026-08-28: "Checking your browser.
|
|
30
|
+
// This only takes a moment." was the top finding). Phrases are specific to
|
|
31
|
+
// the challenge chrome; a claim that merely discusses CAPTCHAs is untouched.
|
|
32
|
+
/\bchecking your browser\b/i,
|
|
33
|
+
/\bjust a moment\b/i,
|
|
34
|
+
/\bi am not a robot\b/i,
|
|
35
|
+
/\bthis check is for\b/i,
|
|
36
|
+
/\bverify (?:you are|that you are) (?:a )?human\b/i,
|
|
37
|
+
/\bplease enable (?:javascript|cookies)\b/i,
|
|
38
|
+
/\bray id\b/i,
|
|
39
|
+
/\bddos protection by\b/i,
|
|
40
|
+
/\bperformance (?:&|and) security by\b/i
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
// Tokens that mark an affiliation line rather than a sentence about the topic.
|
|
44
|
+
const AFFILIATION_TOKENS = new Set([
|
|
45
|
+
'university', 'universities', 'institute', 'institut', 'department', 'dept',
|
|
46
|
+
'faculty', 'college', 'school', 'laboratory', 'laboratoire', 'academy',
|
|
47
|
+
'inc', 'llc', 'ltd', 'gmbh', 'corp', 'corporation', 'foundation'
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
// Closed-class finite verbs. A declarative claim contains one of these or an
|
|
51
|
+
// inflected lexical verb (see hasFiniteVerb).
|
|
52
|
+
const AUXILIARY_AND_MODAL_VERBS = new Set([
|
|
53
|
+
'is', 'are', 'was', 'were', 'am', 'be', 'been', 'being',
|
|
54
|
+
'has', 'have', 'had', 'do', 'does', 'did',
|
|
55
|
+
'can', 'could', 'will', 'would', 'shall', 'should', 'may', 'might', 'must'
|
|
56
|
+
]);
|
|
57
|
+
|
|
58
|
+
// Frequent base-form verbs. A plural subject takes an uninflected verb
|
|
59
|
+
// ("Akamai and Imperva rely on device fingerprinting"), which the -s/-ed test
|
|
60
|
+
// below cannot see.
|
|
61
|
+
const COMMON_BASE_VERBS = new Set([
|
|
62
|
+
'use', 'rely', 'block', 'detect', 'deploy', 'prevent', 'provide', 'offer',
|
|
63
|
+
'include', 'require', 'allow', 'make', 'run', 'work', 'help', 'need', 'show',
|
|
64
|
+
'report', 'find', 'remain', 'become', 'appear', 'support', 'handle',
|
|
65
|
+
'protect', 'track', 'monitor', 'serve', 'apply', 'target', 'return', 'know',
|
|
66
|
+
'take', 'give', 'keep', 'add', 'build', 'create', 'enable', 'identify',
|
|
67
|
+
'reduce', 'increase', 'mean', 'occur', 'exist', 'vary', 'differ', 'depend',
|
|
68
|
+
'contain', 'combine', 'measure', 'generate'
|
|
69
|
+
]);
|
|
70
|
+
|
|
71
|
+
// Lowercase words that end in -s/-es/-ed but are not verbs; without these the
|
|
72
|
+
// inflection test in hasFiniteVerb reads plural nouns as verbs.
|
|
73
|
+
const NOT_VERBS = new Set([
|
|
74
|
+
'as', 'its', 'this', 'thus', 'less', 'gas', 'plus', 'versus', 'yes',
|
|
75
|
+
'always', 'perhaps', 'various', 'previous', 'obvious', 'serious', 'numerous',
|
|
76
|
+
'analysis', 'access', 'process', 'business', 'address', 'success', 'progress'
|
|
77
|
+
]);
|
|
78
|
+
|
|
79
|
+
// Marketing register. Present in a vendor's own copy, rare in a factual claim.
|
|
80
|
+
const PROMOTIONAL_CUES = [
|
|
81
|
+
/\bbest\b/i, /\bleading\b/i, /\bindustry[- ]leading\b/i, /\bworld[- ]class\b/i,
|
|
82
|
+
/\bfastest\b/i, /\bmost (?:accurate|reliable|powerful|advanced)\b/i,
|
|
83
|
+
/\btrusted by\b/i, /\breliable\b/i, /\beffortless/i, /\bseamless/i,
|
|
84
|
+
/\bpowerful\b/i, /\bsolution\b/i, /\bunlimited\b/i, /\bguarantee/i,
|
|
85
|
+
/\bget started\b/i, /\bsign up\b/i, /\bfree trial\b/i, /\bno credit card\b/i,
|
|
86
|
+
/\bstart (?:scraping|crawling|building)\b/i, /\bcontact sales\b/i,
|
|
87
|
+
/\bpricing\b/i, /\bplans start\b/i, /\b99\.9\d*%\b/i,
|
|
88
|
+
/\bmost widely used\b/i, /\bmost popular\b/i
|
|
89
|
+
];
|
|
90
|
+
|
|
91
|
+
// A named commercial offering: a capitalized name bound to a product noun
|
|
92
|
+
// ("Zyte API", "Scrapy Cloud", "Managed Data service"), or a third-person
|
|
93
|
+
// possessive standing in for one ("their API"). This is the SUBJECT half of a
|
|
94
|
+
// recommendation. Nothing here names a company — the test is grammatical.
|
|
95
|
+
const NAMED_OFFERING_PATTERNS = [
|
|
96
|
+
/\b[A-Z][A-Za-z0-9.\-]+\s+(?:APIs?|SDKs?|[Pp]latform|[Pp]roduct|[Ss]ervice|[Ss]uite|[Tt]oolkit|[Cc]loud)\b/,
|
|
97
|
+
/\b(?:their|its|our)\s+(?:api|sdk|platform|product|service|suite|toolkit|cloud|tool)\b/i,
|
|
98
|
+
/\bflagship product\b/i
|
|
99
|
+
];
|
|
100
|
+
|
|
101
|
+
// The PREDICATE half: an offering credited with doing the work for you, or
|
|
102
|
+
// ranked above its peers. Deliberately excludes plain capability verbs
|
|
103
|
+
// ("detects", "blocks", "scores", "protects"), which is how factual sentences
|
|
104
|
+
// about anti-bot systems read — those must survive to answer the question.
|
|
105
|
+
const PITCH_PREDICATE_PATTERNS = [
|
|
106
|
+
/\bdesigned to\b/i, /\bbuilt to\b/i, /\bpurpose[- ]built\b/i,
|
|
107
|
+
/\boffers?\b/i, /\bprovides?\b/i, /\bdelivers?\b/i,
|
|
108
|
+
/\b(?:lets|helps|allows) you\b/i, /\bso you can\b/i,
|
|
109
|
+
/\bautomates?\b/i, /\bunblocks?\b/i, /\bhandles? (?:everything|it all|the (?:complexity|hard part))/i,
|
|
110
|
+
/\branked (?:#\s*1|number one|first)\b/i, /\ball[- ]in[- ]one\b/i,
|
|
111
|
+
/\bout of the box\b/i, /\bno code (?:required|needed)\b/i,
|
|
112
|
+
/\bstarts? at \$/i, /\bfree tier\b/i
|
|
113
|
+
];
|
|
114
|
+
|
|
115
|
+
/** Text that is document furniture rather than a claim about the topic. */
|
|
116
|
+
export function isFrontMatter(text) {
|
|
117
|
+
return FRONT_MATTER_PATTERNS.some(pattern => pattern.test(text));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Does the text contain a finite verb?
|
|
122
|
+
*
|
|
123
|
+
* Deliberately permissive: a closed list of auxiliaries, modals and frequent
|
|
124
|
+
* base-form verbs, plus any lowercase token inflected as -s/-es/-ed. It
|
|
125
|
+
* rejects verbless fragments
|
|
126
|
+
* (author rows, keyword lists, DOI stubs), not bad grammar. Capitalized tokens
|
|
127
|
+
* are never counted, so "Jane Doe, University of Somewhere" has no verb while
|
|
128
|
+
* "Cloudflare uses TLS fingerprinting" does.
|
|
129
|
+
*/
|
|
130
|
+
export function hasFiniteVerb(text) {
|
|
131
|
+
const tokens = text.split(/[^A-Za-z'-]+/).filter(Boolean);
|
|
132
|
+
|
|
133
|
+
return tokens.some(token => {
|
|
134
|
+
const lower = token.toLowerCase();
|
|
135
|
+
if (AUXILIARY_AND_MODAL_VERBS.has(lower)) return true;
|
|
136
|
+
// Lexical verbs only in their lowercase form — a capitalized token here is
|
|
137
|
+
// a proper noun ("Systems Inc"), not a verb.
|
|
138
|
+
if (token !== lower || NOT_VERBS.has(lower)) return false;
|
|
139
|
+
return COMMON_BASE_VERBS.has(lower) || /^[a-z]{3,}(?:s|es|ed)$/.test(lower);
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Share of tokens that look like proper nouns or affiliation words, ignoring
|
|
145
|
+
* the first token (a normal sentence starts capitalized).
|
|
146
|
+
*/
|
|
147
|
+
export function properNounDensity(text) {
|
|
148
|
+
const tokens = text.split(/[^A-Za-z'-]+/).filter(Boolean).slice(1);
|
|
149
|
+
if (tokens.length === 0) return 0;
|
|
150
|
+
|
|
151
|
+
const marked = tokens.filter(
|
|
152
|
+
token => /^[A-Z]/.test(token) || AFFILIATION_TOKENS.has(token.toLowerCase())
|
|
153
|
+
).length;
|
|
154
|
+
|
|
155
|
+
return marked / tokens.length;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Above this share of proper-noun/affiliation tokens the text reads as an
|
|
159
|
+
// author or affiliation block rather than a sentence about the topic.
|
|
160
|
+
const PROPER_NOUN_DENSITY_MAX = 0.5;
|
|
161
|
+
|
|
162
|
+
/** A candidate claim is admissible when it is a verb-bearing, non-front-matter sentence. */
|
|
163
|
+
export function isAdmissibleClaim(text) {
|
|
164
|
+
if (typeof text !== 'string' || text.trim().length === 0) return false;
|
|
165
|
+
if (isFrontMatter(text)) return false;
|
|
166
|
+
if (!hasFiniteVerb(text)) return false;
|
|
167
|
+
return properNounDensity(text) <= PROPER_NOUN_DENSITY_MAX;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Brand token of a URL's host: "https://scrape.do/blog" -> "scrape". */
|
|
171
|
+
export function brandFromUrl(url) {
|
|
172
|
+
try {
|
|
173
|
+
const host = new URL(url).hostname.toLowerCase().replace(/^www\./, '');
|
|
174
|
+
const labels = host.split('.');
|
|
175
|
+
// Drop the public suffix; for "scrape.do" that leaves "scrape", for
|
|
176
|
+
// "api.datadome.co.uk" it leaves "datadome".
|
|
177
|
+
const brand = labels.length > 2 && labels[labels.length - 2].length <= 3
|
|
178
|
+
? labels[labels.length - 3]
|
|
179
|
+
: labels[labels.length - 2];
|
|
180
|
+
return brand || null;
|
|
181
|
+
} catch {
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Is this claim a vendor describing its own product in marketing register?
|
|
188
|
+
*
|
|
189
|
+
* Signal: the text refers to the site it came from (its brand token, or a
|
|
190
|
+
* first-person "we/our") AND uses at least one marketing cue. That is enough
|
|
191
|
+
* to stop "Scrape.do offers reliable scraping with effective anti-bot bypass"
|
|
192
|
+
* being laundered into a research conclusion.
|
|
193
|
+
*
|
|
194
|
+
* On its own it does not catch one vendor pitching another vendor's product —
|
|
195
|
+
* see looksLikeVendorPage + isProductPitch, which cover that case.
|
|
196
|
+
*/
|
|
197
|
+
export function isVendorSelfPromotion(text, sourceUrl) {
|
|
198
|
+
if (typeof text !== 'string' || text.length === 0) return false;
|
|
199
|
+
|
|
200
|
+
const brand = brandFromUrl(sourceUrl);
|
|
201
|
+
const selfReference =
|
|
202
|
+
(brand && brand.length >= 3 && new RegExp(`\\b${brand}\\b`, 'i').test(text)) ||
|
|
203
|
+
/\b(?:we|our|us)\b/i.test(text);
|
|
204
|
+
|
|
205
|
+
return Boolean(selfReference) && PROMOTIONAL_CUES.some(cue => cue.test(text));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Is this claim a recommendation rather than evidence?
|
|
210
|
+
*
|
|
211
|
+
* A recommendation has a shape: its subject is a named commercial offering and
|
|
212
|
+
* its predicate credits that offering with doing the work or ranking above its
|
|
213
|
+
* peers. "Their flagship product, Zyte API, is a web scraping API designed to
|
|
214
|
+
* unblock, render, and extract data from any website" has both halves; "CF-RAY
|
|
215
|
+
* in the response header means Cloudflare" and "Modern anti-bot systems
|
|
216
|
+
* fingerprint your TLS handshake" have neither, and both must survive — they
|
|
217
|
+
* are the answer to an anti-bot question.
|
|
218
|
+
*
|
|
219
|
+
* The test is grammatical and domain-independent: no company or product name
|
|
220
|
+
* appears anywhere in this module, and a page describing its own product and a
|
|
221
|
+
* page describing a peer's are treated identically.
|
|
222
|
+
*
|
|
223
|
+
* It does NOT catch a recommendation written without a product noun ("Scrapy is
|
|
224
|
+
* what most teams reach for"), and it will flag a factual sentence that
|
|
225
|
+
* describes a named product as doing work for you — including an anti-bot
|
|
226
|
+
* vendor's own product page. Both halves must match, which is what keeps
|
|
227
|
+
* ordinary claims about detection systems out of it.
|
|
228
|
+
*/
|
|
229
|
+
export function isProductRecommendation(text) {
|
|
230
|
+
if (typeof text !== 'string' || text.length === 0) return false;
|
|
231
|
+
|
|
232
|
+
return NAMED_OFFERING_PATTERNS.some(pattern => pattern.test(text)) &&
|
|
233
|
+
PITCH_PREDICATE_PATTERNS.some(pattern => pattern.test(text));
|
|
234
|
+
}
|
|
235
|
+
|
|
@@ -246,6 +246,7 @@ const searchWebShape = {
|
|
|
246
246
|
// ── extract_structured ───────────────────────────────────────────────────────
|
|
247
247
|
|
|
248
248
|
const extractStructuredShape = {
|
|
249
|
+
success: z.boolean().optional().describe('False when the extraction errored or a required field came back missing or empty'),
|
|
249
250
|
url: z.string().optional(),
|
|
250
251
|
data: z.record(z.unknown()).optional().describe('Extracted fields matching the requested schema'),
|
|
251
252
|
extraction_method: z.string().optional().describe('"llm" | "css_fallback" | "keyword_fallback" | "none"'),
|
|
@@ -290,7 +291,10 @@ const crawlDeepShape = {
|
|
|
290
291
|
stats: z.unknown().optional(),
|
|
291
292
|
site_structure: z.object({
|
|
292
293
|
total_pages: z.number().optional(),
|
|
293
|
-
depth_distribution: z.record(z.number()).optional()
|
|
294
|
+
depth_distribution: z.record(z.number()).optional()
|
|
295
|
+
.describe('Pages per crawl depth (links from the start URL)'),
|
|
296
|
+
path_depth_distribution: z.record(z.number()).optional()
|
|
297
|
+
.describe('Pages per URL path-segment depth'),
|
|
294
298
|
path_patterns: z.record(z.number()).optional(),
|
|
295
299
|
file_types: z.record(z.number()).optional(),
|
|
296
300
|
subdomains: z.array(z.string()).optional()
|
|
@@ -9,6 +9,7 @@ import { createHash, randomBytes, timingSafeEqual } from 'crypto';
|
|
|
9
9
|
import path from 'path';
|
|
10
10
|
import { URL } from 'url';
|
|
11
11
|
import DOMPurify from 'isomorphic-dompurify';
|
|
12
|
+
import { identityHeaders } from '../utils/fetchIdentity.js';
|
|
12
13
|
|
|
13
14
|
// Security configuration
|
|
14
15
|
const SECURITY_CONFIG = {
|
|
@@ -125,7 +126,7 @@ export class SSRFProtection {
|
|
|
125
126
|
const fetchOptions = {
|
|
126
127
|
timeout: options.timeout || 30000,
|
|
127
128
|
headers: {
|
|
128
|
-
'
|
|
129
|
+
...identityHeaders({ role: 'health-check' }),
|
|
129
130
|
...options.headers
|
|
130
131
|
},
|
|
131
132
|
...options
|
|
@@ -24,3 +24,26 @@ export const requestContext = new AsyncLocalStorage();
|
|
|
24
24
|
export function isInternalRequest() {
|
|
25
25
|
return requestContext.getStore()?.internal === true;
|
|
26
26
|
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Record that the compliance gate refused this invocation before anything was
|
|
30
|
+
* fetched — robots.txt disallowed the path, or the host is on the permanent
|
|
31
|
+
* blocklist.
|
|
32
|
+
*
|
|
33
|
+
* withAuth reads this when it decides the charge. The flag rather than the
|
|
34
|
+
* error's `code` is deliberate: tool handlers catch their own errors and
|
|
35
|
+
* return `{ isError: true }` with only a message, so the typed error never
|
|
36
|
+
* reaches withAuth. It also survives both routes a refusal can take — thrown,
|
|
37
|
+
* or swallowed into an isError result.
|
|
38
|
+
*
|
|
39
|
+
* @param {string} code 'ROBOTS_DISALLOWED' | 'HOST_BLOCKED'
|
|
40
|
+
*/
|
|
41
|
+
export function markPreflightRefusal(code) {
|
|
42
|
+
const store = requestContext.getStore();
|
|
43
|
+
if (store) store.preflightRefusal = code;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The refusal code recorded for this invocation, or null. */
|
|
47
|
+
export function preflightRefusal() {
|
|
48
|
+
return requestContext.getStore()?.preflightRefusal ?? null;
|
|
49
|
+
}
|
package/src/server/withAuth.js
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
import { createHash } from 'node:crypto';
|
|
17
17
|
import { recordToolInvocation } from '../observability/tracing.js';
|
|
18
|
-
import { isInternalRequest } from './requestContext.js';
|
|
18
|
+
import { isInternalRequest, preflightRefusal, requestContext } from './requestContext.js';
|
|
19
19
|
|
|
20
20
|
export function hashParams(params) {
|
|
21
21
|
try {
|
|
@@ -33,7 +33,7 @@ export function hashParams(params) {
|
|
|
33
33
|
*/
|
|
34
34
|
export function makeWithAuth({ authManager, logger, metrics = null }) {
|
|
35
35
|
return function withAuth(toolName, handler) {
|
|
36
|
-
|
|
36
|
+
const invoke = async (params) => {
|
|
37
37
|
const startTime = Date.now();
|
|
38
38
|
const paramHash = hashParams(params);
|
|
39
39
|
const creatorMode = authManager.isCreatorMode();
|
|
@@ -82,7 +82,14 @@ export function makeWithAuth({ authManager, logger, metrics = null }) {
|
|
|
82
82
|
// it as an error, honoring CLAUDE.md's "half credits on error" contract.
|
|
83
83
|
const isErrorResult = result?.isError === true;
|
|
84
84
|
outcome = isErrorResult ? 'error' : 'success';
|
|
85
|
-
|
|
85
|
+
// A pre-flight refusal (robots.txt disallowed the path, or the host is
|
|
86
|
+
// blocklisted) means we fetched nothing at all, so it costs nothing —
|
|
87
|
+
// not even the half-credit error rate, which exists for work that ran
|
|
88
|
+
// and then failed. Only when the refusal actually sank the call: a
|
|
89
|
+
// multi-URL tool that skipped one disallowed URL and still returned a
|
|
90
|
+
// result did real work and bills for it.
|
|
91
|
+
const refused = isErrorResult && preflightRefusal() !== null;
|
|
92
|
+
const charge = creditCost === 0 || refused
|
|
86
93
|
? 0
|
|
87
94
|
: (isErrorResult ? Math.max(1, Math.floor(creditCost * 0.5)) : creditCost);
|
|
88
95
|
|
|
@@ -121,7 +128,7 @@ export function makeWithAuth({ authManager, logger, metrics = null }) {
|
|
|
121
128
|
// DataForSEO is unconfigured — a no-op). Emit NO usage event at all so
|
|
122
129
|
// the backend has nothing to (re-)price; reporting 0 would still create
|
|
123
130
|
// a serp_rank record the backend could recompute to full cost.
|
|
124
|
-
if (!creatorMode && creditCost > 0) {
|
|
131
|
+
if (!creatorMode && creditCost > 0 && !refused) {
|
|
125
132
|
await authManager.reportUsage(toolName, charge, params, isErrorResult ? 500 : 200, Date.now() - startTime);
|
|
126
133
|
}
|
|
127
134
|
|
|
@@ -132,7 +139,7 @@ export function makeWithAuth({ authManager, logger, metrics = null }) {
|
|
|
132
139
|
// Half-charge on error — but never charge a free (0-cost) call, never
|
|
133
140
|
// let Math.max(1, …) floor a 0 up to 1 credit, and never bill at all
|
|
134
141
|
// if the handler never ran (e.g. the credit check itself threw).
|
|
135
|
-
if (!creatorMode && creditCost > 0 && handlerStarted) {
|
|
142
|
+
if (!creatorMode && creditCost > 0 && handlerStarted && preflightRefusal() === null) {
|
|
136
143
|
await authManager.reportUsage(
|
|
137
144
|
toolName,
|
|
138
145
|
Math.max(1, Math.floor(creditCost * 0.5)),
|
|
@@ -183,5 +190,14 @@ export function makeWithAuth({ authManager, logger, metrics = null }) {
|
|
|
183
190
|
}, thrown);
|
|
184
191
|
}
|
|
185
192
|
};
|
|
193
|
+
|
|
194
|
+
// Every invocation runs in its own context so the compliance gate can stamp
|
|
195
|
+
// a refusal where the billing decision can see it. Any outer store (the
|
|
196
|
+
// HTTP transport's `internal` flag) is spread in, not replaced — and stdio
|
|
197
|
+
// callers, who have no transport-provided store, get one here.
|
|
198
|
+
return async (params) => requestContext.run(
|
|
199
|
+
{ ...(requestContext.getStore() ?? {}), preflightRefusal: null },
|
|
200
|
+
() => invoke(params)
|
|
201
|
+
);
|
|
186
202
|
};
|
|
187
203
|
}
|
|
@@ -30,7 +30,7 @@ skill (`scrape` / `extract_content`) instead.
|
|
|
30
30
|
| Need | Tool | Cost |
|
|
31
31
|
|------|------|------|
|
|
32
32
|
| A ranked list of result URLs + snippets | `search_web` | 5 |
|
|
33
|
-
| Reddit posts, comments, or a full thread | `reddit_search` |
|
|
33
|
+
| Reddit posts, comments, or a full thread | `reddit_search` | 5 |
|
|
34
34
|
| A direct answer, agent decides what to read | `agent` | 8 (scales) |
|
|
35
35
|
| A synthesized, multi-source, cited report | `deep_research` | 10+ (scales) |
|
|
36
36
|
|
|
@@ -83,6 +83,33 @@ const ScrollActionSchema = BaseActionSchema.extend({
|
|
|
83
83
|
y: z.number().min(0).optional()
|
|
84
84
|
});
|
|
85
85
|
|
|
86
|
+
const SelectActionSchema = BaseActionSchema.extend({
|
|
87
|
+
type: z.literal('select'),
|
|
88
|
+
selector: z.string(),
|
|
89
|
+
// A plain string matches an <option> by value OR label — see ActionExecutor's
|
|
90
|
+
// SelectActionSchema.
|
|
91
|
+
value: z.string().optional(),
|
|
92
|
+
values: z.array(z.string()).optional()
|
|
93
|
+
}).refine(data => data.value !== undefined || (data.values && data.values.length > 0), {
|
|
94
|
+
message: 'Select action requires value or values'
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const HoverActionSchema = BaseActionSchema.extend({
|
|
98
|
+
type: z.literal('hover'),
|
|
99
|
+
selector: z.string(),
|
|
100
|
+
force: z.boolean().default(false),
|
|
101
|
+
position: z.object({
|
|
102
|
+
x: z.number(),
|
|
103
|
+
y: z.number()
|
|
104
|
+
}).optional()
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const NavigateActionSchema = BaseActionSchema.extend({
|
|
108
|
+
type: z.literal('navigate'),
|
|
109
|
+
url: z.string().url(),
|
|
110
|
+
waitUntil: z.enum(['load', 'domcontentloaded', 'networkidle', 'commit']).optional()
|
|
111
|
+
});
|
|
112
|
+
|
|
86
113
|
const ScreenshotActionSchema = BaseActionSchema.extend({
|
|
87
114
|
type: z.literal('screenshot'),
|
|
88
115
|
selector: z.string().optional(),
|
|
@@ -104,6 +131,9 @@ const ActionSchema = z.union([
|
|
|
104
131
|
TypeActionSchema,
|
|
105
132
|
PressActionSchema,
|
|
106
133
|
ScrollActionSchema,
|
|
134
|
+
SelectActionSchema,
|
|
135
|
+
HoverActionSchema,
|
|
136
|
+
NavigateActionSchema,
|
|
107
137
|
ScreenshotActionSchema,
|
|
108
138
|
ExecuteJavaScriptActionSchema
|
|
109
139
|
]);
|
|
@@ -145,7 +175,11 @@ const ScrapeWithActionsSchema = z.object({
|
|
|
145
175
|
userAgent: z.string().optional(),
|
|
146
176
|
viewportWidth: z.number().min(800).max(1920).default(1280),
|
|
147
177
|
viewportHeight: z.number().min(600).max(1080).default(720),
|
|
148
|
-
timeout: z.number().min(10000).max(120000).default(30000)
|
|
178
|
+
timeout: z.number().min(10000).max(120000).default(30000),
|
|
179
|
+
// Run the chain in the stealth browser (StealthBrowserManager) instead of
|
|
180
|
+
// the standard pool. executeSession turns this into the stealthMode object
|
|
181
|
+
// ActionExecutor/BrowserProcessor read.
|
|
182
|
+
stealth: z.boolean().default(false)
|
|
149
183
|
}).optional(),
|
|
150
184
|
|
|
151
185
|
// Content extraction options
|
|
@@ -156,6 +190,10 @@ const ScrapeWithActionsSchema = z.object({
|
|
|
156
190
|
includeImages: z.boolean().default(true)
|
|
157
191
|
}).optional(),
|
|
158
192
|
|
|
193
|
+
// Per-request robots.txt override (G5). Undefined leaves the configured
|
|
194
|
+
// default in force; false is honoured, warned about and audited by the gate.
|
|
195
|
+
respect_robots: z.boolean().optional(),
|
|
196
|
+
|
|
159
197
|
// Error handling
|
|
160
198
|
continueOnActionError: z.boolean().default(false),
|
|
161
199
|
maxRetries: z.number().min(0).max(3).default(1),
|
|
@@ -341,6 +379,16 @@ export class ScrapeWithActionsTool extends EventEmitter {
|
|
|
341
379
|
...params.browserOptions
|
|
342
380
|
};
|
|
343
381
|
|
|
382
|
+
// `stealth: true` is the caller-facing switch; everything downstream reads
|
|
383
|
+
// browserOptions.stealthMode.
|
|
384
|
+
if (browserOptions.stealth) {
|
|
385
|
+
browserOptions.stealthMode = { enabled: true };
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// The robots override rides with the browser options so ActionExecutor's
|
|
389
|
+
// gate sees it on the initial load and on every `navigate` action.
|
|
390
|
+
browserOptions.respectRobots = params.respect_robots;
|
|
391
|
+
|
|
344
392
|
// Build action chain with form auto-fill if provided
|
|
345
393
|
let actionChain = [...params.actions];
|
|
346
394
|
|
|
@@ -23,6 +23,10 @@ export const BatchScrapeSchema = z.object({
|
|
|
23
23
|
signingSecret: z.string().optional()
|
|
24
24
|
}).optional(),
|
|
25
25
|
extractionSchema: z.record(z.string()).optional(),
|
|
26
|
+
// Compliance overrides, per request: identify as yourself for a target you
|
|
27
|
+
// have your own agreement with, and take responsibility for ignoring robots.
|
|
28
|
+
user_agent: z.string().optional(),
|
|
29
|
+
respect_robots: z.boolean().optional(),
|
|
26
30
|
maxConcurrency: z.number().min(1).max(20).default(10),
|
|
27
31
|
delayBetweenRequests: z.number().min(0).max(10000).default(100),
|
|
28
32
|
includeMetadata: z.boolean().default(true),
|
|
@@ -7,33 +7,37 @@
|
|
|
7
7
|
import { load } from 'cheerio';
|
|
8
8
|
import { config as appConfig } from '../../../constants/config.js';
|
|
9
9
|
import { ssrfGuard, isSsrfError } from '../../../utils/ssrfGuard.js';
|
|
10
|
-
import {
|
|
10
|
+
import { noteRetryAfter } from '../../../utils/hostRateLimiter.js';
|
|
11
|
+
import { preflightFetch } from '../../../utils/robotsGate.js';
|
|
11
12
|
import { htmlToMarkdown } from '../../../utils/htmlToMarkdown.js';
|
|
12
13
|
|
|
13
|
-
const USER_AGENT = 'MCP-WebScraper-BatchTool/1.0.0';
|
|
14
|
-
|
|
15
14
|
/**
|
|
16
15
|
* Fetch a URL with AbortController timeout (SSRF-guarded + per-host throttled).
|
|
17
16
|
* The timeout stays live through the body read (not just until headers
|
|
18
17
|
* arrive), and the body is size-capped, so a server that sends headers then
|
|
19
18
|
* drips the body can't hold a semaphore slot indefinitely or exhaust memory.
|
|
20
|
-
* Returns { response, html } rather than the raw Response.
|
|
19
|
+
* Returns { response, html, warnings } rather than the raw Response.
|
|
21
20
|
*/
|
|
22
21
|
export async function fetchUrl(url, options = {}) {
|
|
23
|
-
const { timeout = 15000, headers = {} } = options;
|
|
22
|
+
const { timeout = 15000, headers = {}, userAgent, respectRobots, apiKey } = options;
|
|
24
23
|
const maxBodySize = appConfig.fetch.maxBodySize;
|
|
25
24
|
const guard = ssrfGuard(url); // SSRF pre-flight (throws before connecting)
|
|
26
|
-
|
|
25
|
+
// robots.txt / blocklist gate + per-host throttle. Throws if it refuses,
|
|
26
|
+
// which scrapeUrl turns into a failure for this URL alone.
|
|
27
|
+
const gate = await preflightFetch(url, { userAgent, respectRobots, apiKey, tool: 'batch_scrape' });
|
|
27
28
|
const controller = new AbortController();
|
|
28
29
|
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
29
30
|
try {
|
|
30
31
|
const response = await fetch(url, {
|
|
31
32
|
signal: controller.signal,
|
|
32
|
-
headers: {
|
|
33
|
+
headers: { ...gate.headers, ...headers },
|
|
33
34
|
...guard
|
|
34
35
|
});
|
|
36
|
+
if (response.status === 429 || response.status === 503) {
|
|
37
|
+
noteRetryAfter(url, response.headers?.get?.('retry-after'));
|
|
38
|
+
}
|
|
35
39
|
const html = await readBodyCapped(response, maxBodySize);
|
|
36
|
-
return { response, html };
|
|
40
|
+
return { response, html, warnings: gate.warnings };
|
|
37
41
|
} catch (error) {
|
|
38
42
|
if (isSsrfError(error)) throw new Error(error.cause?.message || error.message);
|
|
39
43
|
if (error.name === 'AbortError') throw new Error(`Request timeout after ${timeout}ms`);
|
|
@@ -93,9 +97,12 @@ async function readBodyCapped(response, maxBodySize) {
|
|
|
93
97
|
export async function scrapeUrl(config, options, defaultTimeout) {
|
|
94
98
|
const startTime = Date.now();
|
|
95
99
|
try {
|
|
96
|
-
const { response, html } = await fetchUrl(config.url, {
|
|
100
|
+
const { response, html, warnings } = await fetchUrl(config.url, {
|
|
97
101
|
headers: config.headers,
|
|
98
|
-
timeout: config.timeout || defaultTimeout
|
|
102
|
+
timeout: config.timeout || defaultTimeout,
|
|
103
|
+
userAgent: options.user_agent,
|
|
104
|
+
respectRobots: options.respect_robots,
|
|
105
|
+
apiKey: options.apiKey
|
|
99
106
|
});
|
|
100
107
|
|
|
101
108
|
if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
@@ -115,6 +122,8 @@ export async function scrapeUrl(config, options, defaultTimeout) {
|
|
|
115
122
|
}
|
|
116
123
|
};
|
|
117
124
|
|
|
125
|
+
if (warnings.length > 0) result.warnings = warnings;
|
|
126
|
+
|
|
118
127
|
if (options.extractionSchema || config.selectors) {
|
|
119
128
|
result.extracted = extractStructuredData($, { ...config.selectors, ...options.extractionSchema });
|
|
120
129
|
}
|
|
@@ -1,18 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shared HTTP fetch helper for basic tools.
|
|
3
|
-
* Applies an AbortController timeout and
|
|
3
|
+
* Applies an AbortController timeout and the shared pre-fetch gate.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { readBody } from 'crawlforge-extractors';
|
|
7
7
|
import { config } from '../../constants/config.js';
|
|
8
|
-
import { createRequire } from 'module';
|
|
9
8
|
import { ssrfGuard, isSsrfError } from '../../utils/ssrfGuard.js';
|
|
10
|
-
import {
|
|
11
|
-
|
|
12
|
-
// Derive User-Agent from package version so it reflects the actual release.
|
|
13
|
-
const _require = createRequire(import.meta.url);
|
|
14
|
-
const _pkg = _require('../../../package.json');
|
|
15
|
-
const CRAWLFORGE_UA = `CrawlForge/${_pkg.version} (+https://crawlforge.dev)`;
|
|
9
|
+
import { noteRetryAfter } from '../../utils/hostRateLimiter.js';
|
|
10
|
+
import { preflightFetch } from '../../utils/robotsGate.js';
|
|
16
11
|
|
|
17
12
|
/**
|
|
18
13
|
* Fetch a URL with a configurable timeout and body-size cap.
|
|
@@ -23,19 +18,21 @@ const CRAWLFORGE_UA = `CrawlForge/${_pkg.version} (+https://crawlforge.dev)`;
|
|
|
23
18
|
* default 25 MB).
|
|
24
19
|
*
|
|
25
20
|
* @param {string} url
|
|
26
|
-
* @param {{ timeout?: number, headers?: Record<string,string
|
|
27
|
-
*
|
|
21
|
+
* @param {{ timeout?: number, headers?: Record<string,string>, userAgent?: string,
|
|
22
|
+
* respectRobots?: boolean, tool?: string, apiKey?: string }} [options]
|
|
23
|
+
* @returns {Promise<Response & { _body: string, _warnings: string[] }>}
|
|
28
24
|
*/
|
|
29
25
|
export async function fetchWithTimeout(url, options = {}) {
|
|
30
|
-
const { timeout = 10000, headers = {} } = options;
|
|
26
|
+
const { timeout = 10000, headers = {}, userAgent, respectRobots, tool, apiKey } = options;
|
|
31
27
|
const maxBodySize = config.fetch.maxBodySize;
|
|
32
28
|
|
|
33
29
|
// SSRF pre-flight (protocol / metadata host). Throws a clear error before any
|
|
34
30
|
// connection is attempted; `guard.dispatcher` enforces IP rules at connect time.
|
|
35
31
|
const guard = ssrfGuard(url);
|
|
36
32
|
|
|
37
|
-
//
|
|
38
|
-
|
|
33
|
+
// robots.txt / blocklist gate + per-host politeness throttle, before the
|
|
34
|
+
// timeout window starts. Throws if the gate refuses the URL.
|
|
35
|
+
const gate = await preflightFetch(url, { userAgent, respectRobots, tool, apiKey });
|
|
39
36
|
|
|
40
37
|
const controller = new AbortController();
|
|
41
38
|
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
@@ -57,7 +54,7 @@ export async function fetchWithTimeout(url, options = {}) {
|
|
|
57
54
|
response = await fetch(url, {
|
|
58
55
|
signal: controller.signal,
|
|
59
56
|
headers: {
|
|
60
|
-
|
|
57
|
+
...gate.headers,
|
|
61
58
|
...headers
|
|
62
59
|
},
|
|
63
60
|
...guard
|
|
@@ -72,6 +69,12 @@ export async function fetchWithTimeout(url, options = {}) {
|
|
|
72
69
|
throw error;
|
|
73
70
|
}
|
|
74
71
|
|
|
72
|
+
// A host asking us to back off is honoured on the *next* request to it,
|
|
73
|
+
// rather than retrying straight into the wall.
|
|
74
|
+
if (response.status === 429 || response.status === 503) {
|
|
75
|
+
noteRetryAfter(url, response.headers?.get?.('retry-after'));
|
|
76
|
+
}
|
|
77
|
+
|
|
75
78
|
// Reading is delegated to crawlforge-extractors so the REST API applies
|
|
76
79
|
// the same cap and the same charset handling to the same page.
|
|
77
80
|
let bodyText;
|
|
@@ -89,7 +92,8 @@ export async function fetchWithTimeout(url, options = {}) {
|
|
|
89
92
|
text: () => Promise.resolve(bodyText),
|
|
90
93
|
json: () => Promise.resolve(JSON.parse(bodyText)),
|
|
91
94
|
_body: bodyText,
|
|
92
|
-
_responseTime: Date.now() - startedAt
|
|
95
|
+
_responseTime: Date.now() - startedAt,
|
|
96
|
+
_warnings: gate.warnings
|
|
93
97
|
});
|
|
94
98
|
} finally {
|
|
95
99
|
clearTimeout(timeoutId);
|
|
@@ -7,11 +7,16 @@ import { load } from 'cheerio';
|
|
|
7
7
|
import { fetchWithTimeout } from './_fetch.js';
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
|
-
* @param {{ url: string, filter_external?: boolean, base_url?: string
|
|
10
|
+
* @param {{ url: string, filter_external?: boolean, base_url?: string,
|
|
11
|
+
* user_agent?: string, respect_robots?: boolean }} params
|
|
11
12
|
*/
|
|
12
|
-
export async function extractLinksHandler({ url, filter_external, base_url }) {
|
|
13
|
+
export async function extractLinksHandler({ url, filter_external, base_url, user_agent, respect_robots }) {
|
|
13
14
|
try {
|
|
14
|
-
const response = await fetchWithTimeout(url
|
|
15
|
+
const response = await fetchWithTimeout(url, {
|
|
16
|
+
userAgent: user_agent,
|
|
17
|
+
respectRobots: respect_robots,
|
|
18
|
+
tool: 'extract_links'
|
|
19
|
+
});
|
|
15
20
|
if (!response.ok) {
|
|
16
21
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
17
22
|
}
|
|
@@ -60,11 +60,15 @@ function parseMicrodata($) {
|
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
/**
|
|
63
|
-
* @param {{ url: string }} params
|
|
63
|
+
* @param {{ url: string, user_agent?: string, respect_robots?: boolean }} params
|
|
64
64
|
*/
|
|
65
|
-
export async function extractMetadataHandler({ url }) {
|
|
65
|
+
export async function extractMetadataHandler({ url, user_agent, respect_robots }) {
|
|
66
66
|
try {
|
|
67
|
-
const response = await fetchWithTimeout(url
|
|
67
|
+
const response = await fetchWithTimeout(url, {
|
|
68
|
+
userAgent: user_agent,
|
|
69
|
+
respectRobots: respect_robots,
|
|
70
|
+
tool: 'extract_metadata'
|
|
71
|
+
});
|
|
68
72
|
if (!response.ok) {
|
|
69
73
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
70
74
|
}
|