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
@@ -89,6 +89,8 @@ module Html2rss
89
89
  # @option options [Boolean, nil] :enhance whether to force enhance: true
90
90
  # @option options [Integer, nil] :max_redirects optional redirect limit override
91
91
  # @option options [Integer, nil] :max_requests optional request budget override
92
+ # @option options [Integer, nil] :total_timeout_seconds optional wall-clock bound (seconds) for the whole
93
+ # request chain, including every +:auto+ fallback hop (shared budget, not per hop)
92
94
  # @option options [Integer, nil] :limit max articles to keep
93
95
  # @option options [String, nil] :local_file_path optional local HTML file path
94
96
  # @return [CaptureResult]
@@ -108,6 +110,8 @@ module Html2rss
108
110
  # @option options [Boolean, nil] :enhance whether to force enhance: true
109
111
  # @option options [Integer, nil] :max_redirects optional redirect limit override
110
112
  # @option options [Integer, nil] :max_requests optional request budget override
113
+ # @option options [Integer, nil] :total_timeout_seconds optional wall-clock bound (seconds) for the whole
114
+ # request chain, including every +:auto+ fallback hop (shared budget, not per hop)
111
115
  # @option options [Integer, nil] :limit max articles to keep
112
116
  # @option options [String, nil] :local_file_path optional local HTML file path
113
117
  def initialize(url, strategy: :auto, **options) # rubocop:disable Metrics/MethodLength
@@ -119,8 +123,7 @@ module Html2rss
119
123
  @summary = options.delete(:summary)
120
124
  @force = options.delete(:force) || false
121
125
  @enhance_option = options.delete(:enhance)
122
- @max_redirects = options.delete(:max_redirects)
123
- @max_requests = options.delete(:max_requests)
126
+ @request_options = options.slice(:max_redirects, :max_requests, :total_timeout_seconds)
124
127
  @limit = options.delete(:limit)
125
128
  @local_file_path = options.delete(:local_file_path)
126
129
  end
@@ -147,7 +150,6 @@ module Html2rss
147
150
 
