html2rss 0.22.1 → 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 +53 -57
  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 +15 -25
  74. data/lib/html2rss/request_service/browserless_strategy.rb +2 -19
  75. data/lib/html2rss/request_service/budget.rb +102 -14
  76. data/lib/html2rss/request_service/context.rb +1 -1
  77. data/lib/html2rss/request_service/faraday_strategy.rb +12 -25
  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 +105 -4
  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
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ class RequestService
5
+ ##
6
+ # Shared anti-bot/interstitial signatures used by request and auto-source flows.
7
+ #
8
+ # This module centralizes signature matching so request-time guards and
9
+ # auto-source surface classification stay consistent.
10
+ module BlockedSurface
11
+ # Known interstitial fingerprints used to detect blocked or anti-bot surfaces.
12
+ INTERSTITIAL_SIGNATURES = [
13
+ {
14
+ key: :cloudflare_interstitial,
15
+ min_matches: 2,
16
+ patterns: [
17
+ %r{<title>\s*just a moment\.\.\.\s*</title>}i,
18
+ /checking your browser before accessing/i,
19
+ /please (?:enable|turn on) javascript and cookies/i,
20
+ %r{cdn-cgi/challenge-platform}i,
21
+ /cloudflare ray id/i
22
+ ],
23
+ message: 'Blocked surface detected: Cloudflare anti-bot interstitial page. ' \
24
+ 'Retry with --strategy browserless, try a more specific public listing URL, ' \
25
+ 'or run from an environment that can complete anti-bot checks.'
26
+ }
27
+ ].freeze
28
+
29
+ ##
30
+ # Returns the first matching interstitial signature for the provided body.
31
+ #
32
+ # @param body [String, nil] response body candidate
33
+ # @return [Hash, nil] signature hash when matched, otherwise nil
34
+ def self.interstitial_signature_for(body)
35
+ normalized_body = normalize_body(body)
36
+ INTERSTITIAL_SIGNATURES.find { |signature| interstitial_signature_match?(normalized_body, signature) }
37
+ end
38
+
39
+ ##
40
+ # @param body [String, nil] response body candidate
41
+ # @return [Boolean] true when body matches a known interstitial signature
42
+ def self.interstitial?(body)
43
+ !interstitial_signature_for(body).nil?
44
+ end
45
+
46
+ def self.interstitial_signature_match?(body, signature)
47
+ min_matches = signature.fetch(:min_matches, 1)
48
+ matches = 0
49
+
50
+ signature.fetch(:patterns).each do |pattern|
51
+ matches += 1 if pattern.match?(body)
52
+ return true if matches >= min_matches
53
+ end
54
+
55
+ false
56
+ end
57
+ private_class_method :interstitial_signature_match?
58
+
59
+ def self.normalize_body(body)
60
+ body.to_s.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: '')
61
+ rescue Encoding::CompatibilityError, Encoding::UndefinedConversionError, Encoding::InvalidByteSequenceError
62
+ body.to_s.force_encoding(Encoding::UTF_8).scrub
63
+ end
64
+ private_class_method :normalize_body
65
+ end
66
+ end
67
+ end
@@ -2,11 +2,7 @@
2
2
 
3
3
  require 'json'
4
4
 
5
- ##
6
- # Main html2rss namespace.
7
5
  module Html2rss
8
- ##
9
- # Request transport orchestration and strategies.
10
6
  class RequestService
11
7
  ##
12
8
  # Maps html2rss request/response handling to the botasaurus-scrape-api contract.
@@ -8,39 +8,23 @@ module Html2rss
8
8
  ##
9
9
  # Strategy to delegate fetching to a Botasaurus scrape API.
10
10
  class BotasaurusStrategy < Strategy
