html2rss 0.32.4 → 0.33.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 (105) hide show
  1. checksums.yaml +4 -4
  2. data/lib/html2rss/article/enclosure.rb +2 -6
  3. data/lib/html2rss/auto_source/cleanup.rb +1 -3
  4. data/lib/html2rss/auto_source/scraper/html.rb +2 -8
  5. data/lib/html2rss/auto_source/scraper/json_document.rb +2 -3
  6. data/lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb +128 -128
  7. data/lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb +37 -37
  8. data/lib/html2rss/auto_source/scraper/json_state/document_scanner.rb +123 -123
  9. data/lib/html2rss/auto_source/scraper/json_state/value_finder.rb +34 -34
  10. data/lib/html2rss/auto_source/scraper/json_state.rb +2 -6
  11. data/lib/html2rss/auto_source/scraper/meta_oembed.rb +32 -2
  12. data/lib/html2rss/auto_source/scraper/microdata.rb +228 -245
  13. data/lib/html2rss/auto_source/scraper/microformats2.rb +9 -1
  14. data/lib/html2rss/auto_source/scraper/native_feed.rb +1 -1
  15. data/lib/html2rss/auto_source/scraper/schema/category_extractor.rb +7 -2
  16. data/lib/html2rss/auto_source/scraper/schema/item_list.rb +2 -2
  17. data/lib/html2rss/auto_source/scraper/schema/thing.rb +98 -21
  18. data/lib/html2rss/auto_source/scraper/schema.rb +49 -2
  19. data/lib/html2rss/auto_source/scraper/semantic_html.rb +12 -16
  20. data/lib/html2rss/auto_source/scraper/sitemap/parser.rb +1 -4
  21. data/lib/html2rss/auto_source/scraper/sitemap.rb +3 -11
  22. data/lib/html2rss/auto_source/scraper/wordpress_api/page_scope.rb +2 -2
  23. data/lib/html2rss/auto_source/scraper/wordpress_api/posts_endpoint.rb +1 -3
  24. data/lib/html2rss/auto_source/scraper/wordpress_api.rb +4 -4
  25. data/lib/html2rss/auto_source/scraper/xhr_articles.rb +1 -1
  26. data/lib/html2rss/auto_source/scraper.rb +0 -11
  27. data/lib/html2rss/auto_source/segmenter/cluster.rb +5 -11
  28. data/lib/html2rss/auto_source/segmenter/list.rb +25 -57
  29. data/lib/html2rss/auto_source/segmenter/semantic.rb +6 -13
  30. data/lib/html2rss/auto_source.rb +1 -1
  31. data/lib/html2rss/batch.rb +22 -32
  32. data/lib/html2rss/capture/README.md +1 -1
  33. data/lib/html2rss/capture.rb +7 -27
  34. data/lib/html2rss/cli/render.rb +97 -96
  35. data/lib/html2rss/cli/validate.rb +2 -4
  36. data/lib/html2rss/cli.rb +33 -27
  37. data/lib/html2rss/config/request_controls.rb +10 -2
  38. data/lib/html2rss/config/schema.rb +8 -10
  39. data/lib/html2rss/config/selectors_validator.rb +1 -0
  40. data/lib/html2rss/config/validator.rb +3 -3
  41. data/lib/html2rss/config.rb +1 -1
  42. data/lib/html2rss/defaults.rb +5 -16
  43. data/lib/html2rss/doctor/botasaurus.rb +11 -19
  44. data/lib/html2rss/error.rb +7 -3
  45. data/lib/html2rss/feed_builder/rss.rb +1 -1
  46. data/lib/html2rss/feed_pipeline/auto_fallback.rb +3 -3
  47. data/lib/html2rss/feed_resolution/candidate_generator.rb +3 -4
  48. data/lib/html2rss/feed_resolution/options.rb +3 -7
  49. data/lib/html2rss/feed_resolution/policy.rb +29 -33
  50. data/lib/html2rss/feed_resolution/probe.rb +3 -3
  51. data/lib/html2rss/feed_resolution/scorer.rb +29 -29
  52. data/lib/html2rss/feed_resolution.rb +5 -9
  53. data/lib/html2rss/feed_result.rb +4 -3
  54. data/lib/html2rss/hash_util.rb +91 -90
  55. data/lib/html2rss/html/article_extractor/category_extractor.rb +6 -9
  56. data/lib/html2rss/html/article_extractor/date_extractor.rb +1 -1
  57. data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +1 -3
  58. data/lib/html2rss/html/article_extractor/id_generator.rb +1 -1
  59. data/lib/html2rss/html/probe.rb +51 -51
  60. data/lib/html2rss/link_destination/path_classifier.rb +11 -33
  61. data/lib/html2rss/mcp/config_argument.rb +9 -2
  62. data/lib/html2rss/mcp/contract.rb +7 -5
  63. data/lib/html2rss/mcp/outcome.rb +4 -5
  64. data/lib/html2rss/mcp/runtime.rb +17 -24
  65. data/lib/html2rss/mcp/server/tools.rb +9 -9
  66. data/lib/html2rss/option.rb +48 -0
  67. data/lib/html2rss/page_recon/diagnostics.rb +7 -9
  68. data/lib/html2rss/page_recon.rb +0 -4
  69. data/lib/html2rss/recon.rb +10 -12
  70. data/lib/html2rss/request_service/blocked_surface.rb +3 -7
  71. data/lib/html2rss/request_service/botasaurus_strategy.rb +2 -2
  72. data/lib/html2rss/request_service/budget.rb +3 -24
  73. data/lib/html2rss/request_service/compressed_body.rb +36 -48
  74. data/lib/html2rss/request_service/context.rb +1 -3
  75. data/lib/html2rss/request_service/local_file_strategy.rb +0 -1
  76. data/lib/html2rss/request_service/network_guard.rb +3 -2
  77. data/lib/html2rss/request_service/policy.rb +8 -26
  78. data/lib/html2rss/request_service/response.rb +3 -6
  79. data/lib/html2rss/request_session/pager/base.rb +3 -3
  80. data/lib/html2rss/request_session/pager/custom_selector.rb +4 -4
  81. data/lib/html2rss/request_session/pager/offset.rb +1 -7
  82. data/lib/html2rss/request_session/pager/rel_next.rb +1 -1
  83. data/lib/html2rss/request_session/pager/url_template.rb +1 -7
  84. data/lib/html2rss/scoring/engine.rb +4 -32
  85. data/lib/html2rss/scoring/link_resolver.rb +2 -7
  86. data/lib/html2rss/selectors/extractors/href.rb +2 -4
  87. data/lib/html2rss/selectors/json_xml.rb +33 -34
  88. data/lib/html2rss/selectors/post_processors/gsub.rb +2 -3
  89. data/lib/html2rss/selectors/post_processors/parse_time.rb +4 -5
  90. data/lib/html2rss/selectors/schema_export.rb +60 -60
  91. data/lib/html2rss/selectors.rb +14 -18
  92. data/lib/html2rss/sst/node.rb +9 -3
  93. data/lib/html2rss/sst/normalizer.rb +25 -24
  94. data/lib/html2rss/sst/text.rb +53 -57
  95. data/lib/html2rss/status.rb +23 -30
  96. data/lib/html2rss/syndication/alternate_links.rb +39 -0
  97. data/lib/html2rss/syndication/discovery.rb +117 -187
  98. data/lib/html2rss/syndication/parser.rb +92 -115
  99. data/lib/html2rss/test/enhance_audit.rb +13 -15
  100. data/lib/html2rss/test/policy.rb +9 -11
  101. data/lib/html2rss/test.rb +29 -48
  102. data/lib/html2rss/url.rb +53 -53
  103. data/lib/html2rss/version.rb +1 -1
  104. data/lib/html2rss.rb +2 -1
  105. metadata +4 -2
