html2rss 0.22.2 → 0.23.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 (138) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +11 -108
  3. data/lib/html2rss/{articles → article}/deduplicator.rb +3 -4
  4. data/lib/html2rss/{rss_builder → article}/enclosure.rb +6 -22
  5. data/lib/html2rss/article.rb +206 -0
  6. data/lib/html2rss/auto_source/discovery/dom_clustering/group_scorer.rb +80 -0
  7. data/lib/html2rss/auto_source/discovery/dom_clustering/overlap_resolver.rb +86 -0
  8. data/lib/html2rss/auto_source/discovery/dom_clustering.rb +119 -0
  9. data/lib/html2rss/auto_source/discovery/list_candidates.rb +94 -0
  10. data/lib/html2rss/auto_source/discovery/semantic_anchor_candidates.rb +219 -0
  11. data/lib/html2rss/auto_source/discovery/semantic_containers.rb +71 -0
  12. data/lib/html2rss/auto_source/discovery/sitemap.rb +159 -0
  13. data/lib/html2rss/auto_source/discovery.rb +14 -0
  14. data/lib/html2rss/auto_source/link_heuristics/anchor_signals.rb +28 -0
  15. data/lib/html2rss/auto_source/link_heuristics/container_assessor.rb +106 -0
  16. data/lib/html2rss/auto_source/link_heuristics/container_signals.rb +80 -0
  17. data/lib/html2rss/auto_source/link_heuristics/destination_facts.rb +42 -0
  18. data/lib/html2rss/auto_source/link_heuristics/href_extractor.rb +38 -0
  19. data/lib/html2rss/auto_source/link_heuristics/path_classifier.rb +221 -0
  20. data/lib/html2rss/auto_source/link_heuristics/text_classifier.rb +66 -0
  21. data/lib/html2rss/auto_source/link_heuristics.rb +139 -0
  22. data/lib/html2rss/auto_source/scraper/html.rb +22 -33
  23. data/lib/html2rss/auto_source/scraper/json_state.rb +1 -1
  24. data/lib/html2rss/auto_source/scraper/meta_oembed.rb +163 -0
  25. data/lib/html2rss/auto_source/scraper/microformats2.rb +146 -0
  26. data/lib/html2rss/auto_source/scraper/schema/thing.rb +21 -2
  27. data/lib/html2rss/auto_source/scraper/semantic_html/{deduplicator.rb → entry_deduplicator.rb} +3 -3
  28. data/lib/html2rss/auto_source/scraper/semantic_html.rb +23 -171
  29. data/lib/html2rss/auto_source/scraper/sitemap.rb +153 -0
  30. data/lib/html2rss/auto_source/scraper/wordpress_api/page_scope/date_archive_range.rb +110 -0
  31. data/lib/html2rss/auto_source/scraper/wordpress_api/page_scope.rb +2 -78
  32. data/lib/html2rss/auto_source/scraper/wordpress_api.rb +9 -0
  33. data/lib/html2rss/auto_source/scraper.rb +10 -3
  34. data/lib/html2rss/auto_source.rb +30 -35
  35. data/lib/html2rss/channel.rb +118 -0
  36. data/lib/html2rss/cli.rb +5 -6
  37. data/lib/html2rss/config/auto_source_contract.rb +51 -0
  38. data/lib/html2rss/config/request_controls.rb +136 -0
  39. data/lib/html2rss/config/request_headers.rb +1 -1
  40. data/lib/html2rss/config/schema.rb +65 -114
  41. data/lib/html2rss/{selectors/config.rb → config/selectors_validator.rb} +56 -7
  42. data/lib/html2rss/config/validator.rb +6 -6
  43. data/lib/html2rss/config.rb +176 -19
  44. data/lib/html2rss/{configuration.rb → defaults.rb} +12 -10
  45. data/lib/html2rss/feed_builder/json_feed/item.rb +96 -0
  46. data/lib/html2rss/feed_builder/json_feed.rb +61 -0
  47. data/lib/html2rss/feed_builder/rss/stylesheet.rb +65 -0
  48. data/lib/html2rss/feed_builder/rss.rb +116 -0
  49. data/lib/html2rss/feed_builder.rb +23 -0
  50. data/lib/html2rss/feed_pipeline/auto_fallback.rb +59 -42
  51. data/lib/html2rss/feed_pipeline/runtime_policy.rb +108 -0
  52. data/lib/html2rss/feed_pipeline/strategy_plan.rb +64 -0
  53. data/lib/html2rss/feed_pipeline.rb +52 -65
  54. data/lib/html2rss/html/article_extractor/category_extractor.rb +123 -0
  55. data/lib/html2rss/html/article_extractor/date_extractor.rb +22 -0
  56. data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +95 -0
  57. data/lib/html2rss/html/article_extractor/heading_extractor.rb +54 -0
  58. data/lib/html2rss/html/article_extractor/id_generator.rb +69 -0
  59. data/lib/html2rss/html/article_extractor/image_extractor.rb +60 -0
  60. data/lib/html2rss/html/article_extractor.rb +134 -0
  61. data/lib/html2rss/html/navigator/text_extractor.rb +79 -0
  62. data/lib/html2rss/html/navigator.rb +146 -0
  63. data/lib/html2rss/html/rendering/audio_renderer.rb +38 -0
  64. data/lib/html2rss/html/rendering/description_builder.rb +89 -0
  65. data/lib/html2rss/html/rendering/image_renderer.rb +43 -0
  66. data/lib/html2rss/html/rendering/media_renderer.rb +39 -0
  67. data/lib/html2rss/html/rendering/pdf_renderer.rb +36 -0
  68. data/lib/html2rss/html/rendering/video_renderer.rb +38 -0
  69. data/lib/html2rss/html/rendering.rb +25 -0
  70. data/lib/html2rss/html.rb +8 -0
  71. data/lib/html2rss/request_service/blocked_surface.rb +67 -0
  72. data/lib/html2rss/request_service/botasaurus_contract.rb +0 -4
  73. data/lib/html2rss/request_service/botasaurus_strategy.rb +9 -27
  74. data/lib/html2rss/request_service/browserless_strategy.rb +2 -23
  75. data/lib/html2rss/request_service/budget.rb +90 -16
  76. data/lib/html2rss/request_service/context.rb +1 -3
  77. data/lib/html2rss/request_service/faraday_strategy.rb +12 -26
  78. data/lib/html2rss/request_service/local_file_strategy.rb +12 -4
  79. data/lib/html2rss/request_service/network_guard.rb +125 -0
  80. data/lib/html2rss/request_service/policy.rb +10 -82
  81. data/lib/html2rss/request_service/puppet_commander/navigation_guards.rb +148 -0
  82. data/lib/html2rss/request_service/puppet_commander/preload_runner.rb +86 -0
  83. data/lib/html2rss/request_service/puppet_commander.rb +22 -191
  84. data/lib/html2rss/request_service/response_guard.rb +1 -1
  85. data/lib/html2rss/request_service/strategy.rb +102 -6
  86. data/lib/html2rss/request_service.rb +7 -2
  87. data/lib/html2rss/request_session/pager/base.rb +83 -0
  88. data/lib/html2rss/request_session/pager/custom_selector.rb +39 -0
  89. data/lib/html2rss/request_session/pager/json_cursor.rb +78 -0
  90. data/lib/html2rss/request_session/pager/offset.rb +48 -0
  91. data/lib/html2rss/request_session/pager/rel_next.rb +23 -0
  92. data/lib/html2rss/request_session/pager/url_template.rb +48 -0
  93. data/lib/html2rss/request_session/pager.rb +75 -0
  94. data/lib/html2rss/request_session.rb +25 -16
  95. data/lib/html2rss/selectors/item_scope.rb +30 -0
  96. data/lib/html2rss/selectors/post_processors/base.rb +1 -1
  97. data/lib/html2rss/selectors/post_processors/markdown_to_html.rb +0 -1
  98. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +0 -2
  99. data/lib/html2rss/selectors/post_processors/template.rb +7 -3
  100. data/lib/html2rss/selectors.rb +63 -65
  101. data/lib/html2rss/version.rb +1 -3
  102. data/lib/html2rss.rb +19 -19
  103. data/schema/html2rss-config.schema.json +109 -9
  104. metadata +68 -42
  105. data/lib/html2rss/auto_source/scraper/html/class_clustering.rb +0 -196
  106. data/lib/html2rss/auto_source/scraper/link_heuristics.rb +0 -401
  107. data/lib/html2rss/auto_source/scraper/semantic_html/anchor_selector.rb +0 -49
  108. data/lib/html2rss/blocked_surface.rb +0 -65
  109. data/lib/html2rss/category_extractor.rb +0 -116
  110. data/lib/html2rss/config/class_methods.rb +0 -183
  111. data/lib/html2rss/html_extractor/date_extractor.rb +0 -20
  112. data/lib/html2rss/html_extractor/enclosure_extractor.rb +0 -91
  113. data/lib/html2rss/html_extractor/heading_extractor.rb +0 -50
  114. data/lib/html2rss/html_extractor/id_generator.rb +0 -67
  115. data/lib/html2rss/html_extractor/image_extractor.rb +0 -58
  116. data/lib/html2rss/html_extractor/list_candidates.rb +0 -88
  117. data/lib/html2rss/html_extractor/semantic_anchor_candidates.rb +0 -260
  118. data/lib/html2rss/html_extractor/semantic_containers.rb +0 -69
  119. data/lib/html2rss/html_extractor/text_extractor.rb +0 -77
  120. data/lib/html2rss/html_extractor.rb +0 -190
  121. data/lib/html2rss/html_navigator.rb +0 -71
  122. data/lib/html2rss/json_feed_builder/item.rb +0 -94
  123. data/lib/html2rss/json_feed_builder.rb +0 -59
  124. data/lib/html2rss/rendering/audio_renderer.rb +0 -36
  125. data/lib/html2rss/rendering/description_builder.rb +0 -87
  126. data/lib/html2rss/rendering/image_renderer.rb +0 -41
  127. data/lib/html2rss/rendering/media_renderer.rb +0 -37
  128. data/lib/html2rss/rendering/pdf_renderer.rb +0 -34
  129. data/lib/html2rss/rendering/video_renderer.rb +0 -36
  130. data/lib/html2rss/rendering.rb +0 -23
  131. data/lib/html2rss/request_controls.rb +0 -133
  132. data/lib/html2rss/request_session/rel_next_pager.rb +0 -70
  133. data/lib/html2rss/request_session/runtime_input.rb +0 -71
  134. data/lib/html2rss/request_session/runtime_policy.rb +0 -84
  135. data/lib/html2rss/rss_builder/article.rb +0 -208
  136. data/lib/html2rss/rss_builder/channel.rb +0 -118
  137. data/lib/html2rss/rss_builder/stylesheet.rb +0 -63
  138. data/lib/html2rss/rss_builder.rb +0 -101
