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 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 **Bing Webmaster Tools**. Stop exporting CSVs and start asking questions.
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
- 1. Open Google Search Console Export CSV
17
- 2. Open Bing Webmaster Tools Export CSV
18
- 3. Paste both into a spreadsheet
19
- 4. Manually compare keywords across engines
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 New Way
24
- **Just ask:**
25
- > "Find low-hanging fruit keywords (positions 11-20) with high impressions that I should optimize."
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
- > "Compare my Google and Bing performance — where am I leaving Bing traffic on the table?"
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
+ }