html2rss 0.30.0 → 0.31.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 (126) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/lib/html2rss/article/deduplicator.rb +2 -4
  4. data/lib/html2rss/article.rb +16 -39
  5. data/lib/html2rss/auto_source/README.md +1 -1
  6. data/lib/html2rss/auto_source/cleanup.rb +15 -6
  7. data/lib/html2rss/auto_source/scraper/html.rb +5 -0
  8. data/lib/html2rss/auto_source/scraper/json_state.rb +5 -0
  9. data/lib/html2rss/auto_source/scraper/meta_oembed.rb +8 -1
  10. data/lib/html2rss/auto_source/scraper/microdata.rb +8 -0
  11. data/lib/html2rss/auto_source/scraper/microformats2.rb +8 -0
  12. data/lib/html2rss/auto_source/scraper/native_feed.rb +3 -0
  13. data/lib/html2rss/auto_source/scraper/schema/thing.rb +4 -5
  14. data/lib/html2rss/auto_source/scraper/schema.rb +8 -0
  15. data/lib/html2rss/auto_source/scraper/semantic_html.rb +5 -0
  16. data/lib/html2rss/auto_source/scraper/sitemap.rb +3 -0
  17. data/lib/html2rss/auto_source/scraper/wordpress_api.rb +3 -0
  18. data/lib/html2rss/auto_source/scraper.rb +4 -14
  19. data/lib/html2rss/auto_source/segment.rb +7 -4
  20. data/lib/html2rss/auto_source/segmenter/cluster.rb +22 -23
  21. data/lib/html2rss/auto_source/segmenter/list.rb +54 -31
  22. data/lib/html2rss/auto_source/segmenter.rb +5 -5
  23. data/lib/html2rss/auto_source.rb +2 -2
  24. data/lib/html2rss/batch.rb +3 -1
  25. data/lib/html2rss/capture/README.md +6 -4
  26. data/lib/html2rss/capture.rb +67 -35
  27. data/lib/html2rss/channel.rb +3 -3
  28. data/lib/html2rss/cli/probe_view.rb +5 -3
  29. data/lib/html2rss/cli/render.rb +6 -5
  30. data/lib/html2rss/cli/validate.rb +28 -27
  31. data/lib/html2rss/cli.rb +1 -1
  32. data/lib/html2rss/config/issue_mapper.rb +108 -0
  33. data/lib/html2rss/config/request_headers.rb +2 -3
  34. data/lib/html2rss/config/schema.rb +16 -1
  35. data/lib/html2rss/config/selectors_validator.rb +92 -70
  36. data/lib/html2rss/config/validation_issue.rb +60 -0
  37. data/lib/html2rss/config/validation_report.rb +55 -0
  38. data/lib/html2rss/config/validator.rb +10 -6
  39. data/lib/html2rss/config.rb +35 -113
  40. data/lib/html2rss/defaults.rb +42 -30
  41. data/lib/html2rss/doctor/botasaurus.rb +6 -4
  42. data/lib/html2rss/doctor.rb +8 -0
  43. data/lib/html2rss/error.rb +1 -1
  44. data/lib/html2rss/feed_builder/rss.rb +9 -14
  45. data/lib/html2rss/feed_pipeline/runtime_policy.rb +14 -10
  46. data/lib/html2rss/feed_pipeline/strategy_plan.rb +6 -2
  47. data/lib/html2rss/feed_resolution/candidate_generator.rb +1 -4
  48. data/lib/html2rss/feed_resolution/diag.rb +3 -1
  49. data/lib/html2rss/feed_resolution/options.rb +3 -1
  50. data/lib/html2rss/feed_resolution/policy.rb +2 -2
  51. data/lib/html2rss/html/article_extractor/date_extractor.rb +1 -1
  52. data/lib/html2rss/html/article_extractor.rb +2 -4
  53. data/lib/html2rss/html/feed_link.rb +3 -1
  54. data/lib/html2rss/html/navigator/text_extractor.rb +1 -1
  55. data/lib/html2rss/html/navigator.rb +10 -10
  56. data/lib/html2rss/html/rendering/description_builder.rb +1 -1
  57. data/lib/html2rss/html/sst_article_extractor.rb +8 -5
  58. data/lib/html2rss/link_destination/destination_facts.rb +4 -2
  59. data/lib/html2rss/link_destination/noise_policy.rb +10 -1
  60. data/lib/html2rss/link_destination/path_classifier.rb +45 -38
  61. data/lib/html2rss/mcp/README.md +2 -0
  62. data/lib/html2rss/mcp/config_argument.rb +3 -1
  63. data/lib/html2rss/mcp/contract.rb +6 -20
  64. data/lib/html2rss/mcp/outcome/playbook.rb +2 -2
  65. data/lib/html2rss/mcp/outcome.rb +36 -43
  66. data/lib/html2rss/mcp/server/tools.rb +111 -131
  67. data/lib/html2rss/mcp/server.rb +26 -18
  68. data/lib/html2rss/page_recon/README.md +5 -5
  69. data/lib/html2rss/page_recon/diagnostics.rb +71 -81
  70. data/lib/html2rss/page_recon.rb +9 -5
  71. data/lib/html2rss/recon.rb +19 -30
  72. data/lib/html2rss/request_service/botasaurus_contract.rb +11 -12
  73. data/lib/html2rss/request_service/botasaurus_strategy.rb +1 -1
  74. data/lib/html2rss/request_service/compressed_body.rb +4 -1
  75. data/lib/html2rss/request_service/httpx_strategy.rb +39 -28
  76. data/lib/html2rss/request_service/network_guard.rb +7 -5
  77. data/lib/html2rss/request_service/response.rb +1 -2
  78. data/lib/html2rss/request_service/strategy.rb +3 -8
  79. data/lib/html2rss/scoring/container_assessor.rb +3 -1
  80. data/lib/html2rss/scoring/engine.rb +8 -8
  81. data/lib/html2rss/scoring/observation.rb +5 -3
  82. data/lib/html2rss/scoring/ranked_segment.rb +3 -1
  83. data/lib/html2rss/scrape_target.rb +3 -1
  84. data/lib/html2rss/selector_candidates.rb +234 -0
  85. data/lib/html2rss/selectors/README.md +64 -0
  86. data/lib/html2rss/selectors/extractors/attribute.rb +13 -15
  87. data/lib/html2rss/selectors/extractors/href.rb +15 -16
  88. data/lib/html2rss/selectors/extractors/html.rb +8 -9
  89. data/lib/html2rss/selectors/extractors/static.rb +12 -14
  90. data/lib/html2rss/selectors/extractors/text.rb +8 -9
  91. data/lib/html2rss/selectors/extractors.rb +33 -20
  92. data/lib/html2rss/selectors/item_env.rb +31 -0
  93. data/lib/html2rss/selectors/{object_to_xml_converter.rb → json_xml.rb} +11 -12
  94. data/lib/html2rss/selectors/option_spec.rb +140 -0
  95. data/lib/html2rss/selectors/post_processors/base.rb +79 -31
  96. data/lib/html2rss/selectors/post_processors/gsub.rb +103 -39
  97. data/lib/html2rss/selectors/post_processors/html_transformers/transform_urls_to_absolute_ones.rb +4 -4
  98. data/lib/html2rss/selectors/post_processors/markdown_to_html.rb +10 -11
  99. data/lib/html2rss/selectors/post_processors/parse_time.rb +50 -26
  100. data/lib/html2rss/selectors/post_processors/parse_uri.rb +11 -7
  101. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +46 -32
  102. data/lib/html2rss/selectors/post_processors/substring.rb +14 -25
  103. data/lib/html2rss/selectors/post_processors/template.rb +23 -19
  104. data/lib/html2rss/selectors/post_processors.rb +6 -5
  105. data/lib/html2rss/selectors/{schema_doc.rb → schema_export.rb} +12 -31
  106. data/lib/html2rss/selectors/step_env.rb +24 -0
  107. data/lib/html2rss/selectors.rb +231 -142
  108. data/lib/html2rss/sst/attrs.rb +5 -3
  109. data/lib/html2rss/sst/document.rb +3 -1
  110. data/lib/html2rss/sst/index.rb +3 -1
  111. data/lib/html2rss/sst/node.rb +29 -10
  112. data/lib/html2rss/sst/normalizer.rb +12 -16
  113. data/lib/html2rss/sst/tags.rb +7 -7
  114. data/lib/html2rss/status.rb +13 -8
  115. data/lib/html2rss/surface_category.rb +14 -32
  116. data/lib/html2rss/syndication/parser.rb +2 -2
  117. data/lib/html2rss/test/enhance_audit.rb +1 -3
  118. data/lib/html2rss/test/policy.rb +106 -0
  119. data/lib/html2rss/test.rb +126 -173
  120. data/lib/html2rss/url.rb +25 -4
  121. data/lib/html2rss/version.rb +1 -1
  122. data/lib/html2rss.rb +21 -13
  123. data/lib/tasks/config_schema.rake +2 -2
  124. data/schema/html2rss-config.schema.json +11 -8
  125. metadata +15 -6
  126. data/lib/html2rss/selectors/item_scope.rb +0 -30
