html2rss 0.25.0 → 0.26.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 (68) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +76 -2
  3. data/html2rss.gemspec +4 -1
  4. data/lib/html2rss/auto_source/cleanup.rb +62 -36
  5. data/lib/html2rss/auto_source/scraper/json_state.rb +31 -22
  6. data/lib/html2rss/auto_source/scraper/schema/item_list.rb +2 -14
  7. data/lib/html2rss/auto_source/scraper/xhr_articles.rb +69 -0
  8. data/lib/html2rss/auto_source/scraper.rb +34 -15
  9. data/lib/html2rss/auto_source/segmenter/list.rb +3 -1
  10. data/lib/html2rss/auto_source/segmenter/primary_link.rb +6 -7
  11. data/lib/html2rss/auto_source/segmenter.rb +30 -1
  12. data/lib/html2rss/auto_source.rb +7 -2
  13. data/lib/html2rss/capture.rb +315 -0
  14. data/lib/html2rss/cli.rb +64 -5
  15. data/lib/html2rss/config/auto_source_contract.rb +3 -1
  16. data/lib/html2rss/config/schema.rb +67 -26
  17. data/lib/html2rss/config/selectors_validator.rb +60 -21
  18. data/lib/html2rss/config/validator.rb +18 -27
  19. data/lib/html2rss/error.rb +15 -3
  20. data/lib/html2rss/feed_pipeline/auto_fallback.rb +16 -6
  21. data/lib/html2rss/feed_pipeline/runtime_policy.rb +3 -43
  22. data/lib/html2rss/feed_pipeline.rb +10 -3
  23. data/lib/html2rss/hash_util.rb +17 -0
  24. data/lib/html2rss/html/sst_article_extractor.rb +36 -7
  25. data/lib/html2rss/link_destination/noise_policy.rb +7 -18
  26. data/lib/html2rss/link_destination/path_classifier.rb +3 -0
  27. data/lib/html2rss/mcp/server.rb +577 -0
  28. data/lib/html2rss/mcp.rb +21 -0
  29. data/lib/html2rss/request_service/blocked_surface.rb +24 -1
  30. data/lib/html2rss/request_service/botasaurus_contract.rb +89 -9
  31. data/lib/html2rss/request_service/botasaurus_strategy.rb +4 -2
  32. data/lib/html2rss/request_service/budget.rb +7 -35
  33. data/lib/html2rss/request_service/context.rb +0 -6
  34. data/lib/html2rss/request_service/faraday_strategy.rb +45 -2
  35. data/lib/html2rss/request_service/policy.rb +1 -1
  36. data/lib/html2rss/request_service/response.rb +15 -1
  37. data/lib/html2rss/request_service/strategy.rb +1 -2
  38. data/lib/html2rss/request_service.rb +4 -9
  39. data/lib/html2rss/scoring/container_assessor.rb +9 -15
  40. data/lib/html2rss/scoring/engine.rb +49 -5
  41. data/lib/html2rss/scoring/link_resolver.rb +12 -0
  42. data/lib/html2rss/scoring/ranked_segment.rb +0 -12
  43. data/lib/html2rss/scoring/score.rb +1 -20
  44. data/lib/html2rss/scoring.rb +0 -25
  45. data/lib/html2rss/selectors/extractors/attribute.rb +15 -0
  46. data/lib/html2rss/selectors/extractors/href.rb +12 -0
  47. data/lib/html2rss/selectors/extractors/html.rb +12 -0
  48. data/lib/html2rss/selectors/extractors/static.rb +14 -0
  49. data/lib/html2rss/selectors/extractors/text.rb +11 -0
  50. data/lib/html2rss/selectors/post_processors/gsub.rb +18 -0
  51. data/lib/html2rss/selectors/post_processors/html_to_markdown.rb +11 -0
  52. data/lib/html2rss/selectors/post_processors/markdown_to_html.rb +12 -0
  53. data/lib/html2rss/selectors/post_processors/parse_time.rb +11 -0
  54. data/lib/html2rss/selectors/post_processors/parse_uri.rb +11 -0
  55. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +13 -0
  56. data/lib/html2rss/selectors/post_processors/substring.rb +21 -0
  57. data/lib/html2rss/selectors/post_processors/template.rb +20 -0
  58. data/lib/html2rss/selectors/schema_doc.rb +99 -0
  59. data/lib/html2rss/url.rb +11 -7
  60. data/lib/html2rss/version.rb +1 -1
  61. data/lib/html2rss.rb +32 -1
  62. data/schema/html2rss-config.schema.json +408 -85
  63. metadata +54 -12
  64. data/lib/html2rss/request_service/browserless_strategy.rb +0 -132
  65. data/lib/html2rss/request_service/puppet_commander/navigation_guards.rb +0 -148
  66. data/lib/html2rss/request_service/puppet_commander/preload_runner.rb +0 -86
  67. data/lib/html2rss/request_service/puppet_commander.rb +0 -95
  68. data/lib/html2rss/scoring/anchor_score.rb +0 -34
