html2rss 0.24.0 → 0.25.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 (89) hide show
  1. checksums.yaml +4 -4
  2. data/lib/html2rss/auto_source/scraper/html.rb +68 -145
  3. data/lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb +116 -0
  4. data/lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb +63 -0
  5. data/lib/html2rss/auto_source/scraper/json_state/document_scanner.rb +179 -0
  6. data/lib/html2rss/auto_source/scraper/json_state/value_finder.rb +55 -0
  7. data/lib/html2rss/auto_source/scraper/json_state.rb +0 -379
  8. data/lib/html2rss/auto_source/scraper/semantic_html/entry_deduplicator.rb +35 -30
  9. data/lib/html2rss/auto_source/scraper/semantic_html.rb +40 -120
  10. data/lib/html2rss/auto_source/scraper/sitemap/parser.rb +161 -0
  11. data/lib/html2rss/auto_source/scraper/sitemap.rb +10 -7
  12. data/lib/html2rss/auto_source/scraper/wordpress_api.rb +6 -2
  13. data/lib/html2rss/auto_source/scraper.rb +81 -44
  14. data/lib/html2rss/auto_source/segment.rb +29 -0
  15. data/lib/html2rss/auto_source/segmenter/cluster.rb +163 -0
  16. data/lib/html2rss/auto_source/segmenter/list.rb +101 -0
  17. data/lib/html2rss/auto_source/segmenter/primary_link.rb +81 -0
  18. data/lib/html2rss/auto_source/segmenter/semantic.rb +104 -0
  19. data/lib/html2rss/auto_source/segmenter.rb +66 -0
  20. data/lib/html2rss/auto_source.rb +74 -21
  21. data/lib/html2rss/cli.rb +8 -13
  22. data/lib/html2rss/config/auto_source_contract.rb +2 -0
  23. data/lib/html2rss/config/request_controls.rb +33 -0
  24. data/lib/html2rss/config/validator.rb +15 -8
  25. data/lib/html2rss/config.rb +6 -2
  26. data/lib/html2rss/feed_builder/rss.rb +17 -5
  27. data/lib/html2rss/feed_pipeline.rb +4 -0
  28. data/lib/html2rss/feed_result.rb +1 -1
  29. data/lib/html2rss/html/article_extractor/category_extractor.rb +10 -36
  30. data/lib/html2rss/html/article_extractor/date_extractor.rb +2 -7
  31. data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +5 -50
  32. data/lib/html2rss/html/article_extractor/image_extractor.rb +5 -16
  33. data/lib/html2rss/html/article_rules/category.rb +55 -0
  34. data/lib/html2rss/html/article_rules/date.rb +25 -0
  35. data/lib/html2rss/html/article_rules/enclosure.rb +72 -0
  36. data/lib/html2rss/html/article_rules/image.rb +109 -0
  37. data/lib/html2rss/html/article_rules.rb +10 -0
  38. data/lib/html2rss/html/rendering/audio_renderer.rb +2 -12
  39. data/lib/html2rss/html/rendering/escaped_attributes.rb +27 -0
  40. data/lib/html2rss/html/rendering/image_renderer.rb +2 -12
  41. data/lib/html2rss/html/rendering/pdf_renderer.rb +2 -8
  42. data/lib/html2rss/html/rendering/video_renderer.rb +2 -12
  43. data/lib/html2rss/html/sst_article_extractor.rb +279 -0
  44. data/lib/html2rss/link_destination/destination_facts.rb +40 -0
  45. data/lib/html2rss/link_destination/noise_policy.rb +79 -0
  46. data/lib/html2rss/link_destination/path_classifier.rb +205 -0
  47. data/lib/html2rss/link_destination/text_classifier.rb +64 -0
  48. data/lib/html2rss/link_destination.rb +8 -0
  49. data/lib/html2rss/request_service/faraday_strategy.rb +42 -1
  50. data/lib/html2rss/request_service/network_guard.rb +5 -3
  51. data/lib/html2rss/scoring/anchor_score.rb +34 -0
  52. data/lib/html2rss/scoring/cluster_scorer.rb +87 -0
  53. data/lib/html2rss/scoring/container_assessor.rb +83 -0
  54. data/lib/html2rss/scoring/engine.rb +101 -0
  55. data/lib/html2rss/scoring/link_resolver.rb +72 -0
  56. data/lib/html2rss/scoring/observation.rb +53 -0
  57. data/lib/html2rss/scoring/ranked_segment.rb +45 -0
  58. data/lib/html2rss/scoring/score.rb +30 -0
  59. data/lib/html2rss/scoring.rb +33 -0
  60. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +10 -1
  61. data/lib/html2rss/selectors.rb +0 -20
  62. data/lib/html2rss/sst/attrs.rb +91 -0
  63. data/lib/html2rss/sst/document.rb +23 -0
  64. data/lib/html2rss/sst/index.rb +112 -0
  65. data/lib/html2rss/sst/node.rb +147 -0
  66. data/lib/html2rss/sst/normalizer.rb +171 -0
  67. data/lib/html2rss/sst/tags.rb +26 -0
  68. data/lib/html2rss/sst/text.rb +81 -0
  69. data/lib/html2rss/sst.rb +8 -0
  70. data/lib/html2rss/version.rb +1 -1
  71. data/lib/html2rss.rb +26 -26
  72. data/schema/html2rss-config.schema.json +20 -0
  73. metadata +42 -18
  74. data/lib/html2rss/auto_source/discovery/dom_clustering/group_scorer.rb +0 -80
  75. data/lib/html2rss/auto_source/discovery/dom_clustering/overlap_resolver.rb +0 -86
  76. data/lib/html2rss/auto_source/discovery/dom_clustering.rb +0 -119
  77. data/lib/html2rss/auto_source/discovery/list_candidates.rb +0 -94
  78. data/lib/html2rss/auto_source/discovery/semantic_anchor_candidates.rb +0 -219
  79. data/lib/html2rss/auto_source/discovery/semantic_containers.rb +0 -71
  80. data/lib/html2rss/auto_source/discovery/sitemap.rb +0 -159
  81. data/lib/html2rss/auto_source/discovery.rb +0 -14
  82. data/lib/html2rss/auto_source/link_heuristics/anchor_signals.rb +0 -28
  83. data/lib/html2rss/auto_source/link_heuristics/container_assessor.rb +0 -106
  84. data/lib/html2rss/auto_source/link_heuristics/container_signals.rb +0 -80
  85. data/lib/html2rss/auto_source/link_heuristics/destination_facts.rb +0 -42
  86. data/lib/html2rss/auto_source/link_heuristics/href_extractor.rb +0 -38
  87. data/lib/html2rss/auto_source/link_heuristics/path_classifier.rb +0 -221
  88. data/lib/html2rss/auto_source/link_heuristics/text_classifier.rb +0 -66
  89. data/lib/html2rss/auto_source/link_heuristics.rb +0 -139
