html2rss 0.27.2 → 0.29.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) 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 +12 -9
  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 +93 -0
  11. data/lib/html2rss/auto_source/scraper/schema.rb +39 -21
  12. data/lib/html2rss/auto_source/scraper.rb +6 -4
  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 +32 -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/auto_source_contract.rb +7 -0
  26. data/lib/html2rss/config/request_headers.rb +4 -14
  27. data/lib/html2rss/config/schema.rb +7 -5
  28. data/lib/html2rss/config/validator.rb +1 -0
  29. data/lib/html2rss/config.rb +64 -18
  30. data/lib/html2rss/doctor/botasaurus.rb +130 -0
  31. data/lib/html2rss/feed_builder/rss.rb +1 -2
  32. data/lib/html2rss/feed_pipeline/README.md +7 -5
  33. data/lib/html2rss/feed_pipeline/auto_fallback.rb +78 -19
  34. data/lib/html2rss/feed_pipeline/runtime_policy.rb +1 -0
  35. data/lib/html2rss/feed_pipeline.rb +48 -21
  36. data/lib/html2rss/feed_resolution/README.md +52 -0
  37. data/lib/html2rss/feed_resolution/candidate_generator.rb +139 -0
  38. data/lib/html2rss/feed_resolution/diag.rb +32 -0
  39. data/lib/html2rss/feed_resolution/options.rb +31 -0
  40. data/lib/html2rss/feed_resolution/policy.rb +47 -0
  41. data/lib/html2rss/feed_resolution/probe.rb +59 -0
  42. data/lib/html2rss/feed_resolution/scorer.rb +49 -0
  43. data/lib/html2rss/feed_resolution.rb +268 -0
  44. data/lib/html2rss/html/article_extractor/heading_extractor.rb +24 -14
  45. data/lib/html2rss/html/article_extractor.rb +4 -9
  46. data/lib/html2rss/html/feed_link.rb +4 -2
  47. data/lib/html2rss/html/navigator.rb +4 -39
  48. data/lib/html2rss/html/probe.rb +82 -0
  49. data/lib/html2rss/html/sst_article_extractor.rb +1 -2
  50. data/lib/html2rss/link_destination/noise_policy.rb +1 -2
  51. data/lib/html2rss/link_destination/path_classifier.rb +20 -19
  52. data/lib/html2rss/mcp/README.md +80 -0
  53. data/lib/html2rss/mcp/contract.rb +135 -13
  54. data/lib/html2rss/mcp/outcome/playbook.rb +122 -0
  55. data/lib/html2rss/mcp/outcome.rb +112 -40
  56. data/lib/html2rss/mcp/runtime.rb +45 -0
  57. data/lib/html2rss/mcp/server/tools.rb +277 -0
  58. data/lib/html2rss/mcp/server.rb +91 -232
  59. data/lib/html2rss/mcp.rb +3 -0
  60. data/lib/html2rss/page_recon/README.md +69 -0
  61. data/lib/html2rss/page_recon/diagnostics.rb +211 -0
  62. data/lib/html2rss/page_recon.rb +297 -0
  63. data/lib/html2rss/recon.rb +252 -0
  64. data/lib/html2rss/request_service/blocked_surface.rb +1 -0
  65. data/lib/html2rss/request_service/botasaurus_contract.rb +8 -1
  66. data/lib/html2rss/request_service/botasaurus_strategy.rb +1 -1
  67. data/lib/html2rss/request_service/faraday_strategy.rb +11 -7
  68. data/lib/html2rss/request_service/policy.rb +1 -2
  69. data/lib/html2rss/request_service/response.rb +27 -4
  70. data/lib/html2rss/request_service/strategy.rb +1 -2
  71. data/lib/html2rss/request_service.rb +15 -1
  72. data/lib/html2rss/request_session.rb +4 -2
  73. data/lib/html2rss/scrape_target.rb +24 -0
  74. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +2 -4
  75. data/lib/html2rss/selectors/post_processors.rb +0 -1
  76. data/lib/html2rss/selectors.rb +33 -10
  77. data/lib/html2rss/sst/attrs.rb +1 -2
  78. data/lib/html2rss/sst/normalizer.rb +49 -11
  79. data/lib/html2rss/status.rb +48 -24
  80. data/lib/html2rss/surface_category.rb +63 -0
  81. data/lib/html2rss/syndication/README.md +21 -0
  82. data/lib/html2rss/syndication/candidate_catalog.rb +26 -0
  83. data/lib/html2rss/syndication/discovery.rb +215 -0
  84. data/lib/html2rss/syndication/parser.rb +137 -0
  85. data/lib/html2rss/syndication.rb +10 -0
  86. data/lib/html2rss/test/enhance_audit.rb +192 -0
  87. data/lib/html2rss/test.rb +458 -0
  88. data/lib/html2rss/url.rb +23 -2
  89. data/lib/html2rss/version.rb +1 -1
  90. data/lib/html2rss.rb +157 -35
  91. data/schema/html2rss-config.schema.json +47 -29
  92. metadata +34 -18
  93. data/lib/html2rss/mcp/inspect.rb +0 -241
  94. data/lib/html2rss/selectors/post_processors/html_to_markdown.rb +0 -59