@@ -40,10 +40,10 @@ module Html2rss
40
40
  attr_accessor :transport_meta
41
41
 
42
42
  # @return [String, nil] scrape-api timeout stage when known (overridden by RequestTimedOut)
43
- def timeout_phase = nil
43
+ def timeout_phase; end
44
44
 
45
45
  # @return [Symbol, nil] surface classification when known (overridden by NoFeedItemsExtracted)
46
- def surface_category = nil
46
+ def surface_category; end
47
47
 
48
48
  ##
49
49
  # Sparse telemetry hash for observability consumers.
@@ -59,6 +59,9 @@ module Html2rss
59
59
  end
60
60
  end
61
61
 
62
+ # Raised when a caller-supplied option value is malformed or out of range.
63
+ class InvalidOption < Error; end
64
+
62
65
  # Raised when auto fallback exhausts all concrete tiers and extractors find no feed items.
63
66
  class NoFeedItemsExtracted < Error
64
67
  # Categories that append shared surface guidance to the empty-feed message.
@@ -87,7 +90,8 @@ module Html2rss
87
90
 
88
91
  def base_message
89
92
  summaries = attempts.map do |attempt|
90
- details = attempt[:items_count].nil? ? "#{attempt[:error_class]} error" : "#{attempt[:items_count]} items"
93
+ count = attempt[:items_count]
94
+ details = count.nil? ? "#{attempt[:error_class]} error" : "#{count} items"
91
95
  "#{attempt[:strategy]} (#{details})"
