html2rss 0.24.0 → 0.25.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/lib/html2rss/auto_source/scraper/html.rb +68 -145
- data/lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb +116 -0
- data/lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb +63 -0
- data/lib/html2rss/auto_source/scraper/json_state/document_scanner.rb +179 -0
- data/lib/html2rss/auto_source/scraper/json_state/value_finder.rb +55 -0
- data/lib/html2rss/auto_source/scraper/json_state.rb +0 -379
- data/lib/html2rss/auto_source/scraper/semantic_html/entry_deduplicator.rb +35 -30
- data/lib/html2rss/auto_source/scraper/semantic_html.rb +40 -120
- data/lib/html2rss/auto_source/scraper/sitemap/parser.rb +161 -0
- data/lib/html2rss/auto_source/scraper/sitemap.rb +10 -7
- data/lib/html2rss/auto_source/scraper/wordpress_api.rb +6 -2
- data/lib/html2rss/auto_source/scraper.rb +81 -44
- data/lib/html2rss/auto_source/segment.rb +29 -0
- data/lib/html2rss/auto_source/segmenter/cluster.rb +163 -0
- data/lib/html2rss/auto_source/segmenter/list.rb +101 -0
- data/lib/html2rss/auto_source/segmenter/primary_link.rb +81 -0
- data/lib/html2rss/auto_source/segmenter/semantic.rb +104 -0
- data/lib/html2rss/auto_source/segmenter.rb +66 -0
- data/lib/html2rss/auto_source.rb +74 -21
- data/lib/html2rss/cli.rb +8 -13
- data/lib/html2rss/config/auto_source_contract.rb +2 -0
- data/lib/html2rss/config/request_controls.rb +33 -0
- data/lib/html2rss/config/validator.rb +15 -8
- data/lib/html2rss/config.rb +6 -2
- data/lib/html2rss/feed_builder/rss.rb +17 -5
- data/lib/html2rss/feed_pipeline.rb +4 -0
- data/lib/html2rss/feed_result.rb +1 -1
- data/lib/html2rss/html/article_extractor/category_extractor.rb +10 -36
- data/lib/html2rss/html/article_extractor/date_extractor.rb +2 -7
- data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +5 -50
- data/lib/html2rss/html/article_extractor/image_extractor.rb +5 -16
- data/lib/html2rss/html/article_rules/category.rb +55 -0
- data/lib/html2rss/html/article_rules/date.rb +25 -0
- data/lib/html2rss/html/article_rules/enclosure.rb +72 -0
- data/lib/html2rss/html/article_rules/image.rb +109 -0
- data/lib/html2rss/html/article_rules.rb +10 -0
- data/lib/html2rss/html/rendering/audio_renderer.rb +2 -12
- data/lib/html2rss/html/rendering/escaped_attributes.rb +27 -0
- data/lib/html2rss/html/rendering/image_renderer.rb +2 -12
- data/lib/html2rss/html/rendering/pdf_renderer.rb +2 -8
- data/lib/html2rss/html/rendering/video_renderer.rb +2 -12
- data/lib/html2rss/html/sst_article_extractor.rb +279 -0
- data/lib/html2rss/link_destination/destination_facts.rb +40 -0
- data/lib/html2rss/link_destination/noise_policy.rb +79 -0
- data/lib/html2rss/link_destination/path_classifier.rb +205 -0
- data/lib/html2rss/link_destination/text_classifier.rb +64 -0
- data/lib/html2rss/link_destination.rb +8 -0
- data/lib/html2rss/request_service/faraday_strategy.rb +42 -1
- data/lib/html2rss/request_service/network_guard.rb +5 -3
- data/lib/html2rss/scoring/anchor_score.rb +34 -0
- data/lib/html2rss/scoring/cluster_scorer.rb +87 -0
- data/lib/html2rss/scoring/container_assessor.rb +83 -0
- data/lib/html2rss/scoring/engine.rb +101 -0
- data/lib/html2rss/scoring/link_resolver.rb +72 -0
- data/lib/html2rss/scoring/observation.rb +53 -0
- data/lib/html2rss/scoring/ranked_segment.rb +45 -0
- data/lib/html2rss/scoring/score.rb +30 -0
- data/lib/html2rss/scoring.rb +33 -0
- data/lib/html2rss/selectors/post_processors/sanitize_html.rb +10 -1
- data/lib/html2rss/selectors.rb +0 -20
- data/lib/html2rss/sst/attrs.rb +91 -0
- data/lib/html2rss/sst/document.rb +23 -0
- data/lib/html2rss/sst/index.rb +112 -0
- data/lib/html2rss/sst/node.rb +147 -0
- data/lib/html2rss/sst/normalizer.rb +171 -0
- data/lib/html2rss/sst/tags.rb +26 -0
- data/lib/html2rss/sst/text.rb +81 -0
- data/lib/html2rss/sst.rb +8 -0
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss.rb +26 -26
- data/schema/html2rss-config.schema.json +20 -0
- metadata +42 -18
- data/lib/html2rss/auto_source/discovery/dom_clustering/group_scorer.rb +0 -80
- data/lib/html2rss/auto_source/discovery/dom_clustering/overlap_resolver.rb +0 -86
- data/lib/html2rss/auto_source/discovery/dom_clustering.rb +0 -119
- data/lib/html2rss/auto_source/discovery/list_candidates.rb +0 -94
- data/lib/html2rss/auto_source/discovery/semantic_anchor_candidates.rb +0 -219
- data/lib/html2rss/auto_source/discovery/semantic_containers.rb +0 -71
- data/lib/html2rss/auto_source/discovery/sitemap.rb +0 -159
- data/lib/html2rss/auto_source/discovery.rb +0 -14
- data/lib/html2rss/auto_source/link_heuristics/anchor_signals.rb +0 -28
- data/lib/html2rss/auto_source/link_heuristics/container_assessor.rb +0 -106
- data/lib/html2rss/auto_source/link_heuristics/container_signals.rb +0 -80
- data/lib/html2rss/auto_source/link_heuristics/destination_facts.rb +0 -42
- data/lib/html2rss/auto_source/link_heuristics/href_extractor.rb +0 -38
- data/lib/html2rss/auto_source/link_heuristics/path_classifier.rb +0 -221
- data/lib/html2rss/auto_source/link_heuristics/text_classifier.rb +0 -66
- data/lib/html2rss/auto_source/link_heuristics.rb +0 -139
data/lib/html2rss/cli.rb
CHANGED
|
@@ -79,6 +79,9 @@ module Html2rss
|
|
|
79
79
|
method_option :max_requests,
|
|
80
80
|
type: :numeric,
|
|
81
81
|
desc: 'Maximum requests to allow for this feed build'
|
|
82
|
+
method_option :limit,
|
|
83
|
+
type: :numeric,
|
|
84
|
+
desc: 'Maximum number of articles to keep (default: 25)'
|
|
82
85
|
method_option :input,
|
|
83
86
|
type: :string,
|
|
84
87
|
desc: 'Local HTML file path to read input from'
|
|
@@ -187,22 +190,13 @@ module Html2rss
|
|
|
187
190
|
end
|
|
188
191
|
|
|
189
192
|
def request_controls
|
|
190
|
-
Html2rss::Config::RequestControls.
|
|
191
|
-
strategy: options[:strategy]
|
|
193
|
+
Html2rss::Config::RequestControls.from_cli_options(
|
|
194
|
+
strategy: options[:strategy],
|
|
192
195
|
max_redirects: options[:max_redirects],
|
|
193
|
-
max_requests: options[:max_requests]
|
|
194
|
-
explicit_keys: explicit_request_control_keys
|
|
196
|
+
max_requests: options[:max_requests]
|
|
195
197
|
)
|
|
196
198
|
end
|
|
197
199
|
|
|
198
|
-
def explicit_request_control_keys
|
|
199
|
-
keys = []
|
|
200
|
-
keys << :strategy if options[:strategy]
|
|
201
|
-
keys << :max_redirects unless options[:max_redirects].nil?
|
|
202
|
-
keys << :max_requests unless options[:max_requests].nil?
|
|
203
|
-
keys
|
|
204
|
-
end
|
|
205
|
-
|
|
206
200
|
def current_strategy
|
|
207
201
|
options[:strategy]&.to_sym || :auto
|
|
208
202
|
end
|
|
@@ -249,7 +243,8 @@ module Html2rss
|
|
|
249
243
|
items_selector: options[:items_selector],
|
|
250
244
|
max_redirects: options[:max_redirects],
|
|
251
245
|
max_requests: options[:max_requests],
|
|
252
|
-
local_file_path
|
|
246
|
+
local_file_path:,
|
|
247
|
+
limit: options[:limit]&.to_i
|
|
253
248
|
)
|
|
254
249
|
end
|
|
255
250
|
|
|
@@ -6,6 +6,8 @@ module Html2rss
|
|
|
6
6
|
class Config
|
|
7
7
|
# Runtime source of truth for validating auto-source config values.
|
|
8
8
|
AutoSourceContract = Dry::Schema.Params do # rubocop:disable Metrics/BlockLength
|
|
9
|
+
optional(:limit).filled(:integer, gt?: 0)
|
|
10
|
+
|
|
9
11
|
optional(:scraper).hash do # rubocop:disable Metrics/BlockLength
|
|
10
12
|
optional(:wordpress_api).hash do
|
|
11
13
|
optional(:enabled).filled(:bool)
|
|
@@ -43,6 +43,39 @@ module Html2rss
|
|
|
43
43
|
request_config.is_a?(Hash) && request_config.key?(key)
|
|
44
44
|
end
|
|
45
45
|
|
|
46
|
+
##
|
|
47
|
+
# Builds controls for public API shortcuts ({Html2rss.auto_source}, etc.).
|
|
48
|
+
# Strategy is explicit only when non-nil and not the configured default.
|
|
49
|
+
#
|
|
50
|
+
# @param strategy [Symbol, nil]
|
|
51
|
+
# @param max_redirects [Integer, nil]
|
|
52
|
+
# @param max_requests [Integer, nil]
|
|
53
|
+
# @return [RequestControls]
|
|
54
|
+
def self.from_shortcut(strategy:, max_redirects: nil, max_requests: nil)
|
|
55
|
+
keys = []
|
|
56
|
+
keys << :strategy unless strategy.nil? || strategy == Config.default_strategy_name
|
|
57
|
+
keys << :max_redirects unless max_redirects.nil?
|
|
58
|
+
keys << :max_requests unless max_requests.nil?
|
|
59
|
+
new(strategy:, max_redirects:, max_requests:, explicit_keys: keys)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
##
|
|
63
|
+
# Builds controls from CLI option values. Strategy is explicit when the
|
|
64
|
+
# option was provided (truthy string/symbol), matching Thor option presence.
|
|
65
|
+
#
|
|
66
|
+
# @param strategy [Symbol, String, nil]
|
|
67
|
+
# @param max_redirects [Integer, nil]
|
|
68
|
+
# @param max_requests [Integer, nil]
|
|
69
|
+
# @return [RequestControls]
|
|
70
|
+
def self.from_cli_options(strategy:, max_redirects: nil, max_requests: nil)
|
|
71
|
+
strategy_sym = strategy&.to_sym
|
|
72
|
+
keys = []
|
|
73
|
+
keys << :strategy if strategy
|
|
74
|
+
keys << :max_redirects unless max_redirects.nil?
|
|
75
|
+
keys << :max_requests unless max_requests.nil?
|
|
76
|
+
new(strategy: strategy_sym, max_redirects:, max_requests:, explicit_keys: keys)
|
|
77
|
+
end
|
|
78
|
+
|
|
46
79
|
private_class_method :explicit_keys_for, :request_value_for, :request_key?
|
|
47
80
|
|
|
48
81
|
##
|
|
@@ -24,6 +24,8 @@ module Html2rss
|
|
|
24
24
|
optional(:language).maybe(:string, format?: LANGUAGE_FORMAT_REGEX)
|
|
25
25
|
optional(:ttl).maybe(:integer, gt?: 0)
|
|
26
26
|
optional(:time_zone).maybe(:string)
|
|
27
|
+
optional(:author).maybe(:string)
|
|
28
|
+
optional(:image).maybe(:string, format?: URI_REGEXP)
|
|
27
29
|
end
|
|
28
30
|
|
|
29
31
|
# Contract for a stylesheet entry in `stylesheets`.
|
|
@@ -132,15 +134,20 @@ module Html2rss
|
|
|
132
134
|
|
|
133
135
|
# URL validation delegated to Url class
|
|
134
136
|
rule(:channel) do
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
137
|
+
if (url_string = values.dig(:channel, :url)) && !url_string.empty?
|
|
138
|
+
begin
|
|
139
|
+
Html2rss::Url.for_channel(url_string)
|
|
140
|
+
rescue ArgumentError => error
|
|
141
|
+
key(%i[channel url]).failure(error.message)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
139
144
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
145
|
+
if (image_string = values.dig(:channel, :image)) && !image_string.empty?
|
|
146
|
+
begin
|
|
147
|
+
Html2rss::Url.from_absolute(image_string)
|
|
148
|
+
rescue ArgumentError => error
|
|
149
|
+
key(%i[channel image]).failure(error.message)
|
|
150
|
+
end
|
|
144
151
|
end
|
|
145
152
|
end
|
|
146
153
|
end
|
data/lib/html2rss/config.rb
CHANGED
|
@@ -120,11 +120,15 @@ module Html2rss
|
|
|
120
120
|
# @param url [String] source page URL
|
|
121
121
|
# @param items_selector [String, nil] optional selector hint for item extraction
|
|
122
122
|
# @param request_controls [Html2rss::Config::RequestControls, nil] explicit request controls to write
|
|
123
|
+
# @param limit [Integer, nil] max articles to keep in the auto-sourced feed
|
|
123
124
|
# @return [Hash{Symbol => Object}] feed config hash ready for {from_hash}
|
|
124
|
-
def auto_source_config(url:, items_selector: nil, request_controls: nil)
|
|
125
|
+
def auto_source_config(url:, items_selector: nil, request_controls: nil, limit: nil)
|
|
126
|
+
auto_source = AutoSource::DEFAULT_CONFIG
|
|
127
|
+
auto_source = auto_source.merge(limit:) unless limit.nil?
|
|
128
|
+
|
|
125
129
|
config = {
|
|
126
130
|
channel: default_config[:channel].merge(url:),
|
|
127
|
-
auto_source:
|
|
131
|
+
auto_source:
|
|
128
132
|
}
|
|
129
133
|
|
|
130
134
|
request_controls ||= RequestControls.new
|
|
@@ -79,7 +79,7 @@ module Html2rss
|
|
|
79
79
|
RSS::Maker.make('2.0') do |maker|
|
|
80
80
|
Stylesheet.add(maker, stylesheets)
|
|
81
81
|
|
|
82
|
-
make_channel(maker
|
|
82
|
+
make_channel(maker)
|
|
83
83
|
make_items(maker)
|
|
84
84
|
end
|
|
85
85
|
end
|
|
@@ -96,14 +96,26 @@ module Html2rss
|
|
|
96
96
|
@stylesheets.map { |style| Stylesheet.new(**style) }
|
|
97
97
|
end
|
|
98
98
|
|
|
99
|
+
# rubocop:disable Metrics/AbcSize
|
|
99
100
|
def make_channel(maker)
|
|
101
|
+
channel_maker = maker.channel
|
|
100
102
|
%i[language title description ttl].each do |key|
|
|
101
|
-
|
|
103
|
+
channel_maker.public_send(:"#{key}=", channel.public_send(key))
|
|
102
104
|
end
|
|
103
105
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
106
|
+
channel_maker.managingEditor = channel.author if channel.author
|
|
107
|
+
channel_maker.author = channel.author if channel.author
|
|
108
|
+
channel_maker.link = channel.url.to_s
|
|
109
|
+
channel_maker.generator = generator
|
|
110
|
+
channel_maker.updated = channel.last_build_date
|
|
111
|
+
|
|
112
|
+
make_image(maker.image) if channel.image
|
|
113
|
+
end
|
|
114
|
+
# rubocop:enable Metrics/AbcSize
|
|
115
|
+
|
|
116
|
+
def make_image(image_maker)
|
|
117
|
+
image_maker.url = channel.image.to_s
|
|
118
|
+
image_maker.title = channel.title.to_s
|
|
107
119
|
end
|
|
108
120
|
|
|
109
121
|
def make_items(maker)
|
|
@@ -81,6 +81,10 @@ module Html2rss
|
|
|
81
81
|
request_session = request_session_for(config, strategy:, resources:)
|
|
82
82
|
response = request_session.fetch_initial_response
|
|
83
83
|
articles, dedup_dropped = deduplicated_articles(config:, response:, request_session:)
|
|
84
|
+
if config.auto_source && articles.empty?
|
|
85
|
+
raise NoFeedItemsExtracted.new(attempts: [{ strategy:, items_count: 0, error_class: nil }])
|
|
86
|
+
end
|
|
87
|
+
|
|
84
88
|
PipelineOutcome.new(
|
|
85
89
|
response:, articles:, dedup_dropped:, selected_strategy: nil, attempt_count: 0, strategy_attempts: []
|
|
86
90
|
)
|
data/lib/html2rss/feed_result.rb
CHANGED
|
@@ -69,7 +69,7 @@ module Html2rss
|
|
|
69
69
|
|
|
70
70
|
##
|
|
71
71
|
# @return [Html2rss::Status] frozen scraper tallies, dedup count, and generator formatter.
|
|
72
|
-
# {#to_h} on +status+ is a stable consumer contract for observability payloads.
|
|
72
|
+
# {Status#to_h} on +status+ is a stable consumer contract for observability payloads.
|
|
73
73
|
attr_reader :status
|
|
74
74
|
|
|
75
75
|
private
|
|
@@ -7,20 +7,16 @@ module Html2rss
|
|
|
7
7
|
# CategoryExtractor is responsible for extracting categories from HTML elements
|
|
8
8
|
# by looking for CSS class names containing common category-related terms.
|
|
9
9
|
class CategoryExtractor
|
|
10
|
-
#
|
|
11
|
-
CATEGORY_TERMS =
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
].freeze
|
|
10
|
+
# Shared category vocabulary (owned by {ArticleRules::Category}).
|
|
11
|
+
CATEGORY_TERMS = ArticleRules::Category::CATEGORY_TERMS
|
|
12
|
+
# Shared category attribute/class pattern (owned by {ArticleRules::Category}).
|
|
13
|
+
CATEGORY_ATTR_PATTERN = ArticleRules::Category::CATEGORY_ATTR_PATTERN
|
|
15
14
|
|
|
16
15
|
# CSS selectors to find elements with category-related class names or data attributes
|
|
17
16
|
CATEGORY_SELECTORS = CATEGORY_TERMS.flat_map do |term|
|
|
18
17
|
["[class*=\"#{term}\"]", "[data-#{term}]", "[#{term}]"]
|
|
19
18
|
end.freeze
|
|
20
19
|
|
|
21
|
-
# Regex pattern for matching category-related attribute names
|
|
22
|
-
CATEGORY_ATTR_PATTERN = /#{CATEGORY_TERMS.join('|')}/i
|
|
23
|
-
|
|
24
20
|
##
|
|
25
21
|
# Extracts categories from the given article tag by looking for elements
|
|
26
22
|
# with class names containing common category-related terms.
|
|
@@ -30,7 +26,6 @@ module Html2rss
|
|
|
30
26
|
def self.call(article_tag)
|
|
31
27
|
return [] unless article_tag
|
|
32
28
|
|
|
33
|
-
# Single optimized traversal that extracts all category types
|
|
34
29
|
extract_all_categories(article_tag)
|
|
35
30
|
.map(&:strip)
|
|
36
31
|
.reject(&:empty?)
|
|
@@ -44,10 +39,7 @@ module Html2rss
|
|
|
44
39
|
def self.extract_all_categories(article_tag)
|
|
45
40
|
Set.new.tap do |categories|
|
|
46
41
|
article_tag.css(CATEGORY_SELECTORS.join(',')).each do |element|
|
|
47
|
-
|
|
48
|
-
extract_text_categories!(categories, element) if element['class']&.match?(CATEGORY_ATTR_PATTERN)
|
|
49
|
-
|
|
50
|
-
# Extract data categories from all elements
|
|
42
|
+
extract_text_categories!(categories, element) if ArticleRules::Category.class_match?(element['class'])
|
|
51
43
|
extract_element_data_categories!(categories, element)
|
|
52
44
|
end
|
|
53
45
|
end
|
|
@@ -61,10 +53,9 @@ module Html2rss
|
|
|
61
53
|
# @return [void]
|
|
62
54
|
def self.extract_element_data_categories!(categories, element)
|
|
63
55
|
element.attributes.each_value do |attr|
|
|
64
|
-
next unless attr.name
|
|
56
|
+
next unless ArticleRules::Category.attr_name_match?(attr.name)
|
|
65
57
|
|
|
66
|
-
|
|
67
|
-
categories.add(value) if value && !value.empty?
|
|
58
|
+
ArticleRules::Category.add_text!(categories, attr.value)
|
|
68
59
|
end
|
|
69
60
|
end
|
|
70
61
|
|
|
@@ -85,7 +76,7 @@ module Html2rss
|
|
|
85
76
|
if anchors.any?
|
|
86
77
|
anchors.each { |node| add_text_to_categories!(categories, node) }
|
|
87
78
|
else
|
|
88
|
-
|
|
79
|
+
ArticleRules::Category.add_split_text!(categories, element.text)
|
|
89
80
|
end
|
|
90
81
|
end
|
|
91
82
|
|
|
@@ -96,27 +87,10 @@ module Html2rss
|
|
|
96
87
|
# @param element [Nokogiri::XML::Element] The element to extract text from
|
|
97
88
|
# @return [void]
|
|
98
89
|
def self.add_text_to_categories!(categories, element)
|
|
99
|
-
|
|
100
|
-
categories.add(text) if text && !text.empty?
|
|
101
|
-
end
|
|
102
|
-
|
|
103
|
-
##
|
|
104
|
-
# Extracts categories from the element's text by splitting on newlines.
|
|
105
|
-
#
|
|
106
|
-
# @param categories [Set<String>] Accumulator set
|
|
107
|
-
# @param element [Nokogiri::XML::Element] The element to extract text from
|
|
108
|
-
# @return [void]
|
|
109
|
-
def self.extract_split_text_categories!(categories, element)
|
|
110
|
-
text = element.text
|
|
111
|
-
return unless text
|
|
112
|
-
|
|
113
|
-
text.split(/\n+/).each do |line|
|
|
114
|
-
line = line.strip
|
|
115
|
-
categories.add(line) unless line.empty?
|
|
116
|
-
end
|
|
90
|
+
ArticleRules::Category.add_text!(categories, Navigator.extract_visible_text(element))
|
|
117
91
|
end
|
|
118
92
|
|
|
119
|
-
private_class_method :add_text_to_categories
|
|
93
|
+
private_class_method :add_text_to_categories!
|
|
120
94
|
end
|
|
121
95
|
end
|
|
122
96
|
end
|
|
@@ -8,13 +8,8 @@ module Html2rss
|
|
|
8
8
|
# @param article_tag [Nokogiri::XML::Element] article container node
|
|
9
9
|
# @return [DateTime, nil]
|
|
10
10
|
def self.call(article_tag)
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
rescue ArgumentError, TypeError
|
|
14
|
-
nil
|
|
15
|
-
end
|
|
16
|
-
|
|
17
|
-
times.min
|
|
11
|
+
datetimes = article_tag.css('[datetime]').map { |tag| tag['datetime'] }
|
|
12
|
+
ArticleRules::Date.earliest(datetimes)
|
|
18
13
|
end
|
|
19
14
|
end
|
|
20
15
|
end
|
|
@@ -33,62 +33,17 @@ module Html2rss
|
|
|
33
33
|
def self.extract_from_element(element, base_url)
|
|
34
34
|
case element.name
|
|
35
35
|
when 'img'
|
|
36
|
-
|
|
36
|
+
ArticleRules::Enclosure.from_image(element['src'], base_url)
|
|
37
37
|
when 'video', 'audio', 'source'
|
|
38
|
-
|
|
38
|
+
ArticleRules::Enclosure.from_media(element['src'], element['type'], base_url)
|
|
39
39
|
when 'iframe'
|
|
40
|
-
|
|
40
|
+
ArticleRules::Enclosure.from_iframe(element['src'], base_url)
|
|
41
41
|
when 'a'
|
|
42
|
-
|
|
42
|
+
ArticleRules::Enclosure.from_anchor(element['href'], base_url)
|
|
43
43
|
end
|
|
44
44
|
end
|
|
45
45
|
|
|
46
|
-
|
|
47
|
-
src = img['src'].to_s
|
|
48
|
-
return if src.empty?
|
|
49
|
-
|
|
50
|
-
abs_url = Url.from_relative(src, base_url)
|
|
51
|
-
{
|
|
52
|
-
url: abs_url,
|
|
53
|
-
type: Article::Enclosure.guess_content_type_from_url(abs_url, default: 'image/jpeg')
|
|
54
|
-
}
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
def self.extract_media(element, base_url)
|
|
58
|
-
src = element['src'].to_s
|
|
59
|
-
return if src.empty?
|
|
60
|
-
|
|
61
|
-
{
|
|
62
|
-
url: Url.from_relative(src, base_url),
|
|
63
|
-
type: element['type']
|
|
64
|
-
}
|
|
65
|
-
end
|
|
66
|
-
|
|
67
|
-
def self.extract_iframe(iframe, base_url)
|
|
68
|
-
src = iframe['src'].to_s
|
|
69
|
-
return if src.empty?
|
|
70
|
-
|
|
71
|
-
abs_url = Url.from_relative(src, base_url)
|
|
72
|
-
{
|
|
73
|
-
url: abs_url,
|
|
74
|
-
type: Article::Enclosure.guess_content_type_from_url(abs_url, default: 'text/html')
|
|
75
|
-
}
|
|
76
|
-
end
|
|
77
|
-
|
|
78
|
-
def self.extract_a(link, base_url)
|
|
79
|
-
href = link['href'].to_s
|
|
80
|
-
return if href.empty?
|
|
81
|
-
|
|
82
|
-
abs_url = Url.from_relative(href, base_url)
|
|
83
|
-
|
|
84
|
-
if href.end_with?('.pdf')
|
|
85
|
-
{ url: abs_url, type: Article::Enclosure.guess_content_type_from_url(abs_url) }
|
|
86
|
-
else
|
|
87
|
-
{ url: abs_url, type: 'application/zip' }
|
|
88
|
-
end
|
|
89
|
-
end
|
|
90
|
-
|
|
91
|
-
private_class_method :extract_from_element, :extract_image, :extract_media, :extract_iframe, :extract_a
|
|
46
|
+
private_class_method :extract_from_element
|
|
92
47
|
end
|
|
93
48
|
end
|
|
94
49
|
end
|
|
@@ -32,27 +32,16 @@ module Html2rss
|
|
|
32
32
|
# @see <https://developer.mozilla.org/en-US/docs/Learn/HTML/Multimedia_and_embedding/Responsive_images>
|
|
33
33
|
# @see <https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img#srcset>
|
|
34
34
|
# @see <https://developer.mozilla.org/en-US/docs/Web/HTML/Element/picture>
|
|
35
|
-
def self.from_source(article_tag)
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
next if url.nil? || url.start_with?('data:')
|
|
39
|
-
|
|
40
|
-
width_value = width.to_i.zero? ? 0 : width.scan(/\d+/).first.to_i
|
|
41
|
-
|
|
42
|
-
[width_value, url.strip]
|
|
43
|
-
end
|
|
44
|
-
end.compact.to_h
|
|
45
|
-
|
|
46
|
-
hash[hash.keys.max]
|
|
35
|
+
def self.from_source(article_tag)
|
|
36
|
+
srcsets = article_tag.css('img[srcset], picture > source[srcset]').map { |source| source['srcset'] }
|
|
37
|
+
ArticleRules::Image.largest_from_srcsets(srcsets)
|
|
47
38
|
end
|
|
48
39
|
|
|
49
40
|
# @param article_tag [Nokogiri::XML::Element] article container node
|
|
50
41
|
# @return [String, nil] best style-based background image URL
|
|
51
42
|
def self.from_style(article_tag)
|
|
52
|
-
article_tag.css('[style*="url"]')
|
|
53
|
-
|
|
54
|
-
.reject { |src| src.start_with?('data:') }
|
|
55
|
-
.max_by(&:size)
|
|
43
|
+
styles = article_tag.css('[style*="url"]').map { |tag| tag['style'] }
|
|
44
|
+
ArticleRules::Image.best_from_styles(styles)
|
|
56
45
|
end
|
|
57
46
|
end
|
|
58
47
|
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
module Html
|
|
5
|
+
module ArticleRules
|
|
6
|
+
##
|
|
7
|
+
# Shared category term vocabulary and text accumulation (DOM walk stays in adapters).
|
|
8
|
+
module Category
|
|
9
|
+
# Class/data attribute tokens that mark category metadata.
|
|
10
|
+
CATEGORY_TERMS = %w[
|
|
11
|
+
category categories tag tags topic topics section sections
|
|
12
|
+
label labels theme themes subject subjects
|
|
13
|
+
].freeze
|
|
14
|
+
|
|
15
|
+
# Regex matching category-related attribute or class names.
|
|
16
|
+
CATEGORY_ATTR_PATTERN = /#{CATEGORY_TERMS.join('|')}/i
|
|
17
|
+
|
|
18
|
+
class << self
|
|
19
|
+
##
|
|
20
|
+
# @param name [String]
|
|
21
|
+
# @return [Boolean]
|
|
22
|
+
def attr_name_match?(name)
|
|
23
|
+
name.to_s.match?(CATEGORY_ATTR_PATTERN)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
##
|
|
27
|
+
# @param class_attr [String, nil]
|
|
28
|
+
# @return [Boolean]
|
|
29
|
+
def class_match?(class_attr)
|
|
30
|
+
class_attr.to_s.match?(CATEGORY_ATTR_PATTERN)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
##
|
|
34
|
+
# @param categories [Set<String>]
|
|
35
|
+
# @param text [String, nil]
|
|
36
|
+
# @return [void]
|
|
37
|
+
def add_text!(categories, text)
|
|
38
|
+
value = text.to_s.strip
|
|
39
|
+
categories.add(value) unless value.empty?
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
##
|
|
43
|
+
# @param categories [Set<String>]
|
|
44
|
+
# @param text [String, nil]
|
|
45
|
+
# @return [void]
|
|
46
|
+
def add_split_text!(categories, text)
|
|
47
|
+
return unless text
|
|
48
|
+
|
|
49
|
+
text.split(/\n+/).each { |line| add_text!(categories, line) }
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
module Html
|
|
5
|
+
module ArticleRules
|
|
6
|
+
##
|
|
7
|
+
# DOM-agnostic datetime aggregation for article published_at.
|
|
8
|
+
module Date
|
|
9
|
+
class << self
|
|
10
|
+
##
|
|
11
|
+
# @param datetime_strings [Array<String, nil>] raw datetime attribute values
|
|
12
|
+
# @return [DateTime, nil] earliest successfully parsed instant
|
|
13
|
+
def earliest(datetime_strings)
|
|
14
|
+
times = Array(datetime_strings).filter_map do |value|
|
|
15
|
+
DateTime.parse(value.to_s)
|
|
16
|
+
rescue ArgumentError, TypeError
|
|
17
|
+
nil
|
|
18
|
+
end
|
|
19
|
+
times.min
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
module Html
|
|
5
|
+
module ArticleRules
|
|
6
|
+
##
|
|
7
|
+
# DOM-agnostic enclosure URL/type normalization.
|
|
8
|
+
module Enclosure
|
|
9
|
+
# Href path suffixes treated as download/archive enclosure links.
|
|
10
|
+
ARCHIVE_HREF_SUFFIXES = %w[.pdf .zip .tar.gz .tgz].freeze
|
|
11
|
+
|
|
12
|
+
class << self
|
|
13
|
+
##
|
|
14
|
+
# @param href [String]
|
|
15
|
+
# @return [Boolean]
|
|
16
|
+
def archive_href?(href)
|
|
17
|
+
ARCHIVE_HREF_SUFFIXES.any? { |suffix| href.to_s.end_with?(suffix) }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
##
|
|
21
|
+
# @param src [String]
|
|
22
|
+
# @param base_url [String, Html2rss::Url]
|
|
23
|
+
# @return [Hash{Symbol => Object}, nil]
|
|
24
|
+
def from_image(src, base_url)
|
|
25
|
+
return if src.to_s.empty?
|
|
26
|
+
|
|
27
|
+
abs = Url.from_relative(src, base_url)
|
|
28
|
+
{ url: abs, type: Article::Enclosure.guess_content_type_from_url(abs, default: 'image/jpeg') }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
##
|
|
32
|
+
# @param src [String]
|
|
33
|
+
# @param type [String, nil]
|
|
34
|
+
# @param base_url [String, Html2rss::Url]
|
|
35
|
+
# @return [Hash{Symbol => Object}, nil]
|
|
36
|
+
def from_media(src, type, base_url)
|
|
37
|
+
return if src.to_s.empty?
|
|
38
|
+
|
|
39
|
+
{ url: Url.from_relative(src, base_url), type: }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
##
|
|
43
|
+
# @param src [String]
|
|
44
|
+
# @param base_url [String, Html2rss::Url]
|
|
45
|
+
# @return [Hash{Symbol => Object}, nil]
|
|
46
|
+
def from_iframe(src, base_url)
|
|
47
|
+
return if src.to_s.empty?
|
|
48
|
+
|
|
49
|
+
abs = Url.from_relative(src, base_url)
|
|
50
|
+
{ url: abs, type: Article::Enclosure.guess_content_type_from_url(abs, default: 'text/html') }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
##
|
|
54
|
+
# @param href [String]
|
|
55
|
+
# @param base_url [String, Html2rss::Url]
|
|
56
|
+
# @return [Hash{Symbol => Object}, nil]
|
|
57
|
+
def from_anchor(href, base_url)
|
|
58
|
+
return if href.to_s.empty?
|
|
59
|
+
|
|
60
|
+
abs = Url.from_relative(href, base_url)
|
|
61
|
+
type = if href.end_with?('.pdf')
|
|
62
|
+
Article::Enclosure.guess_content_type_from_url(abs)
|
|
63
|
+
else
|
|
64
|
+
'application/zip'
|
|
65
|
+
end
|
|
66
|
+
{ url: abs, type: }
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|