html2rss 0.23.0 → 0.24.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.
@@ -31,6 +31,11 @@ module Html2rss
31
31
  ITEM_TAGS = %i[title url description author comments published_at guid enclosure categories].freeze
32
32
  # Item attributes that require dedicated extraction logic.
33
33
  SPECIAL_ATTRIBUTES = Set[:guid, :enclosure, :categories].freeze
34
+ # Config selector keys that map onto a different {Article} attribute.
35
+ # +:enclosure+ stays singular in YAML; Article stores +:enclosures+.
36
+ SELECTOR_TO_ARTICLE_KEY = { enclosure: :enclosures }.freeze
37
+ # Selector keys that may be copied onto an Article (PROVIDED_KEYS + mapped aliases).
38
+ SELECTABLE_SELECTOR_KEYS = (Html2rss::Article::PROVIDED_KEYS + SELECTOR_TO_ARTICLE_KEY.keys).to_set.freeze
34
39
 
35
40
  ##
36
41
  # Initializes a new Selectors instance.
@@ -45,7 +50,7 @@ module Html2rss
45
50
  @time_zone = time_zone
46
51
 
47
52
  prepare_selectors!
48
- @rss_item_attributes = @selectors.keys & Html2rss::Article::PROVIDED_KEYS
53
+ @rss_item_attributes = @selectors.keys.select { |key| SELECTABLE_SELECTOR_KEYS.include?(key) }
49
54
  end
50
55
 
51
56
  ##
@@ -92,7 +97,13 @@ module Html2rss
92
97
  # @return [Hash] Hash of attributes for the article.
93
98
  def extract_article(item, page_response = response)
94
99
  scope = item_scope_for(item, page_response.url)
95
- @rss_item_attributes.to_h { |key| [key, scope.select(key)] }.compact
100
+ @rss_item_attributes.each_with_object({}) do |selector_key, hash|
101
+ value = scope.select(selector_key)
102
+ next if value.nil?
103
+
104
+ article_key = SELECTOR_TO_ARTICLE_KEY.fetch(selector_key, selector_key)
105
+ hash[article_key] = article_key == :enclosures ? wrap_enclosure_value(value) : value
106
+ end
96
107
  end
97
108
 
98
109
  ##
@@ -308,9 +319,20 @@ module Html2rss
308
319
  @channel_contexts[base_url] ||= { url: base_url, time_zone: @time_zone }.freeze
309
320
  end
310
321
 
311
- # @return [Hash] enclosure details.
322
+ # Keep a single enclosure Hash as one list entry; +Array(hash)+ would split pairs.
323
+ #
324
+ # @param value [Hash, Array] enclosure hash or list of hashes
325
+ # @return [Array]
326
+ def wrap_enclosure_value(value)
327
+ value.is_a?(Array) ? value : [value]
328
+ end
329
+
330
+ # @return [Hash, nil] enclosure details, or nil when the selector yields nothing.
312
331
  def enclosure(scope:, config:)
313
- url = Url.from_relative(select_regular(:enclosure, scope:, config:), scope.base_url)
332
+ selected = select_regular(:enclosure, scope:, config:)
333
+ return if selected.nil? || selected.to_s.strip.empty?
334
+
335
+ url = Url.from_relative(selected, scope.base_url)
314
336
 
315
337
  { url:, type: config[:content_type] }
316
338
  end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ ##
