html2rss 0.22.2 → 0.24.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 (146) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +11 -108
  3. data/lib/html2rss/{articles → article}/deduplicator.rb +3 -4
  4. data/lib/html2rss/{rss_builder → article}/enclosure.rb +6 -22
  5. data/lib/html2rss/article.rb +213 -0
  6. data/lib/html2rss/auto_source/discovery/dom_clustering/group_scorer.rb +80 -0
  7. data/lib/html2rss/auto_source/discovery/dom_clustering/overlap_resolver.rb +86 -0
  8. data/lib/html2rss/auto_source/discovery/dom_clustering.rb +119 -0
  9. data/lib/html2rss/auto_source/discovery/list_candidates.rb +94 -0
  10. data/lib/html2rss/auto_source/discovery/semantic_anchor_candidates.rb +219 -0
  11. data/lib/html2rss/auto_source/discovery/semantic_containers.rb +71 -0
  12. data/lib/html2rss/auto_source/discovery/sitemap.rb +159 -0
  13. data/lib/html2rss/auto_source/discovery.rb +14 -0
  14. data/lib/html2rss/auto_source/link_heuristics/anchor_signals.rb +28 -0
  15. data/lib/html2rss/auto_source/link_heuristics/container_assessor.rb +106 -0
  16. data/lib/html2rss/auto_source/link_heuristics/container_signals.rb +80 -0
  17. data/lib/html2rss/auto_source/link_heuristics/destination_facts.rb +42 -0
  18. data/lib/html2rss/auto_source/link_heuristics/href_extractor.rb +38 -0
  19. data/lib/html2rss/auto_source/link_heuristics/path_classifier.rb +221 -0
  20. data/lib/html2rss/auto_source/link_heuristics/text_classifier.rb +66 -0
  21. data/lib/html2rss/auto_source/link_heuristics.rb +139 -0
  22. data/lib/html2rss/auto_source/scraper/html.rb +22 -33
  23. data/lib/html2rss/auto_source/scraper/json_state.rb +1 -1
  24. data/lib/html2rss/auto_source/scraper/meta_oembed.rb +163 -0
  25. data/lib/html2rss/auto_source/scraper/microformats2.rb +146 -0
  26. data/lib/html2rss/auto_source/scraper/schema/category_extractor.rb +29 -114
  27. data/lib/html2rss/auto_source/scraper/schema/item_list.rb +50 -11
  28. data/lib/html2rss/auto_source/scraper/schema/thing.rb +35 -7
  29. data/lib/html2rss/auto_source/scraper/schema.rb +61 -18
  30. data/lib/html2rss/auto_source/scraper/semantic_html/{deduplicator.rb → entry_deduplicator.rb} +3 -3
  31. data/lib/html2rss/auto_source/scraper/semantic_html.rb +23 -171
  32. data/lib/html2rss/auto_source/scraper/sitemap.rb +153 -0
  33. data/lib/html2rss/auto_source/scraper/wordpress_api/page_scope/date_archive_range.rb +110 -0
  34. data/lib/html2rss/auto_source/scraper/wordpress_api/page_scope.rb +2 -78
  35. data/lib/html2rss/auto_source/scraper/wordpress_api.rb +9 -0
  36. data/lib/html2rss/auto_source/scraper.rb +10 -3
  37. data/lib/html2rss/auto_source.rb +30 -35
  38. data/lib/html2rss/channel.rb +198 -0
  39. data/lib/html2rss/cli.rb +5 -6
  40. data/lib/html2rss/config/auto_source_contract.rb +51 -0
  41. data/lib/html2rss/config/request_controls.rb +136 -0
  42. data/lib/html2rss/config/request_headers.rb +1 -1
  43. data/lib/html2rss/config/schema.rb +65 -114
  44. data/lib/html2rss/{selectors/config.rb → config/selectors_validator.rb} +56 -7
  45. data/lib/html2rss/config/validator.rb +6 -6
  46. data/lib/html2rss/config.rb +176 -19
  47. data/lib/html2rss/{configuration.rb → defaults.rb} +12 -10
  48. data/lib/html2rss/feed_builder/item_presentation.rb +42 -0
  49. data/lib/html2rss/feed_builder/json_feed/item.rb +112 -0
  50. data/lib/html2rss/feed_builder/json_feed.rb +81 -0
  51. data/lib/html2rss/feed_builder/rss/stylesheet.rb +65 -0
  52. data/lib/html2rss/feed_builder/rss.rb +116 -0
  53. data/lib/html2rss/feed_builder.rb +15 -0
  54. data/lib/html2rss/feed_pipeline/auto_fallback.rb +120 -60
  55. data/lib/html2rss/feed_pipeline/runtime_policy.rb +108 -0
  56. data/lib/html2rss/feed_pipeline/strategy_plan.rb +64 -0
  57. data/lib/html2rss/feed_pipeline.rb +83 -89
  58. data/lib/html2rss/feed_result.rb +92 -0
  59. data/lib/html2rss/html/article_extractor/category_extractor.rb +123 -0
  60. data/lib/html2rss/html/article_extractor/date_extractor.rb +22 -0
  61. data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +95 -0
  62. data/lib/html2rss/html/article_extractor/heading_extractor.rb +54 -0
  63. data/lib/html2rss/html/article_extractor/id_generator.rb +69 -0
  64. data/lib/html2rss/html/article_extractor/image_extractor.rb +60 -0
  65. data/lib/html2rss/html/article_extractor.rb +134 -0
  66. data/lib/html2rss/html/navigator/text_extractor.rb +79 -0
  67. data/lib/html2rss/html/navigator.rb +146 -0
  68. data/lib/html2rss/html/rendering/audio_renderer.rb +38 -0
  69. data/lib/html2rss/html/rendering/description_builder.rb +89 -0
  70. data/lib/html2rss/html/rendering/image_renderer.rb +43 -0
  71. data/lib/html2rss/html/rendering/media_renderer.rb +39 -0
  72. data/lib/html2rss/html/rendering/pdf_renderer.rb +36 -0
  73. data/lib/html2rss/html/rendering/video_renderer.rb +38 -0
  74. data/lib/html2rss/html/rendering.rb +25 -0
  75. data/lib/html2rss/html.rb +8 -0
  76. data/lib/html2rss/request_service/blocked_surface.rb +67 -0
  77. data/lib/html2rss/request_service/botasaurus_contract.rb +33 -9
  78. data/lib/html2rss/request_service/botasaurus_strategy.rb +21 -30
  79. data/lib/html2rss/request_service/browserless_strategy.rb +2 -23
  80. data/lib/html2rss/request_service/budget.rb +97 -18
  81. data/lib/html2rss/request_service/context.rb +1 -3
  82. data/lib/html2rss/request_service/faraday_strategy.rb +12 -26
  83. data/lib/html2rss/request_service/local_file_strategy.rb +12 -4
  84. data/lib/html2rss/request_service/network_guard.rb +125 -0
  85. data/lib/html2rss/request_service/policy.rb +10 -82
  86. data/lib/html2rss/request_service/puppet_commander/navigation_guards.rb +148 -0
  87. data/lib/html2rss/request_service/puppet_commander/preload_runner.rb +86 -0
  88. data/lib/html2rss/request_service/puppet_commander.rb +22 -191
  89. data/lib/html2rss/request_service/response.rb +9 -1
  90. data/lib/html2rss/request_service/response_guard.rb +1 -1
  91. data/lib/html2rss/request_service/strategy.rb +123 -5
  92. data/lib/html2rss/request_service.rb +7 -2
  93. data/lib/html2rss/request_session/pager/base.rb +83 -0
  94. data/lib/html2rss/request_session/pager/custom_selector.rb +39 -0
  95. data/lib/html2rss/request_session/pager/json_cursor.rb +78 -0
  96. data/lib/html2rss/request_session/pager/offset.rb +48 -0
  97. data/lib/html2rss/request_session/pager/rel_next.rb +23 -0
  98. data/lib/html2rss/request_session/pager/url_template.rb +48 -0
  99. data/lib/html2rss/request_session/pager.rb +75 -0
  100. data/lib/html2rss/request_session.rb +29 -16
  101. data/lib/html2rss/selectors/item_scope.rb +30 -0
  102. data/lib/html2rss/selectors/post_processors/base.rb +1 -1
  103. data/lib/html2rss/selectors/post_processors/markdown_to_html.rb +0 -1
  104. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +0 -2
  105. data/lib/html2rss/selectors/post_processors/template.rb +7 -3
  106. data/lib/html2rss/selectors.rb +84 -64
  107. data/lib/html2rss/status.rb +132 -0
  108. data/lib/html2rss/version.rb +1 -3
  109. data/lib/html2rss.rb +34 -22
  110. data/schema/html2rss-config.schema.json +109 -9
  111. metadata +71 -43
  112. data/lib/html2rss/auto_source/scraper/html/class_clustering.rb +0 -196
  113. data/lib/html2rss/auto_source/scraper/link_heuristics.rb +0 -401
  114. data/lib/html2rss/auto_source/scraper/schema/list_item.rb +0 -28
  115. data/lib/html2rss/auto_source/scraper/semantic_html/anchor_selector.rb +0 -49
  116. data/lib/html2rss/blocked_surface.rb +0 -65
  117. data/lib/html2rss/category_extractor.rb +0 -116
  118. data/lib/html2rss/config/class_methods.rb +0 -183
  119. data/lib/html2rss/html_extractor/date_extractor.rb +0 -20
  120. data/lib/html2rss/html_extractor/enclosure_extractor.rb +0 -91
  121. data/lib/html2rss/html_extractor/heading_extractor.rb +0 -50
  122. data/lib/html2rss/html_extractor/id_generator.rb +0 -67
  123. data/lib/html2rss/html_extractor/image_extractor.rb +0 -58
  124. data/lib/html2rss/html_extractor/list_candidates.rb +0 -88
  125. data/lib/html2rss/html_extractor/semantic_anchor_candidates.rb +0 -260
  126. data/lib/html2rss/html_extractor/semantic_containers.rb +0 -69
  127. data/lib/html2rss/html_extractor/text_extractor.rb +0 -77
  128. data/lib/html2rss/html_extractor.rb +0 -190
  129. data/lib/html2rss/html_navigator.rb +0 -71
  130. data/lib/html2rss/json_feed_builder/item.rb +0 -94
  131. data/lib/html2rss/json_feed_builder.rb +0 -59
  132. data/lib/html2rss/rendering/audio_renderer.rb +0 -36
  133. data/lib/html2rss/rendering/description_builder.rb +0 -87
  134. data/lib/html2rss/rendering/image_renderer.rb +0 -41
  135. data/lib/html2rss/rendering/media_renderer.rb +0 -37
  136. data/lib/html2rss/rendering/pdf_renderer.rb +0 -34
  137. data/lib/html2rss/rendering/video_renderer.rb +0 -36
  138. data/lib/html2rss/rendering.rb +0 -23
  139. data/lib/html2rss/request_controls.rb +0 -133
  140. data/lib/html2rss/request_session/rel_next_pager.rb +0 -70
  141. data/lib/html2rss/request_session/runtime_input.rb +0 -71
  142. data/lib/html2rss/request_session/runtime_policy.rb +0 -84
  143. data/lib/html2rss/rss_builder/article.rb +0 -208
  144. data/lib/html2rss/rss_builder/channel.rb +0 -118
  145. data/lib/html2rss/rss_builder/stylesheet.rb +0 -63
  146. data/lib/html2rss/rss_builder.rb +0 -101
