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
@@ -0,0 +1,239 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'httpx'
4
+ require 'httpx/plugins/follow_redirects'
5
+ require 'httpx/plugins/callbacks'
6
+ require 'httpx/plugins/ssrf_filter'
7
+ require 'httpx/plugins/brotli'
8
+ require 'httpx/plugins/retries'
9
+
10
+ module Html2rss
11
+ class RequestService
12
+ ##
13
+ # Strategy to use HTTPX for HTTP requests.
14
+ # Provides native HTTP/2 with ALPN, built-in SSRF protection, streaming byte limits,
15
+ # and redirect handling without monkey-patching.
16
+ # rubocop:disable-next Metrics/ClassLength -- terminal redirect retry colocated with HTTPX transport
17
+ class HttpxStrategy < Strategy
18
+ # HTTPX default; kept explicit so ping reconnects cannot omit a bound.
19
+ MAX_RETRIES = 3
20
+ # Forces the selector timer retry path. Unset retry_after recurses in fetch_response;
21
+ # combined with ping! on a reused closed selector that never decrements retries,
22
+ # that overflows the stack and kills Falcon workers.
23
+ RETRY_AFTER_SECONDS = 0.25
24
+ # Bounds passed to the retries plugin and every +session.with+.
25
+ # HTTPX retries stay inside one {RequestService::Budget} slot and the shared
26
+ # wall-clock deadline; do not consume a slot per attempt.
27
+ # Do not reload +:fiber_concurrency+: a reused closed selector made +ping!+
28
+ # skip decrementing retries and recurse until +SystemStackError+.
29
+ #
30
+ # @return [Hash{Symbol => Integer, Float}]
31
+ RETRY_OPTIONS = { max_retries: MAX_RETRIES, retry_after: RETRY_AFTER_SECONDS }.freeze
32
+
33
+ class << self
34
+ # rubocop:disable ThreadSafety/ClassInstanceVariable
35
+ # @return [HTTPX::Session]
36
+ def base_session
37
+ @base_session ||= HTTPX
38
+ .plugin(:follow_redirects)
39
+ .plugin(:callbacks)
40
+ .plugin(:brotli)
41
+ .plugin(:retries, **RETRY_OPTIONS)
42
+ end
43
+
44
+ # @return [HTTPX::Session]
45
+ def base_ssrf_session
46
+ @base_ssrf_session ||= base_session.plugin(:ssrf_filter)
47
+ end
48
+ # rubocop:enable ThreadSafety/ClassInstanceVariable
49
+ end
50
+
51
+ # Executes the request with runtime policy enforcement, returning the normalized response.
52
+ #
53
+ # @return [Response] normalized response
54
+ def perform_execute
55
+ deadline = request_deadline
56
+ reset_redirect_tracking!
57
+ raw_response = request_with_terminal_redirect_retry(deadline:)
58
+ build_response(raw_response)
59
+ end
60
+
61
+ private
62
+
63
+ def request_deadline
64
+ monotonic_now + ctx.budget.effective_timeout_seconds(fallback: ctx.policy.total_timeout_seconds)
65
+ end
66
+
67
+ def reset_redirect_tracking!
68
+ @last_redirect_to = nil
69
+ @terminal_redirect_retried = false
70
+ @request_url_override = nil
71
+ @current_url = request_url
72
+ end
73
+
74
+ def request_with_terminal_redirect_retry(deadline:)
75
+ raw_response = execute_http_request(deadline:)
76
+ return raw_response unless redirect_limit_reached?(raw_response)
77
+
78
+ unless terminal_redirect_retryable?(raw_response)
79
+ raise RedirectLimitReached, "Too many redirects (status #{raw_response.status})"
80
+ end
81
+
82
+ retry_from_terminal_redirect!(raw_response, deadline:)
83
+ end
84
+
85
+ def redirect_limit_reached?(response)
86
+ return false unless response.is_a?(HTTPX::Response)
87
+ return false if response.status == 304
88
+ return false unless (300..399).cover?(response.status)
89
+
90
+ location = response.headers['location']
91
+ !location.to_s.strip.empty?
92
+ end
93
+
94
+ def terminal_redirect_url(raw_response)
95
+ location = raw_response.headers['location']
96
+ if location && !location.empty?
97
+ base = raw_response.uri || request_url
98
+ return normalize_url(URI.join(base.to_s, location))
99
+ end
100
+
101
+ @last_redirect_to
102
+ end
103
+
104
+ def terminal_redirect_retryable?(raw_response)
105
+ return false if @terminal_redirect_retried
106
+
107
+ target = terminal_redirect_url(raw_response)
108
+ target && target.to_s != request_url.to_s
109
+ end
110
+
111
+ def retry_from_terminal_redirect!(raw_response, deadline:)
112
+ terminal_url = terminal_redirect_url(raw_response)
113
+ @terminal_redirect_retried = true
114
+ Log.debug("#{self.class}: redirect limit reached; retrying once from #{terminal_url}")
115
+ begin_terminal_url_request!(terminal_url)
116
+ new_response = execute_http_request(deadline:, consume_budget: false)
117
+ if redirect_limit_reached?(new_response)
118
+ raise RedirectLimitReached, "Too many redirects (status #{new_response.status})"
119
+ end
120
+
121
+ new_response
122
+ end
123
+
124
+ def begin_terminal_url_request!(terminal_url)
125
+ ctx.policy.validate_request!(url: terminal_url, origin_url: ctx.origin_url, relation: ctx.relation)
126
+ @request_url_override = terminal_url
127
+ @last_redirect_to = nil
128
+ @current_url = terminal_url
129
+ end
130
+
131
+ def request_url
132
+ @request_url_override || ctx.url
133
+ end
134
+
135
+ def execute_http_request(deadline:, consume_budget: true)
136
+ preflight!(consume_budget:)
137
+ session = session_client(deadline)
138
+ response = http_get(session)
139
+ raise response.error if response.is_a?(HTTPX::ErrorResponse)
140
+
141
+ response
142
+ end
143
+
144
+ # GET only. Overflow here is HTTPX retry recursion; the same error from
145
+ # redirect handling or body decode must stay loud.
146
+ def http_get(session)
147
+ session.get(request_url.to_s, headers: ctx.headers)
148
+ rescue SystemStackError
149
+ raise HTTPX::ConnectionError, 'HTTPX retry recursion exhausted'
150
+ end
151
+
152
+ def session_client(deadline)
153
+ session = ctx.policy.allow_private_networks? ? self.class.base_session : self.class.base_ssrf_session
154
+ session.with(session_options(deadline))
155
+ end
156
+
157
+ def session_options(deadline)
158
+ remaining = remaining_timeout_seconds(deadline)
159
+ {
160
+ timeout: session_timeouts(remaining),
161
+ max_redirects: ctx.policy.max_redirects,
162
+ follow_insecure_redirects: false,
163
+ max_response_body_size: ctx.policy.max_response_bytes,
164
+ resolver_class: :system,
165
+ redirect_on: redirect_callback,
166
+ **RETRY_OPTIONS
167
+ }
168
+ end
169
+
170
+ def session_timeouts(remaining)
171
+ {
172
+ connect_timeout: [ctx.policy.connect_timeout_seconds, remaining].min,
173
+ read_timeout: [ctx.policy.read_timeout_seconds, remaining].min,
174
+ operation_timeout: remaining,
175
+ request_timeout: remaining,
176
+ total_request_timeout: remaining
177
+ }
178
+ end
179
+
180
+ def redirect_callback
181
+ lambda do |redirect_uri|
182
+ to_url = normalize_url(redirect_uri)
183
+ from_url = @current_url
184
+ @last_redirect_to = to_url
185
+ ctx.policy.validate_redirect!(from_url:, to_url:, origin_url: ctx.origin_url, relation: ctx.relation)
186
+ @current_url = to_url
187
+ true
188
+ end
189
+ end
190
+
191
+ def build_response(response)
192
+ headers = response.headers.to_h
193
+ Response.new(
194
+ body: CompressedBody.decode(response.body.to_s, headers:),
195
+ headers:,
196
+ url: response_url(response),
197
+ status: response.status
198
+ )
199
+ end
200
+
201
+ def response_url(response)
202
+ return ctx.url unless (uri = response.uri)
203
+
204
+ Html2rss::Url.from_absolute(uri.to_s)
205
+ end
206
+
207
+ def normalize_url(url)
208
+ Html2rss::Url.from_absolute(url.to_s)
209
+ end
210
+
211
+ def remaining_timeout_seconds(deadline)
212
+ remaining = deadline - monotonic_now
213
+ raise RequestTimedOut, 'Request timed out' if remaining <= 0
214
+
215
+ remaining
216
+ end
217
+
218
+ def monotonic_now
219
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
220
+ end
221
+
222
+ def timeout_error?(error)
223
+ error.is_a?(HTTPX::TimeoutError) || super
224
+ end
225
+
226
+ def ssrf_error?(error)
227
+ error.is_a?(HTTPX::ServerSideRequestForgeryError) || super
228
+ end
229
+
230
+ def response_too_large_error?(error)
231
+ (error.is_a?(HTTPX::Error) && error.message.include?('maximum response body size exceeded')) || super
232
+ end
233
+
234
+ def connection_error?(error)
235
+ error.is_a?(HTTPX::ConnectionError) || error.is_a?(HTTPX::TLSError) || super
236
+ end
237
+ end
238
+ end
239
+ end
@@ -13,7 +13,7 @@ module Html2rss
13
13
  # {Policy} so SSRF/origin rules stay single-homed on the public façade.
