html2rss 0.28.0 → 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 (80) 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 +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 +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/request_headers.rb +4 -14
  26. data/lib/html2rss/config/schema.rb +7 -5
  27. data/lib/html2rss/config/validator.rb +1 -0
  28. data/lib/html2rss/config.rb +59 -22
  29. data/lib/html2rss/doctor/botasaurus.rb +130 -0
  30. data/lib/html2rss/feed_builder/rss.rb +1 -2
  31. data/lib/html2rss/feed_pipeline/README.md +5 -5
  32. data/lib/html2rss/feed_pipeline/auto_fallback.rb +29 -10
  33. data/lib/html2rss/feed_pipeline.rb +27 -16
  34. data/lib/html2rss/feed_resolution/README.md +11 -11
  35. data/lib/html2rss/feed_resolution.rb +6 -12
  36. data/lib/html2rss/html/article_extractor/heading_extractor.rb +24 -14
  37. data/lib/html2rss/html/article_extractor.rb +4 -9
  38. data/lib/html2rss/html/feed_link.rb +4 -2
  39. data/lib/html2rss/html/navigator.rb +4 -39
  40. data/lib/html2rss/html/probe.rb +82 -0
  41. data/lib/html2rss/html/sst_article_extractor.rb +1 -2
  42. data/lib/html2rss/link_destination/noise_policy.rb +1 -2
  43. data/lib/html2rss/link_destination/path_classifier.rb +20 -19
  44. data/lib/html2rss/mcp/README.md +80 -0
  45. data/lib/html2rss/mcp/contract.rb +135 -13
  46. data/lib/html2rss/mcp/outcome/playbook.rb +122 -0
  47. data/lib/html2rss/mcp/outcome.rb +112 -42
  48. data/lib/html2rss/mcp/runtime.rb +45 -0
  49. data/lib/html2rss/mcp/server/tools.rb +277 -0
  50. data/lib/html2rss/mcp/server.rb +91 -232
  51. data/lib/html2rss/mcp.rb +3 -0
  52. data/lib/html2rss/page_recon/README.md +69 -0
  53. data/lib/html2rss/page_recon/diagnostics.rb +211 -0
  54. data/lib/html2rss/page_recon.rb +56 -2
  55. data/lib/html2rss/recon.rb +252 -0
  56. data/lib/html2rss/request_service/blocked_surface.rb +1 -0
  57. data/lib/html2rss/request_service/faraday_strategy.rb +11 -7
  58. data/lib/html2rss/request_service/policy.rb +1 -2
  59. data/lib/html2rss/request_service/response.rb +3 -4
  60. data/lib/html2rss/request_service/strategy.rb +1 -2
  61. data/lib/html2rss/request_service.rb +2 -0
  62. data/lib/html2rss/request_session.rb +1 -2
  63. data/lib/html2rss/scrape_target.rb +1 -1
  64. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +2 -4
  65. data/lib/html2rss/selectors/post_processors.rb +0 -1
  66. data/lib/html2rss/selectors.rb +33 -10
  67. data/lib/html2rss/sst/attrs.rb +1 -2
  68. data/lib/html2rss/sst/normalizer.rb +49 -11
  69. data/lib/html2rss/status.rb +3 -6
  70. data/lib/html2rss/syndication/README.md +6 -6
  71. data/lib/html2rss/syndication/discovery.rb +2 -4
  72. data/lib/html2rss/test/enhance_audit.rb +192 -0
  73. data/lib/html2rss/test.rb +458 -0
  74. data/lib/html2rss/url.rb +1 -2
  75. data/lib/html2rss/version.rb +1 -1
  76. data/lib/html2rss.rb +157 -35
  77. data/schema/html2rss-config.schema.json +9 -29
  78. metadata +17 -18
  79. data/lib/html2rss/mcp/inspect.rb +0 -138
  80. data/lib/html2rss/selectors/post_processors/html_to_markdown.rb +0 -59
@@ -31,7 +31,7 @@ module Html2rss
31
31
  # @param status [Integer, nil] the HTTP status code when available
32
32
  # @param transport_meta [Hash] allowlisted upstream telemetry (frozen when present)
33
33
  # @param captured_responses [Array<Hash>] JSON XHR/fetch bodies captured during browser scrapes
