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
@@ -11,18 +11,20 @@ module Html2rss
11
11
  # Detection is intentionally shallow for most scrapers, but instance-based
12
12
  # matching is available for scrapers that need to carry expensive selection
13
13
  # state forward into extraction.
14
- module Scraper
14
+ module Scraper # rubocop:disable Metrics/ModuleLength -- tier registry + construction helpers
15
15
  # Root markers indicating likely app-shell/client-rendered surfaces.
16
16
  APP_SHELL_ROOT_SELECTORS = '#app, #root, #__next, [data-reactroot], [ng-app], [id*="app-shell"]'
17
17
  # Maximum anchors tolerated before app-shell detection is considered unlikely.
18
18
  APP_SHELL_MAX_ANCHORS = 2
19
+ # Minimum same-page anchors suggesting a high-entropy homepage/hub surface.
20
+ HIGH_ENTROPY_MIN_ANCHORS = 20
19
21
  # Maximum visible text length tolerated for app-shell classification.
20
22
  APP_SHELL_MAX_VISIBLE_TEXT_LENGTH = 220
21
23
 
22
24
  # Extraction tiers: merge within a tier, then stop when AutoSource has enough articles.
23
25
  # Heuristic scrapers are separate tiers so SemanticHtml can satisfy before Html runs.
24
26
  SCRAPER_TIERS = [
25
- [Schema, Microdata, Microformats2, JsonState].freeze,
27
+ [Schema, Microdata, Microformats2, JsonState, XhrArticles].freeze,
26
28
  [WordpressApi, Sitemap, MetaOembed].freeze,
27
29
  [SemanticHtml].freeze,
28
30
  [Html].freeze
@@ -35,19 +37,24 @@ module Html2rss
35
37
  HEURISTIC_SCRAPERS = [SemanticHtml, Html].freeze
36
38
  # Scrapers that accept a shared follow-up +request_session+.
37
39
  REQUEST_SESSION_SCRAPERS = [WordpressApi, Sitemap, MetaOembed].freeze
40
+ # Scrapers that consume browser-captured XHR/fetch JSON bodies.
41
+ CAPTURED_RESPONSE_SCRAPERS = [XhrArticles].freeze
38
42
 
39
43
  ##
40
44
  # Error raised when no suitable scraper is found.
41
45
  class NoScraperFound < Html2rss::Error
42
- # User-facing messages grouped by no-scraper surface category.
46
+ # Surface diagnostics shared with {Html2rss::NoFeedItemsExtracted} (one string home).
43
47
  CATEGORY_MESSAGES = {
44
- blocked_surface: 'No scrapers found: blocked surface likely (anti-bot or interstitial). ' \
45
- 'Retry with --strategy browserless, try a more specific public listing URL, ' \
48
+ blocked_surface: 'blocked surface likely (anti-bot or interstitial). ' \
49
+ 'Target a direct listing URL, configure BOTASAURUS_SCRAPER_URL, ' \
46
50
  'or run from an environment that can complete anti-bot checks.',
47
- app_shell: 'No scrapers found: app-shell surface detected (client-rendered page with little or no ' \
48
- 'server-rendered article HTML). Retry with --strategy browserless, or target a direct ' \
51
+ app_shell: 'app-shell surface detected (client-rendered page with little or no ' \
52
+ 'server-rendered article HTML). Configure BOTASAURUS_SCRAPER_URL or target a direct ' \
49
53
  'listing/update URL instead of a homepage or shell entrypoint.',
50
- unsupported_surface: 'No scrapers found: unsupported extraction surface for auto mode. ' \
54
+ high_entropy_surface: 'high-entropy surface (e.g. news homepage) with few or no durable ' \
55
+ 'article destinations after admission. Target a section, topic, or ' \
56
+ 'listing/update URL instead of the site homepage.',
57
+ unsupported_surface: 'unsupported extraction surface for auto mode. ' \
51
58
  'Try a direct listing/changelog/category URL, ' \
52
59
  'or use explicit selectors in a feed config.'
53
60
  }.freeze
@@ -57,7 +64,7 @@ module Html2rss
57
64
  def initialize(message = nil, category: :unsupported_surface)
58
65
  validate_category!(category)
59
66
  @category = category
60
- super(message || CATEGORY_MESSAGES.fetch(@category))
67
+ super(message || "No scrapers found: #{CATEGORY_MESSAGES.fetch(@category)}")
61
68
  end
62
69
 
63
70
  attr_reader :category
@@ -81,6 +88,7 @@ module Html2rss
81
88
  # @option opts [Hash] :microdata scraper toggle and configuration
82
89
  # @option opts [Hash] :microformats2 scraper toggle and configuration
83
90
  # @option opts [Hash] :json_state scraper toggle and configuration
91
+ # @option opts [Hash] :xhr_articles scraper toggle and configuration
84
92
  # @option opts [Hash] :meta_oembed scraper toggle and configuration
85
93
  # @option opts [Hash] :semantic_html scraper toggle and configuration
86
94
  # @option opts [Hash] :html scraper toggle and configuration
@@ -122,27 +130,36 @@ module Html2rss
122
130
  # @param body [String, nil]
123
131
  # @param document [SST::Document, nil]
124
132
  # @param link_resolver [Scoring::LinkResolver, nil]
133
+ # @param captured_responses [Array<Hash>] browser-captured JSON XHR/fetch bodies
125
134
  # @option opts [Hash] :wordpress_api scraper toggle and configuration
126
135
  # @option opts [Hash] :schema scraper toggle and configuration
127
136
  # @option opts [Hash] :microdata scraper toggle and configuration
128
137
  # @option opts [Hash] :microformats2 scraper toggle and configuration
129
138
  # @option opts [Hash] :json_state scraper toggle and configuration
139
+ # @option opts [Hash] :xhr_articles scraper toggle and configuration
130
140
  # @option opts [Hash] :meta_oembed scraper toggle and configuration
131
141
  # @option opts [Hash] :semantic_html scraper toggle and configuration
132
142
  # @option opts [Hash] :html scraper toggle and configuration
133
143
  # @option opts [Hash] :sitemap scraper toggle and configuration
134
144
  # @return [Object, nil]
135
- # rubocop:disable Metrics/ParameterLists -- construction context for structured and heuristic scrapers
145
+ # rubocop:disable Metrics/ParameterLists, Metrics/MethodLength -- construction context for structured and heuristic scrapers
136
146
  def self.build_instance(scraper, parsed_body, opts:, url:, request_session: nil, body: nil, document: nil,
137
- link_resolver: nil)
147
+ link_resolver: nil, captured_responses: [])
138
148
  return unless opts.dig(scraper.options_key, :enabled)
