html2rss 0.24.0 → 0.26.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 (129) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +76 -2
  3. data/html2rss.gemspec +4 -1
  4. data/lib/html2rss/auto_source/cleanup.rb +62 -36
  5. data/lib/html2rss/auto_source/scraper/html.rb +68 -145
  6. data/lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb +116 -0
  7. data/lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb +63 -0
  8. data/lib/html2rss/auto_source/scraper/json_state/document_scanner.rb +179 -0
  9. data/lib/html2rss/auto_source/scraper/json_state/value_finder.rb +55 -0
  10. data/lib/html2rss/auto_source/scraper/json_state.rb +31 -401
  11. data/lib/html2rss/auto_source/scraper/schema/item_list.rb +2 -14
  12. data/lib/html2rss/auto_source/scraper/semantic_html/entry_deduplicator.rb +35 -30
  13. data/lib/html2rss/auto_source/scraper/semantic_html.rb +40 -120
  14. data/lib/html2rss/auto_source/scraper/sitemap/parser.rb +161 -0
  15. data/lib/html2rss/auto_source/scraper/sitemap.rb +10 -7
  16. data/lib/html2rss/auto_source/scraper/wordpress_api.rb +6 -2
  17. data/lib/html2rss/auto_source/scraper/xhr_articles.rb +69 -0
  18. data/lib/html2rss/auto_source/scraper.rb +109 -53
  19. data/lib/html2rss/auto_source/segment.rb +29 -0
  20. data/lib/html2rss/auto_source/segmenter/cluster.rb +163 -0
  21. data/lib/html2rss/auto_source/segmenter/list.rb +103 -0
  22. data/lib/html2rss/auto_source/segmenter/primary_link.rb +80 -0
  23. data/lib/html2rss/auto_source/segmenter/semantic.rb +104 -0
  24. data/lib/html2rss/auto_source/segmenter.rb +95 -0
  25. data/lib/html2rss/auto_source.rb +79 -21
  26. data/lib/html2rss/capture.rb +315 -0
  27. data/lib/html2rss/cli.rb +72 -18
  28. data/lib/html2rss/config/auto_source_contract.rb +5 -1
  29. data/lib/html2rss/config/request_controls.rb +33 -0
  30. data/lib/html2rss/config/schema.rb +67 -26
  31. data/lib/html2rss/config/selectors_validator.rb +60 -21
  32. data/lib/html2rss/config/validator.rb +33 -35
  33. data/lib/html2rss/config.rb +6 -2
  34. data/lib/html2rss/error.rb +15 -3
  35. data/lib/html2rss/feed_builder/rss.rb +17 -5
  36. data/lib/html2rss/feed_pipeline/auto_fallback.rb +16 -6
  37. data/lib/html2rss/feed_pipeline/runtime_policy.rb +3 -43
  38. data/lib/html2rss/feed_pipeline.rb +11 -0
  39. data/lib/html2rss/feed_result.rb +1 -1
  40. data/lib/html2rss/hash_util.rb +17 -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 +308 -0
  56. data/lib/html2rss/link_destination/destination_facts.rb +40 -0
  57. data/lib/html2rss/link_destination/noise_policy.rb +68 -0
  58. data/lib/html2rss/link_destination/path_classifier.rb +208 -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/mcp/server.rb +577 -0
  62. data/lib/html2rss/mcp.rb +21 -0
  63. data/lib/html2rss/request_service/blocked_surface.rb +24 -1
  64. data/lib/html2rss/request_service/botasaurus_contract.rb +89 -9
  65. data/lib/html2rss/request_service/botasaurus_strategy.rb +4 -2
  66. data/lib/html2rss/request_service/budget.rb +7 -35
  67. data/lib/html2rss/request_service/context.rb +0 -6
  68. data/lib/html2rss/request_service/faraday_strategy.rb +87 -3
  69. data/lib/html2rss/request_service/network_guard.rb +5 -3
  70. data/lib/html2rss/request_service/policy.rb +1 -1
  71. data/lib/html2rss/request_service/response.rb +15 -1
  72. data/lib/html2rss/request_service/strategy.rb +1 -2
  73. data/lib/html2rss/request_service.rb +4 -9
  74. data/lib/html2rss/scoring/cluster_scorer.rb +87 -0
  75. data/lib/html2rss/scoring/container_assessor.rb +77 -0
  76. data/lib/html2rss/scoring/engine.rb +145 -0
  77. data/lib/html2rss/scoring/link_resolver.rb +84 -0
  78. data/lib/html2rss/scoring/observation.rb +53 -0
  79. data/lib/html2rss/scoring/ranked_segment.rb +33 -0
  80. data/lib/html2rss/scoring/score.rb +11 -0
  81. data/lib/html2rss/scoring.rb +8 -0
  82. data/lib/html2rss/selectors/extractors/attribute.rb +15 -0
  83. data/lib/html2rss/selectors/extractors/href.rb +12 -0
  84. data/lib/html2rss/selectors/extractors/html.rb +12 -0
  85. data/lib/html2rss/selectors/extractors/static.rb +14 -0
  86. data/lib/html2rss/selectors/extractors/text.rb +11 -0
  87. data/lib/html2rss/selectors/post_processors/gsub.rb +18 -0
  88. data/lib/html2rss/selectors/post_processors/html_to_markdown.rb +11 -0
  89. data/lib/html2rss/selectors/post_processors/markdown_to_html.rb +12 -0
  90. data/lib/html2rss/selectors/post_processors/parse_time.rb +11 -0
  91. data/lib/html2rss/selectors/post_processors/parse_uri.rb +11 -0
  92. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +23 -1
  93. data/lib/html2rss/selectors/post_processors/substring.rb +21 -0
  94. data/lib/html2rss/selectors/post_processors/template.rb +20 -0
  95. data/lib/html2rss/selectors/schema_doc.rb +99 -0
  96. data/lib/html2rss/selectors.rb +0 -20
  97. data/lib/html2rss/sst/attrs.rb +91 -0
  98. data/lib/html2rss/sst/document.rb +23 -0
  99. data/lib/html2rss/sst/index.rb +112 -0
  100. data/lib/html2rss/sst/node.rb +147 -0
  101. data/lib/html2rss/sst/normalizer.rb +171 -0
  102. data/lib/html2rss/sst/tags.rb +26 -0
  103. data/lib/html2rss/sst/text.rb +81 -0
  104. data/lib/html2rss/sst.rb +8 -0
  105. data/lib/html2rss/url.rb +11 -7
  106. data/lib/html2rss/version.rb +1 -1
  107. data/lib/html2rss.rb +57 -26
  108. data/schema/html2rss-config.schema.json +428 -85
  109. metadata +93 -27
  110. data/lib/html2rss/auto_source/discovery/dom_clustering/group_scorer.rb +0 -80
  111. data/lib/html2rss/auto_source/discovery/dom_clustering/overlap_resolver.rb +0 -86
  112. data/lib/html2rss/auto_source/discovery/dom_clustering.rb +0 -119
  113. data/lib/html2rss/auto_source/discovery/list_candidates.rb +0 -94
  114. data/lib/html2rss/auto_source/discovery/semantic_anchor_candidates.rb +0 -219
  115. data/lib/html2rss/auto_source/discovery/semantic_containers.rb +0 -71
  116. data/lib/html2rss/auto_source/discovery/sitemap.rb +0 -159
  117. data/lib/html2rss/auto_source/discovery.rb +0 -14
  118. data/lib/html2rss/auto_source/link_heuristics/anchor_signals.rb +0 -28
  119. data/lib/html2rss/auto_source/link_heuristics/container_assessor.rb +0 -106
  120. data/lib/html2rss/auto_source/link_heuristics/container_signals.rb +0 -80
  121. data/lib/html2rss/auto_source/link_heuristics/destination_facts.rb +0 -42
  122. data/lib/html2rss/auto_source/link_heuristics/href_extractor.rb +0 -38
  123. data/lib/html2rss/auto_source/link_heuristics/path_classifier.rb +0 -221
  124. data/lib/html2rss/auto_source/link_heuristics/text_classifier.rb +0 -66
  125. data/lib/html2rss/auto_source/link_heuristics.rb +0 -139
  126. data/lib/html2rss/request_service/browserless_strategy.rb +0 -132
  127. data/lib/html2rss/request_service/puppet_commander/navigation_guards.rb +0 -148
  128. data/lib/html2rss/request_service/puppet_commander/preload_runner.rb +0 -86
  129. data/lib/html2rss/request_service/puppet_commander.rb +0 -95
