websitescreenshotworld 0.1.3
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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +59 -0
- data/LICENSE +21 -0
- data/README.md +371 -0
- data/lib/websitescreenshotworld/auto_schedule.rb +22 -0
- data/lib/websitescreenshotworld/capture.rb +124 -0
- data/lib/websitescreenshotworld/capture_file.rb +167 -0
- data/lib/websitescreenshotworld/catalog.rb +19 -0
- data/lib/websitescreenshotworld/client.rb +48 -0
- data/lib/websitescreenshotworld/defaults.rb +87 -0
- data/lib/websitescreenshotworld/error_response.rb +76 -0
- data/lib/websitescreenshotworld/errors.rb +63 -0
- data/lib/websitescreenshotworld/event_data.rb +164 -0
- data/lib/websitescreenshotworld/events.rb +74 -0
- data/lib/websitescreenshotworld/instant_snap.rb +260 -0
- data/lib/websitescreenshotworld/model.rb +46 -0
- data/lib/websitescreenshotworld/service.rb +65 -0
- data/lib/websitescreenshotworld/tokens.rb +110 -0
- data/lib/websitescreenshotworld/transport.rb +124 -0
- data/lib/websitescreenshotworld/trigger_result.rb +24 -0
- data/lib/websitescreenshotworld/version.rb +6 -0
- data/lib/websitescreenshotworld/webhook.rb +68 -0
- data/lib/websitescreenshotworld.rb +33 -0
- metadata +69 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 18f107d8d939617295e29e83fdebb5fcbeb03f641b49df6017769b16169b8fec
|
|
4
|
+
data.tar.gz: 9b1ca9e8c2b2fa3f7312672e1b7628e97615f5601b87ce1fcc0a65d295da0a41
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 6bdeeeecca966e596712851b050965e7c07c379cbfbd266959e2d1c69874884f7cd85ca7662fce47b94da259dfc760c46cef51f9b00f4c1b75578f3632eb1fc3
|
|
7
|
+
data.tar.gz: de2bcee7717050479ecfcce4b600abf981758c522db692857991ef55fd873aecabf4ba5d9c33c4f435ac0af8b622627bd6661b80deab60fcc1eded5b5d9c110e
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this gem are documented here. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the gem follows
|
|
5
|
+
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
7
|
+
## 0.1.3 - 2026-11-13
|
|
8
|
+
|
|
9
|
+
The first version on RubyGems. The library is the same as 0.1.0; 0.1.0 to 0.1.2 were tagged but never published.
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- Publishing: the release job names the owning RubyGems.org account when it exchanges its OIDC token, which
|
|
14
|
+
RubyGems.org requires before a trusted publisher may create a new gem.
|
|
15
|
+
|
|
16
|
+
## 0.1.2 - 2026-11-13
|
|
17
|
+
|
|
18
|
+
Tagged but not published (see 0.1.3).
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- A test compared the User-Agent with a hard-coded 0.1.0, so the 0.1.1 release stopped at the test step. It now
|
|
23
|
+
reads the version from `WebsiteScreenshotWorld::VERSION`.
|
|
24
|
+
|
|
25
|
+
## 0.1.1 - 2026-11-13
|
|
26
|
+
|
|
27
|
+
Tagged but not published (see 0.1.2).
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
|
|
31
|
+
- Publishing: the release job builds and pushes the gem itself instead of running `rake release`, which tried to
|
|
32
|
+
create and push a `ruby-v` tag left over from the old multi-library repository.
|
|
33
|
+
|
|
34
|
+
## 0.1.0 - 2026-11-09
|
|
35
|
+
|
|
36
|
+
The first release.
|
|
37
|
+
|
|
38
|
+
### Added
|
|
39
|
+
|
|
40
|
+
- `WebsiteScreenshotWorld::Client`, one client for both products, with keys read from
|
|
41
|
+
`WSW_INSTANT_SNAP_API_KEY` and `WSW_AUTO_SCHEDULE_API_KEY` by default.
|
|
42
|
+
- Instant Snap: `capture`, `capture_file` (streamed, or buffered to a `Tempfile`), `capture_to_bucket`,
|
|
43
|
+
`get`, `download`, `defaults` and `catalog`, taking every capture option of the API as keyword arguments.
|
|
44
|
+
- Auto Schedule: `trigger_now`.
|
|
45
|
+
- The `press_escape` capture option, and the `sitemap_schedule_not_triggerable` error code (an
|
|
46
|
+
`InvalidRequestError`: a sitemap schedule runs only on its schedule), from API version 1.1.0.
|
|
47
|
+
- `WebsiteScreenshotWorld::Webhook.verify`, which checks the `X-WSW-Signature` header and returns a typed
|
|
48
|
+
event for each webhook type, or `UnknownEvent` for types added later.
|
|
49
|
+
- Immutable `Data` results with `Time` fields and the original JSON in `#raw`.
|
|
50
|
+
- One error class per kind of refusal under `WebsiteScreenshotWorld::Error`, plus `ConnectionError`.
|
|
51
|
+
- Automatic retries of rate limits, busy or unavailable services and connection failures, with a random
|
|
52
|
+
`Idempotency-Key` per capture so a retry never captures twice, and timeouts that cover the whole call, retries
|
|
53
|
+
included.
|
|
54
|
+
- Redirects are never followed, so the API key is never sent to another host; a 3xx answer is raised as an error.
|
|
55
|
+
- Async Instant Snap captures: `submit` and `submit_to_bucket` (answered at once with the capture
|
|
56
|
+
`queued`) and `wait`, which polls `get` until the capture has finished or its `timeout_seconds` run out;
|
|
57
|
+
the `timeout:` option of an async capture (30 to 300 seconds of browser time); the `CaptureStatus::QUEUED`
|
|
58
|
+
and `CaptureStatus::RUNNING` statuses; and the `queue_full` (`RateLimitError`, not retried) and
|
|
59
|
+
`capture_expired` (`CaptureError`) error codes.
|
data/LICENSE
ADDED
|
@@ -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.
|
data/README.md
ADDED
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
# Website Screenshot World for Ruby
|
|
2
|
+
|
|
3
|
+
The Ruby client for [Website Screenshot World](https://websitescreenshotworld.com): capture web pages as
|
|
4
|
+
images, videos and PDFs with **Instant Snap**, start scheduled captures with **Auto Schedule**, and verify
|
|
5
|
+
the webhooks both send.
|
|
6
|
+
|
|
7
|
+
It has no runtime dependencies beyond the Ruby standard library.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
bundle add websitescreenshotworld
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
or `gem install websitescreenshotworld`.
|
|
16
|
+
|
|
17
|
+
## Quick start
|
|
18
|
+
|
|
19
|
+
### Instant Snap
|
|
20
|
+
|
|
21
|
+
```ruby
|
|
22
|
+
require "websitescreenshotworld"
|
|
23
|
+
|
|
24
|
+
# Reads WSW_INSTANT_SNAP_API_KEY and WSW_AUTO_SCHEDULE_API_KEY from the environment.
|
|
25
|
+
client = WebsiteScreenshotWorld::Client.new
|
|
26
|
+
|
|
27
|
+
capture = client.instant_snap.capture(
|
|
28
|
+
url: "https://example.com",
|
|
29
|
+
format: "webp",
|
|
30
|
+
viewport: { width: 1440, height: 900 },
|
|
31
|
+
scroll_mode: "scroll",
|
|
32
|
+
block_ads: false
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
capture.id # => "3f6c2a1e-..."
|
|
36
|
+
capture.status # => "succeeded"
|
|
37
|
+
capture.captured_at # => 2026-09-26 10:30:04.512 UTC (a Time)
|
|
38
|
+
capture.download_url # valid for 60 minutes
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Options are keyword arguments with the API's own names; nested blocks are hashes. An option you leave out
|
|
42
|
+
is not sent and takes the server's default, while `false`, `0` and `""` are sent as given. The keys of
|
|
43
|
+
`headers:` and `cookies:` are sent exactly as you write them. Every option is listed in
|
|
44
|
+
`WebsiteScreenshotWorld::InstantSnap::CAPTURE_OPTIONS`, and a misspelt one raises `ArgumentError` before
|
|
45
|
+
anything is sent.
|
|
46
|
+
|
|
47
|
+
Constants name the known values of each token, for example `WebsiteScreenshotWorld::Format::WEBP` or
|
|
48
|
+
`WebsiteScreenshotWorld::CaptureStatus::SUCCEEDED`. Tokens are plain strings, so a value added to the API
|
|
49
|
+
later still comes through.
|
|
50
|
+
|
|
51
|
+
### Auto Schedule
|
|
52
|
+
|
|
53
|
+
```ruby
|
|
54
|
+
result = client.auto_schedule.trigger_now("7d4e2b1a-6c5f-4a3e-9d8c-2b1a0f9e8d7c")
|
|
55
|
+
|
|
56
|
+
result.status # => "triggered", or "already_pending" if a capture was already queued
|
|
57
|
+
result.triggered_at # => a Time
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
The web page is set up in the dashboard. Its capture arrives later by [webhook](#webhooks), where the
|
|
61
|
+
same id is called `schedule_id`.
|
|
62
|
+
|
|
63
|
+
## Saving the file with `capture_file`
|
|
64
|
+
|
|
65
|
+
`capture_file` answers with the file itself (up to 64 MB) and its metadata. With a block, the body streams
|
|
66
|
+
straight from the connection and is never held in memory:
|
|
67
|
+
|
|
68
|
+
```ruby
|
|
69
|
+
client.instant_snap.capture_file(url: "https://example.com", format: "png") do |file|
|
|
70
|
+
file.filename # => "3f6c2a1e-....png"
|
|
71
|
+
file.width # => 1920
|
|
72
|
+
file.sha256 # => lowercase hex digest of the body
|
|
73
|
+
file.save("page.png")
|
|
74
|
+
end
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
`save` also takes an open IO, such as a socket or an upload stream. `read` returns the bytes as a String
|
|
78
|
+
and `each_chunk` yields them as they arrive. The body can be read once.
|
|
79
|
+
|
|
80
|
+
Without a block, the body is buffered to a `Tempfile` first, so the file outlives the connection. Close it
|
|
81
|
+
when you are done to delete the temporary copy:
|
|
82
|
+
|
|
83
|
+
```ruby
|
|
84
|
+
file = client.instant_snap.capture_file(url: "https://example.com")
|
|
85
|
+
begin
|
|
86
|
+
file.path # the Tempfile's path
|
|
87
|
+
file.save("page.png")
|
|
88
|
+
ensure
|
|
89
|
+
file.close
|
|
90
|
+
end
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Uploading to a bucket
|
|
94
|
+
|
|
95
|
+
`capture_to_bucket` uploads the file to your S3-compatible bucket instead of keeping it:
|
|
96
|
+
|
|
97
|
+
```ruby
|
|
98
|
+
capture = client.instant_snap.capture_to_bucket(
|
|
99
|
+
url: "https://example.com",
|
|
100
|
+
s3_delivery: {
|
|
101
|
+
access_key_id: ENV.fetch("S3_ACCESS_KEY_ID"),
|
|
102
|
+
secret_access_key: ENV.fetch("S3_SECRET_ACCESS_KEY"),
|
|
103
|
+
bucket: "captures",
|
|
104
|
+
region: "eu-west-2",
|
|
105
|
+
key_prefix: "site/",
|
|
106
|
+
wait_for_sync: true
|
|
107
|
+
}
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
capture.s3_delivery.status # => "completed", "in_progress", ...
|
|
111
|
+
capture.s3_delivery.key # => the object key, once known
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Use `endpoint_url:` (and `force_path_style: true` where needed) for providers other than AWS.
|
|
115
|
+
|
|
116
|
+
## Async captures
|
|
117
|
+
|
|
118
|
+
`submit` answers at once, before the capture runs, with the capture in status `queued`; it then goes
|
|
119
|
+
`running`, and ends `succeeded` or `failed`. `wait` reads it with `get` every `poll_interval_seconds`
|
|
120
|
+
(default 3) until it has finished, and returns it:
|
|
121
|
+
|
|
122
|
+
```ruby
|
|
123
|
+
queued = client.instant_snap.submit(url: "https://example.com", format: "webp", timeout: 240)
|
|
124
|
+
queued.status # => "queued"
|
|
125
|
+
|
|
126
|
+
capture = client.instant_snap.wait(queued) # or wait(queued.id), with timeout_seconds: 360 by default
|
|
127
|
+
if capture.status == WebsiteScreenshotWorld::CaptureStatus::SUCCEEDED
|
|
128
|
+
client.instant_snap.download(capture, "page.webp")
|
|
129
|
+
end
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
If `timeout_seconds` runs out first, `wait` returns the capture as last read, still `queued` or `running`,
|
|
133
|
+
rather than raising, so you can wait again. The `capture.succeeded` and `capture.failed` [webhooks](#webhooks)
|
|
134
|
+
tell you without polling.
|
|
135
|
+
|
|
136
|
+
`submit_to_bucket` is the async `capture_to_bucket`:
|
|
137
|
+
|
|
138
|
+
```ruby
|
|
139
|
+
queued = client.instant_snap.submit_to_bucket(
|
|
140
|
+
url: "https://example.com",
|
|
141
|
+
s3_delivery: { access_key_id: "...", secret_access_key: "...", bucket: "captures", region: "eu-west-2" }
|
|
142
|
+
)
|
|
143
|
+
capture = client.instant_snap.wait(queued.id, poll_interval_seconds: 5)
|
|
144
|
+
capture.s3_delivery.status # => "completed", ...
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
- On `submit` and `submit_to_bucket`, `timeout:` is the browser time the capture may use, 30 to 300 seconds
|
|
148
|
+
(the server uses 120 when you leave it out). The request itself is quick and uses the client's ordinary
|
|
149
|
+
`timeout` (30 seconds by default). On the other capture methods `timeout:` is the call's own timeout, as in
|
|
150
|
+
[Timeouts](#timeouts).
|
|
151
|
+
- A capture still queued 15 minutes after it was accepted ends `failed` with `capture_expired`, and is not
|
|
152
|
+
charged.
|
|
153
|
+
- When your organization already has as many async captures waiting or running as it may, `submit` raises
|
|
154
|
+
`RateLimitError` with `error_code` `queue_full` and `retry_after_seconds`. It is not retried, since a retry
|
|
155
|
+
would meet the same queue.
|
|
156
|
+
|
|
157
|
+
## Reading a capture back
|
|
158
|
+
|
|
159
|
+
```ruby
|
|
160
|
+
capture = client.instant_snap.get("3f6c2a1e-9b8d-4c7e-a5f4-1d2e3c4b5a69")
|
|
161
|
+
|
|
162
|
+
# get reissues download_url; download streams it to a path or an IO.
|
|
163
|
+
client.instant_snap.download(capture, "page.png")
|
|
164
|
+
client.instant_snap.download(capture.page_source, "page.html") if capture.page_source
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
Files are kept for 24 hours (`capture.expires_at`). The API key is not sent to the download host.
|
|
168
|
+
|
|
169
|
+
`client.instant_snap.defaults` returns the values a capture takes for options it does not send (no key
|
|
170
|
+
needed), and `client.instant_snap.catalog` lists the accepted `emulated_device` and `proxy: { country: }`
|
|
171
|
+
values.
|
|
172
|
+
|
|
173
|
+
Every result is an immutable `Data` object. Dates are `Time` objects in UTC, and `raw` holds the JSON
|
|
174
|
+
object as the API sent it, including fields added after this version of the gem.
|
|
175
|
+
|
|
176
|
+
## Errors and retries
|
|
177
|
+
|
|
178
|
+
Every error inherits from `WebsiteScreenshotWorld::Error`, which carries `error_code`, `message`,
|
|
179
|
+
`http_status`, `retry_after_seconds` and the raw response `body`. Switch on `error_code`, never on the
|
|
180
|
+
status or the message.
|
|
181
|
+
|
|
182
|
+
| Class | Raised for |
|
|
183
|
+
|---|---|
|
|
184
|
+
| `AuthenticationError` | `api_key_invalid`, or a key that was never given |
|
|
185
|
+
| `InvalidRequestError` | `validation_failed`, `s3_destination_invalid`, `web_page_id_invalid`, `idempotency_key_reused`, `sitemap_schedule_not_triggerable` |
|
|
186
|
+
| `PlanLimitError` | `quota_exhausted`, `feature_not_available`, `no_trigger_allowance`, `forbidden` |
|
|
187
|
+
| `NotFoundError` | `capture_not_found`, `web_page_not_found`, `not_found` |
|
|
188
|
+
| `RateLimitError` | `rate_limited`, `queue_full` |
|
|
189
|
+
| `CaptureError` | `capture_failed`, `capture_timeout`, `payload_too_large`, `capture_expired` |
|
|
190
|
+
| `ServiceError` | `capture_busy`, `service_unavailable`, `internal_error`, `request_cancelled`, `idempotency_key_in_use` |
|
|
191
|
+
| `ConnectionError` | no answer: the connection failed or broke, or the timeout ran out (see `cause`) |
|
|
192
|
+
| `SignatureVerificationError` | a webhook signature that does not verify |
|
|
193
|
+
| `Error` | a code this version does not know yet |
|
|
194
|
+
|
|
195
|
+
```ruby
|
|
196
|
+
begin
|
|
197
|
+
client.instant_snap.capture(url: "https://example.com")
|
|
198
|
+
rescue WebsiteScreenshotWorld::CaptureError => e
|
|
199
|
+
# The website refused or was too slow. Nothing was charged.
|
|
200
|
+
warn "#{e.error_code}: #{e.message}"
|
|
201
|
+
rescue WebsiteScreenshotWorld::PlanLimitError => e
|
|
202
|
+
warn "Plan limit: #{e.error_code}"
|
|
203
|
+
rescue WebsiteScreenshotWorld::Error => e
|
|
204
|
+
warn "#{e.class}: #{e.message} (HTTP #{e.http_status})"
|
|
205
|
+
end
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
`rate_limited`, `capture_busy`, `service_unavailable`, `idempotency_key_in_use` and connection failures
|
|
209
|
+
are retried up to twice before the error is raised. The wait is the `retry_after_seconds` the API sends
|
|
210
|
+
(or its `Retry-After` header), capped at 60 seconds, or else 0.5 and then 1 second with some random jitter.
|
|
211
|
+
Nothing else is retried, including `capture_failed`, `capture_timeout` and `queue_full`.
|
|
212
|
+
|
|
213
|
+
## Idempotency keys
|
|
214
|
+
|
|
215
|
+
Every capture sends an `Idempotency-Key`, and its retries reuse it, so a retry never captures twice. The
|
|
216
|
+
gem makes a random one per call. Pass your own to make a call safe to repeat across processes, for
|
|
217
|
+
example from a background job:
|
|
218
|
+
|
|
219
|
+
```ruby
|
|
220
|
+
capture = client.instant_snap.capture(url: "https://example.com", idempotency_key: "order-1234-hero")
|
|
221
|
+
capture.idempotent_replayed? # => true when this answers an earlier request with the same key
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
A key is remembered for 24 hours. Reusing it with a different body, or after a `capture_file` call (whose
|
|
225
|
+
file is not kept), raises `InvalidRequestError` with `idempotency_key_reused`.
|
|
226
|
+
|
|
227
|
+
## Timeouts
|
|
228
|
+
|
|
229
|
+
A capture may run for up to 120 seconds on the server, so capture calls and downloads wait up to 180
|
|
230
|
+
seconds by default and every other call, `submit` and `submit_to_bucket` included, 30 seconds. A timeout covers the whole call, retries included: a
|
|
231
|
+
wait that would run past it is not started, and the last error is raised at once.
|
|
232
|
+
|
|
233
|
+
```ruby
|
|
234
|
+
client = WebsiteScreenshotWorld::Client.new(capture_timeout: 240, timeout: 10)
|
|
235
|
+
|
|
236
|
+
# Per call, on capture, capture_file, capture_to_bucket and download:
|
|
237
|
+
client.instant_snap.capture(url: "https://example.com", timeout: 60)
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
Redirects are never followed, so the API key is never sent to another host. Neither the API nor a download link
|
|
241
|
+
redirects, so a `3xx` answer is raised as an error.
|
|
242
|
+
|
|
243
|
+
## Webhooks
|
|
244
|
+
|
|
245
|
+
Deliveries are signed with the `X-WSW-Signature` header. `Webhook.verify` checks the signature and the
|
|
246
|
+
timestamp (within 300 seconds by default) and returns a typed event:
|
|
247
|
+
|
|
248
|
+
| `type` | Class | `data` |
|
|
249
|
+
|---|---|---|
|
|
250
|
+
| `capture.succeeded` | `CaptureSucceededEvent` | `Capture` (Instant Snap) or `ScheduledCapture` (Auto Schedule) |
|
|
251
|
+
| `capture.failed` | `CaptureFailedEvent` | `Capture` or `ScheduledCapture` |
|
|
252
|
+
| `delivery.completed` | `DeliveryCompletedEvent` | `ScheduledCapture` with `destinations` |
|
|
253
|
+
| `capture.changed` | `CaptureChangedEvent` | `ScheduledCapture` with `change_percent` |
|
|
254
|
+
| `schedule.paused` | `SchedulePausedEvent` | `SchedulePause` |
|
|
255
|
+
| `sitemap_run.completed` | `SitemapRunCompletedEvent` | `SitemapRun` |
|
|
256
|
+
| `ping` | `PingEvent` | `Ping` |
|
|
257
|
+
| anything newer | `UnknownEvent` | the raw `Hash` |
|
|
258
|
+
|
|
259
|
+
All of them inherit from `WebsiteScreenshotWorld::Event`, which has `id`, `type`, `created_at`, `product`
|
|
260
|
+
and `data`.
|
|
261
|
+
|
|
262
|
+
**Pass the body exactly as it arrived.** Parsing the JSON and serialising it again changes the bytes and the
|
|
263
|
+
signature no longer matches. Each framework below reads the raw body.
|
|
264
|
+
|
|
265
|
+
### Rails
|
|
266
|
+
|
|
267
|
+
```ruby
|
|
268
|
+
class WebsiteScreenshotWorldWebhooksController < ActionController::API
|
|
269
|
+
def create
|
|
270
|
+
event = WebsiteScreenshotWorld::Webhook.verify(
|
|
271
|
+
request.raw_post,
|
|
272
|
+
request.headers["X-WSW-Signature"],
|
|
273
|
+
ENV.fetch("WSW_WEBHOOK_SECRET")
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
case event
|
|
277
|
+
when WebsiteScreenshotWorld::CaptureSucceededEvent
|
|
278
|
+
Rails.logger.info("Capture #{event.data.id} succeeded")
|
|
279
|
+
when WebsiteScreenshotWorld::SchedulePausedEvent
|
|
280
|
+
Rails.logger.warn("Schedule #{event.data.schedule_name} paused: #{event.data.reason}")
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
head :ok
|
|
284
|
+
rescue WebsiteScreenshotWorld::SignatureVerificationError
|
|
285
|
+
head :bad_request
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
### Sinatra
|
|
291
|
+
|
|
292
|
+
```ruby
|
|
293
|
+
post "/webhooks/websitescreenshotworld" do
|
|
294
|
+
request.body.rewind
|
|
295
|
+
event = WebsiteScreenshotWorld::Webhook.verify(
|
|
296
|
+
request.body.read,
|
|
297
|
+
request.env["HTTP_X_WSW_SIGNATURE"],
|
|
298
|
+
ENV.fetch("WSW_WEBHOOK_SECRET")
|
|
299
|
+
)
|
|
300
|
+
logger.info("Received #{event.type} #{event.id}")
|
|
301
|
+
200
|
|
302
|
+
rescue WebsiteScreenshotWorld::SignatureVerificationError
|
|
303
|
+
400
|
|
304
|
+
end
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
### Rack
|
|
308
|
+
|
|
309
|
+
```ruby
|
|
310
|
+
class WebsiteScreenshotWorldWebhook
|
|
311
|
+
def call(env)
|
|
312
|
+
request = Rack::Request.new(env)
|
|
313
|
+
event = WebsiteScreenshotWorld::Webhook.verify(
|
|
314
|
+
request.body.read,
|
|
315
|
+
env["HTTP_X_WSW_SIGNATURE"],
|
|
316
|
+
ENV.fetch("WSW_WEBHOOK_SECRET")
|
|
317
|
+
)
|
|
318
|
+
warn "Received #{event.type} #{event.id}"
|
|
319
|
+
[200, {}, []]
|
|
320
|
+
rescue WebsiteScreenshotWorld::SignatureVerificationError
|
|
321
|
+
[400, {}, []]
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
Events also work with pattern matching:
|
|
327
|
+
|
|
328
|
+
```ruby
|
|
329
|
+
case event
|
|
330
|
+
in { type: "capture.changed", data: { change_percent:, url: } } if change_percent > 10
|
|
331
|
+
puts "#{url} changed by #{change_percent}%"
|
|
332
|
+
in WebsiteScreenshotWorld::UnknownEvent
|
|
333
|
+
# A type added after this version of the gem.
|
|
334
|
+
else
|
|
335
|
+
end
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
## Configuration
|
|
339
|
+
|
|
340
|
+
| Keyword for `Client.new` | Default |
|
|
341
|
+
|---|---|
|
|
342
|
+
| `instant_snap_api_key` | `ENV["WSW_INSTANT_SNAP_API_KEY"]` |
|
|
343
|
+
| `auto_schedule_api_key` | `ENV["WSW_AUTO_SCHEDULE_API_KEY"]` |
|
|
344
|
+
| `instant_snap_base_url` | `https://api.websitescreenshotworld.com` |
|
|
345
|
+
| `auto_schedule_base_url` | `https://autoapi.websitescreenshotworld.com` |
|
|
346
|
+
| `capture_timeout` | `180` seconds, for captures and downloads |
|
|
347
|
+
| `timeout` | `30` seconds, for everything else, including `submit` and `submit_to_bucket` |
|
|
348
|
+
| `max_retries` | `2` (so at most 3 attempts); `0` turns retrying off |
|
|
349
|
+
|
|
350
|
+
Either key may be left out. Calling a product whose key is missing raises `AuthenticationError` before
|
|
351
|
+
anything is sent. A client holds no open connection and is safe to share between threads.
|
|
352
|
+
|
|
353
|
+
Requests go through `Net::HTTP`, which honours the `https_proxy` and `no_proxy` environment variables.
|
|
354
|
+
|
|
355
|
+
## Supported Ruby versions
|
|
356
|
+
|
|
357
|
+
Ruby 3.2 and later. CI runs on 3.2 and 3.4.
|
|
358
|
+
|
|
359
|
+
## Development
|
|
360
|
+
|
|
361
|
+
The test suite plays the shared cases in `spec/` back through `spec/conformance/server.js`, so it needs
|
|
362
|
+
Node.js 22 or later. `spec/` is the [websitescreenshotworld-spec](https://github.com/websitescreenshotworld/websitescreenshotworld-spec) submodule: clone with `git clone --recurse-submodules`, or run `git submodule update --init` in an existing clone.
|
|
363
|
+
|
|
364
|
+
```sh
|
|
365
|
+
bundle install
|
|
366
|
+
bundle exec rake # tests, then RuboCop
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
## License
|
|
370
|
+
|
|
371
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module WebsiteScreenshotWorld
|
|
4
|
+
# The Auto Schedule API: start a scheduled web page's capture now. Reach it through
|
|
5
|
+
# {Client#auto_schedule}. The capture's result arrives by webhook.
|
|
6
|
+
class AutoSchedule < Service
|
|
7
|
+
MISSING_KEY = "No Auto Schedule API key was given: pass auto_schedule_api_key: to " \
|
|
8
|
+
"WebsiteScreenshotWorld::Client.new or set WSW_AUTO_SCHEDULE_API_KEY."
|
|
9
|
+
|
|
10
|
+
# Queues a capture of a web page set up in the dashboard. Safe to repeat: while a capture is pending,
|
|
11
|
+
# another call answers +already_pending+ and charges nothing.
|
|
12
|
+
#
|
|
13
|
+
# @param web_page_id [String] the web page's id, the same id webhooks call +schedule_id+
|
|
14
|
+
# @return [TriggerResult]
|
|
15
|
+
# @raise [NotFoundError] when there is no such web page for this account
|
|
16
|
+
# @raise [Error] another subclass matching the refusal
|
|
17
|
+
def trigger_now(web_page_id)
|
|
18
|
+
hash, = request_json(:post, "/auto-schedule/v1/web-pages/#{path_segment(web_page_id)}/trigger-now")
|
|
19
|
+
TriggerResult.from_api(hash)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module WebsiteScreenshotWorld
|
|
4
|
+
# The page source kept alongside a capture when +page_source:+ was requested.
|
|
5
|
+
#
|
|
6
|
+
# @!attribute [r] format
|
|
7
|
+
# @return [String] one of {PageSourceFormat}
|
|
8
|
+
# @!attribute [r] content_type
|
|
9
|
+
# @return [String, nil]
|
|
10
|
+
# @!attribute [r] size_bytes
|
|
11
|
+
# @return [Integer, nil]
|
|
12
|
+
# @!attribute [r] sha256
|
|
13
|
+
# @return [String, nil] lowercase hex digest of the source
|
|
14
|
+
# @!attribute [r] download_url
|
|
15
|
+
# @return [String, nil] short-lived link, reissued on every read; pass the object to
|
|
16
|
+
# {InstantSnap#download} to fetch it
|
|
17
|
+
PageSource = Data.define(:format, :content_type, :size_bytes, :sha256, :download_url) do
|
|
18
|
+
include Model::Raw
|
|
19
|
+
|
|
20
|
+
# @api private
|
|
21
|
+
def self.from_api(hash)
|
|
22
|
+
Model.build(self, hash)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Where a +cloud_storage+ capture was uploaded.
|
|
27
|
+
#
|
|
28
|
+
# @!attribute [r] status
|
|
29
|
+
# @return [String] one of {S3DeliveryStatus}
|
|
30
|
+
# @!attribute [r] bucket
|
|
31
|
+
# @return [String]
|
|
32
|
+
# @!attribute [r] key
|
|
33
|
+
# @return [String, nil] the object key, once known
|
|
34
|
+
# @!attribute [r] access_key_id
|
|
35
|
+
# @return [String, nil] masked to its last four characters
|
|
36
|
+
# @!attribute [r] error
|
|
37
|
+
# @return [String, nil] why the upload failed
|
|
38
|
+
S3Delivery = Data.define(:status, :bucket, :key, :access_key_id, :error) do
|
|
39
|
+
include Model::Raw
|
|
40
|
+
|
|
41
|
+
# @api private
|
|
42
|
+
def self.from_api(hash)
|
|
43
|
+
Model.build(self, hash)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# An Instant Snap capture, as returned by {InstantSnap#capture}, {InstantSnap#capture_to_bucket},
|
|
48
|
+
# {InstantSnap#submit}, {InstantSnap#submit_to_bucket}, {InstantSnap#wait}, {InstantSnap#get} and the
|
|
49
|
+
# Instant Snap webhooks.
|
|
50
|
+
#
|
|
51
|
+
# @!attribute [r] id
|
|
52
|
+
# @return [String]
|
|
53
|
+
# @!attribute [r] status
|
|
54
|
+
# @return [String] one of {CaptureStatus}; an async capture is +queued+, then +running+, then
|
|
55
|
+
# +succeeded+ or +failed+, and its file fields are nil until it has finished
|
|
56
|
+
# @!attribute [r] response_type
|
|
57
|
+
# @return [String] one of {ResponseType}
|
|
58
|
+
# @!attribute [r] media_category
|
|
59
|
+
# @return [String, nil] one of {MediaCategory}; nil while an async capture is unfinished
|
|
60
|
+
# @!attribute [r] format
|
|
61
|
+
# @return [String, nil] the format written, one of {Format}
|
|
62
|
+
# @!attribute [r] content_type
|
|
63
|
+
# @return [String, nil]
|
|
64
|
+
# @!attribute [r] size_bytes
|
|
65
|
+
# @return [Integer, nil]
|
|
66
|
+
# @!attribute [r] width
|
|
67
|
+
# @return [Integer, nil]
|
|
68
|
+
# @!attribute [r] height
|
|
69
|
+
# @return [Integer, nil]
|
|
70
|
+
# @!attribute [r] duration_ms
|
|
71
|
+
# @return [Integer, nil] how long the capture took
|
|
72
|
+
# @!attribute [r] page_http_status
|
|
73
|
+
# @return [Integer, nil] the status the website answered with
|
|
74
|
+
# @!attribute [r] captured_at
|
|
75
|
+
# @return [Time, nil]
|
|
76
|
+
# @!attribute [r] final_url
|
|
77
|
+
# @return [String, nil] where the browser ended up, when the page redirected
|
|
78
|
+
# @!attribute [r] media_sha256
|
|
79
|
+
# @return [String, nil] lowercase hex digest of the file
|
|
80
|
+
# @!attribute [r] download_url
|
|
81
|
+
# @return [String, nil] valid for 60 minutes and reissued by {InstantSnap#get}; nil for +by_format+,
|
|
82
|
+
# +cloud_storage+ and webhooks
|
|
83
|
+
# @!attribute [r] expires_at
|
|
84
|
+
# @return [Time, nil] when the file is deleted
|
|
85
|
+
# @!attribute [r] page_source
|
|
86
|
+
# @return [PageSource, nil]
|
|
87
|
+
# @!attribute [r] s3_delivery
|
|
88
|
+
# @return [S3Delivery, nil] present for +cloud_storage+ only
|
|
89
|
+
# @!attribute [r] callback_url
|
|
90
|
+
# @return [String, nil]
|
|
91
|
+
# @!attribute [r] error_code
|
|
92
|
+
# @return [String, nil] why a +failed+ or +timed_out+ capture ended
|
|
93
|
+
# @!attribute [r] spec_version
|
|
94
|
+
# @return [String] the default set the capture was taken under
|
|
95
|
+
Capture = Data.define(
|
|
96
|
+
:id, :status, :response_type, :media_category, :format, :content_type, :size_bytes, :width, :height,
|
|
97
|
+
:duration_ms, :page_http_status, :captured_at, :final_url, :media_sha256, :download_url, :expires_at,
|
|
98
|
+
:page_source, :s3_delivery, :callback_url, :error_code, :spec_version
|
|
99
|
+
) do
|
|
100
|
+
include Model::Raw
|
|
101
|
+
|
|
102
|
+
def initialize(idempotent_replayed: false, **fields)
|
|
103
|
+
@idempotent_replayed = idempotent_replayed
|
|
104
|
+
super(**fields)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# @return [Boolean] whether this answer replays an earlier request made with the same idempotency key
|
|
108
|
+
def idempotent_replayed?
|
|
109
|
+
@idempotent_replayed
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# @api private
|
|
113
|
+
def self.from_api(hash, idempotent_replayed: false)
|
|
114
|
+
Model.build(
|
|
115
|
+
self, hash,
|
|
116
|
+
idempotent_replayed: idempotent_replayed,
|
|
117
|
+
captured_at: Model.time(hash["captured_at"]),
|
|
118
|
+
expires_at: Model.time(hash["expires_at"]),
|
|
119
|
+
page_source: hash["page_source"]&.then { PageSource.from_api(_1) },
|
|
120
|
+
s3_delivery: hash["s3_delivery"]&.then { S3Delivery.from_api(_1) }
|
|
121
|
+
)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|