serpkite 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.
- serpkite-0.1.0/.gitignore +9 -0
- serpkite-0.1.0/LICENSE +21 -0
- serpkite-0.1.0/PKG-INFO +295 -0
- serpkite-0.1.0/README.md +263 -0
- serpkite-0.1.0/pyproject.toml +93 -0
- serpkite-0.1.0/src/serpkite/__init__.py +48 -0
- serpkite-0.1.0/src/serpkite/_base.py +203 -0
- serpkite-0.1.0/src/serpkite/_client.py +1614 -0
- serpkite-0.1.0/src/serpkite/_exceptions.py +169 -0
- serpkite-0.1.0/src/serpkite/_models.py +863 -0
- serpkite-0.1.0/src/serpkite/_version.py +1 -0
- serpkite-0.1.0/src/serpkite/crewai.py +133 -0
- serpkite-0.1.0/src/serpkite/py.typed +0 -0
- serpkite-0.1.0/src/serpkite/types.py +264 -0
- serpkite-0.1.0/src/serpkite/webhooks.py +49 -0
- serpkite-0.1.0/tests/__init__.py +0 -0
- serpkite-0.1.0/tests/conftest.py +31 -0
- serpkite-0.1.0/tests/helpers.py +44 -0
- serpkite-0.1.0/tests/test_batches_async.py +151 -0
- serpkite-0.1.0/tests/test_client.py +426 -0
- serpkite-0.1.0/tests/test_crewai.py +53 -0
- serpkite-0.1.0/tests/test_idempotency_webhooks.py +66 -0
- serpkite-0.1.0/tests/test_typing.py +47 -0
serpkite-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 SerpKite
|
|
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.
|
serpkite-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: serpkite
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python SDK for SerpKite, the Google search API built for AI agents: clean JSON or Markdown results.
|
|
5
|
+
Project-URL: Homepage, https://serpkite.com
|
|
6
|
+
Project-URL: Documentation, https://serpkite.com/docs/sdks/python
|
|
7
|
+
Project-URL: Repository, https://github.com/serpkite/serpkite-python
|
|
8
|
+
Author-email: SerpKite <support@serpkite.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: agents,ai,api,google,llm,markdown,search,serp,serpkite
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: Indexing/Search
|
|
23
|
+
Classifier: Typing :: Typed
|
|
24
|
+
Requires-Python: >=3.9
|
|
25
|
+
Requires-Dist: eval-type-backport>=0.2; python_version < '3.10'
|
|
26
|
+
Requires-Dist: httpx>=0.27
|
|
27
|
+
Requires-Dist: pydantic>=2.6
|
|
28
|
+
Requires-Dist: typing-extensions>=4.7
|
|
29
|
+
Provides-Extra: crewai
|
|
30
|
+
Requires-Dist: crewai>=0.80; (python_version >= '3.10') and extra == 'crewai'
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# serpkite
|
|
34
|
+
|
|
35
|
+
The official Python SDK for [SerpKite](https://serpkite.com), the Google search API built for AI
|
|
36
|
+
agents. You get clean JSON or Markdown, typed pydantic models, sync and asyncio clients, a native
|
|
37
|
+
CrewAI tool, and credits that never expire.
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install serpkite
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
It needs Python 3.9+ and depends only on `httpx` and `pydantic` v2.
|
|
44
|
+
|
|
45
|
+
## Quickstart
|
|
46
|
+
|
|
47
|
+
Create a key at [app.serpkite.com/keys](https://app.serpkite.com/keys) and export it:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
export SERPKITE_API_KEY=skt_live_...
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from serpkite import SerpKite
|
|
55
|
+
|
|
56
|
+
sk = SerpKite() # reads SERPKITE_API_KEY
|
|
57
|
+
res = sk.search("best espresso machine", country="us")
|
|
58
|
+
print(res.results[0].title, res.meta.credits_used)
|
|
59
|
+
|
|
60
|
+
md = sk.search("best espresso machine", format="markdown") # str, token-lean for LLMs
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Every response shares one envelope: `request` (the normalised request), `results` (the vertical's
|
|
64
|
+
primary list), vertical-specific extras (`ai_overview`, `answer_box`, `knowledge_graph`,
|
|
65
|
+
`people_also_ask`, `related_searches`, `top_stories`, `places`, `ads` on search) and `meta`
|
|
66
|
+
(`request_id`, `credits_used`, `cached`, `latency_ms`, …). All keys are snake_case.
|
|
67
|
+
|
|
68
|
+
Models keep unknown fields (`extra="allow"`), so new API fields never break an older SDK. You can
|
|
69
|
+
read them with `res.model_extra` or `res.model_dump()`.
|
|
70
|
+
|
|
71
|
+
## Client options
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
sk = SerpKite(
|
|
75
|
+
api_key="skt_live_...", # default: SERPKITE_API_KEY
|
|
76
|
+
base_url="https://api.serpkite.com", # default: SERPKITE_BASE_URL or the public API
|
|
77
|
+
timeout=60.0, # seconds or an httpx.Timeout
|
|
78
|
+
max_retries=2, # 429 / 5xx / connection errors
|
|
79
|
+
http_client=httpx.Client(proxy="http://..."), # optional, bring your own
|
|
80
|
+
default_headers={"X-Request-Id": "..."},
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
with SerpKite() as sk: # closes the HTTP client on exit
|
|
84
|
+
...
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Methods
|
|
88
|
+
|
|
89
|
+
Every query vertical takes `q` plus optional keyword parameters: `country`, `language`,
|
|
90
|
+
`location`, `uule`, `num`, `page`, `time` (`hour|day|week|month|year`), `tbs`, `device`, `safe`,
|
|
91
|
+
`autocorrect`, `ai_overview`, `ads`, `max_age`, `engine` (see
|
|
92
|
+
[Search engines & fallback](#search-engines--fallback)) and `include_content` (search only).
|
|
93
|
+
|
|
94
|
+
| Method | Endpoint | Returns |
|
|
95
|
+
| --- | --- | --- |
|
|
96
|
+
| `sk.search(q, **params)` | `/v1/search` | `SearchResponse` |
|
|
97
|
+
| `sk.images(q, **params)` | `/v1/images` | `ImagesResponse` |
|
|
98
|
+
| `sk.videos(q, **params)` | `/v1/videos` | `VideosResponse` |
|
|
99
|
+
| `sk.news(q, **params)` | `/v1/news` | `NewsResponse` |
|
|
100
|
+
| `sk.maps(q, ll="@40.7,-74.0,14z", **params)` | `/v1/maps` | `PlacesResponse` |
|
|
101
|
+
| `sk.places(q, **params)` | `/v1/places` | `PlacesResponse` |
|
|
102
|
+
| `sk.reviews(place_id=… \| cid=… \| fid=…, sort=, page_token=, num=)` | `/v1/reviews` | `ReviewsResponse` |
|
|
103
|
+
| `sk.shopping(q, **params)` | `/v1/shopping` | `ShoppingResponse` |
|
|
104
|
+
| `sk.scholar(q, **params)` | `/v1/scholar` | `ScholarResponse` |
|
|
105
|
+
| `sk.patents(q, **params)` | `/v1/patents` | `PatentsResponse` |
|
|
106
|
+
| `sk.autocomplete(q, **params)` | `/v1/autocomplete` | `AutocompleteResponse` |
|
|
107
|
+
| `sk.lens(url)` | `/v1/lens` | `LensResponse` |
|
|
108
|
+
| `sk.ai_mode(q, **params)` | `/v1/ai-mode` | `AIModeResponse` (`answer`, `markdown`, `results`) |
|
|
109
|
+
| `sk.webpage(url, include_html=False)` | `/v1/webpage` | `WebpageResponse` (`markdown`, `text`, `metadata`) |
|
|
110
|
+
| `sk.rank(q, domain, num=100)` | `/v1/rank` | `RankResponse` (`position` or `None`, `matches`) |
|
|
111
|
+
| `sk.account()` | `/v1/account` | `Account` (balance, limits, month usage) |
|
|
112
|
+
| `sk.batches.create / get / wait` | `/v1/batches` | see [Batches](#batches) |
|
|
113
|
+
| `sk.request(endpoint, params)` | `/v1/<endpoint>` | low-level: `str` for Markdown, else `dict` |
|
|
114
|
+
|
|
115
|
+
The output format changes the return type, and the overloads make type checkers follow it:
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
sk.search("q") # SearchResponse
|
|
119
|
+
sk.search("q", format="markdown") # str
|
|
120
|
+
sk.search("q", format="compact") # dict: lean results + meta
|
|
121
|
+
sk.search("q", fields="results.title,results.link,ai_overview") # dict: projected
|
|
122
|
+
sk.webpage("https://example.com", format="markdown") # str
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Some more examples:
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
# Top 5 results with their pages as Markdown (+1 credit per fetched page)
|
|
129
|
+
res = sk.search("vector databases comparison", include_content=5)
|
|
130
|
+
for r in res.results:
|
|
131
|
+
print(r.title, len(r.content or ""))
|
|
132
|
+
|
|
133
|
+
# Reviews, paged
|
|
134
|
+
page = sk.reviews(place_id="ChIJN1t_tDeuEmsRUsoyG83frY4", sort="newest")
|
|
135
|
+
nxt = sk.reviews(place_id="ChIJN1t_tDeuEmsRUsoyG83frY4", page_token=page.next_page_token)
|
|
136
|
+
|
|
137
|
+
# Where does example.com rank?
|
|
138
|
+
r = sk.rank("espresso machine", "example.com")
|
|
139
|
+
print(r.position, r.checked)
|
|
140
|
+
|
|
141
|
+
# Accept a cached result up to an hour old (half the credits on a hit)
|
|
142
|
+
sk.news("fed rate decision", max_age=3600)
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## Search engines & fallback
|
|
146
|
+
|
|
147
|
+
By default every request is answered by Google only (`engine="google"`); SerpKite already fails
|
|
148
|
+
over across its own proxy pools. Opt in to other providers with `engine`:
|
|
149
|
+
|
|
150
|
+
```python
|
|
151
|
+
# Fall back to other enabled providers when Google is blocked or times out
|
|
152
|
+
res = sk.search("best espresso machine", engine="auto")
|
|
153
|
+
print(res.meta.engine) # "google", or e.g. "brave" if Google was unavailable
|
|
154
|
+
for step in res.meta.route or []: # absent on cache hits
|
|
155
|
+
print(step.provider, step.outcome, step.ms) # google blocked 812 / brave ok 431
|
|
156
|
+
|
|
157
|
+
# Only these providers, in this order (any sequence of names works)
|
|
158
|
+
sk.news("espresso", engine=["google", "brave"])
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
- `engine` is `"google"` (default), `"auto"`, `"consensus"`, one provider (`"brave"`, `"bing"`,
|
|
162
|
+
`"yahoo"`, `"duckduckgo"`, `"mojeek"`, `"wikipedia"`) or a list. `"auto"` and `"consensus"` can't be combined
|
|
163
|
+
with other names; unknown names, or a provider that doesn't serve the endpoint, raise
|
|
164
|
+
`BadRequestError` (`invalid_request`).
|
|
165
|
+
- `engine="consensus"` (`search` only) asks several independent indexes in parallel, merges the
|
|
166
|
+
results by URL and ranks them by agreement: each result has `sources` (e.g.
|
|
167
|
+
`["google", "brave"]`), `meta.engine` is `"consensus"`, and it costs the sum of one page per
|
|
168
|
+
provider that returned results.
|
|
169
|
+
- `meta.engine` names the provider that answered; `meta.route` lists each attempt as a
|
|
170
|
+
`RouteStep` (`provider`, `outcome`, `ms`). `request.engine` echoes what you asked for.
|
|
171
|
+
- Credits (`meta.credits_used`) follow the answering provider's price.
|
|
172
|
+
|
|
173
|
+
## Async
|
|
174
|
+
|
|
175
|
+
`AsyncSerpKite` has the same surface, built on `httpx.AsyncClient`:
|
|
176
|
+
|
|
177
|
+
```python
|
|
178
|
+
import asyncio
|
|
179
|
+
from serpkite import AsyncSerpKite
|
|
180
|
+
|
|
181
|
+
async def main() -> None:
|
|
182
|
+
async with AsyncSerpKite() as sk:
|
|
183
|
+
res, news = await asyncio.gather(
|
|
184
|
+
sk.search("best espresso machine"),
|
|
185
|
+
sk.news("espresso prices"),
|
|
186
|
+
)
|
|
187
|
+
print(res.results[0].link, news.results[0].source)
|
|
188
|
+
|
|
189
|
+
asyncio.run(main())
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
## Errors
|
|
193
|
+
|
|
194
|
+
Every API error raises a `serpkite.SerpKiteError` with `status`, `code`, `message` and
|
|
195
|
+
`request_id`. The class depends on the HTTP status:
|
|
196
|
+
|
|
197
|
+
| Exception | Status | Typical `code` |
|
|
198
|
+
| --- | --- | --- |
|
|
199
|
+
| `BadRequestError` | 400 | `invalid_request` |
|
|
200
|
+
| `AuthenticationError` | 401 | `unauthorized` |
|
|
201
|
+
| `InsufficientCreditsError` | 402 | `insufficient_credits` |
|
|
202
|
+
| `PermissionDeniedError` | 403 | `spend_cap_reached`, `key_limit_reached`, `forbidden` |
|
|
203
|
+
| `NotFoundError` | 404 | `not_found` |
|
|
204
|
+
| `RateLimitError` | 429 | `rate_limited`, `daily_limit_reached` (has `.retry_after`) |
|
|
205
|
+
| `APIError` | 5xx | `upstream_error`, `upstream_blocked`, `upstream_timeout`, `unavailable` (all 503) |
|
|
206
|
+
| `APIConnectionError` / `APITimeoutError` | none | the request never got a response |
|
|
207
|
+
|
|
208
|
+
```python
|
|
209
|
+
import serpkite
|
|
210
|
+
|
|
211
|
+
try:
|
|
212
|
+
sk.search("espresso")
|
|
213
|
+
except serpkite.InsufficientCreditsError:
|
|
214
|
+
print("Top up at https://app.serpkite.com/billing")
|
|
215
|
+
except serpkite.SerpKiteError as e:
|
|
216
|
+
print(e.status, e.code, e.message, e.request_id)
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
Failed, empty and blocked searches are not billed. The client retries 429s, 5xx responses and
|
|
220
|
+
connection errors up to `max_retries` times. It uses exponential backoff with jitter and honours
|
|
221
|
+
`Retry-After`. It never retries `daily_limit_reached`. Batch creation is retried only when the
|
|
222
|
+
request provably never reached the server, unless you pass `idempotency_key`.
|
|
223
|
+
|
|
224
|
+
## Batches
|
|
225
|
+
|
|
226
|
+
Batches run at half price. You queue 1-100 requests for one endpoint, then poll for the results
|
|
227
|
+
or receive a signed webhook:
|
|
228
|
+
|
|
229
|
+
```python
|
|
230
|
+
job = sk.batches.create(
|
|
231
|
+
endpoint="search",
|
|
232
|
+
requests=[{"q": "a"}, {"q": "b", "country": "de"}],
|
|
233
|
+
webhook_url="https://example.com/hooks/serpkite", # optional
|
|
234
|
+
)
|
|
235
|
+
for entry in job.batches: # one entry per request, in order: Batch or ErrorResponse
|
|
236
|
+
if isinstance(entry, serpkite.types.Batch):
|
|
237
|
+
done = sk.batches.wait(str(entry.id), timeout=300, poll_interval=2)
|
|
238
|
+
if done.status == "done":
|
|
239
|
+
res = serpkite.types.SearchResponse.model_validate(done.result)
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
`batches.wait` raises `BatchTimeoutError` (a `TimeoutError`) when the timeout expires. Results
|
|
243
|
+
are kept for 24 hours.
|
|
244
|
+
|
|
245
|
+
Pass `idempotency_key=` (e.g. a UUID you store with the batch) to make `create` safe to retry: for
|
|
246
|
+
24 hours the server replays the first response for the same key and body, so the client then also
|
|
247
|
+
retries 5xx and connection errors.
|
|
248
|
+
|
|
249
|
+
Webhook deliveries are always signed. Check one with the raw body:
|
|
250
|
+
|
|
251
|
+
```python
|
|
252
|
+
from serpkite import verify_webhook
|
|
253
|
+
|
|
254
|
+
if not verify_webhook(os.environ["SERPKITE_WEBHOOK_SECRET"], request.get_data(), request.headers):
|
|
255
|
+
abort(401)
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
## CrewAI
|
|
259
|
+
|
|
260
|
+
```bash
|
|
261
|
+
pip install "serpkite[crewai]"
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
```python
|
|
265
|
+
from crewai import Agent
|
|
266
|
+
from serpkite.crewai import SerpKiteSearchTool
|
|
267
|
+
|
|
268
|
+
tool = SerpKiteSearchTool() # optional: endpoint="news", country="de", num=10, engine="auto"
|
|
269
|
+
agent = Agent(role="Researcher", goal="Find current facts", backstory="...", tools=[tool])
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
The tool returns Markdown, which uses far fewer tokens than JSON. The agent can set `query`,
|
|
273
|
+
`num`, `country`, `language` and `time`. `endpoint` can be `search`, `news`, `images`, `videos`,
|
|
274
|
+
`maps`, `places`, `shopping`, `scholar`, `patents`, `autocomplete` or `ai-mode`. `engine` (you set
|
|
275
|
+
it, not the agent) opts in to [fallback providers](#search-engines--fallback).
|
|
276
|
+
|
|
277
|
+
## LangChain
|
|
278
|
+
|
|
279
|
+
See [`langchain-serpkite`](https://pypi.org/project/langchain-serpkite/) for LangChain tools, a
|
|
280
|
+
retriever and a document loader.
|
|
281
|
+
|
|
282
|
+
## Development
|
|
283
|
+
|
|
284
|
+
```bash
|
|
285
|
+
uv sync
|
|
286
|
+
uv run pytest
|
|
287
|
+
uv run ruff check . && uv run ruff format --check .
|
|
288
|
+
uv run mypy
|
|
289
|
+
./scripts/gen_models.sh # regenerate src/serpkite/_models.py from backend/api/serp-api.yaml
|
|
290
|
+
uv run --with "crewai>=0.80" pytest tests/test_crewai.py # optional CrewAI tests
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
## License
|
|
294
|
+
|
|
295
|
+
MIT
|
serpkite-0.1.0/README.md
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
# serpkite
|
|
2
|
+
|
|
3
|
+
The official Python SDK for [SerpKite](https://serpkite.com), the Google search API built for AI
|
|
4
|
+
agents. You get clean JSON or Markdown, typed pydantic models, sync and asyncio clients, a native
|
|
5
|
+
CrewAI tool, and credits that never expire.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install serpkite
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
It needs Python 3.9+ and depends only on `httpx` and `pydantic` v2.
|
|
12
|
+
|
|
13
|
+
## Quickstart
|
|
14
|
+
|
|
15
|
+
Create a key at [app.serpkite.com/keys](https://app.serpkite.com/keys) and export it:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
export SERPKITE_API_KEY=skt_live_...
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
from serpkite import SerpKite
|
|
23
|
+
|
|
24
|
+
sk = SerpKite() # reads SERPKITE_API_KEY
|
|
25
|
+
res = sk.search("best espresso machine", country="us")
|
|
26
|
+
print(res.results[0].title, res.meta.credits_used)
|
|
27
|
+
|
|
28
|
+
md = sk.search("best espresso machine", format="markdown") # str, token-lean for LLMs
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Every response shares one envelope: `request` (the normalised request), `results` (the vertical's
|
|
32
|
+
primary list), vertical-specific extras (`ai_overview`, `answer_box`, `knowledge_graph`,
|
|
33
|
+
`people_also_ask`, `related_searches`, `top_stories`, `places`, `ads` on search) and `meta`
|
|
34
|
+
(`request_id`, `credits_used`, `cached`, `latency_ms`, …). All keys are snake_case.
|
|
35
|
+
|
|
36
|
+
Models keep unknown fields (`extra="allow"`), so new API fields never break an older SDK. You can
|
|
37
|
+
read them with `res.model_extra` or `res.model_dump()`.
|
|
38
|
+
|
|
39
|
+
## Client options
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
sk = SerpKite(
|
|
43
|
+
api_key="skt_live_...", # default: SERPKITE_API_KEY
|
|
44
|
+
base_url="https://api.serpkite.com", # default: SERPKITE_BASE_URL or the public API
|
|
45
|
+
timeout=60.0, # seconds or an httpx.Timeout
|
|
46
|
+
max_retries=2, # 429 / 5xx / connection errors
|
|
47
|
+
http_client=httpx.Client(proxy="http://..."), # optional, bring your own
|
|
48
|
+
default_headers={"X-Request-Id": "..."},
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
with SerpKite() as sk: # closes the HTTP client on exit
|
|
52
|
+
...
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Methods
|
|
56
|
+
|
|
57
|
+
Every query vertical takes `q` plus optional keyword parameters: `country`, `language`,
|
|
58
|
+
`location`, `uule`, `num`, `page`, `time` (`hour|day|week|month|year`), `tbs`, `device`, `safe`,
|
|
59
|
+
`autocorrect`, `ai_overview`, `ads`, `max_age`, `engine` (see
|
|
60
|
+
[Search engines & fallback](#search-engines--fallback)) and `include_content` (search only).
|
|
61
|
+
|
|
62
|
+
| Method | Endpoint | Returns |
|
|
63
|
+
| --- | --- | --- |
|
|
64
|
+
| `sk.search(q, **params)` | `/v1/search` | `SearchResponse` |
|
|
65
|
+
| `sk.images(q, **params)` | `/v1/images` | `ImagesResponse` |
|
|
66
|
+
| `sk.videos(q, **params)` | `/v1/videos` | `VideosResponse` |
|
|
67
|
+
| `sk.news(q, **params)` | `/v1/news` | `NewsResponse` |
|
|
68
|
+
| `sk.maps(q, ll="@40.7,-74.0,14z", **params)` | `/v1/maps` | `PlacesResponse` |
|
|
69
|
+
| `sk.places(q, **params)` | `/v1/places` | `PlacesResponse` |
|
|
70
|
+
| `sk.reviews(place_id=… \| cid=… \| fid=…, sort=, page_token=, num=)` | `/v1/reviews` | `ReviewsResponse` |
|
|
71
|
+
| `sk.shopping(q, **params)` | `/v1/shopping` | `ShoppingResponse` |
|
|
72
|
+
| `sk.scholar(q, **params)` | `/v1/scholar` | `ScholarResponse` |
|
|
73
|
+
| `sk.patents(q, **params)` | `/v1/patents` | `PatentsResponse` |
|
|
74
|
+
| `sk.autocomplete(q, **params)` | `/v1/autocomplete` | `AutocompleteResponse` |
|
|
75
|
+
| `sk.lens(url)` | `/v1/lens` | `LensResponse` |
|
|
76
|
+
| `sk.ai_mode(q, **params)` | `/v1/ai-mode` | `AIModeResponse` (`answer`, `markdown`, `results`) |
|
|
77
|
+
| `sk.webpage(url, include_html=False)` | `/v1/webpage` | `WebpageResponse` (`markdown`, `text`, `metadata`) |
|
|
78
|
+
| `sk.rank(q, domain, num=100)` | `/v1/rank` | `RankResponse` (`position` or `None`, `matches`) |
|
|
79
|
+
| `sk.account()` | `/v1/account` | `Account` (balance, limits, month usage) |
|
|
80
|
+
| `sk.batches.create / get / wait` | `/v1/batches` | see [Batches](#batches) |
|
|
81
|
+
| `sk.request(endpoint, params)` | `/v1/<endpoint>` | low-level: `str` for Markdown, else `dict` |
|
|
82
|
+
|
|
83
|
+
The output format changes the return type, and the overloads make type checkers follow it:
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
sk.search("q") # SearchResponse
|
|
87
|
+
sk.search("q", format="markdown") # str
|
|
88
|
+
sk.search("q", format="compact") # dict: lean results + meta
|
|
89
|
+
sk.search("q", fields="results.title,results.link,ai_overview") # dict: projected
|
|
90
|
+
sk.webpage("https://example.com", format="markdown") # str
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Some more examples:
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
# Top 5 results with their pages as Markdown (+1 credit per fetched page)
|
|
97
|
+
res = sk.search("vector databases comparison", include_content=5)
|
|
98
|
+
for r in res.results:
|
|
99
|
+
print(r.title, len(r.content or ""))
|
|
100
|
+
|
|
101
|
+
# Reviews, paged
|
|
102
|
+
page = sk.reviews(place_id="ChIJN1t_tDeuEmsRUsoyG83frY4", sort="newest")
|
|
103
|
+
nxt = sk.reviews(place_id="ChIJN1t_tDeuEmsRUsoyG83frY4", page_token=page.next_page_token)
|
|
104
|
+
|
|
105
|
+
# Where does example.com rank?
|
|
106
|
+
r = sk.rank("espresso machine", "example.com")
|
|
107
|
+
print(r.position, r.checked)
|
|
108
|
+
|
|
109
|
+
# Accept a cached result up to an hour old (half the credits on a hit)
|
|
110
|
+
sk.news("fed rate decision", max_age=3600)
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## Search engines & fallback
|
|
114
|
+
|
|
115
|
+
By default every request is answered by Google only (`engine="google"`); SerpKite already fails
|
|
116
|
+
over across its own proxy pools. Opt in to other providers with `engine`:
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
# Fall back to other enabled providers when Google is blocked or times out
|
|
120
|
+
res = sk.search("best espresso machine", engine="auto")
|
|
121
|
+
print(res.meta.engine) # "google", or e.g. "brave" if Google was unavailable
|
|
122
|
+
for step in res.meta.route or []: # absent on cache hits
|
|
123
|
+
print(step.provider, step.outcome, step.ms) # google blocked 812 / brave ok 431
|
|
124
|
+
|
|
125
|
+
# Only these providers, in this order (any sequence of names works)
|
|
126
|
+
sk.news("espresso", engine=["google", "brave"])
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
- `engine` is `"google"` (default), `"auto"`, `"consensus"`, one provider (`"brave"`, `"bing"`,
|
|
130
|
+
`"yahoo"`, `"duckduckgo"`, `"mojeek"`, `"wikipedia"`) or a list. `"auto"` and `"consensus"` can't be combined
|
|
131
|
+
with other names; unknown names, or a provider that doesn't serve the endpoint, raise
|
|
132
|
+
`BadRequestError` (`invalid_request`).
|
|
133
|
+
- `engine="consensus"` (`search` only) asks several independent indexes in parallel, merges the
|
|
134
|
+
results by URL and ranks them by agreement: each result has `sources` (e.g.
|
|
135
|
+
`["google", "brave"]`), `meta.engine` is `"consensus"`, and it costs the sum of one page per
|
|
136
|
+
provider that returned results.
|
|
137
|
+
- `meta.engine` names the provider that answered; `meta.route` lists each attempt as a
|
|
138
|
+
`RouteStep` (`provider`, `outcome`, `ms`). `request.engine` echoes what you asked for.
|
|
139
|
+
- Credits (`meta.credits_used`) follow the answering provider's price.
|
|
140
|
+
|
|
141
|
+
## Async
|
|
142
|
+
|
|
143
|
+
`AsyncSerpKite` has the same surface, built on `httpx.AsyncClient`:
|
|
144
|
+
|
|
145
|
+
```python
|
|
146
|
+
import asyncio
|
|
147
|
+
from serpkite import AsyncSerpKite
|
|
148
|
+
|
|
149
|
+
async def main() -> None:
|
|
150
|
+
async with AsyncSerpKite() as sk:
|
|
151
|
+
res, news = await asyncio.gather(
|
|
152
|
+
sk.search("best espresso machine"),
|
|
153
|
+
sk.news("espresso prices"),
|
|
154
|
+
)
|
|
155
|
+
print(res.results[0].link, news.results[0].source)
|
|
156
|
+
|
|
157
|
+
asyncio.run(main())
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
## Errors
|
|
161
|
+
|
|
162
|
+
Every API error raises a `serpkite.SerpKiteError` with `status`, `code`, `message` and
|
|
163
|
+
`request_id`. The class depends on the HTTP status:
|
|
164
|
+
|
|
165
|
+
| Exception | Status | Typical `code` |
|
|
166
|
+
| --- | --- | --- |
|
|
167
|
+
| `BadRequestError` | 400 | `invalid_request` |
|
|
168
|
+
| `AuthenticationError` | 401 | `unauthorized` |
|
|
169
|
+
| `InsufficientCreditsError` | 402 | `insufficient_credits` |
|
|
170
|
+
| `PermissionDeniedError` | 403 | `spend_cap_reached`, `key_limit_reached`, `forbidden` |
|
|
171
|
+
| `NotFoundError` | 404 | `not_found` |
|
|
172
|
+
| `RateLimitError` | 429 | `rate_limited`, `daily_limit_reached` (has `.retry_after`) |
|
|
173
|
+
| `APIError` | 5xx | `upstream_error`, `upstream_blocked`, `upstream_timeout`, `unavailable` (all 503) |
|
|
174
|
+
| `APIConnectionError` / `APITimeoutError` | none | the request never got a response |
|
|
175
|
+
|
|
176
|
+
```python
|
|
177
|
+
import serpkite
|
|
178
|
+
|
|
179
|
+
try:
|
|
180
|
+
sk.search("espresso")
|
|
181
|
+
except serpkite.InsufficientCreditsError:
|
|
182
|
+
print("Top up at https://app.serpkite.com/billing")
|
|
183
|
+
except serpkite.SerpKiteError as e:
|
|
184
|
+
print(e.status, e.code, e.message, e.request_id)
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Failed, empty and blocked searches are not billed. The client retries 429s, 5xx responses and
|
|
188
|
+
connection errors up to `max_retries` times. It uses exponential backoff with jitter and honours
|
|
189
|
+
`Retry-After`. It never retries `daily_limit_reached`. Batch creation is retried only when the
|
|
190
|
+
request provably never reached the server, unless you pass `idempotency_key`.
|
|
191
|
+
|
|
192
|
+
## Batches
|
|
193
|
+
|
|
194
|
+
Batches run at half price. You queue 1-100 requests for one endpoint, then poll for the results
|
|
195
|
+
or receive a signed webhook:
|
|
196
|
+
|
|
197
|
+
```python
|
|
198
|
+
job = sk.batches.create(
|
|
199
|
+
endpoint="search",
|
|
200
|
+
requests=[{"q": "a"}, {"q": "b", "country": "de"}],
|
|
201
|
+
webhook_url="https://example.com/hooks/serpkite", # optional
|
|
202
|
+
)
|
|
203
|
+
for entry in job.batches: # one entry per request, in order: Batch or ErrorResponse
|
|
204
|
+
if isinstance(entry, serpkite.types.Batch):
|
|
205
|
+
done = sk.batches.wait(str(entry.id), timeout=300, poll_interval=2)
|
|
206
|
+
if done.status == "done":
|
|
207
|
+
res = serpkite.types.SearchResponse.model_validate(done.result)
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
`batches.wait` raises `BatchTimeoutError` (a `TimeoutError`) when the timeout expires. Results
|
|
211
|
+
are kept for 24 hours.
|
|
212
|
+
|
|
213
|
+
Pass `idempotency_key=` (e.g. a UUID you store with the batch) to make `create` safe to retry: for
|
|
214
|
+
24 hours the server replays the first response for the same key and body, so the client then also
|
|
215
|
+
retries 5xx and connection errors.
|
|
216
|
+
|
|
217
|
+
Webhook deliveries are always signed. Check one with the raw body:
|
|
218
|
+
|
|
219
|
+
```python
|
|
220
|
+
from serpkite import verify_webhook
|
|
221
|
+
|
|
222
|
+
if not verify_webhook(os.environ["SERPKITE_WEBHOOK_SECRET"], request.get_data(), request.headers):
|
|
223
|
+
abort(401)
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
## CrewAI
|
|
227
|
+
|
|
228
|
+
```bash
|
|
229
|
+
pip install "serpkite[crewai]"
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
```python
|
|
233
|
+
from crewai import Agent
|
|
234
|
+
from serpkite.crewai import SerpKiteSearchTool
|
|
235
|
+
|
|
236
|
+
tool = SerpKiteSearchTool() # optional: endpoint="news", country="de", num=10, engine="auto"
|
|
237
|
+
agent = Agent(role="Researcher", goal="Find current facts", backstory="...", tools=[tool])
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
The tool returns Markdown, which uses far fewer tokens than JSON. The agent can set `query`,
|
|
241
|
+
`num`, `country`, `language` and `time`. `endpoint` can be `search`, `news`, `images`, `videos`,
|
|
242
|
+
`maps`, `places`, `shopping`, `scholar`, `patents`, `autocomplete` or `ai-mode`. `engine` (you set
|
|
243
|
+
it, not the agent) opts in to [fallback providers](#search-engines--fallback).
|
|
244
|
+
|
|
245
|
+
## LangChain
|
|
246
|
+
|
|
247
|
+
See [`langchain-serpkite`](https://pypi.org/project/langchain-serpkite/) for LangChain tools, a
|
|
248
|
+
retriever and a document loader.
|
|
249
|
+
|
|
250
|
+
## Development
|
|
251
|
+
|
|
252
|
+
```bash
|
|
253
|
+
uv sync
|
|
254
|
+
uv run pytest
|
|
255
|
+
uv run ruff check . && uv run ruff format --check .
|
|
256
|
+
uv run mypy
|
|
257
|
+
./scripts/gen_models.sh # regenerate src/serpkite/_models.py from backend/api/serp-api.yaml
|
|
258
|
+
uv run --with "crewai>=0.80" pytest tests/test_crewai.py # optional CrewAI tests
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
## License
|
|
262
|
+
|
|
263
|
+
MIT
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "serpkite"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Official Python SDK for SerpKite, the Google search API built for AI agents: clean JSON or Markdown results."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
requires-python = ">=3.9"
|
|
9
|
+
authors = [{ name = "SerpKite", email = "support@serpkite.com" }]
|
|
10
|
+
keywords = ["serpkite", "google", "search", "serp", "api", "ai", "agents", "llm", "markdown"]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 4 - Beta",
|
|
13
|
+
"Intended Audience :: Developers",
|
|
14
|
+
"Operating System :: OS Independent",
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
17
|
+
"Programming Language :: Python :: 3.9",
|
|
18
|
+
"Programming Language :: Python :: 3.10",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Programming Language :: Python :: 3.13",
|
|
22
|
+
"Topic :: Internet :: WWW/HTTP :: Indexing/Search",
|
|
23
|
+
"Typing :: Typed",
|
|
24
|
+
]
|
|
25
|
+
dependencies = [
|
|
26
|
+
"httpx>=0.27",
|
|
27
|
+
"pydantic>=2.6",
|
|
28
|
+
"typing-extensions>=4.7",
|
|
29
|
+
# Lets pydantic evaluate `X | None` annotations in the generated models on Python 3.9.
|
|
30
|
+
"eval-type-backport>=0.2; python_version < '3.10'",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[project.optional-dependencies]
|
|
34
|
+
crewai = ["crewai>=0.80; python_version >= '3.10'"]
|
|
35
|
+
|
|
36
|
+
[project.urls]
|
|
37
|
+
Homepage = "https://serpkite.com"
|
|
38
|
+
Documentation = "https://serpkite.com/docs/sdks/python"
|
|
39
|
+
Repository = "https://github.com/serpkite/serpkite-python"
|
|
40
|
+
|
|
41
|
+
[dependency-groups]
|
|
42
|
+
dev = [
|
|
43
|
+
"pytest>=8",
|
|
44
|
+
"pytest-asyncio>=0.23",
|
|
45
|
+
"respx>=0.21",
|
|
46
|
+
"ruff>=0.6",
|
|
47
|
+
"mypy>=1.10",
|
|
48
|
+
"datamodel-code-generator>=0.30; python_version >= '3.10'",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
[build-system]
|
|
52
|
+
requires = ["hatchling>=1.26"]
|
|
53
|
+
build-backend = "hatchling.build"
|
|
54
|
+
|
|
55
|
+
[tool.hatch.build.targets.wheel]
|
|
56
|
+
packages = ["src/serpkite"]
|
|
57
|
+
|
|
58
|
+
[tool.hatch.build.targets.sdist]
|
|
59
|
+
include = ["src/serpkite", "tests", "README.md", "LICENSE", "pyproject.toml"]
|
|
60
|
+
|
|
61
|
+
[tool.pytest.ini_options]
|
|
62
|
+
testpaths = ["tests"]
|
|
63
|
+
asyncio_mode = "auto"
|
|
64
|
+
|
|
65
|
+
[tool.ruff]
|
|
66
|
+
target-version = "py39"
|
|
67
|
+
line-length = 110
|
|
68
|
+
src = ["src", "tests"]
|
|
69
|
+
extend-exclude = ["src/serpkite/_models.py", "*.md"]
|
|
70
|
+
|
|
71
|
+
[tool.ruff.lint]
|
|
72
|
+
select = ["E", "F", "W", "I", "B", "UP", "SIM", "RUF"]
|
|
73
|
+
ignore = ["UP006", "UP007", "UP035", "UP045"]
|
|
74
|
+
|
|
75
|
+
[tool.mypy]
|
|
76
|
+
python_version = "3.10"
|
|
77
|
+
strict = true
|
|
78
|
+
files = ["src/serpkite", "tests"]
|
|
79
|
+
plugins = ["pydantic.mypy"]
|
|
80
|
+
|
|
81
|
+
[[tool.mypy.overrides]]
|
|
82
|
+
module = ["crewai", "crewai.*"]
|
|
83
|
+
ignore_missing_imports = true
|
|
84
|
+
|
|
85
|
+
[[tool.mypy.overrides]]
|
|
86
|
+
# The public overloads pin each method's return type; the shared _vertical helper returns Any.
|
|
87
|
+
module = ["serpkite._client"]
|
|
88
|
+
warn_return_any = false
|
|
89
|
+
|
|
90
|
+
[[tool.mypy.overrides]]
|
|
91
|
+
module = ["serpkite.crewai"]
|
|
92
|
+
disallow_subclassing_any = false
|
|
93
|
+
warn_unused_ignores = false
|