Class: NSWTopo::Chrome

Inherits:
Object
  • Object
show all
Defined in:
lib/nswtopo/chrome.rb

Defined Under Namespace

Classes: Error, Node

Constant Summary collapse

MIN_VERSION =
112
TIMEOUT_KILL =
5
TIMEOUT_LOADEVENT =
30
TIMEOUT_COMMAND =
10
TIMEOUT_SCREENSHOT =
120
ARGS =
%W[
  --disable-background-networking
  --disable-component-extensions-with-background-pages
  --disable-component-update
  --disable-default-apps
  --disable-extensions
  --disable-features=site-per-process,Translate
  --disable-lcd-text
  --disable-renderer-backgrounding
  --force-color-profile=srgb
  --force-device-scale-factor=1
  --headless=new
  --hide-scrollbars
  --no-default-browser-check
  --no-first-run
  --no-startup-window
  --remote-debugging-pipe=JSON
  --use-mock-keychain
]

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(url, width: 800, height: 600, background: { r: 0, g: 0, b: 0, a: 0 }, args: []) ⇒ Chrome

Returns a new instance of Chrome.



110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
# File 'lib/nswtopo/chrome.rb', line 110

def initialize(url, width: 800, height: 600, background: { r: 0, g: 0, b: 0, a: 0 }, args: [])
  @id, @data_dir = 0, Dir.mktmpdir("nswtopo_headless_chrome_")
  ObjectSpace.define_finalizer self, Chrome.rmdir(@data_dir)

  args = [*args, "--disable-gpu"] if Config["gpu"] == false
  args = [*args, "--user-data-dir=#{@data_dir}"]

  input, @input, @output, output = *IO.pipe, *IO.pipe
  input.nonblock, output.nonblock = false, false
  @input.sync = true

  @pid = Process.spawn Chrome.path, *ARGS, *args, 1 => File::NULL, 2 => File::NULL, 3 => input, 4 => output, :pgroup => Chrome.windows? ? nil : true
  ObjectSpace.define_finalizer self, Chrome.kill(@pid, @input, @output)
  input.close; output.close

  target_id = command("Target.createTarget", url: url).fetch("targetId")
  @session_id = command("Target.attachToTarget", targetId: target_id, flatten: true).fetch("sessionId")
  command "Page.enable"
  wait "Page.loadEventFired", timeout: TIMEOUT_LOADEVENT
  command "Emulation.setDeviceMetricsOverride", width: width, height: height, deviceScaleFactor: 1, mobile: false
  command "Emulation.setDefaultBackgroundColorOverride", color: background
  @node_id = command("DOM.getDocument").fetch("root").fetch("nodeId")
rescue SystemCallError
  raise Error, "couldn't start chrome"
rescue KeyError
  raise Error
end

Class Method Details

.kill(pid, *pipes) ⇒ Object



74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
# File 'lib/nswtopo/chrome.rb', line 74