data/lib/html2rss/cli.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'fileutils'
4
4
  require 'json'
5
+ require 'yaml'
5
6
  require 'thor'
6
7
 
7
8
  module Html2rss
@@ -79,6 +80,9 @@ module Html2rss
79
80
  method_option :max_requests,
80
81
  type: :numeric,
81
82
  desc: 'Maximum requests to allow for this feed build'
83
+ method_option :limit,
84
+ type: :numeric,
85
+ desc: 'Maximum number of articles to keep (default: 25)'
82
86
  method_option :input,
83
87
  type: :string,
84
88
  desc: 'Local HTML file path to read input from'
@@ -95,6 +99,43 @@ module Html2rss
95
99
  puts(format == 'jsonfeed' ? JSON.pretty_generate(result) : result)
96
100
  end
97
101
 
102
+ desc 'capture URL', 'Analyze a URL and print a reusable YAML feed config'
103
+ method_option :strategy,
104
+ type: :string,
105
+ desc: STRATEGY_OPTION_DESC,
106
+ enum: STRATEGY_OPTION_ENUM
107
+ method_option :items_selector, type: :string, desc: 'CSS selector hint for items (optional)'
108
+ method_option :max_redirects,
109
+ type: :numeric,
110
+ desc: 'Maximum redirects to follow per request'
111
+ method_option :max_requests,
112
+ type: :numeric,
113
+ desc: 'Maximum requests to allow for this feed build'
114
+ method_option :limit,
115
+ type: :numeric,
116
+ desc: 'Maximum number of articles to keep (default: 25)'
117
+ method_option :input,
118
+ type: :string,
119
+ desc: 'Local HTML file path to read input from'
120
+ ##
121
+ # Captures a URL and prints a reusable YAML config.
122
+ #
123
+ # @param url [String, nil] source page URL for capture
124
+ # @return [void]
125
+ def capture(url = nil) # rubocop:disable Metrics/MethodLength
126
+ strategy, local_file_path, url = prepare_auto_inputs(url, options[:input])
127
+ config = Html2rss.capture(
128
+ url,
129
+ strategy:,
130
+ items_selector: options[:items_selector],
131
+ limit: options[:limit]&.to_i,
132
+ max_redirects: options[:max_redirects],
133
+ max_requests: options[:max_requests],
134
+ local_file_path:
135
+ )
136
+ puts YAML.dump(HashUtil.deep_stringify_keys(config))
137
+ end
138
+
98
139
  desc 'schema', 'Print the exported config JSON Schema'
