rankright 1.0.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.
@@ -0,0 +1,87 @@
1
+ Metadata-Version: 2.4
2
+ Name: rankright
3
+ Version: 1.0.0
4
+ Summary: Thin Python client for the RankRight API (SEO tracking + AI Visibility): RPCs, jobs, exports, OAuth, webhook verification.
5
+ Author: RankRight
6
+ License: MIT
7
+ Project-URL: Homepage, https://rankright.dev/
8
+ Project-URL: Documentation, https://app.rankright.dev/developers.md
9
+ Project-URL: API changelog, https://app.rankright.dev/changelog.md
10
+ Keywords: rankright,seo,aeo,ai-visibility,api,mcp
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Internet :: WWW/HTTP
16
+ Requires-Python: >=3.9
17
+ Description-Content-Type: text/markdown
18
+ Requires-Dist: requests>=2.28
19
+
20
+ # rankright — Python SDK
21
+
22
+ A thin client for the [RankRight](https://rankright.dev/) API: SEO tracking and AI Visibility (AEO) for agencies. It wraps the documented REST surface — auth, retries, idempotency keys, the error envelope, job polling, exports, OAuth and webhook verification — and contains no business logic of its own. Everything it calls is documented at <https://app.rankright.dev/developers.md>.
23
+
24
+ ```bash
25
+ pip install rankright
26
+ # or, straight from the app:
27
+ pip install https://app.rankright.dev/sdk/rankright-python.zip
28
+ ```
29
+
30
+ ## Quick start
31
+
32
+ ```python
33
+ from rankright import Client, RankRightError
34
+
35
+ rr = Client(api_key="rrk_...") # or Client(access_token="rro_...") after OAuth
36
+ # or set RANKRIGHT_API_KEY in the environment
37
+
38
+ clients = rr.get_all_clients() # every RPC is a method
39
+ summary = rr.get_aeo_summary(client_id=12) # keyword arguments = the RPC's parameters
40
+ rr.rpc("update_aeo_item_status", item_id=5, status="done", client_id=12)
41
+
42
+ try:
43
+ rr.set_aeo_cadence(client_id=12, engines=["claude", "chatgpt-web"])
44
+ except RankRightError as e:
45
+ print(e.status, e.code, e.detail, e.hint) # the API's error envelope, as an exception
46
+ ```
47
+
48
+ ## Jobs and exports
49
+
50
+ ```python
51
+ job = rr.jobs.run("strategist", client_id=12) # dispatch + wait (raises if the job fails)
52
+ job = rr.jobs.create("blog_pipeline", client_id=12) # dispatch only
53
+ rr.jobs.wait(job["job_id"], on_progress=lambda j: print(j["progress_msg"]))
54
+
55
+ export = rr.exports.run() # the whole organization, one zip
56
+ rr.exports.download(export, "rankright-export.zip")
57
+ ```
58
+
59
+ ## OAuth (for CLIs and agents)
60
+
61
+ ```python
62
+ from rankright import OAuthFlow, Client
63
+
64
+ tokens = OAuthFlow("https://app.rankright.dev").login(scopes=["read", "write", "offline_access"])
65
+ rr = Client(access_token=tokens["access_token"])
66
+ # later: OAuthFlow(...).refresh(tokens["refresh_token"])
67
+ ```
68
+
69
+ `login()` registers a public client, opens the consent page in your browser, catches the redirect on a loopback port, and exchanges the code (PKCE). No key to paste.
70
+
71
+ ## Webhooks
72
+
73
+ ```python
74
+ from rankright import verify_webhook_signature
75
+
76
+ ok = verify_webhook_signature(secret, request.headers["X-RankRight-Signature"], request.get_data())
77
+ ```
78
+
79
+ ## Try it without an account
80
+
81
+ A public read-only sandbox key is published in <https://app.rankright.dev/llms.txt>; it works for every read RPC and can run the `export_org` job.
82
+
83
+ ## Versioning
84
+
85
+ The SDK follows semver. The API is path-versioned (`/api/v1`); additive changes ship without notice and are listed in the [changelog](https://app.rankright.dev/changelog.md); breaking changes only arrive in a new version, and removed methods get 60 days' notice with `Deprecation` / `Sunset` headers. Full policy: <https://app.rankright.dev/developers.md#versioning--deprecation>.
86
+
87
+ MIT licensed.
@@ -0,0 +1,68 @@
1
+ # rankright — Python SDK
2
+
3
+ A thin client for the [RankRight](https://rankright.dev/) API: SEO tracking and AI Visibility (AEO) for agencies. It wraps the documented REST surface — auth, retries, idempotency keys, the error envelope, job polling, exports, OAuth and webhook verification — and contains no business logic of its own. Everything it calls is documented at <https://app.rankright.dev/developers.md>.
4
+
5
+ ```bash
6
+ pip install rankright
7
+ # or, straight from the app:
8
+ pip install https://app.rankright.dev/sdk/rankright-python.zip
9
+ ```
10
+
11
+ ## Quick start
12
+
13
+ ```python
14
+ from rankright import Client, RankRightError
15
+
16
+ rr = Client(api_key="rrk_...") # or Client(access_token="rro_...") after OAuth
17
+ # or set RANKRIGHT_API_KEY in the environment
18
+
19
+ clients = rr.get_all_clients() # every RPC is a method
20
+ summary = rr.get_aeo_summary(client_id=12) # keyword arguments = the RPC's parameters
21
+ rr.rpc("update_aeo_item_status", item_id=5, status="done", client_id=12)
22
+
23
+ try:
24
+ rr.set_aeo_cadence(client_id=12, engines=["claude", "chatgpt-web"])
25
+ except RankRightError as e:
26
+ print(e.status, e.code, e.detail, e.hint) # the API's error envelope, as an exception
27
+ ```
28
+
29
+ ## Jobs and exports
30
+
31
+ ```python
32
+ job = rr.jobs.run("strategist", client_id=12) # dispatch + wait (raises if the job fails)
33
+ job = rr.jobs.create("blog_pipeline", client_id=12) # dispatch only
34
+ rr.jobs.wait(job["job_id"], on_progress=lambda j: print(j["progress_msg"]))
35
+
36
+ export = rr.exports.run() # the whole organization, one zip
37
+ rr.exports.download(export, "rankright-export.zip")
38
+ ```
39
+
40
+ ## OAuth (for CLIs and agents)
41
+
42
+ ```python
43
+ from rankright import OAuthFlow, Client
44
+
45
+ tokens = OAuthFlow("https://app.rankright.dev").login(scopes=["read", "write", "offline_access"])
46
+ rr = Client(access_token=tokens["access_token"])
47
+ # later: OAuthFlow(...).refresh(tokens["refresh_token"])
48
+ ```
49
+
50
+ `login()` registers a public client, opens the consent page in your browser, catches the redirect on a loopback port, and exchanges the code (PKCE). No key to paste.
51
+
52
+ ## Webhooks
53
+
54
+ ```python
55
+ from rankright import verify_webhook_signature
56
+
57
+ ok = verify_webhook_signature(secret, request.headers["X-RankRight-Signature"], request.get_data())
58
+ ```
59
+
60
+ ## Try it without an account
61
+
62
+ A public read-only sandbox key is published in <https://app.rankright.dev/llms.txt>; it works for every read RPC and can run the `export_org` job.
63
+
64
+ ## Versioning
65
+
66
+ The SDK follows semver. The API is path-versioned (`/api/v1`); additive changes ship without notice and are listed in the [changelog](https://app.rankright.dev/changelog.md); breaking changes only arrive in a new version, and removed methods get 60 days' notice with `Deprecation` / `Sunset` headers. Full policy: <https://app.rankright.dev/developers.md#versioning--deprecation>.
67
+
68
+ MIT licensed.
@@ -0,0 +1,29 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "rankright"
7
+ version = "1.0.0"
8
+ description = "Thin Python client for the RankRight API (SEO tracking + AI Visibility): RPCs, jobs, exports, OAuth, webhook verification."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "RankRight" }]
13
+ keywords = ["rankright", "seo", "aeo", "ai-visibility", "api", "mcp"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Topic :: Internet :: WWW/HTTP",
20
+ ]
21
+ dependencies = ["requests>=2.28"]
22
+
23
+ [project.urls]
24
+ Homepage = "https://rankright.dev/"
25
+ Documentation = "https://app.rankright.dev/developers.md"
26
+ "API changelog" = "https://app.rankright.dev/changelog.md"
27
+
28
+ [tool.setuptools.packages.find]
29
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,333 @@
1
+ """RankRight Python SDK — a thin client for the RankRight API.
2
+
3
+ pip install rankright (PyPI)
4
+ pip install https://app.rankright.dev/sdk/rankright-python.zip
5
+
6
+ from rankright import Client
7
+ rr = Client(api_key="rrk_...") # or access_token="rro_..." (OAuth)
8
+ rr.get_aeo_summary(client_id=12) # any RPC, as a method
9
+ rr.rpc("set_aeo_cadence", client_id=12, engines=["claude"])
10
+ job = rr.jobs.run("export_org") # dispatch + wait
11
+ rr.exports.download(job["result"]["download_url"], "export.zip")
12
+
13
+ # OAuth for CLIs / agents (opens a browser, catches the redirect locally)
14
+ tokens = OAuthFlow("https://app.rankright.dev").login(scopes=["read", "write", "offline_access"])
15
+ rr = Client(access_token=tokens["access_token"])
16
+
17
+ The SDK contains no business logic: it is the same calls documented at
18
+ https://app.rankright.dev/developers.md, with auth, retries (429 →
19
+ Retry-After), idempotency keys, the error envelope as an exception, job
20
+ polling and webhook signature verification done for you.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import base64
25
+ import hashlib
26
+ import hmac
27
+ import json
28
+ import os
29
+ import secrets
30
+ import threading
31
+ import time
32
+ import webbrowser
33
+ from http.server import BaseHTTPRequestHandler, HTTPServer
34
+ from typing import Any, Callable, Iterable, Optional
35
+ from urllib.parse import parse_qs, urlencode, urlparse
36
+
37
+ __version__ = '1.0.0'
38
+ __all__ = ['Client', 'RankRightError', 'OAuthFlow', 'verify_webhook_signature', '__version__']
39
+
40
+ DEFAULT_BASE_URL = 'https://app.rankright.dev'
41
+
42
+
43
+ class RankRightError(Exception):
44
+ """The API's error envelope as an exception: .status, .code, .detail, .hint."""
45
+
46
+ def __init__(self, status: int, code: str = 'error', detail: str = '', hint: str = '',
47
+ retry_after: Optional[float] = None, body: Any = None):
48
+ super().__init__(f'{status} {code}: {detail or ""}'.strip())
49
+ self.status, self.code, self.detail, self.hint, self.retry_after, self.body = status, code, detail, hint, retry_after, body
50
+
51
+
52
+ def verify_webhook_signature(secret: str, header: str, body: str | bytes, tolerance_seconds: int = 300) -> bool:
53
+ """Check an X-RankRight-Signature header against the raw request body."""
54
+ if isinstance(body, bytes):
55
+ body = body.decode('utf-8')
56
+ try:
57
+ parts = dict(p.split('=', 1) for p in (header or '').split(','))
58
+ ts, given = int(parts['t']), parts['v1']
59
+ except Exception:
60
+ return False
61
+ if abs(time.time() - ts) > tolerance_seconds:
62
+ return False
63
+ expected = hmac.new(secret.encode('utf-8'), f'{ts}.{body}'.encode('utf-8'), hashlib.sha256).hexdigest()
64
+ return hmac.compare_digest(expected, given)
65
+
66
+
67
+ # ---------------------------------------------------------------------------
68
+ class Client:
69
+ """HTTP client for /api/v1. Pass an rrk_ API key or an rro_ OAuth access
70
+ token (or set RANKRIGHT_API_KEY). Any RPC is available as a method:
71
+ `rr.get_all_clients()`, `rr.get_aeo_items(client_id=12, status="open")`."""
72
+
73
+ def __init__(self, api_key: Optional[str] = None, access_token: Optional[str] = None, *,
74
+ base_url: str = DEFAULT_BASE_URL, session: Any = None, timeout: float = 60.0,
75
+ max_retries: int = 3, user_agent: Optional[str] = None):
76
+ self.base_url = (base_url or os.environ.get('RANKRIGHT_BASE_URL') or DEFAULT_BASE_URL).rstrip('/')
77
+ self.token = api_key or access_token or os.environ.get('RANKRIGHT_API_KEY') or ''
78
+ self.timeout = timeout
79
+ self.max_retries = max_retries
80
+ self.user_agent = user_agent or f'rankright-python/{__version__}'
81
+ if session is None:
82
+ import requests
83
+ session = requests.Session()
84
+ self._session = session
85
+ self.jobs = _Jobs(self)
86
+ self.exports = _Exports(self)
87
+
88
+ # -- low level -----------------------------------------------------------
89
+ def request(self, method: str, path: str, *, json_body: Any = None, data: Any = None,
90
+ headers: Optional[dict] = None, stream: bool = False):
91
+ url = path if path.startswith('http') else self.base_url + path
92
+ h = {'Accept': 'application/json', 'User-Agent': self.user_agent}
93
+ if self.token:
94
+ h['Authorization'] = f'Bearer {self.token}'
95
+ if headers:
96
+ h.update(headers)
97
+ attempt = 0
98
+ while True:
99
+ r = self._session.request(method, url, json=json_body, data=data, headers=h, timeout=self.timeout, stream=stream)
100
+ if r.status_code == 429 and attempt < self.max_retries:
101
+ attempt += 1
102
+ time.sleep(float(r.headers.get('Retry-After') or 2 ** attempt))
103
+ continue
104
+ if r.status_code >= 400:
105
+ raise self._error(r)
106
+ return r
107
+
108
+ @staticmethod
109
+ def _error(r) -> RankRightError:
110
+ try:
111
+ body = r.json()
112
+ except Exception:
113
+ body = None
114
+ if isinstance(body, dict):
115
+ code = body.get('code') or body.get('error') or 'error'
116
+ return RankRightError(r.status_code, str(code), str(body.get('detail') or body.get('error_description') or body.get('error') or ''),
117
+ str(body.get('hint') or ''), _float(r.headers.get('Retry-After')), body)
118
+ return RankRightError(r.status_code, 'error', (getattr(r, 'text', '') or '')[:300])
119
+
120
+ # -- RPC -----------------------------------------------------------------
121
+ def rpc(self, method: str, idempotency_key: Optional[str] = None, **kwargs) -> Any:
122
+ """POST /api/v1/rpc/<method> with keyword arguments; returns the result."""
123
+ headers = {'Idempotency-Key': idempotency_key} if idempotency_key else None
124
+ return self.request('POST', f'/api/v1/rpc/{method}', json_body=kwargs, headers=headers).json().get('result')
125
+
126
+ def __getattr__(self, name: str) -> Callable[..., Any]:
127
+ if name.startswith('_') or name in ('jobs', 'exports'):
128
+ raise AttributeError(name)
129
+
130
+ def call(**kwargs):
131
+ return self.rpc(name, **kwargs)
132
+ call.__name__ = name
133
+ call.__doc__ = f'RPC {name} — see {self.base_url}/developers.md'
134
+ return call
135
+
136
+ # -- meta ----------------------------------------------------------------
137
+ def capabilities(self) -> dict:
138
+ return self.request('GET', '/api/v1/capabilities').json()
139
+
140
+ def openapi(self) -> dict:
141
+ return self.request('GET', '/api/v1/openapi.json').json()
142
+
143
+ def developers_md(self) -> str:
144
+ return self.request('GET', '/developers.md').text
145
+
146
+ def health(self) -> dict:
147
+ return self.request('GET', '/api/v1/health').json()
148
+
149
+
150
+ class _Jobs:
151
+ def __init__(self, client: Client):
152
+ self._c = client
153
+
154
+ def create(self, kind: str, *, client_id: Optional[int] = None, group_id: Optional[int] = None,
155
+ org_id: Optional[int] = None, payload: Optional[dict] = None, idempotency_key: Optional[str] = None,
156
+ **extra) -> dict:
157
+ body: dict[str, Any] = {'kind': kind}
158
+ if client_id is not None:
159
+ body['client_id'] = client_id
160
+ if group_id is not None:
161
+ body['group_id'] = group_id
162
+ if org_id is not None:
163
+ body['org_id'] = org_id
164
+ if payload is not None:
165
+ body['payload'] = payload
166
+ body.update(extra)
167
+ headers = {'Idempotency-Key': idempotency_key} if idempotency_key else None
168
+ return self._c.request('POST', '/api/v1/jobs', json_body=body, headers=headers).json()
169
+
170
+ def get(self, job_id: int) -> dict:
171
+ return self._c.request('GET', f'/api/v1/jobs/{int(job_id)}').json()
172
+
173
+ def cancel(self, job_id: int) -> dict:
174
+ return self._c.request('POST', f'/api/v1/jobs/{int(job_id)}/cancel').json()
175
+
176
+ def wait(self, job_id: int, *, timeout: float = 900.0, interval: float = 3.0,
177
+ on_progress: Optional[Callable[[dict], None]] = None) -> dict:
178
+ """Poll until the job is done; raise RankRightError if it failed or was cancelled."""
179
+ deadline = time.time() + timeout
180
+ while True:
181
+ job = self.get(job_id)
182
+ if on_progress:
183
+ on_progress(job)
184
+ status = job.get('status')
185
+ if status == 'done':
186
+ return job
187
+ if status in ('failed', 'cancelled'):
188
+ raise RankRightError(409, f'job_{status}', job.get('error') or f'job {job_id} {status}', body=job)
189
+ if time.time() > deadline:
190
+ raise RankRightError(408, 'job_timeout', f'job {job_id} still {status} after {timeout:.0f}s', body=job)
191
+ time.sleep(interval)
192
+
193
+ def run(self, kind: str, *, wait: bool = True, timeout: float = 900.0, **kwargs) -> dict:
194
+ """Dispatch and (by default) wait for the result."""
195
+ created = self.create(kind, **kwargs)
196
+ return self.wait(created['job_id'], timeout=timeout) if wait else created
197
+
198
+
199
+ class _Exports:
200
+ def __init__(self, client: Client):
201
+ self._c = client
202
+
203
+ def run(self, *, org_id: Optional[int] = None, wait: bool = True, timeout: float = 900.0) -> dict:
204
+ """POST export_org (+wait). Returns the job; the zip is at job['result']['download_url']."""
205
+ return self._c.jobs.run('export_org', org_id=org_id, wait=wait, timeout=timeout)
206
+
207
+ def list(self) -> list:
208
+ return self._c.rpc('list_org_exports') or []
209
+
210
+ def download(self, download_url_or_job: Any, dest: str) -> str:
211
+ """Save an export zip to `dest`. Accepts the download_url, a job dict, or an export listing row."""
212
+ url = download_url_or_job
213
+ if isinstance(url, dict):
214
+ url = (url.get('result') or {}).get('download_url') or url.get('download_url')
215
+ if not url:
216
+ raise ValueError('no download_url')
217
+ r = self._c.request('GET', url, headers={'Accept': 'application/zip'})
218
+ with open(dest, 'wb') as f:
219
+ f.write(r.content)
220
+ return dest
221
+
222
+
223
+ # ---------------------------------------------------------------------------
224
+ class OAuthFlow:
225
+ """Authorization code + PKCE for a CLI or agent: registers a public client
226
+ (dynamic registration), opens the consent page in the browser, catches
227
+ the redirect on a loopback port, exchanges the code. Returns the token
228
+ response ({access_token, refresh_token?, expires_in, scope})."""
229
+
230
+ def __init__(self, base_url: str = DEFAULT_BASE_URL, *, client_name: str = 'rankright-python',
231
+ session: Any = None, timeout: float = 30.0):
232
+ self.base_url = base_url.rstrip('/')
233
+ self.client_name = client_name
234
+ self.timeout = timeout
235
+ if session is None:
236
+ import requests
237
+ session = requests.Session()
238
+ self._session = session
239
+ self.client_id: Optional[str] = None
240
+
241
+ def metadata(self) -> dict:
242
+ r = self._session.request('GET', self.base_url + '/.well-known/oauth-authorization-server', timeout=self.timeout)
243
+ r.raise_for_status()
244
+ return r.json()
245
+
246
+ def register(self, redirect_uri: str) -> str:
247
+ r = self._session.request('POST', self.base_url + '/oauth/register', timeout=self.timeout,
248
+ json={'client_name': self.client_name, 'redirect_uris': [redirect_uri],
249
+ 'token_endpoint_auth_method': 'none'})
250
+ if r.status_code >= 400:
251
+ raise RankRightError(r.status_code, 'registration_failed', getattr(r, 'text', '')[:300])
252
+ self.client_id = r.json()['client_id']
253
+ return self.client_id
254
+
255
+ @staticmethod
256
+ def pkce() -> tuple[str, str]:
257
+ verifier = secrets.token_urlsafe(48)
258
+ challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b'=').decode()
259
+ return verifier, challenge
260
+
261
+ def authorization_url(self, redirect_uri: str, scopes: Iterable[str], state: str, challenge: str) -> str:
262
+ md = self.metadata()
263
+ q = urlencode({'response_type': 'code', 'client_id': self.client_id, 'redirect_uri': redirect_uri,
264
+ 'scope': ' '.join(scopes), 'state': state, 'code_challenge': challenge,
265
+ 'code_challenge_method': 'S256', 'resource': self.base_url + '/api/v1/mcp'})
266
+ return md['authorization_endpoint'] + ('&' if '?' in md['authorization_endpoint'] else '?') + q
267
+
268
+ def exchange(self, code: str, verifier: str, redirect_uri: str) -> dict:
269
+ md = self.metadata()
270
+ r = self._session.request('POST', md['token_endpoint'], timeout=self.timeout,
271
+ data={'grant_type': 'authorization_code', 'code': code, 'code_verifier': verifier,
272
+ 'redirect_uri': redirect_uri, 'client_id': self.client_id})
273
+ if r.status_code >= 400:
274
+ raise Client._error(r)
275
+ return r.json()
276
+
277
+ def refresh(self, refresh_token: str) -> dict:
278
+ md = self.metadata()
279
+ r = self._session.request('POST', md['token_endpoint'], timeout=self.timeout,
280
+ data={'grant_type': 'refresh_token', 'refresh_token': refresh_token, 'client_id': self.client_id})
281
+ if r.status_code >= 400:
282
+ raise Client._error(r)
283
+ return r.json()
284
+
285
+ def login(self, scopes: Iterable[str] = ('read', 'write', 'jobs', 'offline_access'), *, open_browser: bool = True,
286
+ timeout: float = 300.0, port: int = 0) -> dict:
287
+ """Interactive login: returns the token response. Prints the URL if the browser cannot be opened."""
288
+ server = HTTPServer(('127.0.0.1', port), _CallbackHandler)
289
+ server.result = {} # type: ignore[attr-defined]
290
+ redirect_uri = f'http://127.0.0.1:{server.server_port}/callback'
291
+ self.register(redirect_uri)
292
+ verifier, challenge = self.pkce()
293
+ state = secrets.token_urlsafe(16)
294
+ url = self.authorization_url(redirect_uri, scopes, state, challenge)
295
+ t = threading.Thread(target=server.serve_forever, daemon=True)
296
+ t.start()
297
+ try:
298
+ if open_browser:
299
+ webbrowser.open(url)
300
+ print(f'Open this URL to authorize:\n {url}')
301
+ deadline = time.time() + timeout
302
+ while not server.result and time.time() < deadline: # type: ignore[attr-defined]
303
+ time.sleep(0.2)
304
+ finally:
305
+ server.shutdown()
306
+ res = server.result # type: ignore[attr-defined]
307
+ if not res:
308
+ raise RankRightError(408, 'oauth_timeout', 'no authorization received')
309
+ if res.get('error'):
310
+ raise RankRightError(400, res['error'], res.get('error_description', ''))
311
+ if res.get('state') != state:
312
+ raise RankRightError(400, 'state_mismatch', 'authorization response did not match the request')
313
+ return self.exchange(res['code'], verifier, redirect_uri)
314
+
315
+
316
+ class _CallbackHandler(BaseHTTPRequestHandler):
317
+ def do_GET(self): # noqa: N802
318
+ q = {k: v[0] for k, v in parse_qs(urlparse(self.path).query).items()}
319
+ self.server.result = q # type: ignore[attr-defined]
320
+ self.send_response(200)
321
+ self.send_header('Content-Type', 'text/html; charset=utf-8')
322
+ self.end_headers()
323
+ self.wfile.write(b'<html><body style="font-family:sans-serif"><h2>RankRight: you can close this window.</h2></body></html>')
324
+
325
+ def log_message(self, *args): # silence
326
+ return
327
+
328
+
329
+ def _float(v) -> Optional[float]:
330
+ try:
331
+ return float(v) if v is not None else None
332
+ except (TypeError, ValueError):
333
+ return None
@@ -0,0 +1,87 @@
1
+ Metadata-Version: 2.4
2
+ Name: rankright
3
+ Version: 1.0.0
4
+ Summary: Thin Python client for the RankRight API (SEO tracking + AI Visibility): RPCs, jobs, exports, OAuth, webhook verification.
5
+ Author: RankRight
6
+ License: MIT
7
+ Project-URL: Homepage, https://rankright.dev/
8
+ Project-URL: Documentation, https://app.rankright.dev/developers.md
9
+ Project-URL: API changelog, https://app.rankright.dev/changelog.md
10
+ Keywords: rankright,seo,aeo,ai-visibility,api,mcp
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Internet :: WWW/HTTP
16
+ Requires-Python: >=3.9
17
+ Description-Content-Type: text/markdown
18
+ Requires-Dist: requests>=2.28
19
+
20
+ # rankright — Python SDK
21
+
22
+ A thin client for the [RankRight](https://rankright.dev/) API: SEO tracking and AI Visibility (AEO) for agencies. It wraps the documented REST surface — auth, retries, idempotency keys, the error envelope, job polling, exports, OAuth and webhook verification — and contains no business logic of its own. Everything it calls is documented at <https://app.rankright.dev/developers.md>.
23
+
24
+ ```bash
25
+ pip install rankright
26
+ # or, straight from the app:
27
+ pip install https://app.rankright.dev/sdk/rankright-python.zip
28
+ ```
29
+
30
+ ## Quick start
31
+
32
+ ```python
33
+ from rankright import Client, RankRightError
34
+
35
+ rr = Client(api_key="rrk_...") # or Client(access_token="rro_...") after OAuth
36
+ # or set RANKRIGHT_API_KEY in the environment
37
+
38
+ clients = rr.get_all_clients() # every RPC is a method
39
+ summary = rr.get_aeo_summary(client_id=12) # keyword arguments = the RPC's parameters
40
+ rr.rpc("update_aeo_item_status", item_id=5, status="done", client_id=12)
41
+
42
+ try:
43
+ rr.set_aeo_cadence(client_id=12, engines=["claude", "chatgpt-web"])
44
+ except RankRightError as e:
45
+ print(e.status, e.code, e.detail, e.hint) # the API's error envelope, as an exception
46
+ ```
47
+
48
+ ## Jobs and exports
49
+
50
+ ```python
51
+ job = rr.jobs.run("strategist", client_id=12) # dispatch + wait (raises if the job fails)
52
+ job = rr.jobs.create("blog_pipeline", client_id=12) # dispatch only
53
+ rr.jobs.wait(job["job_id"], on_progress=lambda j: print(j["progress_msg"]))
54
+
55
+ export = rr.exports.run() # the whole organization, one zip
56
+ rr.exports.download(export, "rankright-export.zip")
57
+ ```
58
+
59
+ ## OAuth (for CLIs and agents)
60
+
61
+ ```python
62
+ from rankright import OAuthFlow, Client
63
+
64
+ tokens = OAuthFlow("https://app.rankright.dev").login(scopes=["read", "write", "offline_access"])
65
+ rr = Client(access_token=tokens["access_token"])
66
+ # later: OAuthFlow(...).refresh(tokens["refresh_token"])
67
+ ```
68
+
69
+ `login()` registers a public client, opens the consent page in your browser, catches the redirect on a loopback port, and exchanges the code (PKCE). No key to paste.
70
+
71
+ ## Webhooks
72
+
73
+ ```python
74
+ from rankright import verify_webhook_signature
75
+
76
+ ok = verify_webhook_signature(secret, request.headers["X-RankRight-Signature"], request.get_data())
77
+ ```
78
+
79
+ ## Try it without an account
80
+
81
+ A public read-only sandbox key is published in <https://app.rankright.dev/llms.txt>; it works for every read RPC and can run the `export_org` job.
82
+
83
+ ## Versioning
84
+
85
+ The SDK follows semver. The API is path-versioned (`/api/v1`); additive changes ship without notice and are listed in the [changelog](https://app.rankright.dev/changelog.md); breaking changes only arrive in a new version, and removed methods get 60 days' notice with `Deprecation` / `Sunset` headers. Full policy: <https://app.rankright.dev/developers.md#versioning--deprecation>.
86
+
87
+ MIT licensed.
@@ -0,0 +1,8 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/rankright/__init__.py
4
+ src/rankright.egg-info/PKG-INFO
5
+ src/rankright.egg-info/SOURCES.txt
6
+ src/rankright.egg-info/dependency_links.txt
7
+ src/rankright.egg-info/requires.txt
8
+ src/rankright.egg-info/top_level.txt
@@ -0,0 +1 @@
1
+ requests>=2.28
@@ -0,0 +1 @@
1
+ rankright