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
@@ -10,7 +10,7 @@ module Html2rss
10
10
  # Default Botasaurus scrape options when no explicit config is provided.
11
11
  DEFAULT_OPTIONS = {
12
12
  navigation_mode: 'auto',
13
- max_retries: 2,
13
+ max_retries: 1,
14
14
  headless: false
15
15
  }.freeze
16
16
 
@@ -30,6 +30,17 @@ module Html2rss
30
30
  lang
31
31
  ].freeze
32
32
 
33
+ # Allowlisted upstream response keys exposed as Response#transport_meta.
34
+ META_KEYS = %w[
35
+ request_id strategy_used render_ms challenge_detected blocked_detected attempts error_category
36
+ ].freeze
37
+
38
+ # Remaining seconds at or below which Botasaurus retries are disabled.
39
+ TIGHT_BUDGET_SECONDS = 12
40
+
41
+ # Seconds reserved from remaining budget before setting wait_timeout_seconds.
42
+ BUDGET_WAIT_RESERVE_SECONDS = 2
43
+
33
44
  # Parsed Botasaurus response wrapper.
34
45
  class ParsedResponse
35
46
  # Fallback headers when upstream omits response headers.
@@ -90,6 +101,9 @@ module Html2rss
90
101
  # @return [String, nil] final URL reported by upstream
91
102
  def final_url = payload['final_url']
92
103
 
104
+ # @return [Hash{String => Object}] allowlisted upstream telemetry (frozen)
105
+ def transport_meta = payload.slice(*META_KEYS).compact.freeze
106
+
93
107
  private
94
108
 
95
109
  attr_reader :payload, :transport_status
@@ -109,6 +123,7 @@ module Html2rss
109
123
  ##
110
124
  # @param url [Html2rss::Url] canonical URL to scrape
111
125
  # @param options [Hash] validated request.botasaurus options
126
+ # @param remaining_timeout_seconds [Numeric, nil] shared request budget remainder for clamps
112
127
  # @option options [String] :navigation_mode
113
128
  # @option options [Integer] :max_retries
114
129
  # @option options [String] :wait_for_selector
@@ -121,14 +136,15 @@ module Html2rss
121
136
  # @option options [String] :user_agent
122
137
  # @option options [Array<Integer>] :window_size
123
138
  # @option options [String] :lang
124
- def initialize(url:, options: {})
139
+ def initialize(url:, options: {}, remaining_timeout_seconds: nil)
125
140
  @url = url
126
141
  @options = options
142
+ @remaining_timeout_seconds = remaining_timeout_seconds
127
143
  end
128
144
 
129
- # @return [Hash] payload for POST /scrape
145
+ # @return [Hash] payload for POST /scrape (budget-clamped when remaining is known)
130
146
  def request_payload
131
- DEFAULT_OPTIONS.merge(filtered_options).merge(url: url.to_s)
147
+ DEFAULT_OPTIONS.merge(filtered_options).merge(url: url.to_s).then { clamp_for_budget(_1) }
132
148
  end
133
149
 
134
150
  # @param transport_response [Faraday::Response] upstream HTTP response
@@ -145,13 +161,25 @@ module Html2rss
145
161
 
146
162
  private
147
163
 
148
- attr_reader :url, :options
164
+ attr_reader :url, :options, :remaining_timeout_seconds
149
165
 
150
166
  def filtered_options
151
167
  OPTION_KEYS.each_with_object({}) do |key, normalized|
152
168
  normalized[key] = options[key] if options.key?(key)
153
169
  end
154
170
  end
171
+
172
+ def clamp_for_budget(payload)
173
+ remaining = remaining_timeout_seconds
174
+ return payload if remaining.nil?
175
+
176
+ clamped = payload.dup
177
+ clamped[:max_retries] = 0 if remaining <= TIGHT_BUDGET_SECONDS
178
+ budget_wait = [1, (remaining - BUDGET_WAIT_RESERVE_SECONDS).floor].max
179
+ configured = clamped[:wait_timeout_seconds]
180
+ clamped[:wait_timeout_seconds] = configured ? [configured, budget_wait].min : budget_wait
181
+ clamped
182
+ end
155
183
  end
156
184
  end
157
185
  end
@@ -27,7 +27,8 @@ module Html2rss
27
27
  body: parsed_response.html,
28
28
  headers: parsed_response.headers,
29
29
  url: response_url(parsed_response.final_url),
30
- status: parsed_response.status
30
+ status: parsed_response.status,
31
+ transport_meta: parsed_response.transport_meta
31
32
  )
32
33
  end
33
34
 
@@ -52,7 +53,11 @@ module Html2rss
52
53
  end
53
54
 
54
55
  def contract
55
- @contract ||= BotasaurusContract.new(url: ctx.url, options: ctx.request.fetch(:botasaurus, {}))
56
+ @contract ||= BotasaurusContract.new(
57
+ url: ctx.url,
58
+ options: ctx.request.fetch(:botasaurus, {}),
59
+ remaining_timeout_seconds: attempt_timeout_seconds
60
+ )
56
61
  end
57
62
 
58
63
  def client
@@ -60,9 +65,13 @@ module Html2rss
60
65
  end
61
66
 
62
67
  def request_options
