html2rss 0.32.4 → 0.33.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 (105) hide show
  1. checksums.yaml +4 -4
  2. data/lib/html2rss/article/enclosure.rb +2 -6
  3. data/lib/html2rss/auto_source/cleanup.rb +1 -3
  4. data/lib/html2rss/auto_source/scraper/html.rb +2 -8
  5. data/lib/html2rss/auto_source/scraper/json_document.rb +2 -3
  6. data/lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb +128 -128
  7. data/lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb +37 -37
  8. data/lib/html2rss/auto_source/scraper/json_state/document_scanner.rb +123 -123
  9. data/lib/html2rss/auto_source/scraper/json_state/value_finder.rb +34 -34
  10. data/lib/html2rss/auto_source/scraper/json_state.rb +2 -6
  11. data/lib/html2rss/auto_source/scraper/meta_oembed.rb +32 -2
  12. data/lib/html2rss/auto_source/scraper/microdata.rb +228 -245
  13. data/lib/html2rss/auto_source/scraper/microformats2.rb +9 -1
  14. data/lib/html2rss/auto_source/scraper/native_feed.rb +1 -1
  15. data/lib/html2rss/auto_source/scraper/schema/category_extractor.rb +7 -2
  16. data/lib/html2rss/auto_source/scraper/schema/item_list.rb +2 -2
  17. data/lib/html2rss/auto_source/scraper/schema/thing.rb +98 -21
  18. data/lib/html2rss/auto_source/scraper/schema.rb +49 -2
  19. data/lib/html2rss/auto_source/scraper/semantic_html.rb +12 -16
  20. data/lib/html2rss/auto_source/scraper/sitemap/parser.rb +1 -4
  21. data/lib/html2rss/auto_source/scraper/sitemap.rb +3 -11
  22. data/lib/html2rss/auto_source/scraper/wordpress_api/page_scope.rb +2 -2
  23. data/lib/html2rss/auto_source/scraper/wordpress_api/posts_endpoint.rb +1 -3
  24. data/lib/html2rss/auto_source/scraper/wordpress_api.rb +4 -4
  25. data/lib/html2rss/auto_source/scraper/xhr_articles.rb +1 -1
  26. data/lib/html2rss/auto_source/scraper.rb +0 -11
  27. data/lib/html2rss/auto_source/segmenter/cluster.rb +5 -11
  28. data/lib/html2rss/auto_source/segmenter/list.rb +25 -57
  29. data/lib/html2rss/auto_source/segmenter/semantic.rb +6 -13
  30. data/lib/html2rss/auto_source.rb +1 -1
  31. data/lib/html2rss/batch.rb +22 -32
  32. data/lib/html2rss/capture/README.md +1 -1
  33. data/lib/html2rss/capture.rb +7 -27
  34. data/lib/html2rss/cli/render.rb +97 -96
  35. data/lib/html2rss/cli/validate.rb +2 -4
  36. data/lib/html2rss/cli.rb +33 -27
  37. data/lib/html2rss/config/request_controls.rb +10 -2
  38. data/lib/html2rss/config/schema.rb +8 -10
  39. data/lib/html2rss/config/selectors_validator.rb +1 -0
  40. data/lib/html2rss/config/validator.rb +3 -3
  41. data/lib/html2rss/config.rb +1 -1
  42. data/lib/html2rss/defaults.rb +5 -16
  43. data/lib/html2rss/doctor/botasaurus.rb +11 -19
  44. data/lib/html2rss/error.rb +7 -3
  45. data/lib/html2rss/feed_builder/rss.rb +1 -1
  46. data/lib/html2rss/feed_pipeline/auto_fallback.rb +3 -3
  47. data/lib/html2rss/feed_resolution/candidate_generator.rb +3 -4
  48. data/lib/html2rss/feed_resolution/options.rb +3 -7
  49. data/lib/html2rss/feed_resolution/policy.rb +29 -33
  50. data/lib/html2rss/feed_resolution/probe.rb +3 -3
  51. data/lib/html2rss/feed_resolution/scorer.rb +29 -29
  52. data/lib/html2rss/feed_resolution.rb +5 -9
  53. data/lib/html2rss/feed_result.rb +4 -3
  54. data/lib/html2rss/hash_util.rb +91 -90
  55. data/lib/html2rss/html/article_extractor/category_extractor.rb +6 -9
  56. data/lib/html2rss/html/article_extractor/date_extractor.rb +1 -1
  57. data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +1 -3
  58. data/lib/html2rss/html/article_extractor/id_generator.rb +1 -1
  59. data/lib/html2rss/html/probe.rb +51 -51
  60. data/lib/html2rss/link_destination/path_classifier.rb +11 -33
  61. data/lib/html2rss/mcp/config_argument.rb +9 -2
  62. data/lib/html2rss/mcp/contract.rb +7 -5
  63. data/lib/html2rss/mcp/outcome.rb +4 -5
  64. data/lib/html2rss/mcp/runtime.rb +17 -24
  65. data/lib/html2rss/mcp/server/tools.rb +9 -9
  66. data/lib/html2rss/option.rb +48 -0
  67. data/lib/html2rss/page_recon/diagnostics.rb +7 -9
  68. data/lib/html2rss/page_recon.rb +0 -4
  69. data/lib/html2rss/recon.rb +10 -12
  70. data/lib/html2rss/request_service/blocked_surface.rb +3 -7
  71. data/lib/html2rss/request_service/botasaurus_strategy.rb +2 -2
  72. data/lib/html2rss/request_service/budget.rb +3 -24
  73. data/lib/html2rss/request_service/compressed_body.rb +36 -48
  74. data/lib/html2rss/request_service/context.rb +1 -3
  75. data/lib/html2rss/request_service/local_file_strategy.rb +0 -1
  76. data/lib/html2rss/request_service/network_guard.rb +3 -2
  77. data/lib/html2rss/request_service/policy.rb +8 -26
  78. data/lib/html2rss/request_service/response.rb +3 -6
  79. data/lib/html2rss/request_session/pager/base.rb +3 -3
  80. data/lib/html2rss/request_session/pager/custom_selector.rb +4 -4
  81. data/lib/html2rss/request_session/pager/offset.rb +1 -7
  82. data/lib/html2rss/request_session/pager/rel_next.rb +1 -1
  83. data/lib/html2rss/request_session/pager/url_template.rb +1 -7
  84. data/lib/html2rss/scoring/engine.rb +4 -32
  85. data/lib/html2rss/scoring/link_resolver.rb +2 -7
  86. data/lib/html2rss/selectors/extractors/href.rb +2 -4
  87. data/lib/html2rss/selectors/json_xml.rb +33 -34
  88. data/lib/html2rss/selectors/post_processors/gsub.rb +2 -3
  89. data/lib/html2rss/selectors/post_processors/parse_time.rb +4 -5
  90. data/lib/html2rss/selectors/schema_export.rb +60 -60
  91. data/lib/html2rss/selectors.rb +14 -18
  92. data/lib/html2rss/sst/node.rb +9 -3
  93. data/lib/html2rss/sst/normalizer.rb +25 -24
  94. data/lib/html2rss/sst/text.rb +53 -57
  95. data/lib/html2rss/status.rb +23 -30
  96. data/lib/html2rss/syndication/alternate_links.rb +39 -0
  97. data/lib/html2rss/syndication/discovery.rb +117 -187
  98. data/lib/html2rss/syndication/parser.rb +92 -115
  99. data/lib/html2rss/test/enhance_audit.rb +13 -15
  100. data/lib/html2rss/test/policy.rb +9 -11
  101. data/lib/html2rss/test.rb +29 -48
  102. data/lib/html2rss/url.rb +53 -53
  103. data/lib/html2rss/version.rb +1 -1
  104. data/lib/html2rss.rb +2 -1
  105. metadata +4 -2