@@ -18,7 +18,9 @@ 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)
21
+ # When built via {ItemScope#context_for}, +item_scope+ carries the per-item
22
+ # extraction base_url for nested selects (e.g. Template).
23
+ Context = Struct.new('Context', :options, :item, :config, :scraper, :item_scope, keyword_init: true)
22
24
 
23
25
  # Default selectors options merged into user configuration.
24
26
  DEFAULT_CONFIG = { items: { enhance: true } }.freeze
@@ -29,9 +31,11 @@ module Html2rss
29
31
  ITEM_TAGS = %i[title url description author comments published_at guid enclosure categories].freeze
30
32
  # Item attributes that require dedicated extraction logic.
31
33
  SPECIAL_ATTRIBUTES = Set[:guid, :enclosure, :categories].freeze
32
-
33
- # Mapping of new attribute names to their legacy names for backward compatibility.
34
- RENAMED_ATTRIBUTES = { published_at: %i[updated pubDate] }.freeze
34
+ # Config selector keys that map onto a different {Article} attribute.
35
+ # +:enclosure+ stays singular in YAML; Article stores +:enclosures+.
36
+ SELECTOR_TO_ARTICLE_KEY = { enclosure: :enclosures }.freeze
37
+ # Selector keys that may be copied onto an Article (PROVIDED_KEYS + mapped aliases).
38
+ SELECTABLE_SELECTOR_KEYS = (Html2rss::Article::PROVIDED_KEYS + SELECTOR_TO_ARTICLE_KEY.keys).to_set.freeze
35
39
 
