html2rss 0.32.4 → 0.34.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 (202) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +4 -2
  3. data/lib/html2rss/article/deduplicator.rb +5 -2
  4. data/lib/html2rss/article/enclosure.rb +185 -25
  5. data/lib/html2rss/article/title.rb +203 -0
  6. data/lib/html2rss/article.rb +79 -13
  7. data/lib/html2rss/auto_source/README.md +16 -8
  8. data/lib/html2rss/auto_source/cleanup.rb +127 -53
  9. data/lib/html2rss/auto_source/corroboration.rb +167 -0
  10. data/lib/html2rss/auto_source/page_links/anchor_claims.rb +82 -0
  11. data/lib/html2rss/auto_source/page_links/card_units.rb +77 -0
  12. data/lib/html2rss/auto_source/page_links/label_binding.rb +79 -0
  13. data/lib/html2rss/auto_source/page_links/sponsored_cards.rb +147 -0
  14. data/lib/html2rss/auto_source/page_links.rb +164 -0
  15. data/lib/html2rss/auto_source/scraper/html.rb +11 -45
  16. data/lib/html2rss/auto_source/scraper/json_document.rb +3 -6
  17. data/lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb +202 -125
  18. data/lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb +27 -38
  19. data/lib/html2rss/auto_source/scraper/json_state/document_scanner.rb +123 -123
  20. data/lib/html2rss/auto_source/scraper/json_state/value_finder.rb +27 -35
  21. data/lib/html2rss/auto_source/scraper/json_state.rb +4 -9
  22. data/lib/html2rss/auto_source/scraper/meta_oembed.rb +27 -5
  23. data/lib/html2rss/auto_source/scraper/microdata.rb +233 -248
  24. data/lib/html2rss/auto_source/scraper/microformats2.rb +10 -4
  25. data/lib/html2rss/auto_source/scraper/native_feed.rb +28 -18
  26. data/lib/html2rss/auto_source/scraper/schema/category_extractor.rb +7 -2
  27. data/lib/html2rss/auto_source/scraper/schema/item_list.rb +5 -3
  28. data/lib/html2rss/auto_source/scraper/schema/thing.rb +140 -22
  29. data/lib/html2rss/auto_source/scraper/schema.rb +52 -8
  30. data/lib/html2rss/auto_source/scraper/semantic_html/entry_deduplicator.rb +3 -9
  31. data/lib/html2rss/auto_source/scraper/semantic_html.rb +12 -20
  32. data/lib/html2rss/auto_source/scraper/sitemap/parser.rb +1 -4
  33. data/lib/html2rss/auto_source/scraper/sitemap.rb +7 -14
  34. data/lib/html2rss/auto_source/scraper/wordpress_api/page_scope.rb +2 -2
  35. data/lib/html2rss/auto_source/scraper/wordpress_api/posts_endpoint.rb +1 -3
  36. data/lib/html2rss/auto_source/scraper/wordpress_api.rb +7 -7
  37. data/lib/html2rss/auto_source/scraper/xhr_articles.rb +2 -4
  38. data/lib/html2rss/auto_source/scraper.rb +10 -13
  39. data/lib/html2rss/auto_source/segment.rb +19 -5
  40. data/lib/html2rss/auto_source/segmenter/cluster.rb +18 -22
  41. data/lib/html2rss/auto_source/segmenter/list.rb +35 -59
  42. data/lib/html2rss/auto_source/segmenter/primary_link.rb +29 -4
  43. data/lib/html2rss/auto_source/segmenter/semantic.rb +23 -19
  44. data/lib/html2rss/auto_source/segmenter.rb +4 -8
  45. data/lib/html2rss/auto_source.rb +81 -26
  46. data/lib/html2rss/batch.rb +24 -32
  47. data/lib/html2rss/capture/README.md +6 -3
  48. data/lib/html2rss/capture.rb +51 -59
  49. data/lib/html2rss/channel.rb +30 -12
  50. data/lib/html2rss/cli/render.rb +97 -96
  51. data/lib/html2rss/cli/validate.rb +2 -4
  52. data/lib/html2rss/cli.rb +55 -38
  53. data/lib/html2rss/config/auto_source_contract.rb +52 -45
  54. data/lib/html2rss/config/issue_mapper.rb +4 -1
  55. data/lib/html2rss/config/request_controls.rb +16 -5
  56. data/lib/html2rss/config/schema.rb +23 -15
  57. data/lib/html2rss/config/selectors_validator.rb +3 -2
  58. data/lib/html2rss/config/validation_issue.rb +4 -1
  59. data/lib/html2rss/config/validator.rb +14 -3
  60. data/lib/html2rss/config.rb +54 -28
  61. data/lib/html2rss/contract_error.rb +9 -0
  62. data/lib/html2rss/defaults.rb +25 -38
  63. data/lib/html2rss/doctor/botasaurus.rb +18 -22
  64. data/lib/html2rss/error.rb +17 -3
  65. data/lib/html2rss/error_kind.rb +39 -0
  66. data/lib/html2rss/feed_builder/item_presentation.rb +22 -9
  67. data/lib/html2rss/feed_builder/json_feed/item.rb +2 -1
  68. data/lib/html2rss/feed_builder/json_feed.rb +2 -2
  69. data/lib/html2rss/feed_builder/rss/stylesheet.rb +3 -3
  70. data/lib/html2rss/feed_builder/rss.rb +50 -7
  71. data/lib/html2rss/feed_builder.rb +2 -1
  72. data/lib/html2rss/feed_pipeline/README.md +1 -1
  73. data/lib/html2rss/feed_pipeline/auto_fallback.rb +16 -34
  74. data/lib/html2rss/feed_pipeline/strategy_plan.rb +3 -3
  75. data/lib/html2rss/feed_pipeline.rb +40 -27
  76. data/lib/html2rss/feed_resolution/README.md +1 -1
  77. data/lib/html2rss/feed_resolution/candidate_generator.rb +6 -8
  78. data/lib/html2rss/feed_resolution/options.rb +3 -7
  79. data/lib/html2rss/feed_resolution/policy.rb +29 -33
  80. data/lib/html2rss/feed_resolution/probe.rb +6 -4
  81. data/lib/html2rss/feed_resolution/scorer.rb +29 -29
  82. data/lib/html2rss/feed_resolution.rb +16 -21
  83. data/lib/html2rss/feed_result.rb +11 -6
  84. data/lib/html2rss/hash_util.rb +91 -90
  85. data/lib/html2rss/html/article_extractor/category_extractor.rb +7 -10
  86. data/lib/html2rss/html/article_extractor/date_extractor.rb +1 -1
  87. data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +12 -33
  88. data/lib/html2rss/html/article_extractor/heading_extractor.rb +2 -3
  89. data/lib/html2rss/html/article_extractor/id_generator.rb +7 -10
  90. data/lib/html2rss/html/article_extractor/image_extractor.rb +9 -1
  91. data/lib/html2rss/html/article_extractor.rb +19 -47
  92. data/lib/html2rss/html/article_rules/category.rb +3 -3
  93. data/lib/html2rss/html/article_rules/description.rb +6 -15
  94. data/lib/html2rss/html/article_rules/enclosure.rb +70 -42
  95. data/lib/html2rss/html/article_rules/image.rb +30 -16
  96. data/lib/html2rss/html/feed_link.rb +7 -9
  97. data/lib/html2rss/html/navigator/text_extractor.rb +26 -16
  98. data/lib/html2rss/html/navigator.rb +39 -9
  99. data/lib/html2rss/html/probe.rb +55 -51
  100. data/lib/html2rss/html/rendering/description_builder.rb +46 -32
  101. data/lib/html2rss/html/rendering.rb +2 -9
  102. data/lib/html2rss/html/sst_article_extractor.rb +55 -145
  103. data/lib/html2rss/html/title_pieces/kicker.rb +147 -0
  104. data/lib/html2rss/html/title_pieces/nodes.rb +107 -0
  105. data/lib/html2rss/html/title_pieces/nokogiri_nodes.rb +70 -0
  106. data/lib/html2rss/html/title_pieces/sst_nodes.rb +71 -0
  107. data/lib/html2rss/html/title_pieces.rb +169 -0
  108. data/lib/html2rss/html/visibility.rb +56 -0
  109. data/lib/html2rss/html.rb +2 -0
  110. data/lib/html2rss/link_destination/destination_facts.rb +17 -1
  111. data/lib/html2rss/link_destination/noise_policy.rb +11 -10
  112. data/lib/html2rss/link_destination/path_classifier.rb +111 -42
  113. data/lib/html2rss/link_destination/sponsorship/label_text.rb +82 -0
  114. data/lib/html2rss/link_destination/sponsorship.rb +184 -0
  115. data/lib/html2rss/link_destination/text_classifier.rb +104 -8
  116. data/lib/html2rss/mcp/README.md +13 -1
  117. data/lib/html2rss/mcp/config_argument.rb +14 -4
  118. data/lib/html2rss/mcp/contract.rb +20 -9
  119. data/lib/html2rss/mcp/outcome.rb +18 -17
  120. data/lib/html2rss/mcp/runtime.rb +53 -23
  121. data/lib/html2rss/mcp/server/tools.rb +9 -9
  122. data/lib/html2rss/mcp/server.rb +13 -4
  123. data/lib/html2rss/option.rb +48 -0
  124. data/lib/html2rss/page_recon/README.md +1 -1
  125. data/lib/html2rss/page_recon/diagnostics.rb +9 -9
  126. data/lib/html2rss/page_recon.rb +0 -4
  127. data/lib/html2rss/recon.rb +19 -23
  128. data/lib/html2rss/request_service/blocked_surface.rb +5 -8
  129. data/lib/html2rss/request_service/botasaurus_contract.rb +25 -3
  130. data/lib/html2rss/request_service/botasaurus_strategy.rb +44 -12
  131. data/lib/html2rss/request_service/budget.rb +3 -24
  132. data/lib/html2rss/request_service/compressed_body.rb +36 -48
  133. data/lib/html2rss/request_service/context.rb +16 -17
  134. data/lib/html2rss/request_service/cookie_jar.rb +89 -0
  135. data/lib/html2rss/request_service/cookie_store.rb +79 -0
  136. data/lib/html2rss/request_service/feed_advertisement.rb +72 -0
  137. data/lib/html2rss/request_service/httpx_strategy.rb +7 -3
  138. data/lib/html2rss/request_service/local_file_strategy.rb +2 -3
  139. data/lib/html2rss/request_service/network_guard.rb +3 -2
  140. data/lib/html2rss/request_service/policy.rb +55 -36
  141. data/lib/html2rss/request_service/replay_breaker.rb +56 -0
  142. data/lib/html2rss/request_service/response.rb +33 -19
  143. data/lib/html2rss/request_service/response_cache.rb +109 -0
  144. data/lib/html2rss/request_service/response_caching.rb +95 -0
  145. data/lib/html2rss/request_service/strategy.rb +5 -11
  146. data/lib/html2rss/request_service.rb +71 -12
  147. data/lib/html2rss/request_session/pager/base.rb +3 -10
  148. data/lib/html2rss/request_session/pager/custom_selector.rb +4 -4
  149. data/lib/html2rss/request_session/pager/offset.rb +1 -7
  150. data/lib/html2rss/request_session/pager/rel_next.rb +1 -1
  151. data/lib/html2rss/request_session/pager/url_template.rb +1 -7
  152. data/lib/html2rss/request_session/pager.rb +1 -1
  153. data/lib/html2rss/request_session.rb +6 -2
  154. data/lib/html2rss/scoring/cluster_scorer.rb +1 -1
  155. data/lib/html2rss/scoring/container_assessor.rb +12 -13
  156. data/lib/html2rss/scoring/engine.rb +10 -38
  157. data/lib/html2rss/scoring/link_resolver.rb +2 -7
  158. data/lib/html2rss/scoring/observation.rb +2 -2
  159. data/lib/html2rss/selector_candidates.rb +19 -8
  160. data/lib/html2rss/selectors/README.md +1 -1
  161. data/lib/html2rss/selectors/extractors/href.rb +2 -4
  162. data/lib/html2rss/selectors/json_xml.rb +33 -34
  163. data/lib/html2rss/selectors/option_spec.rb +4 -1
  164. data/lib/html2rss/selectors/post_processors/base.rb +0 -8
  165. data/lib/html2rss/selectors/post_processors/gsub.rb +9 -10
  166. data/lib/html2rss/selectors/post_processors/html_transformers/keep_playable_media.rb +50 -0
  167. data/lib/html2rss/selectors/post_processors/html_transformers/transform_urls_to_absolute_ones.rb +1 -3
  168. data/lib/html2rss/selectors/post_processors/parse_time.rb +5 -6
  169. data/lib/html2rss/selectors/post_processors/parse_uri.rb +1 -1
  170. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +18 -5
  171. data/lib/html2rss/selectors/post_processors/substring.rb +1 -1
  172. data/lib/html2rss/selectors/schema_export.rb +60 -60
  173. data/lib/html2rss/selectors.rb +67 -30
  174. data/lib/html2rss/sst/attrs.rb +2 -2
  175. data/lib/html2rss/sst/document.rb +3 -3
  176. data/lib/html2rss/sst/index.rb +30 -11
  177. data/lib/html2rss/sst/node.rb +42 -29
  178. data/lib/html2rss/sst/normalizer.rb +47 -34
  179. data/lib/html2rss/sst/tags.rb +113 -2
  180. data/lib/html2rss/sst/text.rb +94 -58
  181. data/lib/html2rss/sst.rb +2 -0
  182. data/lib/html2rss/status.rb +27 -31
  183. data/lib/html2rss/surface_category.rb +1 -1
  184. data/lib/html2rss/syndication/README.md +12 -6
  185. data/lib/html2rss/syndication/alternate_links.rb +39 -0
  186. data/lib/html2rss/syndication/channel_facts.rb +87 -0
  187. data/lib/html2rss/syndication/discovery.rb +171 -187
  188. data/lib/html2rss/syndication/feed.rb +20 -0
  189. data/lib/html2rss/syndication/item_media.rb +103 -0
  190. data/lib/html2rss/syndication/parser.rb +117 -116
  191. data/lib/html2rss/test/enhance_audit.rb +15 -17
  192. data/lib/html2rss/test/item_sample.rb +43 -0
  193. data/lib/html2rss/test/policy.rb +9 -11
  194. data/lib/html2rss/test.rb +66 -57
  195. data/lib/html2rss/url.rb +163 -62
  196. data/lib/html2rss/version.rb +1 -1
  197. data/lib/html2rss/word_count.rb +53 -0
  198. data/lib/html2rss.rb +19 -9
  199. data/schema/html2rss-config.schema.json +56 -41
  200. metadata +33 -4
  201. data/lib/html2rss/html/rendering/media_renderer.rb +0 -39
  202. data/lib/html2rss/html/rendering/pdf_renderer.rb +0 -30