92
96
  end.join(', ')
93
97
 
@@ -37,7 +37,7 @@ module Html2rss
37
37
  item_maker.title = article.title
38
38
  item_maker.author = article.author
39
39
  description = ItemPresentation.description_for(article)
40
- item_maker.description = description if description && !description.empty?
40
+ item_maker.description = description if description
41
41
  item_maker.link = article.url.to_s if article.url
42
42
  item_maker.pubDate = article.published_at&.rfc2822
43
43
  end
@@ -83,7 +83,7 @@ module Html2rss
83
83
  # @param elapsed_ms [Integer, nil] monotonic hop duration in milliseconds
84
84
  # @return [void]
85
85
  def record_error(strategy:, error:, elapsed_ms: nil)
86
- meta = error.is_a?(Html2rss::Error) ? error.transport_meta : nil
86
+ meta = error.transport_meta if error.is_a?(Error)
87
87
  append_attempt(strategy:, error_class: error.class.name, transport_meta: meta, elapsed_ms:)
88
88
  @last_error = error
89
89
  return unless TERMINAL_TRANSPORT_ERRORS.any? { |klass| error.is_a?(klass) }
@@ -137,7 +137,7 @@ module Html2rss
137
137
  # @raise [Html2rss::Error, StandardError]
138
138
  def raise_failure!
139
139
  if (error = failure_error)
140
- error.attempts = attempts if error.is_a?(Html2rss::Error)
140
+ error.attempts = attempts if error.is_a?(Error)
141
141
  raise error
142
142
  end
143
143
 
@@ -215,7 +215,7 @@ module Html2rss
215
215
  rescue *NON_FALLBACK_ERRORS => error
216
216
  state.record_error(strategy:, error:, elapsed_ms: monotonic_ms - started_ms)
217
217
  state.abort!
218
- rescue StandardError => error
218
+ rescue Html2rss::Error => error
219
219
  state.record_error(strategy:, error:, elapsed_ms: monotonic_ms - started_ms)
220
220
  log_fallback_error(strategy:, next_strategy:, error:, request_session:) if next_strategy && request_session
221
221
  end
@@ -50,7 +50,6 @@ module Html2rss
50
50
  return unless parsed_html
51
51
 
52
52
  url = Syndication::Discovery.candidate_urls(page_url: entry_url, parsed_body: parsed_html).first
53
- return unless url
54
53
  return unless eligible_url?(url)
55
54
 
56
55
  url
@@ -86,6 +85,8 @@ module Html2rss
86
85
  return [] unless parsed_html
87
86
 
88
87
  sst = SST::Normalizer.call(parsed_html)
88
+ return [] unless sst
89
+
89
90
  link_resolver = Scoring::LinkResolver.new(entry_url)
90
91
 
91
92
  SEGMENT_STRATEGIES.each do |strategy|
@@ -93,8 +94,6 @@ module Html2rss
93
94
  return urls if urls.size >= 2
94
95
  end
95
96
  []
96
- rescue StandardError
97
- []
98
97
  end
99
98
 
100
99
  def segment_urls_for(sst, strategy, link_resolver)
@@ -120,7 +119,7 @@ module Html2rss
120
119
  return if href.nil? || href.empty? || href.start_with?('#', 'mailto:', 'javascript:')
121
120
 
122
121
  Html2rss::Url.from_relative(href, entry_url)
123
- rescue ArgumentError
122
+ rescue Html2rss::Url::InvalidUrlError
124
123
  nil
125
124
  end
126
125
 
@@ -8,15 +8,11 @@ module Html2rss
8
8
  # Typed +auto_source.entry_resolution+ options (one expansion from config Hash).
9
9
  class Options
10
10
  ##
11
- # @param auto_source [Hash, nil]
11
+ # @param auto_source [Hash, nil] validated +auto_source+ config
12
12
  # @return [Options]
13
13
  def self.from_auto_source(auto_source)
