checkanysite 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.
- checkanysite-0.1.0/.gitignore +11 -0
- checkanysite-0.1.0/CHANGELOG.md +16 -0
- checkanysite-0.1.0/LICENSE +21 -0
- checkanysite-0.1.0/PKG-INFO +245 -0
- checkanysite-0.1.0/README.md +216 -0
- checkanysite-0.1.0/examples/quickstart.py +48 -0
- checkanysite-0.1.0/openapi.json +2305 -0
- checkanysite-0.1.0/pyproject.toml +97 -0
- checkanysite-0.1.0/src/checkanysite/__init__.py +48 -0
- checkanysite-0.1.0/src/checkanysite/_base_client.py +268 -0
- checkanysite-0.1.0/src/checkanysite/_client.py +130 -0
- checkanysite-0.1.0/src/checkanysite/_constants.py +49 -0
- checkanysite-0.1.0/src/checkanysite/_retries.py +60 -0
- checkanysite-0.1.0/src/checkanysite/_version.py +3 -0
- checkanysite-0.1.0/src/checkanysite/errors.py +228 -0
- checkanysite-0.1.0/src/checkanysite/models/__init__.py +33 -0
- checkanysite-0.1.0/src/checkanysite/models/_base.py +37 -0
- checkanysite-0.1.0/src/checkanysite/models/_generated.py +1329 -0
- checkanysite-0.1.0/src/checkanysite/models/batch.py +82 -0
- checkanysite-0.1.0/src/checkanysite/models/meta.py +119 -0
- checkanysite-0.1.0/src/checkanysite/py.typed +0 -0
- checkanysite-0.1.0/src/checkanysite/resources/__init__.py +19 -0
- checkanysite-0.1.0/src/checkanysite/resources/_params.py +38 -0
- checkanysite-0.1.0/src/checkanysite/resources/scans.py +338 -0
- checkanysite-0.1.0/src/checkanysite/resources/sites.py +120 -0
- checkanysite-0.1.0/src/checkanysite/resources/usage.py +28 -0
- checkanysite-0.1.0/tests/conftest.py +50 -0
- checkanysite-0.1.0/tests/support.py +118 -0
- checkanysite-0.1.0/tests/test_async.py +180 -0
- checkanysite-0.1.0/tests/test_client.py +132 -0
- checkanysite-0.1.0/tests/test_errors.py +169 -0
- checkanysite-0.1.0/tests/test_models.py +172 -0
- checkanysite-0.1.0/tests/test_retries.py +272 -0
- checkanysite-0.1.0/tests/test_scans.py +271 -0
- checkanysite-0.1.0/tests/test_sites.py +166 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# The Python SDK's local environment, caches and build output (the repository's own .gitignore
|
|
2
|
+
# covers the rest). Kept here so the source package, which carries this file, names only these.
|
|
3
|
+
.venv/
|
|
4
|
+
dist/
|
|
5
|
+
build/
|
|
6
|
+
*.egg-info/
|
|
7
|
+
__pycache__/
|
|
8
|
+
.pytest_cache/
|
|
9
|
+
.mypy_cache/
|
|
10
|
+
.ruff_cache/
|
|
11
|
+
.coverage
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to the `checkanysite` Python library. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow [semantic versioning](https://semver.org).
|
|
4
|
+
|
|
5
|
+
## 0.1.0 (unreleased)
|
|
6
|
+
|
|
7
|
+
The first version, for the CheckAnySite API v1.
|
|
8
|
+
|
|
9
|
+
- `CheckAnySite` and `AsyncCheckAnySite`, configured by argument or by `CHECKANYSITE_API_KEY` and `CHECKANYSITE_BASE_URL`.
|
|
10
|
+
- The Lookup API: `sites.retrieve` and `sites.batch`.
|
|
11
|
+
- The Scan API: `scans.create`, `scans.retrieve` and `scans.batch`; `scans.wait`, `scans.create_and_wait` and `scans.batch_and_wait`, which poll as often as `Retry-After` asks and raise `ScanTimeoutError` at their timeout.
|
|
12
|
+
- `usage.retrieve`.
|
|
13
|
+
- Typed answers generated from the API's OpenAPI document, keeping fields and values added later; `.meta` with the request id, credits and rate limit from the headers.
|
|
14
|
+
- Errors by status (`BadRequestError` … `InternalServerError`), connection errors and timeouts, each with the API's code, message, parameter and request id.
|
|
15
|
+
- Retries with exponential backoff and jitter, honouring `Retry-After`; an automatic `Idempotency-Key` on every POST, kept across its retries.
|
|
16
|
+
- A client never closed closes its connections when it is garbage collected, so it leaves no `ResourceWarning`.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 CheckAnySite
|
|
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,245 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: checkanysite
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: The official Python library for the CheckAnySite API: what is this website, with evidence.
|
|
5
|
+
Project-URL: Homepage, https://checkanysite.com
|
|
6
|
+
Project-URL: Documentation, https://docs.checkanysite.com
|
|
7
|
+
Author-email: CheckAnySite <hello@checkanysite.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: ai,api,checkanysite,dns,sdk,tech stack,website
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
22
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
23
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
24
|
+
Classifier: Typing :: Typed
|
|
25
|
+
Requires-Python: >=3.9
|
|
26
|
+
Requires-Dist: httpx<1,>=0.23.0
|
|
27
|
+
Requires-Dist: pydantic<3,>=2.5.0
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# CheckAnySite Python library
|
|
31
|
+
|
|
32
|
+
The official Python library for the [CheckAnySite API](https://docs.checkanysite.com): what is this website? Ask about any public site and get, with evidence, the traces of AI website builders, its AI readiness, popularity, technology, DNS records, trust signals, a summary and its history.
|
|
33
|
+
|
|
34
|
+
It gives typed answers (pydantic models), a sync and an async client, retries with backoff, idempotent POSTs and the credits each call cost. Python 3.9 or newer; its only dependencies are httpx and pydantic.
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
pip install checkanysite
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Quickstart
|
|
43
|
+
|
|
44
|
+
Get a key free at [checkanysite.com/account/keys](https://checkanysite.com/account/keys) and put it in the environment:
|
|
45
|
+
|
|
46
|
+
```sh
|
|
47
|
+
export CHECKANYSITE_API_KEY="cas_live_…"
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
from checkanysite import CheckAnySite
|
|
52
|
+
|
|
53
|
+
client = CheckAnySite() # reads CHECKANYSITE_API_KEY
|
|
54
|
+
|
|
55
|
+
site = client.sites.retrieve("stripe.com", checks=["ai", "tech"], max_age_days=30)
|
|
56
|
+
print(site.ai.level) # "high", "medium" or "none"
|
|
57
|
+
for technology in site.tech.technologies:
|
|
58
|
+
print(technology.name, technology.category)
|
|
59
|
+
|
|
60
|
+
print(site.meta.credits_cost, site.meta.credits_remaining, site.meta.request_id)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
There are two APIs behind one key:
|
|
64
|
+
|
|
65
|
+
- **Lookup** (`client.sites`) answers from stored results at once and never scans. A found result costs each check asked at its stored price; a miss (`no_result`) is free.
|
|
66
|
+
- **Scan** (`client.scans`) scans a site live. A scan answers asynchronously: start it, then poll it, or let `create_and_wait` poll for you.
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
scan = client.scans.create("example.com", checks=["readiness"]) # a Scan, maybe still queued
|
|
70
|
+
scan = client.scans.retrieve(scan.id) # read it again, free
|
|
71
|
+
scan = client.scans.wait(scan) # poll it until it is complete, free
|
|
72
|
+
|
|
73
|
+
scan = client.scans.create_and_wait("example.com", checks=["readiness"], timeout=120)
|
|
74
|
+
if scan.status == "completed":
|
|
75
|
+
print(scan.result.readiness.score)
|
|
76
|
+
else:
|
|
77
|
+
print(scan.failure.code, scan.failure.message)
|
|
78
|
+
|
|
79
|
+
usage = client.usage.retrieve()
|
|
80
|
+
print(usage.remaining, "of", usage.allowance, "credits left this month")
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
The checks are `ai`, `readiness`, `popularity`, `tech`, `dns`, `trust`, `summary` and `history`; leave `checks` out to ask every check. A scan runs only what its checks need, so fewer checks are faster and cost less.
|
|
84
|
+
|
|
85
|
+
`wait` and `create_and_wait` wait as long as each answer's `Retry-After` asks (3 seconds today) and return the scan once it is complete: completed with every slow source in, or failed. When their `timeout` (120 seconds by default) comes first, they raise `ScanTimeoutError`, which names the scan's id and holds the scan as last read; the scan goes on, so read it later with `client.scans.retrieve(error.scan_id)` or wait again with `client.scans.wait(error.scan_id)`.
|
|
86
|
+
|
|
87
|
+
## Batches
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
batch = client.sites.batch(["stripe.com", "lovable.dev"], checks=["ai"])
|
|
91
|
+
for item in batch.data: # one item per unique domain, in the order first sent
|
|
92
|
+
if item.result:
|
|
93
|
+
print(item.domain, item.result.ai.level)
|
|
94
|
+
else:
|
|
95
|
+
print(item.input, item.error.code) # no_result, invalid_domain, …
|
|
96
|
+
|
|
97
|
+
from checkanysite.models import Scan
|
|
98
|
+
|
|
99
|
+
scans = client.scans.batch_and_wait(["stripe.com", "lovable.dev"], checks=["ai"])
|
|
100
|
+
for item in scans.data:
|
|
101
|
+
if not isinstance(item, Scan): # a ScanBatchFailure: this one couldn't be scanned
|
|
102
|
+
print(item.input, item.error.code)
|
|
103
|
+
elif item.status == "completed":
|
|
104
|
+
print(item.domain, item.result.ai.level)
|
|
105
|
+
else: # failed
|
|
106
|
+
print(item.domain, item.failure.code)
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
A lookup batch takes 1 to 50 domains or URLs, a scan batch 1 to 10. `scans.batch` starts the scans and answers at once; `batch_and_wait` also waits until every one is complete, within one `timeout` for them all, and keeps the batch's `meta` (what it cost).
|
|
110
|
+
|
|
111
|
+
## What each answer's headers said
|
|
112
|
+
|
|
113
|
+
Every object a method returns has `.meta`, read from the answer's headers:
|
|
114
|
+
|
|
115
|
+
| Attribute | Header |
|
|
116
|
+
| -------------------------------------------------------------- | ------------------------------------------ |
|
|
117
|
+
| `request_id` | `X-Request-Id`: quote it when you write us |
|
|
118
|
+
| `credits_cost`, `credits_remaining`, `credits_reset` | `X-Credits-*` (on calls that cost credits) |
|
|
119
|
+
| `rate_limit_limit`, `rate_limit_remaining`, `rate_limit_reset` | `X-RateLimit-*` |
|
|
120
|
+
| `retry_after_seconds` | `Retry-After` |
|
|
121
|
+
| `idempotent_replayed` | `Idempotent-Replayed` |
|
|
122
|
+
| `status_code`, `etag`, `headers` | the status, `ETag`, every header |
|
|
123
|
+
|
|
124
|
+
## Errors
|
|
125
|
+
|
|
126
|
+
Every error is an `APIError`. When the API answers with an error, the class comes from the status, and `.code` says exactly what happened:
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
import checkanysite
|
|
130
|
+
|
|
131
|
+
domain = "example.com"
|
|
132
|
+
try:
|
|
133
|
+
site = client.sites.retrieve(domain, checks=["ai"])
|
|
134
|
+
except checkanysite.NotFoundError as error:
|
|
135
|
+
if error.code == "no_result": # nothing stored for this site yet: scan it
|
|
136
|
+
scan = client.scans.create_and_wait(domain, checks=["ai"])
|
|
137
|
+
except checkanysite.RateLimitError as error:
|
|
138
|
+
print(error.code) # rate_limited, or a limit used up, such as quota_exceeded
|
|
139
|
+
except checkanysite.APIStatusError as error:
|
|
140
|
+
print(error.status_code, error.code, error.message, error.param, error.request_id, error.docs)
|
|
141
|
+
except checkanysite.APIConnectionError:
|
|
142
|
+
print("No answer from the API")
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
| Status | Class | Codes |
|
|
146
|
+
| ---------- | -------------------------- | --------------------------------------------------------------------------- |
|
|
147
|
+
| 400 | `BadRequestError` | `invalid_json`, `invalid_parameter`, `invalid_domain` |
|
|
148
|
+
| 401 | `AuthenticationError` | `missing_api_key`, `invalid_api_key`, `revoked_api_key`, `expired_api_key` |
|
|
149
|
+
| 403 | `PermissionDeniedError` | `read_only_key`, `browser_request` |
|
|
150
|
+
| 404 | `NotFoundError` | `not_found`, `no_result`, `scan_not_found` |
|
|
151
|
+
| 409 | `ConflictError` | `idempotency_key_in_use` |
|
|
152
|
+
| 422 | `UnprocessableEntityError` | `site_refused`, `idempotency_key_reused` |
|
|
153
|
+
| 429 | `RateLimitError` | `rate_limited`, `quota_exceeded`, `key_limit_reached`, `scan_limit_reached` |
|
|
154
|
+
| 500 and up | `InternalServerError` | `internal_error`, `capacity` |
|
|
155
|
+
| other | `APIStatusError` | `method_not_allowed` (405), `request_too_large` (413), … |
|
|
156
|
+
| no answer | `APIConnectionError` | `APITimeoutError` when the request timed out |
|
|
157
|
+
|
|
158
|
+
New codes may appear within v1; they land in the class for their status. Every code is explained on the [errors page](https://docs.checkanysite.com/errors).
|
|
159
|
+
|
|
160
|
+
## Retries
|
|
161
|
+
|
|
162
|
+
A failure that may pass is retried twice by default, with exponential backoff (0.5, 1, 2 … up to 8 seconds) and jitter, waiting as long as `Retry-After` asks when the API sends it:
|
|
163
|
+
|
|
164
|
+
- no answer (a connection error or a timeout);
|
|
165
|
+
- 409 `idempotency_key_in_use`;
|
|
166
|
+
- 429 `rate_limited`, but never `quota_exceeded` or `key_limit_reached` (the month's credits are spent) or `scan_limit_reached` (the day's new scans are started);
|
|
167
|
+
- 500, 502, 503 and 504.
|
|
168
|
+
|
|
169
|
+
A `Retry-After` longer than 60 seconds isn't waited out: the error is raised so you can decide.
|
|
170
|
+
|
|
171
|
+
```python
|
|
172
|
+
client = CheckAnySite(max_retries=5) # or 0 to turn retrying off
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
## Idempotency
|
|
176
|
+
|
|
177
|
+
Every POST carries an `Idempotency-Key`, a new UUID per call, kept across that call's retries: if an answer is lost and the call is retried, the API replays the first answer instead of scanning (and charging) twice. Pass your own key to make a call safe to repeat across runs:
|
|
178
|
+
|
|
179
|
+
```python
|
|
180
|
+
scan = client.scans.create("example.com", idempotency_key="order-1234")
|
|
181
|
+
scan.meta.idempotent_replayed # True when this is the stored first answer
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
A key is kept 24 hours. The same key with a different body raises `UnprocessableEntityError` (`idempotency_key_reused`).
|
|
185
|
+
|
|
186
|
+
## Timeouts and configuration
|
|
187
|
+
|
|
188
|
+
```python
|
|
189
|
+
import httpx
|
|
190
|
+
from checkanysite import CheckAnySite
|
|
191
|
+
|
|
192
|
+
client = CheckAnySite(
|
|
193
|
+
api_key="cas_live_…", # default: CHECKANYSITE_API_KEY
|
|
194
|
+
base_url="https://api.checkanysite.com/v1", # default: CHECKANYSITE_BASE_URL, else this
|
|
195
|
+
timeout=30.0, # seconds per request, or an httpx.Timeout
|
|
196
|
+
max_retries=2,
|
|
197
|
+
http_client=httpx.Client(proxy="http://proxy.internal:8080"), # optional; you close it
|
|
198
|
+
)
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
Use the client as a context manager (`with CheckAnySite() as client:`) or call `client.close()` when done.
|
|
202
|
+
|
|
203
|
+
## Async
|
|
204
|
+
|
|
205
|
+
`AsyncCheckAnySite` has the same methods, awaited:
|
|
206
|
+
|
|
207
|
+
```python
|
|
208
|
+
import asyncio
|
|
209
|
+
from checkanysite import AsyncCheckAnySite
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
async def main() -> None:
|
|
213
|
+
async with AsyncCheckAnySite() as client:
|
|
214
|
+
sites = await asyncio.gather(
|
|
215
|
+
client.sites.retrieve("stripe.com", checks=["ai"]),
|
|
216
|
+
client.sites.retrieve("lovable.dev", checks=["ai"]),
|
|
217
|
+
)
|
|
218
|
+
scan = await client.scans.create_and_wait("example.com", checks=["dns"])
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
asyncio.run(main())
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
## Models
|
|
225
|
+
|
|
226
|
+
The answers are pydantic models generated from the API's OpenAPI document, in `checkanysite.models`. Attributes are snake_case (`site.scanned_at`); `model_dump(by_alias=True)` gives the API's camelCase JSON. Within v1 the API may add fields and values, never remove or rename them, so the models keep what they don't know: new fields are in `model.model_extra`, and new values of a field such as `level` arrive as strings.
|
|
227
|
+
|
|
228
|
+
## Versioning
|
|
229
|
+
|
|
230
|
+
This library follows [semantic versioning](https://semver.org) and the API v1. Changes are listed in [CHANGELOG.md](CHANGELOG.md).
|
|
231
|
+
|
|
232
|
+
## Development
|
|
233
|
+
|
|
234
|
+
From `sdks/python/`, with [uv](https://docs.astral.sh/uv/):
|
|
235
|
+
|
|
236
|
+
```sh
|
|
237
|
+
uv run ruff format --check . && uv run ruff check . && uv run mypy && uv run pytest
|
|
238
|
+
uv build # an sdist and a wheel in dist/
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
The models come from the server's schemas. After an API change, from the repository root:
|
|
242
|
+
|
|
243
|
+
```sh
|
|
244
|
+
pnpm sdk:python:models # exports openapi.json, then regenerates src/checkanysite/models/_generated.py
|
|
245
|
+
```
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
# CheckAnySite Python library
|
|
2
|
+
|
|
3
|
+
The official Python library for the [CheckAnySite API](https://docs.checkanysite.com): what is this website? Ask about any public site and get, with evidence, the traces of AI website builders, its AI readiness, popularity, technology, DNS records, trust signals, a summary and its history.
|
|
4
|
+
|
|
5
|
+
It gives typed answers (pydantic models), a sync and an async client, retries with backoff, idempotent POSTs and the credits each call cost. Python 3.9 or newer; its only dependencies are httpx and pydantic.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
pip install checkanysite
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Quickstart
|
|
14
|
+
|
|
15
|
+
Get a key free at [checkanysite.com/account/keys](https://checkanysite.com/account/keys) and put it in the environment:
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
export CHECKANYSITE_API_KEY="cas_live_…"
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
from checkanysite import CheckAnySite
|
|
23
|
+
|
|
24
|
+
client = CheckAnySite() # reads CHECKANYSITE_API_KEY
|
|
25
|
+
|
|
26
|
+
site = client.sites.retrieve("stripe.com", checks=["ai", "tech"], max_age_days=30)
|
|
27
|
+
print(site.ai.level) # "high", "medium" or "none"
|
|
28
|
+
for technology in site.tech.technologies:
|
|
29
|
+
print(technology.name, technology.category)
|
|
30
|
+
|
|
31
|
+
print(site.meta.credits_cost, site.meta.credits_remaining, site.meta.request_id)
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
There are two APIs behind one key:
|
|
35
|
+
|
|
36
|
+
- **Lookup** (`client.sites`) answers from stored results at once and never scans. A found result costs each check asked at its stored price; a miss (`no_result`) is free.
|
|
37
|
+
- **Scan** (`client.scans`) scans a site live. A scan answers asynchronously: start it, then poll it, or let `create_and_wait` poll for you.
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
scan = client.scans.create("example.com", checks=["readiness"]) # a Scan, maybe still queued
|
|
41
|
+
scan = client.scans.retrieve(scan.id) # read it again, free
|
|
42
|
+
scan = client.scans.wait(scan) # poll it until it is complete, free
|
|
43
|
+
|
|
44
|
+
scan = client.scans.create_and_wait("example.com", checks=["readiness"], timeout=120)
|
|
45
|
+
if scan.status == "completed":
|
|
46
|
+
print(scan.result.readiness.score)
|
|
47
|
+
else:
|
|
48
|
+
print(scan.failure.code, scan.failure.message)
|
|
49
|
+
|
|
50
|
+
usage = client.usage.retrieve()
|
|
51
|
+
print(usage.remaining, "of", usage.allowance, "credits left this month")
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
The checks are `ai`, `readiness`, `popularity`, `tech`, `dns`, `trust`, `summary` and `history`; leave `checks` out to ask every check. A scan runs only what its checks need, so fewer checks are faster and cost less.
|
|
55
|
+
|
|
56
|
+
`wait` and `create_and_wait` wait as long as each answer's `Retry-After` asks (3 seconds today) and return the scan once it is complete: completed with every slow source in, or failed. When their `timeout` (120 seconds by default) comes first, they raise `ScanTimeoutError`, which names the scan's id and holds the scan as last read; the scan goes on, so read it later with `client.scans.retrieve(error.scan_id)` or wait again with `client.scans.wait(error.scan_id)`.
|
|
57
|
+
|
|
58
|
+
## Batches
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
batch = client.sites.batch(["stripe.com", "lovable.dev"], checks=["ai"])
|
|
62
|
+
for item in batch.data: # one item per unique domain, in the order first sent
|
|
63
|
+
if item.result:
|
|
64
|
+
print(item.domain, item.result.ai.level)
|
|
65
|
+
else:
|
|
66
|
+
print(item.input, item.error.code) # no_result, invalid_domain, …
|
|
67
|
+
|
|
68
|
+
from checkanysite.models import Scan
|
|
69
|
+
|
|
70
|
+
scans = client.scans.batch_and_wait(["stripe.com", "lovable.dev"], checks=["ai"])
|
|
71
|
+
for item in scans.data:
|
|
72
|
+
if not isinstance(item, Scan): # a ScanBatchFailure: this one couldn't be scanned
|
|
73
|
+
print(item.input, item.error.code)
|
|
74
|
+
elif item.status == "completed":
|
|
75
|
+
print(item.domain, item.result.ai.level)
|
|
76
|
+
else: # failed
|
|
77
|
+
print(item.domain, item.failure.code)
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
A lookup batch takes 1 to 50 domains or URLs, a scan batch 1 to 10. `scans.batch` starts the scans and answers at once; `batch_and_wait` also waits until every one is complete, within one `timeout` for them all, and keeps the batch's `meta` (what it cost).
|
|
81
|
+
|
|
82
|
+
## What each answer's headers said
|
|
83
|
+
|
|
84
|
+
Every object a method returns has `.meta`, read from the answer's headers:
|
|
85
|
+
|
|
86
|
+
| Attribute | Header |
|
|
87
|
+
| -------------------------------------------------------------- | ------------------------------------------ |
|
|
88
|
+
| `request_id` | `X-Request-Id`: quote it when you write us |
|
|
89
|
+
| `credits_cost`, `credits_remaining`, `credits_reset` | `X-Credits-*` (on calls that cost credits) |
|
|
90
|
+
| `rate_limit_limit`, `rate_limit_remaining`, `rate_limit_reset` | `X-RateLimit-*` |
|
|
91
|
+
| `retry_after_seconds` | `Retry-After` |
|
|
92
|
+
| `idempotent_replayed` | `Idempotent-Replayed` |
|
|
93
|
+
| `status_code`, `etag`, `headers` | the status, `ETag`, every header |
|
|
94
|
+
|
|
95
|
+
## Errors
|
|
96
|
+
|
|
97
|
+
Every error is an `APIError`. When the API answers with an error, the class comes from the status, and `.code` says exactly what happened:
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
import checkanysite
|
|
101
|
+
|
|
102
|
+
domain = "example.com"
|
|
103
|
+
try:
|
|
104
|
+
site = client.sites.retrieve(domain, checks=["ai"])
|
|
105
|
+
except checkanysite.NotFoundError as error:
|
|
106
|
+
if error.code == "no_result": # nothing stored for this site yet: scan it
|
|
107
|
+
scan = client.scans.create_and_wait(domain, checks=["ai"])
|
|
108
|
+
except checkanysite.RateLimitError as error:
|
|
109
|
+
print(error.code) # rate_limited, or a limit used up, such as quota_exceeded
|
|
110
|
+
except checkanysite.APIStatusError as error:
|
|
111
|
+
print(error.status_code, error.code, error.message, error.param, error.request_id, error.docs)
|
|
112
|
+
except checkanysite.APIConnectionError:
|
|
113
|
+
print("No answer from the API")
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
| Status | Class | Codes |
|
|
117
|
+
| ---------- | -------------------------- | --------------------------------------------------------------------------- |
|
|
118
|
+
| 400 | `BadRequestError` | `invalid_json`, `invalid_parameter`, `invalid_domain` |
|
|
119
|
+
| 401 | `AuthenticationError` | `missing_api_key`, `invalid_api_key`, `revoked_api_key`, `expired_api_key` |
|
|
120
|
+
| 403 | `PermissionDeniedError` | `read_only_key`, `browser_request` |
|
|
121
|
+
| 404 | `NotFoundError` | `not_found`, `no_result`, `scan_not_found` |
|
|
122
|
+
| 409 | `ConflictError` | `idempotency_key_in_use` |
|
|
123
|
+
| 422 | `UnprocessableEntityError` | `site_refused`, `idempotency_key_reused` |
|
|
124
|
+
| 429 | `RateLimitError` | `rate_limited`, `quota_exceeded`, `key_limit_reached`, `scan_limit_reached` |
|
|
125
|
+
| 500 and up | `InternalServerError` | `internal_error`, `capacity` |
|
|
126
|
+
| other | `APIStatusError` | `method_not_allowed` (405), `request_too_large` (413), … |
|
|
127
|
+
| no answer | `APIConnectionError` | `APITimeoutError` when the request timed out |
|
|
128
|
+
|
|
129
|
+
New codes may appear within v1; they land in the class for their status. Every code is explained on the [errors page](https://docs.checkanysite.com/errors).
|
|
130
|
+
|
|
131
|
+
## Retries
|
|
132
|
+
|
|
133
|
+
A failure that may pass is retried twice by default, with exponential backoff (0.5, 1, 2 … up to 8 seconds) and jitter, waiting as long as `Retry-After` asks when the API sends it:
|
|
134
|
+
|
|
135
|
+
- no answer (a connection error or a timeout);
|
|
136
|
+
- 409 `idempotency_key_in_use`;
|
|
137
|
+
- 429 `rate_limited`, but never `quota_exceeded` or `key_limit_reached` (the month's credits are spent) or `scan_limit_reached` (the day's new scans are started);
|
|
138
|
+
- 500, 502, 503 and 504.
|
|
139
|
+
|
|
140
|
+
A `Retry-After` longer than 60 seconds isn't waited out: the error is raised so you can decide.
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
client = CheckAnySite(max_retries=5) # or 0 to turn retrying off
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## Idempotency
|
|
147
|
+
|
|
148
|
+
Every POST carries an `Idempotency-Key`, a new UUID per call, kept across that call's retries: if an answer is lost and the call is retried, the API replays the first answer instead of scanning (and charging) twice. Pass your own key to make a call safe to repeat across runs:
|
|
149
|
+
|
|
150
|
+
```python
|
|
151
|
+
scan = client.scans.create("example.com", idempotency_key="order-1234")
|
|
152
|
+
scan.meta.idempotent_replayed # True when this is the stored first answer
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
A key is kept 24 hours. The same key with a different body raises `UnprocessableEntityError` (`idempotency_key_reused`).
|
|
156
|
+
|
|
157
|
+
## Timeouts and configuration
|
|
158
|
+
|
|
159
|
+
```python
|
|
160
|
+
import httpx
|
|
161
|
+
from checkanysite import CheckAnySite
|
|
162
|
+
|
|
163
|
+
client = CheckAnySite(
|
|
164
|
+
api_key="cas_live_…", # default: CHECKANYSITE_API_KEY
|
|
165
|
+
base_url="https://api.checkanysite.com/v1", # default: CHECKANYSITE_BASE_URL, else this
|
|
166
|
+
timeout=30.0, # seconds per request, or an httpx.Timeout
|
|
167
|
+
max_retries=2,
|
|
168
|
+
http_client=httpx.Client(proxy="http://proxy.internal:8080"), # optional; you close it
|
|
169
|
+
)
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Use the client as a context manager (`with CheckAnySite() as client:`) or call `client.close()` when done.
|
|
173
|
+
|
|
174
|
+
## Async
|
|
175
|
+
|
|
176
|
+
`AsyncCheckAnySite` has the same methods, awaited:
|
|
177
|
+
|
|
178
|
+
```python
|
|
179
|
+
import asyncio
|
|
180
|
+
from checkanysite import AsyncCheckAnySite
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
async def main() -> None:
|
|
184
|
+
async with AsyncCheckAnySite() as client:
|
|
185
|
+
sites = await asyncio.gather(
|
|
186
|
+
client.sites.retrieve("stripe.com", checks=["ai"]),
|
|
187
|
+
client.sites.retrieve("lovable.dev", checks=["ai"]),
|
|
188
|
+
)
|
|
189
|
+
scan = await client.scans.create_and_wait("example.com", checks=["dns"])
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
asyncio.run(main())
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
## Models
|
|
196
|
+
|
|
197
|
+
The answers are pydantic models generated from the API's OpenAPI document, in `checkanysite.models`. Attributes are snake_case (`site.scanned_at`); `model_dump(by_alias=True)` gives the API's camelCase JSON. Within v1 the API may add fields and values, never remove or rename them, so the models keep what they don't know: new fields are in `model.model_extra`, and new values of a field such as `level` arrive as strings.
|
|
198
|
+
|
|
199
|
+
## Versioning
|
|
200
|
+
|
|
201
|
+
This library follows [semantic versioning](https://semver.org) and the API v1. Changes are listed in [CHANGELOG.md](CHANGELOG.md).
|
|
202
|
+
|
|
203
|
+
## Development
|
|
204
|
+
|
|
205
|
+
From `sdks/python/`, with [uv](https://docs.astral.sh/uv/):
|
|
206
|
+
|
|
207
|
+
```sh
|
|
208
|
+
uv run ruff format --check . && uv run ruff check . && uv run mypy && uv run pytest
|
|
209
|
+
uv build # an sdist and a wheel in dist/
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
The models come from the server's schemas. After an API change, from the repository root:
|
|
213
|
+
|
|
214
|
+
```sh
|
|
215
|
+
pnpm sdk:python:models # exports openapi.json, then regenerates src/checkanysite/models/_generated.py
|
|
216
|
+
```
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""A first look at the CheckAnySite API: a stored result, a live scan and this month's credits.
|
|
2
|
+
|
|
3
|
+
Run it with your key in the environment (it spends a few of the month's free credits):
|
|
4
|
+
|
|
5
|
+
export CHECKANYSITE_API_KEY="cas_live_…"
|
|
6
|
+
python examples/quickstart.py stripe.com
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import sys
|
|
12
|
+
|
|
13
|
+
import checkanysite
|
|
14
|
+
from checkanysite import CheckAnySite
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def main(domain: str) -> None:
|
|
18
|
+
with CheckAnySite() as client:
|
|
19
|
+
# The Lookup API: a stored result, at once. A miss is free.
|
|
20
|
+
try:
|
|
21
|
+
site = client.sites.retrieve(domain, checks=["ai", "tech"], max_age_days=30)
|
|
22
|
+
except checkanysite.NotFoundError as error:
|
|
23
|
+
print(f"No stored result for {domain} ({error.code}); scanning it now.")
|
|
24
|
+
else:
|
|
25
|
+
level = site.ai.level if site.ai else "not asked"
|
|
26
|
+
technologies = [item.name for item in site.tech.technologies] if site.tech else []
|
|
27
|
+
print(f"{site.domain}, scanned {site.scanned_at:%d %b %Y}")
|
|
28
|
+
print(f" AI builder traces: {level}")
|
|
29
|
+
print(f" Technologies: {', '.join(technologies) or 'none found'}")
|
|
30
|
+
print(f" Cost {site.meta.credits_cost} credits; {site.meta.credits_remaining} left")
|
|
31
|
+
|
|
32
|
+
# The Scan API: a live scan, polled until it is complete.
|
|
33
|
+
try:
|
|
34
|
+
scan = client.scans.create_and_wait(domain, checks=["readiness"], timeout=120)
|
|
35
|
+
except checkanysite.ScanTimeoutError as error:
|
|
36
|
+
print(f"Still scanning; read it later with scans.retrieve({error.scan_id!r}).")
|
|
37
|
+
else:
|
|
38
|
+
if scan.result and scan.result.readiness:
|
|
39
|
+
print(f" AI readiness: {scan.result.readiness.answer}")
|
|
40
|
+
elif scan.failure:
|
|
41
|
+
print(f" The scan failed: {scan.failure.message}")
|
|
42
|
+
|
|
43
|
+
usage = client.usage.retrieve()
|
|
44
|
+
print(f"{usage.remaining} of {usage.allowance} credits left this month.")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
if __name__ == "__main__":
|
|
48
|
+
main(sys.argv[1] if len(sys.argv) > 1 else "stripe.com")
|