@@ -8,6 +8,9 @@ module Html2rss
8
8
  #
9
9
  # {include:file:lib/html2rss/feed_resolution/README.md}
10
10
  module FeedResolution
11
+ # Raised when the tournament or its policy is handed articles that are not an Array.
12
+ class InvalidError < Html2rss::ContractError; end
13
+
11
14
  # Default entry-resolution options merged into AutoSource::DEFAULT_CONFIG.
12
15
  DEFAULT_CONFIG = {
13
16
  enabled: true,
@@ -116,17 +119,13 @@ module Html2rss
116
119
  return unless resolution.applied
117
120
 
118
121
  effective = scrape_target.with_effective(resolution.scrape_url)
119
- return ApplyOutcome.new(scrape_target: effective, status: :succeeded) if retry_with(
120
- resolution, effective:
121
- )
122
-
123
- nil
122
+ ApplyOutcome.new(scrape_target: effective, status: :succeeded) if retry_with(effective:)
124
123
  rescue RequestService::RequestBudgetExceeded
125
124
  log_budget_exhausted
126
125
  nil
127
- rescue *NON_FALLBACK_ERRORS
126
+ rescue *NON_FALLBACK_ERRORS, Html2rss::ContractError
128
127
  raise
129
- rescue StandardError => error
128
+ rescue Html2rss::Error => error
130
129
  log_retry_failed(error)
131
130
  nil
132
131
  end
@@ -158,7 +157,7 @@ module Html2rss
158
157
  # @return [String, nil]
159
158
  def channel_host
160
159
  Url.from_absolute(config.url.to_s).host
161
- rescue ArgumentError
160
+ rescue Html2rss::Url::InvalidUrlError
162
161
  nil
163
162
  end
164
163
 
@@ -184,24 +183,20 @@ module Html2rss
184
183
  )
