html2rss 0.28.0 → 0.30.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.
Files changed (85) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +84 -27
  3. data/html2rss.gemspec +1 -4
  4. data/lib/html2rss/article.rb +1 -2
  5. data/lib/html2rss/auto_source/README.md +8 -8
  6. data/lib/html2rss/auto_source/cleanup.rb +92 -1
  7. data/lib/html2rss/auto_source/scraper/json_state/document_scanner.rb +3 -5
  8. data/lib/html2rss/auto_source/scraper/meta_oembed.rb +15 -5
  9. data/lib/html2rss/auto_source/scraper/microdata.rb +8 -6
  10. data/lib/html2rss/auto_source/scraper/native_feed.rb +5 -2
  11. data/lib/html2rss/auto_source/scraper/schema.rb +39 -21
  12. data/lib/html2rss/auto_source/scraper.rb +2 -3
  13. data/lib/html2rss/auto_source/segmenter/cluster.rb +2 -3
  14. data/lib/html2rss/auto_source/segmenter/primary_link.rb +1 -2
  15. data/lib/html2rss/auto_source/segmenter.rb +1 -2
  16. data/lib/html2rss/auto_source.rb +25 -11
  17. data/lib/html2rss/batch.rb +169 -0
  18. data/lib/html2rss/capture/README.md +15 -6
  19. data/lib/html2rss/capture.rb +159 -16
  20. data/lib/html2rss/channel.rb +1 -2
  21. data/lib/html2rss/cli/probe_view.rb +66 -0
  22. data/lib/html2rss/cli/render.rb +124 -0
  23. data/lib/html2rss/cli/validate.rb +91 -0
  24. data/lib/html2rss/cli.rb +318 -193
  25. data/lib/html2rss/config/request_headers.rb +9 -16
  26. data/lib/html2rss/config/schema.rb +7 -5
  27. data/lib/html2rss/config/validator.rb +1 -0
  28. data/lib/html2rss/config.rb +68 -19
  29. data/lib/html2rss/doctor/botasaurus.rb +138 -0
  30. data/lib/html2rss/feed_builder/rss.rb +1 -2
  31. data/lib/html2rss/feed_pipeline/README.md +11 -11
  32. data/lib/html2rss/feed_pipeline/auto_fallback.rb +30 -11
  33. data/lib/html2rss/feed_pipeline/strategy_plan.rb +1 -1
  34. data/lib/html2rss/feed_pipeline.rb +27 -16
  35. data/lib/html2rss/feed_resolution/README.md +11 -11
  36. data/lib/html2rss/feed_resolution.rb +6 -12
  37. data/lib/html2rss/html/article_extractor/heading_extractor.rb +24 -14
  38. data/lib/html2rss/html/article_extractor.rb +4 -9
  39. data/lib/html2rss/html/feed_link.rb +4 -2
  40. data/lib/html2rss/html/navigator.rb +4 -39
  41. data/lib/html2rss/html/probe.rb +82 -0
  42. data/lib/html2rss/html/sst_article_extractor.rb +1 -2
  43. data/lib/html2rss/link_destination/noise_policy.rb +1 -2
  44. data/lib/html2rss/link_destination/path_classifier.rb +20 -19
  45. data/lib/html2rss/mcp/README.md +80 -0
  46. data/lib/html2rss/mcp/contract.rb +144 -18
  47. data/lib/html2rss/mcp/outcome/playbook.rb +122 -0
  48. data/lib/html2rss/mcp/outcome.rb +112 -42
  49. data/lib/html2rss/mcp/runtime.rb +45 -0
  50. data/lib/html2rss/mcp/server/tools.rb +277 -0
  51. data/lib/html2rss/mcp/server.rb +92 -233
  52. data/lib/html2rss/mcp.rb +3 -0
  53. data/lib/html2rss/page_recon/README.md +69 -0
  54. data/lib/html2rss/page_recon/diagnostics.rb +211 -0
  55. data/lib/html2rss/page_recon.rb +56 -2
  56. data/lib/html2rss/recon.rb +252 -0
  57. data/lib/html2rss/request_service/blocked_surface.rb +1 -0
  58. data/lib/html2rss/request_service/botasaurus_strategy.rb +32 -28
  59. data/lib/html2rss/request_service/compressed_body.rb +13 -8
  60. data/lib/html2rss/request_service/httpx_strategy.rb +228 -0
  61. data/lib/html2rss/request_service/policy.rb +1 -2
  62. data/lib/html2rss/request_service/response.rb +3 -4
  63. data/lib/html2rss/request_service/response_guard.rb +0 -16
  64. data/lib/html2rss/request_service/strategy.rb +20 -5
  65. data/lib/html2rss/request_service.rb +48 -85
  66. data/lib/html2rss/request_session.rb +1 -2
  67. data/lib/html2rss/scrape_target.rb +1 -1
  68. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +2 -4
  69. data/lib/html2rss/selectors/post_processors.rb +0 -1
  70. data/lib/html2rss/selectors.rb +33 -10
  71. data/lib/html2rss/sst/attrs.rb +1 -2
  72. data/lib/html2rss/sst/normalizer.rb +49 -11
  73. data/lib/html2rss/status.rb +3 -6
  74. data/lib/html2rss/syndication/README.md +6 -6
  75. data/lib/html2rss/syndication/discovery.rb +2 -4
  76. data/lib/html2rss/test/enhance_audit.rb +192 -0
  77. data/lib/html2rss/test.rb +459 -0
  78. data/lib/html2rss/url.rb +1 -2
  79. data/lib/html2rss/version.rb +1 -1
  80. data/lib/html2rss.rb +157 -35
  81. data/schema/html2rss-config.schema.json +9 -29
  82. metadata +21 -56
  83. data/lib/html2rss/mcp/inspect.rb +0 -138
  84. data/lib/html2rss/request_service/faraday_strategy.rb +0 -229
  85. data/lib/html2rss/selectors/post_processors/html_to_markdown.rb +0 -59
