html2rss 0.21.0 → 0.22.1

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 (35) hide show
  1. checksums.yaml +4 -4
  2. data/lib/html2rss/auto_source/scraper/html/class_clustering.rb +196 -0
  3. data/lib/html2rss/auto_source/scraper/html.rb +14 -2
  4. data/lib/html2rss/auto_source/scraper/json_state.rb +1 -1
  5. data/lib/html2rss/auto_source/scraper/microdata.rb +16 -4
  6. data/lib/html2rss/auto_source/scraper/semantic_html/deduplicator.rb +1 -1
  7. data/lib/html2rss/auto_source/scraper/semantic_html.rb +28 -20
  8. data/lib/html2rss/auto_source.rb +6 -2
  9. data/lib/html2rss/cli.rb +61 -10
  10. data/lib/html2rss/config/class_methods.rb +3 -3
  11. data/lib/html2rss/config/validator.rb +1 -0
  12. data/lib/html2rss/config.rb +2 -2
  13. data/lib/html2rss/html_extractor/heading_extractor.rb +50 -0
  14. data/lib/html2rss/html_extractor/id_generator.rb +67 -0
  15. data/lib/html2rss/html_extractor/semantic_anchor_candidates.rb +4 -18
  16. data/lib/html2rss/html_extractor/semantic_containers.rb +28 -3
  17. data/lib/html2rss/html_extractor/text_extractor.rb +77 -0
  18. data/lib/html2rss/html_extractor.rb +78 -61
  19. data/lib/html2rss/html_navigator.rb +17 -0
  20. data/lib/html2rss/rendering.rb +2 -2
  21. data/lib/html2rss/request_service/local_file_strategy.rb +29 -0
  22. data/lib/html2rss/request_service/puppet_commander.rb +4 -0
  23. data/lib/html2rss/request_service.rb +2 -1
  24. data/lib/html2rss/selectors/extractors/attribute.rb +1 -1
  25. data/lib/html2rss/selectors/extractors/href.rb +1 -1
  26. data/lib/html2rss/selectors/extractors/html.rb +1 -1
  27. data/lib/html2rss/selectors/extractors/static.rb +1 -1
  28. data/lib/html2rss/selectors/extractors/text.rb +1 -1
  29. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +8 -1
  30. data/lib/html2rss/selectors.rb +9 -4
  31. data/lib/html2rss/url.rb +26 -13
  32. data/lib/html2rss/version.rb +1 -1
  33. data/lib/html2rss.rb +28 -6
  34. data/schema/html2rss-config.schema.json +20 -2
  35. metadata +7 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8168109d2cc60920d8a18b6b99970a5558e43163ad5cd11cb3d3f0d944d46943
4
- data.tar.gz: 833a936f89f9ce31c0b4fb0036020c7962a4ac77e0dfa72f1134a0bae8bea4c4
3
+ metadata.gz: 750b7fb967b328cef2238b66729cafe122d1bae23bee05fd8504bb31e760b8a7
4
+ data.tar.gz: 327406de9c7c97ea13e90c89bec1c2653c962bbcaccfd29ddb78b282477f7578
5
5
  SHA512:
