html2rss 0.25.0 → 0.27.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/README.md +99 -2
  3. data/html2rss.gemspec +4 -1
  4. data/lib/html2rss/auto_source/README.md +57 -0
  5. data/lib/html2rss/auto_source/cleanup.rb +140 -49
  6. data/lib/html2rss/auto_source/scraper/json_state.rb +31 -22
  7. data/lib/html2rss/auto_source/scraper/schema/item_list.rb +2 -14
  8. data/lib/html2rss/auto_source/scraper/xhr_articles.rb +69 -0
  9. data/lib/html2rss/auto_source/scraper.rb +47 -15
  10. data/lib/html2rss/auto_source/segmenter/list.rb +3 -1
  11. data/lib/html2rss/auto_source/segmenter/primary_link.rb +6 -7
  12. data/lib/html2rss/auto_source/segmenter.rb +30 -1
  13. data/lib/html2rss/auto_source.rb +41 -10
  14. data/lib/html2rss/capture/README.md +61 -0
  15. data/lib/html2rss/capture.rb +318 -0
  16. data/lib/html2rss/cli.rb +94 -17
  17. data/lib/html2rss/config/auto_source_contract.rb +3 -1
  18. data/lib/html2rss/config/schema.rb +79 -26
  19. data/lib/html2rss/config/selectors_validator.rb +60 -21
  20. data/lib/html2rss/config/validator.rb +46 -32
  21. data/lib/html2rss/config.rb +28 -0
  22. data/lib/html2rss/error.rb +31 -1
  23. data/lib/html2rss/feed_pipeline/README.md +42 -0
  24. data/lib/html2rss/feed_pipeline/auto_fallback.rb +38 -15
  25. data/lib/html2rss/feed_pipeline/runtime_policy.rb +3 -43
  26. data/lib/html2rss/feed_pipeline/strategy_plan.rb +11 -0
  27. data/lib/html2rss/feed_pipeline.rb +40 -15
  28. data/lib/html2rss/hash_util.rb +17 -0
  29. data/lib/html2rss/html/article_extractor/category_extractor.rb +36 -22
  30. data/lib/html2rss/html/article_extractor/date_extractor.rb +5 -3
  31. data/lib/html2rss/html/article_extractor.rb +95 -17
  32. data/lib/html2rss/html/article_rules/category.rb +28 -11
  33. data/lib/html2rss/html/article_rules/date.rb +60 -6
  34. data/lib/html2rss/html/article_rules/description.rb +122 -0
  35. data/lib/html2rss/html/card_walk.rb +42 -0
  36. data/lib/html2rss/html/feed_link.rb +34 -0
  37. data/lib/html2rss/html/navigator.rb +18 -0
  38. data/lib/html2rss/html/sst_article_extractor.rb +147 -31
  39. data/lib/html2rss/link_destination/noise_policy.rb +7 -18
  40. data/lib/html2rss/link_destination/path_classifier.rb +49 -32
  41. data/lib/html2rss/mcp/config_argument.rb +42 -0
  42. data/lib/html2rss/mcp/contract.rb +173 -0
  43. data/lib/html2rss/mcp/inspect.rb +241 -0
  44. data/lib/html2rss/mcp/outcome.rb +188 -0
  45. data/lib/html2rss/mcp/server.rb +421 -0
  46. data/lib/html2rss/mcp.rb +21 -0
  47. data/lib/html2rss/request_service/blocked_surface.rb +24 -1
  48. data/lib/html2rss/request_service/botasaurus_contract.rb +261 -90
  49. data/lib/html2rss/request_service/botasaurus_strategy.rb +18 -9
  50. data/lib/html2rss/request_service/budget.rb +7 -35
  51. data/lib/html2rss/request_service/compressed_body.rb +109 -0
  52. data/lib/html2rss/request_service/context.rb +0 -6
  53. data/lib/html2rss/request_service/faraday_strategy.rb +48 -3
  54. data/lib/html2rss/request_service/policy.rb +2 -2
  55. data/lib/html2rss/request_service/response.rb +72 -7
  56. data/lib/html2rss/request_service/strategy.rb +1 -2
  57. data/lib/html2rss/request_service.rb +10 -10
  58. data/lib/html2rss/scoring/container_assessor.rb +9 -15
  59. data/lib/html2rss/scoring/engine.rb +49 -5
  60. data/lib/html2rss/scoring/link_resolver.rb +12 -0
  61. data/lib/html2rss/scoring/ranked_segment.rb +0 -12
  62. data/lib/html2rss/scoring/score.rb +1 -20
  63. data/lib/html2rss/scoring.rb +0 -25
  64. data/lib/html2rss/selectors/extractors/attribute.rb +15 -0
  65. data/lib/html2rss/selectors/extractors/href.rb +12 -0
  66. data/lib/html2rss/selectors/extractors/html.rb +12 -0
  67. data/lib/html2rss/selectors/extractors/static.rb +14 -0
  68. data/lib/html2rss/selectors/extractors/text.rb +11 -0
  69. data/lib/html2rss/selectors/post_processors/gsub.rb +18 -0
  70. data/lib/html2rss/selectors/post_processors/html_to_markdown.rb +11 -0
  71. data/lib/html2rss/selectors/post_processors/markdown_to_html.rb +12 -0
  72. data/lib/html2rss/selectors/post_processors/parse_time.rb +11 -0
  73. data/lib/html2rss/selectors/post_processors/parse_uri.rb +11 -0
  74. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +13 -0
  75. data/lib/html2rss/selectors/post_processors/substring.rb +21 -0
  76. data/lib/html2rss/selectors/post_processors/template.rb +20 -0
  77. data/lib/html2rss/selectors/schema_doc.rb +99 -0
  78. data/lib/html2rss/selectors.rb +2 -1
  79. data/lib/html2rss/status.rb +27 -11
  80. data/lib/html2rss/url.rb +31 -7
  81. data/lib/html2rss/version.rb +1 -1
  82. data/lib/html2rss.rb +62 -7
  83. data/schema/html2rss-config.schema.json +428 -94
  84. metadata +65 -12
  85. data/lib/html2rss/request_service/browserless_strategy.rb +0 -132
  86. data/lib/html2rss/request_service/puppet_commander/navigation_guards.rb +0 -148
  87. data/lib/html2rss/request_service/puppet_commander/preload_runner.rb +0 -86
  88. data/lib/html2rss/request_service/puppet_commander.rb +0 -95
  89. data/lib/html2rss/scoring/anchor_score.rb +0 -34
