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.
@@ -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