14
14
  class NetworkGuard
15
15
  # Hostnames treated as local/private surfaces.
16
- LOCAL_HOSTS = %w[localhost localhost.localdomain metadata.google.internal].to_set.freeze
16
+ LOCAL_HOSTS = Set['localhost', 'localhost.localdomain', 'metadata.google.internal'].freeze
17
17
  # IP ranges blocked when private networks are disabled.
18
18
  BLOCKED_IP_RANGES = [
19
19
  IPAddr.new('0.0.0.0/8'),
@@ -32,7 +32,9 @@ module Html2rss
32
32
 
33
33
  ##
34
34
  # @param allow_private_networks [Boolean] whether private network targets are allowed
35
- # @param resolver [#each_address, #getaddrinfo] DNS resolver used for hostname classification
35
+ # @param resolver [Socket, Resolv] DNS resolver used for hostname classification.
36
+ # +Socket+ (the default) uses +getaddrinfo+; any other resolver is Resolv-shaped
37
+ # and must implement +each_address+.
36
38
  def initialize(allow_private_networks:, resolver: Socket)
37
39
  @allow_private_networks = allow_private_networks ? true : false
38
40
  @resolver = resolver
@@ -89,10 +91,10 @@ module Html2rss
89
91
  literal = parse_ip(host)
90
92
  return [literal] if literal
91
93
 
92
- if resolver.respond_to?(:each_address)
93
- addresses_from_each_address(host)
94
- else
94
+ if resolver == Socket
95
95
  addresses_from_getaddrinfo(host)
96
+ else
97
+ addresses_from_each_address(host)
96
98
  end
97
99
  rescue Resolv::ResolvError, SocketError, SystemCallError
98
100
  []
@@ -36,8 +36,7 @@ module Html2rss
36
36
  captured_responses: EMPTY_CAPTURED_RESPONSES)