@@ -9,8 +9,6 @@ module Html2rss
9
9
  # Service that runs reconnaissance on a URL to discover redirect chains,
10
10
  # native RSS/Atom feeds, surface categorization, and emit an actionable verdict.
11
11
  module Recon # rubocop:disable Metrics/ModuleLength
12
- module_function
13
-
14
12
  Verdict = Data.define(:name)
15
13
 
16
14
  ##
@@ -106,12 +104,12 @@ module Html2rss
106
104
  # @option options [Integer, nil] :max_redirects optional maximum redirects
107
105
  # @option options [Integer, nil] :max_requests optional request budget
108
106
  # @return [Html2rss::Recon::Result]
109
- def call(url, strategy: :auto, cache_dir: nil, cache_mutex: nil, **) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
107
+ def self.call(url, strategy: :auto, cache_dir: nil, cache_mutex: nil, **) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
110
108
  url_obj = Url.from_absolute(url)
111
109
 
112
110
  begin
113
111
  probe = PageRecon.probe(url_obj, strategy:, **)
114
- rescue StandardError => error
112
+ rescue Html2rss::Error => error
115
113
  return error_result(url_obj, error)
116
114
  end
117
115
 
@@ -149,7 +147,7 @@ module Html2rss
149
147
  # @option options [Integer, nil] :max_redirects optional maximum redirects
150
148
  # @option options [Integer, nil] :max_requests optional request budget
151
149
  # @return [Array<Html2rss::Recon::Result>]
