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,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