html2rss 0.32.4 → 0.33.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 (105) hide show
  1. checksums.yaml +4 -4
  2. data/lib/html2rss/article/enclosure.rb +2 -6
  3. data/lib/html2rss/auto_source/cleanup.rb +1 -3
  4. data/lib/html2rss/auto_source/scraper/html.rb +2 -8
  5. data/lib/html2rss/auto_source/scraper/json_document.rb +2 -3
  6. data/lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb +128 -128
  7. data/lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb +37 -37
  8. data/lib/html2rss/auto_source/scraper/json_state/document_scanner.rb +123 -123
  9. data/lib/html2rss/auto_source/scraper/json_state/value_finder.rb +34 -34
  10. data/lib/html2rss/auto_source/scraper/json_state.rb +2 -6
  11. data/lib/html2rss/auto_source/scraper/meta_oembed.rb +32 -2
  12. data/lib/html2rss/auto_source/scraper/microdata.rb +228 -245
  13. data/lib/html2rss/auto_source/scraper/microformats2.rb +9 -1
  14. data/lib/html2rss/auto_source/scraper/native_feed.rb +1 -1
  15. data/lib/html2rss/auto_source/scraper/schema/category_extractor.rb +7 -2
  16. data/lib/html2rss/auto_source/scraper/schema/item_list.rb +2 -2
  17. data/lib/html2rss/auto_source/scraper/schema/thing.rb +98 -21
  18. data/lib/html2rss/auto_source/scraper/schema.rb +49 -2
  19. data/lib/html2rss/auto_source/scraper/semantic_html.rb +12 -16
  20. data/lib/html2rss/auto_source/scraper/sitemap/parser.rb +1 -4
  21. data/lib/html2rss/auto_source/scraper/sitemap.rb +3 -11
  22. data/lib/html2rss/auto_source/scraper/wordpress_api/page_scope.rb +2 -2
  23. data/lib/html2rss/auto_source/scraper/wordpress_api/posts_endpoint.rb +1 -3
  24. data/lib/html2rss/auto_source/scraper/wordpress_api.rb +4 -4
  25. data/lib/html2rss/auto_source/scraper/xhr_articles.rb +1 -1
  26. data/lib/html2rss/auto_source/scraper.rb +0 -11
  27. data/lib/html2rss/auto_source/segmenter/cluster.rb +5 -11
  28. data/lib/html2rss/auto_source/segmenter/list.rb +25 -57
  29. data/lib/html2rss/auto_source/segmenter/semantic.rb +6 -13
  30. data/lib/html2rss/auto_source.rb +1 -1
  31. data/lib/html2rss/batch.rb +22 -32
  32. data/lib/html2rss/capture/README.md +1 -1
  33. data/lib/html2rss/capture.rb +7 -27
  34. data/lib/html2rss/cli/render.rb +97 -96
  35. data/lib/html2rss/cli/validate.rb +2 -4
  36. data/lib/html2rss/cli.rb +33 -27
  37. data/lib/html2rss/config/request_controls.rb +10 -2
  38. data/lib/html2rss/config/schema.rb +8 -10
  39. data/lib/html2rss/config/selectors_validator.rb +1 -0
  40. data/lib/html2rss/config/validator.rb +3 -3
  41. data/lib/html2rss/config.rb +1 -1
  42. data/lib/html2rss/defaults.rb +5 -16
  43. data/lib/html2rss/doctor/botasaurus.rb +11 -19
  44. data/lib/html2rss/error.rb +7 -3
  45. data/lib/html2rss/feed_builder/rss.rb +1 -1
  46. data/lib/html2rss/feed_pipeline/auto_fallback.rb +3 -3
  47. data/lib/html2rss/feed_resolution/candidate_generator.rb +3 -4
  48. data/lib/html2rss/feed_resolution/options.rb +3 -7
  49. data/lib/html2rss/feed_resolution/policy.rb +29 -33
  50. data/lib/html2rss/feed_resolution/probe.rb +3 -3
  51. data/lib/html2rss/feed_resolution/scorer.rb +29 -29
  52. data/lib/html2rss/feed_resolution.rb +5 -9
  53. data/lib/html2rss/feed_result.rb +4 -3
  54. data/lib/html2rss/hash_util.rb +91 -90
  55. data/lib/html2rss/html/article_extractor/category_extractor.rb +6 -9
  56. data/lib/html2rss/html/article_extractor/date_extractor.rb +1 -1
  57. data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +1 -3
  58. data/lib/html2rss/html/article_extractor/id_generator.rb +1 -1
  59. data/lib/html2rss/html/probe.rb +51 -51
  60. data/lib/html2rss/link_destination/path_classifier.rb +11 -33
  61. data/lib/html2rss/mcp/config_argument.rb +9 -2
  62. data/lib/html2rss/mcp/contract.rb +7 -5
  63. data/lib/html2rss/mcp/outcome.rb +4 -5
  64. data/lib/html2rss/mcp/runtime.rb +17 -24
  65. data/lib/html2rss/mcp/server/tools.rb +9 -9
  66. data/lib/html2rss/option.rb +48 -0
  67. data/lib/html2rss/page_recon/diagnostics.rb +7 -9
  68. data/lib/html2rss/page_recon.rb +0 -4
  69. data/lib/html2rss/recon.rb +10 -12
  70. data/lib/html2rss/request_service/blocked_surface.rb +3 -7
  71. data/lib/html2rss/request_service/botasaurus_strategy.rb +2 -2
  72. data/lib/html2rss/request_service/budget.rb +3 -24
  73. data/lib/html2rss/request_service/compressed_body.rb +36 -48
  74. data/lib/html2rss/request_service/context.rb +1 -3
  75. data/lib/html2rss/request_service/local_file_strategy.rb +0 -1
  76. data/lib/html2rss/request_service/network_guard.rb +3 -2
  77. data/lib/html2rss/request_service/policy.rb +8 -26
  78. data/lib/html2rss/request_service/response.rb +3 -6
  79. data/lib/html2rss/request_session/pager/base.rb +3 -3
  80. data/lib/html2rss/request_session/pager/custom_selector.rb +4 -4
  81. data/lib/html2rss/request_session/pager/offset.rb +1 -7
  82. data/lib/html2rss/request_session/pager/rel_next.rb +1 -1
  83. data/lib/html2rss/request_session/pager/url_template.rb +1 -7
  84. data/lib/html2rss/scoring/engine.rb +4 -32
  85. data/lib/html2rss/scoring/link_resolver.rb +2 -7
  86. data/lib/html2rss/selectors/extractors/href.rb +2 -4
  87. data/lib/html2rss/selectors/json_xml.rb +33 -34
  88. data/lib/html2rss/selectors/post_processors/gsub.rb +2 -3
  89. data/lib/html2rss/selectors/post_processors/parse_time.rb +4 -5
  90. data/lib/html2rss/selectors/schema_export.rb +60 -60
  91. data/lib/html2rss/selectors.rb +14 -18
  92. data/lib/html2rss/sst/node.rb +9 -3
  93. data/lib/html2rss/sst/normalizer.rb +25 -24
  94. data/lib/html2rss/sst/text.rb +53 -57
  95. data/lib/html2rss/status.rb +23 -30
  96. data/lib/html2rss/syndication/alternate_links.rb +39 -0
  97. data/lib/html2rss/syndication/discovery.rb +117 -187
  98. data/lib/html2rss/syndication/parser.rb +92 -115
  99. data/lib/html2rss/test/enhance_audit.rb +13 -15
  100. data/lib/html2rss/test/policy.rb +9 -11
  101. data/lib/html2rss/test.rb +29 -48
  102. data/lib/html2rss/url.rb +53 -53
  103. data/lib/html2rss/version.rb +1 -1
  104. data/lib/html2rss.rb +2 -1
  105. metadata +4 -2
