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
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ class FeedPipeline
5
+ ##
6
+ # Planner for the runtime request policy and budgets of a feed run.
7
+ #
8
+ # HTTP request slots (pagination, document GETs, strategy fallback) are planned
9
+ # separately from Browserless preload interaction budget so preload cannot steal
10
+ # pagination slots.
11
+ class RuntimePolicy
12
+ # Policy plus Budget from one config expansion (shared meters for a feed run).
13
+ Resources = Data.define(:policy, :budget)
14
+
15
+ ##
16
+ # @param config [Html2rss::Config] validated feed config
17
+ # @return [Html2rss::RequestService::Policy] request policy derived from runtime config
18
+ def self.from_config(config)
19
+ RequestService::Policy.new(
20
+ max_requests: effective_max_requests_for(config),
21
+ max_redirects: config.max_redirects,
22
+ total_timeout_seconds: config.total_timeout_seconds || RequestService::Policy::DEFAULTS[:total_timeout_seconds]
23
+ )
24
+ end
25
+
26
+ ##
27
+ # Builds policy and budget from one `from_config` so FeedPipeline does not dual-own expansion.
28
+ #
29
+ # @param config [Html2rss::Config] validated feed config
30
+ # @return [Resources] shared policy + budget for the feed build
31
+ def self.resources_for(config)
32
+ policy = from_config(config)
33
+ Resources.new(
34
+ policy:,
35
+ budget: RequestService::Budget.new(
36
+ max_requests: policy.max_requests,
37
+ max_interactions: interaction_budget_for(config),
38
+ total_timeout_seconds: policy.total_timeout_seconds
39
+ )
40
+ )
41
+ end
42
+
43
+ ##
44
+ # Builds a Budget with separate request and interaction slot pools.
45
+ #
46
+ # @param config [Html2rss::Config] validated feed config
47
+ # @return [Html2rss::RequestService::Budget] shared budget for the feed build
48
+ def self.budget_for(config) = resources_for(config).budget
49
+
50
+ ##
51
+ # @param config [Html2rss::Config] validated feed config
52
+ # @return [Integer] preload interaction slots derived from browserless preload config
53
+ def self.interaction_budget_for(config)
54
+ browserless_preload_budget_for(config)
55
+ end
56
+
57
+ class << self
58
+ private
59
+
60
+ def effective_max_requests_for(config)
61
+ return config.max_requests if config.explicit_max_requests?
62
+
63
+ [baseline_request_budget_for(config), config.max_requests].max
64
+ end
65
+
66
+ # Reserve enough HTTP request slots for the initial request plus predictable
67
+ # follow-ups. Preload interactions are planned separately.
68
+ def baseline_request_budget_for(config)
69
+ 1 +
70
+ RequestSession::Pager.request_slots_for(config.selectors&.dig(:items, :pagination)) +
71
+ AutoSource.request_slots_for(config.auto_source) +
72
+ StrategyPlan.resolve(config.strategy).request_slots
73
+ end
74
+
75
+ def browserless_preload_budget_for(config)
76
+ preload = config.request.dig(:browserless, :preload)
77
+ return 0 unless preload
78
+
79
+ top_level_preload_wait_budget(preload) +
80
+ click_selector_preload_budget(preload) +
81
+ scroll_preload_budget(preload)
82
+ end
83
+
84
+ def top_level_preload_wait_budget(preload)
85
+ preload[:wait_after_ms] ? 2 : 0
86
+ end
87
+
88
+ def click_selector_preload_budget(preload)
89
+ preload.fetch(:click_selectors, []).sum { preload_action_budget(_1, :max_clicks) }
90
+ end
91
+
92
+ def scroll_preload_budget(preload)
93
+ scroll = preload[:scroll_down]
94
+ return 0 unless scroll
95
+
96
+ preload_action_budget(scroll, :iterations)
97
+ end
98
+
99
+ def preload_action_budget(config, count_key)
100
+ action_count = config.fetch(count_key, 1)
101
+ wait_budget = config[:wait_after_ms] ? action_count : 0
102
+
103
+ action_count + wait_budget
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ class FeedPipeline
5
+ ##
6
+ # Feed-level request plan: +:auto+ (fallback chain) or a concrete transport strategy.
7
+ #
8
+ # {RequestService} executes concrete adapters only. +:auto+ is resolved here before
9
+ # any session or adapter call — plan vs transport locality.
10
+ class StrategyPlan
11
+ # Feed-level auto plan: try {AutoFallback::CHAIN} until items are extracted.
12
+ Auto = Data.define do
13
+ # @return [Integer]
14
+ def request_slots
15
+ [AutoFallback::CHAIN.size - 1, 0].max
16
+ end
17
+ end
18
+
19
+ # Concrete transport strategy name for {RequestService}.
20
+ Concrete = Data.define(:strategy) do
21
+ # @return [Integer]
22
+ def request_slots = 0
23
+ end
24
+
25
+ # Symbol used in config / configure for the auto plan.
26
+ AUTO_NAME = :auto
27
+
28
+ class << self
29
+ ##
30
+ # @param name [Symbol, String] config or configure strategy value
31
+ # @return [Auto, Concrete] resolved feed-level plan
32
+ # @raise [ArgumentError] when +name+ is neither +:auto+ nor a registered strategy
33
+ def resolve(name)
34
+ normalized = name.to_sym
35
+ return Auto.new if normalized == AUTO_NAME
36
+
37
+ unless RequestService.strategy_registered?(normalized)
38
+ raise ArgumentError, "unknown strategy plan: #{name.inspect}"
39
+ end
40
+
41
+ Concrete.new(strategy: normalized)
42
+ end
43
+
44
+ ##
45
+ # @param name [Symbol, String, nil] candidate plan name
46
+ # @return [Boolean] whether +name+ is a valid feed-level strategy plan
47
+ def valid?(name)
48
+ return false unless name.is_a?(Symbol) || name.is_a?(String)
49
+
50
+ resolve(name)
51
+ true
52
+ rescue ArgumentError
53
+ false
54
+ end
55
+
56
+ ##
57
+ # @return [Array<Symbol>] accepted plan names (+:auto+ plus registered strategies)
58
+ def accepted_names
59
+ [AUTO_NAME, *RequestService.strategy_names.map(&:to_sym)].uniq
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -3,7 +3,15 @@
3
3
  module Html2rss
