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.
- checksums.yaml +4 -4
- data/README.md +11 -108
- data/lib/html2rss/{articles → article}/deduplicator.rb +3 -4
- data/lib/html2rss/{rss_builder → article}/enclosure.rb +6 -22
- data/lib/html2rss/article.rb +206 -0
- data/lib/html2rss/auto_source/discovery/dom_clustering/group_scorer.rb +80 -0
- data/lib/html2rss/auto_source/discovery/dom_clustering/overlap_resolver.rb +86 -0
- data/lib/html2rss/auto_source/discovery/dom_clustering.rb +119 -0
- data/lib/html2rss/auto_source/discovery/list_candidates.rb +94 -0
- data/lib/html2rss/auto_source/discovery/semantic_anchor_candidates.rb +219 -0
- data/lib/html2rss/auto_source/discovery/semantic_containers.rb +71 -0
- data/lib/html2rss/auto_source/discovery/sitemap.rb +159 -0
- data/lib/html2rss/auto_source/discovery.rb +14 -0
- data/lib/html2rss/auto_source/link_heuristics/anchor_signals.rb +28 -0
- data/lib/html2rss/auto_source/link_heuristics/container_assessor.rb +106 -0
- data/lib/html2rss/auto_source/link_heuristics/container_signals.rb +80 -0
- data/lib/html2rss/auto_source/link_heuristics/destination_facts.rb +42 -0
- data/lib/html2rss/auto_source/link_heuristics/href_extractor.rb +38 -0
- data/lib/html2rss/auto_source/link_heuristics/path_classifier.rb +221 -0
- data/lib/html2rss/auto_source/link_heuristics/text_classifier.rb +66 -0
- data/lib/html2rss/auto_source/link_heuristics.rb +139 -0
- data/lib/html2rss/auto_source/scraper/html.rb +22 -33
- data/lib/html2rss/auto_source/scraper/json_state.rb +1 -1
- data/lib/html2rss/auto_source/scraper/meta_oembed.rb +163 -0
- data/lib/html2rss/auto_source/scraper/microformats2.rb +146 -0
- data/lib/html2rss/auto_source/scraper/schema/thing.rb +21 -2
- data/lib/html2rss/auto_source/scraper/semantic_html/{deduplicator.rb → entry_deduplicator.rb} +3 -3
- data/lib/html2rss/auto_source/scraper/semantic_html.rb +23 -171
- data/lib/html2rss/auto_source/scraper/sitemap.rb +153 -0
- data/lib/html2rss/auto_source/scraper/wordpress_api/page_scope/date_archive_range.rb +110 -0
- data/lib/html2rss/auto_source/scraper/wordpress_api/page_scope.rb +2 -78
- data/lib/html2rss/auto_source/scraper/wordpress_api.rb +9 -0
- data/lib/html2rss/auto_source/scraper.rb +10 -3
- data/lib/html2rss/auto_source.rb +30 -35
- data/lib/html2rss/channel.rb +118 -0
- data/lib/html2rss/cli.rb +5 -6
- data/lib/html2rss/config/auto_source_contract.rb +51 -0
- data/lib/html2rss/config/request_controls.rb +136 -0
- data/lib/html2rss/config/request_headers.rb +1 -1
- data/lib/html2rss/config/schema.rb +65 -114
- data/lib/html2rss/{selectors/config.rb → config/selectors_validator.rb} +56 -7
- data/lib/html2rss/config/validator.rb +6 -6
- data/lib/html2rss/config.rb +176 -19
- data/lib/html2rss/{configuration.rb → defaults.rb} +12 -10
- data/lib/html2rss/feed_builder/json_feed/item.rb +96 -0
- data/lib/html2rss/feed_builder/json_feed.rb +61 -0
- data/lib/html2rss/feed_builder/rss/stylesheet.rb +65 -0
- data/lib/html2rss/feed_builder/rss.rb +116 -0
- data/lib/html2rss/feed_builder.rb +23 -0
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +59 -42
- data/lib/html2rss/feed_pipeline/runtime_policy.rb +108 -0
- data/lib/html2rss/feed_pipeline/strategy_plan.rb +64 -0
- data/lib/html2rss/feed_pipeline.rb +52 -65
- data/lib/html2rss/html/article_extractor/category_extractor.rb +123 -0
- data/lib/html2rss/html/article_extractor/date_extractor.rb +22 -0
- data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +95 -0
- data/lib/html2rss/html/article_extractor/heading_extractor.rb +54 -0
- data/lib/html2rss/html/article_extractor/id_generator.rb +69 -0
- data/lib/html2rss/html/article_extractor/image_extractor.rb +60 -0
- data/lib/html2rss/html/article_extractor.rb +134 -0
- data/lib/html2rss/html/navigator/text_extractor.rb +79 -0
- data/lib/html2rss/html/navigator.rb +146 -0
- data/lib/html2rss/html/rendering/audio_renderer.rb +38 -0
- data/lib/html2rss/html/rendering/description_builder.rb +89 -0
- data/lib/html2rss/html/rendering/image_renderer.rb +43 -0
- data/lib/html2rss/html/rendering/media_renderer.rb +39 -0
- data/lib/html2rss/html/rendering/pdf_renderer.rb +36 -0
- data/lib/html2rss/html/rendering/video_renderer.rb +38 -0
- data/lib/html2rss/html/rendering.rb +25 -0
- data/lib/html2rss/html.rb +8 -0
- data/lib/html2rss/request_service/blocked_surface.rb +67 -0
- data/lib/html2rss/request_service/botasaurus_contract.rb +0 -4
- data/lib/html2rss/request_service/botasaurus_strategy.rb +9 -27
- data/lib/html2rss/request_service/browserless_strategy.rb +2 -23
- data/lib/html2rss/request_service/budget.rb +90 -16
- data/lib/html2rss/request_service/context.rb +1 -3
- data/lib/html2rss/request_service/faraday_strategy.rb +12 -26
- data/lib/html2rss/request_service/local_file_strategy.rb +12 -4
- data/lib/html2rss/request_service/network_guard.rb +125 -0
- data/lib/html2rss/request_service/policy.rb +10 -82
- data/lib/html2rss/request_service/puppet_commander/navigation_guards.rb +148 -0
- data/lib/html2rss/request_service/puppet_commander/preload_runner.rb +86 -0
- data/lib/html2rss/request_service/puppet_commander.rb +22 -191
- data/lib/html2rss/request_service/response_guard.rb +1 -1
- data/lib/html2rss/request_service/strategy.rb +102 -6
- data/lib/html2rss/request_service.rb +7 -2
- data/lib/html2rss/request_session/pager/base.rb +83 -0
- data/lib/html2rss/request_session/pager/custom_selector.rb +39 -0
- data/lib/html2rss/request_session/pager/json_cursor.rb +78 -0
- data/lib/html2rss/request_session/pager/offset.rb +48 -0
- data/lib/html2rss/request_session/pager/rel_next.rb +23 -0
- data/lib/html2rss/request_session/pager/url_template.rb +48 -0
- data/lib/html2rss/request_session/pager.rb +75 -0
- data/lib/html2rss/request_session.rb +25 -16
- data/lib/html2rss/selectors/item_scope.rb +30 -0
- data/lib/html2rss/selectors/post_processors/base.rb +1 -1
- data/lib/html2rss/selectors/post_processors/markdown_to_html.rb +0 -1
- data/lib/html2rss/selectors/post_processors/sanitize_html.rb +0 -2
- data/lib/html2rss/selectors/post_processors/template.rb +7 -3
- data/lib/html2rss/selectors.rb +63 -65
- data/lib/html2rss/version.rb +1 -3
- data/lib/html2rss.rb +19 -19
- data/schema/html2rss-config.schema.json +109 -9
- metadata +68 -42
- data/lib/html2rss/auto_source/scraper/html/class_clustering.rb +0 -196
- data/lib/html2rss/auto_source/scraper/link_heuristics.rb +0 -401
- data/lib/html2rss/auto_source/scraper/semantic_html/anchor_selector.rb +0 -49
- data/lib/html2rss/blocked_surface.rb +0 -65
- data/lib/html2rss/category_extractor.rb +0 -116
- data/lib/html2rss/config/class_methods.rb +0 -183
- data/lib/html2rss/html_extractor/date_extractor.rb +0 -20
- data/lib/html2rss/html_extractor/enclosure_extractor.rb +0 -91
- data/lib/html2rss/html_extractor/heading_extractor.rb +0 -50
- data/lib/html2rss/html_extractor/id_generator.rb +0 -67
- data/lib/html2rss/html_extractor/image_extractor.rb +0 -58
- data/lib/html2rss/html_extractor/list_candidates.rb +0 -88
- data/lib/html2rss/html_extractor/semantic_anchor_candidates.rb +0 -260
- data/lib/html2rss/html_extractor/semantic_containers.rb +0 -69
- data/lib/html2rss/html_extractor/text_extractor.rb +0 -77
- data/lib/html2rss/html_extractor.rb +0 -190
- data/lib/html2rss/html_navigator.rb +0 -71
- data/lib/html2rss/json_feed_builder/item.rb +0 -94
- data/lib/html2rss/json_feed_builder.rb +0 -59
- data/lib/html2rss/rendering/audio_renderer.rb +0 -36
- data/lib/html2rss/rendering/description_builder.rb +0 -87
- data/lib/html2rss/rendering/image_renderer.rb +0 -41
- data/lib/html2rss/rendering/media_renderer.rb +0 -37
- data/lib/html2rss/rendering/pdf_renderer.rb +0 -34
- data/lib/html2rss/rendering/video_renderer.rb +0 -36
- data/lib/html2rss/rendering.rb +0 -23
- data/lib/html2rss/request_controls.rb +0 -133
- data/lib/html2rss/request_session/rel_next_pager.rb +0 -70
- data/lib/html2rss/request_session/runtime_input.rb +0 -71
- data/lib/html2rss/request_session/runtime_policy.rb +0 -84
- data/lib/html2rss/rss_builder/article.rb +0 -208
- data/lib/html2rss/rss_builder/channel.rb +0 -118
- data/lib/html2rss/rss_builder/stylesheet.rb +0 -63
- 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
|
|
@@ -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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
11
|
-
# @param total_timeout_seconds [Integer, nil]
|
|
12
|
-
def initialize(max_requests:, total_timeout_seconds: nil)
|
|
13
|
-
|
|
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
|
-
@
|
|
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
|
|
26
|
+
# Consumes one HTTP request slot.
|
|
25
27
|
#
|
|
26
|
-
# @return [Integer] remaining request
|
|
27
|
-
# @raise [RequestBudgetExceeded] if no
|
|
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
|
|
56
|
+
raise InteractionBudgetExceeded, 'Interaction budget exhausted' if @remaining_interactions.zero?
|
|
31
57
|
|
|
32
|
-
@
|
|
58
|
+
@remaining_interactions -= 1
|
|
33
59
|
end
|
|
34
60
|
end
|
|
35
61
|
|
|
36
62
|
##
|
|
37
|
-
# @return [Integer]
|
|
63
|
+
# @return [Integer] HTTP request slots still available
|
|
38
64
|
def remaining
|
|
39
|
-
|
|
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)
|
|
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
|
-
#
|
|
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
|
|
33
|
-
|
|
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
|
|
40
|
-
response_guard
|
|
41
|
-
|
|
42
|
-
|
|
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 +
|
|
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
|
-
|
|
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
|
|
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
|