html2rss 0.29.1 → 0.31.0

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.
Files changed (132) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +8 -8
  3. data/html2rss.gemspec +1 -3
  4. data/lib/html2rss/article/deduplicator.rb +2 -4
  5. data/lib/html2rss/article.rb +16 -39
  6. data/lib/html2rss/auto_source/README.md +1 -1
  7. data/lib/html2rss/auto_source/cleanup.rb +15 -6
  8. data/lib/html2rss/auto_source/scraper/html.rb +5 -0
  9. data/lib/html2rss/auto_source/scraper/json_state.rb +5 -0
  10. data/lib/html2rss/auto_source/scraper/meta_oembed.rb +8 -1
  11. data/lib/html2rss/auto_source/scraper/microdata.rb +8 -0
  12. data/lib/html2rss/auto_source/scraper/microformats2.rb +8 -0
  13. data/lib/html2rss/auto_source/scraper/native_feed.rb +3 -0
  14. data/lib/html2rss/auto_source/scraper/schema/thing.rb +4 -5
  15. data/lib/html2rss/auto_source/scraper/schema.rb +8 -0
  16. data/lib/html2rss/auto_source/scraper/semantic_html.rb +5 -0
  17. data/lib/html2rss/auto_source/scraper/sitemap.rb +3 -0
  18. data/lib/html2rss/auto_source/scraper/wordpress_api.rb +3 -0
  19. data/lib/html2rss/auto_source/scraper.rb +4 -14
  20. data/lib/html2rss/auto_source/segment.rb +7 -4
  21. data/lib/html2rss/auto_source/segmenter/cluster.rb +22 -23
  22. data/lib/html2rss/auto_source/segmenter/list.rb +54 -31
  23. data/lib/html2rss/auto_source/segmenter.rb +5 -5
  24. data/lib/html2rss/auto_source.rb +2 -2
  25. data/lib/html2rss/batch.rb +6 -4
  26. data/lib/html2rss/capture/README.md +7 -5
  27. data/lib/html2rss/capture.rb +68 -36
  28. data/lib/html2rss/channel.rb +3 -3
  29. data/lib/html2rss/cli/probe_view.rb +5 -3
  30. data/lib/html2rss/cli/render.rb +6 -5
  31. data/lib/html2rss/cli/validate.rb +28 -27
  32. data/lib/html2rss/cli.rb +1 -1
  33. data/lib/html2rss/config/issue_mapper.rb +108 -0
  34. data/lib/html2rss/config/request_headers.rb +6 -4
  35. data/lib/html2rss/config/schema.rb +16 -1
  36. data/lib/html2rss/config/selectors_validator.rb +92 -70
  37. data/lib/html2rss/config/validation_issue.rb +60 -0
  38. data/lib/html2rss/config/validation_report.rb +55 -0
  39. data/lib/html2rss/config/validator.rb +10 -6
  40. data/lib/html2rss/config.rb +35 -101
  41. data/lib/html2rss/defaults.rb +42 -30
  42. data/lib/html2rss/doctor/botasaurus.rb +19 -9
  43. data/lib/html2rss/doctor.rb +8 -0
  44. data/lib/html2rss/error.rb +1 -1
  45. data/lib/html2rss/feed_builder/rss.rb +9 -14
  46. data/lib/html2rss/feed_pipeline/README.md +8 -8
  47. data/lib/html2rss/feed_pipeline/auto_fallback.rb +1 -1
  48. data/lib/html2rss/feed_pipeline/runtime_policy.rb +14 -10
  49. data/lib/html2rss/feed_pipeline/strategy_plan.rb +7 -3
  50. data/lib/html2rss/feed_resolution/candidate_generator.rb +1 -4
  51. data/lib/html2rss/feed_resolution/diag.rb +3 -1
  52. data/lib/html2rss/feed_resolution/options.rb +3 -1
  53. data/lib/html2rss/feed_resolution/policy.rb +2 -2
  54. data/lib/html2rss/html/article_extractor/date_extractor.rb +1 -1
  55. data/lib/html2rss/html/article_extractor.rb +2 -4
  56. data/lib/html2rss/html/feed_link.rb +3 -1
  57. data/lib/html2rss/html/navigator/text_extractor.rb +1 -1
  58. data/lib/html2rss/html/navigator.rb +10 -10
  59. data/lib/html2rss/html/rendering/description_builder.rb +1 -1
  60. data/lib/html2rss/html/sst_article_extractor.rb +8 -5
  61. data/lib/html2rss/link_destination/destination_facts.rb +4 -2
  62. data/lib/html2rss/link_destination/noise_policy.rb +10 -1
  63. data/lib/html2rss/link_destination/path_classifier.rb +45 -38
  64. data/lib/html2rss/mcp/README.md +5 -3
  65. data/lib/html2rss/mcp/config_argument.rb +3 -1
  66. data/lib/html2rss/mcp/contract.rb +15 -25
  67. data/lib/html2rss/mcp/outcome/playbook.rb +7 -7
  68. data/lib/html2rss/mcp/outcome.rb +36 -43
  69. data/lib/html2rss/mcp/server/tools.rb +112 -132
  70. data/lib/html2rss/mcp/server.rb +27 -19
  71. data/lib/html2rss/page_recon/README.md +5 -5
  72. data/lib/html2rss/page_recon/diagnostics.rb +71 -81
  73. data/lib/html2rss/page_recon.rb +9 -5
  74. data/lib/html2rss/recon.rb +20 -31
  75. data/lib/html2rss/request_service/botasaurus_contract.rb +11 -12
  76. data/lib/html2rss/request_service/botasaurus_strategy.rb +32 -28
  77. data/lib/html2rss/request_service/compressed_body.rb +17 -9
  78. data/lib/html2rss/request_service/httpx_strategy.rb +239 -0
  79. data/lib/html2rss/request_service/network_guard.rb +7 -5
  80. data/lib/html2rss/request_service/response.rb +1 -2
  81. data/lib/html2rss/request_service/response_guard.rb +0 -16
  82. data/lib/html2rss/request_service/strategy.rb +22 -11
  83. data/lib/html2rss/request_service.rb +46 -85
  84. data/lib/html2rss/scoring/container_assessor.rb +3 -1
  85. data/lib/html2rss/scoring/engine.rb +8 -8
  86. data/lib/html2rss/scoring/observation.rb +5 -3
  87. data/lib/html2rss/scoring/ranked_segment.rb +3 -1
  88. data/lib/html2rss/scrape_target.rb +3 -1
  89. data/lib/html2rss/selector_candidates.rb +234 -0
  90. data/lib/html2rss/selectors/README.md +64 -0
  91. data/lib/html2rss/selectors/extractors/attribute.rb +13 -15
  92. data/lib/html2rss/selectors/extractors/href.rb +15 -16
  93. data/lib/html2rss/selectors/extractors/html.rb +8 -9
  94. data/lib/html2rss/selectors/extractors/static.rb +12 -14
  95. data/lib/html2rss/selectors/extractors/text.rb +8 -9
  96. data/lib/html2rss/selectors/extractors.rb +33 -20
  97. data/lib/html2rss/selectors/item_env.rb +31 -0
  98. data/lib/html2rss/selectors/{object_to_xml_converter.rb → json_xml.rb} +11 -12
  99. data/lib/html2rss/selectors/option_spec.rb +140 -0
  100. data/lib/html2rss/selectors/post_processors/base.rb +79 -31
  101. data/lib/html2rss/selectors/post_processors/gsub.rb +103 -39
  102. data/lib/html2rss/selectors/post_processors/html_transformers/transform_urls_to_absolute_ones.rb +4 -4
  103. data/lib/html2rss/selectors/post_processors/markdown_to_html.rb +10 -11
  104. data/lib/html2rss/selectors/post_processors/parse_time.rb +50 -26
  105. data/lib/html2rss/selectors/post_processors/parse_uri.rb +11 -7
  106. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +46 -32
  107. data/lib/html2rss/selectors/post_processors/substring.rb +14 -25
  108. data/lib/html2rss/selectors/post_processors/template.rb +23 -19
  109. data/lib/html2rss/selectors/post_processors.rb +6 -5
  110. data/lib/html2rss/selectors/{schema_doc.rb → schema_export.rb} +12 -31
  111. data/lib/html2rss/selectors/step_env.rb +24 -0
  112. data/lib/html2rss/selectors.rb +231 -142
  113. data/lib/html2rss/sst/attrs.rb +5 -3
  114. data/lib/html2rss/sst/document.rb +3 -1
  115. data/lib/html2rss/sst/index.rb +3 -1
  116. data/lib/html2rss/sst/node.rb +29 -10
  117. data/lib/html2rss/sst/normalizer.rb +12 -16
  118. data/lib/html2rss/sst/tags.rb +7 -7
  119. data/lib/html2rss/status.rb +13 -8
  120. data/lib/html2rss/surface_category.rb +14 -32
  121. data/lib/html2rss/syndication/parser.rb +2 -2
  122. data/lib/html2rss/test/enhance_audit.rb +1 -3
  123. data/lib/html2rss/test/policy.rb +106 -0
  124. data/lib/html2rss/test.rb +126 -172
  125. data/lib/html2rss/url.rb +25 -4
  126. data/lib/html2rss/version.rb +1 -1
  127. data/lib/html2rss.rb +27 -19
  128. data/lib/tasks/config_schema.rake +2 -2
  129. data/schema/html2rss-config.schema.json +11 -8
  130. metadata +19 -44
  131. data/lib/html2rss/request_service/faraday_strategy.rb +0 -233
  132. data/lib/html2rss/selectors/item_scope.rb +0 -30
