ghostcrawl 2.2.2 → 2.3.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 +4 -4
- data/LICENSE +21 -46
- data/README.md +40 -4
- data/_generated/v1/binary/binary.rb +9 -0
- data/_generated/v1/v1_request_builder.rb +1 -6
- data/lib/ghostcrawl/client.rb +424 -59
- data/lib/ghostcrawl/error_codes.rb +8 -1
- data/lib/ghostcrawl/version.rb +1 -1
- metadata +3 -6
- data/_generated/v1/binary/binary_request_builder.rb +0 -31
- data/_generated/v1/binary/download/download.rb +0 -0
- data/_generated/v1/binary/download/download_request_builder.rb +0 -77
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 482db5d9b31ab1b7293fc19b6834be23062a37d6ef8632fae462e369e5dfc244
|
|
4
|
+
data.tar.gz: 01eababfa0e5cd0ecc5ca39386c4b7a8cdbe2d8f0ff7f079382c89fed5859c9c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 866336d0c4c835f6ccaafd9a444c03eb4931b97cbf4b65ca003b01ff5d374a68b06500542805951ee25c3276adf64b0d16c433a956f0dbbe8bebd91ef10e0211
|
|
7
|
+
data.tar.gz: 250b128f21670a470961468a6da62c1647495d7b4065a6d1fbe4f13328ac680d7b40ebc6acfd68338e88fa9f8bbfb99ec88ea0d5f6f6ebb56cff56261800ea41
|
data/LICENSE
CHANGED
|
@@ -1,46 +1,21 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2026 GhostCrawl, LLC
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
34
|
-
|
|
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,40 @@ data = client.extract(
|
|
|
72
74
|
puts "#{data["name"]} — $#{data["price"]}"
|
|
73
75
|
```
|
|
74
76
|
|
|
77
|
+
## Crawl runs — wait for completion
|
|
78
|
+
|
|
79
|
+
Crawl runs are asynchronous. Instead of hand-writing a poll-with-sleep loop, use
|
|
80
|
+
the event-driven wait API: the **server** blocks until the run reaches a terminal
|
|
81
|
+
state (`completed` / `failed` / `cancelled`) and returns the finished run.
|
|
82
|
+
|
|
83
|
+
```ruby
|
|
84
|
+
# Start and wait in one call — sends wait_until: "completed"
|
|
85
|
+
run = client.crawl_runs.start(
|
|
86
|
+
url: "https://example.com",
|
|
87
|
+
max_depth: 2,
|
|
88
|
+
max_pages: 50,
|
|
89
|
+
wait: true, # block until terminal (no client sleep loop)
|
|
90
|
+
timeout: 300 # give up after 300s, returning the still-running run
|
|
91
|
+
)
|
|
92
|
+
puts run["status"] # "completed" | "failed" | "cancelled"
|
|
93
|
+
|
|
94
|
+
# Or wait on a run you already started (e.g. from a webhook or another process):
|
|
95
|
+
run = client.crawl_runs.wait_for_completion(run_id, timeout: 300)
|
|
96
|
+
|
|
97
|
+
# Deep crawl supports the same wait: keyword:
|
|
98
|
+
run = client.crawl(url: "https://example.com", wait: true, timeout: 300)
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Under the hood `wait_for_completion` chains server-blocking
|
|
102
|
+
`GET /v1/crawl-runs/{run_id}?wait=true&timeout_s=N` requests until the run is
|
|
103
|
+
terminal or your `timeout` deadline — there is no `sleep` between checks. On
|
|
104
|
+
timeout the current non-terminal run is returned, so you can simply call
|
|
105
|
+
`wait_for_completion` again to keep waiting.
|
|
106
|
+
|
|
107
|
+
**Prefer webhooks for fire-and-forget crawls.** Register an endpoint with
|
|
108
|
+
`client.webhooks.create(url:, event_types: ["crawl.completed"])` and let GhostCrawl
|
|
109
|
+
POST the result to you instead of holding a connection open.
|
|
110
|
+
|
|
75
111
|
## Error handling
|
|
76
112
|
|
|
77
113
|
```ruby
|
|
@@ -113,7 +149,7 @@ client = Ghostcrawl::Client.new(
|
|
|
113
149
|
| Deep crawl | `client.crawl(url:)` | Crawl a site depth-first |
|
|
114
150
|
| URL map | `client.map(url:)` | Discover all reachable URLs |
|
|
115
151
|
| Account | `client.me` | Get account info and usage |
|
|
116
|
-
| Crawl runs | `client.crawl_runs` | start, list, get
|
|
152
|
+
| Crawl runs | `client.crawl_runs` | start (with `wait:`), wait\_for\_completion, list, get, cancel |
|
|
117
153
|
| Sessions | `client.sessions` | create, list, extend, release |
|
|
118
154
|
| Profiles | `client.profiles` | list, get\_profile, create, update, delete |
|
|
119
155
|
| Webhooks | `client.webhooks` | list, get\_webhook, create, delete, rotate\_secret |
|
|
@@ -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/
|
|
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)
|
data/lib/ghostcrawl/client.rb
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
# client = Ghostcrawl::Client.new(token: "gck_live_YOUR_KEY")
|
|
17
17
|
# result = client.scrape(url: "https://example.com")
|
|
18
18
|
|
|
19
|
+
require "json"
|
|
19
20
|
require "microsoft_kiota_abstractions"
|
|
20
21
|
require "microsoft_kiota_faraday"
|
|
21
22
|
require "microsoft_kiota_serialization_json"
|
|
@@ -57,6 +58,72 @@ module Ghostcrawl
|
|
|
57
58
|
super
|
|
58
59
|
end
|
|
59
60
|
end
|
|
61
|
+
|
|
62
|
+
# Fix 1b: the pinned Kiota writer's write_collection_of_object_values calls
|
|
63
|
+
# `self.write_object_value(nil, v).writer` on each element — it relies on
|
|
64
|
+
# write_object_value(nil, ...) returning a *writer object* that responds to
|
|
65
|
+
# `.writer`. Our write_object_value override above (Fix 1) changes that
|
|
66
|
+
# nil-key contract to serialize-into-self and return the serialize result
|
|
67
|
+
# (a Hash / nil), so the upstream `.writer` call raises
|
|
68
|
+
# `NoMethodError: undefined method 'writer' for {}:Hash`. That aborts
|
|
69
|
+
# serialize for ANY response model carrying a collection of typed objects
|
|
70
|
+
# (WebhookListResponse#items, etc.), which ResponseHelper.serialize_parsable
|
|
71
|
+
# then swallows -> the facade returns {} and silently drops the whole list.
|
|
72
|
+
#
|
|
73
|
+
# Fix: serialize each element into its OWN fresh writer and collect the raw
|
|
74
|
+
# per-element hashes directly, never depending on the write_object_value
|
|
75
|
+
# return contract. Mirrors the non-nil upstream branch (line 154) without the
|
|
76
|
+
# broken `.writer` deref.
|
|
77
|
+
def write_collection_of_object_values(key, values)
|
|
78
|
+
return unless values
|
|
79
|
+
hashes = values.map do |v|
|
|
80
|
+
temp = MicrosoftKiotaSerializationJson::JsonSerializationWriter.new
|
|
81
|
+
v.serialize(temp)
|
|
82
|
+
temp.writer
|
|
83
|
+
end
|
|
84
|
+
if key.nil?
|
|
85
|
+
hashes
|
|
86
|
+
else
|
|
87
|
+
writer[key] = hashes
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Fix 1c: the pinned Kiota writer's write_any_value (used by
|
|
92
|
+
# write_additional_data, i.e. EVERY field AdditionalDataBody sends) has no
|
|
93
|
+
# case for a plain Hash and mis-handles an Array of Hashes:
|
|
94
|
+
# * a Hash hits the `value.is_a? Object` branch -> `return value.to_s`,
|
|
95
|
+
# which returns a string but NEVER assigns @writer[key] -> the whole
|
|
96
|
+
# nested object is SILENTLY DROPPED from the request body.
|
|
97
|
+
# * an Array is sent to write_collection_of_primitive_values, which mangles
|
|
98
|
+
# any non-primitive (Hash) element.
|
|
99
|
+
# This drops/mangles every nested field the facade sends: scrape(extract_schema),
|
|
100
|
+
# extract(schema), crawl/crawl_runs opts, datasets.append(rows: [ {..} ]),
|
|
101
|
+
# schedules.create(job_params: {..}) — the last of which the API REQUIRES,
|
|
102
|
+
# so the request 422s ("job_params required") even though the caller supplied it.
|
|
103
|
+
#
|
|
104
|
+
# Fix: intercept Hash and Array here and write a fully-recursed, JSON-native
|
|
105
|
+
# structure straight into @writer[key]. The writer emits @writer.to_json at the
|
|
106
|
+
# end, so plain nested Hash/Array values serialize correctly. Everything else
|
|
107
|
+
# falls through to the upstream primitive handling.
|
|
108
|
+
def write_any_value(key, value)
|
|
109
|
+
if value.is_a?(Hash) || value.is_a?(Array)
|
|
110
|
+
return super unless key # nil-key arrays keep upstream behavior
|
|
111
|
+
writer[key] = KiotaWriterFix.jsonable(value)
|
|
112
|
+
return
|
|
113
|
+
end
|
|
114
|
+
super
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Recursively converts a value into a JSON-native structure (Hash/Array of
|
|
118
|
+
# plain scalars), so it round-trips through @writer.to_json intact.
|
|
119
|
+
# @api private
|
|
120
|
+
def self.jsonable(value)
|
|
121
|
+
case value
|
|
122
|
+
when Hash then value.each_with_object({}) { |(k, v), h| h[k.to_s] = jsonable(v) }
|
|
123
|
+
when Array then value.map { |v| jsonable(v) }
|
|
124
|
+
else value
|
|
125
|
+
end
|
|
126
|
+
end
|
|
60
127
|
end
|
|
61
128
|
|
|
62
129
|
# Patch the writer class once, idempotently
|
|
@@ -190,17 +257,130 @@ module Ghostcrawl
|
|
|
190
257
|
when NilClass
|
|
191
258
|
{}
|
|
192
259
|
else
|
|
193
|
-
# Typed Parsable
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
260
|
+
# Typed Parsable (Kiota-generated model). Kiota models keep their spec
|
|
261
|
+
# fields in typed instance variables, NOT in +additional_data+ (which is
|
|
262
|
+
# only the overflow bucket for unmapped keys). Reading +additional_data+
|
|
263
|
+
# alone therefore returns +{}+ for a fully-typed response (e.g.
|
|
264
|
+
# MapResponse{@links,@success}, WebhookListResponse{@items,@total}),
|
|
265
|
+
# silently dropping the real payload.
|
|
266
|
+
#
|
|
267
|
+
# Recover the typed fields by round-tripping the model through its own
|
|
268
|
+
# +serialize+ (the Parsable contract) into a JSON writer and parsing the
|
|
269
|
+
# result back to a plain Hash. Then overlay any +additional_data+ (unmapped
|
|
270
|
+
# keys the server sent that the model didn't declare). Degrade gracefully:
|
|
271
|
+
# a model whose +serialize+ raises (e.g. an unresolved composed-type member)
|
|
272
|
+
# falls back to the prior additional_data/to_h behavior so nothing regresses.
|
|
273
|
+
parsable_to_hash(value)
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# Converts a typed Kiota Parsable into a plain Hash by serializing it via its
|
|
278
|
+
# own +serialize+ and parsing the emitted JSON, then merging +additional_data+.
|
|
279
|
+
# Falls back to +additional_data+ / +to_h+ / the value itself on any failure.
|
|
280
|
+
# @api private
|
|
281
|
+
def self.parsable_to_hash(value)
|
|
282
|
+
serialized = serialize_parsable(value)
|
|
283
|
+
if serialized.is_a?(Hash)
|
|
284
|
+
extra = (value.additional_data if value.respond_to?(:additional_data)) || {}
|
|
285
|
+
merged = serialized.merge(extra.transform_keys(&:to_s))
|
|
286
|
+
return merged.transform_values { |v| to_hash(v) }
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
if value.respond_to?(:additional_data) && value.additional_data && !value.additional_data.empty?
|
|
290
|
+
value.additional_data.transform_values { |v| to_hash(v) }
|
|
291
|
+
elsif value.respond_to?(:to_h)
|
|
292
|
+
value.to_h.transform_values { |v| to_hash(v) }
|
|
293
|
+
elsif value.respond_to?(:additional_data) && value.additional_data
|
|
294
|
+
value.additional_data.transform_values { |v| to_hash(v) }
|
|
295
|
+
else
|
|
296
|
+
value
|
|
201
297
|
end
|
|
202
298
|
end
|
|
203
299
|
|
|
300
|
+
# Serializes a Parsable to a Ruby Hash using the pinned Kiota JSON writer.
|
|
301
|
+
# Returns the parsed Hash, or +nil+ when the model isn't a serializable
|
|
302
|
+
# Parsable or serialization/parse fails (caller then falls back).
|
|
303
|
+
# @api private
|
|
304
|
+
def self.serialize_parsable(value)
|
|
305
|
+
return nil unless value.respond_to?(:serialize)
|
|
306
|
+
|
|
307
|
+
writer = MicrosoftKiotaSerializationJson::JsonSerializationWriter.new
|
|
308
|
+
value.serialize(writer)
|
|
309
|
+
content = writer.get_serialized_content
|
|
310
|
+
json = content.is_a?(String) ? content : content.read
|
|
311
|
+
return nil if json.nil? || json.empty?
|
|
312
|
+
|
|
313
|
+
parsed = JSON.parse(json)
|
|
314
|
+
parsed.is_a?(Hash) ? parsed : nil
|
|
315
|
+
rescue StandardError
|
|
316
|
+
nil
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
# Executes a request that returns NO response body (an HTTP 204, or any DELETE
|
|
320
|
+
# whose spec maps the success response to +void+) and returns a plain Hash.
|
|
321
|
+
#
|
|
322
|
+
# Why this exists — two upstream defects in the pinned Kiota Ruby runtime that
|
|
323
|
+
# make the generated void-DELETE builders unusable:
|
|
324
|
+
#
|
|
325
|
+
# 1. The generated +delete+ for a void response calls
|
|
326
|
+
# +send_async(request_info, nil, ...)+, and the Faraday adapter hard-raises
|
|
327
|
+
# +"factory cannot be null"+ BEFORE the request is ever sent — so the DELETE
|
|
328
|
+
# never reaches the server and the caller gets a bare +StandardError+
|
|
329
|
+
# (not a {GhostcrawlError}).
|
|
330
|
+
# 2. For void DELETEs the adapter DOES send maps to +Binary+, the response is an
|
|
331
|
+
# empty 204 body, and +get_root_parse_node+ then feeds +""+ to the JSON parser,
|
|
332
|
+
# raising +JSON::ParserError+ on a perfectly successful delete.
|
|
333
|
+
#
|
|
334
|
+
# This helper reuses the adapter's OWN public request pipeline
|
|
335
|
+
# (+convert_to_native_request_async+ applies base-url + auth, +run_request+
|
|
336
|
+
# sends it) but skips body deserialization entirely: a 2xx returns +{}+ (or the
|
|
337
|
+
# decoded JSON when the server did send a body, e.g. +{"deleted":true}+), and a
|
|
338
|
+
# >=400 is translated into the documented typed {GhostcrawlError} hierarchy.
|
|
339
|
+
#
|
|
340
|
+
# @param adapter [MicrosoftKiotaFaraday::FaradayRequestAdapter]
|
|
341
|
+
# @param request_info [MicrosoftKiotaAbstractions::RequestInformation]
|
|
342
|
+
# @return [Hash] +{}+ on an empty 2xx, or the decoded body when one is present
|
|
343
|
+
# @api private
|
|
344
|
+
def self.void_request!(adapter, request_info)
|
|
345
|
+
# Replicate the first half of the adapter's send_async by hand — apply the
|
|
346
|
+
# base URL, run the bearer-auth Fiber (which mutates request_info's headers),
|
|
347
|
+
# then build the native Faraday request. We deliberately do NOT call the
|
|
348
|
+
# adapter's convert_to_native_request_async helper: its body uses a bare
|
|
349
|
+
# +return+ inside a Fiber, which raises LocalJumpError("unexpected return")
|
|
350
|
+
# when resumed under Ruby 3.x.
|
|
351
|
+
request_info.path_parameters["baseurl"] = adapter.get_base_url
|
|
352
|
+
# authenticate_request returns a Fiber only when the Authorization header is
|
|
353
|
+
# not already present; it returns nil otherwise. Guard the resume.
|
|
354
|
+
auth_fiber = adapter.authentication_provider.authenticate_request(request_info)
|
|
355
|
+
auth_fiber.resume if auth_fiber.respond_to?(:resume)
|
|
356
|
+
request = adapter.get_request_from_request_info(request_info)
|
|
357
|
+
response = adapter.client.run_request(
|
|
358
|
+
request.http_method, request.path, request.body, request.headers
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
status = response.status
|
|
362
|
+
if status >= 400
|
|
363
|
+
# Reuse the typed-error translation: synthesize a message carrying the
|
|
364
|
+
# ":<status>" token raise_translated keys off, plus any body text.
|
|
365
|
+
body = response.body.to_s
|
|
366
|
+
err = MicrosoftKiotaAbstractions::ApiError.new(
|
|
367
|
+
"The server returned an unexpected status code:#{status}" \
|
|
368
|
+
"#{body.empty? ? '' : " #{body}"}"
|
|
369
|
+
)
|
|
370
|
+
Ghostcrawl.raise_translated(err)
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
body = response.body.to_s
|
|
374
|
+
return {} if body.strip.empty?
|
|
375
|
+
|
|
376
|
+
parsed = (JSON.parse(body) rescue nil)
|
|
377
|
+
parsed.is_a?(Hash) ? parsed.transform_values { |v| to_hash(v) } : {}
|
|
378
|
+
rescue Ghostcrawl::GhostcrawlError
|
|
379
|
+
raise
|
|
380
|
+
rescue StandardError => e
|
|
381
|
+
Ghostcrawl.raise_translated(e)
|
|
382
|
+
end
|
|
383
|
+
|
|
204
384
|
# Inspects a decoded HTTP-200 response hash for a RESULT-channel failure (the
|
|
205
385
|
# target page could not be scraped) and raises {Ghostcrawl::ScrapeError} when
|
|
206
386
|
# one is present. This is the reliable, highest-value error path: the body is
|
|
@@ -283,26 +463,101 @@ module Ghostcrawl
|
|
|
283
463
|
|
|
284
464
|
# Manage crawl runs — /v1/crawl-runs.
|
|
285
465
|
class CrawlRunsClient
|
|
286
|
-
|
|
466
|
+
# Terminal run states — a crawl run in any of these will never change again,
|
|
467
|
+
# so a wait can stop. Both spellings of "cancelled" are accepted.
|
|
468
|
+
TERMINAL_STATUSES = %w[completed failed cancelled canceled].freeze
|
|
469
|
+
|
|
470
|
+
# Per-request server-block window (seconds) for the long-poll wait. Each
|
|
471
|
+
# +GET ...?wait=true&timeout_s=WAIT_WINDOW_S+ makes the SERVER block for up
|
|
472
|
+
# to this long, so the client never sleeps between checks. Kept well under
|
|
473
|
+
# the client read timeout ({Client::DEFAULT_TIMEOUT}) so a single blocking
|
|
474
|
+
# request can never trip the transport timeout.
|
|
475
|
+
WAIT_WINDOW_S = 30
|
|
476
|
+
|
|
477
|
+
def initialize(v1, adapter = nil)
|
|
287
478
|
@v1 = v1
|
|
479
|
+
@adapter = adapter
|
|
288
480
|
end
|
|
289
481
|
|
|
290
482
|
# Start a new crawl run from a seed URL.
|
|
291
483
|
# Delegates to POST /v1/crawl-runs via the generated CrawlRunsRequestBuilder.
|
|
292
484
|
# The endpoint is a tagged union: a start request requires +action: "start"+
|
|
293
485
|
# and a +seed_urls+ array (not a bare +url+).
|
|
486
|
+
#
|
|
487
|
+
# Pass +wait: true+ to block until the run reaches a terminal state
|
|
488
|
+
# (completed | failed | cancelled) or +timeout+ seconds elapse. This sends
|
|
489
|
+
# +wait_until: "completed"+ so the SERVER blocks — no client-side poll loop,
|
|
490
|
+
# no +sleep+. When the run is still running at +timeout+ the current
|
|
491
|
+
# (non-terminal) record is returned; call {#wait_for_completion} again to
|
|
492
|
+
# keep waiting.
|
|
493
|
+
#
|
|
294
494
|
# @param url [String] seed URL
|
|
295
495
|
# @param max_depth [Integer] maximum crawl depth (default 2)
|
|
296
496
|
# @param max_pages [Integer] maximum pages (default 100)
|
|
497
|
+
# @param wait [Boolean] block until the run is terminal (default false)
|
|
498
|
+
# @param timeout [Integer] total seconds to wait when +wait: true+ (default 300)
|
|
297
499
|
# @param raise_on_result_error [Boolean] raise {Ghostcrawl::ScrapeError} on a
|
|
298
|
-
# target-side (HTTP-200) failure instead of returning the raw hash (default true)
|
|
299
|
-
#
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
500
|
+
# target-side (HTTP-200) failure instead of returning the raw hash (default true).
|
|
501
|
+
# Ignored on the +wait: true+ path, which always returns the terminal run so
|
|
502
|
+
# the caller can inspect a +failed+ status.
|
|
503
|
+
# @return [Hash] crawl run record with +run_id+ and +status+ (results present
|
|
504
|
+
# when it completed while waiting)
|
|
505
|
+
def start(url:, max_depth: 2, max_pages: 100, wait: false, timeout: 300,
|
|
506
|
+
raise_on_result_error: true, **opts)
|
|
507
|
+
data = { "action" => "start", "seed_urls" => [url],
|
|
508
|
+
"max_depth" => max_depth, "max_pages" => max_pages }
|
|
509
|
+
.merge(opts.transform_keys(&:to_s))
|
|
510
|
+
|
|
511
|
+
unless wait
|
|
512
|
+
hash = ResponseHelper.to_hash(@v1.crawl_runs.post(AdditionalDataBody.new(data)))
|
|
513
|
+
return raise_on_result_error ? ResponseHelper.raise_on_result_error!(hash) : hash
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
# Start-and-wait: ask the server to block until terminal. Bound the first
|
|
517
|
+
# server block to a safe window, then long-poll the rest via GET so a low
|
|
518
|
+
# per-request read timeout can never abort the whole wait.
|
|
519
|
+
window = [timeout, WAIT_WINDOW_S].min
|
|
520
|
+
data["wait_until"] = "completed"
|
|
521
|
+
data["timeout_s"] = window
|
|
522
|
+
hash = ResponseHelper.to_hash(@v1.crawl_runs.post(AdditionalDataBody.new(data)))
|
|
523
|
+
return hash if terminal?(hash)
|
|
524
|
+
|
|
525
|
+
run_id = hash["run_id"]
|
|
526
|
+
remaining = timeout - window
|
|
527
|
+
return hash if run_id.nil? || run_id.to_s.empty? || remaining <= 0
|
|
528
|
+
wait_for_completion(run_id, timeout: remaining)
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
# Block until an existing crawl run reaches a terminal state, or +timeout+
|
|
532
|
+
# seconds elapse. Event-driven: each iteration issues a SERVER-blocking
|
|
533
|
+
# +GET /v1/crawl-runs/{run_id}?wait=true&timeout_s=N+ that returns the moment
|
|
534
|
+
# the run goes terminal (or after its own window). There is no client +sleep+
|
|
535
|
+
# — the wait cost lives on the server, and successive blocking windows are
|
|
536
|
+
# chained until the caller's +timeout+ deadline.
|
|
537
|
+
#
|
|
538
|
+
# On timeout the current non-terminal run is returned (never raises for a
|
|
539
|
+
# slow run); a terminal run is returned as soon as it is observed.
|
|
540
|
+
#
|
|
541
|
+
# @param run_id [String] the run to wait on
|
|
542
|
+
# @param timeout [Integer] total seconds to wait (default 300)
|
|
543
|
+
# @return [Hash] the run record (terminal when it finished in time)
|
|
544
|
+
def wait_for_completion(run_id, timeout: 300)
|
|
545
|
+
if run_id.nil? || run_id.to_s.empty?
|
|
546
|
+
raise ArgumentError, "run_id is required"
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
deadline = monotonic + timeout
|
|
550
|
+
loop do
|
|
551
|
+
remaining = deadline - monotonic
|
|
552
|
+
# Deadline reached — one final non-blocking read of the current state.
|
|
553
|
+
return get(run_id) if remaining <= 0
|
|
554
|
+
|
|
555
|
+
window = [remaining, WAIT_WINDOW_S].min
|
|
556
|
+
run = fetch_waiting(run_id, window)
|
|
557
|
+
return run if terminal?(run)
|
|
558
|
+
# The server just blocked for ~window seconds; loop straight into the
|
|
559
|
+
# next blocking window. No client-side delay.
|
|
560
|
+
end
|
|
306
561
|
end
|
|
307
562
|
|
|
308
563
|
# List crawl runs.
|
|
@@ -322,6 +577,33 @@ module Ghostcrawl
|
|
|
322
577
|
def cancel(run_id)
|
|
323
578
|
ResponseHelper.to_hash(@v1.crawl_runs.by_run_id(run_id).cancel.post)
|
|
324
579
|
end
|
|
580
|
+
|
|
581
|
+
private
|
|
582
|
+
|
|
583
|
+
# True when +run+ is a Hash carrying a terminal +status+.
|
|
584
|
+
def terminal?(run)
|
|
585
|
+
run.is_a?(Hash) && TERMINAL_STATUSES.include?(run["status"].to_s)
|
|
586
|
+
end
|
|
587
|
+
|
|
588
|
+
# Issues one SERVER-blocking GET on the run with +?wait=true&timeout_s=N+.
|
|
589
|
+
#
|
|
590
|
+
# The generated item builder's +to_get_request_information+ does not wire
|
|
591
|
+
# query parameters (its URL template has no query placeholder), so we take
|
|
592
|
+
# the request it builds, extend the template with +{?wait,timeout_s}+, and
|
|
593
|
+
# set the params before sending through the raw adapter — mirroring how
|
|
594
|
+
# {Client#me} re-issues a request with the Binary (raw-JSON) response factory.
|
|
595
|
+
def fetch_waiting(run_id, timeout_s)
|
|
596
|
+
request_info = @v1.crawl_runs.by_run_id(run_id).to_get_request_information(nil)
|
|
597
|
+
request_info.url_template = "{+baseurl}/v1/crawl-runs/{run_id}{?wait,timeout_s}"
|
|
598
|
+
request_info.query_parameters["wait"] = "true"
|
|
599
|
+
request_info.query_parameters["timeout_s"] = timeout_s.to_i.to_s
|
|
600
|
+
ResponseHelper.to_hash(@adapter.send_async(request_info, Ghostcrawl::V1::Binary, {}))
|
|
601
|
+
end
|
|
602
|
+
|
|
603
|
+
# Monotonic clock (seconds) — immune to wall-clock adjustments during a wait.
|
|
604
|
+
def monotonic
|
|
605
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
606
|
+
end
|
|
325
607
|
end
|
|
326
608
|
|
|
327
609
|
# Manage browser sessions — /v1/sessions.
|
|
@@ -347,7 +629,7 @@ module Ghostcrawl
|
|
|
347
629
|
# Extend a session's TTL.
|
|
348
630
|
# Delegates to POST /v1/sessions/{id}/extend via the generated builder.
|
|
349
631
|
def extend(session_id, duration_seconds: 300)
|
|
350
|
-
ResponseHelper.to_hash(@v1.sessions.
|
|
632
|
+
ResponseHelper.to_hash(@v1.sessions.by_profile_id(session_id).extend.post(
|
|
351
633
|
AdditionalDataBody.new({ "ttl_seconds" => duration_seconds })
|
|
352
634
|
))
|
|
353
635
|
end
|
|
@@ -355,14 +637,15 @@ module Ghostcrawl
|
|
|
355
637
|
# Release a session back to the pool.
|
|
356
638
|
# Delegates to POST /v1/sessions/{id}/release via the generated builder.
|
|
357
639
|
def release(session_id)
|
|
358
|
-
ResponseHelper.to_hash(@v1.sessions.
|
|
640
|
+
ResponseHelper.to_hash(@v1.sessions.by_profile_id(session_id).release.post)
|
|
359
641
|
end
|
|
360
642
|
end
|
|
361
643
|
|
|
362
644
|
# Manage identity profiles — /v1/profiles.
|
|
363
645
|
class ProfilesClient
|
|
364
|
-
def initialize(v1)
|
|
646
|
+
def initialize(v1, adapter = nil)
|
|
365
647
|
@v1 = v1
|
|
648
|
+
@adapter = adapter
|
|
366
649
|
end
|
|
367
650
|
|
|
368
651
|
# List all profiles.
|
|
@@ -392,15 +675,18 @@ module Ghostcrawl
|
|
|
392
675
|
|
|
393
676
|
# Delete a profile.
|
|
394
677
|
# Delegates to DELETE /v1/profiles/{name} via the generated builder.
|
|
678
|
+
# The endpoint answers 204 No Content; routing through {ResponseHelper.void_request!}
|
|
679
|
+
# avoids the Kiota JSON parser choking on the empty body.
|
|
395
680
|
def delete(name)
|
|
396
|
-
ResponseHelper.
|
|
681
|
+
ResponseHelper.void_request!(@adapter, @v1.profiles.by_name(name).to_delete_request_information(nil))
|
|
397
682
|
end
|
|
398
683
|
end
|
|
399
684
|
|
|
400
685
|
# Manage webhooks — /v1/webhooks.
|
|
401
686
|
class WebhooksClient
|
|
402
|
-
def initialize(v1)
|
|
687
|
+
def initialize(v1, adapter = nil)
|
|
403
688
|
@v1 = v1
|
|
689
|
+
@adapter = adapter
|
|
404
690
|
end
|
|
405
691
|
|
|
406
692
|
# List all webhooks.
|
|
@@ -426,10 +712,13 @@ module Ghostcrawl
|
|
|
426
712
|
end
|
|
427
713
|
|
|
428
714
|
# Delete a webhook.
|
|
429
|
-
# Delegates to DELETE /v1/webhooks/{id}
|
|
715
|
+
# Delegates to DELETE /v1/webhooks/{id}. The generated builder passes a +nil+
|
|
716
|
+
# response factory (204 void), which the Kiota adapter rejects with a bare
|
|
717
|
+
# StandardError BEFORE sending the request — so we build the request info and
|
|
718
|
+
# run it through {ResponseHelper.void_request!} instead, which actually fires
|
|
719
|
+
# the DELETE and returns +{}+ on success.
|
|
430
720
|
def delete(webhook_id)
|
|
431
|
-
@v1.webhooks.by_webhook_id(webhook_id).
|
|
432
|
-
{}
|
|
721
|
+
ResponseHelper.void_request!(@adapter, @v1.webhooks.by_webhook_id(webhook_id).to_delete_request_information(nil))
|
|
433
722
|
end
|
|
434
723
|
|
|
435
724
|
# Rotate the signing secret for a webhook.
|
|
@@ -441,8 +730,9 @@ module Ghostcrawl
|
|
|
441
730
|
|
|
442
731
|
# Manage schedules — /v1/schedules.
|
|
443
732
|
class SchedulesClient
|
|
444
|
-
def initialize(v1)
|
|
733
|
+
def initialize(v1, adapter = nil)
|
|
445
734
|
@v1 = v1
|
|
735
|
+
@adapter = adapter
|
|
446
736
|
end
|
|
447
737
|
|
|
448
738
|
# List all schedules.
|
|
@@ -459,16 +749,45 @@ module Ghostcrawl
|
|
|
459
749
|
|
|
460
750
|
# Create a new schedule.
|
|
461
751
|
# Delegates to POST /v1/schedules via the generated SchedulesRequestBuilder.
|
|
462
|
-
|
|
463
|
-
|
|
752
|
+
#
|
|
753
|
+
# The API's ScheduleCreateRequest requires +name+, +job_type+
|
|
754
|
+
# ("scrape" | "crawl" | "change_monitor"), +cron_expr+, and +job_params+
|
|
755
|
+
# (the full scrape/crawl request body). It rejects any other top-level field
|
|
756
|
+
# with a 422 — so a legacy +task+ key must be TRANSLATED, never forwarded.
|
|
757
|
+
#
|
|
758
|
+
# @param cron [String] cron expression (sent as +cron_expr+)
|
|
759
|
+
# @param name [String] schedule name (required by the API)
|
|
760
|
+
# @param job_type [String, nil] "scrape" | "crawl" | "change_monitor"
|
|
761
|
+
# @param job_params [Hash, nil] full job request body (e.g. { url: ... })
|
|
762
|
+
# @param task [Hash, nil] DEPRECATED legacy shape { "action" => ..., ...rest };
|
|
763
|
+
# when given (and job_type/job_params are absent) it is split into
|
|
764
|
+
# +job_type+ (from +task["action"]+) and +job_params+ (the remaining keys).
|
|
765
|
+
def create(cron:, name: nil, job_type: nil, job_params: nil, task: nil, **opts)
|
|
766
|
+
# Back-compat: derive job_type/job_params from a legacy `task` hash.
|
|
767
|
+
if task.is_a?(Hash)
|
|
768
|
+
t = task.transform_keys(&:to_s)
|
|
769
|
+
job_type ||= t["action"] || t["job_type"] || t["type"]
|
|
770
|
+
job_params ||= t.reject { |k, _| %w[action job_type type].include?(k) }
|
|
771
|
+
end
|
|
772
|
+
|
|
773
|
+
data = { "cron_expr" => cron }
|
|
774
|
+
data["name"] = name unless name.nil?
|
|
775
|
+
data["job_type"] = job_type unless job_type.nil?
|
|
776
|
+
data["job_params"] = job_params unless job_params.nil?
|
|
777
|
+
# opts may override/supply name/job_type/job_params/notify_webhook/monitor_mode.
|
|
778
|
+
# Never forward a raw `task` field — the API 422s on it.
|
|
779
|
+
data.merge!(opts.transform_keys(&:to_s).reject { |k, _| k == "task" })
|
|
780
|
+
|
|
464
781
|
ResponseHelper.to_hash(@v1.schedules.post(AdditionalDataBody.new(data)))
|
|
465
782
|
end
|
|
466
783
|
|
|
467
784
|
# Delete a schedule.
|
|
468
|
-
# Delegates to DELETE /v1/schedules/{id}
|
|
785
|
+
# Delegates to DELETE /v1/schedules/{id}. The generated builder returns a Fiber
|
|
786
|
+
# that must be resumed to actually send the request (the old +; {}+ discarded it,
|
|
787
|
+
# so the DELETE never fired). {ResponseHelper.void_request!} runs it and tolerates
|
|
788
|
+
# the empty 204 body.
|
|
469
789
|
def delete(schedule_id)
|
|
470
|
-
@v1.schedules.by_schedule_id(schedule_id).
|
|
471
|
-
{}
|
|
790
|
+
ResponseHelper.void_request!(@adapter, @v1.schedules.by_schedule_id(schedule_id).to_delete_request_information(nil))
|
|
472
791
|
end
|
|
473
792
|
end
|
|
474
793
|
|
|
@@ -519,8 +838,9 @@ module Ghostcrawl
|
|
|
519
838
|
|
|
520
839
|
# Manage session recordings — /v1/recordings.
|
|
521
840
|
class RecordingsClient
|
|
522
|
-
def initialize(v1)
|
|
841
|
+
def initialize(v1, adapter = nil)
|
|
523
842
|
@v1 = v1
|
|
843
|
+
@adapter = adapter
|
|
524
844
|
end
|
|
525
845
|
|
|
526
846
|
# List all recordings.
|
|
@@ -531,15 +851,17 @@ module Ghostcrawl
|
|
|
531
851
|
|
|
532
852
|
# Get a recording by ID.
|
|
533
853
|
# Delegates to GET /v1/recordings/{id} via the generated builder.
|
|
854
|
+
# (The generated accessor is +by_recording_id+, single underscore.)
|
|
534
855
|
def get(recording_id)
|
|
535
|
-
ResponseHelper.to_hash(@v1.recordings.
|
|
856
|
+
ResponseHelper.to_hash(@v1.recordings.by_recording_id(recording_id).get)
|
|
536
857
|
end
|
|
537
858
|
|
|
538
859
|
# Delete a recording.
|
|
539
|
-
# Delegates to DELETE /v1/recordings/{id}
|
|
860
|
+
# Delegates to DELETE /v1/recordings/{id}. Same void-DELETE defect as webhooks
|
|
861
|
+
# (generated builder passes a +nil+ factory → adapter raises before sending), so
|
|
862
|
+
# we route through {ResponseHelper.void_request!}.
|
|
540
863
|
def delete(recording_id)
|
|
541
|
-
@v1.recordings.
|
|
542
|
-
{}
|
|
864
|
+
ResponseHelper.void_request!(@adapter, @v1.recordings.by_recording_id(recording_id).to_delete_request_information(nil))
|
|
543
865
|
end
|
|
544
866
|
end
|
|
545
867
|
|
|
@@ -628,13 +950,18 @@ module Ghostcrawl
|
|
|
628
950
|
# (Binary) response factory instead of the typed MeResponse model.
|
|
629
951
|
@adapter = adapter
|
|
630
952
|
|
|
631
|
-
|
|
953
|
+
# CrawlRunsClient needs the raw adapter for the event-driven long-poll wait
|
|
954
|
+
# (the generated item builder can't attach ?wait/&timeout_s query params).
|
|
955
|
+
@crawl_runs = CrawlRunsClient.new(@v1, @adapter)
|
|
632
956
|
@sessions = SessionsClient.new(@v1)
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
957
|
+
# Sub-clients with void-DELETE endpoints (204 No Content) also need the raw
|
|
958
|
+
# adapter so ResponseHelper.void_request! can work around the broken generated
|
|
959
|
+
# delete builders. See ResponseHelper.void_request! for the two Kiota defects.
|
|
960
|
+
@profiles = ProfilesClient.new(@v1, @adapter)
|
|
961
|
+
@webhooks = WebhooksClient.new(@v1, @adapter)
|
|
962
|
+
@schedules = SchedulesClient.new(@v1, @adapter)
|
|
636
963
|
@datasets = DatasetsClient.new(@v1)
|
|
637
|
-
@recordings = RecordingsClient.new(@v1)
|
|
964
|
+
@recordings = RecordingsClient.new(@v1, @adapter)
|
|
638
965
|
@kv = KVClient.new(@v1)
|
|
639
966
|
end
|
|
640
967
|
|
|
@@ -722,17 +1049,34 @@ module Ghostcrawl
|
|
|
722
1049
|
|
|
723
1050
|
# Start a deep crawl from a seed URL.
|
|
724
1051
|
# Delegates to POST /v1/crawl/deep via the generated CrawlDeepRequestBuilder.
|
|
1052
|
+
#
|
|
1053
|
+
# Pass +wait: true+ to block until the run is terminal. The deep-crawl start
|
|
1054
|
+
# returns a +run_id+, then the wait is handled by the same event-driven
|
|
1055
|
+
# server-blocking long-poll as {CrawlRunsClient#wait_for_completion} — no
|
|
1056
|
+
# client-side +sleep+ loop.
|
|
1057
|
+
#
|
|
725
1058
|
# @param url [String] seed URL
|
|
726
1059
|
# @param max_depth [Integer] maximum crawl depth (default 2)
|
|
727
1060
|
# @param max_pages [Integer] maximum pages (default 100)
|
|
1061
|
+
# @param wait [Boolean] block until the run is terminal (default false)
|
|
1062
|
+
# @param timeout [Integer] total seconds to wait when +wait: true+ (default 300)
|
|
728
1063
|
# @param raise_on_result_error [Boolean] raise {Ghostcrawl::ScrapeError} on a
|
|
729
|
-
# target-side (HTTP-200) failure instead of returning the raw hash (default true)
|
|
730
|
-
#
|
|
731
|
-
|
|
1064
|
+
# target-side (HTTP-200) failure instead of returning the raw hash (default true).
|
|
1065
|
+
# Ignored on the +wait: true+ path, which returns the terminal run.
|
|
1066
|
+
# @return [Hash] crawl run record (terminal when +wait: true+ and it finished in time)
|
|
1067
|
+
def crawl(url:, max_depth: 2, max_pages: 100, wait: false, timeout: 300,
|
|
1068
|
+
raise_on_result_error: true, **opts)
|
|
732
1069
|
data = { "seed_urls" => [url], "max_depth" => max_depth,
|
|
733
1070
|
"max_urls" => max_pages }.merge(opts.transform_keys(&:to_s))
|
|
734
1071
|
hash = ResponseHelper.to_hash(@v1.crawl.deep.post(AdditionalDataBody.new(data)))
|
|
735
|
-
|
|
1072
|
+
|
|
1073
|
+
unless wait
|
|
1074
|
+
return raise_on_result_error ? ResponseHelper.raise_on_result_error!(hash) : hash
|
|
1075
|
+
end
|
|
1076
|
+
|
|
1077
|
+
run_id = hash["run_id"]
|
|
1078
|
+
return hash if run_id.nil? || run_id.to_s.empty?
|
|
1079
|
+
@crawl_runs.wait_for_completion(run_id, timeout: timeout)
|
|
736
1080
|
end
|
|
737
1081
|
|
|
738
1082
|
# Map all URLs reachable from a seed URL.
|
|
@@ -763,14 +1107,22 @@ module Ghostcrawl
|
|
|
763
1107
|
|
|
764
1108
|
# Normalizes a +"content"+ key onto a decoded scrape response.
|
|
765
1109
|
#
|
|
766
|
-
# The API
|
|
767
|
-
#
|
|
768
|
-
#
|
|
769
|
-
#
|
|
1110
|
+
# The API has two scrape response shapes:
|
|
1111
|
+
# * the FLAT +format="markdown"+ shape, which carries the rendered page at
|
|
1112
|
+
# the top level under the format-specific key (+"markdown"+/+"html"+/
|
|
1113
|
+
# +"text"+) but omits +identity_id+; and
|
|
1114
|
+
# * the standard ENVELOPE ({ +status+, +results+, +routing_mode+,
|
|
1115
|
+
# +request_class+, +identity_id+ }), where the rendered page lives under
|
|
1116
|
+
# +results[0]+ and the top-level +identity_id+ is present.
|
|
1117
|
+
# The documented quickstart reads +result["content"]+, so this mirrors the
|
|
1118
|
+
# rendered page onto +"content"+ in place from WHICHEVER shape is present,
|
|
1119
|
+
# KEEPING the original keys intact (backward compatible).
|
|
770
1120
|
#
|
|
771
1121
|
# No-op unless +result+ is a Hash that does not already carry +"content"+.
|
|
772
1122
|
# The value chosen is: the field named by +result["format"]+ when that field
|
|
773
|
-
# is a String, else the first String among +"markdown"+, +"html"+, +"text"
|
|
1123
|
+
# is a String, else the first String among +"markdown"+, +"html"+, +"text"+ —
|
|
1124
|
+
# looked up first at the top level (flat shape), then on +results[0]+
|
|
1125
|
+
# (envelope shape).
|
|
774
1126
|
#
|
|
775
1127
|
# @param result [Object] the decoded response (only mutated when a Hash)
|
|
776
1128
|
# @return [Object] the same +result+, unchanged reference
|
|
@@ -779,21 +1131,34 @@ module Ghostcrawl
|
|
|
779
1131
|
return result unless result.is_a?(Hash) && !result.key?("content")
|
|
780
1132
|
|
|
781
1133
|
fmt = result["format"]
|
|
782
|
-
value = result
|
|
783
|
-
value = nil unless value.is_a?(String)
|
|
1134
|
+
value = scrape_content_from(result, fmt)
|
|
784
1135
|
|
|
1136
|
+
# Envelope shape: the page is under results[0], not at the top level.
|
|
785
1137
|
unless value.is_a?(String)
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
value = candidate
|
|
790
|
-
break
|
|
791
|
-
end
|
|
792
|
-
end
|
|
1138
|
+
first = result["results"]
|
|
1139
|
+
first = first.first if first.is_a?(Array)
|
|
1140
|
+
value = scrape_content_from(first, fmt) if first.is_a?(Hash)
|
|
793
1141
|
end
|
|
794
1142
|
|
|
795
1143
|
result["content"] = value if value.is_a?(String)
|
|
796
1144
|
result
|
|
797
1145
|
end
|
|
1146
|
+
|
|
1147
|
+
# Picks the rendered-page String out of a scrape result Hash: the field named
|
|
1148
|
+
# by +fmt+ when present, else the first String among +"markdown"+, +"html"+,
|
|
1149
|
+
# +"text"+. Returns +nil+ when +src+ carries no such String.
|
|
1150
|
+
# @api private
|
|
1151
|
+
def scrape_content_from(src, fmt)
|
|
1152
|
+
return nil unless src.is_a?(Hash)
|
|
1153
|
+
|
|
1154
|
+
value = src[fmt] if fmt.is_a?(String)
|
|
1155
|
+
return value if value.is_a?(String)
|
|
1156
|
+
|
|
1157
|
+
%w[markdown html text].each do |key|
|
|
1158
|
+
candidate = src[key]
|
|
1159
|
+
return candidate if candidate.is_a?(String)
|
|
1160
|
+
end
|
|
1161
|
+
nil
|
|
1162
|
+
end
|
|
798
1163
|
end
|
|
799
1164
|
end
|
|
@@ -92,7 +92,14 @@ module Ghostcrawl
|
|
|
92
92
|
when 403 then FORBIDDEN
|
|
93
93
|
when 404 then NOT_FOUND
|
|
94
94
|
when 409 then CONFLICT
|
|
95
|
-
|
|
95
|
+
# 422 is the generic validation status the API returns for ANY malformed
|
|
96
|
+
# request (its canonical code is "bad_request": missing field, failed
|
|
97
|
+
# extraction validation, etc.). byo_proxy_invalid is only ONE 422 sub-case
|
|
98
|
+
# and, when it genuinely occurs, arrives WITH a problem+json body carrying
|
|
99
|
+
# code:"byo_proxy_invalid" (which raise_translated reads first). Using the
|
|
100
|
+
# BYO-specific code as the blanket 422 fallback mislabels every unrelated
|
|
101
|
+
# validation 422. Fall back to the generic bad_request instead.
|
|
102
|
+
when 422 then BAD_REQUEST
|
|
96
103
|
when 429 then RATE_LIMITED
|
|
97
104
|
when 503 then SERVICE_UNAVAILABLE
|
|
98
105
|
when 504 then FLEET_TIMEOUT
|
data/lib/ghostcrawl/version.rb
CHANGED
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.
|
|
4
|
+
version: 2.3.0
|
|
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-
|
|
11
|
+
date: 2026-07-19 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
|
-
-
|
|
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
|