@@ -4,13 +4,20 @@ module Html2rss
4
4
  ##
5
5
  # Global defaults for the Html2rss gem.
6
6
  class Defaults
7
- # The valid symbol log levels.
8
- VALID_LOG_LEVELS = %i[debug info warn error fatal unknown].freeze
9
-
10
- # @return [Object] the logger
7
+ # Named Logger severities accepted by {#log_level=}.
8
+ LOG_LEVELS = {
9
+ debug: Logger::DEBUG,
10
+ info: Logger::INFO,
11
+ warn: Logger::WARN,
12
+ error: Logger::ERROR,
13
+ fatal: Logger::FATAL,
14
+ unknown: Logger::UNKNOWN
15
+ }.freeze
16
+
17
+ # @return [Logger] the logger
11
18
  attr_reader :logger
12
19
 
13
- # @return [Proc, nil] the logger formatter
20
+ # @return [Proc, Logger::Formatter, nil] the logger formatter
14
21
  attr_reader :logger_formatter
15
22
 
16
23
  # @return [Symbol, Integer] the current log level
@@ -46,12 +53,15 @@ module Html2rss
46
53
  ##
47
54
  # Sets the logger.
48
55
  #
49
- # @param logger [Object]
50
- # @return [Object] the logger
56
+ # @param logger [Logger]
57
+ # @return [Logger] the logger
58
+ # @raise [ArgumentError] if logger is not a Logger
51
59
  def logger=(logger)