@@ -6,9 +6,11 @@ module Html2rss
6
6
  # Diagnostic inspect path (not Capture or Recon ownership). Fetches via {.probe},
7
7
  # then adds scraper/XHR diagnostics for curation inspect surfaces.
8
8
  module Diagnostics # rubocop:disable Metrics/ModuleLength -- diagnostic wire fields stay co-located
9
+ Report = Data.define(:data)
10
+
9
11
  ##
10
12
  # Typed diagnostic report for inspect wire payloads and Outcome policy.
11
- Report = Data.define(:data) do
13
+ class Report
12
14
  ##
13
15
  # @return [Boolean]
14
16
  def alternate_feeds?
@@ -115,97 +117,85 @@ module Html2rss
115
117
  end
116
118
  end
117
119
 
118
- def build_data(probe, recon, response)
119
- data = recon.to_h.merge(
120
- strategy: probe.strategy,
121
- scraper_eligibility: scraper_info(safe_parsed_body(response)),
122
- html_present: html_present?(recon, response),
123
- likely_js_shell: likely_js_shell?(recon, response),
124
- redirect_summary: redirect_summary(recon)
125
- )
126
- data[:xhr_capture] = xhr_capture_info(response) if probe.strategy == :botasaurus
127
- log_js_shell(data, response.body&.bytesize.to_i) if data[:likely_js_shell]
128
- data
129
- end
130
- module_function :build_data
131
- private_class_method :build_data
120
+ class << self
121
+ private
122
+
123
+ def build_data(probe, recon, response)
124
+ data = recon.to_h.merge(
125
+ strategy: probe.strategy,
126
+ scraper_eligibility: scraper_info(safe_parsed_body(response)),
127
+ html_present: html_present?(recon, response),
128
+ likely_js_shell: likely_js_shell?(recon, response),
129
+ redirect_summary: redirect_summary(recon)
130
+ )
131
+ data[:xhr_capture] = xhr_capture_info(response) if probe.strategy == :botasaurus
132
+ log_js_shell(data, response.body&.bytesize.to_i) if data[:likely_js_shell]
133
+ data
134
+ end
132
135
 
