vermin-sdk 0.1.0__py3-none-any.whl
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/__init__.py +90 -0
- vermin/_base.py +269 -0
- vermin/_client.py +545 -0
- vermin/_errors.py +174 -0
- vermin/_models.py +280 -0
- vermin/_sse.py +121 -0
- vermin/_version.py +1 -0
- vermin/_webhooks.py +141 -0
- vermin/py.typed +0 -0
- vermin_sdk-0.1.0.dist-info/METADATA +234 -0
- vermin_sdk-0.1.0.dist-info/RECORD +12 -0
- vermin_sdk-0.1.0.dist-info/WHEEL +4 -0
vermin/__init__.py
ADDED
|
@@ -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
|
+
]
|
vermin/_base.py
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""Shared plumbing for the sync and async clients: config, request building, retries, parsing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import random
|
|
8
|
+
import uuid
|
|
9
|
+
from typing import Any, Dict, List, Mapping, NamedTuple, Optional, Type, TypeVar, Union
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
from pydantic import BaseModel, ValidationError
|
|
13
|
+
|
|
14
|
+
from ._errors import VerminError
|
|
15
|
+
from ._models import CrawlEvent, CrawlStatus, Document, ScrapeOptions, Webhook
|
|
16
|
+
from ._sse import SSEMessage
|
|
17
|
+
from ._version import __version__
|
|
18
|
+
|
|
19
|
+
from typing_extensions import TypedDict
|
|
20
|
+
|
|
21
|
+
DEFAULT_BASE_URL = "https://api.vermin.dev"
|
|
22
|
+
DEFAULT_TIMEOUT = 60.0
|
|
23
|
+
DEFAULT_MAX_RETRIES = 2
|
|
24
|
+
|
|
25
|
+
M = TypeVar("M", bound=BaseModel)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ---- Keyword-argument shapes (snake_case; camelCase keys are accepted too) ---
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ScrapeOptionsDict(TypedDict, total=False):
|
|
32
|
+
formats: List[str]
|
|
33
|
+
only_main_content: bool
|
|
34
|
+
site_markdown: bool
|
|
35
|
+
include_tags: List[str]
|
|
36
|
+
exclude_tags: List[str]
|
|
37
|
+
tier: str
|
|
38
|
+
allow_premium: bool
|
|
39
|
+
actions: List[Dict[str, Any]]
|
|
40
|
+
wait_for: int
|
|
41
|
+
timeout: int
|
|
42
|
+
"""Server-side page timeout in ms (1000–60000), not the HTTP client timeout."""
|
|
43
|
+
max_age: int
|
|
44
|
+
headers: Dict[str, str]
|
|
45
|
+
location: Dict[str, Any]
|
|
46
|
+
chunk_size: int
|
|
47
|
+
ignore_robots: bool
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
ScrapeOptionsInput = Union[ScrapeOptions, ScrapeOptionsDict, Mapping[str, Any]]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class CrawlOptionsDict(TypedDict, total=False):
|
|
54
|
+
limit: int
|
|
55
|
+
max_depth: int
|
|
56
|
+
include_paths: List[str]
|
|
57
|
+
exclude_paths: List[str]
|
|
58
|
+
allow_subdomains: bool
|
|
59
|
+
sitemap: str
|
|
60
|
+
remove_boilerplate: bool
|
|
61
|
+
ignore_robots: bool
|
|
62
|
+
scrape_options: ScrapeOptionsInput
|
|
63
|
+
webhook: Union[Webhook, Mapping[str, Any]]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class MapOptionsDict(TypedDict, total=False):
|
|
67
|
+
search: str
|
|
68
|
+
limit: int
|
|
69
|
+
include_subdomains: bool
|
|
70
|
+
sitemap: str
|
|
71
|
+
include_paths: List[str]
|
|
72
|
+
"""Only return URLs whose path matches one of these globs."""
|
|
73
|
+
exclude_paths: List[str]
|
|
74
|
+
"""Never return URLs whose path matches one of these globs."""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class SearchOptionsDict(TypedDict, total=False):
|
|
78
|
+
limit: int
|
|
79
|
+
country: str
|
|
80
|
+
language: str
|
|
81
|
+
time_range: str
|
|
82
|
+
scrape_options: ScrapeOptionsInput
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# ---- Helpers -----------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def camel(key: str) -> str:
|
|
89
|
+
if "_" not in key:
|
|
90
|
+
return key
|
|
91
|
+
head, *rest = key.split("_")
|
|
92
|
+
return head + "".join(p[:1].upper() + p[1:] for p in rest)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def scrape_options_wire(value: Optional[ScrapeOptionsInput]) -> Optional[Dict[str, Any]]:
|
|
96
|
+
if value is None:
|
|
97
|
+
return None
|
|
98
|
+
model = value if isinstance(value, ScrapeOptions) else ScrapeOptions.model_validate(dict(value))
|
|
99
|
+
return model.to_dict()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def wire(options: Mapping[str, Any]) -> Dict[str, Any]:
|
|
103
|
+
"""Convert snake_case keyword options to the camelCase wire body (``None`` values dropped)."""
|
|
104
|
+
body: Dict[str, Any] = {}
|
|
105
|
+
for key, value in options.items():
|
|
106
|
+
if value is None:
|
|
107
|
+
continue
|
|
108
|
+
if key in ("scrape_options", "scrapeOptions"):
|
|
109
|
+
value = scrape_options_wire(value)
|
|
110
|
+
elif key == "webhook":
|
|
111
|
+
value = (value if isinstance(value, Webhook) else Webhook.model_validate(dict(value))).to_dict()
|
|
112
|
+
body[camel(key)] = value
|
|
113
|
+
return body
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def scrape_body(url: str, options: Mapping[str, Any]) -> Dict[str, Any]:
|
|
117
|
+
return {"url": url, **ScrapeOptions.model_validate(dict(options)).to_dict()}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def extract_schema(schema: Any) -> Optional[Dict[str, Any]]:
|
|
121
|
+
if schema is None:
|
|
122
|
+
return None
|
|
123
|
+
if isinstance(schema, type) and issubclass(schema, BaseModel):
|
|
124
|
+
return schema.model_json_schema()
|
|
125
|
+
if isinstance(schema, Mapping):
|
|
126
|
+
return dict(schema)
|
|
127
|
+
raise TypeError("schema must be a JSON Schema dict or a pydantic BaseModel subclass")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def read_env(name: str) -> Optional[str]:
|
|
131
|
+
return os.environ.get(name) or None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def backoff_seconds(attempt: int) -> float:
|
|
135
|
+
"""Exponential backoff with jitter: 0.5s, 1s, 2s… capped at 8s, randomised to 50–100%."""
|
|
136
|
+
base: float = min(8.0, 0.5 * 2.0**attempt)
|
|
137
|
+
return base / 2 + random.random() * (base / 2)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class Request(NamedTuple):
|
|
141
|
+
method: str
|
|
142
|
+
path: str
|
|
143
|
+
params: Optional[Dict[str, Any]] = None
|
|
144
|
+
json: Optional[Dict[str, Any]] = None
|
|
145
|
+
idempotency_key: Optional[str] = None
|
|
146
|
+
"""Set for POSTs that accept an ``Idempotency-Key``; reused across retries."""
|
|
147
|
+
headers: Optional[Dict[str, str]] = None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def idempotent(key: Optional[str]) -> str:
|
|
151
|
+
return key or str(uuid.uuid4())
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class BaseClient:
|
|
155
|
+
api_key: str
|
|
156
|
+
base_url: str
|
|
157
|
+
timeout: float
|
|
158
|
+
max_retries: int
|
|
159
|
+
|
|
160
|
+
def __init__(
|
|
161
|
+
self,
|
|
162
|
+
api_key: Optional[str],
|
|
163
|
+
base_url: Optional[str],
|
|
164
|
+
timeout: Optional[float],
|
|
165
|
+
max_retries: Optional[int],
|
|
166
|
+
headers: Optional[Mapping[str, str]],
|
|
167
|
+
) -> None:
|
|
168
|
+
key = api_key or read_env("VERMIN_API_KEY")
|
|
169
|
+
if not key:
|
|
170
|
+
raise VerminError(
|
|
171
|
+
"missing_api_key",
|
|
172
|
+
"Missing API key: pass api_key=... or set the VERMIN_API_KEY environment variable.",
|
|
173
|
+
)
|
|
174
|
+
self.api_key = key
|
|
175
|
+
self.base_url = (base_url or read_env("VERMIN_API_URL") or DEFAULT_BASE_URL).rstrip("/")
|
|
176
|
+
self.timeout = DEFAULT_TIMEOUT if timeout is None else timeout
|
|
177
|
+
self.max_retries = max(0, DEFAULT_MAX_RETRIES if max_retries is None else max_retries)
|
|
178
|
+
self._extra_headers = dict(headers or {})
|
|
179
|
+
|
|
180
|
+
def _url(self, path: str) -> str:
|
|
181
|
+
return self.base_url + path
|
|
182
|
+
|
|
183
|
+
def _headers(self, req: Request, accept: str = "application/json") -> Dict[str, str]:
|
|
184
|
+
headers = {
|
|
185
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
186
|
+
"Accept": accept,
|
|
187
|
+
"User-Agent": f"vermin-python/{__version__}",
|
|
188
|
+
**self._extra_headers,
|
|
189
|
+
**(req.headers or {}),
|
|
190
|
+
}
|
|
191
|
+
if req.idempotency_key:
|
|
192
|
+
headers["Idempotency-Key"] = req.idempotency_key
|
|
193
|
+
return headers
|
|
194
|
+
|
|
195
|
+
@staticmethod
|
|
196
|
+
def _params(req: Request) -> Optional[Dict[str, Any]]:
|
|
197
|
+
if not req.params:
|
|
198
|
+
return None
|
|
199
|
+
return {k: (str(v).lower() if isinstance(v, bool) else v) for k, v in req.params.items() if v is not None}
|
|
200
|
+
|
|
201
|
+
@staticmethod
|
|
202
|
+
def _transport_error(exc: Exception, timeout: float) -> VerminError:
|
|
203
|
+
if isinstance(exc, httpx.TimeoutException):
|
|
204
|
+
err = VerminError("timeout", f"Request timed out after {timeout}s")
|
|
205
|
+
else:
|
|
206
|
+
err = VerminError("connection_error", f"Connection error: {exc}")
|
|
207
|
+
err.__cause__ = exc
|
|
208
|
+
return err
|
|
209
|
+
|
|
210
|
+
@staticmethod
|
|
211
|
+
def _retry_delay(err: VerminError, attempt: int) -> float:
|
|
212
|
+
if err.retry_after_ms is not None:
|
|
213
|
+
return err.retry_after_ms / 1000
|
|
214
|
+
return backoff_seconds(attempt)
|
|
215
|
+
|
|
216
|
+
@staticmethod
|
|
217
|
+
def _parse(model: Type[M], response: httpx.Response) -> M:
|
|
218
|
+
try:
|
|
219
|
+
body = response.json()
|
|
220
|
+
result = model.model_validate(body)
|
|
221
|
+
except (ValueError, ValidationError) as exc:
|
|
222
|
+
err = VerminError(
|
|
223
|
+
"invalid_response",
|
|
224
|
+
f"Could not parse {model.__name__} from the response: {exc}",
|
|
225
|
+
status=response.status_code,
|
|
226
|
+
request_id=response.headers.get("x-request-id"),
|
|
227
|
+
body=response.text,
|
|
228
|
+
)
|
|
229
|
+
raise err from exc
|
|
230
|
+
if getattr(result, "request_id", "") is None and response.headers.get("x-request-id"):
|
|
231
|
+
result.request_id = response.headers["x-request-id"] # type: ignore[attr-defined]
|
|
232
|
+
return result
|
|
233
|
+
|
|
234
|
+
@staticmethod
|
|
235
|
+
def _crawl_event(msg: SSEMessage) -> Optional[CrawlEvent]:
|
|
236
|
+
if msg.event not in ("page", "status", "done"):
|
|
237
|
+
return None
|
|
238
|
+
try:
|
|
239
|
+
data = json.loads(msg.data)
|
|
240
|
+
parsed: Union[Document, CrawlStatus] = (
|
|
241
|
+
Document.model_validate(data) if msg.event == "page" else CrawlStatus.model_validate(data)
|
|
242
|
+
)
|
|
243
|
+
except (ValueError, ValidationError) as exc:
|
|
244
|
+
raise VerminError("invalid_response", f"Invalid {msg.event!r} event: {exc}", body=msg.data) from exc
|
|
245
|
+
return CrawlEvent(type=msg.event, id=msg.id, data=parsed)
|
|
246
|
+
|
|
247
|
+
@staticmethod
|
|
248
|
+
def _validate_extract(schema: Any, response: Any) -> Any:
|
|
249
|
+
if isinstance(schema, type) and issubclass(schema, BaseModel):
|
|
250
|
+
try:
|
|
251
|
+
response.data = schema.model_validate(response.data)
|
|
252
|
+
except ValidationError as exc:
|
|
253
|
+
raise VerminError(
|
|
254
|
+
"invalid_response",
|
|
255
|
+
f"Extracted data did not match {schema.__name__}: {exc}",
|
|
256
|
+
status=200,
|
|
257
|
+
request_id=response.request_id,
|
|
258
|
+
body=response.data,
|
|
259
|
+
) from exc
|
|
260
|
+
return response
|
|
261
|
+
|
|
262
|
+
@staticmethod
|
|
263
|
+
def _stream_closed(reconnects: int, cause: Optional[BaseException]) -> VerminError:
|
|
264
|
+
err = VerminError(
|
|
265
|
+
"connection_error",
|
|
266
|
+
f'Crawl event stream closed before "done" (after {reconnects} reconnects)',
|
|
267
|
+
)
|
|
268
|
+
err.__cause__ = cause
|
|
269
|
+
return err
|