11
- ##
12
- # Executes a Botasaurus-backed request with shared request policy guards.
13
- #
14
- # @return [Response] normalized request response
15
- # @raise [BotasaurusConfigurationError] when BOTASAURUS_SCRAPER_URL is missing or invalid
16
- # @raise [BotasaurusConnectionFailed] when Botasaurus cannot be reached or returns an invalid payload
17
- # @raise [RequestTimedOut] when the Botasaurus request exceeds configured timeout
18
- def execute
19
- validate_request!
20
- transport_response = client.post('/scrape', JSON.generate(contract.request_payload), content_type_header)
21
- parsed_response = contract.parse_response(transport_response)
11
+ private
12
+
13
+ def fetch
14
+ parsed_response = post_scrape_request
22
15
  raise_if_challenge_blocked!(parsed_response)
23
16
  raise_if_upstream_failed!(parsed_response)
24
17
  build_response(parsed_response)
25
- rescue Faraday::TimeoutError, Timeout::Error => error
26
- raise RequestTimedOut, error.message
27
- rescue Faraday::ConnectionFailed, Faraday::SSLError => error
28
- raise BotasaurusConnectionFailed, "Botasaurus connection failed: #{error.message}"
29
18
  end
30
19
 
31
- private
32
-
33
- def validate_request!
34
- ctx.budget.consume!
35
- ctx.policy.validate_request!(url: ctx.url, origin_url: ctx.origin_url, relation: ctx.relation)
20
+ def post_scrape_request
21
+ transport_response = client.post('/scrape', JSON.generate(contract.request_payload), content_type_header)
22
+ contract.parse_response(transport_response)
36
23
  end
37
24
 
38
25
  def build_response(parsed_response)
