search-console-mcp 1.12.0 → 1.13.0
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 +50 -16
- package/dist/accounts.js +8 -2
- package/dist/bing/tools/inspection.js +19 -0
- package/dist/bing/tools/seo-insights.js +10 -9
- 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/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 +346 -41
- 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:
|
|
@@ -217,6 +228,29 @@ These are low-level tools designed to be used by other AI agents to build comple
|
|
|
217
228
|
| `bing_analytics_time_series` | Advanced time series analysis for Bing. |
|
|
218
229
|
| `bing_sitemaps_list` / `bing_sitemaps_submit` | Manage sitemaps in Bing. |
|
|
219
230
|
|
|
231
|
+
### Google Analytics 4 (GA4)
|
|
232
|
+
| Tool | Description |
|
|
233
|
+
|------|-------------|
|
|
234
|
+
| `analytics_page_performance` | Detailed page metrics (sessions, engagement, views). |
|
|
235
|
+
| `analytics_traffic_sources` | Analyze sessions by Channel, Source, and Medium. |
|
|
236
|
+
| `analytics_organic_landing_pages` | Focused metrics for organic traffic landing pages. |
|
|
237
|
+
| `analytics_content_performance` | Analyze content performance by Content Group in GA4. |
|
|
238
|
+
| `analytics_conversion_funnel` | Top converting pages and events. |
|
|
239
|
+
| `analytics_user_behavior` | Device, Country, and Engagement breakdown. |
|
|
240
|
+
| `analytics_audience_segments` | New vs Returning, Age, and OS analysis. |
|
|
241
|
+
| `analytics_realtime` | Live active user data by page and location. |
|
|
242
|
+
| `analytics_ecommerce` | Product and revenue performance. |
|
|
243
|
+
| `analytics_pagespeed_correlation` | Correlate GA4 metrics with PageSpeed scores. |
|
|
244
|
+
|
|
245
|
+
### Cross-Platform Intelligence
|
|
246
|
+
| Tool | Description |
|
|
247
|
+
|------|-------------|
|
|
248
|
+
| `opportunity_matrix` | **[Flagship]** Prioritize SEO tasks by combining signals from GSC, GA4, and Bing. |
|
|
249
|
+
| `page_analysis` | Joint analysis of ranking (GSC) vs behavior (GA4) for pages. |
|
|
250
|
+
| `traffic_health_check` | Diagnose tracking gaps by comparing GSC clicks to GA4 organic sessions. |
|
|
251
|
+
| `brand_analysis` | Brand vs Non-Brand split across GSC, Bing, and GA4. |
|
|
252
|
+
| `compare_engines` | Compare keyword performance between Google and Bing. |
|
|
253
|
+
|
|
220
254
|
|
|
221
255
|
|
|
222
256
|
## 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
|
+
}
|
|
@@ -3,8 +3,8 @@ import { getQueryStats, getQueryPageStats } from './analytics.js';
|
|
|
3
3
|
* Find "low-hanging fruit" keywords in Bing.
|
|
4
4
|
*/
|
|
5
5
|
export async function findLowHangingFruit(siteUrl, options = {}) {
|
|
6
|
-
const { minImpressions = 100, limit = 50 } = options;
|
|
7
|
-
const rows = await getQueryStats(siteUrl);
|
|
6
|
+
const { minImpressions = 100, limit = 50, queryStats } = options;
|
|
7
|
+
const rows = queryStats || await getQueryStats(siteUrl);
|
|
8
8
|
// Filter for low-hanging fruit: position 5-20, high impressions
|
|
9
9
|
const candidates = rows
|
|
10
10
|
.filter(row => {
|
|
@@ -36,8 +36,8 @@ export async function findLowHangingFruit(siteUrl, options = {}) {
|
|
|
36
36
|
* Find keywords ranking just off page 1 in Bing (positions 8-15).
|
|
37
37
|
*/
|
|
38
38
|
export async function findStrikingDistance(siteUrl, options = {}) {
|
|
39
|
-
const { limit = 50 } = options;
|
|
40
|
-
const rows = await getQueryStats(siteUrl);
|
|
39
|
+
const { limit = 50, queryStats } = options;
|
|
40
|
+
const rows = queryStats || await getQueryStats(siteUrl);
|
|
41
41
|
return rows
|
|
42
42
|
.filter(r => r.AvgPosition >= 8 && r.AvgPosition <= 15)
|
|
43
43
|
.sort((a, b) => b.Impressions - a.Impressions)
|
|
@@ -47,8 +47,8 @@ export async function findStrikingDistance(siteUrl, options = {}) {
|
|
|
47
47
|
* Identify queries where the site ranks well but has low CTR in Bing.
|
|
48
48
|
*/
|
|
49
49
|
export async function findLowCTROpportunities(siteUrl, options = {}) {
|
|
50
|
-
const { minImpressions = 500, limit = 50 } = options;
|
|
51
|
-
const rows = await getQueryStats(siteUrl);
|
|
50
|
+
const { minImpressions = 500, limit = 50, queryStats } = options;
|
|
51
|
+
const rows = queryStats || await getQueryStats(siteUrl);
|
|
52
52
|
// Approximate benchmarks for CTR by position (simplified)
|
|
53
53
|
const benchmarks = {
|
|
54
54
|
1: 0.30, 2: 0.15, 3: 0.10, 4: 0.06, 5: 0.04,
|
|
@@ -119,10 +119,11 @@ export async function detectCannibalization(siteUrl, options = {}) {
|
|
|
119
119
|
*/
|
|
120
120
|
export async function generateRecommendations(siteUrl) {
|
|
121
121
|
const insights = [];
|
|
122
|
+
const queryStatsPromise = getQueryStats(siteUrl);
|
|
122
123
|
const [lowHangingFruit, strikingDistance, lowCTR, cannibalization] = await Promise.all([
|
|
123
|
-
findLowHangingFruit(siteUrl, { limit: 10 }),
|
|
124
|
-
findStrikingDistance(siteUrl, { limit: 10 }),
|
|
125
|
-
findLowCTROpportunities(siteUrl, { limit: 10 }),
|
|
124
|
+
queryStatsPromise.then(stats => findLowHangingFruit(siteUrl, { limit: 10, queryStats: stats })),
|
|
125
|
+
queryStatsPromise.then(stats => findStrikingDistance(siteUrl, { limit: 10, queryStats: stats })),
|
|
126
|
+
queryStatsPromise.then(stats => findLowCTROpportunities(siteUrl, { limit: 10, queryStats: stats })),
|
|
126
127
|
detectCannibalization(siteUrl, { limit: 10 })
|
|
127
128
|
]);
|
|
128
129
|
if (lowHangingFruit.length > 0) {
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { queryAnalytics as queryGSC } from '../../../google/tools/analytics.js';
|
|
2
|
+
import { getOrganicLandingPages, getTrafficSources } from '../../../ga4/tools/analytics.js';
|
|
3
|
+
import { normalizeGA4Row } from './ga4-adapters.js';
|
|
4
|
+
import { extractUrlPath, extractBasePropertyName } from './utils.js';
|
|
5
|
+
export async function analyzePagesCrossPlatform(gscSiteUrl, ga4PropertyId, startDate, endDate, limit = 50, ga4AccountId, gscAccountId) {
|
|
6
|
+
// Parallel fetch
|
|
7
|
+
const [gscResult, ga4Result] = await Promise.allSettled([
|
|
8
|
+
queryGSC({
|
|
9
|
+
siteUrl: gscSiteUrl,
|
|
10
|
+
accountId: gscAccountId,
|
|
11
|
+
startDate,
|
|
12
|
+
endDate,
|
|
13
|
+
dimensions: ['page'],
|
|
14
|
+
limit: limit * 2 // Fetch more to ensure overlap
|
|
15
|
+
}),
|
|
16
|
+
getOrganicLandingPages(ga4PropertyId, startDate, endDate, limit * 2, ga4AccountId)
|
|
17
|
+
]);
|
|
18
|
+
const gscRows = gscResult.status === 'fulfilled' ? gscResult.value : [];
|
|
19
|
+
const ga4Rows = ga4Result.status === 'fulfilled' ? ga4Result.value : [];
|
|
20
|
+
// Map GA4 rows by path
|
|
21
|
+
const ga4Map = new Map();
|
|
22
|
+
for (const row of ga4Rows) {
|
|
23
|
+
// row.landingPagePlusQueryString is path
|
|
24
|
+
const path = row.landingPagePlusQueryString || row.pagePath;
|
|
25
|
+
if (path) {
|
|
26
|
+
ga4Map.set(path, row);
|
|
27
|
+
// Also normalized path (without query string)
|
|
28
|
+
const basePath = path.split('?')[0];
|
|
29
|
+
if (basePath !== path) {
|
|
30
|
+
ga4Map.set(basePath, row);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const results = [];
|
|
35
|
+
for (const gscRow of gscRows) {
|
|
36
|
+
const url = gscRow.keys?.[0] || '';
|
|
37
|
+
const path = extractUrlPath(url);
|
|
38
|
+
// Try exact path match first, then path without query string
|
|
39
|
+
let ga4Row = ga4Map.get(path);
|
|
40
|
+
if (!ga4Row) {
|
|
41
|
+
ga4Row = ga4Map.get(extractBasePropertyName(url));
|
|
42
|
+
}
|
|
43
|
+
if (ga4Row || (gscRow.clicks || 0) > 0) { // Only include if relevant
|
|
44
|
+
const ga4Stats = ga4Row ? normalizeGA4Row(ga4Row) : undefined;
|
|
45
|
+
const gscStats = {
|
|
46
|
+
clicks: gscRow.clicks || 0,
|
|
47
|
+
impressions: gscRow.impressions || 0,
|
|
48
|
+
ctr: gscRow.ctr || 0,
|
|
49
|
+
position: gscRow.position || 0
|
|
50
|
+
};
|
|
51
|
+
// Derived metrics
|
|
52
|
+
let clickToSessionRatio = 0;
|
|
53
|
+
if (gscStats.clicks > 0 && ga4Stats) {
|
|
54
|
+
clickToSessionRatio = ga4Stats.sessions / gscStats.clicks;
|
|
55
|
+
}
|
|
56
|
+
// Opportunity Score
|
|
57
|
+
const visibilityScore = Math.log10(gscStats.impressions + 1);
|
|
58
|
+
const engagementScore = ga4Stats ? ga4Stats.engagementRate : 0;
|
|
59
|
+
const conversionScore = ga4Stats ? Math.log10(ga4Stats.conversions + 1) : 0;
|
|
60
|
+
const opportunityScore = (visibilityScore * 10) + (engagementScore * 100) + (conversionScore * 20);
|
|
61
|
+
results.push({
|
|
62
|
+
url,
|
|
63
|
+
gsc: gscStats,
|
|
64
|
+
ga4: ga4Stats,
|
|
65
|
+
clickToSessionRatio: parseFloat(clickToSessionRatio.toFixed(2)),
|
|
66
|
+
opportunityScore: parseFloat(opportunityScore.toFixed(2))
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
// Sort by opportunity score
|
|
71
|
+
return results.sort((a, b) => (b.opportunityScore || 0) - (a.opportunityScore || 0));
|
|
72
|
+
}
|
|
73
|
+
export async function checkTrafficHealth(gscSiteUrl, ga4PropertyId, startDate, endDate, ga4AccountId, gscAccountId) {
|
|
74
|
+
const [gscRows, ga4Rows] = await Promise.all([
|
|
75
|
+
queryGSC({
|
|
76
|
+
siteUrl: gscSiteUrl,
|
|
77
|
+
accountId: gscAccountId,
|
|
78
|
+
startDate,
|
|
79
|
+
endDate,
|
|
80
|
+
limit: 1 // Total
|
|
81
|
+
}),
|
|
82
|
+
getTrafficSources(ga4PropertyId, startDate, endDate, 'Organic Search', 100, ga4AccountId) // Increase limit to capture variants
|
|
83
|
+
]);
|
|
84
|
+
const gscClicks = gscRows.reduce((sum, row) => sum + (row.clicks || 0), 0);
|
|
85
|
+
const ga4OrganicSessions = ga4Rows.reduce((sum, row) => sum + Number(row.sessions || 0), 0);
|
|
86
|
+
const ratio = gscClicks > 0 ? ga4OrganicSessions / gscClicks : 0;
|
|
87
|
+
let classification = 'Healthy';
|
|
88
|
+
let recommendation = "Traffic tracking looks normal.";
|
|
89
|
+
if (gscClicks > 0) {
|
|
90
|
+
if (ratio < 0.6) {
|
|
91
|
+
classification = 'Tracking Gap';
|
|
92
|
+
recommendation = "GA4 is capturing significantly fewer sessions than GSC clicks. Check for missing tracking codes, cookie consent issues, or aggressive bot filtering.";
|
|
93
|
+
}
|
|
94
|
+
else if (ratio > 1.3) {
|
|
95
|
+
classification = 'Filter Issue';
|
|
96
|
+
recommendation = "GA4 reports more organic sessions than GSC clicks. This might indicate direct traffic being misclassified as organic, or GSC property not covering all site variants (http/https/www).";
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
recommendation = "No GSC clicks recorded.";
|
|
101
|
+
}
|
|
102
|
+
return [{
|
|
103
|
+
date: `${startDate} to ${endDate}`,
|
|
104
|
+
gscClicks,
|
|
105
|
+
ga4OrganicSessions,
|
|
106
|
+
ratio: parseFloat(ratio.toFixed(2)),
|
|
107
|
+
classification,
|
|
108
|
+
recommendation
|
|
109
|
+
}];
|
|
110
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared utilities for cross-platform comparison tools.
|
|
3
|
+
*/
|
|
4
|
+
/**
|
|
5
|
+
* Normalizes a URL by extracting the pathname and search string.
|
|
6
|
+
* This ensures consistency when joining data from different platforms
|
|
7
|
+
* that might report URLs differently (e.g. with or without domain).
|
|
8
|
+
*
|
|
9
|
+
* @param url The full URL or path to normalize
|
|
10
|
+
* @returns The normalized path (pathname + search)
|
|
11
|
+
*/
|
|
12
|
+
export function extractUrlPath(url) {
|
|
13
|
+
if (!url)
|
|
14
|
+
return '';
|
|
15
|
+
try {
|
|
16
|
+
const urlObj = new URL(url);
|
|
17
|
+
return urlObj.pathname + urlObj.search;
|
|
18
|
+
}
|
|
19
|
+
catch (e) {
|
|
20
|
+
// Fallback for cases where input is already a path or invalid URL
|
|
21
|
+
return url;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Extracts the base pathname without the query string.
|
|
26
|
+
*
|
|
27
|
+
* @param url The full URL or path
|
|
28
|
+
* @returns The pathname only
|
|
29
|
+
*/
|
|
30
|
+
export function extractBasePropertyName(url) {
|
|
31
|
+
const path = extractUrlPath(url);
|
|
32
|
+
return path.split('?')[0];
|
|
33
|
+
}
|