bing-webmaster-ai-cli-mcp 0.1.2__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.
- bing_webmaster_ai_cli_mcp/__init__.py +3 -0
- bing_webmaster_ai_cli_mcp/_serialize.py +64 -0
- bing_webmaster_ai_cli_mcp/apply.py +197 -0
- bing_webmaster_ai_cli_mcp/audit.py +45 -0
- bing_webmaster_ai_cli_mcp/auth.py +42 -0
- bing_webmaster_ai_cli_mcp/cli.py +920 -0
- bing_webmaster_ai_cli_mcp/client.py +230 -0
- bing_webmaster_ai_cli_mcp/config.py +142 -0
- bing_webmaster_ai_cli_mcp/emptiness.py +82 -0
- bing_webmaster_ai_cli_mcp/errors.py +147 -0
- bing_webmaster_ai_cli_mcp/http_server.py +77 -0
- bing_webmaster_ai_cli_mcp/limits.py +85 -0
- bing_webmaster_ai_cli_mcp/mcp_server.py +1153 -0
- bing_webmaster_ai_cli_mcp/ops/__init__.py +1 -0
- bing_webmaster_ai_cli_mcp/ops/_common.py +83 -0
- bing_webmaster_ai_cli_mcp/ops/blocking.py +21 -0
- bing_webmaster_ai_cli_mcp/ops/crawl.py +255 -0
- bing_webmaster_ai_cli_mcp/ops/geo.py +11 -0
- bing_webmaster_ai_cli_mcp/ops/indexnow.py +296 -0
- bing_webmaster_ai_cli_mcp/ops/keywords.py +46 -0
- bing_webmaster_ai_cli_mcp/ops/links.py +27 -0
- bing_webmaster_ai_cli_mcp/ops/params.py +11 -0
- bing_webmaster_ai_cli_mcp/ops/sitemaps.py +20 -0
- bing_webmaster_ai_cli_mcp/ops/sites.py +54 -0
- bing_webmaster_ai_cli_mcp/ops/submission.py +16 -0
- bing_webmaster_ai_cli_mcp/ops/traffic.py +55 -0
- bing_webmaster_ai_cli_mcp/plans.py +355 -0
- bing_webmaster_ai_cli_mcp/render.py +151 -0
- bing_webmaster_ai_cli_mcp/urls.py +87 -0
- bing_webmaster_ai_cli_mcp/writes.py +361 -0
- bing_webmaster_ai_cli_mcp-0.1.2.dist-info/METADATA +436 -0
- bing_webmaster_ai_cli_mcp-0.1.2.dist-info/RECORD +35 -0
- bing_webmaster_ai_cli_mcp-0.1.2.dist-info/WHEEL +4 -0
- bing_webmaster_ai_cli_mcp-0.1.2.dist-info/entry_points.txt +4 -0
- bing_webmaster_ai_cli_mcp-0.1.2.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Serialization for Bing's JSON endpoint and its ASP.NET date representation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from datetime import UTC, datetime, timedelta, timezone
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from .errors import MalformedResponse
|
|
10
|
+
|
|
11
|
+
_TICKS = re.compile(r"^\\?/Date\((?P<ms>-?\d+)(?:(?P<sign>[+-])(?P<hh>\d{2})(?P<mm>\d{2}))?\)\\?/$")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def unwrap(payload: Any) -> Any:
|
|
15
|
+
if not isinstance(payload, dict) or "d" not in payload:
|
|
16
|
+
raise MalformedResponse(
|
|
17
|
+
"response body has no 'd' envelope",
|
|
18
|
+
suggestion="inspect the status and upstream response contract",
|
|
19
|
+
details={"keys": sorted(payload) if isinstance(payload, dict) else None},
|
|
20
|
+
)
|
|
21
|
+
return payload["d"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def parse_bing_datetime(value: str) -> datetime:
|
|
25
|
+
match = _TICKS.fullmatch(value)
|
|
26
|
+
if match is None:
|
|
27
|
+
raise MalformedResponse(
|
|
28
|
+
f"not an ASP.NET tick date: {value!r}",
|
|
29
|
+
suggestion="Bing JSON dates must use /Date(milliseconds±hhmm)/",
|
|
30
|
+
)
|
|
31
|
+
try:
|
|
32
|
+
moment = datetime.fromtimestamp(int(match["ms"]) / 1000, tz=UTC)
|
|
33
|
+
if match["sign"] is None:
|
|
34
|
+
return moment
|
|
35
|
+
if int(match["mm"]) >= 60:
|
|
36
|
+
raise ValueError("timezone minutes must be below 60")
|
|
37
|
+
offset = timedelta(hours=int(match["hh"]), minutes=int(match["mm"]))
|
|
38
|
+
if match["sign"] == "-":
|
|
39
|
+
offset = -offset
|
|
40
|
+
return moment.astimezone(timezone(offset))
|
|
41
|
+
except (OSError, OverflowError, ValueError) as exc:
|
|
42
|
+
# A tick count or an offset outside what datetime can represent.
|
|
43
|
+
raise MalformedResponse(f"tick date is out of range: {value!r}") from exc
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def encode_bing_datetime(value: datetime) -> str:
|
|
47
|
+
if value.tzinfo is None or value.utcoffset() is None:
|
|
48
|
+
raise ValueError("Bing datetimes must be timezone-aware")
|
|
49
|
+
milliseconds = int(value.timestamp() * 1000)
|
|
50
|
+
offset = value.utcoffset() or timedelta(0)
|
|
51
|
+
sign = "+" if offset >= timedelta(0) else "-"
|
|
52
|
+
total_minutes = abs(int(offset.total_seconds() // 60))
|
|
53
|
+
hours, minutes = divmod(total_minutes, 60)
|
|
54
|
+
return f"/Date({milliseconds}{sign}{hours:02d}{minutes:02d})/"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def decode(value: Any) -> Any:
|
|
58
|
+
if isinstance(value, dict):
|
|
59
|
+
return {key: decode(item) for key, item in value.items() if key != "__type"}
|
|
60
|
+
if isinstance(value, list):
|
|
61
|
+
return [decode(item) for item in value]
|
|
62
|
+
if isinstance(value, str) and _TICKS.fullmatch(value):
|
|
63
|
+
return parse_bing_datetime(value)
|
|
64
|
+
return value
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""The only module that executes a mutating Bing API request."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from .audit import AuditLog
|
|
10
|
+
from .client import BingClient
|
|
11
|
+
from .config import Settings
|
|
12
|
+
from .errors import BingWebmasterError, PlanUnknownOutcome
|
|
13
|
+
from .limits import RateLimiter
|
|
14
|
+
from .ops import indexnow
|
|
15
|
+
from .plans import PlanStore, create_write_plan
|
|
16
|
+
from .render import redact, secret_values
|
|
17
|
+
from .writes import prepare_write
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
async def apply_plan(
|
|
21
|
+
plan_id: str,
|
|
22
|
+
*,
|
|
23
|
+
settings: Settings,
|
|
24
|
+
store: PlanStore,
|
|
25
|
+
client: BingClient | None,
|
|
26
|
+
audit: AuditLog,
|
|
27
|
+
limiter: RateLimiter,
|
|
28
|
+
indexnow_transport: httpx.AsyncBaseTransport | None = None,
|
|
29
|
+
) -> dict[str, Any]:
|
|
30
|
+
with store.applying(plan_id) as lease:
|
|
31
|
+
plan = lease.plan
|
|
32
|
+
prepared = prepare_write(plan.operation, plan.args)
|
|
33
|
+
if prepared.method != "IndexNow" and client is None:
|
|
34
|
+
raise RuntimeError("a Bing client is required to apply this plan")
|
|
35
|
+
# Re-checked here, not just at plan creation: the denylist may have grown since,
|
|
36
|
+
# and a plan can sit pending for a whole BING_WM_PLAN_TTL_SECONDS.
|
|
37
|
+
try:
|
|
38
|
+
settings.check_site_allowed(plan.site_url)
|
|
39
|
+
except BingWebmasterError as exc:
|
|
40
|
+
audit.record("plan_apply_denied", plan_id=plan_id, error=exc.to_dict())
|
|
41
|
+
raise
|
|
42
|
+
|
|
43
|
+
# Reserved before the request, not counted after it: a ceiling that refuses once
|
|
44
|
+
# the write has already been sent would leave the plan pending and invite a retry.
|
|
45
|
+
reserved_day = limiter.consume(plan.site_url, prepared.cost)
|
|
46
|
+
try:
|
|
47
|
+
if prepared.method == "IndexNow":
|
|
48
|
+
# Fetching the key file sends no submission, so a failure or an interrupt
|
|
49
|
+
# here is an ordinary failure and stays outside the unknown-outcome zone.
|
|
50
|
+
async with httpx.AsyncClient(
|
|
51
|
+
transport=indexnow_transport, timeout=30.0
|
|
52
|
+
) as preflight:
|
|
53
|
+
await indexnow.verify_key_file(
|
|
54
|
+
preflight,
|
|
55
|
+
prepared.body["host"],
|
|
56
|
+
prepared.body["key"],
|
|
57
|
+
prepared.body["keyLocation"],
|
|
58
|
+
)
|
|
59
|
+
audit.record(
|
|
60
|
+
"plan_apply_attempted",
|
|
61
|
+
plan_id=plan_id,
|
|
62
|
+
operation=plan.operation,
|
|
63
|
+
site=plan.site_url,
|
|
64
|
+
)
|
|
65
|
+
except BingWebmasterError as exc:
|
|
66
|
+
limiter.release(plan.site_url, prepared.cost, day=reserved_day)
|
|
67
|
+
audit.record("plan_apply_failed", plan_id=plan_id, error=exc.to_dict())
|
|
68
|
+
raise
|
|
69
|
+
except BaseException:
|
|
70
|
+
limiter.release(plan.site_url, prepared.cost, day=reserved_day)
|
|
71
|
+
raise
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
if prepared.method == "IndexNow":
|
|
75
|
+
async with httpx.AsyncClient(
|
|
76
|
+
transport=indexnow_transport, timeout=30.0
|
|
77
|
+
) as indexnow_http:
|
|
78
|
+
result = await indexnow.submit(
|
|
79
|
+
indexnow_http,
|
|
80
|
+
prepared.body["host"],
|
|
81
|
+
prepared.body["key"],
|
|
82
|
+
prepared.body["urlList"],
|
|
83
|
+
prepared.body["keyLocation"],
|
|
84
|
+
)
|
|
85
|
+
else:
|
|
86
|
+
result = await client.call(
|
|
87
|
+
prepared.method,
|
|
88
|
+
body=prepared.body,
|
|
89
|
+
mutating=True,
|
|
90
|
+
)
|
|
91
|
+
except PlanUnknownOutcome as exc:
|
|
92
|
+
try:
|
|
93
|
+
store.mark_unknown(plan_id)
|
|
94
|
+
except BaseException:
|
|
95
|
+
lease.preserve_lock()
|
|
96
|
+
raise
|
|
97
|
+
audit.record("plan_apply_unknown", plan_id=plan_id, error=exc.to_dict())
|
|
98
|
+
raise
|
|
99
|
+
except BingWebmasterError as exc:
|
|
100
|
+
# Everything reaching here failed before or during dispatch: a lost or
|
|
101
|
+
# ambiguous response is a PlanUnknownOutcome and was handled above.
|
|
102
|
+
limiter.release(plan.site_url, prepared.cost, day=reserved_day)
|
|
103
|
+
audit.record("plan_apply_failed", plan_id=plan_id, error=exc.to_dict())
|
|
104
|
+
raise
|
|
105
|
+
except BaseException as exc:
|
|
106
|
+
# Ctrl-C or any other interruption once dispatch has begun: the lock is
|
|
107
|
+
# released by the context manager, so a plan left pending would invite a
|
|
108
|
+
# retry of a write that may already have been sent.
|
|
109
|
+
try:
|
|
110
|
+
store.mark_unknown(plan_id)
|
|
111
|
+
except BaseException:
|
|
112
|
+
lease.preserve_lock()
|
|
113
|
+
raise
|
|
114
|
+
audit.record(
|
|
115
|
+
"plan_apply_unknown",
|
|
116
|
+
plan_id=plan_id,
|
|
117
|
+
error={"code": "INTERRUPTED", "type": type(exc).__name__},
|
|
118
|
+
)
|
|
119
|
+
raise
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
store.mark_applied(plan_id)
|
|
123
|
+
except BaseException:
|
|
124
|
+
lease.preserve_lock()
|
|
125
|
+
raise
|
|
126
|
+
audit.record("plan_apply_succeeded", plan_id=plan_id)
|
|
127
|
+
return {
|
|
128
|
+
"applied": True,
|
|
129
|
+
"plan_id": plan_id,
|
|
130
|
+
"operation": plan.operation,
|
|
131
|
+
# A write leaves by this door, and reads leave by ``ops._common.fetch``.
|
|
132
|
+
# Both are redacted, so neither is the way a code Bing echoed back reaches a
|
|
133
|
+
# transcript. The literals we sent are hidden too, not only the named fields.
|
|
134
|
+
"result": redact(result, secret_values(prepared.body)),
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
async def execute_write(
|
|
139
|
+
operation: str,
|
|
140
|
+
args: dict[str, Any],
|
|
141
|
+
*,
|
|
142
|
+
settings: Settings,
|
|
143
|
+
client: BingClient | None,
|
|
144
|
+
audit: AuditLog,
|
|
145
|
+
limiter: RateLimiter,
|
|
146
|
+
indexnow_transport: httpx.AsyncBaseTransport | None = None,
|
|
147
|
+
) -> dict[str, Any]:
|
|
148
|
+
"""Record a plan and apply it in the same call.
|
|
149
|
+
|
|
150
|
+
The one-step path is not a second write implementation: it still creates the durable
|
|
151
|
+
plan record and then goes through ``apply_plan``, so the denylist, Bing's own
|
|
152
|
+
submission quota, the local daily ceiling, one-shot application and the audit trail
|
|
153
|
+
are exactly the ones the reviewed path uses. What it drops is the human between the
|
|
154
|
+
two steps, which is what ``BING_WM_ALLOW_WRITES`` decides.
|
|
155
|
+
"""
|
|
156
|
+
settings.check_writes_allowed()
|
|
157
|
+
store = PlanStore(settings.state_dir, settings.plan_ttl_seconds)
|
|
158
|
+
plan = await create_write_plan(operation, args, settings=settings, client=client)
|
|
159
|
+
try:
|
|
160
|
+
return await apply_plan(
|
|
161
|
+
plan.plan_id,
|
|
162
|
+
settings=settings,
|
|
163
|
+
store=store,
|
|
164
|
+
client=client,
|
|
165
|
+
audit=audit,
|
|
166
|
+
limiter=limiter,
|
|
167
|
+
indexnow_transport=indexnow_transport,
|
|
168
|
+
)
|
|
169
|
+
except BingWebmasterError as exc:
|
|
170
|
+
_close_out(store, plan.plan_id, audit)
|
|
171
|
+
# Whatever went wrong, the caller has to be able to find the record: for an
|
|
172
|
+
# unknown outcome the plan id is the only way to tell which write to check
|
|
173
|
+
# against Bing.
|
|
174
|
+
exc.details = {**(exc.details or {}), "plan_id": plan.plan_id}
|
|
175
|
+
raise
|
|
176
|
+
except BaseException:
|
|
177
|
+
_close_out(store, plan.plan_id, audit)
|
|
178
|
+
raise
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _close_out(store: PlanStore, plan_id: str, audit: AuditLog) -> None:
|
|
182
|
+
"""Refuse a one-step plan whose write never happened.
|
|
183
|
+
|
|
184
|
+
Nobody is coming back to it. Left pending it would stay applicable by hand for a
|
|
185
|
+
whole TTL, which would send a change no human ever reviewed. A plan the apply
|
|
186
|
+
boundary already made terminal - applied, or an unknown outcome - keeps exactly the
|
|
187
|
+
state it recorded, and a failure to clean up never masks the original error.
|
|
188
|
+
"""
|
|
189
|
+
try:
|
|
190
|
+
if store.get(plan_id).state != "pending":
|
|
191
|
+
return
|
|
192
|
+
store.reject(plan_id)
|
|
193
|
+
audit.record("plan_discarded", plan_id=plan_id, reason="one_step_write_failed")
|
|
194
|
+
except BingWebmasterError:
|
|
195
|
+
return
|
|
196
|
+
except OSError:
|
|
197
|
+
return
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Append-only record of attempted and completed writes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from datetime import UTC, datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
try: # pragma: no cover - Windows has no fcntl; O_APPEND still protects each write offset.
|
|
12
|
+
import fcntl
|
|
13
|
+
except ImportError: # pragma: no cover
|
|
14
|
+
fcntl = None # type: ignore[assignment]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AuditLog:
|
|
18
|
+
def __init__(self, state_dir: Path) -> None:
|
|
19
|
+
directory = Path(state_dir)
|
|
20
|
+
directory.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
21
|
+
self._path = directory / "audit.jsonl"
|
|
22
|
+
|
|
23
|
+
def record(self, event: str, **fields: Any) -> None:
|
|
24
|
+
entry = {"ts": datetime.now(UTC).isoformat(), "event": event, **fields}
|
|
25
|
+
line = (json.dumps(entry, ensure_ascii=False, default=str) + "\n").encode()
|
|
26
|
+
descriptor = os.open(self._path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
|
|
27
|
+
try:
|
|
28
|
+
if fcntl is not None:
|
|
29
|
+
fcntl.flock(descriptor, fcntl.LOCK_EX)
|
|
30
|
+
remaining = memoryview(line)
|
|
31
|
+
while remaining:
|
|
32
|
+
written = os.write(descriptor, remaining)
|
|
33
|
+
if written == 0:
|
|
34
|
+
raise OSError("audit write returned zero bytes")
|
|
35
|
+
remaining = remaining[written:]
|
|
36
|
+
os.fsync(descriptor)
|
|
37
|
+
finally:
|
|
38
|
+
if fcntl is not None:
|
|
39
|
+
fcntl.flock(descriptor, fcntl.LOCK_UN)
|
|
40
|
+
os.close(descriptor)
|
|
41
|
+
|
|
42
|
+
def entries(self) -> list[dict[str, Any]]:
|
|
43
|
+
if not self._path.exists():
|
|
44
|
+
return []
|
|
45
|
+
return [json.loads(line) for line in self._path.read_text().splitlines() if line]
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Credential application with an interface that can later host OAuth2."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Protocol
|
|
6
|
+
|
|
7
|
+
from .config import Settings
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AuthProvider(Protocol):
|
|
11
|
+
def apply(self, params: dict[str, Any], headers: dict[str, str]) -> None: ...
|
|
12
|
+
|
|
13
|
+
def secrets(self) -> frozenset[str]: ...
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ApiKeyAuth:
|
|
17
|
+
def __init__(self, api_key: str) -> None:
|
|
18
|
+
self._api_key = api_key
|
|
19
|
+
|
|
20
|
+
def apply(self, params: dict[str, Any], headers: dict[str, str]) -> None:
|
|
21
|
+
# Microsoft documents the API key only as a query-string parameter; the header
|
|
22
|
+
# form it documents (``Authorization: Bearer``) belongs to the separate OAuth2
|
|
23
|
+
# flow, not to a key. So the credential is part of every URL this client builds,
|
|
24
|
+
# and ``secrets`` exists to cover what that implies.
|
|
25
|
+
params["apikey"] = self._api_key
|
|
26
|
+
|
|
27
|
+
def secrets(self) -> frozenset[str]:
|
|
28
|
+
"""The literals this provider puts into a request, for the redaction boundary.
|
|
29
|
+
|
|
30
|
+
Whoever applies a credential is the only one that knows what it is. Asking the
|
|
31
|
+
provider instead of reaching into the settings keeps the two in step: a provider
|
|
32
|
+
added later hides its own token by implementing this, and nothing else changes.
|
|
33
|
+
"""
|
|
34
|
+
return frozenset({self._api_key})
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def build_auth(settings: Settings) -> AuthProvider:
|
|
38
|
+
if settings.api_key is None:
|
|
39
|
+
from .errors import AuthFailed
|
|
40
|
+
|
|
41
|
+
raise AuthFailed("BING_WM_API_KEY is required for Bing Webmaster API calls")
|
|
42
|
+
return ApiKeyAuth(settings.api_key.get_secret_value())
|