37
37
  @body = body
38
38
 
39
- headers = headers.dup
40
- headers.transform_keys!(&:to_s)
39
+ headers = (headers || {}).transform_keys(&:to_s)
41
40
  HashUtil.assert_string_keys!(headers, context: 'response headers', deep: false)
42
41
 
43
42
  @headers = headers
@@ -9,22 +9,6 @@ module Html2rss
9
9
  # @param policy [Policy] request policy that defines byte ceilings
10
10
  def initialize(policy:)
11
11
  @policy = policy
12
- @streamed_bytes = 0
13
- end
14
-
15
- ##
16
- # Validates response headers and streamed byte count.
17
- #
18
- # @param total_bytes [Integer] cumulative byte count received so far
19
- # @param headers [Hash, nil] response headers if known
20
- # @return [void]
21
- # @raise [ResponseTooLarge] if the response exceeds configured limits
22
- def inspect_chunk!(total_bytes:, headers: nil)
23
- header_length = headers&.fetch('content-length', headers&.fetch('Content-Length', nil))
24
- raise_if_too_large!(header_length.to_i, policy.max_response_bytes) if header_length
25
-
26
- @streamed_bytes = total_bytes
27
- raise_if_too_large!(@streamed_bytes, policy.max_response_bytes)
28
12
  end
