seo-stack-mcp 0.1.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.
- seo_stack_mcp/__init__.py +0 -0
- seo_stack_mcp/bing/__init__.py +0 -0
- seo_stack_mcp/bing/client.py +303 -0
- seo_stack_mcp/bing/tools.py +834 -0
- seo_stack_mcp/clarity/__init__.py +0 -0
- seo_stack_mcp/clarity/cache.py +102 -0
- seo_stack_mcp/clarity/client.py +165 -0
- seo_stack_mcp/clarity/quota.py +131 -0
- seo_stack_mcp/clarity/tools.py +431 -0
- seo_stack_mcp/ga4/__init__.py +0 -0
- seo_stack_mcp/ga4/client.py +186 -0
- seo_stack_mcp/ga4/tools.py +582 -0
- seo_stack_mcp/google_auth.py +104 -0
- seo_stack_mcp/gsc/__init__.py +0 -0
- seo_stack_mcp/gsc/client.py +80 -0
- seo_stack_mcp/gsc/tools.py +779 -0
- seo_stack_mcp/server.py +69 -0
- seo_stack_mcp-0.1.0.dist-info/METADATA +178 -0
- seo_stack_mcp-0.1.0.dist-info/RECORD +22 -0
- seo_stack_mcp-0.1.0.dist-info/WHEEL +4 -0
- seo_stack_mcp-0.1.0.dist-info/entry_points.txt +2 -0
- seo_stack_mcp-0.1.0.dist-info/licenses/LICENSE +21 -0
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
"""Bing Webmaster Tools API client.
|
|
2
|
+
|
|
3
|
+
Thin async wrapper around the Bing Webmaster JSON API
|
|
4
|
+
(https://ssl.bing.com/webmaster/api.svc/json), using httpx.
|
|
5
|
+
|
|
6
|
+
Responses are wrapped in :class:`ApiObject` instances that expose the
|
|
7
|
+
API's PascalCase fields as snake_case attributes (``AvgClickPosition``
|
|
8
|
+
-> ``avg_click_position``) and parse WCF ``/Date(ms)/`` strings into
|
|
9
|
+
``datetime`` objects, so callers can use plain attribute access.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
from typing import Any, Optional
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
|
|
19
|
+
API_BASE = "https://ssl.bing.com/webmaster/api.svc/json"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class BingWebmasterError(Exception):
|
|
23
|
+
"""Error returned by the Bing Webmaster API (or missing configuration)."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, message: str, error_code: Any = None):
|
|
26
|
+
super().__init__(message)
|
|
27
|
+
self.message = message
|
|
28
|
+
self.error_code = error_code
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
# Response conversion
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
_WCF_DATE_RE = re.compile(r"^/Date\((-?\d+)(?:[+-]\d{4})?\)/$")
|
|
36
|
+
_CAMEL_RE = re.compile(r"(?<!^)(?=[A-Z])")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _snake(name: str) -> str:
|
|
40
|
+
"""Convert a PascalCase API field name to snake_case."""
|
|
41
|
+
return _CAMEL_RE.sub("_", name).lower()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _convert(value):
|
|
45
|
+
"""Recursively convert an API JSON value (objects, lists, WCF dates)."""
|
|
46
|
+
if isinstance(value, dict):
|
|
47
|
+
return ApiObject(value)
|
|
48
|
+
if isinstance(value, list):
|
|
49
|
+
return [_convert(v) for v in value]
|
|
50
|
+
if isinstance(value, str):
|
|
51
|
+
m = _WCF_DATE_RE.match(value)
|
|
52
|
+
if m:
|
|
53
|
+
return datetime.fromtimestamp(
|
|
54
|
+
int(m.group(1)) / 1000, tz=timezone.utc
|
|
55
|
+
).replace(tzinfo=None)
|
|
56
|
+
return value
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ApiObject:
|
|
60
|
+
"""Attribute-access wrapper over an API JSON object (snake_case keys)."""
|
|
61
|
+
|
|
62
|
+
def __init__(self, data: dict):
|
|
63
|
+
self._data = {
|
|
64
|
+
_snake(k): _convert(v)
|
|
65
|
+
for k, v in data.items()
|
|
66
|
+
if not k.startswith("__") # drop WCF "__type" metadata
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
def __getattr__(self, name: str):
|
|
70
|
+
try:
|
|
71
|
+
return self._data[name]
|
|
72
|
+
except KeyError:
|
|
73
|
+
raise AttributeError(name) from None
|
|
74
|
+
|
|
75
|
+
def __repr__(self) -> str:
|
|
76
|
+
return f"ApiObject({self._data!r})"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ---------------------------------------------------------------------------
|
|
80
|
+
# Client
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
class BingWebmasterClient:
|
|
84
|
+
"""Async client for the Bing Webmaster Tools JSON API."""
|
|
85
|
+
|
|
86
|
+
def __init__(self, api_key: str):
|
|
87
|
+
self._api_key = api_key
|
|
88
|
+
self._http = httpx.AsyncClient(timeout=30.0)
|
|
89
|
+
|
|
90
|
+
async def _request(self, method: str, params: dict, json_body: Optional[dict] = None):
|
|
91
|
+
query = {k: v for k, v in params.items() if v is not None}
|
|
92
|
+
query["apikey"] = self._api_key
|
|
93
|
+
try:
|
|
94
|
+
if json_body is None:
|
|
95
|
+
resp = await self._http.get(f"{API_BASE}/{method}", params=query)
|
|
96
|
+
else:
|
|
97
|
+
resp = await self._http.post(
|
|
98
|
+
f"{API_BASE}/{method}", params=query, json=json_body
|
|
99
|
+
)
|
|
100
|
+
except httpx.HTTPError as e:
|
|
101
|
+
raise BingWebmasterError(f"HTTP request failed: {e}") from e
|
|
102
|
+
|
|
103
|
+
if resp.status_code >= 400:
|
|
104
|
+
message = resp.text
|
|
105
|
+
code: Any = resp.status_code
|
|
106
|
+
try:
|
|
107
|
+
err = resp.json()
|
|
108
|
+
if isinstance(err, dict):
|
|
109
|
+
message = err.get("Message") or err.get("message") or message
|
|
110
|
+
code = err.get("ErrorCode", code)
|
|
111
|
+
except ValueError:
|
|
112
|
+
pass
|
|
113
|
+
raise BingWebmasterError(message, error_code=code)
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
data = resp.json()
|
|
117
|
+
except ValueError:
|
|
118
|
+
raise BingWebmasterError(f"Invalid JSON response from {method}") from None
|
|
119
|
+
if isinstance(data, dict) and "d" in data:
|
|
120
|
+
data = data["d"]
|
|
121
|
+
return _convert(data)
|
|
122
|
+
|
|
123
|
+
async def _get(self, method: str, **params):
|
|
124
|
+
return await self._request(method, params)
|
|
125
|
+
|
|
126
|
+
async def _post(self, method: str, body: dict):
|
|
127
|
+
return await self._request(method, {}, json_body=body)
|
|
128
|
+
|
|
129
|
+
@staticmethod
|
|
130
|
+
def _date(dt: datetime) -> str:
|
|
131
|
+
return dt.strftime("%Y-%m-%d")
|
|
132
|
+
|
|
133
|
+
# ── Sites ───────────────────────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
async def get_sites(self):
|
|
136
|
+
"""List all sites verified in the account."""
|
|
137
|
+
return await self._get("GetUserSites") or []
|
|
138
|
+
|
|
139
|
+
# ── Traffic analytics ───────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
async def get_query_stats(self, site_url: str):
|
|
142
|
+
return await self._get("GetQueryStats", siteUrl=site_url) or []
|
|
143
|
+
|
|
144
|
+
async def get_page_stats(self, site_url: str):
|
|
145
|
+
return await self._get("GetPageStats", siteUrl=site_url) or []
|
|
146
|
+
|
|
147
|
+
async def get_rank_and_traffic_stats(self, site_url: str):
|
|
148
|
+
return await self._get("GetRankAndTrafficStats", siteUrl=site_url) or []
|
|
149
|
+
|
|
150
|
+
async def get_page_query_stats(self, site_url: str, page: str):
|
|
151
|
+
return await self._get("GetPageQueryStats", siteUrl=site_url, page=page) or []
|
|
152
|
+
|
|
153
|
+
async def get_query_page_stats(self, site_url: str, query: str):
|
|
154
|
+
return await self._get("GetQueryPageStats", siteUrl=site_url, query=query) or []
|
|
155
|
+
|
|
156
|
+
async def get_query_page_detail_stats(self, site_url: str, query: str, page: str):
|
|
157
|
+
return (
|
|
158
|
+
await self._get(
|
|
159
|
+
"GetQueryPageDetailStats", siteUrl=site_url, query=query, page=page
|
|
160
|
+
)
|
|
161
|
+
or []
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
async def get_query_traffic_stats(self, site_url: str, query: str):
|
|
165
|
+
return (
|
|
166
|
+
await self._get("GetQueryTrafficStats", siteUrl=site_url, query=query) or []
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
# ── Crawl statistics ────────────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
async def get_crawl_stats(self, site_url: str):
|
|
172
|
+
return await self._get("GetCrawlStats", siteUrl=site_url) or []
|
|
173
|
+
|
|
174
|
+
async def get_crawl_issues(self, site_url: str):
|
|
175
|
+
return await self._get("GetCrawlIssues", siteUrl=site_url) or []
|
|
176
|
+
|
|
177
|
+
# ── Keyword research ────────────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
async def get_keyword_stats(self, keyword: str, country: str, language: str):
|
|
180
|
+
return (
|
|
181
|
+
await self._get(
|
|
182
|
+
"GetKeywordStats", q=keyword, country=country, language=language
|
|
183
|
+
)
|
|
184
|
+
or []
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
async def get_related_keywords(
|
|
188
|
+
self,
|
|
189
|
+
keyword: str,
|
|
190
|
+
country: str,
|
|
191
|
+
language: str,
|
|
192
|
+
start_date: datetime,
|
|
193
|
+
end_date: datetime,
|
|
194
|
+
):
|
|
195
|
+
return (
|
|
196
|
+
await self._get(
|
|
197
|
+
"GetRelatedKeywords",
|
|
198
|
+
q=keyword,
|
|
199
|
+
country=country,
|
|
200
|
+
language=language,
|
|
201
|
+
startDate=self._date(start_date),
|
|
202
|
+
endDate=self._date(end_date),
|
|
203
|
+
)
|
|
204
|
+
or []
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
async def get_keyword(
|
|
208
|
+
self,
|
|
209
|
+
query: str,
|
|
210
|
+
country: str,
|
|
211
|
+
language: str,
|
|
212
|
+
start_date: datetime,
|
|
213
|
+
end_date: datetime,
|
|
214
|
+
):
|
|
215
|
+
return await self._get(
|
|
216
|
+
"GetKeyword",
|
|
217
|
+
q=query,
|
|
218
|
+
country=country,
|
|
219
|
+
language=language,
|
|
220
|
+
startDate=self._date(start_date),
|
|
221
|
+
endDate=self._date(end_date),
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
# ── URL info ────────────────────────────────────────────────────────
|
|
225
|
+
|
|
226
|
+
async def get_url_info(self, site_url: str, url: str):
|
|
227
|
+
return await self._get("GetUrlInfo", siteUrl=site_url, url=url)
|
|
228
|
+
|
|
229
|
+
async def get_url_traffic_info(self, site_url: str, url: str):
|
|
230
|
+
return await self._get("GetUrlTrafficInfo", siteUrl=site_url, url=url)
|
|
231
|
+
|
|
232
|
+
# ── URL submission ──────────────────────────────────────────────────
|
|
233
|
+
|
|
234
|
+
async def submit_url(self, site_url: str, url: str):
|
|
235
|
+
return await self._post("SubmitUrl", {"siteUrl": site_url, "url": url})
|
|
236
|
+
|
|
237
|
+
async def submit_url_batch(self, site_url: str, urls: list):
|
|
238
|
+
return await self._post(
|
|
239
|
+
"SubmitUrlBatch", {"siteUrl": site_url, "urlList": urls}
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
async def get_url_submission_quota(self, site_url: str):
|
|
243
|
+
return await self._get("GetUrlSubmissionQuota", siteUrl=site_url)
|
|
244
|
+
|
|
245
|
+
# ── Sitemaps / feeds ────────────────────────────────────────────────
|
|
246
|
+
|
|
247
|
+
async def get_feeds(self, site_url: str):
|
|
248
|
+
return await self._get("GetFeeds", siteUrl=site_url) or []
|
|
249
|
+
|
|
250
|
+
async def submit_feed(self, site_url: str, feed_url: str):
|
|
251
|
+
return await self._post(
|
|
252
|
+
"SubmitFeed", {"siteUrl": site_url, "feedUrl": feed_url}
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
# ── Backlinks ───────────────────────────────────────────────────────
|
|
256
|
+
|
|
257
|
+
async def get_link_counts(self, site_url: str, page: int = 0):
|
|
258
|
+
return await self._get("GetLinkCounts", siteUrl=site_url, page=page)
|
|
259
|
+
|
|
260
|
+
async def get_url_links(self, site_url: str, url: str, page: int = 0):
|
|
261
|
+
return await self._get("GetUrlLinks", siteUrl=site_url, link=url, page=page)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
# ---------------------------------------------------------------------------
|
|
265
|
+
# Module-level helpers
|
|
266
|
+
# ---------------------------------------------------------------------------
|
|
267
|
+
|
|
268
|
+
_client: Optional[BingWebmasterClient] = None
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
async def get_client() -> BingWebmasterClient:
|
|
272
|
+
"""Get or create the singleton BingWebmasterClient."""
|
|
273
|
+
global _client
|
|
274
|
+
if _client is None:
|
|
275
|
+
api_key = os.getenv("BING_WEBMASTER_API_KEY", "")
|
|
276
|
+
if not api_key:
|
|
277
|
+
raise BingWebmasterError(
|
|
278
|
+
"BING_WEBMASTER_API_KEY environment variable is not set. "
|
|
279
|
+
"Generate an API key in Bing Webmaster Tools "
|
|
280
|
+
"(Settings > API access) and export it."
|
|
281
|
+
)
|
|
282
|
+
_client = BingWebmasterClient(api_key)
|
|
283
|
+
return _client
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def get_site_url() -> str:
|
|
287
|
+
"""Get the default site URL from the environment."""
|
|
288
|
+
url = os.getenv("BING_SITE_URL", "")
|
|
289
|
+
if not url:
|
|
290
|
+
raise ValueError(
|
|
291
|
+
"No site URL provided. Pass the site_url parameter or set the "
|
|
292
|
+
"BING_SITE_URL environment variable (e.g. https://example.com)."
|
|
293
|
+
)
|
|
294
|
+
return url
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def format_date(dt) -> str:
|
|
298
|
+
"""Convert a datetime object to a YYYY-MM-DD string."""
|
|
299
|
+
if dt is None:
|
|
300
|
+
return "N/A"
|
|
301
|
+
if isinstance(dt, datetime):
|
|
302
|
+
return dt.strftime("%Y-%m-%d")
|
|
303
|
+
return str(dt)
|