html2rss 0.28.0 → 0.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +84 -27
- data/html2rss.gemspec +1 -4
- 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 +159 -16
- 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 +9 -16
- data/lib/html2rss/config/schema.rb +7 -5
- data/lib/html2rss/config/validator.rb +1 -0
- data/lib/html2rss/config.rb +68 -19
- data/lib/html2rss/doctor/botasaurus.rb +138 -0
- data/lib/html2rss/feed_builder/rss.rb +1 -2
- data/lib/html2rss/feed_pipeline/README.md +11 -11
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +30 -11
- data/lib/html2rss/feed_pipeline/strategy_plan.rb +1 -1
- 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 +144 -18
- 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 +92 -233
- 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/botasaurus_strategy.rb +32 -28
- data/lib/html2rss/request_service/compressed_body.rb +13 -8
- data/lib/html2rss/request_service/httpx_strategy.rb +228 -0
- data/lib/html2rss/request_service/policy.rb +1 -2
- data/lib/html2rss/request_service/response.rb +3 -4
- data/lib/html2rss/request_service/response_guard.rb +0 -16
- data/lib/html2rss/request_service/strategy.rb +20 -5
- data/lib/html2rss/request_service.rb +48 -85
- 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 +459 -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 +21 -56
- data/lib/html2rss/mcp/inspect.rb +0 -138
- data/lib/html2rss/request_service/faraday_strategy.rb +0 -229
- data/lib/html2rss/selectors/post_processors/html_to_markdown.rb +0 -59
|
@@ -1,229 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require 'brotli'
|
|
4
|
-
require 'faraday'
|
|
5
|
-
require 'faraday/follow_redirects'
|
|
6
|
-
require 'faraday/gzip'
|
|
7
|
-
|
|
8
|
-
module Html2rss
|
|
9
|
-
class RequestService
|
|
10
|
-
##
|
|
11
|
-
# Strategy to use Faraday for the request.
|
|
12
|
-
# @see https://rubygems.org/gems/faraday
|
|
13
|
-
# rubocop:disable Metrics/ClassLength -- terminal redirect retry colocated with Faraday transport
|
|
14
|
-
class FaradayStrategy < Strategy
|
|
15
|
-
##
|
|
16
|
-
# Restores buffered streamed bytes so response middleware can process them.
|
|
17
|
-
class StreamingBodyMiddleware < Faraday::Middleware
|
|
18
|
-
# Request-context key used to store streamed chunks before middleware completion.
|
|
19
|
-
STREAM_BUFFER_KEY = :html2rss_stream_buffer
|
|
20
|
-
|
|
21
|
-
# @param env [Faraday::Env] completed response environment
|
|
22
|
-
# @return [void]
|
|
23
|
-
def on_complete(env)
|
|
24
|
-
buffer = env.request.context&.delete(STREAM_BUFFER_KEY)
|
|
25
|
-
return if buffer.nil? || buffer.empty?
|
|
26
|
-
|
|
27
|
-
env.body = buffer
|
|
28
|
-
end
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
##
|
|
32
|
-
# @return [ResponseGuard]
|
|
33
|
-
attr_reader :response_guard
|
|
34
|
-
|
|
35
|
-
# Executes the request with runtime policy enforcement, returning the normalized response.
|
|
36
|
-
#
|
|
37
|
-
# @return [Response] normalized response
|
|
38
|
-
def perform_execute
|
|
39
|
-
deadline = request_deadline
|
|
40
|
-
@response_guard = ResponseGuard.new(policy: ctx.policy)
|
|
41
|
-
reset_redirect_tracking!
|
|
42
|
-
raw_response = request_with_terminal_redirect_retry(response_guard, deadline:)
|
|
43
|
-
raw_response = retry_without_streaming(response_guard, deadline:) if retry_without_streaming?(raw_response)
|
|
44
|
-
build_response(raw_response)
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
private
|
|
48
|
-
|
|
49
|
-
def request_deadline
|
|
50
|
-
monotonic_now + ctx.budget.effective_timeout_seconds(fallback: ctx.policy.total_timeout_seconds)
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
def reset_redirect_tracking!
|
|
54
|
-
@last_redirect_to = nil
|
|
55
|
-
@terminal_redirect_retried = false
|
|
56
|
-
@request_url_override = nil
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
def request_with_terminal_redirect_retry(response_guard, deadline:)
|
|
60
|
-
faraday_request(response_guard, deadline:, streaming_buffer: true)
|
|
61
|
-
rescue Faraday::FollowRedirects::RedirectLimitReached => error
|
|
62
|
-
raise error unless terminal_redirect_retryable?
|
|
63
|
-
|
|
64
|
-
retry_from_terminal_redirect!(response_guard, deadline:)
|
|
65
|
-
end
|
|
66
|
-
|
|
67
|
-
def terminal_redirect_retryable?
|
|
68
|
-
return false if @terminal_redirect_retried || @last_redirect_to.nil?
|
|
69
|
-
|
|
70
|
-
@last_redirect_to.to_s != request_url.to_s
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
def retry_from_terminal_redirect!(response_guard, deadline:)
|
|
74
|
-
terminal_url = @last_redirect_to
|
|
75
|
-
@terminal_redirect_retried = true
|
|
76
|
-
Log.debug("#{self.class}: redirect limit reached; retrying once from #{terminal_url}")
|
|
77
|
-
begin_terminal_url_request!(terminal_url)
|
|
78
|
-
faraday_request(response_guard, deadline:, streaming_buffer: true, consume_budget: false)
|
|
79
|
-
end
|
|
80
|
-
|
|
81
|
-
def begin_terminal_url_request!(terminal_url)
|
|
82
|
-
ctx.policy.validate_request!(url: terminal_url, origin_url: ctx.origin_url, relation: ctx.relation)
|
|
83
|
-
@request_url_override = terminal_url
|
|
84
|
-
@client = nil
|
|
85
|
-
@last_redirect_to = nil
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
def request_url
|
|
89
|
-
@request_url_override || ctx.url
|
|
90
|
-
end
|
|
91
|
-
|
|
92
|
-
def build_response(response)
|
|
93
|
-
Response.new(body: CompressedBody.decode(response.body, headers: response.headers),
|
|
94
|
-
headers: response.headers, url: response_url(response),
|
|
95
|
-
status: response.status)
|
|
96
|
-
end
|
|
97
|
-
|
|
98
|
-
def faraday_request(response_guard, deadline:, streaming_buffer:, consume_budget: true)
|
|
99
|
-
preflight!(consume_budget:)
|
|
100
|
-
|
|
101
|
-
client.get do |req|
|
|
102
|
-
apply_timeouts(req, deadline:)
|
|
103
|
-
buffer = prepare_stream_buffer(req) if streaming_buffer
|
|
104
|
-
req.options.on_data = on_data_callback(response_guard, buffer)
|
|
105
|
-
end
|
|
106
|
-
end
|
|
107
|
-
|
|
108
|
-
def retry_without_streaming(response_guard, deadline:)
|
|
109
|
-
faraday_request(response_guard, deadline:, streaming_buffer: false, consume_budget: false)
|
|
110
|
-
end
|
|
111
|
-
|
|
112
|
-
##
|
|
113
|
-
# Validates the remote socket peer IP during Net::HTTP start.
|
|
114
|
-
module PeerIpValidator
|
|
115
|
-
module_function
|
|
116
|
-
|
|
117
|
-
# @param http [Net::HTTP] connection to configure
|
|
118
|
-
# @param policy [Policy] request policy
|
|
119
|
-
# @return [void]
|
|
120
|
-
def install!(http, policy:)
|
|
121
|
-
orig_start = http.method(:start)
|
|
122
|
-
http.define_singleton_method(:start) do |&block|
|
|
123
|
-
orig_start.call do |opened_http|
|
|
124
|
-
PeerIpValidator.validate!(opened_http, policy:)
|
|
125
|
-
block.call(opened_http)
|
|
126
|
-
end
|
|
127
|
-
end
|
|
128
|
-
end
|
|
129
|
-
|
|
130
|
-
# @param opened_http [Net::HTTP] active connection
|
|
131
|
-
# @param policy [Policy] request policy
|
|
132
|
-
# @return [void]
|
|
133
|
-
def validate!(opened_http, policy:)
|
|
134
|
-
scheme = opened_http.use_ssl? ? 'https' : 'http'
|
|
135
|
-
url = Html2rss::Url.from_absolute("#{scheme}://#{opened_http.address}:#{opened_http.port}")
|
|
136
|
-
policy.validate_remote_ip!(ip: peer_ip_for(opened_http), url:)
|
|
137
|
-
end
|
|
138
|
-
|
|
139
|
-
# @param opened_http [Net::HTTP]
|
|
140
|
-
# @return [String, nil]
|
|
141
|
-
def peer_ip_for(opened_http)
|
|
142
|
-
sock = opened_http.instance_variable_get(:@socket)
|
|
143
|
-
io = sock.respond_to?(:io) ? sock.io : sock
|
|
144
|
-
peeraddr = io.respond_to?(:peeraddr) ? io.peeraddr : nil
|
|
145
|
-
peeraddr&.[](3) || peeraddr&.[](2)
|
|
146
|
-
end
|
|
147
|
-
end
|
|
148
|
-
|
|
149
|
-
# rubocop:disable Metrics/AbcSize
|
|
150
|
-
def client
|
|
151
|
-
@client ||= Faraday.new(url: request_url.to_s, headers: ctx.headers) do |faraday|
|
|
152
|
-
faraday.use Faraday::FollowRedirects::Middleware, limit: ctx.policy.max_redirects, callback: redirect_callback
|
|
153
|
-
faraday.request :gzip
|
|
154
|
-
faraday.use StreamingBodyMiddleware
|
|
155
|
-
faraday.adapter Faraday.default_adapter do |http|
|
|
156
|
-
PeerIpValidator.install!(http, policy: ctx.policy)
|
|
157
|
-
end
|
|
158
|
-
end
|
|
159
|
-
end
|
|
160
|
-
# rubocop:enable Metrics/AbcSize
|
|
161
|
-
|
|
162
|
-
def apply_timeouts(request, deadline:)
|
|
163
|
-
remaining_timeout = remaining_timeout_seconds(deadline)
|
|
164
|
-
request.options.timeout = remaining_timeout
|
|
165
|
-
request.options.open_timeout = [ctx.policy.connect_timeout_seconds, remaining_timeout].min
|
|
166
|
-
request.options.read_timeout = [ctx.policy.read_timeout_seconds, remaining_timeout].min
|
|
167
|
-
end
|
|
168
|
-
|
|
169
|
-
def prepare_stream_buffer(request)
|
|
170
|
-
request.options.context ||= {}
|
|
171
|
-
request.options.context[StreamingBodyMiddleware::STREAM_BUFFER_KEY] = +''
|
|
172
|
-
end
|
|
173
|
-
|
|
174
|
-
def on_data_callback(response_guard, buffer)
|
|
175
|
-
proc do |chunk, total_bytes, env|
|
|
176
|
-
response_guard.inspect_chunk!(total_bytes:, headers: env&.response_headers)
|
|
177
|
-
buffer&.<< chunk
|
|
178
|
-
end
|
|
179
|
-
end
|
|
180
|
-
|
|
181
|
-
def remaining_timeout_seconds(deadline)
|
|
182
|
-
remaining = deadline - monotonic_now
|
|
183
|
-
raise RequestTimedOut, 'Request timed out' if remaining <= 0
|
|
184
|
-
|
|
185
|
-
remaining
|
|
186
|
-
end
|
|
187
|
-
|
|
188
|
-
def retry_without_streaming?(response)
|
|
189
|
-
return false if response.body.to_s.empty? == false
|
|
190
|
-
return false unless response_success?(response)
|
|
191
|
-
|
|
192
|
-
final_url = response.env&.url
|
|
193
|
-
return false unless final_url
|
|
194
|
-
|
|
195
|
-
final_url.to_s != ctx.url.to_s
|
|
196
|
-
end
|
|
197
|
-
|
|
198
|
-
def response_success?(response)
|
|
199
|
-
return true if response.status.nil?
|
|
200
|
-
|
|
201
|
-
response.status >= 200 && response.status < 300
|
|
202
|
-
end
|
|
203
|
-
|
|
204
|
-
def response_url(response)
|
|
205
|
-
return ctx.url unless (url = response.env&.url)
|
|
206
|
-
|
|
207
|
-
Html2rss::Url.from_absolute(url.to_s)
|
|
208
|
-
end
|
|
209
|
-
|
|
210
|
-
def redirect_callback
|
|
211
|
-
lambda do |old_env, new_env|
|
|
212
|
-
from_url = normalize_url(old_env[:url])
|
|
213
|
-
to_url = normalize_url(new_env[:url])
|
|
214
|
-
@last_redirect_to = to_url
|
|
215
|
-
ctx.policy.validate_redirect!(from_url:, to_url:, origin_url: ctx.origin_url, relation: ctx.relation)
|
|
216
|
-
end
|
|
217
|
-
end
|
|
218
|
-
|
|
219
|
-
def normalize_url(url)
|
|
220
|
-
Html2rss::Url.from_absolute(url.to_s)
|
|
221
|
-
end
|
|
222
|
-
|
|
223
|
-
def monotonic_now
|
|
224
|
-
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
225
|
-
end
|
|
226
|
-
end
|
|
227
|
-
# rubocop:enable Metrics/ClassLength
|
|
228
|
-
end
|
|
229
|
-
end
|
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require 'reverse_markdown'
|
|
4
|
-
|
|
5
|
-
module Html2rss
|
|
6
|
-
class Selectors
|
|
7
|
-
module PostProcessors
|
|
8
|
-
##
|
|
9
|
-
# Returns HTML code as Markdown formatted String.
|
|
10
|
-
# Before converting to markdown, the HTML is sanitized with SanitizeHtml.
|
|
11
|
-
# Imagine this HTML structure:
|
|
12
|
-
#
|
|
13
|
-
# <section>
|
|
14
|
-
# Lorem <b>ipsum</b> dolor...
|
|
15
|
-
# <iframe src="https://evil.corp/miner"></iframe>
|
|
16
|
-
# <script>alert();</script>
|
|
17
|
-
# </section>
|
|
18
|
-
#
|
|
19
|
-
# YAML usage example:
|
|
20
|
-
#
|
|
21
|
-
# selectors:
|
|
22
|
-
# description:
|
|
23
|
-
# selector: section
|
|
24
|
-
# extractor: html
|
|
25
|
-
# post_process:
|
|
26
|
-
# name: html_to_markdown
|
|
27
|
-
#
|
|
28
|
-
# Would return:
|
|
29
|
-
# 'Lorem **ipsum** dolor'
|
|
30
|
-
class HtmlToMarkdown < Base
|
|
31
|
-
# JSON Schema description exported via +schema_doc+.
|
|
32
|
-
DESCRIPTION = 'Sanitize HTML then convert it to Markdown (via ReverseMarkdown).'
|
|
33
|
-
|
|
34
|
-
# Example post-process objects for JSON Schema +examples+.
|
|
35
|
-
EXAMPLES = [
|
|
36
|
-
{ 'name' => 'html_to_markdown' }
|
|
37
|
-
].freeze
|
|
38
|
-
|
|
39
|
-
# @return [Hash{Symbol => Object}] JSON Schema fragment for this post-processor
|
|
40
|
-
def self.schema_doc = SchemaDoc.for_post_processor(name: :html_to_markdown, klass: self)
|
|
41
|
-
|
|
42
|
-
# @param value [String] extracted selector value
|
|
43
|
-
# @param context [Selectors::Context] post-processor context
|
|
44
|
-
# @return [void]
|
|
45
|
-
def self.validate_args!(value, context)
|
|
46
|
-
assert_type value, String, :value, context:
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
##
|
|
50
|
-
# @return [String] formatted in Markdown
|
|
51
|
-
def get
|
|
52
|
-
sanitized_value = SanitizeHtml.new(value, context).get
|
|
53
|
-
|
|
54
|
-
ReverseMarkdown.convert(sanitized_value)
|
|
55
|
-
end
|
|
56
|
-
end
|
|
57
|
-
end
|
|
58
|
-
end
|
|
59
|
-
end
|