29
13
 
30
14
  ##
@@ -22,7 +22,7 @@ module Html2rss
22
22
  def execute
23
23
  check_timeout!
24
24
  response = perform_execute
25
- postflight!(response, response_guard:)
25
+ postflight!(response)
26
26
  response
27
27
  rescue StandardError => error
28
28
  handle_error(error)
@@ -67,13 +67,11 @@ module Html2rss
67
67
  # Enforces response-size and blocked-surface checks after transport work.
68
68
  #
69
69
  # @param response [Response, nil] adapter response
70
- # @param response_guard [ResponseGuard, nil] existing guard to reuse (e.g. streaming)
71
70
  # @return [void]
72
- def postflight!(response, response_guard: nil)
71
+ def postflight!(response)
73
72
  return if skip_postflight? || response.nil?
74
73
 
75
- guard = response_guard || ResponseGuard.new(policy: ctx.policy)
76
- guard.inspect_body!(response.body)
74
+ ResponseGuard.new(policy: ctx.policy).inspect_body!(response.body)
77
75
  end
78
76
 
79
77
  ##
@@ -91,17 +89,19 @@ module Html2rss
91
89
  raise
92
90
  end
93
91
 
94
- # @return [ResponseGuard, nil]
95
- def response_guard = nil
96
-
97
92
  # @param error [StandardError]
98
93
  # @return [void]
99
94
  # @raise [StandardError]
95
+ # rubocop:disable-next Metrics/AbcSize, Metrics/MethodLength -- error translation dispatch
100
96
  def handle_error(error)
101
97
  if timeout_error?(error)
102
98
  log_timeout!(reason: 'transport')
103
99
  Log.debug("#{self.class}: transport timeout message=#{error.message}")
104
100
  raise RequestTimedOut, error.message
101
+ elsif ssrf_error?(error)
102
+ raise PrivateNetworkDenied, error.message
103
+ elsif response_too_large_error?(error)
104
+ raise ResponseTooLarge, "Response exceeded #{ctx.policy.max_response_bytes} bytes"
105
105
  elsif connection_error?(error)
106
106
  translate_connection_error(error)
107
107
  else
@@ -135,14 +135,25 @@ module Html2rss
135
135
  # @param error [StandardError]
136
136
  # @return [Boolean]
137
137
  def timeout_error?(error)
138
- error.is_a?(Faraday::TimeoutError) ||
139
- error.is_a?(Timeout::Error)
138
+ error.is_a?(Timeout::Error)
139
+ end
140
+
141
+ # @param _error [StandardError]
142
+ # @return [Boolean]
143
+ def ssrf_error?(_error)
144
+ false
145
+ end
146
+
147
+ # @param _error [StandardError]
148
+ # @return [Boolean]
149
+ def response_too_large_error?(_error)
150
+ false
140
151
  end
141
152
 
142
153
  # @param error [StandardError]
143
154
  # @return [Boolean]
144
155
  def connection_error?(error)
145
- error.is_a?(Faraday::ConnectionFailed) || error.is_a?(Faraday::SSLError)
156
+ error.is_a?(SocketError) || error.is_a?(SystemCallError)
146
157
  end
147
158
  end
148
159
  end
@@ -1,18 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'singleton'
4
- require 'forwardable'
5
-
6
3
  module Html2rss
7
4
  ##
8
5
  # Requests website URLs to retrieve their HTML for further processing.
9
- # Provides concrete transport strategies (e.g. Faraday, Botasaurus).
6
+ # Provides concrete transport strategies (e.g. HttpxStrategy, BotasaurusStrategy).
10
7
  #
11
8
  # Feed-level +:auto+ is not registered here — {FeedPipeline::StrategyPlan} resolves
12
9
  # it to a concrete strategy (or {FeedPipeline::AutoFallback} chain) before execute.
13
10
  class RequestService
14
- include Singleton
15
-
16
11
  # Raised when an unknown request strategy is requested.
17
12
  class UnknownStrategy < Html2rss::Error; end
18
13
  # Raised when a URL cannot be parsed or validated.
@@ -59,94 +54,60 @@ module Html2rss
59
54
  # Raised when Botasaurus responds but the scrape fails (upstream error, bad payload).
