html2rss 0.24.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 (89) hide show
  1. checksums.yaml +4 -4
  2. data/lib/html2rss/auto_source/scraper/html.rb +68 -145
  3. data/lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb +116 -0
  4. data/lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb +63 -0
  5. data/lib/html2rss/auto_source/scraper/json_state/document_scanner.rb +179 -0
  6. data/lib/html2rss/auto_source/scraper/json_state/value_finder.rb +55 -0
  7. data/lib/html2rss/auto_source/scraper/json_state.rb +0 -379
  8. data/lib/html2rss/auto_source/scraper/semantic_html/entry_deduplicator.rb +35 -30
  9. data/lib/html2rss/auto_source/scraper/semantic_html.rb +40 -120
  10. data/lib/html2rss/auto_source/scraper/sitemap/parser.rb +161 -0
  11. data/lib/html2rss/auto_source/scraper/sitemap.rb +10 -7
  12. data/lib/html2rss/auto_source/scraper/wordpress_api.rb +6 -2
  13. data/lib/html2rss/auto_source/scraper.rb +81 -44
  14. data/lib/html2rss/auto_source/segment.rb +29 -0
  15. data/lib/html2rss/auto_source/segmenter/cluster.rb +163 -0
  16. data/lib/html2rss/auto_source/segmenter/list.rb +101 -0
  17. data/lib/html2rss/auto_source/segmenter/primary_link.rb +81 -0
  18. data/lib/html2rss/auto_source/segmenter/semantic.rb +104 -0
  19. data/lib/html2rss/auto_source/segmenter.rb +66 -0
  20. data/lib/html2rss/auto_source.rb +74 -21
  21. data/lib/html2rss/cli.rb +8 -13
  22. data/lib/html2rss/config/auto_source_contract.rb +2 -0
  23. data/lib/html2rss/config/request_controls.rb +33 -0
  24. data/lib/html2rss/config/validator.rb +15 -8
  25. data/lib/html2rss/config.rb +6 -2
  26. data/lib/html2rss/feed_builder/rss.rb +17 -5
  27. data/lib/html2rss/feed_pipeline.rb +4 -0
  28. data/lib/html2rss/feed_result.rb +1 -1
  29. data/lib/html2rss/html/article_extractor/category_extractor.rb +10 -36
  30. data/lib/html2rss/html/article_extractor/date_extractor.rb +2 -7
  31. data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +5 -50
  32. data/lib/html2rss/html/article_extractor/image_extractor.rb +5 -16
  33. data/lib/html2rss/html/article_rules/category.rb +55 -0
  34. data/lib/html2rss/html/article_rules/date.rb +25 -0
  35. data/lib/html2rss/html/article_rules/enclosure.rb +72 -0
  36. data/lib/html2rss/html/article_rules/image.rb +109 -0
  37. data/lib/html2rss/html/article_rules.rb +10 -0
  38. data/lib/html2rss/html/rendering/audio_renderer.rb +2 -12
  39. data/lib/html2rss/html/rendering/escaped_attributes.rb +27 -0
  40. data/lib/html2rss/html/rendering/image_renderer.rb +2 -12
  41. data/lib/html2rss/html/rendering/pdf_renderer.rb +2 -8
  42. data/lib/html2rss/html/rendering/video_renderer.rb +2 -12
  43. data/lib/html2rss/html/sst_article_extractor.rb +279 -0
  44. data/lib/html2rss/link_destination/destination_facts.rb +40 -0
  45. data/lib/html2rss/link_destination/noise_policy.rb +79 -0
  46. data/lib/html2rss/link_destination/path_classifier.rb +205 -0
  47. data/lib/html2rss/link_destination/text_classifier.rb +64 -0
  48. data/lib/html2rss/link_destination.rb +8 -0
  49. data/lib/html2rss/request_service/faraday_strategy.rb +42 -1
  50. data/lib/html2rss/request_service/network_guard.rb +5 -3
  51. data/lib/html2rss/scoring/anchor_score.rb +34 -0
  52. data/lib/html2rss/scoring/cluster_scorer.rb +87 -0
  53. data/lib/html2rss/scoring/container_assessor.rb +83 -0
  54. data/lib/html2rss/scoring/engine.rb +101 -0
  55. data/lib/html2rss/scoring/link_resolver.rb +72 -0
  56. data/lib/html2rss/scoring/observation.rb +53 -0
  57. data/lib/html2rss/scoring/ranked_segment.rb +45 -0
  58. data/lib/html2rss/scoring/score.rb +30 -0
  59. data/lib/html2rss/scoring.rb +33 -0
  60. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +10 -1
  61. data/lib/html2rss/selectors.rb +0 -20
  62. data/lib/html2rss/sst/attrs.rb +91 -0
  63. data/lib/html2rss/sst/document.rb +23 -0
  64. data/lib/html2rss/sst/index.rb +112 -0
  65. data/lib/html2rss/sst/node.rb +147 -0
  66. data/lib/html2rss/sst/normalizer.rb +171 -0
  67. data/lib/html2rss/sst/tags.rb +26 -0
  68. data/lib/html2rss/sst/text.rb +81 -0
  69. data/lib/html2rss/sst.rb +8 -0
  70. data/lib/html2rss/version.rb +1 -1
  71. data/lib/html2rss.rb +26 -26
  72. data/schema/html2rss-config.schema.json +20 -0
  73. metadata +42 -18
  74. data/lib/html2rss/auto_source/discovery/dom_clustering/group_scorer.rb +0 -80
  75. data/lib/html2rss/auto_source/discovery/dom_clustering/overlap_resolver.rb +0 -86
  76. data/lib/html2rss/auto_source/discovery/dom_clustering.rb +0 -119
  77. data/lib/html2rss/auto_source/discovery/list_candidates.rb +0 -94
  78. data/lib/html2rss/auto_source/discovery/semantic_anchor_candidates.rb +0 -219
  79. data/lib/html2rss/auto_source/discovery/semantic_containers.rb +0 -71
  80. data/lib/html2rss/auto_source/discovery/sitemap.rb +0 -159
  81. data/lib/html2rss/auto_source/discovery.rb +0 -14
  82. data/lib/html2rss/auto_source/link_heuristics/anchor_signals.rb +0 -28
  83. data/lib/html2rss/auto_source/link_heuristics/container_assessor.rb +0 -106
  84. data/lib/html2rss/auto_source/link_heuristics/container_signals.rb +0 -80
  85. data/lib/html2rss/auto_source/link_heuristics/destination_facts.rb +0 -42
  86. data/lib/html2rss/auto_source/link_heuristics/href_extractor.rb +0 -38
  87. data/lib/html2rss/auto_source/link_heuristics/path_classifier.rb +0 -221
  88. data/lib/html2rss/auto_source/link_heuristics/text_classifier.rb +0 -66
  89. data/lib/html2rss/auto_source/link_heuristics.rb +0 -139
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ module Html
5
+ module ArticleRules
6
+ ##
7
+ # DOM-agnostic image URL selection (srcset sizing, CSS background urls).
8
+ #
9
+ # Uses string scans instead of Regexp: CodeQL rb/polynomial-redos flags the
10
+ # prior patterns on uncontrolled HTML attributes, and length guards alone
11
+ # do not clear the query.
12
+ module Image
13
+ # Skip absurdly large attributes before scanning.
14
+ MAX_ATTR_CHARS = 4_096
15
+ # Optional quoting around a CSS `url(...)` value.
16
+ QUOTES = ['"', "'"].freeze
17
+ # Srcset width/height descriptor suffixes.
18
+ UNITS = %w[w h].freeze
19
+ # HTML/CSS whitespace treated as srcset token separators.
20
+ WS = [' ', "\t", "\n", "\r", "\f"].freeze
21
+
22
+ class << self
23
+ ##
24
+ # @param srcset_strings [Array<String>] raw srcset attribute values
25
+ # @return [String, nil] URL of the largest width/height candidate
26
+ def largest_from_srcsets(srcset_strings)
27
+ by_size = {}
28
+ Array(srcset_strings).each { |srcset| merge_srcset!(by_size, srcset) }
29
+ by_size[by_size.keys.max]
30
+ end
31
+
32
+ ##
33
+ # @param style_strings [Array<String>] CSS style attribute values
34
+ # @return [String, nil] longest non-data background url
35
+ def best_from_styles(style_strings)
36
+ Array(style_strings)
37
+ .filter_map { |style| style_url(style) }
38
+ .reject { |src| src.start_with?('data:') }
39
+ .max_by(&:size)
40
+ end
41
+
42
+ private
43
+
44
+ def merge_srcset!(by_size, srcset)
45
+ s = srcset.to_s
46
+ return if s.length > MAX_ATTR_CHARS
47
+
48
+ each_srcset_pair(s) do |url, size|
49
+ next if url.start_with?('data:')
50
+
51
+ by_size[size] = url
52
+ end
53
+ end
54
+
55
+ # URL token + `Nw`/`Nh` pairs. Commas may appear inside URLs; a candidate
56
+ # boundary is whitespace before a width/height descriptor.
57
+ def each_srcset_pair(string) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
58
+ i = 0
59
+ len = string.length
60
+ while i < len
61
+ i += 1 while i < len && sep?(string[i])
62
+ break if i >= len
63
+
64
+ start = i
65
+ i += 1 while i < len && !ws?(string[i])
66
+ url = string[start, i - start]
67
+
68
+ i += 1 while i < len && ws?(string[i])
69
+ size_start = i
70
+ i += 1 while i < len && string[i].between?('0', '9')
71
+ next unless i > size_start && i < len && UNITS.include?(string[i])
72
+
73
+ yield url, string[size_start, i - size_start].to_i
74
+ i += 1
75
+ end
76
+ end
77
+
78
+ def style_url(style)
79
+ s = style.to_s
80
+ return if s.length > MAX_ATTR_CHARS
81
+
82
+ pos = s.index('url(')
83
+ return unless pos
84
+
85
+ extract_url_body(s, pos + 4)
86
+ end
87
+
88
+ def extract_url_body(string, index)
89
+ quote = string[index]
90
+ if QUOTES.include?(quote)
91
+ slice_until(string, index + 1, quote)
92
+ else
93
+ slice_until(string, index, ')')
94
+ end
95
+ end
96
+
97
+ def slice_until(string, start, closer)
98
+ close = string.index(closer, start)
99
+ string[start, close - start] if close
100
+ end
101
+
102
+ def sep?(char) = ws?(char) || char == ','
103
+
104
+ def ws?(char) = WS.include?(char)
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ module Html
5
+ ##
6
+ # DOM-agnostic article field rules shared by Nokogiri and SST adapters.
7
+ module ArticleRules
8
+ end
9
+ end
10
+ end
@@ -1,12 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'cgi'
4
-
5
3
  module Html2rss
