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
@@ -0,0 +1,268 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ ##
5
+ # Entry URL tournament for weak homepage/hub extracts.
6
+ #
7
+ # {include:file:lib/html2rss/feed_resolution/README.md}
8
+ module FeedResolution
9
+ # Default entry-resolution options merged into AutoSource::DEFAULT_CONFIG.
10
+ DEFAULT_CONFIG = {
11
+ enabled: true,
12
+ max_probes: 5
13
+ }.freeze
14
+
15
+ ##
16
+ # Tournament outcome for one entry-resolution attempt.
17
+ Result = Data.define(:entry_url, :scrape_url, :applied, :reason, :probe_count, :winner_score)
18
+
19
+ ##
20
+ # @param auto_source [Hash, nil]
21
+ # @return [Integer] entry-resolution budget slots (probes + retry GET when enabled)
22
+ def self.request_slots_for(auto_source)
23
+ return 0 unless auto_source
24
+
25
+ Options.from_auto_source(auto_source).request_slots
26
+ end
27
+
28
+ ##
29
+ # @param entry_url [String, Html2rss::Url]
30
+ # @param response [Html2rss::RequestService::Response]
31
+ # @param session [Html2rss::RequestSession]
32
+ # @param config [Html2rss::Config]
33
+ # @param articles [Array<Html2rss::Article>]
34
+ # @param surface_category [Symbol, nil]
35
+ # @return [Result]
36
+ # rubocop:disable-next Metrics/ParameterLists -- tournament kwargs stay co-located
37
+ def self.call(entry_url:, response:, session:, config:, articles:, surface_category: nil)
38
+ Runner.new(
39
+ entry_url:, response:, session:, config:, articles:, surface_category:
40
+ ).call
41
+ end
42
+
43
+ ##
44
+ # Outcome of {FeedResolution.try_apply!} for the auto-fallback chain.
45
+ ApplyOutcome = Data.define(:scrape_target, :status)
46
+
47
+ ##
48
+ # Runs the tournament and, when a winner applies, re-fetches and re-extracts articles.
49
+ #
50
+ # @param pipeline [Html2rss::FeedPipeline]
51
+ # @param config [Html2rss::Config]
52
+ # @param response [Html2rss::RequestService::Response]
53
+ # @param session [Html2rss::RequestSession]
54
+ # @param strategy [Symbol]
55
+ # @param resources [Html2rss::FeedPipeline::RuntimePolicy::Resources]
56
+ # @param articles [Array]
57
+ # @param scrape_target [Html2rss::ScrapeTarget]
58
+ # @param state [Html2rss::FeedPipeline::AutoFallback::AttemptState]
59
+ # @param budget [Html2rss::RequestService::Budget]
60
+ # @return [ApplyOutcome, nil] `:succeeded` sticky scrape target when retry extract yielded items;
61
+ # `nil` when no winner or retry was empty (entry scrape target kept; Diag.applied may still be true)
62
+ # rubocop:disable-next Metrics/ParameterLists -- orchestration kwargs stay co-located
63
+ def self.try_apply!(pipeline:, config:, response:, session:, strategy:, resources:, articles:,
64
+ scrape_target:, state:, budget:)
65
+ Orchestrator.new(
66
+ pipeline:, config:, response:, session:, strategy:, resources:, articles:,
67
+ scrape_target:, state:, budget:
68
+ ).call
69
+ end
70
+
71
+ # Tournament + optional retry orchestration for {FeedPipeline::AutoFallback}.
72
+ class Orchestrator
73
+ # Minimum remaining request budget required before probing candidates.
74
+ MIN_BUDGET_REMAINING = 3
75
+
76
+ # Error classes that abort resolution instead of falling through (mirrors AutoFallback).
77
+ NON_FALLBACK_ERRORS = FeedPipeline::AutoFallback::NON_FALLBACK_ERRORS
78
+
79
+ ##
80
+ # @param pipeline [Html2rss::FeedPipeline]
81
+ # @param config [Html2rss::Config]
82
+ # @param response [Html2rss::RequestService::Response]
83
+ # @param session [Html2rss::RequestSession]
84
+ # @param strategy [Symbol]
85
+ # @param resources [Html2rss::FeedPipeline::RuntimePolicy::Resources]
86
+ # @param articles [Array]
87
+ # @param scrape_target [Html2rss::ScrapeTarget]
88
+ # @param state [Html2rss::FeedPipeline::AutoFallback::AttemptState]
89
+ # @param budget [Html2rss::RequestService::Budget]
90
+ # rubocop:disable-next Metrics/ParameterLists -- orchestration context stays co-located
91
+ def initialize(pipeline:, config:, response:, session:, strategy:, resources:, articles:,
92
+ scrape_target:, state:, budget:)
93
+ @pipeline = pipeline
94
+ @config = config
95
+ @response = response
96
+ @session = session
97
+ @strategy = strategy
98
+ @resources = resources
99
+ @articles = articles
100
+ @scrape_target = scrape_target
101
+ @state = state
102
+ @budget = budget
103
+ end
104
+
105
+ ##
106
+ # @return [ApplyOutcome, nil]
107
+ # rubocop:disable-next Metrics/MethodLength -- eligibility + tournament + retry path
108
+ def call
109
+ return unless eligible?
110
+
111
+ state.mark_resolution_tried!
112
+ resolution = run_tournament
113
+ state.remember_entry_resolution(resolution)
114
+ return unless resolution.applied
115
+
116
+ effective = scrape_target.with_effective(resolution.scrape_url)
117
+ return ApplyOutcome.new(scrape_target: effective, status: :succeeded) if retry_with(
118
+ resolution, effective:
119
+ )
120
+
121
+ nil
122
+ rescue RequestService::RequestBudgetExceeded
123
+ Log.warn('FeedResolution: entry resolution skipped (request budget exhausted)')
124
+ nil
125
+ rescue *NON_FALLBACK_ERRORS
126
+ raise
127
+ rescue StandardError => error
128
+ Log.warn("FeedResolution: entry resolution retry failed (#{error.class})")
129
+ nil
130
+ end
131
+
132
+ private
133
+
134
+ attr_reader :pipeline, :config, :response, :session, :strategy, :resources, :articles,
135
+ :scrape_target, :state, :budget, :assessment
136
+
137
+ def eligible?
138
+ return false if state.resolution_tried?
139
+ return false if response.feed_response?
140
+ return false if budget.remaining_requests < MIN_BUDGET_REMAINING
141
+
142
+ @assessment = PageRecon.assess(response:, url: config.url)
143
+ Policy.resolve?(
144
+ config:, articles:, surface_category: assessment.surface_category
145
+ )
146
+ end
147
+
148
+ def run_tournament
149
+ FeedResolution.call(
150
+ entry_url: config.url,
151
+ response:,
152
+ session:,
153
+ config:,
154
+ articles:,
155
+ surface_category: assessment.surface_category
156
+ )
157
+ end
158
+
159
+ def retry_with(_resolution, effective:) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- retry extract path
160
+ retry_session = pipeline.request_session_for(
161
+ config, strategy:, resources:, scrape_url: effective.effective_url
162
+ )
163
+ retry_response = retry_session.fetch_initial_response
164
+ retry_articles, dedup_dropped, admission_drops = pipeline.deduplicated_articles(
165
+ config:, response: retry_response, request_session: retry_session
166
+ )
167
+ state.remember_response(retry_response)
168
+ state.record_items(
169
+ strategy:, items_count: retry_articles.size, transport_meta: retry_response.transport_meta
170
+ )
171
+ return unless retry_articles.size.positive?
172
+
173
+ state.succeed!(
174
+ response: retry_response, articles: retry_articles, dedup_dropped:,
175
+ selected_strategy: strategy, attempt_count: state.attempts.size, admission_drops:,
176
+ scrape_target: effective
177
+ )
178
+ true
179
+ end
180
+ end
181
+
182
+ # Internal tournament runner (keeps {FeedResolution} a Zeitwerk namespace module).
183
+ class Runner
184
+ ##
185
+ # @param entry_url [String, Html2rss::Url]
186
+ # @param response [Html2rss::RequestService::Response]
187
+ # @param session [Html2rss::RequestSession]
188
+ # @param config [Html2rss::Config]
189
+ # @param articles [Array<Html2rss::Article>]
190
+ # @param surface_category [Symbol, nil]
191
+ # rubocop:disable-next Metrics/ParameterLists -- tournament context stays co-located
192
+ def initialize(entry_url:, response:, session:, config:, articles:, surface_category: nil)
193
+ raise ArgumentError, 'articles must be an Array' unless articles.is_a?(Array)
194
+
195
+ @entry_url = Html2rss::Url.from_absolute(entry_url)
196
+ @response = response
197
+ @session = session
198
+ @config = config
199
+ @articles = articles
200
+ @surface_category = surface_category
201
+ end
202
+
203
+ ##
204
+ # @return [Result]
205
+ # rubocop:disable-next Metrics/AbcSize, Metrics/MethodLength -- policy → candidates → probe → select
206
+ def call
207
+ return skip(:policy_skip) unless Policy.resolve?(
208
+ config:, articles:, surface_category:
209
+ )
210
+
211
+ candidates = CandidateGenerator.call(
212
+ entry_url:,
213
+ response:,
214
+ max: max_probes
215
+ )
216
+ return skip(:no_candidates) if candidates.empty?
217
+
218
+ scored = probe_candidates(candidates)
219
+ winner = Scorer.pick_winner(scored:, entry_articles_count: articles.size)
220
+ return skip(:no_winner, probe_count: scored.size) unless winner
221
+
222
+ apply(winner, probe_count: scored.size)
223
+ end
224
+
225
+ private
226
+
227
+ attr_reader :entry_url, :response, :session, :config, :articles, :surface_category
228
+
229
+ def max_probes
230
+ Options.from_auto_source(config.auto_source).max_probes
231
+ end
232
+
233
+ def probe_candidates(candidates)
234
+ probe = Probe.new(request_session: session, origin_url: entry_url)
235
+ candidates.filter_map { |url| probe.call(url) }
236
+ end
237
+
238
+ def apply(winner, probe_count:)
239
+ result(
240
+ applied: true,
241
+ reason: :winner,
242
+ scrape_url: winner.url.to_s,
243
+ probe_count:,
244
+ winner_score: winner.score
245
+ )
246
+ end
247
+
248
+ def skip(reason, probe_count: 0)
249
+ result(
250
+ applied: false,
251
+ reason:,
252
+ scrape_url: entry_url.to_s,
253
+ probe_count:,
254
+ winner_score: nil
255
+ )
256
+ end
257
+
258
+ def result(applied:, reason:, scrape_url:, probe_count:, winner_score:)
259
+ scrape_host = Html2rss::Url.from_absolute(scrape_url).host
260
+ Log.info(
261
+ "FeedResolution: entry_host=#{entry_url.host} scrape_host=#{scrape_host} " \
262
+ "applied=#{applied} probe_count=#{probe_count} winner_score=#{winner_score.inspect} reason=#{reason}"
263
+ )
264
+ Result.new(entry_url: entry_url.to_s, scrape_url:, applied:, reason:, probe_count:, winner_score:)
265
+ end
266
+ end
267
+ end
268
+ end
@@ -19,26 +19,22 @@ module Html2rss
19
19
  tags = article_tag.css(HEADING_TAGS.join(','))
