search-console-mcp 1.13.1 → 1.13.2

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.
@@ -177,3 +177,89 @@ export async function getPerformanceSummary(siteUrl, days = 28) {
177
177
  endDate: endDate.toISOString().split('T')[0]
178
178
  };
179
179
  }
180
+ /**
181
+ * Detect rising or declining trends in Bing query performance.
182
+ *
183
+ * @param siteUrl - The URL of the site to analyze.
184
+ * @param options - Configuration including lookback period and click thresholds.
185
+ * @returns A list of queries showing significant momentum.
186
+ */
187
+ export async function detectTrends(siteUrl, options = {}) {
188
+ const days = options.days || 28;
189
+ const threshold = options.threshold || 10;
190
+ const minClicks = options.minClicks || 100;
191
+ const limit = options.limit || 20;
192
+ const midPoint = Math.floor(days / 2);
193
+ const endDate = new Date();
194
+ endDate.setDate(endDate.getDate() - 2); // Data delay
195
+ const currentEndDate = new Date(endDate);
196
+ const currentStartDate = new Date(currentEndDate);
197
+ currentStartDate.setDate(currentStartDate.getDate() - midPoint + 1);
198
+ const previousEndDate = new Date(currentStartDate);
199
+ previousEndDate.setDate(previousEndDate.getDate() - 1);
200
+ const previousStartDate = new Date(previousEndDate);
201
+ previousStartDate.setDate(previousStartDate.getDate() - midPoint + 1);
202
+ const allStats = await getQueryStats(siteUrl);
203
+ const filterStats = (start, end) => {
204
+ const map = new Map();
205
+ allStats.forEach(stat => {
206
+ const d = new Date(stat.Date);
207
+ if (d >= start && d <= end) {
208
+ map.set(stat.Query, (map.get(stat.Query) || 0) + stat.Clicks);
209
+ }
210
+ });
211
+ return map;
212
+ };
213
+ const currentMap = filterStats(currentStartDate, currentEndDate);
214
+ const previousMap = filterStats(previousStartDate, previousEndDate);
215
+ const trends = [];
216
+ for (const [query, currClicks] of currentMap.entries()) {
217
+ const prevClicks = previousMap.get(query) || 0;
218
+ if (currClicks < minClicks && prevClicks < minClicks)
219
+ continue;
220
+ if (prevClicks > 0) {
221
+ const change = currClicks - prevClicks;
222
+ const percent = (change / prevClicks) * 100;
223
+ if (Math.abs(percent) >= threshold) {
224
+ trends.push({
225
+ key: query,
226
+ metric: 'clicks',
227
+ change,
228
+ changePercent: parseFloat(percent.toFixed(2)),
229
+ trend: percent > 0 ? 'rising' : 'declining',
230
+ currentValue: currClicks,
231
+ previousValue: prevClicks
232
+ });
233
+ }
234
+ }
235
+ else if (currClicks >= minClicks) {
236
+ // Zero to Hero
237
+ trends.push({
238
+ key: query,
239
+ metric: 'clicks',
240
+ change: currClicks,
241
+ changePercent: 100,
242
+ trend: 'rising',
243
+ currentValue: currClicks,
244
+ previousValue: 0
245
+ });
246
+ }
247
+ }
248
+ // Also check for queries that dropped to zero
249
+ for (const [query, prevClicks] of previousMap.entries()) {
250
+ if (!currentMap.has(query) && prevClicks >= minClicks) {
251
+ trends.push({
252
+ key: query,
253
+ metric: 'clicks',
254
+ change: -prevClicks,
255
+ changePercent: -100,
256
+ trend: 'declining',
257
+ currentValue: 0,
258
+ previousValue: prevClicks
259
+ });
260
+ }
261
+ }
262
+ return trends
263
+ .sort((a, b) => Math.abs(b.change) - Math.abs(a.change))
264
+ .slice(0, limit);
265
+ }
@@ -2,6 +2,7 @@ import * as bingSites from './sites.js';
2
2
  import * as bingSitemaps from './sitemaps.js';
3
3
  import * as bingAnalytics from './analytics.js';
4
4
  import * as bingCrawl from './crawl.js';
5
+ import { limitConcurrency } from '../../common/concurrency.js';
5
6
  /**
6
7
  * Run a health check on a single Bing site.
7
8
  */
