html2rss 0.27.2 → 0.29.1

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.
Files changed (94) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +82 -25
  3. data/html2rss.gemspec +0 -1
  4. data/lib/html2rss/article.rb +1 -2
  5. data/lib/html2rss/auto_source/README.md +12 -9
  6. data/lib/html2rss/auto_source/cleanup.rb +92 -1
  7. data/lib/html2rss/auto_source/scraper/json_state/document_scanner.rb +3 -5
  8. data/lib/html2rss/auto_source/scraper/meta_oembed.rb +15 -5
  9. data/lib/html2rss/auto_source/scraper/microdata.rb +8 -6
  10. data/lib/html2rss/auto_source/scraper/native_feed.rb +93 -0
  11. data/lib/html2rss/auto_source/scraper/schema.rb +39 -21
  12. data/lib/html2rss/auto_source/scraper.rb +6 -4
  13. data/lib/html2rss/auto_source/segmenter/cluster.rb +2 -3
  14. data/lib/html2rss/auto_source/segmenter/primary_link.rb +1 -2
  15. data/lib/html2rss/auto_source/segmenter.rb +1 -2
  16. data/lib/html2rss/auto_source.rb +32 -11
  17. data/lib/html2rss/batch.rb +169 -0
  18. data/lib/html2rss/capture/README.md +15 -6
  19. data/lib/html2rss/capture.rb +158 -15
  20. data/lib/html2rss/channel.rb +1 -2
  21. data/lib/html2rss/cli/probe_view.rb +66 -0
  22. data/lib/html2rss/cli/render.rb +124 -0
  23. data/lib/html2rss/cli/validate.rb +91 -0
  24. data/lib/html2rss/cli.rb +318 -193
  25. data/lib/html2rss/config/auto_source_contract.rb +7 -0
  26. data/lib/html2rss/config/request_headers.rb +4 -14
  27. data/lib/html2rss/config/schema.rb +7 -5
  28. data/lib/html2rss/config/validator.rb +1 -0
  29. data/lib/html2rss/config.rb +64 -18
  30. data/lib/html2rss/doctor/botasaurus.rb +130 -0
  31. data/lib/html2rss/feed_builder/rss.rb +1 -2
  32. data/lib/html2rss/feed_pipeline/README.md +7 -5
  33. data/lib/html2rss/feed_pipeline/auto_fallback.rb +78 -19
  34. data/lib/html2rss/feed_pipeline/runtime_policy.rb +1 -0
  35. data/lib/html2rss/feed_pipeline.rb +48 -21
  36. data/lib/html2rss/feed_resolution/README.md +52 -0
  37. data/lib/html2rss/feed_resolution/candidate_generator.rb +139 -0
  38. data/lib/html2rss/feed_resolution/diag.rb +32 -0
  39. data/lib/html2rss/feed_resolution/options.rb +31 -0
  40. data/lib/html2rss/feed_resolution/policy.rb +47 -0
  41. data/lib/html2rss/feed_resolution/probe.rb +59 -0
  42. data/lib/html2rss/feed_resolution/scorer.rb +49 -0
  43. data/lib/html2rss/feed_resolution.rb +268 -0
  44. data/lib/html2rss/html/article_extractor/heading_extractor.rb +24 -14
  45. data/lib/html2rss/html/article_extractor.rb +4 -9
  46. data/lib/html2rss/html/feed_link.rb +4 -2
  47. data/lib/html2rss/html/navigator.rb +4 -39
  48. data/lib/html2rss/html/probe.rb +82 -0
  49. data/lib/html2rss/html/sst_article_extractor.rb +1 -2
  50. data/lib/html2rss/link_destination/noise_policy.rb +1 -2
  51. data/lib/html2rss/link_destination/path_classifier.rb +20 -19
  52. data/lib/html2rss/mcp/README.md +80 -0
  53. data/lib/html2rss/mcp/contract.rb +135 -13
  54. data/lib/html2rss/mcp/outcome/playbook.rb +122 -0
  55. data/lib/html2rss/mcp/outcome.rb +112 -40
  56. data/lib/html2rss/mcp/runtime.rb +45 -0
  57. data/lib/html2rss/mcp/server/tools.rb +277 -0
  58. data/lib/html2rss/mcp/server.rb +91 -232
  59. data/lib/html2rss/mcp.rb +3 -0
  60. data/lib/html2rss/page_recon/README.md +69 -0
  61. data/lib/html2rss/page_recon/diagnostics.rb +211 -0
  62. data/lib/html2rss/page_recon.rb +297 -0
  63. data/lib/html2rss/recon.rb +252 -0
  64. data/lib/html2rss/request_service/blocked_surface.rb +1 -0
  65. data/lib/html2rss/request_service/botasaurus_contract.rb +8 -1
  66. data/lib/html2rss/request_service/botasaurus_strategy.rb +1 -1
  67. data/lib/html2rss/request_service/faraday_strategy.rb +11 -7
  68. data/lib/html2rss/request_service/policy.rb +1 -2
  69. data/lib/html2rss/request_service/response.rb +27 -4
  70. data/lib/html2rss/request_service/strategy.rb +1 -2
  71. data/lib/html2rss/request_service.rb +15 -1
  72. data/lib/html2rss/request_session.rb +4 -2
  73. data/lib/html2rss/scrape_target.rb +24 -0
  74. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +2 -4
  75. data/lib/html2rss/selectors/post_processors.rb +0 -1
  76. data/lib/html2rss/selectors.rb +33 -10
  77. data/lib/html2rss/sst/attrs.rb +1 -2
  78. data/lib/html2rss/sst/normalizer.rb +49 -11
  79. data/lib/html2rss/status.rb +48 -24
  80. data/lib/html2rss/surface_category.rb +63 -0
  81. data/lib/html2rss/syndication/README.md +21 -0
  82. data/lib/html2rss/syndication/candidate_catalog.rb +26 -0
  83. data/lib/html2rss/syndication/discovery.rb +215 -0
  84. data/lib/html2rss/syndication/parser.rb +137 -0
  85. data/lib/html2rss/syndication.rb +10 -0
  86. data/lib/html2rss/test/enhance_audit.rb +192 -0
  87. data/lib/html2rss/test.rb +458 -0
  88. data/lib/html2rss/url.rb +23 -2
  89. data/lib/html2rss/version.rb +1 -1
  90. data/lib/html2rss.rb +157 -35
  91. data/schema/html2rss-config.schema.json +47 -29
  92. metadata +34 -18
  93. data/lib/html2rss/mcp/inspect.rb +0 -241
  94. data/lib/html2rss/selectors/post_processors/html_to_markdown.rb +0 -59