60
+ raise ArgumentError, 'logger must be a Logger' unless logger.is_a?(::Logger)
61
+
52
62
  @logger = logger
53
- @logger.level = @log_level if @logger.respond_to?(:level=)
54
- @logger.formatter = @logger_formatter if @logger_formatter && @logger.respond_to?(:formatter=)
63
+ @logger.level = @log_level
64
+ @logger.formatter = @logger_formatter if @logger_formatter
55
65
  end
56
66
 
57
67
  ##
@@ -62,34 +72,39 @@ module Html2rss
62
72
  # @raise [ArgumentError] if the log level is invalid
63
73
  def log_level=(level)
64
74
  @log_level = normalize_log_level(level)
65
- @logger.level = @log_level if @logger.respond_to?(:level=)
75
+ @logger.level = @log_level
66
76
  end
67
77
 
68
78
  ##
69
79
  # Sets the logger formatter.
70
80
  #
71
- # @param formatter [Proc, #call, nil] the new logger formatter
72
- # @return [Proc, #call, nil] the new logger formatter
73
- # @raise [ArgumentError] if formatter does not respond to #call
81
+ # @param formatter [Proc, Logger::Formatter, nil] the new logger formatter
82
+ # @return [Proc, Logger::Formatter, nil] the new logger formatter
83
+ # @raise [ArgumentError] if formatter is not a Proc, Logger::Formatter, or nil
74
84
  def logger_formatter=(formatter)
75
- raise ArgumentError, 'formatter must respond to #call or be nil' if formatter && !formatter.respond_to?(:call)
76
-
77
- @logger_formatter = formatter
78
- @logger.formatter = @logger_formatter if @logger.respond_to?(:formatter=)
85
+ @logger_formatter = case formatter
86
+ when nil, Proc, Logger::Formatter
87
+ formatter
88
+ else
89
+ raise ArgumentError, 'formatter must be a Proc, Logger::Formatter, or nil'
90
+ end
91
+ @logger.formatter = @logger_formatter
79
92
  end