@@ -103,7 +104,7 @@ export async function healthCheck(siteUrl) {
103
104
  if (allSites.length === 0) {
104
105
  return [];
105
106
  }
106
- const reports = await Promise.all(allSites.map((site) => checkSite(site.Url)));
107
+ const reports = await limitConcurrency(allSites, 5, (site) => checkSite(site.Url));
107
108
  const order = { critical: 0, warning: 1, healthy: 2 };
108
109
  return reports.sort((a, b) => (order[a.status] || 0) - (order[b.status] || 0));
109
110
  }
@@ -29,6 +29,9 @@ export async function batchQueryAnalytics(options) {
29
29
  return cached.promise;
30
30
  }
31
31
  if (now - cached.timestamp < CACHE_TTL_MS) {
32
+ // LRU: Refresh key position
33
+ analyticsCache.delete(cacheKey);
34
+ analyticsCache.set(cacheKey, cached);
32
35
  return cached.data;
33
36
  }
34
37
  analyticsCache.delete(cacheKey);
@@ -77,6 +80,9 @@ export async function queryAnalytics(options) {
77
80
  return cached.promise;
78
81
  }
79
82
  if (now - cached.timestamp < CACHE_TTL_MS) {
83
+ // LRU: Refresh key position
84
+ analyticsCache.delete(cacheKey);
85
+ analyticsCache.set(cacheKey, cached);
80
86
  return cached.data;
81
87
  }
82
88
  analyticsCache.delete(cacheKey);
@@ -158,7 +158,8 @@ export async function getTimeSeriesInsights(siteUrl, options = {}) {
158
158
  return {
159
159
  date: r.keys?.[dimensions.indexOf('date')] || '',
160
160
  dimensions: dimObj,
161
- metrics: metricObj
161
+ metrics: metricObj,
162
+ original: r
162
163
  };
163
164
  }).sort((a, b) => a.date.localeCompare(b.date));
164
165
  // Support weekly granularity
@@ -166,33 +167,56 @@ export async function getTimeSeriesInsights(siteUrl, options = {}) {
166
167
  const weeklyData = {};
167
168
  data.forEach(d => {
168
169
  const date = new Date(d.date);
169
- const day = date.getDay();
170
- const diff = date.getDate() - day + (day === 0 ? -6 : 1); // Adjust to Monday
171
- const monday = new Date(date.setDate(diff));
170
+ const day = date.getUTCDay();
171
+ const diff = date.getUTCDate() - day + (day === 0 ? -6 : 1); // Adjust to Monday
172
+ const monday = new Date(date);
173
+ monday.setUTCDate(diff);
172
174
  const weekKey = monday.toISOString().split('T')[0];
173
175
  if (!weeklyData[weekKey]) {
176
+ const initMetrics = {};
177
+ metrics.forEach(m => initMetrics[m] = 0);
174
178
  weeklyData[weekKey] = {
175
179
  date: weekKey,
176
180
  dimensions: d.dimensions,
177
- metrics: { ...d.metrics }
181
+ metrics: initMetrics,
182
+ accumulators: { clicks: 0, impressions: 0, weightedPos: 0, count: 0 }
178
183
  };
179
184
  }
180
- else {
181
- metrics.forEach(m => {
182
- if (m === 'position') {
183
- // For average position, we might need a weighted average, but simple average for now
184
- weeklyData[weekKey].metrics[m] = (weeklyData[weekKey].metrics[m] + d.metrics[m]) / 2;
185
- }
186
- else if (m === 'ctr') {
187
- weeklyData[weekKey].metrics[m] = (weeklyData[weekKey].metrics[m] + d.metrics[m]) / 2;
188
- }
189
- else {
190
- weeklyData[weekKey].metrics[m] += d.metrics[m];
191
- }
192
- });
193
- }
185
+ // Accumulate raw values
186
+ const entry = weeklyData[weekKey];
187
+ const r = d.original;
188
+ const clicks = r.clicks ?? 0;
189
+ const impressions = r.impressions ?? 0;
190
+ const position = r.position ?? 0;
191
+ entry.accumulators.clicks += clicks;
192
+ entry.accumulators.impressions += impressions;
193
+ entry.accumulators.weightedPos += (position * impressions);
194
+ entry.accumulators.count += 1;
194
195
  });
195
- data = Object.values(weeklyData).sort((a, b) => a.date.localeCompare(b.date));
196
+ // Compute final weekly metrics
197
+ data = Object.values(weeklyData).map(w => {
198
+ const m = w.metrics;
199
+ const acc = w.accumulators;
200
+ const finalClicks = acc.clicks;
201
+ const finalImpressions = acc.impressions;
202
+ const finalCtr = finalImpressions > 0 ? finalClicks / finalImpressions : 0;
203
+ const finalPos = finalImpressions > 0 ? acc.weightedPos / finalImpressions : 0;
204
+ metrics.forEach(metric => {
205
+ if (metric === 'clicks')
206
+ m[metric] = finalClicks;
207
+ else if (metric === 'impressions')
208
+ m[metric] = finalImpressions;
209
+ else if (metric === 'ctr')
210
+ m[metric] = finalCtr;
211
+ else if (metric === 'position')
212
+ m[metric] = parseFloat(finalPos.toFixed(2));
213
+ });
214
+ return {
215
+ date: w.date,
216
+ dimensions: w.dimensions,
217
+ metrics: m
218
+ };
219
+ }).sort((a, b) => a.date.localeCompare(b.date));
196
220
  }