5
+ # Shared RSS +generator+ / JSON Feed +user_comment+ formatter string.
6
+ #
7
+ # Exposed publicly via {FeedResult#status}. Safe to log without reading articles.
8
+ # Stable telemetry payload for cross-repo consumers (e.g. html2rss-web observability).
9
+ # Tallies and counters are validated and frozen at construction (including Marshal load).
10
+ Status = Data.define(
11
+ :version, :scraper_tallies, :dedup_dropped, :selected_strategy, :attempt_count, :strategy_attempts
12
+ ) do
13
+ class << self
14
+ ##
15
+ # Builds status from extracted articles and scrape telemetry.
16
+ #
17
+ # @param articles [Array<Html2rss::Article>] articles kept after deduplication
18
+ # @param dedup_dropped [Integer] number of articles removed by deduplication
19
+ # @param selected_strategy [Symbol, nil] concrete strategy that succeeded under +:auto+ (else +nil+)
20
+ # @param attempt_count [Integer] auto-fallback attempt count (0 when not under +:auto+)
21
+ # @param strategy_attempts [Array<Hash>] auto-fallback attempt hashes (empty outside +:auto+)
22
+ # @return [Html2rss::Status]
23
+ def build(articles:, dedup_dropped: 0, selected_strategy: nil, attempt_count: 0, strategy_attempts: [])
24
+ tallies = articles.filter_map(&:scraper).tally.transform_keys { |klass| scraper_name(klass) }
25
+ new(
26
+ version: Html2rss::VERSION,
27
+ scraper_tallies: tallies,
28
+ dedup_dropped:,
29
+ selected_strategy:,
30
+ attempt_count:,
31
+ strategy_attempts:
32
+ )
33
+ end
34
+
35
+ ##
36
+ # @param klass [Class, #to_s] scraper class
37
+ # @return [String] short scraper label for tallies / generator text
38
+ def scraper_name(klass)
39
+ klass.to_s.gsub(/(?:Html2rss|Scraper)::/, '')
40
+ end
41
+ end
42
+
43
+ ##
44
+ # @param version [String]
45
+ # @param scraper_tallies [Hash{String => Integer}]
46
+ # @param dedup_dropped [Integer]
47
+ # @param selected_strategy [Symbol, nil]
48
+ # @param attempt_count [Integer]
49
+ # @param strategy_attempts [Array<Hash>]
50
+ # rubocop:disable Metrics/ParameterLists, Metrics/MethodLength -- Status Data.define members
51
+ def initialize(
52
+ version:, scraper_tallies:, dedup_dropped:, selected_strategy: nil, attempt_count: 0, strategy_attempts: []
53
+ )
54
+ dedup = Integer(dedup_dropped)
55
+ attempts = Integer(attempt_count)
56
+ validate_counters!(dedup:, attempts:, selected_strategy:)
57
+
58
+ super(
59
+ version: version.to_s.dup.freeze,
60
+ scraper_tallies: freeze_tallies(scraper_tallies),
61
+ dedup_dropped: dedup,
62
+ selected_strategy:,
63
+ attempt_count: attempts,
64
+ strategy_attempts: freeze_attempts(strategy_attempts)
65
+ )
66
+ end
67
+ # rubocop:enable Metrics/ParameterLists, Metrics/MethodLength
68
+
69
+ ##
70
+ # Observability hash for web (+scraper_status+). Omits empty/absent optional keys:
71
+ # +:scraper_tallies+ when empty, +:selected_strategy+ when +nil+, +:attempt_count+ when zero,
72
+ # +:strategy_attempts+ when empty.
73
+ # Data members remain available via readers even when omitted here.
74
+ #
75
+ # @return [Hash{Symbol => Object}] always +:version+ (String), +:dedup_dropped+ (Integer);
76
+ # optionally +:scraper_tallies+, +:selected_strategy+, +:attempt_count+, +:strategy_attempts+
77
+ def to_h
78
+ {
79
+ version:,
80
+ dedup_dropped:,
81
+ **(scraper_tallies.any? ? { scraper_tallies: } : {}),
82
+ **(selected_strategy.nil? ? {} : { selected_strategy: }),
83
+ **(attempt_count.positive? ? { attempt_count: } : {}),
84
+ **(strategy_attempts.any? ? { strategy_attempts: } : {})
85
+ }
86
+ end
87
+
88
+ ##
89
+ # Formats the RSS +generator+ string and JSON Feed +user_comment+.
90
+ # Scraper-focused only — auto strategy summary stays on {#to_h}, not this string.
91
+ # Omits the +(scrapers: …)+ clause when tallies are empty.
92
+ #
93
+ # @return [String]
94
+ def to_generator_comment
95
+ return "html2rss V. #{version}" if scraper_tallies.empty?
96
+
97
+ counts = scraper_tallies.map { |name, count| "#{name} (#{count})" }
98
+ "html2rss V. #{version} (scrapers: #{counts.join(', ')})"
99
+ end
100
+
101
+ private
102
+
103
+ def marshal_dump
104
+ [version, scraper_tallies, dedup_dropped, selected_strategy, attempt_count, strategy_attempts]
105
+ end
106
+
107
+ def marshal_load((version, scraper_tallies, dedup_dropped, selected_strategy, attempt_count, strategy_attempts))
108
+ initialize(version:, scraper_tallies:, dedup_dropped:, selected_strategy:, attempt_count:, strategy_attempts:)
109
+ end
110
+
111
+ def freeze_tallies(tallies)
112
+ tallies.to_h.transform_keys(&:to_s).transform_values { |count| Integer(count) }.freeze
113
+ end
114
+
115
+ def freeze_attempts(attempts)
116
+ Array(attempts).map { |attempt| attempt.to_h.freeze }.freeze
117
+ end
118
+
119
+ def validate_counters!(dedup:, attempts:, selected_strategy:)
120
+ raise ArgumentError, 'dedup_dropped must be >= 0' if dedup.negative?
121
+ raise ArgumentError, 'attempt_count must be >= 0' if attempts.negative?
122
+
123
+ unless selected_strategy.nil? || selected_strategy.is_a?(Symbol)
124
+ raise ArgumentError, 'selected_strategy must be a Symbol or nil'
125
+ end
126
+
127
+ return unless selected_strategy && attempts < 1
128
+
129
+ raise ArgumentError, 'attempt_count must be >= 1 when selected_strategy is set'
130
+ end
131
+ end
132
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Html2rss
4
4
  # Current application version.
5
- VERSION = '0.23.0'
5
+ VERSION = '0.24.0'
6
6
  public_constant :VERSION
7
7
  end
data/lib/html2rss.rb CHANGED
@@ -34,22 +34,34 @@ module Html2rss
34
34
  Config.load_yaml(file, feed_name)
35
35
  end
36
36
 
