unmagic-browser 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 +7 -0
- data/CHANGELOG.md +51 -0
- data/LICENSE +21 -0
- data/README.md +372 -0
- data/lib/unmagic/browser/configuration.rb +55 -0
- data/lib/unmagic/browser/console/banner.rb +82 -0
- data/lib/unmagic/browser/console/graphics.rb +145 -0
- data/lib/unmagic/browser/console/helpers.rb +325 -0
- data/lib/unmagic/browser/console/prompt.rb +152 -0
- data/lib/unmagic/browser/console/renderer.rb +205 -0
- data/lib/unmagic/browser/console/terminal.rb +108 -0
- data/lib/unmagic/browser/console/values.rb +78 -0
- data/lib/unmagic/browser/console.rb +161 -0
- data/lib/unmagic/browser/driver/base.rb +146 -0
- data/lib/unmagic/browser/driver/cloudflare/transport.rb +67 -0
- data/lib/unmagic/browser/driver/cloudflare.rb +214 -0
- data/lib/unmagic/browser/driver/connection.rb +75 -0
- data/lib/unmagic/browser/driver/local.rb +87 -0
- data/lib/unmagic/browser/driver/remote.rb +45 -0
- data/lib/unmagic/browser/driver.rb +75 -0
- data/lib/unmagic/browser/element.rb +257 -0
- data/lib/unmagic/browser/emulation.rb +248 -0
- data/lib/unmagic/browser/error.rb +44 -0
- data/lib/unmagic/browser/failures.rb +76 -0
- data/lib/unmagic/browser/locator.rb +256 -0
- data/lib/unmagic/browser/markdown.rb +54 -0
- data/lib/unmagic/browser/session.rb +588 -0
- data/lib/unmagic/browser/version.rb +8 -0
- data/lib/unmagic/browser.rb +214 -0
- metadata +105 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "puppeteer"
|
|
4
|
+
require "async/websocket/client"
|
|
5
|
+
|
|
6
|
+
module Unmagic
|
|
7
|
+
class Browser
|
|
8
|
+
module Driver
|
|
9
|
+
class Cloudflare < Base
|
|
10
|
+
# The one seam between puppeteer-ruby and Cloudflare.
|
|
11
|
+
#
|
|
12
|
+
# puppeteer-ruby opens the CDP websocket with no custom headers, and
|
|
13
|
+
# Cloudflare's remote devtools endpoint authenticates on the handshake
|
|
14
|
+
# with `Authorization: Bearer`. This subclass injects it; everything
|
|
15
|
+
# else about driving the browser is unchanged.
|
|
16
|
+
class Transport < Puppeteer::WebSocketTransport
|
|
17
|
+
class << self
|
|
18
|
+
# Connect to Cloudflare's devtools endpoint.
|
|
19
|
+
#
|
|
20
|
+
# Must be called from inside a running Async reactor. Outside one,
|
|
21
|
+
# `connect`'s `Async do ... end` becomes the reactor itself and
|
|
22
|
+
# blocks until the read loop ends — that is, until Cloudflare drops
|
|
23
|
+
# the idle socket — handing back a transport that's already dead.
|
|
24
|
+
#
|
|
25
|
+
# @param url [String] the `wss://` devtools endpoint
|
|
26
|
+
# @param token [String] a Cloudflare API token with Browser Rendering access
|
|
27
|
+
# @return [Transport] a connected transport
|
|
28
|
+
def create(url, token)
|
|
29
|
+
new(url, token).tap { |transport| transport.connect.wait }
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @param url [String] the `wss://` devtools endpoint
|
|
34
|
+
# @param token [String] a Cloudflare API token with Browser Rendering access
|
|
35
|
+
def initialize(url, token)
|
|
36
|
+
super(url)
|
|
37
|
+
@headers = [["authorization", "Bearer #{token}"]]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# @return [Async::Promise] resolved once the socket is open
|
|
41
|
+
def connect
|
|
42
|
+
return @connect_promise if @connect_promise
|
|
43
|
+
|
|
44
|
+
@connect_promise = Async::Promise.new
|
|
45
|
+
@task = Async do
|
|
46
|
+
Async::WebSocket::Client.connect(@endpoint, headers: @headers) do |connection|
|
|
47
|
+
@connection = connection
|
|
48
|
+
@connected = true
|
|
49
|
+
@connect_promise.resolve(true) unless @connect_promise.resolved?
|
|
50
|
+
send(:receive_loop, connection)
|
|
51
|
+
end
|
|
52
|
+
rescue Async::Stop
|
|
53
|
+
nil # Task stopped on the way out; nothing to clean up here.
|
|
54
|
+
rescue StandardError => error
|
|
55
|
+
@connect_promise.reject(error) unless @connect_promise.resolved?
|
|
56
|
+
close
|
|
57
|
+
ensure
|
|
58
|
+
@connected = false
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
@connect_promise
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "puppeteer"
|
|
6
|
+
require "uri"
|
|
7
|
+
|
|
8
|
+
module Unmagic
|
|
9
|
+
class Browser
|
|
10
|
+
module Driver
|
|
11
|
+
# [Cloudflare Browser
|
|
12
|
+
# Rendering](https://developers.cloudflare.com/browser-rendering/) — no
|
|
13
|
+
# browser runs on your own servers.
|
|
14
|
+
#
|
|
15
|
+
# It's the only driver with two ways in, and it uses both. A one-off page
|
|
16
|
+
# grab goes to the REST API, which renders and returns in one request and
|
|
17
|
+
# holds no session at all. A {Session} connects over CDP to a real remote
|
|
18
|
+
# browser, which is what multi-step work needs — and what occupies one of
|
|
19
|
+
# your account's concurrent slots until it's closed.
|
|
20
|
+
#
|
|
21
|
+
# @example
|
|
22
|
+
# Unmagic::Browser.new(driver: Unmagic::Browser::Driver::Cloudflare.new(
|
|
23
|
+
# account_id: ENV["CLOUDFLARE_ACCOUNT_ID"],
|
|
24
|
+
# token: ENV["CLOUDFLARE_API_TOKEN"],
|
|
25
|
+
# ))
|
|
26
|
+
#
|
|
27
|
+
# @example Both credentials come from the environment
|
|
28
|
+
# Unmagic::Browser.new(driver: :cloudflare)
|
|
29
|
+
class Cloudflare < Base
|
|
30
|
+
# Where the REST rendering endpoints live.
|
|
31
|
+
API_ROOT = "https://api.cloudflare.com/client/v4/accounts/%<account_id>s/browser-rendering"
|
|
32
|
+
|
|
33
|
+
# Cloudflare's own devtools endpoint. Closing the browser rather than
|
|
34
|
+
# merely disconnecting is what frees the concurrency slot immediately;
|
|
35
|
+
# an abandoned session holds one until the service's own idle timeout
|
|
36
|
+
# reaps it.
|
|
37
|
+
CDP_ENDPOINT = "wss://api.cloudflare.com/client/v4/accounts/%<account_id>s/browser-rendering/devtools/browser"
|
|
38
|
+
|
|
39
|
+
# Seconds to wait on a REST render before giving up. Cloudflare's own
|
|
40
|
+
# ceiling is lower than this; the margin is for the round trip.
|
|
41
|
+
REST_TIMEOUT = 60
|
|
42
|
+
|
|
43
|
+
# @return [String] the Cloudflare account the browsers belong to
|
|
44
|
+
attr_reader :account_id
|
|
45
|
+
|
|
46
|
+
# Where the token comes from when it isn't passed, most specific first.
|
|
47
|
+
# `CLOUDFLARE_API_TOKEN` is Cloudflare's own name for it — the one
|
|
48
|
+
# wrangler and their docs use — so it's what a machine already set up
|
|
49
|
+
# for Cloudflare will have. The narrower name wins when both are set, so
|
|
50
|
+
# a token scoped to just Browser Rendering can be kept apart from the
|
|
51
|
+
# account-wide one.
|
|
52
|
+
TOKEN_VARIABLES = ["CLOUDFLARE_BROWSER_RENDERING_TOKEN", "CLOUDFLARE_API_TOKEN"].freeze
|
|
53
|
+
|
|
54
|
+
# @param account_id [String, nil] defaults to `$CLOUDFLARE_ACCOUNT_ID`
|
|
55
|
+
# @param token [String, nil] an API token with Browser Rendering access,
|
|
56
|
+
# defaulting to the first of {TOKEN_VARIABLES} that's set
|
|
57
|
+
# @raise [Error::Misconfigured] when either credential is missing
|
|
58
|
+
def initialize(account_id: nil, token: nil)
|
|
59
|
+
super()
|
|
60
|
+
@account_id = account_id || ENV["CLOUDFLARE_ACCOUNT_ID"]
|
|
61
|
+
@token = token || TOKEN_VARIABLES.filter_map { |name| presence(ENV[name]) }.first
|
|
62
|
+
require_credential(@account_id, "account_id", ["CLOUDFLARE_ACCOUNT_ID"])
|
|
63
|
+
require_credential(@token, "token", TOKEN_VARIABLES)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# @param url [String] the page to render
|
|
67
|
+
# @return [String] the page as Markdown, rendered by Cloudflare
|
|
68
|
+
def markdown(url)
|
|
69
|
+
json_request("markdown", url: url)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# @param url [String] the page to render
|
|
73
|
+
# @return [String] the page's HTML, after JavaScript has run
|
|
74
|
+
def html(url)
|
|
75
|
+
json_request("content", url: url)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# @param url [String] the page to render
|
|
79
|
+
# @param full_page [Boolean] capture the whole scrollable page
|
|
80
|
+
# @param selector [String, nil] capture just the first element matching this CSS
|
|
81
|
+
# @return [String] PNG bytes
|
|
82
|
+
def screenshot(url, full_page: false, selector: nil)
|
|
83
|
+
binary_request(
|
|
84
|
+
"screenshot",
|
|
85
|
+
url: url,
|
|
86
|
+
selector: selector,
|
|
87
|
+
screenshotOptions: { fullPage: full_page, type: "png" },
|
|
88
|
+
)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# @param url [String] the page to render
|
|
92
|
+
# @return [String] PDF bytes
|
|
93
|
+
def pdf(url)
|
|
94
|
+
binary_request("pdf", url: url)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Nothing to tear down: one-offs never open a browser, and every session
|
|
98
|
+
# owns its own.
|
|
99
|
+
#
|
|
100
|
+
# @return [void]
|
|
101
|
+
def close
|
|
102
|
+
nil
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
protected
|
|
106
|
+
|
|
107
|
+
# Cloudflare needs its auth header on the websocket handshake, so the
|
|
108
|
+
# transport has to be built before the connection — and inside a
|
|
109
|
+
# reactor, or its read loop would take over the calling thread. We run
|
|
110
|
+
# our own so both halves live in the same one, then hand back the same
|
|
111
|
+
# kind of thread-safe proxy `Puppeteer.connect` would have.
|
|
112
|
+
def connect_browser
|
|
113
|
+
runner = Puppeteer::ReactorRunner.new
|
|
114
|
+
begin
|
|
115
|
+
browser = runner.sync do
|
|
116
|
+
transport = Transport.create(format(CDP_ENDPOINT, account_id: @account_id), @token)
|
|
117
|
+
Puppeteer.connect(transport: transport)
|
|
118
|
+
end
|
|
119
|
+
rescue StandardError
|
|
120
|
+
runner.close
|
|
121
|
+
raise
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
Puppeteer::ReactorRunner::Proxy.new(runner, browser, owns_runner: true)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Disconnecting alone leaves the remote browser running, idle, holding a
|
|
128
|
+
# concurrency slot until Cloudflare's own timeout reaps it. Closing it
|
|
129
|
+
# gives the slot back now.
|
|
130
|
+
def disconnect_browser(browser)
|
|
131
|
+
browser.close
|
|
132
|
+
rescue StandardError
|
|
133
|
+
nil
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
private
|
|
137
|
+
|
|
138
|
+
def require_credential(value, option, variables)
|
|
139
|
+
return if presence(value)
|
|
140
|
+
|
|
141
|
+
names = variables.map { |name| "$#{name}" }.join(" or ")
|
|
142
|
+
raise Error::Misconfigured,
|
|
143
|
+
"Cloudflare Browser Rendering needs #{option}: pass Cloudflare.new(#{option}: ...) or set #{names}."
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def presence(value)
|
|
147
|
+
value unless value.nil? || value.to_s.strip.empty?
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def json_request(endpoint, **body)
|
|
151
|
+
response = post(endpoint, body)
|
|
152
|
+
payload = parse_json(response)
|
|
153
|
+
raise Error::RenderFailed, "Cloudflare couldn't render #{body[:url]}." unless payload["success"]
|
|
154
|
+
|
|
155
|
+
payload["result"].to_s
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def binary_request(endpoint, **body)
|
|
159
|
+
post(endpoint, body).body
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def post(endpoint, body)
|
|
163
|
+
uri = URI.join("#{format(API_ROOT, account_id: @account_id)}/", endpoint)
|
|
164
|
+
request = Net::HTTP::Post.new(uri)
|
|
165
|
+
request["Authorization"] = "Bearer #{@token}"
|
|
166
|
+
request["Content-Type"] = "application/json"
|
|
167
|
+
request.body = JSON.generate(body.compact)
|
|
168
|
+
|
|
169
|
+
check(perform(request, uri), body[:url])
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def perform(request, uri)
|
|
173
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: true, open_timeout: 10, read_timeout: REST_TIMEOUT) do |http|
|
|
174
|
+
http.request(request)
|
|
175
|
+
end
|
|
176
|
+
rescue Net::OpenTimeout, Net::ReadTimeout => error
|
|
177
|
+
raise Error::TimedOut, "Cloudflare Browser Rendering took too long: #{error.message}"
|
|
178
|
+
rescue SystemCallError, SocketError, OpenSSL::SSL::SSLError, IOError => error
|
|
179
|
+
raise Error::Unreachable, "Couldn't reach Cloudflare Browser Rendering: #{error.message}"
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def check(response, url)
|
|
183
|
+
return response if response.is_a?(Net::HTTPSuccess)
|
|
184
|
+
|
|
185
|
+
case response
|
|
186
|
+
when Net::HTTPTooManyRequests
|
|
187
|
+
raise Error::RateLimited, "Cloudflare Browser Rendering rate limit exceeded."
|
|
188
|
+
when Net::HTTPUnauthorized, Net::HTTPForbidden
|
|
189
|
+
raise Error::Misconfigured, "Cloudflare rejected the Browser Rendering credentials (HTTP #{response.code})."
|
|
190
|
+
else
|
|
191
|
+
raise Error::RenderFailed, failure_message(response, url)
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def failure_message(response, url)
|
|
196
|
+
detail = begin
|
|
197
|
+
JSON.parse(response.body).dig("errors", 0, "message")
|
|
198
|
+
rescue StandardError
|
|
199
|
+
nil
|
|
200
|
+
end
|
|
201
|
+
"Cloudflare couldn't render #{url} (HTTP #{response.code}#{" — #{detail}" if detail})."
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def parse_json(response)
|
|
205
|
+
JSON.parse(response.body)
|
|
206
|
+
rescue JSON::ParserError => error
|
|
207
|
+
raise Error::RenderFailed, "Cloudflare Browser Rendering returned something that isn't JSON: #{error.message}"
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
require_relative "cloudflare/transport"
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Unmagic
|
|
4
|
+
class Browser
|
|
5
|
+
module Driver
|
|
6
|
+
# One live browser, and the driver's promise to shut it down properly.
|
|
7
|
+
#
|
|
8
|
+
# Every {Session} owns exactly one of these — that's what "a kept session
|
|
9
|
+
# holds a real browser, and on Cloudflare one of a limited number of
|
|
10
|
+
# concurrent slots, until you close it" means in code.
|
|
11
|
+
class Connection
|
|
12
|
+
# @param driver [Base] the driver that opened this browser
|
|
13
|
+
# @param browser [Puppeteer::Browser] the connected browser
|
|
14
|
+
def initialize(driver, browser)
|
|
15
|
+
@driver = driver
|
|
16
|
+
@browser = browser
|
|
17
|
+
@closed = false
|
|
18
|
+
@mutex = Mutex.new
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# @return [Puppeteer::Browser] the live browser
|
|
22
|
+
attr_reader :browser
|
|
23
|
+
|
|
24
|
+
# A page to drive.
|
|
25
|
+
#
|
|
26
|
+
# A freshly started browser already has one blank tab open, and opening
|
|
27
|
+
# a second costs most of a second — so the first caller gets the one
|
|
28
|
+
# that's already there.
|
|
29
|
+
#
|
|
30
|
+
# @return [Puppeteer::Page]
|
|
31
|
+
def new_page
|
|
32
|
+
raise Error::SessionClosed, "This browser connection is closed." if closed?
|
|
33
|
+
|
|
34
|
+
Failures.wrap("opening a page") { claim_initial_page || @browser.new_page }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Shut the browser down the way its driver wants: killed, disconnected
|
|
38
|
+
# from, or told to close so a remote slot is freed immediately.
|
|
39
|
+
#
|
|
40
|
+
# @return [void]
|
|
41
|
+
def close
|
|
42
|
+
@mutex.synchronize do
|
|
43
|
+
return if @closed
|
|
44
|
+
|
|
45
|
+
@closed = true
|
|
46
|
+
@driver.send(:disconnect_browser, @browser)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @return [Boolean] whether this connection has been closed
|
|
51
|
+
def closed?
|
|
52
|
+
@closed
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
# The blank tab a new browser starts with, once and only once, and only
|
|
58
|
+
# while it's still blank and alone — anything else and we'd be handing
|
|
59
|
+
# one caller another's page.
|
|
60
|
+
def claim_initial_page
|
|
61
|
+
@mutex.synchronize do
|
|
62
|
+
return nil if @initial_page_claimed
|
|
63
|
+
|
|
64
|
+
@initial_page_claimed = true
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
pages = @browser.pages
|
|
68
|
+
pages.first if pages.size == 1 && pages.first.url == "about:blank"
|
|
69
|
+
rescue StandardError
|
|
70
|
+
nil
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "puppeteer"
|
|
4
|
+
|
|
5
|
+
module Unmagic
|
|
6
|
+
class Browser
|
|
7
|
+
module Driver
|
|
8
|
+
# Chrome on this machine.
|
|
9
|
+
#
|
|
10
|
+
# The development default, and the only driver where you can watch what's
|
|
11
|
+
# happening: `headless: false` opens a real window, which is how a person
|
|
12
|
+
# gets to solve a captcha mid-session and hand control back.
|
|
13
|
+
#
|
|
14
|
+
# @example
|
|
15
|
+
# Unmagic::Browser.new(driver: Unmagic::Browser::Driver::Local.new(headless: false))
|
|
16
|
+
class Local < Base
|
|
17
|
+
# Chrome flags that only make sense for automation. `--no-sandbox` is
|
|
18
|
+
# what lets Chrome run as root inside a container, which is where CI
|
|
19
|
+
# lives.
|
|
20
|
+
DEFAULT_ARGS = [
|
|
21
|
+
"--disable-dev-shm-usage",
|
|
22
|
+
"--no-sandbox",
|
|
23
|
+
"--disable-gpu",
|
|
24
|
+
].freeze
|
|
25
|
+
|
|
26
|
+
# @return [Boolean] whether Chrome runs without a window
|
|
27
|
+
attr_reader :headless
|
|
28
|
+
|
|
29
|
+
# @return [String, nil] the Chrome binary to run, or nil to let Puppeteer find one
|
|
30
|
+
attr_reader :executable_path
|
|
31
|
+
|
|
32
|
+
# @return [Array<String>] the flags Chrome is launched with
|
|
33
|
+
attr_reader :args
|
|
34
|
+
|
|
35
|
+
# @param headless [Boolean] false opens a window you can watch and click
|
|
36
|
+
# @param executable_path [String, nil] a specific Chrome binary
|
|
37
|
+
# @param args [Array<String>] extra Chrome flags, added to {DEFAULT_ARGS}
|
|
38
|
+
# @param slow_mo [Integer, nil] milliseconds to pause between operations, to watch a run
|
|
39
|
+
def initialize(headless: true, executable_path: nil, args: [], slow_mo: nil)
|
|
40
|
+
super()
|
|
41
|
+
@headless = headless
|
|
42
|
+
@executable_path = executable_path
|
|
43
|
+
@args = (DEFAULT_ARGS + args).uniq.freeze
|
|
44
|
+
@slow_mo = slow_mo
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
protected
|
|
48
|
+
|
|
49
|
+
def connect_browser
|
|
50
|
+
Puppeteer.launch(**launch_options)
|
|
51
|
+
rescue StandardError => error
|
|
52
|
+
raise Error::Misconfigured, chrome_missing_message(error) if chrome_missing?(error)
|
|
53
|
+
|
|
54
|
+
raise
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Local Chrome is ours: we started it, so we kill it. Anything less
|
|
58
|
+
# leaves a browser process behind for every session ever opened.
|
|
59
|
+
def disconnect_browser(browser)
|
|
60
|
+
browser.close
|
|
61
|
+
rescue StandardError
|
|
62
|
+
nil
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def launch_options
|
|
68
|
+
{
|
|
69
|
+
headless: @headless,
|
|
70
|
+
args: @args,
|
|
71
|
+
executable_path: @executable_path,
|
|
72
|
+
slow_mo: @slow_mo,
|
|
73
|
+
}.compact
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def chrome_missing?(error)
|
|
77
|
+
error.message.to_s.match?(/Failed to launch|executable|ENOENT|Could not find/i)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def chrome_missing_message(error)
|
|
81
|
+
"Couldn't start local Chrome (#{error.message}). Install Chrome, or point the driver at a " \
|
|
82
|
+
"binary with Unmagic::Browser::Driver::Local.new(executable_path: \"...\")."
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "puppeteer"
|
|
4
|
+
|
|
5
|
+
module Unmagic
|
|
6
|
+
class Browser
|
|
7
|
+
module Driver
|
|
8
|
+
# A browser you already run somewhere else — browserless, a pool of your
|
|
9
|
+
# own, a Chrome started with `--remote-debugging-port`.
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# Unmagic::Browser.new(driver: Unmagic::Browser::Driver::Remote.new(ws: "ws://browserless.internal:3000"))
|
|
13
|
+
class Remote < Base
|
|
14
|
+
# @return [String] the websocket endpoint this driver connects to
|
|
15
|
+
attr_reader :ws
|
|
16
|
+
|
|
17
|
+
# @param ws [String] a CDP websocket URL, e.g. `"ws://browserless.internal:3000"`
|
|
18
|
+
def initialize(ws: nil)
|
|
19
|
+
super()
|
|
20
|
+
@ws = ws || ENV["BROWSER_WS_ENDPOINT"]
|
|
21
|
+
return if @ws
|
|
22
|
+
|
|
23
|
+
raise Error::Misconfigured,
|
|
24
|
+
"The remote driver needs a websocket endpoint: Remote.new(ws: \"ws://...\") or $BROWSER_WS_ENDPOINT."
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
protected
|
|
28
|
+
|
|
29
|
+
def connect_browser
|
|
30
|
+
Puppeteer.connect(browser_ws_endpoint: @ws)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# The browser on the other end isn't ours to kill — it may be a pool
|
|
34
|
+
# serving other callers. We let go of it and leave it running; a service
|
|
35
|
+
# that hands out a browser per connection (browserless does) reaps it
|
|
36
|
+
# itself once we're gone.
|
|
37
|
+
def disconnect_browser(browser)
|
|
38
|
+
browser.disconnect
|
|
39
|
+
rescue StandardError
|
|
40
|
+
nil
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Unmagic
|
|
4
|
+
class Browser
|
|
5
|
+
# The three ways to get a browser: one on this machine, one somewhere else
|
|
6
|
+
# you already run, or Cloudflare's.
|
|
7
|
+
#
|
|
8
|
+
# They're interchangeable — the same {Session} verbs run against all three —
|
|
9
|
+
# so picking one is the only decision, and it's made once, when you
|
|
10
|
+
# construct the browser.
|
|
11
|
+
module Driver
|
|
12
|
+
# What a bare symbol means.
|
|
13
|
+
REGISTRY = {
|
|
14
|
+
local: "Unmagic::Browser::Driver::Local",
|
|
15
|
+
remote: "Unmagic::Browser::Driver::Remote",
|
|
16
|
+
cloudflare: "Unmagic::Browser::Driver::Cloudflare",
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
19
|
+
class << self
|
|
20
|
+
# Coerce whatever a caller passed as `driver:` into a driver.
|
|
21
|
+
#
|
|
22
|
+
# A symbol builds the driver with its defaults; anything else is taken
|
|
23
|
+
# as a driver already, which is how a driver gets options — and how
|
|
24
|
+
# somebody's own driver gets used without inheriting from {Base}.
|
|
25
|
+
#
|
|
26
|
+
# @param value [Symbol, String, Base] `:local`, `:remote`, `:cloudflare`, or an instance
|
|
27
|
+
# @return [Base]
|
|
28
|
+
# @raise [Error::Misconfigured] when the symbol names no known driver
|
|
29
|
+
def build(value)
|
|
30
|
+
return value unless value.is_a?(Symbol) || value.is_a?(String)
|
|
31
|
+
|
|
32
|
+
name = value.to_sym
|
|
33
|
+
class_name = REGISTRY[name]
|
|
34
|
+
unless class_name
|
|
35
|
+
known = REGISTRY.keys.map(&:inspect).join(", ")
|
|
36
|
+
raise Error::Misconfigured, "Unknown driver #{value.inspect}. Known drivers are #{known}."
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
Object.const_get(class_name).new
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Remember a driver so its shared one-off connection is torn down when
|
|
43
|
+
# the process ends. Registering is idempotent, and the hook is installed
|
|
44
|
+
# only once a driver has actually opened a browser — a program that only
|
|
45
|
+
# ever talks to Cloudflare's REST API never installs one.
|
|
46
|
+
#
|
|
47
|
+
# @param driver [Base]
|
|
48
|
+
# @return [void]
|
|
49
|
+
def close_at_exit(driver)
|
|
50
|
+
@registry_mutex ||= Mutex.new
|
|
51
|
+
@registry_mutex.synchronize do
|
|
52
|
+
@registry ||= []
|
|
53
|
+
return if @registry.include?(driver)
|
|
54
|
+
|
|
55
|
+
install_at_exit_hook unless @at_exit_installed
|
|
56
|
+
@at_exit_installed = true
|
|
57
|
+
@registry << driver
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def install_at_exit_hook
|
|
64
|
+
at_exit do
|
|
65
|
+
@registry.each do |driver|
|
|
66
|
+
driver.close
|
|
67
|
+
rescue StandardError
|
|
68
|
+
nil # Exiting anyway; a browser we can't reach is already gone.
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|