html2rss 0.22.2 → 0.23.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 (138) 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 +206 -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/thing.rb +21 -2
  27. data/lib/html2rss/auto_source/scraper/semantic_html/{deduplicator.rb → entry_deduplicator.rb} +3 -3
  28. data/lib/html2rss/auto_source/scraper/semantic_html.rb +23 -171
  29. data/lib/html2rss/auto_source/scraper/sitemap.rb +153 -0
  30. data/lib/html2rss/auto_source/scraper/wordpress_api/page_scope/date_archive_range.rb +110 -0
  31. data/lib/html2rss/auto_source/scraper/wordpress_api/page_scope.rb +2 -78
  32. data/lib/html2rss/auto_source/scraper/wordpress_api.rb +9 -0
  33. data/lib/html2rss/auto_source/scraper.rb +10 -3
  34. data/lib/html2rss/auto_source.rb +30 -35
  35. data/lib/html2rss/channel.rb +118 -0
  36. data/lib/html2rss/cli.rb +5 -6
  37. data/lib/html2rss/config/auto_source_contract.rb +51 -0
  38. data/lib/html2rss/config/request_controls.rb +136 -0
  39. data/lib/html2rss/config/request_headers.rb +1 -1
  40. data/lib/html2rss/config/schema.rb +65 -114
  41. data/lib/html2rss/{selectors/config.rb → config/selectors_validator.rb} +56 -7
  42. data/lib/html2rss/config/validator.rb +6 -6
  43. data/lib/html2rss/config.rb +176 -19
  44. data/lib/html2rss/{configuration.rb → defaults.rb} +12 -10
  45. data/lib/html2rss/feed_builder/json_feed/item.rb +96 -0
  46. data/lib/html2rss/feed_builder/json_feed.rb +61 -0
  47. data/lib/html2rss/feed_builder/rss/stylesheet.rb +65 -0
  48. data/lib/html2rss/feed_builder/rss.rb +116 -0
  49. data/lib/html2rss/feed_builder.rb +23 -0
  50. data/lib/html2rss/feed_pipeline/auto_fallback.rb +59 -42
  51. data/lib/html2rss/feed_pipeline/runtime_policy.rb +108 -0
  52. data/lib/html2rss/feed_pipeline/strategy_plan.rb +64 -0
  53. data/lib/html2rss/feed_pipeline.rb +52 -65
  54. data/lib/html2rss/html/article_extractor/category_extractor.rb +123 -0
  55. data/lib/html2rss/html/article_extractor/date_extractor.rb +22 -0
  56. data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +95 -0
  57. data/lib/html2rss/html/article_extractor/heading_extractor.rb +54 -0
  58. data/lib/html2rss/html/article_extractor/id_generator.rb +69 -0
  59. data/lib/html2rss/html/article_extractor/image_extractor.rb +60 -0
  60. data/lib/html2rss/html/article_extractor.rb +134 -0
  61. data/lib/html2rss/html/navigator/text_extractor.rb +79 -0
  62. data/lib/html2rss/html/navigator.rb +146 -0
  63. data/lib/html2rss/html/rendering/audio_renderer.rb +38 -0
  64. data/lib/html2rss/html/rendering/description_builder.rb +89 -0
  65. data/lib/html2rss/html/rendering/image_renderer.rb +43 -0
  66. data/lib/html2rss/html/rendering/media_renderer.rb +39 -0
  67. data/lib/html2rss/html/rendering/pdf_renderer.rb +36 -0
  68. data/lib/html2rss/html/rendering/video_renderer.rb +38 -0
  69. data/lib/html2rss/html/rendering.rb +25 -0
  70. data/lib/html2rss/html.rb +8 -0
  71. data/lib/html2rss/request_service/blocked_surface.rb +67 -0
  72. data/lib/html2rss/request_service/botasaurus_contract.rb +0 -4
  73. data/lib/html2rss/request_service/botasaurus_strategy.rb +9 -27
  74. data/lib/html2rss/request_service/browserless_strategy.rb +2 -23
  75. data/lib/html2rss/request_service/budget.rb +90 -16
  76. data/lib/html2rss/request_service/context.rb +1 -3
  77. data/lib/html2rss/request_service/faraday_strategy.rb +12 -26
  78. data/lib/html2rss/request_service/local_file_strategy.rb +12 -4
  79. data/lib/html2rss/request_service/network_guard.rb +125 -0
  80. data/lib/html2rss/request_service/policy.rb +10 -82
  81. data/lib/html2rss/request_service/puppet_commander/navigation_guards.rb +148 -0
  82. data/lib/html2rss/request_service/puppet_commander/preload_runner.rb +86 -0
  83. data/lib/html2rss/request_service/puppet_commander.rb +22 -191
  84. data/lib/html2rss/request_service/response_guard.rb +1 -1
  85. data/lib/html2rss/request_service/strategy.rb +102 -6
  86. data/lib/html2rss/request_service.rb +7 -2
  87. data/lib/html2rss/request_session/pager/base.rb +83 -0
  88. data/lib/html2rss/request_session/pager/custom_selector.rb +39 -0
  89. data/lib/html2rss/request_session/pager/json_cursor.rb +78 -0
  90. data/lib/html2rss/request_session/pager/offset.rb +48 -0
  91. data/lib/html2rss/request_session/pager/rel_next.rb +23 -0
  92. data/lib/html2rss/request_session/pager/url_template.rb +48 -0
  93. data/lib/html2rss/request_session/pager.rb +75 -0
  94. data/lib/html2rss/request_session.rb +25 -16
  95. data/lib/html2rss/selectors/item_scope.rb +30 -0
  96. data/lib/html2rss/selectors/post_processors/base.rb +1 -1
  97. data/lib/html2rss/selectors/post_processors/markdown_to_html.rb +0 -1
  98. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +0 -2
  99. data/lib/html2rss/selectors/post_processors/template.rb +7 -3
  100. data/lib/html2rss/selectors.rb +63 -65
  101. data/lib/html2rss/version.rb +1 -3
  102. data/lib/html2rss.rb +19 -19
  103. data/schema/html2rss-config.schema.json +109 -9
  104. metadata +68 -42
  105. data/lib/html2rss/auto_source/scraper/html/class_clustering.rb +0 -196
  106. data/lib/html2rss/auto_source/scraper/link_heuristics.rb +0 -401
  107. data/lib/html2rss/auto_source/scraper/semantic_html/anchor_selector.rb +0 -49
  108. data/lib/html2rss/blocked_surface.rb +0 -65
  109. data/lib/html2rss/category_extractor.rb +0 -116
  110. data/lib/html2rss/config/class_methods.rb +0 -183
  111. data/lib/html2rss/html_extractor/date_extractor.rb +0 -20
  112. data/lib/html2rss/html_extractor/enclosure_extractor.rb +0 -91
  113. data/lib/html2rss/html_extractor/heading_extractor.rb +0 -50
  114. data/lib/html2rss/html_extractor/id_generator.rb +0 -67
  115. data/lib/html2rss/html_extractor/image_extractor.rb +0 -58
  116. data/lib/html2rss/html_extractor/list_candidates.rb +0 -88
  117. data/lib/html2rss/html_extractor/semantic_anchor_candidates.rb +0 -260
  118. data/lib/html2rss/html_extractor/semantic_containers.rb +0 -69
  119. data/lib/html2rss/html_extractor/text_extractor.rb +0 -77
  120. data/lib/html2rss/html_extractor.rb +0 -190
  121. data/lib/html2rss/html_navigator.rb +0 -71
  122. data/lib/html2rss/json_feed_builder/item.rb +0 -94
  123. data/lib/html2rss/json_feed_builder.rb +0 -59
  124. data/lib/html2rss/rendering/audio_renderer.rb +0 -36
  125. data/lib/html2rss/rendering/description_builder.rb +0 -87
  126. data/lib/html2rss/rendering/image_renderer.rb +0 -41
  127. data/lib/html2rss/rendering/media_renderer.rb +0 -37
  128. data/lib/html2rss/rendering/pdf_renderer.rb +0 -34
  129. data/lib/html2rss/rendering/video_renderer.rb +0 -36
  130. data/lib/html2rss/rendering.rb +0 -23
  131. data/lib/html2rss/request_controls.rb +0 -133
  132. data/lib/html2rss/request_session/rel_next_pager.rb +0 -70
  133. data/lib/html2rss/request_session/runtime_input.rb +0 -71
  134. data/lib/html2rss/request_session/runtime_policy.rb +0 -84
  135. data/lib/html2rss/rss_builder/article.rb +0 -208
  136. data/lib/html2rss/rss_builder/channel.rb +0 -118
  137. data/lib/html2rss/rss_builder/stylesheet.rb +0 -63
  138. data/lib/html2rss/rss_builder.rb +0 -101
