html2rss 0.26.0 → 0.27.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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +41 -18
  3. data/html2rss.gemspec +1 -1
  4. data/lib/html2rss/auto_source/README.md +57 -0
  5. data/lib/html2rss/auto_source/cleanup.rb +121 -56
  6. data/lib/html2rss/auto_source/scraper.rb +13 -0
  7. data/lib/html2rss/auto_source.rb +34 -8
  8. data/lib/html2rss/capture/README.md +61 -0
  9. data/lib/html2rss/capture.rb +120 -117
  10. data/lib/html2rss/cli.rb +35 -17
  11. data/lib/html2rss/config/schema.rb +12 -0
  12. data/lib/html2rss/config/validator.rb +31 -8
  13. data/lib/html2rss/config.rb +28 -0
  14. data/lib/html2rss/error.rb +24 -6
  15. data/lib/html2rss/feed_pipeline/README.md +42 -0
  16. data/lib/html2rss/feed_pipeline/auto_fallback.rb +22 -9
  17. data/lib/html2rss/feed_pipeline/strategy_plan.rb +11 -0
  18. data/lib/html2rss/feed_pipeline.rb +30 -12
  19. data/lib/html2rss/html/article_extractor/category_extractor.rb +36 -22
  20. data/lib/html2rss/html/article_extractor/date_extractor.rb +5 -3
  21. data/lib/html2rss/html/article_extractor.rb +95 -17
  22. data/lib/html2rss/html/article_rules/category.rb +28 -11
  23. data/lib/html2rss/html/article_rules/date.rb +60 -6
  24. data/lib/html2rss/html/article_rules/description.rb +122 -0
  25. data/lib/html2rss/html/card_walk.rb +42 -0
  26. data/lib/html2rss/html/feed_link.rb +34 -0
  27. data/lib/html2rss/html/navigator.rb +18 -0
  28. data/lib/html2rss/html/sst_article_extractor.rb +119 -32
  29. data/lib/html2rss/link_destination/path_classifier.rb +49 -35
  30. data/lib/html2rss/mcp/config_argument.rb +42 -0
  31. data/lib/html2rss/mcp/contract.rb +173 -0
  32. data/lib/html2rss/mcp/inspect.rb +241 -0
  33. data/lib/html2rss/mcp/outcome.rb +188 -0
  34. data/lib/html2rss/mcp/server.rb +253 -409
  35. data/lib/html2rss/request_service/botasaurus_contract.rb +193 -102
  36. data/lib/html2rss/request_service/botasaurus_strategy.rb +15 -8
  37. data/lib/html2rss/request_service/compressed_body.rb +109 -0
  38. data/lib/html2rss/request_service/faraday_strategy.rb +3 -1
  39. data/lib/html2rss/request_service/policy.rb +1 -1
  40. data/lib/html2rss/request_service/response.rb +57 -6
  41. data/lib/html2rss/request_service.rb +6 -1
  42. data/lib/html2rss/selectors.rb +2 -1
  43. data/lib/html2rss/status.rb +27 -11
  44. data/lib/html2rss/url.rb +20 -0
  45. data/lib/html2rss/version.rb +1 -1
  46. data/lib/html2rss.rb +30 -6
  47. data/schema/html2rss-config.schema.json +26 -15
  48. metadata +15 -4
@@ -6,7 +6,7 @@ module Html2rss
6
6
  #
7
7
  # Owned by {FeedPipeline}; invoked only after {StrategyPlan} resolves +:auto+.
8
8
  # Hosted by the pipeline instance: session + extract call back into FeedPipeline.
9
- class AutoFallback
9
+ class AutoFallback # rubocop:disable Metrics/ClassLength -- attempt state + logging stay co-located
10
10
  # Ordered list of concrete request strategies attempted by the :auto plan.
11
11
  CHAIN = %i[faraday botasaurus].freeze
12
12
 
@@ -15,7 +15,6 @@ module Html2rss
15
15
  RequestService::UnknownStrategy,
16
16
  RequestService::InvalidUrl,
