Class: Obxcura::Page

Inherits:
Object
  • Object
show all
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, ...).

Examples:

page = browser.create_page
page.goto("https://example.com")
page.html                 # rendered DOM after JS
page.title                # "Example Domain"
page.at_css("h1").text    # => "Example Domain"
page.screenshot(path: "example.png")
page.close

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.

Returns:

  • (Integer)
5
SCREENSHOT_FORMATS =

Image formats Obscura's render engine will encode.

Returns:

  • (Array<Symbol>)
%i[png jpeg webp].freeze
SCREENSHOT_EXTENSIONS =

File extensions mapped to a SCREENSHOT_FORMATS entry, so path: alone can pick the encoder.

Returns:

  • (Hash{String=>Symbol})
{
  ".png" => :png, ".jpg" => :jpeg, ".jpeg" => :jpeg, ".webp" => :webp
}.freeze
PDF_PAPER_SIZES =

Named paper sizes for #pdf, as [width, height] in inches.

Returns:

  • (Hash{Symbol=>Array<Float>})
{
  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.

Returns:

  • (Range)
(0.1..2.0)

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(browser, target_id:, session_id:) ⇒ Page

Returns a new instance of Page.

Parameters:

  • browser (Obxcura::Browser)

    the owning browser.

  • target_id (String)

    the CDP target id.

  • session_id (String)

    the CDP session attached to the target.



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

#clientString, ... (readonly)

Returns:

  • (String)

    the CDP target id backing this page.

  • (String)

    the CDP session id attached to the target.

  • (Obxcura::Client)

    the shared CDP transport.

  • (Obxcura::Frame)

    the page's main frame.



65
66
67
# File 'lib/obxcura/page.rb', line 65

def client
  @client
end

#frameString, ... (readonly)

Returns:

  • (String)

    the CDP target id backing this page.

  • (String)

    the CDP session id attached to the target.

  • (Obxcura::Client)

    the shared CDP transport.

  • (Obxcura::Frame)

    the page's main frame.



65
66
67
# File 'lib/obxcura/page.rb', line 65

def frame
  @frame
end

#session_idString, ... (readonly)

Returns:

  • (String)

    the CDP target id backing this page.

  • (String)

    the CDP session id attached to the target.

  • (Obxcura::Client)

    the shared CDP transport.

  • (Obxcura::Frame)

    the page's main frame.



65
66
67
# File 'lib/obxcura/page.rb', line 65

def session_id
  @session_id
end

#target_idString, ... (readonly)

Returns:

  • (String)

    the CDP target id backing this page.

  • (String)

    the CDP session id attached to the target.

  • (Obxcura::Client)

    the shared CDP transport.

  • (Obxcura::Frame)

    the page's main frame.



65
66
67
# File 'lib/obxcura/page.rb', line 65

def target_id
  @target_id
end

Instance Method Details

#closeObxcura::Page?

Close this page's target and stop listening for its events.

Returns:



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_connectionvoid

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.

Parameters:

  • method (String)

    the CDP method name.

  • params (Hash) (defaults to: {})

    the method parameters.

Returns:

  • (Hash)

    the command's result object.



188
189
190
# File 'lib/obxcura/page.rb', line 188

def command(method, params = {})
  @client.command(method, params, session_id: @session_id)
end

#cookiesObxcura::Cookies

The browser's cookies, seen from this page.

page.cookies.map { |c| c["name"] }   # what the current URL would send
page.cookies["session"]              # one of them by name
page.cookies.all                     # the whole connection's jar
page.cookies.set("token", "abc")
page.cookies.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.

Returns:



179
180
181
# File 'lib/obxcura/page.rb', line 179

def cookies
  @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.

Parameters:

  • url (String)

    the URL to navigate to.

Returns:

  • (self)


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

#headersObxcura::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.

Returns:



115
116
117
# File 'lib/obxcura/page.rb', line 115

def headers
  @headers ||= Headers.new(self)
end

#network_logArray<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.

Returns:

  • (Array<Hash>)

    a snapshot of the log, safe to iterate.



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.

Examples:

A4, backgrounds painted, no margins

page.pdf(path: "report.pdf", paper: :a4, print_background: true, margin: 0)

Parameters:

  • path (String, nil) (defaults to: nil)

    write the document here and return this path.

  • landscape (Boolean) (defaults to: false)

    swap the page box.

  • print_background (Boolean) (defaults to: false)

    paint backgrounds and images.

  • scale (Float, nil) (defaults to: nil)

    0.1..2.0. Smaller fits more per page.

  • paper (Symbol, String, nil) (defaults to: nil)

    a PDF_PAPER_SIZES name. Mutually exclusive with paper_width:/paper_height:.

  • paper_width (Float, nil) (defaults to: nil)

    page width in inches.

  • paper_height (Float, nil) (defaults to: nil)

    page height in inches.

  • margin (Numeric, Hash, nil) (defaults to: nil)

    inches — one number for all four sides, or top:/bottom:/left:/right:.

  • page_ranges (String, nil) (defaults to: nil)

    e.g. "1", "1-3", "1,4-5".

Returns:

  • (String)

    the PDF bytes, or path when one was given.

Raises:

  • (ArgumentError)

    on an unknown paper name, contradictory paper options, or a scale outside PDF_SCALE_RANGE — before any CDP call.

  • (Obxcura::Error)

    if the browser has no render feature.



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.

Parameters:

  • url (String)

    the URL to POST to.

  • payload (String)

    the raw request body.

  • content_type (String)

    the Content-Type header value.

  • headers (Hash{String=>String})

    extra request headers.

  • timeout (Integer, nil) (defaults to: nil)

    seconds to allow before giving up.

Returns:

  • (Hash)

    { "status" => Integer, "ok" => Boolean, "body" => String }.

Raises:



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, timeout_message(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, timeout_message(url)
end

#refreshHash Also known as: reload

Reload the page and block until it loads again. Aliased as reload.

Returns:

  • (Hash)

    the CDP Page.reload result.



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.

Examples:

Whole document, not just the viewport

page.screenshot(path: "full.png", full_page: true)

A region, at twice the pixel density

page.screenshot(clip: { x: 0, y: 0, width: 300, height: 200, scale: 2 })

Parameters:

  • path (String, nil) (defaults to: nil)

    write the image here and return this path.

  • format (Symbol, String, nil) (defaults to: nil)

    :png, :jpeg or :webp. Defaults to the format implied by path's extension, then to :png.

  • quality (Integer, nil) (defaults to: nil)

    0..100, :jpeg only. Rejected for :png, which ignores it, and for :webp, whose Obscura encoder is lossless and refuses the parameter outright.

  • full_page (Boolean) (defaults to: false)

    capture the whole document rather than the viewport. Mutually exclusive with clip.

  • clip (Hash, nil) (defaults to: nil)

    region to capture: x:, y:, width:, height: and an optional scale: (default 1).

Returns:

  • (String)

    the image bytes, or path when one was given.

Raises:

  • (ArgumentError)

    on an unsupported format or contradictory options, before any CDP round trip.

  • (Obxcura::Error)

    if the browser has no render feature.



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