@@ -32,7 +32,7 @@ module Html2rss
32
32
 
33
33
  def parse_id_from_dom(article_tag, url, selected_anchor)
34
34
  candidates = [article_tag['id'], article_tag.at_css('[id]')&.attr('id')]
35
- candidates += [url&.path, url&.query] if selected_anchor
35
+ candidates += [url&.path] if selected_anchor
36
36
  candidates.compact.reject(&:empty?).first
37
37
  end
38
38
 
@@ -16,66 +16,66 @@ module Html2rss
16
16
  # @see #mime_match?
17
17
  APPLICATION_ATOM_XML = 'application/atom+xml'
18
18
 
19
- module_function
19
+ class << self
20
+ ##
21
+ # @param string [String, Symbol, #to_s] wire value
22
+ # @return [String] folded comparison key
23
+ def fold(string)
24
+ string.to_s.downcase
25
+ end
20
26
 
21
- ##
22
- # @param string [String, Symbol, #to_s] wire value
23
- # @return [String] folded comparison key
24
- def fold(string)
25
- string.to_s.downcase
26
- end
27
-
28
- ##
29
- # @param node [Nokogiri::XML::Node]
30
- # @return [String] folded element name
31
- def tag(node)
32
- name = node.name
33
- return fold(name) unless name.is_a?(String)
34
- return name if name.ascii_only? && !name.match?(/[A-Z]/)
27
+ ##
28
+ # @param node [Nokogiri::XML::Node]
29
+ # @return [String] folded element name
30
+ def tag(node)
31
+ name = node.name
32
+ return fold(name) unless name.is_a?(String)
33
+ return name if name.ascii_only? && !name.match?(/[A-Z]/)
35
34
 
