ghostcrawl 2.3.0 → 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 +4 -4
- data/README.md +56 -0
- data/lib/ghostcrawl/client.rb +226 -0
- data/lib/ghostcrawl/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9b83f5c591c7a3ffd859db07f96c9725f84db05826b65e9227eadce1ff3eded3
|
|
4
|
+
data.tar.gz: 0aa598b5c6d776b5477c0709aa26dd3789d5f79084421b1fae802e90892c4dc0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3458805483c931bbd9adca163e74d0610c7d02abdb6ca260459fbdbe162c573f29752846d92bf45189a8fcea7957f770588364c9c483082729386c8b2d7c281a
|
|
7
|
+
data.tar.gz: 8d56532b83ea407f5a8e9190832fe1fd8ae3327cd427cb3f26be0fd727c20567a5bc1331998caa403d622edd7456956b5a373ae1961d1281f336b741ea607b32
|
data/README.md
CHANGED
|
@@ -74,6 +74,58 @@ data = client.extract(
|
|
|
74
74
|
puts "#{data["name"]} — $#{data["price"]}"
|
|
75
75
|
```
|
|
76
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
|
+
|
|
77
129
|
## Crawl runs — wait for completion
|
|
78
130
|
|
|
79
131
|
Crawl runs are asynchronous. Instead of hand-writing a poll-with-sleep loop, use
|
|
@@ -148,6 +200,10 @@ client = Ghostcrawl::Client.new(
|
|
|
148
200
|
| Data extraction | `client.extract(url:, schema:)` | Structured JSON from any page |
|
|
149
201
|
| Deep crawl | `client.crawl(url:)` | Crawl a site depth-first |
|
|
150
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 |
|
|
151
207
|
| Account | `client.me` | Get account info and usage |
|
|
152
208
|
| Crawl runs | `client.crawl_runs` | start (with `wait:`), wait\_for\_completion, list, get, cancel |
|
|
153
209
|
| Sessions | `client.sessions` | create, list, extend, release |
|
data/lib/ghostcrawl/client.rb
CHANGED
|
@@ -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
|
|
@@ -1103,8 +1144,193 @@ module Ghostcrawl
|
|
|
1103
1144
|
ResponseHelper.to_hash(@adapter.send_async(request_info, Ghostcrawl::V1::Binary, {}))
|
|
1104
1145
|
end
|
|
1105
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
|
+
|
|
1106
1315
|
private
|
|
1107
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
|
+
|
|
1108
1334
|
# Normalizes a +"content"+ key onto a decoded scrape response.
|
|
1109
1335
|
#
|
|
1110
1336
|
# The API has two scrape response shapes:
|
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.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-
|
|
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
|