34
- # rubocop:disable Metrics/MethodLength, Metrics/ParameterLists -- transport + capture fields stay co-located
34
+ # rubocop:disable-next Metrics/MethodLength, Metrics/ParameterLists -- transport + capture fields stay co-located
35
35
  def initialize(body:, url:, headers: {}, status: nil, transport_meta: EMPTY_TRANSPORT_META,
36
36
  captured_responses: EMPTY_CAPTURED_RESPONSES)
37
37
  @body = body
@@ -50,7 +50,6 @@ module Html2rss
50
50
  captured_responses.freeze
51
51
  end
52
52
  end
53
- # rubocop:enable Metrics/MethodLength, Metrics/ParameterLists
54
53
 
55
54
  # @return [String] the raw body of the response
56
55
  attr_reader :body
@@ -160,12 +159,12 @@ module Html2rss
160
159
  return false if ct.empty? || ct.include?('html') || ct.include?('json')
161
160
 
162
161
  # Substring match against Content-Type (not Array#intersect? on a String).
163
- FEED_CT_MARKERS.any? { |marker| ct.include?(marker) } # rubocop:disable Style/ArrayIntersect
162
+ FEED_CT_MARKERS.any? { |marker| ct.include?(marker) }
164
163
  end
165
164
 
166
165
  def feed_looking_body?
167
166
  snippet = body.to_s[0, 800].downcase
168
- FEED_BODY_MARKERS.any? { |marker| snippet.include?(marker) } # rubocop:disable Style/ArrayIntersect
167
+ FEED_BODY_MARKERS.any? { |marker| snippet.include?(marker) }
169
168
  end
170
169
  end
171
170
  end
@@ -111,7 +111,7 @@ module Html2rss
111
111
 
112
112
  # @param reason [String] timeout classification (budget_exhausted / transport)
113
113
  # @return [void]
114
- # rubocop:disable Metrics/AbcSize -- structured timeout fields stay in one log line
114
+ # rubocop:disable-next Metrics/AbcSize -- structured timeout fields stay in one log line
115
115
  def log_timeout!(reason:)
116
116
  remaining = ctx.budget.remaining_timeout_seconds
117
117
  remaining_label = remaining.nil? ? 'untracked' : format('%.3f', remaining)
@@ -124,7 +124,6 @@ module Html2rss
124
124
  ]
125
125
  Log.info("#{self.class}: request timeout #{detail.join(' ')}")
126
126
  end
127
- # rubocop:enable Metrics/AbcSize
128
127
 
129
128
  # @param error [StandardError]
130
129
  # @return [void]
@@ -29,6 +29,8 @@ module Html2rss
29
29
  class CrossOriginFollowUpDenied < Html2rss::Error; end
30
30
  # Raised when a response exceeds configured size limits.
31
31
  class ResponseTooLarge < Html2rss::Error; end
32
+ # Raised when HTTP redirect limits are exceeded.
33
+ class RedirectLimitReached < Html2rss::Error; end
32
34
  # Raised when blocked content surfaces are detected.
33
35
  class BlockedSurfaceDetected < Html2rss::Error; end
34
36
 
@@ -18,14 +18,13 @@ module Html2rss
18
18
  # @param scrape_url [String, nil] effective fetch URL when it differs from config channel URL
19
19
  # @param logger [Logger] logger used for operational warnings
20
20
  # @return [RequestSession] configured request session
21
- # rubocop:disable Metrics/ParameterLists -- scrape_url override stays beside config
21
+ # rubocop:disable-next Metrics/ParameterLists -- scrape_url override stays beside config
22
22
  def build(config:, strategy:, budget:, policy:, scrape_url: nil, logger: Html2rss::Log)
23
23
  context = RequestService::Context.new(
24
24
  url: scrape_url || config.url, headers: config.headers, request: config.request, policy:, budget:
25
25
  )
26
26
  new(context:, strategy:, logger:)
27
27
  end
28
- # rubocop:enable Metrics/ParameterLists
29
28
  end
30
29
 
31
30
  ##
@@ -4,7 +4,7 @@ module Html2rss
4
4
  ##
5
5
  # Immutable entry vs effective scrape URLs for one pipeline run.
6
6
  #
7
- # Replaces mutating {Config#scrape_url=} after {FeedResolution} rewrites the fetch URL.
7
+ # Replaces mutating +Config#scrape_url=+ after {FeedResolution} rewrites the fetch URL.
8
8
  ScrapeTarget = Data.define(:entry_url, :effective_url) do
9
9
  ##
10
10
  # @param config [Html2rss::Config]
@@ -105,7 +105,7 @@ module Html2rss
105
105
  # @param html [String]
106
106
  # @param url [String, Html2rss::Url]
