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,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
  #
@@ -100,6 +109,10 @@ module Html2rss
100
109
  visited_urls.add(normalize_url(url))
101
110
  end
102
111
 
112
+ ##
113
+ # @return [Html2rss::Url] the session's current request URL
114
+ def url = context.url
115
+
103
116
  private
104
117
 
105
118
  attr_reader :context, :strategy, :logger, :visited_urls
@@ -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
@@ -42,6 +42,10 @@ module Html2rss
42
42
 
43
43
  string = context[:options]&.dig(:string).to_s
44
44
  raise InvalidType, 'The `string` template is absent.' if string.empty?
45
+
46
+ return if context.item_scope
47
+
48
+ raise MissingOption, 'The post-processor context is missing `item_scope`.', [], cause: nil
45
49
  end
46
50
 
47
51
  ##
@@ -51,8 +55,6 @@ module Html2rss
51
55
  super
52
56
 
53
57
  @options = context[:options] || {}
54
- @scraper = context[:scraper]
55
- @item = context[:item]
56
58
  @string = @options[:string].to_s
57
59
  @getter = ->(key) { item_value(key) }
58
60
  end
@@ -69,7 +71,9 @@ module Html2rss
69
71
  # @return [String]
70
72
  def item_value(key)
71
73
  key = key.to_sym
72
- key == :self ? value : @scraper.select(key, @item)
74
+ return value if key == :self
75
+
76
+ @context.item_scope.select(key)
73
77
  end
74
78
  end
75
79
  end