185
184
  end
186
185
 
187
- def retry_with(_resolution, effective:) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- retry extract path
186
+ def retry_with(effective:) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- retry extract path
188
187
  retry_session = pipeline.request_session_for(
189
188
  config, strategy:, resources:, scrape_url: effective.effective_url
190
189
  )
191
190
  retry_response = retry_session.fetch_initial_response
192
- retry_articles, dedup_dropped, admission_drops = pipeline.deduplicated_articles(
193
- config:, response: retry_response, request_session: retry_session
194
- )
191
+ extraction = pipeline.extract(config:, response: retry_response, request_session: retry_session)
192
+ items_count = extraction.articles.size
195
193
  state.remember_response(retry_response)
196
- state.record_items(
197
- strategy:, items_count: retry_articles.size, transport_meta: retry_response.transport_meta
198
- )
199
- return unless retry_articles.size.positive?
194
+ state.record_items(strategy:, items_count:, transport_meta: retry_response.transport_meta)
195
+ return unless items_count.positive?
200
196
 
201
197
  state.succeed!(
202
- response: retry_response, articles: retry_articles, dedup_dropped:,
203
- selected_strategy: strategy, attempt_count: state.attempts.size, admission_drops:,
204
- scrape_target: effective
198
+ response: retry_response, extraction:, selected_strategy: strategy,
199
+ attempt_count: state.attempts.size, scrape_target: effective
205
200
  )
