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.
Files changed (80) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +82 -25
  3. data/html2rss.gemspec +0 -1
  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 +158 -15
  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 +4 -14
  26. data/lib/html2rss/config/schema.rb +7 -5
  27. data/lib/html2rss/config/validator.rb +1 -0
  28. data/lib/html2rss/config.rb +59 -22
  29. data/lib/html2rss/doctor/botasaurus.rb +130 -0
  30. data/lib/html2rss/feed_builder/rss.rb +1 -2
  31. data/lib/html2rss/feed_pipeline/README.md +5 -5
  32. data/lib/html2rss/feed_pipeline/auto_fallback.rb +29 -10
  33. data/lib/html2rss/feed_pipeline.rb +27 -16
  34. data/lib/html2rss/feed_resolution/README.md +11 -11
  35. data/lib/html2rss/feed_resolution.rb +6 -12
  36. data/lib/html2rss/html/article_extractor/heading_extractor.rb +24 -14
  37. data/lib/html2rss/html/article_extractor.rb +4 -9
  38. data/lib/html2rss/html/feed_link.rb +4 -2
  39. data/lib/html2rss/html/navigator.rb +4 -39
  40. data/lib/html2rss/html/probe.rb +82 -0
  41. data/lib/html2rss/html/sst_article_extractor.rb +1 -2
  42. data/lib/html2rss/link_destination/noise_policy.rb +1 -2
  43. data/lib/html2rss/link_destination/path_classifier.rb +20 -19
  44. data/lib/html2rss/mcp/README.md +80 -0
  45. data/lib/html2rss/mcp/contract.rb +135 -13
  46. data/lib/html2rss/mcp/outcome/playbook.rb +122 -0
  47. data/lib/html2rss/mcp/outcome.rb +112 -42
  48. data/lib/html2rss/mcp/runtime.rb +45 -0
  49. data/lib/html2rss/mcp/server/tools.rb +277 -0
  50. data/lib/html2rss/mcp/server.rb +91 -232
  51. data/lib/html2rss/mcp.rb +3 -0
  52. data/lib/html2rss/page_recon/README.md +69 -0
  53. data/lib/html2rss/page_recon/diagnostics.rb +211 -0
  54. data/lib/html2rss/page_recon.rb +56 -2
  55. data/lib/html2rss/recon.rb +252 -0
  56. data/lib/html2rss/request_service/blocked_surface.rb +1 -0
  57. data/lib/html2rss/request_service/faraday_strategy.rb +11 -7
  58. data/lib/html2rss/request_service/policy.rb +1 -2
  59. data/lib/html2rss/request_service/response.rb +3 -4
  60. data/lib/html2rss/request_service/strategy.rb +1 -2
  61. data/lib/html2rss/request_service.rb +2 -0
  62. data/lib/html2rss/request_session.rb +1 -2
  63. data/lib/html2rss/scrape_target.rb +1 -1
  64. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +2 -4
  65. data/lib/html2rss/selectors/post_processors.rb +0 -1
  66. data/lib/html2rss/selectors.rb +33 -10
  67. data/lib/html2rss/sst/attrs.rb +1 -2
  68. data/lib/html2rss/sst/normalizer.rb +49 -11
  69. data/lib/html2rss/status.rb +3 -6
  70. data/lib/html2rss/syndication/README.md +6 -6
  71. data/lib/html2rss/syndication/discovery.rb +2 -4
  72. data/lib/html2rss/test/enhance_audit.rb +192 -0
  73. data/lib/html2rss/test.rb +458 -0
  74. data/lib/html2rss/url.rb +1 -2
  75. data/lib/html2rss/version.rb +1 -1
  76. data/lib/html2rss.rb +157 -35
  77. data/schema/html2rss-config.schema.json +9 -29
  78. metadata +17 -18
  79. data/lib/html2rss/mcp/inspect.rb +0 -138
  80. 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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Html2rss
4
4
  # Current application version.
5
- VERSION = '0.28.0'
5
+ VERSION = '0.29.1'
6
6
  public_constant :VERSION
7
7
  end