37
+ ##
38
+ # Returns an opaque, Marshal-cacheable result of one scrape.
39
+ #
40
+ # Prefer this when the same scrape must render as RSS and JSON Feed (e.g. web cache).
41
+ #
42
+ # @param raw_config [Hash{Symbol => Object}] feed configuration
43
+ # @return [Html2rss::FeedResult]
44
+ def self.feed_result(raw_config)
45
+ FeedPipeline.new(raw_config).to_result
46
+ end
47
+
37
48
  ##
38
49
  # Returns an RSS object generated from the provided configuration.
39
50
  #
40
51
  # @param raw_config [Hash{Symbol => Object}] feed configuration
41
52
  # @return [RSS::Rss] generated RSS feed
42
53
  def self.feed(raw_config)
43
- FeedPipeline.new(raw_config).to_rss
54
+ feed_result(raw_config).to_rss
44
55
  end
45
56
 
46
57
  ##
47
58
  # Returns a JSONFeed 1.1 hash generated from the provided configuration.
48
59
  #
49
60
  # @param raw_config [Hash{Symbol => Object}] feed configuration
61
+ # @param feed_url [String, nil] optional self URL for the feed (JSON Feed +feed_url+)
50
62
  # @return [Hash] JSONFeed-compliant hash
51
- def self.json_feed(raw_config)
52
- FeedPipeline.new(raw_config).to_json_feed
63
+ def self.json_feed(raw_config, feed_url: nil)
64
+ feed_result(raw_config).to_json_feed(feed_url:)
53
65
  end
54
66
 
55
67
  # rubocop:disable Metrics/ParameterLists
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: html2rss
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.23.0
4
+ version: 0.24.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gil Desmarais
@@ -303,7 +303,6 @@ files:
303
303
  - lib/html2rss/auto_source/scraper/schema.rb
304
304
  - lib/html2rss/auto_source/scraper/schema/category_extractor.rb
305
305
  - lib/html2rss/auto_source/scraper/schema/item_list.rb
306
- - lib/html2rss/auto_source/scraper/schema/list_item.rb
307
306
  - lib/html2rss/auto_source/scraper/schema/thing.rb
308
307
  - lib/html2rss/auto_source/scraper/semantic_html.rb
309
308
  - lib/html2rss/auto_source/scraper/semantic_html/entry_deduplicator.rb
@@ -326,6 +325,7 @@ files:
326
325
  - lib/html2rss/defaults.rb
327
326
  - lib/html2rss/error.rb
328
327
  - lib/html2rss/feed_builder.rb
328
+ - lib/html2rss/feed_builder/item_presentation.rb
329
329
  - lib/html2rss/feed_builder/json_feed.rb
330
330
  - lib/html2rss/feed_builder/json_feed/item.rb
331
331
  - lib/html2rss/feed_builder/rss.rb
@@ -334,6 +334,7 @@ files:
334
334
  - lib/html2rss/feed_pipeline/auto_fallback.rb
335
335
  - lib/html2rss/feed_pipeline/runtime_policy.rb
336
336
  - lib/html2rss/feed_pipeline/strategy_plan.rb
337
+ - lib/html2rss/feed_result.rb
337
338
  - lib/html2rss/hash_util.rb
338
339
  - lib/html2rss/html.rb
339
340
  - lib/html2rss/html/article_extractor.rb
@@ -398,6 +399,7 @@ files:
398
399
  - lib/html2rss/selectors/post_processors/sanitize_html.rb
399
400
  - lib/html2rss/selectors/post_processors/substring.rb
400
401
  - lib/html2rss/selectors/post_processors/template.rb
402
+ - lib/html2rss/status.rb
401
403
  - lib/html2rss/url.rb
402
404
  - lib/html2rss/version.rb
403
405
  - lib/tasks/config_schema.rake
@@ -407,7 +409,7 @@ licenses:
407
409
  - MIT
408
410
  metadata:
409
411
  allowed_push_host: https://rubygems.org
410
- changelog_uri: https://github.com/html2rss/html2rss/releases/tag/v0.23.0
412
+ changelog_uri: https://github.com/html2rss/html2rss/releases/tag/v0.24.0
411
413
  rubygems_mfa_required: 'true'
412
414
  rdoc_options: []
413
415
  require_paths:
@@ -1,28 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Html2rss
4
- class AutoSource
5
- module Scraper
6
- class Schema
7
- ##
8
- # @see https://schema.org/ListItem
9
- class ListItem < Thing
10
- # @return [String, nil] stable list-item identifier
11
- def id = (id = (schema_object.dig(:item, :@id) || super).to_s).empty? ? nil : id
12
- # @return [String, nil] list-item title
13
- def title = schema_object.dig(:item, :name) || super || url&.titleized
14
- # @return [String, nil] list-item description
15
- def description = schema_object.dig(:item, :description) || super
16
-
17
- # @return [Html2rss::Url, nil]
18
- def url
19
- return @url if defined?(@url)
20
-
21
- item_url = schema_object.dig(:item, :url)
22
- @url = item_url ? Url.from_relative(item_url, base_url || item_url) : super
23
- end
24
- end
25
- end
26
- end
27
- end
28
- end