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.
Files changed (85) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +84 -27
  3. data/html2rss.gemspec +1 -4
  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 +159 -16
  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 +9 -16
  26. data/lib/html2rss/config/schema.rb +7 -5
  27. data/lib/html2rss/config/validator.rb +1 -0
  28. data/lib/html2rss/config.rb +68 -19
  29. data/lib/html2rss/doctor/botasaurus.rb +138 -0
  30. data/lib/html2rss/feed_builder/rss.rb +1 -2
  31. data/lib/html2rss/feed_pipeline/README.md +11 -11
  32. data/lib/html2rss/feed_pipeline/auto_fallback.rb +30 -11
  33. data/lib/html2rss/feed_pipeline/strategy_plan.rb +1 -1
  34. data/lib/html2rss/feed_pipeline.rb +27 -16
  35. data/lib/html2rss/feed_resolution/README.md +11 -11
  36. data/lib/html2rss/feed_resolution.rb +6 -12
  37. data/lib/html2rss/html/article_extractor/heading_extractor.rb +24 -14
  38. data/lib/html2rss/html/article_extractor.rb +4 -9
  39. data/lib/html2rss/html/feed_link.rb +4 -2
  40. data/lib/html2rss/html/navigator.rb +4 -39
  41. data/lib/html2rss/html/probe.rb +82 -0
  42. data/lib/html2rss/html/sst_article_extractor.rb +1 -2
  43. data/lib/html2rss/link_destination/noise_policy.rb +1 -2
  44. data/lib/html2rss/link_destination/path_classifier.rb +20 -19
  45. data/lib/html2rss/mcp/README.md +80 -0
  46. data/lib/html2rss/mcp/contract.rb +144 -18
  47. data/lib/html2rss/mcp/outcome/playbook.rb +122 -0
  48. data/lib/html2rss/mcp/outcome.rb +112 -42
  49. data/lib/html2rss/mcp/runtime.rb +45 -0
  50. data/lib/html2rss/mcp/server/tools.rb +277 -0
  51. data/lib/html2rss/mcp/server.rb +92 -233
  52. data/lib/html2rss/mcp.rb +3 -0
  53. data/lib/html2rss/page_recon/README.md +69 -0
  54. data/lib/html2rss/page_recon/diagnostics.rb +211 -0
  55. data/lib/html2rss/page_recon.rb +56 -2
  56. data/lib/html2rss/recon.rb +252 -0
  57. data/lib/html2rss/request_service/blocked_surface.rb +1 -0
  58. data/lib/html2rss/request_service/botasaurus_strategy.rb +32 -28
  59. data/lib/html2rss/request_service/compressed_body.rb +13 -8
  60. data/lib/html2rss/request_service/httpx_strategy.rb +228 -0
  61. data/lib/html2rss/request_service/policy.rb +1 -2
  62. data/lib/html2rss/request_service/response.rb +3 -4
  63. data/lib/html2rss/request_service/response_guard.rb +0 -16
  64. data/lib/html2rss/request_service/strategy.rb +20 -5
  65. data/lib/html2rss/request_service.rb +48 -85
  66. data/lib/html2rss/request_session.rb +1 -2
  67. data/lib/html2rss/scrape_target.rb +1 -1
  68. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +2 -4
  69. data/lib/html2rss/selectors/post_processors.rb +0 -1
  70. data/lib/html2rss/selectors.rb +33 -10
  71. data/lib/html2rss/sst/attrs.rb +1 -2
  72. data/lib/html2rss/sst/normalizer.rb +49 -11
  73. data/lib/html2rss/status.rb +3 -6
  74. data/lib/html2rss/syndication/README.md +6 -6
  75. data/lib/html2rss/syndication/discovery.rb +2 -4
  76. data/lib/html2rss/test/enhance_audit.rb +192 -0
  77. data/lib/html2rss/test.rb +459 -0
  78. data/lib/html2rss/url.rb +1 -2
  79. data/lib/html2rss/version.rb +1 -1
  80. data/lib/html2rss.rb +157 -35
  81. data/schema/html2rss-config.schema.json +9 -29
  82. metadata +21 -56
  83. data/lib/html2rss/mcp/inspect.rb +0 -138
  84. data/lib/html2rss/request_service/faraday_strategy.rb +0 -229
  85. 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