139
149
  return if HEURISTIC_SCRAPERS.include?(scraper) && document.nil?
140
150
 
141
151
  scraper_opts = opts.fetch(scraper.options_key, {}).except(:enabled)
142
- kwargs = construction_kwargs(scraper, request_session:, body:, document:, link_resolver:)
152
+ kwargs = construction_kwargs(
153
+ scraper,
154
+ request_session:,
155
+ body:,
156
+ document:,
157
+ link_resolver:,
158
+ captured_responses:
159
+ )
143
160
  scraper.new(parsed_body, url:, **kwargs, **scraper_opts)
144
161
  end
145
- # rubocop:enable Metrics/ParameterLists
162
+ # rubocop:enable Metrics/ParameterLists, Metrics/MethodLength
146
163
 
147
164
  ##
148
165
  # @param instance [Object]
@@ -162,25 +179,33 @@ module Html2rss
162
179
  NoScraperFound.new(category: classify_no_scraper_surface(parsed_body, body:))
163
180
  end
164
181
 
165
- def self.construction_kwargs(scraper, request_session:, body:, document:, link_resolver:)
182
+ def self.construction_kwargs(scraper, request_session:, body:, document:, link_resolver:, # rubocop:disable Metrics/ParameterLists -- scraper construction bag
183
+ captured_responses:)
166
184
  if HEURISTIC_SCRAPERS.include?(scraper)
167
185
  { document:, link_resolver: }.compact
168
186
  else
169
187
  {}.tap do |kwargs|
170
188
  kwargs[:request_session] = request_session if REQUEST_SESSION_SCRAPERS.include?(scraper)
171
189
  kwargs[:body] = body if scraper == Sitemap
190
+ kwargs[:captured_responses] = captured_responses if CAPTURED_RESPONSE_SCRAPERS.include?(scraper)
172
191
  end
173
192
  end
174
193
  end
175
194
  private_class_method :construction_kwargs
176
195
 
196
+ ##
197
+ # Classifies why scrapers could not extract from a parsed page.
198
+ #
199
+ # @param parsed_body [Nokogiri::HTML::Document]
200
+ # @param body [String, nil] raw body for blocked-surface detection
201
+ # @return [Symbol] one of {NoScraperFound::CATEGORY_MESSAGES} keys
177
202
  def self.classify_no_scraper_surface(parsed_body, body: nil)