36
- fold(name)
37
- end
35
+ fold(name)
36
+ end
38
37
 
39
- ##
40
- # @param type [String, Symbol, #to_s, nil] MIME type attribute value
41
- # @return [String] media type without parameters, folded
42
- def mime_base(type)
43
- raw = type.to_s
44
- return fold(raw) unless raw.include?(';') || raw.match?(/\A\s|\s\z/)
38
+ ##
39
+ # @param type [String, Symbol, #to_s, nil] MIME type attribute value
40
+ # @return [String] media type without parameters, folded
41
+ def mime_base(type)
42
+ raw = type.to_s
43
+ return fold(raw) unless raw.include?(';') || raw.match?(/\A\s|\s\z/)
45
44
 
46
- fold(raw.split(';', 2).first.to_s.strip)
47
- end
45
+ fold(raw.split(';', 2).first.to_s.strip)
46
+ end
48
47
 
49
- ##
50
- # @param actual [String, Symbol, #to_s, nil] observed MIME type
51
- # @param expected [Array<String>] canonical MIME types to match
52
- # @return [Boolean]
53
- def mime_match?(actual, *expected)
54
- base = mime_base(actual)
55
- expected.any? { |candidate| base == candidate || base == mime_base(candidate) }
56
- end
48
+ ##
49
+ # @param actual [String, Symbol, #to_s, nil] observed MIME type
50
+ # @param expected [Array<String>] canonical MIME types to match
51
+ # @return [Boolean]
52
+ def mime_match?(actual, *expected)
53
+ base = mime_base(actual)
54
+ expected.any? { |candidate| base == candidate || base == mime_base(candidate) }
55
+ end
57
56
 
58
- ##
59
- # @param doc [Nokogiri::XML::Node]
60
- # @param mime_types [Array<String>] optional MIME filters (folded once per call)
61
- # @return [Array<Nokogiri::XML::Element>]
62
- def scripts(doc, *mime_types)
63
- return doc.css('script') if mime_types.empty?
57
+ ##
58
+ # @param doc [Nokogiri::XML::Node]
59
+ # @param mime_types [Array<String>] optional MIME filters (folded once per call)
60
+ # @return [Array<Nokogiri::XML::Element>]
61
+ def scripts(doc, *mime_types)
62
+ return doc.css('script') if mime_types.empty?
64
63
 
65
- allowed = mime_types.to_set { |mime| mime_base(mime) }
66
- doc.css('script').select { |script| allowed.include?(mime_base(script['type'])) }
67
- end
64
+ allowed = mime_types.to_set { |mime| mime_base(mime) }
65
+ doc.css('script').select { |script| allowed.include?(mime_base(script['type'])) }
66
+ end
68
67
 
69
- ##
70
- # @param doc [Nokogiri::XML::Node]
71
- # @param rel [String] link rel token
72
- # @param mime [String, nil] optional MIME filter
73
- # @return [Array<Nokogiri::XML::Element>]
74
- def alternate_links(doc, rel:, mime: nil)
75
- nodes = doc.css(%(link[rel~="#{rel}"][href]))
76
- return nodes if mime.nil?
68
+ ##
69
+ # @param doc [Nokogiri::XML::Node]
70
+ # @param rel [String] link rel token
71
+ # @param mime [String, nil] optional MIME filter
72
+ # @return [Array<Nokogiri::XML::Element>]
73
+ def alternate_links(doc, rel:, mime: nil)
74
+ nodes = doc.css(%(link[rel~="#{rel}"][href]))
75
+ return nodes if mime.nil?
77
76
 
78
- nodes.select { |node| mime_match?(node['type'], mime) }
77
+ nodes.select { |node| mime_match?(node['type'], mime) }
78
+ end
79
79
  end
80
80
  end
81
81
  end
@@ -106,13 +106,13 @@ module Html2rss
106
106
  def shallow?
107
107
  segment_count = segments.size
108
108
 