@@ -0,0 +1,297 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ ##
5
+ # Shared page recon for MCP Inspect, curation Recon, and FeedResolution probes.
6
+ #
7
+ # Owns surface class, native feed hints, segment stats, and a cheap AutoSource
8
+ # article count. Diagnostic fetch for Inspect/Recon lives on {.probe} — not twin
9
+ # fetch helpers in those callers.
10
+ class PageRecon # rubocop:disable Metrics/ClassLength -- recon bag stays co-located
11
+ ##
12
+ # Cheap surface + admission facts shared by AutoFallback gates and FeedResolution probes.
13
+ Assessment = Data.define(:surface_category, :articles_count, :admission_drops, :html_response) do
14
+ ##
15
+ # @return [Html2rss::SurfaceCategory]
16
+ def category = SurfaceCategory.coerce(surface_category)
17
+
18
+ ##
19
+ # @return [Boolean]
20
+ def weak? = category.weak?
21
+
22
+ ##
23
+ # @return [Boolean]
24
+ def blocked? = category.blocked?
25
+
26
+ ##
27
+ # @return [Boolean]
28
+ def listing_bonus? = category.listing_bonus?
29
+ end
30
+ ##
31
+ # Recon facts used by Inspect and FeedResolution.
32
+ Result = Data.define(
33
+ :requested_url,
34
+ :final_url,
35
+ :status,
36
+ :scheme_downgrade,
37
+ :alternate_feeds,
38
+ :surface_category,
39
+ :articles_count,
40
+ :admission_drops,
41
+ :segment_stats,
42
+ :html_response,
43
+ :content_type,
44
+ :blocked_surface,
45
+ :sst
46
+ ) do
47
+ ##
48
+ # @return [Hash{Symbol => Object}]
49
+ def to_h # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- omit-empty optional keys
50
+ {
51
+ requested_url:,
52
+ final_url:,
53
+ status:,
54
+ scheme_downgrade:,
55
+ alternate_feeds:,
56
+ surface_category:,
57
+ articles_count:,
58
+ html_response:,
59
+ content_type:,
60
+ **(admission_drops.any? ? { admission_drops: } : {}),
61
+ **(segment_stats ? { segment_stats: } : {}),
62
+ **(blocked_surface ? { blocked_surface: } : {}),
63
+ **(sst ? { sst: } : {})
64
+ }
65
+ end
66
+ end
67
+
68
+ ##
69
+ # Diagnostic fetch + assess bundle. One home for session build + initial GET + {call}.
70
+ # Consumed by curation {Html2rss::Recon} and {Html2rss::PageRecon::Diagnostics}.
71
+ Probe = Data.define(:session, :response, :result, :strategy)
72
+
73
+ ##
74
+ # @param response [Html2rss::RequestService::Response]
75
+ # @param url [String, Html2rss::Url] requested entry URL
76
+ # @param strategy [Symbol, nil] unused (reserved for callers that already chose a strategy)
77
+ # @return [Result]
78
+ def self.call(response:, url:, strategy: nil) # rubocop:disable Lint/UnusedMethodArgument
79
+ new(response:, url:).call
80
+ end
81
+
82
+ ##
83
+ # Builds a request session, fetches the URL once, and runs full page recon.
84
+ #
85
+ # @param url [String, Html2rss::Url]
86
+ # @param strategy [Symbol] request strategy (:auto resolves to a concrete diagnostic strategy)
87
+ # @option options [Integer, nil] :max_redirects
88
+ # @option options [Integer, nil] :max_requests
89
+ # @return [Probe]
90
+ def self.probe(url, strategy: :auto, **)
91
+ url_obj = Url.from_absolute(url)
92
+ resolved = FeedPipeline::StrategyPlan.concrete_for_diagnostic(strategy)
93
+ session = build_probe_session(url_obj, resolved, **)
94
+ response = session.fetch_initial_response
95
+ Probe.new(
96
+ session:,
97
+ response:,
98
+ result: call(response:, url: url_obj, strategy: resolved),
99
+ strategy: resolved
100
+ )
101
+ end
102
+
103
+ ##
104
+ # @param url_obj [Html2rss::Url]
105
+ # @param strategy [Symbol]
106
+ # @option options [Integer, nil] :max_redirects
107
+ # @option options [Integer, nil] :max_requests
108
+ # @return [Html2rss::RequestSession]
109
+ def self.build_probe_session(url_obj, strategy, **options) # rubocop:disable Metrics/MethodLength
110
+ raw_config = Config.auto_source_config(
111
+ url: url_obj.to_s,
112
+ request_controls: Config::RequestControls.from_shortcut(
113
+ strategy:,
114
+ max_redirects: options[:max_redirects],
115
+ max_requests: options[:max_requests]
116
+ )
117
+ )
118
+ raw_config[:strategy] = strategy
119
+ config = Config.from_hash(raw_config)
120
+ resources = FeedPipeline::RuntimePolicy.resources_for(config)
121
+ RequestSession.build(
122
+ config:,
123
+ strategy: config.strategy,
124
+ budget: resources.budget,
125
+ policy: resources.policy
126
+ )
127
+ end
128
+ private_class_method :build_probe_session
129
+
130
+ ##
131
+ # Cheap page assessment for policy gates and probe scoring (fixed AutoSource limit).
132
+ #
133
+ # @param response [Html2rss::RequestService::Response]
134
+ # @param url [String, Html2rss::Url]
135
+ # @return [Assessment]
136
+ def self.assess(response:, url:)
137
+ new(response:, url:).assess
138
+ end
139
+
140
+ ##
141
+ # Surface class only — no AutoSource extract (for empty-extract error labels).
142
+ #
143
+ # @param response [Html2rss::RequestService::Response]
144
+ # @param url [String, Html2rss::Url]
145
+ # @return [Symbol]
146
+ def self.surface_category_for(response:, url:)
147
+ new(response:, url:).surface_category_for
148
+ end
149
+
150
+ ##
151
+ # @param sst [Html2rss::SST::Document]
152
+ # @param url [String, Html2rss::Url]
153
+ # @return [Array]
154
+ def self.discover_segments(sst, url)
155
+ link_resolver = Scoring::LinkResolver.new(url)
156
+ AutoSource::Segmenter.call(sst, base_url: url, strategy: :list, link_resolver:)
157
+ rescue StandardError
158
+ []
159
+ end
160
+
161
+ ##
162
+ # @param response [Html2rss::RequestService::Response]
163
+ # @param url [String, Html2rss::Url]
164
+ def initialize(response:, url:)
165
+ @response = response
166
+ @url = url
167
+ end
168
+
169
+ ##
170
+ # @return [Assessment]
171
+ def assess
172
+ return feed_assessment if response.feed_response?
173
+
174
+ parsed = html_parsed_body
175
+ articles_count, admission_drops = cheap_articles
176
+ Assessment.new(
177
+ surface_category: surface_category(parsed),
178
+ articles_count:,
179
+ admission_drops:,
180
+ html_response: true
181
+ )
182
+ end
183
+
184
+ ##
185
+ # @return [Symbol]
186
+ def surface_category_for
187
+ return :unsupported_surface if response.feed_response?
188
+
189
+ surface_category(html_parsed_body)
190
+ end
191
+
192
+ ##
193
+ # @return [Result]
194
+ def call # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- assemble recon Result
195
+ requested = Url.from_absolute(url)
196
+ final = response.url
197
+ parsed = html_parsed_body
198
+ assessment = assess
199
+ sst_payload, segment_stats = sst_payload_and_segments(requested)
200
+
201
+ Result.new(
202
+ requested_url: requested.to_s,
203
+ final_url: final.to_s,
204
+ status: response.status,
205
+ scheme_downgrade: scheme_downgrade?(requested, final),
206
+ alternate_feeds: alternate_feeds_from(parsed),
207
+ surface_category: assessment.surface_category,
208
+ articles_count: assessment.articles_count,
209
+ admission_drops: assessment.admission_drops,
210
+ segment_stats:,
211
+ html_response: response.html_response?,
212
+ content_type: response.content_type,
213
+ blocked_surface: blocked_surface_key,
214
+ sst: sst_payload
215
+ )
216
+ end
217
+
218
+ private
219
+
220
+ attr_reader :response, :url
221
+
222
+ def feed_assessment
223
+ Assessment.new(
224
+ surface_category: :unsupported_surface,
225
+ articles_count: 0,
226
+ admission_drops: {},
227
+ html_response: false
228
+ )
229
+ end
230
+
231
+ def html_parsed_body
232
+ return unless response.html_response?
233
+
234
+ response.parsed_body
235
+ rescue RequestService::UnsupportedResponseContentType
236
+ nil
237
+ end
238
+
239
+ def surface_category(parsed)
240
+ return :unsupported_surface unless parsed
241
+
242
+ AutoSource::Scraper.classify_no_scraper_surface(parsed, body: response.body)
243
+ end
244
+
245
+ def cheap_articles
246
+ return [0, {}] unless response.html_response?
247
+
248
+ source = AutoSource.new(response, AutoSource::DEFAULT_CONFIG.merge(limit: 10))
249
+ [source.articles.size, source.admission_drops]
250
+ end
251
+
252
+ def sst_payload_and_segments(requested)
253
+ return [nil, nil] unless response.html_response?
254
+
255
+ sst = sst_document
256
+ return [nil, nil] unless sst
257
+
258
+ stats = segment_stats(sst, requested)
259
+ [
260
+ { node_count: sst.node_count, degraded: sst.degraded, segment_stats: stats },
261
+ stats
262
+ ]
263
+ end
264
+
265
+ def sst_document
266
+ Html2rss::SST::Normalizer.call(response.body)
267
+ rescue ArgumentError
268
+ nil
269
+ end
270
+
271
+ def segment_stats(sst, page_url)
272
+ segments = self.class.discover_segments(sst, page_url)
273
+ return { found: 0 } if segments.empty?
274
+
275
+ {
276
+ found: segments.size,
277
+ strategies: segments.map(&:strategy).uniq,
278
+ sample_paths: segments.first(5).map { |s| s.root_node.tag_path }
279
+ }
280
+ end
281
+
282
+ def scheme_downgrade?(requested, final)
283
+ requested.scheme == 'https' && final.scheme == 'http'
284
+ end
285
+
286
+ def alternate_feeds_from(parsed)
287
+ return [] unless parsed.is_a?(Nokogiri::HTML::Document)
288
+
289
+ Html::FeedLink.from_document(parsed).map { |link| { href: link.href, mime_type: link.mime_type } }
290
+ end
291
+
292
+ def blocked_surface_key
293
+ blocked = RequestService::BlockedSurface.interstitial_signature_for(response.body)
294
+ blocked[:key].to_s if blocked
295
+ end
296
+ end
297
+ end
@@ -0,0 +1,252 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'uri'
5
+ require 'fileutils'
6
+
7
+ module Html2rss
8
+ ##
9
+ # Service that runs reconnaissance on a URL to discover redirect chains,
10
+ # native RSS/Atom feeds, surface categorization, and emit an actionable verdict.
11
+ module Recon # rubocop:disable Metrics/ModuleLength
12
+ module_function
13
+
14
+ Verdict = Data.define(:name)
15
+
16
+ ##
17
+ # Closed curation verdict (:build / :defer / :drop).
18
+ class Verdict
19
+ # Closed set of curation verdict wire names.
20
+ NAMES = Set[:build, :defer, :drop].freeze
21
+
22
+ class << self
23
+ ##
24
+ # @param value [Verdict, Symbol, String]
25
+ # @return [Verdict]
26
+ def coerce(value)
27
+ return value if value.is_a?(self)
28
+
29
+ new(name: value.to_sym)
30
+ end
31
+ end
32
+
33
+ ##
34
+ # @param name [Symbol]
35
+ def initialize(name:)
36
+ raise ArgumentError, "unknown verdict: #{name.inspect}" unless NAMES.include?(name)
37
+
38
+ super
39
+ end
40
+
41
+ ##
42
+ # @return [Boolean]
43
+ def build? = name == :build
44
+
45
+ ##
46
+ # @return [Boolean]
47
+ def defer? = name == :defer
48
+
49
+ ##
50
+ # @return [Boolean]
51
+ def drop? = name == :drop
52
+
53
+ ##
54
+ # @return [Symbol]
55
+ def to_sym = name
56
+
57
+ ##
58
+ # @return [String]
59
+ def to_s = name.to_s
60
+ end
61
+
62
+ ##
63
+ # Immutable outcome of a reconnaissance operation.
64
+ Result = Data.define(
65
+ :requested_url,
66
+ :final_url,
67
+ :status,
68
+ :verdict,
69
+ :native_feed,
70
+ :surface_category,
71
+ :articles_count,
72
+ :scheme_downgrade,
73
+ :notes,
74
+ :html_bytesize
75
+ ) do
76
+ ##
77
+ # @return [Boolean]
78
+ def build? = verdict.build?
79
+
80
+ ##
81
+ # @return [Boolean]
82
+ def defer? = verdict.defer?
83
+
84
+ ##
85
+ # @return [Boolean]
86
+ def drop? = verdict.drop?
87
+
88
+ ##
89
+ # @return [Boolean]
90
+ def native_feed? = !native_feed.nil?
91
+
92
+ ##
93
+ # @return [Hash{Symbol => Object}]
94
+ def to_h # rubocop:disable Metrics/MethodLength
95
+ {
96
+ requested_url: requested_url.to_s,
97
+ final_url: final_url.to_s,
98
+ status:,
99
+ verdict: verdict.to_sym,
100
+ native_feed: native_feed&.to_s,
101
+ surface_category: surface_category&.to_s,
102
+ articles_count:,
103
+ scheme_downgrade:,
104
+ notes:,
105
+ html_bytesize:
106
+ }.compact
107
+ end
108
+ end
109
+
110
+ ##
111
+ # Runs reconnaissance on a single URL.
112
+ #
113
+ # @param url [String, Html2rss::Url] source page URL
114
+ # @param strategy [Symbol] request strategy (:auto, :faraday, :botasaurus)
115
+ # @param cache_dir [String, nil] optional directory to cache raw HTML bodies
116
+ # @param cache_mutex [Mutex, nil] optional mutex serializing cache writes
117
+ # @option options [Integer, nil] :max_redirects optional maximum redirects
118
+ # @option options [Integer, nil] :max_requests optional request budget
119
+ # @return [Html2rss::Recon::Result]
120
+ def call(url, strategy: :auto, cache_dir: nil, cache_mutex: nil, **) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
121
+ url_obj = Url.from_absolute(url)
122
+
123
+ begin
124
+ probe = PageRecon.probe(url_obj, strategy:, **)
125
+ rescue StandardError => error
126
+ return error_result(url_obj, error)
127
+ end
128
+
129
+ cache_html_body(probe.response.body, url_obj, cache_dir, cache_mutex) if cache_dir && probe.response.body
130
+
131
+ recon = probe.result
132
+ native_feed, discovery_note = find_native_feed(url_obj, probe.session, probe.response)
133
+ notes = build_notes(recon, native_feed, probe.response)
134
+ notes << discovery_note if discovery_note
135
+ verdict = determine_verdict(recon, native_feed)
136
+
137
+ Result.new(
138
+ requested_url: url_obj,
139
+ final_url: probe.response.url,
140
+ status: probe.response.status,
141
+ verdict:,
142
+ native_feed:,
143
+ surface_category: SurfaceCategory.coerce(recon.surface_category),
144
+ articles_count: recon.articles_count,
145
+ scheme_downgrade: recon.scheme_downgrade,
146
+ notes:,
147
+ html_bytesize: probe.response.body&.bytesize
148
+ )
149
+ end
150
+
151
+ ##
152
+ # Runs batch reconnaissance across an Enumerable of URLs.
153
+ # Results are returned in input order. Concurrency uses a Thread pool
154
+ # (not Ractors) for I/O overlap with one loaded gem image.
155
+ #
156
+ # @param urls [Enumerable<String>] list of URLs
157
+ # @param strategy [Symbol] request strategy
158
+ # @param cache_dir [String, nil] optional directory to cache raw HTML bodies
159
+ # @param max_threads [Integer] concurrent worker count
160
+ # @option options [Integer, nil] :max_redirects optional maximum redirects
161
+ # @option options [Integer, nil] :max_requests optional request budget
162
+ # @return [Array<Html2rss::Recon::Result>]
163
+ def batch(urls, strategy: :auto, cache_dir: nil, max_threads: 5, **options)
164
+ url_list = urls.map { |u| u.to_s.strip }.reject(&:empty?)
165
+ return [] if url_list.empty?
166
+
167
+ FileUtils.mkdir_p(cache_dir) if cache_dir
168
+ cache_mutex = Mutex.new if cache_dir
169
+
170
+ Batch.map(url_list, concurrency: max_threads) do |target_url|
171
+ call(target_url, strategy:, cache_dir:, cache_mutex:, **options)
172
+ end
173
+ end
174
+
175
+ ##
176
+ # @param url_obj [Html2rss::Url]
177
+ # @param session [Html2rss::RequestSession]
178
+ # @param response [Html2rss::RequestService::Response]
179
+ # @return [Array(Html2rss::Url, nil), String, nil)] feed URL and optional discovery error note
180
+ def find_native_feed(url_obj, session, response)
181
+ feed = Syndication::Discovery.best_feed_url(
182
+ page_url: url_obj,
183
+ request_session: session,
184
+ parsed_body: (response.parsed_body if response.html_response?),
185
+ html: response.body
186
+ )
187
+ [feed, nil]
188
+ rescue StandardError => error
189
+ [nil, "discovery_error=#{error.class}: #{error.message}"]
190
+ end
191
+ private_class_method :find_native_feed
192
+
193
+ def build_notes(recon, native_feed, response)
194
+ notes = []
195
+ notes << "native_rss=#{native_feed}" if native_feed
196
+ if recon.scheme_downgrade
197
+ notes << 'scheme_downgrade'
198
+ notes << 'botasaurus_retry=Try strategy botasaurus once before DROP (HTTPS→HTTP may need JS fetch)'
199
+ end
200
+ notes << "blocked=#{recon.blocked_surface}" if recon.blocked_surface
201
+ notes << "html_bytes=#{response.body&.bytesize}" if response.body
202
+ notes
203
+ end
204
+ private_class_method :build_notes
205
+
206
+ def determine_verdict(recon, native_feed)
207
+ return Verdict.coerce(:drop) if recon.status.nil? || recon.status >= 400 || recon.scheme_downgrade
208
+ return Verdict.coerce(:defer) if native_feed
209
+
210
+ category = SurfaceCategory.coerce(recon.surface_category)
211
+ return Verdict.coerce(:drop) if category.blocked?
212
+
213
+ Verdict.coerce(:build)
214
+ end
215
+ private_class_method :determine_verdict
216
+
217
+ def error_result(url_obj, error) # rubocop:disable Metrics/MethodLength
218
+ Result.new(
219
+ requested_url: url_obj,
220
+ final_url: url_obj,
221
+ status: nil,
222
+ verdict: Verdict.coerce(:drop),
223
+ native_feed: nil,
224
+ surface_category: SurfaceCategory.coerce(:unsupported_surface),
225
+ articles_count: 0,
226
+ scheme_downgrade: false,
227
+ notes: ["error: #{error.class} - #{error.message}"],
228
+ html_bytesize: nil
229
+ )
230
+ end
231
+ private_class_method :error_result
232
+
233
+ # Writes the raw response body once under a host+url-digest filename.
234
+ #
235
+ # @param body [String]
236
+ # @param url_obj [Html2rss::Url]
237
+ # @param cache_dir [String]
238
+ # @param cache_mutex [Mutex, nil]
239
+ # @return [void]
240
+ def cache_html_body(body, url_obj, cache_dir, cache_mutex)
241
+ host = url_obj.host.to_s.delete_prefix('www.')
242
+ host = 'snapshot' if host.empty?
243
+ digest = Digest::SHA256.hexdigest(url_obj.to_s)[0, 12]
244
+ file_path = File.join(cache_dir, "#{host}-#{digest}.html")
245
+ writer = -> { File.write(file_path, body) }
246
+ cache_mutex ? cache_mutex.synchronize(&writer) : writer.call
247
+ rescue StandardError => error
248
+ Log.debug("Recon cache failed: #{error.message}")
249
+ end
250
+ private_class_method :cache_html_body
251
+ end
252
+ end
@@ -18,6 +18,7 @@ module Html2rss
18
18
  /checking your browser before accessing/i,
