blopus 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.
blopus/__init__.py ADDED
@@ -0,0 +1,61 @@
1
+ """Blopus — official Python SDK for the Blopus web search + fetch API.
2
+
3
+ Quickstart::
4
+
5
+ from blopus import Blopus
6
+
7
+ client = Blopus(api_key="blp_live_...") # or set BLOPUS_API_KEY
8
+ for hit in client.search("latest ai news", freshness="pd"):
9
+ print(hit.title, hit.url)
10
+
11
+ doc = client.fetch("https://example.com/article")
12
+
13
+ The SDK calls only two data-plane endpoints — ``POST /v1/search`` and
14
+ ``POST /v1/fetch`` on https://api.blopus.ai.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ from ._async import AsyncBlopus
19
+ from ._version import __version__
20
+ from .client import Blopus
21
+ from .exceptions import (
22
+ APIConnectionError,
23
+ AuthError,
24
+ BadRequestError,
25
+ BlopusError,
26
+ NotFoundError,
27
+ QuotaError,
28
+ RateLimitError,
29
+ ServerError,
30
+ )
31
+ from .mcp import mcp_config, print_mcp_config
32
+ from .models import (
33
+ BatchFetchResponse,
34
+ FetchFailure,
35
+ FetchResult,
36
+ SearchResponse,
37
+ SearchResult,
38
+ )
39
+
40
+ __all__ = [
41
+ "__version__",
42
+ "Blopus",
43
+ "AsyncBlopus",
44
+ "mcp_config",
45
+ "print_mcp_config",
46
+ # models
47
+ "SearchResponse",
48
+ "SearchResult",
49
+ "FetchResult",
50
+ "FetchFailure",
51
+ "BatchFetchResponse",
52
+ # exceptions
53
+ "BlopusError",
54
+ "APIConnectionError",
55
+ "AuthError",
56
+ "BadRequestError",
57
+ "NotFoundError",
58
+ "RateLimitError",
59
+ "QuotaError",
60
+ "ServerError",
61
+ ]
blopus/_async.py ADDED
@@ -0,0 +1,139 @@
1
+ """Asynchronous Blopus client (httpx.AsyncClient). Mirrors :class:`Blopus` exactly.
2
+
3
+ import asyncio
4
+ from blopus import AsyncBlopus
5
+
6
+ async def main():
7
+ async with AsyncBlopus() as client:
8
+ res = await client.search("openai news", freshness="pw")
9
+ docs = await client.fetch([r.url for r in res])
10
+
11
+ asyncio.run(main())
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ from typing import Optional, Sequence, Union
17
+
18
+ import httpx
19
+
20
+ from . import _common as C
21
+ from .exceptions import APIConnectionError
22
+ from .models import BatchFetchResponse, FetchFailure, FetchResult, SearchResponse
23
+
24
+
25
+ class AsyncBlopus:
26
+ """Async client for the Blopus search + fetch API. Same surface as :class:`Blopus`."""
27
+
28
+ def __init__(
29
+ self,
30
+ api_key: Optional[str] = None,
31
+ *,
32
+ base_url: Optional[str] = None,
33
+ timeout: float = C.DEFAULT_TIMEOUT,
34
+ max_retries: int = C.DEFAULT_MAX_RETRIES,
35
+ chunk_delay: float = C.DEFAULT_CHUNK_DELAY,
36
+ user_agent: str = C.USER_AGENT,
37
+ ) -> None:
38
+ self._api_key = C.resolve_api_key(api_key)
39
+ self.base_url = C.resolve_base_url(base_url)
40
+ self.max_retries = max_retries
41
+ self.chunk_delay = chunk_delay
42
+ self._http = httpx.AsyncClient(
43
+ base_url=self.base_url,
44
+ timeout=timeout,
45
+ headers=C.build_headers(self._api_key, user_agent),
46
+ )
47
+
48
+ async def aclose(self) -> None:
49
+ await self._http.aclose()
50
+
51
+ async def __aenter__(self) -> "AsyncBlopus":
52
+ return self
53
+
54
+ async def __aexit__(self, *exc) -> None:
55
+ await self.aclose()
56
+
57
+ async def _post(self, path: str, body: dict) -> dict:
58
+ attempt = 0
59
+ while True:
60
+ status: Optional[int] = None
61
+ retry_after: Optional[int] = None
62
+ try:
63
+ resp = await self._http.post(path, json=body)
64
+ status = resp.status_code
65
+ retry_after = C.parse_retry_after(resp.headers.get("Retry-After"))
66
+ if 200 <= status < 300:
67
+ return C.parse_json_or_raise(status, resp.text, retry_after)
68
+ except httpx.HTTPError as exc:
69
+ if not C.should_retry(None, attempt, self.max_retries):
70
+ raise APIConnectionError(f"Request failed: {exc}") from exc
71
+ await asyncio.sleep(C.backoff_seconds(attempt, None))
72
+ attempt += 1
73
+ continue
74
+
75
+ if C.should_retry(status, attempt, self.max_retries):
76
+ await asyncio.sleep(C.backoff_seconds(attempt, retry_after))
77
+ attempt += 1
78
+ continue
79
+ return C.parse_json_or_raise(status, resp.text, retry_after)
80
+
81
+ async def search(
82
+ self,
83
+ query: str,
84
+ *,
85
+ count: int = 10,
86
+ freshness: str = "all",
87
+ include_domains: Optional[Sequence[str]] = None,
88
+ exclude_domains: Optional[Sequence[str]] = None,
89
+ start_date: Optional[str] = None,
90
+ end_date: Optional[str] = None,
91
+ language: Optional[str] = None,
92
+ offset: int = 0,
93
+ include_excerpt: bool = False,
94
+ excerpt_chars: Optional[int] = None,
95
+ ) -> SearchResponse:
96
+ body = C.build_search_body(
97
+ query,
98
+ count=count,
99
+ freshness=freshness,
100
+ include_domains=include_domains,
101
+ exclude_domains=exclude_domains,
102
+ start_date=start_date,
103
+ end_date=end_date,
104
+ language=language,
105
+ offset=offset,
106
+ include_excerpt=include_excerpt,
107
+ excerpt_chars=excerpt_chars,
108
+ )
109
+ return SearchResponse.from_dict(await self._post("/v1/search", body))
110
+
111
+ async def fetch(
112
+ self, url_or_urls: Union[str, Sequence[str]]
113
+ ) -> Union[FetchResult, BatchFetchResponse]:
114
+ is_batch, urls, single = C.normalize_fetch_urls(url_or_urls)
115
+ if not is_batch:
116
+ data = await self._post("/v1/fetch", {"url": single})
117
+ return FetchResult.from_dict(data)
118
+ return await self._fetch_batch(urls)
119
+
120
+ async def _fetch_batch(self, urls: Sequence[str]) -> BatchFetchResponse:
121
+ chunks = C.chunk_urls(urls)
122
+ merged_results: list[FetchResult] = []
123
+ merged_failed: list[FetchFailure] = []
124
+ remaining: Optional[int] = None
125
+ for i, chunk in enumerate(chunks):
126
+ if i > 0 and self.chunk_delay > 0:
127
+ await asyncio.sleep(self.chunk_delay)
128
+ data = await self._post("/v1/fetch", {"urls": list(chunk)})
129
+ part = BatchFetchResponse.from_dict(data)
130
+ merged_results.extend(part.results)
131
+ merged_failed.extend(part.failed_results)
132
+ if part.remaining_quota is not None:
133
+ remaining = part.remaining_quota
134
+ return BatchFetchResponse(
135
+ results=merged_results,
136
+ failed_results=merged_failed,
137
+ count=len(merged_results),
138
+ remaining_quota=remaining,
139
+ )
blopus/_common.py ADDED
@@ -0,0 +1,155 @@
1
+ """Shared, transport-agnostic helpers used by both the sync and async clients.
2
+
3
+ Keeping request-building, response-parsing, retry policy and error mapping here
4
+ guarantees the two clients behave identically — the only difference between them
5
+ is ``httpx.Client`` vs ``httpx.AsyncClient``.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import random
11
+ from typing import Any, Dict, List, Optional, Sequence, Union
12
+
13
+ from ._version import __version__
14
+ from .exceptions import BlopusError, error_from_response
15
+
16
+ DEFAULT_BASE_URL = "https://api.blopus.ai"
17
+ DEFAULT_MCP_URL = "https://mcp.blopus.ai"
18
+ DEFAULT_TIMEOUT = 30.0
19
+ DEFAULT_MAX_RETRIES = 2
20
+ ENV_API_KEY = "BLOPUS_API_KEY"
21
+ ENV_BASE_URL = "BLOPUS_BASE_URL"
22
+
23
+ # Server-enforced cap: at most 50 URLs per /v1/fetch call.
24
+ BATCH_URL_LIMIT = 50
25
+ # Small courtesy delay between auto-chunked batches to respect per-second limits.
26
+ DEFAULT_CHUNK_DELAY = 0.25
27
+
28
+ # A *real* User-Agent is mandatory: Cloudflare 1010-blocks default library UAs
29
+ # (python-httpx/..., node-fetch, etc.) on api.blopus.ai.
30
+ USER_AGENT = f"blopus-python/{__version__}"
31
+
32
+ # Retry these HTTP statuses with backoff (transient / rate-limited).
33
+ RETRY_STATUSES = frozenset({429, 500, 502, 503, 504})
34
+
35
+
36
+ def resolve_api_key(api_key: Optional[str]) -> str:
37
+ key = api_key or os.environ.get(ENV_API_KEY)
38
+ if not key:
39
+ raise BlopusError(
40
+ "No API key provided. Pass api_key=... or set the "
41
+ f"{ENV_API_KEY} environment variable.",
42
+ code="no_api_key",
43
+ )
44
+ return key
45
+
46
+
47
+ def resolve_base_url(base_url: Optional[str]) -> str:
48
+ return (base_url or os.environ.get(ENV_BASE_URL) or DEFAULT_BASE_URL).rstrip("/")
49
+
50
+
51
+ def build_headers(api_key: str, user_agent: str = USER_AGENT) -> Dict[str, str]:
52
+ return {
53
+ "Authorization": f"Bearer {api_key}",
54
+ "User-Agent": user_agent,
55
+ "Content-Type": "application/json",
56
+ "Accept": "application/json",
57
+ }
58
+
59
+
60
+ def build_search_body(
61
+ query: str,
62
+ *,
63
+ count: int = 10,
64
+ freshness: str = "all",
65
+ include_domains: Optional[Sequence[str]] = None,
66
+ exclude_domains: Optional[Sequence[str]] = None,
67
+ start_date: Optional[str] = None,
68
+ end_date: Optional[str] = None,
69
+ language: Optional[str] = None,
70
+ offset: int = 0,
71
+ include_excerpt: bool = False,
72
+ excerpt_chars: Optional[int] = None,
73
+ ) -> Dict[str, Any]:
74
+ """Assemble the JSON body for ``POST /v1/search`` (omitting unset optionals)."""
75
+ body: Dict[str, Any] = {
76
+ "query": query,
77
+ "count": count,
78
+ "freshness": freshness,
79
+ "offset": offset,
80
+ }
81
+ if include_domains:
82
+ body["include_domains"] = list(include_domains)
83
+ if exclude_domains:
84
+ body["exclude_domains"] = list(exclude_domains)
85
+ if start_date is not None:
86
+ body["start_date"] = start_date
87
+ if end_date is not None:
88
+ body["end_date"] = end_date
89
+ if language is not None:
90
+ body["language"] = language
91
+ if include_excerpt:
92
+ body["include_excerpt"] = True
93
+ if excerpt_chars is not None:
94
+ body["excerpt_chars"] = excerpt_chars
95
+ return body
96
+
97
+
98
+ def normalize_fetch_urls(
99
+ url_or_urls: Union[str, Sequence[str]],
100
+ ) -> tuple[bool, List[str], Optional[str]]:
101
+ """Return (is_batch, url_list, single_url).
102
+
103
+ A single string is a single fetch; any sequence is a batch (even length 1).
104
+ """
105
+ if isinstance(url_or_urls, str):
106
+ return False, [url_or_urls], url_or_urls
107
+ urls = [str(u) for u in url_or_urls]
108
+ if not urls:
109
+ raise BlopusError("fetch() requires at least one URL.", code="bad_request")
110
+ return True, urls, None
111
+
112
+
113
+ def chunk_urls(urls: Sequence[str], size: int = BATCH_URL_LIMIT) -> List[List[str]]:
114
+ return [list(urls[i : i + size]) for i in range(0, len(urls), size)]
115
+
116
+
117
+ def parse_json_or_raise(status: int, text: str, retry_after: Optional[int]) -> Dict[str, Any]:
118
+ """Parse a response body; raise a typed error for non-2xx statuses."""
119
+ import json
120
+
121
+ body: Optional[Dict[str, Any]]
122
+ try:
123
+ body = json.loads(text) if text else None
124
+ except (ValueError, TypeError):
125
+ body = None
126
+ if 200 <= status < 300:
127
+ if not isinstance(body, dict):
128
+ raise error_from_response(status, {"error": {"code": "invalid_response",
129
+ "message": "Malformed JSON from API."}})
130
+ return body
131
+ raise error_from_response(status, body, retry_after=retry_after)
132
+
133
+
134
+ def parse_retry_after(value: Optional[str]) -> Optional[int]:
135
+ if not value:
136
+ return None
137
+ try:
138
+ return max(0, int(float(value)))
139
+ except (TypeError, ValueError):
140
+ return None
141
+
142
+
143
+ def backoff_seconds(attempt: int, retry_after: Optional[int]) -> float:
144
+ """Exponential backoff with jitter; honor server Retry-After when present."""
145
+ if retry_after is not None:
146
+ return float(retry_after)
147
+ return min(8.0, (2 ** attempt) * 0.5) + random.uniform(0, 0.25)
148
+
149
+
150
+ def should_retry(status: Optional[int], attempt: int, max_retries: int) -> bool:
151
+ if attempt >= max_retries:
152
+ return False
153
+ if status is None: # connection error
154
+ return True
155
+ return status in RETRY_STATUSES
blopus/_version.py ADDED
@@ -0,0 +1,8 @@
1
+ """Single source of truth for the package version.
2
+
3
+ Bump this on every release (see PUBLISH.md). It also feeds the User-Agent
4
+ header, so a correct value here is what keeps Cloudflare from 1010-blocking
5
+ requests as an unidentified library.
6
+ """
7
+
8
+ __version__ = "0.1.0"
blopus/cli.py ADDED
@@ -0,0 +1,136 @@
1
+ """Command-line interface for Blopus.
2
+
3
+ blopus search "who won the game" --count 5 --freshness pd
4
+ blopus search "openai" --include-domains techcrunch.com,theverge.com --json
5
+ blopus fetch https://example.com/a https://example.com/b
6
+ blopus mcp-config
7
+
8
+ Reads the API key from --api-key or the BLOPUS_API_KEY environment variable.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import json
14
+ import sys
15
+ from typing import List, Optional
16
+
17
+ from ._version import __version__
18
+ from .client import Blopus
19
+ from .exceptions import BlopusError
20
+ from .mcp import mcp_config
21
+ from .models import BatchFetchResponse
22
+
23
+
24
+ def _csv(value: Optional[str]) -> Optional[List[str]]:
25
+ if not value:
26
+ return None
27
+ return [v.strip() for v in value.split(",") if v.strip()]
28
+
29
+
30
+ def _cmd_search(args: argparse.Namespace) -> int:
31
+ client = Blopus(api_key=args.api_key, base_url=args.base_url)
32
+ res = client.search(
33
+ args.query,
34
+ count=args.count,
35
+ freshness=args.freshness,
36
+ include_domains=_csv(args.include_domains),
37
+ exclude_domains=_csv(args.exclude_domains),
38
+ language=args.language,
39
+ offset=args.offset,
40
+ include_excerpt=args.include_excerpt,
41
+ )
42
+ if args.json:
43
+ print(json.dumps(res.raw, indent=2, ensure_ascii=False))
44
+ else:
45
+ if not res.results:
46
+ print("No results.")
47
+ for i, r in enumerate(res.results, 1):
48
+ print(f"{i}. {r.title}")
49
+ print(f" {r.url}")
50
+ if r.snippet:
51
+ print(f" {r.snippet}")
52
+ print()
53
+ if res.remaining_quota is not None:
54
+ print(f"[remaining quota: {res.remaining_quota}]", file=sys.stderr)
55
+ return 0
56
+
57
+
58
+ def _cmd_fetch(args: argparse.Namespace) -> int:
59
+ client = Blopus(api_key=args.api_key, base_url=args.base_url)
60
+ result = client.fetch(args.urls if len(args.urls) > 1 else args.urls[0])
61
+ if args.json:
62
+ raw = result.raw if not isinstance(result, BatchFetchResponse) else {
63
+ "results": [r.raw for r in result.results],
64
+ "failed_results": [f.raw for f in result.failed_results],
65
+ "count": result.count,
66
+ "remaining_quota": result.remaining_quota,
67
+ }
68
+ print(json.dumps(raw, indent=2, ensure_ascii=False))
69
+ return 0
70
+ if isinstance(result, BatchFetchResponse):
71
+ for r in result.results:
72
+ print(f"# {r.title}\n{r.url}\n{r.content[:2000]}\n")
73
+ if result.failed_results:
74
+ print("Not found:", ", ".join(f.url for f in result.failed_results),
75
+ file=sys.stderr)
76
+ else:
77
+ print(f"# {result.title}\n{result.url}\n\n{result.content}")
78
+ return 0
79
+
80
+
81
+ def _cmd_mcp_config(args: argparse.Namespace) -> int:
82
+ print(json.dumps(mcp_config(args.api_key), indent=2))
83
+ return 0
84
+
85
+
86
+ def build_parser() -> argparse.ArgumentParser:
87
+ p = argparse.ArgumentParser(prog="blopus", description="Blopus web search + fetch API.")
88
+ p.add_argument("--version", action="version", version=f"blopus {__version__}")
89
+
90
+ # Shared auth options are attached to each subcommand (via a parent parser) so
91
+ # they work in the natural position, e.g. `blopus search "q" --api-key ...`.
92
+ common = argparse.ArgumentParser(add_help=False)
93
+ common.add_argument("--api-key", default=None, help="API key (or set BLOPUS_API_KEY).")
94
+ common.add_argument("--base-url", default=None, help="Override API base URL.")
95
+
96
+ sub = p.add_subparsers(dest="command", required=True)
97
+
98
+ s = sub.add_parser("search", help="Run a web search.", parents=[common])
99
+ s.add_argument("query")
100
+ s.add_argument("--count", type=int, default=10)
101
+ s.add_argument("--freshness", default="all",
102
+ choices=["pd", "pw", "pm", "p3m", "p1y", "all"])
103
+ s.add_argument("--include-domains", default=None, help="Comma-separated allowlist.")
104
+ s.add_argument("--exclude-domains", default=None, help="Comma-separated blocklist.")
105
+ s.add_argument("--language", default=None, help="Language code, e.g. en.")
106
+ s.add_argument("--offset", type=int, default=0)
107
+ s.add_argument("--include-excerpt", action="store_true", help="Request longer excerpts.")
108
+ s.add_argument("--json", action="store_true", help="Emit raw JSON.")
109
+ s.set_defaults(func=_cmd_search)
110
+
111
+ f = sub.add_parser("fetch", help="Fetch indexed content for one or more URLs.",
112
+ parents=[common])
113
+ f.add_argument("urls", nargs="+")
114
+ f.add_argument("--json", action="store_true", help="Emit raw JSON.")
115
+ f.set_defaults(func=_cmd_fetch)
116
+
117
+ m = sub.add_parser("mcp-config", help="Print the MCP server config JSON.",
118
+ parents=[common])
119
+ m.set_defaults(func=_cmd_mcp_config)
120
+ return p
121
+
122
+
123
+ def main(argv: Optional[List[str]] = None) -> int:
124
+ parser = build_parser()
125
+ args = parser.parse_args(argv)
126
+ try:
127
+ return args.func(args)
128
+ except BlopusError as exc:
129
+ print(f"Error: {exc}", file=sys.stderr)
130
+ return 1
131
+ except KeyboardInterrupt: # pragma: no cover
132
+ return 130
133
+
134
+
135
+ if __name__ == "__main__": # pragma: no cover
136
+ raise SystemExit(main())
blopus/client.py ADDED
@@ -0,0 +1,158 @@
1
+ """Synchronous Blopus client.
2
+
3
+ from blopus import Blopus
4
+
5
+ client = Blopus() # reads BLOPUS_API_KEY
6
+ res = client.search("who won the game last night", freshness="pd")
7
+ for hit in res:
8
+ print(hit.title, hit.url)
9
+
10
+ doc = client.fetch("https://example.com/article")
11
+ batch = client.fetch(["https://a.com", "https://b.com"]) # auto-chunks > 50
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import time
16
+ from typing import Optional, Sequence, Union
17
+
18
+ import httpx
19
+
20
+ from . import _common as C
21
+ from .exceptions import APIConnectionError, BlopusError
22
+ from .models import BatchFetchResponse, FetchFailure, FetchResult, SearchResponse
23
+
24
+
25
+ class Blopus:
26
+ """Synchronous client for the Blopus search + fetch API.
27
+
28
+ Args:
29
+ api_key: ``blp_live_...`` key. Falls back to ``$BLOPUS_API_KEY``.
30
+ base_url: API origin. Defaults to ``https://api.blopus.ai``.
31
+ timeout: Per-request timeout in seconds.
32
+ max_retries: Retries on 429 / 5xx / connection errors (honors Retry-After).
33
+ chunk_delay: Delay between auto-chunked batch-fetch calls (seconds).
34
+ """
35
+
36
+ def __init__(
37
+ self,
38
+ api_key: Optional[str] = None,
39
+ *,
40
+ base_url: Optional[str] = None,
41
+ timeout: float = C.DEFAULT_TIMEOUT,
42
+ max_retries: int = C.DEFAULT_MAX_RETRIES,
43
+ chunk_delay: float = C.DEFAULT_CHUNK_DELAY,
44
+ user_agent: str = C.USER_AGENT,
45
+ ) -> None:
46
+ self._api_key = C.resolve_api_key(api_key)
47
+ self.base_url = C.resolve_base_url(base_url)
48
+ self.max_retries = max_retries
49
+ self.chunk_delay = chunk_delay
50
+ self._http = httpx.Client(
51
+ base_url=self.base_url,
52
+ timeout=timeout,
53
+ headers=C.build_headers(self._api_key, user_agent),
54
+ )
55
+
56
+ # -- lifecycle ---------------------------------------------------------- #
57
+ def close(self) -> None:
58
+ self._http.close()
59
+
60
+ def __enter__(self) -> "Blopus":
61
+ return self
62
+
63
+ def __exit__(self, *exc) -> None:
64
+ self.close()
65
+
66
+ # -- transport ---------------------------------------------------------- #
67
+ def _post(self, path: str, body: dict) -> dict:
68
+ attempt = 0
69
+ while True:
70
+ status: Optional[int] = None
71
+ retry_after: Optional[int] = None
72
+ try:
73
+ resp = self._http.post(path, json=body)
74
+ status = resp.status_code
75
+ retry_after = C.parse_retry_after(resp.headers.get("Retry-After"))
76
+ if 200 <= status < 300:
77
+ return C.parse_json_or_raise(status, resp.text, retry_after)
78
+ except httpx.HTTPError as exc:
79
+ if not C.should_retry(None, attempt, self.max_retries):
80
+ raise APIConnectionError(f"Request failed: {exc}") from exc
81
+ time.sleep(C.backoff_seconds(attempt, None))
82
+ attempt += 1
83
+ continue
84
+
85
+ if C.should_retry(status, attempt, self.max_retries):
86
+ time.sleep(C.backoff_seconds(attempt, retry_after))
87
+ attempt += 1
88
+ continue
89
+ # non-retryable (or out of retries): raise a typed error
90
+ return C.parse_json_or_raise(status, resp.text, retry_after)
91
+
92
+ # -- API ---------------------------------------------------------------- #
93
+ def search(
94
+ self,
95
+ query: str,
96
+ *,
97
+ count: int = 10,
98
+ freshness: str = "all",
99
+ include_domains: Optional[Sequence[str]] = None,
100
+ exclude_domains: Optional[Sequence[str]] = None,
101
+ start_date: Optional[str] = None,
102
+ end_date: Optional[str] = None,
103
+ language: Optional[str] = None,
104
+ offset: int = 0,
105
+ include_excerpt: bool = False,
106
+ excerpt_chars: Optional[int] = None,
107
+ ) -> SearchResponse:
108
+ """Run a web search. Always costs 1 credit regardless of params."""
109
+ body = C.build_search_body(
110
+ query,
111
+ count=count,
112
+ freshness=freshness,
113
+ include_domains=include_domains,
114
+ exclude_domains=exclude_domains,
115
+ start_date=start_date,
116
+ end_date=end_date,
117
+ language=language,
118
+ offset=offset,
119
+ include_excerpt=include_excerpt,
120
+ excerpt_chars=excerpt_chars,
121
+ )
122
+ return SearchResponse.from_dict(self._post("/v1/search", body))
123
+
124
+ def fetch(
125
+ self, url_or_urls: Union[str, Sequence[str]]
126
+ ) -> Union[FetchResult, BatchFetchResponse]:
127
+ """Fetch indexed page content.
128
+
129
+ A single URL string returns a :class:`FetchResult`. A list returns a
130
+ :class:`BatchFetchResponse`. Lists longer than 50 are auto-chunked into
131
+ ≤50-URL calls, run sequentially with a small delay, and merged.
132
+ """
133
+ is_batch, urls, single = C.normalize_fetch_urls(url_or_urls)
134
+ if not is_batch:
135
+ data = self._post("/v1/fetch", {"url": single})
136
+ return FetchResult.from_dict(data)
137
+ return self._fetch_batch(urls)
138
+
139
+ def _fetch_batch(self, urls: Sequence[str]) -> BatchFetchResponse:
140
+ chunks = C.chunk_urls(urls)
141
+ merged_results: list[FetchResult] = []
142
+ merged_failed: list[FetchFailure] = []
143
+ remaining: Optional[int] = None
144
+ for i, chunk in enumerate(chunks):
145
+ if i > 0 and self.chunk_delay > 0:
146
+ time.sleep(self.chunk_delay)
147
+ data = self._post("/v1/fetch", {"urls": list(chunk)})
148
+ part = BatchFetchResponse.from_dict(data)
149
+ merged_results.extend(part.results)
150
+ merged_failed.extend(part.failed_results)
151
+ if part.remaining_quota is not None:
152
+ remaining = part.remaining_quota
153
+ return BatchFetchResponse(
154
+ results=merged_results,
155
+ failed_results=merged_failed,
156
+ count=len(merged_results),
157
+ remaining_quota=remaining,
158
+ )