html2rss 0.25.0 → 0.26.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 (68) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +76 -2
  3. data/html2rss.gemspec +4 -1
  4. data/lib/html2rss/auto_source/cleanup.rb +62 -36
  5. data/lib/html2rss/auto_source/scraper/json_state.rb +31 -22
  6. data/lib/html2rss/auto_source/scraper/schema/item_list.rb +2 -14
  7. data/lib/html2rss/auto_source/scraper/xhr_articles.rb +69 -0
  8. data/lib/html2rss/auto_source/scraper.rb +34 -15
  9. data/lib/html2rss/auto_source/segmenter/list.rb +3 -1
  10. data/lib/html2rss/auto_source/segmenter/primary_link.rb +6 -7
  11. data/lib/html2rss/auto_source/segmenter.rb +30 -1
  12. data/lib/html2rss/auto_source.rb +7 -2
  13. data/lib/html2rss/capture.rb +315 -0
  14. data/lib/html2rss/cli.rb +64 -5
  15. data/lib/html2rss/config/auto_source_contract.rb +3 -1
  16. data/lib/html2rss/config/schema.rb +67 -26
  17. data/lib/html2rss/config/selectors_validator.rb +60 -21
  18. data/lib/html2rss/config/validator.rb +18 -27
  19. data/lib/html2rss/error.rb +15 -3
  20. data/lib/html2rss/feed_pipeline/auto_fallback.rb +16 -6
  21. data/lib/html2rss/feed_pipeline/runtime_policy.rb +3 -43
  22. data/lib/html2rss/feed_pipeline.rb +10 -3
  23. data/lib/html2rss/hash_util.rb +17 -0
  24. data/lib/html2rss/html/sst_article_extractor.rb +36 -7
  25. data/lib/html2rss/link_destination/noise_policy.rb +7 -18
  26. data/lib/html2rss/link_destination/path_classifier.rb +3 -0
  27. data/lib/html2rss/mcp/server.rb +577 -0
  28. data/lib/html2rss/mcp.rb +21 -0
  29. data/lib/html2rss/request_service/blocked_surface.rb +24 -1
  30. data/lib/html2rss/request_service/botasaurus_contract.rb +89 -9
  31. data/lib/html2rss/request_service/botasaurus_strategy.rb +4 -2
  32. data/lib/html2rss/request_service/budget.rb +7 -35
  33. data/lib/html2rss/request_service/context.rb +0 -6
  34. data/lib/html2rss/request_service/faraday_strategy.rb +45 -2
  35. data/lib/html2rss/request_service/policy.rb +1 -1
  36. data/lib/html2rss/request_service/response.rb +15 -1
  37. data/lib/html2rss/request_service/strategy.rb +1 -2
  38. data/lib/html2rss/request_service.rb +4 -9
  39. data/lib/html2rss/scoring/container_assessor.rb +9 -15
  40. data/lib/html2rss/scoring/engine.rb +49 -5
  41. data/lib/html2rss/scoring/link_resolver.rb +12 -0
  42. data/lib/html2rss/scoring/ranked_segment.rb +0 -12
  43. data/lib/html2rss/scoring/score.rb +1 -20
  44. data/lib/html2rss/scoring.rb +0 -25
  45. data/lib/html2rss/selectors/extractors/attribute.rb +15 -0
  46. data/lib/html2rss/selectors/extractors/href.rb +12 -0
  47. data/lib/html2rss/selectors/extractors/html.rb +12 -0
  48. data/lib/html2rss/selectors/extractors/static.rb +14 -0
  49. data/lib/html2rss/selectors/extractors/text.rb +11 -0
  50. data/lib/html2rss/selectors/post_processors/gsub.rb +18 -0
  51. data/lib/html2rss/selectors/post_processors/html_to_markdown.rb +11 -0
  52. data/lib/html2rss/selectors/post_processors/markdown_to_html.rb +12 -0
  53. data/lib/html2rss/selectors/post_processors/parse_time.rb +11 -0
  54. data/lib/html2rss/selectors/post_processors/parse_uri.rb +11 -0
  55. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +13 -0
  56. data/lib/html2rss/selectors/post_processors/substring.rb +21 -0
  57. data/lib/html2rss/selectors/post_processors/template.rb +20 -0
  58. data/lib/html2rss/selectors/schema_doc.rb +99 -0
  59. data/lib/html2rss/url.rb +11 -7
  60. data/lib/html2rss/version.rb +1 -1
  61. data/lib/html2rss.rb +32 -1
  62. data/schema/html2rss-config.schema.json +408 -85
  63. metadata +54 -12
  64. data/lib/html2rss/request_service/browserless_strategy.rb +0 -132
  65. data/lib/html2rss/request_service/puppet_commander/navigation_guards.rb +0 -148
  66. data/lib/html2rss/request_service/puppet_commander/preload_runner.rb +0 -86
  67. data/lib/html2rss/request_service/puppet_commander.rb +0 -95
  68. data/lib/html2rss/scoring/anchor_score.rb +0 -34
