html2rss 0.25.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 (89) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +99 -2
  3. data/html2rss.gemspec +4 -1
  4. data/lib/html2rss/auto_source/README.md +57 -0
  5. data/lib/html2rss/auto_source/cleanup.rb +140 -49
  6. data/lib/html2rss/auto_source/scraper/json_state.rb +31 -22
  7. data/lib/html2rss/auto_source/scraper/schema/item_list.rb +2 -14
  8. data/lib/html2rss/auto_source/scraper/xhr_articles.rb +69 -0
  9. data/lib/html2rss/auto_source/scraper.rb +47 -15
  10. data/lib/html2rss/auto_source/segmenter/list.rb +3 -1
  11. data/lib/html2rss/auto_source/segmenter/primary_link.rb +6 -7
  12. data/lib/html2rss/auto_source/segmenter.rb +30 -1
  13. data/lib/html2rss/auto_source.rb +41 -10
  14. data/lib/html2rss/capture/README.md +61 -0
  15. data/lib/html2rss/capture.rb +318 -0
  16. data/lib/html2rss/cli.rb +94 -17
  17. data/lib/html2rss/config/auto_source_contract.rb +3 -1
  18. data/lib/html2rss/config/schema.rb +79 -26
  19. data/lib/html2rss/config/selectors_validator.rb +60 -21
  20. data/lib/html2rss/config/validator.rb +46 -32
  21. data/lib/html2rss/config.rb +28 -0
  22. data/lib/html2rss/error.rb +31 -1
  23. data/lib/html2rss/feed_pipeline/README.md +42 -0
  24. data/lib/html2rss/feed_pipeline/auto_fallback.rb +38 -15
  25. data/lib/html2rss/feed_pipeline/runtime_policy.rb +3 -43
  26. data/lib/html2rss/feed_pipeline/strategy_plan.rb +11 -0
  27. data/lib/html2rss/feed_pipeline.rb +40 -15
  28. data/lib/html2rss/hash_util.rb +17 -0
  29. data/lib/html2rss/html/article_extractor/category_extractor.rb +36 -22
  30. data/lib/html2rss/html/article_extractor/date_extractor.rb +5 -3
  31. data/lib/html2rss/html/article_extractor.rb +95 -17
  32. data/lib/html2rss/html/article_rules/category.rb +28 -11
  33. data/lib/html2rss/html/article_rules/date.rb +60 -6
  34. data/lib/html2rss/html/article_rules/description.rb +122 -0
  35. data/lib/html2rss/html/card_walk.rb +42 -0
  36. data/lib/html2rss/html/feed_link.rb +34 -0
  37. data/lib/html2rss/html/navigator.rb +18 -0
  38. data/lib/html2rss/html/sst_article_extractor.rb +147 -31
  39. data/lib/html2rss/link_destination/noise_policy.rb +7 -18
  40. data/lib/html2rss/link_destination/path_classifier.rb +49 -32
  41. data/lib/html2rss/mcp/config_argument.rb +42 -0
  42. data/lib/html2rss/mcp/contract.rb +173 -0
  43. data/lib/html2rss/mcp/inspect.rb +241 -0
  44. data/lib/html2rss/mcp/outcome.rb +188 -0
  45. data/lib/html2rss/mcp/server.rb +421 -0
  46. data/lib/html2rss/mcp.rb +21 -0
  47. data/lib/html2rss/request_service/blocked_surface.rb +24 -1
  48. data/lib/html2rss/request_service/botasaurus_contract.rb +261 -90
  49. data/lib/html2rss/request_service/botasaurus_strategy.rb +18 -9
  50. data/lib/html2rss/request_service/budget.rb +7 -35
  51. data/lib/html2rss/request_service/compressed_body.rb +109 -0
  52. data/lib/html2rss/request_service/context.rb +0 -6
  53. data/lib/html2rss/request_service/faraday_strategy.rb +48 -3
  54. data/lib/html2rss/request_service/policy.rb +2 -2
  55. data/lib/html2rss/request_service/response.rb +72 -7
  56. data/lib/html2rss/request_service/strategy.rb +1 -2
  57. data/lib/html2rss/request_service.rb +10 -10
  58. data/lib/html2rss/scoring/container_assessor.rb +9 -15
  59. data/lib/html2rss/scoring/engine.rb +49 -5
  60. data/lib/html2rss/scoring/link_resolver.rb +12 -0
  61. data/lib/html2rss/scoring/ranked_segment.rb +0 -12
  62. data/lib/html2rss/scoring/score.rb +1 -20
  63. data/lib/html2rss/scoring.rb +0 -25
  64. data/lib/html2rss/selectors/extractors/attribute.rb +15 -0
  65. data/lib/html2rss/selectors/extractors/href.rb +12 -0
  66. data/lib/html2rss/selectors/extractors/html.rb +12 -0
  67. data/lib/html2rss/selectors/extractors/static.rb +14 -0
  68. data/lib/html2rss/selectors/extractors/text.rb +11 -0
  69. data/lib/html2rss/selectors/post_processors/gsub.rb +18 -0
  70. data/lib/html2rss/selectors/post_processors/html_to_markdown.rb +11 -0
  71. data/lib/html2rss/selectors/post_processors/markdown_to_html.rb +12 -0
  72. data/lib/html2rss/selectors/post_processors/parse_time.rb +11 -0
  73. data/lib/html2rss/selectors/post_processors/parse_uri.rb +11 -0
  74. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +13 -0
  75. data/lib/html2rss/selectors/post_processors/substring.rb +21 -0
  76. data/lib/html2rss/selectors/post_processors/template.rb +20 -0
  77. data/lib/html2rss/selectors/schema_doc.rb +99 -0
  78. data/lib/html2rss/selectors.rb +2 -1
  79. data/lib/html2rss/status.rb +27 -11
  80. data/lib/html2rss/url.rb +31 -7
  81. data/lib/html2rss/version.rb +1 -1
  82. data/lib/html2rss.rb +62 -7
  83. data/schema/html2rss-config.schema.json +428 -94
  84. metadata +65 -12
  85. data/lib/html2rss/request_service/browserless_strategy.rb +0 -132
  86. data/lib/html2rss/request_service/puppet_commander/navigation_guards.rb +0 -148
  87. data/lib/html2rss/request_service/puppet_commander/preload_runner.rb +0 -86
  88. data/lib/html2rss/request_service/puppet_commander.rb +0 -95
  89. data/lib/html2rss/scoring/anchor_score.rb +0 -34
