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 +21 -0
- serpbit-0.1.0/PKG-INFO +84 -0
- serpbit-0.1.0/README.md +64 -0
- serpbit-0.1.0/pyproject.toml +33 -0
- serpbit-0.1.0/setup.cfg +4 -0
- serpbit-0.1.0/src/serpbit/__init__.py +26 -0
- serpbit-0.1.0/src/serpbit/_client.py +201 -0
- serpbit-0.1.0/src/serpbit/_errors.py +75 -0
- serpbit-0.1.0/src/serpbit/_webhook.py +24 -0
- serpbit-0.1.0/src/serpbit/py.typed +0 -0
- serpbit-0.1.0/src/serpbit.egg-info/PKG-INFO +84 -0
- serpbit-0.1.0/src/serpbit.egg-info/SOURCES.txt +13 -0
- serpbit-0.1.0/src/serpbit.egg-info/dependency_links.txt +1 -0
- serpbit-0.1.0/src/serpbit.egg-info/top_level.txt +1 -0
- serpbit-0.1.0/tests/test_client.py +167 -0
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
|
+
```
|
serpbit-0.1.0/README.md
ADDED
|
@@ -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"]
|
serpbit-0.1.0/setup.cfg
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
|
+
]
|
|
@@ -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
|
+
|
|
@@ -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"))
|