clawfetch 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.
- clawfetch/__init__.py +17 -0
- clawfetch/async_client.py +451 -0
- clawfetch/client.py +478 -0
- clawfetch/errors.py +49 -0
- clawfetch-0.1.0.dist-info/METADATA +268 -0
- clawfetch-0.1.0.dist-info/RECORD +8 -0
- clawfetch-0.1.0.dist-info/WHEEL +5 -0
- clawfetch-0.1.0.dist-info/top_level.txt +1 -0
clawfetch/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""ClawFetch — Web Intelligence API for AI Agents (x402-native)"""
|
|
2
|
+
|
|
3
|
+
from .client import ClawFetch, RetryOptions
|
|
4
|
+
from .async_client import AsyncClawFetch
|
|
5
|
+
from .errors import ApiError, ClawFetchError, NetworkError, PaymentError, RateLimitError
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"ClawFetch",
|
|
9
|
+
"AsyncClawFetch",
|
|
10
|
+
"RetryOptions",
|
|
11
|
+
"ClawFetchError",
|
|
12
|
+
"PaymentError",
|
|
13
|
+
"NetworkError",
|
|
14
|
+
"RateLimitError",
|
|
15
|
+
"ApiError",
|
|
16
|
+
]
|
|
17
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
"""ClawFetch Async Python SDK — async/await support using httpx.AsyncClient."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import base64
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import os
|
|
10
|
+
import random
|
|
11
|
+
import time
|
|
12
|
+
from typing import Any, Dict, List, Optional
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
from eth_account import Account
|
|
16
|
+
from eth_account.messages import encode_typed_data
|
|
17
|
+
|
|
18
|
+
from .client import RetryOptions, BASE_URL, _RETRYABLE_STATUS_CODES
|
|
19
|
+
from .errors import ApiError, ClawFetchError, NetworkError, PaymentError, RateLimitError
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger("clawfetch.async")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class AsyncClawFetch:
|
|
25
|
+
"""Async client for the ClawFetch Web Intelligence API.
|
|
26
|
+
|
|
27
|
+
Mirrors all methods from the sync ``ClawFetch`` client, but uses
|
|
28
|
+
``httpx.AsyncClient`` and ``async/await`` for non-blocking I/O.
|
|
29
|
+
|
|
30
|
+
Handles the full x402 payment flow asynchronously:
|
|
31
|
+
1. Make request → get 402 with payment requirements
|
|
32
|
+
2. Sign EIP-3009 gasless USDC transfer on Base
|
|
33
|
+
3. Retry with PAYMENT-SIGNATURE header → get data
|
|
34
|
+
|
|
35
|
+
Usage::
|
|
36
|
+
|
|
37
|
+
import asyncio
|
|
38
|
+
from clawfetch import AsyncClawFetch
|
|
39
|
+
|
|
40
|
+
async def main():
|
|
41
|
+
async with AsyncClawFetch(private_key="0x...") as cf:
|
|
42
|
+
btc = await cf.extract("https://coingecko.com/en/coins/bitcoin")
|
|
43
|
+
print(btc["data"])
|
|
44
|
+
|
|
45
|
+
asyncio.run(main())
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
private_key: str,
|
|
51
|
+
base_url: str = BASE_URL,
|
|
52
|
+
timeout: float = 30.0,
|
|
53
|
+
retry: RetryOptions | bool | None = None,
|
|
54
|
+
debug: bool = False,
|
|
55
|
+
):
|
|
56
|
+
"""Initialize AsyncClawFetch client.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
private_key: Ethereum private key (hex string with 0x prefix).
|
|
60
|
+
base_url: API base URL. Defaults to https://api.clawfetch.ai.
|
|
61
|
+
timeout: Request timeout in seconds. Defaults to 30.
|
|
62
|
+
retry: Retry configuration. Pass False to disable, True or None for defaults,
|
|
63
|
+
or a RetryOptions instance for custom config.
|
|
64
|
+
debug: Enable debug logging.
|
|
65
|
+
"""
|
|
66
|
+
self._account = Account.from_key(private_key)
|
|
67
|
+
self._base_url = base_url.rstrip("/")
|
|
68
|
+
self._timeout = timeout
|
|
69
|
+
self._client = httpx.AsyncClient(timeout=timeout)
|
|
70
|
+
|
|
71
|
+
# Configure retry
|
|
72
|
+
if retry is False:
|
|
73
|
+
self._retry: RetryOptions | None = None
|
|
74
|
+
elif retry is True or retry is None:
|
|
75
|
+
self._retry = RetryOptions()
|
|
76
|
+
elif isinstance(retry, RetryOptions):
|
|
77
|
+
self._retry = retry
|
|
78
|
+
else:
|
|
79
|
+
self._retry = RetryOptions()
|
|
80
|
+
|
|
81
|
+
if debug:
|
|
82
|
+
logger.setLevel(logging.DEBUG)
|
|
83
|
+
if not logger.handlers:
|
|
84
|
+
handler = logging.StreamHandler()
|
|
85
|
+
handler.setFormatter(
|
|
86
|
+
logging.Formatter("[clawfetch.async] %(levelname)s %(message)s")
|
|
87
|
+
)
|
|
88
|
+
logger.addHandler(handler)
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def address(self) -> str:
|
|
92
|
+
"""Wallet address derived from the private key."""
|
|
93
|
+
return self._account.address
|
|
94
|
+
|
|
95
|
+
async def close(self) -> None:
|
|
96
|
+
"""Close the underlying HTTP client."""
|
|
97
|
+
await self._client.aclose()
|
|
98
|
+
|
|
99
|
+
async def __aenter__(self):
|
|
100
|
+
return self
|
|
101
|
+
|
|
102
|
+
async def __aexit__(self, *args):
|
|
103
|
+
await self.close()
|
|
104
|
+
|
|
105
|
+
# ─── Public endpoints ──────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
async def fetch(self, url: str, *, max_chars: int | None = None) -> dict:
|
|
108
|
+
"""Fetch a URL as clean markdown ($0.001).
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
url: URL to fetch.
|
|
112
|
+
max_chars: Maximum characters to return.
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
Dict with url, title, content, contentType fields.
|
|
116
|
+
|
|
117
|
+
Raises:
|
|
118
|
+
PaymentError: If x402 payment fails.
|
|
119
|
+
RateLimitError: If rate limited (429).
|
|
120
|
+
ApiError: If server returns 4xx/5xx.
|
|
121
|
+
NetworkError: If connection fails.
|
|
122
|
+
"""
|
|
123
|
+
body: dict = {"url": url}
|
|
124
|
+
if max_chars:
|
|
125
|
+
body["maxChars"] = max_chars
|
|
126
|
+
return await self._paid_post("/fetch", body)
|
|
127
|
+
|
|
128
|
+
async def render(self, url: str, *, max_chars: int | None = None) -> dict:
|
|
129
|
+
"""Render JS-heavy page with stealth browser ($0.002).
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
url: URL to render.
|
|
133
|
+
max_chars: Maximum characters to return.
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
Dict with url, title, content fields.
|
|
137
|
+
"""
|
|
138
|
+
body: dict = {"url": url}
|
|
139
|
+
if max_chars:
|
|
140
|
+
body["maxChars"] = max_chars
|
|
141
|
+
return await self._paid_post("/render", body)
|
|
142
|
+
|
|
143
|
+
async def extract(self, url: str) -> dict:
|
|
144
|
+
"""Extract structured data from supported URL ($0.003).
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
url: URL to extract from (must match a supported extractor).
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
Dict with url, extractor, data fields.
|
|
151
|
+
"""
|
|
152
|
+
return await self._paid_post("/extract", {"url": url})
|
|
153
|
+
|
|
154
|
+
async def research(self, topic: str, *, sources: int | None = None) -> dict:
|
|
155
|
+
"""Multi-source research on a topic ($0.01).
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
topic: Research topic/query.
|
|
159
|
+
sources: Number of sources to consult.
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
Dict with topic, summary, sources fields.
|
|
163
|
+
"""
|
|
164
|
+
body: dict = {"topic": topic}
|
|
165
|
+
if sources:
|
|
166
|
+
body["sources"] = sources
|
|
167
|
+
return await self._paid_post("/research", body)
|
|
168
|
+
|
|
169
|
+
async def domains_check(self, domains: list[str]) -> dict:
|
|
170
|
+
"""Check domain availability ($0.002).
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
domains: List of domain names to check.
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
Dict with domains list, each having domain and available fields.
|
|
177
|
+
"""
|
|
178
|
+
return await self._paid_post("/domains/check", {"domains": domains})
|
|
179
|
+
|
|
180
|
+
async def domains_suggest(self, query: str, *, tlds: list[str] | None = None) -> dict:
|
|
181
|
+
"""Generate domain suggestions ($0.002).
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
query: Topic or keyword for domain suggestions.
|
|
185
|
+
tlds: Preferred TLDs (e.g., [".ai", ".dev"]).
|
|
186
|
+
|
|
187
|
+
Returns:
|
|
188
|
+
Dict with query and suggestions list.
|
|
189
|
+
"""
|
|
190
|
+
body: dict = {"query": query}
|
|
191
|
+
if tlds:
|
|
192
|
+
body["tlds"] = tlds
|
|
193
|
+
return await self._paid_post("/domains/suggest", body)
|
|
194
|
+
|
|
195
|
+
async def extractors(self) -> list[dict]:
|
|
196
|
+
"""List available extractors ($0.001).
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
List of extractor dicts with name, domains, description, fields.
|
|
200
|
+
"""
|
|
201
|
+
resp = await self._paid_request("GET", "/extractors")
|
|
202
|
+
return resp.get("extractors", [])
|
|
203
|
+
|
|
204
|
+
async def health(self) -> dict:
|
|
205
|
+
"""Check service health (free, no payment required).
|
|
206
|
+
|
|
207
|
+
Returns:
|
|
208
|
+
Dict with status, service, version fields.
|
|
209
|
+
|
|
210
|
+
Raises:
|
|
211
|
+
NetworkError: If connection to API fails.
|
|
212
|
+
"""
|
|
213
|
+
try:
|
|
214
|
+
r = await self._client.get(f"{self._base_url}/health")
|
|
215
|
+
r.raise_for_status()
|
|
216
|
+
return r.json()
|
|
217
|
+
except (httpx.ConnectError, httpx.TimeoutException) as exc:
|
|
218
|
+
raise NetworkError(
|
|
219
|
+
f"Health check failed: {exc}", "/health", cause=exc
|
|
220
|
+
) from exc
|
|
221
|
+
|
|
222
|
+
# ─── Retry engine ──────────────────────────────────────────
|
|
223
|
+
|
|
224
|
+
def _should_retry(self, status_code: int) -> bool:
|
|
225
|
+
"""Determine if a status code is retryable."""
|
|
226
|
+
return status_code in _RETRYABLE_STATUS_CODES
|
|
227
|
+
|
|
228
|
+
def _get_retry_delay_ms(self, attempt: int, retry_after_ms: int | None = None) -> int:
|
|
229
|
+
"""Calculate delay for a retry attempt with exponential backoff + jitter."""
|
|
230
|
+
if self._retry is None:
|
|
231
|
+
return 0
|
|
232
|
+
|
|
233
|
+
if retry_after_ms and retry_after_ms > 0:
|
|
234
|
+
return retry_after_ms
|
|
235
|
+
|
|
236
|
+
delay = self._retry.initial_delay_ms * (
|
|
237
|
+
self._retry.backoff_multiplier ** attempt
|
|
238
|
+
)
|
|
239
|
+
# Add jitter (±25%)
|
|
240
|
+
jitter = delay * 0.25
|
|
241
|
+
delay = delay + random.uniform(-jitter, jitter)
|
|
242
|
+
return min(int(delay), self._retry.max_delay_ms)
|
|
243
|
+
|
|
244
|
+
def _parse_retry_after(self, headers: httpx.Headers) -> int | None:
|
|
245
|
+
"""Parse Retry-After header into milliseconds."""
|
|
246
|
+
val = headers.get("retry-after")
|
|
247
|
+
if val is None:
|
|
248
|
+
return None
|
|
249
|
+
try:
|
|
250
|
+
return int(float(val) * 1000)
|
|
251
|
+
except (ValueError, TypeError):
|
|
252
|
+
return None
|
|
253
|
+
|
|
254
|
+
# ─── x402 payment flow with retry ──────────────────────────
|
|
255
|
+
|
|
256
|
+
async def _paid_post(self, path: str, body: dict) -> dict:
|
|
257
|
+
return await self._paid_request("POST", path, json_body=body)
|
|
258
|
+
|
|
259
|
+
async def _paid_request(
|
|
260
|
+
self, method: str, path: str, json_body: dict | None = None
|
|
261
|
+
) -> dict:
|
|
262
|
+
url = f"{self._base_url}{path}"
|
|
263
|
+
kwargs: dict = {}
|
|
264
|
+
if json_body is not None:
|
|
265
|
+
kwargs["json"] = json_body
|
|
266
|
+
|
|
267
|
+
max_attempts = (self._retry.max_retries + 1) if self._retry else 1
|
|
268
|
+
last_error: Exception | None = None
|
|
269
|
+
|
|
270
|
+
for attempt in range(max_attempts):
|
|
271
|
+
try:
|
|
272
|
+
logger.debug(
|
|
273
|
+
"Request %s %s (attempt %d/%d)", method, path, attempt + 1, max_attempts
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
# First request — expect 402
|
|
277
|
+
resp = await self._client.request(method, url, **kwargs)
|
|
278
|
+
|
|
279
|
+
if resp.status_code == 402:
|
|
280
|
+
# x402 payment flow
|
|
281
|
+
payment_required = self._parse_payment_required(resp)
|
|
282
|
+
payment_payload = self._create_payment_payload(payment_required)
|
|
283
|
+
encoded = base64.b64encode(
|
|
284
|
+
json.dumps(payment_payload).encode()
|
|
285
|
+
).decode()
|
|
286
|
+
|
|
287
|
+
# Retry with payment header
|
|
288
|
+
headers = {"PAYMENT-SIGNATURE": encoded}
|
|
289
|
+
resp = await self._client.request(method, url, headers=headers, **kwargs)
|
|
290
|
+
|
|
291
|
+
# Check for retryable errors
|
|
292
|
+
if resp.status_code == 429:
|
|
293
|
+
retry_after_ms = self._parse_retry_after(resp.headers)
|
|
294
|
+
if attempt < max_attempts - 1 and self._retry:
|
|
295
|
+
delay_ms = self._get_retry_delay_ms(attempt, retry_after_ms)
|
|
296
|
+
logger.debug(
|
|
297
|
+
"Rate limited (429), retrying in %dms", delay_ms
|
|
298
|
+
)
|
|
299
|
+
await asyncio.sleep(delay_ms / 1000)
|
|
300
|
+
continue
|
|
301
|
+
raise RateLimitError(
|
|
302
|
+
f"Rate limited on {path}",
|
|
303
|
+
endpoint=path,
|
|
304
|
+
retry_after_ms=retry_after_ms,
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
if resp.status_code >= 500 and self._should_retry(resp.status_code):
|
|
308
|
+
if attempt < max_attempts - 1 and self._retry:
|
|
309
|
+
delay_ms = self._get_retry_delay_ms(attempt)
|
|
310
|
+
logger.debug(
|
|
311
|
+
"Server error %d, retrying in %dms",
|
|
312
|
+
resp.status_code,
|
|
313
|
+
delay_ms,
|
|
314
|
+
)
|
|
315
|
+
await asyncio.sleep(delay_ms / 1000)
|
|
316
|
+
continue
|
|
317
|
+
# Final attempt — raise
|
|
318
|
+
try:
|
|
319
|
+
body_text = resp.json().get("error", resp.text)
|
|
320
|
+
except Exception:
|
|
321
|
+
body_text = str(resp.status_code)
|
|
322
|
+
raise ApiError(
|
|
323
|
+
f"Server error on {path}: {body_text}",
|
|
324
|
+
resp.status_code,
|
|
325
|
+
endpoint=path,
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
if resp.status_code >= 400:
|
|
329
|
+
# Non-retryable client errors
|
|
330
|
+
try:
|
|
331
|
+
body_text = resp.json().get("error", str(resp.status_code))
|
|
332
|
+
except Exception:
|
|
333
|
+
body_text = str(resp.status_code)
|
|
334
|
+
raise ApiError(
|
|
335
|
+
f"API error on {path}: {body_text}",
|
|
336
|
+
resp.status_code,
|
|
337
|
+
endpoint=path,
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
return resp.json()
|
|
341
|
+
|
|
342
|
+
except (PaymentError, RateLimitError, ApiError):
|
|
343
|
+
raise
|
|
344
|
+
|
|
345
|
+
except (httpx.ConnectError, httpx.TimeoutException, httpx.ReadTimeout) as exc:
|
|
346
|
+
last_error = exc
|
|
347
|
+
if attempt < max_attempts - 1 and self._retry:
|
|
348
|
+
delay_ms = self._get_retry_delay_ms(attempt)
|
|
349
|
+
logger.debug(
|
|
350
|
+
"Network error (%s), retrying in %dms",
|
|
351
|
+
type(exc).__name__,
|
|
352
|
+
delay_ms,
|
|
353
|
+
)
|
|
354
|
+
await asyncio.sleep(delay_ms / 1000)
|
|
355
|
+
continue
|
|
356
|
+
raise NetworkError(
|
|
357
|
+
f"Network error on {path}: {exc}",
|
|
358
|
+
endpoint=path,
|
|
359
|
+
cause=exc,
|
|
360
|
+
) from exc
|
|
361
|
+
|
|
362
|
+
# Should never reach here, but just in case
|
|
363
|
+
raise NetworkError(
|
|
364
|
+
f"All {max_attempts} attempts failed for {path}",
|
|
365
|
+
endpoint=path,
|
|
366
|
+
cause=last_error,
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
# ─── x402 helpers (same as sync — signing is CPU-bound) ───
|
|
370
|
+
|
|
371
|
+
def _parse_payment_required(self, resp: httpx.Response) -> dict:
|
|
372
|
+
"""Parse x402 payment requirements from 402 response."""
|
|
373
|
+
header = resp.headers.get("payment-required") or resp.headers.get(
|
|
374
|
+
"PAYMENT-REQUIRED"
|
|
375
|
+
)
|
|
376
|
+
if header:
|
|
377
|
+
return json.loads(base64.b64decode(header))
|
|
378
|
+
|
|
379
|
+
body = resp.json()
|
|
380
|
+
if "x402Version" in body:
|
|
381
|
+
return body
|
|
382
|
+
|
|
383
|
+
raise ValueError("Cannot parse x402 payment requirements from 402 response")
|
|
384
|
+
|
|
385
|
+
def _create_payment_payload(self, payment_required: dict) -> dict:
|
|
386
|
+
"""Create EIP-3009 TransferWithAuthorization payload."""
|
|
387
|
+
if "accepts" in payment_required:
|
|
388
|
+
req = payment_required["accepts"][0]
|
|
389
|
+
else:
|
|
390
|
+
req = payment_required
|
|
391
|
+
|
|
392
|
+
now = int(time.time())
|
|
393
|
+
nonce = os.urandom(32)
|
|
394
|
+
|
|
395
|
+
authorization = {
|
|
396
|
+
"from": self._account.address,
|
|
397
|
+
"to": req["payTo"],
|
|
398
|
+
"value": str(req.get("maxAmountRequired", req.get("amount", "0"))),
|
|
399
|
+
"validAfter": str(now - 600),
|
|
400
|
+
"validBefore": str(now + req.get("maxTimeoutSeconds", 300)),
|
|
401
|
+
"nonce": "0x" + nonce.hex(),
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
extra = req.get("extra", {})
|
|
405
|
+
token_name = extra.get("name", "USD Coin")
|
|
406
|
+
token_version = extra.get("version", "2")
|
|
407
|
+
chain_id = int(req["network"].split(":")[1])
|
|
408
|
+
token_address = req["asset"]
|
|
409
|
+
|
|
410
|
+
domain = {
|
|
411
|
+
"name": token_name,
|
|
412
|
+
"version": token_version,
|
|
413
|
+
"chainId": chain_id,
|
|
414
|
+
"verifyingContract": token_address,
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
types = {
|
|
418
|
+
"TransferWithAuthorization": [
|
|
419
|
+
{"name": "from", "type": "address"},
|
|
420
|
+
{"name": "to", "type": "address"},
|
|
421
|
+
{"name": "value", "type": "uint256"},
|
|
422
|
+
{"name": "validAfter", "type": "uint256"},
|
|
423
|
+
{"name": "validBefore", "type": "uint256"},
|
|
424
|
+
{"name": "nonce", "type": "bytes32"},
|
|
425
|
+
],
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
message = {
|
|
429
|
+
"from": authorization["from"],
|
|
430
|
+
"to": authorization["to"],
|
|
431
|
+
"value": int(authorization["value"]),
|
|
432
|
+
"validAfter": int(authorization["validAfter"]),
|
|
433
|
+
"validBefore": int(authorization["validBefore"]),
|
|
434
|
+
"nonce": bytes.fromhex(authorization["nonce"][2:]),
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
signed = self._account.sign_typed_data(
|
|
438
|
+
domain_data=domain,
|
|
439
|
+
message_types=types,
|
|
440
|
+
message_data=message,
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
return {
|
|
444
|
+
"x402Version": 2,
|
|
445
|
+
"payload": {
|
|
446
|
+
"authorization": authorization,
|
|
447
|
+
"signature": signed.signature.hex()
|
|
448
|
+
if isinstance(signed.signature, bytes)
|
|
449
|
+
else str(signed.signature),
|
|
450
|
+
},
|
|
451
|
+
}
|
clawfetch/client.py
ADDED
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
"""ClawFetch Python SDK — handles x402 payment flow automatically."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
import random
|
|
10
|
+
import time
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Any, Dict, List, Optional
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
from eth_account import Account
|
|
16
|
+
from eth_account.messages import encode_typed_data
|
|
17
|
+
|
|
18
|
+
from .errors import ApiError, ClawFetchError, NetworkError, PaymentError, RateLimitError
|
|
19
|
+
|
|
20
|
+
BASE_URL = "https://api.clawfetch.ai"
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger("clawfetch")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class RetryOptions:
|
|
27
|
+
"""Configuration for automatic retry with exponential backoff."""
|
|
28
|
+
|
|
29
|
+
max_retries: int = 3
|
|
30
|
+
"""Maximum number of retry attempts."""
|
|
31
|
+
|
|
32
|
+
initial_delay_ms: int = 500
|
|
33
|
+
"""Initial delay in milliseconds before first retry."""
|
|
34
|
+
|
|
35
|
+
max_delay_ms: int = 10_000
|
|
36
|
+
"""Maximum delay in milliseconds between retries."""
|
|
37
|
+
|
|
38
|
+
backoff_multiplier: float = 2.0
|
|
39
|
+
"""Multiplier for exponential backoff."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# Statuses that are safe to retry
|
|
43
|
+
_RETRYABLE_STATUS_CODES = frozenset({429, 500, 502, 503, 504})
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ClawFetch:
|
|
47
|
+
"""Client for the ClawFetch Web Intelligence API.
|
|
48
|
+
|
|
49
|
+
Handles the full x402 payment flow:
|
|
50
|
+
1. Make request → get 402 with payment requirements
|
|
51
|
+
2. Sign EIP-3009 gasless USDC transfer on Base
|
|
52
|
+
3. Retry with PAYMENT-SIGNATURE header → get data
|
|
53
|
+
|
|
54
|
+
Features:
|
|
55
|
+
- Automatic x402 payment signing (EIP-3009 TransferWithAuthorization)
|
|
56
|
+
- Configurable retry with exponential backoff + jitter
|
|
57
|
+
- Typed error hierarchy (PaymentError, NetworkError, RateLimitError, ApiError)
|
|
58
|
+
- Configurable timeout
|
|
59
|
+
- Debug logging
|
|
60
|
+
|
|
61
|
+
Usage::
|
|
62
|
+
|
|
63
|
+
from clawfetch import ClawFetch
|
|
64
|
+
|
|
65
|
+
cf = ClawFetch(private_key="0x...")
|
|
66
|
+
btc = cf.extract("https://coingecko.com/en/coins/bitcoin")
|
|
67
|
+
print(btc["data"])
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def __init__(
|
|
71
|
+
self,
|
|
72
|
+
private_key: str,
|
|
73
|
+
base_url: str = BASE_URL,
|
|
74
|
+
timeout: float = 30.0,
|
|
75
|
+
retry: RetryOptions | bool | None = None,
|
|
76
|
+
debug: bool = False,
|
|
77
|
+
):
|
|
78
|
+
"""Initialize ClawFetch client.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
private_key: Ethereum private key (hex string with 0x prefix).
|
|
82
|
+
base_url: API base URL. Defaults to https://api.clawfetch.ai.
|
|
83
|
+
timeout: Request timeout in seconds. Defaults to 30.
|
|
84
|
+
retry: Retry configuration. Pass False to disable, True or None for defaults,
|
|
85
|
+
or a RetryOptions instance for custom config.
|
|
86
|
+
debug: Enable debug logging.
|
|
87
|
+
"""
|
|
88
|
+
self._account = Account.from_key(private_key)
|
|
89
|
+
self._base_url = base_url.rstrip("/")
|
|
90
|
+
self._timeout = timeout
|
|
91
|
+
self._client = httpx.Client(timeout=timeout)
|
|
92
|
+
|
|
93
|
+
# Configure retry
|
|
94
|
+
if retry is False:
|
|
95
|
+
self._retry: RetryOptions | None = None
|
|
96
|
+
elif retry is True or retry is None:
|
|
97
|
+
self._retry = RetryOptions()
|
|
98
|
+
elif isinstance(retry, RetryOptions):
|
|
99
|
+
self._retry = retry
|
|
100
|
+
else:
|
|
101
|
+
self._retry = RetryOptions()
|
|
102
|
+
|
|
103
|
+
if debug:
|
|
104
|
+
logger.setLevel(logging.DEBUG)
|
|
105
|
+
if not logger.handlers:
|
|
106
|
+
handler = logging.StreamHandler()
|
|
107
|
+
handler.setFormatter(
|
|
108
|
+
logging.Formatter("[clawfetch] %(levelname)s %(message)s")
|
|
109
|
+
)
|
|
110
|
+
logger.addHandler(handler)
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def address(self) -> str:
|
|
114
|
+
"""Wallet address derived from the private key."""
|
|
115
|
+
return self._account.address
|
|
116
|
+
|
|
117
|
+
def close(self) -> None:
|
|
118
|
+
"""Close the underlying HTTP client."""
|
|
119
|
+
self._client.close()
|
|
120
|
+
|
|
121
|
+
def __enter__(self):
|
|
122
|
+
return self
|
|
123
|
+
|
|
124
|
+
def __exit__(self, *args):
|
|
125
|
+
self.close()
|
|
126
|
+
|
|
127
|
+
# ─── Public endpoints ──────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
def fetch(self, url: str, *, max_chars: int | None = None) -> dict:
|
|
130
|
+
"""Fetch a URL as clean markdown ($0.001).
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
url: URL to fetch.
|
|
134
|
+
max_chars: Maximum characters to return.
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
Dict with url, title, content, contentType fields.
|
|
138
|
+
|
|
139
|
+
Raises:
|
|
140
|
+
PaymentError: If x402 payment fails.
|
|
141
|
+
RateLimitError: If rate limited (429).
|
|
142
|
+
ApiError: If server returns 4xx/5xx.
|
|
143
|
+
NetworkError: If connection fails.
|
|
144
|
+
"""
|
|
145
|
+
body: dict = {"url": url}
|
|
146
|
+
if max_chars:
|
|
147
|
+
body["maxChars"] = max_chars
|
|
148
|
+
return self._paid_post("/fetch", body)
|
|
149
|
+
|
|
150
|
+
def render(self, url: str, *, max_chars: int | None = None) -> dict:
|
|
151
|
+
"""Render JS-heavy page with stealth browser ($0.002).
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
url: URL to render.
|
|
155
|
+
max_chars: Maximum characters to return.
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
Dict with url, title, content fields.
|
|
159
|
+
"""
|
|
160
|
+
body: dict = {"url": url}
|
|
161
|
+
if max_chars:
|
|
162
|
+
body["maxChars"] = max_chars
|
|
163
|
+
return self._paid_post("/render", body)
|
|
164
|
+
|
|
165
|
+
def extract(self, url: str) -> dict:
|
|
166
|
+
"""Extract structured data from supported URL ($0.003).
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
url: URL to extract from (must match a supported extractor).
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
Dict with url, extractor, data fields.
|
|
173
|
+
"""
|
|
174
|
+
return self._paid_post("/extract", {"url": url})
|
|
175
|
+
|
|
176
|
+
def research(self, topic: str, *, sources: int | None = None) -> dict:
|
|
177
|
+
"""Multi-source research on a topic ($0.01).
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
topic: Research topic/query.
|
|
181
|
+
sources: Number of sources to consult.
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
Dict with topic, summary, sources fields.
|
|
185
|
+
"""
|
|
186
|
+
body: dict = {"topic": topic}
|
|
187
|
+
if sources:
|
|
188
|
+
body["sources"] = sources
|
|
189
|
+
return self._paid_post("/research", body)
|
|
190
|
+
|
|
191
|
+
def domains_check(self, domains: list[str]) -> dict:
|
|
192
|
+
"""Check domain availability ($0.002).
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
domains: List of domain names to check.
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
Dict with domains list, each having domain and available fields.
|
|
199
|
+
"""
|
|
200
|
+
return self._paid_post("/domains/check", {"domains": domains})
|
|
201
|
+
|
|
202
|
+
def domains_suggest(self, query: str, *, tlds: list[str] | None = None) -> dict:
|
|
203
|
+
"""Generate domain suggestions ($0.002).
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
query: Topic or keyword for domain suggestions.
|
|
207
|
+
tlds: Preferred TLDs (e.g., [".ai", ".dev"]).
|
|
208
|
+
|
|
209
|
+
Returns:
|
|
210
|
+
Dict with query and suggestions list.
|
|
211
|
+
"""
|
|
212
|
+
body: dict = {"query": query}
|
|
213
|
+
if tlds:
|
|
214
|
+
body["tlds"] = tlds
|
|
215
|
+
return self._paid_post("/domains/suggest", body)
|
|
216
|
+
|
|
217
|
+
def extractors(self) -> list[dict]:
|
|
218
|
+
"""List available extractors ($0.001).
|
|
219
|
+
|
|
220
|
+
Returns:
|
|
221
|
+
List of extractor dicts with name, domains, description, fields.
|
|
222
|
+
"""
|
|
223
|
+
resp = self._paid_request("GET", "/extractors")
|
|
224
|
+
return resp.get("extractors", [])
|
|
225
|
+
|
|
226
|
+
def health(self) -> dict:
|
|
227
|
+
"""Check service health (free, no payment required).
|
|
228
|
+
|
|
229
|
+
Returns:
|
|
230
|
+
Dict with status, service, version fields.
|
|
231
|
+
|
|
232
|
+
Raises:
|
|
233
|
+
NetworkError: If connection to API fails.
|
|
234
|
+
"""
|
|
235
|
+
try:
|
|
236
|
+
r = self._client.get(f"{self._base_url}/health")
|
|
237
|
+
r.raise_for_status()
|
|
238
|
+
return r.json()
|
|
239
|
+
except (httpx.ConnectError, httpx.TimeoutException) as exc:
|
|
240
|
+
raise NetworkError(
|
|
241
|
+
f"Health check failed: {exc}", "/health", cause=exc
|
|
242
|
+
) from exc
|
|
243
|
+
|
|
244
|
+
# ─── Retry engine ──────────────────────────────────────────
|
|
245
|
+
|
|
246
|
+
def _should_retry(self, status_code: int) -> bool:
|
|
247
|
+
"""Determine if a status code is retryable."""
|
|
248
|
+
return status_code in _RETRYABLE_STATUS_CODES
|
|
249
|
+
|
|
250
|
+
def _get_retry_delay_ms(self, attempt: int, retry_after_ms: int | None = None) -> int:
|
|
251
|
+
"""Calculate delay for a retry attempt with exponential backoff + jitter."""
|
|
252
|
+
if self._retry is None:
|
|
253
|
+
return 0
|
|
254
|
+
|
|
255
|
+
if retry_after_ms and retry_after_ms > 0:
|
|
256
|
+
return retry_after_ms
|
|
257
|
+
|
|
258
|
+
delay = self._retry.initial_delay_ms * (
|
|
259
|
+
self._retry.backoff_multiplier ** attempt
|
|
260
|
+
)
|
|
261
|
+
# Add jitter (±25%)
|
|
262
|
+
jitter = delay * 0.25
|
|
263
|
+
delay = delay + random.uniform(-jitter, jitter)
|
|
264
|
+
return min(int(delay), self._retry.max_delay_ms)
|
|
265
|
+
|
|
266
|
+
def _parse_retry_after(self, headers: httpx.Headers) -> int | None:
|
|
267
|
+
"""Parse Retry-After header into milliseconds."""
|
|
268
|
+
val = headers.get("retry-after")
|
|
269
|
+
if val is None:
|
|
270
|
+
return None
|
|
271
|
+
try:
|
|
272
|
+
return int(float(val) * 1000)
|
|
273
|
+
except (ValueError, TypeError):
|
|
274
|
+
return None
|
|
275
|
+
|
|
276
|
+
# ─── x402 payment flow with retry ──────────────────────────
|
|
277
|
+
|
|
278
|
+
def _paid_post(self, path: str, body: dict) -> dict:
|
|
279
|
+
return self._paid_request("POST", path, json_body=body)
|
|
280
|
+
|
|
281
|
+
def _paid_request(
|
|
282
|
+
self, method: str, path: str, json_body: dict | None = None
|
|
283
|
+
) -> dict:
|
|
284
|
+
url = f"{self._base_url}{path}"
|
|
285
|
+
kwargs: dict = {}
|
|
286
|
+
if json_body is not None:
|
|
287
|
+
kwargs["json"] = json_body
|
|
288
|
+
|
|
289
|
+
max_attempts = (self._retry.max_retries + 1) if self._retry else 1
|
|
290
|
+
last_error: Exception | None = None
|
|
291
|
+
|
|
292
|
+
for attempt in range(max_attempts):
|
|
293
|
+
try:
|
|
294
|
+
logger.debug(
|
|
295
|
+
"Request %s %s (attempt %d/%d)", method, path, attempt + 1, max_attempts
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
# First request — expect 402
|
|
299
|
+
resp = self._client.request(method, url, **kwargs)
|
|
300
|
+
|
|
301
|
+
if resp.status_code == 402:
|
|
302
|
+
# x402 payment flow
|
|
303
|
+
payment_required = self._parse_payment_required(resp)
|
|
304
|
+
payment_payload = self._create_payment_payload(payment_required)
|
|
305
|
+
encoded = base64.b64encode(
|
|
306
|
+
json.dumps(payment_payload).encode()
|
|
307
|
+
).decode()
|
|
308
|
+
|
|
309
|
+
# Retry with payment header
|
|
310
|
+
headers = {"PAYMENT-SIGNATURE": encoded}
|
|
311
|
+
resp = self._client.request(method, url, headers=headers, **kwargs)
|
|
312
|
+
|
|
313
|
+
# Check for retryable errors
|
|
314
|
+
if resp.status_code == 429:
|
|
315
|
+
retry_after_ms = self._parse_retry_after(resp.headers)
|
|
316
|
+
if attempt < max_attempts - 1 and self._retry:
|
|
317
|
+
delay_ms = self._get_retry_delay_ms(attempt, retry_after_ms)
|
|
318
|
+
logger.debug(
|
|
319
|
+
"Rate limited (429), retrying in %dms", delay_ms
|
|
320
|
+
)
|
|
321
|
+
time.sleep(delay_ms / 1000)
|
|
322
|
+
continue
|
|
323
|
+
raise RateLimitError(
|
|
324
|
+
f"Rate limited on {path}",
|
|
325
|
+
endpoint=path,
|
|
326
|
+
retry_after_ms=retry_after_ms,
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
if resp.status_code >= 500 and self._should_retry(resp.status_code):
|
|
330
|
+
if attempt < max_attempts - 1 and self._retry:
|
|
331
|
+
delay_ms = self._get_retry_delay_ms(attempt)
|
|
332
|
+
logger.debug(
|
|
333
|
+
"Server error %d, retrying in %dms",
|
|
334
|
+
resp.status_code,
|
|
335
|
+
delay_ms,
|
|
336
|
+
)
|
|
337
|
+
time.sleep(delay_ms / 1000)
|
|
338
|
+
continue
|
|
339
|
+
# Final attempt — raise
|
|
340
|
+
try:
|
|
341
|
+
body_text = resp.json().get("error", resp.text)
|
|
342
|
+
except Exception:
|
|
343
|
+
body_text = str(resp.status_code)
|
|
344
|
+
raise ApiError(
|
|
345
|
+
f"Server error on {path}: {body_text}",
|
|
346
|
+
resp.status_code,
|
|
347
|
+
endpoint=path,
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
if resp.status_code >= 400:
|
|
351
|
+
# Non-retryable client errors
|
|
352
|
+
try:
|
|
353
|
+
body_text = resp.json().get("error", str(resp.status_code))
|
|
354
|
+
except Exception:
|
|
355
|
+
body_text = str(resp.status_code)
|
|
356
|
+
raise ApiError(
|
|
357
|
+
f"API error on {path}: {body_text}",
|
|
358
|
+
resp.status_code,
|
|
359
|
+
endpoint=path,
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
return resp.json()
|
|
363
|
+
|
|
364
|
+
except (PaymentError, RateLimitError, ApiError):
|
|
365
|
+
raise
|
|
366
|
+
|
|
367
|
+
except (httpx.ConnectError, httpx.TimeoutException, httpx.ReadTimeout) as exc:
|
|
368
|
+
last_error = exc
|
|
369
|
+
if attempt < max_attempts - 1 and self._retry:
|
|
370
|
+
delay_ms = self._get_retry_delay_ms(attempt)
|
|
371
|
+
logger.debug(
|
|
372
|
+
"Network error (%s), retrying in %dms",
|
|
373
|
+
type(exc).__name__,
|
|
374
|
+
delay_ms,
|
|
375
|
+
)
|
|
376
|
+
time.sleep(delay_ms / 1000)
|
|
377
|
+
continue
|
|
378
|
+
raise NetworkError(
|
|
379
|
+
f"Network error on {path}: {exc}",
|
|
380
|
+
endpoint=path,
|
|
381
|
+
cause=exc,
|
|
382
|
+
) from exc
|
|
383
|
+
|
|
384
|
+
# Should never reach here, but just in case
|
|
385
|
+
raise NetworkError(
|
|
386
|
+
f"All {max_attempts} attempts failed for {path}",
|
|
387
|
+
endpoint=path,
|
|
388
|
+
cause=last_error,
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
# ─── x402 helpers ──────────────────────────────────────────
|
|
392
|
+
|
|
393
|
+
def _parse_payment_required(self, resp: httpx.Response) -> dict:
|
|
394
|
+
"""Parse x402 payment requirements from 402 response."""
|
|
395
|
+
# v2: base64-encoded JSON in PAYMENT-REQUIRED header
|
|
396
|
+
header = resp.headers.get("payment-required") or resp.headers.get(
|
|
397
|
+
"PAYMENT-REQUIRED"
|
|
398
|
+
)
|
|
399
|
+
if header:
|
|
400
|
+
return json.loads(base64.b64decode(header))
|
|
401
|
+
|
|
402
|
+
# v1: JSON body
|
|
403
|
+
body = resp.json()
|
|
404
|
+
if "x402Version" in body:
|
|
405
|
+
return body
|
|
406
|
+
|
|
407
|
+
raise ValueError("Cannot parse x402 payment requirements from 402 response")
|
|
408
|
+
|
|
409
|
+
def _create_payment_payload(self, payment_required: dict) -> dict:
|
|
410
|
+
"""Create EIP-3009 TransferWithAuthorization payload."""
|
|
411
|
+
# v2 format: accepts is a list of payment options
|
|
412
|
+
if "accepts" in payment_required:
|
|
413
|
+
req = payment_required["accepts"][0]
|
|
414
|
+
else:
|
|
415
|
+
req = payment_required
|
|
416
|
+
|
|
417
|
+
now = int(time.time())
|
|
418
|
+
nonce = os.urandom(32)
|
|
419
|
+
|
|
420
|
+
authorization = {
|
|
421
|
+
"from": self._account.address,
|
|
422
|
+
"to": req["payTo"],
|
|
423
|
+
"value": str(req.get("maxAmountRequired", req.get("amount", "0"))),
|
|
424
|
+
"validAfter": str(now - 600),
|
|
425
|
+
"validBefore": str(now + req.get("maxTimeoutSeconds", 300)),
|
|
426
|
+
"nonce": "0x" + nonce.hex(),
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
# Get token metadata for EIP-712 domain
|
|
430
|
+
extra = req.get("extra", {})
|
|
431
|
+
token_name = extra.get("name", "USD Coin")
|
|
432
|
+
token_version = extra.get("version", "2")
|
|
433
|
+
chain_id = int(req["network"].split(":")[1])
|
|
434
|
+
token_address = req["asset"]
|
|
435
|
+
|
|
436
|
+
# Sign EIP-712 typed data
|
|
437
|
+
domain = {
|
|
438
|
+
"name": token_name,
|
|
439
|
+
"version": token_version,
|
|
440
|
+
"chainId": chain_id,
|
|
441
|
+
"verifyingContract": token_address,
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
types = {
|
|
445
|
+
"TransferWithAuthorization": [
|
|
446
|
+
{"name": "from", "type": "address"},
|
|
447
|
+
{"name": "to", "type": "address"},
|
|
448
|
+
{"name": "value", "type": "uint256"},
|
|
449
|
+
{"name": "validAfter", "type": "uint256"},
|
|
450
|
+
{"name": "validBefore", "type": "uint256"},
|
|
451
|
+
{"name": "nonce", "type": "bytes32"},
|
|
452
|
+
],
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
message = {
|
|
456
|
+
"from": authorization["from"],
|
|
457
|
+
"to": authorization["to"],
|
|
458
|
+
"value": int(authorization["value"]),
|
|
459
|
+
"validAfter": int(authorization["validAfter"]),
|
|
460
|
+
"validBefore": int(authorization["validBefore"]),
|
|
461
|
+
"nonce": bytes.fromhex(authorization["nonce"][2:]),
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
signed = self._account.sign_typed_data(
|
|
465
|
+
domain_data=domain,
|
|
466
|
+
message_types=types,
|
|
467
|
+
message_data=message,
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
return {
|
|
471
|
+
"x402Version": 2,
|
|
472
|
+
"payload": {
|
|
473
|
+
"authorization": authorization,
|
|
474
|
+
"signature": signed.signature.hex()
|
|
475
|
+
if isinstance(signed.signature, bytes)
|
|
476
|
+
else str(signed.signature),
|
|
477
|
+
},
|
|
478
|
+
}
|
clawfetch/errors.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""ClawFetch error hierarchy — mirrors the TypeScript SDK error classes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ClawFetchError(Exception):
|
|
7
|
+
"""Base error for all ClawFetch SDK errors."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, message: str, status_code: int, endpoint: str) -> None:
|
|
10
|
+
super().__init__(message)
|
|
11
|
+
self.status_code = status_code
|
|
12
|
+
self.endpoint = endpoint
|
|
13
|
+
|
|
14
|
+
def __repr__(self) -> str:
|
|
15
|
+
return f"{self.__class__.__name__}({self.status_code}, {self.endpoint!r})"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class PaymentError(ClawFetchError):
|
|
19
|
+
"""Payment-related errors (402, insufficient USDC, invalid signature)."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, message: str, status_code: int = 402, endpoint: str = "") -> None:
|
|
22
|
+
super().__init__(message, status_code, endpoint)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class NetworkError(ClawFetchError):
|
|
26
|
+
"""Network errors (connection refused, DNS failure, timeout)."""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self, message: str, endpoint: str = "", cause: Exception | None = None
|
|
30
|
+
) -> None:
|
|
31
|
+
super().__init__(message, 0, endpoint)
|
|
32
|
+
self.__cause__ = cause
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class RateLimitError(ClawFetchError):
|
|
36
|
+
"""Rate limit errors (429 Too Many Requests)."""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self, message: str, endpoint: str = "", retry_after_ms: int | None = None
|
|
40
|
+
) -> None:
|
|
41
|
+
super().__init__(message, 429, endpoint)
|
|
42
|
+
self.retry_after_ms = retry_after_ms
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ApiError(ClawFetchError):
|
|
46
|
+
"""API errors (4xx other than 402/429, 5xx)."""
|
|
47
|
+
|
|
48
|
+
def __init__(self, message: str, status_code: int, endpoint: str = "") -> None:
|
|
49
|
+
super().__init__(message, status_code, endpoint)
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: clawfetch
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for ClawFetch — Web Intelligence API for AI Agents (x402-native)
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://api.clawfetch.ai
|
|
7
|
+
Project-URL: Repository, https://github.com/clawfetch/sdk-python
|
|
8
|
+
Keywords: clawfetch,x402,web-intelligence,ai-agents,crypto,usdc,base
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
14
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
15
|
+
Requires-Python: >=3.9
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
Requires-Dist: httpx>=0.25.0
|
|
18
|
+
Requires-Dist: eth-account>=0.13.0
|
|
19
|
+
Requires-Dist: eth-abi>=5.0.0
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: pytest>=9.0; extra == "dev"
|
|
22
|
+
Requires-Dist: pytest-mock>=3.15; extra == "dev"
|
|
23
|
+
Requires-Dist: pytest-asyncio>=0.24.0; extra == "dev"
|
|
24
|
+
|
|
25
|
+
# clawfetch
|
|
26
|
+
|
|
27
|
+
Python SDK for [ClawFetch](https://api.clawfetch.ai) — Web Intelligence API for AI Agents.
|
|
28
|
+
|
|
29
|
+
Pay-per-request via [x402](https://x402.org) (gasless USDC on Base). No API keys, no subscriptions.
|
|
30
|
+
|
|
31
|
+
## Install
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install clawfetch
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Quick Start
|
|
38
|
+
|
|
39
|
+
### Sync
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from clawfetch import ClawFetch
|
|
43
|
+
|
|
44
|
+
with ClawFetch(private_key="0x...") as cf:
|
|
45
|
+
# Fetch any URL as clean markdown ($0.001)
|
|
46
|
+
page = cf.fetch("https://example.com")
|
|
47
|
+
|
|
48
|
+
# Extract structured data ($0.003)
|
|
49
|
+
btc = cf.extract("https://coingecko.com/en/coins/bitcoin")
|
|
50
|
+
print(btc["data"])
|
|
51
|
+
|
|
52
|
+
# Multi-source research ($0.01)
|
|
53
|
+
report = cf.research("latest AI agent frameworks")
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### Async
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
import asyncio
|
|
60
|
+
from clawfetch import AsyncClawFetch
|
|
61
|
+
|
|
62
|
+
async def main():
|
|
63
|
+
async with AsyncClawFetch(private_key="0x...") as cf:
|
|
64
|
+
page = await cf.fetch("https://example.com")
|
|
65
|
+
btc = await cf.extract("https://coingecko.com/en/coins/bitcoin")
|
|
66
|
+
report = await cf.research("latest AI agent frameworks")
|
|
67
|
+
|
|
68
|
+
asyncio.run(main())
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Both clients have identical APIs — all 7 endpoints, full x402 payment flow, retry with exponential backoff, and typed error hierarchy.
|
|
72
|
+
|
|
73
|
+
## API
|
|
74
|
+
|
|
75
|
+
| Method | Price | Description |
|
|
76
|
+
|--------|-------|-------------|
|
|
77
|
+
| `fetch(url)` | $0.001 | URL → clean markdown |
|
|
78
|
+
| `render(url)` | $0.002 | JS-rendered page → markdown |
|
|
79
|
+
| `extract(url)` | $0.003 | Structured data from 17+ sites |
|
|
80
|
+
| `research(topic)` | $0.010 | Multi-source topic research |
|
|
81
|
+
| `domains_check(domains)` | $0.002 | Domain availability check |
|
|
82
|
+
| `domains_suggest(query)` | $0.002 | Domain name suggestions |
|
|
83
|
+
| `extractors()` | $0.001 | List available extractors |
|
|
84
|
+
| `health()` | Free | API status check |
|
|
85
|
+
|
|
86
|
+
## Error Handling
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
from clawfetch import (
|
|
90
|
+
ClawFetch,
|
|
91
|
+
ClawFetchError, # Base class
|
|
92
|
+
PaymentError, # 402 — insufficient USDC
|
|
93
|
+
NetworkError, # Connection/timeout failures
|
|
94
|
+
RateLimitError, # 429 — includes retry_after_ms
|
|
95
|
+
ApiError, # Other 4xx/5xx errors
|
|
96
|
+
)
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Configuration
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
from clawfetch import ClawFetch, RetryOptions
|
|
103
|
+
|
|
104
|
+
cf = ClawFetch(
|
|
105
|
+
private_key="0x...",
|
|
106
|
+
base_url="https://api.clawfetch.ai", # default
|
|
107
|
+
timeout=30.0, # seconds
|
|
108
|
+
retry=RetryOptions(
|
|
109
|
+
max_retries=3,
|
|
110
|
+
initial_delay_ms=500,
|
|
111
|
+
max_delay_ms=10000,
|
|
112
|
+
backoff_multiplier=2.0,
|
|
113
|
+
),
|
|
114
|
+
debug=False,
|
|
115
|
+
)
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## Requirements
|
|
119
|
+
|
|
120
|
+
- Python 3.9+
|
|
121
|
+
- A wallet with USDC on Base (even $1 gives you 1,000+ requests)
|
|
122
|
+
|
|
123
|
+
## License
|
|
124
|
+
|
|
125
|
+
MIT
|
|
126
|
+
|
|
127
|
+
---
|
|
128
|
+
|
|
129
|
+
# Original README below
|
|
130
|
+
|
|
131
|
+
Python SDK for [ClawFetch](https://api.clawfetch.ai) — Web Intelligence API for AI Agents.
|
|
132
|
+
|
|
133
|
+
Pay-per-request via [x402](https://x402.org) (gasless USDC on Base). No API keys, no subscriptions.
|
|
134
|
+
|
|
135
|
+
## Install
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
pip install clawfetch
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
## Quick Start
|
|
142
|
+
|
|
143
|
+
```python
|
|
144
|
+
from clawfetch import ClawFetch
|
|
145
|
+
|
|
146
|
+
cf = ClawFetch(private_key="0x...")
|
|
147
|
+
|
|
148
|
+
# Fetch any URL as clean markdown ($0.001)
|
|
149
|
+
page = cf.fetch("https://example.com")
|
|
150
|
+
|
|
151
|
+
# Extract structured data ($0.003)
|
|
152
|
+
btc = cf.extract("https://coingecko.com/en/coins/bitcoin")
|
|
153
|
+
print(btc["data"]) # {'name': 'Bitcoin', 'price': 98432.12, ...}
|
|
154
|
+
|
|
155
|
+
# JS-rendered pages ($0.002)
|
|
156
|
+
rendered = cf.render("https://app.uniswap.org")
|
|
157
|
+
|
|
158
|
+
# Multi-source research ($0.01)
|
|
159
|
+
report = cf.research("latest AI agent frameworks")
|
|
160
|
+
|
|
161
|
+
# Domain availability ($0.002)
|
|
162
|
+
domains = cf.domains_check(["coolstartup.com", "coolstartup.ai"])
|
|
163
|
+
|
|
164
|
+
# List extractors ($0.001)
|
|
165
|
+
extractors = cf.extractors()
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
## Configuration
|
|
169
|
+
|
|
170
|
+
```python
|
|
171
|
+
from clawfetch import ClawFetch, RetryOptions
|
|
172
|
+
|
|
173
|
+
cf = ClawFetch(
|
|
174
|
+
private_key="0x...",
|
|
175
|
+
base_url="https://api.clawfetch.ai", # default
|
|
176
|
+
timeout=30.0, # request timeout in seconds
|
|
177
|
+
retry=RetryOptions( # or False to disable retries
|
|
178
|
+
max_retries=3,
|
|
179
|
+
initial_delay_ms=500,
|
|
180
|
+
max_delay_ms=10_000,
|
|
181
|
+
backoff_multiplier=2.0,
|
|
182
|
+
),
|
|
183
|
+
debug=False, # enable debug logging
|
|
184
|
+
)
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
## Error Handling
|
|
188
|
+
|
|
189
|
+
All errors extend `ClawFetchError` for easy catching:
|
|
190
|
+
|
|
191
|
+
```python
|
|
192
|
+
from clawfetch import ClawFetch, ClawFetchError, PaymentError, NetworkError, RateLimitError, ApiError
|
|
193
|
+
|
|
194
|
+
cf = ClawFetch(private_key="0x...")
|
|
195
|
+
|
|
196
|
+
try:
|
|
197
|
+
result = cf.fetch("https://example.com")
|
|
198
|
+
except PaymentError as e:
|
|
199
|
+
print(f"Payment failed ({e.status_code}): {e}")
|
|
200
|
+
except RateLimitError as e:
|
|
201
|
+
print(f"Rate limited, retry after {e.retry_after_ms}ms")
|
|
202
|
+
except NetworkError as e:
|
|
203
|
+
print(f"Network error: {e}")
|
|
204
|
+
except ApiError as e:
|
|
205
|
+
print(f"API error {e.status_code}: {e}")
|
|
206
|
+
except ClawFetchError as e:
|
|
207
|
+
print(f"ClawFetch error: {e}")
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
| Error | Status | Retried |
|
|
211
|
+
|-------|--------|---------|
|
|
212
|
+
| `PaymentError` | 402 | No |
|
|
213
|
+
| `RateLimitError` | 429 | Yes (with Retry-After) |
|
|
214
|
+
| `ApiError` | 400, 401, 404 | No |
|
|
215
|
+
| `ApiError` | 500, 502, 503, 504 | Yes |
|
|
216
|
+
| `NetworkError` | Connection/timeout | Yes |
|
|
217
|
+
|
|
218
|
+
## Retry Behavior
|
|
219
|
+
|
|
220
|
+
By default, retries are enabled with exponential backoff + jitter:
|
|
221
|
+
|
|
222
|
+
| Attempt | Delay |
|
|
223
|
+
|---------|-------|
|
|
224
|
+
| 1st retry | ~500ms |
|
|
225
|
+
| 2nd retry | ~1,000ms |
|
|
226
|
+
| 3rd retry | ~2,000ms |
|
|
227
|
+
|
|
228
|
+
Respects `Retry-After` headers on 429 responses. Non-retryable errors (400, 401, 402, 404) fail immediately.
|
|
229
|
+
|
|
230
|
+
Disable retries:
|
|
231
|
+
|
|
232
|
+
```python
|
|
233
|
+
cf = ClawFetch(private_key="0x...", retry=False)
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
## How It Works
|
|
237
|
+
|
|
238
|
+
1. SDK makes a request to ClawFetch
|
|
239
|
+
2. Server returns `402 Payment Required` with USDC amount
|
|
240
|
+
3. SDK auto-signs an EIP-3009 gasless USDC transfer on Base
|
|
241
|
+
4. Request is retried with payment header
|
|
242
|
+
5. You get structured data back
|
|
243
|
+
|
|
244
|
+
No gas fees. No API keys. Just USDC on Base.
|
|
245
|
+
|
|
246
|
+
## Context Manager
|
|
247
|
+
|
|
248
|
+
```python
|
|
249
|
+
with ClawFetch(private_key="0x...") as cf:
|
|
250
|
+
data = cf.extract("https://coingecko.com/en/coins/bitcoin")
|
|
251
|
+
# Client is automatically closed
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
## Requirements
|
|
255
|
+
|
|
256
|
+
- Python 3.9+
|
|
257
|
+
- A wallet with USDC on Base (even $1 gives you 1,000+ requests)
|
|
258
|
+
|
|
259
|
+
## Development
|
|
260
|
+
|
|
261
|
+
```bash
|
|
262
|
+
pip install -e ".[dev]"
|
|
263
|
+
pytest tests/ -v
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
## License
|
|
267
|
+
|
|
268
|
+
MIT
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
clawfetch/__init__.py,sha256=6AKQnl_n7a0lJyA6l5VTNCIMAVaGuu34xXFFhVGfSks,440
|
|
2
|
+
clawfetch/async_client.py,sha256=3Fycu93Ih8EnimnRWv1cd80LiGNSsjf4HUeU3E9ipIU,16121
|
|
3
|
+
clawfetch/client.py,sha256=lYlRdjEIM5qljnzPJx3xcB-6rjmRoOX0PgN5nzfiB3w,16726
|
|
4
|
+
clawfetch/errors.py,sha256=z_lEkjD9ztWSvVe65tfn4s0YeZIx-KTTtPP26nNxTyU,1594
|
|
5
|
+
clawfetch-0.1.0.dist-info/METADATA,sha256=VPXar2PTOz61Gp9fOyRaMXldJEywAfINXyPXNhtwo90,6887
|
|
6
|
+
clawfetch-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
7
|
+
clawfetch-0.1.0.dist-info/top_level.txt,sha256=PqD6ZdHlgbhi5CypO0tAg5fLHbi3UY0U9xCtgwFFAJE,10
|
|
8
|
+
clawfetch-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
clawfetch
|