html2rss 0.28.0 → 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 (80) 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 +8 -8
  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 +5 -2
  11. data/lib/html2rss/auto_source/scraper/schema.rb +39 -21
  12. data/lib/html2rss/auto_source/scraper.rb +2 -3
  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 +25 -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/request_headers.rb +4 -14
  26. data/lib/html2rss/config/schema.rb +7 -5
  27. data/lib/html2rss/config/validator.rb +1 -0
  28. data/lib/html2rss/config.rb +59 -22
  29. data/lib/html2rss/doctor/botasaurus.rb +130 -0
  30. data/lib/html2rss/feed_builder/rss.rb +1 -2
  31. data/lib/html2rss/feed_pipeline/README.md +5 -5
  32. data/lib/html2rss/feed_pipeline/auto_fallback.rb +29 -10
  33. data/lib/html2rss/feed_pipeline.rb +27 -16
  34. data/lib/html2rss/feed_resolution/README.md +11 -11
  35. data/lib/html2rss/feed_resolution.rb +6 -12
  36. data/lib/html2rss/html/article_extractor/heading_extractor.rb +24 -14
  37. data/lib/html2rss/html/article_extractor.rb +4 -9
  38. data/lib/html2rss/html/feed_link.rb +4 -2
  39. data/lib/html2rss/html/navigator.rb +4 -39
  40. data/lib/html2rss/html/probe.rb +82 -0
  41. data/lib/html2rss/html/sst_article_extractor.rb +1 -2
  42. data/lib/html2rss/link_destination/noise_policy.rb +1 -2
  43. data/lib/html2rss/link_destination/path_classifier.rb +20 -19
  44. data/lib/html2rss/mcp/README.md +80 -0
  45. data/lib/html2rss/mcp/contract.rb +135 -13
  46. data/lib/html2rss/mcp/outcome/playbook.rb +122 -0
  47. data/lib/html2rss/mcp/outcome.rb +112 -42
  48. data/lib/html2rss/mcp/runtime.rb +45 -0
  49. data/lib/html2rss/mcp/server/tools.rb +277 -0
  50. data/lib/html2rss/mcp/server.rb +91 -232
  51. data/lib/html2rss/mcp.rb +3 -0
  52. data/lib/html2rss/page_recon/README.md +69 -0
  53. data/lib/html2rss/page_recon/diagnostics.rb +211 -0
  54. data/lib/html2rss/page_recon.rb +56 -2
  55. data/lib/html2rss/recon.rb +252 -0
  56. data/lib/html2rss/request_service/blocked_surface.rb +1 -0
  57. data/lib/html2rss/request_service/faraday_strategy.rb +11 -7
  58. data/lib/html2rss/request_service/policy.rb +1 -2
  59. data/lib/html2rss/request_service/response.rb +3 -4
  60. data/lib/html2rss/request_service/strategy.rb +1 -2
  61. data/lib/html2rss/request_service.rb +2 -0
  62. data/lib/html2rss/request_session.rb +1 -2
  63. data/lib/html2rss/scrape_target.rb +1 -1
  64. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +2 -4
  65. data/lib/html2rss/selectors/post_processors.rb +0 -1
  66. data/lib/html2rss/selectors.rb +33 -10
  67. data/lib/html2rss/sst/attrs.rb +1 -2
  68. data/lib/html2rss/sst/normalizer.rb +49 -11
  69. data/lib/html2rss/status.rb +3 -6
  70. data/lib/html2rss/syndication/README.md +6 -6
  71. data/lib/html2rss/syndication/discovery.rb +2 -4
  72. data/lib/html2rss/test/enhance_audit.rb +192 -0
  73. data/lib/html2rss/test.rb +458 -0
  74. data/lib/html2rss/url.rb +1 -2
  75. data/lib/html2rss/version.rb +1 -1
  76. data/lib/html2rss.rb +157 -35
  77. data/schema/html2rss-config.schema.json +9 -29
  78. metadata +17 -18
  79. data/lib/html2rss/mcp/inspect.rb +0 -138
  80. data/lib/html2rss/selectors/post_processors/html_to_markdown.rb +0 -59
