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
data/lib/html2rss/url.rb CHANGED
@@ -26,6 +26,9 @@ module Html2rss
26
26
  class Url
27
27
  include Comparable
28
28
 
29
+ # Raised when a string cannot be parsed or resolved to a valid URL.
30
+ class InvalidUrlError < Html2rss::Error; end
31
+
29
32
  # Regular expression for basic URI format validation
30
33
  URI_REGEXP = Addressable::URI::URIREGEX
31
34
  # Schemes accepted by channel URL validation.
@@ -37,22 +40,21 @@ module Html2rss
37
40
  # @param relative_url [String, Html2rss::Url] the relative URL to resolve
38
41
  # @param base_url [String, Html2rss::Url] the base URL to resolve against
39
42
  # @return [Url] the resolved absolute URL
40
- # @raise [ArgumentError] if the URL cannot be parsed
43
+ # @raise [InvalidUrlError] if the URL cannot be parsed
41
44
  def self.from_relative(relative_url, base_url)
42
45
  url = Addressable::URI.parse(relative_url.to_s.strip)
43
46
  return new(url) if url.absolute?
44
47
 
45
- base_url.is_a?(self) ? base_url.join_uri(url) : join_parsed_base(base_url, url)
48
+ join_parsed_base(base_url, url)
46
49
  rescue Addressable::URI::InvalidURIError
47
- raise ArgumentError, 'URL could not be parsed'
50
+ raise InvalidUrlError, 'URL could not be parsed'
48
51
  end
49
52
 
50
- # @param base_url [String]
53
+ # @param base_url [String, Url]
51
54
  # @param url [Addressable::URI]
52
55
  # @return [Url]
53
56
  def self.join_parsed_base(base_url, url)
54
57
  uri = Addressable::URI.parse(base_url.to_s)
55
- uri.path = '/' if uri.path.empty?
56
58
  new(uri.join(url).normalize)
57
59
  end
58
60
  private_class_method :join_parsed_base
@@ -64,10 +66,10 @@ module Html2rss
64
66
  # @param raw_url [String] the raw URL string to sanitize
65
67
  # @return [Url, nil] the sanitized URL, or nil if no valid URL found
66
68
  def self.sanitize(raw_url)