60
55
  class BotasaurusServiceError < Html2rss::Error; end
61
56
 
62
- class << self
63
- extend Forwardable
64
-
65
- %i[default_strategy_name
66
- default_strategy_name=
67
- strategy_names
68
- register_strategy
69
- unregister_strategy
70
- strategy_registered?
71
- execute].each do |method|
72
- def_delegator :instance, method
73
- end
74
- end
75
-
76
- def initialize
77
- @strategies = {
78
- faraday: FaradayStrategy,
79
- botasaurus: BotasaurusStrategy,
80
- local_file: LocalFileStrategy
81
- }
82
- @default_strategy_name = :faraday
83
- end
57
+ # Map of supported strategy names to their implementation classes.
58
+ # @return [Hash{Symbol => Class<Strategy>}]
59
+ STRATEGIES = {
60
+ default: HttpxStrategy,
61
+ httpx: HttpxStrategy,
62
+ faraday: HttpxStrategy,
63
+ botasaurus: BotasaurusStrategy,
64
+ local_file: LocalFileStrategy
65
+ }.freeze
84
66
 
85
- # @return [Symbol] the default strategy name
86
- attr_reader :default_strategy_name
67
+ # Canonical default strategy symbol.
68
+ # @return [Symbol]
69
+ DEFAULT_STRATEGY_NAME = :default
87
70
 
88
- ##
89
- # Sets the default strategy.
90
- # @param strategy [Symbol] the name of the strategy
91
- # @return [Symbol] the selected default strategy name
92
- # @raise [UnknownStrategy] if the strategy is not registered
93
- def default_strategy_name=(strategy)
94
- raise UnknownStrategy unless strategy_registered?(strategy)
95
-
96
- @default_strategy_name = strategy.to_sym
97
- end
71
+ class << self
72
+ # @return [Symbol] the default strategy name
73
+ def default_strategy_name = DEFAULT_STRATEGY_NAME
98
74
 
99
- # @return [Array<String>] the names of the registered strategies
100
- def strategy_names = @strategies.keys.map(&:to_s)
75
+ # @return [Array<String>] the names of the registered strategies
76
+ def strategy_names = STRATEGIES.keys.map(&:to_s)
101
77
 
102
- ##
103
- # Registers a new strategy.
104
- # @param name [Symbol] the name of the strategy
105
- # @param strategy_class [Class] the class implementing the strategy
106
- # @return [Class] the registered strategy class
107
- # @raise [ArgumentError] if strategy_class is not a Class
108
- def register_strategy(name, strategy_class)
109
- unless strategy_class.is_a?(Class)
110
- raise ArgumentError, "Expected a Class for strategy, got #{strategy_class.class}"
78
+ ##
79
+ # Checks if a strategy is registered.
80
+ # @param name [Symbol, String] the name of the strategy
81
+ # @return [Boolean] true if the strategy is registered, false otherwise.
82
+ def strategy_registered?(name)
83
+ STRATEGIES.key?(name.to_sym)
111
84
  end
112
85
 
113
- @strategies[name.to_sym] = strategy_class
114
- end
115
-
116
- ##
117
- # Checks if a strategy is registered.
118
- # @param name [Symbol] the name of the strategy
119
- # @return [Boolean] true if the strategy is registered, false otherwise.
120
- def strategy_registered?(name)
121
- @strategies.key?(name.to_sym)
122
- end
123
-
124
- ##
125
- # Unregisters a strategy.
126
- # @param name [Symbol] the name of the strategy
127
- # @return [Boolean] true if the strategy was unregistered, false otherwise.
128
- # @raise [ArgumentError] if attempting to unregister the default strategy.
129
- def unregister_strategy(name) # rubocop:disable Naming/PredicateMethod
130
- name_sym = name.to_sym
131
- raise ArgumentError, 'Cannot unregister the default strategy.' if name_sym == @default_strategy_name
86
+ ##
87
+ # Executes the request using the specified strategy.
88
+ # @param ctx [Context] the context for the request.
89
+ # @param strategy [Symbol, String] the strategy to use (defaults to the default strategy).
90
+ # @return [Response] the response from the executed strategy.
91
+ # @raise [UnknownStrategy] if the strategy is not registered.
92
+ def execute(ctx, strategy: default_strategy_name)
93
+ strategy_sym = strategy.to_sym
94
+ strategy_class = STRATEGIES.fetch(strategy_sym) do
95
+ raise UnknownStrategy,
96
+ "The strategy '#{strategy}' is not known. Available strategies: #{strategy_names.join(', ')}"
97
+ end
98
+
99
+ warn_migration_strategy(strategy_sym) if strategy_sym == :faraday
100
+ strategy_class.new(ctx).execute
101
+ end
132
102
 
