blopus 0.1.0__tar.gz
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-0.1.0/LICENSE +21 -0
- blopus-0.1.0/PKG-INFO +192 -0
- blopus-0.1.0/README.md +157 -0
- blopus-0.1.0/blopus/__init__.py +61 -0
- blopus-0.1.0/blopus/_async.py +139 -0
- blopus-0.1.0/blopus/_common.py +155 -0
- blopus-0.1.0/blopus/_version.py +8 -0
- blopus-0.1.0/blopus/cli.py +136 -0
- blopus-0.1.0/blopus/client.py +158 -0
- blopus-0.1.0/blopus/crewai.py +62 -0
- blopus-0.1.0/blopus/exceptions.py +130 -0
- blopus-0.1.0/blopus/langchain.py +59 -0
- blopus-0.1.0/blopus/llamaindex.py +67 -0
- blopus-0.1.0/blopus/mcp.py +37 -0
- blopus-0.1.0/blopus/models.py +166 -0
- blopus-0.1.0/blopus.egg-info/PKG-INFO +192 -0
- blopus-0.1.0/blopus.egg-info/SOURCES.txt +24 -0
- blopus-0.1.0/blopus.egg-info/dependency_links.txt +1 -0
- blopus-0.1.0/blopus.egg-info/entry_points.txt +2 -0
- blopus-0.1.0/blopus.egg-info/requires.txt +14 -0
- blopus-0.1.0/blopus.egg-info/top_level.txt +1 -0
- blopus-0.1.0/pyproject.toml +46 -0
- blopus-0.1.0/setup.cfg +4 -0
- blopus-0.1.0/tests/test_client.py +110 -0
- blopus-0.1.0/tests/test_common.py +105 -0
- blopus-0.1.0/tests/test_mcp.py +10 -0
blopus-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Blopus
|
|
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.
|
blopus-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: blopus
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python SDK for the Blopus web search + fetch API.
|
|
5
|
+
Author: Blopus
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://blopus.ai
|
|
8
|
+
Project-URL: Documentation, https://docs.blopus.ai
|
|
9
|
+
Project-URL: Source, https://github.com/blopus-ai/blopus-python
|
|
10
|
+
Keywords: blopus,search,web-search,fetch,api,sdk,agents,mcp
|
|
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.8
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: Indexing/Search
|
|
21
|
+
Requires-Python: >=3.8
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
License-File: LICENSE
|
|
24
|
+
Requires-Dist: httpx>=0.23
|
|
25
|
+
Provides-Extra: langchain
|
|
26
|
+
Requires-Dist: langchain-core>=0.1; extra == "langchain"
|
|
27
|
+
Provides-Extra: llamaindex
|
|
28
|
+
Requires-Dist: llama-index-core>=0.10; extra == "llamaindex"
|
|
29
|
+
Provides-Extra: crewai
|
|
30
|
+
Requires-Dist: crewai>=0.1; extra == "crewai"
|
|
31
|
+
Provides-Extra: dev
|
|
32
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
33
|
+
Requires-Dist: respx>=0.20; extra == "dev"
|
|
34
|
+
Dynamic: license-file
|
|
35
|
+
|
|
36
|
+
# blopus — Python SDK
|
|
37
|
+
|
|
38
|
+
Official Python client for the [Blopus](https://blopus.ai) web search + fetch API.
|
|
39
|
+
Blopus is a cheap, fast web-search API backed by an owned index — built for bots
|
|
40
|
+
and agents.
|
|
41
|
+
|
|
42
|
+
The SDK talks to exactly two data-plane endpoints: `POST /v1/search` and
|
|
43
|
+
`POST /v1/fetch` on `https://api.blopus.ai`.
|
|
44
|
+
|
|
45
|
+
## Install
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pip install blopus
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Optional framework adapters:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
pip install "blopus[langchain]" # blopus.langchain.BlopusSearch
|
|
55
|
+
pip install "blopus[llamaindex]" # blopus.llamaindex.BlopusToolSpec
|
|
56
|
+
pip install "blopus[crewai]" # blopus.crewai.BlopusSearchTool
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Authentication
|
|
60
|
+
|
|
61
|
+
Pass an API key (`blp_live_...`) directly, or set `BLOPUS_API_KEY`:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
export BLOPUS_API_KEY="blp_live_xxx"
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Quickstart
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
from blopus import Blopus
|
|
71
|
+
|
|
72
|
+
client = Blopus() # reads BLOPUS_API_KEY
|
|
73
|
+
|
|
74
|
+
res = client.search("who won the game last night", count=5, freshness="pd")
|
|
75
|
+
for hit in res:
|
|
76
|
+
print(hit.score, hit.title, hit.url)
|
|
77
|
+
print("remaining quota:", res.remaining_quota)
|
|
78
|
+
|
|
79
|
+
# Fetch indexed page content
|
|
80
|
+
doc = client.fetch("https://example.com/article")
|
|
81
|
+
print(doc.title, len(doc.content))
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### Async
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
import asyncio
|
|
88
|
+
from blopus import AsyncBlopus
|
|
89
|
+
|
|
90
|
+
async def main():
|
|
91
|
+
async with AsyncBlopus() as client:
|
|
92
|
+
res = await client.search("openai news", freshness="pw")
|
|
93
|
+
docs = await client.fetch([r.url for r in res]) # batch fetch
|
|
94
|
+
print(docs.count, "fetched,", len(docs.failed_results), "missing")
|
|
95
|
+
|
|
96
|
+
asyncio.run(main())
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## `search(...)`
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
client.search(
|
|
103
|
+
query,
|
|
104
|
+
count=10, # 1..50
|
|
105
|
+
freshness="all", # pd | pw | pm | p3m | p1y | all
|
|
106
|
+
include_domains=None, # ["techcrunch.com", ...]
|
|
107
|
+
exclude_domains=None,
|
|
108
|
+
start_date=None, # "YYYY-MM-DD" or epoch seconds
|
|
109
|
+
end_date=None,
|
|
110
|
+
language=None, # "en", "pt", ...
|
|
111
|
+
offset=0, # pagination, up to 200
|
|
112
|
+
include_excerpt=False, # opt in to longer excerpts
|
|
113
|
+
excerpt_chars=None, # up to 1200
|
|
114
|
+
)
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Returns a `SearchResponse` (iterable over `SearchResult`):
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
res.query # echoed query
|
|
121
|
+
res.results # list[SearchResult]
|
|
122
|
+
res.count # number of results returned
|
|
123
|
+
res.offset
|
|
124
|
+
res.more_results # bool — more pages available
|
|
125
|
+
res.remaining_quota # int — your remaining monthly units
|
|
126
|
+
|
|
127
|
+
# SearchResult fields:
|
|
128
|
+
# title, url, snippet, domain, site_name, favicon,
|
|
129
|
+
# published_at, age_seconds, language, score
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Search always costs **1 credit**, regardless of parameters or excerpt size.
|
|
133
|
+
|
|
134
|
+
## `fetch(url_or_urls)`
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
# single URL -> FetchResult
|
|
138
|
+
doc = client.fetch("https://example.com/a")
|
|
139
|
+
doc.url, doc.canonical_url, doc.title, doc.content, doc.domain, doc.published_at, doc.language, doc.found
|
|
140
|
+
|
|
141
|
+
# list of URLs -> BatchFetchResponse
|
|
142
|
+
batch = client.fetch(["https://a.com", "https://b.com"])
|
|
143
|
+
batch.results # list[FetchResult] that were found
|
|
144
|
+
batch.failed_results # list[FetchFailure] (url, found=False)
|
|
145
|
+
batch.count # number found (== credits billed)
|
|
146
|
+
batch.remaining_quota
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Batches over the server cap of **50 URLs** are automatically split into ≤50-URL
|
|
150
|
+
calls, run sequentially with a small delay, and merged for you. Fetch bills per
|
|
151
|
+
document found.
|
|
152
|
+
|
|
153
|
+
## Errors
|
|
154
|
+
|
|
155
|
+
All errors subclass `blopus.BlopusError`:
|
|
156
|
+
|
|
157
|
+
| Exception | When |
|
|
158
|
+
|----------------------|----------------------------------------|
|
|
159
|
+
| `AuthError` | 401 / 403 — bad, missing or revoked key |
|
|
160
|
+
| `QuotaError` | 402 — monthly quota exhausted |
|
|
161
|
+
| `RateLimitError` | 429 — slow down (`.retry_after`) |
|
|
162
|
+
| `NotFoundError` | 404 — no indexed content for a URL |
|
|
163
|
+
| `BadRequestError` | 400 / 413 — malformed / too large |
|
|
164
|
+
| `ServerError` | 5xx — gateway/backend problem |
|
|
165
|
+
| `APIConnectionError` | network failure (no response) |
|
|
166
|
+
|
|
167
|
+
Requests to `429`/`5xx`/connection errors are retried with exponential backoff
|
|
168
|
+
(honoring `Retry-After`); tune with `Blopus(max_retries=...)`.
|
|
169
|
+
|
|
170
|
+
## MCP
|
|
171
|
+
|
|
172
|
+
Blopus also exposes a hosted MCP server (`search` + `fetch` tools) at
|
|
173
|
+
`https://mcp.blopus.ai`, using the same Bearer auth.
|
|
174
|
+
|
|
175
|
+
```python
|
|
176
|
+
from blopus import mcp_config, print_mcp_config
|
|
177
|
+
print_mcp_config() # prints the mcpServers JSON to paste into your MCP client
|
|
178
|
+
cfg = mcp_config() # or get it as a dict
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
## CLI
|
|
182
|
+
|
|
183
|
+
```bash
|
|
184
|
+
blopus search "who won the game" --count 5 --freshness pd
|
|
185
|
+
blopus search "openai" --include-domains techcrunch.com,theverge.com --json
|
|
186
|
+
blopus fetch https://example.com/a https://example.com/b
|
|
187
|
+
blopus mcp-config
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
## License
|
|
191
|
+
|
|
192
|
+
MIT
|
blopus-0.1.0/README.md
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# blopus — Python SDK
|
|
2
|
+
|
|
3
|
+
Official Python client for the [Blopus](https://blopus.ai) web search + fetch API.
|
|
4
|
+
Blopus is a cheap, fast web-search API backed by an owned index — built for bots
|
|
5
|
+
and agents.
|
|
6
|
+
|
|
7
|
+
The SDK talks to exactly two data-plane endpoints: `POST /v1/search` and
|
|
8
|
+
`POST /v1/fetch` on `https://api.blopus.ai`.
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pip install blopus
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Optional framework adapters:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pip install "blopus[langchain]" # blopus.langchain.BlopusSearch
|
|
20
|
+
pip install "blopus[llamaindex]" # blopus.llamaindex.BlopusToolSpec
|
|
21
|
+
pip install "blopus[crewai]" # blopus.crewai.BlopusSearchTool
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Authentication
|
|
25
|
+
|
|
26
|
+
Pass an API key (`blp_live_...`) directly, or set `BLOPUS_API_KEY`:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
export BLOPUS_API_KEY="blp_live_xxx"
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Quickstart
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from blopus import Blopus
|
|
36
|
+
|
|
37
|
+
client = Blopus() # reads BLOPUS_API_KEY
|
|
38
|
+
|
|
39
|
+
res = client.search("who won the game last night", count=5, freshness="pd")
|
|
40
|
+
for hit in res:
|
|
41
|
+
print(hit.score, hit.title, hit.url)
|
|
42
|
+
print("remaining quota:", res.remaining_quota)
|
|
43
|
+
|
|
44
|
+
# Fetch indexed page content
|
|
45
|
+
doc = client.fetch("https://example.com/article")
|
|
46
|
+
print(doc.title, len(doc.content))
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### Async
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
import asyncio
|
|
53
|
+
from blopus import AsyncBlopus
|
|
54
|
+
|
|
55
|
+
async def main():
|
|
56
|
+
async with AsyncBlopus() as client:
|
|
57
|
+
res = await client.search("openai news", freshness="pw")
|
|
58
|
+
docs = await client.fetch([r.url for r in res]) # batch fetch
|
|
59
|
+
print(docs.count, "fetched,", len(docs.failed_results), "missing")
|
|
60
|
+
|
|
61
|
+
asyncio.run(main())
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## `search(...)`
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
client.search(
|
|
68
|
+
query,
|
|
69
|
+
count=10, # 1..50
|
|
70
|
+
freshness="all", # pd | pw | pm | p3m | p1y | all
|
|
71
|
+
include_domains=None, # ["techcrunch.com", ...]
|
|
72
|
+
exclude_domains=None,
|
|
73
|
+
start_date=None, # "YYYY-MM-DD" or epoch seconds
|
|
74
|
+
end_date=None,
|
|
75
|
+
language=None, # "en", "pt", ...
|
|
76
|
+
offset=0, # pagination, up to 200
|
|
77
|
+
include_excerpt=False, # opt in to longer excerpts
|
|
78
|
+
excerpt_chars=None, # up to 1200
|
|
79
|
+
)
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Returns a `SearchResponse` (iterable over `SearchResult`):
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
res.query # echoed query
|
|
86
|
+
res.results # list[SearchResult]
|
|
87
|
+
res.count # number of results returned
|
|
88
|
+
res.offset
|
|
89
|
+
res.more_results # bool — more pages available
|
|
90
|
+
res.remaining_quota # int — your remaining monthly units
|
|
91
|
+
|
|
92
|
+
# SearchResult fields:
|
|
93
|
+
# title, url, snippet, domain, site_name, favicon,
|
|
94
|
+
# published_at, age_seconds, language, score
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Search always costs **1 credit**, regardless of parameters or excerpt size.
|
|
98
|
+
|
|
99
|
+
## `fetch(url_or_urls)`
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
# single URL -> FetchResult
|
|
103
|
+
doc = client.fetch("https://example.com/a")
|
|
104
|
+
doc.url, doc.canonical_url, doc.title, doc.content, doc.domain, doc.published_at, doc.language, doc.found
|
|
105
|
+
|
|
106
|
+
# list of URLs -> BatchFetchResponse
|
|
107
|
+
batch = client.fetch(["https://a.com", "https://b.com"])
|
|
108
|
+
batch.results # list[FetchResult] that were found
|
|
109
|
+
batch.failed_results # list[FetchFailure] (url, found=False)
|
|
110
|
+
batch.count # number found (== credits billed)
|
|
111
|
+
batch.remaining_quota
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Batches over the server cap of **50 URLs** are automatically split into ≤50-URL
|
|
115
|
+
calls, run sequentially with a small delay, and merged for you. Fetch bills per
|
|
116
|
+
document found.
|
|
117
|
+
|
|
118
|
+
## Errors
|
|
119
|
+
|
|
120
|
+
All errors subclass `blopus.BlopusError`:
|
|
121
|
+
|
|
122
|
+
| Exception | When |
|
|
123
|
+
|----------------------|----------------------------------------|
|
|
124
|
+
| `AuthError` | 401 / 403 — bad, missing or revoked key |
|
|
125
|
+
| `QuotaError` | 402 — monthly quota exhausted |
|
|
126
|
+
| `RateLimitError` | 429 — slow down (`.retry_after`) |
|
|
127
|
+
| `NotFoundError` | 404 — no indexed content for a URL |
|
|
128
|
+
| `BadRequestError` | 400 / 413 — malformed / too large |
|
|
129
|
+
| `ServerError` | 5xx — gateway/backend problem |
|
|
130
|
+
| `APIConnectionError` | network failure (no response) |
|
|
131
|
+
|
|
132
|
+
Requests to `429`/`5xx`/connection errors are retried with exponential backoff
|
|
133
|
+
(honoring `Retry-After`); tune with `Blopus(max_retries=...)`.
|
|
134
|
+
|
|
135
|
+
## MCP
|
|
136
|
+
|
|
137
|
+
Blopus also exposes a hosted MCP server (`search` + `fetch` tools) at
|
|
138
|
+
`https://mcp.blopus.ai`, using the same Bearer auth.
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
from blopus import mcp_config, print_mcp_config
|
|
142
|
+
print_mcp_config() # prints the mcpServers JSON to paste into your MCP client
|
|
143
|
+
cfg = mcp_config() # or get it as a dict
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## CLI
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
blopus search "who won the game" --count 5 --freshness pd
|
|
150
|
+
blopus search "openai" --include-domains techcrunch.com,theverge.com --json
|
|
151
|
+
blopus fetch https://example.com/a https://example.com/b
|
|
152
|
+
blopus mcp-config
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
## License
|
|
156
|
+
|
|
157
|
+
MIT
|
|
@@ -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
|
+
]
|
|
@@ -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
|
+
)
|