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.
Files changed (109) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/lib/html2rss/article.rb +42 -35
  4. data/lib/html2rss/auto_source/scraper/html.rb +68 -145
  5. data/lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb +116 -0
  6. data/lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb +63 -0
  7. data/lib/html2rss/auto_source/scraper/json_state/document_scanner.rb +179 -0
  8. data/lib/html2rss/auto_source/scraper/json_state/value_finder.rb +55 -0
  9. data/lib/html2rss/auto_source/scraper/json_state.rb +0 -379
  10. data/lib/html2rss/auto_source/scraper/schema/category_extractor.rb +29 -114
  11. data/lib/html2rss/auto_source/scraper/schema/item_list.rb +50 -11
  12. data/lib/html2rss/auto_source/scraper/schema/thing.rb +14 -5
  13. data/lib/html2rss/auto_source/scraper/schema.rb +61 -18
  14. data/lib/html2rss/auto_source/scraper/semantic_html/entry_deduplicator.rb +35 -30
  15. data/lib/html2rss/auto_source/scraper/semantic_html.rb +40 -120
  16. data/lib/html2rss/auto_source/scraper/sitemap/parser.rb +161 -0
  17. data/lib/html2rss/auto_source/scraper/sitemap.rb +10 -7
  18. data/lib/html2rss/auto_source/scraper/wordpress_api.rb +6 -2
  19. data/lib/html2rss/auto_source/scraper.rb +81 -44
  20. data/lib/html2rss/auto_source/segment.rb +29 -0
  21. data/lib/html2rss/auto_source/segmenter/cluster.rb +163 -0
  22. data/lib/html2rss/auto_source/segmenter/list.rb +101 -0
  23. data/lib/html2rss/auto_source/segmenter/primary_link.rb +81 -0
  24. data/lib/html2rss/auto_source/segmenter/semantic.rb +104 -0
  25. data/lib/html2rss/auto_source/segmenter.rb +66 -0
  26. data/lib/html2rss/auto_source.rb +74 -21
  27. data/lib/html2rss/channel.rb +159 -79
  28. data/lib/html2rss/cli.rb +8 -13
  29. data/lib/html2rss/config/auto_source_contract.rb +2 -0
  30. data/lib/html2rss/config/request_controls.rb +33 -0
  31. data/lib/html2rss/config/validator.rb +15 -8
  32. data/lib/html2rss/config.rb +6 -2
  33. data/lib/html2rss/feed_builder/item_presentation.rb +42 -0
  34. data/lib/html2rss/feed_builder/json_feed/item.rb +20 -4
  35. data/lib/html2rss/feed_builder/json_feed.rb +22 -2
  36. data/lib/html2rss/feed_builder/rss.rb +36 -24
  37. data/lib/html2rss/feed_builder.rb +9 -17
  38. data/lib/html2rss/feed_pipeline/auto_fallback.rb +71 -28
  39. data/lib/html2rss/feed_pipeline.rb +66 -55
  40. data/lib/html2rss/feed_result.rb +92 -0
  41. data/lib/html2rss/html/article_extractor/category_extractor.rb +10 -36
  42. data/lib/html2rss/html/article_extractor/date_extractor.rb +2 -7
  43. data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +5 -50
  44. data/lib/html2rss/html/article_extractor/image_extractor.rb +5 -16
  45. data/lib/html2rss/html/article_rules/category.rb +55 -0
  46. data/lib/html2rss/html/article_rules/date.rb +25 -0
  47. data/lib/html2rss/html/article_rules/enclosure.rb +72 -0
  48. data/lib/html2rss/html/article_rules/image.rb +109 -0
  49. data/lib/html2rss/html/article_rules.rb +10 -0
  50. data/lib/html2rss/html/rendering/audio_renderer.rb +2 -12
  51. data/lib/html2rss/html/rendering/escaped_attributes.rb +27 -0
  52. data/lib/html2rss/html/rendering/image_renderer.rb +2 -12
  53. data/lib/html2rss/html/rendering/pdf_renderer.rb +2 -8
  54. data/lib/html2rss/html/rendering/video_renderer.rb +2 -12
  55. data/lib/html2rss/html/sst_article_extractor.rb +279 -0
  56. data/lib/html2rss/link_destination/destination_facts.rb +40 -0
  57. data/lib/html2rss/link_destination/noise_policy.rb +79 -0
  58. data/lib/html2rss/link_destination/path_classifier.rb +205 -0
  59. data/lib/html2rss/link_destination/text_classifier.rb +64 -0
  60. data/lib/html2rss/link_destination.rb +8 -0
  61. data/lib/html2rss/request_service/botasaurus_contract.rb +33 -5
  62. data/lib/html2rss/request_service/botasaurus_strategy.rb +13 -4
  63. data/lib/html2rss/request_service/budget.rb +7 -2
  64. data/lib/html2rss/request_service/faraday_strategy.rb +42 -1
  65. data/lib/html2rss/request_service/network_guard.rb +5 -3
  66. data/lib/html2rss/request_service/response.rb +9 -1
  67. data/lib/html2rss/request_service/strategy.rb +22 -0
  68. data/lib/html2rss/request_session.rb +4 -0
  69. data/lib/html2rss/scoring/anchor_score.rb +34 -0
  70. data/lib/html2rss/scoring/cluster_scorer.rb +87 -0
  71. data/lib/html2rss/scoring/container_assessor.rb +83 -0
  72. data/lib/html2rss/scoring/engine.rb +101 -0
  73. data/lib/html2rss/scoring/link_resolver.rb +72 -0
  74. data/lib/html2rss/scoring/observation.rb +53 -0
  75. data/lib/html2rss/scoring/ranked_segment.rb +45 -0
  76. data/lib/html2rss/scoring/score.rb +30 -0
  77. data/lib/html2rss/scoring.rb +33 -0
  78. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +10 -1
  79. data/lib/html2rss/selectors.rb +26 -24
  80. data/lib/html2rss/sst/attrs.rb +91 -0
  81. data/lib/html2rss/sst/document.rb +23 -0
  82. data/lib/html2rss/sst/index.rb +112 -0
  83. data/lib/html2rss/sst/node.rb +147 -0
  84. data/lib/html2rss/sst/normalizer.rb +171 -0
  85. data/lib/html2rss/sst/tags.rb +26 -0
  86. data/lib/html2rss/sst/text.rb +81 -0
  87. data/lib/html2rss/sst.rb +8 -0
  88. data/lib/html2rss/status.rb +132 -0
  89. data/lib/html2rss/version.rb +1 -1
  90. data/lib/html2rss.rb +41 -29
  91. data/schema/html2rss-config.schema.json +20 -0
  92. metadata +45 -19
  93. data/lib/html2rss/auto_source/discovery/dom_clustering/group_scorer.rb +0 -80
  94. data/lib/html2rss/auto_source/discovery/dom_clustering/overlap_resolver.rb +0 -86
  95. data/lib/html2rss/auto_source/discovery/dom_clustering.rb +0 -119
  96. data/lib/html2rss/auto_source/discovery/list_candidates.rb +0 -94
  97. data/lib/html2rss/auto_source/discovery/semantic_anchor_candidates.rb +0 -219
  98. data/lib/html2rss/auto_source/discovery/semantic_containers.rb +0 -71
  99. data/lib/html2rss/auto_source/discovery/sitemap.rb +0 -159
  100. data/lib/html2rss/auto_source/discovery.rb +0 -14
  101. data/lib/html2rss/auto_source/link_heuristics/anchor_signals.rb +0 -28
  102. data/lib/html2rss/auto_source/link_heuristics/container_assessor.rb +0 -106
  103. data/lib/html2rss/auto_source/link_heuristics/container_signals.rb +0 -80
  104. data/lib/html2rss/auto_source/link_heuristics/destination_facts.rb +0 -42
  105. data/lib/html2rss/auto_source/link_heuristics/href_extractor.rb +0 -38
  106. data/lib/html2rss/auto_source/link_heuristics/path_classifier.rb +0 -221
  107. data/lib/html2rss/auto_source/link_heuristics/text_classifier.rb +0 -66
  108. data/lib/html2rss/auto_source/link_heuristics.rb +0 -139
  109. 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
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ ##
5
+ # Destination URL / anchor-text classification shared by Scoring.
6
+ module LinkDestination
7
+ end
8
+ end