133
- def resolve_inspect_strategy(strategy, deep:)
134
- name = (strategy || :auto).to_sym
135
- return :botasaurus if deep && name == :auto && MCP::Runtime.botasaurus_configured?
136
+ def resolve_inspect_strategy(strategy, deep:)
137
+ name = (strategy || :auto).to_sym
138
+ return :botasaurus if deep && name == :auto && MCP::Runtime.botasaurus_configured?
136
139
 
137
- name
138
- end
139
- module_function :resolve_inspect_strategy
140
- private_class_method :resolve_inspect_strategy
140
+ name
141
+ end
141
142
 
142
- def html_present?(recon, response)
143
- recon.html_response && !response.body.to_s.empty?
144
- end
145
- module_function :html_present?
146
- private_class_method :html_present?
143
+ def html_present?(recon, response)
144
+ recon.html_response && !response.body.to_s.empty?
145
+ end
147
146
 
148
- def likely_js_shell?(recon, response)
149
- return false unless html_present?(recon, response)
150
- return false if recon.articles_count.positive?
151
- return false if recon.blocked_surface || recon.surface_category == :blocked_surface
147
+ def likely_js_shell?(recon, response)
148
+ return false unless html_present?(recon, response)
149
+ return false if recon.articles_count.positive?
150
+ return false if recon.blocked_surface || recon.surface_category == :blocked_surface
152
151
 
