websitescreenshotworld 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.
- websitescreenshotworld-0.1.0/.gitignore +13 -0
- websitescreenshotworld-0.1.0/CHANGELOG.md +37 -0
- websitescreenshotworld-0.1.0/LICENSE +21 -0
- websitescreenshotworld-0.1.0/PKG-INFO +506 -0
- websitescreenshotworld-0.1.0/README.md +475 -0
- websitescreenshotworld-0.1.0/pyproject.toml +65 -0
- websitescreenshotworld-0.1.0/src/websitescreenshotworld/__init__.py +102 -0
- websitescreenshotworld-0.1.0/src/websitescreenshotworld/_auto_schedule.py +41 -0
- websitescreenshotworld-0.1.0/src/websitescreenshotworld/_client.py +132 -0
- websitescreenshotworld-0.1.0/src/websitescreenshotworld/_config.py +92 -0
- websitescreenshotworld-0.1.0/src/websitescreenshotworld/_constants.py +122 -0
- websitescreenshotworld-0.1.0/src/websitescreenshotworld/_dates.py +34 -0
- websitescreenshotworld-0.1.0/src/websitescreenshotworld/_errors.py +198 -0
- websitescreenshotworld-0.1.0/src/websitescreenshotworld/_files.py +217 -0
- websitescreenshotworld-0.1.0/src/websitescreenshotworld/_instant_snap.py +406 -0
- websitescreenshotworld-0.1.0/src/websitescreenshotworld/_models.py +302 -0
- websitescreenshotworld-0.1.0/src/websitescreenshotworld/_params.py +144 -0
- websitescreenshotworld-0.1.0/src/websitescreenshotworld/_requests.py +105 -0
- websitescreenshotworld-0.1.0/src/websitescreenshotworld/_transport.py +186 -0
- websitescreenshotworld-0.1.0/src/websitescreenshotworld/_version.py +1 -0
- websitescreenshotworld-0.1.0/src/websitescreenshotworld/py.typed +0 -0
- websitescreenshotworld-0.1.0/src/websitescreenshotworld/webhooks/__init__.py +56 -0
- websitescreenshotworld-0.1.0/src/websitescreenshotworld/webhooks/_events.py +313 -0
- websitescreenshotworld-0.1.0/src/websitescreenshotworld/webhooks/_verify.py +86 -0
- websitescreenshotworld-0.1.0/tests/conftest.py +33 -0
- websitescreenshotworld-0.1.0/tests/support.py +16 -0
- websitescreenshotworld-0.1.0/tests/test_client.py +508 -0
- websitescreenshotworld-0.1.0/tests/test_conformance.py +212 -0
- websitescreenshotworld-0.1.0/tests/test_dates.py +48 -0
- websitescreenshotworld-0.1.0/tests/test_webhooks.py +144 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this package are documented here. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the package uses
|
|
5
|
+
[semantic versioning](https://semver.org/).
|
|
6
|
+
|
|
7
|
+
## [0.1.0] - 2026-11-09
|
|
8
|
+
|
|
9
|
+
The first release.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- `WebsiteScreenshotWorld` and `AsyncWebsiteScreenshotWorld`, one client for both products, with keys
|
|
14
|
+
read from `WSW_INSTANT_SNAP_API_KEY` and `WSW_AUTO_SCHEDULE_API_KEY` when not passed, and an
|
|
15
|
+
optional `httpx.Client` or `httpx.AsyncClient` of your own.
|
|
16
|
+
- Instant Snap: `capture`, `capture_file` (the file streamed from the answer, with its metadata),
|
|
17
|
+
`capture_to_bucket`, `get`, `download`, `defaults` and `catalog`, taking every capture option as a
|
|
18
|
+
typed keyword argument.
|
|
19
|
+
- Instant Snap async captures: `submit` and `submit_to_bucket` (answered at once with the capture
|
|
20
|
+
`queued`) and `wait`, which reads the capture until it has finished or `timeout_seconds` runs out;
|
|
21
|
+
the `timeout` option (`SubmitOptions`, 30 to 300 seconds); the `CaptureStatus.QUEUED` and
|
|
22
|
+
`CaptureStatus.RUNNING` statuses; and the `queue_full` (`RateLimitError`, not retried) and
|
|
23
|
+
`capture_expired` (`CaptureError`) error codes.
|
|
24
|
+
- Auto Schedule: `trigger_now`.
|
|
25
|
+
- The `press_escape` capture option, and the `sitemap_schedule_not_triggerable` error code (an
|
|
26
|
+
`InvalidRequestError`: a sitemap schedule runs only on its schedule), from API version 1.1.0.
|
|
27
|
+
- Frozen dataclass results with timezone-aware `datetime` fields, string tokens with named constants,
|
|
28
|
+
and the answer as received in `raw`.
|
|
29
|
+
- An `Idempotency-Key` on every capture, generated when not given and reused on retries.
|
|
30
|
+
- Retries of `rate_limited`, `capture_busy`, `service_unavailable`, `idempotency_key_in_use` and
|
|
31
|
+
connection failures, honouring `retry_after_seconds` and `Retry-After`, within the call's timeout.
|
|
32
|
+
- One error class per kind of refusal under `WebsiteScreenshotWorldError`, plus `APIConnectionError`
|
|
33
|
+
and `APITimeoutError`.
|
|
34
|
+
- `webhooks.verify`, which checks the `X-WSW-Signature` header and returns a typed event for every
|
|
35
|
+
documented event type, or `UnknownEvent` for a type added later.
|
|
36
|
+
|
|
37
|
+
[0.1.0]: https://github.com/websitescreenshotworld/websitescreenshotworld-python/tree/v0.1.0
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Silhouettec Solutions Private Limited
|
|
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,506 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: websitescreenshotworld
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client for the Website Screenshot World Instant Snap and Auto Schedule APIs.
|
|
5
|
+
Project-URL: Homepage, https://websitescreenshotworld.com
|
|
6
|
+
Project-URL: Source, https://github.com/websitescreenshotworld/websitescreenshotworld-python
|
|
7
|
+
Project-URL: Changelog, https://github.com/websitescreenshotworld/websitescreenshotworld-python/blob/main/CHANGELOG.md
|
|
8
|
+
Project-URL: Issues, https://github.com/websitescreenshotworld/websitescreenshotworld-python/issues
|
|
9
|
+
Author-email: Silhouettec Solutions Private Limited <support@websitescreenshotworld.com>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: api client,pdf,screenshot,webhooks,website screenshot
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
23
|
+
Classifier: Typing :: Typed
|
|
24
|
+
Requires-Python: >=3.10
|
|
25
|
+
Requires-Dist: httpx<1,>=0.27
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: mypy==2.4.0; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest==9.2.0; extra == 'dev'
|
|
29
|
+
Requires-Dist: ruff==0.17.3; extra == 'dev'
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# Website Screenshot World for Python
|
|
33
|
+
|
|
34
|
+
The Python client for [Website Screenshot World](https://websitescreenshotworld.com): capture web pages
|
|
35
|
+
on demand with **Instant Snap**, start a scheduled capture now with **Auto Schedule**, and verify the
|
|
36
|
+
webhooks both send.
|
|
37
|
+
|
|
38
|
+
It has a sync and an asyncio client, typed options and results, streamed file downloads, async
|
|
39
|
+
captures (`submit`, `submit_to_bucket` and `wait`), and the retry and idempotency rules the API
|
|
40
|
+
expects.
|
|
41
|
+
|
|
42
|
+
## Install
|
|
43
|
+
|
|
44
|
+
```sh
|
|
45
|
+
pip install websitescreenshotworld
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Python 3.10 or later. The only dependency is [httpx](https://www.python-httpx.org/).
|
|
49
|
+
|
|
50
|
+
## Quick start
|
|
51
|
+
|
|
52
|
+
Create API keys in the dashboard and put them in the environment:
|
|
53
|
+
|
|
54
|
+
```sh
|
|
55
|
+
export WSW_INSTANT_SNAP_API_KEY="wsw_api_..."
|
|
56
|
+
export WSW_AUTO_SCHEDULE_API_KEY="wsw_autoapi_..."
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### Instant Snap
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
from websitescreenshotworld import WebsiteScreenshotWorld
|
|
63
|
+
|
|
64
|
+
with WebsiteScreenshotWorld() as client:
|
|
65
|
+
capture = client.instant_snap.capture(
|
|
66
|
+
url="https://example.com",
|
|
67
|
+
format="webp",
|
|
68
|
+
viewport={"width": 1440, "height": 900},
|
|
69
|
+
block_ads=True,
|
|
70
|
+
)
|
|
71
|
+
print(capture.id, capture.width, capture.height)
|
|
72
|
+
print(capture.download_url) # valid for 60 minutes
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Options are keyword arguments named exactly as in the
|
|
76
|
+
[API reference](https://api.websitescreenshotworld.com/instant-snap/v1/openapi.json). Nested blocks
|
|
77
|
+
(`viewport`, `geolocation`, `selectors`, `proxy`, `timestamp_watermark`, `page_source`) are plain
|
|
78
|
+
dictionaries, typed with `TypedDict`s so your editor and type checker know their fields. An option you
|
|
79
|
+
leave out (or pass as `None`) is not sent and takes the API's default; `False`, `0` and `""` are sent.
|
|
80
|
+
The names in `headers` and `cookies` are sent exactly as you write them.
|
|
81
|
+
|
|
82
|
+
### Auto Schedule
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
from websitescreenshotworld import TriggerStatus, WebsiteScreenshotWorld
|
|
86
|
+
|
|
87
|
+
with WebsiteScreenshotWorld() as client:
|
|
88
|
+
result = client.auto_schedule.trigger_now("7d4e2b1a-6c5f-4a3e-9d8c-2b1a0f9e8d7c")
|
|
89
|
+
if result.status == TriggerStatus.ALREADY_PENDING:
|
|
90
|
+
print("A capture of this page is already queued; nothing new was charged.")
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
The web page's id is in the dashboard. The capture's result arrives by [webhook](#webhooks).
|
|
94
|
+
|
|
95
|
+
### asyncio
|
|
96
|
+
|
|
97
|
+
`AsyncWebsiteScreenshotWorld` has the same settings and methods; await them.
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
import asyncio
|
|
101
|
+
|
|
102
|
+
from websitescreenshotworld import AsyncWebsiteScreenshotWorld
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
async def main() -> None:
|
|
106
|
+
async with AsyncWebsiteScreenshotWorld() as client:
|
|
107
|
+
capture = await client.instant_snap.capture(url="https://example.com")
|
|
108
|
+
print(capture.download_url)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
asyncio.run(main())
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Getting the file
|
|
115
|
+
|
|
116
|
+
### In the answer: `capture_file`
|
|
117
|
+
|
|
118
|
+
`capture_file` returns the file itself, streamed from the connection rather than held in memory.
|
|
119
|
+
Use it in a `with` block so the connection is released:
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
from websitescreenshotworld import WebsiteScreenshotWorld
|
|
123
|
+
|
|
124
|
+
client = WebsiteScreenshotWorld()
|
|
125
|
+
|
|
126
|
+
with client.instant_snap.capture_file(url="https://example.com", format="png") as file:
|
|
127
|
+
print(file.filename, file.content_type, file.width, file.height, file.sha256)
|
|
128
|
+
file.save("example.png")
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
The body can be read once: `save(path_or_stream)`, `read()` for the bytes, or `iter_bytes()` for
|
|
132
|
+
chunks. The metadata from the `X-InstantSnap-*` headers is on the object (`id`, `status`, `width`,
|
|
133
|
+
`height`, `size_bytes`, `duration_ms`, `page_http_status`, `captured_at`, `final_url`, `sha256`,
|
|
134
|
+
`part_count`, `spec_version`), with `filename` taken from `Content-Disposition`.
|
|
135
|
+
|
|
136
|
+
With asyncio, `async with client.instant_snap.capture_file(...) as file:` and `await file.save(...)`.
|
|
137
|
+
|
|
138
|
+
A file returned this way is not kept by the API, and `page_source` is not available with it.
|
|
139
|
+
|
|
140
|
+
### Later: `get` and `download`
|
|
141
|
+
|
|
142
|
+
A capture made with `capture` is kept for 24 hours. `get` reads it back with a fresh
|
|
143
|
+
`download_url`, and `download` streams the file to a path or a binary stream:
|
|
144
|
+
|
|
145
|
+
```python
|
|
146
|
+
from websitescreenshotworld import WebsiteScreenshotWorld
|
|
147
|
+
|
|
148
|
+
client = WebsiteScreenshotWorld()
|
|
149
|
+
|
|
150
|
+
capture = client.instant_snap.get("3f6c2a1e-9b8d-4c7e-a5f4-1d2e3c4b5a69")
|
|
151
|
+
client.instant_snap.download(capture, "example.png")
|
|
152
|
+
|
|
153
|
+
with open("example-copy.png", "wb") as stream:
|
|
154
|
+
client.instant_snap.download(capture, stream)
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
The download link is signed, so the API key is not sent with it.
|
|
158
|
+
|
|
159
|
+
### Into your bucket: `capture_to_bucket`
|
|
160
|
+
|
|
161
|
+
```python
|
|
162
|
+
import os
|
|
163
|
+
|
|
164
|
+
from websitescreenshotworld import WebsiteScreenshotWorld
|
|
165
|
+
|
|
166
|
+
client = WebsiteScreenshotWorld()
|
|
167
|
+
|
|
168
|
+
capture = client.instant_snap.capture_to_bucket(
|
|
169
|
+
url="https://example.com",
|
|
170
|
+
s3_delivery={
|
|
171
|
+
"access_key_id": os.environ["S3_ACCESS_KEY_ID"],
|
|
172
|
+
"secret_access_key": os.environ["S3_SECRET_ACCESS_KEY"],
|
|
173
|
+
"bucket": "captures",
|
|
174
|
+
"region": "eu-west-2",
|
|
175
|
+
"key_prefix": "example/",
|
|
176
|
+
},
|
|
177
|
+
)
|
|
178
|
+
assert capture.s3_delivery is not None
|
|
179
|
+
print(capture.s3_delivery.status) # "in_progress", or "completed" with wait_for_sync
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
Any S3-compatible service works: give `endpoint_url` (https) instead of, or as well as, `region`.
|
|
183
|
+
|
|
184
|
+
### Defaults and catalog
|
|
185
|
+
|
|
186
|
+
`client.instant_snap.defaults()` returns the value each option takes when you leave it out, and the
|
|
187
|
+
limits (no key needed). `client.instant_snap.catalog()` lists the devices `emulated_device` accepts
|
|
188
|
+
and the countries `proxy` accepts.
|
|
189
|
+
|
|
190
|
+
## Async captures
|
|
191
|
+
|
|
192
|
+
`submit` queues a capture and answers at once with the capture in status `queued`; it then runs when a
|
|
193
|
+
browser is free (`queued`, then `running`, then `succeeded` or `failed`). `wait` reads it with `get`
|
|
194
|
+
until it has finished:
|
|
195
|
+
|
|
196
|
+
```python
|
|
197
|
+
from websitescreenshotworld import CaptureStatus, WebsiteScreenshotWorld
|
|
198
|
+
|
|
199
|
+
with WebsiteScreenshotWorld() as client:
|
|
200
|
+
queued = client.instant_snap.submit(url="https://example.com", format="png", timeout=240)
|
|
201
|
+
print(queued.id, queued.status) # "queued"
|
|
202
|
+
|
|
203
|
+
capture = client.instant_snap.wait(queued, timeout_seconds=360, poll_interval_seconds=3)
|
|
204
|
+
if capture.status == CaptureStatus.SUCCEEDED:
|
|
205
|
+
client.instant_snap.download(capture, "example.png")
|
|
206
|
+
elif capture.status == CaptureStatus.FAILED:
|
|
207
|
+
print("Failed:", capture.error_code)
|
|
208
|
+
else:
|
|
209
|
+
print("Still", capture.status, "- wait again later")
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
`submit_to_bucket` does the same into your bucket, taking `s3_delivery` as `capture_to_bucket` does:
|
|
213
|
+
|
|
214
|
+
```python
|
|
215
|
+
import os
|
|
216
|
+
|
|
217
|
+
from websitescreenshotworld import WebsiteScreenshotWorld
|
|
218
|
+
|
|
219
|
+
client = WebsiteScreenshotWorld()
|
|
220
|
+
|
|
221
|
+
queued = client.instant_snap.submit_to_bucket(
|
|
222
|
+
url="https://example.com",
|
|
223
|
+
s3_delivery={
|
|
224
|
+
"access_key_id": os.environ["S3_ACCESS_KEY_ID"],
|
|
225
|
+
"secret_access_key": os.environ["S3_SECRET_ACCESS_KEY"],
|
|
226
|
+
"bucket": "captures",
|
|
227
|
+
"region": "eu-west-2",
|
|
228
|
+
},
|
|
229
|
+
)
|
|
230
|
+
capture = client.instant_snap.wait(queued.id)
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
- `timeout` here is the API's option (`SubmitOptions`): the browser time the capture may use, 30 to
|
|
234
|
+
300 seconds, 120 when left out. Only `submit` and `submit_to_bucket` take it. The call itself waits
|
|
235
|
+
only for the answer, so it uses the client's `timeout` (30 seconds), not `capture_timeout`.
|
|
236
|
+
- A capture still waiting 15 minutes after it was accepted ends `failed` with `capture_expired`, and
|
|
237
|
+
is not charged.
|
|
238
|
+
- When the organization already has as many async captures waiting or running as it may, `submit`
|
|
239
|
+
raises `RateLimitError` with `error_code` `queue_full` and `retry_after_seconds`. It is not retried
|
|
240
|
+
by the library: wait that long, or for some captures to finish, and submit again.
|
|
241
|
+
- `wait` takes the capture or its id. It returns as soon as the status is no longer `queued`,
|
|
242
|
+
`running` or `pending`. If `timeout_seconds` (default 360) runs out first, it returns the capture as
|
|
243
|
+
last read, still unfinished, rather than raising. `poll_interval_seconds` defaults to 3. An error
|
|
244
|
+
from `get` is raised as it is. With asyncio, cancelling the task or `asyncio.timeout()` stops it.
|
|
245
|
+
|
|
246
|
+
## Results
|
|
247
|
+
|
|
248
|
+
Results are frozen dataclasses: `Capture`, `TriggerResult`, `Defaults`, `Catalog`. Timestamps are
|
|
249
|
+
timezone-aware `datetime`s. Tokens such as `status` and `format` stay plain strings, so a value the
|
|
250
|
+
API adds later still comes through; compare them with the constants in `CaptureStatus`, `Format`,
|
|
251
|
+
`MediaCategory`, `ResponseType`, `S3DeliveryStatus`, `TriggerStatus` and `TriggeredBy`. Each result
|
|
252
|
+
also has `raw`, the answer as received, for fields added after this release.
|
|
253
|
+
|
|
254
|
+
## Errors and retries
|
|
255
|
+
|
|
256
|
+
Every error is a `WebsiteScreenshotWorldError` with `error_code`, `message`, `http_status`,
|
|
257
|
+
`retry_after_seconds` and `body`. Branch on the class or on `error_code`, never on the status or the
|
|
258
|
+
message:
|
|
259
|
+
|
|
260
|
+
| Class | Raised for |
|
|
261
|
+
|---|---|
|
|
262
|
+
| `AuthenticationError` | `api_key_invalid`, or no key for the product (raised before sending) |
|
|
263
|
+
| `InvalidRequestError` | `validation_failed`, `s3_destination_invalid`, `web_page_id_invalid`, `idempotency_key_reused`, `sitemap_schedule_not_triggerable` |
|
|
264
|
+
| `PlanLimitError` | `quota_exhausted`, `feature_not_available`, `no_trigger_allowance`, `forbidden` |
|
|
265
|
+
| `NotFoundError` | `capture_not_found`, `web_page_not_found`, `not_found` |
|
|
266
|
+
| `RateLimitError` | `rate_limited`, `queue_full` |
|
|
267
|
+
| `CaptureError` | `capture_failed`, `capture_timeout`, `payload_too_large`, `capture_expired` |
|
|
268
|
+
| `ServiceError` | `capture_busy`, `service_unavailable`, `internal_error`, `request_cancelled`, `idempotency_key_in_use` |
|
|
269
|
+
| `APIConnectionError` | the request could not be sent, or no answer came back |
|
|
270
|
+
| `APITimeoutError` | a subclass of `APIConnectionError`: the timeout passed |
|
|
271
|
+
| `SignatureVerificationError` | a webhook signature that does not match |
|
|
272
|
+
|
|
273
|
+
A code this version does not know yet raises `WebsiteScreenshotWorldError` itself.
|
|
274
|
+
|
|
275
|
+
```python
|
|
276
|
+
from websitescreenshotworld import (
|
|
277
|
+
CaptureError,
|
|
278
|
+
PlanLimitError,
|
|
279
|
+
WebsiteScreenshotWorld,
|
|
280
|
+
WebsiteScreenshotWorldError,
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
client = WebsiteScreenshotWorld()
|
|
284
|
+
|
|
285
|
+
try:
|
|
286
|
+
capture = client.instant_snap.capture(url="https://example.com")
|
|
287
|
+
except CaptureError as error:
|
|
288
|
+
print("The page could not be captured:", error.message)
|
|
289
|
+
except PlanLimitError as error:
|
|
290
|
+
print("Plan limit:", error.error_code)
|
|
291
|
+
except WebsiteScreenshotWorldError as error:
|
|
292
|
+
print("Failed:", error)
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
`rate_limited`, `capture_busy`, `service_unavailable`, `idempotency_key_in_use` and connection
|
|
296
|
+
failures are retried twice (three attempts in all). The wait is the API's `retry_after_seconds` (or
|
|
297
|
+
`Retry-After`), at most 60 seconds, and otherwise 0.5 s and then 1 s with a little jitter. Nothing
|
|
298
|
+
else is retried: `capture_failed` and `capture_timeout` are answers about the website, and a retry
|
|
299
|
+
would fail the same way; `queue_full` would only meet the same full queue, so it is raised at once
|
|
300
|
+
with `retry_after_seconds`. Set `max_retries` on the client to change the count, or `0` to turn retries
|
|
301
|
+
off.
|
|
302
|
+
|
|
303
|
+
## Idempotency keys
|
|
304
|
+
|
|
305
|
+
Every capture carries an `Idempotency-Key`, so a retry never captures or charges twice. The client
|
|
306
|
+
makes a random one per call and reuses it on that call's retries. Pass your own to make a call safe
|
|
307
|
+
to repeat across processes for 24 hours, for example when a job queue may run a task twice:
|
|
308
|
+
|
|
309
|
+
```python
|
|
310
|
+
from websitescreenshotworld import WebsiteScreenshotWorld
|
|
311
|
+
|
|
312
|
+
client = WebsiteScreenshotWorld()
|
|
313
|
+
|
|
314
|
+
capture = client.instant_snap.capture(
|
|
315
|
+
url="https://example.com/pricing",
|
|
316
|
+
idempotency_key="order-1234-hero",
|
|
317
|
+
)
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
A repeat with the same key gets the first capture back. The key must not be reused with different
|
|
321
|
+
options (`InvalidRequestError`, `idempotency_key_reused`), and `capture_file` results are not kept,
|
|
322
|
+
so they cannot be replayed. `trigger_now` needs no key: it is idempotent by itself.
|
|
323
|
+
|
|
324
|
+
## Timeouts
|
|
325
|
+
|
|
326
|
+
A capture may take the API up to 120 seconds on a slow page, so captures are allowed 180 seconds
|
|
327
|
+
and every other call 30 seconds. The time covers the whole call, retries included: once it has
|
|
328
|
+
passed, retrying stops and `APITimeoutError` is raised. Change the defaults on the client, or pass
|
|
329
|
+
`timeout` to a single capture or download:
|
|
330
|
+
|
|
331
|
+
```python
|
|
332
|
+
from websitescreenshotworld import WebsiteScreenshotWorld
|
|
333
|
+
|
|
334
|
+
client = WebsiteScreenshotWorld(timeout=10, capture_timeout=240)
|
|
335
|
+
capture = client.instant_snap.capture(url="https://example.com", timeout=60)
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
`submit` and `submit_to_bucket` use `timeout`, not `capture_timeout`, since they are answered at
|
|
339
|
+
once; their `timeout` keyword is the API's async option (see [Async captures](#async-captures)).
|
|
340
|
+
|
|
341
|
+
For asyncio, `asyncio.timeout()` and task cancellation stop a call, retries included, at once.
|
|
342
|
+
|
|
343
|
+
## Webhooks
|
|
344
|
+
|
|
345
|
+
Deliveries are signed with the endpoint's secret in the `X-WSW-Signature` header.
|
|
346
|
+
`webhooks.verify` checks the signature and the timestamp (within 5 minutes by default) and returns a
|
|
347
|
+
typed event:
|
|
348
|
+
|
|
349
|
+
```python
|
|
350
|
+
from websitescreenshotworld import webhooks
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def handle(body: bytes, signature: str | None, secret: str) -> None:
|
|
354
|
+
event = webhooks.verify(body, signature, secret)
|
|
355
|
+
match event:
|
|
356
|
+
case webhooks.InstantSnapCaptureEvent(type="capture.succeeded", data=capture):
|
|
357
|
+
print("Instant Snap capture ready:", capture.id)
|
|
358
|
+
case webhooks.AutoScheduleCaptureEvent(data=capture):
|
|
359
|
+
print(event.type, capture.schedule_id, capture.status)
|
|
360
|
+
case webhooks.CaptureChangedEvent(data=change):
|
|
361
|
+
print(f"{change.url} changed by {change.change_percent}%")
|
|
362
|
+
case webhooks.UnknownEvent():
|
|
363
|
+
print("An event type this library does not know yet:", event.type, event.data)
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
| Class | `type` | `data` |
|
|
367
|
+
|---|---|---|
|
|
368
|
+
| `InstantSnapCaptureEvent` | `capture.succeeded`, `capture.failed` (Instant Snap) | `Capture` |
|
|
369
|
+
| `AutoScheduleCaptureEvent` | `capture.succeeded`, `capture.failed` (Auto Schedule) | `ScheduleCapture` |
|
|
370
|
+
| `DeliveryCompletedEvent` | `delivery.completed` | `Delivery` |
|
|
371
|
+
| `CaptureChangedEvent` | `capture.changed` | `CaptureChange` |
|
|
372
|
+
| `SchedulePausedEvent` | `schedule.paused` | `SchedulePaused` |
|
|
373
|
+
| `SitemapRunCompletedEvent` | `sitemap_run.completed` | `SitemapRun` |
|
|
374
|
+
| `PingEvent` | `ping` | `Ping` |
|
|
375
|
+
| `UnknownEvent` | anything else | the `data` dictionary |
|
|
376
|
+
|
|
377
|
+
Every event has `id` (the same on redeliveries, so you can drop duplicates), `type`, `created_at`,
|
|
378
|
+
`product` and `raw`. A bad signature raises `SignatureVerificationError`; answer it with a 400.
|
|
379
|
+
|
|
380
|
+
**Pass the raw body.** The signature covers the exact bytes sent. A framework that parses the JSON
|
|
381
|
+
and serialises it again changes them, and the signature no longer matches.
|
|
382
|
+
|
|
383
|
+
Flask:
|
|
384
|
+
|
|
385
|
+
```python
|
|
386
|
+
from flask import Flask, request
|
|
387
|
+
|
|
388
|
+
from websitescreenshotworld import webhooks
|
|
389
|
+
|
|
390
|
+
app = Flask(__name__)
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
@app.post("/webhooks/wsw")
|
|
394
|
+
def wsw_webhook() -> tuple[str, int]:
|
|
395
|
+
try:
|
|
396
|
+
event = webhooks.verify(
|
|
397
|
+
request.get_data(), request.headers.get("X-WSW-Signature"), app.config["WSW_SECRET"]
|
|
398
|
+
)
|
|
399
|
+
except webhooks.SignatureVerificationError:
|
|
400
|
+
return "bad signature", 400
|
|
401
|
+
print(event.type)
|
|
402
|
+
return "", 204
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
Django:
|
|
406
|
+
|
|
407
|
+
```python
|
|
408
|
+
from django.conf import settings
|
|
409
|
+
from django.http import HttpRequest, HttpResponse, HttpResponseBadRequest
|
|
410
|
+
from django.views.decorators.csrf import csrf_exempt
|
|
411
|
+
from django.views.decorators.http import require_POST
|
|
412
|
+
|
|
413
|
+
from websitescreenshotworld import webhooks
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
@csrf_exempt
|
|
417
|
+
@require_POST
|
|
418
|
+
def wsw_webhook(request: HttpRequest) -> HttpResponse:
|
|
419
|
+
try:
|
|
420
|
+
event = webhooks.verify(
|
|
421
|
+
request.body, request.headers.get("X-WSW-Signature"), settings.WSW_SECRET
|
|
422
|
+
)
|
|
423
|
+
except webhooks.SignatureVerificationError:
|
|
424
|
+
return HttpResponseBadRequest("bad signature")
|
|
425
|
+
print(event.type)
|
|
426
|
+
return HttpResponse(status=204)
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
FastAPI (and Starlette):
|
|
430
|
+
|
|
431
|
+
```python
|
|
432
|
+
import os
|
|
433
|
+
|
|
434
|
+
from fastapi import FastAPI, HTTPException, Request, Response
|
|
435
|
+
|
|
436
|
+
from websitescreenshotworld import webhooks
|
|
437
|
+
|
|
438
|
+
app = FastAPI()
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
@app.post("/webhooks/wsw", status_code=204)
|
|
442
|
+
async def wsw_webhook(request: Request) -> Response:
|
|
443
|
+
try:
|
|
444
|
+
event = webhooks.verify(
|
|
445
|
+
await request.body(),
|
|
446
|
+
request.headers.get("X-WSW-Signature"),
|
|
447
|
+
os.environ["WSW_WEBHOOK_SECRET"],
|
|
448
|
+
)
|
|
449
|
+
except webhooks.SignatureVerificationError:
|
|
450
|
+
raise HTTPException(status_code=400, detail="bad signature") from None
|
|
451
|
+
print(event.type)
|
|
452
|
+
return Response(status_code=204)
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
`webhooks.SignatureVerificationError` is the same class as `websitescreenshotworld.SignatureVerificationError`.
|
|
456
|
+
|
|
457
|
+
## Configuration
|
|
458
|
+
|
|
459
|
+
| Argument | Default |
|
|
460
|
+
|---|---|
|
|
461
|
+
| `instant_snap_api_key` | `WSW_INSTANT_SNAP_API_KEY` from the environment |
|
|
462
|
+
| `auto_schedule_api_key` | `WSW_AUTO_SCHEDULE_API_KEY` from the environment |
|
|
463
|
+
| `instant_snap_base_url` | `https://api.websitescreenshotworld.com` |
|
|
464
|
+
| `auto_schedule_base_url` | `https://autoapi.websitescreenshotworld.com` |
|
|
465
|
+
| `capture_timeout` | 180 seconds |
|
|
466
|
+
| `timeout` | 30 seconds, for everything but captures |
|
|
467
|
+
| `max_retries` | 2 |
|
|
468
|
+
| `http_client` | a new `httpx.Client` (`httpx.AsyncClient` for the async client) |
|
|
469
|
+
|
|
470
|
+
Either key may be left out if you use only one product; calling the other raises
|
|
471
|
+
`AuthenticationError` before anything is sent.
|
|
472
|
+
|
|
473
|
+
Pass your own `httpx.Client` for proxies, custom certificates or connection limits. The library does
|
|
474
|
+
not close a client you pass in:
|
|
475
|
+
|
|
476
|
+
```python
|
|
477
|
+
import httpx
|
|
478
|
+
|
|
479
|
+
from websitescreenshotworld import WebsiteScreenshotWorld
|
|
480
|
+
|
|
481
|
+
http = httpx.Client(proxy="http://proxy.example.com:3128")
|
|
482
|
+
client = WebsiteScreenshotWorld(http_client=http)
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
Create the client once and reuse it, so connections are pooled.
|
|
486
|
+
|
|
487
|
+
## Supported versions
|
|
488
|
+
|
|
489
|
+
CPython 3.10 to 3.13, tested on 3.10 and 3.13. The library follows semantic versioning; until 1.0, a
|
|
490
|
+
minor version may change the API, as noted in the [changelog](CHANGELOG.md).
|
|
491
|
+
|
|
492
|
+
## Development
|
|
493
|
+
|
|
494
|
+
```sh
|
|
495
|
+
pip install -e ".[dev]"
|
|
496
|
+
ruff format --check . && ruff check . && mypy
|
|
497
|
+
pytest
|
|
498
|
+
```
|
|
499
|
+
|
|
500
|
+
The tests start `spec/conformance/server.js`, so they need Node.js 22 or later. `spec/` is the
|
|
501
|
+
[websitescreenshotworld-spec](https://github.com/websitescreenshotworld/websitescreenshotworld-spec) submodule:
|
|
502
|
+
clone with `git clone --recurse-submodules`, or run `git submodule update --init` in an existing clone.
|
|
503
|
+
|
|
504
|
+
## License
|
|
505
|
+
|
|
506
|
+
MIT. See [LICENSE](LICENSE).
|