99
140
  method_option :pretty,
100
141
  type: :boolean,
@@ -120,6 +161,27 @@ module Html2rss
120
161
  puts schema_json
121
162
  end
122
163
 
164
+ desc 'mcp', 'Start the MCP server for AI client consumption'
165
+ method_option :transport,
166
+ type: :string,
167
+ desc: 'MCP transport protocol',
168
+ enum: %w[stdio http],
169
+ default: 'stdio'
170
+ method_option :port,
171
+ type: :numeric,
172
+ desc: 'Port for HTTP transport (binds 127.0.0.1)',
173
+ default: 8080
174
+ ##
175
+ # Starts the MCP server for AI client consumption.
176
+ #
177
+ # @return [void]
178
+ def mcp
179
+ Html2rss::MCP.start(
180
+ transport: options[:transport].to_sym,
181
+ port: options[:port]
182
+ )
183
+ end
184
+
123
185
  desc 'validate YAML_FILE [feed_name]', 'Validate a YAML config with the runtime validator'
124
186
  method_option :params,
125
187
  type: :hash,
@@ -180,29 +242,20 @@ module Html2rss
180
242
 
181
243
  file_path = check_file_exists!(input_option)
182
244
  detected_url = url || detect_base_url!(
183
- file_path, 'Please specify a URL: html2rss auto [URL] --input <file>'
245
+ file_path, 'Please specify a URL: html2rss <command> [URL] --input <file>'
184
246
  )
185
247
 
186
248
  [:local_file, file_path, detected_url]
187
249
  end
188
250
 
189
251
  def request_controls
190
- Html2rss::Config::RequestControls.new(
191
- strategy: options[:strategy]&.to_sym,
252
+ Html2rss::Config::RequestControls.from_cli_options(
253
+ strategy: options[:strategy],
192
254
  max_redirects: options[:max_redirects],
193
- max_requests: options[:max_requests],
194
- explicit_keys: explicit_request_control_keys
255
+ max_requests: options[:max_requests]
195
256
  )
196
257
  end
197
258
 
198
- def explicit_request_control_keys
199
- keys = []
200
- keys << :strategy if options[:strategy]
201
- keys << :max_redirects unless options[:max_redirects].nil?
202
- keys << :max_requests unless options[:max_requests].nil?
203
- keys
204
- end
205
-
206
259
  def current_strategy
207
260
  options[:strategy]&.to_sym || :auto
208
261
  end
@@ -227,15 +280,15 @@ module Html2rss
227
280
  yield
228
281
  rescue Faraday::FollowRedirects::RedirectLimitReached => error
229
282
  raise Thor::Error,
230
- "#{error.message}. retry with --max-redirects #{suggested_max_redirects} or use the final URL directly."
283
+ "#{error.message}. already retried the last redirect hop once; " \
284
+ "retry with --max-redirects #{suggested_max_redirects} or use the final URL directly."
231
285
  rescue Html2rss::RequestService::RequestBudgetExceeded => error
232
286
  raise Thor::Error,
233
287
  "#{error.message}. retry with --max-requests #{suggested_max_requests} " \
234
288
  'or increase request.max_requests in the config.'
235
- rescue Html2rss::RequestService::BrowserlessConfigurationError,
236
- Html2rss::RequestService::BrowserlessConnectionFailed,
237
- Html2rss::RequestService::BotasaurusConfigurationError,
289
+ rescue Html2rss::RequestService::BotasaurusConfigurationError,
238
290
  Html2rss::RequestService::BotasaurusConnectionFailed,
291
+ Html2rss::RequestService::BotasaurusServiceError,
239
292
  Html2rss::RequestService::BlockedSurfaceDetected,
240
293
  Html2rss::NoFeedItemsExtracted => error
241
294
  raise Thor::Error, error.message
@@ -249,7 +302,8 @@ module Html2rss
249
302
  items_selector: options[:items_selector],
250
303
  max_redirects: options[:max_redirects],
251
304
  max_requests: options[:max_requests],
252
- local_file_path:
305
+ local_file_path:,
306
+ limit: options[:limit]&.to_i
253
307
  )