@@ -9,6 +9,7 @@ module Html2rss
9
9
  class BotasaurusContract
10
10
  # Default Botasaurus scrape options when no explicit config is provided.
11
11
  DEFAULT_OPTIONS = {
12
+ execution_mode: 'auto',
12
13
  navigation_mode: 'auto',
13
14
  max_retries: 1,
14
15
  headless: false
@@ -16,23 +17,30 @@ module Html2rss
16
17
 
17
18
  # Allowlisted request.botasaurus keys forwarded to upstream.
18
19
  OPTION_KEYS = %i[
20
+ execution_mode
19
21
  navigation_mode
20
22
  max_retries
21
23
  wait_for_selector
22
24
  wait_timeout_seconds
25
+ scroll
26
+ scroll_to_bottom
23
27
  block_images
24
28
  block_images_and_css
29
+ block_trackers
25
30
  wait_for_complete_page_load
26
31
  headless
27
32
  proxy
28
33
  user_agent
29
34
  window_size
30
35
  lang
36
+ headers
37
+ cookies
31
38
  ].freeze
32
39
 
33
40
  # Allowlisted upstream response keys exposed as Response#transport_meta.
34
41
  META_KEYS = %w[
35
42
  request_id strategy_used render_ms challenge_detected blocked_detected attempts error_category
43
+ execution_tier detected_challenge
36
44
  ].freeze
37
45
 
38
46
  # Remaining seconds at or below which Botasaurus retries are disabled.
@@ -45,6 +53,10 @@ module Html2rss
45
53
  class ParsedResponse
46
54
  # Fallback headers when upstream omits response headers.
47
55
  DEFAULT_HEADERS = { 'content-type' => 'text/html' }.freeze
56
+ # Per-body size cap for captured XHR JSON (defense-in-depth vs upstream).
57
+ MAX_XHR_BODY_BYTES = 500_000
58
+ # Aggregate size cap across all captured XHR bodies.
59
+ MAX_XHR_AGGREGATE_BYTES = 2_000_000
48
60
 
49
61
  # @param payload [Hash{String => Object}] parsed Botasaurus response payload
50
62
  # @param transport_status [Integer] HTTP status returned by Botasaurus
@@ -58,7 +70,7 @@ module Html2rss
58
70
 
59
71
  # @return [Boolean] true when upstream returned non-200 or an error payload
60
72
  def upstream_failure?
61
- status != 200 || error_message?
73
+ transport_status != 200 || status != 200 || error_message?
62
74
  end
63
75
 
64
76
  # @return [String] normalized challenge error message
@@ -76,10 +88,10 @@ module Html2rss
76
88
  end
77
89
 
78
90
  # @return [String] rendered HTML body from Botasaurus
79
- # @raise [BotasaurusConnectionFailed] when html is missing
91
+ # @raise [BotasaurusServiceError] when html is missing
80
92
  def html
81
93
  value = payload['html']
82
- raise BotasaurusConnectionFailed, "Botasaurus response missing required 'html' field" if value.nil?
94
+ raise BotasaurusServiceError, "Botasaurus response missing required 'html' field" if value.nil?
83
95
 
84
96
  value.to_s
85
97
  end
@@ -104,6 +116,21 @@ module Html2rss
104
116
  # @return [Hash{String => Object}] allowlisted upstream telemetry (frozen)
105
117
  def transport_meta = payload.slice(*META_KEYS).compact.freeze
106
118
 
119
+ # @return [Array<Hash{String => Object}>] size-capped XHR/fetch captures (absent → [])
120
+ def xhr_responses
121
+ raw = payload['xhr_responses']
122
+ return [] unless raw.is_a?(Array)
123
+
124
+ aggregate_bytes = 0
125
+ raw.filter_map do |entry|
126
+ normalized = normalize_xhr_entry(entry)
127
+ next unless normalized
128
+ next unless (aggregate_bytes = advance_xhr_budget(normalized, aggregate_bytes))
129
+
130
+ normalized
131
+ end
132
+ end
133
+
107
134
  private
108
135
 
109
136
  attr_reader :payload, :transport_status
@@ -118,50 +145,103 @@ module Html2rss
118
145
  value = error
119
146
  value.is_a?(String) ? !value.empty? : !value.nil?
120
147
  end
148
+
149
+ def normalize_xhr_entry(entry)
150
+ return unless entry.is_a?(Hash)
151
+
152
+ body = xhr_field(entry, :body)
153
+ return unless body.is_a?(String) && !body.empty?
154
+
155
+ {
156
+ 'url' => xhr_field(entry, :url).to_s,
157
+ 'body' => body,
158
+ 'headers' => xhr_headers(xhr_field(entry, :headers)),
159
+ 'status_code' => xhr_status_code(xhr_field(entry, :status_code))
160
+ }
161
+ end
162
+
163
+ # @return [Integer, nil] next aggregate byte total when accepted
164
+ def advance_xhr_budget(normalized, aggregate_bytes)
165
+ body_bytes = normalized.fetch('body').bytesize
166
+ return if body_bytes > MAX_XHR_BODY_BYTES
167
+ return if aggregate_bytes + body_bytes > MAX_XHR_AGGREGATE_BYTES
168
+
169
+ aggregate_bytes + body_bytes
170
+ end
171
+
172
+ def xhr_field(entry, key)
173
+ entry[key.to_s] || entry[key]
174
+ end
175
+
176
+ def xhr_headers(raw)
177
+ return {} unless raw.is_a?(Hash)
178
+
179
+ raw.to_h { |key, value| [key.to_s, value.to_s] }
180
+ end
181
+
182
+ def xhr_status_code(value)
183
+ value.is_a?(Integer) ? value : nil
184
+ end
121
185
  end
122
186
 
123
187
  ##
124
188
  # @param url [Html2rss::Url] canonical URL to scrape
189
+ # @param headers [Hash] request headers from context
125
190
  # @param options [Hash] validated request.botasaurus options
126
191
  # @param remaining_timeout_seconds [Numeric, nil] shared request budget remainder for clamps
192
+ # @option options [String] :execution_mode
127
193
  # @option options [String] :navigation_mode
128
194
  # @option options [Integer] :max_retries
129
195
  # @option options [String] :wait_for_selector
130
196
  # @option options [Integer] :wait_timeout_seconds
131
197
  # @option options [Boolean] :block_images
132
198
  # @option options [Boolean] :block_images_and_css
199
+ # @option options [Boolean] :block_trackers
133
200
  # @option options [Boolean] :wait_for_complete_page_load
134
201
  # @option options [Boolean] :headless
135
202
  # @option options [String] :proxy
136
203
  # @option options [String] :user_agent
137
204
  # @option options [Array<Integer>] :window_size
138
205
  # @option options [String] :lang
139
- def initialize(url:, options: {}, remaining_timeout_seconds: nil)
206
+ # @option options [Hash] :cookies
207
+ # @option options [Hash] :headers
208
+ def initialize(url:, headers: {}, options: {}, remaining_timeout_seconds: nil)
140
209
  @url = url
210
+ @headers = headers
141
211
  @options = options
142
212
  @remaining_timeout_seconds = remaining_timeout_seconds
143
213
  end
144
214
 
145
215
  # @return [Hash] payload for POST /scrape (budget-clamped when remaining is known)
146
216
  def request_payload
147
- DEFAULT_OPTIONS.merge(filtered_options).merge(url: url.to_s).then { clamp_for_budget(_1) }
217
+ payload = DEFAULT_OPTIONS.merge(filtered_options)
218
+ forwarded_headers = merged_headers
219
+ payload[:headers] = forwarded_headers if forwarded_headers&.any?
220
+ payload.merge(url: url.to_s).then { clamp_for_budget(_1) }
148
221
  end
149
222
 
150
223
  # @param transport_response [Faraday::Response] upstream HTTP response
151
224
  # @return [ParsedResponse]
152
- # @raise [BotasaurusConnectionFailed] when payload is not valid JSON object
225
+ # @raise [BotasaurusServiceError] when payload is not valid JSON object
153
226
  def parse_response(transport_response)
154
227
  payload = JSON.parse(transport_response.body.to_s)
155
- raise BotasaurusConnectionFailed, 'Botasaurus response must be a JSON object' unless payload.is_a?(Hash)
228
+ raise BotasaurusServiceError, 'Botasaurus response must be a JSON object' unless payload.is_a?(Hash)
156
229
 
157
230
  ParsedResponse.new(payload:, transport_status: transport_response.status)
158
231
  rescue JSON::ParserError => error
159
- raise BotasaurusConnectionFailed, "Botasaurus response JSON parse failed: #{error.message}"
232
+ raise BotasaurusServiceError, "Botasaurus response JSON parse failed: #{error.message}"
160
233
  end
161
234
 
162
235
  private
163
236
 
164
- attr_reader :url, :options, :remaining_timeout_seconds
237
+ attr_reader :url, :headers, :options, :remaining_timeout_seconds
238
+
239
+ def merged_headers
240
+ explicit = options[:headers] || {}
241
+ base = headers.is_a?(Hash) ? headers : {}
242
+ merged = base.merge(explicit).transform_keys(&:to_s).compact
243
+ merged.empty? ? nil : merged
244
+ end
165
245
 
166
246
  def filtered_options
167
247
  OPTION_KEYS.each_with_object({}) do |key, normalized|
@@ -28,7 +28,8 @@ module Html2rss
28
28
  headers: parsed_response.headers,
29
29
  url: response_url(parsed_response.final_url),
30
30
  status: parsed_response.status,
31
- transport_meta: parsed_response.transport_meta
31
+ transport_meta: parsed_response.transport_meta,
32
+ captured_responses: parsed_response.xhr_responses
32
33
  )
