html2rss 0.26.0 → 0.27.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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +41 -18
  3. data/html2rss.gemspec +1 -1
  4. data/lib/html2rss/auto_source/README.md +57 -0
  5. data/lib/html2rss/auto_source/cleanup.rb +121 -56
  6. data/lib/html2rss/auto_source/scraper.rb +13 -0
  7. data/lib/html2rss/auto_source.rb +34 -8
  8. data/lib/html2rss/capture/README.md +61 -0
  9. data/lib/html2rss/capture.rb +120 -117
  10. data/lib/html2rss/cli.rb +35 -17
  11. data/lib/html2rss/config/schema.rb +12 -0
  12. data/lib/html2rss/config/validator.rb +31 -8
  13. data/lib/html2rss/config.rb +28 -0
  14. data/lib/html2rss/error.rb +24 -6
  15. data/lib/html2rss/feed_pipeline/README.md +42 -0
  16. data/lib/html2rss/feed_pipeline/auto_fallback.rb +22 -9
  17. data/lib/html2rss/feed_pipeline/strategy_plan.rb +11 -0
  18. data/lib/html2rss/feed_pipeline.rb +30 -12
  19. data/lib/html2rss/html/article_extractor/category_extractor.rb +36 -22
  20. data/lib/html2rss/html/article_extractor/date_extractor.rb +5 -3
  21. data/lib/html2rss/html/article_extractor.rb +95 -17
  22. data/lib/html2rss/html/article_rules/category.rb +28 -11
  23. data/lib/html2rss/html/article_rules/date.rb +60 -6
  24. data/lib/html2rss/html/article_rules/description.rb +122 -0
  25. data/lib/html2rss/html/card_walk.rb +42 -0
  26. data/lib/html2rss/html/feed_link.rb +34 -0
  27. data/lib/html2rss/html/navigator.rb +18 -0
  28. data/lib/html2rss/html/sst_article_extractor.rb +119 -32
  29. data/lib/html2rss/link_destination/path_classifier.rb +49 -35
  30. data/lib/html2rss/mcp/config_argument.rb +42 -0
  31. data/lib/html2rss/mcp/contract.rb +173 -0
  32. data/lib/html2rss/mcp/inspect.rb +241 -0
  33. data/lib/html2rss/mcp/outcome.rb +188 -0
  34. data/lib/html2rss/mcp/server.rb +253 -409
  35. data/lib/html2rss/request_service/botasaurus_contract.rb +193 -102
  36. data/lib/html2rss/request_service/botasaurus_strategy.rb +15 -8
  37. data/lib/html2rss/request_service/compressed_body.rb +109 -0
  38. data/lib/html2rss/request_service/faraday_strategy.rb +3 -1
  39. data/lib/html2rss/request_service/policy.rb +1 -1
  40. data/lib/html2rss/request_service/response.rb +57 -6
  41. data/lib/html2rss/request_service.rb +6 -1
  42. data/lib/html2rss/selectors.rb +2 -1
  43. data/lib/html2rss/status.rb +27 -11
  44. data/lib/html2rss/url.rb +20 -0
  45. data/lib/html2rss/version.rb +1 -1
  46. data/lib/html2rss.rb +30 -6
  47. data/schema/html2rss-config.schema.json +26 -15
  48. metadata +15 -4
@@ -13,6 +13,7 @@ module Html2rss
13
13
  ].to_set.freeze
14
14
 
15
15
  # Segment groups used to classify article, taxonomy, utility, and vanity routes.
16
+ # Utility = taxonomy ∪ chrome ∪ soft utility (do not re-list taxonomy tokens).
16
17
  SEGMENT_SETS = begin
17
18
  content = %w[
18
19
  article articles blog blogs changelog changelogs insight insights
@@ -31,28 +32,23 @@ module Html2rss
31
32
  join membership plus premium pricing plans subscribe signup
32
33
  abonnieren abo suscribirse boletin s-abonner saboner
33
34
  ].to_set.freeze