152
- def batch(urls, strategy: :auto, cache_dir: nil, max_threads: 5, **options)
150
+ def self.batch(urls, strategy: :auto, cache_dir: nil, max_threads: 5, **options)
153
151
  url_list = urls.map { |u| u.to_s.strip }.reject(&:empty?)
154
152
  return [] if url_list.empty?
155
153
 
@@ -166,7 +164,7 @@ module Html2rss
166
164
  # @param session [Html2rss::RequestSession]
167
165
  # @param response [Html2rss::RequestService::Response]
168
166
  # @return [Array(Html2rss::Url, nil), String, nil)] feed URL and optional discovery error note
169
- def find_native_feed(url_obj, session, response)
167
+ def self.find_native_feed(url_obj, session, response)
170
168
  feed = Syndication::Discovery.best_feed_url(
171
169
  page_url: url_obj,
172
170
  request_session: session,
@@ -174,12 +172,12 @@ module Html2rss
174
172
  html: response.body
175
173
  )
176
174
  [feed, nil]
177
- rescue StandardError => error
175
+ rescue Html2rss::Error => error
178
176
  [nil, "discovery_error=#{error.class}: #{error.message}"]
179
177
  end
180
178
  private_class_method :find_native_feed
181
179
 
182
- def build_notes(recon, native_feed, response)
180
+ def self.build_notes(recon, native_feed, response)
183
181
  notes = []
184
182
  notes << "native_rss=#{native_feed}" if native_feed
185
183
  if recon.scheme_downgrade
@@ -192,7 +190,7 @@ module Html2rss
192
190
  end
193
191
  private_class_method :build_notes
194
192
 
195
- def determine_verdict(recon, native_feed)
193
+ def self.determine_verdict(recon, native_feed)
196
194
  return Verdict[:drop] if recon.status.nil? || recon.status >= 400 || recon.scheme_downgrade
197
195
  return Verdict[:defer] if native_feed
198
196
 
@@ -203,7 +201,7 @@ module Html2rss
203
201
  end
204
202
  private_class_method :determine_verdict
205
203
 
206
- def error_result(url_obj, error) # rubocop:disable Metrics/MethodLength
204
+ def self.error_result(url_obj, error) # rubocop:disable Metrics/MethodLength
207
205
  Result.new(
208
206
  requested_url: url_obj,
209
207
  final_url: url_obj,
@@ -226,14 +224,14 @@ module Html2rss
226
224
  # @param cache_dir [String]
227
225
  # @param cache_mutex [Mutex, nil]
228
226
  # @return [void]
229
- def cache_html_body(body, url_obj, cache_dir, cache_mutex)
227
+ def self.cache_html_body(body, url_obj, cache_dir, cache_mutex)
230
228
  host = url_obj.host.to_s.delete_prefix('www.')
231
229
  host = 'snapshot' if host.empty?
232
230
  digest = Digest::SHA256.hexdigest(url_obj.to_s)[0, 12]
233
231
  file_path = File.join(cache_dir, "#{host}-#{digest}.html")
234
232
  writer = -> { File.write(file_path, body) }
235
233
  cache_mutex ? cache_mutex.synchronize(&writer) : writer.call
236
- rescue StandardError => error
234
+ rescue SystemCallError, IOError => error
237
235
  Log.debug("Recon cache failed: #{error.message}")
238
236
  end
239
237
  private_class_method :cache_html_body
@@ -79,10 +79,10 @@ module Html2rss
79
79
  end
80
80
 
81
81
  def self.interstitial_signature_match?(body, signature)
82
- min_matches = signature.fetch(:min_matches, 1)
82
+ min_matches = signature[:min_matches]
83
83
  matches = 0
84
84
 
85
- signature.fetch(:patterns).each do |pattern|
85
+ signature[:patterns].each do |pattern|
86
86
  matches += 1 if pattern.match?(body)
87
87
  return true if matches >= min_matches
88
88
  end
@@ -91,11 +91,7 @@ module Html2rss
91
91
  end
92
92
  private_class_method :interstitial_signature_match?
93
93
 
94
- def self.normalize_body(body)
95
- body.to_s.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: '')
96
- rescue Encoding::CompatibilityError, Encoding::UndefinedConversionError, Encoding::InvalidByteSequenceError
97
- body.to_s.force_encoding(Encoding::UTF_8).scrub
98
- end
94
+ def self.normalize_body(body) = body.to_s.scrub
99
95
  private_class_method :normalize_body
100
96
  end
101
97
  end
@@ -65,7 +65,7 @@ module Html2rss
65
65
  return ctx.url if final_url.nil?
66
66
 
67
67
  Html2rss::Url.from_absolute(final_url)
68
- rescue ArgumentError
68
+ rescue Html2rss::Url::InvalidUrlError
69
69
  ctx.url
70
70
  end
71
71
 