4
4
  ##
5
5
  # Builds feeds from validated config through request, extraction, and rendering stages.
6
- class FeedPipeline # rubocop:disable Metrics/ClassLength
6
+ class FeedPipeline
7
+ # Scrape-finished facts after request + extraction + dedup (before Channel/Status materialize).
8
+ # selected_strategy: set on :auto success; nil otherwise.
9
+ # attempt_count: auto attempts attempted; 0 outside :auto.
10
+ # strategy_attempts: auto attempt hashes (with optional transport_meta); empty outside :auto.
11
+ PipelineOutcome = Data.define(
12
+ :response, :articles, :dedup_dropped, :selected_strategy, :attempt_count, :strategy_attempts
13
+ )
14
+
7
15
  ##
8
16
  # @param raw_config [Hash{Symbol => Object}] user-provided feed config
9
17
  def initialize(raw_config)
@@ -11,123 +19,109 @@ module Html2rss
11
19
  end
12
20
 
13
21
  ##
14
- # @return [RSS::Rss] generated RSS feed
15
- def to_rss
16
- run do |response:, config:, articles:|
17
- channel = RssBuilder::Channel.new(response, overrides: config.channel)
18
- RssBuilder.new(channel:, articles:, stylesheets: config.stylesheets).call
19
- end
22
+ # Runs the pipeline once and returns an opaque, Marshal-cacheable result.
23
+ #
24
+ # @return [Html2rss::FeedResult]
25
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- Status kwargs stay co-located with Channel
26
+ def to_result
27
+ config = Config.from_hash(raw_config, params: raw_config[:params])
28
+ outcome = pipeline_outcome_for(config)
29
+ channel = Channel.from_response(outcome.response, overrides: config.channel)
30
+ status = Status.build(
31
+ articles: outcome.articles,
32
+ dedup_dropped: outcome.dedup_dropped,
33
+ selected_strategy: outcome.selected_strategy,
34
+ attempt_count: outcome.attempt_count,
35
+ strategy_attempts: outcome.strategy_attempts
36
+ )
37
+ FeedResult.new(channel:, articles: outcome.articles, status:, stylesheets: config.stylesheets)
38
+ end
39
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
40
+
41
+ # @api private Host seam for {AutoFallback} (and single-strategy path).
42
+ # @param config [Html2rss::Config]
43
+ # @param strategy [Symbol]
44
+ # @param resources [Html2rss::FeedPipeline::RuntimePolicy::Resources]
45
+ # @return [Html2rss::RequestSession]
46
+ def request_session_for(config, strategy:, resources:)
47
+ RequestSession.build(
48
+ config:,
49
+ strategy:,
50
+ budget: resources.budget,
51
+ policy: resources.policy
52
+ )
20
53
  end
