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 +61 -0
- blopus/_async.py +139 -0
- blopus/_common.py +155 -0
- blopus/_version.py +8 -0
- blopus/cli.py +136 -0
- blopus/client.py +158 -0
- blopus/crewai.py +62 -0
- blopus/exceptions.py +130 -0
- blopus/langchain.py +59 -0
- blopus/llamaindex.py +67 -0
- blopus/mcp.py +37 -0
- blopus/models.py +166 -0
- blopus-0.1.0.dist-info/METADATA +192 -0
- blopus-0.1.0.dist-info/RECORD +18 -0
- blopus-0.1.0.dist-info/WHEEL +5 -0
- blopus-0.1.0.dist-info/entry_points.txt +2 -0
- blopus-0.1.0.dist-info/licenses/LICENSE +21 -0
- blopus-0.1.0.dist-info/top_level.txt +1 -0
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
|
+
)
|