@@ -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,13 +94,21 @@ 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:)
84
- if config.auto_source && articles.empty?
85
- raise NoFeedItemsExtracted.new(attempts: [{ strategy:, items_count: 0, error_class: nil }])
86
- end
97
+ articles, dedup_dropped, admission_drops = deduplicated_articles(config:, response:, request_session:)
98
+ raise_empty_auto_source!(strategy:, response:) if config.auto_source && articles.empty?
87
99
 
88
100
  PipelineOutcome.new(
89
- 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:
103
+ )
104
+ end
105
+
106
+ def raise_empty_auto_source!(strategy:, response:)
107
+ raise NoFeedItemsExtracted.new(
108
+ attempts: [{ strategy:, items_count: 0, error_class: nil }],
109
+ surface_category: AutoSource::Scraper.classify_no_scraper_surface(
110
+ response.parsed_body, body: response.body
111
+ )
90
112
  )
91
113
  end
92
114
 
@@ -101,8 +123,9 @@ module Html2rss
101
123
  end
102
124
 
103
125
  def collect_articles(config:, response:, request_session:)
104
- selector_articles(config:, response:, request_session:) +
105
- 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]
106
129
  end
107
130
 
108
131
  # rubocop:disable Metrics/MethodLength
@@ -125,10 +148,12 @@ module Html2rss
125
148
  end
126
149
  # rubocop:enable Metrics/MethodLength
127
150
 