@@ -3,7 +3,10 @@
3
3
  module Html2rss
4
4
  ##
5
5
  # Builds feeds from validated config through request, extraction, and rendering stages.
6
- class FeedPipeline # rubocop:disable Metrics/ClassLength
6
+ class FeedPipeline
7
+ # Bundle of inputs shared by selector and auto-source article collection.
8
+ ExtractionContext = Data.define(:config, :response, :request_session)
9
+
7
10
  ##
8
11
  # @param raw_config [Hash{Symbol => Object}] user-provided feed config
9
12
  def initialize(raw_config)
@@ -14,8 +17,8 @@ module Html2rss
14
17
  # @return [RSS::Rss] generated RSS feed
15
18
  def to_rss
16
19
  run do |response:, config:, articles:|
17
- channel = RssBuilder::Channel.new(response, overrides: config.channel)
18
- RssBuilder.new(channel:, articles:, stylesheets: config.stylesheets).call
20
+ channel = Html2rss::Channel.new(response, overrides: config.channel)
21
+ FeedBuilder.build(:rss, channel:, articles:, stylesheets: config.stylesheets)
19
22
  end
20
23
  end
21
24
 
@@ -23,8 +26,8 @@ module Html2rss
23
26
  # @return [Hash] generated JSONFeed 1.1 payload
