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 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:
@@ -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 rows = await getQueryPageStats(siteUrl);
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 rows) {
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
+ }