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,215 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ module Syndication
5
+ ##
6
+ # Finds a same-origin RSS/Atom URL for a page via head alternates and path guesses.
7
+ #
8
+ # Ports configs +probe_rss+ path/alternate logic onto {RequestSession#follow_up}.
9
+ module Discovery # rubocop:disable Metrics/ModuleLength -- discovery + path probes stay co-located
10
+ # Common feed path suffixes probed after head +rel=alternate+ hints.
11
+ DEFAULT_PATHS = CandidateCatalog::FEED_PATHS
12
+
13
+ LINK_TAG_RE = /<link\b[^>]*>/i
14
+ HREF_RE = /\bhref\s*=\s*["']([^"']+)["']/i
15
+ TYPE_RE = /\btype\s*=\s*["']([^"']+)["']/i
16
+ REL_RE = /\brel\s*=\s*["']([^"']+)["']/i
17
+ private_constant :LINK_TAG_RE, :HREF_RE, :TYPE_RE, :REL_RE
18
+
19
+ module_function
20
+
21
+ ##
22
+ # Probes candidates lazily and returns the first feedish URL.
23
+ #
24
+ # @param page_url [String, Html2rss::Url] page or origin URL
25
+ # @param request_session [Html2rss::RequestSession] shared session for follow-ups
26
+ # @param parsed_body [Nokogiri::HTML::Document, nil] parsed HTML when available
27
+ # @param html [String, nil] raw HTML when a document is unavailable
28
+ # @param extra_paths [Array<String>] additional path guesses
29
+ # @param max_probes [Integer, nil] max candidate GETs (nil = uncapped)
30
+ # @return [Html2rss::Url, nil]
31
+ # rubocop:disable-next Metrics/ParameterLists -- discovery kwargs stay co-located
32
+ def best_feed_url(page_url:, request_session:, parsed_body: nil, html: nil, extra_paths: [],
33
+ max_probes: nil)
34
+ best_feed_response(
35
+ page_url:, request_session:, parsed_body:, html:, extra_paths:, max_probes:
36
+ )&.url
37
+ end
38
+
39
+ ##
40
+ # Like {#best_feed_url} but returns the validated syndication response for parsing.
41
+ #
42
+ # @param page_url [String, Html2rss::Url]
43
+ # @param request_session [Html2rss::RequestSession]
44
+ # @param parsed_body [Nokogiri::HTML::Document, nil]
45
+ # @param html [String, nil]
46
+ # @param extra_paths [Array<String>]
47
+ # @param max_probes [Integer, nil] max candidate GETs (nil = uncapped)
48
+ # @return [Html2rss::RequestService::Response, nil]
49
+ # rubocop:disable-next Metrics/ParameterLists, Metrics/MethodLength -- discovery kwargs stay co-located
50
+ def best_feed_response(page_url:, request_session:, parsed_body: nil, html: nil, extra_paths: [],
51
+ max_probes: nil)
52
+ page = Html2rss::Url.from_absolute(page_url)
53
+ candidates = candidate_urls(page_url: page, parsed_body:, html:, extra_paths:)
54
+ Log.debug(
55
+ "Syndication::Discovery: host=#{page.host} candidate_count=#{candidates.size}"
56
+ )
57
+
58
+ first_feedish_response(
59
+ candidates, request_session:, origin_url: page, max_probes:
60
+ ).tap do |response|
61
+ next unless response
62
+
63
+ Log.info("Syndication::Discovery: host=#{page.host} selected_feed_url=#{response.url}")
64
+ end
65
+ end
66
+
67
+ ##
68
+ # Ordered candidate feed URLs (head alternates first, then path guesses).
69
+ #
70
+ # @param page_url [String, Html2rss::Url]
71
+ # @param parsed_body [Nokogiri::HTML::Document, nil]
72
+ # @param html [String, nil]
73
+ # @param extra_paths [Array<String>]
74
+ # @return [Array<Html2rss::Url>]
75
+ def candidate_urls(page_url:, parsed_body: nil, html: nil, extra_paths: [])
76
+ page = Html2rss::Url.from_absolute(page_url)
77
+ (alternate_feed_urls(page, parsed_body:, html:) +
78
+ path_guess_urls(page, extra_paths:)).uniq
79
+ end
80
+
81
+ ##
82
+ # @param response [Html2rss::RequestService::Response, nil]
83
+ # @return [Boolean]
84
+ def feedish?(response)
85
+ return false unless response
86
+ return false unless successful_status?(response)
87
+
88
+ response.feed_response?
89
+ end
90
+
91
+ def successful_status?(response)
92
+ status = response.status
93
+ status.nil? || status.between?(200, 299)
94
+ end
95
+ module_function :successful_status?
96
+ private_class_method :successful_status?
97
+
98
+ ##
99
+ # Directory-relative feed path guesses for a page URL.
100
+ #
101
+ # @param page_url [String, Html2rss::Url]
102
+ # @return [Array<String>] relative paths
103
+ def page_dir_paths(page_url)
104
+ path = Html2rss::Url.from_absolute(page_url).path
105
+ return [] if path.nil? || path.empty? || path == '/'
106
+
107
+ dir = path.sub(%r{/[^/]*$}, '/')
108
+ ["#{dir}feed", "#{dir}rss.xml", "#{dir}atom.xml"]
109
+ end
110
+
111
+ def first_feedish_response(urls, request_session:, origin_url:, max_probes: nil)
112
+ scoped = max_probes.nil? ? urls.lazy : urls.lazy.take(max_probes)
113
+ scoped.filter_map do |url|
114
+ response = probe(url, request_session:, origin_url:)
115
+ response if feedish?(response)
116
+ end.first
117
+ end
118
+ module_function :first_feedish_response
119
+ private_class_method :first_feedish_response
120
+
121
+ def first_feedish_url(urls, request_session:, origin_url:, max_probes: nil)
122
+ first_feedish_response(urls, request_session:, origin_url:, max_probes:)&.url
123
+ end
124
+ module_function :first_feedish_url
125
+ private_class_method :first_feedish_url
126
+
127
+ def probe(url, request_session:, origin_url:)
128
+ request_session.follow_up(url:, relation: :auto_source, origin_url:)
129
+ rescue Html2rss::Error => error
130
+ Log.debug(
131
+ "Syndication::Discovery: probe skipped host=#{origin_url.host} " \
132
+ "(#{error.class})"
133
+ )
134
+ nil
135
+ end
136
+ module_function :probe
137
+ private_class_method :probe
138
+
139
+ def alternate_feed_urls(page, parsed_body:, html:)
140
+ from_document = feed_links_from_document(parsed_body, page)
141
+ return from_document if from_document.any?
142
+
143
+ alternate_feed_hrefs(html.to_s, page)
144
+ end
145
+ module_function :alternate_feed_urls
146
+ private_class_method :alternate_feed_urls
147
+
148
+ def feed_links_from_document(parsed_body, page)
149
+ return [] unless parsed_body.is_a?(Nokogiri::HTML::Document)
150
+
151
+ Html::FeedLink.from_document(parsed_body).filter_map do |link|
152
+ absolute_url(page, link.href)
153
+ end
154
+ end
155
+ module_function :feed_links_from_document
156
+ private_class_method :feed_links_from_document
157
+
158
+ def path_guess_urls(page, extra_paths:)
159
+ paths = (page_dir_paths(page) + DEFAULT_PATHS + Array(extra_paths)).uniq
160
+ paths.filter_map { |path| absolute_url(page, path) }
161
+ end
162
+ module_function :path_guess_urls
163
+ private_class_method :path_guess_urls
164
+
165
+ def alternate_feed_hrefs(html, page)
166
+ return [] if html.nil? || html.empty?
167
+
168
+ html.scan(LINK_TAG_RE).filter_map { |tag| href_from_alternate_link(tag, page) }.uniq
169
+ end
170
+ module_function :alternate_feed_hrefs
171
+ private_class_method :alternate_feed_hrefs
172
+
173
+ def href_from_alternate_link(tag, page)
174
+ return unless alternate_rel?(tag)
175
+
176
+ href = tag[HREF_RE, 1]
177
+ return if href.nil? || href.empty?
178
+ return unless syndication_type?(tag[TYPE_RE, 1]) || feed_like_href?(href)
179
+
180
+ absolute_url(page, href.strip)
181
+ end
182
+ module_function :href_from_alternate_link
183
+ private_class_method :href_from_alternate_link
184
+
185
+ def alternate_rel?(tag)
186
+ tag[REL_RE, 1].to_s.downcase.split(/\s+/).include?('alternate')
187
+ end
188
+ module_function :alternate_rel?
189
+ private_class_method :alternate_rel?
190
+
191
+ def syndication_type?(type)
192
+ return false if type.nil? || type.empty?
193
+
194
+ t = type.downcase
195
+ t.include?('rss+xml') || t.include?('atom+xml') || t.include?('rss') || t.include?('atom')
196
+ end
197
+ module_function :syndication_type?
198
+ private_class_method :syndication_type?
199
+
200
+ def feed_like_href?(href)
201
+ href.match?(/rss|atom|feed|\.xml/i)
202
+ end
203
+ module_function :feed_like_href?
204
+ private_class_method :feed_like_href?
205
+
206
+ def absolute_url(base, path)
207
+ Html2rss::Url.from_relative(path, base)
208
+ rescue ArgumentError
209
+ nil
210
+ end
211
+ module_function :absolute_url
212
+ private_class_method :absolute_url
213
+ end
214
+ end
215
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rss'
4
+
5
+ module Html2rss
6
+ module Syndication
7
+ ##
8
+ # Parses RSS 2.0 and Atom documents into AutoSource article hashes.
9
+ module Parser
10
+ module_function
11
+
12
+ ##
13
+ # @param response [Html2rss::RequestService::Response]
14
+ # @return [Array<Hash{Symbol => Object}>] article hashes (may be empty)
15
+ # @raise [ArgumentError] when the response is not a syndication feed
16
+ def parse_response(response)
17
+ raise ArgumentError, 'response is not a syndication feed' unless response.feed_response?
18
+
19
+ parse(response.body, base_url: response.url)
20
+ end
21
+
22
+ ##
23
+ # @param body [String] raw RSS/Atom document
24
+ # @param base_url [String, Html2rss::Url, nil] fallback base for relative item links
25
+ # @return [Array<Hash{Symbol => Object}>]
26
+ def parse(body, base_url: nil)
27
+ feed = RSS::Parser.parse(body.to_s, false)
28
+ return [] unless feed
29
+
30
+ items_from(feed, base_url:).filter_map { |item| article_hash(item, base_url:) }
31
+ rescue RSS::Error => error
32
+ Log.warn("Syndication::Parser: failed to parse feed (#{error.class}: #{error.message})")
33
+ []
34
+ end
35
+
36
+ def items_from(feed, base_url:) # rubocop:disable Lint/UnusedMethodArgument -- base reserved for callers
37
+ case feed
38
+ when RSS::Rss then Array(feed.items)
39
+ when RSS::Atom::Feed then Array(feed.entries)
40
+ else []
41
+ end
42
+ end
43
+ module_function :items_from
44
+ private_class_method :items_from
45
+
46
+ def article_hash(item, base_url:)
47
+ case item
48
+ when RSS::Rss::Channel::Item then rss_item_hash(item, base_url:)
49
+ when RSS::Atom::Entry, RSS::Atom::Feed::Entry then atom_entry_hash(item, base_url:)
50
+ end
51
+ end
52
+ module_function :article_hash
53
+ private_class_method :article_hash
54
+
55
+ def rss_item_hash(item, base_url:) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- RSS item mapping
56
+ url = absolute_url(item.link, base_url:)
57
+ return unless url
58
+
59
+ {
60
+ id: present(item.guid&.content) || url.to_s,
61
+ title: present(item.title),
62
+ description: present(item.description),
63
+ url:,
64
+ published_at: item.date || item.pubDate,
65
+ author: present(item.author),
66
+ categories: Array(item.categories).filter_map { |cat| present(cat.content) }
67
+ }.compact
68
+ end
69
+ module_function :rss_item_hash
70
+ private_class_method :rss_item_hash
71
+
72
+ def atom_entry_hash(entry, base_url:) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity -- Atom entry mapping
73
+ url = atom_link(entry, base_url:)
74
+ return unless url
75
+
76
+ {
77
+ id: present(entry.id&.content) || url.to_s,
78
+ title: present(atom_text(entry.title)),
79
+ description: present(atom_text(entry.content) || atom_text(entry.summary)),
80
+ url:,
81
+ published_at: entry.published&.content || entry.updated&.content,
82
+ author: present(atom_author(entry)),
83
+ categories: Array(entry.categories).filter_map { |cat| present(cat.term) }
84
+ }.compact
85
+ end
86
+ module_function :atom_entry_hash
87
+ private_class_method :atom_entry_hash
88
+
89
+ def atom_link(entry, base_url:)
90
+ links = Array(entry.links)
91
+ link = links.find { |node| node.rel.nil? || node.rel == 'alternate' } || links.first
92
+ absolute_url(link&.href, base_url:)
93
+ end
94
+ module_function :atom_link
95
+ private_class_method :atom_link
96
+
97
+ def atom_text(node)
98
+ return if node.nil?
99
+
100
+ node.respond_to?(:content) ? node.content : node.to_s
101
+ end
102
+ module_function :atom_text
103
+ private_class_method :atom_text
104
+
105
+ def atom_author(entry)
106
+ author = Array(entry.author).first
107
+ return unless author
108
+
109
+ present(author.name&.content) || present(author.email&.content)
110
+ end
111
+ module_function :atom_author
112
+ private_class_method :atom_author
113
+
114
+ def absolute_url(raw, base_url:)
115
+ value = present(raw)
116
+ return unless value
117
+
118
+ if base_url
119
+ Html2rss::Url.from_relative(value, base_url)
120
+ else
121
+ Html2rss::Url.from_absolute(value)
122
+ end
123
+ rescue ArgumentError
124
+ nil
125
+ end
126
+ module_function :absolute_url
127
+ private_class_method :absolute_url
128
+
129
+ def present(value)
130
+ text = value.to_s.strip
131
+ text unless text.empty?
132
+ end
133
+ module_function :present
134
+ private_class_method :present
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ ##
5
+ # Native RSS/Atom discovery and parse (not HTML AutoSource ownership).
6
+ #
7
+ # {include:file:lib/html2rss/syndication/README.md}
8
+ module Syndication
9
+ end
10
+ end
@@ -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