bidi2pdf 0.1.5 → 0.1.6

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b92ff566ca3ae322033cd9602818c94ae30da2594c7075bbb86f4c16351d58ce
4
- data.tar.gz: 1b4d46aa7fe35c1810522a51400c3558f04d8fdc4093c7b3efa0f507a4959392
3
+ metadata.gz: 541e9193b3285383b2a82065b173af87c68edeb08ec157125761f44285118dff
4
+ data.tar.gz: ab12e33ad21e8377ef964aa12d0d9b0ba757ee941c217febe6d46fb1cc381d5e
5
5
  SHA512:
6
- metadata.gz: 63aa303c4dc6245510cb212adaf22523190d8e00892f4ffe9dc9ba9b65e966f74517ee748f4a232736701a3271ae086879fcbf0adc02ce9f9cd82d103ea3e70d
7
- data.tar.gz: 320a0a03c399541157bd192b6389fecf5541c994cdcaa186694085887ecc8de0829b63badc2be5fa0cd2b8c9baa4ee60bfe7a2f457743d4f21506f1d4bb44850
6
+ metadata.gz: 91be15b3310098c7f16153331fa0a27a8b869dfebc9f3720a7da26d2c59b8444f266e21e93d74891b423e50d8223b01b2198f230753d38ad523b8c846c12150f
7
+ data.tar.gz: 504516ecc1eac40e6c6e159c5f14aafdeb9d23bc587fcf6914ab60d8676a930a7866d6d0c07998398359c402780584f1e5b317c274dd6f3dba0ba50c1a73cbe6
data/CHANGELOG.md CHANGED
@@ -6,10 +6,34 @@ All notable changes to this project will be documented in this file.
6
6
 
7
7
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
8
8
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
9
+
9
10
  [unreleased]: https://github.com/dieter-medium/bidi2pdf/compare/v0.1.5..HEAD
10
11
 
11
12
  <!-- generated by git-cliff end -->
12
13
 