@@ -75,6 +75,9 @@ module Html2rss
75
75
  ##
76
76
  # Validates the configuration of a single selector.
77
77
  class Selector < Dry::Validation::Contract
78
+ # Extractor Options members supplied at runtime, not from feed YAML.
79
+ RUNTIME_EXTRACTOR_FIELDS = %i[selector channel].to_set.freeze
80
+
78
81
  params do
79
82
  optional(:selector)
80
83
  optional(:extractor).filled(:string)
@@ -88,33 +91,69 @@ module Html2rss
88
91
  end
89
92
 
90
93
  rule(:extractor) do
91
- # dependent on the extractor, validate required fields, (i.e. static, attribute)
92
- case value
93
- when 'attribute'
94
- key(:attribute).failure('`attribute` must be a string') unless values[:attribute].is_a?(String)
95
- when 'static'
96
- key(:static).failure('`static` must be a string') unless values[:static].is_a?(String)
94
+ next unless value
95
+
96
+ klass = Selectors::Extractors::NAME_TO_CLASS[value.to_sym]
97
+ next key(:extractor).failure("unknown extractor: #{value}") unless klass
98
+
99
+ klass::Options.members.each do |field|
100
+ next if RUNTIME_EXTRACTOR_FIELDS.include?(field)
101
+ next if values[field]
102
+
103
+ key(field).failure("`#{field}` is required for extractor `#{value}`")
97
104
  end
98
105
  end
99
106
 
100
107
  rule(:post_process).each do
101
- case (name = value[:name])
102
- when 'gsub'
103
- key(:pattern).failure('`pattern` must be a string') unless value[:pattern].is_a?(String)
104
- key(:replacement).failure('`replacement` must be a string') unless value[:replacement].is_a?(String)
105
- when 'substring'
106
- key(:start).failure('`start` must be an integer') unless value[:start].is_a?(Integer)
107
- key(:end).failure('`end` must be an integer or omitted') if !value[:end].nil? && !value[:end].is_a?(Integer)
108
- when 'template'
109
- key(:string).failure('`string` must be a string') unless value[:string].is_a?(String)
110
- when 'html_to_markdown', 'markdown_to_html', 'parse_time', 'parse_uri', 'sanitize_html'
111
- # nothing to validate
112
- when nil
113
- key(:post_process).failure('Missing post_processor `name`')
114
- else
115
- key(:post_process).failure("Unknown post_processor `name`: #{name}")
108
+ name = value[:name]
109
+ next key(:post_process).failure('Missing post_processor `name`') if name.nil?
110
+
111
+ klass = Selectors::PostProcessors::NAME_TO_CLASS[name.to_sym]
112
+ next key(:post_process).failure("Unknown post_processor `name`: #{name}") unless klass
113
+ next unless klass.const_defined?(:Options)
114
+
115
+ post_process_option_type_errors(klass, value).each do |field, message|
116
+ key(field).failure(message)
116
117
  end
117
118
  end