80
93
 
81
94
  ##
82
95
  # Sets the global request headers.
83
96
  #
84
- # @param headers [Hash, Proc, #call, nil] the HTTP request headers to globally apply
85
- # @return [Hash, Proc, #call, nil] the assigned headers
86
- # @raise [ArgumentError] if headers is not a Hash or callable
97
+ # @param headers [Hash, Proc, nil] the HTTP request headers to globally apply
98
+ # @return [Hash, Proc, nil] the assigned headers
99
+ # @raise [ArgumentError] if headers is not a Hash, Proc, or nil
87
100
  def headers=(headers)
88
- if headers && !headers.is_a?(Hash) && !headers.respond_to?(:call)
89
- raise ArgumentError, 'headers must be a Hash or respond to #call'
90
- end
91
-
92
- @headers = headers.is_a?(Hash) ? headers.dup.freeze : headers
101
+ @headers = case headers
102
+ when nil then nil
103
+ when Hash then headers.dup.freeze
104
+ when Proc then headers
105
+ else
106
+ raise ArgumentError, 'headers must be a Hash or Proc'
107
+ end
93
108
  end
94
109
 
95
110
  ##
@@ -164,14 +179,11 @@ module Html2rss
164
179
 
165
180
  def normalize_log_level(level)
166
181
  if level.is_a?(Integer)
167
- raise ArgumentError, "invalid log level: #{level}" unless level.between?(0, 5)
182
+ raise ArgumentError, "invalid log level: #{level}" unless LOG_LEVELS.value?(level)
168
183
 
169
184
  level
170
185
  else
171
- sym = level.to_s.downcase.to_sym
172
- raise ArgumentError, "invalid log level: #{level}" unless VALID_LOG_LEVELS.include?(sym)
173
-
174
- Logger.const_get(sym.upcase)
186
+ LOG_LEVELS.fetch(level.to_s.downcase.to_sym) { raise ArgumentError, "invalid log level: #{level}" }
175
187
  end
176
188
  end
177
189
  end
@@ -4,15 +4,15 @@ require 'httpx'
4
4
  require 'json'
5
5
 
6
6
  module Html2rss
7
- ##
8
- # CLI runtime health checks for optional scrape dependencies.
9
7
  module Doctor
10
8
  ##
11
9
  # Preflight checks for Botasaurus scrape-api connectivity.
12
10
  module Botasaurus
11
+ Check = Data.define(:name, :ok, :detail)
12
+
13
13
  ##
14
14
  # One named doctor check outcome.
15
- Check = Data.define(:name, :ok, :detail) do
15
+ class Check
16
16
  ##
17
17
  # @return [Hash{Symbol => Object}]
18
18
  def to_h
@@ -20,9 +20,11 @@ module Html2rss
20
20
  end
21
21
  end
22
22
 
23
+ Result = Data.define(:ok, :checks, :message)
24
+
23
25
  ##
24
26
  # Aggregate doctor command outcome.
25
- Result = Data.define(:ok, :checks, :message) do
27
+ class Result
26
28
  ##
27
29
  # @return [Hash{Symbol => Object}]
28
30
  def to_h
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ ##
5
+ # CLI runtime health checks for optional scrape dependencies.
6
+ module Doctor
7
+ end
8
+ end
@@ -7,7 +7,7 @@ module Html2rss
7
7
  # Raised when auto fallback exhausts all concrete tiers and extractors find no feed items.
8
8
  class NoFeedItemsExtracted < Error
9
9
  # Categories that append shared surface guidance to the empty-feed message.
10
- SURFACE_HINT_CATEGORIES = %i[app_shell blocked_surface high_entropy_surface].freeze
10
+ SURFACE_HINT_CATEGORIES = Set[:app_shell, :blocked_surface, :high_entropy_surface].freeze
11
11
 
12
12
  ##
13
13
  # @param attempts [Array<Hash{Symbol => Object}>] tier attempt diagnostics
@@ -34,16 +34,10 @@ module Html2rss
34
34
  private
35
35
 
36
36
  def add_item_string_values(article, item_maker)