178
203
  return :blocked_surface if blocked_surface?(parsed_body, body:)
179
204
  return :app_shell if app_shell_surface?(parsed_body)
205
+ return :high_entropy_surface if high_entropy_surface?(parsed_body)
180
206
 
181
207
  :unsupported_surface
182
208
  end
183
- private_class_method :classify_no_scraper_surface
184
209
 
185
210
  def self.blocked_surface?(parsed_body, body: nil)
186
211
  Html2rss::RequestService::BlockedSurface.interstitial?(body || parsed_body.to_html)
@@ -197,6 +222,13 @@ module Html2rss
197
222
  end
198
223
  private_class_method :app_shell_surface?
199
224
 
225
+ def self.high_entropy_surface?(parsed_body)
226
+ return false if parsed_body.nil?
227
+
228
+ parsed_body.css('body a[href]').size >= HIGH_ENTROPY_MIN_ANCHORS
229
+ end
230
+ private_class_method :high_entropy_surface?
231
+
200
232
  def self.sparse_anchor_surface?(parsed_body)
201
233
  parsed_body.css('body a[href]').size <= APP_SHELL_MAX_ANCHORS
202
234
  end
@@ -70,7 +70,9 @@ module Html2rss
70
70
  return false unless facts
71
71
 
72
72
  text = node.visible_text.to_s.strip
73
- !segmenter.noise_policy.noise_anchor?(text:, destination_facts: facts, anchor: node)
73
+ !segmenter.noise_policy.noise_anchor?(
74
+ text:, destination_facts: facts, anchor: node, utility_landmark_ancestor: false
75
+ )
74
76
  end
75
77
  module_function :relevant_anchor?
76
78
  private_class_method :relevant_anchor?
@@ -51,16 +51,15 @@ module Html2rss
51
51
  heading_match = meaningful && heading_text.match?(/\p{Alnum}/) && heading_text == text
52
52
 
53
53
  return if @noise_policy.noise_anchor?(
54
- text:, destination_facts: destination, anchor:, container:, heading_anchor:
54
+ text:, destination_facts: destination, anchor:, container:, heading_anchor:,
55
+ utility_landmark_ancestor: @segmenter.landmark_ancestor?(anchor, container)
55
56
  )
56
57
  return unless meaningful || content_like || heading_anchor
57
58
 
58
- score = Scoring::AnchorScore.score(
59
- heading_anchor:,
60
- heading_text_match: heading_match,
61
- meaningful_text: meaningful,
62
- content_like_destination: content_like
63
- )
59
+ score = (heading_anchor ? 100 : 0) +
60
+ (heading_match ? 20 : 0) +
61
+ (meaningful ? 10 : 0) +
62
+ (content_like ? 10 : 0)
64
63
 
65
64
  { anchor:, destination: destination.destination, score: }
66
65
  end
@@ -44,7 +44,7 @@ module Html2rss
44
44
  @minimum_selector_frequency = opts.fetch(:minimum_selector_frequency, 2)
45
45
  @use_top_selectors = opts.fetch(:use_top_selectors, 5)
46
46
  @link_resolver = opts[:link_resolver] || Scoring::LinkResolver.new(base_url)
47
- @noise_policy = LinkDestination::NoisePolicy.new(link_resolver: @link_resolver, index: document.index)
47
+ @noise_policy = LinkDestination::NoisePolicy.new(link_resolver: @link_resolver)
48
48
  end
49
49
 
50
50
  # @return [Array<Segment>]
@@ -61,6 +61,35 @@ module Html2rss
61
61
 
62
62
  # @return [SST::Index]
63
63
  def index = document.index
64
+
65
+ ##
66
+ # Whether +anchor+ has a utility-landmark ancestor outside +container+.
67
+ #
68
+ # @param anchor [SST::Node]
69
+ # @param container [SST::Node]
70
+ # @return [Boolean]
71
+ def landmark_ancestor?(anchor, container)
72
+ cache = (@landmark_cache ||= {})
73
+ key = [anchor.object_id, container.object_id]
74
+ return cache[key] if cache.key?(key)
75
+
76
+ cache[key] = compute_landmark_ancestor?(anchor:, container:)
77
+ end
78
+
79
+ private
80
+
81
+ def compute_landmark_ancestor?(anchor:, container:) # rubocop:disable Metrics/CyclomaticComplexity
82
+ return false unless anchor && container
83
+
84
+ curr = index.parent_of(anchor)
85
+ while curr && curr.name != :html
86
+ return true if curr != container && curr.utility_landmark?
87
+ return false if curr.equal?(container)
88
+
89
+ curr = index.parent_of(curr)
90
+ end
91
+ false
92
+ end
64
93
  end