20
20
  if tags.any?
21
21
  select_best_heading(tags)
22
- elsif fallback_anchorless && selected_anchor.nil?
23
- fallback_heading(article_tag)
22
+ else
23
+ labeled = heading_from_aria_or_title(article_tag)
24
+ return labeled if labeled
25
+
26
+ fallback_heading(article_tag) if fallback_anchorless && selected_anchor.nil?
24
27
  end
25
28
  end
26
29
 
27
30
  private
28
31
 
29
32
  def select_best_heading(tags)
30
- min_tag_name = tags.map(&:name).min
31
- best_tag = nil
32
- max_size = -1
33
-
34
- tags.each do |tag|
35
- next if tag.name != min_tag_name
36
-
37
- size = Navigator::TextExtractor.call(tag)&.size.to_i
38
- (best_tag = tag) && (max_size = size) if size > max_size
39
- end
40
-
41
- best_tag
33
+ tagged = tags.map { |tag| [Probe.tag(tag), tag] }
34
+ min_tag_name = tagged.map(&:first).min
35
+ tagged.select { |name, _tag| name == min_tag_name }
36
+ .max_by { |_name, tag| Navigator::TextExtractor.call(tag)&.size.to_i }
37
+ &.last
42
38
  end
43
39
 
44
40
  def fallback_heading(article_tag)