197
221
  // 1. Calculate Rolling Averages for EACH metric
198
222
  const history = data.map((d, i) => {
@@ -7,18 +7,17 @@ export function clearAnalyticsCache() {
7
7
  }
8
8
  function generateCacheKey(options) {
9
9
  const clone = { ...options };
10
- // Dimensions order matters because it determines the order of keys in the response.
11
- // We should NOT sort them.
12
- /*
13
- if (clone.dimensions) {
14
- clone.dimensions = [...clone.dimensions].sort();
15
- }
16
- */
17
10
  if (clone.filters) {
18
11
  clone.filters = [...clone.filters].sort((a, b) => (a.dimension + a.operator + a.expression).localeCompare(b.dimension + b.operator + b.expression));
19
12
  }
20
- // Sort object keys
21
- return JSON.stringify(clone, Object.keys(clone).sort());
13
+ // We need a stable JSON string for the cache key.
14
+ // Instead of the broken JSON.stringify(clone, keys) which wipes nested objects,
15
+ // we manually sort the top-level keys.
16
+ const sorted = {};
17
+ Object.keys(clone).sort().forEach(key => {
18
+ sorted[key] = clone[key];
19
+ });
20
+ return JSON.stringify(sorted);
22
21
  }
23
22
  /**
24
23
  * Executes a raw query against the Google Search Console Search Analytics API.
@@ -36,6 +35,9 @@ export async function queryAnalytics(options) {
36
35
  return cached;
37
36
  }
38
37
  if (now - cached.timestamp < CACHE_TTL_MS) {
38
+ // LRU: Refresh key position
39
+ analyticsCache.delete(cacheKey);
40
+ analyticsCache.set(cacheKey, cached);
39
41
  return cached.data;
40
42
  }
41
43
  analyticsCache.delete(cacheKey);
@@ -57,13 +59,16 @@ export async function queryAnalytics(options) {
57
59
  requestBody.startRow = options.startRow;
58
60
  }
59
61
  if (options.filters && options.filters.length > 0) {
60
- requestBody.dimensionFilterGroups = [{
61
- filters: options.filters.map(f => ({
62
+ // We use separate dimensionFilterGroups for each filter to ensure they are joined by AND.
63
+ // GSC API joins multiple filters within the same group by OR if they share the same dimension.
64
+ // By putting them in separate groups, we guarantee strict AND behavior for all filters.
65
+ requestBody.dimensionFilterGroups = options.filters.map(f => ({
66
+ filters: [{
62
67
  dimension: f.dimension,
63
68
  operator: f.operator,
64
69
  expression: f.expression
65
- }))
66
- }];
70
+ }]
71
+ }));
67
72
  }
68
73
  const res = await client.searchanalytics.query({
69
74
  siteUrl: options.siteUrl,
@@ -356,24 +361,27 @@ export async function detectTrends(siteUrl, options = {}) {
356
361
  const limit = options.limit ?? 20;
357
362
  // Split the period into two halves
358
363
  const midPoint = Math.floor(days / 2);
359
- const endDate = new Date();
360
- endDate.setDate(endDate.getDate() - DATA_DELAY_DAYS);
361
- const midDate = new Date(endDate);
362
- midDate.setDate(midDate.getDate() - midPoint);
363
- const startDate = new Date(midDate);
364
- startDate.setDate(startDate.getDate() - midPoint);
364
+ // Calculate periods ensuring no overlap
365
+ const currentEndDate = new Date();
366
+ currentEndDate.setDate(currentEndDate.getDate() - DATA_DELAY_DAYS);
367
+ const currentStartDate = new Date(currentEndDate);
368
+ currentStartDate.setDate(currentStartDate.getDate() - midPoint + 1);
369
+ const previousEndDate = new Date(currentStartDate);
370
+ previousEndDate.setDate(previousEndDate.getDate() - 1);
371
+ const previousStartDate = new Date(previousEndDate);
372
+ previousStartDate.setDate(previousStartDate.getDate() - midPoint + 1);
365
373
  const [currentPeriod, previousPeriod] = await Promise.all([
366
374
  queryAnalytics({
367
375
  siteUrl,
368
- startDate: midDate.toISOString().split('T')[0],
369
- endDate: endDate.toISOString().split('T')[0],
376
+ startDate: currentStartDate.toISOString().split('T')[0],
377
+ endDate: currentEndDate.toISOString().split('T')[0],
370
378
  dimensions: [dimension],
371
379
  limit: 5000 // Get enough data to find trends
372
380
  }),
373
381
  queryAnalytics({
374
382
  siteUrl,
375
- startDate: startDate.toISOString().split('T')[0],
376
- endDate: midDate.toISOString().split('T')[0],
383
+ startDate: previousStartDate.toISOString().split('T')[0],
384
+ endDate: previousEndDate.toISOString().split('T')[0],
377
385
  dimensions: [dimension],
378
386
  limit: 5000
379
387
  })
package/dist/index.js CHANGED
@@ -101,6 +101,22 @@ server.tool("sites_list", "List all verified sites across all authorized account
101
101
  return formatError(error);
102
102
  }
103
103
  });
104
+ server.tool("bing_analytics_trends", "Detect rising or declining trends in Bing query performance", {
105
+ siteUrl: z.string().describe("The URL of the site"),
106
+ days: z.number().optional().describe("Number of days to analyze (default 28)"),
107
+ threshold: z.number().optional().describe("Minimum percentage change (default 10)"),
108
+ minClicks: z.number().optional().describe("Minimum clicks required (default 100)")
109
+ }, async ({ siteUrl, days, threshold, minClicks }) => {
110
+ try {
111
+ const results = await bingAnalytics.detectTrends(siteUrl, { days, threshold, minClicks });
112
+ return {
113
+ content: [{ type: "text", text: JSON.stringify(results, null, 2) }]
114
+ };
115
+ }
116
+ catch (error) {
117
+ return formatError(error);
118
+ }
119
+ });
104
120
  server.tool("sites_add", "Add a new site to Search Console or Bing Webmaster Tools", {
105
121
  siteUrl: z.string().describe("The URL of the site to add"),
106
122
  engine: z.enum(["google", "bing"]).optional().describe("The search engine (default: google)")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "search-console-mcp",
3
- "version": "1.13.1",
3
+ "version": "1.13.2",
4
4
  "mcpName": "io.github.saurabhsharma2u/search-console-mcp",
5
5
  "description": "MCP server for Google Search Console, Bing Webmaster Tools, and Google Analytics 4",
6
6
  "type": "module",
@@ -37,7 +37,7 @@
37
37
  "@modelcontextprotocol/sdk": "^1.26.0",
38
38
  "@napi-rs/keyring": "^1.2.0",
39
39
  "cheerio": "^1.2.0",
40
- "dotenv": "^17.2.3",
40
+ "dotenv": "^17.3.1",
41
41
  "googleapis": "^171.0.0",
42
42
  "node-machine-id": "^1.1.12",
43
43
  "open": "^11.0.0",
@@ -52,4 +52,4 @@
52
52
  "typescript": "^5.9.3",
53
53
  "vitest": "^4.0.18"
54
54
  }
55
- }
55
+ }