@@ -32,12 +32,6 @@ module Html2rss
32
32
  # @return [Hash] the request specific options
33
33
  attr_reader :request
34
34
 
35
- # @return [Hash] browserless specific options
36
- def browserless = request.fetch(:browserless, {})
37
-
38
- # @return [Hash, nil] preload options for browserless requests
39
- def browserless_preload = browserless[:preload]
40
-
41
35
  # @return [Symbol] the request relation
42
36
  attr_reader :relation
43
37
 
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'brotli'
3
4
  require 'faraday'
4
5
  require 'faraday/follow_redirects'
5
6
  require 'faraday/gzip'
@@ -9,6 +10,7 @@ module Html2rss
9
10
  ##
10
11
  # Strategy to use Faraday for the request.
11
12
  # @see https://rubygems.org/gems/faraday
13
+ # rubocop:disable Metrics/ClassLength -- terminal redirect retry colocated with Faraday transport
12
14
  class FaradayStrategy < Strategy
13
15
  ##
14
16
  # Restores buffered streamed bytes so response middleware can process them.
@@ -36,7 +38,8 @@ module Html2rss
36
38
  def perform_execute
37
39
  deadline = request_deadline
38
40
  @response_guard = ResponseGuard.new(policy: ctx.policy)
39
- raw_response = faraday_request(response_guard, deadline:, streaming_buffer: true)
41
+ reset_redirect_tracking!
42
+ raw_response = request_with_terminal_redirect_retry(response_guard, deadline:)
40
43
  raw_response = retry_without_streaming(response_guard, deadline:) if retry_without_streaming?(raw_response)
41
44
  build_response(raw_response)
42
45
  end
@@ -47,8 +50,48 @@ module Html2rss
47
50
  monotonic_now + ctx.budget.effective_timeout_seconds(fallback: ctx.policy.total_timeout_seconds)
48
51
  end
49
52
 
53
+ def reset_redirect_tracking!
54
+ @last_redirect_to = nil
55
+ @terminal_redirect_retried = false
56
+ @request_url_override = nil
57
+ end
58
+
59
+ def request_with_terminal_redirect_retry(response_guard, deadline:)
60
+ faraday_request(response_guard, deadline:, streaming_buffer: true)
61
+ rescue Faraday::FollowRedirects::RedirectLimitReached => error
62
+ raise error unless terminal_redirect_retryable?
63
+
64
+ retry_from_terminal_redirect!(response_guard, deadline:)
65
+ end
66
+
67
+ def terminal_redirect_retryable?
68
+ return false if @terminal_redirect_retried || @last_redirect_to.nil?
69
+
70
+ @last_redirect_to.to_s != request_url.to_s
71
+ end
72
+
73
+ def retry_from_terminal_redirect!(response_guard, deadline:)
74
+ terminal_url = @last_redirect_to
75
+ @terminal_redirect_retried = true
76
+ Log.debug("#{self.class}: redirect limit reached; retrying once from #{terminal_url}")
77
+ begin_terminal_url_request!(terminal_url)
78
+ faraday_request(response_guard, deadline:, streaming_buffer: true, consume_budget: false)
79
+ end
80
+
81
+ def begin_terminal_url_request!(terminal_url)
82
+ ctx.policy.validate_request!(url: terminal_url, origin_url: ctx.origin_url, relation: ctx.relation)
83
+ @request_url_override = terminal_url
84
+ @client = nil
85
+ @last_redirect_to = nil
86
+ end
87
+
88
+ def request_url
89
+ @request_url_override || ctx.url
90
+ end
91
+
50
92
  def build_response(response)