36
40
  ##
37
41
  # Initializes a new Selectors instance.
@@ -46,14 +50,14 @@ module Html2rss
46
50
  @time_zone = time_zone
47
51
 
48
52
  prepare_selectors!
49
- @rss_item_attributes = @selectors.keys & Html2rss::RssBuilder::Article::PROVIDED_KEYS
53
+ @rss_item_attributes = @selectors.keys.select { |key| SELECTABLE_SELECTOR_KEYS.include?(key) }
50
54
  end
51
55
 
52
56
  ##
53
57
  # Returns articles extracted from the response.
54
58
  # Reverses order if config specifies reverse ordering.
55
59
  #
56
- # @return [Array<Html2rss::RssBuilder::Article>]
60
+ # @return [Array<Html2rss::Article>]
57
61
  def articles
58
62
  @articles ||= @selectors.dig(ITEMS_SELECTOR_KEY, :order) == 'reverse' ? to_a.tap(&:reverse!) : to_a
59
63
  end
@@ -61,7 +65,7 @@ module Html2rss
61
65
  ##
62
66
  # Iterates over each scraped article.
63
67
  #
64
- # @yield [article] Gives each article as an Html2rss::RssBuilder::Article.
68
+ # @yield [article] Gives each article as an Html2rss::Article.
65
69
  # @return [Enumerator] An enumerator if no block is given.
66
70
  def each(&)
67
71
  return enum_for(:each) unless block_given?
@@ -73,7 +77,7 @@ module Html2rss
73
77
 
74
78
  enhance_article_hash(article_hash, item, response.url) if enhance
75
79
 
76
- yield Html2rss::RssBuilder::Article.new(**article_hash, scraper: self.class)
80
+ yield Html2rss::Article.new(**article_hash, scraper: self.class)
77
81
  end