@@ -3,7 +3,10 @@
3
3
  module Html2rss
4
4
  class RequestService
5
5
  ##
6
- # Defines the interface of every request strategy.
6
+ # Defines the guarded request interface for every strategy adapter.
7
+ #
8
+ # `#execute` always runs timeout check → budget/policy preflight → `#fetch`
9
+ # → ResponseGuard postflight so new adapters cannot skip shared controls.
7
10
  class Strategy
8
11
  ##
9
12
  # @param ctx [Context] the context for the request
@@ -12,11 +15,17 @@ module Html2rss
12
15
  end
13
16
 
14
17
  ##
15
- # Executes the request.
18
+ # Executes the request through the shared preflight/postflight path.
19
+ #
16
20
  # @return [Response] the response from the strategy
17
- # @raise [NotImplementedError] if the method is not implemented by the subclass
21
+ # @raise [NotImplementedError] if the subclass does not implement `#fetch`
18
22
  def execute
19
- raise NotImplementedError, 'Subclass must implement #execute'
23
+ check_timeout!
24
+ response = perform_execute
25
+ postflight!(response, response_guard:)
26
+ response
27
+ rescue StandardError => error
28
+ handle_error(error)
20
29
  end
21
30
 
22
31
  private
@@ -24,9 +33,96 @@ module Html2rss
24
33
  # @return [Context] the context for the request