14
- raw = auto_source.is_a?(Hash) ? auto_source[:entry_resolution] : nil
15
- raw = {} unless raw.is_a?(Hash)
16
- new(
17
- enabled: raw.fetch(:enabled, FeedResolution::DEFAULT_CONFIG[:enabled]) != false,
18
- max_probes: Integer(raw.fetch(:max_probes, FeedResolution::DEFAULT_CONFIG[:max_probes]))
19
- )
14
+ raw = auto_source&.dig(:entry_resolution) || {}
15
+ new(enabled: raw.fetch(:enabled, true), max_probes: raw.fetch(:max_probes, DEFAULT_CONFIG[:max_probes]))
20
16
  end
21
17
 
22
18
  ##
@@ -8,40 +8,36 @@ module Html2rss
8
8
  # Minimum admitted articles before the entry URL is considered strong enough.
9
9
  ARTICLE_FLOOR = 3
10
10
 
11
- module_function
12
-
13
- ##
14
- # @param config [Html2rss::Config]
15
- # @param articles [Array<Html2rss::Article>] typed extract results (count derived)
16
- # @param surface_category [Html2rss::SurfaceCategory, Symbol]
17
- # @return [Boolean]
18
- def resolve?(config:, articles:, surface_category:)
19
- raise ArgumentError, 'articles must be an Array' unless articles.is_a?(Array)
20
- return false unless eligible_config?(config)
21
-
22
- category = SurfaceCategory[surface_category]
23
- return false if category.blocked?
24
-
25
- articles.size < ARTICLE_FLOOR || category.weak? || native_feed_majority?(articles)
26
- end
27
-
28
- def eligible_config?(config)
29
- return false unless config.auto_source
30
- return false if config.selectors
31
-
32
- Options.from_auto_source(config.auto_source).enabled?
33
- end
34
- module_function :eligible_config?
35
- private_class_method :eligible_config?
36
-
37
- def native_feed_majority?(articles)
38
- return false if articles.empty?
39
-
40
- native = articles.count { |article| article.scraper == AutoSource::Scraper::NativeFeed }
41
- native * 2 >= articles.size
11
+ class << self
12
+ ##
13
+ # @param config [Html2rss::Config]
14
+ # @param articles [Array<Html2rss::Article>] typed extract results (count derived)
15
+ # @param surface_category [Html2rss::SurfaceCategory, Symbol]
16
+ # @return [Boolean]
17
+ def resolve?(config:, articles:, surface_category:)
18
+ raise ArgumentError, 'articles must be an Array' unless articles.is_a?(Array)
19
+ return false unless eligible_config?(config)
20
+
21
+ category = SurfaceCategory[surface_category]
22
+ return false if category.blocked?
23
+
24
+ articles.size < ARTICLE_FLOOR || category.weak? || native_feed_majority?(articles)
25
+ end
26
+
27
+ private
28
+
29
+ def eligible_config?(config)
30
+ return false unless config.auto_source
31
+ return false if config.selectors
32
+
33
+ Options.from_auto_source(config.auto_source).enabled?
34
+ end
35
+
36
+ def native_feed_majority?(articles)
37
+ native = articles.count { |article| article.scraper == AutoSource::Scraper::NativeFeed }
38
+ native * 2 >= articles.size
39
+ end
42
40
  end
43
- module_function :native_feed_majority?
44
- private_class_method :native_feed_majority?
45
41
  end
46
42
  end
47
43
  end
@@ -16,7 +16,7 @@ module Html2rss
16
16
  # @param origin_url [String, Html2rss::Url]
17
17
  def initialize(request_session:, origin_url:)
18
18
  @request_session = request_session
19
- @origin_url = Html2rss::Url.from_absolute(origin_url)
19
+ @origin_url = Url.from_absolute(origin_url)
20
20
  end
21
21
 
22
22
  ##
@@ -28,7 +28,7 @@ module Html2rss
28
28
  relation: :entry_resolution,
29
29
  origin_url:
30
30
  )
31
- score_response(url, response)
31
+ score_response(response)
32
32
  rescue Html2rss::Error
33
33
  nil
34
34
  end
@@ -37,7 +37,7 @@ module Html2rss
37
37
 
38
38
  attr_reader :request_session, :origin_url
39
39
 
40
- def score_response(_url, response) # rubocop:disable Metrics/MethodLength -- feed vs HTML branches
40
+ def score_response(response) # rubocop:disable Metrics/MethodLength -- feed vs HTML branches
41
41
  if response.feed_response?
42
42
  count = Syndication::Parser.parse_response(response).size