148
151
  CaptureResult.new(
149
152
  config:,
150
- yaml: "#{SCHEMA_MODELINE}\n#{Config.to_yaml(config)}",
151
153
  articles_count: outcome.articles.size,
152
154
  channel_title: ch_title,
153
155
  has_selectors: !selectors.empty?,
@@ -173,15 +175,10 @@ module Html2rss
173
175
  end
174
176
 
175
177
  def probe_native_feed(response)
176
- return nil unless response && @url
177
-
178
- Syndication::Discovery.best_feed_url(
179
- page_url: @url,
180
- request_session: discovery_session,
181
- parsed_body: (response.parsed_body if response.html_response?),
182
- html: response.body
183
- )&.to_s
184
- rescue StandardError
178
+ Syndication::Discovery.native_feed_url(response:, request_session: discovery_session)&.to_s
179
+ rescue Html2rss::ContractError
180
+ raise
181
+ rescue Html2rss::Error
185
182
  nil
186
183
  end
187
184
 
@@ -237,11 +234,7 @@ module Html2rss
237
234
  Config.auto_source_config(
238
235
  url: @url,
239
236
  items_selector: @items_selector_hint,
240
- request_controls: Config::RequestControls.from_shortcut(
241
- strategy: @strategy,
242
- max_redirects: @max_redirects,
243
- max_requests: @max_requests
244
- ),
237
+ request_controls: Config::RequestControls.from_shortcut(strategy: @strategy, **@request_options),
245
238
  limit: @limit
246
239
  ).tap { |config| apply_local_file_path!(config) }
247
240
  end
@@ -270,9 +263,6 @@ module Html2rss
270
263
  return [*default_selectors, SelectorCandidates.empty] unless sst
271
264
 
272
265
  select_enhance_selectors(sst, articles)
273
- rescue ArgumentError => error
274
- Log.warn("Capture selector derivation failed: #{error.message}")
275
- [*default_selectors, SelectorCandidates.empty]
276
266
  end
277
267
 
278
268
  def hint_selectors
@@ -300,7 +290,7 @@ module Html2rss
300
290
 
301
291
  SEGMENT_STRATEGIES.flat_map do |strategy|
302
292
  segments = AutoSource::Segmenter.call(
303
- sst, base_url: @url, strategy:, permit_unanchored: false, link_resolver:
293
+ sst, base_url: @url, strategy:, link_resolver:
304
294
  )
305
295
  matched = match_segments_to_articles(segments, articles)
306
296
  next [] if matched.size < MIN_SELECTOR_MATCHES
@@ -338,42 +328,34 @@ module Html2rss
338
328
  end
339
329
  end
340
330
 
341
- def find_matching_article(segment, articles_by_url) # rubocop:disable Metrics/AbcSize
342
- href = segment.primary_link.attrs.href.to_s
343
- return articles_by_url.values.find { |a| a.title == title_from_segment(segment) } if href.empty?
344
-
345
- resolved = Url.from_relative(href, @url)
331
+ def find_matching_article(segment, articles_by_url)
332
+ resolved = Url.from_relative(segment.primary_link.attrs.href.to_s, @url)
346
333
  articles_by_url[resolved.to_s] || articles_by_url.values.find { |a| fuzzy_url_match?(a.url, resolved) }
347
334
  end
348
335
 
349
336
  def fuzzy_url_match?(article_url, resolved)
350
337
  article_url.to_s.end_with?(resolved.path.to_s)
351
- rescue StandardError
352
- false
353
- end
354
-
355
- def title_from_segment(segment)
356
- segment.root_node.visible_text.to_s.strip
357
338
  end
358
339
 
359
340
  def lift_heading_link_roots(roots)
360
- roots.map { |root| lift_heading_link_root(root, roots) }
341
+ enclosed = enclosed_root_counts(roots)
342
+ roots.map { |root| lift_heading_link_root(root, enclosed) }
361
343
  end
362
344
 
363
- def lift_heading_link_root(root, all_roots)
345
+ def lift_heading_link_root(root, enclosed)
364
346
  return root unless heading_or_inner_title_link?(root)
365
347
 
366
348
  index = SST::Index.for_node(root)
367
349
  return root unless index
368
350
 
369
- walk_usable_card(root, index, all_roots)
351
+ walk_usable_card(root, index, enclosed)
370
352
  end
371
353
 
372
- def walk_usable_card(root, index, all_roots)
354
+ def walk_usable_card(root, index, enclosed)
373
355
  candidate = root
374
356
  parent = index.parent_of(root)
375
- while parent && Html::Navigator.usable_card_parent?(parent)
376
- break if contains_other_root?(parent, root, all_roots)
357
+ while Html::Navigator.usable_card_parent?(parent)
358
+ break if contains_other_root?(parent, enclosed)
377
359
 
378
360
  candidate = parent
379
361
  parent = index.parent_of(parent)
@@ -381,6 +363,28 @@ module Html2rss
381
363
  candidate
382
364
  end
383
365
 
366
+ # Identity-keyed tally of how many distinct roots each node is, or strictly encloses, so the
367
+ # card walk answers "does this ancestor hold another root?" without rescanning all roots.
368
+ def enclosed_root_counts(roots)
369
+ tally = Hash.new(0).compare_by_identity
370
+ roots.uniq(&:object_id).each do |root|
371
+ tally[root] += 1
372
+ count_enclosing_ancestors(root, tally)
373
+ end
374
+ tally
375
+ end
376
+
377
+ def count_enclosing_ancestors(root, tally)
378
+ index = SST::Index.for_node(root)
379
+ return unless index
380
+
381
+ ancestor = index.parent_of(root)
382
+ while ancestor
383
+ tally[ancestor] += 1
384
+ ancestor = index.parent_of(ancestor)
385
+ end
386
+ end
387
+
384
388
  def heading_or_inner_title_link?(node)
385
389
  name = node.name.to_s
386
390
  return false if wrapping_anchor_root?(node)
@@ -395,16 +399,9 @@ module Html2rss
395
399
  node.find { |child| !child.equal?(node) && tags.include?(child.name.to_s) }
396
400
  end
397
401
 
398
- def contains_other_root?(parent, root, all_roots)
399
- all_roots.any? do |other|
400
- next if other.equal?(root)
401
-
402
- other.equal?(parent) || sst_descendant?(other, parent)
403
- end
404
- end
405
-
406
- def sst_descendant?(child, ancestor)
407
- SST::Index.for_node(child)&.descendant_of?(child, ancestor)
402
+ # The walked-from root is itself tallied under every ancestor, so more than one means another root.
403
+ def contains_other_root?(parent, enclosed)
404
+ enclosed[parent] > 1
408
405
  end
409
406
 
410
407
  def shared_class_items_selector(roots)
@@ -420,12 +417,7 @@ module Html2rss
420
417
  end
421
418
 
422
419
  def path_items_selector(roots)
423
- paths = roots.map(&:tag_path)
424
- common = common_path_prefix(paths)
425
- tag_path = common.empty? ? paths.first.to_s : common
426
- return if tag_path.empty?
427
-
428
- css_from_trimmed_tag_path(tag_path)
420
+ css_from_trimmed_tag_path(common_path_prefix(roots.map(&:tag_path)))
429
421
  end
430
422
 
431
423
  def css_from_trimmed_tag_path(tag_path)
@@ -443,9 +435,7 @@ module Html2rss
443
435
  trimmed.empty? ? [segments.last] : trimmed
444
436
  end
445
437
 
446
- def common_path_prefix(paths) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity
447
- return '' if paths.empty?
448
-
438
+ def common_path_prefix(paths)
449
439
  segments = paths.map { |p| p.split('/').reject(&:empty?) }
450
440
  prefix = segments.first
451
441
  segments.each do |seg|
@@ -466,7 +456,9 @@ module Html2rss
466
456
 
467
457
  def channel_title_from(response)
468
458
  Channel.from_response(response).title
469
- rescue StandardError
459
+ rescue Html2rss::ContractError
460
+ raise
461
+ rescue Html2rss::Error
470
462
  nil
471
463
  end
472
464
 
@@ -486,7 +478,7 @@ module Html2rss
486
478
  return final unless Url.from_absolute(@url).same_document?(Url.from_absolute(final))
487
479
 
488
480
  nil
489
- rescue ArgumentError
481
+ rescue Html2rss::Url::InvalidUrlError
490
482
  nil
491
483
  end
492
484
  end
@@ -3,7 +3,9 @@
3
3
  require 'time'
4
4
 
5
5
  module Html2rss
6
- Channel = Data.define(:title, :url, :description, :language, :ttl, :last_build_date, :image, :author)
6
+ Channel = Data.define(
7
+ :title, :url, :description, :language, :ttl, :last_build_date, :image, :author, :categories, :explicit
8
+ )
7
9
 
8
10
  ##
9
11
  # Materialized channel metadata for feed builders and {FeedResult}.
@@ -12,6 +14,10 @@ module Html2rss
12
14
  # do not own channel extraction. Safe to Marshal (no Response / Nokogiri retained).
13
15
  # Strings and image URL are normalized and frozen at construction (including Marshal load).
14
16
  #
17
+ # The author is the config +channel.author+, else the followed native feed's author
18
+ # ({Syndication::ChannelFacts}), else the page's +<meta name="author">+. Categories and the explicit
19
+ # flag come only from that feed, when it states them.
20
+ #
15
21
  # @!attribute [r] title
16
22
  # @return [String]
17
23
  # @!attribute [r] url
@@ -28,6 +34,10 @@ module Html2rss
28
34
  # @return [Html2rss::Url, nil]
29
35
  # @!attribute [r] author
30
36
  # @return [String, nil]
37
+ # @!attribute [r] categories
38
+ # @return [Array<Array<String>>] each category followed by its subcategories (empty when unstated)
39
+ # @!attribute [r] explicit
40
+ # @return [Boolean, nil] whether the content is declared explicit; nil when unstated
31
41
  class Channel # rubocop:disable Metrics/ClassLength -- Channel value object owns extract + freeze + Marshal
32
42
  class << self
33
43
  ##
@@ -35,8 +45,9 @@ module Html2rss
35
45
  #
36
46
  # @param response [Html2rss::RequestService::Response]
37
47
  # @param overrides [Hash{Symbol => Object}] optional overrides for channel attributes
48
+ # @param feed [Html2rss::Syndication::ChannelFacts, nil] what the followed native feed states
38
49
  # @return [Html2rss::Channel]
39
- def from_response(response, overrides: {})
50
+ def from_response(response, overrides: {}, feed: nil) # rubocop:disable Metrics/MethodLength -- one line per member
40
51
  new(
41
52
  title: title_for(response, overrides),
42
53
  url: url_for(response),
@@ -45,7 +56,9 @@ module Html2rss
45
56
  ttl: ttl_for(response, overrides),
46
57
  last_build_date: last_build_date_for(response),
47
58
  image: image_for(response, overrides),
48
- author: author_for(response, overrides)
59
+ author: overrides[:author] || feed&.author || meta_author(response),
60
+ categories: feed&.categories || [],
61
+ explicit: feed&.explicit
49
62
  )
50
63
  end
51
64
 
@@ -117,8 +130,7 @@ module Html2rss
117
130
  parsed_body(response)['lang'] || parsed_body(response).at_css('[lang]')&.[]('lang')
118
131
  end
119
132
 
120
- def author_for(response, overrides)
121
- return overrides[:author] if overrides[:author]
133
+ def meta_author(response)
122
134
  return unless html_response?(response)
123
135
 
124
136
  parsed_body(response).at_css('meta[name="author"]')&.[]('content')
@@ -156,8 +168,11 @@ module Html2rss
156
168
  # @param last_build_date [Time, String]
157
169
  # @param image [Html2rss::Url, String, nil]
158
170
  # @param author [String, nil]
159
- # rubocop:disable-next Metrics/ParameterLists -- Data.define members are the channel contract
160
- def initialize(title:, url:, description:, language:, ttl:, last_build_date:, image:, author:)
171
+ # @param categories [Array<Array<String>>] each category followed by its subcategories
172
+ # @param explicit [Boolean, nil]
173
+ # rubocop:disable-next Metrics/ParameterLists, Metrics/MethodLength -- Data.define members are the channel contract
174
+ def initialize(title:, url:, description:, language:, ttl:, last_build_date:, image:, author:,
175
+ categories: [], explicit: nil)
161
176
  super(
162
177
  title: freeze_string(title),
163
178
  url: url.is_a?(Url) ? url : Url.from_absolute(url),
@@ -166,16 +181,19 @@ module Html2rss
166
181
  ttl: Integer(ttl),
167
182
  last_build_date: normalize_last_build_date(last_build_date),
168
183
  image: normalize_image(image),
169
- author: author && freeze_string(author)
184
+ author: author && freeze_string(author),
185
+ categories: categories.map { |path| path.map { freeze_string(_1) }.freeze }.freeze,
186
+ explicit:
170
187
  )
171
188
  end
172
189
 
173
190
  private
174
191
 
175
- def marshal_dump = [title, url, description, language, ttl, last_build_date, image, author]
192
+ def marshal_dump = [title, url, description, language, ttl, last_build_date, image, author, categories, explicit]
176
193
 
177
- def marshal_load((title, url, description, language, ttl, last_build_date, image, author))
178
- initialize(title:, url:, description:, language:, ttl:, last_build_date:, image:, author:)
194
+ def marshal_load((title, url, description, language, ttl, last_build_date, image, author, categories, explicit))
195
+ initialize(title:, url:, description:, language:, ttl:, last_build_date:, image:, author:, categories:,
196
+ explicit:)
179
197
  end
180
198
 
181
199
  def freeze_string(value) = value.to_s.dup.freeze
@@ -189,7 +207,7 @@ module Html2rss
189
207
 
190
208
  def normalize_last_build_date(value)
191
209
  return value if value.is_a?(Time)
192
- raise ArgumentError, 'last_build_date must be a Time or HTTP-date String' unless value.is_a?(String)
210
+ raise InvalidOption, 'last_build_date must be a Time or HTTP-date String' unless value.is_a?(String)
193
211
 
194
212
  Time.httpdate(value)
195
213
  end
@@ -10,116 +10,117 @@ module Html2rss
10
10
  # ANSI colors for recon verdict labels in text output.
11
11
  VERDICT_COLORS = { build: "\e[32m", defer: "\e[33m" }.freeze
12
12
 
13
- module_function
14
-
15
- ##
16
- # @param results [Array<Hash>]
17
- # @param format [String]
18
- # @param batch_mode [Boolean]
19
- # @return [void]
20
- def inspect_output(results, format:, batch_mode:)
21
- if format == 'json'
22
- json(results, batch_mode)
23
- else
24
- results.each { |data| probe_card(ProbeView.from_wire(data)) }
13
+ class << self
14
+ ##
15
+ # @param results [Array<Hash>]
16
+ # @param format [String]
17
+ # @param batch_mode [Boolean]
18
+ # @return [void]
19
+ def inspect_output(results, format:, batch_mode:)
20
+ if format == 'json'
21
+ json(results, batch_mode)
22
+ else
23
+ results.each { |data| probe_card(ProbeView.from_wire(data)) }
24
+ end
25
25
  end
26
- end
27
26
 
28
- ##
29
- # @param results [Array<Html2rss::Recon::Result>]
30
- # @param format [String]
31
- # @param batch_mode [Boolean]
32
- # @param url_only [Boolean]
33
- # @return [void]
34
- def recon_output(results, format:, batch_mode:, url_only: false) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
35
- if url_only
36
- results.each { |r| puts r.requested_url }
37
- elsif format == 'json'
38
- json(results.map(&:to_h), batch_mode)
39
- elsif format == 'tsv'
40
- puts %w[verdict status requested_url final_url native_feed notes].join("\t")
41
- results.each do |r|
42
- row = [
43
- r.verdict.to_s.upcase,
44
- r.status || '-',
45
- r.requested_url,
46
- r.final_url,
47
- r.native_feed || '-',
48
- r.notes.join('; ')
49
- ]
50
- puts row.join("\t")
27
+ ##
28
+ # @param results [Array<Html2rss::Recon::Result>]
29
+ # @param format [String]
30
+ # @param batch_mode [Boolean]
31
+ # @param url_only [Boolean]
32
+ # @return [void]
33
+ def recon_output(results, format:, batch_mode:, url_only: false) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
34
+ if url_only
35
+ results.each { |r| puts r.requested_url }
36
+ elsif format == 'json'
37
+ json(results.map(&:to_h), batch_mode)
38
+ elsif format == 'tsv'
39
+ puts %w[verdict status requested_url final_url native_feed notes].join("\t")
40
+ results.each do |r|
41
+ row = [
42
+ r.verdict.to_s.upcase,
43
+ r.status || '-',
44
+ r.requested_url,
45
+ r.final_url,
46
+ r.native_feed || '-',
47
+ r.notes.join('; ')
48
+ ]
49
+ puts row.join("\t")
50
+ end
51
+ else
52
+ results.each { |r| probe_card(ProbeView.from_recon(r)) }
51
53
  end
52
- else
53
- results.each { |r| probe_card(ProbeView.from_recon(r)) }
54
54
  end
55
- end
56
55
 
57
- ##
58
- # @param view [ProbeView]
59
- # @return [void]
60
- def probe_card(view) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
61
- if view.verdict
62
- color = VERDICT_COLORS.fetch(view.verdict.to_sym, "\e[31m")
63
- puts "#{color}[#{view.verdict.to_s.upcase}]\e[0m #{view.requested}"
64
- else
65
- puts view.requested
66
- end
67
- probe_lines(view)
68
- if view.alternate_feeds.any?
69
- hrefs = view.alternate_feeds.filter_map { |f| ProbeView.wire_val(f, :href) }
70
- puts " Feeds: #{hrefs.join(', ')}" if hrefs.any?
56
+ ##
57
+ # @param view [ProbeView]
58
+ # @return [void]
59
+ def probe_card(view) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
60
+ if view.verdict
61
+ color = VERDICT_COLORS.fetch(view.verdict.to_sym, "\e[31m")
62
+ puts "#{color}[#{view.verdict.to_s.upcase}]\e[0m #{view.requested}"
63
+ else
64
+ puts view.requested
65
+ end
66
+ probe_lines(view)
67
+ if view.alternate_feeds.any?
68
+ hrefs = view.alternate_feeds.filter_map { |f| ProbeView.wire_val(f, :href) }
69
+ puts " Feeds: #{hrefs.join(', ')}" if hrefs.any?
70
+ end
71
+ puts " Feed: #{view.native_feed}" if view.native_feed
72
+ puts " Notes: #{view.notes.join(', ')}" if view.notes.any?
73
+ puts " Strategy: #{view.strategy}" if view.strategy
74
+ puts ''
71
75
  end
72
- puts " Feed: #{view.native_feed}" if view.native_feed
73
- puts " Notes: #{view.notes.join(', ')}" if view.notes.any?
74
- puts " Strategy: #{view.strategy}" if view.strategy
75
- puts ''
76
- end
77
76
 
78
- ##
79
- # @param result [Html2rss::Test::Result]
80
- # @param source [String]
81
- # @return [void]
82
- def test_card(result, source) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
83
- if result.success
84
- puts "\e[32m✓ Schema valid\e[0m (#{source})"
85
- dur = "#{result.duration_seconds}s"
86
- puts "\e[32m✓ Extracted #{result.item_count} items in #{dur}\e[0m (strategy: #{result.strategy_used})"
87
- puts "\nChannel: #{result.channel_title} (#{result.channel_url})"
88
- if result.sample_items.any?
89
- puts 'Sample items:'
90
- result.sample_items.each_with_index do |item, i|
91
- puts " #{i + 1}. #{"#{item[:published_at]} | " if item[:published_at]}#{item[:title]}"
92
- puts " #{item[:url]}"
77
+ ##
78
+ # @param result [Html2rss::Test::Result]
79
+ # @param source [String]
80
+ # @return [void]
81
+ def test_card(result, source) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
82
+ if result.success
83
+ puts "\e[32m✓ Schema valid\e[0m (#{source})"
84
+ dur = "#{result.duration_seconds}s"
85
+ puts "\e[32m✓ Extracted #{result.item_count} items in #{dur}\e[0m (strategy: #{result.strategy_used})"
86
+ puts "\nChannel: #{result.channel_title} (#{result.channel_url})"
87
+ if result.sample_items.any?
88
+ puts 'Sample items:'
89
+ result.sample_items.each_with_index do |item, i|
90
+ puts " #{i + 1}. #{"#{item[:published_at]} | " if item[:published_at]}#{item[:title]}"
91
+ puts " #{item[:url]}"
92
+ end
93
+ end
94
+ else
95
+ $stderr.puts "\e[31m✗ Test failed\e[0m (#{source})" # rubocop:disable Style/StderrPuts
96
+ $stderr.puts " Error: #{result.error_message}" if result.error_message # rubocop:disable Style/StderrPuts
97
+ result.validation_issues&.each do |issue|
98
+ $stderr.puts " Schema error #{issue}" # rubocop:disable Style/StderrPuts
93
99
  end
94
- end
95
- else
96
- $stderr.puts "\e[31m✗ Test failed\e[0m (#{source})" # rubocop:disable Style/StderrPuts
97
- $stderr.puts " Error: #{result.error_message}" if result.error_message # rubocop:disable Style/StderrPuts
98
- result.validation_issues&.each do |issue|
99
- $stderr.puts " Schema error #{issue}" # rubocop:disable Style/StderrPuts
100
100
  end
101
101
  end
102
- end
103
102
 
104
- ##
105
- # @param data [Object]
106
- # @param batch_mode [Boolean]
107
- # @return [void]
108
- def json(data, batch_mode)
109
- payload = batch_mode ? data : data.first
110
- puts JSON.pretty_generate(payload)
111
- end
103
+ ##
104
+ # @param data [Object]
105
+ # @param batch_mode [Boolean]
106
+ # @return [void]
107
+ def json(data, batch_mode)
108
+ payload = batch_mode ? data : data.first
109
+ puts JSON.pretty_generate(payload)
110
+ end
112
111
 
113
- ##
114
- # @param view [ProbeView]
115
- # @return [void]
116
- def probe_lines(view)
117
- if view.final && view.final != view.requested
118
- puts " Final: #{view.final} (HTTP #{view.status || 'ERR'})"
112
+ private
113
+
114
+ ##
115
+ # @param view [ProbeView]
116
+ # @return [void]
117
+ def probe_lines(view)
118
+ if view.final && view.final != view.requested
119
+ puts " Final: #{view.final} (HTTP #{view.status || 'ERR'})"
120
+ end
121
+ puts " Surface: #{view.surface} (#{view.articles_count} articles)"
119
122
  end
120
- puts " Surface: #{view.surface} (#{view.articles_count} articles)"
121
123
  end
122
- private_class_method :probe_lines
123
124
  end
124
125
  end
125
126
  end
@@ -5,15 +5,13 @@ module Html2rss
5
5
  ##
6
6
  # Validate command: file/glob/stdin branching and multi-file reporting.
7
7
  module Validate
8
- module_function
9
-
10
8
  ##
11
9
  # @param files [Array<String>]
12
10
  # @param params [Hash]
13
11
  # @param quiet [Boolean]
14
12
  # @return [void]
15
13
  # @raise [Thor::Error]
16
- def run(files, params:, quiet:) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
14
+ def self.run(files, params:, quiet:) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
17
15
  if files.size == 2 && File.file?(files[0].to_s) && !File.exist?(files[1].to_s)
18
16
  raise Thor::Error, "No such file: #{files[1]}" if path_like_config?(files[1])
19
17
 
@@ -45,7 +43,7 @@ module Html2rss
45
43
  ##
46
44
  # @param files [Array<String>]
47
45
  # @return [Array<String>]
48
- def resolve_files(files)
46
+ def self.resolve_files(files)
49
47
  return ['-'] if files.empty? || files == ['-']
50
48
 
51
49
  resolved = []