bidi2pdf 0.1.13 → 0.1.15
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 +4 -4
- data/.devcontainer/Dockerfile +53 -0
- data/.devcontainer/devcontainer.json +51 -0
- data/.devcontainer/docker-compose.yml +33 -0
- data/.ruby-gemset +1 -1
- data/.ruby-version +1 -1
- data/CHANGELOG.md +93 -6
- data/README.md +80 -5
- data/docker/Dockerfile +1 -1
- data/docker/Dockerfile.slim +2 -2
- data/docker/chrome-driver/chrome.json +874 -0
- data/lib/bidi2pdf/bidi/browser_tab.rb +97 -4
- data/lib/bidi2pdf/bidi/buffered_web_socket_client.rb +178 -0
- data/lib/bidi2pdf/bidi/client.rb +12 -5
- data/lib/bidi2pdf/bidi/commands/base.rb +2 -0
- data/lib/bidi2pdf/bidi/commands/capture_screenshot.rb +33 -0
- data/lib/bidi2pdf/bidi/commands/set_viewport.rb +32 -0
- data/lib/bidi2pdf/bidi/commands.rb +2 -0
- data/lib/bidi2pdf/bidi/network_event.rb +12 -3
- data/lib/bidi2pdf/bidi/network_events.rb +9 -1
- data/lib/bidi2pdf/bidi/session.rb +1 -1
- data/lib/bidi2pdf/chromedriver_manager.rb +10 -6
- data/lib/bidi2pdf/cli.rb +11 -1
- data/lib/bidi2pdf/launcher.rb +5 -3
- data/lib/bidi2pdf/notifications/logging_subscriber.rb +2 -0
- data/lib/bidi2pdf/session_warmer.rb +377 -0
- data/lib/bidi2pdf/test_helpers/images.rb +8 -0
- data/lib/bidi2pdf/test_helpers/pdf_reader_utils.rb +1 -2
- data/lib/bidi2pdf/test_helpers/pdf_text_sanitizer.rb +1 -2
- data/lib/bidi2pdf/test_helpers/testcontainers/chromedriver_container.rb +1 -1
- data/lib/bidi2pdf/test_helpers/testcontainers/chromedriver_test_helper.rb +22 -2
- data/lib/bidi2pdf/test_helpers/testcontainers/container_endpoint.rb +30 -0
- data/lib/bidi2pdf/test_helpers/testcontainers/testcontainers_refinement.rb +33 -0
- data/lib/bidi2pdf/test_helpers/testcontainers.rb +1 -0
- data/lib/bidi2pdf/version.rb +1 -1
- data/lib/bidi2pdf.rb +26 -1
- data/sig/bidi2pdf/bidi/add_headers_interceptor.rbs +16 -9
- data/sig/bidi2pdf/bidi/auth_interceptor.rbs +32 -7
- data/sig/bidi2pdf/bidi/browser.rbs +4 -31
- data/sig/bidi2pdf/bidi/browser_console_logger.rbs +59 -0
- data/sig/bidi2pdf/bidi/browser_tab.rbs +269 -25
- data/sig/bidi2pdf/bidi/buffered_web_socket_client.rbs +81 -0
- data/sig/bidi2pdf/bidi/client.rbs +103 -43
- data/sig/bidi2pdf/bidi/command_manager.rbs +5 -17
- data/sig/bidi2pdf/bidi/commands/add_intercept.rbs +16 -8
- data/sig/bidi2pdf/bidi/commands/base.rbs +49 -9
- data/sig/bidi2pdf/bidi/commands/browser_close.rbs +3 -1
- data/sig/bidi2pdf/bidi/commands/browser_create_user_context.rbs +2 -0
- data/sig/bidi2pdf/bidi/commands/browser_remove_user_context.rbs +19 -0
- data/sig/bidi2pdf/bidi/commands/browsing_context_close.rbs +8 -2
- data/sig/bidi2pdf/bidi/commands/browsing_context_navigate.rbs +12 -6
- data/sig/bidi2pdf/bidi/commands/browsing_context_print.rbs +10 -5
- data/sig/bidi2pdf/bidi/commands/cancel_auth.rbs +8 -2
- data/sig/bidi2pdf/bidi/commands/capture_screenshot.rbs +31 -0
- data/sig/bidi2pdf/bidi/commands/cdp_get_session.rbs +17 -0
- data/sig/bidi2pdf/bidi/commands/create_window.rbs +4 -0
- data/sig/bidi2pdf/bidi/commands/get_user_contexts.rbs +2 -0
- data/sig/bidi2pdf/bidi/commands/network_continue.rbs +4 -0
- data/sig/bidi2pdf/bidi/commands/page_print.rbs +28 -0
- data/sig/bidi2pdf/bidi/commands/print_parameters_validator.rbs +3 -0
- data/sig/bidi2pdf/bidi/commands/provide_credentials.rbs +12 -6
- data/sig/bidi2pdf/bidi/commands/script_evaluate.rbs +4 -0
- data/sig/bidi2pdf/bidi/commands/session_end.rbs +2 -0
- data/sig/bidi2pdf/bidi/commands/session_status.rbs +3 -1
- data/sig/bidi2pdf/bidi/commands/session_subscribe.rbs +4 -0
- data/sig/bidi2pdf/bidi/commands/set_tab_cookie.rbs +51 -23
- data/sig/bidi2pdf/bidi/commands/set_usercontext_cookie.rbs +17 -19
- data/sig/bidi2pdf/bidi/commands/set_viewport.rbs +31 -0
- data/sig/bidi2pdf/bidi/connection_manager.rbs +2 -1
- data/sig/bidi2pdf/bidi/event_manager.rbs +14 -19
- data/sig/bidi2pdf/bidi/interceptor.rbs +5 -0
- data/sig/bidi2pdf/bidi/js_logger_helper.rbs +9 -0
- data/sig/bidi2pdf/bidi/logger_events.rbs +21 -0
- data/sig/bidi2pdf/bidi/navigation_failed_events.rbs +17 -0
- data/sig/bidi2pdf/bidi/network_event.rbs +64 -46
- data/sig/bidi2pdf/bidi/network_event_formatters/network_event_console_formatter.rbs +63 -0
- data/sig/bidi2pdf/bidi/network_event_formatters/network_event_formatter_utils.rbs +15 -0
- data/sig/bidi2pdf/bidi/network_event_formatters/network_event_html_formatter.rbs +18 -0
- data/sig/bidi2pdf/bidi/network_event_formatters.rbs +6 -0
- data/sig/bidi2pdf/bidi/network_events.rbs +14 -39
- data/sig/bidi2pdf/bidi/session.rbs +144 -31
- data/sig/bidi2pdf/bidi/user_context.rbs +64 -45
- data/sig/bidi2pdf/bidi/web_socket_dispatcher.rbs +22 -30
- data/sig/bidi2pdf/chromedriver_manager.rbs +61 -22
- data/sig/bidi2pdf/cli.rbs +22 -2
- data/sig/bidi2pdf/dsl.rbs +34 -0
- data/sig/bidi2pdf/launcher.rbs +39 -1
- data/sig/bidi2pdf/notifications/event.rbs +40 -0
- data/sig/bidi2pdf/notifications/instrumenter.rbs +21 -0
- data/sig/bidi2pdf/notifications/logging_subscriber.rbs +41 -0
- data/sig/bidi2pdf/notifications.rbs +24 -0
- data/sig/bidi2pdf/process_tree.rbs +2 -2
- data/sig/bidi2pdf/session_runner.rbs +41 -1
- data/sig/bidi2pdf/session_warmer.rbs +221 -0
- data/sig/bidi2pdf/test_helpers/configuration.rbs +51 -0
- data/sig/bidi2pdf/test_helpers/images/extractor.rbs +43 -0
- data/sig/bidi2pdf/test_helpers/images/image_similarity_checker.rbs +35 -0
- data/sig/bidi2pdf/test_helpers/images/tiff_helper.rbs +70 -0
- data/sig/bidi2pdf/test_helpers/images.rbs +0 -0
- data/sig/bidi2pdf/test_helpers/matchers/contains_pdf_image.rbs +0 -0
- data/sig/bidi2pdf/test_helpers/matchers/contains_pdf_text.rbs +0 -0
- data/sig/bidi2pdf/test_helpers/matchers/have_pdf_page_count.rbs +0 -0
- data/sig/bidi2pdf/test_helpers/matchers/match_pdf_text.rbs +0 -0
- data/sig/bidi2pdf/test_helpers/pdf_file_helper.rbs +23 -0
- data/sig/bidi2pdf/test_helpers/pdf_reader_utils.rbs +49 -0
- data/sig/bidi2pdf/test_helpers/pdf_text_sanitizer.rbs +38 -0
- data/sig/bidi2pdf/test_helpers/spec_paths_helper.rbs +35 -0
- data/sig/bidi2pdf/test_helpers/testcontainers/chromedriver_container.rbs +43 -0
- data/sig/bidi2pdf/test_helpers/testcontainers/chromedriver_test_helper.rbs +45 -0
- data/sig/bidi2pdf/test_helpers/testcontainers/container_endpoint.rbs +25 -0
- data/sig/bidi2pdf/test_helpers/testcontainers/shared_docker_network.rbs +3 -0
- data/sig/bidi2pdf/test_helpers/testcontainers/testcontainers_refinement.rbs +47 -0
- data/sig/bidi2pdf/test_helpers/testcontainers.rbs +6 -0
- data/sig/bidi2pdf/test_helpers.rbs +0 -0
- data/sig/bidi2pdf/verbose_logger.rbs +37 -0
- data/sig/bidi2pdf/version.rbs +3 -0
- data/sig/bidi2pdf.rbs +130 -2
- data/tasks/generate_rbs.rake +11 -11
- data/tasks/release_credentials_check.rake +97 -0
- metadata +67 -10
- data/sig/bidi2pdf/utils.rbs +0 -5
|
@@ -444,6 +444,58 @@ module Bidi2pdf
|
|
|
444
444
|
|
|
445
445
|
# rubocop:enable Metrics/AbcSize, Metrics/PerceivedComplexity
|
|
446
446
|
|
|
447
|
+
# Captures a screenshot of the browser tab, mirroring #print's own
|
|
448
|
+
# file/block/base64-return trichotomy.
|
|
449
|
+
#
|
|
450
|
+
# @param [String, nil] outputfile The output file for the PNG. Defaults to nil.
|
|
451
|
+
# @param [String] origin "document" (default) or "viewport" - see browsingContext.captureScreenshot.
|
|
452
|
+
# @param [String, nil] format Image format override, e.g. "image/png". Defaults to the browser's own default.
|
|
453
|
+
# @param [Hash, nil] clip A clip rectangle/element, passed through verbatim. Defaults to nil (full capture).
|
|
454
|
+
# @yield [png_base64] A block to handle the PNG content.
|
|
455
|
+
# @return [String, nil] The base64-encoded PNG content, or nil if outputfile or block is provided.
|
|
456
|
+
# rubocop:disable Metrics/AbcSize
|
|
457
|
+
def screenshot(outputfile = nil, origin: "document", format: nil, clip: nil, &block)
|
|
458
|
+
Bidi2pdf.notification_service.instrument("screenshot.bidi2pdf") do |instrumentation_payload|
|
|
459
|
+
cmd = Commands::CaptureScreenshot.new(context: browsing_context_id, origin: origin, format: format, clip: clip)
|
|
460
|
+
|
|
461
|
+
instrumentation_payload[:cmd] = cmd
|
|
462
|
+
|
|
463
|
+
client.send_cmd_and_wait(cmd) do |response|
|
|
464
|
+
if response["result"]
|
|
465
|
+
png_base64 = response.dig("result", "data")
|
|
466
|
+
|
|
467
|
+
instrumentation_payload[:png_base64] = png_base64
|
|
468
|
+
|
|
469
|
+
if outputfile
|
|
470
|
+
raise ScreenshotError, "Folder does not exist: #{File.dirname(outputfile)}" unless File.directory?(File.dirname(outputfile))
|
|
471
|
+
|
|
472
|
+
File.binwrite(outputfile, Base64.decode64(png_base64))
|
|
473
|
+
Bidi2pdf.logger.info "Screenshot saved as '#{outputfile}'."
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
block.call(png_base64) if block_given?
|
|
477
|
+
|
|
478
|
+
return png_base64 unless outputfile || block_given?
|
|
479
|
+
else
|
|
480
|
+
Bidi2pdf.logger.error "Error capturing screenshot: #{response}"
|
|
481
|
+
end
|
|
482
|
+
end
|
|
483
|
+
end
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
# rubocop:enable Metrics/AbcSize
|
|
487
|
+
|
|
488
|
+
# Sets the browsing context's viewport - makes a headless render deterministic across
|
|
489
|
+
# machines, unlike leaving it at the browser's own default.
|
|
490
|
+
#
|
|
491
|
+
# @param [Integer] width
|
|
492
|
+
# @param [Integer] height
|
|
493
|
+
# @param [Float, nil] device_pixel_ratio Defaults to nil (browser default).
|
|
494
|
+
def set_viewport(width:, height:, device_pixel_ratio: nil)
|
|
495
|
+
cmd = Commands::SetViewport.new(context: browsing_context_id, width: width, height: height, device_pixel_ratio: device_pixel_ratio)
|
|
496
|
+
client.send_cmd_and_wait(cmd) { |response| response }
|
|
497
|
+
end
|
|
498
|
+
|
|
447
499
|
private
|
|
448
500
|
|
|
449
501
|
def build_command_and_extractor(print_options)
|
|
@@ -481,11 +533,21 @@ module Bidi2pdf
|
|
|
481
533
|
|
|
482
534
|
cmd = Bidi2pdf::Bidi::Commands::BrowsingContextNavigate.new url: url, context: browsing_context_id, wait: wait
|
|
483
535
|
|
|
484
|
-
client.send_cmd_and_wait(cmd) do |response|
|
|
485
|
-
|
|
536
|
+
navigation_id = client.send_cmd_and_wait(cmd) do |response|
|
|
537
|
+
logged_url = url.start_with?("data:") ? "data:[#{url.bytesize} bytes]" : url
|
|
538
|
+
response_navigation_id = response.dig("result", "navigation")
|
|
539
|
+
Bidi2pdf.logger.debug "Navigated to page url: #{logged_url} navigation: #{response_navigation_id}"
|
|
540
|
+
|
|
541
|
+
response_navigation_id
|
|
486
542
|
end
|
|
543
|
+
|
|
544
|
+
check_navigation_http_status(url, navigation_id)
|
|
487
545
|
rescue Bidi2pdf::CmdError => e
|
|
488
|
-
|
|
546
|
+
raise_navigation_error_for(url, e)
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
def raise_navigation_error_for(url, error)
|
|
550
|
+
msg = error.response["message"]
|
|
489
551
|
case msg
|
|
490
552
|
when /^net::ERR_INVALID_AUTH_CREDENTIALS/
|
|
491
553
|
raise NavigationAuthError.new(url, msg)
|
|
@@ -494,10 +556,41 @@ module Bidi2pdf
|
|
|
494
556
|
when /^net::/
|
|
495
557
|
raise NavigationError, "Connection error: #{url} #{msg}"
|
|
496
558
|
else
|
|
497
|
-
raise
|
|
559
|
+
raise error
|
|
498
560
|
end
|
|
499
561
|
end
|
|
500
562
|
|
|
563
|
+
# browsingContext.navigate's own response never carries an HTTP status - only a
|
|
564
|
+
# network.responseCompleted event does, correlated back to this specific navigation via its
|
|
565
|
+
# "navigation" field (a Chrome/redirect-chain-wide ID, not the network request's own id).
|
|
566
|
+
# register_event_listeners already ran above, so network_events has been tracking since
|
|
567
|
+
# before the navigate command was even sent. Deliberately conservative: with no navigation
|
|
568
|
+
# ID, or no correlated *completed* request found, this stays silent rather than guessing -
|
|
569
|
+
# only a positively confirmed >= 400 status raises. max_by(&:start_timestamp) picks the last
|
|
570
|
+
# hop of a redirect chain (every hop shares the same navigation ID), matching the page the
|
|
571
|
+
# browser actually ended up on.
|
|
572
|
+
def check_navigation_http_status(url, navigation_id)
|
|
573
|
+
return unless navigation_id
|
|
574
|
+
|
|
575
|
+
final_response = correlated_navigation_response(navigation_id)
|
|
576
|
+
return unless final_response
|
|
577
|
+
|
|
578
|
+
status = final_response.http_status_code
|
|
579
|
+
return if status < 400
|
|
580
|
+
|
|
581
|
+
raise NavigationNotFoundError, "Navigation to #{url} failed: HTTP 404 Not Found" if status == 404
|
|
582
|
+
|
|
583
|
+
raise NavigationError, "Navigation to #{url} failed: HTTP #{status}"
|
|
584
|
+
end
|
|
585
|
+
|
|
586
|
+
# The last hop of a redirect chain (every hop shares the same navigation ID) - the page the
|
|
587
|
+
# browser actually ended up on, not wherever the chain started.
|
|
588
|
+
def correlated_navigation_response(navigation_id)
|
|
589
|
+
network_events.all_events
|
|
590
|
+
.select { |event| event.navigation == navigation_id && event.http_status_code }
|
|
591
|
+
.max_by(&:start_timestamp)
|
|
592
|
+
end
|
|
593
|
+
|
|
501
594
|
def register_event_listeners
|
|
502
595
|
return if @event_handlers_registered
|
|
503
596
|
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "monitor"
|
|
4
|
+
require "openssl"
|
|
5
|
+
require "socket"
|
|
6
|
+
require "uri"
|
|
7
|
+
require "websocket"
|
|
8
|
+
|
|
9
|
+
module Bidi2pdf
|
|
10
|
+
module Bidi
|
|
11
|
+
# A threaded WebSocket client on the `websocket` gem's framing - the same shape Selenium's Ruby
|
|
12
|
+
# BiDi client uses. It replaces websocket-client-simple, whose reader pulled one byte at a time
|
|
13
|
+
# (`getc`, then a frame-parse attempt per byte): a printed PDF comes back as one base64 message
|
|
14
|
+
# and every network event for a `data:` navigation echoes the whole URL, so that loop ran
|
|
15
|
+
# millions of times per render - ~730 ms to decode a 700 KB frame, against ~0.5 ms in 16 KB
|
|
16
|
+
# chunks.
|
|
17
|
+
#
|
|
18
|
+
# Emits :open, :message (a WebSocket frame, payload in #data), :error and :close.
|
|
19
|
+
class BufferedWebSocketClient
|
|
20
|
+
READ_CHUNK_BYTES = 16_384
|
|
21
|
+
|
|
22
|
+
attr_reader :url
|
|
23
|
+
|
|
24
|
+
def self.connect(url, options = {})
|
|
25
|
+
client = new
|
|
26
|
+
yield client if block_given?
|
|
27
|
+
client.connect url, options
|
|
28
|
+
client
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def initialize
|
|
32
|
+
@listeners = Hash.new { |hash, event| hash[event] = [] }
|
|
33
|
+
@listeners_mutex = Mutex.new
|
|
34
|
+
@write_mutex = Mutex.new
|
|
35
|
+
# Re-entrant: a failed write inside #close closes again.
|
|
36
|
+
@close_monitor = Monitor.new
|
|
37
|
+
@handshaked = false
|
|
38
|
+
@closed = false
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def on(event, &listener)
|
|
42
|
+
@listeners_mutex.synchronize { @listeners[event] << listener }
|
|
43
|
+
listener
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def connect(url, options = {})
|
|
47
|
+
return if @socket
|
|
48
|
+
|
|
49
|
+
@url = url
|
|
50
|
+
@socket = open_socket(URI.parse(url), options)
|
|
51
|
+
::WebSocket.should_raise = true
|
|
52
|
+
@handshake = ::WebSocket::Handshake::Client.new url: url, headers: options[:headers]
|
|
53
|
+
|
|
54
|
+
socket = @socket # #close clears the ivar; the reader keeps its own reference
|
|
55
|
+
@thread = Thread.new { read_loop(socket, ::WebSocket::Frame::Incoming::Client.new) }
|
|
56
|
+
write @handshake.to_s
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Commands are sent from whichever thread issues them. On a plain TCP socket one IO#write is
|
|
60
|
+
# already atomic, but OpenSSL::SSL::SSLSocket#write is not, so writes share a lock for wss://.
|
|
61
|
+
def send(data, type: :text)
|
|
62
|
+
return unless open?
|
|
63
|
+
|
|
64
|
+
write ::WebSocket::Frame::Outgoing::Client.new(data: data, type: type, version: @handshake.version).to_s
|
|
65
|
+
rescue IOError, SystemCallError, OpenSSL::SSL::SSLError => e
|
|
66
|
+
close e
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Safe from any thread, including the reader's own: the reader sees the peer hang up and
|
|
70
|
+
# closes from inside its loop, so it must neither race a caller closing at the same moment
|
|
71
|
+
# nor be killed before :close has been emitted.
|
|
72
|
+
def close(error = nil)
|
|
73
|
+
@close_monitor.synchronize do
|
|
74
|
+
return if @closed
|
|
75
|
+
|
|
76
|
+
say_goodbye unless error
|
|
77
|
+
@closed = true
|
|
78
|
+
@socket&.close
|
|
79
|
+
@socket = nil
|
|
80
|
+
emit :close, error
|
|
81
|
+
@thread.kill if @thread && @thread != Thread.current
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def open? = @handshaked && !@closed
|
|
86
|
+
|
|
87
|
+
def closed? = @closed
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
def emit(event, *)
|
|
92
|
+
@listeners_mutex.synchronize { @listeners[event].dup }.each { |listener| listener.call(*) }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def write(bytes)
|
|
96
|
+
@write_mutex.synchronize { @socket&.write bytes }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def say_goodbye
|
|
100
|
+
write ::WebSocket::Frame::Outgoing::Client.new(data: nil, type: :close, version: @handshake.version).to_s if @handshaked
|
|
101
|
+
rescue IOError, SystemCallError, OpenSSL::SSL::SSLError
|
|
102
|
+
nil # the peer is already gone; there is nobody left to say goodbye to
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def open_socket(uri, options)
|
|
106
|
+
tcp_socket = TCPSocket.new(uri.host, uri.port || (uri.scheme == "wss" ? 443 : 80))
|
|
107
|
+
return tcp_socket unless %w[https wss].include?(uri.scheme)
|
|
108
|
+
|
|
109
|
+
::OpenSSL::SSL::SSLSocket.new(tcp_socket, ssl_context(options)).tap do |ssl_socket|
|
|
110
|
+
ssl_socket.sync_close = true
|
|
111
|
+
ssl_socket.hostname = uri.host
|
|
112
|
+
ssl_socket.connect
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def ssl_context(options)
|
|
117
|
+
::OpenSSL::SSL::SSLContext.new.tap do |context|
|
|
118
|
+
context.ssl_version = options[:ssl_version] if options[:ssl_version]
|
|
119
|
+
context.verify_mode = options[:verify_mode] if options[:verify_mode]
|
|
120
|
+
context.cert_store = (options[:cert_store] || ::OpenSSL::X509::Store.new).tap(&:set_default_paths)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def read_loop(socket, frame)
|
|
125
|
+
until @closed
|
|
126
|
+
begin
|
|
127
|
+
consume(socket.readpartial(READ_CHUNK_BYTES), frame)
|
|
128
|
+
# Same terminal-error list as #send/#say_goodbye - Errno::ECONNRESET is already a
|
|
129
|
+
# SystemCallError, so listing it separately was both redundant and, worse, incomplete: any
|
|
130
|
+
# *other* SystemCallError or OpenSSL::SSL::SSLError fell through to the generic rescue
|
|
131
|
+
# below, which only emits :error and loops back into readpartial - on a permanently broken
|
|
132
|
+
# socket (not just ECONNRESET) that re-raises the same error every iteration forever
|
|
133
|
+
# instead of ever closing.
|
|
134
|
+
rescue IOError, SystemCallError, OpenSSL::SSL::SSLError => e # EOFError is an IOError
|
|
135
|
+
close e
|
|
136
|
+
rescue StandardError => e
|
|
137
|
+
emit :error, e
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def consume(chunk, frame)
|
|
143
|
+
if @handshaked
|
|
144
|
+
frame << chunk
|
|
145
|
+
else
|
|
146
|
+
@handshake << chunk
|
|
147
|
+
return unless @handshake.finished?
|
|
148
|
+
|
|
149
|
+
@handshaked = true
|
|
150
|
+
emit :open
|
|
151
|
+
# Anything the server sent straight after its handshake response is already in this chunk.
|
|
152
|
+
frame << @handshake.leftovers unless @handshake.leftovers.to_s.empty?
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
while (message = frame.next)
|
|
156
|
+
dispatch_frame(message)
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# WebSocket control frames (ping/pong/close) must never reach a JSON-parsing consumer -
|
|
161
|
+
# WebSocketDispatcher tries to parse every :message payload as JSON, so a raw close frame
|
|
162
|
+
# would raise JSON::ParserError, and an unanswered ping can make chromedriver or an
|
|
163
|
+
# intermediate proxy tear down an otherwise healthy connection on its own keepalive timeout.
|
|
164
|
+
def dispatch_frame(message)
|
|
165
|
+
case message.type
|
|
166
|
+
when :ping
|
|
167
|
+
send(message.data, type: :pong)
|
|
168
|
+
when :pong
|
|
169
|
+
nil
|
|
170
|
+
when :close
|
|
171
|
+
close
|
|
172
|
+
else
|
|
173
|
+
emit :message, message
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
data/lib/bidi2pdf/bidi/client.rb
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
require_relative "buffered_web_socket_client"
|
|
6
5
|
require_relative "web_socket_dispatcher"
|
|
7
6
|
require_relative "command_manager"
|
|
8
7
|
require_relative "connection_manager"
|
|
@@ -44,11 +43,11 @@ module Bidi2pdf
|
|
|
44
43
|
|
|
45
44
|
# Starts the WebSocket client and establishes a connection.
|
|
46
45
|
#
|
|
47
|
-
# @return [
|
|
46
|
+
# @return [BufferedWebSocketClient] The WebSocket connection object.
|
|
48
47
|
def start
|
|
49
48
|
return @socket if started?
|
|
50
49
|
|
|
51
|
-
|
|
50
|
+
BufferedWebSocketClient.connect(ws_url) do |socket|
|
|
52
51
|
@socket = socket
|
|
53
52
|
@command_manager = CommandManager.new(@socket)
|
|
54
53
|
|
|
@@ -62,11 +61,19 @@ module Bidi2pdf
|
|
|
62
61
|
@socket
|
|
63
62
|
end
|
|
64
63
|
|
|
65
|
-
# Checks if the WebSocket client has started.
|
|
64
|
+
# Checks if the WebSocket client has started. Unlike #open?, this never flips back to false on
|
|
65
|
+
# its own if the connection dies externally - it only reflects whether #start has run.
|
|
66
66
|
#
|
|
67
67
|
# @return [Boolean] True if the client has started, false otherwise.
|
|
68
68
|
def started? = @started
|
|
69
69
|
|
|
70
|
+
# Checks if the underlying WebSocket connection is actually open right now - false before
|
|
71
|
+
# #start, after #close, or if the reader thread noticed the connection died on its own (a
|
|
72
|
+
# real liveness signal, not just an internal "did we ever start" flag).
|
|
73
|
+
#
|
|
74
|
+
# @return [Boolean] True if the underlying socket reports itself open.
|
|
75
|
+
def open? = !!@socket&.open?
|
|
76
|
+
|
|
70
77
|
# Waits until the WebSocket connection is open.
|
|
71
78
|
#
|
|
72
79
|
# @param [Integer] timeout The timeout duration in seconds.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bidi2pdf
|
|
4
|
+
module Bidi
|
|
5
|
+
module Commands
|
|
6
|
+
class CaptureScreenshot
|
|
7
|
+
include Base
|
|
8
|
+
|
|
9
|
+
attr_reader :context, :origin, :format, :clip
|
|
10
|
+
|
|
11
|
+
def initialize(context:, origin: "document", format: nil, clip: nil)
|
|
12
|
+
@context = context
|
|
13
|
+
@origin = origin
|
|
14
|
+
@format = format
|
|
15
|
+
@clip = clip
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def method_name
|
|
19
|
+
"browsingContext.captureScreenshot"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def params
|
|
23
|
+
{
|
|
24
|
+
context: context,
|
|
25
|
+
origin: origin,
|
|
26
|
+
format: format,
|
|
27
|
+
clip: clip
|
|
28
|
+
}.compact
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bidi2pdf
|
|
4
|
+
module Bidi
|
|
5
|
+
module Commands
|
|
6
|
+
class SetViewport
|
|
7
|
+
include Base
|
|
8
|
+
|
|
9
|
+
attr_reader :context, :width, :height, :device_pixel_ratio
|
|
10
|
+
|
|
11
|
+
def initialize(context:, width:, height:, device_pixel_ratio: nil)
|
|
12
|
+
@context = context
|
|
13
|
+
@width = width
|
|
14
|
+
@height = height
|
|
15
|
+
@device_pixel_ratio = device_pixel_ratio
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def method_name
|
|
19
|
+
"browsingContext.setViewport"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def params
|
|
23
|
+
{
|
|
24
|
+
context: context,
|
|
25
|
+
viewport: { width: width, height: height },
|
|
26
|
+
devicePixelRatio: device_pixel_ratio
|
|
27
|
+
}.compact
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -18,6 +18,8 @@ module Bidi2pdf
|
|
|
18
18
|
require_relative "commands/browsing_context_close"
|
|
19
19
|
require_relative "commands/browsing_context_navigate"
|
|
20
20
|
require_relative "commands/browsing_context_print"
|
|
21
|
+
require_relative "commands/capture_screenshot"
|
|
22
|
+
require_relative "commands/set_viewport"
|
|
21
23
|
require_relative "commands/cdp_get_session"
|
|
22
24
|
require_relative "commands/page_print"
|
|
23
25
|
require_relative "commands/session_subscribe"
|
|
@@ -4,7 +4,7 @@ module Bidi2pdf
|
|
|
4
4
|
module Bidi
|
|
5
5
|
class NetworkEvent
|
|
6
6
|
attr_reader :id, :url, :state, :start_timestamp, :end_timestamp, :timing, :http_status_code,
|
|
7
|
-
:http_method, :bytes_received
|
|
7
|
+
:http_method, :bytes_received, :navigation
|
|
8
8
|
|
|
9
9
|
STATE_MAP = {
|
|
10
10
|
"network.responseStarted" => "started",
|
|
@@ -12,7 +12,13 @@ module Bidi2pdf
|
|
|
12
12
|
"network.fetchError" => "error"
|
|
13
13
|
}.freeze
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
# @param [String, nil] navigation The BiDi navigation ID this request belongs to - present on
|
|
16
|
+
# the main-frame document request, letting a caller correlate a specific
|
|
17
|
+
# browsingContext.navigate call to the network event carrying its actual HTTP status. A
|
|
18
|
+
# sub-resource request (an image, a script, ...) has no real BiDi navigation ID and stays
|
|
19
|
+
# nil here - #id already distinguishes one event from another in logs, so this field is
|
|
20
|
+
# left alone rather than filled with a value the protocol never sent.
|
|
21
|
+
def initialize(id:, url:, timestamp:, timing:, state:, http_status_code: nil, http_method: nil, navigation: nil)
|
|
16
22
|
@id = id
|
|
17
23
|
@url = url
|
|
18
24
|
@start_timestamp = timestamp
|
|
@@ -20,6 +26,7 @@ module Bidi2pdf
|
|
|
20
26
|
@state = map_state(state)
|
|
21
27
|
@http_status_code = http_status_code
|
|
22
28
|
@http_method = http_method
|
|
29
|
+
@navigation = navigation
|
|
23
30
|
end
|
|
24
31
|
|
|
25
32
|
def update_state(new_state, timestamp: nil, timing: nil, http_status_code: nil, bytes_received: nil)
|
|
@@ -61,6 +68,7 @@ module Bidi2pdf
|
|
|
61
68
|
"method=#{method_str.inspect}, " \
|
|
62
69
|
"url=#{@url.inspect}, " \
|
|
63
70
|
"state=#{@state.inspect}, " \
|
|
71
|
+
"navigation=#{@navigation.inspect}, " \
|
|
64
72
|
"#{http_status}, " \
|
|
65
73
|
"bytes_received=#{bytes_str}, " \
|
|
66
74
|
"start=#{start_str}, " \
|
|
@@ -76,7 +84,8 @@ module Bidi2pdf
|
|
|
76
84
|
timing: @timing&.dup,
|
|
77
85
|
state: @state,
|
|
78
86
|
http_status_code: @http_status_code,
|
|
79
|
-
http_method: @http_method
|
|
87
|
+
http_method: @http_method,
|
|
88
|
+
navigation: @navigation
|
|
80
89
|
).tap do |duped|
|
|
81
90
|
duped.instance_variable_set(:@end_timestamp, @end_timestamp)
|
|
82
91
|
duped.instance_variable_set(:@bytes_received, @bytes_received)
|
|
@@ -41,6 +41,13 @@ module Bidi2pdf
|
|
|
41
41
|
timing = request["timings"]
|
|
42
42
|
http_method = request["method"]
|
|
43
43
|
|
|
44
|
+
# A top-level sibling of "request"/"response" in BiDi's own network.BaseParameters, not
|
|
45
|
+
# nested under either - present for the main-frame document request of a navigation, nil
|
|
46
|
+
# for an unrelated sub-resource fetch (an image, a script, ...). Lets a caller correlate a
|
|
47
|
+
# specific browsingContext.navigate call to the exact network event carrying its real HTTP
|
|
48
|
+
# status, which the navigate command's own response never includes.
|
|
49
|
+
navigation = event["navigation"]
|
|
50
|
+
|
|
44
51
|
timestamp = event["timestamp"]
|
|
45
52
|
|
|
46
53
|
Bidi2pdf.notification_service.instrument("network_event_received.bidi2pdf",
|
|
@@ -57,7 +64,8 @@ module Bidi2pdf
|
|
|
57
64
|
timestamp: timestamp,
|
|
58
65
|
timing: timing,
|
|
59
66
|
state: method,
|
|
60
|
-
http_method: http_method
|
|
67
|
+
http_method: http_method,
|
|
68
|
+
navigation: navigation
|
|
61
69
|
)
|
|
62
70
|
elsif events.key?(id)
|
|
63
71
|
events[id].update_state(method, timestamp: timestamp, timing: timing, http_status_code: http_status_code, bytes_received: bytes_received)
|
|
@@ -62,7 +62,7 @@ module Bidi2pdf
|
|
|
62
62
|
"--disable-breakpad", # Disable the Breakpad crash-reporting library entirely
|
|
63
63
|
"--enable-features=PdfOopif", # Enable out-of-process iframe (OOPIF) architecture for PDF rendering
|
|
64
64
|
"--disable-features=Translate,AcceptCHFrame,MediaRouter,OptimizationHints,ProcessPerSiteUpToMainFrameThreshold,IsolateSandboxedIframes",
|
|
65
|
-
"--disable-extensions about:blank"
|
|
65
|
+
"--disable-extensions" # Don't load extensions. One switch per entry: it used to read "--disable-extensions about:blank", which Chrome rejected whole ("Ignoring switch with invalid name").
|
|
66
66
|
].freeze
|
|
67
67
|
|
|
68
68
|
# @return [URI] The URI of the session.
|
|
@@ -194,13 +194,17 @@ module Bidi2pdf
|
|
|
194
194
|
cmd.join(" ")
|
|
195
195
|
end
|
|
196
196
|
|
|
197
|
+
# Chromedriver's own internal log verbosity is independent from this app's - defaults to
|
|
198
|
+
# mirroring Bidi2pdf.logger.level for backward compatibility, but Bidi2pdf.chromedriver_log_level
|
|
199
|
+
# lets a consumer quiet chromedriver's own (often much more verbose, e.g. full BiDi command/
|
|
200
|
+
# response dumps at its own INFO level) output without changing their app's own log level.
|
|
197
201
|
def chromedriver_log_level
|
|
198
|
-
case Bidi2pdf.logger.level
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
202
|
+
Bidi2pdf.chromedriver_log_level || case Bidi2pdf.logger.level
|
|
203
|
+
when Logger::DEBUG then "ALL"
|
|
204
|
+
when Logger::INFO then "INFO"
|
|
205
|
+
when Logger::WARN then "WARNING"
|
|
206
|
+
else "SEVERE"
|
|
207
|
+
end
|
|
204
208
|
end
|
|
205
209
|
|
|
206
210
|
def user_data_dir_arg
|
data/lib/bidi2pdf/cli.rb
CHANGED
|
@@ -40,6 +40,8 @@ module Bidi2pdf
|
|
|
40
40
|
option :header, type: :array, default: [], banner: "name=value", desc: "One or more custom headers", aliases: "-H"
|
|
41
41
|
option :auth, type: :string, banner: "user:pass", desc: "Basic auth credentials", aliases: "-a"
|
|
42
42
|
option :headless, type: :boolean, default: true, desc: "Run Chrome in headless mode"
|
|
43
|
+
option :no_sandbox, type: :boolean, default: false, desc: "Disable Chrome sandbox (needed inside containers)"
|
|
44
|
+
option :chrome_flag, type: :array, default: [], banner: "--flag[=value]", desc: "Extra Chrome flags appended to defaults", aliases: "--cf"
|
|
43
45
|
option :port, type: :numeric, default: 0, desc: "Port to run ChromeDriver on (0 = auto)"
|
|
44
46
|
option :wait_window_loaded,
|
|
45
47
|
type: :boolean,
|
|
@@ -114,6 +116,8 @@ module Bidi2pdf
|
|
|
114
116
|
"url" => "https://example.com",
|
|
115
117
|
"output" => "output.pdf",
|
|
116
118
|
"headless" => true,
|
|
119
|
+
"no_sandbox" => false,
|
|
120
|
+
"chrome_flag" => [],
|
|
117
121
|
"print_options" => {
|
|
118
122
|
"background" => true,
|
|
119
123
|
"orientation" => "portrait",
|
|
@@ -210,6 +214,11 @@ module Bidi2pdf
|
|
|
210
214
|
@launcher ||= begin
|
|
211
215
|
username, password = parse_auth(merged_options[:auth]) if merged_options[:auth]
|
|
212
216
|
|
|
217
|
+
chrome_args = Bidi2pdf::Bidi::Session::DEFAULT_CHROME_ARGS.dup
|
|
218
|
+
no_sandbox = merged_options[:no_sandbox] || ENV["DISABLE_CHROME_SANDBOX"] == "true"
|
|
219
|
+
chrome_args << "--no-sandbox" if no_sandbox
|
|
220
|
+
chrome_args.concat(Array(merged_options[:chrome_flag]))
|
|
221
|
+
|
|
213
222
|
Bidi2pdf::Launcher.new(
|
|
214
223
|
url: merged_options[:url],
|
|
215
224
|
inputfile: merged_options[:hmtl_file],
|
|
@@ -223,7 +232,8 @@ module Bidi2pdf
|
|
|
223
232
|
wait_window_loaded: merged_options[:wait_window_loaded],
|
|
224
233
|
wait_network_idle: merged_options[:wait_network_idle],
|
|
225
234
|
print_options: print_options,
|
|
226
|
-
network_log_format: merged_options[:network_log_format]
|
|
235
|
+
network_log_format: merged_options[:network_log_format],
|
|
236
|
+
chrome_args: chrome_args
|
|
227
237
|
)
|
|
228
238
|
end
|
|
229
239
|
end
|
data/lib/bidi2pdf/launcher.rb
CHANGED
|
@@ -38,7 +38,8 @@ module Bidi2pdf
|
|
|
38
38
|
class Launcher
|
|
39
39
|
# rubocop:disable Metrics/ParameterLists
|
|
40
40
|
def initialize(url:, inputfile:, output:, cookies:, headers:, auth:, headless: true, port: 0, wait_window_loaded: false,
|
|
41
|
-
wait_network_idle: false, print_options: {}, remote_browser_url: nil, network_log_format: :console
|
|
41
|
+
wait_network_idle: false, print_options: {}, remote_browser_url: nil, network_log_format: :console,
|
|
42
|
+
chrome_args: Bidi::Session::DEFAULT_CHROME_ARGS)
|
|
42
43
|
@url = url
|
|
43
44
|
@inputfile = inputfile
|
|
44
45
|
@port = port
|
|
@@ -54,6 +55,7 @@ module Bidi2pdf
|
|
|
54
55
|
@remote_browser_url = remote_browser_url
|
|
55
56
|
@custom_session = nil
|
|
56
57
|
@network_log_format = network_log_format
|
|
58
|
+
@chrome_args = chrome_args
|
|
57
59
|
end
|
|
58
60
|
|
|
59
61
|
# rubocop:enable Metrics/ParameterLists
|
|
@@ -84,9 +86,9 @@ module Bidi2pdf
|
|
|
84
86
|
|
|
85
87
|
def session
|
|
86
88
|
if @remote_browser_url
|
|
87
|
-
@custom_session = Bidi::Session.new(session_url: @remote_browser_url, headless: @headless)
|
|
89
|
+
@custom_session = Bidi::Session.new(session_url: @remote_browser_url, headless: @headless, chrome_args: @chrome_args)
|
|
88
90
|
else
|
|
89
|
-
@manager = ChromedriverManager.new(port: @port, headless: @headless)
|
|
91
|
+
@manager = ChromedriverManager.new(port: @port, headless: @headless, chrome_args: @chrome_args)
|
|
90
92
|
@manager.start
|
|
91
93
|
@manager.session
|
|
92
94
|
end
|