def self.kill(pid, *pipes)
  Proc.new do
    if windows?
      *, status = Open3.capture2e *%W[taskkill /f /t /pid #{pid}]
      Process.kill "KILL", pid unless status.success?
    else
      Process.kill "-USR1", Process.getpgid(pid)
      Enumerator.produce(Time.now) do |time|
        sleep 0.05 and Time.now
      end.each.with_object(Time.now) do |time, start|
        break true if Process.wait pid, Process::WNOHANG
        break false if time - start > TIMEOUT_KILL
      end or begin
        Process.kill "-KILL", Process.getpgid(pid)
        Process.wait pid
      end
    end
  rescue Errno::ESRCH, Errno::ECHILD
  ensure
    pipes.each(&:close)
  end
end

.mac?Boolean

Returns:

  • (Boolean)


35
36
37
# File 'lib/nswtopo/chrome.rb', line 35

def self.mac?
  /darwin/ === RbConfig::CONFIG["host_os"]
end

.pathObject



43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
# File 'lib/nswtopo/chrome.rb', line 43

def self.path
  @path ||= case
  when Config["chrome"]
    [Config["chrome"]]
  when mac?
    ["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "/Applications/Chromium.app/Contents/MacOS/Chromium"]
  when windows?
    ["C:/Program Files/Google/Chrome/Application/chrome.exe", "C:/Program Files (x86)/Google/Chrome/Application/chrome.exe"]
  else
    ENV["PATH"].split(File::PATH_SEPARATOR).product(%w[chrome google-chrome chromium chromium-browser]).map do |path, binary|
      [path, binary].join(File::SEPARATOR)
    end
  end.find do |path|
    File.executable?(path) && !File.directory?(path)
  end.tap do |path|
    raise Error, "couldn't find chrome" unless path
    stdout, status = Open3.capture2 path, "--version"
    raise Error, "couldn't start chrome" unless status.success?
    version = /(?<major>\d+)(?:\.\d+)*/.match stdout
    raise Error, "couldn't start chrome" unless version
    raise Error, "chrome version #{MIN_VERSION} or higher required" if version[:major].to_i < MIN_VERSION
  end
end

.rmdir(tmp) ⇒ Object



67
68
69
70
71
72
# File 'lib/nswtopo/chrome.rb', line 67

def self.rmdir(tmp)
  Proc.new do
    FileUtils.remove_entry tmp
  rescue SystemCallError
  end
end

.windows?Boolean

Returns:

  • (Boolean)


39
40
41
# File 'lib/nswtopo/chrome.rb', line 39

def self.windows?
  /mingw|mswin|cygwin/ === RbConfig::CONFIG["host_os"]
end

.with_browser(url, **opts, &block) ⇒ Object



103
104
105
106
107
108
# File 'lib/nswtopo/chrome.rb', line 103

def self.with_browser(url, **opts, &block)
  browser = new url, **opts
  block.call browser
ensure
  browser&.close
end

Instance Method Details

#closeObject



97
98
99
100
101
# File 'lib/nswtopo/chrome.rb', line 97

def close
  Chrome.kill(@pid, @input, @output).call
  Chrome.rmdir(@data_dir).call
  ObjectSpace.undefine_finalizer self
end

#command(method, timeout: TIMEOUT_COMMAND, **params) ⇒ Object



165
166
167
168
169
170
171
172
173
174
# File 'lib/nswtopo/chrome.rb', line 165

def command(method, timeout: TIMEOUT_COMMAND, **params)
  send id: @id += 1, method: method, params: params
  Timeout.timeout(timeout) do
    messages.find do |message|
      message["id"] == @id
    end
  end.fetch("result")
rescue Timeout::Error, KeyError
  raise Error
end

#messagesObject



143
144
145
146
147
148
149
150
151
152
153
# File 'lib/nswtopo/chrome.rb', line 143

def messages
  Enumerator.produce do
    json = @output.readline(?\0).chomp(?\0)
    JSON.parse(json).tap do |message|
      raise Error if message["error"]
      raise Error if message["method"] == "Target.detachedFromTarget"
    end
  rescue JSON::ParserError, EOFError
    raise Error
  end
end


183
184
185
186
187
188
189
190
191
192
193
# File 'lib/nswtopo/chrome.rb', line 183

def print_to_pdf(pdf_path, &block)
  data = command("Page.printToPDF", timeout: nil, preferCSSPageSize: true).fetch("data")
  pdf = Base64.decode64 data
  if defined? HexaPDF
    HexaPDF::Document.new(io: StringIO.new(pdf)).tap(&block).write(pdf_path.to_s)
  else
    pdf_path.binwrite(pdf)
  end
rescue KeyError
  raise Error
end

#query_selector(selector) ⇒ Object



231
232
233
# File 'lib/nswtopo/chrome.rb', line 231

def query_selector(selector)
  Node.new self, selector
end

#query_selector_node_id(selector) ⇒ Object



195
196
197
198
199
# File 'lib/nswtopo/chrome.rb', line 195

def query_selector_node_id(selector)
  command("DOM.querySelector", selector: selector, nodeId: @node_id).fetch("nodeId")
rescue KeyError
  raise Error
end

#screenshot(png_path) ⇒ Object



176
177
178
179
180
181
# File 'lib/nswtopo/chrome.rb', line 176

def screenshot(png_path)
  data = command("Page.captureScreenshot", timeout: TIMEOUT_SCREENSHOT).fetch("data")
  png_path.binwrite Base64.decode64(data)
rescue KeyError
  raise Error
end

#send(**message) ⇒ Object



138
139
140
141
# File 'lib/nswtopo/chrome.rb', line 138

def send(**message)
  message.merge! sessionId: @session_id if @session_id
  @input.write message.to_json, ?\0
end

#wait(event, timeout: nil) ⇒ Object



155
156
157
158
159
160
161
162
163
# File 'lib/nswtopo/chrome.rb', line 155

def wait(event, timeout: nil)
  Timeout.timeout(timeout) do
    messages.find do |message|
      message["method"] == event
    end
  end
rescue Timeout::Error
  raise Error
end