78
82
  end
79
83
 
@@ -92,7 +96,14 @@ module Html2rss
92
96
  # @param page_response [RequestService::Response] response used for selector extraction context
93
97
  # @return [Hash] Hash of attributes for the article.
94
98
  def extract_article(item, page_response = response)
95
- @rss_item_attributes.to_h { |key| [key, select(key, item, base_url: page_response.url)] }.compact
99
+ scope = item_scope_for(item, page_response.url)
100
+ @rss_item_attributes.each_with_object({}) do |selector_key, hash|
101
+ value = scope.select(selector_key)
102
+ next if value.nil?
103
+
104
+ article_key = SELECTOR_TO_ARTICLE_KEY.fetch(selector_key, selector_key)
105
+ hash[article_key] = article_key == :enclosures ? wrap_enclosure_value(value) : value
106
+ end
96
107
  end
97
108
 
98
109
  ##
@@ -105,13 +116,13 @@ module Html2rss
105
116
  # @return [Hash] The enhanced article hash.
106
117
  # rubocop:disable Metrics/MethodLength
107
118
  def enhance_article_hash(article_hash, article_tag, base_url = @url)
108
- selected_anchor = HtmlExtractor.main_anchor_for(article_tag)
109
- extracted = HtmlExtractor.new(
119
+ selected_anchor = Html2rss::Html::Navigator.main_anchor_for(article_tag)
120
+ extracted = Html2rss::Html::ArticleExtractor.call(
110
121
  article_tag,
111
122
  base_url:,
112
123
  selected_anchor:,
113
124
  fallback_anchorless: true
114
- ).call
125
+ )
115
126
  return article_hash unless extracted
116
127
 
117
128
  extracted.each_with_object(article_hash) do |(key, value), hash|
@@ -131,6 +142,18 @@ module Html2rss
131
142
  # @return [Object, Array<Object>] The selected value(s).
132
143
  # @raise [InvalidSelectorName] If the attribute name is invalid or not defined.
133
144
  def select(name, item, base_url: @url)
145
+ select_in_scope(name, item_scope_for(item, base_url))
146
+ end
147
+
148
+ ##
149
+ # Selects the value for a given attribute within an existing {ItemScope}.
150
+ # Used by {ItemScope#select} so nested selects reuse one scope per extraction pass.
151
+ #
152
+ # @param name [Symbol, String] Name of the attribute.
153
+ # @param scope [ItemScope] Per-item extraction scope.
154
+ # @return [Object, Array<Object>] The selected value(s).
155
+ # @raise [InvalidSelectorName] If the attribute name is invalid or not defined.
156
+ def select_in_scope(name, scope)
134
157
  name = name.to_sym
135
158
 
136
159
  raise InvalidSelectorName, "Attribute selector '#{name}' is reserved for items." if name == ITEMS_SELECTOR_KEY
@@ -138,9 +161,9 @@ module Html2rss
138
161
  selector_key, config = selector_config_for(name)
139
162
 
140
163
  if SPECIAL_ATTRIBUTES.member?(selector_key)
141
- select_special(selector_key, item:, config:, base_url:)
164
+ select_special(selector_key, scope:, config:)
142
165
  else
143
- select_regular(selector_key, item:, config:, base_url:)
166
+ select_regular(selector_key, scope:, config:)
144
167
  end
145
168
  end
146
169
 
@@ -148,10 +171,18 @@ module Html2rss
148
171
 
149
172
  attr_reader :response
150
173
 
174
+ def item_scope_for(item, base_url)
175
+ ItemScope.new(
176
+ item:,
177
+ base_url:,
178
+ scraper: self,
179
+ channel: channel_context(base_url)
180
+ )
181
+ end
182
+
151
183
  def prepare_selectors!
152
184
  validate_url_and_link_exclusivity!
153
185
  fix_url_and_link!
154
- handle_renamed_attributes!
155
186
  end
156
187
 
157
188
  def validate_url_and_link_exclusivity!
@@ -167,17 +198,6 @@ module Html2rss
167
198
  @selectors[:url] = @selectors[:link]
168
199
  end
169
200
 
170
- def handle_renamed_attributes!
171
- RENAMED_ATTRIBUTES.each_pair do |new_name, old_names|
172
- old_names.each do |old_name|
173
- next unless @selectors.key?(old_name)
174
-
175
- Html2rss::Log.warn("Selector '#{old_name}' is deprecated. Please rename to '#{new_name}'.")
176
- @selectors[new_name] ||= @selectors.delete(old_name)
177
- end
178
- end
179
- end
180
-
181
201
  def parsed_body
182
202
  parsed_body_for(response)
183
203
  end
@@ -192,64 +212,58 @@ module Html2rss
192
212
  end
193
213
  end
194
214
 
195
- def select_special(name, item:, config:, base_url:)
215
+ def select_special(name, scope:, config:)
196
216
  case name
197
217
  when :enclosure
198
- enclosure(item:, config:, base_url:)
218
+ enclosure(scope:, config:)
199
219
  when :guid