206
201
  true
207
202
  end
@@ -218,9 +213,9 @@ module Html2rss
218
213
  # @param surface_category [Symbol, nil]
219
214
  # rubocop:disable-next Metrics/ParameterLists -- tournament context stays co-located
220
215
  def initialize(entry_url:, response:, session:, config:, articles:, surface_category: nil)
221
- raise ArgumentError, 'articles must be an Array' unless articles.is_a?(Array)
216
+ raise InvalidError, 'articles must be an Array' unless articles.is_a?(Array)
222
217
 
223
- @entry_url = Html2rss::Url.from_absolute(entry_url)
218
+ @entry_url = Url.from_absolute(entry_url)
224
219
  @response = response
225
220
  @session = session
226
221
  @config = config
@@ -15,17 +15,20 @@ module Html2rss
15
15
  # are re-frozen via private {#marshal_load}. For trusted cache reads only,
16
16
  # +Marshal.load(payload, freeze: true)+ is also safe.
17
17
  class FeedResult
18
+ # Raised when the scrape handle is built from invalid parts.
19
+ class InvalidError < Html2rss::ContractError; end
20
+
18
21
  ##
19
22
  # @param channel [Html2rss::Channel] channel metadata (internal; not exposed on the public API)
20
23
  # @param articles [Array<Html2rss::Article>] extracted articles (deduplicated; not exposed)
21
24
  # @param status [Html2rss::Status] pipeline telemetry
22
25
  # @param stylesheets [Array<Hash>] optional RSS stylesheet configs
26
+ # @raise [InvalidError] when a part has the wrong type
23
27
  def initialize(channel:, articles:, status:, stylesheets: [])
