search-console-mcp 1.9.0 → 1.9.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 +110 -230
- package/dist/docs/algorithm-updates.js +20 -0
- package/dist/docs/index.js +1 -0
- package/dist/docs/patterns.js +29 -0
- package/dist/google-client.js +8 -2
- package/dist/index.js +252 -3
- package/dist/setup.js +62 -12
- package/dist/tools/advanced-analytics.js +246 -0
- package/dist/tools/analytics.js +242 -4
- package/dist/tools/inspection.js +8 -0
- package/dist/tools/pagespeed.js +9 -2
- package/dist/tools/schema-validator.js +16 -6
- package/dist/tools/seo-insights.js +265 -54
- package/dist/tools/seo-primitives.js +120 -0
- package/dist/tools/sitemaps.js +27 -0
- package/dist/tools/sites.js +23 -0
- package/dist/tools/support.js +46 -0
- package/package.json +7 -3
package/dist/index.js
CHANGED
|
@@ -8,7 +8,9 @@ import * as analytics from "./tools/analytics.js";
|
|
|
8
8
|
import * as inspection from "./tools/inspection.js";
|
|
9
9
|
import * as pagespeed from "./tools/pagespeed.js";
|
|
10
10
|
import * as seoInsights from "./tools/seo-insights.js";
|
|
11
|
+
import * as seoPrimitives from "./tools/seo-primitives.js";
|
|
11
12
|
import * as schemaValidator from "./tools/schema-validator.js";
|
|
13
|
+
import * as advancedAnalytics from "./tools/advanced-analytics.js";
|
|
12
14
|
import { formatError } from "./errors.js";
|
|
13
15
|
const server = new McpServer({
|
|
14
16
|
name: "search-console-mcp",
|
|
@@ -119,8 +121,10 @@ server.tool("analytics_query", "Query search analytics data with optional pagina
|
|
|
119
121
|
startDate: z.string().describe("Start date (YYYY-MM-DD)"),
|
|
120
122
|
endDate: z.string().describe("End date (YYYY-MM-DD)"),
|
|
121
123
|
dimensions: z.array(z.string()).optional().describe("Dimensions to group by (date, query, page, country, device, searchAppearance)"),
|
|
122
|
-
type: z.
|
|
123
|
-
|
|
124
|
+
type: z.enum(["web", "image", "video", "news", "discover", "googleNews"]).optional().describe("Search type (default: web)"),
|
|
125
|
+
aggregationType: z.enum(["auto", "byProperty", "byPage"]).optional().describe("How to aggregate data (default: auto)"),
|
|
126
|
+
dataState: z.enum(["final", "all"]).optional().describe("Include fresh data? 'all' includes fresh (preliminary) data (default: final)"),
|
|
127
|
+
limit: z.number().optional().describe("Max rows to return (default: 1000)"),
|
|
124
128
|
startRow: z.number().optional().describe("Starting row for pagination (0-based)"),
|
|
125
129
|
filters: z.array(z.object({
|
|
126
130
|
dimension: z.string(),
|
|
@@ -201,6 +205,122 @@ server.tool("analytics_top_pages", "Get top performing pages by clicks or impres
|
|
|
201
205
|
return formatError(error);
|
|
202
206
|
}
|
|
203
207
|
});
|
|
208
|
+
server.tool("analytics_by_country", "Get performance breakdown by country for the last N days.", {
|
|
209
|
+
siteUrl: z.string().describe("The URL of the site"),
|
|
210
|
+
days: z.number().optional().describe("Number of days to look back (default: 28)"),
|
|
211
|
+
limit: z.number().optional().describe("Number of countries to return (default: 250)"),
|
|
212
|
+
sortBy: z.enum(["clicks", "impressions"]).optional().describe("Sort by clicks or impressions (default: clicks)")
|
|
213
|
+
}, async ({ siteUrl, days, limit, sortBy }) => {
|
|
214
|
+
try {
|
|
215
|
+
const result = await analytics.getPerformanceByCountry(siteUrl, { days, limit, sortBy });
|
|
216
|
+
return {
|
|
217
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
catch (error) {
|
|
221
|
+
return formatError(error);
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
server.tool("analytics_search_appearance", "Get performance breakdown by search appearance type for the last N days.", {
|
|
225
|
+
siteUrl: z.string().describe("The URL of the site"),
|
|
226
|
+
days: z.number().optional().describe("Number of days to look back (default: 28)"),
|
|
227
|
+
limit: z.number().optional().describe("Number of types to return (default: 50)"),
|
|
228
|
+
sortBy: z.enum(["clicks", "impressions"]).optional().describe("Sort by clicks or impressions (default: clicks)")
|
|
229
|
+
}, async ({ siteUrl, days, limit, sortBy }) => {
|
|
230
|
+
try {
|
|
231
|
+
const result = await analytics.getPerformanceBySearchAppearance(siteUrl, { days, limit, sortBy });
|
|
232
|
+
return {
|
|
233
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
catch (error) {
|
|
237
|
+
return formatError(error);
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
server.tool("analytics_trends", "Detect traffic trends (rising/declining) for queries or pages.", {
|
|
241
|
+
siteUrl: z.string().describe("The URL of the site"),
|
|
242
|
+
dimension: z.enum(["query", "page"]).optional().describe("Dimension to analyze (default: query)"),
|
|
243
|
+
days: z.number().optional().describe("Number of days to analyze (default: 28)"),
|
|
244
|
+
threshold: z.number().optional().describe("Minimum percentage change to consider (default: 10)"),
|
|
245
|
+
minClicks: z.number().optional().describe("Minimum clicks required to be considered (default: 100)"),
|
|
246
|
+
limit: z.number().optional().describe("Max results to return (default: 20)")
|
|
247
|
+
}, async ({ siteUrl, dimension, days, threshold, minClicks, limit }) => {
|
|
248
|
+
try {
|
|
249
|
+
const result = await analytics.detectTrends(siteUrl, { dimension, days, threshold, minClicks, limit });
|
|
250
|
+
return {
|
|
251
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
catch (error) {
|
|
255
|
+
return formatError(error);
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
server.tool("analytics_anomalies", "Identify unusual daily spikes or drops in traffic.", {
|
|
259
|
+
siteUrl: z.string().describe("The URL of the site"),
|
|
260
|
+
days: z.number().optional().describe("Number of days to look back for baseline (default: 30)"),
|
|
261
|
+
threshold: z.number().optional().describe("Sensitivity threshold (Standard Deviations, default: 2.5)")
|
|
262
|
+
}, async ({ siteUrl, days, threshold }) => {
|
|
263
|
+
try {
|
|
264
|
+
const result = await analytics.detectAnomalies(siteUrl, { days, threshold });
|
|
265
|
+
return {
|
|
266
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
catch (error) {
|
|
270
|
+
return formatError(error);
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
server.tool("analytics_drop_attribution", "Analyze a significant traffic drop to identify if it was caused by specific devices (mobile/desktop) or coincides with known Google algorithm updates.", {
|
|
274
|
+
siteUrl: z.string().describe("The URL of the site"),
|
|
275
|
+
days: z.number().optional().describe("Number of days to look back (default: 30)"),
|
|
276
|
+
threshold: z.number().optional().describe("Sensitivity threshold for drop detection (Standard Deviations, default: 2.0)")
|
|
277
|
+
}, async ({ siteUrl, days, threshold }) => {
|
|
278
|
+
try {
|
|
279
|
+
const result = await advancedAnalytics.analyzeDropAttribution(siteUrl, { days, threshold });
|
|
280
|
+
return {
|
|
281
|
+
content: [{ type: "text", text: result ? JSON.stringify(result, null, 2) : "No significant traffic drop detected in the specified period." }]
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
catch (error) {
|
|
285
|
+
return formatError(error);
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
server.tool("analytics_time_series", "Get advanced time series data including rolling averages, seasonality strength, and trend forecasting. Supports multi-dimensional analysis, metrics selection, and custom granularities.", {
|
|
289
|
+
siteUrl: z.string().describe("The URL of the site"),
|
|
290
|
+
days: z.number().optional().describe("Number of days of history to analyze (default: 60)"),
|
|
291
|
+
startDate: z.string().optional().describe("Start date (YYYY-MM-DD)"),
|
|
292
|
+
endDate: z.string().optional().describe("End date (YYYY-MM-DD)"),
|
|
293
|
+
dimensions: z.array(z.string()).optional().describe("Dimensions to group by (default: ['date'])"),
|
|
294
|
+
metrics: z.array(z.enum(["clicks", "impressions", "ctr", "position"])).optional().describe("Metrics to analyze (default: ['clicks'])"),
|
|
295
|
+
granularity: z.enum(["daily", "weekly"]).optional().describe("Granularity of the data (default: daily)"),
|
|
296
|
+
filters: z.array(z.object({
|
|
297
|
+
dimension: z.string(),
|
|
298
|
+
operator: z.string(),
|
|
299
|
+
expression: z.string()
|
|
300
|
+
})).optional().describe("Filter groups to apply"),
|
|
301
|
+
window: z.number().optional().describe("Window size for rolling average in days/weeks (default: 7)"),
|
|
302
|
+
forecastDays: z.number().optional().describe("Number of units (days/weeks) to forecast into the future (default: 7)")
|
|
303
|
+
}, async ({ siteUrl, days, startDate, endDate, dimensions, metrics, granularity, filters, window, forecastDays }) => {
|
|
304
|
+
try {
|
|
305
|
+
const result = await advancedAnalytics.getTimeSeriesInsights(siteUrl, {
|
|
306
|
+
days,
|
|
307
|
+
startDate,
|
|
308
|
+
endDate,
|
|
309
|
+
dimensions,
|
|
310
|
+
metrics,
|
|
311
|
+
granularity,
|
|
312
|
+
filters,
|
|
313
|
+
window,
|
|
314
|
+
forecastDays
|
|
315
|
+
});
|
|
316
|
+
return {
|
|
317
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
catch (error) {
|
|
321
|
+
return formatError(error);
|
|
322
|
+
}
|
|
323
|
+
});
|
|
204
324
|
// Inspection Tools
|
|
205
325
|
server.tool("inspection_inspect", "Inspect a URL to check its indexing status, crawl info, and mobile usability", {
|
|
206
326
|
siteUrl: z.string().describe("The URL of the property (must match a verified property in Search Console)"),
|
|
@@ -292,6 +412,67 @@ server.tool("seo_cannibalization", "Detect keyword cannibalization - multiple pa
|
|
|
292
412
|
return formatError(error);
|
|
293
413
|
}
|
|
294
414
|
});
|
|
415
|
+
server.tool("seo_low_ctr_opportunities", "Find queries ranking in positions 1-10 with low CTR (< 60% of benchmark). Great for title tag optimization.", {
|
|
416
|
+
siteUrl: z.string().describe("The site URL"),
|
|
417
|
+
days: z.number().optional().describe("Number of days to analyze (default: 28)"),
|
|
418
|
+
minImpressions: z.number().optional().describe("Minimum impressions threshold (default: 500)"),
|
|
419
|
+
limit: z.number().optional().describe("Max results to return (default: 50)")
|
|
420
|
+
}, async ({ siteUrl, days, minImpressions, limit }) => {
|
|
421
|
+
try {
|
|
422
|
+
const result = await seoInsights.findLowCTROpportunities(siteUrl, { days, minImpressions, limit });
|
|
423
|
+
return {
|
|
424
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
catch (error) {
|
|
428
|
+
return formatError(error);
|
|
429
|
+
}
|
|
430
|
+
});
|
|
431
|
+
server.tool("seo_striking_distance", "Find keywords ranking in positions 8-15. These are high-priority targets to push to Page 1.", {
|
|
432
|
+
siteUrl: z.string().describe("The site URL"),
|
|
433
|
+
days: z.number().optional().describe("Number of days to analyze (default: 28)"),
|
|
434
|
+
limit: z.number().optional().describe("Max results to return (default: 50)")
|
|
435
|
+
}, async ({ siteUrl, days, limit }) => {
|
|
436
|
+
try {
|
|
437
|
+
const result = await seoInsights.findStrikingDistance(siteUrl, { days, limit });
|
|
438
|
+
return {
|
|
439
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
catch (error) {
|
|
443
|
+
return formatError(error);
|
|
444
|
+
}
|
|
445
|
+
});
|
|
446
|
+
server.tool("seo_lost_queries", "Identify queries that lost all traffic (or dropped >80%) compared to the previous period.", {
|
|
447
|
+
siteUrl: z.string().describe("The site URL"),
|
|
448
|
+
days: z.number().optional().describe("Number of days to compare (default: 28)"),
|
|
449
|
+
limit: z.number().optional().describe("Max results to return (default: 50)")
|
|
450
|
+
}, async ({ siteUrl, days, limit }) => {
|
|
451
|
+
try {
|
|
452
|
+
const result = await seoInsights.findLostQueries(siteUrl, { days, limit });
|
|
453
|
+
return {
|
|
454
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
catch (error) {
|
|
458
|
+
return formatError(error);
|
|
459
|
+
}
|
|
460
|
+
});
|
|
461
|
+
server.tool("seo_brand_vs_nonbrand", "Analyze performance split between Brand and Non-Brand queries using a regex.", {
|
|
462
|
+
siteUrl: z.string().describe("The site URL"),
|
|
463
|
+
brandRegex: z.string().describe("Regex to match brand keywords (e.g. 'acme|acme corp')"),
|
|
464
|
+
days: z.number().optional().describe("Number of days to analyze (default: 28)")
|
|
465
|
+
}, async ({ siteUrl, brandRegex, days }) => {
|
|
466
|
+
try {
|
|
467
|
+
const result = await seoInsights.analyzeBrandVsNonBrand(siteUrl, brandRegex, { days });
|
|
468
|
+
return {
|
|
469
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
catch (error) {
|
|
473
|
+
return formatError(error);
|
|
474
|
+
}
|
|
475
|
+
});
|
|
295
476
|
server.tool("seo_quick_wins", "Find pages with queries ranking on page 2 (positions 11-20) that could be pushed to page 1", {
|
|
296
477
|
siteUrl: z.string().describe("The site URL"),
|
|
297
478
|
days: z.number().optional().describe("Number of days to analyze (default: 28)"),
|
|
@@ -308,6 +489,43 @@ server.tool("seo_quick_wins", "Find pages with queries ranking on page 2 (positi
|
|
|
308
489
|
return formatError(error);
|
|
309
490
|
}
|
|
310
491
|
});
|
|
492
|
+
// SEO Primitives (Atoms)
|
|
493
|
+
server.tool("seo_primitive_ranking_bucket", "primitive: Get the ranking bucket for a specific position (e.g. Top 3, Page 1).", { position: z.number().describe("The ranking position") }, async ({ position }) => {
|
|
494
|
+
return {
|
|
495
|
+
content: [{ type: "text", text: JSON.stringify(seoPrimitives.getRankingBucket(position), null, 2) }]
|
|
496
|
+
};
|
|
497
|
+
});
|
|
498
|
+
server.tool("seo_primitive_traffic_delta", "primitive: Calculate the delta between two traffic metrics (absolute and percentage).", {
|
|
499
|
+
current: z.number().describe("Current value"),
|
|
500
|
+
previous: z.number().describe("Previous value")
|
|
501
|
+
}, async ({ current, previous }) => {
|
|
502
|
+
return {
|
|
503
|
+
content: [{ type: "text", text: JSON.stringify(seoPrimitives.calculateTrafficDelta(current, previous), null, 2) }]
|
|
504
|
+
};
|
|
505
|
+
});
|
|
506
|
+
server.tool("seo_primitive_is_brand", "primitive: Check if a query is a brand query based on a regex pattern.", {
|
|
507
|
+
query: z.string().describe("The search query"),
|
|
508
|
+
brandRegex: z.string().describe("Regex pattern to identify brand terms")
|
|
509
|
+
}, async ({ query, brandRegex }) => {
|
|
510
|
+
return {
|
|
511
|
+
content: [{ type: "text", text: JSON.stringify(seoPrimitives.isBrandQuery(query, brandRegex), null, 2) }]
|
|
512
|
+
};
|
|
513
|
+
});
|
|
514
|
+
server.tool("seo_primitive_is_cannibalized", "primitive: Check if two pages are competing for the same query based on their metrics.", {
|
|
515
|
+
query: z.string().describe("The search query"),
|
|
516
|
+
pageA_position: z.number(),
|
|
517
|
+
pageA_impressions: z.number(),
|
|
518
|
+
pageA_clicks: z.number(),
|
|
519
|
+
pageB_position: z.number(),
|
|
520
|
+
pageB_impressions: z.number(),
|
|
521
|
+
pageB_clicks: z.number()
|
|
522
|
+
}, async ({ query, pageA_position, pageA_impressions, pageA_clicks, pageB_position, pageB_impressions, pageB_clicks }) => {
|
|
523
|
+
const pageA = { position: pageA_position, impressions: pageA_impressions, clicks: pageA_clicks };
|
|
524
|
+
const pageB = { position: pageB_position, impressions: pageB_impressions, clicks: pageB_clicks };
|
|
525
|
+
return {
|
|
526
|
+
content: [{ type: "text", text: JSON.stringify(seoPrimitives.isCannibalized(query, pageA, pageB), null, 2) }]
|
|
527
|
+
};
|
|
528
|
+
});
|
|
311
529
|
// Schema Validator Tools
|
|
312
530
|
server.tool("schema_validate", "Validate Schema.org structured data (JSON-LD) from a URL, HTML snippet, or JSON object.", {
|
|
313
531
|
type: z.enum(["url", "html", "json"]).describe("The type of input provided"),
|
|
@@ -323,6 +541,19 @@ server.tool("schema_validate", "Validate Schema.org structured data (JSON-LD) fr
|
|
|
323
541
|
return formatError(error);
|
|
324
542
|
}
|
|
325
543
|
});
|
|
544
|
+
// Support Tools
|
|
545
|
+
server.tool("util_star_repo", "Star the GitHub repository to support the project. Uses GitHub CLI if available, or opens a browser.", {}, async () => {
|
|
546
|
+
try {
|
|
547
|
+
const { starRepository } = await import("./tools/support.js");
|
|
548
|
+
const result = await starRepository();
|
|
549
|
+
return {
|
|
550
|
+
content: [{ type: "text", text: result }]
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
catch (error) {
|
|
554
|
+
return formatError(error);
|
|
555
|
+
}
|
|
556
|
+
});
|
|
326
557
|
// Resources
|
|
327
558
|
server.resource("sites", "sites://list", async (uri) => {
|
|
328
559
|
const result = await sites.listSites();
|
|
@@ -357,7 +588,7 @@ server.resource("analytics-summary", "analytics://summary/{siteUrl}", async (uri
|
|
|
357
588
|
};
|
|
358
589
|
});
|
|
359
590
|
// Documentation Resources
|
|
360
|
-
import { dimensionsDocs, filtersDocs, searchTypesDocs, patternsDocs } from "./docs/index.js";
|
|
591
|
+
import { dimensionsDocs, filtersDocs, searchTypesDocs, patternsDocs, algorithmUpdatesDocs } from "./docs/index.js";
|
|
361
592
|
server.resource("docs-dimensions", "docs://dimensions", async (uri) => ({
|
|
362
593
|
contents: [{
|
|
363
594
|
uri: uri.href,
|
|
@@ -386,6 +617,13 @@ server.resource("docs-patterns", "docs://patterns", async (uri) => ({
|
|
|
386
617
|
mimeType: "text/markdown"
|
|
387
618
|
}]
|
|
388
619
|
}));
|
|
620
|
+
server.resource("docs-algorithm-updates", "docs://algorithm-updates", async (uri) => ({
|
|
621
|
+
contents: [{
|
|
622
|
+
uri: uri.href,
|
|
623
|
+
text: algorithmUpdatesDocs,
|
|
624
|
+
mimeType: "text/markdown"
|
|
625
|
+
}]
|
|
626
|
+
}));
|
|
389
627
|
// Prompts
|
|
390
628
|
server.prompt("analyze-site-performance", { siteUrl: z.string().describe("The URL of the site to analyze") }, ({ siteUrl }) => ({
|
|
391
629
|
messages: [{
|
|
@@ -503,6 +741,17 @@ Provide a summary with actionable recommendations.`
|
|
|
503
741
|
}]
|
|
504
742
|
}));
|
|
505
743
|
async function main() {
|
|
744
|
+
if (!process.env.GOOGLE_APPLICATION_CREDENTIALS) {
|
|
745
|
+
console.error('\n╔══════════════════════════════════════════════════════════════╗');
|
|
746
|
+
console.error('║ 🚀 Google Search Console MCP Server ║');
|
|
747
|
+
console.error('╚══════════════════════════════════════════════════════════════╝\n');
|
|
748
|
+
console.error('❌ GOOGLE_APPLICATION_CREDENTIALS environment variable is not set.\n');
|
|
749
|
+
console.error('💡 To set up the server, run the setup wizard:');
|
|
750
|
+
console.error(' npx search-console-mcp-setup\n');
|
|
751
|
+
console.error('Alternatively, set the variable manually:');
|
|
752
|
+
console.error(' export GOOGLE_APPLICATION_CREDENTIALS=/path/to/your/key.json\n');
|
|
753
|
+
console.error('─'.repeat(64) + '\n');
|
|
754
|
+
}
|
|
506
755
|
const transport = new StdioServerTransport();
|
|
507
756
|
await server.connect(transport);
|
|
508
757
|
console.error("Google Search Console MCP Server running on stdio");
|
package/dist/setup.js
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { readFileSync, existsSync } from 'fs';
|
|
3
|
-
import { resolve } from 'path';
|
|
2
|
+
import { readFileSync, existsSync, statSync } from 'fs';
|
|
3
|
+
import { resolve, dirname, extname } from 'path';
|
|
4
4
|
import { createInterface } from 'readline';
|
|
5
5
|
import { homedir } from 'os';
|
|
6
|
+
import { fileURLToPath } from 'url';
|
|
7
|
+
import { execSync } from 'child_process';
|
|
8
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
9
|
+
const __dirname = dirname(__filename);
|
|
6
10
|
const rl = createInterface({
|
|
7
11
|
input: process.stdin,
|
|
8
12
|
output: process.stdout
|
|
@@ -33,11 +37,25 @@ function printInfo(text) {
|
|
|
33
37
|
}
|
|
34
38
|
function validateKeyFile(path) {
|
|
35
39
|
try {
|
|
36
|
-
const
|
|
40
|
+
const sanitizedPath = path.trim().replace(/\0/g, '');
|
|
41
|
+
const fullPath = resolve(sanitizedPath.replace('~', homedir()));
|
|
37
42
|
if (!existsSync(fullPath)) {
|
|
38
43
|
printError(`File not found: ${fullPath}`);
|
|
39
44
|
return null;
|
|
40
45
|
}
|
|
46
|
+
const stats = statSync(fullPath);
|
|
47
|
+
if (!stats.isFile()) {
|
|
48
|
+
printError(`Not a regular file: ${fullPath}`);
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
if (extname(fullPath).toLowerCase() !== '.json') {
|
|
52
|
+
printError(`Invalid file type. Please provide a .json file.`);
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
if (stats.size > 1024 * 1024) {
|
|
56
|
+
printError(`File too large. Service account keys are typically small JSON files.`);
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
41
59
|
const content = readFileSync(fullPath, 'utf-8');
|
|
42
60
|
const key = JSON.parse(content);
|
|
43
61
|
const required = ['type', 'project_id', 'client_email', 'private_key'];
|
|
@@ -103,6 +121,30 @@ function showConfigSnippets(credentialsPath) {
|
|
|
103
121
|
}, null, 2));
|
|
104
122
|
console.log('─'.repeat(60));
|
|
105
123
|
}
|
|
124
|
+
export function resolveRepo(dirname) {
|
|
125
|
+
let repo = '';
|
|
126
|
+
try {
|
|
127
|
+
const url = execSync('git remote get-url origin', { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
128
|
+
repo = url
|
|
129
|
+
.replace(/^git@github\.com:|^https:\/\/github\.com\//, '')
|
|
130
|
+
.replace(/\.git$/, '');
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
// Fallback to package.json
|
|
134
|
+
const pkgPath = resolve(dirname, '../package.json');
|
|
135
|
+
if (existsSync(pkgPath)) {
|
|
136
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
137
|
+
if (pkg.repository?.url) {
|
|
138
|
+
repo = pkg.repository.url.replace(/.*github\.com\//, '').replace(/\.git$/, '');
|
|
139
|
+
}
|
|
140
|
+
else if (pkg.mcpName && pkg.mcpName.includes('/')) {
|
|
141
|
+
// Handle io.github.owner/repo or owner/repo
|
|
142
|
+
repo = pkg.mcpName.replace(/^io\.github\./, '').split('/').slice(-2).join('/');
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return repo;
|
|
147
|
+
}
|
|
106
148
|
async function main() {
|
|
107
149
|
printHeader();
|
|
108
150
|
console.log('This wizard will help you set up Search Console MCP.');
|
|
@@ -176,13 +218,18 @@ async function main() {
|
|
|
176
218
|
const answer = await ask('Would you like to star the repo on GitHub? (y/n): ');
|
|
177
219
|
if (answer.toLowerCase().startsWith('y')) {
|
|
178
220
|
try {
|
|
179
|
-
const
|
|
180
|
-
|
|
181
|
-
|
|
221
|
+
const repo = resolveRepo(__dirname);
|
|
222
|
+
if (repo && repo.includes('/')) {
|
|
223
|
+
execSync(`gh api -X PUT /user/starred/${repo}`, { stdio: 'ignore' });
|
|
224
|
+
printSuccess('Thanks for your support! ⭐');
|
|
225
|
+
}
|
|
226
|
+
else {
|
|
227
|
+
throw new Error('Could not resolve repo');
|
|
228
|
+
}
|
|
182
229
|
}
|
|
183
230
|
catch (error) {
|
|
184
|
-
|
|
185
|
-
console.log('
|
|
231
|
+
console.log('\nCould not star automatically. Please star us manually if you like:');
|
|
232
|
+
console.log('🔗 https://github.com/saurabhsharma2u/search-console-mcp');
|
|
186
233
|
}
|
|
187
234
|
}
|
|
188
235
|
else {
|
|
@@ -191,7 +238,10 @@ async function main() {
|
|
|
191
238
|
rl.close();
|
|
192
239
|
}
|
|
193
240
|
// Run if called directly
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
241
|
+
const isMain = process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1]);
|
|
242
|
+
if (isMain) {
|
|
243
|
+
main().catch((error) => {
|
|
244
|
+
console.error('Setup failed:', error);
|
|
245
|
+
process.exit(1);
|
|
246
|
+
});
|
|
247
|
+
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { queryAnalytics, detectAnomalies } from './analytics.js';
|
|
2
|
+
/**
|
|
3
|
+
* Historical Google Algorithm Update dates (recent notable ones)
|
|
4
|
+
*/
|
|
5
|
+
const ALGORITHM_UPDATES = [
|
|
6
|
+
{ date: '2023-09-14', name: 'September 2023 Helpful Content Update' },
|
|
7
|
+
{ date: '2023-10-05', name: 'October 2023 Core Update' },
|
|
8
|
+
{ date: '2023-11-02', name: 'November 2023 Core Update' },
|
|
9
|
+
{ date: '2024-03-05', name: 'March 2024 Core Update' },
|
|
10
|
+
{ date: '2024-06-20', name: 'June 2024 Spam Update' },
|
|
11
|
+
{ date: '2024-08-15', name: 'August 2024 Core Update' },
|
|
12
|
+
{ date: '2024-11-11', name: 'November 2024 Core Update' },
|
|
13
|
+
{ date: '2025-01-15', name: 'January 2025 Core Update' }
|
|
14
|
+
];
|
|
15
|
+
/**
|
|
16
|
+
* Identify the cause of a significant traffic drop by analyzing device distribution and algorithm updates.
|
|
17
|
+
*
|
|
18
|
+
* @param siteUrl - The URL of the site to analyze.
|
|
19
|
+
* @param options - Configuration including the lookback period and anomaly threshold.
|
|
20
|
+
* @returns A detailed attribution object or null if no significant drop is found.
|
|
21
|
+
*/
|
|
22
|
+
export async function analyzeDropAttribution(siteUrl, options = {}) {
|
|
23
|
+
const anomalies = await detectAnomalies(siteUrl, { days: options.days ?? 30, threshold: options.threshold ?? 2.0 });
|
|
24
|
+
// Find the most recent 'drop'
|
|
25
|
+
const mostRecentDrop = anomalies.find(a => a.type === 'drop');
|
|
26
|
+
if (!mostRecentDrop)
|
|
27
|
+
return null;
|
|
28
|
+
const dropDate = mostRecentDrop.date;
|
|
29
|
+
const previousWeekStart = new Date(dropDate);
|
|
30
|
+
previousWeekStart.setDate(previousWeekStart.getDate() - 7);
|
|
31
|
+
const previousWeekEnd = new Date(dropDate);
|
|
32
|
+
previousWeekEnd.setDate(previousWeekEnd.getDate() - 1);
|
|
33
|
+
const [dropDayStats, baselineStats] = await Promise.all([
|
|
34
|
+
queryAnalytics({
|
|
35
|
+
siteUrl,
|
|
36
|
+
startDate: dropDate,
|
|
37
|
+
endDate: dropDate,
|
|
38
|
+
dimensions: ['device']
|
|
39
|
+
}),
|
|
40
|
+
queryAnalytics({
|
|
41
|
+
siteUrl,
|
|
42
|
+
startDate: previousWeekStart.toISOString().split('T')[0],
|
|
43
|
+
endDate: previousWeekEnd.toISOString().split('T')[0],
|
|
44
|
+
dimensions: ['device']
|
|
45
|
+
})
|
|
46
|
+
]);
|
|
47
|
+
const dropMap = new Map(dropDayStats.map(r => [r.keys?.[0]?.toLowerCase(), r.clicks ?? 0]));
|
|
48
|
+
const baselineMap = new Map();
|
|
49
|
+
baselineStats.forEach(r => {
|
|
50
|
+
const device = r.keys?.[0]?.toLowerCase();
|
|
51
|
+
const current = baselineMap.get(device) || 0;
|
|
52
|
+
baselineMap.set(device, current + (r.clicks ?? 0) / 7);
|
|
53
|
+
});
|
|
54
|
+
const impacts = { mobile: 0, desktop: 0, tablet: 0 };
|
|
55
|
+
let primaryDevice = 'unknown';
|
|
56
|
+
let maxDeficit = 0;
|
|
57
|
+
['mobile', 'desktop', 'tablet'].forEach(device => {
|
|
58
|
+
const baseline = baselineMap.get(device) || 0;
|
|
59
|
+
const actual = dropMap.get(device) || 0;
|
|
60
|
+
const deficit = baseline - actual;
|
|
61
|
+
impacts[device] = parseFloat(deficit.toFixed(2));
|
|
62
|
+
if (deficit > maxDeficit) {
|
|
63
|
+
maxDeficit = deficit;
|
|
64
|
+
primaryDevice = device;
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
// Check for algorithm updates within 2 days of the drop
|
|
68
|
+
const possibleUpdate = ALGORITHM_UPDATES.find(u => {
|
|
69
|
+
const uDate = new Date(u.date);
|
|
70
|
+
const dDate = new Date(dropDate);
|
|
71
|
+
const diff = Math.abs(dDate.getTime() - uDate.getTime()) / (1000 * 60 * 60 * 24);
|
|
72
|
+
return diff <= 2;
|
|
73
|
+
});
|
|
74
|
+
return {
|
|
75
|
+
date: dropDate,
|
|
76
|
+
metric: mostRecentDrop.metric,
|
|
77
|
+
totalDrop: mostRecentDrop.value - mostRecentDrop.expectedValue,
|
|
78
|
+
deviceImpact: impacts,
|
|
79
|
+
primaryCause: primaryDevice !== 'unknown' ? `Disproportionate drop on ${primaryDevice}` : 'Uniform drop across devices',
|
|
80
|
+
possibleAlgorithmUpdate: possibleUpdate?.name
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Advanced time series analysis for smoothing, seasonality, and forecasting.
|
|
85
|
+
* Supports dynamic dimensions, multiple metrics, granularities, and custom filtering.
|
|
86
|
+
*
|
|
87
|
+
* @param siteUrl - The URL of the site to analyze.
|
|
88
|
+
* @param options - Analysis configuration including metrics, granularity, and forecast length.
|
|
89
|
+
* @returns Historical data points with rolling averages and a forecast object.
|
|
90
|
+
*/
|
|
91
|
+
export async function getTimeSeriesInsights(siteUrl, options = {}) {
|
|
92
|
+
const metrics = options.metrics || ['clicks'];
|
|
93
|
+
const granularity = options.granularity || 'daily';
|
|
94
|
+
const windowSize = options.window || 7;
|
|
95
|
+
const forecastDays = options.forecastDays ?? 7;
|
|
96
|
+
const dimensions = options.dimensions || ['date'];
|
|
97
|
+
// Ensure 'date' is included in dimensions for time series if not already there
|
|
98
|
+
if (!dimensions.includes('date')) {
|
|
99
|
+
dimensions.unshift('date');
|
|
100
|
+
}
|
|
101
|
+
let startDate;
|
|
102
|
+
let endDate;
|
|
103
|
+
if (options.startDate && options.endDate) {
|
|
104
|
+
startDate = options.startDate;
|
|
105
|
+
endDate = options.endDate;
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
const days = options.days ?? 60;
|
|
109
|
+
const end = new Date();
|
|
110
|
+
end.setDate(end.getDate() - 3); // GSC delay
|
|
111
|
+
const start = new Date(end);
|
|
112
|
+
start.setDate(start.getDate() - days);
|
|
113
|
+
startDate = start.toISOString().split('T')[0];
|
|
114
|
+
endDate = end.toISOString().split('T')[0];
|
|
115
|
+
}
|
|
116
|
+
const rows = await queryAnalytics({
|
|
117
|
+
siteUrl,
|
|
118
|
+
startDate,
|
|
119
|
+
endDate,
|
|
120
|
+
dimensions,
|
|
121
|
+
filters: options.filters
|
|
122
|
+
});
|
|
123
|
+
// Handle data parsing and grouping
|
|
124
|
+
let data = rows.map(r => {
|
|
125
|
+
const dimObj = {};
|
|
126
|
+
dimensions.forEach((d, i) => {
|
|
127
|
+
if (d !== 'date')
|
|
128
|
+
dimObj[d] = r.keys?.[i] || '';
|
|
129
|
+
});
|
|
130
|
+
const metricObj = {};
|
|
131
|
+
metrics.forEach(m => {
|
|
132
|
+
metricObj[m] = r[m] ?? 0;
|
|
133
|
+
});
|
|
134
|
+
return {
|
|
135
|
+
date: r.keys?.[dimensions.indexOf('date')] || '',
|
|
136
|
+
dimensions: dimObj,
|
|
137
|
+
metrics: metricObj
|
|
138
|
+
};
|
|
139
|
+
}).sort((a, b) => a.date.localeCompare(b.date));
|
|
140
|
+
// Support weekly granularity
|
|
141
|
+
if (granularity === 'weekly') {
|
|
142
|
+
const weeklyData = {};
|
|
143
|
+
data.forEach(d => {
|
|
144
|
+
const date = new Date(d.date);
|
|
145
|
+
const day = date.getDay();
|
|
146
|
+
const diff = date.getDate() - day + (day === 0 ? -6 : 1); // Adjust to Monday
|
|
147
|
+
const monday = new Date(date.setDate(diff));
|
|
148
|
+
const weekKey = monday.toISOString().split('T')[0];
|
|
149
|
+
if (!weeklyData[weekKey]) {
|
|
150
|
+
weeklyData[weekKey] = {
|
|
151
|
+
date: weekKey,
|
|
152
|
+
dimensions: d.dimensions,
|
|
153
|
+
metrics: { ...d.metrics }
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
metrics.forEach(m => {
|
|
158
|
+
if (m === 'position') {
|
|
159
|
+
// For average position, we might need a weighted average, but simple average for now
|
|
160
|
+
weeklyData[weekKey].metrics[m] = (weeklyData[weekKey].metrics[m] + d.metrics[m]) / 2;
|
|
161
|
+
}
|
|
162
|
+
else if (m === 'ctr') {
|
|
163
|
+
weeklyData[weekKey].metrics[m] = (weeklyData[weekKey].metrics[m] + d.metrics[m]) / 2;
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
weeklyData[weekKey].metrics[m] += d.metrics[m];
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
data = Object.values(weeklyData).sort((a, b) => a.date.localeCompare(b.date));
|
|
172
|
+
}
|
|
173
|
+
// 1. Calculate Rolling Averages for EACH metric
|
|
174
|
+
const history = data.map((d, i) => {
|
|
175
|
+
const win = data.slice(Math.max(0, i - (windowSize - 1)), i + 1);
|
|
176
|
+
const rollingAvgObj = {};
|
|
177
|
+
metrics.forEach(m => {
|
|
178
|
+
const avg = win.reduce((sum, curr) => sum + curr.metrics[m], 0) / win.length;
|
|
179
|
+
rollingAvgObj[m] = parseFloat(avg.toFixed(2));
|
|
180
|
+
});
|
|
181
|
+
return {
|
|
182
|
+
date: granularity === 'daily' ? d.date : undefined,
|
|
183
|
+
week: granularity === 'weekly' ? d.date : undefined,
|
|
184
|
+
dimensions: Object.keys(d.dimensions).length > 0 ? d.dimensions : undefined,
|
|
185
|
+
metrics: d.metrics,
|
|
186
|
+
rollingAverages: rollingAvgObj,
|
|
187
|
+
isSeasonalPeak: false
|
|
188
|
+
};
|
|
189
|
+
});
|
|
190
|
+
// 2. Identify Seasonality (only makes sense for daily data)
|
|
191
|
+
let seasonalityStrength = 0;
|
|
192
|
+
if (granularity === 'daily' && data.length >= 14) {
|
|
193
|
+
const firstMetric = metrics[0];
|
|
194
|
+
const dowStats = [[], [], [], [], [], [], []];
|
|
195
|
+
data.forEach(d => {
|
|
196
|
+
const day = new Date(d.date).getDay();
|
|
197
|
+
dowStats[day].push(d.metrics[firstMetric]);
|
|
198
|
+
});
|
|
199
|
+
const dowAverages = dowStats.map(vals => vals.length > 0 ? vals.reduce((a, b) => a + b, 0) / vals.length : 0);
|
|
200
|
+
const globalAvg = dowAverages.reduce((a, b) => a + b, 0) / 7;
|
|
201
|
+
const variance = dowAverages.reduce((a, b) => a + Math.pow(b - globalAvg, 2), 0) / 7;
|
|
202
|
+
const stdDev = Math.sqrt(variance);
|
|
203
|
+
seasonalityStrength = parseFloat(Math.min(1, stdDev / (globalAvg || 1)).toFixed(2));
|
|
204
|
+
const peakDay = dowAverages.indexOf(Math.max(...dowAverages));
|
|
205
|
+
history.forEach(h => {
|
|
206
|
+
if (h.date && new Date(h.date).getDay() === peakDay)
|
|
207
|
+
h.isSeasonalPeak = true;
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
// 3. Simple Linear Regression for Forecasting for EACH metric
|
|
211
|
+
const forecastResults = {};
|
|
212
|
+
let overallTrend = 'stable';
|
|
213
|
+
metrics.forEach(m => {
|
|
214
|
+
const n = data.length;
|
|
215
|
+
if (n < 2) {
|
|
216
|
+
forecastResults[m] = [];
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
let sumX = 0, sumY = 0, sumXY = 0, sumXX = 0;
|
|
220
|
+
for (let i = 0; i < n; i++) {
|
|
221
|
+
sumX += i;
|
|
222
|
+
sumY += data[i].metrics[m];
|
|
223
|
+
sumXY += i * data[i].metrics[m];
|
|
224
|
+
sumXX += i * i;
|
|
225
|
+
}
|
|
226
|
+
const slope = (n * sumXY - sumX * sumY) / (n * sumXX - sumX * sumX);
|
|
227
|
+
const intercept = (sumY - slope * sumX) / n;
|
|
228
|
+
if (m === metrics[0]) {
|
|
229
|
+
overallTrend = slope > 0.05 ? 'up' : (slope < -0.05 ? 'down' : 'stable');
|
|
230
|
+
}
|
|
231
|
+
const forecastPoints = [];
|
|
232
|
+
for (let i = n; i < n + forecastDays; i++) {
|
|
233
|
+
const rawForecast = slope * i + intercept;
|
|
234
|
+
forecastPoints.push(Math.max(0, Math.round(rawForecast)));
|
|
235
|
+
}
|
|
236
|
+
forecastResults[m] = forecastPoints;
|
|
237
|
+
});
|
|
238
|
+
return {
|
|
239
|
+
history,
|
|
240
|
+
forecast: {
|
|
241
|
+
currentTrend: overallTrend,
|
|
242
|
+
forecastedValues: forecastResults,
|
|
243
|
+
seasonalityStrength
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
}
|