vermin-sdk 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.
- vermin_sdk-0.1.0/.gitignore +6 -0
- vermin_sdk-0.1.0/PKG-INFO +234 -0
- vermin_sdk-0.1.0/README.md +204 -0
- vermin_sdk-0.1.0/pyproject.toml +56 -0
- vermin_sdk-0.1.0/src/vermin/__init__.py +90 -0
- vermin_sdk-0.1.0/src/vermin/_base.py +269 -0
- vermin_sdk-0.1.0/src/vermin/_client.py +545 -0
- vermin_sdk-0.1.0/src/vermin/_errors.py +174 -0
- vermin_sdk-0.1.0/src/vermin/_models.py +280 -0
- vermin_sdk-0.1.0/src/vermin/_sse.py +121 -0
- vermin_sdk-0.1.0/src/vermin/_version.py +1 -0
- vermin_sdk-0.1.0/src/vermin/_webhooks.py +141 -0
- vermin_sdk-0.1.0/src/vermin/py.typed +0 -0
- vermin_sdk-0.1.0/tests/__init__.py +0 -0
- vermin_sdk-0.1.0/tests/conftest.py +130 -0
- vermin_sdk-0.1.0/tests/fixtures.py +143 -0
- vermin_sdk-0.1.0/tests/spec.py +74 -0
- vermin_sdk-0.1.0/tests/test_async.py +111 -0
- vermin_sdk-0.1.0/tests/test_client.py +480 -0
- vermin_sdk-0.1.0/tests/test_edges.py +135 -0
- vermin_sdk-0.1.0/tests/test_fixtures.py +125 -0
- vermin_sdk-0.1.0/tests/test_sse.py +32 -0
- vermin_sdk-0.1.0/tests/test_webhooks.py +52 -0
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: vermin-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python SDK for the Vermin web-data API: scrape, crawl, map and brand.
|
|
5
|
+
Project-URL: Homepage, https://vermin.dev
|
|
6
|
+
Project-URL: Documentation, https://vermin.dev/docs
|
|
7
|
+
Keywords: ai,crawl,llm,scrape,sdk,vermin,web-data
|
|
8
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Typing :: Typed
|
|
18
|
+
Requires-Python: >=3.9
|
|
19
|
+
Requires-Dist: httpx<1,>=0.25
|
|
20
|
+
Requires-Dist: pydantic<3,>=2.5
|
|
21
|
+
Requires-Dist: typing-extensions>=4.7
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: jsonschema[format-nongpl]>=4.18; extra == 'dev'
|
|
24
|
+
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
25
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
26
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
27
|
+
Requires-Dist: pyyaml>=6; extra == 'dev'
|
|
28
|
+
Requires-Dist: respx>=0.21; extra == 'dev'
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# vermin
|
|
32
|
+
|
|
33
|
+
Official Python SDK for [Vermin](https://vermin.dev), the web-data API for AI: **scrape, crawl, map, search, extract and brand**.
|
|
34
|
+
|
|
35
|
+
- Sync `Vermin` and async `AsyncVermin` clients with the same methods, built on [httpx](https://www.python-httpx.org/).
|
|
36
|
+
- Typed [pydantic v2](https://docs.pydantic.dev/) response models that follow the [OpenAPI contract](../../packages/spec/openapi.yaml).
|
|
37
|
+
- Automatic retries with jittered backoff on 429, 5xx and network errors. They honour `retry_after_ms` / `Retry-After`.
|
|
38
|
+
- Auto-generated `Idempotency-Key` on `scrape` and `crawl.start`, reused across retries.
|
|
39
|
+
- `credits_used` and `request_id` on every result, plus a typed `VerminError`.
|
|
40
|
+
|
|
41
|
+
Requires Python 3.9+.
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
pip install vermin-sdk
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Quick start
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from vermin import Vermin
|
|
51
|
+
|
|
52
|
+
vermin = Vermin() # reads VERMIN_API_KEY (and optionally VERMIN_API_URL)
|
|
53
|
+
|
|
54
|
+
page = vermin.scrape("https://example.com", formats=["markdown", "metadata"])
|
|
55
|
+
print(page.data.markdown, page.data.metadata.title)
|
|
56
|
+
print(f"cost: {page.credits_used} credits (request {page.request_id})")
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Async:
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
import asyncio
|
|
63
|
+
from vermin import AsyncVermin
|
|
64
|
+
|
|
65
|
+
async def main():
|
|
66
|
+
async with AsyncVermin() as vermin:
|
|
67
|
+
page = await vermin.scrape("https://example.com")
|
|
68
|
+
print(page.data.markdown)
|
|
69
|
+
|
|
70
|
+
asyncio.run(main())
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### Options
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
Vermin(
|
|
77
|
+
api_key="vmn_live_…", # default: VERMIN_API_KEY
|
|
78
|
+
base_url="https://api.vermin.dev", # default: VERMIN_API_URL, then https://api.vermin.dev
|
|
79
|
+
timeout=60.0, # per-attempt HTTP timeout, seconds
|
|
80
|
+
max_retries=2, # retries on 429 / 5xx / network errors
|
|
81
|
+
headers={"X-Team": "growth"}, # extra headers on every request
|
|
82
|
+
http_client=httpx.Client(proxy=…), # bring your own httpx client
|
|
83
|
+
)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Use the client as a context manager (`with Vermin() as vermin:`), or call `close()` / `await aclose()` when you're done.
|
|
87
|
+
|
|
88
|
+
Keyword options use snake_case (`only_main_content=True`). The SDK sends them as the API's camelCase. Nested `scrape_options` accept a dict with snake_case or camelCase keys, or a `ScrapeOptions` model.
|
|
89
|
+
|
|
90
|
+
## API
|
|
91
|
+
|
|
92
|
+
### `scrape(url, **options)`
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
res = vermin.scrape(
|
|
96
|
+
"https://news.ycombinator.com",
|
|
97
|
+
formats=["markdown", "links", "chunks"],
|
|
98
|
+
only_main_content=True,
|
|
99
|
+
tier="auto", # fetch → render → premium (if allow_premium)
|
|
100
|
+
chunk_size=1200,
|
|
101
|
+
actions=[{"type": "click", "selector": "#more"}, {"type": "wait", "ms": 1000}],
|
|
102
|
+
)
|
|
103
|
+
for chunk in res.data.chunks or []:
|
|
104
|
+
print(chunk.heading_path, chunk.tokens)
|
|
105
|
+
res.data.quality.issues # e.g. ["js_required"]
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### `crawl`
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
job = vermin.crawl.start("https://docs.example.com", limit=200, max_depth=3,
|
|
112
|
+
scrape_options={"formats": ["markdown"]})
|
|
113
|
+
|
|
114
|
+
# Poll until finished and collect every page (follows `next` cursors):
|
|
115
|
+
result = vermin.crawl.wait(job.id, poll_interval=2.0, timeout=600,
|
|
116
|
+
on_status=lambda s: print(s.completed, "/", s.total))
|
|
117
|
+
print(result.status, len(result.data), result.credits_used)
|
|
118
|
+
|
|
119
|
+
# …or stream pages as they arrive (server-sent events):
|
|
120
|
+
for event in vermin.crawl.stream(job.id):
|
|
121
|
+
if event.type == "page":
|
|
122
|
+
print(event.data.url)
|
|
123
|
+
elif event.type == "done":
|
|
124
|
+
print("finished:", event.data.status)
|
|
125
|
+
|
|
126
|
+
# Lower level:
|
|
127
|
+
status = vermin.crawl.get(job.id, limit=50)
|
|
128
|
+
page2 = vermin.crawl.get(job.id, cursor=status.next) if status.next else None
|
|
129
|
+
for doc in vermin.crawl.documents(job.id):
|
|
130
|
+
print(doc.url)
|
|
131
|
+
vermin.crawl.cancel(job.id)
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
With `AsyncVermin`, use `await vermin.crawl.wait(...)`, `async for event in vermin.crawl.stream(...)` and `async for doc in vermin.crawl.documents(...)`.
|
|
135
|
+
|
|
136
|
+
`crawl.wait` returns the final status even if the crawl `failed` or was `cancelled`, so check `result.status`. It raises `VerminError(code="timeout")` if `timeout` seconds pass first. A failed crawl carries `result.error` (`code`, `message`). `crawl.stream` reconnects with `Last-Event-ID` if the connection drops before `done` (waiting the server's `retry:` delay, else backoff), up to `max_reconnects` consecutive times (default `max_retries`; the budget resets after each page/status event), then raises `VerminError(code="connection_error")`. Resume with `crawl.stream(id, last_event_id="41")`.
|
|
137
|
+
|
|
138
|
+
### `map(url, **options)`
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
res = vermin.map("https://example.com", search="pricing", limit=500)
|
|
142
|
+
urls = [link.url for link in res.links]
|
|
143
|
+
vermin.map("https://example.com", include_paths=["/blog/*"], exclude_paths=["/blog/tag/*"]) # path globs
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
### `search(query, **options)`
|
|
147
|
+
|
|
148
|
+
```python
|
|
149
|
+
res = vermin.search("best vector databases 2026", limit=5, time_range="month",
|
|
150
|
+
scrape_options={"formats": ["markdown"]}) # optional: scrape each result
|
|
151
|
+
for hit in res.results:
|
|
152
|
+
print(hit.position, hit.title, hit.document and len(hit.document.markdown or ""))
|
|
153
|
+
# hit.date and hit.source are set when the provider reports them (e.g. news results)
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
### `extract(urls, schema=..., prompt=...)`
|
|
157
|
+
|
|
158
|
+
Pass a pydantic model class or a JSON Schema dict. A model is sent as its JSON Schema. The returned `data` is then parsed into an instance of the model.
|
|
159
|
+
|
|
160
|
+
```python
|
|
161
|
+
from pydantic import BaseModel
|
|
162
|
+
|
|
163
|
+
class Product(BaseModel):
|
|
164
|
+
name: str
|
|
165
|
+
price: float
|
|
166
|
+
in_stock: bool
|
|
167
|
+
|
|
168
|
+
res = vermin.extract("https://shop.example.com/p/123", schema=Product, prompt="Extract the product")
|
|
169
|
+
res.data.price # float, and res.data is a Product
|
|
170
|
+
|
|
171
|
+
raw = vermin.extract(["https://example.com"], schema={"type": "object", "properties": {"title": {"type": "string"}}})
|
|
172
|
+
raw.data["title"]
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
If the data doesn't match the model, `extract` raises `VerminError(code="invalid_response")`. Pass `validate=False` to get the raw dict instead.
|
|
176
|
+
|
|
177
|
+
### `brand(domain, max_age=None)`
|
|
178
|
+
|
|
179
|
+
```python
|
|
180
|
+
res = vermin.brand("stripe.com") # max_age=0 forces a fresh extraction
|
|
181
|
+
brand = res.data
|
|
182
|
+
brand.logo.url # Vermin-hosted copy; brand.logo.source_url is where it was found
|
|
183
|
+
next(c.hex for c in brand.colors if c.role == "primary") # each colour has text_color + WCAG contrast
|
|
184
|
+
# Every image found, tagged with type ("logo" | "icon" | "wordmark") and background ("light" | "dark" | "any")
|
|
185
|
+
for_dark_ui = [l for l in brand.logos or [] if l.background == "dark" and l.type != "icon"]
|
|
186
|
+
res.cached, res.stale # stale: cached result older than max_age, served because a fresh lookup failed
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
## Errors
|
|
190
|
+
|
|
191
|
+
Every failure raises `VerminError`:
|
|
192
|
+
|
|
193
|
+
```python
|
|
194
|
+
from vermin import VerminError
|
|
195
|
+
|
|
196
|
+
try:
|
|
197
|
+
vermin.scrape("https://example.com")
|
|
198
|
+
except VerminError as err:
|
|
199
|
+
err.code # "rate_limited", "insufficient_credits", "blocked_by_site", … or "connection_error"
|
|
200
|
+
err.status # HTTP status (0 if no response)
|
|
201
|
+
err.request_id # quote this to support
|
|
202
|
+
err.retry_after_ms # server hint, when present
|
|
203
|
+
err.retryable # whether retrying could help
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
Retries apply to 429, 5xx (including `capacity_exceeded`), timeouts and connection errors. By default the SDK makes up to 3 attempts, with jittered exponential backoff from 0.5 s up to 8 s. When the server sends a `retry_after_ms` hint (or a `Retry-After` header, seconds or HTTP date), the SDK waits exactly that long. It never retries `invalid_request`, `unauthorized`, `forbidden`, `not_found`, `insufficient_credits`, `spend_cap_reached`, `blocked_url`, `not_implemented`, `not_configured` or `extract_failed`, whatever the status. Failed requests cost 0 credits.
|
|
207
|
+
|
|
208
|
+
## Webhooks
|
|
209
|
+
|
|
210
|
+
Crawl webhooks carry a `Vermin-Signature: t=<unix seconds>,v1=<hex>` header, where `v1` is the HMAC-SHA256 of `"<t>.<raw body>"` keyed with your signing secret (Dashboard → Webhooks). Verify it against the **raw** request body before trusting the payload. The helper compares in constant time, accepts any of several `v1=` values (secret rotation) and rejects timestamps more than 300 s from now (`tolerance`; `0` disables the check).
|
|
211
|
+
|
|
212
|
+
```python
|
|
213
|
+
import os
|
|
214
|
+
from vermin import VerminError, verify_webhook
|
|
215
|
+
|
|
216
|
+
def handle(body: bytes, headers) -> int:
|
|
217
|
+
try:
|
|
218
|
+
event = verify_webhook(body, headers.get("Vermin-Signature"), os.environ["VERMIN_WEBHOOK_SECRET"])
|
|
219
|
+
except VerminError: # code == "invalid_signature"
|
|
220
|
+
return 400
|
|
221
|
+
if event["type"] == "crawl.page":
|
|
222
|
+
print(event["data"]["url"])
|
|
223
|
+
return 204
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
`is_valid_webhook()` returns a bool instead, and `sign_webhook()` builds a header for your tests.
|
|
227
|
+
|
|
228
|
+
## Development
|
|
229
|
+
|
|
230
|
+
```sh
|
|
231
|
+
python3 -m venv .venv
|
|
232
|
+
.venv/bin/pip install -e ".[dev]"
|
|
233
|
+
.venv/bin/pytest # respx-mocked; every mock response is validated against the OpenAPI spec
|
|
234
|
+
```
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# vermin
|
|
2
|
+
|
|
3
|
+
Official Python SDK for [Vermin](https://vermin.dev), the web-data API for AI: **scrape, crawl, map, search, extract and brand**.
|
|
4
|
+
|
|
5
|
+
- Sync `Vermin` and async `AsyncVermin` clients with the same methods, built on [httpx](https://www.python-httpx.org/).
|
|
6
|
+
- Typed [pydantic v2](https://docs.pydantic.dev/) response models that follow the [OpenAPI contract](../../packages/spec/openapi.yaml).
|
|
7
|
+
- Automatic retries with jittered backoff on 429, 5xx and network errors. They honour `retry_after_ms` / `Retry-After`.
|
|
8
|
+
- Auto-generated `Idempotency-Key` on `scrape` and `crawl.start`, reused across retries.
|
|
9
|
+
- `credits_used` and `request_id` on every result, plus a typed `VerminError`.
|
|
10
|
+
|
|
11
|
+
Requires Python 3.9+.
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
pip install vermin-sdk
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Quick start
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
from vermin import Vermin
|
|
21
|
+
|
|
22
|
+
vermin = Vermin() # reads VERMIN_API_KEY (and optionally VERMIN_API_URL)
|
|
23
|
+
|
|
24
|
+
page = vermin.scrape("https://example.com", formats=["markdown", "metadata"])
|
|
25
|
+
print(page.data.markdown, page.data.metadata.title)
|
|
26
|
+
print(f"cost: {page.credits_used} credits (request {page.request_id})")
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Async:
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
import asyncio
|
|
33
|
+
from vermin import AsyncVermin
|
|
34
|
+
|
|
35
|
+
async def main():
|
|
36
|
+
async with AsyncVermin() as vermin:
|
|
37
|
+
page = await vermin.scrape("https://example.com")
|
|
38
|
+
print(page.data.markdown)
|
|
39
|
+
|
|
40
|
+
asyncio.run(main())
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### Options
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
Vermin(
|
|
47
|
+
api_key="vmn_live_…", # default: VERMIN_API_KEY
|
|
48
|
+
base_url="https://api.vermin.dev", # default: VERMIN_API_URL, then https://api.vermin.dev
|
|
49
|
+
timeout=60.0, # per-attempt HTTP timeout, seconds
|
|
50
|
+
max_retries=2, # retries on 429 / 5xx / network errors
|
|
51
|
+
headers={"X-Team": "growth"}, # extra headers on every request
|
|
52
|
+
http_client=httpx.Client(proxy=…), # bring your own httpx client
|
|
53
|
+
)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Use the client as a context manager (`with Vermin() as vermin:`), or call `close()` / `await aclose()` when you're done.
|
|
57
|
+
|
|
58
|
+
Keyword options use snake_case (`only_main_content=True`). The SDK sends them as the API's camelCase. Nested `scrape_options` accept a dict with snake_case or camelCase keys, or a `ScrapeOptions` model.
|
|
59
|
+
|
|
60
|
+
## API
|
|
61
|
+
|
|
62
|
+
### `scrape(url, **options)`
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
res = vermin.scrape(
|
|
66
|
+
"https://news.ycombinator.com",
|
|
67
|
+
formats=["markdown", "links", "chunks"],
|
|
68
|
+
only_main_content=True,
|
|
69
|
+
tier="auto", # fetch → render → premium (if allow_premium)
|
|
70
|
+
chunk_size=1200,
|
|
71
|
+
actions=[{"type": "click", "selector": "#more"}, {"type": "wait", "ms": 1000}],
|
|
72
|
+
)
|
|
73
|
+
for chunk in res.data.chunks or []:
|
|
74
|
+
print(chunk.heading_path, chunk.tokens)
|
|
75
|
+
res.data.quality.issues # e.g. ["js_required"]
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### `crawl`
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
job = vermin.crawl.start("https://docs.example.com", limit=200, max_depth=3,
|
|
82
|
+
scrape_options={"formats": ["markdown"]})
|
|
83
|
+
|
|
84
|
+
# Poll until finished and collect every page (follows `next` cursors):
|
|
85
|
+
result = vermin.crawl.wait(job.id, poll_interval=2.0, timeout=600,
|
|
86
|
+
on_status=lambda s: print(s.completed, "/", s.total))
|
|
87
|
+
print(result.status, len(result.data), result.credits_used)
|
|
88
|
+
|
|
89
|
+
# …or stream pages as they arrive (server-sent events):
|
|
90
|
+
for event in vermin.crawl.stream(job.id):
|
|
91
|
+
if event.type == "page":
|
|
92
|
+
print(event.data.url)
|
|
93
|
+
elif event.type == "done":
|
|
94
|
+
print("finished:", event.data.status)
|
|
95
|
+
|
|
96
|
+
# Lower level:
|
|
97
|
+
status = vermin.crawl.get(job.id, limit=50)
|
|
98
|
+
page2 = vermin.crawl.get(job.id, cursor=status.next) if status.next else None
|
|
99
|
+
for doc in vermin.crawl.documents(job.id):
|
|
100
|
+
print(doc.url)
|
|
101
|
+
vermin.crawl.cancel(job.id)
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
With `AsyncVermin`, use `await vermin.crawl.wait(...)`, `async for event in vermin.crawl.stream(...)` and `async for doc in vermin.crawl.documents(...)`.
|
|
105
|
+
|
|
106
|
+
`crawl.wait` returns the final status even if the crawl `failed` or was `cancelled`, so check `result.status`. It raises `VerminError(code="timeout")` if `timeout` seconds pass first. A failed crawl carries `result.error` (`code`, `message`). `crawl.stream` reconnects with `Last-Event-ID` if the connection drops before `done` (waiting the server's `retry:` delay, else backoff), up to `max_reconnects` consecutive times (default `max_retries`; the budget resets after each page/status event), then raises `VerminError(code="connection_error")`. Resume with `crawl.stream(id, last_event_id="41")`.
|
|
107
|
+
|
|
108
|
+
### `map(url, **options)`
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
res = vermin.map("https://example.com", search="pricing", limit=500)
|
|
112
|
+
urls = [link.url for link in res.links]
|
|
113
|
+
vermin.map("https://example.com", include_paths=["/blog/*"], exclude_paths=["/blog/tag/*"]) # path globs
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### `search(query, **options)`
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
res = vermin.search("best vector databases 2026", limit=5, time_range="month",
|
|
120
|
+
scrape_options={"formats": ["markdown"]}) # optional: scrape each result
|
|
121
|
+
for hit in res.results:
|
|
122
|
+
print(hit.position, hit.title, hit.document and len(hit.document.markdown or ""))
|
|
123
|
+
# hit.date and hit.source are set when the provider reports them (e.g. news results)
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### `extract(urls, schema=..., prompt=...)`
|
|
127
|
+
|
|
128
|
+
Pass a pydantic model class or a JSON Schema dict. A model is sent as its JSON Schema. The returned `data` is then parsed into an instance of the model.
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
from pydantic import BaseModel
|
|
132
|
+
|
|
133
|
+
class Product(BaseModel):
|
|
134
|
+
name: str
|
|
135
|
+
price: float
|
|
136
|
+
in_stock: bool
|
|
137
|
+
|
|
138
|
+
res = vermin.extract("https://shop.example.com/p/123", schema=Product, prompt="Extract the product")
|
|
139
|
+
res.data.price # float, and res.data is a Product
|
|
140
|
+
|
|
141
|
+
raw = vermin.extract(["https://example.com"], schema={"type": "object", "properties": {"title": {"type": "string"}}})
|
|
142
|
+
raw.data["title"]
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
If the data doesn't match the model, `extract` raises `VerminError(code="invalid_response")`. Pass `validate=False` to get the raw dict instead.
|
|
146
|
+
|
|
147
|
+
### `brand(domain, max_age=None)`
|
|
148
|
+
|
|
149
|
+
```python
|
|
150
|
+
res = vermin.brand("stripe.com") # max_age=0 forces a fresh extraction
|
|
151
|
+
brand = res.data
|
|
152
|
+
brand.logo.url # Vermin-hosted copy; brand.logo.source_url is where it was found
|
|
153
|
+
next(c.hex for c in brand.colors if c.role == "primary") # each colour has text_color + WCAG contrast
|
|
154
|
+
# Every image found, tagged with type ("logo" | "icon" | "wordmark") and background ("light" | "dark" | "any")
|
|
155
|
+
for_dark_ui = [l for l in brand.logos or [] if l.background == "dark" and l.type != "icon"]
|
|
156
|
+
res.cached, res.stale # stale: cached result older than max_age, served because a fresh lookup failed
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
## Errors
|
|
160
|
+
|
|
161
|
+
Every failure raises `VerminError`:
|
|
162
|
+
|
|
163
|
+
```python
|
|
164
|
+
from vermin import VerminError
|
|
165
|
+
|
|
166
|
+
try:
|
|
167
|
+
vermin.scrape("https://example.com")
|
|
168
|
+
except VerminError as err:
|
|
169
|
+
err.code # "rate_limited", "insufficient_credits", "blocked_by_site", … or "connection_error"
|
|
170
|
+
err.status # HTTP status (0 if no response)
|
|
171
|
+
err.request_id # quote this to support
|
|
172
|
+
err.retry_after_ms # server hint, when present
|
|
173
|
+
err.retryable # whether retrying could help
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Retries apply to 429, 5xx (including `capacity_exceeded`), timeouts and connection errors. By default the SDK makes up to 3 attempts, with jittered exponential backoff from 0.5 s up to 8 s. When the server sends a `retry_after_ms` hint (or a `Retry-After` header, seconds or HTTP date), the SDK waits exactly that long. It never retries `invalid_request`, `unauthorized`, `forbidden`, `not_found`, `insufficient_credits`, `spend_cap_reached`, `blocked_url`, `not_implemented`, `not_configured` or `extract_failed`, whatever the status. Failed requests cost 0 credits.
|
|
177
|
+
|
|
178
|
+
## Webhooks
|
|
179
|
+
|
|
180
|
+
Crawl webhooks carry a `Vermin-Signature: t=<unix seconds>,v1=<hex>` header, where `v1` is the HMAC-SHA256 of `"<t>.<raw body>"` keyed with your signing secret (Dashboard → Webhooks). Verify it against the **raw** request body before trusting the payload. The helper compares in constant time, accepts any of several `v1=` values (secret rotation) and rejects timestamps more than 300 s from now (`tolerance`; `0` disables the check).
|
|
181
|
+
|
|
182
|
+
```python
|
|
183
|
+
import os
|
|
184
|
+
from vermin import VerminError, verify_webhook
|
|
185
|
+
|
|
186
|
+
def handle(body: bytes, headers) -> int:
|
|
187
|
+
try:
|
|
188
|
+
event = verify_webhook(body, headers.get("Vermin-Signature"), os.environ["VERMIN_WEBHOOK_SECRET"])
|
|
189
|
+
except VerminError: # code == "invalid_signature"
|
|
190
|
+
return 400
|
|
191
|
+
if event["type"] == "crawl.page":
|
|
192
|
+
print(event["data"]["url"])
|
|
193
|
+
return 204
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
`is_valid_webhook()` returns a bool instead, and `sign_webhook()` builds a header for your tests.
|
|
197
|
+
|
|
198
|
+
## Development
|
|
199
|
+
|
|
200
|
+
```sh
|
|
201
|
+
python3 -m venv .venv
|
|
202
|
+
.venv/bin/pip install -e ".[dev]"
|
|
203
|
+
.venv/bin/pytest # respx-mocked; every mock response is validated against the OpenAPI spec
|
|
204
|
+
```
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.21"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "vermin-sdk"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Official Python SDK for the Vermin web-data API: scrape, crawl, map and brand."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
keywords = ["vermin", "scrape", "crawl", "web-data", "ai", "llm", "sdk"]
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Development Status :: 4 - Beta",
|
|
14
|
+
"Intended Audience :: Developers",
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
17
|
+
"Programming Language :: Python :: 3.9",
|
|
18
|
+
"Programming Language :: Python :: 3.10",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Programming Language :: Python :: 3.13",
|
|
22
|
+
"Typing :: Typed",
|
|
23
|
+
]
|
|
24
|
+
dependencies = [
|
|
25
|
+
"httpx>=0.25,<1",
|
|
26
|
+
"pydantic>=2.5,<3",
|
|
27
|
+
"typing-extensions>=4.7",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.optional-dependencies]
|
|
31
|
+
dev = [
|
|
32
|
+
"pytest>=8",
|
|
33
|
+
"pytest-asyncio>=0.23",
|
|
34
|
+
"respx>=0.21",
|
|
35
|
+
"jsonschema[format-nongpl]>=4.18",
|
|
36
|
+
"pyyaml>=6",
|
|
37
|
+
"mypy>=1.10",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
[project.urls]
|
|
41
|
+
Homepage = "https://vermin.dev"
|
|
42
|
+
Documentation = "https://vermin.dev/docs"
|
|
43
|
+
|
|
44
|
+
[tool.hatch.version]
|
|
45
|
+
path = "src/vermin/_version.py"
|
|
46
|
+
|
|
47
|
+
[tool.hatch.build.targets.wheel]
|
|
48
|
+
packages = ["src/vermin"]
|
|
49
|
+
|
|
50
|
+
[tool.hatch.build.targets.sdist]
|
|
51
|
+
include = ["src/vermin", "tests", "README.md", "pyproject.toml"]
|
|
52
|
+
|
|
53
|
+
[tool.pytest.ini_options]
|
|
54
|
+
testpaths = ["tests"]
|
|
55
|
+
asyncio_mode = "auto"
|
|
56
|
+
filterwarnings = ["error"]
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Official Python SDK for Vermin (https://vermin.dev): web data for AI."""
|
|
2
|
+
|
|
3
|
+
from ._base import DEFAULT_BASE_URL, CrawlOptionsDict, MapOptionsDict, ScrapeOptionsDict, SearchOptionsDict
|
|
4
|
+
from ._client import AsyncCrawl, AsyncVermin, Crawl, Vermin
|
|
5
|
+
from ._errors import API_ERROR_CODES, CLIENT_ERROR_CODES, VerminError
|
|
6
|
+
from ._models import (
|
|
7
|
+
Action,
|
|
8
|
+
Brand,
|
|
9
|
+
BrandColor,
|
|
10
|
+
BrandFont,
|
|
11
|
+
BrandImage,
|
|
12
|
+
BrandResponse,
|
|
13
|
+
BrandSocial,
|
|
14
|
+
Chunk,
|
|
15
|
+
CrawlError,
|
|
16
|
+
CrawlEvent,
|
|
17
|
+
CrawlJob,
|
|
18
|
+
CrawlStatus,
|
|
19
|
+
Document,
|
|
20
|
+
ExtractResponse,
|
|
21
|
+
ExtractSource,
|
|
22
|
+
Location,
|
|
23
|
+
MapLink,
|
|
24
|
+
MapResponse,
|
|
25
|
+
Metadata,
|
|
26
|
+
Quality,
|
|
27
|
+
ScrapeOptions,
|
|
28
|
+
ScrapeResponse,
|
|
29
|
+
SearchHit,
|
|
30
|
+
SearchResponse,
|
|
31
|
+
VerminModel,
|
|
32
|
+
Webhook,
|
|
33
|
+
)
|
|
34
|
+
from ._version import __version__
|
|
35
|
+
from ._webhooks import (
|
|
36
|
+
WEBHOOK_SIGNATURE_HEADER,
|
|
37
|
+
WEBHOOK_TOLERANCE_SECONDS,
|
|
38
|
+
is_valid_webhook,
|
|
39
|
+
sign_webhook,
|
|
40
|
+
verify_webhook,
|
|
41
|
+
verify_webhook_signature,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
__all__ = [
|
|
45
|
+
"API_ERROR_CODES",
|
|
46
|
+
"CLIENT_ERROR_CODES",
|
|
47
|
+
"DEFAULT_BASE_URL",
|
|
48
|
+
"Action",
|
|
49
|
+
"AsyncCrawl",
|
|
50
|
+
"AsyncVermin",
|
|
51
|
+
"Brand",
|
|
52
|
+
"BrandColor",
|
|
53
|
+
"BrandFont",
|
|
54
|
+
"BrandImage",
|
|
55
|
+
"BrandResponse",
|
|
56
|
+
"BrandSocial",
|
|
57
|
+
"Chunk",
|
|
58
|
+
"Crawl",
|
|
59
|
+
"CrawlError",
|
|
60
|
+
"CrawlEvent",
|
|
61
|
+
"CrawlJob",
|
|
62
|
+
"CrawlOptionsDict",
|
|
63
|
+
"CrawlStatus",
|
|
64
|
+
"Document",
|
|
65
|
+
"ExtractResponse",
|
|
66
|
+
"ExtractSource",
|
|
67
|
+
"Location",
|
|
68
|
+
"MapLink",
|
|
69
|
+
"MapOptionsDict",
|
|
70
|
+
"MapResponse",
|
|
71
|
+
"Metadata",
|
|
72
|
+
"Quality",
|
|
73
|
+
"ScrapeOptions",
|
|
74
|
+
"ScrapeOptionsDict",
|
|
75
|
+
"ScrapeResponse",
|
|
76
|
+
"SearchHit",
|
|
77
|
+
"SearchOptionsDict",
|
|
78
|
+
"SearchResponse",
|
|
79
|
+
"Vermin",
|
|
80
|
+
"VerminError",
|
|
81
|
+
"VerminModel",
|
|
82
|
+
"WEBHOOK_SIGNATURE_HEADER",
|
|
83
|
+
"WEBHOOK_TOLERANCE_SECONDS",
|
|
84
|
+
"Webhook",
|
|
85
|
+
"__version__",
|
|
86
|
+
"is_valid_webhook",
|
|
87
|
+
"sign_webhook",
|
|
88
|
+
"verify_webhook",
|
|
89
|
+
"verify_webhook_signature",
|
|
90
|
+
]
|