119
+
120
+ private
121
+
122
+ def post_process_option_type_errors(klass, value)
123
+ required_option_type_errors(klass, value) + optional_option_type_errors(klass, value)
124
+ end
125
+
126
+ def required_option_type_errors(klass, value)
127
+ option_types = klass.const_defined?(:OPTION_TYPES) ? klass::OPTION_TYPES : {}
128
+
129
+ klass::Options.members.filter_map do |field|
130
+ expected = option_types.fetch(field, String)
131
+ next if value[field].is_a?(expected)
132
+
133
+ [field, option_type_failure(field, expected)]
134
+ end
135
+ end
136
+
137
+ def optional_option_type_errors(klass, value)
138
+ return [] unless klass.const_defined?(:OPTIONAL_OPTION_TYPES)
139
+
140
+ klass::OPTIONAL_OPTION_TYPES.filter_map do |field, expected|
141
+ actual = value[field]
142
+ next if actual.nil? || actual.is_a?(expected)
143
+
144
+ [field, option_type_failure(field, expected, optional: true)]
145
+ end
146
+ end
147
+
148
+ def option_type_failure(field, expected, optional: false)
149
+ label = {
150
+ Integer => 'an integer',
151
+ String => 'a string',
152
+ Hash => 'a hash'
153
+ }.fetch(expected) { "a #{expected}" }
154
+ suffix = optional ? ' or omitted' : ''
155
+ "`#{field}` must be #{label}#{suffix}"
156
+ end
118
157
  end
119
158
 
120
159
  ##
@@ -15,6 +15,11 @@ module Html2rss
15
15
  LANGUAGE_FORMAT_REGEX = /\A[a-z]{2}(-[A-Z]{2})?\z/
16
16
  # Baseline strategy-plan enum (:auto plus concrete RequestService strategies).
17
17
  BASE_STRATEGY_OPTIONS = Html2rss::FeedPipeline::StrategyPlan.accepted_names.freeze
18
+ # Controlled vocabulary for catalog-only `directory.topics` (not RSS channel fields).
19
+ DIRECTORY_TOPICS = %w[
20
+ sports energy tech science news entertainment jobs finance
21
+ security travel environment consumer civic product research
22
+ ].freeze
18
23
 
19
24
  # Contract for the top-level `channel` section.
20
25
  ChannelConfig = Dry::Schema.Params do
@@ -28,6 +33,11 @@ module Html2rss
28
33
  optional(:image).maybe(:string, format?: URI_REGEXP)
29
34
  end
30
35
 
36
+ # Contract for catalog-only `directory` metadata (topics for feed directories).
37
+ DirectoryConfig = Dry::Schema.Params do
38
+ optional(:topics).value(:array, min_size?: 1).each(:string, included_in?: DIRECTORY_TOPICS)
39
+ end
40
+
31
41
  # Contract for a stylesheet entry in `stylesheets`.
32
42
  StylesheetConfig = Dry::Schema.Params do
33
43
  required(:href).filled(:string)
@@ -35,47 +45,28 @@ module Html2rss
35
45
  optional(:media).maybe(:string)
36
46
  end
37
47
 
38
- # Contract for Browserless click-preload options.
39
- BrowserlessPreloadClickSelectorConfig = Dry::Schema.Params do
40
- required(:selector).filled(:string)
41
- optional(:max_clicks).filled(:integer, gt?: 0)
42
- optional(:wait_after_ms).filled(:integer, gteq?: 0)
43
- end
44
-
45
- # Contract for Browserless scroll-preload options.
46
- BrowserlessPreloadScrollConfig = Dry::Schema.Params do
47
- optional(:iterations).filled(:integer, gt?: 0)
48
- optional(:wait_after_ms).filled(:integer, gteq?: 0)
49
- end
50
-
51
- # Contract for Browserless preload orchestration options.
52
- BrowserlessPreloadConfig = Dry::Schema.Params do
53
- optional(:wait_after_ms).filled(:integer, gteq?: 0)
54
- optional(:click_selectors).array(BrowserlessPreloadClickSelectorConfig)
55
- optional(:scroll_down).hash(BrowserlessPreloadScrollConfig)
56
- end
57
-
58
- # Contract for Browserless-specific request options.
59
- BrowserlessRequestConfig = Dry::Schema.Params do
60
- optional(:preload).hash(BrowserlessPreloadConfig)
61
- end
62
-
63
48
  # Contract for Botasaurus-specific request options.
64
49
  BotasaurusRequestConfig = Dry::Schema.Params do
65
50
  config.validate_keys = true
66
51
 
67
- optional(:navigation_mode).filled(:string, included_in?: %w[auto get google_get google_get_bypass])
52
+ optional(:execution_mode).filled(:string, included_in?: %w[auto request browser])
53
+ optional(:navigation_mode).filled(:string, included_in?: %w[auto get google_get google_get_bypass organic_get])
68
54
  optional(:max_retries).filled(:integer, gteq?: 0, lteq?: 3)
