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
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lightpanda
|
|
4
|
+
class Cookies
|
|
5
|
+
attr_reader :browser
|
|
6
|
+
|
|
7
|
+
def initialize(browser)
|
|
8
|
+
@browser = browser
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def all
|
|
12
|
+
result = browser.command("Network.getAllCookies")
|
|
13
|
+
|
|
14
|
+
result["cookies"] || []
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def get(name)
|
|
18
|
+
all.find { |cookie| cookie["name"] == name }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def set(name:, value:, domain: nil, path: "/", secure: false, http_only: false, expires: nil)
|
|
22
|
+
params = {
|
|
23
|
+
name: name,
|
|
24
|
+
value: value,
|
|
25
|
+
path: path,
|
|
26
|
+
secure: secure,
|
|
27
|
+
httpOnly: http_only
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
params[:domain] = domain if domain
|
|
31
|
+
params[:expires] = expires.to_i if expires
|
|
32
|
+
|
|
33
|
+
browser.command("Network.setCookie", **params)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def remove(name:, domain: nil, path: "/")
|
|
37
|
+
params = { name: name, path: path }
|
|
38
|
+
params[:domain] = domain if domain
|
|
39
|
+
|
|
40
|
+
browser.command("Network.deleteCookies", **params)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def clear
|
|
44
|
+
browser.command("Network.clearBrowserCookies")
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lightpanda
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
|
|
6
|
+
class ProcessTimeoutError < Error; end
|
|
7
|
+
class BinaryNotFoundError < Error; end
|
|
8
|
+
class BinaryError < Error; end
|
|
9
|
+
class UnsupportedPlatformError < Error; end
|
|
10
|
+
|
|
11
|
+
class DeadBrowserError < Error; end
|
|
12
|
+
class TimeoutError < Error; end
|
|
13
|
+
|
|
14
|
+
class BrowserError < Error
|
|
15
|
+
attr_reader :response
|
|
16
|
+
|
|
17
|
+
def initialize(response)
|
|
18
|
+
@response = response
|
|
19
|
+
super(response["message"])
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
class JavaScriptError < Error
|
|
24
|
+
attr_reader :class_name, :message
|
|
25
|
+
|
|
26
|
+
def initialize(response)
|
|
27
|
+
@class_name = response.dig("exceptionDetails", "exception", "className")
|
|
28
|
+
@message = response.dig("exceptionDetails", "exception",
|
|
29
|
+
"description") || response.dig("exceptionDetails", "text")
|
|
30
|
+
|
|
31
|
+
super(@message)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
class NodeNotFoundError < Error; end
|
|
36
|
+
class NoExecutionContextError < Error; end
|
|
37
|
+
|
|
38
|
+
class NoSuchPageError < Error; end
|
|
39
|
+
class StatusError < Error; end
|
|
40
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lightpanda
|
|
4
|
+
class Network
|
|
5
|
+
attr_reader :browser
|
|
6
|
+
|
|
7
|
+
def initialize(browser)
|
|
8
|
+
@browser = browser
|
|
9
|
+
@traffic = []
|
|
10
|
+
@enabled = false
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def enable
|
|
14
|
+
return if @enabled
|
|
15
|
+
|
|
16
|
+
browser.command("Network.enable")
|
|
17
|
+
subscribe
|
|
18
|
+
@enabled = true
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def disable
|
|
22
|
+
return unless @enabled
|
|
23
|
+
|
|
24
|
+
browser.command("Network.disable")
|
|
25
|
+
@enabled = false
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def traffic
|
|
29
|
+
@traffic.dup
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def clear
|
|
33
|
+
@traffic.clear
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def wait_for_idle(timeout: 5, connections: 0) # rubocop:disable Naming/PredicateMethod
|
|
37
|
+
started_at = Time.now
|
|
38
|
+
|
|
39
|
+
while Time.now - started_at < timeout
|
|
40
|
+
pending = @traffic.count { |t| t[:response].nil? }
|
|
41
|
+
return true if pending <= connections
|
|
42
|
+
|
|
43
|
+
sleep 0.1
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
false
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def subscribe
|
|
52
|
+
browser.on("Network.requestWillBeSent") do |params|
|
|
53
|
+
@traffic << {
|
|
54
|
+
request_id: params["requestId"],
|
|
55
|
+
url: params.dig("request", "url"),
|
|
56
|
+
method: params.dig("request", "method"),
|
|
57
|
+
timestamp: params["timestamp"],
|
|
58
|
+
response: nil
|
|
59
|
+
}
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
browser.on("Network.responseReceived") do |params|
|
|
63
|
+
request = @traffic.find { |t| t[:request_id] == params["requestId"] }
|
|
64
|
+
|
|
65
|
+
next unless request
|
|
66
|
+
|
|
67
|
+
request[:response] = {
|
|
68
|
+
status: params.dig("response", "status"),
|
|
69
|
+
headers: params.dig("response", "headers"),
|
|
70
|
+
mime_type: params.dig("response", "mimeType")
|
|
71
|
+
}
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lightpanda
|
|
4
|
+
class Options
|
|
5
|
+
DEFAULT_TIMEOUT = ENV.fetch("LIGHTPANDA_DEFAULT_TIMEOUT", 5).to_i
|
|
6
|
+
DEFAULT_PROCESS_TIMEOUT = ENV.fetch("LIGHTPANDA_PROCESS_TIMEOUT", 10).to_i
|
|
7
|
+
DEFAULT_HOST = "127.0.0.1"
|
|
8
|
+
DEFAULT_PORT = 9222
|
|
9
|
+
DEFAULT_WINDOW_SIZE = [1024, 768].freeze
|
|
10
|
+
|
|
11
|
+
attr_accessor :host, :port, :timeout, :process_timeout, :window_size, :browser_path, :headless
|
|
12
|
+
attr_writer :ws_url
|
|
13
|
+
|
|
14
|
+
def initialize(options = {})
|
|
15
|
+
@host = options.fetch(:host, DEFAULT_HOST)
|
|
16
|
+
@port = options.fetch(:port, DEFAULT_PORT)
|
|
17
|
+
@timeout = options.fetch(:timeout, DEFAULT_TIMEOUT)
|
|
18
|
+
@process_timeout = options.fetch(:process_timeout, DEFAULT_PROCESS_TIMEOUT)
|
|
19
|
+
@window_size = options.fetch(:window_size, DEFAULT_WINDOW_SIZE)
|
|
20
|
+
@browser_path = options[:browser_path]
|
|
21
|
+
@headless = options.fetch(:headless, true)
|
|
22
|
+
@ws_url = options[:ws_url]
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def ws_url
|
|
26
|
+
@ws_url || "ws://#{host}:#{port}/"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def ws_url?
|
|
30
|
+
!@ws_url.nil?
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def to_h
|
|
34
|
+
{
|
|
35
|
+
host: host,
|
|
36
|
+
port: port,
|
|
37
|
+
timeout: timeout,
|
|
38
|
+
process_timeout: process_timeout,
|
|
39
|
+
window_size: window_size,
|
|
40
|
+
browser_path: browser_path,
|
|
41
|
+
headless: headless,
|
|
42
|
+
ws_url: ws_url
|
|
43
|
+
}
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lightpanda
|
|
4
|
+
class Process
|
|
5
|
+
READY_PATTERN = /server running.*address=(\d+\.\d+\.\d+\.\d+:\d+)/
|
|
6
|
+
|
|
7
|
+
attr_reader :pid, :ws_url
|
|
8
|
+
|
|
9
|
+
def initialize(options)
|
|
10
|
+
@options = options
|
|
11
|
+
@pid = nil
|
|
12
|
+
@ws_url = nil
|
|
13
|
+
@stdout_r = nil
|
|
14
|
+
@stdout_w = nil
|
|
15
|
+
@stderr_r = nil
|
|
16
|
+
@stderr_w = nil
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def start
|
|
20
|
+
binary_path = @options.browser_path || Binary.find_or_download
|
|
21
|
+
|
|
22
|
+
raise BinaryNotFoundError, "Lightpanda binary not found" unless binary_path
|
|
23
|
+
|
|
24
|
+
@stdout_r, @stdout_w = IO.pipe
|
|
25
|
+
@stderr_r, @stderr_w = IO.pipe
|
|
26
|
+
|
|
27
|
+
@pid = spawn_process(binary_path)
|
|
28
|
+
|
|
29
|
+
@stdout_w.close
|
|
30
|
+
@stderr_w.close
|
|
31
|
+
|
|
32
|
+
wait_for_ready
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def stop
|
|
36
|
+
return unless @pid
|
|
37
|
+
|
|
38
|
+
begin
|
|
39
|
+
::Process.kill("TERM", @pid)
|
|
40
|
+
::Process.wait(@pid)
|
|
41
|
+
rescue Errno::ESRCH, Errno::ECHILD
|
|
42
|
+
# Process already dead
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
cleanup_pipes
|
|
46
|
+
@pid = nil
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def alive?
|
|
50
|
+
return false unless @pid
|
|
51
|
+
|
|
52
|
+
::Process.kill(0, @pid)
|
|
53
|
+
true
|
|
54
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
55
|
+
false
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def spawn_process(binary_path)
|
|
61
|
+
args = build_args
|
|
62
|
+
|
|
63
|
+
::Process.spawn(
|
|
64
|
+
binary_path, *args,
|
|
65
|
+
out: @stdout_w,
|
|
66
|
+
err: @stderr_w,
|
|
67
|
+
pgroup: true
|
|
68
|
+
)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def build_args
|
|
72
|
+
%W[
|
|
73
|
+
serve
|
|
74
|
+
--host #{@options.host}
|
|
75
|
+
--port #{@options.port}
|
|
76
|
+
--log_level info
|
|
77
|
+
]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def wait_for_ready
|
|
81
|
+
started_at = Time.now
|
|
82
|
+
output = +""
|
|
83
|
+
|
|
84
|
+
catch(:ready) do
|
|
85
|
+
while Time.now - started_at < @options.process_timeout
|
|
86
|
+
ready = IO.select([@stdout_r, @stderr_r], nil, nil, 0.1)
|
|
87
|
+
|
|
88
|
+
next unless ready
|
|
89
|
+
|
|
90
|
+
ready[0].each do |io|
|
|
91
|
+
chunk = io.read_nonblock(1024)
|
|
92
|
+
output << chunk
|
|
93
|
+
|
|
94
|
+
if (match = output.match(READY_PATTERN))
|
|
95
|
+
@ws_url = "ws://#{match[1]}/"
|
|
96
|
+
throw(:ready)
|
|
97
|
+
end
|
|
98
|
+
rescue IO::WaitReadable
|
|
99
|
+
# No data available yet
|
|
100
|
+
rescue EOFError
|
|
101
|
+
# Pipe closed
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
stop
|
|
106
|
+
|
|
107
|
+
raise ProcessTimeoutError,
|
|
108
|
+
"Lightpanda failed to start within #{@options.process_timeout} seconds.\nOutput: #{output}"
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def cleanup_pipes
|
|
113
|
+
[@stdout_r, @stdout_w, @stderr_r, @stderr_w].each do |pipe|
|
|
114
|
+
pipe&.close unless pipe&.closed?
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
data/lib/lightpanda/version.rb
CHANGED
data/lib/lightpanda.rb
CHANGED
|
@@ -1,8 +1,19 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "lightpanda/version"
|
|
4
|
+
require_relative "lightpanda/errors"
|
|
5
|
+
require_relative "lightpanda/options"
|
|
6
|
+
require_relative "lightpanda/binary"
|
|
7
|
+
require_relative "lightpanda/process"
|
|
8
|
+
require_relative "lightpanda/client"
|
|
9
|
+
require_relative "lightpanda/network"
|
|
10
|
+
require_relative "lightpanda/cookies"
|
|
11
|
+
require_relative "lightpanda/browser"
|
|
4
12
|
|
|
5
13
|
module Lightpanda
|
|
6
|
-
class
|
|
7
|
-
|
|
14
|
+
class << self
|
|
15
|
+
def new(**options)
|
|
16
|
+
Browser.new(**options)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
8
19
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: lightpanda
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.0
|
|
4
|
+
version: 0.1.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Marco Roth
|
|
@@ -60,8 +60,22 @@ executables: []
|
|
|
60
60
|
extensions: []
|
|
61
61
|
extra_rdoc_files: []
|
|
62
62
|
files:
|
|
63
|
+
- LICENSE.txt
|
|
63
64
|
- README.md
|
|
64
65
|
- lib/lightpanda.rb
|
|
66
|
+
- lib/lightpanda/binary.rb
|
|
67
|
+
- lib/lightpanda/browser.rb
|
|
68
|
+
- lib/lightpanda/capybara.rb
|
|
69
|
+
- lib/lightpanda/capybara/driver.rb
|
|
70
|
+
- lib/lightpanda/capybara/node.rb
|
|
71
|
+
- lib/lightpanda/client.rb
|
|
72
|
+
- lib/lightpanda/client/subscriber.rb
|
|
73
|
+
- lib/lightpanda/client/web_socket.rb
|
|
74
|
+
- lib/lightpanda/cookies.rb
|
|
75
|
+
- lib/lightpanda/errors.rb
|
|
76
|
+
- lib/lightpanda/network.rb
|
|
77
|
+
- lib/lightpanda/options.rb
|
|
78
|
+
- lib/lightpanda/process.rb
|
|
65
79
|
- lib/lightpanda/version.rb
|
|
66
80
|
homepage: https://github.com/marcoroth/lightpanda-ruby
|
|
67
81
|
licenses:
|