51
- Response.new(body: response.body, headers: response.headers, url: response_url(response),
93
+ Response.new(body: CompressedBody.decode(response.body, headers: response.headers),
94
+ headers: response.headers, url: response_url(response),
52
95
  status: response.status)
53
96
  end
54
97
 
@@ -105,7 +148,7 @@ module Html2rss
105
148
 
106
149
  # rubocop:disable Metrics/AbcSize
107
150
  def client
108
- @client ||= Faraday.new(url: ctx.url.to_s, headers: ctx.headers) do |faraday|
151
+ @client ||= Faraday.new(url: request_url.to_s, headers: ctx.headers) do |faraday|
109
152
  faraday.use Faraday::FollowRedirects::Middleware, limit: ctx.policy.max_redirects, callback: redirect_callback
110
153
  faraday.request :gzip
111
154
  faraday.use StreamingBodyMiddleware
@@ -168,6 +211,7 @@ module Html2rss
168
211
  lambda do |old_env, new_env|
169
212
  from_url = normalize_url(old_env[:url])
170
213
  to_url = normalize_url(new_env[:url])
214
+ @last_redirect_to = to_url
171
215
  ctx.policy.validate_redirect!(from_url:, to_url:, origin_url: ctx.origin_url, relation: ctx.relation)
172
216
  end
173
217
  end
@@ -180,5 +224,6 @@ module Html2rss
180
224
  Process.clock_gettime(Process::CLOCK_MONOTONIC)
181
225
  end
182
226
  end
227
+ # rubocop:enable Metrics/ClassLength
183
228
  end
184
229
  end
@@ -18,7 +18,7 @@ module Html2rss
18
18
  connect_timeout_seconds: Integer(ENV.fetch('HTML2RSS_CONNECT_TIMEOUT_SECONDS', 5)),
19
19
  read_timeout_seconds: Integer(ENV.fetch('HTML2RSS_READ_TIMEOUT_SECONDS', 10)),
20
20
  total_timeout_seconds: Integer(ENV.fetch('HTML2RSS_TOTAL_TIMEOUT_SECONDS', 30)),
21
- max_redirects: 3,
21
+ max_redirects: 5,
22
22
  max_response_bytes: 5_242_880,
23
23
  max_decompressed_bytes: 10_485_760,
24
24
  max_requests: 1,
@@ -115,7 +115,7 @@ module Html2rss
115
115
  # @raise [UnsupportedUrlScheme] if the redirect downgrades from HTTPS to HTTP
116
116
  def validate_redirect!(from_url:, to_url:, origin_url:, relation:)
117
117
  if from_url.scheme == 'https' && to_url.scheme == 'http'
118
- raise UnsupportedUrlScheme, 'Redirect downgraded from https to http'
118
+ raise UnsupportedUrlScheme, "Redirect downgraded from #{from_url} to #{to_url}"
119
119
  end
120
120
 
121
121
  validate_request!(url: to_url, origin_url:, relation:)
@@ -9,6 +9,16 @@ module Html2rss
9
9
  class Response
10
10
  # Default when a strategy does not attach transport telemetry.
11
11
  EMPTY_TRANSPORT_META = {}.freeze
12
+ # Default when a strategy does not capture sub-resource responses.
13
+ EMPTY_CAPTURED_RESPONSES = [].freeze
14
+
15
+ # Bodies that look like HTML even when Content-Type is missing, empty, or wrong.
16
+ HTML_BODY_SNIFF = /\A\s*(?:<!DOCTYPE\s+html|<html)/i
17
+ # Charset from Content-Type or a leading <meta charset>.
18
+ CHARSET_PARAMETER = /charset\s*=\s*["']?([\w.:-]+)/i
19
+ # Bytes scanned for a meta charset hint (HTML spec looks at the first 1024).
20
+ META_CHARSET_BYTES = 2048
21
+ private_constant :CHARSET_PARAMETER, :META_CHARSET_BYTES
12
22
 
13
23
  ##
14
24
  # @param body [String] the body of the response
@@ -16,7 +26,10 @@ module Html2rss
16
26
  # @param headers [Hash] the headers of the response
17
27
  # @param status [Integer, nil] the HTTP status code when available
18
28
  # @param transport_meta [Hash] allowlisted upstream telemetry (frozen when present)
19
- def initialize(body:, url:, headers: {}, status: nil, transport_meta: EMPTY_TRANSPORT_META)
29
+ # @param captured_responses [Array<Hash>] JSON XHR/fetch bodies captured during browser scrapes
30
+ # rubocop:disable Metrics/MethodLength, Metrics/ParameterLists -- transport + capture fields stay co-located
31
+ def initialize(body:, url:, headers: {}, status: nil, transport_meta: EMPTY_TRANSPORT_META,
32
+ captured_responses: EMPTY_CAPTURED_RESPONSES)
20
33
  @body = body
21
34
 
22
35
  headers = headers.dup
@@ -27,7 +40,13 @@ module Html2rss
27
40
  @status = status
28
41
  @url = url
29
42
  @transport_meta = transport_meta.nil? || transport_meta.empty? ? EMPTY_TRANSPORT_META : transport_meta.freeze
43
+ @captured_responses = if captured_responses.nil? || captured_responses.empty?
44
+ EMPTY_CAPTURED_RESPONSES
45
+ else
46
+ captured_responses.freeze
47
+ end
30
48
  end
49
+ # rubocop:enable Metrics/MethodLength, Metrics/ParameterLists
31
50
 
32
51
  # @return [String] the raw body of the response
33
52
  attr_reader :body
@@ -44,24 +63,26 @@ module Html2rss
44
63
  # @return [Hash] allowlisted upstream transport telemetry
45
64
  attr_reader :transport_meta
46
65
 
66
+ # @return [Array<Hash>] captured JSON XHR/fetch responses (empty when unsupported)
67
+ attr_reader :captured_responses
68
+
47
69
  # @return [String] normalized content type header value
48
70
  def content_type = header('content-type').to_s
49
71
 
50
72
  # @return [Boolean] whether response content is JSON
51
73
  def json_response? = content_type.include?('application/json')
52
74
 
53
- # @return [Boolean] whether response content is HTML
54
- def html_response? = content_type.include?('text/html')
75
+ # @return [Boolean] whether response content is HTML (header or sniffed body, never JSON)
76
+ def html_response?
77
+ content_type.include?('text/html') || (!json_response? && html_looking_body?)
78
+ end
55
79
 
56
80
  ##
57
81
  # @return [Nokogiri::HTML::Document, Hash] the parsed body of the response, frozen object
58
82
  # @raise [UnsupportedResponseContentType] if the content type is not supported
59
83
  def parsed_body
60
84
  @parsed_body ||= if html_response?
61
- Nokogiri::HTML(body).tap do |doc|
62
- # Remove comments from the document to avoid processing irrelevant content
63
- doc.xpath('//comment()').each(&:remove)
64
- end.freeze
85
+ parse_html_document
65
86
  elsif json_response?
66
87
  JSON.parse(body, symbolize_names: true).freeze
67
88
  else
@@ -78,6 +99,50 @@ module Html2rss
78
99
  headers.find { |key, _value| key.casecmp?(name) }&.last
79
100
  end
80
101
  end
102
+
103
+ def parse_html_document
104
+ Nokogiri::HTML(decoded_html_body).tap do |doc|
105
+ doc.xpath('//comment()').each(&:remove)
106
+ end.freeze
107
+ end
108
+
109
+ def decoded_html_body
110
+ bytes = body.to_s.b
111
+ transcode_html(bytes, header_charset || meta_charset(bytes))
112
+ end
113
+
114
+ def transcode_html(bytes, charset)
115
+ encoding = html_encoding_for(charset)
116
+ return utf8_scrub(bytes) unless encoding
117
+
118
+ bytes.dup.force_encoding(encoding).encode(Encoding::UTF_8)
119
+ rescue Encoding::UndefinedConversionError, Encoding::InvalidByteSequenceError
120
+ utf8_scrub(bytes)
121
+ end
122
+
123
+ def html_encoding_for(charset)
124
+ return if charset.nil? || utf8_charset?(charset)
125
+
126
+ Encoding.find(charset)
127
+ rescue ArgumentError
128
+ nil
129
+ end
130
+
131
+ def utf8_charset?(charset) = charset.to_s.downcase.gsub(/[\s_-]/, '') == 'utf8'
132
+
133
+ def utf8_scrub(bytes) = bytes.dup.force_encoding(Encoding::UTF_8).scrub
134
+
135
+ def header_charset
136
+ content_type[CHARSET_PARAMETER, 1]
137
+ end
138
+
139
+ def meta_charset(bytes)
140
+ bytes.byteslice(0, META_CHARSET_BYTES).to_s[CHARSET_PARAMETER, 1]
141
+ end
142
+
143
+ def html_looking_body?
144
+ body.to_s.b.match?(HTML_BODY_SNIFF)
145
+ end
81
146
  end
82
147
  end
83
148
  end
@@ -137,8 +137,7 @@ module Html2rss
137
137
  # @return [Boolean]
138
138
  def timeout_error?(error)
139
139
  error.is_a?(Faraday::TimeoutError) ||
140
- error.is_a?(Timeout::Error) ||
141
- (defined?(Puppeteer::TimeoutError) && error.is_a?(Puppeteer::TimeoutError))
140
+ error.is_a?(Timeout::Error)
142
141
  end
143
142
 
144
143
  # @param error [StandardError]
@@ -6,7 +6,7 @@ require 'forwardable'
6
6
  module Html2rss
7
7
  ##
8
8
  # Requests website URLs to retrieve their HTML for further processing.
9
- # Provides concrete transport strategies (e.g. Faraday, Browserless).
9
+ # Provides concrete transport strategies (e.g. Faraday, Botasaurus).
10
10
  #
11
11
  # Feed-level +:auto+ is not registered here — {FeedPipeline::StrategyPlan} resolves
12
12
  # it to a concrete strategy (or {FeedPipeline::AutoFallback} chain) before execute.
@@ -23,8 +23,6 @@ module Html2rss
23
23
  class UnsupportedResponseContentType < Html2rss::Error; end
24
24
  # Raised when HTTP request slot limits are exceeded.
25
25
  class RequestBudgetExceeded < Html2rss::Error; end
26
- # Raised when Browserless preload interaction limits are exceeded.
27
- class InteractionBudgetExceeded < Html2rss::Error; end
28
26
  # Raised when policy denies private-network access.
29
27
  class PrivateNetworkDenied < Html2rss::Error; end
30
28
  # Raised when cross-origin follow-up requests are denied.
@@ -35,14 +33,17 @@ module Html2rss
35
33
  class BlockedSurfaceDetected < Html2rss::Error; end
36
34
  # Raised when a request times out.
37
35
  class RequestTimedOut < Html2rss::Error; end
38
- # Raised when Browserless configuration is missing or invalid.
39
- class BrowserlessConfigurationError < Html2rss::Error; end
40
- # Raised when Browserless cannot be reached.
41
- class BrowserlessConnectionFailed < Html2rss::Error; end
36
+
42
37
  # Raised when Botasaurus configuration is missing or invalid.
43
- class BotasaurusConfigurationError < Html2rss::Error; end
44
- # Raised when Botasaurus cannot be reached or returns invalid payloads.
38
+ class BotasaurusConfigurationError < Html2rss::Error
39
+ # Short empty-feed guidance owned once (composed by {Html2rss::NoFeedItemsExtracted}).
40
+ EMPTY_FEED_HINT = 'Configure BOTASAURUS_SCRAPER_URL to enable the Botasaurus request strategy.'
41
+ end
42
+
43
+ # Raised when the Botasaurus service cannot be reached (network / DNS / SSL).
45
44
  class BotasaurusConnectionFailed < Html2rss::Error; end
45
+ # Raised when Botasaurus responds but the scrape fails (upstream error, bad payload).
46
+ class BotasaurusServiceError < Html2rss::Error; end
46
47
 
47
48
  class << self
48
49
  extend Forwardable
@@ -62,7 +63,6 @@ module Html2rss
62
63
  @strategies = {
63
64
  faraday: FaradayStrategy,
64
65
  botasaurus: BotasaurusStrategy,
65
- browserless: BrowserlessStrategy,
66
66
  local_file: LocalFileStrategy
67
67
  }
68
68
  @default_strategy_name = :faraday
@@ -8,21 +8,15 @@ module Html2rss
8
8
  # Microdata itemprop values treated as publish/update markers.
9
9
  PUBLISH_ITEMPROPS = %w[datePublished dateModified].freeze
10
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
11
  # @param text_classifier [LinkDestination::TextClassifier]
24
- def initialize(text_classifier: LinkDestination::TextClassifier.new)
12
+ # @param content_token_regexp [Regexp]
13
+ # @param junk_token_regexp [Regexp]
14
+ def initialize(text_classifier: LinkDestination::TextClassifier.new,
15
+ content_token_regexp: LinkResolver::CONTENT_TOKEN_REGEXP,
16
+ junk_token_regexp: LinkResolver::JUNK_TOKEN_REGEXP)
25
17
  @text_classifier = text_classifier
18
+ @content_token_regexp = content_token_regexp
19
+ @junk_token_regexp = junk_token_regexp
26
20
  end
27
21
 
28
22
  ##
@@ -41,8 +35,8 @@ module Html2rss
41
35
  publish_marker: publish_marker?(container),
42
36
  descriptive_context: descriptive_context?(container.visible_text, title),
43
37
  article_container: container.name == :article,
44
- content_tokens: tokens.match?(CONTENT_TOKEN_REGEXP),
45
- junk_tokens: tokens.match?(JUNK_TOKEN_REGEXP),
38
+ content_tokens: tokens.match?(@content_token_regexp),
39
+ junk_tokens: tokens.match?(@junk_token_regexp),
46
40
  utility_prefix_title: @text_classifier.utility_prefix?(title),
47
41
  recommended_title: @text_classifier.recommended?(title),
48
42
  utility_path: destination_facts&.utility_path,
@@ -10,7 +10,17 @@ module Html2rss
10
10
  # Minimum composite score retained by {#rank_top} (precision floor).
11
11
  SCORE_FLOOR = 0.0
12
12
 
13
- # Quality feature triples: [FeatureId, predicate, weight].
13
+ FEATURE_IDS = %i[
14
+ title_word_count_ge3 title_word_count_ge7 path_length_gt6 content_path
15
+ publish_marker descriptive_context article_container content_tokens
16
+ non_content_utility_path utility_prefix_title_short shallow weak_container
17
+ recommended_title_non_content high_confidence_junk_path junk_tokens
18
+ heading_anchor heading_text_match meaningful_text content_like_destination
19
+ cluster_size cluster_avg_words cluster_heading cluster_time cluster_date
20
+ ].to_set.freeze
21
+ private_constant :FEATURE_IDS
22
+
23
+ # Quality feature triples: [feature_id, predicate, weight].
14
24
  QUALITY_RULES = [
15
25
  [:title_word_count_ge3, ->(o) { o.title_word_count >= 3 }, 40],
16
26
  [:title_word_count_ge7, ->(o) { o.title_word_count >= 7 }, 15],
@@ -22,7 +32,7 @@ module Html2rss
22
32
  [:content_tokens, lambda(&:content_tokens), 10]
23
33
  ].freeze
24
34
 
25
- # Junk feature triples: [FeatureId, predicate, weight].
35
+ # Junk feature triples: [feature_id, predicate, weight].
26
36
  JUNK_RULES = [
27
37
  [:non_content_utility_path, ->(o) { o.utility_path && !o.content_path && !o.strong_post_suffix }, 25],
28
38
  [:utility_prefix_title_short, ->(o) { o.utility_prefix_title && o.title_word_count <= 6 }, 15],
@@ -33,7 +43,17 @@ module Html2rss
33
43
  [:junk_tokens, lambda(&:junk_tokens), 15]
34
44
  ].freeze
35
45
 
36
- (QUALITY_RULES + JUNK_RULES).each { |feature_id, _, _| FeatureId.assert!(feature_id) }
46
+ ##
47
+ # @param id [Symbol]
48
+ # @return [Symbol]
49
+ # @raise [ArgumentError] when id is not in the closed set
50
+ def self.assert_feature_id!(id)
51
+ raise ArgumentError, "unknown feature: #{id.inspect}" unless FEATURE_IDS.include?(id)
52
+
53
+ id
54
+ end
55
+
56
+ (QUALITY_RULES + JUNK_RULES).each { |feature_id, _, _| assert_feature_id!(feature_id) }
37
57
 
38
58
  # @param link_resolver [LinkResolver]
39
59
  def initialize(link_resolver:)
@@ -78,9 +98,9 @@ module Html2rss
78
98
 
79
99
  quality, quality_parts = apply_rules(QUALITY_RULES, obs)
80
100
  junk, junk_parts = apply_rules(JUNK_RULES, obs)
81
- RankedSegment.build(
101
+ ranked(
82
102
  segment:,
83
- score: Score.build(
103
+ score: build_score(
84
104
  composite: quality - junk,
85
105
  quality:,
86
106
  junk:,
@@ -96,6 +116,30 @@ module Html2rss
96
116
  end
97
117
  [parts.values.sum, parts]
98
118
  end
119
+
120
+ def build_score(composite:, quality: nil, junk: nil, breakdown: nil)
121
+ raise ArgumentError, 'composite must be Numeric' unless composite.is_a?(Numeric)
122
+
123
+ Score.new(
124
+ composite: composite.to_f,
125
+ quality: (quality.nil? ? composite : quality).to_f,
126
+ junk: (junk || 0).to_f,
127
+ breakdown: normalize_breakdown(breakdown)
128
+ )
129
+ end
130
+
131
+ def normalize_breakdown(breakdown)
132
+ return Score::EMPTY_BREAKDOWN if breakdown.nil?
133
+
134
+ breakdown.transform_keys { |id| self.class.assert_feature_id!(id) }.freeze
135
+ end
136
+
137
+ def ranked(segment:, score:)
138
+ raise ArgumentError, 'segment must be AutoSource::Segment' unless segment.is_a?(Html2rss::AutoSource::Segment)
139
+ raise ArgumentError, 'score must be Scoring::Score' unless score.is_a?(Score)
140
+
141
+ RankedSegment.new(segment:, score:)
142
+ end
99
143
  end
100
144
  end
101
145
  end
@@ -8,6 +8,18 @@ module Html2rss
8
8
  # Captures the href portion before a fragment for memoization keys.
9
9
  HREF_BASE_PATTERN = /\A([^#]*)/
10
10
 
11
+ # Matches content-like tokens in class/id strings (from PathClassifier vocabulary).
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 (from PathClassifier vocabulary).
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
+
11
23
  # @param base_url [String, Html2rss::Url]
12
24
  def initialize(base_url)
13
25
  @base_url = base_url
@@ -5,18 +5,6 @@ module Html2rss
5
5
  ##
6
6
  # A segment paired with its composite score.
7
7
  RankedSegment = Data.define(:segment, :score) do
8
- ##
9
- # @param segment [Html2rss::AutoSource::Segment]
10
- # @param score [Score]
11
- # @return [RankedSegment]
12
- # @raise [ArgumentError] on invalid types
13
- def self.build(segment:, score:)
14
- raise ArgumentError, 'segment must be AutoSource::Segment' unless segment.is_a?(Html2rss::AutoSource::Segment)
15
- raise ArgumentError, 'score must be Scoring::Score' unless score.is_a?(Score)
16
-
17
- new(segment:, score:)
18
- end
19
-
20
8
  ##
21
9
  # @return [SST::Node]
22
10
  def root_node = segment.root_node
@@ -4,26 +4,7 @@ module Html2rss
4
4
  module Scoring
5
5
  ##
6
6
  # Composite score with quality/junk split and optional typed feature breakdown.
7
- Score = Data.define(:composite, :quality, :junk, :breakdown) do
8
- ##
9
- # @param composite [Numeric]
10
- # @param quality [Numeric, nil]
11
- # @param junk [Numeric, nil]
12
- # @param breakdown [Hash{Symbol => Numeric}, nil]
13
- # @return [Score]
14
- # @raise [ArgumentError] when composite is not numeric
15
- def self.build(composite:, quality: nil, junk: nil, breakdown: nil)
16
- raise ArgumentError, 'composite must be Numeric' unless composite.is_a?(Numeric)
17
-
18
- parts = breakdown.nil? ? Score::EMPTY_BREAKDOWN : breakdown.transform_keys { FeatureId.assert!(_1) }.freeze
19
- new(
20
- composite: composite.to_f,
21
- quality: (quality.nil? ? composite : quality).to_f,
22
- junk: (junk || 0).to_f,
23
- breakdown: parts
24
- )
25
- end
26
- end
7
+ Score = Data.define(:composite, :quality, :junk, :breakdown)
27
8
  # Shared empty feature breakdown for scores without per-feature tallies.
28
9
  Score::EMPTY_BREAKDOWN = {}.freeze
29
10
  end
@@ -4,30 +4,5 @@ module Html2rss
4
4
  ##
5
5
  # Declarative feature scoring for AutoSource SST segments.
6
6
  module Scoring
7
- ##
8
- # Closed set of feature identifiers used by the scoring registry.
9
- module FeatureId
10
- # Closed set of allowed feature identifier symbols.
11
- IDS = %i[
12
- title_word_count_ge3 title_word_count_ge7 path_length_gt6 content_path
13
- publish_marker descriptive_context article_container content_tokens
14
- non_content_utility_path utility_prefix_title_short shallow weak_container
15
- recommended_title_non_content high_confidence_junk_path junk_tokens
16
- heading_anchor heading_text_match meaningful_text content_like_destination
17
- cluster_size cluster_avg_words cluster_heading cluster_time cluster_date
18
- ].to_set.freeze
19
-
20
- module_function
21
-
22
- ##
23
- # @param id [Symbol]
24
- # @return [Symbol]
25
- # @raise [ArgumentError] when id is not in the closed set
26
- def assert!(id)
27
- raise ArgumentError, "unknown FeatureId: #{id.inspect}" unless IDS.include?(id)
28
-
29
- id
30
- end
31
- end
32
7
  end
33
8
  end
@@ -24,9 +24,24 @@ module Html2rss
24
24
  # In case you're extracting a date or a time, consider parsing it
25
25
  # during post processing with {PostProcessors::ParseTime}.
26
26
  class Attribute
27
+ # Config-facing option types (excluding shared selector fields supplied at runtime).
28
+ OPTION_TYPES = { attribute: String }.freeze
29
+
27
30
  # The available options for the attribute extractor.
28
31
  Options = Struct.new('AttributeOptions', :selector, :attribute, keyword_init: true)
29
32
 
33
+ # JSON Schema description exported via +schema_doc+.
34
+ DESCRIPTION = 'Return the value of an HTML attribute on the selected element. ' \
35
+ 'Requires sibling selector option `attribute` (attribute name).'
36
+
37
+ # Example extractor name values for JSON Schema +examples+.
38
+ EXAMPLES = [
39
+ 'attribute'
40
+ ].freeze
41
+
42
+ # @return [Hash{Symbol => Object}] JSON Schema fragment for this extractor name
43
+ def self.schema_doc = SchemaDoc.for_extractor(name: :attribute, klass: self)
44
+
30
45
  ##
31
46
  # Initializes the Attribute extractor.
32
47
  #
@@ -27,6 +27,18 @@ module Html2rss
27
27
  # The available options for the href (attribute) extractor.
28
28
  Options = Struct.new('HrefOptions', :selector, :channel, keyword_init: true)
29
29
 
30
+ # JSON Schema description exported via +schema_doc+.
31
+ DESCRIPTION = 'Return the absolute URL from the selected element\'s `href` attribute ' \
32
+ '(relative hrefs are resolved against the channel URL).'
33
+
34
+ # Example extractor name values for JSON Schema +examples+.
35
+ EXAMPLES = [
36
+ 'href'
37
+ ].freeze
38
+
39
+ # @return [Hash{Symbol => Object}] JSON Schema fragment for this extractor name
40
+ def self.schema_doc = SchemaDoc.for_extractor(name: :href, klass: self)
41
+
30
42
  ##
31
43
  # Initializes the Href extractor.
32
44
  #
@@ -26,6 +26,18 @@ module Html2rss
26
26
  # The available options for the html extractor.
27
27
  Options = Struct.new('HtmlOptions', :selector, keyword_init: true)
28
28
 
29
+ # JSON Schema description exported via +schema_doc+.
30
+ DESCRIPTION = 'Return the outer HTML of the selected element. ' \
31
+ 'Sanitize during post-processing (e.g. `sanitize_html`).'
32
+
33
+ # Example extractor name values for JSON Schema +examples+.
34
+ EXAMPLES = [
35
+ 'html'
36
+ ].freeze
37
+
38
+ # @return [Hash{Symbol => Object}] JSON Schema fragment for this extractor name
39
+ def self.schema_doc = SchemaDoc.for_extractor(name: :html, klass: self)
40
+
29
41
  ##
30
42
  # Initializes the Html extractor.
31
43
  #
@@ -16,9 +16,23 @@ module Html2rss
16
16
  # Would return:
17
17
  # 'Foobar'
18
18
  class Static
19
+ # Config-facing option types (excluding shared selector fields supplied at runtime).
20
+ OPTION_TYPES = { static: String }.freeze
21
+
19
22
  # The available option for the static extractor.
20
23
  Options = Struct.new('StaticOptions', :static, keyword_init: true)
21
24
 
25
+ # JSON Schema description exported via +schema_doc+.
26
+ DESCRIPTION = 'Return a fixed value from sibling selector option `static` (no DOM read).'
27
+
28
+ # Example extractor name values for JSON Schema +examples+.
29
+ EXAMPLES = [
30
+ 'static'
31
+ ].freeze
32
+
33
+ # @return [Hash{Symbol => Object}] JSON Schema fragment for this extractor name
34
+ def self.schema_doc = SchemaDoc.for_extractor(name: :static, klass: self)
35
+
22
36
  ##
23
37
  # Initializes the Static extractor.
24
38
  #
@@ -24,6 +24,17 @@ module Html2rss
24
24
  # The available options for the text extractor.
25
25
  Options = Struct.new('TextOptions', :selector, keyword_init: true)
26
26
 
27
+ # JSON Schema description exported via +schema_doc+.
28
+ DESCRIPTION = 'Return collapsed visible text of the selected element (default extractor).'
29
+
30
+ # Example extractor name values for JSON Schema +examples+.
31
+ EXAMPLES = [
32
+ 'text'
33
+ ].freeze
34
+
35
+ # @return [Hash{Symbol => Object}] JSON Schema fragment for this extractor name
36
+ def self.schema_doc = SchemaDoc.for_extractor(name: :text, klass: self)
37
+
27
38
  ##
28
39
  # Initializes the Text extractor.
29
40
  #