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.
- rankright-1.0.0/PKG-INFO +87 -0
- rankright-1.0.0/README.md +68 -0
- rankright-1.0.0/pyproject.toml +29 -0
- rankright-1.0.0/setup.cfg +4 -0
- rankright-1.0.0/src/rankright/__init__.py +333 -0
- rankright-1.0.0/src/rankright.egg-info/PKG-INFO +87 -0
- rankright-1.0.0/src/rankright.egg-info/SOURCES.txt +8 -0
- rankright-1.0.0/src/rankright.egg-info/dependency_links.txt +1 -0
- rankright-1.0.0/src/rankright.egg-info/requires.txt +1 -0
- rankright-1.0.0/src/rankright.egg-info/top_level.txt +1 -0
rankright-1.0.0/PKG-INFO
ADDED
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
requests>=2.28
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
rankright
|