109
- segment_count <= 1 || (segment_count == 2 && high_confidence_junk_at?(segment_count - 1))
109
+ segment_count <= 1 || (segment_count == 2 && high_confidence_junk_at?(1))
110
110
  end
111
111
 
112
112
  # @return [Boolean] true when the final path segment looks like a post slug
113
113
  def strong_post_suffix?
114
114
  @strong_post_suffix ||= segments.any? &&
115
- included_last_segment? &&
115
+ slug_last_segment? &&
116
116
  trusted_post_context?(segments.size - 1)
117
117
  end
118
118
 
@@ -121,13 +121,9 @@ module Html2rss
121
121
  (0...segments.size).all? { |i| high_confidence_junk_at?(i) }
122
122
  end
123
123
 
124
- # @return [Boolean] true when the route is shallow and contains high-confidence noise
125
- def shallow_high_confidence_route?
126
- vanity_segments = SEGMENT_SETS.fetch(:vanity)
127
-
128
- shallow? && (0...segments.size).any? do |i|
129
- high_confidence_junk_at?(i) || vanity_segments.include?(@lexicon[i])
130
- end
124
+ # @return [Boolean] true when the route is shallow and names a soft vanity segment
125
+ def shallow_vanity_route?
126
+ shallow? && @lexicon.any? { |folded| SEGMENT_SETS.fetch(:vanity).include?(folded) }
131
127
  end
132
128
 
133
129
  # @return [Boolean] true when the leading segments are all utility chrome
@@ -139,11 +135,7 @@ module Html2rss
139
135
  def junk_path?
140
136
  return false if excluded_content_route?
141
137
 
142
- any_high_confidence_junk_segment? ||
143
- taxonomy_path? ||
144
- utility_only_route? ||
145
- deep_utility_context_route? ||
146
- shallow_high_confidence_route?
138
+ any_high_confidence_junk_segment? || shallow_vanity_route?
147
139
  end
148
140
 
149
141
  # @return [Boolean] true when the route points at conversion or account chrome
@@ -164,8 +156,7 @@ module Html2rss
164
156
  end
165
157
 
166
158
  def yearish_content_context?
167
- segments.any? { |segment| segment.match?(YEARISH_SEGMENT) } &&
168
- (strong_post_suffix? || trusted_post_context?(segments.size - 1))
159
+ segments.any? { |segment| segment.match?(YEARISH_SEGMENT) } && trusted_post_context?(segments.size - 1)
169
160
  end
170
161
 
171
162
  def excluded_content_route?
@@ -199,28 +190,15 @@ module Html2rss
199
190
  end
200
191
 
201
192
  def trusted_post_context?(limit)
202
- return false if limit <= 0
203
193
  return false if leading_high_confidence_junk?
204
194
 
205
- content_segments = SEGMENT_SETS.fetch(:content)
206
- context_segments = SEGMENT_SETS.fetch(:deep_post_context)
207
-
208
- (0...limit).any? do |i|
209
- content_segments.include?(@lexicon[i]) ||
210
- segments[i].match?(PathClassifier::YEARISH_SEGMENT) ||
211
- context_segments.include?(@lexicon[i])
195
+ content = SEGMENT_SETS.fetch(:content)
196
+ context = SEGMENT_SETS.fetch(:deep_post_context)
197
+ segments.zip(@lexicon).first(limit.clamp(0..)).any? do |segment, folded|
198
+ content.include?(folded) || segment.match?(YEARISH_SEGMENT) || context.include?(folded)
212
199
  end
213
200
  end
214
201
 
215
- def included_last_segment?
216
- !excluded_last_segment? && slug_last_segment?
217
- end
218
-
219
- def excluded_last_segment?
220
- last_i = segments.size - 1
221
- high_confidence_junk_at?(last_i) || SEGMENT_SETS[:vanity].include?(@lexicon[last_i])
222
- end
223
-
224
202
  def slug_last_segment?
225
203
  last = segments.last
226
204
  last.match?(YEARISH_SEGMENT) || last.match?(POST_SLUG_SEGMENT)
@@ -14,14 +14,15 @@ module Html2rss
14
14
  # @param yaml [String, nil] feed configuration YAML
15
15
  # @return [ConfigArgument]
16
16
  # @raise [ArgumentError] unless exactly one of +config+ or +yaml+ is present
17
+ # @raise [Html2rss::InvalidOption] when +config+ is not a Hash or +yaml+ is not a String
17
18
  # @raise [Contract::UnpublishedRequestError] when the parsed config uses
