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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 1f442f73a1d9529c04c844c02cae4d3baeef123ae4ed73abe666f2de077d555c
|
|
4
|
+
data.tar.gz: b3b0f32e366f5c361fff8f883ace120af199b30d0ac8c6354d42993fe5a1f9e8
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 0bcd7c33a2504e7f91e565ae10099f040eb2f764f29cafa5d8cd7f890ca6d2241031a7ee820b0bc530766d94d4ba173ecbdc3afd660a55bef813dfb4f4663d6b
|
|
7
|
+
data.tar.gz: 916197bf8151333633b6a7a66682393dda30ccd01cc321ba330b9cf4ba6ad27b33b047821d6c20cbc8c1dbbaf2dc0ac1067242293040fa92aacab04f25f262eb
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.1.0]
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **One-off page grabs** — `markdown`, `html`, `screenshot` and `pdf` straight on
|
|
14
|
+
the browser, each taking the cheapest path its driver offers: Cloudflare's REST
|
|
15
|
+
rendering API where there is one, a throwaway page on a shared browser otherwise.
|
|
16
|
+
- **Sessions** — `browser.open(url)` for a live page driven step by step, with
|
|
17
|
+
Capybara-flavoured verbs (`fill_in`, `click_button`, `select`, `check`,
|
|
18
|
+
`within`, `wait_for`, …) that auto-wait for their element.
|
|
19
|
+
- **Smart locators** — a plain string is resolved by label, then visible text,
|
|
20
|
+
then ARIA role, falling back to CSS, so a caller that has never seen the page's
|
|
21
|
+
markup can still drive it. `css:`, `text:`, `label:` and `role:` name a
|
|
22
|
+
strategy explicitly.
|
|
23
|
+
- **Sessions that outlive one call** — `open` without a block hands the session
|
|
24
|
+
back to keep, so an agent can open a page in one turn and act on it in the next.
|
|
25
|
+
Operations are serialised, and `#lock` makes a whole sequence atomic.
|
|
26
|
+
- **Emulation** — colour scheme, reduced motion, viewport, device, user agent,
|
|
27
|
+
locale, timezone, geolocation and media type in one `emulate:` config, settable
|
|
28
|
+
when a session opens, live with `session.emulate`, or scoped to a block. What
|
|
29
|
+
you leave out renders at a fixed default rather than at the host machine's
|
|
30
|
+
preference, so a page looks the same on a laptop and on CI.
|
|
31
|
+
- **Three drivers** — `Local` (Chrome on this machine, `headless: false` to watch
|
|
32
|
+
it), `Remote` (any CDP websocket — browserless, your own pool) and `Cloudflare`
|
|
33
|
+
(Browser Rendering, so no browser runs on your servers).
|
|
34
|
+
- **Typed errors** — `TimedOut`, `Unreachable`, `RenderFailed`, `RateLimited`,
|
|
35
|
+
`Misconfigured`, `NotFound` and `SessionClosed`, so failures are classified by
|
|
36
|
+
rescuing rather than by parsing a message.
|
|
37
|
+
- **An escape hatch** — `browser.driver` and `session.driver` hand back the
|
|
38
|
+
underlying driver and the live `Puppeteer::Page` for anything the curated API
|
|
39
|
+
doesn't wrap.
|
|
40
|
+
- **A console** — `require "unmagic/browser/console"` and
|
|
41
|
+
`Unmagic::Browser::Console.start` for a browser you can type at: the verbs as
|
|
42
|
+
bare words (`open "https://example.com"; click "Sign in"; screenshot`) driving
|
|
43
|
+
one current session. Results are rendered rather than inspected — source
|
|
44
|
+
syntax-highlighted with [rouge](https://github.com/rouge-ruby/rouge),
|
|
45
|
+
screenshots drawn in the terminal over the [kitty graphics
|
|
46
|
+
protocol](https://sw.kovidgoyal.net/kitty/graphics-protocol/) at half its
|
|
47
|
+
width (`Graphics.scale` to change that) and written full-size to /tmp,
|
|
48
|
+
sessions and elements summarised. `start(prompt: :page)` for a prompt that
|
|
49
|
+
says what's open — a green dot and the page's title — instead of `browser>`.
|
|
50
|
+
Not required by default, and neither rouge nor a graphics-capable terminal is
|
|
51
|
+
a dependency — without them it runs monochrome and prints paths.
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Keith Pitt
|
|
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 all
|
|
13
|
+
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 THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
# Unmagic::Browser
|
|
2
|
+
|
|
3
|
+
A real browser you can drive from Ruby — for one-off page grabs *and* multi-step
|
|
4
|
+
automation. It runs locally in development and against [Cloudflare Browser
|
|
5
|
+
Rendering](https://developers.cloudflare.com/browser-rendering/) (or any remote Puppeteer
|
|
6
|
+
endpoint) in production, behind one API. Built so an LLM agent — or plain Ruby code — can
|
|
7
|
+
browse the web the way a person does.
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
browser = Unmagic::Browser.new
|
|
11
|
+
|
|
12
|
+
# one-off: grab a JS-rendered page as Markdown
|
|
13
|
+
browser.markdown("https://example.com")
|
|
14
|
+
|
|
15
|
+
# multi-step: log in, search, read the result
|
|
16
|
+
browser.open("https://news.ycombinator.com/login") do |session|
|
|
17
|
+
session.fill_in "acct", with: "pg"
|
|
18
|
+
session.fill_in "pw", with: ENV["HN_PASSWORD"]
|
|
19
|
+
session.click_button "login"
|
|
20
|
+
session.wait_for text: "logout"
|
|
21
|
+
session.screenshot
|
|
22
|
+
end
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Installation
|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
gem "unmagic-browser"
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Bundler requires it for you. Outside Bundler, the gem's name maps to its path in the usual
|
|
32
|
+
way:
|
|
33
|
+
|
|
34
|
+
```ruby
|
|
35
|
+
require "unmagic/browser"
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Drivers
|
|
39
|
+
|
|
40
|
+
The same API runs against three drivers. Pick one when you construct the browser; nothing
|
|
41
|
+
else in your code changes. A symbol builds the driver with its defaults — when a driver
|
|
42
|
+
needs options, construct it yourself and pass the instance.
|
|
43
|
+
|
|
44
|
+
```ruby
|
|
45
|
+
# A symbol builds the driver with no arguments…
|
|
46
|
+
Unmagic::Browser.new(driver: :local) # same as driver: Unmagic::Browser::Driver::Local.new
|
|
47
|
+
Unmagic::Browser.new(driver: :remote)
|
|
48
|
+
Unmagic::Browser.new(driver: :cloudflare)
|
|
49
|
+
|
|
50
|
+
# …pass an instance to hand a driver options.
|
|
51
|
+
|
|
52
|
+
# Local Chrome — great for development. headless: false opens a window you can watch.
|
|
53
|
+
Unmagic::Browser.new(driver: Unmagic::Browser::Driver::Local.new(headless: false))
|
|
54
|
+
|
|
55
|
+
# Any remote Puppeteer / CDP websocket — browserless, your own pool, etc.
|
|
56
|
+
Unmagic::Browser.new(driver: Unmagic::Browser::Driver::Remote.new(ws: "ws://browserless.internal:3000"))
|
|
57
|
+
|
|
58
|
+
# Cloudflare Browser Rendering — no browser runs on your servers.
|
|
59
|
+
Unmagic::Browser.new(driver: Unmagic::Browser::Driver::Cloudflare.new(
|
|
60
|
+
account_id: ENV["CF_ACCOUNT_ID"],
|
|
61
|
+
token: ENV["CF_BROWSER_TOKEN"],
|
|
62
|
+
))
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
With no arguments, `Unmagic::Browser.new` uses `:local` in development and the configured
|
|
66
|
+
[`default_driver`](#configuration) everywhere else.
|
|
67
|
+
|
|
68
|
+
## One-off page grabs
|
|
69
|
+
|
|
70
|
+
For "just get me this page", call straight on the browser. Each call takes the cheapest path
|
|
71
|
+
its driver offers: the REST rendering API on Cloudflare (no browser to hold open), a
|
|
72
|
+
throwaway page on local and remote.
|
|
73
|
+
|
|
74
|
+
```ruby
|
|
75
|
+
browser.markdown("https://example.com") # => String (clean text)
|
|
76
|
+
browser.html("https://example.com") # => String (fully JS-rendered HTML)
|
|
77
|
+
browser.screenshot("https://example.com") # => PNG bytes
|
|
78
|
+
browser.screenshot("https://example.com",
|
|
79
|
+
full_page: true, # the whole scrollable page
|
|
80
|
+
selector: "#main") # or a single element
|
|
81
|
+
browser.pdf("https://example.com") # => PDF bytes
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
One-offs always render with default settings. To capture a page in dark mode, on a phone
|
|
85
|
+
viewport, or in another locale, open a session and use
|
|
86
|
+
[emulation](#emulation-dark-mode-viewport-devices-locale).
|
|
87
|
+
|
|
88
|
+
On local and remote the throwaway pages share one browser, started on first use and shut
|
|
89
|
+
down when the process exits. `browser.close` lets it go sooner; sessions are unaffected,
|
|
90
|
+
because each holds its own.
|
|
91
|
+
|
|
92
|
+
## Sessions
|
|
93
|
+
|
|
94
|
+
A **session** is a live browser page you drive step by step. `browser.open` works like
|
|
95
|
+
`File.open`: give it a URL and a block, and you get a session — already on that page —
|
|
96
|
+
that's cleaned up when the block ends.
|
|
97
|
+
|
|
98
|
+
```ruby
|
|
99
|
+
browser.open("https://store.example.com") do |session|
|
|
100
|
+
session.fill_in "Search", with: "espresso"
|
|
101
|
+
session.click_button "Go"
|
|
102
|
+
session.wait_for text: "results"
|
|
103
|
+
|
|
104
|
+
session.all(".product").map { |el| el.text }
|
|
105
|
+
end
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
`Unmagic::Browser.open(url)` is shorthand for `Unmagic::Browser.new.open(url)`.
|
|
109
|
+
|
|
110
|
+
Call `open` **without** a block and it returns the session for you to keep — see
|
|
111
|
+
[sessions that outlive one call](#sessions-that-outlive-one-call).
|
|
112
|
+
|
|
113
|
+
### Verbs
|
|
114
|
+
|
|
115
|
+
The API reads like [Capybara](https://github.com/teamcapybara/capybara), so it's instantly
|
|
116
|
+
familiar.
|
|
117
|
+
|
|
118
|
+
```ruby
|
|
119
|
+
# navigate
|
|
120
|
+
session.visit(url)
|
|
121
|
+
session.reload
|
|
122
|
+
session.go_back
|
|
123
|
+
session.go_forward
|
|
124
|
+
session.current_url
|
|
125
|
+
session.title
|
|
126
|
+
|
|
127
|
+
# act
|
|
128
|
+
session.fill_in("Email", with: "me@example.com")
|
|
129
|
+
session.click_button("Sign in")
|
|
130
|
+
session.click_link("Forgot password?")
|
|
131
|
+
session.click("Anything at all")
|
|
132
|
+
session.select("Australia", from: "Country")
|
|
133
|
+
session.check("Remember me")
|
|
134
|
+
session.uncheck("Send me email")
|
|
135
|
+
session.choose("Express shipping")
|
|
136
|
+
session.attach_file("Résumé", "/path/to/cv.pdf")
|
|
137
|
+
session.press(:enter)
|
|
138
|
+
session.hover("Profile")
|
|
139
|
+
|
|
140
|
+
# read
|
|
141
|
+
session.text
|
|
142
|
+
session.html
|
|
143
|
+
session.markdown
|
|
144
|
+
session.value("Email")
|
|
145
|
+
session.screenshot
|
|
146
|
+
session.pdf
|
|
147
|
+
session.evaluate("document.title")
|
|
148
|
+
|
|
149
|
+
# wait & ask
|
|
150
|
+
session.wait_for(text: "Welcome")
|
|
151
|
+
session.wait_for(selector: ".loaded")
|
|
152
|
+
session.has_text?("Welcome")
|
|
153
|
+
session.has_button?("Sign in")
|
|
154
|
+
session.has_link?("Log out")
|
|
155
|
+
session.has_field?("Email")
|
|
156
|
+
session.has_selector?(".row")
|
|
157
|
+
|
|
158
|
+
# scope
|
|
159
|
+
session.find(".cart")
|
|
160
|
+
session.all(".product")
|
|
161
|
+
session.within(".checkout") { session.click_button("Pay") }
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Verbs **auto-wait**: `click_button "Sign in"` keeps looking until the button appears and is
|
|
165
|
+
actionable, up to a timeout — so you rarely need an explicit `wait_for`.
|
|
166
|
+
|
|
167
|
+
Questions don't. `has_button?` answers about the page as it stands and returns straight
|
|
168
|
+
away, because a caller asking whether a challenge appeared wants "no" now, not in ten
|
|
169
|
+
seconds. When you *mean* to wait, say so with `wait_for`.
|
|
170
|
+
|
|
171
|
+
### Smart locators
|
|
172
|
+
|
|
173
|
+
You don't have to hand-write CSS. A plain string is resolved by **label**, then visible
|
|
174
|
+
**text**, then ARIA **role**, falling back to CSS — whichever matches. That's what keeps the
|
|
175
|
+
API working when the caller (an LLM, say) doesn't know the page's markup.
|
|
176
|
+
|
|
177
|
+
```ruby
|
|
178
|
+
session.fill_in "Email", with: "me@example.com" # finds <label>Email</label> → its input
|
|
179
|
+
session.click_button "Sign in" # finds the button reading "Sign in"
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
Be explicit when you want to be:
|
|
183
|
+
|
|
184
|
+
```ruby
|
|
185
|
+
session.fill_in css: "#user_email", with: "me@example.com"
|
|
186
|
+
session.click_button text: "Sign in"
|
|
187
|
+
session.find label: "Date of birth"
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
### Emulation (dark mode, viewport, devices, locale…)
|
|
191
|
+
|
|
192
|
+
Everything about *how the page renders* — colour scheme, viewport, device, locale, timezone —
|
|
193
|
+
lives in one `emulate:` config, set in exactly two places: passed to `open`, or changed live
|
|
194
|
+
with `session.emulate`.
|
|
195
|
+
|
|
196
|
+
```ruby
|
|
197
|
+
browser.open("https://example.com",
|
|
198
|
+
emulate: { color_scheme: :dark,
|
|
199
|
+
viewport: { width: 1440, height: 900 } }) do |session|
|
|
200
|
+
session.screenshot # dark mode, at 1440×900
|
|
201
|
+
end
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
The full set of knobs — valid as keys in any `emulate:` hash, or as a reusable object:
|
|
205
|
+
|
|
206
|
+
```ruby
|
|
207
|
+
emulation = Unmagic::Browser::Emulation.new(
|
|
208
|
+
color_scheme: :dark, # :light | :dark | :no_preference
|
|
209
|
+
reduced_motion: :reduce, # :reduce | :no_preference
|
|
210
|
+
viewport: { width: 390, height: 844,
|
|
211
|
+
scale: 2, mobile: true, touch: true }, # scale = device pixel ratio
|
|
212
|
+
device: "iPhone 13", # shortcut: sets viewport + UA + touch
|
|
213
|
+
user_agent: "MyBot/1.0",
|
|
214
|
+
locale: "en-AU", # Accept-Language
|
|
215
|
+
timezone: "Australia/Sydney",
|
|
216
|
+
geolocation: { latitude: -33.87, longitude: 151.21 },
|
|
217
|
+
media: :print, # :screen | :print (handy before #pdf)
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
# build it once, reuse it across sessions
|
|
221
|
+
browser.open(url, emulate: emulation) { |session| ... }
|
|
222
|
+
|
|
223
|
+
# change it live — applies to everything after
|
|
224
|
+
session.emulate(color_scheme: :light)
|
|
225
|
+
session.emulate(viewport: { width: 1280, height: 800 })
|
|
226
|
+
|
|
227
|
+
# or scope a change to a block — reverts when the block ends
|
|
228
|
+
session.emulate(color_scheme: :dark) do
|
|
229
|
+
session.screenshot # dark…
|
|
230
|
+
end
|
|
231
|
+
session.screenshot # …light again
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
Whatever you leave out renders at a fixed default — desktop viewport, light, screen media —
|
|
235
|
+
not at whatever the machine underneath prefers. Headless Chrome inherits the host's
|
|
236
|
+
appearance if you let it, which would mean the same page rendering dark on a laptop after
|
|
237
|
+
sunset and light on CI. Pass `color_scheme: :no_preference` if you actually want the host's.
|
|
238
|
+
|
|
239
|
+
Niche emulation (CPU/network throttling, offline, vision-deficiency simulation) isn't
|
|
240
|
+
wrapped — reach it through the [escape hatch](#escape-hatch).
|
|
241
|
+
|
|
242
|
+
## Sessions that outlive one call
|
|
243
|
+
|
|
244
|
+
A block session is perfect inside a single method. An LLM agent, though, works *turn by
|
|
245
|
+
turn* — open a page in one call, act on it in the next, let a human step in, then continue.
|
|
246
|
+
So `open` without a block hands you the session to keep. It's a plain object; hold it
|
|
247
|
+
wherever your app keeps state.
|
|
248
|
+
|
|
249
|
+
```ruby
|
|
250
|
+
session = browser.open("https://example.com/login")
|
|
251
|
+
session.fill_in "Email", with: "me@example.com"
|
|
252
|
+
session.click_button "Continue"
|
|
253
|
+
|
|
254
|
+
# ...later, same object, same live browser...
|
|
255
|
+
session.fill_in "Password", with: secret
|
|
256
|
+
session.click_button "Sign in"
|
|
257
|
+
session.text
|
|
258
|
+
|
|
259
|
+
session.close
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
A kept session holds a real browser — and on Cloudflare, one of a limited number of
|
|
263
|
+
concurrent slots — until you `close` it.
|
|
264
|
+
|
|
265
|
+
Operations on a session are serialized, so two concurrent callers can't interleave a
|
|
266
|
+
keystroke. When a *sequence* must be atomic against other callers, wrap it:
|
|
267
|
+
|
|
268
|
+
```ruby
|
|
269
|
+
session.lock do
|
|
270
|
+
session.fill_in "Amount", with: "100"
|
|
271
|
+
session.click_button "Transfer"
|
|
272
|
+
end
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
### Human in the loop (captcha / 2FA)
|
|
276
|
+
|
|
277
|
+
Because a kept session stays live between calls, a human can step in mid-flow: submit the
|
|
278
|
+
login, detect a challenge, show the person a screenshot, and resume once they've answered —
|
|
279
|
+
all against the same session.
|
|
280
|
+
|
|
281
|
+
```ruby
|
|
282
|
+
session = browser.open("https://example.com/login")
|
|
283
|
+
session.fill_in "Email", with: "me@example.com"
|
|
284
|
+
session.fill_in "Password", with: secret
|
|
285
|
+
session.click_button "Sign in"
|
|
286
|
+
|
|
287
|
+
if session.has_text?("Enter the code we texted you")
|
|
288
|
+
# ...show session.screenshot to a person, collect the code...
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# later, once the human has answered — same session, still live:
|
|
292
|
+
session.fill_in "Code", with: human_supplied_code
|
|
293
|
+
session.click_button "Verify"
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
For visual captchas, use a local driver with `headless: false` and let the person click the
|
|
297
|
+
real window, then keep driving.
|
|
298
|
+
|
|
299
|
+
## Errors
|
|
300
|
+
|
|
301
|
+
Failures are typed, so you classify them by rescuing — never by parsing a message.
|
|
302
|
+
|
|
303
|
+
```ruby
|
|
304
|
+
begin
|
|
305
|
+
browser.markdown(url)
|
|
306
|
+
rescue Unmagic::Browser::Error::TimedOut # the page took too long
|
|
307
|
+
rescue Unmagic::Browser::Error::Unreachable # couldn't reach the page or the rendering service
|
|
308
|
+
rescue Unmagic::Browser::Error::RenderFailed # the service answered but couldn't render
|
|
309
|
+
rescue Unmagic::Browser::Error::RateLimited # hit a concurrency / rate limit
|
|
310
|
+
rescue Unmagic::Browser::Error::Misconfigured # missing credentials / config
|
|
311
|
+
rescue Unmagic::Browser::Error::NotFound # nothing on the page matched a locator
|
|
312
|
+
rescue Unmagic::Browser::Error::SessionClosed # the session has been closed
|
|
313
|
+
rescue Unmagic::Browser::Error # base class for all of the above
|
|
314
|
+
end
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
`NotFound` is a `TimedOut`, because locators auto-wait: "not found" always means "still not
|
|
318
|
+
there when we ran out of patience".
|
|
319
|
+
|
|
320
|
+
## Configuration
|
|
321
|
+
|
|
322
|
+
```ruby
|
|
323
|
+
Unmagic::Browser.configure do |config|
|
|
324
|
+
# used when Unmagic::Browser.new is called without a driver — a symbol or an instance
|
|
325
|
+
config.default_driver = Unmagic::Browser::Driver::Cloudflare.new(
|
|
326
|
+
account_id: ENV["CF_ACCOUNT_ID"],
|
|
327
|
+
token: ENV["CF_BROWSER_TOKEN"],
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
# how long a locator keeps looking, and a `wait_for` waits, before giving up
|
|
331
|
+
config.timeout = 10
|
|
332
|
+
|
|
333
|
+
# how long a single navigation is given — longer, because a cold page on a
|
|
334
|
+
# slow network legitimately takes a while and there's nothing to retry against
|
|
335
|
+
config.navigation_timeout = 30
|
|
336
|
+
end
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
Either timeout can be overridden per browser (`Unmagic::Browser.new(timeout: 5)`), per
|
|
340
|
+
session (`browser.open(url, timeout: 5)`), or per call (`session.find(".row", wait: 2)`).
|
|
341
|
+
|
|
342
|
+
The Cloudflare driver falls back to `CLOUDFLARE_ACCOUNT_ID` and, for the token,
|
|
343
|
+
`CLOUDFLARE_BROWSER_RENDERING_TOKEN` then `CLOUDFLARE_API_TOKEN` — so a machine already set
|
|
344
|
+
up for Cloudflare needs nothing, and a token scoped to just Browser Rendering still wins
|
|
345
|
+
over the account-wide one. The remote driver falls back to `BROWSER_WS_ENDPOINT`. In a
|
|
346
|
+
container you can usually skip the block entirely.
|
|
347
|
+
|
|
348
|
+
## Escape hatch
|
|
349
|
+
|
|
350
|
+
The curated API stays small on purpose. When you need something it doesn't wrap (network
|
|
351
|
+
interception, tracing, the full Puppeteer surface), drop down to the object doing the real
|
|
352
|
+
work. It's the mirror of the `driver:` option: you pick a driver when you construct the
|
|
353
|
+
browser, and `driver` hands the underlying instance back.
|
|
354
|
+
|
|
355
|
+
```ruby
|
|
356
|
+
browser.driver # => the driver instance (e.g. Unmagic::Browser::Driver::Cloudflare)
|
|
357
|
+
|
|
358
|
+
browser.open("https://example.com") do |session|
|
|
359
|
+
puppeteer_page = session.driver # => the live Puppeteer::Page this session is driving
|
|
360
|
+
puppeteer_page.request_interception = true
|
|
361
|
+
# ...full puppeteer-ruby API...
|
|
362
|
+
end
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
Every driver drives the browser over CDP through
|
|
366
|
+
[puppeteer-ruby](https://github.com/YusukeIwaki/puppeteer-ruby), so `session.driver` is
|
|
367
|
+
always a `Puppeteer::Page`. Session operations stay serialized as usual; wrap multi-step
|
|
368
|
+
native work in `session.lock` when it must be atomic against other callers.
|
|
369
|
+
|
|
370
|
+
## License
|
|
371
|
+
|
|
372
|
+
MIT © Keith Pitt
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "logger"
|
|
4
|
+
|
|
5
|
+
module Unmagic
|
|
6
|
+
class Browser
|
|
7
|
+
# Global defaults, set once with {Unmagic::Browser.configure}. Anything a
|
|
8
|
+
# caller passes to {Unmagic::Browser#initialize} or to a session wins over
|
|
9
|
+
# what's here.
|
|
10
|
+
class Configuration
|
|
11
|
+
# The driver used when {Unmagic::Browser.new} is called without one, in
|
|
12
|
+
# every environment except development. A symbol (`:local`, `:remote`,
|
|
13
|
+
# `:cloudflare`) or a driver instance.
|
|
14
|
+
attr_accessor :default_driver
|
|
15
|
+
|
|
16
|
+
# Seconds a locator keeps looking, and a {Session#wait_for} waits, before
|
|
17
|
+
# giving up. Deliberately short: a verb that can't find its element is
|
|
18
|
+
# usually wrong rather than early.
|
|
19
|
+
attr_accessor :timeout
|
|
20
|
+
|
|
21
|
+
# Seconds a single navigation is given. Longer than {#timeout} because a
|
|
22
|
+
# cold page on a slow network legitimately takes a while, and there's
|
|
23
|
+
# nothing to retry against in the meantime.
|
|
24
|
+
attr_accessor :navigation_timeout
|
|
25
|
+
|
|
26
|
+
attr_writer :logger
|
|
27
|
+
|
|
28
|
+
def initialize
|
|
29
|
+
@default_driver = :cloudflare
|
|
30
|
+
@timeout = 10
|
|
31
|
+
@navigation_timeout = 30
|
|
32
|
+
@logger = nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Where the gem narrates what it's doing. Defaults to `Rails.logger` in a
|
|
36
|
+
# Rails app, and to a silent logger everywhere else — a library shouldn't
|
|
37
|
+
# write to a host app's stdout unasked.
|
|
38
|
+
#
|
|
39
|
+
# @return [Logger]
|
|
40
|
+
def logger
|
|
41
|
+
@logger ||= default_logger
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def default_logger
|
|
47
|
+
if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
|
|
48
|
+
Rails.logger
|
|
49
|
+
else
|
|
50
|
+
Logger.new(IO::NULL)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Unmagic
|
|
4
|
+
class Browser
|
|
5
|
+
module Console
|
|
6
|
+
# What the console says for itself on the way in, and when asked for `help`.
|
|
7
|
+
module Banner
|
|
8
|
+
# The verb table, by heading: each row is what to type and what it does.
|
|
9
|
+
SECTIONS = {
|
|
10
|
+
"open" => [
|
|
11
|
+
['open "https://example.com"', "open a page — every other verb drives it"],
|
|
12
|
+
["visit url", "go somewhere else in the same session"],
|
|
13
|
+
["back / forward / reload", "the browser buttons"],
|
|
14
|
+
["close", "let the browser go"],
|
|
15
|
+
["use :cloudflare", "switch drivers (:local, :remote, :cloudflare)"],
|
|
16
|
+
],
|
|
17
|
+
"act" => [
|
|
18
|
+
['fill_in "Email", with: "me@example.com"', "by label, placeholder, name or id"],
|
|
19
|
+
['click "Sign in"', "anything — button, link, div"],
|
|
20
|
+
['click_button "Go" / click_link "More"', "when you want to be specific"],
|
|
21
|
+
['select "Australia", from: "Country"', "by the label a reader sees"],
|
|
22
|
+
["check / uncheck / choose", "boxes and radios"],
|
|
23
|
+
["press :enter", "at whatever has focus"],
|
|
24
|
+
],
|
|
25
|
+
"read" => [
|
|
26
|
+
["text / title / url", "the page as prose"],
|
|
27
|
+
["html / markdown", "the page as source, highlighted"],
|
|
28
|
+
["screenshot", "drawn right here, and saved to /tmp"],
|
|
29
|
+
["screenshot full_page: true", "the whole scrollable page"],
|
|
30
|
+
["pdf", "written to /tmp"],
|
|
31
|
+
['js "document.title"', "run JavaScript in the page"],
|
|
32
|
+
],
|
|
33
|
+
"find" => [
|
|
34
|
+
['find ".cart"', "one element — waits for it"],
|
|
35
|
+
['all ".product"', "every match, right now"],
|
|
36
|
+
['within(".checkout") { click "Pay" }', "scope the verbs inside"],
|
|
37
|
+
['wait_for text: "Welcome"', "block until it shows up"],
|
|
38
|
+
['has_text? "Welcome"', "ask, and get an answer now"],
|
|
39
|
+
],
|
|
40
|
+
"emulate" => [
|
|
41
|
+
["emulate color_scheme: :dark", "and screenshot it"],
|
|
42
|
+
['emulate device: "iPhone 13"', "viewport, user agent and touch"],
|
|
43
|
+
["emulate viewport: { width: 1440, height: 900 }", ""],
|
|
44
|
+
],
|
|
45
|
+
}.freeze
|
|
46
|
+
|
|
47
|
+
# The four verbs that take a URL work without a session too, which is
|
|
48
|
+
# worth saying once rather than five times in the table.
|
|
49
|
+
ONE_OFFS = "markdown, html, screenshot and pdf take a URL for a one-off grab: " \
|
|
50
|
+
'markdown "https://example.com"'
|
|
51
|
+
|
|
52
|
+
module_function
|
|
53
|
+
|
|
54
|
+
# @return [String] the banner shown at startup
|
|
55
|
+
def render
|
|
56
|
+
[
|
|
57
|
+
"",
|
|
58
|
+
" #{Terminal.paint(Terminal::BOLD, "unmagic-browser")} #{Terminal.dim(Unmagic::Browser::VERSION)}",
|
|
59
|
+
" #{Terminal.dim("a browser you can type at · `help` for the verbs")}",
|
|
60
|
+
"",
|
|
61
|
+
].join("\n")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# @return [String] the full list of verbs
|
|
65
|
+
def help
|
|
66
|
+
lines = [""]
|
|
67
|
+
SECTIONS.each do |heading, rows|
|
|
68
|
+
lines << " #{Terminal.paint(Terminal::GREEN, heading)}"
|
|
69
|
+
width = rows.map { |verb, _| verb.length }.max
|
|
70
|
+
rows.each do |verb, note|
|
|
71
|
+
lines << " #{Terminal.paint(Terminal::BLUE, verb.ljust(width))} #{Terminal.dim(note)}".rstrip
|
|
72
|
+
end
|
|
73
|
+
lines << ""
|
|
74
|
+
end
|
|
75
|
+
lines << " #{Terminal.dim(ONE_OFFS)}"
|
|
76
|
+
lines << ""
|
|
77
|
+
lines.join("\n")
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|