34
- utility = (
35
- taxonomy.to_a + %w[
36
- about account archive archives author authors comment comments
37
- contact feedback help login logout notification notifications
38
- preference preferences profile register search settings share signup subscribe
39
- feed feeds comment-feed comments-feed privacy terms cookie cookies user users
40
- kategorie kategorien schlagwort schlagworte thema themen autor autoren archiv
41
- ueber-uns ueber ueberuns profil kontakt impressum suche hilfe anmelden registrieren
42
- konto registrierung anmeldung abonnieren abo datenschutz nutzungsbedingungen agb
43
- categoria categorias etiqueta etiquetas tema temas autores archivos
44
- sobre-nosotros sobre quienes-somos buscar busqueda ayuda entrar ingresar
45
- registrarse registro cuenta suscribirse boletin privacidad condiciones
46
- categorie etiquette etiquettes sujet sujets theme themes auteur auteurs
47
- a-propos apropos recherche rechercher aide connexion s-inscrire
48
- sinscrire inscription compte s-abonner saboner lettre-information confidentialite
49
- mentions-legales cgu menu sidebar widget social modal popup banner promo ad ads
50
- related recommendation recommendations pagination pager
51
- dating jobs job career careers deals deal shopping shop trading broker
52
- versicherung tierversicherung insurance vergleich comparison
53
- partnerboerse singleboerse krypto crypto
54
- ] + SOFT_UTILITY.to_a
55
- ).to_set.freeze
35
+ chrome = %w[
36
+ about account archive archives author authors comment comments contact feedback
37
+ help login logout notification notifications preference preferences profile register
38
+ search settings share signup subscribe feed feeds comment-feed comments-feed privacy
39
+ terms cookie cookies user users autor autoren archiv ueber-uns ueber ueberuns profil
40
+ kontakt impressum suche hilfe anmelden registrieren konto registrierung anmeldung
41
+ abonnieren abo datenschutz nutzungsbedingungen agb autores archivos sobre-nosotros
42
+ sobre quienes-somos buscar busqueda ayuda entrar ingresar registrarse registro cuenta
43
+ suscribirse boletin privacidad condiciones auteur auteurs a-propos apropos recherche
44
+ rechercher aide connexion s-inscrire sinscrire inscription compte s-abonner saboner
45
+ lettre-information confidentialite mentions-legales cgu menu sidebar widget social
46
+ modal popup banner promo ad ads related recommendation recommendations pagination pager
47
+ dating jobs job career careers deals deal shopping shop trading broker versicherung
48
+ tierversicherung insurance vergleich comparison partnerboerse singleboerse krypto crypto
49
+ casinos casino kreditkarten kreditkarte kredit echtgeld vpn games kaufberater leasing
50
+ ].to_set.freeze
51
+ utility = (taxonomy | chrome | SOFT_UTILITY).freeze
56
52
  {
57
53
  content:,
58
54
  utility:,
@@ -66,6 +62,11 @@ module Html2rss
66
62
  YEARISH_SEGMENT = /\A\d{4,}[\w-]*\z/
67
63
  # Hyphenated slug shape common to article permalinks.
68
64
  POST_SLUG_SEGMENT = /\A[a-z0-9]+(?:-[a-z0-9]+){2,}\z/i
65
+ # Multi-label host mistaken for a path segment (affiliate/outlink chrome).
66
+ # Final label must be alphabetic (TLD-like); exclude common file extensions.
67
+ HOST_SHAPED_SEGMENT = /\A(?:www\.)?(?:[\w-]+\.)+[a-z]{2,24}\z/i
68
+ # Common file-extension suffixes excluded from host-shaped junk matching.
69
+ FILE_EXTENSION_SEGMENT = /\.(?:pdf|jpe?g|png|gif|svg|webp|css|js|mjs|html?|xml|json|mp4|webm|zip|gz)\z/i
69
70
 
70
71
  # @param segments [Array<String>] normalized URL path segments
71
72
  def initialize(segments)
@@ -74,8 +75,8 @@ module Html2rss
74
75
 
75
76
  # @return [Boolean] true when the route has article-like path evidence
76
77
  def content_path?
77
- @content_path ||= SEGMENT_SETS[:content].intersect?(segments) ||
78
- yearish_content_context?
78
+ @content_path ||= !leading_high_confidence_junk? &&
79
+ (SEGMENT_SETS[:content].intersect?(segments) || yearish_content_context?)
79
80
  end
80
81
 
81
82
  # @return [Boolean] true when the route includes utility/navigation evidence
@@ -96,9 +97,8 @@ module Html2rss
96
97
  # @return [Boolean] true when the route is too shallow to strongly indicate an article
97
98
  def shallow?
98
99
  segment_count = segments.size
99
- junk_segments = SEGMENT_SETS.fetch(:high_confidence_junk)
100
100
 
101
- segment_count <= 1 || (segment_count == 2 && junk_segments.include?(segments.last))
101
+ segment_count <= 1 || (segment_count == 2 && high_confidence_junk_segment?(segments.last))
102
102
  end
103
103
 
104
104
  # @return [Boolean] true when the final path segment looks like a post slug
@@ -110,18 +110,15 @@ module Html2rss
110
110
 
111
111
  # @return [Boolean] true when every path segment is utility chrome
112
112
  def utility_only_route?
113
- junk_segments = SEGMENT_SETS.fetch(:high_confidence_junk)
114
-
115
- segments.all? { |segment| junk_segments.include?(segment) }
113
+ segments.all? { |segment| high_confidence_junk_segment?(segment) }
116
114
  end
117
115
 
118
116
  # @return [Boolean] true when the route is shallow and contains high-confidence noise
119
117
  def shallow_high_confidence_route?
120
- junk_segments = SEGMENT_SETS.fetch(:high_confidence_junk)
121
118
  vanity_segments = SEGMENT_SETS.fetch(:vanity)
122
119
 
123
120
  shallow? && segments.any? do |segment|
124
- junk_segments.include?(segment) || vanity_segments.include?(segment)
121
+ high_confidence_junk_segment?(segment) || vanity_segments.include?(segment)
125
122
  end
126
123
  end
127
124
 
@@ -134,7 +131,8 @@ module Html2rss
134
131
  def junk_path?
135
132
  return false if excluded_content_route?
136
133
 
137
- taxonomy_path? ||
134
+ any_high_confidence_junk_segment? ||
135
+ taxonomy_path? ||
138
136
  utility_only_route? ||
139
137
  deep_utility_context_route? ||
140
138
  shallow_high_confidence_route?
@@ -149,6 +147,14 @@ module Html2rss
149
147
 
150
148
  private
151
149
 
150
+ def leading_high_confidence_junk?
151
+ segments.any? && high_confidence_junk_segment?(segments.first)
152
+ end
153
+
154
+ def any_high_confidence_junk_segment?
155
+ segments.any? { |segment| high_confidence_junk_segment?(segment) }
156
+ end
157
+
152
158
  def yearish_content_context?
153
159
  segments.any? { |segment| segment.match?(YEARISH_SEGMENT) } &&
154
160
  (strong_post_suffix? || trusted_post_context?(segments.size - 1))
@@ -172,12 +178,20 @@ module Html2rss
172
178
  def all_junk?(limit)
173
179
  return false if limit <= 0
174
180
 
175
- junk_segments = SEGMENT_SETS.fetch(:high_confidence_junk)
176
- (0...limit).all? { |i| junk_segments.include?(segments[i]) }
181
+ (0...limit).all? { |i| high_confidence_junk_segment?(segments[i]) }
182
+ end
183
+
184
+ def high_confidence_junk_segment?(segment)
185
+ SEGMENT_SETS.fetch(:high_confidence_junk).include?(segment) || host_shaped_segment?(segment)
186
+ end
187
+
188
+ def host_shaped_segment?(segment)
189
+ segment.match?(HOST_SHAPED_SEGMENT) && !segment.match?(FILE_EXTENSION_SEGMENT)
177
190
  end
178
191
 
179
192
  def trusted_post_context?(limit)
180
193
  return false if limit <= 0
194
+ return false if leading_high_confidence_junk?
181
195
 
182
196
  content_segments = SEGMENT_SETS.fetch(:content)
183
197
  context_segments = SEGMENT_SETS.fetch(:deep_post_context)
@@ -196,7 +210,7 @@ module Html2rss
196
210
 
197
211
  def excluded_last_segment?
198
212
  last = segments.last
199
- [SEGMENT_SETS[:high_confidence_junk], SEGMENT_SETS[:vanity]].any? { |set| set.include?(last) }
213
+ high_confidence_junk_segment?(last) || SEGMENT_SETS[:vanity].include?(last)
200
214
  end
201
215
 
202
216
  def slug_last_segment?
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ module MCP
5
+ # Feed config supplied over MCP as a hash XOR a YAML string.
6
+ #
7
+ # Config does not know MCP. YAML parsing stays on {Html2rss::Config.from_yaml}.
8
+ ConfigArgument = Data.define(:config) do
9
+ class << self
10
+ ##
11
+ # @param config [Hash, nil] feed configuration hash
12
+ # @param yaml [String, nil] feed configuration YAML
13
+ # @return [ConfigArgument]
14
+ # @raise [ArgumentError] unless exactly one of +config+ or +yaml+ is present
15
+ # @raise [Contract::UnpublishedRequestError] when the parsed config uses
16
+ # an unpublished MCP strategy or +request.local_file_path+
17
+ def parse(config: nil, yaml: nil)
18
+ parsed = case [present?(config), present?(yaml)]
19
+ in [true, false]
20
+ HashUtil.deep_symbolize_keys(config, context: 'config')
21
+ in [false, true]
22
+ Config.from_yaml(yaml)
23
+ else
24
+ raise ArgumentError, 'Provide exactly one of config or yaml'
25
+ end
26
+ Contract.assert_published_request!(parsed)
27
+ new(config: parsed)
28
+ end
29
+
30
+ private
31
+
32
+ def present?(value)
33
+ case value
34
+ in nil then false
35
+ in String then !value.strip.empty?
36
+ else true
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ module MCP
5
+ ##
6
+ # Published MCP contract: strategy enum, input/output schemas, listing
7
+ # annotations, and the single compact JSON envelope response.
8
+ module Contract # rubocop:disable Metrics/ModuleLength -- published listing constants stay co-located
9
+ # Published MCP request strategies (excludes +local_file+).
10
+ STRATEGIES = %w[auto faraday botasaurus].freeze
11
+
12
+ # Raised when apply/validate config uses an unpublished MCP request adapter.
13
+ class UnpublishedRequestError < ArgumentError; end
14
+
15
+ # JSON Schema property for a source page URL.
16
+ URL_PROPERTY = {
17
+ type: 'string',
18
+ format: 'uri',
19
+ description: 'Source page URL'
20
+ }.freeze
21
+
22
+ # JSON Schema property for scrape/capture +strategy+.
23
+ STRATEGY_PROPERTY = {
24
+ type: 'string',
25
+ enum: STRATEGIES,
26
+ default: 'auto',
27
+ description: 'Request strategy (auto runs faraday → botasaurus fallback chain)'
28
+ }.freeze
29
+
30
+ # JSON Schema property for inspect +strategy+ (auto stays on Faraday).
31
+ INSPECT_STRATEGY_PROPERTY = STRATEGY_PROPERTY.merge(
32
+ description: 'Request strategy (auto uses Faraday for cheap diagnostics; pin botasaurus when needed)'
33
+ ).freeze
34
+
35
+ # JSON Schema +oneOf+ requiring exactly one of +config+ or +yaml+.
36
+ XOR_ONE_OF = [
37
+ { required: %w[config], not: { required: %w[yaml] } }.freeze,
38
+ { required: %w[yaml], not: { required: %w[config] } }.freeze
39
+ ].freeze
40
+
41
+ # JSON Schema properties for the config/yaml XOR pair.
42
+ CONFIG_XOR_PROPERTIES = {
43
+ config: {
44
+ type: 'object',
45
+ description: 'Feed configuration hash with channel and selectors (XOR yaml)'
46
+ }.freeze,
47
+ yaml: {
48
+ type: 'string',
49
+ pattern: '\\S',
50
+ description: 'Feed configuration YAML string (XOR config)'
51
+ }.freeze
52
+ }.freeze
53
+
54
+ # Input schema for +validate_config+ (config XOR yaml).
55
+ CONFIG_XOR_SCHEMA = {
56
+ type: 'object',
57
+ properties: CONFIG_XOR_PROPERTIES,
58
+ oneOf: XOR_ONE_OF
59
+ }.freeze
60
+
61
+ # Input schema for +apply_config+ (required URL plus config XOR yaml).
62
+ APPLY_INPUT_SCHEMA = {
63
+ type: 'object',
64
+ properties: { url: URL_PROPERTY, **CONFIG_XOR_PROPERTIES }.freeze,
65
+ required: %w[url],
66
+ oneOf: XOR_ONE_OF
67
+ }.freeze
68
+
69
+ # Input schema for +scrape_url+.
70
+ SCRAPE_INPUT_SCHEMA = {
71
+ type: 'object',
72
+ properties: {
73
+ url: URL_PROPERTY,
74
+ strategy: STRATEGY_PROPERTY,
75
+ limit: { type: 'integer', description: 'Max articles to keep (default 25)', default: 25 },
76
+ items_selector: { type: 'string', description: 'Optional CSS selector hint for items' }
77
+ }.freeze,
78
+ required: %w[url]
79
+ }.freeze
80
+
81
+ # Input schema for +inspect_url+.
82
+ INSPECT_INPUT_SCHEMA = {
83
+ type: 'object',
84
+ properties: { url: URL_PROPERTY, strategy: INSPECT_STRATEGY_PROPERTY }.freeze,
85
+ required: %w[url]
86
+ }.freeze
87
+
88
+ # Input schema for +capture_config+.
89
+ CAPTURE_INPUT_SCHEMA = {
90
+ type: 'object',
91
+ properties: {
92
+ url: URL_PROPERTY,
93
+ strategy: STRATEGY_PROPERTY,
94
+ items_selector: { type: 'string', description: 'Optional CSS selector hint for items' }
95
+ }.freeze,
96
+ required: %w[url]
97
+ }.freeze
98
+
99
+ # Tool annotations for open-world read-only tools.
100
+ ANNOTATIONS_OPEN_WORLD = {
101
+ read_only_hint: true,
102
+ destructive_hint: false,
103
+ idempotent_hint: true,
104
+ open_world_hint: true
105
+ }.freeze
106
+
107
+ # Tool annotations for +validate_config+ (closed world).
108
+ ANNOTATIONS_VALIDATE = ANNOTATIONS_OPEN_WORLD.merge(open_world_hint: false).freeze
109
+
110
+ # Human titles for +tools/list+.
111
+ TITLES = {
112
+ scrape_url: 'Scrape URL',
113
+ inspect_url: 'Inspect URL',
114
+ capture_config: 'Capture feed config',
115
+ validate_config: 'Validate feed config',
116
+ apply_config: 'Apply feed config'
117
+ }.freeze
118
+
119
+ class << self
120
+ ##
121
+ # Envelope JSON Schema. Built lazily so Zeitwerk can load Contract before Outcome.
122
+ #
123
+ # @return [Hash]
124
+ def output_schema # rubocop:disable Metrics/MethodLength -- schema document is one hash
125
+ {
126
+ type: 'object',
127
+ additionalProperties: false,
128
+ required: %w[ok next_step guidance payload],
129
+ properties: {
130
+ ok: { type: 'boolean' },
131
+ next_step: { type: 'string', enum: Outcome::NextStep::NAMES.map(&:to_s) },
132
+ guidance: { type: 'string' },
133
+ payload: { type: 'object' }
134
+ }
135
+ }
136
+ end
137
+
138
+ ##
139
+ # One envelope Hash, one compact JSON body, no +_meta+.
140
+ #
141
+ # @param outcome [Outcome]
142
+ # @return [::MCP::Tool::Response]
143
+ def response(outcome)
144
+ wire = outcome.to_h
145
+ ::MCP::Tool::Response.new(
146
+ [{ type: 'text', text: JSON.generate(wire) }],
147
+ error: !outcome.ok,
148
+ structured_content: wire
149
+ )
150
+ end
151
+
152
+ ##
153
+ # Rejects unpublished MCP request adapters so apply/validate cannot
154
+ # {File.read} arbitrary paths. CLI and Config still allow +local_file+.
155
+ #
156
+ # @param config [Hash]
157
+ # @return [void]
158
+ # @raise [UnpublishedRequestError] when +strategy+ is outside {STRATEGIES}
159
+ # or +request.local_file_path+ is present
160
+ def assert_published_request!(config)
161
+ strategy = config[:strategy]
162
+ unless strategy.nil? || STRATEGIES.include?(strategy.to_s)
163
+ raise UnpublishedRequestError,
164
+ "MCP does not accept strategy #{strategy} (published: #{STRATEGIES.join(', ')})"
165
+ end
166
+ return unless config.dig(:request, :local_file_path)
167
+
168
+ raise UnpublishedRequestError, 'MCP does not accept request.local_file_path'
169
+ end
170
+ end
171
+ end
172
+ end
173
+ end
@@ -0,0 +1,241 @@
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