18
19
  # an unpublished MCP strategy or +request.local_file_path+
19
20
  def parse(config: nil, yaml: nil)
20
21
  parsed = case [present?(config), present?(yaml)]
21
22
  in [true, false]
22
- HashUtil.deep_symbolize_keys(config, context: 'config')
23
+ HashUtil.deep_symbolize_keys(require_type!(config, Hash, 'config'), context: 'config')
23
24
  in [false, true]
24
- Config.from_yaml(yaml)
25
+ Config.from_yaml(require_type!(yaml, String, 'yaml'))
25
26
  else
26
27
  raise ArgumentError, 'Provide exactly one of config or yaml'
27
28
  end
@@ -31,6 +32,12 @@ module Html2rss
31
32
 
32
33
  private
33
34
 
35
+ def require_type!(value, type, name)
36
+ return value if value.is_a?(type)
37
+
38
+ raise Html2rss::InvalidOption, "#{name} must be a #{type}, got #{value.class}"
39
+ end
40
+
34
41
  def present?(value)
35
42
  case value
36
43
  in nil then false
@@ -103,7 +103,7 @@ module Html2rss
103
103
  properties: {
104
104
  url: URL_PROPERTY,
105
105
  strategy: STRATEGY_PROPERTY,
106
- limit: { type: 'integer', description: 'Max articles to keep (default 25)', default: 25 },
106
+ limit: { type: 'integer', minimum: 1, description: 'Max articles to keep (default 25)', default: 25 },
107
107
  items_selector: { type: 'string', description: 'Optional CSS selector hint for items' }
108
108
  }.freeze,
109
109
  required: %w[url]
@@ -135,9 +135,9 @@ module Html2rss
135
135
  title: { type: 'string', description: 'Channel title override' },
136
136
  summary: { type: 'string', description: 'Directory summary override' },
137
137
  enhance: { type: 'boolean', description: 'Force enhance on or off' },
138
- limit: { type: 'integer', description: 'Max articles to keep' },
139
- max_redirects: { type: 'integer', description: 'Optional redirect limit override' },
140
- max_requests: { type: 'integer', description: 'Optional request budget override' }
138
+ limit: { type: 'integer', minimum: 1, description: 'Max articles to keep' },
139
+ max_redirects: { type: 'integer', minimum: 0, description: 'Optional redirect limit override' },
140
+ max_requests: { type: 'integer', minimum: 1, description: 'Optional request budget override' }
141
141
  }.freeze,
142
142
  required: %w[url]
143
143
  }.freeze
@@ -163,6 +163,8 @@ module Html2rss
163
163
  strategy: strategy_property,
164
164
  concurrency: {
165
165
  type: 'integer',
166
+ minimum: 1,
167
+ maximum: 10,
166
168
  description: 'Max parallel worker threads (1..10, default: 5)',
167
169
  default: 5
168
170
  },
@@ -177,7 +179,7 @@ module Html2rss
177
179
  urls_description: 'List of page URLs to scrape (1..25)',
178
180
  strategy_property: STRATEGY_PROPERTY,
179
181
  extra_properties: {
180
- limit: { type: 'integer', description: 'Max articles per URL to keep (default 10)', default: 10 }
182
+ limit: { type: 'integer', minimum: 1, description: 'Max articles per URL to keep (default 10)', default: 10 }
181
183
  }
182
184
  ).freeze
183
185
 
@@ -60,7 +60,7 @@ module Html2rss
60
60
  ##
61
61
  # @param report [PageRecon::Diagnostics::Report]
62
62
  # @return [Outcome]
63
- def inspect(report:)
63
+ def inspect_report(report:)
64
64
  next_step = inspect_next_step(report)
65
65
  guidance = Playbook.inspect_guidance(report)
66
66
  new(ok: true, next_step:, guidance:, payload: report.to_wire_h)
@@ -194,7 +194,7 @@ module Html2rss
194
194
  NextStep[:scrape]
195
195
  end
196
196
 
197
- def capture_next_step(articles_count:, has_selectors:, native_feed: nil)
197
+ def capture_next_step(articles_count:, has_selectors:, native_feed:)
198
198
  return NextStep[:done] if native_feed
199
199
 
200
200
  articles_count.positive? && has_selectors ? NextStep[:test] : NextStep[:inspect]
@@ -205,7 +205,6 @@ module Html2rss
205
205
 
206
206
  kind = test_result.failure_kind
207
207
  return NextStep[:validate] if kind&.schema?