17
17
  RequestService::UnsupportedUrlScheme,
18
- RequestService::UnsupportedResponseContentType,
19
18
  RequestService::RequestBudgetExceeded,
20
19
  RequestService::PrivateNetworkDenied,
21
20
  RequestService::CrossOriginFollowUpDenied,
@@ -64,17 +63,22 @@ module Html2rss
64
63
  # @param dedup_dropped [Integer] articles removed by deduplication
65
64
  # @param selected_strategy [Symbol] concrete strategy that produced items
66
65
  # @param attempt_count [Integer] number of attempts recorded for this chain
66
+ # @param admission_drops [Hash{String => Integer}] Cleanup drop tallies
67
67
  # @return [void]
68
- def succeed!(response:, articles:, dedup_dropped:, selected_strategy:, attempt_count:)
68
+ # rubocop:disable Metrics/ParameterLists -- PipelineOutcome kwargs stay co-located
69
+ def succeed!(response:, articles:, dedup_dropped:, selected_strategy:, attempt_count:,
70
+ admission_drops: {})
69
71
  @result = PipelineOutcome.new(
70
72
  response:,
71
73
  articles:,
72
74
  dedup_dropped:,
73
75
  selected_strategy:,
74
76
  attempt_count:,
75
- strategy_attempts: attempts
77
+ strategy_attempts: attempts,
78
+ admission_drops:
76
79
  )
77
80
  end
81
+ # rubocop:enable Metrics/ParameterLists
78
82
  end
79
83
 
80
84
  ##
@@ -120,6 +124,9 @@ module Html2rss
120
124
  return unless response
121
125
 
122
126
  process_response(response:, strategy:, next_strategy:, request_session:, state:)
127
+ rescue RequestService::UnsupportedResponseContentType => error
128
+ state.record_error(strategy:, error:)
129
+ log_fallback_error(strategy:, next_strategy:, error:, request_session:) if next_strategy
123
130
  end
124
131
 
125
132
  def fetch_response(request_session:, strategy:, next_strategy:, state:)
@@ -132,15 +139,18 @@ module Html2rss
132
139
  nil
133
140
  end
134
141
 
135
- def process_response(response:, strategy:, next_strategy:, request_session:, state:)
142
+ def process_response(response:, strategy:, next_strategy:, request_session:, state:) # rubocop:disable Metrics/MethodLength -- extract + success path
143
+ articles, dedup_dropped, admission_drops = articles_for(response:, request_session:)
136
144
  state.remember_response(response)
137
- articles, dedup_dropped = articles_for(response:, request_session:)
138
145
  items_count = articles.size
139
146
  state.record_items(strategy:, items_count:, transport_meta: response.transport_meta)
140
147
  Log.debug("#{self.class}: strategy=#{strategy} items=#{items_count} " \
141
148
  "host=#{response.url.host} elapsed=#{format('%.3f', budget.elapsed_seconds)}s " \
142
149
  "budget_remaining=#{budget_remaining_label}")
143
- return record_success(response:, strategy:, articles:, dedup_dropped:, state:) if items_count.positive?
150
+ if items_count.positive?
151
+ return record_success(response:, strategy:, articles:, dedup_dropped:, admission_drops:,
152
+ state:)
153
+ end
144
154
 
145
155
  log_info_fallback_zero_items(strategy:, next_strategy:, response:) if next_strategy
146
156
  end
@@ -149,15 +159,18 @@ module Html2rss
149
159
  pipeline.deduplicated_articles(config:, response:, request_session:)
150
160
  end
151
161
 
152
- def record_success(response:, strategy:, articles:, dedup_dropped:, state:)
162
+ # rubocop:disable Metrics/ParameterLists -- success kwargs match PipelineOutcome
163
+ def record_success(response:, strategy:, articles:, dedup_dropped:, admission_drops:, state:)
153
164
  attempt_count = state.attempts.size
154
- state.succeed!(response:, articles:, dedup_dropped:, selected_strategy: strategy, attempt_count:)
165
+ state.succeed!(response:, articles:, dedup_dropped:, selected_strategy: strategy,
166
+ attempt_count:, admission_drops:)
155
167
  return unless attempt_count > 1