data/lib/html2rss/cli.rb CHANGED
@@ -79,6 +79,9 @@ module Html2rss
79
79
  method_option :max_requests,
80
80
  type: :numeric,
81
81
  desc: 'Maximum requests to allow for this feed build'
82
+ method_option :limit,
83
+ type: :numeric,
84
+ desc: 'Maximum number of articles to keep (default: 25)'
82
85
  method_option :input,
83
86
  type: :string,
84
87
  desc: 'Local HTML file path to read input from'
@@ -187,22 +190,13 @@ module Html2rss
187
190
  end
188
191
 
189
192
  def request_controls
190
- Html2rss::Config::RequestControls.new(
191
- strategy: options[:strategy]&.to_sym,
193
+ Html2rss::Config::RequestControls.from_cli_options(
194
+ strategy: options[:strategy],
192
195
  max_redirects: options[:max_redirects],
193
- max_requests: options[:max_requests],
194
- explicit_keys: explicit_request_control_keys
196
+ max_requests: options[:max_requests]
195
197
  )
196
198
  end
197
199
 
198
- def explicit_request_control_keys
199
- keys = []
200
- keys << :strategy if options[:strategy]
201
- keys << :max_redirects unless options[:max_redirects].nil?
202
- keys << :max_requests unless options[:max_requests].nil?
203
- keys
204
- end
205
-
206
200
  def current_strategy
207
201
  options[:strategy]&.to_sym || :auto
208
202
  end
@@ -249,7 +243,8 @@ module Html2rss
249
243
  items_selector: options[:items_selector],
250
244
  max_redirects: options[:max_redirects],
251
245
  max_requests: options[:max_requests],
252
- local_file_path:
246
+ local_file_path:,
247
+ limit: options[:limit]&.to_i
253
248
  )
254
249
  end
255
250
 