33
34
  end
34
35
 
@@ -41,7 +42,7 @@ module Html2rss
41
42
  def raise_if_upstream_failed!(parsed_response)
42
43
  return unless parsed_response.upstream_failure?
43
44
 
44
- raise BotasaurusConnectionFailed, parsed_response.upstream_failure_message
45
+ raise BotasaurusServiceError, parsed_response.upstream_failure_message
45
46
  end
46
47
 
47
48
  def response_url(final_url)
@@ -55,6 +56,7 @@ module Html2rss
55
56
  def contract
56
57
  @contract ||= BotasaurusContract.new(
57
58
  url: ctx.url,
59
+ headers: ctx.headers,
58
60
  options: ctx.request.fetch(:botasaurus, {}),
59
61
  remaining_timeout_seconds: attempt_timeout_seconds
60
62
  )
@@ -3,20 +3,17 @@
3
3
  module Html2rss
4
4
  class RequestService
5
5
  ##
6
- # Tracks request slots, interaction budget, and wall-clock deadline for one feed build.
6
+ # Tracks HTTP request slots and wall-clock deadline for one feed build.
7
7
  #
8
- # HTTP fetches consume request slots; Browserless preload clicks/scrolls/waits consume
9
- # interaction budget. Deadline remains on `#remaining_timeout_seconds`.
8
+ # HTTP fetches consume request slots. Deadline remains on `#remaining_timeout_seconds`.
10
9
  class Budget
