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
|
@@ -14,11 +14,15 @@ module Html2rss
|
|
|
14
14
|
|
|
15
15
|
# Bodies that look like HTML even when Content-Type is missing, empty, or wrong.
|
|
16
16
|
HTML_BODY_SNIFF = /\A\s*(?:<!DOCTYPE\s+html|<html)/i
|
|
17
|
+
# Content-Type markers for RSS/Atom syndication responses.
|
|
18
|
+
FEED_CT_MARKERS = %w[rss+xml atom+xml rss atom].freeze
|
|
19
|
+
# Body sniff for unlabeled syndication (first 800 bytes, downcased).
|
|
20
|
+
FEED_BODY_MARKERS = ['<rss', '<feed xmlns', '<feed '].freeze
|
|
17
21
|
# Charset from Content-Type or a leading <meta charset>.
|
|
18
22
|
CHARSET_PARAMETER = /charset\s*=\s*["']?([\w.:-]+)/i
|
|
19
23
|
# Bytes scanned for a meta charset hint (HTML spec looks at the first 1024).
|
|
20
24
|
META_CHARSET_BYTES = 2048
|
|
21
|
-
private_constant :CHARSET_PARAMETER, :META_CHARSET_BYTES
|
|
25
|
+
private_constant :CHARSET_PARAMETER, :META_CHARSET_BYTES, :FEED_CT_MARKERS, :FEED_BODY_MARKERS
|
|
22
26
|
|
|
23
27
|
##
|
|
24
28
|
# @param body [String] the body of the response
|
|
@@ -27,7 +31,7 @@ module Html2rss
|
|
|
27
31
|
# @param status [Integer, nil] the HTTP status code when available
|
|
28
32
|
# @param transport_meta [Hash] allowlisted upstream telemetry (frozen when present)
|
|
29
33
|
# @param captured_responses [Array<Hash>] JSON XHR/fetch bodies captured during browser scrapes
|
|
30
|
-
# 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
|
|
31
35
|
def initialize(body:, url:, headers: {}, status: nil, transport_meta: EMPTY_TRANSPORT_META,
|
|
32
36
|
captured_responses: EMPTY_CAPTURED_RESPONSES)
|
|
33
37
|
@body = body
|
|
@@ -46,7 +50,6 @@ module Html2rss
|
|
|
46
50
|
captured_responses.freeze
|
|
47
51
|
end
|
|
48
52
|
end
|
|
49
|
-
# rubocop:enable Metrics/MethodLength, Metrics/ParameterLists
|
|
50
53
|
|
|
51
54
|
# @return [String] the raw body of the response
|
|
52
55
|
attr_reader :body
|
|
@@ -74,7 +77,14 @@ module Html2rss
|
|
|
74
77
|
|
|
75
78
|
# @return [Boolean] whether response content is HTML (header or sniffed body, never JSON)
|
|
76
79
|
def html_response?
|
|
77
|
-
content_type.include?('text/html') || (!json_response? && html_looking_body?)
|
|
80
|
+
content_type.include?('text/html') || (!json_response? && !feed_response? && html_looking_body?)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# @return [Boolean] whether response content is RSS/Atom (header or sniffed body)
|
|
84
|
+
def feed_response?
|
|
85
|
+
return @feed_response if defined?(@feed_response)
|
|
86
|
+
|
|
87
|
+
@feed_response = feed_content_type? || (!json_response? && !html_looking_body? && feed_looking_body?)
|
|
78
88
|
end
|
|
79
89
|
|
|
80
90
|
##
|
|
@@ -143,6 +153,19 @@ module Html2rss
|
|
|
143
153
|
def html_looking_body?
|
|
144
154
|
body.to_s.b.match?(HTML_BODY_SNIFF)
|
|
145
155
|
end
|
|
156
|
+
|
|
157
|
+
def feed_content_type?
|
|
158
|
+
ct = content_type.downcase
|
|
159
|
+
return false if ct.empty? || ct.include?('html') || ct.include?('json')
|
|
160
|
+
|
|
161
|
+
# Substring match against Content-Type (not Array#intersect? on a String).
|
|
162
|
+
FEED_CT_MARKERS.any? { |marker| ct.include?(marker) }
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def feed_looking_body?
|
|
166
|
+
snippet = body.to_s[0, 800].downcase
|
|
167
|
+
FEED_BODY_MARKERS.any? { |marker| snippet.include?(marker) }
|
|
168
|
+
end
|
|
146
169
|
end
|
|
147
170
|
end
|
|
148
171
|
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,10 +29,24 @@ 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
|
|
36
|
+
|
|
34
37
|
# Raised when a request times out.
|
|
35
|
-
class RequestTimedOut < Html2rss::Error
|
|
38
|
+
class RequestTimedOut < Html2rss::Error
|
|
39
|
+
##
|
|
40
|
+
# @param message [String, nil] timeout failure summary
|
|
41
|
+
# @param timeout_phase [String, nil] scrape-api stage when known (+queue+/+boot+/+work+)
|
|
42
|
+
def initialize(message = nil, timeout_phase: nil)
|
|
43
|
+
@timeout_phase = timeout_phase
|
|
44
|
+
super(message)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# @return [String, nil] scrape-api timeout stage, or nil for transport/budget timeouts
|
|
48
|
+
attr_reader :timeout_phase
|
|
49
|
+
end
|
|
36
50
|
|
|
37
51
|
# Raised when Botasaurus configuration is missing or invalid.
|
|
38
52
|
class BotasaurusConfigurationError < Html2rss::Error
|
|
@@ -15,11 +15,13 @@ module Html2rss
|
|
|
15
15
|
# @param strategy [Symbol] request strategy for the session
|
|
16
16
|
# @param budget [RequestService::Budget] shared request budget
|
|
17
17
|
# @param policy [RequestService::Policy] request policy (from FeedPipeline::RuntimePolicy.resources_for)
|
|
18
|
+
# @param scrape_url [String, nil] effective fetch URL when it differs from config channel URL
|
|
18
19
|
# @param logger [Logger] logger used for operational warnings
|
|
19
20
|
# @return [RequestSession] configured request session
|
|
20
|
-
|
|
21
|
+
# rubocop:disable-next Metrics/ParameterLists -- scrape_url override stays beside config
|
|
22
|
+
def build(config:, strategy:, budget:, policy:, scrape_url: nil, logger: Html2rss::Log)
|
|
21
23
|
context = RequestService::Context.new(
|
|
22
|
-
url: config.url, headers: config.headers, request: config.request, policy:, budget:
|
|
24
|
+
url: scrape_url || config.url, headers: config.headers, request: config.request, policy:, budget:
|
|
23
25
|
)
|
|
24
26
|
new(context:, strategy:, logger:)
|
|
25
27
|
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
##
|
|
5
|
+
# Immutable entry vs effective scrape URLs for one pipeline run.
|
|
6
|
+
#
|
|
7
|
+
# Replaces mutating +Config#scrape_url=+ after {FeedResolution} rewrites the fetch URL.
|
|
8
|
+
ScrapeTarget = Data.define(:entry_url, :effective_url) do
|
|
9
|
+
##
|
|
10
|
+
# @param config [Html2rss::Config]
|
|
11
|
+
# @return [ScrapeTarget]
|
|
12
|
+
def self.from_config(config)
|
|
13
|
+
entry = config.url
|
|
14
|
+
new(entry_url: entry, effective_url: entry)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
##
|
|
18
|
+
# @param url [String, Html2rss::Url]
|
|
19
|
+
# @return [ScrapeTarget]
|
|
20
|
+
def with_effective(url)
|
|
21
|
+
self.class.new(entry_url:, effective_url: Url.from_absolute(url).to_s)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
module Html2rss
|
|
3
|
+
module Html2rss # rubocop:disable Metrics/ModuleLength -- Status Data.define + marshal stay co-located
|
|
4
4
|
##
|
|
5
5
|
# Shared RSS +generator+ / JSON Feed +user_comment+ formatter string.
|
|
6
6
|
#
|
|
@@ -9,7 +9,7 @@ module Html2rss
|
|
|
9
9
|
# Tallies and counters are validated and frozen at construction (including Marshal load).
|
|
10
10
|
Status = Data.define(
|
|
11
11
|
:version, :scraper_tallies, :dedup_dropped, :selected_strategy, :attempt_count,
|
|
12
|
-
:strategy_attempts, :admission_drops
|
|
12
|
+
:strategy_attempts, :admission_drops, :entry_url, :scrape_url, :entry_resolution
|
|
13
13
|
) do
|
|
14
14
|
class << self
|
|
15
15
|
##
|
|
@@ -21,10 +21,15 @@ module Html2rss
|
|
|
21
21
|
# @param attempt_count [Integer] auto-fallback attempt count (0 when not under +:auto+)
|
|
22
22
|
# @param strategy_attempts [Array<Hash>] auto-fallback attempt hashes (empty outside +:auto+)
|
|
23
23
|
# @param admission_drops [Hash{String => Integer}] Cleanup reason → count (empty when unused)
|
|
24
|
+
# @param scrape_target [Html2rss::ScrapeTarget, nil] domain URL pair (mapped to wire strings)
|
|
25
|
+
# @param entry_url [String, nil] original channel URL when entry resolution ran
|
|
26
|
+
# @param scrape_url [String, nil] effective scrape URL after resolution
|
|
27
|
+
# @param entry_resolution [Html2rss::FeedResolution::Diag, Hash, nil] resolution diagnostics
|
|
24
28
|
# @return [Html2rss::Status]
|
|
25
|
-
# rubocop:disable Metrics/ParameterLists -- Status kwargs stay co-located
|
|
29
|
+
# rubocop:disable-next Metrics/ParameterLists, Metrics/MethodLength -- Status kwargs stay co-located
|
|
26
30
|
def build(articles:, dedup_dropped: 0, selected_strategy: nil, attempt_count: 0,
|
|
27
|
-
strategy_attempts: [], admission_drops: {}
|
|
31
|
+
strategy_attempts: [], admission_drops: {}, scrape_target: nil,
|
|
32
|
+
entry_url: nil, scrape_url: nil, entry_resolution: nil)
|
|
28
33
|
tallies = articles.filter_map(&:scraper).tally.transform_keys { |klass| scraper_name(klass) }
|
|
29
34
|
new(
|
|
30
35
|
version: Html2rss::VERSION,
|
|
@@ -33,10 +38,12 @@ module Html2rss
|
|
|
33
38
|
selected_strategy:,
|
|
34
39
|
attempt_count:,
|
|
35
40
|
strategy_attempts:,
|
|
36
|
-
admission_drops
|
|
41
|
+
admission_drops:,
|
|
42
|
+
entry_url: entry_url || scrape_target&.entry_url,
|
|
43
|
+
scrape_url: scrape_url || scrape_target&.effective_url,
|
|
44
|
+
entry_resolution:
|
|
37
45
|
)
|
|
38
46
|
end
|
|
39
|
-
# rubocop:enable Metrics/ParameterLists
|
|
40
47
|
|
|
41
48
|
##
|
|
42
49
|
# @param klass [Class, #to_s] scraper class
|
|
@@ -54,10 +61,13 @@ module Html2rss
|
|
|
54
61
|
# @param attempt_count [Integer]
|
|
55
62
|
# @param strategy_attempts [Array<Hash>]
|
|
56
63
|
# @param admission_drops [Hash{String => Integer}]
|
|
57
|
-
#
|
|
64
|
+
# @param entry_url [String, nil]
|
|
65
|
+
# @param scrape_url [String, nil]
|
|
66
|
+
# @param entry_resolution [Hash, nil]
|
|
67
|
+
# rubocop:disable-next Metrics/ParameterLists, Metrics/MethodLength -- Status Data.define members
|
|
58
68
|
def initialize(
|
|
59
69
|
version:, scraper_tallies:, dedup_dropped:, selected_strategy: nil, attempt_count: 0,
|
|
60
|
-
strategy_attempts: [], admission_drops: {}
|
|
70
|
+
strategy_attempts: [], admission_drops: {}, entry_url: nil, scrape_url: nil, entry_resolution: nil
|
|
61
71
|
)
|
|
62
72
|
dedup = Integer(dedup_dropped)
|
|
63
73
|
attempts = Integer(attempt_count)
|
|
@@ -70,21 +80,18 @@ module Html2rss
|
|
|
70
80
|
selected_strategy:,
|
|
71
81
|
attempt_count: attempts,
|
|
72
82
|
strategy_attempts: freeze_attempts(strategy_attempts),
|
|
73
|
-
admission_drops: freeze_tallies(admission_drops)
|
|
83
|
+
admission_drops: freeze_tallies(admission_drops),
|
|
84
|
+
entry_url: freeze_optional_string(entry_url),
|
|
85
|
+
scrape_url: freeze_optional_string(scrape_url),
|
|
86
|
+
entry_resolution: freeze_entry_resolution(entry_resolution)
|
|
74
87
|
)
|
|
75
88
|
end
|
|
76
|
-
# rubocop:enable Metrics/ParameterLists, Metrics/MethodLength
|
|
77
89
|
|
|
78
90
|
##
|
|
79
|
-
# Observability hash for web (+scraper_status+). Omits empty/absent optional keys
|
|
80
|
-
# +:scraper_tallies+ when empty, +:selected_strategy+ when +nil+, +:attempt_count+ when zero,
|
|
81
|
-
# +:strategy_attempts+ / +:admission_drops+ when empty.
|
|
82
|
-
# Data members remain available via readers even when omitted here.
|
|
91
|
+
# Observability hash for web (+scraper_status+). Omits empty/absent optional keys.
|
|
83
92
|
#
|
|
84
|
-
# @return [Hash{Symbol => Object}]
|
|
85
|
-
#
|
|
86
|
-
# +:strategy_attempts+, +:admission_drops+
|
|
87
|
-
# rubocop:disable Metrics/AbcSize -- omit-empty optional keys stay explicit
|
|
93
|
+
# @return [Hash{Symbol => Object}]
|
|
94
|
+
# rubocop:disable-next Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity -- omit-empty optional keys stay explicit
|
|
88
95
|
def to_h
|
|
89
96
|
{
|
|
90
97
|
version:,
|
|
@@ -93,10 +100,12 @@ module Html2rss
|
|
|
93
100
|
**(selected_strategy.nil? ? {} : { selected_strategy: }),
|
|
94
101
|
**(attempt_count.positive? ? { attempt_count: } : {}),
|
|
95
102
|
**(strategy_attempts.any? ? { strategy_attempts: } : {}),
|
|
96
|
-
**(admission_drops.any? ? { admission_drops: } : {})
|
|
103
|
+
**(admission_drops.any? ? { admission_drops: } : {}),
|
|
104
|
+
**(entry_url ? { entry_url: } : {}),
|
|
105
|
+
**(scrape_url ? { scrape_url: } : {}),
|
|
106
|
+
**(entry_resolution ? { entry_resolution: } : {})
|
|
97
107
|
}
|
|
98
108
|
end
|
|
99
|
-
# rubocop:enable Metrics/AbcSize
|
|
100
109
|
|
|
101
110
|
##
|
|
102
111
|
# Formats the RSS +generator+ string and JSON Feed +user_comment+.
|
|
@@ -115,13 +124,15 @@ module Html2rss
|
|
|
115
124
|
|
|
116
125
|
def marshal_dump
|
|
117
126
|
[version, scraper_tallies, dedup_dropped, selected_strategy, attempt_count, strategy_attempts,
|
|
118
|
-
admission_drops]
|
|
127
|
+
admission_drops, entry_url, scrape_url, entry_resolution]
|
|
119
128
|
end
|
|
120
129
|
|
|
121
|
-
def marshal_load(
|
|
122
|
-
|
|
130
|
+
def marshal_load(payload)
|
|
131
|
+
version, scraper_tallies, dedup_dropped, selected_strategy, attempt_count,
|
|
132
|
+
strategy_attempts, admission_drops, entry_url, scrape_url, entry_resolution = payload
|
|
123
133
|
initialize(version:, scraper_tallies:, dedup_dropped:, selected_strategy:, attempt_count:,
|
|
124
|
-
strategy_attempts:, admission_drops: admission_drops || {}
|
|
134
|
+
strategy_attempts:, admission_drops: admission_drops || {}, entry_url:, scrape_url:,
|
|
135
|
+
entry_resolution:)
|
|
125
136
|
end
|
|
126
137
|
|
|
127
138
|
def freeze_tallies(tallies)
|
|
@@ -132,6 +143,19 @@ module Html2rss
|
|
|
132
143
|
Array(attempts).map { |attempt| attempt.to_h.freeze }.freeze
|
|
133
144
|
end
|
|
134
145
|
|
|
146
|
+
def freeze_optional_string(value)
|
|
147
|
+
return if value.nil?
|
|
148
|
+
|
|
149
|
+
value.to_s.dup.freeze
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def freeze_entry_resolution(value)
|
|
153
|
+
return if value.nil?
|
|
154
|
+
|
|
155
|
+
hash = value.respond_to?(:to_h) ? value.to_h : value
|
|
156
|
+
hash.to_h.transform_keys(&:to_sym).freeze
|
|
157
|
+
end
|
|
158
|
+
|
|
135
159
|
def validate_counters!(dedup:, attempts:, selected_strategy:)
|
|
136
160
|
raise ArgumentError, 'dedup_dropped must be >= 0' if dedup.negative?
|
|
137
161
|
raise ArgumentError, 'attempt_count must be >= 0' if attempts.negative?
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
##
|
|
5
|
+
# Closed surface class for no-scraper / page-assessment gates.
|
|
6
|
+
#
|
|
7
|
+
# Construction: {AutoSource::Scraper.classify_no_scraper_surface} and {PageRecon.assess}.
|
|
8
|
+
# Predicates own weak/blocked/listing-bonus decisions — do not re-list WEAK sets in Policy/Scorer.
|
|
9
|
+
class SurfaceCategory
|
|
10
|
+
# Surfaces that warrant listing/feed resolution (hubs / shells — not blocked).
|
|
11
|
+
WEAK = Set[:high_entropy_surface, :app_shell, :unsupported_surface].freeze
|
|
12
|
+
|
|
13
|
+
class << self
|
|
14
|
+
##
|
|
15
|
+
# @param value [SurfaceCategory, Symbol, String, nil]
|
|
16
|
+
# @return [SurfaceCategory]
|
|
17
|
+
def coerce(value)
|
|
18
|
+
return value if value.is_a?(self)
|
|
19
|
+
return new(name: nil) if value.nil?
|
|
20
|
+
|
|
21
|
+
new(name: value.to_sym)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
##
|
|
26
|
+
# @return [Symbol, nil]
|
|
27
|
+
attr_reader :name
|
|
28
|
+
|
|
29
|
+
##
|
|
30
|
+
# @param name [Symbol, nil]
|
|
31
|
+
def initialize(name:)
|
|
32
|
+
@name = name
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
##
|
|
36
|
+
# @return [Boolean]
|
|
37
|
+
def weak? = WEAK.include?(name)
|
|
38
|
+
|
|
39
|
+
##
|
|
40
|
+
# @return [Boolean]
|
|
41
|
+
def blocked? = name == :blocked_surface
|
|
42
|
+
|
|
43
|
+
##
|
|
44
|
+
# @return [Boolean] non-weak, non-blocked surface eligible for listing bonus
|
|
45
|
+
def listing_bonus? = !name.nil? && !weak? && !blocked?
|
|
46
|
+
|
|
47
|
+
##
|
|
48
|
+
# @return [Symbol, nil]
|
|
49
|
+
def to_sym = name
|
|
50
|
+
|
|
51
|
+
##
|
|
52
|
+
# @param other [Object]
|
|
53
|
+
# @return [Boolean]
|
|
54
|
+
def ==(other)
|
|
55
|
+
other.is_a?(self.class) && name == other.name
|
|
56
|
+
end
|
|
57
|
+
alias eql? ==
|
|
58
|
+
|
|
59
|
+
##
|
|
60
|
+
# @return [Integer]
|
|
61
|
+
def hash = [self.class, name].hash
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Syndication
|
|
2
|
+
|
|
3
|
+
Native RSS/Atom discovery and parse for auto-source promotion and direct feed URLs.
|
|
4
|
+
|
|
5
|
+
## Ownership
|
|
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) |
|
|
13
|
+
|
|
14
|
+
## Non-goals
|
|
15
|
+
|
|
16
|
+
- Entry-URL tournament / listing resolution (`FeedResolution`)
|
|
17
|
+
- Page recon / surface classification (`PageRecon`)
|
|
18
|
+
- Feed channel metadata for output (`Channel` / `FeedBuilder`)
|
|
19
|
+
- Ranking or quality scoring of competing feeds beyond first feedish hit
|
|
20
|
+
|
|
21
|
+
Discovery stops at the first same-origin feedish URL (lazy probe). Path guessing mirrors the legacy configs `probe_rss` script; that script should eventually thin-wrap this module.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
module Syndication
|
|
5
|
+
##
|
|
6
|
+
# Shared path lexicon for syndication discovery and entry-resolution candidates.
|
|
7
|
+
module CandidateCatalog
|
|
8
|
+
# Common feed path suffixes probed after head +rel=alternate+ hints.
|
|
9
|
+
FEED_PATHS = %w[
|
|
10
|
+
/feed
|
|
11
|
+
/feed.xml
|
|
12
|
+
/rss
|
|
13
|
+
/rss.xml
|
|
14
|
+
/atom.xml
|
|
15
|
+
/index.xml
|
|
16
|
+
/news/rss
|
|
17
|
+
/news/feed
|
|
18
|
+
/blog/feed
|
|
19
|
+
/blog/rss.xml
|
|
20
|
+
].freeze
|
|
21
|
+
|
|
22
|
+
# HTML listing paths probed only by {FeedResolution::CandidateGenerator}.
|
|
23
|
+
LISTING_PATHS = %w[/news /blog /releases /changelog /updates].freeze
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|