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/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.string().optional().describe("Search type (web, image, video, news, discover, googleNews)"),
123
- limit: z.number().optional().describe("Max number of rows (default: 1000, max: 25000)"),
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 fullPath = resolve(path.replace('~', homedir()));
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 { execSync } = await import('child_process');
180
- execSync('gh repo star saurabhsharma2u/search-console-mcp', { stdio: 'inherit' });
181
- printSuccess('Thanks for your support! ');
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
- printInfo('Could not auto-star the repo (GitHub CLI might be missing).');
185
- console.log('Please star us manually at: https://github.com/saurabhsharma2u/search-console-mcp');
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
- main().catch((error) => {
195
- console.error('Setup failed:', error);
196
- process.exit(1);
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
+ }