14
+ ## [0.1.6]
15
+
16
+ ### ⚠️ Breaking Changes
17
+
18
+ - Rename view_html_page to render_html by @dieter-medium
19
+ - Rename wait_until_all_finished to wait_until_network_idle by @dieter-medium
20
+
21
+ ### 📝 Docs
22
+
23
+ - Update Docker instructions in README by @dieter-medium
24
+
25
+ ### 🔄 Changed
26
+
27
+ - Add details on network logging and console capture by @dieter-medium
28
+
29
+ ### 🚀 Added
30
+
31
+ - Add PDF network log formatting and customizable outputs by @dieter-medium
32
+ - Add option to log network traffic and handle failures within the cli command by @dieter-medium
33
+ - Add structured network traffic logging by @dieter-medium
34
+ - Add slim variant Dockerfile and build matrix for CI by @dieter-medium
35
+ - Add official image at [Docker Hub](https://hub.docker.com/r/dieters877565/bidi2pdf) by @dieter-medium
36
+
13
37
  ## [0.1.5] - 2025-04-10
14
38
 
15
39
  ### 📝 Docs
@@ -92,3 +116,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
92
116
  ## [0.1.0] - 2025-03-26
93
117
 
94
118
  - Initial release
119
+
120
+ [unreleased]: https://github.com/dieter-medium/bidi2pdf/compare/v0.1.6..HEAD
121
+
122
+ [0.1.6]: https://github.com/dieter-medium/bidi2pdf/compare/v0.1.5..v0.1.6
123
+
124
+ [0.1.5]: https://github.com/dieter-medium/bidi2pdf/compare/v0.1.4..v0.1.5
125
+
126
+ [0.1.4]: https://github.com/dieter-medium/bidi2pdf/compare/v0.1.3..v0.1.4
data/README.md CHANGED
@@ -7,8 +7,8 @@
7
7
 
8
8
  # 📄 Bidi2pdf – Bulletproof PDF generation via Chrome's BiDi Protocol
9
9
 
10
- **Bidi2pdf** is a powerful Ruby gem that transforms modern web pages into high-fidelity PDFs using Chrome’s *
11
- *BiDirectional (BiDi)** protocol. Whether you're automating reports, archiving websites, or shipping documentation,
10
+ **Bidi2pdf** is a powerful Ruby gem that transforms modern web pages into high-fidelity PDFs using Chrome’s
11
+ **BiDirectional (BiDi)** protocol. Whether you're automating reports, archiving websites, or shipping documentation,
12
12
  Bidi2pdf gives you **precision, flexibility, and full control**.
13
13
 
14
14
  ---
@@ -20,7 +20,9 @@ Bidi2pdf gives you **precision, flexibility, and full control**.
20
20
  ✅ **Smart waiting** – Wait for complete page load or network idle
21
21
  ✅ **Headless support** – Run quietly in the background
22
22
  ✅ **Docker-ready** – Plug and play with containers
23
- ✅ **Modern architecture** – Built on Chrome's next-gen BiDi protocol
23
+ ✅ **Modern architecture** – Built on Chrome's next-gen BiDi protocol
24
+ ✅ **Network logging** – Know which requests fail during rendering
25
+ ✅ **Console log capture** – See what goes wrong inside the browser
24
26
 
25
27
  ---
26
28
 
@@ -96,8 +98,8 @@ launcher.launch
96
98
  require "bidi2pdf"
97
99
 
98
100
  Bidi2pdf::DSL.with_tab(headless: true) do |tab|
99
- tab.open_page("https://example.com")
100
- tab.wait_until_all_finished
101
+ tab.navigate_to("https://example.com")
102
+ tab.wait_until_network_idle
101
103
  tab.print("example.pdf")
102
104
  end
103
105
  ```
@@ -142,12 +144,12 @@ tab.basic_auth(url_patterns: [{ type: "pattern", protocol: "https", hostname: "e
142
144
  username: "username", password: "secret")
143
145
 
144
146
  # 4. Render PDF
145
- tab.open_page "https://example.com"
147
+ tab.navigate_to "https://example.com"
146
148
 
147
149
  # Alternative: send html code to the browser
148
- # tab.view_html_page("<html>...</html>")
150
+ # tab.render_html_content("<html>...</html>")
149
151
 
150
- tab.wait_until_all_finished
152
+ tab.wait_until_network_idle
151
153
  tab.print("my.pdf")
152
154
 
153
155
  # 5. Cleanup
@@ -162,15 +164,36 @@ session.close
162
164
 
163
165
  ## 🐳 Docker Support
164
166
 
165
- ### Build & Run
167
+ ### 🛠️ Build & Run Locally
166
168
 
167
169
  ```bash
170
+ # Prepare the environment
168
171
  rake build
172
+
173
+ # Build the Docker image
169
174
  docker build -t bidi2pdf -f docker/Dockerfile .
170
- docker run -it --rm -v ./output:/reports bidi2pdf \
175
+
176
+ # Run the container and generate a PDF
177
+ docker run -it --rm \
178
+ -v ./output:/reports \
179
+ bidi2pdf \
180
+ bidi2pdf render --url=https://example.com --output /reports/example.pdf
181
+
182
+ ```
183
+
184
+ ### ⚡ Use the Prebuilt Image (Recommended for Fast Start)
185
+
186
+ Grab it directly from [Docker Hub](https://hub.docker.com/r/dieters877565/bidi2pdf)
187
+
188
+ ```bash
189
+ docker run -it --rm \
190
+ -v ./output:/reports \
191
+ dieters877565/bidi2pdf:main-slim \
171
192
  bidi2pdf render --url=https://example.com --output /reports/example.pdf
172
193
  ```
173
194
 
195
+ ✅ Tip: Mount your local directory (e.g. ./output) to /reports in the container to easily access the generated PDFs.
196
+
174
197
  ### Docker Compose
175
198
 
176
199
  ```bash
data/docker/Dockerfile CHANGED
@@ -1,9 +1,11 @@
1
1
  FROM ruby:3.3
2
2
 
3
+ ENV DEBIAN_FRONTEND=noninteractive
4
+
3
5
  # Install dependencies
4
- RUN apt-get update && \
5
- apt-get install -y \
6
- chromium \
6
+ RUN apt-get update && apt-get upgrade &&\
7
+ apt-get install -y --no-install-recommends\
8
+ chromium chromium-driver\
7
9
  libglib2.0-0 \
8
10
  libnss3 \
9
11
  libxss1 \
@@ -19,6 +21,12 @@ RUN apt-get update && \
19
21
  # Create a non-root user
20
22
  RUN groupadd -r appuser && useradd -r -g appuser -m -d /home/appuser appuser
21
23
 
24
+ # ARM compatibility workaround:
25
+ # On ARM architectures (such as Apple Silicon), downloading chromedriver via automated scripts may fail or cause ELF binary errors,
26
+ # such as "rosetta error: failed to open elf at /lib64/ld-linux-x86-64.so.2".
27
+ # To avoid these issues, we directly install 'chromium-driver' via the package manager and explicitly create a symlink in the expected location.
28
+
29
+ RUN mkdir -p /home/appuser/.webdrivers && ln -s /usr/bin/chromedriver /home/appuser/.webdrivers/chromedriver
22
30
 
23
31
  # Set working directory
24
32
  WORKDIR /app
@@ -2,9 +2,11 @@ FROM ruby:3.3
2
2
 
3
3
  ARG CHROMEDRIVER_PORT=3000
4
4
 
5
+ ENV DEBIAN_FRONTEND=noninteractive
6
+
5
7
  # Install dependencies
6
- RUN apt-get update && \
7
- apt-get install -y \
8
+ RUN apt-get update && apt-get upgrade && \
9
+ apt-get install -y --no-install-recommends\
8
10
  chromium \
9
11
  libglib2.0-0 \
10
12
  libnss3 \
@@ -0,0 +1,75 @@
1
+ FROM ruby:3.3-slim AS builder
2
+
3
+ ENV DEBIAN_FRONTEND=noninteractive
4
+
5
+ # Install dependencies
6
+ RUN apt-get update && apt-get upgrade && \
7
+ apt-get install -y --no-install-recommends \
8
+ chromium \
9
+ libglib2.0-0 \
10
+ libnss3 \
11
+ libxss1 \
12
+ libasound2 \
13
+ libatk-bridge2.0-0 \
14
+ libgtk-3-0 \
15
+ libdrm2 \
16
+ curl \
17
+ unzip \
18
+ xvfb \
19
+ build-essential \
20
+ libpq-dev pkg-config \
21
+ && rm -rf /var/lib/apt/lists/*
22
+
23
+ # Set working directory
24
+ WORKDIR /app
25
+
26
+ # Copy your gem into container
27
+ COPY ./pkg/bidi2pdf-*.gem ./
28
+
29
+ RUN gem install ./bidi2pdf-*.gem
30
+
31
+
32
+ # Stage 2
33
+
34
+ FROM ruby:3.3-slim
35
+
36
+ ENV DEBIAN_FRONTEND=noninteractive
37
+
38
+ # Install dependencies
39
+ RUN apt-get update && apt-get upgrade &&\
40
+ apt-get install -y --no-install-recommends\
41
+ chromium chromium-driver\
42
+ libglib2.0-0 \
43
+ libnss3 \
44
+ libxss1 \
45
+ libasound2 \
46
+ libatk-bridge2.0-0 \
47
+ libgtk-3-0 \
48
+ libdrm2 \
49
+ curl \
50
+ unzip \
51
+ xvfb \
52
+ && rm -rf /var/lib/apt/lists/*
53
+
54
+ COPY --from=builder /usr/local/bundle /usr/local/bundle
55
+
56
+ # Create a non-root user
57
+ RUN groupadd -r appuser && useradd -r -g appuser -m -d /home/appuser appuser
58
+
59
+ # ARM compatibility workaround:
60
+ # On ARM architectures (such as Apple Silicon), downloading chromedriver via automated scripts may fail or cause ELF binary errors,
61
+ # such as "rosetta error: failed to open elf at /lib64/ld-linux-x86-64.so.2".
62
+ # To avoid these issues, we directly install 'chromium-driver' via the package manager and explicitly create a symlink in the expected location.
63
+
64
+ RUN mkdir -p /home/appuser/.webdrivers && ln -s /usr/bin/chromedriver /home/appuser/.webdrivers/chromedriver
65
+
66
+ # Set working directory
67
+ WORKDIR /app
68
+
69
+ RUN chown -R appuser:appuser /app
70
+
71
+ # Switch to non-root user
72
+ USER appuser
73
+
74
+ CMD ["/usr/bin/bash"]
75
+
@@ -3,13 +3,14 @@
3
3
  require "base64"
4
4
 
5
5
  require_relative "network_events"
6
+ require_relative "logger_events"
6
7
  require_relative "auth_interceptor"
7
8
  require_relative "add_headers_interceptor"
8
9
 
9
10
  module Bidi2pdf
10
11
  module Bidi
11
12
  class BrowserTab
12
- attr_reader :client, :browsing_context_id, :user_context_id, :tabs, :network_events, :open
13
+ attr_reader :client, :browsing_context_id, :user_context_id, :tabs, :network_events, :open, :logger_events
13
14
 
14
15
  def initialize(client, browsing_context_id, user_context_id)
15
16
  @client = client
@@ -17,6 +18,7 @@ module Bidi2pdf
17
18
  @user_context_id = user_context_id
18
19
  @tabs = []
19
20
  @network_events = NetworkEvents.new browsing_context_id
21
+ @logger_events = LoggerEvents.new browsing_context_id
20
22
  @open = true
21
23
  end
22
24
 
@@ -77,10 +79,13 @@ module Bidi2pdf
77
79
  ).tap { |interceptor| interceptor.register_with_client(client: client) }
78
80
  end
79
81
 
80
- def open_page(url)
81
- client.on_event("network.responseStarted", "network.responseCompleted", "network.fetchError",
82
+ def navigate_to(url)
83
+ client.on_event("network.beforeRequestSent", "network.responseStarted", "network.responseCompleted", "network.fetchError",
82
84
  &network_events.method(:handle_event))
83
85
 
86
+ client.on_event("log.entryAdded",
87
+ &logger_events.method(:handle_event))
88
+
84
89
  cmd = Bidi2pdf::Bidi::Commands::BrowsingContextNavigate.new url: url, context: browsing_context_id
85
90
 
86
91
  client.send_cmd_and_wait(cmd) do |response|
@@ -88,11 +93,11 @@ module Bidi2pdf
88
93
  end
89
94
  end
90
95
 
91
- def view_html_page(html_content)
96
+ def render_html_content(html_content)
92
97
  base64_encoded = Base64.strict_encode64(html_content)
93
98
  data_url = "data:text/html;charset=utf-8;base64,#{base64_encoded}"
94
99
 
95
- open_page(data_url)
100
+ navigate_to(data_url)
96
101
  end
97
102
 
98
103
  def execute_script(script)
@@ -104,8 +109,29 @@ module Bidi2pdf
104
109
  end
105
110
  end
106
111
 
107
- def wait_until_all_finished(timeout: 10, poll_interval: 0.1)
108
- network_events.wait_until_all_finished(timeout: timeout, poll_interval: poll_interval)
112
+ def wait_until_network_idle(timeout: 10, poll_interval: 0.1)
113
+ network_events.wait_until_network_idle(timeout: timeout, poll_interval: poll_interval)
114
+ end
115
+
116
+ def log_network_traffic(format: :console, output: nil, print_options: { background: true }, &block)
117
+ format = format.to_sym
118
+
119
+ if format == :console
120
+ network_events.log_network_traffic format: :console
121
+ elsif format == :pdf
122
+ html_content = network_events.log_network_traffic format: :html
123
+
124
+ return unless html_content
125
+
126
+ logging_tab = create_browser_tab
127
+
128
+ logging_tab.render_html_content(html_content)
129
+ logging_tab.wait_until_network_idle
130
+
131
+ logging_tab.print(output, print_options: print_options, &block)
132
+
133
+ logging_tab.close
134
+ end
109
135
  end
110
136
 
111
137
  def close
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "network_event"
4
+
5
+ module Bidi2pdf
6
+ module Bidi
7
+ class LoggerEvents
8
+ attr_reader :context_id
9
+
10
+ def initialize(context_id)
11
+ @context_id = context_id
12
+ end
13
+
14
+ def handle_event(data)
15
+ event = data["params"]
16
+ method = data["method"]
17
+
18
+ if event.dig("source", "context") == context_id
19
+ handle_response(method, event)
20
+ else
21
+ Bidi2pdf.logger.debug "Ignoring Log event: #{method}, context_id: #{context_id}, params: #{event}"
22
+ end
23
+ rescue StandardError => e
24
+ Bidi2pdf.logger.error "Error handling Log event: #{e.message}\n#{e.backtrace&.join("\n")}"
25
+ end
26
+
27
+ def handle_response(_method, event)
28
+ level = resolve_log_level(event["level"])
29
+ text = event["text"]
30
+ args = event["args"] || []
31
+ stack_trace = event["stackTrace"]
32
+ timestamp = format_timestamp(event["timestamp"])
33
+ prefix = log_prefix(timestamp)
34
+
35
+ log_message(level, prefix, text)
36
+ log_args(prefix, args)
37
+ log_stack_trace(prefix, stack_trace) if stack_trace && level == :error
38
+ end
39
+
40
+ private
41
+
42
+ def log_message(level, prefix, text)
43
+ return unless text
44
+
45
+ Bidi2pdf.logger.send(level, "#{prefix} #{text}")
46
+ end
47
+
48
+ def log_args(prefix, args)
49
+ return if args.empty?
50
+
51
+ Bidi2pdf.logger.debug("#{prefix} Args: #{args.inspect}")
52
+ end
53
+
54
+ def log_stack_trace(prefix, trace)
55
+ formatted_trace = format_stack_trace(trace)
56
+ Bidi2pdf.logger.error("#{prefix} Stack trace captured:\n#{formatted_trace}")
57
+ end
58
+
59
+ def format_timestamp(timestamp)
60
+ return "N/A" unless timestamp
61
+
62
+ Time.at(timestamp.to_f / 1000).utc.strftime("%Y-%m-%d %H:%M:%S.%L UTC")
63
+ end
64
+
65
+ def format_stack_trace(trace)
66
+ trace["callFrames"].each_with_index.map do |frame, index|
67
+ function = frame["functionName"].to_s.empty? ? "(anonymous)" : frame["functionName"]
68
+ "##{index} #{function} at #{frame["url"]}:#{frame["lineNumber"]}:#{frame["columnNumber"]}"
69
+ end.join("\n")
70
+ end
71
+
72
+ def resolve_log_level(js_level)
73
+ case js_level
74
+ when "info", "warn", "error"
75
+ js_level.to_sym
76
+ else
77
+ :debug
78
+ end
79
+ end
80
+
81
+ def log_prefix(timestamp)
82
+ "[#{timestamp}][Browser Console Log]"
83
+ end
84
+ end
85
+ end
86
+ end
@@ -3,7 +3,8 @@
3
3
  module Bidi2pdf
4
4
  module Bidi
5
5
  class NetworkEvent
6
- attr_reader :id, :url, :state, :start_timestamp, :end_timestamp, :timing
6
+ attr_reader :id, :url, :state, :start_timestamp, :end_timestamp, :timing, :http_status_code,
7
+ :http_method, :bytes_received
7
8
 
8
9
  STATE_MAP = {
9
10
  "network.responseStarted" => "started",
@@ -11,18 +12,22 @@ module Bidi2pdf
11
12
  "network.fetchError" => "error"
12
13
  }.freeze
13
14
 
14
- def initialize(id:, url:, timestamp:, timing:, state:)
15
+ def initialize(id:, url:, timestamp:, timing:, state:, http_status_code: nil, http_method: nil)
15
16
  @id = id
16
17
  @url = url
17
18
  @start_timestamp = timestamp
18
19
  @timing = timing
19
20
  @state = map_state(state)
21
+ @http_status_code = http_status_code
22
+ @http_method = http_method
20
23
  end
21
24
 
22
- def update_state(new_state, timestamp: nil, timing: nil)
25
+ def update_state(new_state, timestamp: nil, timing: nil, http_status_code: nil, bytes_received: nil)
23
26
  @state = map_state(new_state)
24
27
  @end_timestamp = timestamp if timestamp
25
28
  @timing = timing if timing
29
+ @http_status_code = http_status_code if http_status_code
30
+ @bytes_received = bytes_received if bytes_received
26
31
  end
27
32
 
28
33
  def map_state(state)
@@ -44,10 +49,23 @@ module Bidi2pdf
44
49
  def in_progress? = state == "started"
45
50
 
46
51
  def to_s
47
- took_str = duration_seconds ? "took #{duration_seconds} sec" : "in progress"
48
- "#<NetworkEvent id=#{@id} url=#{@url} state=#{@state} " \
49
- "start=#{format_timestamp(@start_timestamp)} " \
50
- "end=#{format_timestamp(@end_timestamp)} #{took_str}>"
52
+ took_str = duration_seconds ? "#{duration_seconds.round(2)} sec" : "in progress"
53
+ http_status = @http_status_code ? "HTTP #{@http_status_code}" : "HTTP (N/A)"
54
+ start_str = format_timestamp(@start_timestamp) || "N/A"
55
+ end_str = format_timestamp(@end_timestamp) || "N/A"
56
+ method_str = @http_method || "N/A"
57
+ bytes_str = @bytes_received ? "#{@bytes_received} bytes" : "0 bytes"
58
+
59
+ "#<NetworkEvent " \
60
+ "id=#{@id.inspect}, " \
61
+ "method=#{method_str.inspect}, " \
62
+ "url=#{@url.inspect}, " \
63
+ "state=#{@state.inspect}, " \
64
+ "#{http_status}, " \
65
+ "bytes_received=#{bytes_str}, " \
66
+ "start=#{start_str}, " \
67
+ "end=#{end_str}, " \
68
+ "duration=#{took_str}>"
51
69
  end
52
70
  end
53
71
  end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bidi2pdf
4
+ module Bidi
5
+ module NetworkEventFormatters
6
+ # rubocop: disable Metrics/AbcSize
7
+ class NetworkEventConsoleFormatter
8
+ include NetworkEventFormatterUtils
9
+
10
+ attr_reader :color_enabled
11
+
12
+ # ANSI styles
13
+ RESET = "\e[0m"
14
+ BOLD = "\e[1m"
15
+ DIM = "\e[2m"
16
+ RED = "\e[31m"
17
+ GREEN = "\e[32m"
18
+ YELLOW = "\e[33m"
19
+ CYAN = "\e[36m"
20
+ GRAY = "\e[90m"
21
+
22
+ def initialize(color: true)
23
+ @color_enabled = color
24
+ end
25
+
26
+ def log(events)
27
+ events.each { |event| pretty_log(event).each_line { |line| Bidi2pdf.network_events_logger.info(line.chomp) } }
28
+ end
29
+
30
+ def pretty_log(event)
31
+ status = event.http_status_code ? "HTTP #{event.http_status_code}" : "pending"
32
+ status_color = color_for_status(event.http_status_code)
33
+ start = event.format_timestamp(event.start_timestamp)
34
+ finish = event.end_timestamp ? event.format_timestamp(event.end_timestamp) : dim("...")
35
+ duration = event.duration_seconds ? cyan("#{event.duration_seconds}s") : dim("in progress")
36
+ timing_details = format_timing(event)
37
+ bytes = event.bytes_received ? format_bytes(event.bytes_received) : dim("N/A")
38
+ displayed_url = shorten_url(event.url)
39
+
40
+ <<~LOG.strip
41
+ #{bold("┌─ Network Event ──────────────────────────────────────")}
42
+ #{bold("│ Request: ")}#{event.http_method || "?"} #{displayed_url}#{" "}
43
+ #{bold("│ State: ")}#{event.state}
44
+ #{bold("│ Status: ")}#{status_color}#{status}#{reset}
45
+ #{bold("│ Started: ")}#{start}
46
+ #{bold("│ Ended: ")}#{finish}
47
+ #{bold("│ Duration:")} #{duration}
48
+ #{bold("│ Received:")} #{bytes}
49
+ #{timing_details}
50
+ #{bold("└──────────────────────────────────────────────────────")}
51
+ LOG
52
+ end
53
+
54
+ private
55
+
56
+ def format_timing(event)
57
+ return "" unless event.timing.is_a?(Hash)
58
+
59
+ keys = %w[
60
+ requestTime proxyStart proxyEnd dnsStart dnsEnd connectStart connectEnd
61
+ sslStart sslEnd workerStart workerReady sendStart sendEnd receiveHeadersEnd
62
+ ]
63
+
64
+ visible = keys.map do |key|
65
+ next unless event.timing[key]
66
+
67
+ label = key.gsub(/([A-Z])/, ' \1').capitalize
68
+ "#{dim("│")} #{label.ljust(20)}: #{event.timing[key].round(2)} ms#{reset}"
69
+ end.compact
70
+
71
+ return "" if visible.empty?
72
+
73
+ [dim("│").to_s, dim("│ Timing Phases:").to_s].concat(visible).join("\n")
74
+ end
75
+
76
+ # === Color Helpers ===
77
+
78
+ def color_for_status(code)
79
+ return gray unless code
80
+
81
+ case code.to_i
82
+ when 200..299 then green
83
+ when 300..499 then yellow
84
+ when 500..599 then red
85
+ else gray
86
+ end
87
+ end
88
+
89
+ def bold(str) = color_enabled ? "#{BOLD}#{str}#{RESET}" : str
90
+
91
+ def dim(str) = color_enabled ? "#{DIM}#{str}#{RESET}" : str
92
+
93
+ def green(str = "") = color_enabled ? "#{GREEN}#{str}" : str
94
+
95
+ def yellow(str = "") = color_enabled ? "#{YELLOW}#{str}" : str
96
+
97
+ def red(str = "") = color_enabled ? "#{RED}#{str}" : str
98
+
99
+ def cyan(str = "") = color_enabled ? "#{CYAN}#{str}" : str
100
+
101
+ def gray(str = "") = color_enabled ? "#{GRAY}#{str}" : str
102
+
103
+ def reset = color_enabled ? RESET : ""
104
+
105
+ # rubocop: enable Metrics/AbcSize
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+
5
+ module Bidi2pdf
6
+ module Bidi
7
+ module NetworkEventFormatters
8
+ module NetworkEventFormatterUtils
9
+ def format_bytes(size)
10
+ return "N/A" unless size.is_a?(Numeric)
11
+
12
+ units = %w[B KB MB GB TB]
13
+ idx = 0
14
+ while size >= 1024 && idx < units.size - 1
15
+ size /= 1024.0
16
+ idx += 1
17
+ end
18
+ format("%<size>.2f %<unit>s", size: size, unit: units[idx])
19
+ end
20
+
21
+ def parse_timing(event)
22
+ return [] unless event.timing.is_a?(Hash)
23
+
24
+ keys = %w[
25
+ requestTime proxyStart proxyEnd dnsStart dnsEnd connectStart connectEnd
26
+ sslStart sslEnd workerStart workerReady sendStart sendEnd receiveHeadersEnd
27
+ ]
28
+
29
+ keys.filter_map do |key|
30
+ next unless event.timing[key]
31
+
32
+ label = key.gsub(/([A-Z])/, ' \1').capitalize
33
+ { label: label, key: key, ms: event.timing[key].round(2) }
34
+ end
35
+ end
36
+
37
+ def format_timestamp(timestamp)
38
+ return "N/A" unless timestamp
39
+
40
+ Time.at(timestamp.to_f / 1000).utc.strftime("%Y-%m-%d %H:%M:%S.%L UTC")
41
+ end
42
+
43
+ def shorten_url(url)
44
+ sanitized_url = CGI.escapeHTML(url)
45
+
46
+ return sanitized_url unless sanitized_url.start_with?("data:text/html")
47
+
48
+ "data:text/html,..."
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bidi2pdf
4
+ module Bidi
5
+ module NetworkEventFormatters
6
+ class NetworkEventHtmlFormatter
7
+ include NetworkEventFormatterUtils
8
+
9
+ def render(events)
10
+ return unless Bidi2pdf.network_events_logger.info?
11
+
12
+ <<~HTML
13
+ <!DOCTYPE html>
14
+ <html lang="en" data-bs-theme="light">
15
+ <head>
16
+ <meta charset="UTF-8">
17
+ <title>Network Events</title>
18
+ <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
19
+ <style>
20
+ body { font-family: monospace; padding: 2rem; }
21
+ .event { background: var(--bs-body-bg); border: 1px solid var(--bs-border-color); padding: 1rem; margin-bottom: 2rem; border-radius: .5rem; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
22
+ .bar-container { position: relative; height: 20px; background: var(--bs-secondary-bg); border-radius: 4px; }
23
+ .bar { position: absolute; top: 0; height: 100%; background-color: #0d6efd; opacity: 0.8; }
24
+ .toc a { text-decoration: none; display: block; margin-bottom: 0.5rem; }
25
+
26
+ @media print {
27
+ .no-break {
28
+ page-break-inside: avoid;
29
+ }
30
+ #{" "}
31
+ #{" "}
32
+ .form-select, .form-label, #theme-select {
33
+ display: none !important; /* Hide theme selector when printing */
34
+ }
35
+ }
36
+
37
+ .event {
38
+ word-break: break-word;
39
+ overflow-wrap: anywhere;
40
+ }
41
+ </style>
42
+ <script>
43
+ function toggleTheme(value) {
44
+ document.documentElement.setAttribute('data-bs-theme', value);
45
+ }
46
+ </script>
47
+ </head>
48
+ <body>
49
+ <h1>Network Events</h1>
50
+ <div class="mb-4">
51
+ <label for="theme-select" class="form-label">Theme:</label>
52
+ <select id="theme-select" class="form-select w-auto d-inline-block" onchange="toggleTheme(this.value)">
53
+ <option value="light">Light</option>
54
+ <option value="dark">Dark</option>
55
+ </select>
56
+ </div>
57
+
58
+ <h2>Index</h2>
59
+ <div class="toc mb-4">
60
+ #{events.map.with_index { |e, i| toc_entry(e, i) }.join("\n")}
61
+ </div>
62
+
63
+ #{events.map.with_index { |e, i| render_event(e, i) }.join("\n")}
64
+ </body>
65
+ </html>
66
+ HTML
67
+ end
68
+
69
+ def toc_entry(event, index)
70
+ "<a href=\"#event-#{index}\">[#{index + 1}] #{event.http_method} #{event.url}</a>"
71
+ end
72
+
73
+ # rubocop: disable Metrics/AbcSize
74
+ def render_event(event, index)
75
+ timing = parse_timing(event)
76
+ duration = event.duration_seconds || 0
77
+ duration_str = event.in_progress? ? "in progress" : "#{duration}s"
78
+ status = event.http_status_code || "?"
79
+ method = event.http_method || "?"
80
+ start = format_timestamp(event.start_timestamp)
81
+ finish = event.end_timestamp ? format_timestamp(event.end_timestamp) : "..."
82
+ bytes = event.bytes_received ? format_bytes(event.bytes_received) : "N/A"
83
+ bars = render_timing_bars(timing)
84
+ displayed_url = shorten_url(event.url)
85
+
86
+ <<~HTML
87
+ <div class="event no-break" id="event-#{index}">
88
+ <div><strong>Request:</strong> #{method} #{displayed_url}</div>
89
+ <div><strong>Status:</strong> HTTP #{status}</div>
90
+ <div><strong>State:</strong> #{event.state}</div>
91
+ <div><strong>Start:</strong> #{start}</div>
92
+ <div><strong>End:</strong> #{finish}</div>
93
+ <div><strong>Duration:</strong> #{duration_str}</div>
94
+ <div><strong>Received:</strong> #{bytes}</div>
95
+ #{bars}
96
+ </div>
97
+ HTML
98
+ end
99
+
100
+ # rubocop: enable Metrics/AbcSize
101
+
102
+ def render_timing_bars(timing)
103
+ return "" if timing.empty?
104
+
105
+ max_ms = timing.map { |t| t[:ms] }.max
106
+ scale = max_ms.zero? ? 0 : 100.0 / max_ms
107
+
108
+ bars = timing.map do |t|
109
+ width = (t[:ms] * scale).clamp(1, 100).round(2)
110
+ <<~HTML
111
+ <div>
112
+ <small>#{t[:label]} (#{t[:ms]} ms)</small>
113
+ <div class="bar-container mb-2">
114
+ <div class="bar" style="width: #{width}%"></div>
115
+ </div>
116
+ </div>
117
+ HTML
118
+ end
119
+
120
+ "<div class=\"mt-3\"><strong>Timing Waterfall</strong>#{bars.join}</div>"
121
+ end
122
+ end
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bidi2pdf
4
+ module Bidi
5
+ module NetworkEventFormatters
6
+ require_relative "network_event_formatters/network_event_formatter_utils"
7
+ require_relative "network_event_formatters/network_event_console_formatter"
8
+ require_relative "network_event_formatters/network_event_html_formatter"
9
+ end
10
+ end
11
+ end
@@ -1,15 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "network_event"
4
+ require_relative "network_event_formatters"
4
5
 