@@ -47,6 +43,20 @@ module Html2rss
47
43
  )
48
44
  fallback_tags.find { |t| !Navigator::TextExtractor.call(t).to_s.strip.empty? }
49
45
  end
46
+
47
+ def heading_from_aria_or_title(article_tag)
48
+ article_tag.css('[aria-label]').each do |node|
49
+ next if node['aria-label'].to_s.strip.empty?
50
+
51
+ return node
52
+ end
53
+ article_tag.css('[title]').each do |node|
54
+ next if node['title'].to_s.strip.empty?
55
+
56
+ return node
57
+ end
58
+ nil
59
+ end
50
60
  end
51
61
  end
52
62
  end
@@ -5,7 +5,7 @@ module Html2rss
5
5
  ##
6
6
  # ArticleExtractor is responsible for extracting details (headline, url, images, etc.)
7
7
  # from an article_tag DOM node. DOM chrome helpers live on {Navigator}.
8
- # rubocop:disable Metrics/ClassLength -- leftover re-extract stays with field extractors
8
+ # rubocop:disable-next Metrics/ClassLength -- leftover re-extract stays with field extractors
9
9
  class ArticleExtractor
10
10
  class << self
11
11
  ##
@@ -79,7 +79,7 @@ module Html2rss
79
79
  Url.from_relative("##{id}", base_url) if id
