html2rss 0.23.0 → 0.25.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 (109) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/lib/html2rss/article.rb +42 -35
  4. data/lib/html2rss/auto_source/scraper/html.rb +68 -145
  5. data/lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb +116 -0
  6. data/lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb +63 -0
  7. data/lib/html2rss/auto_source/scraper/json_state/document_scanner.rb +179 -0
  8. data/lib/html2rss/auto_source/scraper/json_state/value_finder.rb +55 -0
  9. data/lib/html2rss/auto_source/scraper/json_state.rb +0 -379
  10. data/lib/html2rss/auto_source/scraper/schema/category_extractor.rb +29 -114
  11. data/lib/html2rss/auto_source/scraper/schema/item_list.rb +50 -11
  12. data/lib/html2rss/auto_source/scraper/schema/thing.rb +14 -5
  13. data/lib/html2rss/auto_source/scraper/schema.rb +61 -18
  14. data/lib/html2rss/auto_source/scraper/semantic_html/entry_deduplicator.rb +35 -30
  15. data/lib/html2rss/auto_source/scraper/semantic_html.rb +40 -120
  16. data/lib/html2rss/auto_source/scraper/sitemap/parser.rb +161 -0
  17. data/lib/html2rss/auto_source/scraper/sitemap.rb +10 -7
  18. data/lib/html2rss/auto_source/scraper/wordpress_api.rb +6 -2
  19. data/lib/html2rss/auto_source/scraper.rb +81 -44
  20. data/lib/html2rss/auto_source/segment.rb +29 -0
  21. data/lib/html2rss/auto_source/segmenter/cluster.rb +163 -0
  22. data/lib/html2rss/auto_source/segmenter/list.rb +101 -0
  23. data/lib/html2rss/auto_source/segmenter/primary_link.rb +81 -0
  24. data/lib/html2rss/auto_source/segmenter/semantic.rb +104 -0
  25. data/lib/html2rss/auto_source/segmenter.rb +66 -0
  26. data/lib/html2rss/auto_source.rb +74 -21
  27. data/lib/html2rss/channel.rb +159 -79
  28. data/lib/html2rss/cli.rb +8 -13
  29. data/lib/html2rss/config/auto_source_contract.rb +2 -0
  30. data/lib/html2rss/config/request_controls.rb +33 -0
  31. data/lib/html2rss/config/validator.rb +15 -8
  32. data/lib/html2rss/config.rb +6 -2
  33. data/lib/html2rss/feed_builder/item_presentation.rb +42 -0
  34. data/lib/html2rss/feed_builder/json_feed/item.rb +20 -4
  35. data/lib/html2rss/feed_builder/json_feed.rb +22 -2
  36. data/lib/html2rss/feed_builder/rss.rb +36 -24
  37. data/lib/html2rss/feed_builder.rb +9 -17
  38. data/lib/html2rss/feed_pipeline/auto_fallback.rb +71 -28
  39. data/lib/html2rss/feed_pipeline.rb +66 -55
  40. data/lib/html2rss/feed_result.rb +92 -0
  41. data/lib/html2rss/html/article_extractor/category_extractor.rb +10 -36
  42. data/lib/html2rss/html/article_extractor/date_extractor.rb +2 -7
  43. data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +5 -50
  44. data/lib/html2rss/html/article_extractor/image_extractor.rb +5 -16
  45. data/lib/html2rss/html/article_rules/category.rb +55 -0
  46. data/lib/html2rss/html/article_rules/date.rb +25 -0
  47. data/lib/html2rss/html/article_rules/enclosure.rb +72 -0
  48. data/lib/html2rss/html/article_rules/image.rb +109 -0
  49. data/lib/html2rss/html/article_rules.rb +10 -0
  50. data/lib/html2rss/html/rendering/audio_renderer.rb +2 -12
  51. data/lib/html2rss/html/rendering/escaped_attributes.rb +27 -0
  52. data/lib/html2rss/html/rendering/image_renderer.rb +2 -12
  53. data/lib/html2rss/html/rendering/pdf_renderer.rb +2 -8
  54. data/lib/html2rss/html/rendering/video_renderer.rb +2 -12
  55. data/lib/html2rss/html/sst_article_extractor.rb +279 -0
  56. data/lib/html2rss/link_destination/destination_facts.rb +40 -0
  57. data/lib/html2rss/link_destination/noise_policy.rb +79 -0
  58. data/lib/html2rss/link_destination/path_classifier.rb +205 -0
  59. data/lib/html2rss/link_destination/text_classifier.rb +64 -0
  60. data/lib/html2rss/link_destination.rb +8 -0
  61. data/lib/html2rss/request_service/botasaurus_contract.rb +33 -5
  62. data/lib/html2rss/request_service/botasaurus_strategy.rb +13 -4
  63. data/lib/html2rss/request_service/budget.rb +7 -2
  64. data/lib/html2rss/request_service/faraday_strategy.rb +42 -1
  65. data/lib/html2rss/request_service/network_guard.rb +5 -3
  66. data/lib/html2rss/request_service/response.rb +9 -1
  67. data/lib/html2rss/request_service/strategy.rb +22 -0
  68. data/lib/html2rss/request_session.rb +4 -0
  69. data/lib/html2rss/scoring/anchor_score.rb +34 -0
  70. data/lib/html2rss/scoring/cluster_scorer.rb +87 -0
  71. data/lib/html2rss/scoring/container_assessor.rb +83 -0
  72. data/lib/html2rss/scoring/engine.rb +101 -0
  73. data/lib/html2rss/scoring/link_resolver.rb +72 -0
  74. data/lib/html2rss/scoring/observation.rb +53 -0
  75. data/lib/html2rss/scoring/ranked_segment.rb +45 -0
  76. data/lib/html2rss/scoring/score.rb +30 -0
  77. data/lib/html2rss/scoring.rb +33 -0
  78. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +10 -1
  79. data/lib/html2rss/selectors.rb +26 -24
  80. data/lib/html2rss/sst/attrs.rb +91 -0
  81. data/lib/html2rss/sst/document.rb +23 -0
  82. data/lib/html2rss/sst/index.rb +112 -0
  83. data/lib/html2rss/sst/node.rb +147 -0
  84. data/lib/html2rss/sst/normalizer.rb +171 -0
  85. data/lib/html2rss/sst/tags.rb +26 -0
  86. data/lib/html2rss/sst/text.rb +81 -0
  87. data/lib/html2rss/sst.rb +8 -0
  88. data/lib/html2rss/status.rb +132 -0
  89. data/lib/html2rss/version.rb +1 -1
  90. data/lib/html2rss.rb +41 -29
  91. data/schema/html2rss-config.schema.json +20 -0
  92. metadata +45 -19
  93. data/lib/html2rss/auto_source/discovery/dom_clustering/group_scorer.rb +0 -80
  94. data/lib/html2rss/auto_source/discovery/dom_clustering/overlap_resolver.rb +0 -86
  95. data/lib/html2rss/auto_source/discovery/dom_clustering.rb +0 -119
  96. data/lib/html2rss/auto_source/discovery/list_candidates.rb +0 -94
  97. data/lib/html2rss/auto_source/discovery/semantic_anchor_candidates.rb +0 -219
  98. data/lib/html2rss/auto_source/discovery/semantic_containers.rb +0 -71
  99. data/lib/html2rss/auto_source/discovery/sitemap.rb +0 -159
  100. data/lib/html2rss/auto_source/discovery.rb +0 -14
  101. data/lib/html2rss/auto_source/link_heuristics/anchor_signals.rb +0 -28
  102. data/lib/html2rss/auto_source/link_heuristics/container_assessor.rb +0 -106
  103. data/lib/html2rss/auto_source/link_heuristics/container_signals.rb +0 -80
  104. data/lib/html2rss/auto_source/link_heuristics/destination_facts.rb +0 -42
  105. data/lib/html2rss/auto_source/link_heuristics/href_extractor.rb +0 -38
  106. data/lib/html2rss/auto_source/link_heuristics/path_classifier.rb +0 -221
  107. data/lib/html2rss/auto_source/link_heuristics/text_classifier.rb +0 -66
  108. data/lib/html2rss/auto_source/link_heuristics.rb +0 -139
  109. data/lib/html2rss/auto_source/scraper/schema/list_item.rb +0 -28