43
43
  return Scored.new(
@@ -11,38 +11,38 @@ module Html2rss
11
11
  # Bonus when the surface looks like a listing rather than a hub/shell.
12
12
  LISTING_SURFACE_BONUS = 5
13
13
 
14
- module_function
14
+ class << self
15
+ ##
16
+ # @param articles_count [Integer]
17
+ # @return [Integer]
18
+ def score_feed(articles_count:)
19
+ articles_count * ARTICLE_WEIGHT
20
+ end
15
21
 
16
- ##
17
- # @param articles_count [Integer]
18
- # @return [Integer]
19
- def score_feed(articles_count:)
20
- articles_count.to_i * ARTICLE_WEIGHT
21
- end
22
-
23
- ##
24
- # @param assessment [Html2rss::PageRecon::Assessment]
25
- # @return [Integer]
26
- def score_assessment(assessment)
27
- score = assessment.articles_count * ARTICLE_WEIGHT
28
- score += LISTING_SURFACE_BONUS if assessment.listing_bonus?
29
- drops = assessment.admission_drops.values.sum
30
- total = assessment.articles_count + drops
31
- score -= ((drops.to_f / total) * ARTICLE_WEIGHT).round if total.positive?
32
- score
33
- end
22
+ ##
23
+ # @param assessment [Html2rss::PageRecon::Assessment]
24
+ # @return [Integer]
25
+ def score_assessment(assessment)
26
+ score = assessment.articles_count * ARTICLE_WEIGHT
27
+ score += LISTING_SURFACE_BONUS if assessment.listing_bonus?
28
+ drops = assessment.admission_drops.values.sum
29
+ total = assessment.articles_count + drops
30
+ score -= ((drops.to_f / total) * ARTICLE_WEIGHT).round if total.positive?
31
+ score
32
+ end
34
33
 
35
- ##
36
- # @param scored [Array<Probe::Scored>]
37
- # @param entry_articles_count [Integer]
38
- # @return [Probe::Scored, nil]
39
- def pick_winner(scored:, entry_articles_count:)
40
- winner = scored.max_by(&:score)
41
- return unless winner
42
- return if winner.articles_count <= entry_articles_count.to_i
43
- return if winner.score <= 0
34
+ ##
35
+ # @param scored [Array<Probe::Scored>]
36
+ # @param entry_articles_count [Integer]
37
+ # @return [Probe::Scored, nil]
38
+ def pick_winner(scored:, entry_articles_count:)
39
+ winner = scored.max_by(&:score)
40
+ return unless winner
41
+ return if winner.articles_count <= entry_articles_count.to_i
42
+ return if winner.score <= 0
44
43
 
45
- winner
44
+ winner
45
+ end
46
46
  end
47
47
  end
48
48
  end
@@ -116,17 +116,13 @@ module Html2rss
116
116
  return unless resolution.applied
117
117
 
118
118
  effective = scrape_target.with_effective(resolution.scrape_url)
119
- return ApplyOutcome.new(scrape_target: effective, status: :succeeded) if retry_with(
120
- resolution, effective:
121
- )
122
-
123
- nil
119
+ ApplyOutcome.new(scrape_target: effective, status: :succeeded) if retry_with(effective:)
124
120
  rescue RequestService::RequestBudgetExceeded
125
121
  log_budget_exhausted
126
122
  nil
127
123
  rescue *NON_FALLBACK_ERRORS
128
124
  raise
129
- rescue StandardError => error
125
+ rescue Html2rss::Error => error
130
126
  log_retry_failed(error)
131
127
  nil
132
128
  end
@@ -158,7 +154,7 @@ module Html2rss
158
154
  # @return [String, nil]
159
155
  def channel_host
160
156
  Url.from_absolute(config.url.to_s).host
161
- rescue ArgumentError
157
+ rescue Html2rss::Url::InvalidUrlError
162
158
  nil
163
159
  end
164
160
 
@@ -184,7 +180,7 @@ module Html2rss
184
180
  )
185
181
  end
186
182
 
187
- def retry_with(_resolution, effective:) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- retry extract path
183
+ def retry_with(effective:) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- retry extract path
188
184
  retry_session = pipeline.request_session_for(
189
185
  config, strategy:, resources:, scrape_url: effective.effective_url
190
186
  )
@@ -220,7 +216,7 @@ module Html2rss
220
216
  def initialize(entry_url:, response:, session:, config:, articles:, surface_category: nil)
221
217
  raise ArgumentError, 'articles must be an Array' unless articles.is_a?(Array)
222
218
 
