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.
- checksums.yaml +4 -4
- data/README.md +82 -25
- data/html2rss.gemspec +0 -1
- data/lib/html2rss/article.rb +1 -2
- data/lib/html2rss/auto_source/README.md +12 -9
- data/lib/html2rss/auto_source/cleanup.rb +92 -1
- data/lib/html2rss/auto_source/scraper/json_state/document_scanner.rb +3 -5
- data/lib/html2rss/auto_source/scraper/meta_oembed.rb +15 -5
- data/lib/html2rss/auto_source/scraper/microdata.rb +8 -6
- data/lib/html2rss/auto_source/scraper/native_feed.rb +93 -0
- data/lib/html2rss/auto_source/scraper/schema.rb +39 -21
- data/lib/html2rss/auto_source/scraper.rb +6 -4
- data/lib/html2rss/auto_source/segmenter/cluster.rb +2 -3
- data/lib/html2rss/auto_source/segmenter/primary_link.rb +1 -2
- data/lib/html2rss/auto_source/segmenter.rb +1 -2
- data/lib/html2rss/auto_source.rb +32 -11
- data/lib/html2rss/batch.rb +169 -0
- data/lib/html2rss/capture/README.md +15 -6
- data/lib/html2rss/capture.rb +158 -15
- data/lib/html2rss/channel.rb +1 -2
- data/lib/html2rss/cli/probe_view.rb +66 -0
- data/lib/html2rss/cli/render.rb +124 -0
- data/lib/html2rss/cli/validate.rb +91 -0
- data/lib/html2rss/cli.rb +318 -193
- data/lib/html2rss/config/auto_source_contract.rb +7 -0
- data/lib/html2rss/config/request_headers.rb +4 -14
- data/lib/html2rss/config/schema.rb +7 -5
- data/lib/html2rss/config/validator.rb +1 -0
- data/lib/html2rss/config.rb +64 -18
- data/lib/html2rss/doctor/botasaurus.rb +130 -0
- data/lib/html2rss/feed_builder/rss.rb +1 -2
- data/lib/html2rss/feed_pipeline/README.md +7 -5
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +78 -19
- data/lib/html2rss/feed_pipeline/runtime_policy.rb +1 -0
- data/lib/html2rss/feed_pipeline.rb +48 -21
- data/lib/html2rss/feed_resolution/README.md +52 -0
- data/lib/html2rss/feed_resolution/candidate_generator.rb +139 -0
- data/lib/html2rss/feed_resolution/diag.rb +32 -0
- data/lib/html2rss/feed_resolution/options.rb +31 -0
- data/lib/html2rss/feed_resolution/policy.rb +47 -0
- data/lib/html2rss/feed_resolution/probe.rb +59 -0
- data/lib/html2rss/feed_resolution/scorer.rb +49 -0
- data/lib/html2rss/feed_resolution.rb +268 -0
- data/lib/html2rss/html/article_extractor/heading_extractor.rb +24 -14
- data/lib/html2rss/html/article_extractor.rb +4 -9
- data/lib/html2rss/html/feed_link.rb +4 -2
- data/lib/html2rss/html/navigator.rb +4 -39
- data/lib/html2rss/html/probe.rb +82 -0
- data/lib/html2rss/html/sst_article_extractor.rb +1 -2
- data/lib/html2rss/link_destination/noise_policy.rb +1 -2
- data/lib/html2rss/link_destination/path_classifier.rb +20 -19
- data/lib/html2rss/mcp/README.md +80 -0
- data/lib/html2rss/mcp/contract.rb +135 -13
- data/lib/html2rss/mcp/outcome/playbook.rb +122 -0
- data/lib/html2rss/mcp/outcome.rb +112 -40
- data/lib/html2rss/mcp/runtime.rb +45 -0
- data/lib/html2rss/mcp/server/tools.rb +277 -0
- data/lib/html2rss/mcp/server.rb +91 -232
- data/lib/html2rss/mcp.rb +3 -0
- data/lib/html2rss/page_recon/README.md +69 -0
- data/lib/html2rss/page_recon/diagnostics.rb +211 -0
- data/lib/html2rss/page_recon.rb +297 -0
- data/lib/html2rss/recon.rb +252 -0
- data/lib/html2rss/request_service/blocked_surface.rb +1 -0
- data/lib/html2rss/request_service/botasaurus_contract.rb +8 -1
- data/lib/html2rss/request_service/botasaurus_strategy.rb +1 -1
- data/lib/html2rss/request_service/faraday_strategy.rb +11 -7
- data/lib/html2rss/request_service/policy.rb +1 -2
- data/lib/html2rss/request_service/response.rb +27 -4
- data/lib/html2rss/request_service/strategy.rb +1 -2
- data/lib/html2rss/request_service.rb +15 -1
- data/lib/html2rss/request_session.rb +4 -2
- data/lib/html2rss/scrape_target.rb +24 -0
- data/lib/html2rss/selectors/post_processors/sanitize_html.rb +2 -4
- data/lib/html2rss/selectors/post_processors.rb +0 -1
- data/lib/html2rss/selectors.rb +33 -10
- data/lib/html2rss/sst/attrs.rb +1 -2
- data/lib/html2rss/sst/normalizer.rb +49 -11
- data/lib/html2rss/status.rb +48 -24
- data/lib/html2rss/surface_category.rb +63 -0
- data/lib/html2rss/syndication/README.md +21 -0
- data/lib/html2rss/syndication/candidate_catalog.rb +26 -0
- data/lib/html2rss/syndication/discovery.rb +215 -0
- data/lib/html2rss/syndication/parser.rb +137 -0
- data/lib/html2rss/syndication.rb +10 -0
- data/lib/html2rss/test/enhance_audit.rb +192 -0
- data/lib/html2rss/test.rb +458 -0
- data/lib/html2rss/url.rb +23 -2
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss.rb +157 -35
- data/schema/html2rss-config.schema.json +47 -29
- metadata +34 -18
- data/lib/html2rss/mcp/inspect.rb +0 -241
- 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,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
|