@@ -117,7 +117,7 @@ module Html2rss
117
117
  raise BotasaurusConfigurationError, 'BOTASAURUS_SCRAPER_URL is required for strategy=botasaurus.'
118
118
  end
119
119
  Html2rss::Url.for_channel(configured)
120
- rescue ArgumentError => error
120
+ rescue Html2rss::Url::InvalidUrlError => error
121
121
  raise BotasaurusConfigurationError, "BOTASAURUS_SCRAPER_URL is invalid: #{error.message}"
122
122
  end
123
123
  end
@@ -11,7 +11,7 @@ module Html2rss
11
11
  # @param max_requests [Integer] maximum HTTP request slots
12
12
  # @param total_timeout_seconds [Integer, nil] wall-clock timeout for the feed build
13
13
  def initialize(max_requests:, total_timeout_seconds: nil)
14
- validate_slot_limits!(max_requests:)
14
+ Option.integer(:max_requests, max_requests, min: 1)
15
15
 
16
16
  @remaining_requests = max_requests
17
17
  @start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
@@ -25,33 +25,20 @@ module Html2rss
25
25
  # @return [Integer] remaining request slots after consumption
26
26
  # @raise [RequestBudgetExceeded] if no request slots remain
27
27
  def consume!
28
- consume_request!
29
- end
30
-
31
- ##
32
- # Consumes one HTTP request slot.
33
- #
34
- # @return [Integer] remaining request slots after consumption
35
- # @raise [RequestBudgetExceeded] if no request slots remain
36
- def consume_request!
37
28
  @mutex.synchronize do
38
29
  raise RequestBudgetExceeded, 'Request budget exhausted' if @remaining_requests.zero?
39
30
 
40
31
  @remaining_requests -= 1
41
32
  end
42
33
  end
43
-
44
- ##
45
- # @return [Integer] HTTP request slots still available
46
- def remaining
47
- remaining_requests
48
- end
34
+ alias consume_request! consume!
49
35
 
50
36
  ##
51
37
  # @return [Integer] HTTP request slots still available
52
38
  def remaining_requests
53
39
  @mutex.synchronize { @remaining_requests }
54
40
  end
41
+ alias remaining remaining_requests
55
42
 
56
43
  ##
57
44
  # @return [Float, nil] the remaining timeout in seconds, or nil if not tracked
@@ -91,14 +78,6 @@ module Html2rss
91
78
  def effective_timeout_ms(fallback:)
92
79
  (effective_timeout_seconds(fallback:) * 1000).to_i
93
80
  end
94
-
95
- private
96
-
97
- def validate_slot_limits!(max_requests:)
98
- return if max_requests.is_a?(Integer) && max_requests.positive?
99
-
100
- raise ArgumentError, 'max_requests must be positive'
101
- end
102
81
  end
103
82
  end
104
83
  end
@@ -7,56 +7,58 @@ require 'zlib'
7
7
  module Html2rss
8
8
  class RequestService
9
9
  # Decodes gzip/brotli bodies when Content-Encoding is missing or the type is octet-stream.
10
- module CompressedBody
10
+ class CompressedBody
11
11
  # Gzip stream magic (RFC 1952).
12
12
  GZIP_MAGIC = "\x1F\x8B".b.freeze
13
13
  # HTML prefix for unlabeled brotli trial decode. Same object as {Response::HTML_BODY_SNIFF}.
14
14
  HTML_BODY_SNIFF = Response::HTML_BODY_SNIFF
15
15
 
16
- module_function
17
-
18
16
  # @param body [String, nil] raw response body
19
17
  # @param headers [Hash] response headers (Content-Encoding / Content-Type)
20
18
  # @return [String, nil] decoded body when compressed; otherwise the original body
21
- def decode(body, headers: {})
22
- return body if body.nil? || body.empty?
23
-
19
+ def self.decode(body, headers: {})
24
20
  raw = body.to_s.b
25
- decode_labeled(raw, headers) || decode_unlabeled(raw, headers) || body
21
+ new(raw, headers).decoded_body || body
22
+ end
23
+
24
+ # @param raw [String] binary response body
25
+ # @param headers [Hash] response headers (Content-Encoding / Content-Type)
26
+ def initialize(raw, headers)
27
+ @raw = raw
28
+ @headers = headers
26
29
  end
30
+ private_class_method :new
27
31
 
28
- def decode_labeled(raw, headers)
29
- encodings = content_encodings(headers)
32
+ # @return [String, nil] inflated body, or nil when the bytes are not a recognized stream
33
+ def decoded_body
34
+ decode_labeled || decode_unlabeled
35
+ end
36
+
37
+ private
38
+
39
+ def decode_labeled
40
+ encodings = content_encodings
30
41
  return if encodings.empty?