24
- raise ArgumentError, 'channel must be a Html2rss::Channel' unless channel.is_a?(Channel)
25
- raise ArgumentError, 'status must be a Html2rss::Status' unless status.is_a?(Status)
26
-
27
- articles = Array(articles)
28
- raise ArgumentError, 'articles must all be Html2rss::Article' unless articles.all?(Article)
28
+ raise InvalidError, 'channel must be a Html2rss::Channel' unless channel.is_a?(Channel)
29
+ raise InvalidError, 'status must be a Html2rss::Status' unless status.is_a?(Status)
30
+ raise InvalidError, 'articles must be an Array' unless articles.is_a?(Array)
31
+ raise InvalidError, 'articles must all be Html2rss::Article' unless articles.all?(Article)
29
32
 
30
33
  @channel = channel
31
34
  @articles = articles.dup.freeze
@@ -82,7 +85,9 @@ module Html2rss
82
85
  end
83
86
 
84
87
  def freeze_stylesheets(stylesheets)
85
- Array(stylesheets).map do |sheet|
88
+ raise InvalidError, 'stylesheets must be an Array' unless stylesheets.is_a?(Array)
89
+
90
+ stylesheets.map do |sheet|
86
91
  sheet.to_h.transform_keys(&:to_sym).transform_values do |value|
87
92
  value.is_a?(String) ? value.dup.freeze : value
88
93
  end.freeze
@@ -3,116 +3,117 @@
3
3
  module Html2rss
4
4
  # Shared helpers for hash normalization and structural operations.
5
5
  module HashUtil
6
- module_function
6
+ class << self
7
+ # Deeply duplicates nested arrays and hashes.
8
+ #
9
+ # @param object [Object] nested value from configuration or runtime state
10
+ # @return [Object] deep duplicated object
11
+ def deep_dup(object)
12
+ case object
13
+ in Hash
14
+ object.transform_values { deep_dup(_1) }
15
+ in Array
16
+ object.map { deep_dup(_1) }
17
+ else
18
+ object.dup
19
+ end
20
+ end
7
21
 
8
- # Deeply duplicates nested arrays and hashes.
9
- #
10
- # @param object [Object] nested value from configuration or runtime state
11
- # @return [Object] deep duplicated object
12
- def deep_dup(object)
13
- case object
14
- in Hash
15
- object.transform_values { deep_dup(_1) }
16
- in Array
17
- object.map { deep_dup(_1) }
18
- else
19
- object.dup rescue StandardError # rubocop:disable Style/RescueModifier
22
+ # Deeply merges nested hashes while replacing non-hash values from override.
23
+ #
24
+ # @param base [Hash] base hash
25
+ # @param override [Hash] override hash
26
+ # @return [Hash] merged hash
27
+ def deep_merge(base, override)
28
+ base.merge(override) do |_key, old_val, new_val|
29
+ case [old_val, new_val]
30
+ in [Hash, Hash]
31
+ deep_merge(old_val, new_val)
32
+ else
33
+ new_val
34
+ end
35
+ end
20
36
  end
21
- end
22
37
 
23
- # Deeply merges nested hashes while replacing non-hash values from override.
24
- #
25
- # @param base [Hash] base hash
26
- # @param override [Hash] override hash
27
- # @return [Hash] merged hash
28
- def deep_merge(base, override)
29
- base.merge(override) do |_key, old_val, new_val|
30
- case [old_val, new_val]
31
- in [Hash, Hash]
32
- deep_merge(old_val, new_val)
38
+ # Converts string-keyed hashes to symbol-keyed hashes recursively.
39
+ #
40
+ # @param object [Object] value to normalize
41
+ # @param context [String] error context
42
+ # @return [Object] normalized value
43
+ def deep_symbolize_keys(object, context: 'hash')
44
+ case object
45
+ in Hash
46
+ object.each_with_object({}) do |(k, v), memo|
47
+ memo[symbol_key(k, context:)] = deep_symbolize_keys(v, context:)
48
+ end
49
+ in Array
50
+ object.map { deep_symbolize_keys(_1, context:) }
33
51
  else
34
- new_val
52
+ object
35
53
  end
36
54
  end
37
- end
38
55
 
39
- # Converts string-keyed hashes to symbol-keyed hashes recursively.
40
- #
41
- # @param object [Object] value to normalize
42
- # @param context [String] error context
43
- # @return [Object] normalized value
44
- def deep_symbolize_keys(object, context: 'hash')
45
- case object
46
- in Hash
47
- object.each_with_object({}) do |(k, v), memo|
48
- memo[symbol_key(k, context:)] = deep_symbolize_keys(v, context:)
56
+ # Converts hash keys (and Symbol values) to strings recursively.
57
+ #
58
+ # @param object [Object] value to normalize
59
+ # @return [Object] normalized value with string keys
60
+ def deep_stringify_keys(object)
61
+ case object
62
+ when Hash
63
+ object.to_h { |key, value| [key.to_s, deep_stringify_keys(value)] }
64
+ when Array
65
+ object.map { deep_stringify_keys(_1) }
66
+ when Symbol
67
+ object.to_s
68
+ else
69
+ object
49
70
  end