254
308
  end
255
309
 
@@ -6,6 +6,8 @@ module Html2rss
6
6
  class Config
7
7
  # Runtime source of truth for validating auto-source config values.
8
8
  AutoSourceContract = Dry::Schema.Params do # rubocop:disable Metrics/BlockLength
9
+ optional(:limit).filled(:integer, gt?: 0)
10
+
9
11
  optional(:scraper).hash do # rubocop:disable Metrics/BlockLength
10
12
  optional(:wordpress_api).hash do
11
13
  optional(:enabled).filled(:bool)
@@ -27,6 +29,9 @@ module Html2rss
27
29
  optional(:json_state).hash do
28
30
  optional(:enabled).filled(:bool)
29
31
  end
32
+ optional(:xhr_articles).hash do
33
+ optional(:enabled).filled(:bool)
34
+ end
30
35
  optional(:meta_oembed).hash do
31
36
  optional(:enabled).filled(:bool)
32
37
  end
@@ -44,7 +49,6 @@ module Html2rss
44
49
 
45
50
  optional(:cleanup).hash do
46
51
  optional(:keep_different_domain).filled(:bool)
47
- optional(:min_words_title).filled(:integer, gt?: 0)
48
52
  end
49
53
  end
50
54
  end
@@ -43,6 +43,39 @@ module Html2rss
43
43
  request_config.is_a?(Hash) && request_config.key?(key)
44
44
  end
45
45
 
46
+ ##
47
+ # Builds controls for public API shortcuts ({Html2rss.auto_source}, etc.).
48
+ # Strategy is explicit only when non-nil and not the configured default.
49
+ #
50
+ # @param strategy [Symbol, nil]
51
+ # @param max_redirects [Integer, nil]
52
+ # @param max_requests [Integer, nil]
53
+ # @return [RequestControls]
54
+ def self.from_shortcut(strategy:, max_redirects: nil, max_requests: nil)
55
+ keys = []
56
+ keys << :strategy unless strategy.nil? || strategy == Config.default_strategy_name
57
+ keys << :max_redirects unless max_redirects.nil?
58
+ keys << :max_requests unless max_requests.nil?
59
+ new(strategy:, max_redirects:, max_requests:, explicit_keys: keys)
60
+ end
61
+
62
+ ##
63
+ # Builds controls from CLI option values. Strategy is explicit when the
64
+ # option was provided (truthy string/symbol), matching Thor option presence.
65
+ #
66
+ # @param strategy [Symbol, String, nil]
67
+ # @param max_redirects [Integer, nil]
68
+ # @param max_requests [Integer, nil]
69
+ # @return [RequestControls]
70
+ def self.from_cli_options(strategy:, max_redirects: nil, max_requests: nil)
71
+ strategy_sym = strategy&.to_sym
72
+ keys = []
73
+ keys << :strategy if strategy
74
+ keys << :max_redirects unless max_redirects.nil?
75
+ keys << :max_requests unless max_requests.nil?
76
+ new(strategy: strategy_sym, max_redirects:, max_requests:, explicit_keys: keys)
77
+ end
78
+
46
79
  private_class_method :explicit_keys_for, :request_value_for, :request_key?
47
80
 
48
81
  ##
@@ -48,6 +48,7 @@ module Html2rss
48
48
  ##
49
49
  # Orchestrates schema assembly from runtime validator contracts plus
50
50
  # client-facing overlays.
51
+ # rubocop:disable Metrics/ClassLength -- overlay assembly stays in one builder
51
52
  class Builder
52
53
  class << self
53
54
  # @return [Hash{String => Object}] fully assembled JSON schema hash
@@ -60,9 +61,10 @@ module Html2rss
60
61
  def call
61
62
  schema = validator_schema
62
63
  apply_top_level(schema)
64
+ schema[:$defs] = registry_catalog_defs
63
65
  schema.fetch(:properties).merge!(overlay)
64
66
  schema.fetch(:properties).delete(:dynamic_params_error)
65
- DeepStringifier.call(schema)
67
+ HashUtil.deep_stringify_keys(schema)
66
68
  end
67
69
 
68
70
  private
@@ -77,6 +79,23 @@ module Html2rss
77
79
  { 'required' => ['selectors'] },
78
80
  { 'required' => ['auto_source'] }
79
81
  ]