19
19
  /please (?:enable|turn on) javascript and cookies/i,
20
20
  %r{cdn-cgi/challenge-platform}i,
21
+ /cf-challenge/i,
21
22
  /cloudflare ray id/i
22
23
  ],
23
24
  message: 'Blocked surface detected: Cloudflare anti-bot interstitial page. ' \
@@ -43,7 +43,7 @@ module Html2rss
43
43
  MAX_RETRIES = 3
44
44
 
45
45
  # Allowlisted ScrapeDiagnostics keys nested under diagnostics.
46
- 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
47
47
  # Allowlisted ChallengeSignal keys nested under diagnostics.challenge.
48
48
  CHALLENGE_KEYS = %w[blocked detected marker].freeze
49
49
 
@@ -185,10 +185,17 @@ module Html2rss
185
185
  'Botasaurus challenge block detected.'
186
186
  end
187
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
+
188
194
  # @return [String] actionable upstream failure summary
189
195
  def failure_message
190
196
  details = ["status=#{transport_status}", "error_category=#{error_category}", "error=#{error}"]
191
197
  details << "request_id=#{request_id}" if request_id
198
+ details << "timeout_phase=#{timeout_phase}" if timeout_phase
192
199
  "Botasaurus scrape failed (#{details.join(', ')})."
