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,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
@@ -66,14 +66,55 @@ module Html2rss
66
66
  faraday_request(response_guard, deadline:, streaming_buffer: false, consume_budget: false)
67
67
  end
68
68
 
69
+ ##
70
+ # Validates the remote socket peer IP during Net::HTTP start.
71
+ module PeerIpValidator
72
+ module_function
73
+
74
+ # @param http [Net::HTTP] connection to configure
75
+ # @param policy [Policy] request policy
76
+ # @return [void]
77
+ def install!(http, policy:)
78
+ orig_start = http.method(:start)
79
+ http.define_singleton_method(:start) do |&block|
80
+ orig_start.call do |opened_http|
81
+ PeerIpValidator.validate!(opened_http, policy:)
82
+ block.call(opened_http)
83
+ end
84
+ end
85
+ end
86
+
87
+ # @param opened_http [Net::HTTP] active connection
88
+ # @param policy [Policy] request policy
89
+ # @return [void]
90
+ def validate!(opened_http, policy:)
91
+ scheme = opened_http.use_ssl? ? 'https' : 'http'
92
+ url = Html2rss::Url.from_absolute("#{scheme}://#{opened_http.address}:#{opened_http.port}")
93
+ policy.validate_remote_ip!(ip: peer_ip_for(opened_http), url:)
94
+ end
95
+
96
+ # @param opened_http [Net::HTTP]
97
+ # @return [String, nil]
98
+ def peer_ip_for(opened_http)
99
+ sock = opened_http.instance_variable_get(:@socket)
100
+ io = sock.respond_to?(:io) ? sock.io : sock
101
+ peeraddr = io.respond_to?(:peeraddr) ? io.peeraddr : nil
102
+ peeraddr&.[](3) || peeraddr&.[](2)
103
+ end
104
+ end
105
+
106
+ # rubocop:disable Metrics/AbcSize
69
107
  def client
70
108
  @client ||= Faraday.new(url: ctx.url.to_s, headers: ctx.headers) do |faraday|
71
109
  faraday.use Faraday::FollowRedirects::Middleware, limit: ctx.policy.max_redirects, callback: redirect_callback
72
110
  faraday.request :gzip
73
111
  faraday.use StreamingBodyMiddleware
74
- faraday.adapter Faraday.default_adapter
112
+ faraday.adapter Faraday.default_adapter do |http|
113
+ PeerIpValidator.install!(http, policy: ctx.policy)
114
+ end
75
115
  end
76
116
  end
117
+ # rubocop:enable Metrics/AbcSize
77
118
 
78
119
  def apply_timeouts(request, deadline:)
79
120
  remaining_timeout = remaining_timeout_seconds(deadline)
@@ -48,7 +48,10 @@ module Html2rss
48
48
  def enforce_public_network!(url)
49
49
  host = url.host
50
50
  return if allow_private_networks?
51
- return unless blocked_host?(host) || resolved_ip_addresses(host).any? { |address| blocked_ip?(address) }
51
+ raise PrivateNetworkDenied, "Private network target denied for #{url}" if blocked_host?(host)
52
+
53
+ addresses = resolved_ip_addresses(host)
54
+ return unless addresses.empty? || addresses.any? { |address| blocked_ip?(address) }
52
55
 
53
56
  raise PrivateNetworkDenied, "Private network target denied for #{url}"
54
57
  end
@@ -62,9 +65,8 @@ module Html2rss
62
65
  # @raise [PrivateNetworkDenied] if the response came from a blocked address
63
66
  def validate_remote_ip!(ip:, url:)
64
67
  return if allow_private_networks?
65
- return if ip.nil? || ip.empty?
66
68
 
67
- parsed_ip = parse_ip(ip)
69
+ parsed_ip = ip && !ip.to_s.empty? ? parse_ip(ip) : nil
68
70
  raise PrivateNetworkDenied, "Remote IP could not be validated for #{url}" unless parsed_ip
69
71
  return unless blocked_ip?(parsed_ip)