223
- @entry_url = Html2rss::Url.from_absolute(entry_url)
219
+ @entry_url = Url.from_absolute(entry_url)
224
220
  @response = response
225
221
  @session = session
226
222
  @config = config
@@ -23,8 +23,7 @@ module Html2rss
23
23
  def initialize(channel:, articles:, status:, stylesheets: [])
24
24
  raise ArgumentError, 'channel must be a Html2rss::Channel' unless channel.is_a?(Channel)
25
25
  raise ArgumentError, 'status must be a Html2rss::Status' unless status.is_a?(Status)
26
-
27
- articles = Array(articles)
26
+ raise ArgumentError, 'articles must be an Array' unless articles.is_a?(Array)
28
27
  raise ArgumentError, 'articles must all be Html2rss::Article' unless articles.all?(Article)
29
28
 
30
29
  @channel = channel
@@ -82,7 +81,9 @@ module Html2rss
82
81
  end
83
82
 
84
83
  def freeze_stylesheets(stylesheets)
85
- Array(stylesheets).map do |sheet|
84
+ raise ArgumentError, 'stylesheets must be an Array' unless stylesheets.is_a?(Array)
85
+
86
+ stylesheets.map do |sheet|
86
87
  sheet.to_h.transform_keys(&:to_sym).transform_values do |value|
87
88
  value.is_a?(String) ? value.dup.freeze : value
88
89
  end.freeze
@@ -3,116 +3,117 @@
3
3
  module Html2rss
4
4
  # Shared helpers for hash normalization and structural operations.
5
5
  module HashUtil
6
- module_function
6
+ class << self
7
+ # Deeply duplicates nested arrays and hashes.
8
+ #
9
+ # @param object [Object] nested value from configuration or runtime state
10
+ # @return [Object] deep duplicated object
11
+ def deep_dup(object)
12
+ case object
13
+ in Hash
14
+ object.transform_values { deep_dup(_1) }
15
+ in Array
16
+ object.map { deep_dup(_1) }
17
+ else
18
+ object.dup
19
+ end
20
+ end
7
21
 
8
- # Deeply duplicates nested arrays and hashes.
9
- #
10
- # @param object [Object] nested value from configuration or runtime state
11
- # @return [Object] deep duplicated object
12
- def deep_dup(object)
13
- case object
14
- in Hash
15
- object.transform_values { deep_dup(_1) }
16
- in Array
17
- object.map { deep_dup(_1) }
18
- else
19
- object.dup rescue StandardError # rubocop:disable Style/RescueModifier
22
+ # Deeply merges nested hashes while replacing non-hash values from override.
23
+ #
24
+ # @param base [Hash] base hash
25
+ # @param override [Hash] override hash
26
+ # @return [Hash] merged hash
27
+ def deep_merge(base, override)
28
+ base.merge(override) do |_key, old_val, new_val|
29
+ case [old_val, new_val]
30
+ in [Hash, Hash]
31
+ deep_merge(old_val, new_val)
32
+ else
33
+ new_val
34
+ end
35
+ end
20
36
  end
21
- end
22
37
 
23
- # Deeply merges nested hashes while replacing non-hash values from override.
24
- #
25
- # @param base [Hash] base hash
26
- # @param override [Hash] override hash
27
- # @return [Hash] merged hash
28
- def deep_merge(base, override)
29
- base.merge(override) do |_key, old_val, new_val|
30
- case [old_val, new_val]
31
- in [Hash, Hash]
32
- deep_merge(old_val, new_val)
38
+ # Converts string-keyed hashes to symbol-keyed hashes recursively.
39
+ #
40
+ # @param object [Object] value to normalize
41
+ # @param context [String] error context
42
+ # @return [Object] normalized value
43
+ def deep_symbolize_keys(object, context: 'hash')
44
+ case object
45
+ in Hash
46
+ object.each_with_object({}) do |(k, v), memo|
47
+ memo[symbol_key(k, context:)] = deep_symbolize_keys(v, context:)
48
+ end
49
+ in Array
50
+ object.map { deep_symbolize_keys(_1, context:) }
33
51
  else
34
- new_val
52
+ object
35
53
  end
36
54
  end
37
- end
38
55
 
