screenshotfreeapi 1.0.0

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: a74c7308a606f4b35000f23f5acd86ddbe75e8b924babc380474d75d758a1d32
4
+ data.tar.gz: fa76ec1108119c0279dde70d5498b5f818dc5aa14fb446e173ae64b2e6bf8943
5
+ SHA512:
6
+ metadata.gz: 686cb5b19cb4b36f59d180164c355a2474d690d3e1735e48a9be0fa708e00ea6c1a7afe37e387f7c5a4faaa0daeb995194865932f043a124c4fc7896a8329274
7
+ data.tar.gz: 5e6951a5d5310c3eb0fa78b05b156140461ca7627da59e03fae588d3354a65283569df390f15564dc35dcd64b1e17c6154131cc2ae9448226d5247cfd33d75f6
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ScreenshotFreeAPI
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,442 @@
1
+ # ScreenshotFreeAPI — Ruby SDK
2
+
3
+ Official Ruby client for [ScreenshotFreeAPI](https://api.screenshotfreeapi.com) — screenshot-as-a-service.
4
+
5
+ - Capture web pages, mobile app store listings, and HTML strings
6
+ - Async job polling with configurable timeout
7
+ - Webhook signature verification (HMAC-SHA256)
8
+ - Billing, workspace, and monitor management
9
+ - Zero runtime dependencies — stdlib only (`net/http`, `json`, `openssl`)
10
+ - Ruby 2.7+ compatible
11
+
12
+ ---
13
+
14
+ ## Installation
15
+
16
+ ### RubyGems
17
+
18
+ ```bash
19
+ gem install screenshotfreeapi
20
+ ```
21
+
22
+ ### Bundler
23
+
24
+ Add to your `Gemfile`:
25
+
26
+ ```ruby
27
+ gem "screenshotfreeapi", "~> 1.0"
28
+ ```
29
+
30
+ Then run:
31
+
32
+ ```bash
33
+ bundle install
34
+ ```
35
+
36
+ ---
37
+
38
+ ## Quick Start
39
+
40
+ ```ruby
41
+ require "screenshotfreeapi"
42
+
43
+ client = ScreenshotFreeAPI.new(api_key: ENV["SCREENSHOTFREEAPI_KEY"])
44
+
45
+ # Capture a URL and wait for the result (blocks until complete)
46
+ result = client.capture("https://stripe.com/pricing")
47
+
48
+ puts result["screenshots"].first["url"]
49
+ # => "https://s3.amazonaws.com/...?X-Amz-Expires=900"
50
+ ```
51
+
52
+ ---
53
+
54
+ ## All Screenshot Types
55
+
56
+ ### Web Screenshot
57
+
58
+ ```ruby
59
+ client = ScreenshotFreeAPI.new(api_key: ENV["SCREENSHOTFREEAPI_KEY"])
60
+
61
+ # Enqueue (returns immediately with a job receipt)
62
+ job = client.screenshots.web(
63
+ url: "https://stripe.com/pricing",
64
+ format: "png", # "png" | "jpeg" | "webp" | "pdf"
65
+ full_page: true, # capture the full scrollable page
66
+ dimensions: { width: 1440, height: 900 },
67
+ block_ads: true, # STARTER+
68
+ accept_cookies: true, # STARTER+
69
+ webhook_url: "https://yourapp.com/webhooks/screenshot-events"
70
+ )
71
+
72
+ puts job["jobId"] # => "clxyz123"
73
+ puts job["statusUrl"] # => "/jobs/clxyz123/status"
74
+
75
+ # --- AI-targeted element capture ---
76
+ job = client.screenshots.web(
77
+ url: "https://stripe.com/pricing",
78
+ description: "the pricing comparison table" # triggers Claude vision path
79
+ )
80
+
81
+ # --- CSS selector targeting ---
82
+ job = client.screenshots.web(
83
+ url: "https://github.com",
84
+ element: ".hero-section"
85
+ )
86
+
87
+ # --- Enqueue and block until done ---
88
+ result = client.screenshots.web_and_wait(
89
+ url: "https://example.com",
90
+ format: "png"
91
+ )
92
+ puts result["screenshots"].first["url"]
93
+ ```
94
+
95
+ ### Mobile App Screenshot
96
+
97
+ ```ruby
98
+ # Store listing screenshots
99
+ job = client.screenshots.mobile(
100
+ app_name: "Instagram",
101
+ platform: "both", # "ios" | "android" | "both"
102
+ bundle_id: "com.instagram.android",
103
+ include_store_listing: true,
104
+ device_emulation: "iPhone 12"
105
+ )
106
+
107
+ # Block until complete
108
+ result = client.screenshots.mobile_and_wait(
109
+ app_name: "Spotify",
110
+ platform: "ios"
111
+ )
112
+ puts result["screenshots"].map { |s| s["url"] }
113
+ ```
114
+
115
+ ### HTML String Screenshot
116
+
117
+ ```ruby
118
+ html_content = <<~HTML
119
+ <!DOCTYPE html>
120
+ <html>
121
+ <body style="font-family: sans-serif; padding: 40px;">
122
+ <h1>Invoice #1042</h1>
123
+ <p>Total: <strong>$149.00</strong></p>
124
+ </body>
125
+ </html>
126
+ HTML
127
+
128
+ result = client.screenshots.html_and_wait(
129
+ html: html_content,
130
+ format: "png",
131
+ full_page: true,
132
+ dimensions: { width: 800, height: 600 }
133
+ )
134
+ puts result["screenshots"].first["url"]
135
+ ```
136
+
137
+ ---
138
+
139
+ ## Manual Polling
140
+
141
+ If you want to enqueue a job and check on it yourself:
142
+
143
+ ```ruby
144
+ # 1. Enqueue
145
+ job = client.screenshots.web(url: "https://example.com")
146
+ job_id = job["jobId"]
147
+
148
+ # 2. Poll manually
149
+ loop do
150
+ status = client.jobs.status(job_id)
151
+ puts "#{status["status"]} — #{status["progress"]}%"
152
+ break if status["status"] == "completed"
153
+ raise "Job failed: #{status["error"]}" if status["status"] == "failed"
154
+ sleep 2
155
+ end
156
+
157
+ # 3. Fetch result
158
+ result = client.jobs.result(job_id)
159
+ puts result["screenshots"].first["url"]
160
+ ```
161
+
162
+ Or use the built-in `wait` method with a progress callback:
163
+
164
+ ```ruby
165
+ result = client.screenshots.wait(job_id, poll_interval: 3, timeout: 180) do |status|
166
+ puts "[#{status["status"]}] #{status["progress"]}%"
167
+ end
168
+ ```
169
+
170
+ ---
171
+
172
+ ## Authentication
173
+
174
+ ```ruby
175
+ # Register a new account (returns apiKey shown once only)
176
+ account = client.auth.register(
177
+ email: "you@example.com",
178
+ password: "securepassword123!",
179
+ name: "Your Name"
180
+ )
181
+ puts account["apiKey"] # store this securely
182
+
183
+ # Get a management JWT (needed for billing / workspaces / monitors)
184
+ token_data = client.auth.token(
185
+ email: "you@example.com",
186
+ password: "securepassword123!"
187
+ )
188
+ jwt = token_data["token"]
189
+
190
+ # Refresh an expiring JWT
191
+ new_token = client.auth.refresh(refresh_token: "your_refresh_token")
192
+ ```
193
+
194
+ ---
195
+
196
+ ## Error Handling
197
+
198
+ ```ruby
199
+ require "screenshotfreeapi"
200
+
201
+ client = ScreenshotFreeAPI.new(api_key: ENV["SCREENSHOTFREEAPI_KEY"])
202
+
203
+ begin
204
+ result = client.capture("https://example.com")
205
+
206
+ rescue ScreenshotFreeAPI::AuthenticationError => e
207
+ # 401 — API key is missing or invalid
208
+ puts "Authentication failed: #{e.message}"
209
+
210
+ rescue ScreenshotFreeAPI::PaymentRequiredError => e
211
+ # 402 — Subscription cancelled or payment overdue
212
+ puts "Payment required: #{e.message}"
213
+
214
+ rescue ScreenshotFreeAPI::ForbiddenError => e
215
+ # 403 — API key revoked or account suspended
216
+ puts "Forbidden: #{e.message}"
217
+
218
+ rescue ScreenshotFreeAPI::NotFoundError => e
219
+ # 404 — Job not found or not owned by this API key
220
+ puts "Not found: #{e.message}"
221
+
222
+ rescue ScreenshotFreeAPI::ValidationError => e
223
+ # 400 — Request body failed server-side validation
224
+ puts "Validation error: #{e.message}"
225
+ puts "Details: #{e.details.inspect}" if e.details
226
+
227
+ rescue ScreenshotFreeAPI::RateLimitError => e
228
+ # 429 per-minute rate limit
229
+ puts "Rate limited. Retry after #{e.retry_after}s"
230
+ sleep(e.retry_after || 60)
231
+ retry
232
+
233
+ rescue ScreenshotFreeAPI::QuotaExceededError => e
234
+ # 429 monthly quota exhausted
235
+ puts "Monthly quota exceeded: #{e.message}"
236
+
237
+ rescue ScreenshotFreeAPI::JobFailedError => e
238
+ # Job transitioned to "failed" during polling
239
+ puts "Job #{e.job_id} failed: #{e.reason}"
240
+
241
+ rescue ScreenshotFreeAPI::JobTimeoutError => e
242
+ # Job did not complete within the polling timeout
243
+ puts "Job #{e.job_id} timed out after #{e.timeout_seconds}s"
244
+
245
+ rescue ScreenshotFreeAPI::ScreenshotFreeAPIError => e
246
+ # Catch-all for unexpected HTTP errors (5xx, etc.)
247
+ puts "API error #{e.status_code}: #{e.message}"
248
+ end
249
+ ```
250
+
251
+ ---
252
+
253
+ ## Billing
254
+
255
+ ```ruby
256
+ # Get JWT first
257
+ jwt = client.auth.token(email: "you@example.com", password: "secret")["token"]
258
+
259
+ # List available plans (no auth needed)
260
+ plans = client.billing.plans
261
+ plans.each { |p| puts "#{p["name"]}: $#{p["price"]}/mo" }
262
+
263
+ # Current plan and usage
264
+ plan = client.billing.plan(jwt: jwt)
265
+ usage = client.billing.usage(jwt: jwt)
266
+ puts "Plan: #{plan["plan"]}, Used: #{plan["screenshotsUsed"]}"
267
+
268
+ # Upgrade
269
+ checkout = client.billing.upgrade(jwt: jwt, plan: "GROWTH")
270
+ puts "Complete payment at: #{checkout["checkoutUrl"]}"
271
+
272
+ # Verify payment after redirect
273
+ verified = client.billing.verify(jwt: jwt, transaction_ref: checkout["transactionRef"])
274
+ puts verified["message"]
275
+
276
+ # Cancel
277
+ client.billing.cancel(jwt: jwt)
278
+ ```
279
+
280
+ ---
281
+
282
+ ## Workspaces
283
+
284
+ ```ruby
285
+ jwt = client.auth.token(email: "you@example.com", password: "secret")["token"]
286
+
287
+ # Create
288
+ workspace = client.workspaces.create(jwt: jwt, name: "Acme Corp")
289
+
290
+ # List
291
+ workspaces = client.workspaces.list(jwt: jwt)
292
+
293
+ # Get details (includes members)
294
+ ws = client.workspaces.get(jwt: jwt, workspace_id: workspace["id"])
295
+
296
+ # Invite a member
297
+ client.workspaces.invite(
298
+ jwt: jwt,
299
+ workspace_id: workspace["id"],
300
+ email: "colleague@example.com",
301
+ role: "member" # "admin" | "member" | "viewer"
302
+ )
303
+
304
+ # Change role
305
+ client.workspaces.update_member(
306
+ jwt: jwt,
307
+ workspace_id: workspace["id"],
308
+ user_id: "usr_abc",
309
+ role: "admin"
310
+ )
311
+
312
+ # Remove member
313
+ client.workspaces.remove_member(
314
+ jwt: jwt,
315
+ workspace_id: workspace["id"],
316
+ user_id: "usr_abc"
317
+ )
318
+ ```
319
+
320
+ ---
321
+
322
+ ## App Monitors
323
+
324
+ ```ruby
325
+ jwt = client.auth.token(email: "you@example.com", password: "secret")["token"]
326
+
327
+ # Create a monitor — runs daily at 9 AM UTC
328
+ monitor = client.monitors.create(
329
+ jwt: jwt,
330
+ app_name: "Spotify",
331
+ platform: "both",
332
+ schedule: "0 9 * * *",
333
+ webhook_url: "https://yourapp.com/webhooks/monitor"
334
+ )
335
+
336
+ # List
337
+ client.monitors.list(jwt: jwt)
338
+
339
+ # Get
340
+ client.monitors.get(jwt: jwt, monitor_id: monitor["id"])
341
+
342
+ # Run history (last 20 entries)
343
+ history = client.monitors.history(jwt: jwt, monitor_id: monitor["id"], limit: 20)
344
+
345
+ # Delete
346
+ client.monitors.delete(jwt: jwt, monitor_id: monitor["id"])
347
+ ```
348
+
349
+ ---
350
+
351
+ ## Zapier Integration
352
+
353
+ ```ruby
354
+ # Subscribe (register a webhook for a Zapier trigger)
355
+ sub = client.integrations.zapier_subscribe(
356
+ target_url: "https://hooks.zapier.com/hooks/catch/...",
357
+ event: "screenshot.completed"
358
+ )
359
+
360
+ # Sample payload for Zap editor
361
+ sample = client.integrations.zapier_triggers(event: "screenshot.completed")
362
+
363
+ # Unsubscribe
364
+ client.integrations.zapier_unsubscribe(subscription_id: sub["id"])
365
+ ```
366
+
367
+ ---
368
+
369
+ ## Webhook Verification
370
+
371
+ ScreenshotFreeAPI signs all webhook payloads using HMAC-SHA256. Always verify the
372
+ signature before processing the event.
373
+
374
+ ```ruby
375
+ # Standalone verification
376
+ require "screenshotfreeapi"
377
+
378
+ raw_body = request.body.read # raw bytes — do NOT parse yet
379
+ signature = request.headers["X-ScreenshotFree-Signature"]
380
+ secret = ENV["SCREENSHOTFREEAPI_WEBHOOK_SECRET"]
381
+
382
+ unless ScreenshotFreeAPI::Webhooks.verify_signature(raw_body, signature, secret)
383
+ render plain: "Invalid signature", status: :forbidden
384
+ return
385
+ end
386
+
387
+ event = JSON.parse(raw_body)
388
+ puts event["status"] # "completed" | "failed"
389
+
390
+ # Or use construct_event (raises ArgumentError on bad signature):
391
+ begin
392
+ event = ScreenshotFreeAPI::Webhooks.construct_event(raw_body, signature, secret)
393
+ rescue ArgumentError => e
394
+ render plain: e.message, status: :forbidden
395
+ return
396
+ end
397
+ ```
398
+
399
+ ---
400
+
401
+ ## Rails Integration
402
+
403
+ Create an initializer `config/initializers/screenshotfreeapi.rb`:
404
+
405
+ ```ruby
406
+ require "screenshotfreeapi"
407
+
408
+ SCREENSHOTFREEAPI_CLIENT = ScreenshotFreeAPI.new(
409
+ api_key: Rails.application.credentials.screenshotfreeapi_key!,
410
+ timeout: 45,
411
+ max_retries: 3
412
+ )
413
+ ```
414
+
415
+ Then use `SCREENSHOTFREEAPI_CLIENT` anywhere in your application:
416
+
417
+ ```ruby
418
+ class ScreenshotJob < ApplicationJob
419
+ def perform(url)
420
+ result = SCREENSHOTFREEAPI_CLIENT.capture(url, format: "png", full_page: true)
421
+ url = result["screenshots"].first["url"]
422
+ Rails.logger.info "Screenshot ready: #{url}"
423
+ end
424
+ end
425
+ ```
426
+
427
+ ---
428
+
429
+ ## Configuration Reference
430
+
431
+ | Option | Type | Default | Description |
432
+ |---------------|---------|--------------------------------|--------------------------------------|
433
+ | `api_key` | String | required | Your ScreenshotFreeAPI key (`sfa_...`) |
434
+ | `base_url` | String | `https://api.screenshotfreeapi.com` | Override for staging / local dev |
435
+ | `timeout` | Integer | `30` | HTTP open/read timeout (seconds) |
436
+ | `max_retries` | Integer | `3` | Retries on 429 and 5xx errors |
437
+
438
+ ---
439
+
440
+ ## License
441
+
442
+ MIT — see LICENSE file.
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ScreenshotFreeAPI
4
+ # Main entry point for the ScreenshotFreeAPI Ruby SDK.
5
+ #
6
+ # Instantiate once (e.g., in a Rails initializer) and reuse across requests.
7
+ # All resource objects share the same underlying HttpClient instance.
8
+ #
9
+ # Quick start:
10
+ #
11
+ # client = ScreenshotFreeAPI::Client.new(api_key: ENV["SCREENSHOTFREEAPI_KEY"])
12
+ # result = client.capture("https://stripe.com", format: "png")
13
+ # puts result["screenshots"].first["url"]
14
+ #
15
+ class Client
16
+ attr_reader :screenshots, :jobs, :auth, :billing, :workspaces, :monitors, :integrations
17
+
18
+ # @param api_key [String] Your ScreenshotFreeAPI key (sfa_...)
19
+ # @param base_url [String] Override base URL for staging / local dev
20
+ # @param timeout [Integer] HTTP open/read timeout in seconds (default: 30)
21
+ # @param max_retries [Integer] Max retry attempts on 429 / 5xx (default: 3)
22
+ def initialize(
23
+ api_key:,
24
+ base_url: HttpClient::DEFAULT_BASE_URL,
25
+ timeout: HttpClient::DEFAULT_TIMEOUT,
26
+ max_retries: HttpClient::DEFAULT_RETRIES
27
+ )
28
+ @http = HttpClient.new(
29
+ api_key: api_key,
30
+ base_url: base_url,
31
+ timeout: timeout,
32
+ max_retries: max_retries
33
+ )
34
+
35
+ @screenshots = Resources::Screenshots.new(@http)
36
+ @jobs = Resources::Jobs.new(@http)
37
+ @auth = Resources::Auth.new(@http)
38
+ @billing = Resources::Billing.new(@http)
39
+ @workspaces = Resources::Workspaces.new(@http)
40
+ @monitors = Resources::Monitors.new(@http)
41
+ @integrations = Resources::Integrations.new(@http)
42
+ end
43
+
44
+ # Convenience method: capture a URL and wait for the result.
45
+ #
46
+ # Equivalent to `client.screenshots.web_and_wait({ url: url }.merge(opts))`.
47
+ #
48
+ # @param url [String] URL to capture
49
+ # @param poll_interval [Numeric] Seconds between status polls (default: 2)
50
+ # @param timeout [Numeric] Maximum seconds to wait (default: 120)
51
+ # @param opts [Hash] Any additional web screenshot options
52
+ # @yieldparam status [Hash] Progress callback, called on each poll
53
+ #
54
+ # @return [Hash] Full result hash with "screenshots" array
55
+ def capture(url, poll_interval: 2, timeout: 120, **opts, &on_progress)
56
+ options = { url: url }.merge(opts)
57
+ @screenshots.web_and_wait(options, poll_interval: poll_interval, timeout: timeout, &on_progress)
58
+ end
59
+
60
+ # Check API health (no authentication required).
61
+ #
62
+ # @return [Hash] { "status" => "ok", "version" => "..." }
63
+ def health
64
+ @http.request(:get, "/health", auth_header: "")
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ScreenshotFreeAPI
4
+ # Base error class for all ScreenshotFreeAPI errors that originate from HTTP responses.
5
+ class ScreenshotFreeAPIError < StandardError
6
+ attr_reader :status_code, :error_code, :details
7
+
8
+ def initialize(status_code, error_code, message, details = nil)
9
+ super(message)
10
+ @status_code = status_code
11
+ @error_code = error_code
12
+ @details = details
13
+ end
14
+ end
15
+
16
+ # 401 — Invalid or missing API key / JWT.
17
+ class AuthenticationError < ScreenshotFreeAPIError
18
+ def initialize(msg = "Invalid or missing API key")
19
+ super(401, "Unauthorized", msg)
20
+ end
21
+ end
22
+
23
+ # 403 — API key revoked or account suspended.
24
+ class ForbiddenError < ScreenshotFreeAPIError
25
+ def initialize(msg = "Access forbidden")
26
+ super(403, "Forbidden", msg)
27
+ end
28
+ end
29
+
30
+ # 404 — Resource not found or not owned by this key.
31
+ class NotFoundError < ScreenshotFreeAPIError
32
+ def initialize(msg = "Resource not found")
33
+ super(404, "NotFound", msg)
34
+ end
35
+ end
36
+
37
+ # 400 — Request body failed Zod validation on the server.
38
+ class ValidationError < ScreenshotFreeAPIError
39
+ def initialize(msg = "Validation error", details = nil)
40
+ super(400, "ValidationError", msg, details)
41
+ end
42
+ end
43
+
44
+ # 402 — Subscription cancelled or payment overdue.
45
+ class PaymentRequiredError < ScreenshotFreeAPIError
46
+ def initialize(msg = "Payment required — subscription cancelled or payment overdue")
47
+ super(402, "PaymentRequired", msg)
48
+ end
49
+ end
50
+
51
+ # 429 — Per-minute rate limit exceeded. Includes `retry_after` seconds.
52
+ class RateLimitError < ScreenshotFreeAPIError
53
+ attr_reader :retry_after
54
+
55
+ def initialize(retry_after = nil, msg = "Rate limit exceeded")
56
+ super(429, "RateLimitExceeded", msg)
57
+ @retry_after = retry_after
58
+ end
59
+ end
60
+
61
+ # 429 with quota body — monthly screenshot quota exhausted.
62
+ class QuotaExceededError < ScreenshotFreeAPIError
63
+ def initialize(msg = "Monthly screenshot quota exceeded")
64
+ super(429, "QuotaExceeded", msg)
65
+ end
66
+ end
67
+
68
+ # Raised when polling a job that has transitioned to the FAILED state.
69
+ class JobFailedError < StandardError
70
+ attr_reader :job_id, :reason
71
+
72
+ def initialize(job_id, reason)
73
+ super("Job #{job_id} failed: #{reason}")
74
+ @job_id = job_id
75
+ @reason = reason
76
+ end
77
+ end
78
+
79
+ # Raised when a job does not complete within the configured polling timeout.
80
+ class JobTimeoutError < StandardError
81
+ attr_reader :job_id, :timeout_seconds
82
+
83
+ def initialize(job_id, timeout_seconds)
84
+ super("Job #{job_id} did not complete within #{timeout_seconds}s")
85
+ @job_id = job_id
86
+ @timeout_seconds = timeout_seconds
87
+ end
88
+ end
89
+ end