156
168
 
157
169
  Log.info("#{self.class}: auto selected strategy=#{strategy} after attempts=#{attempt_count} " \
158
170
  "host=#{response.url.host} elapsed=#{format('%.3f', budget.elapsed_seconds)}s " \
159
171
  "budget_remaining=#{budget_remaining_label}")
160
172
  end
173
+ # rubocop:enable Metrics/ParameterLists
161
174
 
162
175
  def finalize_failure(attempts:, response:)
163
176
  surface_category = response && AutoSource::Scraper.classify_no_scraper_surface(
@@ -41,6 +41,17 @@ module Html2rss
41
41
  Concrete.new(strategy: normalized)
42
42
  end
43
43
 
44
+ ##
45
+ # Cheap single-request diagnostic default: +:auto+ → +:faraday+.
46
+ # Does not run {AutoFallback}. Prefer {resolve} for scrape/capture feeds.
47
+ #
48
+ # @param name [Symbol, String, nil] plan name (+nil+ → +:auto+)
49
+ # @return [Symbol] concrete {RequestService} strategy
50
+ def concrete_for_diagnostic(name = AUTO_NAME)
51
+ plan = resolve(name || AUTO_NAME)
52
+ plan.is_a?(Auto) ? RequestService.default_strategy_name : plan.strategy
53
+ end
54
+
44
55
  ##
45
56
  # @param name [Symbol, String, nil] candidate plan name
46
57
  # @return [Boolean] whether +name+ is a valid feed-level strategy plan
@@ -3,13 +3,16 @@
3
3
  module Html2rss
4
4
  ##
5
5
  # Builds feeds from validated config through request, extraction, and rendering stages.
6
- class FeedPipeline
6
+ #
7
+ # {include:file:lib/html2rss/feed_pipeline/README.md}
8
+ class FeedPipeline # rubocop:disable Metrics/ClassLength -- outcome + collect seams stay co-located
7
9
  # Scrape-finished facts after request + extraction + dedup (before Channel/Status materialize).
8
10
  # selected_strategy: set on :auto success; nil otherwise.
9
11
  # attempt_count: auto attempts attempted; 0 outside :auto.
10
12
  # strategy_attempts: auto attempt hashes (with optional transport_meta); empty outside :auto.
11
13
  PipelineOutcome = Data.define(
12
- :response, :articles, :dedup_dropped, :selected_strategy, :attempt_count, :strategy_attempts
14
+ :response, :articles, :dedup_dropped, :selected_strategy, :attempt_count, :strategy_attempts,
15
+ :admission_drops
13
16
  )
14
17
 
15
18
  ##
@@ -18,6 +21,16 @@ module Html2rss
18
21
  @raw_config = raw_config
19
22
  end
20
23
 
24
+ ##
25
+ # Runs the pipeline once and returns scrape-finished outcome (before Channel/Status).
26
+ # Used by {Capture} which needs the response body for SST selector derivation.
27
+ #
28
+ # @return [PipelineOutcome]
29
+ def to_outcome
30
+ config = Config.from_hash(raw_config, params: raw_config[:params])
31
+ pipeline_outcome_for(config)
32
+ end
33
+
21
34
  ##
22
35
  # Runs the pipeline once and returns an opaque, Marshal-cacheable result.
23
36
  #
@@ -32,7 +45,8 @@ module Html2rss
32
45
  dedup_dropped: outcome.dedup_dropped,
33
46
  selected_strategy: outcome.selected_strategy,
34
47
  attempt_count: outcome.attempt_count,
35
- strategy_attempts: outcome.strategy_attempts
48
+ strategy_attempts: outcome.strategy_attempts,
49
+ admission_drops: outcome.admission_drops
36
50
  )
37
51
  FeedResult.new(channel:, articles: outcome.articles, status:, stylesheets: config.stylesheets)
38
52
  end
@@ -56,11 +70,11 @@ module Html2rss
56
70
  # @param config [Html2rss::Config]