39
- # Converts string-keyed hashes to symbol-keyed hashes recursively.
40
- #
41
- # @param object [Object] value to normalize
42
- # @param context [String] error context
43
- # @return [Object] normalized value
44
- def deep_symbolize_keys(object, context: 'hash')
45
- case object
46
- in Hash
47
- object.each_with_object({}) do |(k, v), memo|
48
- memo[symbol_key(k, context:)] = deep_symbolize_keys(v, context:)
56
+ # Converts hash keys (and Symbol values) to strings recursively.
57
+ #
58
+ # @param object [Object] value to normalize
59
+ # @return [Object] normalized value with string keys
60
+ def deep_stringify_keys(object)
61
+ case object
62
+ when Hash
63
+ object.to_h { |key, value| [key.to_s, deep_stringify_keys(value)] }
64
+ when Array
65
+ object.map { deep_stringify_keys(_1) }
66
+ when Symbol
67
+ object.to_s
68
+ else
69
+ object
49
70
  end
50
- in Array
51
- object.map { deep_symbolize_keys(_1, context:) }
52
- else
53
- object
54
71
  end
55
- end
56
72
 
57
- # Converts hash keys (and Symbol values) to strings recursively.
58
- #
59
- # @param object [Object] value to normalize
60
- # @return [Object] normalized value with string keys
61
- def deep_stringify_keys(object)
62
- case object
63
- when Hash
64
- object.to_h { |key, value| [key.to_s, deep_stringify_keys(value)] }
65
- when Array
66
- object.map { deep_stringify_keys(_1) }
67
- when Symbol
68
- object.to_s
69
- else
70
- object
71
- end
72
- end
73
+ # Validates that hash keys are symbols.
74
+ #
75
+ # @param value [Object] candidate hash container whose keys must be symbols
76
+ # @param context [String] error context
77
+ # @param deep [Boolean] whether nested hashes should also be validated
78
+ # @return [void]
79
+ def assert_symbol_keys!(value, context: 'hash', deep: true)
80
+ return unless value in Hash
73
81
 
74
- # Validates that hash keys are symbols.
75
- #
76
- # @param value [Object] candidate hash container whose keys must be symbols
77
- # @param context [String] error context
78
- # @param deep [Boolean] whether nested hashes should also be validated
79
- # @return [void]
80
- def assert_symbol_keys!(value, context: 'hash', deep: true)
81
- return unless value in Hash
82
+ unless value.each_key.all?(Symbol)
83
+ invalid_key = value.keys.find { _1.class != Symbol }
84
+ raise ArgumentError, "#{context} must use symbol keys (found #{invalid_key.inspect})"
85
+ end
82
86
 
83
- unless value.each_key.all?(Symbol)
84
- invalid_key = value.keys.find { _1.class != Symbol }
85
- raise ArgumentError, "#{context} must use symbol keys (found #{invalid_key.inspect})"
87
+ value.each_value { assert_symbol_keys!(_1, context:, deep:) } if deep
86
88
  end
87
89
 
88
- value.each_value { assert_symbol_keys!(_1, context:, deep:) } if deep
89
- end
90
+ # Validates that hash keys are strings.
91
+ #
92
+ # @param value [Object] candidate hash container whose keys must be strings
93
+ # @param context [String] error context
94
+ # @param deep [Boolean] whether nested hashes should also be validated
95
+ # @return [void]
96
+ def assert_string_keys!(value, context: 'hash', deep: true)
97
+ return unless value in Hash
90
98
 
91
- # Validates that hash keys are strings.
92
- #
93
- # @param value [Object] candidate hash container whose keys must be strings
94
- # @param context [String] error context
95
- # @param deep [Boolean] whether nested hashes should also be validated
96
- # @return [void]
97
- def assert_string_keys!(value, context: 'hash', deep: true)
98
- return unless value in Hash
99
+ unless value.each_key.all?(String)
100
+ invalid_key = value.keys.find { _1.class != String }
101
+ raise ArgumentError, "#{context} must use string keys (found #{invalid_key.inspect})"
102
+ end
99
103
 
100
- unless value.each_key.all?(String)
101
- invalid_key = value.keys.find { _1.class != String }
102
- raise ArgumentError, "#{context} must use string keys (found #{invalid_key.inspect})"
104
+ value.each_value { assert_string_keys!(_1, context:, deep:) } if deep
103
105
  end
104
106
 
105
- value.each_value { assert_string_keys!(_1, context:, deep:) } if deep
106
- end
107
+ private
107
108
 
108
- def symbol_key(key, context:)
109
- case key
110
- in Symbol then key
111
- in String then key.to_sym
112
- else
113
- raise ArgumentError, "#{context} must use string or symbol keys (found #{key.inspect})"
109
+ def symbol_key(key, context:)
110
+ case key
111
+ in Symbol then key
112
+ in String then key.to_sym
113
+ else
114
+ raise ArgumentError, "#{context} must use string or symbol keys (found #{key.inspect})"
115
+ end
114
116
  end