37
- {
38
- title: article.title,
39
- description: ItemPresentation.description_for(article),
40
- author: article.author
41
- }.each do |attr, value|
42
- next if value.nil? || value.empty?
43
-
44
- item_maker.public_send(:"#{attr}=", value)
45
- end
46
-
37
+ item_maker.title = article.title
38
+ item_maker.author = article.author
39
+ description = ItemPresentation.description_for(article)
40
+ item_maker.description = description if description && !description.empty?
47
41
  item_maker.link = article.url.to_s if article.url
48
42
  item_maker.pubDate = article.published_at&.rfc2822
49
43
  end
@@ -96,12 +90,13 @@ module Html2rss
96
90
  @stylesheets.map { |style| Stylesheet.new(**style) }
97
91
  end
98
92
 
99
- # rubocop:disable-next Metrics/AbcSize
93
+ # rubocop:disable-next Metrics/AbcSize, Metrics/MethodLength
100
94
  def make_channel(maker)
101
95
  channel_maker = maker.channel
102
- %i[language title description ttl].each do |key|
103
- channel_maker.public_send(:"#{key}=", channel.public_send(key))
104
- end
96
+ channel_maker.language = channel.language
97
+ channel_maker.title = channel.title
98
+ channel_maker.description = channel.description
99
+ channel_maker.ttl = channel.ttl
105
100
 
106
101
  channel_maker.managingEditor = channel.author if channel.author
107
102
  channel_maker.author = channel.author if channel.author
@@ -45,6 +45,20 @@ module Html2rss
45
45
  # @return [Html2rss::RequestService::Budget] shared budget for the feed build
46
46
  def self.budget_for(config) = resources_for(config).budget
47
47
 
48
+ ##
49
+ # Reserve enough HTTP request slots for the initial request plus predictable
50
+ # follow-ups.
51
+ #
52
+ # @param config [Html2rss::Config] validated feed config
53
+ # @return [Integer] number of reserved baseline request slots
54
+ def self.baseline_request_budget_for(config)
55
+ 1 +
56
+ RequestSession::Pager.request_slots_for(config.selectors&.dig(:items, :pagination)) +
57
+ AutoSource.request_slots_for(config.auto_source) +
58
+ FeedResolution.request_slots_for(config.auto_source) +
59
+ StrategyPlan.resolve(config.strategy).request_slots
60
+ end
61
+
48
62
  class << self
49
63
  private
50
64
 
@@ -53,16 +67,6 @@ module Html2rss
53
67
 
54
68
  [baseline_request_budget_for(config), config.max_requests].max
55
69
  end
56
-
57
- # Reserve enough HTTP request slots for the initial request plus predictable
58
- # follow-ups.
59
- def baseline_request_budget_for(config)
60
- 1 +
61
- RequestSession::Pager.request_slots_for(config.selectors&.dig(:items, :pagination)) +
62
- AutoSource.request_slots_for(config.auto_source) +
63
- FeedResolution.request_slots_for(config.auto_source) +
64
- StrategyPlan.resolve(config.strategy).request_slots
65
- end
66
70
  end
67
71
  end
68
72
  end
@@ -8,16 +8,20 @@ module Html2rss
8
8
  # {RequestService} executes concrete adapters only. +:auto+ is resolved here before
9
9
  # any session or adapter call — plan vs transport locality.
10
10
  class StrategyPlan
11
+ Auto = Data.define
12
+
11
13
  # Feed-level auto plan: try {AutoFallback::CHAIN} until items are extracted.
12
- Auto = Data.define do
14
+ class Auto
13
15
  # @return [Integer]
14
16
  def request_slots
15
17
  [AutoFallback::CHAIN.size - 1, 0].max
16
18
  end
17
19
  end
18
20
 
21
+ Concrete = Data.define(:strategy)
22
+
19
23
  # Concrete transport strategy name for {RequestService}.
20
- Concrete = Data.define(:strategy) do
24
+ class Concrete
21
25
  # @return [Integer]
22
26
  def request_slots = 0
23
27
  end
@@ -99,11 +99,8 @@ module Html2rss
99
99
 
100
100
  def segment_urls_for(sst, strategy, link_resolver)
101
101
  segmenter = AutoSource::Segmenter.new(sst, base_url: entry_url, strategy:, link_resolver:)
102
- primary = AutoSource::Segmenter::PrimaryLink.new(segmenter)
103
-
104
102
  segmenter.call.filter_map do |segment|
