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 +72 -0
- webclaw/_endpoints.py +264 -0
- webclaw/async_client.py +210 -0
- webclaw/client.py +229 -0
- webclaw/errors.py +39 -0
- webclaw/py.typed +0 -0
- webclaw/types.py +210 -0
- webclaw-0.1.0.dist-info/METADATA +435 -0
- webclaw-0.1.0.dist-info/RECORD +11 -0
- webclaw-0.1.0.dist-info/WHEEL +4 -0
- webclaw-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|
+
)
|
webclaw/async_client.py
ADDED
|
@@ -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)
|