11
10
  ##
12
11
  # @param max_requests [Integer] maximum HTTP request slots
13
- # @param max_interactions [Integer] maximum preload interaction slots (default 0)
14
12
  # @param total_timeout_seconds [Integer, nil] wall-clock timeout for the feed build
15
- def initialize(max_requests:, max_interactions: 0, total_timeout_seconds: nil)
16
- validate_slot_limits!(max_requests:, max_interactions:)
13
+ def initialize(max_requests:, total_timeout_seconds: nil)
14
+ validate_slot_limits!(max_requests:)
17
15
 
18
16
  @remaining_requests = max_requests
19
- @remaining_interactions = max_interactions
20
17
  @start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
21
18
  @total_timeout_seconds = total_timeout_seconds
22
19
  @mutex = Mutex.new
@@ -44,21 +41,6 @@ module Html2rss
44
41
  end
45
42
  end
46
43
 
47
- ##
48
- # Consumes one Browserless preload interaction slot.
49
- #
50
- # Preload must not steal pagination HTTP request slots.
51
- #
52
- # @return [Integer] remaining interaction slots after consumption
53
- # @raise [InteractionBudgetExceeded] if no interaction slots remain
54
- def consume_interaction!
55
- @mutex.synchronize do
56
- raise InteractionBudgetExceeded, 'Interaction budget exhausted' if @remaining_interactions.zero?
57
-
58
- @remaining_interactions -= 1
59
- end
60
- end
61
-
62
44
  ##
63
45
  # @return [Integer] HTTP request slots still available
64
46
  def remaining
@@ -71,12 +53,6 @@ module Html2rss
71
53
  @mutex.synchronize { @remaining_requests }
72
54
  end
73
55
 
74
- ##
75
- # @return [Integer] preload interaction slots still available
76
- def remaining_interactions
77
- @mutex.synchronize { @remaining_interactions }
78
- end
79
-
80
56
  ##
81
57
  # @return [Float, nil] the remaining timeout in seconds, or nil if not tracked
82
58
  def remaining_timeout_seconds
@@ -118,14 +94,10 @@ module Html2rss
118
94
 
119
95
  private
120
96
 
121
- def validate_slot_limits!(max_requests:, max_interactions:)
122
- unless max_requests.is_a?(Integer) && max_requests.positive?
123
- raise ArgumentError,
124
- 'max_requests must be positive'
125
- end
126
- return if max_interactions.is_a?(Integer) && !max_interactions.negative?
97
+ def validate_slot_limits!(max_requests:)
98
+ return if max_requests.is_a?(Integer) && max_requests.positive?
127
99
 
128
- raise ArgumentError, 'max_interactions must be a non-negative integer'
100
+ raise ArgumentError, 'max_requests must be positive'
129
101
  end
130
102
  end
131
103
  end
@@ -32,12 +32,6 @@ module Html2rss
32
32
  # @return [Hash] the request specific options