67
- match = raw_url.to_s.match(%r{(?:(?:https?|ftp|mailto)://|mailto:)[^\s<>"]+})
69
+ match = raw_url.to_s.match(%r{(?:(?:https?|ftp)://|mailto:)[^\s<>"]+})
68
70
  return unless match
69
71
 
70
- new(Addressable::URI.parse(match[0].strip).normalize)
72
+ new(Addressable::URI.parse(match[0]).normalize)
71
73
  end
72
74
 
73
75
  ##
@@ -75,15 +77,15 @@ module Html2rss
75
77
  #
76
78
  # @param url_string [String, Html2rss::Url] the absolute URL to parse
77
79
  # @return [Url] the parsed and normalized URL
78
- # @raise [ArgumentError] if the URL is not absolute or cannot be parsed
80
+ # @raise [InvalidUrlError] if the URL is not absolute or cannot be parsed
79
81
  def self.from_absolute(url_string)
80
82
  return url_string if url_string.is_a?(self)
81
83
 
82
- new(Addressable::URI.parse(url_string.to_s.strip).normalize).tap do |url|
83
- raise ArgumentError, 'URL must be absolute' unless url.absolute?
84
+ new(Addressable::URI.parse(url_string.to_s.lstrip).normalize).tap do |url|
85
+ raise InvalidUrlError, 'URL must be absolute' unless url.absolute?
84
86
  end
85
87
  rescue Addressable::URI::InvalidURIError
86
- raise ArgumentError, 'URL must be absolute'
88
+ raise InvalidUrlError, 'URL must be absolute'
87
89
  end
88
90
 
89
91
  ##
@@ -92,20 +94,30 @@ module Html2rss
92
94
  #
93
95
  # @param url_string [String] the URL string to validate and parse
94
96
  # @return [Url] the validated and parsed URL
95
- # @raise [ArgumentError] if the URL doesn't meet channel requirements
97
+ # @raise [InvalidUrlError] if the URL doesn't meet channel requirements
96
98
  # @example Creating a channel URL
97
99
  # Url.for_channel('https://example.com')
98
100
  # # => #<Html2rss::Url:... @uri=#<Addressable::URI:... URI:https://example.com>>
99
101
  # @example Invalid channel URL
100
102
  # Url.for_channel('/relative/path')
101
- # # => raises ArgumentError: "URL must be absolute"
103
+ # # => raises InvalidUrlError: "URL must be absolute"
102
104
  def self.for_channel(url_string)
103
- stripped = url_string.to_s.strip
105
+ stripped = url_string.to_s.lstrip
104
106
  return nil if stripped.empty?
105
107
 
106
108
  from_absolute(stripped).tap { validate_channel_url(_1) }
107
109
  end
108
110
 
111
+ ##
112
+ # Creates an absolute http(s) URL, for example for a channel image.
113
+ #
114
+ # @param url_string [String, Html2rss::Url] the URL to validate and parse
115
+ # @return [Url] the validated and parsed URL
116
+ # @raise [InvalidUrlError] if the URL is not absolute or not http(s)
117
+ def self.from_absolute_web(url_string)
118
+ from_absolute(url_string).tap { validate_scheme(_1) }
119
+ end
120
+
109
121
  ##
110
122
  # Extracts a base URL from HTML metadata tags.
111
123
  #
@@ -116,10 +128,10 @@ module Html2rss
116
128
  tags = { 'link[rel="canonical"]' => 'href', 'meta[property="og:url"]' => 'content',
117
129
  'meta[name="twitter:url"]' => 'content', 'base[href]' => 'href' }
118
130
  tags.each do |sel, attr|
119
- val = doc.at_css(sel)&.[](attr).to_s.strip
120
- return from_absolute(val) unless val.empty?
121
- rescue ArgumentError
122
- next
131
+ val = doc.at_css(sel)&.[](attr)
132
+ return from_absolute(val)
133
+ rescue InvalidUrlError
134
+ nil
123
135
  end
124
136
  nil
125
137
  end
@@ -128,31 +140,32 @@ module Html2rss
128
140
  # Validates that a URL meets channel requirements.
129
141
  #
130
142
  # @param url [Url] the URL to validate
131
- # @raise [ArgumentError] if the URL doesn't meet channel requirements
143
+ # @raise [InvalidUrlError] if the URL doesn't meet channel requirements
132
144
  def self.validate_channel_url(url)
133
- raise ArgumentError, 'URL must be absolute' unless url.absolute?
145
+ has_forbidden_at = url.user
146
+ has_forbidden_at ||= [url.query, url.fragment].compact.any? { |value| value.include?('@') }
147
+ raise InvalidUrlError, 'URL must not contain an @ character' if has_forbidden_at
134
148
 
135
- uri = Addressable::URI.parse(url.to_s)
136
- has_forbidden_at = uri.user || uri.password
137
- has_forbidden_at ||= [uri.query, uri.fragment].compact.any? { |value| value.include?('@') }
138
- raise ArgumentError, 'URL must not contain an @ character' if has_forbidden_at
149
+ validate_scheme(url)
150
+ end
139
151
 
152
+ def self.validate_scheme(url)
140
153
  scheme = url.scheme
141
- raise ArgumentError, "URL scheme '#{scheme}' is not supported" unless SUPPORTED_SCHEMES.include?(scheme)
154
+ raise InvalidUrlError, "URL scheme '#{scheme}' is not supported" unless SUPPORTED_SCHEMES.include?(scheme)
142
155
  end
143
156
 
144
- private_class_method :validate_channel_url
157
+ private_class_method :validate_channel_url, :validate_scheme
145
158
 
146
159
  ##
147
160
  # @param uri [Addressable::URI] the underlying Addressable::URI object (internal use only)
148
161
  def initialize(uri)
149
162
  @uri = uri.freeze
150
- @path_segments = @uri.path.to_s.split('/').reject(&:empty?).freeze
163
+ @path_segments = path.split('/').reject(&:empty?).freeze
151
164
  freeze
152
165
  end
153
166
 
154
167
  # @return [String] normalized URL string
155
- def to_s = @uri.to_s
168
+ def to_s = @uri.to_str
156
169
 
157
170
  # @return [String, nil] URI scheme, for example `http` or `https`
158
171
  def scheme = @uri.scheme
@@ -160,6 +173,9 @@ module Html2rss
160
173
  # @return [String, nil] URI host component
161
174
  def host = @uri.host
162
175
 
176
+ # @return [String, nil] userinfo user, without the password
177
+ def user = @uri.user
178
+
163
179
  # Registrable domain (eTLD+1). Same-site hosts like www.wp.pl and wiadomosci.wp.pl
164
180
  # share `wp.pl`. IP literals stay the host so 1.2.3.4 is not treated as `3.4`.
165
181
  #
@@ -189,7 +205,7 @@ module Html2rss
189
205
  # Returns the URL query string as a hash of string keys and values.
190
206
  #
191
207
  # @return [Hash{String => String}] normalized query parameters
192
- def query_values = @uri.query_values(Hash) || {}
208
+ def query_values = @uri.query_values || {}
193
209
 
194
210
  ##
195
211
  # Returns the URL path split into non-empty segments.
@@ -205,7 +221,7 @@ module Html2rss
205
221
  def with_path(path)
206
222
  uri = @uri.dup
207
223
  uri.path = path
208
- self.class.from_absolute(uri.normalize.to_s)
224
+ self.class.from_absolute(uri)
209
225
  end
210
226
 
211
227
  ##
@@ -213,7 +229,7 @@ module Html2rss
213
229
  #
214
230
  # @return [Url] this URL when already fragment-free; otherwise a new URL
215
231
  def without_fragment
216
- @uri.fragment ? self.class.from_absolute(@uri.omit(:fragment).normalize.to_s) : self
232
+ fragment ? self.class.from_absolute(@uri.omit(:fragment)) : self
217
233
  end
218
234
 
219
235
  ##
@@ -224,7 +240,7 @@ module Html2rss
224
240
  def with_query_values(values)
225
241
  uri = @uri.dup
226
242
  uri.query_values = values.transform_keys(&:to_s).transform_values(&:to_s)
227
- self.class.from_absolute(uri.normalize.to_s)
243
+ self.class.from_absolute(uri)
228
244
  end
229
245
 
230
246
  ##
@@ -240,15 +256,7 @@ module Html2rss
240
256
  # url = Url.from_absolute('https://example.com/hello%20world/article.html')
241
257
  # url.titleized # => "Hello World Article"
242
258
  def titleized
243
- path = @uri.path
244
- return '' if path.empty?
245
-
246
- nicer_path = CGI.unescapeURIComponent(path)
247
- .split('/')
248
- .flat_map do |part|
249
- part.gsub(/[^a-zA-Z0-9.]/, ' ').gsub(/\s+/, ' ').split
250
- end
251
-
259
+ nicer_path = CGI.unescapeURIComponent(path).gsub(/[^a-zA-Z0-9.]/, ' ').split
252
260
  nicer_path.map!(&:capitalize)
253
261
  File.basename(nicer_path.join(' '), '.*')
254
262
  end
@@ -266,8 +274,7 @@ module Html2rss
266
274
  # url = Url.from_absolute('https://example.com')
267
275
  # url.channel_titleized # => "example.com"
268
276
  def channel_titleized
269
- nicer_path = CGI.unescapeURIComponent(@uri.path).split('/').reject(&:empty?)
270
- host = @uri.host
277
+ nicer_path = CGI.unescapeURIComponent(path).split('/').reject(&:empty?)
271
278
 
272
279
  nicer_path.any? ? "#{host}: #{nicer_path.map(&:capitalize).join(' ')}" : host
273
280
  end
@@ -318,12 +325,7 @@ module Html2rss
318
325
  # @param relative_uri [Addressable::URI]
319
326
  # @return [Url]
320
327
  def join_uri(relative_uri)
321
- base = @uri
322
- if base.path.empty?
323
- base = base.dup
324
- base.path = '/'
325
- end
326
- self.class.new(base.join(relative_uri).normalize)
328
+ self.class.new(@uri.join(relative_uri).normalize)
327
329
  end
328
330
 
329
331
  protected
@@ -332,15 +334,13 @@ module Html2rss
332
334
  #
333
335
  # @return [String]
334
336
  def document_identity
335
- uri = @uri.omit(:fragment)
336
- "#{uri.scheme}\0#{uri.authority}\0#{uri.path.to_s.chomp('/')}\0#{uri.query}"
337
+ "#{scheme}\0#{@uri.authority}\0#{path.chomp('/')}\0#{query}"
337
338
  end
338
339
 
339
340
  private
340
341
 
341
342
  def ip_address_host?
342
- IPAddr.new(host.to_s)
343
- true
343
+ !IPAddr.new(host).nil?
344
344
  rescue IPAddr::Error
345
345
  false
346
346
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Html2rss
4
4
  # Current application version.
5
- VERSION = '0.32.4'
5
+ VERSION = '0.33.0'
6
6
  public_constant :VERSION
7
7
  end
data/lib/html2rss.rb CHANGED
@@ -101,7 +101,7 @@ module Html2rss # rubocop:disable Metrics/ModuleLength
101
101
  #
102
102
  # @param config_input [Hash, String] config hash, YAML string, or file path
103
103
  # @param feed_name [String, nil] optional feed name in multi-feed file
104
- # @param min_items [Integer] minimum required items (default: 1)
104
+ # @param min_items [Integer] minimum required items, >= 0 (default: 1)
105
105
  # @param params [Hash] dynamic feed params
106
106
  # @param strategy [Symbol, nil] optional strategy override
107
107
  # @param strict_quality [Boolean] when true, fail on ship-quality audit thresholds
@@ -109,6 +109,7 @@ module Html2rss # rubocop:disable Metrics/ModuleLength
109
109
  # @return [Html2rss::Test::Result]
110
110
  def self.test(config_input, feed_name = nil, min_items: 1, params: {}, strategy: nil, strict_quality: false, # rubocop:disable Metrics/ParameterLists
111
111
  compare_enhance: false)
112
+ Option.integer(:min_items, min_items, min: 0)
112
113
  Test.call(config_input, feed_name, min_items:, params:, strategy:, strict_quality:, compare_enhance:)
113
114
  end
114
115
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: html2rss
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.32.4
4
+ version: 0.33.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gil Desmarais
@@ -391,6 +391,7 @@ files:
391
391
  - lib/html2rss/mcp/runtime.rb
392
392
  - lib/html2rss/mcp/server.rb
393
393
  - lib/html2rss/mcp/server/tools.rb
394
+ - lib/html2rss/option.rb
394
395
  - lib/html2rss/page_recon.rb
395
396
  - lib/html2rss/page_recon/README.md
396
397
  - lib/html2rss/page_recon/diagnostics.rb
@@ -463,6 +464,7 @@ files:
463
464
  - lib/html2rss/surface_category.rb
464
465
  - lib/html2rss/syndication.rb
465
466
  - lib/html2rss/syndication/README.md
467
+ - lib/html2rss/syndication/alternate_links.rb
466
468
  - lib/html2rss/syndication/candidate_catalog.rb
467
469
  - lib/html2rss/syndication/discovery.rb
468
470
  - lib/html2rss/syndication/parser.rb
@@ -478,7 +480,7 @@ licenses:
478
480
  - MIT
479
481
  metadata:
480
482
  allowed_push_host: https://rubygems.org
481
- changelog_uri: https://github.com/html2rss/html2rss/releases/tag/v0.32.4
483
+ changelog_uri: https://github.com/html2rss/html2rss/releases/tag/v0.33.0
482
484
  rubygems_mfa_required: 'true'
483
485
  rdoc_options: []
484
486
  require_paths: