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
|
@@ -4,15 +4,13 @@ module Html2rss
|
|
|
4
4
|
##
|
|
5
5
|
# HtmlExtractor is responsible for extracting details (headline, url, images, etc.)
|
|
6
6
|
# from an article_tag.
|
|
7
|
+
# rubocop:disable Metrics/ClassLength
|
|
7
8
|
class HtmlExtractor
|
|
8
|
-
# Tags ignored when extracting visible text content from article containers.
|
|
9
|
-
INVISIBLE_CONTENT_TAGS = %w[svg script noscript style template].to_set.freeze
|
|
10
|
-
# Element path pattern ignored when traversing candidate article containers.
|
|
11
|
-
IGNORED_CONTAINER_PATH = /(nav|footer|header|svg|script|style)/i
|
|
12
9
|
# Heading tags used to prioritize title extraction.
|
|
13
10
|
HEADING_TAGS = %w[h1 h2 h3 h4 h5 h6].freeze
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|
16
14
|
|
|
17
15
|
# Anchor selector used to identify the canonical article link element.
|
|
18
16
|
MAIN_ANCHOR_SELECTOR = begin
|
|
@@ -26,27 +24,47 @@ 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
|
-
|
|
33
|
+
def extract_visible_text(tag, separator: ' ', exclude_nodes: nil)
|
|
34
|
+
TextExtractor.call(tag, separator:, exclude_nodes:)
|
|
35
|
+
end
|
|
36
36
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
|
41
45
|
|
|
42
|
-
|
|
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
|
+
def ignored_container_path?(node, cache = nil)
|
|
51
|
+
return cache[node] if cache&.key?(node)
|
|
52
|
+
|
|
53
|
+
res = walk_ignored_container_path?(node)
|
|
54
|
+
cache[node] = res if cache
|
|
55
|
+
res
|
|
43
56
|
end
|
|
44
57
|
|
|
45
58
|
private
|
|
46
59
|
|
|
47
|
-
def
|
|
48
|
-
|
|
49
|
-
|
|
60
|
+
def walk_ignored_container_path?(node)
|
|
61
|
+
curr = node
|
|
62
|
+
while curr.respond_to?(:parent)
|
|
63
|
+
return true if IGNORED_CONTAINER_TAGS.include?(curr.name)
|
|
64
|
+
|
|
65
|
+
curr = curr.parent
|
|
66
|
+
end
|
|
67
|
+
false
|
|
50
68
|
end
|
|
51
69
|
end
|
|
52
70
|
|
|
@@ -54,12 +72,14 @@ module Html2rss
|
|
|
54
72
|
# @param article_tag [Nokogiri::XML::Node] article-like container to extract from
|
|
55
73
|
# @param base_url [String, Html2rss::Url] base url used to resolve relative links
|
|
56
74
|
# @param selected_anchor [Nokogiri::XML::Node, nil] explicit primary anchor for the container
|
|
57
|
-
|
|
75
|
+
# @param fallback_anchorless [Boolean] whether to fall back to anchorless extraction
|
|
76
|
+
def initialize(article_tag, base_url:, selected_anchor:, fallback_anchorless: false)
|
|
58
77
|
raise ArgumentError, 'article_tag is required' unless article_tag
|
|
59
78
|
|
|
60
79
|
@article_tag = article_tag
|
|
61
80
|
@base_url = base_url
|
|
62
81
|
@selected_anchor = selected_anchor
|
|
82
|
+
@fallback_anchorless = fallback_anchorless
|
|
63
83
|
end
|
|
64
84
|
|
|
65
85
|
# @return [Hash{Symbol => Object}] extracted article attributes
|
|
@@ -80,68 +100,66 @@ module Html2rss
|
|
|
80
100
|
|
|
81
101
|
attr_reader :article_tag, :base_url, :selected_anchor
|
|
82
102
|
|
|
83
|
-
class << self
|
|
84
|
-
##
|
|
85
|
-
# @param article_tag [Nokogiri::XML::Node] article-like container to search within
|
|
86
|
-
# @return [Nokogiri::XML::Node, nil] first eligible descendant anchor
|
|
87
|
-
def main_anchor_for(article_tag)
|
|
88
|
-
return article_tag if article_tag.name == 'a' && article_tag.matches?(MAIN_ANCHOR_SELECTOR)
|
|
89
|
-
|
|
90
|
-
article_tag.at_css(MAIN_ANCHOR_SELECTOR)
|
|
91
|
-
end
|
|
92
|
-
|
|
93
|
-
##
|
|
94
|
-
# @param node [Nokogiri::XML::Node, String] node or path to test
|
|
95
|
-
# @return [Boolean] true when the node belongs to ignored DOM chrome
|
|
96
|
-
def ignored_container_path?(node)
|
|
97
|
-
path = node.respond_to?(:path) ? node.path : node.to_s
|
|
98
|
-
|
|
99
|
-
path.match?(IGNORED_CONTAINER_PATH)
|
|
100
|
-
end
|
|
101
|
-
end
|
|
102
|
-
|
|
103
103
|
def extract_url
|
|
104
104
|
@extract_url ||= begin
|
|
105
105
|
href = selected_anchor&.[]('href').to_s
|
|
106
106
|
|
|
107
|
-
|
|
107
|
+
if href.empty?
|
|
108
|
+
anchorless_url_fallback
|
|
109
|
+
else
|
|
110
|
+
Url.from_relative(href.split('#').first.strip, base_url)
|
|
111
|
+
end
|
|
108
112
|
end
|
|
109
113
|
end
|
|
110
114
|
|
|
115
|
+
def anchorless_url_fallback
|
|
116
|
+
return unless @fallback_anchorless
|
|
117
|
+
|
|
118
|
+
id = generate_id
|
|
119
|
+
Url.from_relative("##{id}", base_url) if id
|
|
120
|
+
end
|
|
121
|
+
|
|
111
122
|
def extract_title
|
|
112
123
|
title_source = heading || selected_anchor
|
|
113
|
-
|
|
124
|
+
if title_source
|
|
125
|
+
self.class.extract_visible_text(title_source)
|
|
126
|
+
else
|
|
127
|
+
fallback_anchorless_title
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def fallback_anchorless_title
|
|
132
|
+
return unless @fallback_anchorless && selected_anchor.nil?
|
|
133
|
+
|
|
134
|
+
text_node = article_tag.xpath('.//text()').find { |t| !t.text.strip.empty? }
|
|
135
|
+
text_node&.text&.strip
|
|
114
136
|
end
|
|
115
137
|
|
|
116
138
|
def heading
|
|
117
|
-
@heading ||=
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
self.class.extract_visible_text(tag)&.size.to_i
|
|
123
|
-
end
|
|
124
|
-
end
|
|
125
|
-
end
|
|
139
|
+
@heading ||= HeadingExtractor.call(
|
|
140
|
+
article_tag,
|
|
141
|
+
fallback_anchorless: @fallback_anchorless,
|
|
142
|
+
selected_anchor:
|
|
143
|
+
)
|
|
126
144
|
end
|
|
127
145
|
|
|
128
146
|
def extract_description
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
description = self.class.extract_visible_text(article_tag)
|
|
133
|
-
return nil if description.nil? || description.strip.empty?
|
|
147
|
+
exclude = [heading, selected_anchor].compact.to_set
|
|
148
|
+
description = self.class.extract_visible_text(article_tag, exclude_nodes: exclude)
|
|
149
|
+
return if description.nil?
|
|
134
150
|
|
|
135
|
-
description.strip
|
|
151
|
+
desc = description.strip
|
|
152
|
+
desc.empty? ? nil : desc
|
|
136
153
|
end
|
|
137
154
|
|
|
138
155
|
def generate_id
|
|
139
|
-
|
|
140
|
-
article_tag
|
|
141
|
-
|
|
142
|
-
extract_url
|
|
143
|
-
|
|
144
|
-
|
|
156
|
+
@generate_id ||= IdGenerator.call(
|
|
157
|
+
article_tag,
|
|
158
|
+
heading:,
|
|
159
|
+
url: (selected_anchor ? extract_url : nil),
|
|
160
|
+
selected_anchor:,
|
|
161
|
+
fallback_anchorless: @fallback_anchorless
|
|
162
|
+
)
|
|
145
163
|
end
|
|
146
164
|
|
|
147
165
|
def extract_image = ImageExtractor.call(article_tag, base_url:)
|
|
@@ -149,4 +167,5 @@ module Html2rss
|
|
|
149
167
|
def extract_enclosures = EnclosureExtractor.call(article_tag, base_url)
|
|
150
168
|
def extract_categories = CategoryExtractor.call(article_tag)
|
|
151
169
|
end
|
|
170
|
+
# rubocop:enable Metrics/ClassLength
|
|
152
171
|
end
|
|
@@ -25,12 +25,12 @@ module Html2rss
|
|
|
25
25
|
# @param end_of_range [Integer] Optional, defaults to half the text length
|
|
26
26
|
# @return [String]
|
|
27
27
|
def self.remove_pattern_from_start(text, pattern, end_of_range: (text.size * 0.5).to_i)
|
|
28
|
-
return text unless text.is_a?(String) && pattern.is_a?(String)
|
|
28
|
+
return text unless text.is_a?(String) && pattern.is_a?(String) && !pattern.empty?
|
|
29
29
|
|
|
30
30
|
index = text.index(pattern)
|
|
31
|
-
return text if index.nil? || index
|
|
31
|
+
return text if index.nil? || index > end_of_range
|
|
32
32
|
|
|
33
|
-
text
|
|
33
|
+
"#{text[0, index]}#{text[(index + pattern.size)..]}"
|
|
34
34
|
end
|
|
35
35
|
|
|
36
36
|
# @param base [String] The base text content for the description
|
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
|
|
@@ -9,6 +9,7 @@ module Html2rss
|
|
|
9
9
|
##
|
|
10
10
|
# Article is a simple data object representing an article extracted from a page.
|
|
11
11
|
# It is enumerable and responds to all keys specified in PROVIDED_KEYS.
|
|
12
|
+
# rubocop:disable Metrics/ClassLength
|
|
12
13
|
class Article
|
|
13
14
|
include Enumerable
|
|
14
15
|
include Comparable
|
|
@@ -17,6 +18,11 @@ module Html2rss
|
|
|
17
18
|
PROVIDED_KEYS = %i[id title description url image author guid published_at enclosures categories scraper].freeze
|
|
18
19
|
# Separator used to build deterministic deduplication fingerprints.
|
|
19
20
|
DEDUP_FINGERPRINT_SEPARATOR = '#!/'
|
|
21
|
+
# Sentinel object used to pre-initialize instance variables in the constructor.
|
|
22
|
+
# This ensures all Article instances share the exact same object shape (Ruby 3.3+ optimization),
|
|
23
|
+
# preventing performance warnings and slower instance variable access due to shape transitions
|
|
24
|
+
# when attributes are lazily/conditionally accessed in different sequences.
|
|
25
|
+
NOT_SET = Object.new.freeze
|
|
20
26
|
|
|
21
27
|
# @param options [Hash{Symbol => String}]
|
|
22
28
|
# @option options [String] :id stable article identifier
|
|
@@ -31,9 +37,9 @@ module Html2rss
|
|
|
31
37
|
# @option options [Array<String>] :categories category labels
|
|
32
38
|
# @option options [Class] :scraper scraper class that produced the article
|
|
33
39
|
def initialize(**options)
|
|
34
|
-
@to_h = {}
|
|
35
|
-
|
|
36
|
-
@
|
|
40
|
+
@to_h = options.each_with_object({}) { |(k, v), h| h[k] = v.freeze if v }.freeze
|
|
41
|
+
|
|
42
|
+
@description = @url = @image = @guid = @enclosures = @enclosure = @categories = @published_at = NOT_SET
|
|
37
43
|
|
|
38
44
|
return unless (unknown_keys = options.keys - PROVIDED_KEYS).any?
|
|
39
45
|
|
|
@@ -62,7 +68,9 @@ module Html2rss
|
|
|
62
68
|
|
|
63
69
|
# @return [String] rendered article description
|
|
64
70
|
def description
|
|
65
|
-
@description
|
|
71
|
+
return @description unless @description == NOT_SET
|
|
72
|
+
|
|
73
|
+
@description = Rendering::DescriptionBuilder.new(
|
|
66
74
|
base: @to_h[:description],
|
|
67
75
|
title:,
|
|
68
76
|
url:,
|
|
@@ -73,12 +81,16 @@ module Html2rss
|
|
|
73
81
|
|
|
74
82
|
# @return [Url, nil]
|
|
75
83
|
def url
|
|
76
|
-
@url
|
|
84
|
+
return @url unless @url == NOT_SET
|
|
85
|
+
|
|
86
|
+
@url = Url.sanitize(@to_h[:url])
|
|
77
87
|
end
|
|
78
88
|
|
|
79
89
|
# @return [Url, nil]
|
|
80
90
|
def image
|
|
81
|
-
@image
|
|
91
|
+
return @image unless @image == NOT_SET
|
|
92
|
+
|
|
93
|
+
@image = Url.sanitize(@to_h[:image])
|
|
82
94
|
end
|
|
83
95
|
|
|
84
96
|
# @return [String, nil]
|
|
@@ -87,7 +99,9 @@ module Html2rss
|
|
|
87
99
|
# Generates a unique identifier based on the URL and ID using CRC32.
|
|
88
100
|
# @return [String]
|
|
89
101
|
def guid
|
|
90
|
-
@guid
|
|
102
|
+
return @guid unless @guid == NOT_SET
|
|
103
|
+
|
|
104
|
+
@guid = Zlib.crc32(fetch_guid).to_s(36).encode('utf-8')
|
|
91
105
|
end
|
|
92
106
|
|
|
93
107
|
##
|
|
@@ -100,27 +114,32 @@ module Html2rss
|
|
|
100
114
|
|
|
101
115
|
# @return [Array<Html2rss::RssBuilder::Enclosure>] normalized enclosure objects
|
|
102
116
|
def enclosures
|
|
103
|
-
@enclosures
|
|
104
|
-
|
|
117
|
+
return @enclosures unless @enclosures == NOT_SET
|
|
118
|
+
|
|
119
|
+
@enclosures = Array(@to_h[:enclosures])
|
|
120
|
+
.map { |enclosure| Html2rss::RssBuilder::Enclosure.new(**enclosure) }
|
|
105
121
|
end
|
|
106
122
|
|
|
107
123
|
# @return [Html2rss::RssBuilder::Enclosure, nil]
|
|
108
124
|
def enclosure
|
|
109
|
-
return @enclosure
|
|
110
|
-
|
|
111
|
-
case (object = @to_h[:enclosures]&.first)
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
125
|
+
return @enclosure unless @enclosure == NOT_SET
|
|
126
|
+
|
|
127
|
+
@enclosure = case (object = @to_h[:enclosures]&.first)
|
|
128
|
+
when Hash
|
|
129
|
+
Html2rss::RssBuilder::Enclosure.new(**object)
|
|
130
|
+
when nil
|
|
131
|
+
Html2rss::RssBuilder::Enclosure.new(url: image) if image
|
|
132
|
+
else
|
|
133
|
+
Log.warn "Article: unknown enclosure type: #{object.class}"
|
|
134
|
+
nil
|
|
135
|
+
end
|
|
119
136
|
end
|
|
120
137
|
|
|
121
138
|
# @return [Array<String>] normalized, unique category names
|
|
122
139
|
def categories
|
|
123
|
-
@categories
|
|
140
|
+
return @categories unless @categories == NOT_SET
|
|
141
|
+
|
|
142
|
+
@categories = @to_h[:categories].dup.to_a.tap do |categories|
|
|
124
143
|
categories.map! { |category| category.to_s.strip }
|
|
125
144
|
categories.reject!(&:empty?)
|
|
126
145
|
categories.uniq!
|
|
@@ -130,11 +149,12 @@ module Html2rss
|
|
|
130
149
|
# Parses and returns the published_at time.
|
|
131
150
|
# @return [DateTime, nil]
|
|
132
151
|
def published_at
|
|
133
|
-
return
|
|
152
|
+
return @published_at unless @published_at == NOT_SET
|
|
134
153
|
|
|
135
|
-
@published_at
|
|
154
|
+
string = @to_h[:published_at].to_s.strip
|
|
155
|
+
@published_at = string.empty? ? nil : DateTime.parse(string)
|
|
136
156
|
rescue ArgumentError
|
|
137
|
-
nil
|
|
157
|
+
@published_at = nil
|
|
138
158
|
end
|
|
139
159
|
|
|
140
160
|
# @return [Class, nil] scraper class that produced this article
|
|
@@ -183,5 +203,6 @@ module Html2rss
|
|
|
183
203
|
value
|
|
184
204
|
end
|
|
185
205
|
end
|
|
206
|
+
# rubocop:enable Metrics/ClassLength
|
|
186
207
|
end
|
|
187
208
|
end
|
|
@@ -16,9 +16,11 @@ module Html2rss
|
|
|
16
16
|
def self.guess_content_type_from_url(url, default: 'application/octet-stream')
|
|
17
17
|
return default unless url
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
path = url.path
|
|
20
|
+
ext = File.extname(path)
|
|
21
|
+
ext = ext[1..] if ext.start_with?('.')
|
|
20
22
|
|
|
21
|
-
content_type = MIME::Types.type_for(
|
|
23
|
+
content_type = MIME::Types.type_for(ext)
|
|
22
24
|
content_type.first&.to_s || 'application/octet-stream'
|
|
23
25
|
end
|
|
24
26
|
|
|
@@ -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.
|
|
@@ -91,7 +91,6 @@ module Html2rss
|
|
|
91
91
|
end
|
|
92
92
|
|
|
93
93
|
##
|
|
94
|
-
# Shorthand method to get the sanitized HTML.
|
|
95
94
|
# @param html [String]
|
|
96
95
|
# @param url [String, Html2rss::Url]
|
|
97
96
|
# @return [String, nil]
|
|
@@ -102,11 +101,42 @@ module Html2rss
|
|
|
102
101
|
new(html, context).get
|
|
103
102
|
end
|
|
104
103
|
|
|
104
|
+
##
|
|
105
|
+
# @param channel_url [String, Html2rss::Url]
|
|
106
|
+
# @return [Hash] the memoized sanitize configuration
|
|
107
|
+
# rubocop:disable Metrics/MethodLength, ThreadSafety/ClassInstanceVariable
|
|
108
|
+
def self.sanitize_config(channel_url)
|
|
109
|
+
@sanitize_configs ||= {}
|
|
110
|
+
@sanitize_configs[channel_url] ||= begin
|
|
111
|
+
config = Sanitize::Config.merge(
|
|
112
|
+
Sanitize::Config::RELAXED,
|
|
113
|
+
attributes: { all: %w[dir lang alt title translate] },
|
|
114
|
+
add_attributes: TAG_ATTRIBUTES,
|
|
115
|
+
transformers: [
|
|
116
|
+
lambda { |env|
|
|
117
|
+
HtmlTransformers::TransformUrlsToAbsoluteOnes.new(channel_url).call(**env)
|
|
118
|
+
},
|
|
119
|
+
->(env) { HtmlTransformers::WrapImgInA.new.call(**env) }
|
|
120
|
+
]
|
|
121
|
+
)
|
|
122
|
+
config[:elements].push('audio', 'video', 'source')
|
|
123
|
+
config.freeze
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
# rubocop:enable Metrics/MethodLength, ThreadSafety/ClassInstanceVariable
|
|
127
|
+
|
|
105
128
|
##
|
|
106
129
|
# @return [String, nil]
|
|
107
130
|
def get
|
|
108
|
-
|
|
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
|
|
109
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
|
+
|
|
110
140
|
sanitized_html.strip!
|
|
111
141
|
sanitized_html.empty? ? nil : sanitized_html
|
|
112
142
|
end
|
|
@@ -114,40 +144,6 @@ module Html2rss
|
|
|
114
144
|
private
|
|
115
145
|
|
|
116
146
|
def channel_url = context.dig(:config, :channel, :url)
|
|
117
|
-
|
|
118
|
-
##
|
|
119
|
-
# @return [Sanitize::Config]
|
|
120
|
-
def sanitize_config # rubocop:disable Metrics/MethodLength
|
|
121
|
-
config = Sanitize::Config.merge(
|
|
122
|
-
Sanitize::Config::RELAXED,
|
|
123
|
-
attributes: { all: %w[dir lang alt title translate] },
|
|
124
|
-
add_attributes: TAG_ATTRIBUTES,
|
|
125
|
-
transformers: [
|
|
126
|
-
method(:transform_urls_to_absolute_ones),
|
|
127
|
-
method(:wrap_img_in_a)
|
|
128
|
-
]
|
|
129
|
-
)
|
|
130
|
-
config[:elements].push('audio', 'video', 'source')
|
|
131
|
-
config
|
|
132
|
-
end
|
|
133
|
-
|
|
134
|
-
##
|
|
135
|
-
# Wrapper for transform_urls_to_absolute_ones to pass the channel_url.
|
|
136
|
-
#
|
|
137
|
-
# @param env [Hash]
|
|
138
|
-
# @return [nil]
|
|
139
|
-
def transform_urls_to_absolute_ones(env)
|
|
140
|
-
HtmlTransformers::TransformUrlsToAbsoluteOnes.new(channel_url).call(**env)
|
|
141
|
-
end
|
|
142
|
-
|
|
143
|
-
##
|
|
144
|
-
# Wrapper for wrap_img_in_a.
|
|
145
|
-
#
|
|
146
|
-
# @param env [Hash]
|
|
147
|
-
# @return [nil]
|
|
148
|
-
def wrap_img_in_a(env)
|
|
149
|
-
HtmlTransformers::WrapImgInA.new.call(**env)
|
|
150
|
-
end
|
|
151
147
|
end
|
|
152
148
|
end
|
|
153
149
|
end
|
|
@@ -34,19 +34,17 @@ module Html2rss
|
|
|
34
34
|
# @param context [Selectors::Context] post-processor context
|
|
35
35
|
# @return [void]
|
|
36
36
|
def self.validate_args!(value, context)
|
|
37
|
-
assert_type
|
|
37
|
+
assert_type(value, String, :value, context:)
|
|
38
38
|
|
|
39
39
|
options = context[:options]
|
|
40
|
-
assert_type
|
|
41
|
-
|
|
42
|
-
end_index = options[:end]
|
|
43
|
-
assert_type(end_index, Integer, :end, context:) if end_index
|
|
40
|
+
assert_type(options[:start], Integer, :start, context:)
|
|
41
|
+
assert_type(options[:end], Integer, :end, context:) if options.key?(:end)
|
|
44
42
|
end
|
|
45
43
|
|
|
46
44
|
##
|
|
47
45
|
# Extracts the substring from the original string based on the provided start and end indices.
|
|
48
46
|
#
|
|
49
|
-
# @return [String] The extracted substring.
|
|
47
|
+
# @return [String, nil] The extracted substring.
|
|
50
48
|
def get
|
|
51
49
|
value[range]
|
|
52
50
|
end
|
|
@@ -56,21 +54,16 @@ module Html2rss
|
|
|
56
54
|
#
|
|
57
55
|
# @return [Range] The range object representing the start and end/Infinity (integers).
|
|
58
56
|
def range
|
|
59
|
-
|
|
57
|
+
options = context[:options]
|
|
58
|
+
start = options[:start]
|
|
60
59
|
|
|
61
|
-
|
|
62
|
-
raise ArgumentError,
|
|
63
|
-
'The `start` value must be unequal to the `end` value.'
|
|
64
|
-
end
|
|
60
|
+
return (start..) unless options.key?(:end)
|
|
65
61
|
|
|
66
|
-
|
|
67
|
-
|
|
62
|
+
finish = options[:end]
|
|
63
|
+
raise ArgumentError, 'The `start` value must be unequal to the `end` value.' if start == finish
|
|
68
64
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
def end_index? = !context[:options][:end].to_s.empty?
|
|
72
|
-
def end_index = context[:options][:end].to_i
|
|
73
|
-
def start_index = context[:options][:start].to_i
|
|
65
|
+
(start..finish)
|
|
66
|
+
end
|
|
74
67
|
end
|
|
75
68
|
end
|
|
76
69
|
end
|