80
80
  end
81
81
 
82
- # rubocop:disable Metrics/CyclomaticComplexity
82
+ # rubocop:disable-next Metrics/CyclomaticComplexity
83
83
  def extract_title
84
84
  source = heading || selected_anchor
85
85
  title_text = source ? Navigator.extract_visible_text(source) : fallback_anchorless_title
@@ -88,7 +88,6 @@ module Html2rss
88
88
  kicker = kicker_node ? Navigator.extract_visible_text(kicker_node).to_s.strip : nil
89
89
  kicker && !kicker.empty? && !title_text.include?(kicker) ? "#{kicker}: #{title_text}" : title_text
90
90
  end
91
- # rubocop:enable Metrics/CyclomaticComplexity
92
91
 
93
92
  def fallback_anchorless_title
94
93
  return unless @fallback_anchorless && selected_anchor.nil?
@@ -130,11 +129,8 @@ module Html2rss
130
129
  end
131
130
 
132
131
  def heading_or_anchor_item?
133
- heading_item? || article_tag.name.to_s == 'a'
134
- end
135
-
136
- def heading_item?
137
- Navigator::HEADING_TAGS.include?(article_tag.name.to_s)
132
+ tag = Probe.tag(article_tag)
133
+ Navigator::HEADING_TAGS.include?(tag) || tag == 'a'
138
134
  end
139
135
 
140
136
  def heading_or_anchor_miss?(title, lines, published_at)
@@ -207,6 +203,5 @@ module Html2rss
207
203
 
208
204
  def extract_enclosures = EnclosureExtractor.call(article_tag, base_url)
209
205
  end
210
- # rubocop:enable Metrics/ClassLength
211
206
  end
212
207
  end
@@ -25,8 +25,10 @@ module Html2rss
25
25
  end
26
26
 
27
27
  def feed_mime_type(type)
28
- media_type = type.to_s.split(';', 2).first.strip.downcase
29
- media_type if media_type in 'application/rss+xml' | 'application/atom+xml'
28
+ return Probe::APPLICATION_RSS_XML if Probe.mime_match?(type, Probe::APPLICATION_RSS_XML)
29
+ return Probe::APPLICATION_ATOM_XML if Probe.mime_match?(type, Probe::APPLICATION_ATOM_XML)
30
+
31
+ nil
30
32
  end
31
33
  end
32
34
  end
@@ -11,9 +11,6 @@ module Html2rss
11
11
  # Element tags that indicate ignored DOM chrome when found in a container path.
12
12
  IGNORED_CONTAINER_TAGS = %w[nav footer header svg script style].to_set.freeze
13
13
 
14
- # Layout roots and chrome tags excluded from class-clustering candidate nodes.
15
- CLUSTER_EXCLUDED_TAGS = Set['html', 'body', 'nav', 'footer', 'header', 'svg', 'script', 'style'].freeze
16
-
17
14
  # Ancestor tags that usually indicate navigation/utility regions inside a content container.
