html2rss 0.20.1 → 0.22.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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/html2rss.gemspec +1 -2
  3. data/lib/html2rss/auto_source/scraper/html/class_clustering.rb +185 -0
  4. data/lib/html2rss/auto_source/scraper/html.rb +73 -16
  5. data/lib/html2rss/auto_source/scraper/json_state.rb +41 -28
  6. data/lib/html2rss/auto_source/scraper/link_heuristics.rb +85 -131
  7. data/lib/html2rss/auto_source/scraper/microdata.rb +2 -2
  8. data/lib/html2rss/auto_source/scraper/schema/category_extractor.rb +74 -28
  9. data/lib/html2rss/auto_source/scraper/schema/list_item.rb +3 -2
  10. data/lib/html2rss/auto_source/scraper/schema/thing.rb +31 -60
  11. data/lib/html2rss/auto_source/scraper/schema.rb +8 -2
  12. data/lib/html2rss/auto_source/scraper/semantic_html/deduplicator.rb +4 -18
  13. data/lib/html2rss/auto_source/scraper/semantic_html.rb +55 -11
  14. data/lib/html2rss/auto_source/scraper.rb +0 -3
  15. data/lib/html2rss/auto_source.rb +5 -12
  16. data/lib/html2rss/category_extractor.rb +54 -20
  17. data/lib/html2rss/cli.rb +61 -10
  18. data/lib/html2rss/config/class_methods.rb +3 -3
  19. data/lib/html2rss/config/validator.rb +1 -0
  20. data/lib/html2rss/config.rb +2 -2
  21. data/lib/html2rss/html_extractor/enclosure_extractor.rb +60 -89
  22. data/lib/html2rss/html_extractor/heading_extractor.rb +50 -0
  23. data/lib/html2rss/html_extractor/id_generator.rb +67 -0
  24. data/lib/html2rss/html_extractor/list_candidates.rb +2 -8
  25. data/lib/html2rss/html_extractor/semantic_anchor_candidates.rb +29 -12
  26. data/lib/html2rss/html_extractor/semantic_containers.rb +9 -35
  27. data/lib/html2rss/html_extractor/text_extractor.rb +77 -0
  28. data/lib/html2rss/html_extractor.rb +80 -61
  29. data/lib/html2rss/rendering/description_builder.rb +3 -3
  30. data/lib/html2rss/rendering.rb +2 -2
  31. data/lib/html2rss/request_service/local_file_strategy.rb +29 -0
  32. data/lib/html2rss/request_service/puppet_commander.rb +4 -0
  33. data/lib/html2rss/request_service.rb +2 -1
  34. data/lib/html2rss/rss_builder/article.rb +44 -23
  35. data/lib/html2rss/rss_builder/enclosure.rb +4 -2
  36. data/lib/html2rss/selectors/extractors/attribute.rb +1 -1
  37. data/lib/html2rss/selectors/extractors/href.rb +1 -1
  38. data/lib/html2rss/selectors/extractors/html.rb +1 -1
  39. data/lib/html2rss/selectors/extractors/static.rb +1 -1
  40. data/lib/html2rss/selectors/extractors/text.rb +1 -1
  41. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +32 -36
  42. data/lib/html2rss/selectors/post_processors/substring.rb +11 -18
  43. data/lib/html2rss/selectors/post_processors/template.rb +3 -2
  44. data/lib/html2rss/selectors.rb +19 -5
  45. data/lib/html2rss/url.rb +29 -15
  46. data/lib/html2rss/version.rb +1 -1
  47. data/lib/html2rss.rb +28 -6
  48. data/schema/html2rss-config.schema.json +12 -1
  49. metadata +8 -17
@@ -29,7 +29,7 @@ module Html2rss
29
29
  # selector: h1
30
30
  # post_process:
31
31
  # name: template
32
- # string: '%{self} (%{price})'
32
+ # string: '`%{self}` (`%{price}`)'
33
33
  #
34
34
  # Would return:
35
35
  # 'Product (23,42€)'
