http_mimic 0.5.3 → 0.5.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: 1b0b4192818428818cca7cde26b4ce90d6fa847db6e80e2b63c163dd8171bdd9
4
- data.tar.gz: de6a71df66c710a30e7cb8fcbe6976653ec6242a6d29fd6959016513da4513be
3
+ metadata.gz: da5487d3d05ebd5c3bdd3613cc4b5461221622115e0846512e8f443b034f8aad
4
+ data.tar.gz: 88997137ba1212244539e60258b3851fd5ee7777d00c94206c89b86d76581529
5
5
  SHA512:
6
- metadata.gz: 3b4ba6f1355da36e9f1064e62b1b988200f483a58b49524e3dd5350b0c99660d0b93ab650fc9ea8b3b7aa2204f41b7e2ca51c6b439128c267d7660be77b2b644
7
- data.tar.gz: dd25b3fbf25dca0d935b910cb2b0981ab77d9dbfe45cfa5b522a99bbca7081016e6323af3fc59c389ef925c6df0b4d0bbf7321c7a93df5154d0d41e328962d76
6
+ metadata.gz: 95d2f45f527dc454bfb3ea989c89a05ed0c12c79d05c6427a52f79bc36fbad5309cb6d80b23ad9bebdeed1461717c4b0a32b6049ee21670b4f24666ebcf9aafc
7
+ data.tar.gz: 13bfd8e2a063a626148ae19774f1f13c1ba235bd961b2bbafac1044134916b704a1aa3c37f0989ee85c57cc2864aebb336c07b7ca2ad2e648e1d663e444e4da3
data/CHANGELOG.md CHANGED
@@ -5,6 +5,42 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.5.6] - 2026-09-06
9
+
10
+ ### Fixed
11
+ - **Ruby 2.3 & 2.4 Compatibility**:
12
+ - Wrapped `rescue StandardError` inside `begin...end` in `AkamaiSolver.extract_sensor_script_urls` (`akamai_solver.rb`). Ruby 2.5 introduced omitting `begin` within `do...end` blocks; in Ruby 2.3 / 2.4, omitting `begin` resulted in `SyntaxError: unexpected keyword_rescue, expecting keyword_end` during deployment.
13
+
14
+ ## [0.5.5] - 2026-09-05
15
+
16
+ ### Fixed
17
+ - **QuickJS CPT & Bot Manager Telemetry Resolution (Tier 1 WAF Bypass)**:
18
+ - **Standard `document.cookie` CookieJar**: Replaced the plain property in `browser_context.js` with a compliant `Map`-backed CookieJar. When Akamai CPT writes `document.cookie = "bm_lso=..."`, existing session cookies (`bm_sz`, `_abck`) are preserved rather than overwritten. This fixes the critical bug where main sensor telemetry fell back to default hash `8888888` instead of the session cookie hash.
19
+ - **`HTMLScriptElement.src` Property Reflection**: Implemented bidirectional synchronization between `el.src` and `el.getAttribute("src")`. CPT scripts can now successfully inspect `document.currentScript.getAttribute("src")` to locate their challenge parameters and relative post endpoints.
20
+ - **Accurate `document.currentScript` Execution Pipeline**: `AkamaiSolver.build_driver_script` now assigns `document.currentScript` before evaluating each script block, matching real browser multi-script execution order.
21
+ - **Hybrid `XMLHttpRequest` with `responseURL` Support**: Retained instance-level methods (`open`, `send`) required by obfuscated Akamai scripts while maintaining prototype-level methods for DOM script hooks, and populating `responseURL` to allow `challenge.html` reload checks to succeed.
22
+ - **Challenge Query String Preservation in Telemetry POST**: When sending CPT PoW solutions, `AkamaiSolver` now preserves query parameters (e.g. `v=...&t=...`) from the original challenge script URL so that edge servers correctly issue validated `bm_sc` session tokens.
23
+ - **End-to-End Akamai Verification**:
24
+ - `HttpMimic.get('https://www.adidas.com/om/en/samba-og-shoes/KK2371.html')` now successfully solves the CPT challenge within QuickJS and returns the complete 400KB+ product HTML without invoking Tier 2 Obscura.
25
+
26
+ ## [0.5.4] - 2026-09-05
27
+
28
+
29
+ ### Fixed
30
+ - **Challenge Page Regression Prevention**:
31
+ - `HttpMimic::Request` no longer preserves un-bypassed WAF challenge pages (such as Akamai 200 CPT skeletons) as `best_response`. If all resolution and fallback attempts fail, true status codes (e.g. 403) are preserved instead of overwriting with skeleton 200 responses.
32
+ - **Explicit Profile Forwarding in Fallbacks & Retries**:
33
+ - `determine_profiles` now respects `explicit_profile = options[:profile]`, ensuring targeted profiles (e.g., `:android`) remain active when `auto_fallback: false` and are prioritized during fallback loops.
34
+ - `AkamaiSolver` now explicitly forwards `profile` and concrete `impersonate` targets during final URL retries to prevent TLS/fingerprint mismatches.
35
+ - **Mobile/Android Emulation Fidelity in QuickJS Browser Polyfill**:
36
+ - Fixed typo in Android platform identifier (`Linux armv8l` instead of `Linux armv81`).
37
+ - Mobile environments now correctly emulate zero-length plugin and mimeType lists (removing desktop-only PDF plugins).
38
+ - Aligned mobile screen dimensions (412x915 portrait-primary) and WebGL renderer parameters (Qualcomm Adreno 640).
39
+ - **Default Impersonate Profile Upgraded to `chrome150`**:
40
+ - Upgraded default client TLS fingerprint to `chrome150` to satisfy modern Akamai edge HTTP/2 and TLS cipher requirements.
41
+ - **Modern CORS & Fetch Headers in Sensor POSTs**:
42
+ - Automatically injected `Sec-Fetch-Dest: empty`, `Sec-Fetch-Mode: cors`, `Sec-Fetch-Site: same-origin`, and client hints (`sec-ch-ua`, `sec-ch-ua-mobile`, `sec-ch-ua-platform`).
43
+
8
44
  ## [0.5.3] - 2026-09-05