57
71
  # @param response [Html2rss::RequestService::Response]
58
72
  # @param request_session [Html2rss::RequestSession]
59
- # @return [Array(Array<Html2rss::Article>, Integer)] unique articles and drop count
73
+ # @return [Array(Array<Html2rss::Article>, Integer, Hash)] unique articles, dedup drops, admission drops
60
74
  def deduplicated_articles(config:, response:, request_session:)
61
- collected = collect_articles(config:, response:, request_session:)
75
+ collected, admission_drops = collect_articles(config:, response:, request_session:)
62
76
  unique = Article::Deduplicator.new(collected).call
63
- [unique, collected.size - unique.size]
77
+ [unique, collected.size - unique.size, admission_drops]
64
78
  end
65
79
 
66
80
  private
@@ -80,11 +94,12 @@ module Html2rss
80
94
  def run_pipeline_for_strategy(config, strategy:, resources:)
81
95
  request_session = request_session_for(config, strategy:, resources:)
82
96
  response = request_session.fetch_initial_response
83
- articles, dedup_dropped = deduplicated_articles(config:, response:, request_session:)
97
+ articles, dedup_dropped, admission_drops = deduplicated_articles(config:, response:, request_session:)
84
98
  raise_empty_auto_source!(strategy:, response:) if config.auto_source && articles.empty?
85
99
 
86
100
  PipelineOutcome.new(
87
- response:, articles:, dedup_dropped:, selected_strategy: nil, attempt_count: 0, strategy_attempts: []
101
+ response:, articles:, dedup_dropped:, selected_strategy: nil, attempt_count: 0,
102
+ strategy_attempts: [], admission_drops:
88
103
  )
89
104
  end
90
105
 
@@ -108,8 +123,9 @@ module Html2rss
108
123
  end
109
124
 
110
125
  def collect_articles(config:, response:, request_session:)
111
- selector_articles(config:, response:, request_session:) +
112
- auto_source_articles(config:, response:, request_session:)
126
+ selector = selector_articles(config:, response:, request_session:)
127
+ auto, admission_drops = auto_source_articles(config:, response:, request_session:)
128
+ [selector + auto, admission_drops]
113
129
  end
114
130
 
115
131
  # rubocop:disable Metrics/MethodLength
@@ -132,10 +148,12 @@ module Html2rss
132
148
  end
133
149
  # rubocop:enable Metrics/MethodLength
134
150
 
151
+ # @return [Array(Array<Html2rss::Article>, Hash{String => Integer})]
135
152
  def auto_source_articles(config:, response:, request_session:)
136
- return [] unless (auto_source = config.auto_source)
153
+ return [[], {}] unless (auto_source = config.auto_source)
137
154
 
138
- AutoSource.new(response, auto_source, request_session:).articles
155
+ source = AutoSource.new(response, auto_source, request_session:)
156
+ [source.articles, source.admission_drops]
139
157
  end
140
158
  end
141
159
  end
@@ -9,24 +9,26 @@ module Html2rss
9
9
  class CategoryExtractor
10
10
  # Shared category vocabulary (owned by {ArticleRules::Category}).
11
11
  CATEGORY_TERMS = ArticleRules::Category::CATEGORY_TERMS
12
- # Shared category attribute/class pattern (owned by {ArticleRules::Category}).
13
- CATEGORY_ATTR_PATTERN = ArticleRules::Category::CATEGORY_ATTR_PATTERN
14
12
 
15
13
  # CSS selectors to find elements with category-related class names or data attributes
16
14
  CATEGORY_SELECTORS = CATEGORY_TERMS.flat_map do |term|
17
15
  ["[class*=\"#{term}\"]", "[data-#{term}]", "[#{term}]"]
18
16
  end.freeze
19
17
 
18
+ # Nested blocks that mean a "category" node is actually a content container.
19
+ CONTAINER_CHILD_SELECTOR = 'p, article, section, h1, h2, h3, h4, h5, h6'
20
+
20
21
  ##
21
22
  # Extracts categories from the given article tag by looking for elements
