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.
- 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 +8 -8
- 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 +5 -2
- data/lib/html2rss/auto_source/scraper/schema.rb +39 -21
- data/lib/html2rss/auto_source/scraper.rb +2 -3
- 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 +25 -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/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 +59 -22
- data/lib/html2rss/doctor/botasaurus.rb +130 -0
- data/lib/html2rss/feed_builder/rss.rb +1 -2
- data/lib/html2rss/feed_pipeline/README.md +5 -5
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +29 -10
- data/lib/html2rss/feed_pipeline.rb +27 -16
- data/lib/html2rss/feed_resolution/README.md +11 -11
- data/lib/html2rss/feed_resolution.rb +6 -12
- 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 -42
- 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 +56 -2
- data/lib/html2rss/recon.rb +252 -0
- data/lib/html2rss/request_service/blocked_surface.rb +1 -0
- 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 +3 -4
- data/lib/html2rss/request_service/strategy.rb +1 -2
- data/lib/html2rss/request_service.rb +2 -0
- data/lib/html2rss/request_session.rb +1 -2
- data/lib/html2rss/scrape_target.rb +1 -1
- 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 +3 -6
- data/lib/html2rss/syndication/README.md +6 -6
- data/lib/html2rss/syndication/discovery.rb +2 -4
- data/lib/html2rss/test/enhance_audit.rb +192 -0
- data/lib/html2rss/test.rb +458 -0
- data/lib/html2rss/url.rb +1 -2
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss.rb +157 -35
- data/schema/html2rss-config.schema.json +9 -29
- metadata +17 -18
- data/lib/html2rss/mcp/inspect.rb +0 -138
- 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) }
|
|
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) }
|
|
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
|
|
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]
|
data/lib/html2rss/selectors.rb
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/html2rss/sst/attrs.rb
CHANGED
|
@@ -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 =
|
|
101
|
+
root_nk = resolve_root_nk
|
|
93
102
|
root = normalize_element(root_nk, parent: nil, depth: 0, path: '', chrome: false)
|
|
94
|
-
raise
|
|
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
|
-
#
|
|
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
|
|
146
|
+
return unless nk_node.element?
|
|
106
147
|
|
|
107
|
-
tag = nk_node
|
|
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 ||
|
|
162
|
+
chrome_here = chrome || IGNORED_CONTAINER_TAGS.include?(tag)
|
|
123
163
|
|
|
124
|
-
children = nk_node.
|
|
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
|
|
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
|
data/lib/html2rss/status.rb
CHANGED
|
@@ -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
|
|
8
|
-
|
|
|
9
|
-
| Head `rel=alternate` + common path probes + status-gated feedish | `Syndication::Discovery`
|
|
10
|
-
| RSS 2.0 / Atom → article hashes
|
|
11
|
-
| Strict head-only link parse (no path guessing)
|
|
12
|
-
| Response Content-Type / body feed sniff
|
|
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
|