6
- metadata.gz: 734f286a486d49c86ab7baf48d157cdee9d988fdc8b693ac7d79bf3c64c661fcd54538d5e94dc19bdc8a6f3021168c1ecac2d8e34417f56879392d71600c7340
7
- data.tar.gz: f008a767b452557cff1b45b1abb0eccb26f38d839417e95d21b8cf74f4546f9143b067e2d11f9fc00f5955c3f145f8933a1b1d4912ad25756c890280d4bb1a37
6
+ metadata.gz: 5f41e00edfdd19ceb012900db7518f28236b417662dc4f11f45d9c498ede0720bdbbc0fb31443d80495eaf6076df646062fdf7f972b27bd71c50cc4e198b4540
7
+ data.tar.gz: 7a7eff85bd7f98cd872131041aa58faf9d3fba1aff47893a6d286378b6f52904ee11120f053e3ca8beca3060d86d1438cf2037e3e4e7e1586d3d3c4679b026f0
@@ -0,0 +1,196 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ class AutoSource
5
+ module Scraper
6
+ class Html
7
+ ##
8
+ # ClassClustering clusters DOM elements on anchorless pages by class lists and scores
9
+ # candidate groups to find the best list of content cards/articles.
10
+ # rubocop:disable Metrics/ClassLength
11
+ class ClassClustering
12
+ # Node tags considered layout containers
13
+ LAYOUT_TAG_NAMES = Set['div', 'section', 'article', 'li', 'ul', 'ol'].freeze
14
+ # HTML/layout tags excluded from candidate nodes
15
+ EXCLUDED_TAGS = Set['html', 'body', 'nav', 'footer', 'header', 'svg', 'script', 'style'].freeze
16
+
17
+ class << self
18
+ ##
19
+ # Clusters elements in parsed_body and returns the best set of content card nodes.
20
+ #
21
+ # @param parsed_body [Nokogiri::HTML::Document] parsed HTML document
22
+ # @param minimum_selector_frequency [Integer] minimum frequency for class groups
23
+ # @return [Array<Nokogiri::XML::Node>] candidate nodes of the top-scoring class group
24
+ def call(parsed_body, minimum_selector_frequency:)
25
+ new(parsed_body, minimum_selector_frequency:).call
26
+ end
27
+ end
28
+
29
+ # @param parsed_body [Nokogiri::HTML::Document]
30
+ # @param minimum_selector_frequency [Integer]
31
+ def initialize(parsed_body, minimum_selector_frequency:)
32
+ @parsed_body = parsed_body
33
+ @minimum_frequency = minimum_selector_frequency
34
+ @text_words = {}.compare_by_identity
35
+ @has_date = {}.compare_by_identity
36
+ end
37
+
38
+ # @return [Array<Nokogiri::XML::Node>]
39
+ def call
40
+ candidate_groups = collect_candidate_groups
41
+ return [] if candidate_groups.empty?
42
+
43
+ non_containers = filter_containers(candidate_groups)
44
+ final_groups = filter_1_to_1_overlap(non_containers)
45
+
46
+ select_best_group(final_groups)
47
+ end
48
+
49
+ private
50
+
51
+ def collect_candidate_groups
52
+ class_groups = Hash.new { |h, k| h[k] = [] }
53
+ cache = {}.compare_by_identity
54
+
55
+ @parsed_body.css('[class]').each { |node| add_node_to_groups(node, class_groups, cache) }
56
+
57
+ class_groups.select { |_, nodes| nodes.size >= @minimum_frequency }
58
+ end
59
+
60
+ def add_node_to_groups(node, class_groups, cache)
61
+ return if EXCLUDED_TAGS.include?(node.name) || HtmlExtractor.ignored_container_path?(node, cache)
62
+
63
+ cls = normalize_class(node['class'])
64
+ class_groups[cls] << node unless cls.empty?
65
+ end
66
+
67
+ def normalize_class(class_attr)
68
+ class_str = class_attr.to_s.strip
69
+ return '' if class_str.empty?
70
+
71
+ # Bypass split/sort/join allocation for single-class lists
72
+ if class_str.include?(' ')
73
+ class_str.split(/\s+/).sort.join(' ')
74
+ else
75
+ class_str
76
+ end
77
+ end
78
+
79
+ # Discard group A if any node of A contains > 1 node of another group B
80
+ def filter_containers(groups)
81
+ groups.reject do |cls_a, nodes_a|
82
+ groups.any? { |cls_b, nodes_b| cls_a != cls_b && container_of?(nodes_a, nodes_b) }
83
+ end
84
+ end
85
+
86
+ # rubocop:disable Metrics/MethodLength
87
+ def container_of?(nodes_a, nodes_b)
88
+ return false unless LAYOUT_TAG_NAMES.include?(nodes_b.first.name)
89
+
90
+ nodes_a.any? do |node_a|
91
+ count = 0
92
+ nodes_b.each do |node_b|
93
+ next if node_a == node_b
94
+
95
+ if HtmlNavigator.descendant_of?(node_b, node_a)
96
+ count += 1
97
+ break if count > 1
98
+ end
99
+ end
100
+ count > 1
101
+ end
102
+ end
103
+ # rubocop:enable Metrics/MethodLength
104
+
105
+ # If group A contains group B, and they have the same size:
106
+ # - If B (the descendant) contains >= 80% of A's words, AND B's tag is div/section/article,
107
+ # B is the actual content card. Discard A.
108
+ # - Otherwise, B is a sub-element (header, metadata line, button). Discard B.
109
+ def filter_1_to_1_overlap(groups)
110
+ discarded = Set.new
111
+ groups.each_key do |cls_a|
112
+ groups.each_key do |cls_b|
113
+ next if cls_a == cls_b || discarded.include?(cls_a) || discarded.include?(cls_b)
114
+
115
+ resolve_1_to_1_overlap(cls_a, cls_b, groups, discarded)
116
+ end
117
+ end
118
+
119
+ groups.except(*discarded)
120
+ end
121
+
122
+ def resolve_1_to_1_overlap(cls_a, cls_b, groups, discarded)
123
+ nodes_a = groups[cls_a]
124
+ nodes_b = groups[cls_b]
125
+ return if nodes_a.size != nodes_b.size
126
+ return unless nodes_a.zip(nodes_b).all? { |a, b| a != b && HtmlNavigator.descendant_of?(b, a) }
127
+
128
+ discarded << (keep_descendant?(nodes_a, nodes_b) ? cls_a : cls_b)
129
+ end
130
+
131
+ def keep_descendant?(nodes_a, nodes_b)
132
+ avg_words(nodes_b) >= 0.8 * avg_words(nodes_a) &&
133
+ LAYOUT_TAG_NAMES.include?(nodes_b.first.name)
134
+ end
135
+
136
+ def select_best_group(groups)
137
+ best_nodes = []
138
+ best_score = -1
139
+
140
+ groups.each_value do |nodes|
141
+ score = score_group(nodes)
142
+ next if score.negative?
143
+
144
+ (best_nodes = nodes) && (best_score = score) if score > best_score
145
+ end
146
+
147
+ best_nodes
148
+ end
149
+
150
+ def score_group(nodes)
151
+ avg_w = avg_words(nodes)
152
+ return -1 if avg_w < 5
153
+
154
+ score = nodes.size + (avg_w / 5.0)
155
+ score += 20 if nodes_heading?(nodes)
156
+ score += 20 if nodes_time?(nodes)
157
+ score += 40 if nodes_date?(nodes)
158
+ score
159
+ end
160
+
161
+ def nodes_heading?(nodes)
162
+ nodes.any? do |n|
163
+ n.at_css(HtmlExtractor::HEADING_TAGS.join(',')) ||
164
+ n.at_css('.font-bold, .font-semibold')
165
+ end
166
+ end
167
+
168
+ def nodes_time?(nodes)
169
+ nodes.any? { |n| n.at_css('time, [datetime]') }
170
+ end
171
+
172
+ def nodes_date?(nodes)
173
+ nodes.any? { |n| date?(n) }
174
+ end
175
+
176
+ def avg_words(nodes)
177
+ nodes.sum { |n| text_words(n) } / nodes.size.to_f
178
+ end
179
+
180
+ def text_words(node)
181
+ @text_words[node] ||= HtmlExtractor.extract_visible_text(node).to_s.scan(/\p{Alnum}+/).size
182
+ end
183
+
184
+ def date?(node)
185
+ @has_date[node] ||= begin
186
+ text = HtmlExtractor.extract_visible_text(node).to_s
187
+ text.match?(%r{\b\d{4}[-/]\d{2}[-/]\d{2}\b}) ||
188
+ text.match?(/\b(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\b/i)
189
+ end
190
+ end
191
+ # rubocop:enable Metrics/ClassLength
192
+ end
193
+ end
194
+ end
195
+ end
196
+ end
@@ -62,6 +62,7 @@ module Html2rss
62
62
  @url = url
63
63
  @extractor = extractor
64
64
  @opts = opts
65
+ @fallback_anchorless = opts.fetch(:fallback_anchorless, false)
65
66
  @link_heuristics = LinkHeuristics.new(url)
66
67
  @ignored_cache = {}.compare_by_identity
67
68
  end
@@ -105,8 +106,19 @@ module Html2rss
105
106
  private
106
107
 
107
108
  def articles
108
- @articles ||= each_article_tag.filter_map do |article_tag, selected_anchor|
109
- extract_article(article_tag, selected_anchor:)
109
+ @articles ||= begin
110
+ extracted = each_article_tag.filter_map do |article_tag, selected_anchor|
111
+ extract_article(article_tag, selected_anchor:)
112
+ end
113
+
114
+ extracted += find_anchorless_articles if @fallback_anchorless
115
+ extracted
116
+ end
117
+ end
118
+
119
+ def find_anchorless_articles
120
+ ClassClustering.call(parsed_body, minimum_selector_frequency:).map do |node|
121
+ @extractor.new(node, base_url: @url, selected_anchor: nil, fallback_anchorless: true).call
110
122
  end
111
123
  end
112
124
 
@@ -305,7 +305,7 @@ module Html2rss
305
305
  # rubocop:disable Metrics/MethodLength
306
306
  # @param entry [Hash] raw article entry candidate
307
307
  # @param base_url [String, Html2rss::Url] base URL for relative link resolution
308
- # @return [Hash{Symbol => Object}, nil] normalized article hash for downstream extraction
308
+ # @return [Hash{Symbol => Object, nil}] normalized article hash for downstream extraction
309
309
  def normalise(entry, base_url:)
310
310
  return unless entry.is_a?(Hash)
311
311
 
@@ -55,7 +55,13 @@ module Html2rss
55
55
  def top_level_item?(node)
56
56
  return false if node.attribute('itemprop')
57
57
 
58
- node.ancestors.none? { |ancestor| ancestor.attribute('itemscope') && ancestor.attribute('itemprop') }
58
+ curr = node.parent
59
+ while curr && !curr.document? && curr.name != 'html'
60
+ return false if curr.attribute('itemscope') && curr.attribute('itemprop')
61
+
62
+ curr = curr.parent
63
+ end
64
+ true
59
65
  end
60
66
  end
61
67
 
@@ -92,7 +98,7 @@ module Html2rss
92
98
  attr_reader :parsed_body, :url
93
99
 
94
100
  # @param root [Nokogiri::XML::Element] supported Microdata root node
95
- # @return [Hash{Symbol => Object}, nil] normalized article hash
101
+ # @return [Hash{Symbol => Object, nil}] normalized article hash
96
102
  def article_from(root)
97
103
  schema_object = SchemaObjectBuilder.call(root)
98
104
  return unless schema_object
@@ -147,7 +153,13 @@ module Html2rss
147
153
  def direct_property?(root, node)
148
154
  return false if node == root
149
155
 
150
- node.ancestors.take_while { _1 != root }.none? { |ancestor| ancestor.attribute('itemscope') }
156
+ curr = node.parent
157
+ while curr && curr != root
158
+ return false if curr.attribute('itemscope')
159
+
160
+ curr = curr.parent
161
+ end
162
+ true
151
163
  end
152
164
 
153
165
  # @param node [Nokogiri::XML::Element] itemprop node
@@ -378,7 +390,7 @@ module Html2rss
378
390
  extend ValueNormalizer
379
391
 
380
392
  # @param root [Nokogiri::XML::Element] supported microdata root node
381
- # @return [Hash{Symbol => Object}, nil] compact schema-like object
393
+ # @return [Hash{Symbol => Object, nil}] compact schema-like object
382
394
  def call(root)
383
395
  type = Microdata.supported_type_name(root)
384
396
  return unless type
@@ -37,7 +37,7 @@ module Html2rss
37
37
 
38
38
  @article_cache.fetch(entry) do
39
39
  @article_cache[entry] = @extractor.new(
40
- entry.container, base_url: @url, selected_anchor: entry.selected_anchor
40
+ entry.container, base_url: @url, selected_anchor: entry.selected_anchor, fallback_anchorless: true
41
41
  ).call
42
42
  end
43
43
  end
@@ -60,12 +60,13 @@ module Html2rss
60
60
  # @param parsed_body [Nokogiri::HTML::Document] parsed HTML document
61
61
  # @param url [String, Html2rss::Url] base url
62
62
  # @param extractor [Class] extractor class used for article extraction
63
- # @param _opts [Hash] scraper-specific options
64
- # @option _opts [Object] :_reserved reserved for future scraper-specific options
65
- def initialize(parsed_body, url:, extractor: HtmlExtractor, **_opts)
63
+ # @param opts [Hash] scraper-specific options
64
+ # @option opts [Boolean] :fallback_anchorless whether to extract anchorless blocks
65
+ def initialize(parsed_body, url:, extractor: HtmlExtractor, **opts)
66
66
  @parsed_body = parsed_body
67
67
  @url = url
68
68
  @extractor = extractor
69
+ @fallback_anchorless = opts.fetch(:fallback_anchorless, false)
69
70
  @link_heuristics = LinkHeuristics.new(url)
70
71
  @anchor_selector = AnchorSelector.new(url)
71
72
  end
@@ -107,14 +108,15 @@ module Html2rss
107
108
  @anchor_selector.primary_anchor_for(container)
108
109
  end
109
110
 
110
- def extractable_entries # rubocop:disable Metrics/MethodLength
111
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
112
+ def extractable_entries
111
113
  @extractable_entries ||= candidate_containers.filter_map do |container|
112
114
  selected_anchor = primary_anchor_for(container)
113
115
 
114
- next unless selected_anchor
116
+ next unless selected_anchor || @fallback_anchorless
115
117
 
116
- destination_facts = normalized_destination(selected_anchor)
117
- next unless destination_facts
118
+ destination_facts = selected_anchor ? normalized_destination(selected_anchor) : nil
119
+ next if selected_anchor && !destination_facts
118
120
  next if hard_junk_entry?(container, selected_anchor, destination_facts)
119
121
 
120
122
  quality = quality_score(container, selected_anchor, destination_facts)
@@ -132,6 +134,7 @@ module Html2rss
132
134
  )
