search-console-mcp 1.12.0 → 1.13.1
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 +54 -18
- package/dist/accounts.js +8 -2
- package/dist/bing/tools/inspection.js +19 -0
- package/dist/bing/tools/seo-insights.js +105 -15
- package/dist/common/auth/config.js +9 -0
- package/dist/common/concurrency.js +22 -0
- package/dist/common/platforms.js +19 -0
- package/dist/common/tools/compare-engines/ga4-adapters.js +12 -0
- package/dist/common/tools/compare-engines/ga4-gsc-bing-comparator.js +207 -0
- package/dist/common/tools/compare-engines/ga4-gsc-comparator.js +110 -0
- package/dist/common/tools/compare-engines/utils.js +33 -0
- package/dist/common/tools/get-started.js +348 -0
- package/dist/common/tools/schema-validator.js +10 -1
- package/dist/common/utils/csv.js +27 -0
- package/dist/ga4/client.js +151 -0
- package/dist/ga4/tools/analytics.js +245 -0
- package/dist/ga4/tools/behavior.js +113 -0
- package/dist/ga4/tools/pagespeed.js +38 -0
- package/dist/ga4/tools/realtime.js +17 -0
- package/dist/ga4/utils.js +19 -0
- package/dist/google/client.js +4 -4
- package/dist/google/tools/analytics.js +1 -1
- package/dist/google/tools/inspection.js +20 -0
- package/dist/google/tools/seo-insights.js +99 -45
- package/dist/google/tools/sites-health.js +5 -25
- package/dist/index.js +406 -54
- package/dist/prompts/index.js +315 -0
- package/dist/setup.js +231 -27
- package/package.json +6 -3
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
|
|
2
2
|
# Search Console MCP
|
|
3
3
|
|
|
4
|
-
A Model Context Protocol (MCP) server that transforms how you interact with **Google Search Console** and **
|
|
4
|
+
A Model Context Protocol (MCP) server that transforms how you interact with **Google Search Console**, **Bing Webmaster Tools**, and **Google Analytics 4**. Stop exporting CSVs and start asking questions.
|
|
5
5
|
|
|
6
6
|
[📚 View Documentation](https://searchconsolemcp.mintlify.app/)
|
|
7
7
|
|
|
@@ -12,23 +12,19 @@ A Model Context Protocol (MCP) server that transforms how you interact with **Go
|
|
|
12
12
|
|
|
13
13
|
## Why use this?
|
|
14
14
|
|
|
15
|
-
### ❌ The Old Way
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
5. Switch between accounts for different clients
|
|
21
|
-
6. Repeat weekly
|
|
15
|
+
### ❌ The Old, Broken Way
|
|
16
|
+
* **Data Silos**: Manually checking Google, then Bing, then GA4.
|
|
17
|
+
* **Manual Correlation**: Exporting 3 different CSVs and using VLOOKUPs to see if your #1 ranking page actually converts.
|
|
18
|
+
* **Switching Fatigue**: Logging in and out to manage multiple clients or properties.
|
|
19
|
+
* **AI Context Limits**: Uploading giant spreadsheets that hit context limits and cause model hallucinations.
|
|
22
20
|
|
|
23
|
-
### ✅ The
|
|
24
|
-
**
|
|
25
|
-
|
|
21
|
+
### ✅ The Search Console MCP Way
|
|
22
|
+
* **Platform Synergy**: **GSC + Bing + GA4** in a single context window. Stop exporting, start analyzing.
|
|
23
|
+
* **Deterministic Intelligence**: The server performs the complex SEO math (cannibalization, Z-score anomalies, striking distance) so your AI agent gets curated insights, not raw data piles.
|
|
24
|
+
* **Cross-Platform ROI**: Use the **Opportunity Matrix** to prioritize keywords that have high search visibility (GSC) but poor on-site engagement (GA4).
|
|
25
|
+
* **Zero-Config Multi-Account**: Connect 20+ accounts. The server automatically resolves the correct credentials for every site URL.
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
> "Run a health check across all my sites and give me 3 actions for next week."
|
|
30
|
-
|
|
31
|
-
**One server. Both engines. Multiple accounts. Zero spreadsheets.**
|
|
27
|
+
**One Server. Three Platforms. Infinite Accounts. Actionable Intelligence.**
|
|
32
28
|
|
|
33
29
|
---
|
|
34
30
|
## 🎯 Magic Prompts
|
|
@@ -62,6 +58,9 @@ Copy and paste these into your MCP client (Claude Desktop, etc.) to see the inte
|
|
|
62
58
|
#### ⚠️ Google Dependency Check
|
|
63
59
|
> "Am I too dependent on Google? Check my click share across both engines and flag any keywords where over 85% of traffic comes from Google."
|
|
64
60
|
|
|
61
|
+
#### 💰 The ROI Prioritizer (GSC + GA4)
|
|
62
|
+
> "Run an `opportunity_matrix` for my top 20 pages. Which high-visibility pages have the lowest engagement or conversion rates? These are my conversion optimization priorities."
|
|
63
|
+
|
|
65
64
|
---
|
|
66
65
|
|
|
67
66
|
## 🔐 Authentication (Desktop Flow)
|
|
@@ -124,6 +123,18 @@ To access Bing data, you simply need an API Key.
|
|
|
124
123
|
|
|
125
124
|
---
|
|
126
125
|
|
|
126
|
+
## 📊 Google Analytics 4 (GA4)
|
|
127
|
+
|
|
128
|
+
Connect your GA4 properties to correlate ranking data with user behavior.
|
|
129
|
+
|
|
130
|
+
### Setup:
|
|
131
|
+
1. **Run Setup**: `npx search-console-mcp setup --engine=ga4`
|
|
132
|
+
2. **Auth Method**: Choose **Service Account** (JSON Key). This is the recommended method for server-side integrations.
|
|
133
|
+
* **Service Account**: You must add the service account email as a user in **GA4 Admin > Property Settings > Property Access Management**.
|
|
134
|
+
3. **Property Selection**: The tool will automatically fetch all available GA4 properties for your account and let you select one from a list. You can also enter a Property ID manually if needed.
|
|
135
|
+
|
|
136
|
+
---
|
|
137
|
+
|
|
127
138
|
## 👥 Multi-Account Management
|
|
128
139
|
|
|
129
140
|
Manage multiple Google and Bing accounts from the CLI:
|
|
@@ -168,7 +179,7 @@ This MCP server implements a multi-layered security architecture:
|
|
|
168
179
|
| `analytics_drop_attribution` | **[NEW]** Attribute traffic drops to mobile/desktop or correlate with known Google Algorithm Updates. |
|
|
169
180
|
| `analytics_time_series` | **[NEW]** Advanced time series with rolling averages, seasonality detection, and forecasting. |
|
|
170
181
|
| `analytics_compare_periods` | Compare two date ranges (e.g., WoW, MoM). |
|
|
171
|
-
| `seo_brand_vs_nonbrand` | **[NEW]** Analyze performance split between Brand vs Non-Brand traffic. |
|
|
182
|
+
| `seo_brand_vs_nonbrand` | **[NEW]** Analyze performance split between Brand vs Non-Brand traffic. (Supports Google & Bing). |
|
|
172
183
|
|
|
173
184
|
### SEO Opportunities (Opinionated)
|
|
174
185
|
| Tool | Description |
|
|
@@ -177,7 +188,7 @@ This MCP server implements a multi-layered security architecture:
|
|
|
177
188
|
| `seo_striking_distance` | **[NEW]** Find keywords ranking 8-15 (Quickest ROI wins). |
|
|
178
189
|
| `seo_low_ctr_opportunities` | **[NEW]** Find top ranking queries (pos 1-10) with poor CTR. |
|
|
179
190
|
| `seo_cannibalization` | **[Enhanced]** Detect pages competing for the same query with traffic conflict. |
|
|
180
|
-
| `seo_lost_queries` | **[NEW]** Identify queries that lost all traffic in the last 28 days. |
|
|
191
|
+
| `seo_lost_queries` | **[NEW]** Identify queries that lost all traffic in the last 28 days. (Supports Google & Bing). |
|
|
181
192
|
|
|
182
193
|
### SEO Primitives (Atoms for Agents)
|
|
183
194
|
These are low-level tools designed to be used by other AI agents to build complex logic.
|
|
@@ -215,8 +226,33 @@ These are low-level tools designed to be used by other AI agents to build comple
|
|
|
215
226
|
| `bing_crawl_issues` | List crawl issues detected by Bing. |
|
|
216
227
|
| `bing_analytics_detect_anomalies` | Detect daily spikes or drops in Bing traffic. |
|
|
217
228
|
| `bing_analytics_time_series` | Advanced time series analysis for Bing. |
|
|
229
|
+
| `bing_seo_lost_queries` | Identify queries that lost significant traffic on Bing. |
|
|
230
|
+
| `bing_brand_analysis` | Analyze performance split between Brand vs Non-Brand traffic on Bing. |
|
|
218
231
|
| `bing_sitemaps_list` / `bing_sitemaps_submit` | Manage sitemaps in Bing. |
|
|
219
232
|
|
|
233
|
+
### Google Analytics 4 (GA4)
|
|
234
|
+
| Tool | Description |
|
|
235
|
+
|------|-------------|
|
|
236
|
+
| `analytics_page_performance` | Detailed page metrics (sessions, engagement, views). |
|
|
237
|
+
| `analytics_traffic_sources` | Analyze sessions by Channel, Source, and Medium. |
|
|
238
|
+
| `analytics_organic_landing_pages` | Focused metrics for organic traffic landing pages. |
|
|
239
|
+
| `analytics_content_performance` | Analyze content performance by Content Group in GA4. |
|
|
240
|
+
| `analytics_conversion_funnel` | Top converting pages and events. |
|
|
241
|
+
| `analytics_user_behavior` | Device, Country, and Engagement breakdown. |
|
|
242
|
+
| `analytics_audience_segments` | New vs Returning, Age, and OS analysis. |
|
|
243
|
+
| `analytics_realtime` | Live active user data by page and location. |
|
|
244
|
+
| `analytics_ecommerce` | Product and revenue performance. |
|
|
245
|
+
| `analytics_pagespeed_correlation` | Correlate GA4 metrics with PageSpeed scores. |
|
|
246
|
+
|
|
247
|
+
### Cross-Platform Intelligence
|
|
248
|
+
| Tool | Description |
|
|
249
|
+
|------|-------------|
|
|
250
|
+
| `opportunity_matrix` | **[Flagship]** Prioritize SEO tasks by combining signals from GSC, GA4, and Bing. |
|
|
251
|
+
| `page_analysis` | Joint analysis of ranking (GSC) vs behavior (GA4) for pages. |
|
|
252
|
+
| `traffic_health_check` | Diagnose tracking gaps by comparing GSC clicks to GA4 organic sessions. |
|
|
253
|
+
| `brand_analysis` | Brand vs Non-Brand split across GSC, Bing, and GA4. |
|
|
254
|
+
| `compare_engines` | Compare keyword performance between Google and Bing. |
|
|
255
|
+
|
|
220
256
|
|
|
221
257
|
|
|
222
258
|
## License
|
package/dist/accounts.js
CHANGED
|
@@ -32,7 +32,8 @@ export async function main(args) {
|
|
|
32
32
|
resolution: {
|
|
33
33
|
command: "search-console-mcp setup",
|
|
34
34
|
google: "search-console-mcp setup --engine=google",
|
|
35
|
-
bing: "search-console-mcp setup --engine=bing"
|
|
35
|
+
bing: "search-console-mcp setup --engine=bing",
|
|
36
|
+
ga4: "search-console-mcp setup --engine=ga4"
|
|
36
37
|
},
|
|
37
38
|
setup_instructions: {
|
|
38
39
|
google: [
|
|
@@ -44,6 +45,11 @@ export async function main(args) {
|
|
|
44
45
|
"Run: search-console-mcp setup --engine=bing",
|
|
45
46
|
"Get your API key from https://www.bing.com/webmasters/settings/api",
|
|
46
47
|
"Enter the API key when prompted"
|
|
48
|
+
],
|
|
49
|
+
ga4: [
|
|
50
|
+
"Run: search-console-mcp setup --engine=ga4",
|
|
51
|
+
"Choose Service Account (OAuth coming soon)",
|
|
52
|
+
"Add the service account email to your GA4 property"
|
|
47
53
|
]
|
|
48
54
|
}
|
|
49
55
|
}, null, 2));
|
|
@@ -52,7 +58,7 @@ export async function main(args) {
|
|
|
52
58
|
const siteRows = [];
|
|
53
59
|
for (const a of accounts) {
|
|
54
60
|
const alias = a.alias || '[Unnamed]';
|
|
55
|
-
const engine = a.engine === 'google' ? 'Google' : 'Bing';
|
|
61
|
+
const engine = a.engine === 'google' ? 'Google' : a.engine === 'bing' ? 'Bing' : 'GA4';
|
|
56
62
|
if (!a.websites || a.websites.length === 0) {
|
|
57
63
|
siteRows.push({
|
|
58
64
|
site: '[All Sites Authorized]',
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { getBingClient } from '../client.js';
|
|
2
|
+
import { limitConcurrency } from '../../common/concurrency.js';
|
|
2
3
|
/**
|
|
3
4
|
* Get detailed indexing and crawl information for a URL.
|
|
4
5
|
*/
|
|
@@ -6,3 +7,21 @@ export async function getUrlInfo(siteUrl, url) {
|
|
|
6
7
|
const client = await getBingClient(siteUrl);
|
|
7
8
|
return client.getUrlInfo(siteUrl, url);
|
|
8
9
|
}
|
|
10
|
+
/**
|
|
11
|
+
* Inspect multiple URLs for a site in batch (Bing).
|
|
12
|
+
*
|
|
13
|
+
* @param siteUrl - The URL of the site as defined in Bing Webmaster Tools.
|
|
14
|
+
* @param inspectionUrls - The list of URLs to inspect.
|
|
15
|
+
* @returns An array of results, each containing the URL and its inspection result or error.
|
|
16
|
+
*/
|
|
17
|
+
export async function inspectBatch(siteUrl, inspectionUrls) {
|
|
18
|
+
return limitConcurrency(inspectionUrls, 5, async (url) => {
|
|
19
|
+
try {
|
|
20
|
+
const result = await getUrlInfo(siteUrl, url);
|
|
21
|
+
return { url, result };
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
return { url, error: error.message };
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { getQueryStats, getQueryPageStats } from './analytics.js';
|
|
2
|
+
import { safeTestBatch } from '../../common/utils/regex.js';
|
|
2
3
|
/**
|
|
3
4
|
* Find "low-hanging fruit" keywords in Bing.
|
|
4
5
|
*/
|
|
5
6
|
export async function findLowHangingFruit(siteUrl, options = {}) {
|
|
6
|
-
const { minImpressions = 100, limit = 50 } = options;
|
|
7
|
-
const rows = await getQueryStats(siteUrl);
|
|
7
|
+
const { minImpressions = 100, limit = 50, queryStats } = options;
|
|
8
|
+
const rows = queryStats || await getQueryStats(siteUrl);
|
|
8
9
|
// Filter for low-hanging fruit: position 5-20, high impressions
|
|
9
10
|
const candidates = rows
|
|
10
11
|
.filter(row => {
|
|
@@ -36,8 +37,8 @@ export async function findLowHangingFruit(siteUrl, options = {}) {
|
|
|
36
37
|
* Find keywords ranking just off page 1 in Bing (positions 8-15).
|
|
37
38
|
*/
|
|
38
39
|
export async function findStrikingDistance(siteUrl, options = {}) {
|
|
39
|
-
const { limit = 50 } = options;
|
|
40
|
-
const rows = await getQueryStats(siteUrl);
|
|
40
|
+
const { limit = 50, queryStats } = options;
|
|
41
|
+
const rows = queryStats || await getQueryStats(siteUrl);
|
|
41
42
|
return rows
|
|
42
43
|
.filter(r => r.AvgPosition >= 8 && r.AvgPosition <= 15)
|
|
43
44
|
.sort((a, b) => b.Impressions - a.Impressions)
|
|
@@ -47,8 +48,8 @@ export async function findStrikingDistance(siteUrl, options = {}) {
|
|
|
47
48
|
* Identify queries where the site ranks well but has low CTR in Bing.
|
|
48
49
|
*/
|
|
49
50
|
export async function findLowCTROpportunities(siteUrl, options = {}) {
|
|
50
|
-
const { minImpressions = 500, limit = 50 } = options;
|
|
51
|
-
const rows = await getQueryStats(siteUrl);
|
|
51
|
+
const { minImpressions = 500, limit = 50, queryStats } = options;
|
|
52
|
+
const rows = queryStats || await getQueryStats(siteUrl);
|
|
52
53
|
// Approximate benchmarks for CTR by position (simplified)
|
|
53
54
|
const benchmarks = {
|
|
54
55
|
1: 0.30, 2: 0.15, 3: 0.10, 4: 0.06, 5: 0.04,
|
|
@@ -71,12 +72,12 @@ export async function findLowCTROpportunities(siteUrl, options = {}) {
|
|
|
71
72
|
/**
|
|
72
73
|
* Detect keyword cannibalization in Bing where multiple pages compete for the same query.
|
|
73
74
|
*/
|
|
74
|
-
export async function detectCannibalization(siteUrl, options = {}) {
|
|
75
|
-
const { minImpressions = 50, limit = 30 } = options;
|
|
76
|
-
const
|
|
75
|
+
export async function detectCannibalization(siteUrl, options = {}, rows) {
|
|
76
|
+
const { minImpressions = 50, limit = 30, startDate, endDate } = options;
|
|
77
|
+
const stats = rows || await getQueryPageStats(siteUrl, startDate, endDate);
|
|
77
78
|
// Group by query
|
|
78
79
|
const queryMap = new Map();
|
|
79
|
-
for (const row of
|
|
80
|
+
for (const row of stats) {
|
|
80
81
|
if (row.Impressions < minImpressions)
|
|
81
82
|
continue;
|
|
82
83
|
if (!queryMap.has(row.Query)) {
|
|
@@ -114,16 +115,105 @@ export async function detectCannibalization(siteUrl, options = {}) {
|
|
|
114
115
|
.sort((a, b) => (b.totalClicks * b.clickShareConflict) - (a.totalClicks * a.clickShareConflict))
|
|
115
116
|
.slice(0, limit);
|
|
116
117
|
}
|
|
118
|
+
/**
|
|
119
|
+
* Identify queries that have lost significant visibility or clicks compared to the previous period.
|
|
120
|
+
*/
|
|
121
|
+
export async function findLostQueries(siteUrl, options = {}) {
|
|
122
|
+
const { days = 28, limit = 50 } = options;
|
|
123
|
+
const endDate = new Date();
|
|
124
|
+
endDate.setDate(endDate.getDate() - 2); // Account for data delay
|
|
125
|
+
const midDate = new Date(endDate);
|
|
126
|
+
midDate.setDate(midDate.getDate() - days);
|
|
127
|
+
const startDate = new Date(midDate);
|
|
128
|
+
startDate.setDate(startDate.getDate() - days);
|
|
129
|
+
const [currentStats, previousStats] = await Promise.all([
|
|
130
|
+
getQueryStats(siteUrl, midDate.toISOString().split('T')[0], endDate.toISOString().split('T')[0]),
|
|
131
|
+
getQueryStats(siteUrl, startDate.toISOString().split('T')[0], midDate.toISOString().split('T')[0])
|
|
132
|
+
]);
|
|
133
|
+
const currentMap = new Map();
|
|
134
|
+
for (const row of currentStats) {
|
|
135
|
+
currentMap.set(row.Query, row);
|
|
136
|
+
}
|
|
137
|
+
const lostQueries = [];
|
|
138
|
+
for (const prev of previousStats) {
|
|
139
|
+
if (prev.Clicks < 5)
|
|
140
|
+
continue; // Ignore low volume noise
|
|
141
|
+
const curr = currentMap.get(prev.Query);
|
|
142
|
+
const currClicks = curr ? curr.Clicks : 0;
|
|
143
|
+
// Definition of lost: >80% drop or zero
|
|
144
|
+
if (currClicks === 0 || (currClicks / prev.Clicks) < 0.2) {
|
|
145
|
+
lostQueries.push({
|
|
146
|
+
query: prev.Query,
|
|
147
|
+
previousClicks: prev.Clicks,
|
|
148
|
+
previousImpressions: prev.Impressions,
|
|
149
|
+
previousPosition: prev.AvgPosition,
|
|
150
|
+
currentClicks: currClicks,
|
|
151
|
+
currentImpressions: curr ? curr.Impressions : 0,
|
|
152
|
+
currentPosition: curr ? curr.AvgPosition : 0,
|
|
153
|
+
lostClicks: prev.Clicks - currClicks
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return lostQueries
|
|
158
|
+
.sort((a, b) => b.lostClicks - a.lostClicks)
|
|
159
|
+
.slice(0, limit);
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Segment search performance into Brand and Non-Brand categories.
|
|
163
|
+
*/
|
|
164
|
+
export async function analyzeBrandVsNonBrand(siteUrl, brandRegexString, options = {}) {
|
|
165
|
+
const { days = 28 } = options;
|
|
166
|
+
const endDate = new Date();
|
|
167
|
+
endDate.setDate(endDate.getDate() - 2);
|
|
168
|
+
const startDate = new Date(endDate);
|
|
169
|
+
startDate.setDate(startDate.getDate() - days);
|
|
170
|
+
const rows = await getQueryStats(siteUrl, startDate.toISOString().split('T')[0], endDate.toISOString().split('T')[0]);
|
|
171
|
+
const queries = rows.map(r => r.Query);
|
|
172
|
+
const isBrandResults = safeTestBatch(brandRegexString, 'i', queries);
|
|
173
|
+
const brandStats = { clicks: 0, impressions: 0, weightedPos: 0, queryCount: 0 };
|
|
174
|
+
const nonBrandStats = { clicks: 0, impressions: 0, weightedPos: 0, queryCount: 0 };
|
|
175
|
+
rows.forEach((row, index) => {
|
|
176
|
+
const isBrand = isBrandResults[index];
|
|
177
|
+
const stats = isBrand ? brandStats : nonBrandStats;
|
|
178
|
+
stats.clicks += row.Clicks;
|
|
179
|
+
stats.impressions += row.Impressions;
|
|
180
|
+
stats.weightedPos += row.AvgPosition * row.Impressions;
|
|
181
|
+
stats.queryCount++;
|
|
182
|
+
});
|
|
183
|
+
const calc = (stats, segment) => ({
|
|
184
|
+
segment,
|
|
185
|
+
clicks: stats.clicks,
|
|
186
|
+
impressions: stats.impressions,
|
|
187
|
+
ctr: stats.impressions > 0 ? stats.clicks / stats.impressions : 0,
|
|
188
|
+
position: stats.impressions > 0 ? stats.weightedPos / stats.impressions : 0,
|
|
189
|
+
queryCount: stats.queryCount
|
|
190
|
+
});
|
|
191
|
+
return [
|
|
192
|
+
calc(brandStats, 'Brand'),
|
|
193
|
+
calc(nonBrandStats, 'Non-Brand')
|
|
194
|
+
];
|
|
195
|
+
}
|
|
117
196
|
/**
|
|
118
197
|
* Generate prioritized Bing SEO recommendations.
|
|
119
198
|
*/
|
|
120
|
-
export async function generateRecommendations(siteUrl) {
|
|
199
|
+
export async function generateRecommendations(siteUrl, options = {}) {
|
|
200
|
+
const { days = 28 } = options;
|
|
121
201
|
const insights = [];
|
|
202
|
+
const endDate = new Date();
|
|
203
|
+
endDate.setDate(endDate.getDate() - 2);
|
|
204
|
+
const startDate = new Date(endDate);
|
|
205
|
+
startDate.setDate(startDate.getDate() - days);
|
|
206
|
+
const sDate = startDate.toISOString().split('T')[0];
|
|
207
|
+
const eDate = endDate.toISOString().split('T')[0];
|
|
208
|
+
const [queryStats, queryPageStats] = await Promise.all([
|
|
209
|
+
getQueryStats(siteUrl, sDate, eDate),
|
|
210
|
+
getQueryPageStats(siteUrl, sDate, eDate)
|
|
211
|
+
]);
|
|
122
212
|
const [lowHangingFruit, strikingDistance, lowCTR, cannibalization] = await Promise.all([
|
|
123
|
-
findLowHangingFruit(siteUrl, { limit: 10 }),
|
|
124
|
-
findStrikingDistance(siteUrl, { limit: 10 }),
|
|
125
|
-
findLowCTROpportunities(siteUrl, { limit: 10 }),
|
|
126
|
-
detectCannibalization(siteUrl, { limit: 10 })
|
|
213
|
+
findLowHangingFruit(siteUrl, { limit: 10, queryStats: queryStats }),
|
|
214
|
+
findStrikingDistance(siteUrl, { limit: 10, queryStats: queryStats }),
|
|
215
|
+
findLowCTROpportunities(siteUrl, { limit: 10, queryStats: queryStats }),
|
|
216
|
+
detectCannibalization(siteUrl, { limit: 10, startDate: sDate, endDate: eDate }, queryPageStats)
|
|
127
217
|
]);
|
|
128
218
|
if (lowHangingFruit.length > 0) {
|
|
129
219
|
const totalPotential = lowHangingFruit.reduce((sum, l) => sum + l.potentialClicks, 0);
|
|
@@ -7,6 +7,10 @@ const { machineIdSync } = nodeMachineId;
|
|
|
7
7
|
const CONFIG_PATH = join(homedir(), '.search-console-mcp-config.enc');
|
|
8
8
|
const LEGACY_TOKEN_PATH = join(homedir(), '.search-console-mcp-tokens.enc');
|
|
9
9
|
const ENCRYPTION_ALGORITHM = 'aes-256-gcm';
|
|
10
|
+
let cachedConfig = null;
|
|
11
|
+
export function resetConfigCache() {
|
|
12
|
+
cachedConfig = null;
|
|
13
|
+
}
|
|
10
14
|
function getEncryptionKey() {
|
|
11
15
|
const mId = machineIdSync();
|
|
12
16
|
const salt = process.env.USER || 'sc-mcp-salt';
|
|
@@ -36,6 +40,9 @@ function decrypt(data) {
|
|
|
36
40
|
* Load the unified configuration, including lazy migration from legacy Google tokens.
|
|
37
41
|
*/
|
|
38
42
|
export async function loadConfig() {
|
|
43
|
+
if (cachedConfig) {
|
|
44
|
+
return cachedConfig;
|
|
45
|
+
}
|
|
39
46
|
let config = { accounts: {} };
|
|
40
47
|
// 1. Try to load from new unified config
|
|
41
48
|
if (existsSync(CONFIG_PATH)) {
|
|
@@ -125,12 +132,14 @@ export async function loadConfig() {
|
|
|
125
132
|
};
|
|
126
133
|
}
|
|
127
134
|
}
|
|
135
|
+
cachedConfig = config;
|
|
128
136
|
return config;
|
|
129
137
|
}
|
|
130
138
|
export async function saveConfig(config) {
|
|
131
139
|
try {
|
|
132
140
|
const encrypted = encrypt(JSON.stringify(config));
|
|
133
141
|
writeFileSync(CONFIG_PATH, encrypted, { mode: 0o600 });
|
|
142
|
+
cachedConfig = config;
|
|
134
143
|
}
|
|
135
144
|
catch (e) {
|
|
136
145
|
console.error('Failed to save config:', e.message);
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Limit the number of concurrent executions of an async mapping function.
|
|
3
|
+
*
|
|
4
|
+
* @param items - The array of items to process.
|
|
5
|
+
* @param limit - The maximum number of concurrent executions.
|
|
6
|
+
* @param fn - The async function to execute for each item.
|
|
7
|
+
* @returns A promise that resolves to an array of results.
|
|
8
|
+
*/
|
|
9
|
+
export async function limitConcurrency(items, limit, fn) {
|
|
10
|
+
const results = [];
|
|
11
|
+
const executing = new Set();
|
|
12
|
+
for (const item of items) {
|
|
13
|
+
const p = fn(item);
|
|
14
|
+
results.push(p);
|
|
15
|
+
const e = p.then(() => { executing.delete(e); }, () => { executing.delete(e); });
|
|
16
|
+
executing.add(e);
|
|
17
|
+
if (executing.size >= limit) {
|
|
18
|
+
await Promise.race(executing);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return Promise.all(results);
|
|
22
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { existsSync } from "fs";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import { homedir } from "os";
|
|
4
|
+
/**
|
|
5
|
+
* Detect which search platforms are currently enabled based on environment
|
|
6
|
+
* variables and credential files. This is evaluated at call time so it
|
|
7
|
+
* always reflects the current state.
|
|
8
|
+
*/
|
|
9
|
+
export function getEnabledPlatforms() {
|
|
10
|
+
const hasServiceAccount = !!process.env.GOOGLE_APPLICATION_CREDENTIALS || (!!process.env.GOOGLE_CLIENT_EMAIL && !!process.env.GOOGLE_PRIVATE_KEY);
|
|
11
|
+
const tokenPath = join(homedir(), '.search-console-mcp-tokens.enc');
|
|
12
|
+
const configPath = join(homedir(), '.search-console-mcp-config.enc');
|
|
13
|
+
const hasOAuthTokens = existsSync(tokenPath) || existsSync(configPath);
|
|
14
|
+
const isGoogleEnabled = hasServiceAccount || hasOAuthTokens;
|
|
15
|
+
const isBingEnabled = !!process.env.BING_API_KEY;
|
|
16
|
+
// GA4 needs the config file (for service accounts or oauth)
|
|
17
|
+
const isGA4Enabled = existsSync(configPath);
|
|
18
|
+
return { isGoogleEnabled, isBingEnabled, isGA4Enabled };
|
|
19
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export function normalizeGA4Row(row) {
|
|
2
|
+
return {
|
|
3
|
+
sessions: Number(row.sessions || 0),
|
|
4
|
+
bounceRate: Number(row.bounceRate || 0),
|
|
5
|
+
engagementRate: Number(row.engagementRate || 0),
|
|
6
|
+
averageSessionDuration: Number(row.averageSessionDuration || 0),
|
|
7
|
+
conversions: Number(row.conversions || 0),
|
|
8
|
+
activeUsers: row.activeUsers !== undefined ? Number(row.activeUsers) : undefined,
|
|
9
|
+
eventCount: row.eventCount !== undefined ? Number(row.eventCount) : undefined,
|
|
10
|
+
revenue: row.totalRevenue !== undefined ? Number(row.totalRevenue) : undefined
|
|
11
|
+
};
|
|
12
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { queryAnalytics as queryGSC } from '../../../google/tools/analytics.js';
|
|
2
|
+
import { getBingClient } from '../../../bing/client.js';
|
|
3
|
+
import { getOrganicLandingPages } from '../../../ga4/tools/analytics.js';
|
|
4
|
+
import { normalizeGA4Row } from './ga4-adapters.js';
|
|
5
|
+
import { extractUrlPath } from './utils.js';
|
|
6
|
+
export async function getOpportunityMatrix(gscSiteUrl, bingSiteUrl, ga4PropertyId, startDate, endDate, limit = 20, ga4AccountId, gscAccountId, bingAccountId) {
|
|
7
|
+
const gscPromise = queryGSC({
|
|
8
|
+
siteUrl: gscSiteUrl,
|
|
9
|
+
accountId: gscAccountId,
|
|
10
|
+
startDate,
|
|
11
|
+
endDate,
|
|
12
|
+
dimensions: ['page'],
|
|
13
|
+
limit: limit * 2
|
|
14
|
+
});
|
|
15
|
+
const bingPromise = (async () => {
|
|
16
|
+
try {
|
|
17
|
+
const client = await getBingClient(bingSiteUrl, bingAccountId);
|
|
18
|
+
return await client.getPageStats(bingSiteUrl);
|
|
19
|
+
}
|
|
20
|
+
catch (e) {
|
|
21
|
+
console.error("Bing fetch failed:", e);
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
24
|
+
})();
|
|
25
|
+
const ga4Promise = getOrganicLandingPages(ga4PropertyId, startDate, endDate, limit * 2, ga4AccountId);
|
|
26
|
+
const [gscResult, bingResult, ga4Result] = await Promise.allSettled([gscPromise, bingPromise, ga4Promise]);
|
|
27
|
+
const gscRows = gscResult.status === 'fulfilled' ? gscResult.value : [];
|
|
28
|
+
const bingRows = bingResult.status === 'fulfilled' ? bingResult.value : [];
|
|
29
|
+
const ga4Rows = ga4Result.status === 'fulfilled' ? ga4Result.value : [];
|
|
30
|
+
// Normalize and Join
|
|
31
|
+
const map = new Map();
|
|
32
|
+
// Process GSC
|
|
33
|
+
for (const row of gscRows) {
|
|
34
|
+
const url = row.keys?.[0] || '';
|
|
35
|
+
const path = extractUrlPath(url);
|
|
36
|
+
map.set(path, {
|
|
37
|
+
url,
|
|
38
|
+
gsc: {
|
|
39
|
+
clicks: row.clicks || 0,
|
|
40
|
+
impressions: row.impressions || 0,
|
|
41
|
+
ctr: row.ctr || 0,
|
|
42
|
+
position: row.position || 0
|
|
43
|
+
},
|
|
44
|
+
priorityScore: 0,
|
|
45
|
+
action: '',
|
|
46
|
+
category: 'Content Fix'
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
// Process Bing
|
|
50
|
+
for (const row of bingRows) {
|
|
51
|
+
// Bing API returns the URL in the 'Query' field for GetPageStats results
|
|
52
|
+
const path = extractUrlPath(row.Query);
|
|
53
|
+
const existing = map.get(path);
|
|
54
|
+
const bingStats = {
|
|
55
|
+
clicks: row.Clicks,
|
|
56
|
+
impressions: row.Impressions,
|
|
57
|
+
ctr: row.CTR,
|
|
58
|
+
position: row.AvgPosition
|
|
59
|
+
};
|
|
60
|
+
if (existing) {
|
|
61
|
+
existing.bing = bingStats;
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
map.set(path, {
|
|
65
|
+
url: row.Query,
|
|
66
|
+
gsc: { clicks: 0, impressions: 0, ctr: 0, position: 0 },
|
|
67
|
+
bing: bingStats,
|
|
68
|
+
priorityScore: 0,
|
|
69
|
+
action: '',
|
|
70
|
+
category: 'Bing Opportunity'
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
// Process GA4
|
|
75
|
+
for (const row of ga4Rows) {
|
|
76
|
+
const path = row.landingPagePlusQueryString || row.pagePath;
|
|
77
|
+
if (!path)
|
|
78
|
+
continue;
|
|
79
|
+
// Try precise match then path match
|
|
80
|
+
const existing = map.get(path) || map.get(path.split('?')[0]);
|
|
81
|
+
const ga4Stats = normalizeGA4Row(row);
|
|
82
|
+
if (existing) {
|
|
83
|
+
existing.ga4 = ga4Stats;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// Classify and Prioritize
|
|
87
|
+
const results = Array.from(map.values());
|
|
88
|
+
for (const row of results) {
|
|
89
|
+
const gscPos = row.gsc.position;
|
|
90
|
+
const bingPos = row.bing?.position || 100;
|
|
91
|
+
const engagement = row.ga4?.engagementRate || 0;
|
|
92
|
+
const bounce = row.ga4?.bounceRate || 0;
|
|
93
|
+
const impressions = Math.max(row.gsc.impressions, row.bing?.impressions || 0);
|
|
94
|
+
let score = 0;
|
|
95
|
+
let category = 'Content Fix';
|
|
96
|
+
let action = 'Optimize content';
|
|
97
|
+
if (gscPos > 10 && gscPos < 30 && engagement > 0.5) {
|
|
98
|
+
category = 'Quick Win';
|
|
99
|
+
action = 'Improve title/meta description to boost CTR';
|
|
100
|
+
score = 80 + (engagement * 20);
|
|
101
|
+
}
|
|
102
|
+
else if (impressions > 1000 && (engagement < 0.3 || bounce > 0.7)) {
|
|
103
|
+
category = 'Content Fix';
|
|
104
|
+
action = 'Improve page content to reduce bounce rate';
|
|
105
|
+
score = 70 + (Math.log10(impressions) * 5);
|
|
106
|
+
}
|
|
107
|
+
else if (bingPos < 10 && gscPos > 20) {
|
|
108
|
+
category = 'Bing Opportunity';
|
|
109
|
+
action = 'Analyze Bing success factors and apply to Google';
|
|
110
|
+
score = 60 + (100 - bingPos);
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
score = (row.gsc.clicks + (row.bing?.clicks || 0)) / 10;
|
|
114
|
+
}
|
|
115
|
+
row.category = category;
|
|
116
|
+
row.action = action;
|
|
117
|
+
row.priorityScore = parseFloat(score.toFixed(1));
|
|
118
|
+
}
|
|
119
|
+
return results.sort((a, b) => b.priorityScore - a.priorityScore).slice(0, limit);
|
|
120
|
+
}
|
|
121
|
+
export async function getBrandAnalysis(brandTerms, gscSiteUrl, bingSiteUrl, ga4PropertyId, startDate, endDate, ga4AccountId, gscAccountId, bingAccountId) {
|
|
122
|
+
const gscPromise = queryGSC({
|
|
123
|
+
siteUrl: gscSiteUrl,
|
|
124
|
+
accountId: gscAccountId,
|
|
125
|
+
startDate,
|
|
126
|
+
endDate,
|
|
127
|
+
dimensions: ['query'],
|
|
128
|
+
limit: 5000
|
|
129
|
+
});
|
|
130
|
+
const bingPromise = (async () => {
|
|
131
|
+
try {
|
|
132
|
+
const client = await getBingClient(bingSiteUrl, bingAccountId);
|
|
133
|
+
return await client.getQueryStats(bingSiteUrl);
|
|
134
|
+
}
|
|
135
|
+
catch (e) {
|
|
136
|
+
console.error("Bing fetch failed:", e);
|
|
137
|
+
return [];
|
|
138
|
+
}
|
|
139
|
+
})();
|
|
140
|
+
// GA4 doesn't expose query-level brand data. We fetch total organic sessions
|
|
141
|
+
// as a contextual reference alongside GSC/Bing brand splits.
|
|
142
|
+
const ga4Promise = getOrganicLandingPages(ga4PropertyId, startDate, endDate, 5000, ga4AccountId);
|
|
143
|
+
const [gscResult, bingResult, ga4Result] = await Promise.allSettled([gscPromise, bingPromise, ga4Promise]);
|
|
144
|
+
const gscRows = gscResult.status === 'fulfilled' ? gscResult.value : [];
|
|
145
|
+
const bingRows = bingResult.status === 'fulfilled' ? bingResult.value : [];
|
|
146
|
+
const ga4Rows = ga4Result.status === 'fulfilled' ? ga4Result.value : [];
|
|
147
|
+
const isBrand = (query) => {
|
|
148
|
+
const q = query.toLowerCase();
|
|
149
|
+
return brandTerms.some(term => q.includes(term.toLowerCase()));
|
|
150
|
+
};
|
|
151
|
+
// GSC Analysis
|
|
152
|
+
const gscBrand = { clicks: 0, impressions: 0 };
|
|
153
|
+
const gscNonBrand = { clicks: 0, impressions: 0 };
|
|
154
|
+
for (const row of gscRows) {
|
|
155
|
+
const query = row.keys?.[0] || '';
|
|
156
|
+
if (isBrand(query)) {
|
|
157
|
+
gscBrand.clicks += row.clicks || 0;
|
|
158
|
+
gscBrand.impressions += row.impressions || 0;
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
gscNonBrand.clicks += row.clicks || 0;
|
|
162
|
+
gscNonBrand.impressions += row.impressions || 0;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
// Bing Analysis
|
|
166
|
+
const bingBrand = { clicks: 0, impressions: 0 };
|
|
167
|
+
const bingNonBrand = { clicks: 0, impressions: 0 };
|
|
168
|
+
for (const row of bingRows) {
|
|
169
|
+
if (isBrand(row.Query)) {
|
|
170
|
+
bingBrand.clicks += row.Clicks;
|
|
171
|
+
bingBrand.impressions += row.Impressions;
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
bingNonBrand.clicks += row.Clicks;
|
|
175
|
+
bingNonBrand.impressions += row.Impressions;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
// GA4: Compute total organic sessions as context.
|
|
179
|
+
// GA4 does not provide keyword-level brand/non-brand splits.
|
|
180
|
+
const totalOrganicSessions = ga4Rows.reduce((sum, row) => sum + Number(row.sessions || 0), 0);
|
|
181
|
+
// Estimate brand session share by applying GSC brand click ratio to GA4 sessions
|
|
182
|
+
const gscTotalClicks = gscBrand.clicks + gscNonBrand.clicks;
|
|
183
|
+
const gscBrandRatio = gscTotalClicks > 0 ? gscBrand.clicks / gscTotalClicks : 0;
|
|
184
|
+
const estimatedBrandSessions = Math.round(totalOrganicSessions * gscBrandRatio);
|
|
185
|
+
const estimatedNonBrandSessions = totalOrganicSessions - estimatedBrandSessions;
|
|
186
|
+
const rows = [
|
|
187
|
+
{
|
|
188
|
+
platform: 'Google',
|
|
189
|
+
brandMetrics: gscBrand,
|
|
190
|
+
nonBrandMetrics: gscNonBrand,
|
|
191
|
+
brandShare: parseFloat((gscBrand.clicks / ((gscBrand.clicks + gscNonBrand.clicks) || 1)).toFixed(2))
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
platform: 'Bing',
|
|
195
|
+
brandMetrics: bingBrand,
|
|
196
|
+
nonBrandMetrics: bingNonBrand,
|
|
197
|
+
brandShare: parseFloat((bingBrand.clicks / ((bingBrand.clicks + bingNonBrand.clicks) || 1)).toFixed(2))
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
platform: 'GA4',
|
|
201
|
+
brandMetrics: { sessions: estimatedBrandSessions },
|
|
202
|
+
nonBrandMetrics: { sessions: estimatedNonBrandSessions },
|
|
203
|
+
brandShare: parseFloat(gscBrandRatio.toFixed(2))
|
|
204
|
+
}
|
|
205
|
+
];
|
|
206
|
+
return rows;
|
|
207
|
+
}
|