25
34
  attr_reader :ctx
26
35
 
36
+ ##
37
+ # Adapter hook: perform the preflight, fetch, and optional retry lifecycle.
38
+ #
39
+ # @return [Response] normalized response
40
+ def perform_execute
41
+ preflight!
42
+ fetch
43
+ end
44
+
45
+ ##
46
+ # Adapter hook: perform the transport-specific fetch after preflight.
47
+ #
48
+ # @return [Response] normalized response
49
+ # @raise [NotImplementedError] when a subclass omits the hook
50
+ def fetch
51
+ raise NotImplementedError, 'Subclass must implement #fetch'
52
+ end
53
+
54
+ ##
55
+ # Consumes a request slot and validates the destination before transport work.
56
+ #
57
+ # @param consume_budget [Boolean] whether to decrement the request budget
58
+ # @return [void]
59
+ def preflight!(consume_budget: true)
60
+ return if skip_preflight?
61
+
62
+ ctx.budget.consume! if consume_budget
63
+ ctx.policy.validate_request!(url: ctx.url, origin_url: ctx.origin_url, relation: ctx.relation)
64
+ end
65
+
66
+ ##
67
+ # Enforces response-size and blocked-surface checks after transport work.
68
+ #
69
+ # @param response [Response, nil] adapter response
70
+ # @param response_guard [ResponseGuard, nil] existing guard to reuse (e.g. streaming)
71
+ # @return [void]
72
+ def postflight!(response, response_guard: nil)
73
+ return if skip_postflight? || response.nil?
74
+
75
+ guard = response_guard || ResponseGuard.new(policy: ctx.policy)
76
+ guard.inspect_body!(response.body)
77
+ end
78
+
79
+ ##
80
+ # @return [Boolean] true when remote budget/policy preflight must be skipped
81
+ def skip_preflight? = false
82
+
83
+ ##
84
+ # @return [Boolean] true when ResponseGuard postflight must be skipped
85
+ def skip_postflight? = false
86
+
27
87
  def check_timeout!