@@ -54,12 +54,13 @@ module Html2rss
54
54
  @scraper = context[:scraper]
55
55
  @item = context[:item]
56
56
  @string = @options[:string].to_s
57
+ @getter = ->(key) { item_value(key) }
57
58
  end
58
59
 
59
60
  ##
60
61
  # @return [String]
61
62
  def get
62
- Html2rss::Config::DynamicParams.call(@string, {}, getter: method(:item_value), replace_missing_with: '')
63
+ Html2rss::Config::DynamicParams.call(@string, {}, getter: @getter, replace_missing_with: '')
63
64
  end
64
65
 
65
66
  private
@@ -18,7 +18,7 @@ module Html2rss
18
18
  include Enumerable
19
19
 
20
20
  # A context instance passed to item extractors and post-processors.
21
- Context = Struct.new('Context', :options, :item, :config, :scraper, keyword_init: true) # rubocop:disable Style/RedundantStructKeywordInit
21
+ Context = Struct.new('Context', :options, :item, :config, :scraper, keyword_init: true)
22
22
 
23
23
  # Default selectors options merged into user configuration.
24
24
  DEFAULT_CONFIG = { items: { enhance: true } }.freeze
@@ -199,7 +199,10 @@ module Html2rss
199
199
  end
200
200
 
201
201
  def select_regular(_name, item:, config:, base_url:)
202
- value = Extractors.get(config.merge(channel: channel_context(base_url)), item)
202
+ @merged_configs ||= {}
203
+ merged_config = @merged_configs[[config.object_id, base_url]] ||=
204
+ config.merge(channel: channel_context(base_url)).freeze
205
+ value = Extractors.get(merged_config, item)
203
206
 
204
207
  if value && (post_process_steps = config[:post_process])
205
208
  steps = post_process_steps.is_a?(Array) ? post_process_steps : [post_process_steps]
@@ -210,8 +213,9 @@ module Html2rss
210
213
  end
211
214
 
212
215
  def post_process(item, value, post_process_steps, base_url:)
216
+ pp_context = channel_post_process_context(base_url)
213
217
  post_process_steps.each do |options|