@@ -6,6 +6,8 @@ module Html2rss
6
6
  class Config
7
7
  # Runtime source of truth for validating auto-source config values.
8
8
  AutoSourceContract = Dry::Schema.Params do # rubocop:disable Metrics/BlockLength
9
+ optional(:limit).filled(:integer, gt?: 0)
10
+
9
11
  optional(:scraper).hash do # rubocop:disable Metrics/BlockLength
10
12
  optional(:wordpress_api).hash do
11
13
  optional(:enabled).filled(:bool)
@@ -43,6 +43,39 @@ module Html2rss
43
43
  request_config.is_a?(Hash) && request_config.key?(key)
44
44
  end
45
45
 
46
+ ##
47
+ # Builds controls for public API shortcuts ({Html2rss.auto_source}, etc.).
48
+ # Strategy is explicit only when non-nil and not the configured default.
49
+ #
50
+ # @param strategy [Symbol, nil]
51
+ # @param max_redirects [Integer, nil]
52
+ # @param max_requests [Integer, nil]
53
+ # @return [RequestControls]
54
+ def self.from_shortcut(strategy:, max_redirects: nil, max_requests: nil)
55
+ keys = []
56
+ keys << :strategy unless strategy.nil? || strategy == Config.default_strategy_name
57
+ keys << :max_redirects unless max_redirects.nil?
58
+ keys << :max_requests unless max_requests.nil?
59
+ new(strategy:, max_redirects:, max_requests:, explicit_keys: keys)
60
+ end
61
+
62
+ ##
63
+ # Builds controls from CLI option values. Strategy is explicit when the
64
+ # option was provided (truthy string/symbol), matching Thor option presence.
65
+ #
66
+ # @param strategy [Symbol, String, nil]
67
+ # @param max_redirects [Integer, nil]
68
+ # @param max_requests [Integer, nil]
69
+ # @return [RequestControls]
70
+ def self.from_cli_options(strategy:, max_redirects: nil, max_requests: nil)
71
+ strategy_sym = strategy&.to_sym
72
+ keys = []
73
+ keys << :strategy if strategy
74
+ keys << :max_redirects unless max_redirects.nil?
75
+ keys << :max_requests unless max_requests.nil?
76
+ new(strategy: strategy_sym, max_redirects:, max_requests:, explicit_keys: keys)
77
+ end
78
+
46
79
  private_class_method :explicit_keys_for, :request_value_for, :request_key?
47
80
 
48
81
  ##
@@ -24,6 +24,8 @@ module Html2rss
24
24
  optional(:language).maybe(:string, format?: LANGUAGE_FORMAT_REGEX)
25
25
  optional(:ttl).maybe(:integer, gt?: 0)
26
26
  optional(:time_zone).maybe(:string)
27
+ optional(:author).maybe(:string)
28
+ optional(:image).maybe(:string, format?: URI_REGEXP)
27
29
  end
28
30
 
29
31
  # Contract for a stylesheet entry in `stylesheets`.
@@ -132,15 +134,20 @@ module Html2rss
132
134
 
133
135
  # URL validation delegated to Url class
134
136
  rule(:channel) do
135
- next unless values[:channel]&.key?(:url)
136
-
137
- url_string = values[:channel][:url]
138
- next if url_string.nil? || url_string.empty?
137
+ if (url_string = values.dig(:channel, :url)) && !url_string.empty?
138
+ begin
139
+ Html2rss::Url.for_channel(url_string)
140
+ rescue ArgumentError => error
141
+ key(%i[channel url]).failure(error.message)
142
+ end
143
+ end
139
144
 
140
- begin
141
- Html2rss::Url.for_channel(url_string)
142
- rescue ArgumentError => error
143
- key(%i[channel url]).failure(error.message)
145
+ if (image_string = values.dig(:channel, :image)) && !image_string.empty?
146
+ begin
147
+ Html2rss::Url.from_absolute(image_string)
148
+ rescue ArgumentError => error
149
+ key(%i[channel image]).failure(error.message)
150
+ end
144
151
  end
145
152
  end
146
153
  end
@@ -120,11 +120,15 @@ module Html2rss
120
120
  # @param url [String] source page URL
121
121
  # @param items_selector [String, nil] optional selector hint for item extraction
122
122
  # @param request_controls [Html2rss::Config::RequestControls, nil] explicit request controls to write
