crawlforge-mcp-server 6.1.0 → 6.2.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 +1 -1
- package/server.js +5 -97
- package/src/cli/commands/monitor.js +18 -4
- package/src/skills/agent-skills/crawlforge-change-tracking/SKILL.md +53 -14
- package/src/tools/tracking/trackChanges/hosted.js +176 -0
- package/src/tools/tracking/trackChanges/index.js +123 -7
- package/src/tools/tracking/trackChanges/notifier.js +5 -4
- package/src/tools/tracking/trackChanges/schema.js +36 -22
- package/src/core/AlertNotificationSystem.js +0 -602
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "crawlforge-mcp-server",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.2.0",
|
|
4
4
|
"mcpName": "io.github.mysleekdesigns/crawlforge-mcp-server",
|
|
5
5
|
"description": "CrawlForge MCP Server - Professional Model Context Protocol server with 30 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
|
@@ -23,7 +23,7 @@ import { ListOllamaModelsTool } from "./src/tools/extract/listOllamaModels.js";
|
|
|
23
23
|
import { BatchScrapeTool } from "./src/tools/advanced/BatchScrapeTool.js";
|
|
24
24
|
import { ScrapeWithActionsTool } from "./src/tools/advanced/ScrapeWithActionsTool.js";
|
|
25
25
|
import { DeepResearchTool } from "./src/tools/research/deepResearch.js";
|
|
26
|
-
import { TrackChangesTool } from "./src/tools/tracking/trackChanges/index.js";
|
|
26
|
+
import { TrackChangesTool, TRACK_CHANGES_INPUT_SHAPE } from "./src/tools/tracking/trackChanges/index.js";
|
|
27
27
|
import { GenerateLLMsTxtTool } from "./src/tools/llmstxt/generateLLMsTxt.js";
|
|
28
28
|
import { ScrapeTemplateTool } from "./src/tools/templates/ScrapeTemplateTool.js"; // D3.3
|
|
29
29
|
import { UnifiedScrapeTool, SCRAPE_INPUT_SHAPE } from "./src/tools/scrape/unifiedScrape.js"; // D4 D1
|
|
@@ -107,7 +107,7 @@ if (configErrors.length > 0 && config.server.nodeEnv === 'production') {
|
|
|
107
107
|
// Create the server
|
|
108
108
|
const server = new McpServer({
|
|
109
109
|
name: "crawlforge",
|
|
110
|
-
version: "6.
|
|
110
|
+
version: "6.2.0",
|
|
111
111
|
description: "Production-ready MCP server with 30 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.",
|
|
112
112
|
homepage: "https://www.crawlforge.dev",
|
|
113
113
|
icon: "https://www.crawlforge.dev/icon.png",
|
|
@@ -1138,103 +1138,11 @@ registerToolIfEnabled("agent", {
|
|
|
1138
1138
|
|
|
1139
1139
|
// Tool: track_changes
|
|
1140
1140
|
registerToolIfEnabled("track_changes", {
|
|
1141
|
-
description: "Use this to monitor a URL for content changes over time - competitor pricing, regulation updates, product availability. Start with operation:\"create_baseline\", then periodically use operation:\"compare\" to diff; repeated compare calls on the same URL are expected. Supports webhooks and scheduled monitoring. Not for a one-off read (scrape). Cost: 3 credits. Example: track_changes({url: \"https://example.com/pricing\", operation: \"create_baseline\"})",
|
|
1141
|
+
description: "Use this to monitor a URL for content changes over time - competitor pricing, regulation updates, product availability. Start with operation:\"create_baseline\", then periodically use operation:\"compare\" to diff; repeated compare calls on the same URL are expected. Supports webhooks and scheduled monitoring, and scheduledMonitorOptions.hosted:true runs the monitor on CrawlForge's servers with email and signed webhooks. Not for a one-off read (scrape). Cost: 3 credits. Example: track_changes({url: \"https://example.com/pricing\", operation: \"create_baseline\"})",
|
|
1142
1142
|
annotations: { title: "Track Changes", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
1143
|
+
// The tool module owns the schema (G5); this is the same shape it validates with.
|
|
1143
1144
|
inputSchema: {
|
|
1144
|
-
|
|
1145
|
-
operation: z.enum([
|
|
1146
|
-
'create_baseline', 'compare', 'monitor', 'get_history', 'get_stats',
|
|
1147
|
-
'create_scheduled_monitor', 'stop_scheduled_monitor', 'list_scheduled_monitors', 'get_dashboard',
|
|
1148
|
-
'export_history', 'create_alert_rule', 'generate_trend_report', 'get_monitoring_templates'
|
|
1149
|
-
]).default('compare').describe("Tracking operation to perform"),
|
|
1150
|
-
content: z.string().optional().describe("Content to compare against baseline"),
|
|
1151
|
-
html: z.string().optional().describe("HTML content to compare against baseline"),
|
|
1152
|
-
trackingOptions: z.object({
|
|
1153
|
-
granularity: z.enum(['page', 'section', 'element', 'text']).default('section'),
|
|
1154
|
-
trackText: z.boolean().default(true),
|
|
1155
|
-
trackStructure: z.boolean().default(true),
|
|
1156
|
-
trackAttributes: z.boolean().default(false),
|
|
1157
|
-
trackImages: z.boolean().default(false),
|
|
1158
|
-
trackLinks: z.boolean().default(true),
|
|
1159
|
-
ignoreWhitespace: z.boolean().default(true),
|
|
1160
|
-
ignoreCase: z.boolean().default(false),
|
|
1161
|
-
customSelectors: z.array(z.string()).optional(),
|
|
1162
|
-
excludeSelectors: z.array(z.string()).optional(),
|
|
1163
|
-
significanceThresholds: z.object({
|
|
1164
|
-
minor: z.number().min(0).max(1).default(0.1),
|
|
1165
|
-
moderate: z.number().min(0).max(1).default(0.3),
|
|
1166
|
-
major: z.number().min(0).max(1).default(0.7)
|
|
1167
|
-
}).optional()
|
|
1168
|
-
}).optional().describe("Options for how changes are tracked and compared"),
|
|
1169
|
-
monitoringOptions: z.object({
|
|
1170
|
-
enabled: z.boolean().default(false),
|
|
1171
|
-
interval: z.number().min(60000).max(24 * 60 * 60 * 1000).default(300000),
|
|
1172
|
-
maxRetries: z.number().min(0).max(5).default(3),
|
|
1173
|
-
retryDelay: z.number().min(1000).max(60000).default(5000),
|
|
1174
|
-
notificationThreshold: z.enum(['minor', 'moderate', 'major', 'critical']).default('moderate'),
|
|
1175
|
-
enableWebhook: z.boolean().default(false),
|
|
1176
|
-
webhookUrl: z.string().url().optional(),
|
|
1177
|
-
webhookSecret: z.string().optional()
|
|
1178
|
-
}).optional().describe("Monitoring schedule and notification settings"),
|
|
1179
|
-
storageOptions: z.object({
|
|
1180
|
-
enableSnapshots: z.boolean().default(true),
|
|
1181
|
-
retainHistory: z.boolean().default(true),
|
|
1182
|
-
maxHistoryEntries: z.number().min(1).max(1000).default(100),
|
|
1183
|
-
compressionEnabled: z.boolean().default(true),
|
|
1184
|
-
deltaStorageEnabled: z.boolean().default(true)
|
|
1185
|
-
}).optional().describe("Storage and history retention settings"),
|
|
1186
|
-
queryOptions: z.object({
|
|
1187
|
-
limit: z.number().min(1).max(500).default(50),
|
|
1188
|
-
offset: z.number().min(0).default(0),
|
|
1189
|
-
startTime: z.number().optional(),
|
|
1190
|
-
endTime: z.number().optional(),
|
|
1191
|
-
includeContent: z.boolean().default(false),
|
|
1192
|
-
significanceFilter: z.enum(['all', 'minor', 'moderate', 'major', 'critical']).optional()
|
|
1193
|
-
}).optional().describe("Query options for history and stats retrieval"),
|
|
1194
|
-
notificationOptions: z.object({
|
|
1195
|
-
webhook: z.object({
|
|
1196
|
-
enabled: z.boolean().default(false),
|
|
1197
|
-
url: z.string().url().optional(),
|
|
1198
|
-
method: z.enum(['POST', 'PUT']).default('POST'),
|
|
1199
|
-
headers: z.record(z.string()).optional(),
|
|
1200
|
-
signingSecret: z.string().optional(),
|
|
1201
|
-
includeContent: z.boolean().default(false)
|
|
1202
|
-
}).optional(),
|
|
1203
|
-
slack: z.object({
|
|
1204
|
-
enabled: z.boolean().default(false),
|
|
1205
|
-
webhookUrl: z.string().url().optional(),
|
|
1206
|
-
channel: z.string().optional(),
|
|
1207
|
-
username: z.string().optional()
|
|
1208
|
-
}).optional()
|
|
1209
|
-
}).optional().describe("Notification configuration for webhooks and Slack"),
|
|
1210
|
-
scheduledMonitorOptions: z.object({
|
|
1211
|
-
schedule: z.string().optional().describe("Optional cron expression (power users)"),
|
|
1212
|
-
templateId: z.string().optional(),
|
|
1213
|
-
enabled: z.boolean().default(true),
|
|
1214
|
-
interval: z.number().min(60000).optional().describe("Polling interval in ms (default 1h)"),
|
|
1215
|
-
goal: z.string().optional().describe("Plain-English alert goal; an LLM judges whether a change matches (degrades to threshold if no LLM)"),
|
|
1216
|
-
monitorId: z.string().optional().describe("Monitor id for stop_scheduled_monitor"),
|
|
1217
|
-
notificationThreshold: z.enum(['minor', 'moderate', 'major', 'critical']).optional()
|
|
1218
|
-
}).optional().describe("Scheduled monitoring: recurring compare + notify, optional plain-English goal"),
|
|
1219
|
-
alertRuleOptions: z.object({
|
|
1220
|
-
ruleId: z.string().optional(),
|
|
1221
|
-
condition: z.string().optional(),
|
|
1222
|
-
actions: z.array(z.enum(['webhook', 'email', 'slack'])).optional(),
|
|
1223
|
-
throttle: z.number().min(0).optional(),
|
|
1224
|
-
priority: z.enum(['low', 'medium', 'high']).optional()
|
|
1225
|
-
}).optional().describe("Alert rule configuration for change notifications"),
|
|
1226
|
-
exportOptions: z.object({
|
|
1227
|
-
format: z.enum(['json', 'csv']).default('json'),
|
|
1228
|
-
startTime: z.number().optional(),
|
|
1229
|
-
endTime: z.number().optional(),
|
|
1230
|
-
includeContent: z.boolean().default(false),
|
|
1231
|
-
includeSnapshots: z.boolean().default(false)
|
|
1232
|
-
}).optional().describe("Export options for change history data"),
|
|
1233
|
-
dashboardOptions: z.object({
|
|
1234
|
-
includeRecentAlerts: z.boolean().default(true),
|
|
1235
|
-
includeTrends: z.boolean().default(true),
|
|
1236
|
-
includeMonitorStatus: z.boolean().default(true)
|
|
1237
|
-
}).optional().describe("Dashboard display options"),
|
|
1145
|
+
...TRACK_CHANGES_INPUT_SHAPE,
|
|
1238
1146
|
...COMPLIANCE_PARAMS
|
|
1239
1147
|
}
|
|
1240
1148
|
}, withAuth("track_changes", async (params) => {
|
|
@@ -61,19 +61,33 @@ export function register(program) {
|
|
|
61
61
|
.option('--threshold <level>', 'Notification threshold: minor|moderate|major|critical', 'moderate')
|
|
62
62
|
.option('--cron <expr>', 'Optional cron expression (advanced)')
|
|
63
63
|
.option('--selector <css>', 'CSS selector to scope monitoring')
|
|
64
|
+
.option('--hosted', "Run the monitor on CrawlForge's servers (fires without this process; email + signed webhooks; 3 credits per compared target per check)")
|
|
65
|
+
.option('--email <addresses>', 'Comma-separated notification emails (sent by hosted monitors only)')
|
|
66
|
+
.option('--name <text>', 'Display name for a hosted monitor (default: the URL host)')
|
|
64
67
|
.action(async (url, opts) => {
|
|
65
68
|
const tool = new TrackChangesTool(getToolConfig('track_changes'));
|
|
69
|
+
const notificationOptions = {
|
|
70
|
+
...(opts.webhook ? { webhook: { enabled: true, url: opts.webhook } } : {}),
|
|
71
|
+
...(opts.email ? { email: { enabled: true, recipients: opts.email.split(',').map((s) => s.trim()).filter(Boolean) } } : {})
|
|
72
|
+
};
|
|
73
|
+
if (opts.email && !opts.hosted) {
|
|
74
|
+
process.stderr.write('Warning: local monitors do not send email; add --hosted for --email to take effect.\n');
|
|
75
|
+
}
|
|
66
76
|
try {
|
|
67
77
|
const res = await tool.execute({
|
|
68
78
|
url,
|
|
69
79
|
operation: 'create_scheduled_monitor',
|
|
70
80
|
...(opts.selector ? { trackingOptions: { customSelectors: [opts.selector] } } : {}),
|
|
71
|
-
...(
|
|
81
|
+
...(Object.keys(notificationOptions).length ? { notificationOptions } : {}),
|
|
72
82
|
scheduledMonitorOptions: {
|
|
73
83
|
interval: Math.max(parseInt(opts.every, 10), 60) * 1000,
|
|
74
84
|
...(opts.goal ? { goal: opts.goal } : {}),
|
|
75
85
|
...(opts.cron ? { schedule: opts.cron } : {}),
|
|
76
|
-
|
|
86
|
+
...(opts.hosted ? { hosted: true } : {}),
|
|
87
|
+
...(opts.name ? { name: opts.name } : {}),
|
|
88
|
+
// Local only: a hosted check has no significance threshold, and
|
|
89
|
+
// the option's default would otherwise warn on every hosted create.
|
|
90
|
+
...(opts.hosted ? {} : { notificationThreshold: opts.threshold })
|
|
77
91
|
}
|
|
78
92
|
});
|
|
79
93
|
emit(res);
|
|
@@ -86,7 +100,7 @@ export function register(program) {
|
|
|
86
100
|
|
|
87
101
|
program
|
|
88
102
|
.command('monitor:list')
|
|
89
|
-
.description('List
|
|
103
|
+
.description('List scheduled monitors (local and hosted)')
|
|
90
104
|
.action(async () => {
|
|
91
105
|
const tool = new TrackChangesTool(getToolConfig('track_changes'));
|
|
92
106
|
try {
|
|
@@ -100,7 +114,7 @@ export function register(program) {
|
|
|
100
114
|
|
|
101
115
|
program
|
|
102
116
|
.command('monitor:stop <id>')
|
|
103
|
-
.description('Stop and remove a scheduled monitor by id')
|
|
117
|
+
.description('Stop and remove a scheduled monitor by id (local or hosted)')
|
|
104
118
|
.action(async (id) => {
|
|
105
119
|
const tool = new TrackChangesTool(getToolConfig('track_changes'));
|
|
106
120
|
try {
|
|
@@ -68,7 +68,14 @@ CLI: `crawlforge track https://example.com --selector ".price" --threshold 1`.
|
|
|
68
68
|
|
|
69
69
|
## Scheduled monitoring & notifications
|
|
70
70
|
|
|
71
|
-
|
|
71
|
+
`create_scheduled_monitor` repeats `compare` on a schedule and notifies on
|
|
72
|
+
change. It comes in two kinds.
|
|
73
|
+
|
|
74
|
+
**Local** (default): persisted in `~/.crawlforge/monitors/`; fires in-process
|
|
75
|
+
only while this MCP server runs (missed runs catch up on restart; `crawlforge
|
|
76
|
+
monitor:run-due` from system cron guarantees firing). Notifies by webhook or
|
|
77
|
+
Slack — never email. `goal` (plain-English LLM judge) and
|
|
78
|
+
`notificationThreshold` apply to local monitors only.
|
|
72
79
|
|
|
73
80
|
```json
|
|
74
81
|
{
|
|
@@ -76,19 +83,47 @@ Run continuous monitoring with webhooks instead of polling manually:
|
|
|
76
83
|
"params": {
|
|
77
84
|
"url": "https://example.com/pricing",
|
|
78
85
|
"operation": "create_scheduled_monitor",
|
|
79
|
-
"
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
86
|
+
"scheduledMonitorOptions": { "interval": 1800000, "notificationThreshold": "moderate" },
|
|
87
|
+
"notificationOptions": { "webhook": { "enabled": true, "url": "https://my-site.com/notify" } }
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
**Hosted** (`scheduledMonitorOptions.hosted: true`): registered with
|
|
93
|
+
CrawlForge's hosted monitors API under your API key; nothing is created locally
|
|
94
|
+
and this process never fetches the page. CrawlForge's own scheduler runs every
|
|
95
|
+
check whether or not this process is alive, records it, and notifies by email
|
|
96
|
+
and signed webhook on every changed, new, blocked or errored page. Passing
|
|
97
|
+
`goal` or `notificationThreshold` adds a `warnings` entry.
|
|
98
|
+
|
|
99
|
+
```json
|
|
100
|
+
{
|
|
101
|
+
"tool": "track_changes",
|
|
102
|
+
"params": {
|
|
103
|
+
"url": "https://example.com/pricing",
|
|
104
|
+
"operation": "create_scheduled_monitor",
|
|
105
|
+
"trackingOptions": { "customSelectors": [".price"] },
|
|
106
|
+
"scheduledMonitorOptions": { "hosted": true, "interval": 21600000 },
|
|
107
|
+
"notificationOptions": {
|
|
108
|
+
"email": { "enabled": true, "recipients": ["you@example.com"] },
|
|
109
|
+
"webhook": { "enabled": true, "url": "https://my-site.com/notify" }
|
|
85
110
|
}
|
|
86
111
|
}
|
|
87
112
|
}
|
|
88
113
|
```
|
|
89
114
|
|
|
90
|
-
|
|
91
|
-
`
|
|
115
|
+
Hosted mapping: each `customSelectors` entry becomes a target selector on the
|
|
116
|
+
URL; `schedule` (cron) passes through, otherwise `interval` becomes a cron of
|
|
117
|
+
5–60 minutes dividing 60, whole hours dividing 24, or daily — other values
|
|
118
|
+
round to the nearest and `warnings` says what they became. Up to 5 email
|
|
119
|
+
recipients. `webhook.signingSecret` (16–128 chars) becomes the webhook secret;
|
|
120
|
+
omit it and the response returns a generated `webhookSecret`. The response's
|
|
121
|
+
`monitor` carries the hosted `id`, `nextRunAt`, `estimatedCreditsPerMonth` and
|
|
122
|
+
a `dashboardUrl` for managing it.
|
|
123
|
+
|
|
124
|
+
CLI: `crawlforge monitor:create <url> --every 1800 --webhook <url>` (local) or
|
|
125
|
+
`crawlforge monitor:create <url> --hosted --email you@example.com` (hosted);
|
|
126
|
+
`monitor:list` shows both kinds and `monitor:stop <id>` removes either.
|
|
92
127
|
|
|
93
128
|
## Other operations
|
|
94
129
|
|
|
@@ -99,10 +134,12 @@ CLI (runs until Ctrl+C):
|
|
|
99
134
|
| `monitor` | One monitoring pass. |
|
|
100
135
|
| `get_history` | Retrieve past change records (`queryOptions`). |
|
|
101
136
|
| `get_stats` | Summary statistics for a tracked URL. |
|
|
102
|
-
| `create_scheduled_monitor`
|
|
137
|
+
| `create_scheduled_monitor` | Recurring `compare` + notify; local by default, `scheduledMonitorOptions.hosted: true` for a CrawlForge-run monitor (see above). |
|
|
138
|
+
| `list_scheduled_monitors` | Local monitors (`hosted: false`) then hosted ones (`hosted: true`), with `localCount`/`hostedCount`; `hostedError` if the website is unreachable. |
|
|
139
|
+
| `stop_scheduled_monitor` | By `scheduledMonitorOptions.monitorId`: stops a local monitor, or deletes the hosted one with that id. By `url` alone: stops every local monitor on it and deletes hosted monitors whose targets are all that exact URL. |
|
|
103
140
|
| `get_dashboard` | Aggregate status, recent alerts, trends. |
|
|
104
141
|
| `export_history` | Export change history as `json` or `csv`. |
|
|
105
|
-
| `create_alert_rule` | Conditional alerts (webhook / email
|
|
142
|
+
| `create_alert_rule` | Conditional alerts fired from `compare` (webhook / slack; the email action is not sent by a local process — use a hosted monitor for email). |
|
|
106
143
|
| `generate_trend_report` | Trend analysis over time. |
|
|
107
144
|
| `get_monitoring_templates` | List built-in monitoring presets. |
|
|
108
145
|
|
|
@@ -111,6 +148,8 @@ against the baseline without re-fetching.
|
|
|
111
148
|
|
|
112
149
|
## Cost note
|
|
113
150
|
|
|
114
|
-
`track_changes` = 3 credits per call
|
|
115
|
-
|
|
116
|
-
|
|
151
|
+
`track_changes` = 3 credits per call, except that creating a hosted monitor or
|
|
152
|
+
stopping a hosted-only one charges 0 (3 is the projected ceiling). A typical
|
|
153
|
+
watch is one `create_baseline` plus periodic `compare` calls, or one scheduled
|
|
154
|
+
monitor. Each hosted check bills 3 credits per compared target to your account;
|
|
155
|
+
blocked and errored targets are free.
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TrackChanges — hosted monitors (Phase 6.1).
|
|
3
|
+
*
|
|
4
|
+
* `scheduledMonitorOptions.hosted: true` registers a monitor with the
|
|
5
|
+
* website's /api/v1/monitors instead of the local MonitorStore. The website's
|
|
6
|
+
* cron then fetches, compares, bills the account and sends the notifications
|
|
7
|
+
* (email, signed webhooks), so the monitor fires whether or not this process
|
|
8
|
+
* is alive. This module is the thin client; index.js decides when to use it
|
|
9
|
+
* and shapes the tool results.
|
|
10
|
+
*
|
|
11
|
+
* These calls go to our own configured backend (AuthManager.apiEndpoint, from
|
|
12
|
+
* CRAWLFORGE_API_URL through endpointGuard), not to a caller-supplied URL, so
|
|
13
|
+
* they use bare fetch with the X-API-Key header exactly as AuthManager does.
|
|
14
|
+
* The SSRF guard is for pages a caller names; the endpoint is legitimately
|
|
15
|
+
* localhost in development.
|
|
16
|
+
*/
|
|
17
|
+
import authManager from '../../../core/AuthManager.js';
|
|
18
|
+
|
|
19
|
+
const HOSTED_TIMEOUT_MS = 30_000;
|
|
20
|
+
const MINUTE = 60_000;
|
|
21
|
+
const HOUR = 60 * MINUTE;
|
|
22
|
+
|
|
23
|
+
export const HOSTED_FIRING_GUARANTEE_NOTE =
|
|
24
|
+
"Runs from CrawlForge's scheduler whether or not this process is alive. Each check bills " +
|
|
25
|
+
'3 credits per compared target from the account; blocked and errored targets are free.';
|
|
26
|
+
|
|
27
|
+
export const NO_KEY_MESSAGE =
|
|
28
|
+
'hosted monitors need a CrawlForge API key — run `crawlforge-setup` or `crawlforge login`';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The endpoint and key the hosted calls authenticate with, from the sources
|
|
32
|
+
* the server and the CLI already use: CRAWLFORGE_API_KEY (the CLI's preAction
|
|
33
|
+
* hook fills it from --api-key or the stored config), then the key AuthManager
|
|
34
|
+
* loaded at startup, then the stored config read directly — the server skips
|
|
35
|
+
* loading it in creator mode, and a hosted monitor is billed to an account
|
|
36
|
+
* either way. No network: initialize() would re-validate the key.
|
|
37
|
+
*/
|
|
38
|
+
export async function resolveHostedCredentials() {
|
|
39
|
+
let apiKey = process.env.CRAWLFORGE_API_KEY || authManager.getConfig()?.apiKey;
|
|
40
|
+
if (!apiKey) {
|
|
41
|
+
try {
|
|
42
|
+
await authManager.loadConfig();
|
|
43
|
+
apiKey = authManager.getConfig()?.apiKey;
|
|
44
|
+
} catch {
|
|
45
|
+
/* no stored config */
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (!apiKey) throw new Error(NO_KEY_MESSAGE);
|
|
49
|
+
return { endpoint: authManager.apiEndpoint, apiKey };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// resolveApiEndpoint keeps a trailing slash on the configured endpoint; joined
|
|
53
|
+
// to an absolute path that is `//api/...`, a redirect on every call and a
|
|
54
|
+
// dashboard link with a double slash.
|
|
55
|
+
const base = (endpoint) => String(endpoint).replace(/\/+$/, '');
|
|
56
|
+
|
|
57
|
+
async function request(method, pathname, creds, body) {
|
|
58
|
+
const response = await fetch(`${base(creds.endpoint)}${pathname}`, {
|
|
59
|
+
method,
|
|
60
|
+
headers: {
|
|
61
|
+
'X-API-Key': creds.apiKey,
|
|
62
|
+
...(body ? { 'Content-Type': 'application/json' } : {})
|
|
63
|
+
},
|
|
64
|
+
...(body ? { body: JSON.stringify(body) } : {}),
|
|
65
|
+
signal: AbortSignal.timeout(HOSTED_TIMEOUT_MS)
|
|
66
|
+
});
|
|
67
|
+
let payload = null;
|
|
68
|
+
try {
|
|
69
|
+
payload = await response.json();
|
|
70
|
+
} catch {
|
|
71
|
+
/* no JSON body */
|
|
72
|
+
}
|
|
73
|
+
if (!response.ok) {
|
|
74
|
+
// `{ error: { code, message, details? } }` from the monitors API; the
|
|
75
|
+
// API-key middleware's 401 is the same envelope. The website's own words
|
|
76
|
+
// reach the caller so a validation or robots refusal is readable.
|
|
77
|
+
const err = payload?.error;
|
|
78
|
+
const code = err?.code || `HTTP_${response.status}`;
|
|
79
|
+
const message = (typeof err === 'string' ? err : err?.message) || response.statusText || 'request failed';
|
|
80
|
+
const details = err?.details !== undefined ? ` ${JSON.stringify(err.details)}` : '';
|
|
81
|
+
const failure = new Error(`${code}: ${message}${details}`);
|
|
82
|
+
failure.code = code;
|
|
83
|
+
failure.status = response.status;
|
|
84
|
+
throw failure;
|
|
85
|
+
}
|
|
86
|
+
return payload?.data;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function createHostedMonitor(input, creds) {
|
|
90
|
+
return request('POST', '/api/v1/monitors', creds, input);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function listHostedMonitors(creds) {
|
|
94
|
+
// An account holds at most 50 monitors, so one page is the whole list.
|
|
95
|
+
return (await request('GET', '/api/v1/monitors?limit=100', creds)) ?? [];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function deleteHostedMonitor(id, creds) {
|
|
99
|
+
return request('DELETE', `/api/v1/monitors/${encodeURIComponent(id)}`, creds);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// The cron slots the website accepts: consecutive runs at least 5 minutes
|
|
103
|
+
// apart. For a `*/N` minute step that means N must divide 60 (`*/7` has a
|
|
104
|
+
// 4-minute gap at the top of every hour); for an hour step, H must divide 24.
|
|
105
|
+
const SLOTS = [
|
|
106
|
+
...[5, 6, 10, 12, 15, 20, 30].map((m) => ({ ms: m * MINUTE, cron: `*/${m} * * * *` })),
|
|
107
|
+
{ ms: HOUR, cron: '0 * * * *' },
|
|
108
|
+
...[2, 3, 4, 6, 8, 12].map((h) => ({ ms: h * HOUR, cron: `0 */${h} * * *` })),
|
|
109
|
+
{ ms: 24 * HOUR, cron: '0 0 * * *' }
|
|
110
|
+
];
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The hosted schedule for a polling interval in ms.
|
|
114
|
+
* @returns {{ cron: string, effectiveIntervalMs: number, adjusted: boolean }}
|
|
115
|
+
* `adjusted` is true when the interval was not an accepted slot and the
|
|
116
|
+
* nearest one was used; a tie goes to the longer interval (fewer billed checks).
|
|
117
|
+
*/
|
|
118
|
+
export function intervalToCron(ms) {
|
|
119
|
+
let best = SLOTS[0];
|
|
120
|
+
for (const slot of SLOTS) {
|
|
121
|
+
const d = Math.abs(slot.ms - ms);
|
|
122
|
+
const bestD = Math.abs(best.ms - ms);
|
|
123
|
+
if (d < bestD || (d === bestD && slot.ms > best.ms)) best = slot;
|
|
124
|
+
}
|
|
125
|
+
return { cron: best.cron, effectiveIntervalMs: best.ms, adjusted: best.ms !== ms };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function formatInterval(ms) {
|
|
129
|
+
return ms % HOUR === 0 ? `${ms / HOUR} h` : `${Math.round(ms / MINUTE)} min`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const parseIso = (iso) => (iso ? Date.parse(iso) || null : null);
|
|
133
|
+
|
|
134
|
+
export function hostedDashboardUrl(endpoint, id) {
|
|
135
|
+
return `${base(endpoint)}/dashboard/monitors/${id}`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The `monitor` a hosted create_scheduled_monitor returns. */
|
|
139
|
+
export function createdHostedMonitor(record, endpoint) {
|
|
140
|
+
return {
|
|
141
|
+
id: record.id,
|
|
142
|
+
hosted: true,
|
|
143
|
+
name: record.name,
|
|
144
|
+
targets: record.targets,
|
|
145
|
+
schedule: record.schedule_cron,
|
|
146
|
+
timezone: record.timezone,
|
|
147
|
+
notifyEmails: record.notify_emails,
|
|
148
|
+
webhookUrl: record.webhook_url,
|
|
149
|
+
webhookSecret: record.webhook_secret,
|
|
150
|
+
status: record.status,
|
|
151
|
+
nextRunAt: parseIso(record.next_run_at),
|
|
152
|
+
estimatedCreditsPerMonth: record.estimated_credits_per_month,
|
|
153
|
+
dashboardUrl: hostedDashboardUrl(endpoint, record.id)
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** A hosted monitor as list_scheduled_monitors shows it, beside the local ones. */
|
|
158
|
+
export function listedHostedMonitor(record, endpoint) {
|
|
159
|
+
const active = record.status === 'active';
|
|
160
|
+
return {
|
|
161
|
+
id: record.id,
|
|
162
|
+
hosted: true,
|
|
163
|
+
url: record.targets?.[0]?.url,
|
|
164
|
+
targets: record.targets,
|
|
165
|
+
name: record.name,
|
|
166
|
+
schedule: record.schedule_cron,
|
|
167
|
+
timezone: record.timezone,
|
|
168
|
+
enabled: active,
|
|
169
|
+
nextDueAt: parseIso(record.next_run_at),
|
|
170
|
+
lastCheckAt: parseIso(record.last_check_at),
|
|
171
|
+
lastCheck: record.last_check ?? null,
|
|
172
|
+
estimatedCreditsPerMonth: record.estimated_credits_per_month,
|
|
173
|
+
dashboardUrl: hostedDashboardUrl(endpoint, record.id),
|
|
174
|
+
scheduled: active
|
|
175
|
+
};
|
|
176
|
+
}
|
|
@@ -21,10 +21,18 @@ import SnapshotManager from '../../../core/SnapshotManager.js';
|
|
|
21
21
|
import CacheManager from '../../../core/cache/CacheManager.js';
|
|
22
22
|
import { MonitorStore } from '../../../core/MonitorStore.js';
|
|
23
23
|
import { MonitorScheduler } from '../../../core/MonitorScheduler.js';
|
|
24
|
+
import { setActualCost } from '../../../server/requestContext.js';
|
|
24
25
|
import { TrackChangesSchema } from './schema.js';
|
|
25
26
|
import { fetchContent, mergeHistoryData, matchesSignificanceFilter, calculateAverageInterval, calculateSignificanceDistribution } from './differ.js';
|
|
26
27
|
import { performMonitoringCheck, stopMonitor } from './monitor.js';
|
|
27
28
|
import { sendNotifications } from './notifier.js';
|
|
29
|
+
import {
|
|
30
|
+
HOSTED_FIRING_GUARANTEE_NOTE, createHostedMonitor, createdHostedMonitor, deleteHostedMonitor,
|
|
31
|
+
formatInterval, intervalToCron, listHostedMonitors, listedHostedMonitor, resolveHostedCredentials
|
|
32
|
+
} from './hosted.js';
|
|
33
|
+
|
|
34
|
+
// server.js spreads this into the registered inputSchema (G5: one declaration).
|
|
35
|
+
export { TRACK_CHANGES_INPUT_SHAPE } from './schema.js';
|
|
28
36
|
|
|
29
37
|
export class TrackChangesTool extends EventEmitter {
|
|
30
38
|
constructor(options = {}) {
|
|
@@ -42,6 +50,9 @@ export class TrackChangesTool extends EventEmitter {
|
|
|
42
50
|
enableRealTimeMonitoring: true,
|
|
43
51
|
maxConcurrentMonitors: 50,
|
|
44
52
|
defaultPollingInterval: 300000,
|
|
53
|
+
// The key and endpoint hosted monitors authenticate with; tests inject
|
|
54
|
+
// a stub so nothing reads ~/.crawlforge or reaches the website.
|
|
55
|
+
resolveHostedCredentials,
|
|
45
56
|
...options
|
|
46
57
|
};
|
|
47
58
|
|
|
@@ -404,6 +415,9 @@ export class TrackChangesTool extends EventEmitter {
|
|
|
404
415
|
);
|
|
405
416
|
}
|
|
406
417
|
}
|
|
418
|
+
if (opts.hosted) {
|
|
419
|
+
return this._createHostedMonitor({ url, opts, preset, trackingOptions, notificationOptions });
|
|
420
|
+
}
|
|
407
421
|
// Precedence: scheduledMonitorOptions > preset > monitoringOptions. The
|
|
408
422
|
// schema fills monitoringOptions.interval/notificationThreshold with
|
|
409
423
|
// defaults, so they cannot sit above a preset without always winning.
|
|
@@ -423,25 +437,127 @@ export class TrackChangesTool extends EventEmitter {
|
|
|
423
437
|
};
|
|
424
438
|
}
|
|
425
439
|
|
|
440
|
+
/**
|
|
441
|
+
* Hosted (6.1): the website's /api/v1/monitors owns the monitor — its cron
|
|
442
|
+
* fetches, compares, bills and notifies — so nothing is stored or fetched
|
|
443
|
+
* here. The interval precedence matches the local path except that
|
|
444
|
+
* monitoringOptions.interval (schema-defaulted to 5 min) is not consulted:
|
|
445
|
+
* a hosted check is billed, and the website's own default is hourly.
|
|
446
|
+
*/
|
|
447
|
+
async _createHostedMonitor({ url, opts, preset, trackingOptions, notificationOptions }) {
|
|
448
|
+
const creds = await this.options.resolveHostedCredentials();
|
|
449
|
+
const warnings = [];
|
|
450
|
+
let scheduleCron = opts.schedule;
|
|
451
|
+
const interval = opts.interval ?? preset?.frequency;
|
|
452
|
+
if (!scheduleCron && interval) {
|
|
453
|
+
const slot = intervalToCron(interval);
|
|
454
|
+
scheduleCron = slot.cron;
|
|
455
|
+
if (slot.adjusted) {
|
|
456
|
+
warnings.push(
|
|
457
|
+
`interval ${formatInterval(interval)} is not a hosted schedule slot; the monitor runs every ` +
|
|
458
|
+
`${formatInterval(slot.effectiveIntervalMs)} (${slot.cron}). Hosted runs are at least 5 minutes apart ` +
|
|
459
|
+
'and divide the hour or the day evenly.'
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
if (opts.goal ?? preset?.goal) {
|
|
464
|
+
warnings.push('goal is judged by the local goal judge only and is not applied to a hosted monitor, which notifies on every changed, new, blocked or errored page');
|
|
465
|
+
}
|
|
466
|
+
if (opts.notificationThreshold) {
|
|
467
|
+
warnings.push('notificationThreshold has no effect on a hosted monitor; hosted checks have no significance threshold');
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const tracking = preset ? { ...preset.options, ...(trackingOptions || {}) } : (trackingOptions || {});
|
|
471
|
+
const selectors = tracking.customSelectors || [];
|
|
472
|
+
const email = notificationOptions?.email;
|
|
473
|
+
const webhook = notificationOptions?.webhook;
|
|
474
|
+
const secret = webhook?.signingSecret;
|
|
475
|
+
const record = await createHostedMonitor({
|
|
476
|
+
name: opts.name || new URL(url).host.slice(0, 80),
|
|
477
|
+
targets: selectors.length ? selectors.map((selector) => ({ url, selector })) : [{ url }],
|
|
478
|
+
...(scheduleCron ? { schedule_cron: scheduleCron } : {}),
|
|
479
|
+
timezone: 'UTC',
|
|
480
|
+
...(email?.enabled && email.recipients?.length ? { notify_emails: email.recipients } : {}),
|
|
481
|
+
...(webhook?.enabled && webhook.url ? { webhook_url: webhook.url } : {}),
|
|
482
|
+
// A secret outside 16-128 chars is left out so the website generates one.
|
|
483
|
+
...(webhook?.enabled && webhook.url && secret?.length >= 16 && secret.length <= 128 ? { webhook_secret: secret } : {}),
|
|
484
|
+
status: 'active'
|
|
485
|
+
}, creds);
|
|
486
|
+
// Nothing ran on this machine and the monitors API is free (G4).
|
|
487
|
+
setActualCost(0);
|
|
488
|
+
return {
|
|
489
|
+
success: true, operation: 'create_scheduled_monitor', url, hosted: true,
|
|
490
|
+
...(preset ? { templateId: preset.id } : {}),
|
|
491
|
+
monitor: createdHostedMonitor(record, creds.endpoint),
|
|
492
|
+
firingGuarantee: HOSTED_FIRING_GUARANTEE_NOTE,
|
|
493
|
+
...(warnings.length ? { warnings } : {}),
|
|
494
|
+
timestamp: Date.now()
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
|
|
426
498
|
async stopScheduledMonitor(params) {
|
|
427
499
|
const { url, scheduledMonitorOptions } = params;
|
|
428
500
|
const monitorId = scheduledMonitorOptions?.monitorId;
|
|
429
501
|
if (monitorId) {
|
|
430
|
-
|
|
431
|
-
if (
|
|
432
|
-
|
|
502
|
+
if (!this.monitorStore._loaded) await this.monitorStore.load();
|
|
503
|
+
if (this.monitorStore.get(monitorId)) {
|
|
504
|
+
await this.scheduler.stopMonitor(monitorId);
|
|
505
|
+
return { success: true, operation: 'stop_scheduled_monitor', monitorId, stopped: true, timestamp: Date.now() };
|
|
433
506
|
}
|
|
434
|
-
|
|
507
|
+
// Not in the local store: it may be hosted.
|
|
508
|
+
try {
|
|
509
|
+
await deleteHostedMonitor(monitorId, await this.options.resolveHostedCredentials());
|
|
510
|
+
} catch (error) {
|
|
511
|
+
const reason = error.status === 404 ? '' : ` (hosted lookup failed: ${error.message})`;
|
|
512
|
+
return { success: false, operation: 'stop_scheduled_monitor', monitorId, stopped: false, error: `No scheduled monitor found with id ${monitorId}${reason}`, timestamp: Date.now() };
|
|
513
|
+
}
|
|
514
|
+
// Nothing ran on this machine and the monitors API is free (G4).
|
|
515
|
+
setActualCost(0);
|
|
516
|
+
return { success: true, operation: 'stop_scheduled_monitor', monitorId, stopped: true, hosted: true, timestamp: Date.now() };
|
|
435
517
|
}
|
|
436
518
|
if (!url) throw new Error('stop_scheduled_monitor requires a url or scheduledMonitorOptions.monitorId');
|
|
437
519
|
const result = await this.scheduler.stopByUrl(url);
|
|
438
|
-
|
|
520
|
+
// Only a hosted monitor whose every target is this URL — never a
|
|
521
|
+
// multi-target monitor that merely includes it.
|
|
522
|
+
let stoppedHosted = 0;
|
|
523
|
+
let hostedError = null;
|
|
524
|
+
try {
|
|
525
|
+
const creds = await this.options.resolveHostedCredentials();
|
|
526
|
+
for (const m of await listHostedMonitors(creds)) {
|
|
527
|
+
if (m.targets?.length && m.targets.every((t) => t.url === url)) {
|
|
528
|
+
await deleteHostedMonitor(m.id, creds);
|
|
529
|
+
stoppedHosted++;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
} catch (error) {
|
|
533
|
+
hostedError = error.message;
|
|
534
|
+
}
|
|
535
|
+
return {
|
|
536
|
+
success: true, operation: 'stop_scheduled_monitor', url, stoppedMonitors: result.stopped, stoppedHosted,
|
|
537
|
+
...(hostedError ? { hostedError } : {}),
|
|
538
|
+
timestamp: Date.now()
|
|
539
|
+
};
|
|
439
540
|
}
|
|
440
541
|
|
|
441
542
|
async listScheduledMonitors() {
|
|
442
543
|
if (!this.monitorStore._loaded) await this.monitorStore.load();
|
|
443
|
-
const
|
|
444
|
-
|
|
544
|
+
const local = this.scheduler.list().map((m) => ({ ...m, hosted: false }));
|
|
545
|
+
// The local list never fails because the website is unreachable.
|
|
546
|
+
let hosted = [];
|
|
547
|
+
let hostedError = null;
|
|
548
|
+
try {
|
|
549
|
+
const creds = await this.options.resolveHostedCredentials();
|
|
550
|
+
hosted = (await listHostedMonitors(creds)).map((r) => listedHostedMonitor(r, creds.endpoint));
|
|
551
|
+
} catch (error) {
|
|
552
|
+
hostedError = error.message;
|
|
553
|
+
}
|
|
554
|
+
const monitors = [...local, ...hosted];
|
|
555
|
+
return {
|
|
556
|
+
success: true, operation: 'list_scheduled_monitors', monitors,
|
|
557
|
+
count: monitors.length, localCount: local.length, hostedCount: hosted.length,
|
|
558
|
+
...(hostedError ? { hostedError } : {}),
|
|
559
|
+
timestamp: Date.now()
|
|
560
|
+
};
|
|
445
561
|
}
|
|
446
562
|
|
|
447
563
|
async getMonitoringDashboard(params) {
|
|
@@ -79,12 +79,13 @@ export async function sendWebhookNotification(url, changeResult, webhookConfig,
|
|
|
79
79
|
}
|
|
80
80
|
|
|
81
81
|
export async function sendEmailNotification(url, changeResult, emailConfig, emitter) {
|
|
82
|
-
//
|
|
83
|
-
|
|
82
|
+
// This process has no mail service. Until 6.2.0 this emitted
|
|
83
|
+
// notificationSent { success: true } for a message that was never sent.
|
|
84
|
+
// Email is sent by the website's cron for hosted monitors.
|
|
85
|
+
emitter?.emit('notificationError', {
|
|
84
86
|
type: 'email',
|
|
85
87
|
url,
|
|
86
|
-
|
|
87
|
-
note: 'Email notifications require external service integration'
|
|
88
|
+
error: 'Local monitors do not send email; create the monitor with scheduledMonitorOptions.hosted: true (or in the website dashboard) for email notifications'
|
|
88
89
|
});
|
|
89
90
|
}
|
|
90
91
|
|