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.
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)