107
107
  # @return [String, nil]
108
- # rubocop:disable ThreadSafety/ClassInstanceVariable
108
+ # rubocop:disable-next ThreadSafety/ClassInstanceVariable
109
109
  def self.get(html, url)
110
110
  return nil if String(html).empty?
111
111
 
@@ -117,12 +117,11 @@ module Html2rss
117
117
  context = Selectors::Context.new(config: { channel: { url: } }, options: {})
118
118
  @fragment_cache[key] = new(html, context).get
119
119
  end
120
- # rubocop:enable ThreadSafety/ClassInstanceVariable
121
120
 
122
121
  ##
123
122
  # @param channel_url [String, Html2rss::Url]
124
123
  # @return [Hash] the memoized sanitize configuration
125
- # rubocop:disable Metrics/MethodLength, ThreadSafety/ClassInstanceVariable
124
+ # rubocop:disable-next Metrics/MethodLength, ThreadSafety/ClassInstanceVariable
126
125
  def self.sanitize_config(channel_url)
127
126
  @sanitize_configs ||= {}
128
127
  @sanitize_configs[channel_url] ||= begin
@@ -143,7 +142,6 @@ module Html2rss
143
142
  config.freeze
144
143
  end
145
144
  end
146
- # rubocop:enable Metrics/MethodLength, ThreadSafety/ClassInstanceVariable
147
145
 
148
146
  ##
149
147
  # @return [String, nil]
@@ -23,7 +23,6 @@ module Html2rss
23
23
  # The key is the name to use in the feed config.
