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
@@ -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,44 +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
- check_timeout!
20
- validate_request!
11
+ private
12
+
13
+ def fetch
21
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
20
  def post_scrape_request
34
21
  transport_response = client.post('/scrape', JSON.generate(contract.request_payload), content_type_header)
35
22
  contract.parse_response(transport_response)
36
23
  end
37
24
 
38
- def validate_request!
39
- ctx.budget.consume!
40
- ctx.policy.validate_request!(url: ctx.url, origin_url: ctx.origin_url, relation: ctx.relation)
41
- end
42
-
43
25
  def build_response(parsed_response)
44
- body = parsed_response.html
45
- ResponseGuard.new(policy: ctx.policy).inspect_body!(body)
46
-
47
26
  Response.new(
48
- body:,
27
+ body: parsed_response.html,
49
28
  headers: parsed_response.headers,
50
29
  url: response_url(parsed_response.final_url),
51
30
  status: parsed_response.status
@@ -81,8 +60,7 @@ module Html2rss
81
60
  end
82
61
 
83
62
  def request_options
84
- timeout = ctx.budget.remaining_timeout_seconds || ctx.policy.total_timeout_seconds
85
- raise RequestTimedOut, 'Request timed out' if timeout <= 0
63
+ timeout = ctx.budget.effective_timeout_seconds(fallback: ctx.policy.total_timeout_seconds)
86
64
 
87
65
  { timeout: timeout.to_i }
88
66
  end
@@ -101,6 +79,10 @@ module Html2rss
101
79
  raise BotasaurusConfigurationError, "BOTASAURUS_SCRAPER_URL is invalid: #{error.message}"
102
80
  end
103
81
  end
82
+
83
+ def translate_connection_error(error)
84
+ raise BotasaurusConnectionFailed, "Botasaurus connection failed: #{error.message}"
85
+ end
104
86
  end
105
87
  end
106
88
  end
@@ -31,19 +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
- check_timeout!
41
- validate_request!
42
- execute_browserless_request
43
- rescue Puppeteer::TimeoutError => error
44
- raise RequestTimedOut, error.message
45
- end
46
-
47
34
  ##
48
35
  # @return [String] the Browserless websocket endpoint with token query param
49
36
  # @raise [ArgumentError] if a custom endpoint is configured without an API token
@@ -58,12 +45,7 @@ module Html2rss
58
45
 
59
46
  private
60
47
 
61
- def validate_request!
62
- ctx.budget.consume!
63
- ctx.policy.validate_request!(url: ctx.url, origin_url: ctx.origin_url, relation: ctx.relation)
64
- end
65
-
66
- def execute_browserless_request
48
+ def fetch
67
49
  connect_with_timeout_support do |browser|
68
50
  PuppetCommander.new(ctx, browser).call
69
51
  ensure
@@ -72,10 +54,7 @@ module Html2rss
72
54
  end
73
55
 
74
56
  def protocol_timeout_ms
75
- timeout = ctx.budget.remaining_timeout_seconds || ctx.policy.total_timeout_seconds
76
- raise RequestTimedOut, 'Request timed out' if timeout <= 0
77
-
78
- (timeout * 1000).to_i
57
+ ctx.budget.effective_timeout_ms(fallback: ctx.policy.total_timeout_seconds)
79
58
  end
80
59
 
81
60
  def connect_with_timeout_support(&)
@@ -3,40 +3,78 @@
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
- ##
10
- # @param max_requests [Integer] the maximum number of requests allowed
11
- # @param total_timeout_seconds [Integer, nil] the total timeout for the feed build
12
- def initialize(max_requests:, total_timeout_seconds: nil)
13
- unless max_requests.is_a?(Integer) && max_requests.positive?
14
- raise ArgumentError, 'max_requests must be positive'
15
- 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:)
16
17
 
17
- @remaining = max_requests
18
+ @remaining_requests = max_requests
19
+ @remaining_interactions = max_interactions
18
20
  @start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
19
21
  @total_timeout_seconds = total_timeout_seconds
20
22
  @mutex = Mutex.new
21
23
  end
22
24
 
23
25
  ##
24
- # Consumes one request from the budget.
26
+ # Consumes one HTTP request slot.
25
27
  #
26
- # @return [Integer] remaining request count after consumption
27
- # @raise [RequestBudgetExceeded] if no requests remain
28
+ # @return [Integer] remaining request slots after consumption
29
+ # @raise [RequestBudgetExceeded] if no request slots remain
28
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!
29
55
  @mutex.synchronize do
30
- raise RequestBudgetExceeded, 'Request budget exhausted' if @remaining.zero?
56
+ raise InteractionBudgetExceeded, 'Interaction budget exhausted' if @remaining_interactions.zero?
31
57
 
32
- @remaining -= 1
58
+ @remaining_interactions -= 1
33
59
  end
34
60
  end
35
61
 
36
62
  ##
37
- # @return [Integer] requests still available
63
+ # @return [Integer] HTTP request slots still available
38
64
  def remaining
39
- @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 }
40
78
  end
41
79
 
42
80
  ##
@@ -48,6 +86,42 @@ module Html2rss
48
86
  remaining = @total_timeout_seconds - elapsed
49
87
  [remaining, 0.0].max
50
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'
124
+ end
51
125
  end
52
126
  end
53
127
  end
@@ -77,9 +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) do
81
- Budget.new(max_requests: policy.max_requests, total_timeout_seconds: policy.total_timeout_seconds)
82
- end
80
+ @budget = request_options.fetch(:budget) { raise ArgumentError, 'budget is required' }
83
81
  raise ArgumentError, 'budget must not be nil' if @budget.nil?
84
82
  end
85
83
 
@@ -27,33 +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
37
- check_timeout!
35
+ # @return [Response] normalized response
36
+ def perform_execute
38
37
  deadline = request_deadline
39
- response_guard, response = perform_request(deadline:)
40
- response_guard.inspect_body!(response.body)
41
- build_response(response)
42
- rescue Faraday::TimeoutError, Timeout::Error => error
43
- 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)
44
42
  end
45
43
 
46
44
  private
47
45
 
48
46
  def request_deadline
49
- monotonic_now + (ctx.budget.remaining_timeout_seconds || ctx.policy.total_timeout_seconds)
50
- end
51
-
52
- def perform_request(deadline:)
53
- response_guard = ResponseGuard.new(policy: ctx.policy)
54
- response = faraday_request(response_guard, deadline:, streaming_buffer: true)
55
- response = retry_without_streaming(response_guard, deadline:) if retry_without_streaming?(response)
56
- [response_guard, response]
47
+ monotonic_now + ctx.budget.effective_timeout_seconds(fallback: ctx.policy.total_timeout_seconds)
57
48
  end
58
49
 
59
50
  def build_response(response)
@@ -61,13 +52,8 @@ module Html2rss
61
52
  status: response.status)
62
53
  end
63
54
 
64
- def validate_request!(consume_budget: true)
65
- ctx.budget.consume! if consume_budget
66
- ctx.policy.validate_request!(url: ctx.url, origin_url: ctx.origin_url, relation: ctx.relation)
67
- end
68
-
69
55
  def faraday_request(response_guard, deadline:, streaming_buffer:, consume_budget: true)
70
- validate_request!(consume_budget:)
56
+ preflight!(consume_budget:)
71
57
 
72
58
  client.get do |req|
73
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