123
+ # @param limit [Integer, nil] max articles to keep in the auto-sourced feed
123
124
  # @return [Hash{Symbol => Object}] feed config hash ready for {from_hash}
124
- def auto_source_config(url:, items_selector: nil, request_controls: nil)
125
+ def auto_source_config(url:, items_selector: nil, request_controls: nil, limit: nil)
126
+ auto_source = AutoSource::DEFAULT_CONFIG
127
+ auto_source = auto_source.merge(limit:) unless limit.nil?
128
+
125
129
  config = {
126
130
  channel: default_config[:channel].merge(url:),
127
- auto_source: AutoSource::DEFAULT_CONFIG
131
+ auto_source:
128
132
  }
129
133
 
130
134
  request_controls ||= RequestControls.new
@@ -79,7 +79,7 @@ module Html2rss
79
79
  RSS::Maker.make('2.0') do |maker|
80
80
  Stylesheet.add(maker, stylesheets)
81
81
 
82
- make_channel(maker.channel)
82
+ make_channel(maker)
83
83
  make_items(maker)
84
84
  end
85
85
  end
@@ -96,14 +96,26 @@ module Html2rss
96
96
  @stylesheets.map { |style| Stylesheet.new(**style) }
97
97
  end
98
98
 
99
+ # rubocop:disable Metrics/AbcSize
99
100
  def make_channel(maker)
101
+ channel_maker = maker.channel
100
102
  %i[language title description ttl].each do |key|
101
- maker.public_send(:"#{key}=", channel.public_send(key))
103
+ channel_maker.public_send(:"#{key}=", channel.public_send(key))
102
104
  end
103
105
 
104
- maker.link = channel.url.to_s
105
- maker.generator = generator
106
- maker.updated = channel.last_build_date
106
+ channel_maker.managingEditor = channel.author if channel.author
107
+ channel_maker.author = channel.author if channel.author
108
+ channel_maker.link = channel.url.to_s
109
+ channel_maker.generator = generator
110
+ channel_maker.updated = channel.last_build_date
111
+
112
+ make_image(maker.image) if channel.image
113
+ end
114
+ # rubocop:enable Metrics/AbcSize
115
+
116
+ def make_image(image_maker)
117
+ image_maker.url = channel.image.to_s
118
+ image_maker.title = channel.title.to_s
107
119
  end
108
120
 
109
121
  def make_items(maker)
@@ -81,6 +81,10 @@ module Html2rss
81
81
  request_session = request_session_for(config, strategy:, resources:)
82
82
  response = request_session.fetch_initial_response
83
83
  articles, dedup_dropped = deduplicated_articles(config:, response:, request_session:)
84
+ if config.auto_source && articles.empty?
85
+ raise NoFeedItemsExtracted.new(attempts: [{ strategy:, items_count: 0, error_class: nil }])
86
+ end
87
+
84
88
  PipelineOutcome.new(
85
89
  response:, articles:, dedup_dropped:, selected_strategy: nil, attempt_count: 0, strategy_attempts: []
86
90
  )
@@ -69,7 +69,7 @@ module Html2rss
69
69
 
70
70
  ##
71
71
  # @return [Html2rss::Status] frozen scraper tallies, dedup count, and generator formatter.
72
- # {#to_h} on +status+ is a stable consumer contract for observability payloads.
72
+ # {Status#to_h} on +status+ is a stable consumer contract for observability payloads.
73
73
  attr_reader :status
74
74
 
75
75
  private
@@ -7,20 +7,16 @@ module Html2rss
7
7
  # CategoryExtractor is responsible for extracting categories from HTML elements
8
8
  # by looking for CSS class names containing common category-related terms.
9
9
  class CategoryExtractor
10
- # Common category-related terms to look for in class names
11
- CATEGORY_TERMS = %w[
12
- category categories tag tags topic topics section sections
13
- label labels theme themes subject subjects
14
- ].freeze
10
+ # Shared category vocabulary (owned by {ArticleRules::Category}).
11
+ CATEGORY_TERMS = ArticleRules::Category::CATEGORY_TERMS
12
+ # Shared category attribute/class pattern (owned by {ArticleRules::Category}).
13
+ CATEGORY_ATTR_PATTERN = ArticleRules::Category::CATEGORY_ATTR_PATTERN
15
14
 
16
15
  # CSS selectors to find elements with category-related class names or data attributes
17
16
  CATEGORY_SELECTORS = CATEGORY_TERMS.flat_map do |term|