153
- return true if recon.surface_category == :app_shell
152
+ return true if recon.surface_category == :app_shell
154
153
 
155
- response.body.bytesize >= JS_SHELL_MIN_BODY_BYTES &&
156
- SurfaceCategory.coerce(recon.surface_category).weak?
157
- end
158
- module_function :likely_js_shell?
159
- private_class_method :likely_js_shell?
154
+ response.body.bytesize >= JS_SHELL_MIN_BODY_BYTES &&
155
+ SurfaceCategory[recon.surface_category].weak?
156
+ end
160
157
 
161
- def redirect_summary(recon)
162
- {
163
- requested_url: recon.requested_url,
164
- final_url: recon.final_url,
165
- status: recon.status,
166
- scheme_downgrade: recon.scheme_downgrade
167
- }
168
- end
169
- module_function :redirect_summary
170
- private_class_method :redirect_summary
158
+ def redirect_summary(recon)
159
+ {
160
+ requested_url: recon.requested_url,
161
+ final_url: recon.final_url,
162
+ status: recon.status,
163
+ scheme_downgrade: recon.scheme_downgrade
164
+ }
165
+ end
171
166
 
172
- def log_js_shell(data, body_bytesize)
173
- Log.debug(
174
- "Diagnostics js_shell: bytesize=#{body_bytesize} surface_category=#{data[:surface_category]}"
175
- )
176
- end
177
- module_function :log_js_shell
178
- private_class_method :log_js_shell
167
+ def log_js_shell(data, body_bytesize)
168
+ Log.debug(
169
+ "Diagnostics js_shell: bytesize=#{body_bytesize} surface_category=#{data[:surface_category]}"
170
+ )
171
+ end
179
172
 
180
- def safe_parsed_body(response)
181
- return unless response.html_response?
173
+ def safe_parsed_body(response)
174
+ return unless response.html_response?
182
175
 
183
- response.parsed_body
184
- rescue RequestService::UnsupportedResponseContentType
185
- nil
186
- end
187
- module_function :safe_parsed_body
188
- private_class_method :safe_parsed_body
189
-
190
- def error_report(url, error) # rubocop:disable Metrics/MethodLength -- error hash mirrors success report shape
191
- Report.new(
192
- data: {
193
- requested_url: url.to_s,
194
- final_url: url.to_s,
195
- status: nil,
196
- scheme_downgrade: false,
197
- alternate_feeds: [],
198
- surface_category: :unsupported_surface,
199
- articles_count: 0,
200
- html_response: false,
201
- content_type: nil,
202
- strategy: nil,
203
- scraper_eligibility: { error: "#{error.class} - #{error.message}" }
204
- }
205
- )
176
+ response.parsed_body
177
+ rescue RequestService::UnsupportedResponseContentType
178
+ nil
179
+ end
180
+
181
+ def error_report(url, error) # rubocop:disable Metrics/MethodLength -- error hash mirrors success report shape
182
+ Report.new(
183
+ data: {
184
+ requested_url: url.to_s,
185
+ final_url: url.to_s,
186
+ status: nil,
187
+ scheme_downgrade: false,
188
+ alternate_feeds: [],
189
+ surface_category: :unsupported_surface,
190
+ articles_count: 0,
191
+ html_response: false,
192
+ content_type: nil,
193
+ strategy: nil,
194
+ scraper_eligibility: { error: "#{error.class} - #{error.message}" }
195
+ }
196
+ )
197
+ end
206
198
  end
207
- module_function :error_report
208
- private_class_method :error_report
209
199
  end
210
200
  end
211
201
  end
@@ -8,12 +8,14 @@ module Html2rss
8
8
  # article count. Diagnostic fetch for Inspect/Recon lives on {.probe} — not twin
9
9
  # fetch helpers in those callers.
10
10
  class PageRecon # rubocop:disable Metrics/ClassLength -- recon bag stays co-located
11
+ Assessment = Data.define(:surface_category, :articles_count, :admission_drops, :html_response)
12
+
11
13
  ##