18
15
  UTILITY_LANDMARK_TAGS = %w[nav aside footer menu].to_set.freeze
19
16
 
@@ -48,43 +45,11 @@ module Html2rss
48
45
  # @param article_tag [Nokogiri::XML::Node] article-like container to search within
49
46
  # @return [Nokogiri::XML::Node, nil] first eligible descendant anchor
50
47
  def main_anchor_for(article_tag)
51
- return article_tag if article_tag.name == 'a' && article_tag.matches?(MAIN_ANCHOR_SELECTOR)
48
+ return article_tag if Probe.tag(article_tag) == 'a' && article_tag.matches?(MAIN_ANCHOR_SELECTOR)
52
49
 
53
50
  article_tag.at_css(MAIN_ANCHOR_SELECTOR)
54
51
  end
55
52
 
56
- ##
57
- # @param node [Nokogiri::XML::Node]
58
- # @param cache [Hash, nil] identity cache used to store results (must use compare_by_identity)
59
- # @return [Boolean] true when the node belongs to ignored DOM chrome
60
- # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
61
- def ignored_container_path?(node, cache = nil)
62
- return cache[node] if cache&.key?(node)
63
-
64
- curr = node
65
- visited = []
66
- is_ignored = false
67
-
68
- while curr.respond_to?(:parent) && curr
69
- if cache&.key?(curr)
70
- is_ignored = cache[curr]
71
- break
72
- end
73
-
74
- if IGNORED_CONTAINER_TAGS.include?(curr.name)
75
- is_ignored = true
76
- break
77
- end
78
-
79
- visited << curr
80
- curr = curr.parent
81
- end
82
- visited.each { |n| cache[n] = is_ignored } if cache
83
-
84
- is_ignored
85
- end
86
- # rubocop:enable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
87
-
88
53
  ##
89
54
  # Returns the first parent that satisfies the condition.
90
55
  # If the condition is met, it returns the node itself.
@@ -93,7 +58,7 @@ module Html2rss
93
58
  # @param condition [Proc] The condition to be met.
94
59
  # @return [Nokogiri::XML::Node, nil] The first parent that satisfies the condition.
95
60
  def parent_until_condition(node, condition)
96
- while node && !node.document? && node.name != 'html'
61
+ while node && !node.document? && Probe.tag(node) != 'html'
97
62
  return node if condition.call(node)
98
63
 
99
64
  node = node.parent
@@ -109,7 +74,7 @@ module Html2rss
109
74
  return false unless node
110
75
  return false if node.respond_to?(:document?) && node.document?
111
76
 
112
- !CARD_WALK_STOP_TAGS.include?(node.name.to_s)
77
+ !CARD_WALK_STOP_TAGS.include?(Probe.tag(node))
113
78
  end
114
79
 
115
80
  ##
@@ -137,7 +102,7 @@ module Html2rss
137
102
  # @param tag_name [String] tag name to find in ancestors
138
103
  # @return [Nokogiri::XML::Node, nil] matching ancestor node
139
104
  def find_tag_in_ancestors(current_tag, tag_name)
140
- return current_tag if current_tag.name == tag_name
105
+ return current_tag if Probe.tag(current_tag) == Probe.fold(tag_name)
141
106
 
142
107
  current_tag.ancestors(tag_name).first
143
108
  end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ module Html