18
17
  ["[class*=\"#{term}\"]", "[data-#{term}]", "[#{term}]"]
19
18
  end.freeze
20
19
 
21
- # Regex pattern for matching category-related attribute names
22
- CATEGORY_ATTR_PATTERN = /#{CATEGORY_TERMS.join('|')}/i
23
-
24
20
  ##
25
21
  # Extracts categories from the given article tag by looking for elements
26
22
  # with class names containing common category-related terms.
@@ -30,7 +26,6 @@ module Html2rss
30
26
  def self.call(article_tag)
31
27
  return [] unless article_tag
32
28
 
33
- # Single optimized traversal that extracts all category types
34
29
  extract_all_categories(article_tag)
35
30
  .map(&:strip)
36
31
  .reject(&:empty?)
@@ -44,10 +39,7 @@ module Html2rss
44
39
  def self.extract_all_categories(article_tag)
45
40
  Set.new.tap do |categories|
46
41
  article_tag.css(CATEGORY_SELECTORS.join(',')).each do |element|
47
- # Extract text categories from elements with category-related class names
48
- extract_text_categories!(categories, element) if element['class']&.match?(CATEGORY_ATTR_PATTERN)
49
-
50
- # Extract data categories from all elements
42
+ extract_text_categories!(categories, element) if ArticleRules::Category.class_match?(element['class'])
51
43
  extract_element_data_categories!(categories, element)
52
44
  end
53
45
  end
@@ -61,10 +53,9 @@ module Html2rss
61
53
  # @return [void]
62
54
  def self.extract_element_data_categories!(categories, element)
63
55
  element.attributes.each_value do |attr|
64
- next unless attr.name.match?(CATEGORY_ATTR_PATTERN)
56
+ next unless ArticleRules::Category.attr_name_match?(attr.name)
65
57
 
66
- value = attr.value&.strip
67
- categories.add(value) if value && !value.empty?
58
+ ArticleRules::Category.add_text!(categories, attr.value)
68
59
  end
69
60
  end
70
61
 
@@ -85,7 +76,7 @@ module Html2rss
85
76
  if anchors.any?
86
77
  anchors.each { |node| add_text_to_categories!(categories, node) }
87
78
  else
88
- extract_split_text_categories!(categories, element)
79
+ ArticleRules::Category.add_split_text!(categories, element.text)
89
80
  end
90
81
  end
91
82
 
@@ -96,27 +87,10 @@ module Html2rss
96
87
  # @param element [Nokogiri::XML::Element] The element to extract text from
97
88
  # @return [void]
98
89
  def self.add_text_to_categories!(categories, element)
99
- text = Navigator.extract_visible_text(element)
100
- categories.add(text) if text && !text.empty?
101
- end
102
-
103
- ##
104
- # Extracts categories from the element's text by splitting on newlines.
105
- #
106
- # @param categories [Set<String>] Accumulator set
107
- # @param element [Nokogiri::XML::Element] The element to extract text from
108
- # @return [void]
109
- def self.extract_split_text_categories!(categories, element)
110
- text = element.text
111
- return unless text
112
-
113
- text.split(/\n+/).each do |line|
114
- line = line.strip
115
- categories.add(line) unless line.empty?
116
- end
90
+ ArticleRules::Category.add_text!(categories, Navigator.extract_visible_text(element))
117
91
  end
118
92
 
119
- private_class_method :add_text_to_categories!, :extract_split_text_categories!
93
+ private_class_method :add_text_to_categories!
120
94
  end
121
95
  end
122
96
  end
@@ -8,13 +8,8 @@ module Html2rss
8
8
  # @param article_tag [Nokogiri::XML::Element] article container node
9
9
  # @return [DateTime, nil]
10
10
  def self.call(article_tag)
11
- times = article_tag.css('[datetime]').filter_map do |tag|
12
- DateTime.parse(tag['datetime'])
13
- rescue ArgumentError, TypeError
14
- nil
15
- end
16
-
17
- times.min
11
+ datetimes = article_tag.css('[datetime]').map { |tag| tag['datetime'] }
12
+ ArticleRules::Date.earliest(datetimes)
18
13
  end
19
14
  end
20
15
  end
@@ -33,62 +33,17 @@ module Html2rss
33
33
  def self.extract_from_element(element, base_url)
34
34
  case element.name
35
35
  when 'img'