69
55
  optional(:wait_for_selector).maybe(:string)
70
56
  optional(:wait_timeout_seconds).filled(:integer, gt?: 0)
57
+ optional(:scroll).filled(:bool)
58
+ optional(:scroll_to_bottom).filled(:bool)
71
59
  optional(:block_images).filled(:bool)
72
60
  optional(:block_images_and_css).filled(:bool)
61
+ optional(:block_trackers).filled(:bool)
73
62
  optional(:wait_for_complete_page_load).filled(:bool)
74
63
  optional(:headless).filled(:bool)
75
64
  optional(:proxy).filled(:string)
76
65
  optional(:user_agent).filled(:string)
77
66
  optional(:window_size).value(:array, min_size?: 2, max_size?: 2).each(:integer, gt?: 0)
78
67
  optional(:lang).filled(:string)
68
+ optional(:cookies).hash
69
+ optional(:headers).hash
79
70
  end
80
71
 
81
72
  # Contract for the top-level `request` section.
@@ -83,7 +74,6 @@ module Html2rss
83
74
  optional(:max_redirects).filled(:integer, gteq?: 0)
84
75
  optional(:max_requests).filled(:integer, gt?: 0)
85
76
  optional(:total_timeout_seconds).filled(:integer, gt?: 0)
86
- optional(:browserless).hash(BrowserlessRequestConfig)
87
77
  optional(:botasaurus).hash(BotasaurusRequestConfig)
88
78
  optional(:local_file_path).filled(:string)
89
79
  end
@@ -91,6 +81,7 @@ module Html2rss
91
81
  params do
92
82
  optional(:strategy).filled(:symbol)
93
83
  required(:channel).hash(ChannelConfig)
84
+ optional(:directory).hash(DirectoryConfig)
94
85
  optional(:headers).hash
95
86
  optional(:stylesheets).array(StylesheetConfig)
96
87
  optional(:auto_source).hash(Config::AutoSourceContract)
@@ -6,16 +6,24 @@ module Html2rss
6
6
 
7
7
  # Raised when auto fallback exhausts all concrete tiers and extractors find no feed items.
8
8
  class NoFeedItemsExtracted < Error
9
+ # Categories that append shared surface guidance to the empty-feed message.
10
+ SURFACE_HINT_CATEGORIES = %i[app_shell blocked_surface].freeze
11
+
9
12
  ##
10
13
  # @param attempts [Array<Hash{Symbol => Object}>] tier attempt diagnostics
11
- def initialize(attempts:)
14
+ # @param surface_category [Symbol, nil] optional Scraper surface classification
15
+ def initialize(attempts:, surface_category: nil)
12
16
  @attempts = attempts
17
+ @surface_category = surface_category
13
18
  super(build_message)
14
19
  end
15
20
 
16
21
  # @return [Array<Hash{Symbol => Object}>] tier attempt diagnostics
17
22
  attr_reader :attempts
18
23
 
24
+ # @return [Symbol, nil] surface classification when a response body was available
25
+ attr_reader :surface_category
26
+
19
27
  private
20
28
 
21
29
  def build_message
@@ -24,8 +32,12 @@ module Html2rss
24
32
  "#{attempt[:strategy]} (#{details})"
25
33
  end.join(', ')
26
34
 
27
- "No feed items extracted after auto fallback across strategies: #{summaries}. " \
28
- 'Try a more specific listing URL or provide explicit selectors.'
35
+ message = "No feed items extracted after auto fallback across strategies: #{summaries}. " \
36
+ 'Try a more specific listing URL or provide explicit selectors.'
37
+ return message unless SURFACE_HINT_CATEGORIES.include?(surface_category)
38
+
39
+ guidance = AutoSource::Scraper::NoScraperFound::CATEGORY_MESSAGES.fetch(surface_category)
40
+ "#{message} #{guidance}"
29
41
  end
30
42
  end
31
43
  end
@@ -19,18 +19,18 @@ module Html2rss
19
19
  RequestService::RequestBudgetExceeded,
20
20
  RequestService::PrivateNetworkDenied,
