Metadata-Version: 2.5
Name: checkanysite
Version: 0.1.0
Summary: The official Python library for the CheckAnySite API: what is this website, with evidence.
Project-URL: Homepage, https://checkanysite.com
Project-URL: Documentation, https://docs.checkanysite.com
Author-email: CheckAnySite <hello@checkanysite.com>
License-Expression: MIT
License-File: LICENSE
Keywords: ai,api,checkanysite,dns,sdk,tech stack,website
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Internet :: WWW/HTTP
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.9
Requires-Dist: httpx<1,>=0.23.0
Requires-Dist: pydantic<3,>=2.5.0
Description-Content-Type: text/markdown

# CheckAnySite Python library

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.

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.

## Install

```sh
pip install checkanysite
```

## Quickstart

Get a key free at [checkanysite.com/account/keys](https://checkanysite.com/account/keys) and put it in the environment:

```sh
export CHECKANYSITE_API_KEY="cas_live_…"
```

```python
from checkanysite import CheckAnySite

client = CheckAnySite()  # reads CHECKANYSITE_API_KEY

site = client.sites.retrieve("stripe.com", checks=["ai", "tech"], max_age_days=30)
print(site.ai.level)  # "high", "medium" or "none"
for technology in site.tech.technologies:
    print(technology.name, technology.category)

print(site.meta.credits_cost, site.meta.credits_remaining, site.meta.request_id)
```

There are two APIs behind one key:

- **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.
- **Scan** (`client.scans`) scans a site live. A scan answers asynchronously: start it, then poll it, or let `create_and_wait` poll for you.

```python
scan = client.scans.create("example.com", checks=["readiness"])  # a Scan, maybe still queued
scan = client.scans.retrieve(scan.id)  # read it again, free
scan = client.scans.wait(scan)  # poll it until it is complete, free

scan = client.scans.create_and_wait("example.com", checks=["readiness"], timeout=120)
if scan.status == "completed":
    print(scan.result.readiness.score)
else:
    print(scan.failure.code, scan.failure.message)

usage = client.usage.retrieve()
print(usage.remaining, "of", usage.allowance, "credits left this month")
```

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.

`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)`.

## Batches

```python
batch = client.sites.batch(["stripe.com", "lovable.dev"], checks=["ai"])
for item in batch.data:  # one item per unique domain, in the order first sent
    if item.result:
        print(item.domain, item.result.ai.level)
    else:
        print(item.input, item.error.code)  # no_result, invalid_domain, …

from checkanysite.models import Scan

scans = client.scans.batch_and_wait(["stripe.com", "lovable.dev"], checks=["ai"])
for item in scans.data:
    if not isinstance(item, Scan):  # a ScanBatchFailure: this one couldn't be scanned
        print(item.input, item.error.code)
    elif item.status == "completed":
        print(item.domain, item.result.ai.level)
    else:  # failed
        print(item.domain, item.failure.code)
```

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).

## What each answer's headers said

Every object a method returns has `.meta`, read from the answer's headers:

| Attribute                                                      | Header                                     |
| -------------------------------------------------------------- | ------------------------------------------ |
| `request_id`                                                   | `X-Request-Id`: quote it when you write us |
| `credits_cost`, `credits_remaining`, `credits_reset`           | `X-Credits-*` (on calls that cost credits) |
| `rate_limit_limit`, `rate_limit_remaining`, `rate_limit_reset` | `X-RateLimit-*`                            |
| `retry_after_seconds`                                          | `Retry-After`                              |
| `idempotent_replayed`                                          | `Idempotent-Replayed`                      |
| `status_code`, `etag`, `headers`                               | the status, `ETag`, every header           |

## Errors

Every error is an `APIError`. When the API answers with an error, the class comes from the status, and `.code` says exactly what happened:

