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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 482db5d9b31ab1b7293fc19b6834be23062a37d6ef8632fae462e369e5dfc244
4
- data.tar.gz: 01eababfa0e5cd0ecc5ca39386c4b7a8cdbe2d8f0ff7f079382c89fed5859c9c
3
+ metadata.gz: 9b83f5c591c7a3ffd859db07f96c9725f84db05826b65e9227eadce1ff3eded3
4
+ data.tar.gz: 0aa598b5c6d776b5477c0709aa26dd3789d5f79084421b1fae802e90892c4dc0
5
5
  SHA512:
6
- metadata.gz: 866336d0c4c835f6ccaafd9a444c03eb4931b97cbf4b65ca003b01ff5d374a68b06500542805951ee25c3276adf64b0d16c433a956f0dbbe8bebd91ef10e0211
7
- data.tar.gz: 250b128f21670a470961468a6da62c1647495d7b4065a6d1fbe4f13328ac680d7b40ebc6acfd68338e88fa9f8bbfb99ec88ea0d5f6f6ebb56cff56261800ea41
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 |
@@ -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:
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ghostcrawl
4
- VERSION = "2.3.0"
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.3.0
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-19 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