crawlforge-mcp-server 5.2.9 → 5.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +13 -1
- package/README.md +11 -9
- package/package.json +3 -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 +401 -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 +496 -0
- package/src/core/llm/OllamaProvider.js +14 -5
- 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 +6 -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/ollamaConfig.js +36 -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
|
@@ -14,28 +14,37 @@ export class OllamaProvider extends LLMProvider {
|
|
|
14
14
|
super(options);
|
|
15
15
|
|
|
16
16
|
// Resolved lazily: choosing the best installed model needs an HTTP call.
|
|
17
|
+
// An explicit model applies to every role; otherwise each role resolves
|
|
18
|
+
// (and caches) its own choice.
|
|
17
19
|
this.model = options.model || null;
|
|
20
|
+
this.modelByRole = new Map();
|
|
18
21
|
this.embeddingModel = options.embeddingModel || process.env.OLLAMA_EMBEDDING_MODEL || null;
|
|
19
22
|
this.timeout = options.timeout || 120000;
|
|
20
23
|
}
|
|
21
24
|
|
|
22
|
-
/**
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
25
|
+
/**
|
|
26
|
+
* The model to use, selecting the best installed one for the role on first
|
|
27
|
+
* use. Extraction and judgement have different winners — see JUDGEMENT_MODELS.
|
|
28
|
+
* @param {'default'|'judgement'} [role]
|
|
29
|
+
*/
|
|
30
|
+
async resolveModel(role = 'default') {
|
|
31
|
+
if (this.model) return this.model;
|
|
32
|
+
if (!this.modelByRole.has(role)) this.modelByRole.set(role, await selectOllamaModel(role));
|
|
33
|
+
return this.modelByRole.get(role);
|
|
26
34
|
}
|
|
27
35
|
|
|
28
36
|
async generateCompletion(prompt, options = {}) {
|
|
29
|
-
const model = await this.resolveModel();
|
|
30
37
|
const {
|
|
31
38
|
maxTokens = 1000,
|
|
32
39
|
temperature = 0.7,
|
|
33
40
|
systemPrompt = null,
|
|
41
|
+
role = 'default',
|
|
34
42
|
// 'json' constrains the model to emit a parseable object, or pass a JSON
|
|
35
43
|
// Schema to constrain the shape as well. Small local models otherwise
|
|
36
44
|
// wrap JSON in prose and the caller's JSON.parse fails.
|
|
37
45
|
format = null
|
|
38
46
|
} = options;
|
|
47
|
+
const model = await this.resolveModel(role);
|
|
39
48
|
|
|
40
49
|
const messages = [];
|
|
41
50
|
if (systemPrompt) {
|
|
@@ -385,6 +385,33 @@ export class BrowserProcessor {
|
|
|
385
385
|
return page;
|
|
386
386
|
}
|
|
387
387
|
|
|
388
|
+
/**
|
|
389
|
+
* Release a stealth page and hand its context slot back to the pool.
|
|
390
|
+
*
|
|
391
|
+
* createStealthPage() registers every context in activeContexts and in
|
|
392
|
+
* StealthBrowserManager's capped pool (MAX_BROWSER_CONTEXTS, default 10).
|
|
393
|
+
* Nothing reuses a context after the call that created it, so a caller that
|
|
394
|
+
* only closes the page keeps the slot: after 10 stealth runs the next
|
|
395
|
+
* createStealthContext() waits for a free slot and then throws. Going
|
|
396
|
+
* through the manager's closeContext() returns the slot and the renderer.
|
|
397
|
+
* @param {Page} page - Stealth page returned by initializePage()
|
|
398
|
+
* @returns {Promise<void>}
|
|
399
|
+
*/
|
|
400
|
+
async releaseStealthPage(page) {
|
|
401
|
+
try { await page.close(); } catch (_) { /* ignore close errors */ }
|
|
402
|
+
|
|
403
|
+
for (const [contextId, contextData] of this.activeContexts.entries()) {
|
|
404
|
+
if (contextData.page !== page) continue;
|
|
405
|
+
this.activeContexts.delete(contextId);
|
|
406
|
+
try {
|
|
407
|
+
await this.stealthManager?.closeContext(contextId);
|
|
408
|
+
} catch (error) {
|
|
409
|
+
console.warn(`Failed to close stealth context ${contextId}:`, error.message);
|
|
410
|
+
}
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
388
415
|
/**
|
|
389
416
|
* Apply additional stealth middleware to page
|
|
390
417
|
* @param {Page} page - Playwright page
|
|
@@ -7,6 +7,7 @@ import { Readability } from '@mozilla/readability';
|
|
|
7
7
|
import { JSDOM } from 'jsdom';
|
|
8
8
|
import * as cheerio from 'cheerio';
|
|
9
9
|
import { z } from 'zod';
|
|
10
|
+
import { ContentQualityAssessor } from '../../utils/contentUtils.js';
|
|
10
11
|
|
|
11
12
|
const ContentProcessorSchema = z.object({
|
|
12
13
|
html: z.string(),
|
|
@@ -381,7 +382,11 @@ export class ContentProcessor {
|
|
|
381
382
|
}
|
|
382
383
|
|
|
383
384
|
/**
|
|
384
|
-
* Calculate readability score
|
|
385
|
+
* Calculate readability metrics. The Flesch score, level and syllable
|
|
386
|
+
* counting come from ContentQualityAssessor.calculateSimpleReadability —
|
|
387
|
+
* the single Flesch implementation — so this never disagrees with a
|
|
388
|
+
* qualityAssessment computed over the same text. Score is unclamped; see
|
|
389
|
+
* that method for why.
|
|
385
390
|
* @param {string} text - Text content
|
|
386
391
|
* @returns {Object} - Readability metrics
|
|
387
392
|
*/
|
|
@@ -399,55 +404,22 @@ export class ContentProcessor {
|
|
|
399
404
|
return null;
|
|
400
405
|
}
|
|
401
406
|
|
|
402
|
-
const
|
|
407
|
+
const readability = ContentQualityAssessor.calculateSimpleReadability(text);
|
|
403
408
|
const avgCharsPerWord = charactersNoSpaces / words.length;
|
|
404
|
-
const avgSyllablesPerWord = words.reduce((sum, w) => sum + this._countSyllables(w), 0) / words.length;
|
|
405
|
-
|
|
406
|
-
// Flesch Reading-Ease: higher score = easier to read
|
|
407
|
-
const readabilityScore = 206.835 - (1.015 * avgWordsPerSentence) - (84.6 * avgSyllablesPerWord);
|
|
408
409
|
|
|
409
410
|
return {
|
|
410
411
|
sentences: sentences.length,
|
|
411
412
|
words: words.length,
|
|
412
413
|
characters,
|
|
413
414
|
charactersNoSpaces,
|
|
414
|
-
avgWordsPerSentence:
|
|
415
|
+
avgWordsPerSentence: readability.avgWordsPerSentence,
|
|
415
416
|
avgCharsPerWord: Math.round(avgCharsPerWord * 100) / 100,
|
|
416
|
-
avgSyllablesPerWord:
|
|
417
|
-
readabilityScore:
|
|
418
|
-
readabilityLevel:
|
|
417
|
+
avgSyllablesPerWord: readability.avgSyllablesPerWord,
|
|
418
|
+
readabilityScore: readability.score,
|
|
419
|
+
readabilityLevel: readability.level
|
|
419
420
|
};
|
|
420
421
|
}
|
|
421
422
|
|
|
422
|
-
/**
|
|
423
|
-
* Get readability level based on score
|
|
424
|
-
* @param {number} score - Readability score
|
|
425
|
-
* @returns {string} - Readability level
|
|
426
|
-
*/
|
|
427
|
-
getReadabilityLevel(score) {
|
|
428
|
-
if (score >= 90) return 'Very Easy';
|
|
429
|
-
if (score >= 80) return 'Easy';
|
|
430
|
-
if (score >= 70) return 'Fairly Easy';
|
|
431
|
-
if (score >= 60) return 'Standard';
|
|
432
|
-
if (score >= 50) return 'Fairly Difficult';
|
|
433
|
-
if (score >= 30) return 'Difficult';
|
|
434
|
-
return 'Very Difficult';
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
/**
|
|
438
|
-
* Count syllables in a word (heuristic)
|
|
439
|
-
* @param {string} word
|
|
440
|
-
* @returns {number}
|
|
441
|
-
*/
|
|
442
|
-
_countSyllables(word) {
|
|
443
|
-
const w = word.toLowerCase().replace(/[^a-z]/g, '');
|
|
444
|
-
if (w.length <= 3) return 1;
|
|
445
|
-
// Remove trailing silent e
|
|
446
|
-
const stripped = w.replace(/e$/, '');
|
|
447
|
-
const matches = stripped.match(/[aeiouy]+/g);
|
|
448
|
-
return Math.max(1, matches ? matches.length : 1);
|
|
449
|
-
}
|
|
450
|
-
|
|
451
423
|
/**
|
|
452
424
|
* Extract fallback content when Readability fails
|
|
453
425
|
* @param {string} html - HTML content
|
|
@@ -9,6 +9,7 @@ import fs from 'fs/promises';
|
|
|
9
9
|
import path from 'path';
|
|
10
10
|
import { safeFetch } from '../../utils/ssrfGuard.js';
|
|
11
11
|
import { config } from '../../constants/config.js';
|
|
12
|
+
import { identityHeaders } from '../../utils/fetchIdentity.js';
|
|
12
13
|
|
|
13
14
|
const PDFProcessorSchema = z.object({
|
|
14
15
|
source: z.string().min(1),
|
|
@@ -237,9 +238,7 @@ export class PDFProcessor {
|
|
|
237
238
|
// ignores unknown properties, so only `signal` actually enforces a
|
|
238
239
|
// deadline here.
|
|
239
240
|
const response = await safeFetch(url, {
|
|
240
|
-
headers:
|
|
241
|
-
'User-Agent': 'Mozilla/5.0 (compatible; MCP-WebScraper/2.0; PDF-Processor)'
|
|
242
|
-
},
|
|
241
|
+
headers: identityHeaders(),
|
|
243
242
|
signal: AbortSignal.timeout(30000)
|
|
244
243
|
});
|
|
245
244
|
|
|
@@ -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
|
|
|
@@ -127,5 +127,10 @@ With no LLM configured, `deep_research` returns structured **raw evidence** for
|
|
|
127
127
|
the calling assistant (e.g. Claude Code) to synthesize — this is expected, do
|
|
128
128
|
not suggest adding API keys.
|
|
129
129
|
|
|
130
|
+
On local Ollama, claim judgement (relevance, grouping, contradiction) uses
|
|
131
|
+
`gemma3:12b` when it is installed and the extraction model otherwise; conflicts
|
|
132
|
+
are reported only with that model or a cloud provider, so `conflictsFound: 0`
|
|
133
|
+
on a machine without it is expected, not a failure.
|
|
134
|
+
|
|
130
135
|
See [research workflows](references/workflows.md) for pipelines, depth tiers,
|
|
131
136
|
and parameter detail.
|
|
@@ -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),
|