5
6
  module Bidi2pdf
6
7
  module Bidi
7
8
  class NetworkEvents
8
- attr_reader :context_id, :events
9
+ attr_reader :context_id, :events, :network_event_formatter
9
10
 
10
11
  def initialize(context_id)
11
12
  @context_id = context_id
12
13
  @events = {}
14
+ @network_event_formatter = NetworkEventFormatters::NetworkEventConsoleFormatter.new
13
15
  end
14
16
 
15
17
  def handle_event(data)
@@ -30,23 +32,28 @@ module Bidi2pdf
30
32
  return unless event && event["request"]
31
33
 
32
34
  request = event["request"]
35
+ response = event["response"]
36
+ http_status_code = response&.dig("status")
37
+ bytes_received = response&.dig("bytesReceived")
33
38
 
34
39
  id = request["request"]
35
40
  url = request["url"]
36
41
  timing = request["timings"]
42
+ http_method = request["method"]
37
43
 
38
44
  timestamp = event["timestamp"]
39
45
 
40
- if method == "network.responseStarted"
46
+ if method == "network.beforeRequestSent"
41
47
  events[id] ||= NetworkEvent.new(
42
48
  id: id,
43
49
  url: url,
44
50
  timestamp: timestamp,
45
51
  timing: timing,
46
- state: method
52
+ state: method,
53
+ http_method: http_method
47
54
  )
