citeflow-python 0.1.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.
- citeflow_python-0.1.0/.gitignore +11 -0
- citeflow_python-0.1.0/LICENSE +21 -0
- citeflow_python-0.1.0/PKG-INFO +134 -0
- citeflow_python-0.1.0/README.md +105 -0
- citeflow_python-0.1.0/pyproject.toml +47 -0
- citeflow_python-0.1.0/src/citeflow/__init__.py +25 -0
- citeflow_python-0.1.0/src/citeflow/_http.py +125 -0
- citeflow_python-0.1.0/src/citeflow/audits.py +72 -0
- citeflow_python-0.1.0/src/citeflow/balance.py +20 -0
- citeflow_python-0.1.0/src/citeflow/billing.py +29 -0
- citeflow_python-0.1.0/src/citeflow/client.py +44 -0
- citeflow_python-0.1.0/src/citeflow/errors.py +72 -0
- citeflow_python-0.1.0/src/citeflow/webhooks.py +118 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 CiteFlow
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: citeflow-python
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python SDK for the CiteFlow API (SEO, AEO, GEO audits).
|
|
5
|
+
Project-URL: Homepage, https://citeflow.io
|
|
6
|
+
Project-URL: Documentation, https://citeflow.io/help/partner-api
|
|
7
|
+
Author-email: CiteFlow <support@citeflow.io>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: aeo,api,audit,citeflow,geo,sdk,seo
|
|
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: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
21
|
+
Requires-Python: >=3.9
|
|
22
|
+
Requires-Dist: requests>=2.28
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
25
|
+
Requires-Dist: pytest-mock>=3.12; extra == 'dev'
|
|
26
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
27
|
+
Requires-Dist: responses>=0.25; extra == 'dev'
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# citeflow-python
|
|
31
|
+
|
|
32
|
+
Official Python SDK for the **[CiteFlow](https://citeflow.io)** Partner
|
|
33
|
+
API — programmatic SEO, AEO, and GEO audits.
|
|
34
|
+
|
|
35
|
+
[CiteFlow](https://citeflow.io) is an AI-visibility scanner: it audits how
|
|
36
|
+
well a website can be crawled, understood, and cited by AI search engines
|
|
37
|
+
(ChatGPT, Claude, Perplexity, Google AI Overviews). This SDK lets partners
|
|
38
|
+
run those audits from their own products via the CiteFlow Partner API.
|
|
39
|
+
|
|
40
|
+
- Website: <https://citeflow.io>
|
|
41
|
+
- API docs & getting started: <https://citeflow.io/help/partner-api>
|
|
42
|
+
- Dashboard (API keys, billing, webhooks): <https://citeflow.io/dashboard/api>
|
|
43
|
+
|
|
44
|
+
## Install
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pip install citeflow-python
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Python 3.9+ required. Depends on `requests`.
|
|
51
|
+
|
|
52
|
+
## Quickstart
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
import os
|
|
56
|
+
from citeflow import Citeflow
|
|
57
|
+
|
|
58
|
+
client = Citeflow(api_key=os.environ["CITEFLOW_API_KEY"])
|
|
59
|
+
|
|
60
|
+
# Create an audit and wait for the result (~30s for SEO).
|
|
61
|
+
audit = client.audits.create(url="https://example.com", type="seo")
|
|
62
|
+
result = client.audits.wait_for_completion(audit["audit_id"], timeout=90)
|
|
63
|
+
|
|
64
|
+
if result["status"] == "complete":
|
|
65
|
+
print("Scores:", result["scores"])
|
|
66
|
+
elif result["status"] == "failed":
|
|
67
|
+
print("Failure:", result["failure_reason"])
|
|
68
|
+
|
|
69
|
+
# Check remaining balance.
|
|
70
|
+
b = client.balance.retrieve()
|
|
71
|
+
print(f"Balance: {b['balance_usd']} ({b['balance']} credits)")
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Features
|
|
75
|
+
|
|
76
|
+
- **Auto-retry** on `429` and `5xx` with exponential back-off + jitter.
|
|
77
|
+
Honors `Retry-After`.
|
|
78
|
+
- **Auto Idempotency-Key** on every POST — safe to retry on network
|
|
79
|
+
failure without double-charging.
|
|
80
|
+
- **`wait_for_completion`** polling helper with configurable timeout.
|
|
81
|
+
- **HMAC webhook verification** with timestamp tolerance + rotation
|
|
82
|
+
grace handling.
|
|
83
|
+
- Pluggable `requests.Session` for custom proxies / adapters.
|
|
84
|
+
|
|
85
|
+
## Webhook verification
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
from citeflow import verify_webhook_signature, parse_webhook_event, CiteflowSignatureError
|
|
89
|
+
|
|
90
|
+
# In your webhook handler (Flask, FastAPI, Django, …):
|
|
91
|
+
raw_body = request.get_data() # bytes — do NOT JSON-parse first
|
|
92
|
+
try:
|
|
93
|
+
event = parse_webhook_event(
|
|
94
|
+
raw_body=raw_body,
|
|
95
|
+
headers=request.headers,
|
|
96
|
+
secret=os.environ["CITEFLOW_WEBHOOK_SECRET"],
|
|
97
|
+
)
|
|
98
|
+
# event["type"] in {"audit.completed", "audit.failed", "audit.cancelled", "balance.low"}
|
|
99
|
+
print(event["id"], event["type"], event["data"])
|
|
100
|
+
return "", 200
|
|
101
|
+
except CiteflowSignatureError:
|
|
102
|
+
return "", 400
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Errors
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
from citeflow import CiteflowError
|
|
109
|
+
|
|
110
|
+
try:
|
|
111
|
+
client.audits.create(url="invalid", type="seo")
|
|
112
|
+
except CiteflowError as err:
|
|
113
|
+
print(err.code, err.request_id)
|
|
114
|
+
if err.code == "INSUFFICIENT_CREDITS":
|
|
115
|
+
print(f"Need {err.required} more credits")
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Full error catalog: https://citeflow.io/help/partner-api#troubleshooting.
|
|
119
|
+
|
|
120
|
+
## Configuration
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
client = Citeflow(
|
|
124
|
+
api_key="ckf_…",
|
|
125
|
+
base_url="https://citeflow.io/api/v1", # override for staging
|
|
126
|
+
timeout=30.0, # per-request seconds
|
|
127
|
+
max_retries=3, # for 429 + 5xx + network
|
|
128
|
+
)
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## License
|
|
132
|
+
|
|
133
|
+
MIT. CiteFlow API itself is a paid service — see
|
|
134
|
+
https://citeflow.io/terms-of-service.
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# citeflow-python
|
|
2
|
+
|
|
3
|
+
Official Python SDK for the **[CiteFlow](https://citeflow.io)** Partner
|
|
4
|
+
API — programmatic SEO, AEO, and GEO audits.
|
|
5
|
+
|
|
6
|
+
[CiteFlow](https://citeflow.io) is an AI-visibility scanner: it audits how
|
|
7
|
+
well a website can be crawled, understood, and cited by AI search engines
|
|
8
|
+
(ChatGPT, Claude, Perplexity, Google AI Overviews). This SDK lets partners
|
|
9
|
+
run those audits from their own products via the CiteFlow Partner API.
|
|
10
|
+
|
|
11
|
+
- Website: <https://citeflow.io>
|
|
12
|
+
- API docs & getting started: <https://citeflow.io/help/partner-api>
|
|
13
|
+
- Dashboard (API keys, billing, webhooks): <https://citeflow.io/dashboard/api>
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pip install citeflow-python
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Python 3.9+ required. Depends on `requests`.
|
|
22
|
+
|
|
23
|
+
## Quickstart
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
import os
|
|
27
|
+
from citeflow import Citeflow
|
|
28
|
+
|
|
29
|
+
client = Citeflow(api_key=os.environ["CITEFLOW_API_KEY"])
|
|
30
|
+
|
|
31
|
+
# Create an audit and wait for the result (~30s for SEO).
|
|
32
|
+
audit = client.audits.create(url="https://example.com", type="seo")
|
|
33
|
+
result = client.audits.wait_for_completion(audit["audit_id"], timeout=90)
|
|
34
|
+
|
|
35
|
+
if result["status"] == "complete":
|
|
36
|
+
print("Scores:", result["scores"])
|
|
37
|
+
elif result["status"] == "failed":
|
|
38
|
+
print("Failure:", result["failure_reason"])
|
|
39
|
+
|
|
40
|
+
# Check remaining balance.
|
|
41
|
+
b = client.balance.retrieve()
|
|
42
|
+
print(f"Balance: {b['balance_usd']} ({b['balance']} credits)")
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Features
|
|
46
|
+
|
|
47
|
+
- **Auto-retry** on `429` and `5xx` with exponential back-off + jitter.
|
|
48
|
+
Honors `Retry-After`.
|
|
49
|
+
- **Auto Idempotency-Key** on every POST — safe to retry on network
|
|
50
|
+
failure without double-charging.
|
|
51
|
+
- **`wait_for_completion`** polling helper with configurable timeout.
|
|
52
|
+
- **HMAC webhook verification** with timestamp tolerance + rotation
|
|
53
|
+
grace handling.
|
|
54
|
+
- Pluggable `requests.Session` for custom proxies / adapters.
|
|
55
|
+
|
|
56
|
+
## Webhook verification
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
from citeflow import verify_webhook_signature, parse_webhook_event, CiteflowSignatureError
|
|
60
|
+
|
|
61
|
+
# In your webhook handler (Flask, FastAPI, Django, …):
|
|
62
|
+
raw_body = request.get_data() # bytes — do NOT JSON-parse first
|
|
63
|
+
try:
|
|
64
|
+
event = parse_webhook_event(
|
|
65
|
+
raw_body=raw_body,
|
|
66
|
+
headers=request.headers,
|
|
67
|
+
secret=os.environ["CITEFLOW_WEBHOOK_SECRET"],
|
|
68
|
+
)
|
|
69
|
+
# event["type"] in {"audit.completed", "audit.failed", "audit.cancelled", "balance.low"}
|
|
70
|
+
print(event["id"], event["type"], event["data"])
|
|
71
|
+
return "", 200
|
|
72
|
+
except CiteflowSignatureError:
|
|
73
|
+
return "", 400
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Errors
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
from citeflow import CiteflowError
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
client.audits.create(url="invalid", type="seo")
|
|
83
|
+
except CiteflowError as err:
|
|
84
|
+
print(err.code, err.request_id)
|
|
85
|
+
if err.code == "INSUFFICIENT_CREDITS":
|
|
86
|
+
print(f"Need {err.required} more credits")
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Full error catalog: https://citeflow.io/help/partner-api#troubleshooting.
|
|
90
|
+
|
|
91
|
+
## Configuration
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
client = Citeflow(
|
|
95
|
+
api_key="ckf_…",
|
|
96
|
+
base_url="https://citeflow.io/api/v1", # override for staging
|
|
97
|
+
timeout=30.0, # per-request seconds
|
|
98
|
+
max_retries=3, # for 429 + 5xx + network
|
|
99
|
+
)
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## License
|
|
103
|
+
|
|
104
|
+
MIT. CiteFlow API itself is a paid service — see
|
|
105
|
+
https://citeflow.io/terms-of-service.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "citeflow-python"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Official Python SDK for the CiteFlow API (SEO, AEO, GEO audits)."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [{ name = "CiteFlow", email = "support@citeflow.io" }]
|
|
13
|
+
keywords = ["citeflow", "seo", "aeo", "geo", "audit", "api", "sdk"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.9",
|
|
20
|
+
"Programming Language :: Python :: 3.10",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Programming Language :: Python :: 3.13",
|
|
24
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
25
|
+
]
|
|
26
|
+
dependencies = ["requests>=2.28"]
|
|
27
|
+
|
|
28
|
+
[project.urls]
|
|
29
|
+
Homepage = "https://citeflow.io"
|
|
30
|
+
Documentation = "https://citeflow.io/help/partner-api"
|
|
31
|
+
|
|
32
|
+
[project.optional-dependencies]
|
|
33
|
+
dev = ["pytest>=8", "pytest-mock>=3.12", "responses>=0.25", "mypy>=1.10"]
|
|
34
|
+
|
|
35
|
+
[tool.hatch.build.targets.wheel]
|
|
36
|
+
packages = ["src/citeflow"]
|
|
37
|
+
|
|
38
|
+
[tool.hatch.build.targets.sdist]
|
|
39
|
+
include = ["src/citeflow", "README.md", "LICENSE", "pyproject.toml"]
|
|
40
|
+
|
|
41
|
+
[tool.pytest.ini_options]
|
|
42
|
+
testpaths = ["tests"]
|
|
43
|
+
addopts = "-q"
|
|
44
|
+
|
|
45
|
+
[tool.mypy]
|
|
46
|
+
strict = true
|
|
47
|
+
python_version = "3.9"
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""CiteFlow Python SDK.
|
|
2
|
+
|
|
3
|
+
Public surface mirrors @citeflow/sdk for Node. See https://docs.citeflow.io.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from .client import Citeflow
|
|
7
|
+
from .errors import (
|
|
8
|
+
CiteflowError,
|
|
9
|
+
CiteflowNetworkError,
|
|
10
|
+
CiteflowSignatureError,
|
|
11
|
+
CiteflowTimeoutError,
|
|
12
|
+
)
|
|
13
|
+
from .webhooks import parse_webhook_event, verify_webhook_signature
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"Citeflow",
|
|
17
|
+
"CiteflowError",
|
|
18
|
+
"CiteflowNetworkError",
|
|
19
|
+
"CiteflowSignatureError",
|
|
20
|
+
"CiteflowTimeoutError",
|
|
21
|
+
"parse_webhook_event",
|
|
22
|
+
"verify_webhook_signature",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Base HTTP client with retry + auto Idempotency-Key.
|
|
2
|
+
|
|
3
|
+
Internal — use Citeflow.* facades, not HttpClient directly.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import random
|
|
9
|
+
import time
|
|
10
|
+
import uuid
|
|
11
|
+
from typing import Any, Optional
|
|
12
|
+
|
|
13
|
+
import requests
|
|
14
|
+
|
|
15
|
+
from .errors import CiteflowError, CiteflowNetworkError
|
|
16
|
+
|
|
17
|
+
DEFAULT_BASE_URL = "https://citeflow.io/api/v1"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class HttpClient:
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
*,
|
|
24
|
+
api_key: str,
|
|
25
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
26
|
+
timeout: float = 30.0,
|
|
27
|
+
max_retries: int = 3,
|
|
28
|
+
session: Optional[requests.Session] = None,
|
|
29
|
+
) -> None:
|
|
30
|
+
if not api_key:
|
|
31
|
+
raise ValueError(
|
|
32
|
+
"`api_key` is required. Generate one at "
|
|
33
|
+
"https://citeflow.io/dashboard/api/keys"
|
|
34
|
+
)
|
|
35
|
+
self._api_key = api_key
|
|
36
|
+
self._base_url = base_url.rstrip("/")
|
|
37
|
+
self._timeout = timeout
|
|
38
|
+
self._max_retries = max_retries
|
|
39
|
+
# Caller can inject a configured session (e.g., with custom adapters
|
|
40
|
+
# or proxy settings); otherwise use a fresh one we own.
|
|
41
|
+
self._session = session or requests.Session()
|
|
42
|
+
|
|
43
|
+
def request(
|
|
44
|
+
self,
|
|
45
|
+
*,
|
|
46
|
+
method: str,
|
|
47
|
+
path: str,
|
|
48
|
+
body: Optional[Any] = None,
|
|
49
|
+
idempotency_key: Optional[str] = None,
|
|
50
|
+
auto_idempotency: bool = True,
|
|
51
|
+
) -> dict[str, Any]:
|
|
52
|
+
url = f"{self._base_url}{path}"
|
|
53
|
+
headers = {
|
|
54
|
+
"Authorization": f"Bearer {self._api_key}",
|
|
55
|
+
"Accept": "application/json",
|
|
56
|
+
"User-Agent": "citeflow-python",
|
|
57
|
+
}
|
|
58
|
+
if body is not None:
|
|
59
|
+
headers["Content-Type"] = "application/json"
|
|
60
|
+
if method == "POST" and auto_idempotency:
|
|
61
|
+
headers["Idempotency-Key"] = idempotency_key or str(uuid.uuid4())
|
|
62
|
+
|
|
63
|
+
attempt = 0
|
|
64
|
+
while True:
|
|
65
|
+
try:
|
|
66
|
+
response = self._session.request(
|
|
67
|
+
method=method,
|
|
68
|
+
url=url,
|
|
69
|
+
headers=headers,
|
|
70
|
+
json=body if body is not None else None,
|
|
71
|
+
timeout=self._timeout,
|
|
72
|
+
)
|
|
73
|
+
except requests.RequestException as exc:
|
|
74
|
+
if self._should_retry_network(attempt):
|
|
75
|
+
self._sleep_backoff(None, attempt)
|
|
76
|
+
attempt += 1
|
|
77
|
+
continue
|
|
78
|
+
raise CiteflowNetworkError(
|
|
79
|
+
f"Network error calling {method} {path}", cause=exc
|
|
80
|
+
) from exc
|
|
81
|
+
|
|
82
|
+
if response.ok:
|
|
83
|
+
try:
|
|
84
|
+
return response.json()
|
|
85
|
+
except ValueError as exc:
|
|
86
|
+
raise CiteflowNetworkError(
|
|
87
|
+
f"Failed to parse JSON from {method} {path}", cause=exc
|
|
88
|
+
) from exc
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
err_body = response.json()
|
|
92
|
+
except ValueError:
|
|
93
|
+
err_body = {
|
|
94
|
+
"error": {
|
|
95
|
+
"code": "INTERNAL_ERROR",
|
|
96
|
+
"message": f"Non-JSON {response.status_code} response",
|
|
97
|
+
"doc_url": "https://docs.citeflow.io/errors",
|
|
98
|
+
},
|
|
99
|
+
"request_id": response.headers.get("X-Request-Id", "unknown"),
|
|
100
|
+
}
|
|
101
|
+
error = CiteflowError.from_api_response(response.status_code, err_body)
|
|
102
|
+
|
|
103
|
+
if self._should_retry_api(error, attempt):
|
|
104
|
+
self._sleep_backoff(error, attempt)
|
|
105
|
+
attempt += 1
|
|
106
|
+
continue
|
|
107
|
+
raise error
|
|
108
|
+
|
|
109
|
+
def _should_retry_network(self, attempt: int) -> bool:
|
|
110
|
+
return attempt < self._max_retries
|
|
111
|
+
|
|
112
|
+
def _should_retry_api(self, error: CiteflowError, attempt: int) -> bool:
|
|
113
|
+
if attempt >= self._max_retries:
|
|
114
|
+
return False
|
|
115
|
+
return error.status == 429 or error.status >= 500
|
|
116
|
+
|
|
117
|
+
def _sleep_backoff(self, error: Optional[CiteflowError], attempt: int) -> None:
|
|
118
|
+
# Server-supplied Retry-After always wins.
|
|
119
|
+
if error is not None and error.retry_after:
|
|
120
|
+
time.sleep(error.retry_after)
|
|
121
|
+
return
|
|
122
|
+
# 250ms · 1s · 4s with ±25% jitter.
|
|
123
|
+
base = 0.250 * (4**attempt)
|
|
124
|
+
jitter = base * (random.random() * 0.5 - 0.25)
|
|
125
|
+
time.sleep(max(0.0, base + jitter))
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Audits resource."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from typing import Any, Optional
|
|
7
|
+
|
|
8
|
+
from ._http import HttpClient
|
|
9
|
+
from .errors import CiteflowTimeoutError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AuditsResource:
|
|
13
|
+
def __init__(self, http: HttpClient) -> None:
|
|
14
|
+
self._http = http
|
|
15
|
+
|
|
16
|
+
def create(
|
|
17
|
+
self,
|
|
18
|
+
*,
|
|
19
|
+
url: str,
|
|
20
|
+
type: str, # noqa: A002 — mirror API field name
|
|
21
|
+
metadata: Optional[dict[str, Any]] = None,
|
|
22
|
+
idempotency_key: Optional[str] = None,
|
|
23
|
+
) -> dict[str, Any]:
|
|
24
|
+
body: dict[str, Any] = {"url": url, "type": type}
|
|
25
|
+
if metadata is not None:
|
|
26
|
+
body["metadata"] = metadata
|
|
27
|
+
envelope = self._http.request(
|
|
28
|
+
method="POST",
|
|
29
|
+
path="/audit",
|
|
30
|
+
body=body,
|
|
31
|
+
idempotency_key=idempotency_key,
|
|
32
|
+
)
|
|
33
|
+
return envelope["data"]
|
|
34
|
+
|
|
35
|
+
def get(self, audit_id: str) -> dict[str, Any]:
|
|
36
|
+
envelope = self._http.request(
|
|
37
|
+
method="GET",
|
|
38
|
+
path=f"/audit/{audit_id}",
|
|
39
|
+
auto_idempotency=False,
|
|
40
|
+
)
|
|
41
|
+
return envelope["data"]
|
|
42
|
+
|
|
43
|
+
def cancel(self, audit_id: str) -> dict[str, Any]:
|
|
44
|
+
envelope = self._http.request(
|
|
45
|
+
method="POST",
|
|
46
|
+
path=f"/audit/{audit_id}:cancel",
|
|
47
|
+
auto_idempotency=False,
|
|
48
|
+
)
|
|
49
|
+
return envelope["data"]
|
|
50
|
+
|
|
51
|
+
def wait_for_completion(
|
|
52
|
+
self,
|
|
53
|
+
audit_id: str,
|
|
54
|
+
*,
|
|
55
|
+
timeout: float = 90.0,
|
|
56
|
+
interval: float = 2.0,
|
|
57
|
+
) -> dict[str, Any]:
|
|
58
|
+
"""Poll until terminal status reached.
|
|
59
|
+
|
|
60
|
+
Raises CiteflowTimeoutError if `timeout` seconds elapse first.
|
|
61
|
+
Webhooks are preferred for long-running flows; use this only when
|
|
62
|
+
synchronous callers need the result.
|
|
63
|
+
"""
|
|
64
|
+
deadline = time.monotonic() + timeout
|
|
65
|
+
while True:
|
|
66
|
+
audit = self.get(audit_id)
|
|
67
|
+
status = audit.get("status")
|
|
68
|
+
if status not in ("queued", "processing"):
|
|
69
|
+
return audit
|
|
70
|
+
if time.monotonic() + interval > deadline:
|
|
71
|
+
raise CiteflowTimeoutError(audit_id, timeout)
|
|
72
|
+
time.sleep(interval)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Balance resource."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from ._http import HttpClient
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class BalanceResource:
|
|
11
|
+
def __init__(self, http: HttpClient) -> None:
|
|
12
|
+
self._http = http
|
|
13
|
+
|
|
14
|
+
def retrieve(self) -> dict[str, Any]:
|
|
15
|
+
envelope = self._http.request(
|
|
16
|
+
method="GET",
|
|
17
|
+
path="/balance",
|
|
18
|
+
auto_idempotency=False,
|
|
19
|
+
)
|
|
20
|
+
return envelope["data"]
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Billing resource."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from ._http import HttpClient
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class BillingResource:
|
|
11
|
+
def __init__(self, http: HttpClient) -> None:
|
|
12
|
+
self._http = http
|
|
13
|
+
|
|
14
|
+
def create_topup(
|
|
15
|
+
self,
|
|
16
|
+
*,
|
|
17
|
+
tier: str,
|
|
18
|
+
success_url: str,
|
|
19
|
+
cancel_url: str,
|
|
20
|
+
) -> dict[str, Any]:
|
|
21
|
+
# Each call legitimately mints a fresh Stripe Checkout Session;
|
|
22
|
+
# auto Idempotency-Key would actively confuse partners.
|
|
23
|
+
envelope = self._http.request(
|
|
24
|
+
method="POST",
|
|
25
|
+
path="/billing/topup",
|
|
26
|
+
body={"tier": tier, "success_url": success_url, "cancel_url": cancel_url},
|
|
27
|
+
auto_idempotency=False,
|
|
28
|
+
)
|
|
29
|
+
return envelope["data"]
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Top-level Citeflow client.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
|
|
5
|
+
from citeflow import Citeflow
|
|
6
|
+
client = Citeflow(api_key="ckf_…")
|
|
7
|
+
audit = client.audits.create(url="https://example.com", type="seo")
|
|
8
|
+
result = client.audits.wait_for_completion(audit["audit_id"])
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
import requests
|
|
16
|
+
|
|
17
|
+
from ._http import HttpClient
|
|
18
|
+
from .audits import AuditsResource
|
|
19
|
+
from .balance import BalanceResource
|
|
20
|
+
from .billing import BillingResource
|
|
21
|
+
from .webhooks import WebhooksResource
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Citeflow:
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
*,
|
|
28
|
+
api_key: str,
|
|
29
|
+
base_url: str = "https://citeflow.io/api/v1",
|
|
30
|
+
timeout: float = 30.0,
|
|
31
|
+
max_retries: int = 3,
|
|
32
|
+
session: Optional[requests.Session] = None,
|
|
33
|
+
) -> None:
|
|
34
|
+
self._http = HttpClient(
|
|
35
|
+
api_key=api_key,
|
|
36
|
+
base_url=base_url,
|
|
37
|
+
timeout=timeout,
|
|
38
|
+
max_retries=max_retries,
|
|
39
|
+
session=session,
|
|
40
|
+
)
|
|
41
|
+
self.audits = AuditsResource(self._http)
|
|
42
|
+
self.balance = BalanceResource(self._http)
|
|
43
|
+
self.billing = BillingResource(self._http)
|
|
44
|
+
self.webhooks = WebhooksResource(self._http)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Exception hierarchy.
|
|
2
|
+
|
|
3
|
+
All API-level errors raise CiteflowError carrying the stable `code`
|
|
4
|
+
attribute. Branch on `code`, not on HTTP status — codes are versioned
|
|
5
|
+
with the API path (`/api/v1`).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any, Optional
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CiteflowError(Exception):
|
|
14
|
+
"""Raised on any non-2xx response from the CiteFlow API."""
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
*,
|
|
19
|
+
code: str,
|
|
20
|
+
status: int,
|
|
21
|
+
message: str,
|
|
22
|
+
request_id: str,
|
|
23
|
+
doc_url: str,
|
|
24
|
+
retry_after: Optional[int] = None,
|
|
25
|
+
required: Optional[int] = None,
|
|
26
|
+
) -> None:
|
|
27
|
+
super().__init__(message)
|
|
28
|
+
self.code = code
|
|
29
|
+
self.status = status
|
|
30
|
+
self.request_id = request_id
|
|
31
|
+
self.doc_url = doc_url
|
|
32
|
+
self.retry_after = retry_after
|
|
33
|
+
self.required = required
|
|
34
|
+
|
|
35
|
+
@classmethod
|
|
36
|
+
def from_api_response(cls, status: int, body: dict[str, Any]) -> "CiteflowError":
|
|
37
|
+
err = body.get("error", {})
|
|
38
|
+
return cls(
|
|
39
|
+
code=err.get("code", "INTERNAL_ERROR"),
|
|
40
|
+
status=status,
|
|
41
|
+
message=err.get("message", "Unknown error"),
|
|
42
|
+
request_id=body.get("request_id", "unknown"),
|
|
43
|
+
doc_url=err.get("doc_url", "https://docs.citeflow.io/errors"),
|
|
44
|
+
retry_after=err.get("retryAfter"),
|
|
45
|
+
required=err.get("required"),
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
def __repr__(self) -> str:
|
|
49
|
+
return f"CiteflowError(code={self.code!r}, status={self.status}, request_id={self.request_id!r})"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class CiteflowNetworkError(Exception):
|
|
53
|
+
"""Raised on transport-layer failures (DNS, timeout, connection reset)."""
|
|
54
|
+
|
|
55
|
+
def __init__(self, message: str, cause: Optional[BaseException] = None) -> None:
|
|
56
|
+
super().__init__(message)
|
|
57
|
+
self.__cause__ = cause
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class CiteflowTimeoutError(Exception):
|
|
61
|
+
"""`wait_for_completion` exhausted its budget before reaching a terminal status."""
|
|
62
|
+
|
|
63
|
+
def __init__(self, audit_id: str, timeout_seconds: float) -> None:
|
|
64
|
+
super().__init__(
|
|
65
|
+
f"Audit {audit_id} did not reach terminal state within {timeout_seconds}s"
|
|
66
|
+
)
|
|
67
|
+
self.audit_id = audit_id
|
|
68
|
+
self.timeout_seconds = timeout_seconds
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class CiteflowSignatureError(Exception):
|
|
72
|
+
"""Webhook signature verification failed."""
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Webhook verification + replay."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import hashlib
|
|
7
|
+
import hmac
|
|
8
|
+
import json
|
|
9
|
+
import time
|
|
10
|
+
from typing import Any, Mapping, Union
|
|
11
|
+
|
|
12
|
+
from ._http import HttpClient
|
|
13
|
+
from .errors import CiteflowSignatureError
|
|
14
|
+
|
|
15
|
+
ID_HEADER = "x-citeflow-webhook-id"
|
|
16
|
+
TIMESTAMP_HEADER = "x-citeflow-webhook-timestamp"
|
|
17
|
+
SIGNATURE_HEADER = "x-citeflow-webhook-signature"
|
|
18
|
+
|
|
19
|
+
HeaderInput = Mapping[str, Union[str, list[str]]]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _get(headers: HeaderInput, name: str) -> Union[str, None]:
|
|
23
|
+
"""Case-insensitive header read across dict / Flask / FastAPI shapes."""
|
|
24
|
+
target = name.lower()
|
|
25
|
+
for key, value in headers.items():
|
|
26
|
+
if key.lower() == target:
|
|
27
|
+
if isinstance(value, list):
|
|
28
|
+
return value[0] if value else None
|
|
29
|
+
return value
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def verify_webhook_signature(
|
|
34
|
+
*,
|
|
35
|
+
raw_body: Union[str, bytes],
|
|
36
|
+
headers: HeaderInput,
|
|
37
|
+
secret: str,
|
|
38
|
+
tolerance_seconds: int = 300,
|
|
39
|
+
) -> None:
|
|
40
|
+
"""Verify a CiteFlow webhook signature.
|
|
41
|
+
|
|
42
|
+
Raises CiteflowSignatureError on any mismatch / drift / missing header.
|
|
43
|
+
During the 7-day secret-rotation grace window the header may carry
|
|
44
|
+
comma-separated `v1=` values; any matching value succeeds.
|
|
45
|
+
"""
|
|
46
|
+
event_id = _get(headers, ID_HEADER)
|
|
47
|
+
timestamp = _get(headers, TIMESTAMP_HEADER)
|
|
48
|
+
signature = _get(headers, SIGNATURE_HEADER)
|
|
49
|
+
|
|
50
|
+
if not event_id or not timestamp or not signature:
|
|
51
|
+
raise CiteflowSignatureError(
|
|
52
|
+
f"Missing webhook headers (id={bool(event_id)}, "
|
|
53
|
+
f"ts={bool(timestamp)}, sig={bool(signature)})"
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
ts_num = int(timestamp)
|
|
58
|
+
except ValueError as exc:
|
|
59
|
+
raise CiteflowSignatureError("Webhook timestamp is not a number") from exc
|
|
60
|
+
|
|
61
|
+
skew = abs(int(time.time()) - ts_num)
|
|
62
|
+
if skew > tolerance_seconds:
|
|
63
|
+
raise CiteflowSignatureError(
|
|
64
|
+
f"Webhook timestamp drift {skew}s exceeds tolerance {tolerance_seconds}s"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
body_bytes = raw_body.encode("utf-8") if isinstance(raw_body, str) else raw_body
|
|
68
|
+
signed_payload = f"{event_id}.{ts_num}.".encode("utf-8") + body_bytes
|
|
69
|
+
expected = base64.b64encode(
|
|
70
|
+
hmac.new(secret.encode("utf-8"), signed_payload, hashlib.sha256).digest()
|
|
71
|
+
).decode("ascii")
|
|
72
|
+
|
|
73
|
+
candidates = [
|
|
74
|
+
part.strip()[3:]
|
|
75
|
+
for part in signature.split(",")
|
|
76
|
+
if part.strip().startswith("v1=")
|
|
77
|
+
]
|
|
78
|
+
if not candidates:
|
|
79
|
+
raise CiteflowSignatureError("No v1= signature values present in header")
|
|
80
|
+
|
|
81
|
+
for cand in candidates:
|
|
82
|
+
if hmac.compare_digest(cand, expected):
|
|
83
|
+
return
|
|
84
|
+
raise CiteflowSignatureError("Webhook signature mismatch")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def parse_webhook_event(
|
|
88
|
+
*,
|
|
89
|
+
raw_body: str,
|
|
90
|
+
headers: HeaderInput,
|
|
91
|
+
secret: str,
|
|
92
|
+
tolerance_seconds: int = 300,
|
|
93
|
+
) -> dict[str, Any]:
|
|
94
|
+
"""Verify + JSON-parse in one call. Returns the event envelope dict."""
|
|
95
|
+
verify_webhook_signature(
|
|
96
|
+
raw_body=raw_body,
|
|
97
|
+
headers=headers,
|
|
98
|
+
secret=secret,
|
|
99
|
+
tolerance_seconds=tolerance_seconds,
|
|
100
|
+
)
|
|
101
|
+
return json.loads(raw_body)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class WebhooksResource:
|
|
105
|
+
def __init__(self, http: HttpClient) -> None:
|
|
106
|
+
self._http = http
|
|
107
|
+
|
|
108
|
+
def replay(self, delivery_id: str) -> dict[str, Any]:
|
|
109
|
+
envelope = self._http.request(
|
|
110
|
+
method="POST",
|
|
111
|
+
path=f"/webhooks/deliveries/{delivery_id}/replay",
|
|
112
|
+
auto_idempotency=False,
|
|
113
|
+
)
|
|
114
|
+
return envelope["data"]
|
|
115
|
+
|
|
116
|
+
# Mirror the Node SDK's static helpers for symmetry.
|
|
117
|
+
verify = staticmethod(verify_webhook_signature)
|
|
118
|
+
parse_and_verify = staticmethod(parse_webhook_event)
|