31
42
 
32
- encodings.reverse.reduce(raw) { |buffer, encoding| inflate(buffer, encoding) || buffer }
43
+ encodings.reverse.reduce(@raw) { |buffer, encoding| inflate(buffer, encoding) || buffer }
33
44
  end
34
- module_function :decode_labeled
35
- private_class_method :decode_labeled
36
45
 
37
- def decode_unlabeled(raw, headers)
38
- return uncompress_gzip(raw) if raw.start_with?(GZIP_MAGIC)
39
- return unless octet_stream?(headers)
46
+ def decode_unlabeled
47
+ return uncompress_gzip(@raw) if @raw.start_with?(GZIP_MAGIC)
48
+ return unless octet_stream?
40
49
 
41
- try_brotli(raw)
50
+ try_brotli(@raw)
42
51
  end
43
- module_function :decode_unlabeled
44
- private_class_method :decode_unlabeled
45
52
 
46
53
  def inflate(raw, encoding)
47
54
  case encoding
48
- when 'gzip'
49
- return raw unless raw.start_with?(GZIP_MAGIC)
50
-
51
- uncompress_gzip(raw)
55
+ when 'gzip' then uncompress_gzip(raw)
52
56
  when 'br' then try_brotli(raw, require_html: false)
53
57
  when 'deflate' then inflate_deflate(raw)
54
58
  end
55
59
  rescue Zlib::Error, Brotli::Error, ArgumentError
56
60
  nil
57
61
  end
58
- module_function :inflate
59
- private_class_method :inflate
60
62
 
61
63
  def inflate_deflate(raw)
62
64
  inflater = nil
@@ -67,51 +69,37 @@ module Html2rss
67
69
  ensure
68
70
  inflater&.close
69
71
  end
70
- module_function :inflate_deflate
71
- private_class_method :inflate_deflate
72
72
 
73
73
  def uncompress_gzip(raw)
74
- Zlib::GzipReader.wrap(StringIO.new(raw), encoding: 'ASCII-8BIT', &:read)
74
+ Zlib::GzipReader.wrap(StringIO.new(raw), &:read).b
75
75
  rescue Zlib::Error
76
76
  nil
77
77
  end
78
- module_function :uncompress_gzip
79
- private_class_method :uncompress_gzip
80
78
 
81
79
  def try_brotli(raw, require_html: true)
82
80
  inflated = Brotli.inflate(raw)
83
81
  return inflated unless require_html
84
- return inflated if inflated.b.match?(HTML_BODY_SNIFF)
85
82
 
86
- nil
83
+ inflated.b.match?(HTML_BODY_SNIFF) ? inflated : nil
87
84
  rescue Brotli::Error, ArgumentError
88
85
  nil
89
86
  end
90
- module_function :try_brotli
91
- private_class_method :try_brotli
92
87
 
93
- def content_encodings(headers)
94
- header(headers, 'content-encoding')
88
+ def content_encodings
89
+ header('content-encoding')
95
90
  .to_s
96
91
  .split(',')
97
- .filter_map { |val| (s = val.strip.downcase).empty? ? nil : s }
92
+ .map { |val| val.strip.downcase }
93
+ .reject(&:empty?)
98
94
  end
99
- module_function :content_encodings
100
- private_class_method :content_encodings
101
95
 
102
- def octet_stream?(headers)
103
- header(headers, 'content-type').to_s.downcase.include?('application/octet-stream')
96
+ def octet_stream?
97
+ header('content-type').to_s.downcase.include?('application/octet-stream')
104
98
  end
105
- module_function :octet_stream?
106
- private_class_method :octet_stream?
107
99
 
108
- def header(headers, name)
109
- headers.fetch(name) do
110
- headers.find { |key, _value| key.to_s.casecmp?(name) }&.last
111
- end
100
+ def header(name)
101
+ @headers.find { |key, _value| key.to_s.casecmp?(name) }&.last
112
102
  end
113
- module_function :header
114
- private_class_method :header
115
103
  end
116
104
  end
117
105
  end
@@ -87,9 +87,7 @@ module Html2rss
87
87
  end
88
88
 
89
89
  def normalize_request(request)
90
- normalized = HashUtil.deep_symbolize_keys(request, context: 'request')
91
- HashUtil.assert_symbol_keys!(normalized, context: 'request')
92
- normalized
90
+ HashUtil.deep_symbolize_keys(request, context: 'request')
93
91
  end
94
92
  end
95
93
  end
@@ -25,7 +25,6 @@ module Html2rss
25
25
  def fetch
26
26
  file_path = ctx.request[:local_file_path]
27
27
  raise ArgumentError, 'Local file path is required for local_file strategy' unless file_path
28
- raise Errno::ENOENT, "File not found: #{file_path}" unless File.exist?(file_path)
29
28
 