65
94
  end
66
95
  end
@@ -15,8 +15,8 @@ module Html2rss
15
15
  # @see Html2rss::AutoSource::Scraper::Schema
16
16
  # @see Html2rss::AutoSource::Scraper::SemanticHtml
17
17
  # @see Html2rss::AutoSource::Scraper::Html
18
- # rubocop:disable Metrics/ClassLength -- defaults + tiered extract stay on the contributor entry type
19
- class AutoSource
18
+ # {include:file:lib/html2rss/auto_source/README.md}
19
+ class AutoSource # rubocop:disable Metrics/ClassLength -- defaults + tiered extract stay on the contributor entry type
20
20
  # Default max articles to keep (also the short-circuit floor across scraper tiers).
21
21
  DEFAULT_LIMIT = 25
22
22
 
@@ -44,6 +44,9 @@ module Html2rss
44
44
  json_state: {
45
45
  enabled: true
46
46
  },
47
+ xhr_articles: {
48
+ enabled: true
49
+ },
47
50
  meta_oembed: {
48
51
  enabled: true
49
52
  },
@@ -93,6 +96,7 @@ module Html2rss
93
96
  @parsed_body = response.parsed_body
94
97
  @body = response.body
95
98
  @url = response.url
99
+ @captured_responses = response.captured_responses
96
100
  @opts = opts
97
101
  @request_session = request_session
98
102
  end
@@ -103,18 +107,29 @@ module Html2rss
103
107
  # Tiers: in-page structured → follow-up IO → SemanticHtml → Html.
104
108
  # SST is built only when a heuristic tier runs. Later tiers are skipped once
105
109
  # +limit+ articles with url+title remain after Cleanup; the result is capped to +limit+.
110
+ # Html is skipped when earlier tiers already admitted at least one clean article
111
+ # below +limit+ — quality over padding with weaker heuristic junk.
106
112
  #
107
113
  # @return [Array<Html2rss::Article>] extracted articles
108
114
  def articles
109
115
  @articles ||= extract_articles
110
116
  rescue Html2rss::AutoSource::Scraper::NoScraperFound => error
111
117
  Log.warn "#{self.class}: no scraper matched #{url} (#{error.message})"
118
+ @admission_drops = {}.freeze
112
119
  []
113
120
  end
114
121
 
122
+ ##
123
+ # Reason → count tallies from the final {Cleanup} pass (empty until {#articles} runs).
124
+ #
125
+ # @return [Hash{String => Integer}]
126
+ def admission_drops
127
+ @admission_drops || {}
128
+ end
129
+
115
130
  private
116
131
 
117
- attr_reader :url, :parsed_body, :body, :request_session
132
+ attr_reader :url, :parsed_body, :body, :request_session, :captured_responses
118
133
 
119
134
  # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
120
135
  def extract_articles
@@ -126,6 +141,7 @@ module Html2rss
126
141
 
127
142
  Scraper::SCRAPER_TIERS.each do |tier|
128
143
  break if enough_articles?(articles)
144
+ break if skip_html_padding?(tier, articles)
129
145
 
130
146
  if Scraper.heuristic_tier?(tier)
131
147
  document ||= Scraper.normalize_sst(parsed_body)
@@ -143,7 +159,8 @@ module Html2rss
143
159
  request_session:,
144
160
  body:,
145
161
  document:,
146
- link_resolver:
162
+ link_resolver:,
163
+ captured_responses:
147
164
  )
148
165
  next unless instance
149
166
  next unless Scraper.extractable_instance?(instance, parsed_body)
@@ -155,16 +172,31 @@ module Html2rss
155
172
 
156
173
  raise Scraper.no_scraper_found_for(parsed_body, body:) unless matched
157
174
 
158
- Cleanup.call(articles, url:, **cleanup_options).first(article_limit)
175
+ result = Cleanup.call(articles, url:, **cleanup_options)
176
+ @admission_drops = result.drop_tallies
177
+ result.articles.first(article_limit)
159
178
  end
160
179
  # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
161
180
 
162
181
  def enough_articles?(articles)
163
- threshold = article_limit
164
- return false if articles.size < threshold
182
+ return false if articles.size < article_limit
165
183
 