24
27
  def to_json_feed
25
28
  run do |response:, config:, articles:|
26
- channel = RssBuilder::Channel.new(response, overrides: config.channel)
27
- JsonFeedBuilder.new(channel:, articles:).call
29
+ channel = Html2rss::Channel.new(response, overrides: config.channel)
30
+ FeedBuilder.build(:json_feed, channel:, articles:)
28
31
  end
29
32
  end
30
33
 
@@ -39,98 +42,82 @@ module Html2rss
39
42
  end
40
43
 
41
44
  def pipeline_state_for(config)
42
- if config.strategy == :auto
43
- run_auto_pipeline(config)
45
+ plan = StrategyPlan.resolve(config.strategy)
46
+ resources = RuntimePolicy.resources_for(config)
47
+ if plan.is_a?(StrategyPlan::Auto)
48
+ run_auto_pipeline(config, resources:)
44
49
  else
45
- run_pipeline_for_strategy(config, strategy: config.strategy)
50
+ run_pipeline_for_strategy(config, strategy: plan.strategy, resources:)
46
51
  end
47
52
  end
48
53
 
49
- def run_pipeline_for_strategy(config, strategy:, budget: nil)
50
- request_session = request_session_for(config, strategy:, budget:)
54
+ def run_pipeline_for_strategy(config, strategy:, resources:)
55
+ request_session = request_session_for(config, strategy:, resources:)
51
56
  response = request_session.fetch_initial_response
