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
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
##
|
|
5
|
+
# Service that runs schema validation and live feed extraction on a configuration,
|
|
6
|
+
# enforcing minimum item thresholds and capturing sample output.
|
|
7
|
+
module Test # rubocop:disable Metrics/ModuleLength -- Result + FailureKind nest with owner
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
FailureKind = Data.define(:name)
|
|
11
|
+
|
|
12
|
+
##
|
|
13
|
+
# Closed failure classification for a failed test run.
|
|
14
|
+
class FailureKind
|
|
15
|
+
# Closed set of test failure wire names.
|
|
16
|
+
NAMES = Set[:schema, :execution, :min_items, :quality].freeze
|
|
17
|
+
|
|
18
|
+
class << self
|
|
19
|
+
##
|
|
20
|
+
# @param value [FailureKind, Symbol, String]
|
|
21
|
+
# @return [FailureKind]
|
|
22
|
+
def coerce(value)
|
|
23
|
+
return value if value.is_a?(self)
|
|
24
|
+
|
|
25
|
+
new(name: value.to_sym)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
##
|
|
30
|
+
# @param name [Symbol]
|
|
31
|
+
def initialize(name:)
|
|
32
|
+
raise ArgumentError, "unknown failure kind: #{name.inspect}" unless NAMES.include?(name)
|
|
33
|
+
|
|
34
|
+
super
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
##
|
|
38
|
+
# @return [Boolean]
|
|
39
|
+
def schema? = name == :schema
|
|
40
|
+
|
|
41
|
+
##
|
|
42
|
+
# @return [Boolean]
|
|
43
|
+
def execution? = name == :execution
|
|
44
|
+
|
|
45
|
+
##
|
|
46
|
+
# @return [Boolean]
|
|
47
|
+
def min_items? = name == :min_items
|
|
48
|
+
|
|
49
|
+
##
|
|
50
|
+
# @return [Boolean]
|
|
51
|
+
def quality? = name == :quality
|
|
52
|
+
|
|
53
|
+
##
|
|
54
|
+
# @return [Symbol]
|
|
55
|
+
def to_sym = name
|
|
56
|
+
|
|
57
|
+
##
|
|
58
|
+
# @return [String]
|
|
59
|
+
def to_s = name.to_s
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
##
|
|
63
|
+
# Ship-quality audit summary for a configuration test (warn-only).
|
|
64
|
+
QualityReport = Data.define(:warnings, :metrics, :native_feed, :defer_reason) do
|
|
65
|
+
##
|
|
66
|
+
# @return [Hash{Symbol => Object}]
|
|
67
|
+
def to_h
|
|
68
|
+
{
|
|
69
|
+
warnings: warnings.map(&:to_s),
|
|
70
|
+
metrics:,
|
|
71
|
+
**(native_feed ? { native_feed:, defer_reason: defer_reason.to_s } : {})
|
|
72
|
+
}.compact
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
##
|
|
76
|
+
# @param audit [Html2rss::AutoSource::Cleanup::AuditResult]
|
|
77
|
+
# @param native_feed [String, nil]
|
|
78
|
+
# @return [QualityReport]
|
|
79
|
+
def self.from_audit(audit, native_feed: nil)
|
|
80
|
+
report_warnings = audit.warnings.dup
|
|
81
|
+
defer_reason = nil
|
|
82
|
+
if native_feed
|
|
83
|
+
report_warnings << :native_feed_present unless report_warnings.include?(:native_feed_present)
|
|
84
|
+
defer_reason = :native_feed
|
|
85
|
+
end
|
|
86
|
+
new(warnings: report_warnings.freeze, metrics: audit.metrics, native_feed:, defer_reason:)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
##
|
|
91
|
+
# Immutable outcome of a configuration test. Success carries +rss+ XML from the
|
|
92
|
+
# first live extraction; failures carry a typed {FailureKind}.
|
|
93
|
+
Result = Data.define(
|
|
94
|
+
:success,
|
|
95
|
+
:item_count,
|
|
96
|
+
:sample_items,
|
|
97
|
+
:channel_title,
|
|
98
|
+
:channel_url,
|
|
99
|
+
:strategy_used,
|
|
100
|
+
:duration_seconds,
|
|
101
|
+
:validation_errors,
|
|
102
|
+
:error_message,
|
|
103
|
+
:failure_kind,
|
|
104
|
+
:rss,
|
|
105
|
+
:quality_report,
|
|
106
|
+
:enhance_compare
|
|
107
|
+
) do
|
|
108
|
+
##
|
|
109
|
+
# @param success [Boolean]
|
|
110
|
+
# @param item_count [Integer]
|
|
111
|
+
# @param sample_items [Array<Hash>]
|
|
112
|
+
# @param channel_title [String, nil]
|
|
113
|
+
# @param channel_url [String, nil]
|
|
114
|
+
# @param strategy_used [Symbol, nil]
|
|
115
|
+
# @param duration_seconds [Float]
|
|
116
|
+
# @param validation_errors [Hash, nil]
|
|
117
|
+
# @param error_message [String, nil]
|
|
118
|
+
# @param failure_kind [FailureKind, nil]
|
|
119
|
+
# @param rss [String, nil]
|
|
120
|
+
# @param quality_report [QualityReport, nil]
|
|
121
|
+
# @param enhance_compare [Hash, nil]
|
|
122
|
+
def initialize(success:, item_count:, sample_items:, channel_title:, channel_url:, # rubocop:disable Metrics/ParameterLists
|
|
123
|
+
strategy_used:, duration_seconds:, validation_errors:, error_message:,
|
|
124
|
+
failure_kind:, rss:, quality_report: nil, enhance_compare: nil)
|
|
125
|
+
super
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
##
|
|
129
|
+
# @return [Boolean] whether the schema validation succeeded
|
|
130
|
+
def valid_schema?
|
|
131
|
+
validation_errors.nil? || validation_errors.empty?
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
##
|
|
135
|
+
# @return [Boolean] whether the test extracted zero items
|
|
136
|
+
def empty_feed?
|
|
137
|
+
item_count.zero?
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
##
|
|
141
|
+
# @return [Hash{Symbol => Object}] hash representation
|
|
142
|
+
def to_h # rubocop:disable Metrics/MethodLength
|
|
143
|
+
{
|
|
144
|
+
success:,
|
|
145
|
+
item_count:,
|
|
146
|
+
sample_items:,
|
|
147
|
+
channel_title:,
|
|
148
|
+
channel_url:,
|
|
149
|
+
strategy_used:,
|
|
150
|
+
duration_seconds:,
|
|
151
|
+
validation_errors:,
|
|
152
|
+
error_message:,
|
|
153
|
+
failure_kind: failure_kind&.to_sym,
|
|
154
|
+
rss:,
|
|
155
|
+
quality_report: quality_report&.to_h,
|
|
156
|
+
enhance_compare:
|
|
157
|
+
}.compact
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
##
|
|
162
|
+
# Tests a configuration by validating schema and executing live feed extraction.
|
|
163
|
+
#
|
|
164
|
+
# @param config_input [Hash, String] config hash, YAML string, or file path
|
|
165
|
+
# @param feed_name [String, nil] optional feed name for multi-feed files
|
|
166
|
+
# @param min_items [Integer] minimum extracted items required to pass
|
|
167
|
+
# @param params [Hash] optional dynamic feed parameters
|
|
168
|
+
# @param strategy [Symbol, nil] optional strategy override
|
|
169
|
+
# @param strict_quality [Boolean] when true, fail on ship-quality audit thresholds
|
|
170
|
+
# @param compare_enhance [Boolean] diagnostic enhance off vs on comparison on cached HTML
|
|
171
|
+
# @return [Html2rss::Test::Result]
|
|
172
|
+
def call(config_input, feed_name = nil, min_items: 1, params: {}, strategy: nil, # rubocop:disable Metrics/ParameterLists, Metrics/MethodLength, Metrics/AbcSize, Metrics/PerceivedComplexity, Metrics/CyclomaticComplexity
|
|
173
|
+
strict_quality: false, compare_enhance: false)
|
|
174
|
+
raw_config, validation = Config.resolve_and_validate(config_input, feed_name:, params:)
|
|
175
|
+
return validation_failure_result(validation.errors.to_h, raw_config) unless validation.success?
|
|
176
|
+
|
|
177
|
+
raw_config[:strategy] = strategy.to_sym if strategy
|
|
178
|
+
raw_config[:params] = params if params&.any?
|
|
179
|
+
|
|
180
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
181
|
+
begin
|
|
182
|
+
feed_result, pipeline_outcome = extract_feed(raw_config)
|
|
183
|
+
duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
|
|
184
|
+
|
|
185
|
+
rss_doc = feed_result.to_rss
|
|
186
|
+
rss_xml = rss_doc.to_s
|
|
187
|
+
item_count = rss_doc.items.size
|
|
188
|
+
sample_items = extract_samples(rss_doc.items)
|
|
189
|
+
quality_report = quality_report_for(
|
|
190
|
+
rss_doc.items,
|
|
191
|
+
channel_url: raw_config.dig(:channel, :url).to_s,
|
|
192
|
+
raw_config:,
|
|
193
|
+
feed_result:,
|
|
194
|
+
pipeline_outcome:
|
|
195
|
+
)
|
|
196
|
+
enhance_compare = build_enhance_compare(raw_config, pipeline_outcome) if compare_enhance
|
|
197
|
+
|
|
198
|
+
channel_title = feed_result.channel_title
|
|
199
|
+
channel_url = raw_config.dig(:channel, :url).to_s
|
|
200
|
+
strategy_used = feed_result.status.selected_strategy || raw_config[:strategy] || :faraday
|
|
201
|
+
min_items_passed = item_count >= min_items
|
|
202
|
+
quality_failed = strict_quality && min_items_passed && quality_failure?(quality_report)
|
|
203
|
+
passed = min_items_passed && !quality_failed
|
|
204
|
+
failure_kind, error_message = outcome_failure(min_items_passed:, quality_failed:, item_count:, min_items:,
|
|
205
|
+
quality_report:)
|
|
206
|
+
|
|
207
|
+
log_strict_quality_failure(failure_kind, item_count) if quality_failed
|
|
208
|
+
|
|
209
|
+
Result.new(
|
|
210
|
+
success: passed,
|
|
211
|
+
item_count:,
|
|
212
|
+
sample_items:,
|
|
213
|
+
channel_title:,
|
|
214
|
+
channel_url:,
|
|
215
|
+
strategy_used:,
|
|
216
|
+
duration_seconds: duration.round(3),
|
|
217
|
+
validation_errors: nil,
|
|
218
|
+
error_message:,
|
|
219
|
+
failure_kind:,
|
|
220
|
+
rss: passed ? rss_xml : nil,
|
|
221
|
+
quality_report:,
|
|
222
|
+
enhance_compare:
|
|
223
|
+
)
|
|
224
|
+
rescue StandardError => error
|
|
225
|
+
duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
|
|
226
|
+
execution_failure_result(error, raw_config, duration)
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def extract_samples(items, limit: 3)
|
|
231
|
+
items.first(limit).map do |item|
|
|
232
|
+
{
|
|
233
|
+
title: item.title.to_s.strip,
|
|
234
|
+
url: (item.respond_to?(:link) ? item.link : item.url).to_s,
|
|
235
|
+
published_at: (item.respond_to?(:pubDate) ? item.pubDate : item.published_at)
|
|
236
|
+
}
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
private_class_method :extract_samples
|
|
240
|
+
|
|
241
|
+
##
|
|
242
|
+
# Builds a ship-quality report for RSS items (shared by test and MCP apply).
|
|
243
|
+
#
|
|
244
|
+
# @param items [Array]
|
|
245
|
+
# @param channel_url [String]
|
|
246
|
+
# @param raw_config [Hash]
|
|
247
|
+
# @param feed_result [Html2rss::FeedResult]
|
|
248
|
+
# @param pipeline_outcome [Html2rss::FeedPipeline::PipelineOutcome]
|
|
249
|
+
# @param probe_native_feed [Boolean] when false, skip syndication discovery (apply ship gate)
|
|
250
|
+
# @return [QualityReport]
|
|
251
|
+
def quality_report_for(items, channel_url:, raw_config:, feed_result:, pipeline_outcome:, # rubocop:disable Metrics/ParameterLists
|
|
252
|
+
probe_native_feed: true)
|
|
253
|
+
audit = AutoSource::Cleanup.audit_feed_items(items)
|
|
254
|
+
native_feed = probe_native_feed ? probe_native_feed_url(channel_url, raw_config)&.to_s : nil
|
|
255
|
+
report = QualityReport.from_audit(audit, native_feed:)
|
|
256
|
+
report = append_url_mismatch_warning(report, channel_url, feed_result)
|
|
257
|
+
merge_enhance_audit(report, raw_config, pipeline_outcome)
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def extract_feed(raw_config)
|
|
261
|
+
outcome, feed_result = FeedPipeline.new(raw_config).to_outcome_and_result
|
|
262
|
+
[feed_result, outcome]
|
|
263
|
+
end
|
|
264
|
+
private_class_method :extract_feed
|
|
265
|
+
|
|
266
|
+
def enhance_enabled?(raw_config)
|
|
267
|
+
config = Config.from_hash(raw_config)
|
|
268
|
+
return false unless config.selectors
|
|
269
|
+
|
|
270
|
+
!!config.selectors.dig(:items, :enhance)
|
|
271
|
+
end
|
|
272
|
+
private_class_method :enhance_enabled?
|
|
273
|
+
|
|
274
|
+
def build_enhance_compare(raw_config, pipeline_outcome)
|
|
275
|
+
return nil unless pipeline_outcome
|
|
276
|
+
|
|
277
|
+
config = Config.from_hash(raw_config)
|
|
278
|
+
return nil unless config.selectors
|
|
279
|
+
|
|
280
|
+
EnhanceAudit.compare(
|
|
281
|
+
response: pipeline_outcome.response,
|
|
282
|
+
selectors: config.selectors,
|
|
283
|
+
time_zone: config.time_zone
|
|
284
|
+
)
|
|
285
|
+
end
|
|
286
|
+
private_class_method :build_enhance_compare
|
|
287
|
+
|
|
288
|
+
def merge_enhance_audit(report, raw_config, pipeline_outcome) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- warning merge + metrics
|
|
289
|
+
return report unless pipeline_outcome && enhance_enabled?(raw_config)
|
|
290
|
+
|
|
291
|
+
config = Config.from_hash(raw_config)
|
|
292
|
+
return report unless config.selectors
|
|
293
|
+
|
|
294
|
+
slice = EnhanceAudit.probe(
|
|
295
|
+
response: pipeline_outcome.response,
|
|
296
|
+
selectors: config.selectors,
|
|
297
|
+
time_zone: config.time_zone
|
|
298
|
+
)
|
|
299
|
+
return report unless slice
|
|
300
|
+
|
|
301
|
+
warnings = report.warnings.dup
|
|
302
|
+
slice.warnings.each { |warning| warnings << warning unless warnings.include?(warning) }
|
|
303
|
+
metrics = report.metrics.merge(enhance_gains: enhance_gains_to_h(slice.enhance_gains))
|
|
304
|
+
QualityReport.new(
|
|
305
|
+
warnings: warnings.freeze,
|
|
306
|
+
metrics:,
|
|
307
|
+
native_feed: report.native_feed,
|
|
308
|
+
defer_reason: report.defer_reason
|
|
309
|
+
)
|
|
310
|
+
end
|
|
311
|
+
private_class_method :merge_enhance_audit
|
|
312
|
+
|
|
313
|
+
def enhance_gains_to_h(enhance_gains)
|
|
314
|
+
{
|
|
315
|
+
items_probed: enhance_gains.items_probed,
|
|
316
|
+
keys_added: enhance_gains.keys_added,
|
|
317
|
+
descriptions_added: enhance_gains.descriptions_added,
|
|
318
|
+
no_op: enhance_gains.no_op
|
|
319
|
+
}
|
|
320
|
+
end
|
|
321
|
+
private_class_method :enhance_gains_to_h
|
|
322
|
+
|
|
323
|
+
def append_url_mismatch_warning(report, channel_url, feed_result)
|
|
324
|
+
return report unless url_mismatch?(channel_url, feed_result)
|
|
325
|
+
|
|
326
|
+
warnings = report.warnings.dup
|
|
327
|
+
warnings << :url_mismatch unless warnings.include?(:url_mismatch)
|
|
328
|
+
QualityReport.new(
|
|
329
|
+
warnings: warnings.freeze,
|
|
330
|
+
metrics: report.metrics.merge(url_mismatch: true),
|
|
331
|
+
native_feed: report.native_feed,
|
|
332
|
+
defer_reason: report.defer_reason
|
|
333
|
+
)
|
|
334
|
+
end
|
|
335
|
+
private_class_method :append_url_mismatch_warning
|
|
336
|
+
|
|
337
|
+
def url_mismatch?(channel_url, feed_result)
|
|
338
|
+
status = feed_result&.status
|
|
339
|
+
return false unless status
|
|
340
|
+
|
|
341
|
+
configured = channel_url.to_s
|
|
342
|
+
final = status.scrape_url.to_s
|
|
343
|
+
final = status.entry_url.to_s if final.empty?
|
|
344
|
+
return false if configured.empty? || final.empty?
|
|
345
|
+
|
|
346
|
+
!Url.from_absolute(configured).same_document?(Url.from_absolute(final))
|
|
347
|
+
rescue ArgumentError
|
|
348
|
+
false
|
|
349
|
+
end
|
|
350
|
+
private_class_method :url_mismatch?
|
|
351
|
+
|
|
352
|
+
def quality_failure?(quality_report)
|
|
353
|
+
metrics = quality_report.metrics
|
|
354
|
+
item_count = metrics[:item_count]
|
|
355
|
+
return false if item_count.zero?
|
|
356
|
+
|
|
357
|
+
duplicate_urls_failure?(metrics) || junk_title_ratio_failure?(metrics) || short_title_failure?(metrics)
|
|
358
|
+
end
|
|
359
|
+
private_class_method :quality_failure?
|
|
360
|
+
|
|
361
|
+
def duplicate_urls_failure?(metrics)
|
|
362
|
+
metrics[:item_count] >= 2 && metrics[:unique_url_count] < 2
|
|
363
|
+
end
|
|
364
|
+
private_class_method :duplicate_urls_failure?
|
|
365
|
+
|
|
366
|
+
def junk_title_ratio_failure?(metrics)
|
|
367
|
+
metrics[:junk_title_count] > (metrics[:item_count] / 2.0)
|
|
368
|
+
end
|
|
369
|
+
private_class_method :junk_title_ratio_failure?
|
|
370
|
+
|
|
371
|
+
def short_title_failure?(metrics)
|
|
372
|
+
metrics[:short_title_count].positive?
|
|
373
|
+
end
|
|
374
|
+
private_class_method :short_title_failure?
|
|
375
|
+
|
|
376
|
+
def outcome_failure(min_items_passed:, quality_failed:, item_count:, min_items:, quality_report:)
|
|
377
|
+
return [nil, nil] if min_items_passed && !quality_failed
|
|
378
|
+
unless min_items_passed
|
|
379
|
+
return [FailureKind.coerce(:min_items),
|
|
380
|
+
"Extracted #{item_count} items (minimum required: #{min_items})"]
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
[FailureKind.coerce(:quality), quality_failure_message(quality_report)]
|
|
384
|
+
end
|
|
385
|
+
private_class_method :outcome_failure
|
|
386
|
+
|
|
387
|
+
def quality_failure_message(quality_report)
|
|
388
|
+
reasons = quality_failure_reasons(quality_report.metrics)
|
|
389
|
+
"Feed quality check failed (#{reasons.join(', ')})"
|
|
390
|
+
end
|
|
391
|
+
private_class_method :quality_failure_message
|
|
392
|
+
|
|
393
|
+
def quality_failure_reasons(metrics)
|
|
394
|
+
reasons = []
|
|
395
|
+
reasons << 'duplicate_urls' if duplicate_urls_failure?(metrics)
|
|
396
|
+
reasons << 'generic_titles' if junk_title_ratio_failure?(metrics)
|
|
397
|
+
reasons << 'short_titles' if short_title_failure?(metrics)
|
|
398
|
+
reasons
|
|
399
|
+
end
|
|
400
|
+
private_class_method :quality_failure_reasons
|
|
401
|
+
|
|
402
|
+
def log_strict_quality_failure(failure_kind, item_count)
|
|
403
|
+
Log.info("Test strict quality: failure_kind=#{failure_kind.to_sym} item_count=#{item_count}")
|
|
404
|
+
end
|
|
405
|
+
private_class_method :log_strict_quality_failure
|
|
406
|
+
|
|
407
|
+
def probe_native_feed_url(channel_url, raw_config)
|
|
408
|
+
return nil if channel_url.to_s.empty?
|
|
409
|
+
|
|
410
|
+
config = Config.from_hash(raw_config)
|
|
411
|
+
resources = FeedPipeline::RuntimePolicy.resources_for(config)
|
|
412
|
+
strategy = FeedPipeline::StrategyPlan.concrete_for_diagnostic(raw_config[:strategy])
|
|
413
|
+
session = RequestSession.build(
|
|
414
|
+
config:, strategy:, budget: resources.budget, policy: resources.policy
|
|
415
|
+
)
|
|
416
|
+
Syndication::Discovery.best_feed_url(page_url: channel_url, request_session: session)
|
|
417
|
+
rescue StandardError
|
|
418
|
+
nil
|
|
419
|
+
end
|
|
420
|
+
private_class_method :probe_native_feed_url
|
|
421
|
+
|
|
422
|
+
def validation_failure_result(errors, raw_config) # rubocop:disable Metrics/MethodLength
|
|
423
|
+
Result.new(
|
|
424
|
+
success: false,
|
|
425
|
+
item_count: 0,
|
|
426
|
+
sample_items: [],
|
|
427
|
+
channel_title: raw_config.dig(:channel, :title),
|
|
428
|
+
channel_url: raw_config.dig(:channel, :url),
|
|
429
|
+
strategy_used: raw_config[:strategy],
|
|
430
|
+
duration_seconds: 0.0,
|
|
431
|
+
validation_errors: errors,
|
|
432
|
+
error_message: 'Configuration schema validation failed',
|
|
433
|
+
failure_kind: FailureKind.coerce(:schema),
|
|
434
|
+
rss: nil,
|
|
435
|
+
quality_report: nil
|
|
436
|
+
)
|
|
437
|
+
end
|
|
438
|
+
private_class_method :validation_failure_result
|
|
439
|
+
|
|
440
|
+
def execution_failure_result(error, raw_config, duration) # rubocop:disable Metrics/MethodLength
|
|
441
|
+
Result.new(
|
|
442
|
+
success: false,
|
|
443
|
+
item_count: 0,
|
|
444
|
+
sample_items: [],
|
|
445
|
+
channel_title: raw_config.dig(:channel, :title),
|
|
446
|
+
channel_url: raw_config.dig(:channel, :url),
|
|
447
|
+
strategy_used: raw_config[:strategy],
|
|
448
|
+
duration_seconds: duration.round(3),
|
|
449
|
+
validation_errors: nil,
|
|
450
|
+
error_message: "#{error.class}: #{error.message}",
|
|
451
|
+
failure_kind: FailureKind.coerce(:execution),
|
|
452
|
+
rss: nil,
|
|
453
|
+
quality_report: nil
|
|
454
|
+
)
|
|
455
|
+
end
|
|
456
|
+
private_class_method :execution_failure_result
|
|
457
|
+
end
|
|
458
|
+
end
|
data/lib/html2rss/url.rb
CHANGED
|
@@ -22,7 +22,7 @@ module Html2rss
|
|
|
22
22
|
# url = Url.from_relative('/foo-bar/baz.txt', 'https://example.com')
|
|
23
23
|
# url.titleized # => "Foo Bar Baz"
|
|
24
24
|
# url.channel_titleized # => "example.com: Foo Bar Baz"
|
|
25
|
-
# rubocop:disable Metrics/ClassLength -- value object owns resolve/sanitize/titleize/identity
|
|
25
|
+
# rubocop:disable-next Metrics/ClassLength -- value object owns resolve/sanitize/titleize/identity
|
|
26
26
|
class Url
|
|
27
27
|
include Comparable
|
|
28
28
|
|
|
@@ -324,5 +324,4 @@ module Html2rss
|
|
|
324
324
|
false
|
|
325
325
|
end
|
|
326
326
|
end
|
|
327
|
-
# rubocop:enable Metrics/ClassLength
|
|
328
327
|
end
|