133
135
  end
134
136
  end
137
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
135
138
 
136
139
  # rubocop:disable Metrics/MethodLength
137
140
  def ranked_entries
@@ -177,8 +180,8 @@ module Html2rss
177
180
 
178
181
  score += 40 if words >= 3
179
182
  score += 15 if words >= 7
180
- score += 20 if destination_facts.url.path.to_s.length > 6
181
- score += 15 if destination_facts.content_path
183
+ score += 20 if destination_facts&.url&.path.to_s.length > 6
184
+ score += 15 if destination_facts&.content_path
182
185
  score += 15 if publish_marker?(container)
183
186
  score += 10 if descriptive_context?(container_text, title)
184
187
  score += 10 if article_container?(container)
@@ -190,12 +193,12 @@ module Html2rss
190
193
  title = entry_title(container, selected_anchor)
191
194
  utility_text = @link_heuristics.utility_prefix_text?(title)
192
195
  recommended_text = @link_heuristics.recommended_text?(title)
193
- content_signal = destination_facts.content_path
196
+ content_signal = destination_facts&.content_path
194
197
  no_content_signal = !content_signal
195
198
  non_content_utility_path =
196
- destination_facts.utility_path &&
199
+ destination_facts&.utility_path &&
197
200
  no_content_signal &&
