html2rss 0.23.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/README.md +2 -2
- data/lib/html2rss/article.rb +42 -35
- 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/schema/category_extractor.rb +29 -114
- data/lib/html2rss/auto_source/scraper/schema/item_list.rb +50 -11
- data/lib/html2rss/auto_source/scraper/schema/thing.rb +14 -5
- data/lib/html2rss/auto_source/scraper/schema.rb +61 -18
- 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/channel.rb +159 -79
- 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/item_presentation.rb +42 -0
- data/lib/html2rss/feed_builder/json_feed/item.rb +20 -4
- data/lib/html2rss/feed_builder/json_feed.rb +22 -2
- data/lib/html2rss/feed_builder/rss.rb +36 -24
- data/lib/html2rss/feed_builder.rb +9 -17
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +71 -28
- data/lib/html2rss/feed_pipeline.rb +66 -55
- data/lib/html2rss/feed_result.rb +92 -0
- 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/botasaurus_contract.rb +33 -5
- data/lib/html2rss/request_service/botasaurus_strategy.rb +13 -4
- data/lib/html2rss/request_service/budget.rb +7 -2
- data/lib/html2rss/request_service/faraday_strategy.rb +42 -1
- data/lib/html2rss/request_service/network_guard.rb +5 -3
- data/lib/html2rss/request_service/response.rb +9 -1
- data/lib/html2rss/request_service/strategy.rb +22 -0
- data/lib/html2rss/request_session.rb +4 -0
- 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 +26 -24
- 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/status.rb +132 -0
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss.rb +41 -29
- data/schema/html2rss-config.schema.json +20 -0
- metadata +45 -19
- 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/auto_source/scraper/schema/list_item.rb +0 -28
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
class AutoSource
|
|
5
|
+
class Segmenter
|
|
6
|
+
##
|
|
7
|
+
# Class / structure clustering for anchorless pages
|
|
8
|
+
# (port of Discovery::DomClustering + OverlapResolver; group scoring owned by Scoring::ClusterScorer).
|
|
9
|
+
module Cluster
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
##
|
|
13
|
+
# @param segmenter [Segmenter]
|
|
14
|
+
# @return [Array<Segment>]
|
|
15
|
+
def call(segmenter)
|
|
16
|
+
nodes = best_group_nodes(segmenter)
|
|
17
|
+
nodes.each_with_index.map do |node, position|
|
|
18
|
+
Segment.build(root_node: node, primary_link: nil, strategy: :cluster, position:)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def best_group_nodes(segmenter)
|
|
23
|
+
class_groups = collect_class_groups(segmenter)
|
|
24
|
+
groups = class_groups.empty? ? collect_structure_groups(segmenter) : class_groups
|
|
25
|
+
return [] if groups.empty?
|
|
26
|
+
|
|
27
|
+
resolver = OverlapResolver.new(index: segmenter.index)
|
|
28
|
+
scorer = Scoring::ClusterScorer.new
|
|
29
|
+
non_containers = resolver.filter_containers(groups)
|
|
30
|
+
final_groups = resolver.filter_1_to_1_overlap(non_containers)
|
|
31
|
+
scorer.select_best_group(final_groups)
|
|
32
|
+
end
|
|
33
|
+
module_function :best_group_nodes
|
|
34
|
+
private_class_method :best_group_nodes
|
|
35
|
+
|
|
36
|
+
def collect_class_groups(segmenter) # rubocop:disable Metrics/AbcSize
|
|
37
|
+
groups = Hash.new { |h, k| h[k] = [] }
|
|
38
|
+
segmenter.index.each_node do |node|
|
|
39
|
+
next if SST::Tags::CLUSTER_EXCLUDED_NAMES.include?(node.name)
|
|
40
|
+
next if segmenter.index.ignored_chrome?(node)
|
|
41
|
+
|
|
42
|
+
cls = normalize_class(node.attrs.class_names)
|
|
43
|
+
groups[cls] << node unless cls.empty?
|
|
44
|
+
end
|
|
45
|
+
groups.select { |_, nodes| nodes.size >= segmenter.minimum_selector_frequency }
|
|
46
|
+
end
|
|
47
|
+
module_function :collect_class_groups
|
|
48
|
+
private_class_method :collect_class_groups
|
|
49
|
+
|
|
50
|
+
def collect_structure_groups(segmenter) # rubocop:disable Metrics/AbcSize
|
|
51
|
+
groups = Hash.new { |h, k| h[k] = [] }
|
|
52
|
+
segmenter.index.each_node do |node|
|
|
53
|
+
next if SST::Tags::CLUSTER_EXCLUDED_NAMES.include?(node.name)
|
|
54
|
+
next if segmenter.index.ignored_chrome?(node)
|
|
55
|
+
|
|
56
|
+
sig = structure_signature(node)
|
|
57
|
+
groups[sig] << node unless sig.empty?
|
|
58
|
+
end
|
|
59
|
+
groups.select { |_, nodes| nodes.size >= segmenter.minimum_selector_frequency }
|
|
60
|
+
end
|
|
61
|
+
module_function :collect_structure_groups
|
|
62
|
+
private_class_method :collect_structure_groups
|
|
63
|
+
|
|
64
|
+
def normalize_class(class_names)
|
|
65
|
+
return '' if class_names.empty?
|
|
66
|
+
|
|
67
|
+
class_names.sort.join(' ')
|
|
68
|
+
end
|
|
69
|
+
module_function :normalize_class
|
|
70
|
+
private_class_method :normalize_class
|
|
71
|
+
|
|
72
|
+
def structure_signature(node)
|
|
73
|
+
kids = node.children
|
|
74
|
+
return '' if kids.empty?
|
|
75
|
+
|
|
76
|
+
kids.map { |c| c.name.to_s }.join('>')
|
|
77
|
+
end
|
|
78
|
+
module_function :structure_signature
|
|
79
|
+
private_class_method :structure_signature
|
|
80
|
+
|
|
81
|
+
##
|
|
82
|
+
# Filters layout wrapper groups and resolves 1-to-1 nested card wrappers.
|
|
83
|
+
class OverlapResolver
|
|
84
|
+
# Cap pairwise work on dense class grids.
|
|
85
|
+
MAX_GROUPS = 40
|
|
86
|
+
|
|
87
|
+
# @param index [SST::Index]
|
|
88
|
+
def initialize(index:)
|
|
89
|
+
@index = index
|
|
90
|
+
@layout_tags = SST::Tags::LAYOUT_NAMES
|
|
91
|
+
@scorer = Scoring::ClusterScorer.new
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# @param groups [Hash{String => Array<SST::Node>}]
|
|
95
|
+
# @return [Hash{String => Array<SST::Node>}]
|
|
96
|
+
def filter_containers(groups)
|
|
97
|
+
capped = cap_groups(groups)
|
|
98
|
+
capped.reject do |cls_a, nodes_a|
|
|
99
|
+
capped.any? { |cls_b, nodes_b| cls_a != cls_b && container_of?(nodes_a, nodes_b) }
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# @param groups [Hash{String => Array<SST::Node>}]
|
|
104
|
+
# @return [Hash{String => Array<SST::Node>}]
|
|
105
|
+
def filter_1_to_1_overlap(groups)
|
|
106
|
+
discarded = {}
|
|
107
|
+
groups.each_key do |cls_a|
|
|
108
|
+
groups.each_key do |cls_b|
|
|
109
|
+
next if cls_a == cls_b || discarded[cls_a] || discarded[cls_b]
|
|
110
|
+
|
|
111
|
+
resolve_1_to_1_overlap(cls_a, cls_b, groups, discarded)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
groups.reject { |cls, _| discarded[cls] }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
private
|
|
118
|
+
|
|
119
|
+
def cap_groups(groups)
|
|
120
|
+
return groups if groups.size <= MAX_GROUPS
|
|
121
|
+
|
|
122
|
+
groups.sort_by { |_key, nodes| -nodes.size }.first(MAX_GROUPS).to_h
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# rubocop:disable Metrics/MethodLength
|
|
126
|
+
def container_of?(nodes_a, nodes_b)
|
|
127
|
+
return false unless @layout_tags.include?(nodes_b.first.name)
|
|
128
|
+
|
|
129
|
+
nodes_a.any? do |node_a|
|
|
130
|
+
count = 0
|
|
131
|
+
nodes_b.each do |node_b|
|
|
132
|
+
next if node_a.equal?(node_b)
|
|
133
|
+
|
|
134
|
+
if @index.descendant_of?(node_b, node_a)
|
|
135
|
+
count += 1
|
|
136
|
+
break if count > 1
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
count > 1
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
# rubocop:enable Metrics/MethodLength
|
|
143
|
+
|
|
144
|
+
def resolve_1_to_1_overlap(cls_a, cls_b, groups, discarded)
|
|
145
|
+
nodes_a = groups[cls_a]
|
|
146
|
+
nodes_b = groups[cls_b]
|
|
147
|
+
return if nodes_a.size != nodes_b.size
|
|
148
|
+
|
|
149
|
+
nested = nodes_a.zip(nodes_b).all? { |a, b| !a.equal?(b) && @index.descendant_of?(b, a) }
|
|
150
|
+
return unless nested
|
|
151
|
+
|
|
152
|
+
discarded[keep_descendant?(nodes_a, nodes_b) ? cls_a : cls_b] = true
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def keep_descendant?(nodes_a, nodes_b)
|
|
156
|
+
@scorer.avg_words(nodes_b) >= 0.8 * @scorer.avg_words(nodes_a) &&
|
|
157
|
+
@layout_tags.include?(nodes_b.first.name)
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
class AutoSource
|
|
5
|
+
class Segmenter
|
|
6
|
+
##
|
|
7
|
+
# Builds repeated-list article segments from tag_path frequency
|
|
8
|
+
# (port of Discovery::ListCandidates using tag_path instead of xpath).
|
|
9
|
+
module List
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
##
|
|
13
|
+
# @param segmenter [Segmenter]
|
|
14
|
+
# @return [Array<Segment>]
|
|
15
|
+
def call(segmenter)
|
|
16
|
+
primary = PrimaryLink.new(segmenter)
|
|
17
|
+
pairs = article_pairs(segmenter)
|
|
18
|
+
seen = {}.compare_by_identity
|
|
19
|
+
|
|
20
|
+
pairs.each_with_index.filter_map do |(article_tag, selected_anchor), position|
|
|
21
|
+
next unless seen[article_tag].nil?
|
|
22
|
+
|
|
23
|
+
seen[article_tag] = true
|
|
24
|
+
|
|
25
|
+
link = selected_anchor || primary.select(article_tag)
|
|
26
|
+
next unless link || segmenter.permit_unanchored
|
|
27
|
+
|
|
28
|
+
Segment.build(root_node: article_tag, primary_link: link, strategy: :list, position:)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def article_pairs(segmenter)
|
|
33
|
+
by_path = relevant_links_by_path(segmenter)
|
|
34
|
+
top_paths(segmenter, by_path).flat_map do |path|
|
|
35
|
+
by_path.fetch(path, []).filter_map do |node|
|
|
36
|
+
article_tag = parent_until_boundary(segmenter, node)
|
|
37
|
+
next unless article_tag
|
|
38
|
+
|
|
39
|
+
[article_tag, node]
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
module_function :article_pairs
|
|
44
|
+
private_class_method :article_pairs
|
|
45
|
+
|
|
46
|
+
def relevant_links_by_path(segmenter)
|
|
47
|
+
by_path = Hash.new { |hash, path| hash[path] = [] }
|
|
48
|
+
segmenter.index.each_node do |node|
|
|
49
|
+
next unless node.link?
|
|
50
|
+
next if segmenter.index.ignored_chrome?(node)
|
|
51
|
+
next unless relevant_anchor?(segmenter, node)
|
|
52
|
+
|
|
53
|
+
by_path[node.tag_path] << node
|
|
54
|
+
end
|
|
55
|
+
by_path
|
|
56
|
+
end
|
|
57
|
+
module_function :relevant_links_by_path
|
|
58
|
+
private_class_method :relevant_links_by_path
|
|
59
|
+
|
|
60
|
+
def top_paths(segmenter, by_path)
|
|
61
|
+
by_path.select { |_path, nodes| nodes.size >= segmenter.minimum_selector_frequency }
|
|
62
|
+
.max_by(segmenter.use_top_selectors) { |_path, nodes| nodes.size }
|
|
63
|
+
.map(&:first)
|
|
64
|
+
end
|
|
65
|
+
module_function :top_paths
|
|
66
|
+
private_class_method :top_paths
|
|
67
|
+
|
|
68
|
+
def relevant_anchor?(segmenter, node)
|
|
69
|
+
facts = segmenter.link_resolver.destination_facts(node)
|
|
70
|
+
return false unless facts
|
|
71
|
+
|
|
72
|
+
text = node.visible_text.to_s.strip
|
|
73
|
+
!segmenter.noise_policy.noise_anchor?(text:, destination_facts: facts, anchor: node)
|
|
74
|
+
end
|
|
75
|
+
module_function :relevant_anchor?
|
|
76
|
+
private_class_method :relevant_anchor?
|
|
77
|
+
|
|
78
|
+
def parent_until_boundary(segmenter, node)
|
|
79
|
+
index = segmenter.index
|
|
80
|
+
link_counts = Hash.new { |hash, curr| hash[curr] = count_links(curr) }
|
|
81
|
+
|
|
82
|
+
index.parent_until(node, lambda { |curr|
|
|
83
|
+
return true if %i[body html].include?(curr.name)
|
|
84
|
+
return false if index.ignored_chrome?(curr)
|
|
85
|
+
|
|
86
|
+
parent = index.parent_of(curr)
|
|
87
|
+
parent && link_counts[parent] > link_counts[curr]
|
|
88
|
+
})
|
|
89
|
+
end
|
|
90
|
+
module_function :parent_until_boundary
|
|
91
|
+
private_class_method :parent_until_boundary
|
|
92
|
+
|
|
93
|
+
def count_links(node)
|
|
94
|
+
node.link? ? 1 : node.descendants.count(&:link?)
|
|
95
|
+
end
|
|
96
|
+
module_function :count_links
|
|
97
|
+
private_class_method :count_links
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
class AutoSource
|
|
5
|
+
class Segmenter
|
|
6
|
+
##
|
|
7
|
+
# Selects the strongest content-like primary link inside a container
|
|
8
|
+
# (port of Discovery::SemanticAnchorCandidates).
|
|
9
|
+
class PrimaryLink
|
|
10
|
+
# @param segmenter [Segmenter]
|
|
11
|
+
def initialize(segmenter)
|
|
12
|
+
@segmenter = segmenter
|
|
13
|
+
@index = segmenter.index
|
|
14
|
+
@link_resolver = segmenter.link_resolver
|
|
15
|
+
@noise_policy = segmenter.noise_policy
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
##
|
|
19
|
+
# @param container [SST::Node]
|
|
20
|
+
# @return [SST::Node, nil]
|
|
21
|
+
def select(container) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
22
|
+
winners = {}
|
|
23
|
+
|
|
24
|
+
container.find_all(&:link?).each do |anchor|
|
|
25
|
+
next if @index.ignored_chrome?(anchor)
|
|
26
|
+
|
|
27
|
+
facts = candidate_facts(anchor, container)
|
|
28
|
+
next unless facts
|
|
29
|
+
|
|
30
|
+
dest = facts[:destination]
|
|
31
|
+
current = winners[dest]
|
|
32
|
+
winners[dest] = facts if current.nil? || facts[:score] > current[:score]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
winners.values.max_by { |f| f[:score] }&.fetch(:anchor)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
41
|
+
def candidate_facts(anchor, container)
|
|
42
|
+
destination = @link_resolver.destination_facts(anchor)
|
|
43
|
+
return unless destination
|
|
44
|
+
|
|
45
|
+
text = anchor.visible_text.to_s.strip
|
|
46
|
+
heading = first_heading(container)
|
|
47
|
+
heading_text = heading ? heading.visible_text.to_s.strip : ''
|
|
48
|
+
heading_anchor = heading_anchor?(anchor, heading)
|
|
49
|
+
meaningful = text.match?(/\p{Alnum}/)
|
|
50
|
+
content_like = destination.content_path
|
|
51
|
+
heading_match = meaningful && heading_text.match?(/\p{Alnum}/) && heading_text == text
|
|
52
|
+
|
|
53
|
+
return if @noise_policy.noise_anchor?(
|
|
54
|
+
text:, destination_facts: destination, anchor:, container:, heading_anchor:
|
|
55
|
+
)
|
|
56
|
+
return unless meaningful || content_like || heading_anchor
|
|
57
|
+
|
|
58
|
+
score = Scoring::AnchorScore.score(
|
|
59
|
+
heading_anchor:,
|
|
60
|
+
heading_text_match: heading_match,
|
|
61
|
+
meaningful_text: meaningful,
|
|
62
|
+
content_like_destination: content_like
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
{ anchor:, destination: destination.destination, score: }
|
|
66
|
+
end
|
|
67
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
68
|
+
|
|
69
|
+
def first_heading(container)
|
|
70
|
+
(@headings ||= {}.compare_by_identity)[container] ||= container.find(&:heading?)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def heading_anchor?(anchor, heading)
|
|
74
|
+
return false unless heading
|
|
75
|
+
|
|
76
|
+
anchor.equal?(heading) || @index.descendant_of?(anchor, heading)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
class AutoSource
|
|
5
|
+
class Segmenter
|
|
6
|
+
##
|
|
7
|
+
# Collects leaf semantic containers (port of Discovery::SemanticContainers).
|
|
8
|
+
module Semantic
|
|
9
|
+
# Tag names collected as leaf semantic containers.
|
|
10
|
+
CANDIDATE_NAMES = %i[article section li tr div].to_set.freeze
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
##
|
|
15
|
+
# @param segmenter [Segmenter]
|
|
16
|
+
# @return [Array<Segment>]
|
|
17
|
+
def call(segmenter)
|
|
18
|
+
containers = collect_containers(segmenter.index)
|
|
19
|
+
primary = PrimaryLink.new(segmenter)
|
|
20
|
+
|
|
21
|
+
containers.each_with_index.filter_map do |container, position|
|
|
22
|
+
link = primary.select(container)
|
|
23
|
+
next unless link || segmenter.permit_unanchored
|
|
24
|
+
|
|
25
|
+
Segment.build(root_node: container, primary_link: link, strategy: :semantic, position:)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def collect_containers(index)
|
|
30
|
+
non_leaf = mark_non_leaf_candidates(index.root)
|
|
31
|
+
candidates = []
|
|
32
|
+
index.each_node do |node|
|
|
33
|
+
next unless CANDIDATE_NAMES.include?(node.name)
|
|
34
|
+
next if non_leaf.include?(node)
|
|
35
|
+
next if index.ignored_chrome?(node)
|
|
36
|
+
|
|
37
|
+
candidates << node
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
candidates = filter_nested(candidates, index)
|
|
41
|
+
sort_by_depth(candidates, index)
|
|
42
|
+
end
|
|
43
|
+
module_function :collect_containers
|
|
44
|
+
private_class_method :collect_containers
|
|
45
|
+
|
|
46
|
+
TAG_MASKS = { article: 1, section: 2, li: 4, tr: 8, div: 16 }.freeze
|
|
47
|
+
private_constant :TAG_MASKS
|
|
48
|
+
|
|
49
|
+
# Single O(N) post-order pass: mark candidate nodes that contain a same-name candidate.
|
|
50
|
+
def mark_non_leaf_candidates(root) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
51
|
+
non_leaf = Set.new.compare_by_identity
|
|
52
|
+
visit = lambda do |node|
|
|
53
|
+
seen_mask = 0
|
|
54
|
+
node.children.each do |child|
|
|
55
|
+
child_mask = visit.call(child)
|
|
56
|
+
seen_mask |= child_mask | (TAG_MASKS[child.name] || 0)
|
|
57
|
+
end
|
|
58
|
+
node_mask = TAG_MASKS[node.name]
|
|
59
|
+
non_leaf.add(node) if node_mask && seen_mask.anybits?(node_mask)
|
|
60
|
+
seen_mask
|
|
61
|
+
end
|
|
62
|
+
visit.call(root)
|
|
63
|
+
non_leaf
|
|
64
|
+
end
|
|
65
|
+
module_function :mark_non_leaf_candidates
|
|
66
|
+
private_class_method :mark_non_leaf_candidates
|
|
67
|
+
|
|
68
|
+
def filter_nested(candidates, index)
|
|
69
|
+
candidate_set = {}.compare_by_identity
|
|
70
|
+
candidates.each { |c| candidate_set[c] = true }
|
|
71
|
+
rejected = {}.compare_by_identity
|
|
72
|
+
|
|
73
|
+
candidates.each do |candidate|
|
|
74
|
+
next if candidate.name == :div
|
|
75
|
+
|
|
76
|
+
reject_ancestors(candidate, candidate_set, rejected, index)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
candidates.reject { |c| rejected[c] }
|
|
80
|
+
end
|
|
81
|
+
module_function :filter_nested
|
|
82
|
+
private_class_method :filter_nested
|
|
83
|
+
|
|
84
|
+
def reject_ancestors(node, candidate_set, rejected, index)
|
|
85
|
+
curr = index.parent_of(node)
|
|
86
|
+
while curr && curr.name != :html
|
|
87
|
+
rejected[curr] = true if candidate_set[curr]
|
|
88
|
+
curr = index.parent_of(curr)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
module_function :reject_ancestors
|
|
92
|
+
private_class_method :reject_ancestors
|
|
93
|
+
|
|
94
|
+
def sort_by_depth(candidates, index)
|
|
95
|
+
candidates.each_with_index
|
|
96
|
+
.sort_by { |node, i| [-index.depth_of(node), i] }
|
|
97
|
+
.map(&:first)
|
|
98
|
+
end
|
|
99
|
+
module_function :sort_by_depth
|
|
100
|
+
private_class_method :sort_by_depth
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
class AutoSource
|
|
5
|
+
##
|
|
6
|
+
# Discovers candidate content segments from an SST::Document.
|
|
7
|
+
#
|
|
8
|
+
# Strategies:
|
|
9
|
+
# - +:semantic+ — leaf article/section/li/tr/div containers with a primary link
|
|
10
|
+
# - +:list+ — repeated tag_path anchors walked to a shared container boundary
|
|
11
|
+
# - +:cluster+ — class / structure clustering for anchorless card grids
|
|
12
|
+
class Segmenter
|
|
13
|
+
# @param document [SST::Document]
|
|
14
|
+
# @param base_url [String, Html2rss::Url]
|
|
15
|
+
# @param strategy [Symbol]
|
|
16
|
+
# @param opts [Hash] segmentation options
|
|
17
|
+
# @option opts [Boolean] :permit_unanchored keep containers without a primary link
|
|
18
|
+
# @option opts [Integer] :minimum_selector_frequency list strategy frequency floor
|
|
19
|
+
# @option opts [Integer] :use_top_selectors list strategy selector budget
|
|
20
|
+
# @option opts [Scoring::LinkResolver, nil] :link_resolver page-scoped destination facts cache
|
|
21
|
+
# @return [Array<Segment>]
|
|
22
|
+
# rubocop:disable Style/ArgumentsForwarding -- keep named opts for YARD @option
|
|
23
|
+
def self.call(document, base_url:, strategy:, **opts)
|
|
24
|
+
new(document, base_url:, strategy:, **opts).call
|
|
25
|
+
end
|
|
26
|
+
# rubocop:enable Style/ArgumentsForwarding
|
|
27
|
+
|
|
28
|
+
# @param document [SST::Document]
|
|
29
|
+
# @param base_url [String, Html2rss::Url]
|
|
30
|
+
# @param strategy [Symbol]
|
|
31
|
+
# @param opts [Hash] segmentation options
|
|
32
|
+
# @option opts [Boolean] :permit_unanchored keep containers without a primary link
|
|
33
|
+
# @option opts [Integer] :minimum_selector_frequency list strategy frequency floor
|
|
34
|
+
# @option opts [Integer] :use_top_selectors list strategy selector budget
|
|
35
|
+
# @option opts [Scoring::LinkResolver, nil] :link_resolver page-scoped destination facts cache
|
|
36
|
+
def initialize(document, base_url:, strategy:, **opts)
|
|
37
|
+
raise ArgumentError, 'document must be SST::Document' unless document.is_a?(SST::Document)
|
|
38
|
+
raise ArgumentError, "unknown strategy: #{strategy.inspect}" unless Segment::STRATEGIES.include?(strategy)
|
|
39
|
+
|
|
40
|
+
@document = document
|
|
41
|
+
@base_url = base_url
|
|
42
|
+
@strategy = strategy
|
|
43
|
+
@permit_unanchored = opts.fetch(:permit_unanchored, false)
|
|
44
|
+
@minimum_selector_frequency = opts.fetch(:minimum_selector_frequency, 2)
|
|
45
|
+
@use_top_selectors = opts.fetch(:use_top_selectors, 5)
|
|
46
|
+
@link_resolver = opts[:link_resolver] || Scoring::LinkResolver.new(base_url)
|
|
47
|
+
@noise_policy = LinkDestination::NoisePolicy.new(link_resolver: @link_resolver, index: document.index)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @return [Array<Segment>]
|
|
51
|
+
def call
|
|
52
|
+
case @strategy
|
|
53
|
+
when :semantic then Semantic.call(self)
|
|
54
|
+
when :list then List.call(self)
|
|
55
|
+
when :cluster then Cluster.call(self)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
attr_reader :document, :base_url, :permit_unanchored, :minimum_selector_frequency,
|
|
60
|
+
:use_top_selectors, :link_resolver, :noise_policy
|
|
61
|
+
|
|
62
|
+
# @return [SST::Index]
|
|
63
|
+
def index = document.index
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
data/lib/html2rss/auto_source.rb
CHANGED
|
@@ -15,17 +15,22 @@ module Html2rss
|
|
|
15
15
|
# @see Html2rss::AutoSource::Scraper::Schema
|
|
16
16
|
# @see Html2rss::AutoSource::Scraper::SemanticHtml
|
|
17
17
|
# @see Html2rss::AutoSource::Scraper::Html
|
|
18
|
+
# rubocop:disable Metrics/ClassLength -- defaults + tiered extract stay on the contributor entry type
|
|
18
19
|
class AutoSource
|
|
20
|
+
# Default max articles to keep (also the short-circuit floor across scraper tiers).
|
|
21
|
+
DEFAULT_LIMIT = 25
|
|
22
|
+
|
|
19
23
|
# Default auto-source configuration shipped for scraper and cleanup behavior.
|
|
20
24
|
DEFAULT_CONFIG = {
|
|
25
|
+
limit: DEFAULT_LIMIT,
|
|
21
26
|
scraper: {
|
|
22
27
|
wordpress_api: {
|
|
23
28
|
enabled: true
|
|
24
29
|
},
|
|
25
30
|
sitemap: {
|
|
26
31
|
enabled: true,
|
|
27
|
-
min_priority:
|
|
28
|
-
max_age_days:
|
|
32
|
+
min_priority: Scraper::Sitemap::Parser::DEFAULT_MIN_PRIORITY,
|
|
33
|
+
max_age_days: Scraper::Sitemap::Parser::DEFAULT_MAX_AGE_DAYS
|
|
29
34
|
},
|
|
30
35
|
schema: {
|
|
31
36
|
enabled: true
|
|
@@ -80,28 +85,24 @@ module Html2rss
|
|
|
80
85
|
# @param response [Html2rss::RequestService::Response] initial page response
|
|
81
86
|
# @param opts [Hash] validated auto-source options
|
|
82
87
|
# @param request_session [Html2rss::RequestSession, nil] shared request session for follow-up fetches
|
|
88
|
+
# @option opts [Integer] :limit max articles to keep; later tiers stop once this many survive Cleanup
|
|
83
89
|
# @option opts [Hash] :scraper scraper configuration map
|
|
84
90
|
# @option opts [Hash] :cleanup cleanup configuration map
|
|
85
91
|
# @return [void]
|
|
86
92
|
def initialize(response, opts = DEFAULT_CONFIG, request_session: nil)
|
|
87
93
|
@parsed_body = response.parsed_body
|
|
94
|
+
@body = response.body
|
|
88
95
|
@url = response.url
|
|
89
96
|
@opts = opts
|
|
90
97
|
@request_session = request_session
|
|
91
98
|
end
|
|
92
99
|
|
|
93
100
|
##
|
|
94
|
-
# Extracts
|
|
95
|
-
# page shape, running those scrapers, and normalizing the resulting hashes
|
|
96
|
-
# into `Article` objects.
|
|
97
|
-
#
|
|
98
|
-
# The contributor-facing flow is:
|
|
99
|
-
# 1. choose scraper instances that match the page
|
|
100
|
-
# 2. let each scraper collect its own candidates
|
|
101
|
-
# 3. clean and deduplicate the merged article list
|
|
101
|
+
# Extracts articles by running scraper tiers until a sufficient set is found.
|
|
102
102
|
#
|
|
103
|
-
#
|
|
104
|
-
#
|
|
103
|
+
# Tiers: in-page structured → follow-up IO → SemanticHtml → Html.
|
|
104
|
+
# SST is built only when a heuristic tier runs. Later tiers are skipped once
|
|
105
|
+
# +limit+ articles with url+title remain after Cleanup; the result is capped to +limit+.
|
|
105
106
|
#
|
|
106
107
|
# @return [Array<Html2rss::Article>] extracted articles
|
|
107
108
|
def articles
|
|
@@ -113,22 +114,73 @@ module Html2rss
|
|
|
113
114
|
|
|
114
115
|
private
|
|
115
116
|
|
|
116
|
-
attr_reader :url, :parsed_body, :request_session
|
|
117
|
+
attr_reader :url, :parsed_body, :body, :request_session
|
|
117
118
|
|
|
119
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
118
120
|
def extract_articles
|
|
119
|
-
|
|
120
|
-
|
|
121
|
+
articles = []
|
|
122
|
+
matched = false
|
|
123
|
+
document = nil
|
|
124
|
+
link_resolver = nil
|
|
125
|
+
scraper_opts = @opts.fetch(:scraper, {})
|
|
126
|
+
|
|
127
|
+
Scraper::SCRAPER_TIERS.each do |tier|
|
|
128
|
+
break if enough_articles?(articles)
|
|
129
|
+
|
|
130
|
+
if Scraper.heuristic_tier?(tier)
|
|
131
|
+
document ||= Scraper.normalize_sst(parsed_body)
|
|
132
|
+
next unless document
|
|
121
133
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
134
|
+
link_resolver ||= ::Html2rss::Scoring::LinkResolver.new(url)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
tier.each do |scraper_class|
|
|
138
|
+
instance = Scraper.build_instance(
|
|
139
|
+
scraper_class,
|
|
140
|
+
parsed_body,
|
|
141
|
+
opts: scraper_opts,
|
|
142
|
+
url:,
|
|
143
|
+
request_session:,
|
|
144
|
+
body:,
|
|
145
|
+
document:,
|
|
146
|
+
link_resolver:
|
|
147
|
+
)
|
|
148
|
+
next unless instance
|
|
149
|
+
next unless Scraper.extractable_instance?(instance, parsed_body)
|
|
150
|
+
|
|
151
|
+
matched = true
|
|
152
|
+
articles.concat(run_scraper(instance))
|
|
153
|
+
end
|
|
125
154
|
end
|
|
126
|
-
|
|
155
|
+
|
|
156
|
+
raise Scraper.no_scraper_found_for(parsed_body, body:) unless matched
|
|
157
|
+
|
|
158
|
+
Cleanup.call(articles, url:, **cleanup_options).first(article_limit)
|
|
159
|
+
end
|
|
160
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
161
|
+
|
|
162
|
+
def enough_articles?(articles)
|
|
163
|
+
threshold = article_limit
|
|
164
|
+
return false if articles.size < threshold
|
|
165
|
+
|
|
166
|
+
cleaned = Cleanup.call(articles.dup, url:, **cleanup_options)
|
|
167
|
+
cleaned.count { |article| article.url && !article.title.to_s.empty? } >= threshold
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def article_limit
|
|
171
|
+
@opts.fetch(:limit, DEFAULT_LIMIT)
|
|
127
172
|
end
|
|
128
173
|
|
|
129
174
|
def run_scraper(instance)
|
|
130
|
-
instance.each.map do |
|
|
131
|
-
|
|
175
|
+
instance.each.map do |item|
|
|
176
|
+
case item
|
|
177
|
+
when Article
|
|
178
|
+
item
|
|
179
|
+
when Hash
|
|
180
|
+
Article.new(**item, scraper: instance.class)
|
|
181
|
+
else
|
|
182
|
+
raise TypeError, "#{instance.class} yielded #{item.class}; expected Article or Hash"
|
|
183
|
+
end
|
|
132
184
|
end
|
|
133
185
|
end
|
|
134
186
|
|
|
@@ -136,4 +188,5 @@ module Html2rss
|
|
|
136
188
|
@opts.fetch(:cleanup, {})
|
|
137
189
|
end
|
|
138
190
|
end
|
|
191
|
+
# rubocop:enable Metrics/ClassLength
|
|
139
192
|
end
|