html2rss 0.20.1 → 0.22.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/html2rss.gemspec +1 -2
- data/lib/html2rss/auto_source/scraper/html/class_clustering.rb +185 -0
- data/lib/html2rss/auto_source/scraper/html.rb +73 -16
- data/lib/html2rss/auto_source/scraper/json_state.rb +41 -28
- data/lib/html2rss/auto_source/scraper/link_heuristics.rb +85 -131
- data/lib/html2rss/auto_source/scraper/microdata.rb +2 -2
- data/lib/html2rss/auto_source/scraper/schema/category_extractor.rb +74 -28
- data/lib/html2rss/auto_source/scraper/schema/list_item.rb +3 -2
- data/lib/html2rss/auto_source/scraper/schema/thing.rb +31 -60
- data/lib/html2rss/auto_source/scraper/schema.rb +8 -2
- data/lib/html2rss/auto_source/scraper/semantic_html/deduplicator.rb +4 -18
- data/lib/html2rss/auto_source/scraper/semantic_html.rb +55 -11
- data/lib/html2rss/auto_source/scraper.rb +0 -3
- data/lib/html2rss/auto_source.rb +5 -12
- data/lib/html2rss/category_extractor.rb +54 -20
- data/lib/html2rss/cli.rb +61 -10
- data/lib/html2rss/config/class_methods.rb +3 -3
- data/lib/html2rss/config/validator.rb +1 -0
- data/lib/html2rss/config.rb +2 -2
- data/lib/html2rss/html_extractor/enclosure_extractor.rb +60 -89
- data/lib/html2rss/html_extractor/heading_extractor.rb +50 -0
- data/lib/html2rss/html_extractor/id_generator.rb +67 -0
- data/lib/html2rss/html_extractor/list_candidates.rb +2 -8
- data/lib/html2rss/html_extractor/semantic_anchor_candidates.rb +29 -12
- data/lib/html2rss/html_extractor/semantic_containers.rb +9 -35
- data/lib/html2rss/html_extractor/text_extractor.rb +77 -0
- data/lib/html2rss/html_extractor.rb +80 -61
- data/lib/html2rss/rendering/description_builder.rb +3 -3
- data/lib/html2rss/rendering.rb +2 -2
- data/lib/html2rss/request_service/local_file_strategy.rb +29 -0
- data/lib/html2rss/request_service/puppet_commander.rb +4 -0
- data/lib/html2rss/request_service.rb +2 -1
- data/lib/html2rss/rss_builder/article.rb +44 -23
- data/lib/html2rss/rss_builder/enclosure.rb +4 -2
- data/lib/html2rss/selectors/extractors/attribute.rb +1 -1
- data/lib/html2rss/selectors/extractors/href.rb +1 -1
- data/lib/html2rss/selectors/extractors/html.rb +1 -1
- data/lib/html2rss/selectors/extractors/static.rb +1 -1
- data/lib/html2rss/selectors/extractors/text.rb +1 -1
- data/lib/html2rss/selectors/post_processors/sanitize_html.rb +32 -36
- data/lib/html2rss/selectors/post_processors/substring.rb +11 -18
- data/lib/html2rss/selectors/post_processors/template.rb +3 -2
- data/lib/html2rss/selectors.rb +19 -5
- data/lib/html2rss/url.rb +29 -15
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss.rb +28 -6
- data/schema/html2rss-config.schema.json +12 -1
- metadata +8 -17
|
@@ -5,116 +5,87 @@ module Html2rss
|
|
|
5
5
|
##
|
|
6
6
|
# Extracts enclosures from HTML tags using various strategies.
|
|
7
7
|
class EnclosureExtractor
|
|
8
|
+
# CSS union query covering images, media, PDFs, iframes, and archives.
|
|
9
|
+
SELECTOR = [
|
|
10
|
+
'img[src]:not([src^="data"])',
|
|
11
|
+
'video source[src]',
|
|
12
|
+
'audio source[src]',
|
|
13
|
+
'audio[src]',
|
|
14
|
+
'a[href$=".pdf"]',
|
|
15
|
+
'iframe[src]',
|
|
16
|
+
'a[href$=".zip"]',
|
|
17
|
+
'a[href$=".tar.gz"]',
|
|
18
|
+
'a[href$=".tgz"]'
|
|
19
|
+
].join(',').freeze
|
|
20
|
+
|
|
8
21
|
# @param article_tag [Nokogiri::XML::Element] article container node
|
|
9
22
|
# @param base_url [String, Html2rss::Url] base URL for relative enclosure links
|
|
10
23
|
# @return [Array<Hash{Symbol => Object}>] normalized enclosure hashes
|
|
11
24
|
def self.call(article_tag, base_url)
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
Extractors::Pdf,
|
|
16
|
-
Extractors::Iframe,
|
|
17
|
-
Extractors::Archive
|
|
18
|
-
].flat_map { |strategy| strategy.call(article_tag, base_url:) }
|
|
25
|
+
article_tag.css(SELECTOR).filter_map do |element|
|
|
26
|
+
extract_from_element(element, base_url)
|
|
27
|
+
end
|
|
19
28
|
end
|
|
20
|
-
end
|
|
21
29
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
src = img['src'].to_s
|
|
33
|
-
next if src.empty?
|
|
34
|
-
|
|
35
|
-
abs_url = Url.from_relative(src, base_url)
|
|
36
|
-
{
|
|
37
|
-
url: abs_url,
|
|
38
|
-
type: RssBuilder::Enclosure.guess_content_type_from_url(abs_url, default: 'image/jpeg')
|
|
39
|
-
}
|
|
40
|
-
end
|
|
30
|
+
def self.extract_from_element(element, base_url)
|
|
31
|
+
case element.name
|
|
32
|
+
when 'img'
|
|
33
|
+
extract_image(element, base_url)
|
|
34
|
+
when 'video', 'audio', 'source'
|
|
35
|
+
extract_media(element, base_url)
|
|
36
|
+
when 'iframe'
|
|
37
|
+
extract_iframe(element, base_url)
|
|
38
|
+
when 'a'
|
|
39
|
+
extract_a(element, base_url)
|
|
41
40
|
end
|
|
42
41
|
end
|
|
43
42
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
# @param base_url [String, Html2rss::Url] base URL for relative media sources
|
|
48
|
-
# @return [Array<Hash{Symbol => Object}>] media enclosure hashes
|
|
49
|
-
def self.call(article_tag, base_url:)
|
|
50
|
-
article_tag.css('video source[src], audio source[src], audio[src]').filter_map do |element|
|
|
51
|
-
src = element['src'].to_s
|
|
52
|
-
next if src.empty?
|
|
43
|
+
def self.extract_image(img, base_url)
|
|
44
|
+
src = img['src'].to_s
|
|
45
|
+
return if src.empty?
|
|
53
46
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
end
|
|
47
|
+
abs_url = Url.from_relative(src, base_url)
|
|
48
|
+
{
|
|
49
|
+
url: abs_url,
|
|
50
|
+
type: RssBuilder::Enclosure.guess_content_type_from_url(abs_url, default: 'image/jpeg')
|
|
51
|
+
}
|
|
60
52
|
end
|
|
61
53
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
# @param base_url [String, Html2rss::Url] base URL for relative PDF links
|
|
66
|
-
# @return [Array<Hash{Symbol => Object}>] PDF enclosure hashes
|
|
67
|
-
def self.call(article_tag, base_url:)
|
|
68
|
-
article_tag.css('a[href$=".pdf"]').filter_map do |link|
|
|
69
|
-
href = link['href'].to_s
|
|
70
|
-
next if href.empty?
|
|
54
|
+
def self.extract_media(element, base_url)
|
|
55
|
+
src = element['src'].to_s
|
|
56
|
+
return if src.empty?
|
|
71
57
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
}
|
|
77
|
-
end
|
|
78
|
-
end
|
|
58
|
+
{
|
|
59
|
+
url: Url.from_relative(src, base_url),
|
|
60
|
+
type: element['type']
|
|
61
|
+
}
|
|
79
62
|
end
|
|
80
63
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
# @param base_url [String, Html2rss::Url] base URL for relative iframe links
|
|
85
|
-
# @return [Array<Hash{Symbol => Object}>] iframe enclosure hashes
|
|
86
|
-
def self.call(article_tag, base_url:)
|
|
87
|
-
article_tag.css('iframe[src]').filter_map do |iframe|
|
|
88
|
-
src = iframe['src']
|
|
89
|
-
next if src.nil? || src.empty?
|
|
64
|
+
def self.extract_iframe(iframe, base_url)
|
|
65
|
+
src = iframe['src'].to_s
|
|
66
|
+
return if src.empty?
|
|
90
67
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
end
|
|
97
|
-
end
|
|
68
|
+
abs_url = Url.from_relative(src, base_url)
|
|
69
|
+
{
|
|
70
|
+
url: abs_url,
|
|
71
|
+
type: RssBuilder::Enclosure.guess_content_type_from_url(abs_url, default: 'text/html')
|
|
72
|
+
}
|
|
98
73
|
end
|
|
99
74
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
# @param base_url [String, Html2rss::Url] base URL for relative archive links
|
|
104
|
-
# @return [Array<Hash{Symbol => Object}>] archive enclosure hashes
|
|
105
|
-
def self.call(article_tag, base_url:)
|
|
106
|
-
article_tag.css('a[href$=".zip"], a[href$=".tar.gz"], a[href$=".tgz"]').filter_map do |link|
|
|
107
|
-
href = link['href'].to_s
|
|
108
|
-
next if href.empty?
|
|
75
|
+
def self.extract_a(link, base_url)
|
|
76
|
+
href = link['href'].to_s
|
|
77
|
+
return if href.empty?
|
|
109
78
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
79
|
+
abs_url = Url.from_relative(href, base_url)
|
|
80
|
+
|
|
81
|
+
if href.end_with?('.pdf')
|
|
82
|
+
{ url: abs_url, type: RssBuilder::Enclosure.guess_content_type_from_url(abs_url) }
|
|
83
|
+
else
|
|
84
|
+
{ url: abs_url, type: 'application/zip' }
|
|
116
85
|
end
|
|
117
86
|
end
|
|
87
|
+
|
|
88
|
+
private_class_method :extract_from_element, :extract_image, :extract_media, :extract_iframe, :extract_a
|
|
118
89
|
end
|
|
119
90
|
end
|
|
120
91
|
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
class HtmlExtractor
|
|
5
|
+
##
|
|
6
|
+
# HeadingExtractor identifies and returns the best heading element within a container.
|
|
7
|
+
class HeadingExtractor
|
|
8
|
+
# Heading tags used to prioritize title extraction.
|
|
9
|
+
HEADING_TAGS = HtmlExtractor::HEADING_TAGS
|
|
10
|
+
|
|
11
|
+
class << self
|
|
12
|
+
##
|
|
13
|
+
# @param article_tag [Nokogiri::XML::Element] container node
|
|
14
|
+
# @param fallback_anchorless [Boolean] whether to use fallback search
|
|
15
|
+
# @param selected_anchor [Nokogiri::XML::Node, nil] anchor element
|
|
16
|
+
# @return [Nokogiri::XML::Node, nil] the heading node, if found
|
|
17
|
+
def call(article_tag, fallback_anchorless:, selected_anchor:)
|
|
18
|
+
tags = article_tag.css(HEADING_TAGS.join(','))
|
|
19
|
+
if tags.any?
|
|
20
|
+
select_best_heading(tags)
|
|
21
|
+
elsif fallback_anchorless && selected_anchor.nil?
|
|
22
|
+
fallback_heading(article_tag)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def select_best_heading(tags)
|
|
29
|
+
min_tag_name = tags.map(&:name).min
|
|
30
|
+
best_tag = nil
|
|
31
|
+
max_size = -1
|
|
32
|
+
|
|
33
|
+
tags.each do |tag|
|
|
34
|
+
next if tag.name != min_tag_name
|
|
35
|
+
|
|
36
|
+
size = TextExtractor.call(tag)&.size.to_i
|
|
37
|
+
(best_tag = tag) && (max_size = size) if size > max_size
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
best_tag
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def fallback_heading(article_tag)
|
|
44
|
+
fallback_tags = article_tag.css('strong, b, [class*="title"], [class*="font-bold"], [class*="font-semibold"]')
|
|
45
|
+
fallback_tags.find { |t| !TextExtractor.call(t).to_s.strip.empty? }
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'zlib'
|
|
4
|
+
|
|
5
|
+
module Html2rss
|
|
6
|
+
class HtmlExtractor
|
|
7
|
+
##
|
|
8
|
+
# IdGenerator determines the unique ID for an article container node.
|
|
9
|
+
class IdGenerator
|
|
10
|
+
class << self
|
|
11
|
+
##
|
|
12
|
+
# @param article_tag [Nokogiri::XML::Element] container node
|
|
13
|
+
# @param heading [Nokogiri::XML::Node, nil] heading node
|
|
14
|
+
# @param url [Html2rss::Url, nil] absolute article URL
|
|
15
|
+
# @param selected_anchor [Nokogiri::XML::Node, nil] anchor element
|
|
16
|
+
# @param fallback_anchorless [Boolean] whether to use fallback hashing
|
|
17
|
+
# @return [String, nil] the generated ID, if any
|
|
18
|
+
def call(article_tag, heading:, url:, selected_anchor:, fallback_anchorless:)
|
|
19
|
+
id_from_dom = parse_id_from_dom(article_tag, url, selected_anchor)
|
|
20
|
+
return id_from_dom if id_from_dom
|
|
21
|
+
|
|
22
|
+
heading_text = resolve_heading_text(article_tag, heading, fallback_anchorless)
|
|
23
|
+
if heading_text && !heading_text.strip.empty?
|
|
24
|
+
generate_slug(heading_text)
|
|
25
|
+
elsif fallback_anchorless
|
|
26
|
+
generate_content_hash(article_tag)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def parse_id_from_dom(article_tag, url, selected_anchor)
|
|
33
|
+
candidates = [article_tag['id'], article_tag.at_css('[id]')&.attr('id')]
|
|
34
|
+
candidates += [url&.path, url&.query] if selected_anchor
|
|
35
|
+
candidates.compact.reject(&:empty?).first
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def resolve_heading_text(article_tag, heading, fallback_anchorless)
|
|
39
|
+
text = heading ? TextExtractor.call(heading) : nil
|
|
40
|
+
if text.nil? || text.strip.empty?
|
|
41
|
+
fallback_text_node_content(article_tag, fallback_anchorless)
|
|
42
|
+
else
|
|
43
|
+
text
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def fallback_text_node_content(article_tag, fallback_anchorless)
|
|
48
|
+
return unless fallback_anchorless
|
|
49
|
+
|
|
50
|
+
article_tag.xpath('.//text()').find { |t| !t.text.strip.empty? }&.text&.strip
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def generate_slug(text)
|
|
54
|
+
slug = text.downcase.gsub(/[^a-z0-9]+/, '-')
|
|
55
|
+
slug = slug[1..] if slug.start_with?('-')
|
|
56
|
+
slug = slug[0..-2] if slug.end_with?('-')
|
|
57
|
+
slug unless slug.empty?
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def generate_content_hash(article_tag)
|
|
61
|
+
text = TextExtractor.call(article_tag).to_s.strip
|
|
62
|
+
Zlib.crc32(text).to_s(36) unless text.empty?
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -75,17 +75,11 @@ module Html2rss
|
|
|
75
75
|
def each_anchor(anchor_filter:)
|
|
76
76
|
return enum_for(:each_anchor, anchor_filter:) unless block_given?
|
|
77
77
|
|
|
78
|
-
traversal_root&.
|
|
79
|
-
yield node if
|
|
78
|
+
traversal_root&.css(HtmlExtractor::MAIN_ANCHOR_SELECTOR)&.each do |node|
|
|
79
|
+
yield node if anchor_filter.call(node)
|
|
80
80
|
end
|
|
81
81
|
end
|
|
82
82
|
|
|
83
|
-
def relevant_anchor?(node, anchor_filter:)
|
|
84
|
-
node.element? &&
|
|
85
|
-
node.matches?(HtmlExtractor::MAIN_ANCHOR_SELECTOR) &&
|
|
86
|
-
anchor_filter.call(node)
|
|
87
|
-
end
|
|
88
|
-
|
|
89
83
|
def traversal_root
|
|
90
84
|
parsed_body.at_css('body, html') || parsed_body.root
|
|
91
85
|
end
|
|
@@ -31,6 +31,8 @@ module Html2rss
|
|
|
31
31
|
|
|
32
32
|
# Shared context for all anchors in one semantic container.
|
|
33
33
|
class Context
|
|
34
|
+
attr_reader :container
|
|
35
|
+
|
|
34
36
|
# Ancestor tags that usually indicate navigation/utility regions.
|
|
35
37
|
UTILITY_LANDMARK_TAGS = %w[nav aside footer menu].freeze
|
|
36
38
|
|
|
@@ -56,7 +58,7 @@ module Html2rss
|
|
|
56
58
|
def visible_text(node)
|
|
57
59
|
return '' unless node
|
|
58
60
|
|
|
59
|
-
HtmlExtractor.extract_visible_text(node).to_s.strip
|
|
61
|
+
(@visible_texts ||= {}.compare_by_identity)[node] ||= HtmlExtractor.extract_visible_text(node).to_s.strip
|
|
60
62
|
end
|
|
61
63
|
|
|
62
64
|
# @param anchor [Nokogiri::XML::Node] anchor candidate
|
|
@@ -70,12 +72,6 @@ module Html2rss
|
|
|
70
72
|
def utility_text?(text)
|
|
71
73
|
@link_heuristics.utility_text?(text)
|
|
72
74
|
end
|
|
73
|
-
|
|
74
|
-
# @param ancestors [Array<Nokogiri::XML::Node>]
|
|
75
|
-
# @return [Boolean] true when the anchor lives inside navigation chrome
|
|
76
|
-
def utility_landmark?(ancestors)
|
|
77
|
-
ancestors.any? { |node| UTILITY_LANDMARK_TAGS.include?(node.name) }
|
|
78
|
-
end
|
|
79
75
|
end
|
|
80
76
|
|
|
81
77
|
# One anchor plus the facts needed to decide whether it represents content.
|
|
@@ -131,7 +127,7 @@ module Html2rss
|
|
|
131
127
|
|
|
132
128
|
# @return [Boolean] true when visible anchor text has words
|
|
133
129
|
def meaningful_text?
|
|
134
|
-
text.
|
|
130
|
+
@meaningful_text ||= text.match?(/\p{Alnum}/)
|
|
135
131
|
end
|
|
136
132
|
|
|
137
133
|
# @return [Boolean] true when the destination route has content signals
|
|
@@ -142,8 +138,17 @@ module Html2rss
|
|
|
142
138
|
# @return [Boolean] true when the anchor is inside the selected heading
|
|
143
139
|
def heading_anchor?
|
|
144
140
|
heading = @context.heading
|
|
141
|
+
return false unless heading
|
|
142
|
+
|
|
143
|
+
curr = @anchor
|
|
144
|
+
container = @context.container
|
|
145
|
+
while curr.respond_to?(:parent)
|
|
146
|
+
return true if curr == heading
|
|
147
|
+
break if curr == container
|
|
145
148
|
|
|
146
|
-
|
|
149
|
+
curr = curr.parent
|
|
150
|
+
end
|
|
151
|
+
false
|
|
147
152
|
end
|
|
148
153
|
|
|
149
154
|
# @return [Boolean] true when anchor text exactly matches heading text
|
|
@@ -151,14 +156,14 @@ module Html2rss
|
|
|
151
156
|
heading_text = @context.heading_text
|
|
152
157
|
|
|
153
158
|
meaningful_text? &&
|
|
154
|
-
heading_text.
|
|
159
|
+
heading_text.match?(/\p{Alnum}/) &&
|
|
155
160
|
heading_text == text
|
|
156
161
|
end
|
|
157
162
|
|
|
158
163
|
private
|
|
159
164
|
|
|
160
165
|
def representative_content_anchor?
|
|
161
|
-
|
|
166
|
+
meaningful_text? || content_like_destination? || heading_anchor?
|
|
162
167
|
end
|
|
163
168
|
|
|
164
169
|
def utility_text_suppressed?
|
|
@@ -174,7 +179,19 @@ module Html2rss
|
|
|
174
179
|
def ineligible_anchor?
|
|
175
180
|
destination_facts.high_confidence_utility_destination ||
|
|
176
181
|
icon_only_anchor? ||
|
|
177
|
-
|
|
182
|
+
utility_landmark_ancestor?
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def utility_landmark_ancestor?
|
|
186
|
+
curr = @anchor.parent
|
|
187
|
+
container = @context.container
|
|
188
|
+
while curr.respond_to?(:parent)
|
|
189
|
+
return true if Context::UTILITY_LANDMARK_TAGS.include?(curr.name)
|
|
190
|
+
break if curr == container
|
|
191
|
+
|
|
192
|
+
curr = curr.parent
|
|
193
|
+
end
|
|
194
|
+
false
|
|
178
195
|
end
|
|
179
196
|
|
|
180
197
|
def icon_only_anchor?
|
|
@@ -27,43 +27,17 @@ module Html2rss
|
|
|
27
27
|
|
|
28
28
|
# @return [Array<Nokogiri::XML::Node>] candidate semantic containers
|
|
29
29
|
def call
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
cache = {}.compare_by_identity
|
|
31
|
+
candidates = @parsed_body.css(SELECTORS.join(',')).reject do |node|
|
|
32
|
+
HtmlExtractor.ignored_container_path?(node, cache)
|
|
32
33
|
end
|
|
33
34
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
@document_order ||= begin
|
|
41
|
-
order = {}
|
|
42
|
-
index = 0
|
|
43
|
-
|
|
44
|
-
@parsed_body.traverse do |node|
|
|
45
|
-
next unless node.element?
|
|
46
|
-
|
|
47
|
-
order[node] = index
|
|
48
|
-
index += 1
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
order.compare_by_identity
|
|
52
|
-
end
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
def collect_selector_containers(selector, containers)
|
|
56
|
-
@parsed_body.css(selector).each do |container|
|
|
57
|
-
next if HtmlExtractor.ignored_container_path?(container)
|
|
58
|
-
next if seen[container]
|
|
59
|
-
|
|
60
|
-
seen[container] = true
|
|
61
|
-
containers << container
|
|
62
|
-
end
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
def seen
|
|
66
|
-
@seen ||= {}.compare_by_identity
|
|
35
|
+
# Preserve the original post-order traversal intent (specific-first)
|
|
36
|
+
# by sorting candidates by depth (descending) while keeping original document
|
|
37
|
+
# order for nodes at the same depth.
|
|
38
|
+
candidates.each_with_index
|
|
39
|
+
.sort_by { |node, index| [-node.ancestors.size, index] }
|
|
40
|
+
.map!(&:first)
|
|
67
41
|
end
|
|
68
42
|
end
|
|
69
43
|
end
|
|
@@ -0,0 +1,77 @@
|
|
|
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
|