36
- extract_image(element, base_url)
36
+ ArticleRules::Enclosure.from_image(element['src'], base_url)
37
37
  when 'video', 'audio', 'source'
38
- extract_media(element, base_url)
38
+ ArticleRules::Enclosure.from_media(element['src'], element['type'], base_url)
39
39
  when 'iframe'
40
- extract_iframe(element, base_url)
40
+ ArticleRules::Enclosure.from_iframe(element['src'], base_url)
41
41
  when 'a'
42
- extract_a(element, base_url)
42
+ ArticleRules::Enclosure.from_anchor(element['href'], base_url)
43
43
  end
44
44
  end
45
45
 
46
- def self.extract_image(img, base_url)
47
- src = img['src'].to_s
48
- return if src.empty?
49
-
50
- abs_url = Url.from_relative(src, base_url)
51
- {
52
- url: abs_url,
53
- type: Article::Enclosure.guess_content_type_from_url(abs_url, default: 'image/jpeg')
54
- }
55
- end
56
-
57
- def self.extract_media(element, base_url)
58
- src = element['src'].to_s
59
- return if src.empty?
60
-
61
- {
62
- url: Url.from_relative(src, base_url),
63
- type: element['type']
64
- }
65
- end
66
-
67
- def self.extract_iframe(iframe, base_url)
68
- src = iframe['src'].to_s
69
- return if src.empty?
70
-
71
- abs_url = Url.from_relative(src, base_url)
72
- {
73
- url: abs_url,
74
- type: Article::Enclosure.guess_content_type_from_url(abs_url, default: 'text/html')
75
- }
76
- end
77
-
78
- def self.extract_a(link, base_url)
79
- href = link['href'].to_s
80
- return if href.empty?
81
-
82
- abs_url = Url.from_relative(href, base_url)
83
-
84
- if href.end_with?('.pdf')
85
- { url: abs_url, type: Article::Enclosure.guess_content_type_from_url(abs_url) }
86
- else
87
- { url: abs_url, type: 'application/zip' }
88
- end
89
- end
90
-
91
- private_class_method :extract_from_element, :extract_image, :extract_media, :extract_iframe, :extract_a
46
+ private_class_method :extract_from_element
92
47
  end
93
48
  end
94
49
  end
@@ -32,27 +32,16 @@ module Html2rss
32
32
  # @see <https://developer.mozilla.org/en-US/docs/Learn/HTML/Multimedia_and_embedding/Responsive_images>
33
33
  # @see <https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img#srcset>
34
34
  # @see <https://developer.mozilla.org/en-US/docs/Web/HTML/Element/picture>
35
- def self.from_source(article_tag) # rubocop:disable Metrics/AbcSize
36
- hash = article_tag.css('img[srcset], picture > source[srcset]').flat_map do |source|
37
- source['srcset'].to_s.scan(/(\S+)\s+(\d+w|\d+h)[\s,]?/).map do |url, width|
38
- next if url.nil? || url.start_with?('data:')
39
-
40
- width_value = width.to_i.zero? ? 0 : width.scan(/\d+/).first.to_i
41
-
42
- [width_value, url.strip]
43
- end
44
- end.compact.to_h
45
-
46
- hash[hash.keys.max]
35
+ def self.from_source(article_tag)
36
+ srcsets = article_tag.css('img[srcset], picture > source[srcset]').map { |source| source['srcset'] }
37
+ ArticleRules::Image.largest_from_srcsets(srcsets)
47
38
  end
48
39
 
49
40
  # @param article_tag [Nokogiri::XML::Element] article container node
50
41
  # @return [String, nil] best style-based background image URL
51
42
  def self.from_style(article_tag)