21
54
 
22
- ##
23
- # @return [Hash] generated JSONFeed 1.1 payload
24
- def to_json_feed
25
- run do |response:, config:, articles:|
26
- channel = RssBuilder::Channel.new(response, overrides: config.channel)
27
- JsonFeedBuilder.new(channel:, articles:).call
28
- end
55
+ # @api private Host seam for {AutoFallback} (and single-strategy path).
56
+ # @param config [Html2rss::Config]
57
+ # @param response [Html2rss::RequestService::Response]
58
+ # @param request_session [Html2rss::RequestSession]
59
+ # @return [Array(Array<Html2rss::Article>, Integer)] unique articles and drop count
60
+ def deduplicated_articles(config:, response:, request_session:)
61
+ collected = collect_articles(config:, response:, request_session:)
62
+ unique = Article::Deduplicator.new(collected).call
63
+ [unique, collected.size - unique.size]
29
64
  end
30
65
 
31
66
  private
32
67
 
33
68
  attr_reader :raw_config
34
69
 
35
- def run
36
- config = Config.from_hash(raw_config, params: raw_config[:params])
37
- state = pipeline_state_for(config)
38
- yield response: state.fetch(:response), config:, articles: state.fetch(:articles)
39
- end
40
-
41
- def pipeline_state_for(config)
42
- if config.strategy == :auto
43
- run_auto_pipeline(config)
70
+ def pipeline_outcome_for(config)
71
+ plan = StrategyPlan.resolve(config.strategy)
72
+ resources = RuntimePolicy.resources_for(config)
73
+ if plan.is_a?(StrategyPlan::Auto)
74
+ run_auto_pipeline(config, resources:)
44
75
  else
45
- run_pipeline_for_strategy(config, strategy: config.strategy)
76
+ run_pipeline_for_strategy(config, strategy: plan.strategy, resources:)
46
77
  end
47
78
  end
48
79
 
49
- def run_pipeline_for_strategy(config, strategy:, budget: nil)
50
- request_session = request_session_for(config, strategy:, budget:)
80
+ def run_pipeline_for_strategy(config, strategy:, resources:)
81
+ request_session = request_session_for(config, strategy:, resources:)
51
82
  response = request_session.fetch_initial_response