30
29
  body = File.read(file_path)
31
30
  Response.new(
@@ -68,7 +68,7 @@ module Html2rss
68
68
  def validate_remote_ip!(ip:, url:)
69
69
  return if allow_private_networks?
70
70
 
71
- parsed_ip = ip && !ip.to_s.empty? ? parse_ip(ip) : nil
71
+ parsed_ip = parse_ip(ip.to_s)
72
72
  raise PrivateNetworkDenied, "Remote IP could not be validated for #{url}" unless parsed_ip
73
73
  return unless blocked_ip?(parsed_ip)
74
74
 
@@ -122,7 +122,8 @@ module Html2rss
122
122
  end
123
123
 
124
124
  def blocked_ip?(address)
125
- BLOCKED_IP_RANGES.any? { |range| range.include?(address) }
125
+ native = address.native
126
+ BLOCKED_IP_RANGES.any? { |range| range.include?(native) }
126
127
  end
127
128
  end
128
129
  end
@@ -47,13 +47,13 @@ module Html2rss
47
47
  allow_private_networks: DEFAULTS[:allow_private_networks],
48
48
  allow_cross_origin_followups: DEFAULTS[:allow_cross_origin_followups],
49
49
  resolver: Socket)
50
- @connect_timeout_seconds = validate_positive_integer!(:connect_timeout_seconds, connect_timeout_seconds)
51
- @read_timeout_seconds = validate_positive_integer!(:read_timeout_seconds, read_timeout_seconds)
52
- @total_timeout_seconds = validate_positive_integer!(:total_timeout_seconds, total_timeout_seconds)
53
- @max_redirects = validate_non_negative_integer!(:max_redirects, max_redirects)
54
- @max_response_bytes = validate_positive_integer!(:max_response_bytes, max_response_bytes)
55
- @max_decompressed_bytes = validate_positive_integer!(:max_decompressed_bytes, max_decompressed_bytes)
56
- @max_requests = [validate_positive_integer!(:max_requests, max_requests), MAX_REQUESTS_CEILING].min
50
+ @connect_timeout_seconds = Option.integer(:connect_timeout_seconds, connect_timeout_seconds, min: 1)
51
+ @read_timeout_seconds = Option.integer(:read_timeout_seconds, read_timeout_seconds, min: 1)
52
+ @total_timeout_seconds = Option.integer(:total_timeout_seconds, total_timeout_seconds, min: 1)
53
+ @max_redirects = Option.integer(:max_redirects, max_redirects, min: 0)
54
+ @max_response_bytes = Option.integer(:max_response_bytes, max_response_bytes, min: 1)
55
+ @max_decompressed_bytes = Option.integer(:max_decompressed_bytes, max_decompressed_bytes, min: 1)
56
+ @max_requests = [Option.integer(:max_requests, max_requests, min: 1), MAX_REQUESTS_CEILING].min
57
57
  @allow_private_networks = allow_private_networks ? true : false
58
58
  @allow_cross_origin_followups = allow_cross_origin_followups ? true : false
59
59
  @network_guard = NetworkGuard.new(allow_private_networks: @allow_private_networks, resolver:)
@@ -135,18 +135,6 @@ module Html2rss
135
135
 
136
136
  attr_reader :network_guard
137
137
 
138
- def validate_positive_integer!(name, value)
139
- raise ArgumentError, "#{name} must be positive" unless value.is_a?(Integer) && value.positive?
140
-
141
- value
142
- end
143
-
144
- def validate_non_negative_integer!(name, value)
145
- raise ArgumentError, "#{name} must be non-negative" unless value.is_a?(Integer) && !value.negative?
146
-
147
- value
148
- end
149
-
150
138
  def enforce_same_origin!(url, origin_url, relation)
151
139
  return if relation == :initial || allow_cross_origin_followups?
152
140
 
@@ -163,13 +151,7 @@ module Html2rss
163
151
  end
164
152
 
165
153
  def comparable_origin(url)
166
- [url.host, normalized_port(url)]
167
- end
168
-
169
- def normalized_port(url)
170
- return url.port if url.port
171
-
172
- url.scheme == 'https' ? 443 : 80
154
+ [url.scheme, url.host, url.port]
173
155
  end
174
156
  end
175
157
 
@@ -31,15 +31,12 @@ module Html2rss
31
31
  # @param status [Integer, nil] the HTTP status code when available
32
32
  # @param transport_meta [Hash] allowlisted upstream telemetry (frozen when present)
33
33
  # @param captured_responses [Array<Hash>] JSON XHR/fetch bodies captured during browser scrapes
