html2rss 0.29.1 → 0.31.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 +8 -8
- data/html2rss.gemspec +1 -3
- data/lib/html2rss/article/deduplicator.rb +2 -4
- data/lib/html2rss/article.rb +16 -39
- data/lib/html2rss/auto_source/README.md +1 -1
- data/lib/html2rss/auto_source/cleanup.rb +15 -6
- data/lib/html2rss/auto_source/scraper/html.rb +5 -0
- data/lib/html2rss/auto_source/scraper/json_state.rb +5 -0
- data/lib/html2rss/auto_source/scraper/meta_oembed.rb +8 -1
- data/lib/html2rss/auto_source/scraper/microdata.rb +8 -0
- data/lib/html2rss/auto_source/scraper/microformats2.rb +8 -0
- data/lib/html2rss/auto_source/scraper/native_feed.rb +3 -0
- data/lib/html2rss/auto_source/scraper/schema/thing.rb +4 -5
- data/lib/html2rss/auto_source/scraper/schema.rb +8 -0
- data/lib/html2rss/auto_source/scraper/semantic_html.rb +5 -0
- data/lib/html2rss/auto_source/scraper/sitemap.rb +3 -0
- data/lib/html2rss/auto_source/scraper/wordpress_api.rb +3 -0
- data/lib/html2rss/auto_source/scraper.rb +4 -14
- data/lib/html2rss/auto_source/segment.rb +7 -4
- data/lib/html2rss/auto_source/segmenter/cluster.rb +22 -23
- data/lib/html2rss/auto_source/segmenter/list.rb +54 -31
- data/lib/html2rss/auto_source/segmenter.rb +5 -5
- data/lib/html2rss/auto_source.rb +2 -2
- data/lib/html2rss/batch.rb +6 -4
- data/lib/html2rss/capture/README.md +7 -5
- data/lib/html2rss/capture.rb +68 -36
- data/lib/html2rss/channel.rb +3 -3
- data/lib/html2rss/cli/probe_view.rb +5 -3
- data/lib/html2rss/cli/render.rb +6 -5
- data/lib/html2rss/cli/validate.rb +28 -27
- data/lib/html2rss/cli.rb +1 -1
- data/lib/html2rss/config/issue_mapper.rb +108 -0
- data/lib/html2rss/config/request_headers.rb +6 -4
- data/lib/html2rss/config/schema.rb +16 -1
- data/lib/html2rss/config/selectors_validator.rb +92 -70
- data/lib/html2rss/config/validation_issue.rb +60 -0
- data/lib/html2rss/config/validation_report.rb +55 -0
- data/lib/html2rss/config/validator.rb +10 -6
- data/lib/html2rss/config.rb +35 -101
- data/lib/html2rss/defaults.rb +42 -30
- data/lib/html2rss/doctor/botasaurus.rb +19 -9
- data/lib/html2rss/doctor.rb +8 -0
- data/lib/html2rss/error.rb +1 -1
- data/lib/html2rss/feed_builder/rss.rb +9 -14
- data/lib/html2rss/feed_pipeline/README.md +8 -8
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +1 -1
- data/lib/html2rss/feed_pipeline/runtime_policy.rb +14 -10
- data/lib/html2rss/feed_pipeline/strategy_plan.rb +7 -3
- data/lib/html2rss/feed_resolution/candidate_generator.rb +1 -4
- data/lib/html2rss/feed_resolution/diag.rb +3 -1
- data/lib/html2rss/feed_resolution/options.rb +3 -1
- data/lib/html2rss/feed_resolution/policy.rb +2 -2
- data/lib/html2rss/html/article_extractor/date_extractor.rb +1 -1
- data/lib/html2rss/html/article_extractor.rb +2 -4
- data/lib/html2rss/html/feed_link.rb +3 -1
- data/lib/html2rss/html/navigator/text_extractor.rb +1 -1
- data/lib/html2rss/html/navigator.rb +10 -10
- data/lib/html2rss/html/rendering/description_builder.rb +1 -1
- data/lib/html2rss/html/sst_article_extractor.rb +8 -5
- data/lib/html2rss/link_destination/destination_facts.rb +4 -2
- data/lib/html2rss/link_destination/noise_policy.rb +10 -1
- data/lib/html2rss/link_destination/path_classifier.rb +45 -38
- data/lib/html2rss/mcp/README.md +5 -3
- data/lib/html2rss/mcp/config_argument.rb +3 -1
- data/lib/html2rss/mcp/contract.rb +15 -25
- data/lib/html2rss/mcp/outcome/playbook.rb +7 -7
- data/lib/html2rss/mcp/outcome.rb +36 -43
- data/lib/html2rss/mcp/server/tools.rb +112 -132
- data/lib/html2rss/mcp/server.rb +27 -19
- data/lib/html2rss/page_recon/README.md +5 -5
- data/lib/html2rss/page_recon/diagnostics.rb +71 -81
- data/lib/html2rss/page_recon.rb +9 -5
- data/lib/html2rss/recon.rb +20 -31
- data/lib/html2rss/request_service/botasaurus_contract.rb +11 -12
- data/lib/html2rss/request_service/botasaurus_strategy.rb +32 -28
- data/lib/html2rss/request_service/compressed_body.rb +17 -9
- data/lib/html2rss/request_service/httpx_strategy.rb +239 -0
- data/lib/html2rss/request_service/network_guard.rb +7 -5
- data/lib/html2rss/request_service/response.rb +1 -2
- data/lib/html2rss/request_service/response_guard.rb +0 -16
- data/lib/html2rss/request_service/strategy.rb +22 -11
- data/lib/html2rss/request_service.rb +46 -85
- data/lib/html2rss/scoring/container_assessor.rb +3 -1
- data/lib/html2rss/scoring/engine.rb +8 -8
- data/lib/html2rss/scoring/observation.rb +5 -3
- data/lib/html2rss/scoring/ranked_segment.rb +3 -1
- data/lib/html2rss/scrape_target.rb +3 -1
- data/lib/html2rss/selector_candidates.rb +234 -0
- data/lib/html2rss/selectors/README.md +64 -0
- data/lib/html2rss/selectors/extractors/attribute.rb +13 -15
- data/lib/html2rss/selectors/extractors/href.rb +15 -16
- data/lib/html2rss/selectors/extractors/html.rb +8 -9
- data/lib/html2rss/selectors/extractors/static.rb +12 -14
- data/lib/html2rss/selectors/extractors/text.rb +8 -9
- data/lib/html2rss/selectors/extractors.rb +33 -20
- data/lib/html2rss/selectors/item_env.rb +31 -0
- data/lib/html2rss/selectors/{object_to_xml_converter.rb → json_xml.rb} +11 -12
- data/lib/html2rss/selectors/option_spec.rb +140 -0
- data/lib/html2rss/selectors/post_processors/base.rb +79 -31
- data/lib/html2rss/selectors/post_processors/gsub.rb +103 -39
- data/lib/html2rss/selectors/post_processors/html_transformers/transform_urls_to_absolute_ones.rb +4 -4
- data/lib/html2rss/selectors/post_processors/markdown_to_html.rb +10 -11
- data/lib/html2rss/selectors/post_processors/parse_time.rb +50 -26
- data/lib/html2rss/selectors/post_processors/parse_uri.rb +11 -7
- data/lib/html2rss/selectors/post_processors/sanitize_html.rb +46 -32
- data/lib/html2rss/selectors/post_processors/substring.rb +14 -25
- data/lib/html2rss/selectors/post_processors/template.rb +23 -19
- data/lib/html2rss/selectors/post_processors.rb +6 -5
- data/lib/html2rss/selectors/{schema_doc.rb → schema_export.rb} +12 -31
- data/lib/html2rss/selectors/step_env.rb +24 -0
- data/lib/html2rss/selectors.rb +231 -142
- data/lib/html2rss/sst/attrs.rb +5 -3
- data/lib/html2rss/sst/document.rb +3 -1
- data/lib/html2rss/sst/index.rb +3 -1
- data/lib/html2rss/sst/node.rb +29 -10
- data/lib/html2rss/sst/normalizer.rb +12 -16
- data/lib/html2rss/sst/tags.rb +7 -7
- data/lib/html2rss/status.rb +13 -8
- data/lib/html2rss/surface_category.rb +14 -32
- data/lib/html2rss/syndication/parser.rb +2 -2
- data/lib/html2rss/test/enhance_audit.rb +1 -3
- data/lib/html2rss/test/policy.rb +106 -0
- data/lib/html2rss/test.rb +126 -172
- data/lib/html2rss/url.rb +25 -4
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss.rb +27 -19
- data/lib/tasks/config_schema.rake +2 -2
- data/schema/html2rss-config.schema.json +11 -8
- metadata +19 -44
- data/lib/html2rss/request_service/faraday_strategy.rb +0 -233
- data/lib/html2rss/selectors/item_scope.rb +0 -30
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'httpx'
|
|
4
|
+
require 'httpx/plugins/follow_redirects'
|
|
5
|
+
require 'httpx/plugins/callbacks'
|
|
6
|
+
require 'httpx/plugins/ssrf_filter'
|
|
7
|
+
require 'httpx/plugins/brotli'
|
|
8
|
+
require 'httpx/plugins/retries'
|
|
9
|
+
|
|
10
|
+
module Html2rss
|
|
11
|
+
class RequestService
|
|
12
|
+
##
|
|
13
|
+
# Strategy to use HTTPX for HTTP requests.
|
|
14
|
+
# Provides native HTTP/2 with ALPN, built-in SSRF protection, streaming byte limits,
|
|
15
|
+
# and redirect handling without monkey-patching.
|
|
16
|
+
# rubocop:disable-next Metrics/ClassLength -- terminal redirect retry colocated with HTTPX transport
|
|
17
|
+
class HttpxStrategy < Strategy
|
|
18
|
+
# HTTPX default; kept explicit so ping reconnects cannot omit a bound.
|
|
19
|
+
MAX_RETRIES = 3
|
|
20
|
+
# Forces the selector timer retry path. Unset retry_after recurses in fetch_response;
|
|
21
|
+
# combined with ping! on a reused closed selector that never decrements retries,
|
|
22
|
+
# that overflows the stack and kills Falcon workers.
|
|
23
|
+
RETRY_AFTER_SECONDS = 0.25
|
|
24
|
+
# Bounds passed to the retries plugin and every +session.with+.
|
|
25
|
+
# HTTPX retries stay inside one {RequestService::Budget} slot and the shared
|
|
26
|
+
# wall-clock deadline; do not consume a slot per attempt.
|
|
27
|
+
# Do not reload +:fiber_concurrency+: a reused closed selector made +ping!+
|
|
28
|
+
# skip decrementing retries and recurse until +SystemStackError+.
|
|
29
|
+
#
|
|
30
|
+
# @return [Hash{Symbol => Integer, Float}]
|
|
31
|
+
RETRY_OPTIONS = { max_retries: MAX_RETRIES, retry_after: RETRY_AFTER_SECONDS }.freeze
|
|
32
|
+
|
|
33
|
+
class << self
|
|
34
|
+
# rubocop:disable ThreadSafety/ClassInstanceVariable
|
|
35
|
+
# @return [HTTPX::Session]
|
|
36
|
+
def base_session
|
|
37
|
+
@base_session ||= HTTPX
|
|
38
|
+
.plugin(:follow_redirects)
|
|
39
|
+
.plugin(:callbacks)
|
|
40
|
+
.plugin(:brotli)
|
|
41
|
+
.plugin(:retries, **RETRY_OPTIONS)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# @return [HTTPX::Session]
|
|
45
|
+
def base_ssrf_session
|
|
46
|
+
@base_ssrf_session ||= base_session.plugin(:ssrf_filter)
|
|
47
|
+
end
|
|
48
|
+
# rubocop:enable ThreadSafety/ClassInstanceVariable
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Executes the request with runtime policy enforcement, returning the normalized response.
|
|
52
|
+
#
|
|
53
|
+
# @return [Response] normalized response
|
|
54
|
+
def perform_execute
|
|
55
|
+
deadline = request_deadline
|
|
56
|
+
reset_redirect_tracking!
|
|
57
|
+
raw_response = request_with_terminal_redirect_retry(deadline:)
|
|
58
|
+
build_response(raw_response)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def request_deadline
|
|
64
|
+
monotonic_now + ctx.budget.effective_timeout_seconds(fallback: ctx.policy.total_timeout_seconds)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def reset_redirect_tracking!
|
|
68
|
+
@last_redirect_to = nil
|
|
69
|
+
@terminal_redirect_retried = false
|
|
70
|
+
@request_url_override = nil
|
|
71
|
+
@current_url = request_url
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def request_with_terminal_redirect_retry(deadline:)
|
|
75
|
+
raw_response = execute_http_request(deadline:)
|
|
76
|
+
return raw_response unless redirect_limit_reached?(raw_response)
|
|
77
|
+
|
|
78
|
+
unless terminal_redirect_retryable?(raw_response)
|
|
79
|
+
raise RedirectLimitReached, "Too many redirects (status #{raw_response.status})"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
retry_from_terminal_redirect!(raw_response, deadline:)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def redirect_limit_reached?(response)
|
|
86
|
+
return false unless response.is_a?(HTTPX::Response)
|
|
87
|
+
return false if response.status == 304
|
|
88
|
+
return false unless (300..399).cover?(response.status)
|
|
89
|
+
|
|
90
|
+
location = response.headers['location']
|
|
91
|
+
!location.to_s.strip.empty?
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def terminal_redirect_url(raw_response)
|
|
95
|
+
location = raw_response.headers['location']
|
|
96
|
+
if location && !location.empty?
|
|
97
|
+
base = raw_response.uri || request_url
|
|
98
|
+
return normalize_url(URI.join(base.to_s, location))
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
@last_redirect_to
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def terminal_redirect_retryable?(raw_response)
|
|
105
|
+
return false if @terminal_redirect_retried
|
|
106
|
+
|
|
107
|
+
target = terminal_redirect_url(raw_response)
|
|
108
|
+
target && target.to_s != request_url.to_s
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def retry_from_terminal_redirect!(raw_response, deadline:)
|
|
112
|
+
terminal_url = terminal_redirect_url(raw_response)
|
|
113
|
+
@terminal_redirect_retried = true
|
|
114
|
+
Log.debug("#{self.class}: redirect limit reached; retrying once from #{terminal_url}")
|
|
115
|
+
begin_terminal_url_request!(terminal_url)
|
|
116
|
+
new_response = execute_http_request(deadline:, consume_budget: false)
|
|
117
|
+
if redirect_limit_reached?(new_response)
|
|
118
|
+
raise RedirectLimitReached, "Too many redirects (status #{new_response.status})"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
new_response
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def begin_terminal_url_request!(terminal_url)
|
|
125
|
+
ctx.policy.validate_request!(url: terminal_url, origin_url: ctx.origin_url, relation: ctx.relation)
|
|
126
|
+
@request_url_override = terminal_url
|
|
127
|
+
@last_redirect_to = nil
|
|
128
|
+
@current_url = terminal_url
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def request_url
|
|
132
|
+
@request_url_override || ctx.url
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def execute_http_request(deadline:, consume_budget: true)
|
|
136
|
+
preflight!(consume_budget:)
|
|
137
|
+
session = session_client(deadline)
|
|
138
|
+
response = http_get(session)
|
|
139
|
+
raise response.error if response.is_a?(HTTPX::ErrorResponse)
|
|
140
|
+
|
|
141
|
+
response
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# GET only. Overflow here is HTTPX retry recursion; the same error from
|
|
145
|
+
# redirect handling or body decode must stay loud.
|
|
146
|
+
def http_get(session)
|
|
147
|
+
session.get(request_url.to_s, headers: ctx.headers)
|
|
148
|
+
rescue SystemStackError
|
|
149
|
+
raise HTTPX::ConnectionError, 'HTTPX retry recursion exhausted'
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def session_client(deadline)
|
|
153
|
+
session = ctx.policy.allow_private_networks? ? self.class.base_session : self.class.base_ssrf_session
|
|
154
|
+
session.with(session_options(deadline))
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def session_options(deadline)
|
|
158
|
+
remaining = remaining_timeout_seconds(deadline)
|
|
159
|
+
{
|
|
160
|
+
timeout: session_timeouts(remaining),
|
|
161
|
+
max_redirects: ctx.policy.max_redirects,
|
|
162
|
+
follow_insecure_redirects: false,
|
|
163
|
+
max_response_body_size: ctx.policy.max_response_bytes,
|
|
164
|
+
resolver_class: :system,
|
|
165
|
+
redirect_on: redirect_callback,
|
|
166
|
+
**RETRY_OPTIONS
|
|
167
|
+
}
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def session_timeouts(remaining)
|
|
171
|
+
{
|
|
172
|
+
connect_timeout: [ctx.policy.connect_timeout_seconds, remaining].min,
|
|
173
|
+
read_timeout: [ctx.policy.read_timeout_seconds, remaining].min,
|
|
174
|
+
operation_timeout: remaining,
|
|
175
|
+
request_timeout: remaining,
|
|
176
|
+
total_request_timeout: remaining
|
|
177
|
+
}
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def redirect_callback
|
|
181
|
+
lambda do |redirect_uri|
|
|
182
|
+
to_url = normalize_url(redirect_uri)
|
|
183
|
+
from_url = @current_url
|
|
184
|
+
@last_redirect_to = to_url
|
|
185
|
+
ctx.policy.validate_redirect!(from_url:, to_url:, origin_url: ctx.origin_url, relation: ctx.relation)
|
|
186
|
+
@current_url = to_url
|
|
187
|
+
true
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def build_response(response)
|
|
192
|
+
headers = response.headers.to_h
|
|
193
|
+
Response.new(
|
|
194
|
+
body: CompressedBody.decode(response.body.to_s, headers:),
|
|
195
|
+
headers:,
|
|
196
|
+
url: response_url(response),
|
|
197
|
+
status: response.status
|
|
198
|
+
)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def response_url(response)
|
|
202
|
+
return ctx.url unless (uri = response.uri)
|
|
203
|
+
|
|
204
|
+
Html2rss::Url.from_absolute(uri.to_s)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def normalize_url(url)
|
|
208
|
+
Html2rss::Url.from_absolute(url.to_s)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def remaining_timeout_seconds(deadline)
|
|
212
|
+
remaining = deadline - monotonic_now
|
|
213
|
+
raise RequestTimedOut, 'Request timed out' if remaining <= 0
|
|
214
|
+
|
|
215
|
+
remaining
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def monotonic_now
|
|
219
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def timeout_error?(error)
|
|
223
|
+
error.is_a?(HTTPX::TimeoutError) || super
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def ssrf_error?(error)
|
|
227
|
+
error.is_a?(HTTPX::ServerSideRequestForgeryError) || super
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def response_too_large_error?(error)
|
|
231
|
+
(error.is_a?(HTTPX::Error) && error.message.include?('maximum response body size exceeded')) || super
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def connection_error?(error)
|
|
235
|
+
error.is_a?(HTTPX::ConnectionError) || error.is_a?(HTTPX::TLSError) || super
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
end
|
|
@@ -13,7 +13,7 @@ module Html2rss
|
|
|
13
13
|
# {Policy} so SSRF/origin rules stay single-homed on the public façade.
|
|
14
14
|
class NetworkGuard
|
|
15
15
|
# Hostnames treated as local/private surfaces.
|
|
16
|
-
LOCAL_HOSTS =
|
|
16
|
+
LOCAL_HOSTS = Set['localhost', 'localhost.localdomain', 'metadata.google.internal'].freeze
|
|
17
17
|
# IP ranges blocked when private networks are disabled.
|
|
18
18
|
BLOCKED_IP_RANGES = [
|
|
19
19
|
IPAddr.new('0.0.0.0/8'),
|
|
@@ -32,7 +32,9 @@ module Html2rss
|
|
|
32
32
|
|
|
33
33
|
##
|
|
34
34
|
# @param allow_private_networks [Boolean] whether private network targets are allowed
|
|
35
|
-
# @param resolver [
|
|
35
|
+
# @param resolver [Socket, Resolv] DNS resolver used for hostname classification.
|
|
36
|
+
# +Socket+ (the default) uses +getaddrinfo+; any other resolver is Resolv-shaped
|
|
37
|
+
# and must implement +each_address+.
|
|
36
38
|
def initialize(allow_private_networks:, resolver: Socket)
|
|
37
39
|
@allow_private_networks = allow_private_networks ? true : false
|
|
38
40
|
@resolver = resolver
|
|
@@ -89,10 +91,10 @@ module Html2rss
|
|
|
89
91
|
literal = parse_ip(host)
|
|
90
92
|
return [literal] if literal
|
|
91
93
|
|
|
92
|
-
if resolver
|
|
93
|
-
addresses_from_each_address(host)
|
|
94
|
-
else
|
|
94
|
+
if resolver == Socket
|
|
95
95
|
addresses_from_getaddrinfo(host)
|
|
96
|
+
else
|
|
97
|
+
addresses_from_each_address(host)
|
|
96
98
|
end
|
|
97
99
|
rescue Resolv::ResolvError, SocketError, SystemCallError
|
|
98
100
|
[]
|
|
@@ -36,8 +36,7 @@ module Html2rss
|
|
|
36
36
|
captured_responses: EMPTY_CAPTURED_RESPONSES)
|
|
37
37
|
@body = body
|
|
38
38
|
|
|
39
|
-
headers = headers.
|
|
40
|
-
headers.transform_keys!(&:to_s)
|
|
39
|
+
headers = (headers || {}).transform_keys(&:to_s)
|
|
41
40
|
HashUtil.assert_string_keys!(headers, context: 'response headers', deep: false)
|
|
42
41
|
|
|
43
42
|
@headers = headers
|
|
@@ -9,22 +9,6 @@ module Html2rss
|
|
|
9
9
|
# @param policy [Policy] request policy that defines byte ceilings
|
|
10
10
|
def initialize(policy:)
|
|
11
11
|
@policy = policy
|
|
12
|
-
@streamed_bytes = 0
|
|
13
|
-
end
|
|
14
|
-
|
|
15
|
-
##
|
|
16
|
-
# Validates response headers and streamed byte count.
|
|
17
|
-
#
|
|
18
|
-
# @param total_bytes [Integer] cumulative byte count received so far
|
|
19
|
-
# @param headers [Hash, nil] response headers if known
|
|
20
|
-
# @return [void]
|
|
21
|
-
# @raise [ResponseTooLarge] if the response exceeds configured limits
|
|
22
|
-
def inspect_chunk!(total_bytes:, headers: nil)
|
|
23
|
-
header_length = headers&.fetch('content-length', headers&.fetch('Content-Length', nil))
|
|
24
|
-
raise_if_too_large!(header_length.to_i, policy.max_response_bytes) if header_length
|
|
25
|
-
|
|
26
|
-
@streamed_bytes = total_bytes
|
|
27
|
-
raise_if_too_large!(@streamed_bytes, policy.max_response_bytes)
|
|
28
12
|
end
|
|
29
13
|
|
|
30
14
|
##
|
|
@@ -22,7 +22,7 @@ module Html2rss
|
|
|
22
22
|
def execute
|
|
23
23
|
check_timeout!
|
|
24
24
|
response = perform_execute
|
|
25
|
-
postflight!(response
|
|
25
|
+
postflight!(response)
|
|
26
26
|
response
|
|
27
27
|
rescue StandardError => error
|
|
28
28
|
handle_error(error)
|
|
@@ -67,13 +67,11 @@ module Html2rss
|
|
|
67
67
|
# Enforces response-size and blocked-surface checks after transport work.
|
|
68
68
|
#
|
|
69
69
|
# @param response [Response, nil] adapter response
|
|
70
|
-
# @param response_guard [ResponseGuard, nil] existing guard to reuse (e.g. streaming)
|
|
71
70
|
# @return [void]
|
|
72
|
-
def postflight!(response
|
|
71
|
+
def postflight!(response)
|
|
73
72
|
return if skip_postflight? || response.nil?
|
|
74
73
|
|
|
75
|
-
|
|
76
|
-
guard.inspect_body!(response.body)
|
|
74
|
+
ResponseGuard.new(policy: ctx.policy).inspect_body!(response.body)
|
|
77
75
|
end
|
|
78
76
|
|
|
79
77
|
##
|
|
@@ -91,17 +89,19 @@ module Html2rss
|
|
|
91
89
|
raise
|
|
92
90
|
end
|
|
93
91
|
|
|
94
|
-
# @return [ResponseGuard, nil]
|
|
95
|
-
def response_guard = nil
|
|
96
|
-
|
|
97
92
|
# @param error [StandardError]
|
|
98
93
|
# @return [void]
|
|
99
94
|
# @raise [StandardError]
|
|
95
|
+
# rubocop:disable-next Metrics/AbcSize, Metrics/MethodLength -- error translation dispatch
|
|
100
96
|
def handle_error(error)
|
|
101
97
|
if timeout_error?(error)
|
|
102
98
|
log_timeout!(reason: 'transport')
|
|
103
99
|
Log.debug("#{self.class}: transport timeout message=#{error.message}")
|
|
104
100
|
raise RequestTimedOut, error.message
|
|
101
|
+
elsif ssrf_error?(error)
|
|
102
|
+
raise PrivateNetworkDenied, error.message
|
|
103
|
+
elsif response_too_large_error?(error)
|
|
104
|
+
raise ResponseTooLarge, "Response exceeded #{ctx.policy.max_response_bytes} bytes"
|
|
105
105
|
elsif connection_error?(error)
|
|
106
106
|
translate_connection_error(error)
|
|
107
107
|
else
|
|
@@ -135,14 +135,25 @@ module Html2rss
|
|
|
135
135
|
# @param error [StandardError]
|
|
136
136
|
# @return [Boolean]
|
|
137
137
|
def timeout_error?(error)
|
|
138
|
-
error.is_a?(
|
|
139
|
-
|
|
138
|
+
error.is_a?(Timeout::Error)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# @param _error [StandardError]
|
|
142
|
+
# @return [Boolean]
|
|
143
|
+
def ssrf_error?(_error)
|
|
144
|
+
false
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# @param _error [StandardError]
|
|
148
|
+
# @return [Boolean]
|
|
149
|
+
def response_too_large_error?(_error)
|
|
150
|
+
false
|
|
140
151
|
end
|
|
141
152
|
|
|
142
153
|
# @param error [StandardError]
|
|
143
154
|
# @return [Boolean]
|
|
144
155
|
def connection_error?(error)
|
|
145
|
-
error.is_a?(
|
|
156
|
+
error.is_a?(SocketError) || error.is_a?(SystemCallError)
|
|
146
157
|
end
|
|
147
158
|
end
|
|
148
159
|
end
|
|
@@ -1,18 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'singleton'
|
|
4
|
-
require 'forwardable'
|
|
5
|
-
|
|
6
3
|
module Html2rss
|
|
7
4
|
##
|
|
8
5
|
# Requests website URLs to retrieve their HTML for further processing.
|
|
9
|
-
# Provides concrete transport strategies (e.g.
|
|
6
|
+
# Provides concrete transport strategies (e.g. HttpxStrategy, BotasaurusStrategy).
|
|
10
7
|
#
|
|
11
8
|
# Feed-level +:auto+ is not registered here — {FeedPipeline::StrategyPlan} resolves
|
|
12
9
|
# it to a concrete strategy (or {FeedPipeline::AutoFallback} chain) before execute.
|
|
13
10
|
class RequestService
|
|
14
|
-
include Singleton
|
|
15
|
-
|
|
16
11
|
# Raised when an unknown request strategy is requested.
|
|
17
12
|
class UnknownStrategy < Html2rss::Error; end
|
|
18
13
|
# Raised when a URL cannot be parsed or validated.
|
|
@@ -59,94 +54,60 @@ module Html2rss
|
|
|
59
54
|
# Raised when Botasaurus responds but the scrape fails (upstream error, bad payload).
|
|
60
55
|
class BotasaurusServiceError < Html2rss::Error; end
|
|
61
56
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
execute].each do |method|
|
|
72
|
-
def_delegator :instance, method
|
|
73
|
-
end
|
|
74
|
-
end
|
|
75
|
-
|
|
76
|
-
def initialize
|
|
77
|
-
@strategies = {
|
|
78
|
-
faraday: FaradayStrategy,
|
|
79
|
-
botasaurus: BotasaurusStrategy,
|
|
80
|
-
local_file: LocalFileStrategy
|
|
81
|
-
}
|
|
82
|
-
@default_strategy_name = :faraday
|
|
83
|
-
end
|
|
57
|
+
# Map of supported strategy names to their implementation classes.
|
|
58
|
+
# @return [Hash{Symbol => Class<Strategy>}]
|
|
59
|
+
STRATEGIES = {
|
|
60
|
+
default: HttpxStrategy,
|
|
61
|
+
httpx: HttpxStrategy,
|
|
62
|
+
faraday: HttpxStrategy,
|
|
63
|
+
botasaurus: BotasaurusStrategy,
|
|
64
|
+
local_file: LocalFileStrategy
|
|
65
|
+
}.freeze
|
|
84
66
|
|
|
85
|
-
#
|
|
86
|
-
|
|
67
|
+
# Canonical default strategy symbol.
|
|
68
|
+
# @return [Symbol]
|
|
69
|
+
DEFAULT_STRATEGY_NAME = :default
|
|
87
70
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
# @return [Symbol] the selected default strategy name
|
|
92
|
-
# @raise [UnknownStrategy] if the strategy is not registered
|
|
93
|
-
def default_strategy_name=(strategy)
|
|
94
|
-
raise UnknownStrategy unless strategy_registered?(strategy)
|
|
95
|
-
|
|
96
|
-
@default_strategy_name = strategy.to_sym
|
|
97
|
-
end
|
|
71
|
+
class << self
|
|
72
|
+
# @return [Symbol] the default strategy name
|
|
73
|
+
def default_strategy_name = DEFAULT_STRATEGY_NAME
|
|
98
74
|
|
|
99
|
-
|
|
100
|
-
|
|
75
|
+
# @return [Array<String>] the names of the registered strategies
|
|
76
|
+
def strategy_names = STRATEGIES.keys.map(&:to_s)
|
|
101
77
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
def register_strategy(name, strategy_class)
|
|
109
|
-
unless strategy_class.is_a?(Class)
|
|
110
|
-
raise ArgumentError, "Expected a Class for strategy, got #{strategy_class.class}"
|
|
78
|
+
##
|
|
79
|
+
# Checks if a strategy is registered.
|
|
80
|
+
# @param name [Symbol, String] the name of the strategy
|
|
81
|
+
# @return [Boolean] true if the strategy is registered, false otherwise.
|
|
82
|
+
def strategy_registered?(name)
|
|
83
|
+
STRATEGIES.key?(name.to_sym)
|
|
111
84
|
end
|
|
112
85
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
def unregister_strategy(name) # rubocop:disable Naming/PredicateMethod
|
|
130
|
-
name_sym = name.to_sym
|
|
131
|
-
raise ArgumentError, 'Cannot unregister the default strategy.' if name_sym == @default_strategy_name
|
|
86
|
+
##
|
|
87
|
+
# Executes the request using the specified strategy.
|
|
88
|
+
# @param ctx [Context] the context for the request.
|
|
89
|
+
# @param strategy [Symbol, String] the strategy to use (defaults to the default strategy).
|
|
90
|
+
# @return [Response] the response from the executed strategy.
|
|
91
|
+
# @raise [UnknownStrategy] if the strategy is not registered.
|
|
92
|
+
def execute(ctx, strategy: default_strategy_name)
|
|
93
|
+
strategy_sym = strategy.to_sym
|
|
94
|
+
strategy_class = STRATEGIES.fetch(strategy_sym) do
|
|
95
|
+
raise UnknownStrategy,
|
|
96
|
+
"The strategy '#{strategy}' is not known. Available strategies: #{strategy_names.join(', ')}"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
warn_migration_strategy(strategy_sym) if strategy_sym == :faraday
|
|
100
|
+
strategy_class.new(ctx).execute
|
|
101
|
+
end
|
|
132
102
|
|
|
133
|
-
|
|
134
|
-
end
|
|
103
|
+
private
|
|
135
104
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
# @raise [ArgumentError] if the context is nil.
|
|
142
|
-
# @raise [UnknownStrategy] if the strategy is not registered.
|
|
143
|
-
def execute(ctx, strategy: default_strategy_name)
|
|
144
|
-
strategy_class = @strategies.fetch(strategy.to_sym) do
|
|
145
|
-
raise UnknownStrategy,
|
|
146
|
-
"The strategy '#{strategy}' is not known. Available strategies: #{strategy_names.join(', ')}"
|
|
105
|
+
def warn_migration_strategy(strategy)
|
|
106
|
+
message = "RequestService: strategy ':#{strategy}' is deprecated for migration and will be removed " \
|
|
107
|
+
"in a future release. Use ':default' instead."
|
|
108
|
+
warn(message, category: :deprecated)
|
|
109
|
+
Log.warn(message)
|
|
147
110
|
end
|
|
148
|
-
|
|
149
|
-
strategy_class.new(ctx).execute
|
|
150
111
|
end
|
|
151
112
|
end
|
|
152
113
|
end
|
|
@@ -10,14 +10,14 @@ module Html2rss
|
|
|
10
10
|
# Minimum composite score retained by {#rank_top} (precision floor).
|
|
11
11
|
SCORE_FLOOR = 0.0
|
|
12
12
|
|
|
13
|
-
FEATURE_IDS =
|
|
14
|
-
title_word_count_ge3 title_word_count_ge7 path_length_gt6 content_path
|
|
15
|
-
publish_marker descriptive_context article_container content_tokens
|
|
16
|
-
non_content_utility_path utility_prefix_title_short shallow weak_container
|
|
17
|
-
recommended_title_non_content high_confidence_junk_path junk_tokens
|
|
18
|
-
heading_anchor heading_text_match meaningful_text content_like_destination
|
|
19
|
-
cluster_size cluster_avg_words cluster_heading cluster_time cluster_date
|
|
20
|
-
].
|
|
13
|
+
FEATURE_IDS = Set[
|
|
14
|
+
:title_word_count_ge3, :title_word_count_ge7, :path_length_gt6, :content_path,
|
|
15
|
+
:publish_marker, :descriptive_context, :article_container, :content_tokens,
|
|
16
|
+
:non_content_utility_path, :utility_prefix_title_short, :shallow, :weak_container,
|
|
17
|
+
:recommended_title_non_content, :high_confidence_junk_path, :junk_tokens,
|
|
18
|
+
:heading_anchor, :heading_text_match, :meaningful_text, :content_like_destination,
|
|
19
|
+
:cluster_size, :cluster_avg_words, :cluster_heading, :cluster_time, :cluster_date
|
|
20
|
+
].freeze
|
|
21
21
|
private_constant :FEATURE_IDS
|
|
22
22
|
|
|
23
23
|
# Quality feature triples: [feature_id, predicate, weight].
|
|
@@ -2,8 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
module Html2rss
|
|
4
4
|
module Scoring
|
|
5
|
-
##
|
|
6
|
-
# Typed container signals produced by {ContainerAssessor} for {Engine} rules.
|
|
7
5
|
Observation = Data.define(
|
|
8
6
|
:title_word_count,
|
|
9
7
|
:path_length,
|
|
@@ -21,7 +19,11 @@ module Html2rss
|
|
|
21
19
|
:high_confidence_junk_path,
|
|
22
20
|
:high_confidence_utility_destination,
|
|
23
21
|
:selected_anchor_present
|
|
24
|
-
)
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
##
|
|
25
|
+
# Typed container signals produced by {ContainerAssessor} for {Engine} rules.
|
|
26
|
+
class Observation
|
|
25
27
|
##
|
|
26
28
|
# @return [Boolean] true when fewer than two positive content signals are present
|
|
27
29
|
def weak_signals?
|
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
module Html2rss
|
|
4
4
|
module Scoring
|
|
5
|
+
RankedSegment = Data.define(:segment, :score)
|
|
6
|
+
|
|
5
7
|
##
|
|
6
8
|
# A segment paired with its composite score.
|
|
7
|
-
RankedSegment
|
|
9
|
+
class RankedSegment
|
|
8
10
|
##
|
|
9
11
|
# @return [SST::Node]
|
|
10
12
|
def root_node = segment.root_node
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Html2rss
|
|
4
|
+
ScrapeTarget = Data.define(:entry_url, :effective_url)
|
|
5
|
+
|
|
4
6
|
##
|
|
5
7
|
# Immutable entry vs effective scrape URLs for one pipeline run.
|
|
6
8
|
#
|
|
7
9
|
# Replaces mutating +Config#scrape_url=+ after {FeedResolution} rewrites the fetch URL.
|
|
8
|
-
ScrapeTarget
|
|
10
|
+
class ScrapeTarget
|
|
9
11
|
##
|
|
10
12
|
# @param config [Html2rss::Config]
|
|
11
13
|
# @return [ScrapeTarget]
|