5
+ ##
6
+ # Case-insensitive DOM wire matching for MIME types, tag names, and link relations.
7
+ module Probe
8
+ # Canonical MIME type strings for script and alternate link matching.
9
+ APPLICATION_LD_JSON = 'application/ld+json'
10
+ # @see #mime_match?
11
+ APPLICATION_JSON = 'application/json'
12
+ # @see #mime_match?
13
+ APPLICATION_JSON_OEMBED = 'application/json+oembed'
14
+ # @see #mime_match?
15
+ APPLICATION_RSS_XML = 'application/rss+xml'
16
+ # @see #mime_match?
17
+ APPLICATION_ATOM_XML = 'application/atom+xml'
18
+
19
+ module_function
20
+
21
+ ##
22
+ # @param string [String, Symbol, #to_s] wire value
23
+ # @return [String] folded comparison key
24
+ def fold(string)
25
+ string.to_s.downcase
26
+ end
27
+
28
+ ##
29
+ # @param node [Nokogiri::XML::Node]
30
+ # @return [String] folded element name
31
+ def tag(node)
32
+ name = node.name
33
+ return fold(name) unless name.is_a?(String)
34
+ return name if name.ascii_only? && !name.match?(/[A-Z]/)
35
+
36
+ fold(name)
37
+ end
38
+
39
+ ##
40
+ # @param type [String, Symbol, #to_s, nil] MIME type attribute value
41
+ # @return [String] media type without parameters, folded
42
+ def mime_base(type)
43
+ raw = type.to_s
44
+ return fold(raw) unless raw.include?(';') || raw.match?(/\A\s|\s\z/)
45
+
46
+ fold(raw.split(';', 2).first.to_s.strip)
47
+ end
48
+
49
+ ##
50
+ # @param actual [String, Symbol, #to_s, nil] observed MIME type
51
+ # @param expected [Array<String>] canonical MIME types to match
52
+ # @return [Boolean]
53
+ def mime_match?(actual, *expected)
54
+ base = mime_base(actual)
55
+ expected.any? { |candidate| base == candidate || base == mime_base(candidate) }
56
+ end
57
+
58
+ ##
59
+ # @param doc [Nokogiri::XML::Node]
60
+ # @param mime_types [Array<String>] optional MIME filters (folded once per call)
61
+ # @return [Array<Nokogiri::XML::Element>]
62
+ def scripts(doc, *mime_types)
63
+ return doc.css('script') if mime_types.empty?
64
+
65
+ allowed = mime_types.to_set { |mime| mime_base(mime) }
66
+ doc.css('script').select { |script| allowed.include?(mime_base(script['type'])) }
67
+ end
68
+
69
+ ##
70
+ # @param doc [Nokogiri::XML::Node]
71
+ # @param rel [String] link rel token
72
+ # @param mime [String, nil] optional MIME filter
73
+ # @return [Array<Nokogiri::XML::Element>]
74
+ def alternate_links(doc, rel:, mime: nil)
75
+ nodes = doc.css(%(link[rel~="#{rel}"][href]))
76
+ return nodes if mime.nil?
77
+
78
+ nodes.select { |node| mime_match?(node['type'], mime) }
79
+ end
80
+ end
81
+ end
82
+ end
@@ -7,7 +7,7 @@ module Html2rss
7
7
  ##
8
8
  # Builds an {Html2rss::Article} from an SST segment / ranked segment.
9
9
  # Port of Html::ArticleExtractor field logic onto SST::Node.
10
- # rubocop:disable Metrics/ClassLength -- SST field extractors colocated for parity with ArticleExtractor
10
+ # rubocop:disable-next Metrics/ClassLength -- SST field extractors colocated for parity with ArticleExtractor
11
11
  class SstArticleExtractor
12
12
  # CSS class tokens that mark kicker / eyebrow text (excluded from titles).
13
13
  KICKER_CLASS_PATTERN = /kicker|eyebrow|pre-title|pretitle|overline/i
@@ -390,6 +390,5 @@ module Html2rss
390
390
  end
391
391
  end
392
392
  end
393
- # rubocop:enable Metrics/ClassLength
394
393
  end
395
394
  end
@@ -21,7 +21,7 @@ module Html2rss
21
21
  # @param heading_anchor [Boolean]
22
22
  # @param utility_landmark_ancestor [Boolean] pre-computed by Segmenter tree walk
23
23
  # @return [Boolean]
24
- # rubocop:disable Metrics/CyclomaticComplexity, Metrics/ParameterLists, Metrics/PerceivedComplexity, Lint/UnusedMethodArgument
24
+ # rubocop:disable-next Metrics/CyclomaticComplexity, Metrics/ParameterLists, Metrics/PerceivedComplexity, Lint/UnusedMethodArgument
25
25
  def noise_anchor?(text:, destination_facts:, anchor: nil, container: nil,
26
26
  heading_anchor: false, utility_landmark_ancestor: false)
27
27
  return true unless destination_facts
@@ -35,7 +35,6 @@ module Html2rss
35
35
  icon_only_anchor?(anchor, text) ||
36
36
  utility_landmark_ancestor
37
37
  end
38
- # rubocop:enable Metrics/CyclomaticComplexity, Metrics/ParameterLists, Metrics/PerceivedComplexity, Lint/UnusedMethodArgument
39
38
 
40
39
  private
41
40