166
- cleaned = Cleanup.call(articles.dup, url:, **cleanup_options)
167
- cleaned.count { |article| article.url && !article.title.to_s.empty? } >= threshold
184
+ admitted_articles(articles).size >= article_limit
185
+ end
186
+
187
+ # Prefer fewer clean items over refilling +limit+ from the Html heuristic tier.
188
+ def skip_html_padding?(tier, articles)
189
+ html_only_tier?(tier) && admitted_articles(articles).any?
190
+ end
191
+
192
+ def html_only_tier?(tier)
193
+ tier == [Scraper::Html]
194
+ end
195
+
196
+ def admitted_articles(articles)
197
+ Cleanup.call(articles.dup, url:, **cleanup_options).articles.select do |article|
198
+ article.url && !article.title.to_s.empty?
199
+ end
168
200
  end
169
201
 
170
202
  def article_limit
@@ -188,5 +220,4 @@ module Html2rss
188
220
  @opts.fetch(:cleanup, {})
189
221
  end
190
222
  end
191
- # rubocop:enable Metrics/ClassLength
192
223
  end
@@ -0,0 +1,61 @@
1
+ # Capture
2
+
3
+ `Html2rss.capture` (and CLI `html2rss capture`) analyzes a URL through the feed pipeline and produces a reusable config with **items selector + `enhance: true` only** — no title/url/description attribute-selector soup. At feed-build time, `enhance: true` fills missing article fields via `Html::ArticleExtractor` on each matched item.
4
+
5
+ ## When to use it
6
+
7
+ Point `capture` at a listing URL when you want a first-draft YAML config instead of hand-writing selectors. Treat the output as a draft: selector quality depends on page structure.
8
+
9
+ ## Gem API
10
+
11
+ ```ruby
12
+ config = Html2rss.capture('https://example.com/articles')
13
+
14
+ # {
15
+ # channel: { url: "...", title: "...", time_zone: "UTC" },
16
+ # selectors: {
17
+ # items: { selector: "div.post", enhance: true }
18
+ # }
19
+ # }
20
+
21
+ File.write('my-feed.yml', YAML.dump(Html2rss::HashUtil.deep_stringify_keys(config)))
22
+ feed = Html2rss.feed(config)
23
+ ```
24
+
25
+ `Capture.build` returns a `CaptureResult` with quality meta (`has_selectors`, `segment_strategy`, `admission_drops`, `selected_strategy`). `Html2rss.capture` returns only the config hash.
26
+
27
+ ### Options
28
+
29
+ ```ruby
30
+ Html2rss.capture('https://spa-site.com', strategy: :botasaurus)
31
+ Html2rss.capture('https://example.com', items_selector: '.article-card')
32
+ Html2rss.capture('https://example.com', strategy: :local_file, local_file_path: './page.html')
33
+ ```
34
+
35
+ `strategy: :auto` uses the same AutoFallback chain as scrape (`faraday` → `botasaurus`). When AutoFallback selects a concrete strategy (or you pin one), Capture **stamps** `strategy:` into the emitted config so later `Html2rss.feed(config)` replays the same transport.
36
+
37
+ ## CLI
38
+
39
+ ```bash
40
+ html2rss capture https://example.com/articles
41
+ html2rss capture https://example.com --strategy botasaurus
42
+ html2rss capture https://example.com/articles > my-feed.yml
43
+ html2rss capture https://example.com --input ./page.html
44
+ html2rss capture https://example.com --explain # quality JSON on stderr; YAML on stdout
45
+ ```
46
+
47
+ ## How it works
48
+
49
+ 1. **Request** — `FeedPipeline` (AutoFallback when `:auto`)
50
+ 2. **Discover** — AutoSource extracts admitted articles
51
+ 3. **Segment** — try SST Segmenter strategies `:list` → `:cluster` → `:semantic`
52
+ 4. **Gate** — emit items selector only when ≥ `MIN_SELECTOR_MATCHES` (2) articles match
53
+ 5. **Assemble** — `{ items: { selector:, enhance: true } }` plus channel
54
+
55
+ ## Constraints
56
+
57
+ - Selector quality depends on page structure; treat output as a first draft.
58
+ - When the quality gate fails, selectors are omitted (`has_selectors: false`) rather than inventing attribute selectors.
59
+ - Botasaurus hops need `BOTASAURUS_SCRAPER_URL`.
60
+
61
+ See also {Html2rss::AutoSource} for article discovery and {Html2rss::FeedPipeline} for the `:auto` request chain.