21
21
  RequestService::CrossOriginFollowUpDenied,
22
- RequestService::ResponseTooLarge,
23
- RequestService::BrowserlessConfigurationError
22
+ RequestService::ResponseTooLarge
24
23
  ].freeze
25
24
 
26
25
  ##
27
26
  # Mutable run state for one auto-fallback chain: attempts plus selected result.
28
27
  class AttemptState
29
- attr_reader :attempts, :result
28
+ attr_reader :attempts, :result, :last_response
30
29
 
31
30
  def initialize
32
31
  @attempts = []
33
32
  @result = nil
33
+ @last_response = nil
34
34
  end
35
35
 
36
36
  # @return [Boolean] true when a strategy already yielded items
@@ -53,6 +53,12 @@ module Html2rss
53
53
  @attempts << attempt
54
54
  end
55
55
 
56
+ # @param response [RequestService::Response] last response that reached extraction
57
+ # @return [void]
58
+ def remember_response(response)
59
+ @last_response = response
60
+ end
61
+
56
62
  # @param response [RequestService::Response] successful response
57
63
  # @param articles [Array] extracted articles
58
64
  # @param dedup_dropped [Integer] articles removed by deduplication
@@ -92,7 +98,7 @@ module Html2rss
92
98
  state = run_attempts
93
99
  return state.result if state.succeeded?
94
100
 
95
- finalize_failure(attempts: state.attempts)
101
+ finalize_failure(attempts: state.attempts, response: state.last_response)
96
102
  end
97
103
 
98
104
  private
@@ -127,6 +133,7 @@ module Html2rss
127
133
  end
128
134
 
129
135
  def process_response(response:, strategy:, next_strategy:, request_session:, state:)
136
+ state.remember_response(response)
130
137
  articles, dedup_dropped = articles_for(response:, request_session:)
131
138
  items_count = articles.size
132
139
  state.record_items(strategy:, items_count:, transport_meta: response.transport_meta)
@@ -152,8 +159,11 @@ module Html2rss
152
159
  "budget_remaining=#{budget_remaining_label}")
153
160
  end
154
161
 
155
- def finalize_failure(attempts:)
156
- raise NoFeedItemsExtracted.new(attempts:)
162
+ def finalize_failure(attempts:, response:)
163
+ surface_category = response && AutoSource::Scraper.classify_no_scraper_surface(
164
+ response.parsed_body, body: response.body
165
+ )
166
+ raise NoFeedItemsExtracted.new(attempts:, surface_category:)
157
167
  end
158
168
 
159
169
  # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
@@ -6,8 +6,7 @@ module Html2rss
6
6
  # Planner for the runtime request policy and budgets of a feed run.
7
7
  #
8
8
  # HTTP request slots (pagination, document GETs, strategy fallback) are planned
9
- # separately from Browserless preload interaction budget so preload cannot steal
10
- # pagination slots.
9
+ # from configuration options.
11
10
  class RuntimePolicy
12
11
  # Policy plus Budget from one config expansion (shared meters for a feed run).
13
12
  Resources = Data.define(:policy, :budget)
@@ -34,26 +33,18 @@ module Html2rss
34
33
  policy:,
35
34
  budget: RequestService::Budget.new(
36
35
  max_requests: policy.max_requests,
37
- max_interactions: interaction_budget_for(config),
38
36
  total_timeout_seconds: policy.total_timeout_seconds
39
37
  )
40
38
  )
41
39
  end
42
40
 
43
41
  ##
44
- # Builds a Budget with separate request and interaction slot pools.
42
+ # Builds a Budget with request slot pools.
45
43
  #
46
44
  # @param config [Html2rss::Config] validated feed config
47
45
  # @return [Html2rss::RequestService::Budget] shared budget for the feed build
48
46
  def self.budget_for(config) = resources_for(config).budget
49
47
 
50
- ##
51
- # @param config [Html2rss::Config] validated feed config
52
- # @return [Integer] preload interaction slots derived from browserless preload config
53
- def self.interaction_budget_for(config)
54
- browserless_preload_budget_for(config)
55
- end
56
-
57
48
  class << self
58
49
  private
59
50
 
@@ -64,44 +55,13 @@ module Html2rss
64
55
  end
65
56
 