28
- remaining = ctx.budget.remaining_timeout_seconds
29
- raise RequestTimedOut, 'Request timed out' if remaining && remaining <= 0
88
+ ctx.budget.effective_timeout_seconds(fallback: ctx.policy.total_timeout_seconds)
89
+ end
90
+
91
+ # @return [ResponseGuard, nil]
92
+ def response_guard = nil
93
+
94
+ # @param error [StandardError]
95
+ # @return [void]
96
+ # @raise [StandardError]
97
+ def handle_error(error)
98
+ if timeout_error?(error)
99
+ raise RequestTimedOut, error.message
100
+ elsif connection_error?(error)
101
+ translate_connection_error(error)
102
+ else
103
+ raise error
104
+ end
105
+ end
106
+
107
+ # @param error [StandardError]
108
+ # @return [void]
109
+ # @raise [StandardError]
110
+ def translate_connection_error(error)
111
+ raise error
112
+ end
113
+
114
+ # @param error [StandardError]
115
+ # @return [Boolean]
116
+ def timeout_error?(error)
117
+ error.is_a?(Faraday::TimeoutError) ||
118
+ error.is_a?(Timeout::Error) ||
119
+ (defined?(Puppeteer::TimeoutError) && error.is_a?(Puppeteer::TimeoutError))
120
+ end
121
+
122
+ # @param error [StandardError]
123
+ # @return [Boolean]
124
+ def connection_error?(error)
125
+ error.is_a?(Faraday::ConnectionFailed) || error.is_a?(Faraday::SSLError)
30
126
  end
31
127
  end
32
128
  end
@@ -6,7 +6,10 @@ require 'forwardable'
6
6
  module Html2rss
7
7
  ##
8
8
  # Requests website URLs to retrieve their HTML for further processing.
9
- # Provides strategies, e.g. integrating Browserless.io.
9
+ # Provides concrete transport strategies (e.g. Faraday, Browserless).
10
+ #
11
+ # Feed-level +:auto+ is not registered here — {FeedPipeline::StrategyPlan} resolves
12
+ # it to a concrete strategy (or {FeedPipeline::AutoFallback} chain) before execute.
10
13
  class RequestService
11
14
  include Singleton
12
15
 
@@ -18,8 +21,10 @@ module Html2rss
18
21
  class UnsupportedUrlScheme < Html2rss::Error; end
19
22
  # Raised when a response type cannot be parsed.
20
23
  class UnsupportedResponseContentType < Html2rss::Error; end
21
- # Raised when request limits are exceeded.
24
+ # Raised when HTTP request slot limits are exceeded.
22
25
  class RequestBudgetExceeded < Html2rss::Error; end
26
+ # Raised when Browserless preload interaction limits are exceeded.
27
+ class InteractionBudgetExceeded < Html2rss::Error; end
23
28
  # Raised when policy denies private-network access.
24
29
  class PrivateNetworkDenied < Html2rss::Error; end
