web-auditor 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.
- web_auditor/__init__.py +42 -0
- web_auditor/_client.py +212 -0
- web_auditor/_errors.py +102 -0
- web_auditor/_object.py +38 -0
- web_auditor/_operations.py +44 -0
- web_auditor/_resources.py +402 -0
- web_auditor/_version.py +1 -0
- web_auditor/py.typed +0 -0
- web_auditor/webhooks.py +65 -0
- web_auditor-0.1.0.dist-info/METADATA +214 -0
- web_auditor-0.1.0.dist-info/RECORD +13 -0
- web_auditor-0.1.0.dist-info/WHEEL +4 -0
- web_auditor-0.1.0.dist-info/licenses/LICENSE +21 -0
web_auditor/__init__.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Official Python client for the Web Auditor public API."""
|
|
2
|
+
|
|
3
|
+
from . import webhooks
|
|
4
|
+
from ._client import Page, WebAuditor
|
|
5
|
+
from ._errors import (
|
|
6
|
+
APIConnectionError,
|
|
7
|
+
APIError,
|
|
8
|
+
APITimeoutError,
|
|
9
|
+
AuthenticationError,
|
|
10
|
+
ConflictError,
|
|
11
|
+
InsufficientCreditsError,
|
|
12
|
+
InvalidRequestError,
|
|
13
|
+
NotFoundError,
|
|
14
|
+
PermissionDeniedError,
|
|
15
|
+
PollTimeoutError,
|
|
16
|
+
RateLimitError,
|
|
17
|
+
WebAuditorError,
|
|
18
|
+
)
|
|
19
|
+
from ._object import ApiObject
|
|
20
|
+
from ._resources import NOT_GIVEN
|
|
21
|
+
from ._version import __version__
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"APIConnectionError",
|
|
25
|
+
"APIError",
|
|
26
|
+
"APITimeoutError",
|
|
27
|
+
"ApiObject",
|
|
28
|
+
"AuthenticationError",
|
|
29
|
+
"ConflictError",
|
|
30
|
+
"InsufficientCreditsError",
|
|
31
|
+
"InvalidRequestError",
|
|
32
|
+
"NOT_GIVEN",
|
|
33
|
+
"NotFoundError",
|
|
34
|
+
"Page",
|
|
35
|
+
"PermissionDeniedError",
|
|
36
|
+
"PollTimeoutError",
|
|
37
|
+
"RateLimitError",
|
|
38
|
+
"WebAuditor",
|
|
39
|
+
"WebAuditorError",
|
|
40
|
+
"__version__",
|
|
41
|
+
"webhooks",
|
|
42
|
+
]
|
web_auditor/_client.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import platform
|
|
5
|
+
import random
|
|
6
|
+
import time
|
|
7
|
+
import uuid
|
|
8
|
+
from collections.abc import Iterator
|
|
9
|
+
from typing import Any, Callable
|
|
10
|
+
from urllib.parse import quote
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
from ._errors import APIConnectionError, APITimeoutError, WebAuditorError, error_for_status
|
|
15
|
+
from ._object import ApiObject
|
|
16
|
+
from ._operations import OPERATIONS
|
|
17
|
+
from ._version import __version__
|
|
18
|
+
|
|
19
|
+
DEFAULT_BASE_URL = "https://api.web-auditor.enfection.com"
|
|
20
|
+
DEFAULT_TIMEOUT = 60.0
|
|
21
|
+
DEFAULT_MAX_RETRIES = 2
|
|
22
|
+
# Retry-After longer than this (e.g. a site's hourly audit limit) is returned to the caller instead of slept on.
|
|
23
|
+
MAX_RETRY_AFTER_SECONDS = 60
|
|
24
|
+
RETRY_STATUSES = frozenset({429, 500, 502, 503, 504})
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class WebAuditor:
|
|
28
|
+
"""Client for the Web Auditor public API.
|
|
29
|
+
|
|
30
|
+
>>> client = WebAuditor(api_key="wa_test_…") # or set WEB_AUDITOR_API_KEY
|
|
31
|
+
>>> audit = client.url_audits.create(url="https://example.com/pricing")
|
|
32
|
+
>>> audit = client.url_audits.wait(audit.id)
|
|
33
|
+
>>> audit.report.score
|
|
34
|
+
|
|
35
|
+
Failed requests (network errors, 429 and 5xx) are retried `max_retries` times with backoff, honouring
|
|
36
|
+
`Retry-After`. Every POST carries an `Idempotency-Key`, so a retry never starts a second audit.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
api_key: str | None = None,
|
|
42
|
+
*,
|
|
43
|
+
base_url: str | None = None,
|
|
44
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
45
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
46
|
+
http_client: httpx.Client | None = None,
|
|
47
|
+
_sleep: Callable[[float], None] = time.sleep,
|
|
48
|
+
_monotonic: Callable[[], float] = time.monotonic,
|
|
49
|
+
):
|
|
50
|
+
from ._resources import ( # noqa: PLC0415 — resources import this module
|
|
51
|
+
BrandAudits,
|
|
52
|
+
Checks,
|
|
53
|
+
Monitors,
|
|
54
|
+
ReportShares,
|
|
55
|
+
SiteAudits,
|
|
56
|
+
Status,
|
|
57
|
+
UrlAuditBatches,
|
|
58
|
+
UrlAudits,
|
|
59
|
+
Usage,
|
|
60
|
+
WebhookDeliveries,
|
|
61
|
+
WebhookEndpoints,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
api_key = api_key or os.environ.get("WEB_AUDITOR_API_KEY")
|
|
65
|
+
if not api_key:
|
|
66
|
+
raise WebAuditorError("No API key: pass api_key=... or set WEB_AUDITOR_API_KEY.")
|
|
67
|
+
self.api_key = api_key
|
|
68
|
+
self.base_url = (base_url or os.environ.get("WEB_AUDITOR_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
|
|
69
|
+
self.max_retries = max_retries
|
|
70
|
+
self._http = http_client or httpx.Client(timeout=timeout)
|
|
71
|
+
self._sleep = _sleep
|
|
72
|
+
self._monotonic = _monotonic
|
|
73
|
+
self._user_agent = f"web-auditor-python/{__version__} python/{platform.python_version()}"
|
|
74
|
+
|
|
75
|
+
self.status = Status(self)
|
|
76
|
+
self.usage = Usage(self)
|
|
77
|
+
self.checks = Checks(self)
|
|
78
|
+
self.url_audits = UrlAudits(self)
|
|
79
|
+
self.report_shares = ReportShares(self)
|
|
80
|
+
self.url_audit_batches = UrlAuditBatches(self)
|
|
81
|
+
self.site_audits = SiteAudits(self)
|
|
82
|
+
self.brand_audits = BrandAudits(self)
|
|
83
|
+
self.monitors = Monitors(self)
|
|
84
|
+
self.webhook_endpoints = WebhookEndpoints(self)
|
|
85
|
+
self.webhook_deliveries = WebhookDeliveries(self)
|
|
86
|
+
|
|
87
|
+
def close(self) -> None:
|
|
88
|
+
self._http.close()
|
|
89
|
+
|
|
90
|
+
def __enter__(self) -> WebAuditor:
|
|
91
|
+
return self
|
|
92
|
+
|
|
93
|
+
def __exit__(self, *exc_info: Any) -> None:
|
|
94
|
+
self.close()
|
|
95
|
+
|
|
96
|
+
# ── Transport ────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
def request(
|
|
99
|
+
self,
|
|
100
|
+
operation_id: str,
|
|
101
|
+
path_params: dict[str, str] | None = None,
|
|
102
|
+
*,
|
|
103
|
+
params: dict[str, Any] | None = None,
|
|
104
|
+
json: dict[str, Any] | None = None,
|
|
105
|
+
idempotency_key: str | None = None,
|
|
106
|
+
accept: str = "application/json",
|
|
107
|
+
raw: bool = False,
|
|
108
|
+
timeout: float | None = None,
|
|
109
|
+
retry_timeouts: bool = True,
|
|
110
|
+
) -> Any:
|
|
111
|
+
"""Call one operation by its operationId. Returns an ApiObject, bytes (`raw=True`) or None (204)."""
|
|
112
|
+
method, template = OPERATIONS[operation_id]
|
|
113
|
+
path = template.format(**{name: quote(str(value), safe="") for name, value in (path_params or {}).items()})
|
|
114
|
+
headers = {
|
|
115
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
116
|
+
"User-Agent": self._user_agent,
|
|
117
|
+
"Accept": accept,
|
|
118
|
+
}
|
|
119
|
+
if method == "POST":
|
|
120
|
+
headers["Idempotency-Key"] = idempotency_key or str(uuid.uuid4())
|
|
121
|
+
query = {key: value for key, value in (params or {}).items() if value is not None}
|
|
122
|
+
|
|
123
|
+
attempt = 0
|
|
124
|
+
while True:
|
|
125
|
+
try:
|
|
126
|
+
response = self._http.request(
|
|
127
|
+
method,
|
|
128
|
+
self.base_url + path,
|
|
129
|
+
params=query or None,
|
|
130
|
+
json=json,
|
|
131
|
+
headers=headers,
|
|
132
|
+
**({"timeout": timeout} if timeout is not None else {}),
|
|
133
|
+
)
|
|
134
|
+
except httpx.TimeoutException as exc:
|
|
135
|
+
if retry_timeouts and attempt < self.max_retries:
|
|
136
|
+
self._sleep(_backoff(attempt))
|
|
137
|
+
attempt += 1
|
|
138
|
+
continue
|
|
139
|
+
raise APITimeoutError(f"Request to {path} timed out.") from exc
|
|
140
|
+
except httpx.TransportError as exc:
|
|
141
|
+
if attempt < self.max_retries:
|
|
142
|
+
self._sleep(_backoff(attempt))
|
|
143
|
+
attempt += 1
|
|
144
|
+
continue
|
|
145
|
+
raise APIConnectionError(f"Couldn't reach the Web Auditor API: {exc}") from exc
|
|
146
|
+
|
|
147
|
+
if response.status_code < 400:
|
|
148
|
+
if response.status_code == 204:
|
|
149
|
+
return None
|
|
150
|
+
return response.content if raw else ApiObject(response.json())
|
|
151
|
+
|
|
152
|
+
retry_after = _retry_after(response)
|
|
153
|
+
retryable = response.status_code in RETRY_STATUSES and (
|
|
154
|
+
retry_after is None or retry_after <= MAX_RETRY_AFTER_SECONDS
|
|
155
|
+
)
|
|
156
|
+
if retryable and attempt < self.max_retries:
|
|
157
|
+
self._sleep(retry_after if retry_after is not None else _backoff(attempt))
|
|
158
|
+
attempt += 1
|
|
159
|
+
continue
|
|
160
|
+
raise _error_from(response, retry_after)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class Page(ApiObject):
|
|
164
|
+
"""One page of a list (`data`, `has_more`). `auto_paging_iter()` walks every following page too."""
|
|
165
|
+
|
|
166
|
+
def __init__(self, data: dict, *, fetch: Callable[[str], Page]):
|
|
167
|
+
super().__init__(data)
|
|
168
|
+
object.__setattr__(self, "_fetch", fetch)
|
|
169
|
+
|
|
170
|
+
def auto_paging_iter(self) -> Iterator[ApiObject]:
|
|
171
|
+
page: Page | None = self
|
|
172
|
+
while page is not None:
|
|
173
|
+
yield from page.data
|
|
174
|
+
cursor = _cursor(page.get("next")) if page.get("has_more") else None
|
|
175
|
+
page = page._fetch(cursor) if cursor else None
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _cursor(next_link: str | None) -> str | None:
|
|
179
|
+
# The link names the host the API saw; only its cursor matters, so pages follow this client's base_url.
|
|
180
|
+
return httpx.URL(next_link).params.get("cursor") if next_link else None
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _backoff(attempt: int) -> float:
|
|
184
|
+
return min(0.5 * 2**attempt, 8.0) * random.uniform(0.75, 1.0)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _retry_after(response: httpx.Response) -> int | None:
|
|
188
|
+
value = response.headers.get("Retry-After")
|
|
189
|
+
try:
|
|
190
|
+
return max(0, int(value)) if value is not None else None
|
|
191
|
+
except ValueError:
|
|
192
|
+
return None
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _error_from(response: httpx.Response, retry_after: int | None) -> WebAuditorError:
|
|
196
|
+
try:
|
|
197
|
+
body = response.json()
|
|
198
|
+
except ValueError:
|
|
199
|
+
body = None
|
|
200
|
+
envelope = body.get("error") if isinstance(body, dict) and isinstance(body.get("error"), dict) else {}
|
|
201
|
+
message = envelope.get("message") or f"The API returned HTTP {response.status_code}."
|
|
202
|
+
return error_for_status(
|
|
203
|
+
response.status_code,
|
|
204
|
+
message,
|
|
205
|
+
retry_after=retry_after,
|
|
206
|
+
code=envelope.get("code"),
|
|
207
|
+
type=envelope.get("type"),
|
|
208
|
+
param=envelope.get("param"),
|
|
209
|
+
request_id=envelope.get("request_id") or response.headers.get("X-Request-Id"),
|
|
210
|
+
doc_url=envelope.get("doc_url"),
|
|
211
|
+
body=body,
|
|
212
|
+
)
|
web_auditor/_errors.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class WebAuditorError(Exception):
|
|
7
|
+
"""Base class for every error the SDK raises.
|
|
8
|
+
|
|
9
|
+
API errors carry the fields of the error envelope: `code` (branch on this), `type`, `param`, `request_id`
|
|
10
|
+
(quote it to support) and `doc_url`, plus the HTTP `status`.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
message: str,
|
|
16
|
+
*,
|
|
17
|
+
status: int | None = None,
|
|
18
|
+
code: str | None = None,
|
|
19
|
+
type: str | None = None,
|
|
20
|
+
param: str | None = None,
|
|
21
|
+
request_id: str | None = None,
|
|
22
|
+
doc_url: str | None = None,
|
|
23
|
+
body: Any = None,
|
|
24
|
+
):
|
|
25
|
+
super().__init__(message)
|
|
26
|
+
self.message = message
|
|
27
|
+
self.status = status
|
|
28
|
+
self.code = code
|
|
29
|
+
self.type = type
|
|
30
|
+
self.param = param
|
|
31
|
+
self.request_id = request_id
|
|
32
|
+
self.doc_url = doc_url
|
|
33
|
+
self.body = body
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class APIConnectionError(WebAuditorError):
|
|
37
|
+
"""The API couldn't be reached (DNS, connection refused, TLS...). Retried automatically first."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class APITimeoutError(APIConnectionError):
|
|
41
|
+
"""The request timed out. Retried automatically first."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class APIError(WebAuditorError):
|
|
45
|
+
"""The API failed on its side (5xx). Retried automatically first."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class InvalidRequestError(WebAuditorError):
|
|
49
|
+
"""The request was rejected (400, 409, 422...). `param` names the offending field when there is one."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ConflictError(InvalidRequestError):
|
|
53
|
+
"""409: the resource isn't in a state that allows this, e.g. `audit_not_cancelable`, `delivery_pending`."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class AuthenticationError(WebAuditorError):
|
|
57
|
+
"""401: the API key is missing, malformed, revoked or expired."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class PermissionDeniedError(WebAuditorError):
|
|
61
|
+
"""403: a missing scope, a disallowed IP, a feature outside the plan, or an inactive account."""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class NotFoundError(WebAuditorError):
|
|
65
|
+
"""404: no such object for this key's account and mode (test keys can't see live objects)."""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class InsufficientCreditsError(WebAuditorError):
|
|
69
|
+
"""402 `credits_exhausted`: not enough credits left this period."""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class RateLimitError(WebAuditorError):
|
|
73
|
+
"""429: `rate_limited`, `host_rate_limit` or `concurrent_audit_limit`. `retry_after` is in seconds when known."""
|
|
74
|
+
|
|
75
|
+
def __init__(self, message: str, *, retry_after: int | None = None, **kwargs: Any):
|
|
76
|
+
super().__init__(message, **kwargs)
|
|
77
|
+
self.retry_after = retry_after
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class PollTimeoutError(WebAuditorError, TimeoutError):
|
|
81
|
+
"""`wait()` gave up before the object finished. `last` is the most recent state seen; the work continues."""
|
|
82
|
+
|
|
83
|
+
def __init__(self, message: str, *, last: Any):
|
|
84
|
+
super().__init__(message)
|
|
85
|
+
self.last = last
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
_BY_STATUS = {
|
|
89
|
+
401: AuthenticationError,
|
|
90
|
+
402: InsufficientCreditsError,
|
|
91
|
+
403: PermissionDeniedError,
|
|
92
|
+
404: NotFoundError,
|
|
93
|
+
409: ConflictError,
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def error_for_status(status: int, message: str, *, retry_after: int | None = None, **fields: Any) -> WebAuditorError:
|
|
98
|
+
if status == 429:
|
|
99
|
+
return RateLimitError(message, retry_after=retry_after, status=status, **fields)
|
|
100
|
+
if status >= 500:
|
|
101
|
+
return APIError(message, status=status, **fields)
|
|
102
|
+
return _BY_STATUS.get(status, InvalidRequestError)(message, status=status, **fields)
|
web_auditor/_object.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ApiObject(dict):
|
|
7
|
+
"""A JSON object from the API: a dict that also allows attribute access (`audit.report.score`).
|
|
8
|
+
|
|
9
|
+
Unknown fields added to the API later simply appear, so upgrading the API never breaks old SDK versions.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
def __init__(self, data: dict | None = None):
|
|
13
|
+
super().__init__({key: _wrap(value) for key, value in (data or {}).items()})
|
|
14
|
+
|
|
15
|
+
def __getattr__(self, name: str) -> Any:
|
|
16
|
+
try:
|
|
17
|
+
return self[name]
|
|
18
|
+
except KeyError:
|
|
19
|
+
raise AttributeError(f"{type(self).__name__} has no field {name!r}") from None
|
|
20
|
+
|
|
21
|
+
def to_dict(self) -> dict:
|
|
22
|
+
return {key: _unwrap(value) for key, value in self.items()}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _wrap(value: Any) -> Any:
|
|
26
|
+
if isinstance(value, dict) and not isinstance(value, ApiObject):
|
|
27
|
+
return ApiObject(value)
|
|
28
|
+
if isinstance(value, list):
|
|
29
|
+
return [_wrap(item) for item in value]
|
|
30
|
+
return value
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _unwrap(value: Any) -> Any:
|
|
34
|
+
if isinstance(value, ApiObject):
|
|
35
|
+
return value.to_dict()
|
|
36
|
+
if isinstance(value, list):
|
|
37
|
+
return [_unwrap(item) for item in value]
|
|
38
|
+
return value
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Every public API operation the SDK calls, by the operationId published in /v1/openapi.json."""
|
|
2
|
+
|
|
3
|
+
OPERATIONS = {
|
|
4
|
+
"retrieveStatus": ("GET", "/v1/status"),
|
|
5
|
+
"retrieveUsage": ("GET", "/v1/usage"),
|
|
6
|
+
"listChecks": ("GET", "/v1/checks"),
|
|
7
|
+
"listUrlAudits": ("GET", "/v1/url-audits"),
|
|
8
|
+
"createUrlAudit": ("POST", "/v1/url-audits"),
|
|
9
|
+
"retrieveUrlAudit": ("GET", "/v1/url-audits/{audit_id}"),
|
|
10
|
+
"cancelUrlAudit": ("POST", "/v1/url-audits/{audit_id}/cancel"),
|
|
11
|
+
"listUrlAuditArtifacts": ("GET", "/v1/url-audits/{audit_id}/artifacts"),
|
|
12
|
+
"downloadUrlAuditReportPdf": ("GET", "/v1/url-audits/{audit_id}/report.pdf"),
|
|
13
|
+
"listReportShares": ("GET", "/v1/url-audits/{audit_id}/shares"),
|
|
14
|
+
"createReportShare": ("POST", "/v1/url-audits/{audit_id}/shares"),
|
|
15
|
+
"revokeReportShare": ("DELETE", "/v1/url-audits/{audit_id}/shares/{share_id}"),
|
|
16
|
+
"listUrlAuditBatches": ("GET", "/v1/url-audit-batches"),
|
|
17
|
+
"createUrlAuditBatch": ("POST", "/v1/url-audit-batches"),
|
|
18
|
+
"retrieveUrlAuditBatch": ("GET", "/v1/url-audit-batches/{batch_id}"),
|
|
19
|
+
"cancelUrlAuditBatch": ("POST", "/v1/url-audit-batches/{batch_id}/cancel"),
|
|
20
|
+
"listBrandAudits": ("GET", "/v1/brand-audits"),
|
|
21
|
+
"createBrandAudit": ("POST", "/v1/brand-audits"),
|
|
22
|
+
"retrieveBrandAudit": ("GET", "/v1/brand-audits/{brand_audit_id}"),
|
|
23
|
+
"listSiteAudits": ("GET", "/v1/site-audits"),
|
|
24
|
+
"createSiteAudit": ("POST", "/v1/site-audits"),
|
|
25
|
+
"retrieveSiteAudit": ("GET", "/v1/site-audits/{site_audit_id}"),
|
|
26
|
+
"listSiteAuditPages": ("GET", "/v1/site-audits/{site_audit_id}/pages"),
|
|
27
|
+
"cancelSiteAudit": ("POST", "/v1/site-audits/{site_audit_id}/cancel"),
|
|
28
|
+
"listMonitors": ("GET", "/v1/monitors"),
|
|
29
|
+
"createMonitor": ("POST", "/v1/monitors"),
|
|
30
|
+
"retrieveMonitor": ("GET", "/v1/monitors/{monitor_id}"),
|
|
31
|
+
"updateMonitor": ("PATCH", "/v1/monitors/{monitor_id}"),
|
|
32
|
+
"deleteMonitor": ("DELETE", "/v1/monitors/{monitor_id}"),
|
|
33
|
+
"listMonitorRuns": ("GET", "/v1/monitors/{monitor_id}/runs"),
|
|
34
|
+
"createMonitorRun": ("POST", "/v1/monitors/{monitor_id}/runs"),
|
|
35
|
+
"listWebhookEndpoints": ("GET", "/v1/webhook-endpoints"),
|
|
36
|
+
"createWebhookEndpoint": ("POST", "/v1/webhook-endpoints"),
|
|
37
|
+
"retrieveWebhookEndpoint": ("GET", "/v1/webhook-endpoints/{endpoint_id}"),
|
|
38
|
+
"deleteWebhookEndpoint": ("DELETE", "/v1/webhook-endpoints/{endpoint_id}"),
|
|
39
|
+
"sendWebhookTestEvent": ("POST", "/v1/webhook-endpoints/{endpoint_id}/test"),
|
|
40
|
+
"rotateWebhookEndpointSecret": ("POST", "/v1/webhook-endpoints/{endpoint_id}/rotate-secret"),
|
|
41
|
+
"listWebhookDeliveries": ("GET", "/v1/webhook-deliveries"),
|
|
42
|
+
"retrieveWebhookDelivery": ("GET", "/v1/webhook-deliveries/{delivery_id}"),
|
|
43
|
+
"replayWebhookDelivery": ("POST", "/v1/webhook-deliveries/{delivery_id}/replay"),
|
|
44
|
+
}
|
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
from typing import TYPE_CHECKING, Any
|
|
5
|
+
|
|
6
|
+
from ._client import Page
|
|
7
|
+
from ._errors import PollTimeoutError
|
|
8
|
+
from ._object import ApiObject
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from ._client import WebAuditor
|
|
12
|
+
|
|
13
|
+
AUDIT_FINAL_STATUSES = frozenset({"completed", "failed", "canceled"})
|
|
14
|
+
BATCH_FINAL_STATUSES = frozenset({"completed", "canceled"})
|
|
15
|
+
SITE_AUDIT_FINAL_STATUSES = frozenset({"completed", "canceled", "failed"})
|
|
16
|
+
BRAND_AUDIT_FINAL_STATUSES = frozenset({"completed", "failed"})
|
|
17
|
+
# Longer than the API's 90-second PDF wait, so a busy worker comes back as a clear 503 rather than a timeout.
|
|
18
|
+
PDF_TIMEOUT_SECONDS = 120
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class _NotGiven:
|
|
22
|
+
"""Marks an update argument that wasn't passed, so `None` can mean "clear this field"."""
|
|
23
|
+
|
|
24
|
+
def __repr__(self) -> str:
|
|
25
|
+
return "NOT_GIVEN"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
NOT_GIVEN: Any = _NotGiven()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _given(**fields: Any) -> dict[str, Any]:
|
|
32
|
+
return {key: value for key, value in fields.items() if value is not NOT_GIVEN}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _compact(**fields: Any) -> dict[str, Any]:
|
|
36
|
+
return {key: value for key, value in fields.items() if value is not None}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class _Resource:
|
|
40
|
+
def __init__(self, client: WebAuditor):
|
|
41
|
+
self._client = client
|
|
42
|
+
|
|
43
|
+
def _list(self, operation_id: str, path_params: dict[str, str] | None = None, **params: Any) -> Page:
|
|
44
|
+
def fetch(cursor: str | None) -> Page:
|
|
45
|
+
query = {**params, "cursor": cursor} if cursor else params
|
|
46
|
+
return Page(self._client.request(operation_id, path_params, params=query), fetch=fetch)
|
|
47
|
+
|
|
48
|
+
return fetch(None)
|
|
49
|
+
|
|
50
|
+
def _wait(
|
|
51
|
+
self, retrieve, object_id: str, final: frozenset, timeout: float | None, poll_interval: float
|
|
52
|
+
) -> ApiObject:
|
|
53
|
+
deadline = None if timeout is None else self._client._monotonic() + timeout
|
|
54
|
+
while True:
|
|
55
|
+
current = retrieve(object_id)
|
|
56
|
+
if current.status in final:
|
|
57
|
+
return current
|
|
58
|
+
if deadline is not None and self._client._monotonic() >= deadline:
|
|
59
|
+
raise PollTimeoutError(
|
|
60
|
+
f"{object_id} was still {current.status} after {timeout:g} seconds.",
|
|
61
|
+
last=current,
|
|
62
|
+
)
|
|
63
|
+
self._client._sleep(poll_interval)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class Status(_Resource):
|
|
67
|
+
def retrieve(self) -> ApiObject:
|
|
68
|
+
"""Service health. Needs no scope."""
|
|
69
|
+
return self._client.request("retrieveStatus")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class Usage(_Resource):
|
|
73
|
+
def retrieve(self) -> ApiObject:
|
|
74
|
+
"""Plan limits and credits for the current period."""
|
|
75
|
+
return self._client.request("retrieveUsage")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class Checks(_Resource):
|
|
79
|
+
def list(self) -> ApiObject:
|
|
80
|
+
"""The catalogue of every check an audit can run, with severity and how to fix it."""
|
|
81
|
+
return self._client.request("listChecks")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class UrlAudits(_Resource):
|
|
85
|
+
def create(
|
|
86
|
+
self,
|
|
87
|
+
*,
|
|
88
|
+
url: str,
|
|
89
|
+
render: str | None = None,
|
|
90
|
+
geo: dict[str, Any] | None = None,
|
|
91
|
+
idempotency_key: str | None = None,
|
|
92
|
+
) -> ApiObject:
|
|
93
|
+
"""Start an audit (202). `render`: "always" | "auto" | "never". `geo` turns on AI-engine probes."""
|
|
94
|
+
return self._client.request(
|
|
95
|
+
"createUrlAudit",
|
|
96
|
+
json=_compact(url=url, render=render, geo=geo),
|
|
97
|
+
idempotency_key=idempotency_key,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
def retrieve(self, audit_id: str) -> ApiObject:
|
|
101
|
+
return self._client.request("retrieveUrlAudit", {"audit_id": audit_id})
|
|
102
|
+
|
|
103
|
+
def list(self, *, status: str | None = None, limit: int | None = None) -> Page:
|
|
104
|
+
return self._list("listUrlAudits", status=status, limit=limit)
|
|
105
|
+
|
|
106
|
+
def cancel(self, audit_id: str, *, idempotency_key: str | None = None) -> ApiObject:
|
|
107
|
+
"""Cancel a waiting, queued or running audit; its credits are refunded."""
|
|
108
|
+
return self._client.request("cancelUrlAudit", {"audit_id": audit_id}, idempotency_key=idempotency_key)
|
|
109
|
+
|
|
110
|
+
def artifacts(self, audit_id: str) -> ApiObject:
|
|
111
|
+
"""Raw/rendered HTML and screenshots, as download links valid for 15 minutes."""
|
|
112
|
+
return self._client.request("listUrlAuditArtifacts", {"audit_id": audit_id})
|
|
113
|
+
|
|
114
|
+
def report_pdf(self, audit_id: str) -> bytes:
|
|
115
|
+
"""The finished report as PDF bytes.
|
|
116
|
+
|
|
117
|
+
Printing can take the API up to 90 seconds when its PDF workers are busy (then 503 `pdf_unavailable`), so
|
|
118
|
+
this call waits longer than the client timeout and doesn't retry a timeout — each retry would print again.
|
|
119
|
+
"""
|
|
120
|
+
return self._client.request(
|
|
121
|
+
"downloadUrlAuditReportPdf",
|
|
122
|
+
{"audit_id": audit_id},
|
|
123
|
+
accept="application/pdf, application/json",
|
|
124
|
+
raw=True,
|
|
125
|
+
timeout=PDF_TIMEOUT_SECONDS,
|
|
126
|
+
retry_timeouts=False,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
def wait(self, audit_id: str, *, timeout: float | None = 900, poll_interval: float = 3) -> ApiObject:
|
|
130
|
+
"""Poll until the audit is completed, failed or canceled. Raises PollTimeoutError after `timeout` seconds."""
|
|
131
|
+
return self._wait(self.retrieve, audit_id, AUDIT_FINAL_STATUSES, timeout, poll_interval)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class ReportShares(_Resource):
|
|
135
|
+
def create(
|
|
136
|
+
self, audit_id: str, *, expires_in_days: int | None = None, idempotency_key: str | None = None
|
|
137
|
+
) -> ApiObject:
|
|
138
|
+
"""A read-only report link for people without a key. `url` is returned only now."""
|
|
139
|
+
return self._client.request(
|
|
140
|
+
"createReportShare",
|
|
141
|
+
{"audit_id": audit_id},
|
|
142
|
+
json=_compact(expires_in_days=expires_in_days),
|
|
143
|
+
idempotency_key=idempotency_key,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
def list(self, audit_id: str, *, limit: int | None = None) -> Page:
|
|
147
|
+
return self._list("listReportShares", {"audit_id": audit_id}, limit=limit)
|
|
148
|
+
|
|
149
|
+
def revoke(self, audit_id: str, share_id: str) -> None:
|
|
150
|
+
return self._client.request("revokeReportShare", {"audit_id": audit_id, "share_id": share_id})
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class UrlAuditBatches(_Resource):
|
|
154
|
+
def create(
|
|
155
|
+
self,
|
|
156
|
+
*,
|
|
157
|
+
urls: Sequence[str],
|
|
158
|
+
render: str | None = None,
|
|
159
|
+
geo: dict[str, Any] | None = None,
|
|
160
|
+
idempotency_key: str | None = None,
|
|
161
|
+
) -> ApiObject:
|
|
162
|
+
"""Audit many URLs with the same options. Credits for all of them are reserved up front."""
|
|
163
|
+
return self._client.request(
|
|
164
|
+
"createUrlAuditBatch",
|
|
165
|
+
json=_compact(urls=list(urls), render=render, geo=geo),
|
|
166
|
+
idempotency_key=idempotency_key,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
def retrieve(self, batch_id: str) -> ApiObject:
|
|
170
|
+
return self._client.request("retrieveUrlAuditBatch", {"batch_id": batch_id})
|
|
171
|
+
|
|
172
|
+
def list(self, *, limit: int | None = None) -> Page:
|
|
173
|
+
return self._list("listUrlAuditBatches", limit=limit)
|
|
174
|
+
|
|
175
|
+
def cancel(self, batch_id: str, *, idempotency_key: str | None = None) -> ApiObject:
|
|
176
|
+
return self._client.request("cancelUrlAuditBatch", {"batch_id": batch_id}, idempotency_key=idempotency_key)
|
|
177
|
+
|
|
178
|
+
def wait(self, batch_id: str, *, timeout: float | None = 3600, poll_interval: float = 10) -> ApiObject:
|
|
179
|
+
"""Poll until the batch is completed or canceled. Single-site batches wait on hourly limits, so allow time."""
|
|
180
|
+
return self._wait(self.retrieve, batch_id, BATCH_FINAL_STATUSES, timeout, poll_interval)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class BrandAudits(_Resource):
|
|
184
|
+
def create(
|
|
185
|
+
self,
|
|
186
|
+
*,
|
|
187
|
+
brand: str,
|
|
188
|
+
category: str,
|
|
189
|
+
domain: str | None = None,
|
|
190
|
+
competitors: Sequence[dict[str, Any]] | None = None,
|
|
191
|
+
engines: Sequence[str] | None = None,
|
|
192
|
+
prompts: Sequence[str] | None = None,
|
|
193
|
+
prompt_count: int | None = None,
|
|
194
|
+
region: str | None = None,
|
|
195
|
+
language: str | None = None,
|
|
196
|
+
idempotency_key: str | None = None,
|
|
197
|
+
) -> ApiObject:
|
|
198
|
+
"""Measure how AI engines mention and cite `brand` versus `competitors` ([{"name", "domain"}], up to 5)."""
|
|
199
|
+
return self._client.request(
|
|
200
|
+
"createBrandAudit",
|
|
201
|
+
json=_compact(
|
|
202
|
+
brand=brand,
|
|
203
|
+
category=category,
|
|
204
|
+
domain=domain,
|
|
205
|
+
competitors=list(competitors) if competitors is not None else None,
|
|
206
|
+
engines=list(engines) if engines is not None else None,
|
|
207
|
+
prompts=list(prompts) if prompts is not None else None,
|
|
208
|
+
prompt_count=prompt_count,
|
|
209
|
+
region=region,
|
|
210
|
+
language=language,
|
|
211
|
+
),
|
|
212
|
+
idempotency_key=idempotency_key,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
def retrieve(self, brand_audit_id: str) -> ApiObject:
|
|
216
|
+
return self._client.request("retrieveBrandAudit", {"brand_audit_id": brand_audit_id})
|
|
217
|
+
|
|
218
|
+
def list(self, *, status: str | None = None, limit: int | None = None) -> Page:
|
|
219
|
+
return self._list("listBrandAudits", status=status, limit=limit)
|
|
220
|
+
|
|
221
|
+
def wait(self, brand_audit_id: str, *, timeout: float | None = 900, poll_interval: float = 5) -> ApiObject:
|
|
222
|
+
"""Poll until the brand audit is completed or failed."""
|
|
223
|
+
return self._wait(self.retrieve, brand_audit_id, BRAND_AUDIT_FINAL_STATUSES, timeout, poll_interval)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
class SiteAudits(_Resource):
|
|
227
|
+
def create(
|
|
228
|
+
self,
|
|
229
|
+
*,
|
|
230
|
+
url: str,
|
|
231
|
+
max_pages: int | None = None,
|
|
232
|
+
render: str | None = None,
|
|
233
|
+
idempotency_key: str | None = None,
|
|
234
|
+
) -> ApiObject:
|
|
235
|
+
"""Crawl the site (or the section under `url`'s directory) and audit up to `max_pages` pages, politely.
|
|
236
|
+
|
|
237
|
+
Credits for `max_pages` are reserved up front and unused pages refunded.
|
|
238
|
+
"""
|
|
239
|
+
return self._client.request(
|
|
240
|
+
"createSiteAudit",
|
|
241
|
+
json=_compact(url=url, max_pages=max_pages, render=render),
|
|
242
|
+
idempotency_key=idempotency_key,
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
def retrieve(self, site_audit_id: str) -> ApiObject:
|
|
246
|
+
return self._client.request("retrieveSiteAudit", {"site_audit_id": site_audit_id})
|
|
247
|
+
|
|
248
|
+
def list(self, *, status: str | None = None, limit: int | None = None) -> Page:
|
|
249
|
+
return self._list("listSiteAudits", status=status, limit=limit)
|
|
250
|
+
|
|
251
|
+
def list_pages(self, site_audit_id: str, *, limit: int | None = None) -> Page:
|
|
252
|
+
"""Pages in the order they were found, with each page audit's status and score."""
|
|
253
|
+
return self._list("listSiteAuditPages", {"site_audit_id": site_audit_id}, limit=limit)
|
|
254
|
+
|
|
255
|
+
def cancel(self, site_audit_id: str, *, idempotency_key: str | None = None) -> ApiObject:
|
|
256
|
+
return self._client.request(
|
|
257
|
+
"cancelSiteAudit", {"site_audit_id": site_audit_id}, idempotency_key=idempotency_key
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
def wait(self, site_audit_id: str, *, timeout: float | None = 7200, poll_interval: float = 15) -> ApiObject:
|
|
261
|
+
"""Poll until the site audit is completed, canceled or failed; pages are audited seconds apart."""
|
|
262
|
+
return self._wait(self.retrieve, site_audit_id, SITE_AUDIT_FINAL_STATUSES, timeout, poll_interval)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
class Monitors(_Resource):
|
|
266
|
+
def create(
|
|
267
|
+
self,
|
|
268
|
+
*,
|
|
269
|
+
url: str,
|
|
270
|
+
cadence: str | None = None,
|
|
271
|
+
render: str | None = None,
|
|
272
|
+
geo: dict[str, Any] | None = None,
|
|
273
|
+
score_drop_threshold: int | None = None,
|
|
274
|
+
description: str | None = None,
|
|
275
|
+
idempotency_key: str | None = None,
|
|
276
|
+
) -> ApiObject:
|
|
277
|
+
"""Audit `url` on a schedule. `cadence`: "daily" (default) | "weekly". The first run is due straight away."""
|
|
278
|
+
return self._client.request(
|
|
279
|
+
"createMonitor",
|
|
280
|
+
json=_compact(
|
|
281
|
+
url=url,
|
|
282
|
+
cadence=cadence,
|
|
283
|
+
render=render,
|
|
284
|
+
geo=geo,
|
|
285
|
+
score_drop_threshold=score_drop_threshold,
|
|
286
|
+
description=description,
|
|
287
|
+
),
|
|
288
|
+
idempotency_key=idempotency_key,
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
def retrieve(self, monitor_id: str) -> ApiObject:
|
|
292
|
+
return self._client.request("retrieveMonitor", {"monitor_id": monitor_id})
|
|
293
|
+
|
|
294
|
+
def list(self, *, status: str | None = None, limit: int | None = None) -> Page:
|
|
295
|
+
return self._list("listMonitors", status=status, limit=limit)
|
|
296
|
+
|
|
297
|
+
def update(
|
|
298
|
+
self,
|
|
299
|
+
monitor_id: str,
|
|
300
|
+
*,
|
|
301
|
+
cadence: str = NOT_GIVEN,
|
|
302
|
+
status: str = NOT_GIVEN,
|
|
303
|
+
render: str = NOT_GIVEN,
|
|
304
|
+
geo: dict[str, Any] | None = NOT_GIVEN,
|
|
305
|
+
score_drop_threshold: int = NOT_GIVEN,
|
|
306
|
+
description: str = NOT_GIVEN,
|
|
307
|
+
) -> ApiObject:
|
|
308
|
+
"""Change only the fields you pass. `geo=None` stops GEO probes. The URL can't change."""
|
|
309
|
+
return self._client.request(
|
|
310
|
+
"updateMonitor",
|
|
311
|
+
{"monitor_id": monitor_id},
|
|
312
|
+
json=_given(
|
|
313
|
+
cadence=cadence,
|
|
314
|
+
status=status,
|
|
315
|
+
render=render,
|
|
316
|
+
geo=geo,
|
|
317
|
+
score_drop_threshold=score_drop_threshold,
|
|
318
|
+
description=description,
|
|
319
|
+
),
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
def pause(self, monitor_id: str) -> ApiObject:
|
|
323
|
+
return self.update(monitor_id, status="paused")
|
|
324
|
+
|
|
325
|
+
def resume(self, monitor_id: str) -> ApiObject:
|
|
326
|
+
"""Resume scheduled runs; an overdue monitor runs at the next scheduler pass."""
|
|
327
|
+
return self.update(monitor_id, status="active")
|
|
328
|
+
|
|
329
|
+
def delete(self, monitor_id: str) -> None:
|
|
330
|
+
return self._client.request("deleteMonitor", {"monitor_id": monitor_id})
|
|
331
|
+
|
|
332
|
+
def run(self, monitor_id: str, *, idempotency_key: str | None = None) -> ApiObject:
|
|
333
|
+
"""Start a run now without moving the schedule. InsufficientCreditsError / ConflictError when it can't.
|
|
334
|
+
|
|
335
|
+
Follow the run's audit with `client.url_audits.wait(run.audit.id)`.
|
|
336
|
+
"""
|
|
337
|
+
return self._client.request("createMonitorRun", {"monitor_id": monitor_id}, idempotency_key=idempotency_key)
|
|
338
|
+
|
|
339
|
+
def list_runs(self, monitor_id: str, *, limit: int | None = None) -> Page:
|
|
340
|
+
"""Runs newest first: `status` is `skipped` (see `skip_reason`), the audit's status, or `expired`."""
|
|
341
|
+
return self._list("listMonitorRuns", {"monitor_id": monitor_id}, limit=limit)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
class WebhookEndpoints(_Resource):
|
|
345
|
+
def create(
|
|
346
|
+
self,
|
|
347
|
+
*,
|
|
348
|
+
url: str,
|
|
349
|
+
events: Sequence[str] | None = None,
|
|
350
|
+
description: str | None = None,
|
|
351
|
+
idempotency_key: str | None = None,
|
|
352
|
+
) -> ApiObject:
|
|
353
|
+
"""Register an HTTPS endpoint. The signing `secret` is returned only now; store it."""
|
|
354
|
+
return self._client.request(
|
|
355
|
+
"createWebhookEndpoint",
|
|
356
|
+
json=_compact(url=url, description=description, events=list(events) if events is not None else None),
|
|
357
|
+
idempotency_key=idempotency_key,
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
def list(self, *, limit: int | None = None) -> Page:
|
|
361
|
+
return self._list("listWebhookEndpoints", limit=limit)
|
|
362
|
+
|
|
363
|
+
def retrieve(self, endpoint_id: str) -> ApiObject:
|
|
364
|
+
return self._client.request("retrieveWebhookEndpoint", {"endpoint_id": endpoint_id})
|
|
365
|
+
|
|
366
|
+
def delete(self, endpoint_id: str) -> None:
|
|
367
|
+
return self._client.request("deleteWebhookEndpoint", {"endpoint_id": endpoint_id})
|
|
368
|
+
|
|
369
|
+
def send_test_event(self, endpoint_id: str, *, event_type: str | None = None) -> ApiObject:
|
|
370
|
+
"""Queue a sample event (default `url_audit.completed`) to check your receiver."""
|
|
371
|
+
return self._client.request(
|
|
372
|
+
"sendWebhookTestEvent",
|
|
373
|
+
{"endpoint_id": endpoint_id},
|
|
374
|
+
json=_compact(event_type=event_type),
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
def rotate_secret(self, endpoint_id: str) -> ApiObject:
|
|
378
|
+
"""Replace the signing secret; the new one is returned only now."""
|
|
379
|
+
return self._client.request("rotateWebhookEndpointSecret", {"endpoint_id": endpoint_id})
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
class WebhookDeliveries(_Resource):
|
|
383
|
+
def list(
|
|
384
|
+
self,
|
|
385
|
+
*,
|
|
386
|
+
endpoint: str | None = None,
|
|
387
|
+
status: str | None = None,
|
|
388
|
+
event_type: str | None = None,
|
|
389
|
+
limit: int | None = None,
|
|
390
|
+
) -> Page:
|
|
391
|
+
return self._list("listWebhookDeliveries", endpoint=endpoint, status=status, event_type=event_type, limit=limit)
|
|
392
|
+
|
|
393
|
+
def retrieve(self, delivery_id: str) -> ApiObject:
|
|
394
|
+
return self._client.request("retrieveWebhookDelivery", {"delivery_id": delivery_id})
|
|
395
|
+
|
|
396
|
+
def replay(self, delivery_id: str, *, idempotency_key: str | None = None) -> ApiObject:
|
|
397
|
+
"""Send the same event again (same event ID). Conflict (409 `delivery_pending`) while it's still pending."""
|
|
398
|
+
return self._client.request(
|
|
399
|
+
"replayWebhookDelivery",
|
|
400
|
+
{"delivery_id": delivery_id},
|
|
401
|
+
idempotency_key=idempotency_key,
|
|
402
|
+
)
|
web_auditor/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
web_auditor/py.typed
ADDED
|
File without changes
|
web_auditor/webhooks.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Verify webhook deliveries from Web Auditor.
|
|
2
|
+
|
|
3
|
+
event = webhooks.construct_event(request.body, request.headers["WA-Signature"], secret)
|
|
4
|
+
|
|
5
|
+
Always pass the raw request body, before any JSON parsing.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import hmac
|
|
12
|
+
import json
|
|
13
|
+
import time
|
|
14
|
+
|
|
15
|
+
from ._errors import WebAuditorError
|
|
16
|
+
from ._object import ApiObject
|
|
17
|
+
|
|
18
|
+
SIGNATURE_HEADER = "WA-Signature"
|
|
19
|
+
DEFAULT_TOLERANCE_SECONDS = 300
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class WebhookSignatureError(WebAuditorError):
|
|
23
|
+
"""The payload doesn't match its WA-Signature, or the signature is too old."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def verify_signature(
|
|
27
|
+
payload: bytes | str,
|
|
28
|
+
header: str | None,
|
|
29
|
+
secret: str,
|
|
30
|
+
*,
|
|
31
|
+
tolerance: int | None = DEFAULT_TOLERANCE_SECONDS,
|
|
32
|
+
) -> bool:
|
|
33
|
+
"""True when `header` (`t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<payload>">`) signs `payload`.
|
|
34
|
+
|
|
35
|
+
Timestamps more than `tolerance` seconds from now are rejected to stop replays; pass None to skip that.
|
|
36
|
+
"""
|
|
37
|
+
if not header:
|
|
38
|
+
return False
|
|
39
|
+
timestamp, signatures = None, []
|
|
40
|
+
for part in header.split(","):
|
|
41
|
+
key, _, value = part.strip().partition("=")
|
|
42
|
+
if key == "t":
|
|
43
|
+
timestamp = value
|
|
44
|
+
elif key == "v1" and value:
|
|
45
|
+
signatures.append(value)
|
|
46
|
+
if not signatures or timestamp is None or not timestamp.isdigit():
|
|
47
|
+
return False
|
|
48
|
+
if tolerance is not None and abs(time.time() - int(timestamp)) > tolerance:
|
|
49
|
+
return False
|
|
50
|
+
body = payload.encode() if isinstance(payload, str) else payload
|
|
51
|
+
expected = hmac.new(secret.encode(), f"{timestamp}.".encode() + body, hashlib.sha256).hexdigest()
|
|
52
|
+
return any(hmac.compare_digest(expected, signature) for signature in signatures)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def construct_event(
|
|
56
|
+
payload: bytes | str,
|
|
57
|
+
header: str | None,
|
|
58
|
+
secret: str,
|
|
59
|
+
*,
|
|
60
|
+
tolerance: int | None = DEFAULT_TOLERANCE_SECONDS,
|
|
61
|
+
) -> ApiObject:
|
|
62
|
+
"""Verify the delivery and return the event (`id`, `type`, `mode`, `data`), or raise WebhookSignatureError."""
|
|
63
|
+
if not verify_signature(payload, header, secret, tolerance=tolerance):
|
|
64
|
+
raise WebhookSignatureError("Webhook signature verification failed.")
|
|
65
|
+
return ApiObject(json.loads(payload))
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: web-auditor
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python client for the Web Auditor public API: AI-visibility (AEO/GEO) audits of web pages.
|
|
5
|
+
Project-URL: Documentation, https://api.web-auditor.enfection.com/docs
|
|
6
|
+
Author: Enfection
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: aeo,ai-search,api,geo,seo,web-auditor
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Typing :: Typed
|
|
15
|
+
Requires-Python: >=3.9
|
|
16
|
+
Requires-Dist: httpx<1,>=0.25
|
|
17
|
+
Provides-Extra: test
|
|
18
|
+
Requires-Dist: pytest>=8; extra == 'test'
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# Web Auditor Python SDK
|
|
22
|
+
|
|
23
|
+
The official Python client for the [Web Auditor public API](https://api.web-auditor.enfection.com/docs): audit how
|
|
24
|
+
well a page can be found, read and cited by AI answer engines.
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install web-auditor
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Requires Python 3.9+.
|
|
31
|
+
|
|
32
|
+
## Quick start
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from web_auditor import WebAuditor
|
|
36
|
+
|
|
37
|
+
client = WebAuditor(api_key="wa_test_…") # or set WEB_AUDITOR_API_KEY
|
|
38
|
+
|
|
39
|
+
audit = client.url_audits.create(url="https://example.com/pricing")
|
|
40
|
+
audit = client.url_audits.wait(audit.id)
|
|
41
|
+
|
|
42
|
+
print(audit.status, audit.report.score)
|
|
43
|
+
for check in audit.report.checks:
|
|
44
|
+
if check.status == "fail":
|
|
45
|
+
print(check.id, check.fix)
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Test keys (`wa_test_…`) return a realistic sample report immediately and cost nothing — build your integration with
|
|
49
|
+
one, then switch to a live key.
|
|
50
|
+
|
|
51
|
+
Responses are `ApiObject`s: dicts that also allow attribute access (`audit.report.score` or
|
|
52
|
+
`audit["report"]["score"]`). `to_dict()` returns plain dicts.
|
|
53
|
+
|
|
54
|
+
## What's covered
|
|
55
|
+
|
|
56
|
+
| Resource | Methods |
|
|
57
|
+
|---|---|
|
|
58
|
+
| `client.url_audits` | `create`, `retrieve`, `list`, `cancel`, `artifacts`, `report_pdf`, `wait` |
|
|
59
|
+
| `client.url_audit_batches` | `create`, `retrieve`, `list`, `cancel`, `wait` |
|
|
60
|
+
| `client.report_shares` | `create`, `list`, `revoke` |
|
|
61
|
+
| `client.site_audits` | `create`, `retrieve`, `list`, `list_pages`, `cancel`, `wait` |
|
|
62
|
+
| `client.brand_audits` | `create`, `retrieve`, `list`, `wait` |
|
|
63
|
+
| `client.monitors` | `create`, `retrieve`, `list`, `update`, `pause`, `resume`, `delete`, `run`, `list_runs` |
|
|
64
|
+
| `client.webhook_endpoints` | `create`, `retrieve`, `list`, `delete`, `send_test_event`, `rotate_secret` |
|
|
65
|
+
| `client.webhook_deliveries` | `list`, `retrieve`, `replay` |
|
|
66
|
+
| `client.usage`, `client.status` | `retrieve` |
|
|
67
|
+
| `client.checks` | `list` |
|
|
68
|
+
|
|
69
|
+
### GEO probes and batches
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
audit = client.url_audits.create(
|
|
73
|
+
url="https://example.com/pricing",
|
|
74
|
+
geo={"engines": ["chatgpt", "gemini"], "prompt_count": 3},
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
batch = client.url_audit_batches.create(urls=["https://example.com/a", "https://example.com/b"])
|
|
78
|
+
batch = client.url_audit_batches.wait(batch.id)
|
|
79
|
+
for member in batch.audits:
|
|
80
|
+
print(member.url, member.status, member.score)
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### Site audits
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
site = client.site_audits.create(url="https://example.com/docs/", max_pages=50)
|
|
87
|
+
site = client.site_audits.wait(site.id)
|
|
88
|
+
for page in client.site_audits.list_pages(site.id).auto_paging_iter():
|
|
89
|
+
print(page.url, page.status, page.audit and page.audit.score)
|
|
90
|
+
|
|
91
|
+
print(site.report.score)
|
|
92
|
+
for issue in site.report.top_issues:
|
|
93
|
+
print(issue.id, issue.failed, issue.examples)
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Pages are found from sitemaps, respect robots.txt and are audited one at a time per site, a few seconds apart.
|
|
97
|
+
|
|
98
|
+
### Brand audits
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
brand = client.brand_audits.create(
|
|
102
|
+
brand="Acme Payroll",
|
|
103
|
+
domain="acmepayroll.com",
|
|
104
|
+
category="payroll software for small businesses",
|
|
105
|
+
competitors=[{"name": "Gusto", "domain": "gusto.com"}],
|
|
106
|
+
)
|
|
107
|
+
brand = client.brand_audits.wait(brand.id)
|
|
108
|
+
print(brand.report.summary.brand.share_of_voice, brand.report.summary.gaps)
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### Monitors
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
monitor = client.monitors.create(url="https://example.com/pricing", cadence="weekly", score_drop_threshold=5)
|
|
115
|
+
client.monitors.update(monitor.id, geo=None) # only the fields you pass change; None clears geo
|
|
116
|
+
client.monitors.pause(monitor.id)
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Each run is compared with the previous completed run; `run.regressions` lists what got worse (and the `monitor.regression_detected` webhook announces it).
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
for run in client.monitors.list_runs(monitor.id).auto_paging_iter():
|
|
123
|
+
for regression in run.regressions or []:
|
|
124
|
+
print(run.id, regression.type) # score_drop, ai_bot_blocked, schema_removed, citation_lost, render_regression
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### Pagination
|
|
128
|
+
|
|
129
|
+
`list()` returns one page (`page.data`, `page.has_more`); `auto_paging_iter()` walks all of them:
|
|
130
|
+
|
|
131
|
+
```python
|
|
132
|
+
for audit in client.url_audits.list(status="completed").auto_paging_iter():
|
|
133
|
+
print(audit.id, audit.url)
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## Errors, retries and idempotency
|
|
137
|
+
|
|
138
|
+
Network errors, `429` and `5xx` responses are retried twice (`max_retries=`) with backoff, honouring `Retry-After`.
|
|
139
|
+
A `Retry-After` longer than a minute — such as a site's hourly audit limit — is raised straight away so you can
|
|
140
|
+
decide. Every POST sends an `Idempotency-Key` (pass `idempotency_key=` to choose it), so a retry can never start a
|
|
141
|
+
second audit.
|
|
142
|
+
|
|
143
|
+
```python
|
|
144
|
+
from web_auditor import InsufficientCreditsError, RateLimitError, WebAuditorError
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
client.url_audits.create(url="https://example.com")
|
|
148
|
+
except InsufficientCreditsError:
|
|
149
|
+
...
|
|
150
|
+
except RateLimitError as error:
|
|
151
|
+
print(error.code, error.retry_after) # rate_limited | host_rate_limit | concurrent_audit_limit
|
|
152
|
+
except WebAuditorError as error:
|
|
153
|
+
print(error.status, error.code, error.message, error.request_id)
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
| Exception | When |
|
|
157
|
+
|---|---|
|
|
158
|
+
| `InvalidRequestError` / `ConflictError` | 400, 409 (`param` names the field) |
|
|
159
|
+
| `AuthenticationError` | 401 |
|
|
160
|
+
| `InsufficientCreditsError` | 402 |
|
|
161
|
+
| `PermissionDeniedError` | 403 |
|
|
162
|
+
| `NotFoundError` | 404 |
|
|
163
|
+
| `RateLimitError` | 429 |
|
|
164
|
+
| `APIError` | 5xx |
|
|
165
|
+
| `APIConnectionError` / `APITimeoutError` | the API couldn't be reached |
|
|
166
|
+
| `PollTimeoutError` | `wait()` ran out of time (`error.last` is the latest state) |
|
|
167
|
+
|
|
168
|
+
## Webhooks
|
|
169
|
+
|
|
170
|
+
Verify every delivery with the endpoint's signing secret, over the **raw** request body:
|
|
171
|
+
|
|
172
|
+
```python
|
|
173
|
+
from web_auditor import webhooks
|
|
174
|
+
|
|
175
|
+
@app.post("/webhooks/web-auditor")
|
|
176
|
+
def receive(request):
|
|
177
|
+
try:
|
|
178
|
+
event = webhooks.construct_event(request.body, request.headers.get("WA-Signature"), WEBHOOK_SECRET)
|
|
179
|
+
except webhooks.WebhookSignatureError:
|
|
180
|
+
return Response(status=400)
|
|
181
|
+
if event.type == "url_audit.completed":
|
|
182
|
+
...
|
|
183
|
+
return Response(status=204)
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
Deliveries can arrive more than once (retries and replays keep the same `event.id`), so deduplicate on it.
|
|
187
|
+
|
|
188
|
+
## Configuration
|
|
189
|
+
|
|
190
|
+
```python
|
|
191
|
+
WebAuditor(
|
|
192
|
+
api_key="wa_live_…",
|
|
193
|
+
base_url="https://api.web-auditor.enfection.com", # or WEB_AUDITOR_BASE_URL
|
|
194
|
+
timeout=60,
|
|
195
|
+
max_retries=2,
|
|
196
|
+
http_client=None, # your own httpx.Client (proxies, custom transport)
|
|
197
|
+
)
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
`WebAuditor` can be used as a context manager to close its connections.
|
|
201
|
+
|
|
202
|
+
## Development
|
|
203
|
+
|
|
204
|
+
```bash
|
|
205
|
+
pip install -e '.[test]'
|
|
206
|
+
pytest
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
`tests/test_contract.py` checks the SDK against `../openapi.json`, which the backend regenerates with
|
|
210
|
+
`python manage.py export_public_openapi`.
|
|
211
|
+
|
|
212
|
+
## License
|
|
213
|
+
|
|
214
|
+
MIT — see [LICENSE](LICENSE). Release notes are in [CHANGELOG.md](CHANGELOG.md).
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
web_auditor/__init__.py,sha256=7cstv0xYXJbW6pIdPplSh5Rg2sDJMdaqPgvx2KDNwOg,919
|
|
2
|
+
web_auditor/_client.py,sha256=JbIC0DWLYlq6DIzNZmFvZzGaXy4UaRw6a6sjWUESUok,7983
|
|
3
|
+
web_auditor/_errors.py,sha256=1OCENivN7g-KgJPr1k0LUNO2rCqcGVPtVmXJMeLjpdw,3277
|
|
4
|
+
web_auditor/_object.py,sha256=hdwMNkTPH6Dy-HkYagHy1d-i-olwU7AAz_ftlt4k1cc,1172
|
|
5
|
+
web_auditor/_operations.py,sha256=tBjLkukYZYoUNoK422ivU3WwqszqdnOYDmmVASKwgig,2738
|
|
6
|
+
web_auditor/_resources.py,sha256=Sb_8pdJN-7dR2E2TEDmlc81Z0d0u8ImoOJYat066wXs,16278
|
|
7
|
+
web_auditor/_version.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
8
|
+
web_auditor/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
web_auditor/webhooks.py,sha256=QX-A1Yzdo_32ADrLa1rRJccuySLQukh8PzZmJSqrffo,2161
|
|
10
|
+
web_auditor-0.1.0.dist-info/METADATA,sha256=7qR7SFeJOcAmUl7h0xNSHpLKNxIYisfrazLl9p_q0QU,7194
|
|
11
|
+
web_auditor-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
12
|
+
web_auditor-0.1.0.dist-info/licenses/LICENSE,sha256=3-NjXzwp-pdbFX6LwAnEp2mdSrxych-aHgM7K0xy1VM,1066
|
|
13
|
+
web_auditor-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Enfection
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|