xdive 1.5.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.
- xdive/__init__.py +31 -0
- xdive/client.py +512 -0
- xdive/langchain.py +149 -0
- xdive/llama_index.py +38 -0
- xdive/py.typed +0 -0
- xdive-1.5.0.dist-info/METADATA +185 -0
- xdive-1.5.0.dist-info/RECORD +9 -0
- xdive-1.5.0.dist-info/WHEEL +4 -0
- xdive-1.5.0.dist-info/licenses/LICENSE +21 -0
xdive/__init__.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""XDive SDK: scrape, crawl, map, search and research the web.
|
|
2
|
+
|
|
3
|
+
from xdive import XDive
|
|
4
|
+
|
|
5
|
+
xdive = XDive(api_key="ck_live_...")
|
|
6
|
+
page = xdive.scrape("https://example.com")
|
|
7
|
+
answer = xdive.research("What changed in the RBI's October 2026 policy?")
|
|
8
|
+
print(answer["answer"], answer["citations"])
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from .client import (
|
|
12
|
+
AsyncXDive,
|
|
13
|
+
XDive,
|
|
14
|
+
XDiveError,
|
|
15
|
+
CrawlTimeout,
|
|
16
|
+
QuotaExceeded,
|
|
17
|
+
RateLimited,
|
|
18
|
+
page_markdown,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"AsyncXDive",
|
|
23
|
+
"XDive",
|
|
24
|
+
"XDiveError",
|
|
25
|
+
"CrawlTimeout",
|
|
26
|
+
"QuotaExceeded",
|
|
27
|
+
"RateLimited",
|
|
28
|
+
"page_markdown",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
__version__ = "1.5.0"
|
xdive/client.py
ADDED
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
"""HTTP client for the XDive API (synchronous and asynchronous).
|
|
2
|
+
|
|
3
|
+
Design notes:
|
|
4
|
+
- One API key identifies one organization; the client discovers the
|
|
5
|
+
organization id through ``GET /whoami`` so users configure only the key.
|
|
6
|
+
- ``429`` (organization concurrency/rate limit) and ``503`` with the code
|
|
7
|
+
``SERVER_BUSY`` (node at capacity) are the API's designed answers to
|
|
8
|
+
overload and carry ``Retry-After``; the client waits as told and retries a
|
|
9
|
+
bounded number of times before raising :class:`RateLimited`. Any other
|
|
10
|
+
``503`` (a feature that is not configured, a queue that is down) is an
|
|
11
|
+
ordinary :class:`XDiveError` and is not retried.
|
|
12
|
+
- Asynchronous jobs (crawl, batch scrape) are polled with ``If-None-Match``
|
|
13
|
+
so unchanged status answers are bodiless ``304``\\s, and at the interval the
|
|
14
|
+
server suggests in ``Retry-After``.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import asyncio
|
|
20
|
+
import os
|
|
21
|
+
import time
|
|
22
|
+
import uuid
|
|
23
|
+
from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Union
|
|
24
|
+
|
|
25
|
+
import httpx
|
|
26
|
+
|
|
27
|
+
DEFAULT_BASE_URL = "https://crawler.mehdisadeghian.com/api"
|
|
28
|
+
JSON = Dict[str, Any]
|
|
29
|
+
_TERMINAL = {"success", "error", "cancelled"}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class XDiveError(Exception):
|
|
33
|
+
"""An API error. ``status`` is the HTTP status; ``code`` the API's
|
|
34
|
+
machine-readable code when it sent one."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, status: int, message: str, code: Optional[str] = None, retryable: bool = False):
|
|
37
|
+
super().__init__(f"{status}: {message}" if code is None else f"{status} {code}: {message}")
|
|
38
|
+
self.status = status
|
|
39
|
+
self.message = message
|
|
40
|
+
self.code = code
|
|
41
|
+
#: The server's ``retryable`` flag: the same request may succeed later.
|
|
42
|
+
self.retryable = retryable
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class RateLimited(XDiveError):
|
|
46
|
+
"""Still throttled (``429``, or ``503`` with code ``SERVER_BUSY``) after
|
|
47
|
+
the configured retries."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, status: int, message: str, retry_after: float, code: Optional[str] = None):
|
|
50
|
+
super().__init__(status, message, code, retryable=True)
|
|
51
|
+
self.retry_after = retry_after
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class QuotaExceeded(XDiveError):
|
|
55
|
+
"""The organization's monthly quota is used up (HTTP 402)."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class CrawlTimeout(XDiveError):
|
|
59
|
+
"""A crawl did not finish within ``timeout`` seconds."""
|
|
60
|
+
|
|
61
|
+
def __init__(self, crawl_id: str, timeout: float):
|
|
62
|
+
super().__init__(408, f"crawl {crawl_id} did not finish within {timeout:.0f}s")
|
|
63
|
+
self.crawl_id = crawl_id
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _error_from(response: httpx.Response) -> XDiveError:
|
|
67
|
+
code = None
|
|
68
|
+
retryable = False
|
|
69
|
+
message = response.text.strip()[:500] or response.reason_phrase
|
|
70
|
+
try:
|
|
71
|
+
payload = response.json()
|
|
72
|
+
if isinstance(payload, dict):
|
|
73
|
+
message = payload.get("error") or payload.get("message") or message
|
|
74
|
+
code = payload.get("code")
|
|
75
|
+
retryable = payload.get("retryable") is True
|
|
76
|
+
except ValueError:
|
|
77
|
+
pass
|
|
78
|
+
if response.status_code == 402:
|
|
79
|
+
return QuotaExceeded(response.status_code, message, code, retryable)
|
|
80
|
+
return XDiveError(response.status_code, message, code, retryable)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _is_overload(response: httpx.Response) -> bool:
|
|
84
|
+
"""Whether a response is the API's "slow down" answer, which is retried
|
|
85
|
+
after ``Retry-After``: any ``429``, and a ``503`` whose JSON body carries
|
|
86
|
+
the code ``SERVER_BUSY`` (the node is at capacity). Other ``503``s, such
|
|
87
|
+
as a feature that is not configured, are ordinary errors."""
|
|
88
|
+
if response.status_code == 429:
|
|
89
|
+
return True
|
|
90
|
+
if response.status_code not in (409, 503):
|
|
91
|
+
return False
|
|
92
|
+
try:
|
|
93
|
+
payload = response.json()
|
|
94
|
+
except ValueError:
|
|
95
|
+
return False
|
|
96
|
+
if not isinstance(payload, dict):
|
|
97
|
+
return False
|
|
98
|
+
# 409 IDEMPOTENCY_KEY_IN_PROGRESS: the first attempt of this POST is still
|
|
99
|
+
# running; the retry receives its stored response once it finishes.
|
|
100
|
+
return payload.get("code") == ("SERVER_BUSY" if response.status_code == 503 else "IDEMPOTENCY_KEY_IN_PROGRESS")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _attempt_headers(method: str, headers: Optional[Dict[str, str]]) -> Optional[Dict[str, str]]:
|
|
104
|
+
"""Headers for every attempt of one request. A POST gets one
|
|
105
|
+
Idempotency-Key for all its attempts, so a retry after a timeout or a
|
|
106
|
+
dropped connection gets the first response back instead of running (and
|
|
107
|
+
billing) the work again."""
|
|
108
|
+
if method != "POST":
|
|
109
|
+
return headers
|
|
110
|
+
return {"Idempotency-Key": str(uuid.uuid4()), **(headers or {})}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _backoff(attempt: int) -> float:
|
|
114
|
+
return min(2.0 ** attempt, 8.0)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def page_markdown(page: Mapping[str, Any]) -> str:
|
|
118
|
+
"""The Markdown of a page, whichever field carries it:
|
|
119
|
+
``artifacts.markdown.content`` (a scrape, or a crawl with ``formats``) or
|
|
120
|
+
``markdown`` (a crawl without ``formats``). Empty when there is none."""
|
|
121
|
+
artifacts = page.get("artifacts") or {}
|
|
122
|
+
return (artifacts.get("markdown") or {}).get("content") or page.get("markdown") or ""
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _retry_after(response: httpx.Response, default: float) -> float:
|
|
126
|
+
try:
|
|
127
|
+
return max(0.0, float(response.headers.get("Retry-After", default)))
|
|
128
|
+
except ValueError:
|
|
129
|
+
return default
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _drop_none(payload: JSON) -> JSON:
|
|
133
|
+
return {key: value for key, value in payload.items() if value is not None}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class _Base:
|
|
137
|
+
def __init__(
|
|
138
|
+
self,
|
|
139
|
+
api_key: Optional[str] = None,
|
|
140
|
+
*,
|
|
141
|
+
base_url: Optional[str] = None,
|
|
142
|
+
org_id: Optional[str] = None,
|
|
143
|
+
timeout: float = 120.0,
|
|
144
|
+
max_retries: int = 3,
|
|
145
|
+
):
|
|
146
|
+
self.api_key = api_key or os.environ.get("XDIVE_API_KEY") or os.environ.get("CRAWLER_API_KEY") or ""
|
|
147
|
+
if not self.api_key:
|
|
148
|
+
raise ValueError("an API key is required (argument api_key or XDIVE_API_KEY)")
|
|
149
|
+
self.base_url = (base_url or os.environ.get("XDIVE_BASE_URL") or os.environ.get("CRAWLER_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
|
|
150
|
+
self._org_id = org_id
|
|
151
|
+
self.timeout = timeout
|
|
152
|
+
self.max_retries = max_retries
|
|
153
|
+
self._headers = {
|
|
154
|
+
"X-API-Key": self.api_key,
|
|
155
|
+
"Accept": "application/json",
|
|
156
|
+
"User-Agent": "xdive-python/1.5.0",
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
# --- payload builders (shared by sync and async) -----------------------
|
|
160
|
+
|
|
161
|
+
@staticmethod
|
|
162
|
+
def _scrape_payload(url: str, formats: Optional[Sequence[Union[str, JSON]]], options: JSON) -> JSON:
|
|
163
|
+
payload: JSON = {"url": url}
|
|
164
|
+
if formats:
|
|
165
|
+
payload["formats"] = [f if isinstance(f, dict) else {"type": f} for f in formats]
|
|
166
|
+
payload.update(_drop_none(options))
|
|
167
|
+
return payload
|
|
168
|
+
|
|
169
|
+
@staticmethod
|
|
170
|
+
def _crawl_payload(url: str, options: JSON) -> JSON:
|
|
171
|
+
payload: JSON = {"url": url}
|
|
172
|
+
formats = options.pop("formats", None)
|
|
173
|
+
if formats:
|
|
174
|
+
payload["formats"] = [f if isinstance(f, dict) else {"type": f} for f in formats]
|
|
175
|
+
payload.update(_drop_none(options))
|
|
176
|
+
return payload
|
|
177
|
+
|
|
178
|
+
@staticmethod
|
|
179
|
+
def _batch_payload(urls: Iterable[str], options: JSON) -> JSON:
|
|
180
|
+
payload: JSON = {"urls": list(urls)}
|
|
181
|
+
formats = options.pop("formats", None)
|
|
182
|
+
if formats:
|
|
183
|
+
payload["formats"] = [f if isinstance(f, dict) else {"type": f} for f in formats]
|
|
184
|
+
payload.update(_drop_none(options))
|
|
185
|
+
return payload
|
|
186
|
+
|
|
187
|
+
@staticmethod
|
|
188
|
+
def _llms_txt_payload(url: str, max_urls: Optional[int], full_text: bool) -> JSON:
|
|
189
|
+
return _drop_none({"url": url, "max_urls": max_urls, "full_text": full_text or None})
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class XDive(_Base):
|
|
193
|
+
"""Synchronous client.
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
api_key: an organization API key (``ck_live_…``); defaults to
|
|
197
|
+
``XDIVE_API_KEY`` (or the older ``CRAWLER_API_KEY``).
|
|
198
|
+
base_url: API origin including ``/api`` for a self-hosted instance;
|
|
199
|
+
defaults to ``XDIVE_BASE_URL`` (or ``CRAWLER_BASE_URL``) or the hosted service.
|
|
200
|
+
org_id: skip discovery when you already know the organization id.
|
|
201
|
+
timeout: per-request timeout in seconds.
|
|
202
|
+
max_retries: retries on ``429`` and on ``503`` with code
|
|
203
|
+
``SERVER_BUSY`` (each waits ``Retry-After``).
|
|
204
|
+
"""
|
|
205
|
+
|
|
206
|
+
def __init__(self, api_key: Optional[str] = None, **kwargs: Any):
|
|
207
|
+
super().__init__(api_key, **kwargs)
|
|
208
|
+
self._http = httpx.Client(base_url=self.base_url, headers=self._headers, timeout=self.timeout)
|
|
209
|
+
|
|
210
|
+
def close(self) -> None:
|
|
211
|
+
self._http.close()
|
|
212
|
+
|
|
213
|
+
def __enter__(self) -> "XDive":
|
|
214
|
+
return self
|
|
215
|
+
|
|
216
|
+
def __exit__(self, *exc: Any) -> None:
|
|
217
|
+
self.close()
|
|
218
|
+
|
|
219
|
+
# --- transport ---------------------------------------------------------
|
|
220
|
+
|
|
221
|
+
def _request(self, method: str, path: str, *, json: Optional[JSON] = None, params: Optional[JSON] = None,
|
|
222
|
+
headers: Optional[Dict[str, str]] = None) -> httpx.Response:
|
|
223
|
+
attempt = 0
|
|
224
|
+
headers = _attempt_headers(method, headers)
|
|
225
|
+
while True:
|
|
226
|
+
try:
|
|
227
|
+
response = self._http.request(method, path, json=json, params=params, headers=headers)
|
|
228
|
+
except httpx.TransportError:
|
|
229
|
+
# Timeouts and network failures: safe to retry GETs, and POSTs
|
|
230
|
+
# thanks to the Idempotency-Key.
|
|
231
|
+
if method in ("GET", "POST") and attempt < self.max_retries:
|
|
232
|
+
time.sleep(_backoff(attempt))
|
|
233
|
+
attempt += 1
|
|
234
|
+
continue
|
|
235
|
+
raise
|
|
236
|
+
if _is_overload(response):
|
|
237
|
+
if attempt < self.max_retries:
|
|
238
|
+
attempt += 1
|
|
239
|
+
time.sleep(_retry_after(response, 1.0))
|
|
240
|
+
continue
|
|
241
|
+
error = _error_from(response)
|
|
242
|
+
raise RateLimited(response.status_code, error.message, _retry_after(response, 1.0), error.code)
|
|
243
|
+
if response.status_code >= 400:
|
|
244
|
+
raise _error_from(response)
|
|
245
|
+
return response
|
|
246
|
+
|
|
247
|
+
def _post(self, path: str, payload: JSON) -> JSON:
|
|
248
|
+
return self._request("POST", path, json=payload).json()
|
|
249
|
+
|
|
250
|
+
@property
|
|
251
|
+
def org_id(self) -> str:
|
|
252
|
+
if self._org_id is None:
|
|
253
|
+
self._org_id = self._request("GET", "/whoami").json()["orgId"]
|
|
254
|
+
return self._org_id
|
|
255
|
+
|
|
256
|
+
def _org(self, suffix: str) -> str:
|
|
257
|
+
return f"/orgs/{self.org_id}{suffix}"
|
|
258
|
+
|
|
259
|
+
# --- identity and usage ------------------------------------------------
|
|
260
|
+
|
|
261
|
+
def whoami(self) -> JSON:
|
|
262
|
+
"""Who the API key is: organization, scopes, plan."""
|
|
263
|
+
return self._request("GET", "/whoami").json()
|
|
264
|
+
|
|
265
|
+
# --- pages ---------------------------------------------------------------
|
|
266
|
+
|
|
267
|
+
def scrape(self, url: str, formats: Optional[Sequence[Union[str, JSON]]] = None, **options: Any) -> JSON:
|
|
268
|
+
"""Scrape one page synchronously.
|
|
269
|
+
|
|
270
|
+
``formats`` are ``markdown`` (default), ``text``, ``html``,
|
|
271
|
+
``rawHtml``, ``links``, ``images``, ``json``, ``summary``,
|
|
272
|
+
``question``, ``highlights``, ``branding``, ``audio``, ``screenshot``,
|
|
273
|
+
``product`` and ``menu``; pass a dict such as
|
|
274
|
+
``{"type": "json", "schema": {...}}`` to set a format's options.
|
|
275
|
+
Other keyword options map to the
|
|
276
|
+
API fields (``max_age``, ``headers``, ``respect_robots``,
|
|
277
|
+
``redact_pii``, ``actions``, ``wait_for``…). Returns the ``data``
|
|
278
|
+
page object.
|
|
279
|
+
"""
|
|
280
|
+
return self._post(self._org("/scrape"), self._scrape_payload(url, formats, options))["data"]
|
|
281
|
+
|
|
282
|
+
def start_crawl(self, url: str, **options: Any) -> str:
|
|
283
|
+
"""Start a crawl and return its id (see :meth:`crawl` to wait)."""
|
|
284
|
+
return self._post(self._org("/crawl"), self._crawl_payload(url, options))["id"]
|
|
285
|
+
|
|
286
|
+
def get_crawl(self, crawl_id: str) -> JSON:
|
|
287
|
+
"""Current status and, once finished, results of a crawl."""
|
|
288
|
+
return self._request("GET", self._org(f"/crawls/{crawl_id}")).json()
|
|
289
|
+
|
|
290
|
+
def cancel_crawl(self, crawl_id: str) -> None:
|
|
291
|
+
"""Cancel a queued or running crawl (or batch scrape)."""
|
|
292
|
+
self._request("DELETE", self._org(f"/crawls/{crawl_id}"))
|
|
293
|
+
|
|
294
|
+
def crawl_errors(self, crawl_id: str) -> JSON:
|
|
295
|
+
"""The pages of a crawl that failed: ``errors`` (``url``/``error``
|
|
296
|
+
pairs) and ``robots_blocked`` (URLs refused by robots.txt)."""
|
|
297
|
+
return self._request("GET", self._org(f"/crawls/{crawl_id}/errors")).json()
|
|
298
|
+
|
|
299
|
+
def wait_for_crawl(self, crawl_id: str, *, timeout: float = 600.0, poll_interval: float = 2.0) -> JSON:
|
|
300
|
+
"""Poll until the crawl finishes, honouring the server's ETag and
|
|
301
|
+
Retry-After, and return the final record."""
|
|
302
|
+
deadline = time.monotonic() + timeout
|
|
303
|
+
etag: Optional[str] = None
|
|
304
|
+
last: Optional[JSON] = None
|
|
305
|
+
while True:
|
|
306
|
+
headers = {"If-None-Match": etag} if etag else None
|
|
307
|
+
response = self._request("GET", self._org(f"/crawls/{crawl_id}"), headers=headers)
|
|
308
|
+
if response.status_code != 304:
|
|
309
|
+
last = response.json()
|
|
310
|
+
etag = response.headers.get("ETag")
|
|
311
|
+
if last.get("status") in _TERMINAL:
|
|
312
|
+
return last
|
|
313
|
+
if time.monotonic() >= deadline:
|
|
314
|
+
raise CrawlTimeout(crawl_id, timeout)
|
|
315
|
+
time.sleep(min(_retry_after(response, poll_interval), max(0.0, deadline - time.monotonic())))
|
|
316
|
+
|
|
317
|
+
def crawl(self, url: str, *, timeout: float = 600.0, poll_interval: float = 2.0, **options: Any) -> JSON:
|
|
318
|
+
"""Crawl a site and wait for the result. Options: ``max_depth``,
|
|
319
|
+
``max_pages``, ``same_domain``, ``formats``, ``include_paths``,
|
|
320
|
+
``exclude_paths``, ``render``, ``adaptive_fetch``, ``headers``,
|
|
321
|
+
``max_age``, ``webhook_url``, ``llm_extract``, ``extract_schema``…"""
|
|
322
|
+
return self.wait_for_crawl(self.start_crawl(url, **options), timeout=timeout, poll_interval=poll_interval)
|
|
323
|
+
|
|
324
|
+
def batch_scrape(self, urls: Sequence[str], *, wait: bool = True, timeout: float = 600.0, **options: Any) -> Union[str, JSON]:
|
|
325
|
+
"""Scrape up to 100 URLs through the job pipeline; returns the crawl
|
|
326
|
+
record (or the job id when ``wait=False``)."""
|
|
327
|
+
crawl_id = self._post(self._org("/batch/scrape"), self._batch_payload(urls, options))["id"]
|
|
328
|
+
return self.wait_for_crawl(crawl_id, timeout=timeout) if wait else crawl_id
|
|
329
|
+
|
|
330
|
+
def map(self, url: str, *, search: Optional[str] = None, limit: Optional[int] = None,
|
|
331
|
+
max_depth: Optional[int] = None) -> List[str]:
|
|
332
|
+
"""Discover a site's URLs (sitemap + links), optionally filtered."""
|
|
333
|
+
payload = _drop_none({"url": url, "search": search, "limit": limit, "max_depth": max_depth})
|
|
334
|
+
return self._post(self._org("/map"), payload)["urls"]
|
|
335
|
+
|
|
336
|
+
# --- discovery -----------------------------------------------------------
|
|
337
|
+
|
|
338
|
+
def search(self, query: str, **options: Any) -> JSON:
|
|
339
|
+
"""Web search. Options: ``sources`` (web/news/images), ``limit``,
|
|
340
|
+
``country``, ``freshness``, ``include_domains``, ``exclude_domains``,
|
|
341
|
+
``search_depth`` ("advanced" also reads the top results and attaches
|
|
342
|
+
each one's query-relevant ``passages`` and a ``relevance_score``;
|
|
343
|
+
results keep the engine's order), ``auto_parameters``,
|
|
344
|
+
``include_answer``, ``scrape``."""
|
|
345
|
+
payload: JSON = {"query": query}
|
|
346
|
+
payload.update(_drop_none(options))
|
|
347
|
+
return self._post(self._org("/search"), payload)
|
|
348
|
+
|
|
349
|
+
def research(self, query: str, *, depth: str = "quick", output: Optional[str] = None,
|
|
350
|
+
schema: Optional[JSON] = None, **options: Any) -> JSON:
|
|
351
|
+
"""Answer a question from the web with verified citations.
|
|
352
|
+
|
|
353
|
+
``depth`` is ``quick`` (seconds) or ``deep`` (1–2 minutes, planned
|
|
354
|
+
sub-queries, report). Pass ``schema`` for structured ``data`` instead
|
|
355
|
+
of prose, ``no_cache=True`` to bypass the short-lived result cache.
|
|
356
|
+
Returns ``answer``/``data``, ``citations``, ``sources``,
|
|
357
|
+
``confidence`` and ``usage`` (``cached`` is true for a cache hit).
|
|
358
|
+
``usage.plan``, when present, says how the question was planned:
|
|
359
|
+
``single``, ``multi_hop`` (answered through an intermediate search)
|
|
360
|
+
or ``unavailable`` (the planning step was too slow or failed)."""
|
|
361
|
+
payload: JSON = {"query": query, "depth": depth}
|
|
362
|
+
payload.update(_drop_none({"output": output, "schema": schema}))
|
|
363
|
+
payload.update(_drop_none(options))
|
|
364
|
+
return self._post(self._org("/research"), payload)
|
|
365
|
+
|
|
366
|
+
def extract(self, urls: Sequence[str], *, prompt: Optional[str] = None, schema: Optional[JSON] = None,
|
|
367
|
+
**options: Any) -> JSON:
|
|
368
|
+
"""Extract structured data across up to 10 URLs with a prompt and/or
|
|
369
|
+
a JSON Schema."""
|
|
370
|
+
payload: JSON = {"urls": list(urls)}
|
|
371
|
+
payload.update(_drop_none({"prompt": prompt, "schema": schema}))
|
|
372
|
+
payload.update(_drop_none(options))
|
|
373
|
+
return self._post(self._org("/extract"), payload)
|
|
374
|
+
|
|
375
|
+
def llms_txt(self, url: str, *, max_urls: Optional[int] = None, full_text: bool = False) -> JSON:
|
|
376
|
+
"""Generate llms.txt (and optionally llms-full.txt) for a site."""
|
|
377
|
+
return self._post(self._org("/llmstxt"), self._llms_txt_payload(url, max_urls, full_text))
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
class AsyncXDive(_Base):
|
|
381
|
+
"""Asynchronous client with the same methods as :class:`XDive`."""
|
|
382
|
+
|
|
383
|
+
def __init__(self, api_key: Optional[str] = None, **kwargs: Any):
|
|
384
|
+
super().__init__(api_key, **kwargs)
|
|
385
|
+
self._http = httpx.AsyncClient(base_url=self.base_url, headers=self._headers, timeout=self.timeout)
|
|
386
|
+
|
|
387
|
+
async def aclose(self) -> None:
|
|
388
|
+
await self._http.aclose()
|
|
389
|
+
|
|
390
|
+
async def __aenter__(self) -> "AsyncXDive":
|
|
391
|
+
return self
|
|
392
|
+
|
|
393
|
+
async def __aexit__(self, *exc: Any) -> None:
|
|
394
|
+
await self.aclose()
|
|
395
|
+
|
|
396
|
+
async def _request(self, method: str, path: str, *, json: Optional[JSON] = None, params: Optional[JSON] = None,
|
|
397
|
+
headers: Optional[Dict[str, str]] = None) -> httpx.Response:
|
|
398
|
+
attempt = 0
|
|
399
|
+
headers = _attempt_headers(method, headers)
|
|
400
|
+
while True:
|
|
401
|
+
try:
|
|
402
|
+
response = await self._http.request(method, path, json=json, params=params, headers=headers)
|
|
403
|
+
except httpx.TransportError:
|
|
404
|
+
if method in ("GET", "POST") and attempt < self.max_retries:
|
|
405
|
+
await asyncio.sleep(_backoff(attempt))
|
|
406
|
+
attempt += 1
|
|
407
|
+
continue
|
|
408
|
+
raise
|
|
409
|
+
if _is_overload(response):
|
|
410
|
+
if attempt < self.max_retries:
|
|
411
|
+
attempt += 1
|
|
412
|
+
await asyncio.sleep(_retry_after(response, 1.0))
|
|
413
|
+
continue
|
|
414
|
+
error = _error_from(response)
|
|
415
|
+
raise RateLimited(response.status_code, error.message, _retry_after(response, 1.0), error.code)
|
|
416
|
+
if response.status_code >= 400:
|
|
417
|
+
raise _error_from(response)
|
|
418
|
+
return response
|
|
419
|
+
|
|
420
|
+
async def _post(self, path: str, payload: JSON) -> JSON:
|
|
421
|
+
return (await self._request("POST", path, json=payload)).json()
|
|
422
|
+
|
|
423
|
+
async def org_id(self) -> str:
|
|
424
|
+
if self._org_id is None:
|
|
425
|
+
self._org_id = (await self._request("GET", "/whoami")).json()["orgId"]
|
|
426
|
+
return self._org_id
|
|
427
|
+
|
|
428
|
+
async def _org(self, suffix: str) -> str:
|
|
429
|
+
return f"/orgs/{await self.org_id()}{suffix}"
|
|
430
|
+
|
|
431
|
+
async def whoami(self) -> JSON:
|
|
432
|
+
return (await self._request("GET", "/whoami")).json()
|
|
433
|
+
|
|
434
|
+
async def scrape(self, url: str, formats: Optional[Sequence[Union[str, JSON]]] = None, **options: Any) -> JSON:
|
|
435
|
+
return (await self._post(await self._org("/scrape"), self._scrape_payload(url, formats, options)))["data"]
|
|
436
|
+
|
|
437
|
+
async def start_crawl(self, url: str, **options: Any) -> str:
|
|
438
|
+
return (await self._post(await self._org("/crawl"), self._crawl_payload(url, options)))["id"]
|
|
439
|
+
|
|
440
|
+
async def get_crawl(self, crawl_id: str) -> JSON:
|
|
441
|
+
return (await self._request("GET", await self._org(f"/crawls/{crawl_id}"))).json()
|
|
442
|
+
|
|
443
|
+
async def cancel_crawl(self, crawl_id: str) -> None:
|
|
444
|
+
"""Cancel a queued or running crawl (or batch scrape)."""
|
|
445
|
+
await self._request("DELETE", await self._org(f"/crawls/{crawl_id}"))
|
|
446
|
+
|
|
447
|
+
async def crawl_errors(self, crawl_id: str) -> JSON:
|
|
448
|
+
"""The pages of a crawl that failed: ``errors`` (``url``/``error``
|
|
449
|
+
pairs) and ``robots_blocked`` (URLs refused by robots.txt)."""
|
|
450
|
+
return (await self._request("GET", await self._org(f"/crawls/{crawl_id}/errors"))).json()
|
|
451
|
+
|
|
452
|
+
async def wait_for_crawl(self, crawl_id: str, *, timeout: float = 600.0, poll_interval: float = 2.0) -> JSON:
|
|
453
|
+
"""Poll until the crawl finishes, honouring the server's ETag and
|
|
454
|
+
Retry-After, and return the final record."""
|
|
455
|
+
deadline = time.monotonic() + timeout
|
|
456
|
+
etag: Optional[str] = None
|
|
457
|
+
while True:
|
|
458
|
+
headers = {"If-None-Match": etag} if etag else None
|
|
459
|
+
response = await self._request("GET", await self._org(f"/crawls/{crawl_id}"), headers=headers)
|
|
460
|
+
if response.status_code != 304:
|
|
461
|
+
last = response.json()
|
|
462
|
+
etag = response.headers.get("ETag")
|
|
463
|
+
if last.get("status") in _TERMINAL:
|
|
464
|
+
return last
|
|
465
|
+
if time.monotonic() >= deadline:
|
|
466
|
+
raise CrawlTimeout(crawl_id, timeout)
|
|
467
|
+
await asyncio.sleep(min(_retry_after(response, poll_interval), max(0.0, deadline - time.monotonic())))
|
|
468
|
+
|
|
469
|
+
async def crawl(self, url: str, *, timeout: float = 600.0, poll_interval: float = 2.0, **options: Any) -> JSON:
|
|
470
|
+
"""Crawl a site and wait for the result (see :meth:`XDive.crawl`)."""
|
|
471
|
+
return await self.wait_for_crawl(await self.start_crawl(url, **options), timeout=timeout, poll_interval=poll_interval)
|
|
472
|
+
|
|
473
|
+
async def batch_scrape(self, urls: Sequence[str], *, wait: bool = True, timeout: float = 600.0,
|
|
474
|
+
**options: Any) -> Union[str, JSON]:
|
|
475
|
+
"""Scrape up to 100 URLs through the job pipeline; returns the crawl
|
|
476
|
+
record (or the job id when ``wait=False``)."""
|
|
477
|
+
crawl_id = (await self._post(await self._org("/batch/scrape"), self._batch_payload(urls, options)))["id"]
|
|
478
|
+
return await self.wait_for_crawl(crawl_id, timeout=timeout) if wait else crawl_id
|
|
479
|
+
|
|
480
|
+
async def map(self, url: str, *, search: Optional[str] = None, limit: Optional[int] = None,
|
|
481
|
+
max_depth: Optional[int] = None) -> List[str]:
|
|
482
|
+
"""Discover a site's URLs (sitemap + links), optionally filtered."""
|
|
483
|
+
payload = _drop_none({"url": url, "search": search, "limit": limit, "max_depth": max_depth})
|
|
484
|
+
return (await self._post(await self._org("/map"), payload))["urls"]
|
|
485
|
+
|
|
486
|
+
async def search(self, query: str, **options: Any) -> JSON:
|
|
487
|
+
"""Web search; same options as :meth:`XDive.search` (``search_depth``
|
|
488
|
+
"advanced" attaches query-relevant ``passages`` and a
|
|
489
|
+
``relevance_score`` to the top results, in the engine's order)."""
|
|
490
|
+
payload: JSON = {"query": query}
|
|
491
|
+
payload.update(_drop_none(options))
|
|
492
|
+
return await self._post(await self._org("/search"), payload)
|
|
493
|
+
|
|
494
|
+
async def research(self, query: str, *, depth: str = "quick", output: Optional[str] = None,
|
|
495
|
+
schema: Optional[JSON] = None, **options: Any) -> JSON:
|
|
496
|
+
payload: JSON = {"query": query, "depth": depth}
|
|
497
|
+
payload.update(_drop_none({"output": output, "schema": schema}))
|
|
498
|
+
payload.update(_drop_none(options))
|
|
499
|
+
return await self._post(await self._org("/research"), payload)
|
|
500
|
+
|
|
501
|
+
async def extract(self, urls: Iterable[str], *, prompt: Optional[str] = None, schema: Optional[JSON] = None,
|
|
502
|
+
**options: Any) -> JSON:
|
|
503
|
+
"""Extract structured data across up to 10 URLs with a prompt and/or
|
|
504
|
+
a JSON Schema."""
|
|
505
|
+
payload: JSON = {"urls": list(urls)}
|
|
506
|
+
payload.update(_drop_none({"prompt": prompt, "schema": schema}))
|
|
507
|
+
payload.update(_drop_none(options))
|
|
508
|
+
return await self._post(await self._org("/extract"), payload)
|
|
509
|
+
|
|
510
|
+
async def llms_txt(self, url: str, *, max_urls: Optional[int] = None, full_text: bool = False) -> JSON:
|
|
511
|
+
"""Generate llms.txt (and optionally llms-full.txt) for a site."""
|
|
512
|
+
return await self._post(await self._org("/llmstxt"), self._llms_txt_payload(url, max_urls, full_text))
|
xdive/langchain.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""LangChain integration: tools for agents and a document loader.
|
|
2
|
+
|
|
3
|
+
from xdive.langchain import XDiveSearchTool, XDiveResearchTool, XDiveLoader
|
|
4
|
+
|
|
5
|
+
tools = [XDiveSearchTool(), XDiveScrapeTool(), XDiveResearchTool()]
|
|
6
|
+
docs = XDiveLoader("https://docs.example.com", mode="crawl", max_pages=50).load()
|
|
7
|
+
|
|
8
|
+
Requires ``langchain-core`` (``pip install xdive[langchain]``).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
from typing import Any, Iterator, List, Optional, Type
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
from langchain_core.documents import Document
|
|
18
|
+
from langchain_core.document_loaders import BaseLoader
|
|
19
|
+
from langchain_core.tools import BaseTool
|
|
20
|
+
from pydantic import BaseModel, Field, PrivateAttr
|
|
21
|
+
except ImportError as exc: # pragma: no cover - import guard
|
|
22
|
+
raise ImportError("xdive.langchain needs langchain-core: pip install 'xdive[langchain]'") from exc
|
|
23
|
+
|
|
24
|
+
from .client import XDive, page_markdown
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _compact(obj: Any) -> str:
|
|
28
|
+
return json.dumps(obj, ensure_ascii=False, separators=(",", ":"))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class _XDiveTool(BaseTool):
|
|
32
|
+
"""Base: shares one client across tools."""
|
|
33
|
+
|
|
34
|
+
_client: XDive = PrivateAttr()
|
|
35
|
+
|
|
36
|
+
def __init__(self, client: Optional[XDive] = None, **kwargs: Any):
|
|
37
|
+
super().__init__(**kwargs)
|
|
38
|
+
self._client = client or XDive()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class SearchInput(BaseModel):
|
|
42
|
+
query: str = Field(description="What to search the web for")
|
|
43
|
+
limit: int = Field(default=5, description="How many results (1-20)")
|
|
44
|
+
freshness: Optional[str] = Field(default=None, description="pd, pw, pm or py to prefer recent pages")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class XDiveSearchTool(_XDiveTool):
|
|
48
|
+
"""Web search that returns, for each result, the passages most relevant
|
|
49
|
+
to the query — not whole pages."""
|
|
50
|
+
|
|
51
|
+
name: str = "xdive_search"
|
|
52
|
+
description: str = (
|
|
53
|
+
"Search the web. Returns the top results with their most relevant passages "
|
|
54
|
+
"and a relevance score. Use it to find current information or sources; "
|
|
55
|
+
"use xdive_research when you need a synthesized answer with citations."
|
|
56
|
+
)
|
|
57
|
+
args_schema: Type[BaseModel] = SearchInput
|
|
58
|
+
|
|
59
|
+
def _run(self, query: str, limit: int = 5, freshness: Optional[str] = None) -> str:
|
|
60
|
+
response = self._client.search(query, limit=max(1, min(limit, 20)), freshness=freshness,
|
|
61
|
+
search_depth="advanced", auto_parameters=True)
|
|
62
|
+
results = [
|
|
63
|
+
{
|
|
64
|
+
"title": item.get("title"),
|
|
65
|
+
"url": item.get("url"),
|
|
66
|
+
"snippet": item.get("description"),
|
|
67
|
+
"passages": [p["text"] for p in item.get("passages", [])],
|
|
68
|
+
"relevance": item.get("relevance_score"),
|
|
69
|
+
}
|
|
70
|
+
for item in response.get("results", [])
|
|
71
|
+
]
|
|
72
|
+
return _compact(results)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class ScrapeInput(BaseModel):
|
|
76
|
+
url: str = Field(description="The http(s) URL to read")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class XDiveScrapeTool(_XDiveTool):
|
|
80
|
+
"""Read one page as Markdown (JavaScript rendered and bot filters handled
|
|
81
|
+
automatically)."""
|
|
82
|
+
|
|
83
|
+
name: str = "xdive_scrape"
|
|
84
|
+
description: str = "Fetch a web page and return its main content as Markdown."
|
|
85
|
+
args_schema: Type[BaseModel] = ScrapeInput
|
|
86
|
+
|
|
87
|
+
def _run(self, url: str) -> str:
|
|
88
|
+
return page_markdown(self._client.scrape(url, formats=["markdown"]))[:60_000]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class ResearchInput(BaseModel):
|
|
92
|
+
query: str = Field(description="The question to answer from the web")
|
|
93
|
+
depth: str = Field(default="quick", description="quick (seconds) or deep (minutes, more sources)")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class XDiveResearchTool(_XDiveTool):
|
|
97
|
+
"""Answer a question from several web sources with verified citations."""
|
|
98
|
+
|
|
99
|
+
name: str = "xdive_research"
|
|
100
|
+
description: str = (
|
|
101
|
+
"Research a question on the web and return an answer in which every claim "
|
|
102
|
+
"cites a source, plus the list of citations. Use for questions needing "
|
|
103
|
+
"several sources or up-to-date facts."
|
|
104
|
+
)
|
|
105
|
+
args_schema: Type[BaseModel] = ResearchInput
|
|
106
|
+
|
|
107
|
+
def _run(self, query: str, depth: str = "quick") -> str:
|
|
108
|
+
result = self._client.research(query, depth=depth)
|
|
109
|
+
return _compact({
|
|
110
|
+
"answer": result.get("answer") or result.get("data"),
|
|
111
|
+
"confidence": result.get("confidence"),
|
|
112
|
+
"citations": [{"n": c["number"], "url": c["url"], "title": c.get("title")} for c in result.get("citations", [])],
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class XDiveLoader(BaseLoader):
|
|
117
|
+
"""Load pages as LangChain documents.
|
|
118
|
+
|
|
119
|
+
``mode="scrape"`` loads the one URL; ``mode="crawl"`` crawls from it
|
|
120
|
+
(``max_pages``, ``max_depth`` and other crawl options apply).
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
def __init__(self, url: str, *, mode: str = "scrape", client: Optional[XDive] = None, **options: Any):
|
|
124
|
+
if mode not in ("scrape", "crawl"):
|
|
125
|
+
raise ValueError("mode must be 'scrape' or 'crawl'")
|
|
126
|
+
self.url = url
|
|
127
|
+
self.mode = mode
|
|
128
|
+
self.options = options
|
|
129
|
+
self._client = client or XDive()
|
|
130
|
+
|
|
131
|
+
def lazy_load(self) -> Iterator[Document]:
|
|
132
|
+
if self.mode == "scrape":
|
|
133
|
+
yield self._document(self._client.scrape(self.url, formats=["markdown"], **self.options))
|
|
134
|
+
return
|
|
135
|
+
record = self._client.crawl(self.url, formats=["markdown"], **self.options)
|
|
136
|
+
for page in record.get("results") or []:
|
|
137
|
+
if page.get("error"):
|
|
138
|
+
continue
|
|
139
|
+
yield self._document(page)
|
|
140
|
+
|
|
141
|
+
def load(self) -> List[Document]:
|
|
142
|
+
return list(self.lazy_load())
|
|
143
|
+
|
|
144
|
+
@staticmethod
|
|
145
|
+
def _document(page: dict) -> Document:
|
|
146
|
+
content = page_markdown(page)
|
|
147
|
+
metadata = {key: page.get(key) for key in ("url", "title", "description", "lang", "fetched_at") if page.get(key)}
|
|
148
|
+
metadata["source"] = page.get("url")
|
|
149
|
+
return Document(page_content=content, metadata=metadata)
|
xdive/llama_index.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""LlamaIndex integration (``pip install xdive[llama-index]``).
|
|
2
|
+
|
|
3
|
+
from xdive.llama_index import XDiveReader
|
|
4
|
+
|
|
5
|
+
documents = XDiveReader().load_data("https://docs.example.com", mode="crawl", max_pages=50)
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any, List, Optional
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
from llama_index.core.readers.base import BaseReader
|
|
14
|
+
from llama_index.core.schema import Document
|
|
15
|
+
except ImportError as exc: # pragma: no cover - import guard
|
|
16
|
+
raise ImportError("xdive.llama_index needs llama-index-core: pip install 'xdive[llama-index]'") from exc
|
|
17
|
+
|
|
18
|
+
from .client import XDive, page_markdown
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class XDiveReader(BaseReader):
|
|
22
|
+
"""Reads one page (``mode="scrape"``) or a whole site (``mode="crawl"``)
|
|
23
|
+
into LlamaIndex documents."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, client: Optional[XDive] = None):
|
|
26
|
+
self._client = client or XDive()
|
|
27
|
+
|
|
28
|
+
def load_data(self, url: str, mode: str = "scrape", **options: Any) -> List[Document]:
|
|
29
|
+
if mode == "scrape":
|
|
30
|
+
pages = [self._client.scrape(url, formats=["markdown"], **options)]
|
|
31
|
+
elif mode == "crawl":
|
|
32
|
+
pages = [p for p in self._client.crawl(url, formats=["markdown"], **options).get("results") or [] if not p.get("error")]
|
|
33
|
+
else:
|
|
34
|
+
raise ValueError("mode must be 'scrape' or 'crawl'")
|
|
35
|
+
documents = []
|
|
36
|
+
for page in pages:
|
|
37
|
+
documents.append(Document(text=page_markdown(page), metadata={"url": page.get("url"), "title": page.get("title")}))
|
|
38
|
+
return documents
|
xdive/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: xdive
|
|
3
|
+
Version: 1.5.0
|
|
4
|
+
Summary: Python SDK for XDive: scrape, crawl, map, search and research the web with citations, for AI agents.
|
|
5
|
+
Project-URL: Homepage, https://crawler.mehdisadeghian.com
|
|
6
|
+
Project-URL: Documentation, https://crawler.mehdisadeghian.com/docs/sdk-python
|
|
7
|
+
Author: XDive
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: agents,crawler,langchain,llm,markdown,mcp,rag,research,search,web scraping,xdive
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
17
|
+
Classifier: Typing :: Typed
|
|
18
|
+
Requires-Python: >=3.9
|
|
19
|
+
Requires-Dist: httpx<1,>=0.25
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: langchain-core>=0.2; extra == 'dev'
|
|
22
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
23
|
+
Provides-Extra: langchain
|
|
24
|
+
Requires-Dist: langchain-core>=0.2; extra == 'langchain'
|
|
25
|
+
Provides-Extra: llama-index
|
|
26
|
+
Requires-Dist: llama-index-core>=0.10; extra == 'llama-index'
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# xdive
|
|
30
|
+
|
|
31
|
+
Python client for [XDive](https://crawler.mehdisadeghian.com): scrape pages
|
|
32
|
+
to Markdown, crawl and map sites, search the web, and answer questions with
|
|
33
|
+
verified citations. Synchronous and asynchronous clients, typed errors,
|
|
34
|
+
automatic retries, and LangChain and LlamaIndex integrations.
|
|
35
|
+
|
|
36
|
+
Full documentation: https://crawler.mehdisadeghian.com/docs/sdk-python
|
|
37
|
+
|
|
38
|
+
## Install
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install xdive
|
|
42
|
+
pip install "xdive[langchain]" # LangChain tools and document loader
|
|
43
|
+
pip install "xdive[llama-index]" # LlamaIndex reader
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Requires Python 3.9 or later. The only runtime dependency is `httpx`.
|
|
47
|
+
|
|
48
|
+
## Quickstart
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
from xdive import XDive, page_markdown
|
|
52
|
+
|
|
53
|
+
with XDive() as xdive: # reads XDIVE_API_KEY
|
|
54
|
+
page = xdive.scrape("https://example.com", formats=["markdown", "links"])
|
|
55
|
+
print(page_markdown(page))
|
|
56
|
+
site = xdive.crawl("https://docs.example.com", max_pages=50) # waits for the crawl
|
|
57
|
+
answer = xdive.research("What does PEP 639 change about license metadata?")
|
|
58
|
+
print(answer["answer"])
|
|
59
|
+
for citation in answer["citations"]:
|
|
60
|
+
print(citation["number"], citation["url"])
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Create an API key under **API Keys** in the dashboard. A key belongs to one
|
|
64
|
+
organization; the client looks the organization up once through
|
|
65
|
+
`GET /whoami`, so the key is all you configure.
|
|
66
|
+
|
|
67
|
+
## Configuration
|
|
68
|
+
|
|
69
|
+
| Argument | Environment variable | Default | Description |
|
|
70
|
+
|---|---|---|---|
|
|
71
|
+
| `api_key` | `XDIVE_API_KEY` (or `CRAWLER_API_KEY`) | (required) | Organization API key (`ck_live_…`). |
|
|
72
|
+
| `base_url` | `XDIVE_BASE_URL` (or `CRAWLER_BASE_URL`) | `https://crawler.mehdisadeghian.com/api` | API base URL, including `/api`. |
|
|
73
|
+
| `org_id` | | discovered | Skip the `/whoami` lookup when you know the organization id. |
|
|
74
|
+
| `timeout` | | `120.0` | Per-request timeout in seconds. |
|
|
75
|
+
| `max_retries` | | `3` | Retries of overload responses: `429`, and `503` with code `SERVER_BUSY`. |
|
|
76
|
+
|
|
77
|
+
A missing key raises `ValueError` when the client is created.
|
|
78
|
+
|
|
79
|
+
## Methods
|
|
80
|
+
|
|
81
|
+
`XDive` and `AsyncXDive` have the same methods; on `AsyncXDive` each is
|
|
82
|
+
a coroutine. Keyword options are passed through as the API's JSON fields.
|
|
83
|
+
|
|
84
|
+
| Method | API call | Returns |
|
|
85
|
+
|---|---|---|
|
|
86
|
+
| `whoami()` | `GET /whoami` | The key's organization, scopes and plan. |
|
|
87
|
+
| `scrape(url, formats=None, **options)` | `POST /orgs/{id}/scrape` | The page object (`data`). |
|
|
88
|
+
| `crawl(url, *, timeout=600, poll_interval=2, **options)` | `POST /orgs/{id}/crawl`, then polls | The finished crawl record with `results`. |
|
|
89
|
+
| `start_crawl(url, **options)` | `POST /orgs/{id}/crawl` | The crawl id. |
|
|
90
|
+
| `get_crawl(crawl_id)` | `GET /orgs/{id}/crawls/{crawlId}` | The crawl record. |
|
|
91
|
+
| `wait_for_crawl(crawl_id, *, timeout=600, poll_interval=2)` | polls `GET /orgs/{id}/crawls/{crawlId}` | The finished crawl record. |
|
|
92
|
+
| `cancel_crawl(crawl_id)` | `DELETE /orgs/{id}/crawls/{crawlId}` | `None`. |
|
|
93
|
+
| `crawl_errors(crawl_id)` | `GET /orgs/{id}/crawls/{crawlId}/errors` | Failed pages (`errors`) and `robots_blocked` URLs. |
|
|
94
|
+
| `batch_scrape(urls, *, wait=True, timeout=600, **options)` | `POST /orgs/{id}/batch/scrape` | The finished record, or the job id with `wait=False`. |
|
|
95
|
+
| `map(url, *, search=None, limit=None, max_depth=None)` | `POST /orgs/{id}/map` | A list of URLs. |
|
|
96
|
+
| `search(query, **options)` | `POST /orgs/{id}/search` | The search response with `results`. |
|
|
97
|
+
| `research(query, *, depth="quick", output=None, schema=None, **options)` | `POST /orgs/{id}/research` | `answer` or `data`, `citations`, `sources`, `confidence`, `usage`. |
|
|
98
|
+
| `extract(urls, *, prompt=None, schema=None, **options)` | `POST /orgs/{id}/extract` | The extracted data. |
|
|
99
|
+
| `llms_txt(url, *, max_urls=None, full_text=False)` | `POST /orgs/{id}/llmstxt` | `llms_txt` (and `llms_full_txt`). |
|
|
100
|
+
|
|
101
|
+
`XDive.org_id` is a property; on `AsyncXDive` it is a coroutine,
|
|
102
|
+
`await client.org_id()`.
|
|
103
|
+
|
|
104
|
+
`formats` takes the API's format names (`markdown`, `text`, `html`,
|
|
105
|
+
`rawHtml`, `links`, `images`, `json`, `summary`, `question`, `highlights`,
|
|
106
|
+
`branding`, `audio`, `screenshot`, `product`, `menu`) or dicts such as
|
|
107
|
+
`{"type": "json", "schema": {...}}`. `page_markdown(page)` returns a page's
|
|
108
|
+
Markdown whichever field carries it (`artifacts.markdown.content` or
|
|
109
|
+
`markdown`).
|
|
110
|
+
|
|
111
|
+
## Async
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
import asyncio
|
|
115
|
+
from xdive import AsyncXDive
|
|
116
|
+
|
|
117
|
+
async def main():
|
|
118
|
+
async with AsyncXDive() as client:
|
|
119
|
+
pages = await asyncio.gather(*(client.scrape(u) for u in ["https://a.example", "https://b.example"]))
|
|
120
|
+
|
|
121
|
+
asyncio.run(main())
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Close clients you do not use as context managers: `xdive.close()` or
|
|
125
|
+
`await client.aclose()`.
|
|
126
|
+
|
|
127
|
+
## Errors and retries
|
|
128
|
+
|
|
129
|
+
| Exception | When | Attributes |
|
|
130
|
+
|---|---|---|
|
|
131
|
+
| `XDiveError` | Any API error (status 400 and above). Base class of the others. | `status`, `message`, `code` |
|
|
132
|
+
| `RateLimited` | Still overloaded (`429`, or `503` with code `SERVER_BUSY`) after `max_retries` retries. | `retry_after` (seconds) |
|
|
133
|
+
| `QuotaExceeded` | `402`: the organization's monthly quota is used up. | |
|
|
134
|
+
| `CrawlTimeout` | `crawl`, `wait_for_crawl` or `batch_scrape` waited longer than `timeout`. The job keeps running on the server. | `crawl_id` |
|
|
135
|
+
|
|
136
|
+
Only overload is retried: a `429` (organization rate or concurrency limit)
|
|
137
|
+
and a `503` whose JSON body has the code `SERVER_BUSY` (server at capacity).
|
|
138
|
+
The client waits the number of seconds in the response's `Retry-After`
|
|
139
|
+
header (1 second when absent) before each retry. Every other error, including
|
|
140
|
+
any other `503` (for example a feature the server has not configured), raises
|
|
141
|
+
`XDiveError` at once with the API's `status`, `message` and `code`. Network
|
|
142
|
+
errors and timeouts surface as `httpx` exceptions.
|
|
143
|
+
|
|
144
|
+
Crawl polling sends `If-None-Match`, so an unchanged status comes back as an
|
|
145
|
+
empty `304`, and waits the interval the server sets in `Retry-After`,
|
|
146
|
+
falling back to `poll_interval`.
|
|
147
|
+
|
|
148
|
+
## Integrations
|
|
149
|
+
|
|
150
|
+
```python
|
|
151
|
+
from xdive.langchain import XDiveLoader, XDiveResearchTool, XDiveScrapeTool, XDiveSearchTool
|
|
152
|
+
|
|
153
|
+
tools = [XDiveSearchTool(), XDiveScrapeTool(), XDiveResearchTool()]
|
|
154
|
+
docs = XDiveLoader("https://docs.example.com", mode="crawl", max_pages=100).load()
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
```python
|
|
158
|
+
from xdive.llama_index import XDiveReader
|
|
159
|
+
|
|
160
|
+
documents = XDiveReader().load_data("https://docs.example.com", mode="crawl", max_pages=100)
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
`XDiveSearchTool` returns each result's query-relevant passages rather than
|
|
164
|
+
whole pages, which keeps an agent's context small. See the
|
|
165
|
+
[LangChain](https://crawler.mehdisadeghian.com/docs/langchain) and
|
|
166
|
+
[LlamaIndex](https://crawler.mehdisadeghian.com/docs/llamaindex) guides.
|
|
167
|
+
|
|
168
|
+
## Self-hosted
|
|
169
|
+
|
|
170
|
+
```python
|
|
171
|
+
xdive = XDive(api_key="ck_live_…", base_url="https://your-host/api")
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
or set `XDIVE_BASE_URL=https://your-host/api`.
|
|
175
|
+
|
|
176
|
+
## Development
|
|
177
|
+
|
|
178
|
+
```bash
|
|
179
|
+
pip install -e ".[dev]"
|
|
180
|
+
pytest
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
## License
|
|
184
|
+
|
|
185
|
+
MIT. The license text is in the `LICENSE` file included in the package.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
xdive/__init__.py,sha256=cmN08KYeXc8950h2wxdFOjWJ4KjVdk5TBupFmuAmp3o,621
|
|
2
|
+
xdive/client.py,sha256=zpF7Va6ojjYKvMeplQ46U9M4Vq-M7SoNG8WZMOhUKPE,24086
|
|
3
|
+
xdive/langchain.py,sha256=I9Lg4pTbG7v5BUV98F_uI9iq9TlNJLIgKnIfhADXCiM,5712
|
|
4
|
+
xdive/llama_index.py,sha256=snmUB2wJaSRMmdSJhcmYCnuiiB4HAWttF05LlRP54tM,1501
|
|
5
|
+
xdive/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
xdive-1.5.0.dist-info/METADATA,sha256=1QwOfZ-bUVpYm-XK9R_xVuOAbbgSF8OPBYkp_u-urms,7907
|
|
7
|
+
xdive-1.5.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
8
|
+
xdive-1.5.0.dist-info/licenses/LICENSE,sha256=jiN0WDt_r4yK0PaGOkjmEFbdvfpXEH1f2Eyp875GHEY,1072
|
|
9
|
+
xdive-1.5.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mehdi Sadeghian
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|