48
55
  elsif events.key?(id)
49
- events[id].update_state(method, timestamp: timestamp, timing: timing)
56
+ events[id].update_state(method, timestamp: timestamp, timing: timing, http_status_code: http_status_code, bytes_received: bytes_received)
50
57
  else
51
58
  Bidi2pdf.logger.warn "Received response for unknown request ID: #{id}, URL: #{url}"
52
59
  end
@@ -58,7 +65,19 @@ module Bidi2pdf
58
65
  events.values.sort_by(&:start_timestamp)
59
66
  end
60
67
 
61
- def wait_until_all_finished(timeout: 10, poll_interval: 0.1)
68
+ def log_network_traffic(format: :console)
69
+ format = format.to_sym
70
+
71
+ if format == :console
72
+ NetworkEventFormatters::NetworkEventConsoleFormatter.new.log all_events
73
+ elsif format == :html
74
+ NetworkEventFormatters::NetworkEventHtmlFormatter.new.render(all_events)
75
+ else
76
+ raise ArgumentError, "Unknown network event format: #{format}"
77
+ end
78
+ end
79
+
80
+ def wait_until_network_idle(timeout: 10, poll_interval: 0.1)
62
81
  start_time = Time.now
63
82
 
64
83
  loop do
@@ -10,7 +10,7 @@ require_relative "user_context"
10
10
  module Bidi2pdf