193
200
  end
194
201
 
@@ -59,7 +59,7 @@ module Html2rss
59
59
  return unless error.timeout?
60
60
 
61
61
  log_timeout!(reason: 'botasaurus_upstream')
62
- raise RequestTimedOut, error.failure_message
62
+ raise RequestTimedOut.new(error.failure_message, timeout_phase: error.timeout_phase)
63
63
  end
64
64
 
65
65
  def response_url(final_url)
@@ -10,7 +10,7 @@ module Html2rss
10
10
  ##
11
11
  # Strategy to use Faraday for the request.
12
12
  # @see https://rubygems.org/gems/faraday
13
- # rubocop:disable Metrics/ClassLength -- terminal redirect retry colocated with Faraday transport
13
+ # rubocop:disable-next Metrics/ClassLength -- terminal redirect retry colocated with Faraday transport
14
14
  class FaradayStrategy < Strategy
15
15
  ##
16
16
  # Restores buffered streamed bytes so response middleware can process them.
@@ -59,9 +59,13 @@ module Html2rss
59
59
  def request_with_terminal_redirect_retry(response_guard, deadline:)
60
60
  faraday_request(response_guard, deadline:, streaming_buffer: true)
61
61
  rescue Faraday::FollowRedirects::RedirectLimitReached => error