66
57
  # Reserve enough HTTP request slots for the initial request plus predictable
67
- # follow-ups. Preload interactions are planned separately.
58
+ # follow-ups.
68
59
  def baseline_request_budget_for(config)
69
60
  1 +
70
61
  RequestSession::Pager.request_slots_for(config.selectors&.dig(:items, :pagination)) +
71
62
  AutoSource.request_slots_for(config.auto_source) +
72
63
  StrategyPlan.resolve(config.strategy).request_slots
73
64
  end
74
-
75
- def browserless_preload_budget_for(config)
76
- preload = config.request.dig(:browserless, :preload)
77
- return 0 unless preload
78
-
79
- top_level_preload_wait_budget(preload) +
80
- click_selector_preload_budget(preload) +
81
- scroll_preload_budget(preload)
82
- end
83
-
84
- def top_level_preload_wait_budget(preload)
85
- preload[:wait_after_ms] ? 2 : 0
86
- end
87
-
88
- def click_selector_preload_budget(preload)
89
- preload.fetch(:click_selectors, []).sum { preload_action_budget(_1, :max_clicks) }
90
- end
91
-
92
- def scroll_preload_budget(preload)
93
- scroll = preload[:scroll_down]
94
- return 0 unless scroll
95
-
96
- preload_action_budget(scroll, :iterations)
97
- end
98
-
99
- def preload_action_budget(config, count_key)
100
- action_count = config.fetch(count_key, 1)
101
- wait_budget = config[:wait_after_ms] ? action_count : 0
102
-
103
- action_count + wait_budget
104
- end
105
65
  end
106
66
  end
107
67
  end
@@ -81,15 +81,22 @@ module Html2rss
81
81
  request_session = request_session_for(config, strategy:, resources:)
82
82
  response = request_session.fetch_initial_response
83
83
  articles, dedup_dropped = deduplicated_articles(config:, response:, request_session:)
84
- if config.auto_source && articles.empty?
85
- raise NoFeedItemsExtracted.new(attempts: [{ strategy:, items_count: 0, error_class: nil }])
86
- end
84
+ raise_empty_auto_source!(strategy:, response:) if config.auto_source && articles.empty?
87
85
 
88
86
  PipelineOutcome.new(
89
87
  response:, articles:, dedup_dropped:, selected_strategy: nil, attempt_count: 0, strategy_attempts: []
90
88
  )
91
89
  end
92
90
 
91
+ def raise_empty_auto_source!(strategy:, response:)
92
+ raise NoFeedItemsExtracted.new(
93
+ attempts: [{ strategy:, items_count: 0, error_class: nil }],
94
+ surface_category: AutoSource::Scraper.classify_no_scraper_surface(
95
+ response.parsed_body, body: response.body
96
+ )
97
+ )
98
+ end
99
+
93
100
  def run_auto_pipeline(config, resources:)
