crawlforge-mcp-server 5.4.2 → 5.5.0
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/package.json +2 -2
- package/server.js +32 -8
- package/src/core/AuthManager.js +6 -0
- package/src/core/ChangeTracker.js +331 -0
- package/src/core/crawlers/BFSCrawler.js +13 -1
- package/src/schemas/toolOutputSchemas.js +2 -0
- package/src/skills/agent-skills/crawlforge-structured-extraction/SKILL.md +5 -3
- package/src/skills/agent-skills/crawlforge-structured-extraction/references/templates.md +9 -4
- package/src/tools/advanced/ScrapeWithActionsTool.js +26 -8
- package/src/tools/advanced/batchScrape/index.js +9 -6
- package/src/tools/llmstxt/generateLLMsTxt.js +17 -0
- package/src/tools/research/deepResearch.js +21 -3
- package/src/tools/search/redditSearch.js +92 -28
- package/src/tools/templates/ScrapeTemplateTool.js +11 -1
- package/src/tools/tracking/trackChanges/index.js +60 -27
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "crawlforge-mcp-server",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.5.0",
|
|
4
4
|
"mcpName": "io.github.mysleekdesigns/crawlforge-mcp-server",
|
|
5
5
|
"description": "CrawlForge MCP Server - Professional Model Context Protocol server with 29 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",
|
|
@@ -114,7 +114,7 @@
|
|
|
114
114
|
"cheerio": "^1.1.2",
|
|
115
115
|
"commander": "^14.0.3",
|
|
116
116
|
"compromise": "^14.14.4",
|
|
117
|
-
"crawlforge-extractors": "^1.
|
|
117
|
+
"crawlforge-extractors": "^1.5.0",
|
|
118
118
|
"diff": "^9.0.0",
|
|
119
119
|
"dotenv": "^17.2.1",
|
|
120
120
|
"franc": "^6.2.0",
|
package/server.js
CHANGED
|
@@ -105,7 +105,7 @@ const taskStore = createTaskStore({ logger });
|
|
|
105
105
|
// Create the server
|
|
106
106
|
const server = new McpServer({
|
|
107
107
|
name: "crawlforge",
|
|
108
|
-
version: "5.
|
|
108
|
+
version: "5.5.0",
|
|
109
109
|
description: "Production-ready MCP server with 29 web scraping, crawling, and content processing tools. Features MCP Resources (crawlforge://), Prompts, Sampling fallback, Elicitation, stealth browsing, deep research, structured extraction, embedded JavaScript state extraction, real Google SERP rank tracking, Reddit search via community archives, change tracking, local-LLM extraction via Ollama, unified multi-format scrape, and autonomous agent tool.",
|
|
110
110
|
homepage: "https://www.crawlforge.dev",
|
|
111
111
|
icon: "https://www.crawlforge.dev/icon.png",
|
|
@@ -492,7 +492,7 @@ registerToolIfEnabled("serp_rank", {
|
|
|
492
492
|
|
|
493
493
|
// Tool: reddit_search — search Reddit posts/comments or read a full thread (via community archives)
|
|
494
494
|
registerToolIfEnabled("reddit_search", {
|
|
495
|
-
description: "Use this to search Reddit posts or comments, or read a full comment thread — reddit.com blocks direct scraping, so this reads the Arctic Shift community archive instead (free, no Reddit credentials). Modes: 'posts' (default) and 'comments' search; 'thread' returns a post plus its nested comment tree by link_id. A subreddit/author-scoped search queries the archive directly. A keyword search across ALL of Reddit finds posts with a site-restricted web search and then reads those posts from the archive, because Arctic Shift can only keyword-search within a scope; results come back as real archive rows, ordered by search relevance. An unscoped COMMENT search
|
|
495
|
+
description: "Use this to search Reddit posts or comments, or read a full comment thread — reddit.com blocks direct scraping, so this reads the Arctic Shift community archive instead (free, no Reddit credentials). Modes: 'posts' (default) and 'comments' search; 'thread' returns a post plus its nested comment tree by link_id. A subreddit/author-scoped search queries the archive directly. A keyword search across ALL of Reddit finds posts with a site-restricted web search and then reads those posts from the archive, because Arctic Shift can only keyword-search within a scope; results come back as real archive rows, ordered by search relevance. An unscoped COMMENT search discovers posts the same way and then searches each post's comments for the keywords. A scoped comment search Arctic Shift times out on is retried over narrower windows (7d, 3d, 1d) and reports window_applied. Example: reddit_search({query: \"best mechanical keyboard\", subreddit: \"MechanicalKeyboards\", limit: 10})",
|
|
496
496
|
annotations: { title: "Reddit Search", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
497
497
|
inputSchema: {
|
|
498
498
|
query: z.string().optional().describe("Keyword search. Posts: matches title+selftext; comments: matches body. Supports \"quoted phrases\", OR, -exclusion"),
|
|
@@ -504,7 +504,7 @@ registerToolIfEnabled("reddit_search", {
|
|
|
504
504
|
before: z.string().optional().describe("Only content posted before this date — same formats as after"),
|
|
505
505
|
limit: z.number().min(1).max(100).optional().describe("Max results (default 25; thread mode: max comments returned)"),
|
|
506
506
|
sort: z.enum(["asc", "desc"]).optional().describe("Sort by post date (default desc = newest first)"),
|
|
507
|
-
source: z.enum(["auto", "arctic_shift", "pullpush", "reddit_api"]).optional().describe("Backend: auto routes + falls back (default). reddit_api uses the official Reddit Data API — only when REDDIT_CLIENT_ID/REDDIT_CLIENT_SECRET are set; serves posts/thread, not comment search")
|
|
507
|
+
source: z.enum(["auto", "arctic_shift", "pullpush", "reddit_api", "web_discovery"]).optional().describe("Backend: auto routes + falls back (default). web_discovery serves only unscoped keyword searches (web search finds the posts, the archive supplies the rows). reddit_api uses the official Reddit Data API — only when REDDIT_CLIENT_ID/REDDIT_CLIENT_SECRET are set; serves posts/thread, not comment search")
|
|
508
508
|
},
|
|
509
509
|
outputSchema: OUTPUT_SCHEMAS.reddit_search
|
|
510
510
|
}, withAuth("reddit_search", async ({ query, subreddit, author, mode, link_id, after, before, limit, sort, source }) => {
|
|
@@ -977,7 +977,7 @@ if (toolFilter.isEnabled("deep_research")) {
|
|
|
977
977
|
maxVariations: z.number().min(1).max(20).optional().default(8)
|
|
978
978
|
}).optional().describe("Query expansion settings for broader search coverage"),
|
|
979
979
|
llmConfig: z.object({
|
|
980
|
-
provider: z.enum(['auto', 'openai', 'anthropic']).optional().default('auto'),
|
|
980
|
+
provider: z.enum(['auto', 'openai', 'anthropic', 'ollama']).optional().default('auto'),
|
|
981
981
|
openai: z.object({
|
|
982
982
|
apiKey: z.string().optional(),
|
|
983
983
|
model: z.string().optional().default('gpt-3.5-turbo'),
|
|
@@ -987,9 +987,13 @@ if (toolFilter.isEnabled("deep_research")) {
|
|
|
987
987
|
apiKey: z.string().optional(),
|
|
988
988
|
model: z.string().optional().default('claude-3-haiku-20240307')
|
|
989
989
|
}).optional(),
|
|
990
|
+
ollama: z.object({
|
|
991
|
+
model: z.string().optional(),
|
|
992
|
+
embeddingModel: z.string().optional()
|
|
993
|
+
}).optional(),
|
|
990
994
|
enableSemanticAnalysis: z.boolean().optional().default(true),
|
|
991
995
|
enableIntelligentSynthesis: z.boolean().optional().default(true)
|
|
992
|
-
}).optional().describe("LLM provider configuration for AI-powered analysis"),
|
|
996
|
+
}).optional().describe("LLM provider configuration for AI-powered analysis. provider 'auto' (default) uses a configured cloud key if there is one, else the local Ollama (http://localhost:11434, no key); 'ollama' forces the local model; 'openai'/'anthropic' need the matching API key"),
|
|
993
997
|
concurrency: z.number().min(1).max(20).optional().default(5).describe("Number of concurrent research requests"),
|
|
994
998
|
cacheResults: z.boolean().optional().default(true).describe("Cache research results for reuse"),
|
|
995
999
|
webhook: z.object({
|
|
@@ -1534,10 +1538,30 @@ registerToolIfEnabled("localization", {
|
|
|
1534
1538
|
if (!params.countryCode) throw new Error('countryCode is required for configure_country operation');
|
|
1535
1539
|
result = await localizationManager.configureCountry(params.countryCode, params);
|
|
1536
1540
|
break;
|
|
1537
|
-
case 'localize_search':
|
|
1541
|
+
case 'localize_search': {
|
|
1538
1542
|
if (!params.searchParams) throw new Error('searchParams is required for localize_search operation');
|
|
1539
|
-
|
|
1543
|
+
const localizedParams = await localizationManager.localizeSearchQuery(params.searchParams, params.countryCode);
|
|
1544
|
+
if (!params.searchParams.query) {
|
|
1545
|
+
result = {
|
|
1546
|
+
...localizedParams,
|
|
1547
|
+
note: 'No searchParams.query was given, so no search ran — these are the parameters a localized search would use.'
|
|
1548
|
+
};
|
|
1549
|
+
break;
|
|
1550
|
+
}
|
|
1551
|
+
// Run the search the caller asked for under the localized country and
|
|
1552
|
+
// language, so the operation returns results rather than a config.
|
|
1553
|
+
// Priced as a search_web call (AuthManager.getToolCost).
|
|
1554
|
+
const countryCode = params.countryCode || localizationManager.getCurrentSettings().countryCode;
|
|
1555
|
+
const search = await searchWebTool.execute({
|
|
1556
|
+
query: params.searchParams.query,
|
|
1557
|
+
limit: params.searchParams.limit,
|
|
1558
|
+
offset: params.searchParams.offset,
|
|
1559
|
+
lang: localizedParams.lang,
|
|
1560
|
+
localization: { countryCode, language: params.language || localizedParams.lang }
|
|
1561
|
+
});
|
|
1562
|
+
result = { localizedParams, search };
|
|
1540
1563
|
break;
|
|
1564
|
+
}
|
|
1541
1565
|
case 'localize_browser':
|
|
1542
1566
|
if (!params.browserOptions) throw new Error('browserOptions is required for localize_browser operation');
|
|
1543
1567
|
result = await localizationManager.localizeBrowserContext(params.browserOptions, params.countryCode);
|
|
@@ -1578,7 +1602,7 @@ registerToolIfEnabled("localization", {
|
|
|
1578
1602
|
|
|
1579
1603
|
// Tool: scrape_template (D3.3 — pre-built site templates)
|
|
1580
1604
|
registerToolIfEnabled("scrape_template", {
|
|
1581
|
-
description: "Use this when you want structured data from a well-known site or platform API without writing custom selectors. Three modes: a template id with a url (scrape_template({template:\"github-repo\", url:\"https://github.com/user/repo\"})); template:\"auto\" with a url, which picks the template from the URL and names its choice in the response; or template:\"list\" to enumerate every template with the URLs it handles. Page templates return one record — e-commerce, social, developer and news sites (shopify-product, amazon-product, github-repo, youtube-video,
|
|
1605
|
+
description: "Use this when you want structured data from a well-known site or platform API without writing custom selectors. Three modes: a template id with a url (scrape_template({template:\"github-repo\", url:\"https://github.com/user/repo\"})); template:\"auto\" with a url, which picks the template from the URL and names its choice in the response; or template:\"list\" to enumerate every template with the URLs it handles. Page templates return one record — e-commerce, social, developer and news sites (shopify-product, amazon-product, github-repo, youtube-video, reddit-thread, hacker-news-front-page, producthunt-launch, stackoverflow-question, npm-package; reddit-thread reads the post from the Arctic Shift archive and reddit_search reads the comment tree). linkedin-profile and tweet are retired — those sites' robots.txt disallow every keyless path — and naming one returns the reason. List connectors return N records from one call and are driven by params instead of a url: job boards (Greenhouse, Lever, Ashby, Workable, Recruitee, Teamtailor) return a company's whole careers board, US government APIs (NHTSA VIN decode, NPI provider registry) answer keyless lookups, and shopify-collection returns a whole collection. Example: scrape_template({template:\"greenhouse-jobs\", params:{company:\"stripe\"}})",
|
|
1582
1606
|
annotations: { title: "Scrape Template", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
1583
1607
|
inputSchema: {
|
|
1584
1608
|
template: z.string().describe("Template ID (e.g. github-repo), \"auto\" to detect one from the url, or \"list\" to enumerate available templates"),
|
package/src/core/AuthManager.js
CHANGED
|
@@ -612,6 +612,12 @@ class AuthManager {
|
|
|
612
612
|
if (bookkeepingOps.has(params?.operation)) return 1;
|
|
613
613
|
}
|
|
614
614
|
|
|
615
|
+
// localize_search with a query runs a real web search through the same
|
|
616
|
+
// adapter search_web uses, so it is priced as one rather than undercutting it.
|
|
617
|
+
if (tool === 'localization' && params?.operation === 'localize_search' && params?.searchParams?.query) {
|
|
618
|
+
return costs.search_web;
|
|
619
|
+
}
|
|
620
|
+
|
|
615
621
|
return costs[tool] ?? 1;
|
|
616
622
|
}
|
|
617
623
|
|
|
@@ -103,6 +103,79 @@ function extractMonetaryValues(text) {
|
|
|
103
103
|
return values;
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
/**
|
|
107
|
+
* Built-in monitoring presets, served by track_changes
|
|
108
|
+
* {operation:"get_monitoring_templates"} and applied by
|
|
109
|
+
* create_scheduled_monitor via scheduledMonitorOptions.templateId. `options`
|
|
110
|
+
* are trackingOptions (the baseline analysis options); `frequency` is the
|
|
111
|
+
* polling interval in ms; `goal` is the plain-English alert goal the
|
|
112
|
+
* scheduler's LLM judge uses when one is configured.
|
|
113
|
+
*/
|
|
114
|
+
export const MONITORING_TEMPLATES = {
|
|
115
|
+
'price-watch': {
|
|
116
|
+
name: 'Price watch',
|
|
117
|
+
description: 'Product, plan or rate pages — hourly, and any numeric change counts.',
|
|
118
|
+
frequency: 3600000,
|
|
119
|
+
notificationThreshold: 'minor',
|
|
120
|
+
goal: 'A price, discount, fee or plan cost changed',
|
|
121
|
+
options: { granularity: 'text', trackText: true, trackStructure: false, trackLinks: false, ignoreWhitespace: true, significanceThresholds: { minor: 0.02, moderate: 0.1, major: 0.3 } },
|
|
122
|
+
alertRules: [{ condition: 'significance >= "minor"', actions: ['webhook'], priority: 'high' }]
|
|
123
|
+
},
|
|
124
|
+
'availability': {
|
|
125
|
+
name: 'Stock / availability',
|
|
126
|
+
description: 'In-stock, sold-out and waitlist states — every 15 minutes, any change counts.',
|
|
127
|
+
frequency: 900000,
|
|
128
|
+
notificationThreshold: 'minor',
|
|
129
|
+
goal: 'An item went in or out of stock, or a waitlist or pre-order opened',
|
|
130
|
+
options: { granularity: 'text', trackText: true, trackStructure: false, trackLinks: false, ignoreWhitespace: true, significanceThresholds: { minor: 0.01, moderate: 0.1, major: 0.3 } },
|
|
131
|
+
alertRules: [{ condition: 'significance >= "minor"', actions: ['webhook'], priority: 'high' }]
|
|
132
|
+
},
|
|
133
|
+
'news-feed': {
|
|
134
|
+
name: 'News / announcements',
|
|
135
|
+
description: 'Blogs, changelogs and press pages — every 30 minutes, section-level diffs so a new post registers as one change.',
|
|
136
|
+
frequency: 1800000,
|
|
137
|
+
notificationThreshold: 'moderate',
|
|
138
|
+
goal: 'A new article, release note or announcement was published',
|
|
139
|
+
options: { granularity: 'section', trackText: true, trackStructure: true, trackLinks: true, ignoreWhitespace: true },
|
|
140
|
+
alertRules: [{ condition: 'significance >= "moderate"', actions: ['webhook'], priority: 'medium' }]
|
|
141
|
+
},
|
|
142
|
+
'documentation': {
|
|
143
|
+
name: 'Documentation',
|
|
144
|
+
description: 'API references and docs pages — daily, structure tracked so a new parameter or removed endpoint shows up.',
|
|
145
|
+
frequency: 86400000,
|
|
146
|
+
notificationThreshold: 'moderate',
|
|
147
|
+
goal: 'A documented API, parameter, default or behaviour changed',
|
|
148
|
+
options: { granularity: 'section', trackText: true, trackStructure: true, trackLinks: true, ignoreWhitespace: true },
|
|
149
|
+
alertRules: [{ condition: 'significance >= "moderate"', actions: ['webhook'], priority: 'medium' }]
|
|
150
|
+
},
|
|
151
|
+
'regulatory': {
|
|
152
|
+
name: 'Regulation / policy',
|
|
153
|
+
description: 'Terms, policies, tariffs and rule pages — daily, and every wording change counts.',
|
|
154
|
+
frequency: 86400000,
|
|
155
|
+
notificationThreshold: 'minor',
|
|
156
|
+
goal: 'A rule, deadline, fee, threshold or policy wording changed',
|
|
157
|
+
options: { granularity: 'text', trackText: true, trackStructure: false, trackLinks: false, ignoreWhitespace: true, ignoreCase: false },
|
|
158
|
+
alertRules: [{ condition: 'significance >= "minor"', actions: ['webhook'], priority: 'high' }]
|
|
159
|
+
},
|
|
160
|
+
'job-board': {
|
|
161
|
+
name: 'Job listings',
|
|
162
|
+
description: 'Careers pages — every 6 hours, links tracked so an added or removed posting registers.',
|
|
163
|
+
frequency: 21600000,
|
|
164
|
+
notificationThreshold: 'moderate',
|
|
165
|
+
goal: 'A job opening was added or removed',
|
|
166
|
+
options: { granularity: 'section', trackText: true, trackStructure: false, trackLinks: true, ignoreWhitespace: true },
|
|
167
|
+
alertRules: [{ condition: 'significance >= "moderate"', actions: ['webhook'], priority: 'medium' }]
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const SIGNIFICANCE_RANK = { none: 0, minor: 1, moderate: 2, major: 3, critical: 4 };
|
|
172
|
+
|
|
173
|
+
function csvCell(value) {
|
|
174
|
+
if (value === null || value === undefined) return '';
|
|
175
|
+
const text = typeof value === 'object' ? JSON.stringify(value) : String(value);
|
|
176
|
+
return /[",\n\r]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
|
|
177
|
+
}
|
|
178
|
+
|
|
106
179
|
export class ChangeTracker extends EventEmitter {
|
|
107
180
|
constructor(options = {}) {
|
|
108
181
|
super();
|
|
@@ -147,6 +220,16 @@ export class ChangeTracker extends EventEmitter {
|
|
|
147
220
|
processingTime: 0
|
|
148
221
|
};
|
|
149
222
|
|
|
223
|
+
// Alert rules (create_alert_rule) are evaluated on every compare; the
|
|
224
|
+
// alerts they fire are kept here for get_dashboard. Presets back
|
|
225
|
+
// get_monitoring_templates and scheduledMonitorOptions.templateId.
|
|
226
|
+
this.alertRules = new Map();
|
|
227
|
+
this.recentAlerts = [];
|
|
228
|
+
this.maxRecentAlerts = 100;
|
|
229
|
+
this.monitoringTemplates = new Map(
|
|
230
|
+
Object.entries(MONITORING_TEMPLATES).map(([id, t]) => [id, { id, ...t }])
|
|
231
|
+
);
|
|
232
|
+
|
|
150
233
|
// Semantic analysis tools (if enabled)
|
|
151
234
|
this.semanticAnalyzer = null;
|
|
152
235
|
|
|
@@ -311,6 +394,7 @@ export class ChangeTracker extends EventEmitter {
|
|
|
311
394
|
}
|
|
312
395
|
|
|
313
396
|
this.emit('changeDetected', changeRecord);
|
|
397
|
+
const alerts = this.evaluateAlertRules(changeRecord);
|
|
314
398
|
|
|
315
399
|
const ignoredOptions = this.findIgnoredCompareOptions(options, baseline.options);
|
|
316
400
|
|
|
@@ -322,6 +406,7 @@ export class ChangeTracker extends EventEmitter {
|
|
|
322
406
|
details: changeAnalysis,
|
|
323
407
|
metrics: changeRecord.metrics,
|
|
324
408
|
recommendations: this.generateChangeRecommendations(changeRecord),
|
|
409
|
+
...(alerts.length ? { alerts } : {}),
|
|
325
410
|
...(ignoredOptions.length ? {
|
|
326
411
|
warnings: [
|
|
327
412
|
`${ignoredOptions.join(', ')} passed to this compare ${ignoredOptions.length === 1 ? 'was' : 'were'} ignored — ` +
|
|
@@ -1654,6 +1739,252 @@ export class ChangeTracker extends EventEmitter {
|
|
|
1654
1739
|
};
|
|
1655
1740
|
}
|
|
1656
1741
|
|
|
1742
|
+
// ── Alert rules, dashboard, export, trends, presets ─────────────────────────
|
|
1743
|
+
// Backing for the track_changes operations create_alert_rule, get_dashboard,
|
|
1744
|
+
// export_history, generate_trend_report and get_monitoring_templates. The
|
|
1745
|
+
// tool has called these since v3.1.0; until 5.4.3 none of them existed.
|
|
1746
|
+
|
|
1747
|
+
/**
|
|
1748
|
+
* Turn an alert-rule condition string into a predicate over a change record.
|
|
1749
|
+
* Supported: `significance === "major"`, `significance >= "moderate"` and
|
|
1750
|
+
* the other comparison operators against the significance ladder
|
|
1751
|
+
* none < minor < moderate < major < critical. Anything else never matches.
|
|
1752
|
+
*/
|
|
1753
|
+
static parseAlertCondition(conditionString = '') {
|
|
1754
|
+
const match = String(conditionString).match(/significance\s*(===|==|!==|!=|>=|<=|>|<)\s*["'](\w+)["']/);
|
|
1755
|
+
if (!match) return () => false;
|
|
1756
|
+
const [, op, level] = match;
|
|
1757
|
+
const target = SIGNIFICANCE_RANK[level];
|
|
1758
|
+
if (target === undefined) return () => false;
|
|
1759
|
+
return (record) => {
|
|
1760
|
+
const rank = SIGNIFICANCE_RANK[record?.significance];
|
|
1761
|
+
if (rank === undefined) return false;
|
|
1762
|
+
switch (op) {
|
|
1763
|
+
case '===': case '==': return rank === target;
|
|
1764
|
+
case '!==': case '!=': return rank !== target;
|
|
1765
|
+
case '>=': return rank >= target;
|
|
1766
|
+
case '<=': return rank <= target;
|
|
1767
|
+
case '>': return rank > target;
|
|
1768
|
+
default: return rank < target;
|
|
1769
|
+
}
|
|
1770
|
+
};
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1773
|
+
/**
|
|
1774
|
+
* Register an alert rule. `condition` is a string (see parseAlertCondition);
|
|
1775
|
+
* `url` scopes the rule to one tracked page, or every page when omitted.
|
|
1776
|
+
*/
|
|
1777
|
+
addAlertRule(ruleId, { condition, url = null, actions = ['webhook'], throttle = 600000, priority = 'medium' } = {}) {
|
|
1778
|
+
const rule = {
|
|
1779
|
+
id: ruleId,
|
|
1780
|
+
url,
|
|
1781
|
+
conditionText: condition || 'significance === "major"',
|
|
1782
|
+
condition: ChangeTracker.parseAlertCondition(condition || 'significance === "major"'),
|
|
1783
|
+
actions,
|
|
1784
|
+
throttle,
|
|
1785
|
+
priority,
|
|
1786
|
+
createdAt: Date.now(),
|
|
1787
|
+
fireCount: 0,
|
|
1788
|
+
suppressedCount: 0,
|
|
1789
|
+
lastFiredAt: null
|
|
1790
|
+
};
|
|
1791
|
+
this.alertRules.set(ruleId, rule);
|
|
1792
|
+
return rule;
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1795
|
+
/** Evaluate every rule against a change record; returns the alerts fired. */
|
|
1796
|
+
evaluateAlertRules(changeRecord) {
|
|
1797
|
+
const fired = [];
|
|
1798
|
+
const now = changeRecord.timestamp || Date.now();
|
|
1799
|
+
for (const rule of this.alertRules.values()) {
|
|
1800
|
+
if (rule.url && rule.url !== changeRecord.url) continue;
|
|
1801
|
+
let matches = false;
|
|
1802
|
+
try { matches = rule.condition(changeRecord); } catch { matches = false; }
|
|
1803
|
+
if (!matches) continue;
|
|
1804
|
+
if (rule.lastFiredAt && rule.throttle && now - rule.lastFiredAt < rule.throttle) {
|
|
1805
|
+
rule.suppressedCount++;
|
|
1806
|
+
continue;
|
|
1807
|
+
}
|
|
1808
|
+
rule.lastFiredAt = now;
|
|
1809
|
+
rule.fireCount++;
|
|
1810
|
+
const alert = {
|
|
1811
|
+
id: `alert_${now}_${Math.random().toString(36).slice(2, 8)}`,
|
|
1812
|
+
ruleId: rule.id,
|
|
1813
|
+
url: changeRecord.url,
|
|
1814
|
+
timestamp: now,
|
|
1815
|
+
significance: changeRecord.significance,
|
|
1816
|
+
changeType: changeRecord.changeType,
|
|
1817
|
+
similarity: changeRecord.details?.similarity ?? null,
|
|
1818
|
+
priority: rule.priority,
|
|
1819
|
+
actions: rule.actions
|
|
1820
|
+
};
|
|
1821
|
+
this.recentAlerts.unshift(alert);
|
|
1822
|
+
if (this.recentAlerts.length > this.maxRecentAlerts) this.recentAlerts.length = this.maxRecentAlerts;
|
|
1823
|
+
fired.push(alert);
|
|
1824
|
+
this.emit('alert', alert);
|
|
1825
|
+
}
|
|
1826
|
+
return fired;
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
/** Per-URL change trend over the in-memory history. */
|
|
1830
|
+
summarizeTrend(url) {
|
|
1831
|
+
const history = this.changeHistory.get(url) || [];
|
|
1832
|
+
const changes = history.filter(r => r.significance !== 'none');
|
|
1833
|
+
const significanceDistribution = { none: 0, minor: 0, moderate: 0, major: 0, critical: 0 };
|
|
1834
|
+
for (const r of history) {
|
|
1835
|
+
significanceDistribution[r.significance] = (significanceDistribution[r.significance] || 0) + 1;
|
|
1836
|
+
}
|
|
1837
|
+
const similarities = history.map(r => r.details?.similarity).filter(n => typeof n === 'number');
|
|
1838
|
+
const avg = (arr) => (arr.length ? arr.reduce((a, b) => a + b, 0) / arr.length : null);
|
|
1839
|
+
const half = Math.floor(similarities.length / 2);
|
|
1840
|
+
const early = avg(similarities.slice(0, half));
|
|
1841
|
+
const late = avg(similarities.slice(half));
|
|
1842
|
+
let direction = 'insufficient_data';
|
|
1843
|
+
if (similarities.length >= 4 && early !== null && late !== null) {
|
|
1844
|
+
direction = late < early - 0.05 ? 'diverging' : late > early + 0.05 ? 'stabilizing' : 'steady';
|
|
1845
|
+
}
|
|
1846
|
+
const span = history.length >= 2 ? history[history.length - 1].timestamp - history[0].timestamp : 0;
|
|
1847
|
+
const changeTypes = {};
|
|
1848
|
+
for (const r of changes) changeTypes[r.changeType] = (changeTypes[r.changeType] || 0) + 1;
|
|
1849
|
+
return {
|
|
1850
|
+
url,
|
|
1851
|
+
compares: history.length,
|
|
1852
|
+
changes: changes.length,
|
|
1853
|
+
changeRate: history.length ? changes.length / history.length : 0,
|
|
1854
|
+
changesPerDay: span > 0 ? changes.length / (span / 86400000) : null,
|
|
1855
|
+
averageSimilarity: avg(similarities),
|
|
1856
|
+
direction,
|
|
1857
|
+
significanceDistribution,
|
|
1858
|
+
changeTypes,
|
|
1859
|
+
firstCompareAt: history[0]?.timestamp ?? null,
|
|
1860
|
+
lastCompareAt: history[history.length - 1]?.timestamp ?? null,
|
|
1861
|
+
lastChangeAt: changes[changes.length - 1]?.timestamp ?? null
|
|
1862
|
+
};
|
|
1863
|
+
}
|
|
1864
|
+
|
|
1865
|
+
async generateTrendAnalysisReport(url) {
|
|
1866
|
+
if (url && !this.changeHistory.has(url) && !this.snapshots.has(url)) {
|
|
1867
|
+
throw new Error(`No tracking history for ${url} — run create_baseline and compare first`);
|
|
1868
|
+
}
|
|
1869
|
+
const urls = url ? [url] : Array.from(this.changeHistory.keys());
|
|
1870
|
+
const trends = urls.map(u => this.summarizeTrend(u));
|
|
1871
|
+
const recommendations = [];
|
|
1872
|
+
for (const t of trends) {
|
|
1873
|
+
if (t.compares < 2) {
|
|
1874
|
+
recommendations.push(`${t.url}: ${t.compares} compare${t.compares === 1 ? '' : 's'} recorded — run compare a few more times before reading a trend.`);
|
|
1875
|
+
continue;
|
|
1876
|
+
}
|
|
1877
|
+
if (t.direction === 'diverging') {
|
|
1878
|
+
recommendations.push(`${t.url}: each compare drifts further from the baseline — recreate the baseline if the current page is the new normal.`);
|
|
1879
|
+
}
|
|
1880
|
+
if (t.changes === 0) {
|
|
1881
|
+
recommendations.push(`${t.url}: no changes across ${t.compares} compares — the polling interval can be lengthened.`);
|
|
1882
|
+
}
|
|
1883
|
+
if (t.changesPerDay !== null && t.changesPerDay > 24) {
|
|
1884
|
+
recommendations.push(`${t.url}: changes more than hourly — scope the baseline with customSelectors to skip volatile regions.`);
|
|
1885
|
+
}
|
|
1886
|
+
}
|
|
1887
|
+
return { generatedAt: Date.now(), scope: url || 'global', urlCount: urls.length, trends, recommendations };
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
getMonitoringDashboard() {
|
|
1891
|
+
const urls = Array.from(new Set([...this.snapshots.keys(), ...this.changeHistory.keys()]));
|
|
1892
|
+
const tracked = urls.map(url => {
|
|
1893
|
+
const history = this.changeHistory.get(url) || [];
|
|
1894
|
+
const last = history[history.length - 1] || null;
|
|
1895
|
+
const snaps = this.snapshots.get(url) || [];
|
|
1896
|
+
const baseline = snaps[snaps.length - 1] || null;
|
|
1897
|
+
return {
|
|
1898
|
+
url,
|
|
1899
|
+
baselineVersion: baseline?.version ?? null,
|
|
1900
|
+
baselineCreatedAt: baseline?.timestamp ?? null,
|
|
1901
|
+
compares: history.length,
|
|
1902
|
+
changes: history.filter(r => r.significance !== 'none').length,
|
|
1903
|
+
lastCompareAt: last?.timestamp ?? null,
|
|
1904
|
+
lastSignificance: last?.significance ?? null,
|
|
1905
|
+
lastChangeType: last?.changeType ?? null
|
|
1906
|
+
};
|
|
1907
|
+
});
|
|
1908
|
+
return {
|
|
1909
|
+
generatedAt: Date.now(),
|
|
1910
|
+
summary: {
|
|
1911
|
+
trackedUrls: urls.length,
|
|
1912
|
+
totalCompares: tracked.reduce((n, t) => n + t.compares, 0),
|
|
1913
|
+
totalChanges: tracked.reduce((n, t) => n + t.changes, 0),
|
|
1914
|
+
alertRules: this.alertRules.size,
|
|
1915
|
+
alertsFired: this.recentAlerts.length,
|
|
1916
|
+
activeMonitors: 0,
|
|
1917
|
+
...this.getStats()
|
|
1918
|
+
},
|
|
1919
|
+
tracked,
|
|
1920
|
+
alertRules: Array.from(this.alertRules.values()).map(r => ({
|
|
1921
|
+
id: r.id, url: r.url, condition: r.conditionText, actions: r.actions, throttle: r.throttle,
|
|
1922
|
+
priority: r.priority, fireCount: r.fireCount, suppressedCount: r.suppressedCount, lastFiredAt: r.lastFiredAt
|
|
1923
|
+
})),
|
|
1924
|
+
recentAlerts: this.recentAlerts.slice(0, 20),
|
|
1925
|
+
trends: urls.map(u => this.summarizeTrend(u)),
|
|
1926
|
+
// Polling and scheduled monitors live in the tool, which fills this in.
|
|
1927
|
+
monitors: []
|
|
1928
|
+
};
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
async exportHistoricalData(options = {}) {
|
|
1932
|
+
const { url, format = 'json', startTime, endTime, includeContent = false, includeSnapshots = false } = options;
|
|
1933
|
+
const urls = url ? [url] : Array.from(this.changeHistory.keys());
|
|
1934
|
+
const records = [];
|
|
1935
|
+
for (const u of urls) {
|
|
1936
|
+
for (const r of this.changeHistory.get(u) || []) {
|
|
1937
|
+
if (startTime && r.timestamp < startTime) continue;
|
|
1938
|
+
if (endTime && r.timestamp > endTime) continue;
|
|
1939
|
+
const row = {
|
|
1940
|
+
url: r.url,
|
|
1941
|
+
timestamp: r.timestamp,
|
|
1942
|
+
iso: new Date(r.timestamp).toISOString(),
|
|
1943
|
+
baselineVersion: r.baselineVersion ?? null,
|
|
1944
|
+
changeType: r.changeType,
|
|
1945
|
+
significance: r.significance,
|
|
1946
|
+
similarity: r.details?.similarity ?? null,
|
|
1947
|
+
structuralSimilarity: r.details?.structuralSimilarity ?? null,
|
|
1948
|
+
addedElements: r.metrics?.addedElements ?? 0,
|
|
1949
|
+
removedElements: r.metrics?.removedElements ?? 0,
|
|
1950
|
+
modifiedElements: r.metrics?.modifiedElements ?? 0,
|
|
1951
|
+
processingTime: r.processingTime ?? null
|
|
1952
|
+
};
|
|
1953
|
+
if (includeContent) row.details = r.details;
|
|
1954
|
+
records.push(row);
|
|
1955
|
+
}
|
|
1956
|
+
}
|
|
1957
|
+
records.sort((a, b) => a.timestamp - b.timestamp);
|
|
1958
|
+
|
|
1959
|
+
const result = {
|
|
1960
|
+
format,
|
|
1961
|
+
exportedAt: Date.now(),
|
|
1962
|
+
scope: url || 'global',
|
|
1963
|
+
urlCount: urls.length,
|
|
1964
|
+
recordCount: records.length,
|
|
1965
|
+
...(startTime || endTime ? { range: { startTime: startTime ?? null, endTime: endTime ?? null } } : {})
|
|
1966
|
+
};
|
|
1967
|
+
if (includeSnapshots) {
|
|
1968
|
+
result.snapshots = urls.flatMap(u => (this.snapshots.get(u) || []).map(snap => ({
|
|
1969
|
+
url: u,
|
|
1970
|
+
version: snap.version ?? null,
|
|
1971
|
+
createdAt: snap.timestamp ?? null,
|
|
1972
|
+
contentHash: snap.analysis?.hashes?.content ?? null,
|
|
1973
|
+
options: snap.options ?? null
|
|
1974
|
+
})));
|
|
1975
|
+
}
|
|
1976
|
+
if (format === 'csv') {
|
|
1977
|
+
const columns = ['url', 'timestamp', 'iso', 'baselineVersion', 'changeType', 'significance', 'similarity',
|
|
1978
|
+
'structuralSimilarity', 'addedElements', 'removedElements', 'modifiedElements', 'processingTime',
|
|
1979
|
+
...(includeContent ? ['details'] : [])];
|
|
1980
|
+
result.columns = columns;
|
|
1981
|
+
result.csv = [columns.join(','), ...records.map(r => columns.map(c => csvCell(r[c])).join(','))].join('\n');
|
|
1982
|
+
} else {
|
|
1983
|
+
result.records = records;
|
|
1984
|
+
}
|
|
1985
|
+
return result;
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1657
1988
|
async initializeSemanticAnalyzer() {
|
|
1658
1989
|
// Placeholder for semantic analysis initialization
|
|
1659
1990
|
}
|
|
@@ -7,7 +7,7 @@ import { LinkAnalyzer } from '../analysis/LinkAnalyzer.js';
|
|
|
7
7
|
import { normalizeUrl, extractLinks, isValidUrl } from '../../utils/urlNormalizer.js';
|
|
8
8
|
import { Logger } from '../../utils/Logger.js';
|
|
9
9
|
import { safeFetch } from '../../utils/ssrfGuard.js';
|
|
10
|
-
import { robotsPreflight } from '../../utils/robotsGate.js';
|
|
10
|
+
import { robotsPreflight, RobotsDisallowedError } from '../../utils/robotsGate.js';
|
|
11
11
|
import { throttleHost } from '../../utils/hostRateLimiter.js';
|
|
12
12
|
import { CRAWLFORGE_USER_AGENT, identityHeaders } from '../../utils/fetchIdentity.js';
|
|
13
13
|
|
|
@@ -185,6 +185,18 @@ export class BFSCrawler {
|
|
|
185
185
|
});
|
|
186
186
|
if (!gate.allowed) {
|
|
187
187
|
logger.debug(`Robots.txt blocks: ${normalizedUrl}`);
|
|
188
|
+
// A refused seed used to end the crawl with 0 pages and 0 errors, so the
|
|
189
|
+
// caller could not tell a robots refusal from an empty site. Children
|
|
190
|
+
// stay silent: skipping disallowed links is the normal case.
|
|
191
|
+
if (isSeed) {
|
|
192
|
+
this.errors.push({
|
|
193
|
+
url: normalizedUrl,
|
|
194
|
+
depth,
|
|
195
|
+
error: new RobotsDisallowedError(normalizedUrl).message,
|
|
196
|
+
code: 'ROBOTS_DISALLOWED',
|
|
197
|
+
timestamp: new Date().toISOString()
|
|
198
|
+
});
|
|
199
|
+
}
|
|
188
200
|
return;
|
|
189
201
|
}
|
|
190
202
|
|
|
@@ -189,6 +189,8 @@ const redditSearchShape = {
|
|
|
189
189
|
count: z.number().optional(),
|
|
190
190
|
results: z.array(z.union([redditPostShape, redditCommentShape])).optional().describe('posts/comments modes'),
|
|
191
191
|
discovered: z.number().optional().describe('web_discovery: how many post ids the site-restricted web search surfaced before archive hydration'),
|
|
192
|
+
posts_searched: z.number().optional().describe('web_discovery comments mode: how many discovered posts had their comments searched before limit was reached'),
|
|
193
|
+
window_applied: z.string().optional().describe('arctic_shift comments mode: the after-window ("7d"/"3d"/"1d") the search was narrowed to after the full-history search timed out; absent when the caller set after or no narrowing was needed'),
|
|
192
194
|
post: redditPostShape.nullable().optional().describe('thread mode: the post itself'),
|
|
193
195
|
comments: z.array(z.unknown()).optional().describe('thread mode: nested comment tree ({...comment, replies:[...]}); collapsed branches appear as {more_count, more_ids}'),
|
|
194
196
|
comment_count: z.number().optional(),
|
|
@@ -53,9 +53,11 @@ source and a ready-to-paste path.
|
|
|
53
53
|
```
|
|
54
54
|
|
|
55
55
|
Pass `template:"list"` to enumerate templates. Supports `shopify-product`,
|
|
56
|
-
`amazon-product`, `
|
|
57
|
-
|
|
58
|
-
`
|
|
56
|
+
`amazon-product`, `github-repo`, `youtube-video`, `reddit-thread` (the post,
|
|
57
|
+
from the Arctic Shift archive; `reddit_search` reads the comment tree),
|
|
58
|
+
`hacker-news-front-page`, `producthunt-launch`, `stackoverflow-question`,
|
|
59
|
+
`npm-package`. `linkedin-profile` and `tweet` are retired — those sites'
|
|
60
|
+
robots.txt disallow every keyless path. Prefer `shopify-product` over scraping
|
|
59
61
|
or LLM extraction on any Shopify storefront — it reads the store's own JSON, so
|
|
60
62
|
prices and stock cannot be misread. Full field lists: [templates](references/templates.md).
|
|
61
63
|
CLI: `crawlforge template github-repo https://github.com/owner/repo`.
|
|
@@ -8,16 +8,21 @@ to enumerate at runtime. Cost: 1 credit per call.
|
|
|
8
8
|
|-------------|--------|-------------------------|
|
|
9
9
|
| `shopify-product` | Any Shopify storefront product page (`/products/<handle>`), custom domains included | title, price, compare-at price, on_sale, currency, price range, per-variant stock, options, images, tags — read from the store's `/products/<handle>.json`, not the rendered page |
|
|
10
10
|
| `amazon-product` | Amazon product page (`/dp/...`) | title, price, currency, rating (number), review count (number), brand, ASIN, availability, full-size images, description, breadcrumbs. Breadcrumbs are empty on device pages, which genuinely have none |
|
|
11
|
-
| `linkedin-profile` | LinkedIn public profile | name, headline, location, current role, about, experience |
|
|
12
11
|
| `github-repo` | GitHub repository | name, owner, description, stars, forks, language, topics, README excerpt |
|
|
13
12
|
| `youtube-video` | YouTube watch page | title, channel, views, likes, published date, description |
|
|
14
|
-
| `
|
|
15
|
-
| `reddit-thread` | Reddit thread | title, subreddit, author, score, top comments |
|
|
13
|
+
| `reddit-thread` | Reddit post (read from the Arctic Shift archive, not reddit.com, which blocks plain fetchers) | id, title, subreddit, author, score, upvote_ratio, num_comments, posted, body, link_url, flair, removed. Pass `id` to `reddit_search` with `mode:"thread"` for the comment tree |
|
|
16
14
|
| `hacker-news-front-page` | HN front page | ranked stories: title, URL, points, author, comment count |
|
|
17
15
|
| `producthunt-launch` | Product Hunt launch | name, tagline, upvotes, maker, description |
|
|
18
|
-
| `stackoverflow-question` | Stack Overflow question | title,
|
|
16
|
+
| `stackoverflow-question` | Stack Overflow question (read from the keyless Stack Exchange API, not the page) | title, body, votes, views, tags, author, asked, answered, accepted_answer_id, answer_count, top 5 answers (accepted first: votes, author, posted, body) |
|
|
19
17
|
| `npm-package` | npm package page | name, version, description, weekly downloads, license, repo link |
|
|
20
18
|
|
|
19
|
+
**Retired (2026-08-30):** `linkedin-profile` and `tweet`. LinkedIn's robots.txt
|
|
20
|
+
disallows every path for all agents but its own crawler and profiles sit behind
|
|
21
|
+
an auth wall; X's robots.txt disallows every path for generic agents and its
|
|
22
|
+
keyless embed endpoints are disallowed by their own robots.txt. Naming either
|
|
23
|
+
returns the reason and fetches nothing. For X or LinkedIn data, use the
|
|
24
|
+
platform's own API with the user's credentials.
|
|
25
|
+
|
|
21
26
|
## Usage
|
|
22
27
|
|
|
23
28
|
```json
|
|
@@ -675,19 +675,36 @@ export class ScrapeWithActionsTool extends EventEmitter {
|
|
|
675
675
|
// Prefer the post-action live page HTML captured during action execution.
|
|
676
676
|
// This ensures the final content reflects clicks/typing/navigation rather
|
|
677
677
|
// than re-fetching the original (pre-action) URL.
|
|
678
|
-
|
|
679
|
-
|
|
678
|
+
const extractResult = chainResult?.finalHtml
|
|
679
|
+
? await this.extractContentTool.execute({
|
|
680
680
|
url: chainResult.finalUrl || params.url,
|
|
681
681
|
html: chainResult.finalHtml,
|
|
682
682
|
options
|
|
683
|
-
})
|
|
683
|
+
})
|
|
684
|
+
: await this.extractContentTool.execute({ url: params.url, options });
|
|
685
|
+
|
|
686
|
+
// extractContent never honoured customSelectors, so extractionOptions.
|
|
687
|
+
// selectors only ever surfaced inside captureIntermediateStates. Run them
|
|
688
|
+
// over the post-action DOM here, the one place every caller reads.
|
|
689
|
+
if (chainResult?.finalHtml && (params.extractionOptions?.selectors || params.formats?.includes('text'))) {
|
|
690
|
+
const $ = load(chainResult.finalHtml);
|
|
691
|
+
extractResult.content = extractResult.content || {};
|
|
692
|
+
if (params.extractionOptions?.selectors) {
|
|
693
|
+
extractResult.content.extracted = this.extractWithSelectors($, params.extractionOptions.selectors);
|
|
694
|
+
}
|
|
695
|
+
// Readability picks a "main" block and drops form controls; on a small
|
|
696
|
+
// post-action page (a demo form, a results panel) that leaves the
|
|
697
|
+
// footer alone, or a page minus the very buttons the actions produced.
|
|
698
|
+
// A short readable result means it found nothing substantial, so hand
|
|
699
|
+
// back the whole (equally short) body text instead.
|
|
700
|
+
const readable = extractResult.content.text || '';
|
|
701
|
+
const bodyText = $('body').text().replace(/\s+/g, ' ').trim();
|
|
702
|
+
if (readable.length < 300 && bodyText.length > readable.length) {
|
|
703
|
+
extractResult.content.text = bodyText;
|
|
704
|
+
extractResult.content.textSource = 'body';
|
|
705
|
+
}
|
|
684
706
|
}
|
|
685
707
|
|
|
686
|
-
const extractResult = await this.extractContentTool.execute({
|
|
687
|
-
url: params.url,
|
|
688
|
-
options
|
|
689
|
-
});
|
|
690
|
-
|
|
691
708
|
return extractResult;
|
|
692
709
|
} catch (error) {
|
|
693
710
|
this.log('warn', `Final content extraction failed: ${error.message}`);
|
|
@@ -706,6 +723,7 @@ export class ScrapeWithActionsTool extends EventEmitter {
|
|
|
706
723
|
if (formats.includes('json')) {
|
|
707
724
|
content.json = {
|
|
708
725
|
finalContent: finalContent.content || {},
|
|
726
|
+
...(finalContent.content?.extracted ? { extracted: finalContent.content.extracted } : {}),
|
|
709
727
|
metadata: finalContent.metadata || {},
|
|
710
728
|
actionSummary: {
|
|
711
729
|
totalActions: additionalData.actionResults.length,
|
|
@@ -175,7 +175,7 @@ export class BatchScrapeTool extends EventEmitter {
|
|
|
175
175
|
};
|
|
176
176
|
|
|
177
177
|
if (this.enableResultCaching) {
|
|
178
|
-
this._cacheBatchResult(batchId, processedResults);
|
|
178
|
+
this._cacheBatchResult(batchId, processedResults, 'sync');
|
|
179
179
|
}
|
|
180
180
|
|
|
181
181
|
this.stats.completedBatches++;
|
|
@@ -237,7 +237,7 @@ export class BatchScrapeTool extends EventEmitter {
|
|
|
237
237
|
if (Date.now() - cached.timestamp < cached.ttl) {
|
|
238
238
|
const offset = (page - 1) * pageSize;
|
|
239
239
|
return {
|
|
240
|
-
batchId, success: true,
|
|
240
|
+
batchId, success: true, status: 'completed', ...(cached.mode ? { mode: cached.mode } : {}),
|
|
241
241
|
results: paginateResults(cached.results, offset, pageSize),
|
|
242
242
|
pagination: { page, pageSize, totalResults: cached.results.length, totalPages: Math.ceil(cached.results.length / pageSize) },
|
|
243
243
|
cached: true, timestamp: cached.timestamp
|
|
@@ -267,8 +267,11 @@ export class BatchScrapeTool extends EventEmitter {
|
|
|
267
267
|
if (job.status === 'completed' && job.result) {
|
|
268
268
|
const results = job.result.results || [];
|
|
269
269
|
const offset = (page - 1) * pageSize;
|
|
270
|
+
// Same status/mode fields the pending and running branches carry, so
|
|
271
|
+
// a poller can key on `status` for the whole job lifecycle.
|
|
270
272
|
return {
|
|
271
|
-
batchId, success: true, jobId: job.id,
|
|
273
|
+
batchId, success: true, jobId: job.id, status: 'completed', mode: 'async',
|
|
274
|
+
completedAt: job.completedAt,
|
|
272
275
|
results: paginateResults(results, offset, pageSize),
|
|
273
276
|
pagination: { page, pageSize, totalResults: results.length, totalPages: Math.ceil(results.length / pageSize) },
|
|
274
277
|
cached: false, timestamp: job.completedAt
|
|
@@ -366,8 +369,8 @@ export class BatchScrapeTool extends EventEmitter {
|
|
|
366
369
|
* Cache a batch's results, capped to maxCachedBatches. Map preserves
|
|
367
370
|
* insertion order, so the oldest entry is evicted first (LRU by write time).
|
|
368
371
|
*/
|
|
369
|
-
_cacheBatchResult(batchId, results) {
|
|
370
|
-
this.batchResults.set(batchId, { results, timestamp: Date.now(), ttl: this.resultCacheTtl });
|
|
372
|
+
_cacheBatchResult(batchId, results, mode) {
|
|
373
|
+
this.batchResults.set(batchId, { results, mode, timestamp: Date.now(), ttl: this.resultCacheTtl });
|
|
371
374
|
while (this.batchResults.size > this.maxCachedBatches) {
|
|
372
375
|
const oldestKey = this.batchResults.keys().next().value;
|
|
373
376
|
this.batchResults.delete(oldestKey);
|
|
@@ -430,7 +433,7 @@ export class BatchScrapeTool extends EventEmitter {
|
|
|
430
433
|
};
|
|
431
434
|
|
|
432
435
|
if (this.enableResultCaching) {
|
|
433
|
-
this._cacheBatchResult(batchId, processedResults);
|
|
436
|
+
this._cacheBatchResult(batchId, processedResults, 'async');
|
|
434
437
|
}
|
|
435
438
|
|
|
436
439
|
this.stats.totalUrls += results.length;
|
|
@@ -186,6 +186,9 @@ export class GenerateLLMsTxtTool {
|
|
|
186
186
|
if (Array.isArray(outputOptions.customGuidelines) && outputOptions.customGuidelines.length > 0) {
|
|
187
187
|
details.push(...outputOptions.customGuidelines);
|
|
188
188
|
}
|
|
189
|
+
if (Array.isArray(outputOptions.customRestrictions) && outputOptions.customRestrictions.length > 0) {
|
|
190
|
+
details.push(`Do not access: ${outputOptions.customRestrictions.join(', ')}.`);
|
|
191
|
+
}
|
|
189
192
|
if (details.length > 0) {
|
|
190
193
|
lines.push(details.join(' '));
|
|
191
194
|
lines.push('');
|
|
@@ -591,6 +594,13 @@ export class GenerateLLMsTxtTool {
|
|
|
591
594
|
lines.push('- Form submissions and interactive content');
|
|
592
595
|
lines.push('- Administrative and configuration areas');
|
|
593
596
|
lines.push('');
|
|
597
|
+
if (Array.isArray(outputOptions.customRestrictions) && outputOptions.customRestrictions.length > 0) {
|
|
598
|
+
lines.push('### Additional Restrictions');
|
|
599
|
+
for (const restriction of outputOptions.customRestrictions) {
|
|
600
|
+
lines.push(`- ${restriction}`);
|
|
601
|
+
}
|
|
602
|
+
lines.push('');
|
|
603
|
+
}
|
|
594
604
|
|
|
595
605
|
// Section 4: APIs and Data Sources
|
|
596
606
|
lines.push('## 4. API and Data Sources');
|
|
@@ -669,6 +679,13 @@ export class GenerateLLMsTxtTool {
|
|
|
669
679
|
lines.push('4. **Cache responsibly:** Cache content appropriately to reduce server load');
|
|
670
680
|
lines.push('5. **Handle errors gracefully:** Implement proper error handling and retries');
|
|
671
681
|
lines.push('');
|
|
682
|
+
if (Array.isArray(outputOptions.customGuidelines) && outputOptions.customGuidelines.length > 0) {
|
|
683
|
+
lines.push('### Site-Specific Guidelines');
|
|
684
|
+
for (const guideline of outputOptions.customGuidelines) {
|
|
685
|
+
lines.push(`- ${guideline}`);
|
|
686
|
+
}
|
|
687
|
+
lines.push('');
|
|
688
|
+
}
|
|
672
689
|
|
|
673
690
|
if (analysis.structure && analysis.structure.robotsTxt) {
|
|
674
691
|
lines.push('### Robots.txt Status');
|
|
@@ -49,7 +49,7 @@ const DeepResearchSchema = z.object({
|
|
|
49
49
|
|
|
50
50
|
// LLM Configuration
|
|
51
51
|
llmConfig: z.object({
|
|
52
|
-
provider: z.enum(['auto', 'openai', 'anthropic']).optional().default('auto'),
|
|
52
|
+
provider: z.enum(['auto', 'openai', 'anthropic', 'ollama']).optional().default('auto'),
|
|
53
53
|
openai: z.object({
|
|
54
54
|
apiKey: z.string().optional(),
|
|
55
55
|
model: z.string().optional().default('gpt-3.5-turbo'),
|
|
@@ -59,6 +59,10 @@ const DeepResearchSchema = z.object({
|
|
|
59
59
|
apiKey: z.string().optional(),
|
|
60
60
|
model: z.string().optional().default('claude-3-haiku-20240307')
|
|
61
61
|
}).optional(),
|
|
62
|
+
ollama: z.object({
|
|
63
|
+
model: z.string().optional(),
|
|
64
|
+
embeddingModel: z.string().optional()
|
|
65
|
+
}).optional(),
|
|
62
66
|
enableSemanticAnalysis: z.boolean().optional().default(true),
|
|
63
67
|
enableIntelligentSynthesis: z.boolean().optional().default(true)
|
|
64
68
|
}).optional(),
|
|
@@ -265,9 +269,23 @@ export class DeepResearchTool {
|
|
|
265
269
|
...baseConfig.searchConfig
|
|
266
270
|
};
|
|
267
271
|
|
|
268
|
-
// Add LLM configuration if provided
|
|
272
|
+
// Add LLM configuration if provided. LLMManager selects on
|
|
273
|
+
// `defaultProvider`; the schema's `provider` was never mapped onto it, so
|
|
274
|
+
// an explicit choice was silently ignored and 'auto' always ran.
|
|
269
275
|
if (params.llmConfig) {
|
|
270
|
-
|
|
276
|
+
const { provider = 'auto', ...llmConfig } = params.llmConfig;
|
|
277
|
+
const cloudKey = {
|
|
278
|
+
openai: llmConfig.openai?.apiKey || process.env.OPENAI_API_KEY,
|
|
279
|
+
anthropic: llmConfig.anthropic?.apiKey || process.env.ANTHROPIC_API_KEY
|
|
280
|
+
};
|
|
281
|
+
if (provider in cloudKey && !cloudKey[provider]) {
|
|
282
|
+
throw new Error(
|
|
283
|
+
`llmConfig.provider "${provider}" needs an API key: pass llmConfig.${provider}.apiKey ` +
|
|
284
|
+
`or set ${provider === 'openai' ? 'OPENAI_API_KEY' : 'ANTHROPIC_API_KEY'}. ` +
|
|
285
|
+
'Omit provider (or pass "ollama") to use the local Ollama, which needs no key.'
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
baseConfig.llmConfig = { ...llmConfig, defaultProvider: provider };
|
|
271
289
|
}
|
|
272
290
|
|
|
273
291
|
// params.cacheResults was previously dropped entirely — only the
|
|
@@ -15,9 +15,11 @@
|
|
|
15
15
|
* cross-subreddit full-text search, but has known post-2023 archive gaps
|
|
16
16
|
* and recurring outages.
|
|
17
17
|
*
|
|
18
|
-
* Routing: scoped searches go to Arctic Shift
|
|
19
|
-
*
|
|
20
|
-
*
|
|
18
|
+
* Routing: scoped searches go to Arctic Shift. An unscoped keyword search
|
|
19
|
+
* finds posts through a site-restricted web search and then reads them — or
|
|
20
|
+
* searches their comments — in the archive by id. PullPush stopped serving
|
|
21
|
+
* automated clients in August 2026 and is used only on explicit request.
|
|
22
|
+
* Both archives are free and need no credentials.
|
|
21
23
|
*
|
|
22
24
|
* Optional official-API path: if the user sets REDDIT_CLIENT_ID and
|
|
23
25
|
* REDDIT_CLIENT_SECRET (their own Reddit app), posts/thread requests can read
|
|
@@ -148,14 +150,15 @@ export class RedditSearchTool {
|
|
|
148
150
|
// comment full-text search, so `comments` mode always uses the archives.
|
|
149
151
|
const officialPossible = this.officialConfigured && (v.mode === 'thread' || v.mode === 'posts');
|
|
150
152
|
|
|
151
|
-
// A Reddit-wide keyword search
|
|
152
|
-
//
|
|
153
|
-
|
|
153
|
+
// A Reddit-wide keyword search can be discovered through a web search and
|
|
154
|
+
// then read out of the archive by ID — posts directly, comments by
|
|
155
|
+
// searching each discovered post's comments for the keywords.
|
|
156
|
+
const discoveryPossible = v.mode !== 'thread' && Boolean(v.query) && !scoped;
|
|
154
157
|
|
|
155
158
|
let order; // backends to try, in order
|
|
156
159
|
if (v.source === 'web_discovery') {
|
|
157
160
|
if (!discoveryPossible) {
|
|
158
|
-
throw new Error('web_discovery only serves unscoped keyword searches
|
|
161
|
+
throw new Error('web_discovery only serves unscoped keyword searches (posts or comments) — it finds posts through a web search and then reads them from the archive');
|
|
159
162
|
}
|
|
160
163
|
order = ['web_discovery'];
|
|
161
164
|
} else if (v.source === 'reddit_api') {
|
|
@@ -187,14 +190,6 @@ export class RedditSearchTool {
|
|
|
187
190
|
order = officialPossible ? ['reddit_api', ...archives] : archives;
|
|
188
191
|
}
|
|
189
192
|
|
|
190
|
-
if (order.length === 0) {
|
|
191
|
-
// Unscoped comment search was PullPush-only, and PullPush no longer
|
|
192
|
-
// serves automated clients. Web discovery identifies posts, not comments.
|
|
193
|
-
throw new Error(
|
|
194
|
-
`An unscoped keyword search of ${v.mode} has no available backend: Arctic Shift requires a subreddit or author scope, and PullPush no longer serves automated clients. Add a subreddit or author filter, or search mode:"posts", which finds posts through a web search and reads them from the archive.`
|
|
195
|
-
);
|
|
196
|
-
}
|
|
197
|
-
|
|
198
193
|
const errors = [];
|
|
199
194
|
for (const source of order) {
|
|
200
195
|
try {
|
|
@@ -226,11 +221,12 @@ export class RedditSearchTool {
|
|
|
226
221
|
|
|
227
222
|
/**
|
|
228
223
|
* Reddit-wide keyword search, in two steps: find matching posts with a
|
|
229
|
-
* site-restricted web search, then read
|
|
230
|
-
*
|
|
231
|
-
*
|
|
232
|
-
*
|
|
233
|
-
*
|
|
224
|
+
* site-restricted web search, then read them out of the Arctic Shift archive
|
|
225
|
+
* by ID — the posts themselves, or (comments mode) each post's comments that
|
|
226
|
+
* match the keywords. Arctic Shift cannot keyword-search across all of
|
|
227
|
+
* Reddit and reddit.com blocks scrapers, so discovery has to come from
|
|
228
|
+
* somewhere else — but what comes back are real archive rows, the same shape
|
|
229
|
+
* a scoped search returns, not scraped search-engine snippets.
|
|
234
230
|
*/
|
|
235
231
|
async #searchWebDiscovery(v) {
|
|
236
232
|
let found;
|
|
@@ -252,7 +248,9 @@ export class RedditSearchTool {
|
|
|
252
248
|
}
|
|
253
249
|
|
|
254
250
|
const notes = [
|
|
255
|
-
|
|
251
|
+
v.mode === 'comments'
|
|
252
|
+
? 'Reddit-wide comment search: posts were found with a site-restricted web search, then each post\'s comments were searched for the keywords in the Arctic Shift archive, in post relevance order.'
|
|
253
|
+
: 'Reddit-wide keyword search: posts were found with a site-restricted web search, then read from the Arctic Shift archive by ID.',
|
|
256
254
|
'Results are ordered by web-search relevance, not by score or date.',
|
|
257
255
|
'Arctic Shift cannot keyword-search across all of Reddit, and PullPush no longer serves automated clients — scope the search to a subreddit or author to query the archive directly.',
|
|
258
256
|
];
|
|
@@ -262,11 +260,47 @@ export class RedditSearchTool {
|
|
|
262
260
|
notes.push('after/before were NOT applied: discovery runs through a web search, which cannot filter by post date. Scope the search to a subreddit or author to use date filters.');
|
|
263
261
|
}
|
|
264
262
|
|
|
263
|
+
const envelope = {
|
|
264
|
+
source: 'web_discovery', mode: v.mode,
|
|
265
|
+
query: v.query ?? null, subreddit: null, author: null,
|
|
266
|
+
};
|
|
265
267
|
if (ids.length === 0) {
|
|
268
|
+
return { ...envelope, count: 0, results: [], notes, checkedAt: new Date().toISOString() };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (v.mode === 'comments') {
|
|
272
|
+
// Arctic Shift's body search needs a scope, and link_id is one: search
|
|
273
|
+
// the discovered posts in relevance order until `limit` comments are in
|
|
274
|
+
// hand. A post whose search fails is skipped and counted, so one
|
|
275
|
+
// throttled request does not discard the others' results.
|
|
276
|
+
const results = [];
|
|
277
|
+
const skipped = [];
|
|
278
|
+
let postsSearched = 0;
|
|
279
|
+
for (const id of ids) {
|
|
280
|
+
if (results.length >= v.limit) break;
|
|
281
|
+
try {
|
|
282
|
+
const data = await this.#get(`${this.arcticBaseUrl}/api/comments/search`, {
|
|
283
|
+
link_id: id,
|
|
284
|
+
body: v.query,
|
|
285
|
+
limit: String(v.limit - results.length),
|
|
286
|
+
sort: v.sort,
|
|
287
|
+
});
|
|
288
|
+
postsSearched++;
|
|
289
|
+
const rows = Array.isArray(data.data) ? data.data : [];
|
|
290
|
+
results.push(...rows.map(normalizeComment));
|
|
291
|
+
} catch (error) {
|
|
292
|
+
skipped.push(`${id}: ${error.message}`);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
if (postsSearched === 0) {
|
|
296
|
+
throw new Error(`comment search failed for every discovered post — ${skipped.join('; ')}`);
|
|
297
|
+
}
|
|
298
|
+
if (skipped.length > 0) {
|
|
299
|
+
notes.push(`${skipped.length} discovered post(s) could not be searched: ${skipped.join('; ')}`);
|
|
300
|
+
}
|
|
266
301
|
return {
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
count: 0, results: [],
|
|
302
|
+
...envelope, count: results.length, results,
|
|
303
|
+
discovered: ids.length, posts_searched: postsSearched,
|
|
270
304
|
notes, checkedAt: new Date().toISOString(),
|
|
271
305
|
};
|
|
272
306
|
}
|
|
@@ -280,9 +314,7 @@ export class RedditSearchTool {
|
|
|
280
314
|
const ordered = ids.map(id => byId.get(id)).filter(Boolean);
|
|
281
315
|
|
|
282
316
|
return {
|
|
283
|
-
|
|
284
|
-
query: v.query ?? null, subreddit: null, author: null,
|
|
285
|
-
count: ordered.length, results: ordered.map(normalizePost),
|
|
317
|
+
...envelope, count: ordered.length, results: ordered.map(normalizePost),
|
|
286
318
|
discovered: ids.length,
|
|
287
319
|
notes, checkedAt: new Date().toISOString(),
|
|
288
320
|
};
|
|
@@ -327,13 +359,45 @@ export class RedditSearchTool {
|
|
|
327
359
|
if (v.query) query.body = v.query;
|
|
328
360
|
if (v.link_id) query.link_id = stripIdPrefix(v.link_id);
|
|
329
361
|
}
|
|
330
|
-
|
|
362
|
+
let data;
|
|
363
|
+
let windowApplied = null;
|
|
364
|
+
try {
|
|
365
|
+
data = await this.#get(`${this.arcticBaseUrl}${path}`, query);
|
|
366
|
+
} catch (error) {
|
|
367
|
+
// Arctic Shift sheds a scoped comment keyword search over its whole
|
|
368
|
+
// history with the same 422 "Timeout. Maybe slow down a bit" it uses for
|
|
369
|
+
// throttling (observed live 2026-08-30: r/webdev body=react failed
|
|
370
|
+
// unbounded and at 7d, answered at 3d). When the caller set no window,
|
|
371
|
+
// narrow it step by step rather than hand the caller the 422.
|
|
372
|
+
const narrowable = v.mode === 'comments' && Boolean(v.query) && !v.after;
|
|
373
|
+
if (!error.retryable || !narrowable) throw error;
|
|
374
|
+
let lastError = error;
|
|
375
|
+
for (const window of ['7d', '3d', '1d']) {
|
|
376
|
+
await new Promise((resolve) => setTimeout(resolve, this.retryDelayMs));
|
|
377
|
+
try {
|
|
378
|
+
data = await this.#getOnce(`${this.arcticBaseUrl}${path}?${new URLSearchParams({ ...query, after: window })}`);
|
|
379
|
+
windowApplied = window;
|
|
380
|
+
break;
|
|
381
|
+
} catch (retryError) {
|
|
382
|
+
lastError = retryError;
|
|
383
|
+
if (!retryError.retryable) throw retryError;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
if (!data) {
|
|
387
|
+
throw new Error(
|
|
388
|
+
`${lastError.message} — Arctic Shift timed out at every window (full history, 7d, 3d, 1d), ` +
|
|
389
|
+
'so its comment search is under load right now. Retry later, pass a narrower after/before, or scope to one post with link_id.'
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
notes.push(`Arctic Shift timed out searching the full history; results are limited to the last ${windowApplied}. Pass after/before to choose the window.`);
|
|
393
|
+
}
|
|
331
394
|
const rows = Array.isArray(data.data) ? data.data : [];
|
|
332
395
|
const results = v.mode === 'posts' ? rows.map(normalizePost) : rows.map(normalizeComment);
|
|
333
396
|
return {
|
|
334
397
|
source: 'arctic_shift', mode: v.mode,
|
|
335
398
|
query: v.query ?? null, subreddit: subreddit ?? null, author: author ?? null,
|
|
336
399
|
count: results.length, results,
|
|
400
|
+
...(windowApplied && { window_applied: windowApplied }),
|
|
337
401
|
notes, checkedAt: new Date().toISOString(),
|
|
338
402
|
};
|
|
339
403
|
}
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* one call — the registry builds the URL, this tool fetches it.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import { TemplateRegistry } from 'crawlforge-extractors';
|
|
14
|
+
import { TemplateRegistry, retiredTemplate } from 'crawlforge-extractors';
|
|
15
15
|
import { safeFetch } from '../../utils/ssrfGuard.js';
|
|
16
16
|
import { preflightFetch } from '../../utils/robotsGate.js';
|
|
17
17
|
import { noteRetryAfter } from '../../utils/hostRateLimiter.js';
|
|
@@ -60,6 +60,12 @@ export class ScrapeTemplateTool {
|
|
|
60
60
|
}
|
|
61
61
|
const detected = this.registry.detect(url);
|
|
62
62
|
if (!detected) {
|
|
63
|
+
// A URL a withdrawn template used to handle gets the reason it is
|
|
64
|
+
// gone, not "no template matches".
|
|
65
|
+
const retired = retiredTemplate(url);
|
|
66
|
+
if (retired) {
|
|
67
|
+
throw badRequest(`The "${retired.id}" template that handled ${url} was retired: ${retired.reason}`);
|
|
68
|
+
}
|
|
63
69
|
throw badRequest(
|
|
64
70
|
`No template matches ${url}. Pass template:"list" to see every template and the URLs ` +
|
|
65
71
|
'each one handles, or name a template explicitly.'
|
|
@@ -74,6 +80,10 @@ export class ScrapeTemplateTool {
|
|
|
74
80
|
// Validate template exists before making network call
|
|
75
81
|
const tpl = this.registry.get(templateId);
|
|
76
82
|
if (!tpl) {
|
|
83
|
+
const retired = retiredTemplate(templateId);
|
|
84
|
+
if (retired) {
|
|
85
|
+
throw badRequest(`Template "${templateId}" was retired: ${retired.reason}`);
|
|
86
|
+
}
|
|
77
87
|
const available = this.registry.list().map(t => t.id).join(', ');
|
|
78
88
|
throw new Error(`Unknown template "${templateId}". Available templates: ${available}`);
|
|
79
89
|
}
|
|
@@ -289,6 +289,7 @@ export class TrackChangesTool extends EventEmitter {
|
|
|
289
289
|
...(comparisonResult.warnings || fetchWarnings.length
|
|
290
290
|
? { warnings: [...(comparisonResult.warnings || []), ...fetchWarnings] }
|
|
291
291
|
: {}),
|
|
292
|
+
...(comparisonResult.alerts ? { alerts: comparisonResult.alerts } : {}),
|
|
292
293
|
snapshot: snapshotInfo, timestamp: Date.now()
|
|
293
294
|
};
|
|
294
295
|
}
|
|
@@ -390,16 +391,36 @@ export class TrackChangesTool extends EventEmitter {
|
|
|
390
391
|
const { url, scheduledMonitorOptions, monitoringOptions, trackingOptions, notificationOptions } = params;
|
|
391
392
|
if (!url) throw new Error('create_scheduled_monitor requires a url');
|
|
392
393
|
const opts = scheduledMonitorOptions || {};
|
|
394
|
+
|
|
395
|
+
// A preset fills in whatever the caller left unspecified; explicit values win.
|
|
396
|
+
let preset = null;
|
|
397
|
+
if (opts.templateId) {
|
|
398
|
+
preset = this.changeTracker.monitoringTemplates.get(opts.templateId);
|
|
399
|
+
if (!preset) {
|
|
400
|
+
throw new Error(
|
|
401
|
+
`Unknown monitoring template "${opts.templateId}". ` +
|
|
402
|
+
`Available: ${Array.from(this.changeTracker.monitoringTemplates.keys()).join(', ')} ` +
|
|
403
|
+
'(operation:"get_monitoring_templates" describes each).'
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
// Precedence: scheduledMonitorOptions > preset > monitoringOptions. The
|
|
408
|
+
// schema fills monitoringOptions.interval/notificationThreshold with
|
|
409
|
+
// defaults, so they cannot sit above a preset without always winning.
|
|
393
410
|
const monitor = await this.scheduler.createMonitor({
|
|
394
411
|
url,
|
|
395
|
-
interval: opts.interval ?? monitoringOptions?.interval,
|
|
412
|
+
interval: opts.interval ?? preset?.frequency ?? monitoringOptions?.interval,
|
|
396
413
|
schedule: opts.schedule,
|
|
397
|
-
goal: opts.goal,
|
|
398
|
-
notificationThreshold: opts.notificationThreshold || monitoringOptions?.notificationThreshold || 'moderate',
|
|
399
|
-
trackingOptions,
|
|
414
|
+
goal: opts.goal ?? preset?.goal,
|
|
415
|
+
notificationThreshold: opts.notificationThreshold || preset?.notificationThreshold || monitoringOptions?.notificationThreshold || 'moderate',
|
|
416
|
+
trackingOptions: preset ? { ...preset.options, ...(trackingOptions || {}) } : trackingOptions,
|
|
400
417
|
notificationOptions
|
|
401
418
|
});
|
|
402
|
-
return {
|
|
419
|
+
return {
|
|
420
|
+
success: true, operation: 'create_scheduled_monitor', url, monitor,
|
|
421
|
+
...(preset ? { templateId: preset.id } : {}),
|
|
422
|
+
firingGuarantee: monitor.firingGuarantee, timestamp: Date.now()
|
|
423
|
+
};
|
|
403
424
|
}
|
|
404
425
|
|
|
405
426
|
async stopScheduledMonitor(params) {
|
|
@@ -424,8 +445,24 @@ export class TrackChangesTool extends EventEmitter {
|
|
|
424
445
|
}
|
|
425
446
|
|
|
426
447
|
async getMonitoringDashboard(params) {
|
|
427
|
-
|
|
448
|
+
// The schema defaults every flag to true, but only inside a dashboardOptions
|
|
449
|
+
// object the caller supplied; an omitted object must mean the same thing.
|
|
450
|
+
const dashboardOptions = { includeRecentAlerts: true, includeTrends: true, includeMonitorStatus: true, ...(params.dashboardOptions || {}) };
|
|
428
451
|
const dashboard = this.changeTracker.getMonitoringDashboard();
|
|
452
|
+
// The tracker only knows tracked pages; the monitors that poll them live here.
|
|
453
|
+
const polling = Array.from(this.activeMonitors.entries()).map(([url, m]) => ({
|
|
454
|
+
id: `poll:${url}`, url, kind: 'polling', status: 'active',
|
|
455
|
+
interval: m.options?.interval ?? null, stats: m.stats
|
|
456
|
+
}));
|
|
457
|
+
if (!this.monitorStore._loaded) await this.monitorStore.load();
|
|
458
|
+
const scheduled = this.scheduler.list().map(m => ({
|
|
459
|
+
id: m.id, url: m.url, kind: 'scheduled',
|
|
460
|
+
status: m.enabled === false ? 'disabled' : m.scheduled ? 'scheduled' : 'idle',
|
|
461
|
+
interval: m.interval, goal: m.goal, nextDueAt: m.nextDueAt,
|
|
462
|
+
lastCheckAt: m.lastCheckAt, lastChangeAt: m.lastChangeAt, stats: m.stats
|
|
463
|
+
}));
|
|
464
|
+
dashboard.monitors = [...polling, ...scheduled];
|
|
465
|
+
dashboard.summary.activeMonitors = dashboard.monitors.length;
|
|
429
466
|
if (!dashboardOptions?.includeRecentAlerts) delete dashboard.recentAlerts;
|
|
430
467
|
if (!dashboardOptions?.includeTrends) delete dashboard.trends;
|
|
431
468
|
if (!dashboardOptions?.includeMonitorStatus) {
|
|
@@ -441,16 +478,22 @@ export class TrackChangesTool extends EventEmitter {
|
|
|
441
478
|
}
|
|
442
479
|
|
|
443
480
|
async createAlertRule(params) {
|
|
444
|
-
const { alertRuleOptions } = params;
|
|
481
|
+
const { url, alertRuleOptions } = params;
|
|
445
482
|
const ruleId = alertRuleOptions?.ruleId || `custom_rule_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
446
|
-
const rule = {
|
|
447
|
-
condition:
|
|
483
|
+
const rule = this.changeTracker.addAlertRule(ruleId, {
|
|
484
|
+
condition: alertRuleOptions?.condition,
|
|
485
|
+
url: url || null,
|
|
448
486
|
actions: alertRuleOptions?.actions || ['webhook'],
|
|
449
|
-
throttle: alertRuleOptions?.throttle
|
|
487
|
+
throttle: alertRuleOptions?.throttle ?? 600000,
|
|
450
488
|
priority: alertRuleOptions?.priority || 'medium'
|
|
489
|
+
});
|
|
490
|
+
// Rules fire from compare: every later compare that matches returns an
|
|
491
|
+
// `alerts` array, and get_dashboard lists what fired.
|
|
492
|
+
return {
|
|
493
|
+
success: true, operation: 'create_alert_rule', ruleId,
|
|
494
|
+
rule: { id: rule.id, url: rule.url, condition: rule.conditionText, actions: rule.actions, throttle: rule.throttle, priority: rule.priority },
|
|
495
|
+
timestamp: Date.now()
|
|
451
496
|
};
|
|
452
|
-
this.changeTracker.alertRules.set(ruleId, rule);
|
|
453
|
-
return { success: true, operation: 'create_alert_rule', ruleId, rule, timestamp: Date.now() };
|
|
454
497
|
}
|
|
455
498
|
|
|
456
499
|
async generateTrendReport(params) {
|
|
@@ -461,7 +504,11 @@ export class TrackChangesTool extends EventEmitter {
|
|
|
461
504
|
async getMonitoringTemplates() {
|
|
462
505
|
const templates = {};
|
|
463
506
|
for (const [id, template] of this.changeTracker.monitoringTemplates.entries()) {
|
|
464
|
-
templates[id] = {
|
|
507
|
+
templates[id] = {
|
|
508
|
+
name: template.name, description: template.description, frequency: template.frequency,
|
|
509
|
+
notificationThreshold: template.notificationThreshold, goal: template.goal,
|
|
510
|
+
options: template.options, alertRules: template.alertRules
|
|
511
|
+
};
|
|
465
512
|
}
|
|
466
513
|
return { success: true, operation: 'get_monitoring_templates', templates, count: Object.keys(templates).length, timestamp: Date.now() };
|
|
467
514
|
}
|
|
@@ -533,20 +580,6 @@ export class TrackChangesTool extends EventEmitter {
|
|
|
533
580
|
stats.newestMonitor = Math.max(...all.map(v => v.started));
|
|
534
581
|
return stats;
|
|
535
582
|
}
|
|
536
|
-
|
|
537
|
-
_parseCondition(conditionString) {
|
|
538
|
-
return (changeResult) => {
|
|
539
|
-
try {
|
|
540
|
-
if (conditionString.includes('significance')) {
|
|
541
|
-
const match = conditionString.match(/significance\s*===\s*["'](\w+)["']/);
|
|
542
|
-
if (match) return changeResult.significance === match[1];
|
|
543
|
-
}
|
|
544
|
-
return false;
|
|
545
|
-
} catch {
|
|
546
|
-
return false;
|
|
547
|
-
}
|
|
548
|
-
};
|
|
549
|
-
}
|
|
550
583
|
}
|
|
551
584
|
|
|
552
585
|
export default TrackChangesTool;
|