search-console-mcp 1.13.0 → 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.
- package/README.md +4 -2
- package/dist/bing/tools/analytics.js +86 -0
- package/dist/bing/tools/seo-insights.js +99 -10
- package/dist/bing/tools/sites-health.js +2 -1
- package/dist/common/auth/config.js +9 -0
- package/dist/common/utils/csv.js +27 -0
- package/dist/ga4/tools/analytics.js +6 -0
- package/dist/google/tools/advanced-analytics.js +44 -20
- package/dist/google/tools/analytics.js +31 -23
- package/dist/index.js +78 -15
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -179,7 +179,7 @@ This MCP server implements a multi-layered security architecture:
|
|
|
179
179
|
| `analytics_drop_attribution` | **[NEW]** Attribute traffic drops to mobile/desktop or correlate with known Google Algorithm Updates. |
|
|
180
180
|
| `analytics_time_series` | **[NEW]** Advanced time series with rolling averages, seasonality detection, and forecasting. |
|
|
181
181
|
| `analytics_compare_periods` | Compare two date ranges (e.g., WoW, MoM). |
|
|
182
|
-
| `seo_brand_vs_nonbrand` | **[NEW]** Analyze performance split between Brand vs Non-Brand traffic. |
|
|
182
|
+
| `seo_brand_vs_nonbrand` | **[NEW]** Analyze performance split between Brand vs Non-Brand traffic. (Supports Google & Bing). |
|
|
183
183
|
|
|
184
184
|
### SEO Opportunities (Opinionated)
|
|
185
185
|
| Tool | Description |
|
|
@@ -188,7 +188,7 @@ This MCP server implements a multi-layered security architecture:
|
|
|
188
188
|
| `seo_striking_distance` | **[NEW]** Find keywords ranking 8-15 (Quickest ROI wins). |
|
|
189
189
|
| `seo_low_ctr_opportunities` | **[NEW]** Find top ranking queries (pos 1-10) with poor CTR. |
|
|
190
190
|
| `seo_cannibalization` | **[Enhanced]** Detect pages competing for the same query with traffic conflict. |
|
|
191
|
-
| `seo_lost_queries` | **[NEW]** Identify queries that lost all traffic in the last 28 days. |
|
|
191
|
+
| `seo_lost_queries` | **[NEW]** Identify queries that lost all traffic in the last 28 days. (Supports Google & Bing). |
|
|
192
192
|
|
|
193
193
|
### SEO Primitives (Atoms for Agents)
|
|
194
194
|
These are low-level tools designed to be used by other AI agents to build complex logic.
|
|
@@ -226,6 +226,8 @@ These are low-level tools designed to be used by other AI agents to build comple
|
|
|
226
226
|
| `bing_crawl_issues` | List crawl issues detected by Bing. |
|
|
227
227
|
| `bing_analytics_detect_anomalies` | Detect daily spikes or drops in Bing traffic. |
|
|
228
228
|
| `bing_analytics_time_series` | Advanced time series analysis for Bing. |
|
|
229
|
+
| `bing_seo_lost_queries` | Identify queries that lost significant traffic on Bing. |
|
|
230
|
+
| `bing_brand_analysis` | Analyze performance split between Brand vs Non-Brand traffic on Bing. |
|
|
229
231
|
| `bing_sitemaps_list` / `bing_sitemaps_submit` | Manage sitemaps in Bing. |
|
|
230
232
|
|
|
231
233
|
### Google Analytics 4 (GA4)
|
|
@@ -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
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { getQueryStats, getQueryPageStats } from './analytics.js';
|
|
2
|
+
import { safeTestBatch } from '../../common/utils/regex.js';
|
|
2
3
|
/**
|
|
3
4
|
* Find "low-hanging fruit" keywords in Bing.
|
|
4
5
|
*/
|
|
@@ -71,12 +72,12 @@ export async function findLowCTROpportunities(siteUrl, options = {}) {
|
|
|
71
72
|
/**
|
|
72
73
|
* Detect keyword cannibalization in Bing where multiple pages compete for the same query.
|
|
73
74
|
*/
|
|
74
|
-
export async function detectCannibalization(siteUrl, options = {}) {
|
|
75
|
-
const { minImpressions = 50, limit = 30 } = options;
|
|
76
|
-
const
|
|
75
|
+
export async function detectCannibalization(siteUrl, options = {}, rows) {
|
|
76
|
+
const { minImpressions = 50, limit = 30, startDate, endDate } = options;
|
|
77
|
+
const stats = rows || await getQueryPageStats(siteUrl, startDate, endDate);
|
|
77
78
|
// Group by query
|
|
78
79
|
const queryMap = new Map();
|
|
79
|
-
for (const row of
|
|
80
|
+
for (const row of stats) {
|
|
80
81
|
if (row.Impressions < minImpressions)
|
|
81
82
|
continue;
|
|
82
83
|
if (!queryMap.has(row.Query)) {
|
|
@@ -114,17 +115,105 @@ export async function detectCannibalization(siteUrl, options = {}) {
|
|
|
114
115
|
.sort((a, b) => (b.totalClicks * b.clickShareConflict) - (a.totalClicks * a.clickShareConflict))
|
|
115
116
|
.slice(0, limit);
|
|
116
117
|
}
|
|
118
|
+
/**
|
|
119
|
+
* Identify queries that have lost significant visibility or clicks compared to the previous period.
|
|
120
|
+
*/
|
|
121
|
+
export async function findLostQueries(siteUrl, options = {}) {
|
|
122
|
+
const { days = 28, limit = 50 } = options;
|
|
123
|
+
const endDate = new Date();
|
|
124
|
+
endDate.setDate(endDate.getDate() - 2); // Account for data delay
|
|
125
|
+
const midDate = new Date(endDate);
|
|
126
|
+
midDate.setDate(midDate.getDate() - days);
|
|
127
|
+
const startDate = new Date(midDate);
|
|
128
|
+
startDate.setDate(startDate.getDate() - days);
|
|
129
|
+
const [currentStats, previousStats] = await Promise.all([
|
|
130
|
+
getQueryStats(siteUrl, midDate.toISOString().split('T')[0], endDate.toISOString().split('T')[0]),
|
|
131
|
+
getQueryStats(siteUrl, startDate.toISOString().split('T')[0], midDate.toISOString().split('T')[0])
|
|
132
|
+
]);
|
|
133
|
+
const currentMap = new Map();
|
|
134
|
+
for (const row of currentStats) {
|
|
135
|
+
currentMap.set(row.Query, row);
|
|
136
|
+
}
|
|
137
|
+
const lostQueries = [];
|
|
138
|
+
for (const prev of previousStats) {
|
|
139
|
+
if (prev.Clicks < 5)
|
|
140
|
+
continue; // Ignore low volume noise
|
|
141
|
+
const curr = currentMap.get(prev.Query);
|
|
142
|
+
const currClicks = curr ? curr.Clicks : 0;
|
|
143
|
+
// Definition of lost: >80% drop or zero
|
|
144
|
+
if (currClicks === 0 || (currClicks / prev.Clicks) < 0.2) {
|
|
145
|
+
lostQueries.push({
|
|
146
|
+
query: prev.Query,
|
|
147
|
+
previousClicks: prev.Clicks,
|
|
148
|
+
previousImpressions: prev.Impressions,
|
|
149
|
+
previousPosition: prev.AvgPosition,
|
|
150
|
+
currentClicks: currClicks,
|
|
151
|
+
currentImpressions: curr ? curr.Impressions : 0,
|
|
152
|
+
currentPosition: curr ? curr.AvgPosition : 0,
|
|
153
|
+
lostClicks: prev.Clicks - currClicks
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return lostQueries
|
|
158
|
+
.sort((a, b) => b.lostClicks - a.lostClicks)
|
|
159
|
+
.slice(0, limit);
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Segment search performance into Brand and Non-Brand categories.
|
|
163
|
+
*/
|
|
164
|
+
export async function analyzeBrandVsNonBrand(siteUrl, brandRegexString, options = {}) {
|
|
165
|
+
const { days = 28 } = options;
|
|
166
|
+
const endDate = new Date();
|
|
167
|
+
endDate.setDate(endDate.getDate() - 2);
|
|
168
|
+
const startDate = new Date(endDate);
|
|
169
|
+
startDate.setDate(startDate.getDate() - days);
|
|
170
|
+
const rows = await getQueryStats(siteUrl, startDate.toISOString().split('T')[0], endDate.toISOString().split('T')[0]);
|
|
171
|
+
const queries = rows.map(r => r.Query);
|
|
172
|
+
const isBrandResults = safeTestBatch(brandRegexString, 'i', queries);
|
|
173
|
+
const brandStats = { clicks: 0, impressions: 0, weightedPos: 0, queryCount: 0 };
|
|
174
|
+
const nonBrandStats = { clicks: 0, impressions: 0, weightedPos: 0, queryCount: 0 };
|
|
175
|
+
rows.forEach((row, index) => {
|
|
176
|
+
const isBrand = isBrandResults[index];
|
|
177
|
+
const stats = isBrand ? brandStats : nonBrandStats;
|
|
178
|
+
stats.clicks += row.Clicks;
|
|
179
|
+
stats.impressions += row.Impressions;
|
|
180
|
+
stats.weightedPos += row.AvgPosition * row.Impressions;
|
|
181
|
+
stats.queryCount++;
|
|
182
|
+
});
|
|
183
|
+
const calc = (stats, segment) => ({
|
|
184
|
+
segment,
|
|
185
|
+
clicks: stats.clicks,
|
|
186
|
+
impressions: stats.impressions,
|
|
187
|
+
ctr: stats.impressions > 0 ? stats.clicks / stats.impressions : 0,
|
|
188
|
+
position: stats.impressions > 0 ? stats.weightedPos / stats.impressions : 0,
|
|
189
|
+
queryCount: stats.queryCount
|
|
190
|
+
});
|
|
191
|
+
return [
|
|
192
|
+
calc(brandStats, 'Brand'),
|
|
193
|
+
calc(nonBrandStats, 'Non-Brand')
|
|
194
|
+
];
|
|
195
|
+
}
|
|
117
196
|
/**
|
|
118
197
|
* Generate prioritized Bing SEO recommendations.
|
|
119
198
|
*/
|
|
120
|
-
export async function generateRecommendations(siteUrl) {
|
|
199
|
+
export async function generateRecommendations(siteUrl, options = {}) {
|
|
200
|
+
const { days = 28 } = options;
|
|
121
201
|
const insights = [];
|
|
122
|
-
const
|
|
202
|
+
const endDate = new Date();
|
|
203
|
+
endDate.setDate(endDate.getDate() - 2);
|
|
204
|
+
const startDate = new Date(endDate);
|
|
205
|
+
startDate.setDate(startDate.getDate() - days);
|
|
206
|
+
const sDate = startDate.toISOString().split('T')[0];
|
|
207
|
+
const eDate = endDate.toISOString().split('T')[0];
|
|
208
|
+
const [queryStats, queryPageStats] = await Promise.all([
|
|
209
|
+
getQueryStats(siteUrl, sDate, eDate),
|
|
210
|
+
getQueryPageStats(siteUrl, sDate, eDate)
|
|
211
|
+
]);
|
|
123
212
|
const [lowHangingFruit, strikingDistance, lowCTR, cannibalization] = await Promise.all([
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
detectCannibalization(siteUrl, { limit: 10 })
|
|
213
|
+
findLowHangingFruit(siteUrl, { limit: 10, queryStats: queryStats }),
|
|
214
|
+
findStrikingDistance(siteUrl, { limit: 10, queryStats: queryStats }),
|
|
215
|
+
findLowCTROpportunities(siteUrl, { limit: 10, queryStats: queryStats }),
|
|
216
|
+
detectCannibalization(siteUrl, { limit: 10, startDate: sDate, endDate: eDate }, queryPageStats)
|
|
128
217
|
]);
|
|
129
218
|
if (lowHangingFruit.length > 0) {
|
|
130
219
|
const totalPotential = lowHangingFruit.reduce((sum, l) => sum + l.potentialClicks, 0);
|
|
@@ -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
|
|
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
|
}
|
|
@@ -7,6 +7,10 @@ const { machineIdSync } = nodeMachineId;
|
|
|
7
7
|
const CONFIG_PATH = join(homedir(), '.search-console-mcp-config.enc');
|
|
8
8
|
const LEGACY_TOKEN_PATH = join(homedir(), '.search-console-mcp-tokens.enc');
|
|
9
9
|
const ENCRYPTION_ALGORITHM = 'aes-256-gcm';
|
|
10
|
+
let cachedConfig = null;
|
|
11
|
+
export function resetConfigCache() {
|
|
12
|
+
cachedConfig = null;
|
|
13
|
+
}
|
|
10
14
|
function getEncryptionKey() {
|
|
11
15
|
const mId = machineIdSync();
|
|
12
16
|
const salt = process.env.USER || 'sc-mcp-salt';
|
|
@@ -36,6 +40,9 @@ function decrypt(data) {
|
|
|
36
40
|
* Load the unified configuration, including lazy migration from legacy Google tokens.
|
|
37
41
|
*/
|
|
38
42
|
export async function loadConfig() {
|
|
43
|
+
if (cachedConfig) {
|
|
44
|
+
return cachedConfig;
|
|
45
|
+
}
|
|
39
46
|
let config = { accounts: {} };
|
|
40
47
|
// 1. Try to load from new unified config
|
|
41
48
|
if (existsSync(CONFIG_PATH)) {
|
|
@@ -125,12 +132,14 @@ export async function loadConfig() {
|
|
|
125
132
|
};
|
|
126
133
|
}
|
|
127
134
|
}
|
|
135
|
+
cachedConfig = config;
|
|
128
136
|
return config;
|
|
129
137
|
}
|
|
130
138
|
export async function saveConfig(config) {
|
|
131
139
|
try {
|
|
132
140
|
const encrypted = encrypt(JSON.stringify(config));
|
|
133
141
|
writeFileSync(CONFIG_PATH, encrypted, { mode: 0o600 });
|
|
142
|
+
cachedConfig = config;
|
|
134
143
|
}
|
|
135
144
|
catch (e) {
|
|
136
145
|
console.error('Failed to save config:', e.message);
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Convert an array of flat JSON objects to a CSV string.
|
|
3
|
+
* Automatically handles escaping of double quotes and commas.
|
|
4
|
+
*/
|
|
5
|
+
export function jsonToCsv(data) {
|
|
6
|
+
if (!data || data.length === 0) {
|
|
7
|
+
return "";
|
|
8
|
+
}
|
|
9
|
+
// Get headers from the first object
|
|
10
|
+
const headers = Object.keys(data[0]);
|
|
11
|
+
const csvRows = [headers.join(',')];
|
|
12
|
+
for (const row of data) {
|
|
13
|
+
const values = headers.map(header => {
|
|
14
|
+
const val = row[header];
|
|
15
|
+
const stringVal = val === undefined || val === null ? '' : String(val);
|
|
16
|
+
// Escape double quotes by doubling them
|
|
17
|
+
const escaped = stringVal.replace(/"/g, '""');
|
|
18
|
+
// Wrap in quotes if it contains comma, quote or newline
|
|
19
|
+
if (escaped.includes(',') || escaped.includes('"') || escaped.includes('\n')) {
|
|
20
|
+
return `"${escaped}"`;
|
|
21
|
+
}
|
|
22
|
+
return escaped;
|
|
23
|
+
});
|
|
24
|
+
csvRows.push(values.join(','));
|
|
25
|
+
}
|
|
26
|
+
return csvRows.join('\n');
|
|
27
|
+
}
|
|
@@ -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.
|
|
170
|
-
const diff = date.
|
|
171
|
-
const monday = new Date(date
|
|
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:
|
|
181
|
+
metrics: initMetrics,
|
|
182
|
+
accumulators: { clicks: 0, impressions: 0, weightedPos: 0, count: 0 }
|
|
178
183
|
};
|
|
179
184
|
}
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
21
|
-
|
|
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
|
-
|
|
61
|
-
|
|
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
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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:
|
|
369
|
-
endDate:
|
|
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:
|
|
376
|
-
endDate:
|
|
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
|
@@ -44,6 +44,7 @@ import { fileURLToPath } from 'url';
|
|
|
44
44
|
import { dirname } from 'path';
|
|
45
45
|
import { getStartedHandler, getStartedToolName, getStartedToolDescription, getStartedToolSchema } from "./common/tools/get-started.js";
|
|
46
46
|
import { registerPrompts } from "./prompts/index.js";
|
|
47
|
+
import { jsonToCsv } from "./common/utils/csv.js";
|
|
47
48
|
const __filename = fileURLToPath(import.meta.url);
|
|
48
49
|
const __dirname = dirname(__filename);
|
|
49
50
|
// Load version from package.json
|
|
@@ -100,6 +101,22 @@ server.tool("sites_list", "List all verified sites across all authorized account
|
|
|
100
101
|
return formatError(error);
|
|
101
102
|
}
|
|
102
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
|
+
});
|
|
103
120
|
server.tool("sites_add", "Add a new site to Search Console or Bing Webmaster Tools", {
|
|
104
121
|
siteUrl: z.string().describe("The URL of the site to add"),
|
|
105
122
|
engine: z.enum(["google", "bing"]).optional().describe("The search engine (default: google)")
|
|
@@ -229,10 +246,29 @@ server.tool("analytics_query", "Query search analytics data with optional pagina
|
|
|
229
246
|
dimension: z.string(),
|
|
230
247
|
operator: z.string(),
|
|
231
248
|
expression: z.string()
|
|
232
|
-
})).optional().describe("Filters (dimension: query/page/country/device, operator: equals/contains/notContains/includingRegex/excludingRegex)")
|
|
249
|
+
})).optional().describe("Filters (dimension: query/page/country/device, operator: equals/contains/notContains/includingRegex/excludingRegex)"),
|
|
250
|
+
format: z.enum(["json", "csv"]).optional().describe("Output format (default: json)")
|
|
233
251
|
}, async (args) => {
|
|
234
252
|
try {
|
|
235
253
|
const result = await analytics.queryAnalytics(args);
|
|
254
|
+
if (args.format === 'csv') {
|
|
255
|
+
const flatData = result.map(row => {
|
|
256
|
+
const newRow = { ...row };
|
|
257
|
+
if (row.keys && Array.isArray(row.keys)) {
|
|
258
|
+
row.keys.forEach((keyVal, idx) => {
|
|
259
|
+
const dimName = args.dimensions && args.dimensions[idx]
|
|
260
|
+
? args.dimensions[idx]
|
|
261
|
+
: `dimension_${idx + 1}`;
|
|
262
|
+
newRow[dimName] = keyVal;
|
|
263
|
+
});
|
|
264
|
+
delete newRow.keys;
|
|
265
|
+
}
|
|
266
|
+
return newRow;
|
|
267
|
+
});
|
|
268
|
+
return {
|
|
269
|
+
content: [{ type: "text", text: jsonToCsv(flatData) }]
|
|
270
|
+
};
|
|
271
|
+
}
|
|
236
272
|
return {
|
|
237
273
|
content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
|
|
238
274
|
};
|
|
@@ -514,10 +550,13 @@ server.tool("pagespeed_core_web_vitals", "Get Core Web Vitals for both mobile an
|
|
|
514
550
|
// SEO Insights Tools
|
|
515
551
|
server.tool("seo_recommendations", "Generate SEO recommendations based on site performance data", {
|
|
516
552
|
siteUrl: z.string().describe("The site URL (e.g., https://example.com)"),
|
|
517
|
-
days: z.number().optional().describe("Number of days to analyze (default: 28)")
|
|
518
|
-
|
|
553
|
+
days: z.number().optional().describe("Number of days to analyze (default: 28)"),
|
|
554
|
+
engine: z.enum(["google", "bing"]).optional().describe("The search engine (default: google)")
|
|
555
|
+
}, async ({ siteUrl, days, engine = "google" }) => {
|
|
519
556
|
try {
|
|
520
|
-
const result =
|
|
557
|
+
const result = engine === "google"
|
|
558
|
+
? await seoInsights.generateRecommendations(siteUrl, { days })
|
|
559
|
+
: await bingSeoInsights.generateRecommendations(siteUrl, { days });
|
|
521
560
|
return {
|
|
522
561
|
content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
|
|
523
562
|
};
|
|
@@ -604,10 +643,13 @@ server.tool("seo_striking_distance", "Find keywords ranking in positions 8-15. T
|
|
|
604
643
|
server.tool("seo_lost_queries", "Identify queries that lost all traffic (or dropped >80%) compared to the previous period.", {
|
|
605
644
|
siteUrl: z.string().describe("The site URL"),
|
|
606
645
|
days: z.number().optional().describe("Number of days to compare (default: 28)"),
|
|
607
|
-
limit: z.number().optional().describe("Max results to return (default: 50)")
|
|
608
|
-
|
|
646
|
+
limit: z.number().optional().describe("Max results to return (default: 50)"),
|
|
647
|
+
engine: z.enum(["google", "bing"]).optional().describe("The search engine (default: google)")
|
|
648
|
+
}, async ({ siteUrl, days, limit, engine = "google" }) => {
|
|
609
649
|
try {
|
|
610
|
-
const result =
|
|
650
|
+
const result = engine === "google"
|
|
651
|
+
? await seoInsights.findLostQueries(siteUrl, { days, limit })
|
|
652
|
+
: await bingSeoInsights.findLostQueries(siteUrl, { days, limit });
|
|
611
653
|
return {
|
|
612
654
|
content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
|
|
613
655
|
};
|
|
@@ -619,10 +661,13 @@ server.tool("seo_lost_queries", "Identify queries that lost all traffic (or drop
|
|
|
619
661
|
server.tool("seo_brand_vs_nonbrand", "Analyze performance split between Brand and Non-Brand queries using a regex.", {
|
|
620
662
|
siteUrl: z.string().describe("The site URL"),
|
|
621
663
|
brandRegex: z.string().describe("Regex to match brand keywords (e.g. 'acme|acme corp')"),
|
|
622
|
-
days: z.number().optional().describe("Number of days to analyze (default: 28)")
|
|
623
|
-
|
|
664
|
+
days: z.number().optional().describe("Number of days to analyze (default: 28)"),
|
|
665
|
+
engine: z.enum(["google", "bing"]).optional().describe("The search engine (default: google)")
|
|
666
|
+
}, async ({ siteUrl, brandRegex, days, engine = "google" }) => {
|
|
624
667
|
try {
|
|
625
|
-
const result =
|
|
668
|
+
const result = engine === "google"
|
|
669
|
+
? await seoInsights.analyzeBrandVsNonBrand(siteUrl, brandRegex, { days })
|
|
670
|
+
: await bingSeoInsights.analyzeBrandVsNonBrand(siteUrl, brandRegex, { days });
|
|
626
671
|
return {
|
|
627
672
|
content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
|
|
628
673
|
};
|
|
@@ -850,10 +895,22 @@ server.tool("bing_sitemaps_delete", "Remove a sitemap from Bing Webmaster Tools"
|
|
|
850
895
|
}
|
|
851
896
|
});
|
|
852
897
|
server.tool("bing_analytics_query", "Get query performance stats from Bing Webmaster Tools (Top Queries)", {
|
|
853
|
-
siteUrl: z.string().describe("The URL of the site")
|
|
854
|
-
|
|
898
|
+
siteUrl: z.string().describe("The URL of the site"),
|
|
899
|
+
startDate: z.string().optional().describe("Start date (YYYY-MM-DD)"),
|
|
900
|
+
endDate: z.string().optional().describe("End date (YYYY-MM-DD)"),
|
|
901
|
+
limit: z.number().optional().describe("Max rows to return (default: 1000)"),
|
|
902
|
+
format: z.enum(["json", "csv"]).optional().describe("Output format (default: json)")
|
|
903
|
+
}, async ({ siteUrl, startDate, endDate, limit, format }) => {
|
|
855
904
|
try {
|
|
856
|
-
|
|
905
|
+
let results = await bingAnalytics.getQueryStats(siteUrl, startDate, endDate);
|
|
906
|
+
if (limit) {
|
|
907
|
+
results = results.slice(0, limit);
|
|
908
|
+
}
|
|
909
|
+
if (format === 'csv') {
|
|
910
|
+
return {
|
|
911
|
+
content: [{ type: "text", text: jsonToCsv(results) }]
|
|
912
|
+
};
|
|
913
|
+
}
|
|
857
914
|
return {
|
|
858
915
|
content: [{ type: "text", text: JSON.stringify(results, null, 2) }]
|
|
859
916
|
};
|
|
@@ -1226,10 +1283,16 @@ server.tool("analytics_page_performance", "Get detailed page performance metrics
|
|
|
1226
1283
|
startDate: z.string().describe("Start date (YYYY-MM-DD)"),
|
|
1227
1284
|
endDate: z.string().describe("End date (YYYY-MM-DD)"),
|
|
1228
1285
|
pagePath: z.string().optional().describe("Filter by specific page path"),
|
|
1229
|
-
limit: z.number().optional().describe("Max rows (default 50)")
|
|
1230
|
-
|
|
1286
|
+
limit: z.number().optional().describe("Max rows (default 50)"),
|
|
1287
|
+
format: z.enum(["json", "csv"]).optional().describe("Output format (default: json)")
|
|
1288
|
+
}, async ({ propertyId, accountId, startDate, endDate, pagePath, limit, format }) => {
|
|
1231
1289
|
try {
|
|
1232
1290
|
const result = await ga4Analytics.getPagePerformance(propertyId, startDate, endDate, pagePath, limit, accountId);
|
|
1291
|
+
if (format === 'csv') {
|
|
1292
|
+
return {
|
|
1293
|
+
content: [{ type: "text", text: jsonToCsv(result) }]
|
|
1294
|
+
};
|
|
1295
|
+
}
|
|
1233
1296
|
return {
|
|
1234
1297
|
content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
|
|
1235
1298
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "search-console-mcp",
|
|
3
|
-
"version": "1.13.
|
|
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.
|
|
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
|
+
}
|