crawlforge-mcp-server 5.0.2 → 5.0.4
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 +5 -3
- package/src/cli/commands/llmstxt.js +9 -2
- package/src/constants/config.js +5 -1
- package/src/core/ActionExecutor.js +20 -1
- package/src/core/AgentOrchestrator.js +59 -5
- package/src/core/ChangeTracker.js +28 -16
- 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 +201 -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/_fetchAndParse.js +32 -1
- package/src/tools/llmstxt/generateLLMsTxt.js +147 -7
- package/src/tools/templates/TemplateRegistry.js +5 -2
- 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.4
|
|
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.4",
|
|
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
|
@@ -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
|
}
|
package/src/constants/config.js
CHANGED
|
@@ -303,7 +303,11 @@ export function validateConfig() {
|
|
|
303
303
|
export function getToolConfig(toolName) {
|
|
304
304
|
const toolConfigs = {
|
|
305
305
|
search_web: {
|
|
306
|
-
|
|
306
|
+
// Read env at call time, not import time: the CLI resolves the key
|
|
307
|
+
// (--api-key flag / env / ~/.crawlforge/config.json) in a preAction
|
|
308
|
+
// hook that runs AFTER this module was imported, so the frozen
|
|
309
|
+
// config.crawlforge.apiKey snapshot misses stored keys.
|
|
310
|
+
apiKey: process.env.CRAWLFORGE_API_KEY || config.crawlforge.apiKey,
|
|
307
311
|
apiBaseUrl: config.crawlforge.apiBaseUrl,
|
|
308
312
|
|
|
309
313
|
// Common configuration
|
|
@@ -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,18 @@ 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
|
+
// Seeds and prompt-named sites also outrank search results at SHAPE time
|
|
204
|
+
// (see priorityUrls below) — generic term overlap must not bury them.
|
|
205
|
+
const priorityUrls = [...seedUrls];
|
|
206
|
+
for (const site of namedSites) {
|
|
207
|
+
const url = (/^https?:\/\//i.test(site) ? site : `https://${site}`).replace(/[.,;:!?)]+$/, '');
|
|
208
|
+
if (!urlQueue.includes(url)) urlQueue.push(url);
|
|
209
|
+
if (!priorityUrls.includes(url)) priorityUrls.push(url);
|
|
210
|
+
}
|
|
188
211
|
const searchResults = [];
|
|
189
212
|
|
|
190
213
|
if (urlQueue.length < capUrls) {
|
|
@@ -227,8 +250,10 @@ export class AgentOrchestrator {
|
|
|
227
250
|
const { textContent, finalUrl } = await fetchAndParse(url, { timeoutMs: 10000 });
|
|
228
251
|
if (!isRelevant(textContent, prompt)) continue;
|
|
229
252
|
step++;
|
|
253
|
+
const sr = searchResults.find(s => s.url === url);
|
|
230
254
|
evidence.push({
|
|
231
255
|
url: finalUrl,
|
|
256
|
+
title: sr ? sr.title : '',
|
|
232
257
|
text: truncate(textContent),
|
|
233
258
|
step
|
|
234
259
|
});
|
|
@@ -236,7 +261,26 @@ export class AgentOrchestrator {
|
|
|
236
261
|
}
|
|
237
262
|
|
|
238
263
|
// ── SHAPE ─────────────────────────────────────────────────────────────────
|
|
239
|
-
|
|
264
|
+
// Order evidence by simple relevance to the prompt (term overlap with
|
|
265
|
+
// url+title+text) instead of raw queue order, then give every source a
|
|
266
|
+
// per-source slice of the synthesis budget so no source is silently cut off.
|
|
267
|
+
const promptTerms = prompt.toLowerCase().split(/\s+/).filter(t => t.length > 3);
|
|
268
|
+
// Seeds/prompt-named sites get a fixed boost above any term-overlap score:
|
|
269
|
+
// a generic article mentioning the task's words ("title", "story") must
|
|
270
|
+
// never outrank the source the user explicitly pointed at (live retest
|
|
271
|
+
// 2026-08-20: an NFL article buried the named news.ycombinator.com page).
|
|
272
|
+
const isPriority = url => priorityUrls.some(p => url === p || url.startsWith(`${p}/`) || `${url}/` === p);
|
|
273
|
+
const orderedEvidence = evidence
|
|
274
|
+
.map(e => ({
|
|
275
|
+
...e,
|
|
276
|
+
_score: (isPriority(e.url) ? 1000 : 0) +
|
|
277
|
+
promptTerms.filter(t => `${e.url} ${e.title || ''} ${e.text}`.toLowerCase().includes(t)).length
|
|
278
|
+
}))
|
|
279
|
+
.sort((a, b) => b._score - a._score);
|
|
280
|
+
const perSourceCap = Math.max(1500, Math.floor(12000 / Math.max(evidence.length, 1)));
|
|
281
|
+
const combinedText = orderedEvidence
|
|
282
|
+
.map(e => `--- Source: ${e.url} ---\n${truncate(e.text, perSourceCap)}`)
|
|
283
|
+
.join('\n\n');
|
|
240
284
|
|
|
241
285
|
if (!combinedText.trim()) {
|
|
242
286
|
return {
|
|
@@ -283,10 +327,20 @@ export class AgentOrchestrator {
|
|
|
283
327
|
let degradedReason;
|
|
284
328
|
|
|
285
329
|
try {
|
|
330
|
+
// Wording matters for small local models (llama3.2-class): without the
|
|
331
|
+
// "already fetched / do not refuse" framing they answer "I cannot access
|
|
332
|
+
// the internet" or "the sources do not contain it" even when the answer
|
|
333
|
+
// sits in the first source (live retest 2026-08-20).
|
|
286
334
|
const synthesisPrompt =
|
|
287
|
-
`You are a
|
|
335
|
+
`You are a data-extraction assistant. The web sources below have ALREADY been fetched for you and their text is included — no internet access is needed; do not refuse for lack of browsing ability. They are ordered most-relevant first. Answer the task using ONLY this source text:\n\n` +
|
|
288
336
|
`Task: ${prompt}\n\n` +
|
|
289
|
-
`${
|
|
337
|
+
`${combinedText}\n\n` +
|
|
338
|
+
`Rules:\n` +
|
|
339
|
+
`- Answer ONLY from the provided sources; do not use outside knowledge.\n` +
|
|
340
|
+
`- Read the sources carefully before concluding anything is missing from them.\n` +
|
|
341
|
+
`- Cite the exact source URL(s) you used.\n` +
|
|
342
|
+
`- If, after careful reading, the sources genuinely do not contain the answer, say so explicitly.\n` +
|
|
343
|
+
`- NEVER invent or guess a URL; cite only URLs that appear in the sources above.\n\n` +
|
|
290
344
|
`Provide a clear, concise answer.`;
|
|
291
345
|
|
|
292
346
|
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) {
|
|
@@ -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
|
|