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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 750b7fb967b328cef2238b66729cafe122d1bae23bee05fd8504bb31e760b8a7
|
|
4
|
+
data.tar.gz: 327406de9c7c97ea13e90c89bec1c2653c962bbcaccfd29ddb78b282477f7578
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5f41e00edfdd19ceb012900db7518f28236b417662dc4f11f45d9c498ede0720bdbbc0fb31443d80495eaf6076df646062fdf7f972b27bd71c50cc4e198b4540
|
|
7
|
+
data.tar.gz: 7a7eff85bd7f98cd872131041aa58faf9d3fba1aff47893a6d286378b6f52904ee11120f053e3ca8beca3060d86d1438cf2037e3e4e7e1586d3d3c4679b026f0
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
class AutoSource
|
|
5
|
+
module Scraper
|
|
6
|
+
class Html
|
|
7
|
+
##
|
|
8
|
+
# ClassClustering clusters DOM elements on anchorless pages by class lists and scores
|
|
9
|
+
# candidate groups to find the best list of content cards/articles.
|
|
10
|
+
# rubocop:disable Metrics/ClassLength
|
|
11
|
+
class ClassClustering
|
|
12
|
+
# Node tags considered layout containers
|
|
13
|
+
LAYOUT_TAG_NAMES = Set['div', 'section', 'article', 'li', 'ul', 'ol'].freeze
|
|
14
|
+
# HTML/layout tags excluded from candidate nodes
|
|
15
|
+
EXCLUDED_TAGS = Set['html', 'body', 'nav', 'footer', 'header', 'svg', 'script', 'style'].freeze
|
|
16
|
+
|
|
17
|
+
class << self
|
|
18
|
+
##
|
|
19
|
+
# Clusters elements in parsed_body and returns the best set of content card nodes.
|
|
20
|
+
#
|
|
21
|
+
# @param parsed_body [Nokogiri::HTML::Document] parsed HTML document
|
|
22
|
+
# @param minimum_selector_frequency [Integer] minimum frequency for class groups
|
|
23
|
+
# @return [Array<Nokogiri::XML::Node>] candidate nodes of the top-scoring class group
|
|
24
|
+
def call(parsed_body, minimum_selector_frequency:)
|
|
25
|
+
new(parsed_body, minimum_selector_frequency:).call
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @param parsed_body [Nokogiri::HTML::Document]
|
|
30
|
+
# @param minimum_selector_frequency [Integer]
|
|
31
|
+
def initialize(parsed_body, minimum_selector_frequency:)
|
|
32
|
+
@parsed_body = parsed_body
|
|
33
|
+
@minimum_frequency = minimum_selector_frequency
|
|
34
|
+
@text_words = {}.compare_by_identity
|
|
35
|
+
@has_date = {}.compare_by_identity
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @return [Array<Nokogiri::XML::Node>]
|
|
39
|
+
def call
|
|
40
|
+
candidate_groups = collect_candidate_groups
|
|
41
|
+
return [] if candidate_groups.empty?
|
|
42
|
+
|
|
43
|
+
non_containers = filter_containers(candidate_groups)
|
|
44
|
+
final_groups = filter_1_to_1_overlap(non_containers)
|
|
45
|
+
|
|
46
|
+
select_best_group(final_groups)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def collect_candidate_groups
|
|
52
|
+
class_groups = Hash.new { |h, k| h[k] = [] }
|
|
53
|
+
cache = {}.compare_by_identity
|
|
54
|
+
|
|
55
|
+
@parsed_body.css('[class]').each { |node| add_node_to_groups(node, class_groups, cache) }
|
|
56
|
+
|
|
57
|
+
class_groups.select { |_, nodes| nodes.size >= @minimum_frequency }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def add_node_to_groups(node, class_groups, cache)
|
|
61
|
+
return if EXCLUDED_TAGS.include?(node.name) || HtmlExtractor.ignored_container_path?(node, cache)
|
|
62
|
+
|
|
63
|
+
cls = normalize_class(node['class'])
|
|
64
|
+
class_groups[cls] << node unless cls.empty?
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def normalize_class(class_attr)
|
|
68
|
+
class_str = class_attr.to_s.strip
|
|
69
|
+
return '' if class_str.empty?
|
|
70
|
+
|
|
71
|
+
# Bypass split/sort/join allocation for single-class lists
|
|
72
|
+
if class_str.include?(' ')
|
|
73
|
+
class_str.split(/\s+/).sort.join(' ')
|
|
74
|
+
else
|
|
75
|
+
class_str
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Discard group A if any node of A contains > 1 node of another group B
|
|
80
|
+
def filter_containers(groups)
|
|
81
|
+
groups.reject do |cls_a, nodes_a|
|
|
82
|
+
groups.any? { |cls_b, nodes_b| cls_a != cls_b && container_of?(nodes_a, nodes_b) }
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# rubocop:disable Metrics/MethodLength
|
|
87
|
+
def container_of?(nodes_a, nodes_b)
|
|
88
|
+
return false unless LAYOUT_TAG_NAMES.include?(nodes_b.first.name)
|
|
89
|
+
|
|
90
|
+
nodes_a.any? do |node_a|
|
|
91
|
+
count = 0
|
|
92
|
+
nodes_b.each do |node_b|
|
|
93
|
+
next if node_a == node_b
|
|
94
|
+
|
|
95
|
+
if HtmlNavigator.descendant_of?(node_b, node_a)
|
|
96
|
+
count += 1
|
|
97
|
+
break if count > 1
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
count > 1
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
# rubocop:enable Metrics/MethodLength
|
|
104
|
+
|
|
105
|
+
# If group A contains group B, and they have the same size:
|
|
106
|
+
# - If B (the descendant) contains >= 80% of A's words, AND B's tag is div/section/article,
|
|
107
|
+
# B is the actual content card. Discard A.
|
|
108
|
+
# - Otherwise, B is a sub-element (header, metadata line, button). Discard B.
|
|
109
|
+
def filter_1_to_1_overlap(groups)
|
|
110
|
+
discarded = Set.new
|
|
111
|
+
groups.each_key do |cls_a|
|
|
112
|
+
groups.each_key do |cls_b|
|
|
113
|
+
next if cls_a == cls_b || discarded.include?(cls_a) || discarded.include?(cls_b)
|
|
114
|
+
|
|
115
|
+
resolve_1_to_1_overlap(cls_a, cls_b, groups, discarded)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
groups.except(*discarded)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def resolve_1_to_1_overlap(cls_a, cls_b, groups, discarded)
|
|
123
|
+
nodes_a = groups[cls_a]
|
|
124
|
+
nodes_b = groups[cls_b]
|
|
125
|
+
return if nodes_a.size != nodes_b.size
|
|
126
|
+
return unless nodes_a.zip(nodes_b).all? { |a, b| a != b && HtmlNavigator.descendant_of?(b, a) }
|
|
127
|
+
|
|
128
|
+
discarded << (keep_descendant?(nodes_a, nodes_b) ? cls_a : cls_b)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def keep_descendant?(nodes_a, nodes_b)
|
|
132
|
+
avg_words(nodes_b) >= 0.8 * avg_words(nodes_a) &&
|
|
133
|
+
LAYOUT_TAG_NAMES.include?(nodes_b.first.name)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def select_best_group(groups)
|
|
137
|
+
best_nodes = []
|
|
138
|
+
best_score = -1
|
|
139
|
+
|
|
140
|
+
groups.each_value do |nodes|
|
|
141
|
+
score = score_group(nodes)
|
|
142
|
+
next if score.negative?
|
|
143
|
+
|
|
144
|
+
(best_nodes = nodes) && (best_score = score) if score > best_score
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
best_nodes
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def score_group(nodes)
|
|
151
|
+
avg_w = avg_words(nodes)
|
|
152
|
+
return -1 if avg_w < 5
|
|
153
|
+
|
|
154
|
+
score = nodes.size + (avg_w / 5.0)
|
|
155
|
+
score += 20 if nodes_heading?(nodes)
|
|
156
|
+
score += 20 if nodes_time?(nodes)
|
|
157
|
+
score += 40 if nodes_date?(nodes)
|
|
158
|
+
score
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def nodes_heading?(nodes)
|
|
162
|
+
nodes.any? do |n|
|
|
163
|
+
n.at_css(HtmlExtractor::HEADING_TAGS.join(',')) ||
|
|
164
|
+
n.at_css('.font-bold, .font-semibold')
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def nodes_time?(nodes)
|
|
169
|
+
nodes.any? { |n| n.at_css('time, [datetime]') }
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def nodes_date?(nodes)
|
|
173
|
+
nodes.any? { |n| date?(n) }
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def avg_words(nodes)
|
|
177
|
+
nodes.sum { |n| text_words(n) } / nodes.size.to_f
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def text_words(node)
|
|
181
|
+
@text_words[node] ||= HtmlExtractor.extract_visible_text(node).to_s.scan(/\p{Alnum}+/).size
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def date?(node)
|
|
185
|
+
@has_date[node] ||= begin
|
|
186
|
+
text = HtmlExtractor.extract_visible_text(node).to_s
|
|
187
|
+
text.match?(%r{\b\d{4}[-/]\d{2}[-/]\d{2}\b}) ||
|
|
188
|
+
text.match?(/\b(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\b/i)
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
# rubocop:enable Metrics/ClassLength
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
@@ -62,6 +62,7 @@ module Html2rss
|
|
|
62
62
|
@url = url
|
|
63
63
|
@extractor = extractor
|
|
64
64
|
@opts = opts
|
|
65
|
+
@fallback_anchorless = opts.fetch(:fallback_anchorless, false)
|
|
65
66
|
@link_heuristics = LinkHeuristics.new(url)
|
|
66
67
|
@ignored_cache = {}.compare_by_identity
|
|
67
68
|
end
|
|
@@ -105,8 +106,19 @@ module Html2rss
|
|
|
105
106
|
private
|
|
106
107
|
|
|
107
108
|
def articles
|
|
108
|
-
@articles ||=
|
|
109
|
-
|
|
109
|
+
@articles ||= begin
|
|
110
|
+
extracted = each_article_tag.filter_map do |article_tag, selected_anchor|
|
|
111
|
+
extract_article(article_tag, selected_anchor:)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
extracted += find_anchorless_articles if @fallback_anchorless
|
|
115
|
+
extracted
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def find_anchorless_articles
|
|
120
|
+
ClassClustering.call(parsed_body, minimum_selector_frequency:).map do |node|
|
|
121
|
+
@extractor.new(node, base_url: @url, selected_anchor: nil, fallback_anchorless: true).call
|
|
110
122
|
end
|
|
111
123
|
end
|
|
112
124
|
|
|
@@ -305,7 +305,7 @@ module Html2rss
|
|
|
305
305
|
# rubocop:disable Metrics/MethodLength
|
|
306
306
|
# @param entry [Hash] raw article entry candidate
|
|
307
307
|
# @param base_url [String, Html2rss::Url] base URL for relative link resolution
|
|
308
|
-
# @return [Hash{Symbol => Object
|
|
308
|
+
# @return [Hash{Symbol => Object, nil}] normalized article hash for downstream extraction
|
|
309
309
|
def normalise(entry, base_url:)
|
|
310
310
|
return unless entry.is_a?(Hash)
|
|
311
311
|
|
|
@@ -55,7 +55,13 @@ module Html2rss
|
|
|
55
55
|
def top_level_item?(node)
|
|
56
56
|
return false if node.attribute('itemprop')
|
|
57
57
|
|
|
58
|
-
|
|
58
|
+
curr = node.parent
|
|
59
|
+
while curr && !curr.document? && curr.name != 'html'
|
|
60
|
+
return false if curr.attribute('itemscope') && curr.attribute('itemprop')
|
|
61
|
+
|
|
62
|
+
curr = curr.parent
|
|
63
|
+
end
|
|
64
|
+
true
|
|
59
65
|
end
|
|
60
66
|
end
|
|
61
67
|
|
|
@@ -92,7 +98,7 @@ module Html2rss
|
|
|
92
98
|
attr_reader :parsed_body, :url
|
|
93
99
|
|
|
94
100
|
# @param root [Nokogiri::XML::Element] supported Microdata root node
|
|
95
|
-
# @return [Hash{Symbol => Object
|
|
101
|
+
# @return [Hash{Symbol => Object, nil}] normalized article hash
|
|
96
102
|
def article_from(root)
|
|
97
103
|
schema_object = SchemaObjectBuilder.call(root)
|
|
98
104
|
return unless schema_object
|
|
@@ -147,7 +153,13 @@ module Html2rss
|
|
|
147
153
|
def direct_property?(root, node)
|
|
148
154
|
return false if node == root
|
|
149
155
|
|
|
150
|
-
|
|
156
|
+
curr = node.parent
|
|
157
|
+
while curr && curr != root
|
|
158
|
+
return false if curr.attribute('itemscope')
|
|
159
|
+
|
|
160
|
+
curr = curr.parent
|
|
161
|
+
end
|
|
162
|
+
true
|
|
151
163
|
end
|
|
152
164
|
|
|
153
165
|
# @param node [Nokogiri::XML::Element] itemprop node
|
|
@@ -378,7 +390,7 @@ module Html2rss
|
|
|
378
390
|
extend ValueNormalizer
|
|
379
391
|
|
|
380
392
|
# @param root [Nokogiri::XML::Element] supported microdata root node
|
|
381
|
-
# @return [Hash{Symbol => Object
|
|
393
|
+
# @return [Hash{Symbol => Object, nil}] compact schema-like object
|
|
382
394
|
def call(root)
|
|
383
395
|
type = Microdata.supported_type_name(root)
|
|
384
396
|
return unless type
|
|
@@ -37,7 +37,7 @@ module Html2rss
|
|
|
37
37
|
|
|
38
38
|
@article_cache.fetch(entry) do
|
|
39
39
|
@article_cache[entry] = @extractor.new(
|
|
40
|
-
entry.container, base_url: @url, selected_anchor: entry.selected_anchor
|
|
40
|
+
entry.container, base_url: @url, selected_anchor: entry.selected_anchor, fallback_anchorless: true
|
|
41
41
|
).call
|
|
42
42
|
end
|
|
43
43
|
end
|
|
@@ -60,12 +60,13 @@ module Html2rss
|
|
|
60
60
|
# @param parsed_body [Nokogiri::HTML::Document] parsed HTML document
|
|
61
61
|
# @param url [String, Html2rss::Url] base url
|
|
62
62
|
# @param extractor [Class] extractor class used for article extraction
|
|
63
|
-
# @param
|
|
64
|
-
# @option
|
|
65
|
-
def initialize(parsed_body, url:, extractor: HtmlExtractor, **
|
|
63
|
+
# @param opts [Hash] scraper-specific options
|
|
64
|
+
# @option opts [Boolean] :fallback_anchorless whether to extract anchorless blocks
|
|
65
|
+
def initialize(parsed_body, url:, extractor: HtmlExtractor, **opts)
|
|
66
66
|
@parsed_body = parsed_body
|
|
67
67
|
@url = url
|
|
68
68
|
@extractor = extractor
|
|
69
|
+
@fallback_anchorless = opts.fetch(:fallback_anchorless, false)
|
|
69
70
|
@link_heuristics = LinkHeuristics.new(url)
|
|
70
71
|
@anchor_selector = AnchorSelector.new(url)
|
|
71
72
|
end
|
|
@@ -107,14 +108,15 @@ module Html2rss
|
|
|
107
108
|
@anchor_selector.primary_anchor_for(container)
|
|
108
109
|
end
|
|
109
110
|
|
|
110
|
-
|
|
111
|
+
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
112
|
+
def extractable_entries
|
|
111
113
|
@extractable_entries ||= candidate_containers.filter_map do |container|
|
|
112
114
|
selected_anchor = primary_anchor_for(container)
|
|
113
115
|
|
|
114
|
-
next unless selected_anchor
|
|
116
|
+
next unless selected_anchor || @fallback_anchorless
|
|
115
117
|
|
|
116
|
-
destination_facts = normalized_destination(selected_anchor)
|
|
117
|
-
next
|
|
118
|
+
destination_facts = selected_anchor ? normalized_destination(selected_anchor) : nil
|
|
119
|
+
next if selected_anchor && !destination_facts
|
|
118
120
|
next if hard_junk_entry?(container, selected_anchor, destination_facts)
|
|
119
121
|
|
|
120
122
|
quality = quality_score(container, selected_anchor, destination_facts)
|
|
@@ -132,6 +134,7 @@ module Html2rss
|
|
|
132
134
|
)
|
|
133
135
|
end
|
|
134
136
|
end
|
|
137
|
+
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
135
138
|
|
|
136
139
|
# rubocop:disable Metrics/MethodLength
|
|
137
140
|
def ranked_entries
|
|
@@ -177,8 +180,8 @@ module Html2rss
|
|
|
177
180
|
|
|
178
181
|
score += 40 if words >= 3
|
|
179
182
|
score += 15 if words >= 7
|
|
180
|
-
score += 20 if destination_facts
|
|
181
|
-
score += 15 if destination_facts
|
|
183
|
+
score += 20 if destination_facts&.url&.path.to_s.length > 6
|
|
184
|
+
score += 15 if destination_facts&.content_path
|
|
182
185
|
score += 15 if publish_marker?(container)
|
|
183
186
|
score += 10 if descriptive_context?(container_text, title)
|
|
184
187
|
score += 10 if article_container?(container)
|
|
@@ -190,12 +193,12 @@ module Html2rss
|
|
|
190
193
|
title = entry_title(container, selected_anchor)
|
|
191
194
|
utility_text = @link_heuristics.utility_prefix_text?(title)
|
|
192
195
|
recommended_text = @link_heuristics.recommended_text?(title)
|
|
193
|
-
content_signal = destination_facts
|
|
196
|
+
content_signal = destination_facts&.content_path
|
|
194
197
|
no_content_signal = !content_signal
|
|
195
198
|
non_content_utility_path =
|
|
196
|
-
destination_facts
|
|
199
|
+
destination_facts&.utility_path &&
|
|
197
200
|
no_content_signal &&
|
|
198
|
-
!destination_facts
|
|
201
|
+
!destination_facts&.strong_post_suffix
|
|
199
202
|
publish_signal = publish_marker?(container)
|
|
200
203
|
descriptive_signal = descriptive_context?(visible_text(container), title)
|
|
201
204
|
weak_container = !publish_signal && !descriptive_signal
|
|
@@ -203,19 +206,20 @@ module Html2rss
|
|
|
203
206
|
|
|
204
207
|
score += 25 if non_content_utility_path
|
|
205
208
|
score += 15 if utility_text && word_count(title) <= 6
|
|
206
|
-
score += 10 if destination_facts
|
|
209
|
+
score += 10 if destination_facts&.shallow
|
|
207
210
|
score += 10 if weak_container
|
|
208
211
|
score += 10 if recommended_text && no_content_signal
|
|
209
|
-
score += 5 if destination_facts
|
|
212
|
+
score += 5 if destination_facts&.high_confidence_junk_path
|
|
210
213
|
score += 15 if junk_tokens?(container_tokens(container))
|
|
211
214
|
score
|
|
212
215
|
end
|
|
213
216
|
|
|
214
|
-
|
|
217
|
+
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
218
|
+
def hard_junk_entry?(container, selected_anchor, destination_facts)
|
|
215
219
|
title = entry_title(container, selected_anchor)
|
|
216
220
|
publish_signal = publish_marker?(container)
|
|
217
221
|
descriptive_signal = descriptive_context?(visible_text(container), title)
|
|
218
|
-
content_signal = destination_facts
|
|
222
|
+
content_signal = destination_facts&.content_path
|
|
219
223
|
weak_article_candidate = article_signal_count(
|
|
220
224
|
container,
|
|
221
225
|
publish_signal:,
|
|
@@ -223,12 +227,16 @@ module Html2rss
|
|
|
223
227
|
content_signal:
|
|
224
228
|
) < 2
|
|
225
229
|
|
|
226
|
-
destination_facts
|
|
227
|
-
(
|
|
228
|
-
|
|
229
|
-
destination_facts
|
|
230
|
+
destination_facts&.high_confidence_junk_path ||
|
|
231
|
+
(selected_anchor &&
|
|
232
|
+
@link_heuristics.recommended_text?(title) &&
|
|
233
|
+
destination_facts&.shallow &&
|
|
234
|
+
weak_article_candidate) ||
|
|
235
|
+
(selected_anchor && @link_heuristics.utility_prefix_text?(title) &&
|
|
236
|
+
destination_facts&.high_confidence_utility_destination &&
|
|
230
237
|
weak_article_candidate)
|
|
231
238
|
end
|
|
239
|
+
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
232
240
|
|
|
233
241
|
##
|
|
234
242
|
# @param container [Nokogiri::XML::Node]
|
data/lib/html2rss/auto_source.rb
CHANGED
|
@@ -32,12 +32,14 @@ module Html2rss
|
|
|
32
32
|
enabled: true
|
|
33
33
|
},
|
|
34
34
|
semantic_html: {
|
|
35
|
-
enabled: true
|
|
35
|
+
enabled: true,
|
|
36
|
+
fallback_anchorless: true
|
|
36
37
|
},
|
|
37
38
|
html: {
|
|
38
39
|
enabled: true,
|
|
39
40
|
minimum_selector_frequency: Scraper::Html::DEFAULT_MINIMUM_SELECTOR_FREQUENCY,
|
|
40
|
-
use_top_selectors: Scraper::Html::DEFAULT_USE_TOP_SELECTORS
|
|
41
|
+
use_top_selectors: Scraper::Html::DEFAULT_USE_TOP_SELECTORS,
|
|
42
|
+
fallback_anchorless: true
|
|
41
43
|
}
|
|
42
44
|
},
|
|
43
45
|
cleanup: Cleanup::DEFAULT_CONFIG
|
|
@@ -58,11 +60,13 @@ module Html2rss
|
|
|
58
60
|
end
|
|
59
61
|
optional(:semantic_html).hash do
|
|
60
62
|
optional(:enabled).filled(:bool)
|
|
63
|
+
optional(:fallback_anchorless).filled(:bool)
|
|
61
64
|
end
|
|
62
65
|
optional(:html).hash do
|
|
63
66
|
optional(:enabled).filled(:bool)
|
|
64
67
|
optional(:minimum_selector_frequency).filled(:integer, gt?: 0)
|
|
65
68
|
optional(:use_top_selectors).filled(:integer, gt?: 0)
|
|
69
|
+
optional(:fallback_anchorless).filled(:bool)
|
|
66
70
|
end
|
|
67
71
|
end.freeze
|
|
68
72
|
private_constant :SCRAPER_CONFIG
|
data/lib/html2rss/cli.rb
CHANGED
|
@@ -48,6 +48,9 @@ module Html2rss
|
|
|
48
48
|
method_option :max_requests,
|
|
49
49
|
type: :numeric,
|
|
50
50
|
desc: 'Maximum requests to allow for this feed build'
|
|
51
|
+
method_option :input,
|
|
52
|
+
type: :string,
|
|
53
|
+
desc: 'Local HTML file path to read input from'
|
|
51
54
|
# @param yaml_file [String] path to YAML config
|
|
52
55
|
# @param feed_name [String, nil] optional named feed in multi-feed config
|
|
53
56
|
# @return [void]
|
|
@@ -55,6 +58,7 @@ module Html2rss
|
|
|
55
58
|
config = Html2rss.config_from_yaml_file(yaml_file, feed_name)
|
|
56
59
|
config[:params] = options[:params] || {}
|
|
57
60
|
apply_runtime_request_overrides!(config)
|
|
61
|
+
apply_local_file_input!(config, options[:input]) if options[:input]
|
|
58
62
|
|
|
59
63
|
puts(execute_feed { Html2rss.feed(config) })
|
|
60
64
|
end
|
|
@@ -76,20 +80,17 @@ module Html2rss
|
|
|
76
80
|
method_option :max_requests,
|
|
77
81
|
type: :numeric,
|
|
78
82
|
desc: 'Maximum requests to allow for this feed build'
|
|
79
|
-
|
|
83
|
+
method_option :input,
|
|
84
|
+
type: :string,
|
|
85
|
+
desc: 'Local HTML file path to read input from'
|
|
86
|
+
# @param url [String, nil] source page URL for auto discovery
|
|
80
87
|
# @return [void]
|
|
81
|
-
def auto(url
|
|
88
|
+
def auto(url = nil)
|
|
82
89
|
format = options.fetch(:format, 'rss')
|
|
83
|
-
|
|
90
|
+
strategy, local_file_path, url = prepare_auto_inputs(url, options[:input])
|
|
84
91
|
|
|
85
92
|
result = execute_feed do
|
|
86
|
-
|
|
87
|
-
url,
|
|
88
|
-
strategy: current_strategy,
|
|
89
|
-
items_selector: options[:items_selector],
|
|
90
|
-
max_redirects: options[:max_redirects],
|
|
91
|
-
max_requests: options[:max_requests]
|
|
92
|
-
)
|
|
93
|
+
source_call(url, strategy, local_file_path, format == 'jsonfeed')
|
|
93
94
|
end
|
|
94
95
|
|
|
95
96
|
puts(format == 'jsonfeed' ? JSON.pretty_generate(result) : result)
|
|
@@ -159,6 +160,33 @@ module Html2rss
|
|
|
159
160
|
config.delete(:request) if request_config.empty?
|
|
160
161
|
end
|
|
161
162
|
|
|
163
|
+
def apply_local_file_input!(config, input_path)
|
|
164
|
+
file_path = check_file_exists!(input_path)
|
|
165
|
+
config[:strategy] = :local_file
|
|
166
|
+
config[:request] = (config[:request] || {}).merge(local_file_path: file_path)
|
|
167
|
+
|
|
168
|
+
return unless config.dig(:channel, :url).to_s.empty?
|
|
169
|
+
|
|
170
|
+
config[:channel] = (config[:channel] || {}).merge(
|
|
171
|
+
url: detect_base_url!(file_path, 'Please specify a channel.url in the config.')
|
|
172
|
+
)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def prepare_auto_inputs(url, input_option)
|
|
176
|
+
if input_option.nil?
|
|
177
|
+
raise Thor::Error, 'A URL is required unless --input is specified' unless url
|
|
178
|
+
|
|
179
|
+
return [current_strategy, nil, url]
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
file_path = check_file_exists!(input_option)
|
|
183
|
+
detected_url = url || detect_base_url!(
|
|
184
|
+
file_path, 'Please specify a URL: html2rss auto [URL] --input <file>'
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
[:local_file, file_path, detected_url]
|
|
188
|
+
end
|
|
189
|
+
|
|
162
190
|
def request_controls
|
|
163
191
|
Html2rss::RequestControls.new(
|
|
164
192
|
strategy: options[:strategy]&.to_sym,
|
|
@@ -213,5 +241,28 @@ module Html2rss
|
|
|
213
241
|
Html2rss::NoFeedItemsExtracted => error
|
|
214
242
|
raise Thor::Error, error.message
|
|
215
243
|
end
|
|
244
|
+
|
|
245
|
+
def source_call(url, strategy, local_file_path, is_json)
|
|
246
|
+
method = is_json ? Html2rss.method(:auto_json_feed) : Html2rss.method(:auto_source)
|
|
247
|
+
method.call(
|
|
248
|
+
url,
|
|
249
|
+
strategy:,
|
|
250
|
+
items_selector: options[:items_selector],
|
|
251
|
+
max_redirects: options[:max_redirects],
|
|
252
|
+
max_requests: options[:max_requests],
|
|
253
|
+
local_file_path:
|
|
254
|
+
)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def check_file_exists!(path)
|
|
258
|
+
File.expand_path(path).tap do |file_path|
|
|
259
|
+
raise Thor::Error, "Input file does not exist: #{path}" unless File.exist?(file_path)
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def detect_base_url!(file_path, error_hint)
|
|
264
|
+
Html2rss::Url.extract_from_html(File.read(file_path))&.to_s ||
|
|
265
|
+
raise(Thor::Error, "Could not auto-detect a base URL from HTML metadata. #{error_hint}")
|
|
266
|
+
end
|
|
216
267
|
end
|
|
217
268
|
end
|
|
@@ -29,7 +29,7 @@ module Html2rss
|
|
|
29
29
|
# Validates a configuration hash with the runtime validator.
|
|
30
30
|
#
|
|
31
31
|
# @param config [Hash{Symbol => Object}] the configuration hash
|
|
32
|
-
# @param params [Hash{Symbol => Object
|
|
32
|
+
# @param params [Hash{Symbol => Object, Hash{String => Object, nil}}] dynamic parameters for string formatting
|
|
33
33
|
# @return [Dry::Validation::Result] validation result after defaults and deprecations are applied
|
|
34
34
|
def validate(config, params: UNSET)
|
|
35
35
|
prepared_config = prepare_for_validation(resolve_effective_config(config, params:))
|
|
@@ -56,7 +56,7 @@ module Html2rss
|
|
|
56
56
|
# @param file [String] the YAML file to load
|
|
57
57
|
# @param feed_name [String, nil] optional feed name for multi-feed files
|
|
58
58
|
# @param multiple_feeds_key [Symbol] key under which multiple feeds are defined
|
|
59
|
-
# @param params [Hash{Symbol => Object
|
|
59
|
+
# @param params [Hash{Symbol => Object, Hash{String => Object, nil}}] dynamic parameters for string formatting
|
|
60
60
|
# @return [Dry::Validation::Result] validation result after defaults and deprecations are applied
|
|
61
61
|
def validate_yaml(file, feed_name = nil, multiple_feeds_key: MultipleFeedsConfig::CONFIG_KEY_FEEDS, params: UNSET)
|
|
62
62
|
validate(load_yaml(file, feed_name, multiple_feeds_key:), params:)
|
|
@@ -99,7 +99,7 @@ module Html2rss
|
|
|
99
99
|
# and returns a new configuration object.
|
|
100
100
|
#
|
|
101
101
|
# @param config [Hash{Symbol => Object}] the configuration hash.
|
|
102
|
-
# @param params [Hash{Symbol => Object
|
|
102
|
+
# @param params [Hash{Symbol => Object, Hash{String => Object, nil}}] dynamic parameters for string formatting.
|
|
103
103
|
# @return [Html2rss::Config] the configuration object.
|
|
104
104
|
def from_hash(config, params: UNSET)
|
|
105
105
|
new(resolve_effective_config(config, params:))
|
data/lib/html2rss/config.rb
CHANGED
|
@@ -69,9 +69,9 @@ module Html2rss
|
|
|
69
69
|
# @return [Hash{Symbol => Object}] request envelope configuration
|
|
70
70
|
def request = config[:request]
|
|
71
71
|
|
|
72
|
-
# @return [Hash{Symbol => Object
|
|
72
|
+
# @return [Hash{Symbol => Object, nil}] selectors configuration
|
|
73
73
|
def selectors = config[:selectors]
|
|
74
|
-
# @return [Hash{Symbol => Object
|
|
74
|
+
# @return [Hash{Symbol => Object, nil}] auto-source configuration
|
|
75
75
|
def auto_source = config[:auto_source]
|
|
76
76
|
|
|
77
77
|
private
|
|
@@ -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
|