50
- in Array
51
- object.map { deep_symbolize_keys(_1, context:) }
52
- else
53
- object
54
71
  end
55
- end
56
72
 
57
- # Converts hash keys (and Symbol values) to strings recursively.
58
- #
59
- # @param object [Object] value to normalize
60
- # @return [Object] normalized value with string keys
61
- def deep_stringify_keys(object)
62
- case object
63
- when Hash
64
- object.to_h { |key, value| [key.to_s, deep_stringify_keys(value)] }
65
- when Array
66
- object.map { deep_stringify_keys(_1) }
67
- when Symbol
68
- object.to_s
69
- else
70
- object
71
- end
72
- end
73
+ # Validates that hash keys are symbols.
74
+ #
75
+ # @param value [Object] candidate hash container whose keys must be symbols
76
+ # @param context [String] error context
77
+ # @param deep [Boolean] whether nested hashes should also be validated
78
+ # @return [void]
79
+ def assert_symbol_keys!(value, context: 'hash', deep: true)
80
+ return unless value in Hash
73
81
 
74
- # Validates that hash keys are symbols.
75
- #
76
- # @param value [Object] candidate hash container whose keys must be symbols
77
- # @param context [String] error context
78
- # @param deep [Boolean] whether nested hashes should also be validated
79
- # @return [void]
80
- def assert_symbol_keys!(value, context: 'hash', deep: true)
81
- return unless value in Hash
82
+ unless value.each_key.all?(Symbol)
83
+ invalid_key = value.keys.find { _1.class != Symbol }
84
+ raise InvalidOption, "#{context} must use symbol keys (found #{invalid_key.inspect})"
85
+ end
82
86
 
83
- unless value.each_key.all?(Symbol)
84
- invalid_key = value.keys.find { _1.class != Symbol }
85
- raise ArgumentError, "#{context} must use symbol keys (found #{invalid_key.inspect})"
87
+ value.each_value { assert_symbol_keys!(_1, context:, deep:) } if deep
86
88
  end
87
89
 
88
- value.each_value { assert_symbol_keys!(_1, context:, deep:) } if deep
89
- end
90
+ # Validates that hash keys are strings.
91
+ #
92
+ # @param value [Object] candidate hash container whose keys must be strings
93
+ # @param context [String] error context
94
+ # @param deep [Boolean] whether nested hashes should also be validated
95
+ # @return [void]
96
+ def assert_string_keys!(value, context: 'hash', deep: true)
97
+ return unless value in Hash
90
98
 
91
- # Validates that hash keys are strings.
92
- #
93
- # @param value [Object] candidate hash container whose keys must be strings
94
- # @param context [String] error context
95
- # @param deep [Boolean] whether nested hashes should also be validated
96
- # @return [void]
97
- def assert_string_keys!(value, context: 'hash', deep: true)
98
- return unless value in Hash
99
+ unless value.each_key.all?(String)
100
+ invalid_key = value.keys.find { _1.class != String }
101
+ raise InvalidOption, "#{context} must use string keys (found #{invalid_key.inspect})"
102
+ end
99
103
 
100
- unless value.each_key.all?(String)
101
- invalid_key = value.keys.find { _1.class != String }
102
- raise ArgumentError, "#{context} must use string keys (found #{invalid_key.inspect})"
104
+ value.each_value { assert_string_keys!(_1, context:, deep:) } if deep
103
105
  end
104
106
 
105
- value.each_value { assert_string_keys!(_1, context:, deep:) } if deep
106
- end
107
+ private
107
108
 
108
- def symbol_key(key, context:)
109
- case key
110
- in Symbol then key
111
- in String then key.to_sym
112
- else
113
- raise ArgumentError, "#{context} must use string or symbol keys (found #{key.inspect})"
109
+ def symbol_key(key, context:)
110
+ case key
111
+ in Symbol then key
112
+ in String then key.to_sym
113
+ else
114
+ raise InvalidOption, "#{context} must use string or symbol keys (found #{key.inspect})"
115
+ end
114
116
  end
115
117
  end
116
- private_class_method :symbol_key
117
118
  end
118
119
  end
@@ -28,24 +28,21 @@ module Html2rss
28
28
  def self.call(article_tag, title: nil)
29
29
  return [] unless article_tag
30
30
 
31
- extract_all_categories(article_tag, title:)
32
- .map(&:strip)
33
- .reject(&:empty?)
31
+ extract_all_categories(article_tag, title:).to_a
34
32
  end
35
33
 
36
34
  ##
37
35
  # Optimized single DOM traversal that extracts all category types.
36
+ # `Node#css` only yields descendants, so the container itself is never visited.
38
37
  #
39
38
  # @param article_tag [Nokogiri::XML::Element] The article element
40
39
  # @param title [String, nil]
41
40
  # @return [Set<String>] Set of category strings
42
- def self.extract_all_categories(article_tag, title: nil)
41
+ def self.extract_all_categories(article_tag, title:)
43
42
  Set.new.tap do |categories|