115
117
  end
116
- private_class_method :symbol_key
117
118
  end
118
119
  end
@@ -28,23 +28,20 @@ module Html2rss
28
28
  def self.call(article_tag, title: nil)
29
29
  return [] unless article_tag
30
30
 
31
- extract_all_categories(article_tag, title:)
32
- .map(&:strip)
33
- .reject(&:empty?)
31
+ extract_all_categories(article_tag, title:).to_a
34
32
  end
35
33
 
36
34
  ##
37
35
  # Optimized single DOM traversal that extracts all category types.
36
+ # `Node#css` only yields descendants, so the container itself is never visited.
38
37
  #
39
38
  # @param article_tag [Nokogiri::XML::Element] The article element
40
39
  # @param title [String, nil]
41
40
  # @return [Set<String>] Set of category strings
42
- def self.extract_all_categories(article_tag, title: nil)
41
+ def self.extract_all_categories(article_tag, title:)
43
42
  Set.new.tap do |categories|
44
43
  extract_element_data_categories!(categories, article_tag, title:)
45
44
  article_tag.css(CATEGORY_SELECTORS.join(',')).each do |element|
46
- next if element == article_tag
47
-
48
45
  if ArticleRules::Category.class_match?(element['class'])
49
46
  extract_text_categories!(categories, element, title:)
50
47
  end
@@ -60,7 +57,7 @@ module Html2rss
60
57
  # @param element [Nokogiri::XML::Element] metadata element that may contain category links
61
58
  # @param title [String, nil]
62
59
  # @return [void]
63
- def self.extract_element_data_categories!(categories, element, title: nil)
60
+ def self.extract_element_data_categories!(categories, element, title:)
64
61
  element.attributes.each_value do |attr|
65
62
  next unless ArticleRules::Category.attr_name_match?(attr.name)
66
63
 
@@ -75,7 +72,7 @@ module Html2rss
75
72
  # @param element [Nokogiri::XML::Element] metadata element whose text may contain delimiters
76
73
  # @param title [String, nil]
77
74
  # @return [void]
78
- def self.extract_text_categories!(categories, element, title: nil)
75
+ def self.extract_text_categories!(categories, element, title:)
79
76
  return if category_container?(element)
80
77
 
81
78
  anchors = element.name == 'a' ? [element] : element.css('a').to_a
@@ -96,7 +93,7 @@ module Html2rss
96
93
  # @param element [Nokogiri::XML::Element] The element to extract text from
97
94
  # @param title [String, nil]
98
95
  # @return [void]
99
- def self.add_text_to_categories!(categories, element, title: nil)
96
+ def self.add_text_to_categories!(categories, element, title:)
100
97
  ArticleRules::Category.add_text!(categories, Navigator.extract_visible_text(element), title:)
101
98
  end
102
99
 
@@ -9,7 +9,7 @@ module Html2rss
9
9
  # @param leftover_lines [Array<String>] leftover visible lines (already split)
10
10
  # @param time_zone [String] channel time zone for naive values
11
11
  # @return [DateTime, nil]
12
- def self.call(article_tag, leftover_lines: [], time_zone: 'UTC')
12
+ def self.call(article_tag, time_zone:, leftover_lines: [])
13
13
  datetimes = article_tag.css('[datetime]').map { |tag| tag['datetime'] }
14
14
  ArticleRules::Date.earliest(datetimes, leftover_lines:, time_zone:)
15
15
  end
@@ -23,8 +23,6 @@ module Html2rss
23
23
  # @param base_url [String, Html2rss::Url] base URL for relative enclosure links
24
24
  # @return [Array<Hash{Symbol => Object}>] normalized enclosure hashes
25
25
  def self.call(article_tag, base_url)
26
- return [] unless article_tag
27
-
28
26
  article_tag.css(SELECTOR).filter_map do |element|
29
27
  extract_from_element(element, base_url)
30
28
  end
@@ -34,7 +32,7 @@ module Html2rss
34
32
  case element.name
35
33
  when 'img'
36
34
  ArticleRules::Enclosure.from_image(element['src'], base_url)
37
- when 'video', 'audio', 'source'
35
+ when 'audio', 'source'
38
36
  ArticleRules::Enclosure.from_media(element['src'], element['type'], base_url)
39
37
  when 'iframe'
40
38
  ArticleRules::Enclosure.from_iframe(element['src'], base_url)