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 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.1
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.1",
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.1",
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("Content for auto-detection of language and locale"),
1303
- url: z.string().url().optional().describe("URL for geo-blocking detection or auto-detection"),
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 || !params.url) throw new Error('content and url are required for auto_detect operation');
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: parseInt(opts.maxPages, 10) }
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 (one per line, no bullets):\n\n${prompt}`;
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').map(l => l.replace(/^[-*\d.)\s]+/, '').trim()).filter(Boolean);
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
- const combinedText = evidence.map(e => `--- Source: ${e.url} ---\n${e.text}`).join('\n\n');
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. Based on the sources below, answer this task:\n\n` +
321
+ `You are a research assistant. Answer this task using ONLY the sources below:\n\n` +
288
322
  `Task: ${prompt}\n\n` +
289
- `${truncate(combinedText, 12000)}\n\n` +
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
- textChanges.push({
748
- type: 'word_diff',
749
- changes: wordDiff.filter(part => part.added || part.removed)
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
- return (tagSimilarity + hierarchySimilarity) / 2;
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
- const baselineTags = baselineElements.map(el => el.tag);
912
- const currentTags = currentElements.map(el => el.tag);
913
-
914
- const intersection = baselineTags.filter(tag => currentTags.includes(tag));
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
- if (href && (text.includes('api') || text.includes('developer'))) {
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 - Source 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
- 'JP': 'YYYY/MM/DD'
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/DD/YYYY';
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.confidence += detectedLang.confidence / 100 * 0.2;
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
- bestMatch = { language: lang, confidence: Math.min(95, matches * 5) };
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
- const urlObj = new URL(url);
1368
- const tldMatch = urlObj.hostname.match(/\.([a-z]{2})$/);
1369
- if (tldMatch) {
1370
- const tld = tldMatch[1].toUpperCase();
1371
- if (SUPPORTED_COUNTRIES[tld]) {
1372
- detection.detectedCountry = tld;
1373
- detection.evidence.push(`TLD suggests country: ${tld}`);
1374
- detection.confidence += 0.2;
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
 
@@ -1,18 +1,28 @@
1
1
  /**
2
2
  * MonitorStore — disk persistence for scheduled change-monitors.
3
3
  *
4
- * One JSON file per monitor under ./monitors/<id>.json. Mirrors JobManager's
5
- * persistence *pattern* (mkdir-recursive, per-file JSON, randomUUID, load-on-
6
- * start) but deliberately omits TTL/eviction scheduled monitors are long-lived
7
- * and must never be auto-expired.
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 = './monitors' } = {}) {
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
- page = await this._stealthBrowser.newPage();
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 import('camoufox');
2037
+ await this._load();
2007
2038
  return true;
2008
- } catch {
2009
- return false;
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 import('camoufox');
2017
- } catch {
2018
- throw new Error(
2019
- 'camoufox is not installed. Run: npm install camoufox. Note: camoufox is MIT-licensed and requires Firefox to be installed.'
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
- // camoufox API mirrors playwright — returns a Browser object
2024
- const browser = await (camoufox.launch || camoufox.default?.launch)({
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
- return browser;
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