html2rss 0.24.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/html.rb +68 -145
- data/lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb +116 -0
- data/lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb +63 -0
- data/lib/html2rss/auto_source/scraper/json_state/document_scanner.rb +179 -0
- data/lib/html2rss/auto_source/scraper/json_state/value_finder.rb +55 -0
- data/lib/html2rss/auto_source/scraper/json_state.rb +31 -401
- data/lib/html2rss/auto_source/scraper/schema/item_list.rb +2 -14
- data/lib/html2rss/auto_source/scraper/semantic_html/entry_deduplicator.rb +35 -30
- data/lib/html2rss/auto_source/scraper/semantic_html.rb +40 -120
- data/lib/html2rss/auto_source/scraper/sitemap/parser.rb +161 -0
- data/lib/html2rss/auto_source/scraper/sitemap.rb +10 -7
- data/lib/html2rss/auto_source/scraper/wordpress_api.rb +6 -2
- data/lib/html2rss/auto_source/scraper/xhr_articles.rb +69 -0
- data/lib/html2rss/auto_source/scraper.rb +109 -53
- data/lib/html2rss/auto_source/segment.rb +29 -0
- data/lib/html2rss/auto_source/segmenter/cluster.rb +163 -0
- data/lib/html2rss/auto_source/segmenter/list.rb +103 -0
- data/lib/html2rss/auto_source/segmenter/primary_link.rb +80 -0
- data/lib/html2rss/auto_source/segmenter/semantic.rb +104 -0
- data/lib/html2rss/auto_source/segmenter.rb +95 -0
- data/lib/html2rss/auto_source.rb +79 -21
- data/lib/html2rss/capture.rb +315 -0
- data/lib/html2rss/cli.rb +72 -18
- data/lib/html2rss/config/auto_source_contract.rb +5 -1
- data/lib/html2rss/config/request_controls.rb +33 -0
- data/lib/html2rss/config/schema.rb +67 -26
- data/lib/html2rss/config/selectors_validator.rb +60 -21
- data/lib/html2rss/config/validator.rb +33 -35
- data/lib/html2rss/config.rb +6 -2
- data/lib/html2rss/error.rb +15 -3
- data/lib/html2rss/feed_builder/rss.rb +17 -5
- 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 +11 -0
- data/lib/html2rss/feed_result.rb +1 -1
- data/lib/html2rss/hash_util.rb +17 -0
- data/lib/html2rss/html/article_extractor/category_extractor.rb +10 -36
- data/lib/html2rss/html/article_extractor/date_extractor.rb +2 -7
- data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +5 -50
- data/lib/html2rss/html/article_extractor/image_extractor.rb +5 -16
- data/lib/html2rss/html/article_rules/category.rb +55 -0
- data/lib/html2rss/html/article_rules/date.rb +25 -0
- data/lib/html2rss/html/article_rules/enclosure.rb +72 -0
- data/lib/html2rss/html/article_rules/image.rb +109 -0
- data/lib/html2rss/html/article_rules.rb +10 -0
- data/lib/html2rss/html/rendering/audio_renderer.rb +2 -12
- data/lib/html2rss/html/rendering/escaped_attributes.rb +27 -0
- data/lib/html2rss/html/rendering/image_renderer.rb +2 -12
- data/lib/html2rss/html/rendering/pdf_renderer.rb +2 -8
- data/lib/html2rss/html/rendering/video_renderer.rb +2 -12
- data/lib/html2rss/html/sst_article_extractor.rb +308 -0
- data/lib/html2rss/link_destination/destination_facts.rb +40 -0
- data/lib/html2rss/link_destination/noise_policy.rb +68 -0
- data/lib/html2rss/link_destination/path_classifier.rb +208 -0
- data/lib/html2rss/link_destination/text_classifier.rb +64 -0
- data/lib/html2rss/link_destination.rb +8 -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 +87 -3
- data/lib/html2rss/request_service/network_guard.rb +5 -3
- 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/cluster_scorer.rb +87 -0
- data/lib/html2rss/scoring/container_assessor.rb +77 -0
- data/lib/html2rss/scoring/engine.rb +145 -0
- data/lib/html2rss/scoring/link_resolver.rb +84 -0
- data/lib/html2rss/scoring/observation.rb +53 -0
- data/lib/html2rss/scoring/ranked_segment.rb +33 -0
- data/lib/html2rss/scoring/score.rb +11 -0
- data/lib/html2rss/scoring.rb +8 -0
- 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 +23 -1
- 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/selectors.rb +0 -20
- data/lib/html2rss/sst/attrs.rb +91 -0
- data/lib/html2rss/sst/document.rb +23 -0
- data/lib/html2rss/sst/index.rb +112 -0
- data/lib/html2rss/sst/node.rb +147 -0
- data/lib/html2rss/sst/normalizer.rb +171 -0
- data/lib/html2rss/sst/tags.rb +26 -0
- data/lib/html2rss/sst/text.rb +81 -0
- data/lib/html2rss/sst.rb +8 -0
- data/lib/html2rss/url.rb +11 -7
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss.rb +57 -26
- data/schema/html2rss-config.schema.json +428 -85
- metadata +93 -27
- data/lib/html2rss/auto_source/discovery/dom_clustering/group_scorer.rb +0 -80
- data/lib/html2rss/auto_source/discovery/dom_clustering/overlap_resolver.rb +0 -86
- data/lib/html2rss/auto_source/discovery/dom_clustering.rb +0 -119
- data/lib/html2rss/auto_source/discovery/list_candidates.rb +0 -94
- data/lib/html2rss/auto_source/discovery/semantic_anchor_candidates.rb +0 -219
- data/lib/html2rss/auto_source/discovery/semantic_containers.rb +0 -71
- data/lib/html2rss/auto_source/discovery/sitemap.rb +0 -159
- data/lib/html2rss/auto_source/discovery.rb +0 -14
- data/lib/html2rss/auto_source/link_heuristics/anchor_signals.rb +0 -28
- data/lib/html2rss/auto_source/link_heuristics/container_assessor.rb +0 -106
- data/lib/html2rss/auto_source/link_heuristics/container_signals.rb +0 -80
- data/lib/html2rss/auto_source/link_heuristics/destination_facts.rb +0 -42
- data/lib/html2rss/auto_source/link_heuristics/href_extractor.rb +0 -38
- data/lib/html2rss/auto_source/link_heuristics/path_classifier.rb +0 -221
- data/lib/html2rss/auto_source/link_heuristics/text_classifier.rb +0 -66
- data/lib/html2rss/auto_source/link_heuristics.rb +0 -139
- 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
|
@@ -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)
|
|
@@ -66,14 +107,55 @@ module Html2rss
|
|
|
66
107
|
faraday_request(response_guard, deadline:, streaming_buffer: false, consume_budget: false)
|
|
67
108
|
end
|
|
68
109
|
|
|
110
|
+
##
|
|
111
|
+
# Validates the remote socket peer IP during Net::HTTP start.
|
|
112
|
+
module PeerIpValidator
|
|
113
|
+
module_function
|
|
114
|
+
|
|
115
|
+
# @param http [Net::HTTP] connection to configure
|
|
116
|
+
# @param policy [Policy] request policy
|
|
117
|
+
# @return [void]
|
|
118
|
+
def install!(http, policy:)
|
|
119
|
+
orig_start = http.method(:start)
|
|
120
|
+
http.define_singleton_method(:start) do |&block|
|
|
121
|
+
orig_start.call do |opened_http|
|
|
122
|
+
PeerIpValidator.validate!(opened_http, policy:)
|
|
123
|
+
block.call(opened_http)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# @param opened_http [Net::HTTP] active connection
|
|
129
|
+
# @param policy [Policy] request policy
|
|
130
|
+
# @return [void]
|
|
131
|
+
def validate!(opened_http, policy:)
|
|
132
|
+
scheme = opened_http.use_ssl? ? 'https' : 'http'
|
|
133
|
+
url = Html2rss::Url.from_absolute("#{scheme}://#{opened_http.address}:#{opened_http.port}")
|
|
134
|
+
policy.validate_remote_ip!(ip: peer_ip_for(opened_http), url:)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# @param opened_http [Net::HTTP]
|
|
138
|
+
# @return [String, nil]
|
|
139
|
+
def peer_ip_for(opened_http)
|
|
140
|
+
sock = opened_http.instance_variable_get(:@socket)
|
|
141
|
+
io = sock.respond_to?(:io) ? sock.io : sock
|
|
142
|
+
peeraddr = io.respond_to?(:peeraddr) ? io.peeraddr : nil
|
|
143
|
+
peeraddr&.[](3) || peeraddr&.[](2)
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# rubocop:disable Metrics/AbcSize
|
|
69
148
|
def client
|
|
70
|
-
@client ||= Faraday.new(url:
|
|
149
|
+
@client ||= Faraday.new(url: request_url.to_s, headers: ctx.headers) do |faraday|
|
|
71
150
|
faraday.use Faraday::FollowRedirects::Middleware, limit: ctx.policy.max_redirects, callback: redirect_callback
|
|
72
151
|
faraday.request :gzip
|
|
73
152
|
faraday.use StreamingBodyMiddleware
|
|
74
|
-
faraday.adapter Faraday.default_adapter
|
|
153
|
+
faraday.adapter Faraday.default_adapter do |http|
|
|
154
|
+
PeerIpValidator.install!(http, policy: ctx.policy)
|
|
155
|
+
end
|
|
75
156
|
end
|
|
76
157
|
end
|
|
158
|
+
# rubocop:enable Metrics/AbcSize
|
|
77
159
|
|
|
78
160
|
def apply_timeouts(request, deadline:)
|
|
79
161
|
remaining_timeout = remaining_timeout_seconds(deadline)
|
|
@@ -127,6 +209,7 @@ module Html2rss
|
|
|
127
209
|
lambda do |old_env, new_env|
|
|
128
210
|
from_url = normalize_url(old_env[:url])
|
|
129
211
|
to_url = normalize_url(new_env[:url])
|
|
212
|
+
@last_redirect_to = to_url
|
|
130
213
|
ctx.policy.validate_redirect!(from_url:, to_url:, origin_url: ctx.origin_url, relation: ctx.relation)
|
|
131
214
|
end
|
|
132
215
|
end
|
|
@@ -139,5 +222,6 @@ module Html2rss
|
|
|
139
222
|
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
140
223
|
end
|
|
141
224
|
end
|
|
225
|
+
# rubocop:enable Metrics/ClassLength
|
|
142
226
|
end
|
|
143
227
|
end
|
|
@@ -48,7 +48,10 @@ module Html2rss
|
|
|
48
48
|
def enforce_public_network!(url)
|
|
49
49
|
host = url.host
|
|
50
50
|
return if allow_private_networks?
|
|
51
|
-
|
|
51
|
+
raise PrivateNetworkDenied, "Private network target denied for #{url}" if blocked_host?(host)
|
|
52
|
+
|
|
53
|
+
addresses = resolved_ip_addresses(host)
|
|
54
|
+
return unless addresses.empty? || addresses.any? { |address| blocked_ip?(address) }
|
|
52
55
|
|
|
53
56
|
raise PrivateNetworkDenied, "Private network target denied for #{url}"
|
|
54
57
|
end
|
|
@@ -62,9 +65,8 @@ module Html2rss
|
|
|
62
65
|
# @raise [PrivateNetworkDenied] if the response came from a blocked address
|
|
63
66
|
def validate_remote_ip!(ip:, url:)
|
|
64
67
|
return if allow_private_networks?
|
|
65
|
-
return if ip.nil? || ip.empty?
|
|
66
68
|
|
|
67
|
-
parsed_ip = parse_ip(ip)
|
|
69
|
+
parsed_ip = ip && !ip.to_s.empty? ? parse_ip(ip) : nil
|
|
68
70
|
raise PrivateNetworkDenied, "Remote IP could not be validated for #{url}" unless parsed_ip
|
|
69
71
|
return unless blocked_ip?(parsed_ip)
|
|
70
72
|
|
|
@@ -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
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
module Scoring
|
|
5
|
+
##
|
|
6
|
+
# Scores DomClustering-style node groups (heading/time/date/word density).
|
|
7
|
+
# Sole home for cluster-group ranking weights.
|
|
8
|
+
class ClusterScorer
|
|
9
|
+
# Minimum average visible words before a group is eligible.
|
|
10
|
+
MIN_AVG_WORDS = 5
|
|
11
|
+
# Bonus when any node has a heading or bold title class.
|
|
12
|
+
HEADING_BONUS = 20
|
|
13
|
+
# Bonus when any node has a time/datetime marker.
|
|
14
|
+
TIME_BONUS = 20
|
|
15
|
+
# Bonus when any node has date-like visible text.
|
|
16
|
+
DATE_BONUS = 40
|
|
17
|
+
|
|
18
|
+
def initialize
|
|
19
|
+
@text_words = {}.compare_by_identity
|
|
20
|
+
@has_date = {}.compare_by_identity
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
##
|
|
24
|
+
# @param groups [Hash{String => Array<SST::Node>}]
|
|
25
|
+
# @return [Array<SST::Node>]
|
|
26
|
+
def select_best_group(groups)
|
|
27
|
+
best_nodes = []
|
|
28
|
+
best_score = -1
|
|
29
|
+
|
|
30
|
+
groups.each_value do |nodes|
|
|
31
|
+
score = score_group(nodes)
|
|
32
|
+
next if score.negative?
|
|
33
|
+
|
|
34
|
+
(best_nodes = nodes) && (best_score = score) if score > best_score
|
|
35
|
+
end
|
|
36
|
+
best_nodes
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
##
|
|
40
|
+
# @param nodes [Array<SST::Node>]
|
|
41
|
+
# @return [Float]
|
|
42
|
+
def avg_words(nodes)
|
|
43
|
+
nodes.sum { |n| text_words(n) } / nodes.size.to_f
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def score_group(nodes)
|
|
49
|
+
avg_w = avg_words(nodes)
|
|
50
|
+
return -1 if avg_w < MIN_AVG_WORDS
|
|
51
|
+
|
|
52
|
+
score = nodes.size + (avg_w / 5.0)
|
|
53
|
+
score += HEADING_BONUS if nodes_heading?(nodes)
|
|
54
|
+
score += TIME_BONUS if nodes_time?(nodes)
|
|
55
|
+
score += DATE_BONUS if nodes_date?(nodes)
|
|
56
|
+
score
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def nodes_heading?(nodes)
|
|
60
|
+
nodes.any? do |n|
|
|
61
|
+
n.find(&:heading?) ||
|
|
62
|
+
n.attrs.class_names.any? { |c| c.match?(/font-bold|font-semibold/) }
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def nodes_time?(nodes)
|
|
67
|
+
nodes.any? { |n| n.find { |d| d.name == :time || d.attrs.datetime } }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def nodes_date?(nodes)
|
|
71
|
+
nodes.any? { |n| date?(n) }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def text_words(node)
|
|
75
|
+
@text_words[node] ||= node.word_count
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def date?(node)
|
|
79
|
+
@has_date[node] ||= begin
|
|
80
|
+
text = node.visible_text.to_s
|
|
81
|
+
text.match?(%r{\b\d{4}[-/]\d{2}[-/]\d{2}\b}) ||
|
|
82
|
+
text.match?(/\b(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\b/i)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|