seoscoreapi 1.3.1__tar.gz → 1.5.0__tar.gz
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.
- {seoscoreapi-1.3.1 → seoscoreapi-1.5.0}/PKG-INFO +40 -1
- {seoscoreapi-1.3.1 → seoscoreapi-1.5.0}/README.md +39 -0
- {seoscoreapi-1.3.1 → seoscoreapi-1.5.0}/pyproject.toml +1 -1
- seoscoreapi-1.5.0/seoscoreapi/__init__.py +282 -0
- {seoscoreapi-1.3.1 → seoscoreapi-1.5.0}/seoscoreapi.egg-info/PKG-INFO +40 -1
- {seoscoreapi-1.3.1 → seoscoreapi-1.5.0}/seoscoreapi.egg-info/SOURCES.txt +2 -1
- seoscoreapi-1.5.0/tests/test_deep_audit.py +114 -0
- seoscoreapi-1.3.1/seoscoreapi/__init__.py +0 -132
- {seoscoreapi-1.3.1 → seoscoreapi-1.5.0}/seoscoreapi.egg-info/dependency_links.txt +0 -0
- {seoscoreapi-1.3.1 → seoscoreapi-1.5.0}/seoscoreapi.egg-info/requires.txt +0 -0
- {seoscoreapi-1.3.1 → seoscoreapi-1.5.0}/seoscoreapi.egg-info/top_level.txt +0 -0
- {seoscoreapi-1.3.1 → seoscoreapi-1.5.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: seoscoreapi
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.5.0
|
|
4
4
|
Summary: Python client for SEO Score API — audit any URL for SEO issues with one function call
|
|
5
5
|
Author-email: SEO Score API <info@seoscoreapi.com>
|
|
6
6
|
License: MIT
|
|
@@ -83,6 +83,45 @@ add_monitor(
|
|
|
83
83
|
|
|
84
84
|
Slack incoming-webhook URLs are auto-formatted as Block Kit messages; any other https endpoint receives the raw event JSON.
|
|
85
85
|
|
|
86
|
+
## Deep Site Audit (Pro/Ultra, or credits)
|
|
87
|
+
|
|
88
|
+
A deep, AI-assisted audit scoring a URL across 9 dimensions (thousands of catalog
|
|
89
|
+
checks plus up to 150 AI checks). It runs asynchronously, so start a job and poll it,
|
|
90
|
+
or use `deep_audit` to block for the result:
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
import seoscoreapi as seo
|
|
94
|
+
|
|
95
|
+
# One call, waits for the result (handles the queue + backpressure for you):
|
|
96
|
+
result = seo.deep_audit(
|
|
97
|
+
"https://yoursite.com", API_KEY,
|
|
98
|
+
business_type="saas", # tunes which checks apply
|
|
99
|
+
on_progress=lambda s: print(s["status"], s.get("queue_position", s.get("progress"))),
|
|
100
|
+
)
|
|
101
|
+
print(result["scores"]["lai_score"], result["scores"]["section_scores"])
|
|
102
|
+
|
|
103
|
+
# Or drive the job yourself:
|
|
104
|
+
job = seo.site_audit("https://yoursite.com", API_KEY) # POST /site-audit
|
|
105
|
+
status = seo.get_site_audit(job["job_id"], API_KEY) # GET /site-audit/{job_id}
|
|
106
|
+
# status["status"] -> queued | running | completed | failed
|
|
107
|
+
# queued -> {"queue_position", "eta_seconds"}
|
|
108
|
+
# completed -> {"result"}
|
|
109
|
+
result = seo.wait_for_site_audit(job["job_id"], API_KEY, timeout=600)
|
|
110
|
+
|
|
111
|
+
seo.deep_audit_usage(API_KEY) # GET /deep-audit/usage -> {"site_audit": {"used", "remaining"}, ...}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Deep audits are included on **Pro** ($39/mo, 20/mo) and **Ultra** ($99/mo, 100/mo);
|
|
115
|
+
any other key can run them on purchased credits.
|
|
116
|
+
|
|
117
|
+
Since 1.5.0 the SDK calls the main host, `https://seoscoreapi.com`, like every other
|
|
118
|
+
endpoint. To point Deep Audit somewhere else (a proxy, staging, or the legacy
|
|
119
|
+
`engine.seoscoreapi.com` host, which still works), pass `base_url=` to any Deep Audit
|
|
120
|
+
function, assign `seoscoreapi.DEEP_AUDIT_URL`, or set `SEOSCORE_DEEP_AUDIT_URL`.
|
|
121
|
+
`engine_usage()` is kept as an alias of `deep_audit_usage()`.
|
|
122
|
+
|
|
123
|
+
Docs: [seoscoreapi.com/docs](https://seoscoreapi.com/docs) (Deep Site Audit section).
|
|
124
|
+
|
|
86
125
|
## Full Documentation
|
|
87
126
|
|
|
88
127
|
[seoscoreapi.com/docs](https://seoscoreapi.com/docs)
|
|
@@ -65,6 +65,45 @@ add_monitor(
|
|
|
65
65
|
|
|
66
66
|
Slack incoming-webhook URLs are auto-formatted as Block Kit messages; any other https endpoint receives the raw event JSON.
|
|
67
67
|
|
|
68
|
+
## Deep Site Audit (Pro/Ultra, or credits)
|
|
69
|
+
|
|
70
|
+
A deep, AI-assisted audit scoring a URL across 9 dimensions (thousands of catalog
|
|
71
|
+
checks plus up to 150 AI checks). It runs asynchronously, so start a job and poll it,
|
|
72
|
+
or use `deep_audit` to block for the result:
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
import seoscoreapi as seo
|
|
76
|
+
|
|
77
|
+
# One call, waits for the result (handles the queue + backpressure for you):
|
|
78
|
+
result = seo.deep_audit(
|
|
79
|
+
"https://yoursite.com", API_KEY,
|
|
80
|
+
business_type="saas", # tunes which checks apply
|
|
81
|
+
on_progress=lambda s: print(s["status"], s.get("queue_position", s.get("progress"))),
|
|
82
|
+
)
|
|
83
|
+
print(result["scores"]["lai_score"], result["scores"]["section_scores"])
|
|
84
|
+
|
|
85
|
+
# Or drive the job yourself:
|
|
86
|
+
job = seo.site_audit("https://yoursite.com", API_KEY) # POST /site-audit
|
|
87
|
+
status = seo.get_site_audit(job["job_id"], API_KEY) # GET /site-audit/{job_id}
|
|
88
|
+
# status["status"] -> queued | running | completed | failed
|
|
89
|
+
# queued -> {"queue_position", "eta_seconds"}
|
|
90
|
+
# completed -> {"result"}
|
|
91
|
+
result = seo.wait_for_site_audit(job["job_id"], API_KEY, timeout=600)
|
|
92
|
+
|
|
93
|
+
seo.deep_audit_usage(API_KEY) # GET /deep-audit/usage -> {"site_audit": {"used", "remaining"}, ...}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Deep audits are included on **Pro** ($39/mo, 20/mo) and **Ultra** ($99/mo, 100/mo);
|
|
97
|
+
any other key can run them on purchased credits.
|
|
98
|
+
|
|
99
|
+
Since 1.5.0 the SDK calls the main host, `https://seoscoreapi.com`, like every other
|
|
100
|
+
endpoint. To point Deep Audit somewhere else (a proxy, staging, or the legacy
|
|
101
|
+
`engine.seoscoreapi.com` host, which still works), pass `base_url=` to any Deep Audit
|
|
102
|
+
function, assign `seoscoreapi.DEEP_AUDIT_URL`, or set `SEOSCORE_DEEP_AUDIT_URL`.
|
|
103
|
+
`engine_usage()` is kept as an alias of `deep_audit_usage()`.
|
|
104
|
+
|
|
105
|
+
Docs: [seoscoreapi.com/docs](https://seoscoreapi.com/docs) (Deep Site Audit section).
|
|
106
|
+
|
|
68
107
|
## Full Documentation
|
|
69
108
|
|
|
70
109
|
[seoscoreapi.com/docs](https://seoscoreapi.com/docs)
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "seoscoreapi"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.5.0"
|
|
8
8
|
description = "Python client for SEO Score API — audit any URL for SEO issues with one function call"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = {text = "MIT"}
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SEO Score API - Python Client
|
|
3
|
+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
4
|
+
|
|
5
|
+
Audit any URL for SEO issues with one function call.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
from seoscoreapi import audit, signup
|
|
9
|
+
|
|
10
|
+
# Get a free API key
|
|
11
|
+
key = signup("you@example.com")
|
|
12
|
+
|
|
13
|
+
# Run an audit
|
|
14
|
+
result = audit("https://example.com", api_key=key)
|
|
15
|
+
print(f"Score: {result['score']}/100 ({result['grade']})")
|
|
16
|
+
|
|
17
|
+
Full docs: https://seoscoreapi.com/docs
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations # lazy annotations so `list[str]` works on Python 3.8
|
|
21
|
+
|
|
22
|
+
import os
|
|
23
|
+
import time
|
|
24
|
+
from typing import Callable, Optional
|
|
25
|
+
|
|
26
|
+
import requests
|
|
27
|
+
|
|
28
|
+
BASE_URL = "https://seoscoreapi.com"
|
|
29
|
+
# Deep Site Audit is served on the main host (POST /site-audit,
|
|
30
|
+
# GET /site-audit/{job_id}, GET /deep-audit/usage). Override per call with
|
|
31
|
+
# ``base_url=``, globally by assigning ``seoscoreapi.DEEP_AUDIT_URL``, or with the
|
|
32
|
+
# ``SEOSCORE_DEEP_AUDIT_URL`` environment variable.
|
|
33
|
+
DEEP_AUDIT_URL = os.environ.get("SEOSCORE_DEEP_AUDIT_URL") or BASE_URL
|
|
34
|
+
# Legacy dedicated engine host. Still serves the same endpoints (its quota
|
|
35
|
+
# endpoint is plain ``/usage``); pass it as ``base_url`` if you need it.
|
|
36
|
+
ENGINE_URL = "https://engine.seoscoreapi.com"
|
|
37
|
+
__version__ = "1.5.0"
|
|
38
|
+
|
|
39
|
+
_HEADERS = {"User-Agent": f"seoscoreapi-python/{__version__}"}
|
|
40
|
+
_TIMEOUT = 30 # seconds — default per-request timeout for engine calls
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def signup(email: str) -> str:
|
|
44
|
+
"""Sign up for a free API key. Returns the raw key (save it — shown only once)."""
|
|
45
|
+
r = requests.post(f"{BASE_URL}/signup", json={"email": email}, headers=_HEADERS)
|
|
46
|
+
r.raise_for_status()
|
|
47
|
+
return r.json()["api_key"]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def audit(url: str, api_key: str) -> dict:
|
|
51
|
+
"""Run an SEO audit on a URL. Returns score, grade, checks, and priorities."""
|
|
52
|
+
r = requests.get(f"{BASE_URL}/audit", params={"url": url}, headers={"X-API-Key": api_key, **_HEADERS})
|
|
53
|
+
r.raise_for_status()
|
|
54
|
+
return r.json()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def batch_audit(urls: list[str], api_key: str) -> dict:
|
|
58
|
+
"""Audit multiple URLs (paid plans only). Returns list of results."""
|
|
59
|
+
r = requests.post(f"{BASE_URL}/audit/batch", json={"urls": urls}, headers={"X-API-Key": api_key, **_HEADERS})
|
|
60
|
+
r.raise_for_status()
|
|
61
|
+
return r.json()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def usage(api_key: str) -> dict:
|
|
65
|
+
"""Check your API usage and limits."""
|
|
66
|
+
r = requests.get(f"{BASE_URL}/usage", headers={"X-API-Key": api_key, **_HEADERS})
|
|
67
|
+
r.raise_for_status()
|
|
68
|
+
return r.json()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def add_monitor(
|
|
72
|
+
url: str,
|
|
73
|
+
api_key: str,
|
|
74
|
+
frequency: str = "daily",
|
|
75
|
+
webhook_url: Optional[str] = None,
|
|
76
|
+
alert_threshold: int = 5,
|
|
77
|
+
) -> dict:
|
|
78
|
+
"""Set up score monitoring for a URL (paid plans only).
|
|
79
|
+
|
|
80
|
+
`webhook_url` (optional): receives a POST whenever the score drops
|
|
81
|
+
by `alert_threshold` points or more. Slack incoming-webhook URLs are
|
|
82
|
+
auto-formatted as Block Kit messages; any other https endpoint
|
|
83
|
+
receives the raw event JSON.
|
|
84
|
+
"""
|
|
85
|
+
payload: dict = {"url": url, "frequency": frequency, "alert_threshold": alert_threshold}
|
|
86
|
+
if webhook_url:
|
|
87
|
+
payload["webhook_url"] = webhook_url
|
|
88
|
+
r = requests.post(f"{BASE_URL}/monitors", json=payload, headers={"X-API-Key": api_key, **_HEADERS})
|
|
89
|
+
r.raise_for_status()
|
|
90
|
+
return r.json()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def list_monitors(api_key: str) -> list:
|
|
94
|
+
"""List your active monitors."""
|
|
95
|
+
r = requests.get(f"{BASE_URL}/monitors", headers={"X-API-Key": api_key, **_HEADERS})
|
|
96
|
+
r.raise_for_status()
|
|
97
|
+
return r.json()["monitors"]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def scoreboard_opt_out(api_key: str, opt_out: bool = True) -> dict:
|
|
101
|
+
"""Opt in or out of the public SEO scoreboard."""
|
|
102
|
+
r = requests.put(f"{BASE_URL}/scoreboard/opt-out", params={"opt_out": str(opt_out).lower()}, headers={"X-API-Key": api_key, **_HEADERS})
|
|
103
|
+
r.raise_for_status()
|
|
104
|
+
return r.json()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def compare(urls: list[str], api_key: str) -> dict:
|
|
108
|
+
"""Compare 2–5 URLs side by side with a structured diff (Basic plan or higher).
|
|
109
|
+
|
|
110
|
+
Returns each URL's score and category breakdown plus a `diff`
|
|
111
|
+
object describing who is ahead and by how much, per category and
|
|
112
|
+
per URL pair.
|
|
113
|
+
"""
|
|
114
|
+
r = requests.post(f"{BASE_URL}/compare", json={"urls": urls}, headers={"X-API-Key": api_key, **_HEADERS})
|
|
115
|
+
r.raise_for_status()
|
|
116
|
+
return r.json()
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def competitive_audit(url: str, competitor_url: str, keyword: str, api_key: str) -> dict:
|
|
120
|
+
"""Run a head-to-head competitive audit (Pro plan or higher). Returns gap score, per-check diffs, and action items."""
|
|
121
|
+
r = requests.post(f"{BASE_URL}/audit/competitive", json={"url": url, "competitor_url": competitor_url, "keyword": keyword}, headers={"X-API-Key": api_key, **_HEADERS})
|
|
122
|
+
r.raise_for_status()
|
|
123
|
+
return r.json()
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def report_url(domain: str) -> str:
|
|
127
|
+
"""Get the shareable report URL for a domain."""
|
|
128
|
+
return f"{BASE_URL}/report/{domain}"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def history(url: str, api_key: str, limit: int = 100, since: Optional[float] = None) -> dict:
|
|
132
|
+
"""Get historical audit scores and trend summary for a URL (Starter plan or higher)."""
|
|
133
|
+
params: dict = {"url": url, "limit": limit}
|
|
134
|
+
if since is not None:
|
|
135
|
+
params["since"] = since
|
|
136
|
+
r = requests.get(f"{BASE_URL}/history", params=params, headers={"X-API-Key": api_key, **_HEADERS})
|
|
137
|
+
r.raise_for_status()
|
|
138
|
+
return r.json()
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def history_domains(api_key: str) -> list:
|
|
142
|
+
"""List every domain audited by this key with latest score and 30-day trend (Starter plan or higher)."""
|
|
143
|
+
r = requests.get(f"{BASE_URL}/history/domains", headers={"X-API-Key": api_key, **_HEADERS})
|
|
144
|
+
r.raise_for_status()
|
|
145
|
+
return r.json()["domains"]
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
# --- Deep Site Audit (Pro/Ultra, or credits) ---------------------------------
|
|
149
|
+
|
|
150
|
+
def _deep_base(base_url: Optional[str]) -> str:
|
|
151
|
+
return (base_url or DEEP_AUDIT_URL).rstrip("/")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _usage_path(base: str) -> str:
|
|
155
|
+
# The main host serves the engine's quota at /deep-audit/usage (its own
|
|
156
|
+
# /usage is the per-URL audit allowance); the legacy engine host uses /usage.
|
|
157
|
+
return "/usage" if base.split("://", 1)[-1].startswith("engine.") else "/deep-audit/usage"
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _start(url: str, api_key: str, base: str, options: dict) -> requests.Response:
|
|
161
|
+
return requests.post(
|
|
162
|
+
f"{base}/site-audit",
|
|
163
|
+
json={"url": url, **options},
|
|
164
|
+
headers={"X-API-Key": api_key, **_HEADERS},
|
|
165
|
+
timeout=_TIMEOUT,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def site_audit(url: str, api_key: str, *, base_url: Optional[str] = None, **options) -> dict:
|
|
170
|
+
"""Start a Deep Site Audit. Asynchronous: returns a job
|
|
171
|
+
``{"job_id", "status", "poll"}``; poll it with :func:`get_site_audit` or
|
|
172
|
+
block on it with :func:`wait_for_site_audit`.
|
|
173
|
+
|
|
174
|
+
Optional keyword args are sent to the API as-is: ``business_type``
|
|
175
|
+
(saas | local_service | ecommerce | storefront | blog | publisher),
|
|
176
|
+
``is_local``, ``webhook_url``.
|
|
177
|
+
"""
|
|
178
|
+
r = _start(url, api_key, _deep_base(base_url), options)
|
|
179
|
+
r.raise_for_status()
|
|
180
|
+
return r.json()
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def get_site_audit(job_id: str, api_key: str, *, base_url: Optional[str] = None) -> dict:
|
|
184
|
+
"""Poll a Deep Site Audit job. Returns ``status`` plus ``queue_position``/
|
|
185
|
+
``eta_seconds`` while queued, ``progress``/``stage`` while running, and
|
|
186
|
+
``result`` when completed (or ``error`` when failed)."""
|
|
187
|
+
r = requests.get(
|
|
188
|
+
f"{_deep_base(base_url)}/site-audit/{job_id}",
|
|
189
|
+
headers={"X-API-Key": api_key, **_HEADERS},
|
|
190
|
+
timeout=_TIMEOUT,
|
|
191
|
+
)
|
|
192
|
+
r.raise_for_status()
|
|
193
|
+
return r.json()
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def wait_for_site_audit(
|
|
197
|
+
job_id: str,
|
|
198
|
+
api_key: str,
|
|
199
|
+
*,
|
|
200
|
+
poll_interval: float = 5.0,
|
|
201
|
+
timeout: float = 600.0,
|
|
202
|
+
on_progress: Optional[Callable[[dict], None]] = None,
|
|
203
|
+
base_url: Optional[str] = None,
|
|
204
|
+
) -> dict:
|
|
205
|
+
"""Poll an existing Deep Site Audit job until it finishes; return its result.
|
|
206
|
+
|
|
207
|
+
Honors the server's ETA while queued. Raises ``TimeoutError`` if it isn't
|
|
208
|
+
done within ``timeout`` seconds, or ``RuntimeError`` if the audit fails.
|
|
209
|
+
"""
|
|
210
|
+
deadline = time.monotonic() + timeout
|
|
211
|
+
while True:
|
|
212
|
+
if time.monotonic() > deadline:
|
|
213
|
+
raise TimeoutError("Timed out waiting for audit to complete")
|
|
214
|
+
status = get_site_audit(job_id, api_key, base_url=base_url)
|
|
215
|
+
if on_progress:
|
|
216
|
+
on_progress(status)
|
|
217
|
+
state = status.get("status")
|
|
218
|
+
if state == "completed":
|
|
219
|
+
return status["result"]
|
|
220
|
+
if state == "failed":
|
|
221
|
+
raise RuntimeError(status.get("error", "Audit failed"))
|
|
222
|
+
if state == "queued" and status.get("eta_seconds"):
|
|
223
|
+
time.sleep(min(float(status["eta_seconds"]), 15.0))
|
|
224
|
+
else:
|
|
225
|
+
time.sleep(poll_interval)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def deep_audit(
|
|
229
|
+
url: str,
|
|
230
|
+
api_key: str,
|
|
231
|
+
*,
|
|
232
|
+
poll_interval: float = 5.0,
|
|
233
|
+
timeout: float = 600.0,
|
|
234
|
+
on_progress: Optional[Callable[[dict], None]] = None,
|
|
235
|
+
base_url: Optional[str] = None,
|
|
236
|
+
**options,
|
|
237
|
+
) -> dict:
|
|
238
|
+
"""Start a Deep Site Audit and block until it finishes; return the result dict.
|
|
239
|
+
|
|
240
|
+
Retries the submit on queue backpressure (429 + ``Retry-After``), then
|
|
241
|
+
polls with :func:`wait_for_site_audit`. Raises ``TimeoutError`` if it
|
|
242
|
+
doesn't finish within ``timeout`` seconds, or ``RuntimeError`` if it fails.
|
|
243
|
+
"""
|
|
244
|
+
base = _deep_base(base_url)
|
|
245
|
+
deadline = time.monotonic() + timeout
|
|
246
|
+
while True: # submit, retrying on backpressure
|
|
247
|
+
r = _start(url, api_key, base, options)
|
|
248
|
+
if r.status_code == 429:
|
|
249
|
+
retry = float(r.headers.get("Retry-After", 5))
|
|
250
|
+
if time.monotonic() + retry > deadline:
|
|
251
|
+
raise TimeoutError("Timed out waiting for queue capacity")
|
|
252
|
+
time.sleep(retry)
|
|
253
|
+
continue
|
|
254
|
+
r.raise_for_status()
|
|
255
|
+
job = r.json()
|
|
256
|
+
break
|
|
257
|
+
return wait_for_site_audit(
|
|
258
|
+
job["job_id"],
|
|
259
|
+
api_key,
|
|
260
|
+
poll_interval=poll_interval,
|
|
261
|
+
timeout=max(deadline - time.monotonic(), 0.0),
|
|
262
|
+
on_progress=on_progress,
|
|
263
|
+
base_url=base,
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def deep_audit_usage(api_key: str, *, base_url: Optional[str] = None) -> dict:
|
|
268
|
+
"""Deep Site Audits used/remaining this month for this key
|
|
269
|
+
(``{"tier", "site_audit": {"used", "remaining"}, ...}``)."""
|
|
270
|
+
base = _deep_base(base_url)
|
|
271
|
+
r = requests.get(
|
|
272
|
+
f"{base}{_usage_path(base)}",
|
|
273
|
+
headers={"X-API-Key": api_key, **_HEADERS},
|
|
274
|
+
timeout=_TIMEOUT,
|
|
275
|
+
)
|
|
276
|
+
r.raise_for_status()
|
|
277
|
+
return r.json()
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def engine_usage(api_key: str, *, base_url: Optional[str] = None) -> dict:
|
|
281
|
+
"""Deprecated alias of :func:`deep_audit_usage` (kept for 1.4 callers)."""
|
|
282
|
+
return deep_audit_usage(api_key, base_url=base_url)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: seoscoreapi
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.5.0
|
|
4
4
|
Summary: Python client for SEO Score API — audit any URL for SEO issues with one function call
|
|
5
5
|
Author-email: SEO Score API <info@seoscoreapi.com>
|
|
6
6
|
License: MIT
|
|
@@ -83,6 +83,45 @@ add_monitor(
|
|
|
83
83
|
|
|
84
84
|
Slack incoming-webhook URLs are auto-formatted as Block Kit messages; any other https endpoint receives the raw event JSON.
|
|
85
85
|
|
|
86
|
+
## Deep Site Audit (Pro/Ultra, or credits)
|
|
87
|
+
|
|
88
|
+
A deep, AI-assisted audit scoring a URL across 9 dimensions (thousands of catalog
|
|
89
|
+
checks plus up to 150 AI checks). It runs asynchronously, so start a job and poll it,
|
|
90
|
+
or use `deep_audit` to block for the result:
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
import seoscoreapi as seo
|
|
94
|
+
|
|
95
|
+
# One call, waits for the result (handles the queue + backpressure for you):
|
|
96
|
+
result = seo.deep_audit(
|
|
97
|
+
"https://yoursite.com", API_KEY,
|
|
98
|
+
business_type="saas", # tunes which checks apply
|
|
99
|
+
on_progress=lambda s: print(s["status"], s.get("queue_position", s.get("progress"))),
|
|
100
|
+
)
|
|
101
|
+
print(result["scores"]["lai_score"], result["scores"]["section_scores"])
|
|
102
|
+
|
|
103
|
+
# Or drive the job yourself:
|
|
104
|
+
job = seo.site_audit("https://yoursite.com", API_KEY) # POST /site-audit
|
|
105
|
+
status = seo.get_site_audit(job["job_id"], API_KEY) # GET /site-audit/{job_id}
|
|
106
|
+
# status["status"] -> queued | running | completed | failed
|
|
107
|
+
# queued -> {"queue_position", "eta_seconds"}
|
|
108
|
+
# completed -> {"result"}
|
|
109
|
+
result = seo.wait_for_site_audit(job["job_id"], API_KEY, timeout=600)
|
|
110
|
+
|
|
111
|
+
seo.deep_audit_usage(API_KEY) # GET /deep-audit/usage -> {"site_audit": {"used", "remaining"}, ...}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Deep audits are included on **Pro** ($39/mo, 20/mo) and **Ultra** ($99/mo, 100/mo);
|
|
115
|
+
any other key can run them on purchased credits.
|
|
116
|
+
|
|
117
|
+
Since 1.5.0 the SDK calls the main host, `https://seoscoreapi.com`, like every other
|
|
118
|
+
endpoint. To point Deep Audit somewhere else (a proxy, staging, or the legacy
|
|
119
|
+
`engine.seoscoreapi.com` host, which still works), pass `base_url=` to any Deep Audit
|
|
120
|
+
function, assign `seoscoreapi.DEEP_AUDIT_URL`, or set `SEOSCORE_DEEP_AUDIT_URL`.
|
|
121
|
+
`engine_usage()` is kept as an alias of `deep_audit_usage()`.
|
|
122
|
+
|
|
123
|
+
Docs: [seoscoreapi.com/docs](https://seoscoreapi.com/docs) (Deep Site Audit section).
|
|
124
|
+
|
|
86
125
|
## Full Documentation
|
|
87
126
|
|
|
88
127
|
[seoscoreapi.com/docs](https://seoscoreapi.com/docs)
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Deep Site Audit client tests. No network: requests.get/post are patched."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from unittest import mock
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
10
|
+
|
|
11
|
+
import seoscoreapi as seo # noqa: E402
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class FakeResponse:
|
|
15
|
+
def __init__(self, payload=None, status=200, headers=None):
|
|
16
|
+
self._payload = payload or {}
|
|
17
|
+
self.status_code = status
|
|
18
|
+
self.headers = headers or {}
|
|
19
|
+
|
|
20
|
+
def json(self):
|
|
21
|
+
return self._payload
|
|
22
|
+
|
|
23
|
+
def raise_for_status(self):
|
|
24
|
+
if self.status_code >= 400:
|
|
25
|
+
raise seo.requests.HTTPError(f"HTTP {self.status_code}")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@pytest.fixture(autouse=True)
|
|
29
|
+
def no_sleep(monkeypatch):
|
|
30
|
+
monkeypatch.setattr(seo.time, "sleep", lambda s: None)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_defaults_to_main_host():
|
|
34
|
+
assert seo.DEEP_AUDIT_URL == "https://seoscoreapi.com"
|
|
35
|
+
assert seo.__version__ == "1.5.0"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_site_audit_posts_to_main_host():
|
|
39
|
+
with mock.patch.object(seo.requests, "post", return_value=FakeResponse({"job_id": "abc", "status": "queued"})) as post:
|
|
40
|
+
job = seo.site_audit("https://example.com", "k", business_type="saas")
|
|
41
|
+
assert job["job_id"] == "abc"
|
|
42
|
+
args, kwargs = post.call_args
|
|
43
|
+
assert args[0] == "https://seoscoreapi.com/site-audit"
|
|
44
|
+
assert kwargs["json"] == {"url": "https://example.com", "business_type": "saas"}
|
|
45
|
+
assert kwargs["headers"]["X-API-Key"] == "k"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_get_site_audit_url():
|
|
49
|
+
with mock.patch.object(seo.requests, "get", return_value=FakeResponse({"status": "running"})) as get:
|
|
50
|
+
seo.get_site_audit("abc", "k")
|
|
51
|
+
assert get.call_args[0][0] == "https://seoscoreapi.com/site-audit/abc"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_base_url_override_per_call():
|
|
55
|
+
with mock.patch.object(seo.requests, "post", return_value=FakeResponse({"job_id": "x"})) as post:
|
|
56
|
+
seo.site_audit("https://example.com", "k", base_url="http://localhost:9000/")
|
|
57
|
+
assert post.call_args[0][0] == "http://localhost:9000/site-audit"
|
|
58
|
+
assert "base_url" not in post.call_args[1]["json"]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_module_override(monkeypatch):
|
|
62
|
+
monkeypatch.setattr(seo, "DEEP_AUDIT_URL", "https://staging.example")
|
|
63
|
+
with mock.patch.object(seo.requests, "get", return_value=FakeResponse({})) as get:
|
|
64
|
+
seo.get_site_audit("abc", "k")
|
|
65
|
+
assert get.call_args[0][0] == "https://staging.example/site-audit/abc"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_usage_main_host_path():
|
|
69
|
+
with mock.patch.object(seo.requests, "get", return_value=FakeResponse({"tier": "pro"})) as get:
|
|
70
|
+
assert seo.deep_audit_usage("k") == {"tier": "pro"}
|
|
71
|
+
assert get.call_args[0][0] == "https://seoscoreapi.com/deep-audit/usage"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_usage_legacy_engine_path_and_alias():
|
|
75
|
+
with mock.patch.object(seo.requests, "get", return_value=FakeResponse({})) as get:
|
|
76
|
+
seo.engine_usage("k", base_url=seo.ENGINE_URL)
|
|
77
|
+
assert get.call_args[0][0] == "https://engine.seoscoreapi.com/usage"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_wait_for_site_audit_completes():
|
|
81
|
+
seq = [
|
|
82
|
+
FakeResponse({"status": "queued", "eta_seconds": 30}),
|
|
83
|
+
FakeResponse({"status": "running", "progress": 50}),
|
|
84
|
+
FakeResponse({"status": "completed", "result": {"scores": {"lai_score": 3.2}}}),
|
|
85
|
+
]
|
|
86
|
+
seen = []
|
|
87
|
+
with mock.patch.object(seo.requests, "get", side_effect=seq):
|
|
88
|
+
result = seo.wait_for_site_audit("abc", "k", on_progress=lambda s: seen.append(s["status"]))
|
|
89
|
+
assert result == {"scores": {"lai_score": 3.2}}
|
|
90
|
+
assert seen == ["queued", "running", "completed"]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_wait_for_site_audit_failed():
|
|
94
|
+
with mock.patch.object(seo.requests, "get", return_value=FakeResponse({"status": "failed", "error": "boom"})):
|
|
95
|
+
with pytest.raises(RuntimeError, match="boom"):
|
|
96
|
+
seo.wait_for_site_audit("abc", "k")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_wait_for_site_audit_timeout():
|
|
100
|
+
with mock.patch.object(seo.requests, "get", return_value=FakeResponse({"status": "running"})):
|
|
101
|
+
with pytest.raises(TimeoutError):
|
|
102
|
+
seo.wait_for_site_audit("abc", "k", timeout=0)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_deep_audit_retries_backpressure_then_polls():
|
|
106
|
+
posts = [
|
|
107
|
+
FakeResponse(status=429, headers={"Retry-After": "1"}),
|
|
108
|
+
FakeResponse({"job_id": "abc", "status": "queued"}),
|
|
109
|
+
]
|
|
110
|
+
with mock.patch.object(seo.requests, "post", side_effect=posts) as post, \
|
|
111
|
+
mock.patch.object(seo.requests, "get", return_value=FakeResponse({"status": "completed", "result": {"ok": 1}})) as get:
|
|
112
|
+
assert seo.deep_audit("https://example.com", "k") == {"ok": 1}
|
|
113
|
+
assert post.call_count == 2
|
|
114
|
+
assert get.call_args[0][0] == "https://seoscoreapi.com/site-audit/abc"
|
|
@@ -1,132 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
SEO Score API - Python Client
|
|
3
|
-
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
4
|
-
|
|
5
|
-
Audit any URL for SEO issues with one function call.
|
|
6
|
-
|
|
7
|
-
Usage:
|
|
8
|
-
from seoscoreapi import audit, signup
|
|
9
|
-
|
|
10
|
-
# Get a free API key
|
|
11
|
-
key = signup("you@example.com")
|
|
12
|
-
|
|
13
|
-
# Run an audit
|
|
14
|
-
result = audit("https://example.com", api_key=key)
|
|
15
|
-
print(f"Score: {result['score']}/100 ({result['grade']})")
|
|
16
|
-
|
|
17
|
-
Full docs: https://seoscoreapi.com/docs
|
|
18
|
-
"""
|
|
19
|
-
|
|
20
|
-
from typing import Optional
|
|
21
|
-
|
|
22
|
-
import requests
|
|
23
|
-
|
|
24
|
-
BASE_URL = "https://seoscoreapi.com"
|
|
25
|
-
__version__ = "1.3.1"
|
|
26
|
-
|
|
27
|
-
_HEADERS = {"User-Agent": f"seoscoreapi-python/{__version__}"}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
def signup(email: str) -> str:
|
|
31
|
-
"""Sign up for a free API key. Returns the raw key (save it — shown only once)."""
|
|
32
|
-
r = requests.post(f"{BASE_URL}/signup", json={"email": email}, headers=_HEADERS)
|
|
33
|
-
r.raise_for_status()
|
|
34
|
-
return r.json()["api_key"]
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
def audit(url: str, api_key: str) -> dict:
|
|
38
|
-
"""Run an SEO audit on a URL. Returns score, grade, checks, and priorities."""
|
|
39
|
-
r = requests.get(f"{BASE_URL}/audit", params={"url": url}, headers={"X-API-Key": api_key, **_HEADERS})
|
|
40
|
-
r.raise_for_status()
|
|
41
|
-
return r.json()
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
def batch_audit(urls: list[str], api_key: str) -> dict:
|
|
45
|
-
"""Audit multiple URLs (paid plans only). Returns list of results."""
|
|
46
|
-
r = requests.post(f"{BASE_URL}/audit/batch", json={"urls": urls}, headers={"X-API-Key": api_key, **_HEADERS})
|
|
47
|
-
r.raise_for_status()
|
|
48
|
-
return r.json()
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
def usage(api_key: str) -> dict:
|
|
52
|
-
"""Check your API usage and limits."""
|
|
53
|
-
r = requests.get(f"{BASE_URL}/usage", headers={"X-API-Key": api_key, **_HEADERS})
|
|
54
|
-
r.raise_for_status()
|
|
55
|
-
return r.json()
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
def add_monitor(
|
|
59
|
-
url: str,
|
|
60
|
-
api_key: str,
|
|
61
|
-
frequency: str = "daily",
|
|
62
|
-
webhook_url: Optional[str] = None,
|
|
63
|
-
alert_threshold: int = 5,
|
|
64
|
-
) -> dict:
|
|
65
|
-
"""Set up score monitoring for a URL (paid plans only).
|
|
66
|
-
|
|
67
|
-
`webhook_url` (optional): receives a POST whenever the score drops
|
|
68
|
-
by `alert_threshold` points or more. Slack incoming-webhook URLs are
|
|
69
|
-
auto-formatted as Block Kit messages; any other https endpoint
|
|
70
|
-
receives the raw event JSON.
|
|
71
|
-
"""
|
|
72
|
-
payload: dict = {"url": url, "frequency": frequency, "alert_threshold": alert_threshold}
|
|
73
|
-
if webhook_url:
|
|
74
|
-
payload["webhook_url"] = webhook_url
|
|
75
|
-
r = requests.post(f"{BASE_URL}/monitors", json=payload, headers={"X-API-Key": api_key, **_HEADERS})
|
|
76
|
-
r.raise_for_status()
|
|
77
|
-
return r.json()
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
def list_monitors(api_key: str) -> list:
|
|
81
|
-
"""List your active monitors."""
|
|
82
|
-
r = requests.get(f"{BASE_URL}/monitors", headers={"X-API-Key": api_key, **_HEADERS})
|
|
83
|
-
r.raise_for_status()
|
|
84
|
-
return r.json()["monitors"]
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
def scoreboard_opt_out(api_key: str, opt_out: bool = True) -> dict:
|
|
88
|
-
"""Opt in or out of the public SEO scoreboard."""
|
|
89
|
-
r = requests.put(f"{BASE_URL}/scoreboard/opt-out", params={"opt_out": str(opt_out).lower()}, headers={"X-API-Key": api_key, **_HEADERS})
|
|
90
|
-
r.raise_for_status()
|
|
91
|
-
return r.json()
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
def compare(urls: list[str], api_key: str) -> dict:
|
|
95
|
-
"""Compare 2–5 URLs side by side with a structured diff (Basic plan or higher).
|
|
96
|
-
|
|
97
|
-
Returns each URL's score and category breakdown plus a `diff`
|
|
98
|
-
object describing who is ahead and by how much, per category and
|
|
99
|
-
per URL pair.
|
|
100
|
-
"""
|
|
101
|
-
r = requests.post(f"{BASE_URL}/compare", json={"urls": urls}, headers={"X-API-Key": api_key, **_HEADERS})
|
|
102
|
-
r.raise_for_status()
|
|
103
|
-
return r.json()
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
def competitive_audit(url: str, competitor_url: str, keyword: str, api_key: str) -> dict:
|
|
107
|
-
"""Run a head-to-head competitive audit (Pro plan or higher). Returns gap score, per-check diffs, and action items."""
|
|
108
|
-
r = requests.post(f"{BASE_URL}/audit/competitive", json={"url": url, "competitor_url": competitor_url, "keyword": keyword}, headers={"X-API-Key": api_key, **_HEADERS})
|
|
109
|
-
r.raise_for_status()
|
|
110
|
-
return r.json()
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
def report_url(domain: str) -> str:
|
|
114
|
-
"""Get the shareable report URL for a domain."""
|
|
115
|
-
return f"{BASE_URL}/report/{domain}"
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
def history(url: str, api_key: str, limit: int = 100, since: Optional[float] = None) -> dict:
|
|
119
|
-
"""Get historical audit scores and trend summary for a URL (Starter plan or higher)."""
|
|
120
|
-
params: dict = {"url": url, "limit": limit}
|
|
121
|
-
if since is not None:
|
|
122
|
-
params["since"] = since
|
|
123
|
-
r = requests.get(f"{BASE_URL}/history", params=params, headers={"X-API-Key": api_key, **_HEADERS})
|
|
124
|
-
r.raise_for_status()
|
|
125
|
-
return r.json()
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
def history_domains(api_key: str) -> list:
|
|
129
|
-
"""List every domain audited by this key with latest score and 30-day trend (Starter plan or higher)."""
|
|
130
|
-
r = requests.get(f"{BASE_URL}/history/domains", headers={"X-API-Key": api_key, **_HEADERS})
|
|
131
|
-
r.raise_for_status()
|
|
132
|
-
return r.json()["domains"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|