12
14
  # Cheap surface + admission facts shared by AutoFallback gates and FeedResolution probes.
13
- Assessment = Data.define(:surface_category, :articles_count, :admission_drops, :html_response) do
15
+ class Assessment
14
16
  ##
15
17
  # @return [Html2rss::SurfaceCategory]
16
- def category = SurfaceCategory.coerce(surface_category)
18
+ def category = SurfaceCategory[surface_category]
17
19
 
18
20
  ##
19
21
  # @return [Boolean]
@@ -27,8 +29,6 @@ module Html2rss
27
29
  # @return [Boolean]
28
30
  def listing_bonus? = category.listing_bonus?
29
31
  end
30
- ##
31
- # Recon facts used by Inspect and FeedResolution.
32
32
  Result = Data.define(
33
33
  :requested_url,
34
34
  :final_url,
@@ -43,7 +43,11 @@ module Html2rss
43
43
  :content_type,
44
44
  :blocked_surface,
45
45
  :sst
46
- ) do
46
+ )
47
+
48
+ ##
49
+ # Recon facts used by Inspect and FeedResolution.
50
+ class Result
47
51
  ##
48
52
  # @return [Hash{Symbol => Object}]
49
53
  def to_h # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- omit-empty optional keys
@@ -16,27 +16,14 @@ module Html2rss
16
16
  ##
17
17
  # Closed curation verdict (:build / :defer / :drop).
18
18
  class Verdict
19
- # Closed set of curation verdict wire names.
20
- NAMES = Set[:build, :defer, :drop].freeze
21
-
22
- class << self
23
- ##
24
- # @param value [Verdict, Symbol, String]
25
- # @return [Verdict]
26
- def coerce(value)
27
- return value if value.is_a?(self)
28
-
29
- new(name: value.to_sym)
30
- end
31
- end
19
+ # Frozen name → instance table.
20
+ ALL = %i[build defer drop].to_h { |name| [name, new(name:)] }.freeze
21
+ private_class_method :new
32
22
 
33
23
  ##
34
- # @param name [Symbol]
35
- def initialize(name:)
36
- raise ArgumentError, "unknown verdict: #{name.inspect}" unless NAMES.include?(name)
37
-
38
- super
39
- end
24
+ # @param name [Symbol, String]
25
+ # @return [Verdict]
26
+ def self.[](name) = ALL.fetch(name.to_sym) { raise ArgumentError, "unknown verdict: #{name.inspect}" }
40
27
 
41
28
  ##
42
29
  # @return [Boolean]
@@ -59,8 +46,6 @@ module Html2rss
59
46
  def to_s = name.to_s
60
47
  end
61
48
 
62
- ##
63
- # Immutable outcome of a reconnaissance operation.
64
49
  Result = Data.define(
65
50
  :requested_url,
66
51
  :final_url,
@@ -72,7 +57,11 @@ module Html2rss
72
57
  :scheme_downgrade,
73
58
  :notes,
74
59
  :html_bytesize
75
- ) do
60
+ )
61
+
62
+ ##
63
+ # Immutable outcome of a reconnaissance operation.
64
+ class Result
76
65
  ##
77
66
  # @return [Boolean]
78
67
  def build? = verdict.build?
@@ -111,7 +100,7 @@ module Html2rss
111
100
  # Runs reconnaissance on a single URL.
112
101
  #
113
102
  # @param url [String, Html2rss::Url] source page URL
114
- # @param strategy [Symbol] request strategy (:auto, :faraday, :botasaurus)
103
+ # @param strategy [Symbol] request strategy (:auto, :default, :botasaurus)
115
104
  # @param cache_dir [String, nil] optional directory to cache raw HTML bodies
116
105
  # @param cache_mutex [Mutex, nil] optional mutex serializing cache writes
117
106
  # @option options [Integer, nil] :max_redirects optional maximum redirects
@@ -140,7 +129,7 @@ module Html2rss
140
129
  status: probe.response.status,
141
130
  verdict:,
142
131
  native_feed:,
143
- surface_category: SurfaceCategory.coerce(recon.surface_category),
132
+ surface_category: SurfaceCategory[recon.surface_category],
144
133
  articles_count: recon.articles_count,
145
134
  scheme_downgrade: recon.scheme_downgrade,