52
- articles = deduplicated_articles(response:, config:, request_session:)
53
- { response:, articles: }
54
- end
55
-
56
- def request_session_for(config, strategy:, budget: nil)
57
- RequestSession.from_runtime_input(runtime_input_for(config, strategy:), budget:)
58
- end
59
-
60
- def runtime_input_for(config, strategy:)
61
- RequestSession::RuntimeInput.new(
62
- url: config.url,
63
- headers: config.headers,
64
- request: config.request,
65
- strategy:,
66
- request_policy: RequestSession::RuntimePolicy.from_config(config)
83
+ articles, dedup_dropped = deduplicated_articles(config:, response:, request_session:)
84
+ PipelineOutcome.new(
85
+ response:, articles:, dedup_dropped:, selected_strategy: nil, attempt_count: 0, strategy_attempts: []
67
86
  )
68
87
  end
69
88
 
70
- def deduplicated_articles(response:, config:, request_session:)
71
- Articles::Deduplicator.new(
72
- collect_articles(response:, config:, request_session:)
89
+ def run_auto_pipeline(config, resources:)
90
+ AutoFallback.new(
91
+ strategies: AutoFallback::CHAIN,
92
+ budget: resources.budget,
93
+ pipeline: self,
94
+ config:,
95
+ resources:
73
96
  ).call
74
97
  end
75
98
 
76
- def run_auto_pipeline(config)
77
- auto_fallback_for(config).call
99
+ def collect_articles(config:, response:, request_session:)
100
+ selector_articles(config:, response:, request_session:) +
101
+ auto_source_articles(config:, response:, request_session:)
78
102
  end
79
103
 
80
104
  # rubocop:disable Metrics/MethodLength
81
- def auto_fallback_for(config)
82
- AutoFallback.new(
83
- strategies: AutoFallback::CHAIN,
84
- budget: auto_pipeline_budget(config),
85
- session_for: lambda do |strategy:, budget:|
86
- if budget.remaining_timeout_seconds && budget.remaining_timeout_seconds <= 0
87
- raise RequestService::RequestTimedOut, 'Request timed out'
88
- end
89
-
90
- request_session_for(config, strategy:, budget:)
91
- end,
92
- articles_for: lambda do |response:, request_session:|
93
- deduplicated_articles(response:, config:, request_session:)
94
- end
95
- )
96
- end
97
- # rubocop:enable Metrics/MethodLength
105
+ def selector_articles(config:, response:, request_session:)
106
+ return [] unless (selectors = config.selectors)
98
107
 
99
- def auto_pipeline_budget(config)
100
- policy = RequestSession::RuntimePolicy.from_config(config)
101
- RequestService::Budget.new(
102
- max_requests: policy.max_requests,
103
- total_timeout_seconds: policy.total_timeout_seconds
108
+ page_responses = request_session.page_responses(
109
+ response,
110
+ pagination_config: selectors.dig(:items, :pagination)
104
111
  )
105
- end
106
112
 
107
- def collect_articles(response:, config:, request_session:)
108
- selector_articles(response:, config:, request_session:) +
109
- auto_source_articles(response:, config:, request_session:)
110
- end
113
+ articles = []
114
+ page_responses.each do |page_response|
115
+ page_articles = Selectors.new(page_response, selectors:, time_zone: config.time_zone).articles
116
+ break if page_articles.empty? && articles.any?
111
117
 
112
- def selector_articles(response:, config:, request_session:) # rubocop:disable Metrics/MethodLength
113
- return [] unless (selectors = config.selectors)
114
-
115
- page_responses = if (max_pages = selectors.dig(:items, :pagination, :max_pages))
116
- RequestSession::RelNextPager.new(
117
- session: request_session,
118
- initial_response: response,
119
- max_pages:
120
- ).to_a
121
- else
122
- [response]
123
- end
124
-
125
- page_responses.flat_map do |page_response|
126
- Selectors.new(page_response, selectors:, time_zone: config.time_zone).articles
118
+ articles.concat(page_articles)
127
119
  end
120
+ articles
128
121
  end
122
+ # rubocop:enable Metrics/MethodLength
129
123
 
130
- def auto_source_articles(response:, config:, request_session:)
124
+ def auto_source_articles(config:, response:, request_session:)
131
125
  return [] unless (auto_source = config.auto_source)
132
126
 
133
127
  AutoSource.new(response, auto_source, request_session:).articles
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ ##
5
+ # Closed Marshal-cacheable handle for one scrape.
6
+ #
7
+ # Frozen consumer query/render set (do not grow without an explicit contract change):
8
+ # {#empty?}, {#channel_title}, {#to_rss}, {#to_json_feed}, and {#status}.
9
+ #
10
+ # Non-goals: no Channel reader, no Articles reader, no peephole into scrape internals.
11
+ # html2rss-web and other consumers must use only this surface.
12
+ #
13
+ # @note Gem contract: instances must round-trip through +Marshal.dump+ / +Marshal.load+
14
+ # so web can cache one scrape and render RSS or JSON Feed on read. Loaded results
15
+ # are re-frozen via private {#marshal_load}. For trusted cache reads only,
16
+ # +Marshal.load(payload, freeze: true)+ is also safe.
17
+ class FeedResult
18
+ ##
19
+ # @param channel [Html2rss::Channel] channel metadata (internal; not exposed on the public API)
20
+ # @param articles [Array<Html2rss::Article>] extracted articles (deduplicated; not exposed)
21
+ # @param status [Html2rss::Status] pipeline telemetry
22
+ # @param stylesheets [Array<Hash>] optional RSS stylesheet configs
23
+ 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)
29
+
30
+ @channel = channel
31
+ @articles = articles.dup.freeze
32
+ @status = status
33
+ @stylesheets = freeze_stylesheets(stylesheets)
34
+ freeze
35
+ end
36
+
37
+ ##
38
+ # @return [Boolean] true when the scrape produced no items
39
+ def empty? = @articles.empty?
40
+
41
+ ##
42
+ # Channel title string only — does not expose the channel object.
43
+ #
44
+ # @return [String] title from the materialized channel
45
+ def channel_title = @channel.title
46
+
47
+ ##
48
+ # @return [RSS::Rss] RSS 2.0 document
49
+ def to_rss
50
+ FeedBuilder::Rss.new(
51
+ channel: @channel,
52
+ articles: @articles,
53
+ stylesheets: @stylesheets,
54
+ generator: status.to_generator_comment
55
+ ).call
56
+ end
57
+
58
+ ##
59
+ # @param feed_url [String, nil] optional self URL for JSON Feed (+feed_url+)
60
+ # @return [Hash] JSON Feed 1.1 hash
61
+ def to_json_feed(feed_url: nil)
62
+ FeedBuilder::JsonFeed.new(
63
+ channel: @channel,
64
+ articles: @articles,
65
+ feed_url:,
66
+ user_comment: status.to_generator_comment
67
+ ).call
68
+ end
69
+
70
+ ##
71
+ # @return [Html2rss::Status] frozen scraper tallies, dedup count, and generator formatter.
72
+ # {#to_h} on +status+ is a stable consumer contract for observability payloads.
73
+ attr_reader :status
74
+
75
+ private
76
+
77
+ # Constructor payload for Marshal (avoids default ivar thaw). Rebuilds via {#initialize}.
78
+ def marshal_dump = [@channel, @articles, @status, @stylesheets]
79
+
80
+ def marshal_load((channel, articles, status, stylesheets))
81
+ initialize(channel:, articles:, status:, stylesheets:)
82
+ end
83
+
84
+ def freeze_stylesheets(stylesheets)
85
+ Array(stylesheets).map do |sheet|
86
+ sheet.to_h.transform_keys(&:to_sym).transform_values do |value|
87
+ value.is_a?(String) ? value.dup.freeze : value
88
+ end.freeze
89
+ end.freeze
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ module Html
5
+ class ArticleExtractor
6
+ ##
7
+ # CategoryExtractor is responsible for extracting categories from HTML elements
8
+ # by looking for CSS class names containing common category-related terms.
9
+ class CategoryExtractor
10
+ # Common category-related terms to look for in class names
11
+ CATEGORY_TERMS = %w[
12
+ category categories tag tags topic topics section sections
13
+ label labels theme themes subject subjects
14
+ ].freeze
15
+
16
+ # CSS selectors to find elements with category-related class names or data attributes
17
+ CATEGORY_SELECTORS = CATEGORY_TERMS.flat_map do |term|
18
+ ["[class*=\"#{term}\"]", "[data-#{term}]", "[#{term}]"]
19
+ end.freeze
20
+
21
+ # Regex pattern for matching category-related attribute names
22
+ CATEGORY_ATTR_PATTERN = /#{CATEGORY_TERMS.join('|')}/i
23
+
24
+ ##
25
+ # Extracts categories from the given article tag by looking for elements
26
+ # with class names containing common category-related terms.
27
+ #
28
+ # @param article_tag [Nokogiri::XML::Element] The article element to extract categories from
29
+ # @return [Array<String>] Array of category strings, empty if none found
30
+ def self.call(article_tag)
31
+ return [] unless article_tag
32
+
33
+ # Single optimized traversal that extracts all category types
34
+ extract_all_categories(article_tag)
35
+ .map(&:strip)
36
+ .reject(&:empty?)
37
+ end
38
+
39
+ ##
40
+ # Optimized single DOM traversal that extracts all category types.
41
+ #
42
+ # @param article_tag [Nokogiri::XML::Element] The article element
43
+ # @return [Set<String>] Set of category strings
44
+ def self.extract_all_categories(article_tag)
45
+ Set.new.tap do |categories|
46
+ article_tag.css(CATEGORY_SELECTORS.join(',')).each do |element|
47
+ # Extract text categories from elements with category-related class names
48
+ extract_text_categories!(categories, element) if element['class']&.match?(CATEGORY_ATTR_PATTERN)
49
+
50
+ # Extract data categories from all elements
51
+ extract_element_data_categories!(categories, element)
52
+ end
53
+ end
54
+ end
55
+
56
+ ##
57
+ # Extracts categories from data attributes of a single element.
58
+ #
59
+ # @param categories [Set<String>] Accumulator set
60
+ # @param element [Nokogiri::XML::Element] metadata element that may contain category links
61
+ # @return [void]
62
+ def self.extract_element_data_categories!(categories, element)
63
+ element.attributes.each_value do |attr|
64
+ next unless attr.name.match?(CATEGORY_ATTR_PATTERN)
65
+
66
+ value = attr.value&.strip
67
+ categories.add(value) if value && !value.empty?
68
+ end
69
+ end
70
+
71
+ ##
72
+ # Extracts text-based categories from elements, splitting content into discrete values.
73
+ #
74
+ # @param categories [Set<String>] Accumulator set
75
+ # @param element [Nokogiri::XML::Element] metadata element whose text may contain delimiters
76
+ # @return [void]
77
+ def self.extract_text_categories!(categories, element)
78
+ if element.name == 'a'
79
+ add_text_to_categories!(categories, element)
80
+ return
81
+ end
82
+
83
+ anchors = element.css('a')
84
+
85
+ if anchors.any?
86
+ anchors.each { |node| add_text_to_categories!(categories, node) }
87
+ else
88
+ extract_split_text_categories!(categories, element)
89
+ end
90
+ end
91
+
92
+ ##
93
+ # Adds the visible text of the given element to the categories set.
94
+ #
95
+ # @param categories [Set<String>] Accumulator set
96
+ # @param element [Nokogiri::XML::Element] The element to extract text from
97
+ # @return [void]
98
+ def self.add_text_to_categories!(categories, element)
99
+ text = Navigator.extract_visible_text(element)
100
+ categories.add(text) if text && !text.empty?
101
+ end
102
+
103
+ ##
104
+ # Extracts categories from the element's text by splitting on newlines.
105
+ #
106
+ # @param categories [Set<String>] Accumulator set
107
+ # @param element [Nokogiri::XML::Element] The element to extract text from
108
+ # @return [void]
109
+ def self.extract_split_text_categories!(categories, element)
110
+ text = element.text
111
+ return unless text
112
+
113
+ text.split(/\n+/).each do |line|
114
+ line = line.strip
115
+ categories.add(line) unless line.empty?
116
+ end
117
+ end
118
+
119
+ private_class_method :add_text_to_categories!, :extract_split_text_categories!
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ module Html
5
+ class ArticleExtractor
6
+ # Extracts the earliest date from an article_tag.
7
+ class DateExtractor
8
+ # @param article_tag [Nokogiri::XML::Element] article container node
9
+ # @return [DateTime, nil]
10
+ def self.call(article_tag)
11
+ times = article_tag.css('[datetime]').filter_map do |tag|
12
+ DateTime.parse(tag['datetime'])
13
+ rescue ArgumentError, TypeError
14
+ nil
15
+ end
16
+
17
+ times.min
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end