52
- articles = deduplicated_articles(response:, config:, request_session:)
57
+ articles = deduplicated_articles(
58
+ ExtractionContext.new(config:, response:, request_session:)
59
+ )
53
60
  { response:, articles: }
54
61
  end
55
62
 
56
- def request_session_for(config, strategy:, budget: nil)
57
- RequestSession.from_runtime_input(runtime_input_for(config, strategy:), budget:)
58
- end
59
-
60
- def runtime_input_for(config, strategy:)
61
- RequestSession::RuntimeInput.new(
62
- url: config.url,
63
- headers: config.headers,
64
- request: config.request,
63
+ def request_session_for(config, strategy:, resources:)
64
+ RequestSession.build(
65
+ config:,
65
66
  strategy:,
66
- request_policy: RequestSession::RuntimePolicy.from_config(config)
67
+ budget: resources.budget,
68
+ policy: resources.policy
67
69
  )
68
70
  end
69
71
 
70
- def deduplicated_articles(response:, config:, request_session:)
71
- Articles::Deduplicator.new(
72
- collect_articles(response:, config:, request_session:)
73
- ).call
74
- end
75
-
76
- def run_auto_pipeline(config)
77
- auto_fallback_for(config).call
72
+ def deduplicated_articles(extraction)
73
+ Article::Deduplicator.new(collect_articles(extraction)).call
78
74
  end
79
75
 
80
76
  # rubocop:disable Metrics/MethodLength
81
- def auto_fallback_for(config)
77
+ def run_auto_pipeline(config, resources:)
82
78
  AutoFallback.new(
83
79
  strategies: AutoFallback::CHAIN,
84
- budget: auto_pipeline_budget(config),
80
+ budget: resources.budget,
85
81
  session_for: lambda do |strategy:, budget:|
86
- if budget.remaining_timeout_seconds && budget.remaining_timeout_seconds <= 0
87
- raise RequestService::RequestTimedOut, 'Request timed out'
88
- end
89
-
90
- request_session_for(config, strategy:, budget:)
82
+ budget.effective_timeout_seconds(fallback: resources.policy.total_timeout_seconds)
83
+ request_session_for(config, strategy:, resources:)
91
84
  end,
92
85
  articles_for: lambda do |response:, request_session:|
93
- deduplicated_articles(response:, config:, request_session:)
86
+ deduplicated_articles(ExtractionContext.new(config:, response:, request_session:))
94
87
  end
95
- )
88
+ ).call
96
89
  end
97
90
  # rubocop:enable Metrics/MethodLength
98
91
 
99
- def auto_pipeline_budget(config)
100
- policy = RequestSession::RuntimePolicy.from_config(config)
101
- RequestService::Budget.new(
102
- max_requests: policy.max_requests,
103
- total_timeout_seconds: policy.total_timeout_seconds
104
- )
92
+ def collect_articles(extraction)
93
+ selector_articles(extraction) + auto_source_articles(extraction)
105
94
  end
106
95
 
107
- def collect_articles(response:, config:, request_session:)
108
- selector_articles(response:, config:, request_session:) +
109
- auto_source_articles(response:, config:, request_session:)
110
- end
111
-
112
- def selector_articles(response:, config:, request_session:) # rubocop:disable Metrics/MethodLength
96
+ # rubocop:disable Metrics/MethodLength
97
+ def selector_articles(extraction)
98
+ config = extraction.config
113
99
  return [] unless (selectors = config.selectors)
