open-google-mcp 0.2.0__py3-none-any.whl
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.
- open_google_mcp/__init__.py +0 -0
- open_google_mcp/__main__.py +57 -0
- open_google_mcp/bing_client.py +463 -0
- open_google_mcp/client.py +91 -0
- open_google_mcp/ga4_client.py +87 -0
- open_google_mcp/gsc_client.py +175 -0
- open_google_mcp/keyword_planner.py +303 -0
- open_google_mcp/server.py +1947 -0
- open_google_mcp-0.2.0.dist-info/METADATA +810 -0
- open_google_mcp-0.2.0.dist-info/RECORD +14 -0
- open_google_mcp-0.2.0.dist-info/WHEEL +5 -0
- open_google_mcp-0.2.0.dist-info/entry_points.txt +2 -0
- open_google_mcp-0.2.0.dist-info/licenses/LICENSE +21 -0
- open_google_mcp-0.2.0.dist-info/top_level.txt +1 -0
|
File without changes
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
from .server import mcp
|
|
5
|
+
|
|
6
|
+
DOCS_URL = "https://github.com/ceotind/open-google-mcp#setup"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _env(name: str) -> str:
|
|
10
|
+
return (os.environ.get(name) or "").strip()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _has_service_account() -> bool:
|
|
14
|
+
return bool(_env("GOOGLE_SERVICE_ACCOUNT_EMAIL") and _env("GOOGLE_SERVICE_ACCOUNT_KEY"))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _has_credential_file() -> bool:
|
|
18
|
+
names = ("GOOGLE_ADS_JSON_KEY_FILE", "GA4_CREDENTIALS_PATH", "GSC_CREDENTIALS_PATH")
|
|
19
|
+
return any(_env(name) for name in names)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _warn_unconfigured() -> None:
|
|
23
|
+
"""Print setup hints to stderr. Never raises, never writes to stdout."""
|
|
24
|
+
if not (_has_service_account() or _has_credential_file()):
|
|
25
|
+
notes = [
|
|
26
|
+
"Google Ads, Search Console and Analytics: no credentials. "
|
|
27
|
+
"Set GOOGLE_SERVICE_ACCOUNT_EMAIL and GOOGLE_SERVICE_ACCOUNT_KEY.",
|
|
28
|
+
]
|
|
29
|
+
else:
|
|
30
|
+
notes = []
|
|
31
|
+
|
|
32
|
+
if not _env("GOOGLE_ADS_DEVELOPER_TOKEN"):
|
|
33
|
+
notes.append("Google Ads: GOOGLE_ADS_DEVELOPER_TOKEN is unset; Ads and Keyword Planner calls will fail.")
|
|
34
|
+
|
|
35
|
+
if not _env("BING_WEBMASTER_API_KEY"):
|
|
36
|
+
notes.append("Bing: BING_WEBMASTER_API_KEY is unset; Bing tools will fail.")
|
|
37
|
+
|
|
38
|
+
if not notes:
|
|
39
|
+
return
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
print("[open-google-mcp] startup check", file=sys.stderr)
|
|
43
|
+
for note in notes:
|
|
44
|
+
print(f" - {note}", file=sys.stderr)
|
|
45
|
+
print(" Pass these in your MCP client 'env' block, or export them before launch.", file=sys.stderr)
|
|
46
|
+
print(f" Setup: {DOCS_URL}", file=sys.stderr)
|
|
47
|
+
except Exception:
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def main() -> None:
|
|
52
|
+
_warn_unconfigured()
|
|
53
|
+
mcp.run()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
if __name__ == "__main__":
|
|
57
|
+
main()
|
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
"""Bing Webmaster Tools API client.
|
|
2
|
+
|
|
3
|
+
Read-only client for the Bing Webmaster JSON API. Docs:
|
|
4
|
+
https://learn.microsoft.com/en-us/bingwebmaster/
|
|
5
|
+
|
|
6
|
+
Authentication uses a single API key from Bing Webmaster Tools:
|
|
7
|
+
Settings -> API Access -> Generate API Key.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import time
|
|
15
|
+
from datetime import datetime, timedelta, timezone
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
|
|
20
|
+
API_BASE = "https://ssl.bing.com/webmaster/api.svc/json"
|
|
21
|
+
DEFAULT_TIMEOUT = 30.0
|
|
22
|
+
DEFAULT_CACHE_TTL = 300
|
|
23
|
+
MAX_RETRIES = 2
|
|
24
|
+
|
|
25
|
+
# Bing takes the API key in the query string. Stop httpx from logging full URLs,
|
|
26
|
+
# so the key never lands in server logs.
|
|
27
|
+
logging.getLogger("httpx").setLevel(logging.WARNING)
|
|
28
|
+
logging.getLogger("httpcore").setLevel(logging.WARNING)
|
|
29
|
+
|
|
30
|
+
_DOTNET_DATE = re.compile(r"^/Date\((-?\d+)([-+]\d{4})?\)/$")
|
|
31
|
+
|
|
32
|
+
_cache: dict[str, tuple[float, Any]] = {}
|
|
33
|
+
_transport: httpx.BaseTransport | None = None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class BingAPIError(Exception):
|
|
37
|
+
"""Raised when the Bing Webmaster API returns an error."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, method: str, status: int | None, message: str, hint: str = ""):
|
|
40
|
+
self.method = method
|
|
41
|
+
self.status = status
|
|
42
|
+
self.message = message
|
|
43
|
+
self.hint = hint
|
|
44
|
+
super().__init__(self._format())
|
|
45
|
+
|
|
46
|
+
def _format(self) -> str:
|
|
47
|
+
parts = [f"Bing Webmaster API error in {self.method}"]
|
|
48
|
+
if self.status:
|
|
49
|
+
parts.append(f"(HTTP {self.status})")
|
|
50
|
+
text = " ".join(parts) + f": {self.message}"
|
|
51
|
+
if self.hint:
|
|
52
|
+
text += f"\n{self.hint}"
|
|
53
|
+
return text
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# ---------------------------------------------------------------------------
|
|
57
|
+
# Configuration
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
def api_key() -> str:
|
|
61
|
+
"""Return the configured Bing Webmaster API key."""
|
|
62
|
+
key = os.environ.get("BING_WEBMASTER_API_KEY", "").strip()
|
|
63
|
+
if not key:
|
|
64
|
+
raise ValueError(
|
|
65
|
+
"BING_WEBMASTER_API_KEY is not set.\n\n"
|
|
66
|
+
"Get the key from Bing Webmaster Tools:\n"
|
|
67
|
+
" 1. Sign in at https://www.bing.com/webmasters\n"
|
|
68
|
+
" 2. Verify your site\n"
|
|
69
|
+
" 3. Settings -> API Access -> Generate API Key\n\n"
|
|
70
|
+
"Then add it to .env:\n"
|
|
71
|
+
' BING_WEBMASTER_API_KEY="your_key_here"'
|
|
72
|
+
)
|
|
73
|
+
return key
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def is_configured() -> bool:
|
|
77
|
+
"""Return True when an API key is present."""
|
|
78
|
+
return bool(os.environ.get("BING_WEBMASTER_API_KEY", "").strip())
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _timeout() -> float:
|
|
82
|
+
raw = os.environ.get("BING_REQUEST_TIMEOUT", "")
|
|
83
|
+
try:
|
|
84
|
+
return float(raw) if raw else DEFAULT_TIMEOUT
|
|
85
|
+
except ValueError:
|
|
86
|
+
return DEFAULT_TIMEOUT
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _cache_ttl() -> float:
|
|
90
|
+
raw = os.environ.get("BING_CACHE_TTL", "")
|
|
91
|
+
try:
|
|
92
|
+
return float(raw) if raw else DEFAULT_CACHE_TTL
|
|
93
|
+
except ValueError:
|
|
94
|
+
return DEFAULT_CACHE_TTL
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ---------------------------------------------------------------------------
|
|
98
|
+
# Normalization
|
|
99
|
+
# ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
def _to_snake(name: str) -> str:
|
|
102
|
+
"""Convert PascalCase or camelCase keys to snake_case."""
|
|
103
|
+
first = re.sub(r"(.)([A-Z][a-z]+)", r"\1_\2", name)
|
|
104
|
+
second = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", first)
|
|
105
|
+
return second.lower()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _parse_dotnet_date(value: str) -> str | None:
|
|
109
|
+
"""Convert a .NET '/Date(ms-offset)/' string to an ISO 8601 string."""
|
|
110
|
+
match = _DOTNET_DATE.match(value)
|
|
111
|
+
if not match:
|
|
112
|
+
return None
|
|
113
|
+
millis = int(match.group(1))
|
|
114
|
+
offset = match.group(2)
|
|
115
|
+
if offset:
|
|
116
|
+
sign = 1 if offset[0] == "+" else -1
|
|
117
|
+
hours = int(offset[1:3])
|
|
118
|
+
minutes = int(offset[3:5])
|
|
119
|
+
tz = timezone(sign * timedelta(hours=hours, minutes=minutes))
|
|
120
|
+
else:
|
|
121
|
+
tz = timezone.utc
|
|
122
|
+
moment = datetime.fromtimestamp(millis / 1000, tz=tz)
|
|
123
|
+
return moment.isoformat()
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def normalize(value: Any) -> Any:
|
|
127
|
+
"""Recursively normalize Bing JSON values.
|
|
128
|
+
|
|
129
|
+
Rules: drop '__type', snake_case keys, convert .NET dates.
|
|
130
|
+
"""
|
|
131
|
+
if isinstance(value, dict):
|
|
132
|
+
result = {}
|
|
133
|
+
for key, item in value.items():
|
|
134
|
+
if key == "__type":
|
|
135
|
+
continue
|
|
136
|
+
result[_to_snake(key)] = normalize(item)
|
|
137
|
+
return result
|
|
138
|
+
if isinstance(value, list):
|
|
139
|
+
return [normalize(item) for item in value]
|
|
140
|
+
if isinstance(value, str):
|
|
141
|
+
parsed = _parse_dotnet_date(value)
|
|
142
|
+
return parsed if parsed else value
|
|
143
|
+
return value
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _as_rows(value: Any) -> list[dict]:
|
|
147
|
+
"""Return a list of rows from a Bing response value."""
|
|
148
|
+
if value is None:
|
|
149
|
+
return []
|
|
150
|
+
if isinstance(value, dict):
|
|
151
|
+
return [value]
|
|
152
|
+
if isinstance(value, list):
|
|
153
|
+
return value
|
|
154
|
+
return []
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# ---------------------------------------------------------------------------
|
|
158
|
+
# HTTP transport
|
|
159
|
+
# ---------------------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
def set_transport(transport: httpx.BaseTransport | None) -> None:
|
|
162
|
+
"""Override the HTTP transport. Used by tests."""
|
|
163
|
+
global _transport
|
|
164
|
+
_transport = transport
|
|
165
|
+
clear_cache()
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def clear_cache() -> None:
|
|
169
|
+
"""Drop cached responses."""
|
|
170
|
+
_cache.clear()
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _cache_key(method: str, params: dict | None) -> str:
|
|
174
|
+
return method + "|" + json.dumps(params or {}, sort_keys=True)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _error_hint(status: int, message: str = "") -> str:
|
|
178
|
+
lowered = message.lower()
|
|
179
|
+
if "invalidapikey" in lowered or "invalid api key" in lowered:
|
|
180
|
+
return (
|
|
181
|
+
"Bing rejected the API key. BING_WEBMASTER_API_KEY must come from "
|
|
182
|
+
"Bing Webmaster Tools -> Settings -> API Access. "
|
|
183
|
+
"The site must also be verified in the same account."
|
|
184
|
+
)
|
|
185
|
+
if "quota" in lowered or "throttl" in lowered:
|
|
186
|
+
return "Bing rate limit or daily quota reached. Wait before retrying."
|
|
187
|
+
if status in (401, 403):
|
|
188
|
+
return (
|
|
189
|
+
"Check BING_WEBMASTER_API_KEY. The key must come from "
|
|
190
|
+
"Bing Webmaster Tools -> Settings -> API Access. "
|
|
191
|
+
"The site must also be verified in the same account."
|
|
192
|
+
)
|
|
193
|
+
if status == 404:
|
|
194
|
+
return (
|
|
195
|
+
"Check the site_url value. Run bing_list_sites to get the exact "
|
|
196
|
+
"URL, including the trailing slash."
|
|
197
|
+
)
|
|
198
|
+
if status == 429:
|
|
199
|
+
return "Bing rate limit or daily quota reached. Wait before retrying."
|
|
200
|
+
if status == 400:
|
|
201
|
+
return "Check the method parameters. Bing rejected the request."
|
|
202
|
+
return ""
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _request(
|
|
206
|
+
method: str,
|
|
207
|
+
params: dict | None = None,
|
|
208
|
+
body: dict | None = None,
|
|
209
|
+
use_cache: bool = True,
|
|
210
|
+
) -> Any:
|
|
211
|
+
"""Call one Bing Webmaster API method and return normalized data."""
|
|
212
|
+
key = _cache_key(method, params)
|
|
213
|
+
if use_cache and body is None:
|
|
214
|
+
cached = _cache.get(key)
|
|
215
|
+
if cached and cached[0] > time.time():
|
|
216
|
+
return cached[1]
|
|
217
|
+
|
|
218
|
+
query = dict(params or {})
|
|
219
|
+
query["apikey"] = api_key()
|
|
220
|
+
url = f"{API_BASE}/{method}"
|
|
221
|
+
|
|
222
|
+
response: httpx.Response | None = None
|
|
223
|
+
for attempt in range(MAX_RETRIES + 1):
|
|
224
|
+
try:
|
|
225
|
+
with httpx.Client(
|
|
226
|
+
timeout=_timeout(), transport=_transport
|
|
227
|
+
) as client:
|
|
228
|
+
response = client.request(
|
|
229
|
+
"POST" if body is not None else "GET",
|
|
230
|
+
url,
|
|
231
|
+
params=query,
|
|
232
|
+
json=body,
|
|
233
|
+
)
|
|
234
|
+
except httpx.HTTPError as exc:
|
|
235
|
+
raise BingAPIError(method, None, f"Network error: {exc}") from exc
|
|
236
|
+
|
|
237
|
+
if response.status_code in (429, 500, 502, 503, 504) and attempt < MAX_RETRIES:
|
|
238
|
+
time.sleep(2 ** attempt)
|
|
239
|
+
continue
|
|
240
|
+
break
|
|
241
|
+
|
|
242
|
+
assert response is not None
|
|
243
|
+
if response.status_code >= 400:
|
|
244
|
+
message = _error_message(response)
|
|
245
|
+
raise BingAPIError(
|
|
246
|
+
method,
|
|
247
|
+
response.status_code,
|
|
248
|
+
message,
|
|
249
|
+
_error_hint(response.status_code, message),
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
try:
|
|
253
|
+
payload = response.json()
|
|
254
|
+
except ValueError as exc:
|
|
255
|
+
raise BingAPIError(method, response.status_code, "Response was not JSON") from exc
|
|
256
|
+
|
|
257
|
+
data = payload.get("d") if isinstance(payload, dict) else payload
|
|
258
|
+
result = normalize(data)
|
|
259
|
+
|
|
260
|
+
if use_cache and body is None:
|
|
261
|
+
_cache[key] = (time.time() + _cache_ttl(), result)
|
|
262
|
+
return result
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _error_message(response: httpx.Response) -> str:
|
|
266
|
+
"""Best-effort error message from a Bing error response."""
|
|
267
|
+
try:
|
|
268
|
+
payload = response.json()
|
|
269
|
+
except ValueError:
|
|
270
|
+
return (response.text or "Unknown error")[:300]
|
|
271
|
+
if isinstance(payload, dict):
|
|
272
|
+
for key in ("error", "message", "Message", "ErrorMessage"):
|
|
273
|
+
value = payload.get(key)
|
|
274
|
+
if isinstance(value, str) and value:
|
|
275
|
+
return value
|
|
276
|
+
if isinstance(value, dict):
|
|
277
|
+
nested = value.get("message") or value.get("Message")
|
|
278
|
+
if nested:
|
|
279
|
+
return str(nested)
|
|
280
|
+
return json.dumps(payload)[:300]
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
# ---------------------------------------------------------------------------
|
|
284
|
+
# API methods
|
|
285
|
+
# ---------------------------------------------------------------------------
|
|
286
|
+
|
|
287
|
+
def list_sites() -> list[dict]:
|
|
288
|
+
"""List all sites for the authenticated user."""
|
|
289
|
+
return _as_rows(_request("GetUserSites"))
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def get_query_stats(site_url: str) -> list[dict]:
|
|
293
|
+
"""Top queries with clicks, impressions, and average positions."""
|
|
294
|
+
return _as_rows(_request("GetQueryStats", {"siteUrl": site_url}))
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def get_page_stats(site_url: str) -> list[dict]:
|
|
298
|
+
"""Top pages with clicks, impressions, and average positions."""
|
|
299
|
+
return _as_rows(_request("GetPageStats", {"siteUrl": site_url}))
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def get_rank_and_traffic_stats(site_url: str) -> list[dict]:
|
|
303
|
+
"""Daily clicks and impressions for a site."""
|
|
304
|
+
return _as_rows(_request("GetRankAndTrafficStats", {"siteUrl": site_url}))
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def get_query_pages(site_url: str, query: str) -> list[dict]:
|
|
308
|
+
"""Pages that rank for one query."""
|
|
309
|
+
return _as_rows(
|
|
310
|
+
_request("GetQueryPageStats", {"siteUrl": site_url, "query": query})
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def get_page_queries(site_url: str, page: str) -> list[dict]:
|
|
315
|
+
"""Queries that a page ranks for."""
|
|
316
|
+
return _as_rows(
|
|
317
|
+
_request("GetPageQueryStats", {"siteUrl": site_url, "page": page})
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def get_url_info(site_url: str, url: str) -> dict:
|
|
322
|
+
"""Crawl and index details for one URL."""
|
|
323
|
+
data = _request("GetUrlInfo", {"siteUrl": site_url, "url": url})
|
|
324
|
+
return data if isinstance(data, dict) else {}
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def get_crawl_stats(site_url: str) -> Any:
|
|
328
|
+
"""Crawl statistics for a site."""
|
|
329
|
+
return _request("GetCrawlStats", {"siteUrl": site_url})
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def get_crawl_issues(site_url: str) -> list[dict]:
|
|
333
|
+
"""Crawl issues reported for a site."""
|
|
334
|
+
return _as_rows(_request("GetCrawlIssues", {"siteUrl": site_url}))
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def get_link_counts(site_url: str, page: int = 0) -> Any:
|
|
338
|
+
"""Inbound link counts for site pages."""
|
|
339
|
+
return _request("GetLinkCounts", {"siteUrl": site_url, "page": page})
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def get_feeds(site_url: str) -> list[dict]:
|
|
343
|
+
"""List submitted sitemaps and feeds for a site."""
|
|
344
|
+
return _as_rows(_request("GetFeeds", {"siteUrl": site_url}))
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _normalize_language(language: str, country: str) -> str:
|
|
348
|
+
"""Return a Bing IETF language tag such as en-US.
|
|
349
|
+
|
|
350
|
+
Bing rejects a bare code like 'en'. When the caller passes one, add the
|
|
351
|
+
country code. When the tag already has a region, keep it.
|
|
352
|
+
"""
|
|
353
|
+
language = (language or "").strip()
|
|
354
|
+
country = (country or "").strip().upper()
|
|
355
|
+
if not language:
|
|
356
|
+
language = "en"
|
|
357
|
+
if "-" in language:
|
|
358
|
+
return language
|
|
359
|
+
return f"{language}-{country}" if country else language
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def get_keyword_stats(
|
|
363
|
+
query: str,
|
|
364
|
+
country: str = "us",
|
|
365
|
+
language: str = "en-US",
|
|
366
|
+
) -> list[dict]:
|
|
367
|
+
"""Historical keyword statistics."""
|
|
368
|
+
params = {
|
|
369
|
+
"q": query,
|
|
370
|
+
"country": country.lower(),
|
|
371
|
+
"language": _normalize_language(language, country),
|
|
372
|
+
}
|
|
373
|
+
return _as_rows(_request("GetKeywordStats", params))
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def get_related_keywords(
|
|
377
|
+
query: str,
|
|
378
|
+
country: str = "us",
|
|
379
|
+
language: str = "en-US",
|
|
380
|
+
start_date: str | None = None,
|
|
381
|
+
end_date: str | None = None,
|
|
382
|
+
) -> list[dict]:
|
|
383
|
+
"""Related keywords for a seed term."""
|
|
384
|
+
params: dict[str, Any] = {
|
|
385
|
+
"q": query,
|
|
386
|
+
"country": country.lower(),
|
|
387
|
+
"language": _normalize_language(language, country),
|
|
388
|
+
}
|
|
389
|
+
if start_date:
|
|
390
|
+
params["startDate"] = start_date
|
|
391
|
+
if end_date:
|
|
392
|
+
params["endDate"] = end_date
|
|
393
|
+
return _as_rows(_request("GetRelatedKeywords", params))
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
# ---------------------------------------------------------------------------
|
|
397
|
+
# Google vs Bing query comparison
|
|
398
|
+
# ---------------------------------------------------------------------------
|
|
399
|
+
|
|
400
|
+
def _query_key(row: dict) -> str:
|
|
401
|
+
query = row.get("query", "")
|
|
402
|
+
return str(query).strip().lower()
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def compare_queries(
|
|
406
|
+
bing_rows: list[dict],
|
|
407
|
+
gsc_rows: list[dict],
|
|
408
|
+
limit: int = 25,
|
|
409
|
+
) -> dict:
|
|
410
|
+
"""Join Bing and GSC top-query rows on normalized query text.
|
|
411
|
+
|
|
412
|
+
Both inputs use normalized keys: query, clicks, impressions.
|
|
413
|
+
"""
|
|
414
|
+
bing_map: dict[str, dict] = {}
|
|
415
|
+
for row in bing_rows:
|
|
416
|
+
key = _query_key(row)
|
|
417
|
+
if key:
|
|
418
|
+
bing_map[key] = row
|
|
419
|
+
|
|
420
|
+
gsc_map: dict[str, dict] = {}
|
|
421
|
+
for row in gsc_rows:
|
|
422
|
+
key = _query_key(row)
|
|
423
|
+
if key:
|
|
424
|
+
gsc_map[key] = row
|
|
425
|
+
|
|
426
|
+
shared: list[dict] = []
|
|
427
|
+
for key in set(bing_map) & set(gsc_map):
|
|
428
|
+
bing = bing_map[key]
|
|
429
|
+
gsc = gsc_map[key]
|
|
430
|
+
bing_clicks = int(bing.get("clicks", 0) or 0)
|
|
431
|
+
gsc_clicks = int(gsc.get("clicks", 0) or 0)
|
|
432
|
+
bing_position = bing.get("avg_impression_position")
|
|
433
|
+
if bing_position is None:
|
|
434
|
+
bing_position = bing.get("avg_click_position")
|
|
435
|
+
shared.append({
|
|
436
|
+
"query": key,
|
|
437
|
+
"bing_clicks": bing_clicks,
|
|
438
|
+
"gsc_clicks": gsc_clicks,
|
|
439
|
+
"click_gap": bing_clicks - gsc_clicks,
|
|
440
|
+
"bing_impressions": int(bing.get("impressions", 0) or 0),
|
|
441
|
+
"gsc_impressions": int(gsc.get("impressions", 0) or 0),
|
|
442
|
+
"bing_position": bing_position,
|
|
443
|
+
"gsc_position": gsc.get("position"),
|
|
444
|
+
})
|
|
445
|
+
|
|
446
|
+
shared.sort(key=lambda item: abs(item["click_gap"]), reverse=True)
|
|
447
|
+
|
|
448
|
+
return {
|
|
449
|
+
"shared": shared[:limit],
|
|
450
|
+
"bing_only": sorted(
|
|
451
|
+
bing_map.keys() - gsc_map.keys(),
|
|
452
|
+
key=lambda key: int(bing_map[key].get("clicks", 0) or 0),
|
|
453
|
+
reverse=True,
|
|
454
|
+
)[:limit],
|
|
455
|
+
"gsc_only": sorted(
|
|
456
|
+
gsc_map.keys() - bing_map.keys(),
|
|
457
|
+
key=lambda key: int(gsc_map[key].get("clicks", 0) or 0),
|
|
458
|
+
reverse=True,
|
|
459
|
+
)[:limit],
|
|
460
|
+
"bing_query_count": len(bing_map),
|
|
461
|
+
"gsc_query_count": len(gsc_map),
|
|
462
|
+
"shared_query_count": len(set(bing_map) & set(gsc_map)),
|
|
463
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
from google.ads.googleads import oauth2
|
|
4
|
+
from google.ads.googleads.client import GoogleAdsClient
|
|
5
|
+
from google.oauth2 import service_account
|
|
6
|
+
|
|
7
|
+
_client: GoogleAdsClient | None = None
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _expand_path(path: str | None) -> str | None:
|
|
11
|
+
if not path:
|
|
12
|
+
return None
|
|
13
|
+
return os.path.expandvars(os.path.expanduser(path))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _load_service_account_creds() -> service_account.Credentials | None:
|
|
17
|
+
"""Try to load service account credentials from env vars or file.
|
|
18
|
+
|
|
19
|
+
Priority: GOOGLE_ADS_JSON_KEY_FILE (file path), then individual env vars.
|
|
20
|
+
"""
|
|
21
|
+
json_key_file = _expand_path(os.environ.get("GOOGLE_ADS_JSON_KEY_FILE"))
|
|
22
|
+
if json_key_file:
|
|
23
|
+
if not os.path.exists(json_key_file):
|
|
24
|
+
raise FileNotFoundError(
|
|
25
|
+
f"GOOGLE_ADS_JSON_KEY_FILE is set to {json_key_file!r} but does not exist."
|
|
26
|
+
)
|
|
27
|
+
return service_account.Credentials.from_service_account_file(
|
|
28
|
+
json_key_file, scopes=["https://www.googleapis.com/auth/adwords"],
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
email = os.environ.get("GOOGLE_SERVICE_ACCOUNT_EMAIL")
|
|
32
|
+
key = os.environ.get("GOOGLE_SERVICE_ACCOUNT_KEY")
|
|
33
|
+
if email and key:
|
|
34
|
+
info = {
|
|
35
|
+
"type": "service_account",
|
|
36
|
+
"client_email": email,
|
|
37
|
+
"private_key": key.replace("\\n", "\n"),
|
|
38
|
+
"private_key_id": os.environ.get("GOOGLE_SERVICE_ACCOUNT_KEY_ID", ""),
|
|
39
|
+
"client_id": os.environ.get("GOOGLE_SERVICE_ACCOUNT_CLIENT_ID", ""),
|
|
40
|
+
"project_id": os.environ.get("GOOGLE_SERVICE_ACCOUNT_PROJECT_ID", ""),
|
|
41
|
+
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
|
42
|
+
"token_uri": "https://oauth2.googleapis.com/token",
|
|
43
|
+
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
|
|
44
|
+
}
|
|
45
|
+
return service_account.Credentials.from_service_account_info(
|
|
46
|
+
info, scopes=["https://www.googleapis.com/auth/adwords"],
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def get_client() -> GoogleAdsClient:
|
|
53
|
+
global _client
|
|
54
|
+
if _client is not None:
|
|
55
|
+
return _client
|
|
56
|
+
|
|
57
|
+
developer_token = os.environ.get("GOOGLE_ADS_DEVELOPER_TOKEN")
|
|
58
|
+
if not developer_token:
|
|
59
|
+
raise ValueError("GOOGLE_ADS_DEVELOPER_TOKEN environment variable is required")
|
|
60
|
+
|
|
61
|
+
sa_creds = _load_service_account_creds()
|
|
62
|
+
if sa_creds:
|
|
63
|
+
creds = sa_creds
|
|
64
|
+
else:
|
|
65
|
+
refresh_token = os.environ.get("GOOGLE_ADS_REFRESH_TOKEN")
|
|
66
|
+
client_id = os.environ.get("GOOGLE_ADS_CLIENT_ID")
|
|
67
|
+
client_secret = os.environ.get("GOOGLE_ADS_CLIENT_SECRET")
|
|
68
|
+
|
|
69
|
+
if refresh_token and client_id and client_secret:
|
|
70
|
+
from google.oauth2.credentials import Credentials
|
|
71
|
+
|
|
72
|
+
creds = Credentials(
|
|
73
|
+
token=refresh_token,
|
|
74
|
+
client_id=client_id,
|
|
75
|
+
client_secret=client_secret,
|
|
76
|
+
token_uri="https://accounts.google.com/o/oauth2/token",
|
|
77
|
+
)
|
|
78
|
+
else:
|
|
79
|
+
creds = oauth2.get_application_default_credentials()
|
|
80
|
+
|
|
81
|
+
kwargs: dict = {
|
|
82
|
+
"credentials": creds,
|
|
83
|
+
"developer_token": developer_token,
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
login_customer_id = os.environ.get("GOOGLE_ADS_LOGIN_CUSTOMER_ID")
|
|
87
|
+
if login_customer_id:
|
|
88
|
+
kwargs["login_customer_id"] = login_customer_id
|
|
89
|
+
|
|
90
|
+
_client = GoogleAdsClient(**kwargs)
|
|
91
|
+
return _client
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from functools import lru_cache
|
|
3
|
+
|
|
4
|
+
from google.analytics.data_v1beta import BetaAnalyticsDataClient
|
|
5
|
+
from google.oauth2 import service_account
|
|
6
|
+
|
|
7
|
+
SCOPES = ["https://www.googleapis.com/auth/analytics.readonly"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _expand_path(path: str | None) -> str | None:
|
|
11
|
+
if not path:
|
|
12
|
+
return None
|
|
13
|
+
return os.path.expandvars(os.path.expanduser(path))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def load_service_account_creds() -> service_account.Credentials | None:
|
|
17
|
+
"""Try to load service account credentials from env vars or file.
|
|
18
|
+
|
|
19
|
+
Uses the same GOOGLE_SERVICE_ACCOUNT_* env vars as the other clients,
|
|
20
|
+
plus a dedicated GA4_CREDENTIALS_PATH or the shared GSC_CREDENTIALS_PATH.
|
|
21
|
+
"""
|
|
22
|
+
creds_path = _expand_path(
|
|
23
|
+
os.environ.get("GA4_CREDENTIALS_PATH")
|
|
24
|
+
or os.environ.get("GSC_CREDENTIALS_PATH")
|
|
25
|
+
or os.environ.get("GOOGLE_ADS_JSON_KEY_FILE")
|
|
26
|
+
)
|
|
27
|
+
if creds_path:
|
|
28
|
+
if not os.path.exists(creds_path):
|
|
29
|
+
raise FileNotFoundError(
|
|
30
|
+
f"Credential file set to {creds_path!r} but does not exist."
|
|
31
|
+
)
|
|
32
|
+
return service_account.Credentials.from_service_account_file(
|
|
33
|
+
creds_path, scopes=SCOPES,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
email = os.environ.get("GOOGLE_SERVICE_ACCOUNT_EMAIL")
|
|
37
|
+
key = os.environ.get("GOOGLE_SERVICE_ACCOUNT_KEY")
|
|
38
|
+
if email and key:
|
|
39
|
+
info = {
|
|
40
|
+
"type": "service_account",
|
|
41
|
+
"client_email": email,
|
|
42
|
+
"private_key": key.replace("\\n", "\n"),
|
|
43
|
+
"private_key_id": os.environ.get("GOOGLE_SERVICE_ACCOUNT_KEY_ID", ""),
|
|
44
|
+
"client_id": os.environ.get("GOOGLE_SERVICE_ACCOUNT_CLIENT_ID", ""),
|
|
45
|
+
"project_id": os.environ.get("GOOGLE_SERVICE_ACCOUNT_PROJECT_ID", ""),
|
|
46
|
+
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
|
47
|
+
"token_uri": "https://oauth2.googleapis.com/token",
|
|
48
|
+
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
|
|
49
|
+
}
|
|
50
|
+
return service_account.Credentials.from_service_account_info(
|
|
51
|
+
info, scopes=SCOPES,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@lru_cache(maxsize=1)
|
|
58
|
+
def get_client() -> BetaAnalyticsDataClient:
|
|
59
|
+
"""Get a cached GA4 BetaAnalyticsDataClient.
|
|
60
|
+
|
|
61
|
+
Tries in order: service account (env vars) -> ADC (gcloud).
|
|
62
|
+
"""
|
|
63
|
+
creds = load_service_account_creds()
|
|
64
|
+
if creds:
|
|
65
|
+
return BetaAnalyticsDataClient(credentials=creds)
|
|
66
|
+
|
|
67
|
+
# Try Application Default Credentials
|
|
68
|
+
try:
|
|
69
|
+
import google.auth
|
|
70
|
+
|
|
71
|
+
adc_creds, _ = google.auth.default(scopes=SCOPES)
|
|
72
|
+
if adc_creds:
|
|
73
|
+
return BetaAnalyticsDataClient(credentials=adc_creds)
|
|
74
|
+
except Exception:
|
|
75
|
+
pass
|
|
76
|
+
|
|
77
|
+
raise ValueError(
|
|
78
|
+
"No GA4 authentication configured.\n\n"
|
|
79
|
+
"Use the same service account as Google Ads / Search Console:\n"
|
|
80
|
+
" - Set GOOGLE_SERVICE_ACCOUNT_EMAIL and GOOGLE_SERVICE_ACCOUNT_KEY in .env\n"
|
|
81
|
+
" - Ensure the service account has the 'analytics.readonly' scope\n"
|
|
82
|
+
" - Add the service account email to your GA4 property\n"
|
|
83
|
+
" (Admin → Account Access Management → Add user → View-only)\n\n"
|
|
84
|
+
"Or use ADC:\n"
|
|
85
|
+
" gcloud auth application-default login \\\n"
|
|
86
|
+
" --scopes=https://www.googleapis.com/auth/analytics.readonly"
|
|
87
|
+
)
|