ghostcrawl 2.2.3 → 2.3.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e499bc41b516f5cea6e37d2ec06c17e8fe770a8d928e8415e470be0874d56786
4
- data.tar.gz: ed4d7fa29dcdc4f1316d0f870555bf32cb61063b3bc7efc07fe46e5682626a50
3
+ metadata.gz: 9b83f5c591c7a3ffd859db07f96c9725f84db05826b65e9227eadce1ff3eded3
4
+ data.tar.gz: 0aa598b5c6d776b5477c0709aa26dd3789d5f79084421b1fae802e90892c4dc0
5
5
  SHA512:
6
- metadata.gz: 349f33fe5785ed3458e5e749b7a45e56bf6e9aa8fdcc4b68625792a174676733ac1d0bed6f6148bed07716059498f5f5e565bf4d013357af1d8d4632f5d9ffb3
7
- data.tar.gz: 7939615bd575ac30e78455194a2979f204d82523b548ede855da34ff06f1422f8e8f676f44fe7ad47262ad63f03b6ef68c39acdc190f9dcb981c06e39717191f
6
+ metadata.gz: 3458805483c931bbd9adca163e74d0610c7d02abdb6ca260459fbdbe162c573f29752846d92bf45189a8fcea7957f770588364c9c483082729386c8b2d7c281a
7
+ data.tar.gz: 8d56532b83ea407f5a8e9190832fe1fd8ae3327cd427cb3f26be0fd727c20567a5bc1331998caa403d622edd7456956b5a373ae1961d1281f336b741ea607b32
data/LICENSE CHANGED
@@ -1,46 +1,21 @@
1
- GhostCrawl Software License
2
-
3
- Copyright (c) 2026 GhostCrawl, LLC. All rights reserved.
4
-
5
- This software including the GhostCrawl SDKs, command-line interface, example
6
- code, documentation, and the GhostCrawl self-host image (collectively, the
7
- "Software") is the proprietary property of GhostCrawl, LLC ("GhostCrawl") and
8
- its licensors. It is licensed, not sold.
9
-
10
- 1. LICENSE GRANT. Subject to your continuous compliance with this License and the
11
- GhostCrawl Terms of Service (https://docs.ghostcrawl.io/legal/terms),
12
- GhostCrawl grants you a limited, revocable, non-exclusive, non-transferable,
13
- and non-sublicensable license to install and use the Software, including
14
- within your own applications, solely to access and use the GhostCrawl service
15
- under a valid GhostCrawl account.
16
-
17
- 2. RESTRICTIONS. Except as expressly permitted in Section 1, you may not, in
18
- whole or in part: (a) copy the Software except as strictly necessary to use it
19
- as permitted; (b) modify, adapt, translate, or create derivative works of the
20
- Software; (c) distribute, publish, sublicense, sell, resell, rent, lease,
21
- lend, or otherwise transfer the Software, or make it available to any third
22
- party, including as a hosted or managed service; (d) reverse engineer,
23
- decompile, disassemble, or otherwise attempt to derive the source code,
24
- structure, or internal design of any compiled component of the Software;
25
- (e) circumvent, disable, or tamper with any license, authentication,
26
- usage-metering, watermarking, or security mechanism; or (f) remove, alter, or
27
- obscure any proprietary, copyright, or attribution notice.
28
-
29
- 3. OWNERSHIP. GhostCrawl and its licensors retain all right, title, and interest
30
- in and to the Software and all intellectual property rights therein. No rights
31
- are granted to you except as expressly set out in this License. All rights
32
- reserved.
33
-
34
- 4. TERMINATION. This License terminates automatically, without notice, if you
35
- breach any of its terms or cease to maintain a valid GhostCrawl account where
36
- one is required. Upon termination you must cease all use of the Software and
37
- destroy all copies in your possession or control.
38
-
39
- 5. NO WARRANTY; LIMITATION OF LIABILITY. THE SOFTWARE IS PROVIDED "AS IS" WITHOUT
40
- WARRANTY OF ANY KIND, EXPRESS OR IMPLIED. TO THE MAXIMUM EXTENT PERMITTED BY
41
- LAW, GHOSTCRAWL AND ITS LICENSORS WILL NOT BE LIABLE FOR ANY CLAIM, DAMAGES, OR
42
- OTHER LIABILITY ARISING FROM OR IN CONNECTION WITH THE SOFTWARE OR ITS USE. The
43
- GhostCrawl Terms of Service and Acceptable Use Policy
44
- (https://docs.ghostcrawl.io/legal/terms) are incorporated by reference.
45
-
46
- For licensing inquiries, contact legal@ghostcrawl.io.
1
+ MIT License
2
+
3
+ Copyright (c) 2026 GhostCrawl, LLC
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 CHANGED
@@ -29,9 +29,11 @@ client = Ghostcrawl::Client.new(token: "gck_live_YOUR_KEY")
29
29
  result = client.scrape(url: "https://example.com", format: "markdown")
30
30
  puts result["content"]
31
31
 
32
- # Start a crawl run
33
- run = client.crawl_runs.start(url: "https://example.com", max_depth: 2, max_pages: 50)
34
- puts "run_id: #{run["run_id"]}"
32
+ # Start a crawl run and wait for it to finish — the server blocks until the run
33
+ # is terminal (no client-side poll-with-sleep loop).
34
+ run = client.crawl_runs.start(url: "https://example.com", max_depth: 2, max_pages: 50,
35
+ wait: true, timeout: 300)
36
+ puts "#{run["run_id"]} → #{run["status"]}"
35
37
 
36
38
  # Web search
37
39
  results = client.search(query: "latest AI research", engine: "google", limit: 10)
@@ -72,6 +74,92 @@ data = client.extract(
72
74
  puts "#{data["name"]} — $#{data["price"]}"
73
75
  ```
74
76
 
77
+ ## Browser utilities — content, screenshot, PDF
78
+
79
+ ```ruby
80
+ require "ghostcrawl"
81
+
82
+ client = Ghostcrawl::Client.new(token: "gck_live_YOUR_KEY")
83
+
84
+ # Rendered content as a JSON envelope: {url, status, format, status_code, content, bytes}
85
+ page = client.content(url: "https://example.com")
86
+ puts "#{page["status_code"]} — #{page["bytes"]} bytes"
87
+
88
+ # Screenshot — returns raw PNG bytes
89
+ png = client.screenshot(url: "https://example.com", full_page: true)
90
+ File.binwrite("page.png", png)
91
+
92
+ # PDF — returns raw application/pdf bytes (Chrome-only; a Firefox/WebKit identity
93
+ # is rejected with a 400 pdf_engine_unsupported)
94
+ pdf = client.pdf(url: "https://example.com", paper_format: "a4")
95
+ File.binwrite("page.pdf", pdf)
96
+ ```
97
+
98
+ ## Agent (BYO model, account-gated)
99
+
100
+ The agent lane runs a natural-language browser task. It is **bring-your-own-model** (supply your
101
+ own `provider_config` in the request body) and **account-gated**: the API returns `404 not_found`
102
+ unless the capability is enabled for your account. `agent` does **not** raise on that 404 — it
103
+ returns the `problem+json` body as a Hash, so branch on `result.key?("detail")`.
104
+
105
+ ```ruby
106
+ require "ghostcrawl"
107
+
108
+ client = Ghostcrawl::Client.new(token: "gck_live_YOUR_KEY")
109
+
110
+ # provider_config is BYO — reference your provider key by env-var name, never a literal.
111
+ result = client.agent(
112
+ task: {
113
+ "instruction" => "click the 'Books to Scrape' link",
114
+ "start_url" => "https://books.toscrape.com"
115
+ },
116
+ provider_config: {
117
+ "provider" => "openai",
118
+ "api_key" => "OPENAI_API_KEY",
119
+ "model" => "gpt-4o"
120
+ }
121
+ )
122
+ if result.key?("detail")
123
+ puts "agent lane not enabled for this account (BYO/gated)"
124
+ else
125
+ puts result
126
+ end
127
+ ```
128
+
129
+ ## Crawl runs — wait for completion
130
+
131
+ Crawl runs are asynchronous. Instead of hand-writing a poll-with-sleep loop, use
132
+ the event-driven wait API: the **server** blocks until the run reaches a terminal
133
+ state (`completed` / `failed` / `cancelled`) and returns the finished run.
134
+
135
+ ```ruby
136
+ # Start and wait in one call — sends wait_until: "completed"
137
+ run = client.crawl_runs.start(
138
+ url: "https://example.com",
139
+ max_depth: 2,
140
+ max_pages: 50,
141
+ wait: true, # block until terminal (no client sleep loop)
142
+ timeout: 300 # give up after 300s, returning the still-running run
143
+ )
144
+ puts run["status"] # "completed" | "failed" | "cancelled"
145
+
146
+ # Or wait on a run you already started (e.g. from a webhook or another process):
147
+ run = client.crawl_runs.wait_for_completion(run_id, timeout: 300)
148
+
149
+ # Deep crawl supports the same wait: keyword:
150
+ run = client.crawl(url: "https://example.com", wait: true, timeout: 300)
151
+ ```
152
+
153
+ Under the hood `wait_for_completion` chains server-blocking
154
+ `GET /v1/crawl-runs/{run_id}?wait=true&timeout_s=N` requests until the run is
155
+ terminal or your `timeout` deadline — there is no `sleep` between checks. On
156
+ timeout the current non-terminal run is returned, so you can simply call
157
+ `wait_for_completion` again to keep waiting.
158
+
159
+ **Prefer webhooks for fire-and-forget crawls.** Register an endpoint with
160
+ `client.webhooks.create(url:, event_types: ["crawl.completed"])` and let GhostCrawl
161
+ POST the result to you instead of holding a connection open.
162
+
75
163
  ## Error handling
76
164
 
77
165
  ```ruby
@@ -112,8 +200,12 @@ client = Ghostcrawl::Client.new(
112
200
  | Data extraction | `client.extract(url:, schema:)` | Structured JSON from any page |
113
201
  | Deep crawl | `client.crawl(url:)` | Crawl a site depth-first |
114
202
  | URL map | `client.map(url:)` | Discover all reachable URLs |
203
+ | Content | `client.content(url:)` | Rendered content JSON envelope |
204
+ | Screenshot | `client.screenshot(url:)` | Capture a URL to PNG bytes |
205
+ | PDF | `client.pdf(url:)` | Render a URL to PDF bytes (Chrome-only) |
206
+ | Agent (BYO) | `client.agent(task:)` | NL browser task — account-gated, BYO model |
115
207
  | Account | `client.me` | Get account info and usage |
116
- | Crawl runs | `client.crawl_runs` | start, list, get\_run, cancel |
208
+ | Crawl runs | `client.crawl_runs` | start (with `wait:`), wait\_for\_completion, list, get, cancel |
117
209
  | Sessions | `client.sessions` | create, list, extend, release |
118
210
  | Profiles | `client.profiles` | list, get\_profile, create, update, delete |
119
211
  | Webhooks | `client.webhooks` | list, get\_webhook, create, delete, rotate\_secret |
@@ -0,0 +1,9 @@
1
+ module Ghostcrawl
2
+ module V1
3
+ ##
4
+ # Namespace retained as the raw-response sentinel used by binary-returning
5
+ # operations (datasets/page/har/discovery) and the long-poll wait factory.
6
+ module Binary
7
+ end
8
+ end
9
+ end
@@ -2,7 +2,7 @@ require 'microsoft_kiota_abstractions'
2
2
  require_relative '../ghostcrawl'
3
3
  require_relative './audit/audit_request_builder'
4
4
  require_relative './billing/billing_request_builder'
5
- require_relative './binary/binary_request_builder'
5
+ require_relative './binary/binary'
6
6
  require_relative './budgets/budgets_request_builder'
7
7
  require_relative './cdp/cdp_request_builder'
8
8
  require_relative './contact/contact_request_builder'
@@ -50,11 +50,6 @@ module Ghostcrawl
50
50
  return Ghostcrawl::V1::Billing::BillingRequestBuilder.new(@path_parameters, @request_adapter)
51
51
  end
52
52
  ##
53
- # The binary property
54
- def binary()
55
- return Ghostcrawl::V1::Binary::BinaryRequestBuilder.new(@path_parameters, @request_adapter)
56
- end
57
- ##
58
53
  # The budgets property
59
54
  def budgets()
60
55
  return Ghostcrawl::V1::Budgets::BudgetsRequestBuilder.new(@path_parameters, @request_adapter)
@@ -381,6 +381,47 @@ module Ghostcrawl
381
381
  Ghostcrawl.raise_translated(e)
382
382
  end
383
383
 
384
+ # Executes a request whose success response is RAW BINARY (e.g. the
385
+ # +application/pdf+ body from +POST /v1/pdf+) and returns the bytes verbatim.
386
+ #
387
+ # Mirrors {void_request!}: it reuses the adapter's own request pipeline
388
+ # (base URL + bearer auth via +authenticate_request+, then +run_request+) but
389
+ # deliberately skips Kiota body deserialization — the response is not JSON, so
390
+ # there is no parse node to build. A 2xx returns the raw response body (an
391
+ # ASCII-8BIT String of bytes, ready to write to a file); a >=400 is translated
392
+ # into the documented typed {GhostcrawlError} hierarchy (the problem+json
393
+ # +detail+ rides along in the message).
394
+ #
395
+ # @param adapter [MicrosoftKiotaFaraday::FaradayRequestAdapter]
396
+ # @param request_info [MicrosoftKiotaAbstractions::RequestInformation]
397
+ # @return [String] the raw response bytes (ASCII-8BIT)
398
+ # @api private
399
+ def self.binary_request!(adapter, request_info)
400
+ request_info.path_parameters["baseurl"] = adapter.get_base_url
401
+ auth_fiber = adapter.authentication_provider.authenticate_request(request_info)
402
+ auth_fiber.resume if auth_fiber.respond_to?(:resume)
403
+ request = adapter.get_request_from_request_info(request_info)
404
+ response = adapter.client.run_request(
405
+ request.http_method, request.path, request.body, request.headers
406
+ )
407
+
408
+ status = response.status
409
+ if status >= 400
410
+ body = response.body.to_s
411
+ err = MicrosoftKiotaAbstractions::ApiError.new(
412
+ "The server returned an unexpected status code:#{status}" \
413
+ "#{body.empty? ? '' : " #{body}"}"
414
+ )
415
+ Ghostcrawl.raise_translated(err)
416
+ end
417
+
418
+ response.body.to_s.b
419
+ rescue Ghostcrawl::GhostcrawlError
420
+ raise
421
+ rescue StandardError => e
422
+ Ghostcrawl.raise_translated(e)
423
+ end
424
+
384
425
  # Inspects a decoded HTTP-200 response hash for a RESULT-channel failure (the
385
426
  # target page could not be scraped) and raises {Ghostcrawl::ScrapeError} when
386
427
  # one is present. This is the reliable, highest-value error path: the body is
@@ -463,26 +504,101 @@ module Ghostcrawl
463
504
 
464
505
  # Manage crawl runs — /v1/crawl-runs.
465
506
  class CrawlRunsClient
466
- def initialize(v1)
507
+ # Terminal run states — a crawl run in any of these will never change again,
508
+ # so a wait can stop. Both spellings of "cancelled" are accepted.
509
+ TERMINAL_STATUSES = %w[completed failed cancelled canceled].freeze
510
+
511
+ # Per-request server-block window (seconds) for the long-poll wait. Each
512
+ # +GET ...?wait=true&timeout_s=WAIT_WINDOW_S+ makes the SERVER block for up
513
+ # to this long, so the client never sleeps between checks. Kept well under
514
+ # the client read timeout ({Client::DEFAULT_TIMEOUT}) so a single blocking
515
+ # request can never trip the transport timeout.
516
+ WAIT_WINDOW_S = 30
517
+
518
+ def initialize(v1, adapter = nil)
467
519
  @v1 = v1
520
+ @adapter = adapter
468
521
  end
469
522
 
470
523
  # Start a new crawl run from a seed URL.
471
524
  # Delegates to POST /v1/crawl-runs via the generated CrawlRunsRequestBuilder.
472
525
  # The endpoint is a tagged union: a start request requires +action: "start"+
473
526
  # and a +seed_urls+ array (not a bare +url+).
527
+ #
528
+ # Pass +wait: true+ to block until the run reaches a terminal state
529
+ # (completed | failed | cancelled) or +timeout+ seconds elapse. This sends
530
+ # +wait_until: "completed"+ so the SERVER blocks — no client-side poll loop,
531
+ # no +sleep+. When the run is still running at +timeout+ the current
532
+ # (non-terminal) record is returned; call {#wait_for_completion} again to
533
+ # keep waiting.
534
+ #
474
535
  # @param url [String] seed URL
475
536
  # @param max_depth [Integer] maximum crawl depth (default 2)
476
537
  # @param max_pages [Integer] maximum pages (default 100)
538
+ # @param wait [Boolean] block until the run is terminal (default false)
539
+ # @param timeout [Integer] total seconds to wait when +wait: true+ (default 300)
477
540
  # @param raise_on_result_error [Boolean] raise {Ghostcrawl::ScrapeError} on a
478
- # target-side (HTTP-200) failure instead of returning the raw hash (default true)
479
- # @return [Hash] crawl run record with +run_id+ and +status+
480
- def start(url:, max_depth: 2, max_pages: 100, raise_on_result_error: true, **opts)
481
- body = AdditionalDataBody.new({ "action" => "start", "seed_urls" => [url],
482
- "max_depth" => max_depth, "max_pages" => max_pages }
483
- .merge(opts.transform_keys(&:to_s)))
484
- hash = ResponseHelper.to_hash(@v1.crawl_runs.post(body))
485
- raise_on_result_error ? ResponseHelper.raise_on_result_error!(hash) : hash
541
+ # target-side (HTTP-200) failure instead of returning the raw hash (default true).
542
+ # Ignored on the +wait: true+ path, which always returns the terminal run so
543
+ # the caller can inspect a +failed+ status.
544
+ # @return [Hash] crawl run record with +run_id+ and +status+ (results present
545
+ # when it completed while waiting)
546
+ def start(url:, max_depth: 2, max_pages: 100, wait: false, timeout: 300,
547
+ raise_on_result_error: true, **opts)
548
+ data = { "action" => "start", "seed_urls" => [url],
549
+ "max_depth" => max_depth, "max_pages" => max_pages }
550
+ .merge(opts.transform_keys(&:to_s))
551
+
552
+ unless wait
553
+ hash = ResponseHelper.to_hash(@v1.crawl_runs.post(AdditionalDataBody.new(data)))
554
+ return raise_on_result_error ? ResponseHelper.raise_on_result_error!(hash) : hash
555
+ end
556
+
557
+ # Start-and-wait: ask the server to block until terminal. Bound the first
558
+ # server block to a safe window, then long-poll the rest via GET so a low
559
+ # per-request read timeout can never abort the whole wait.
560
+ window = [timeout, WAIT_WINDOW_S].min
561
+ data["wait_until"] = "completed"
562
+ data["timeout_s"] = window
563
+ hash = ResponseHelper.to_hash(@v1.crawl_runs.post(AdditionalDataBody.new(data)))
564
+ return hash if terminal?(hash)
565
+
566
+ run_id = hash["run_id"]
567
+ remaining = timeout - window
568
+ return hash if run_id.nil? || run_id.to_s.empty? || remaining <= 0
569
+ wait_for_completion(run_id, timeout: remaining)
570
+ end
571
+
572
+ # Block until an existing crawl run reaches a terminal state, or +timeout+
573
+ # seconds elapse. Event-driven: each iteration issues a SERVER-blocking
574
+ # +GET /v1/crawl-runs/{run_id}?wait=true&timeout_s=N+ that returns the moment
575
+ # the run goes terminal (or after its own window). There is no client +sleep+
576
+ # — the wait cost lives on the server, and successive blocking windows are
577
+ # chained until the caller's +timeout+ deadline.
578
+ #
579
+ # On timeout the current non-terminal run is returned (never raises for a
580
+ # slow run); a terminal run is returned as soon as it is observed.
581
+ #
582
+ # @param run_id [String] the run to wait on
583
+ # @param timeout [Integer] total seconds to wait (default 300)
584
+ # @return [Hash] the run record (terminal when it finished in time)
585
+ def wait_for_completion(run_id, timeout: 300)
586
+ if run_id.nil? || run_id.to_s.empty?
587
+ raise ArgumentError, "run_id is required"
588
+ end
589
+
590
+ deadline = monotonic + timeout
591
+ loop do
592
+ remaining = deadline - monotonic
593
+ # Deadline reached — one final non-blocking read of the current state.
594
+ return get(run_id) if remaining <= 0
595
+
596
+ window = [remaining, WAIT_WINDOW_S].min
597
+ run = fetch_waiting(run_id, window)
598
+ return run if terminal?(run)
599
+ # The server just blocked for ~window seconds; loop straight into the
600
+ # next blocking window. No client-side delay.
601
+ end
486
602
  end
487
603
 
488
604
  # List crawl runs.
@@ -502,6 +618,33 @@ module Ghostcrawl
502
618
  def cancel(run_id)
503
619
  ResponseHelper.to_hash(@v1.crawl_runs.by_run_id(run_id).cancel.post)
504
620
  end
621
+
622
+ private
623
+
624
+ # True when +run+ is a Hash carrying a terminal +status+.
625
+ def terminal?(run)
626
+ run.is_a?(Hash) && TERMINAL_STATUSES.include?(run["status"].to_s)
627
+ end
628
+
629
+ # Issues one SERVER-blocking GET on the run with +?wait=true&timeout_s=N+.
630
+ #
631
+ # The generated item builder's +to_get_request_information+ does not wire
632
+ # query parameters (its URL template has no query placeholder), so we take
633
+ # the request it builds, extend the template with +{?wait,timeout_s}+, and
634
+ # set the params before sending through the raw adapter — mirroring how
635
+ # {Client#me} re-issues a request with the Binary (raw-JSON) response factory.
636
+ def fetch_waiting(run_id, timeout_s)
637
+ request_info = @v1.crawl_runs.by_run_id(run_id).to_get_request_information(nil)
638
+ request_info.url_template = "{+baseurl}/v1/crawl-runs/{run_id}{?wait,timeout_s}"
639
+ request_info.query_parameters["wait"] = "true"
640
+ request_info.query_parameters["timeout_s"] = timeout_s.to_i.to_s
641
+ ResponseHelper.to_hash(@adapter.send_async(request_info, Ghostcrawl::V1::Binary, {}))
642
+ end
643
+
644
+ # Monotonic clock (seconds) — immune to wall-clock adjustments during a wait.
645
+ def monotonic
646
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
647
+ end
505
648
  end
506
649
 
507
650
  # Manage browser sessions — /v1/sessions.
@@ -848,7 +991,9 @@ module Ghostcrawl
848
991
  # (Binary) response factory instead of the typed MeResponse model.
849
992
  @adapter = adapter
850
993
 
851
- @crawl_runs = CrawlRunsClient.new(@v1)
994
+ # CrawlRunsClient needs the raw adapter for the event-driven long-poll wait
995
+ # (the generated item builder can't attach ?wait/&timeout_s query params).
996
+ @crawl_runs = CrawlRunsClient.new(@v1, @adapter)
852
997
  @sessions = SessionsClient.new(@v1)
853
998
  # Sub-clients with void-DELETE endpoints (204 No Content) also need the raw
854
999
  # adapter so ResponseHelper.void_request! can work around the broken generated
@@ -945,17 +1090,34 @@ module Ghostcrawl
945
1090
 
946
1091
  # Start a deep crawl from a seed URL.
947
1092
  # Delegates to POST /v1/crawl/deep via the generated CrawlDeepRequestBuilder.
1093
+ #
1094
+ # Pass +wait: true+ to block until the run is terminal. The deep-crawl start
1095
+ # returns a +run_id+, then the wait is handled by the same event-driven
1096
+ # server-blocking long-poll as {CrawlRunsClient#wait_for_completion} — no
1097
+ # client-side +sleep+ loop.
1098
+ #
948
1099
  # @param url [String] seed URL
949
1100
  # @param max_depth [Integer] maximum crawl depth (default 2)
950
1101
  # @param max_pages [Integer] maximum pages (default 100)
1102
+ # @param wait [Boolean] block until the run is terminal (default false)
1103
+ # @param timeout [Integer] total seconds to wait when +wait: true+ (default 300)
951
1104
  # @param raise_on_result_error [Boolean] raise {Ghostcrawl::ScrapeError} on a
952
- # target-side (HTTP-200) failure instead of returning the raw hash (default true)
953
- # @return [Hash] crawl run record
954
- def crawl(url:, max_depth: 2, max_pages: 100, raise_on_result_error: true, **opts)
1105
+ # target-side (HTTP-200) failure instead of returning the raw hash (default true).
1106
+ # Ignored on the +wait: true+ path, which returns the terminal run.
1107
+ # @return [Hash] crawl run record (terminal when +wait: true+ and it finished in time)
1108
+ def crawl(url:, max_depth: 2, max_pages: 100, wait: false, timeout: 300,
1109
+ raise_on_result_error: true, **opts)
955
1110
  data = { "seed_urls" => [url], "max_depth" => max_depth,
956
1111
  "max_urls" => max_pages }.merge(opts.transform_keys(&:to_s))
957
1112
  hash = ResponseHelper.to_hash(@v1.crawl.deep.post(AdditionalDataBody.new(data)))
958
- raise_on_result_error ? ResponseHelper.raise_on_result_error!(hash) : hash
1113
+
1114
+ unless wait
1115
+ return raise_on_result_error ? ResponseHelper.raise_on_result_error!(hash) : hash
1116
+ end
1117
+
1118
+ run_id = hash["run_id"]
1119
+ return hash if run_id.nil? || run_id.to_s.empty?
1120
+ @crawl_runs.wait_for_completion(run_id, timeout: timeout)
959
1121
  end
960
1122
 
961
1123
  # Map all URLs reachable from a seed URL.
@@ -982,18 +1144,211 @@ module Ghostcrawl
982
1144
  ResponseHelper.to_hash(@adapter.send_async(request_info, Ghostcrawl::V1::Binary, {}))
983
1145
  end
984
1146
 
1147
+ # Render a URL to a PDF document and return the raw +application/pdf+ bytes.
1148
+ # Delegates to POST /v1/pdf.
1149
+ #
1150
+ # +/v1/pdf+ responds with binary, not a JSON envelope, so this returns the
1151
+ # bytes verbatim (an ASCII-8BIT String) — write them straight to a file:
1152
+ #
1153
+ # data = client.pdf(url: "https://example.com")
1154
+ # File.binwrite("page.pdf", data)
1155
+ #
1156
+ # PDF output is Chrome-only; a request that resolves to a Firefox or WebKit
1157
+ # identity is rejected with 400 +pdf_engine_unsupported+ (a {GhostcrawlError}).
1158
+ #
1159
+ # +/v1/pdf+ has no generated request builder, so this hand-builds a
1160
+ # {MicrosoftKiotaAbstractions::RequestInformation} and routes it through the
1161
+ # SAME adapter (base URL + bearer auth + transport) as the modeled calls.
1162
+ #
1163
+ # @param url [String] target URL to render
1164
+ # @param paper_format [String] page size: "a4" (default), "letter", "legal", "tabloid"
1165
+ # @param landscape [Boolean] render in landscape orientation (default false)
1166
+ # @param engine [String] browser engine (PDF is Chrome-only; default "auto")
1167
+ # @return [String] the raw PDF bytes (ASCII-8BIT)
1168
+ def pdf(url:, paper_format: "a4", landscape: false, engine: "auto", **opts)
1169
+ data = { "url" => url, "paper_format" => paper_format,
1170
+ "landscape" => landscape, "engine" => engine }.merge(opts.transform_keys(&:to_s))
1171
+ request_info = MicrosoftKiotaAbstractions::RequestInformation.new
1172
+ request_info.http_method = :POST
1173
+ request_info.url_template = "{+baseurl}/v1/pdf"
1174
+ request_info.headers.try_add("Accept", "application/pdf")
1175
+ request_info.set_stream_content(JSON.generate(data), "application/json")
1176
+ ResponseHelper.binary_request!(@adapter, request_info)
1177
+ end
1178
+
1179
+ # Capture a screenshot of a URL and return the raw +image/png+ bytes.
1180
+ # Delegates to POST /v1/screenshot.
1181
+ #
1182
+ # +/v1/screenshot+ responds with a binary image, not a JSON envelope, so this
1183
+ # returns the bytes verbatim (an ASCII-8BIT String) — write them straight to a
1184
+ # file:
1185
+ #
1186
+ # data = client.screenshot(url: "https://example.com")
1187
+ # File.binwrite("page.png", data)
1188
+ #
1189
+ # +/v1/screenshot+ has no generated request builder, so this hand-builds a
1190
+ # {MicrosoftKiotaAbstractions::RequestInformation} and routes it through the
1191
+ # SAME adapter (base URL + bearer auth + transport) as the modeled calls,
1192
+ # mirroring {#pdf} exactly (the raw-bytes dispatch idiom).
1193
+ #
1194
+ # @param url [String] target URL to capture
1195
+ # @param format [String] image format: "png" (default), "jpeg", "webp"
1196
+ # @param full_page [Boolean] capture the full scrollable page (default false)
1197
+ # @param screenshot_selector [String, nil] CSS selector to clip to (optional)
1198
+ # @param engine [String] browser engine (default "auto")
1199
+ # @return [String] the raw image bytes (ASCII-8BIT)
1200
+ def screenshot(url:, format: "png", full_page: false, screenshot_selector: nil,
1201
+ engine: "auto", **opts)
1202
+ data = { "url" => url, "format" => format, "full_page" => full_page,
1203
+ "engine" => engine }
1204
+ data["screenshot_selector"] = screenshot_selector unless screenshot_selector.nil?
1205
+ data.merge!(opts.transform_keys(&:to_s))
1206
+ request_info = MicrosoftKiotaAbstractions::RequestInformation.new
1207
+ request_info.http_method = :POST
1208
+ request_info.url_template = "{+baseurl}/v1/screenshot"
1209
+ request_info.headers.try_add("Accept", "image/png")
1210
+ request_info.set_stream_content(JSON.generate(data), "application/json")
1211
+ ResponseHelper.binary_request!(@adapter, request_info)
1212
+ end
1213
+
1214
+ # Render a URL and return the rendered-content JSON envelope as a Hash.
1215
+ # Delegates to POST /v1/content.
1216
+ #
1217
+ # +/v1/content+ responds with +application/json+ ({+content+, +url+, +status+,
1218
+ # +format+, +status_code+, +bytes+}); this returns the decoded Hash. It uses
1219
+ # the same hand-built {MicrosoftKiotaAbstractions::RequestInformation} +
1220
+ # shared adapter dispatch as {#pdf}, parsing the JSON body at the end.
1221
+ #
1222
+ # @param url [String] target URL to render
1223
+ # @param engine [String] browser engine (default "auto")
1224
+ # @return [Hash] the rendered-content envelope (top-level +content+ = HTML)
1225
+ def content(url:, engine: "auto", **opts)
1226
+ data = { "url" => url, "engine" => engine }.merge(opts.transform_keys(&:to_s))
1227
+ request_info = MicrosoftKiotaAbstractions::RequestInformation.new
1228
+ request_info.http_method = :POST
1229
+ request_info.url_template = "{+baseurl}/v1/content"
1230
+ request_info.headers.try_add("Accept", "application/json")
1231
+ request_info.set_stream_content(JSON.generate(data), "application/json")
1232
+ bytes = ResponseHelper.binary_request!(@adapter, request_info)
1233
+ parsed = JSON.parse(bytes)
1234
+ parsed.is_a?(Hash) ? parsed : { "content" => parsed }
1235
+ end
1236
+
1237
+ # Execute an agent task via POST /v1/agent.
1238
+ #
1239
+ # The agent capability is gated per account; when it is not enabled the API
1240
+ # replies +404 not_found+ — this returns that problem+json body as a Hash
1241
+ # (carrying +"detail"+) rather than raising, so callers can branch on
1242
+ # +result.key?("detail")+. The LLM step is BYO / not a hosted agent loop
1243
+ # (RESEARCH §2) — this method only proves the client serialize→POST→parse
1244
+ # plumbing round-trips against the real route.
1245
+ #
1246
+ # +/v1/agent+ has no generated request builder, so this hand-builds a
1247
+ # {MicrosoftKiotaAbstractions::RequestInformation} and routes it through the
1248
+ # SAME adapter (base URL + bearer auth + transport) as the modeled calls.
1249
+ #
1250
+ # @param url [String, nil] optional starting URL (folded into +task+ as +start_url+)
1251
+ # @param instruction [String, nil] natural-language task instruction
1252
+ # @param task [Hash, nil] explicit structured task object (overrides url/instruction)
1253
+ # @return [Hash] the agent result, or the gated-404 problem+json body (has +"detail"+)
1254
+ def agent(url: nil, instruction: nil, task: nil, **opts)
1255
+ payload = task || { "instruction" => instruction, "start_url" => url }
1256
+ data = { "task" => payload }.merge(opts.transform_keys(&:to_s))
1257
+ request_info = MicrosoftKiotaAbstractions::RequestInformation.new
1258
+ request_info.http_method = :POST
1259
+ request_info.url_template = "{+baseurl}/v1/agent"
1260
+ request_info.headers.try_add("Accept", "application/json")
1261
+ request_info.set_stream_content(JSON.generate(data), "application/json")
1262
+ bytes = ResponseHelper.binary_request!(@adapter, request_info)
1263
+ parsed = JSON.parse(bytes)
1264
+ parsed.is_a?(Hash) ? parsed : { "detail" => parsed }
1265
+ rescue Ghostcrawl::GhostcrawlError => e
1266
+ # Agent is account-gated: a 404 not_found is the documented "capability
1267
+ # disabled" answer, not a transport failure. Surface the problem+json body
1268
+ # as a Hash (carrying "detail") rather than raising. Any other status is a
1269
+ # real error and is re-raised.
1270
+ raise unless e.status_code == 404
1271
+ body = e.body.to_s
1272
+ brace = body.index("{")
1273
+ decoded =
1274
+ if brace
1275
+ begin
1276
+ JSON.parse(body[brace..])
1277
+ rescue StandardError
1278
+ nil
1279
+ end
1280
+ end
1281
+ decoded.is_a?(Hash) ? decoded : { "detail" => e.message, "code" => (e.code || "not_found") }
1282
+ end
1283
+
1284
+ # Create a new identity envelope.
1285
+ # Delegates to POST /v1/identity. Returns the opaque encrypted identity
1286
+ # envelope; the SDK never decodes or inspects the ciphertext.
1287
+ #
1288
+ # @param claim_os [String] OS to emulate (ios/android/macos/windows/linux)
1289
+ # @param claim_browser [String] browser to emulate (chrome/firefox/safari)
1290
+ # @param device_model [String, nil] device model (required for mobile claim_os)
1291
+ # @param viewport [Object, nil] "fullscreen"/"any" or {"width"=>Integer,"height"=>Integer}
1292
+ # @return [Hash] the identity envelope
1293
+ def identity(claim_os:, claim_browser:, device_model: nil, viewport: nil, **opts)
1294
+ data = { "claim_os" => claim_os, "claim_browser" => claim_browser }
1295
+ data["device_model"] = device_model unless device_model.nil?
1296
+ data["viewport"] = viewport unless viewport.nil?
1297
+ data.merge!(opts.transform_keys(&:to_s))
1298
+ json_request(:POST, "/v1/identity", data)
1299
+ end
1300
+
1301
+ # Get the current account's cost/usage report.
1302
+ # Delegates to GET /v1/me/usage (the canonical account-scoped usage endpoint).
1303
+ # @return [Hash] usage envelope (+wallet+, +total_usd+, +by_engine+, …)
1304
+ def usage
1305
+ json_request(:GET, "/v1/me/usage", nil)
1306
+ end
1307
+
1308
+ # List the account's persisted session storage-states.
1309
+ # Delegates to GET /v1/storage-states.
1310
+ # @return [Hash] the storage-states envelope
1311
+ def storage_states
1312
+ json_request(:GET, "/v1/storage-states", nil)
1313
+ end
1314
+
985
1315
  private
986
1316
 
1317
+ # Route an ad-hoc JSON request through the SAME adapter (base URL + bearer
1318
+ # auth + transport) as the modeled calls, for routes the generated core has
1319
+ # no builder for. Returns the decoded Hash. Mirrors {#content}'s dispatch.
1320
+ # @api private
1321
+ def json_request(method, path, body)
1322
+ request_info = MicrosoftKiotaAbstractions::RequestInformation.new
1323
+ request_info.http_method = method
1324
+ request_info.url_template = "{+baseurl}#{path}"
1325
+ request_info.headers.try_add("Accept", "application/json")
1326
+ if !body.nil? && method != :GET
1327
+ request_info.set_stream_content(JSON.generate(body), "application/json")
1328
+ end
1329
+ bytes = ResponseHelper.binary_request!(@adapter, request_info)
1330
+ parsed = (JSON.parse(bytes) if bytes && !bytes.empty?)
1331
+ parsed.is_a?(Hash) ? parsed : {}
1332
+ end
1333
+
987
1334
  # Normalizes a +"content"+ key onto a decoded scrape response.
988
1335
  #
989
- # The API returns the rendered page under a FORMAT-SPECIFIC key
990
- # (+"markdown"+, +"html"+, or +"text"+), but the documented quickstart reads
991
- # +result["content"]+. This mirrors that value onto +"content"+ in place,
992
- # KEEPING the format-specific key intact (backward compatible).
1336
+ # The API has two scrape response shapes:
1337
+ # * the FLAT +format="markdown"+ shape, which carries the rendered page at
1338
+ # the top level under the format-specific key (+"markdown"+/+"html"+/
1339
+ # +"text"+) but omits +identity_id+; and
1340
+ # * the standard ENVELOPE ({ +status+, +results+, +routing_mode+,
1341
+ # +request_class+, +identity_id+ }), where the rendered page lives under
1342
+ # +results[0]+ and the top-level +identity_id+ is present.
1343
+ # The documented quickstart reads +result["content"]+, so this mirrors the
1344
+ # rendered page onto +"content"+ in place from WHICHEVER shape is present,
1345
+ # KEEPING the original keys intact (backward compatible).
993
1346
  #
994
1347
  # No-op unless +result+ is a Hash that does not already carry +"content"+.
995
1348
  # The value chosen is: the field named by +result["format"]+ when that field
996
- # is a String, else the first String among +"markdown"+, +"html"+, +"text"+.
1349
+ # is a String, else the first String among +"markdown"+, +"html"+, +"text"+ —
1350
+ # looked up first at the top level (flat shape), then on +results[0]+
1351
+ # (envelope shape).
997
1352
  #
998
1353
  # @param result [Object] the decoded response (only mutated when a Hash)
999
1354
  # @return [Object] the same +result+, unchanged reference
@@ -1002,21 +1357,34 @@ module Ghostcrawl
1002
1357
  return result unless result.is_a?(Hash) && !result.key?("content")
1003
1358
 
1004
1359
  fmt = result["format"]
1005
- value = result[fmt] if fmt.is_a?(String)
1006
- value = nil unless value.is_a?(String)
1360
+ value = scrape_content_from(result, fmt)
1007
1361
 
1362
+ # Envelope shape: the page is under results[0], not at the top level.
1008
1363
  unless value.is_a?(String)
1009
- %w[markdown html text].each do |key|
1010
- candidate = result[key]
1011
- if candidate.is_a?(String)
1012
- value = candidate
1013
- break
1014
- end
1015
- end
1364
+ first = result["results"]
1365
+ first = first.first if first.is_a?(Array)
1366
+ value = scrape_content_from(first, fmt) if first.is_a?(Hash)
1016
1367
  end
1017
1368
 
1018
1369
  result["content"] = value if value.is_a?(String)
1019
1370
  result
1020
1371
  end
1372
+
1373
+ # Picks the rendered-page String out of a scrape result Hash: the field named
1374
+ # by +fmt+ when present, else the first String among +"markdown"+, +"html"+,
1375
+ # +"text"+. Returns +nil+ when +src+ carries no such String.
1376
+ # @api private
1377
+ def scrape_content_from(src, fmt)
1378
+ return nil unless src.is_a?(Hash)
1379
+
1380
+ value = src[fmt] if fmt.is_a?(String)
1381
+ return value if value.is_a?(String)
1382
+
1383
+ %w[markdown html text].each do |key|
1384
+ candidate = src[key]
1385
+ return candidate if candidate.is_a?(String)
1386
+ end
1387
+ nil
1388
+ end
1021
1389
  end
1022
1390
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ghostcrawl
4
- VERSION = "2.2.3"
4
+ VERSION = "2.3.1"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ghostcrawl
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.2.3
4
+ version: 2.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - GhostCrawl
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-07-01 00:00:00.000000000 Z
11
+ date: 2026-07-21 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: microsoft_kiota_abstractions
@@ -225,9 +225,6 @@ files:
225
225
  - _generated/v1/billing/usage/usage.rb
226
226
  - _generated/v1/billing/usage/usage_request_builder.rb
227
227
  - _generated/v1/binary/binary.rb
228
- - _generated/v1/binary/binary_request_builder.rb
229
- - _generated/v1/binary/download/download.rb
230
- - _generated/v1/binary/download/download_request_builder.rb
231
228
  - _generated/v1/budgets/budgets.rb
232
229
  - _generated/v1/budgets/budgets_request_builder.rb
233
230
  - _generated/v1/budgets/policy/item/item.rb
@@ -431,7 +428,7 @@ files:
431
428
  - lib/ghostcrawl/version.rb
432
429
  homepage: https://ghostcrawl.io
433
430
  licenses:
434
- - Nonstandard
431
+ - MIT
435
432
  metadata:
436
433
  homepage_uri: https://ghostcrawl.io
437
434
  documentation_uri: https://docs.ghostcrawl.io
@@ -1,31 +0,0 @@
1
- require 'microsoft_kiota_abstractions'
2
- require_relative '../../ghostcrawl'
3
- require_relative '../v1'
4
- require_relative './binary'
5
- require_relative './download/download_request_builder'
6
-
7
- module Ghostcrawl
8
- module V1
9
- module Binary
10
- ##
11
- # Builds and executes requests for operations under #v1#binary
12
- class BinaryRequestBuilder < MicrosoftKiotaAbstractions::BaseRequestBuilder
13
-
14
- ##
15
- # The download property
16
- def download()
17
- return Ghostcrawl::V1::Binary::Download::DownloadRequestBuilder.new(@path_parameters, @request_adapter)
18
- end
19
- ##
20
- ## Instantiates a new BinaryRequestBuilder and sets the default values.
21
- ## @param path_parameters Path parameters for the request
22
- ## @param request_adapter The request adapter to use to execute the requests.
23
- ## @return a void
24
- ##
25
- def initialize(path_parameters, request_adapter)
26
- super(path_parameters, request_adapter, "{+baseurl}/v1/binary")
27
- end
28
- end
29
- end
30
- end
31
- end
File without changes
@@ -1,77 +0,0 @@
1
- require 'microsoft_kiota_abstractions'
2
- require_relative '../../../ghostcrawl'
3
- require_relative '../../../models/h_t_t_p_validation_error'
4
- require_relative '../../v1'
5
- require_relative '../binary'
6
- require_relative './download'
7
-
8
- module Ghostcrawl
9
- module V1
10
- module Binary
11
- module Download
12
- ##
13
- # Builds and executes requests for operations under #v1#binary#download
14
- class DownloadRequestBuilder < MicrosoftKiotaAbstractions::BaseRequestBuilder
15
-
16
- ##
17
- ## Instantiates a new DownloadRequestBuilder and sets the default values.
18
- ## @param path_parameters Path parameters for the request
19
- ## @param request_adapter The request adapter to use to execute the requests.
20
- ## @return a void
21
- ##
22
- def initialize(path_parameters, request_adapter)
23
- super(path_parameters, request_adapter, "{+baseurl}/v1/binary/download?engine={engine}{&release_id*}")
24
- end
25
- ##
26
- ## Download your licensed engine binary (Chrome, Firefox, or WebKit) for self-hosting.Select the engine via the ``engine`` query parameter. The binary is streamedto the client and bound to your account.
27
- ## @param request_configuration Configuration for the request such as headers, query parameters, and middleware options.
28
- ## @return a Fiber of binary
29
- ##
30
- def get(request_configuration=nil)
31
- request_info = self.to_get_request_information(
32
- request_configuration
33
- )
34
- error_mapping = Hash.new
35
- error_mapping["422"] = lambda {|pn| Ghostcrawl::Models::HTTPValidationError.create_from_discriminator_value(pn) }
36
- return @request_adapter.send_async(request_info, Binary, error_mapping)
37
- end
38
- ##
39
- ## Download your licensed engine binary (Chrome, Firefox, or WebKit) for self-hosting.Select the engine via the ``engine`` query parameter. The binary is streamedto the client and bound to your account.
40
- ## @param request_configuration Configuration for the request such as headers, query parameters, and middleware options.
41
- ## @return a request_information
42
- ##
43
- def to_get_request_information(request_configuration=nil)
44
- request_info = MicrosoftKiotaAbstractions::RequestInformation.new()
45
- unless request_configuration.nil?
46
- request_info.add_headers_from_raw_object(request_configuration.headers)
47
- request_info.set_query_string_parameters_from_raw_object(request_configuration.query_parameters)
48
- request_info.add_request_options(request_configuration.options)
49
- end
50
- request_info.url_template = @url_template
51
- request_info.path_parameters = @path_parameters
52
- request_info.http_method = :GET
53
- request_info.headers.try_add('Accept', 'application/json')
54
- return request_info
55
- end
56
- ##
57
- ## Returns a request builder with the provided arbitrary URL. Using this method means any other path or query parameters are ignored.
58
- ## @param raw_url The raw URL to use for the request builder.
59
- ## @return a download_request_builder
60
- ##
61
- def with_url(raw_url)
62
- raise StandardError, 'raw_url cannot be null' if raw_url.nil?
63
- return DownloadRequestBuilder.new(raw_url, @request_adapter)
64
- end
65
-
66
- ##
67
- # Download your licensed engine binary (Chrome, Firefox, or WebKit) for self-hosting.Select the engine via the ``engine`` query parameter. The binary is streamedto the client and bound to your account.
68
- class DownloadRequestBuilderGetQueryParameters
69
-
70
- attr_accessor :engine
71
- attr_accessor :release_id
72
- end
73
- end
74
- end
75
- end
76
- end
77
- end