html2rss 0.25.0 → 0.26.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 +76 -2
- data/html2rss.gemspec +4 -1
- data/lib/html2rss/auto_source/cleanup.rb +62 -36
- data/lib/html2rss/auto_source/scraper/json_state.rb +31 -22
- data/lib/html2rss/auto_source/scraper/schema/item_list.rb +2 -14
- data/lib/html2rss/auto_source/scraper/xhr_articles.rb +69 -0
- data/lib/html2rss/auto_source/scraper.rb +34 -15
- data/lib/html2rss/auto_source/segmenter/list.rb +3 -1
- data/lib/html2rss/auto_source/segmenter/primary_link.rb +6 -7
- data/lib/html2rss/auto_source/segmenter.rb +30 -1
- data/lib/html2rss/auto_source.rb +7 -2
- data/lib/html2rss/capture.rb +315 -0
- data/lib/html2rss/cli.rb +64 -5
- data/lib/html2rss/config/auto_source_contract.rb +3 -1
- data/lib/html2rss/config/schema.rb +67 -26
- data/lib/html2rss/config/selectors_validator.rb +60 -21
- data/lib/html2rss/config/validator.rb +18 -27
- data/lib/html2rss/error.rb +15 -3
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +16 -6
- data/lib/html2rss/feed_pipeline/runtime_policy.rb +3 -43
- data/lib/html2rss/feed_pipeline.rb +10 -3
- data/lib/html2rss/hash_util.rb +17 -0
- data/lib/html2rss/html/sst_article_extractor.rb +36 -7
- data/lib/html2rss/link_destination/noise_policy.rb +7 -18
- data/lib/html2rss/link_destination/path_classifier.rb +3 -0
- data/lib/html2rss/mcp/server.rb +577 -0
- data/lib/html2rss/mcp.rb +21 -0
- data/lib/html2rss/request_service/blocked_surface.rb +24 -1
- data/lib/html2rss/request_service/botasaurus_contract.rb +89 -9
- data/lib/html2rss/request_service/botasaurus_strategy.rb +4 -2
- data/lib/html2rss/request_service/budget.rb +7 -35
- data/lib/html2rss/request_service/context.rb +0 -6
- data/lib/html2rss/request_service/faraday_strategy.rb +45 -2
- data/lib/html2rss/request_service/policy.rb +1 -1
- data/lib/html2rss/request_service/response.rb +15 -1
- data/lib/html2rss/request_service/strategy.rb +1 -2
- data/lib/html2rss/request_service.rb +4 -9
- data/lib/html2rss/scoring/container_assessor.rb +9 -15
- data/lib/html2rss/scoring/engine.rb +49 -5
- data/lib/html2rss/scoring/link_resolver.rb +12 -0
- data/lib/html2rss/scoring/ranked_segment.rb +0 -12
- data/lib/html2rss/scoring/score.rb +1 -20
- data/lib/html2rss/scoring.rb +0 -25
- data/lib/html2rss/selectors/extractors/attribute.rb +15 -0
- data/lib/html2rss/selectors/extractors/href.rb +12 -0
- data/lib/html2rss/selectors/extractors/html.rb +12 -0
- data/lib/html2rss/selectors/extractors/static.rb +14 -0
- data/lib/html2rss/selectors/extractors/text.rb +11 -0
- data/lib/html2rss/selectors/post_processors/gsub.rb +18 -0
- data/lib/html2rss/selectors/post_processors/html_to_markdown.rb +11 -0
- data/lib/html2rss/selectors/post_processors/markdown_to_html.rb +12 -0
- data/lib/html2rss/selectors/post_processors/parse_time.rb +11 -0
- data/lib/html2rss/selectors/post_processors/parse_uri.rb +11 -0
- data/lib/html2rss/selectors/post_processors/sanitize_html.rb +13 -0
- data/lib/html2rss/selectors/post_processors/substring.rb +21 -0
- data/lib/html2rss/selectors/post_processors/template.rb +20 -0
- data/lib/html2rss/selectors/schema_doc.rb +99 -0
- data/lib/html2rss/url.rb +11 -7
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss.rb +32 -1
- data/schema/html2rss-config.schema.json +408 -85
- metadata +54 -12
- data/lib/html2rss/request_service/browserless_strategy.rb +0 -132
- data/lib/html2rss/request_service/puppet_commander/navigation_guards.rb +0 -148
- data/lib/html2rss/request_service/puppet_commander/preload_runner.rb +0 -86
- data/lib/html2rss/request_service/puppet_commander.rb +0 -95
- data/lib/html2rss/scoring/anchor_score.rb +0 -34
|
@@ -9,6 +9,7 @@ module Html2rss
|
|
|
9
9
|
class BotasaurusContract
|
|
10
10
|
# Default Botasaurus scrape options when no explicit config is provided.
|
|
11
11
|
DEFAULT_OPTIONS = {
|
|
12
|
+
execution_mode: 'auto',
|
|
12
13
|
navigation_mode: 'auto',
|
|
13
14
|
max_retries: 1,
|
|
14
15
|
headless: false
|
|
@@ -16,23 +17,30 @@ module Html2rss
|
|
|
16
17
|
|
|
17
18
|
# Allowlisted request.botasaurus keys forwarded to upstream.
|
|
18
19
|
OPTION_KEYS = %i[
|
|
20
|
+
execution_mode
|
|
19
21
|
navigation_mode
|
|
20
22
|
max_retries
|
|
21
23
|
wait_for_selector
|
|
22
24
|
wait_timeout_seconds
|
|
25
|
+
scroll
|
|
26
|
+
scroll_to_bottom
|
|
23
27
|
block_images
|
|
24
28
|
block_images_and_css
|
|
29
|
+
block_trackers
|
|
25
30
|
wait_for_complete_page_load
|
|
26
31
|
headless
|
|
27
32
|
proxy
|
|
28
33
|
user_agent
|
|
29
34
|
window_size
|
|
30
35
|
lang
|
|
36
|
+
headers
|
|
37
|
+
cookies
|
|
31
38
|
].freeze
|
|
32
39
|
|
|
33
40
|
# Allowlisted upstream response keys exposed as Response#transport_meta.
|
|
34
41
|
META_KEYS = %w[
|
|
35
42
|
request_id strategy_used render_ms challenge_detected blocked_detected attempts error_category
|
|
43
|
+
execution_tier detected_challenge
|
|
36
44
|
].freeze
|
|
37
45
|
|
|
38
46
|
# Remaining seconds at or below which Botasaurus retries are disabled.
|
|
@@ -45,6 +53,10 @@ module Html2rss
|
|
|
45
53
|
class ParsedResponse
|
|
46
54
|
# Fallback headers when upstream omits response headers.
|
|
47
55
|
DEFAULT_HEADERS = { 'content-type' => 'text/html' }.freeze
|
|
56
|
+
# Per-body size cap for captured XHR JSON (defense-in-depth vs upstream).
|
|
57
|
+
MAX_XHR_BODY_BYTES = 500_000
|
|
58
|
+
# Aggregate size cap across all captured XHR bodies.
|
|
59
|
+
MAX_XHR_AGGREGATE_BYTES = 2_000_000
|
|
48
60
|
|
|
49
61
|
# @param payload [Hash{String => Object}] parsed Botasaurus response payload
|
|
50
62
|
# @param transport_status [Integer] HTTP status returned by Botasaurus
|
|
@@ -58,7 +70,7 @@ module Html2rss
|
|
|
58
70
|
|
|
59
71
|
# @return [Boolean] true when upstream returned non-200 or an error payload
|
|
60
72
|
def upstream_failure?
|
|
61
|
-
status != 200 || error_message?
|
|
73
|
+
transport_status != 200 || status != 200 || error_message?
|
|
62
74
|
end
|
|
63
75
|
|
|
64
76
|
# @return [String] normalized challenge error message
|
|
@@ -76,10 +88,10 @@ module Html2rss
|
|
|
76
88
|
end
|
|
77
89
|
|
|
78
90
|
# @return [String] rendered HTML body from Botasaurus
|
|
79
|
-
# @raise [
|
|
91
|
+
# @raise [BotasaurusServiceError] when html is missing
|
|
80
92
|
def html
|
|
81
93
|
value = payload['html']
|
|
82
|
-
raise
|
|
94
|
+
raise BotasaurusServiceError, "Botasaurus response missing required 'html' field" if value.nil?
|
|
83
95
|
|
|
84
96
|
value.to_s
|
|
85
97
|
end
|
|
@@ -104,6 +116,21 @@ module Html2rss
|
|
|
104
116
|
# @return [Hash{String => Object}] allowlisted upstream telemetry (frozen)
|
|
105
117
|
def transport_meta = payload.slice(*META_KEYS).compact.freeze
|
|
106
118
|
|
|
119
|
+
# @return [Array<Hash{String => Object}>] size-capped XHR/fetch captures (absent → [])
|
|
120
|
+
def xhr_responses
|
|
121
|
+
raw = payload['xhr_responses']
|
|
122
|
+
return [] unless raw.is_a?(Array)
|
|
123
|
+
|
|
124
|
+
aggregate_bytes = 0
|
|
125
|
+
raw.filter_map do |entry|
|
|
126
|
+
normalized = normalize_xhr_entry(entry)
|
|
127
|
+
next unless normalized
|
|
128
|
+
next unless (aggregate_bytes = advance_xhr_budget(normalized, aggregate_bytes))
|
|
129
|
+
|
|
130
|
+
normalized
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
107
134
|
private
|
|
108
135
|
|
|
109
136
|
attr_reader :payload, :transport_status
|
|
@@ -118,50 +145,103 @@ module Html2rss
|
|
|
118
145
|
value = error
|
|
119
146
|
value.is_a?(String) ? !value.empty? : !value.nil?
|
|
120
147
|
end
|
|
148
|
+
|
|
149
|
+
def normalize_xhr_entry(entry)
|
|
150
|
+
return unless entry.is_a?(Hash)
|
|
151
|
+
|
|
152
|
+
body = xhr_field(entry, :body)
|
|
153
|
+
return unless body.is_a?(String) && !body.empty?
|
|
154
|
+
|
|
155
|
+
{
|
|
156
|
+
'url' => xhr_field(entry, :url).to_s,
|
|
157
|
+
'body' => body,
|
|
158
|
+
'headers' => xhr_headers(xhr_field(entry, :headers)),
|
|
159
|
+
'status_code' => xhr_status_code(xhr_field(entry, :status_code))
|
|
160
|
+
}
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# @return [Integer, nil] next aggregate byte total when accepted
|
|
164
|
+
def advance_xhr_budget(normalized, aggregate_bytes)
|
|
165
|
+
body_bytes = normalized.fetch('body').bytesize
|
|
166
|
+
return if body_bytes > MAX_XHR_BODY_BYTES
|
|
167
|
+
return if aggregate_bytes + body_bytes > MAX_XHR_AGGREGATE_BYTES
|
|
168
|
+
|
|
169
|
+
aggregate_bytes + body_bytes
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def xhr_field(entry, key)
|
|
173
|
+
entry[key.to_s] || entry[key]
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def xhr_headers(raw)
|
|
177
|
+
return {} unless raw.is_a?(Hash)
|
|
178
|
+
|
|
179
|
+
raw.to_h { |key, value| [key.to_s, value.to_s] }
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def xhr_status_code(value)
|
|
183
|
+
value.is_a?(Integer) ? value : nil
|
|
184
|
+
end
|
|
121
185
|
end
|
|
122
186
|
|
|
123
187
|
##
|
|
124
188
|
# @param url [Html2rss::Url] canonical URL to scrape
|
|
189
|
+
# @param headers [Hash] request headers from context
|
|
125
190
|
# @param options [Hash] validated request.botasaurus options
|
|
126
191
|
# @param remaining_timeout_seconds [Numeric, nil] shared request budget remainder for clamps
|
|
192
|
+
# @option options [String] :execution_mode
|
|
127
193
|
# @option options [String] :navigation_mode
|
|
128
194
|
# @option options [Integer] :max_retries
|
|
129
195
|
# @option options [String] :wait_for_selector
|
|
130
196
|
# @option options [Integer] :wait_timeout_seconds
|
|
131
197
|
# @option options [Boolean] :block_images
|
|
132
198
|
# @option options [Boolean] :block_images_and_css
|
|
199
|
+
# @option options [Boolean] :block_trackers
|
|
133
200
|
# @option options [Boolean] :wait_for_complete_page_load
|
|
134
201
|
# @option options [Boolean] :headless
|
|
135
202
|
# @option options [String] :proxy
|
|
136
203
|
# @option options [String] :user_agent
|
|
137
204
|
# @option options [Array<Integer>] :window_size
|
|
138
205
|
# @option options [String] :lang
|
|
139
|
-
|
|
206
|
+
# @option options [Hash] :cookies
|
|
207
|
+
# @option options [Hash] :headers
|
|
208
|
+
def initialize(url:, headers: {}, options: {}, remaining_timeout_seconds: nil)
|
|
140
209
|
@url = url
|
|
210
|
+
@headers = headers
|
|
141
211
|
@options = options
|
|
142
212
|
@remaining_timeout_seconds = remaining_timeout_seconds
|
|
143
213
|
end
|
|
144
214
|
|
|
145
215
|
# @return [Hash] payload for POST /scrape (budget-clamped when remaining is known)
|
|
146
216
|
def request_payload
|
|
147
|
-
DEFAULT_OPTIONS.merge(filtered_options)
|
|
217
|
+
payload = DEFAULT_OPTIONS.merge(filtered_options)
|
|
218
|
+
forwarded_headers = merged_headers
|
|
219
|
+
payload[:headers] = forwarded_headers if forwarded_headers&.any?
|
|
220
|
+
payload.merge(url: url.to_s).then { clamp_for_budget(_1) }
|
|
148
221
|
end
|
|
149
222
|
|
|
150
223
|
# @param transport_response [Faraday::Response] upstream HTTP response
|
|
151
224
|
# @return [ParsedResponse]
|
|
152
|
-
# @raise [
|
|
225
|
+
# @raise [BotasaurusServiceError] when payload is not valid JSON object
|
|
153
226
|
def parse_response(transport_response)
|
|
154
227
|
payload = JSON.parse(transport_response.body.to_s)
|
|
155
|
-
raise
|
|
228
|
+
raise BotasaurusServiceError, 'Botasaurus response must be a JSON object' unless payload.is_a?(Hash)
|
|
156
229
|
|
|
157
230
|
ParsedResponse.new(payload:, transport_status: transport_response.status)
|
|
158
231
|
rescue JSON::ParserError => error
|
|
159
|
-
raise
|
|
232
|
+
raise BotasaurusServiceError, "Botasaurus response JSON parse failed: #{error.message}"
|
|
160
233
|
end
|
|
161
234
|
|
|
162
235
|
private
|
|
163
236
|
|
|
164
|
-
attr_reader :url, :options, :remaining_timeout_seconds
|
|
237
|
+
attr_reader :url, :headers, :options, :remaining_timeout_seconds
|
|
238
|
+
|
|
239
|
+
def merged_headers
|
|
240
|
+
explicit = options[:headers] || {}
|
|
241
|
+
base = headers.is_a?(Hash) ? headers : {}
|
|
242
|
+
merged = base.merge(explicit).transform_keys(&:to_s).compact
|
|
243
|
+
merged.empty? ? nil : merged
|
|
244
|
+
end
|
|
165
245
|
|
|
166
246
|
def filtered_options
|
|
167
247
|
OPTION_KEYS.each_with_object({}) do |key, normalized|
|
|
@@ -28,7 +28,8 @@ module Html2rss
|
|
|
28
28
|
headers: parsed_response.headers,
|
|
29
29
|
url: response_url(parsed_response.final_url),
|
|
30
30
|
status: parsed_response.status,
|
|
31
|
-
transport_meta: parsed_response.transport_meta
|
|
31
|
+
transport_meta: parsed_response.transport_meta,
|
|
32
|
+
captured_responses: parsed_response.xhr_responses
|
|
32
33
|
)
|
|
33
34
|
end
|
|
34
35
|
|
|
@@ -41,7 +42,7 @@ module Html2rss
|
|
|
41
42
|
def raise_if_upstream_failed!(parsed_response)
|
|
42
43
|
return unless parsed_response.upstream_failure?
|
|
43
44
|
|
|
44
|
-
raise
|
|
45
|
+
raise BotasaurusServiceError, parsed_response.upstream_failure_message
|
|
45
46
|
end
|
|
46
47
|
|
|
47
48
|
def response_url(final_url)
|
|
@@ -55,6 +56,7 @@ module Html2rss
|
|
|
55
56
|
def contract
|
|
56
57
|
@contract ||= BotasaurusContract.new(
|
|
57
58
|
url: ctx.url,
|
|
59
|
+
headers: ctx.headers,
|
|
58
60
|
options: ctx.request.fetch(:botasaurus, {}),
|
|
59
61
|
remaining_timeout_seconds: attempt_timeout_seconds
|
|
60
62
|
)
|
|
@@ -3,20 +3,17 @@
|
|
|
3
3
|
module Html2rss
|
|
4
4
|
class RequestService
|
|
5
5
|
##
|
|
6
|
-
# Tracks request slots
|
|
6
|
+
# Tracks HTTP request slots and wall-clock deadline for one feed build.
|
|
7
7
|
#
|
|
8
|
-
# HTTP fetches consume request slots
|
|
9
|
-
# interaction budget. Deadline remains on `#remaining_timeout_seconds`.
|
|
8
|
+
# HTTP fetches consume request slots. Deadline remains on `#remaining_timeout_seconds`.
|
|
10
9
|
class Budget
|
|
11
10
|
##
|
|
12
11
|
# @param max_requests [Integer] maximum HTTP request slots
|
|
13
|
-
# @param max_interactions [Integer] maximum preload interaction slots (default 0)
|
|
14
12
|
# @param total_timeout_seconds [Integer, nil] wall-clock timeout for the feed build
|
|
15
|
-
def initialize(max_requests:,
|
|
16
|
-
validate_slot_limits!(max_requests
|
|
13
|
+
def initialize(max_requests:, total_timeout_seconds: nil)
|
|
14
|
+
validate_slot_limits!(max_requests:)
|
|
17
15
|
|
|
18
16
|
@remaining_requests = max_requests
|
|
19
|
-
@remaining_interactions = max_interactions
|
|
20
17
|
@start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
21
18
|
@total_timeout_seconds = total_timeout_seconds
|
|
22
19
|
@mutex = Mutex.new
|
|
@@ -44,21 +41,6 @@ module Html2rss
|
|
|
44
41
|
end
|
|
45
42
|
end
|
|
46
43
|
|
|
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!
|
|
55
|
-
@mutex.synchronize do
|
|
56
|
-
raise InteractionBudgetExceeded, 'Interaction budget exhausted' if @remaining_interactions.zero?
|
|
57
|
-
|
|
58
|
-
@remaining_interactions -= 1
|
|
59
|
-
end
|
|
60
|
-
end
|
|
61
|
-
|
|
62
44
|
##
|
|
63
45
|
# @return [Integer] HTTP request slots still available
|
|
64
46
|
def remaining
|
|
@@ -71,12 +53,6 @@ module Html2rss
|
|
|
71
53
|
@mutex.synchronize { @remaining_requests }
|
|
72
54
|
end
|
|
73
55
|
|
|
74
|
-
##
|
|
75
|
-
# @return [Integer] preload interaction slots still available
|
|
76
|
-
def remaining_interactions
|
|
77
|
-
@mutex.synchronize { @remaining_interactions }
|
|
78
|
-
end
|
|
79
|
-
|
|
80
56
|
##
|
|
81
57
|
# @return [Float, nil] the remaining timeout in seconds, or nil if not tracked
|
|
82
58
|
def remaining_timeout_seconds
|
|
@@ -118,14 +94,10 @@ module Html2rss
|
|
|
118
94
|
|
|
119
95
|
private
|
|
120
96
|
|
|
121
|
-
def validate_slot_limits!(max_requests
|
|
122
|
-
|
|
123
|
-
raise ArgumentError,
|
|
124
|
-
'max_requests must be positive'
|
|
125
|
-
end
|
|
126
|
-
return if max_interactions.is_a?(Integer) && !max_interactions.negative?
|
|
97
|
+
def validate_slot_limits!(max_requests:)
|
|
98
|
+
return if max_requests.is_a?(Integer) && max_requests.positive?
|
|
127
99
|
|
|
128
|
-
raise ArgumentError, '
|
|
100
|
+
raise ArgumentError, 'max_requests must be positive'
|
|
129
101
|
end
|
|
130
102
|
end
|
|
131
103
|
end
|
|
@@ -32,12 +32,6 @@ module Html2rss
|
|
|
32
32
|
# @return [Hash] the request specific options
|
|
33
33
|
attr_reader :request
|
|
34
34
|
|
|
35
|
-
# @return [Hash] browserless specific options
|
|
36
|
-
def browserless = request.fetch(:browserless, {})
|
|
37
|
-
|
|
38
|
-
# @return [Hash, nil] preload options for browserless requests
|
|
39
|
-
def browserless_preload = browserless[:preload]
|
|
40
|
-
|
|
41
35
|
# @return [Symbol] the request relation
|
|
42
36
|
attr_reader :relation
|
|
43
37
|
|
|
@@ -9,6 +9,7 @@ module Html2rss
|
|
|
9
9
|
##
|
|
10
10
|
# Strategy to use Faraday for the request.
|
|
11
11
|
# @see https://rubygems.org/gems/faraday
|
|
12
|
+
# rubocop:disable Metrics/ClassLength -- terminal redirect retry colocated with Faraday transport
|
|
12
13
|
class FaradayStrategy < Strategy
|
|
13
14
|
##
|
|
14
15
|
# Restores buffered streamed bytes so response middleware can process them.
|
|
@@ -36,7 +37,8 @@ module Html2rss
|
|
|
36
37
|
def perform_execute
|
|
37
38
|
deadline = request_deadline
|
|
38
39
|
@response_guard = ResponseGuard.new(policy: ctx.policy)
|
|
39
|
-
|
|
40
|
+
reset_redirect_tracking!
|
|
41
|
+
raw_response = request_with_terminal_redirect_retry(response_guard, deadline:)
|
|
40
42
|
raw_response = retry_without_streaming(response_guard, deadline:) if retry_without_streaming?(raw_response)
|
|
41
43
|
build_response(raw_response)
|
|
42
44
|
end
|
|
@@ -47,6 +49,45 @@ module Html2rss
|
|
|
47
49
|
monotonic_now + ctx.budget.effective_timeout_seconds(fallback: ctx.policy.total_timeout_seconds)
|
|
48
50
|
end
|
|
49
51
|
|
|
52
|
+
def reset_redirect_tracking!
|
|
53
|
+
@last_redirect_to = nil
|
|
54
|
+
@terminal_redirect_retried = false
|
|
55
|
+
@request_url_override = nil
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def request_with_terminal_redirect_retry(response_guard, deadline:)
|
|
59
|
+
faraday_request(response_guard, deadline:, streaming_buffer: true)
|
|
60
|
+
rescue Faraday::FollowRedirects::RedirectLimitReached => error
|
|
61
|
+
raise error unless terminal_redirect_retryable?
|
|
62
|
+
|
|
63
|
+
retry_from_terminal_redirect!(response_guard, deadline:)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def terminal_redirect_retryable?
|
|
67
|
+
return false if @terminal_redirect_retried || @last_redirect_to.nil?
|
|
68
|
+
|
|
69
|
+
@last_redirect_to.to_s != request_url.to_s
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def retry_from_terminal_redirect!(response_guard, deadline:)
|
|
73
|
+
terminal_url = @last_redirect_to
|
|
74
|
+
@terminal_redirect_retried = true
|
|
75
|
+
Log.debug("#{self.class}: redirect limit reached; retrying once from #{terminal_url}")
|
|
76
|
+
begin_terminal_url_request!(terminal_url)
|
|
77
|
+
faraday_request(response_guard, deadline:, streaming_buffer: true, consume_budget: false)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def begin_terminal_url_request!(terminal_url)
|
|
81
|
+
ctx.policy.validate_request!(url: terminal_url, origin_url: ctx.origin_url, relation: ctx.relation)
|
|
82
|
+
@request_url_override = terminal_url
|
|
83
|
+
@client = nil
|
|
84
|
+
@last_redirect_to = nil
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def request_url
|
|
88
|
+
@request_url_override || ctx.url
|
|
89
|
+
end
|
|
90
|
+
|
|
50
91
|
def build_response(response)
|
|
51
92
|
Response.new(body: response.body, headers: response.headers, url: response_url(response),
|
|
52
93
|
status: response.status)
|
|
@@ -105,7 +146,7 @@ module Html2rss
|
|
|
105
146
|
|
|
106
147
|
# rubocop:disable Metrics/AbcSize
|
|
107
148
|
def client
|
|
108
|
-
@client ||= Faraday.new(url:
|
|
149
|
+
@client ||= Faraday.new(url: request_url.to_s, headers: ctx.headers) do |faraday|
|
|
109
150
|
faraday.use Faraday::FollowRedirects::Middleware, limit: ctx.policy.max_redirects, callback: redirect_callback
|
|
110
151
|
faraday.request :gzip
|
|
111
152
|
faraday.use StreamingBodyMiddleware
|
|
@@ -168,6 +209,7 @@ module Html2rss
|
|
|
168
209
|
lambda do |old_env, new_env|
|
|
169
210
|
from_url = normalize_url(old_env[:url])
|
|
170
211
|
to_url = normalize_url(new_env[:url])
|
|
212
|
+
@last_redirect_to = to_url
|
|
171
213
|
ctx.policy.validate_redirect!(from_url:, to_url:, origin_url: ctx.origin_url, relation: ctx.relation)
|
|
172
214
|
end
|
|
173
215
|
end
|
|
@@ -180,5 +222,6 @@ module Html2rss
|
|
|
180
222
|
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
181
223
|
end
|
|
182
224
|
end
|
|
225
|
+
# rubocop:enable Metrics/ClassLength
|
|
183
226
|
end
|
|
184
227
|
end
|
|
@@ -18,7 +18,7 @@ module Html2rss
|
|
|
18
18
|
connect_timeout_seconds: Integer(ENV.fetch('HTML2RSS_CONNECT_TIMEOUT_SECONDS', 5)),
|
|
19
19
|
read_timeout_seconds: Integer(ENV.fetch('HTML2RSS_READ_TIMEOUT_SECONDS', 10)),
|
|
20
20
|
total_timeout_seconds: Integer(ENV.fetch('HTML2RSS_TOTAL_TIMEOUT_SECONDS', 30)),
|
|
21
|
-
max_redirects:
|
|
21
|
+
max_redirects: 5,
|
|
22
22
|
max_response_bytes: 5_242_880,
|
|
23
23
|
max_decompressed_bytes: 10_485_760,
|
|
24
24
|
max_requests: 1,
|
|
@@ -9,6 +9,8 @@ module Html2rss
|
|
|
9
9
|
class Response
|
|
10
10
|
# Default when a strategy does not attach transport telemetry.
|
|
11
11
|
EMPTY_TRANSPORT_META = {}.freeze
|
|
12
|
+
# Default when a strategy does not capture sub-resource responses.
|
|
13
|
+
EMPTY_CAPTURED_RESPONSES = [].freeze
|
|
12
14
|
|
|
13
15
|
##
|
|
14
16
|
# @param body [String] the body of the response
|
|
@@ -16,7 +18,10 @@ module Html2rss
|
|
|
16
18
|
# @param headers [Hash] the headers of the response
|
|
17
19
|
# @param status [Integer, nil] the HTTP status code when available
|
|
18
20
|
# @param transport_meta [Hash] allowlisted upstream telemetry (frozen when present)
|
|
19
|
-
|
|
21
|
+
# @param captured_responses [Array<Hash>] JSON XHR/fetch bodies captured during browser scrapes
|
|
22
|
+
# rubocop:disable Metrics/MethodLength, Metrics/ParameterLists -- transport + capture fields stay co-located
|
|
23
|
+
def initialize(body:, url:, headers: {}, status: nil, transport_meta: EMPTY_TRANSPORT_META,
|
|
24
|
+
captured_responses: EMPTY_CAPTURED_RESPONSES)
|
|
20
25
|
@body = body
|
|
21
26
|
|
|
22
27
|
headers = headers.dup
|
|
@@ -27,7 +32,13 @@ module Html2rss
|
|
|
27
32
|
@status = status
|
|
28
33
|
@url = url
|
|
29
34
|
@transport_meta = transport_meta.nil? || transport_meta.empty? ? EMPTY_TRANSPORT_META : transport_meta.freeze
|
|
35
|
+
@captured_responses = if captured_responses.nil? || captured_responses.empty?
|
|
36
|
+
EMPTY_CAPTURED_RESPONSES
|
|
37
|
+
else
|
|
38
|
+
captured_responses.freeze
|
|
39
|
+
end
|
|
30
40
|
end
|
|
41
|
+
# rubocop:enable Metrics/MethodLength, Metrics/ParameterLists
|
|
31
42
|
|
|
32
43
|
# @return [String] the raw body of the response
|
|
33
44
|
attr_reader :body
|
|
@@ -44,6 +55,9 @@ module Html2rss
|
|
|
44
55
|
# @return [Hash] allowlisted upstream transport telemetry
|
|
45
56
|
attr_reader :transport_meta
|
|
46
57
|
|
|
58
|
+
# @return [Array<Hash>] captured JSON XHR/fetch responses (empty when unsupported)
|
|
59
|
+
attr_reader :captured_responses
|
|
60
|
+
|
|
47
61
|
# @return [String] normalized content type header value
|
|
48
62
|
def content_type = header('content-type').to_s
|
|
49
63
|
|
|
@@ -137,8 +137,7 @@ module Html2rss
|
|
|
137
137
|
# @return [Boolean]
|
|
138
138
|
def timeout_error?(error)
|
|
139
139
|
error.is_a?(Faraday::TimeoutError) ||
|
|
140
|
-
error.is_a?(Timeout::Error)
|
|
141
|
-
(defined?(Puppeteer::TimeoutError) && error.is_a?(Puppeteer::TimeoutError))
|
|
140
|
+
error.is_a?(Timeout::Error)
|
|
142
141
|
end
|
|
143
142
|
|
|
144
143
|
# @param error [StandardError]
|
|
@@ -6,7 +6,7 @@ require 'forwardable'
|
|
|
6
6
|
module Html2rss
|
|
7
7
|
##
|
|
8
8
|
# Requests website URLs to retrieve their HTML for further processing.
|
|
9
|
-
# Provides concrete transport strategies (e.g. Faraday,
|
|
9
|
+
# Provides concrete transport strategies (e.g. Faraday, Botasaurus).
|
|
10
10
|
#
|
|
11
11
|
# Feed-level +:auto+ is not registered here — {FeedPipeline::StrategyPlan} resolves
|
|
12
12
|
# it to a concrete strategy (or {FeedPipeline::AutoFallback} chain) before execute.
|
|
@@ -23,8 +23,6 @@ module Html2rss
|
|
|
23
23
|
class UnsupportedResponseContentType < Html2rss::Error; end
|
|
24
24
|
# Raised when HTTP request slot limits are exceeded.
|
|
25
25
|
class RequestBudgetExceeded < Html2rss::Error; end
|
|
26
|
-
# Raised when Browserless preload interaction limits are exceeded.
|
|
27
|
-
class InteractionBudgetExceeded < Html2rss::Error; end
|
|
28
26
|
# Raised when policy denies private-network access.
|
|
29
27
|
class PrivateNetworkDenied < Html2rss::Error; end
|
|
30
28
|
# Raised when cross-origin follow-up requests are denied.
|
|
@@ -35,14 +33,12 @@ module Html2rss
|
|
|
35
33
|
class BlockedSurfaceDetected < Html2rss::Error; end
|
|
36
34
|
# Raised when a request times out.
|
|
37
35
|
class RequestTimedOut < Html2rss::Error; end
|
|
38
|
-
# Raised when Browserless configuration is missing or invalid.
|
|
39
|
-
class BrowserlessConfigurationError < Html2rss::Error; end
|
|
40
|
-
# Raised when Browserless cannot be reached.
|
|
41
|
-
class BrowserlessConnectionFailed < Html2rss::Error; end
|
|
42
36
|
# Raised when Botasaurus configuration is missing or invalid.
|
|
43
37
|
class BotasaurusConfigurationError < Html2rss::Error; end
|
|
44
|
-
# Raised when Botasaurus cannot be reached
|
|
38
|
+
# Raised when the Botasaurus service cannot be reached (network / DNS / SSL).
|
|
45
39
|
class BotasaurusConnectionFailed < Html2rss::Error; end
|
|
40
|
+
# Raised when Botasaurus responds but the scrape fails (upstream error, bad payload).
|
|
41
|
+
class BotasaurusServiceError < Html2rss::Error; end
|
|
46
42
|
|
|
47
43
|
class << self
|
|
48
44
|
extend Forwardable
|
|
@@ -62,7 +58,6 @@ module Html2rss
|
|
|
62
58
|
@strategies = {
|
|
63
59
|
faraday: FaradayStrategy,
|
|
64
60
|
botasaurus: BotasaurusStrategy,
|
|
65
|
-
browserless: BrowserlessStrategy,
|
|
66
61
|
local_file: LocalFileStrategy
|
|
67
62
|
}
|
|
68
63
|
@default_strategy_name = :faraday
|
|
@@ -8,21 +8,15 @@ module Html2rss
|
|
|
8
8
|
# Microdata itemprop values treated as publish/update markers.
|
|
9
9
|
PUBLISH_ITEMPROPS = %w[datePublished dateModified].freeze
|
|
10
10
|
|
|
11
|
-
# Matches content-like tokens in class/id strings.
|
|
12
|
-
CONTENT_TOKEN_REGEXP = begin
|
|
13
|
-
words = LinkDestination::PathClassifier::SEGMENT_SETS.fetch(:content)
|
|
14
|
-
/(?:^|\s|[-_])(#{Regexp.union(words.to_a).source})(?:\s|[-_]|$)/i
|
|
15
|
-
end.freeze
|
|
16
|
-
|
|
17
|
-
# Matches utility/junk tokens in class/id strings.
|
|
18
|
-
JUNK_TOKEN_REGEXP = begin
|
|
19
|
-
words = LinkDestination::PathClassifier::SEGMENT_SETS.fetch(:utility)
|
|
20
|
-
/(?:^|\s|[-_])(#{Regexp.union(words.to_a).source})(?:\s|[-_]|$)/i
|
|
21
|
-
end.freeze
|
|
22
|
-
|
|
23
11
|
# @param text_classifier [LinkDestination::TextClassifier]
|
|
24
|
-
|
|
12
|
+
# @param content_token_regexp [Regexp]
|
|
13
|
+
# @param junk_token_regexp [Regexp]
|
|
14
|
+
def initialize(text_classifier: LinkDestination::TextClassifier.new,
|
|
15
|
+
content_token_regexp: LinkResolver::CONTENT_TOKEN_REGEXP,
|
|
16
|
+
junk_token_regexp: LinkResolver::JUNK_TOKEN_REGEXP)
|
|
25
17
|
@text_classifier = text_classifier
|
|
18
|
+
@content_token_regexp = content_token_regexp
|
|
19
|
+
@junk_token_regexp = junk_token_regexp
|
|
26
20
|
end
|
|
27
21
|
|
|
28
22
|
##
|
|
@@ -41,8 +35,8 @@ module Html2rss
|
|
|
41
35
|
publish_marker: publish_marker?(container),
|
|
42
36
|
descriptive_context: descriptive_context?(container.visible_text, title),
|
|
43
37
|
article_container: container.name == :article,
|
|
44
|
-
content_tokens: tokens.match?(
|
|
45
|
-
junk_tokens: tokens.match?(
|
|
38
|
+
content_tokens: tokens.match?(@content_token_regexp),
|
|
39
|
+
junk_tokens: tokens.match?(@junk_token_regexp),
|
|
46
40
|
utility_prefix_title: @text_classifier.utility_prefix?(title),
|
|
47
41
|
recommended_title: @text_classifier.recommended?(title),
|
|
48
42
|
utility_path: destination_facts&.utility_path,
|
|
@@ -10,7 +10,17 @@ module Html2rss
|
|
|
10
10
|
# Minimum composite score retained by {#rank_top} (precision floor).
|
|
11
11
|
SCORE_FLOOR = 0.0
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
FEATURE_IDS = %i[
|
|
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
|
+
].to_set.freeze
|
|
21
|
+
private_constant :FEATURE_IDS
|
|
22
|
+
|
|
23
|
+
# Quality feature triples: [feature_id, predicate, weight].
|
|
14
24
|
QUALITY_RULES = [
|
|
15
25
|
[:title_word_count_ge3, ->(o) { o.title_word_count >= 3 }, 40],
|
|
16
26
|
[:title_word_count_ge7, ->(o) { o.title_word_count >= 7 }, 15],
|
|
@@ -22,7 +32,7 @@ module Html2rss
|
|
|
22
32
|
[:content_tokens, lambda(&:content_tokens), 10]
|
|
23
33
|
].freeze
|
|
24
34
|
|
|
25
|
-
# Junk feature triples: [
|
|
35
|
+
# Junk feature triples: [feature_id, predicate, weight].
|
|
26
36
|
JUNK_RULES = [
|
|
27
37
|
[:non_content_utility_path, ->(o) { o.utility_path && !o.content_path && !o.strong_post_suffix }, 25],
|
|
28
38
|
[:utility_prefix_title_short, ->(o) { o.utility_prefix_title && o.title_word_count <= 6 }, 15],
|
|
@@ -33,7 +43,17 @@ module Html2rss
|
|
|
33
43
|
[:junk_tokens, lambda(&:junk_tokens), 15]
|
|
34
44
|
].freeze
|
|
35
45
|
|
|
36
|
-
|
|
46
|
+
##
|
|
47
|
+
# @param id [Symbol]
|
|
48
|
+
# @return [Symbol]
|
|
49
|
+
# @raise [ArgumentError] when id is not in the closed set
|
|
50
|
+
def self.assert_feature_id!(id)
|
|
51
|
+
raise ArgumentError, "unknown feature: #{id.inspect}" unless FEATURE_IDS.include?(id)
|
|
52
|
+
|
|
53
|
+
id
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
(QUALITY_RULES + JUNK_RULES).each { |feature_id, _, _| assert_feature_id!(feature_id) }
|
|
37
57
|
|
|
38
58
|
# @param link_resolver [LinkResolver]
|
|
39
59
|
def initialize(link_resolver:)
|
|
@@ -78,9 +98,9 @@ module Html2rss
|
|
|
78
98
|
|
|
79
99
|
quality, quality_parts = apply_rules(QUALITY_RULES, obs)
|
|
80
100
|
junk, junk_parts = apply_rules(JUNK_RULES, obs)
|
|
81
|
-
|
|
101
|
+
ranked(
|
|
82
102
|
segment:,
|
|
83
|
-
score:
|
|
103
|
+
score: build_score(
|
|
84
104
|
composite: quality - junk,
|
|
85
105
|
quality:,
|
|
86
106
|
junk:,
|
|
@@ -96,6 +116,30 @@ module Html2rss
|
|
|
96
116
|
end
|
|
97
117
|
[parts.values.sum, parts]
|
|
98
118
|
end
|
|
119
|
+
|
|
120
|
+
def build_score(composite:, quality: nil, junk: nil, breakdown: nil)
|
|
121
|
+
raise ArgumentError, 'composite must be Numeric' unless composite.is_a?(Numeric)
|
|
122
|
+
|
|
123
|
+
Score.new(
|
|
124
|
+
composite: composite.to_f,
|
|
125
|
+
quality: (quality.nil? ? composite : quality).to_f,
|
|
126
|
+
junk: (junk || 0).to_f,
|
|
127
|
+
breakdown: normalize_breakdown(breakdown)
|
|
128
|
+
)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def normalize_breakdown(breakdown)
|
|
132
|
+
return Score::EMPTY_BREAKDOWN if breakdown.nil?
|
|
133
|
+
|
|
134
|
+
breakdown.transform_keys { |id| self.class.assert_feature_id!(id) }.freeze
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def ranked(segment:, score:)
|
|
138
|
+
raise ArgumentError, 'segment must be AutoSource::Segment' unless segment.is_a?(Html2rss::AutoSource::Segment)
|
|
139
|
+
raise ArgumentError, 'score must be Scoring::Score' unless score.is_a?(Score)
|
|
140
|
+
|
|
141
|
+
RankedSegment.new(segment:, score:)
|
|
142
|
+
end
|
|
99
143
|
end
|
|
100
144
|
end
|
|
101
145
|
end
|
|
@@ -8,6 +8,18 @@ module Html2rss
|
|
|
8
8
|
# Captures the href portion before a fragment for memoization keys.
|
|
9
9
|
HREF_BASE_PATTERN = /\A([^#]*)/
|
|
10
10
|
|
|
11
|
+
# Matches content-like tokens in class/id strings (from PathClassifier vocabulary).
|
|
12
|
+
CONTENT_TOKEN_REGEXP = begin
|
|
13
|
+
words = LinkDestination::PathClassifier::SEGMENT_SETS.fetch(:content)
|
|
14
|
+
/(?:^|\s|[-_])(#{Regexp.union(words.to_a).source})(?:\s|[-_]|$)/i
|
|
15
|
+
end.freeze
|
|
16
|
+
|
|
17
|
+
# Matches utility/junk tokens in class/id strings (from PathClassifier vocabulary).
|
|
18
|
+
JUNK_TOKEN_REGEXP = begin
|
|
19
|
+
words = LinkDestination::PathClassifier::SEGMENT_SETS.fetch(:utility)
|
|
20
|
+
/(?:^|\s|[-_])(#{Regexp.union(words.to_a).source})(?:\s|[-_]|$)/i
|
|
21
|
+
end.freeze
|
|
22
|
+
|
|
11
23
|
# @param base_url [String, Html2rss::Url]
|
|
12
24
|
def initialize(base_url)
|
|
13
25
|
@base_url = base_url
|