70
72
 
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ module Scoring
5
+ ##
6
+ # Primary-link ranking weights (sole home; FeatureId-aligned).
7
+ module AnchorScore
8
+ # Signal → integer weight map for primary-link ranking.
9
+ WEIGHTS = {
10
+ heading_anchor: 100,
11
+ heading_text_match: 20,
12
+ meaningful_text: 10,
13
+ content_like_destination: 10
14
+ }.freeze
15
+
16
+ module_function
17
+
18
+ ##
19
+ # @param heading_anchor [Boolean]
20
+ # @param heading_text_match [Boolean]
21
+ # @param meaningful_text [Boolean]
22
+ # @param content_like_destination [Boolean]
23
+ # @return [Integer]
24
+ def score(heading_anchor:, heading_text_match:, meaningful_text:, content_like_destination:)
25
+ total = 0
26
+ total += WEIGHTS[:heading_anchor] if heading_anchor
27
+ total += WEIGHTS[:heading_text_match] if heading_text_match
28
+ total += WEIGHTS[:meaningful_text] if meaningful_text
29
+ total += WEIGHTS[:content_like_destination] if content_like_destination
30
+ total
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ module Scoring
5
+ ##
6
+ # Scores DomClustering-style node groups (heading/time/date/word density).
7
+ # Sole home for cluster-group ranking weights.
8
+ class ClusterScorer
9
+ # Minimum average visible words before a group is eligible.
10
+ MIN_AVG_WORDS = 5
11
+ # Bonus when any node has a heading or bold title class.
12
+ HEADING_BONUS = 20
13
+ # Bonus when any node has a time/datetime marker.
14
+ TIME_BONUS = 20
15
+ # Bonus when any node has date-like visible text.
16
+ DATE_BONUS = 40
17
+
18
+ def initialize
19
+ @text_words = {}.compare_by_identity
20
+ @has_date = {}.compare_by_identity
21
+ end
22
+
23
+ ##
24
+ # @param groups [Hash{String => Array<SST::Node>}]
25
+ # @return [Array<SST::Node>]
26
+ def select_best_group(groups)
27
+ best_nodes = []
28
+ best_score = -1
29
+
30
+ groups.each_value do |nodes|
31
+ score = score_group(nodes)
32
+ next if score.negative?
33
+
34
+ (best_nodes = nodes) && (best_score = score) if score > best_score
35
+ end
36
+ best_nodes
37
+ end
38
+
39
+ ##
40
+ # @param nodes [Array<SST::Node>]
41
+ # @return [Float]
42
+ def avg_words(nodes)
43
+ nodes.sum { |n| text_words(n) } / nodes.size.to_f
44
+ end
45
+
46
+ private
47
+
48
+ def score_group(nodes)
49
+ avg_w = avg_words(nodes)
50
+ return -1 if avg_w < MIN_AVG_WORDS
51
+
52
+ score = nodes.size + (avg_w / 5.0)
53
+ score += HEADING_BONUS if nodes_heading?(nodes)
54
+ score += TIME_BONUS if nodes_time?(nodes)
55
+ score += DATE_BONUS if nodes_date?(nodes)
56
+ score
57
+ end
58
+
59
+ def nodes_heading?(nodes)
60
+ nodes.any? do |n|
61
+ n.find(&:heading?) ||
62
+ n.attrs.class_names.any? { |c| c.match?(/font-bold|font-semibold/) }
63
+ end
64
+ end
65
+
66
+ def nodes_time?(nodes)
67
+ nodes.any? { |n| n.find { |d| d.name == :time || d.attrs.datetime } }
68
+ end
69
+
70
+ def nodes_date?(nodes)
71
+ nodes.any? { |n| date?(n) }
72
+ end
73
+
74
+ def text_words(node)
75
+ @text_words[node] ||= node.word_count
76
+ end
77
+
78
+ def date?(node)
79
+ @has_date[node] ||= begin
80
+ text = node.visible_text.to_s
81
+ text.match?(%r{\b\d{4}[-/]\d{2}[-/]\d{2}\b}) ||
82
+ text.match?(/\b(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\b/i)
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ module Scoring
5
+ ##
6
+ # Observes an SST container and builds typed {Observation} score inputs.
7
+ class ContainerAssessor
8
+ # Microdata itemprop values treated as publish/update markers.
9
+ PUBLISH_ITEMPROPS = %w[datePublished dateModified].freeze
10
+
11
+ # Matches content-like tokens in class/id strings.
12
+ CONTENT_TOKEN_REGEXP = begin
13
+ words = LinkDestination::PathClassifier::SEGMENT_SETS.fetch(:content)
14
+ /(?:^|\s|[-_])(#{Regexp.union(words.to_a).source})(?:\s|[-_]|$)/i
15
+ end.freeze
16
+
17
+ # Matches utility/junk tokens in class/id strings.
18
+ JUNK_TOKEN_REGEXP = begin
19
+ words = LinkDestination::PathClassifier::SEGMENT_SETS.fetch(:utility)
20
+ /(?:^|\s|[-_])(#{Regexp.union(words.to_a).source})(?:\s|[-_]|$)/i
21
+ end.freeze
22
+
23
+ # @param text_classifier [LinkDestination::TextClassifier]
24
+ def initialize(text_classifier: LinkDestination::TextClassifier.new)
25
+ @text_classifier = text_classifier
26
+ end
27
+
28
+ ##
29
+ # @param container [SST::Node]
30
+ # @param selected_anchor [SST::Node, nil]
31
+ # @param destination_facts [LinkDestination::DestinationFacts, nil]
32
+ # @return [Observation]
33
+ def call(container, selected_anchor, destination_facts:) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
34
+ title = entry_title(container, selected_anchor)
35
+ tokens = "#{container.attrs.class_attr} #{container.attrs.id}"
36
+
37
+ Observation.new(
38
+ title_word_count: word_count(title),
39
+ path_length: destination_facts&.url&.path.to_s.length,
40
+ content_path: destination_facts&.content_path,
41
+ publish_marker: publish_marker?(container),
42
+ descriptive_context: descriptive_context?(container.visible_text, title),
43
+ article_container: container.name == :article,
44
+ content_tokens: tokens.match?(CONTENT_TOKEN_REGEXP),
45
+ junk_tokens: tokens.match?(JUNK_TOKEN_REGEXP),
46
+ utility_prefix_title: @text_classifier.utility_prefix?(title),
47
+ recommended_title: @text_classifier.recommended?(title),
48
+ utility_path: destination_facts&.utility_path,
49
+ strong_post_suffix: destination_facts&.strong_post_suffix,
50
+ shallow: destination_facts&.shallow,
51
+ high_confidence_junk_path: destination_facts&.high_confidence_junk_path,
52
+ high_confidence_utility_destination: destination_facts&.high_confidence_utility_destination,
53
+ selected_anchor_present: !selected_anchor.nil?
54
+ )
55
+ end
56
+
57
+ private
58
+
59
+ def publish_marker?(container)
60
+ !!container.find do |n|
61
+ n.name == :time ||
62
+ n.attrs.datetime ||
63
+ PUBLISH_ITEMPROPS.include?(n.attrs.itemprop.to_s)
64
+ end
65
+ end
66
+
67
+ def descriptive_context?(container_text, title)
68
+ snippet = container_text.to_s.sub(/\A#{Regexp.escape(title.to_s)}/i, '')
69
+ snippet.length > 30 && word_count(snippet) >= 8
70
+ end
71
+
72
+ def entry_title(container, selected_anchor)
73
+ heading = container.find(&:heading?)
74
+ source = heading || selected_anchor
75
+ source ? source.visible_text.to_s.strip : ''
76
+ end
77
+
78
+ def word_count(text)
79
+ text.to_s.scan(/\p{Alnum}+/).size
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ module Scoring
5
+ ##
6
+ # Ranks AutoSource::Segment instances with an explicit feature registry (no send).
7
+ class Engine
8
+ # Soft cap after score-floor filtering (lazy extraction guard; breadth > blind top-50).
9
+ TOP_K = 99
10
+ # Minimum composite score retained by {#rank_top} (precision floor).
11
+ SCORE_FLOOR = 0.0
12
+
13
+ # Quality feature triples: [FeatureId, predicate, weight].
14
+ QUALITY_RULES = [
15
+ [:title_word_count_ge3, ->(o) { o.title_word_count >= 3 }, 40],
16
+ [:title_word_count_ge7, ->(o) { o.title_word_count >= 7 }, 15],
17
+ [:path_length_gt6, ->(o) { o.path_length > 6 }, 20],
18
+ [:content_path, lambda(&:content_path), 15],
19
+ [:publish_marker, lambda(&:publish_marker), 15],
20
+ [:descriptive_context, lambda(&:descriptive_context), 10],
21
+ [:article_container, lambda(&:article_container), 10],
22
+ [:content_tokens, lambda(&:content_tokens), 10]
23
+ ].freeze
24
+
25
+ # Junk feature triples: [FeatureId, predicate, weight].
26
+ JUNK_RULES = [
27
+ [:non_content_utility_path, ->(o) { o.utility_path && !o.content_path && !o.strong_post_suffix }, 25],
28
+ [:utility_prefix_title_short, ->(o) { o.utility_prefix_title && o.title_word_count <= 6 }, 15],
29
+ [:shallow, lambda(&:shallow), 10],
30
+ [:weak_container, ->(o) { !o.publish_marker && !o.descriptive_context }, 10],
31
+ [:recommended_title_non_content, ->(o) { o.recommended_title && !o.content_path }, 10],
32
+ [:high_confidence_junk_path, lambda(&:high_confidence_junk_path), 5],
33
+ [:junk_tokens, lambda(&:junk_tokens), 15]
34
+ ].freeze
35
+
36
+ (QUALITY_RULES + JUNK_RULES).each { |feature_id, _, _| FeatureId.assert!(feature_id) }
37
+
38
+ # @param link_resolver [LinkResolver]
39
+ def initialize(link_resolver:)
40
+ @link_resolver = link_resolver
41
+ @assessor = ContainerAssessor.new(text_classifier: link_resolver.text_classifier)
42
+ end
43
+
44
+ ##
45
+ # @param segments [Array<Html2rss::AutoSource::Segment>]
46
+ # @return [Array<RankedSegment>] sorted by composite desc, position asc; hard-junk dropped
47
+ def rank(segments)
48
+ segments.filter_map { |segment| rank_one(segment) }
49
+ .sort_by { |ranked| [-ranked.final_score, ranked.position] }
50
+ end
51
+
52
+ ##
53
+ # Filters hard-junk but preserves discovery order (list/cluster scrapers).
54
+ #
55
+ # @param segments [Array<Html2rss::AutoSource::Segment>]
56
+ # @param limit [Integer]
57
+ # @return [Array<RankedSegment>]
58
+ def select_eligible(segments, limit: TOP_K)
59
+ segments.filter_map { |segment| rank_one(segment, hard_junk_mode: :lenient) }
60
+ .sort_by(&:position)
61
+ .first(limit)
62
+ end
63
+
64
+ ##
65
+ # @param segments [Array<Html2rss::AutoSource::Segment>]
66
+ # @param limit [Integer]
67
+ # @return [Array<RankedSegment>] score-floor filtered, then capped
68
+ def rank_top(segments, limit: TOP_K)
69
+ rank(segments).select { |ranked| ranked.final_score >= SCORE_FLOOR }.first(limit)
70
+ end
71
+
72
+ private
73
+
74
+ def rank_one(segment, hard_junk_mode: :strict) # rubocop:disable Metrics/MethodLength
75
+ facts = segment.primary_link ? @link_resolver.destination_facts(segment.primary_link) : nil
76
+ obs = @assessor.call(segment.root_node, segment.primary_link, destination_facts: facts)
77
+ return if obs.hard_junk?(mode: hard_junk_mode)
78
+
79
+ quality, quality_parts = apply_rules(QUALITY_RULES, obs)
80
+ junk, junk_parts = apply_rules(JUNK_RULES, obs)
81
+ RankedSegment.build(
82
+ segment:,
83
+ score: Score.build(
84
+ composite: quality - junk,
85
+ quality:,
86
+ junk:,
87
+ breakdown: quality_parts.merge(junk_parts)
88
+ )
89
+ )
90
+ end
91
+
92
+ def apply_rules(rules, obs)
93
+ parts = {}
94
+ rules.each do |feature_id, predicate, weight|
95
+ parts[feature_id] = weight if predicate.call(obs)
96
+ end
97
+ [parts.values.sum, parts]
98
+ end
99
+ end
100
+ end
101
+ end