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,279 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'zlib'
|
|
4
|
+
|
|
5
|
+
module Html2rss
|
|
6
|
+
module Html
|
|
7
|
+
##
|
|
8
|
+
# Builds an {Html2rss::Article} from an SST segment / ranked segment.
|
|
9
|
+
# Port of Html::ArticleExtractor field logic onto SST::Node.
|
|
10
|
+
# rubocop:disable Metrics/ClassLength -- SST field extractors colocated for parity with ArticleExtractor
|
|
11
|
+
class SstArticleExtractor
|
|
12
|
+
# CSS class tokens that mark kicker / eyebrow text (excluded from titles).
|
|
13
|
+
KICKER_CLASS_PATTERN = /kicker|eyebrow|pre-title|pretitle|overline/i
|
|
14
|
+
# Inline emphasis tags used as title fallbacks when no heading exists.
|
|
15
|
+
FALLBACK_HEADING_NAMES = %i[strong b].freeze
|
|
16
|
+
|
|
17
|
+
class << self
|
|
18
|
+
##
|
|
19
|
+
# @param ranked_or_segment [Scoring::RankedSegment, AutoSource::Segment]
|
|
20
|
+
# @param base_url [String, Html2rss::Url]
|
|
21
|
+
# @param scraper [Class, nil]
|
|
22
|
+
# @param fallback_anchorless [Boolean]
|
|
23
|
+
# @return [Html2rss::Article, nil]
|
|
24
|
+
def call(ranked_or_segment, base_url:, scraper: nil, fallback_anchorless: false)
|
|
25
|
+
new(ranked_or_segment, base_url:, scraper:, fallback_anchorless:).call
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @param ranked_or_segment [Scoring::RankedSegment, AutoSource::Segment]
|
|
30
|
+
# @param base_url [String, Html2rss::Url]
|
|
31
|
+
# @param scraper [Class, nil]
|
|
32
|
+
# @param fallback_anchorless [Boolean]
|
|
33
|
+
def initialize(ranked_or_segment, base_url:, scraper: nil, fallback_anchorless: false)
|
|
34
|
+
segment = ranked_or_segment.is_a?(Scoring::RankedSegment) ? ranked_or_segment.segment : ranked_or_segment
|
|
35
|
+
raise ArgumentError, 'segment is required' unless segment.is_a?(AutoSource::Segment)
|
|
36
|
+
|
|
37
|
+
@segment = segment
|
|
38
|
+
@root = segment.root_node
|
|
39
|
+
@selected_anchor = segment.primary_link
|
|
40
|
+
@base_url = base_url
|
|
41
|
+
@scraper = scraper
|
|
42
|
+
@fallback_anchorless = fallback_anchorless
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
##
|
|
46
|
+
# @return [Html2rss::Article, nil]
|
|
47
|
+
def call # rubocop:disable Metrics/MethodLength
|
|
48
|
+
attrs = {
|
|
49
|
+
title: extract_title,
|
|
50
|
+
url: extract_url,
|
|
51
|
+
image: extract_image,
|
|
52
|
+
description: extract_description,
|
|
53
|
+
id: generate_id,
|
|
54
|
+
published_at: extract_published_at,
|
|
55
|
+
enclosures: extract_enclosures,
|
|
56
|
+
categories: extract_categories,
|
|
57
|
+
scraper: @scraper
|
|
58
|
+
}
|
|
59
|
+
article = Article.new(**attrs)
|
|
60
|
+
article.valid? ? article : nil
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def extract_url
|
|
66
|
+
@extract_url ||= begin
|
|
67
|
+
href = @selected_anchor&.attrs&.href.to_s
|
|
68
|
+
if href.empty?
|
|
69
|
+
anchorless_url_fallback
|
|
70
|
+
else
|
|
71
|
+
Url.from_relative(href.split('#').first.strip, @base_url)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def anchorless_url_fallback
|
|
77
|
+
return unless @fallback_anchorless
|
|
78
|
+
|
|
79
|
+
id = generate_id
|
|
80
|
+
Url.from_relative("##{id}", @base_url) if id
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def extract_title # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
84
|
+
source = heading || @selected_anchor
|
|
85
|
+
title_text = if source
|
|
86
|
+
source.visible_text
|
|
87
|
+
elsif @fallback_anchorless && @selected_anchor.nil?
|
|
88
|
+
first_nonempty_own_text
|
|
89
|
+
end
|
|
90
|
+
return unless title_text
|
|
91
|
+
|
|
92
|
+
kicker = kicker_node&.visible_text.to_s.strip
|
|
93
|
+
kicker && !kicker.empty? && !title_text.include?(kicker) ? "#{kicker}: #{title_text}" : title_text
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def heading
|
|
97
|
+
@heading ||= begin
|
|
98
|
+
tags = @root.find_all(&:heading?)
|
|
99
|
+
if tags.any?
|
|
100
|
+
select_best_heading(tags)
|
|
101
|
+
elsif @fallback_anchorless && @selected_anchor.nil?
|
|
102
|
+
fallback_heading
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def select_best_heading(tags)
|
|
108
|
+
min_name = tags.map { |t| t.name.to_s }.min
|
|
109
|
+
best = nil
|
|
110
|
+
max_size = -1
|
|
111
|
+
tags.each do |tag|
|
|
112
|
+
next unless tag.name.to_s == min_name
|
|
113
|
+
|
|
114
|
+
size = tag.visible_text.to_s.size
|
|
115
|
+
(best = tag) && (max_size = size) if size > max_size
|
|
116
|
+
end
|
|
117
|
+
best
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def fallback_heading
|
|
121
|
+
@root.find do |n|
|
|
122
|
+
next true if FALLBACK_HEADING_NAMES.include?(n.name)
|
|
123
|
+
|
|
124
|
+
n.attrs.class_names.any? { |c| c.match?(/title|font-bold|font-semibold/) } &&
|
|
125
|
+
!n.visible_text.to_s.strip.empty?
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def kicker_node # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
130
|
+
@kicker_node ||= begin
|
|
131
|
+
node = @root.find do |n|
|
|
132
|
+
n.attrs.raw.key?('data-tb-kicker') ||
|
|
133
|
+
n.attrs.class_names.any? { |c| c.match?(KICKER_CLASS_PATTERN) }
|
|
134
|
+
end
|
|
135
|
+
node && heading && (node.equal?(heading) || descendant_of?(node, heading)) ? nil : node
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def descendant_of?(child, ancestor)
|
|
140
|
+
# Walk via root children since Extractor may not hold Index; use tag walk on tree.
|
|
141
|
+
return true if child.equal?(ancestor)
|
|
142
|
+
|
|
143
|
+
ancestor.descendants.any? { |d| d.equal?(child) }
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def extract_description
|
|
147
|
+
exclude = [heading, @selected_anchor, kicker_node].compact
|
|
148
|
+
description = @root.visible_text(exclude:)
|
|
149
|
+
return if description.nil?
|
|
150
|
+
|
|
151
|
+
desc = description.strip
|
|
152
|
+
desc.empty? ? nil : desc
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def generate_id # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
156
|
+
@generate_id ||= begin
|
|
157
|
+
id_from_dom = parse_id_from_dom
|
|
158
|
+
if id_from_dom
|
|
159
|
+
id_from_dom
|
|
160
|
+
else
|
|
161
|
+
heading_text = heading&.visible_text
|
|
162
|
+
heading_text = first_nonempty_own_text if heading_text.to_s.strip.empty? && @fallback_anchorless
|
|
163
|
+
if heading_text && !heading_text.strip.empty?
|
|
164
|
+
generate_slug(heading_text)
|
|
165
|
+
elsif @fallback_anchorless
|
|
166
|
+
text = @root.visible_text.to_s.strip
|
|
167
|
+
Zlib.crc32(text).to_s(36) unless text.empty?
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def parse_id_from_dom
|
|
174
|
+
candidates = [@root.attrs.id]
|
|
175
|
+
nested = @root.find { |n| n.attrs.id }
|
|
176
|
+
candidates << nested.attrs.id if nested
|
|
177
|
+
if @selected_anchor
|
|
178
|
+
url = extract_url
|
|
179
|
+
candidates += [url&.path, url&.query]
|
|
180
|
+
end
|
|
181
|
+
candidates.compact.reject(&:empty?).first
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def generate_slug(text)
|
|
185
|
+
slug = text.downcase.gsub(/[^a-z0-9]+/, '-')
|
|
186
|
+
slug = slug[1..] if slug.start_with?('-')
|
|
187
|
+
slug = slug[0..-2] if slug.end_with?('-')
|
|
188
|
+
slug unless slug.empty?
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def first_nonempty_own_text
|
|
192
|
+
@root.find { |n| !n.own_text.to_s.strip.empty? }&.own_text&.strip
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def extract_image
|
|
196
|
+
img_src = image_from_srcset || image_from_img || image_from_style
|
|
197
|
+
Url.from_relative(img_src, @base_url) if img_src
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def image_from_img
|
|
201
|
+
@root.find { |n| n.image? && n.attrs.src && !n.attrs.src.start_with?('data:') }&.attrs&.src
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def image_from_srcset
|
|
205
|
+
srcsets = @root.find_all { |n| n.attrs.srcset }.map { |n| n.attrs.srcset }
|
|
206
|
+
ArticleRules::Image.largest_from_srcsets(srcsets)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def image_from_style
|
|
210
|
+
styles = @root.find_all { |n| n.attrs.style&.include?('url') }.map { |n| n.attrs.style }
|
|
211
|
+
ArticleRules::Image.best_from_styles(styles)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def extract_published_at
|
|
215
|
+
datetimes = @root.find_all { |n| n.attrs.datetime }.map { |n| n.attrs.datetime }
|
|
216
|
+
ArticleRules::Date.earliest(datetimes)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def extract_enclosures
|
|
220
|
+
@root.find_all { |n| enclosure_node?(n) }.filter_map { |element| enclosure_from(element) }
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def enclosure_node?(node)
|
|
224
|
+
case node.name
|
|
225
|
+
when :img then node.attrs.src && !node.attrs.src.start_with?('data:')
|
|
226
|
+
when :source, :audio, :video, :iframe then node.attrs.src
|
|
227
|
+
when :a then ArticleRules::Enclosure.archive_href?(node.attrs.href)
|
|
228
|
+
else false
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def enclosure_from(element)
|
|
233
|
+
case element.name
|
|
234
|
+
when :img
|
|
235
|
+
ArticleRules::Enclosure.from_image(element.attrs.src, @base_url)
|
|
236
|
+
when :video, :audio, :source
|
|
237
|
+
ArticleRules::Enclosure.from_media(element.attrs.src, element.attrs.type, @base_url)
|
|
238
|
+
when :iframe
|
|
239
|
+
ArticleRules::Enclosure.from_iframe(element.attrs.src, @base_url)
|
|
240
|
+
when :a
|
|
241
|
+
ArticleRules::Enclosure.from_anchor(element.attrs.href, @base_url)
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def extract_categories
|
|
246
|
+
Set.new.tap do |categories|
|
|
247
|
+
@root.find_all { |n| category_candidate?(n) }.each do |element|
|
|
248
|
+
add_category_text!(categories, element) if ArticleRules::Category.class_match?(element.attrs.class_attr)
|
|
249
|
+
element.attrs.raw.each do |name, value|
|
|
250
|
+
next unless ArticleRules::Category.attr_name_match?(name)
|
|
251
|
+
|
|
252
|
+
ArticleRules::Category.add_text!(categories, value)
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end.to_a
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def category_candidate?(node)
|
|
259
|
+
ArticleRules::Category.class_match?(node.attrs.class_attr) ||
|
|
260
|
+
node.attrs.raw.keys.any? { |k| ArticleRules::Category.attr_name_match?(k) }
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def add_category_text!(categories, element)
|
|
264
|
+
if element.link?
|
|
265
|
+
ArticleRules::Category.add_text!(categories, element.visible_text)
|
|
266
|
+
return
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
anchors = element.find_all(&:link?)
|
|
270
|
+
if anchors.any?
|
|
271
|
+
anchors.each { |a| ArticleRules::Category.add_text!(categories, a.visible_text) }
|
|
272
|
+
else
|
|
273
|
+
ArticleRules::Category.add_split_text!(categories, element.visible_text)
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
# rubocop:enable Metrics/ClassLength
|
|
278
|
+
end
|
|
279
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
module LinkDestination
|
|
5
|
+
# Normalized URL plus reusable route-classification facts for one link.
|
|
6
|
+
DestinationFacts = Data.define(
|
|
7
|
+
:url,
|
|
8
|
+
:destination,
|
|
9
|
+
:segments,
|
|
10
|
+
:content_path,
|
|
11
|
+
:utility_path,
|
|
12
|
+
:taxonomy_path,
|
|
13
|
+
:vanity_path,
|
|
14
|
+
:shallow,
|
|
15
|
+
:strong_post_suffix,
|
|
16
|
+
:high_confidence_junk_path,
|
|
17
|
+
:high_confidence_utility_destination
|
|
18
|
+
) do
|
|
19
|
+
# @param url [Html2rss::Url] normalized destination URL
|
|
20
|
+
# @return [DestinationFacts] route facts for downstream link scoring
|
|
21
|
+
def self.build(url) # rubocop:disable Metrics/MethodLength
|
|
22
|
+
classifier = PathClassifier.new(url.path_segments)
|
|
23
|
+
|
|
24
|
+
new(
|
|
25
|
+
url:,
|
|
26
|
+
destination: url.to_s,
|
|
27
|
+
segments: classifier.segments,
|
|
28
|
+
strong_post_suffix: classifier.strong_post_suffix?,
|
|
29
|
+
content_path: classifier.content_path?,
|
|
30
|
+
utility_path: classifier.utility_path?,
|
|
31
|
+
taxonomy_path: classifier.taxonomy_path?,
|
|
32
|
+
vanity_path: classifier.vanity_path?,
|
|
33
|
+
shallow: classifier.shallow?,
|
|
34
|
+
high_confidence_junk_path: classifier.junk_path?,
|
|
35
|
+
high_confidence_utility_destination: classifier.utility_destination?
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
module LinkDestination
|
|
5
|
+
##
|
|
6
|
+
# Noise / junk chrome eligibility for SST anchors (port of LinkHeuristics#noise_anchor?).
|
|
7
|
+
class NoisePolicy
|
|
8
|
+
# Child tag names that mark icon-only anchors as noise.
|
|
9
|
+
ICON_NAMES = %i[img svg].freeze
|
|
10
|
+
|
|
11
|
+
# @param link_resolver [LinkResolver]
|
|
12
|
+
# @param index [SST::Index]
|
|
13
|
+
def initialize(link_resolver:, index:)
|
|
14
|
+
@link_resolver = link_resolver
|
|
15
|
+
@index = index
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
##
|
|
19
|
+
# @param text [String]
|
|
20
|
+
# @param destination_facts [LinkDestination::DestinationFacts, nil]
|
|
21
|
+
# @param anchor [SST::Node, nil]
|
|
22
|
+
# @param container [SST::Node, nil]
|
|
23
|
+
# @param heading_anchor [Boolean]
|
|
24
|
+
# @return [Boolean]
|
|
25
|
+
def noise_anchor?(text:, destination_facts:, anchor: nil, container: nil, heading_anchor: false) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
26
|
+
return true unless destination_facts
|
|
27
|
+
|
|
28
|
+
destination_facts.taxonomy_path ||
|
|
29
|
+
short_utility_label?(text, destination_facts) ||
|
|
30
|
+
recommended_chrome?(text, destination_facts, heading_anchor:) ||
|
|
31
|
+
(@link_resolver.utility_prefix_text?(text) && destination_facts.high_confidence_utility_destination) ||
|
|
32
|
+
(@link_resolver.utility_text?(text) && destination_facts.vanity_path) ||
|
|
33
|
+
utility_text_chrome?(text, destination_facts, heading_anchor:) ||
|
|
34
|
+
icon_only_anchor?(anchor, text) ||
|
|
35
|
+
utility_landmark_ancestor?(anchor, container)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def short_utility_label?(text, destination_facts)
|
|
41
|
+
destination_facts.utility_path &&
|
|
42
|
+
!destination_facts.content_path &&
|
|
43
|
+
!destination_facts.strong_post_suffix &&
|
|
44
|
+
text.to_s.scan(/\p{Alnum}+/).size <= 3
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def recommended_chrome?(text, destination_facts, heading_anchor:)
|
|
48
|
+
!heading_anchor && @link_resolver.recommended_text?(text) && destination_facts.shallow
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def utility_text_chrome?(text, destination_facts, heading_anchor:)
|
|
52
|
+
return false if destination_facts.content_path
|
|
53
|
+
return false unless @link_resolver.utility_text?(text)
|
|
54
|
+
|
|
55
|
+
!heading_anchor && !destination_facts.strong_post_suffix
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def icon_only_anchor?(anchor, text)
|
|
59
|
+
return false unless anchor
|
|
60
|
+
return false if text.to_s.match?(/\p{Alnum}/)
|
|
61
|
+
|
|
62
|
+
!!anchor.find { |n| ICON_NAMES.include?(n.name) }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def utility_landmark_ancestor?(anchor, container) # rubocop:disable Metrics/CyclomaticComplexity
|
|
66
|
+
return false unless anchor && container
|
|
67
|
+
|
|
68
|
+
curr = @index.parent_of(anchor)
|
|
69
|
+
while curr && curr.name != :html
|
|
70
|
+
return true if curr != container && curr.utility_landmark?
|
|
71
|
+
return false if curr.equal?(container)
|
|
72
|
+
|
|
73
|
+
curr = @index.parent_of(curr)
|
|
74
|
+
end
|
|
75
|
+
false
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
module LinkDestination
|
|
5
|
+
# Classifies normalized destination path segments for scoring.
|
|
6
|
+
class PathClassifier # rubocop:disable Metrics/ClassLength
|
|
7
|
+
attr_reader :segments
|
|
8
|
+
|
|
9
|
+
# Soft utility segments excluded from high-confidence junk (still utility_path).
|
|
10
|
+
SOFT_UTILITY = %w[
|
|
11
|
+
for-you join member members membership newsletter newsletters
|
|
12
|
+
plans plus premium pricing recommended
|
|
13
|
+
].to_set.freeze
|
|
14
|
+
|
|
15
|
+
# Segment groups used to classify article, taxonomy, utility, and vanity routes.
|
|
16
|
+
SEGMENT_SETS = begin
|
|
17
|
+
content = %w[
|
|
18
|
+
article articles blog blogs changelog changelogs insight insights
|
|
19
|
+
launch launches news post posts release releases story stories update updates
|
|
20
|
+
artikel beitrag beitraege nachrichten neuigkeiten aktuelles
|
|
21
|
+
articulo articulos noticia noticias entrada entradas publicacion publicaciones
|
|
22
|
+
actualite actualites nouvelle nouvelles teaser teasers card cards
|
|
23
|
+
].to_set.freeze
|
|
24
|
+
taxonomy = %w[
|
|
25
|
+
category categories tag tags topic topics
|
|
26
|
+
kategorie kategorien schlagwort schlagworte thema themen
|
|
27
|
+
categoria categorias etiqueta etiquetas tema temas
|
|
28
|
+
categorie etiquette etiquettes sujet sujets theme themes
|
|
29
|
+
].to_set.freeze
|
|
30
|
+
vanity = %w[
|
|
31
|
+
join membership plus premium pricing plans subscribe signup
|
|
32
|
+
abonnieren abo suscribirse boletin s-abonner saboner
|
|
33
|
+
].to_set.freeze
|
|
34
|
+
utility = (
|
|
35
|
+
taxonomy.to_a + %w[
|
|
36
|
+
about account archive archives author authors comment comments
|
|
37
|
+
contact feedback help login logout notification notifications
|
|
38
|
+
preference preferences profile register search settings share signup subscribe
|
|
39
|
+
feed feeds comment-feed comments-feed privacy terms cookie cookies user users
|
|
40
|
+
kategorie kategorien schlagwort schlagworte thema themen autor autoren archiv
|
|
41
|
+
ueber-uns ueber ueberuns profil kontakt impressum suche hilfe anmelden registrieren
|
|
42
|
+
konto registrierung anmeldung abonnieren abo datenschutz nutzungsbedingungen agb
|
|
43
|
+
categoria categorias etiqueta etiquetas tema temas autores archivos
|
|
44
|
+
sobre-nosotros sobre quienes-somos buscar busqueda ayuda entrar ingresar
|
|
45
|
+
registrarse registro cuenta suscribirse boletin privacidad condiciones
|
|
46
|
+
categorie etiquette etiquettes sujet sujets theme themes auteur auteurs
|
|
47
|
+
a-propos apropos recherche rechercher aide connexion s-inscrire
|
|
48
|
+
sinscrire inscription compte s-abonner saboner lettre-information confidentialite
|
|
49
|
+
mentions-legales cgu menu sidebar widget social modal popup banner promo ad ads
|
|
50
|
+
related recommendation recommendations pagination pager
|
|
51
|
+
] + SOFT_UTILITY.to_a
|
|
52
|
+
).to_set.freeze
|
|
53
|
+
{
|
|
54
|
+
content:,
|
|
55
|
+
utility:,
|
|
56
|
+
high_confidence_junk: (utility - SOFT_UTILITY).freeze,
|
|
57
|
+
taxonomy:,
|
|
58
|
+
vanity:,
|
|
59
|
+
deep_post_context: %w[press newsroom presse pressemitteilungen prensa].to_set.freeze
|
|
60
|
+
}.freeze
|
|
61
|
+
end
|
|
62
|
+
# Path segment that begins with a year-like publishing marker.
|
|
63
|
+
YEARISH_SEGMENT = /\A\d{4,}[\w-]*\z/
|
|
64
|
+
# Hyphenated slug shape common to article permalinks.
|
|
65
|
+
POST_SLUG_SEGMENT = /\A[a-z0-9]+(?:-[a-z0-9]+){2,}\z/i
|
|
66
|
+
|
|
67
|
+
# @param segments [Array<String>] normalized URL path segments
|
|
68
|
+
def initialize(segments)
|
|
69
|
+
@segments = segments
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# @return [Boolean] true when the route has article-like path evidence
|
|
73
|
+
def content_path?
|
|
74
|
+
@content_path ||= SEGMENT_SETS[:content].intersect?(segments) ||
|
|
75
|
+
yearish_content_context?
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# @return [Boolean] true when the route includes utility/navigation evidence
|
|
79
|
+
def utility_path?
|
|
80
|
+
@utility_path ||= SEGMENT_SETS[:utility].intersect?(segments)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# @return [Boolean] true when the route points at conversion or account chrome
|
|
84
|
+
def vanity_path?
|
|
85
|
+
@vanity_path ||= SEGMENT_SETS[:vanity].intersect?(segments)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# @return [Boolean] true when the route points at taxonomy/listing chrome
|
|
89
|
+
def taxonomy_path?
|
|
90
|
+
@taxonomy_path ||= SEGMENT_SETS[:taxonomy].intersect?(segments)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# @return [Boolean] true when the route is too shallow to strongly indicate an article
|
|
94
|
+
def shallow?
|
|
95
|
+
segment_count = segments.size
|
|
96
|
+
junk_segments = SEGMENT_SETS.fetch(:high_confidence_junk)
|
|
97
|
+
|
|
98
|
+
segment_count <= 1 || (segment_count == 2 && junk_segments.include?(segments.last))
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# @return [Boolean] true when the final path segment looks like a post slug
|
|
102
|
+
def strong_post_suffix?
|
|
103
|
+
@strong_post_suffix ||= segments.any? &&
|
|
104
|
+
included_last_segment? &&
|
|
105
|
+
trusted_post_context?(segments.size - 1)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# @return [Boolean] true when every path segment is utility chrome
|
|
109
|
+
def utility_only_route?
|
|
110
|
+
junk_segments = SEGMENT_SETS.fetch(:high_confidence_junk)
|
|
111
|
+
|
|
112
|
+
segments.all? { |segment| junk_segments.include?(segment) }
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# @return [Boolean] true when the route is shallow and contains high-confidence noise
|
|
116
|
+
def shallow_high_confidence_route?
|
|
117
|
+
junk_segments = SEGMENT_SETS.fetch(:high_confidence_junk)
|
|
118
|
+
vanity_segments = SEGMENT_SETS.fetch(:vanity)
|
|
119
|
+
|
|
120
|
+
shallow? && segments.any? do |segment|
|
|
121
|
+
junk_segments.include?(segment) || vanity_segments.include?(segment)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# @return [Boolean] true when the leading segments are all utility chrome
|
|
126
|
+
def deep_utility_context_route?
|
|
127
|
+
all_junk?(segments.size - 1)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# @return [Boolean] true when the route is shallow and contains high-confidence noise
|
|
131
|
+
def junk_path?
|
|
132
|
+
return false if excluded_content_route?
|
|
133
|
+
|
|
134
|
+
taxonomy_path? ||
|
|
135
|
+
utility_only_route? ||
|
|
136
|
+
deep_utility_context_route? ||
|
|
137
|
+
shallow_high_confidence_route?
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# @return [Boolean] true when the route points at conversion or account chrome
|
|
141
|
+
def utility_destination?
|
|
142
|
+
return false if excluded_content_route?
|
|
143
|
+
|
|
144
|
+
vanity_path? || utility_route?
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
private
|
|
148
|
+
|
|
149
|
+
def yearish_content_context?
|
|
150
|
+
segments.any? { |segment| segment.match?(YEARISH_SEGMENT) } &&
|
|
151
|
+
(strong_post_suffix? || trusted_post_context?(segments.size - 1))
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def excluded_content_route?
|
|
155
|
+
segments.empty? || content_path? || strong_post_suffix?
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def utility_route?
|
|
159
|
+
taxonomy_path? ||
|
|
160
|
+
utility_only_route? ||
|
|
161
|
+
deep_utility_context_route? ||
|
|
162
|
+
shallow_utility_route?
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def shallow_utility_route?
|
|
166
|
+
shallow? && utility_path?
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def all_junk?(limit)
|
|
170
|
+
return false if limit <= 0
|
|
171
|
+
|
|
172
|
+
junk_segments = SEGMENT_SETS.fetch(:high_confidence_junk)
|
|
173
|
+
(0...limit).all? { |i| junk_segments.include?(segments[i]) }
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def trusted_post_context?(limit)
|
|
177
|
+
return false if limit <= 0
|
|
178
|
+
|
|
179
|
+
content_segments = SEGMENT_SETS.fetch(:content)
|
|
180
|
+
context_segments = SEGMENT_SETS.fetch(:deep_post_context)
|
|
181
|
+
|
|
182
|
+
(0...limit).any? do |i|
|
|
183
|
+
segment = segments[i]
|
|
184
|
+
content_segments.include?(segment) ||
|
|
185
|
+
segment.match?(PathClassifier::YEARISH_SEGMENT) ||
|
|
186
|
+
context_segments.include?(segment)
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def included_last_segment?
|
|
191
|
+
!excluded_last_segment? && slug_last_segment?
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def excluded_last_segment?
|
|
195
|
+
last = segments.last
|
|
196
|
+
[SEGMENT_SETS[:high_confidence_junk], SEGMENT_SETS[:vanity]].any? { |set| set.include?(last) }
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def slug_last_segment?
|
|
200
|
+
last = segments.last
|
|
201
|
+
last.match?(YEARISH_SEGMENT) || last.match?(POST_SLUG_SEGMENT)
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
module LinkDestination
|
|
5
|
+
# Classifies visible anchor text for utility and recommendation chrome.
|
|
6
|
+
class TextClassifier
|
|
7
|
+
# Prefix labels that usually identify navigation or subscription links.
|
|
8
|
+
UTILITY_PREFIX_PATTERN = /
|
|
9
|
+
\A\s*(
|
|
10
|
+
# English
|
|
11
|
+
view\s+all|see\s+all|all\s+news|subscribe|newsletter|comment\s+feed|comments\s+feed|join|premium|plus|
|
|
12
|
+
# German
|
|
13
|
+
alle\s+anzeigen|alle\s+news|abonnieren|newsletter|kommentar\s+feed|mitmachen|
|
|
14
|
+
# Spanish
|
|
15
|
+
ver\s+todos|ver\s+todo|todas\s+las\s+noticias|suscribirse|bolet(i|í)n|comentarios\s+feed|unirse|
|
|
16
|
+
# French
|
|
17
|
+
voir\s+tout|voir\s+tous|toutes\s+les\s+nouvelles|s['’]abonner|flux\s+de\s+commentaires|rejoindre
|
|
18
|
+
)\b
|
|
19
|
+
/ix
|
|
20
|
+
# Short labels that usually identify non-article navigation links.
|
|
21
|
+
UTILITY_PATTERN = /
|
|
22
|
+
\A\s*(
|
|
23
|
+
# English
|
|
24
|
+
about|contact|comments?|join|log\s+in|login|member(ship)?|
|
|
25
|
+
plus|premium|pricing|recommended(\s+for\s+you)?|
|
|
26
|
+
see\s+all|share|sign\s+up|signup|subscribe|view\s+all|
|
|
27
|
+
# German
|
|
28
|
+
(ue|ü)ber(\s+uns)?|kontakt|kommentare?|mitmachen|anmelden|login|
|
|
29
|
+
mitglied(schaft)?|empfohlen(\s+f(ue|ü)r\s+dich)?|alle\s+anzeigen|
|
|
30
|
+
teilen|registrieren|abonnieren|newsletter|
|
|
31
|
+
# Spanish
|
|
32
|
+
sobre(\s+nosotros)?|contacto|comentarios?|unirse|iniciar\s+sesion|
|
|
33
|
+
login|miembro|membres(i|í)a|recomendado(\s+para\s+ti)?|ver\s+todo|
|
|
34
|
+
compartir|registrarse|suscribirse|bolet(i|í)n|
|
|
35
|
+
# French
|
|
36
|
+
(a|à)\s+propos|(a|à)propos|contact|commentaires?|rejoindre|
|
|
37
|
+
se\s+connecter|login|membre|abonnement|recommand(e|é)(\s+pour\s+vous)?|
|
|
38
|
+
voir\s+tout|partager|s['’]inscrire|s['’]abonner|newsletter
|
|
39
|
+
)\b
|
|
40
|
+
/ix
|
|
41
|
+
# Labels for recommendation chrome rather than source articles.
|
|
42
|
+
RECOMMENDED_PATTERN = /
|
|
43
|
+
\A\s*(
|
|
44
|
+
recommended(\s+for\s+you)?|
|
|
45
|
+
empfohlen(\s+f(ue|ü)r\s+dich)?|
|
|
46
|
+
recomendado(\s+para\s+ti)?|
|
|
47
|
+
recommand(e|é)(\s+pour\s+vous)?
|
|
48
|
+
)\b
|
|
49
|
+
/ix
|
|
50
|
+
|
|
51
|
+
# @param text [String, #to_s] visible anchor text
|
|
52
|
+
# @return [Boolean] true when text matches a utility label
|
|
53
|
+
def utility?(text) = text.to_s.match?(UTILITY_PATTERN)
|
|
54
|
+
|
|
55
|
+
# @param text [String, #to_s] visible anchor text
|
|
56
|
+
# @return [Boolean] true when text begins with a utility label
|
|
57
|
+
def utility_prefix?(text) = text.to_s.match?(UTILITY_PREFIX_PATTERN)
|
|
58
|
+
|
|
59
|
+
# @param text [String, #to_s] visible anchor text
|
|
60
|
+
# @return [Boolean] true when text identifies recommendation chrome
|
|
61
|
+
def recommended?(text) = text.to_s.match?(RECOMMENDED_PATTERN)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|