11
11
  module Bidi
12
12
  class Session
13
- SUBSCRIBE_EVENTS = %w[log script].freeze
13
+ SUBSCRIBE_EVENTS = %w[script].freeze
14
14
  DEFAULT_CHROME_ARGS = %w[--disable-gpu --disable-popup-blocking --disable-hang-monitor].freeze
15
15
 
16
16
  attr_reader :session_uri, :started, :chrome_args
data/lib/bidi2pdf/cli.rb CHANGED
@@ -51,6 +51,12 @@ module Bidi2pdf
51
51
  option :log_level,
52
52
  type: :string,
53
53
  default: "info", enum: %w[debug info warn error fatal unknown], desc: "Set log level"
54
+ option :log_network_traffic, type: :boolean, default: false, desc: "Log network traffic", aliases: "-n"
55
+ option :network_log_format,
56
+ type: :string,
57
+ default: "console",
58
+ enum: %w[console pdf],
59
+ desc: "Choose network log format: console or pdf", aliases: "-f"
54
60
 
55
61
  option :background, type: :boolean, default: true, desc: "Print background graphics"
56
62
  option :margin_top, type: :numeric, default: 1.0, desc: "Top margin in inches"
@@ -64,6 +70,12 @@ module Bidi2pdf
64
70
  option :scale, type: :numeric, default: 1.0, desc: "Scale between 0.1 and 2.0"
