lightpanda 0.0.1 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/LICENSE.txt +21 -0
- data/README.md +173 -17
- data/lib/lightpanda/binary.rb +158 -0
- data/lib/lightpanda/browser.rb +212 -0
- data/lib/lightpanda/capybara/driver.rb +113 -0
- data/lib/lightpanda/capybara/node.rb +254 -0
- data/lib/lightpanda/capybara.rb +46 -0
- data/lib/lightpanda/client/subscriber.rb +42 -0
- data/lib/lightpanda/client/web_socket.rb +147 -0
- data/lib/lightpanda/client.rb +121 -0
- data/lib/lightpanda/cookies.rb +47 -0
- data/lib/lightpanda/errors.rb +40 -0
- data/lib/lightpanda/network.rb +75 -0
- data/lib/lightpanda/options.rb +46 -0
- data/lib/lightpanda/process.rb +118 -0
- data/lib/lightpanda/version.rb +1 -1
- data/lib/lightpanda.rb +13 -2
- metadata +15 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 591cd0a182a0ce6cfababbdfa954162a4ac47c0968f9a84663d34fb0acb71c20
|
|
4
|
+
data.tar.gz: e4783c19ac142387b2d3eb9144115bae6d252a9d7ca4c3c944d5692aeb8bfb12
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1aec3d07fd8fb537e82c1e1c938b6008727d90f8fca579caf0d2120501d9acb2659e4bb177b3568e43bbeb2d27f83084bf3a9e61673aa89e11830ff7409c152b
|
|
7
|
+
data.tar.gz: 300e97b99c1b1f5524c53bec47cded120cde15c680d260da0be7472e447f8e15c939c6068172b36ef408d78bde75a8c591ee633863bb5393235611a0ae0e1eb5
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Marco Roth
|
|
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
CHANGED
|
@@ -1,39 +1,195 @@
|
|
|
1
1
|
# Lightpanda
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Ruby client for the [Lightpanda](https://lightpanda.io) headless browser via CDP (Chrome DevTools Protocol).
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Lightpanda is a fast, lightweight headless browser built for web automation, AI agents, and scraping. This gem provides a high-level Ruby API to control Lightpanda, similar to [Ferrum](https://github.com/rubycdp/ferrum) for Chrome.
|
|
6
6
|
|
|
7
|
-
##
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- High-level browser automation API
|
|
10
|
+
- CDP (Chrome DevTools Protocol) client
|
|
11
|
+
- Capybara driver included
|
|
12
|
+
- Auto-downloads Lightpanda binary if not found
|
|
13
|
+
- Ruby 3.1+
|
|
8
14
|
|
|
9
|
-
|
|
15
|
+
## Installation
|
|
10
16
|
|
|
11
|
-
|
|
17
|
+
Add to your Gemfile:
|
|
12
18
|
|
|
13
|
-
```
|
|
14
|
-
|
|
19
|
+
```ruby
|
|
20
|
+
gem "lightpanda"
|
|
15
21
|
```
|
|
16
22
|
|
|
17
|
-
|
|
23
|
+
Or install directly:
|
|
18
24
|
|
|
19
25
|
```bash
|
|
20
|
-
gem install
|
|
26
|
+
gem install lightpanda
|
|
21
27
|
```
|
|
22
28
|
|
|
29
|
+
The Lightpanda binary will be automatically downloaded on first use if not found in your `PATH`.
|
|
30
|
+
|
|
23
31
|
## Usage
|
|
24
32
|
|
|
25
|
-
|
|
33
|
+
### Basic Browser Control
|
|
26
34
|
|
|
27
|
-
|
|
35
|
+
**Create a browser instance**
|
|
36
|
+
|
|
37
|
+
```ruby
|
|
38
|
+
require "lightpanda"
|
|
39
|
+
|
|
40
|
+
browser = Lightpanda::Browser.new
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
**Navigate to a page**
|
|
44
|
+
|
|
45
|
+
```ruby
|
|
46
|
+
browser.go_to("https://example.com")
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
**Get page info**
|
|
50
|
+
|
|
51
|
+
```ruby
|
|
52
|
+
browser.current_url # => "https://example.com/"
|
|
53
|
+
browser.title # => "Example Domain"
|
|
54
|
+
browser.body # => "<html>...</html>"
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
**Evaluate JavaScript**
|
|
28
58
|
|
|
29
|
-
|
|
59
|
+
```ruby
|
|
60
|
+
browser.evaluate("1 + 1") # => 2
|
|
61
|
+
browser.evaluate("document.querySelector('h1').textContent") # => "Example Domain"
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
**Execute JavaScript (no return value)**
|
|
65
|
+
|
|
66
|
+
```ruby
|
|
67
|
+
browser.execute("console.log('Hello from Lightpanda!')")
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
**Send raw CDP commands**
|
|
71
|
+
|
|
72
|
+
```ruby
|
|
73
|
+
browser.command("Browser.getVersion")
|
|
74
|
+
# => {"protocolVersion"=>"1.3", "product"=>"Chrome/124.0.6367.29", ...}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
**Clean up**
|
|
30
78
|
|
|
31
|
-
|
|
79
|
+
```ruby
|
|
80
|
+
browser.quit
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### Configuration Options
|
|
84
|
+
|
|
85
|
+
```ruby
|
|
86
|
+
browser = Lightpanda.new(
|
|
87
|
+
host: "127.0.0.1", # CDP server host
|
|
88
|
+
port: 9222, # CDP server port
|
|
89
|
+
timeout: 5, # Command timeout in seconds
|
|
90
|
+
process_timeout: 10, # Process startup timeout
|
|
91
|
+
window_size: [1024, 768],
|
|
92
|
+
browser_path: "/path/to/lightpanda" # Custom binary path
|
|
93
|
+
)
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### Binary Management
|
|
32
97
|
|
|
33
|
-
|
|
98
|
+
**Get binary path (downloads if needed)**
|
|
34
99
|
|
|
35
|
-
|
|
100
|
+
```ruby
|
|
101
|
+
Lightpanda::Binary.path # => "/Users/you/.cache/lightpanda/lightpanda"
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
**Get version**
|
|
105
|
+
|
|
106
|
+
```ruby
|
|
107
|
+
Lightpanda::Binary.version # => "7c976209"
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
**Run arbitrary commands**
|
|
111
|
+
|
|
112
|
+
```ruby
|
|
113
|
+
result = Lightpanda::Binary.run("--help")
|
|
114
|
+
result.stdout # => ""
|
|
115
|
+
result.stderr # => "usage: lightpanda command [options] [URL]..."
|
|
116
|
+
result.success? # => false (help exits with 1)
|
|
117
|
+
result.output # => returns stderr if stdout empty
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
**Fetch a URL directly (no browser instance needed)**
|
|
121
|
+
|
|
122
|
+
```ruby
|
|
123
|
+
html = Lightpanda::Binary.fetch("https://example.com")
|
|
124
|
+
# => "<!DOCTYPE html><html>..."
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### Capybara Integration
|
|
128
|
+
|
|
129
|
+
**Basic usage**
|
|
130
|
+
|
|
131
|
+
```ruby
|
|
132
|
+
require "lightpanda/capybara"
|
|
133
|
+
|
|
134
|
+
Capybara.default_driver = :lightpanda
|
|
135
|
+
|
|
136
|
+
visit "https://example.com"
|
|
137
|
+
find("h1").text # => "Example Domain"
|
|
138
|
+
all("p").count # => 2
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
**Configuration**
|
|
142
|
+
|
|
143
|
+
```ruby
|
|
144
|
+
Lightpanda::Capybara.configure do |config|
|
|
145
|
+
config.host = "127.0.0.1"
|
|
146
|
+
config.port = 9222
|
|
147
|
+
config.timeout = 5
|
|
148
|
+
end
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
**In tests**
|
|
152
|
+
|
|
153
|
+
```ruby
|
|
154
|
+
class FeatureTest < Minitest::Spec
|
|
155
|
+
include Capybara::DSL
|
|
156
|
+
|
|
157
|
+
def setup
|
|
158
|
+
Capybara.default_driver = :lightpanda
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def teardown
|
|
162
|
+
Capybara.reset_sessions!
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
it "shows the homepage" do
|
|
166
|
+
visit "https://example.com"
|
|
167
|
+
assert find("h1").text == "Example Domain"
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
## Environment Variables
|
|
173
|
+
|
|
174
|
+
- `LIGHTPANDA_PATH` - Custom path to Lightpanda binary
|
|
175
|
+
- `LIGHTPANDA_DEFAULT_TIMEOUT` - Default command timeout (default: 5)
|
|
176
|
+
- `LIGHTPANDA_PROCESS_TIMEOUT` - Process startup timeout (default: 10)
|
|
177
|
+
|
|
178
|
+
## Limitations
|
|
179
|
+
|
|
180
|
+
Lightpanda is a lightweight browser with some limitations compared to Chrome:
|
|
181
|
+
|
|
182
|
+
- Single browser context only (no incognito/multi-context)
|
|
183
|
+
- No XPath support (`XPathResult` not implemented)
|
|
184
|
+
- Limited CDP command coverage
|
|
185
|
+
|
|
186
|
+
## Development
|
|
187
|
+
|
|
188
|
+
```bash
|
|
189
|
+
bundle install
|
|
190
|
+
bundle exec mtest
|
|
191
|
+
```
|
|
36
192
|
|
|
37
|
-
##
|
|
193
|
+
## License
|
|
38
194
|
|
|
39
|
-
|
|
195
|
+
MIT License. See [LICENSE.txt](LICENSE.txt).
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "open3"
|
|
6
|
+
require "rbconfig"
|
|
7
|
+
require "uri"
|
|
8
|
+
|
|
9
|
+
module Lightpanda
|
|
10
|
+
class Binary
|
|
11
|
+
Result = Struct.new(:stdout, :stderr, :status, keyword_init: true) do
|
|
12
|
+
def success?
|
|
13
|
+
status.success?
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def exit_code
|
|
17
|
+
status.exitstatus
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def output
|
|
21
|
+
stdout.empty? ? stderr : stdout
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
GITHUB_RELEASE_URL = "https://github.com/lightpanda-io/browser/releases/download/nightly"
|
|
26
|
+
|
|
27
|
+
PLATFORMS = {
|
|
28
|
+
%w[x86_64 linux] => "lightpanda-x86_64-linux",
|
|
29
|
+
%w[aarch64 darwin] => "lightpanda-aarch64-macos",
|
|
30
|
+
%w[arm64 darwin] => "lightpanda-aarch64-macos"
|
|
31
|
+
}.freeze
|
|
32
|
+
|
|
33
|
+
class << self
|
|
34
|
+
def path
|
|
35
|
+
find_or_download
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def find_or_download
|
|
39
|
+
find || download
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def run(*args)
|
|
43
|
+
binary = find_or_download
|
|
44
|
+
stdout, stderr, status = Open3.capture3(binary, *args)
|
|
45
|
+
|
|
46
|
+
Result.new(stdout: stdout, stderr: stderr, status: status)
|
|
47
|
+
rescue Errno::ENOENT
|
|
48
|
+
raise BinaryNotFoundError, "Lightpanda binary not found"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def fetch(url)
|
|
52
|
+
result = run("fetch", "--dump", url)
|
|
53
|
+
raise BinaryError, result.stderr unless result.success?
|
|
54
|
+
|
|
55
|
+
result.stdout
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def version
|
|
59
|
+
result = run("version")
|
|
60
|
+
result.output.strip
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def find
|
|
64
|
+
env_path = ENV.fetch("LIGHTPANDA_PATH", nil)
|
|
65
|
+
return env_path if env_path && File.executable?(env_path)
|
|
66
|
+
|
|
67
|
+
path_binary = find_in_path
|
|
68
|
+
return path_binary if path_binary
|
|
69
|
+
|
|
70
|
+
default_path = default_binary_path
|
|
71
|
+
return default_path if File.executable?(default_path)
|
|
72
|
+
|
|
73
|
+
nil
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def download
|
|
77
|
+
binary_name = platform_binary
|
|
78
|
+
url = "#{GITHUB_RELEASE_URL}/#{binary_name}"
|
|
79
|
+
destination = default_binary_path
|
|
80
|
+
|
|
81
|
+
FileUtils.mkdir_p(File.dirname(destination))
|
|
82
|
+
|
|
83
|
+
download_file(url, destination)
|
|
84
|
+
FileUtils.chmod(0o755, destination)
|
|
85
|
+
|
|
86
|
+
destination
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def platform_binary
|
|
90
|
+
arch = normalize_arch(RbConfig::CONFIG["host_cpu"])
|
|
91
|
+
os = normalize_os(RbConfig::CONFIG["host_os"])
|
|
92
|
+
|
|
93
|
+
PLATFORMS[[arch, os]] || raise(UnsupportedPlatformError, "Unsupported platform: #{arch}-#{os}")
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def default_binary_path
|
|
97
|
+
cache_dir = ENV.fetch("XDG_CACHE_HOME") { File.expand_path("~/.cache") }
|
|
98
|
+
|
|
99
|
+
File.join(cache_dir, "lightpanda", "lightpanda")
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
def find_in_path
|
|
105
|
+
ENV["PATH"].to_s.split(File::PATH_SEPARATOR).each do |dir|
|
|
106
|
+
path = File.join(dir, "lightpanda")
|
|
107
|
+
|
|
108
|
+
return path if File.executable?(path)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
nil
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def normalize_arch(arch)
|
|
115
|
+
case arch
|
|
116
|
+
when /x86_64|amd64/i then "x86_64"
|
|
117
|
+
when /aarch64|arm64/i then "aarch64"
|
|
118
|
+
else arch
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def normalize_os(os)
|
|
123
|
+
case os
|
|
124
|
+
when /darwin|mac/i then "darwin"
|
|
125
|
+
when /linux/i then "linux"
|
|
126
|
+
else os
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def download_file(url, destination)
|
|
131
|
+
uri = URI.parse(url)
|
|
132
|
+
|
|
133
|
+
follow_redirects(uri, destination)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def follow_redirects(uri, destination, limit = 10)
|
|
137
|
+
raise BinaryNotFoundError, "Too many redirects" if limit.zero?
|
|
138
|
+
|
|
139
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
|
|
140
|
+
request = Net::HTTP::Get.new(uri)
|
|
141
|
+
|
|
142
|
+
http.request(request) do |response|
|
|
143
|
+
case response
|
|
144
|
+
when Net::HTTPSuccess
|
|
145
|
+
File.open(destination, "wb") do |file|
|
|
146
|
+
response.read_body { |chunk| file.write(chunk) }
|
|
147
|
+
end
|
|
148
|
+
when Net::HTTPRedirection
|
|
149
|
+
follow_redirects(URI.parse(response["location"]), destination, limit - 1)
|
|
150
|
+
else
|
|
151
|
+
raise BinaryNotFoundError, "Failed to download binary: #{response.code} #{response.message}"
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "forwardable"
|
|
4
|
+
|
|
5
|
+
module Lightpanda
|
|
6
|
+
class Browser
|
|
7
|
+
extend Forwardable
|
|
8
|
+
|
|
9
|
+
attr_reader :options, :process, :client, :target_id, :session_id
|
|
10
|
+
|
|
11
|
+
delegate %i[on off] => :client
|
|
12
|
+
|
|
13
|
+
def initialize(options = {})
|
|
14
|
+
@options = Options.new(options)
|
|
15
|
+
@process = nil
|
|
16
|
+
@client = nil
|
|
17
|
+
@target_id = nil
|
|
18
|
+
@session_id = nil
|
|
19
|
+
@started = false
|
|
20
|
+
@page_events_enabled = false
|
|
21
|
+
|
|
22
|
+
start
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def start
|
|
26
|
+
return if @started
|
|
27
|
+
|
|
28
|
+
if @options.ws_url?
|
|
29
|
+
@client = Client.new(@options.ws_url, @options)
|
|
30
|
+
else
|
|
31
|
+
@process = Process.new(@options)
|
|
32
|
+
@process.start
|
|
33
|
+
@client = Client.new(@process.ws_url, @options)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
create_page
|
|
37
|
+
|
|
38
|
+
@started = true
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def create_page
|
|
42
|
+
result = @client.command("Target.createTarget", { url: "about:blank" })
|
|
43
|
+
@target_id = result["targetId"]
|
|
44
|
+
|
|
45
|
+
attach_result = @client.command("Target.attachToTarget", { targetId: @target_id, flatten: true })
|
|
46
|
+
@session_id = attach_result["sessionId"]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def restart
|
|
50
|
+
quit
|
|
51
|
+
start
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def quit
|
|
55
|
+
@client&.close
|
|
56
|
+
@process&.stop
|
|
57
|
+
@client = nil
|
|
58
|
+
@process = nil
|
|
59
|
+
@started = false
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def command(method, **params)
|
|
63
|
+
@client.command(method, params)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def page_command(method, **params)
|
|
67
|
+
@client.command(method, params, session_id: @session_id)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def go_to(url, wait: true)
|
|
71
|
+
enable_page_events
|
|
72
|
+
|
|
73
|
+
if wait
|
|
74
|
+
loaded = Concurrent::Event.new
|
|
75
|
+
|
|
76
|
+
handler = proc { loaded.set }
|
|
77
|
+
@client.on("Page.loadEventFired", &handler)
|
|
78
|
+
|
|
79
|
+
result = page_command("Page.navigate", url: url)
|
|
80
|
+
|
|
81
|
+
loaded.wait(@options.timeout)
|
|
82
|
+
|
|
83
|
+
@client.off("Page.loadEventFired", handler)
|
|
84
|
+
|
|
85
|
+
result
|
|
86
|
+
else
|
|
87
|
+
page_command("Page.navigate", url: url)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
alias goto go_to
|
|
91
|
+
|
|
92
|
+
def enable_page_events
|
|
93
|
+
return if @page_events_enabled
|
|
94
|
+
|
|
95
|
+
page_command("Page.enable")
|
|
96
|
+
@page_events_enabled = true
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def back
|
|
100
|
+
page_command("Page.navigateToHistoryEntry", entryId: current_entry_id - 1)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def forward
|
|
104
|
+
page_command("Page.navigateToHistoryEntry", entryId: current_entry_id + 1)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def refresh
|
|
108
|
+
page_command("Page.reload")
|
|
109
|
+
end
|
|
110
|
+
alias reload refresh
|
|
111
|
+
|
|
112
|
+
def current_url
|
|
113
|
+
evaluate("window.location.href")
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def title
|
|
117
|
+
evaluate("document.title")
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def body
|
|
121
|
+
evaluate("document.documentElement.outerHTML")
|
|
122
|
+
end
|
|
123
|
+
alias html body
|
|
124
|
+
|
|
125
|
+
def evaluate(expression)
|
|
126
|
+
response = page_command("Runtime.evaluate", expression: expression, returnByValue: true, awaitPromise: true)
|
|
127
|
+
|
|
128
|
+
handle_evaluate_response(response)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def execute(expression)
|
|
132
|
+
page_command("Runtime.evaluate", expression: expression, returnByValue: false, awaitPromise: false)
|
|
133
|
+
nil
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def css(selector)
|
|
137
|
+
node_ids = page_command("DOM.querySelectorAll", nodeId: document_node_id, selector: selector)
|
|
138
|
+
node_ids["nodeIds"] || []
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def at_css(selector)
|
|
142
|
+
result = page_command("DOM.querySelector", nodeId: document_node_id, selector: selector)
|
|
143
|
+
|
|
144
|
+
result["nodeId"]
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def screenshot(path: nil, format: :png, quality: nil, full_page: false, encoding: :binary)
|
|
148
|
+
params = { format: format.to_s }
|
|
149
|
+
params[:quality] = quality if quality && format == :jpeg
|
|
150
|
+
|
|
151
|
+
if full_page
|
|
152
|
+
metrics = page_command("Page.getLayoutMetrics")
|
|
153
|
+
content_size = metrics["contentSize"]
|
|
154
|
+
|
|
155
|
+
params[:clip] = {
|
|
156
|
+
x: 0,
|
|
157
|
+
y: 0,
|
|
158
|
+
width: content_size["width"],
|
|
159
|
+
height: content_size["height"],
|
|
160
|
+
scale: 1
|
|
161
|
+
}
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
result = page_command("Page.captureScreenshot", **params)
|
|
165
|
+
data = result["data"]
|
|
166
|
+
|
|
167
|
+
if encoding == :base64
|
|
168
|
+
data
|
|
169
|
+
else
|
|
170
|
+
decoded = Base64.decode64(data)
|
|
171
|
+
|
|
172
|
+
if path
|
|
173
|
+
File.binwrite(path, decoded)
|
|
174
|
+
path
|
|
175
|
+
else
|
|
176
|
+
decoded
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def network
|
|
182
|
+
@network ||= Network.new(self)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def cookies
|
|
186
|
+
@cookies ||= Cookies.new(self)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
private
|
|
190
|
+
|
|
191
|
+
def document_node_id
|
|
192
|
+
result = page_command("DOM.getDocument")
|
|
193
|
+
|
|
194
|
+
result.dig("root", "nodeId")
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def current_entry_id
|
|
198
|
+
result = page_command("Page.getNavigationHistory")
|
|
199
|
+
|
|
200
|
+
result["currentIndex"]
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def handle_evaluate_response(response)
|
|
204
|
+
raise JavaScriptError, response if response["exceptionDetails"]
|
|
205
|
+
|
|
206
|
+
result = response["result"]
|
|
207
|
+
return nil if result["type"] == "undefined"
|
|
208
|
+
|
|
209
|
+
result["value"]
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|