search-console-mcp 1.13.3 → 1.13.4
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/bing/tools/advanced-analytics.js +11 -8
- package/dist/bing/tools/analytics.js +21 -20
- package/dist/common/auth/resolver.js +17 -7
- package/dist/common/diagnostics.js +63 -0
- package/dist/common/utils/dates.js +32 -0
- package/dist/ga4/tools/analytics.js +14 -6
- package/dist/google/client.js +7 -1
- package/dist/google/tools/analytics.js +14 -2
- package/dist/index.js +54 -16
- package/dist/utils/logger.js +39 -0
- package/package.json +2 -2
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { detectAnomalies, getRankAndTrafficStats } from './analytics.js';
|
|
2
|
+
import { parseMicrosoftDate, startOfDay, endOfDay } from '../../common/utils/dates.js';
|
|
2
3
|
/**
|
|
3
4
|
* Historical Bing Algorithm Update dates (recent notable ones)
|
|
4
5
|
*/
|
|
@@ -35,8 +36,8 @@ export async function analyzeDropAttribution(siteUrl, options = {}) {
|
|
|
35
36
|
const impacts = { mobile: 0, desktop: 0, tablet: 0 };
|
|
36
37
|
// Check for algorithm updates within 2 days of the drop
|
|
37
38
|
const possibleUpdate = ALGORITHM_UPDATES.find(u => {
|
|
38
|
-
const uDate = new Date(u.date);
|
|
39
|
-
const dDate =
|
|
39
|
+
const uDate = startOfDay(new Date(u.date));
|
|
40
|
+
const dDate = startOfDay(parseMicrosoftDate(dropDate));
|
|
40
41
|
const diff = Math.abs(dDate.getTime() - uDate.getTime()) / (1000 * 60 * 60 * 24);
|
|
41
42
|
return diff <= 2;
|
|
42
43
|
});
|
|
@@ -66,12 +67,13 @@ export async function getTimeSeriesInsights(siteUrl, options = {}) {
|
|
|
66
67
|
const dimensions = ['date'];
|
|
67
68
|
const rawRows = await getRankAndTrafficStats(siteUrl);
|
|
68
69
|
// Sort rows by date ascending
|
|
69
|
-
const rows = rawRows.sort((a, b) =>
|
|
70
|
+
const rows = rawRows.sort((a, b) => parseMicrosoftDate(a.Date).getTime() - parseMicrosoftDate(b.Date).getTime());
|
|
70
71
|
// Filter by date range if provided
|
|
71
72
|
let startIndex = 0;
|
|
72
73
|
let endIndex = rows.length;
|
|
73
74
|
if (options.startDate) {
|
|
74
|
-
|
|
75
|
+
const s = startOfDay(new Date(options.startDate)).getTime();
|
|
76
|
+
startIndex = rows.findIndex(r => parseMicrosoftDate(r.Date).getTime() >= s);
|
|
75
77
|
// If no date is >= startDate, then all dates are < startDate (since sorted ascending)
|
|
76
78
|
if (startIndex === -1)
|
|
77
79
|
startIndex = rows.length;
|
|
@@ -80,8 +82,9 @@ export async function getTimeSeriesInsights(siteUrl, options = {}) {
|
|
|
80
82
|
startIndex = Math.max(0, rows.length - options.days);
|
|
81
83
|
}
|
|
82
84
|
if (options.endDate) {
|
|
85
|
+
const e = endOfDay(new Date(options.endDate)).getTime();
|
|
83
86
|
// find index where date > endDate
|
|
84
|
-
endIndex = rows.findIndex(r => r.Date >
|
|
87
|
+
endIndex = rows.findIndex(r => parseMicrosoftDate(r.Date).getTime() > e);
|
|
85
88
|
if (endIndex === -1)
|
|
86
89
|
endIndex = rows.length;
|
|
87
90
|
}
|
|
@@ -113,7 +116,7 @@ export async function getTimeSeriesInsights(siteUrl, options = {}) {
|
|
|
113
116
|
if (granularity === 'weekly') {
|
|
114
117
|
const weeklyData = {};
|
|
115
118
|
data.forEach(d => {
|
|
116
|
-
const date =
|
|
119
|
+
const date = parseMicrosoftDate(d.date);
|
|
117
120
|
const day = date.getUTCDay();
|
|
118
121
|
const diff = date.getUTCDate() - day + (day === 0 ? -6 : 1); // Adjust to Monday
|
|
119
122
|
const monday = new Date(date);
|
|
@@ -185,7 +188,7 @@ export async function getTimeSeriesInsights(siteUrl, options = {}) {
|
|
|
185
188
|
const firstMetric = metrics[0];
|
|
186
189
|
const dowStats = [[], [], [], [], [], [], []];
|
|
187
190
|
data.forEach(d => {
|
|
188
|
-
const day =
|
|
191
|
+
const day = parseMicrosoftDate(d.date).getDay();
|
|
189
192
|
dowStats[day].push(d.metrics[firstMetric]);
|
|
190
193
|
});
|
|
191
194
|
const dowAverages = dowStats.map(vals => vals.length > 0 ? vals.reduce((a, b) => a + b, 0) / vals.length : 0);
|
|
@@ -195,7 +198,7 @@ export async function getTimeSeriesInsights(siteUrl, options = {}) {
|
|
|
195
198
|
seasonalityStrength = parseFloat(Math.min(1, stdDev / (globalAvg || 1)).toFixed(2));
|
|
196
199
|
const peakDay = dowAverages.indexOf(Math.max(...dowAverages));
|
|
197
200
|
history.forEach(h => {
|
|
198
|
-
if (h.date &&
|
|
201
|
+
if (h.date && parseMicrosoftDate(h.date).getDay() === peakDay)
|
|
199
202
|
h.isSeasonalPeak = true;
|
|
200
203
|
});
|
|
201
204
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { getBingClient } from '../client.js';
|
|
2
|
+
import { parseMicrosoftDate, startOfDay, endOfDay } from '../../common/utils/dates.js';
|
|
2
3
|
/**
|
|
3
4
|
* Get query performance stats for a Bing site with optional date filtering.
|
|
4
5
|
*/
|
|
@@ -7,10 +8,10 @@ export async function getQueryStats(siteUrl, startDate, endDate) {
|
|
|
7
8
|
const stats = await client.getQueryStats(siteUrl);
|
|
8
9
|
if (!startDate && !endDate)
|
|
9
10
|
return stats;
|
|
10
|
-
const start = startDate ? new Date(startDate) : new Date(0);
|
|
11
|
-
const end = endDate ? new Date(endDate) : new Date();
|
|
11
|
+
const start = startDate ? startOfDay(new Date(startDate)) : new Date(0);
|
|
12
|
+
const end = endDate ? endOfDay(new Date(endDate)) : new Date();
|
|
12
13
|
return stats.filter(row => {
|
|
13
|
-
const d =
|
|
14
|
+
const d = parseMicrosoftDate(row.Date);
|
|
14
15
|
return d >= start && d <= end;
|
|
15
16
|
});
|
|
16
17
|
}
|
|
@@ -22,10 +23,10 @@ export async function getPageStats(siteUrl, startDate, endDate) {
|
|
|
22
23
|
const stats = await client.getPageStats(siteUrl);
|
|
23
24
|
if (!startDate && !endDate)
|
|
24
25
|
return stats;
|
|
25
|
-
const start = startDate ? new Date(startDate) : new Date(0);
|
|
26
|
-
const end = endDate ? new Date(endDate) : new Date();
|
|
26
|
+
const start = startDate ? startOfDay(new Date(startDate)) : new Date(0);
|
|
27
|
+
const end = endDate ? endOfDay(new Date(endDate)) : new Date();
|
|
27
28
|
return stats.filter(row => {
|
|
28
|
-
const d =
|
|
29
|
+
const d = parseMicrosoftDate(row.Date);
|
|
29
30
|
return d >= start && d <= end;
|
|
30
31
|
});
|
|
31
32
|
}
|
|
@@ -37,10 +38,10 @@ export async function getPageQueryStats(siteUrl, pageUrl, startDate, endDate) {
|
|
|
37
38
|
const stats = await client.getPageQueryStats(siteUrl, pageUrl);
|
|
38
39
|
if (!startDate && !endDate)
|
|
39
40
|
return stats;
|
|
40
|
-
const start = startDate ? new Date(startDate) : new Date(0);
|
|
41
|
-
const end = endDate ? new Date(endDate) : new Date();
|
|
41
|
+
const start = startDate ? startOfDay(new Date(startDate)) : new Date(0);
|
|
42
|
+
const end = endDate ? endOfDay(new Date(endDate)) : new Date();
|
|
42
43
|
return stats.filter(row => {
|
|
43
|
-
const d =
|
|
44
|
+
const d = parseMicrosoftDate(row.Date);
|
|
44
45
|
return d >= start && d <= end;
|
|
45
46
|
});
|
|
46
47
|
}
|
|
@@ -52,10 +53,10 @@ export async function getQueryPageStats(siteUrl, startDate, endDate) {
|
|
|
52
53
|
const stats = await client.getQueryPageStats(siteUrl);
|
|
53
54
|
if (!startDate && !endDate)
|
|
54
55
|
return stats;
|
|
55
|
-
const start = startDate ? new Date(startDate) : new Date(0);
|
|
56
|
-
const end = endDate ? new Date(endDate) : new Date();
|
|
56
|
+
const start = startDate ? startOfDay(new Date(startDate)) : new Date(0);
|
|
57
|
+
const end = endDate ? endOfDay(new Date(endDate)) : new Date();
|
|
57
58
|
return stats.filter(row => {
|
|
58
|
-
const d =
|
|
59
|
+
const d = parseMicrosoftDate(row.Date);
|
|
59
60
|
return d >= start && d <= end;
|
|
60
61
|
});
|
|
61
62
|
}
|
|
@@ -72,10 +73,10 @@ export async function getRankAndTrafficStats(siteUrl) {
|
|
|
72
73
|
export async function comparePeriods(siteUrl, startDate1, endDate1, startDate2, endDate2) {
|
|
73
74
|
const stats = await getRankAndTrafficStats(siteUrl);
|
|
74
75
|
const getSummary = (start, end) => {
|
|
75
|
-
const s = new Date(start);
|
|
76
|
-
const e = new Date(end);
|
|
76
|
+
const s = startOfDay(new Date(start));
|
|
77
|
+
const e = endOfDay(new Date(end));
|
|
77
78
|
const filtered = stats.filter((row) => {
|
|
78
|
-
const d =
|
|
79
|
+
const d = parseMicrosoftDate(row.Date);
|
|
79
80
|
return d >= s && d <= e;
|
|
80
81
|
});
|
|
81
82
|
const clicks = filtered.reduce((acc, row) => acc + row.Clicks, 0);
|
|
@@ -127,7 +128,7 @@ export async function detectAnomalies(siteUrl, options = {}) {
|
|
|
127
128
|
const threshold = options.threshold || 2.5;
|
|
128
129
|
const stats = await getRankAndTrafficStats(siteUrl);
|
|
129
130
|
// Sort by date ascending
|
|
130
|
-
const sorted = [...stats].sort((a, b) =>
|
|
131
|
+
const sorted = [...stats].sort((a, b) => parseMicrosoftDate(a.Date).getTime() - parseMicrosoftDate(b.Date).getTime());
|
|
131
132
|
if (sorted.length < 5)
|
|
132
133
|
return [];
|
|
133
134
|
const anomalies = [];
|
|
@@ -157,11 +158,11 @@ export async function detectAnomalies(siteUrl, options = {}) {
|
|
|
157
158
|
export async function getPerformanceSummary(siteUrl, days = 28) {
|
|
158
159
|
const stats = await getRankAndTrafficStats(siteUrl);
|
|
159
160
|
// Bing typically has historical data; filter for last N days
|
|
160
|
-
const endDate = new Date();
|
|
161
|
-
const startDate = new Date();
|
|
161
|
+
const endDate = endOfDay(new Date());
|
|
162
|
+
const startDate = startOfDay(new Date());
|
|
162
163
|
startDate.setDate(endDate.getDate() - days);
|
|
163
164
|
const filtered = stats.filter((row) => {
|
|
164
|
-
const d =
|
|
165
|
+
const d = parseMicrosoftDate(row.Date);
|
|
165
166
|
return d >= startDate && d <= endDate;
|
|
166
167
|
});
|
|
167
168
|
const clicks = filtered.reduce((acc, row) => acc + row.Clicks, 0);
|
|
@@ -203,7 +204,7 @@ export async function detectTrends(siteUrl, options = {}) {
|
|
|
203
204
|
const filterStats = (start, end) => {
|
|
204
205
|
const map = new Map();
|
|
205
206
|
allStats.forEach(stat => {
|
|
206
|
-
const d =
|
|
207
|
+
const d = parseMicrosoftDate(stat.Date);
|
|
207
208
|
if (d >= start && d <= end) {
|
|
208
209
|
map.set(stat.Query, (map.get(stat.Query) || 0) + stat.Clicks);
|
|
209
210
|
}
|
|
@@ -36,19 +36,29 @@ export async function resolveAccount(siteUrl, engine) {
|
|
|
36
36
|
return exactMatch;
|
|
37
37
|
// 2. Domain Match (if target is a URL prefix and we have a domain boundary)
|
|
38
38
|
if (normalizedTarget.type === 'url-prefix') {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
39
|
+
try {
|
|
40
|
+
const targetHost = new URL(siteUrl).hostname.toLowerCase();
|
|
41
|
+
const domainMatch = accounts.find(a => a.websites?.some(w => {
|
|
42
|
+
const nw = normalizeWebsite(w);
|
|
43
|
+
return nw.type === 'domain' && (targetHost === nw.value || targetHost.endsWith('.' + nw.value));
|
|
44
|
+
}));
|
|
45
|
+
if (domainMatch)
|
|
46
|
+
return domainMatch;
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
// If the URL is invalid, we can't do a domain match.
|
|
50
|
+
// Just skip to the next resolution step.
|
|
51
|
+
}
|
|
46
52
|
}
|
|
47
53
|
// 3. Global Accounts (Empty websites list)
|
|
48
54
|
const globalAccounts = accounts.filter(a => !a.websites || a.websites.length === 0);
|
|
49
55
|
if (globalAccounts.length === 1) {
|
|
50
56
|
return globalAccounts[0];
|
|
51
57
|
}
|
|
58
|
+
// Single account fallback: If no siteUrl requested and only one account exists, use it.
|
|
59
|
+
if (!siteUrl && accounts.length === 1) {
|
|
60
|
+
return accounts[0];
|
|
61
|
+
}
|
|
52
62
|
if (globalAccounts.length > 1) {
|
|
53
63
|
const error = new Error(`Multiple ${engine} accounts found. Please specify an account boundary or remove unused accounts.`);
|
|
54
64
|
error.code = 'AMBIGUOUS';
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { loadConfig } from './auth/config.js';
|
|
2
|
+
import { getSearchConsoleClient } from '../google/client.js';
|
|
3
|
+
import { getBingClient } from '../bing/client.js';
|
|
4
|
+
import { logger } from '../utils/logger.js';
|
|
5
|
+
/**
|
|
6
|
+
* Runs a set of diagnostic checks to verify API connectivity and account health.
|
|
7
|
+
*/
|
|
8
|
+
export async function runDiagnostics() {
|
|
9
|
+
const config = await loadConfig();
|
|
10
|
+
const accounts = Object.values(config.accounts);
|
|
11
|
+
const results = [];
|
|
12
|
+
logger.info(`Starting diagnostics for ${accounts.length} accounts...`);
|
|
13
|
+
logger.info(`System Time: ${new Date().toISOString()}`);
|
|
14
|
+
logger.info(`Timezone: ${Intl.DateTimeFormat().resolvedOptions().timeZone}`);
|
|
15
|
+
for (const account of accounts) {
|
|
16
|
+
logger.debug(`Checking account: ${account.alias} (${account.engine})`);
|
|
17
|
+
try {
|
|
18
|
+
if (account.engine === 'google') {
|
|
19
|
+
const client = await getSearchConsoleClient(undefined, account.id);
|
|
20
|
+
// Test call: list sites (limited to 1 for quick check)
|
|
21
|
+
const res = await client.sites.list();
|
|
22
|
+
results.push({
|
|
23
|
+
engine: 'google',
|
|
24
|
+
account: account.alias,
|
|
25
|
+
status: 'ok',
|
|
26
|
+
message: `Successfully connected. Account has access to ${res.data.siteEntry?.length || 0} sites.`,
|
|
27
|
+
details: { sitesCount: res.data.siteEntry?.length || 0 }
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
else if (account.engine === 'bing') {
|
|
31
|
+
const client = await getBingClient(undefined, account.id);
|
|
32
|
+
const res = await client.getSiteList();
|
|
33
|
+
results.push({
|
|
34
|
+
engine: 'bing',
|
|
35
|
+
account: account.alias,
|
|
36
|
+
status: 'ok',
|
|
37
|
+
message: `Successfully connected. Account has access to ${res.length} sites.`,
|
|
38
|
+
details: { sitesCount: res.length }
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
// Add GA4 diagnostics if needed
|
|
42
|
+
}
|
|
43
|
+
catch (e) {
|
|
44
|
+
const error = e;
|
|
45
|
+
logger.error(`Diagnostic failed for ${account.alias}: ${error.message}`);
|
|
46
|
+
results.push({
|
|
47
|
+
engine: account.engine,
|
|
48
|
+
account: account.alias,
|
|
49
|
+
status: 'error',
|
|
50
|
+
message: error.message
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (accounts.length === 0) {
|
|
55
|
+
results.push({
|
|
56
|
+
engine: 'system',
|
|
57
|
+
account: 'none',
|
|
58
|
+
status: 'error',
|
|
59
|
+
message: 'No accounts configured. Run setup first.'
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
return results;
|
|
63
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parses a Microsoft-style JSON date string (e.g., "/Date(1731225600000-0800)/")
|
|
3
|
+
* into a standard JavaScript Date object.
|
|
4
|
+
*/
|
|
5
|
+
export function parseMicrosoftDate(dateStr) {
|
|
6
|
+
if (!dateStr)
|
|
7
|
+
return new Date(0);
|
|
8
|
+
// Extract the timestamp digits
|
|
9
|
+
const match = dateStr.match(/\/Date\((\d+)([+-]\d+)?\)\//);
|
|
10
|
+
if (match) {
|
|
11
|
+
const timestamp = parseInt(match[1], 10);
|
|
12
|
+
return new Date(timestamp);
|
|
13
|
+
}
|
|
14
|
+
// Fallback to standard Date parsing if format doesn't match
|
|
15
|
+
return new Date(dateStr);
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Normalizes a date to the start of the day (00:00:00.000) in local time.
|
|
19
|
+
*/
|
|
20
|
+
export function startOfDay(date) {
|
|
21
|
+
const d = new Date(date);
|
|
22
|
+
d.setHours(0, 0, 0, 0);
|
|
23
|
+
return d;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Normalizes a date to the end of the day (23:59:59.999) in local time.
|
|
27
|
+
*/
|
|
28
|
+
export function endOfDay(date) {
|
|
29
|
+
const d = new Date(date);
|
|
30
|
+
d.setHours(23, 59, 59, 999);
|
|
31
|
+
return d;
|
|
32
|
+
}
|
|
@@ -9,9 +9,12 @@ export function clearAnalyticsCache() {
|
|
|
9
9
|
function generateCacheKey(options) {
|
|
10
10
|
// Recursive stable stringify to handle nested object key ordering
|
|
11
11
|
const sortObject = (obj) => {
|
|
12
|
-
if (obj === null || typeof obj !== 'object'
|
|
12
|
+
if (obj === null || typeof obj !== 'object') {
|
|
13
13
|
return obj;
|
|
14
14
|
}
|
|
15
|
+
if (Array.isArray(obj)) {
|
|
16
|
+
return obj.map(sortObject);
|
|
17
|
+
}
|
|
15
18
|
const sorted = {};
|
|
16
19
|
Object.keys(obj).sort().forEach(key => {
|
|
17
20
|
sorted[key] = sortObject(obj[key]);
|
|
@@ -121,7 +124,7 @@ export async function queryAnalytics(options) {
|
|
|
121
124
|
analyticsCache.set(cacheKey, { type: 'pending', promise: fetchPromise });
|
|
122
125
|
return fetchPromise;
|
|
123
126
|
}
|
|
124
|
-
export async function getPagePerformance(propertyId, startDate, endDate, pagePath, limit = 50, accountId) {
|
|
127
|
+
export async function getPagePerformance(propertyId, startDate, endDate, pagePath, limit = 50, accountId, offset) {
|
|
125
128
|
const dimensionFilter = pagePath ? {
|
|
126
129
|
filter: {
|
|
127
130
|
fieldName: 'pagePath',
|
|
@@ -148,11 +151,12 @@ export async function getPagePerformance(propertyId, startDate, endDate, pagePat
|
|
|
148
151
|
],
|
|
149
152
|
dimensionFilter,
|
|
150
153
|
limit,
|
|
154
|
+
offset,
|
|
151
155
|
orderBys: [{ metric: { metricName: 'sessions' }, desc: true }]
|
|
152
156
|
});
|
|
153
157
|
return formatRows(response);
|
|
154
158
|
}
|
|
155
|
-
export async function getTrafficSources(propertyId, startDate, endDate, channelGroup, limit = 50, accountId) {
|
|
159
|
+
export async function getTrafficSources(propertyId, startDate, endDate, channelGroup, limit = 50, accountId, offset) {
|
|
156
160
|
const dimensionFilter = channelGroup ? {
|
|
157
161
|
filter: {
|
|
158
162
|
fieldName: 'sessionDefaultChannelGroup',
|
|
@@ -172,11 +176,12 @@ export async function getTrafficSources(propertyId, startDate, endDate, channelG
|
|
|
172
176
|
metrics: ['sessions'],
|
|
173
177
|
dimensionFilter,
|
|
174
178
|
limit,
|
|
179
|
+
offset,
|
|
175
180
|
orderBys: [{ metric: { metricName: 'sessions' }, desc: true }]
|
|
176
181
|
});
|
|
177
182
|
return formatRows(response);
|
|
178
183
|
}
|
|
179
|
-
export async function getOrganicLandingPages(propertyId, startDate, endDate, limit = 50, accountId) {
|
|
184
|
+
export async function getOrganicLandingPages(propertyId, startDate, endDate, limit = 50, accountId, offset) {
|
|
180
185
|
const response = await queryAnalytics({
|
|
181
186
|
propertyId,
|
|
182
187
|
accountId,
|
|
@@ -201,11 +206,12 @@ export async function getOrganicLandingPages(propertyId, startDate, endDate, lim
|
|
|
201
206
|
}
|
|
202
207
|
},
|
|
203
208
|
limit,
|
|
209
|
+
offset,
|
|
204
210
|
orderBys: [{ metric: { metricName: 'sessions' }, desc: true }]
|
|
205
211
|
});
|
|
206
212
|
return formatRows(response);
|
|
207
213
|
}
|
|
208
|
-
export async function getContentPerformance(propertyId, startDate, endDate, limit = 50, accountId) {
|
|
214
|
+
export async function getContentPerformance(propertyId, startDate, endDate, limit = 50, accountId, offset) {
|
|
209
215
|
// Try to query content group first?
|
|
210
216
|
// Not sure if contentGroup is standard. 'contentGroup' is a standard dimension.
|
|
211
217
|
// If not used, it might return (not set).
|
|
@@ -217,6 +223,7 @@ export async function getContentPerformance(propertyId, startDate, endDate, limi
|
|
|
217
223
|
dimensions: ['contentGroup'], // Fallback to pagePath if needed? No, separate tool or option.
|
|
218
224
|
metrics: ['sessions', 'averageSessionDuration', 'conversions'],
|
|
219
225
|
limit,
|
|
226
|
+
offset,
|
|
220
227
|
orderBys: [{ metric: { metricName: 'sessions' }, desc: true }]
|
|
221
228
|
});
|
|
222
229
|
const rows = formatRows(response);
|
|
@@ -228,7 +235,7 @@ export async function getContentPerformance(propertyId, startDate, endDate, limi
|
|
|
228
235
|
}
|
|
229
236
|
return rows;
|
|
230
237
|
}
|
|
231
|
-
export async function getEcommerce(propertyId, startDate, endDate, limit = 50, accountId) {
|
|
238
|
+
export async function getEcommerce(propertyId, startDate, endDate, limit = 50, accountId, offset) {
|
|
232
239
|
const response = await queryAnalytics({
|
|
233
240
|
propertyId,
|
|
234
241
|
accountId,
|
|
@@ -237,6 +244,7 @@ export async function getEcommerce(propertyId, startDate, endDate, limit = 50, a
|
|
|
237
244
|
dimensions: ['itemName'],
|
|
238
245
|
metrics: ['itemRevenue', 'itemsPurchased', 'ecommercePurchases'],
|
|
239
246
|
limit,
|
|
247
|
+
offset,
|
|
240
248
|
orderBys: [{ metric: { metricName: 'itemRevenue' }, desc: true }]
|
|
241
249
|
});
|
|
242
250
|
// Check if we have data. If total revenue is 0 across all rows, maybe return warning.
|
package/dist/google/client.js
CHANGED
|
@@ -2,6 +2,7 @@ import { google } from 'googleapis';
|
|
|
2
2
|
import nodeMachineId from 'node-machine-id';
|
|
3
3
|
import { loadConfig, updateAccount, removeAccount } from '../common/auth/config.js';
|
|
4
4
|
import { resolveAccount } from '../common/auth/resolver.js';
|
|
5
|
+
import { logger } from '../utils/logger.js';
|
|
5
6
|
const { machineIdSync } = nodeMachineId;
|
|
6
7
|
const SCOPES = [
|
|
7
8
|
'https://www.googleapis.com/auth/webmasters.readonly',
|
|
@@ -32,8 +33,10 @@ export async function getSearchConsoleClient(siteUrl, accountId) {
|
|
|
32
33
|
}
|
|
33
34
|
const cacheKey = account.id;
|
|
34
35
|
if (cachedClientMap[cacheKey]) {
|
|
36
|
+
logger.debug(`Using cached client for account: ${account.alias} (${account.id})`);
|
|
35
37
|
return cachedClientMap[cacheKey];
|
|
36
38
|
}
|
|
39
|
+
logger.debug(`Initializing Search Console client for ${account.alias} (ID: ${account.id}, Engine: ${account.engine})`);
|
|
37
40
|
// 2. Load Tokens
|
|
38
41
|
const tokens = await loadTokensForAccount(account);
|
|
39
42
|
if (tokens) {
|
|
@@ -42,16 +45,18 @@ export async function getSearchConsoleClient(siteUrl, accountId) {
|
|
|
42
45
|
oauth2Client.setCredentials(tokens);
|
|
43
46
|
// Check for expiry (refresh if needed)
|
|
44
47
|
if (tokens.expiry_date && tokens.expiry_date <= Date.now()) {
|
|
48
|
+
logger.debug(`Tokens expired for ${account.alias}, refreshing...`);
|
|
45
49
|
const { credentials } = await oauth2Client.refreshAccessToken();
|
|
46
50
|
await saveTokensForAccount(account, credentials);
|
|
47
51
|
oauth2Client.setCredentials(credentials);
|
|
48
52
|
}
|
|
49
53
|
const client = google.searchconsole({ version: 'v1', auth: oauth2Client });
|
|
54
|
+
logger.debug(`Client successfully initialized with OAuth2 for ${account.alias}`);
|
|
50
55
|
cachedClientMap[cacheKey] = client;
|
|
51
56
|
return client;
|
|
52
57
|
}
|
|
53
58
|
catch (error) {
|
|
54
|
-
|
|
59
|
+
logger.error(`Failed to use tokens for account ${account.alias}:`, error.message);
|
|
55
60
|
}
|
|
56
61
|
}
|
|
57
62
|
// 3. Support Service Account Path (Multi-Account)
|
|
@@ -62,6 +67,7 @@ export async function getSearchConsoleClient(siteUrl, accountId) {
|
|
|
62
67
|
});
|
|
63
68
|
const client = google.searchconsole({ version: 'v1', auth });
|
|
64
69
|
cachedClientMap[cacheKey] = client;
|
|
70
|
+
logger.debug(`Client initialized with Service Account Path for ${account.alias}`);
|
|
65
71
|
return client;
|
|
66
72
|
}
|
|
67
73
|
// 4. Fallback to Service Account (Environment Variables) - Only if no specific account was resolved or it was a legacy fallback
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { getSearchConsoleClient } from '../client.js';
|
|
2
|
+
import { logger } from '../../utils/logger.js';
|
|
2
3
|
const CACHE_TTL_MS = 60 * 1000; // 60 seconds
|
|
3
4
|
const MAX_CACHE_SIZE = 100;
|
|
4
5
|
const analyticsCache = new Map();
|
|
@@ -32,9 +33,11 @@ export async function queryAnalytics(options) {
|
|
|
32
33
|
const cached = analyticsCache.get(cacheKey);
|
|
33
34
|
if (cached) {
|
|
34
35
|
if ('then' in cached) {
|
|
36
|
+
logger.debug(`Cache hit (pending) for ${options.siteUrl}`);
|
|
35
37
|
return cached;
|
|
36
38
|
}
|
|
37
39
|
if (now - cached.timestamp < CACHE_TTL_MS) {
|
|
40
|
+
logger.debug(`Cache hit (fresh) for ${options.siteUrl}`);
|
|
38
41
|
// LRU: Refresh key position
|
|
39
42
|
analyticsCache.delete(cacheKey);
|
|
40
43
|
analyticsCache.set(cacheKey, cached);
|
|
@@ -48,7 +51,7 @@ export async function queryAnalytics(options) {
|
|
|
48
51
|
const requestBody = {
|
|
49
52
|
startDate: options.startDate,
|
|
50
53
|
endDate: options.endDate,
|
|
51
|
-
dimensions: options.dimensions
|
|
54
|
+
dimensions: (options.dimensions && options.dimensions.length > 0) ? options.dimensions : undefined,
|
|
52
55
|
type: options.type || 'web',
|
|
53
56
|
aggregationType: options.aggregationType || 'auto',
|
|
54
57
|
dataState: options.dataState || 'final',
|
|
@@ -70,11 +73,18 @@ export async function queryAnalytics(options) {
|
|
|
70
73
|
}]
|
|
71
74
|
}));
|
|
72
75
|
}
|
|
76
|
+
logger.debug(`Fetching analytics for ${options.siteUrl}`, {
|
|
77
|
+
startDate: options.startDate,
|
|
78
|
+
endDate: options.endDate,
|
|
79
|
+
dimensions: options.dimensions,
|
|
80
|
+
filters: options.filters?.length || 0
|
|
81
|
+
});
|
|
73
82
|
const res = await client.searchanalytics.query({
|
|
74
83
|
siteUrl: options.siteUrl,
|
|
75
84
|
requestBody
|
|
76
85
|
});
|
|
77
86
|
const rows = res.data.rows || [];
|
|
87
|
+
logger.debug(`Received ${rows.length} rows for ${options.siteUrl}`);
|
|
78
88
|
analyticsCache.set(cacheKey, {
|
|
79
89
|
data: rows,
|
|
80
90
|
timestamp: Date.now()
|
|
@@ -88,6 +98,7 @@ export async function queryAnalytics(options) {
|
|
|
88
98
|
return rows;
|
|
89
99
|
}
|
|
90
100
|
catch (error) {
|
|
101
|
+
logger.error(`Error fetching analytics for ${options.siteUrl}:`, error.message);
|
|
91
102
|
analyticsCache.delete(cacheKey);
|
|
92
103
|
throw error;
|
|
93
104
|
}
|
|
@@ -104,7 +115,7 @@ export async function queryAnalytics(options) {
|
|
|
104
115
|
* @returns Combined metrics for the requested period.
|
|
105
116
|
*/
|
|
106
117
|
export async function getPerformanceSummary(siteUrl, days = 28) {
|
|
107
|
-
const DATA_DELAY_DAYS =
|
|
118
|
+
const DATA_DELAY_DAYS = 1; // Use 1 day delay with 'all' dataState (fresh data)
|
|
108
119
|
const endDate = new Date();
|
|
109
120
|
endDate.setDate(endDate.getDate() - DATA_DELAY_DAYS);
|
|
110
121
|
const startDate = new Date(endDate);
|
|
@@ -115,6 +126,7 @@ export async function getPerformanceSummary(siteUrl, days = 28) {
|
|
|
115
126
|
siteUrl,
|
|
116
127
|
startDate: startDateStr,
|
|
117
128
|
endDate: endDateStr,
|
|
129
|
+
dataState: 'all',
|
|
118
130
|
limit: 1
|
|
119
131
|
});
|
|
120
132
|
if (rows.length > 0) {
|
package/dist/index.js
CHANGED
|
@@ -48,6 +48,8 @@ import { dirname } from 'path';
|
|
|
48
48
|
import { getStartedHandler, getStartedToolName, getStartedToolDescription, getStartedToolSchema } from "./common/tools/get-started.js";
|
|
49
49
|
import { registerPrompts } from "./prompts/index.js";
|
|
50
50
|
import { jsonToCsv } from "./common/utils/csv.js";
|
|
51
|
+
import { runDiagnostics } from "./common/diagnostics.js";
|
|
52
|
+
import { logger } from "./utils/logger.js";
|
|
51
53
|
const __filename = fileURLToPath(import.meta.url);
|
|
52
54
|
const __dirname = dirname(__filename);
|
|
53
55
|
// Load version from package.json
|
|
@@ -91,6 +93,7 @@ server.tool("sites_list", "List all verified sites or properties across all auth
|
|
|
91
93
|
else {
|
|
92
94
|
results = [];
|
|
93
95
|
}
|
|
96
|
+
const rawCount = results.length;
|
|
94
97
|
// Boundary Filtering: Honor the website in the config
|
|
95
98
|
if (account.websites && account.websites.length > 0) {
|
|
96
99
|
results = results.filter(site => {
|
|
@@ -103,12 +106,20 @@ server.tool("sites_list", "List all verified sites or properties across all auth
|
|
|
103
106
|
}
|
|
104
107
|
try {
|
|
105
108
|
const normalizedSite = normalizeWebsite(url).value;
|
|
106
|
-
|
|
109
|
+
const isMatch = account.websites.some(w => normalizeWebsite(w).value === normalizedSite);
|
|
110
|
+
if (!isMatch) {
|
|
111
|
+
logger.debug(`Filtered out site ${url} for account ${account.alias} (not in whitelist)`);
|
|
112
|
+
}
|
|
113
|
+
return isMatch;
|
|
107
114
|
}
|
|
108
115
|
catch {
|
|
109
116
|
return account.websites.includes(url);
|
|
110
117
|
}
|
|
111
118
|
});
|
|
119
|
+
logger.debug(`Account ${account.alias}: ${results.length}/${rawCount} sites kept after filtering.`);
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
logger.debug(`Account ${account.alias}: Found ${results.length} sites (no filtering).`);
|
|
112
123
|
}
|
|
113
124
|
return {
|
|
114
125
|
account: account.alias,
|
|
@@ -117,6 +128,7 @@ server.tool("sites_list", "List all verified sites or properties across all auth
|
|
|
117
128
|
};
|
|
118
129
|
}
|
|
119
130
|
catch (e) {
|
|
131
|
+
logger.error(`Failed to list sites for account ${account.alias}:`, e.message);
|
|
120
132
|
return {
|
|
121
133
|
account: account.alias,
|
|
122
134
|
accountId: account.id,
|
|
@@ -1358,10 +1370,11 @@ server.tool("analytics_page_performance", "Get detailed page performance metrics
|
|
|
1358
1370
|
endDate: z.string().describe("End date (YYYY-MM-DD)"),
|
|
1359
1371
|
pagePath: z.string().optional().describe("Filter by specific page path"),
|
|
1360
1372
|
limit: z.number().optional().describe("Max rows (default 50)"),
|
|
1373
|
+
offset: z.number().optional().describe("Starting row for pagination (0-based)"),
|
|
1361
1374
|
format: z.enum(["json", "csv"]).optional().describe("Output format (default: json)")
|
|
1362
|
-
}, async ({ propertyId, accountId, startDate, endDate, pagePath, limit, format }) => {
|
|
1375
|
+
}, async ({ propertyId, accountId, startDate, endDate, pagePath, limit, offset, format }) => {
|
|
1363
1376
|
try {
|
|
1364
|
-
const result = await ga4Analytics.getPagePerformance(propertyId, startDate, endDate, pagePath, limit, accountId);
|
|
1377
|
+
const result = await ga4Analytics.getPagePerformance(propertyId, startDate, endDate, pagePath, limit, accountId, offset);
|
|
1365
1378
|
if (format === 'csv') {
|
|
1366
1379
|
return {
|
|
1367
1380
|
content: [{ type: "text", text: jsonToCsv(result) }]
|
|
@@ -1381,10 +1394,11 @@ server.tool("analytics_traffic_sources", "Analyze traffic sources (Channel, Sour
|
|
|
1381
1394
|
startDate: z.string().describe("Start date (YYYY-MM-DD)"),
|
|
1382
1395
|
endDate: z.string().describe("End date (YYYY-MM-DD)"),
|
|
1383
1396
|
channelGroup: z.string().optional().describe("Filter by Channel Group (e.g. 'Organic Search')"),
|
|
1384
|
-
limit: z.number().optional().describe("Max rows (default 50)")
|
|
1385
|
-
|
|
1397
|
+
limit: z.number().optional().describe("Max rows (default 50)"),
|
|
1398
|
+
offset: z.number().optional().describe("Starting row for pagination (0-based)")
|
|
1399
|
+
}, async ({ propertyId, accountId, startDate, endDate, channelGroup, limit, offset }) => {
|
|
1386
1400
|
try {
|
|
1387
|
-
const result = await ga4Analytics.getTrafficSources(propertyId, startDate, endDate, channelGroup, limit, accountId);
|
|
1401
|
+
const result = await ga4Analytics.getTrafficSources(propertyId, startDate, endDate, channelGroup, limit, accountId, offset);
|
|
1388
1402
|
return {
|
|
1389
1403
|
content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
|
|
1390
1404
|
};
|
|
@@ -1398,10 +1412,11 @@ server.tool("analytics_organic_landing_pages", "Get performance of organic landi
|
|
|
1398
1412
|
accountId: z.string().optional().describe("GA4 account ID for multi-account setups"),
|
|
1399
1413
|
startDate: z.string().describe("Start date (YYYY-MM-DD)"),
|
|
1400
1414
|
endDate: z.string().describe("End date (YYYY-MM-DD)"),
|
|
1401
|
-
limit: z.number().optional().describe("Max rows (default 50)")
|
|
1402
|
-
|
|
1415
|
+
limit: z.number().optional().describe("Max rows (default 50)"),
|
|
1416
|
+
offset: z.number().optional().describe("Starting row for pagination (0-based)")
|
|
1417
|
+
}, async ({ propertyId, accountId, startDate, endDate, limit, offset }) => {
|
|
1403
1418
|
try {
|
|
1404
|
-
const result = await ga4Analytics.getOrganicLandingPages(propertyId, startDate, endDate, limit, accountId);
|
|
1419
|
+
const result = await ga4Analytics.getOrganicLandingPages(propertyId, startDate, endDate, limit, accountId, offset);
|
|
1405
1420
|
return {
|
|
1406
1421
|
content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
|
|
1407
1422
|
};
|
|
@@ -1415,10 +1430,11 @@ server.tool("analytics_content_performance", "Analyze content performance by Con
|
|
|
1415
1430
|
accountId: z.string().optional().describe("GA4 account ID for multi-account setups"),
|
|
1416
1431
|
startDate: z.string().describe("Start date (YYYY-MM-DD)"),
|
|
1417
1432
|
endDate: z.string().describe("End date (YYYY-MM-DD)"),
|
|
1418
|
-
limit: z.number().optional().describe("Max rows (default 50)")
|
|
1419
|
-
|
|
1433
|
+
limit: z.number().optional().describe("Max rows (default 50)"),
|
|
1434
|
+
offset: z.number().optional().describe("Starting row for pagination (0-based)")
|
|
1435
|
+
}, async ({ propertyId, accountId, startDate, endDate, limit, offset }) => {
|
|
1420
1436
|
try {
|
|
1421
|
-
const result = await ga4Analytics.getContentPerformance(propertyId, startDate, endDate, limit, accountId);
|
|
1437
|
+
const result = await ga4Analytics.getContentPerformance(propertyId, startDate, endDate, limit, accountId, offset);
|
|
1422
1438
|
return {
|
|
1423
1439
|
content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
|
|
1424
1440
|
};
|
|
@@ -1432,10 +1448,11 @@ server.tool("analytics_ecommerce", "Get ecommerce performance (products, revenue
|
|
|
1432
1448
|
accountId: z.string().optional().describe("GA4 account ID for multi-account setups"),
|
|
1433
1449
|
startDate: z.string().describe("Start date (YYYY-MM-DD)"),
|
|
1434
1450
|
endDate: z.string().describe("End date (YYYY-MM-DD)"),
|
|
1435
|
-
limit: z.number().optional().describe("Max rows (default 50)")
|
|
1436
|
-
|
|
1451
|
+
limit: z.number().optional().describe("Max rows (default 50)"),
|
|
1452
|
+
offset: z.number().optional().describe("Starting row for pagination (0-based)")
|
|
1453
|
+
}, async ({ propertyId, accountId, startDate, endDate, limit, offset }) => {
|
|
1437
1454
|
try {
|
|
1438
|
-
const result = await ga4Analytics.getEcommerce(propertyId, startDate, endDate, limit, accountId);
|
|
1455
|
+
const result = await ga4Analytics.getEcommerce(propertyId, startDate, endDate, limit, accountId, offset);
|
|
1439
1456
|
return {
|
|
1440
1457
|
content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
|
|
1441
1458
|
};
|
|
@@ -1976,8 +1993,19 @@ If any site has a 'critical' or 'warning' status:
|
|
|
1976
1993
|
}]
|
|
1977
1994
|
};
|
|
1978
1995
|
});
|
|
1979
|
-
// Register additional prompts
|
|
1980
1996
|
registerPrompts(server);
|
|
1997
|
+
// Diagnostics Tool
|
|
1998
|
+
server.tool("diagnostics", "Run connectivity diagnostics for all connected accounts. Use this to troubleshoot '0 results' or authentication issues.", {}, async () => {
|
|
1999
|
+
try {
|
|
2000
|
+
const results = await runDiagnostics();
|
|
2001
|
+
return {
|
|
2002
|
+
content: [{ type: "text", text: JSON.stringify(results, null, 2) }]
|
|
2003
|
+
};
|
|
2004
|
+
}
|
|
2005
|
+
catch (error) {
|
|
2006
|
+
return formatError(error);
|
|
2007
|
+
}
|
|
2008
|
+
});
|
|
1981
2009
|
async function main() {
|
|
1982
2010
|
const command = process.argv[2];
|
|
1983
2011
|
// Handle standalone commands
|
|
@@ -2001,6 +2029,16 @@ async function main() {
|
|
|
2001
2029
|
await login();
|
|
2002
2030
|
return;
|
|
2003
2031
|
}
|
|
2032
|
+
if (command === 'diagnostics') {
|
|
2033
|
+
const results = await runDiagnostics();
|
|
2034
|
+
console.log(JSON.stringify(results, null, 2));
|
|
2035
|
+
return;
|
|
2036
|
+
}
|
|
2037
|
+
if (command === 'sites') {
|
|
2038
|
+
const { main: accountsMain } = await import('./accounts.js');
|
|
2039
|
+
await accountsMain(['list']);
|
|
2040
|
+
return;
|
|
2041
|
+
}
|
|
2004
2042
|
// Check for credentials
|
|
2005
2043
|
const config = await loadConfig();
|
|
2006
2044
|
const accounts = Object.values(config.accounts);
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Logging utility for Search Console MCP.
|
|
3
|
+
* Since this is a stdio-based MCP server, we must log to stderr
|
|
4
|
+
* to avoid interfering with the MCP protocol on stdout.
|
|
5
|
+
*/
|
|
6
|
+
import { colors } from './ui.js';
|
|
7
|
+
const isDebugEnabled = process.env.DEBUG === 'true' || process.env.NODE_ENV === 'development';
|
|
8
|
+
export const logger = {
|
|
9
|
+
/**
|
|
10
|
+
* Log a debug message. Only visible if DEBUG=true or NODE_ENV=development.
|
|
11
|
+
*/
|
|
12
|
+
debug(message, ...args) {
|
|
13
|
+
if (isDebugEnabled) {
|
|
14
|
+
const timestamp = new Date().toISOString();
|
|
15
|
+
console.error(`${colors.dim}[${timestamp}]${colors.reset} ${colors.blue}[DEBUG]${colors.reset} ${message}`, ...args);
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
/**
|
|
19
|
+
* Log an error message. Always visible.
|
|
20
|
+
*/
|
|
21
|
+
error(message, error) {
|
|
22
|
+
const timestamp = new Date().toISOString();
|
|
23
|
+
console.error(`${colors.dim}[${timestamp}]${colors.reset} ${colors.red}[ERROR]${colors.reset} ${message}`, error || '');
|
|
24
|
+
},
|
|
25
|
+
/**
|
|
26
|
+
* Log an info message. Always visible.
|
|
27
|
+
*/
|
|
28
|
+
info(message, ...args) {
|
|
29
|
+
const timestamp = new Date().toISOString();
|
|
30
|
+
console.error(`${colors.dim}[${timestamp}]${colors.reset} ${colors.green}[INFO]${colors.reset} ${message}`, ...args);
|
|
31
|
+
},
|
|
32
|
+
/**
|
|
33
|
+
* Log a warning message. Always visible.
|
|
34
|
+
*/
|
|
35
|
+
warn(message, ...args) {
|
|
36
|
+
const timestamp = new Date().toISOString();
|
|
37
|
+
console.error(`${colors.dim}[${timestamp}]${colors.reset} ${colors.yellow}[WARN]${colors.reset} ${message}`, ...args);
|
|
38
|
+
}
|
|
39
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "search-console-mcp",
|
|
3
|
-
"version": "1.13.
|
|
3
|
+
"version": "1.13.4",
|
|
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",
|
|
@@ -52,4 +52,4 @@
|
|
|
52
52
|
"typescript": "^5.9.3",
|
|
53
53
|
"vitest": "^4.0.18"
|
|
54
54
|
}
|
|
55
|
-
}
|
|
55
|
+
}
|