22
23
  # with class names containing common category-related terms.
23
24
  #
24
25
  # @param article_tag [Nokogiri::XML::Element] The article element to extract categories from
26
+ # @param title [String, nil] article title used to reject title-echo values
25
27
  # @return [Array<String>] Array of category strings, empty if none found
26
- def self.call(article_tag)
28
+ def self.call(article_tag, title: nil)
27
29
  return [] unless article_tag
28
30
 
29
- extract_all_categories(article_tag)
31
+ extract_all_categories(article_tag, title:)
30
32
  .map(&:strip)
31
33
  .reject(&:empty?)
32
34
  end
@@ -35,12 +37,18 @@ module Html2rss
35
37
  # Optimized single DOM traversal that extracts all category types.
36
38
  #
37
39
  # @param article_tag [Nokogiri::XML::Element] The article element
40
+ # @param title [String, nil]
38
41
  # @return [Set<String>] Set of category strings
39
- def self.extract_all_categories(article_tag)
42
+ def self.extract_all_categories(article_tag, title: nil)
40
43
  Set.new.tap do |categories|
44
+ extract_element_data_categories!(categories, article_tag, title:)
41
45
  article_tag.css(CATEGORY_SELECTORS.join(',')).each do |element|
42
- extract_text_categories!(categories, element) if ArticleRules::Category.class_match?(element['class'])
43
- extract_element_data_categories!(categories, element)
46
+ next if element == article_tag
47
+
48
+ if ArticleRules::Category.class_match?(element['class'])
49
+ extract_text_categories!(categories, element, title:)
50
+ end
51
+ extract_element_data_categories!(categories, element, title:)
44
52
  end
45
53
  end
46
54
  end
@@ -50,12 +58,13 @@ module Html2rss
50
58
  #
51
59
  # @param categories [Set<String>] Accumulator set
52
60
  # @param element [Nokogiri::XML::Element] metadata element that may contain category links
61
+ # @param title [String, nil]
53
62
  # @return [void]
54
- def self.extract_element_data_categories!(categories, element)
63
+ def self.extract_element_data_categories!(categories, element, title: nil)
55
64
  element.attributes.each_value do |attr|
56
65
  next unless ArticleRules::Category.attr_name_match?(attr.name)
57
66
 
58
- ArticleRules::Category.add_text!(categories, attr.value)
67
+ ArticleRules::Category.add_text!(categories, attr.value, title:)
59
68
  end
60
69
  end
61
70
 
@@ -64,20 +73,20 @@ module Html2rss
64
73
  #
65
74
  # @param categories [Set<String>] Accumulator set
66
75
  # @param element [Nokogiri::XML::Element] metadata element whose text may contain delimiters
76
+ # @param title [String, nil]
67
77
  # @return [void]
68
- def self.extract_text_categories!(categories, element)
69
- if element.name == 'a'
70
- add_text_to_categories!(categories, element)
71
- return
72
- end
73
-
74
- anchors = element.css('a')
78
+ def self.extract_text_categories!(categories, element, title: nil)
79
+ return if category_container?(element)
75
80
 
81
+ anchors = element.name == 'a' ? [element] : element.css('a').to_a
76
82
  if anchors.any?
77
- anchors.each { |node| add_text_to_categories!(categories, node) }
78
- else
79
- ArticleRules::Category.add_split_text!(categories, element.text)
83
+ anchors.each { |node| add_text_to_categories!(categories, node, title:) }
84
+ return
80
85
  end
86
+
87
+ ArticleRules::Category.add_split_text!(
88
+ categories, Navigator.extract_visible_text(element), title:
89
+ )
81
90
  end
82
91
 
83
92
  ##
@@ -85,12 +94,17 @@ module Html2rss
85
94
  #
86
95
  # @param categories [Set<String>] Accumulator set
87
96
  # @param element [Nokogiri::XML::Element] The element to extract text from
97
+ # @param title [String, nil]
88
98
  # @return [void]
