Class: Obxcura::Page
- Inherits:
-
Object
- Object
- Obxcura::Page
- Extended by:
- Forwardable
- Defined in:
- lib/obxcura/page.rb
Overview
A single page/tab: a CDP target with its own attached session. Created via Browser#create_page — you rarely instantiate this directly.
Page owns the CDP session, navigation, in-page POSTs and lifecycle. Reading
the DOM and executing JS belong to its main Frame (see Frame::DOM and
Frame::Runtime), and Page delegates those (#evaluate, #html, #title,
#at_css, ...).
Constant Summary collapse
- TIMEOUT_HEADROOM =
Seconds of slack given to the CDP reply beyond a #post timeout, so the in-page abort is what surfaces rather than the transport giving up first.
5- SCREENSHOT_FORMATS =
Image formats Obscura's render engine will encode.
%i[png jpeg webp].freeze
- SCREENSHOT_EXTENSIONS =
File extensions mapped to a SCREENSHOT_FORMATS entry, so
path:alone can pick the encoder. { ".png" => :png, ".jpg" => :jpeg, ".jpeg" => :jpeg, ".webp" => :webp }.freeze
- PDF_PAPER_SIZES =
Named paper sizes for #pdf, as
[width, height]in inches. { letter: [ 8.5, 11.0 ], legal: [ 8.5, 14.0 ], tabloid: [ 11.0, 17.0 ], a3: [ 11.7, 16.54 ], a4: [ 8.27, 11.69 ], a5: [ 5.83, 8.27 ] }.freeze
- PDF_SCALE_RANGE =
Scale factors Obscura will accept for #pdf.
(0.1..2.0)
Instance Attribute Summary collapse
- #client ⇒ String, ... readonly
- #frame ⇒ String, ... readonly
- #session_id ⇒ String, ... readonly
- #target_id ⇒ String, ... readonly
Instance Method Summary collapse
-
#close ⇒ Obxcura::Page?
Close this page's target and stop listening for its events.
-
#close_connection ⇒ void
Drop the underlying WebSocket connection (affects the whole browser).
-
#command(method, params = {}) ⇒ Hash
Send a CDP command scoped to this page's session.
-
#cookies ⇒ Obxcura::Cookies
The browser's cookies, seen from this page.
-
#goto(url) ⇒ self
(also: #go_to)
Navigate to
urland block until the page's load event fires. -
#headers ⇒ Obxcura::Headers
Extra HTTP headers sent with requests this page navigates to.
-
#initialize(browser, target_id:, session_id:) ⇒ Page
constructor
A new instance of Page.
-
#network_log ⇒ Array<Hash>
Requests this page issued, oldest first, as
{ url:, request_id:, finished: }. -
#pdf(path: nil, landscape: false, print_background: false, scale: nil, paper: nil, paper_width: nil, paper_height: nil, margin: nil, page_ranges: nil) ⇒ String
Print the page to PDF.
-
#post(url, payload, content_type, headers, timeout: nil) ⇒ Hash
(also: #xhr_post)
POST from the page context via
fetch. -
#refresh ⇒ Hash
(also: #reload)
Reload the page and block until it loads again.
-
#screenshot(path: nil, format: nil, quality: nil, full_page: false, clip: nil) ⇒ String
Capture the page as an image.
Constructor Details
#initialize(browser, target_id:, session_id:) ⇒ Page
Returns a new instance of Page.
74 75 76 77 78 79 80 81 82 83 84 85 86 |
# File 'lib/obxcura/page.rb', line 74 def initialize(browser, target_id:, session_id:) @browser = browser @client = browser.client @target_id = target_id @session_id = session_id @frame = Frame.new(target_id, self) @load_queue = Queue.new @network_log = [] @network_mutex = Mutex.new @client.subscribe(@session_id) { |method, params| dispatch_event(method, params) } command("Network.enable") end |
Instance Attribute Details
#client ⇒ String, ... (readonly)
65 66 67 |
# File 'lib/obxcura/page.rb', line 65 def client @client end |
#frame ⇒ String, ... (readonly)
65 66 67 |
# File 'lib/obxcura/page.rb', line 65 def frame @frame end |
#session_id ⇒ String, ... (readonly)
65 66 67 |
# File 'lib/obxcura/page.rb', line 65 def session_id @session_id end |
#target_id ⇒ String, ... (readonly)
65 66 67 |
# File 'lib/obxcura/page.rb', line 65 def target_id @target_id end |
Instance Method Details
#close ⇒ Obxcura::Page?
Close this page's target and stop listening for its events.
135 136 137 138 139 140 141 142 143 |
# File 'lib/obxcura/page.rb', line 135 def close @client.unsubscribe(@session_id) # @client.command("Network.clearBrowserCookies", { targetId: @target_id }) @client.command("Target.closeTarget", { targetId: @target_id }) rescue ProtocolError # Target already gone — nothing to do. ensure @browser.remove_page(self) end |
#close_connection ⇒ void
This method returns an undefined value.
Drop the underlying WebSocket connection (affects the whole browser).
148 149 150 |
# File 'lib/obxcura/page.rb', line 148 def close_connection @client.close end |
#command(method, params = {}) ⇒ Hash
Send a CDP command scoped to this page's session.
188 189 190 |
# File 'lib/obxcura/page.rb', line 188 def command(method, params = {}) @client.command(method, params, session_id: @session_id) end |
#cookies ⇒ Obxcura::Cookies
The browser's cookies, seen from this page.
page..map { |c| c["name"] } # what the current URL would send
page.["session"] # one of them by name
page..all # the whole connection's jar
page..set("token", "abc")
page..remove("token")
Enumerable over the cookies the browser would attach to #current_url —
including the HttpOnly ones document.cookie hides, which is usually the
point — and writable through #set, #remove and #clear.
Read Cookies before trusting the scope: the jar belongs to the
connection, the filtering is done in Ruby because Obscura ignores
Network.getCookies' urls: parameter, and an expired cookie stays in the
jar without ever being sent.
179 180 181 |
# File 'lib/obxcura/page.rb', line 179 def @cookies ||= Cookies.new(self) end |
#goto(url) ⇒ self Also known as: go_to
Navigate to url and block until the page's load event fires. Aliased as
go_to.
124 125 126 127 128 129 |
# File 'lib/obxcura/page.rb', line 124 def goto(url) @load_queue = Queue.new command("Page.navigate", url: url) wait_for_load self end |
#headers ⇒ Obxcura::Headers
Extra HTTP headers sent with requests this page navigates to.
page.headers.set("X-Token" => "abc")
page.headers.add("Accept-Language" => "es-MX")
page.headers["X-Token"] # => "abc"
page.headers.clear
Read Headers before relying on the scope: Obscura applies one
table per connection, so this changes the headers for every page on the
same browser, and it covers navigation only — #post needs its own
headers argument.
115 116 117 |
# File 'lib/obxcura/page.rb', line 115 def headers @headers ||= Headers.new(self) end |
#network_log ⇒ Array<Hash>
Requests this page issued, oldest first, as
{ url:, request_id:, finished: }.
Scope is deliberately narrow: Obscura emits Network events for requests the
navigation drives (the document and its subresources), but not for ones
started from script. A #post — or any in-page fetch/XMLHttpRequest —
therefore never shows up here. Verified against Obscura 0.1.11: enabling the
Network domain and issuing a scripted POST produces no events at all.
98 99 100 |
# File 'lib/obxcura/page.rb', line 98 def network_log @network_mutex.synchronize { @network_log.map(&:dup) } end |
#pdf(path: nil, landscape: false, print_background: false, scale: nil, paper: nil, paper_width: nil, paper_height: nil, margin: nil, page_ranges: nil) ⇒ String
Print the page to PDF.
Like #screenshot this needs a build carrying the render feature. Output
is raster-backed — Obscura reports print-media-raster — so the text in
the PDF is drawn, not selectable. Print stylesheets are honoured.
Returns the raw PDF bytes, or writes them and returns path.
Obscura does not implement header/footer rendering or CSS @page sizing
yet, so those CDP options are deliberately not exposed; ask for them
through #command and the browser will tell you so itself.
349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 |
# File 'lib/obxcura/page.rb', line 349 def pdf(path: nil, landscape: false, print_background: false, scale: nil, paper: nil, paper_width: nil, paper_height: nil, margin: nil, page_ranges: nil) width, height = resolve_paper(paper, paper_width, paper_height) validate_pdf!(scale) params = {} params[:landscape] = true if landscape params[:printBackground] = true if print_background params[:scale] = scale if scale params[:paperWidth] = width if width params[:paperHeight] = height if height params[:pageRanges] = page_ranges.to_s if page_ranges params.merge!(pdf_margins(margin)) unless margin.nil? document = decode_payload(command("Page.printToPDF", params)["data"]) return document unless path File.binwrite(path, document) path rescue ProtocolError => e raise_unless_render_missing(e, "print to PDF") end |
#post(url, payload, content_type, headers, timeout: nil) ⇒ Hash Also known as: xhr_post
POST from the page context via fetch. All values cross as arguments, never
interpolated into the JS. Returns { status, ok, body } on any HTTP reply
(including 4xx/5xx). A transport failure — the request never reached the
server (blocked by CORS / private-network SSRF guard, mixed origin, or a
dead host) — raises ConnectionError instead of silently returning nil.
timeout (seconds) is enforced in the page by racing the fetch against a
timer, so a server that accepts the connection and never answers (some
anti-bot endpoints tarpit non-stealth clients) fails in roughly timeout
seconds instead of Client::DEFAULT_TIMEOUT. The CDP reply gets a little
headroom past that so the in-page result is what we observe.
The race is deliberate, and not the obvious AbortSignal.timeout. Obscura
does accept an abort signal, but when the abort actually fires, the fetch
rejection is swallowed and the call returns undefined — the same
in-page-throws-vanish behaviour that makes Frame::Runtime use { error: }
sentinels. A rejection can't carry the reason across, so a resolved value
has to. We still abort the underlying request once the timer wins, purely so
it stops occupying the connection.
Requests made here do not appear in #network_log; see that method.
222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 |
# File 'lib/obxcura/page.rb', line 222 def post(url, payload, content_type, headers, timeout: nil) timeout_ms = timeout && (timeout * 1000).to_i result = evaluate_func(<<~JS, url, payload, content_type, headers, timeout_ms, timeout: timeout && timeout + TIMEOUT_HEADROOM) function(url, payload, contentType, headers, timeoutMs) { const controller = timeoutMs ? new AbortController() : null; const init = { method: "POST", headers: Object.assign({ "Content-Type": contentType }, headers), body: payload }; if (controller) init.signal = controller.signal; let timer = null; const request = fetch(url, init) .then((r) => r.text().then((body) => ({ status: r.status, ok: r.ok, body: body }))) .catch((e) => ({ error: String(e) })) .then((outcome) => { if (timer) clearTimeout(timer); return outcome; }); if (!timeoutMs) return request; return Promise.race([ request, new Promise((resolve) => { timer = setTimeout(() => { if (controller) controller.abort(); resolve({ timeout: true }); }, timeoutMs); }) ]); } JS raise TimeoutError, (url) if result.is_a?(Hash) && result["timeout"] if result.nil? || result["error"] reason = result&.dig("error") || "no response (request blocked or never settled)" raise ConnectionError, "POST #{url} failed: #{reason}" end result rescue TimeoutError raise TimeoutError, (url) end |
#refresh ⇒ Hash Also known as: reload
Reload the page and block until it loads again. Aliased as reload.
155 156 157 158 |
# File 'lib/obxcura/page.rb', line 155 def refresh command("Page.reload") wait_for_load end |
#screenshot(path: nil, format: nil, quality: nil, full_page: false, clip: nil) ⇒ String
Capture the page as an image.
Obscura gained a paint engine in 0.2.0, so this is real rasterisation
rather than a serialised DOM. It needs a build carrying the render
feature: the -no-render archives of the same version refuse
Page.captureScreenshot, and that refusal is re-raised here as an
Error naming the fix rather than a bare Obxcura::ProtocolError.
Returns the raw image bytes (BINARY encoding). Base64 is a transport
detail of CDP and is decoded here, so callers get something they can write
to disk or hand to an image library directly. With path: the bytes are
written for you and the path comes back instead.
301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 |
# File 'lib/obxcura/page.rb', line 301 def screenshot(path: nil, format: nil, quality: nil, full_page: false, clip: nil) format = resolve_screenshot_format(format, path) validate_screenshot!(format, quality, full_page, clip) params = { format: format.to_s } params[:quality] = quality if quality params[:captureBeyondViewport] = true if full_page params[:clip] = normalize_clip(clip) if clip image = decode_payload(command("Page.captureScreenshot", params)["data"]) return image unless path File.binwrite(path, image) path rescue ProtocolError => e raise_unless_render_missing(e, "take screenshots") end |