```python
import checkanysite

domain = "example.com"
try:
    site = client.sites.retrieve(domain, checks=["ai"])
except checkanysite.NotFoundError as error:
    if error.code == "no_result":  # nothing stored for this site yet: scan it
        scan = client.scans.create_and_wait(domain, checks=["ai"])
except checkanysite.RateLimitError as error:
    print(error.code)  # rate_limited, or a limit used up, such as quota_exceeded
except checkanysite.APIStatusError as error:
    print(error.status_code, error.code, error.message, error.param, error.request_id, error.docs)
except checkanysite.APIConnectionError:
    print("No answer from the API")
```

| Status     | Class                      | Codes                                                                       |
| ---------- | -------------------------- | --------------------------------------------------------------------------- |
| 400        | `BadRequestError`          | `invalid_json`, `invalid_parameter`, `invalid_domain`                       |
| 401        | `AuthenticationError`      | `missing_api_key`, `invalid_api_key`, `revoked_api_key`, `expired_api_key`  |
| 403        | `PermissionDeniedError`    | `read_only_key`, `browser_request`                                          |
| 404        | `NotFoundError`            | `not_found`, `no_result`, `scan_not_found`                                  |
| 409        | `ConflictError`            | `idempotency_key_in_use`                                                    |
| 422        | `UnprocessableEntityError` | `site_refused`, `idempotency_key_reused`                                    |
| 429        | `RateLimitError`           | `rate_limited`, `quota_exceeded`, `key_limit_reached`, `scan_limit_reached` |
| 500 and up | `InternalServerError`      | `internal_error`, `capacity`                                                |
| other      | `APIStatusError`           | `method_not_allowed` (405), `request_too_large` (413), …                    |
| no answer  | `APIConnectionError`       | `APITimeoutError` when the request timed out                                |

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).

## Retries

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:

- no answer (a connection error or a timeout);
- 409 `idempotency_key_in_use`;
- 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);
- 500, 502, 503 and 504.

A `Retry-After` longer than 60 seconds isn't waited out: the error is raised so you can decide.

```python
client = CheckAnySite(max_retries=5)  # or 0 to turn retrying off
```

## Idempotency

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:

```python
scan = client.scans.create("example.com", idempotency_key="order-1234")
scan.meta.idempotent_replayed  # True when this is the stored first answer
```

A key is kept 24 hours. The same key with a different body raises `UnprocessableEntityError` (`idempotency_key_reused`).

## Timeouts and configuration

```python
import httpx
from checkanysite import CheckAnySite

client = CheckAnySite(
    api_key="cas_live_…",  # default: CHECKANYSITE_API_KEY
    base_url="https://api.checkanysite.com/v1",  # default: CHECKANYSITE_BASE_URL, else this
    timeout=30.0,  # seconds per request, or an httpx.Timeout
    max_retries=2,
    http_client=httpx.Client(proxy="http://proxy.internal:8080"),  # optional; you close it
)
```

Use the client as a context manager (`with CheckAnySite() as client:`) or call `client.close()` when done.

## Async

`AsyncCheckAnySite` has the same methods, awaited:

```python
import asyncio
from checkanysite import AsyncCheckAnySite


async def main() -> None:
    async with AsyncCheckAnySite() as client:
        sites = await asyncio.gather(
            client.sites.retrieve("stripe.com", checks=["ai"]),
            client.sites.retrieve("lovable.dev", checks=["ai"]),
        )
        scan = await client.scans.create_and_wait("example.com", checks=["dns"])


asyncio.run(main())
```

## Models

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.

## Versioning

This library follows [semantic versioning](https://semver.org) and the API v1. Changes are listed in [CHANGELOG.md](CHANGELOG.md).

## Development

From `sdks/python/`, with [uv](https://docs.astral.sh/uv/):

```sh
uv run ruff format --check . && uv run ruff check . && uv run mypy && uv run pytest
uv build                                   # an sdist and a wheel in dist/
```

The models come from the server's schemas. After an API change, from the repository root:

```sh
pnpm sdk:python:models  # exports openapi.json, then regenerates src/checkanysite/models/_generated.py
```