65
71
  option :shrink_to_fit, type: :boolean, default: true, desc: "Shrink content to fit page"
66
72
 
73
+ class << self
74
+ def exit_on_failure?
75
+ true
76
+ end
77
+ end
78
+
67
79
  def render
68
80
  load_config
69
81
 
@@ -198,7 +210,8 @@ module Bidi2pdf
198
210
  headless: merged_options[:headless],
199
211
  wait_window_loaded: merged_options[:wait_window_loaded],
200
212
  wait_network_idle: merged_options[:wait_network_idle],
201
- print_options: print_options
213
+ print_options: print_options,
214
+ network_log_format: merged_options[:network_log_format]
202
215
  )
203
216
  end
204
217
  end
@@ -208,6 +221,9 @@ module Bidi2pdf
208
221
  def configure
209
222
  Bidi2pdf.configure do |config|
210
223
  config.logger.level = log_level
224
+
225
+ config.network_events_logger.level = Logger::INFO if merged_options[:log_network_traffic]
226
+
211
227
  config.default_timeout = merged_options[:default_timeout]
212
228
 
213
229
  Chromedriver::Binary.configure do |c|
@@ -8,7 +8,7 @@ module Bidi2pdf
8
8
  class Launcher
9
9
  # rubocop:disable Metrics/ParameterLists