146
135
  notes:,
@@ -204,13 +193,13 @@ module Html2rss
204
193
  private_class_method :build_notes
205
194
 
206
195
  def determine_verdict(recon, native_feed)
207
- return Verdict.coerce(:drop) if recon.status.nil? || recon.status >= 400 || recon.scheme_downgrade
208
- return Verdict.coerce(:defer) if native_feed
196
+ return Verdict[:drop] if recon.status.nil? || recon.status >= 400 || recon.scheme_downgrade
197
+ return Verdict[:defer] if native_feed
209
198
 
210
- category = SurfaceCategory.coerce(recon.surface_category)
211
- return Verdict.coerce(:drop) if category.blocked?
199
+ category = SurfaceCategory[recon.surface_category]
200
+ return Verdict[:drop] if category.blocked?
212
201
 
213
- Verdict.coerce(:build)
202
+ Verdict[:build]
214
203
  end
215
204
  private_class_method :determine_verdict
216
205
 
@@ -219,9 +208,9 @@ module Html2rss
219
208
  requested_url: url_obj,
220
209
  final_url: url_obj,
221
210
  status: nil,
222
- verdict: Verdict.coerce(:drop),
211
+ verdict: Verdict[:drop],
223
212
  native_feed: nil,
224
- surface_category: SurfaceCategory.coerce(:unsupported_surface),
213
+ surface_category: SurfaceCategory[:unsupported_surface],
225
214
  articles_count: 0,
226
215
  scheme_downgrade: false,
227
216
  notes: ["error: #{error.class} - #{error.message}"],
@@ -9,11 +9,11 @@ module Html2rss
9
9
  # +ScrapeRequest+ / +ScrapeSuccess+ / +ScrapeError+ (sibling +openapi.yaml+).
10
10
  class BotasaurusContract
11
11
  # Closed set from OpenAPI ExecutionMode.
12
- EXECUTION_MODES = %w[auto request browser].freeze
12
+ EXECUTION_MODES = Set['auto', 'request', 'browser'].freeze
13
13
  # Closed set from OpenAPI NavigationMode.
14
- NAVIGATION_MODES = %w[auto get google_get google_get_bypass organic_get].freeze
14
+ NAVIGATION_MODES = Set['auto', 'get', 'google_get', 'google_get_bypass', 'organic_get'].freeze
15
15
  # Closed set from OpenAPI ErrorCategory.
16
- ERROR_CATEGORIES = %w[timeout challenge_block navigation_error metadata_error validation].freeze
16
+ ERROR_CATEGORIES = Set['timeout', 'challenge_block', 'navigation_error', 'metadata_error', 'validation'].freeze
17
17
 
18
18
  # ScrapeRequest properties except +url+ (html2rss supplies the target URL).