33
33
  attr_reader :request
34
34
 
35
- # @return [Hash] browserless specific options
36
- def browserless = request.fetch(:browserless, {})
37
-
38
- # @return [Hash, nil] preload options for browserless requests
39
- def browserless_preload = browserless[:preload]
40
-
41
35
  # @return [Symbol] the request relation
42
36
  attr_reader :relation
43
37
 
@@ -9,6 +9,7 @@ module Html2rss
9
9
  ##
10
10
  # Strategy to use Faraday for the request.
11
11
  # @see https://rubygems.org/gems/faraday
12
+ # rubocop:disable Metrics/ClassLength -- terminal redirect retry colocated with Faraday transport
12
13
  class FaradayStrategy < Strategy
13
14
  ##
14
15
  # Restores buffered streamed bytes so response middleware can process them.
@@ -36,7 +37,8 @@ module Html2rss
36
37
  def perform_execute
37
38
  deadline = request_deadline
38
39
  @response_guard = ResponseGuard.new(policy: ctx.policy)
39
- raw_response = faraday_request(response_guard, deadline:, streaming_buffer: true)
40
+ reset_redirect_tracking!
41
+ raw_response = request_with_terminal_redirect_retry(response_guard, deadline:)
40
42
  raw_response = retry_without_streaming(response_guard, deadline:) if retry_without_streaming?(raw_response)
41
43
  build_response(raw_response)
42
44
  end
@@ -47,6 +49,45 @@ module Html2rss
47
49
  monotonic_now + ctx.budget.effective_timeout_seconds(fallback: ctx.policy.total_timeout_seconds)
48
50
  end
49
51
 
52
+ def reset_redirect_tracking!
53
+ @last_redirect_to = nil
54
+ @terminal_redirect_retried = false
55
+ @request_url_override = nil
56
+ end
57
+
58
+ def request_with_terminal_redirect_retry(response_guard, deadline:)
59
+ faraday_request(response_guard, deadline:, streaming_buffer: true)
60
+ rescue Faraday::FollowRedirects::RedirectLimitReached => error
61
+ raise error unless terminal_redirect_retryable?
62
+
63
+ retry_from_terminal_redirect!(response_guard, deadline:)
64
+ end
65
+
66
+ def terminal_redirect_retryable?
67
+ return false if @terminal_redirect_retried || @last_redirect_to.nil?
68
+
69
+ @last_redirect_to.to_s != request_url.to_s
70
+ end
71
+
72
+ def retry_from_terminal_redirect!(response_guard, deadline:)
73
+ terminal_url = @last_redirect_to
74
+ @terminal_redirect_retried = true
75
+ Log.debug("#{self.class}: redirect limit reached; retrying once from #{terminal_url}")
76
+ begin_terminal_url_request!(terminal_url)
77
+ faraday_request(response_guard, deadline:, streaming_buffer: true, consume_budget: false)
78
+ end
79
+
80
+ def begin_terminal_url_request!(terminal_url)
81
+ ctx.policy.validate_request!(url: terminal_url, origin_url: ctx.origin_url, relation: ctx.relation)
82
+ @request_url_override = terminal_url
83
+ @client = nil
84
+ @last_redirect_to = nil
85
+ end
86
+
87
+ def request_url
88
+ @request_url_override || ctx.url
89
+ end
90
+
50
91
  def build_response(response)
51
92
  Response.new(body: response.body, headers: response.headers, url: response_url(response),
52
93
  status: response.status)
@@ -105,7 +146,7 @@ module Html2rss
105
146
 
106
147
  # rubocop:disable Metrics/AbcSize
107
148
  def client
108
- @client ||= Faraday.new(url: ctx.url.to_s, headers: ctx.headers) do |faraday|
149
+ @client ||= Faraday.new(url: request_url.to_s, headers: ctx.headers) do |faraday|
109
150
  faraday.use Faraday::FollowRedirects::Middleware, limit: ctx.policy.max_redirects, callback: redirect_callback
110
151
  faraday.request :gzip
111
152
  faraday.use StreamingBodyMiddleware
@@ -168,6 +209,7 @@ module Html2rss
168
209
  lambda do |old_env, new_env|
169
210
  from_url = normalize_url(old_env[:url])
170
211
  to_url = normalize_url(new_env[:url])
212
+ @last_redirect_to = to_url
171
213
  ctx.policy.validate_redirect!(from_url:, to_url:, origin_url: ctx.origin_url, relation: ctx.relation)
172
214
  end
173
215
  end
@@ -180,5 +222,6 @@ module Html2rss
180
222
  Process.clock_gettime(Process::CLOCK_MONOTONIC)
181
223
  end
182
224
  end
225
+ # rubocop:enable Metrics/ClassLength
183
226
  end
184
227
  end