198
- !destination_facts.strong_post_suffix
201
+ !destination_facts&.strong_post_suffix
199
202
  publish_signal = publish_marker?(container)
200
203
  descriptive_signal = descriptive_context?(visible_text(container), title)
201
204
  weak_container = !publish_signal && !descriptive_signal
@@ -203,19 +206,20 @@ module Html2rss
203
206
 
204
207
  score += 25 if non_content_utility_path
205
208
  score += 15 if utility_text && word_count(title) <= 6
206
- score += 10 if destination_facts.shallow
209
+ score += 10 if destination_facts&.shallow
207
210
  score += 10 if weak_container
208
211
  score += 10 if recommended_text && no_content_signal
209
- score += 5 if destination_facts.high_confidence_junk_path
212
+ score += 5 if destination_facts&.high_confidence_junk_path
210
213
  score += 15 if junk_tokens?(container_tokens(container))
211
214
  score
212
215
  end
213
216
 
214
- def hard_junk_entry?(container, selected_anchor, destination_facts) # rubocop:disable Metrics/MethodLength
217
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
218
+ def hard_junk_entry?(container, selected_anchor, destination_facts)
215
219
  title = entry_title(container, selected_anchor)
216
220
  publish_signal = publish_marker?(container)
217
221
  descriptive_signal = descriptive_context?(visible_text(container), title)