62
- raise error unless terminal_redirect_retryable?
62
+ raise RedirectLimitReached, error.message unless terminal_redirect_retryable?
63
63
 
64
- retry_from_terminal_redirect!(response_guard, deadline:)
64
+ begin
65
+ retry_from_terminal_redirect!(response_guard, deadline:)
66
+ rescue Faraday::FollowRedirects::RedirectLimitReached => retry_error
67
+ raise RedirectLimitReached, retry_error.message
68
+ end
65
69
  end
66
70
 
67
71
  def terminal_redirect_retryable?
@@ -100,7 +104,9 @@ module Html2rss
100
104
 
101
105
  client.get do |req|
102
106
  apply_timeouts(req, deadline:)
103
- buffer = prepare_stream_buffer(req) if streaming_buffer
107
+ next unless streaming_buffer
108
+
109
+ buffer = prepare_stream_buffer(req)
104
110
  req.options.on_data = on_data_callback(response_guard, buffer)
105
111
  end
106
112
  end
@@ -146,7 +152,7 @@ module Html2rss
146
152
  end
147
153
  end
148
154
 
149
- # rubocop:disable Metrics/AbcSize
155
+ # rubocop:disable-next Metrics/AbcSize
150
156
  def client
151
157
  @client ||= Faraday.new(url: request_url.to_s, headers: ctx.headers) do |faraday|
152
158
  faraday.use Faraday::FollowRedirects::Middleware, limit: ctx.policy.max_redirects, callback: redirect_callback
@@ -157,7 +163,6 @@ module Html2rss
157
163
  end
158
164
  end
159
165
  end
160
- # rubocop:enable Metrics/AbcSize
161
166
 
162
167
  def apply_timeouts(request, deadline:)
163
168
  remaining_timeout = remaining_timeout_seconds(deadline)
@@ -224,6 +229,5 @@ module Html2rss
224
229
  Process.clock_gettime(Process::CLOCK_MONOTONIC)
225
230
  end
226
231
  end
227
- # rubocop:enable Metrics/ClassLength
228
232
  end
229
233
  end
@@ -84,11 +84,10 @@ module Html2rss
84
84
  # Returns the default request policy.
85
85
  #
86
86
  # @return [Policy] a default, frozen policy instance
87
- # rubocop:disable Layout/ClassStructure
87
+ # rubocop:disable-next Layout/ClassStructure
88
88
  def self.default
89
89
  new
90
90
  end
91
- # rubocop:enable Layout/ClassStructure
92
91
 
93
92
  ##
94
93
  # Validates whether a request target is permitted for the given context.