105
- link = segment.primary_link || primary.select(segment.root_node)
106
- absolute_same_origin(link&.attrs&.href)
103
+ absolute_same_origin(segment.primary_link&.attrs&.href)
107
104
  end.uniq
108
105
  end
109
106
 
@@ -2,9 +2,11 @@
2
2
 
3
3
  module Html2rss
4
4
  module FeedResolution
5
+ Diag = Data.define(:applied, :probe_count, :reason, :winner_score)
6
+
5
7
  ##
6
8
  # Wire-safe diagnostics for one entry-resolution attempt (Status / Marshal edge).
7
- Diag = Data.define(:applied, :probe_count, :reason, :winner_score) do
9
+ class Diag
8
10
  ##
9
11
  # @param result [Html2rss::FeedResolution::Result]
10
12
  # @return [Diag]
@@ -2,9 +2,11 @@
2
2
 
3
3
  module Html2rss
4
4
  module FeedResolution
5
+ Options = Data.define(:enabled, :max_probes)
6
+
5
7
  ##
6
8
  # Typed +auto_source.entry_resolution+ options (one expansion from config Hash).
7
- Options = Data.define(:enabled, :max_probes) do
9
+ class Options
8
10
  ##
9
11
  # @param auto_source [Hash, nil]
10
12
  # @return [Options]
@@ -13,13 +13,13 @@ module Html2rss
13
13
  ##
14
14
  # @param config [Html2rss::Config]
15
15
  # @param articles [Array<Html2rss::Article>] typed extract results (count derived)
16
- # @param surface_category [Html2rss::SurfaceCategory, Symbol, nil]
16
+ # @param surface_category [Html2rss::SurfaceCategory, Symbol]
17
17
  # @return [Boolean]
18
18
  def resolve?(config:, articles:, surface_category:)
19
19
  raise ArgumentError, 'articles must be an Array' unless articles.is_a?(Array)
20
20
  return false unless eligible_config?(config)
21
21
 
22
- category = SurfaceCategory.coerce(surface_category)
22
+ category = SurfaceCategory[surface_category]
23
23
  return false if category.blocked?
24
24
 
25
25
  articles.size < ARTICLE_FLOOR || category.weak? || native_feed_majority?(articles)
@@ -10,7 +10,7 @@ module Html2rss
10
10
  # @param time_zone [String] channel time zone for naive values
11
11
  # @return [DateTime, nil]
12
12
  def self.call(article_tag, leftover_lines: [], time_zone: 'UTC')
13
- datetimes = article_tag.respond_to?(:css) ? article_tag.css('[datetime]').map { |tag| tag['datetime'] } : []
13
+ datetimes = article_tag.css('[datetime]').map { |tag| tag['datetime'] }
14
14
  ArticleRules::Date.earliest(datetimes, leftover_lines:, time_zone:)
15
15
  end
16
16
  end
@@ -124,8 +124,7 @@ module Html2rss
124
124
  end
125
125
 
126
126
  def leftover_exclude_nodes_for(node)
127
- times = node.respond_to?(:css) ? node.css('time') : []
128
- [heading, selected_anchor, kicker_node, *times].compact
127
+ [heading, selected_anchor, kicker_node, *node.css('time')].compact
129
128
  end
130
129
 
131
130
  def heading_or_anchor_item?
@@ -156,8 +155,7 @@ module Html2rss
156
155
  end
157
156
 
158
157
  def immediate_card_parent
159
- parent = article_tag.respond_to?(:parent) ? article_tag.parent : nil
160
- Navigator.parent_until_condition(parent, method(:usable_walk_parent?))
158
+ Navigator.parent_until_condition(article_tag.parent, ->(node) { usable_walk_parent?(node) })
161
159
  end
162
160
 
163
161
  def usable_walk_parent?(node)
@@ -2,10 +2,12 @@
2
2
 
3
3
  module Html2rss
4
4
  module Html
5
+ FeedLink = Data.define(:href, :mime_type)
6
+
5
7
  # A native RSS/Atom hint from a document head +link[rel=alternate]+.
6
8
  #
7
9
  # Does not guess +/feed+ or +/rss.xml+ paths — that stays in configs +probe_rss+.
8
- FeedLink = Data.define(:href, :mime_type) do
10
+ class FeedLink
9
11
  class << self