6
4
  module Html
7
5
  module Rendering
8
6
  # Renders an HTML <audio> tag from a URL and type.
9
7
  class AudioRenderer
8
+ include EscapedAttributes
9
+
10
10
  # @param url [String, Html2rss::Url] media URL for the audio source
11
11
  # @param type [String] MIME type for the audio source
12
12
  def initialize(url:, type:)
@@ -22,16 +22,6 @@ module Html2rss
22
22
  '</audio>'
23
23
  ].join
24
24
  end
25
-
26
- private
27
-
28
- def escaped_url
29
- CGI.escapeHTML(@url.to_s)
30
- end
31
-
32
- def escaped_type
33
- CGI.escapeHTML(@type.to_s)
34
- end
35
25
  end
36
26
  end
37
27
  end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cgi'
4
+
5
+ module Html2rss
6
+ module Html
7
+ module Rendering
8
+ ##
9
+ # Shared HTML attribute escaping for media renderers.
10
+ module EscapedAttributes
11
+ private
12
+
13
+ def escaped_url
14
+ CGI.escapeHTML(@url.to_s)
15
+ end
16
+
17
+ def escaped_type
18
+ CGI.escapeHTML(@type.to_s)
19
+ end
20
+
21
+ def escaped_title
22
+ CGI.escapeHTML(@title.to_s)
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -1,12 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'cgi'
4
-
5
3
  module Html2rss
