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.
- checksums.yaml +4 -4
- data/README.md +11 -108
- data/lib/html2rss/{articles → article}/deduplicator.rb +3 -4
- data/lib/html2rss/{rss_builder → article}/enclosure.rb +6 -22
- data/lib/html2rss/article.rb +213 -0
- data/lib/html2rss/auto_source/discovery/dom_clustering/group_scorer.rb +80 -0
- data/lib/html2rss/auto_source/discovery/dom_clustering/overlap_resolver.rb +86 -0
- data/lib/html2rss/auto_source/discovery/dom_clustering.rb +119 -0
- data/lib/html2rss/auto_source/discovery/list_candidates.rb +94 -0
- data/lib/html2rss/auto_source/discovery/semantic_anchor_candidates.rb +219 -0
- data/lib/html2rss/auto_source/discovery/semantic_containers.rb +71 -0
- data/lib/html2rss/auto_source/discovery/sitemap.rb +159 -0
- data/lib/html2rss/auto_source/discovery.rb +14 -0
- data/lib/html2rss/auto_source/link_heuristics/anchor_signals.rb +28 -0
- data/lib/html2rss/auto_source/link_heuristics/container_assessor.rb +106 -0
- data/lib/html2rss/auto_source/link_heuristics/container_signals.rb +80 -0
- data/lib/html2rss/auto_source/link_heuristics/destination_facts.rb +42 -0
- data/lib/html2rss/auto_source/link_heuristics/href_extractor.rb +38 -0
- data/lib/html2rss/auto_source/link_heuristics/path_classifier.rb +221 -0
- data/lib/html2rss/auto_source/link_heuristics/text_classifier.rb +66 -0
- data/lib/html2rss/auto_source/link_heuristics.rb +139 -0
- data/lib/html2rss/auto_source/scraper/html.rb +22 -33
- data/lib/html2rss/auto_source/scraper/json_state.rb +1 -1
- data/lib/html2rss/auto_source/scraper/meta_oembed.rb +163 -0
- data/lib/html2rss/auto_source/scraper/microformats2.rb +146 -0
- data/lib/html2rss/auto_source/scraper/schema/category_extractor.rb +29 -114
- data/lib/html2rss/auto_source/scraper/schema/item_list.rb +50 -11
- data/lib/html2rss/auto_source/scraper/schema/thing.rb +35 -7
- data/lib/html2rss/auto_source/scraper/schema.rb +61 -18
- data/lib/html2rss/auto_source/scraper/semantic_html/{deduplicator.rb → entry_deduplicator.rb} +3 -3
- data/lib/html2rss/auto_source/scraper/semantic_html.rb +23 -171
- data/lib/html2rss/auto_source/scraper/sitemap.rb +153 -0
- data/lib/html2rss/auto_source/scraper/wordpress_api/page_scope/date_archive_range.rb +110 -0
- data/lib/html2rss/auto_source/scraper/wordpress_api/page_scope.rb +2 -78
- data/lib/html2rss/auto_source/scraper/wordpress_api.rb +9 -0
- data/lib/html2rss/auto_source/scraper.rb +10 -3
- data/lib/html2rss/auto_source.rb +30 -35
- data/lib/html2rss/channel.rb +198 -0
- data/lib/html2rss/cli.rb +5 -6
- data/lib/html2rss/config/auto_source_contract.rb +51 -0
- data/lib/html2rss/config/request_controls.rb +136 -0
- data/lib/html2rss/config/request_headers.rb +1 -1
- data/lib/html2rss/config/schema.rb +65 -114
- data/lib/html2rss/{selectors/config.rb → config/selectors_validator.rb} +56 -7
- data/lib/html2rss/config/validator.rb +6 -6
- data/lib/html2rss/config.rb +176 -19
- data/lib/html2rss/{configuration.rb → defaults.rb} +12 -10
- data/lib/html2rss/feed_builder/item_presentation.rb +42 -0
- data/lib/html2rss/feed_builder/json_feed/item.rb +112 -0
- data/lib/html2rss/feed_builder/json_feed.rb +81 -0
- data/lib/html2rss/feed_builder/rss/stylesheet.rb +65 -0
- data/lib/html2rss/feed_builder/rss.rb +116 -0
- data/lib/html2rss/feed_builder.rb +15 -0
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +120 -60
- data/lib/html2rss/feed_pipeline/runtime_policy.rb +108 -0
- data/lib/html2rss/feed_pipeline/strategy_plan.rb +64 -0
- data/lib/html2rss/feed_pipeline.rb +83 -89
- data/lib/html2rss/feed_result.rb +92 -0
- data/lib/html2rss/html/article_extractor/category_extractor.rb +123 -0
- data/lib/html2rss/html/article_extractor/date_extractor.rb +22 -0
- data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +95 -0
- data/lib/html2rss/html/article_extractor/heading_extractor.rb +54 -0
- data/lib/html2rss/html/article_extractor/id_generator.rb +69 -0
- data/lib/html2rss/html/article_extractor/image_extractor.rb +60 -0
- data/lib/html2rss/html/article_extractor.rb +134 -0
- data/lib/html2rss/html/navigator/text_extractor.rb +79 -0
- data/lib/html2rss/html/navigator.rb +146 -0
- data/lib/html2rss/html/rendering/audio_renderer.rb +38 -0
- data/lib/html2rss/html/rendering/description_builder.rb +89 -0
- data/lib/html2rss/html/rendering/image_renderer.rb +43 -0
- data/lib/html2rss/html/rendering/media_renderer.rb +39 -0
- data/lib/html2rss/html/rendering/pdf_renderer.rb +36 -0
- data/lib/html2rss/html/rendering/video_renderer.rb +38 -0
- data/lib/html2rss/html/rendering.rb +25 -0
- data/lib/html2rss/html.rb +8 -0
- data/lib/html2rss/request_service/blocked_surface.rb +67 -0
- data/lib/html2rss/request_service/botasaurus_contract.rb +33 -9
- data/lib/html2rss/request_service/botasaurus_strategy.rb +21 -30
- data/lib/html2rss/request_service/browserless_strategy.rb +2 -23
- data/lib/html2rss/request_service/budget.rb +97 -18
- data/lib/html2rss/request_service/context.rb +1 -3
- data/lib/html2rss/request_service/faraday_strategy.rb +12 -26
- data/lib/html2rss/request_service/local_file_strategy.rb +12 -4
- data/lib/html2rss/request_service/network_guard.rb +125 -0
- data/lib/html2rss/request_service/policy.rb +10 -82
- data/lib/html2rss/request_service/puppet_commander/navigation_guards.rb +148 -0
- data/lib/html2rss/request_service/puppet_commander/preload_runner.rb +86 -0
- data/lib/html2rss/request_service/puppet_commander.rb +22 -191
- data/lib/html2rss/request_service/response.rb +9 -1
- data/lib/html2rss/request_service/response_guard.rb +1 -1
- data/lib/html2rss/request_service/strategy.rb +123 -5
- data/lib/html2rss/request_service.rb +7 -2
- data/lib/html2rss/request_session/pager/base.rb +83 -0
- data/lib/html2rss/request_session/pager/custom_selector.rb +39 -0
- data/lib/html2rss/request_session/pager/json_cursor.rb +78 -0
- data/lib/html2rss/request_session/pager/offset.rb +48 -0
- data/lib/html2rss/request_session/pager/rel_next.rb +23 -0
- data/lib/html2rss/request_session/pager/url_template.rb +48 -0
- data/lib/html2rss/request_session/pager.rb +75 -0
- data/lib/html2rss/request_session.rb +29 -16
- data/lib/html2rss/selectors/item_scope.rb +30 -0
- data/lib/html2rss/selectors/post_processors/base.rb +1 -1
- data/lib/html2rss/selectors/post_processors/markdown_to_html.rb +0 -1
- data/lib/html2rss/selectors/post_processors/sanitize_html.rb +0 -2
- data/lib/html2rss/selectors/post_processors/template.rb +7 -3
- data/lib/html2rss/selectors.rb +84 -64
- data/lib/html2rss/status.rb +132 -0
- data/lib/html2rss/version.rb +1 -3
- data/lib/html2rss.rb +34 -22
- data/schema/html2rss-config.schema.json +109 -9
- metadata +71 -43
- data/lib/html2rss/auto_source/scraper/html/class_clustering.rb +0 -196
- data/lib/html2rss/auto_source/scraper/link_heuristics.rb +0 -401
- data/lib/html2rss/auto_source/scraper/schema/list_item.rb +0 -28
- data/lib/html2rss/auto_source/scraper/semantic_html/anchor_selector.rb +0 -49
- data/lib/html2rss/blocked_surface.rb +0 -65
- data/lib/html2rss/category_extractor.rb +0 -116
- data/lib/html2rss/config/class_methods.rb +0 -183
- data/lib/html2rss/html_extractor/date_extractor.rb +0 -20
- data/lib/html2rss/html_extractor/enclosure_extractor.rb +0 -91
- data/lib/html2rss/html_extractor/heading_extractor.rb +0 -50
- data/lib/html2rss/html_extractor/id_generator.rb +0 -67
- data/lib/html2rss/html_extractor/image_extractor.rb +0 -58
- data/lib/html2rss/html_extractor/list_candidates.rb +0 -88
- data/lib/html2rss/html_extractor/semantic_anchor_candidates.rb +0 -260
- data/lib/html2rss/html_extractor/semantic_containers.rb +0 -69
- data/lib/html2rss/html_extractor/text_extractor.rb +0 -77
- data/lib/html2rss/html_extractor.rb +0 -190
- data/lib/html2rss/html_navigator.rb +0 -71
- data/lib/html2rss/json_feed_builder/item.rb +0 -94
- data/lib/html2rss/json_feed_builder.rb +0 -59
- data/lib/html2rss/rendering/audio_renderer.rb +0 -36
- data/lib/html2rss/rendering/description_builder.rb +0 -87
- data/lib/html2rss/rendering/image_renderer.rb +0 -41
- data/lib/html2rss/rendering/media_renderer.rb +0 -37
- data/lib/html2rss/rendering/pdf_renderer.rb +0 -34
- data/lib/html2rss/rendering/video_renderer.rb +0 -36
- data/lib/html2rss/rendering.rb +0 -23
- data/lib/html2rss/request_controls.rb +0 -133
- data/lib/html2rss/request_session/rel_next_pager.rb +0 -70
- data/lib/html2rss/request_session/runtime_input.rb +0 -71
- data/lib/html2rss/request_session/runtime_policy.rb +0 -84
- data/lib/html2rss/rss_builder/article.rb +0 -208
- data/lib/html2rss/rss_builder/channel.rb +0 -118
- data/lib/html2rss/rss_builder/stylesheet.rb +0 -63
- 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
|