html2rss 0.24.0 → 0.26.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 +76 -2
- data/html2rss.gemspec +4 -1
- data/lib/html2rss/auto_source/cleanup.rb +62 -36
- 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 +31 -401
- data/lib/html2rss/auto_source/scraper/schema/item_list.rb +2 -14
- 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/xhr_articles.rb +69 -0
- data/lib/html2rss/auto_source/scraper.rb +109 -53
- 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 +103 -0
- data/lib/html2rss/auto_source/segmenter/primary_link.rb +80 -0
- data/lib/html2rss/auto_source/segmenter/semantic.rb +104 -0
- data/lib/html2rss/auto_source/segmenter.rb +95 -0
- data/lib/html2rss/auto_source.rb +79 -21
- data/lib/html2rss/capture.rb +315 -0
- data/lib/html2rss/cli.rb +72 -18
- data/lib/html2rss/config/auto_source_contract.rb +5 -1
- data/lib/html2rss/config/request_controls.rb +33 -0
- data/lib/html2rss/config/schema.rb +67 -26
- data/lib/html2rss/config/selectors_validator.rb +60 -21
- data/lib/html2rss/config/validator.rb +33 -35
- data/lib/html2rss/config.rb +6 -2
- data/lib/html2rss/error.rb +15 -3
- data/lib/html2rss/feed_builder/rss.rb +17 -5
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +16 -6
- data/lib/html2rss/feed_pipeline/runtime_policy.rb +3 -43
- data/lib/html2rss/feed_pipeline.rb +11 -0
- data/lib/html2rss/feed_result.rb +1 -1
- data/lib/html2rss/hash_util.rb +17 -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 +308 -0
- data/lib/html2rss/link_destination/destination_facts.rb +40 -0
- data/lib/html2rss/link_destination/noise_policy.rb +68 -0
- data/lib/html2rss/link_destination/path_classifier.rb +208 -0
- data/lib/html2rss/link_destination/text_classifier.rb +64 -0
- data/lib/html2rss/link_destination.rb +8 -0
- data/lib/html2rss/mcp/server.rb +577 -0
- data/lib/html2rss/mcp.rb +21 -0
- data/lib/html2rss/request_service/blocked_surface.rb +24 -1
- data/lib/html2rss/request_service/botasaurus_contract.rb +89 -9
- data/lib/html2rss/request_service/botasaurus_strategy.rb +4 -2
- data/lib/html2rss/request_service/budget.rb +7 -35
- data/lib/html2rss/request_service/context.rb +0 -6
- data/lib/html2rss/request_service/faraday_strategy.rb +87 -3
- data/lib/html2rss/request_service/network_guard.rb +5 -3
- data/lib/html2rss/request_service/policy.rb +1 -1
- data/lib/html2rss/request_service/response.rb +15 -1
- data/lib/html2rss/request_service/strategy.rb +1 -2
- data/lib/html2rss/request_service.rb +4 -9
- data/lib/html2rss/scoring/cluster_scorer.rb +87 -0
- data/lib/html2rss/scoring/container_assessor.rb +77 -0
- data/lib/html2rss/scoring/engine.rb +145 -0
- data/lib/html2rss/scoring/link_resolver.rb +84 -0
- data/lib/html2rss/scoring/observation.rb +53 -0
- data/lib/html2rss/scoring/ranked_segment.rb +33 -0
- data/lib/html2rss/scoring/score.rb +11 -0
- data/lib/html2rss/scoring.rb +8 -0
- data/lib/html2rss/selectors/extractors/attribute.rb +15 -0
- data/lib/html2rss/selectors/extractors/href.rb +12 -0
- data/lib/html2rss/selectors/extractors/html.rb +12 -0
- data/lib/html2rss/selectors/extractors/static.rb +14 -0
- data/lib/html2rss/selectors/extractors/text.rb +11 -0
- data/lib/html2rss/selectors/post_processors/gsub.rb +18 -0
- data/lib/html2rss/selectors/post_processors/html_to_markdown.rb +11 -0
- data/lib/html2rss/selectors/post_processors/markdown_to_html.rb +12 -0
- data/lib/html2rss/selectors/post_processors/parse_time.rb +11 -0
- data/lib/html2rss/selectors/post_processors/parse_uri.rb +11 -0
- data/lib/html2rss/selectors/post_processors/sanitize_html.rb +23 -1
- data/lib/html2rss/selectors/post_processors/substring.rb +21 -0
- data/lib/html2rss/selectors/post_processors/template.rb +20 -0
- data/lib/html2rss/selectors/schema_doc.rb +99 -0
- 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/url.rb +11 -7
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss.rb +57 -26
- data/schema/html2rss-config.schema.json +428 -85
- metadata +93 -27
- 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/request_service/browserless_strategy.rb +0 -132
- data/lib/html2rss/request_service/puppet_commander/navigation_guards.rb +0 -148
- data/lib/html2rss/request_service/puppet_commander/preload_runner.rb +0 -86
- data/lib/html2rss/request_service/puppet_commander.rb +0 -95
|
@@ -1,219 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Html2rss
|
|
4
|
-
class AutoSource
|
|
5
|
-
module Discovery
|
|
6
|
-
##
|
|
7
|
-
# Builds ranked anchor facts for one semantic content container.
|
|
8
|
-
class SemanticAnchorCandidates
|
|
9
|
-
# Anchor candidate plus scoring signals used by semantic anchor ranking.
|
|
10
|
-
AnchorFacts = Data.define(
|
|
11
|
-
:anchor,
|
|
12
|
-
:text,
|
|
13
|
-
:url,
|
|
14
|
-
:destination,
|
|
15
|
-
:segments,
|
|
16
|
-
:meaningful_text,
|
|
17
|
-
:content_like_destination,
|
|
18
|
-
:heading_anchor,
|
|
19
|
-
:heading_text_match,
|
|
20
|
-
:score
|
|
21
|
-
) do
|
|
22
|
-
# @param candidate [Candidate] eligible semantic anchor candidate
|
|
23
|
-
# @return [AnchorFacts] serializable facts used for ranking and dedupe
|
|
24
|
-
def self.from_candidate(candidate)
|
|
25
|
-
signals = candidate.anchor_signals
|
|
26
|
-
|
|
27
|
-
new(
|
|
28
|
-
**candidate.anchor_identity_attributes,
|
|
29
|
-
meaningful_text: signals.meaningful_text,
|
|
30
|
-
content_like_destination: signals.content_like_destination,
|
|
31
|
-
heading_anchor: signals.heading_anchor,
|
|
32
|
-
heading_text_match: signals.heading_text_match,
|
|
33
|
-
score: signals.score
|
|
34
|
-
)
|
|
35
|
-
end
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
# Shared context for all anchors in one semantic container.
|
|
39
|
-
class Context
|
|
40
|
-
attr_reader :container, :link_heuristics
|
|
41
|
-
|
|
42
|
-
# @param container [Nokogiri::XML::Node] semantic container
|
|
43
|
-
# @param link_heuristics [Html2rss::AutoSource::LinkHeuristics] destination/text heuristics
|
|
44
|
-
def initialize(container, link_heuristics:)
|
|
45
|
-
@container = container
|
|
46
|
-
@link_heuristics = link_heuristics
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
# @return [Nokogiri::XML::Node, nil] heading used to identify title anchors
|
|
50
|
-
def heading
|
|
51
|
-
@heading ||= @container.at_css(Html2rss::Html::Navigator::HEADING_TAGS.join(','))
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
# @return [String] visible heading text
|
|
55
|
-
def heading_text
|
|
56
|
-
@heading_text ||= visible_text(heading)
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
# @param node [Nokogiri::XML::Node, nil] node to extract text from
|
|
60
|
-
# @return [String] visible text for the node
|
|
61
|
-
def visible_text(node)
|
|
62
|
-
return '' unless node
|
|
63
|
-
|
|
64
|
-
(@visible_texts ||= {}.compare_by_identity)[node] ||= Html2rss::Html::Navigator.extract_visible_text(node).to_s.strip
|
|
65
|
-
end
|
|
66
|
-
|
|
67
|
-
# @param anchor [Nokogiri::XML::Node] anchor candidate
|
|
68
|
-
# @return [Html2rss::AutoSource::LinkHeuristics::DestinationFacts, nil] destination facts
|
|
69
|
-
def destination_facts(anchor)
|
|
70
|
-
@link_heuristics.destination_facts(anchor)
|
|
71
|
-
end
|
|
72
|
-
end
|
|
73
|
-
|
|
74
|
-
# One anchor plus the facts needed to decide whether it represents content.
|
|
75
|
-
class Candidate
|
|
76
|
-
attr_reader :anchor
|
|
77
|
-
|
|
78
|
-
# @param anchor [Nokogiri::XML::Node] anchor candidate
|
|
79
|
-
# @param context [Context] semantic container context
|
|
80
|
-
def initialize(anchor, context)
|
|
81
|
-
@anchor = anchor
|
|
82
|
-
@context = context
|
|
83
|
-
end
|
|
84
|
-
|
|
85
|
-
# @return [AnchorFacts, nil] ranked anchor facts when the anchor is eligible
|
|
86
|
-
def facts
|
|
87
|
-
return unless destination_facts
|
|
88
|
-
return if noise_anchor?
|
|
89
|
-
return unless representative_content_anchor?
|
|
90
|
-
|
|
91
|
-
AnchorFacts.from_candidate(self)
|
|
92
|
-
end
|
|
93
|
-
|
|
94
|
-
# @return [Html2rss::AutoSource::LinkHeuristics::DestinationFacts, nil] destination facts
|
|
95
|
-
def destination_facts
|
|
96
|
-
@destination_facts ||= @context.destination_facts(@anchor)
|
|
97
|
-
end
|
|
98
|
-
|
|
99
|
-
# @return [String] visible anchor text
|
|
100
|
-
def text
|
|
101
|
-
@text ||= @context.visible_text(@anchor)
|
|
102
|
-
end
|
|
103
|
-
|
|
104
|
-
# @return [Hash] anchor identity attributes used to build AnchorFacts
|
|
105
|
-
def anchor_identity_attributes
|
|
106
|
-
{
|
|
107
|
-
anchor:,
|
|
108
|
-
text:,
|
|
109
|
-
url: destination_facts.url,
|
|
110
|
-
destination: destination_facts.destination,
|
|
111
|
-
segments: destination_facts.segments
|
|
112
|
-
}
|
|
113
|
-
end
|
|
114
|
-
|
|
115
|
-
# @return [Html2rss::AutoSource::LinkHeuristics::AnchorSignals] ranking signals
|
|
116
|
-
def anchor_signals
|
|
117
|
-
LinkHeuristics::AnchorSignals.new(
|
|
118
|
-
meaningful_text: meaningful_text?,
|
|
119
|
-
content_like_destination: content_like_destination?,
|
|
120
|
-
heading_anchor: heading_anchor?,
|
|
121
|
-
heading_text_match: heading_text_match?
|
|
122
|
-
)
|
|
123
|
-
end
|
|
124
|
-
|
|
125
|
-
# @return [Boolean] true when visible anchor text has words
|
|
126
|
-
def meaningful_text?
|
|
127
|
-
@meaningful_text ||= text.match?(/\p{Alnum}/)
|
|
128
|
-
end
|
|
129
|
-
|
|
130
|
-
# @return [Boolean] true when the destination route has content signals
|
|
131
|
-
def content_like_destination?
|
|
132
|
-
destination_facts.content_path
|
|
133
|
-
end
|
|
134
|
-
|
|
135
|
-
# @return [Boolean] true when the anchor is inside the selected heading
|
|
136
|
-
def heading_anchor?
|
|
137
|
-
heading = @context.heading
|
|
138
|
-
heading && (@anchor == heading || Html2rss::Html::Navigator.descendant_of?(@anchor, heading))
|
|
139
|
-
end
|
|
140
|
-
|
|
141
|
-
# @return [Boolean] true when anchor text exactly matches heading text
|
|
142
|
-
def heading_text_match?
|
|
143
|
-
heading_text = @context.heading_text
|
|
144
|
-
|
|
145
|
-
meaningful_text? &&
|
|
146
|
-
heading_text.match?(/\p{Alnum}/) &&
|
|
147
|
-
heading_text == text
|
|
148
|
-
end
|
|
149
|
-
|
|
150
|
-
private
|
|
151
|
-
|
|
152
|
-
def representative_content_anchor?
|
|
153
|
-
meaningful_text? || content_like_destination? || heading_anchor?
|
|
154
|
-
end
|
|
155
|
-
|
|
156
|
-
def noise_anchor?
|
|
157
|
-
@context.link_heuristics.noise_anchor?(
|
|
158
|
-
text:,
|
|
159
|
-
destination_facts:,
|
|
160
|
-
anchor: @anchor,
|
|
161
|
-
container: @context.container,
|
|
162
|
-
heading_anchor: heading_anchor?
|
|
163
|
-
)
|
|
164
|
-
end
|
|
165
|
-
end
|
|
166
|
-
|
|
167
|
-
# Keeps the strongest semantic anchor fact for each destination.
|
|
168
|
-
class DestinationWinners
|
|
169
|
-
def initialize
|
|
170
|
-
@winners = {}
|
|
171
|
-
end
|
|
172
|
-
|
|
173
|
-
# @return [Array<AnchorFacts>] strongest candidate per destination
|
|
174
|
-
def to_a
|
|
175
|
-
@winners.values
|
|
176
|
-
end
|
|
177
|
-
|
|
178
|
-
# @param facts [AnchorFacts] candidate anchor facts
|
|
179
|
-
# @return [void]
|
|
180
|
-
def add(facts)
|
|
181
|
-
destination = facts.destination
|
|
182
|
-
@winners[destination] = stronger_fact(@winners[destination], facts)
|
|
183
|
-
end
|
|
184
|
-
|
|
185
|
-
private
|
|
186
|
-
|
|
187
|
-
def stronger_fact(current, candidate)
|
|
188
|
-
return candidate unless current
|
|
189
|
-
|
|
190
|
-
current.score >= candidate.score ? current : candidate
|
|
191
|
-
end
|
|
192
|
-
end
|
|
193
|
-
|
|
194
|
-
# @param container [Nokogiri::XML::Node] semantic container
|
|
195
|
-
# @param link_heuristics [Html2rss::AutoSource::LinkHeuristics] destination/text heuristics
|
|
196
|
-
def initialize(container, link_heuristics:)
|
|
197
|
-
@container = container
|
|
198
|
-
@context = Context.new(container, link_heuristics:)
|
|
199
|
-
end
|
|
200
|
-
|
|
201
|
-
# @return [Array<AnchorFacts>] strongest candidate per destination
|
|
202
|
-
def to_a
|
|
203
|
-
@container.css(Html2rss::Html::Navigator::MAIN_ANCHOR_SELECTOR)
|
|
204
|
-
.each_with_object(DestinationWinners.new) { |anchor, winners| add_anchor(anchor, winners) }
|
|
205
|
-
.to_a
|
|
206
|
-
end
|
|
207
|
-
|
|
208
|
-
private
|
|
209
|
-
|
|
210
|
-
def add_anchor(anchor, winners)
|
|
211
|
-
return if Html2rss::Html::Navigator.ignored_container_path?(anchor)
|
|
212
|
-
|
|
213
|
-
facts = Candidate.new(anchor, @context).facts
|
|
214
|
-
winners.add(facts) if facts
|
|
215
|
-
end
|
|
216
|
-
end
|
|
217
|
-
end
|
|
218
|
-
end
|
|
219
|
-
end
|
|
@@ -1,71 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Html2rss
|
|
4
|
-
class AutoSource
|
|
5
|
-
module Discovery
|
|
6
|
-
##
|
|
7
|
-
# Collects semantic content containers from a parsed HTML document.
|
|
8
|
-
class SemanticContainers
|
|
9
|
-
# Candidate selectors used to locate extractable semantic content blocks.
|
|
10
|
-
SELECTORS = [
|
|
11
|
-
'article:not(:has(article))',
|
|
12
|
-
'section:not(:has(section))',
|
|
13
|
-
'li:not(:has(li))',
|
|
14
|
-
'tr:not(:has(tr))',
|
|
15
|
-
'div:not(:has(div))'
|
|
16
|
-
].freeze
|
|
17
|
-
|
|
18
|
-
# @param parsed_body [Nokogiri::HTML::Document] parsed document
|
|
19
|
-
# @return [Array<Nokogiri::XML::Node>] candidate semantic containers
|
|
20
|
-
def self.call(parsed_body)
|
|
21
|
-
new(parsed_body).call
|
|
22
|
-
end
|
|
23
|
-
|
|
24
|
-
# @param parsed_body [Nokogiri::HTML::Document] parsed document
|
|
25
|
-
def initialize(parsed_body)
|
|
26
|
-
@parsed_body = parsed_body
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
# @return [Array<Nokogiri::XML::Node>] candidate semantic containers
|
|
30
|
-
def call
|
|
31
|
-
cache = {}.compare_by_identity
|
|
32
|
-
candidates = @parsed_body.css(SELECTORS.join(',')).reject do |node|
|
|
33
|
-
Html2rss::Html::Navigator.ignored_container_path?(node, cache)
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
candidates = filter_nested_containers(candidates)
|
|
37
|
-
sort_by_depth(candidates)
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
private
|
|
41
|
-
|
|
42
|
-
def filter_nested_containers(candidates)
|
|
43
|
-
candidate_set = Set.new(candidates)
|
|
44
|
-
rejected = Set.new
|
|
45
|
-
|
|
46
|
-
candidates.each do |candidate_b|
|
|
47
|
-
next if candidate_b.name == 'div'
|
|
48
|
-
|
|
49
|
-
find_and_reject_ancestors(candidate_b, candidate_set, rejected)
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
candidates.reject { |c| rejected.include?(c) }
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
def find_and_reject_ancestors(node, candidate_set, rejected)
|
|
56
|
-
curr = node.parent
|
|
57
|
-
while curr && !curr.document? && curr.name != 'html'
|
|
58
|
-
rejected << curr if candidate_set.include?(curr)
|
|
59
|
-
curr = curr.parent
|
|
60
|
-
end
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
def sort_by_depth(candidates)
|
|
64
|
-
candidates.each_with_index
|
|
65
|
-
.sort_by { |node, index| [-node.ancestors.size, index] }
|
|
66
|
-
.map!(&:first)
|
|
67
|
-
end
|
|
68
|
-
end
|
|
69
|
-
end
|
|
70
|
-
end
|
|
71
|
-
end
|
|
@@ -1,159 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require 'date'
|
|
4
|
-
require 'nokogiri'
|
|
5
|
-
|
|
6
|
-
module Html2rss
|
|
7
|
-
class AutoSource
|
|
8
|
-
module Discovery
|
|
9
|
-
##
|
|
10
|
-
# Parses XML sitemap documents and extracts prioritized, recent article entries.
|
|
11
|
-
class Sitemap
|
|
12
|
-
# Default minimum priority threshold (0.0 to 1.0)
|
|
13
|
-
DEFAULT_MIN_PRIORITY = 0.3
|
|
14
|
-
# Default maximum age in days for sitemap entries
|
|
15
|
-
DEFAULT_MAX_AGE_DAYS = 30
|
|
16
|
-
# Google News sitemap XML namespace
|
|
17
|
-
NEWS_NS = 'http://www.google.com/schemas/sitemap-news/0.9'
|
|
18
|
-
|
|
19
|
-
# Result value object returned by .call
|
|
20
|
-
# @!attribute entries [Array<SitemapEntry>] parsed entries (empty for sitemapindex docs)
|
|
21
|
-
# @!attribute sub_sitemap_urls [Array<String>] child sitemap URLs (empty for urlset docs)
|
|
22
|
-
Result = Data.define(:entries, :sub_sitemap_urls)
|
|
23
|
-
|
|
24
|
-
# Immutable facts for one sitemap entry
|
|
25
|
-
SitemapEntry = Data.define(:url, :title, :published_at, :priority, :changefreq)
|
|
26
|
-
|
|
27
|
-
class << self
|
|
28
|
-
##
|
|
29
|
-
# Parses sitemap XML body and returns a Result.
|
|
30
|
-
#
|
|
31
|
-
# @param xml_content [String] sitemap XML body string
|
|
32
|
-
# @param min_priority [Float] minimum priority score (0.0..1.0)
|
|
33
|
-
# @param max_age_days [Integer] maximum age in days for entries
|
|
34
|
-
# @return [Result] result with entries and/or sub_sitemap_urls
|
|
35
|
-
def call(xml_content, min_priority: DEFAULT_MIN_PRIORITY, max_age_days: DEFAULT_MAX_AGE_DAYS)
|
|
36
|
-
new(xml_content, min_priority:, max_age_days:).call
|
|
37
|
-
end
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
##
|
|
41
|
-
# @param xml_content [String]
|
|
42
|
-
# @param min_priority [Float]
|
|
43
|
-
# @param max_age_days [Integer]
|
|
44
|
-
def initialize(xml_content, min_priority: DEFAULT_MIN_PRIORITY, max_age_days: DEFAULT_MAX_AGE_DAYS)
|
|
45
|
-
@xml_content = xml_content
|
|
46
|
-
@min_priority = min_priority
|
|
47
|
-
@max_age_days = max_age_days
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
##
|
|
51
|
-
# @return [Result]
|
|
52
|
-
def call
|
|
53
|
-
document = Nokogiri::XML(xml_content)
|
|
54
|
-
return Result.new(entries: [], sub_sitemap_urls: []) if invalid_xml?(document)
|
|
55
|
-
|
|
56
|
-
if sitemapindex?(document)
|
|
57
|
-
Result.new(entries: [], sub_sitemap_urls: extract_sub_sitemap_urls(document))
|
|
58
|
-
else
|
|
59
|
-
Result.new(entries: extract_entries(document), sub_sitemap_urls: [])
|
|
60
|
-
end
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
private
|
|
64
|
-
|
|
65
|
-
attr_reader :xml_content, :min_priority, :max_age_days
|
|
66
|
-
|
|
67
|
-
def invalid_xml?(document)
|
|
68
|
-
document.errors.any? && document.at_css('urlset, sitemapindex').nil?
|
|
69
|
-
end
|
|
70
|
-
|
|
71
|
-
def sitemapindex?(document)
|
|
72
|
-
!document.at_xpath('//*[local-name()="sitemapindex"]').nil?
|
|
73
|
-
end
|
|
74
|
-
|
|
75
|
-
def extract_sub_sitemap_urls(document)
|
|
76
|
-
document.xpath('//*[local-name()="sitemap"]/*[local-name()="loc"]').filter_map do |node|
|
|
77
|
-
text = node.text.strip
|
|
78
|
-
text unless text.empty?
|
|
79
|
-
end
|
|
80
|
-
end
|
|
81
|
-
|
|
82
|
-
def extract_entries(document)
|
|
83
|
-
document.xpath('//*[local-name()="url"]').filter_map { build_entry(_1) }
|
|
84
|
-
end
|
|
85
|
-
|
|
86
|
-
def build_entry(url_node)
|
|
87
|
-
loc = parse_loc(url_node)
|
|
88
|
-
return unless loc
|
|
89
|
-
return if invalid_entry_signals?(url_node)
|
|
90
|
-
|
|
91
|
-
published_at = parse_date(url_node)
|
|
92
|
-
return if stale_date?(published_at)
|
|
93
|
-
|
|
94
|
-
build_entry_facts(url_node, loc, published_at)
|
|
95
|
-
end
|
|
96
|
-
|
|
97
|
-
def build_entry_facts(url_node, loc, published_at)
|
|
98
|
-
SitemapEntry.new(
|
|
99
|
-
url: loc,
|
|
100
|
-
title: parse_title(url_node),
|
|
101
|
-
published_at:,
|
|
102
|
-
priority: parse_priority(url_node),
|
|
103
|
-
changefreq: parse_changefreq(url_node)
|
|
104
|
-
)
|
|
105
|
-
end
|
|
106
|
-
|
|
107
|
-
def parse_loc(url_node)
|
|
108
|
-
node = url_node.at_xpath('./*[local-name()="loc"]')
|
|
109
|
-
text = node&.text&.strip
|
|
110
|
-
text unless text.to_s.empty?
|
|
111
|
-
end
|
|
112
|
-
|
|
113
|
-
def parse_changefreq(url_node)
|
|
114
|
-
node = url_node.at_xpath('./*[local-name()="changefreq"]')
|
|
115
|
-
node ? node.text.strip.downcase : nil
|
|
116
|
-
end
|
|
117
|
-
|
|
118
|
-
def invalid_entry_signals?(url_node)
|
|
119
|
-
parse_priority(url_node) < min_priority || parse_changefreq(url_node) == 'never'
|
|
120
|
-
end
|
|
121
|
-
|
|
122
|
-
def parse_priority(url_node)
|
|
123
|
-
node = url_node.at_xpath('./*[local-name()="priority"]')
|
|
124
|
-
text = node&.text&.strip
|
|
125
|
-
text ? text.to_f : 0.5
|
|
126
|
-
end
|
|
127
|
-
|
|
128
|
-
def parse_date(url_node)
|
|
129
|
-
raw_date = raw_date_from(url_node)
|
|
130
|
-
return unless raw_date
|
|
131
|
-
|
|
132
|
-
Time.parse(raw_date).utc.iso8601
|
|
133
|
-
rescue ArgumentError
|
|
134
|
-
nil
|
|
135
|
-
end
|
|
136
|
-
|
|
137
|
-
def raw_date_from(url_node)
|
|
138
|
-
news_node = url_node.at_xpath('.//news:publication_date', 'news' => NEWS_NS)
|
|
139
|
-
lastmod_node = url_node.at_xpath('./*[local-name()="lastmod"]')
|
|
140
|
-
(news_node || lastmod_node)&.text&.strip
|
|
141
|
-
end
|
|
142
|
-
|
|
143
|
-
def parse_title(url_node)
|
|
144
|
-
node = url_node.at_xpath('.//news:title', 'news' => NEWS_NS)
|
|
145
|
-
node&.text&.strip
|
|
146
|
-
end
|
|
147
|
-
|
|
148
|
-
def stale_date?(published_at)
|
|
149
|
-
return false unless published_at
|
|
150
|
-
|
|
151
|
-
time = Time.parse(published_at)
|
|
152
|
-
(Time.now.utc - time) > (max_age_days * 86_400)
|
|
153
|
-
rescue ArgumentError
|
|
154
|
-
false
|
|
155
|
-
end
|
|
156
|
-
end
|
|
157
|
-
end
|
|
158
|
-
end
|
|
159
|
-
end
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Html2rss
|
|
4
|
-
class AutoSource
|
|
5
|
-
##
|
|
6
|
-
# Card/list discovery helpers used by AutoSource scrapers before field extraction.
|
|
7
|
-
#
|
|
8
|
-
# Html2rss::Html::ArticleExtractor fills article fields from a container; discovery finds those containers.
|
|
9
|
-
#
|
|
10
|
-
# DOM list discovery for anchorless or classless pages is owned by {Discovery::DomClustering}.
|
|
11
|
-
module Discovery
|
|
12
|
-
end
|
|
13
|
-
end
|
|
14
|
-
end
|
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Html2rss
|
|
4
|
-
class AutoSource
|
|
5
|
-
class LinkHeuristics
|
|
6
|
-
# Score weights keyed by AnchorSignals member name.
|
|
7
|
-
ANCHOR_SCORE_RULES = {
|
|
8
|
-
heading_anchor: 100,
|
|
9
|
-
heading_text_match: 20,
|
|
10
|
-
meaningful_text: 10,
|
|
11
|
-
content_like_destination: 10
|
|
12
|
-
}.freeze
|
|
13
|
-
|
|
14
|
-
# Anchor ranking signals used by semantic primary-link selection.
|
|
15
|
-
AnchorSignals = Data.define(
|
|
16
|
-
:heading_anchor,
|
|
17
|
-
:heading_text_match,
|
|
18
|
-
:meaningful_text,
|
|
19
|
-
:content_like_destination
|
|
20
|
-
) do
|
|
21
|
-
# @return [Integer] ranking score for one eligible anchor
|
|
22
|
-
def score
|
|
23
|
-
ANCHOR_SCORE_RULES.sum { |signal, weight| public_send(signal) ? weight : 0 }
|
|
24
|
-
end
|
|
25
|
-
end
|
|
26
|
-
end
|
|
27
|
-
end
|
|
28
|
-
end
|
|
@@ -1,106 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Html2rss
|
|
4
|
-
class AutoSource
|
|
5
|
-
class LinkHeuristics
|
|
6
|
-
##
|
|
7
|
-
# Turns container DOM observations into ContainerSignals inputs.
|
|
8
|
-
#
|
|
9
|
-
# Owns publish markers, headings, visible text, and class/id tokens so
|
|
10
|
-
# LinkHeuristics can keep eligibility policy and ContainerSignals can
|
|
11
|
-
# keep scoring without re-reading the DOM.
|
|
12
|
-
class ContainerAssessor
|
|
13
|
-
# Token pattern for content-like class/id markers on containers.
|
|
14
|
-
CONTENT_TOKEN_REGEXP = begin
|
|
15
|
-
words = PathClassifier::SEGMENT_SETS.fetch(:content)
|
|
16
|
-
/(?:^|\s|[-_])(#{Regexp.union(words.to_a).source})(?:\s|[-_]|$)/i
|
|
17
|
-
end.freeze
|
|
18
|
-
|
|
19
|
-
# Token pattern for junk/utility class/id markers on containers.
|
|
20
|
-
JUNK_TOKEN_REGEXP = begin
|
|
21
|
-
words = PathClassifier::SEGMENT_SETS.fetch(:utility)
|
|
22
|
-
/(?:^|\s|[-_])(#{Regexp.union(words.to_a).source})(?:\s|[-_]|$)/i
|
|
23
|
-
end.freeze
|
|
24
|
-
|
|
25
|
-
# @param text_classifier [TextClassifier] shared utility/recommended text policy
|
|
26
|
-
def initialize(text_classifier:)
|
|
27
|
-
@text_classifier = text_classifier
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
##
|
|
31
|
-
# Observes a container and builds ranking signals, including hard-junk.
|
|
32
|
-
#
|
|
33
|
-
# @param container [Nokogiri::XML::Node] semantic container node
|
|
34
|
-
# @param selected_anchor [Nokogiri::XML::Node, nil] primary anchor for the container
|
|
35
|
-
# @param destination_facts [DestinationFacts, nil] route facts for the selected anchor
|
|
36
|
-
# @return [ContainerSignals] observation + scoring signals for the container
|
|
37
|
-
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
38
|
-
def call(container, selected_anchor, destination_facts:)
|
|
39
|
-
title = entry_title(container, selected_anchor)
|
|
40
|
-
tokens = container_tokens(container)
|
|
41
|
-
|
|
42
|
-
ContainerSignals.new(
|
|
43
|
-
title_word_count: word_count(title),
|
|
44
|
-
path_length: destination_facts&.url&.path.to_s.length,
|
|
45
|
-
content_path: destination_facts&.content_path,
|
|
46
|
-
publish_marker: publish_marker?(container),
|
|
47
|
-
descriptive_context: descriptive_context?(visible_text(container), title),
|
|
48
|
-
article_container: container.name == 'article',
|
|
49
|
-
content_tokens: content_tokens?(tokens),
|
|
50
|
-
junk_tokens: junk_tokens?(tokens),
|
|
51
|
-
utility_prefix_title: @text_classifier.utility_prefix?(title),
|
|
52
|
-
recommended_title: @text_classifier.recommended?(title),
|
|
53
|
-
utility_path: destination_facts&.utility_path,
|
|
54
|
-
strong_post_suffix: destination_facts&.strong_post_suffix,
|
|
55
|
-
shallow: destination_facts&.shallow,
|
|
56
|
-
high_confidence_junk_path: destination_facts&.high_confidence_junk_path,
|
|
57
|
-
high_confidence_utility_destination: destination_facts&.high_confidence_utility_destination,
|
|
58
|
-
selected_anchor_present: !selected_anchor.nil?
|
|
59
|
-
)
|
|
60
|
-
end
|
|
61
|
-
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
|
|
62
|
-
|
|
63
|
-
private
|
|
64
|
-
|
|
65
|
-
def publish_marker?(container)
|
|
66
|
-
(@publish_markers ||= {}.compare_by_identity)[container] ||=
|
|
67
|
-
!!container.at_css('time, [datetime], [itemprop="datePublished"], [itemprop="dateModified"]')
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
def descriptive_context?(container_text, title)
|
|
71
|
-
snippet = container_text.to_s.sub(/\A#{Regexp.escape(title.to_s)}/i, '')
|
|
72
|
-
snippet.length > 30 && word_count(snippet) >= 8
|
|
73
|
-
end
|
|
74
|
-
|
|
75
|
-
def heading_for(container)
|
|
76
|
-
(@headings ||= {}.compare_by_identity)[container] ||=
|
|
77
|
-
container.at_css(Html2rss::Html::Navigator::HEADING_TAGS.join(','))
|
|
78
|
-
end
|
|
79
|
-
|
|
80
|
-
def visible_text(node)
|
|
81
|
-
return '' unless node
|
|
82
|
-
|
|
83
|
-
(@visible_texts ||= {}.compare_by_identity)[node] ||= Html2rss::Html::Navigator.extract_visible_text(node).to_s.strip
|
|
84
|
-
end
|
|
85
|
-
|
|
86
|
-
def entry_title(container, selected_anchor) = visible_text(heading_for(container) || selected_anchor)
|
|
87
|
-
|
|
88
|
-
def word_count(text)
|
|
89
|
-
(@word_counts ||= {})[text] ||= begin
|
|
90
|
-
count = 0
|
|
91
|
-
text.to_s.scan(/\p{Alnum}+/) { count += 1 }
|
|
92
|
-
count
|
|
93
|
-
end
|
|
94
|
-
end
|
|
95
|
-
|
|
96
|
-
def container_tokens(container)
|
|
97
|
-
(@container_tokens ||= {}.compare_by_identity)[container] ||= "#{container['class']} #{container['id']}"
|
|
98
|
-
end
|
|
99
|
-
|
|
100
|
-
def content_tokens?(tokens) = tokens.to_s.match?(CONTENT_TOKEN_REGEXP)
|
|
101
|
-
|
|
102
|
-
def junk_tokens?(tokens) = tokens.to_s.match?(JUNK_TOKEN_REGEXP)
|
|
103
|
-
end
|
|
104
|
-
end
|
|
105
|
-
end
|
|
106
|
-
end
|
|
@@ -1,80 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Html2rss
|
|
4
|
-
class AutoSource
|
|
5
|
-
class LinkHeuristics
|
|
6
|
-
# Container observations used to compute quality/junk scores.
|
|
7
|
-
ContainerSignals = Data.define(
|
|
8
|
-
:title_word_count,
|
|
9
|
-
:path_length,
|
|
10
|
-
:content_path,
|
|
11
|
-
:publish_marker,
|
|
12
|
-
:descriptive_context,
|
|
13
|
-
:article_container,
|
|
14
|
-
:content_tokens,
|
|
15
|
-
:junk_tokens,
|
|
16
|
-
:utility_prefix_title,
|
|
17
|
-
:recommended_title,
|
|
18
|
-
:utility_path,
|
|
19
|
-
:strong_post_suffix,
|
|
20
|
-
:shallow,
|
|
21
|
-
:high_confidence_junk_path,
|
|
22
|
-
:high_confidence_utility_destination,
|
|
23
|
-
:selected_anchor_present
|
|
24
|
-
) do
|
|
25
|
-
# @return [Integer] positive quality contribution for ranking
|
|
26
|
-
def quality_score # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
27
|
-
score = 0
|
|
28
|
-
score += 40 if title_word_count >= 3
|
|
29
|
-
score += 15 if title_word_count >= 7
|
|
30
|
-
score += 20 if path_length > 6
|
|
31
|
-
score += 15 if content_path
|
|
32
|
-
score += 15 if publish_marker
|
|
33
|
-
score += 10 if descriptive_context
|
|
34
|
-
score += 10 if article_container
|
|
35
|
-
score += 10 if content_tokens
|
|
36
|
-
score
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
# @return [Integer] junk penalty subtracted from quality
|
|
40
|
-
def junk_score # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
41
|
-
score = 0
|
|
42
|
-
score += 25 if non_content_utility_path?
|
|
43
|
-
score += 15 if utility_prefix_title && title_word_count <= 6
|
|
44
|
-
score += 10 if shallow
|
|
45
|
-
score += 10 if weak_container?
|
|
46
|
-
score += 10 if recommended_title && !content_path
|
|
47
|
-
score += 5 if high_confidence_junk_path
|
|
48
|
-
score += 15 if junk_tokens
|
|
49
|
-
score
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
# @return [Integer] quality minus junk for stable ranking
|
|
53
|
-
def final_score = quality_score - junk_score
|
|
54
|
-
|
|
55
|
-
# @return [Boolean] true when the entry should be dropped before ranking
|
|
56
|
-
def hard_junk? # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
57
|
-
weak = weak_article_candidate?
|
|
58
|
-
|
|
59
|
-
high_confidence_junk_path ||
|
|
60
|
-
(selected_anchor_present && recommended_title && shallow && weak) ||
|
|
61
|
-
(selected_anchor_present && utility_prefix_title &&
|
|
62
|
-
high_confidence_utility_destination && weak)
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
private
|
|
66
|
-
|
|
67
|
-
# @return [Boolean] true when article evidence is too weak to keep
|
|
68
|
-
def weak_article_candidate?
|
|
69
|
-
[article_container, publish_marker, descriptive_context, content_path].count(&:itself) < 2
|
|
70
|
-
end
|
|
71
|
-
|
|
72
|
-
def non_content_utility_path?
|
|
73
|
-
utility_path && !content_path && !strong_post_suffix
|
|
74
|
-
end
|
|
75
|
-
|
|
76
|
-
def weak_container? = !publish_marker && !descriptive_context
|
|
77
|
-
end
|
|
78
|
-
end
|
|
79
|
-
end
|
|
80
|
-
end
|