serpbit 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.
serpbit-0.1.0/LICENSE ADDED
@@ -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.
serpbit-0.1.0/PKG-INFO ADDED
@@ -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,64 @@
1
+ # serpbit
2
+
3
+ Google Search, News, Shopping, Images, Videos, Maps, Autocomplete and AI Mode as data, from
4
+ [Serpbit](https://serpbit.com). No dependencies: the standard library only, Python 3.9+.
5
+
6
+ ```sh
7
+ pip install serpbit
8
+ ```
9
+
10
+ ```python
11
+ from serpbit import Serpbit
12
+
13
+ sb = Serpbit() # reads SERPBIT_API_KEY; make a key at https://app.serpbit.com/keys
14
+
15
+ answer = sb.search("espresso grinder", gl="de", hl="de", pages=2)
16
+ for r in answer["organic"]:
17
+ print(r["position"], r["title"], r["url"])
18
+
19
+ # The first three results' pages, as Markdown text: 1 credit a page read.
20
+ answer = sb.search("how to descale an espresso machine", content=3)
21
+ print(answer["organic"][0]["content"]["text"][:500])
22
+
23
+ # Markdown instead of JSON, for a language model's context.
24
+ print(sb.news("wildfire", sort="date", output="md"))
25
+ ```
26
+
27
+ Every door of the API has a method: `search`, `news`, `shopping`, `shopping_product`, `images`, `videos`, `maps`,
28
+ `autocomplete`, `ai_mode`. Keyword arguments are the door's parameters, as the [docs](https://serpbit.com/docs/)
29
+ name them:
30
+ - `True` is sent as `true`;
31
+ - `None` and `False` are left out;
32
+ - a list is joined with commas.
33
+
34
+ A method returns the API's JSON as a dict, or a `str` with `output="md"`. `sb.last_request_id` and
35
+ `sb.last_credits` give the last answer's id and the credits it spent.
36
+
37
+ ## Errors and retries
38
+
39
+ A failed call raises a `SerpbitError`. The subclasses are:
40
+ - `BadRequestError` (400);
41
+ - `AuthenticationError` (401);
42
+ - `InsufficientCreditsError` (402);
43
+ - `RateLimitError` (429, with `retry_after`);
44
+ - `APIError` (5xx);
45
+ - `APIConnectionError`.
46
+
47
+ Each carries `status`, `code` and `request_id`. Quote the `request_id` to support.
48
+
49
+ Calls are retried twice on 429 and 5xx (set with `max_retries`), waiting as `Retry-After` says. Every attempt of one
50
+ call carries the same `Idempotency-Key`, so an answer lost on the way is given back free instead of being billed
51
+ twice. You can pass your own with `idempotency_key=`.
52
+
53
+ ## Batches
54
+
55
+ ```python
56
+ b = sb.batch([{"path": "/v1/search", "params": {"q": kw, "gl": "us"}, "id": kw} for kw in keywords],
57
+ webhook="https://example.com/serpbit")
58
+ sb.get_batch(b["id"]) # status, and each call's
59
+ sb.batch_results(b["id"], offset=0) # the answers, 100 at a time, for a day after it ends
60
+
61
+ # In your webhook: check the event is Serpbit's before you trust it.
62
+ from serpbit import verify_webhook
63
+ ok = verify_webhook(request.body, request.headers["Serpbit-Signature"], api_key)
64
+ ```
@@ -0,0 +1,33 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "serpbit"
7
+ version = "0.1.0"
8
+ description = "Google Search, News, Shopping, Images, Videos, Maps, Autocomplete and AI Mode as data: the Serpbit API client."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ authors = [{ name = "Serpbit", email = "support@serpbit.com" }]
13
+ requires-python = ">=3.9"
14
+ dependencies = []
15
+ keywords = ["serp", "google search api", "serp api", "web search", "ai agents", "rag"]
16
+ classifiers = [
17
+ "Programming Language :: Python :: 3",
18
+ "Operating System :: OS Independent",
19
+ "Intended Audience :: Developers",
20
+ "Topic :: Internet :: WWW/HTTP :: Indexing/Search",
21
+ "Typing :: Typed",
22
+ ]
23
+
24
+ [project.urls]
25
+ Homepage = "https://serpbit.com"
26
+ Documentation = "https://serpbit.com/docs/#sdks"
27
+ Changelog = "https://serpbit.com/changelog/"
28
+
29
+ [tool.setuptools.packages.find]
30
+ where = ["src"]
31
+
32
+ [tool.setuptools.package-data]
33
+ serpbit = ["py.typed"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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
+ ]
@@ -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)
@@ -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)
@@ -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)
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,13 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/serpbit/__init__.py
5
+ src/serpbit/_client.py
6
+ src/serpbit/_errors.py
7
+ src/serpbit/_webhook.py
8
+ src/serpbit/py.typed
9
+ src/serpbit.egg-info/PKG-INFO
10
+ src/serpbit.egg-info/SOURCES.txt
11
+ src/serpbit.egg-info/dependency_links.txt
12
+ src/serpbit.egg-info/top_level.txt
13
+ tests/test_client.py
@@ -0,0 +1 @@
1
+ serpbit
@@ -0,0 +1,167 @@
1
+ import json
2
+ import threading
3
+ import unittest
4
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
5
+ from urllib.parse import parse_qs, urlparse
6
+
7
+ import serpbit
8
+ from serpbit import Serpbit
9
+
10
+
11
+ class FakeAPI(BaseHTTPRequestHandler):
12
+ """Answers as the API does; seen keeps each request, script the statuses to give before the answer."""
13
+
14
+ seen = []
15
+ script = []
16
+
17
+ def do_GET(self): # noqa: N802
18
+ u = urlparse(self.path)
19
+ FakeAPI.seen.append({"path": u.path, "query": {k: v[0] for k, v in parse_qs(u.query).items()},
20
+ "auth": self.headers.get("Authorization"), "idem": self.headers.get("Idempotency-Key"),
21
+ "ua": self.headers.get("User-Agent")})
22
+ if FakeAPI.script:
23
+ status, headers = FakeAPI.script.pop(0)
24
+ self.send(status, {"error": "rate_limited" if status == 429 else "unavailable", "message": "later",
25
+ "request_id": "r-err"}, headers)
26
+ return
27
+ q = FakeAPI.seen[-1]["query"]
28
+ if q.get("q") == "broke":
29
+ self.send(402, {"error": "out_of_credits", "message": "no credits left", "request_id": "r402"})
30
+ return
31
+ if q.get("output") == "md":
32
+ self.send(200, "# Google results", {"X-Credits": "1", "X-Request-Id": "rmd"}, "text/markdown; charset=utf-8")
33
+ return
34
+ self.send(200, {"request_id": "r1", "organic": [{"position": 1, "page": 1, "title": "T"}], "meta": {"credits": 1}},
35
+ {"X-Credits": "1", "X-Request-Id": "r1"})
36
+
37
+ def do_POST(self): # noqa: N802
38
+ n = int(self.headers.get("Content-Length") or 0)
39
+ FakeAPI.seen.append({"path": self.path, "method": "POST", "body": json.loads(self.rfile.read(n)),
40
+ "idem": self.headers.get("Idempotency-Key"), "ctype": self.headers.get("Content-Type")})
41
+ self.send(202, {"id": "bat_1", "status": "running", "total": 1})
42
+
43
+ def send(self, status, body, headers=None, ctype="application/json"):
44
+ raw = (body if isinstance(body, str) else json.dumps(body)).encode()
45
+ self.send_response(status)
46
+ self.send_header("Content-Type", ctype)
47
+ for k, v in (headers or {}).items():
48
+ self.send_header(k, v)
49
+ self.send_header("Content-Length", str(len(raw)))
50
+ self.end_headers()
51
+ self.wfile.write(raw)
52
+
53
+ def log_message(self, *a):
54
+ pass
55
+
56
+
57
+ class ClientTest(unittest.TestCase):
58
+ @classmethod
59
+ def setUpClass(cls):
60
+ cls.srv = ThreadingHTTPServer(("127.0.0.1", 0), FakeAPI)
61
+ threading.Thread(target=cls.srv.serve_forever, daemon=True).start()
62
+ cls.base = "http://127.0.0.1:%d" % cls.srv.server_address[1]
63
+
64
+ @classmethod
65
+ def tearDownClass(cls):
66
+ cls.srv.shutdown()
67
+
68
+ def setUp(self):
69
+ FakeAPI.seen, FakeAPI.script = [], []
70
+ self.sb = Serpbit("sb_live_test", base_url=self.base, max_retries=2)
71
+
72
+ def test_a_search_sends_its_parameters_and_reads_the_json(self):
73
+ a = self.sb.search("coffee grinder", gl="de", pages=2, content=True, ai_overview=True, safe=None, html=False,
74
+ lr=["lang_de", "lang_en"])
75
+ self.assertEqual(a["organic"][0]["title"], "T")
76
+ r = FakeAPI.seen[0]
77
+ self.assertEqual(r["path"], "/v1/search")
78
+ self.assertEqual(r["query"], {"q": "coffee grinder", "gl": "de", "pages": "2", "content": "true",
79
+ "ai_overview": "true", "lr": "lang_de,lang_en"})
80
+ self.assertEqual(r["auth"], "Bearer sb_live_test")
81
+ self.assertTrue(r["ua"].startswith("serpbit-python/"))
82
+ self.assertEqual((self.sb.last_request_id, self.sb.last_credits), ("r1", 1))
83
+
84
+ def test_each_door_has_its_path(self):
85
+ for call, path in [(lambda: self.sb.news("x"), "/v1/news"), (lambda: self.sb.shopping("x"), "/v1/shopping"),
86
+ (lambda: self.sb.shopping_product("123"), "/v1/shopping/product"),
87
+ (lambda: self.sb.images("x"), "/v1/images"), (lambda: self.sb.videos("x"), "/v1/videos"),
88
+ (lambda: self.sb.maps("x"), "/v1/maps"), (lambda: self.sb.autocomplete("x"), "/v1/autocomplete"),
89
+ (lambda: self.sb.ai_mode("x"), "/v1/ai-mode")]:
90
+ call()
91
+ self.assertEqual(FakeAPI.seen[-1]["path"], path)
92
+ self.assertEqual(FakeAPI.seen[2]["query"], {"product_id": "123"})
93
+
94
+ def test_markdown_comes_as_text(self):
95
+ self.assertEqual(self.sb.search("x", output="md"), "# Google results")
96
+ self.assertEqual(self.sb.last_request_id, "rmd")
97
+
98
+ def test_retries_keep_one_idempotency_key_and_wait_as_asked(self):
99
+ FakeAPI.script = [(429, {"Retry-After": "0"}), (503, {})]
100
+ a = self.sb.search("x")
101
+ self.assertEqual(a["request_id"], "r1")
102
+ keys = {r["idem"] for r in FakeAPI.seen}
103
+ self.assertEqual(len(FakeAPI.seen), 3)
104
+ self.assertEqual(len(keys), 1)
105
+ self.assertTrue(next(iter(keys)))
106
+
107
+ def test_a_refusal_is_its_own_error(self):
108
+ with self.assertRaises(serpbit.InsufficientCreditsError) as e:
109
+ self.sb.search("broke")
110
+ self.assertEqual((e.exception.status, e.exception.code, e.exception.request_id), (402, "out_of_credits", "r402"))
111
+ FakeAPI.script = [(429, {"Retry-After": "0"})] * 3
112
+ with self.assertRaises(serpbit.RateLimitError) as e:
113
+ self.sb.search("x")
114
+ self.assertEqual(e.exception.retry_after, 0)
115
+ self.assertIsInstance(e.exception, serpbit.SerpbitError)
116
+
117
+ def test_an_unreachable_api(self):
118
+ sb = Serpbit("k", base_url="http://127.0.0.1:9", max_retries=0, timeout=2)
119
+ with self.assertRaises(serpbit.APIConnectionError):
120
+ sb.search("x")
121
+
122
+ def test_a_key_is_needed(self):
123
+ import os
124
+
125
+ old = os.environ.pop("SERPBIT_API_KEY", None)
126
+ try:
127
+ with self.assertRaises(serpbit.SerpbitError):
128
+ Serpbit()
129
+ finally:
130
+ if old is not None:
131
+ os.environ["SERPBIT_API_KEY"] = old
132
+
133
+
134
+ if __name__ == "__main__":
135
+ unittest.main()
136
+
137
+
138
+ class BatchTest(ClientTest):
139
+ def test_a_batch_posts_its_calls_and_is_read_back(self):
140
+ b = self.sb.batch([{"path": "/v1/search", "params": {"q": "x", "pages": 2, "html": False}, "id": "kw-1"}],
141
+ webhook="https://example.com/hook", idempotency_key="b-1")
142
+ self.assertEqual(b["id"], "bat_1")
143
+ r = FakeAPI.seen[0]
144
+ self.assertEqual((r["method"], r["path"], r["idem"], r["ctype"]), ("POST", "/v1/batches", "b-1", "application/json"))
145
+ self.assertEqual(r["body"], {"requests": [{"path": "/v1/search", "params": {"q": "x", "pages": 2}, "id": "kw-1"}],
146
+ "webhook": "https://example.com/hook"})
147
+ self.sb.get_batch("bat_1")
148
+ self.sb.batch_results("bat_1", offset=100)
149
+ self.assertEqual(FakeAPI.seen[1]["path"], "/v1/batches/bat_1")
150
+ self.assertEqual((FakeAPI.seen[2]["path"], FakeAPI.seen[2]["query"]), ("/v1/batches/bat_1/results",
151
+ {"offset": "100", "limit": "100"}))
152
+
153
+ def test_a_webhook_is_verified_with_the_key(self):
154
+ import hashlib
155
+ import hmac
156
+ import time
157
+
158
+ body = b'{"type":"batch.completed","data":{}}'
159
+ ts = str(int(time.time()))
160
+ sig = hmac.new(b"sb_live_test", ts.encode() + b"." + body, hashlib.sha256).hexdigest()
161
+ header = "t=%s,v1=%s" % (ts, sig)
162
+ self.assertTrue(serpbit.verify_webhook(body, header, "sb_live_test"))
163
+ self.assertFalse(serpbit.verify_webhook(body + b" ", header, "sb_live_test"))
164
+ self.assertFalse(serpbit.verify_webhook(body, header, "another_key"))
165
+ old = "t=%d,v1=%s" % (int(time.time()) - 3600, sig)
166
+ self.assertFalse(serpbit.verify_webhook(body, old, "sb_live_test"))
167
+ self.assertFalse(serpbit.verify_webhook(body, None, "sb_live_test"))