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
@@ -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
  ##
@@ -44,10 +82,51 @@ module Html2rss
44
82
  def remaining_timeout_seconds
45
83
  return unless @total_timeout_seconds
46
84
 
47
- elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - @start_time
48
- remaining = @total_timeout_seconds - elapsed
85
+ remaining = @total_timeout_seconds - elapsed_seconds
49
86
  [remaining, 0.0].max
50
87
  end
88
+
89
+ ##
90
+ # @return [Float] seconds since this budget was created
91
+ def elapsed_seconds
92
+ Process.clock_gettime(Process::CLOCK_MONOTONIC) - @start_time
93
+ end
94
+
95
+ ##
96
+ # Resolves wall-clock seconds for the next adapter attempt.
97
+ #
98
+ # Prefers the tracked deadline remainder; falls back to the policy total when
99
+ # this Budget was constructed without a timeout (common in unit tests).
100
+ #
101
+ # @param fallback [Numeric] policy total timeout when remaining is untracked
102
+ # @return [Float] seconds available for this attempt
103
+ # @raise [RequestTimedOut] when no time remains
104
+ def effective_timeout_seconds(fallback:)
105
+ timeout = remaining_timeout_seconds || fallback
106
+ raise RequestTimedOut, 'Request timed out' if timeout <= 0
107
+
108
+ timeout.to_f
109
+ end
110
+
111
+ ##
112
+ # @param fallback [Numeric] policy total timeout when remaining is untracked
113
+ # @return [Integer] milliseconds available for this attempt
114
+ # @raise [RequestTimedOut] when no time remains
115
+ def effective_timeout_ms(fallback:)
116
+ (effective_timeout_seconds(fallback:) * 1000).to_i
117
+ end
118
+
119
+ private
120
+
121
+ def validate_slot_limits!(max_requests:, max_interactions:)
122
+ unless max_requests.is_a?(Integer) && max_requests.positive?
123
+ raise ArgumentError,
124
+ 'max_requests must be positive'
125
+ end
126
+ return if max_interactions.is_a?(Integer) && !max_interactions.negative?
127
+
128
+ raise ArgumentError, 'max_interactions must be a non-negative integer'
129
+ end
51
130
  end
52
131
  end
53
132
  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
@@ -1,32 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'ipaddr'
4
- require 'resolv'
5
3
  require 'socket'
6
4
 
7
5
  module Html2rss
8
6
  class RequestService
9
7
  ##
10
- # Describes the runtime request envelope for a single feed build.
11
- class Policy # rubocop:disable Metrics/ClassLength
8
+ # Enforcer for the runtime request envelope of a single feed build.
9
+ #
10
+ # Public façade for request limits, same-origin/follow-up rules, and
11
+ # network reachability. DNS/host/IP enforcement lives on {NetworkGuard}.
12
+ class Policy
13
+ # Hard ceiling for configured max_requests during a feed build.
12
14
  MAX_REQUESTS_CEILING = 10
13
- # Hostnames treated as local/private surfaces.
14
- LOCAL_HOSTS = %w[localhost localhost.localdomain metadata.google.internal].to_set.freeze
15
- # IP ranges blocked when private networks are disabled.
16
- BLOCKED_IP_RANGES = [
17
- IPAddr.new('0.0.0.0/8'),
18
- IPAddr.new('10.0.0.0/8'),
19
- IPAddr.new('127.0.0.0/8'),
20
- IPAddr.new('169.254.0.0/16'),
21
- IPAddr.new('172.16.0.0/12'),
22
- IPAddr.new('192.168.0.0/16'),
23
- IPAddr.new('224.0.0.0/4'),
24
- IPAddr.new('::/128'),
25
- IPAddr.new('::1/128'),
26
- IPAddr.new('fe80::/10'),
27
- IPAddr.new('fc00::/7'),
28
- IPAddr.new('ff00::/8')
29
- ].freeze
30
15
 
31
16
  # Default policy values used when request controls are not explicitly set.