44
43
  extract_element_data_categories!(categories, article_tag, title:)
45
44
  article_tag.css(CATEGORY_SELECTORS.join(',')).each do |element|
46
- next if element == article_tag
47
-
48
- if ArticleRules::Category.class_match?(element['class'])
45
+ if ArticleRules::Category.class_match?(element['class'].to_s.split)
49
46
  extract_text_categories!(categories, element, title:)
50
47
  end
51
48
  extract_element_data_categories!(categories, element, title:)
@@ -60,7 +57,7 @@ module Html2rss
60
57
  # @param element [Nokogiri::XML::Element] metadata element that may contain category links
61
58
  # @param title [String, nil]
62
59
  # @return [void]
63
- def self.extract_element_data_categories!(categories, element, title: nil)
60
+ def self.extract_element_data_categories!(categories, element, title:)
64
61
  element.attributes.each_value do |attr|
65
62
  next unless ArticleRules::Category.attr_name_match?(attr.name)
66
63
 
@@ -75,7 +72,7 @@ module Html2rss
75
72
  # @param element [Nokogiri::XML::Element] metadata element whose text may contain delimiters
76
73
  # @param title [String, nil]
77
74
  # @return [void]
78
- def self.extract_text_categories!(categories, element, title: nil)
75
+ def self.extract_text_categories!(categories, element, title:)
79
76
  return if category_container?(element)
80
77
 
81
78
  anchors = element.name == 'a' ? [element] : element.css('a').to_a
@@ -96,7 +93,7 @@ module Html2rss
96
93
  # @param element [Nokogiri::XML::Element] The element to extract text from
97
94
  # @param title [String, nil]
98
95
  # @return [void]
99
- def self.add_text_to_categories!(categories, element, title: nil)
96
+ def self.add_text_to_categories!(categories, element, title:)
100
97
  ArticleRules::Category.add_text!(categories, Navigator.extract_visible_text(element), title:)
101
98
  end
102
99
 
@@ -9,7 +9,7 @@ module Html2rss
9
9
  # @param leftover_lines [Array<String>] leftover visible lines (already split)
10
10
  # @param time_zone [String] channel time zone for naive values
11
11
  # @return [DateTime, nil]
12
- def self.call(article_tag, leftover_lines: [], time_zone: 'UTC')
12
+ def self.call(article_tag, time_zone:, leftover_lines: [])
13
13
  datetimes = article_tag.css('[datetime]').map { |tag| tag['datetime'] }
14
14
  ArticleRules::Date.earliest(datetimes, leftover_lines:, time_zone:)
15
15
  end
@@ -4,46 +4,25 @@ module Html2rss
4
4
  module Html
5
5
  class ArticleExtractor
6
6
  ##
7
- # Extracts enclosures from HTML tags using various strategies.
7
+ # Adapts Nokogiri elements to {ArticleRules::Enclosure}, which owns which elements become enclosures.
8
8
  class EnclosureExtractor
9
- # CSS union query covering images, media, PDFs, iframes, and archives.
10
- SELECTOR = [
11
- 'img[src]:not([src^="data"])',
12
- 'video source[src]',
13
- 'audio source[src]',
14
- 'audio[src]',
15
- 'a[href$=".pdf"]',
16
- 'iframe[src]',
17
- 'a[href$=".zip"]',
18
- 'a[href$=".tar.gz"]',
19
- 'a[href$=".tgz"]'
20
- ].join(',').freeze
9
+ # CSS union of the element names {ArticleRules::Enclosure} may turn into an enclosure.
10
+ SELECTOR = ArticleRules::Enclosure::ELEMENT_NAMES.join(',').freeze
11
+ # CSS union of the players a +source+ element must sit inside.
12
+ PLAYERS = ArticleRules::Enclosure::MEDIA_ELEMENTS.join(',').freeze
21
13
 
22
14
  # @param article_tag [Nokogiri::XML::Element] article container node
23
15
  # @param base_url [String, Html2rss::Url] base URL for relative enclosure links
24
- # @return [Array<Hash{Symbol => Object}>] normalized enclosure hashes
16
+ # @return [Array<Hash{Symbol => Object}>] normalized enclosure hashes in document order, one per URL
25
17
  def self.call(article_tag, base_url)
26
- return [] unless article_tag
27
-
28
- article_tag.css(SELECTOR).filter_map do |element|
29
- extract_from_element(element, base_url)
18
+ found = article_tag.css(SELECTOR).filter_map do |element|
19
+ attrs = { src: element['src'], href: element['href'], type: element['type'] }
20
+ ArticleRules::Enclosure.from_element(element.name, base_url, **attrs) do
21
+ element.ancestors(PLAYERS).first&.name
22
+ end
30
23
  end
24
+ ArticleRules::Enclosure.unique(found)
31
25
  end