214
- context = Context.new(config: { channel: { url: base_url, time_zone: @time_zone } },
218
+ context = Context.new(config: pp_context,
215
219
  item:, scraper: self, options:)
216
220
 
217
221
  value = PostProcessors.get(options[:name], value, context)
@@ -262,7 +266,11 @@ module Html2rss
262
266
  end
263
267
 
264
268
  def category_node_options(selector_config, base_url:)
265
- selector_config.merge(channel: channel_context(base_url), selector: nil)
269
+ @category_node_configs ||= {}
270
+ @category_node_configs[[selector_config.object_id, base_url]] ||= selector_config.merge(
271
+ channel: channel_context(base_url),
272
+ selector: nil
273
+ ).freeze
266
274
  end
267
275
 
268
276
  def apply_post_process_steps(item:, value:, post_process_steps:, base_url:)
@@ -288,7 +296,13 @@ module Html2rss
288
296
  end
289
297
 
290
298
  def channel_context(base_url)
291
- { url: base_url, time_zone: @time_zone }
299
+ @channel_contexts ||= {}
300
+ @channel_contexts[base_url] ||= { url: base_url, time_zone: @time_zone }.freeze
301
+ end
302
+
303
+ def channel_post_process_context(base_url)
304
+ @channel_pp_contexts ||= {}
305
+ @channel_pp_contexts[base_url] ||= { channel: channel_context(base_url) }.freeze
292
306
  end
293
307
 
294
308
  # @return [Hash] enclosure details.
data/lib/html2rss/url.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'addressable/uri'
4
4
  require 'cgi'
5
+ require 'nokogiri'
5
6
 
6
7
  module Html2rss
7
8
  ##
@@ -54,11 +55,10 @@ module Html2rss
54
55
  # @param raw_url [String] the raw URL string to sanitize
55
56
  # @return [Url, nil] the sanitized URL, or nil if no valid URL found
56
57
  def self.sanitize(raw_url)
57
- matched_urls = raw_url.to_s.scan(%r{(?:(?:https?|ftp|mailto)://|mailto:)[^\s<>"]+})
58
- url = matched_urls.first.to_s.strip
59
- return nil if url.empty?
58
+ match = raw_url.to_s.match(%r{(?:(?:https?|ftp|mailto)://|mailto:)[^\s<>"]+})
59
+ return unless match
60
60
 
61
- new(Addressable::URI.parse(url).normalize)
61
+ new(Addressable::URI.parse(match[0].strip).normalize)
62
62
  end
63
63
 
64
64
  ##
@@ -70,10 +70,9 @@ module Html2rss
70
70
  def self.from_absolute(url_string)
71
71
  return url_string if url_string.is_a?(self)
72
72
 
73
- url = new(Addressable::URI.parse(url_string.to_s.strip).normalize)
74
- raise ArgumentError, 'URL must be absolute' unless url.absolute?
75
-
76
- url
73
+ new(Addressable::URI.parse(url_string.to_s.strip).normalize).tap do |url|
74
+ raise ArgumentError, 'URL must be absolute' unless url.absolute?
75
+ end
77
76
  rescue Addressable::URI::InvalidURIError
78
77
  raise ArgumentError, 'URL must be absolute'
79
78
  end
@@ -92,14 +91,28 @@ module Html2rss
92
91
  # Url.for_channel('/relative/path')
93
92
  # # => raises ArgumentError: "URL must be absolute"
94
93
  def self.for_channel(url_string)
95
- return nil if url_string.nil? || url_string.empty?
96
-
97
- stripped = url_string.strip
94
+ stripped = url_string.to_s.strip
98
95
  return nil if stripped.empty?
99
96
 
100
- url = from_absolute(stripped)
101
- validate_channel_url(url)
102
- url
97
+ from_absolute(stripped).tap { validate_channel_url(_1) }
98
+ end
99
+
100
+ ##
101
+ # Extracts a base URL from HTML metadata tags.
102
+ #
103
+ # @param html [String] raw HTML content
104
+ # @return [Url, nil] the extracted absolute URL, or nil if none is found
105
+ def self.extract_from_html(html)
106
+ doc = Nokogiri::HTML(html)
107
+ tags = { 'link[rel="canonical"]' => 'href', 'meta[property="og:url"]' => 'content',
108
+ 'meta[name="twitter:url"]' => 'content', 'base[href]' => 'href' }
109
+ tags.each do |sel, attr|
110
+ val = doc.at_css(sel)&.[](attr).to_s.strip
111
+ return from_absolute(val) unless val.empty?
112
+ rescue ArgumentError
113
+ next
114
+ end
115
+ nil
103
116
  end
104
117
 
105
118
  ##
@@ -125,6 +138,7 @@ module Html2rss
125
138
  # @param uri [Addressable::URI] the underlying Addressable::URI object (internal use only)
126
139
  def initialize(uri)
127
140
  @uri = uri.freeze
141
+ @path_segments = @uri.path.to_s.split('/').reject(&:empty?).freeze
128
142
  freeze
129
143
  end
130
144
 
@@ -162,7 +176,7 @@ module Html2rss
162
176
  # Returns the URL path split into non-empty segments.
163
177
  #
164
178
  # @return [Array<String>] normalized path segments
165
- def path_segments = @uri.path.to_s.split('/').reject(&:empty?)
179
+ attr_reader :path_segments
166
180
 
167
181
  ##
168
182
  # Returns a copy of the URL with the provided path.
@@ -4,6 +4,6 @@
4
4
  # The Html2rss namespace.
5
5
  module Html2rss
6
6
  # Current application version.
7
- VERSION = '0.20.1'
7
+ VERSION = '0.22.0'
8
8
  public_constant :VERSION
9
9
  end
data/lib/html2rss.rb CHANGED
@@ -52,6 +52,8 @@ module Html2rss
52
52
  FeedPipeline.new(raw_config).to_json_feed
53
53
  end
54
54
 
55
+ # rubocop:disable Metrics/ParameterLists
56
+
55
57
  ##
56
58
  # Scrapes the provided URL and returns an RSS object.
57
59
  #
@@ -60,9 +62,15 @@ module Html2rss
60
62
  # @param items_selector [String, nil] optional selector hint for item extraction
61
63
  # @param max_redirects [Integer, nil] optional redirect limit override
62
64
  # @param max_requests [Integer, nil] optional request budget override
65
+ # @param local_file_path [String, nil] optional local HTML file path
63
66
  # @return [RSS::Rss] generated RSS feed
64
- def self.auto_source(url, strategy: :auto, items_selector: nil, max_redirects: nil, max_requests: nil)
65
- feed(build_auto_source_config(url:, strategy:, items_selector:, max_redirects:, max_requests:))
67
+ def self.auto_source(url,
68
+ strategy: :auto,
69
+ items_selector: nil,
70
+ max_redirects: nil,
71
+ max_requests: nil,
72
+ local_file_path: nil)
73
+ feed(build_auto_source_config(url:, strategy:, items_selector:, max_redirects:, max_requests:, local_file_path:))
66
74
  end
67
75
 
68
76
  ##
@@ -73,11 +81,20 @@ module Html2rss
73
81
  # @param items_selector [String, nil] optional selector hint for item extraction
74
82
  # @param max_redirects [Integer, nil] optional redirect limit override
75
83
  # @param max_requests [Integer, nil] optional request budget override
84
+ # @param local_file_path [String, nil] optional local HTML file path
76
85
  # @return [Hash] JSONFeed-compliant hash
77
- def self.auto_json_feed(url, strategy: :auto, items_selector: nil, max_redirects: nil, max_requests: nil)
78
- json_feed(build_auto_source_config(url:, strategy:, items_selector:, max_redirects:, max_requests:))
86
+ def self.auto_json_feed(url,
87
+ strategy: :auto,
88
+ items_selector: nil,
89
+ max_redirects: nil,
90
+ max_requests: nil,
91
+ local_file_path: nil)
92
+ json_feed(build_auto_source_config(url:, strategy:, items_selector:, max_redirects:, max_requests:,
93
+ local_file_path:))
79
94
  end
80
95
 
96
+ # rubocop:enable Metrics/ParameterLists
97
+
81
98
  # rubocop:disable ThreadSafety/ClassInstanceVariable
82
99
  class << self
83
100
  ##
@@ -125,12 +142,17 @@ module Html2rss
125
142
  class << self
126
143
  private
127
144
 
128
- def build_auto_source_config(url:, strategy:, items_selector:, max_redirects:, max_requests:)
129
- Config.auto_source_config(
145
+ def build_auto_source_config(url:, strategy:, items_selector:, max_redirects:, max_requests:, local_file_path: nil) # rubocop:disable Metrics/ParameterLists
146
+ config = Config.auto_source_config(
130
147
  url:,
131
148
  items_selector:,
132
149
  request_controls: shortcut_request_controls(strategy:, max_redirects:, max_requests:)
133
150
  )
151
+ if local_file_path
152
+ config[:request] ||= {}
153
+ config[:request][:local_file_path] = local_file_path
154
+ end
155
+ config
134
156
  end
135
157
 
136
158
  def shortcut_request_controls(strategy:, max_redirects:, max_requests:)
@@ -179,6 +179,12 @@
179
179
  "type": "null"
180
180
  },
181
181
  "exclusiveMinimum": 0
182
+ },
183
+ "fallback_anchorless": {
184
+ "type": "boolean",
185
+ "not": {
186
+ "type": "null"
187
+ }
182
188
  }
183
189
  },
184
190
  "required": []
@@ -227,7 +233,8 @@
227
233
  "html": {
228
234
  "enabled": true,
229
235
  "minimum_selector_frequency": 2,
230
- "use_top_selectors": 5
236
+ "use_top_selectors": 5,
237
+ "fallback_anchorless": true
231
238
  }
232
239
  },
233
240
  "cleanup": {
@@ -535,6 +542,10 @@
535
542
  }
536
543
  },
537
544
  "required": []
545
+ },
546
+ "local_file_path": {
547
+ "type": "string",
548
+ "minLength": 1
538
549
  }
539
550
  },
540
551
  "required": []
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.20.1
4
+ version: 0.22.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gil Desmarais
@@ -147,20 +147,6 @@ dependencies:
147
147
  - - "<"
148
148
  - !ruby/object:Gem::Version
149
149
  version: '2.0'
150
- - !ruby/object:Gem::Dependency
151
- name: parallel
152
- requirement: !ruby/object:Gem::Requirement
153
- requirements:
154
- - - ">="
155
- - !ruby/object:Gem::Version
156
- version: '0'
157
- type: :runtime
158
- prerelease: false
159
- version_requirements: !ruby/object:Gem::Requirement
160
- requirements:
161
- - - ">="
162
- - !ruby/object:Gem::Version
163
- version: '0'
164
150
  - !ruby/object:Gem::Dependency
165
151
  name: puppeteer-ruby
166
152
  requirement: !ruby/object:Gem::Requirement
@@ -292,6 +278,7 @@ files:
292
278
  - lib/html2rss/auto_source/cleanup.rb
293
279
  - lib/html2rss/auto_source/scraper.rb
294
280
  - lib/html2rss/auto_source/scraper/html.rb
281
+ - lib/html2rss/auto_source/scraper/html/class_clustering.rb
295
282
  - lib/html2rss/auto_source/scraper/json_state.rb
296
283
  - lib/html2rss/auto_source/scraper/link_heuristics.rb
297
284
  - lib/html2rss/auto_source/scraper/microdata.rb
@@ -324,10 +311,13 @@ files:
324
311
  - lib/html2rss/html_extractor.rb
325
312
  - lib/html2rss/html_extractor/date_extractor.rb
326
313
  - lib/html2rss/html_extractor/enclosure_extractor.rb
314
+ - lib/html2rss/html_extractor/heading_extractor.rb
315
+ - lib/html2rss/html_extractor/id_generator.rb
327
316
  - lib/html2rss/html_extractor/image_extractor.rb
328
317
  - lib/html2rss/html_extractor/list_candidates.rb
329
318
  - lib/html2rss/html_extractor/semantic_anchor_candidates.rb
330
319
  - lib/html2rss/html_extractor/semantic_containers.rb
320
+ - lib/html2rss/html_extractor/text_extractor.rb
331
321
  - lib/html2rss/html_navigator.rb
332
322
  - lib/html2rss/json_feed_builder.rb
333
323
  - lib/html2rss/json_feed_builder/item.rb
@@ -346,6 +336,7 @@ files:
346
336
  - lib/html2rss/request_service/budget.rb
347
337
  - lib/html2rss/request_service/context.rb
348
338
  - lib/html2rss/request_service/faraday_strategy.rb
339
+ - lib/html2rss/request_service/local_file_strategy.rb
349
340
  - lib/html2rss/request_service/policy.rb
350
341
  - lib/html2rss/request_service/puppet_commander.rb
351
342
  - lib/html2rss/request_service/response.rb
@@ -390,7 +381,7 @@ licenses:
390
381
  - MIT
391
382
  metadata:
392
383
  allowed_push_host: https://rubygems.org
393
- changelog_uri: https://github.com/html2rss/html2rss/releases/tag/v0.20.1
384
+ changelog_uri: https://github.com/html2rss/html2rss/releases/tag/v0.22.0
394
385
  rubygems_mfa_required: 'true'
395
386
  rdoc_options: []
396
387
  require_paths:
@@ -399,7 +390,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
399
390
  requirements:
400
391
  - - ">="
401
392
  - !ruby/object:Gem::Version
402
- version: '3.2'
393
+ version: '3.3'
403
394
  required_rubygems_version: !ruby/object:Gem::Requirement
404
395
  requirements:
405
396
  - - ">="