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 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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ clawfetch