114
100
 
115
- page_responses = if (max_pages = selectors.dig(:items, :pagination, :max_pages))
116
- RequestSession::RelNextPager.new(
117
- session: request_session,
118
- initial_response: response,
119
- max_pages:
120
- ).to_a
121
- else
122
- [response]
123
- end
124
-
125
- page_responses.flat_map do |page_response|
126
- Selectors.new(page_response, selectors:, time_zone: config.time_zone).articles
101
+ page_responses = extraction.request_session.page_responses(
102
+ extraction.response,
103
+ pagination_config: selectors.dig(:items, :pagination)
104
+ )
105
+
106
+ articles = []
107
+ page_responses.each do |page_response|
108
+ page_articles = Selectors.new(page_response, selectors:, time_zone: config.time_zone).articles
109
+ break if page_articles.empty? && articles.any?
110
+
111
+ articles.concat(page_articles)
127
112
  end
113
+ articles
128
114
  end
115
+ # rubocop:enable Metrics/MethodLength
129
116
 
130
- def auto_source_articles(response:, config:, request_session:)
131
- return [] unless (auto_source = config.auto_source)
117
+ def auto_source_articles(extraction)
118
+ return [] unless (auto_source = extraction.config.auto_source)
132
119
 
133
- AutoSource.new(response, auto_source, request_session:).articles
120
+ AutoSource.new(extraction.response, auto_source, request_session: extraction.request_session).articles
134
121
  end
135
122
  end
136
123
  end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ module Html
