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,205 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
begin
|
|
4
|
+
require "rouge"
|
|
5
|
+
rescue LoadError
|
|
6
|
+
# Syntax highlighting is a nicety for a human at a terminal, not something a
|
|
7
|
+
# gem that renders web pages should make anyone install. Without it the
|
|
8
|
+
# console still works — the source just comes out plain.
|
|
9
|
+
nil
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
module Unmagic
|
|
13
|
+
class Browser
|
|
14
|
+
module Console
|
|
15
|
+
# Turns whatever the last expression evaluated to into something worth
|
|
16
|
+
# looking at.
|
|
17
|
+
#
|
|
18
|
+
# IRB normally echoes `inspect`, which for this gem means a screenful of
|
|
19
|
+
# escaped HTML or, worse, a megabyte of PNG bytes. Every result comes
|
|
20
|
+
# through here instead: source gets highlighted, images get drawn, and
|
|
21
|
+
# everything else falls back to a highlighted `inspect`.
|
|
22
|
+
module Renderer
|
|
23
|
+
# Long enough to read a page's shape, short enough not to lose the
|
|
24
|
+
# prompt. The value itself is untouched — `puts _` still gives you all
|
|
25
|
+
# of it.
|
|
26
|
+
MAX_LINES = 60
|
|
27
|
+
|
|
28
|
+
# A belt to MAX_LINES' braces: one minified line of HTML is a screenful
|
|
29
|
+
# on its own.
|
|
30
|
+
MAX_CHARS = 8_000
|
|
31
|
+
|
|
32
|
+
class << self
|
|
33
|
+
# The Rouge theme used for highlighting, when Rouge is available.
|
|
34
|
+
#
|
|
35
|
+
# @return [Class]
|
|
36
|
+
attr_writer :theme
|
|
37
|
+
|
|
38
|
+
# @return [Class] defaults to Monokai
|
|
39
|
+
def theme
|
|
40
|
+
@theme ||= Rouge::Themes::Monokai if highlighting?
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# @return [Boolean] whether Rouge is loaded and can highlight
|
|
44
|
+
def highlighting?
|
|
45
|
+
defined?(Rouge) ? true : false
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# @param value [Object] whatever the last expression returned
|
|
49
|
+
# @return [String] what to print
|
|
50
|
+
def render(value)
|
|
51
|
+
case value
|
|
52
|
+
when Image then image(value)
|
|
53
|
+
when Document then document(value)
|
|
54
|
+
when Code then code(value)
|
|
55
|
+
when Unmagic::Browser::Session then session(value)
|
|
56
|
+
when Unmagic::Browser::Element then element(value)
|
|
57
|
+
when Unmagic::Browser then browser(value)
|
|
58
|
+
when Unmagic::Browser::Emulation then emulation(value)
|
|
59
|
+
when Array then array(value)
|
|
60
|
+
when String then string(value)
|
|
61
|
+
when nil then Terminal.dim("nil")
|
|
62
|
+
else ruby(value.inspect)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Drawing happens as a side effect, because the image goes out as a
|
|
67
|
+
# raw escape sequence rather than as text IRB could indent or wrap.
|
|
68
|
+
#
|
|
69
|
+
# @param value [Image]
|
|
70
|
+
# @return [String] the caption printed under the image
|
|
71
|
+
def image(value)
|
|
72
|
+
path = Graphics.show(value)
|
|
73
|
+
width, height = value.dimensions
|
|
74
|
+
size = width ? "#{width}×#{height}" : "#{value.bytesize} bytes"
|
|
75
|
+
caption = Terminal.dim("#{size} · #{path}")
|
|
76
|
+
Terminal.graphics? ? caption : "#{Terminal.paint(Terminal::YELLOW, "[image]")} #{caption}"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# @param value [Document]
|
|
80
|
+
# @return [String] where it was written
|
|
81
|
+
def document(value)
|
|
82
|
+
path = Graphics.save(value)
|
|
83
|
+
"#{Terminal.paint(Terminal::YELLOW, "[#{value.extension}]")} " +
|
|
84
|
+
Terminal.dim("#{value.bytesize} bytes · #{path}")
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# @param value [Code]
|
|
88
|
+
# @return [String] the source, highlighted and truncated
|
|
89
|
+
def code(value)
|
|
90
|
+
body, omitted = clamp(value)
|
|
91
|
+
[highlight(body, value.language), footer(value, omitted)].compact.join("\n")
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# @param value [String]
|
|
95
|
+
# @return [String] the string, as prose if it's long, as Ruby if it's short
|
|
96
|
+
def string(value)
|
|
97
|
+
return ruby(value.inspect) unless value.include?("\n") || value.length > 100
|
|
98
|
+
|
|
99
|
+
body, omitted = clamp(value)
|
|
100
|
+
[body, footer(value, omitted)].compact.join("\n")
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# @param value [Unmagic::Browser::Session]
|
|
104
|
+
# @return [String] where the session is, and what it's emulating
|
|
105
|
+
def session(value)
|
|
106
|
+
return Terminal.paint(Terminal::GREY, "session (closed)") if value.closed?
|
|
107
|
+
|
|
108
|
+
# Plenty of pages have no <title> — Hacker News' login page, for one
|
|
109
|
+
# — and a bold empty string reads as something having gone wrong.
|
|
110
|
+
title = value.title.to_s.strip
|
|
111
|
+
heading = title.empty? ? Terminal.dim("(untitled)") : Terminal.paint(Terminal::BOLD, title)
|
|
112
|
+
lines = ["#{Terminal.paint(Terminal::GREEN, "●")} #{heading}"]
|
|
113
|
+
lines << Terminal.dim(" #{value.current_url}")
|
|
114
|
+
knobs = value.emulation.to_h.compact
|
|
115
|
+
lines << Terminal.dim(" emulating #{describe(knobs)}") if knobs.any?
|
|
116
|
+
lines.join("\n")
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# @param value [Unmagic::Browser::Element]
|
|
120
|
+
# @return [String] the element's tag and the text in it
|
|
121
|
+
def element(value)
|
|
122
|
+
text = value.text
|
|
123
|
+
text = "#{text[0, 80]}…" if text.length > 80
|
|
124
|
+
tag = Terminal.paint(Terminal::BLUE, "<#{value.tag_name}>")
|
|
125
|
+
text.empty? ? tag : "#{tag} #{text.inspect}"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# @param value [Array]
|
|
129
|
+
# @return [String] a numbered list for elements, highlighted Ruby otherwise
|
|
130
|
+
def array(value)
|
|
131
|
+
return ruby(value.inspect) unless value.any? && value.all?(Unmagic::Browser::Element)
|
|
132
|
+
|
|
133
|
+
width = value.size.to_s.length
|
|
134
|
+
value.each_with_index.map do |item, index|
|
|
135
|
+
"#{Terminal.dim("[#{index.to_s.rjust(width)}]")} #{element(item)}"
|
|
136
|
+
end.join("\n")
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# @param value [Unmagic::Browser]
|
|
140
|
+
# @return [String] the driver it's running against
|
|
141
|
+
def browser(value)
|
|
142
|
+
"#{Terminal.paint(Terminal::BOLD, "browser")} #{Terminal.dim("via #{value.driver.name}")}"
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# @param value [Unmagic::Browser::Emulation]
|
|
146
|
+
# @return [String] the knobs that are set, or that none are
|
|
147
|
+
def emulation(value)
|
|
148
|
+
knobs = value.to_h.compact
|
|
149
|
+
return Terminal.dim("emulating nothing — desktop, light, screen") if knobs.empty?
|
|
150
|
+
|
|
151
|
+
knobs.map { |key, setting| "#{Terminal.paint(Terminal::BLUE, key.to_s)} #{setting.inspect}" }.join("\n")
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# @param source [String] Ruby source, usually from #inspect
|
|
155
|
+
# @return [String] it, highlighted
|
|
156
|
+
def ruby(source)
|
|
157
|
+
highlight(source, "ruby")
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# @param source [String]
|
|
161
|
+
# @param language [String] a Rouge lexer name
|
|
162
|
+
# @return [String] the source with terminal colour, or untouched
|
|
163
|
+
# without a tty or without Rouge
|
|
164
|
+
def highlight(source, language)
|
|
165
|
+
return source unless Terminal.tty? && highlighting?
|
|
166
|
+
|
|
167
|
+
lexer = Rouge::Lexer.find(language) || Rouge::Lexers::PlainText
|
|
168
|
+
formatter.format(lexer.new.lex(source)).chomp
|
|
169
|
+
rescue StandardError
|
|
170
|
+
source # Never let a highlighting failure swallow the value itself.
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Every terminal that can draw an image can also do 24-bit colour, so
|
|
174
|
+
# there's no reason to quantise to 256.
|
|
175
|
+
#
|
|
176
|
+
# @return [Rouge::Formatter]
|
|
177
|
+
def formatter
|
|
178
|
+
@formatter ||= Rouge::Formatters::TerminalTruecolor.new(theme.new)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
private
|
|
182
|
+
|
|
183
|
+
def describe(knobs)
|
|
184
|
+
knobs.map { |key, setting| "#{key}: #{setting.inspect}" }.join(", ")
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# @return [Array(String, Integer)] as much as we'll print, and how many
|
|
188
|
+
# lines were held back
|
|
189
|
+
def clamp(value)
|
|
190
|
+
lines = value.lines
|
|
191
|
+
kept = lines.first(MAX_LINES)
|
|
192
|
+
kept = kept.join[0, MAX_CHARS].lines if kept.join.length > MAX_CHARS
|
|
193
|
+
[kept.join.chomp, lines.size - kept.size]
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def footer(value, omitted)
|
|
197
|
+
return nil unless omitted.positive?
|
|
198
|
+
|
|
199
|
+
Terminal.dim("… #{omitted} more lines (#{value.length} chars in all — `puts _` for everything)")
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "io/console"
|
|
4
|
+
|
|
5
|
+
module Unmagic
|
|
6
|
+
class Browser
|
|
7
|
+
module Console
|
|
8
|
+
# What this terminal can do, and how to draw on it.
|
|
9
|
+
module Terminal
|
|
10
|
+
# The palette, kept to the 256-colour cube so it looks the same wherever
|
|
11
|
+
# the console runs. Only {Renderer}'s syntax highlighting reaches for
|
|
12
|
+
# true colour, and only when Rouge is there to do it.
|
|
13
|
+
|
|
14
|
+
# Back to the terminal's own colours.
|
|
15
|
+
RESET = "\e[0m"
|
|
16
|
+
|
|
17
|
+
# For anything secondary — paths, sizes, counts.
|
|
18
|
+
DIM = "\e[2m"
|
|
19
|
+
|
|
20
|
+
# For the one thing on the line that matters.
|
|
21
|
+
BOLD = "\e[1m"
|
|
22
|
+
|
|
23
|
+
# For a session that's gone.
|
|
24
|
+
GREY = "\e[38;5;245m"
|
|
25
|
+
|
|
26
|
+
# For a session that's live, and for headings.
|
|
27
|
+
GREEN = "\e[38;5;114m"
|
|
28
|
+
|
|
29
|
+
# For elements, keys, and the verbs in the banner.
|
|
30
|
+
BLUE = "\e[38;5;75m"
|
|
31
|
+
|
|
32
|
+
# For what the terminal can't draw and had to write to a file instead.
|
|
33
|
+
YELLOW = "\e[38;5;179m"
|
|
34
|
+
|
|
35
|
+
# `TIOCGWINSZ` is the only way to ask a terminal how big a character cell
|
|
36
|
+
# is in *pixels*, which is what an image has to be measured against. The
|
|
37
|
+
# request number is baked into each kernel's headers rather than
|
|
38
|
+
# standardised.
|
|
39
|
+
WINSIZE_REQUEST = {
|
|
40
|
+
/darwin|bsd/ => 0x40087468,
|
|
41
|
+
/linux/ => 0x5413,
|
|
42
|
+
}.find { |pattern, _| pattern.match?(RUBY_PLATFORM) }&.last
|
|
43
|
+
|
|
44
|
+
# A sane guess for a cell when the terminal won't say.
|
|
45
|
+
FALLBACK_CELL = [8, 17].freeze
|
|
46
|
+
|
|
47
|
+
module_function
|
|
48
|
+
|
|
49
|
+
# Terminals that implement the kitty graphics protocol. Ghostty and
|
|
50
|
+
# WezTerm both do, and neither says "kitty" anywhere in `$TERM`.
|
|
51
|
+
#
|
|
52
|
+
# @return [Boolean] whether an image can be drawn inline
|
|
53
|
+
def graphics?
|
|
54
|
+
return false unless tty?
|
|
55
|
+
return true if ENV["KITTY_WINDOW_ID"] || ENV["WEZTERM_PANE"]
|
|
56
|
+
|
|
57
|
+
"#{ENV["TERM"]} #{ENV["TERM_PROGRAM"]}".downcase.match?(/kitty|ghostty|wezterm/)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# @return [Boolean] whether output is going to a terminal at all
|
|
61
|
+
def tty?
|
|
62
|
+
$stdout.tty?
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# @return [Integer] the terminal's width in characters
|
|
66
|
+
def columns
|
|
67
|
+
IO.console&.winsize&.last || 80
|
|
68
|
+
rescue StandardError
|
|
69
|
+
80
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# @return [Array(Integer, Integer)] the width and height of one character cell, in pixels
|
|
73
|
+
def cell
|
|
74
|
+
rows, cols, width, height = window
|
|
75
|
+
return FALLBACK_CELL if width.nil? || width.zero? || cols.zero? || rows.zero?
|
|
76
|
+
|
|
77
|
+
[width / cols, height / rows]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# @param text [String]
|
|
81
|
+
# @return [String] the text, dimmed, if the terminal has colour
|
|
82
|
+
def dim(text)
|
|
83
|
+
paint(DIM, text)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# @param colour [String] one of the escape constants above
|
|
87
|
+
# @param text [String]
|
|
88
|
+
# @return [String] the text in that colour, if the terminal has colour
|
|
89
|
+
def paint(colour, text)
|
|
90
|
+
tty? ? "#{colour}#{text}#{RESET}" : text.to_s
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# @return [Array(Integer, Integer, Integer, Integer)] rows, columns, and
|
|
94
|
+
# the window's width and height in pixels; zeroes when the terminal
|
|
95
|
+
# won't say
|
|
96
|
+
def window
|
|
97
|
+
return [0, 0, 0, 0] unless WINSIZE_REQUEST && tty?
|
|
98
|
+
|
|
99
|
+
buffer = [0, 0, 0, 0].pack("S_4")
|
|
100
|
+
$stdout.ioctl(WINSIZE_REQUEST, buffer)
|
|
101
|
+
buffer.unpack("S_4")
|
|
102
|
+
rescue StandardError
|
|
103
|
+
[0, 0, 0, 0]
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Unmagic
|
|
4
|
+
class Browser
|
|
5
|
+
module Console
|
|
6
|
+
# A String that also knows how it wants to be shown.
|
|
7
|
+
#
|
|
8
|
+
# Sniffing a value to guess whether it's HTML, Markdown or PNG bytes would
|
|
9
|
+
# be guesswork the console doesn't need to do: the helper that produced it
|
|
10
|
+
# knows exactly what it asked for. These carry that answer through to the
|
|
11
|
+
# renderer while staying plain Strings for everything else, so
|
|
12
|
+
# `html.include?("...")` and `puts markdown` work the way you'd expect.
|
|
13
|
+
class Tagged < String
|
|
14
|
+
# @param value [String] the bytes themselves
|
|
15
|
+
# @param name [String] what to call it in the console, and on disk
|
|
16
|
+
def initialize(value, name:)
|
|
17
|
+
super(value.to_s)
|
|
18
|
+
@name = name
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# @return [String]
|
|
22
|
+
attr_reader :name
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Source the renderer should syntax-highlight.
|
|
26
|
+
class Code < Tagged
|
|
27
|
+
# @param value [String] the source
|
|
28
|
+
# @param language [String] a Rouge lexer name, e.g. `"html"`
|
|
29
|
+
# @param name [String] what to call it in the console
|
|
30
|
+
def initialize(value, language:, name: language)
|
|
31
|
+
super(value, name: name)
|
|
32
|
+
@language = language
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# @return [String] the Rouge lexer to highlight with
|
|
36
|
+
attr_reader :language
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Image bytes the renderer should draw in the terminal.
|
|
40
|
+
class Image < Tagged
|
|
41
|
+
# @param value [String] PNG bytes
|
|
42
|
+
# @param name [String] a filename stem for the copy written to disk
|
|
43
|
+
def initialize(value, name: "screenshot")
|
|
44
|
+
super(value, name: name)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# The image's pixel size, read straight out of the PNG header — the IHDR
|
|
48
|
+
# chunk is always the first one, at a fixed offset.
|
|
49
|
+
#
|
|
50
|
+
# @return [Array(Integer, Integer), nil] width and height, or nil if it isn't a PNG
|
|
51
|
+
def dimensions
|
|
52
|
+
return nil unless start_with?("\x89PNG\r\n\x1A\n".b)
|
|
53
|
+
|
|
54
|
+
byteslice(16, 8)&.unpack("N2")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# @return [String] the file extension for the copy written to disk
|
|
58
|
+
def extension
|
|
59
|
+
"png"
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Bytes there's no way to draw in a terminal — a PDF. Saved and pointed at.
|
|
64
|
+
class Document < Tagged
|
|
65
|
+
# @param value [String] the bytes
|
|
66
|
+
# @param extension [String] the file extension to save under
|
|
67
|
+
# @param name [String] a filename stem for the copy written to disk
|
|
68
|
+
def initialize(value, extension: "pdf", name: "document")
|
|
69
|
+
super(value, name: name)
|
|
70
|
+
@extension = extension
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# @return [String] the file extension for the copy written to disk
|
|
74
|
+
attr_reader :extension
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../browser"
|
|
4
|
+
require_relative "console/values"
|
|
5
|
+
require_relative "console/terminal"
|
|
6
|
+
require_relative "console/graphics"
|
|
7
|
+
require_relative "console/renderer"
|
|
8
|
+
require_relative "console/prompt"
|
|
9
|
+
require_relative "console/banner"
|
|
10
|
+
require_relative "console/helpers"
|
|
11
|
+
|
|
12
|
+
module Unmagic
|
|
13
|
+
class Browser
|
|
14
|
+
# A browser you can type at.
|
|
15
|
+
#
|
|
16
|
+
# open "https://example.com"
|
|
17
|
+
# click "More information"
|
|
18
|
+
# screenshot
|
|
19
|
+
#
|
|
20
|
+
# Not required by `unmagic/browser` — it's for a person at a terminal, not
|
|
21
|
+
# for an app, and it takes over `main` and IRB's output when it starts.
|
|
22
|
+
# Require it yourself:
|
|
23
|
+
#
|
|
24
|
+
# require "unmagic/browser/console"
|
|
25
|
+
# Unmagic::Browser::Console.start
|
|
26
|
+
#
|
|
27
|
+
# Every result is rendered rather than inspected: source is syntax
|
|
28
|
+
# highlighted, screenshots are drawn in the terminal, elements and sessions
|
|
29
|
+
# get a summary worth reading. Highlighting wants
|
|
30
|
+
# [rouge](https://github.com/rouge-ruby/rouge) — the console works without
|
|
31
|
+
# it, just in monochrome — and drawing images wants a terminal that speaks
|
|
32
|
+
# the [kitty graphics
|
|
33
|
+
# protocol](https://sw.kovidgoyal.net/kitty/graphics-protocol/) (kitty,
|
|
34
|
+
# Ghostty, WezTerm). Neither is a dependency.
|
|
35
|
+
module Console
|
|
36
|
+
# Raised when a verb needs a page and there isn't one open.
|
|
37
|
+
class NothingOpen < Error; end
|
|
38
|
+
|
|
39
|
+
# One line of a dotenv file: an optional `export`, a name, an `=`, a value
|
|
40
|
+
# that may be quoted, and an optional trailing comment.
|
|
41
|
+
ENV_LINE = /\A\s*(?:export\s+)?([A-Z_][A-Z0-9_]*)\s*=\s*(.*?)\s*\z/i
|
|
42
|
+
|
|
43
|
+
class << self
|
|
44
|
+
# The browser every verb runs against, built on first use.
|
|
45
|
+
#
|
|
46
|
+
# @return [Unmagic::Browser]
|
|
47
|
+
def browser
|
|
48
|
+
@browser ||= Unmagic::Browser.new(driver: @driver || :local)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @return [Unmagic::Browser::Session, nil] the session the verbs are driving
|
|
52
|
+
attr_accessor :session
|
|
53
|
+
|
|
54
|
+
# Throw away the browser and any session, so the next verb builds a new one.
|
|
55
|
+
#
|
|
56
|
+
# @param driver [Symbol, Unmagic::Browser::Driver::Base, nil] what to build next time
|
|
57
|
+
# @return [void]
|
|
58
|
+
def reset(driver = nil)
|
|
59
|
+
session&.close
|
|
60
|
+
@session = nil
|
|
61
|
+
@browser&.close
|
|
62
|
+
@browser = nil
|
|
63
|
+
@driver = driver if driver
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Mix the verbs into an object — `main`, in a console — and start IRB.
|
|
67
|
+
#
|
|
68
|
+
# @param context [Binding] whose receiver gets the verbs, and where IRB runs
|
|
69
|
+
# @param banner [Boolean] print the banner and the verb list first
|
|
70
|
+
# @param prompt [Symbol] `:plain` for `browser>`, or `:page` for a prompt
|
|
71
|
+
# that follows the browser around — see {Prompt}
|
|
72
|
+
# @return [void]
|
|
73
|
+
def start(context: TOPLEVEL_BINDING, banner: true, prompt: :plain)
|
|
74
|
+
require "irb"
|
|
75
|
+
require "irb/workspace"
|
|
76
|
+
|
|
77
|
+
# Only the one object gets the verbs, rather than every Object in the
|
|
78
|
+
# process — `open` and `select` shadow Kernel's, which is a trade
|
|
79
|
+
# worth making for `main` and nothing else.
|
|
80
|
+
context.receiver.extend(Helpers)
|
|
81
|
+
at_exit { reset rescue nil }
|
|
82
|
+
|
|
83
|
+
if banner
|
|
84
|
+
puts(Banner.render)
|
|
85
|
+
puts(Banner.help)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
ARGV.clear
|
|
89
|
+
IRB.setup(nil)
|
|
90
|
+
# After `setup`, never before: it resets IRB.conf, which would throw
|
|
91
|
+
# away a prompt registered ahead of it and leave PROMPT_MODE pointing
|
|
92
|
+
# at nothing.
|
|
93
|
+
configure
|
|
94
|
+
irb = IRB::Irb.new(IRB::WorkSpace.new(context))
|
|
95
|
+
Prompt.install(irb.context) if prompt == :page
|
|
96
|
+
irb.run(IRB.conf)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Read a dotenv file into ENV, if it's there. Values already in the
|
|
100
|
+
# environment win — an export in your shell should beat a checked-in
|
|
101
|
+
# file.
|
|
102
|
+
#
|
|
103
|
+
# @param path [String] the file to read
|
|
104
|
+
# @return [Array<String>] the names that were set
|
|
105
|
+
def load_env(path)
|
|
106
|
+
return [] unless File.exist?(path)
|
|
107
|
+
|
|
108
|
+
File.readlines(path).filter_map do |line|
|
|
109
|
+
next if line.strip.empty? || line.strip.start_with?("#")
|
|
110
|
+
|
|
111
|
+
name, raw = ENV_LINE.match(line)&.captures
|
|
112
|
+
next unless name && !ENV.key?(name)
|
|
113
|
+
|
|
114
|
+
ENV[name] = unquote(raw)
|
|
115
|
+
name
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
private
|
|
120
|
+
|
|
121
|
+
# IRB echoes `inspect` by default, which for a page of HTML is a
|
|
122
|
+
# screenful of escapes and for a screenshot is a megabyte of PNG bytes.
|
|
123
|
+
# Every result goes through the renderer instead.
|
|
124
|
+
def configure
|
|
125
|
+
IRB::Inspector.def_inspector([:unmagic_browser]) do |value, colorize: true|
|
|
126
|
+
Renderer.render(value)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# What the prompt is unless `start` was asked for `prompt: :page`, in
|
|
130
|
+
# which case {Prompt} takes these over on the context once IRB is
|
|
131
|
+
# built.
|
|
132
|
+
IRB.conf[:PROMPT][:UNMAGIC_BROWSER] = {
|
|
133
|
+
PROMPT_I: "browser> ",
|
|
134
|
+
PROMPT_S: "browser%l ",
|
|
135
|
+
PROMPT_C: "browser* ",
|
|
136
|
+
# No `=> `: the renderer draws images and highlighted source, and a
|
|
137
|
+
# prefix in front of a picture reads as a mistake.
|
|
138
|
+
RETURN: "%s\n",
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
IRB.conf[:INSPECT_MODE] = :unmagic_browser
|
|
142
|
+
IRB.conf[:PROMPT_MODE] = :UNMAGIC_BROWSER
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Strip one layer of matching quotes and any trailing comment. A `#`
|
|
146
|
+
# inside quotes stays — that's what quoting it is for — which is why the
|
|
147
|
+
# comment has to come off after the quotes rather than before.
|
|
148
|
+
def unquote(raw)
|
|
149
|
+
case raw
|
|
150
|
+
when /\A"((?:[^"\\]|\\.)*)"(?:\s+#.*)?\z/m
|
|
151
|
+
Regexp.last_match(1).gsub('\\n', "\n").gsub('\\"', '"').gsub("\\\\", "\\")
|
|
152
|
+
when /\A'([^']*)'(?:\s+#.*)?\z/m
|
|
153
|
+
Regexp.last_match(1)
|
|
154
|
+
else
|
|
155
|
+
raw.sub(/\s+#.*\z/, "")
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Unmagic
|
|
4
|
+
class Browser
|
|
5
|
+
module Driver
|
|
6
|
+
# What every driver has to be able to do, and the parts none of them need
|
|
7
|
+
# to write twice.
|
|
8
|
+
#
|
|
9
|
+
# A driver answers two questions: how do I get a browser, and what's the
|
|
10
|
+
# cheapest way to grab a single page? Subclasses answer the first with
|
|
11
|
+
# {#connect_browser} and {#disconnect_browser}. The second is answered
|
|
12
|
+
# here, for free, by opening a throwaway page on a shared connection —
|
|
13
|
+
# {Cloudflare} overrides it because it has a REST API that needs no
|
|
14
|
+
# browser at all.
|
|
15
|
+
class Base
|
|
16
|
+
# Seconds a one-off page grab waits for the page to settle.
|
|
17
|
+
DEFAULT_TIMEOUT = 30
|
|
18
|
+
|
|
19
|
+
# When a page counts as loaded. `networkidle2` waits out the XHR that a
|
|
20
|
+
# JS-rendered page fires after `load`, which is the whole reason to run
|
|
21
|
+
# a real browser instead of fetching HTML.
|
|
22
|
+
WAIT_UNTIL = "networkidle2"
|
|
23
|
+
|
|
24
|
+
def initialize
|
|
25
|
+
@scratch_mutex = Mutex.new
|
|
26
|
+
@scratch = nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Open a browser of this driver's kind, for a caller who'll hold it.
|
|
30
|
+
#
|
|
31
|
+
# @return [Connection]
|
|
32
|
+
def open_connection
|
|
33
|
+
Failures.wrap("connecting to the browser") { Connection.new(self, connect_browser) }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @param url [String] the page to render
|
|
37
|
+
# @return [String] the page as Markdown
|
|
38
|
+
def markdown(url)
|
|
39
|
+
Unmagic::Browser::Markdown.from_html(html(url))
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @param url [String] the page to render
|
|
43
|
+
# @return [String] the page's HTML, after JavaScript has run
|
|
44
|
+
def html(url)
|
|
45
|
+
on_scratch_page(url) { |page| page.content }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# @param url [String] the page to render
|
|
49
|
+
# @param full_page [Boolean] capture the whole scrollable page
|
|
50
|
+
# @param selector [String, nil] capture just the first element matching this CSS
|
|
51
|
+
# @return [String] PNG bytes
|
|
52
|
+
# @raise [Error::NotFound] when +selector+ matches nothing
|
|
53
|
+
def screenshot(url, full_page: false, selector: nil)
|
|
54
|
+
on_scratch_page(url) do |page|
|
|
55
|
+
if selector
|
|
56
|
+
element = page.query_selector(selector)
|
|
57
|
+
raise Error::NotFound, "No element matches #{selector.inspect} on #{url}." unless element
|
|
58
|
+
|
|
59
|
+
element.screenshot(type: "png")
|
|
60
|
+
else
|
|
61
|
+
page.screenshot(type: "png", full_page: full_page)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# @param url [String] the page to render
|
|
67
|
+
# @return [String] PDF bytes
|
|
68
|
+
def pdf(url)
|
|
69
|
+
on_scratch_page(url) { |page| page.pdf }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Drop the shared connection one-off grabs run on. Sessions are
|
|
73
|
+
# unaffected — each holds its own browser.
|
|
74
|
+
#
|
|
75
|
+
# @return [void]
|
|
76
|
+
def close
|
|
77
|
+
scratch = nil
|
|
78
|
+
@scratch_mutex.synchronize do
|
|
79
|
+
scratch = @scratch
|
|
80
|
+
@scratch = nil
|
|
81
|
+
end
|
|
82
|
+
scratch&.close
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# @return [String] a short description used in log lines
|
|
86
|
+
def name
|
|
87
|
+
self.class.name.split("::").last.downcase
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
protected
|
|
91
|
+
|
|
92
|
+
# @return [Puppeteer::Browser] a browser of this driver's kind
|
|
93
|
+
# @raise [NotImplementedError] when a subclass hasn't implemented it
|
|
94
|
+
def connect_browser
|
|
95
|
+
raise NotImplementedError, "#{self.class} must implement #connect_browser"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# @param browser [Puppeteer::Browser] the browser to shut down
|
|
99
|
+
# @return [void]
|
|
100
|
+
# @raise [NotImplementedError] when a subclass hasn't implemented it
|
|
101
|
+
def disconnect_browser(browser)
|
|
102
|
+
raise NotImplementedError, "#{self.class} must implement #disconnect_browser"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Render +url+ on a throwaway page and hand it to the block. The page
|
|
106
|
+
# goes away afterwards; the browser under it is reused by the next
|
|
107
|
+
# one-off, because launching Chrome per call would dwarf the work.
|
|
108
|
+
#
|
|
109
|
+
# @param url [String] the page to render
|
|
110
|
+
# @yieldparam page [Puppeteer::Page]
|
|
111
|
+
# @return [Object] whatever the block returns
|
|
112
|
+
def on_scratch_page(url)
|
|
113
|
+
Failures.wrap("rendering #{url}") do
|
|
114
|
+
page = scratch.new_page
|
|
115
|
+
begin
|
|
116
|
+
Emulation.new.apply(page)
|
|
117
|
+
page.goto(url, wait_until: WAIT_UNTIL, timeout: DEFAULT_TIMEOUT * 1000)
|
|
118
|
+
yield page
|
|
119
|
+
ensure
|
|
120
|
+
begin
|
|
121
|
+
page&.close
|
|
122
|
+
rescue StandardError
|
|
123
|
+
nil # A page whose browser already went away can't be closed, and needn't be.
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
private
|
|
130
|
+
|
|
131
|
+
# The connection one-off grabs share. Created on first use and closed at
|
|
132
|
+
# exit, so a `browser.markdown(url)` in a script doesn't leave a Chrome
|
|
133
|
+
# behind.
|
|
134
|
+
def scratch
|
|
135
|
+
@scratch_mutex.synchronize do
|
|
136
|
+
if @scratch.nil? || @scratch.closed?
|
|
137
|
+
@scratch = Connection.new(self, connect_browser)
|
|
138
|
+
Driver.close_at_exit(self)
|
|
139
|
+
end
|
|
140
|
+
@scratch
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|