24
24
  NAME_TO_CLASS = {
25
25
  gsub: Gsub,
26
- html_to_markdown: HtmlToMarkdown,
27
26
  markdown_to_html: MarkdownToHtml,
28
27
  parse_time: ParseTime,
29
28
  parse_uri: ParseUri,
@@ -95,13 +95,16 @@ module Html2rss
95
95
  # @return [Hash] Hash of attributes for the article.
96
96
  def extract_article(item, page_response = response)
97
97
  scope = item_scope_for(item, page_response.url)
98
- @rss_item_attributes.each_with_object({}) do |selector_key, hash|
98
+ hash = @rss_item_attributes.each_with_object({}) do |selector_key, h|
99
99
  value = scope.select(selector_key)
100
100
  next if value.nil?
101
101
 
102
102
  article_key = SELECTOR_TO_ARTICLE_KEY.fetch(selector_key, selector_key)
103
- hash[article_key] = article_key == :enclosures ? wrap_enclosure_value(value) : value
103
+ h[article_key] = article_key == :enclosures ? wrap_enclosure_value(value) : value
104
104
  end
105
+
106
+ hash[:url] ||= default_item_url(item, page_response.url) if anchor_element?(item)
107
+ hash
105
108
  end
106
109
 
107
110
  ##
@@ -112,7 +115,7 @@ module Html2rss
112
115
  # @param article_tag [Nokogiri::XML::Element] HTML element to extract additional info from.
113
116
  # @param base_url [String, Html2rss::Url] base URL for normalization during enhancement
114
117
  # @return [Hash] The enhanced article hash.
115
- # rubocop:disable Metrics/MethodLength
118
+ # rubocop:disable-next Metrics/MethodLength
116
119
  def enhance_article_hash(article_hash, article_tag, base_url = @url)
117
120
  selected_anchor = Html2rss::Html::Navigator.main_anchor_for(article_tag)
118
121
  extracted = Html2rss::Html::ArticleExtractor.call(
@@ -130,7 +133,6 @@ module Html2rss
130
133
  hash[key] = value
131
134
  end
132
135
  end
133
- # rubocop:enable Metrics/MethodLength
134
136
 
135
137
  ##
136
138
  # Selects the value for a given attribute from an HTML element.
@@ -157,13 +159,11 @@ module Html2rss
157
159
 
158
160
  raise InvalidSelectorName, "Attribute selector '#{name}' is reserved for items." if name == ITEMS_SELECTOR_KEY
159
161
 
160
- selector_key, config = selector_config_for(name)
162
+ selector_key, config = selector_config_for(name, allow_nil: name == :url)
163
+ return default_item_url(scope.item, scope.base_url) if fallback_url_selector?(selector_key, config, scope.item)
164
+ raise InvalidSelectorName, "Selector for '#{selector_key}' is not defined." if config.nil?
161
165
 
162
- if SPECIAL_ATTRIBUTES.member?(selector_key)
163
- select_special(selector_key, scope:, config:)
164
- else
165
- select_regular(selector_key, scope:, config:)
166
- end
166
+ dispatch_select(selector_key, scope:, config:)
167
167
  end
168
168
 
169
169
  private
@@ -317,5 +317,28 @@ module Html2rss
317
317
 
318
318
  { url:, type: config[:content_type] }
319
319
  end
320
+
321
+ def anchor_element?(item)
322
+ item.respond_to?(:name) && item.name.to_s.casecmp('a').zero?
323
+ end
324
+
325
+ def fallback_url_selector?(selector_key, config, item)
326
+ selector_key == :url && config.nil? && anchor_element?(item)
327
+ end
328
+
329
+ def dispatch_select(selector_key, scope:, config:)
330
+ if SPECIAL_ATTRIBUTES.member?(selector_key)
331
+ select_special(selector_key, scope:, config:)
332
+ else
333
+ select_regular(selector_key, scope:, config:)
334
+ end
335
+ end
336
+
337
+ def default_item_url(item, base_url)
338
+ href = item['href'].to_s.strip
339
+ return if href.empty?
340
+
341
+ Url.from_relative(href, base_url)
342
+ end
320
343
  end
321
344
  end
@@ -29,7 +29,7 @@ module Html2rss
29
29
  # @param type [String, nil]
30
30
  # @param raw [Hash{String => String}, nil]
31
31
  # @return [Attrs]
32
- # rubocop:disable Metrics/MethodLength, Metrics/ParameterLists
32
+ # rubocop:disable-next Metrics/MethodLength, Metrics/ParameterLists
33
33
  def build(href: nil, src: nil, id: nil, class_names: nil, datetime: nil, itemprop: nil,
34
34
  style: nil, srcset: nil, type: nil, raw: nil)
35
35
  new(
@@ -45,7 +45,6 @@ module Html2rss
45
45
  raw: normalize_raw(raw)
46
46
  )
47
47
  end
48
- # rubocop:enable Metrics/MethodLength, Metrics/ParameterLists
49
48
 
50
49
  ##
51
50
  # @return [Attrs]
@@ -8,6 +8,9 @@ module Html2rss
8
8
  # Sole Nokogiri consumer on the heuristic auto-source path. Builds an
9
9
  # immutable SST::Document with parent/depth/chrome indices.
10
10
  class Normalizer # rubocop:disable Metrics/ClassLength
11
+ # Raised when no SST nodes survive normalization for the chosen root.
12
+ class EmptyTree < ArgumentError; end
13
+
11
14
  # Hard ceiling for SST node allocations; beyond this we degrade.
12
15
  MAX_NODES = 5_000
13
16
 
@@ -50,6 +53,12 @@ module Html2rss
50
53
  )
51
54
  /xi
52
55
 
56
+ # Typed Attrs fields excluded from the leftover raw hash.
57
+ TYPED_ATTR_NAMES = %w[href src id class datetime itemprop style srcset type].to_set.freeze
58
+
59
+ # String form of Tags::IGNORED_CONTAINER_NAMES for chrome checks without to_sym.
60
+ IGNORED_CONTAINER_TAGS = Tags::IGNORED_CONTAINER_NAMES.to_set(&:to_s).freeze
61
+
53
62
  class << self
54
63
  ##
55
64
  # @param input [String, Nokogiri::HTML::Document, Nokogiri::XML::Node]
@@ -89,9 +98,9 @@ module Html2rss
89
98
  ##
90
99
  # @return [Document]
91
100
  def call
92
- root_nk = @parsed_body.respond_to?(:root) ? (@parsed_body.at_css('html') || @parsed_body.root) : @parsed_body
101
+ root_nk = resolve_root_nk
93
102
  root = normalize_element(root_nk, parent: nil, depth: 0, path: '', chrome: false)
94
- raise ArgumentError, 'SST Normalizer produced an empty tree' unless root
103
+ raise EmptyTree, 'SST Normalizer produced an empty tree' unless root
95
104
 
96
105
  index = Index.new(root:, parents: @parents, depths: @depths, ignored_chrome: @ignored_chrome)
97
106
  Document.build(root:, index:, degraded: @degraded, node_count: @node_count)
@@ -99,14 +108,45 @@ module Html2rss
99
108
 
100
109
  private
101
110
 
102
- # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
111
+ # @return [Nokogiri::XML::Node]
112
+ # rubocop:disable-next Metrics/MethodLength -- explicit root discovery fallback chain
113
+ def resolve_root_nk
114
+ parsed = @parsed_body
115
+
116
+ if parsed.respond_to?(:at_css)
117
+ html = parsed.at_css('html')
118
+ return html if html
119
+
120
+ body = parsed.at_css('body')
121
+ if body
122
+ Html2rss::Log.warn('sst.normalizer root fallback: body')
123
+ return body
124
+ end
125
+ end
126
+
127
+ if parsed.respond_to?(:element_children)
128
+ first = parsed.element_children.find { |child| element_root?(child) }
129
+ if first
130
+ Html2rss::Log.warn('sst.normalizer root fallback: first element child')
131
+ return first
132
+ end
133
+ end
134
+
135
+ Html2rss::Log.warn('sst.normalizer root fallback: self')
136
+ parsed
137
+ end
138
+
139
+ def element_root?(node)
140
+ node.element? && !STRIPPED_TAGS.include?(Html2rss::Html::Probe.tag(node))
141
+ end
142
+
143
+ # rubocop:disable-next Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
103
144
  def normalize_element(nk_node, parent:, depth:, path:, chrome:)
104
145
  return unless nk_node.respond_to?(:name)
105
- return if nk_node.text? || nk_node.comment? || nk_node.cdata?
146
+ return unless nk_node.element?
106
147
 
107
- tag = nk_node.name.to_s.downcase
148
+ tag = Html2rss::Html::Probe.tag(nk_node)
108
149
  return if STRIPPED_TAGS.include?(tag)
109
- return if @degraded && !SEMANTIC_DEGRADE_TAGS.include?(tag)
110
150
 
111
151
  if @node_count >= MAX_NODES && !@degraded
112
152
  @degraded = true
@@ -119,9 +159,9 @@ module Html2rss
119
159
  tag_path = path.empty? ? "/#{tag}" : "#{path}/#{tag}"
120
160
  attrs = extract_attrs(nk_node)
121
161
  own_text = direct_text(nk_node)
122
- chrome_here = chrome || Tags::IGNORED_CONTAINER_NAMES.include?(tag.to_sym)
162
+ chrome_here = chrome || IGNORED_CONTAINER_TAGS.include?(tag)
123
163
 
124
- children = nk_node.children.filter_map do |child|
164
+ children = nk_node.element_children.filter_map do |child|
125
165
  normalize_element(child, parent: :pending, depth: depth + 1, path: tag_path, chrome: chrome_here)
126
166
  end.freeze
127
167
 
@@ -135,7 +175,6 @@ module Html2rss
135
175
 
136
176
  node
137
177
  end
138
- # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
139
178
 
140
179
  def extract_attrs(nk_node) # rubocop:disable Metrics/MethodLength
141
180
  Attrs.build(
@@ -153,10 +192,9 @@ module Html2rss
153
192
  end
154
193
 
155
194
  def raw_attrs(nk_node)
156
- typed = %w[href src id class datetime itemprop style srcset type].to_set
157
195
  nk_node.attribute_nodes.each_with_object({}) do |attr, raw|
158
196
  name = attr.name.to_s
159
- next if typed.include?(name)
197
+ next if TYPED_ATTR_NAMES.include?(name)
160
198
  next unless name.match?(RAW_ATTR_KEEP)
161
199
 
162
200
  raw[name] = attr.value.to_s
@@ -26,7 +26,7 @@ module Html2rss # rubocop:disable Metrics/ModuleLength -- Status Data.define + m
26
26
  # @param scrape_url [String, nil] effective scrape URL after resolution
27
27
  # @param entry_resolution [Html2rss::FeedResolution::Diag, Hash, nil] resolution diagnostics
28
28
  # @return [Html2rss::Status]
29
- # rubocop:disable Metrics/ParameterLists, Metrics/MethodLength -- Status kwargs stay co-located
29
+ # rubocop:disable-next Metrics/ParameterLists, Metrics/MethodLength -- Status kwargs stay co-located
30
30
  def build(articles:, dedup_dropped: 0, selected_strategy: nil, attempt_count: 0,
31
31
  strategy_attempts: [], admission_drops: {}, scrape_target: nil,
32
32
  entry_url: nil, scrape_url: nil, entry_resolution: nil)
@@ -44,7 +44,6 @@ module Html2rss # rubocop:disable Metrics/ModuleLength -- Status Data.define + m
44
44
  entry_resolution:
45
45
  )
46
46
  end
47
- # rubocop:enable Metrics/ParameterLists, Metrics/MethodLength
48
47
 
49
48
  ##
50
49
  # @param klass [Class, #to_s] scraper class
@@ -65,7 +64,7 @@ module Html2rss # rubocop:disable Metrics/ModuleLength -- Status Data.define + m
65
64
  # @param entry_url [String, nil]
66
65
  # @param scrape_url [String, nil]
67
66
  # @param entry_resolution [Hash, nil]
68
- # rubocop:disable Metrics/ParameterLists, Metrics/MethodLength -- Status Data.define members
67
+ # rubocop:disable-next Metrics/ParameterLists, Metrics/MethodLength -- Status Data.define members
69
68
  def initialize(
70
69
  version:, scraper_tallies:, dedup_dropped:, selected_strategy: nil, attempt_count: 0,
71
70
  strategy_attempts: [], admission_drops: {}, entry_url: nil, scrape_url: nil, entry_resolution: nil
@@ -87,13 +86,12 @@ module Html2rss # rubocop:disable Metrics/ModuleLength -- Status Data.define + m
87
86
  entry_resolution: freeze_entry_resolution(entry_resolution)
88
87
  )
89
88
  end
90
- # rubocop:enable Metrics/ParameterLists, Metrics/MethodLength
91
89
 
92
90
  ##
93
91
  # Observability hash for web (+scraper_status+). Omits empty/absent optional keys.
94
92
  #
95
93
  # @return [Hash{Symbol => Object}]
96
- # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity -- omit-empty optional keys stay explicit
94
+ # rubocop:disable-next Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity -- omit-empty optional keys stay explicit
97
95
  def to_h
98
96
  {
99
97
  version:,
@@ -108,7 +106,6 @@ module Html2rss # rubocop:disable Metrics/ModuleLength -- Status Data.define + m
108
106
  **(entry_resolution ? { entry_resolution: } : {})
109
107
  }
110
108
  end
111
- # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
112
109
 
113
110
  ##
114
111
  # Formats the RSS +generator+ string and JSON Feed +user_comment+.
@@ -4,12 +4,12 @@ Native RSS/Atom discovery and parse for auto-source promotion and direct feed UR
4
4
 
5
5
  ## Ownership
6
6
 
7
- | Concern | Owner |
8
- | --- | --- |
9
- | Head `rel=alternate` + common path probes + status-gated feedish | `Syndication::Discovery` |
10
- | RSS 2.0 / Atom → article hashes | `Syndication::Parser` |
11
- | Strict head-only link parse (no path guessing) | `Html::FeedLink` |
12
- | Response Content-Type / body feed sniff | `RequestService::Response#feed_response?` (sole sniff owner; Discovery only adds HTTP status gate) |
7
+ | Concern | Owner |
8
+ | ---------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
9
+ | Head `rel=alternate` + common path probes + status-gated feedish | `Syndication::Discovery` |
10
+ | RSS 2.0 / Atom → article hashes | `Syndication::Parser` |
11
+ | Strict head-only link parse (no path guessing) | `Html::FeedLink` |
12
+ | Response Content-Type / body feed sniff | `RequestService::Response#feed_response?` (sole sniff owner; Discovery only adds HTTP status gate) |
13
13
 
14
14
  ## Non-goals
15
15
 
@@ -28,14 +28,13 @@ module Html2rss
28
28
  # @param extra_paths [Array<String>] additional path guesses
29
29
  # @param max_probes [Integer, nil] max candidate GETs (nil = uncapped)
30
30
  # @return [Html2rss::Url, nil]
31
- # rubocop:disable Metrics/ParameterLists -- discovery kwargs stay co-located
31
+ # rubocop:disable-next Metrics/ParameterLists -- discovery kwargs stay co-located
32
32
  def best_feed_url(page_url:, request_session:, parsed_body: nil, html: nil, extra_paths: [],
33
33
  max_probes: nil)
34
34
  best_feed_response(
35
35
  page_url:, request_session:, parsed_body:, html:, extra_paths:, max_probes:
36
36
  )&.url
37
37
  end
38
- # rubocop:enable Metrics/ParameterLists
39
38
 
40
39
  ##
41
40
  # Like {#best_feed_url} but returns the validated syndication response for parsing.
@@ -47,7 +46,7 @@ module Html2rss
47
46
  # @param extra_paths [Array<String>]
48
47
  # @param max_probes [Integer, nil] max candidate GETs (nil = uncapped)
49
48
  # @return [Html2rss::RequestService::Response, nil]
50
- # rubocop:disable Metrics/ParameterLists, Metrics/MethodLength -- discovery kwargs stay co-located
49
+ # rubocop:disable-next Metrics/ParameterLists, Metrics/MethodLength -- discovery kwargs stay co-located
51
50
  def best_feed_response(page_url:, request_session:, parsed_body: nil, html: nil, extra_paths: [],
52
51
  max_probes: nil)
53
52
  page = Html2rss::Url.from_absolute(page_url)
@@ -64,7 +63,6 @@ module Html2rss
64
63
  Log.info("Syndication::Discovery: host=#{page.host} selected_feed_url=#{response.url}")
65
64
  end
66
65
  end
67
- # rubocop:enable Metrics/ParameterLists, Metrics/MethodLength
68
66
 
69
67
  ##
70
68
  # Ordered candidate feed URLs (head alternates first, then path guesses).
@@ -0,0 +1,192 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ module Test
5
+ ##
6
+ # Probes list-card +enhance+ value on cached listing HTML.
7
+ # Sole owner of enhance_gains metrics and enhance-specific warn-only warnings.
8
+ module EnhanceAudit # rubocop:disable Metrics/ModuleLength -- probe + compare + heuristics stay co-located
9
+ module_function
10
+
11
+ TRACKED_KEYS = %i[title url description published_at categories author image id enclosures].freeze
12
+ CURATOR_KEYS = %i[title url description published_at].freeze
13
+ private_constant :TRACKED_KEYS, :CURATOR_KEYS
14
+
15
+ ##
16
+ # Aggregate enhance probe metrics for one configuration test.
17
+ EnhanceGains = Data.define(:items_probed, :keys_added, :descriptions_added, :no_op)
18
+
19
+ ##
20
+ # Probe output merged into {QualityReport}.
21
+ AuditSlice = Data.define(:enhance_gains, :warnings)
22
+
23
+ IMAGE_FILENAME = /\.(jpe?g|png|gif|webp|avif|svg)\z/i
24
+ private_constant :IMAGE_FILENAME
25
+
26
+ ##
27
+ # @param response [Html2rss::RequestService::Response]
28
+ # @param selectors [Hash{Symbol => Object}]
29
+ # @param time_zone [String]
30
+ # @return [AuditSlice, nil] nil when selectors are absent
31
+ def probe(response:, selectors:, time_zone:) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- per-item probe loop
32
+ return nil unless selectors&.dig(:items, :selector)
33
+
34
+ selector_engine = Selectors.new(response, selectors:, time_zone:)
35
+ gains = {
36
+ items_probed: 0,
37
+ keys_added: zero_keys_added.dup,
38
+ descriptions_added: 0,
39
+ no_op: true
40
+ }
41
+ warnings = []
42
+
43
+ response.parsed_body.css(selector_engine.items_selector).each do |item|
44
+ baseline = selector_engine.extract_article(item, response)
45
+ enhanced = selector_engine.enhance_article_hash(baseline.dup, item, response.url)
46
+ item_keys = keys_added(baseline, enhanced)
47
+ merge_keys_added!(gains, item_keys)
48
+ append_item_warnings!(warnings, item:, enhanced:, item_keys:)
49
+ end
50
+
51
+ finalize_gains!(gains)
52
+ warnings << :enhance_no_op if gains.fetch(:no_op)
53
+ AuditSlice.new(
54
+ enhance_gains: EnhanceGains.new(
55
+ items_probed: gains.fetch(:items_probed),
56
+ keys_added: gains.fetch(:keys_added).freeze,
57
+ descriptions_added: gains.fetch(:descriptions_added),
58
+ no_op: gains.fetch(:no_op)
59
+ ),
60
+ warnings: warnings.freeze
61
+ )
62
+ end
63
+
64
+ ##
65
+ # @param response [Html2rss::RequestService::Response]
66
+ # @param selectors [Hash{Symbol => Object}]
67
+ # @param time_zone [String]
68
+ # @return [Hash{Symbol => Object}, nil]
69
+ def compare(response:, selectors:, time_zone:) # rubocop:disable Metrics/MethodLength -- enhance off/on stats + delta
70
+ return nil unless selectors&.dig(:items, :selector)
71
+
72
+ off = extraction_stats(response, selectors, time_zone, enhance: false)
73
+ on = extraction_stats(response, selectors, time_zone, enhance: true)
74
+ delta_gains = probe(response:, selectors:, time_zone:)&.enhance_gains || empty_gains
75
+
76
+ {
77
+ enhance_off: off,
78
+ enhance_on: on,
79
+ delta: {
80
+ descriptions_gained: on[:descriptions_filled] - off[:descriptions_filled],
81
+ keys_added: delta_gains.keys_added,
82
+ no_op: delta_gains.no_op
83
+ }
84
+ }
85
+ end
86
+
87
+ ##
88
+ # @return [EnhanceGains] zeroed gains for compare fallback
89
+ def empty_gains
90
+ EnhanceGains.new(
91
+ items_probed: 0,
92
+ keys_added: zero_keys_added,
93
+ descriptions_added: 0,
94
+ no_op: true
95
+ )
96
+ end
97
+
98
+ def zero_keys_added
99
+ TRACKED_KEYS.to_h { |key| [key, 0] }.freeze
100
+ end
101
+ private_class_method :zero_keys_added
102
+
103
+ def extraction_stats(response, selectors, time_zone, enhance:)
104
+ merged = selectors_with_enhance(selectors, enhance:)
105
+ articles = Selectors.new(response, selectors: merged, time_zone:).articles
106
+ {
107
+ item_count: articles.size,
108
+ descriptions_filled: articles.count { |article| article.description.to_s.strip != '' }
109
+ }
110
+ end
111
+ private_class_method :extraction_stats
112
+
113
+ def selectors_with_enhance(selectors, enhance:)
114
+ merged = HashUtil.deep_dup(selectors)
115
+ merged[:items] = merged.fetch(:items, {}).merge(enhance:)
116
+ merged
117
+ end
118
+ private_class_method :selectors_with_enhance
119
+
120
+ def keys_added(baseline, enhanced)
121
+ enhanced.each_with_object({}) do |(key, value), counts|
122
+ next if value.nil?
123
+
124
+ baseline_value = baseline[key]
125
+ next if baseline.key?(key) && baseline_value
126
+
127
+ counts[key] = (counts[key] || 0) + 1
128
+ end
129
+ end
130
+ private_class_method :keys_added
131
+
132
+ def merge_keys_added!(gains, item_keys)
133
+ gains[:items_probed] += 1
134
+ item_keys.each do |key, count|
135
+ next unless TRACKED_KEYS.include?(key)
136
+
137
+ gains[:keys_added][key] += count
138
+ end
139
+ gains[:descriptions_added] += item_keys.fetch(:description, 0)
140
+ end
141
+ private_class_method :merge_keys_added!
142
+
143
+ def finalize_gains!(gains)
144
+ curator_total = CURATOR_KEYS.sum { |key| gains[:keys_added][key] }
145
+ gains[:no_op] = curator_total.zero?
146
+ end
147
+ private_class_method :finalize_gains!
148
+
149
+ def append_item_warnings!(warnings, item:, enhanced:, item_keys:)
150
+ curator_added = item_keys.slice(*CURATOR_KEYS)
151
+ return if curator_added.empty?
152
+ return unless curator_added.key?(:description)
153
+
154
+ description = enhanced[:description].to_s.strip
155
+ return if description.empty?
156
+
157
+ if category_only_description?(description, enhanced)
158
+ warnings << :enhance_category_only_description
159
+ elsif image_only_description?(description, item)
160
+ warnings << :enhance_image_only_description
161
+ end
162
+ end
163
+ private_class_method :append_item_warnings!
164
+
165
+ def category_only_description?(description, enhanced)
166
+ normalized = description.downcase
167
+ return true if Html2rss::Html::ArticleRules::Description::TYPE_CHIPS.include?(normalized)
168
+
169
+ categories = Array(enhanced[:categories]).map { |category| category.to_s.strip }
170
+ categories.any? { |category| category.casecmp?(description) }
171
+ end
172
+ private_class_method :category_only_description?
173
+
174
+ def image_only_description?(description, item)
175
+ images = item.css('img')
176
+ return description == images.first['alt'].to_s.strip if images.size == 1 && images.first['alt']
177
+
178
+ prose_shaped?(description) == false
179
+ end
180
+ private_class_method :image_only_description?
181
+
182
+ def prose_shaped?(text)
183
+ return false if text.match?(IMAGE_FILENAME)
184
+ return false if text.match?(%r{\Ahttps?://}i)
185
+ return false if text.match?(%r{\A/[\w./-]+\z})
186
+
187
+ text.match?(/[a-z]/i)
188
+ end
189
+ private_class_method :prose_shaped?
190
+ end
191
+ end
192
+ end