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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: seoscoreapi
3
- Version: 1.3.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.3.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.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)
@@ -5,4 +5,5 @@ seoscoreapi.egg-info/PKG-INFO
5
5
  seoscoreapi.egg-info/SOURCES.txt
6
6
  seoscoreapi.egg-info/dependency_links.txt
7
7
  seoscoreapi.egg-info/requires.txt
8
- seoscoreapi.egg-info/top_level.txt
8
+ seoscoreapi.egg-info/top_level.txt
9
+ tests/test_deep_audit.py
@@ -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