82
+
83
+ topics = schema.dig(:properties, :directory, :properties, :topics)
84
+ topics[:minItems] = 1 if topics.is_a?(Hash)
85
+ end
86
+
87
+ # @return [Hash{Symbol => Hash}] catalog under $defs.post_processors / $defs.extractors
88
+ def registry_catalog_defs
89
+ {
90
+ post_processors: catalog_from_registry(Selectors::PostProcessors::NAME_TO_CLASS),
91
+ extractors: catalog_from_registry(Selectors::Extractors::NAME_TO_CLASS)
92
+ }
93
+ end
94
+
95
+ # @param registry [Hash{Symbol => Class}]
96
+ # @return [Hash{String => Hash}]
97
+ def catalog_from_registry(registry)
98
+ registry.keys.sort.to_h { |name| [name.to_s, registry.fetch(name).schema_doc] }
80
99
  end
81
100
 
82
101
  # rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Layout/LineLength
@@ -105,6 +124,13 @@ module Html2rss
105
124
  ]
106
125
  }
107
126
 
127
+ selector_schema = Schema.apply_selector_registry_refs!(
128
+ Html2rss::Config::SelectorsValidator::Selector.new.schema.json_schema(loose: true)
129
+ )
130
+ enclosure_schema = Schema.apply_selector_registry_refs!(
131
+ Html2rss::Config::SelectorsValidator::Enclosure.new.schema.json_schema(loose: true)
132
+ )
133
+
108
134
  {
109
135
  strategy: {
110
136
  type: 'string',
@@ -121,7 +147,7 @@ module Html2rss
121
147
  items: Html2rss::Config::Validator::StylesheetConfig.json_schema(loose: true)
122
148
  },
123
149
  auto_source: Html2rss::Config::AutoSourceContract.json_schema(loose: true).merge(
124
- default: DeepStringifier.call(Html2rss::AutoSource::DEFAULT_CONFIG)
150
+ default: HashUtil.deep_stringify_keys(Html2rss::AutoSource::DEFAULT_CONFIG)
125
151
  ),
126
152
  selectors: {
127
153
  type: 'object',
@@ -130,14 +156,14 @@ module Html2rss
130
156
  items: items_schema.merge(
131
157
  description: 'Defines the items selector and optional enhancement settings.'
132
158
  ),
133
- enclosure: Html2rss::Config::SelectorsValidator::Enclosure.new.schema.json_schema(loose: true).merge(
159
+ enclosure: enclosure_schema.merge(
134
160
  description: 'Describes enclosure extraction settings.'
135
161
  ),
136
162
  guid: reference_array('List of selector keys used to build the GUID. Each entry must reference a sibling selector key; runtime validation enforces those references.'),
137
163
  categories: reference_array('List of selector keys whose values will be used as categories. Each entry must reference a sibling selector key; runtime validation enforces those references.')
138
164
  },
139
165
  patternProperties: {
140
- '^(?!items$|enclosure$|guid$|categories$).+$' => Html2rss::Config::SelectorsValidator::Selector.new.schema.json_schema(loose: true).merge(
166
+ '^(?!items$|enclosure$|guid$|categories$).+$' => selector_schema.merge(
141
167
  description: 'Dynamic selector definition keyed by attribute name.'
142
168
  )
143
169
  },
@@ -159,31 +185,46 @@ module Html2rss
159
185
  }
160
186
  end
161
187
  end
188
+ # rubocop:enable Metrics/ClassLength
162
189
 
163
190
  ##
164
- # Converts nested hash keys to strings so the resulting schema serializes cleanly.
165
- module DeepStringifier
166
- module_function
167
-
168
- # @param object [Hash, Array, Object] nested data to normalize
169
- # @return [Hash, Array, Object] deep copy with stringified hash keys
170
- def call(object)
171
- case object
172
- when Hash
173
- stringify_hash(object)
174
- when Array
175
- object.map { |value| call(value) }
176
- when Symbol
177
- object.to_s
178
- else
179
- object
180
- end
181
- end
191
+ # Wires extractor / post_process to thin oneOf $refs into the registry catalog.
192
+ #
193
+ # @param schema [Hash] dry-schema JSON schema fragment for a selector
194
+ # @return [Hash] same schema with registry $ref wiring applied
195
+ def apply_selector_registry_refs!(schema)
196
+ properties = schema.fetch(:properties)
197
+ properties[:extractor] = extractor_wire_schema
198
+ properties[:post_process] = post_process_wire_schema
199
+ schema
200
+ end
201
+
202
+ # @return [Hash{Symbol => Object}]
203
+ def extractor_wire_schema
204
+ {
205
+ description: 'Extractor used to pull a value from the selected element.',
206
+ oneOf: registry_ref_list('extractors', Selectors::Extractors::NAME_TO_CLASS)
207
+ }
208
+ end
182
209
 
183
- # @param object [Hash{Object => Object}] hash whose keys should become strings
184
- # @return [Hash{String => Object}] hash with recursively normalized values
185
- def stringify_hash(object)
186
- object.to_h { |key, value| [key.to_s, call(value)] }
210
+ # @return [Hash{Symbol => Object}]
211
+ def post_process_wire_schema
212
+ {
213
+ type: 'array',
214
+ description: 'Ordered transforms applied to the extracted value.',
215
+ items: {
216
+ oneOf: registry_ref_list('post_processors', Selectors::PostProcessors::NAME_TO_CLASS)
217
+ }
218
+ }
219
+ end
220
+
221
+ ##
222
+ # @param catalog [String] $defs catalog name (`extractors` or `post_processors`)
223
+ # @param registry [Hash{Symbol => Class}]
224
+ # @return [Array<Hash>] oneOf entries of `{ '$ref' => ... }`
225
+ def registry_ref_list(catalog, registry)
226
+ registry.keys.sort.map do |name|
227
+ { '$ref' => "#/$defs/#{catalog}/#{name}" }
187
228
  end
188
229
  end
189
230
  end
@@ -75,6 +75,9 @@ module Html2rss
75
75
  ##
76
76
  # Validates the configuration of a single selector.
77
77
  class Selector < Dry::Validation::Contract
78
+ # Extractor Options members supplied at runtime, not from feed YAML.
79
+ RUNTIME_EXTRACTOR_FIELDS = %i[selector channel].to_set.freeze
80
+
78
81
  params do
79
82
  optional(:selector)
80
83
  optional(:extractor).filled(:string)
@@ -88,33 +91,69 @@ module Html2rss
88
91
  end
89
92
 
90
93
  rule(:extractor) do
91
- # dependent on the extractor, validate required fields, (i.e. static, attribute)
92
- case value
93
- when 'attribute'
94
- key(:attribute).failure('`attribute` must be a string') unless values[:attribute].is_a?(String)
95
- when 'static'
96
- key(:static).failure('`static` must be a string') unless values[:static].is_a?(String)
94
+ next unless value
95
+
96
+ klass = Selectors::Extractors::NAME_TO_CLASS[value.to_sym]
97
+ next key(:extractor).failure("unknown extractor: #{value}") unless klass
98
+
99
+ klass::Options.members.each do |field|
100
+ next if RUNTIME_EXTRACTOR_FIELDS.include?(field)
101
+ next if values[field]
102
+
103
+ key(field).failure("`#{field}` is required for extractor `#{value}`")
97
104
  end
98
105
  end
99
106
 
100
107
  rule(:post_process).each do
101
- case (name = value[:name])
102
- when 'gsub'
103
- key(:pattern).failure('`pattern` must be a string') unless value[:pattern].is_a?(String)
104
- key(:replacement).failure('`replacement` must be a string') unless value[:replacement].is_a?(String)
105
- when 'substring'
106
- key(:start).failure('`start` must be an integer') unless value[:start].is_a?(Integer)
107
- key(:end).failure('`end` must be an integer or omitted') if !value[:end].nil? && !value[:end].is_a?(Integer)
108
- when 'template'
109
- key(:string).failure('`string` must be a string') unless value[:string].is_a?(String)
110
- when 'html_to_markdown', 'markdown_to_html', 'parse_time', 'parse_uri', 'sanitize_html'
111
- # nothing to validate
112
- when nil
113
- key(:post_process).failure('Missing post_processor `name`')
114
- else
115
- key(:post_process).failure("Unknown post_processor `name`: #{name}")
108
+ name = value[:name]
109
+ next key(:post_process).failure('Missing post_processor `name`') if name.nil?
110
+
111
+ klass = Selectors::PostProcessors::NAME_TO_CLASS[name.to_sym]
112
+ next key(:post_process).failure("Unknown post_processor `name`: #{name}") unless klass
113
+ next unless klass.const_defined?(:Options)
114
+
115
+ post_process_option_type_errors(klass, value).each do |field, message|
116
+ key(field).failure(message)
116
117
  end
117
118
  end
119
+
120
+ private
121
+
122
+ def post_process_option_type_errors(klass, value)
123
+ required_option_type_errors(klass, value) + optional_option_type_errors(klass, value)
124
+ end
125
+
126
+ def required_option_type_errors(klass, value)
127
+ option_types = klass.const_defined?(:OPTION_TYPES) ? klass::OPTION_TYPES : {}
128
+
129
+ klass::Options.members.filter_map do |field|
130
+ expected = option_types.fetch(field, String)
131
+ next if value[field].is_a?(expected)
132
+
133
+ [field, option_type_failure(field, expected)]
134
+ end
135
+ end
136
+
137
+ def optional_option_type_errors(klass, value)
138
+ return [] unless klass.const_defined?(:OPTIONAL_OPTION_TYPES)
139
+
140
+ klass::OPTIONAL_OPTION_TYPES.filter_map do |field, expected|
141
+ actual = value[field]
142
+ next if actual.nil? || actual.is_a?(expected)
143
+
144
+ [field, option_type_failure(field, expected, optional: true)]
145
+ end
146
+ end
147
+
148
+ def option_type_failure(field, expected, optional: false)
149
+ label = {
150
+ Integer => 'an integer',
151
+ String => 'a string',
152
+ Hash => 'a hash'
153
+ }.fetch(expected) { "a #{expected}" }
154
+ suffix = optional ? ' or omitted' : ''
155
+ "`#{field}` must be #{label}#{suffix}"
156
+ end
118
157
  end
119
158
 
120
159
  ##
@@ -15,6 +15,11 @@ module Html2rss
15
15
  LANGUAGE_FORMAT_REGEX = /\A[a-z]{2}(-[A-Z]{2})?\z/
16
16
  # Baseline strategy-plan enum (:auto plus concrete RequestService strategies).
17
17
  BASE_STRATEGY_OPTIONS = Html2rss::FeedPipeline::StrategyPlan.accepted_names.freeze
18
+ # Controlled vocabulary for catalog-only `directory.topics` (not RSS channel fields).
19
+ DIRECTORY_TOPICS = %w[
20
+ sports energy tech science news entertainment jobs finance
21
+ security travel environment consumer civic product research
22
+ ].freeze
18
23
 
19
24
  # Contract for the top-level `channel` section.
20
25
  ChannelConfig = Dry::Schema.Params do
@@ -24,6 +29,13 @@ module Html2rss
24
29
  optional(:language).maybe(:string, format?: LANGUAGE_FORMAT_REGEX)
25
30
  optional(:ttl).maybe(:integer, gt?: 0)
26
31
  optional(:time_zone).maybe(:string)
32
+ optional(:author).maybe(:string)
33
+ optional(:image).maybe(:string, format?: URI_REGEXP)
34
+ end
35
+
36
+ # Contract for catalog-only `directory` metadata (topics for feed directories).
37
+ DirectoryConfig = Dry::Schema.Params do
38
+ optional(:topics).value(:array, min_size?: 1).each(:string, included_in?: DIRECTORY_TOPICS)
27
39
  end
28
40
 
29
41
  # Contract for a stylesheet entry in `stylesheets`.
@@ -33,47 +45,28 @@ module Html2rss
33
45
  optional(:media).maybe(:string)
34
46
  end
35
47
 
36
- # Contract for Browserless click-preload options.
37
- BrowserlessPreloadClickSelectorConfig = Dry::Schema.Params do
38
- required(:selector).filled(:string)
39
- optional(:max_clicks).filled(:integer, gt?: 0)
40
- optional(:wait_after_ms).filled(:integer, gteq?: 0)
41
- end
42
-
43
- # Contract for Browserless scroll-preload options.
44
- BrowserlessPreloadScrollConfig = Dry::Schema.Params do
45
- optional(:iterations).filled(:integer, gt?: 0)
46
- optional(:wait_after_ms).filled(:integer, gteq?: 0)
47
- end
48
-
49
- # Contract for Browserless preload orchestration options.
50
- BrowserlessPreloadConfig = Dry::Schema.Params do
51
- optional(:wait_after_ms).filled(:integer, gteq?: 0)
52
- optional(:click_selectors).array(BrowserlessPreloadClickSelectorConfig)
53
- optional(:scroll_down).hash(BrowserlessPreloadScrollConfig)
54
- end
55
-
56
- # Contract for Browserless-specific request options.
57
- BrowserlessRequestConfig = Dry::Schema.Params do
58
- optional(:preload).hash(BrowserlessPreloadConfig)
59
- end
60
-
61
48
  # Contract for Botasaurus-specific request options.
62
49
  BotasaurusRequestConfig = Dry::Schema.Params do
63
50
  config.validate_keys = true
64
51
 
65
- optional(:navigation_mode).filled(:string, included_in?: %w[auto get google_get google_get_bypass])
52
+ optional(:execution_mode).filled(:string, included_in?: %w[auto request browser])
53
+ optional(:navigation_mode).filled(:string, included_in?: %w[auto get google_get google_get_bypass organic_get])
66
54
  optional(:max_retries).filled(:integer, gteq?: 0, lteq?: 3)
67
55
  optional(:wait_for_selector).maybe(:string)
68
56
  optional(:wait_timeout_seconds).filled(:integer, gt?: 0)
57
+ optional(:scroll).filled(:bool)
58
+ optional(:scroll_to_bottom).filled(:bool)
69
59
  optional(:block_images).filled(:bool)
70
60
  optional(:block_images_and_css).filled(:bool)
61
+ optional(:block_trackers).filled(:bool)
71
62
  optional(:wait_for_complete_page_load).filled(:bool)
72
63
  optional(:headless).filled(:bool)
73
64
  optional(:proxy).filled(:string)
74
65
  optional(:user_agent).filled(:string)
75
66
  optional(:window_size).value(:array, min_size?: 2, max_size?: 2).each(:integer, gt?: 0)
76
67
  optional(:lang).filled(:string)
68
+ optional(:cookies).hash
69
+ optional(:headers).hash
77
70
  end
78
71
 
79
72
  # Contract for the top-level `request` section.
@@ -81,7 +74,6 @@ module Html2rss
81
74
  optional(:max_redirects).filled(:integer, gteq?: 0)
82
75
  optional(:max_requests).filled(:integer, gt?: 0)
83
76
  optional(:total_timeout_seconds).filled(:integer, gt?: 0)
84
- optional(:browserless).hash(BrowserlessRequestConfig)
85
77
  optional(:botasaurus).hash(BotasaurusRequestConfig)
86
78
  optional(:local_file_path).filled(:string)
87
79
  end
@@ -89,6 +81,7 @@ module Html2rss
89
81
  params do
90
82
  optional(:strategy).filled(:symbol)
91
83
  required(:channel).hash(ChannelConfig)
84
+ optional(:directory).hash(DirectoryConfig)
92
85
  optional(:headers).hash
93
86
  optional(:stylesheets).array(StylesheetConfig)
94
87
  optional(:auto_source).hash(Config::AutoSourceContract)
@@ -132,15 +125,20 @@ module Html2rss
132
125
 
133
126
  # URL validation delegated to Url class
134
127
  rule(:channel) do
135
- next unless values[:channel]&.key?(:url)
136
-
137
- url_string = values[:channel][:url]
138
- next if url_string.nil? || url_string.empty?
128
+ if (url_string = values.dig(:channel, :url)) && !url_string.empty?
129
+ begin
130
+ Html2rss::Url.for_channel(url_string)
131
+ rescue ArgumentError => error
132
+ key(%i[channel url]).failure(error.message)
133
+ end
134
+ end
139
135
 
140
- begin
141
- Html2rss::Url.for_channel(url_string)
142
- rescue ArgumentError => error
143
- key(%i[channel url]).failure(error.message)
136
+ if (image_string = values.dig(:channel, :image)) && !image_string.empty?
137
+ begin
138
+ Html2rss::Url.from_absolute(image_string)
139
+ rescue ArgumentError => error
140
+ key(%i[channel image]).failure(error.message)
141
+ end
144
142
  end
145
143
  end
146
144
  end
@@ -120,11 +120,15 @@ module Html2rss
120
120
  # @param url [String] source page URL
121
121
  # @param items_selector [String, nil] optional selector hint for item extraction
122
122
  # @param request_controls [Html2rss::Config::RequestControls, nil] explicit request controls to write
123
+ # @param limit [Integer, nil] max articles to keep in the auto-sourced feed
123
124
  # @return [Hash{Symbol => Object}] feed config hash ready for {from_hash}
124
- def auto_source_config(url:, items_selector: nil, request_controls: nil)
125
+ def auto_source_config(url:, items_selector: nil, request_controls: nil, limit: nil)
126
+ auto_source = AutoSource::DEFAULT_CONFIG
127
+ auto_source = auto_source.merge(limit:) unless limit.nil?
128
+
125
129
  config = {
126
130
  channel: default_config[:channel].merge(url:),
127
- auto_source: AutoSource::DEFAULT_CONFIG
131
+ auto_source:
128
132
  }
129
133
 
130
134
  request_controls ||= RequestControls.new
@@ -6,16 +6,24 @@ module Html2rss
6
6
 
7
7
  # Raised when auto fallback exhausts all concrete tiers and extractors find no feed items.
8
8
  class NoFeedItemsExtracted < Error
9
+ # Categories that append shared surface guidance to the empty-feed message.
10
+ SURFACE_HINT_CATEGORIES = %i[app_shell blocked_surface].freeze
11
+
9
12
  ##
10
13
  # @param attempts [Array<Hash{Symbol => Object}>] tier attempt diagnostics
11
- def initialize(attempts:)
14
+ # @param surface_category [Symbol, nil] optional Scraper surface classification
15
+ def initialize(attempts:, surface_category: nil)
12
16
  @attempts = attempts
17
+ @surface_category = surface_category
13
18
  super(build_message)
14
19
  end
15
20
 
16
21
  # @return [Array<Hash{Symbol => Object}>] tier attempt diagnostics
17
22
  attr_reader :attempts
18
23
 
24
+ # @return [Symbol, nil] surface classification when a response body was available
25
+ attr_reader :surface_category
26
+
19
27
  private
20
28
 
21
29
  def build_message
@@ -24,8 +32,12 @@ module Html2rss
24
32
  "#{attempt[:strategy]} (#{details})"
25
33
  end.join(', ')
26
34
 
27
- "No feed items extracted after auto fallback across strategies: #{summaries}. " \
28
- 'Try a more specific listing URL or provide explicit selectors.'
35
+ message = "No feed items extracted after auto fallback across strategies: #{summaries}. " \
36
+ 'Try a more specific listing URL or provide explicit selectors.'
37
+ return message unless SURFACE_HINT_CATEGORIES.include?(surface_category)
38
+
39
+ guidance = AutoSource::Scraper::NoScraperFound::CATEGORY_MESSAGES.fetch(surface_category)
40
+ "#{message} #{guidance}"
29
41
  end
30
42
  end
31
43
  end