133
- !!@strategies.delete(name_sym)
134
- end
103
+ private
135
104
 
136
- ##
137
- # Executes the request using the specified strategy.
138
- # @param ctx [Context] the context for the request.
139
- # @param strategy [Symbol] the strategy to use (defaults to the default strategy).
140
- # @return [Response] the response from the executed strategy.
141
- # @raise [ArgumentError] if the context is nil.
142
- # @raise [UnknownStrategy] if the strategy is not registered.
143
- def execute(ctx, strategy: default_strategy_name)
144
- strategy_class = @strategies.fetch(strategy.to_sym) do
145
- raise UnknownStrategy,
146
- "The strategy '#{strategy}' is not known. Available strategies: #{strategy_names.join(', ')}"
105
+ def warn_migration_strategy(strategy)
106
+ message = "RequestService: strategy ':#{strategy}' is deprecated for migration and will be removed " \
107
+ "in a future release. Use ':default' instead."
108
+ warn(message, category: :deprecated)
109
+ Log.warn(message)
147
110
  end
148
-
149
- strategy_class.new(ctx).execute
150
111
  end
151
112
  end
152
113
  end
@@ -70,7 +70,9 @@ module Html2rss
70
70
  end
71
71
 
72
72
  def word_count(text)
73
- text.to_s.scan(/\p{Alnum}+/).size
73
+ count = 0
74
+ text.to_s.scan(/\p{Alnum}+/) { count += 1 }
75
+ count
74
76
  end
75
77
  end
76
78
  end
@@ -10,14 +10,14 @@ module Html2rss
10
10
  # Minimum composite score retained by {#rank_top} (precision floor).
11
11
  SCORE_FLOOR = 0.0
12
12
 
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
13
+ FEATURE_IDS = Set[
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
+ ].freeze
21
21
  private_constant :FEATURE_IDS
22
22
 
23
23
  # Quality feature triples: [feature_id, predicate, weight].
@@ -2,8 +2,6 @@
2
2
 
3
3
  module Html2rss
4
4
  module Scoring
5
- ##
6
- # Typed container signals produced by {ContainerAssessor} for {Engine} rules.
7
5
  Observation = Data.define(
8
6
  :title_word_count,
9
7
  :path_length,
@@ -21,7 +19,11 @@ module Html2rss
21
19
  :high_confidence_junk_path,
22
20
  :high_confidence_utility_destination,
23
21
  :selected_anchor_present
24
- ) do
22
+ )
23
+
24
+ ##
25
+ # Typed container signals produced by {ContainerAssessor} for {Engine} rules.
26
+ class Observation
25
27
  ##
26
28
  # @return [Boolean] true when fewer than two positive content signals are present
27
29
  def weak_signals?
@@ -2,9 +2,11 @@
2
2
 
3
3
  module Html2rss
4
4
  module Scoring
5
+ RankedSegment = Data.define(:segment, :score)
6
+
5
7
  ##
6
8
  # A segment paired with its composite score.
7
- RankedSegment = Data.define(:segment, :score) do
9
+ class RankedSegment
8
10
  ##
9
11
  # @return [SST::Node]
10
12
  def root_node = segment.root_node
@@ -1,11 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Html2rss
4
+ ScrapeTarget = Data.define(:entry_url, :effective_url)
5
+
4
6
  ##
5
7
  # Immutable entry vs effective scrape URLs for one pipeline run.
6
8
  #
7
9
  # Replaces mutating +Config#scrape_url=+ after {FeedResolution} rewrites the fetch URL.
8
- ScrapeTarget = Data.define(:entry_url, :effective_url) do
10
+ class ScrapeTarget
9
11
  ##
10
12
  # @param config [Html2rss::Config]
11
13
  # @return [ScrapeTarget]