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.
- checksums.yaml +4 -4
- data/README.md +82 -25
- data/html2rss.gemspec +0 -1
- data/lib/html2rss/article.rb +1 -2
- data/lib/html2rss/auto_source/README.md +12 -9
- data/lib/html2rss/auto_source/cleanup.rb +92 -1
- data/lib/html2rss/auto_source/scraper/json_state/document_scanner.rb +3 -5
- data/lib/html2rss/auto_source/scraper/meta_oembed.rb +15 -5
- data/lib/html2rss/auto_source/scraper/microdata.rb +8 -6
- data/lib/html2rss/auto_source/scraper/native_feed.rb +93 -0
- data/lib/html2rss/auto_source/scraper/schema.rb +39 -21
- data/lib/html2rss/auto_source/scraper.rb +6 -4
- data/lib/html2rss/auto_source/segmenter/cluster.rb +2 -3
- data/lib/html2rss/auto_source/segmenter/primary_link.rb +1 -2
- data/lib/html2rss/auto_source/segmenter.rb +1 -2
- data/lib/html2rss/auto_source.rb +32 -11
- data/lib/html2rss/batch.rb +169 -0
- data/lib/html2rss/capture/README.md +15 -6
- data/lib/html2rss/capture.rb +158 -15
- data/lib/html2rss/channel.rb +1 -2
- data/lib/html2rss/cli/probe_view.rb +66 -0
- data/lib/html2rss/cli/render.rb +124 -0
- data/lib/html2rss/cli/validate.rb +91 -0
- data/lib/html2rss/cli.rb +318 -193
- data/lib/html2rss/config/auto_source_contract.rb +7 -0
- data/lib/html2rss/config/request_headers.rb +4 -14
- data/lib/html2rss/config/schema.rb +7 -5
- data/lib/html2rss/config/validator.rb +1 -0
- data/lib/html2rss/config.rb +64 -18
- data/lib/html2rss/doctor/botasaurus.rb +130 -0
- data/lib/html2rss/feed_builder/rss.rb +1 -2
- data/lib/html2rss/feed_pipeline/README.md +7 -5
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +78 -19
- data/lib/html2rss/feed_pipeline/runtime_policy.rb +1 -0
- data/lib/html2rss/feed_pipeline.rb +48 -21
- 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 +268 -0
- data/lib/html2rss/html/article_extractor/heading_extractor.rb +24 -14
- data/lib/html2rss/html/article_extractor.rb +4 -9
- data/lib/html2rss/html/feed_link.rb +4 -2
- data/lib/html2rss/html/navigator.rb +4 -39
- data/lib/html2rss/html/probe.rb +82 -0
- data/lib/html2rss/html/sst_article_extractor.rb +1 -2
- data/lib/html2rss/link_destination/noise_policy.rb +1 -2
- data/lib/html2rss/link_destination/path_classifier.rb +20 -19
- data/lib/html2rss/mcp/README.md +80 -0
- data/lib/html2rss/mcp/contract.rb +135 -13
- data/lib/html2rss/mcp/outcome/playbook.rb +122 -0
- data/lib/html2rss/mcp/outcome.rb +112 -40
- data/lib/html2rss/mcp/runtime.rb +45 -0
- data/lib/html2rss/mcp/server/tools.rb +277 -0
- data/lib/html2rss/mcp/server.rb +91 -232
- data/lib/html2rss/mcp.rb +3 -0
- data/lib/html2rss/page_recon/README.md +69 -0
- data/lib/html2rss/page_recon/diagnostics.rb +211 -0
- data/lib/html2rss/page_recon.rb +297 -0
- data/lib/html2rss/recon.rb +252 -0
- data/lib/html2rss/request_service/blocked_surface.rb +1 -0
- data/lib/html2rss/request_service/botasaurus_contract.rb +8 -1
- data/lib/html2rss/request_service/botasaurus_strategy.rb +1 -1
- data/lib/html2rss/request_service/faraday_strategy.rb +11 -7
- data/lib/html2rss/request_service/policy.rb +1 -2
- data/lib/html2rss/request_service/response.rb +27 -4
- data/lib/html2rss/request_service/strategy.rb +1 -2
- data/lib/html2rss/request_service.rb +15 -1
- data/lib/html2rss/request_session.rb +4 -2
- data/lib/html2rss/scrape_target.rb +24 -0
- data/lib/html2rss/selectors/post_processors/sanitize_html.rb +2 -4
- data/lib/html2rss/selectors/post_processors.rb +0 -1
- data/lib/html2rss/selectors.rb +33 -10
- data/lib/html2rss/sst/attrs.rb +1 -2
- data/lib/html2rss/sst/normalizer.rb +49 -11
- data/lib/html2rss/status.rb +48 -24
- 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 +215 -0
- data/lib/html2rss/syndication/parser.rb +137 -0
- data/lib/html2rss/syndication.rb +10 -0
- data/lib/html2rss/test/enhance_audit.rb +192 -0
- data/lib/html2rss/test.rb +458 -0
- data/lib/html2rss/url.rb +23 -2
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss.rb +157 -35
- data/schema/html2rss-config.schema.json +47 -29
- metadata +34 -18
- data/lib/html2rss/mcp/inspect.rb +0 -241
- 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.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|