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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 585fa864b9a0946daa70334644abf23b36a940fc6a283f5a839e8a3f6febe6c0
4
- data.tar.gz: ec6ab4fc8101a82150d9c3861d883825966b280e56d9ab2ca076172ca6676335
3
+ metadata.gz: 591cd0a182a0ce6cfababbdfa954162a4ac47c0968f9a84663d34fb0acb71c20
4
+ data.tar.gz: e4783c19ac142387b2d3eb9144115bae6d252a9d7ca4c3c944d5692aeb8bfb12
5
5
  SHA512:
6
- metadata.gz: 3d09767f01eaf1f49eb91a5e20fc3b2e79f7569fec2b9d1c4d95eaa3b785f6f8b12a2403e70a48d01ad24f3ac8d27df84ab86519bd7c1ea23ac141c0742c0b2c
7
- data.tar.gz: b8515873cfc8a1beb3f4942a4f8b774892dff3fce1fa4d4c8463be42558911480289a6d3e9329da098331e24aaa715dcaf4d990a323e371a69b3e577cb95d9c5
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
- TODO: Delete this and the text below, and describe your gem
3
+ Ruby client for the [Lightpanda](https://lightpanda.io) headless browser via CDP (Chrome DevTools Protocol).
4
4
 
5
- Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/lightpanda`. To experiment with that code, run `bin/console` for an interactive prompt.
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
- ## Installation
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
- TODO: Replace `UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG` with your gem name right after releasing it to RubyGems.org. Please do not do it earlier due to security reasons. Alternatively, replace this section with instructions to install your gem from git if you don't plan to release to RubyGems.org.
15
+ ## Installation
10
16
 
11
- Install the gem and add to the application's Gemfile by executing:
17
+ Add to your Gemfile:
12
18
 
13
- ```bash
14
- bundle add UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
19
+ ```ruby
20
+ gem "lightpanda"
15
21
  ```
16
22
 
17
- If bundler is not being used to manage dependencies, install the gem by executing:
23
+ Or install directly:
18
24
 
19
25
  ```bash
20
- gem install UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
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
- TODO: Write usage instructions here
33
+ ### Basic Browser Control
26
34
 
27
- ## Development
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
- After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
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
- To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
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
- ## Contributing
98
+ **Get binary path (downloads if needed)**
34
99
 
35
- Bug reports and pull requests are welcome on GitHub at https://github.com/marcoroth/lightpanda. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/marcoroth/lightpanda/blob/main/CODE_OF_CONDUCT.md).
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
- ## Code of Conduct
193
+ ## License
38
194
 
39
- Everyone interacting in the Lightpanda project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/marcoroth/lightpanda/blob/main/CODE_OF_CONDUCT.md).
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