@@ -18,7 +18,7 @@ module Html2rss
18
18
  connect_timeout_seconds: Integer(ENV.fetch('HTML2RSS_CONNECT_TIMEOUT_SECONDS', 5)),
19
19
  read_timeout_seconds: Integer(ENV.fetch('HTML2RSS_READ_TIMEOUT_SECONDS', 10)),
20
20
  total_timeout_seconds: Integer(ENV.fetch('HTML2RSS_TOTAL_TIMEOUT_SECONDS', 30)),
21
- max_redirects: 3,
21
+ max_redirects: 5,
22
22
  max_response_bytes: 5_242_880,
23
23
  max_decompressed_bytes: 10_485_760,
24
24
  max_requests: 1,
@@ -9,6 +9,8 @@ module Html2rss
9
9
  class Response
10
10
  # Default when a strategy does not attach transport telemetry.
11
11
  EMPTY_TRANSPORT_META = {}.freeze
12
+ # Default when a strategy does not capture sub-resource responses.
13
+ EMPTY_CAPTURED_RESPONSES = [].freeze
12
14
 
13
15
  ##
14
16
  # @param body [String] the body of the response
@@ -16,7 +18,10 @@ module Html2rss
16
18
  # @param headers [Hash] the headers of the response
17
19
  # @param status [Integer, nil] the HTTP status code when available
18
20
  # @param transport_meta [Hash] allowlisted upstream telemetry (frozen when present)
19
- def initialize(body:, url:, headers: {}, status: nil, transport_meta: EMPTY_TRANSPORT_META)
21
+ # @param captured_responses [Array<Hash>] JSON XHR/fetch bodies captured during browser scrapes
22
+ # rubocop:disable Metrics/MethodLength, Metrics/ParameterLists -- transport + capture fields stay co-located
23
+ def initialize(body:, url:, headers: {}, status: nil, transport_meta: EMPTY_TRANSPORT_META,
24
+ captured_responses: EMPTY_CAPTURED_RESPONSES)
20
25
  @body = body
21
26
 
22
27
  headers = headers.dup
@@ -27,7 +32,13 @@ module Html2rss
27
32
  @status = status
28
33
  @url = url
29
34
  @transport_meta = transport_meta.nil? || transport_meta.empty? ? EMPTY_TRANSPORT_META : transport_meta.freeze
35
+ @captured_responses = if captured_responses.nil? || captured_responses.empty?
36
+ EMPTY_CAPTURED_RESPONSES
37
+ else
38
+ captured_responses.freeze
39
+ end
30
40
  end
41
+ # rubocop:enable Metrics/MethodLength, Metrics/ParameterLists
31
42
 
32
43
  # @return [String] the raw body of the response
33
44
  attr_reader :body
@@ -44,6 +55,9 @@ module Html2rss
44
55
  # @return [Hash] allowlisted upstream transport telemetry
45
56
  attr_reader :transport_meta
46
57
 
58
+ # @return [Array<Hash>] captured JSON XHR/fetch responses (empty when unsupported)
59
+ attr_reader :captured_responses
60
+
47
61
  # @return [String] normalized content type header value
48
62
  def content_type = header('content-type').to_s
49
63
 
@@ -137,8 +137,7 @@ module Html2rss
137
137
  # @return [Boolean]
138
138
  def timeout_error?(error)
139
139
  error.is_a?(Faraday::TimeoutError) ||
140
- error.is_a?(Timeout::Error) ||
141
- (defined?(Puppeteer::TimeoutError) && error.is_a?(Puppeteer::TimeoutError))
140
+ error.is_a?(Timeout::Error)
142
141
  end
143
142
 
144
143
  # @param error [StandardError]
@@ -6,7 +6,7 @@ require 'forwardable'
6
6
  module Html2rss
7
7
  ##
8
8
  # Requests website URLs to retrieve their HTML for further processing.
9
- # Provides concrete transport strategies (e.g. Faraday, Browserless).
9
+ # Provides concrete transport strategies (e.g. Faraday, Botasaurus).
10
10
  #
11
11
  # Feed-level +:auto+ is not registered here — {FeedPipeline::StrategyPlan} resolves
12
12
  # it to a concrete strategy (or {FeedPipeline::AutoFallback} chain) before execute.
@@ -23,8 +23,6 @@ module Html2rss
23
23
  class UnsupportedResponseContentType < Html2rss::Error; end
24
24
  # Raised when HTTP request slot limits are exceeded.
25
25
  class RequestBudgetExceeded < Html2rss::Error; end
26
- # Raised when Browserless preload interaction limits are exceeded.
27
- class InteractionBudgetExceeded < Html2rss::Error; end
28
26
  # Raised when policy denies private-network access.
29
27
  class PrivateNetworkDenied < Html2rss::Error; end
30
28
  # Raised when cross-origin follow-up requests are denied.
