crawlforge-mcp-server 5.4.2 → 5.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "crawlforge-mcp-server",
3
- "version": "5.4.2",
3
+ "version": "5.4.3",
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.4.0",
117
+ "crawlforge-extractors": "^1.4.1",
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.4.2",
108
+ version: "5.4.3",
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",
@@ -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
 
@@ -15,7 +15,7 @@ to enumerate at runtime. Cost: 1 credit per call.
15
15
  | `reddit-thread` | Reddit thread | title, subreddit, author, score, top comments |
16
16
  | `hacker-news-front-page` | HN front page | ranked stories: title, URL, points, author, comment count |
17
17
  | `producthunt-launch` | Product Hunt launch | name, tagline, upvotes, maker, description |
18
- | `stackoverflow-question` | Stack Overflow question | title, tags, votes, question body, accepted answer |
18
+ | `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
19
  | `npm-package` | npm package page | name, version, description, weekly downloads, license, repo link |
20
20
 
21
21
  ## Usage
@@ -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
- if (chainResult?.finalHtml) {
679
- return await this.extractContentTool.execute({
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');
@@ -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 { success: true, operation: 'create_scheduled_monitor', url, monitor, firingGuarantee: monitor.firingGuarantee, timestamp: Date.now() };
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
- const { dashboardOptions } = params;
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: this._parseCondition(alertRuleOptions?.condition || 'significance === "major"'),
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 || 600000,
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] = { name: template.name, frequency: template.frequency, options: template.options, alertRules: template.alertRules };
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;