218
- content_signal = destination_facts.content_path
222
+ content_signal = destination_facts&.content_path
219
223
  weak_article_candidate = article_signal_count(
220
224
  container,
221
225
  publish_signal:,
@@ -223,12 +227,16 @@ module Html2rss
223
227
  content_signal:
224
228
  ) < 2
225
229
 
226
- destination_facts.high_confidence_junk_path ||
227
- (@link_heuristics.recommended_text?(title) && destination_facts.shallow && weak_article_candidate) ||
228
- (@link_heuristics.utility_prefix_text?(title) &&
229
- destination_facts.high_confidence_utility_destination &&
230
+ destination_facts&.high_confidence_junk_path ||
231
+ (selected_anchor &&
232
+ @link_heuristics.recommended_text?(title) &&
233
+ destination_facts&.shallow &&
234
+ weak_article_candidate) ||
235
+ (selected_anchor && @link_heuristics.utility_prefix_text?(title) &&
236
+ destination_facts&.high_confidence_utility_destination &&
230
237
  weak_article_candidate)
231
238
  end
239
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
232
240
 
233
241
  ##
234
242
  # @param container [Nokogiri::XML::Node]
@@ -32,12 +32,14 @@ module Html2rss
32
32
  enabled: true
33
33
  },
34
34
  semantic_html: {
35
- enabled: true
35
+ enabled: true,
36
+ fallback_anchorless: true
36
37
  },
