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 +1 -1
- package/package.json +1 -1
- package/server.js +5 -3
- package/src/cli/commands/llmstxt.js +9 -2
- package/src/core/ActionExecutor.js +20 -1
- package/src/core/AgentOrchestrator.js +44 -5
- package/src/core/ChangeTracker.js +28 -16
- package/src/core/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/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.3
|
|
66
66
|
|
|
67
67
|
## Development Commands
|
|
68
68
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "crawlforge-mcp-server",
|
|
3
|
-
"version": "5.0.
|
|
3
|
+
"version": "5.0.3",
|
|
4
4
|
"mcpName": "io.github.mysleekdesigns/crawlforge-mcp-server",
|
|
5
5
|
"description": "CrawlForge MCP Server - Professional Model Context Protocol server with 27 web scraping, crawling, deep-research, and autonomous-extraction tools. Returns clean Markdown and structured JSON for Claude, Cursor, and any MCP client. Defaults to local Ollama for LLM extraction (no API key needed); OpenAI/Anthropic available as opt-in. Includes a unified multi-format scrape tool, an autonomous agent, pre-built site templates, and Camoufox stealth browsing.",
|
|
6
6
|
"main": "server.js",
|
package/server.js
CHANGED
|
@@ -794,6 +794,8 @@ registerToolIfEnabled("scrape_with_actions", {
|
|
|
794
794
|
distance: z.number().min(0).optional().describe("scroll: pixels to scroll"),
|
|
795
795
|
smooth: z.boolean().optional().describe("scroll: smooth scrolling"),
|
|
796
796
|
toElement: z.string().optional().describe("scroll: selector to scroll to"),
|
|
797
|
+
x: z.number().min(0).optional().describe("scroll: absolute X coordinate to scroll to (window.scrollTo; with y, takes precedence over direction/distance)"),
|
|
798
|
+
y: z.number().min(0).optional().describe("scroll: absolute Y coordinate to scroll to (window.scrollTo; with x, takes precedence over direction/distance)"),
|
|
797
799
|
// screenshot
|
|
798
800
|
fullPage: z.boolean().optional().describe("screenshot: capture full page"),
|
|
799
801
|
quality: z.number().min(0).max(100).optional().describe("screenshot: jpeg quality"),
|
|
@@ -1299,8 +1301,8 @@ registerToolIfEnabled("localization", {
|
|
|
1299
1301
|
extraHTTPHeaders: z.record(z.string()).optional(),
|
|
1300
1302
|
userAgent: z.string().optional()
|
|
1301
1303
|
}).optional().describe("Browser context options for locale emulation"),
|
|
1302
|
-
content: z.string().optional().describe("
|
|
1303
|
-
url: z.string().url().optional().describe("URL for
|
|
1304
|
+
content: z.string().optional().describe("Page content (HTML or plain text) to analyze — required for auto_detect; no fetching is performed"),
|
|
1305
|
+
url: z.string().url().optional().describe("URL — required for handle_geo_blocking; for auto_detect it is optional metadata used only as a TLD country hint (the page is never fetched)"),
|
|
1304
1306
|
response: z.object({
|
|
1305
1307
|
status: z.number(),
|
|
1306
1308
|
body: z.string().optional(),
|
|
@@ -1336,7 +1338,7 @@ registerToolIfEnabled("localization", {
|
|
|
1336
1338
|
result = await localizationManager.detectGeoBlocking(params.url, params.response);
|
|
1337
1339
|
break;
|
|
1338
1340
|
case 'auto_detect':
|
|
1339
|
-
if (!params.content
|
|
1341
|
+
if (!params.content) throw new Error('content is required for auto_detect operation');
|
|
1340
1342
|
result = await localizationManager.autoDetectLocalization(params.content, params.url);
|
|
1341
1343
|
break;
|
|
1342
1344
|
case 'get_stats':
|
|
@@ -10,17 +10,24 @@ export function register(program) {
|
|
|
10
10
|
.command('llmstxt <url>')
|
|
11
11
|
.description('Generate llms.txt for a website (AI compliance file)')
|
|
12
12
|
.option('--include-full', 'Also generate llms-full.txt')
|
|
13
|
-
.option('--max-pages <n>', 'Maximum pages to analyze', '50')
|
|
13
|
+
.option('--max-pages <n>', 'Maximum pages to analyze, 10-500', '50')
|
|
14
14
|
.action(async (url, opts, cmd) => {
|
|
15
15
|
const globals = cmd.parent.opts();
|
|
16
16
|
const cliFlags = { json: globals.json, pretty: globals.pretty, quiet: globals.quiet };
|
|
17
|
+
// GenerateLLMsTxtSchema requires analysisOptions.maxPages between 10 and 500 —
|
|
18
|
+
// validate here so users get a clear message instead of a raw zod error.
|
|
19
|
+
const maxPages = parseInt(opts.maxPages, 10);
|
|
20
|
+
if (!Number.isInteger(maxPages) || maxPages < 10 || maxPages > 500) {
|
|
21
|
+
process.stderr.write('Error: --max-pages must be between 10 and 500\n');
|
|
22
|
+
process.exit(1);
|
|
23
|
+
}
|
|
17
24
|
const tool = new GenerateLLMsTxtTool(getToolConfig('generate_llms_txt'));
|
|
18
25
|
// GenerateLLMsTxtSchema expects: url, format ('both'|'llms-txt'|'llms-full-txt'),
|
|
19
26
|
// analysisOptions.maxPages.
|
|
20
27
|
await runTool(tool, {
|
|
21
28
|
url,
|
|
22
29
|
format: opts.includeFull ? 'both' : 'llms-txt',
|
|
23
|
-
analysisOptions: { maxPages
|
|
30
|
+
analysisOptions: { maxPages }
|
|
24
31
|
}, cliFlags);
|
|
25
32
|
});
|
|
26
33
|
}
|
|
@@ -72,7 +72,12 @@ const ScrollActionSchema = BaseActionSchema.extend({
|
|
|
72
72
|
direction: z.enum(['up', 'down', 'left', 'right']).default('down'),
|
|
73
73
|
distance: z.number().min(0).default(100),
|
|
74
74
|
smooth: z.boolean().default(true),
|
|
75
|
-
toElement: z.string().optional()
|
|
75
|
+
toElement: z.string().optional(),
|
|
76
|
+
// Absolute scroll-to coordinates (window.scrollTo). When present they take
|
|
77
|
+
// precedence over direction/distance. Matches the CLI guide's documented
|
|
78
|
+
// action-script format: { "type": "scroll", "x": 0, "y": 500 }.
|
|
79
|
+
x: z.number().min(0).optional(),
|
|
80
|
+
y: z.number().min(0).optional()
|
|
76
81
|
});
|
|
77
82
|
|
|
78
83
|
const ScreenshotActionSchema = BaseActionSchema.extend({
|
|
@@ -708,6 +713,20 @@ export class ActionExecutor extends EventEmitter {
|
|
|
708
713
|
return { scrolledToElement: action.toElement };
|
|
709
714
|
}
|
|
710
715
|
|
|
716
|
+
// Absolute scroll-to coordinates take precedence over direction/distance.
|
|
717
|
+
// window.scrollTo (not scrollBy/mouse.wheel, which are relative deltas) is
|
|
718
|
+
// the standard Playwright pattern for absolute positioning. A missing axis
|
|
719
|
+
// defaults to 0, matching the plain window.scrollTo(x, y) call form.
|
|
720
|
+
if (action.x !== undefined || action.y !== undefined) {
|
|
721
|
+
const targetX = action.x ?? 0;
|
|
722
|
+
const targetY = action.y ?? 0;
|
|
723
|
+
await page.evaluate(
|
|
724
|
+
([x, y]) => window.scrollTo(x, y),
|
|
725
|
+
[targetX, targetY]
|
|
726
|
+
);
|
|
727
|
+
return { scrolledTo: { x: targetX, y: targetY }, mode: 'absolute' };
|
|
728
|
+
}
|
|
729
|
+
|
|
711
730
|
if (humanBehaviorSimulator) {
|
|
712
731
|
// Use human-like scrolling behavior
|
|
713
732
|
await humanBehaviorSimulator.simulateScroll(page, {
|
|
@@ -175,9 +175,20 @@ export class AgentOrchestrator {
|
|
|
175
175
|
let searchQueries = [prompt]; // fallback: use raw prompt as query
|
|
176
176
|
try {
|
|
177
177
|
const planPrompt =
|
|
178
|
-
`Decompose this research task into 1-3 concise web search queries
|
|
178
|
+
`Decompose this research task into 1-3 concise web search queries. ` +
|
|
179
|
+
`Output ONLY the queries, one per line, no preamble or numbering:\n\n${prompt}`;
|
|
179
180
|
const { text } = await this._getSamplingClient().complete(planPrompt, { maxTokens: 200 });
|
|
180
|
-
const lines = text.split('\n')
|
|
181
|
+
const lines = text.split('\n')
|
|
182
|
+
.map(l => l.replace(/^[-*\d.)\s]+/, '').trim())
|
|
183
|
+
.filter(Boolean)
|
|
184
|
+
// Drop preamble/garbage lines ("Here are 3 concise web search queries:", …)
|
|
185
|
+
// so they never become search query #1 and poison the URL queue.
|
|
186
|
+
.filter(l =>
|
|
187
|
+
!l.endsWith(':') &&
|
|
188
|
+
!/^here (are|is)\b/i.test(l) &&
|
|
189
|
+
!/search quer(y|ies)/i.test(l) &&
|
|
190
|
+
l.length <= 100
|
|
191
|
+
);
|
|
181
192
|
if (lines.length > 0) searchQueries = lines.slice(0, 3);
|
|
182
193
|
} catch {
|
|
183
194
|
// Sampling unavailable — use raw prompt
|
|
@@ -185,6 +196,14 @@ export class AgentOrchestrator {
|
|
|
185
196
|
|
|
186
197
|
// ── GATHER (search) ───────────────────────────────────────────────────────
|
|
187
198
|
const urlQueue = [...seedUrls]; // start with any user-provided seeds
|
|
199
|
+
// Sites named directly in the prompt (full URLs or bare domains like
|
|
200
|
+
// "news.ycombinator.com") are the most authoritative sources for the task —
|
|
201
|
+
// queue them ahead of search results.
|
|
202
|
+
const namedSites = prompt.match(/https?:\/\/[^\s"'<>]+|(?<![\w.@/-])[a-z0-9][\w-]*(?:\.[a-z0-9][\w-]*)*\.[a-z]{2,}(?![\w-])/gi) || [];
|
|
203
|
+
for (const site of namedSites) {
|
|
204
|
+
const url = (/^https?:\/\//i.test(site) ? site : `https://${site}`).replace(/[.,;:!?)]+$/, '');
|
|
205
|
+
if (!urlQueue.includes(url)) urlQueue.push(url);
|
|
206
|
+
}
|
|
188
207
|
const searchResults = [];
|
|
189
208
|
|
|
190
209
|
if (urlQueue.length < capUrls) {
|
|
@@ -227,8 +246,10 @@ export class AgentOrchestrator {
|
|
|
227
246
|
const { textContent, finalUrl } = await fetchAndParse(url, { timeoutMs: 10000 });
|
|
228
247
|
if (!isRelevant(textContent, prompt)) continue;
|
|
229
248
|
step++;
|
|
249
|
+
const sr = searchResults.find(s => s.url === url);
|
|
230
250
|
evidence.push({
|
|
231
251
|
url: finalUrl,
|
|
252
|
+
title: sr ? sr.title : '',
|
|
232
253
|
text: truncate(textContent),
|
|
233
254
|
step
|
|
234
255
|
});
|
|
@@ -236,7 +257,20 @@ export class AgentOrchestrator {
|
|
|
236
257
|
}
|
|
237
258
|
|
|
238
259
|
// ── SHAPE ─────────────────────────────────────────────────────────────────
|
|
239
|
-
|
|
260
|
+
// Order evidence by simple relevance to the prompt (term overlap with
|
|
261
|
+
// url+title+text) instead of raw queue order, then give every source a
|
|
262
|
+
// per-source slice of the synthesis budget so no source is silently cut off.
|
|
263
|
+
const promptTerms = prompt.toLowerCase().split(/\s+/).filter(t => t.length > 3);
|
|
264
|
+
const orderedEvidence = evidence
|
|
265
|
+
.map(e => ({
|
|
266
|
+
...e,
|
|
267
|
+
_score: promptTerms.filter(t => `${e.url} ${e.title || ''} ${e.text}`.toLowerCase().includes(t)).length
|
|
268
|
+
}))
|
|
269
|
+
.sort((a, b) => b._score - a._score);
|
|
270
|
+
const perSourceCap = Math.max(1500, Math.floor(12000 / Math.max(evidence.length, 1)));
|
|
271
|
+
const combinedText = orderedEvidence
|
|
272
|
+
.map(e => `--- Source: ${e.url} ---\n${truncate(e.text, perSourceCap)}`)
|
|
273
|
+
.join('\n\n');
|
|
240
274
|
|
|
241
275
|
if (!combinedText.trim()) {
|
|
242
276
|
return {
|
|
@@ -284,9 +318,14 @@ export class AgentOrchestrator {
|
|
|
284
318
|
|
|
285
319
|
try {
|
|
286
320
|
const synthesisPrompt =
|
|
287
|
-
`You are a research assistant.
|
|
321
|
+
`You are a research assistant. Answer this task using ONLY the sources below:\n\n` +
|
|
288
322
|
`Task: ${prompt}\n\n` +
|
|
289
|
-
`${
|
|
323
|
+
`${combinedText}\n\n` +
|
|
324
|
+
`Rules:\n` +
|
|
325
|
+
`- Answer ONLY from the provided sources; do not use outside knowledge.\n` +
|
|
326
|
+
`- Cite the exact source URL(s) you used.\n` +
|
|
327
|
+
`- If the sources do not contain the answer, say so explicitly.\n` +
|
|
328
|
+
`- NEVER invent or guess a URL; cite only URLs that appear in the sources above.\n\n` +
|
|
290
329
|
`Provide a clear, concise answer.`;
|
|
291
330
|
|
|
292
331
|
const { text } = await this._getSamplingClient().complete(synthesisPrompt, { maxTokens: 1024 });
|
|
@@ -211,12 +211,14 @@ export class ChangeTracker extends EventEmitter {
|
|
|
211
211
|
// Calculate change significance
|
|
212
212
|
const significance = await this.calculateChangeSignificance(changeAnalysis, baseline.options);
|
|
213
213
|
|
|
214
|
-
// Create change record
|
|
214
|
+
// Create change record. An unchanged compare (significance 'none' ⇒
|
|
215
|
+
// hasChanges:false) must report a neutral changeType — classifyChangeType
|
|
216
|
+
// falls through to 'text_change' even when nothing changed at all.
|
|
215
217
|
const changeRecord = {
|
|
216
218
|
url,
|
|
217
219
|
timestamp: Date.now(),
|
|
218
220
|
baselineVersion: baseline.version,
|
|
219
|
-
changeType: this.classifyChangeType(changeAnalysis),
|
|
221
|
+
changeType: significance === 'none' ? 'none' : this.classifyChangeType(changeAnalysis),
|
|
220
222
|
significance,
|
|
221
223
|
details: changeAnalysis,
|
|
222
224
|
metrics: {
|
|
@@ -742,12 +744,17 @@ export class ChangeTracker extends EventEmitter {
|
|
|
742
744
|
currentContent = currentContent.toLowerCase();
|
|
743
745
|
}
|
|
744
746
|
|
|
745
|
-
// Word-level diff
|
|
747
|
+
// Word-level diff. Only record an entry when something actually changed
|
|
748
|
+
// (mirrors line_diff below) — unconditionally pushing an empty word_diff
|
|
749
|
+
// made every unchanged compare report "Text content changed".
|
|
746
750
|
const wordDiff = diffWords(baselineContent, currentContent);
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
+
const wordDiffChanges = wordDiff.filter(part => part.added || part.removed);
|
|
752
|
+
if (wordDiffChanges.length > 0) {
|
|
753
|
+
textChanges.push({
|
|
754
|
+
type: 'word_diff',
|
|
755
|
+
changes: wordDiffChanges
|
|
756
|
+
});
|
|
757
|
+
}
|
|
751
758
|
|
|
752
759
|
// Line-level diff for structured content
|
|
753
760
|
const lineDiff = diffLines(baselineContent, currentContent);
|
|
@@ -903,18 +910,23 @@ export class ChangeTracker extends EventEmitter {
|
|
|
903
910
|
|
|
904
911
|
const tagSimilarity = this.calculateTagSimilarity(baselineElements, currentElements);
|
|
905
912
|
const hierarchySimilarity = this.calculateHierarchySimilarity(baseline.hierarchy, current.hierarchy);
|
|
906
|
-
|
|
907
|
-
|
|
913
|
+
|
|
914
|
+
// Clamp defensively — this is a 0-1 metric and must never leave that range.
|
|
915
|
+
return Math.max(0, Math.min(1, (tagSimilarity + hierarchySimilarity) / 2));
|
|
908
916
|
}
|
|
909
|
-
|
|
917
|
+
|
|
910
918
|
calculateTagSimilarity(baselineElements, currentElements) {
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
919
|
+
// Jaccard similarity: intersection and union must both operate on SETS.
|
|
920
|
+
// The old code intersected the raw (duplicate-laden) tag list against a
|
|
921
|
+
// set union, so repeated tags (div, p, ...) inflated the numerator and
|
|
922
|
+
// produced impossible scores > 1 (e.g. the observed 1.05).
|
|
923
|
+
const baselineTags = new Set(baselineElements.map(el => el.tag));
|
|
924
|
+
const currentTags = new Set(currentElements.map(el => el.tag));
|
|
925
|
+
|
|
926
|
+
const intersection = [...baselineTags].filter(tag => currentTags.has(tag));
|
|
915
927
|
const union = new Set([...baselineTags, ...currentTags]);
|
|
916
|
-
|
|
917
|
-
return intersection.length / union.size;
|
|
928
|
+
|
|
929
|
+
return union.size === 0 ? 1 : intersection.length / union.size;
|
|
918
930
|
}
|
|
919
931
|
|
|
920
932
|
calculateHierarchySimilarity(baseline, current) {
|
|
@@ -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
|
|
|
@@ -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
|
|
452
|
-
* positional bonus
|
|
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
|
-
//
|
|
481
|
-
|
|
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
|
-
|
|
512
|
-
|
|
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.
|
|
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
|
-
|
|
547
|
-
|
|
548
|
-
|
|
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
|
-
|
|
563
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
187
|
-
|
|
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
|
-
|
|
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.
|
|
208
|
-
sections:
|
|
209
|
-
elements:
|
|
210
|
-
createdAt: baseline.
|
|
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()
|