html2rss 0.27.2 → 0.28.0
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/lib/html2rss/auto_source/README.md +4 -1
- data/lib/html2rss/auto_source/scraper/native_feed.rb +90 -0
- data/lib/html2rss/auto_source/scraper.rb +4 -1
- data/lib/html2rss/auto_source.rb +7 -0
- data/lib/html2rss/config/auto_source_contract.rb +7 -0
- data/lib/html2rss/config.rb +10 -1
- data/lib/html2rss/feed_pipeline/README.md +2 -0
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +53 -13
- data/lib/html2rss/feed_pipeline/runtime_policy.rb +1 -0
- data/lib/html2rss/feed_pipeline.rb +24 -8
- 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 +274 -0
- data/lib/html2rss/mcp/inspect.rb +18 -121
- data/lib/html2rss/mcp/outcome.rb +4 -2
- data/lib/html2rss/page_recon.rb +243 -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/response.rb +26 -2
- data/lib/html2rss/request_service.rb +13 -1
- data/lib/html2rss/request_session.rb +5 -2
- data/lib/html2rss/scrape_target.rb +24 -0
- data/lib/html2rss/status.rb +49 -22
- 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 +217 -0
- data/lib/html2rss/syndication/parser.rb +137 -0
- data/lib/html2rss/syndication.rb +10 -0
- data/lib/html2rss/url.rb +22 -0
- data/lib/html2rss/version.rb +1 -1
- data/schema/html2rss-config.schema.json +38 -0
- metadata +19 -2
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
##
|
|
5
|
+
# Shared page recon for MCP Inspect and FeedResolution probes.
|
|
6
|
+
#
|
|
7
|
+
# Owns surface class, native feed hints, segment stats, and a cheap AutoSource
|
|
8
|
+
# article count — not request strategy selection or MCP next-step policy.
|
|
9
|
+
class PageRecon # rubocop:disable Metrics/ClassLength -- recon bag stays co-located
|
|
10
|
+
##
|
|
11
|
+
# Cheap surface + admission facts shared by AutoFallback gates and FeedResolution probes.
|
|
12
|
+
Assessment = Data.define(:surface_category, :articles_count, :admission_drops, :html_response) do
|
|
13
|
+
##
|
|
14
|
+
# @return [Html2rss::SurfaceCategory]
|
|
15
|
+
def category = SurfaceCategory.coerce(surface_category)
|
|
16
|
+
|
|
17
|
+
##
|
|
18
|
+
# @return [Boolean]
|
|
19
|
+
def weak? = category.weak?
|
|
20
|
+
|
|
21
|
+
##
|
|
22
|
+
# @return [Boolean]
|
|
23
|
+
def blocked? = category.blocked?
|
|
24
|
+
|
|
25
|
+
##
|
|
26
|
+
# @return [Boolean]
|
|
27
|
+
def listing_bonus? = category.listing_bonus?
|
|
28
|
+
end
|
|
29
|
+
##
|
|
30
|
+
# Recon facts used by Inspect and FeedResolution.
|
|
31
|
+
Result = Data.define(
|
|
32
|
+
:requested_url,
|
|
33
|
+
:final_url,
|
|
34
|
+
:status,
|
|
35
|
+
:scheme_downgrade,
|
|
36
|
+
:alternate_feeds,
|
|
37
|
+
:surface_category,
|
|
38
|
+
:articles_count,
|
|
39
|
+
:admission_drops,
|
|
40
|
+
:segment_stats,
|
|
41
|
+
:html_response,
|
|
42
|
+
:content_type,
|
|
43
|
+
:blocked_surface,
|
|
44
|
+
:sst
|
|
45
|
+
) do
|
|
46
|
+
##
|
|
47
|
+
# @return [Hash{Symbol => Object}]
|
|
48
|
+
def to_h # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- omit-empty optional keys
|
|
49
|
+
{
|
|
50
|
+
requested_url:,
|
|
51
|
+
final_url:,
|
|
52
|
+
status:,
|
|
53
|
+
scheme_downgrade:,
|
|
54
|
+
alternate_feeds:,
|
|
55
|
+
surface_category:,
|
|
56
|
+
articles_count:,
|
|
57
|
+
html_response:,
|
|
58
|
+
content_type:,
|
|
59
|
+
**(admission_drops.any? ? { admission_drops: } : {}),
|
|
60
|
+
**(segment_stats ? { segment_stats: } : {}),
|
|
61
|
+
**(blocked_surface ? { blocked_surface: } : {}),
|
|
62
|
+
**(sst ? { sst: } : {})
|
|
63
|
+
}
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
##
|
|
68
|
+
# @param response [Html2rss::RequestService::Response]
|
|
69
|
+
# @param url [String, Html2rss::Url] requested entry URL
|
|
70
|
+
# @param strategy [Symbol, nil] unused (reserved for callers that already chose a strategy)
|
|
71
|
+
# @return [Result]
|
|
72
|
+
def self.call(response:, url:, strategy: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
73
|
+
new(response:, url:).call
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
##
|
|
77
|
+
# Cheap page assessment for policy gates and probe scoring (fixed AutoSource limit).
|
|
78
|
+
#
|
|
79
|
+
# @param response [Html2rss::RequestService::Response]
|
|
80
|
+
# @param url [String, Html2rss::Url]
|
|
81
|
+
# @return [Assessment]
|
|
82
|
+
def self.assess(response:, url:)
|
|
83
|
+
new(response:, url:).assess
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
##
|
|
87
|
+
# Surface class only — no AutoSource extract (for empty-extract error labels).
|
|
88
|
+
#
|
|
89
|
+
# @param response [Html2rss::RequestService::Response]
|
|
90
|
+
# @param url [String, Html2rss::Url]
|
|
91
|
+
# @return [Symbol]
|
|
92
|
+
def self.surface_category_for(response:, url:)
|
|
93
|
+
new(response:, url:).surface_category_for
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
##
|
|
97
|
+
# @param sst [Html2rss::SST::Document]
|
|
98
|
+
# @param url [String, Html2rss::Url]
|
|
99
|
+
# @return [Array]
|
|
100
|
+
def self.discover_segments(sst, url)
|
|
101
|
+
link_resolver = Scoring::LinkResolver.new(url)
|
|
102
|
+
AutoSource::Segmenter.call(sst, base_url: url, strategy: :list, link_resolver:)
|
|
103
|
+
rescue StandardError
|
|
104
|
+
[]
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
##
|
|
108
|
+
# @param response [Html2rss::RequestService::Response]
|
|
109
|
+
# @param url [String, Html2rss::Url]
|
|
110
|
+
def initialize(response:, url:)
|
|
111
|
+
@response = response
|
|
112
|
+
@url = url
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
##
|
|
116
|
+
# @return [Assessment]
|
|
117
|
+
def assess
|
|
118
|
+
return feed_assessment if response.feed_response?
|
|
119
|
+
|
|
120
|
+
parsed = html_parsed_body
|
|
121
|
+
articles_count, admission_drops = cheap_articles
|
|
122
|
+
Assessment.new(
|
|
123
|
+
surface_category: surface_category(parsed),
|
|
124
|
+
articles_count:,
|
|
125
|
+
admission_drops:,
|
|
126
|
+
html_response: true
|
|
127
|
+
)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
##
|
|
131
|
+
# @return [Symbol]
|
|
132
|
+
def surface_category_for
|
|
133
|
+
return :unsupported_surface if response.feed_response?
|
|
134
|
+
|
|
135
|
+
surface_category(html_parsed_body)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
##
|
|
139
|
+
# @return [Result]
|
|
140
|
+
def call # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- assemble recon Result
|
|
141
|
+
requested = Url.from_absolute(url)
|
|
142
|
+
final = response.url
|
|
143
|
+
parsed = html_parsed_body
|
|
144
|
+
assessment = assess
|
|
145
|
+
sst_payload, segment_stats = sst_payload_and_segments(requested)
|
|
146
|
+
|
|
147
|
+
Result.new(
|
|
148
|
+
requested_url: requested.to_s,
|
|
149
|
+
final_url: final.to_s,
|
|
150
|
+
status: response.status,
|
|
151
|
+
scheme_downgrade: scheme_downgrade?(requested, final),
|
|
152
|
+
alternate_feeds: alternate_feeds_from(parsed),
|
|
153
|
+
surface_category: assessment.surface_category,
|
|
154
|
+
articles_count: assessment.articles_count,
|
|
155
|
+
admission_drops: assessment.admission_drops,
|
|
156
|
+
segment_stats:,
|
|
157
|
+
html_response: response.html_response?,
|
|
158
|
+
content_type: response.content_type,
|
|
159
|
+
blocked_surface: blocked_surface_key,
|
|
160
|
+
sst: sst_payload
|
|
161
|
+
)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
private
|
|
165
|
+
|
|
166
|
+
attr_reader :response, :url
|
|
167
|
+
|
|
168
|
+
def feed_assessment
|
|
169
|
+
Assessment.new(
|
|
170
|
+
surface_category: :unsupported_surface,
|
|
171
|
+
articles_count: 0,
|
|
172
|
+
admission_drops: {},
|
|
173
|
+
html_response: false
|
|
174
|
+
)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def html_parsed_body
|
|
178
|
+
return unless response.html_response?
|
|
179
|
+
|
|
180
|
+
response.parsed_body
|
|
181
|
+
rescue RequestService::UnsupportedResponseContentType
|
|
182
|
+
nil
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def surface_category(parsed)
|
|
186
|
+
return :unsupported_surface unless parsed
|
|
187
|
+
|
|
188
|
+
AutoSource::Scraper.classify_no_scraper_surface(parsed, body: response.body)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def cheap_articles
|
|
192
|
+
return [0, {}] unless response.html_response?
|
|
193
|
+
|
|
194
|
+
source = AutoSource.new(response, AutoSource::DEFAULT_CONFIG.merge(limit: 10))
|
|
195
|
+
[source.articles.size, source.admission_drops]
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def sst_payload_and_segments(requested)
|
|
199
|
+
return [nil, nil] unless response.html_response?
|
|
200
|
+
|
|
201
|
+
sst = sst_document
|
|
202
|
+
return [nil, nil] unless sst
|
|
203
|
+
|
|
204
|
+
stats = segment_stats(sst, requested)
|
|
205
|
+
[
|
|
206
|
+
{ node_count: sst.node_count, degraded: sst.degraded, segment_stats: stats },
|
|
207
|
+
stats
|
|
208
|
+
]
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def sst_document
|
|
212
|
+
Html2rss::SST::Normalizer.call(response.body)
|
|
213
|
+
rescue ArgumentError
|
|
214
|
+
nil
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def segment_stats(sst, page_url)
|
|
218
|
+
segments = self.class.discover_segments(sst, page_url)
|
|
219
|
+
return { found: 0 } if segments.empty?
|
|
220
|
+
|
|
221
|
+
{
|
|
222
|
+
found: segments.size,
|
|
223
|
+
strategies: segments.map(&:strategy).uniq,
|
|
224
|
+
sample_paths: segments.first(5).map { |s| s.root_node.tag_path }
|
|
225
|
+
}
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def scheme_downgrade?(requested, final)
|
|
229
|
+
requested.scheme == 'https' && final.scheme == 'http'
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def alternate_feeds_from(parsed)
|
|
233
|
+
return [] unless parsed.is_a?(Nokogiri::HTML::Document)
|
|
234
|
+
|
|
235
|
+
Html::FeedLink.from_document(parsed).map { |link| { href: link.href, mime_type: link.mime_type } }
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def blocked_surface_key
|
|
239
|
+
blocked = RequestService::BlockedSurface.interstitial_signature_for(response.body)
|
|
240
|
+
blocked[:key].to_s if blocked
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
end
|
|
@@ -43,7 +43,7 @@ module Html2rss
|
|
|
43
43
|
MAX_RETRIES = 3
|
|
44
44
|
|
|
45
45
|
# Allowlisted ScrapeDiagnostics keys nested under diagnostics.
|
|
46
|
-
DIAGNOSTICS_KEYS = %w[request_id attempts strategy_used render_ms execution_tier challenge].freeze
|
|
46
|
+
DIAGNOSTICS_KEYS = %w[request_id attempts strategy_used render_ms execution_tier challenge timeout_phase].freeze
|
|
47
47
|
# Allowlisted ChallengeSignal keys nested under diagnostics.challenge.
|
|
48
48
|
CHALLENGE_KEYS = %w[blocked detected marker].freeze
|
|
49
49
|
|
|
@@ -185,10 +185,17 @@ module Html2rss
|
|
|
185
185
|
'Botasaurus challenge block detected.'
|
|
186
186
|
end
|
|
187
187
|
|
|
188
|
+
# @return [String, nil] scrape-api timeout stage when present on diagnostics
|
|
189
|
+
def timeout_phase
|
|
190
|
+
value = diagnostics['timeout_phase']
|
|
191
|
+
value.is_a?(String) && !value.empty? ? value : nil
|
|
192
|
+
end
|
|
193
|
+
|
|
188
194
|
# @return [String] actionable upstream failure summary
|
|
189
195
|
def failure_message
|
|
190
196
|
details = ["status=#{transport_status}", "error_category=#{error_category}", "error=#{error}"]
|
|
191
197
|
details << "request_id=#{request_id}" if request_id
|
|
198
|
+
details << "timeout_phase=#{timeout_phase}" if timeout_phase
|
|
192
199
|
"Botasaurus scrape failed (#{details.join(', ')})."
|
|
193
200
|
end
|
|
194
201
|
|
|
@@ -59,7 +59,7 @@ module Html2rss
|
|
|
59
59
|
return unless error.timeout?
|
|
60
60
|
|
|
61
61
|
log_timeout!(reason: 'botasaurus_upstream')
|
|
62
|
-
raise RequestTimedOut, error.
|
|
62
|
+
raise RequestTimedOut.new(error.failure_message, timeout_phase: error.timeout_phase)
|
|
63
63
|
end
|
|
64
64
|
|
|
65
65
|
def response_url(final_url)
|
|
@@ -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
|
|
@@ -74,7 +78,14 @@ module Html2rss
|
|
|
74
78
|
|
|
75
79
|
# @return [Boolean] whether response content is HTML (header or sniffed body, never JSON)
|
|
76
80
|
def html_response?
|
|
77
|
-
content_type.include?('text/html') || (!json_response? && html_looking_body?)
|
|
81
|
+
content_type.include?('text/html') || (!json_response? && !feed_response? && html_looking_body?)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# @return [Boolean] whether response content is RSS/Atom (header or sniffed body)
|
|
85
|
+
def feed_response?
|
|
86
|
+
return @feed_response if defined?(@feed_response)
|
|
87
|
+
|
|
88
|
+
@feed_response = feed_content_type? || (!json_response? && !html_looking_body? && feed_looking_body?)
|
|
78
89
|
end
|
|
79
90
|
|
|
80
91
|
##
|
|
@@ -143,6 +154,19 @@ module Html2rss
|
|
|
143
154
|
def html_looking_body?
|
|
144
155
|
body.to_s.b.match?(HTML_BODY_SNIFF)
|
|
145
156
|
end
|
|
157
|
+
|
|
158
|
+
def feed_content_type?
|
|
159
|
+
ct = content_type.downcase
|
|
160
|
+
return false if ct.empty? || ct.include?('html') || ct.include?('json')
|
|
161
|
+
|
|
162
|
+
# Substring match against Content-Type (not Array#intersect? on a String).
|
|
163
|
+
FEED_CT_MARKERS.any? { |marker| ct.include?(marker) } # rubocop:disable Style/ArrayIntersect
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def feed_looking_body?
|
|
167
|
+
snippet = body.to_s[0, 800].downcase
|
|
168
|
+
FEED_BODY_MARKERS.any? { |marker| snippet.include?(marker) } # rubocop:disable Style/ArrayIntersect
|
|
169
|
+
end
|
|
146
170
|
end
|
|
147
171
|
end
|
|
148
172
|
end
|
|
@@ -31,8 +31,20 @@ module Html2rss
|
|
|
31
31
|
class ResponseTooLarge < Html2rss::Error; end
|
|
32
32
|
# Raised when blocked content surfaces are detected.
|
|
33
33
|
class BlockedSurfaceDetected < Html2rss::Error; end
|
|
34
|
+
|
|
34
35
|
# Raised when a request times out.
|
|
35
|
-
class RequestTimedOut < Html2rss::Error
|
|
36
|
+
class RequestTimedOut < Html2rss::Error
|
|
37
|
+
##
|
|
38
|
+
# @param message [String, nil] timeout failure summary
|
|
39
|
+
# @param timeout_phase [String, nil] scrape-api stage when known (+queue+/+boot+/+work+)
|
|
40
|
+
def initialize(message = nil, timeout_phase: nil)
|
|
41
|
+
@timeout_phase = timeout_phase
|
|
42
|
+
super(message)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# @return [String, nil] scrape-api timeout stage, or nil for transport/budget timeouts
|
|
46
|
+
attr_reader :timeout_phase
|
|
47
|
+
end
|
|
36
48
|
|
|
37
49
|
# Raised when Botasaurus configuration is missing or invalid.
|
|
38
50
|
class BotasaurusConfigurationError < Html2rss::Error
|
|
@@ -15,14 +15,17 @@ 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 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
|
|
28
|
+
# rubocop:enable Metrics/ParameterLists
|
|
26
29
|
end
|
|
27
30
|
|
|
28
31
|
##
|
|
@@ -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
|
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 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,13 @@ 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
|
|
47
|
+
# rubocop:enable Metrics/ParameterLists, Metrics/MethodLength
|
|
40
48
|
|
|
41
49
|
##
|
|
42
50
|
# @param klass [Class, #to_s] scraper class
|
|
@@ -54,10 +62,13 @@ module Html2rss
|
|
|
54
62
|
# @param attempt_count [Integer]
|
|
55
63
|
# @param strategy_attempts [Array<Hash>]
|
|
56
64
|
# @param admission_drops [Hash{String => Integer}]
|
|
65
|
+
# @param entry_url [String, nil]
|
|
66
|
+
# @param scrape_url [String, nil]
|
|
67
|
+
# @param entry_resolution [Hash, nil]
|
|
57
68
|
# rubocop:disable Metrics/ParameterLists, Metrics/MethodLength -- Status Data.define members
|
|
58
69
|
def initialize(
|
|
59
70
|
version:, scraper_tallies:, dedup_dropped:, selected_strategy: nil, attempt_count: 0,
|
|
60
|
-
strategy_attempts: [], admission_drops: {}
|
|
71
|
+
strategy_attempts: [], admission_drops: {}, entry_url: nil, scrape_url: nil, entry_resolution: nil
|
|
61
72
|
)
|
|
62
73
|
dedup = Integer(dedup_dropped)
|
|
63
74
|
attempts = Integer(attempt_count)
|
|
@@ -70,21 +81,19 @@ module Html2rss
|
|
|
70
81
|
selected_strategy:,
|
|
71
82
|
attempt_count: attempts,
|
|
72
83
|
strategy_attempts: freeze_attempts(strategy_attempts),
|
|
73
|
-
admission_drops: freeze_tallies(admission_drops)
|
|
84
|
+
admission_drops: freeze_tallies(admission_drops),
|
|
85
|
+
entry_url: freeze_optional_string(entry_url),
|
|
86
|
+
scrape_url: freeze_optional_string(scrape_url),
|
|
87
|
+
entry_resolution: freeze_entry_resolution(entry_resolution)
|
|
74
88
|
)
|
|
75
89
|
end
|
|
76
90
|
# rubocop:enable Metrics/ParameterLists, Metrics/MethodLength
|
|
77
91
|
|
|
78
92
|
##
|
|
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.
|
|
93
|
+
# Observability hash for web (+scraper_status+). Omits empty/absent optional keys.
|
|
83
94
|
#
|
|
84
|
-
# @return [Hash{Symbol => Object}]
|
|
85
|
-
#
|
|
86
|
-
# +:strategy_attempts+, +:admission_drops+
|
|
87
|
-
# rubocop:disable Metrics/AbcSize -- omit-empty optional keys stay explicit
|
|
95
|
+
# @return [Hash{Symbol => Object}]
|
|
96
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity -- omit-empty optional keys stay explicit
|
|
88
97
|
def to_h
|
|
89
98
|
{
|
|
90
99
|
version:,
|
|
@@ -93,10 +102,13 @@ module Html2rss
|
|
|
93
102
|
**(selected_strategy.nil? ? {} : { selected_strategy: }),
|
|
94
103
|
**(attempt_count.positive? ? { attempt_count: } : {}),
|
|
95
104
|
**(strategy_attempts.any? ? { strategy_attempts: } : {}),
|
|
96
|
-
**(admission_drops.any? ? { admission_drops: } : {})
|
|
105
|
+
**(admission_drops.any? ? { admission_drops: } : {}),
|
|
106
|
+
**(entry_url ? { entry_url: } : {}),
|
|
107
|
+
**(scrape_url ? { scrape_url: } : {}),
|
|
108
|
+
**(entry_resolution ? { entry_resolution: } : {})
|
|
97
109
|
}
|
|
98
110
|
end
|
|
99
|
-
# rubocop:enable Metrics/AbcSize
|
|
111
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
100
112
|
|
|
101
113
|
##
|
|
102
114
|
# Formats the RSS +generator+ string and JSON Feed +user_comment+.
|
|
@@ -115,13 +127,15 @@ module Html2rss
|
|
|
115
127
|
|
|
116
128
|
def marshal_dump
|
|
117
129
|
[version, scraper_tallies, dedup_dropped, selected_strategy, attempt_count, strategy_attempts,
|
|
118
|
-
admission_drops]
|
|
130
|
+
admission_drops, entry_url, scrape_url, entry_resolution]
|
|
119
131
|
end
|
|
120
132
|
|
|
121
|
-
def marshal_load(
|
|
122
|
-
|
|
133
|
+
def marshal_load(payload)
|
|
134
|
+
version, scraper_tallies, dedup_dropped, selected_strategy, attempt_count,
|
|
135
|
+
strategy_attempts, admission_drops, entry_url, scrape_url, entry_resolution = payload
|
|
123
136
|
initialize(version:, scraper_tallies:, dedup_dropped:, selected_strategy:, attempt_count:,
|
|
124
|
-
strategy_attempts:, admission_drops: admission_drops || {}
|
|
137
|
+
strategy_attempts:, admission_drops: admission_drops || {}, entry_url:, scrape_url:,
|
|
138
|
+
entry_resolution:)
|
|
125
139
|
end
|
|
126
140
|
|
|
127
141
|
def freeze_tallies(tallies)
|
|
@@ -132,6 +146,19 @@ module Html2rss
|
|
|
132
146
|
Array(attempts).map { |attempt| attempt.to_h.freeze }.freeze
|
|
133
147
|
end
|
|
134
148
|
|
|
149
|
+
def freeze_optional_string(value)
|
|
150
|
+
return if value.nil?
|
|
151
|
+
|
|
152
|
+
value.to_s.dup.freeze
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def freeze_entry_resolution(value)
|
|
156
|
+
return if value.nil?
|
|
157
|
+
|
|
158
|
+
hash = value.respond_to?(:to_h) ? value.to_h : value
|
|
159
|
+
hash.to_h.transform_keys(&:to_sym).freeze
|
|
160
|
+
end
|
|
161
|
+
|
|
135
162
|
def validate_counters!(dedup:, attempts:, selected_strategy:)
|
|
136
163
|
raise ArgumentError, 'dedup_dropped must be >= 0' if dedup.negative?
|
|
137
164
|
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
|