89
- def self.add_text_to_categories!(categories, element)
90
- ArticleRules::Category.add_text!(categories, Navigator.extract_visible_text(element))
99
+ def self.add_text_to_categories!(categories, element, title: nil)
100
+ ArticleRules::Category.add_text!(categories, Navigator.extract_visible_text(element), title:)
101
+ end
102
+
103
+ def self.category_container?(element)
104
+ element.at_css(CONTAINER_CHILD_SELECTOR)
91
105
  end
92
106
 
93
- private_class_method :add_text_to_categories!
107
+ private_class_method :add_text_to_categories!, :category_container?
94
108
  end
95
109
  end
96
110
  end
@@ -6,10 +6,12 @@ module Html2rss
6
6
  # Extracts the earliest date from an article_tag.
7
7
  class DateExtractor
8
8
  # @param article_tag [Nokogiri::XML::Element] article container node
9
+ # @param leftover_lines [Array<String>] leftover visible lines (already split)
10
+ # @param time_zone [String] channel time zone for naive values
9
11
  # @return [DateTime, nil]
10
- def self.call(article_tag)
11
- datetimes = article_tag.css('[datetime]').map { |tag| tag['datetime'] }
12
- ArticleRules::Date.earliest(datetimes)
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'] } : []
14
+ ArticleRules::Date.earliest(datetimes, leftover_lines:, time_zone:)
13
15
  end
14
16
  end
15
17
  end
@@ -5,6 +5,7 @@ module Html2rss
5
5
  ##
6
6
  # ArticleExtractor is responsible for extracting details (headline, url, images, etc.)
7
7
  # from an article_tag DOM node. DOM chrome helpers live on {Navigator}.
8
+ # rubocop:disable Metrics/ClassLength -- leftover re-extract stays with field extractors
8
9
  class ArticleExtractor
9
10
  class << self
10
11
  ##
@@ -14,9 +15,10 @@ module Html2rss
14
15
  # @param base_url [String, Html2rss::Url] base url used to resolve relative links
15
16
  # @param selected_anchor [Nokogiri::XML::Node, nil] explicit primary anchor for the container
16
17
  # @param fallback_anchorless [Boolean] whether to fall back to anchorless extraction
18
+ # @param time_zone [String] channel time zone for naive leftover dates
17
19
  # @return [Hash{Symbol => Object}] extracted article attributes
18
- def call(article_tag, base_url:, selected_anchor: nil, fallback_anchorless: false)
19
- new(article_tag, base_url:, selected_anchor:, fallback_anchorless:).call
20
+ def call(article_tag, base_url:, selected_anchor: nil, fallback_anchorless: false, time_zone: 'UTC')
21
+ new(article_tag, base_url:, selected_anchor:, fallback_anchorless:, time_zone:).call
20
22
  end
21
23
  end
22
24
 
@@ -25,32 +27,38 @@ module Html2rss
25
27
  # @param base_url [String, Html2rss::Url] base url used to resolve relative links
26
28
  # @param selected_anchor [Nokogiri::XML::Node, nil] explicit primary anchor for the container
27
29
  # @param fallback_anchorless [Boolean] whether to fall back to anchorless extraction
28
- def initialize(article_tag, base_url:, selected_anchor: nil, fallback_anchorless: false)
30
+ # @param time_zone [String] channel time zone for naive leftover dates
31
+ def initialize(article_tag, base_url:, selected_anchor: nil, fallback_anchorless: false, time_zone: 'UTC')
29
32
  raise ArgumentError, 'article_tag is required' unless article_tag
30
33
 
31
34
  @article_tag = article_tag
32
35
  @base_url = base_url
33
36
  @selected_anchor = selected_anchor
34
37
  @fallback_anchorless = fallback_anchorless
38
+ @time_zone = time_zone
35
39
  end
36
40
 
37
41
  # @return [Hash{Symbol => Object}] extracted article attributes
