seoscoreapi 1.4.0__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.4.0
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,10 +83,11 @@ 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 Audits (Pro/Ultra)
86
+ ## Deep Site Audit (Pro/Ultra, or credits)
87
87
 
88
- A deep, AI-assisted audit scoring a URL across 9 dimensions (~150 AI checks). It
89
- runs asynchronously — submit, then poll — or use `deep_audit` to block for the result:
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:
90
91
 
91
92
  ```python
92
93
  import seoscoreapi as seo
@@ -100,17 +101,26 @@ result = seo.deep_audit(
100
101
  print(result["scores"]["lai_score"], result["scores"]["section_scores"])
101
102
 
102
103
  # Or drive the job yourself:
103
- job = seo.site_audit("https://yoursite.com", API_KEY)
104
- status = seo.get_site_audit(job["job_id"], API_KEY)
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}
105
106
  # status["status"] -> queued | running | completed | failed
106
- # queued -> {"queue_position", "eta_seconds"}
107
- # done -> {"result"}
107
+ # queued -> {"queue_position", "eta_seconds"}
108
+ # completed -> {"result"}
109
+ result = seo.wait_for_site_audit(job["job_id"], API_KEY, timeout=600)
108
110
 
109
- seo.engine_usage(API_KEY) # remaining deep-audit + SERP quota
111
+ seo.deep_audit_usage(API_KEY) # GET /deep-audit/usage -> {"site_audit": {"used", "remaining"}, ...}
110
112
  ```
111
113
 
112
- Deep audits require a **Pro** ($39/mo, 20/mo) or **Ultra** ($99/mo, 100/mo) key.
113
- Full engine reference: [engine.seoscoreapi.com/docs](https://engine.seoscoreapi.com/docs).
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).
114
124
 
115
125
  ## Full Documentation
116
126
 
@@ -65,10 +65,11 @@ 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 Audits (Pro/Ultra)
68
+ ## Deep Site Audit (Pro/Ultra, or credits)
69
69
 
70
- A deep, AI-assisted audit scoring a URL across 9 dimensions (~150 AI checks). It
71
- runs asynchronously — submit, then poll — or use `deep_audit` to block for the result:
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:
72
73
 
73
74
  ```python
74
75
  import seoscoreapi as seo
@@ -82,17 +83,26 @@ result = seo.deep_audit(
82
83
  print(result["scores"]["lai_score"], result["scores"]["section_scores"])
83
84
 
84
85
  # Or drive the job yourself:
85
- job = seo.site_audit("https://yoursite.com", API_KEY)
86
- status = seo.get_site_audit(job["job_id"], API_KEY)
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}
87
88
  # status["status"] -> queued | running | completed | failed
88
- # queued -> {"queue_position", "eta_seconds"}
89
- # done -> {"result"}
89
+ # queued -> {"queue_position", "eta_seconds"}
90
+ # completed -> {"result"}
91
+ result = seo.wait_for_site_audit(job["job_id"], API_KEY, timeout=600)
90
92
 
91
- seo.engine_usage(API_KEY) # remaining deep-audit + SERP quota
93
+ seo.deep_audit_usage(API_KEY) # GET /deep-audit/usage -> {"site_audit": {"used", "remaining"}, ...}
92
94
  ```
93
95
 
94
- Deep audits require a **Pro** ($39/mo, 20/mo) or **Ultra** ($99/mo, 100/mo) key.
95
- Full engine reference: [engine.seoscoreapi.com/docs](https://engine.seoscoreapi.com/docs).
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).
96
106
 
97
107
  ## Full Documentation
98
108
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "seoscoreapi"
7
- version = "1.4.0"
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"}
@@ -19,14 +19,22 @@ Full docs: https://seoscoreapi.com/docs
19
19
 
20
20
  from __future__ import annotations # lazy annotations so `list[str]` works on Python 3.8
21
21
 
22
+ import os
22
23
  import time
23
24
  from typing import Callable, Optional
24
25
 
25
26
  import requests
26
27
 
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.
28
36
  ENGINE_URL = "https://engine.seoscoreapi.com"
29
- __version__ = "1.4.0"
37
+ __version__ = "1.5.0"
30
38
 
31
39
  _HEADERS = {"User-Agent": f"seoscoreapi-python/{__version__}"}
32
40
  _TIMEOUT = 30 # seconds — default per-request timeout for engine calls
@@ -137,31 +145,47 @@ def history_domains(api_key: str) -> list:
137
145
  return r.json()["domains"]
138
146
 
139
147
 
140
- # --- Deep Audits (engine.seoscoreapi.com) — Pro/Ultra -----------------------
148
+ # --- Deep Site Audit (Pro/Ultra, or credits) ---------------------------------
141
149
 
142
- def site_audit(url: str, api_key: str, **options) -> dict:
143
- """Start a Deep Audit (Pro/Ultra). Asynchronous — returns a job
144
- ``{"job_id", "status", "poll"}``; poll it with :func:`get_site_audit`.
150
+ def _deep_base(base_url: Optional[str]) -> str:
151
+ return (base_url or DEEP_AUDIT_URL).rstrip("/")
145
152
 
146
- Optional keyword args: ``business_type``, ``is_local``,
147
- ``connected_integrations``, ``webhook_url``.
148
- """
149
- r = requests.post(
150
- f"{ENGINE_URL}/site-audit",
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",
151
163
  json={"url": url, **options},
152
164
  headers={"X-API-Key": api_key, **_HEADERS},
153
165
  timeout=_TIMEOUT,
154
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)
155
179
  r.raise_for_status()