208
- return NextStep[:capture] if kind&.execution? || kind&.min_items? || kind&.quality?
209
208
 
210
209
  NextStep[:capture]
211
210
  end
@@ -223,8 +222,8 @@ module Html2rss
223
222
  end
224
223
 
225
224
  def capture_payload(yaml:, articles_count:, has_selectors:, channel_title:, requested_strategy:, # rubocop:disable Metrics/ParameterLists
226
- segment_strategy:, selected_strategy:, admission_drops:, native_feed: nil,
227
- suggested_channel_url: nil)
225
+ segment_strategy:, selected_strategy:, admission_drops:, native_feed:,
226
+ suggested_channel_url:)
228
227
  {
229
228
  yaml:, articles_count:, has_selectors:, channel_title:, requested_strategy: requested_strategy.to_s,
230
229
  **(native_feed ? { native_feed: native_feed.to_s } : {}),
@@ -14,31 +14,24 @@ module Html2rss
14
14
  :botasaurus_configured
15
15
  )
16
16
 
17
- module_function
17
+ class << self
18
+ ##
19
+ # @return [Boolean] whether Botasaurus transport is configured in this process
20
+ def botasaurus_configured?
21
+ !ENV['BOTASAURUS_SCRAPER_URL'].to_s.strip.empty?
22
+ end
18
23
 
19
- ##
20
- # @return [Boolean] whether Botasaurus transport is configured in this process
21
- def botasaurus_configured?
22
- !ENV['BOTASAURUS_SCRAPER_URL'].to_s.strip.empty?
23
- end
24
-
25
- ##
26
- # @return [Snapshot] runtime capabilities and catalog identity for MCP clients
27
- def snapshot
28
- Snapshot.new(
29
- version: Html2rss::VERSION,
30
- mcp_contract_version: Contract::MCP_CONTRACT_VERSION,
31
- catalog_fingerprint: Contract.catalog_fingerprint,
32
- tools: Contract.catalog_tools,
33
- botasaurus_configured: botasaurus_configured?
34
- )
35
- end
36
-
37
- ##
38
- # @param strategy [String, Symbol, nil]
39
- # @return [Symbol]
40
- def coerce_strategy(strategy)
41
- (strategy || :auto).to_sym
24
+ ##
25
+ # @return [Snapshot] runtime capabilities and catalog identity for MCP clients
26
+ def snapshot
27
+ Snapshot.new(
28
+ version: Html2rss::VERSION,
29
+ mcp_contract_version: Contract::MCP_CONTRACT_VERSION,
30
+ catalog_fingerprint: Contract.catalog_fingerprint,
31
+ tools: Contract.catalog_tools,
32
+ botasaurus_configured: botasaurus_configured?
33
+ )
34
+ end
42
35
  end
43
36
  end
44
37
  end
@@ -36,9 +36,9 @@ module Html2rss
36
36
  input_schema: Contract::INSPECT_INPUT_SCHEMA,
37
37
  annotations: Contract::ANNOTATIONS_OPEN_WORLD,
38
38
  call: lambda { |url:, strategy: 'auto', **|
39
- Outcome.inspect(
39
+ Outcome.inspect_report(
40
40
  report: PageRecon::Diagnostics.call(
41
- url:, strategy: Runtime.coerce_strategy(strategy), deep: false
41
+ url:, strategy: Option.strategy(strategy), deep: false
42
42
  )
43
43
  )
44
44
  }
@@ -51,7 +51,7 @@ module Html2rss
51
51
  input_schema: Contract::RECON_INPUT_SCHEMA,
52
52
  annotations: Contract::ANNOTATIONS_OPEN_WORLD,
53
53
  call: lambda { |url:, strategy: 'auto', **|
54
- Outcome.recon(result: Html2rss.recon(url, strategy: Runtime.coerce_strategy(strategy)))
54
+ Outcome.recon(result: Html2rss.recon(url, strategy: Option.strategy(strategy)))
55
55
  }
56
56
  ),
57
57
  Tool.new(
@@ -63,7 +63,7 @@ module Html2rss
63
63
  annotations: Contract::ANNOTATIONS_OPEN_WORLD,
64
64
  call: lambda { |urls:, strategy: 'auto', concurrency: Batch::DEFAULT_CONCURRENCY, limit: 10, **|
65
65
  Outcome.batch_scrape(
66
- Batch.batch_scrape(urls:, strategy: Runtime.coerce_strategy(strategy), concurrency:, limit:)
66
+ Batch.batch_scrape(urls:, strategy: Option.strategy(strategy), concurrency:, limit:)
67
67
  )
68
68
  }
69
69
  ),