39
- body = parsed_response.html
40
- ResponseGuard.new(policy: ctx.policy).inspect_body!(body)
41
-
42
26
  Response.new(
43
- body:,
27
+ body: parsed_response.html,
44
28
  headers: parsed_response.headers,
45
29
  url: response_url(parsed_response.final_url),
46
30
  status: parsed_response.status
@@ -76,7 +60,9 @@ module Html2rss
76
60
  end
77
61
 
78
62
  def request_options
79
- { timeout: ctx.policy.total_timeout_seconds }
63
+ timeout = ctx.budget.effective_timeout_seconds(fallback: ctx.policy.total_timeout_seconds)
64
+
65
+ { timeout: timeout.to_i }
80
66
  end
81
67
 
82
68
  def content_type_header
@@ -93,6 +79,10 @@ module Html2rss
93
79
  raise BotasaurusConfigurationError, "BOTASAURUS_SCRAPER_URL is invalid: #{error.message}"
94
80
  end
95
81
  end
82
+
83
+ def translate_connection_error(error)
84
+ raise BotasaurusConnectionFailed, "Botasaurus connection failed: #{error.message}"
85
+ end
96
86
  end
97
87
  end
98
88
  end
@@ -31,18 +31,6 @@ module Html2rss
31
31
  # are aligned with the default values.
32
32
  # @see https://github.com/browserless/browserless/pkgs/container/chromium
33
33
  class BrowserlessStrategy < Strategy
34
- ##
35
- # Executes a Browserless-backed request with the shared request policy.
36
- #
37
- # @return [Response] normalized request response
38
- # @raise [RequestTimedOut] if the browser session exceeds the configured timeout
39
- def execute
40
- validate_request!
41
- execute_browserless_request
42
- rescue Puppeteer::TimeoutError => error
43
- raise RequestTimedOut, error.message
44
- end
45
-
46
34
  ##
47
35
  # @return [String] the Browserless websocket endpoint with token query param
48
36
  # @raise [ArgumentError] if a custom endpoint is configured without an API token
@@ -57,12 +45,7 @@ module Html2rss
57
45
 
58
46
  private
59
47
 
60
- def validate_request!
61
- ctx.budget.consume!
62
- ctx.policy.validate_request!(url: ctx.url, origin_url: ctx.origin_url, relation: ctx.relation)
63
- end
64
-
65
- def execute_browserless_request
48
+ def fetch
66
49
  connect_with_timeout_support do |browser|
67
50
  PuppetCommander.new(ctx, browser).call
68
51
  ensure
@@ -71,7 +54,7 @@ module Html2rss
71
54
  end
72
55
 
73
56
  def protocol_timeout_ms
74
- ctx.policy.total_timeout_seconds * 1000
57
+ ctx.budget.effective_timeout_ms(fallback: ctx.policy.total_timeout_seconds)
75
58
  end
76
59
 
77
60
  def connect_with_timeout_support(&)
@@ -3,36 +3,124 @@
3
3
  module Html2rss
4
4
  class RequestService
5
5
  ##
6
- # Tracks how many outbound requests a single feed build may still perform.
6
+ # Tracks request slots, interaction budget, and wall-clock deadline for one feed build.
7
+ #
8
+ # HTTP fetches consume request slots; Browserless preload clicks/scrolls/waits consume
9
+ # interaction budget. Deadline remains on `#remaining_timeout_seconds`.
7
10
  class Budget
8
11
  ##
9
- # @param max_requests [Integer] the maximum number of requests allowed
10
- def initialize(max_requests:)
11
- unless max_requests.is_a?(Integer) && max_requests.positive?
12
- raise ArgumentError, 'max_requests must be positive'
13
- end
12
+ # @param max_requests [Integer] maximum HTTP request slots
13
+ # @param max_interactions [Integer] maximum preload interaction slots (default 0)
14
+ # @param total_timeout_seconds [Integer, nil] wall-clock timeout for the feed build
15
+ def initialize(max_requests:, max_interactions: 0, total_timeout_seconds: nil)
16
+ validate_slot_limits!(max_requests:, max_interactions:)
14
17
 
15
- @remaining = max_requests
18
+ @remaining_requests = max_requests
19
+ @remaining_interactions = max_interactions
20
+ @start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
21
+ @total_timeout_seconds = total_timeout_seconds
16
22
  @mutex = Mutex.new
17
23
  end
18
24
 
19
25
  ##
20
- # Consumes one request from the budget.
26
+ # Consumes one HTTP request slot.
21
27
  #
22
- # @return [Integer] remaining request count after consumption
23
- # @raise [RequestBudgetExceeded] if no requests remain
28
+ # @return [Integer] remaining request slots after consumption
29
+ # @raise [RequestBudgetExceeded] if no request slots remain
24
30
  def consume!
31
+ consume_request!
32
+ end
33
+
34
+ ##
35
+ # Consumes one HTTP request slot.
36
+ #
37
+ # @return [Integer] remaining request slots after consumption
38
+ # @raise [RequestBudgetExceeded] if no request slots remain
39
+ def consume_request!
40
+ @mutex.synchronize do
41
+ raise RequestBudgetExceeded, 'Request budget exhausted' if @remaining_requests.zero?
42
+
43
+ @remaining_requests -= 1
44
+ end
45
+ end
46
+
47
+ ##
48
+ # Consumes one Browserless preload interaction slot.
49
+ #
50
+ # Preload must not steal pagination HTTP request slots.
51
+ #
52
+ # @return [Integer] remaining interaction slots after consumption
53
+ # @raise [InteractionBudgetExceeded] if no interaction slots remain
54
+ def consume_interaction!
25
55
  @mutex.synchronize do
26
- raise RequestBudgetExceeded, 'Request budget exhausted' if @remaining.zero?
56
+ raise InteractionBudgetExceeded, 'Interaction budget exhausted' if @remaining_interactions.zero?
27
57
 
28
- @remaining -= 1
58
+ @remaining_interactions -= 1
29
59
  end
30
60
  end
31
61
 
32
62
  ##
33
- # @return [Integer] requests still available
63
+ # @return [Integer] HTTP request slots still available
34
64
  def remaining
35
- @mutex.synchronize { @remaining }
65
+ remaining_requests
66
+ end
67
+
68
+ ##
69
+ # @return [Integer] HTTP request slots still available
70
+ def remaining_requests
71
+ @mutex.synchronize { @remaining_requests }
72
+ end
73
+
74
+ ##
75
+ # @return [Integer] preload interaction slots still available
76
+ def remaining_interactions
77
+ @mutex.synchronize { @remaining_interactions }
78
+ end
79
+
80
+ ##
81
+ # @return [Float, nil] the remaining timeout in seconds, or nil if not tracked
82
+ def remaining_timeout_seconds
83
+ return unless @total_timeout_seconds
84
+
85
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - @start_time
86
+ remaining = @total_timeout_seconds - elapsed
87
+ [remaining, 0.0].max
88
+ end
89
+
90
+ ##
91
+ # Resolves wall-clock seconds for the next adapter attempt.
92
+ #
93
+ # Prefers the tracked deadline remainder; falls back to the policy total when
94
+ # this Budget was constructed without a timeout (common in unit tests).
95
+ #
96
+ # @param fallback [Numeric] policy total timeout when remaining is untracked
97
+ # @return [Float] seconds available for this attempt
98
+ # @raise [RequestTimedOut] when no time remains
99
+ def effective_timeout_seconds(fallback:)
100
+ timeout = remaining_timeout_seconds || fallback
101
+ raise RequestTimedOut, 'Request timed out' if timeout <= 0
102
+
103
+ timeout.to_f
104
+ end
105
+
106
+ ##
107
+ # @param fallback [Numeric] policy total timeout when remaining is untracked
108
+ # @return [Integer] milliseconds available for this attempt
109
+ # @raise [RequestTimedOut] when no time remains
110
+ def effective_timeout_ms(fallback:)
111
+ (effective_timeout_seconds(fallback:) * 1000).to_i
112
+ end
113
+
114
+ private
115
+
116
+ def validate_slot_limits!(max_requests:, max_interactions:)
117
+ unless max_requests.is_a?(Integer) && max_requests.positive?
118
+ raise ArgumentError,
119
+ 'max_requests must be positive'
120
+ end
121
+ return if max_interactions.is_a?(Integer) && !max_interactions.negative?
122
+
123
+ raise ArgumentError, 'max_interactions must be a non-negative integer'
36
124
  end
37
125
  end
38
126
  end
@@ -77,7 +77,7 @@ module Html2rss
77
77
  raise ArgumentError, 'policy must not be nil' if @policy.nil?
78
78
 
79
79
  @origin_url = normalized_origin_url(request_options[:origin_url])
80
- @budget = request_options.fetch(:budget) { Budget.new(max_requests: policy.max_requests) }
80
+ @budget = request_options.fetch(:budget) { raise ArgumentError, 'budget is required' }
81
81
  raise ArgumentError, 'budget must not be nil' if @budget.nil?
82
82
  end
83
83
 
@@ -27,32 +27,24 @@ module Html2rss
27
27
  end
28
28
 
29
29
  ##
30
- # Executes a request with runtime policy enforcement.
30
+ # @return [ResponseGuard]
31
+ attr_reader :response_guard
32
+
33
+ # Executes the request with runtime policy enforcement, returning the normalized response.
31
34
  #
32
- # @return [Response] normalized request response
33
- # @note Unlike BrowserlessStrategy, Faraday does not expose the remote IP after connect.
34
- # SSRF protection here is pre-connection only (DNS resolution via Policy).
35
- # A DNS rebinding attack between resolution and connect cannot be caught at this layer.
36
- def execute
35
+ # @return [Response] normalized response
36
+ def perform_execute
37
37
  deadline = request_deadline
38
- response_guard, response = perform_request(deadline:)
39
- response_guard.inspect_body!(response.body)
40
- build_response(response)
41
- rescue Faraday::TimeoutError, Timeout::Error => error
42
- raise RequestTimedOut, error.message
38
+ @response_guard = ResponseGuard.new(policy: ctx.policy)
39
+ raw_response = faraday_request(response_guard, deadline:, streaming_buffer: true)
40
+ raw_response = retry_without_streaming(response_guard, deadline:) if retry_without_streaming?(raw_response)
41
+ build_response(raw_response)
43
42
  end
44
43
 
45
44
  private
46
45
 
47
46
  def request_deadline
48
- monotonic_now + ctx.policy.total_timeout_seconds
49
- end
50
-
51
- def perform_request(deadline:)
52
- response_guard = ResponseGuard.new(policy: ctx.policy)
53
- response = faraday_request(response_guard, deadline:, streaming_buffer: true)
54
- response = retry_without_streaming(response_guard, deadline:) if retry_without_streaming?(response)
55
- [response_guard, response]
47
+ monotonic_now + ctx.budget.effective_timeout_seconds(fallback: ctx.policy.total_timeout_seconds)
56
48
  end
57
49
 
58
50
  def build_response(response)
@@ -60,13 +52,8 @@ module Html2rss
60
52
  status: response.status)