@@ -0,0 +1,459 @@
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] ||
201
+ RequestService.default_strategy_name
202
+ min_items_passed = item_count >= min_items
203
+ quality_failed = strict_quality && min_items_passed && quality_failure?(quality_report)
204
+ passed = min_items_passed && !quality_failed
205
+ failure_kind, error_message = outcome_failure(min_items_passed:, quality_failed:, item_count:, min_items:,
206
+ quality_report:)
207
+
208
+ log_strict_quality_failure(failure_kind, item_count) if quality_failed
209
+
210
+ Result.new(
211
+ success: passed,
212
+ item_count:,
213
+ sample_items:,
214
+ channel_title:,
215
+ channel_url:,
216
+ strategy_used:,
217
+ duration_seconds: duration.round(3),
218
+ validation_errors: nil,
219
+ error_message:,
220
+ failure_kind:,
221
+ rss: passed ? rss_xml : nil,
222
+ quality_report:,
223
+ enhance_compare:
224
+ )
225
+ rescue StandardError => error
226
+ duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
227
+ execution_failure_result(error, raw_config, duration)
228
+ end
229
+ end
230
+
231
+ def extract_samples(items, limit: 3)
232
+ items.first(limit).map do |item|
233
+ {
234
+ title: item.title.to_s.strip,
235
+ url: (item.respond_to?(:link) ? item.link : item.url).to_s,
236
+ published_at: (item.respond_to?(:pubDate) ? item.pubDate : item.published_at)
237
+ }
238
+ end
239
+ end
240
+ private_class_method :extract_samples
241
+
242
+ ##
243
+ # Builds a ship-quality report for RSS items (shared by test and MCP apply).
244
+ #
245
+ # @param items [Array]
246
+ # @param channel_url [String]
247
+ # @param raw_config [Hash]
248
+ # @param feed_result [Html2rss::FeedResult]
249
+ # @param pipeline_outcome [Html2rss::FeedPipeline::PipelineOutcome]
250
+ # @param probe_native_feed [Boolean] when false, skip syndication discovery (apply ship gate)
251
+ # @return [QualityReport]
252
+ def quality_report_for(items, channel_url:, raw_config:, feed_result:, pipeline_outcome:, # rubocop:disable Metrics/ParameterLists
253
+ probe_native_feed: true)
254
+ audit = AutoSource::Cleanup.audit_feed_items(items)
255
+ native_feed = probe_native_feed ? probe_native_feed_url(channel_url, raw_config)&.to_s : nil
256
+ report = QualityReport.from_audit(audit, native_feed:)
257
+ report = append_url_mismatch_warning(report, channel_url, feed_result)
258
+ merge_enhance_audit(report, raw_config, pipeline_outcome)
259
+ end
260
+
261
+ def extract_feed(raw_config)
262
+ outcome, feed_result = FeedPipeline.new(raw_config).to_outcome_and_result
263
+ [feed_result, outcome]
264
+ end
265
+ private_class_method :extract_feed
266
+
267
+ def enhance_enabled?(raw_config)
268
+ config = Config.from_hash(raw_config)
269
+ return false unless config.selectors
270
+
271
+ !!config.selectors.dig(:items, :enhance)
272
+ end
273
+ private_class_method :enhance_enabled?
274
+
275
+ def build_enhance_compare(raw_config, pipeline_outcome)
276
+ return nil unless pipeline_outcome
277
+
278
+ config = Config.from_hash(raw_config)
279
+ return nil unless config.selectors
280
+
281
+ EnhanceAudit.compare(
282
+ response: pipeline_outcome.response,
283
+ selectors: config.selectors,
284
+ time_zone: config.time_zone
285
+ )
286
+ end
287
+ private_class_method :build_enhance_compare
288
+
289
+ def merge_enhance_audit(report, raw_config, pipeline_outcome) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- warning merge + metrics
290
+ return report unless pipeline_outcome && enhance_enabled?(raw_config)
291
+
292
+ config = Config.from_hash(raw_config)
293
+ return report unless config.selectors
294
+
295
+ slice = EnhanceAudit.probe(
296
+ response: pipeline_outcome.response,
297
+ selectors: config.selectors,
298
+ time_zone: config.time_zone
299
+ )
300
+ return report unless slice
301
+
302
+ warnings = report.warnings.dup
303
+ slice.warnings.each { |warning| warnings << warning unless warnings.include?(warning) }
304
+ metrics = report.metrics.merge(enhance_gains: enhance_gains_to_h(slice.enhance_gains))
305
+ QualityReport.new(
306
+ warnings: warnings.freeze,
307
+ metrics:,
308
+ native_feed: report.native_feed,
309
+ defer_reason: report.defer_reason
310
+ )
311
+ end
312
+ private_class_method :merge_enhance_audit
313
+
314
+ def enhance_gains_to_h(enhance_gains)
315
+ {
316
+ items_probed: enhance_gains.items_probed,
317
+ keys_added: enhance_gains.keys_added,
318
+ descriptions_added: enhance_gains.descriptions_added,
319
+ no_op: enhance_gains.no_op
320
+ }
321
+ end
322
+ private_class_method :enhance_gains_to_h
323
+
324
+ def append_url_mismatch_warning(report, channel_url, feed_result)
325
+ return report unless url_mismatch?(channel_url, feed_result)
326
+
327
+ warnings = report.warnings.dup
328
+ warnings << :url_mismatch unless warnings.include?(:url_mismatch)
329
+ QualityReport.new(
330
+ warnings: warnings.freeze,
331
+ metrics: report.metrics.merge(url_mismatch: true),
332
+ native_feed: report.native_feed,
333
+ defer_reason: report.defer_reason
334
+ )
335
+ end
336
+ private_class_method :append_url_mismatch_warning
337
+
338
+ def url_mismatch?(channel_url, feed_result)
339
+ status = feed_result&.status
340
+ return false unless status
341
+
342
+ configured = channel_url.to_s
343
+ final = status.scrape_url.to_s
344
+ final = status.entry_url.to_s if final.empty?
345
+ return false if configured.empty? || final.empty?
346
+
347
+ !Url.from_absolute(configured).same_document?(Url.from_absolute(final))
348
+ rescue ArgumentError
349
+ false
350
+ end
351
+ private_class_method :url_mismatch?
352
+
353
+ def quality_failure?(quality_report)
354
+ metrics = quality_report.metrics
355
+ item_count = metrics[:item_count]
356
+ return false if item_count.zero?
357
+
358
+ duplicate_urls_failure?(metrics) || junk_title_ratio_failure?(metrics) || short_title_failure?(metrics)
359
+ end
360
+ private_class_method :quality_failure?
361
+
362
+ def duplicate_urls_failure?(metrics)
363
+ metrics[:item_count] >= 2 && metrics[:unique_url_count] < 2
364
+ end
365
+ private_class_method :duplicate_urls_failure?
366
+
367
+ def junk_title_ratio_failure?(metrics)
368
+ metrics[:junk_title_count] > (metrics[:item_count] / 2.0)
369
+ end
370
+ private_class_method :junk_title_ratio_failure?
371
+
372
+ def short_title_failure?(metrics)
373
+ metrics[:short_title_count].positive?
374
+ end
375
+ private_class_method :short_title_failure?
376
+
377
+ def outcome_failure(min_items_passed:, quality_failed:, item_count:, min_items:, quality_report:)
378
+ return [nil, nil] if min_items_passed && !quality_failed
379
+ unless min_items_passed
380
+ return [FailureKind.coerce(:min_items),
381
+ "Extracted #{item_count} items (minimum required: #{min_items})"]
382
+ end
383
+
384
+ [FailureKind.coerce(:quality), quality_failure_message(quality_report)]
385
+ end
386
+ private_class_method :outcome_failure
387
+
388
+ def quality_failure_message(quality_report)
389
+ reasons = quality_failure_reasons(quality_report.metrics)
390
+ "Feed quality check failed (#{reasons.join(', ')})"
391
+ end
392
+ private_class_method :quality_failure_message
393
+
394
+ def quality_failure_reasons(metrics)
395
+ reasons = []
396
+ reasons << 'duplicate_urls' if duplicate_urls_failure?(metrics)
397
+ reasons << 'generic_titles' if junk_title_ratio_failure?(metrics)
398
+ reasons << 'short_titles' if short_title_failure?(metrics)
399
+ reasons
400
+ end
401
+ private_class_method :quality_failure_reasons
402
+
403
+ def log_strict_quality_failure(failure_kind, item_count)
404
+ Log.info("Test strict quality: failure_kind=#{failure_kind.to_sym} item_count=#{item_count}")
405
+ end
406
+ private_class_method :log_strict_quality_failure
407
+
408
+ def probe_native_feed_url(channel_url, raw_config)
409
+ return nil if channel_url.to_s.empty?
410
+
411
+ config = Config.from_hash(raw_config)
412
+ resources = FeedPipeline::RuntimePolicy.resources_for(config)
413
+ strategy = FeedPipeline::StrategyPlan.concrete_for_diagnostic(raw_config[:strategy])
414
+ session = RequestSession.build(
415
+ config:, strategy:, budget: resources.budget, policy: resources.policy
416
+ )
417
+ Syndication::Discovery.best_feed_url(page_url: channel_url, request_session: session)
418
+ rescue StandardError
419
+ nil
420
+ end
421
+ private_class_method :probe_native_feed_url
422
+
423
+ def validation_failure_result(errors, raw_config) # rubocop:disable Metrics/MethodLength
424
+ Result.new(
425
+ success: false,
426
+ item_count: 0,
427
+ sample_items: [],
428
+ channel_title: raw_config.dig(:channel, :title),
429
+ channel_url: raw_config.dig(:channel, :url),
430
+ strategy_used: raw_config[:strategy],
431
+ duration_seconds: 0.0,
432
+ validation_errors: errors,
433
+ error_message: 'Configuration schema validation failed',
434
+ failure_kind: FailureKind.coerce(:schema),
435
+ rss: nil,
436
+ quality_report: nil
437
+ )
438
+ end
439
+ private_class_method :validation_failure_result
440
+
441
+ def execution_failure_result(error, raw_config, duration) # rubocop:disable Metrics/MethodLength
442
+ Result.new(
443
+ success: false,
444
+ item_count: 0,
445
+ sample_items: [],
446
+ channel_title: raw_config.dig(:channel, :title),
447
+ channel_url: raw_config.dig(:channel, :url),
448
+ strategy_used: raw_config[:strategy],
449
+ duration_seconds: duration.round(3),
450
+ validation_errors: nil,
451
+ error_message: "#{error.class}: #{error.message}",
452
+ failure_kind: FailureKind.coerce(:execution),
453
+ rss: nil,
454
+ quality_report: nil
455
+ )
456
+ end
457
+ private_class_method :execution_failure_result
458
+ end
459
+ 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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Html2rss
4
4
  # Current application version.
5
- VERSION = '0.28.0'
5
+ VERSION = '0.30.0'
6
6
  public_constant :VERSION
7
7
  end