200
- Array(config).map { |selector_name| select(selector_name, item, base_url:) }
220
+ Array(config).map { |selector_name| scope.select(selector_name) }
201
221
  when :categories
202
- select_categories(category_selectors: config, item:, base_url:)
222
+ select_categories(category_selectors: config, scope:)
203
223
  end
204
224
  end
205
225
 
206
- def select_regular(_name, item:, config:, base_url:)
226
+ def select_regular(_name, scope:, config:)
207
227
  @merged_configs ||= {}
208
- merged_config = @merged_configs[[config.object_id, base_url]] ||=
209
- config.merge(channel: channel_context(base_url)).freeze
210
- value = Extractors.get(merged_config, item)
228
+ merged_config = @merged_configs[[config.object_id, scope.base_url]] ||=
229
+ config.merge(channel: scope.channel).freeze
230
+ value = Extractors.get(merged_config, scope.item)
211
231
 
212
232
  if value && (post_process_steps = config[:post_process])
213
233
  steps = post_process_steps.is_a?(Array) ? post_process_steps : [post_process_steps]
214
- value = post_process(item, value, steps, base_url:)
234
+ value = post_process(scope, value, steps)
215
235
  end
216
236
 
217
237
  value
218
238
  end
219
239
 
220
- def post_process(item, value, post_process_steps, base_url:)
221
- pp_context = channel_post_process_context(base_url)
240
+ def post_process(scope, value, post_process_steps)
222
241
  post_process_steps.each do |options|
223
- context = Context.new(config: pp_context,
224
- item:, scraper: self, options:)
225
-
226
- value = PostProcessors.get(options[:name], value, context)
242
+ value = PostProcessors.get(options[:name], value, scope.context_for(options:))
227
243
  end
228
244
 
229
245
  value
230
246
  end
231
247
 
232
- def select_categories(category_selectors:, item:, base_url:)
248
+ def select_categories(category_selectors:, scope:)
233
249
  Array(category_selectors).flat_map do |selector_name|
234
- extract_category_values(selector_name, item:, base_url:)
250
+ extract_category_values(selector_name, scope:)
235
251
  end
236
252
  end
237
253
 
238
- def extract_category_values(selector_name, item:, base_url:)
254
+ def extract_category_values(selector_name, scope:)
239
255
  selector_key, config = selector_config_for(selector_name, allow_nil: true)
240
256
  return [] unless config
241
257
 
242
- nodes = extract_nodes(item:, config:)
243
- unless node_set_with_multiple_elements?(nodes)
244
- return Array(select_regular(selector_key, item:, config:, base_url:))
245
- end
258
+ nodes = extract_nodes(item: scope.item, config:)
259
+ return Array(select_regular(selector_key, scope:, config:)) unless node_set_with_multiple_elements?(nodes)
246
260
 
247
- Array(nodes).flat_map { |node| extract_categories_from_node(node, item:, config:, base_url:) }
261
+ Array(nodes).flat_map { |node| extract_categories_from_node(node, scope:, config:) }
248
262
  end
249
263
 
250
- def extract_categories_from_node(node, item:, config:, base_url:)
251
- values = Extractors.get(category_node_options(config, base_url:), node)
252
- values = apply_post_process_steps(item:, value: values, post_process_steps: config[:post_process], base_url:)
264
+ def extract_categories_from_node(node, scope:, config:)
265
+ values = Extractors.get(category_node_options(config, scope:), node)
266
+ values = apply_post_process_steps(scope:, value: values, post_process_steps: config[:post_process])
253
267
 
254
268
  Array(values).filter_map { |category| extract_category_text(category) }
255
269
  end
@@ -257,7 +271,7 @@ module Html2rss
257
271
  def extract_category_text(category)
258
272
  text = case category
259
273
  when Nokogiri::XML::Node, Nokogiri::XML::NodeSet
260
- HtmlExtractor.extract_visible_text(category)
274
+ Html2rss::Html::Navigator.extract_visible_text(category)
261
275
  else
262
276
  category&.to_s
263
277
  end
@@ -270,19 +284,19 @@ module Html2rss
270
284
  nodes.is_a?(Nokogiri::XML::NodeSet) && nodes.length > 1
271
285
  end
272
286
 
273
- def category_node_options(selector_config, base_url:)
287
+ def category_node_options(selector_config, scope:)
274
288
  @category_node_configs ||= {}