34
- # rubocop:disable-next Metrics/MethodLength, Metrics/ParameterLists -- transport + capture fields stay co-located
34
+ # rubocop:disable-next Metrics/ParameterLists -- transport + capture fields stay co-located
35
35
  def initialize(body:, url:, headers: {}, status: nil, transport_meta: EMPTY_TRANSPORT_META,
36
36
  captured_responses: EMPTY_CAPTURED_RESPONSES)
37
37
  @body = body
38
38
 
39
- headers = (headers || {}).transform_keys(&:to_s)
40
- HashUtil.assert_string_keys!(headers, context: 'response headers', deep: false)
41
-
42
- @headers = headers
39
+ @headers = (headers || {}).transform_keys(&:to_s)
43
40
  @status = status
44
41
  @url = url
45
42
  @transport_meta = transport_meta.nil? || transport_meta.empty? ? EMPTY_TRANSPORT_META : transport_meta.freeze
@@ -165,7 +162,7 @@ module Html2rss
165
162
  end
166
163
 
167
164
  def feed_looking_body?
168
- snippet = body.to_s[0, 800].downcase
165
+ snippet = body.to_s.b[0, 800].downcase
169
166
  FEED_BODY_MARKERS.any? { |marker| snippet.include?(marker) }
170
167
  end
171
168
  end
@@ -26,7 +26,7 @@ module Html2rss
26
26
  # @yield [RequestService::Response] each page response
27
27
  # @return [Enumerator] enumerator when no block is given
28
28
  def each
29
- return enum_for(:each) unless block_given?
29
+ return enum_for unless block_given?
30
30
 
31
31
  yield initial_response
32
32
 
@@ -48,14 +48,14 @@ module Html2rss
48
48
 
49
49
  # @return [Integer] configured maximum pages (always a positive Integer)
50
50
  def max_pages
51
- value = config.fetch(:max_pages, DEFAULT_MAX_PAGES)
51
+ value = config[:max_pages]
52
52
  value.is_a?(Integer) && value.positive? ? value : DEFAULT_MAX_PAGES
53
53
  end
54
54
 
55
55
  # @param next_url [Html2rss::Url, String, nil]
56
56
  # @return [Boolean]
57
57
  def follow_up_allowed?(next_url)
58
- !next_url.nil? && !next_url.to_s.empty? && !session.visited?(next_url)
58
+ !next_url.to_s.empty? && !session.visited?(next_url)
59
59
  end
60
60
 
61
61
  # @param next_url [Html2rss::Url, String]
@@ -11,7 +11,7 @@ module Html2rss
11
11
  # @param page_response [RequestService::Response]
12
12
  # @param page_number [Integer]
13
13
  # @return [String, nil]
14
- def next_page_url(page_response, page_number: nil) # rubocop:disable Lint/UnusedMethodArgument
14
+ def next_page_url(page_response, page_number:) # rubocop:disable Lint/UnusedMethodArgument
15
15
  selector = config[:selector]
16
16
  return nil if selector.nil? || selector.empty?
17
17
 
@@ -19,16 +19,16 @@ module Html2rss
19
19
  return nil unless node
20
20
 
21
21
  href = node['href'] || node.text
22
- return nil if href.nil? || href.strip.empty?
22
+ return nil if href.strip.empty?
23
23
 
24
- Html2rss::Url.from_relative(href.strip, page_response.url)
24
+ Html2rss::Url.from_relative(href, page_response.url)
25
25
  end
26
26
 
27
27
  # @param parsed_body [Nokogiri::HTML::Document]
28
28
  # @param selector [String]
29
29
  # @return [Nokogiri::XML::Node, nil]
30
30
  def extract_node(parsed_body, selector)
31
- parsed_body.at_css(selector) || parsed_body.at_xpath(selector)
31
+ parsed_body.at_css(selector)
32
32
  rescue Nokogiri::CSS::SyntaxError, Nokogiri::XML::XPath::SyntaxError
33
33
  # Pure XPath like `descendant::a` fails LOOKS_LIKE_XPATH / at_css; fall back explicitly.
34
34
  parsed_body.at_xpath(selector)
@@ -25,14 +25,8 @@ module Html2rss
25
25
  # @return [Html2rss::Url]
26
26
  def next_page_url(page_response, page_number:)
27
27
  param = config.fetch(:param, DEFAULT_PARAM)
28
- target_offset_val = target_offset_for(page_number)
29
28
  url = Html2rss::Url.from_absolute(page_response.url)
30
-
31
- if url.to_s.include?('{offset}')
32
- Html2rss::Url.from_absolute(url.to_s.gsub('{offset}', target_offset_val.to_s))
33
- else
34
- url.with_query_values(url.query_values.merge(param.to_s => target_offset_val.to_s))
35
- end
29
+ url.with_query_values(url.query_values.merge(param.to_s => target_offset_for(page_number).to_s))
36
30
  end