94
101
  AutoFallback.new(
95
102
  strategies: AutoFallback::CHAIN,
@@ -54,6 +54,23 @@ module Html2rss
54
54
  end
55
55
  end
56
56
 
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
+
57
74
  # Validates that hash keys are symbols.
58
75
  #
59
76
  # @param value [Object] candidate hash container whose keys must be symbols
@@ -80,19 +80,48 @@ module Html2rss
80
80
  Url.from_relative("##{id}", @base_url) if id
81
81
  end
82
82
 
83
- def extract_title # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
84
- source = heading || @selected_anchor
85
- title_text = if source
86
- source.visible_text
87
- elsif @fallback_anchorless && @selected_anchor.nil?
88
- first_nonempty_own_text
89
- end
83
+ def extract_title
84
+ title_text = title_from_in_card_sources
90
85
  return unless title_text
91
86
 
92
87
  kicker = kicker_node&.visible_text.to_s.strip
93
88
  kicker && !kicker.empty? && !title_text.include?(kicker) ? "#{kicker}: #{title_text}" : title_text
94
89
  end
95
90
 
91
+ # Prefer heading over credit-shaped visible text; never emit agency-only titles.
92
+ #
93
+ # @return [String, nil]
94
+ def title_from_in_card_sources
95
+ title_candidates.find { |text| !credit_shaped_title?(text) }
96
+ end
97
+
98
+ # @return [Array<String>] ordered in-card title candidates (heading → anchor → fallback)
99
+ def title_candidates
100
+ [].tap do |candidates|
101
+ add_title_candidate!(candidates, heading&.visible_text)
102
+ add_title_candidate!(candidates, @selected_anchor&.visible_text)
103
+ next unless candidates.empty? && @fallback_anchorless && @selected_anchor.nil?
104
+
105
+ add_title_candidate!(candidates, first_nonempty_own_text)
106
+ end
107
+ end
108
+
109
+ # @param candidates [Array<String>]
110
+ # @param text [String, nil]
111
+ # @return [void]
112
+ def add_title_candidate!(candidates, text)
113
+ stripped = text.to_s.strip
114
+ return if stripped.empty? || candidates.include?(stripped)
115
+
116
+ candidates << stripped
117
+ end
118
+
119
+ # @param text [String]
120
+ # @return [Boolean]
121
+ def credit_shaped_title?(text)
122
+ AutoSource::Cleanup::CREDIT_TITLE.match?(text)
123
+ end
124
+
96
125
  def heading
97
126
  @heading ||= begin
98
127
  tags = @root.find_all(&:heading?)
@@ -9,10 +9,8 @@ module Html2rss
9
9
  ICON_NAMES = %i[img svg].freeze
10
10
 
11
11
  # @param link_resolver [LinkResolver]
12
- # @param index [SST::Index]
13
- def initialize(link_resolver:, index:)
12
+ def initialize(link_resolver:)
14
13
  @link_resolver = link_resolver
15
- @index = index
16
14
  end
17
15
 
18
16
  ##
@@ -21,8 +19,11 @@ module Html2rss
21
19
  # @param anchor [SST::Node, nil]
22
20
  # @param container [SST::Node, nil]
23
21
  # @param heading_anchor [Boolean]
22
+ # @param utility_landmark_ancestor [Boolean] pre-computed by Segmenter tree walk
24
23
  # @return [Boolean]
25
- def noise_anchor?(text:, destination_facts:, anchor: nil, container: nil, heading_anchor: false) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
24
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/ParameterLists, Metrics/PerceivedComplexity, Lint/UnusedMethodArgument
25
+ def noise_anchor?(text:, destination_facts:, anchor: nil, container: nil,
26
+ heading_anchor: false, utility_landmark_ancestor: false)
26
27
  return true unless destination_facts
27
28
 
28
29
  destination_facts.taxonomy_path ||
@@ -32,8 +33,9 @@ module Html2rss
32
33
  (@link_resolver.utility_text?(text) && destination_facts.vanity_path) ||
33
34
  utility_text_chrome?(text, destination_facts, heading_anchor:) ||
34
35
  icon_only_anchor?(anchor, text) ||
35
- utility_landmark_ancestor?(anchor, container)
36
+ utility_landmark_ancestor
36
37
  end
38
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/ParameterLists, Metrics/PerceivedComplexity, Lint/UnusedMethodArgument
37
39
 
38
40
  private
39
41
 
@@ -61,19 +63,6 @@ module Html2rss
61
63
 
62
64
  !!anchor.find { |n| ICON_NAMES.include?(n.name) }
63
65
  end
64
-
65
- def utility_landmark_ancestor?(anchor, container) # rubocop:disable Metrics/CyclomaticComplexity
66
- return false unless anchor && container
67
-
68
- curr = @index.parent_of(anchor)
69
- while curr && curr.name != :html
70
- return true if curr != container && curr.utility_landmark?
71
- return false if curr.equal?(container)
72
-
73
- curr = @index.parent_of(curr)
74
- end
75
- false
76
- end
77
66
  end
78
67
  end
79
68
  end
@@ -48,6 +48,9 @@ module Html2rss
48
48
  sinscrire inscription compte s-abonner saboner lettre-information confidentialite
49
49
  mentions-legales cgu menu sidebar widget social modal popup banner promo ad ads
50
50
  related recommendation recommendations pagination pager
51
+ dating jobs job career careers deals deal shopping shop trading broker
52
+ versicherung tierversicherung insurance vergleich comparison
53
+ partnerboerse singleboerse krypto crypto
51
54
  ] + SOFT_UTILITY.to_a
52
55
  ).to_set.freeze
53
56
  {