25
30
  # Raised when cross-origin follow-up requests are denied.
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ class RequestSession
5
+ module Pager
6
+ ##
7
+ # Abstract base class for pagination strategies.
8
+ class Base
9
+ include Enumerable
10
+
11
+ # Default maximum number of pages to fetch if omitted.
12
+ # @return [Integer]
13
+ DEFAULT_MAX_PAGES = 5
14
+
15
+ # @param session [RequestSession] request session used to execute follow-ups
16
+ # @param initial_response [RequestService::Response] first page response
17
+ # @param config [Hash, Integer] pagination configuration or max_pages integer
18
+ # @param logger [Logger] logger used for pagination stop warnings
19
+ def initialize(session:, initial_response:, config: {}, logger: Html2rss::Log)
20
+ @session = session
21
+ @initial_response = initial_response
22
+ @config = config.is_a?(Hash) ? config : { max_pages: config }
23
+ @logger = logger
24
+ end
25
+
26
+ # @yield [RequestService::Response] each page response
27
+ # @return [Enumerator] enumerator when no block is given
28
+ def each
29
+ return enum_for(:each) unless block_given?
30
+
31
+ yield initial_response
32
+
33
+ current_response = initial_response
34
+ session.effective_page_budget(max_pages).pred.times do |index|
35
+ next_url = next_page_url(current_response, page_number: index + 2)
36
+ break unless follow_up_allowed?(next_url)
37
+
38
+ current_response = fetch_follow_up_response_or_stop(next_url, current_response.url)
39
+ break unless current_response
40
+
41
+ yield current_response
42
+ end
43
+ end
44
+
45
+ private
46
+
47
+ attr_reader :session, :initial_response, :config, :logger
48
+
49
+ # @return [Integer] configured maximum pages (always a positive Integer)
50
+ def max_pages
51
+ value = config.fetch(:max_pages, DEFAULT_MAX_PAGES)
52
+ value.is_a?(Integer) && value.positive? ? value : DEFAULT_MAX_PAGES
53
+ end
54
+
55
+ # @param next_url [Html2rss::Url, String, nil]
56
+ # @return [Boolean]
57
+ def follow_up_allowed?(next_url)
58
+ !next_url.nil? && !next_url.to_s.empty? && !session.visited?(next_url)
59
+ end
60
+
61
+ # @param next_url [Html2rss::Url, String]
62
+ # @param origin_url [Html2rss::Url, String]
63
+ # @return [RequestService::Response, nil]
64
+ def fetch_follow_up_response_or_stop(next_url, origin_url)
65
+ session.follow_up(url: next_url, relation: :pagination, origin_url:)
66
+ rescue RequestService::RequestBudgetExceeded => error
67
+ logger.warn(
68
+ "#{self.class}: pagination stopped at #{next_url} - #{error.message}. " \
69
+ "Retry with --max-requests #{session.max_requests + 1} or increase request.max_requests in the config."
70
+ )
71
+ nil
72
+ end
73
+
74
+ # @param page_response [RequestService::Response]
75
+ # @param page_number [Integer] 1-based target page number (e.g. 2 for second page)
76
+ # @return [Html2rss::Url, String, nil]
77
+ def next_page_url(page_response, page_number:)
78
+ raise NotImplementedError, "#{self.class}#next_page_url must be implemented by subclass"
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ class RequestSession
5
+ module Pager
6
+ ##
7
+ # Extracts the next-page URL using a configurable CSS or XPath selector.
8
+ class CustomSelector < Base
9
+ private
10
+
11
+ # @param page_response [RequestService::Response]
12
+ # @param page_number [Integer]
13
+ # @return [String, nil]
14
+ def next_page_url(page_response, page_number: nil) # rubocop:disable Lint/UnusedMethodArgument
15
+ selector = config[:selector]
16
+ return nil if selector.nil? || selector.empty?
17
+
18
+ node = extract_node(page_response.parsed_body, selector)
19
+ return nil unless node
20
+
21
+ href = node['href'] || node.text
22
+ return nil if href.nil? || href.strip.empty?
23
+
24
+ Html2rss::Url.from_relative(href.strip, page_response.url)
25
+ end
26
+
27
+ # @param parsed_body [Nokogiri::HTML::Document]
28
+ # @param selector [String]
29
+ # @return [Nokogiri::XML::Node, nil]
30
+ def extract_node(parsed_body, selector)
31
+ parsed_body.at_css(selector) || parsed_body.at_xpath(selector)
32
+ rescue Nokogiri::CSS::SyntaxError, Nokogiri::XML::XPath::SyntaxError
33
+ # Pure XPath like `descendant::a` fails LOOKS_LIKE_XPATH / at_css; fall back explicitly.
34
+ parsed_body.at_xpath(selector)
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Html2rss
6
+ class RequestSession
7
+ module Pager
8
+ ##
9
+ # Digs into JSON response payloads to extract next page URLs or cursor tokens.
10
+ class JsonCursor < Base
11
+ # Default query parameter name for cursor token pagination.
12
+ # @return [String]
13
+ DEFAULT_PARAM = 'cursor'
14
+
15
+ private
16
+
17
+ # @param page_response [RequestService::Response]
18
+ # @param page_number [Integer]
19
+ # @return [String, nil]
20
+ def next_page_url(page_response, page_number: nil) # rubocop:disable Lint/UnusedMethodArgument
21
+ json_data = parse_json_body(page_response)
22
+ return nil unless json_data
23
+
24
+ url_from_next_path(json_data, page_response.url) ||
25
+ url_from_cursor_path(json_data, page_response.url)
26
+ end
27
+
28
+ # @param json_data [Hash]
29
+ # @param origin_url [Html2rss::Url]
30
+ # @return [String, nil]
31
+ def url_from_next_path(json_data, origin_url)
32
+ return nil unless (next_url_path = config[:next_url_path])
33
+
34
+ next_url = dig_path(json_data, next_url_path)
35
+ Html2rss::Url.from_relative(next_url.to_s, origin_url) if next_url && !next_url.to_s.empty?
36
+ end
37
+
38
+ # @param json_data [Hash]
39
+ # @param origin_url [Html2rss::Url]
40
+ # @return [Html2rss::Url, nil]
41
+ def url_from_cursor_path(json_data, origin_url)
42
+ return nil unless (cursor_path = config[:cursor_path])
43
+
44
+ cursor_val = dig_path(json_data, cursor_path)
45
+ return nil if cursor_val.nil? || cursor_val.to_s.empty?
46
+
47
+ param = config.fetch(:param, DEFAULT_PARAM)
48
+ url = Html2rss::Url.from_absolute(origin_url)
49
+ url.with_query_values(url.query_values.merge(param.to_s => cursor_val.to_s))
50
+ end
51
+
52
+ # @param page_response [RequestService::Response]
53
+ # @return [Hash, nil]
54
+ def parse_json_body(page_response)
55
+ JSON.parse(page_response.body)
56
+ rescue JSON::ParserError
57
+ nil
58
+ end
59
+
60
+ # @param hash [Hash]
61
+ # @param path [String, Symbol]
62
+ # @return [Object, nil]
63
+ def dig_path(hash, path)
64
+ return nil unless hash.is_a?(Hash)
65
+
66
+ keys = path.to_s.split('.')
67
+ result = hash
68
+ keys.each do |key|
69
+ return nil unless result.is_a?(Hash)
70
+
71
+ result = result[key] || result[key.to_sym]
72
+ end
73
+ result
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ class RequestSession
5
+ module Pager
6
+ ##
7
+ # Paginate using numeric offset increments in query parameters or URL path templates.
8
+ class Offset < Base
9
+ # Default query parameter name for offset pagination.
10
+ # @return [String]
11
+ DEFAULT_PARAM = 'offset'
12
+
13
+ # Default starting numeric offset value.
14
+ # @return [Integer]
15
+ DEFAULT_START_OFFSET = 0
16
+
17
+ # Default offset increment per page.
18
+ # @return [Integer]
19
+ DEFAULT_INCREMENT = 20
20
+
21
+ private
22
+
23
+ # @param page_response [RequestService::Response]
24
+ # @param page_number [Integer]
25
+ # @return [Html2rss::Url]
26
+ def next_page_url(page_response, page_number:)
27
+ param = config.fetch(:param, DEFAULT_PARAM)
28
+ target_offset_val = target_offset_for(page_number)
29
+ url = Html2rss::Url.from_absolute(page_response.url)
30
+
31
+ if url.to_s.include?('{offset}')
32
+ Html2rss::Url.from_absolute(url.to_s.gsub('{offset}', target_offset_val.to_s))
33
+ else
34
+ url.with_query_values(url.query_values.merge(param.to_s => target_offset_val.to_s))
35
+ end
36
+ end
37
+
38
+ # @param page_number [Integer]
39
+ # @return [Integer]
40
+ def target_offset_for(page_number)
41
+ start_offset = config.fetch(:start_offset, DEFAULT_START_OFFSET)
42
+ increment = config.fetch(:increment, DEFAULT_INCREMENT)
43
+ start_offset + ((page_number - 1) * increment)
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ class RequestSession
5
+ module Pager
6
+ ##
7
+ # Traverses a rel=next pagination chain for HTML documents.
8
+ class RelNext < Base
9
+ private
10
+
11
+ # @param page_response [RequestService::Response]
12
+ # @param page_number [Integer]
13
+ # @return [String, nil]
14
+ def next_page_url(page_response, page_number: nil) # rubocop:disable Lint/UnusedMethodArgument
15
+ href = page_response.parsed_body.at_css('link[rel~="next"][href], a[rel~="next"][href]')&.[]('href')
16
+ return nil if href.nil? || href.empty?
17
+
18
+ Html2rss::Url.from_relative(href, page_response.url)
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ class RequestSession
5
+ module Pager
6
+ ##
7
+ # Paginate using page number increments in query parameters or URL path templates.
8
+ class UrlTemplate < Base
9
+ # Default query parameter name for page number pagination.
10
+ # @return [String]
11
+ DEFAULT_PARAM = 'page'
12
+
13
+ # Default starting page number.
14
+ # @return [Integer]
15
+ DEFAULT_START_PAGE = 1
16
+
17
+ # Default page number step increment.
18
+ # @return [Integer]
19
+ DEFAULT_STEP = 1
20
+
21
+ private
22
+
23
+ # @param page_response [RequestService::Response]
24
+ # @param page_number [Integer] 1-based target page number (e.g. 2 for 2nd page)
25
+ # @return [Html2rss::Url]
26
+ def next_page_url(page_response, page_number:)
27
+ param = config.fetch(:param, DEFAULT_PARAM)
28
+ target_page_val = target_page_for(page_number)
29
+ url = Html2rss::Url.from_absolute(page_response.url)
30
+
31
+ if url.to_s.include?('{page}')
32
+ Html2rss::Url.from_absolute(url.to_s.gsub('{page}', target_page_val.to_s))
33
+ else
34
+ url.with_query_values(url.query_values.merge(param.to_s => target_page_val.to_s))
35
+ end
36
+ end
37
+
38
+ # @param page_number [Integer]
39
+ # @return [Integer]
40
+ def target_page_for(page_number)
41
+ start_page = config.fetch(:start_page, DEFAULT_START_PAGE)
42
+ step = config.fetch(:step, DEFAULT_STEP)
43
+ start_page + ((page_number - 1) * step)
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ class RequestSession
5
+ ##
6
+ # Factory for building pagination strategy instances.
7
+ module Pager
8
+ # Mapping of strategy symbols to strategy class implementations.
9
+ # @return [Hash{Symbol => Class}]
10
+ STRATEGIES = {
11
+ rel_next: RelNext,
12
+ custom_selector: CustomSelector,
13
+ url_template: UrlTemplate,
14
+ offset: Offset,
15
+ json_cursor: JsonCursor
16
+ }.freeze
17
+
18
+ ##
19
+ # Returns the supported pagination strategy names.
20
+ #
21
+ # @return [Array<String>] strategy names derived from {STRATEGIES}
22
+ def self.strategy_names
23
+ STRATEGIES.keys.map(&:to_s).freeze
24
+ end
25
+
26
+ ##
27
+ # Returns the number of request slots needed for the pagination configuration.
28
+ #
29
+ # @param config [Hash, Integer, nil] pagination configuration
30
+ # @return [Integer] number of follow-up requests needed
31
+ def self.request_slots_for(config)
32
+ return 0 unless config
33
+
34
+ max_pages = normalize_config(config)[:max_pages] || Base::DEFAULT_MAX_PAGES
35
+ [max_pages - 1, 0].max
36
+ end
37
+
38
+ ##
39
+ # Returns a pager instance for the provided configuration.
40
+ #
41
+ # @param config [Hash, Integer, nil] pagination configuration
42
+ # @param session [RequestSession] request session used to execute follow-ups
43
+ # @param initial_response [RequestService::Response] initial page response
44
+ # @return [RequestSession::Pager::Base] pager strategy instance
45
+ def self.for(config, session:, initial_response:)
46
+ normalized_config = normalize_config(config)
47
+ strategy_name = normalized_config.fetch(:strategy, :rel_next).to_sym
48
+
49
+ klass = STRATEGIES.fetch(strategy_name) do
50
+ raise ArgumentError, "Unknown pagination strategy: #{strategy_name}"
51
+ end
52
+
53
+ klass.new(session:, initial_response:, config: normalized_config)
54
+ end
55
+
56
+ ##
57
+ # Normalizes integer, hash, or nil inputs into a strategy config hash.
58
+ #
59
+ # @param config [Hash, Integer, nil]
60
+ # @return [Hash]
61
+ def self.normalize_config(config)
62
+ case config
63
+ when Integer
64
+ { max_pages: config, strategy: :rel_next }
65
+ when Hash
66
+ normalized = config.transform_keys(&:to_sym)
67
+ normalized[:strategy] = (normalized[:strategy] || :rel_next).to_sym
68
+ normalized
69
+ else
70
+ { max_pages: Base::DEFAULT_MAX_PAGES, strategy: :rel_next }
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -6,26 +6,22 @@ module Html2rss
6
6
  class RequestSession