19
19
  REQUEST_OPTION_KEYS = %i[
@@ -25,7 +25,7 @@ module Html2rss
25
25
  # OpenAPI WindowSize required keys (positive integers).
26
26
  WINDOW_SIZE_PROPERTIES = %i[width height].freeze
27
27
 
28
- # Faraday POST /scrape cap mirroring botasaurus-scrape-api total scrape wall (`SCRAPE_TIMEOUT_SECONDS`).
28
+ # HTTPX POST /scrape cap mirroring botasaurus-scrape-api total scrape wall (`SCRAPE_TIMEOUT_SECONDS`).
29
29
  SCRAPE_TIMEOUT_SECONDS = Integer(ENV.fetch('BOTASAURUS_SCRAPE_TIMEOUT_SECONDS', 45))
30
30
  # Post-boot navigate/wait budget mirrored from scrape-api (`SCRAPE_WORK_TIMEOUT_SECONDS`).
31
31
  SCRAPE_WORK_TIMEOUT_SECONDS = Integer(ENV.fetch('BOTASAURUS_SCRAPE_WORK_TIMEOUT_SECONDS', 30))
@@ -72,10 +72,7 @@ module Html2rss
72
72
 
73
73
  # @return [Hash{String => String}] normalized response headers (null → {})
74
74
  def headers
75
- raw_headers = payload['headers']
76
- return {} unless raw_headers.is_a?(Hash) && raw_headers.any?
77
-
78
- raw_headers.to_h { |key, value| [key.to_s, value.to_s] }
75
+ stringify_headers(payload['headers'])
79
76
  end
80
77
 
81
78
  # @return [Integer, nil] document status_code when present
@@ -147,10 +144,12 @@ module Html2rss
147
144
  entry[key.to_s] || entry[key]
148
145
  end
149
146
 
150
- def xhr_headers(raw)
151
- return {} unless raw.is_a?(Hash)
147
+ def xhr_headers(raw) = stringify_headers(raw)
148
+
149
+ def stringify_headers(raw)
150
+ return {} unless raw.is_a?(Hash) && raw.any?
152
151
 
153
- raw.to_h { |key, value| [key.to_s, value.to_s] }
152
+ raw.transform_keys(&:to_s).transform_values(&:to_s)
154
153
  end
155
154
 
156
155
  def xhr_status_code(value)
@@ -286,7 +285,7 @@ module Html2rss
286
285
  payload
287
286
  end
288
287
 
289
- # @param transport_response [Faraday::Response] upstream HTTP response
288
+ # @param transport_response [HTTPX::Response] upstream HTTP response
290
289
  # @return [Success, Error]
291
290
  # @raise [BotasaurusServiceError] when payload is not a scrape envelope
292
291
  def parse_response(transport_response)
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'faraday'
3
+ require 'httpx'
4
4
  require 'json'
5
5
  require 'securerandom'
6
6
 
@@ -11,7 +11,7 @@ module Html2rss
11
11
  class BotasaurusStrategy < Strategy
12
12
  # Content-Type negotiation for the scrape API transport hop.
13
13
  TRANSPORT_ACCEPT = 'application/json'
14
- # Disable compressed bodies so Faraday returns raw JSON without implicit decoding surprises.
14
+ # Disable compressed bodies so HTTPX returns raw JSON without implicit decoding surprises.
15
15
  TRANSPORT_ENCODING = 'identity'
16
16
  # Correlates each POST /scrape with botasaurus-scrape-api request logs.
17
17
  REQUEST_ID_HEADER = 'X-Request-Id'
@@ -28,10 +28,15 @@ module Html2rss
28
28
  def post_scrape_request
29
29
  request_id = SecureRandom.uuid
30
30
  Log.debug("#{self.class}: POST /scrape #{REQUEST_ID_HEADER}=#{request_id}")
31
- transport_response = client.post('/scrape', JSON.generate(contract.request_payload), post_headers(request_id))
32
- contract.parse_response(transport_response)
31
+ payload = JSON.generate(contract.request_payload)
32
+ response = client.post(scrape_endpoint, body: payload, headers: post_headers(request_id))
33
+ raise response.error if response.is_a?(HTTPX::ErrorResponse)
34
+
35
+ contract.parse_response(response)
33
36
  end
34
37
 
38
+ def scrape_endpoint = "#{scraper_base_url.to_s.chomp('/')}/scrape"
39
+
35
40
  def build_response(parsed_response)
36
41
  Response.new(
37
42
  body: parsed_response.html,
@@ -44,22 +49,14 @@ module Html2rss
44
49
  end
45
50
 
46
51
  def raise_from_error!(error)
47
- raise_if_challenge_blocked!(error)
48
- raise_if_timed_out!(error)
49
- raise BotasaurusServiceError, error.failure_message
50
- end
51
-
52
- def raise_if_challenge_blocked!(error)
53
- return unless error.challenge_block?
52
+ raise BlockedSurfaceDetected, "Blocked surface detected: #{error.challenge_message}" if error.challenge_block?
54
53
 
55
- raise BlockedSurfaceDetected, "Blocked surface detected: #{error.challenge_message}"
56
- end
57
-
58
- def raise_if_timed_out!(error)
59
- return unless error.timeout?
54
+ if error.timeout?
55
+ log_timeout!(reason: 'botasaurus_upstream')
56
+ raise RequestTimedOut.new(error.failure_message, timeout_phase: error.timeout_phase)
57
+ end
60
58
 
61
- log_timeout!(reason: 'botasaurus_upstream')
62
- raise RequestTimedOut.new(error.failure_message, timeout_phase: error.timeout_phase)
59
+ raise BotasaurusServiceError, error.failure_message
63
60
  end
64
61
 
65
62
  def response_url(final_url)
@@ -79,8 +76,18 @@ module Html2rss
79
76
  end
80
77
 
81
78
  def client
82
- # No :gzip middleware on this client — compression is for remote target fetches only.
83
- @client ||= Faraday.new(url: scraper_base_url.to_s, headers: client_headers, request: request_options)
79
+ timeout = attempt_timeout_seconds.to_f
80
+ @client ||= HTTPX.with(headers: client_headers, timeout: client_timeouts(timeout), resolver_class: :system)
81
+ end
82
+
83
+ def client_timeouts(timeout)
84
+ {
85
+ operation_timeout: timeout,
86
+ connect_timeout: [ctx.policy.connect_timeout_seconds, timeout].min,
87
+ read_timeout: timeout,
88
+ request_timeout: timeout,
89
+ total_request_timeout: timeout
90
+ }
84
91
  end
85
92
 
86
93
  def client_headers
@@ -92,14 +99,7 @@ module Html2rss
92
99
  end
93
100
 
94
101
  def post_headers(request_id)
95
- {
96
- 'Content-Type' => 'application/json',
97
- REQUEST_ID_HEADER => request_id
98
- }
99
- end
100
-
101
- def request_options
102
- { timeout: attempt_timeout_seconds.to_i }
102
+ { 'Content-Type' => 'application/json', REQUEST_ID_HEADER => request_id }
103
103
  end
104
104
 
105
105
  def attempt_timeout_seconds
@@ -123,6 +123,10 @@ module Html2rss
123
123
  def translate_connection_error(error)
124
124
  raise BotasaurusConnectionFailed, "Botasaurus connection failed: #{error.message}"
125
125
  end
126
+
127
+ def timeout_error?(error) = error.is_a?(HTTPX::TimeoutError) || super
128
+
129
+ def connection_error?(error) = error.is_a?(HTTPX::ConnectionError) || error.is_a?(HTTPX::TLSError) || super
126
130
  end
127
131
  end
128
132
  end
@@ -45,9 +45,12 @@ module Html2rss
45
45
 
46
46
  def inflate(raw, encoding)
47
47
  case encoding
48
- when 'gzip' then uncompress_gzip(raw)
49
- when 'deflate' then inflate_deflate(raw)
48
+ when 'gzip'
49
+ return raw unless raw.start_with?(GZIP_MAGIC)
50
+
51
+ uncompress_gzip(raw)
50
52
  when 'br' then try_brotli(raw, require_html: false)
53
+ when 'deflate' then inflate_deflate(raw)
51
54
  end
52
55
  rescue Zlib::Error, Brotli::Error, ArgumentError
53
56
  nil
@@ -55,12 +58,6 @@ module Html2rss
55
58
  module_function :inflate
56
59
  private_class_method :inflate
57
60
 
58
- def uncompress_gzip(raw)
59
- Zlib::GzipReader.wrap(StringIO.new(raw), encoding: 'ASCII-8BIT', &:read)
60
- end
61
- module_function :uncompress_gzip
62
- private_class_method :uncompress_gzip
63
-
64
61
  def inflate_deflate(raw)
65
62
  inflater = nil
66
63
  Zlib::Inflate.inflate(raw)
@@ -73,6 +70,14 @@ module Html2rss
73
70
  module_function :inflate_deflate
74
71
  private_class_method :inflate_deflate
75
72
 
73
+ def uncompress_gzip(raw)
74
+ Zlib::GzipReader.wrap(StringIO.new(raw), encoding: 'ASCII-8BIT', &:read)
75
+ rescue Zlib::Error
76
+ nil
77
+ end
78
+ module_function :uncompress_gzip
79
+ private_class_method :uncompress_gzip
80
+
76
81
  def try_brotli(raw, require_html: true)
77
82
  inflated = Brotli.inflate(raw)
78
83
  return inflated unless require_html
@@ -86,7 +91,10 @@ module Html2rss
86
91
  private_class_method :try_brotli
87
92
 
88
93
  def content_encodings(headers)
89
- header(headers, 'content-encoding').to_s.split(',').map { |value| value.strip.downcase }.reject(&:empty?)
94
+ header(headers, 'content-encoding')
95
+ .to_s
96
+ .split(',')
97
+ .filter_map { |val| (s = val.strip.downcase).empty? ? nil : s }
90
98
  end
91
99
  module_function :content_encodings
92
100
  private_class_method :content_encodings