@@ -1,241 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Html2rss
4
- module MCP
5
- ##
6
- # Diagnostic inspect path (not Capture ownership). Fetches once for SST/scraper stats
7
- # and recon facts (final URL, status, scheme downgrade, native feed hints).
8
- module Inspect # rubocop:disable Metrics/ModuleLength -- diagnostic helpers stay co-located
9
- module_function
10
-
11
- ##
12
- # @param url [String]
13
- # @param strategy [String, Symbol]
14
- # @return [Hash]
15
- def call(url:, strategy: :auto) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
16
- resolved = FeedPipeline::StrategyPlan.concrete_for_diagnostic(strategy)
17
- response = fetch_response(url, resolved)
18
- parsed = response.parsed_body
19
-
20
- result = recon_fields(url, response, parsed).merge(
21
- strategy: resolved,
22
- content_type: response.content_type,
23
- html_response: response.html_response?,
24
- scraper_eligibility: scraper_info(parsed),
25
- sst_stats: sst_stats_from(response)
26
- )
27
-
28
- if response.html_response?
29
- sst = sst_document(response)
30
- if sst
31
- result[:sst] = {
32
- node_count: sst.node_count,
33
- degraded: sst.degraded,
34
- segment_stats: segment_stats(sst, url)
35
- }
36
- end
37
- end
38
-
39
- blocked = Html2rss::RequestService::BlockedSurface.interstitial_signature_for(response.body)
40
- result[:blocked_surface] = blocked[:key].to_s if blocked
41
- result[:xhr_capture] = xhr_capture_info(response) if resolved == :botasaurus
42
- merge_admission_diagnostics!(result, response)
43
-
44
- result
45
- end
46
-
47
- ##
48
- # Surfaces Cleanup admission_drops without re-running full AutoSource discovery.
49
- # Uses articles already extractable from a cheap AutoSource pass only when HTML.
50
- #
51
- # @param result [Hash]
52
- # @param response [Html2rss::RequestService::Response]
53
- # @return [void]
54
- def merge_admission_diagnostics!(result, response)
55
- return unless response.html_response?
56
-
57
- source = AutoSource.new(response, AutoSource::DEFAULT_CONFIG.merge(limit: 10))
58
- articles = source.articles
59
- result[:articles_count] = articles.size
60
- drops = source.admission_drops
61
- result[:admission_drops] = drops if drops.any?
62
- end
63
- module_function :merge_admission_diagnostics!
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
- module_function :xhr_capture_info
77
-
78
- ##
79
- # @param entry [Hash] captured response hash
80
- # @return [String, nil] scheme+host+path only
81
- def redacted_endpoint(entry)
82
- raw = entry['url'] || entry[:url]
83
- return unless raw
84
-
85
- uri = URI.parse(raw.to_s)
86
- return unless uri.scheme && uri.host
87
-
88
- "#{uri.scheme}://#{uri.host}#{uri.path}"
89
- rescue URI::InvalidURIError
90
- nil
91
- end
92
- module_function :redacted_endpoint
93
-
94
- ##
95
- # @param entry [Hash] captured response hash
96
- # @return [Boolean]
97
- def xhr_candidate_articles?(entry)
98
- body = entry['body'] || entry[:body]
99
- return false unless body.is_a?(String)
100
-
101
- document = JSON.parse(body, symbolize_names: true)
102
- AutoSource::Scraper::JsonState::CandidateDetector.candidate_array?(document)
103
- rescue JSON::ParserError
104
- false
105
- end
106
- module_function :xhr_candidate_articles?
107
-
108
- ##
109
- # @param url [String]
110
- # @param strategy [Symbol]
111
- # @return [Html2rss::RequestService::Response]
112
- def fetch_response(url, strategy) # rubocop:disable Metrics/MethodLength -- session construction
113
- raw_config = Config.auto_source_config(
114
- url:,
115
- request_controls: Config::RequestControls.from_shortcut(strategy:)
116
- )
117
- raw_config[:strategy] = strategy
118
- config = Config.from_hash(raw_config)
119
- resources = FeedPipeline::RuntimePolicy.resources_for(config)
120
- session = RequestSession.build(
121
- config:,
122
- strategy: config.strategy,
123
- budget: resources.budget,
124
- policy: resources.policy
125
- )
126
- session.fetch_initial_response
127
- end
128
- module_function :fetch_response
129
-
130
- ##
131
- # @param parsed [Object] parsed response body
132
- # @return [Array<String>, Hash]
133
- def scraper_info(parsed)
134
- return { error: 'Response is not HTML' } unless parsed.is_a?(Nokogiri::HTML::Document)
135
-
136
- begin
137
- Html2rss::AutoSource::Scraper.from(parsed).map(&:name)
138
- rescue Html2rss::AutoSource::Scraper::NoScraperFound => error
139
- { none_found: error.category.to_s }
140
- end
141
- end
142
- module_function :scraper_info
143
-
144
- ##
145
- # @param response [Html2rss::RequestService::Response]
146
- # @return [Hash, nil]
147
- def sst_stats_from(response)
148
- return nil unless response.html_response?
149
-
150
- doc = sst_document(response)
151
- return nil unless doc
152
-
153
- { node_count: doc.node_count, degraded: doc.degraded }
154
- rescue StandardError
155
- nil
156
- end
157
- module_function :sst_stats_from
158
-
159
- ##
160
- # @param response [Html2rss::RequestService::Response]
161
- # @return [Html2rss::SST::Document, nil]
162
- def sst_document(response)
163
- Html2rss::SST::Normalizer.call(response.body)
164
- rescue ArgumentError
165
- nil
166
- end
167
- module_function :sst_document
168
-
169
- ##
170
- # @param sst [Html2rss::SST::Document]
171
- # @param url [String]
172
- # @return [Hash]
173
- def segment_stats(sst, url)
174
- segments = discover_segments(sst, url)
175
- return { found: 0 } if segments.empty?
176
-
177
- {
178
- found: segments.size,
179
- strategies: segments.map(&:strategy).uniq,
180
- sample_paths: segments.first(5).map { |s| s.root_node.tag_path }
181
- }
182
- end
183
- module_function :segment_stats
184
-
185
- ##
186
- # @param sst [Html2rss::SST::Document]
187
- # @param url [String]
188
- # @return [Array]
189
- def discover_segments(sst, url)
190
- link_resolver = Scoring::LinkResolver.new(url)
191
- AutoSource::Segmenter.call(
192
- sst,
193
- base_url: url,
194
- strategy: :list,
195
- link_resolver:
196
- )
197
- rescue StandardError
198
- []
199
- end
200
- module_function :discover_segments
201
-
202
- ##
203
- # @param requested_url [String]
204
- # @param response [Html2rss::RequestService::Response]
205
- # @param parsed [Object]
206
- # @return [Hash]
207
- def recon_fields(requested_url, response, parsed)
208
- requested = Url.from_absolute(requested_url)
209
- final = response.url
210
-
211
- {
212
- requested_url: requested.to_s,
213
- final_url: final.to_s,
214
- status: response.status,
215
- scheme_downgrade: scheme_downgrade?(requested, final),
216
- alternate_feeds: alternate_feeds_from(parsed)
217
- }
218
- end
219
- module_function :recon_fields
220
-
221
- ##
222
- # @param requested [Html2rss::Url]
223
- # @param final [Html2rss::Url]
224
- # @return [Boolean] true when the fetch downgraded https to http
225
- def scheme_downgrade?(requested, final)
226
- requested.scheme == 'https' && final.scheme == 'http'
227
- end
228
- module_function :scheme_downgrade?
229
-
230
- ##
231
- # @param parsed [Object]
232
- # @return [Array<Hash{Symbol => String}>]
233
- def alternate_feeds_from(parsed)
234
- return [] unless parsed.is_a?(Nokogiri::HTML::Document)
235
-
236
- Html::FeedLink.from_document(parsed).map { |link| { href: link.href, mime_type: link.mime_type } }
237
- end
238
- module_function :alternate_feeds_from
239
- end
240
- end
241
- 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