32
-
33
- def self.extract_from_element(element, base_url)
34
- case element.name
35
- when 'img'
36
- ArticleRules::Enclosure.from_image(element['src'], base_url)
37
- when 'video', 'audio', 'source'
38
- ArticleRules::Enclosure.from_media(element['src'], element['type'], base_url)
39
- when 'iframe'
40
- ArticleRules::Enclosure.from_iframe(element['src'], base_url)
41
- when 'a'
42
- ArticleRules::Enclosure.from_anchor(element['href'], base_url)
43
- end
44
- end
45
-
46
- private_class_method :extract_from_element
47
26
  end
48
27
  end
49
28
  end
@@ -12,10 +12,9 @@ module Html2rss
12
12
  class << self
13
13
  ##
14
14
  # @param article_tag [Nokogiri::XML::Element] container node
15
- # @param fallback_anchorless [Boolean] whether to use fallback search
16
15
  # @param selected_anchor [Nokogiri::XML::Node, nil] anchor element
17
16
  # @return [Nokogiri::XML::Node, nil] the heading node, if found
18
- def call(article_tag, fallback_anchorless:, selected_anchor:)
17
+ def call(article_tag, selected_anchor:)
19
18
  tags = article_tag.css(HEADING_TAGS.join(','))
20
19
  if tags.any?
21
20
  select_best_heading(tags)
@@ -23,7 +22,7 @@ module Html2rss
23
22
  labeled = heading_from_aria_or_title(article_tag)
24
23
  return labeled if labeled
25
24
 
26
- fallback_heading(article_tag) if fallback_anchorless && selected_anchor.nil?
25
+ fallback_heading(article_tag) if selected_anchor.nil?
27
26
  end
28
27
  end
29
28
 
@@ -14,16 +14,15 @@ module Html2rss
14
14
  # @param heading [Nokogiri::XML::Node, nil] heading node
15
15
  # @param url [Html2rss::Url, nil] absolute article URL
16
16
  # @param selected_anchor [Nokogiri::XML::Node, nil] anchor element
17
- # @param fallback_anchorless [Boolean] whether to use fallback hashing
18
17
  # @return [String, nil] the generated ID, if any
19
- def call(article_tag, heading:, url:, selected_anchor:, fallback_anchorless:)
18
+ def call(article_tag, heading:, url:, selected_anchor:)
20
19
  id_from_dom = parse_id_from_dom(article_tag, url, selected_anchor)
21
20
  return id_from_dom if id_from_dom
22
21
 
23
- heading_text = resolve_heading_text(article_tag, heading, fallback_anchorless)
22
+ heading_text = resolve_heading_text(article_tag, heading)
24
23
  if heading_text && !heading_text.strip.empty?
25
24
  generate_slug(heading_text)
26
- elsif fallback_anchorless
25
+ else
27
26
  generate_content_hash(article_tag)
28
27
  end
29
28
  end
@@ -32,22 +31,20 @@ module Html2rss
32
31
 
33
32
  def parse_id_from_dom(article_tag, url, selected_anchor)
34
33
  candidates = [article_tag['id'], article_tag.at_css('[id]')&.attr('id')]
35
- candidates += [url&.path, url&.query] if selected_anchor
34
+ candidates += [url&.path] if selected_anchor
36
35
  candidates.compact.reject(&:empty?).first
37
36
  end
38
37
 
39
- def resolve_heading_text(article_tag, heading, fallback_anchorless)
38
+ def resolve_heading_text(article_tag, heading)
40
39
  text = heading ? Navigator::TextExtractor.call(heading) : nil
41
40
  if text.nil? || text.strip.empty?
42
- fallback_text_node_content(article_tag, fallback_anchorless)
41
+ fallback_text_node_content(article_tag)
43
42
  else
44
43
  text
45
44
  end
46
45
  end
47
46
 
48
- def fallback_text_node_content(article_tag, fallback_anchorless)
49
- return unless fallback_anchorless
50
-
47
+ def fallback_text_node_content(article_tag)
51
48
  article_tag.xpath('.//text()').find { |t| !t.text.strip.empty? }&.text&.strip
52
49
  end
53
50
 
@@ -12,7 +12,8 @@ module Html2rss
12
12
  def self.call(article_tag, base_url:)
13
13
  img_src = from_source(article_tag) ||
14
14
  from_img(article_tag) ||
15
- from_style(article_tag)
15
+ from_style(article_tag) ||
16
+ from_poster(article_tag)
16
17
 
17
18
  Url.from_relative(img_src, base_url) if img_src
18
19
  end
@@ -43,6 +44,13 @@ module Html2rss
43
44
  styles = article_tag.css('[style*="url"]').map { |tag| tag['style'] }
44
45
  ArticleRules::Image.best_from_styles(styles)
45
46
  end
47
+
48
+ # @param article_tag [Nokogiri::XML::Element] article container node
49
+ # @return [String, nil] first usable video poster frame: the last resort, when the card has no other image
50
+ def self.from_poster(article_tag)
51
+ posters = article_tag.css('video[poster]').map { |video| video['poster'] }
52
+ posters.find { |src| ArticleRules::Image.remote_src?(src) }
53
+ end
46
54
  end
47
55
  end
48
56
  end