63
- timeout = ctx.budget.effective_timeout_seconds(fallback: ctx.policy.total_timeout_seconds)
68
+ { timeout: attempt_timeout_seconds.to_i }
69
+ end
64
70
 
65
- { timeout: timeout.to_i }
71
+ def attempt_timeout_seconds
72
+ @attempt_timeout_seconds ||= ctx.budget.effective_timeout_seconds(
73
+ fallback: ctx.policy.total_timeout_seconds
74
+ )
66
75
  end
67
76
 
68
77
  def content_type_header
@@ -82,11 +82,16 @@ module Html2rss
82
82
  def remaining_timeout_seconds
83
83
  return unless @total_timeout_seconds
84
84
 
85
- elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - @start_time
86
- remaining = @total_timeout_seconds - elapsed
85
+ remaining = @total_timeout_seconds - elapsed_seconds
87
86
  [remaining, 0.0].max
88
87
  end
89
88
 
89
+ ##
90
+ # @return [Float] seconds since this budget was created
91
+ def elapsed_seconds
92
+ Process.clock_gettime(Process::CLOCK_MONOTONIC) - @start_time
93
+ end
94
+
90
95
  ##
91
96
  # Resolves wall-clock seconds for the next adapter attempt.
92
97
  #
@@ -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
 
@@ -7,12 +7,16 @@ module Html2rss
7
7
  ##
8
8
  # To be used by strategies to provide their response.
9
9
  class Response
10
+ # Default when a strategy does not attach transport telemetry.
11
+ EMPTY_TRANSPORT_META = {}.freeze
12
+
10
13
  ##
11
14
  # @param body [String] the body of the response
12
15
  # @param url [Html2rss::Url] the final request URL
13
16
  # @param headers [Hash] the headers of the response
14
17
  # @param status [Integer, nil] the HTTP status code when available
15
- def initialize(body:, url:, headers: {}, status: nil)
18
+ # @param transport_meta [Hash] allowlisted upstream telemetry (frozen when present)
19
+ def initialize(body:, url:, headers: {}, status: nil, transport_meta: EMPTY_TRANSPORT_META)
16
20
  @body = body
17
21
 
18
22
  headers = headers.dup
@@ -22,6 +26,7 @@ module Html2rss
22
26
  @headers = headers
23
27
  @status = status
24
28
  @url = url
29
+ @transport_meta = transport_meta.nil? || transport_meta.empty? ? EMPTY_TRANSPORT_META : transport_meta.freeze
25
30
  end
26
31
 
27
32
  # @return [String] the raw body of the response
@@ -36,6 +41,9 @@ module Html2rss
36
41
  # @return [Html2rss::Url] the URL of the response
37
42
  attr_reader :url
38
43
 
44
+ # @return [Hash] allowlisted upstream transport telemetry
45
+ attr_reader :transport_meta
46
+
39
47
  # @return [String] normalized content type header value
40
48
  def content_type = header('content-type').to_s
41
49
 
@@ -86,6 +86,9 @@ module Html2rss
86
86
 
87
87
  def check_timeout!
88
88
  ctx.budget.effective_timeout_seconds(fallback: ctx.policy.total_timeout_seconds)
89
+ rescue RequestTimedOut
90
+ log_timeout!(reason: 'budget_exhausted')
91
+ raise
89
92
  end
90
93
 
91
94
  # @return [ResponseGuard, nil]
@@ -96,6 +99,8 @@ module Html2rss
96
99
  # @raise [StandardError]
97
100
  def handle_error(error)
98
101
  if timeout_error?(error)
102
+ log_timeout!(reason: 'transport')
103
+ Log.debug("#{self.class}: transport timeout message=#{error.message}")
99
104
  raise RequestTimedOut, error.message
100
105
  elsif connection_error?(error)
101
106
  translate_connection_error(error)
@@ -104,6 +109,23 @@ module Html2rss
104
109
  end
105
110
  end
106
111
 
112
+ # @param reason [String] timeout classification (budget_exhausted / transport)
113
+ # @return [void]
114
+ # rubocop:disable Metrics/AbcSize -- structured timeout fields stay in one log line
115
+ def log_timeout!(reason:)
116
+ remaining = ctx.budget.remaining_timeout_seconds
117
+ remaining_label = remaining.nil? ? 'untracked' : format('%.3f', remaining)
118
+ detail = [
119
+ "strategy=#{self.class.name}",
120
+ "host=#{ctx.url.host}",
121
+ "elapsed=#{format('%.3f', ctx.budget.elapsed_seconds)}s",
122
+ "budget_remaining=#{remaining_label}",
123
+ "reason=#{reason}"
124
+ ]
125
+ Log.info("#{self.class}: request timeout #{detail.join(' ')}")
126
+ end
127
+ # rubocop:enable Metrics/AbcSize
128
+
107
129
  # @param error [StandardError]
108
130
  # @return [void]
109
131
  # @raise [StandardError]
@@ -109,6 +109,10 @@ module Html2rss
109
109
  visited_urls.add(normalize_url(url))
110
110
  end
111
111
 
112
+ ##
113
+ # @return [Html2rss::Url] the session's current request URL
114
+ def url = context.url
115
+
112
116
  private
113
117
 
114
118
  attr_reader :context, :strategy, :logger, :visited_urls
@@ -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