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.
- 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 +8 -8
- 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 +5 -2
- data/lib/html2rss/auto_source/scraper/schema.rb +39 -21
- data/lib/html2rss/auto_source/scraper.rb +2 -3
- 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 +25 -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/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 +59 -22
- data/lib/html2rss/doctor/botasaurus.rb +130 -0
- data/lib/html2rss/feed_builder/rss.rb +1 -2
- data/lib/html2rss/feed_pipeline/README.md +5 -5
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +29 -10
- data/lib/html2rss/feed_pipeline.rb +27 -16
- data/lib/html2rss/feed_resolution/README.md +11 -11
- data/lib/html2rss/feed_resolution.rb +6 -12
- 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 -42
- 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 +56 -2
- data/lib/html2rss/recon.rb +252 -0
- data/lib/html2rss/request_service/blocked_surface.rb +1 -0
- 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 +3 -4
- data/lib/html2rss/request_service/strategy.rb +1 -2
- data/lib/html2rss/request_service.rb +2 -0
- data/lib/html2rss/request_session.rb +1 -2
- data/lib/html2rss/scrape_target.rb +1 -1
- 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 +3 -6
- data/lib/html2rss/syndication/README.md +6 -6
- data/lib/html2rss/syndication/discovery.rb +2 -4
- data/lib/html2rss/test/enhance_audit.rb +192 -0
- data/lib/html2rss/test.rb +458 -0
- data/lib/html2rss/url.rb +1 -2
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss.rb +157 -35
- data/schema/html2rss-config.schema.json +9 -29
- metadata +17 -18
- data/lib/html2rss/mcp/inspect.rb +0 -138
- 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
|
data/lib/html2rss/page_recon.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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.
|