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,257 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Unmagic
|
|
4
|
+
class Browser
|
|
5
|
+
# One element on the page, found by a {Locator}.
|
|
6
|
+
#
|
|
7
|
+
# Everything a {Session} verb does to a single element goes through here, so
|
|
8
|
+
# `session.click_button "Go"` and `session.find("#go").click` take exactly
|
|
9
|
+
# the same path. Like a session, an element serialises its operations
|
|
10
|
+
# against the session it came from.
|
|
11
|
+
#
|
|
12
|
+
# @example
|
|
13
|
+
# session.all(".product").map { |element| element.text }
|
|
14
|
+
class Element
|
|
15
|
+
# @param session [Session] the session this element was found in
|
|
16
|
+
# @param handle [Puppeteer::ElementHandle] the live handle in the page
|
|
17
|
+
def initialize(session, handle)
|
|
18
|
+
@session = session
|
|
19
|
+
@handle = handle
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# The live Puppeteer handle, for work this API doesn't wrap.
|
|
23
|
+
#
|
|
24
|
+
# @return [Puppeteer::ElementHandle]
|
|
25
|
+
attr_reader :handle
|
|
26
|
+
alias_method :driver, :handle
|
|
27
|
+
|
|
28
|
+
# @return [String] the element's visible text, whitespace collapsed
|
|
29
|
+
def text
|
|
30
|
+
evaluate("element => (element.innerText === undefined ? element.textContent : element.innerText)")
|
|
31
|
+
.to_s.gsub(/\s+/, " ").strip
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# @return [String] the element's own HTML, tag included
|
|
35
|
+
def html
|
|
36
|
+
evaluate("element => element.outerHTML")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @return [String] the element's inner HTML
|
|
40
|
+
def inner_html
|
|
41
|
+
evaluate("element => element.innerHTML")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# @return [String] the lowercased tag name, e.g. `"input"`
|
|
45
|
+
def tag_name
|
|
46
|
+
evaluate("element => element.tagName.toLowerCase()")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# The element's current value — what's typed into a field, what's selected
|
|
50
|
+
# in a `<select>`, or nil for anything without one.
|
|
51
|
+
#
|
|
52
|
+
# @return [String, nil]
|
|
53
|
+
def value
|
|
54
|
+
evaluate("element => ('value' in element ? element.value : null)")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# @param name [String, Symbol] the attribute name
|
|
58
|
+
# @return [String, nil] the attribute's value, or nil when it isn't set
|
|
59
|
+
def attribute(name)
|
|
60
|
+
evaluate("(element, name) => element.getAttribute(name)", name.to_s)
|
|
61
|
+
end
|
|
62
|
+
alias_method :[], :attribute
|
|
63
|
+
|
|
64
|
+
# @return [Boolean] whether the element is rendered and not hidden
|
|
65
|
+
def visible?
|
|
66
|
+
evaluate(<<~JAVASCRIPT)
|
|
67
|
+
(element) => {
|
|
68
|
+
if (element.hidden) return false;
|
|
69
|
+
const style = getComputedStyle(element);
|
|
70
|
+
if (style.visibility === "hidden" || style.display === "none") return false;
|
|
71
|
+
return element.getClientRects().length > 0;
|
|
72
|
+
}
|
|
73
|
+
JAVASCRIPT
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# @return [Boolean] whether a checkbox or radio is ticked
|
|
77
|
+
def checked?
|
|
78
|
+
!!evaluate("element => !!element.checked")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# @return [Boolean] whether the element is disabled
|
|
82
|
+
def disabled?
|
|
83
|
+
!!evaluate("element => !!element.disabled")
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Click the element, scrolling it into view first.
|
|
87
|
+
#
|
|
88
|
+
# @return [self]
|
|
89
|
+
def click
|
|
90
|
+
act do
|
|
91
|
+
@handle.scroll_into_view_if_needed
|
|
92
|
+
@handle.click
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Move the pointer over the element.
|
|
97
|
+
#
|
|
98
|
+
# @return [self]
|
|
99
|
+
def hover
|
|
100
|
+
act do
|
|
101
|
+
@handle.scroll_into_view_if_needed
|
|
102
|
+
@handle.hover
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Replace whatever's in the field with +value+.
|
|
107
|
+
#
|
|
108
|
+
# The existing content is selected and deleted rather than assigned over,
|
|
109
|
+
# so frameworks that watch for real key events (React and friends) see the
|
|
110
|
+
# change.
|
|
111
|
+
#
|
|
112
|
+
# @param value [String] the text to type
|
|
113
|
+
# @return [self]
|
|
114
|
+
def fill_in(value)
|
|
115
|
+
act do
|
|
116
|
+
@handle.scroll_into_view_if_needed
|
|
117
|
+
@handle.focus
|
|
118
|
+
@handle.evaluate(<<~JAVASCRIPT)
|
|
119
|
+
(element) => {
|
|
120
|
+
if (element.select) { element.select(); return; }
|
|
121
|
+
const range = document.createRange();
|
|
122
|
+
range.selectNodeContents(element);
|
|
123
|
+
const selection = getSelection();
|
|
124
|
+
selection.removeAllRanges();
|
|
125
|
+
selection.addRange(range);
|
|
126
|
+
}
|
|
127
|
+
JAVASCRIPT
|
|
128
|
+
@handle.press("Backspace")
|
|
129
|
+
@handle.type_text(value.to_s) unless value.to_s.empty?
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Pick an option in a `<select>` by its visible label, or failing that its
|
|
134
|
+
# value — a caller reading the page sees labels, not values.
|
|
135
|
+
#
|
|
136
|
+
# @param option [String] the option's label or value
|
|
137
|
+
# @return [self]
|
|
138
|
+
# @raise [Error::NotFound] when no option matches
|
|
139
|
+
def select_option(option)
|
|
140
|
+
act do
|
|
141
|
+
found = @handle.evaluate(<<~JAVASCRIPT, option.to_s)
|
|
142
|
+
(element, wanted) => {
|
|
143
|
+
if (element.tagName.toLowerCase() !== "select") throw new Error("not a <select>");
|
|
144
|
+
const norm = (value) => (value || "").replace(/\\s+/g, " ").trim().toLowerCase();
|
|
145
|
+
const needle = norm(wanted);
|
|
146
|
+
const options = Array.from(element.options);
|
|
147
|
+
const match = options.find((option) => norm(option.label || option.textContent) === needle) ||
|
|
148
|
+
options.find((option) => norm(option.value) === needle) ||
|
|
149
|
+
options.find((option) => norm(option.label || option.textContent).indexOf(needle) !== -1);
|
|
150
|
+
if (!match) return false;
|
|
151
|
+
if (element.multiple) { match.selected = true; } else { element.value = match.value; }
|
|
152
|
+
element.dispatchEvent(new Event("input", { bubbles: true }));
|
|
153
|
+
element.dispatchEvent(new Event("change", { bubbles: true }));
|
|
154
|
+
return true;
|
|
155
|
+
}
|
|
156
|
+
JAVASCRIPT
|
|
157
|
+
raise Error::NotFound, "No option matching #{option.inspect} in this select." unless found
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Tick a checkbox, or leave it ticked if it already is.
|
|
162
|
+
#
|
|
163
|
+
# @return [self]
|
|
164
|
+
def check
|
|
165
|
+
set_checked(true)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Untick a checkbox, or leave it unticked if it already is.
|
|
169
|
+
#
|
|
170
|
+
# @return [self]
|
|
171
|
+
def uncheck
|
|
172
|
+
set_checked(false)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Attach files to a file input.
|
|
176
|
+
#
|
|
177
|
+
# @param paths [Array<String>] paths to the files
|
|
178
|
+
# @return [self]
|
|
179
|
+
def attach(*paths)
|
|
180
|
+
act { @handle.upload_file(*paths.flatten.map(&:to_s)) }
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Press a key with this element focused.
|
|
184
|
+
#
|
|
185
|
+
# @param key [String, Symbol] a key name, e.g. `:enter` or `"ArrowDown"`
|
|
186
|
+
# @return [self]
|
|
187
|
+
def press(key)
|
|
188
|
+
act { @handle.press(Session.key_name(key)) }
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# @return [String] PNG bytes of just this element
|
|
192
|
+
def screenshot
|
|
193
|
+
@session.synchronize do
|
|
194
|
+
@handle.scroll_into_view_if_needed
|
|
195
|
+
@handle.screenshot(type: "png")
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Find one element inside this one. Same locator rules as {Session#find}.
|
|
200
|
+
#
|
|
201
|
+
# @param query [String, nil] see {Locator.build}
|
|
202
|
+
# @param options [Hash] see {Locator.build}
|
|
203
|
+
# @return [Element]
|
|
204
|
+
# @raise [Error::NotFound] when nothing matches in time
|
|
205
|
+
def find(query = nil, **options)
|
|
206
|
+
@session.find(query, scope: self, **options)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Find every element inside this one. Same locator rules as {Session#all}.
|
|
210
|
+
#
|
|
211
|
+
# @param query [String, nil] see {Locator.build}
|
|
212
|
+
# @param options [Hash] see {Locator.build}
|
|
213
|
+
# @return [Array<Element>]
|
|
214
|
+
def all(query = nil, **options)
|
|
215
|
+
@session.all(query, scope: self, **options)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Run JavaScript with this element as the first argument.
|
|
219
|
+
#
|
|
220
|
+
# @param script [String] a JavaScript function body, e.g. `"el => el.dataset.id"`
|
|
221
|
+
# @param args [Array] extra arguments passed after the element
|
|
222
|
+
# @return [Object] whatever the script returns, as a Ruby value
|
|
223
|
+
def evaluate(script, *args)
|
|
224
|
+
@session.synchronize { @handle.evaluate(script, *args) }
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Release the handle. Elements found by a session are disposed with the
|
|
228
|
+
# page, so this is only worth calling in a long loop.
|
|
229
|
+
#
|
|
230
|
+
# @return [void]
|
|
231
|
+
def dispose
|
|
232
|
+
@session.synchronize { @handle.dispose }
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# @return [String] the element and the handle behind it
|
|
236
|
+
def inspect
|
|
237
|
+
"#<#{self.class.name} #{@handle.inspect}>"
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
private
|
|
241
|
+
|
|
242
|
+
def set_checked(wanted)
|
|
243
|
+
act do
|
|
244
|
+
@handle.scroll_into_view_if_needed
|
|
245
|
+
@handle.click if @handle.evaluate("element => !!element.checked") != wanted
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Every action returns the element so calls chain, and every action holds
|
|
250
|
+
# the session lock so two callers can't interleave a keystroke.
|
|
251
|
+
def act
|
|
252
|
+
@session.synchronize { yield }
|
|
253
|
+
self
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
end
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "puppeteer"
|
|
4
|
+
|
|
5
|
+
module Unmagic
|
|
6
|
+
class Browser
|
|
7
|
+
# Everything about *how* a page renders — colour scheme, viewport, device,
|
|
8
|
+
# locale, timezone, geolocation, media type — in one immutable value object.
|
|
9
|
+
#
|
|
10
|
+
# It's the only place emulation lives, and it's set in exactly two places:
|
|
11
|
+
# passed to {Unmagic::Browser#open}, or changed live with
|
|
12
|
+
# {Session#emulate}.
|
|
13
|
+
#
|
|
14
|
+
# Anything left unset renders at a *fixed* default — a plain desktop
|
|
15
|
+
# viewport, light, screen media — rather than at whatever the machine
|
|
16
|
+
# underneath happens to prefer. That's what makes re-applying an older
|
|
17
|
+
# Emulation a clean revert rather than a patch, and what stops the same page
|
|
18
|
+
# rendering differently on a laptop and on CI.
|
|
19
|
+
#
|
|
20
|
+
# @example
|
|
21
|
+
# Unmagic::Browser::Emulation.new(color_scheme: :dark, device: "iPhone 13")
|
|
22
|
+
class Emulation
|
|
23
|
+
# What a page looks like with nothing emulated. Applied for any viewport
|
|
24
|
+
# key the caller leaves out, so reverting is always total.
|
|
25
|
+
DEFAULT_VIEWPORT = {
|
|
26
|
+
width: 1280,
|
|
27
|
+
height: 800,
|
|
28
|
+
scale: 1,
|
|
29
|
+
mobile: false,
|
|
30
|
+
touch: false,
|
|
31
|
+
}.freeze
|
|
32
|
+
|
|
33
|
+
# What `prefers-color-scheme` can be told to report.
|
|
34
|
+
COLOR_SCHEMES = [:light, :dark, :no_preference].freeze
|
|
35
|
+
|
|
36
|
+
# What `prefers-reduced-motion` can be told to report.
|
|
37
|
+
REDUCED_MOTIONS = [:reduce, :no_preference].freeze
|
|
38
|
+
|
|
39
|
+
# What the page can be told it's being rendered for.
|
|
40
|
+
MEDIA_TYPES = [:screen, :print].freeze
|
|
41
|
+
|
|
42
|
+
# Present as a current desktop Chrome rather than as a headless build:
|
|
43
|
+
# pages render the way a real visitor sees them, and modern-browser gates
|
|
44
|
+
# let us through.
|
|
45
|
+
DEFAULT_USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " \
|
|
46
|
+
"(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
|
|
47
|
+
|
|
48
|
+
class << self
|
|
49
|
+
# Coerce whatever a caller passed as `emulate:` into an Emulation.
|
|
50
|
+
#
|
|
51
|
+
# @param value [Emulation, Hash, nil] an instance, a hash of knobs, or nothing
|
|
52
|
+
# @return [Emulation]
|
|
53
|
+
# @raise [ArgumentError] when +value+ is neither
|
|
54
|
+
def build(value)
|
|
55
|
+
case value
|
|
56
|
+
when nil then new
|
|
57
|
+
when Emulation then value
|
|
58
|
+
when Hash then new(**value)
|
|
59
|
+
else
|
|
60
|
+
raise ArgumentError, "emulate: expects an Unmagic::Browser::Emulation or a Hash, got #{value.class}"
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
attr_reader :color_scheme, :reduced_motion, :viewport, :device, :user_agent,
|
|
66
|
+
:locale, :timezone, :geolocation, :media
|
|
67
|
+
|
|
68
|
+
# @param color_scheme [Symbol, nil] `:light`, `:dark` or `:no_preference`
|
|
69
|
+
# @param reduced_motion [Symbol, nil] `:reduce` or `:no_preference`
|
|
70
|
+
# @param viewport [Hash, nil] `width:`, `height:`, `scale:`, `mobile:`, `touch:`
|
|
71
|
+
# @param device [String, nil] a Puppeteer device name, e.g. `"iPhone 13"`
|
|
72
|
+
# @param user_agent [String, nil] overrides the device's own user agent
|
|
73
|
+
# @param locale [String, nil] a BCP 47 tag, sent as `Accept-Language`
|
|
74
|
+
# @param timezone [String, nil] an IANA zone, e.g. `"Australia/Sydney"`
|
|
75
|
+
# @param geolocation [Hash, nil] `latitude:`, `longitude:` and optionally `accuracy:`
|
|
76
|
+
# @param media [Symbol, nil] `:screen` or `:print`
|
|
77
|
+
def initialize(color_scheme: nil, reduced_motion: nil, viewport: nil, device: nil,
|
|
78
|
+
user_agent: nil, locale: nil, timezone: nil, geolocation: nil, media: nil)
|
|
79
|
+
@color_scheme = validate_symbol(:color_scheme, color_scheme, COLOR_SCHEMES)
|
|
80
|
+
@reduced_motion = validate_symbol(:reduced_motion, reduced_motion, REDUCED_MOTIONS)
|
|
81
|
+
@media = validate_symbol(:media, media, MEDIA_TYPES)
|
|
82
|
+
@viewport = viewport&.transform_keys(&:to_sym)&.freeze
|
|
83
|
+
@device = device
|
|
84
|
+
@user_agent = user_agent
|
|
85
|
+
@locale = locale
|
|
86
|
+
@timezone = timezone
|
|
87
|
+
@geolocation = geolocation&.transform_keys(&:to_sym)&.freeze
|
|
88
|
+
validate_viewport!
|
|
89
|
+
validate_device!
|
|
90
|
+
freeze
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# A copy with +other+'s settings layered on top. Only keys the other side
|
|
94
|
+
# actually set are overridden, so `emulate(color_scheme: :dark)` on a
|
|
95
|
+
# session that's already emulating a phone keeps the phone.
|
|
96
|
+
#
|
|
97
|
+
# @param other [Emulation, Hash, nil]
|
|
98
|
+
# @return [Emulation]
|
|
99
|
+
def merge(other)
|
|
100
|
+
other = self.class.build(other)
|
|
101
|
+
self.class.new(**to_h.merge(other.to_h) { |_key, mine, theirs| theirs.nil? ? mine : theirs })
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# @return [Hash] the knobs, unset ones included as nil
|
|
105
|
+
def to_h
|
|
106
|
+
{
|
|
107
|
+
color_scheme: @color_scheme,
|
|
108
|
+
reduced_motion: @reduced_motion,
|
|
109
|
+
viewport: @viewport,
|
|
110
|
+
device: @device,
|
|
111
|
+
user_agent: @user_agent,
|
|
112
|
+
locale: @locale,
|
|
113
|
+
timezone: @timezone,
|
|
114
|
+
geolocation: @geolocation,
|
|
115
|
+
media: @media,
|
|
116
|
+
}
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# @return [Boolean] whether every knob is at Chrome's default
|
|
120
|
+
def default?
|
|
121
|
+
to_h.values.all?(&:nil?)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# @param other [Object]
|
|
125
|
+
# @return [Boolean] whether the other emulation has the same settings
|
|
126
|
+
def ==(other)
|
|
127
|
+
other.is_a?(Emulation) && other.to_h == to_h
|
|
128
|
+
end
|
|
129
|
+
alias_method :eql?, :==
|
|
130
|
+
|
|
131
|
+
# @return [Integer] a hash of the settings, so an emulation works as a hash key
|
|
132
|
+
def hash
|
|
133
|
+
to_h.hash
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Push the whole configuration onto a live page. Every knob is sent every
|
|
137
|
+
# time — including the ones left unset, which are sent as their defaults —
|
|
138
|
+
# so applying an Emulation always lands the page in exactly this state,
|
|
139
|
+
# whatever it was in before.
|
|
140
|
+
#
|
|
141
|
+
# @param page [Puppeteer::Page]
|
|
142
|
+
# @return [void]
|
|
143
|
+
def apply(page)
|
|
144
|
+
page.viewport = resolved_viewport
|
|
145
|
+
page.user_agent = resolved_user_agent
|
|
146
|
+
page.emulate_locale(@locale)
|
|
147
|
+
page.emulate_timezone(@timezone)
|
|
148
|
+
apply_geolocation(page)
|
|
149
|
+
apply_media(page)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# The viewport actually sent to Chrome: the device's, with any explicit
|
|
153
|
+
# `viewport:` keys layered over it, over the plain-desktop default.
|
|
154
|
+
#
|
|
155
|
+
# @return [Puppeteer::Viewport]
|
|
156
|
+
def resolved_viewport
|
|
157
|
+
base = DEFAULT_VIEWPORT.merge(device_viewport).merge(@viewport || {})
|
|
158
|
+
Puppeteer::Viewport.new(
|
|
159
|
+
width: base[:width],
|
|
160
|
+
height: base[:height],
|
|
161
|
+
device_scale_factor: base[:scale] || 1,
|
|
162
|
+
is_mobile: !!base[:mobile],
|
|
163
|
+
has_touch: !!base[:touch],
|
|
164
|
+
is_landscape: base[:width] > base[:height],
|
|
165
|
+
)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# @return [String] the user agent sent to Chrome
|
|
169
|
+
def resolved_user_agent
|
|
170
|
+
@user_agent || puppeteer_device&.user_agent || DEFAULT_USER_AGENT
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
private
|
|
174
|
+
|
|
175
|
+
def apply_geolocation(page)
|
|
176
|
+
if @geolocation
|
|
177
|
+
page.geolocation = Puppeteer::Geolocation.new(**@geolocation)
|
|
178
|
+
else
|
|
179
|
+
page.client.send_message("Emulation.clearGeolocationOverride")
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Media type and media features share one CDP command, and each omitted
|
|
184
|
+
# argument resets the other — so they have to go out together rather than
|
|
185
|
+
# through Puppeteer's two separate setters.
|
|
186
|
+
#
|
|
187
|
+
# Unset features are pinned to a value rather than left off. Left off,
|
|
188
|
+
# Chrome falls back to the *host machine's* appearance, so the same page
|
|
189
|
+
# would render light on CI and dark on a Mac after sunset. A rendering
|
|
190
|
+
# library that does that isn't one you can screenshot with.
|
|
191
|
+
def apply_media(page)
|
|
192
|
+
page.client.send_message(
|
|
193
|
+
"Emulation.setEmulatedMedia",
|
|
194
|
+
media: (@media || :screen).to_s,
|
|
195
|
+
features: [
|
|
196
|
+
feature("prefers-color-scheme", @color_scheme || :light),
|
|
197
|
+
feature("prefers-reduced-motion", @reduced_motion || :no_preference),
|
|
198
|
+
],
|
|
199
|
+
)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def feature(name, value)
|
|
203
|
+
{ name: name, value: value.to_s.tr("_", "-") }
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def device_viewport
|
|
207
|
+
viewport = puppeteer_device&.viewport
|
|
208
|
+
return {} unless viewport
|
|
209
|
+
|
|
210
|
+
{
|
|
211
|
+
width: viewport.width,
|
|
212
|
+
height: viewport.height,
|
|
213
|
+
scale: viewport.device_scale_factor,
|
|
214
|
+
mobile: viewport.mobile?,
|
|
215
|
+
touch: viewport.has_touch?,
|
|
216
|
+
}
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def puppeteer_device
|
|
220
|
+
return nil unless @device
|
|
221
|
+
|
|
222
|
+
Puppeteer::DEVICES[@device]
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def validate_symbol(name, value, allowed)
|
|
226
|
+
return nil if value.nil?
|
|
227
|
+
|
|
228
|
+
symbol = value.to_sym
|
|
229
|
+
return symbol if allowed.include?(symbol)
|
|
230
|
+
|
|
231
|
+
raise ArgumentError, "#{name}: expects one of #{allowed.map(&:inspect).join(", ")}, got #{value.inspect}"
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def validate_viewport!
|
|
235
|
+
return if @viewport.nil?
|
|
236
|
+
|
|
237
|
+
unknown = @viewport.keys - DEFAULT_VIEWPORT.keys
|
|
238
|
+
raise ArgumentError, "viewport: unknown key(s) #{unknown.map(&:inspect).join(", ")}" if unknown.any?
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def validate_device!
|
|
242
|
+
return if @device.nil? || Puppeteer::DEVICES.key?(@device)
|
|
243
|
+
|
|
244
|
+
raise ArgumentError, "device: #{@device.inspect} isn't a device Puppeteer knows about"
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Unmagic
|
|
4
|
+
class Browser
|
|
5
|
+
# The base class every failure in this gem inherits from.
|
|
6
|
+
#
|
|
7
|
+
# Failures are typed so callers classify them by rescuing, never by parsing
|
|
8
|
+
# a message — messages carry target URLs and service internals that aren't
|
|
9
|
+
# a stable contract.
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# begin
|
|
13
|
+
# browser.markdown(url)
|
|
14
|
+
# rescue Unmagic::Browser::Error::TimedOut
|
|
15
|
+
# # the page took too long
|
|
16
|
+
# rescue Unmagic::Browser::Error
|
|
17
|
+
# # anything else
|
|
18
|
+
# end
|
|
19
|
+
class Error < StandardError
|
|
20
|
+
# The page, or an element on it, took too long.
|
|
21
|
+
class TimedOut < Error; end
|
|
22
|
+
|
|
23
|
+
# Couldn't reach the page or the rendering service.
|
|
24
|
+
class Unreachable < Error; end
|
|
25
|
+
|
|
26
|
+
# The service answered, but couldn't render the page.
|
|
27
|
+
class RenderFailed < Error; end
|
|
28
|
+
|
|
29
|
+
# A concurrency or rate limit was hit.
|
|
30
|
+
class RateLimited < Error; end
|
|
31
|
+
|
|
32
|
+
# Credentials or configuration are missing.
|
|
33
|
+
class Misconfigured < Error; end
|
|
34
|
+
|
|
35
|
+
# Nothing on the page matched a locator before it gave up. A subclass of
|
|
36
|
+
# {TimedOut} because locators auto-wait: "not found" always means "still
|
|
37
|
+
# not there when we ran out of patience".
|
|
38
|
+
class NotFound < TimedOut; end
|
|
39
|
+
|
|
40
|
+
# The session has been closed, so it can't be driven any more.
|
|
41
|
+
class SessionClosed < Error; end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "puppeteer"
|
|
4
|
+
require "socket"
|
|
5
|
+
|
|
6
|
+
module Unmagic
|
|
7
|
+
class Browser
|
|
8
|
+
# The one place a Puppeteer, socket or HTTP failure becomes an
|
|
9
|
+
# {Unmagic::Browser::Error}.
|
|
10
|
+
#
|
|
11
|
+
# Callers classify failures by rescuing, so every path out of this gem has
|
|
12
|
+
# to raise one of our types — including the ones that come out of
|
|
13
|
+
# puppeteer-ruby, three gems deep, as a bare `Puppeteer::Error`.
|
|
14
|
+
module Failures
|
|
15
|
+
# Chrome reports a failed navigation as a `net::ERR_*` protocol message
|
|
16
|
+
# rather than as a distinct error class, so the message is the only signal
|
|
17
|
+
# separating "couldn't reach the page" from "the browser broke".
|
|
18
|
+
UNREACHABLE_PATTERN = /net::ERR_|ERR_NAME_NOT_RESOLVED|ERR_CONNECTION|ERR_ADDRESS|ERR_INTERNET_DISCONNECTED/
|
|
19
|
+
|
|
20
|
+
# Anything that means the socket underneath went away.
|
|
21
|
+
TRANSPORT_ERRORS = [
|
|
22
|
+
IOError,
|
|
23
|
+
SocketError,
|
|
24
|
+
Errno::ECONNRESET,
|
|
25
|
+
Errno::ECONNREFUSED,
|
|
26
|
+
Errno::EPIPE,
|
|
27
|
+
Errno::EHOSTUNREACH,
|
|
28
|
+
Errno::ENETUNREACH,
|
|
29
|
+
Errno::ETIMEDOUT,
|
|
30
|
+
].freeze
|
|
31
|
+
|
|
32
|
+
module_function
|
|
33
|
+
|
|
34
|
+
# Run +block+, re-raising anything it throws as a typed error.
|
|
35
|
+
#
|
|
36
|
+
# @param doing [String] what was being attempted, for the message
|
|
37
|
+
# @yield the work to guard
|
|
38
|
+
# @return [Object] whatever the block returns
|
|
39
|
+
# @raise [Unmagic::Browser::Error]
|
|
40
|
+
def wrap(doing)
|
|
41
|
+
yield
|
|
42
|
+
rescue Error
|
|
43
|
+
# Already one of ours — a locator that gave up, say. Leave it alone.
|
|
44
|
+
raise
|
|
45
|
+
rescue Puppeteer::TimeoutError => error
|
|
46
|
+
raise Error::TimedOut, "Timed out #{doing}: #{error.message}"
|
|
47
|
+
rescue *TRANSPORT_ERRORS => error
|
|
48
|
+
raise Error::Unreachable, "Couldn't reach the browser while #{doing}: #{error.message}"
|
|
49
|
+
rescue Puppeteer::Error => error
|
|
50
|
+
raise Error::Unreachable, "Couldn't reach the page while #{doing}: #{error.message}" if unreachable?(error)
|
|
51
|
+
|
|
52
|
+
raise Error::RenderFailed, "The browser failed while #{doing}: #{error.message}"
|
|
53
|
+
rescue StandardError => error
|
|
54
|
+
raise Error::RateLimited, "Hit a rate or concurrency limit while #{doing}." if rate_limited?(error)
|
|
55
|
+
|
|
56
|
+
raise
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# @param error [Exception]
|
|
60
|
+
# @return [Boolean] whether the error is Chrome refusing to load a URL
|
|
61
|
+
def unreachable?(error)
|
|
62
|
+
UNREACHABLE_PATTERN.match?(error.message.to_s)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# A rejected WebSocket handshake carries its HTTP status on the exception
|
|
66
|
+
# rather than in the class, and 429 there means a quota — worth retrying
|
|
67
|
+
# on a cooldown — not a dead network.
|
|
68
|
+
#
|
|
69
|
+
# @param error [Exception]
|
|
70
|
+
# @return [Boolean] whether the error is a quota rejection
|
|
71
|
+
def rate_limited?(error)
|
|
72
|
+
error.respond_to?(:response) && error.response.respond_to?(:status) && error.response&.status == 429
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|