@@ -1,23 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Html2rss
4
- # Entrypoint and namespace for feed building and formatting (RSS 2.0 / JSON Feed 1.1).
4
+ # Namespace for feed format adapters (RSS 2.0 / JSON Feed 1.1).
5
+ #
6
+ # {FeedResult} constructs {Rss} / {JsonFeed} directly — there is no dispatcher
7
+ # entrypoint on this module. Stylesheets are a scrape-time artifact carried on
8
+ # FeedResult; +feed_url+ is a render-time JSON Feed-only option.
9
+ #
10
+ # Channel field projection:
11
+ # - Rss: language, title, description, ttl, link, updated
12
+ # - JsonFeed: title, home_page_url, description, language, icon, authors (+ feed_url)
5
13
  module FeedBuilder
6
- # Builds the requested feed type from the channel and article list.
7
- #
8
- # @param type [Symbol] :rss or :json_feed
9
- # @param channel [Html2rss::Channel]
10
- # @param articles [Array<Html2rss::Article>]
11
- # @return [RSS::Rss, Hash] format-compliant representation of the feed
12
- def self.build(type, channel:, articles:, **)
13
- case type
14
- when :rss
15
- Rss.new(channel:, articles:, **).call
16
- when :json_feed
17
- JsonFeed.new(channel:, articles:).call
18
- else
19
- raise ArgumentError, "Unknown feed type: #{type}"
20
- end
21
- end
22
14
  end
23
15
  end
@@ -5,9 +5,10 @@ module Html2rss
5
5
  # Retries feed extraction across concrete request strategies for the :auto plan.
6
6
  #
7
7
  # Owned by {FeedPipeline}; invoked only after {StrategyPlan} resolves +:auto+.
8
+ # Hosted by the pipeline instance: session + extract call back into FeedPipeline.
8
9
  class AutoFallback
9
10
  # Ordered list of concrete request strategies attempted by the :auto plan.
10
- CHAIN = %i[faraday botasaurus browserless].freeze
11
+ CHAIN = %i[faraday botasaurus].freeze
11
12
 
12
13
  # Error classes that should abort auto fallback immediately.
13
14
  NON_FALLBACK_ERRORS = [
@@ -44,34 +45,49 @@ module Html2rss
44
45
 
45
46
  # @param strategy [Symbol] strategy that returned a response
46
47
  # @param items_count [Integer] extracted article count
48
+ # @param transport_meta [Hash, nil] optional allowlisted upstream telemetry
47
49
  # @return [void]
48
- def record_items(strategy:, items_count:)
49
- @attempts << { strategy:, items_count:, error_class: nil }
50
+ def record_items(strategy:, items_count:, transport_meta: nil)
51
+ attempt = { strategy:, items_count:, error_class: nil }
52
+ attempt[:transport_meta] = transport_meta if transport_meta && !transport_meta.empty?
53
+ @attempts << attempt
50
54
  end
51
55
 
52
56
  # @param response [RequestService::Response] successful response
53
57
  # @param articles [Array] extracted articles
58
+ # @param dedup_dropped [Integer] articles removed by deduplication
59
+ # @param selected_strategy [Symbol] concrete strategy that produced items
60
+ # @param attempt_count [Integer] number of attempts recorded for this chain
54
61
  # @return [void]
55
- def succeed!(response:, articles:)
56
- @result = { response:, articles: }
62
+ def succeed!(response:, articles:, dedup_dropped:, selected_strategy:, attempt_count:)
63
+ @result = PipelineOutcome.new(
64
+ response:,
65
+ articles:,
66
+ dedup_dropped:,
67
+ selected_strategy:,
68
+ attempt_count:,
69
+ strategy_attempts: attempts
70
+ )
57
71
  end
58
72
  end
59
73
 
60
74
  ##
61
75
  # @param strategies [Array<Symbol>] ordered concrete strategies for fallback
62
76
  # @param budget [RequestService::Budget] shared request budget across retries
63
- # @param session_for [Proc] request session factory proc
64
- # @param articles_for [Proc] article extraction proc
77
+ # @param pipeline [Html2rss::FeedPipeline] host for session + article extraction
78
+ # @param config [Html2rss::Config] validated feed config
79
+ # @param resources [Html2rss::FeedPipeline::RuntimePolicy::Resources] budget + policy
65
80
  # @return [void]
66
- def initialize(strategies:, budget:, session_for:, articles_for:)
81
+ def initialize(strategies:, budget:, pipeline:, config:, resources:)
67
82
  @strategies = strategies
68
83
  @budget = budget
69
- @session_for = session_for
70
- @articles_for = articles_for
84
+ @pipeline = pipeline
85
+ @config = config
86
+ @resources = resources
71
87
  end
72
88
 
73
89
  ##
74
- # @return [Hash{Symbol => Object}] pipeline state containing :response and :articles
90
+ # @return [Html2rss::FeedPipeline::PipelineOutcome] scrape-finished state for materialization
75
91
  def call
76
92
  state = run_attempts
77
93
  return state.result if state.succeeded?
@@ -81,7 +97,7 @@ module Html2rss
81
97
 
82
98
  private
83
99
 
84
- attr_reader :strategies, :budget, :session_for, :articles_for
100
+ attr_reader :strategies, :budget, :pipeline, :config, :resources
85
101
 
86
102
  def run_attempts
87
103
  AttemptState.new.tap do |state|
@@ -93,7 +109,7 @@ module Html2rss
93
109
  end
94
110
 
95
111
  def attempt(strategy:, next_strategy:, state:)
96
- request_session = session_for.call(strategy:, budget:)
112
+ request_session = pipeline.request_session_for(config, strategy:, resources:)
97
113
  response = fetch_response(request_session:, strategy:, next_strategy:, state:)
98
114
  return unless response
99
115
 
@@ -106,38 +122,65 @@ module Html2rss
106
122
  raise
107
123
  rescue StandardError => error
108
124
  state.record_error(strategy:, error:)
109
- log_warn_fallback_error(strategy:, next_strategy:, error:) if next_strategy
110
- Log.debug("#{self.class}: strategy=#{strategy} error=#{error.class}: #{error.message}")
125
+ log_fallback_error(strategy:, next_strategy:, error:, request_session:) if next_strategy
111
126
  nil
112
127
  end
113
128
 
114
129
  def process_response(response:, strategy:, next_strategy:, request_session:, state:)
115
- articles = articles_for.call(response:, request_session:)
130
+ articles, dedup_dropped = articles_for(response:, request_session:)
116
131
  items_count = articles.size
117
- state.record_items(strategy:, items_count:)
118
- Log.debug("#{self.class}: strategy=#{strategy} items=#{items_count}")
119
- return record_success(response:, strategy:, articles:, state:) if items_count.positive?
132
+ state.record_items(strategy:, items_count:, transport_meta: response.transport_meta)
133
+ Log.debug("#{self.class}: strategy=#{strategy} items=#{items_count} " \
134
+ "host=#{response.url.host} elapsed=#{format('%.3f', budget.elapsed_seconds)}s " \
135
+ "budget_remaining=#{budget_remaining_label}")
136
+ return record_success(response:, strategy:, articles:, dedup_dropped:, state:) if items_count.positive?
120
137
 
121
- log_info_fallback_zero_items(strategy:, next_strategy:) if next_strategy
138
+ log_info_fallback_zero_items(strategy:, next_strategy:, response:) if next_strategy
122
139
  end
123
140
 
124
- def record_success(response:, strategy:, articles:, state:)
125
- state.succeed!(response:, articles:)
126
- return unless state.attempts.size > 1
141
+ def articles_for(response:, request_session:)
142
+ pipeline.deduplicated_articles(config:, response:, request_session:)
143
+ end
144
+
145
+ def record_success(response:, strategy:, articles:, dedup_dropped:, state:)
146
+ attempt_count = state.attempts.size
147
+ state.succeed!(response:, articles:, dedup_dropped:, selected_strategy: strategy, attempt_count:)
148
+ return unless attempt_count > 1
127
149
 
128
- Log.info("#{self.class}: auto selected strategy=#{strategy} after attempts=#{state.attempts.size}")
150
+ Log.info("#{self.class}: auto selected strategy=#{strategy} after attempts=#{attempt_count} " \
151
+ "host=#{response.url.host} elapsed=#{format('%.3f', budget.elapsed_seconds)}s " \
152
+ "budget_remaining=#{budget_remaining_label}")
129
153
  end
130
154
 
131
155
  def finalize_failure(attempts:)
132
156
  raise NoFeedItemsExtracted.new(attempts:)
133
157
  end
134
158
 
135
- def log_warn_fallback_error(strategy:, next_strategy:, error:)
136
- Log.warn("#{self.class}: auto fallback #{strategy} -> #{next_strategy} after error=#{error.class}")
159
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
160
+ def log_fallback_error(strategy:, next_strategy:, error:, request_session:)
161
+ host = request_session.url.host
162
+ detail = "host=#{host} elapsed=#{format('%.3f', budget.elapsed_seconds)}s " \
163
+ "budget_remaining=#{budget_remaining_label}"
164
+ if error.is_a?(RequestService::RequestTimedOut)
165
+ Log.info("#{self.class}: auto fallback #{strategy} -> #{next_strategy} " \
166
+ "after timeout=#{error.class} #{detail}")
167
+ else
168
+ Log.warn("#{self.class}: auto fallback #{strategy} -> #{next_strategy} " \
169
+ "after error=#{error.class} #{detail}")
170
+ end
171
+ Log.debug("#{self.class}: strategy=#{strategy} error=#{error.class}: #{error.message} #{detail}")
172
+ end
173
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
174
+
175
+ def log_info_fallback_zero_items(strategy:, next_strategy:, response:)
176
+ Log.info("#{self.class}: auto fallback #{strategy} -> #{next_strategy} after zero extracted items " \
177
+ "host=#{response.url.host} elapsed=#{format('%.3f', budget.elapsed_seconds)}s " \
178
+ "budget_remaining=#{budget_remaining_label}")
137
179
  end
138
180
 
139
- def log_info_fallback_zero_items(strategy:, next_strategy:)
140
- Log.info("#{self.class}: auto fallback #{strategy} -> #{next_strategy} after zero extracted items")
181
+ def budget_remaining_label
182
+ remaining = budget.remaining_timeout_seconds
183
+ remaining.nil? ? 'untracked' : format('%.3f', remaining)
141
184
  end
142
185
  end
143
186
  end
@@ -4,8 +4,13 @@ module Html2rss
4
4
  ##
5
5
  # Builds feeds from validated config through request, extraction, and rendering stages.
6
6
  class FeedPipeline
7
- # Bundle of inputs shared by selector and auto-source article collection.
8
- ExtractionContext = Data.define(:config, :response, :request_session)
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
+ )
9
14
 
10
15
  ##
11
16
  # @param raw_config [Hash{Symbol => Object}] user-provided feed config
@@ -14,34 +19,55 @@ module Html2rss
14
19
  end
15
20
 
16
21
  ##
17
- # @return [RSS::Rss] generated RSS feed
18
- def to_rss
19
- run do |response:, config:, articles:|
20
- channel = Html2rss::Channel.new(response, overrides: config.channel)
21
- FeedBuilder.build(:rss, channel:, articles:, stylesheets: config.stylesheets)
22
- 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)
23
38
  end
39
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
24
40
 
25
- ##
26
- # @return [Hash] generated JSONFeed 1.1 payload
27
- def to_json_feed
28
- run do |response:, config:, articles:|
29
- channel = Html2rss::Channel.new(response, overrides: config.channel)
30
- FeedBuilder.build(:json_feed, channel:, articles:)
31
- end
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
+ )
53
+ end
54
+
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]
32
64
  end
33
65
 
34
66
  private
35
67
 
36
68
  attr_reader :raw_config
37
69
 
38
- def run
39
- config = Config.from_hash(raw_config, params: raw_config[:params])
40
- state = pipeline_state_for(config)
41
- yield response: state.fetch(:response), config:, articles: state.fetch(:articles)
42
- end
43
-
44
- def pipeline_state_for(config)
70
+ def pipeline_outcome_for(config)
45
71
  plan = StrategyPlan.resolve(config.strategy)
46
72
  resources = RuntimePolicy.resources_for(config)
47
73
  if plan.is_a?(StrategyPlan::Auto)
@@ -54,52 +80,37 @@ module Html2rss
54
80
  def run_pipeline_for_strategy(config, strategy:, resources:)
55
81
  request_session = request_session_for(config, strategy:, resources:)
56
82
  response = request_session.fetch_initial_response
57
- articles = deduplicated_articles(
58
- ExtractionContext.new(config:, response:, request_session:)
59
- )
60
- { response:, articles: }
61
- end
83
+ articles, dedup_dropped = deduplicated_articles(config:, response:, request_session:)
84
+ if config.auto_source && articles.empty?
85
+ raise NoFeedItemsExtracted.new(attempts: [{ strategy:, items_count: 0, error_class: nil }])
86
+ end
62
87
 
63
- def request_session_for(config, strategy:, resources:)
64
- RequestSession.build(
65
- config:,
66
- strategy:,
67
- budget: resources.budget,
68
- policy: resources.policy
88
+ PipelineOutcome.new(
89
+ response:, articles:, dedup_dropped:, selected_strategy: nil, attempt_count: 0, strategy_attempts: []
69
90
  )
70
91
  end
71
92
 
72
- def deduplicated_articles(extraction)
73
- Article::Deduplicator.new(collect_articles(extraction)).call
74
- end
75
-
76
- # rubocop:disable Metrics/MethodLength
77
93
  def run_auto_pipeline(config, resources:)
78
94
  AutoFallback.new(
79
95
  strategies: AutoFallback::CHAIN,
80
96
  budget: resources.budget,
81
- session_for: lambda do |strategy:, budget:|
82
- budget.effective_timeout_seconds(fallback: resources.policy.total_timeout_seconds)
83
- request_session_for(config, strategy:, resources:)
84
- end,
85
- articles_for: lambda do |response:, request_session:|
86
- deduplicated_articles(ExtractionContext.new(config:, response:, request_session:))
87
- end
97
+ pipeline: self,
98
+ config:,
99
+ resources:
88
100
  ).call
89
101
  end
90
- # rubocop:enable Metrics/MethodLength
91
102
 
92
- def collect_articles(extraction)
93
- selector_articles(extraction) + auto_source_articles(extraction)
103
+ def collect_articles(config:, response:, request_session:)
104
+ selector_articles(config:, response:, request_session:) +
105
+ auto_source_articles(config:, response:, request_session:)
94
106
  end
95
107
 
96
108
  # rubocop:disable Metrics/MethodLength
97
- def selector_articles(extraction)
98
- config = extraction.config
109
+ def selector_articles(config:, response:, request_session:)
99
110
  return [] unless (selectors = config.selectors)
100
111
 
101
- page_responses = extraction.request_session.page_responses(
102
- extraction.response,
112
+ page_responses = request_session.page_responses(
113
+ response,
103
114
  pagination_config: selectors.dig(:items, :pagination)
104
115
  )
105
116
 
@@ -114,10 +125,10 @@ module Html2rss
114
125
  end
115
126
  # rubocop:enable Metrics/MethodLength
116
127
 
117
- def auto_source_articles(extraction)
118
- return [] unless (auto_source = extraction.config.auto_source)
128
+ def auto_source_articles(config:, response:, request_session:)
129
+ return [] unless (auto_source = config.auto_source)
119
130
 
120
- AutoSource.new(extraction.response, auto_source, request_session: extraction.request_session).articles
131
+ AutoSource.new(response, auto_source, request_session:).articles
121
132
  end
122
133
  end
123
134
  end
@@ -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
+ # {Status#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
@@ -7,20 +7,16 @@ module Html2rss
7
7
  # CategoryExtractor is responsible for extracting categories from HTML elements
8
8
  # by looking for CSS class names containing common category-related terms.
9
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
10
+ # Shared category vocabulary (owned by {ArticleRules::Category}).
11
+ CATEGORY_TERMS = ArticleRules::Category::CATEGORY_TERMS
12
+ # Shared category attribute/class pattern (owned by {ArticleRules::Category}).
13
+ CATEGORY_ATTR_PATTERN = ArticleRules::Category::CATEGORY_ATTR_PATTERN
15
14
 
16
15
  # CSS selectors to find elements with category-related class names or data attributes
17
16
  CATEGORY_SELECTORS = CATEGORY_TERMS.flat_map do |term|
18
17
  ["[class*=\"#{term}\"]", "[data-#{term}]", "[#{term}]"]
19
18
  end.freeze
20
19
 
21
- # Regex pattern for matching category-related attribute names
22
- CATEGORY_ATTR_PATTERN = /#{CATEGORY_TERMS.join('|')}/i
23
-
24
20
  ##
25
21
  # Extracts categories from the given article tag by looking for elements
26
22
  # with class names containing common category-related terms.
@@ -30,7 +26,6 @@ module Html2rss
30
26
  def self.call(article_tag)
31
27
  return [] unless article_tag
32
28
 
33
- # Single optimized traversal that extracts all category types
34
29
  extract_all_categories(article_tag)
35
30
  .map(&:strip)
36
31
  .reject(&:empty?)
@@ -44,10 +39,7 @@ module Html2rss
44
39
  def self.extract_all_categories(article_tag)
45
40
  Set.new.tap do |categories|
46
41
  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
42
+ extract_text_categories!(categories, element) if ArticleRules::Category.class_match?(element['class'])
51
43
  extract_element_data_categories!(categories, element)
52
44
  end
53
45
  end
@@ -61,10 +53,9 @@ module Html2rss
61
53
  # @return [void]
62
54
  def self.extract_element_data_categories!(categories, element)
63
55
  element.attributes.each_value do |attr|
64
- next unless attr.name.match?(CATEGORY_ATTR_PATTERN)
56
+ next unless ArticleRules::Category.attr_name_match?(attr.name)
65
57
 
66
- value = attr.value&.strip
67
- categories.add(value) if value && !value.empty?
58
+ ArticleRules::Category.add_text!(categories, attr.value)
68
59
  end
69
60
  end
70
61
 
@@ -85,7 +76,7 @@ module Html2rss
85
76
  if anchors.any?
86
77
  anchors.each { |node| add_text_to_categories!(categories, node) }
87
78
  else
88
- extract_split_text_categories!(categories, element)
79
+ ArticleRules::Category.add_split_text!(categories, element.text)
89
80
  end
90
81
  end
91
82
 
@@ -96,27 +87,10 @@ module Html2rss
96
87
  # @param element [Nokogiri::XML::Element] The element to extract text from
97
88
  # @return [void]
98
89
  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
90
+ ArticleRules::Category.add_text!(categories, Navigator.extract_visible_text(element))
117
91
  end
118
92
 
119
- private_class_method :add_text_to_categories!, :extract_split_text_categories!
93
+ private_class_method :add_text_to_categories!
120
94
  end
121
95
  end
122
96
  end
@@ -8,13 +8,8 @@ module Html2rss
8
8
  # @param article_tag [Nokogiri::XML::Element] article container node
9
9
  # @return [DateTime, nil]
10
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
11
+ datetimes = article_tag.css('[datetime]').map { |tag| tag['datetime'] }
12
+ ArticleRules::Date.earliest(datetimes)
18
13
  end
19
14
  end
20
15
  end
@@ -33,62 +33,17 @@ module Html2rss
33
33
  def self.extract_from_element(element, base_url)
34
34
  case element.name
35
35
  when 'img'
36
- extract_image(element, base_url)
36
+ ArticleRules::Enclosure.from_image(element['src'], base_url)
37
37
  when 'video', 'audio', 'source'
38
- extract_media(element, base_url)
38
+ ArticleRules::Enclosure.from_media(element['src'], element['type'], base_url)
39
39
  when 'iframe'
40
- extract_iframe(element, base_url)
40
+ ArticleRules::Enclosure.from_iframe(element['src'], base_url)
41
41
  when 'a'
42
- extract_a(element, base_url)
42
+ ArticleRules::Enclosure.from_anchor(element['href'], base_url)
43
43
  end
44
44
  end
45
45
 
46
- def self.extract_image(img, base_url)
47
- src = img['src'].to_s
48
- return if src.empty?
49
-
50
- abs_url = Url.from_relative(src, base_url)
51
- {
52
- url: abs_url,
53
- type: Article::Enclosure.guess_content_type_from_url(abs_url, default: 'image/jpeg')
54
- }
55
- end
56
-
57
- def self.extract_media(element, base_url)
58
- src = element['src'].to_s
59
- return if src.empty?
60
-
61
- {
62
- url: Url.from_relative(src, base_url),
63
- type: element['type']
64
- }
65
- end
66
-
67
- def self.extract_iframe(iframe, base_url)
68
- src = iframe['src'].to_s
69
- return if src.empty?
70
-
71
- abs_url = Url.from_relative(src, base_url)
72
- {
73
- url: abs_url,
74
- type: Article::Enclosure.guess_content_type_from_url(abs_url, default: 'text/html')
75
- }
76
- end
77
-
78
- def self.extract_a(link, base_url)
79
- href = link['href'].to_s
80
- return if href.empty?
81
-
82
- abs_url = Url.from_relative(href, base_url)
83
-
84
- if href.end_with?('.pdf')
85
- { url: abs_url, type: Article::Enclosure.guess_content_type_from_url(abs_url) }
86
- else
87
- { url: abs_url, type: 'application/zip' }
88
- end
89
- end
90
-
91
- private_class_method :extract_from_element, :extract_image, :extract_media, :extract_iframe, :extract_a
46
+ private_class_method :extract_from_element
92
47
  end
93
48
  end
94
49
  end