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
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ module Html
5
+ ##
6
+ # ArticleExtractor is responsible for extracting details (headline, url, images, etc.)
7
+ # from an article_tag DOM node. DOM chrome helpers live on {Navigator}.
8
+ class ArticleExtractor
9
+ class << self
10
+ ##
11
+ # Extracts article attributes from a DOM element.
12
+ #
13
+ # @param article_tag [Nokogiri::XML::Node] article-like container to extract from
14
+ # @param base_url [String, Html2rss::Url] base url used to resolve relative links
15
+ # @param selected_anchor [Nokogiri::XML::Node, nil] explicit primary anchor for the container
16
+ # @param fallback_anchorless [Boolean] whether to fall back to anchorless extraction
17
+ # @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
+ end
21
+ end
22
+
23
+ ##
24
+ # @param article_tag [Nokogiri::XML::Node] article-like container to extract from
25
+ # @param base_url [String, Html2rss::Url] base url used to resolve relative links
26
+ # @param selected_anchor [Nokogiri::XML::Node, nil] explicit primary anchor for the container
27
+ # @param fallback_anchorless [Boolean] whether to fall back to anchorless extraction
28
+ def initialize(article_tag, base_url:, selected_anchor: nil, fallback_anchorless: false)
29
+ raise ArgumentError, 'article_tag is required' unless article_tag
30
+
31
+ @article_tag = article_tag
32
+ @base_url = base_url
33
+ @selected_anchor = selected_anchor
34
+ @fallback_anchorless = fallback_anchorless
35
+ end
36
+
37
+ # @return [Hash{Symbol => Object}] extracted article attributes
38
+ def call
39
+ {
40
+ title: extract_title,
41
+ url: extract_url,
42
+ image: extract_image,
43
+ description: extract_description,
44
+ id: generate_id,
45
+ published_at: extract_published_at,
46
+ enclosures: extract_enclosures,
47
+ categories: extract_categories
48
+ }
49
+ end
50
+
51
+ private
52
+
53
+ attr_reader :article_tag, :base_url, :selected_anchor
54
+
55
+ def extract_url
56
+ @extract_url ||= begin
57
+ href = selected_anchor&.[]('href').to_s
58
+
59
+ if href.empty?
60
+ anchorless_url_fallback
61
+ else
62
+ Url.from_relative(href.split('#').first.strip, base_url)
63
+ end
64
+ end
65
+ end
66
+
67
+ def anchorless_url_fallback
68
+ return unless @fallback_anchorless
69
+
70
+ id = generate_id
71
+ Url.from_relative("##{id}", base_url) if id
72
+ end
73
+
74
+ # rubocop:disable Metrics/CyclomaticComplexity
75
+ def extract_title
76
+ source = heading || selected_anchor
77
+ title_text = source ? Navigator.extract_visible_text(source) : fallback_anchorless_title
78
+ return unless title_text
79
+
80
+ kicker = kicker_node ? Navigator.extract_visible_text(kicker_node).to_s.strip : nil
81
+ kicker && !kicker.empty? && !title_text.include?(kicker) ? "#{kicker}: #{title_text}" : title_text
82
+ end
83
+ # rubocop:enable Metrics/CyclomaticComplexity
84
+
85
+ def fallback_anchorless_title
86
+ return unless @fallback_anchorless && selected_anchor.nil?
87
+
88
+ text_node = article_tag.xpath('.//text()').find { |t| !t.text.strip.empty? }
89
+ text_node&.text&.strip
90
+ end
91
+
92
+ def heading
93
+ @heading ||= HeadingExtractor.call(
94
+ article_tag,
95
+ fallback_anchorless: @fallback_anchorless,
96
+ selected_anchor:
97
+ )
98
+ end
99
+
100
+ def kicker_node
101
+ @kicker_node ||= begin
102
+ selector = '[data-tb-kicker], [class*="kicker"], [class*="eyebrow"], ' \
103
+ '[class*="pre-title"], [class*="pretitle"], [class*="overline"]'
104
+ node = article_tag.at_css(selector)
105
+ node && heading && (node == heading || Navigator.descendant_of?(node, heading)) ? nil : node
106
+ end
107
+ end
108
+
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?
113
+
114
+ desc = description.strip
115
+ desc.empty? ? nil : desc
116
+ end
117
+
118
+ def generate_id
119
+ @generate_id ||= IdGenerator.call(
120
+ article_tag,
121
+ heading:,
122
+ url: (selected_anchor ? extract_url : nil),
123
+ selected_anchor:,
124
+ fallback_anchorless: @fallback_anchorless
125
+ )
126
+ end
127
+
128
+ def extract_image = ImageExtractor.call(article_tag, base_url:)
129
+ def extract_published_at = DateExtractor.call(article_tag)
130
+ def extract_enclosures = EnclosureExtractor.call(article_tag, base_url)
131
+ def extract_categories = CategoryExtractor.call(article_tag)
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ module Html
5
+ class Navigator
6
+ ##
7
+ # TextExtractor extracts visible text from DOM elements, preserving lists
8
+ # and block spacing while sanitizing white spaces.
9
+ class TextExtractor
10
+ # HTML block elements that trigger line breaks or special formatting.
11
+ BLOCK_TAGS = %w[p div li ul ol h1 h2 h3 h4 h5 h6 tr br].to_set.freeze
12
+ # Tags ignored when extracting visible text content.
13
+ INVISIBLE_CONTENT_TAGS = %w[svg script noscript style template].to_set.freeze
14
+
15
+ class << self
16
+ ##
17
+ # @param tag [Nokogiri::XML::Node] the node from which to extract visible text
18
+ # @param separator [String] separator used to join text fragments (default is a space)
19
+ # @param exclude_nodes [Array<Nokogiri::XML::Node>, nil] nodes to exclude from extraction
20
+ # @return [String, nil] the concatenated visible text, or nil if none is found
21
+ def call(tag, separator: ' ', exclude_nodes: nil)
22
+ return tag.text.gsub(/\s+/, ' ').strip if tag.respond_to?(:text?) && tag.text?
23
+
24
+ parts = iterate_children(tag, separator, exclude_nodes)
25
+ return if parts.empty?
26
+
27
+ parts.join.squeeze(' ').strip
28
+ end
29
+
30
+ private
31
+
32
+ def iterate_children(tag, separator, exclude_nodes)
33
+ last = false
34
+ tag.children.each_with_object([]) do |c, p|
35
+ next if exclude_nodes&.include?(c) || !visible_child?(c)
36
+
37
+ text, block = process_child_node(c, separator, exclude_nodes)
38
+ next if text.empty?
39
+
40
+ append_separator!(p, separator, block, last)
41
+ (p << text) && (last = block)
42
+ end
43
+ end
44
+
45
+ def process_child_node(child, separator, exclude_nodes)
46
+ child_text = get_child_text(child, separator, exclude_nodes)
47
+ return ['', false] if child_text.empty?
48
+
49
+ child_text = "- #{child_text}" if child.name == 'li'
50
+ [child_text, BLOCK_TAGS.include?(child.name)]
51
+ end
52
+
53
+ def get_child_text(child, separator, exclude_nodes)
54
+ if child.children.empty?
55
+ child.text.to_s.gsub(/\s+/, ' ').strip
56
+ else
57
+ call(child, separator:, exclude_nodes:).to_s.strip
58
+ end
59
+ end
60
+
61
+ def append_separator!(parts, separator, is_block, last_was_block)
62
+ return if parts.empty?
63
+
64
+ parts << if is_block || last_was_block
65
+ (separator == ' ' ? "\n" : separator)
66
+ else
67
+ ' '
68
+ end
69
+ end
70
+
71
+ def visible_child?(node)
72
+ !INVISIBLE_CONTENT_TAGS.include?(node.name) &&
73
+ !(node.name == 'a' && node['href']&.start_with?('#'))
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ module Html
5
+ ##
6
+ # Navigator owns DOM chrome recognition and node traversal helpers.
7
+ class Navigator
8
+ # Heading tags used to prioritize title extraction and container assessment.
9
+ HEADING_TAGS = %w[h1 h2 h3 h4 h5 h6].freeze
10
+
11
+ # Element tags that indicate ignored DOM chrome when found in a container path.
12
+ IGNORED_CONTAINER_TAGS = %w[nav footer header svg script style].to_set.freeze
13
+
14
+ # Layout roots and chrome tags excluded from class-clustering candidate nodes.
15
+ CLUSTER_EXCLUDED_TAGS = Set['html', 'body', 'nav', 'footer', 'header', 'svg', 'script', 'style'].freeze
16
+
17
+ # Ancestor tags that usually indicate navigation/utility regions inside a content container.
18
+ UTILITY_LANDMARK_TAGS = %w[nav aside footer menu].to_set.freeze
19
+
20
+ # Anchor selector used to identify the canonical article link element.
21
+ MAIN_ANCHOR_SELECTOR = begin
22
+ buf = +'a[href]:not([href=""])'
23
+ %w[# javascript: mailto: tel: file:// sms: data:].each do |prefix|
24
+ buf << %[:not([href^="#{prefix}"])]
25
+ end
26
+ buf.freeze
27
+ end
28
+
29
+ class << self
30
+ ##
31
+ # Extracts visible text from a given node and its children.
32
+ #
33
+ # @param tag [Nokogiri::XML::Node] the node from which to extract visible text
34
+ # @param separator [String] separator used to join text fragments (default is a space)
35
+ # @param exclude_nodes [Array<Nokogiri::XML::Node>, nil] nodes to exclude from extraction
36
+ # @return [String, nil] the concatenated visible text, or nil if none is found
37
+ def extract_visible_text(tag, separator: ' ', exclude_nodes: nil)
38
+ TextExtractor.call(tag, separator:, exclude_nodes:)
39
+ end
40
+
41
+ ##
42
+ # @param article_tag [Nokogiri::XML::Node] article-like container to search within
43
+ # @return [Nokogiri::XML::Node, nil] first eligible descendant anchor
44
+ def main_anchor_for(article_tag)
45
+ return article_tag if article_tag.name == 'a' && article_tag.matches?(MAIN_ANCHOR_SELECTOR)
46
+
47
+ article_tag.at_css(MAIN_ANCHOR_SELECTOR)
48
+ end
49
+
50
+ ##
51
+ # @param node [Nokogiri::XML::Node]
52
+ # @param cache [Hash, nil] identity cache used to store results (must use compare_by_identity)
53
+ # @return [Boolean] true when the node belongs to ignored DOM chrome
54
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
55
+ def ignored_container_path?(node, cache = nil)
56
+ return cache[node] if cache&.key?(node)
57
+
58
+ curr = node
59
+ visited = []
60
+ is_ignored = false
61
+
62
+ while curr.respond_to?(:parent) && curr
63
+ if cache&.key?(curr)
64
+ is_ignored = cache[curr]
65
+ break
66
+ end
67
+
68
+ if IGNORED_CONTAINER_TAGS.include?(curr.name)
69
+ is_ignored = true
70
+ break
71
+ end
72
+
73
+ visited << curr
74
+ curr = curr.parent
75
+ end
76
+ visited.each { |n| cache[n] = is_ignored } if cache
77
+
78
+ is_ignored
79
+ end
80
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
81
+
82
+ ##
83
+ # Returns the first parent that satisfies the condition.
84
+ # If the condition is met, it returns the node itself.
85
+ #
86
+ # @param node [Nokogiri::XML::Node] The node to start the search from.
87
+ # @param condition [Proc] The condition to be met.
88
+ # @return [Nokogiri::XML::Node, nil] The first parent that satisfies the condition.
89
+ def parent_until_condition(node, condition)
90
+ while node && !node.document? && node.name != 'html'
91
+ return node if condition.call(node)
92
+
93
+ node = node.parent
94
+ end
95
+ end
96
+
97
+ ##
98
+ # Think of it as `css_upwards` method.
99
+ # It searches for the closest parent that matches the given selector.
100
+ #
101
+ # @param current_tag [Nokogiri::XML::Node, nil] starting node
102
+ # @param selector [String] CSS selector to search upwards for
103
+ # @return [Nokogiri::XML::Node, nil] first matching node in upward traversal
104
+ def find_closest_selector_upwards(current_tag, selector)
105
+ while current_tag
106
+ found = current_tag.at_css(selector)
107
+ return found if found
108
+
109
+ return nil unless current_tag.respond_to?(:parent)
110
+
111
+ current_tag = current_tag.parent
112
+ end
113
+ end
114
+
115
+ ##
116
+ # Searches for the closest parent that matches the given tag name.
117
+ #
118
+ # @param current_tag [Nokogiri::XML::Node] starting node
119
+ # @param tag_name [String] tag name to find in ancestors
120
+ # @return [Nokogiri::XML::Node, nil] matching ancestor node
121
+ def find_tag_in_ancestors(current_tag, tag_name)
122
+ return current_tag if current_tag.name == tag_name
123
+
124
+ current_tag.ancestors(tag_name).first
125
+ end
126
+
127
+ ##
128
+ # Returns true if child_node is a descendant of parent_node.
129
+ # Walks up using parent pointers to avoid NodeSet allocations.
130
+ #
131
+ # @param child_node [Nokogiri::XML::Node] potential descendant
132
+ # @param parent_node [Nokogiri::XML::Node] potential ancestor
133
+ # @return [Boolean] true when child_node is a descendant of parent_node
134
+ def descendant_of?(child_node, parent_node)
135
+ curr = child_node.respond_to?(:parent) ? child_node.parent : nil
136
+ while curr
137
+ return true if curr == parent_node
138
+
139
+ curr = curr.respond_to?(:parent) ? curr.parent : nil
140
+ end
141
+ false
142
+ end
143
+ end
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cgi'
4
+
5
+ module Html2rss
6
+ module Html
7
+ module Rendering
8
+ # Renders an HTML <audio> tag from a URL and type.
9
+ class AudioRenderer
10
+ # @param url [String, Html2rss::Url] media URL for the audio source
11
+ # @param type [String] MIME type for the audio source
12
+ def initialize(url:, type:)
13
+ @url = url
14
+ @type = type
15
+ end
16
+
17
+ # @return [String] HTML audio snippet for article rendering
18
+ def to_html
19
+ [
20
+ '<audio controls preload="none" referrerpolicy="no-referrer" crossorigin="anonymous">',
21
+ %(<source src="#{escaped_url}" type="#{escaped_type}">),
22
+ '</audio>'
23
+ ].join
24
+ end
25
+
26
+ private
27
+
28
+ def escaped_url
29
+ CGI.escapeHTML(@url.to_s)
30
+ end
31
+
32
+ def escaped_type
33
+ CGI.escapeHTML(@type.to_s)
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ module Html
5
+ module Rendering
6
+ # Builds a sanitized article description from the base text, title, and optional media.
7
+ #
8
+ # Combines media elements (images, audio, video, PDFs) with sanitized text content
9
+ # to create rich RSS descriptions that reveal more scraped information.
10
+ #
11
+ # @example Basic usage
12
+ # builder = DescriptionBuilder.new(
13
+ # base: "Article content",
14
+ # title: "Article Title",
15
+ # url: "https://example.com",
16
+ # enclosures: [enclosure_object],
17
+ # image: "https://example.com/image.jpg"
18
+ # )
19
+ # description = builder.call
20
+ class DescriptionBuilder
21
+ # Removes the specified pattern from the beginning of the text
22
+ # within a given range if the pattern occurs before the range's end.
23
+ #
24
+ # @param text [String]
25
+ # @param pattern [String]
26
+ # @param end_of_range [Integer] Optional, defaults to half the text length
27
+ # @return [String]
28
+ def self.remove_pattern_from_start(text, pattern, end_of_range: (text.size * 0.5).to_i)
29
+ return text unless text.is_a?(String) && pattern.is_a?(String) && !pattern.empty?
30
+
31
+ index = text.index(pattern)
32
+ return text if index.nil? || index > end_of_range
33
+
34
+ "#{text[0, index]}#{text[(index + pattern.size)..]}"
35
+ end
36
+
37
+ # @param base [String] The base text content for the description
38
+ # @param title [String] The article title (used for alt text and title removal)
39
+ # @param url [String, Html2rss::Url] The article URL (used for sanitization)
40
+ # @param enclosures [Array<Html2rss::Article::Enclosure>, nil] Media enclosures
41
+ # @param image [String, Html2rss::Url, nil] Fallback image URL
42
+ def initialize(base:, title:, url:, enclosures:, image:)
43
+ @base = base.to_s
44
+ @title = title
45
+ @url = url
46
+ @enclosures = Array(enclosures)
47
+ @image = image
48
+ end
49
+
50
+ # Generates the complete description with media and sanitized text.
51
+ #
52
+ # @return [String, nil] The complete description or nil if empty
53
+ def call
54
+ fragments = []
55
+ fragments.concat(Array(rendered_media))
56
+ fragments << processed_base_description
57
+
58
+ result = fragments.compact.join("\n\n").strip
59
+ result.empty? ? nil : result
60
+ end
61
+
62
+ private
63
+
64
+ def rendered_media
65
+ rendered = render_enclosures
66
+ return rendered if rendered.any?
67
+ return render_fallback_image if @image
68
+
69
+ []
70
+ end
71
+
72
+ def render_enclosures
73
+ @enclosures.filter_map do |enclosure|
74
+ MediaRenderer.for(enclosure:, image: @image, title: @title)&.to_html
75
+ end
76
+ end
77
+
78
+ def render_fallback_image
79
+ [MediaRenderer.for(enclosure: nil, image: @image, title: @title)&.to_html]
80
+ end
81
+
82
+ def processed_base_description
83
+ text = self.class.remove_pattern_from_start(@base, @title)
84
+ Html2rss::Selectors::PostProcessors::SanitizeHtml.get(text, @url)
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cgi'
4
+
5
+ module Html2rss
6
+ module Html
7
+ module Rendering
8
+ # Renders an HTML <img> tag from a URL and title.
9
+ class ImageRenderer
10
+ # @param url [String, Html2rss::Url] image URL for the src attribute
11
+ # @param title [String, nil] title/alt text for the image
12
+ def initialize(url:, title:)
13
+ @url = url
14
+ @title = title
15
+ end
16
+
17
+ # @return [String] HTML image snippet for article rendering
18
+ def to_html
19
+ attributes = [
20
+ %(src="#{escaped_url}"),
21
+ %(alt="#{escaped_title}"),
22
+ %(title="#{escaped_title}"),
23
+ 'loading="lazy"',
24
+ 'referrerpolicy="no-referrer"',
25
+ 'decoding="async"',
26
+ 'crossorigin="anonymous"'
27
+ ]
28
+ "<img #{attributes.join(' ')}>"
29
+ end
30
+
31
+ private
32
+
33
+ def escaped_url
34
+ CGI.escapeHTML(@url.to_s)
35
+ end
36
+
37
+ def escaped_title
38
+ CGI.escapeHTML(@title.to_s)
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ module Html
5
+ module Rendering
6
+ # Factory: picks the appropriate renderer for a given enclosure or fallback image.
7
+ class MediaRenderer
8
+ # @param enclosure [Html2rss::Article::Enclosure, nil]
9
+ # @param image [String, Html2rss::Url, nil] Fallback image URL
10
+ # @param title [String]
11
+ # @return [ImageRenderer, VideoRenderer, AudioRenderer, PdfRenderer, nil]
12
+ def self.for(enclosure:, image:, title:)
13
+ return ImageRenderer.new(url: image, title:) if enclosure.nil? && image
14
+ return nil unless enclosure
15
+
16
+ create_renderer_for_type(enclosure.type, url: enclosure.url, title:)
17
+ end
18
+
19
+ # @private
20
+ # @param type [String, nil] enclosure MIME type
21
+ # @param url [String, Html2rss::Url] enclosure URL
22
+ # @param title [String, nil] title used by image renderer
23
+ # @return [ImageRenderer, VideoRenderer, AudioRenderer, PdfRenderer, nil]
24
+ def self.create_renderer_for_type(type, url:, title:)
25
+ case type
26
+ when %r{^image/}
27
+ ImageRenderer.new(url:, title:)
28
+ when %r{^video/}
29
+ VideoRenderer.new(url:, type:)
30
+ when %r{^audio/}
31
+ AudioRenderer.new(url:, type:)
32
+ when 'application/pdf'
33
+ PdfRenderer.new(url:)
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cgi'
4
+
5
+ module Html2rss
6
+ module Html
7
+ module Rendering
8
+ # Renders an HTML <iframe> for PDF documents.
9
+ class PdfRenderer
10
+ # @param url [String, Html2rss::Url] PDF URL rendered in the iframe
11
+ def initialize(url:)
12
+ @url = url
13
+ end
14
+
15
+ # @return [String] HTML iframe snippet for PDF rendering
16
+ def to_html
17
+ attributes = [
18
+ %(src="#{escaped_url}"),
19
+ 'width="100%"',
20
+ 'height="75vh"',
21
+ 'sandbox=""',
22
+ 'referrerpolicy="no-referrer"',
23
+ 'loading="lazy"'
24
+ ]
25
+ "<iframe #{attributes.join(' ')}></iframe>"
26
+ end
27
+
28
+ private
29
+
30
+ def escaped_url
31
+ CGI.escapeHTML(@url.to_s)
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cgi'
4
+
5
+ module Html2rss
6
+ module Html
7
+ module Rendering
8
+ # Renders an HTML <video> tag from a URL and type.
9
+ class VideoRenderer
10
+ # @param url [String, Html2rss::Url] media URL for the video source
11
+ # @param type [String] MIME type for the video source
12
+ def initialize(url:, type:)
13
+ @url = url
14
+ @type = type
15
+ end
16
+
17
+ # @return [String] HTML video snippet for article rendering
18
+ def to_html
19
+ [
20
+ '<video controls preload="none" referrerpolicy="no-referrer" crossorigin="anonymous" playsinline>',
21
+ %(<source src="#{escaped_url}" type="#{escaped_type}">),
22
+ '</video>'
23
+ ].join
24
+ end
25
+
26
+ private
27
+
28
+ def escaped_url
29
+ CGI.escapeHTML(@url.to_s)
30
+ end
31
+
32
+ def escaped_type
33
+ CGI.escapeHTML(@type.to_s)
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ module Html
5
+ # Namespace for HTML rendering logic, used to generate rich content such as
6
+ # images, audio, video, or embedded documents for feed descriptions.
7
+ #
8
+ # @see Html2rss::Html::Rendering::DescriptionBuilder
9
+ #
10
+ # @example
11
+ # Html2rss::Html::Rendering::ImageRenderer.new(
12
+ # url: "https://example.com/image.jpg",
13
+ # title: "Example"
14
+ # ).to_html
15
+ #
16
+ # @example
17
+ # Html2rss::Html::Rendering::MediaRenderer.for(
18
+ # enclosure: nil,
19
+ # image: "https://example.com/image.jpg",
20
+ # title: "Example"
21
+ # )
22
+ module Rendering
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ ##
5
+ # DOM helpers for navigating and extracting from HTML trees.
6
+ module Html
7
+ end
8
+ end