6
4
  module Html
7
5
  module Rendering
8
6
  # Renders an HTML <img> tag from a URL and title.
9
7
  class ImageRenderer
8
+ include EscapedAttributes
9
+
10
10
  # @param url [String, Html2rss::Url] image URL for the src attribute
11
11
  # @param title [String, nil] title/alt text for the image
12
12
  def initialize(url:, title:)
@@ -27,16 +27,6 @@ module Html2rss
27
27
  ]
28
28
  "<img #{attributes.join(' ')}>"
29
29
  end
30
-
31
- private
32
-
33
- def escaped_url
34
- CGI.escapeHTML(@url.to_s)
35
- end
36
-
37
- def escaped_title
38
- CGI.escapeHTML(@title.to_s)
39
- end
40
30
  end
41
31
  end
42
32
  end
@@ -1,12 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'cgi'
4
-
5
3
  module Html2rss
6
4
  module Html
7
5
  module Rendering
8
6
  # Renders an HTML <iframe> for PDF documents.
9
7
  class PdfRenderer
8
+ include EscapedAttributes
9
+
10
10
  # @param url [String, Html2rss::Url] PDF URL rendered in the iframe
11
11
  def initialize(url:)
12
12
  @url = url
@@ -24,12 +24,6 @@ module Html2rss
24
24
  ]
25
25
  "<iframe #{attributes.join(' ')}></iframe>"
26
26
  end
27
-
28
- private
29
-
30
- def escaped_url
31
- CGI.escapeHTML(@url.to_s)
32
- end
33
27
  end
34
28
  end
35
29
  end
@@ -1,12 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'cgi'
4
-
5
3
  module Html2rss
6
4
  module Html
7
5
  module Rendering
8
6
  # Renders an HTML <video> tag from a URL and type.
9
7
  class VideoRenderer
8
+ include EscapedAttributes
9
+
10
10
  # @param url [String, Html2rss::Url] media URL for the video source
11
11
  # @param type [String] MIME type for the video source
12
12
  def initialize(url:, type:)
@@ -22,16 +22,6 @@ module Html2rss
22
22
  '</video>'
23
23
  ].join
24
24
  end
25
-
26
- private
27
-
28
- def escaped_url
29
- CGI.escapeHTML(@url.to_s)
30
- end
31
-
32
- def escaped_type
33
- CGI.escapeHTML(@type.to_s)
34
- end
35
25
  end
36
26
  end
37
27
  end
@@ -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