156
180
  return r.json()
157
181
 
158
182
 
159
- def get_site_audit(job_id: str, api_key: str) -> dict:
160
- """Poll a deep audit job. Returns ``status`` plus ``queue_position``/
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``/
161
185
  ``eta_seconds`` while queued, ``progress``/``stage`` while running, and
162
186
  ``result`` when completed (or ``error`` when failed)."""
163
187
  r = requests.get(
164
- f"{ENGINE_URL}/site-audit/{job_id}",
188
+ f"{_deep_base(base_url)}/site-audit/{job_id}",
165
189
  headers={"X-API-Key": api_key, **_HEADERS},
166
190
  timeout=_TIMEOUT,
167
191
  )
@@ -169,6 +193,38 @@ def get_site_audit(job_id: str, api_key: str) -> dict:
169
193
  return r.json()
170
194
 
171
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
+
172
228
  def deep_audit(
173
229
  url: str,
174
230
  api_key: str,
@@ -176,22 +232,19 @@ def deep_audit(
176
232
  poll_interval: float = 5.0,
177
233
  timeout: float = 600.0,
178
234
  on_progress: Optional[Callable[[dict], None]] = None,
235
+ base_url: Optional[str] = None,
179
236
  **options,
180
237
  ) -> dict:
181
- """Start a deep audit and block until it finishes; return the result dict.
238
+ """Start a Deep Site Audit and block until it finishes; return the result dict.
182
239
 
183
- Handles queue backpressure (429 + ``Retry-After``) and honors the server's
184
- ETA between polls. Raises ``TimeoutError`` if it doesn't finish within
185
- ``timeout`` seconds, or ``RuntimeError`` if the audit fails.
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.
186
243
  """
244
+ base = _deep_base(base_url)
187
245
  deadline = time.monotonic() + timeout
188
246
  while True: # submit, retrying on backpressure
189
- r = requests.post(
190
- f"{ENGINE_URL}/site-audit",
191
- json={"url": url, **options},
192
- headers={"X-API-Key": api_key, **_HEADERS},
193
- timeout=_TIMEOUT,
194
- )
247
+ r = _start(url, api_key, base, options)
195
248
  if r.status_code == 429:
196
249
  retry = float(r.headers.get("Retry-After", 5))
197
250
  if time.monotonic() + retry > deadline:
@@ -201,30 +254,29 @@ def deep_audit(
201
254
  r.raise_for_status()
202
255
  job = r.json()
203
256
  break
204
-
205
- while True: # poll to completion
206
- if time.monotonic() > deadline:
207
- raise TimeoutError("Timed out waiting for audit to complete")
208
- status = get_site_audit(job["job_id"], api_key)
209
- if on_progress:
210
- on_progress(status)
211
- state = status.get("status")
212
- if state == "completed":
213
- return status["result"]
214
- if state == "failed":
215
- raise RuntimeError(status.get("error", "Audit failed"))
216
- if state == "queued" and status.get("eta_seconds"):
217
- time.sleep(min(status["eta_seconds"], 15.0))
218
- else:
219
- time.sleep(poll_interval)
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
+ )
220
265
 
221
266
 
222
- def engine_usage(api_key: str) -> dict:
223
- """Remaining engine quota (deep audits + SERP) for this key."""
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)
224
271
  r = requests.get(
225
- f"{ENGINE_URL}/usage",
272
+ f"{base}{_usage_path(base)}",
226
273
  headers={"X-API-Key": api_key, **_HEADERS},
227
274
  timeout=_TIMEOUT,
228
275
  )
229
276
  r.raise_for_status()
230
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.4.0
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,10 +83,11 @@ 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 Audits (Pro/Ultra)
86
+ ## Deep Site Audit (Pro/Ultra, or credits)
87
87
 
88
- A deep, AI-assisted audit scoring a URL across 9 dimensions (~150 AI checks). It
89
- runs asynchronously — submit, then poll — or use `deep_audit` to block for the result:
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:
90
91
 
91
92
  ```python
92
93
  import seoscoreapi as seo
@@ -100,17 +101,26 @@ result = seo.deep_audit(
100
101
  print(result["scores"]["lai_score"], result["scores"]["section_scores"])
101
102
 
102
103
  # Or drive the job yourself:
103
- job = seo.site_audit("https://yoursite.com", API_KEY)
104
- status = seo.get_site_audit(job["job_id"], API_KEY)
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}
105
106
  # status["status"] -> queued | running | completed | failed
106
- # queued -> {"queue_position", "eta_seconds"}
107
- # done -> {"result"}
107
+ # queued -> {"queue_position", "eta_seconds"}
108
+ # completed -> {"result"}
109
+ result = seo.wait_for_site_audit(job["job_id"], API_KEY, timeout=600)
108
110
 
109
- seo.engine_usage(API_KEY) # remaining deep-audit + SERP quota
111
+ seo.deep_audit_usage(API_KEY) # GET /deep-audit/usage -> {"site_audit": {"used", "remaining"}, ...}
110
112
  ```
111
113
 
112
- Deep audits require a **Pro** ($39/mo, 20/mo) or **Ultra** ($99/mo, 100/mo) key.
113
- Full engine reference: [engine.seoscoreapi.com/docs](https://engine.seoscoreapi.com/docs).
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).
114
124
 
115
125
  ## Full Documentation
116
126
 
@@ -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"
File without changes