32
17
  DEFAULTS = {
@@ -71,7 +56,7 @@ module Html2rss
71
56
  @max_requests = [validate_positive_integer!(:max_requests, max_requests), MAX_REQUESTS_CEILING].min
72
57
  @allow_private_networks = allow_private_networks ? true : false
73
58
  @allow_cross_origin_followups = allow_cross_origin_followups ? true : false
74
- @resolver = resolver
59
+ @network_guard = NetworkGuard.new(allow_private_networks: @allow_private_networks, resolver:)
75
60
  freeze
76
61
  end
77
62
 
@@ -116,7 +101,7 @@ module Html2rss
116
101
  # @raise [PrivateNetworkDenied] if the target resolves to a private address
117
102
  def validate_request!(url:, origin_url:, relation:)
118
103
  enforce_same_origin!(url, origin_url, relation)
119
- enforce_public_network!(url)
104
+ network_guard.enforce_public_network!(url)
120
105
  end
121
106
 
122
107
  ##
@@ -144,19 +129,12 @@ module Html2rss
144
129
  # @return [void]
145
130
  # @raise [PrivateNetworkDenied] if the response came from a blocked address
146
131
  def validate_remote_ip!(ip:, url:)
147
- return if allow_private_networks?
148
- return if ip.nil? || ip.empty?
149
-
150
- parsed_ip = parse_ip(ip)
151
- raise PrivateNetworkDenied, "Remote IP could not be validated for #{url}" unless parsed_ip
152
- return unless blocked_ip?(parsed_ip)
153
-
154
- raise PrivateNetworkDenied, "Private network target denied for #{url}"
132
+ network_guard.validate_remote_ip!(ip:, url:)
155
133
  end
156
134
 
157
135
  private
158
136
 
159
- attr_reader :resolver
137
+ attr_reader :network_guard
160
138
 
161
139
  def validate_positive_integer!(name, value)
162
140
  raise ArgumentError, "#{name} must be positive" unless value.is_a?(Integer) && value.positive?
@@ -194,56 +172,6 @@ module Html2rss
194
172
 
195
173
  url.scheme == 'https' ? 443 : 80
196
174
  end
197
-
198
- def enforce_public_network!(url)
199
- host = url.host
200
- return if allow_private_networks?
201
- return unless blocked_host?(host) || resolved_ip_addresses(host).any? { |address| blocked_ip?(address) }
202
-
203
- raise PrivateNetworkDenied, "Private network target denied for #{url}"
204
- end
205
-
206
- def blocked_host?(host)
207
- LOCAL_HOSTS.include?(host.to_s.downcase)
208
- end
209
-
210
- def resolved_ip_addresses(host)
211
- literal = parse_ip(host)
212
- return [literal] if literal
213
-
214
- if resolver.respond_to?(:each_address)
215
- addresses_from_each_address(host)
216
- else
217
- addresses_from_getaddrinfo(host)
218
- end
219
- rescue Resolv::ResolvError, SocketError, SystemCallError
220
- []
221
- end
222
-
223
- def addresses_from_each_address(host)
224
- [].tap do |addresses|
225
- resolver.each_address(host) do |address|
226
- parsed = parse_ip(address)
227
- addresses << parsed if parsed
228
- end
229
- end
230
- end
231
-
232
- def addresses_from_getaddrinfo(host)
233
- resolver.getaddrinfo(host, nil).filter_map do |entry|
234
- parse_ip(entry[3])
235
- end
236
- end
237
-
238
- def parse_ip(value)
239
- IPAddr.new(value)
240
- rescue IPAddr::AddressFamilyError, IPAddr::InvalidAddressError
241
- nil
242
- end
243
-
244
- def blocked_ip?(address)
245
- BLOCKED_IP_RANGES.any? { |range| range.include?(address) }
246
- end
247
175
  end
248
176
 
249
177
  # Shared immutable policy instance used for default request execution.
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ class RequestService
5
+ class PuppetCommander
6
+ ##
7
+ # Owns Puppeteer request interception, deferred navigation errors, main-frame
8
+ # filtering, and redirect-chain validation via {Policy}.
9
+ #
10
+ # Does not re-own Policy rules — only calls +validate_*!+ on +ctx.policy+.
11
+ class NavigationGuards
12
+ class << self
13
+ ##
14
+ # Resolves the final response URL for Response building and IP validation.
15
+ #
16
+ # @param navigation_response [Puppeteer::HTTPResponse, nil]
17
+ # @param fallback_url [String, Html2rss::Url]
18
+ # @return [Html2rss::Url]
19
+ def response_url(navigation_response, fallback_url)
20
+ raw_url = navigation_response&.url || fallback_url.to_s
21
+ Html2rss::Url.from_absolute(raw_url)
22
+ end
23
+ end
24
+
25
+ ##
26
+ # @param ctx [Context] request context providing policy and origin
27
+ # @param skip_request_resources [Set<String>] resource types to abort
28
+ def initialize(ctx:, skip_request_resources:)
29
+ @ctx = ctx
30
+ @skip_request_resources = skip_request_resources
31
+ @navigation_error = nil
32
+ @latest_navigation_response = nil
33
+ @main_frame = nil
34
+ end
35
+
36
+ ##
37
+ # Captures the main frame and wires request/response interceptors.
38
+ #
39
+ # @param page [Puppeteer::Page] browser page
40
+ # @return [void]
41
+ def install!(page)
42
+ @main_frame = page.main_frame if page.respond_to?(:main_frame)
43
+ page.request_interception = true
44
+ page.on('request') { |request| handle_request(request) }
45
+ page.on('response') { |response| handle_response(response) }
46
+ end
47
+
48
+ ##
49
+ # Clears deferred error and latest navigation response before goto.
50
+ #
51
+ # @return [void]
52
+ def begin_navigation!
53
+ @navigation_error = nil
54
+ @latest_navigation_response = nil
55
+ end
56
+
57
+ ##
58
+ # Re-raises a deferred navigation error when one was captured.
59
+ #
60
+ # @return [void]
61
+ # @raise [Html2rss::Error] when a navigation request or response validation failed
62
+ def raise_deferred_error!
63
+ raise @navigation_error if @navigation_error
64
+ end
65
+
66
+ ##
67
+ # @return [Puppeteer::HTTPResponse, nil] latest main-frame navigation response
68
+ attr_reader :latest_navigation_response
69
+
70
+ ##
71
+ # Validates the remote IP of a navigation response via Policy.
72
+ #
73
+ # @param navigation_response [Puppeteer::HTTPResponse, nil]
74
+ # @return [void]
75
+ def validate_final!(navigation_response)
76
+ final_url = self.class.response_url(navigation_response, ctx.url)
77
+ ctx.policy.validate_remote_ip!(ip: remote_ip(navigation_response), url: final_url)
78
+ end
79
+
80
+ private
81
+
82
+ attr_reader :ctx, :skip_request_resources, :main_frame
83
+
84
+ def handle_request(request)
85
+ validate_request!(request)
86
+
87
+ skip_request_resources.member?(request.resource_type) ? request.abort : request.continue
88
+ rescue Html2rss::Error => error
89
+ store_navigation_error(error, navigation_request: request.navigation_request?)
90
+ request.abort
91
+ end
92
+
93
+ def handle_response(response)
94
+ @latest_navigation_response = response if main_frame_navigation_response?(response)
95
+ validate_final!(response)
96
+ rescue Html2rss::Error => error
97
+ store_navigation_error(error, navigation_request: response.request.navigation_request?)
98
+ end
99
+
100
+ def validate_request!(request)
101
+ validate_navigation_redirect_chain!(request)
102
+ validate_navigation_target!(request)
103
+ end
104
+
105
+ def main_frame_navigation_response?(response)
106
+ request = response.request
107
+ return false unless request.navigation_request?
108
+ return true unless request.respond_to?(:frame)
109
+
110
+ frame = request.frame
111
+ return true if frame.nil?
112
+ return frame == main_frame unless main_frame.nil?
113
+ return true unless frame.respond_to?(:parent_frame)
114
+
115
+ frame.parent_frame.nil?
116
+ end
117
+
118
+ def remote_ip(navigation_response)
119
+ navigation_response.remote_address&.ip
120
+ end
121
+
122
+ def request_chain(request)
123
+ (request.redirect_chain + [request]).map { |entry| request_url(entry) }
124
+ end
125
+
126
+ def request_url(request)
127
+ Html2rss::Url.from_absolute(request.url)
128
+ end
129
+
130
+ def validate_navigation_redirect_chain!(request)
131
+ request_chain(request).each_cons(2) do |from_url, to_url|
132
+ ctx.policy.validate_redirect!(from_url:, to_url:, origin_url: ctx.origin_url, relation: ctx.relation)
133
+ end
134
+ end
135
+
136
+ def validate_navigation_target!(request)
137
+ ctx.policy.validate_request!(url: request_url(request), origin_url: ctx.origin_url, relation: ctx.relation)
138
+ end
139
+
140
+ def store_navigation_error(error, navigation_request:)
141
+ return unless navigation_request
142
+
143
+ @navigation_error = error if @navigation_error.nil?
144
+ end
145
+ end
146
+ end
147
+ end
148
+ end