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
@@ -1,260 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Html2rss
4
- class HtmlExtractor
5
- ##
6
- # Builds ranked anchor facts for one semantic content container.
7
- class SemanticAnchorCandidates
8
- # Anchor candidate plus scoring signals used by semantic anchor ranking.
9
- AnchorFacts = Data.define(
10
- :anchor,
11
- :text,
12
- :url,
13
- :destination,
14
- :segments,
15
- :meaningful_text,
16
- :content_like_destination,
17
- :heading_anchor,
18
- :heading_text_match,
19
- :score
20
- ) do
21
- # @param candidate [Candidate] eligible semantic anchor candidate
22
- # @return [AnchorFacts] serializable facts used for ranking and dedupe
23
- def self.from_candidate(candidate)
24
- new(
25
- **candidate.anchor_identity_attributes,
26
- **candidate.anchor_signal_attributes,
27
- score: Score.new(candidate).value
28
- )
29
- end
30
- end
31
-
32
- # Shared context for all anchors in one semantic container.
33
- class Context
34
- attr_reader :container
35
-
36
- # Ancestor tags that usually indicate navigation/utility regions.
37
- UTILITY_LANDMARK_TAGS = %w[nav aside footer menu].freeze
38
-
39
- # @param container [Nokogiri::XML::Node] semantic container
40
- # @param link_heuristics [Html2rss::AutoSource::Scraper::LinkHeuristics] destination/text heuristics
41
- def initialize(container, link_heuristics:)
42
- @container = container
43
- @link_heuristics = link_heuristics
44
- end
45
-
46
- # @return [Nokogiri::XML::Node, nil] heading used to identify title anchors
47
- def heading
48
- @heading ||= @container.at_css(HtmlExtractor::HEADING_TAGS.join(','))
49
- end
50
-
51
- # @return [String] visible heading text
52
- def heading_text
53
- @heading_text ||= visible_text(heading)
54
- end
55
-
56
- # @param node [Nokogiri::XML::Node, nil] node to extract text from
57
- # @return [String] visible text for the node
58
- def visible_text(node)
59
- return '' unless node
60
-
61
- (@visible_texts ||= {}.compare_by_identity)[node] ||= HtmlExtractor.extract_visible_text(node).to_s.strip
62
- end
63
-
64
- # @param anchor [Nokogiri::XML::Node] anchor candidate
65
- # @return [Html2rss::AutoSource::Scraper::LinkHeuristics::DestinationFacts, nil] destination facts
66
- def destination_facts(anchor)
67
- @link_heuristics.destination_facts(anchor)
68
- end
69
-
70
- # @param text [String] visible anchor text
71
- # @return [Boolean] true when text is utility chrome
72
- def utility_text?(text)
73
- @link_heuristics.utility_text?(text)
74
- end
75
- end
76
-
77
- # One anchor plus the facts needed to decide whether it represents content.
78
- class Candidate
79
- attr_reader :anchor
80
-
81
- # @param anchor [Nokogiri::XML::Node] anchor candidate
82
- # @param context [Context] semantic container context
83
- def initialize(anchor, context)
84
- @anchor = anchor
85
- @context = context
86
- end
87
-
88
- # @return [AnchorFacts, nil] ranked anchor facts when the anchor is eligible
89
- def facts
90
- return unless destination_facts
91
- return if utility_text_suppressed? || ineligible_anchor?
92
- return unless representative_content_anchor?
93
-
94
- AnchorFacts.from_candidate(self)
95
- end
96
-
97
- # @return [Html2rss::AutoSource::Scraper::LinkHeuristics::DestinationFacts, nil] destination facts
98
- def destination_facts
99
- @destination_facts ||= @context.destination_facts(@anchor)
100
- end
101
-
102
- # @return [String] visible anchor text
103
- def text
104
- @text ||= @context.visible_text(@anchor)
105
- end
106
-
107
- # @return [Hash] anchor identity attributes used to build AnchorFacts
108
- def anchor_identity_attributes
109
- {
110
- anchor:,
111
- text:,
112
- url: destination_facts.url,
113
- destination: destination_facts.destination,
114
- segments: destination_facts.segments
115
- }
116
- end
117
-
118
- # @return [Hash] anchor signal attributes used to build AnchorFacts
119
- def anchor_signal_attributes
120
- {
121
- meaningful_text: meaningful_text?,
122
- content_like_destination: content_like_destination?,
123
- heading_anchor: heading_anchor?,
124
- heading_text_match: heading_text_match?
125
- }
126
- end
127
-
128
- # @return [Boolean] true when visible anchor text has words
129
- def meaningful_text?
130
- @meaningful_text ||= text.match?(/\p{Alnum}/)
131
- end
132
-
133
- # @return [Boolean] true when the destination route has content signals
134
- def content_like_destination?
135
- destination_facts.content_path
136
- end
137
-
138
- # @return [Boolean] true when the anchor is inside the selected heading
139
- def heading_anchor?
140
- heading = @context.heading
141
- heading && (@anchor == heading || HtmlNavigator.descendant_of?(@anchor, heading))
142
- end
143
-
144
- # @return [Boolean] true when anchor text exactly matches heading text
145
- def heading_text_match?
146
- heading_text = @context.heading_text
147
-
148
- meaningful_text? &&
149
- heading_text.match?(/\p{Alnum}/) &&
150
- heading_text == text
151
- end
152
-
153
- private
154
-
155
- def representative_content_anchor?
156
- meaningful_text? || content_like_destination? || heading_anchor?
157
- end
158
-
159
- def utility_text_suppressed?
160
- !content_like_destination? &&
161
- @context.utility_text?(text) &&
162
- (destination_facts.high_confidence_utility_destination || non_heading_weak_post?)
163
- end
164
-
165
- def non_heading_weak_post?
166
- !heading_anchor? && !destination_facts.strong_post_suffix
167
- end
168
-
169
- def ineligible_anchor?
170
- destination_facts.high_confidence_utility_destination ||
171
- icon_only_anchor? ||
172
- utility_landmark_ancestor?
173
- end
174
-
175
- def utility_landmark_ancestor?
176
- container = @context.container
177
- condition = proc { |node| node == container || Context::UTILITY_LANDMARK_TAGS.include?(node.name) }
178
- landmark = HtmlNavigator.parent_until_condition(@anchor.parent, condition)
179
-
180
- landmark && landmark != container
181
- end
182
-
183
- def icon_only_anchor?
184
- !meaningful_text? && @anchor.at_css('img, svg')
185
- end
186
- end
187
-
188
- # Scores an eligible semantic anchor candidate.
189
- class Score
190
- # Score weights keyed by candidate signal predicate.
191
- RULES = {
192
- heading_anchor?: 100,
193
- heading_text_match?: 20,
194
- meaningful_text?: 10,
195
- content_like_destination?: 10
196
- }.freeze
197
-
198
- # @param candidate [Candidate] eligible semantic anchor candidate
199
- def initialize(candidate)
200
- @candidate = candidate
201
- end
202
-
203
- # @return [Integer] ranking score
204
- def value
205
- RULES.sum { |predicate, weight| @candidate.public_send(predicate) ? weight : 0 }
206
- end
207
- end
208
-
209
- # Keeps the strongest semantic anchor fact for each destination.
210
- class DestinationWinners
211
- def initialize
212
- @winners = {}
213
- end
214
-
215
- # @return [Array<AnchorFacts>] strongest candidate per destination
216
- def to_a
217
- @winners.values
218
- end
219
-
220
- # @param facts [AnchorFacts] candidate anchor facts
221
- # @return [void]
222
- def add(facts)
223
- destination = facts.destination
224
- @winners[destination] = stronger_fact(@winners[destination], facts)
225
- end
226
-
227
- private
228
-
229
- def stronger_fact(current, candidate)
230
- return candidate unless current
231
-
232
- current.score >= candidate.score ? current : candidate
233
- end
234
- end
235
-
236
- # @param container [Nokogiri::XML::Node] semantic container
237
- # @param link_heuristics [Html2rss::AutoSource::Scraper::LinkHeuristics] destination/text heuristics
238
- def initialize(container, link_heuristics:)
239
- @container = container
240
- @context = Context.new(container, link_heuristics:)
241
- end
242
-
243
- # @return [Array<AnchorFacts>] strongest candidate per destination
244
- def to_a
245
- @container.css(HtmlExtractor::MAIN_ANCHOR_SELECTOR)
246
- .each_with_object(DestinationWinners.new) { |anchor, winners| add_anchor(anchor, winners) }
247
- .to_a
248
- end
249
-
250
- private
251
-
252
- def add_anchor(anchor, winners)
253
- return if HtmlExtractor.ignored_container_path?(anchor)
254
-
255
- facts = Candidate.new(anchor, @context).facts
256
- winners.add(facts) if facts
257
- end
258
- end
259
- end
260
- end
@@ -1,69 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Html2rss
4
- class HtmlExtractor
5
- ##
6
- # Collects semantic content containers from a parsed HTML document.
7
- class SemanticContainers
8
- # Candidate selectors used to locate extractable semantic content blocks.
9
- SELECTORS = [
10
- 'article:not(:has(article))',
11
- 'section:not(:has(section))',
12
- 'li:not(:has(li))',
13
- 'tr:not(:has(tr))',
14
- 'div:not(:has(div))'
15
- ].freeze
16
-
17
- # @param parsed_body [Nokogiri::HTML::Document] parsed document
18
- # @return [Array<Nokogiri::XML::Node>] candidate semantic containers
19
- def self.call(parsed_body)
20
- new(parsed_body).call
21
- end
22
-
23
- # @param parsed_body [Nokogiri::HTML::Document] parsed document
24
- def initialize(parsed_body)
25
- @parsed_body = parsed_body
26
- end
27
-
28
- # @return [Array<Nokogiri::XML::Node>] candidate semantic containers
29
- def call
30
- cache = {}.compare_by_identity
31
- candidates = @parsed_body.css(SELECTORS.join(',')).reject do |node|
32
- HtmlExtractor.ignored_container_path?(node, cache)
33
- end
34
-
35
- candidates = filter_nested_containers(candidates)
36
- sort_by_depth(candidates)
37
- end
38
-
39
- private
40
-
41
- def filter_nested_containers(candidates)
42
- candidate_set = Set.new(candidates)
43
- rejected = Set.new
44
-
45
- candidates.each do |candidate_b|
46
- next if candidate_b.name == 'div'
47
-
48
- find_and_reject_ancestors(candidate_b, candidate_set, rejected)
49
- end
50
-
51
- candidates.reject { |c| rejected.include?(c) }
52
- end
53
-
54
- def find_and_reject_ancestors(node, candidate_set, rejected)
55
- curr = node.parent
56
- while curr && !curr.document? && curr.name != 'html'
57
- rejected << curr if candidate_set.include?(curr)
58
- curr = curr.parent
59
- end
60
- end
61
-
62
- def sort_by_depth(candidates)
63
- candidates.each_with_index
64
- .sort_by { |node, index| [-node.ancestors.size, index] }
65
- .map!(&:first)
66
- end
67
- end
68
- end
69
- end
@@ -1,77 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Html2rss
4
- class HtmlExtractor
5
- ##
6
- # TextExtractor extracts visible text from DOM elements, preserving lists
7
- # and block spacing while sanitizing white spaces.
8
- class TextExtractor
9
- # HTML block elements that trigger line breaks or special formatting.
10
- BLOCK_TAGS = %w[p div li ul ol h1 h2 h3 h4 h5 h6 tr br].to_set.freeze
11
- # Tags ignored when extracting visible text content.
12
- INVISIBLE_CONTENT_TAGS = %w[svg script noscript style template].to_set.freeze
13
-
14
- class << self
15
- ##
16
- # @param tag [Nokogiri::XML::Node] the node from which to extract visible text
17
- # @param separator [String] separator used to join text fragments (default is a space)
18
- # @param exclude_nodes [Array<Nokogiri::XML::Node>, nil] nodes to exclude from extraction
19
- # @return [String, nil] the concatenated visible text, or nil if none is found
20
- def call(tag, separator: ' ', exclude_nodes: nil)
21
- return tag.text.gsub(/\s+/, ' ').strip if tag.respond_to?(:text?) && tag.text?
22
-
23
- parts = iterate_children(tag, separator, exclude_nodes)
24
- return if parts.empty?
25
-
26
- parts.join.squeeze(' ').strip
27
- end
28
-
29
- private
30
-
31
- def iterate_children(tag, separator, exclude_nodes)
32
- last = false
33
- tag.children.each_with_object([]) do |c, p|
34
- next if exclude_nodes&.include?(c) || !visible_child?(c)
35
-
36
- text, block = process_child_node(c, separator, exclude_nodes)
37
- next if text.empty?
38
-
39
- append_separator!(p, separator, block, last)
40
- (p << text) && (last = block)
41
- end
42
- end
43
-
44
- def process_child_node(child, separator, exclude_nodes)
45
- child_text = get_child_text(child, separator, exclude_nodes)
46
- return ['', false] if child_text.empty?
47
-
48
- child_text = "- #{child_text}" if child.name == 'li'
49
- [child_text, BLOCK_TAGS.include?(child.name)]
50
- end
51
-
52
- def get_child_text(child, separator, exclude_nodes)
53
- if child.children.empty?
54
- child.text.to_s.gsub(/\s+/, ' ').strip
55
- else
56
- call(child, separator:, exclude_nodes:).to_s.strip
57
- end
58
- end
59
-
60
- def append_separator!(parts, separator, is_block, last_was_block)
61
- return if parts.empty?
62
-
63
- parts << if is_block || last_was_block
64
- (separator == ' ' ? "\n" : separator)
65
- else
66
- ' '
67
- end
68
- end
69
-
70
- def visible_child?(node)
71
- !INVISIBLE_CONTENT_TAGS.include?(node.name) &&
72
- !(node.name == 'a' && node['href']&.start_with?('#'))
73
- end
74
- end
75
- end
76
- end
77
- end
@@ -1,190 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Html2rss
4
- ##
5
- # HtmlExtractor is responsible for extracting details (headline, url, images, etc.)
6
- # from an article_tag.
7
- # rubocop:disable Metrics/ClassLength
8
- class HtmlExtractor
9
- # Heading tags used to prioritize title extraction.
10
- HEADING_TAGS = %w[h1 h2 h3 h4 h5 h6].freeze
11
-
12
- # Element tags that indicate ignored DOM chrome when found in a container path.
13
- IGNORED_CONTAINER_TAGS = %w[nav footer header svg script style].to_set.freeze
14
-
15
- # Anchor selector used to identify the canonical article link element.
16
- MAIN_ANCHOR_SELECTOR = begin
17
- buf = +'a[href]:not([href=""])'
18
- %w[# javascript: mailto: tel: file:// sms: data:].each do |prefix|
19
- buf << %[:not([href^="#{prefix}"])]
20
- end
21
- buf.freeze
22
- end
23
-
24
- class << self
25
- ##
26
- # Extracts visible text from a given node and its children.
27
- # Delegates to TextExtractor.
28
- #
29
- # @param tag [Nokogiri::XML::Node] the node from which to extract visible text
30
- # @param separator [String] separator used to join text fragments (default is a space)
31
- # @param exclude_nodes [Array<Nokogiri::XML::Node>, nil] nodes to exclude from extraction
32
- # @return [String, nil] the concatenated visible text, or nil if none is found
33
- def extract_visible_text(tag, separator: ' ', exclude_nodes: nil)
34
- TextExtractor.call(tag, separator:, exclude_nodes:)
35
- end
36
-
37
- ##
38
- # @param article_tag [Nokogiri::XML::Node] article-like container to search within
39
- # @return [Nokogiri::XML::Node, nil] first eligible descendant anchor
40
- def main_anchor_for(article_tag)
41
- return article_tag if article_tag.name == 'a' && article_tag.matches?(MAIN_ANCHOR_SELECTOR)
42
-
43
- article_tag.at_css(MAIN_ANCHOR_SELECTOR)
44
- end
45
-
46
- ##
47
- # @param node [Nokogiri::XML::Node]
48
- # @param cache [Hash, nil] identity cache used to store results (must use compare_by_identity)
49
- # @return [Boolean] true when the node belongs to ignored DOM chrome
50
- # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
51
- def ignored_container_path?(node, cache = nil)
52
- return cache[node] if cache&.key?(node)
53
-
54
- curr = node
55
- visited = []
56
- is_ignored = false
57
-
58
- while curr.respond_to?(:parent) && curr
59
- if cache&.key?(curr)
60
- is_ignored = cache[curr]
61
- break
62
- end
63
-
64
- if IGNORED_CONTAINER_TAGS.include?(curr.name)
65
- is_ignored = true
66
- break
67
- end
68
-
69
- visited << curr
70
- curr = curr.parent
71
- end
72
- visited.each { |n| cache[n] = is_ignored } if cache
73
-
74
- is_ignored
75
- end
76
- # rubocop:enable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
77
- end
78
-
79
- ##
80
- # @param article_tag [Nokogiri::XML::Node] article-like container to extract from
81
- # @param base_url [String, Html2rss::Url] base url used to resolve relative links
82
- # @param selected_anchor [Nokogiri::XML::Node, nil] explicit primary anchor for the container
83
- # @param fallback_anchorless [Boolean] whether to fall back to anchorless extraction
84
- def initialize(article_tag, base_url:, selected_anchor:, fallback_anchorless: false)
85
- raise ArgumentError, 'article_tag is required' unless article_tag
86
-
87
- @article_tag = article_tag
88
- @base_url = base_url
89
- @selected_anchor = selected_anchor
90
- @fallback_anchorless = fallback_anchorless
91
- end
92
-
93
- # @return [Hash{Symbol => Object}] extracted article attributes
94
- def call
95
- {
96
- title: extract_title,
97
- url: extract_url,
98
- image: extract_image,
99
- description: extract_description,
100
- id: generate_id,
101
- published_at: extract_published_at,
102
- enclosures: extract_enclosures,
103
- categories: extract_categories
104
- }
105
- end
106
-
107
- private
108
-
109
- attr_reader :article_tag, :base_url, :selected_anchor
110
-
111
- def extract_url
112
- @extract_url ||= begin
113
- href = selected_anchor&.[]('href').to_s
114
-
115
- if href.empty?
116
- anchorless_url_fallback
117
- else
118
- Url.from_relative(href.split('#').first.strip, base_url)
119
- end
120
- end
121
- end
122
-
123
- def anchorless_url_fallback
124
- return unless @fallback_anchorless
125
-
126
- id = generate_id
127
- Url.from_relative("##{id}", base_url) if id
128
- end
129
-
130
- # rubocop:disable Metrics/CyclomaticComplexity
131
- def extract_title
132
- source = heading || selected_anchor
133
- title_text = source ? self.class.extract_visible_text(source) : fallback_anchorless_title
134
- return unless title_text
135
-
136
- kicker = kicker_node ? self.class.extract_visible_text(kicker_node).to_s.strip : nil
137
- kicker && !kicker.empty? && !title_text.include?(kicker) ? "#{kicker}: #{title_text}" : title_text
138
- end
139
- # rubocop:enable Metrics/CyclomaticComplexity
140
-
141
- def fallback_anchorless_title
142
- return unless @fallback_anchorless && selected_anchor.nil?
143
-
144
- text_node = article_tag.xpath('.//text()').find { |t| !t.text.strip.empty? }
145
- text_node&.text&.strip
146
- end
147
-
148
- def heading
149
- @heading ||= HeadingExtractor.call(
150
- article_tag,
151
- fallback_anchorless: @fallback_anchorless,
152
- selected_anchor:
153
- )
154
- end
155
-
156
- def kicker_node
157
- @kicker_node ||= begin
158
- selector = '[data-tb-kicker], [class*="kicker"], [class*="eyebrow"], ' \
159
- '[class*="pre-title"], [class*="pretitle"], [class*="overline"]'
160
- node = article_tag.at_css(selector)
161
- node && heading && (node == heading || HtmlNavigator.descendant_of?(node, heading)) ? nil : node
162
- end
163
- end
164
-
165
- def extract_description
166
- exclude = [heading, selected_anchor, kicker_node].compact.to_set
167
- description = self.class.extract_visible_text(article_tag, exclude_nodes: exclude)
168
- return if description.nil?
169
-
170
- desc = description.strip
171
- desc.empty? ? nil : desc
172
- end
173
-
174
- def generate_id
175
- @generate_id ||= IdGenerator.call(
176
- article_tag,
177
- heading:,
178
- url: (selected_anchor ? extract_url : nil),
179
- selected_anchor:,
180
- fallback_anchorless: @fallback_anchorless
181
- )
182
- end
183
-
184
- def extract_image = ImageExtractor.call(article_tag, base_url:)
185
- def extract_published_at = DateExtractor.call(article_tag)
186
- def extract_enclosures = EnclosureExtractor.call(article_tag, base_url)
187
- def extract_categories = CategoryExtractor.call(article_tag)
188
- end
189
- # rubocop:enable Metrics/ClassLength
190
- end
@@ -1,71 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Html2rss
4
- ##
5
- # HtmlNavigator provides methods to navigate through HTML nodes.
6
- class HtmlNavigator
7
- class << self
8
- ##
9
- # Returns the first parent that satisfies the condition.
10
- # If the condition is met, it returns the node itself.
11
- #
12
- # @param node [Nokogiri::XML::Node] The node to start the search from.
13
- # @param condition [Proc] The condition to be met.
14
- # @return [Nokogiri::XML::Node, nil] The first parent that satisfies the condition.
15
- def parent_until_condition(node, condition)
16
- while node && !node.document? && node.name != 'html'
17
- return node if condition.call(node)
18
-
19
- node = node.parent
20
- end
21
- end
22
-
23
- ##
24
- # Think of it as `css_upwards` method.
25
- # It searches for the closest parent that matches the given selector.
26
- #
27
- # @param current_tag [Nokogiri::XML::Node, nil] starting node
28
- # @param selector [String] CSS selector to search upwards for
29
- # @return [Nokogiri::XML::Node, nil] first matching node in upward traversal
30
- def find_closest_selector_upwards(current_tag, selector)
31
- while current_tag
32
- found = current_tag.at_css(selector)
33
- return found if found
34
-
35
- return nil unless current_tag.respond_to?(:parent)
36
-
37
- current_tag = current_tag.parent
38
- end
39
- end
40
-
41
- ##
42
- # Searches for the closest parent that matches the given tag name.
43
- #
44
- # @param current_tag [Nokogiri::XML::Node] starting node
45
- # @param tag_name [String] tag name to find in ancestors
46
- # @return [Nokogiri::XML::Node, nil] matching ancestor node
47
- def find_tag_in_ancestors(current_tag, tag_name)
48
- return current_tag if current_tag.name == tag_name
49
-
50
- current_tag.ancestors(tag_name).first
51
- end
52
-
53
- ##
54
- # Returns true if child_node is a descendant of parent_node.
55
- # Walks up using parent pointers to avoid NodeSet allocations.
56
- #
57
- # @param child_node [Nokogiri::XML::Node] potential descendant
58
- # @param parent_node [Nokogiri::XML::Node] potential ancestor
59
- # @return [Boolean] true when child_node is a descendant of parent_node
60
- def descendant_of?(child_node, parent_node)
61
- curr = child_node.respond_to?(:parent) ? child_node.parent : nil
62
- while curr
63
- return true if curr == parent_node
64
-
65
- curr = curr.respond_to?(:parent) ? curr.parent : nil
66
- end
67
- false
68
- end
69
- end
70
- end
71
- end