html2rss 0.27.1 → 0.28.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/lib/html2rss/auto_source/README.md +4 -1
- data/lib/html2rss/auto_source/scraper/native_feed.rb +90 -0
- data/lib/html2rss/auto_source/scraper.rb +4 -1
- data/lib/html2rss/auto_source.rb +7 -0
- data/lib/html2rss/config/auto_source_contract.rb +7 -0
- data/lib/html2rss/config.rb +10 -1
- data/lib/html2rss/feed_pipeline/README.md +2 -0
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +53 -13
- data/lib/html2rss/feed_pipeline/runtime_policy.rb +1 -0
- data/lib/html2rss/feed_pipeline.rb +24 -8
- data/lib/html2rss/feed_resolution/README.md +52 -0
- data/lib/html2rss/feed_resolution/candidate_generator.rb +139 -0
- data/lib/html2rss/feed_resolution/diag.rb +32 -0
- data/lib/html2rss/feed_resolution/options.rb +31 -0
- data/lib/html2rss/feed_resolution/policy.rb +47 -0
- data/lib/html2rss/feed_resolution/probe.rb +59 -0
- data/lib/html2rss/feed_resolution/scorer.rb +49 -0
- data/lib/html2rss/feed_resolution.rb +274 -0
- data/lib/html2rss/mcp/inspect.rb +18 -121
- data/lib/html2rss/mcp/outcome.rb +4 -2
- data/lib/html2rss/page_recon.rb +243 -0
- data/lib/html2rss/request_service/botasaurus_contract.rb +24 -47
- data/lib/html2rss/request_service/botasaurus_strategy.rb +34 -12
- data/lib/html2rss/request_service/response.rb +26 -2
- data/lib/html2rss/request_service.rb +13 -1
- data/lib/html2rss/request_session.rb +5 -2
- data/lib/html2rss/scrape_target.rb +24 -0
- data/lib/html2rss/status.rb +49 -22
- data/lib/html2rss/surface_category.rb +63 -0
- data/lib/html2rss/syndication/README.md +21 -0
- data/lib/html2rss/syndication/candidate_catalog.rb +26 -0
- data/lib/html2rss/syndication/discovery.rb +217 -0
- data/lib/html2rss/syndication/parser.rb +137 -0
- data/lib/html2rss/syndication.rb +10 -0
- data/lib/html2rss/url.rb +22 -0
- data/lib/html2rss/version.rb +1 -1
- data/schema/html2rss-config.schema.json +39 -1
- metadata +19 -2
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
##
|
|
5
|
+
# Shared page recon for MCP Inspect and FeedResolution probes.
|
|
6
|
+
#
|
|
7
|
+
# Owns surface class, native feed hints, segment stats, and a cheap AutoSource
|
|
8
|
+
# article count — not request strategy selection or MCP next-step policy.
|
|
9
|
+
class PageRecon # rubocop:disable Metrics/ClassLength -- recon bag stays co-located
|
|
10
|
+
##
|
|
11
|
+
# Cheap surface + admission facts shared by AutoFallback gates and FeedResolution probes.
|
|
12
|
+
Assessment = Data.define(:surface_category, :articles_count, :admission_drops, :html_response) do
|
|
13
|
+
##
|
|
14
|
+
# @return [Html2rss::SurfaceCategory]
|
|
15
|
+
def category = SurfaceCategory.coerce(surface_category)
|
|
16
|
+
|
|
17
|
+
##
|
|
18
|
+
# @return [Boolean]
|
|
19
|
+
def weak? = category.weak?
|
|
20
|
+
|
|
21
|
+
##
|
|
22
|
+
# @return [Boolean]
|
|
23
|
+
def blocked? = category.blocked?
|
|
24
|
+
|
|
25
|
+
##
|
|
26
|
+
# @return [Boolean]
|
|
27
|
+
def listing_bonus? = category.listing_bonus?
|
|
28
|
+
end
|
|
29
|
+
##
|
|
30
|
+
# Recon facts used by Inspect and FeedResolution.
|
|
31
|
+
Result = Data.define(
|
|
32
|
+
:requested_url,
|
|
33
|
+
:final_url,
|
|
34
|
+
:status,
|
|
35
|
+
:scheme_downgrade,
|
|
36
|
+
:alternate_feeds,
|
|
37
|
+
:surface_category,
|
|
38
|
+
:articles_count,
|
|
39
|
+
:admission_drops,
|
|
40
|
+
:segment_stats,
|
|
41
|
+
:html_response,
|
|
42
|
+
:content_type,
|
|
43
|
+
:blocked_surface,
|
|
44
|
+
:sst
|
|
45
|
+
) do
|
|
46
|
+
##
|
|
47
|
+
# @return [Hash{Symbol => Object}]
|
|
48
|
+
def to_h # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- omit-empty optional keys
|
|
49
|
+
{
|
|
50
|
+
requested_url:,
|
|
51
|
+
final_url:,
|
|
52
|
+
status:,
|
|
53
|
+
scheme_downgrade:,
|
|
54
|
+
alternate_feeds:,
|
|
55
|
+
surface_category:,
|
|
56
|
+
articles_count:,
|
|
57
|
+
html_response:,
|
|
58
|
+
content_type:,
|
|
59
|
+
**(admission_drops.any? ? { admission_drops: } : {}),
|
|
60
|
+
**(segment_stats ? { segment_stats: } : {}),
|
|
61
|
+
**(blocked_surface ? { blocked_surface: } : {}),
|
|
62
|
+
**(sst ? { sst: } : {})
|
|
63
|
+
}
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
##
|
|
68
|
+
# @param response [Html2rss::RequestService::Response]
|
|
69
|
+
# @param url [String, Html2rss::Url] requested entry URL
|
|
70
|
+
# @param strategy [Symbol, nil] unused (reserved for callers that already chose a strategy)
|
|
71
|
+
# @return [Result]
|
|
72
|
+
def self.call(response:, url:, strategy: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
73
|
+
new(response:, url:).call
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
##
|
|
77
|
+
# Cheap page assessment for policy gates and probe scoring (fixed AutoSource limit).
|
|
78
|
+
#
|
|
79
|
+
# @param response [Html2rss::RequestService::Response]
|
|
80
|
+
# @param url [String, Html2rss::Url]
|
|
81
|
+
# @return [Assessment]
|
|
82
|
+
def self.assess(response:, url:)
|
|
83
|
+
new(response:, url:).assess
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
##
|
|
87
|
+
# Surface class only — no AutoSource extract (for empty-extract error labels).
|
|
88
|
+
#
|
|
89
|
+
# @param response [Html2rss::RequestService::Response]
|
|
90
|
+
# @param url [String, Html2rss::Url]
|
|
91
|
+
# @return [Symbol]
|
|
92
|
+
def self.surface_category_for(response:, url:)
|
|
93
|
+
new(response:, url:).surface_category_for
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
##
|
|
97
|
+
# @param sst [Html2rss::SST::Document]
|
|
98
|
+
# @param url [String, Html2rss::Url]
|
|
99
|
+
# @return [Array]
|
|
100
|
+
def self.discover_segments(sst, url)
|
|
101
|
+
link_resolver = Scoring::LinkResolver.new(url)
|
|
102
|
+
AutoSource::Segmenter.call(sst, base_url: url, strategy: :list, link_resolver:)
|
|
103
|
+
rescue StandardError
|
|
104
|
+
[]
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
##
|
|
108
|
+
# @param response [Html2rss::RequestService::Response]
|
|
109
|
+
# @param url [String, Html2rss::Url]
|
|
110
|
+
def initialize(response:, url:)
|
|
111
|
+
@response = response
|
|
112
|
+
@url = url
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
##
|
|
116
|
+
# @return [Assessment]
|
|
117
|
+
def assess
|
|
118
|
+
return feed_assessment if response.feed_response?
|
|
119
|
+
|
|
120
|
+
parsed = html_parsed_body
|
|
121
|
+
articles_count, admission_drops = cheap_articles
|
|
122
|
+
Assessment.new(
|
|
123
|
+
surface_category: surface_category(parsed),
|
|
124
|
+
articles_count:,
|
|
125
|
+
admission_drops:,
|
|
126
|
+
html_response: true
|
|
127
|
+
)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
##
|
|
131
|
+
# @return [Symbol]
|
|
132
|
+
def surface_category_for
|
|
133
|
+
return :unsupported_surface if response.feed_response?
|
|
134
|
+
|
|
135
|
+
surface_category(html_parsed_body)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
##
|
|
139
|
+
# @return [Result]
|
|
140
|
+
def call # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- assemble recon Result
|
|
141
|
+
requested = Url.from_absolute(url)
|
|
142
|
+
final = response.url
|
|
143
|
+
parsed = html_parsed_body
|
|
144
|
+
assessment = assess
|
|
145
|
+
sst_payload, segment_stats = sst_payload_and_segments(requested)
|
|
146
|
+
|
|
147
|
+
Result.new(
|
|
148
|
+
requested_url: requested.to_s,
|
|
149
|
+
final_url: final.to_s,
|
|
150
|
+
status: response.status,
|
|
151
|
+
scheme_downgrade: scheme_downgrade?(requested, final),
|
|
152
|
+
alternate_feeds: alternate_feeds_from(parsed),
|
|
153
|
+
surface_category: assessment.surface_category,
|
|
154
|
+
articles_count: assessment.articles_count,
|
|
155
|
+
admission_drops: assessment.admission_drops,
|
|
156
|
+
segment_stats:,
|
|
157
|
+
html_response: response.html_response?,
|
|
158
|
+
content_type: response.content_type,
|
|
159
|
+
blocked_surface: blocked_surface_key,
|
|
160
|
+
sst: sst_payload
|
|
161
|
+
)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
private
|
|
165
|
+
|
|
166
|
+
attr_reader :response, :url
|
|
167
|
+
|
|
168
|
+
def feed_assessment
|
|
169
|
+
Assessment.new(
|
|
170
|
+
surface_category: :unsupported_surface,
|
|
171
|
+
articles_count: 0,
|
|
172
|
+
admission_drops: {},
|
|
173
|
+
html_response: false
|
|
174
|
+
)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def html_parsed_body
|
|
178
|
+
return unless response.html_response?
|
|
179
|
+
|
|
180
|
+
response.parsed_body
|
|
181
|
+
rescue RequestService::UnsupportedResponseContentType
|
|
182
|
+
nil
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def surface_category(parsed)
|
|
186
|
+
return :unsupported_surface unless parsed
|
|
187
|
+
|
|
188
|
+
AutoSource::Scraper.classify_no_scraper_surface(parsed, body: response.body)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def cheap_articles
|
|
192
|
+
return [0, {}] unless response.html_response?
|
|
193
|
+
|
|
194
|
+
source = AutoSource.new(response, AutoSource::DEFAULT_CONFIG.merge(limit: 10))
|
|
195
|
+
[source.articles.size, source.admission_drops]
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def sst_payload_and_segments(requested)
|
|
199
|
+
return [nil, nil] unless response.html_response?
|
|
200
|
+
|
|
201
|
+
sst = sst_document
|
|
202
|
+
return [nil, nil] unless sst
|
|
203
|
+
|
|
204
|
+
stats = segment_stats(sst, requested)
|
|
205
|
+
[
|
|
206
|
+
{ node_count: sst.node_count, degraded: sst.degraded, segment_stats: stats },
|
|
207
|
+
stats
|
|
208
|
+
]
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def sst_document
|
|
212
|
+
Html2rss::SST::Normalizer.call(response.body)
|
|
213
|
+
rescue ArgumentError
|
|
214
|
+
nil
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def segment_stats(sst, page_url)
|
|
218
|
+
segments = self.class.discover_segments(sst, page_url)
|
|
219
|
+
return { found: 0 } if segments.empty?
|
|
220
|
+
|
|
221
|
+
{
|
|
222
|
+
found: segments.size,
|
|
223
|
+
strategies: segments.map(&:strategy).uniq,
|
|
224
|
+
sample_paths: segments.first(5).map { |s| s.root_node.tag_path }
|
|
225
|
+
}
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def scheme_downgrade?(requested, final)
|
|
229
|
+
requested.scheme == 'https' && final.scheme == 'http'
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def alternate_feeds_from(parsed)
|
|
233
|
+
return [] unless parsed.is_a?(Nokogiri::HTML::Document)
|
|
234
|
+
|
|
235
|
+
Html::FeedLink.from_document(parsed).map { |link| { href: link.href, mime_type: link.mime_type } }
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def blocked_surface_key
|
|
239
|
+
blocked = RequestService::BlockedSurface.interstitial_signature_for(response.body)
|
|
240
|
+
blocked[:key].to_s if blocked
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
end
|
|
@@ -7,7 +7,7 @@ module Html2rss
|
|
|
7
7
|
##
|
|
8
8
|
# Maps html2rss request/response handling to botasaurus-scrape-api OpenAPI 2.0
|
|
9
9
|
# +ScrapeRequest+ / +ScrapeSuccess+ / +ScrapeError+ (sibling +openapi.yaml+).
|
|
10
|
-
class BotasaurusContract
|
|
10
|
+
class BotasaurusContract
|
|
11
11
|
# Closed set from OpenAPI ExecutionMode.
|
|
12
12
|
EXECUTION_MODES = %w[auto request browser].freeze
|
|
13
13
|
# Closed set from OpenAPI NavigationMode.
|
|
@@ -25,27 +25,28 @@ module Html2rss
|
|
|
25
25
|
# OpenAPI WindowSize required keys (positive integers).
|
|
26
26
|
WINDOW_SIZE_PROPERTIES = %i[width height].freeze
|
|
27
27
|
|
|
28
|
-
#
|
|
28
|
+
# Faraday POST /scrape cap mirroring botasaurus-scrape-api total scrape wall (`SCRAPE_TIMEOUT_SECONDS`).
|
|
29
|
+
SCRAPE_TIMEOUT_SECONDS = Integer(ENV.fetch('BOTASAURUS_SCRAPE_TIMEOUT_SECONDS', 45))
|
|
30
|
+
# Post-boot navigate/wait budget mirrored from scrape-api (`SCRAPE_WORK_TIMEOUT_SECONDS`).
|
|
31
|
+
SCRAPE_WORK_TIMEOUT_SECONDS = Integer(ENV.fetch('BOTASAURUS_SCRAPE_WORK_TIMEOUT_SECONDS', 30))
|
|
32
|
+
# Seconds added to the scrape total for client transport overhead.
|
|
33
|
+
TRANSPORT_BUFFER_SECONDS = 2
|
|
34
|
+
|
|
35
|
+
# Published wait clamp floor (`[1, SCRAPE_WORK_TIMEOUT_SECONDS]` in OpenAPI description).
|
|
29
36
|
MIN_WAIT_TIMEOUT_SECONDS = 1
|
|
30
37
|
# Published wait clamp ceiling; YAML values above this are rejected.
|
|
31
|
-
MAX_WAIT_TIMEOUT_SECONDS =
|
|
38
|
+
MAX_WAIT_TIMEOUT_SECONDS = SCRAPE_WORK_TIMEOUT_SECONDS
|
|
32
39
|
# OpenAPI wait_timeout_seconds default (omitted from the client payload).
|
|
33
|
-
DEFAULT_WAIT_TIMEOUT_SECONDS = 15
|
|
40
|
+
DEFAULT_WAIT_TIMEOUT_SECONDS = [15, SCRAPE_WORK_TIMEOUT_SECONDS].min
|
|
34
41
|
|
|
35
42
|
# OpenAPI max_retries maximum (default 2 is applied upstream when omitted).
|
|
36
43
|
MAX_RETRIES = 3
|
|
37
44
|
|
|
38
45
|
# Allowlisted ScrapeDiagnostics keys nested under diagnostics.
|
|
39
|
-
DIAGNOSTICS_KEYS = %w[request_id attempts strategy_used render_ms execution_tier challenge].freeze
|
|
46
|
+
DIAGNOSTICS_KEYS = %w[request_id attempts strategy_used render_ms execution_tier challenge timeout_phase].freeze
|
|
40
47
|
# Allowlisted ChallengeSignal keys nested under diagnostics.challenge.
|
|
41
48
|
CHALLENGE_KEYS = %w[blocked detected marker].freeze
|
|
42
49
|
|
|
43
|
-
# Remaining seconds at or below which Botasaurus retries are disabled.
|
|
44
|
-
TIGHT_BUDGET_SECONDS = 12
|
|
45
|
-
|
|
46
|
-
# Seconds reserved from remaining budget before setting wait_timeout_seconds.
|
|
47
|
-
BUDGET_WAIT_RESERVE_SECONDS = 2
|
|
48
|
-
|
|
49
50
|
##
|
|
50
51
|
# Parsed OpenAPI ScrapeSuccess envelope (HTTP 200).
|
|
51
52
|
class Success
|
|
@@ -184,10 +185,17 @@ module Html2rss
|
|
|
184
185
|
'Botasaurus challenge block detected.'
|
|
185
186
|
end
|
|
186
187
|
|
|
188
|
+
# @return [String, nil] scrape-api timeout stage when present on diagnostics
|
|
189
|
+
def timeout_phase
|
|
190
|
+
value = diagnostics['timeout_phase']
|
|
191
|
+
value.is_a?(String) && !value.empty? ? value : nil
|
|
192
|
+
end
|
|
193
|
+
|
|
187
194
|
# @return [String] actionable upstream failure summary
|
|
188
195
|
def failure_message
|
|
189
196
|
details = ["status=#{transport_status}", "error_category=#{error_category}", "error=#{error}"]
|
|
190
197
|
details << "request_id=#{request_id}" if request_id
|
|
198
|
+
details << "timeout_phase=#{timeout_phase}" if timeout_phase
|
|
191
199
|
"Botasaurus scrape failed (#{details.join(', ')})."
|
|
192
200
|
end
|
|
193
201
|
|
|
@@ -246,7 +254,6 @@ module Html2rss
|
|
|
246
254
|
# @param url [Html2rss::Url] canonical URL to scrape
|
|
247
255
|
# @param headers [Hash] request headers from context
|
|
248
256
|
# @param options [Hash] validated request.botasaurus options
|
|
249
|
-
# @param remaining_timeout_seconds [Numeric, nil] shared request budget remainder for clamps
|
|
250
257
|
# @option options [String] :execution_mode
|
|
251
258
|
# @option options [String] :navigation_mode
|
|
252
259
|
# @option options [Integer] :max_retries
|
|
@@ -264,19 +271,19 @@ module Html2rss
|
|
|
264
271
|
# @option options [String] :lang
|
|
265
272
|
# @option options [Hash] :cookies
|
|
266
273
|
# @option options [Hash] :headers
|
|
267
|
-
def initialize(url:, headers: {}, options: {}
|
|
274
|
+
def initialize(url:, headers: {}, options: {})
|
|
268
275
|
@url = url
|
|
269
276
|
@headers = headers
|
|
270
277
|
@options = options
|
|
271
|
-
@remaining_timeout_seconds = remaining_timeout_seconds
|
|
272
278
|
end
|
|
273
279
|
|
|
274
|
-
# @return [Hash] payload for POST /scrape (explicit options plus
|
|
280
|
+
# @return [Hash] payload for POST /scrape (explicit options plus wait cap)
|
|
275
281
|
def request_payload
|
|
276
282
|
payload = { url: url.to_s }.merge(filtered_options)
|
|
277
283
|
forwarded_headers = merged_headers
|
|
278
284
|
payload[:headers] = forwarded_headers if forwarded_headers&.any?
|
|
279
|
-
|
|
285
|
+
cap_wait_timeout!(payload)
|
|
286
|
+
payload
|
|
280
287
|
end
|
|
281
288
|
|
|
282
289
|
# @param transport_response [Faraday::Response] upstream HTTP response
|
|
@@ -291,7 +298,7 @@ module Html2rss
|
|
|
291
298
|
|
|
292
299
|
private
|
|
293
300
|
|
|
294
|
-
attr_reader :url, :headers, :options
|
|
301
|
+
attr_reader :url, :headers, :options
|
|
295
302
|
|
|
296
303
|
def json_object(body)
|
|
297
304
|
payload = JSON.parse(body)
|
|
@@ -315,36 +322,6 @@ module Html2rss
|
|
|
315
322
|
end
|
|
316
323
|
end
|
|
317
324
|
|
|
318
|
-
def clamp_for_budget(payload)
|
|
319
|
-
remaining = remaining_timeout_seconds
|
|
320
|
-
clamped = payload.dup
|
|
321
|
-
unless remaining.nil?
|
|
322
|
-
apply_tight_retries!(clamped, remaining)
|
|
323
|
-
apply_budget_wait!(clamped, remaining)
|
|
324
|
-
end
|
|
325
|
-
cap_wait_timeout!(clamped)
|
|
326
|
-
clamped
|
|
327
|
-
end
|
|
328
|
-
|
|
329
|
-
def apply_tight_retries!(payload, remaining)
|
|
330
|
-
payload[:max_retries] = 0 if remaining <= TIGHT_BUDGET_SECONDS
|
|
331
|
-
end
|
|
332
|
-
|
|
333
|
-
def apply_budget_wait!(payload, remaining)
|
|
334
|
-
budget_wait = budget_wait_seconds(remaining)
|
|
335
|
-
configured = payload[:wait_timeout_seconds]
|
|
336
|
-
if configured
|
|
337
|
-
payload[:wait_timeout_seconds] = [configured, budget_wait].min
|
|
338
|
-
elsif budget_wait < DEFAULT_WAIT_TIMEOUT_SECONDS
|
|
339
|
-
payload[:wait_timeout_seconds] = budget_wait
|
|
340
|
-
end
|
|
341
|
-
end
|
|
342
|
-
|
|
343
|
-
def budget_wait_seconds(remaining)
|
|
344
|
-
reserved = [MIN_WAIT_TIMEOUT_SECONDS, (remaining - BUDGET_WAIT_RESERVE_SECONDS).floor].max
|
|
345
|
-
[reserved, MAX_WAIT_TIMEOUT_SECONDS].min
|
|
346
|
-
end
|
|
347
|
-
|
|
348
325
|
def cap_wait_timeout!(payload)
|
|
349
326
|
wait = payload[:wait_timeout_seconds]
|
|
350
327
|
return unless wait
|
|
@@ -2,12 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
require 'faraday'
|
|
4
4
|
require 'json'
|
|
5
|
+
require 'securerandom'
|
|
5
6
|
|
|
6
7
|
module Html2rss
|
|
7
8
|
class RequestService
|
|
8
9
|
##
|
|
9
10
|
# Strategy to delegate fetching to a Botasaurus scrape API.
|
|
10
11
|
class BotasaurusStrategy < Strategy
|
|
12
|
+
# Content-Type negotiation for the scrape API transport hop.
|
|
13
|
+
TRANSPORT_ACCEPT = 'application/json'
|
|
14
|
+
# Disable compressed bodies so Faraday returns raw JSON without implicit decoding surprises.
|
|
15
|
+
TRANSPORT_ENCODING = 'identity'
|
|
16
|
+
# Correlates each POST /scrape with botasaurus-scrape-api request logs.
|
|
17
|
+
REQUEST_ID_HEADER = 'X-Request-Id'
|
|
18
|
+
|
|
11
19
|
private
|
|
12
20
|
|
|
13
21
|
def fetch
|
|
@@ -18,7 +26,9 @@ module Html2rss
|
|
|
18
26
|
end
|
|
19
27
|
|
|
20
28
|
def post_scrape_request
|
|
21
|
-
|
|
29
|
+
request_id = SecureRandom.uuid
|
|
30
|
+
Log.debug("#{self.class}: POST /scrape #{REQUEST_ID_HEADER}=#{request_id}")
|
|
31
|
+
transport_response = client.post('/scrape', JSON.generate(contract.request_payload), post_headers(request_id))
|
|
22
32
|
contract.parse_response(transport_response)
|
|
23
33
|
end
|
|
24
34
|
|
|
@@ -49,7 +59,7 @@ module Html2rss
|
|
|
49
59
|
return unless error.timeout?
|
|
50
60
|
|
|
51
61
|
log_timeout!(reason: 'botasaurus_upstream')
|
|
52
|
-
raise RequestTimedOut, error.
|
|
62
|
+
raise RequestTimedOut.new(error.failure_message, timeout_phase: error.timeout_phase)
|
|
53
63
|
end
|
|
54
64
|
|
|
55
65
|
def response_url(final_url)
|
|
@@ -64,13 +74,28 @@ module Html2rss
|
|
|
64
74
|
@contract ||= BotasaurusContract.new(
|
|
65
75
|
url: ctx.url,
|
|
66
76
|
headers: ctx.headers,
|
|
67
|
-
options: ctx.request.fetch(:botasaurus, {})
|
|
68
|
-
remaining_timeout_seconds: attempt_timeout_seconds
|
|
77
|
+
options: ctx.request.fetch(:botasaurus, {})
|
|
69
78
|
)
|
|
70
79
|
end
|
|
71
80
|
|
|
72
81
|
def client
|
|
73
|
-
|
|
82
|
+
# No :gzip middleware on this client — compression is for remote target fetches only.
|
|
83
|
+
@client ||= Faraday.new(url: scraper_base_url.to_s, headers: client_headers, request: request_options)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def client_headers
|
|
87
|
+
{
|
|
88
|
+
'User-Agent' => Config::RequestHeaders::DEFAULT_USER_AGENT,
|
|
89
|
+
'Accept' => TRANSPORT_ACCEPT,
|
|
90
|
+
'Accept-Encoding' => TRANSPORT_ENCODING
|
|
91
|
+
}
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def post_headers(request_id)
|
|
95
|
+
{
|
|
96
|
+
'Content-Type' => 'application/json',
|
|
97
|
+
REQUEST_ID_HEADER => request_id
|
|
98
|
+
}
|
|
74
99
|
end
|
|
75
100
|
|
|
76
101
|
def request_options
|
|
@@ -78,13 +103,10 @@ module Html2rss
|
|
|
78
103
|
end
|
|
79
104
|
|
|
80
105
|
def attempt_timeout_seconds
|
|
81
|
-
@attempt_timeout_seconds ||=
|
|
82
|
-
fallback: ctx.policy.total_timeout_seconds
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
def content_type_header
|
|
87
|
-
{ 'Content-Type' => 'application/json' }
|
|
106
|
+
@attempt_timeout_seconds ||= begin
|
|
107
|
+
budget = ctx.budget.effective_timeout_seconds(fallback: ctx.policy.total_timeout_seconds)
|
|
108
|
+
[budget, BotasaurusContract::SCRAPE_TIMEOUT_SECONDS + BotasaurusContract::TRANSPORT_BUFFER_SECONDS].min
|
|
109
|
+
end
|
|
88
110
|
end
|
|
89
111
|
|
|
90
112
|
def scraper_base_url
|
|
@@ -14,11 +14,15 @@ module Html2rss
|
|
|
14
14
|
|
|
15
15
|
# Bodies that look like HTML even when Content-Type is missing, empty, or wrong.
|
|
16
16
|
HTML_BODY_SNIFF = /\A\s*(?:<!DOCTYPE\s+html|<html)/i
|
|
17
|
+
# Content-Type markers for RSS/Atom syndication responses.
|
|
18
|
+
FEED_CT_MARKERS = %w[rss+xml atom+xml rss atom].freeze
|
|
19
|
+
# Body sniff for unlabeled syndication (first 800 bytes, downcased).
|
|
20
|
+
FEED_BODY_MARKERS = ['<rss', '<feed xmlns', '<feed '].freeze
|
|
17
21
|
# Charset from Content-Type or a leading <meta charset>.
|
|
18
22
|
CHARSET_PARAMETER = /charset\s*=\s*["']?([\w.:-]+)/i
|
|
19
23
|
# Bytes scanned for a meta charset hint (HTML spec looks at the first 1024).
|
|
20
24
|
META_CHARSET_BYTES = 2048
|
|
21
|
-
private_constant :CHARSET_PARAMETER, :META_CHARSET_BYTES
|
|
25
|
+
private_constant :CHARSET_PARAMETER, :META_CHARSET_BYTES, :FEED_CT_MARKERS, :FEED_BODY_MARKERS
|
|
22
26
|
|
|
23
27
|
##
|
|
24
28
|
# @param body [String] the body of the response
|
|
@@ -74,7 +78,14 @@ module Html2rss
|
|
|
74
78
|
|
|
75
79
|
# @return [Boolean] whether response content is HTML (header or sniffed body, never JSON)
|
|
76
80
|
def html_response?
|
|
77
|
-
content_type.include?('text/html') || (!json_response? && html_looking_body?)
|
|
81
|
+
content_type.include?('text/html') || (!json_response? && !feed_response? && html_looking_body?)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# @return [Boolean] whether response content is RSS/Atom (header or sniffed body)
|
|
85
|
+
def feed_response?
|
|
86
|
+
return @feed_response if defined?(@feed_response)
|
|
87
|
+
|
|
88
|
+
@feed_response = feed_content_type? || (!json_response? && !html_looking_body? && feed_looking_body?)
|
|
78
89
|
end
|
|
79
90
|
|
|
80
91
|
##
|
|
@@ -143,6 +154,19 @@ module Html2rss
|
|
|
143
154
|
def html_looking_body?
|
|
144
155
|
body.to_s.b.match?(HTML_BODY_SNIFF)
|
|
145
156
|
end
|
|
157
|
+
|
|
158
|
+
def feed_content_type?
|
|
159
|
+
ct = content_type.downcase
|
|
160
|
+
return false if ct.empty? || ct.include?('html') || ct.include?('json')
|
|
161
|
+
|
|
162
|
+
# Substring match against Content-Type (not Array#intersect? on a String).
|
|
163
|
+
FEED_CT_MARKERS.any? { |marker| ct.include?(marker) } # rubocop:disable Style/ArrayIntersect
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def feed_looking_body?
|
|
167
|
+
snippet = body.to_s[0, 800].downcase
|
|
168
|
+
FEED_BODY_MARKERS.any? { |marker| snippet.include?(marker) } # rubocop:disable Style/ArrayIntersect
|
|
169
|
+
end
|
|
146
170
|
end
|
|
147
171
|
end
|
|
148
172
|
end
|
|
@@ -31,8 +31,20 @@ module Html2rss
|
|
|
31
31
|
class ResponseTooLarge < Html2rss::Error; end
|
|
32
32
|
# Raised when blocked content surfaces are detected.
|
|
33
33
|
class BlockedSurfaceDetected < Html2rss::Error; end
|
|
34
|
+
|
|
34
35
|
# Raised when a request times out.
|
|
35
|
-
class RequestTimedOut < Html2rss::Error
|
|
36
|
+
class RequestTimedOut < Html2rss::Error
|
|
37
|
+
##
|
|
38
|
+
# @param message [String, nil] timeout failure summary
|
|
39
|
+
# @param timeout_phase [String, nil] scrape-api stage when known (+queue+/+boot+/+work+)
|
|
40
|
+
def initialize(message = nil, timeout_phase: nil)
|
|
41
|
+
@timeout_phase = timeout_phase
|
|
42
|
+
super(message)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# @return [String, nil] scrape-api timeout stage, or nil for transport/budget timeouts
|
|
46
|
+
attr_reader :timeout_phase
|
|
47
|
+
end
|
|
36
48
|
|
|
37
49
|
# Raised when Botasaurus configuration is missing or invalid.
|
|
38
50
|
class BotasaurusConfigurationError < Html2rss::Error
|
|
@@ -15,14 +15,17 @@ module Html2rss
|
|
|
15
15
|
# @param strategy [Symbol] request strategy for the session
|
|
16
16
|
# @param budget [RequestService::Budget] shared request budget
|
|
17
17
|
# @param policy [RequestService::Policy] request policy (from FeedPipeline::RuntimePolicy.resources_for)
|
|
18
|
+
# @param scrape_url [String, nil] effective fetch URL when it differs from config channel URL
|
|
18
19
|
# @param logger [Logger] logger used for operational warnings
|
|
19
20
|
# @return [RequestSession] configured request session
|
|
20
|
-
|
|
21
|
+
# rubocop:disable Metrics/ParameterLists -- scrape_url override stays beside config
|
|
22
|
+
def build(config:, strategy:, budget:, policy:, scrape_url: nil, logger: Html2rss::Log)
|
|
21
23
|
context = RequestService::Context.new(
|
|
22
|
-
url: config.url, headers: config.headers, request: config.request, policy:, budget:
|
|
24
|
+
url: scrape_url || config.url, headers: config.headers, request: config.request, policy:, budget:
|
|
23
25
|
)
|
|
24
26
|
new(context:, strategy:, logger:)
|
|
25
27
|
end
|
|
28
|
+
# rubocop:enable Metrics/ParameterLists
|
|
26
29
|
end
|
|
27
30
|
|
|
28
31
|
##
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
##
|
|
5
|
+
# Immutable entry vs effective scrape URLs for one pipeline run.
|
|
6
|
+
#
|
|
7
|
+
# Replaces mutating {Config#scrape_url=} after {FeedResolution} rewrites the fetch URL.
|
|
8
|
+
ScrapeTarget = Data.define(:entry_url, :effective_url) do
|
|
9
|
+
##
|
|
10
|
+
# @param config [Html2rss::Config]
|
|
11
|
+
# @return [ScrapeTarget]
|
|
12
|
+
def self.from_config(config)
|
|
13
|
+
entry = config.url
|
|
14
|
+
new(entry_url: entry, effective_url: entry)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
##
|
|
18
|
+
# @param url [String, Html2rss::Url]
|
|
19
|
+
# @return [ScrapeTarget]
|
|
20
|
+
def with_effective(url)
|
|
21
|
+
self.class.new(entry_url:, effective_url: Url.from_absolute(url).to_s)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|