@@ -76,7 +76,7 @@ module Html2rss
76
76
  annotations: Contract::ANNOTATIONS_OPEN_WORLD,
77
77
  call: lambda { |urls:, strategy: 'auto', concurrency: Batch::DEFAULT_CONCURRENCY, **|
78
78
  Outcome.batch_inspect(
79
- Batch.batch_inspect(urls:, strategy: Runtime.coerce_strategy(strategy), concurrency:)
79
+ Batch.batch_inspect(urls:, strategy: Option.strategy(strategy), concurrency:)
80
80
  )
81
81
  }
82
82
  ),
@@ -89,7 +89,7 @@ module Html2rss
89
89
  annotations: Contract::ANNOTATIONS_OPEN_WORLD,
90
90
  call: lambda { |urls:, strategy: 'auto', concurrency: Batch::DEFAULT_CONCURRENCY, **|
91
91
  Outcome.batch_recon(
92
- Batch.batch_recon(urls:, strategy: Runtime.coerce_strategy(strategy), concurrency:)
92
+ Batch.batch_recon(urls:, strategy: Option.strategy(strategy), concurrency:)
93
93
  )
94
94
  }
95
95
  ),
@@ -136,7 +136,7 @@ module Html2rss
136
136
  compare_enhance: false, **kwargs|
137
137
  feed_config = ConfigArgument.parse(config:, yaml:).config
138
138
  test_args = { min_items:, strict_quality:, compare_enhance: }
139
- test_args[:strategy] = Runtime.coerce_strategy(kwargs[:strategy]) if kwargs.key?(:strategy)
139
+ test_args[:strategy] = Option.strategy(kwargs[:strategy]) if kwargs.key?(:strategy)
140
140
  Outcome.test(Html2rss.test(feed_config, **test_args))
141
141
  }
142
142
  ),
@@ -163,7 +163,7 @@ module Html2rss
163
163
  # @param items_selector [String, nil]
164
164
  # @return [Outcome]
165
165
  def scrape_outcome(url:, strategy: 'auto', limit: 25, items_selector: nil)
