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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9ff7cdc4e25f3abc6da000e4f672d6832fb6027e49885aecc0cad38329c5e6ae
|
|
4
|
+
data.tar.gz: e42c216e328bb2c56971dd58871f023f15e672563e27741c56c6cf7fe4cb322a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e52812f947561b9a52537f1b28c530f63e116194642d13ff526fac1ad32f02d7ea6ff8ca9b5ee16e2d7f686e1babec1908ca51399ba42ef9461ed3dbe0d02117
|
|
7
|
+
data.tar.gz: 4754495a5947aca6de71846d1c88128d9fc1826e1014fd00806f58e7cd1e1575dc3bd2380ced3aeecd0cd6d51e0366ddee7a1f7db0220750e13f66645de2edde
|
data/html2rss.gemspec
CHANGED
|
@@ -14,7 +14,7 @@ Gem::Specification.new do |spec|
|
|
|
14
14
|
spec.description = 'Supports JSON content, custom HTTP headers, and post-processing of extracted content.'
|
|
15
15
|
spec.homepage = 'https://github.com/html2rss/html2rss'
|
|
16
16
|
spec.license = 'MIT'
|
|
17
|
-
spec.required_ruby_version = '>= 3.
|
|
17
|
+
spec.required_ruby_version = '>= 3.3'
|
|
18
18
|
|
|
19
19
|
if spec.respond_to?(:metadata)
|
|
20
20
|
spec.metadata['allowed_push_host'] = 'https://rubygems.org'
|
|
@@ -41,7 +41,6 @@ Gem::Specification.new do |spec|
|
|
|
41
41
|
spec.add_dependency 'kramdown'
|
|
42
42
|
spec.add_dependency 'mime-types', '> 3.0'
|
|
43
43
|
spec.add_dependency 'nokogiri', '>= 1.10', '< 2.0'
|
|
44
|
-
spec.add_dependency 'parallel'
|
|
45
44
|
spec.add_dependency 'puppeteer-ruby'
|
|
46
45
|
spec.add_dependency 'regexp_parser'
|
|
47
46
|
spec.add_dependency 'reverse_markdown', '~> 3.0'
|
|
@@ -0,0 +1,185 @@
|
|
|
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'].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
|
+
def container_of?(nodes_a, nodes_b)
|
|
87
|
+
return false unless LAYOUT_TAG_NAMES.include?(nodes_b.first.name)
|
|
88
|
+
|
|
89
|
+
nodes_a.any? do |node_a|
|
|
90
|
+
nodes_b.count { |node_b| node_a != node_b && node_b.ancestors.include?(node_a) } > 1
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# If group A contains group B, and they have the same size:
|
|
95
|
+
# - If B (the descendant) contains >= 80% of A's words, AND B's tag is div/section/article,
|
|
96
|
+
# B is the actual content card. Discard A.
|
|
97
|
+
# - Otherwise, B is a sub-element (header, metadata line, button). Discard B.
|
|
98
|
+
def filter_1_to_1_overlap(groups)
|
|
99
|
+
discarded = Set.new
|
|
100
|
+
groups.each_key do |cls_a|
|
|
101
|
+
groups.each_key do |cls_b|
|
|
102
|
+
next if cls_a == cls_b || discarded.include?(cls_a) || discarded.include?(cls_b)
|
|
103
|
+
|
|
104
|
+
resolve_1_to_1_overlap(cls_a, cls_b, groups, discarded)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
groups.except(*discarded)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def resolve_1_to_1_overlap(cls_a, cls_b, groups, discarded)
|
|
112
|
+
nodes_a = groups[cls_a]
|
|
113
|
+
nodes_b = groups[cls_b]
|
|
114
|
+
return if nodes_a.size != nodes_b.size
|
|
115
|
+
return unless nodes_a.zip(nodes_b).all? { |a, b| a != b && b.ancestors.include?(a) }
|
|
116
|
+
|
|
117
|
+
discarded << (keep_descendant?(nodes_a, nodes_b) ? cls_a : cls_b)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def keep_descendant?(nodes_a, nodes_b)
|
|
121
|
+
avg_words(nodes_b) >= 0.8 * avg_words(nodes_a) &&
|
|
122
|
+
LAYOUT_TAG_NAMES.include?(nodes_b.first.name)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def select_best_group(groups)
|
|
126
|
+
best_nodes = []
|
|
127
|
+
best_score = -1
|
|
128
|
+
|
|
129
|
+
groups.each_value do |nodes|
|
|
130
|
+
score = score_group(nodes)
|
|
131
|
+
next if score.negative?
|
|
132
|
+
|
|
133
|
+
(best_nodes = nodes) && (best_score = score) if score > best_score
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
best_nodes
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def score_group(nodes)
|
|
140
|
+
avg_w = avg_words(nodes)
|
|
141
|
+
return -1 if avg_w < 5
|
|
142
|
+
|
|
143
|
+
score = nodes.size + (avg_w / 5.0)
|
|
144
|
+
score += 20 if nodes_heading?(nodes)
|
|
145
|
+
score += 20 if nodes_time?(nodes)
|
|
146
|
+
score += 40 if nodes_date?(nodes)
|
|
147
|
+
score
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def nodes_heading?(nodes)
|
|
151
|
+
nodes.any? do |n|
|
|
152
|
+
n.at_css(HtmlExtractor::HEADING_TAGS.join(',')) ||
|
|
153
|
+
n.at_css('.font-bold, .font-semibold')
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def nodes_time?(nodes)
|
|
158
|
+
nodes.any? { |n| n.at_css('time, [datetime]') }
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def nodes_date?(nodes)
|
|
162
|
+
nodes.any? { |n| date?(n) }
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def avg_words(nodes)
|
|
166
|
+
nodes.sum { |n| text_words(n) } / nodes.size.to_f
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def text_words(node)
|
|
170
|
+
@text_words[node] ||= HtmlExtractor.extract_visible_text(node).to_s.scan(/\p{Alnum}+/).size
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def date?(node)
|
|
174
|
+
@has_date[node] ||= begin
|
|
175
|
+
text = HtmlExtractor.extract_visible_text(node).to_s
|
|
176
|
+
text.match?(%r{\b\d{4}[-/]\d{2}[-/]\d{2}\b}) ||
|
|
177
|
+
text.match?(/\b(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\b/i)
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
# rubocop:enable Metrics/ClassLength
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
@@ -62,7 +62,9 @@ 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)
|
|
67
|
+
@ignored_cache = {}.compare_by_identity
|
|
66
68
|
end
|
|
67
69
|
|
|
68
70
|
attr_reader :parsed_body
|
|
@@ -73,10 +75,13 @@ module Html2rss
|
|
|
73
75
|
def each
|
|
74
76
|
return enum_for(:each) unless block_given?
|
|
75
77
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
78
|
+
articles.each { yield _1 }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
##
|
|
82
|
+
# @return [Boolean] true when the scraper can likely extract articles
|
|
83
|
+
def extractable?
|
|
84
|
+
articles.any?
|
|
80
85
|
end
|
|
81
86
|
|
|
82
87
|
##
|
|
@@ -91,7 +96,7 @@ module Html2rss
|
|
|
91
96
|
# @return [Boolean] true when the node is a good extraction boundary
|
|
92
97
|
def article_tag_condition?(node)
|
|
93
98
|
# Ignore tags that are below ignored DOM chrome.
|
|
94
|
-
return false if HtmlExtractor.ignored_container_path?(node)
|
|
99
|
+
return false if HtmlExtractor.ignored_container_path?(node, @ignored_cache)
|
|
95
100
|
return true if %w[body html].include?(node.name)
|
|
96
101
|
return false unless (parent = node.parent)
|
|
97
102
|
|
|
@@ -100,14 +105,41 @@ module Html2rss
|
|
|
100
105
|
|
|
101
106
|
private
|
|
102
107
|
|
|
108
|
+
def articles
|
|
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
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
##
|
|
126
|
+
# @return [Integer]
|
|
103
127
|
def minimum_selector_frequency = @opts[:minimum_selector_frequency] || DEFAULT_MINIMUM_SELECTOR_FREQUENCY
|
|
128
|
+
|
|
129
|
+
##
|
|
130
|
+
# @return [Boolean]
|
|
104
131
|
def use_top_selectors = @opts[:use_top_selectors] || DEFAULT_USE_TOP_SELECTORS
|
|
105
132
|
|
|
133
|
+
##
|
|
134
|
+
# @param node [Nokogiri::XML::Node]
|
|
135
|
+
# @return [Integer]
|
|
106
136
|
def anchor_count(node)
|
|
107
|
-
@anchor_counts ||= {}
|
|
108
|
-
@anchor_counts[node.path] ||= node.name == 'a' ? 1 : node.css('a').size
|
|
137
|
+
(@anchor_counts ||= {}.compare_by_identity)[node] ||= node.name == 'a' ? 1 : node.css('a').size
|
|
109
138
|
end
|
|
110
139
|
|
|
140
|
+
##
|
|
141
|
+
# @param node [Nokogiri::XML::Node]
|
|
142
|
+
# @return [Boolean]
|
|
111
143
|
def relevant_anchor?(node)
|
|
112
144
|
destination_facts = @link_heuristics.destination_facts(node)
|
|
113
145
|
return false unless destination_facts
|
|
@@ -115,14 +147,24 @@ module Html2rss
|
|
|
115
147
|
!noise_anchor?(node, destination_facts)
|
|
116
148
|
end
|
|
117
149
|
|
|
150
|
+
##
|
|
151
|
+
# @yield [article_tag, selected_anchor]
|
|
152
|
+
# @yieldparam article_tag [Nokogiri::XML::Node]
|
|
153
|
+
# @yieldparam selected_anchor [Nokogiri::XML::Node]
|
|
154
|
+
# @return [Enumerator, nil]
|
|
118
155
|
def each_article_tag(&block)
|
|
119
156
|
return enum_for(:each_article_tag) unless block
|
|
120
157
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
158
|
+
anchor_filter = ->(node) { relevant_anchor?(node) }
|
|
159
|
+
boundary_condition = ->(node) { article_tag_condition?(node) }
|
|
160
|
+
|
|
161
|
+
list_candidates.each_article_tag(anchor_filter:, boundary_condition:, &block)
|
|
124
162
|
end
|
|
125
163
|
|
|
164
|
+
##
|
|
165
|
+
# @param article_tag [Nokogiri::XML::Node]
|
|
166
|
+
# @param selected_anchor [Nokogiri::XML::Node, nil]
|
|
167
|
+
# @return [Hash, nil]
|
|
126
168
|
def extract_article(article_tag, selected_anchor: nil)
|
|
127
169
|
selected_anchor ||= preferred_anchor_for(article_tag)
|
|
128
170
|
return unless selected_anchor
|
|
@@ -131,18 +173,28 @@ module Html2rss
|
|
|
131
173
|
@extractor.new(article_tag, base_url: @url, selected_anchor:).call
|
|
132
174
|
end
|
|
133
175
|
|
|
176
|
+
##
|
|
177
|
+
# @param anchor [Nokogiri::XML::Node]
|
|
178
|
+
# @param destination_facts [DestinationFacts]
|
|
179
|
+
# @return [Boolean]
|
|
134
180
|
def noise_anchor?(anchor, destination_facts) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
135
181
|
return true unless destination_facts
|
|
136
182
|
|
|
137
|
-
|
|
183
|
+
(@noise_anchors ||= {}.compare_by_identity)[anchor] ||= begin
|
|
184
|
+
text = HtmlExtractor.extract_visible_text(anchor).to_s.strip
|
|
138
185
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
186
|
+
destination_facts.taxonomy_path ||
|
|
187
|
+
short_utility_label?(text, destination_facts) ||
|
|
188
|
+
(@link_heuristics.recommended_text?(text) && destination_facts.shallow) ||
|
|
189
|
+
(@link_heuristics.utility_prefix_text?(text) && destination_facts.high_confidence_utility_destination) ||
|
|
190
|
+
(@link_heuristics.utility_text?(text) && destination_facts.vanity_path)
|
|
191
|
+
end
|
|
144
192
|
end
|
|
145
193
|
|
|
194
|
+
##
|
|
195
|
+
# @param text [String]
|
|
196
|
+
# @param destination_facts [DestinationFacts]
|
|
197
|
+
# @return [Boolean]
|
|
146
198
|
def short_utility_label?(text, destination_facts)
|
|
147
199
|
destination_facts.utility_path &&
|
|
148
200
|
!destination_facts.content_path &&
|
|
@@ -150,11 +202,16 @@ module Html2rss
|
|
|
150
202
|
text.scan(/\p{Alnum}+/).size <= 3
|
|
151
203
|
end
|
|
152
204
|
|
|
205
|
+
##
|
|
206
|
+
# @param article_tag [Nokogiri::XML::Node]
|
|
207
|
+
# @return [Nokogiri::XML::Node, nil]
|
|
153
208
|
def preferred_anchor_for(article_tag)
|
|
154
209
|
article_tag.css(HtmlExtractor::MAIN_ANCHOR_SELECTOR).find { relevant_anchor?(_1) } ||
|
|
155
210
|
HtmlExtractor.main_anchor_for(article_tag)
|
|
156
211
|
end
|
|
157
212
|
|
|
213
|
+
##
|
|
214
|
+
# @return [HtmlExtractor::ListCandidates]
|
|
158
215
|
def list_candidates
|
|
159
216
|
HtmlExtractor::ListCandidates.new(
|
|
160
217
|
parsed_body,
|
|
@@ -30,6 +30,9 @@ module Html2rss
|
|
|
30
30
|
/(?:window|self|globalThis)\.angular\s*=\s*/m
|
|
31
31
|
].freeze
|
|
32
32
|
|
|
33
|
+
# Combined regex for faster matching of global assignments.
|
|
34
|
+
GLOBAL_ASSIGNMENT_REGEXP = Regexp.union(GLOBAL_ASSIGNMENT_PATTERNS).freeze
|
|
35
|
+
|
|
33
36
|
# Preferred keys when extracting title-like values from state payloads.
|
|
34
37
|
TITLE_KEYS = %i[title headline name text].freeze
|
|
35
38
|
# Preferred keys when extracting URL-like values from state payloads.
|
|
@@ -53,7 +56,12 @@ module Html2rss
|
|
|
53
56
|
# @param parsed_body [Nokogiri::HTML::Document] parsed HTML document
|
|
54
57
|
# @return [Array<Hash, Array>] parsed JSON documents discovered in scripts
|
|
55
58
|
def json_documents(parsed_body)
|
|
56
|
-
|
|
59
|
+
# Use identity-based cache to avoid double-parsing of the same document.
|
|
60
|
+
# WeakMap allows the Nokogiri Document (key) to be garbage collected.
|
|
61
|
+
# rubocop:disable ThreadSafety/ClassInstanceVariable
|
|
62
|
+
(@cache ||= ObjectSpace::WeakMap.new)[parsed_body] ||=
|
|
63
|
+
script_documents(parsed_body) + assignment_documents(parsed_body)
|
|
64
|
+
# rubocop:enable ThreadSafety/ClassInstanceVariable
|
|
57
65
|
end
|
|
58
66
|
|
|
59
67
|
# @param parsed_body [Nokogiri::HTML::Document] parsed HTML document
|
|
@@ -80,15 +88,10 @@ module Html2rss
|
|
|
80
88
|
def assignment_payload(text)
|
|
81
89
|
trimmed = text.to_s.strip
|
|
82
90
|
return if trimmed.empty?
|
|
91
|
+
return unless trimmed.match?(GLOBAL_ASSIGNMENT_REGEXP)
|
|
83
92
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
payload = trimmed.sub(pattern, '')
|
|
88
|
-
return extract_assignment_payload(payload)
|
|
89
|
-
end
|
|
90
|
-
|
|
91
|
-
nil
|
|
93
|
+
payload = trimmed.sub(GLOBAL_ASSIGNMENT_REGEXP, '')
|
|
94
|
+
extract_assignment_payload(payload)
|
|
92
95
|
end
|
|
93
96
|
|
|
94
97
|
# @param text [String] text potentially containing JSON-like payloads
|
|
@@ -116,8 +119,10 @@ module Html2rss
|
|
|
116
119
|
in_string = false
|
|
117
120
|
escape = false
|
|
118
121
|
|
|
119
|
-
|
|
120
|
-
|
|
122
|
+
i = start_index
|
|
123
|
+
len = text.length
|
|
124
|
+
while i < len
|
|
125
|
+
char = text[i]
|
|
121
126
|
|
|
122
127
|
if in_string
|
|
123
128
|
if escape
|
|
@@ -127,24 +132,22 @@ module Html2rss
|
|
|
127
132
|
elsif char == '"'
|
|
128
133
|
in_string = false
|
|
129
134
|
end
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
stack << ']'
|
|
140
|
-
when '}', ']'
|
|
141
|
-
expected = stack.pop
|
|
142
|
-
return index if expected == char && stack.empty?
|
|
135
|
+
else
|
|
136
|
+
case char
|
|
137
|
+
when '"' then in_string = true
|
|
138
|
+
when '{' then stack << '}'
|
|
139
|
+
when '[' then stack << ']'
|
|
140
|
+
when '}', ']'
|
|
141
|
+
expected = stack.pop
|
|
142
|
+
return i if expected == char && stack.empty?
|
|
143
|
+
end
|
|
143
144
|
end
|
|
145
|
+
i += 1
|
|
144
146
|
end
|
|
145
147
|
|
|
146
148
|
nil
|
|
147
149
|
end
|
|
150
|
+
|
|
148
151
|
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
149
152
|
|
|
150
153
|
# @param payload [String, nil] JSON payload to parse
|
|
@@ -184,8 +187,9 @@ module Html2rss
|
|
|
184
187
|
# @param jsonish [String] JSON-like string with potentially unquoted keys
|
|
185
188
|
# @return [String] payload with unquoted object keys quoted
|
|
186
189
|
def quote_unquoted_keys(jsonish)
|
|
187
|
-
jsonish.gsub(/(
|
|
188
|
-
|
|
190
|
+
jsonish.gsub(/(?<prefix>\A\s*|[{,\[]\s*)(?<key>[A-Za-z_]\w*)(?<suffix>\s*:)/) do
|
|
191
|
+
captures = Regexp.last_match.named_captures(symbolize_names: true)
|
|
192
|
+
"#{captures[:prefix]}\"#{captures[:key]}\"#{captures[:suffix]}"
|
|
189
193
|
end
|
|
190
194
|
end
|
|
191
195
|
|
|
@@ -301,7 +305,7 @@ module Html2rss
|
|
|
301
305
|
# rubocop:disable Metrics/MethodLength
|
|
302
306
|
# @param entry [Hash] raw article entry candidate
|
|
303
307
|
# @param base_url [String, Html2rss::Url] base URL for relative link resolution
|
|
304
|
-
# @return [Hash{Symbol => Object
|
|
308
|
+
# @return [Hash{Symbol => Object, nil}] normalized article hash for downstream extraction
|
|
305
309
|
def normalise(entry, base_url:)
|
|
306
310
|
return unless entry.is_a?(Hash)
|
|
307
311
|
|
|
@@ -415,12 +419,17 @@ module Html2rss
|
|
|
415
419
|
|
|
416
420
|
attr_reader :parsed_body
|
|
417
421
|
|
|
422
|
+
# @return [Boolean] true when the page contains article-like arrays in JSON state
|
|
423
|
+
def extractable?
|
|
424
|
+
json_documents.any? { CandidateDetector.candidate_array?(_1) }
|
|
425
|
+
end
|
|
426
|
+
|
|
418
427
|
# @yield [Hash{Symbol => Object}] normalized article hash
|
|
419
428
|
# @return [Enumerator, void] article enumerator when no block is given
|
|
420
429
|
def each
|
|
421
430
|
return enum_for(:each) unless block_given?
|
|
422
431
|
|
|
423
|
-
|
|
432
|
+
json_documents.each do |document|
|
|
424
433
|
discover_articles(document) do |article|
|
|
425
434
|
yield article if article
|
|
426
435
|
end
|
|
@@ -431,6 +440,10 @@ module Html2rss
|
|
|
431
440
|
|
|
432
441
|
attr_reader :url
|
|
433
442
|
|
|
443
|
+
def json_documents
|
|
444
|
+
self.class.json_documents(parsed_body)
|
|
445
|
+
end
|
|
446
|
+
|
|
434
447
|
def discover_articles(document, &block)
|
|
435
448
|
case document
|
|
436
449
|
when Array then handle_array(document, &block)
|