37
31
 
38
32
  # @param page_number [Integer]
@@ -11,7 +11,7 @@ module Html2rss
11
11
  # @param page_response [RequestService::Response]
12
12
  # @param page_number [Integer]
13
13
  # @return [String, nil]
14
- def next_page_url(page_response, page_number: nil) # rubocop:disable Lint/UnusedMethodArgument
14
+ def next_page_url(page_response, page_number:) # rubocop:disable Lint/UnusedMethodArgument
15
15
  href = page_response.parsed_body.at_css('link[rel~="next"][href], a[rel~="next"][href]')&.[]('href')
16
16
  return nil if href.nil? || href.empty?
17
17
 
@@ -25,14 +25,8 @@ module Html2rss
25
25
  # @return [Html2rss::Url]
26
26
  def next_page_url(page_response, page_number:)
27
27
  param = config.fetch(:param, DEFAULT_PARAM)
28
- target_page_val = target_page_for(page_number)
29
28
  url = Html2rss::Url.from_absolute(page_response.url)
30
-
31
- if url.to_s.include?('{page}')
32
- Html2rss::Url.from_absolute(url.to_s.gsub('{page}', target_page_val.to_s))
33
- else
34
- url.with_query_values(url.query_values.merge(param.to_s => target_page_val.to_s))
35
- end
29
+ url.with_query_values(url.query_values.merge(param.to_s => target_page_for(page_number).to_s))
36
30
  end
37
31
 
38
32
  # @param page_number [Integer]
@@ -91,21 +91,17 @@ module Html2rss
91
91
 
92
92
  private
93
93
 
94
- def rank_one(segment, hard_junk_mode: :strict) # rubocop:disable Metrics/MethodLength
94
+ def rank_one(segment, hard_junk_mode: :strict) # rubocop:disable Metrics/AbcSize
95
95
  facts = segment.primary_link ? @link_resolver.destination_facts(segment.primary_link) : nil
96
96
  obs = @assessor.call(segment.root_node, segment.primary_link, destination_facts: facts)
97
97
  return if obs.hard_junk?(mode: hard_junk_mode)
98
98
 
99
99
  quality, quality_parts = apply_rules(QUALITY_RULES, obs)
100
100
  junk, junk_parts = apply_rules(JUNK_RULES, obs)
101
- ranked(
101
+ RankedSegment.new(
102
102
  segment:,
103
- score: build_score(
104
- composite: quality - junk,
105
- quality:,
106
- junk:,
107
- breakdown: quality_parts.merge(junk_parts)
108
- )
103
+ score: Score.new(composite: (quality - junk).to_f, quality: quality.to_f, junk: junk.to_f,
104
+ breakdown: quality_parts.merge(junk_parts).freeze)
109
105
  )
110
106
  end
111
107
 
@@ -116,30 +112,6 @@ module Html2rss
116
112
  end
117
113
  [parts.values.sum, parts]
118
114
  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
143
115
  end
144
116
  end
145
117
  end
@@ -24,7 +24,6 @@ module Html2rss
24
24
  def initialize(base_url)
25
25
  @base_url = base_url
26
26
  @by_href = {}
27
- @by_node = {}.compare_by_identity
28
27
  @text_classifier = LinkDestination::TextClassifier.new
29
28
  end
30
29
 
@@ -35,15 +34,11 @@ module Html2rss
35
34
  # @param node_or_href [SST::Node, String, #to_s]
36
35
  # @return [LinkDestination::DestinationFacts, nil]
37
36
  def destination_facts(node_or_href)
38
- return @by_node[node_or_href] if node_or_href.is_a?(SST::Node) && @by_node.key?(node_or_href)
39
-
40
37
  href = extract_href(node_or_href)
41
38
  return unless href
42
39
 
43
- facts = (@by_href[href] ||= build_facts(href))
44
- @by_node[node_or_href] = facts if node_or_href.is_a?(SST::Node)
45
- facts
46
- rescue ArgumentError
40
+ @by_href[href] ||= build_facts(href)
41
+ rescue Html2rss::Url::InvalidUrlError
47
42
  nil
48
43
  end
49
44
 
@@ -54,11 +54,9 @@ module Html2rss
54
54
  ##
55
55
  # Retrieves and returns the normalized absolute URL.
56
56
  #
57
- # @return [Html2rss::Url, nil] The absolute URL.
57
+ # @return [Html2rss::Url, nil] The absolute URL, or nil when nothing matched or the +href+ is blank.
58
58
  def call
59
- return nil unless @href
60
-
61
- Url.from_relative(@href, @base_url)
59
+ Url.from_relative(@href, @base_url) unless @href.strip.empty?
62
60
  end
63
61
  end
64
62
  end