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 +26 -0
- serpbit/_client.py +201 -0
- serpbit/_errors.py +75 -0
- serpbit/_webhook.py +24 -0
- serpbit/py.typed +0 -0
- serpbit-0.1.0.dist-info/METADATA +84 -0
- serpbit-0.1.0.dist-info/RECORD +10 -0
- serpbit-0.1.0.dist-info/WHEEL +5 -0
- serpbit-0.1.0.dist-info/licenses/LICENSE +21 -0
- serpbit-0.1.0.dist-info/top_level.txt +1 -0
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,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
|