webclaw 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.
webclaw/__init__.py ADDED
@@ -0,0 +1,72 @@
1
+ """Webclaw Python SDK -- web extraction API client."""
2
+
3
+ from .async_client import AsyncCrawlJobHandle, AsyncWebclaw
4
+ from .client import CrawlJobHandle, Webclaw
5
+ from .errors import (
6
+ AuthenticationError,
7
+ NotFoundError,
8
+ RateLimitError,
9
+ TimeoutError,
10
+ WebclawError,
11
+ )
12
+ from .types import (
13
+ AgentScrapeResponse,
14
+ BatchResponse,
15
+ BatchResult,
16
+ BrandResponse,
17
+ CacheInfo,
18
+ CrawlJob,
19
+ CrawlPage,
20
+ CrawlStatus,
21
+ DiffResponse,
22
+ ExtractResponse,
23
+ MapResponse,
24
+ ResearchFinding,
25
+ ResearchSource,
26
+ ResearchStartResponse,
27
+ ResearchStatusResponse,
28
+ ScrapeResponse,
29
+ SearchResponse,
30
+ SearchResult,
31
+ SummarizeResponse,
32
+ WatchCheckResponse,
33
+ WatchEntry,
34
+ WatchListResponse,
35
+ )
36
+
37
+ __all__ = [
38
+ # clients
39
+ "Webclaw",
40
+ "AsyncWebclaw",
41
+ "CrawlJobHandle",
42
+ "AsyncCrawlJobHandle",
43
+ # types
44
+ "AgentScrapeResponse",
45
+ "BatchResponse",
46
+ "BatchResult",
47
+ "BrandResponse",
48
+ "CacheInfo",
49
+ "CrawlJob",
50
+ "CrawlPage",
51
+ "CrawlStatus",
52
+ "DiffResponse",
53
+ "ExtractResponse",
54
+ "MapResponse",
55
+ "ResearchFinding",
56
+ "ResearchSource",
57
+ "ResearchStartResponse",
58
+ "ResearchStatusResponse",
59
+ "ScrapeResponse",
60
+ "SearchResponse",
61
+ "SearchResult",
62
+ "SummarizeResponse",
63
+ "WatchCheckResponse",
64
+ "WatchEntry",
65
+ "WatchListResponse",
66
+ # errors
67
+ "WebclawError",
68
+ "AuthenticationError",
69
+ "RateLimitError",
70
+ "NotFoundError",
71
+ "TimeoutError",
72
+ ]
webclaw/_endpoints.py ADDED
@@ -0,0 +1,264 @@
1
+ """Shared request body builders and response parsers.
2
+
3
+ Both sync and async clients delegate to these functions so endpoint
4
+ logic lives in exactly one place. Only the transport layer (sync
5
+ httpx vs async httpx) differs between the two clients.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any, Sequence
11
+
12
+ from .types import (
13
+ BatchResponse,
14
+ BatchResult,
15
+ BrandResponse,
16
+ CacheInfo,
17
+ CrawlJob,
18
+ CrawlPage,
19
+ CrawlStatus,
20
+ ExtractResponse,
21
+ MapResponse,
22
+ ResearchStatusResponse,
23
+ ScrapeResponse,
24
+ SummarizeResponse,
25
+ WatchCheckResponse,
26
+ WatchEntry,
27
+ WatchListResponse,
28
+ )
29
+
30
+ DEFAULT_BASE_URL = "https://api.webclaw.io"
31
+ DEFAULT_TIMEOUT = 30.0
32
+
33
+ # Terminal states shared by crawl and research polling.
34
+ TERMINAL_STATES = frozenset({"completed", "failed"})
35
+
36
+
37
+ # ---------------------------------------------------------------------------
38
+ # Request body builders
39
+ # ---------------------------------------------------------------------------
40
+
41
+ def build_scrape_body(
42
+ url: str,
43
+ *,
44
+ formats: Sequence[str] | None = None,
45
+ include_selectors: list[str] | None = None,
46
+ exclude_selectors: list[str] | None = None,
47
+ only_main_content: bool = False,
48
+ no_cache: bool = False,
49
+ ) -> dict[str, Any]:
50
+ body: dict[str, Any] = {"url": url}
51
+ if formats is not None:
52
+ body["formats"] = list(formats)
53
+ if include_selectors:
54
+ body["include_selectors"] = include_selectors
55
+ if exclude_selectors:
56
+ body["exclude_selectors"] = exclude_selectors
57
+ if only_main_content:
58
+ body["only_main_content"] = True
59
+ if no_cache:
60
+ body["no_cache"] = True
61
+ return body
62
+
63
+
64
+ def build_crawl_body(
65
+ url: str,
66
+ *,
67
+ max_depth: int = 2,
68
+ max_pages: int = 50,
69
+ use_sitemap: bool = False,
70
+ ) -> dict[str, Any]:
71
+ return {
72
+ "url": url,
73
+ "max_depth": max_depth,
74
+ "max_pages": max_pages,
75
+ "use_sitemap": use_sitemap,
76
+ }
77
+
78
+
79
+ def build_batch_body(
80
+ urls: list[str],
81
+ *,
82
+ formats: Sequence[str] | None = None,
83
+ concurrency: int = 5,
84
+ ) -> dict[str, Any]:
85
+ body: dict[str, Any] = {"urls": urls, "concurrency": concurrency}
86
+ if formats is not None:
87
+ body["formats"] = list(formats)
88
+ return body
89
+
90
+
91
+ def build_extract_body(
92
+ url: str,
93
+ *,
94
+ schema: dict[str, Any] | None = None,
95
+ prompt: str | None = None,
96
+ ) -> dict[str, Any]:
97
+ body: dict[str, Any] = {"url": url}
98
+ if schema is not None:
99
+ body["schema"] = schema
100
+ if prompt is not None:
101
+ body["prompt"] = prompt
102
+ return body
103
+
104
+
105
+ def build_summarize_body(
106
+ url: str,
107
+ *,
108
+ max_sentences: int | None = None,
109
+ ) -> dict[str, Any]:
110
+ body: dict[str, Any] = {"url": url}
111
+ if max_sentences is not None:
112
+ body["max_sentences"] = max_sentences
113
+ return body
114
+
115
+
116
+ def build_search_body(
117
+ query: str,
118
+ *,
119
+ num_results: int | None = None,
120
+ topic: str | None = None,
121
+ ) -> dict[str, Any]:
122
+ body: dict[str, Any] = {"query": query}
123
+ if num_results is not None:
124
+ body["num_results"] = num_results
125
+ if topic is not None:
126
+ body["topic"] = topic
127
+ return body
128
+
129
+
130
+ def build_research_body(
131
+ query: str,
132
+ *,
133
+ deep: bool = False,
134
+ max_sources: int | None = None,
135
+ max_iterations: int | None = None,
136
+ topic: str | None = None,
137
+ ) -> dict[str, Any]:
138
+ body: dict[str, Any] = {"query": query, "deep": deep}
139
+ if max_sources is not None:
140
+ body["max_sources"] = max_sources
141
+ if max_iterations is not None:
142
+ body["max_iterations"] = max_iterations
143
+ if topic is not None:
144
+ body["topic"] = topic
145
+ return body
146
+
147
+
148
+ def build_watch_create_body(
149
+ url: str,
150
+ *,
151
+ name: str | None = None,
152
+ interval_minutes: int = 1440,
153
+ webhook_url: str | None = None,
154
+ ) -> dict[str, Any]:
155
+ body: dict[str, Any] = {"url": url, "interval_minutes": interval_minutes}
156
+ if name is not None:
157
+ body["name"] = name
158
+ if webhook_url is not None:
159
+ body["webhook_url"] = webhook_url
160
+ return body
161
+
162
+
163
+ # ---------------------------------------------------------------------------
164
+ # Response parsers
165
+ # ---------------------------------------------------------------------------
166
+
167
+ def parse_scrape(data: dict[str, Any]) -> ScrapeResponse:
168
+ cache = None
169
+ if data.get("cache"):
170
+ cache = CacheInfo(status=data["cache"]["status"])
171
+ return ScrapeResponse(
172
+ url=data["url"],
173
+ metadata=data.get("metadata", {}),
174
+ markdown=data.get("markdown"),
175
+ text=data.get("text"),
176
+ llm=data.get("llm"),
177
+ json_data=data.get("json"),
178
+ cache=cache,
179
+ warning=data.get("warning"),
180
+ )
181
+
182
+
183
+ def parse_crawl_job(data: dict[str, Any]) -> CrawlJob:
184
+ return CrawlJob(id=data["id"], status=data["status"])
185
+
186
+
187
+ def parse_crawl_status(data: dict[str, Any]) -> CrawlStatus:
188
+ pages = [
189
+ CrawlPage(
190
+ url=p["url"],
191
+ markdown=p.get("markdown"),
192
+ metadata=p.get("metadata", {}),
193
+ error=p.get("error"),
194
+ )
195
+ for p in data.get("pages", [])
196
+ ]
197
+ return CrawlStatus(
198
+ id=data["id"],
199
+ status=data["status"],
200
+ pages=pages,
201
+ total=data.get("total", 0),
202
+ completed=data.get("completed", 0),
203
+ errors=data.get("errors", 0),
204
+ )
205
+
206
+
207
+ def parse_map(data: dict[str, Any]) -> MapResponse:
208
+ return MapResponse(urls=data.get("urls", []), count=data.get("count", 0))
209
+
210
+
211
+ def parse_batch(data: dict[str, Any]) -> BatchResponse:
212
+ results = [
213
+ BatchResult(
214
+ url=r["url"],
215
+ markdown=r.get("markdown"),
216
+ metadata=r.get("metadata", {}),
217
+ error=r.get("error"),
218
+ )
219
+ for r in data.get("results", [])
220
+ ]
221
+ return BatchResponse(results=results)
222
+
223
+
224
+ def parse_extract(data: dict[str, Any]) -> ExtractResponse:
225
+ return ExtractResponse(data=data.get("data"))
226
+
227
+
228
+ def parse_summarize(data: dict[str, Any]) -> SummarizeResponse:
229
+ return SummarizeResponse(summary=data.get("summary", ""))
230
+
231
+
232
+ def parse_brand(data: dict[str, Any]) -> BrandResponse:
233
+ return BrandResponse(data=data)
234
+
235
+
236
+ def parse_research(data: dict[str, Any]) -> ResearchStatusResponse:
237
+ return ResearchStatusResponse(
238
+ id=data.get("id", ""),
239
+ status=data.get("status", ""),
240
+ query=data.get("query", ""),
241
+ report=data.get("report", ""),
242
+ sources=data.get("sources", []),
243
+ findings=data.get("findings", []),
244
+ iterations=data.get("iterations", 0),
245
+ elapsed_ms=data.get("elapsed_ms", 0),
246
+ )
247
+
248
+
249
+ def parse_watch_entry(data: dict[str, Any]) -> WatchEntry:
250
+ return WatchEntry.from_dict(data)
251
+
252
+
253
+ def parse_watch_list(data: dict[str, Any]) -> WatchListResponse:
254
+ watches = [WatchEntry.from_dict(w) for w in data.get("watches", [])]
255
+ return WatchListResponse(watches=watches, total=data.get("total", len(watches)))
256
+
257
+
258
+ def parse_watch_check(data: dict[str, Any]) -> WatchCheckResponse:
259
+ return WatchCheckResponse(
260
+ id=data.get("id", ""),
261
+ has_changed=data.get("has_changed", False),
262
+ diff=data.get("diff"),
263
+ checked_at=data.get("checked_at", ""),
264
+ )
@@ -0,0 +1,210 @@
1
+ """Asynchronous Webclaw client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import time
7
+ from typing import Any, Sequence
8
+
9
+ import httpx
10
+
11
+ from . import _endpoints as ep
12
+ from .client import _raise_for_status
13
+ from .errors import TimeoutError, WebclawError
14
+ from .types import (
15
+ BatchResponse, BrandResponse, CrawlStatus, ExtractResponse, MapResponse,
16
+ ResearchStatusResponse, ScrapeResponse, SummarizeResponse,
17
+ WatchCheckResponse, WatchEntry, WatchListResponse,
18
+ )
19
+
20
+
21
+ class AsyncWebclaw:
22
+ """Async client for the Webclaw web extraction API."""
23
+
24
+ def __init__(
25
+ self,
26
+ api_key: str,
27
+ *,
28
+ base_url: str = ep.DEFAULT_BASE_URL,
29
+ timeout: float = ep.DEFAULT_TIMEOUT,
30
+ ) -> None:
31
+ self.api_key = api_key
32
+ self.base_url = base_url.rstrip("/")
33
+ self._client = httpx.AsyncClient(
34
+ base_url=self.base_url,
35
+ headers={"Authorization": f"Bearer {api_key}"},
36
+ timeout=timeout,
37
+ )
38
+
39
+ # -- lifecycle ------------------------------------------------------------
40
+
41
+ async def close(self) -> None:
42
+ await self._client.aclose()
43
+
44
+ async def __aenter__(self) -> AsyncWebclaw:
45
+ return self
46
+
47
+ async def __aexit__(self, *_: Any) -> None:
48
+ await self.close()
49
+
50
+ # -- internal -------------------------------------------------------------
51
+
52
+ async def _request(self, method: str, path: str, **kwargs: Any) -> Any:
53
+ response = await self._client.request(method, path, **kwargs)
54
+ _raise_for_status(response)
55
+ return response.json()
56
+
57
+ # -- endpoints ------------------------------------------------------------
58
+
59
+ async def scrape(
60
+ self,
61
+ url: str,
62
+ *,
63
+ formats: Sequence[str] | None = None,
64
+ include_selectors: list[str] | None = None,
65
+ exclude_selectors: list[str] | None = None,
66
+ only_main_content: bool = False,
67
+ no_cache: bool = False,
68
+ ) -> ScrapeResponse:
69
+ """Scrape a URL and extract content."""
70
+ body = ep.build_scrape_body(
71
+ url, formats=formats, include_selectors=include_selectors,
72
+ exclude_selectors=exclude_selectors, only_main_content=only_main_content,
73
+ no_cache=no_cache,
74
+ )
75
+ return ep.parse_scrape(await self._request("POST", "/v1/scrape", json=body))
76
+
77
+ async def crawl(
78
+ self, url: str, *, max_depth: int = 2, max_pages: int = 50, use_sitemap: bool = False,
79
+ ) -> AsyncCrawlJobHandle:
80
+ """Start a crawl job and return a handle for polling."""
81
+ body = ep.build_crawl_body(url, max_depth=max_depth, max_pages=max_pages, use_sitemap=use_sitemap)
82
+ job = ep.parse_crawl_job(await self._request("POST", "/v1/crawl", json=body))
83
+ return AsyncCrawlJobHandle(client=self, job_id=job.id, status=job.status)
84
+
85
+ async def get_crawl_status(self, job_id: str) -> CrawlStatus:
86
+ """Get current status of a crawl job."""
87
+ return ep.parse_crawl_status(await self._request("GET", f"/v1/crawl/{job_id}"))
88
+
89
+ async def map(self, url: str) -> MapResponse:
90
+ """Discover URLs from a site's sitemap."""
91
+ return ep.parse_map(await self._request("POST", "/v1/map", json={"url": url}))
92
+
93
+ async def batch(
94
+ self, urls: list[str], *, formats: Sequence[str] | None = None, concurrency: int = 5,
95
+ ) -> BatchResponse:
96
+ """Scrape multiple URLs in parallel."""
97
+ body = ep.build_batch_body(urls, formats=formats, concurrency=concurrency)
98
+ return ep.parse_batch(await self._request("POST", "/v1/batch", json=body))
99
+
100
+ async def extract(self, url: str, *, schema: dict[str, Any] | None = None, prompt: str | None = None) -> ExtractResponse:
101
+ """LLM-powered structured data extraction."""
102
+ body = ep.build_extract_body(url, schema=schema, prompt=prompt)
103
+ return ep.parse_extract(await self._request("POST", "/v1/extract", json=body))
104
+
105
+ async def summarize(self, url: str, *, max_sentences: int | None = None) -> SummarizeResponse:
106
+ """Summarize page content."""
107
+ return ep.parse_summarize(await self._request("POST", "/v1/summarize", json=ep.build_summarize_body(url, max_sentences=max_sentences)))
108
+
109
+ async def brand(self, url: str) -> BrandResponse:
110
+ """Extract brand identity from a URL."""
111
+ return ep.parse_brand(await self._request("POST", "/v1/brand", json={"url": url}))
112
+
113
+ async def search(self, query: str, *, num_results: int | None = None, topic: str | None = None) -> dict:
114
+ """Run a web search query via the Serper-backed search endpoint."""
115
+ return await self._request("POST", "/v1/search", json=ep.build_search_body(query, num_results=num_results, topic=topic))
116
+
117
+ async def diff(self, url: str, **kwargs: Any) -> dict:
118
+ """Detect content changes at a URL since the last check."""
119
+ return await self._request("POST", "/v1/diff", json={"url": url, **kwargs})
120
+
121
+ async def agent_scrape(self, url: str, goal: str, **kwargs: Any) -> dict:
122
+ """AI-guided scraping that navigates a page to achieve a goal."""
123
+ return await self._request("POST", "/v1/agent-scrape", json={"url": url, "goal": goal, **kwargs})
124
+
125
+ async def research(
126
+ self, query: str, *, deep: bool = False,
127
+ max_sources: int | None = None, max_iterations: int | None = None, topic: str | None = None,
128
+ ) -> ResearchStatusResponse:
129
+ """Start a research job and await until it completes.
130
+
131
+ Normal queries time out after 600s, deep research after 1200s.
132
+ """
133
+ body = ep.build_research_body(query, deep=deep, max_sources=max_sources, max_iterations=max_iterations, topic=topic)
134
+ job_id = (await self._request("POST", "/v1/research", json=body))["id"]
135
+ return await _async_poll_until_done(
136
+ fetcher=lambda: self._request("GET", f"/v1/research/{job_id}"),
137
+ parser=ep.parse_research,
138
+ label=f"Research {job_id}",
139
+ interval=2.0,
140
+ timeout=1200.0 if deep else 600.0,
141
+ )
142
+
143
+ async def get_research_status(self, job_id: str) -> ResearchStatusResponse:
144
+ """Get status/results of a research job without polling."""
145
+ return ep.parse_research(await self._request("GET", f"/v1/research/{job_id}"))
146
+
147
+ # -- watch endpoints ------------------------------------------------------
148
+
149
+ async def watch_create(
150
+ self, url: str, *, name: str | None = None, interval_minutes: int = 1440, webhook_url: str | None = None,
151
+ ) -> WatchEntry:
152
+ """Create a new watch monitor for a URL."""
153
+ body = ep.build_watch_create_body(url, name=name, interval_minutes=interval_minutes, webhook_url=webhook_url)
154
+ return ep.parse_watch_entry(await self._request("POST", "/v1/watch", json=body))
155
+
156
+ async def watch_list(self, *, limit: int = 50, offset: int = 0) -> WatchListResponse:
157
+ """List all watch monitors."""
158
+ return ep.parse_watch_list(await self._request("GET", "/v1/watch", params={"limit": limit, "offset": offset}))
159
+
160
+ async def watch_get(self, watch_id: str) -> WatchEntry:
161
+ """Get a single watch monitor by ID."""
162
+ return ep.parse_watch_entry(await self._request("GET", f"/v1/watch/{watch_id}"))
163
+
164
+ async def watch_delete(self, watch_id: str) -> None:
165
+ """Delete a watch monitor."""
166
+ await self._request("DELETE", f"/v1/watch/{watch_id}")
167
+
168
+ async def watch_check(self, watch_id: str) -> WatchCheckResponse:
169
+ """Trigger an immediate check for a watch monitor."""
170
+ return ep.parse_watch_check(await self._request("POST", f"/v1/watch/{watch_id}/check"))
171
+
172
+
173
+ class AsyncCrawlJobHandle:
174
+ """Wraps a running crawl job with async polling helpers."""
175
+
176
+ def __init__(self, client: AsyncWebclaw, job_id: str, status: str) -> None:
177
+ self.client = client
178
+ self.id = job_id
179
+ self.status = status
180
+
181
+ async def get_status(self) -> CrawlStatus:
182
+ return await self.client.get_crawl_status(self.id)
183
+
184
+ async def wait(self, *, interval: float = 2.0, timeout: float = 300.0) -> CrawlStatus:
185
+ """Poll until the crawl completes or fails."""
186
+ return await _async_poll_until_done(
187
+ fetcher=self.get_status, parser=lambda s: s,
188
+ label=f"Crawl {self.id}", interval=interval, timeout=timeout,
189
+ status_attr="status",
190
+ )
191
+
192
+
193
+ # -- helpers ------------------------------------------------------------------
194
+
195
+ async def _async_poll_until_done(
196
+ *, fetcher, parser, label: str, interval: float, timeout: float, status_attr: str = "status",
197
+ ) -> Any:
198
+ """Async version of poll-until-done. See client._poll_until_done."""
199
+ deadline = time.monotonic() + timeout
200
+ while True:
201
+ result = await fetcher()
202
+ status = result.get("status", "") if isinstance(result, dict) else getattr(result, status_attr)
203
+ if status == "completed":
204
+ return parser(result)
205
+ if status == "failed":
206
+ error = result.get("error", f"{label} failed") if isinstance(result, dict) else f"{label} failed"
207
+ raise WebclawError(error, status_code=None)
208
+ if time.monotonic() >= deadline:
209
+ raise TimeoutError(f"{label} did not complete within {timeout}s")
210
+ await asyncio.sleep(interval)