38
- def call
42
+ def call # rubocop:disable Metrics/MethodLength
43
+ title = extract_title
44
+ lines = leftover_lines
45
+ published_at = extract_published_at(lines)
46
+ source, lines, published_at = parent_card_fields(title, lines, published_at)
39
47
  {
40
- title: extract_title,
48
+ title:,
41
49
  url: extract_url,
42
50
  image: extract_image,
43
- description: extract_description,
51
+ description: ArticleRules::Description.from_lines(lines, title:),
44
52
  id: generate_id,
45
- published_at: extract_published_at,
53
+ published_at:,
46
54
  enclosures: extract_enclosures,
47
- categories: extract_categories
55
+ categories: CategoryExtractor.call(source, title:)
48
56
  }
49
57
  end
50
58
 
51
59
  private
52
60
 
53
- attr_reader :article_tag, :base_url, :selected_anchor
61
+ attr_reader :article_tag, :base_url, :selected_anchor, :time_zone
54
62
 
55
63
  def extract_url
56
64
  @extract_url ||= begin
@@ -106,13 +114,77 @@ module Html2rss
106
114
  end
107
115
  end
108
116
 
109
- def extract_description
110
- exclude = [heading, selected_anchor, kicker_node].compact
111
- description = Navigator.extract_visible_text(article_tag, exclude_nodes: exclude)
112
- return if description.nil?
117
+ def leftover_lines
118
+ leftover_lines_from(article_tag)
119
+ end
120
+
121
+ def leftover_lines_from(node)
122
+ ArticleRules::Description.lines_from(
123
+ Navigator.extract_visible_text(node, exclude_nodes: leftover_exclude_nodes_for(node))
124
+ )
125
+ end
126
+
127
+ def leftover_exclude_nodes_for(node)
128
+ times = node.respond_to?(:css) ? node.css('time') : []
129
+ [heading, selected_anchor, kicker_node, *times].compact
130
+ end
131
+
132
+ def heading_or_anchor_item?
133
+ heading_item? || article_tag.name.to_s == 'a'
134
+ end
135
+
136
+ def heading_item?
137
+ Navigator::HEADING_TAGS.include?(article_tag.name.to_s)
138
+ end
139
+
140
+ def heading_or_anchor_miss?(title, lines, published_at)
141
+ CardWalk.miss?(
142
+ heading_or_anchor_item: heading_or_anchor_item?,
143
+ published_at:,
144
+ description: ArticleRules::Description.from_lines(lines, title:)
145
+ )
146
+ end
147
+
148
+ def parent_card_fields(title, lines, published_at)
149
+ return article_tag, lines, published_at unless heading_or_anchor_miss?(title, lines, published_at)
150
+
151
+ parent = immediate_card_parent
152
+ if !parent || crowded_parent?(parent)
153
+ Log.debug { "parent-walk abort at #{article_tag.parent&.name}" }
154
+ return article_tag, lines, published_at
155
+ end
113
156
 
114
- desc = description.strip
115
- desc.empty? ? nil : desc
157
+ new_lines = leftover_lines_from(parent)
158
+ new_date = extract_published_at_from(parent, new_lines)
159
+ [parent, new_lines, new_date || published_at]
160
+ end
161
+
162
+ def immediate_card_parent
163
+ parent = article_tag.respond_to?(:parent) ? article_tag.parent : nil
164
+ Navigator.parent_until_condition(parent, method(:usable_walk_parent?))
165
+ end
166
+
167
+ def usable_walk_parent?(node)
168
+ Navigator.usable_card_parent?(node) && !thin_heading_wrapper?(node)
169
+ end
170
+
171
+ def thin_heading_wrapper?(node)
172
+ CardWalk.thin_wrapper?(
173
+ children: node.element_children,
174
+ item: article_tag,
175
+ descendant_of: ->(item, child) { Navigator.descendant_of?(item, child) }
176
+ )
177
+ end
178
+
179
+ def crowded_parent?(node)
180
+ CardWalk.crowded?(
181
+ heading_count: node.css(Navigator::HEADING_TAGS.join(',')).size,
182
+ distinct_main_hrefs: distinct_main_hrefs(node)
183
+ )
184
+ end
185
+
186
+ def distinct_main_hrefs(node)
187
+ node.css(Navigator::MAIN_ANCHOR_SELECTOR).map { |anchor| anchor['href'] }.uniq.size
116
188
  end