@@ -0,0 +1,69 @@
1
+ # PageRecon — inspect diagnostics
2
+
3
+ `PageRecon::Diagnostics` powers the **inspect** verb (CLI, MCP, `Html2rss.inspect`). It fetches once via `PageRecon.probe`, classifies the surface, counts cheap AutoSource articles, and reports redirect facts. Full curation contract: `CONTEXT.md` § Frozen contract.
4
+
5
+ ## What inspect reports
6
+
7
+ | Field | Meaning |
8
+ | ------------------ | --------------------------------------------------------------------------- |
9
+ | `requested_url` | URL you passed in |
10
+ | `final_url` | URL after redirects (may differ from requested) |
11
+ | `status` | HTTP status of the **final** response |
12
+ | `scheme_downgrade` | `true` when HTTPS entry landed on HTTP |
13
+ | `alternate_feeds` | `rel=alternate` RSS/Atom links found in HTML |
14
+ | `surface_category` | AutoSource surface class (`high_entropy_surface`, `unsupported_surface`, …) |
15
+ | `articles_count` | Cheap AutoSource extract (limit 10) — diagnostic only, not ship quality |
16
+ | `strategy` | Concrete transport used (`inspect` maps `auto` → Faraday) |
17
+
18
+ CLI text output omits `requested_url`; it prints the requested URL as the card title. The `Final:` line appears **only when** `final_url` differs from `requested_url`:
19
+
20
+ ```text
21
+ https://apex.example/
22
+ Final: https://www.example/ (HTTP 200)
23
+ Surface: high_entropy_surface (10 articles)
24
+ ```
25
+
26
+ That `Final:` line means the redirect **did** happen — not that inspect stopped at the apex host.
27
+
28
+ ## Apex → www redirects
29
+
30
+ Some publishers redirect apex domains to `www` (301). html2rss follows redirects and records the landing URL in `final_url`.
31
+
32
+ Observed behavior:
33
+
34
+ ```bash
35
+ bin/html2rss inspect https://apex.example
36
+ # Final: https://www.example/ (HTTP 200), high_entropy_surface, articles
37
+
38
+ bin/html2rss inspect https://www.example
39
+ # high_entropy_surface, articles (no Final: line — requested equals final)
40
+ ```
41
+
42
+ Cross-host redirects (e.g. `apex.example` → `www.example`) no longer pin a stale `Host` header from the entry URL. `Config::RequestHeaders` omits `Host` by default; Faraday/Net::HTTP sets it per hop from the current request URL.
43
+
44
+ When the first streamed fetch loses the redirect payload (empty body at the final URL), `FaradayStrategy` retries once without streaming so inspect/recon see the same HTML as a direct canonical URL fetch.
45
+
46
+ ### What to do
47
+
48
+ 1. **Prefer the canonical URL** — if you know the site lives on `www`, pass that URL to inspect, recon, capture, and scrape.
49
+ 2. **Read the `Final:` line** — when `final_url` differs from what you typed and status is 4xx, retry inspect on `final_url` before assuming the site is unreachable.
50
+ 3. **Do not treat apex 403 as “redirect skipped”** — check JSON output (`--format json`) for `requested_url` vs `final_url` when text output is ambiguous.
51
+ 4. **Blocked surfaces** — if the canonical URL still fails, try `strategy: botasaurus` on inspect (MCP) or escalate to recon/capture with browser strategy; Faraday-only inspect is intentionally cheap.
52
+
53
+ ## inspect ≠ recon
54
+
55
+ | Verb | Adds beyond diagnostics |
56
+ | ------- | -------------------------------------------------------------------- |
57
+ | inspect | Scraper eligibility, XHR hints (Botasaurus), surface + article count |
58
+ | recon | Verdict (`:build` / `:defer` / `:drop`), native feed preference |
59
+
60
+ Follow golden-path `next_step` from MCP envelopes; do not call recon when inspect already answers the question.
61
+
62
+ ## Ownership
63
+
64
+ | Concern | Owner |
65
+ | ----------------------------------- | -------------------------------------------------------------------- |
66
+ | Diagnostic fetch + assess | `PageRecon::Diagnostics` → `PageRecon.probe` |
67
+ | Surface class + cheap article count | `PageRecon::Assessment` |
68
+ | Redirect follow + terminal retry | `RequestService::FaradayStrategy` |
69
+ | Outbound header normalization | `Config::RequestHeaders` (no default `Host`; explicit override only) |
@@ -0,0 +1,211 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ class PageRecon
5
+ ##
6
+ # Diagnostic inspect path (not Capture or Recon ownership). Fetches via {.probe},
7
+ # then adds scraper/XHR diagnostics for curation inspect surfaces.
8
+ module Diagnostics # rubocop:disable Metrics/ModuleLength -- diagnostic wire fields stay co-located
9
+ ##
10
+ # Typed diagnostic report for inspect wire payloads and Outcome policy.
11
+ Report = Data.define(:data) do
12
+ ##
13
+ # @return [Boolean]
14
+ def alternate_feeds?
15
+ Array(data[:alternate_feeds]).any?
16
+ end
17
+
18
+ ##
19
+ # @return [Integer]
20
+ def articles_count
21
+ data[:articles_count].to_i
22
+ end
23
+
24
+ ##
25
+ # @return [Hash{Symbol => Object}]
26
+ def to_wire_h
27
+ data
28
+ end
29
+ end
30
+
31
+ # Minimum HTML body size to treat zero-article weak surfaces as likely JS shells.
32
+ JS_SHELL_MIN_BODY_BYTES = 8_192
33
+ private_constant :JS_SHELL_MIN_BODY_BYTES
34
+
35
+ module_function
36
+
37
+ ##
38
+ # @param url [String]
39
+ # @param strategy [String, Symbol]
40
+ # @param deep [Boolean] when true and strategy is auto, one Botasaurus hop if configured
41
+ # @return [Report]
42
+ def call(url:, strategy: :auto, deep: false)
43
+ probe = PageRecon.probe(url, strategy: resolve_inspect_strategy(strategy, deep:))
44
+ recon = probe.result
45
+ response = probe.response
46
+
47
+ Report.new(data: build_data(probe, recon, response))
48
+ end
49
+
50
+ ##
51
+ # Runs diagnostic inspect across URLs with per-URL error isolation.
52
+ #
53
+ # @param urls [Enumerable<String>]
54
+ # @param strategy [Symbol, String]
55
+ # @param concurrency [Integer]
56
+ # @return [Array<Report>]
57
+ def batch(urls:, strategy: :auto, concurrency: Batch::DEFAULT_CONCURRENCY)
58
+ Batch.map(Array(urls), concurrency:) do |url|
59
+ call(url:, strategy:)
60
+ rescue StandardError => error
61
+ error_report(url, error)
62
+ end
63
+ end
64
+
65
+ ##
66
+ # @param response [Html2rss::RequestService::Response]
67
+ # @return [Hash] redacted XHR capture diagnostics (no query strings)
68
+ def xhr_capture_info(response)
69
+ captured = response.captured_responses
70
+ {
71
+ count: captured.size,
72
+ sample_endpoints: captured.first(5).filter_map { |entry| redacted_endpoint(entry) },
73
+ candidate_articles: captured.any? { |entry| xhr_candidate_articles?(entry) }
74
+ }
75
+ end
76
+
77
+ ##
78
+ # @param entry [Hash] captured response hash
79
+ # @return [String, nil] scheme+host+path only
80
+ def redacted_endpoint(entry)
81
+ raw = entry['url'] || entry[:url]
82
+ return unless raw
83
+
84
+ uri = URI.parse(raw.to_s)
85
+ return unless uri.scheme && uri.host
86
+
87
+ "#{uri.scheme}://#{uri.host}#{uri.path}"
88
+ rescue URI::InvalidURIError
89
+ nil
90
+ end
91
+
92
+ ##
93
+ # @param entry [Hash] captured response hash
94
+ # @return [Boolean]
95
+ def xhr_candidate_articles?(entry)
96
+ body = entry['body'] || entry[:body]
97
+ return false unless body.is_a?(String)
98
+
99
+ document = JSON.parse(body, symbolize_names: true)
100
+ AutoSource::Scraper::JsonState::CandidateDetector.candidate_array?(document)
101
+ rescue JSON::ParserError
102
+ false
103
+ end
104
+
105
+ ##
106
+ # @param parsed [Object] parsed response body
107
+ # @return [Array<String>, Hash]
108
+ def scraper_info(parsed)
109
+ return { error: 'Response is not HTML' } unless parsed.is_a?(Nokogiri::HTML::Document)
110
+
111
+ begin
112
+ Html2rss::AutoSource::Scraper.from(parsed).map(&:name)
113
+ rescue Html2rss::AutoSource::Scraper::NoScraperFound => error
114
+ { none_found: error.category.to_s }
115
+ end
116
+ end
117
+
118
+ def build_data(probe, recon, response)
119
+ data = recon.to_h.merge(
120
+ strategy: probe.strategy,
121
+ scraper_eligibility: scraper_info(safe_parsed_body(response)),
122
+ html_present: html_present?(recon, response),
123
+ likely_js_shell: likely_js_shell?(recon, response),
124
+ redirect_summary: redirect_summary(recon)
125
+ )
126
+ data[:xhr_capture] = xhr_capture_info(response) if probe.strategy == :botasaurus
127
+ log_js_shell(data, response.body&.bytesize.to_i) if data[:likely_js_shell]
128
+ data
129
+ end
130
+ module_function :build_data
131
+ private_class_method :build_data
132
+
133
+ def resolve_inspect_strategy(strategy, deep:)
134
+ name = (strategy || :auto).to_sym
135
+ return :botasaurus if deep && name == :auto && MCP::Runtime.botasaurus_configured?
136
+
137
+ name
138
+ end
139
+ module_function :resolve_inspect_strategy
140
+ private_class_method :resolve_inspect_strategy
141
+
142
+ def html_present?(recon, response)
143
+ recon.html_response && !response.body.to_s.empty?
144
+ end
145
+ module_function :html_present?
146
+ private_class_method :html_present?
147
+
148
+ def likely_js_shell?(recon, response)
149
+ return false unless html_present?(recon, response)
150
+ return false if recon.articles_count.positive?
151
+ return false if recon.blocked_surface || recon.surface_category == :blocked_surface
152
+
153
+ return true if recon.surface_category == :app_shell
154
+
155
+ response.body.bytesize >= JS_SHELL_MIN_BODY_BYTES &&
156
+ SurfaceCategory.coerce(recon.surface_category).weak?
157
+ end
158
+ module_function :likely_js_shell?
159
+ private_class_method :likely_js_shell?
160
+
161
+ def redirect_summary(recon)
162
+ {
163
+ requested_url: recon.requested_url,
164
+ final_url: recon.final_url,
165
+ status: recon.status,
166
+ scheme_downgrade: recon.scheme_downgrade
167
+ }
168
+ end
169
+ module_function :redirect_summary
170
+ private_class_method :redirect_summary
171
+
172
+ def log_js_shell(data, body_bytesize)
173
+ Log.debug(
174
+ "Diagnostics js_shell: bytesize=#{body_bytesize} surface_category=#{data[:surface_category]}"
175
+ )
176
+ end
177
+ module_function :log_js_shell
178
+ private_class_method :log_js_shell
179
+
180
+ def safe_parsed_body(response)
181
+ return unless response.html_response?
182
+
183
+ response.parsed_body
184
+ rescue RequestService::UnsupportedResponseContentType
185
+ nil
186
+ end
187
+ module_function :safe_parsed_body
188
+ private_class_method :safe_parsed_body
189
+
190
+ def error_report(url, error) # rubocop:disable Metrics/MethodLength -- error hash mirrors success report shape
191
+ Report.new(
192
+ data: {
193
+ requested_url: url.to_s,
194
+ final_url: url.to_s,
195
+ status: nil,
196
+ scheme_downgrade: false,
197
+ alternate_feeds: [],
198
+ surface_category: :unsupported_surface,
199
+ articles_count: 0,
200
+ html_response: false,
201
+ content_type: nil,
202
+ strategy: nil,
203
+ scraper_eligibility: { error: "#{error.class} - #{error.message}" }
204
+ }
205
+ )
206
+ end
207
+ module_function :error_report
208
+ private_class_method :error_report
209
+ end
210
+ end
211
+ end
@@ -2,10 +2,11 @@
2
2
 
3
3
  module Html2rss
4
4
  ##
5
- # Shared page recon for MCP Inspect and FeedResolution probes.
5
+ # Shared page recon for MCP Inspect, curation Recon, and FeedResolution probes.
6
6
  #
7
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.
8
+ # article count. Diagnostic fetch for Inspect/Recon lives on {.probe} not twin
9
+ # fetch helpers in those callers.
9
10
  class PageRecon # rubocop:disable Metrics/ClassLength -- recon bag stays co-located
10
11
  ##
11
12
  # Cheap surface + admission facts shared by AutoFallback gates and FeedResolution probes.
@@ -64,6 +65,11 @@ module Html2rss
64
65
  end
65
66
  end
66
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
+
67
73
  ##
68
74
  # @param response [Html2rss::RequestService::Response]
69
75
  # @param url [String, Html2rss::Url] requested entry URL
@@ -73,6 +79,54 @@ module Html2rss
73
79
  new(response:, url:).call
74
80
  end
75
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
+
76
130
  ##
77
131
  # Cheap page assessment for policy gates and probe scoring (fixed AutoSource limit).
78
132
  #
@@ -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. ' \
@@ -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.