37
38
  html: {
38
39
  enabled: true,
39
40
  minimum_selector_frequency: Scraper::Html::DEFAULT_MINIMUM_SELECTOR_FREQUENCY,
40
- use_top_selectors: Scraper::Html::DEFAULT_USE_TOP_SELECTORS
41
+ use_top_selectors: Scraper::Html::DEFAULT_USE_TOP_SELECTORS,
42
+ fallback_anchorless: true
41
43
  }
42
44
  },
43
45
  cleanup: Cleanup::DEFAULT_CONFIG
@@ -58,11 +60,13 @@ module Html2rss
58
60
  end
59
61
  optional(:semantic_html).hash do
60
62
  optional(:enabled).filled(:bool)
63
+ optional(:fallback_anchorless).filled(:bool)
61
64
  end
62
65
  optional(:html).hash do
63
66
  optional(:enabled).filled(:bool)
64
67
  optional(:minimum_selector_frequency).filled(:integer, gt?: 0)
65
68
  optional(:use_top_selectors).filled(:integer, gt?: 0)
69
+ optional(:fallback_anchorless).filled(:bool)
66
70
  end
67
71
  end.freeze
68
72
  private_constant :SCRAPER_CONFIG
data/lib/html2rss/cli.rb CHANGED
@@ -48,6 +48,9 @@ module Html2rss
48
48
  method_option :max_requests,
49
49
  type: :numeric,
50
50
  desc: 'Maximum requests to allow for this feed build'
51
+ method_option :input,
52
+ type: :string,
53
+ desc: 'Local HTML file path to read input from'
51
54
  # @param yaml_file [String] path to YAML config
52
55
  # @param feed_name [String, nil] optional named feed in multi-feed config
53
56
  # @return [void]
@@ -55,6 +58,7 @@ module Html2rss
55
58
  config = Html2rss.config_from_yaml_file(yaml_file, feed_name)
56
59
  config[:params] = options[:params] || {}
57
60
  apply_runtime_request_overrides!(config)
61
+ apply_local_file_input!(config, options[:input]) if options[:input]
58
62
 
59
63
  puts(execute_feed { Html2rss.feed(config) })
60
64
  end
@@ -76,20 +80,17 @@ module Html2rss
76
80
  method_option :max_requests,
77
81
  type: :numeric,
78
82
  desc: 'Maximum requests to allow for this feed build'
79
- # @param url [String] source page URL for auto discovery
83
+ method_option :input,
84
+ type: :string,
85
+ desc: 'Local HTML file path to read input from'
86
+ # @param url [String, nil] source page URL for auto discovery
80
87
  # @return [void]
