crawlforge-mcp-server 5.0.2 → 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.2
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.2",
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
@@ -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) {
@@ -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
 
@@ -448,8 +448,9 @@ export class ContentAnalyzer {
448
448
 
449
449
  /**
450
450
  * Create extractive summary by scoring pre-split sentences via word
451
- * frequency (Luhn-style salience), tokenized with compromise, plus a small
452
- * positional bonus for leading/closing sentences. Selects the top N and
451
+ * frequency (Luhn-style salience), tokenized with compromise, plus a
452
+ * lead-biased positional bonus (first sentence strongly favored) and a
453
+ * brevity penalty for very short sentences. Selects the top N and
453
454
  * restores original document order.
454
455
  * @param {string[]} sentences - Sentences in original document order
455
456
  * @param {number} targetSentences - Number of sentences to select
@@ -474,11 +475,22 @@ export class ContentAnalyzer {
474
475
 
475
476
  const scored = sentences.map((sentence, index) => {
476
477
  const words = sentenceWords[index];
478
+ // Very short sentences carry little information yet the frequency
479
+ // average below inflates them (few words, each high-frequency), so
480
+ // dampen their word score (length penalty, Nobata & Sekine 2004).
481
+ const brevityPenalty = words.length < 5 ? 0.5 : 1;
477
482
  const wordScore = words.length > 0
478
- ? words.reduce((sum, w) => sum + freq[w] / maxFreq, 0) / words.length
483
+ ? (words.reduce((sum, w) => sum + freq[w] / maxFreq, 0) / words.length) * brevityPenalty
479
484
  : 0;
480
- // Leading/closing sentences tend to carry more salience in prose.
481
- const positionScore = (index === 0 || index === sentences.length - 1) ? 0.15 : 0;
485
+ // Lead bias (Edmundson position method): document-leading sentences
486
+ // carry the definitional payload Lead-3 remains a near-SOTA
487
+ // extractive baseline — so favor the first sentence strongly, decay
488
+ // over the next two, and keep only a token bonus for the closer.
489
+ let positionScore = 0;
490
+ if (index === 0) positionScore = 0.4;
491
+ else if (index === 1) positionScore = 0.15;
492
+ else if (index === 2) positionScore = 0.08;
493
+ else if (index === sentences.length - 1) positionScore = 0.05;
482
494
  return { sentence, index, score: wordScore + positionScore };
483
495
  });
484
496
 
@@ -508,17 +520,23 @@ export class ContentAnalyzer {
508
520
  const phraseCount = {};
509
521
 
510
522
  allPhrases.forEach(phrase => {
511
- const cleaned = phrase.toLowerCase().trim();
512
- if (cleaned.length > 2) {
523
+ // Strip edge punctuation and skip phrases made only of stop words
524
+ const cleaned = phrase.toLowerCase().trim().replace(/^[^a-z0-9]+|[^a-z0-9]+$/g, '');
525
+ const words = cleaned.split(/\s+/);
526
+ if (cleaned.length > 2 && words.some(w => !this.isStopWord(w))) {
513
527
  phraseCount[cleaned] = (phraseCount[cleaned] || 0) + 1;
514
528
  }
515
529
  });
516
530
 
517
- // Score and rank topics
531
+ // Score and rank topics. Confidence is the phrase frequency relative to
532
+ // the MOST frequent phrase (RAKE-style relative salience) — the old
533
+ // frequency/totalPhrases normalization collapsed toward 0 on long
534
+ // documents, so every topic failed minConfidence and topics came back [].
535
+ const maxFreq = Math.max(1, ...Object.values(phraseCount));
518
536
  const topics = Object.entries(phraseCount)
519
537
  .map(([topic, frequency]) => ({
520
538
  topic,
521
- confidence: Math.min(1, frequency / Math.max(allPhrases.length, 1)),
539
+ confidence: Math.round((frequency / maxFreq) * 100) / 100,
522
540
  keywords: topic.split(' ').filter(w => w.length > 2)
523
541
  }))
524
542
  .filter(topic => topic.confidence >= options.minConfidence)
@@ -543,14 +561,53 @@ export class ContentAnalyzer {
543
561
  try {
544
562
  const doc = nlp(text);
545
563
 
546
- const people = doc.people().out('array');
547
- const places = doc.places().out('array');
548
- const organizations = doc.organizations().out('array');
564
+ // compromise's out('array') keeps adjoining punctuation ("Craigslist.",
565
+ // "United States,") — clean edges, drop bare stopword/pronoun tokens,
566
+ // and dedupe case-insensitively keeping the first casing seen.
567
+ const clean = (arr) => this.dedupeEntities(
568
+ arr.map(e => this.cleanEntityText(e))
569
+ .filter(e => e.length > 1
570
+ && !this.isBareStopword(e)
571
+ && !/^(?:inc|corp|ltd|co|llc)\.?$/i.test(e)) // bare corporate-suffix fragment
572
+ );
573
+
574
+ let people = clean(doc.people().out('array'));
575
+ let places = clean(doc.places().out('array'));
576
+ let organizations = clean(doc.organizations().out('array'));
549
577
  // .dates() needs the compromise-dates plugin (not installed) and threw,
550
578
  // aborting ALL entity extraction; #Date+ tag matching is core compromise.
551
- const dates = doc.match('#Date+').out('array');
552
- const money = doc.money().out('array');
553
- let other = doc.topics().out('array').slice(0, 10);
579
+ const dates = clean(doc.match('#Date+').out('array'));
580
+ const money = clean(doc.money().out('array'));
581
+ let other = clean(doc.topics().out('array').slice(0, 10))
582
+ .filter(e => !this.isSentenceInitialArtifact(e, text));
583
+
584
+ // "X v. Y" fragments are legal-case citations, not people/places/orgs —
585
+ // reclassify them under "other".
586
+ const legalCases = [];
587
+ const extractCases = (list) => list.filter(e => {
588
+ if (this.isLegalCaseFragment(e)) {
589
+ legalCases.push(e);
590
+ return false;
591
+ }
592
+ return true;
593
+ });
594
+ people = extractCases(people);
595
+ places = extractCases(places);
596
+ organizations = extractCases(organizations);
597
+
598
+ // A bare ALL-CAPS token (UNIX, DOM) is not enough evidence for an
599
+ // organization — demote to "other" unless the text corroborates it
600
+ // (corporate suffix or organization-headed alias definition).
601
+ const demotedAcronyms = [];
602
+ organizations = organizations.filter(e => {
603
+ if (/^[A-Z]{2,}$/.test(e) && !this.hasOrganizationEvidence(e, text)) {
604
+ demotedAcronyms.push(e);
605
+ return false;
606
+ }
607
+ return true;
608
+ });
609
+
610
+ other = [...other, ...legalCases, ...demotedAcronyms];
554
611
 
555
612
  // Supplement with capitalized proper nouns that compromise may miss
556
613
  // (technology names, product names, etc.)
@@ -558,15 +615,27 @@ export class ContentAnalyzer {
558
615
  ...people, ...places, ...organizations, ...other
559
616
  ].map(e => e.toLowerCase()));
560
617
 
561
- const properNouns = text.match(/\b[A-Z][a-zA-Z.]+(?:\s+[A-Z][a-zA-Z.]+)*/g) || [];
562
- const supplemental = [...new Set(properNouns)]
563
- .filter(n => !existingEntities.has(n.toLowerCase()) && n.length > 1)
618
+ const properNouns = (text.match(/\b[A-Z][a-zA-Z.]+(?:\s+[A-Z][a-zA-Z.]+)*/g) || [])
619
+ // Split matches that crossed a sentence boundary ("XPath. In") — a
620
+ // period after a lowercase letter followed by a capital is a sentence
621
+ // end, while abbreviations ("U.S. Holdings") stay intact.
622
+ .flatMap(n => n.split(/(?<=[a-z]\.)\s+(?=[A-Z])/));
623
+ const supplemental = this.dedupeEntities(properNouns.map(n => this.cleanEntityText(n)))
624
+ .filter(n => n.length > 1
625
+ && !existingEntities.has(n.toLowerCase())
626
+ && !this.isSentenceInitialArtifact(n, text))
564
627
  .slice(0, 10);
565
628
 
566
629
  if (supplemental.length > 0) {
567
630
  other = [...other, ...supplemental].slice(0, 15);
568
631
  }
569
632
 
633
+ // Keep "other" free of entities already classified more specifically.
634
+ const classified = new Set(
635
+ [...people, ...places, ...organizations, ...dates, ...money].map(e => e.toLowerCase())
636
+ );
637
+ other = this.dedupeEntities(other).filter(e => !classified.has(e.toLowerCase()));
638
+
570
639
  const allEntities = [...people, ...places, ...organizations, ...dates, ...money, ...other];
571
640
  const uniqueEntities = new Set(allEntities.map(e => e.toLowerCase()));
572
641
 
@@ -828,6 +897,120 @@ export class ContentAnalyzer {
828
897
  return 'Very Difficult';
829
898
  }
830
899
 
900
+ /**
901
+ * Strip leading/trailing punctuation from an entity string while preserving
902
+ * trailing periods on abbreviations ("Inc.", "U.S.") and internal
903
+ * punctuation ("Home.dk", "Bidder's Edge").
904
+ * @param {string} raw - Raw entity string
905
+ * @returns {string} - Cleaned entity string
906
+ */
907
+ cleanEntityText(raw) {
908
+ let entity = String(raw).trim();
909
+ let previous;
910
+ do {
911
+ previous = entity;
912
+ entity = entity.replace(/^[\s"'‘’“”`([{<,;:!?«»–—-]+/, '');
913
+ entity = entity.replace(/[\s"'‘’“”`)\]}>,;:!?«»–—-]+$/, '');
914
+ if (entity.endsWith('.')) {
915
+ const lastToken = entity.split(/\s+/).pop();
916
+ const isAbbreviation = /^(?:[A-Za-z]\.)+$/.test(lastToken)
917
+ || /^(?:inc|corp|ltd|co|llc|jr|sr|st|mr|mrs|ms|dr|no|vs?)\.$/i.test(lastToken);
918
+ if (!isAbbreviation) {
919
+ entity = entity.replace(/\.+$/, '');
920
+ }
921
+ }
922
+ } while (entity !== previous);
923
+ return entity;
924
+ }
925
+
926
+ /**
927
+ * Deduplicate entities case-insensitively, keeping the first casing seen
928
+ * @param {string[]} entities - Entity strings
929
+ * @returns {string[]} - Deduplicated entity strings
930
+ */
931
+ dedupeEntities(entities) {
932
+ const seen = new Set();
933
+ const result = [];
934
+ for (const entity of entities) {
935
+ const key = entity.toLowerCase();
936
+ if (!seen.has(key)) {
937
+ seen.add(key);
938
+ result.push(entity);
939
+ }
940
+ }
941
+ return result;
942
+ }
943
+
944
+ /**
945
+ * Check whether an entity is a single bare stop word/pronoun token
946
+ * @param {string} entity - Cleaned entity string
947
+ * @returns {boolean} - True if a bare stop word
948
+ */
949
+ isBareStopword(entity) {
950
+ return !entity.includes(' ') && this.isStopWord(entity.toLowerCase());
951
+ }
952
+
953
+ /**
954
+ * True when a single capitalized token is likely just a sentence-initial
955
+ * common word ("While", "It", "Once"): it is a stop word, the same word
956
+ * also appears in lowercase elsewhere in the text, or it is only ever
957
+ * capitalized at the start of a sentence (real proper nouns keep their
958
+ * capital mid-sentence).
959
+ * @param {string} entity - Cleaned entity string
960
+ * @param {string} text - Full source text
961
+ * @returns {boolean} - True if likely a sentence-initial artifact
962
+ */
963
+ isSentenceInitialArtifact(entity, text) {
964
+ if (entity.includes(' ')) return false;
965
+ const lower = entity.toLowerCase();
966
+ if (this.isStopWord(lower)) return true;
967
+ if (!/^[A-Z][a-z]+$/.test(entity)) return false;
968
+
969
+ // Word also appears in lowercase → ordinary word, capitalized only by position
970
+ if (new RegExp(`(?:^|[^A-Za-z])${lower}(?:[^A-Za-z]|$)`).test(text)) return true;
971
+
972
+ // Keep only if at least one occurrence is NOT at a sentence start
973
+ const occurrence = new RegExp(`\\b${entity}\\b`, 'g');
974
+ let match;
975
+ while ((match = occurrence.exec(text)) !== null) {
976
+ const before = text.slice(0, match.index).replace(/[\s"'‘’“”()[\]]+$/, '');
977
+ if (before.length > 0 && !/[.!?:]$/.test(before)) {
978
+ return false;
979
+ }
980
+ }
981
+ return true;
982
+ }
983
+
984
+ /**
985
+ * Check whether an entity is an "X v. Y" / "X vs. Y" legal-case fragment
986
+ * @param {string} entity - Cleaned entity string
987
+ * @returns {boolean} - True if a legal-case citation fragment
988
+ */
989
+ isLegalCaseFragment(entity) {
990
+ return /\s+vs?\.?\s+/i.test(entity);
991
+ }
992
+
993
+ /**
994
+ * Corroborating evidence that an ALL-CAPS token really is an organization:
995
+ * a corporate suffix follows it ("IBM Corp"), or it is introduced as the
996
+ * alias of an organization-headed name ("American Airlines (AA)",
997
+ * "French Data Protection Authority (CNIL)").
998
+ * @param {string} acronym - ALL-CAPS candidate (already /^[A-Z]{2,}$/)
999
+ * @param {string} text - Full source text
1000
+ * @returns {boolean} - True if the text corroborates the org classification
1001
+ */
1002
+ hasOrganizationEvidence(acronym, text) {
1003
+ const corporateSuffix = new RegExp(
1004
+ `\\b${acronym},?\\s+(?:Inc|Corp|Corporation|Company|Co|Ltd|LLC|Group)\\.?(?:[^a-zA-Z]|$)`
1005
+ );
1006
+ if (corporateSuffix.test(text)) return true;
1007
+
1008
+ const orgHeadedAlias = new RegExp(
1009
+ `\\b(?:Airlines?|Authority|Agency|Association|Bureau|Commission|Committee|Corporation|Company|Council|Court|Foundation|Group|Institute|Institution|Organi[sz]ation|Press|Society|Union|University)\\s*\\(["'“‘]?${acronym}["'”’]?\\)`
1010
+ );
1011
+ return orgHeadedAlias.test(text);
1012
+ }
1013
+
831
1014
  /**
832
1015
  * Check if word is a stop word
833
1016
  * @param {string} word - Word to check
@@ -73,7 +73,11 @@ const ScrollActionSchema = BaseActionSchema.extend({
73
73
  direction: z.enum(['up', 'down', 'left', 'right']).default('down'),
74
74
  distance: z.number().min(0).default(100),
75
75
  smooth: z.boolean().default(true),
76
- toElement: z.string().optional()
76
+ toElement: z.string().optional(),
77
+ // Absolute scroll-to coordinates (window.scrollTo). When present they take
78
+ // precedence over direction/distance — see ActionExecutor's ScrollActionSchema.
79
+ x: z.number().min(0).optional(),
80
+ y: z.number().min(0).optional()
77
81
  });
78
82
 
79
83
  const ScreenshotActionSchema = BaseActionSchema.extend({
@@ -8,6 +8,7 @@ import { load } from 'cheerio';
8
8
  import { config as appConfig } from '../../../constants/config.js';
9
9
  import { ssrfGuard, isSsrfError } from '../../../utils/ssrfGuard.js';
10
10
  import { throttleHost } from '../../../utils/hostRateLimiter.js';
11
+ import { htmlToMarkdown } from '../../../utils/htmlToMarkdown.js';
11
12
 
12
13
  const USER_AGENT = 'MCP-WebScraper-BatchTool/1.0.0';
13
14
 
@@ -165,7 +166,6 @@ function generateFormats($, html, formats) {
165
166
  }
166
167
 
167
168
  function buildMarkdown($) {
168
- let md = '';
169
169
  const title = $('title').text().trim();
170
170
 
171
171
  const selectors = ['article', 'main', '.content', '#content', '.post-content', '.entry-content'];
@@ -176,18 +176,17 @@ function buildMarkdown($) {
176
176
  }
177
177
  if (!$body || $body.length === 0) $body = $('body');
178
178
 
179
+ // Full-fidelity conversion via the shared Turndown helper (same converter
180
+ // the unified `scrape` tool uses). The previous hand-rolled h1–h3/p/li walk
181
+ // silently dropped any text living outside those tags (e.g. quotes in
182
+ // <span>/<small> on quotes.toscrape.com).
183
+ let md = htmlToMarkdown($.html($body));
184
+
179
185
  // C3: de-dup title — only emit the <title> heading if the page has no <h1>
180
186
  // or if the first <h1> text differs from the <title> text (case-insensitive).
181
187
  const firstH1 = $body.find('h1').first().text().trim();
182
188
  const titleDuplicated = firstH1 && firstH1.toLowerCase() === title.toLowerCase();
183
- if (title && !titleDuplicated) md += `# ${title}\n\n`;
184
-
185
- $body.find('h1').each((_, el) => { md += `# ${$(el).text().trim()}\n\n`; });
186
- $body.find('h2').each((_, el) => { md += `## ${$(el).text().trim()}\n\n`; });
187
- $body.find('h3').each((_, el) => { md += `### ${$(el).text().trim()}\n\n`; });
188
- $body.find('p').each((_, el) => { const t = $(el).text().trim(); if (t) md += `${t}\n\n`; });
189
- $body.find('ul li').each((_, el) => { md += `- ${$(el).text().trim()}\n`; });
190
- $body.find('ol li').each((_, el) => { md += `1. ${$(el).text().trim()}\n`; });
189
+ if (title && !titleDuplicated) md = `# ${title}\n\n${md}`;
191
190
 
192
191
  return md.trim();
193
192
  }
@@ -170,6 +170,9 @@ export function buildRecordedEntry(action, timestampMsSinceStart) {
170
170
  if (action.value !== undefined) entry.value = action.value;
171
171
  if (action.direction !== undefined) entry.direction = action.direction;
172
172
  if (action.distance !== undefined) entry.distance = action.distance;
173
+ // scroll: absolute scroll-to coordinates
174
+ if (action.x !== undefined) entry.x = action.x;
175
+ if (action.y !== undefined) entry.y = action.y;
173
176
  if (action.description !== undefined) entry.description = action.description;
174
177
  // executeJavaScript actions require `script` to replay (ActionExecutor's
175
178
  // ActionChainSchema rejects the entry otherwise) — preserve it.
@@ -89,6 +89,13 @@ export async function extractTextHandler({ url, remove_scripts, remove_styles, o
89
89
  if (remove_scripts !== false) $('script').remove();
90
90
  if (remove_styles !== false) $('style').remove();
91
91
 
92
+ // <noscript> contents are parsed as raw TEXT when scripting is enabled
93
+ // (cheerio/parse5 default, per the HTML spec), so leaving them in leaks
94
+ // literal markup into the extracted text — e.g. Wikipedia's
95
+ // Special:CentralAutoLogin 1x1 <img> tracking pixel. Browsers with JS
96
+ // enabled never render noscript content, so always strip it.
97
+ $('noscript').remove();
98
+
92
99
  $('nav, header, footer, aside, .advertisement, .ad, .sidebar').remove();
93
100
 
94
101
  const result = {
@@ -1,7 +1,9 @@
1
1
  import { z } from 'zod';
2
+ import { load } from 'cheerio';
2
3
  import { LLMsTxtAnalyzer } from '../../core/LLMsTxtAnalyzer.js';
3
4
  import { Logger } from '../../utils/Logger.js';
4
5
  import { getBaseUrl } from '../../utils/urlNormalizer.js';
6
+ import { safeFetch } from '../../utils/ssrfGuard.js';
5
7
 
6
8
  const logger = new Logger('GenerateLLMsTxtTool');
7
9
 
@@ -79,6 +81,14 @@ export class GenerateLLMsTxtTool {
79
81
  });
80
82
  const analysis = await analyzer.analyzeWebsite(url, analysisOptions);
81
83
 
84
+ // Capture the homepage's <title>/h1 and meta/og description so the
85
+ // spec-compliant output can carry a real site summary and a named Home
86
+ // link (llmstxt.org) instead of boilerplate. Best-effort: null on
87
+ // failure, in which case the generic fallbacks below apply.
88
+ if (!outputOptions.robotsStyle) {
89
+ analysis.homePage = await this.fetchHomePageMetadata(baseUrl);
90
+ }
91
+
82
92
  // Step 2: Generate LLMs.txt Content
83
93
  const llmsTxtContent = this.generateLLMsTxt(analysis, outputOptions, complianceLevel);
84
94
 
@@ -156,8 +166,10 @@ export class GenerateLLMsTxtTool {
156
166
  lines.push(`# ${title}`);
157
167
  lines.push('');
158
168
 
159
- // Blockquote summary (required by spec)
160
- const summary = `Site map and key resources for ${baseUrl}, generated to help LLMs locate relevant content.`;
169
+ // Blockquote summary (required by spec). Prefer the site's own meta /
170
+ // og:description captured from the homepage over generic boilerplate.
171
+ const summary = analysis.homePage?.description
172
+ || `Site map and key resources for ${baseUrl}, generated to help LLMs locate relevant content.`;
161
173
  lines.push(`> ${summary}`);
162
174
  lines.push('');
163
175
 
@@ -177,13 +189,16 @@ export class GenerateLLMsTxtTool {
177
189
  lines.push('');
178
190
  }
179
191
 
180
- // Helper: emit a "## Section" with a list of [name](url) links.
192
+ // Helper: emit a "## Section" with a list of [name](url) links. Link
193
+ // names prefer the page's actual <title> captured during analysis;
194
+ // otherwise a humanized full path ("/tag/abilities/page/1" ->
195
+ // "Tag: abilities — page 1") — never a bare trailing segment like "1".
196
+ const pageTitles = this.collectPageTitles(analysis);
181
197
  const linkLabel = (u) => {
198
+ const captured = pageTitles.get(this.normalizeTitleKey(u));
199
+ if (captured) return captured;
182
200
  try {
183
- const p = new URL(u).pathname.replace(/\/+$/, '');
184
- if (!p || p === '') return 'Home';
185
- const seg = p.split('/').filter(Boolean).pop() || p;
186
- return seg.replace(/[-_]/g, ' ').replace(/\.[a-z0-9]+$/i, '').trim() || p;
201
+ return this.humanizePath(new URL(u).pathname);
187
202
  } catch {
188
203
  return u;
189
204
  }
@@ -247,6 +262,131 @@ export class GenerateLLMsTxtTool {
247
262
  return lines.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd() + '\n';
248
263
  }
249
264
 
265
+ /**
266
+ * Build a URL -> page title map from titles captured during analysis
267
+ * (classifyPage stores each public page's <title>; the homepage fetch names
268
+ * the site root). A title shared verbatim by multiple URLs is dropped — it
269
+ * cannot identify a specific page, so those links fall back to the
270
+ * humanized path instead.
271
+ */
272
+ collectPageTitles(analysis) {
273
+ const titles = new Map();
274
+ const counts = new Map();
275
+ const add = (url, title) => {
276
+ if (!url || typeof title !== 'string') return;
277
+ const clean = title.replace(/\s+/g, ' ').trim();
278
+ if (!clean) return;
279
+ const key = this.normalizeTitleKey(url);
280
+ const prev = titles.get(key);
281
+ if (prev === clean) return;
282
+ if (prev) counts.set(prev, counts.get(prev) - 1);
283
+ titles.set(key, clean);
284
+ counts.set(clean, (counts.get(clean) || 0) + 1);
285
+ };
286
+
287
+ for (const entries of Object.values(analysis.contentTypes || {})) {
288
+ if (!Array.isArray(entries)) continue;
289
+ for (const entry of entries) {
290
+ add(entry?.url, entry?.metadata?.title);
291
+ }
292
+ }
293
+ if (analysis.homePage?.title && analysis.metadata?.baseUrl) {
294
+ add(analysis.metadata.baseUrl, analysis.homePage.title);
295
+ }
296
+
297
+ for (const [key, title] of titles) {
298
+ if (counts.get(title) > 1) titles.delete(key);
299
+ }
300
+ return titles;
301
+ }
302
+
303
+ normalizeTitleKey(url) {
304
+ try {
305
+ return new URL(url).toString();
306
+ } catch {
307
+ return url;
308
+ }
309
+ }
310
+
311
+ /**
312
+ * Humanize a full URL path into a readable label:
313
+ * "/" -> "Home"
314
+ * "/login" -> "Login"
315
+ * "/author/Albert-Einstein" -> "Author: Albert Einstein"
316
+ * "/tag/abilities/page/1" -> "Tag: abilities — page 1"
317
+ * A trailing pagination segment ("page/2", "page-2", bare "2") is folded
318
+ * into "page N" so a link is never labeled by a bare number.
319
+ */
320
+ humanizePath(pathname) {
321
+ let raw = pathname;
322
+ try { raw = decodeURIComponent(pathname); } catch { /* keep encoded */ }
323
+ const segments = raw.split('/').filter(Boolean);
324
+ if (segments.length === 0) return 'Home';
325
+
326
+ // Strip a file extension from the last segment, then de-slug.
327
+ segments[segments.length - 1] = segments[segments.length - 1].replace(/\.[a-z0-9]+$/i, '');
328
+ const clean = segments.map((s) => s.replace(/[-_]+/g, ' ').trim()).filter(Boolean);
329
+ if (clean.length === 0) return 'Home';
330
+
331
+ let pageNum = null;
332
+ const last = clean[clean.length - 1];
333
+ const pageMatch = last.match(/^page\s*(\d+)$/i);
334
+ if (pageMatch) {
335
+ pageNum = pageMatch[1];
336
+ clean.pop();
337
+ } else if (/^\d+$/.test(last)) {
338
+ pageNum = last;
339
+ clean.pop();
340
+ if (clean.length > 0 && /^pages?$/i.test(clean[clean.length - 1])) {
341
+ clean.pop();
342
+ }
343
+ }
344
+ if (clean.length === 0) return pageNum ? `Page ${pageNum}` : 'Home';
345
+
346
+ const cap = (s) => s.charAt(0).toUpperCase() + s.slice(1);
347
+ let label = clean.length === 1
348
+ ? cap(clean[0])
349
+ : `${cap(clean[0])}: ${clean.slice(1).join(' / ')}`;
350
+ if (pageNum) label += ` — page ${pageNum}`;
351
+ return label;
352
+ }
353
+
354
+ /**
355
+ * Fetch the site's homepage once and extract naming metadata for the spec
356
+ * output. Best-effort: returns null on any failure.
357
+ */
358
+ async fetchHomePageMetadata(baseUrl) {
359
+ const controller = new AbortController();
360
+ const timeoutId = setTimeout(() => controller.abort(), Math.min(this.options.timeout, 10000));
361
+ try {
362
+ const response = await safeFetch(baseUrl, {
363
+ signal: controller.signal,
364
+ headers: { 'User-Agent': this.options.userAgent }
365
+ });
366
+ if (!response.ok) return null;
367
+ return this.extractHomePageMetadata(await response.text());
368
+ } catch {
369
+ return null;
370
+ } finally {
371
+ clearTimeout(timeoutId);
372
+ }
373
+ }
374
+
375
+ /**
376
+ * Extract { title, description } from homepage HTML. Title prefers <title>
377
+ * then the first <h1>; description prefers meta[name="description"] then
378
+ * og:description. Returns null when neither is present.
379
+ */
380
+ extractHomePageMetadata(html) {
381
+ const $ = load(html);
382
+ const clean = (s) => (s || '').replace(/\s+/g, ' ').trim();
383
+ const title = clean($('title').first().text()) || clean($('h1').first().text());
384
+ const description = clean($('meta[name="description"]').attr('content'))
385
+ || clean($('meta[property="og:description"]').attr('content'));
386
+ if (!title && !description) return null;
387
+ return { title, description };
388
+ }
389
+
250
390
  /**
251
391
  * Generate legacy robots.txt-style content (opt-in via outputOptions.robotsStyle).
252
392
  */
@@ -183,8 +183,11 @@ const TEMPLATES = [
183
183
  site: $row.find('.sitebit a').text().trim() || null,
184
184
  score: $score.text().replace(' points', '').trim() || null,
185
185
  author: $subtext.find('.hnuser').text().trim() || null,
186
- posted: $subtext.find('.age a').attr('href') || null,
187
- comments: $subtext.find('a[href*="item"]').last().text().trim() || null
186
+ // ".age a" wraps the relative age string ("3 hours ago"); its href is the item permalink.
187
+ posted: $subtext.find('.age a').text().trim() || null,
188
+ // The comments link is also an item?id= link, so exclude the age anchor.
189
+ // Job posts have no comments link at all -> null.
190
+ comments: $subtext.find('a[href*="item"]').not('.age a').last().text().trim() || null
188
191
  });
189
192
  });
190
193
  return { stories: stories.slice(0, 30), scraped_at: new Date().toISOString() };
@@ -68,7 +68,10 @@ export class TrackChangesTool extends EventEmitter {
68
68
  // Scheduled-monitor subsystem (timers are NOT started here — only the
69
69
  // single server-owned instance calls startScheduler()).
70
70
  this._mcpServer = null;
71
- this.monitorStore = new MonitorStore({ storageDir: this.options.monitorStorageDir || './monitors' });
71
+ // No storageDir fallback here: MonitorStore itself defaults to
72
+ // ~/.crawlforge/monitors (cwd-independent, like snapshotStorageDir above)
73
+ // and runs its legacy ./monitors migration only on that default path.
74
+ this.monitorStore = new MonitorStore({ storageDir: this.options.monitorStorageDir });
72
75
  this.scheduler = new MonitorScheduler({ tool: this, store: this.monitorStore });
73
76
 
74
77
  // Wired synchronously (no I/O) so no 'error' event emitted by
@@ -200,14 +203,18 @@ export class TrackChangesTool extends EventEmitter {
200
203
  snapshotInfo = await this.snapshotManager.storeSnapshot(url, sourceContent, { ...fetchMeta, baseline: true, trackingOptions }, { enableCompression: storageOptions.compressionEnabled });
201
204
  }
202
205
 
206
+ // ChangeTracker.createBaseline returns a summary ({contentHash, sections,
207
+ // elements, createdAt, ...}), NOT the internal baseline object — reading
208
+ // baseline.analysis?./baseline.timestamp here always yielded
209
+ // undefined/0/0/undefined regardless of the page.
203
210
  return {
204
211
  success: true, operation: 'create_baseline', url,
205
212
  baseline: {
206
213
  version: baseline.version,
207
- contentHash: baseline.analysis?.hashes?.page,
208
- sections: Object.keys(baseline.analysis?.hashes?.sections || {}).length,
209
- elements: Object.keys(baseline.analysis?.hashes?.elements || {}).length,
210
- createdAt: baseline.timestamp,
214
+ contentHash: baseline.contentHash,
215
+ sections: baseline.sections,
216
+ elements: baseline.elements,
217
+ createdAt: baseline.createdAt,
211
218
  options: trackingOptions
212
219
  },
213
220
  snapshot: snapshotInfo, timestamp: Date.now()