10
12
  ##
11
13
  # Collects RSS/Atom +rel=alternate+ links from +doc+'s +head+.
@@ -19,7 +19,7 @@ module Html2rss
19
19
  # @param exclude_nodes [Array<Nokogiri::XML::Node>, nil] nodes to exclude from extraction
20
20
  # @return [String, nil] the concatenated visible text, or nil if none is found
21
21
  def call(tag, separator: ' ', exclude_nodes: nil)
22
- return tag.text.gsub(/\s+/, ' ').strip if tag.respond_to?(:text?) && tag.text?
22
+ return tag.text.gsub(/\s+/, ' ').strip if tag.is_a?(Nokogiri::XML::Text)
23
23
 
24
24
  parts = iterate_children(tag, separator, exclude_nodes)
25
25
  return if parts.empty?
@@ -6,16 +6,16 @@ module Html2rss
6
6
  # Navigator owns DOM chrome recognition and node traversal helpers.
7
7
  class Navigator
8
8
  # Heading tags used to prioritize title extraction and container assessment.
9
- HEADING_TAGS = %w[h1 h2 h3 h4 h5 h6].freeze
9
+ HEADING_TAGS = Set['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].freeze
10
10
 
11
11
  # Element tags that indicate ignored DOM chrome when found in a container path.
12
- IGNORED_CONTAINER_TAGS = %w[nav footer header svg script style].to_set.freeze
12
+ IGNORED_CONTAINER_TAGS = Set['nav', 'footer', 'header', 'svg', 'script', 'style'].freeze
13
13
 
14
14
  # Ancestor tags that usually indicate navigation/utility regions inside a content container.
15
- UTILITY_LANDMARK_TAGS = %w[nav aside footer menu].to_set.freeze
15
+ UTILITY_LANDMARK_TAGS = Set['nav', 'aside', 'footer', 'menu'].freeze
16
16
 
17
17
  # Immediate parent walk stops here — not a usable article card.
18
- CARD_WALK_STOP_TAGS = (UTILITY_LANDMARK_TAGS | IGNORED_CONTAINER_TAGS | %w[html body]).freeze
18
+ CARD_WALK_STOP_TAGS = (UTILITY_LANDMARK_TAGS | IGNORED_CONTAINER_TAGS | Set['html', 'body']).freeze
19
19
 
20
20
  # Inner tags that mean a wrapping <a> is a card, not a span-styled list link.
21
21
  WRAPPING_ANCHOR_CHILD_TAGS = (HEADING_TAGS + %w[p]).freeze
@@ -58,7 +58,7 @@ module Html2rss
58
58
  # @param condition [Proc] The condition to be met.
59
59
  # @return [Nokogiri::XML::Node, nil] The first parent that satisfies the condition.
60
60
  def parent_until_condition(node, condition)
61
- while node && !node.document? && Probe.tag(node) != 'html'
61
+ while node && !node.is_a?(Nokogiri::XML::Document) && Probe.tag(node) != 'html'
62
62
  return node if condition.call(node)
63
63
 
64
64
  node = node.parent
@@ -72,7 +72,7 @@ module Html2rss
72
72
  # @return [Boolean]
73
73
  def usable_card_parent?(node)
74
74
  return false unless node
75
- return false if node.respond_to?(:document?) && node.document?
75
+ return false if node.is_a?(Nokogiri::XML::Document)
76
76
 
77
77
  !CARD_WALK_STOP_TAGS.include?(Probe.tag(node))
78
78
  end
@@ -88,8 +88,7 @@ module Html2rss
88
88
  while current_tag
89
89
  found = current_tag.at_css(selector)
90
90
  return found if found
91
-
92
- return nil unless current_tag.respond_to?(:parent)
91
+ break if current_tag.is_a?(Nokogiri::XML::Document)
93
92
 
94
93
  current_tag = current_tag.parent
95
94
  end
@@ -115,11 +114,12 @@ module Html2rss
115
114
  # @param parent_node [Nokogiri::XML::Node] potential ancestor
116
115
  # @return [Boolean] true when child_node is a descendant of parent_node
117
116
  def descendant_of?(child_node, parent_node)
118
- curr = child_node.respond_to?(:parent) ? child_node.parent : nil
117
+ curr = child_node.is_a?(Nokogiri::XML::Document) ? nil : child_node.parent
119
118
  while curr
120
119
  return true if curr == parent_node
120
+ break if curr.is_a?(Nokogiri::XML::Document)
121
121
 
122
- curr = curr.respond_to?(:parent) ? curr.parent : nil
122
+ curr = curr.parent
123
123
  end
124
124
  false
125
125
  end
@@ -81,7 +81,7 @@ module Html2rss
81
81
 
82
82
  def processed_base_description
83
83
  text = self.class.remove_pattern_from_start(@base, @title)
84
- Html2rss::Selectors::PostProcessors::SanitizeHtml.get(text, @url)
84
+ Html2rss::Selectors::PostProcessors::SanitizeHtml.call(text, @url)
85
85
  end
86
86
  end
87
87
  end
@@ -12,9 +12,9 @@ module Html2rss
12
12
  # CSS class tokens that mark kicker / eyebrow text (excluded from titles).
13
13
  KICKER_CLASS_PATTERN = /kicker|eyebrow|pre-title|pretitle|overline/i
14
14
  # Inline emphasis tags used as title fallbacks when no heading exists.
15
- FALLBACK_HEADING_NAMES = %i[strong b].freeze
15
+ FALLBACK_HEADING_NAMES = Set[:strong, :b].freeze
16
16
  # Nested blocks that mean a category node is actually a content container.
17
- CATEGORY_CONTAINER_NAMES = %i[p article section].to_set.freeze
17
+ CATEGORY_CONTAINER_NAMES = Set[:p, :article, :section].freeze
18
18
 
19
19
  class << self
20
20
  ##
@@ -175,10 +175,13 @@ module Html2rss
175
175
  end
176
176
 
177
177
  def descendant_of?(child, ancestor)
178
- # Walk via root children since Extractor may not hold Index; use tag walk on tree.
179
178
  return true if child.equal?(ancestor)
180
179
 
181
- ancestor.descendants.any? { |d| d.equal?(child) }
180
+ if (index = SST::Index.for_node(child))
181
+ return index.descendant_of?(child, ancestor)
182
+ end
183
+
184
+ !ancestor.find { |d| d.equal?(child) }.nil?
182
185
  end
183
186
 
184
187
  def leftover_lines
@@ -220,7 +223,7 @@ module Html2rss
220
223
  def immediate_card_parent
221
224
  parent = sst_parent
222
225
  index = SST::Index.for_node(@root)
223
- index&.parent_until(parent, method(:usable_walk_parent?))
226
+ index&.parent_until(parent, ->(node) { usable_walk_parent?(node) })
224
227
  end
225
228
 
226
229
  def usable_walk_parent?(node)
@@ -2,7 +2,6 @@
2
2
 
3
3
  module Html2rss
4
4
  module LinkDestination
5
- # Normalized URL plus reusable route-classification facts for one link.
6
5
  DestinationFacts = Data.define(
7
6
  :url,
8
7
  :destination,
@@ -15,7 +14,10 @@ module Html2rss
15
14
  :strong_post_suffix,
16
15
  :high_confidence_junk_path,
17
16
  :high_confidence_utility_destination
18
- ) do
17
+ )
18
+
19
+ # Normalized URL plus reusable route-classification facts for one link.
20
+ class DestinationFacts
19
21
  # @param url [Html2rss::Url] normalized destination URL
20
22
  # @return [DestinationFacts] route facts for downstream link scoring
21
23
  def self.build(url) # rubocop:disable Metrics/MethodLength
@@ -42,7 +42,16 @@ module Html2rss
42
42
  destination_facts.utility_path &&
43
43
  !destination_facts.content_path &&
44
44
  !destination_facts.strong_post_suffix &&
45
- text.to_s.scan(/\p{Alnum}+/).size <= 3
45
+ word_count_at_most?(text, 3)
46
+ end
47
+
48
+ def word_count_at_most?(text, max)
49
+ count = 0
50
+ text.to_s.scan(/\p{Alnum}+/) do
51
+ count += 1
52
+ return false if count > max
53
+ end
54
+ true
46
55
  end
47
56
 
48
57
  def recommended_chrome?(text, destination_facts, heading_anchor:)