52
- article_tag.css('[style*="url"]')
53
- .filter_map { |tag| tag['style'][/url\(['"]?(.*?)['"]?\)/, 1] }
54
- .reject { |src| src.start_with?('data:') }
55
- .max_by(&:size)
43
+ styles = article_tag.css('[style*="url"]').map { |tag| tag['style'] }
44
+ ArticleRules::Image.best_from_styles(styles)
56
45
  end
57
46
  end
58
47
  end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ module Html
5
+ module ArticleRules
6
+ ##
7
+ # Shared category term vocabulary and text accumulation (DOM walk stays in adapters).
8
+ module Category
9
+ # Class/data attribute tokens that mark category metadata.
10
+ CATEGORY_TERMS = %w[
11
+ category categories tag tags topic topics section sections
12
+ label labels theme themes subject subjects
13
+ ].freeze
14
+
15
+ # Regex matching category-related attribute or class names.
16
+ CATEGORY_ATTR_PATTERN = /#{CATEGORY_TERMS.join('|')}/i
17
+
18
+ class << self
19
+ ##
20
+ # @param name [String]
21
+ # @return [Boolean]
22
+ def attr_name_match?(name)
23
+ name.to_s.match?(CATEGORY_ATTR_PATTERN)
24
+ end
25
+
26
+ ##
27
+ # @param class_attr [String, nil]
28
+ # @return [Boolean]
29
+ def class_match?(class_attr)
30
+ class_attr.to_s.match?(CATEGORY_ATTR_PATTERN)
31
+ end
32
+
33
+ ##
34
+ # @param categories [Set<String>]
35
+ # @param text [String, nil]
36
+ # @return [void]
37
+ def add_text!(categories, text)
38
+ value = text.to_s.strip
39
+ categories.add(value) unless value.empty?
40
+ end
41
+
42
+ ##
43
+ # @param categories [Set<String>]
44
+ # @param text [String, nil]
45
+ # @return [void]
46
+ def add_split_text!(categories, text)
47
+ return unless text
48
+
49
+ text.split(/\n+/).each { |line| add_text!(categories, line) }
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ module Html
5
+ module ArticleRules
6
+ ##
7
+ # DOM-agnostic datetime aggregation for article published_at.
8
+ module Date
9
+ class << self
10
+ ##
11
+ # @param datetime_strings [Array<String, nil>] raw datetime attribute values
12
+ # @return [DateTime, nil] earliest successfully parsed instant
13
+ def earliest(datetime_strings)
14
+ times = Array(datetime_strings).filter_map do |value|
15
+ DateTime.parse(value.to_s)
16
+ rescue ArgumentError, TypeError
17
+ nil
18
+ end
19
+ times.min
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ module Html
5
+ module ArticleRules
6
+ ##
7
+ # DOM-agnostic enclosure URL/type normalization.
8
+ module Enclosure
9
+ # Href path suffixes treated as download/archive enclosure links.
10
+ ARCHIVE_HREF_SUFFIXES = %w[.pdf .zip .tar.gz .tgz].freeze
11
+
12
+ class << self
13
+ ##
14
+ # @param href [String]
15
+ # @return [Boolean]
16
+ def archive_href?(href)
17
+ ARCHIVE_HREF_SUFFIXES.any? { |suffix| href.to_s.end_with?(suffix) }
18
+ end
19
+
20
+ ##
21
+ # @param src [String]
22
+ # @param base_url [String, Html2rss::Url]
23
+ # @return [Hash{Symbol => Object}, nil]
24
+ def from_image(src, base_url)
25
+ return if src.to_s.empty?
26
+
27
+ abs = Url.from_relative(src, base_url)
28
+ { url: abs, type: Article::Enclosure.guess_content_type_from_url(abs, default: 'image/jpeg') }
29
+ end
30
+
31
+ ##
32
+ # @param src [String]
33
+ # @param type [String, nil]
34
+ # @param base_url [String, Html2rss::Url]
35
+ # @return [Hash{Symbol => Object}, nil]
36
+ def from_media(src, type, base_url)
37
+ return if src.to_s.empty?
38
+
39
+ { url: Url.from_relative(src, base_url), type: }
40
+ end
41
+
42
+ ##
43
+ # @param src [String]
44
+ # @param base_url [String, Html2rss::Url]
45
+ # @return [Hash{Symbol => Object}, nil]
46
+ def from_iframe(src, base_url)
47
+ return if src.to_s.empty?
48
+
49
+ abs = Url.from_relative(src, base_url)
50
+ { url: abs, type: Article::Enclosure.guess_content_type_from_url(abs, default: 'text/html') }
51
+ end
52
+
53
+ ##
54
+ # @param href [String]
55
+ # @param base_url [String, Html2rss::Url]
56
+ # @return [Hash{Symbol => Object}, nil]
57
+ def from_anchor(href, base_url)
58
+ return if href.to_s.empty?
59
+
60
+ abs = Url.from_relative(href, base_url)
61
+ type = if href.end_with?('.pdf')
62
+ Article::Enclosure.guess_content_type_from_url(abs)
63
+ else
64
+ 'application/zip'
65
+ end
66
+ { url: abs, type: }
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end