crawlforge-mcp-server 5.0.1 → 5.0.3
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 +1 -1
- package/package.json +1 -1
- package/server.js +6 -4
- package/src/cli/commands/llmstxt.js +9 -2
- package/src/core/ActionExecutor.js +20 -1
- package/src/core/AgentOrchestrator.js +44 -5
- package/src/core/ChangeTracker.js +28 -16
- package/src/core/LLMsTxtAnalyzer.js +3 -1
- package/src/core/LocalizationManager.js +62 -17
- package/src/core/MonitorStore.js +55 -6
- package/src/core/ResearchOrchestrator.js +6 -1
- package/src/core/StealthBrowserManager.js +72 -11
- package/src/core/analysis/ContentAnalyzer.js +203 -18
- package/src/tools/advanced/ScrapeWithActionsTool.js +5 -1
- package/src/tools/advanced/batchScrape/worker.js +8 -9
- package/src/tools/advanced/scrapeWithActions/recorder.js +3 -0
- package/src/tools/basic/extractText.js +7 -0
- package/src/tools/extract/extractStructured.js +2 -1
- package/src/tools/llmstxt/generateLLMsTxt.js +154 -12
- package/src/tools/templates/TemplateRegistry.js +11 -4
- package/src/tools/tracking/trackChanges/index.js +12 -5
package/CLAUDE.md
CHANGED
|
@@ -62,7 +62,7 @@ These guidelines are working if: fewer unnecessary changes in diffs, fewer rewri
|
|
|
62
62
|
|
|
63
63
|
CrawlForge MCP Server - A professional MCP (Model Context Protocol) server providing 27 web scraping, crawling, and content processing tools (5 inline + 22 advanced).
|
|
64
64
|
|
|
65
|
-
**Current Version:** 5.0.
|
|
65
|
+
**Current Version:** 5.0.3
|
|
66
66
|
|
|
67
67
|
## Development Commands
|
|
68
68
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "crawlforge-mcp-server",
|
|
3
|
-
"version": "5.0.
|
|
3
|
+
"version": "5.0.3",
|
|
4
4
|
"mcpName": "io.github.mysleekdesigns/crawlforge-mcp-server",
|
|
5
5
|
"description": "CrawlForge MCP Server - Professional Model Context Protocol server with 27 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",
|
package/server.js
CHANGED
|
@@ -99,7 +99,7 @@ const taskStore = createTaskStore({ logger });
|
|
|
99
99
|
// Create the server
|
|
100
100
|
const server = new McpServer({
|
|
101
101
|
name: "crawlforge",
|
|
102
|
-
version: "5.0.
|
|
102
|
+
version: "5.0.2",
|
|
103
103
|
description: "Production-ready MCP server with 27 web scraping, crawling, and content processing tools. Features MCP Resources (crawlforge://), Prompts, Sampling fallback, Elicitation, stealth browsing, deep research, structured extraction, real Google SERP rank tracking, change tracking, local-LLM extraction via Ollama, unified multi-format scrape, and autonomous agent tool.",
|
|
104
104
|
homepage: "https://www.crawlforge.dev",
|
|
105
105
|
icon: "https://www.crawlforge.dev/icon.png",
|
|
@@ -794,6 +794,8 @@ registerToolIfEnabled("scrape_with_actions", {
|
|
|
794
794
|
distance: z.number().min(0).optional().describe("scroll: pixels to scroll"),
|
|
795
795
|
smooth: z.boolean().optional().describe("scroll: smooth scrolling"),
|
|
796
796
|
toElement: z.string().optional().describe("scroll: selector to scroll to"),
|
|
797
|
+
x: z.number().min(0).optional().describe("scroll: absolute X coordinate to scroll to (window.scrollTo; with y, takes precedence over direction/distance)"),
|
|
798
|
+
y: z.number().min(0).optional().describe("scroll: absolute Y coordinate to scroll to (window.scrollTo; with x, takes precedence over direction/distance)"),
|
|
797
799
|
// screenshot
|
|
798
800
|
fullPage: z.boolean().optional().describe("screenshot: capture full page"),
|
|
799
801
|
quality: z.number().min(0).max(100).optional().describe("screenshot: jpeg quality"),
|
|
@@ -1299,8 +1301,8 @@ registerToolIfEnabled("localization", {
|
|
|
1299
1301
|
extraHTTPHeaders: z.record(z.string()).optional(),
|
|
1300
1302
|
userAgent: z.string().optional()
|
|
1301
1303
|
}).optional().describe("Browser context options for locale emulation"),
|
|
1302
|
-
content: z.string().optional().describe("
|
|
1303
|
-
url: z.string().url().optional().describe("URL for
|
|
1304
|
+
content: z.string().optional().describe("Page content (HTML or plain text) to analyze — required for auto_detect; no fetching is performed"),
|
|
1305
|
+
url: z.string().url().optional().describe("URL — required for handle_geo_blocking; for auto_detect it is optional metadata used only as a TLD country hint (the page is never fetched)"),
|
|
1304
1306
|
response: z.object({
|
|
1305
1307
|
status: z.number(),
|
|
1306
1308
|
body: z.string().optional(),
|
|
@@ -1336,7 +1338,7 @@ registerToolIfEnabled("localization", {
|
|
|
1336
1338
|
result = await localizationManager.detectGeoBlocking(params.url, params.response);
|
|
1337
1339
|
break;
|
|
1338
1340
|
case 'auto_detect':
|
|
1339
|
-
if (!params.content
|
|
1341
|
+
if (!params.content) throw new Error('content is required for auto_detect operation');
|
|
1340
1342
|
result = await localizationManager.autoDetectLocalization(params.content, params.url);
|
|
1341
1343
|
break;
|
|
1342
1344
|
case 'get_stats':
|
|
@@ -10,17 +10,24 @@ export function register(program) {
|
|
|
10
10
|
.command('llmstxt <url>')
|
|
11
11
|
.description('Generate llms.txt for a website (AI compliance file)')
|
|
12
12
|
.option('--include-full', 'Also generate llms-full.txt')
|
|
13
|
-
.option('--max-pages <n>', 'Maximum pages to analyze', '50')
|
|
13
|
+
.option('--max-pages <n>', 'Maximum pages to analyze, 10-500', '50')
|
|
14
14
|
.action(async (url, opts, cmd) => {
|
|
15
15
|
const globals = cmd.parent.opts();
|
|
16
16
|
const cliFlags = { json: globals.json, pretty: globals.pretty, quiet: globals.quiet };
|
|
17
|
+
// GenerateLLMsTxtSchema requires analysisOptions.maxPages between 10 and 500 —
|
|
18
|
+
// validate here so users get a clear message instead of a raw zod error.
|
|
19
|
+
const maxPages = parseInt(opts.maxPages, 10);
|
|
20
|
+
if (!Number.isInteger(maxPages) || maxPages < 10 || maxPages > 500) {
|
|
21
|
+
process.stderr.write('Error: --max-pages must be between 10 and 500\n');
|
|
22
|
+
process.exit(1);
|
|
23
|
+
}
|
|
17
24
|
const tool = new GenerateLLMsTxtTool(getToolConfig('generate_llms_txt'));
|
|
18
25
|
// GenerateLLMsTxtSchema expects: url, format ('both'|'llms-txt'|'llms-full-txt'),
|
|
19
26
|
// analysisOptions.maxPages.
|
|
20
27
|
await runTool(tool, {
|
|
21
28
|
url,
|
|
22
29
|
format: opts.includeFull ? 'both' : 'llms-txt',
|
|
23
|
-
analysisOptions: { maxPages
|
|
30
|
+
analysisOptions: { maxPages }
|
|
24
31
|
}, cliFlags);
|
|
25
32
|
});
|
|
26
33
|
}
|
|
@@ -72,7 +72,12 @@ const ScrollActionSchema = BaseActionSchema.extend({
|
|
|
72
72
|
direction: z.enum(['up', 'down', 'left', 'right']).default('down'),
|
|
73
73
|
distance: z.number().min(0).default(100),
|
|
74
74
|
smooth: z.boolean().default(true),
|
|
75
|
-
toElement: z.string().optional()
|
|
75
|
+
toElement: z.string().optional(),
|
|
76
|
+
// Absolute scroll-to coordinates (window.scrollTo). When present they take
|
|
77
|
+
// precedence over direction/distance. Matches the CLI guide's documented
|
|
78
|
+
// action-script format: { "type": "scroll", "x": 0, "y": 500 }.
|
|
79
|
+
x: z.number().min(0).optional(),
|
|
80
|
+
y: z.number().min(0).optional()
|
|
76
81
|
});
|
|
77
82
|
|
|
78
83
|
const ScreenshotActionSchema = BaseActionSchema.extend({
|
|
@@ -708,6 +713,20 @@ export class ActionExecutor extends EventEmitter {
|
|
|
708
713
|
return { scrolledToElement: action.toElement };
|
|
709
714
|
}
|
|
710
715
|
|
|
716
|
+
// Absolute scroll-to coordinates take precedence over direction/distance.
|
|
717
|
+
// window.scrollTo (not scrollBy/mouse.wheel, which are relative deltas) is
|
|
718
|
+
// the standard Playwright pattern for absolute positioning. A missing axis
|
|
719
|
+
// defaults to 0, matching the plain window.scrollTo(x, y) call form.
|
|
720
|
+
if (action.x !== undefined || action.y !== undefined) {
|
|
721
|
+
const targetX = action.x ?? 0;
|
|
722
|
+
const targetY = action.y ?? 0;
|
|
723
|
+
await page.evaluate(
|
|
724
|
+
([x, y]) => window.scrollTo(x, y),
|
|
725
|
+
[targetX, targetY]
|
|
726
|
+
);
|
|
727
|
+
return { scrolledTo: { x: targetX, y: targetY }, mode: 'absolute' };
|
|
728
|
+
}
|
|
729
|
+
|
|
711
730
|
if (humanBehaviorSimulator) {
|
|
712
731
|
// Use human-like scrolling behavior
|
|
713
732
|
await humanBehaviorSimulator.simulateScroll(page, {
|
|
@@ -175,9 +175,20 @@ export class AgentOrchestrator {
|
|
|
175
175
|
let searchQueries = [prompt]; // fallback: use raw prompt as query
|
|
176
176
|
try {
|
|
177
177
|
const planPrompt =
|
|
178
|
-
`Decompose this research task into 1-3 concise web search queries
|
|
178
|
+
`Decompose this research task into 1-3 concise web search queries. ` +
|
|
179
|
+
`Output ONLY the queries, one per line, no preamble or numbering:\n\n${prompt}`;
|
|
179
180
|
const { text } = await this._getSamplingClient().complete(planPrompt, { maxTokens: 200 });
|
|
180
|
-
const lines = text.split('\n')
|
|
181
|
+
const lines = text.split('\n')
|
|
182
|
+
.map(l => l.replace(/^[-*\d.)\s]+/, '').trim())
|
|
183
|
+
.filter(Boolean)
|
|
184
|
+
// Drop preamble/garbage lines ("Here are 3 concise web search queries:", …)
|
|
185
|
+
// so they never become search query #1 and poison the URL queue.
|
|
186
|
+
.filter(l =>
|
|
187
|
+
!l.endsWith(':') &&
|
|
188
|
+
!/^here (are|is)\b/i.test(l) &&
|
|
189
|
+
!/search quer(y|ies)/i.test(l) &&
|
|
190
|
+
l.length <= 100
|
|
191
|
+
);
|
|
181
192
|
if (lines.length > 0) searchQueries = lines.slice(0, 3);
|
|
182
193
|
} catch {
|
|
183
194
|
// Sampling unavailable — use raw prompt
|
|
@@ -185,6 +196,14 @@ export class AgentOrchestrator {
|
|
|
185
196
|
|
|
186
197
|
// ── GATHER (search) ───────────────────────────────────────────────────────
|
|
187
198
|
const urlQueue = [...seedUrls]; // start with any user-provided seeds
|
|
199
|
+
// Sites named directly in the prompt (full URLs or bare domains like
|
|
200
|
+
// "news.ycombinator.com") are the most authoritative sources for the task —
|
|
201
|
+
// queue them ahead of search results.
|
|
202
|
+
const namedSites = prompt.match(/https?:\/\/[^\s"'<>]+|(?<![\w.@/-])[a-z0-9][\w-]*(?:\.[a-z0-9][\w-]*)*\.[a-z]{2,}(?![\w-])/gi) || [];
|
|
203
|
+
for (const site of namedSites) {
|
|
204
|
+
const url = (/^https?:\/\//i.test(site) ? site : `https://${site}`).replace(/[.,;:!?)]+$/, '');
|
|
205
|
+
if (!urlQueue.includes(url)) urlQueue.push(url);
|
|
206
|
+
}
|
|
188
207
|
const searchResults = [];
|
|
189
208
|
|
|
190
209
|
if (urlQueue.length < capUrls) {
|
|
@@ -227,8 +246,10 @@ export class AgentOrchestrator {
|
|
|
227
246
|
const { textContent, finalUrl } = await fetchAndParse(url, { timeoutMs: 10000 });
|
|
228
247
|
if (!isRelevant(textContent, prompt)) continue;
|
|
229
248
|
step++;
|
|
249
|
+
const sr = searchResults.find(s => s.url === url);
|
|
230
250
|
evidence.push({
|
|
231
251
|
url: finalUrl,
|
|
252
|
+
title: sr ? sr.title : '',
|
|
232
253
|
text: truncate(textContent),
|
|
233
254
|
step
|
|
234
255
|
});
|
|
@@ -236,7 +257,20 @@ export class AgentOrchestrator {
|
|
|
236
257
|
}
|
|
237
258
|
|
|
238
259
|
// ── SHAPE ─────────────────────────────────────────────────────────────────
|
|
239
|
-
|
|
260
|
+
// Order evidence by simple relevance to the prompt (term overlap with
|
|
261
|
+
// url+title+text) instead of raw queue order, then give every source a
|
|
262
|
+
// per-source slice of the synthesis budget so no source is silently cut off.
|
|
263
|
+
const promptTerms = prompt.toLowerCase().split(/\s+/).filter(t => t.length > 3);
|
|
264
|
+
const orderedEvidence = evidence
|
|
265
|
+
.map(e => ({
|
|
266
|
+
...e,
|
|
267
|
+
_score: promptTerms.filter(t => `${e.url} ${e.title || ''} ${e.text}`.toLowerCase().includes(t)).length
|
|
268
|
+
}))
|
|
269
|
+
.sort((a, b) => b._score - a._score);
|
|
270
|
+
const perSourceCap = Math.max(1500, Math.floor(12000 / Math.max(evidence.length, 1)));
|
|
271
|
+
const combinedText = orderedEvidence
|
|
272
|
+
.map(e => `--- Source: ${e.url} ---\n${truncate(e.text, perSourceCap)}`)
|
|
273
|
+
.join('\n\n');
|
|
240
274
|
|
|
241
275
|
if (!combinedText.trim()) {
|
|
242
276
|
return {
|
|
@@ -284,9 +318,14 @@ export class AgentOrchestrator {
|
|
|
284
318
|
|
|
285
319
|
try {
|
|
286
320
|
const synthesisPrompt =
|
|
287
|
-
`You are a research assistant.
|
|
321
|
+
`You are a research assistant. Answer this task using ONLY the sources below:\n\n` +
|
|
288
322
|
`Task: ${prompt}\n\n` +
|
|
289
|
-
`${
|
|
323
|
+
`${combinedText}\n\n` +
|
|
324
|
+
`Rules:\n` +
|
|
325
|
+
`- Answer ONLY from the provided sources; do not use outside knowledge.\n` +
|
|
326
|
+
`- Cite the exact source URL(s) you used.\n` +
|
|
327
|
+
`- If the sources do not contain the answer, say so explicitly.\n` +
|
|
328
|
+
`- NEVER invent or guess a URL; cite only URLs that appear in the sources above.\n\n` +
|
|
290
329
|
`Provide a clear, concise answer.`;
|
|
291
330
|
|
|
292
331
|
const { text } = await this._getSamplingClient().complete(synthesisPrompt, { maxTokens: 1024 });
|
|
@@ -211,12 +211,14 @@ export class ChangeTracker extends EventEmitter {
|
|
|
211
211
|
// Calculate change significance
|
|
212
212
|
const significance = await this.calculateChangeSignificance(changeAnalysis, baseline.options);
|
|
213
213
|
|
|
214
|
-
// Create change record
|
|
214
|
+
// Create change record. An unchanged compare (significance 'none' ⇒
|
|
215
|
+
// hasChanges:false) must report a neutral changeType — classifyChangeType
|
|
216
|
+
// falls through to 'text_change' even when nothing changed at all.
|
|
215
217
|
const changeRecord = {
|
|
216
218
|
url,
|
|
217
219
|
timestamp: Date.now(),
|
|
218
220
|
baselineVersion: baseline.version,
|
|
219
|
-
changeType: this.classifyChangeType(changeAnalysis),
|
|
221
|
+
changeType: significance === 'none' ? 'none' : this.classifyChangeType(changeAnalysis),
|
|
220
222
|
significance,
|
|
221
223
|
details: changeAnalysis,
|
|
222
224
|
metrics: {
|
|
@@ -742,12 +744,17 @@ export class ChangeTracker extends EventEmitter {
|
|
|
742
744
|
currentContent = currentContent.toLowerCase();
|
|
743
745
|
}
|
|
744
746
|
|
|
745
|
-
// Word-level diff
|
|
747
|
+
// Word-level diff. Only record an entry when something actually changed
|
|
748
|
+
// (mirrors line_diff below) — unconditionally pushing an empty word_diff
|
|
749
|
+
// made every unchanged compare report "Text content changed".
|
|
746
750
|
const wordDiff = diffWords(baselineContent, currentContent);
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
+
const wordDiffChanges = wordDiff.filter(part => part.added || part.removed);
|
|
752
|
+
if (wordDiffChanges.length > 0) {
|
|
753
|
+
textChanges.push({
|
|
754
|
+
type: 'word_diff',
|
|
755
|
+
changes: wordDiffChanges
|
|
756
|
+
});
|
|
757
|
+
}
|
|
751
758
|
|
|
752
759
|
// Line-level diff for structured content
|
|
753
760
|
const lineDiff = diffLines(baselineContent, currentContent);
|
|
@@ -903,18 +910,23 @@ export class ChangeTracker extends EventEmitter {
|
|
|
903
910
|
|
|
904
911
|
const tagSimilarity = this.calculateTagSimilarity(baselineElements, currentElements);
|
|
905
912
|
const hierarchySimilarity = this.calculateHierarchySimilarity(baseline.hierarchy, current.hierarchy);
|
|
906
|
-
|
|
907
|
-
|
|
913
|
+
|
|
914
|
+
// Clamp defensively — this is a 0-1 metric and must never leave that range.
|
|
915
|
+
return Math.max(0, Math.min(1, (tagSimilarity + hierarchySimilarity) / 2));
|
|
908
916
|
}
|
|
909
|
-
|
|
917
|
+
|
|
910
918
|
calculateTagSimilarity(baselineElements, currentElements) {
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
919
|
+
// Jaccard similarity: intersection and union must both operate on SETS.
|
|
920
|
+
// The old code intersected the raw (duplicate-laden) tag list against a
|
|
921
|
+
// set union, so repeated tags (div, p, ...) inflated the numerator and
|
|
922
|
+
// produced impossible scores > 1 (e.g. the observed 1.05).
|
|
923
|
+
const baselineTags = new Set(baselineElements.map(el => el.tag));
|
|
924
|
+
const currentTags = new Set(currentElements.map(el => el.tag));
|
|
925
|
+
|
|
926
|
+
const intersection = [...baselineTags].filter(tag => currentTags.has(tag));
|
|
915
927
|
const union = new Set([...baselineTags, ...currentTags]);
|
|
916
|
-
|
|
917
|
-
return intersection.length / union.size;
|
|
928
|
+
|
|
929
|
+
return union.size === 0 ? 1 : intersection.length / union.size;
|
|
918
930
|
}
|
|
919
931
|
|
|
920
932
|
calculateHierarchySimilarity(baseline, current) {
|
|
@@ -228,7 +228,9 @@ export class LLMsTxtAnalyzer {
|
|
|
228
228
|
$('a[href*="api"], a[href*="developer"], a[href*="docs"]').each((_, element) => {
|
|
229
229
|
const href = $(element).attr('href');
|
|
230
230
|
const text = $(element).text().toLowerCase();
|
|
231
|
-
|
|
231
|
+
// Word-boundary match: substring checks flagged "Sapiens"/"rapid"
|
|
232
|
+
// style words as API links.
|
|
233
|
+
if (href && /\b(api|developer)s?\b/.test(text)) {
|
|
232
234
|
apis.push({
|
|
233
235
|
url: new URL(href, baseUrl).toString(),
|
|
234
236
|
type: 'documentation',
|
|
@@ -548,7 +548,7 @@ export class LocalizationManager extends EventEmitter {
|
|
|
548
548
|
/**
|
|
549
549
|
* Auto-detect appropriate localization from content
|
|
550
550
|
* @param {string} content - Web page content
|
|
551
|
-
* @param {string} url -
|
|
551
|
+
* @param {string} [url] - Optional source URL (used only as a TLD country hint)
|
|
552
552
|
* @returns {Object} - Detected localization settings
|
|
553
553
|
*/
|
|
554
554
|
async autoDetectLocalization(content, url) {
|
|
@@ -698,14 +698,40 @@ export class LocalizationManager extends EventEmitter {
|
|
|
698
698
|
}
|
|
699
699
|
|
|
700
700
|
getDateFormat(countryCode) {
|
|
701
|
+
// Audited against CLDR short-date patterns / Wikipedia "List of date
|
|
702
|
+
// formats by country". Covers every SUPPORTED_COUNTRIES entry; the
|
|
703
|
+
// fallback is DD/MM/YYYY (day-first), which most of the world uses —
|
|
704
|
+
// MM/DD/YYYY is essentially US-only.
|
|
701
705
|
const formats = {
|
|
702
706
|
'US': 'MM/DD/YYYY',
|
|
703
707
|
'GB': 'DD/MM/YYYY',
|
|
704
708
|
'DE': 'DD.MM.YYYY',
|
|
705
|
-
'
|
|
709
|
+
'FR': 'DD/MM/YYYY',
|
|
710
|
+
'JP': 'YYYY/MM/DD',
|
|
711
|
+
'CN': 'YYYY/MM/DD',
|
|
712
|
+
'AU': 'DD/MM/YYYY',
|
|
713
|
+
'CA': 'YYYY-MM-DD',
|
|
714
|
+
'IT': 'DD/MM/YYYY',
|
|
715
|
+
'ES': 'DD/MM/YYYY',
|
|
716
|
+
'RU': 'DD.MM.YYYY',
|
|
717
|
+
'BR': 'DD/MM/YYYY',
|
|
718
|
+
'IN': 'DD/MM/YYYY',
|
|
719
|
+
'KR': 'YYYY.MM.DD',
|
|
720
|
+
'MX': 'DD/MM/YYYY',
|
|
721
|
+
'NL': 'DD-MM-YYYY',
|
|
722
|
+
'SE': 'YYYY-MM-DD',
|
|
723
|
+
'NO': 'DD.MM.YYYY',
|
|
724
|
+
'SA': 'DD/MM/YYYY',
|
|
725
|
+
'AE': 'DD/MM/YYYY',
|
|
726
|
+
'TR': 'DD.MM.YYYY',
|
|
727
|
+
'IL': 'DD.MM.YYYY',
|
|
728
|
+
'TH': 'DD/MM/YYYY',
|
|
729
|
+
'SG': 'DD/MM/YYYY',
|
|
730
|
+
'PL': 'DD.MM.YYYY',
|
|
731
|
+
'ZA': 'YYYY/MM/DD'
|
|
706
732
|
};
|
|
707
|
-
|
|
708
|
-
return formats[countryCode] || 'MM/
|
|
733
|
+
|
|
734
|
+
return formats[countryCode] || 'DD/MM/YYYY';
|
|
709
735
|
}
|
|
710
736
|
|
|
711
737
|
getNumberFormat(countryCode) {
|
|
@@ -1275,7 +1301,17 @@ export class LocalizationManager extends EventEmitter {
|
|
|
1275
1301
|
const detectedLang = await this.analyzeTextLanguage(textSample);
|
|
1276
1302
|
if (detectedLang) {
|
|
1277
1303
|
detection.evidence.push(`Text analysis: ${detectedLang.language} (${detectedLang.confidence}%)`);
|
|
1278
|
-
detection.
|
|
1304
|
+
if (!detection.detectedLanguage) {
|
|
1305
|
+
// Text analysis is the primary (only) language evidence — use it as
|
|
1306
|
+
// the detected language, weighted strongly. Previously the result
|
|
1307
|
+
// was pushed to evidence but detectedLanguage stayed null and the
|
|
1308
|
+
// 0.2 corroboration weight left confidence near zero.
|
|
1309
|
+
detection.detectedLanguage = detectedLang.language;
|
|
1310
|
+
detection.confidence += detectedLang.confidence / 100 * 0.6;
|
|
1311
|
+
} else {
|
|
1312
|
+
// Corroborates (or contradicts) HTML/meta evidence — lower weight
|
|
1313
|
+
detection.confidence += detectedLang.confidence / 100 * 0.2;
|
|
1314
|
+
}
|
|
1279
1315
|
}
|
|
1280
1316
|
}
|
|
1281
1317
|
}
|
|
@@ -1347,15 +1383,22 @@ export class LocalizationManager extends EventEmitter {
|
|
|
1347
1383
|
|
|
1348
1384
|
let bestMatch = null;
|
|
1349
1385
|
let maxMatches = 0;
|
|
1350
|
-
|
|
1386
|
+
const wordCount = text.split(/\s+/).filter(Boolean).length;
|
|
1387
|
+
|
|
1351
1388
|
for (const [lang, pattern] of Object.entries(patterns)) {
|
|
1352
1389
|
const matches = (text.match(pattern) || []).length;
|
|
1353
1390
|
if (matches > maxMatches) {
|
|
1354
1391
|
maxMatches = matches;
|
|
1355
|
-
|
|
1392
|
+
// Confidence from stop-word DENSITY (matches per word), not the
|
|
1393
|
+
// absolute match count — absolute counts made confidence depend on
|
|
1394
|
+
// sample length (a 500-char English sample scored only ~35%).
|
|
1395
|
+
// Stop-words are ~25-40% of natural text in the matching language,
|
|
1396
|
+
// so density * 250 puts an unambiguous match in the 60-95 range.
|
|
1397
|
+
const density = matches / Math.max(1, wordCount);
|
|
1398
|
+
bestMatch = { language: lang, confidence: Math.min(95, Math.round(density * 250)) };
|
|
1356
1399
|
}
|
|
1357
1400
|
}
|
|
1358
|
-
|
|
1401
|
+
|
|
1359
1402
|
return bestMatch;
|
|
1360
1403
|
}
|
|
1361
1404
|
|
|
@@ -1363,15 +1406,17 @@ export class LocalizationManager extends EventEmitter {
|
|
|
1363
1406
|
* Enhanced country detection
|
|
1364
1407
|
*/
|
|
1365
1408
|
async performCountryDetection(content, url, detection) {
|
|
1366
|
-
// TLD analysis
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1409
|
+
// TLD analysis — url is optional metadata; content-only detection skips it
|
|
1410
|
+
if (url) {
|
|
1411
|
+
const urlObj = new URL(url);
|
|
1412
|
+
const tldMatch = urlObj.hostname.match(/\.([a-z]{2})$/);
|
|
1413
|
+
if (tldMatch) {
|
|
1414
|
+
const tld = tldMatch[1].toUpperCase();
|
|
1415
|
+
if (SUPPORTED_COUNTRIES[tld]) {
|
|
1416
|
+
detection.detectedCountry = tld;
|
|
1417
|
+
detection.evidence.push(`TLD suggests country: ${tld}`);
|
|
1418
|
+
detection.confidence += 0.2;
|
|
1419
|
+
}
|
|
1375
1420
|
}
|
|
1376
1421
|
}
|
|
1377
1422
|
|
package/src/core/MonitorStore.js
CHANGED
|
@@ -1,18 +1,28 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* MonitorStore — disk persistence for scheduled change-monitors.
|
|
3
3
|
*
|
|
4
|
-
* One JSON file per monitor under
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* and
|
|
4
|
+
* One JSON file per monitor under <os.homedir()>/.crawlforge/monitors/<id>.json
|
|
5
|
+
* (same base dir as ~/.crawlforge/config.json and ~/.crawlforge/snapshots —
|
|
6
|
+
* user state must never depend on process.cwd(), or monitor:create from dir X
|
|
7
|
+
* and monitor:stop / monitor:run-due from dir Y silently see different stores).
|
|
8
|
+
* Mirrors JobManager's persistence *pattern* (mkdir-recursive, per-file JSON,
|
|
9
|
+
* randomUUID, load-on-start) but deliberately omits TTL/eviction — scheduled
|
|
10
|
+
* monitors are long-lived and must never be auto-expired.
|
|
8
11
|
*/
|
|
9
12
|
import { promises as fs } from 'node:fs';
|
|
13
|
+
import os from 'node:os';
|
|
10
14
|
import path from 'node:path';
|
|
11
15
|
import { randomUUID } from 'node:crypto';
|
|
12
16
|
|
|
13
17
|
export class MonitorStore {
|
|
14
|
-
constructor({ storageDir
|
|
15
|
-
this.storageDir = storageDir;
|
|
18
|
+
constructor({ storageDir, legacyDir } = {}) {
|
|
19
|
+
this.storageDir = storageDir || path.join(os.homedir(), '.crawlforge', 'monitors');
|
|
20
|
+
// One-time best-effort migration source for stores created before v5.0.3,
|
|
21
|
+
// when the default was cwd-relative './monitors'. Only armed when the
|
|
22
|
+
// caller did NOT override storageDir (i.e. the default upgrade path), so
|
|
23
|
+
// tests and embedders with explicit dirs never sweep files out of cwd —
|
|
24
|
+
// unless they opt in by passing legacyDir explicitly.
|
|
25
|
+
this._legacyDir = legacyDir ?? (storageDir ? null : path.resolve('./monitors'));
|
|
16
26
|
this.monitors = new Map();
|
|
17
27
|
this._loaded = false;
|
|
18
28
|
}
|
|
@@ -33,10 +43,49 @@ export class MonitorStore {
|
|
|
33
43
|
} catch {
|
|
34
44
|
/* dir unavailable — start empty */
|
|
35
45
|
}
|
|
46
|
+
await this._migrateLegacyDir();
|
|
36
47
|
this._loaded = true;
|
|
37
48
|
return this.monitors;
|
|
38
49
|
}
|
|
39
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Best-effort, idempotent migration of a legacy cwd-relative './monitors'
|
|
53
|
+
* store into the new home-rooted store. Moves each parseable monitor file
|
|
54
|
+
* whose id is not already present; skips (and leaves in place) everything
|
|
55
|
+
* else. Never throws — a failed migration must not break monitor loading.
|
|
56
|
+
*/
|
|
57
|
+
async _migrateLegacyDir() {
|
|
58
|
+
if (!this._legacyDir) return;
|
|
59
|
+
try {
|
|
60
|
+
if (path.resolve(this._legacyDir) === path.resolve(this.storageDir)) return;
|
|
61
|
+
const files = await fs.readdir(this._legacyDir); // throws if absent — caught below
|
|
62
|
+
for (const f of files) {
|
|
63
|
+
if (!f.endsWith('.json')) continue;
|
|
64
|
+
const from = path.join(this._legacyDir, f);
|
|
65
|
+
try {
|
|
66
|
+
const def = JSON.parse(await fs.readFile(from, 'utf8'));
|
|
67
|
+
if (!def || !def.id || this.monitors.has(def.id)) continue; // skip existing ids
|
|
68
|
+
await fs.mkdir(this.storageDir, { recursive: true });
|
|
69
|
+
const to = path.join(this.storageDir, `${def.id}.json`);
|
|
70
|
+
try {
|
|
71
|
+
await fs.rename(from, to);
|
|
72
|
+
} catch {
|
|
73
|
+
// rename fails across filesystems (EXDEV) — copy+unlink instead
|
|
74
|
+
await fs.copyFile(from, to);
|
|
75
|
+
await fs.unlink(from).catch(() => {});
|
|
76
|
+
}
|
|
77
|
+
this.monitors.set(def.id, def);
|
|
78
|
+
} catch {
|
|
79
|
+
/* unreadable/corrupt legacy file — leave it, keep going */
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
// Tidy up the legacy dir only if the migration emptied it.
|
|
83
|
+
await fs.rmdir(this._legacyDir).catch(() => {});
|
|
84
|
+
} catch {
|
|
85
|
+
/* no legacy dir (the common case) or unreadable — nothing to migrate */
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
40
89
|
newId() {
|
|
41
90
|
return randomUUID();
|
|
42
91
|
}
|
|
@@ -903,8 +903,12 @@ export class ResearchOrchestrator extends EventEmitter {
|
|
|
903
903
|
/** One stealth navigation. Fresh page/context; judges blocked by rendered content. */
|
|
904
904
|
async _stealthFetchOnce(url) {
|
|
905
905
|
let page;
|
|
906
|
+
let camoufoxContext;
|
|
906
907
|
if (this._stealthEngineActive === 'camoufox') {
|
|
907
|
-
|
|
908
|
+
// viewport:null skips Browser.setDefaultViewport, whose playwright-core
|
|
909
|
+
// 1.62 payload (isMobile etc.) camoufox's older Firefox build rejects.
|
|
910
|
+
camoufoxContext = await this._stealthBrowser.newContext({ viewport: null });
|
|
911
|
+
page = await camoufoxContext.newPage();
|
|
908
912
|
} else {
|
|
909
913
|
const { contextId } = await this._stealthManager.createStealthContext({ level: this.stealthLevel });
|
|
910
914
|
page = await this._stealthManager.createStealthPage(contextId);
|
|
@@ -930,6 +934,7 @@ export class ResearchOrchestrator extends EventEmitter {
|
|
|
930
934
|
return html && html.length > 200 ? html : null;
|
|
931
935
|
} finally {
|
|
932
936
|
await page.close().catch(() => {});
|
|
937
|
+
if (camoufoxContext) await camoufoxContext.close().catch(() => {});
|
|
933
938
|
}
|
|
934
939
|
}
|
|
935
940
|
|
|
@@ -421,6 +421,20 @@ export class StealthBrowserManager {
|
|
|
421
421
|
javaScriptEnabled: true
|
|
422
422
|
};
|
|
423
423
|
|
|
424
|
+
// camoufox's Firefox build predates the Browser.setDefaultViewport fields
|
|
425
|
+
// playwright-core 1.62 sends (screenSize, isMobile, ...) and rejects unknown
|
|
426
|
+
// properties, so any fixed viewport fails. viewport:null skips that protocol
|
|
427
|
+
// call entirely (deviceScaleFactor/isMobile/hasTouch/screen are invalid or
|
|
428
|
+
// meaningless without a viewport). window.screen is still spoofed via
|
|
429
|
+
// addInitScript in applyAdvancedStealthConfigurations.
|
|
430
|
+
if (this._launchedEngine === 'camoufox') {
|
|
431
|
+
contextOptions.viewport = null;
|
|
432
|
+
delete contextOptions.deviceScaleFactor;
|
|
433
|
+
delete contextOptions.isMobile;
|
|
434
|
+
delete contextOptions.hasTouch;
|
|
435
|
+
delete contextOptions.screen;
|
|
436
|
+
}
|
|
437
|
+
|
|
424
438
|
const context = await this.browser.newContext(contextOptions);
|
|
425
439
|
const contextId = this.generateContextId();
|
|
426
440
|
|
|
@@ -2001,32 +2015,79 @@ export class BrowserEngine {
|
|
|
2001
2015
|
export class CamoufoxAdapter extends BrowserEngine {
|
|
2002
2016
|
name() { return 'camoufox'; }
|
|
2003
2017
|
|
|
2018
|
+
/**
|
|
2019
|
+
* Load camoufox through its CJS entry (dist/index.cjs) via createRequire.
|
|
2020
|
+
* The package's ESM entry (dist/index.js, an esbuild bundle) throws
|
|
2021
|
+
* 'Dynamic require of "events" is not supported' when imported from ESM,
|
|
2022
|
+
* so `await import('camoufox')` fails even when the package IS installed.
|
|
2023
|
+
*/
|
|
2024
|
+
async _load() {
|
|
2025
|
+
const { createRequire } = await import('module');
|
|
2026
|
+
const require = createRequire(import.meta.url);
|
|
2027
|
+
return require('camoufox'); // CJS build — ESM build is broken
|
|
2028
|
+
}
|
|
2029
|
+
|
|
2030
|
+
/** True only when the camoufox package itself is absent (vs. present but failing to load). */
|
|
2031
|
+
_isNotInstalled(err) {
|
|
2032
|
+
return err?.code === 'MODULE_NOT_FOUND' && (err.message || '').includes("Cannot find module 'camoufox'");
|
|
2033
|
+
}
|
|
2034
|
+
|
|
2004
2035
|
async isAvailable() {
|
|
2005
2036
|
try {
|
|
2006
|
-
await
|
|
2037
|
+
await this._load();
|
|
2007
2038
|
return true;
|
|
2008
|
-
} catch {
|
|
2009
|
-
|
|
2039
|
+
} catch (err) {
|
|
2040
|
+
if (this._isNotInstalled(err)) {
|
|
2041
|
+
return false;
|
|
2042
|
+
}
|
|
2043
|
+
// Installed but broken — surface the real error instead of misreporting "not installed".
|
|
2044
|
+
throw new Error(`camoufox is installed but failed to load: ${err.message}`);
|
|
2010
2045
|
}
|
|
2011
2046
|
}
|
|
2012
2047
|
|
|
2013
2048
|
async launch(config = {}) {
|
|
2014
2049
|
let camoufox;
|
|
2015
2050
|
try {
|
|
2016
|
-
camoufox = await
|
|
2017
|
-
} catch {
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2051
|
+
camoufox = await this._load();
|
|
2052
|
+
} catch (err) {
|
|
2053
|
+
if (this._isNotInstalled(err)) {
|
|
2054
|
+
throw new Error(
|
|
2055
|
+
'camoufox is not installed. Run: npm install camoufox. Note: camoufox is MIT-licensed and requires Firefox to be installed.'
|
|
2056
|
+
);
|
|
2057
|
+
}
|
|
2058
|
+
throw new Error(`camoufox is installed but failed to load: ${err.message}`);
|
|
2021
2059
|
}
|
|
2022
2060
|
|
|
2023
|
-
|
|
2024
|
-
|
|
2061
|
+
await this._ensureMacOSLayout(camoufox);
|
|
2062
|
+
|
|
2063
|
+
// camoufox's launcher is Camoufox(options) — the package has no launch()
|
|
2064
|
+
// export. It resolves the fetched Firefox binary (npx camoufox fetch) and
|
|
2065
|
+
// returns a Playwright-compatible Browser. Takes `headless` directly plus
|
|
2066
|
+
// passthrough Playwright Firefox launch options.
|
|
2067
|
+
return camoufox.Camoufox({
|
|
2025
2068
|
headless: config.headless !== false,
|
|
2026
2069
|
...config.launchOptions
|
|
2027
2070
|
});
|
|
2071
|
+
}
|
|
2028
2072
|
|
|
2029
|
-
|
|
2073
|
+
/**
|
|
2074
|
+
* macOS packaging fix for camoufox-js: it expects properties.json in
|
|
2075
|
+
* Camoufox.app/Contents/MacOS/, but the .app bundle ships it under
|
|
2076
|
+
* Contents/Resources/. Bridge it so the launcher can boot. Best-effort.
|
|
2077
|
+
* (Same fix as ResearchOrchestrator._ensureCamoufoxLayout.)
|
|
2078
|
+
*/
|
|
2079
|
+
async _ensureMacOSLayout(camoufox) {
|
|
2080
|
+
if (process.platform !== 'darwin' || !camoufox?.INSTALL_DIR) return;
|
|
2081
|
+
try {
|
|
2082
|
+
const fs = await import('fs');
|
|
2083
|
+
const path = await import('path');
|
|
2084
|
+
const appDir = path.join(camoufox.INSTALL_DIR, 'Camoufox.app', 'Contents');
|
|
2085
|
+
const target = path.join(appDir, 'MacOS', 'properties.json');
|
|
2086
|
+
const source = path.join(appDir, 'Resources', 'properties.json');
|
|
2087
|
+
if (!fs.existsSync(target) && fs.existsSync(source)) {
|
|
2088
|
+
fs.copyFileSync(source, target);
|
|
2089
|
+
}
|
|
2090
|
+
} catch { /* best-effort; launch surfaces a real error if it matters */ }
|
|
2030
2091
|
}
|
|
2031
2092
|
}
|
|
2032
2093
|
|