7
7
  class << self
8
8
  ##
9
- # Builds a request session from translated runtime request inputs.
9
+ # Builds a request session from config, strategy, and shared budget/policy.
10
10
  #
11
- # @param runtime_input [RuntimeInput] translated runtime request inputs
12
- # @param budget [RequestService::Budget, nil] optional shared budget for multi-attempt runs
11
+ # Context owns URL/header/request normalization once; callers must not
12
+ # pre-normalize through a passthrough bag.
13
+ #
14
+ # @param config [Html2rss::Config] validated feed config
15
+ # @param strategy [Symbol] request strategy for the session
16
+ # @param budget [RequestService::Budget] shared request budget
17
+ # @param policy [RequestService::Policy] request policy (from FeedPipeline::RuntimePolicy.resources_for)
13
18
  # @param logger [Logger] logger used for operational warnings
14
19
  # @return [RequestSession] configured request session
15
- def from_runtime_input(runtime_input, budget: nil, logger: Html2rss::Log) # rubocop:disable Metrics/MethodLength
16
- context_options = {
17
- url: runtime_input.url,
18
- headers: runtime_input.headers,
19
- request: runtime_input.request,
20
- policy: runtime_input.request_policy
21
- }
22
- context_options[:budget] = budget unless budget.nil?
23
-
24
- new(
25
- context: RequestService::Context.new(**context_options),
26
- strategy: runtime_input.strategy,
27
- logger:
20
+ def build(config:, strategy:, budget:, policy:, logger: Html2rss::Log)
21
+ context = RequestService::Context.new(
22
+ url: config.url, headers: config.headers, request: config.request, policy:, budget:
28
23
  )
24
+ new(context:, strategy:, logger:)
29
25
  end
30
26
  end
31
27
 
@@ -48,6 +44,19 @@ module Html2rss
48
44
  execute(context).tap { |response| remember!(response.url) }
49
45
  end
50
46
 
47
+ ##
48
+ # Returns an enumerable sequence of page responses for the given initial response
49
+ # and optional pagination configuration.
50
+ #
51
+ # @param initial_response [RequestService::Response] initial page response
52
+ # @param pagination_config [Hash, Integer, nil] pagination configuration
53
+ # @return [Enumerable<RequestService::Response>] page responses
54
+ def page_responses(initial_response, pagination_config: nil)
55
+ return [initial_response] unless pagination_config
56
+
57
+ Pager.for(pagination_config, session: self, initial_response:)
58
+ end
59
+
51
60
  ##
52
61
  # Executes a follow-up request sharing policy, headers, and budget.
53
62
  #
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ class Selectors
5
+ ##
6
+ # Per-item extraction scope: owns the article node and page +base_url+ for one
7
+ # extraction pass. Channel hashes are injected (and cached) by {Selectors}.
8
+ #
9
+ # Distinct from {Context}, which is the post-processor invocation bag (+options+).
10
+ # Post-processor +config+ is derived from {#channel} — not a parallel bag.
11
+ ItemScope = Data.define(:item, :base_url, :scraper, :channel) do
12
+ ##
13
+ # Selects an attribute using this scope's item and base_url.
14
+ #
15
+ # @param name [Symbol, String]
16
+ # @return [Object, Array<Object>]
17
+ def select(name) = scraper.select_in_scope(name, self)
18
+
19
+ ##
20
+ # Builds a post-processor {Context} carrying this scope for nested selects.
21
+ #
22
+ # @param options [Hash] post-processor options from the selector config
23
+ # @option options [String] :name post-processor name
24
+ # @return [Context]
25
+ def context_for(options:)
26
+ Context.new(options:, item:, config: { channel: }, scraper:, item_scope: self)
27
+ end
28
+ end
29
+ end
30
+ end
@@ -29,7 +29,7 @@ module Html2rss
29
29
  # @param context [Selectors::Context] call-site context used for richer validation errors
30
30
  # @return [void]
31
31
  # @raise [InvalidType] if the value is not of the expected type(s)
32
- def self.assert_type(value, types = [], name, context:)
32
+ def self.assert_type(value, types, name, context:)
33
33
  return if Array(types).any? { |type| value.is_a?(type) }
34
34
 
35
35
  options = if context.respond_to?(:options)
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'kramdown'
4
- require_relative 'sanitize_html'
5
4
 
6
5
  module Html2rss
7
6
  class Selectors
@@ -1,8 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'sanitize'
4
- require_relative 'html_transformers/transform_urls_to_absolute_ones'
5
- require_relative 'html_transformers/wrap_img_in_a'
6
4
 
7
5
  module Html2rss
8
6
  class Selectors