117
189
 
118
190
  def generate_id
@@ -126,9 +198,15 @@ module Html2rss
126
198
  end
127
199
 
128
200
  def extract_image = ImageExtractor.call(article_tag, base_url:)
129
- def extract_published_at = DateExtractor.call(article_tag)
201
+
202
+ def extract_published_at(lines) = extract_published_at_from(article_tag, lines)
203
+
204
+ def extract_published_at_from(node, lines)
205
+ DateExtractor.call(node, leftover_lines: lines, time_zone:)
206
+ end
207
+
130
208
  def extract_enclosures = EnclosureExtractor.call(article_tag, base_url)
131
- def extract_categories = CategoryExtractor.call(article_tag)
132
209
  end
210
+ # rubocop:enable Metrics/ClassLength
133
211
  end
134
212
  end
@@ -6,47 +6,64 @@ module Html2rss
6
6
  ##
7
7
  # Shared category term vocabulary and text accumulation (DOM walk stays in adapters).
8
8
  module Category
9
- # Class/data attribute tokens that mark category metadata.
9
+ # Class/data attribute tokens that mark category metadata (not layout words).
10
10
  CATEGORY_TERMS = %w[
11
- category categories tag tags topic topics section sections
12
- label labels theme themes subject subjects
11
+ category categories tag tags topic topics
12
+ theme themes subject subjects
13
13
  ].freeze
14
14
 
15
- # Regex matching category-related attribute or class names.
16
- CATEGORY_ATTR_PATTERN = /#{CATEGORY_TERMS.join('|')}/i
15
+ # Frozen set for token membership checks.
16
+ TERM_SET = CATEGORY_TERMS.to_set.freeze
17
+
18
+ # Hyphen/underscore/BEM separators inside a single CSS class token.
19
+ CLASS_TOKEN_SPLIT = /[-_]+/
17
20
 
18
21
  class << self
19
22
  ##
20
23
  # @param name [String]
21
24
  # @return [Boolean]
22
25
  def attr_name_match?(name)
23
- name.to_s.match?(CATEGORY_ATTR_PATTERN)
26
+ TERM_SET.include?(normalize_attr_name(name))
24
27
  end
25
28
 
26
29
  ##
27
30
  # @param class_attr [String, nil]
28
31
  # @return [Boolean]
29
32
  def class_match?(class_attr)
30
- class_attr.to_s.match?(CATEGORY_ATTR_PATTERN)
33
+ class_attr.to_s.split(/\s+/).any? { |token| class_token_match?(token) }
31
34
  end
32
35
 
33
36
  ##
34
37
  # @param categories [Set<String>]
35
38
  # @param text [String, nil]
39
+ # @param title [String, nil]
36
40
  # @return [void]
37
- def add_text!(categories, text)
41
+ def add_text!(categories, text, title: nil)
38
42
  value = text.to_s.strip
39
- categories.add(value) unless value.empty?
43
+ return if value.empty? || !Description.keep?(value, title:, type_chips: false)
44
+
45
+ categories.add(value)
40
46
  end
41
47
 
42
48
  ##
43
49
  # @param categories [Set<String>]
44
50
  # @param text [String, nil]
51
+ # @param title [String, nil]
45
52
  # @return [void]
46
- def add_split_text!(categories, text)
53
+ def add_split_text!(categories, text, title: nil)
47
54
  return unless text
48
55
 
49
- text.split(/\n+/).each { |line| add_text!(categories, line) }
56
+ text.split(/\n+/).each { |line| add_text!(categories, line, title:) }
57
+ end
58
+
59
+ private
60
+
61
+ def class_token_match?(token)
62
+ token.downcase.split(CLASS_TOKEN_SPLIT).intersect?(CATEGORY_TERMS)
63
+ end
64
+
65
+ def normalize_attr_name(name)
66
+ name.to_s.downcase.delete_prefix('data-')
50
67
  end
51
68
  end
52
69
  end