search-console-mcp 1.11.0 → 1.12.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 +42 -10
- package/dist/accounts.js +209 -0
- package/dist/bing/client.js +63 -25
- package/dist/bing/tools/analytics.js +75 -17
- package/dist/bing/tools/crawl.js +1 -1
- package/dist/bing/tools/inspection.js +1 -1
- package/dist/bing/tools/links.js +1 -1
- package/dist/bing/tools/seo-insights.js +60 -3
- package/dist/bing/tools/sitemaps.js +14 -3
- package/dist/bing/tools/sites-health.js +2 -2
- package/dist/bing/tools/sites.js +26 -3
- package/dist/bing/tools/url-submission.js +3 -3
- package/dist/common/auth/config.js +149 -0
- package/dist/common/auth/resolver.js +65 -0
- package/dist/common/errors.js +5 -1
- package/dist/common/tools/compare-engines/adapters.js +95 -0
- package/dist/common/tools/compare-engines/comparator.js +61 -0
- package/dist/common/tools/compare-engines/index.js +37 -0
- package/dist/common/tools/compare-engines/normalizer.js +20 -0
- package/dist/common/tools/compare-engines/signals.js +34 -0
- package/dist/common/tools/compare-engines/types.js +1 -0
- package/dist/common/tools/seo-primitives.js +9 -7
- package/dist/google/client.js +80 -132
- package/dist/google/tools/analytics.js +1 -1
- package/dist/google/tools/sites.js +7 -6
- package/dist/index.js +510 -200
- package/dist/setup.js +262 -54
- package/dist/utils/ui.js +35 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -13,17 +13,23 @@ A Model Context Protocol (MCP) server that transforms how you interact with **Go
|
|
|
13
13
|
## Why use this?
|
|
14
14
|
|
|
15
15
|
### ❌ The Old Way
|
|
16
|
-
1. Open Search Console
|
|
17
|
-
2.
|
|
18
|
-
3.
|
|
19
|
-
4.
|
|
20
|
-
5.
|
|
21
|
-
6.
|
|
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
|
|
22
22
|
|
|
23
23
|
### ✅ The New Way
|
|
24
24
|
**Just ask:**
|
|
25
25
|
> "Find low-hanging fruit keywords (positions 11-20) with high impressions that I should optimize."
|
|
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.**
|
|
32
|
+
|
|
27
33
|
---
|
|
28
34
|
## 🎯 Magic Prompts
|
|
29
35
|
|
|
@@ -47,15 +53,21 @@ Copy and paste these into your MCP client (Claude Desktop, etc.) to see the inte
|
|
|
47
53
|
#### ⚡ The Speed vs. Ranking Correlator
|
|
48
54
|
> "Fetch the top 5 pages by impressions. For these pages, run a PageSpeed audit. Is there any correlation between low performance scores and recently declining positions?"
|
|
49
55
|
|
|
50
|
-
####
|
|
56
|
+
#### 🔀 Multi-Engine Comparison
|
|
51
57
|
> "Compare my performance between Google and Bing for the last 30 days. Which keywords are ranking better on Bing but have lower traffic on Google?"
|
|
52
58
|
|
|
59
|
+
#### 🎯 Bing Opportunity Finder
|
|
60
|
+
> "Show me keywords where I'm in the top 5 on Google but not ranking on Bing. These are my easy Bing wins."
|
|
61
|
+
|
|
62
|
+
#### ⚠️ Google Dependency Check
|
|
63
|
+
> "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
|
+
|
|
53
65
|
---
|
|
54
66
|
|
|
55
67
|
## 🔐 Authentication (Desktop Flow)
|
|
56
68
|
|
|
57
69
|
Search Console MCP uses a **Secure Desktop Flow**. This provides high-security, professional grade authentication for your Google account:
|
|
58
|
-
- **Multi-Account Support**:
|
|
70
|
+
- **Multi-Account Support**: Connect multiple Google and Bing accounts. The server automatically picks the right one for each site.
|
|
59
71
|
- **System Keychain Primary**: Tokens are stored in your OS's native credential manager (macOS Keychain, Windows Credential Manager, or Linux Secret Service).
|
|
60
72
|
- **AES-256-GCM Hardware-Bound Encryption**: Fallback storage is encrypted with AES-256-GCM using a key derived from your unique hardware machine ID. Tokens stolen from your machine cannot be decrypted on another computer.
|
|
61
73
|
- **Silent Background Refresh**: Tokens auto-refresh silently when they expire.
|
|
@@ -112,16 +124,36 @@ To access Bing data, you simply need an API Key.
|
|
|
112
124
|
|
|
113
125
|
---
|
|
114
126
|
|
|
127
|
+
## 👥 Multi-Account Management
|
|
128
|
+
|
|
129
|
+
Manage multiple Google and Bing accounts from the CLI:
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
# List all connected accounts
|
|
133
|
+
npx search-console-mcp accounts list
|
|
134
|
+
|
|
135
|
+
# Remove an account
|
|
136
|
+
npx search-console-mcp accounts remove --account=marketing@company.com
|
|
137
|
+
|
|
138
|
+
# Add a site boundary to an account
|
|
139
|
+
npx search-console-mcp accounts add-site --account=marketing@company.com --site=example.com
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
When your AI agent queries a site, the server automatically resolves which account to use. [Learn more →](https://searchconsolemcp.mintlify.app/getting-started/multi-account)
|
|
143
|
+
|
|
144
|
+
---
|
|
145
|
+
|
|
115
146
|
|
|
116
147
|
## 🛡️ Fort Knox Security
|
|
117
148
|
|
|
118
149
|
This MCP server implements a multi-layered security architecture:
|
|
119
150
|
|
|
120
151
|
* **Keychain Integration**: Primarily uses the **macOS Keychain**, **Windows Credential Manager**, or **libsecret (Linux)** to store tokens.
|
|
121
|
-
* **
|
|
152
|
+
* **Encrypted Config**: Account configuration is stored in `~/.search-console-mcp-config.enc` using **AES-256-GCM** encryption.
|
|
122
153
|
* **Machine Fingerprinting**: The encryption key is derived from your unique hardware UUID and OS user. The encrypted file is useless if moved to another machine.
|
|
123
154
|
* **Minimalist Storage**: Only the `refresh_token` and `expiry_date` are stored.
|
|
124
|
-
* **
|
|
155
|
+
* **Legacy Support**: Automatically detects credentials from older versions (tokens files, environment variables).
|
|
156
|
+
* **Strict Unix Permissions**: Config files are created with `mode 600` (read/write only by your user).
|
|
125
157
|
|
|
126
158
|
---
|
|
127
159
|
|
package/dist/accounts.js
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { loadConfig, removeAccount, updateAccount } from './common/auth/config.js';
|
|
2
|
+
function parseFlags(args) {
|
|
3
|
+
const flags = {};
|
|
4
|
+
for (const arg of args) {
|
|
5
|
+
const match = arg.match(/^--([^=]+)=(.+)$/);
|
|
6
|
+
if (match) {
|
|
7
|
+
flags[match[1]] = match[2];
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
return flags;
|
|
11
|
+
}
|
|
12
|
+
function findAccountByAliasOrId(accounts, identifier) {
|
|
13
|
+
// Try exact ID match first
|
|
14
|
+
if (accounts[identifier])
|
|
15
|
+
return accounts[identifier];
|
|
16
|
+
// Then try alias match (case-insensitive)
|
|
17
|
+
return Object.values(accounts).find((a) => a.alias?.toLowerCase() === identifier.toLowerCase() || a.id === identifier);
|
|
18
|
+
}
|
|
19
|
+
export async function main(args) {
|
|
20
|
+
const subcommand = args[0] || 'list';
|
|
21
|
+
const flags = parseFlags(args.slice(1));
|
|
22
|
+
// Positional args (non-flag args after the subcommand)
|
|
23
|
+
const positional = args.slice(1).filter(a => !a.startsWith('--'));
|
|
24
|
+
if (subcommand === 'list') {
|
|
25
|
+
try {
|
|
26
|
+
const config = await loadConfig();
|
|
27
|
+
const accounts = Object.values(config.accounts);
|
|
28
|
+
if (accounts.length === 0) {
|
|
29
|
+
console.log(JSON.stringify({
|
|
30
|
+
accounts: [],
|
|
31
|
+
message: "No accounts connected.",
|
|
32
|
+
resolution: {
|
|
33
|
+
command: "search-console-mcp setup",
|
|
34
|
+
google: "search-console-mcp setup --engine=google",
|
|
35
|
+
bing: "search-console-mcp setup --engine=bing"
|
|
36
|
+
},
|
|
37
|
+
setup_instructions: {
|
|
38
|
+
google: [
|
|
39
|
+
"Run: search-console-mcp setup --engine=google",
|
|
40
|
+
"Choose OAuth 2.0 login or Service Account",
|
|
41
|
+
"Authorize in browser and select sites to restrict (or allow all)"
|
|
42
|
+
],
|
|
43
|
+
bing: [
|
|
44
|
+
"Run: search-console-mcp setup --engine=bing",
|
|
45
|
+
"Get your API key from https://www.bing.com/webmasters/settings/api",
|
|
46
|
+
"Enter the API key when prompted"
|
|
47
|
+
]
|
|
48
|
+
}
|
|
49
|
+
}, null, 2));
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const siteRows = [];
|
|
53
|
+
for (const a of accounts) {
|
|
54
|
+
const alias = a.alias || '[Unnamed]';
|
|
55
|
+
const engine = a.engine === 'google' ? 'Google' : 'Bing';
|
|
56
|
+
if (!a.websites || a.websites.length === 0) {
|
|
57
|
+
siteRows.push({
|
|
58
|
+
site: '[All Sites Authorized]',
|
|
59
|
+
account: alias,
|
|
60
|
+
engine: engine,
|
|
61
|
+
id: a.id
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
for (const site of a.websites) {
|
|
66
|
+
siteRows.push({
|
|
67
|
+
site: site,
|
|
68
|
+
account: alias,
|
|
69
|
+
engine: engine,
|
|
70
|
+
id: a.id
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
console.log(JSON.stringify({ accounts: siteRows }, null, 2));
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
console.log(JSON.stringify({
|
|
79
|
+
error: "Failed to list accounts",
|
|
80
|
+
message: error.message
|
|
81
|
+
}, null, 2));
|
|
82
|
+
}
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (subcommand === 'remove') {
|
|
86
|
+
// Flags (agent): --account=alias --site=url | Positional (human): remove <alias_or_site>
|
|
87
|
+
const identifier = flags['account'] || flags['id'] || positional[0];
|
|
88
|
+
const siteToRemove = flags['site'];
|
|
89
|
+
if (!identifier && !siteToRemove) {
|
|
90
|
+
console.log(JSON.stringify({
|
|
91
|
+
error: "Missing arguments",
|
|
92
|
+
message: "Provide --account=<alias|id> to remove an account, or --site=<url> to remove a specific site boundary.",
|
|
93
|
+
resolution: {
|
|
94
|
+
by_account: "search-console-mcp accounts remove --account=<alias_or_id>",
|
|
95
|
+
by_site: "search-console-mcp accounts remove --site=<siteUrl>"
|
|
96
|
+
}
|
|
97
|
+
}, null, 2));
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
try {
|
|
101
|
+
const config = await loadConfig();
|
|
102
|
+
if (identifier) {
|
|
103
|
+
// Remove entire account by alias or ID
|
|
104
|
+
const account = findAccountByAliasOrId(config.accounts, identifier);
|
|
105
|
+
if (!account) {
|
|
106
|
+
console.log(JSON.stringify({
|
|
107
|
+
error: "Account not found",
|
|
108
|
+
message: `No account matching '${identifier}' was found.`,
|
|
109
|
+
resolution: "Run: search-console-mcp accounts list"
|
|
110
|
+
}, null, 2));
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
await removeAccount(account.id);
|
|
114
|
+
console.log(JSON.stringify({
|
|
115
|
+
success: true,
|
|
116
|
+
message: `Account '${account.alias}' (${account.id}) removed successfully.`
|
|
117
|
+
}, null, 2));
|
|
118
|
+
}
|
|
119
|
+
else if (siteToRemove) {
|
|
120
|
+
// Remove a specific site boundary from whichever account owns it
|
|
121
|
+
let found = false;
|
|
122
|
+
for (const account of Object.values(config.accounts)) {
|
|
123
|
+
if (account.websites?.includes(siteToRemove)) {
|
|
124
|
+
account.websites = account.websites.filter((w) => w !== siteToRemove);
|
|
125
|
+
await updateAccount(account);
|
|
126
|
+
found = true;
|
|
127
|
+
console.log(JSON.stringify({
|
|
128
|
+
success: true,
|
|
129
|
+
message: `Site '${siteToRemove}' removed from account '${account.alias}'.`
|
|
130
|
+
}, null, 2));
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (!found) {
|
|
135
|
+
console.log(JSON.stringify({
|
|
136
|
+
error: "Site not found",
|
|
137
|
+
message: `No account has '${siteToRemove}' in its site boundaries.`,
|
|
138
|
+
resolution: "Run: search-console-mcp accounts list"
|
|
139
|
+
}, null, 2));
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
console.log(JSON.stringify({
|
|
145
|
+
error: "Failed to remove",
|
|
146
|
+
message: error.message
|
|
147
|
+
}, null, 2));
|
|
148
|
+
}
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (subcommand === 'add-site') {
|
|
152
|
+
// Flags (agent): --account=alias --site=url | Positional (human): add-site <alias> <site>
|
|
153
|
+
const identifier = flags['account'] || flags['id'] || positional[0];
|
|
154
|
+
const site = flags['site'] || positional[1];
|
|
155
|
+
if (!identifier || !site) {
|
|
156
|
+
console.log(JSON.stringify({
|
|
157
|
+
error: "Missing arguments",
|
|
158
|
+
message: "Both --account and --site are required.",
|
|
159
|
+
resolution: "search-console-mcp accounts add-site --account=<alias_or_id> --site=<siteUrl>"
|
|
160
|
+
}, null, 2));
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
try {
|
|
164
|
+
const config = await loadConfig();
|
|
165
|
+
const account = findAccountByAliasOrId(config.accounts, identifier);
|
|
166
|
+
if (!account) {
|
|
167
|
+
console.log(JSON.stringify({
|
|
168
|
+
error: "Account not found",
|
|
169
|
+
message: `No account matching '${identifier}' was found.`,
|
|
170
|
+
resolution: "Run: search-console-mcp accounts list"
|
|
171
|
+
}, null, 2));
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
if (!account.websites)
|
|
175
|
+
account.websites = [];
|
|
176
|
+
if (!account.websites.includes(site)) {
|
|
177
|
+
account.websites.push(site);
|
|
178
|
+
await updateAccount(account);
|
|
179
|
+
console.log(JSON.stringify({
|
|
180
|
+
success: true,
|
|
181
|
+
message: `Site '${site}' added to account '${account.alias}'.`
|
|
182
|
+
}, null, 2));
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
console.log(JSON.stringify({
|
|
186
|
+
success: true,
|
|
187
|
+
message: `Account '${account.alias}' already has '${site}' in its boundaries.`
|
|
188
|
+
}, null, 2));
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
catch (error) {
|
|
192
|
+
console.log(JSON.stringify({
|
|
193
|
+
error: "Failed to add site",
|
|
194
|
+
message: error.message
|
|
195
|
+
}, null, 2));
|
|
196
|
+
}
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
console.log(JSON.stringify({
|
|
200
|
+
error: "Unknown command",
|
|
201
|
+
message: `'${subcommand}' is not a valid subcommand.`,
|
|
202
|
+
resolution: {
|
|
203
|
+
list: "search-console-mcp accounts list",
|
|
204
|
+
remove_account: "search-console-mcp accounts remove --account=<alias_or_id>",
|
|
205
|
+
remove_site: "search-console-mcp accounts remove --site=<siteUrl>",
|
|
206
|
+
add_site: "search-console-mcp accounts add-site --account=<alias_or_id> --site=<siteUrl>"
|
|
207
|
+
}
|
|
208
|
+
}, null, 2));
|
|
209
|
+
}
|
package/dist/bing/client.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { resolveAccount } from '../common/auth/resolver.js';
|
|
2
|
+
import { loadConfig } from '../common/auth/config.js';
|
|
1
3
|
export class BingClient {
|
|
2
4
|
apiKey;
|
|
3
5
|
baseUrl = 'https://ssl.bing.com/webmaster/api.svc/json';
|
|
@@ -35,29 +37,38 @@ export class BingClient {
|
|
|
35
37
|
async getSiteList() {
|
|
36
38
|
return this.request('GetUserSites');
|
|
37
39
|
}
|
|
40
|
+
async addSite(siteUrl) {
|
|
41
|
+
return this.request('AddSite', { siteUrl }, true);
|
|
42
|
+
}
|
|
43
|
+
async removeSite(siteUrl) {
|
|
44
|
+
return this.request('RemoveSite', { siteUrl }, true);
|
|
45
|
+
}
|
|
38
46
|
async getQueryStats(siteUrl) {
|
|
39
|
-
return this.request('GetQueryStats', { siteUrl }
|
|
47
|
+
return this.request('GetQueryStats', { siteUrl });
|
|
40
48
|
}
|
|
41
49
|
async getPageStats(siteUrl) {
|
|
42
|
-
return this.request('GetPageStats', { siteUrl }
|
|
50
|
+
return this.request('GetPageStats', { siteUrl });
|
|
43
51
|
}
|
|
44
|
-
async getPageQueryStats(siteUrl,
|
|
45
|
-
return this.request('GetPageQueryStats', { siteUrl,
|
|
52
|
+
async getPageQueryStats(siteUrl, page) {
|
|
53
|
+
return this.request('GetPageQueryStats', { siteUrl, page });
|
|
46
54
|
}
|
|
47
|
-
async submitSitemap(siteUrl,
|
|
48
|
-
await this.request('
|
|
55
|
+
async submitSitemap(siteUrl, feedUrl) {
|
|
56
|
+
await this.request('SubmitFeed', { siteUrl, feedUrl }, true);
|
|
49
57
|
}
|
|
50
|
-
async
|
|
51
|
-
|
|
58
|
+
async deleteSitemap(siteUrl, feedUrl) {
|
|
59
|
+
await this.request('RemoveFeed', { siteUrl, feedUrl }, true);
|
|
60
|
+
}
|
|
61
|
+
async getFeeds(siteUrl) {
|
|
62
|
+
return this.request('GetFeeds', { siteUrl });
|
|
52
63
|
}
|
|
53
64
|
async getKeywordStats(q, country, language) {
|
|
54
|
-
return this.request('GetKeywordStats', { q, country, language }
|
|
65
|
+
return this.request('GetKeywordStats', { q, country, language });
|
|
55
66
|
}
|
|
56
67
|
async getCrawlIssues(siteUrl) {
|
|
57
|
-
return this.request('GetCrawlIssues', { siteUrl }
|
|
68
|
+
return this.request('GetCrawlIssues', { siteUrl });
|
|
58
69
|
}
|
|
59
70
|
async getUrlSubmissionQuota(siteUrl) {
|
|
60
|
-
return this.request('GetUrlSubmissionQuota', { siteUrl }
|
|
71
|
+
return this.request('GetUrlSubmissionQuota', { siteUrl });
|
|
61
72
|
}
|
|
62
73
|
async submitUrl(siteUrl, url) {
|
|
63
74
|
return this.request('SubmitUrl', { siteUrl, url }, true);
|
|
@@ -66,32 +77,59 @@ export class BingClient {
|
|
|
66
77
|
return this.request('SubmitUrlBatch', { siteUrl, urlList }, true);
|
|
67
78
|
}
|
|
68
79
|
async getQueryPageStats(siteUrl) {
|
|
69
|
-
return this.request('GetQueryPageStats', { siteUrl }
|
|
80
|
+
return this.request('GetQueryPageStats', { siteUrl });
|
|
70
81
|
}
|
|
71
82
|
async getRankAndTrafficStats(siteUrl) {
|
|
72
|
-
return this.request('GetRankAndTrafficStats', { siteUrl }
|
|
83
|
+
return this.request('GetRankAndTrafficStats', { siteUrl });
|
|
73
84
|
}
|
|
74
85
|
async getCrawlStats(siteUrl) {
|
|
75
|
-
return this.request('GetCrawlStats', { siteUrl }
|
|
86
|
+
return this.request('GetCrawlStats', { siteUrl });
|
|
76
87
|
}
|
|
77
88
|
async getUrlInfo(siteUrl, url) {
|
|
78
|
-
return this.request('GetUrlInfo', { siteUrl, url }
|
|
89
|
+
return this.request('GetUrlInfo', { siteUrl, url });
|
|
79
90
|
}
|
|
80
91
|
async getLinkCounts(siteUrl) {
|
|
81
|
-
return this.request('GetLinkCounts', { siteUrl }
|
|
92
|
+
return this.request('GetLinkCounts', { siteUrl });
|
|
82
93
|
}
|
|
83
94
|
async getRelatedKeywords(q, country, language) {
|
|
84
|
-
return this.request('GetRelatedKeywords', { q, country, language }
|
|
95
|
+
return this.request('GetRelatedKeywords', { q, country, language });
|
|
85
96
|
}
|
|
86
97
|
}
|
|
87
|
-
let
|
|
88
|
-
export async function getBingClient() {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
98
|
+
let cachedBingClients = {};
|
|
99
|
+
export async function getBingClient(siteUrl, accountId) {
|
|
100
|
+
// 1. Resolve Account
|
|
101
|
+
let apiKey;
|
|
102
|
+
let cacheKey;
|
|
103
|
+
if (accountId) {
|
|
104
|
+
const config = await loadConfig();
|
|
105
|
+
const account = config.accounts[accountId];
|
|
106
|
+
if (!account || account.engine !== 'bing') {
|
|
107
|
+
throw new Error(`Bing account ${accountId} not found.`);
|
|
108
|
+
}
|
|
109
|
+
apiKey = account.apiKey;
|
|
110
|
+
cacheKey = account.id;
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
try {
|
|
114
|
+
const account = await resolveAccount(siteUrl || '', 'bing');
|
|
115
|
+
apiKey = account.apiKey;
|
|
116
|
+
cacheKey = account.id;
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
// Fallback to environment variable for legacy support if resolution fails or no site specified
|
|
120
|
+
apiKey = process.env.BING_API_KEY;
|
|
121
|
+
cacheKey = 'env_fallback';
|
|
122
|
+
if (!apiKey) {
|
|
123
|
+
throw error; // Re-throw the resolution error if no ENV fallback either
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (cachedBingClients[cacheKey])
|
|
128
|
+
return cachedBingClients[cacheKey];
|
|
92
129
|
if (!apiKey) {
|
|
93
|
-
throw new Error('Bing API Key not found. Please
|
|
130
|
+
throw new Error('Bing API Key not found. Please run setup to add an account.');
|
|
94
131
|
}
|
|
95
|
-
|
|
96
|
-
|
|
132
|
+
const client = new BingClient(apiKey);
|
|
133
|
+
cachedBingClients[cacheKey] = client;
|
|
134
|
+
return client;
|
|
97
135
|
}
|
|
@@ -1,37 +1,69 @@
|
|
|
1
1
|
import { getBingClient } from '../client.js';
|
|
2
2
|
/**
|
|
3
|
-
* Get query performance stats for a Bing site.
|
|
3
|
+
* Get query performance stats for a Bing site with optional date filtering.
|
|
4
4
|
*/
|
|
5
|
-
export async function getQueryStats(siteUrl) {
|
|
6
|
-
const client = await getBingClient();
|
|
7
|
-
|
|
5
|
+
export async function getQueryStats(siteUrl, startDate, endDate) {
|
|
6
|
+
const client = await getBingClient(siteUrl);
|
|
7
|
+
const stats = await client.getQueryStats(siteUrl);
|
|
8
|
+
if (!startDate && !endDate)
|
|
9
|
+
return stats;
|
|
10
|
+
const start = startDate ? new Date(startDate) : new Date(0);
|
|
11
|
+
const end = endDate ? new Date(endDate) : new Date();
|
|
12
|
+
return stats.filter(row => {
|
|
13
|
+
const d = new Date(row.Date);
|
|
14
|
+
return d >= start && d <= end;
|
|
15
|
+
});
|
|
8
16
|
}
|
|
9
17
|
/**
|
|
10
|
-
* Get page performance stats (top pages) for a Bing site.
|
|
18
|
+
* Get page performance stats (top pages) for a Bing site with optional date filtering.
|
|
11
19
|
*/
|
|
12
|
-
export async function getPageStats(siteUrl) {
|
|
13
|
-
const client = await getBingClient();
|
|
14
|
-
|
|
20
|
+
export async function getPageStats(siteUrl, startDate, endDate) {
|
|
21
|
+
const client = await getBingClient(siteUrl);
|
|
22
|
+
const stats = await client.getPageStats(siteUrl);
|
|
23
|
+
if (!startDate && !endDate)
|
|
24
|
+
return stats;
|
|
25
|
+
const start = startDate ? new Date(startDate) : new Date(0);
|
|
26
|
+
const end = endDate ? new Date(endDate) : new Date();
|
|
27
|
+
return stats.filter(row => {
|
|
28
|
+
const d = new Date(row.Date);
|
|
29
|
+
return d >= start && d <= end;
|
|
30
|
+
});
|
|
15
31
|
}
|
|
16
32
|
/**
|
|
17
|
-
* Get query stats for a specific page on a Bing site.
|
|
33
|
+
* Get query stats for a specific page on a Bing site with optional date filtering.
|
|
18
34
|
*/
|
|
19
|
-
export async function getPageQueryStats(siteUrl, pageUrl) {
|
|
20
|
-
const client = await getBingClient();
|
|
21
|
-
|
|
35
|
+
export async function getPageQueryStats(siteUrl, pageUrl, startDate, endDate) {
|
|
36
|
+
const client = await getBingClient(siteUrl);
|
|
37
|
+
const stats = await client.getPageQueryStats(siteUrl, pageUrl);
|
|
38
|
+
if (!startDate && !endDate)
|
|
39
|
+
return stats;
|
|
40
|
+
const start = startDate ? new Date(startDate) : new Date(0);
|
|
41
|
+
const end = endDate ? new Date(endDate) : new Date();
|
|
42
|
+
return stats.filter(row => {
|
|
43
|
+
const d = new Date(row.Date);
|
|
44
|
+
return d >= start && d <= end;
|
|
45
|
+
});
|
|
22
46
|
}
|
|
23
47
|
/**
|
|
24
|
-
* Get combined query and page performance stats.
|
|
48
|
+
* Get combined query and page performance stats with optional date filtering.
|
|
25
49
|
*/
|
|
26
|
-
export async function getQueryPageStats(siteUrl) {
|
|
27
|
-
const client = await getBingClient();
|
|
28
|
-
|
|
50
|
+
export async function getQueryPageStats(siteUrl, startDate, endDate) {
|
|
51
|
+
const client = await getBingClient(siteUrl);
|
|
52
|
+
const stats = await client.getQueryPageStats(siteUrl);
|
|
53
|
+
if (!startDate && !endDate)
|
|
54
|
+
return stats;
|
|
55
|
+
const start = startDate ? new Date(startDate) : new Date(0);
|
|
56
|
+
const end = endDate ? new Date(endDate) : new Date();
|
|
57
|
+
return stats.filter(row => {
|
|
58
|
+
const d = new Date(row.Date);
|
|
59
|
+
return d >= start && d <= end;
|
|
60
|
+
});
|
|
29
61
|
}
|
|
30
62
|
/**
|
|
31
63
|
* Get historical rank and traffic statistics for a site.
|
|
32
64
|
*/
|
|
33
65
|
export async function getRankAndTrafficStats(siteUrl) {
|
|
34
|
-
const client = await getBingClient();
|
|
66
|
+
const client = await getBingClient(siteUrl);
|
|
35
67
|
return client.getRankAndTrafficStats(siteUrl);
|
|
36
68
|
}
|
|
37
69
|
/**
|
|
@@ -119,3 +151,29 @@ export async function detectAnomalies(siteUrl, options = {}) {
|
|
|
119
151
|
}
|
|
120
152
|
return anomalies;
|
|
121
153
|
}
|
|
154
|
+
/**
|
|
155
|
+
* Get aggregate performance summary for a Bing site for the last N days.
|
|
156
|
+
*/
|
|
157
|
+
export async function getPerformanceSummary(siteUrl, days = 28) {
|
|
158
|
+
const stats = await getRankAndTrafficStats(siteUrl);
|
|
159
|
+
// Bing typically has historical data; filter for last N days
|
|
160
|
+
const endDate = new Date();
|
|
161
|
+
const startDate = new Date();
|
|
162
|
+
startDate.setDate(endDate.getDate() - days);
|
|
163
|
+
const filtered = stats.filter((row) => {
|
|
164
|
+
const d = new Date(row.Date);
|
|
165
|
+
return d >= startDate && d <= endDate;
|
|
166
|
+
});
|
|
167
|
+
const clicks = filtered.reduce((acc, row) => acc + row.Clicks, 0);
|
|
168
|
+
const impressions = filtered.reduce((acc, row) => acc + row.Impressions, 0);
|
|
169
|
+
const weightedPosSum = filtered.reduce((acc, row) => acc + (row.AvgPosition * row.Impressions), 0);
|
|
170
|
+
const avgPos = impressions > 0 ? weightedPosSum / impressions : 0;
|
|
171
|
+
return {
|
|
172
|
+
clicks,
|
|
173
|
+
impressions,
|
|
174
|
+
ctr: impressions > 0 ? clicks / impressions : 0,
|
|
175
|
+
position: parseFloat(avgPos.toFixed(2)),
|
|
176
|
+
startDate: startDate.toISOString().split('T')[0],
|
|
177
|
+
endDate: endDate.toISOString().split('T')[0]
|
|
178
|
+
};
|
|
179
|
+
}
|
package/dist/bing/tools/crawl.js
CHANGED
|
@@ -10,6 +10,6 @@ export async function getCrawlIssues(siteUrl) {
|
|
|
10
10
|
* Get crawl statistics for a site.
|
|
11
11
|
*/
|
|
12
12
|
export async function getCrawlStats(siteUrl) {
|
|
13
|
-
const client = await getBingClient();
|
|
13
|
+
const client = await getBingClient(siteUrl);
|
|
14
14
|
return client.getCrawlStats(siteUrl);
|
|
15
15
|
}
|
|
@@ -3,6 +3,6 @@ import { getBingClient } from '../client.js';
|
|
|
3
3
|
* Get detailed indexing and crawl information for a URL.
|
|
4
4
|
*/
|
|
5
5
|
export async function getUrlInfo(siteUrl, url) {
|
|
6
|
-
const client = await getBingClient();
|
|
6
|
+
const client = await getBingClient(siteUrl);
|
|
7
7
|
return client.getUrlInfo(siteUrl, url);
|
|
8
8
|
}
|
package/dist/bing/tools/links.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { getQueryStats } from './analytics.js';
|
|
1
|
+
import { getQueryStats, getQueryPageStats } from './analytics.js';
|
|
2
2
|
/**
|
|
3
3
|
* Find "low-hanging fruit" keywords in Bing.
|
|
4
4
|
*/
|
|
@@ -68,15 +68,62 @@ export async function findLowCTROpportunities(siteUrl, options = {}) {
|
|
|
68
68
|
.sort((a, b) => b.Impressions - a.Impressions)
|
|
69
69
|
.slice(0, limit);
|
|
70
70
|
}
|
|
71
|
+
/**
|
|
72
|
+
* Detect keyword cannibalization in Bing where multiple pages compete for the same query.
|
|
73
|
+
*/
|
|
74
|
+
export async function detectCannibalization(siteUrl, options = {}) {
|
|
75
|
+
const { minImpressions = 50, limit = 30 } = options;
|
|
76
|
+
const rows = await getQueryPageStats(siteUrl);
|
|
77
|
+
// Group by query
|
|
78
|
+
const queryMap = new Map();
|
|
79
|
+
for (const row of rows) {
|
|
80
|
+
if (row.Impressions < minImpressions)
|
|
81
|
+
continue;
|
|
82
|
+
if (!queryMap.has(row.Query)) {
|
|
83
|
+
queryMap.set(row.Query, []);
|
|
84
|
+
}
|
|
85
|
+
queryMap.get(row.Query).push({
|
|
86
|
+
page: row.Page,
|
|
87
|
+
clicks: row.Clicks,
|
|
88
|
+
impressions: row.Impressions,
|
|
89
|
+
date: row.Date
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
const cannibalization = [];
|
|
93
|
+
for (const [query, pages] of queryMap) {
|
|
94
|
+
if (pages.length >= 2) {
|
|
95
|
+
pages.sort((a, b) => b.clicks - a.clicks);
|
|
96
|
+
const totalClicks = pages.reduce((sum, p) => sum + p.clicks, 0);
|
|
97
|
+
const totalImpressions = pages.reduce((sum, p) => sum + p.impressions, 0);
|
|
98
|
+
// Calculate conflict score
|
|
99
|
+
const shares = pages.map(p => totalClicks > 0 ? p.clicks / totalClicks : 0);
|
|
100
|
+
const hhi = shares.reduce((sum, s) => sum + s * s, 0);
|
|
101
|
+
const conflictScore = 1 - hhi;
|
|
102
|
+
if (conflictScore > 0.1 || pages[1].impressions > (pages[0].impressions * 0.2)) {
|
|
103
|
+
cannibalization.push({
|
|
104
|
+
query,
|
|
105
|
+
pages,
|
|
106
|
+
totalClicks,
|
|
107
|
+
totalImpressions,
|
|
108
|
+
clickShareConflict: parseFloat(conflictScore.toFixed(2))
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return cannibalization
|
|
114
|
+
.sort((a, b) => (b.totalClicks * b.clickShareConflict) - (a.totalClicks * a.clickShareConflict))
|
|
115
|
+
.slice(0, limit);
|
|
116
|
+
}
|
|
71
117
|
/**
|
|
72
118
|
* Generate prioritized Bing SEO recommendations.
|
|
73
119
|
*/
|
|
74
120
|
export async function generateRecommendations(siteUrl) {
|
|
75
121
|
const insights = [];
|
|
76
|
-
const [lowHangingFruit, strikingDistance, lowCTR] = await Promise.all([
|
|
122
|
+
const [lowHangingFruit, strikingDistance, lowCTR, cannibalization] = await Promise.all([
|
|
77
123
|
findLowHangingFruit(siteUrl, { limit: 10 }),
|
|
78
124
|
findStrikingDistance(siteUrl, { limit: 10 }),
|
|
79
|
-
findLowCTROpportunities(siteUrl, { limit: 10 })
|
|
125
|
+
findLowCTROpportunities(siteUrl, { limit: 10 }),
|
|
126
|
+
detectCannibalization(siteUrl, { limit: 10 })
|
|
80
127
|
]);
|
|
81
128
|
if (lowHangingFruit.length > 0) {
|
|
82
129
|
const totalPotential = lowHangingFruit.reduce((sum, l) => sum + l.potentialClicks, 0);
|
|
@@ -99,6 +146,16 @@ export async function generateRecommendations(siteUrl) {
|
|
|
99
146
|
data: { topQueries: lowCTR.slice(0, 5).map(q => q.Query) }
|
|
100
147
|
});
|
|
101
148
|
}
|
|
149
|
+
if (cannibalization.length > 0) {
|
|
150
|
+
insights.push({
|
|
151
|
+
type: 'warning',
|
|
152
|
+
category: 'Content',
|
|
153
|
+
title: `${cannibalization.length} Bing Cannibalization Issues`,
|
|
154
|
+
description: `Multiple pages are competing for the same keywords in Bing. Consider consolidating content.`,
|
|
155
|
+
priority: 'medium',
|
|
156
|
+
data: { topIssues: cannibalization.slice(0, 3).map(c => c.query) }
|
|
157
|
+
});
|
|
158
|
+
}
|
|
102
159
|
if (strikingDistance.length > 0) {
|
|
103
160
|
insights.push({
|
|
104
161
|
type: 'opportunity',
|