151
+ # @return [Array(Array<Html2rss::Article>, Hash{String => Integer})]
128
152
  def auto_source_articles(config:, response:, request_session:)
129
- return [] unless (auto_source = config.auto_source)
153
+ return [[], {}] unless (auto_source = config.auto_source)
130
154
 
131
- AutoSource.new(response, auto_source, request_session:).articles
155
+ source = AutoSource.new(response, auto_source, request_session:)
156
+ [source.articles, source.admission_drops]
132
157
  end
133
158
  end
134
159
  end
@@ -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
@@ -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
@@ -1,22 +1,76 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'date'
4
+ require 'tzinfo'
5
+
3
6
  module Html2rss
4
7
  module Html
5
8
  module ArticleRules
6
9
  ##
7
10
  # DOM-agnostic datetime aggregation for article published_at.
8
11
  module Date
12
+ # Skip DateTime.parse above this length (Ruby parse is not a validator).
13
+ MAX_DATE_CHARS = Description::MAX_DATE_CHARS
14
+ # Trailing offset / Zulu marker — present means the string already has a zone.
15
+ OFFSET = /(?:Z|[+-]\d{2}:?\d{2})\z/i
16
+ # Prefer standard time when a local clock time occurs twice (DST fall-back).
17
+ AMBIGUOUS_DST = false
18
+
9
19
  class << self
10
20
  ##
11
21
  # @param datetime_strings [Array<String, nil>] raw datetime attribute values
22
+ # @param leftover_lines [Array<String>] leftover visible lines (already split)
23
+ # @param time_zone [String] channel time zone for naive values
12
24
  # @return [DateTime, nil] earliest successfully parsed instant
13
- def earliest(datetime_strings)
14
- times = Array(datetime_strings).filter_map do |value|
15
- DateTime.parse(value.to_s)
16
- rescue ArgumentError, TypeError
17
- nil
25
+ def earliest(datetime_strings, leftover_lines: [], time_zone: 'UTC')
26
+ values = Array(datetime_strings).filter_map { |value| parse_attr(value, time_zone:) }
27
+ Array(leftover_lines).each do |line|
28
+ parsed = parse_line(line, time_zone:)
29
+ values << parsed if parsed
18
30
  end
19
- times.min
31
+ values.min
32
+ end
33
+
34
+ private
35
+
36
+ def parse_attr(value, time_zone:)
37
+ parse_datetime(value.to_s.strip, time_zone:)
38
+ end
39
+
40
+ def parse_line(line, time_zone:)
41
+ return unless Description.date_shaped?(line)
42
+
43
+ parse_datetime(Description.date_core(line), time_zone:)
44
+ end
45
+
46
+ def parse_datetime(string, time_zone:)
47
+ return if string.empty? || string.length > MAX_DATE_CHARS
48
+
49
+ parsed = DateTime.parse(string, limit: MAX_DATE_CHARS)
50
+ naive?(string) ? localize(parsed, time_zone) : parsed
51
+ rescue ArgumentError, TypeError
52
+ nil
53
+ end
54
+
55
+ def naive?(string) = !string.match?(OFFSET)
56
+
57
+ def localize(datetime, time_zone)
58
+ identifier = time_zone.to_s
59
+ identifier = 'UTC' if identifier.empty?
60
+ at_local(timezone_for(identifier), datetime)
61
+ rescue TZInfo::PeriodNotFound
62
+ at_local(timezone_for('UTC'), datetime)
63
+ end
64
+
65
+ def at_local(timezone, datetime)
66
+ timezone.local_datetime(datetime.year, datetime.month, datetime.day,
67
+ datetime.hour, datetime.min, datetime.sec, 0, AMBIGUOUS_DST, &:first)
68
+ end
69
+
70
+ def timezone_for(identifier)
71
+ TZInfo::Timezone.get(identifier)
72
+ rescue TZInfo::InvalidTimezoneIdentifier
73
+ TZInfo::Timezone.get('UTC')
20
74
  end
21
75
  end
22
76
  end