81
- def auto(url) # rubocop:disable Metrics/MethodLength
88
+ def auto(url = nil)
82
89
  format = options.fetch(:format, 'rss')
83
- source_method = format == 'jsonfeed' ? Html2rss.method(:auto_json_feed) : Html2rss.method(:auto_source)
90
+ strategy, local_file_path, url = prepare_auto_inputs(url, options[:input])
84
91
 
85
92
  result = execute_feed do
86
- source_method.call(
87
- url,
88
- strategy: current_strategy,
89
- items_selector: options[:items_selector],
90
- max_redirects: options[:max_redirects],
91
- max_requests: options[:max_requests]
92
- )
93
+ source_call(url, strategy, local_file_path, format == 'jsonfeed')
93
94
  end
94
95
 
95
96
  puts(format == 'jsonfeed' ? JSON.pretty_generate(result) : result)
@@ -159,6 +160,33 @@ module Html2rss
159
160
  config.delete(:request) if request_config.empty?
160
161
  end
161
162
 
163
+ def apply_local_file_input!(config, input_path)
164
+ file_path = check_file_exists!(input_path)
165
+ config[:strategy] = :local_file
166
+ config[:request] = (config[:request] || {}).merge(local_file_path: file_path)
167
+
168
+ return unless config.dig(:channel, :url).to_s.empty?
169
+
170
+ config[:channel] = (config[:channel] || {}).merge(
171
+ url: detect_base_url!(file_path, 'Please specify a channel.url in the config.')
172
+ )
173
+ end
174
+
175
+ def prepare_auto_inputs(url, input_option)
176
+ if input_option.nil?
177
+ raise Thor::Error, 'A URL is required unless --input is specified' unless url
178
+
179
+ return [current_strategy, nil, url]
180
+ end
181
+
182
+ file_path = check_file_exists!(input_option)
183
+ detected_url = url || detect_base_url!(
184
+ file_path, 'Please specify a URL: html2rss auto [URL] --input <file>'
185
+ )
186
+
187
+ [:local_file, file_path, detected_url]
188
+ end
189
+
162
190
  def request_controls