@@ -35,14 +33,12 @@ module Html2rss
35
33
  class BlockedSurfaceDetected < Html2rss::Error; end
36
34
  # Raised when a request times out.
37
35
  class RequestTimedOut < Html2rss::Error; end
38
- # Raised when Browserless configuration is missing or invalid.
39
- class BrowserlessConfigurationError < Html2rss::Error; end
40
- # Raised when Browserless cannot be reached.
41
- class BrowserlessConnectionFailed < Html2rss::Error; end
42
36
  # Raised when Botasaurus configuration is missing or invalid.
43
37
  class BotasaurusConfigurationError < Html2rss::Error; end
44
- # Raised when Botasaurus cannot be reached or returns invalid payloads.
38
+ # Raised when the Botasaurus service cannot be reached (network / DNS / SSL).
45
39
  class BotasaurusConnectionFailed < Html2rss::Error; end
40
+ # Raised when Botasaurus responds but the scrape fails (upstream error, bad payload).
41
+ class BotasaurusServiceError < Html2rss::Error; end
46
42
 
47
43
  class << self
48
44
  extend Forwardable
@@ -62,7 +58,6 @@ module Html2rss
62
58
  @strategies = {
63
59
  faraday: FaradayStrategy,
64
60
  botasaurus: BotasaurusStrategy,
65
- browserless: BrowserlessStrategy,
66
61
  local_file: LocalFileStrategy
67
62
  }
68
63
  @default_strategy_name = :faraday
@@ -8,21 +8,15 @@ module Html2rss
8
8
  # Microdata itemprop values treated as publish/update markers.
9
9
  PUBLISH_ITEMPROPS = %w[datePublished dateModified].freeze
10
10
 