166
- wire = Batch.scrape_wire(url:, strategy: Runtime.coerce_strategy(strategy), limit:, items_selector:)
166
+ wire = Batch.scrape_wire(url:, strategy: Option.strategy(strategy), limit:, items_selector:)
167
167
  Outcome.scrape(
168
168
  items: wire[:items],
169
169
  requested_strategy: wire[:strategy],
@@ -189,7 +189,7 @@ module Html2rss
189
189
  # @return [Outcome]
190
190
  def capture_outcome(url:, strategy: 'auto', items_selector: nil, force: false, topics: nil, title: nil, # rubocop:disable Metrics/MethodLength, Metrics/ParameterLists
191
191
  summary: nil, enhance: nil, limit: nil, max_redirects: nil, max_requests: nil)
192
- plan = Runtime.coerce_strategy(strategy)
192
+ plan = Option.strategy(strategy)
193
193
  result = Html2rss::Capture.build(
194
194
  url,
195
195
  strategy: plan,
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ ##
5
+ # Edge validation for caller-supplied option values (public API, CLI, MCP).
6
+ # Everything past these checks may trust its types.
7
+ module Option
8
+ class << self
9
+ ##
10
+ # @param name [Symbol] option name used in the error message
11
+ # @param value [Object] candidate value
12
+ # @param min [Integer] inclusive lower bound
13
+ # @param max [Integer, nil] inclusive upper bound
14
+ # @param optional [Boolean] whether +nil+ passes through
15
+ # @return [Integer, nil] +value+ when valid
16
+ # @raise [InvalidOption] when +value+ is not an Integer within bounds
17
+ def integer(name, value, min:, max: nil, optional: false)
18
+ return value if value.nil? && optional
19
+ return value if value.is_a?(Integer) && (min..max).cover?(value)
20
+
21
+ raise InvalidOption, "#{name} must be an Integer in #{min}..#{max}, got #{value.inspect}"
22
+ end
23
+
24
+ ##
25
+ # @param value [Symbol, String, nil] strategy plan name (+nil+ means +:auto+)
26
+ # @return [Symbol] the plan name
27
+ # @raise [InvalidOption] when +value+ is not a registered plan
28
+ def strategy(value)
29
+ name = value.nil? ? FeedPipeline::StrategyPlan::AUTO_NAME : value
30
+ return name.to_sym if FeedPipeline::StrategyPlan.valid?(name)
31
+
32
+ accepted = FeedPipeline::StrategyPlan.accepted_names.join(', ')
33
+ raise InvalidOption, "strategy must be one of #{accepted}, got #{value.inspect}"
34
+ end
35
+
36
+ ##
37
+ # @param name [Symbol] option name used in the error message
38
+ # @param value [Object] candidate list
39
+ # @return [Array] +value+ when it is a non-empty Array
40
+ # @raise [InvalidOption] otherwise
41
+ def list(name, value)
42
+ return value if value.is_a?(Array) && value.any?
43
+
44
+ raise InvalidOption, "#{name} must be a non-empty Array, got #{value.inspect}"
45
+ end
46
+ end
47
+ end
48
+ end
@@ -34,14 +34,12 @@ module Html2rss
34
34
  JS_SHELL_MIN_BODY_BYTES = 8_192
35
35
  private_constant :JS_SHELL_MIN_BODY_BYTES
36
36
 
37
- module_function
38
-
39
37
  ##
40
38
  # @param url [String]
41
39
  # @param strategy [String, Symbol]
42
40
  # @param deep [Boolean] when true and strategy is auto, one Botasaurus hop if configured
43
41
  # @return [Report]
44
- def call(url:, strategy: :auto, deep: false)
42
+ def self.call(url:, strategy: :auto, deep: false)
45
43
  probe = PageRecon.probe(url, strategy: resolve_inspect_strategy(strategy, deep:))
46
44
  recon = probe.result
47
45
  response = probe.response
@@ -56,10 +54,10 @@ module Html2rss
56
54
  # @param strategy [Symbol, String]
57
55
  # @param concurrency [Integer]
58
56
  # @return [Array<Report>]
59
- def batch(urls:, strategy: :auto, concurrency: Batch::DEFAULT_CONCURRENCY)
57
+ def self.batch(urls:, strategy: :auto, concurrency: Batch::DEFAULT_CONCURRENCY)
60
58
  Batch.map(Array(urls), concurrency:) do |url|
61
59
  call(url:, strategy:)
62
- rescue StandardError => error
60
+ rescue Html2rss::Error => error
63
61
  error_report(url, error)
64
62
  end
65
63
  end
@@ -67,7 +65,7 @@ module Html2rss
67
65
  ##
68
66
  # @param response [Html2rss::RequestService::Response]
69
67
  # @return [Hash] redacted XHR capture diagnostics (no query strings)
70
- def xhr_capture_info(response)
68
+ def self.xhr_capture_info(response)
71
69
  captured = response.captured_responses
72
70
  {
73
71
  count: captured.size,
@@ -79,7 +77,7 @@ module Html2rss
79
77
  ##
80
78
  # @param entry [Hash] captured response hash
81
79
  # @return [String, nil] scheme+host+path only
82
- def redacted_endpoint(entry)
80
+ def self.redacted_endpoint(entry)
83
81
  raw = entry['url'] || entry[:url]
84
82
  return unless raw
85
83
 
@@ -94,7 +92,7 @@ module Html2rss
94
92
  ##
95
93
  # @param entry [Hash] captured response hash
96
94
  # @return [Boolean]
97
- def xhr_candidate_articles?(entry)
95
+ def self.xhr_candidate_articles?(entry)
98
96
  body = entry['body'] || entry[:body]
99
97
  return false unless body.is_a?(String)
100
98
 
@@ -107,7 +105,7 @@ module Html2rss
107
105
  ##
108
106
  # @param parsed [Object] parsed response body
109
107
  # @return [Array<String>, Hash]
110
- def scraper_info(parsed)
108
+ def self.scraper_info(parsed)
111
109
  return { error: 'Response is not HTML' } unless parsed.is_a?(Nokogiri::HTML::Document)
112
110
 
113
111
  begin
@@ -158,8 +158,6 @@ module Html2rss
158
158
  def self.discover_segments(sst, url)
159
159
  link_resolver = Scoring::LinkResolver.new(url)
160
160
  AutoSource::Segmenter.call(sst, base_url: url, strategy: :list, link_resolver:)
161
- rescue StandardError
162
- []
163
161
  end
164
162
 
165
163
  ##
@@ -268,8 +266,6 @@ module Html2rss
268
266
 
269
267
  def sst_document
270
268
  Html2rss::SST::Normalizer.call(response.body)
271
- rescue ArgumentError
272
- nil
273
269
  end
274
270
 
275
271
  def segment_stats(sst, page_url)