9
45
 
10
46
  ### Added
data/http_mimic.gemspec CHANGED
@@ -21,7 +21,7 @@ Gem::Specification.new do |spec|
21
21
 
22
22
  spec.required_ruby_version = ">= 2.3.0"
23
23
 
24
- spec.files = Dir["lib/**/*.rb", "README.md", "CHANGELOG.md", "LICENSE.txt", "http_mimic.gemspec"]
24
+ spec.files = Dir["lib/**/*.{rb,js}", "README.md", "CHANGELOG.md", "LICENSE.txt", "http_mimic.gemspec"]
25
25
  spec.require_paths = ["lib"]
26
26
 
27
27
  spec.add_development_dependency "bundler", ">= 1.17"
@@ -15,7 +15,7 @@ module HttpMimic
15
15
 
16
16
  TARGET_BINARY_MAP = {
17
17
  # Chrome Desktop
18
- 'chrome' => %w[curl_chrome131 curl_chrome124 curl_chrome120 curl_chrome116 curl_chrome110 curl-impersonate-chrome curl_chrome curl-impersonate],
18
+ 'chrome' => %w[curl_chrome150 curl_chrome146 curl_chrome145 curl_chrome136 curl_chrome133a curl_chrome131 curl_chrome124 curl_chrome120 curl_chrome116 curl_chrome110 curl-impersonate-chrome curl_chrome curl-impersonate],
19
19
  'chrome116' => %w[curl_chrome116 curl-impersonate-chrome curl_chrome curl-impersonate],
20
20
  'chrome120' => %w[curl_chrome120 curl-impersonate-chrome curl_chrome curl-impersonate],
21
21
  'chrome123' => %w[curl_chrome123 curl-impersonate-chrome curl_chrome curl-impersonate],
@@ -118,10 +118,12 @@ module HttpMimic
118
118
  end
119
119
 
120
120
  # Timeouts
121
- timeout = options[:timeout] || options[:read_timeout] || config.default_timeout
121
+ default_max_time = options[:auto_proxy] ? 12 : config.default_timeout
122
+ timeout = options[:timeout] || options[:read_timeout] || default_max_time
122
123
  args << '--max-time' << timeout.to_s if timeout
123
124
 
124
- connect_timeout = options[:connect_timeout] || config.default_connect_timeout
125
+ default_conn_timeout = options[:auto_proxy] ? 4 : config.default_connect_timeout
126
+ connect_timeout = options[:connect_timeout] || default_conn_timeout
125
127
  args << '--connect-timeout' << connect_timeout.to_s if connect_timeout
126
128
 
127
129
  # SSL / Insecure
@@ -56,7 +56,7 @@ module HttpMimic
56
56
  attr_accessor :proxy_timeout
57
57
 
58
58
  def initialize
59
- @default_impersonate = 'chrome131'
59
+ @default_impersonate = 'chrome150'
60
60
  @default_mobile_impersonate = 'safari180_ios'
61
61
  @binary_path = nil
62
62
  @fallback_to_curl = true
@@ -48,8 +48,8 @@ module HttpMimic
48
48
  end
49
49
 
50
50
  attempts = []
51
- profiles_to_try = determine_profiles(mode, auto_fallback)
52
- waf_solve_attempted = false
51
+ profiles_to_try = determine_profiles(mode, auto_fallback, options[:profile])
52
+ waf_solve_attempts = 0
53
53
  best_response = nil
54
54
  proxy_retries_left = should_auto_proxy ? (options[:proxy_retries] || config.proxy_retries || 3) : 0
55
55
 
@@ -60,7 +60,7 @@ module HttpMimic
60
60
 
61
61
  profiles_to_try.each_with_index do |profile, index|
62
62
  # Never fall back to plain curl if a WAF challenge has already been detected/attempted
63
- if profile == :curl && waf_solve_attempted
63
+ if profile == :curl && waf_solve_attempts > 0
64
64
  log_debug("[HttpMimic] Skipping plain :curl fallback for protected WAF target.")
65
65
  next
66
66
  end
@@ -79,25 +79,6 @@ module HttpMimic
79
79
  log_debug("Curl exit status: #{status.exitstatus}")
80
80
  log_debug("Curl stderr: #{stderr}") unless stderr.empty?
81
81
 
82
- # Handle proxy failure retry when auto_proxy is enabled
83
- if should_auto_proxy && current_opts[:proxy] && (status.exitstatus != 0)
84
- is_proxy_err = [5, 7, 28, 35, 56].include?(status.exitstatus) ||
85
- stderr.include?('Failed to connect to') ||
86
- stderr.include?('tunneling failed') ||
87
- stderr.downcase.include?('proxy')
88
-
89
- if is_proxy_err
90
- ProxyPool.mark_dead(current_opts[:proxy])
91
- if proxy_retries_left > 0
92
- proxy_retries_left -= 1
93
- new_proxy = ProxyPool.get
94
- log_debug("[HttpMimic::ProxyPool] Proxy #{current_opts[:proxy]} failed (exit #{status.exitstatus}). Retrying with new proxy #{new_proxy} (#{proxy_retries_left} retries left)...")
95
- options[:proxy] = new_proxy
96
- redo
97
- end
98
- end
99
- end
100
-
101
82
  response = ResponseParser.new(
102
83
  stdout,
103
84
  exit_status: status,
@@ -106,7 +87,23 @@ module HttpMimic
106
87
  request_url: final_url
107
88
  ).parse
108
89
 
109
- if should_auto_proxy && current_opts[:proxy] && response.success?
90
+ # Handle proxy failure retry when auto_proxy is enabled
91
+ is_proxy_failure = should_auto_proxy && current_opts[:proxy] && proxy_failure?(status, stderr, response)
92
+ if is_proxy_failure
93
+ ProxyPool.mark_dead(current_opts[:proxy])
94
+ if proxy_retries_left > 0
95
+ proxy_retries_left -= 1
96
+ new_proxy = ProxyPool.get
97
+ log_debug("[HttpMimic::ProxyPool] Proxy #{current_opts[:proxy]} failed (exit #{status&.exitstatus}, code #{response&.code}, empty body: #{response&.body.to_s.empty?}). Retrying with new proxy #{new_proxy} (#{proxy_retries_left} retries left)...")
98
+ options[:proxy] = new_proxy
99
+ redo
100
+ else
101
+ log_debug("[HttpMimic::ProxyPool] Proxy retries exhausted (#{current_opts[:proxy]} failed). Aborting profile fallback.")
102
+ break
103
+ end
104
+ end
105
+
106
+ if should_auto_proxy && current_opts[:proxy] && response.success? && !is_proxy_failure
110
107
  ProxyPool.mark_alive(current_opts[:proxy])
111
108
  end
112
109
 
@@ -127,14 +124,18 @@ module HttpMimic
127
124
  final_stderr = stderr
128
125
  final_command = full_command
129
126
 
130
- # Track best response so a 200 (or challenge page) isn't wiped out by a failed fallback
131
- if response
132
- if best_response.nil?
133
- best_response = response
134
- elsif response.success? && !best_response.success?
135
- best_response = response
136
- elsif response.code == 200 && best_response.code != 200
137
- best_response = response
127
+ # Track best response so a 200 isn't wiped out by a failed fallback.
128
+ # Only accept responses from successful curl executions that are not proxy failures or WAF challenges.
129
+ if response && (status.nil? || status.exitstatus == 0) && !is_proxy_failure
130
+ is_challenge = Waf::Detector.challenge_page?(response)
131
+ if !is_challenge
132
+ if best_response.nil?
133
+ best_response = response
134
+ elsif response.success? && !best_response.success?
135
+ best_response = response
136
+ elsif response.code == 200 && best_response.code != 200
137
+ best_response = response
138
+ end
138
139
  end
139
140
  end
140
141
 
@@ -145,13 +146,13 @@ module HttpMimic
145
146
  end
146
147
 
147
148
  auto_solve_waf = options.fetch(:solve_waf, config.auto_solve_waf)
148
- is_blocked = (status.exitstatus != 0) || retry_statuses.include?(response.code) || Waf::Detector.challenge_page?(response)
149
+ is_blocked = (status.exitstatus != 0) || is_proxy_failure || retry_statuses.include?(response.code) || Waf::Detector.challenge_page?(response)
149
150
 
150
- # Only attempt WAF resolution once per request to avoid unnecessary latency on subsequent fallbacks
151
- if is_blocked && auto_solve_waf && !waf_solve_attempted && method.to_s.upcase == 'GET'
151
+ # Only attempt WAF resolution if origin server returned challenge (not a proxy error)
152
+ if is_blocked && !is_proxy_failure && auto_solve_waf && waf_solve_attempts < 2 && method.to_s.upcase == 'GET'
152
153
  waf_type = Waf::Detector.detect(response)
153
154
  if waf_type
154
- waf_solve_attempted = true
155
+ waf_solve_attempts += 1
155
156
  log_debug("[HttpMimic] Detected #{waf_type.to_s.capitalize} WAF challenge. Attempting to solve with QuickJS...")
156
157
  solved_resp = Waf.solve(url, response, current_opts)
157
158
  if solved_resp
@@ -160,7 +161,8 @@ module HttpMimic
160
161
  if response.cookies && !response.cookies.empty?
161
162
  options[:cookies] = (options[:cookies] || {}).merge(response.cookies.to_h)
162
163
  end
163
- if response.success? || (response.code == 200 && best_response&.code != 200)
164
+ is_challenge_res = Waf::Detector.challenge_page?(response)
165
+ if !is_challenge_res && (response.success? || (response.code == 200 && best_response&.code != 200))
164
166
  best_response = response
165
167
  end
166
168
  end
@@ -175,17 +177,16 @@ module HttpMimic
175
177
  end
176
178
 
177
179
  # Preserve best response if the last attempt resulted in a regression (e.g. 403 / error after getting 200)
178
- if best_response && (response.nil? || response.error? || (final_status && final_status.exitstatus != 0))
180
+ if best_response && !Waf::Detector.challenge_page?(best_response) && (response.nil? || response.error? || (final_status && final_status.exitstatus != 0) || Waf::Detector.challenge_page?(response))
179
181
  if best_response.success? || (best_response.code == 200 && response&.code != 200)
180
182
  response = best_response
181
183
  end
182
184
  end
183
185
 
184
- # Automatic SPA Detection & Obscura rendering fallback for SPA shells & Behavioral Challenges
186
+ # Automatic SPA Detection & Obscura rendering fallback (strictly requires auto_render_spa: true)
185
187
  auto_render_spa = options.fetch(:auto_render_spa, config.auto_render_spa)
186
- auto_solve_waf = options.fetch(:solve_waf, config.auto_solve_waf)
187
188
  should_render_spa = auto_render_spa && response && response.success? && SpaDetector.spa?(response)
188
- should_render_cpt = auto_solve_waf && response && Waf::Detector.challenge_page?(response)
189
+ should_render_cpt = auto_render_spa && response && Waf::Detector.challenge_page?(response)
189
190
 
190
191
  if (should_render_spa || should_render_cpt) && method.to_s.upcase == 'GET'
191
192
  target_reason = should_render_cpt ? 'WAF challenge page' : 'unhydrated SPA shell'
@@ -231,13 +232,34 @@ module HttpMimic
231
232
  end
232
233
  end
233
234
 
235
+ # If the final response was an intercepted dummy 200 from a broken/intercepting proxy, override code
236
+ if response && should_auto_proxy && proxy_failure?(final_status, final_stderr, response)
237
+ if response.code == 200 && response.body.to_s.empty?
238
+ response.instance_variable_set(:@code, 0)
239
+ response.instance_variable_set(:@status_message, 'Proxy Interception Failure (Empty Body)')
240
+ end
241
+ end
242
+
243
+ # If the final response is an un-bypassed WAF challenge page, annotate status message so it's clear
244
+ if response && response.challenge_page? && (response.status_message.to_s.empty? || response.status_message == 'OK')
245
+ type_str = response.challenge_type ? response.challenge_type.to_s.capitalize : 'WAF'
246
+ response.instance_variable_set(:@status_message, "Challenge Required (#{type_str})")
247
+ end
248
+
234
249
  handle_errors(final_status, final_stderr, final_command, response)
235
250
  response
236
251
  end
237
252
 
238
253
  private
239
254
 
240
- def determine_profiles(mode, auto_fallback)
255
+ def determine_profiles(mode, auto_fallback, explicit_profile = nil)
256
+ if explicit_profile
257
+ p_sym = explicit_profile.to_sym
258
+ return [p_sym] unless auto_fallback
259
+ defaults = [:impersonate, :android, :ios, :firefox, :safari, :curl]
260
+ return ([p_sym] + defaults).uniq
261
+ end
262
+
241
263
  case mode
242
264
  when :curl, :curl_first
243
265
  auto_fallback ? [:curl, :impersonate, :android, :ios, :firefox] : [:curl]
@@ -270,6 +292,37 @@ module HttpMimic
270
292
  end
271
293
  end
272
294
 
295
+ def proxy_failure?(status, stderr, response)
296
+ # 1. Non-zero exit status from curl when using proxy (connection failed, SSL error, empty reply, etc.)
297
+ return true if status.nil? || status.exitstatus != 0
298
+
299
+ # 2. Origin server did not return a valid response (e.g. only CONNECT tunnel block or code 0)
300
+ return true if response.nil? || response.code == 0
301
+
302
+ # 3. Known proxy-level error codes
303
+ return true if [407, 502, 503, 504].include?(response.code)
304
+
305
+ # 4. Proxy fake 200 OK interception detection:
306
+ # A GET request that returns 200 with empty body is almost always a proxy interception / dummy response
307
+ if method.to_s.upcase == 'GET' && response.code == 200 && response.body.to_s.empty?
308
+ server_hdr = response.headers['server'].to_s.downcase
309
+ via_hdr = response.headers['via'].to_s.downcase
310
+ content_len = response.headers['content-length']
311
+
312
+ is_proxy_marker = server_hdr.include?('proxy') ||
313
+ server_hdr.include?('squid') ||
314
+ server_hdr.include?('console') ||
315
+ server_hdr.include?('privoxy') ||
316
+ server_hdr.include?('tinyproxy') ||
317
+ via_hdr.include?('proxy') ||
318
+ content_len == '0'
319
+
320
+ return true if is_proxy_marker
321
+ end
322
+
323
+ false
324
+ end
325
+
273
326
  def execute_open3(command_array, stdin_data)
274
327
  if stdin_data
275
328
  Open3.capture3(*command_array, stdin_data: stdin_data)
@@ -46,8 +46,26 @@ module HttpMimic
46
46
  !!@fallback_triggered
47
47
  end
48
48
 
49
+ def challenge_page?
50
+ if defined?(HttpMimic::Waf::Detector)
51
+ @challenge_page ||= HttpMimic::Waf::Detector.challenge_page?(self)
52
+ else
53
+ false
54
+ end
55
+ end
56
+ alias challenge? challenge_page?
57
+ alias blocked? challenge_page?
58
+
59
+ def challenge_type
60
+ if defined?(HttpMimic::Waf::Detector)
61
+ @challenge_type ||= HttpMimic::Waf::Detector.detect(self)
62
+ else
63
+ nil
64
+ end
65
+ end
66
+
49
67
  def success?
50
- code >= 200 && code < 300
68
+ exit_code == 0 && (code >= 200 && code < 300) && !challenge_page?
51
69
  end
52
70
  alias ok? success?
53
71
 
@@ -64,7 +82,7 @@ module HttpMimic
64
82
  end
65
83
 
66
84
  def error?
67
- client_error? || server_error?
85
+ exit_code != 0 || client_error? || server_error? || challenge_page?
68
86
  end
69
87
 
70
88
  def binary?
@@ -159,7 +177,8 @@ module HttpMimic
159
177
  end
160
178
 
161
179
  def inspect
162
- "#<#{self.class.name}:0x#{object_id.to_s(16)} @code=#{code} @status_message=#{status_message.inspect} @headers=#{headers.to_h.inspect} @parsed_response=#{parsed_response.inspect}>"
180
+ challenge_info = challenge_page? ? " @challenge=true (#{challenge_type || 'WAF'})" : ""
181
+ "#<#{self.class.name}:0x#{object_id.to_s(16)} @code=#{code}#{challenge_info} @status_message=#{status_message.inspect} @headers=#{headers.to_h.inspect} @parsed_response=#{parsed_response.inspect}>"
163
182
  end
164
183
 
165
184
  private
@@ -6,6 +6,7 @@ module HttpMimic
6
6
  class ResponseParser
7
7
  HTTP_STATUS_LINE_REGEX = /\AHTTP\/(?<version>[\d\.]+)\s+(?<code>\d{3})(?:\s+(?<message>.*))?/i
8
8
  HTTP_STATUS_LINE_B = /\AHTTP\/(?:[\d\.]+)\s+\d{3}/i
9
+ PROXY_CONNECT_REGEX = /\AHTTP\/[\d\.]+\s+200\s+Connection\s+established/i
9
10
  BINARY_MIME_KEYWORDS = %w[image/ audio/ video/ pdf octet-stream zip gzip tar compressed stream font wasm].freeze
10
11
 
11
12
  attr_reader :raw_output, :exit_status, :stderr, :command, :request_url
@@ -22,6 +23,11 @@ module HttpMimic
22
23
  # Split header blocks and body in binary-safe manner
23
24
  header_blocks, raw_body = split_headers_and_body(@raw_output)
24
25
 
26
+ # Strip proxy CONNECT tunnel handshake blocks (e.g. "HTTP/1.1 200 Connection established")
27
+ while header_blocks.first && header_blocks.first =~ PROXY_CONNECT_REGEX
28
+ header_blocks.shift
29
+ end
30
+
25
31
  final_header_block = header_blocks.last || ''
26
32
  history_header_blocks = header_blocks.size > 1 ? header_blocks[0...-1] : []
27
33
 
@@ -25,7 +25,7 @@ module HttpMimic
25
25
  # @param response [HttpMimic::Response]
26
26
  # @return [Boolean]
27
27
  def spa?(response)
28
- return false unless response && response.success?
28
+ return false unless response && (response.code >= 200 && response.code < 300)
29
29
  return false if response.respond_to?(:binary?) && response.binary?
30
30
 
31
31
  content_type = response.headers['content-type'].to_s.downcase
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module HttpMimic
4
- VERSION = "0.5.3"
4
+ VERSION = "0.5.6"
5
5
  end
6
+