serpbit 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.
serpbit/__init__.py ADDED
@@ -0,0 +1,26 @@
1
+ """Serpbit: Google results as data. A small client for https://api.serpbit.com, on the standard library alone."""
2
+
3
+ from ._client import VERSION as __version__
4
+ from ._client import Serpbit
5
+ from ._webhook import verify_webhook
6
+ from ._errors import (
7
+ APIConnectionError,
8
+ APIError,
9
+ AuthenticationError,
10
+ BadRequestError,
11
+ InsufficientCreditsError,
12
+ RateLimitError,
13
+ SerpbitError,
14
+ )
15
+
16
+ __all__ = [
17
+ "Serpbit",
18
+ "verify_webhook",
19
+ "SerpbitError",
20
+ "APIConnectionError",
21
+ "APIError",
22
+ "AuthenticationError",
23
+ "BadRequestError",
24
+ "InsufficientCreditsError",
25
+ "RateLimitError",
26
+ ]
serpbit/_client.py ADDED
@@ -0,0 +1,201 @@
1
+ """The client: one method per API door, each a GET whose keyword arguments are the door's parameters."""
2
+
3
+ import json
4
+ import os
5
+ import random
6
+ import socket
7
+ import time
8
+ import urllib.error
9
+ import urllib.parse
10
+ import urllib.request
11
+ import uuid
12
+ from typing import Any, Dict, List, Optional, Union
13
+
14
+ from ._errors import APIConnectionError, SerpbitError, error_for
15
+
16
+ DEFAULT_BASE_URL = "https://api.serpbit.com"
17
+ VERSION = "0.1.0"
18
+
19
+ # A call is retried on these: the rate limit (after Retry-After), Google giving no page in time, our side failing.
20
+ _RETRY = {429, 500, 502, 503, 504}
21
+
22
+ Answer = Union[Dict[str, Any], str]
23
+
24
+
25
+ class Serpbit:
26
+ """A Serpbit client.
27
+
28
+ >>> from serpbit import Serpbit
29
+ >>> sb = Serpbit() # the key from SERPBIT_API_KEY
30
+ >>> answer = sb.search("espresso grinder", gl="de", pages=2, content=3)
31
+ >>> answer["organic"][0]["title"]
32
+
33
+ Every method returns the API's JSON as a dict, or Markdown as a str with output="md". A failed call raises a
34
+ SerpbitError. A retried call carries one Idempotency-Key, so a call that was answered but whose answer was lost
35
+ on the way is given back free rather than asked and billed again.
36
+ """
37
+
38
+ def __init__(
39
+ self,
40
+ api_key: Optional[str] = None,
41
+ *,
42
+ base_url: Optional[str] = None,
43
+ timeout: float = 90.0,
44
+ max_retries: int = 2,
45
+ ) -> None:
46
+ self.api_key = api_key or os.environ.get("SERPBIT_API_KEY", "")
47
+ if not self.api_key:
48
+ raise SerpbitError("no API key: pass api_key or set SERPBIT_API_KEY (keys are made at https://app.serpbit.com/keys)")
49
+ self.base_url = (base_url or os.environ.get("SERPBIT_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
50
+ self.timeout = timeout
51
+ self.max_retries = max(0, max_retries)
52
+ # The last answer's id and credits, from its headers: a Markdown answer has no JSON to carry them.
53
+ self.last_request_id: Optional[str] = None
54
+ self.last_credits: Optional[int] = None
55
+
56
+ # --- the doors -------------------------------------------------------------------------------------------------
57
+
58
+ def search(self, q: Optional[str] = None, **params: Any) -> Answer:
59
+ """Google's results page (/v1/search). url= takes a Google results address instead of q."""
60
+ return self.request("/v1/search", q=q, **params)
61
+
62
+ def news(self, q: str, **params: Any) -> Answer:
63
+ """Google's News tab (/v1/news)."""
64
+ return self.request("/v1/news", q=q, **params)
65
+
66
+ def shopping(self, q: str, **params: Any) -> Answer:
67
+ """Google's Shopping tab (/v1/shopping)."""
68
+ return self.request("/v1/shopping", q=q, **params)
69
+
70
+ def shopping_product(self, product_id: Optional[str] = None, **params: Any) -> Answer:
71
+ """One product and every store's offer (/v1/shopping/product)."""
72
+ return self.request("/v1/shopping/product", product_id=product_id, **params)
73
+
74
+ def images(self, q: str, **params: Any) -> Answer:
75
+ """Google's Images tab (/v1/images)."""
76
+ return self.request("/v1/images", q=q, **params)
77
+
78
+ def videos(self, q: str, **params: Any) -> Answer:
79
+ """Google's Videos tab (/v1/videos)."""
80
+ return self.request("/v1/videos", q=q, **params)
81
+
82
+ def maps(self, q: str, **params: Any) -> Answer:
83
+ """Places as Google Maps lists them (/v1/maps)."""
84
+ return self.request("/v1/maps", q=q, **params)
85
+
86
+ def autocomplete(self, q: str, **params: Any) -> Answer:
87
+ """Google's search-box suggestions (/v1/autocomplete)."""
88
+ return self.request("/v1/autocomplete", q=q, **params)
89
+
90
+ def ai_mode(self, q: str, **params: Any) -> Answer:
91
+ """Google's AI Mode answer with its sources (/v1/ai-mode)."""
92
+ return self.request("/v1/ai-mode", q=q, **params)
93
+
94
+ # --- batches ---------------------------------------------------------------------------------------------------
95
+
96
+ def batch(self, requests: List[Dict[str, Any]], *, webhook: Optional[str] = None,
97
+ idempotency_key: Optional[str] = None) -> Dict[str, Any]:
98
+ """Start a batch of up to 1,000 calls (POST /v1/batches), each {"path": "/v1/search", "params": {...}, "id": ...}.
99
+
100
+ The calls run in the background at the key's limits; each answer goes to webhook as it comes (verify it with
101
+ serpbit.verify_webhook) and stays readable with batch_results for a day after the batch ends.
102
+ """
103
+ body: Dict[str, Any] = {"requests": [
104
+ {**r, "params": {k: v for k, v in (r.get("params") or {}).items() if v is not None and v is not False}}
105
+ for r in requests]}
106
+ if webhook:
107
+ body["webhook"] = webhook
108
+ return self.request("/v1/batches", method="POST", json_body=body, idempotency_key=idempotency_key)
109
+
110
+ def get_batch(self, batch_id: str) -> Dict[str, Any]:
111
+ """A batch's state and each call's status (GET /v1/batches/{id})."""
112
+ return self.request("/v1/batches/" + urllib.parse.quote(batch_id, safe=""))
113
+
114
+ def batch_results(self, batch_id: str, *, offset: int = 0, limit: int = 100) -> Dict[str, Any]:
115
+ """A batch's answers, limit (at most 100) from offset (GET /v1/batches/{id}/results)."""
116
+ return self.request("/v1/batches/" + urllib.parse.quote(batch_id, safe="") + "/results", offset=offset, limit=limit)
117
+
118
+ # --- the call --------------------------------------------------------------------------------------------------
119
+
120
+ def request(self, path: str, *, method: str = "GET", json_body: Any = None, idempotency_key: Optional[str] = None,
121
+ **params: Any) -> Answer:
122
+ """Call path with params. True is sent as true; None and False are left out; a list is joined by commas."""
123
+ query = {k: _value(v) for k, v in params.items() if v is not None and v is not False}
124
+ url = self.base_url + path + ("?" + urllib.parse.urlencode(query) if query else "")
125
+ key = idempotency_key or (uuid.uuid4().hex if self.max_retries else None)
126
+ headers = {
127
+ "Authorization": "Bearer " + self.api_key,
128
+ "Accept": "application/json, text/markdown",
129
+ "User-Agent": "serpbit-python/" + VERSION,
130
+ }
131
+ if key:
132
+ headers["Idempotency-Key"] = key
133
+ data = None
134
+ if json_body is not None:
135
+ data = json.dumps(json_body).encode()
136
+ headers["Content-Type"] = "application/json"
137
+ attempt = 0
138
+ while True:
139
+ try:
140
+ req = urllib.request.Request(url, data=data, headers=headers, method=method)
141
+ with urllib.request.urlopen(req, timeout=self.timeout) as resp:
142
+ body = resp.read()
143
+ self.last_request_id = resp.headers.get("X-Request-Id")
144
+ self.last_credits = _int(resp.headers.get("X-Credits"))
145
+ if resp.headers.get_content_type() == "application/json":
146
+ return json.loads(body)
147
+ return body.decode("utf-8")
148
+ except urllib.error.HTTPError as e:
149
+ body = e.read()
150
+ retry_after = _float(e.headers.get("Retry-After"))
151
+ if e.code in _RETRY and attempt < self.max_retries:
152
+ time.sleep(_backoff(attempt, retry_after))
153
+ attempt += 1
154
+ continue
155
+ raise _refusal(e.code, body, e.headers.get("X-Request-Id"), retry_after) from None
156
+ except (urllib.error.URLError, socket.timeout, TimeoutError, ConnectionError) as e:
157
+ if attempt < self.max_retries:
158
+ time.sleep(_backoff(attempt, None))
159
+ attempt += 1
160
+ continue
161
+ raise APIConnectionError(f"could not reach {self.base_url}: {getattr(e, 'reason', e)}") from None
162
+
163
+
164
+ def _value(v: Any) -> str:
165
+ if v is True:
166
+ return "true"
167
+ if isinstance(v, (list, tuple)):
168
+ return ",".join(str(x) for x in v)
169
+ return str(v)
170
+
171
+
172
+ def _int(s: Optional[str]) -> Optional[int]:
173
+ try:
174
+ return int(s) if s is not None else None
175
+ except ValueError:
176
+ return None
177
+
178
+
179
+ def _float(s: Optional[str]) -> Optional[float]:
180
+ try:
181
+ return float(s) if s is not None else None
182
+ except ValueError:
183
+ return None
184
+
185
+
186
+ def _backoff(attempt: int, retry_after: Optional[float]) -> float:
187
+ if retry_after is not None:
188
+ return min(retry_after, 60.0)
189
+ return min(0.5 * 2**attempt, 8.0) * (0.75 + random.random() / 2)
190
+
191
+
192
+ def _refusal(status: int, body: bytes, request_id: Optional[str], retry_after: Optional[float]) -> SerpbitError:
193
+ try:
194
+ data = json.loads(body)
195
+ except ValueError:
196
+ data = {}
197
+ if not isinstance(data, dict):
198
+ data = {}
199
+ message = data.get("message") or f"the API answered {status}"
200
+ return error_for(status, message, code=data.get("error"), request_id=data.get("request_id") or request_id,
201
+ body=data, retry_after=retry_after)
serpbit/_errors.py ADDED
@@ -0,0 +1,75 @@
1
+ """Errors, one class a kind of refusal: catch SerpbitError for all of them."""
2
+
3
+ from typing import Any, Mapping, Optional
4
+
5
+
6
+ class SerpbitError(Exception):
7
+ """A call that did not answer. status, code and request_id are the API's (request_id is what support asks for)."""
8
+
9
+ def __init__(
10
+ self,
11
+ message: str,
12
+ *,
13
+ status: Optional[int] = None,
14
+ code: Optional[str] = None,
15
+ request_id: Optional[str] = None,
16
+ body: Optional[Mapping[str, Any]] = None,
17
+ ) -> None:
18
+ super().__init__(message)
19
+ self.message = message
20
+ self.status = status
21
+ self.code = code
22
+ self.request_id = request_id
23
+ self.body = body
24
+
25
+ def __str__(self) -> str:
26
+ parts = [self.message]
27
+ if self.status:
28
+ parts.append(f"status {self.status}")
29
+ if self.code:
30
+ parts.append(self.code)
31
+ if self.request_id:
32
+ parts.append(f"request_id {self.request_id}")
33
+ return " · ".join(parts)
34
+
35
+
36
+ class BadRequestError(SerpbitError):
37
+ """400: a parameter the API does not take, or a value out of its range."""
38
+
39
+
40
+ class AuthenticationError(SerpbitError):
41
+ """401: no key, or a key that is revoked or unknown."""
42
+
43
+
44
+ class InsufficientCreditsError(SerpbitError):
45
+ """402: the account has not the credits this call needs."""
46
+
47
+
48
+ class RateLimitError(SerpbitError):
49
+ """429: over the key's requests a minute or at once. retry_after is the seconds the API asked to wait."""
50
+
51
+ def __init__(self, message: str, *, retry_after: Optional[float] = None, **kw: Any) -> None:
52
+ super().__init__(message, **kw)
53
+ self.retry_after = retry_after
54
+
55
+
56
+ class APIError(SerpbitError):
57
+ """5xx: Google gave no page within the call's attempts and time (503), or something failed on our side."""
58
+
59
+
60
+ class APIConnectionError(SerpbitError):
61
+ """The API could not be reached, or did not answer in time."""
62
+
63
+
64
+ def error_for(status: int, message: str, retry_after: Optional[float] = None, **kw: Any) -> SerpbitError:
65
+ if status == 400:
66
+ return BadRequestError(message, status=status, **kw)
67
+ if status == 401:
68
+ return AuthenticationError(message, status=status, **kw)
69
+ if status == 402:
70
+ return InsufficientCreditsError(message, status=status, **kw)
71
+ if status == 429:
72
+ return RateLimitError(message, status=status, retry_after=retry_after, **kw)
73
+ if status >= 500:
74
+ return APIError(message, status=status, **kw)
75
+ return SerpbitError(message, status=status, **kw)
serpbit/_webhook.py ADDED
@@ -0,0 +1,24 @@
1
+ """Webhooks: a batch's events are signed with the API key that started it."""
2
+
3
+ import hashlib
4
+ import hmac
5
+ import time
6
+ from typing import Optional, Union
7
+
8
+
9
+ def verify_webhook(body: Union[bytes, str], signature: Optional[str], api_key: str, *, tolerance: float = 300) -> bool:
10
+ """True when a webhook's body is Serpbit's, sent within tolerance seconds.
11
+
12
+ signature is the Serpbit-Signature header ("t=<unix>,v1=<hex>"); body is the request's raw body, as received.
13
+ """
14
+ if not signature or not api_key:
15
+ return False
16
+ parts = dict(p.split("=", 1) for p in signature.split(",") if "=" in p)
17
+ ts, sig = parts.get("t", ""), parts.get("v1", "")
18
+ if not ts.isdigit() or not sig:
19
+ return False
20
+ if tolerance and abs(time.time() - int(ts)) > tolerance:
21
+ return False
22
+ raw = body.encode() if isinstance(body, str) else body
23
+ want = hmac.new(api_key.encode(), ts.encode() + b"." + raw, hashlib.sha256).hexdigest()
24
+ return hmac.compare_digest(want, sig)
serpbit/py.typed ADDED
File without changes
@@ -0,0 +1,84 @@
1
+ Metadata-Version: 2.4
2
+ Name: serpbit
3
+ Version: 0.1.0
4
+ Summary: Google Search, News, Shopping, Images, Videos, Maps, Autocomplete and AI Mode as data: the Serpbit API client.
5
+ Author-email: Serpbit <support@serpbit.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://serpbit.com
8
+ Project-URL: Documentation, https://serpbit.com/docs/#sdks
9
+ Project-URL: Changelog, https://serpbit.com/changelog/
10
+ Keywords: serp,google search api,serp api,web search,ai agents,rag
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Topic :: Internet :: WWW/HTTP :: Indexing/Search
15
+ Classifier: Typing :: Typed
16
+ Requires-Python: >=3.9
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE
19
+ Dynamic: license-file
20
+
21
+ # serpbit
22
+
23
+ Google Search, News, Shopping, Images, Videos, Maps, Autocomplete and AI Mode as data, from
24
+ [Serpbit](https://serpbit.com). No dependencies: the standard library only, Python 3.9+.
25
+
26
+ ```sh
27
+ pip install serpbit
28
+ ```
29
+
30
+ ```python
31
+ from serpbit import Serpbit
32
+
33
+ sb = Serpbit() # reads SERPBIT_API_KEY; make a key at https://app.serpbit.com/keys
34
+
35
+ answer = sb.search("espresso grinder", gl="de", hl="de", pages=2)
36
+ for r in answer["organic"]:
37
+ print(r["position"], r["title"], r["url"])
38
+
39
+ # The first three results' pages, as Markdown text: 1 credit a page read.
40
+ answer = sb.search("how to descale an espresso machine", content=3)
41
+ print(answer["organic"][0]["content"]["text"][:500])
42
+
43
+ # Markdown instead of JSON, for a language model's context.
44
+ print(sb.news("wildfire", sort="date", output="md"))
45
+ ```
46
+
47
+ Every door of the API has a method: `search`, `news`, `shopping`, `shopping_product`, `images`, `videos`, `maps`,
48
+ `autocomplete`, `ai_mode`. Keyword arguments are the door's parameters, as the [docs](https://serpbit.com/docs/)
49
+ name them:
50
+ - `True` is sent as `true`;
51
+ - `None` and `False` are left out;
52
+ - a list is joined with commas.
53
+
54
+ A method returns the API's JSON as a dict, or a `str` with `output="md"`. `sb.last_request_id` and
55
+ `sb.last_credits` give the last answer's id and the credits it spent.
56
+
57
+ ## Errors and retries
58
+
59
+ A failed call raises a `SerpbitError`. The subclasses are:
60
+ - `BadRequestError` (400);
61
+ - `AuthenticationError` (401);
62
+ - `InsufficientCreditsError` (402);
63
+ - `RateLimitError` (429, with `retry_after`);
64
+ - `APIError` (5xx);
65
+ - `APIConnectionError`.
66
+
67
+ Each carries `status`, `code` and `request_id`. Quote the `request_id` to support.
68
+
69
+ Calls are retried twice on 429 and 5xx (set with `max_retries`), waiting as `Retry-After` says. Every attempt of one
70
+ call carries the same `Idempotency-Key`, so an answer lost on the way is given back free instead of being billed
71
+ twice. You can pass your own with `idempotency_key=`.
72
+
73
+ ## Batches
74
+
75
+ ```python
76
+ b = sb.batch([{"path": "/v1/search", "params": {"q": kw, "gl": "us"}, "id": kw} for kw in keywords],
77
+ webhook="https://example.com/serpbit")
78
+ sb.get_batch(b["id"]) # status, and each call's
79
+ sb.batch_results(b["id"], offset=0) # the answers, 100 at a time, for a day after it ends
80
+
81
+ # In your webhook: check the event is Serpbit's before you trust it.
82
+ from serpbit import verify_webhook
83
+ ok = verify_webhook(request.body, request.headers["Serpbit-Signature"], api_key)
84
+ ```
@@ -0,0 +1,10 @@
1
+ serpbit/__init__.py,sha256=YFeiFUiubTNwOn5r-XxY3ERHLTVdPeFrPweP7LvaBU0,620
2
+ serpbit/_client.py,sha256=KdFE_2EuYj_KINlghcl_GYKj8EJ4ax0yDOGi8k3SUTo,8964
3
+ serpbit/_errors.py,sha256=CqgwJ9xlMz8YGxS6cgpVx9BB1WtVO5xJWkTWj1YbTcc,2533
4
+ serpbit/_webhook.py,sha256=SohRR5COHJyxZamBKIQzZaS5VE-BgZmunGbXGUVIlfw,997
5
+ serpbit/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ serpbit-0.1.0.dist-info/licenses/LICENSE,sha256=8_l_zG7x9IoJSOUDO9EWsYd5mUbic3B8FrL3-hsLQPE,1064
7
+ serpbit-0.1.0.dist-info/METADATA,sha256=4eeCf7VJ7b4kDIE2sqIpGqp2PXNszq9DQEAz94tZ7Y8,3272
8
+ serpbit-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
9
+ serpbit-0.1.0.dist-info/top_level.txt,sha256=ofxIRHP-BKSR-zLawYBppxLqIbqvrKLFxQKyk6yj7t8,8
10
+ serpbit-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Serpbit
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.
@@ -0,0 +1 @@
1
+ serpbit