61
53
  end
62
54
 
63
- def validate_request!(consume_budget: true)
64
- ctx.budget.consume! if consume_budget
65
- ctx.policy.validate_request!(url: ctx.url, origin_url: ctx.origin_url, relation: ctx.relation)
66
- end
67
-
68
55
  def faraday_request(response_guard, deadline:, streaming_buffer:, consume_budget: true)
69
- validate_request!(consume_budget:)
56
+ preflight!(consume_budget:)
70
57
 
71
58
  client.get do |req|
72
59
  apply_timeouts(req, deadline:)
@@ -3,15 +3,23 @@
3
3
  module Html2rss
4
4
  class RequestService
5
5
  ##
6
- # Strategy to read a local HTML file.
6
+ # Strategy to read a local HTML file (CLI `--input`).
7
+ #
8
+ # Intentionally skips remote budget/policy preflight and ResponseGuard
9
+ # postflight: this adapter never opens a network connection. SSRF controls
10
+ # remain on remote strategies; do not use this adapter for remote URLs.
7
11
  class LocalFileStrategy < Strategy
12
+ private
13
+
14
+ def skip_preflight? = true
15
+
16
+ def skip_postflight? = true
17
+
8
18
  ##
9
- # Executes the local file read.
10
- #
11
19
  # @return [Response] the mock response wrapped around the file contents
