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.



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

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 << "--disable-gpu" if Config["gpu"] == false
  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
# 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
      Timeout.timeout(TIMEOUT_KILL, Error) do
        Process.kill "-USR1", Process.getpgid(pid)
        Process.wait pid
      rescue Error
        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



100
101
102
103
104
105
# File 'lib/nswtopo/chrome.rb', line 100

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

Instance Method Details

#closeObject



94
95
96
97
98
# File 'lib/nswtopo/chrome.rb', line 94

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



162
163
164
165
166
167
168
169
170
171
# File 'lib/nswtopo/chrome.rb', line 162

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



140
141
142
143
144
145
146
147
148
149
150
# File 'lib/nswtopo/chrome.rb', line 140

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


180
181
182
183
184
185
186
187
188
189
190
# File 'lib/nswtopo/chrome.rb', line 180

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



228
229
230
# File 'lib/nswtopo/chrome.rb', line 228

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

#query_selector_node_id(selector) ⇒ Object



192
193
194
195
196
# File 'lib/nswtopo/chrome.rb', line 192

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



173
174
175
176
177
178
# File 'lib/nswtopo/chrome.rb', line 173

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



135
136
137
138
# File 'lib/nswtopo/chrome.rb', line 135

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

#wait(event, timeout: nil) ⇒ Object



152
153
154
155
156
157
158
159
160
# File 'lib/nswtopo/chrome.rb', line 152

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