11
- # Matches content-like tokens in class/id strings.
12
- CONTENT_TOKEN_REGEXP = begin
13
- words = LinkDestination::PathClassifier::SEGMENT_SETS.fetch(:content)
14
- /(?:^|\s|[-_])(#{Regexp.union(words.to_a).source})(?:\s|[-_]|$)/i
15
- end.freeze
16
-
17
- # Matches utility/junk tokens in class/id strings.
18
- JUNK_TOKEN_REGEXP = begin
19
- words = LinkDestination::PathClassifier::SEGMENT_SETS.fetch(:utility)
20
- /(?:^|\s|[-_])(#{Regexp.union(words.to_a).source})(?:\s|[-_]|$)/i
21
- end.freeze
22
-
23
11
  # @param text_classifier [LinkDestination::TextClassifier]
24
- def initialize(text_classifier: LinkDestination::TextClassifier.new)
12
+ # @param content_token_regexp [Regexp]
13
+ # @param junk_token_regexp [Regexp]
14
+ def initialize(text_classifier: LinkDestination::TextClassifier.new,
15
+ content_token_regexp: LinkResolver::CONTENT_TOKEN_REGEXP,
16
+ junk_token_regexp: LinkResolver::JUNK_TOKEN_REGEXP)
25
17
  @text_classifier = text_classifier
18
+ @content_token_regexp = content_token_regexp
19
+ @junk_token_regexp = junk_token_regexp
26
20
  end
27
21
 
28
22
  ##
@@ -41,8 +35,8 @@ module Html2rss
41
35
  publish_marker: publish_marker?(container),
42
36
  descriptive_context: descriptive_context?(container.visible_text, title),
43
37
  article_container: container.name == :article,
44
- content_tokens: tokens.match?(CONTENT_TOKEN_REGEXP),
45
- junk_tokens: tokens.match?(JUNK_TOKEN_REGEXP),
38
+ content_tokens: tokens.match?(@content_token_regexp),
39
+ junk_tokens: tokens.match?(@junk_token_regexp),
46
40
  utility_prefix_title: @text_classifier.utility_prefix?(title),
47
41
  recommended_title: @text_classifier.recommended?(title),
48
42
  utility_path: destination_facts&.utility_path,
@@ -10,7 +10,17 @@ module Html2rss
10
10
  # Minimum composite score retained by {#rank_top} (precision floor).
11
11
  SCORE_FLOOR = 0.0
12
12
 
13
- # Quality feature triples: [FeatureId, predicate, weight].
13
+ FEATURE_IDS = %i[
14
+ title_word_count_ge3 title_word_count_ge7 path_length_gt6 content_path
15
+ publish_marker descriptive_context article_container content_tokens
16
+ non_content_utility_path utility_prefix_title_short shallow weak_container
17
+ recommended_title_non_content high_confidence_junk_path junk_tokens
18
+ heading_anchor heading_text_match meaningful_text content_like_destination
19
+ cluster_size cluster_avg_words cluster_heading cluster_time cluster_date
20
+ ].to_set.freeze
21
+ private_constant :FEATURE_IDS
22
+
23
+ # Quality feature triples: [feature_id, predicate, weight].
14
24
  QUALITY_RULES = [
15
25
  [:title_word_count_ge3, ->(o) { o.title_word_count >= 3 }, 40],
16
26
  [:title_word_count_ge7, ->(o) { o.title_word_count >= 7 }, 15],
@@ -22,7 +32,7 @@ module Html2rss
22
32
  [:content_tokens, lambda(&:content_tokens), 10]
23
33
  ].freeze
24
34
 
25
- # Junk feature triples: [FeatureId, predicate, weight].
35
+ # Junk feature triples: [feature_id, predicate, weight].
26
36
  JUNK_RULES = [
27
37
  [:non_content_utility_path, ->(o) { o.utility_path && !o.content_path && !o.strong_post_suffix }, 25],
28
38
  [:utility_prefix_title_short, ->(o) { o.utility_prefix_title && o.title_word_count <= 6 }, 15],
@@ -33,7 +43,17 @@ module Html2rss
33
43
  [:junk_tokens, lambda(&:junk_tokens), 15]
34
44
  ].freeze
35
45
 
36
- (QUALITY_RULES + JUNK_RULES).each { |feature_id, _, _| FeatureId.assert!(feature_id) }
46
+ ##
47
+ # @param id [Symbol]
48
+ # @return [Symbol]
49
+ # @raise [ArgumentError] when id is not in the closed set
50
+ def self.assert_feature_id!(id)
51
+ raise ArgumentError, "unknown feature: #{id.inspect}" unless FEATURE_IDS.include?(id)
52
+
53
+ id
54
+ end
55
+
56
+ (QUALITY_RULES + JUNK_RULES).each { |feature_id, _, _| assert_feature_id!(feature_id) }
37
57
 
38
58
  # @param link_resolver [LinkResolver]
39
59
  def initialize(link_resolver:)
@@ -78,9 +98,9 @@ module Html2rss
78
98
 
79
99
  quality, quality_parts = apply_rules(QUALITY_RULES, obs)
80
100
  junk, junk_parts = apply_rules(JUNK_RULES, obs)
81
- RankedSegment.build(
101
+ ranked(
82
102
  segment:,
83
- score: Score.build(
103
+ score: build_score(
84
104
  composite: quality - junk,
85
105
  quality:,
86
106
  junk:,
@@ -96,6 +116,30 @@ module Html2rss
96
116
  end
97
117
  [parts.values.sum, parts]
98
118
  end
119
+
120
+ def build_score(composite:, quality: nil, junk: nil, breakdown: nil)
121
+ raise ArgumentError, 'composite must be Numeric' unless composite.is_a?(Numeric)
122
+
123
+ Score.new(
124
+ composite: composite.to_f,
125
+ quality: (quality.nil? ? composite : quality).to_f,
126
+ junk: (junk || 0).to_f,
127
+ breakdown: normalize_breakdown(breakdown)
128
+ )
129
+ end
130
+
131
+ def normalize_breakdown(breakdown)
132
+ return Score::EMPTY_BREAKDOWN if breakdown.nil?
133
+
134
+ breakdown.transform_keys { |id| self.class.assert_feature_id!(id) }.freeze
135
+ end
136
+
137
+ def ranked(segment:, score:)
138
+ raise ArgumentError, 'segment must be AutoSource::Segment' unless segment.is_a?(Html2rss::AutoSource::Segment)
139
+ raise ArgumentError, 'score must be Scoring::Score' unless score.is_a?(Score)
140
+
141
+ RankedSegment.new(segment:, score:)
142
+ end
99
143
  end
100
144
  end
101
145
  end
@@ -8,6 +8,18 @@ module Html2rss
8
8
  # Captures the href portion before a fragment for memoization keys.
9
9
  HREF_BASE_PATTERN = /\A([^#]*)/
10
10
 
11
+ # Matches content-like tokens in class/id strings (from PathClassifier vocabulary).
12
+ CONTENT_TOKEN_REGEXP = begin
13
+ words = LinkDestination::PathClassifier::SEGMENT_SETS.fetch(:content)
14
+ /(?:^|\s|[-_])(#{Regexp.union(words.to_a).source})(?:\s|[-_]|$)/i
15
+ end.freeze
16
+
17
+ # Matches utility/junk tokens in class/id strings (from PathClassifier vocabulary).
18
+ JUNK_TOKEN_REGEXP = begin
19
+ words = LinkDestination::PathClassifier::SEGMENT_SETS.fetch(:utility)
20
+ /(?:^|\s|[-_])(#{Regexp.union(words.to_a).source})(?:\s|[-_]|$)/i
21
+ end.freeze
22
+
11
23
  # @param base_url [String, Html2rss::Url]
12
24
  def initialize(base_url)
13
25
  @base_url = base_url