5
+ class ArticleExtractor
6
+ ##
7
+ # CategoryExtractor is responsible for extracting categories from HTML elements
8
+ # by looking for CSS class names containing common category-related terms.
9
+ class CategoryExtractor
10
+ # Common category-related terms to look for in class names
11
+ CATEGORY_TERMS = %w[
12
+ category categories tag tags topic topics section sections
13
+ label labels theme themes subject subjects
14
+ ].freeze
15
+
16
+ # CSS selectors to find elements with category-related class names or data attributes
17
+ CATEGORY_SELECTORS = CATEGORY_TERMS.flat_map do |term|
18
+ ["[class*=\"#{term}\"]", "[data-#{term}]", "[#{term}]"]
19
+ end.freeze
20
+
21
+ # Regex pattern for matching category-related attribute names
22
+ CATEGORY_ATTR_PATTERN = /#{CATEGORY_TERMS.join('|')}/i
23
+
24
+ ##
25
+ # Extracts categories from the given article tag by looking for elements
26
+ # with class names containing common category-related terms.
27
+ #
28
+ # @param article_tag [Nokogiri::XML::Element] The article element to extract categories from
29
+ # @return [Array<String>] Array of category strings, empty if none found
30
+ def self.call(article_tag)
31
+ return [] unless article_tag
32
+
33
+ # Single optimized traversal that extracts all category types
34
+ extract_all_categories(article_tag)
35
+ .map(&:strip)
36
+ .reject(&:empty?)
37
+ end
38
+
39
+ ##
40
+ # Optimized single DOM traversal that extracts all category types.
41
+ #
42
+ # @param article_tag [Nokogiri::XML::Element] The article element
43
+ # @return [Set<String>] Set of category strings
44
+ def self.extract_all_categories(article_tag)
45
+ Set.new.tap do |categories|
46
+ article_tag.css(CATEGORY_SELECTORS.join(',')).each do |element|
47
+ # Extract text categories from elements with category-related class names
48
+ extract_text_categories!(categories, element) if element['class']&.match?(CATEGORY_ATTR_PATTERN)
49
+
50
+ # Extract data categories from all elements
51
+ extract_element_data_categories!(categories, element)
52
+ end
53
+ end
54
+ end
55
+
56
+ ##
57
+ # Extracts categories from data attributes of a single element.
58
+ #
59
+ # @param categories [Set<String>] Accumulator set
60
+ # @param element [Nokogiri::XML::Element] metadata element that may contain category links
61
+ # @return [void]
62
+ def self.extract_element_data_categories!(categories, element)
63
+ element.attributes.each_value do |attr|
64
+ next unless attr.name.match?(CATEGORY_ATTR_PATTERN)
65
+
66
+ value = attr.value&.strip
67
+ categories.add(value) if value && !value.empty?
68
+ end
69
+ end
70
+
71
+ ##
72
+ # Extracts text-based categories from elements, splitting content into discrete values.
73
+ #
74
+ # @param categories [Set<String>] Accumulator set
75
+ # @param element [Nokogiri::XML::Element] metadata element whose text may contain delimiters
76
+ # @return [void]
77
+ def self.extract_text_categories!(categories, element)
78
+ if element.name == 'a'
79
+ add_text_to_categories!(categories, element)
80
+ return
81
+ end
82
+
83
+ anchors = element.css('a')
84
+
85
+ if anchors.any?
86
+ anchors.each { |node| add_text_to_categories!(categories, node) }
87
+ else
88
+ extract_split_text_categories!(categories, element)
89
+ end
90
+ end
91
+
92
+ ##
93
+ # Adds the visible text of the given element to the categories set.
94
+ #
95
+ # @param categories [Set<String>] Accumulator set
96
+ # @param element [Nokogiri::XML::Element] The element to extract text from
97
+ # @return [void]
98
+ def self.add_text_to_categories!(categories, element)
99
+ text = Navigator.extract_visible_text(element)
100
+ categories.add(text) if text && !text.empty?
101
+ end
102
+
103
+ ##
104
+ # Extracts categories from the element's text by splitting on newlines.
105
+ #
106
+ # @param categories [Set<String>] Accumulator set
107
+ # @param element [Nokogiri::XML::Element] The element to extract text from
108
+ # @return [void]
109
+ def self.extract_split_text_categories!(categories, element)
110
+ text = element.text
111
+ return unless text
112
+
113
+ text.split(/\n+/).each do |line|
114
+ line = line.strip
115
+ categories.add(line) unless line.empty?
116
+ end
117
+ end
118
+
119
+ private_class_method :add_text_to_categories!, :extract_split_text_categories!
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ module Html
5
+ class ArticleExtractor
6
+ # Extracts the earliest date from an article_tag.
7
+ class DateExtractor
8
+ # @param article_tag [Nokogiri::XML::Element] article container node
9
+ # @return [DateTime, nil]
10
+ def self.call(article_tag)
11
+ times = article_tag.css('[datetime]').filter_map do |tag|
12
+ DateTime.parse(tag['datetime'])
13
+ rescue ArgumentError, TypeError
14
+ nil
15
+ end
16
+
17
+ times.min
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ module Html
5
+ class ArticleExtractor
6
+ ##
7
+ # Extracts enclosures from HTML tags using various strategies.
8
+ class EnclosureExtractor
9
+ # CSS union query covering images, media, PDFs, iframes, and archives.
10
+ SELECTOR = [
11
+ 'img[src]:not([src^="data"])',
12
+ 'video source[src]',
13
+ 'audio source[src]',
14
+ 'audio[src]',
15
+ 'a[href$=".pdf"]',
16
+ 'iframe[src]',
17
+ 'a[href$=".zip"]',
18
+ 'a[href$=".tar.gz"]',
19
+ 'a[href$=".tgz"]'
20
+ ].join(',').freeze
21
+
22
+ # @param article_tag [Nokogiri::XML::Element] article container node
23
+ # @param base_url [String, Html2rss::Url] base URL for relative enclosure links
24
+ # @return [Array<Hash{Symbol => Object}>] normalized enclosure hashes
25
+ def self.call(article_tag, base_url)
26
+ return [] unless article_tag
27
+
28
+ article_tag.css(SELECTOR).filter_map do |element|
29
+ extract_from_element(element, base_url)
30
+ end
31
+ end
32
+
33
+ def self.extract_from_element(element, base_url)
34
+ case element.name
35
+ when 'img'
36
+ extract_image(element, base_url)
37
+ when 'video', 'audio', 'source'
38
+ extract_media(element, base_url)
39
+ when 'iframe'
40
+ extract_iframe(element, base_url)
41
+ when 'a'
42
+ extract_a(element, base_url)
43
+ end
44
+ end
45
+
46
+ def self.extract_image(img, base_url)
47
+ src = img['src'].to_s
48
+ return if src.empty?
49
+
50
+ abs_url = Url.from_relative(src, base_url)
51
+ {
52
+ url: abs_url,
53
+ type: Article::Enclosure.guess_content_type_from_url(abs_url, default: 'image/jpeg')
54
+ }
55
+ end
56
+
57
+ def self.extract_media(element, base_url)
58
+ src = element['src'].to_s
59
+ return if src.empty?
60
+
61
+ {
62
+ url: Url.from_relative(src, base_url),
63
+ type: element['type']
64
+ }
65
+ end
66
+
67
+ def self.extract_iframe(iframe, base_url)
68
+ src = iframe['src'].to_s
69
+ return if src.empty?
70
+
71
+ abs_url = Url.from_relative(src, base_url)
72
+ {
73
+ url: abs_url,
74
+ type: Article::Enclosure.guess_content_type_from_url(abs_url, default: 'text/html')
75
+ }
76
+ end
77
+
78
+ def self.extract_a(link, base_url)
79
+ href = link['href'].to_s
80
+ return if href.empty?
81
+
82
+ abs_url = Url.from_relative(href, base_url)
83
+
84
+ if href.end_with?('.pdf')
85
+ { url: abs_url, type: Article::Enclosure.guess_content_type_from_url(abs_url) }
86
+ else
87
+ { url: abs_url, type: 'application/zip' }
88
+ end
89
+ end
90
+
91
+ private_class_method :extract_from_element, :extract_image, :extract_media, :extract_iframe, :extract_a
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ module Html
5
+ class ArticleExtractor
6
+ ##
7
+ # HeadingExtractor identifies and returns the best heading element within a container.
8
+ class HeadingExtractor
9
+ # Heading tags used to prioritize title extraction.
10
+ HEADING_TAGS = Navigator::HEADING_TAGS
11
+
12
+ class << self
13
+ ##
14
+ # @param article_tag [Nokogiri::XML::Element] container node
15
+ # @param fallback_anchorless [Boolean] whether to use fallback search
16
+ # @param selected_anchor [Nokogiri::XML::Node, nil] anchor element
17
+ # @return [Nokogiri::XML::Node, nil] the heading node, if found
18
+ def call(article_tag, fallback_anchorless:, selected_anchor:)
19
+ tags = article_tag.css(HEADING_TAGS.join(','))
20
+ if tags.any?
21
+ select_best_heading(tags)
22
+ elsif fallback_anchorless && selected_anchor.nil?
23
+ fallback_heading(article_tag)
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ def select_best_heading(tags)
30
+ min_tag_name = tags.map(&:name).min
31
+ best_tag = nil
32
+ max_size = -1
33
+
34
+ tags.each do |tag|
35
+ next if tag.name != min_tag_name
36
+
37
+ size = Navigator::TextExtractor.call(tag)&.size.to_i
38
+ (best_tag = tag) && (max_size = size) if size > max_size
39
+ end
40
+
41
+ best_tag
42
+ end
43
+
44
+ def fallback_heading(article_tag)
45
+ fallback_tags = article_tag.css(
46
+ 'strong, b, [class*="title"], [class*="font-bold"], [class*="font-semibold"]'
47
+ )
48
+ fallback_tags.find { |t| !Navigator::TextExtractor.call(t).to_s.strip.empty? }
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'zlib'
4
+
5
+ module Html2rss
6
+ module Html
7
+ class ArticleExtractor
8
+ ##
9
+ # IdGenerator determines the unique ID for an article container node.
10
+ class IdGenerator
11
+ class << self
12
+ ##
13
+ # @param article_tag [Nokogiri::XML::Element] container node
14
+ # @param heading [Nokogiri::XML::Node, nil] heading node
15
+ # @param url [Html2rss::Url, nil] absolute article URL
16
+ # @param selected_anchor [Nokogiri::XML::Node, nil] anchor element
17
+ # @param fallback_anchorless [Boolean] whether to use fallback hashing
18
+ # @return [String, nil] the generated ID, if any
19
+ def call(article_tag, heading:, url:, selected_anchor:, fallback_anchorless:)
20
+ id_from_dom = parse_id_from_dom(article_tag, url, selected_anchor)
21
+ return id_from_dom if id_from_dom
22
+
23
+ heading_text = resolve_heading_text(article_tag, heading, fallback_anchorless)
24
+ if heading_text && !heading_text.strip.empty?
25
+ generate_slug(heading_text)
26
+ elsif fallback_anchorless
27
+ generate_content_hash(article_tag)
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ def parse_id_from_dom(article_tag, url, selected_anchor)
34
+ candidates = [article_tag['id'], article_tag.at_css('[id]')&.attr('id')]
35
+ candidates += [url&.path, url&.query] if selected_anchor
36
+ candidates.compact.reject(&:empty?).first
37
+ end
38
+
39
+ def resolve_heading_text(article_tag, heading, fallback_anchorless)
40
+ text = heading ? Navigator::TextExtractor.call(heading) : nil
41
+ if text.nil? || text.strip.empty?
42
+ fallback_text_node_content(article_tag, fallback_anchorless)
43
+ else
44
+ text
45
+ end
46
+ end
47
+
48
+ def fallback_text_node_content(article_tag, fallback_anchorless)
49
+ return unless fallback_anchorless
50
+
51
+ article_tag.xpath('.//text()').find { |t| !t.text.strip.empty? }&.text&.strip
52
+ end
53
+
54
+ def generate_slug(text)
55
+ slug = text.downcase.gsub(/[^a-z0-9]+/, '-')
56
+ slug = slug[1..] if slug.start_with?('-')
57
+ slug = slug[0..-2] if slug.end_with?('-')
58
+ slug unless slug.empty?
59
+ end
60
+
61
+ def generate_content_hash(article_tag)
62
+ text = Navigator::TextExtractor.call(article_tag).to_s.strip
63
+ Zlib.crc32(text).to_s(36) unless text.empty?
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ module Html
5
+ class ArticleExtractor
6
+ ##
7
+ # Image is responsible for extracting image URLs the article_tag.
8
+ class ImageExtractor
9
+ # @param article_tag [Nokogiri::XML::Element] article container node
10
+ # @param base_url [String, Html2rss::Url] base URL for relative image URLs
11
+ # @return [Html2rss::Url, nil] best candidate image URL
12
+ def self.call(article_tag, base_url:)
13
+ img_src = from_source(article_tag) ||
14
+ from_img(article_tag) ||
15
+ from_style(article_tag)
16
+
17
+ Url.from_relative(img_src, base_url) if img_src
18
+ end
19
+
20
+ # @param article_tag [Nokogiri::XML::Element] article container node
21
+ # @return [String, nil] src attribute from first matching image tag
22
+ def self.from_img(article_tag)
23
+ article_tag.at_css('img[src]:not([src^="data"])')&.[]('src')
24
+ end
25
+
26
+ ##
27
+ # Extracts the largest image source from the srcset attribute
28
+ # of an img tag or a source tag inside a picture tag.
29
+ #
30
+ # @param article_tag [Nokogiri::XML::Element] article container node
31
+ # @return [String, nil] largest srcset URL candidate
32
+ # @see <https://developer.mozilla.org/en-US/docs/Learn/HTML/Multimedia_and_embedding/Responsive_images>
33
+ # @see <https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img#srcset>
34
+ # @see <https://developer.mozilla.org/en-US/docs/Web/HTML/Element/picture>
35
+ def self.from_source(article_tag) # rubocop:disable Metrics/AbcSize
36
+ hash = article_tag.css('img[srcset], picture > source[srcset]').flat_map do |source|
37
+ source['srcset'].to_s.scan(/(\S+)\s+(\d+w|\d+h)[\s,]?/).map do |url, width|
38
+ next if url.nil? || url.start_with?('data:')
39
+
40
+ width_value = width.to_i.zero? ? 0 : width.scan(/\d+/).first.to_i
41
+
42
+ [width_value, url.strip]
43
+ end
44
+ end.compact.to_h
45
+
46
+ hash[hash.keys.max]
47
+ end
48
+
49
+ # @param article_tag [Nokogiri::XML::Element] article container node
50
+ # @return [String, nil] best style-based background image URL
51
+ def self.from_style(article_tag)
52
+ article_tag.css('[style*="url"]')
53
+ .filter_map { |tag| tag['style'][/url\(['"]?(.*?)['"]?\)/, 1] }
54
+ .reject { |src| src.start_with?('data:') }
55
+ .max_by(&:size)
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end