html2rss 0.22.2 → 0.24.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 (146) 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 +213 -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/category_extractor.rb +29 -114
  27. data/lib/html2rss/auto_source/scraper/schema/item_list.rb +50 -11
  28. data/lib/html2rss/auto_source/scraper/schema/thing.rb +35 -7
  29. data/lib/html2rss/auto_source/scraper/schema.rb +61 -18
  30. data/lib/html2rss/auto_source/scraper/semantic_html/{deduplicator.rb → entry_deduplicator.rb} +3 -3
  31. data/lib/html2rss/auto_source/scraper/semantic_html.rb +23 -171
  32. data/lib/html2rss/auto_source/scraper/sitemap.rb +153 -0
  33. data/lib/html2rss/auto_source/scraper/wordpress_api/page_scope/date_archive_range.rb +110 -0
  34. data/lib/html2rss/auto_source/scraper/wordpress_api/page_scope.rb +2 -78
  35. data/lib/html2rss/auto_source/scraper/wordpress_api.rb +9 -0
  36. data/lib/html2rss/auto_source/scraper.rb +10 -3
  37. data/lib/html2rss/auto_source.rb +30 -35
  38. data/lib/html2rss/channel.rb +198 -0
  39. data/lib/html2rss/cli.rb +5 -6
  40. data/lib/html2rss/config/auto_source_contract.rb +51 -0
  41. data/lib/html2rss/config/request_controls.rb +136 -0
  42. data/lib/html2rss/config/request_headers.rb +1 -1
  43. data/lib/html2rss/config/schema.rb +65 -114
  44. data/lib/html2rss/{selectors/config.rb → config/selectors_validator.rb} +56 -7
  45. data/lib/html2rss/config/validator.rb +6 -6
  46. data/lib/html2rss/config.rb +176 -19
  47. data/lib/html2rss/{configuration.rb → defaults.rb} +12 -10
  48. data/lib/html2rss/feed_builder/item_presentation.rb +42 -0
  49. data/lib/html2rss/feed_builder/json_feed/item.rb +112 -0
  50. data/lib/html2rss/feed_builder/json_feed.rb +81 -0
  51. data/lib/html2rss/feed_builder/rss/stylesheet.rb +65 -0
  52. data/lib/html2rss/feed_builder/rss.rb +116 -0
  53. data/lib/html2rss/feed_builder.rb +15 -0
  54. data/lib/html2rss/feed_pipeline/auto_fallback.rb +120 -60
  55. data/lib/html2rss/feed_pipeline/runtime_policy.rb +108 -0
  56. data/lib/html2rss/feed_pipeline/strategy_plan.rb +64 -0
  57. data/lib/html2rss/feed_pipeline.rb +83 -89
  58. data/lib/html2rss/feed_result.rb +92 -0
  59. data/lib/html2rss/html/article_extractor/category_extractor.rb +123 -0
  60. data/lib/html2rss/html/article_extractor/date_extractor.rb +22 -0
  61. data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +95 -0
  62. data/lib/html2rss/html/article_extractor/heading_extractor.rb +54 -0
  63. data/lib/html2rss/html/article_extractor/id_generator.rb +69 -0
  64. data/lib/html2rss/html/article_extractor/image_extractor.rb +60 -0
  65. data/lib/html2rss/html/article_extractor.rb +134 -0
  66. data/lib/html2rss/html/navigator/text_extractor.rb +79 -0
  67. data/lib/html2rss/html/navigator.rb +146 -0
  68. data/lib/html2rss/html/rendering/audio_renderer.rb +38 -0
  69. data/lib/html2rss/html/rendering/description_builder.rb +89 -0
  70. data/lib/html2rss/html/rendering/image_renderer.rb +43 -0
  71. data/lib/html2rss/html/rendering/media_renderer.rb +39 -0
  72. data/lib/html2rss/html/rendering/pdf_renderer.rb +36 -0
  73. data/lib/html2rss/html/rendering/video_renderer.rb +38 -0
  74. data/lib/html2rss/html/rendering.rb +25 -0
  75. data/lib/html2rss/html.rb +8 -0
  76. data/lib/html2rss/request_service/blocked_surface.rb +67 -0
  77. data/lib/html2rss/request_service/botasaurus_contract.rb +33 -9
  78. data/lib/html2rss/request_service/botasaurus_strategy.rb +21 -30
  79. data/lib/html2rss/request_service/browserless_strategy.rb +2 -23
  80. data/lib/html2rss/request_service/budget.rb +97 -18
  81. data/lib/html2rss/request_service/context.rb +1 -3
  82. data/lib/html2rss/request_service/faraday_strategy.rb +12 -26
  83. data/lib/html2rss/request_service/local_file_strategy.rb +12 -4
  84. data/lib/html2rss/request_service/network_guard.rb +125 -0
  85. data/lib/html2rss/request_service/policy.rb +10 -82
  86. data/lib/html2rss/request_service/puppet_commander/navigation_guards.rb +148 -0
  87. data/lib/html2rss/request_service/puppet_commander/preload_runner.rb +86 -0
  88. data/lib/html2rss/request_service/puppet_commander.rb +22 -191
  89. data/lib/html2rss/request_service/response.rb +9 -1
  90. data/lib/html2rss/request_service/response_guard.rb +1 -1
  91. data/lib/html2rss/request_service/strategy.rb +123 -5
  92. data/lib/html2rss/request_service.rb +7 -2
  93. data/lib/html2rss/request_session/pager/base.rb +83 -0
  94. data/lib/html2rss/request_session/pager/custom_selector.rb +39 -0
  95. data/lib/html2rss/request_session/pager/json_cursor.rb +78 -0
  96. data/lib/html2rss/request_session/pager/offset.rb +48 -0
  97. data/lib/html2rss/request_session/pager/rel_next.rb +23 -0
  98. data/lib/html2rss/request_session/pager/url_template.rb +48 -0
  99. data/lib/html2rss/request_session/pager.rb +75 -0
  100. data/lib/html2rss/request_session.rb +29 -16
  101. data/lib/html2rss/selectors/item_scope.rb +30 -0
  102. data/lib/html2rss/selectors/post_processors/base.rb +1 -1
  103. data/lib/html2rss/selectors/post_processors/markdown_to_html.rb +0 -1
  104. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +0 -2
  105. data/lib/html2rss/selectors/post_processors/template.rb +7 -3
  106. data/lib/html2rss/selectors.rb +84 -64
  107. data/lib/html2rss/status.rb +132 -0
  108. data/lib/html2rss/version.rb +1 -3
  109. data/lib/html2rss.rb +34 -22
  110. data/schema/html2rss-config.schema.json +109 -9
  111. metadata +71 -43
  112. data/lib/html2rss/auto_source/scraper/html/class_clustering.rb +0 -196
  113. data/lib/html2rss/auto_source/scraper/link_heuristics.rb +0 -401
  114. data/lib/html2rss/auto_source/scraper/schema/list_item.rb +0 -28
  115. data/lib/html2rss/auto_source/scraper/semantic_html/anchor_selector.rb +0 -49
  116. data/lib/html2rss/blocked_surface.rb +0 -65
  117. data/lib/html2rss/category_extractor.rb +0 -116
  118. data/lib/html2rss/config/class_methods.rb +0 -183
  119. data/lib/html2rss/html_extractor/date_extractor.rb +0 -20
  120. data/lib/html2rss/html_extractor/enclosure_extractor.rb +0 -91
  121. data/lib/html2rss/html_extractor/heading_extractor.rb +0 -50
  122. data/lib/html2rss/html_extractor/id_generator.rb +0 -67
  123. data/lib/html2rss/html_extractor/image_extractor.rb +0 -58
  124. data/lib/html2rss/html_extractor/list_candidates.rb +0 -88
  125. data/lib/html2rss/html_extractor/semantic_anchor_candidates.rb +0 -260
  126. data/lib/html2rss/html_extractor/semantic_containers.rb +0 -69
  127. data/lib/html2rss/html_extractor/text_extractor.rb +0 -77
  128. data/lib/html2rss/html_extractor.rb +0 -190
  129. data/lib/html2rss/html_navigator.rb +0 -71
  130. data/lib/html2rss/json_feed_builder/item.rb +0 -94
  131. data/lib/html2rss/json_feed_builder.rb +0 -59
  132. data/lib/html2rss/rendering/audio_renderer.rb +0 -36
  133. data/lib/html2rss/rendering/description_builder.rb +0 -87
  134. data/lib/html2rss/rendering/image_renderer.rb +0 -41
  135. data/lib/html2rss/rendering/media_renderer.rb +0 -37
  136. data/lib/html2rss/rendering/pdf_renderer.rb +0 -34
  137. data/lib/html2rss/rendering/video_renderer.rb +0 -36
  138. data/lib/html2rss/rendering.rb +0 -23
  139. data/lib/html2rss/request_controls.rb +0 -133
  140. data/lib/html2rss/request_session/rel_next_pager.rb +0 -70
  141. data/lib/html2rss/request_session/runtime_input.rb +0 -71
  142. data/lib/html2rss/request_session/runtime_policy.rb +0 -84
  143. data/lib/html2rss/rss_builder/article.rb +0 -208
  144. data/lib/html2rss/rss_builder/channel.rb +0 -118
  145. data/lib/html2rss/rss_builder/stylesheet.rb +0 -63
  146. data/lib/html2rss/rss_builder.rb +0 -101
@@ -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
@@ -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