obxcura 0.1.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 +7 -0
- data/CHANGELOG.md +31 -0
- data/LICENSE.txt +21 -0
- data/README.md +169 -0
- data/lib/obxcura/browser.rb +113 -0
- data/lib/obxcura/client.rb +209 -0
- data/lib/obxcura/frame/dom.rb +64 -0
- data/lib/obxcura/frame/runtime.rb +124 -0
- data/lib/obxcura/frame.rb +41 -0
- data/lib/obxcura/node.rb +36 -0
- data/lib/obxcura/page.rb +187 -0
- data/lib/obxcura/version.rb +8 -0
- data/lib/obxcura.rb +66 -0
- metadata +158 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: f2f8eab2a1520c5f96b85a50ddafee3b7938cd88014e1b288bb4d7a2839454ba
|
|
4
|
+
data.tar.gz: 2d81b467a60b702cfeea5d90792d184f2362f9f2bf96f9a26fdfce68929be12d
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 43e149575bda036ff64b42670f8b86147999142538cc89f459bace265ac3cef2f1feccbea1dda40adec6dbed151958d8ae8d2f40941f40155093226e8cf8232e
|
|
7
|
+
data.tar.gz: ea2770f6a9799e45ab8d1dbbb7c79a7b62c621305831a319f182d7cae3fdc2f257f0a49be55d1757e387065bc6e52ae4d596d0165599d76f501d8ae6ebcaec99
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
## [Unreleased]
|
|
2
|
+
|
|
3
|
+
## [0.1.1] - 2026-07-14
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- Corrected gemspec repository URLs (`homepage`, `source_code_uri`,
|
|
8
|
+
`changelog_uri`) to point at the actual GitHub repository.
|
|
9
|
+
|
|
10
|
+
## [0.1.0] - 2026-07-09
|
|
11
|
+
|
|
12
|
+
Initial release. A Ruby client that drives the Obscura headless browser over
|
|
13
|
+
the Chrome DevTools Protocol.
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- `Browser` — target lifecycle over a single WebSocket, cookie management
|
|
18
|
+
(`cookies`, `clear_cookies`), and `quit`.
|
|
19
|
+
- `Page` — navigation with load waiting, `evaluate`, DOM access, form filling,
|
|
20
|
+
JSON/form XHR POSTs, and network logging. Each page is a CDP target with its
|
|
21
|
+
own attached session, multiplexed over the shared socket.
|
|
22
|
+
- `Client` — WebSocket transport built directly on `websocket-driver`, with a
|
|
23
|
+
single reader thread routing command replies by `id` and events by
|
|
24
|
+
`sessionId`.
|
|
25
|
+
- `Frame` (with `Runtime` and `DOM` mixins) and `Node` helpers for scripting
|
|
26
|
+
and DOM queries.
|
|
27
|
+
- Chunked reads (`read_string`) so large-string getters like `Page#html`
|
|
28
|
+
work around Obscura's ~500–700 KB single-message ceiling.
|
|
29
|
+
- Optional `nokogiri` integration in `Page#dom`, lazily required.
|
|
30
|
+
- Integration test suite that boots one `obscura serve` plus a local WEBrick
|
|
31
|
+
site, tagged `:obscura` and skipped when no browser binary is available.
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Guillermo Moreno
|
|
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
|
|
13
|
+
all 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
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# Obxcura
|
|
2
|
+
|
|
3
|
+
A small Ruby client for the [Obscura](https://github.com/h4ckf0r0day/obscura)
|
|
4
|
+
headless browser, driven over the Chrome DevTools Protocol.
|
|
5
|
+
|
|
6
|
+
A `Browser` owns one WebSocket connection; each `Page` is a CDP target with its
|
|
7
|
+
own attached session. One connection, many pages.
|
|
8
|
+
|
|
9
|
+
> **Obxcura vs Obscura.** This gem is `Obxcura`. The browser it drives is
|
|
10
|
+
> `obscura` — a separate binary you run yourself. The `x` keeps them apart.
|
|
11
|
+
|
|
12
|
+
## Installation
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
bundle add obxcura
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
You also need the `obscura` binary on your `PATH`
|
|
19
|
+
([releases](https://github.com/h4ckf0r0day/obscura/releases)).
|
|
20
|
+
|
|
21
|
+
## Usage
|
|
22
|
+
|
|
23
|
+
Start the browser first (defaults to port 9222):
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
obscura serve
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Then:
|
|
30
|
+
|
|
31
|
+
```ruby
|
|
32
|
+
require "obxcura"
|
|
33
|
+
|
|
34
|
+
browser = Obxcura::Browser.new # or Obxcura.start
|
|
35
|
+
page = browser.go_to("https://example.com")
|
|
36
|
+
|
|
37
|
+
page.html # rendered DOM, post-JS
|
|
38
|
+
page.title # "Example Domain"
|
|
39
|
+
page.current_url # "https://example.com/"
|
|
40
|
+
page.evaluate("1 + 2") # => 3
|
|
41
|
+
|
|
42
|
+
browser.quit
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### Querying the DOM
|
|
46
|
+
|
|
47
|
+
`#at_css` / `#css` return live `Obxcura::Node` handles backed by the real DOM:
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
page.at_css("h1").text # => "Example Domain"
|
|
51
|
+
page.at_css("a")["href"] # attribute value, or nil
|
|
52
|
+
page.at_css("h1").outer_html # "<h1>Example Domain</h1>"
|
|
53
|
+
page.css("p").map(&:text) # every <p>'s text
|
|
54
|
+
page.at_css("#missing") # => nil
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Running JavaScript
|
|
58
|
+
|
|
59
|
+
Pass Ruby values as arguments — they cross into the page as real values
|
|
60
|
+
(`arguments[0]`, `arguments[1]`, …), never string-interpolated into source:
|
|
61
|
+
|
|
62
|
+
```ruby
|
|
63
|
+
page.evaluate("arguments[0] * 2", 21) # => 42
|
|
64
|
+
|
|
65
|
+
# `evaluate_func` calls a function declaration directly:
|
|
66
|
+
page.evaluate_func(<<~JS, "h1")
|
|
67
|
+
function(sel) { return document.querySelector(sel).textContent; }
|
|
68
|
+
JS
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### POSTing from inside the page
|
|
72
|
+
|
|
73
|
+
Obscura routes `XMLHttpRequest` but not `fetch`, so `#xhr_post` runs the POST
|
|
74
|
+
from the page's context (reusing its cookies). Values cross as arguments:
|
|
75
|
+
|
|
76
|
+
```ruby
|
|
77
|
+
result = page.xhr_post(
|
|
78
|
+
"https://example.com/api/login",
|
|
79
|
+
URI.encode_www_form(user: "me", pass: "secret"), # payload
|
|
80
|
+
"application/x-www-form-urlencoded", # content type
|
|
81
|
+
{ "X-Requested-With" => "XMLHttpRequest" } # headers
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
result["status"] # => 200
|
|
85
|
+
result["ok"] # => true (2xx)
|
|
86
|
+
result["body"] # response text
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Any HTTP reply (including 4xx/5xx) comes back as `{ "status", "ok", "body" }`.
|
|
90
|
+
A transport failure — the request never reached the server (CORS, the
|
|
91
|
+
private-network SSRF guard, mixed origin, a dead host) — raises
|
|
92
|
+
`Obxcura::ConnectionError`. If the server accepts the connection but never
|
|
93
|
+
answers, the wait ends with `Obxcura::TimeoutError`; pass `timeout:` (seconds)
|
|
94
|
+
to bound it. Some anti-bot endpoints tarpit non-stealth clients — try
|
|
95
|
+
`obscura serve --stealth`.
|
|
96
|
+
|
|
97
|
+
## API
|
|
98
|
+
|
|
99
|
+
`Obxcura.start(**opts)` is sugar for `Obxcura::Browser.new`.
|
|
100
|
+
|
|
101
|
+
- **`Obxcura::Browser`** — `.new(host:, port:, timeout:)`, `#create_page(url)`,
|
|
102
|
+
`#go_to`/`#goto`, `#targets`, `#version`, `#command`, `#close`/`#quit`.
|
|
103
|
+
Readers: `#client`, `#pages`, `#host`, `#port`.
|
|
104
|
+
- **`Obxcura::Page`** — `#goto`/`#go_to`, `#evaluate`, `#evaluate_func`,
|
|
105
|
+
`#html`/`#body`, `#title`, `#current_url`, `#at_css`, `#css`, `#xhr_post`,
|
|
106
|
+
`#cookies`, `#refresh`/`#reload`, `#command`, `#close`, `#close_connection`.
|
|
107
|
+
Readers: `#frame`, `#target_id`, `#session_id`, `#client`.
|
|
108
|
+
- **`Obxcura::Node`** (from `#at_css`/`#css`) — `#text`, `#[]` (attribute),
|
|
109
|
+
`#outer_html`, `#object_id`.
|
|
110
|
+
- **`Obxcura::Frame`** — the main frame behind a Page; carries the DOM/Runtime
|
|
111
|
+
methods Page delegates (`#evaluate`, `#at_css`, `#read_string`, …).
|
|
112
|
+
- **`Obxcura::Client`** — the CDP transport, if you need raw `#command`,
|
|
113
|
+
`#subscribe` / `#unsubscribe`, or `#close`.
|
|
114
|
+
|
|
115
|
+
Errors all descend from `Obxcura::Error`: `TimeoutError`, `ProtocolError`,
|
|
116
|
+
`ConnectionError`.
|
|
117
|
+
|
|
118
|
+
## Obscura's quirks (and how Obxcura handles them)
|
|
119
|
+
|
|
120
|
+
These are properties of the **Obscura browser**, not of this gem. They shape the
|
|
121
|
+
API, so they're worth knowing:
|
|
122
|
+
|
|
123
|
+
- **No paint engine.** There is no screenshot API, and there never will be one
|
|
124
|
+
here. `Page.captureScreenshot` is unusable.
|
|
125
|
+
- **~500–700 KB message ceiling.** Obscura won't send a single CDP message
|
|
126
|
+
larger than that. `Page#html` works around it by snapshotting `outerHTML` into
|
|
127
|
+
a page global and pulling it back in 400 KB slices. Don't return giant values
|
|
128
|
+
from `#evaluate` directly.
|
|
129
|
+
- **DOM nodes don't serialize.** A node returned by value comes back as an
|
|
130
|
+
internal stub, so `#at_css` / `#css` resolve it (via `DOM.resolveNode`) to a
|
|
131
|
+
live handle and read it with `Runtime.callFunctionOn`.
|
|
132
|
+
- **XHR, not fetch.** Obscura routes `XMLHttpRequest` but not `fetch`, so
|
|
133
|
+
`#xhr_post` uses XHR from the page context. It also ignores
|
|
134
|
+
`XMLHttpRequest#timeout`, so the effective bound is the CDP-level `timeout:`.
|
|
135
|
+
- **Persistent cookies.** `obscura serve` is long-lived and its cookie jar
|
|
136
|
+
survives `#quit` (that only drops the WebSocket). Read them with
|
|
137
|
+
`page.cookies`; closing a page clears the browser's cookie jar.
|
|
138
|
+
- **Private networks are blocked by default.** To drive a local site, start the
|
|
139
|
+
browser with `obscura serve --allow-private-network`.
|
|
140
|
+
|
|
141
|
+
The transport is built directly on `websocket-driver` rather than
|
|
142
|
+
`websocket-client-simple`, which reads a byte at a time and wedges on large
|
|
143
|
+
frames.
|
|
144
|
+
|
|
145
|
+
## Development
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
bin/setup
|
|
149
|
+
bundle exec rake # rubocop + rspec
|
|
150
|
+
bundle exec rake doc # YARD docs into doc/
|
|
151
|
+
bin/console # IRB with the gem loaded
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
The integration specs boot a real `obscura serve` against a local WEBrick test
|
|
155
|
+
site. Point them at your binary:
|
|
156
|
+
|
|
157
|
+
```bash
|
|
158
|
+
OBSCURA_BIN=/path/to/obscura bundle exec rspec
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Without the binary those specs skip cleanly, so `bundle exec rspec` still passes.
|
|
162
|
+
|
|
163
|
+
## Contributing
|
|
164
|
+
|
|
165
|
+
Bug reports and pull requests are welcome at https://github.com/memoxmrdl/obxcura.
|
|
166
|
+
|
|
167
|
+
## License
|
|
168
|
+
|
|
169
|
+
MIT. See [LICENSE.txt](LICENSE.txt).
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "forwardable"
|
|
4
|
+
|
|
5
|
+
module Obxcura
|
|
6
|
+
# The entry point. Owns one Client (one connection) and the pages opened on it.
|
|
7
|
+
#
|
|
8
|
+
# browser = Obxcura::Browser.new
|
|
9
|
+
# page = browser.create_page
|
|
10
|
+
# browser.quit
|
|
11
|
+
#
|
|
12
|
+
# Assumes a running `obscura serve` (default 127.0.0.1:9222). Pass host:/port:
|
|
13
|
+
# to point elsewhere.
|
|
14
|
+
class Browser
|
|
15
|
+
extend Forwardable
|
|
16
|
+
|
|
17
|
+
# @return [String] default host for `obscura serve`.
|
|
18
|
+
DEFAULT_HOST = "127.0.0.1"
|
|
19
|
+
# @return [Integer] default CDP port for `obscura serve`.
|
|
20
|
+
DEFAULT_PORT = 9222
|
|
21
|
+
|
|
22
|
+
# @return [Obxcura::Client] the underlying CDP transport.
|
|
23
|
+
# @return [Array<Obxcura::Page>] the pages currently open.
|
|
24
|
+
# @return [String] the browser host.
|
|
25
|
+
# @return [Integer] the browser port.
|
|
26
|
+
attr_reader :client, :pages, :host, :port
|
|
27
|
+
|
|
28
|
+
delegate %i[command] => :client
|
|
29
|
+
|
|
30
|
+
# Connect to a running `obscura serve`.
|
|
31
|
+
#
|
|
32
|
+
# @param host [String] host the browser listens on.
|
|
33
|
+
# @param port [Integer] CDP port the browser listens on.
|
|
34
|
+
# @param timeout [Integer] default seconds to wait for CDP replies.
|
|
35
|
+
# @raise [Obxcura::ConnectionError] if the browser can't be reached.
|
|
36
|
+
def initialize(host: DEFAULT_HOST, port: DEFAULT_PORT, timeout: Client::DEFAULT_TIMEOUT)
|
|
37
|
+
@host = host
|
|
38
|
+
@port = port
|
|
39
|
+
@timeout = timeout
|
|
40
|
+
@pages = []
|
|
41
|
+
@client = Client.new(browser_ws_url, timeout: @timeout)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Open a fresh page (a CDP target) and attach to it.
|
|
45
|
+
#
|
|
46
|
+
# @param url [String] URL to open the target at (defaults to a blank page).
|
|
47
|
+
# @return [Obxcura::Page] the new, tracked page.
|
|
48
|
+
def create_page(url = "about:blank")
|
|
49
|
+
target_id = command("Target.createTarget", { url: url })["targetId"]
|
|
50
|
+
session_id = command("Target.attachToTarget", { targetId: target_id, flatten: true })["sessionId"]
|
|
51
|
+
|
|
52
|
+
page = Page.new(self, target_id: target_id, session_id: session_id)
|
|
53
|
+
@pages << page
|
|
54
|
+
page
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Open a page and navigate to `url` in one call.
|
|
58
|
+
#
|
|
59
|
+
# Navigates from a blank target rather than passing the URL straight to
|
|
60
|
+
# Target.createTarget: creating two URL-loaded targets and then evaluating
|
|
61
|
+
# crashes `obscura serve` (connection closed: end of file reached).
|
|
62
|
+
#
|
|
63
|
+
# @param url [String] URL to navigate to.
|
|
64
|
+
# @return [Obxcura::Page] the navigated page (load event fired).
|
|
65
|
+
def go_to(url)
|
|
66
|
+
create_page.goto(url)
|
|
67
|
+
end
|
|
68
|
+
alias goto go_to
|
|
69
|
+
|
|
70
|
+
# Every target the browser knows about (pages, workers, ...).
|
|
71
|
+
#
|
|
72
|
+
# @return [Array<Hash>] raw CDP `TargetInfo` hashes.
|
|
73
|
+
def targets
|
|
74
|
+
command("Target.getTargets")["targetInfos"]
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# The browser's `/json/version` metadata (product, protocol, ws endpoint).
|
|
78
|
+
#
|
|
79
|
+
# @return [Hash] the decoded JSON version document.
|
|
80
|
+
def version
|
|
81
|
+
uri = URI("http://#{@host}:#{@port}/json/version")
|
|
82
|
+
JSON.parse(Net::HTTP.get(uri))
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Stop tracking a page. Called by {Obxcura::Page#close}.
|
|
86
|
+
#
|
|
87
|
+
# @param page [Obxcura::Page] the page to forget.
|
|
88
|
+
# @return [Obxcura::Page, nil] the removed page, or nil if unknown.
|
|
89
|
+
def remove_page(page)
|
|
90
|
+
@pages.delete(page)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Close every page, then drop the connection. Aliased as `quit`.
|
|
94
|
+
#
|
|
95
|
+
# @return [void]
|
|
96
|
+
def close
|
|
97
|
+
@pages.dup.each(&:close)
|
|
98
|
+
client.close
|
|
99
|
+
end
|
|
100
|
+
alias quit close
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
def browser_ws_url
|
|
105
|
+
info = version
|
|
106
|
+
info["webSocketDebuggerUrl"] ||
|
|
107
|
+
raise(ConnectionError, "No browser endpoint at #{@host}:#{@port}")
|
|
108
|
+
rescue SystemCallError, SocketError => e
|
|
109
|
+
raise ConnectionError,
|
|
110
|
+
"Could not reach Obscura at #{@host}:#{@port} (#{e.message}). Is it running? Start it with: obscura serve"
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "socket"
|
|
4
|
+
require "openssl"
|
|
5
|
+
require "websocket/driver"
|
|
6
|
+
|
|
7
|
+
module Obxcura
|
|
8
|
+
# The CDP transport: a single WebSocket to the browser endpoint.
|
|
9
|
+
#
|
|
10
|
+
# Built directly on websocket-driver, because the higher-level
|
|
11
|
+
# websocket-client-simple reads the socket one byte at a time (`@socket.getc`)
|
|
12
|
+
# — a ~1MB CDP frame there takes seconds and can wedge the read loop. Here a
|
|
13
|
+
# single reader thread pumps raw bytes through the driver (`readpartial` +
|
|
14
|
+
# `driver.parse`), which parses frames efficiently.
|
|
15
|
+
#
|
|
16
|
+
# This object *is* the driver's I/O adapter: websocket-driver calls #url for the
|
|
17
|
+
# handshake and #write to put bytes on the wire.
|
|
18
|
+
#
|
|
19
|
+
# Everything on the wire is multiplexed over this one socket. Command replies
|
|
20
|
+
# come back keyed by the `id` we assigned (globally unique across the
|
|
21
|
+
# connection), so we resolve those regardless of session. Events instead carry
|
|
22
|
+
# a `sessionId`, so we fan them out to whichever Page subscribed for that
|
|
23
|
+
# session. Browser-level events (no sessionId) go to the `:browser` slot.
|
|
24
|
+
class Client
|
|
25
|
+
# @return [Integer] default seconds to wait for a command reply.
|
|
26
|
+
DEFAULT_TIMEOUT = 30
|
|
27
|
+
|
|
28
|
+
# Obscura frames stay well under this; it's just a guard against a runaway
|
|
29
|
+
# allocation — a sane cap on the receive size.
|
|
30
|
+
#
|
|
31
|
+
# @return [Integer] largest CDP frame (bytes) the driver will assemble.
|
|
32
|
+
MAX_MESSAGE_SIZE = 64 * 1024 * 1024
|
|
33
|
+
|
|
34
|
+
# Bytes pulled per read syscall. Small is fine — the driver reassembles
|
|
35
|
+
# frames across reads; this only bounds how much we buffer at once.
|
|
36
|
+
#
|
|
37
|
+
# @return [Integer]
|
|
38
|
+
READ_CHUNK = 512
|
|
39
|
+
|
|
40
|
+
# @return [String] the WebSocket URL of the browser endpoint.
|
|
41
|
+
attr_reader :url
|
|
42
|
+
|
|
43
|
+
# Open the WebSocket to the browser and block until the handshake completes.
|
|
44
|
+
#
|
|
45
|
+
# @param url [String] the `ws://`/`wss://` browser endpoint.
|
|
46
|
+
# @param timeout [Integer] default seconds to wait for command replies.
|
|
47
|
+
# @raise [Obxcura::TimeoutError] if the connection can't be established.
|
|
48
|
+
def initialize(url, timeout: DEFAULT_TIMEOUT)
|
|
49
|
+
@url = url
|
|
50
|
+
@timeout = timeout
|
|
51
|
+
@command_id = 0
|
|
52
|
+
@mutex = Mutex.new
|
|
53
|
+
@write_mutex = Mutex.new
|
|
54
|
+
@pending = {}
|
|
55
|
+
@subscribers = {}
|
|
56
|
+
@open = Queue.new
|
|
57
|
+
connect
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Send a CDP command and block until its reply arrives.
|
|
61
|
+
#
|
|
62
|
+
# @param method [String] the CDP method name, e.g. "Runtime.evaluate".
|
|
63
|
+
# @param params [Hash] the method parameters.
|
|
64
|
+
# @param session_id [String, nil] nil sends a browser-level command (target
|
|
65
|
+
# management); a session id routes it to a specific page.
|
|
66
|
+
# @param timeout [Integer] seconds to wait for the reply.
|
|
67
|
+
# @return [Hash] the command's `result` object.
|
|
68
|
+
# @raise [Obxcura::TimeoutError] if no reply arrives in time.
|
|
69
|
+
# @raise [Obxcura::ProtocolError] if the browser returns an error.
|
|
70
|
+
def command(method, params = {}, session_id: nil, timeout: @timeout)
|
|
71
|
+
id = next_id
|
|
72
|
+
queue = Queue.new
|
|
73
|
+
@mutex.synchronize { @pending[id] = queue }
|
|
74
|
+
|
|
75
|
+
frame = { id: id, method: method, params: params }
|
|
76
|
+
frame[:sessionId] = session_id if session_id
|
|
77
|
+
@driver.text(JSON.generate(frame))
|
|
78
|
+
|
|
79
|
+
message = Timeout.timeout(timeout, TimeoutError, "#{method} timed out after #{timeout}s") { queue.pop }
|
|
80
|
+
raise ProtocolError, message["error"]["message"] if message["error"]
|
|
81
|
+
|
|
82
|
+
message["result"]
|
|
83
|
+
ensure
|
|
84
|
+
@mutex.synchronize { @pending.delete(id) }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Register a handler for events on a given session.
|
|
88
|
+
#
|
|
89
|
+
# @param session_id [String, Symbol] the page session id, or `:browser` for
|
|
90
|
+
# target-level (session-less) events.
|
|
91
|
+
# @yieldparam method [String] the CDP event name.
|
|
92
|
+
# @yieldparam params [Hash] the event parameters.
|
|
93
|
+
# @return [Proc] the stored handler.
|
|
94
|
+
def subscribe(session_id, &block)
|
|
95
|
+
@subscribers[session_id] = block
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Remove the handler previously registered for a session.
|
|
99
|
+
#
|
|
100
|
+
# @param session_id [String, Symbol] the session to stop listening on.
|
|
101
|
+
# @return [Proc, nil] the removed handler, if any.
|
|
102
|
+
def unsubscribe(session_id)
|
|
103
|
+
@subscribers.delete(session_id)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Close the driver, socket and reader thread. Safe to call more than once.
|
|
107
|
+
#
|
|
108
|
+
# @return [void]
|
|
109
|
+
def close
|
|
110
|
+
@closing = true
|
|
111
|
+
@driver&.close
|
|
112
|
+
@socket&.close
|
|
113
|
+
@reader&.kill
|
|
114
|
+
rescue IOError, SystemCallError
|
|
115
|
+
nil
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# @return [Boolean] whether the connection is being (or has been) torn down.
|
|
119
|
+
def closing?
|
|
120
|
+
@closing == true
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# I/O adapter for websocket-driver: puts outgoing bytes on the wire. Writes
|
|
124
|
+
# are serialized so command threads and the driver's own control frames
|
|
125
|
+
# (ping/pong/close) never interleave and corrupt the stream.
|
|
126
|
+
#
|
|
127
|
+
# @param data [String] raw bytes to write.
|
|
128
|
+
# @return [void]
|
|
129
|
+
def write(data)
|
|
130
|
+
@write_mutex.synchronize { @socket.write(data) }
|
|
131
|
+
rescue IOError, SystemCallError => e
|
|
132
|
+
warn "[Obxcura] websocket write error: #{e}" unless closing?
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
private
|
|
136
|
+
|
|
137
|
+
def connect
|
|
138
|
+
@socket = open_socket
|
|
139
|
+
@driver = ::WebSocket::Driver.client(self, max_length: MAX_MESSAGE_SIZE)
|
|
140
|
+
|
|
141
|
+
@driver.on(:open) { @open.push(true) }
|
|
142
|
+
@driver.on(:message) { |event| handle_message(event.data) }
|
|
143
|
+
@driver.on(:close) { |event| handle_disconnect(event) }
|
|
144
|
+
@driver.on(:error) { |event| warn "[Obxcura] websocket error: #{event.message}" unless closing? }
|
|
145
|
+
|
|
146
|
+
start_reader
|
|
147
|
+
@driver.start
|
|
148
|
+
|
|
149
|
+
Timeout.timeout(@timeout, TimeoutError, "Could not connect to #{@url}") { @open.pop }
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def open_socket
|
|
153
|
+
uri = URI(@url)
|
|
154
|
+
tcp = TCPSocket.new(uri.host, uri.port || (uri.scheme == "wss" ? 443 : 80))
|
|
155
|
+
return tcp unless uri.scheme == "wss"
|
|
156
|
+
|
|
157
|
+
ssl = OpenSSL::SSL::SSLSocket.new(tcp, OpenSSL::SSL::SSLContext.new)
|
|
158
|
+
ssl.sync_close = true
|
|
159
|
+
ssl.hostname = uri.host
|
|
160
|
+
ssl.connect
|
|
161
|
+
ssl
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# One reader thread feeds raw bytes to the driver, which emits :message as
|
|
165
|
+
# frames complete. A raised parse is logged (not fatal); a dead socket ends
|
|
166
|
+
# the loop and wakes any in-flight commands so they don't hang to timeout.
|
|
167
|
+
def start_reader
|
|
168
|
+
@reader = Thread.new do
|
|
169
|
+
until closing?
|
|
170
|
+
begin
|
|
171
|
+
@driver.parse(@socket.readpartial(READ_CHUNK))
|
|
172
|
+
rescue EOFError, IOError, SystemCallError => e
|
|
173
|
+
handle_disconnect(e) unless closing?
|
|
174
|
+
break
|
|
175
|
+
rescue StandardError => e
|
|
176
|
+
warn "[Obxcura] reader error: #{e}" unless closing?
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def handle_message(data)
|
|
183
|
+
message = JSON.parse(data)
|
|
184
|
+
|
|
185
|
+
if message["id"]
|
|
186
|
+
queue = @mutex.synchronize { @pending[message["id"]] }
|
|
187
|
+
queue&.push(message)
|
|
188
|
+
elsif message["method"]
|
|
189
|
+
handler = @subscribers[message["sessionId"] || :browser]
|
|
190
|
+
handler&.call(message["method"], message["params"])
|
|
191
|
+
end
|
|
192
|
+
rescue JSON::ParserError => e
|
|
193
|
+
warn "[Obxcura] dropped unparseable message: #{e}" unless closing?
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# The socket dropped (or the browser closed us). Fail every waiting command
|
|
197
|
+
# so callers get a prompt error instead of a 30s timeout.
|
|
198
|
+
def handle_disconnect(reason)
|
|
199
|
+
description = reason.respond_to?(:message) ? reason.message : reason.to_s
|
|
200
|
+
@mutex.synchronize do
|
|
201
|
+
@pending.each_value { |queue| queue.push({ "error" => { "message" => "connection closed: #{description}" } }) }
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def next_id
|
|
206
|
+
@mutex.synchronize { @command_id += 1 }
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Obxcura
|
|
4
|
+
class Frame
|
|
5
|
+
# DOM: high-level reads of the rendered page, all built on Runtime.
|
|
6
|
+
#
|
|
7
|
+
# Selector queries return live Nodes. Obscura serializes a DOM node over
|
|
8
|
+
# returnByValue as an internal stub carrying an `_nid` — useless on its own,
|
|
9
|
+
# but the `_nid` resolves (DOM.resolveNode) to a real remote object handle we
|
|
10
|
+
# wrap in a Node and drive with Runtime#call_on.
|
|
11
|
+
module DOM
|
|
12
|
+
# @return [String] the current top-window URL.
|
|
13
|
+
def current_url
|
|
14
|
+
evaluate("window.location.href")
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# @return [String] the current document title.
|
|
18
|
+
def title
|
|
19
|
+
evaluate("document.title")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# The live, post-JS HTML. Retrieved in chunks (see {Runtime#read_string})
|
|
23
|
+
# because a full page's outerHTML routinely exceeds Obscura's message limit.
|
|
24
|
+
# Aliased as `html`.
|
|
25
|
+
#
|
|
26
|
+
# @return [String] the rendered document's outer HTML.
|
|
27
|
+
def body
|
|
28
|
+
read_string("document.documentElement.outerHTML")
|
|
29
|
+
end
|
|
30
|
+
alias_method :html, :body
|
|
31
|
+
|
|
32
|
+
# First element matching a CSS selector.
|
|
33
|
+
#
|
|
34
|
+
# @param selector [String] a CSS selector.
|
|
35
|
+
# @return [Obxcura::Node, nil] the matching node, or nil if none.
|
|
36
|
+
def at_css(selector)
|
|
37
|
+
to_node(evaluate_func("function(s) { return document.querySelector(s); }", selector))
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# All elements matching a CSS selector.
|
|
41
|
+
#
|
|
42
|
+
# @param selector [String] a CSS selector.
|
|
43
|
+
# @return [Array<Obxcura::Node>] the matching nodes (empty if none).
|
|
44
|
+
def css(selector)
|
|
45
|
+
Array(evaluate_func("function(s) { return Array.from(document.querySelectorAll(s)); }", selector))
|
|
46
|
+
.filter_map { |stub| to_node(stub) }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
# Resolve Obscura's serialized node stub ({"_nid"=>N,...}) into a Node.
|
|
52
|
+
#
|
|
53
|
+
# @param stub [Hash, nil] the serialized node from a querySelector call.
|
|
54
|
+
# @return [Obxcura::Node, nil] a live node handle, or nil for a blank match.
|
|
55
|
+
def to_node(stub)
|
|
56
|
+
return nil unless stub.is_a?(Hash) && stub["_nid"]
|
|
57
|
+
|
|
58
|
+
object_id = command("DOM.resolveNode", { nodeId: stub["_nid"] }).dig("object", "objectId")
|
|
59
|
+
|
|
60
|
+
Node.new(self, object_id)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Obxcura
|
|
4
|
+
class Frame
|
|
5
|
+
# Runtime: executing JavaScript in the frame's context. This is the side that
|
|
6
|
+
# reaches the Client and puts CDP commands on the wire; everything in DOM is
|
|
7
|
+
# built on top of #evaluate.
|
|
8
|
+
#
|
|
9
|
+
# Ruby values cross into JS as real arguments (Runtime.callFunctionOn), not by
|
|
10
|
+
# being string-interpolated into source. Node-handle resolution, cyclic-node
|
|
11
|
+
# detection and a retry loop are deliberately left out.
|
|
12
|
+
module Runtime
|
|
13
|
+
# Obscura won't send a single CDP message larger than ~500-700KB, so we pull
|
|
14
|
+
# large strings back in slices this size and stitch them together. Well under
|
|
15
|
+
# the ceiling, and each slice is fast over the websocket-driver transport.
|
|
16
|
+
#
|
|
17
|
+
# @return [Integer] slice size (bytes) used by {#read_string}.
|
|
18
|
+
EVALUATE_CHUNK = 400_000
|
|
19
|
+
|
|
20
|
+
# Evaluate a JS expression and return its value (awaits promises).
|
|
21
|
+
#
|
|
22
|
+
# Extra args are passed to the page as real values, reachable in the
|
|
23
|
+
# expression as `arguments[0]`, `arguments[1]`, ... Without args it's a
|
|
24
|
+
# single Runtime.evaluate (the hot path); with args it goes through
|
|
25
|
+
# Runtime.callFunctionOn so nothing is interpolated into source.
|
|
26
|
+
#
|
|
27
|
+
# @param expression [String] the JS expression to evaluate.
|
|
28
|
+
# @param args [Array] values passed to the page as `arguments[...]`.
|
|
29
|
+
# @return [Object, nil] the evaluated value (JSON-decoded).
|
|
30
|
+
# @raise [Obxcura::ProtocolError] if the JS throws.
|
|
31
|
+
# @example
|
|
32
|
+
# frame.evaluate("arguments[0] + arguments[1]", 2, 3) # => 5
|
|
33
|
+
def evaluate(expression, *args)
|
|
34
|
+
return handle_result(evaluate_command(expression)) if args.empty?
|
|
35
|
+
|
|
36
|
+
evaluate_func("function() { return (#{expression}); }", *args)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Call a JS function declaration with the given arguments, bound to the live
|
|
40
|
+
# global object. Unlike {#evaluate}, `expression` is the function itself.
|
|
41
|
+
#
|
|
42
|
+
# @param expression [String] a JS function declaration.
|
|
43
|
+
# @param args [Array] values passed to the function.
|
|
44
|
+
# @param timeout [Integer, nil] override for the client's reply timeout.
|
|
45
|
+
# @return [Object, nil] the function's return value (JSON-decoded).
|
|
46
|
+
# @raise [Obxcura::ProtocolError] if the JS throws.
|
|
47
|
+
# @example
|
|
48
|
+
# frame.evaluate_func("function(sel){ return document.querySelector(sel) }", "#id")
|
|
49
|
+
def evaluate_func(expression, *args, timeout: nil)
|
|
50
|
+
call_on(global_object_id, expression, args, timeout: timeout)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Call a function declaration bound to a specific remote object (its `this`).
|
|
54
|
+
# Used by {#evaluate_func} (bound to globalThis) and by {Node} reads (bound
|
|
55
|
+
# to the node's handle).
|
|
56
|
+
#
|
|
57
|
+
# @param object_id [String] the CDP objectId to bind as `this`.
|
|
58
|
+
# @param function_declaration [String] a JS function declaration.
|
|
59
|
+
# @param args [Array] values passed to the function.
|
|
60
|
+
# @param timeout [Integer, nil] override for the client's reply timeout,
|
|
61
|
+
# e.g. for a call that awaits a slow in-page POST.
|
|
62
|
+
# @return [Object, nil] the function's return value (JSON-decoded).
|
|
63
|
+
# @raise [Obxcura::ProtocolError] if the JS throws.
|
|
64
|
+
def call_on(object_id, function_declaration, args = [], timeout: nil)
|
|
65
|
+
handle_result(command(
|
|
66
|
+
"Runtime.callFunctionOn",
|
|
67
|
+
{
|
|
68
|
+
functionDeclaration: function_declaration,
|
|
69
|
+
objectId: object_id,
|
|
70
|
+
arguments: args.map { |value| { value: value } },
|
|
71
|
+
awaitPromise: true,
|
|
72
|
+
returnByValue: true
|
|
73
|
+
},
|
|
74
|
+
timeout: timeout
|
|
75
|
+
))
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Pull a possibly-large JS string back in {EVALUATE_CHUNK}-sized slices. The
|
|
79
|
+
# expression is snapshotted into a page global once, so it's evaluated a
|
|
80
|
+
# single time no matter how big the result is; we then slice that global.
|
|
81
|
+
#
|
|
82
|
+
# @param js_expression [String] a JS expression producing a string.
|
|
83
|
+
# @return [String] the full string, reassembled from slices.
|
|
84
|
+
def read_string(js_expression)
|
|
85
|
+
length = evaluate("(window.__obxcura_read = String(#{js_expression})).length").to_i
|
|
86
|
+
return "" if length.zero?
|
|
87
|
+
|
|
88
|
+
buffer = String.new(capacity: length)
|
|
89
|
+
offset = 0
|
|
90
|
+
while offset < length
|
|
91
|
+
buffer << evaluate("window.__obxcura_read.slice(#{offset}, #{offset + EVALUATE_CHUNK})")
|
|
92
|
+
offset += EVALUATE_CHUNK
|
|
93
|
+
end
|
|
94
|
+
buffer
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
# objectId of the live global object, fetched fresh so it can't go stale
|
|
100
|
+
# across navigations — no execution-context tracking needed.
|
|
101
|
+
def global_object_id
|
|
102
|
+
evaluate_command("globalThis", by_value: false).dig("result", "objectId")
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def evaluate_command(expression, by_value: true)
|
|
106
|
+
command("Runtime.evaluate", { expression: expression, awaitPromise: true, returnByValue: by_value })
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def handle_result(result)
|
|
110
|
+
if result["exceptionDetails"]
|
|
111
|
+
raise ProtocolError, result.dig("exceptionDetails", "text") || "JS execution error"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
result.dig("result", "value")
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def command(method, params = {}, timeout: nil)
|
|
118
|
+
options = { session_id: page.session_id }
|
|
119
|
+
options[:timeout] = timeout if timeout
|
|
120
|
+
page.client.command(method, params, **options)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "frame/runtime"
|
|
4
|
+
require_relative "frame/dom"
|
|
5
|
+
|
|
6
|
+
module Obxcura
|
|
7
|
+
# A browsing context inside a Page. It ties together Runtime (executing JS via
|
|
8
|
+
# the Client) and DOM (reads built on that), and reaches the transport through
|
|
9
|
+
# its owning Page — so a Frame never touches the Client directly except via
|
|
10
|
+
# `page.client`.
|
|
11
|
+
#
|
|
12
|
+
# Obxcura drives a single main frame per page today (id == the page's
|
|
13
|
+
# target_id); there is deliberately no iframe tree.
|
|
14
|
+
class Frame
|
|
15
|
+
include Runtime
|
|
16
|
+
include DOM
|
|
17
|
+
|
|
18
|
+
# @return [String] the frame's unique id (the page's CDP target_id).
|
|
19
|
+
attr_accessor :id
|
|
20
|
+
|
|
21
|
+
# @return [Obxcura::Page] the page that owns this frame.
|
|
22
|
+
attr_reader :page
|
|
23
|
+
|
|
24
|
+
# @param id [String] the frame id (its page's target_id).
|
|
25
|
+
# @param page [Obxcura::Page] the owning page, used to reach the Client.
|
|
26
|
+
def initialize(id, page)
|
|
27
|
+
@id = id
|
|
28
|
+
@page = page
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @return [String] the frame's current URL.
|
|
32
|
+
def url
|
|
33
|
+
evaluate("window.location.href")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @return [String] the frame's current document title.
|
|
37
|
+
def title
|
|
38
|
+
evaluate("document.title")
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
data/lib/obxcura/node.rb
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Obxcura
|
|
4
|
+
# A live handle to a DOM node inside a Frame. Backed by a CDP remote object, so
|
|
5
|
+
# reads run as function calls bound to that object and reflect the current DOM.
|
|
6
|
+
# Returned by Frame::DOM#at_css / #css.
|
|
7
|
+
class Node
|
|
8
|
+
# @return [String] the CDP remote objectId backing this node.
|
|
9
|
+
attr_reader :object_id
|
|
10
|
+
|
|
11
|
+
# @param frame [Obxcura::Frame] the frame the node lives in.
|
|
12
|
+
# @param object_id [String] the CDP objectId of the node.
|
|
13
|
+
def initialize(frame, object_id)
|
|
14
|
+
@frame = frame
|
|
15
|
+
@object_id = object_id
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# @return [String] the visible text of the node and its descendants.
|
|
19
|
+
def text
|
|
20
|
+
@frame.call_on(@object_id, "function() { return this.textContent; }")
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Read an attribute value.
|
|
24
|
+
#
|
|
25
|
+
# @param name [String] the attribute name.
|
|
26
|
+
# @return [String, nil] the attribute value, or nil if absent.
|
|
27
|
+
def [](name)
|
|
28
|
+
@frame.call_on(@object_id, "function(name) { return this.getAttribute(name); }", [ name ])
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @return [String] the node's serialized outer HTML.
|
|
32
|
+
def outer_html
|
|
33
|
+
@frame.call_on(@object_id, "function() { return this.outerHTML; }")
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
data/lib/obxcura/page.rb
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "forwardable"
|
|
4
|
+
|
|
5
|
+
module Obxcura
|
|
6
|
+
# A single page/tab: a CDP target with its own attached session. Created via
|
|
7
|
+
# {Obxcura::Browser#create_page} — you rarely instantiate this directly.
|
|
8
|
+
#
|
|
9
|
+
# Page owns the CDP session, navigation, in-page POSTs and lifecycle. Reading
|
|
10
|
+
# the DOM and executing JS belong to its main {Frame} (see {Frame::DOM} and
|
|
11
|
+
# {Frame::Runtime}), and Page delegates those (`#evaluate`, `#html`, `#title`,
|
|
12
|
+
# `#at_css`, ...).
|
|
13
|
+
#
|
|
14
|
+
# Obscura has no paint engine, so there is deliberately no screenshot API.
|
|
15
|
+
#
|
|
16
|
+
# @example
|
|
17
|
+
# page = browser.create_page
|
|
18
|
+
# page.goto("https://example.com")
|
|
19
|
+
# page.html # rendered DOM after JS
|
|
20
|
+
# page.title # "Example Domain"
|
|
21
|
+
# page.at_css("h1").text # => "Example Domain"
|
|
22
|
+
# page.close
|
|
23
|
+
class Page
|
|
24
|
+
extend Forwardable
|
|
25
|
+
|
|
26
|
+
# @return [String] the CDP target id backing this page.
|
|
27
|
+
# @return [String] the CDP session id attached to the target.
|
|
28
|
+
# @return [Obxcura::Client] the shared CDP transport.
|
|
29
|
+
# @return [Obxcura::Frame] the page's main frame.
|
|
30
|
+
attr_reader :target_id, :session_id, :client, :frame
|
|
31
|
+
|
|
32
|
+
# Delegated to the main {Frame}: `#evaluate`, `#evaluate_func`,
|
|
33
|
+
# `#current_url`, `#title`, `#html`, `#body`, `#at_css`, `#css`.
|
|
34
|
+
def_delegators :frame, :evaluate, :evaluate_func, :current_url, :title, :html, :body, :at_css, :css
|
|
35
|
+
|
|
36
|
+
# @param browser [Obxcura::Browser] the owning browser.
|
|
37
|
+
# @param target_id [String] the CDP target id.
|
|
38
|
+
# @param session_id [String] the CDP session attached to the target.
|
|
39
|
+
def initialize(browser, target_id:, session_id:)
|
|
40
|
+
@browser = browser
|
|
41
|
+
@client = browser.client
|
|
42
|
+
@target_id = target_id
|
|
43
|
+
@session_id = session_id
|
|
44
|
+
@frame = Frame.new(target_id, self)
|
|
45
|
+
@load_queue = Queue.new
|
|
46
|
+
@network_log = []
|
|
47
|
+
@network_mutex = Mutex.new
|
|
48
|
+
|
|
49
|
+
@client.subscribe(@session_id) { |method, params| dispatch_event(method, params) }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Navigate to `url` and block until the page's load event fires. Aliased as
|
|
53
|
+
# `go_to`.
|
|
54
|
+
#
|
|
55
|
+
# @param url [String] the URL to navigate to.
|
|
56
|
+
# @return [self]
|
|
57
|
+
def goto(url)
|
|
58
|
+
@load_queue = Queue.new
|
|
59
|
+
command("Page.navigate", url: url)
|
|
60
|
+
wait_for_load
|
|
61
|
+
self
|
|
62
|
+
end
|
|
63
|
+
alias_method :go_to, :goto
|
|
64
|
+
|
|
65
|
+
# Close this page's target and stop listening for its events.
|
|
66
|
+
#
|
|
67
|
+
# @return [Obxcura::Page, nil] the page, removed from the browser.
|
|
68
|
+
def close
|
|
69
|
+
@client.unsubscribe(@session_id)
|
|
70
|
+
@client.command("Network.clearBrowserCookies", { targetId: @target_id })
|
|
71
|
+
@client.command("Target.closeTarget", { targetId: @target_id })
|
|
72
|
+
rescue ProtocolError
|
|
73
|
+
# Target already gone — nothing to do.
|
|
74
|
+
ensure
|
|
75
|
+
@browser.remove_page(self)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Drop the underlying WebSocket connection (affects the whole browser).
|
|
79
|
+
#
|
|
80
|
+
# @return [void]
|
|
81
|
+
def close_connection
|
|
82
|
+
@client.close
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Reload the page and block until it loads again. Aliased as `reload`.
|
|
86
|
+
#
|
|
87
|
+
# @return [Hash] the CDP `Page.reload` result.
|
|
88
|
+
def refresh
|
|
89
|
+
command("Page.reload")
|
|
90
|
+
wait_for_load
|
|
91
|
+
end
|
|
92
|
+
alias_method :reload, :refresh
|
|
93
|
+
|
|
94
|
+
# @return [Array<Hash>] the page's cookies as CDP cookie hashes.
|
|
95
|
+
def cookies
|
|
96
|
+
command("Storage.getCookies")["cookies"]
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Send a CDP command scoped to this page's session.
|
|
100
|
+
#
|
|
101
|
+
# @param method [String] the CDP method name.
|
|
102
|
+
# @param params [Hash] the method parameters.
|
|
103
|
+
# @return [Hash] the command's `result` object.
|
|
104
|
+
def command(method, params = {})
|
|
105
|
+
@client.command(method, params, session_id: @session_id)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# POST via XMLHttpRequest from the page context. Obscura routes XHR but not
|
|
109
|
+
# fetch, so this is the reliable POST path. All values cross as arguments,
|
|
110
|
+
# never interpolated into the JS. Returns { status, ok, body } on any HTTP
|
|
111
|
+
# reply (including 4xx/5xx). A transport failure — the request never reached
|
|
112
|
+
# the server (blocked by CORS / private-network SSRF guard, mixed origin, or
|
|
113
|
+
# a dead host) — raises ConnectionError instead of silently returning nil.
|
|
114
|
+
#
|
|
115
|
+
# `timeout` (seconds) bounds how long we wait for the reply. If the server
|
|
116
|
+
# accepts the connection but never answers the XHR (some anti-bot endpoints
|
|
117
|
+
# tarpit non-stealth clients), the wait ends with a TimeoutError that points
|
|
118
|
+
# at the likely cause. Note: Obscura ignores XMLHttpRequest#timeout, so the
|
|
119
|
+
# effective bound is this CDP-level one.
|
|
120
|
+
#
|
|
121
|
+
# @param url [String] the URL to POST to.
|
|
122
|
+
# @param payload [String] the raw request body.
|
|
123
|
+
# @param content_type [String] the Content-Type header value.
|
|
124
|
+
# @param headers [Hash{String=>String}] extra request headers.
|
|
125
|
+
# @param timeout [Integer, nil] seconds to wait for the reply.
|
|
126
|
+
# @return [Hash] `{ "status" => Integer, "ok" => Boolean, "body" => String }`.
|
|
127
|
+
# @raise [Obxcura::ConnectionError] if the request never reached the server.
|
|
128
|
+
# @raise [Obxcura::TimeoutError] if the server accepts but never answers.
|
|
129
|
+
def xhr_post(url, payload, content_type, headers, timeout: nil)
|
|
130
|
+
result = evaluate_func(<<~JS, url, payload, content_type, headers, timeout:)
|
|
131
|
+
function(url, payload, contentType, headers) {
|
|
132
|
+
return new Promise((resolve) => {
|
|
133
|
+
const x = new XMLHttpRequest();
|
|
134
|
+
x.onreadystatechange = () => {
|
|
135
|
+
if (x.readyState === 4) {
|
|
136
|
+
resolve({ status: x.status, ok: x.status >= 200 && x.status < 300, body: x.responseText });
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
x.onerror = () => resolve({ error: "network error or request blocked (CORS / private network / mixed origin)" });
|
|
140
|
+
try {
|
|
141
|
+
x.open("POST", url, true);
|
|
142
|
+
x.setRequestHeader("Content-Type", contentType);
|
|
143
|
+
Object.keys(headers).forEach((k) => x.setRequestHeader(k, headers[k]));
|
|
144
|
+
x.send(payload);
|
|
145
|
+
} catch (e) {
|
|
146
|
+
resolve({ error: String(e) });
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
JS
|
|
151
|
+
|
|
152
|
+
if result.nil? || result["error"]
|
|
153
|
+
reason = result&.dig("error") || "no response (request blocked or never settled)"
|
|
154
|
+
raise ConnectionError, "POST #{url} failed: #{reason}"
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
result
|
|
158
|
+
rescue TimeoutError
|
|
159
|
+
raise TimeoutError,
|
|
160
|
+
"POST #{url} did not complete in time. The server accepted the connection " \
|
|
161
|
+
"but never answered the XHR — likely anti-bot tarpitting. Try submitting the " \
|
|
162
|
+
"real form with #type/#submit, run `obscura serve --stealth`, or pass a larger timeout:."
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
private
|
|
166
|
+
|
|
167
|
+
def dispatch_event(method, params)
|
|
168
|
+
case method
|
|
169
|
+
when "Page.loadEventFired"
|
|
170
|
+
@load_queue.push(true)
|
|
171
|
+
when "Network.responseReceived"
|
|
172
|
+
@network_mutex.synchronize do
|
|
173
|
+
@network_log << { url: params.dig("response", "url"), request_id: params["requestId"], finished: false }
|
|
174
|
+
end
|
|
175
|
+
when "Network.loadingFinished"
|
|
176
|
+
@network_mutex.synchronize do
|
|
177
|
+
entry = @network_log.find { |e| e[:request_id] == params["requestId"] }
|
|
178
|
+
entry[:finished] = true if entry
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def wait_for_load
|
|
184
|
+
Timeout.timeout(Client::DEFAULT_TIMEOUT, TimeoutError, "Page load timed out") { @load_queue.pop }
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
data/lib/obxcura.rb
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Obxcura — a small Ruby API for driving an Obscura browser
|
|
4
|
+
# (h4ckf0r0day/obscura) over the Chrome DevTools Protocol.
|
|
5
|
+
#
|
|
6
|
+
# Obscura speaks CDP over a WebSocket, just like headless Chrome. The shape is
|
|
7
|
+
# simple: a Browser owns one connection, and each Page is a target with its own
|
|
8
|
+
# attached session. One connection, many pages.
|
|
9
|
+
#
|
|
10
|
+
# require "obxcura"
|
|
11
|
+
#
|
|
12
|
+
# browser = Obxcura::Browser.new # connects to a running `obscura serve`
|
|
13
|
+
# page = browser.create_page
|
|
14
|
+
# page.goto("https://www.google.com")
|
|
15
|
+
# puts page.html # rendered DOM (post-JS)
|
|
16
|
+
# browser.quit
|
|
17
|
+
#
|
|
18
|
+
# Start the browser first (defaults to port 9222):
|
|
19
|
+
# obscura serve
|
|
20
|
+
#
|
|
21
|
+
# nokogiri is optional — only needed for page.dom / at_css / css.
|
|
22
|
+
|
|
23
|
+
require "json"
|
|
24
|
+
require "net/http"
|
|
25
|
+
require "uri"
|
|
26
|
+
require "timeout"
|
|
27
|
+
|
|
28
|
+
require_relative "obxcura/version"
|
|
29
|
+
|
|
30
|
+
# Top-level namespace for the gem: a small Ruby client that drives an Obscura
|
|
31
|
+
# headless browser over the Chrome DevTools Protocol. See the file header above
|
|
32
|
+
# for the big picture; start from {Obxcura.start} or {Obxcura::Browser}.
|
|
33
|
+
module Obxcura
|
|
34
|
+
# Base class for every error the gem raises, so callers can rescue the whole
|
|
35
|
+
# family with `rescue Obxcura::Error`.
|
|
36
|
+
class Error < StandardError; end
|
|
37
|
+
|
|
38
|
+
# Raised when a CDP command (or the initial connect) does not answer in time.
|
|
39
|
+
class TimeoutError < Error; end
|
|
40
|
+
|
|
41
|
+
# Raised when Obscura returns a protocol-level error, or JS throws during evaluate.
|
|
42
|
+
class ProtocolError < Error; end
|
|
43
|
+
|
|
44
|
+
# Raised when the browser endpoint can't be reached at all (nothing listening, etc).
|
|
45
|
+
class ConnectionError < Error; end
|
|
46
|
+
|
|
47
|
+
# Connect to a running `obscura serve` and return a Browser.
|
|
48
|
+
#
|
|
49
|
+
# @param options [Hash] forwarded to {Obxcura::Browser#initialize}
|
|
50
|
+
# (`:host`, `:port`, `:timeout`).
|
|
51
|
+
# @return [Obxcura::Browser] a connected browser.
|
|
52
|
+
# @raise [Obxcura::ConnectionError] if no browser is listening.
|
|
53
|
+
# @example
|
|
54
|
+
# browser = Obxcura.start(port: 9222)
|
|
55
|
+
# page = browser.go_to("https://example.com")
|
|
56
|
+
# browser.quit
|
|
57
|
+
def self.start(**options)
|
|
58
|
+
Browser.new(**options)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
require_relative "obxcura/client"
|
|
63
|
+
require_relative "obxcura/node"
|
|
64
|
+
require_relative "obxcura/frame"
|
|
65
|
+
require_relative "obxcura/browser"
|
|
66
|
+
require_relative "obxcura/page"
|
metadata
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: obxcura
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.1
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- memoxmrdl
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-07-15 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: websocket-driver
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '0.7'
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - "~>"
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '0.7'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: nokogiri
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - "~>"
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '1.16'
|
|
34
|
+
type: :development
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - "~>"
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '1.16'
|
|
41
|
+
- !ruby/object:Gem::Dependency
|
|
42
|
+
name: rspec
|
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - "~>"
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '3.13'
|
|
48
|
+
type: :development
|
|
49
|
+
prerelease: false
|
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - "~>"
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '3.13'
|
|
55
|
+
- !ruby/object:Gem::Dependency
|
|
56
|
+
name: rubocop-rails-omakase
|
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
|
58
|
+
requirements:
|
|
59
|
+
- - "~>"
|
|
60
|
+
- !ruby/object:Gem::Version
|
|
61
|
+
version: '1.1'
|
|
62
|
+
type: :development
|
|
63
|
+
prerelease: false
|
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
65
|
+
requirements:
|
|
66
|
+
- - "~>"
|
|
67
|
+
- !ruby/object:Gem::Version
|
|
68
|
+
version: '1.1'
|
|
69
|
+
- !ruby/object:Gem::Dependency
|
|
70
|
+
name: rake
|
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
|
72
|
+
requirements:
|
|
73
|
+
- - "~>"
|
|
74
|
+
- !ruby/object:Gem::Version
|
|
75
|
+
version: '13.0'
|
|
76
|
+
type: :development
|
|
77
|
+
prerelease: false
|
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
79
|
+
requirements:
|
|
80
|
+
- - "~>"
|
|
81
|
+
- !ruby/object:Gem::Version
|
|
82
|
+
version: '13.0'
|
|
83
|
+
- !ruby/object:Gem::Dependency
|
|
84
|
+
name: webrick
|
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
|
86
|
+
requirements:
|
|
87
|
+
- - "~>"
|
|
88
|
+
- !ruby/object:Gem::Version
|
|
89
|
+
version: '1.8'
|
|
90
|
+
type: :development
|
|
91
|
+
prerelease: false
|
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
93
|
+
requirements:
|
|
94
|
+
- - "~>"
|
|
95
|
+
- !ruby/object:Gem::Version
|
|
96
|
+
version: '1.8'
|
|
97
|
+
- !ruby/object:Gem::Dependency
|
|
98
|
+
name: yard
|
|
99
|
+
requirement: !ruby/object:Gem::Requirement
|
|
100
|
+
requirements:
|
|
101
|
+
- - "~>"
|
|
102
|
+
- !ruby/object:Gem::Version
|
|
103
|
+
version: '0.9'
|
|
104
|
+
type: :development
|
|
105
|
+
prerelease: false
|
|
106
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
107
|
+
requirements:
|
|
108
|
+
- - "~>"
|
|
109
|
+
- !ruby/object:Gem::Version
|
|
110
|
+
version: '0.9'
|
|
111
|
+
description: High-level Browser/Page API for driving h4ckf0r0day/obscura via the Chrome
|
|
112
|
+
DevTools Protocol.
|
|
113
|
+
email:
|
|
114
|
+
- jmemox@gmail.com
|
|
115
|
+
executables: []
|
|
116
|
+
extensions: []
|
|
117
|
+
extra_rdoc_files: []
|
|
118
|
+
files:
|
|
119
|
+
- CHANGELOG.md
|
|
120
|
+
- LICENSE.txt
|
|
121
|
+
- README.md
|
|
122
|
+
- lib/obxcura.rb
|
|
123
|
+
- lib/obxcura/browser.rb
|
|
124
|
+
- lib/obxcura/client.rb
|
|
125
|
+
- lib/obxcura/frame.rb
|
|
126
|
+
- lib/obxcura/frame/dom.rb
|
|
127
|
+
- lib/obxcura/frame/runtime.rb
|
|
128
|
+
- lib/obxcura/node.rb
|
|
129
|
+
- lib/obxcura/page.rb
|
|
130
|
+
- lib/obxcura/version.rb
|
|
131
|
+
homepage: https://github.com/consultasimple/obxcura
|
|
132
|
+
licenses:
|
|
133
|
+
- MIT
|
|
134
|
+
metadata:
|
|
135
|
+
homepage_uri: https://github.com/consultasimple/obxcura
|
|
136
|
+
source_code_uri: https://github.com/consultasimple/obxcura
|
|
137
|
+
changelog_uri: https://github.com/consultasimple/obxcura/blob/main/CHANGELOG.md
|
|
138
|
+
rubygems_mfa_required: 'true'
|
|
139
|
+
post_install_message:
|
|
140
|
+
rdoc_options: []
|
|
141
|
+
require_paths:
|
|
142
|
+
- lib
|
|
143
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
144
|
+
requirements:
|
|
145
|
+
- - ">="
|
|
146
|
+
- !ruby/object:Gem::Version
|
|
147
|
+
version: '3.1'
|
|
148
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
149
|
+
requirements:
|
|
150
|
+
- - ">="
|
|
151
|
+
- !ruby/object:Gem::Version
|
|
152
|
+
version: '0'
|
|
153
|
+
requirements: []
|
|
154
|
+
rubygems_version: 3.5.11
|
|
155
|
+
signing_key:
|
|
156
|
+
specification_version: 4
|
|
157
|
+
summary: A small Ruby client for the Obscura headless browser over CDP.
|
|
158
|
+
test_files: []
|