10
10
  def initialize(url:, inputfile:, output:, cookies:, headers:, auth:, headless: true, port: 0, wait_window_loaded: false,
11
- wait_network_idle: false, print_options: {}, remote_browser_url: nil)
11
+ wait_network_idle: false, print_options: {}, remote_browser_url: nil, network_log_format: :console)
12
12
  @url = url
13
13
  @inputfile = inputfile
14
14
  @port = port
@@ -23,6 +23,7 @@ module Bidi2pdf
23
23
  @print_options = print_options || {}
24
24
  @remote_browser_url = remote_browser_url
25
25
  @custom_session = nil
26
+ @network_log_format = network_log_format
26
27
  end
27
28
 
28
29
  # rubocop:enable Metrics/ParameterLists
@@ -38,7 +39,8 @@ module Bidi2pdf
38
39
  auth: @auth,
39
40
  wait_window_loaded: @wait_window_loaded,
40
41
  wait_network_idle: @wait_network_idle,
41
- print_options: @print_options
42
+ print_options: @print_options,
43
+ network_log_format: @network_log_format
42
44
  )
43
45
  runner.run
44
46
  end
@@ -2,8 +2,9 @@
2
2
 
3
3
  module Bidi2pdf
4
4
  class SessionRunner
5
+ # rubocop: disable Metrics/ParameterLists
5
6
  def initialize(session:, url:, inputfile:, output:, cookies: {}, headers: {}, auth: {}, wait_window_loaded: false,
6
- wait_network_idle: false, print_options: {})
7
+ wait_network_idle: false, print_options: {}, network_log_format: :console)
7
8
  @session = session
