html2rss 0.21.0 → 0.22.1
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/lib/html2rss/auto_source/scraper/html/class_clustering.rb +196 -0
- data/lib/html2rss/auto_source/scraper/html.rb +14 -2
- data/lib/html2rss/auto_source/scraper/json_state.rb +1 -1
- data/lib/html2rss/auto_source/scraper/microdata.rb +16 -4
- data/lib/html2rss/auto_source/scraper/semantic_html/deduplicator.rb +1 -1
- data/lib/html2rss/auto_source/scraper/semantic_html.rb +28 -20
- data/lib/html2rss/auto_source.rb +6 -2
- 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/heading_extractor.rb +50 -0
- data/lib/html2rss/html_extractor/id_generator.rb +67 -0
- data/lib/html2rss/html_extractor/semantic_anchor_candidates.rb +4 -18
- data/lib/html2rss/html_extractor/semantic_containers.rb +28 -3
- data/lib/html2rss/html_extractor/text_extractor.rb +77 -0
- data/lib/html2rss/html_extractor.rb +78 -61
- data/lib/html2rss/html_navigator.rb +17 -0
- 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/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 +8 -1
- data/lib/html2rss/selectors.rb +9 -4
- data/lib/html2rss/url.rb +26 -13
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss.rb +28 -6
- data/schema/html2rss-config.schema.json +20 -2
- metadata +7 -2
|
@@ -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
|
|
@@ -138,17 +138,7 @@ module Html2rss
|
|
|
138
138
|
# @return [Boolean] true when the anchor is inside the selected heading
|
|
139
139
|
def heading_anchor?
|
|
140
140
|
heading = @context.heading
|
|
141
|
-
|
|
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
|
|
148
|
-
|
|
149
|
-
curr = curr.parent
|
|
150
|
-
end
|
|
151
|
-
false
|
|
141
|
+
heading && (@anchor == heading || HtmlNavigator.descendant_of?(@anchor, heading))
|
|
152
142
|
end
|
|
153
143
|
|
|
154
144
|
# @return [Boolean] true when anchor text exactly matches heading text
|
|
@@ -183,15 +173,11 @@ module Html2rss
|
|
|
183
173
|
end
|
|
184
174
|
|
|
185
175
|
def utility_landmark_ancestor?
|
|
186
|
-
curr = @anchor.parent
|
|
187
176
|
container = @context.container
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
break if curr == container
|
|
177
|
+
condition = proc { |node| node == container || Context::UTILITY_LANDMARK_TAGS.include?(node.name) }
|
|
178
|
+
landmark = HtmlNavigator.parent_until_condition(@anchor.parent, condition)
|
|
191
179
|
|
|
192
|
-
|
|
193
|
-
end
|
|
194
|
-
false
|
|
180
|
+
landmark && landmark != container
|
|
195
181
|
end
|
|
196
182
|
|
|
197
183
|
def icon_only_anchor?
|
|
@@ -32,9 +32,34 @@ module Html2rss
|
|
|
32
32
|
HtmlExtractor.ignored_container_path?(node, cache)
|
|
33
33
|
end
|
|
34
34
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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)
|
|
38
63
|
candidates.each_with_index
|
|
39
64
|
.sort_by { |node, index| [-node.ancestors.size, index] }
|
|
40
65
|
.map!(&:first)
|
|
@@ -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
|
|
@@ -4,13 +4,11 @@ module Html2rss
|
|
|
4
4
|
##
|
|
5
5
|
# HtmlExtractor is responsible for extracting details (headline, url, images, etc.)
|
|
6
6
|
# from an article_tag.
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
INVISIBLE_CONTENT_TAGS = %w[svg script noscript style template].to_set.freeze
|
|
7
|
+
# rubocop:disable Metrics/ClassLength
|
|
8
|
+
class HtmlExtractor
|
|
10
9
|
# Heading tags used to prioritize title extraction.
|
|
11
10
|
HEADING_TAGS = %w[h1 h2 h3 h4 h5 h6].freeze
|
|
12
|
-
|
|
13
|
-
NON_HEADLINE_SELECTOR = (HEADING_TAGS.map { |tag| ":not(#{tag})" } + INVISIBLE_CONTENT_TAGS.to_a).freeze
|
|
11
|
+
|
|
14
12
|
# Element tags that indicate ignored DOM chrome when found in a container path.
|
|
15
13
|
IGNORED_CONTAINER_TAGS = %w[nav footer header svg script style].to_set.freeze
|
|
16
14
|
|
|
@@ -26,20 +24,14 @@ module Html2rss
|
|
|
26
24
|
class << self
|
|
27
25
|
##
|
|
28
26
|
# Extracts visible text from a given node and its children.
|
|
27
|
+
# Delegates to TextExtractor.
|
|
29
28
|
#
|
|
30
29
|
# @param tag [Nokogiri::XML::Node] the node from which to extract visible text
|
|
31
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
32
|
# @return [String, nil] the concatenated visible text, or nil if none is found
|
|
33
|
-
def extract_visible_text(tag, separator: ' ')
|
|
34
|
-
|
|
35
|
-
next unless visible_child?(child)
|
|
36
|
-
|
|
37
|
-
raw_text = child.children.empty? ? child.text : extract_visible_text(child)
|
|
38
|
-
text = raw_text&.strip
|
|
39
|
-
text unless text.to_s.empty?
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
parts.join(separator).squeeze(' ').strip unless parts.empty?
|
|
33
|
+
def extract_visible_text(tag, separator: ' ', exclude_nodes: nil)
|
|
34
|
+
TextExtractor.call(tag, separator:, exclude_nodes:)
|
|
43
35
|
end
|
|
44
36
|
|
|
45
37
|
##
|
|
@@ -55,42 +47,47 @@ module Html2rss
|
|
|
55
47
|
# @param node [Nokogiri::XML::Node]
|
|
56
48
|
# @param cache [Hash, nil] identity cache used to store results (must use compare_by_identity)
|
|
57
49
|
# @return [Boolean] true when the node belongs to ignored DOM chrome
|
|
50
|
+
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
58
51
|
def ignored_container_path?(node, cache = nil)
|
|
59
52
|
return cache[node] if cache&.key?(node)
|
|
60
53
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
end
|
|
54
|
+
curr = node
|
|
55
|
+
visited = []
|
|
56
|
+
is_ignored = false
|
|
65
57
|
|
|
66
|
-
|
|
58
|
+
while curr.respond_to?(:parent) && curr
|
|
59
|
+
if cache&.key?(curr)
|
|
60
|
+
is_ignored = cache[curr]
|
|
61
|
+
break
|
|
62
|
+
end
|
|
67
63
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
64
|
+
if IGNORED_CONTAINER_TAGS.include?(curr.name)
|
|
65
|
+
is_ignored = true
|
|
66
|
+
break
|
|
67
|
+
end
|
|
72
68
|
|
|
69
|
+
visited << curr
|
|
73
70
|
curr = curr.parent
|
|
74
71
|
end
|
|
75
|
-
|
|
76
|
-
end
|
|
72
|
+
visited.each { |n| cache[n] = is_ignored } if cache
|
|
77
73
|
|
|
78
|
-
|
|
79
|
-
!INVISIBLE_CONTENT_TAGS.include?(node.name) &&
|
|
80
|
-
!(node.name == 'a' && node['href']&.start_with?('#'))
|
|
74
|
+
is_ignored
|
|
81
75
|
end
|
|
76
|
+
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
82
77
|
end
|
|
83
78
|
|
|
84
79
|
##
|
|
85
80
|
# @param article_tag [Nokogiri::XML::Node] article-like container to extract from
|
|
86
81
|
# @param base_url [String, Html2rss::Url] base url used to resolve relative links
|
|
87
82
|
# @param selected_anchor [Nokogiri::XML::Node, nil] explicit primary anchor for the container
|
|
88
|
-
|
|
83
|
+
# @param fallback_anchorless [Boolean] whether to fall back to anchorless extraction
|
|
84
|
+
def initialize(article_tag, base_url:, selected_anchor:, fallback_anchorless: false)
|
|
89
85
|
raise ArgumentError, 'article_tag is required' unless article_tag
|
|
90
86
|
|
|
91
87
|
@article_tag = article_tag
|
|
92
88
|
@base_url = base_url
|
|
93
89
|
@selected_anchor = selected_anchor
|
|
90
|
+
@fallback_anchorless = fallback_anchorless
|
|
94
91
|
end
|
|
95
92
|
|
|
96
93
|
# @return [Hash{Symbol => Object}] extracted article attributes
|
|
@@ -115,54 +112,73 @@ module Html2rss
|
|
|
115
112
|
@extract_url ||= begin
|
|
116
113
|
href = selected_anchor&.[]('href').to_s
|
|
117
114
|
|
|
118
|
-
|
|
115
|
+
if href.empty?
|
|
116
|
+
anchorless_url_fallback
|
|
117
|
+
else
|
|
118
|
+
Url.from_relative(href.split('#').first.strip, base_url)
|
|
119
|
+
end
|
|
119
120
|
end
|
|
120
121
|
end
|
|
121
122
|
|
|
122
|
-
def
|
|
123
|
-
|
|
124
|
-
|
|
123
|
+
def anchorless_url_fallback
|
|
124
|
+
return unless @fallback_anchorless
|
|
125
|
+
|
|
126
|
+
id = generate_id
|
|
127
|
+
Url.from_relative("##{id}", base_url) if id
|
|
125
128
|
end
|
|
126
129
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
|
132
138
|
end
|
|
139
|
+
# rubocop:enable Metrics/CyclomaticComplexity
|
|
140
|
+
|
|
141
|
+
def fallback_anchorless_title
|
|
142
|
+
return unless @fallback_anchorless && selected_anchor.nil?
|
|
133
143
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
max_size = -1
|
|
144
|
+
text_node = article_tag.xpath('.//text()').find { |t| !t.text.strip.empty? }
|
|
145
|
+
text_node&.text&.strip
|
|
146
|
+
end
|
|
138
147
|
|
|
139
|
-
|
|
140
|
-
|
|
148
|
+
def heading
|
|
149
|
+
@heading ||= HeadingExtractor.call(
|
|
150
|
+
article_tag,
|
|
151
|
+
fallback_anchorless: @fallback_anchorless,
|
|
152
|
+
selected_anchor:
|
|
153
|
+
)
|
|
154
|
+
end
|
|
141
155
|
|
|
142
|
-
|
|
143
|
-
|
|
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
|
|
144
162
|
end
|
|
145
|
-
|
|
146
|
-
best_tag
|
|
147
163
|
end
|
|
148
164
|
|
|
149
165
|
def extract_description
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
description = self.class.extract_visible_text(article_tag)
|
|
154
|
-
return nil if description.nil? || description.strip.empty?
|
|
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?
|
|
155
169
|
|
|
156
|
-
description.strip
|
|
170
|
+
desc = description.strip
|
|
171
|
+
desc.empty? ? nil : desc
|
|
157
172
|
end
|
|
158
173
|
|
|
159
174
|
def generate_id
|
|
160
|
-
|
|
161
|
-
article_tag
|
|
162
|
-
|
|
163
|
-
extract_url
|
|
164
|
-
|
|
165
|
-
|
|
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
|
+
)
|
|
166
182
|
end
|
|
167
183
|
|
|
168
184
|
def extract_image = ImageExtractor.call(article_tag, base_url:)
|
|
@@ -170,4 +186,5 @@ module Html2rss
|
|
|
170
186
|
def extract_enclosures = EnclosureExtractor.call(article_tag, base_url)
|
|
171
187
|
def extract_categories = CategoryExtractor.call(article_tag)
|
|
172
188
|
end
|
|
189
|
+
# rubocop:enable Metrics/ClassLength
|
|
173
190
|
end
|
|
@@ -49,6 +49,23 @@ module Html2rss
|
|
|
49
49
|
|
|
50
50
|
current_tag.ancestors(tag_name).first
|
|
51
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
|
|
52
69
|
end
|
|
53
70
|
end
|
|
54
71
|
end
|
data/lib/html2rss/rendering.rb
CHANGED
|
@@ -4,6 +4,8 @@ module Html2rss
|
|
|
4
4
|
# Namespace for HTML rendering logic, used to generate rich content such as
|
|
5
5
|
# images, audio, video, or embedded documents for feed descriptions.
|
|
6
6
|
#
|
|
7
|
+
# @see Html2rss::Rendering::DescriptionBuilder
|
|
8
|
+
#
|
|
7
9
|
# @example
|
|
8
10
|
# Html2rss::Rendering::ImageRenderer.new(
|
|
9
11
|
# url: "https://example.com/image.jpg",
|
|
@@ -16,8 +18,6 @@ module Html2rss
|
|
|
16
18
|
# image: "https://example.com/image.jpg",
|
|
17
19
|
# title: "Example"
|
|
18
20
|
# )
|
|
19
|
-
#
|
|
20
|
-
# @see Html2rss::Rendering::DescriptionBuilder
|
|
21
21
|
module Rendering
|
|
22
22
|
end
|
|
23
23
|
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
class RequestService
|
|
5
|
+
##
|
|
6
|
+
# Strategy to read a local HTML file.
|
|
7
|
+
class LocalFileStrategy < Strategy
|
|
8
|
+
##
|
|
9
|
+
# Executes the local file read.
|
|
10
|
+
#
|
|
11
|
+
# @return [Response] the mock response wrapped around the file contents
|
|
12
|
+
# @raise [ArgumentError] if the local file path is missing
|
|
13
|
+
# @raise [Errno::ENOENT] if the file does not exist
|
|
14
|
+
def execute
|
|
15
|
+
file_path = ctx.request[:local_file_path]
|
|
16
|
+
raise ArgumentError, 'Local file path is required for local_file strategy' unless file_path
|
|
17
|
+
raise Errno::ENOENT, "File not found: #{file_path}" unless File.exist?(file_path)
|
|
18
|
+
|
|
19
|
+
body = File.read(file_path)
|
|
20
|
+
Response.new(
|
|
21
|
+
body:,
|
|
22
|
+
headers: { 'content-type' => 'text/html; charset=utf-8' },
|
|
23
|
+
url: ctx.url,
|
|
24
|
+
status: 200
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -97,6 +97,10 @@ module Html2rss
|
|
|
97
97
|
|
|
98
98
|
attr_reader :ctx, :browser, :skip_request_resources, :referer, :latest_navigation_response, :main_frame
|
|
99
99
|
|
|
100
|
+
##
|
|
101
|
+
# Re-raises a deferred navigation error when one was captured.
|
|
102
|
+
#
|
|
103
|
+
# @raise [Html2rss::Error] when a navigation request or response validation failed
|
|
100
104
|
def raise_navigation_error_if_any
|
|
101
105
|
raise @navigation_error if @navigation_error
|
|
102
106
|
end
|
|
@@ -25,7 +25,7 @@ module Html2rss
|
|
|
25
25
|
# during post processing with {PostProcessors::ParseTime}.
|
|
26
26
|
class Attribute
|
|
27
27
|
# The available options for the attribute extractor.
|
|
28
|
-
Options = Struct.new('AttributeOptions', :selector, :attribute, keyword_init: true)
|
|
28
|
+
Options = Struct.new('AttributeOptions', :selector, :attribute, keyword_init: true)
|
|
29
29
|
|
|
30
30
|
##
|
|
31
31
|
# Initializes the Attribute extractor.
|
|
@@ -25,7 +25,7 @@ module Html2rss
|
|
|
25
25
|
# 'http://blog-without-a-feed.example.com/posts/latest-findings'
|
|
26
26
|
class Href
|
|
27
27
|
# The available options for the href (attribute) extractor.
|
|
28
|
-
Options = Struct.new('HrefOptions', :selector, :channel, keyword_init: true)
|
|
28
|
+
Options = Struct.new('HrefOptions', :selector, :channel, keyword_init: true)
|
|
29
29
|
|
|
30
30
|
##
|
|
31
31
|
# Initializes the Href extractor.
|
|
@@ -24,7 +24,7 @@ module Html2rss
|
|
|
24
24
|
# {PostProcessors::SanitizeHtml}.
|
|
25
25
|
class Html
|
|
26
26
|
# The available options for the html extractor.
|
|
27
|
-
Options = Struct.new('HtmlOptions', :selector, keyword_init: true)
|
|
27
|
+
Options = Struct.new('HtmlOptions', :selector, keyword_init: true)
|
|
28
28
|
|
|
29
29
|
##
|
|
30
30
|
# Initializes the Html extractor.
|
|
@@ -17,7 +17,7 @@ module Html2rss
|
|
|
17
17
|
# 'Foobar'
|
|
18
18
|
class Static
|
|
19
19
|
# The available option for the static extractor.
|
|
20
|
-
Options = Struct.new('StaticOptions', :static, keyword_init: true)
|
|
20
|
+
Options = Struct.new('StaticOptions', :static, keyword_init: true)
|
|
21
21
|
|
|
22
22
|
##
|
|
23
23
|
# Initializes the Static extractor.
|
|
@@ -22,7 +22,7 @@ module Html2rss
|
|
|
22
22
|
# 'Lorem ipsum dolor ...'
|
|
23
23
|
class Text
|
|
24
24
|
# The available options for the text extractor.
|
|
25
|
-
Options = Struct.new('TextOptions', :selector, keyword_init: true)
|
|
25
|
+
Options = Struct.new('TextOptions', :selector, keyword_init: true)
|
|
26
26
|
|
|
27
27
|
##
|
|
28
28
|
# Initializes the Text extractor.
|
|
@@ -128,8 +128,15 @@ module Html2rss
|
|
|
128
128
|
##
|
|
129
129
|
# @return [String, nil]
|
|
130
130
|
def get
|
|
131
|
-
|
|
131
|
+
# Temporarily replace newlines with a placeholder to preserve them during space collapsing
|
|
132
|
+
temp_value = value.to_s.gsub("\n", ' __NEWLINE_PLACEHOLDER__ ')
|
|
133
|
+
sanitized_html = Sanitize.fragment(temp_value, self.class.sanitize_config(channel_url)).to_s
|
|
132
134
|
sanitized_html.gsub!(/\s+/, ' ')
|
|
135
|
+
|
|
136
|
+
# Restore newlines and clean up surrounding whitespace
|
|
137
|
+
sanitized_html.gsub!(/[ \t\r]*__NEWLINE_PLACEHOLDER__[ \t\r]*/, "\n")
|
|
138
|
+
sanitized_html.gsub!(/\n{3,}/, "\n\n")
|
|
139
|
+
|
|
133
140
|
sanitized_html.strip!
|
|
134
141
|
sanitized_html.empty? ? nil : sanitized_html
|
|
135
142
|
end
|
data/lib/html2rss/selectors.rb
CHANGED
|
@@ -18,7 +18,7 @@ module Html2rss
|
|
|
18
18
|
include Enumerable
|
|
19
19
|
|
|
20
20
|
# A context instance passed to item extractors and post-processors.
|
|
21
|
-
Context = Struct.new('Context', :options, :item, :config, :scraper, keyword_init: true)
|
|
21
|
+
Context = Struct.new('Context', :options, :item, :config, :scraper, keyword_init: true)
|
|
22
22
|
|
|
23
23
|
# Default selectors options merged into user configuration.
|
|
24
24
|
DEFAULT_CONFIG = { items: { enhance: true } }.freeze
|
|
@@ -103,11 +103,15 @@ module Html2rss
|
|
|
103
103
|
# @param article_tag [Nokogiri::XML::Element] HTML element to extract additional info from.
|
|
104
104
|
# @param base_url [String, Html2rss::Url] base URL for normalization during enhancement
|
|
105
105
|
# @return [Hash] The enhanced article hash.
|
|
106
|
+
# rubocop:disable Metrics/MethodLength
|
|
106
107
|
def enhance_article_hash(article_hash, article_tag, base_url = @url)
|
|
107
108
|
selected_anchor = HtmlExtractor.main_anchor_for(article_tag)
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
109
|
+
extracted = HtmlExtractor.new(
|
|
110
|
+
article_tag,
|
|
111
|
+
base_url:,
|
|
112
|
+
selected_anchor:,
|
|
113
|
+
fallback_anchorless: true
|
|
114
|
+
).call
|
|
111
115
|
return article_hash unless extracted
|
|
112
116
|
|
|
113
117
|
extracted.each_with_object(article_hash) do |(key, value), hash|
|
|
@@ -116,6 +120,7 @@ module Html2rss
|
|
|
116
120
|
hash[key] = value
|
|
117
121
|
end
|
|
118
122
|
end
|
|
123
|
+
# rubocop:enable Metrics/MethodLength
|
|
119
124
|
|
|
120
125
|
##
|
|
121
126
|
# Selects the value for a given attribute from an HTML element.
|