163
191
  Html2rss::RequestControls.new(
164
192
  strategy: options[:strategy]&.to_sym,
@@ -213,5 +241,28 @@ module Html2rss
213
241
  Html2rss::NoFeedItemsExtracted => error
214
242
  raise Thor::Error, error.message
215
243
  end
244
+
245
+ def source_call(url, strategy, local_file_path, is_json)
246
+ method = is_json ? Html2rss.method(:auto_json_feed) : Html2rss.method(:auto_source)
247
+ method.call(
248
+ url,
249
+ strategy:,
250
+ items_selector: options[:items_selector],
251
+ max_redirects: options[:max_redirects],
252
+ max_requests: options[:max_requests],
253
+ local_file_path:
254
+ )
255
+ end
256
+
257
+ def check_file_exists!(path)
258
+ File.expand_path(path).tap do |file_path|
259
+ raise Thor::Error, "Input file does not exist: #{path}" unless File.exist?(file_path)
260
+ end
261
+ end
262
+
263
+ def detect_base_url!(file_path, error_hint)
264
+ Html2rss::Url.extract_from_html(File.read(file_path))&.to_s ||
265
+ raise(Thor::Error, "Could not auto-detect a base URL from HTML metadata. #{error_hint}")
266
+ end
216
267
  end
217
268
  end
@@ -29,7 +29,7 @@ module Html2rss
29
29
  # Validates a configuration hash with the runtime validator.
30
30
  #
31
31
  # @param config [Hash{Symbol => Object}] the configuration hash
32
- # @param params [Hash{Symbol => Object}, Hash{String => Object}, nil] dynamic parameters for string formatting
32
+ # @param params [Hash{Symbol => Object, Hash{String => Object, nil}}] dynamic parameters for string formatting
33
33
  # @return [Dry::Validation::Result] validation result after defaults and deprecations are applied
34
34
  def validate(config, params: UNSET)
35
35
  prepared_config = prepare_for_validation(resolve_effective_config(config, params:))
@@ -56,7 +56,7 @@ module Html2rss
56
56
  # @param file [String] the YAML file to load
57
57
  # @param feed_name [String, nil] optional feed name for multi-feed files
58
58
  # @param multiple_feeds_key [Symbol] key under which multiple feeds are defined
59
- # @param params [Hash{Symbol => Object}, Hash{String => Object}, nil] dynamic parameters for string formatting
59
+ # @param params [Hash{Symbol => Object, Hash{String => Object, nil}}] dynamic parameters for string formatting
60
60
  # @return [Dry::Validation::Result] validation result after defaults and deprecations are applied
61
61
  def validate_yaml(file, feed_name = nil, multiple_feeds_key: MultipleFeedsConfig::CONFIG_KEY_FEEDS, params: UNSET)
62
62
  validate(load_yaml(file, feed_name, multiple_feeds_key:), params:)
@@ -99,7 +99,7 @@ module Html2rss
99
99
  # and returns a new configuration object.
100
100
  #
101
101
  # @param config [Hash{Symbol => Object}] the configuration hash.
102
- # @param params [Hash{Symbol => Object}, Hash{String => Object}, nil] dynamic parameters for string formatting.
102
+ # @param params [Hash{Symbol => Object, Hash{String => Object, nil}}] dynamic parameters for string formatting.
103
103
  # @return [Html2rss::Config] the configuration object.
104
104
  def from_hash(config, params: UNSET)
105
105
  new(resolve_effective_config(config, params:))
@@ -83,6 +83,7 @@ module Html2rss
83
83
  optional(:total_timeout_seconds).filled(:integer, gt?: 0)
84
84
  optional(:browserless).hash(BrowserlessRequestConfig)
85
85
  optional(:botasaurus).hash(BotasaurusRequestConfig)
86
+ optional(:local_file_path).filled(:string)
86
87
  end
87
88
 
88
89
  params do
@@ -69,9 +69,9 @@ module Html2rss
69
69
  # @return [Hash{Symbol => Object}] request envelope configuration
70
70
  def request = config[:request]
71
71
 
72
- # @return [Hash{Symbol => Object}, nil] selectors configuration
72
+ # @return [Hash{Symbol => Object, nil}] selectors configuration
73
73
  def selectors = config[:selectors]
74
- # @return [Hash{Symbol => Object}, nil] auto-source configuration
74
+ # @return [Hash{Symbol => Object, nil}] auto-source configuration
75
75
  def auto_source = config[:auto_source]
76
76
 
77
77
  private
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ class HtmlExtractor
5
+ ##
6
+ # HeadingExtractor identifies and returns the best heading element within a container.
7
+ class HeadingExtractor
8
+ # Heading tags used to prioritize title extraction.
9
+ HEADING_TAGS = HtmlExtractor::HEADING_TAGS
10
+
11
+ class << self
12
+ ##
13
+ # @param article_tag [Nokogiri::XML::Element] container node
14
+ # @param fallback_anchorless [Boolean] whether to use fallback search
15
+ # @param selected_anchor [Nokogiri::XML::Node, nil] anchor element
16
+ # @return [Nokogiri::XML::Node, nil] the heading node, if found
17
+ def call(article_tag, fallback_anchorless:, selected_anchor:)
18
+ tags = article_tag.css(HEADING_TAGS.join(','))
19
+ if tags.any?
20
+ select_best_heading(tags)
21
+ elsif fallback_anchorless && selected_anchor.nil?
22
+ fallback_heading(article_tag)
23
+ end
24
+ end
25
+
26
+ private
27
+
28
+ def select_best_heading(tags)
29
+ min_tag_name = tags.map(&:name).min
30
+ best_tag = nil
31
+ max_size = -1
32
+
33
+ tags.each do |tag|
34
+ next if tag.name != min_tag_name
35
+
36
+ size = TextExtractor.call(tag)&.size.to_i
37
+ (best_tag = tag) && (max_size = size) if size > max_size
38
+ end
39
+
40
+ best_tag
41
+ end
42
+
43
+ def fallback_heading(article_tag)
44
+ fallback_tags = article_tag.css('strong, b, [class*="title"], [class*="font-bold"], [class*="font-semibold"]')
45
+ fallback_tags.find { |t| !TextExtractor.call(t).to_s.strip.empty? }
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end