8
9
  @url = url
9
10
  @inputfile = inputfile
@@ -14,8 +15,11 @@ module Bidi2pdf
14
15
  @wait_window_loaded = wait_window_loaded
15
16
  @wait_network_idle = wait_network_idle
16
17
  @print_options = print_options || {}
18
+ @network_log_format = network_log_format
17
19
  end
18
20
 
21
+ # rubocop: enable Metrics/ParameterLists
22
+
19
23
  def run
20
24
  @session.start
21
25
  @session.client.on_close { Bidi2pdf.logger.info "WebSocket closed" }
@@ -72,23 +76,27 @@ module Bidi2pdf
72
76
  )
73
77
  end
74
78
 
79
+ # rubocop: disable Metrics/AbcSize
75
80
  def run_flow
76
81
  @session.status
77
82
  @session.user_contexts
78
83
 
79
84
  if @url
80
- @tab.open_page(@url)
85
+ @tab.navigate_to(@url)
81
86
  else
82
87
  Bidi2pdf.logger.info "Loading HTML file #{@inputfile}"
83
88
  data = File.read(@inputfile)
84
- @tab.view_html_page(data)
89
+ @tab.render_html_content(data)
85
90
  end
86
91
 
87
92
  if @wait_network_idle
88
93
  Bidi2pdf.logger.info "Waiting for network idle"
89
- @tab.wait_until_all_finished
94
+ @tab.wait_until_network_idle
90
95
  end
91
96
 
97
+ log_output_file = (@output || "report").sub(/\.pdf$/, "-network.pdf") # only need, when html output
98
+ @tab.log_network_traffic format: @network_log_format, output: log_output_file
99
+
92
100
  if @wait_window_loaded
93
101
  Bidi2pdf.logger.info "Waiting for window to be loaded"
94
102
  @tab.execute_script <<-EOF_SCRIPT
@@ -102,6 +110,8 @@ module Bidi2pdf
102
110
  @window.close
103
111
  end
104
112
 
113
+ # rubocop: enable Metrics/AbcSize
114
+
105
115
  def uri
106
116
  @uri ||= URI(@url)
107
117
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Bidi2pdf
4
- VERSION = "0.1.5"
4
+ VERSION = "0.1.6"
5
5
  end
data/lib/bidi2pdf.rb CHANGED
@@ -26,12 +26,14 @@ module Bidi2pdf
26
26
  class PrintError < Error; end
27
27
 
28
28
  @logger = Logger.new($stdout)
29
+ @network_events_logger = Logger.new($stdout)
29
30
  @logger.level = Logger::INFO
31
+ @network_events_logger.level = Logger::FATAL
30
32
 
31
33
  @default_timeout = 60
32
34
 
33
35
  class << self
34
- attr_accessor :logger, :default_timeout
36
+ attr_accessor :logger, :default_timeout, :network_events_logger
35
37
 
36
38
  # Allow configuration through a block
37
39
  def configure
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: bidi2pdf
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.5
4
+ version: 0.1.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dieter S.
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2025-04-10 00:00:00.000000000 Z
11
+ date: 2025-04-12 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: base64
@@ -310,6 +310,7 @@ files:
310
310
  - cliff.toml
311
311
  - docker/Dockerfile
312
312
  - docker/Dockerfile.chromedriver
313
+ - docker/Dockerfile.slim
313
314
  - docker/docker-compose.yml
314
315
  - docker/entrypoint.sh
315
316
  - docker/nginx/default.conf
@@ -346,7 +347,12 @@ files:
346
347
  - lib/bidi2pdf/bidi/connection_manager.rb
347
348
  - lib/bidi2pdf/bidi/event_manager.rb
348
349
  - lib/bidi2pdf/bidi/interceptor.rb
350
+ - lib/bidi2pdf/bidi/logger_events.rb
349
351
  - lib/bidi2pdf/bidi/network_event.rb
352
+ - lib/bidi2pdf/bidi/network_event_formatters.rb
353
+ - lib/bidi2pdf/bidi/network_event_formatters/network_event_console_formatter.rb
354
+ - lib/bidi2pdf/bidi/network_event_formatters/network_event_formatter_utils.rb
355
+ - lib/bidi2pdf/bidi/network_event_formatters/network_event_html_formatter.rb
350
356
  - lib/bidi2pdf/bidi/network_events.rb
351
357
  - lib/bidi2pdf/bidi/session.rb
352
358
  - lib/bidi2pdf/bidi/user_context.rb