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.
Files changed (30) hide show
  1. websitescreenshotworld-0.1.0/.gitignore +13 -0
  2. websitescreenshotworld-0.1.0/CHANGELOG.md +37 -0
  3. websitescreenshotworld-0.1.0/LICENSE +21 -0
  4. websitescreenshotworld-0.1.0/PKG-INFO +506 -0
  5. websitescreenshotworld-0.1.0/README.md +475 -0
  6. websitescreenshotworld-0.1.0/pyproject.toml +65 -0
  7. websitescreenshotworld-0.1.0/src/websitescreenshotworld/__init__.py +102 -0
  8. websitescreenshotworld-0.1.0/src/websitescreenshotworld/_auto_schedule.py +41 -0
  9. websitescreenshotworld-0.1.0/src/websitescreenshotworld/_client.py +132 -0
  10. websitescreenshotworld-0.1.0/src/websitescreenshotworld/_config.py +92 -0
  11. websitescreenshotworld-0.1.0/src/websitescreenshotworld/_constants.py +122 -0
  12. websitescreenshotworld-0.1.0/src/websitescreenshotworld/_dates.py +34 -0
  13. websitescreenshotworld-0.1.0/src/websitescreenshotworld/_errors.py +198 -0
  14. websitescreenshotworld-0.1.0/src/websitescreenshotworld/_files.py +217 -0
  15. websitescreenshotworld-0.1.0/src/websitescreenshotworld/_instant_snap.py +406 -0
  16. websitescreenshotworld-0.1.0/src/websitescreenshotworld/_models.py +302 -0
  17. websitescreenshotworld-0.1.0/src/websitescreenshotworld/_params.py +144 -0
  18. websitescreenshotworld-0.1.0/src/websitescreenshotworld/_requests.py +105 -0
  19. websitescreenshotworld-0.1.0/src/websitescreenshotworld/_transport.py +186 -0
  20. websitescreenshotworld-0.1.0/src/websitescreenshotworld/_version.py +1 -0
  21. websitescreenshotworld-0.1.0/src/websitescreenshotworld/py.typed +0 -0
  22. websitescreenshotworld-0.1.0/src/websitescreenshotworld/webhooks/__init__.py +56 -0
  23. websitescreenshotworld-0.1.0/src/websitescreenshotworld/webhooks/_events.py +313 -0
  24. websitescreenshotworld-0.1.0/src/websitescreenshotworld/webhooks/_verify.py +86 -0
  25. websitescreenshotworld-0.1.0/tests/conftest.py +33 -0
  26. websitescreenshotworld-0.1.0/tests/support.py +16 -0
  27. websitescreenshotworld-0.1.0/tests/test_client.py +508 -0
  28. websitescreenshotworld-0.1.0/tests/test_conformance.py +212 -0
  29. websitescreenshotworld-0.1.0/tests/test_dates.py +48 -0
  30. websitescreenshotworld-0.1.0/tests/test_webhooks.py +144 -0
@@ -0,0 +1,13 @@
1
+ .DS_Store
2
+ .idea/
3
+ .vscode/
4
+ *.swp
5
+ __pycache__/
6
+ *.py[cod]
7
+ .venv/
8
+ dist/
9
+ build/
10
+ *.egg-info/
11
+ .pytest_cache/
12
+ .mypy_cache/
13
+ .ruff_cache/
@@ -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).