12
20
  # @raise [ArgumentError] if the local file path is missing
13
21
  # @raise [Errno::ENOENT] if the file does not exist
14
- def execute
22
+ def fetch
15
23
  file_path = ctx.request[:local_file_path]
16
24
  raise ArgumentError, 'Local file path is required for local_file strategy' unless file_path
17
25
  raise Errno::ENOENT, "File not found: #{file_path}" unless File.exist?(file_path)
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ipaddr'
4
+ require 'resolv'
5
+ require 'socket'
6
+
7
+ module Html2rss
8
+ class RequestService
9
+ ##
10
+ # Enforces public-network reachability for request targets and remote IPs.
11
+ #
12
+ # Owns DNS resolution, host denylist, and blocked IP ranges. Callers go through
13
+ # {Policy} so SSRF/origin rules stay single-homed on the public façade.
14
+ class NetworkGuard
15
+ # Hostnames treated as local/private surfaces.
16
+ LOCAL_HOSTS = %w[localhost localhost.localdomain metadata.google.internal].to_set.freeze
17
+ # IP ranges blocked when private networks are disabled.
18
+ BLOCKED_IP_RANGES = [
19
+ IPAddr.new('0.0.0.0/8'),
20
+ IPAddr.new('10.0.0.0/8'),
21
+ IPAddr.new('127.0.0.0/8'),
22
+ IPAddr.new('169.254.0.0/16'),
23
+ IPAddr.new('172.16.0.0/12'),
24
+ IPAddr.new('192.168.0.0/16'),
25
+ IPAddr.new('224.0.0.0/4'),
26
+ IPAddr.new('::/128'),
27
+ IPAddr.new('::1/128'),
28
+ IPAddr.new('fe80::/10'),
29
+ IPAddr.new('fc00::/7'),
30
+ IPAddr.new('ff00::/8')
31
+ ].freeze
32
+
33
+ ##
34
+ # @param allow_private_networks [Boolean] whether private network targets are allowed
35
+ # @param resolver [#each_address, #getaddrinfo] DNS resolver used for hostname classification
36
+ def initialize(allow_private_networks:, resolver: Socket)
37
+ @allow_private_networks = allow_private_networks ? true : false
38
+ @resolver = resolver
39
+ freeze
40
+ end
41
+
42
+ ##
43
+ # Rejects URLs whose host is denylisted or resolves to a blocked address.
44
+ #
45
+ # @param url [Html2rss::Url] destination URL
46
+ # @return [void]
47
+ # @raise [PrivateNetworkDenied] if the target resolves to a private address
48
+ def enforce_public_network!(url)
49
+ host = url.host
50
+ return if allow_private_networks?
51
+ return unless blocked_host?(host) || resolved_ip_addresses(host).any? { |address| blocked_ip?(address) }
52
+
53
+ raise PrivateNetworkDenied, "Private network target denied for #{url}"
54
+ end
55
+
56
+ ##
57
+ # Validates the resolved remote IP for a completed request.
58
+ #
59
+ # @param ip [String, nil] remote IP address reported by the client
60
+ # @param url [Html2rss::Url] URL associated with the response
61
+ # @return [void]
62
+ # @raise [PrivateNetworkDenied] if the response came from a blocked address
63
+ def validate_remote_ip!(ip:, url:)
64
+ return if allow_private_networks?
65
+ return if ip.nil? || ip.empty?
66
+
67
+ parsed_ip = parse_ip(ip)
68
+ raise PrivateNetworkDenied, "Remote IP could not be validated for #{url}" unless parsed_ip
69
+ return unless blocked_ip?(parsed_ip)
70
+
71
+ raise PrivateNetworkDenied, "Private network target denied for #{url}"
72
+ end
73
+
74
+ private
75
+
76
+ attr_reader :resolver
77
+
78
+ def allow_private_networks?
79
+ @allow_private_networks
80
+ end
81
+
82
+ def blocked_host?(host)
83
+ LOCAL_HOSTS.include?(host.to_s.downcase)
84
+ end
85
+
86
+ def resolved_ip_addresses(host)
87
+ literal = parse_ip(host)
88
+ return [literal] if literal
89
+
90
+ if resolver.respond_to?(:each_address)
91
+ addresses_from_each_address(host)
92
+ else
93
+ addresses_from_getaddrinfo(host)
94
+ end
95
+ rescue Resolv::ResolvError, SocketError, SystemCallError
96
+ []
97
+ end
98
+
99
+ def addresses_from_each_address(host)
100
+ [].tap do |addresses|
101
+ resolver.each_address(host) do |address|
102
+ parsed = parse_ip(address)
103
+ addresses << parsed if parsed
104
+ end
105
+ end
106
+ end
107
+
108
+ def addresses_from_getaddrinfo(host)
109
+ resolver.getaddrinfo(host, nil).filter_map do |entry|
110
+ parse_ip(entry[3])
111
+ end
112
+ end
113
+
114
+ def parse_ip(value)
115
+ IPAddr.new(value)
116
+ rescue IPAddr::AddressFamilyError, IPAddr::InvalidAddressError
117
+ nil
118
+ end
119
+
120
+ def blocked_ip?(address)
121
+ BLOCKED_IP_RANGES.any? { |range| range.include?(address) }
122
+ end
123
+ end
124
+ end
125
+ end