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 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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.