275
- @category_node_configs[[selector_config.object_id, base_url]] ||= selector_config.merge(
276
- channel: channel_context(base_url),
289
+ @category_node_configs[[selector_config.object_id, scope.base_url]] ||= selector_config.merge(
290
+ channel: scope.channel,
277
291
  selector: nil
278
292
  ).freeze
279
293
  end
280
294
 
281
- def apply_post_process_steps(item:, value:, post_process_steps:, base_url:)
295
+ def apply_post_process_steps(scope:, value:, post_process_steps:)
282
296
  return value unless value && post_process_steps
283
297
 
284
298
  steps = post_process_steps.is_a?(Array) ? post_process_steps : [post_process_steps]
285
- post_process(item, value, steps, base_url:)
299
+ post_process(scope, value, steps)
286
300
  end
287
301
 
288
302
  def selector_config_for(name, allow_nil: false)
@@ -305,14 +319,20 @@ module Html2rss
305
319
  @channel_contexts[base_url] ||= { url: base_url, time_zone: @time_zone }.freeze
306
320
  end
307
321
 
308
- def channel_post_process_context(base_url)
309
- @channel_pp_contexts ||= {}
310
- @channel_pp_contexts[base_url] ||= { channel: channel_context(base_url) }.freeze
322
+ # Keep a single enclosure Hash as one list entry; +Array(hash)+ would split pairs.
323
+ #
324
+ # @param value [Hash, Array] enclosure hash or list of hashes
325
+ # @return [Array]
326
+ def wrap_enclosure_value(value)
327
+ value.is_a?(Array) ? value : [value]
311
328
  end
312
329
 
313
- # @return [Hash] enclosure details.
314
- def enclosure(item:, config:, base_url:)
315
- url = Url.from_relative(select_regular(:enclosure, item:, config:, base_url:), base_url)
330
+ # @return [Hash, nil] enclosure details, or nil when the selector yields nothing.
331
+ def enclosure(scope:, config:)
332
+ selected = select_regular(:enclosure, scope:, config:)
333
+ return if selected.nil? || selected.to_s.strip.empty?
334
+
335
+ url = Url.from_relative(selected, scope.base_url)
316
336
 
317
337
  { url:, type: config[:content_type] }
318
338
  end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ ##
5
+ # Shared RSS +generator+ / JSON Feed +user_comment+ formatter string.
6
+ #
7
+ # Exposed publicly via {FeedResult#status}. Safe to log without reading articles.
8
+ # Stable telemetry payload for cross-repo consumers (e.g. html2rss-web observability).
9
+ # Tallies and counters are validated and frozen at construction (including Marshal load).
10
+ Status = Data.define(
11
+ :version, :scraper_tallies, :dedup_dropped, :selected_strategy, :attempt_count, :strategy_attempts
12
+ ) do
13
+ class << self
14
+ ##
15
+ # Builds status from extracted articles and scrape telemetry.
16
+ #
17
+ # @param articles [Array<Html2rss::Article>] articles kept after deduplication
18
+ # @param dedup_dropped [Integer] number of articles removed by deduplication
19
+ # @param selected_strategy [Symbol, nil] concrete strategy that succeeded under +:auto+ (else +nil+)
20
+ # @param attempt_count [Integer] auto-fallback attempt count (0 when not under +:auto+)
21
+ # @param strategy_attempts [Array<Hash>] auto-fallback attempt hashes (empty outside +:auto+)
22
+ # @return [Html2rss::Status]
23
+ def build(articles:, dedup_dropped: 0, selected_strategy: nil, attempt_count: 0, strategy_attempts: [])
24
+ tallies = articles.filter_map(&:scraper).tally.transform_keys { |klass| scraper_name(klass) }
25
+ new(
26
+ version: Html2rss::VERSION,
27
+ scraper_tallies: tallies,
28
+ dedup_dropped:,
29
+ selected_strategy:,
30
+ attempt_count:,
31
+ strategy_attempts:
32
+ )
33
+ end
34
+
35
+ ##
36
+ # @param klass [Class, #to_s] scraper class
37
+ # @return [String] short scraper label for tallies / generator text
38
+ def scraper_name(klass)
39
+ klass.to_s.gsub(/(?:Html2rss|Scraper)::/, '')
40
+ end
41
+ end
42
+
43
+ ##
44
+ # @param version [String]
45
+ # @param scraper_tallies [Hash{String => Integer}]
46
+ # @param dedup_dropped [Integer]
47
+ # @param selected_strategy [Symbol, nil]
48
+ # @param attempt_count [Integer]
49
+ # @param strategy_attempts [Array<Hash>]
50
+ # rubocop:disable Metrics/ParameterLists, Metrics/MethodLength -- Status Data.define members
51
+ def initialize(
52
+ version:, scraper_tallies:, dedup_dropped:, selected_strategy: nil, attempt_count: 0, strategy_attempts: []
53
+ )
54
+ dedup = Integer(dedup_dropped)
55
+ attempts = Integer(attempt_count)
56
+ validate_counters!(dedup:, attempts:, selected_strategy:)
57
+
58
+ super(
59
+ version: version.to_s.dup.freeze,
60
+ scraper_tallies: freeze_tallies(scraper_tallies),
61
+ dedup_dropped: dedup,
62
+ selected_strategy:,
63
+ attempt_count: attempts,
64
+ strategy_attempts: freeze_attempts(strategy_attempts)
65
+ )
66
+ end
67
+ # rubocop:enable Metrics/ParameterLists, Metrics/MethodLength
68
+
69
+ ##
70
+ # Observability hash for web (+scraper_status+). Omits empty/absent optional keys:
71
+ # +:scraper_tallies+ when empty, +:selected_strategy+ when +nil+, +:attempt_count+ when zero,
72
+ # +:strategy_attempts+ when empty.
73
+ # Data members remain available via readers even when omitted here.
74
+ #
75
+ # @return [Hash{Symbol => Object}] always +:version+ (String), +:dedup_dropped+ (Integer);
76
+ # optionally +:scraper_tallies+, +:selected_strategy+, +:attempt_count+, +:strategy_attempts+
77
+ def to_h
78
+ {
79
+ version:,
80
+ dedup_dropped:,
81
+ **(scraper_tallies.any? ? { scraper_tallies: } : {}),
82
+ **(selected_strategy.nil? ? {} : { selected_strategy: }),
83
+ **(attempt_count.positive? ? { attempt_count: } : {}),
84
+ **(strategy_attempts.any? ? { strategy_attempts: } : {})
85
+ }
86
+ end
87
+
88
+ ##
89
+ # Formats the RSS +generator+ string and JSON Feed +user_comment+.
90
+ # Scraper-focused only — auto strategy summary stays on {#to_h}, not this string.
91
+ # Omits the +(scrapers: …)+ clause when tallies are empty.
92
+ #
93
+ # @return [String]
94
+ def to_generator_comment
95
+ return "html2rss V. #{version}" if scraper_tallies.empty?
96
+
97
+ counts = scraper_tallies.map { |name, count| "#{name} (#{count})" }
98
+ "html2rss V. #{version} (scrapers: #{counts.join(', ')})"
99
+ end
100
+
101
+ private
102
+
103
+ def marshal_dump
104
+ [version, scraper_tallies, dedup_dropped, selected_strategy, attempt_count, strategy_attempts]
105
+ end
106
+
107
+ def marshal_load((version, scraper_tallies, dedup_dropped, selected_strategy, attempt_count, strategy_attempts))
108
+ initialize(version:, scraper_tallies:, dedup_dropped:, selected_strategy:, attempt_count:, strategy_attempts:)
109
+ end
110
+
111
+ def freeze_tallies(tallies)
112
+ tallies.to_h.transform_keys(&:to_s).transform_values { |count| Integer(count) }.freeze
113
+ end
114
+
115
+ def freeze_attempts(attempts)
116
+ Array(attempts).map { |attempt| attempt.to_h.freeze }.freeze
117
+ end
118
+
119
+ def validate_counters!(dedup:, attempts:, selected_strategy:)
120
+ raise ArgumentError, 'dedup_dropped must be >= 0' if dedup.negative?
121
+ raise ArgumentError, 'attempt_count must be >= 0' if attempts.negative?
122
+
123
+ unless selected_strategy.nil? || selected_strategy.is_a?(Symbol)
124
+ raise ArgumentError, 'selected_strategy must be a Symbol or nil'
125
+ end
126
+
127
+ return unless selected_strategy && attempts < 1
128
+
129
+ raise ArgumentError, 'attempt_count must be >= 1 when selected_strategy is set'
130
+ end
131
+ end
132
+ end
@@ -1,9 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- ##
4
- # The Html2rss namespace.
5
3
  module Html2rss
6
4
  # Current application version.
7
- VERSION = '0.22.2'
5
+ VERSION = '0.24.0'
8
6
  public_constant :VERSION
9
7
  end
data/lib/html2rss.rb CHANGED
@@ -8,7 +8,7 @@ loader.setup
8
8
 
9
9
  require 'logger'
10
10
  require 'forwardable'
11
- require 'html2rss/configuration'
11
+ require 'html2rss/defaults'
12
12
 
13
13
  ##
14
14
  # The Html2rss namespace.
@@ -34,22 +34,34 @@ module Html2rss
34
34
  Config.load_yaml(file, feed_name)
35
35
  end
36
36
 
37
+ ##
38
+ # Returns an opaque, Marshal-cacheable result of one scrape.
39
+ #
40
+ # Prefer this when the same scrape must render as RSS and JSON Feed (e.g. web cache).
41
+ #
42
+ # @param raw_config [Hash{Symbol => Object}] feed configuration
43
+ # @return [Html2rss::FeedResult]
44
+ def self.feed_result(raw_config)
45
+ FeedPipeline.new(raw_config).to_result
46
+ end
47
+
37
48
  ##
38
49
  # Returns an RSS object generated from the provided configuration.
39
50
  #
40
51
  # @param raw_config [Hash{Symbol => Object}] feed configuration
41
52
  # @return [RSS::Rss] generated RSS feed
42
53
  def self.feed(raw_config)
43
- FeedPipeline.new(raw_config).to_rss
54
+ feed_result(raw_config).to_rss
44
55
  end
45
56
 
46
57
  ##
47
58
  # Returns a JSONFeed 1.1 hash generated from the provided configuration.
48
59
  #
49
60
  # @param raw_config [Hash{Symbol => Object}] feed configuration
61
+ # @param feed_url [String, nil] optional self URL for the feed (JSON Feed +feed_url+)
50
62
  # @return [Hash] JSONFeed-compliant hash
51
- def self.json_feed(raw_config)
52
- FeedPipeline.new(raw_config).to_json_feed
63
+ def self.json_feed(raw_config, feed_url: nil)
64
+ feed_result(raw_config).to_json_feed(feed_url:)
53
65
  end
54
66
 
55
67
  # rubocop:disable Metrics/ParameterLists
@@ -61,14 +73,14 @@ module Html2rss
61
73
  # @param strategy [Symbol] request strategy to use
62
74
  # @param items_selector [String, nil] optional selector hint for item extraction
63
75
  # @param max_redirects [Integer, nil] optional redirect limit override
64
- # @param max_requests [Integer, nil] optional request budget override
76
+ # @param max_requests [Integer] optional request budget override (default: 4 for sitemap sub-fetches)
65
77
  # @param local_file_path [String, nil] optional local HTML file path
66
78
  # @return [RSS::Rss] generated RSS feed
67
79
  def self.auto_source(url,
68
80
  strategy: :auto,
69
81
  items_selector: nil,
70
82
  max_redirects: nil,
71
- max_requests: nil,
83
+ max_requests: 4,
72
84
  local_file_path: nil)
73
85
  feed(build_auto_source_config(url:, strategy:, items_selector:, max_redirects:, max_requests:, local_file_path:))
74
86
  end
@@ -80,14 +92,14 @@ module Html2rss
80
92
  # @param strategy [Symbol] request strategy to use
81
93
  # @param items_selector [String, nil] optional selector hint for item extraction
82
94
  # @param max_redirects [Integer, nil] optional redirect limit override
83
- # @param max_requests [Integer, nil] optional request budget override
95
+ # @param max_requests [Integer] optional request budget override (default: 4 for sitemap sub-fetches)
84
96
  # @param local_file_path [String, nil] optional local HTML file path
85
97
  # @return [Hash] JSONFeed-compliant hash
86
98
  def self.auto_json_feed(url,
87
99
  strategy: :auto,
88
100
  items_selector: nil,
89
101
  max_redirects: nil,
90
- max_requests: nil,
102
+ max_requests: 4,
91
103
  local_file_path: nil)
92
104
  json_feed(build_auto_source_config(url:, strategy:, items_selector:, max_redirects:, max_requests:,
93
105
  local_file_path:))
@@ -98,26 +110,26 @@ module Html2rss
98
110
  # rubocop:disable ThreadSafety/ClassInstanceVariable
99
111
  class << self
100
112
  ##
101
- # @return [Html2rss::Configuration] the global configuration instance
102
- def configuration
103
- @configuration ||= Configuration.new.freeze
113
+ # @return [Html2rss::Defaults] the global defaults instance
114
+ def defaults
115
+ @defaults ||= Defaults.new.freeze
104
116
  end
105
117
 
106
118
  ##
107
119
  # Configures global library defaults.
108
120
  #
109
- # @yieldparam config [Html2rss::Configuration]
110
- # @return [Html2rss::Configuration] the frozen configuration
121
+ # @yieldparam config [Html2rss::Defaults]
122
+ # @return [Html2rss::Defaults] the frozen defaults
111
123
  def configure
112
- config = configuration.dup
124
+ config = defaults.dup
113
125
  yield config
114
- @configuration = config.freeze
126
+ @defaults = config.freeze
115
127
  end
116
128
 
117
129
  ##
118
130
  # @return [Object] the logger
119
131
  def logger
120
- configuration.logger
132
+ defaults.logger
121
133
  end
122
134
 
123
135
  ##
@@ -129,12 +141,12 @@ module Html2rss
129
141
  private
130
142
 
131
143
  ##
132
- # Resets the global configuration to defaults (mainly for testing).
144
+ # Resets the global defaults (mainly for testing).
133
145
  #
134
146
  # @return [void]
135
- def reset_configuration!
136
- @configuration = nil
137
- logger.level = configuration.log_level if logger.respond_to?(:level=)
147
+ def reset_defaults!
148
+ @defaults = nil
149
+ logger.level = defaults.log_level if logger.respond_to?(:level=)
138
150
  end
139
151
  end
140
152
  # rubocop:enable ThreadSafety/ClassInstanceVariable
@@ -156,7 +168,7 @@ module Html2rss
156
168
  end
157
169
 
158
170
  def shortcut_request_controls(strategy:, max_redirects:, max_requests:)
159
- RequestControls.new(
171
+ Config::RequestControls.new(
160
172
  strategy:,
161
173
  max_redirects:,
162
174
  max_requests:,
@@ -173,7 +185,7 @@ module Html2rss
173
185
  end
174
186
  end
175
187
 
176
- logger.level = configuration.log_level if logger.respond_to?(:level=)
188
+ logger.level = defaults.log_level if logger.respond_to?(:level=)
177
189
  end
178
190
 
179
191
  loader.eager_load