html2rss 0.27.2 → 0.29.1

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 (94) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +82 -25
  3. data/html2rss.gemspec +0 -1
  4. data/lib/html2rss/article.rb +1 -2
  5. data/lib/html2rss/auto_source/README.md +12 -9
  6. data/lib/html2rss/auto_source/cleanup.rb +92 -1
  7. data/lib/html2rss/auto_source/scraper/json_state/document_scanner.rb +3 -5
  8. data/lib/html2rss/auto_source/scraper/meta_oembed.rb +15 -5
  9. data/lib/html2rss/auto_source/scraper/microdata.rb +8 -6
  10. data/lib/html2rss/auto_source/scraper/native_feed.rb +93 -0
  11. data/lib/html2rss/auto_source/scraper/schema.rb +39 -21
  12. data/lib/html2rss/auto_source/scraper.rb +6 -4
  13. data/lib/html2rss/auto_source/segmenter/cluster.rb +2 -3
  14. data/lib/html2rss/auto_source/segmenter/primary_link.rb +1 -2
  15. data/lib/html2rss/auto_source/segmenter.rb +1 -2
  16. data/lib/html2rss/auto_source.rb +32 -11
  17. data/lib/html2rss/batch.rb +169 -0
  18. data/lib/html2rss/capture/README.md +15 -6
  19. data/lib/html2rss/capture.rb +158 -15
  20. data/lib/html2rss/channel.rb +1 -2
  21. data/lib/html2rss/cli/probe_view.rb +66 -0
  22. data/lib/html2rss/cli/render.rb +124 -0
  23. data/lib/html2rss/cli/validate.rb +91 -0
  24. data/lib/html2rss/cli.rb +318 -193
  25. data/lib/html2rss/config/auto_source_contract.rb +7 -0
  26. data/lib/html2rss/config/request_headers.rb +4 -14
  27. data/lib/html2rss/config/schema.rb +7 -5
  28. data/lib/html2rss/config/validator.rb +1 -0
  29. data/lib/html2rss/config.rb +64 -18
  30. data/lib/html2rss/doctor/botasaurus.rb +130 -0
  31. data/lib/html2rss/feed_builder/rss.rb +1 -2
  32. data/lib/html2rss/feed_pipeline/README.md +7 -5
  33. data/lib/html2rss/feed_pipeline/auto_fallback.rb +78 -19
  34. data/lib/html2rss/feed_pipeline/runtime_policy.rb +1 -0
  35. data/lib/html2rss/feed_pipeline.rb +48 -21
  36. data/lib/html2rss/feed_resolution/README.md +52 -0
  37. data/lib/html2rss/feed_resolution/candidate_generator.rb +139 -0
  38. data/lib/html2rss/feed_resolution/diag.rb +32 -0
  39. data/lib/html2rss/feed_resolution/options.rb +31 -0
  40. data/lib/html2rss/feed_resolution/policy.rb +47 -0
  41. data/lib/html2rss/feed_resolution/probe.rb +59 -0
  42. data/lib/html2rss/feed_resolution/scorer.rb +49 -0
  43. data/lib/html2rss/feed_resolution.rb +268 -0
  44. data/lib/html2rss/html/article_extractor/heading_extractor.rb +24 -14
  45. data/lib/html2rss/html/article_extractor.rb +4 -9
  46. data/lib/html2rss/html/feed_link.rb +4 -2
  47. data/lib/html2rss/html/navigator.rb +4 -39
  48. data/lib/html2rss/html/probe.rb +82 -0
  49. data/lib/html2rss/html/sst_article_extractor.rb +1 -2
  50. data/lib/html2rss/link_destination/noise_policy.rb +1 -2
  51. data/lib/html2rss/link_destination/path_classifier.rb +20 -19
  52. data/lib/html2rss/mcp/README.md +80 -0
  53. data/lib/html2rss/mcp/contract.rb +135 -13
  54. data/lib/html2rss/mcp/outcome/playbook.rb +122 -0
  55. data/lib/html2rss/mcp/outcome.rb +112 -40
  56. data/lib/html2rss/mcp/runtime.rb +45 -0
  57. data/lib/html2rss/mcp/server/tools.rb +277 -0
  58. data/lib/html2rss/mcp/server.rb +91 -232
  59. data/lib/html2rss/mcp.rb +3 -0
  60. data/lib/html2rss/page_recon/README.md +69 -0
  61. data/lib/html2rss/page_recon/diagnostics.rb +211 -0
  62. data/lib/html2rss/page_recon.rb +297 -0
  63. data/lib/html2rss/recon.rb +252 -0
  64. data/lib/html2rss/request_service/blocked_surface.rb +1 -0
  65. data/lib/html2rss/request_service/botasaurus_contract.rb +8 -1
  66. data/lib/html2rss/request_service/botasaurus_strategy.rb +1 -1
  67. data/lib/html2rss/request_service/faraday_strategy.rb +11 -7
  68. data/lib/html2rss/request_service/policy.rb +1 -2
  69. data/lib/html2rss/request_service/response.rb +27 -4
  70. data/lib/html2rss/request_service/strategy.rb +1 -2
  71. data/lib/html2rss/request_service.rb +15 -1
  72. data/lib/html2rss/request_session.rb +4 -2
  73. data/lib/html2rss/scrape_target.rb +24 -0
  74. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +2 -4
  75. data/lib/html2rss/selectors/post_processors.rb +0 -1
  76. data/lib/html2rss/selectors.rb +33 -10
  77. data/lib/html2rss/sst/attrs.rb +1 -2
  78. data/lib/html2rss/sst/normalizer.rb +49 -11
  79. data/lib/html2rss/status.rb +48 -24
  80. data/lib/html2rss/surface_category.rb +63 -0
  81. data/lib/html2rss/syndication/README.md +21 -0
  82. data/lib/html2rss/syndication/candidate_catalog.rb +26 -0
  83. data/lib/html2rss/syndication/discovery.rb +215 -0
  84. data/lib/html2rss/syndication/parser.rb +137 -0
  85. data/lib/html2rss/syndication.rb +10 -0
  86. data/lib/html2rss/test/enhance_audit.rb +192 -0
  87. data/lib/html2rss/test.rb +458 -0
  88. data/lib/html2rss/url.rb +23 -2
  89. data/lib/html2rss/version.rb +1 -1
  90. data/lib/html2rss.rb +157 -35
  91. data/schema/html2rss-config.schema.json +47 -29
  92. metadata +34 -18
  93. data/lib/html2rss/mcp/inspect.rb +0 -241
  94. data/lib/html2rss/selectors/post_processors/html_to_markdown.rb +0 -59
@@ -15,27 +15,33 @@ module Html2rss
15
15
  class Schema
16
16
  include Enumerable
17
17
 
18
- # Selector for JSON-LD script tags containing Schema.org objects.
19
- TAG_SELECTOR = 'script[type="application/ld+json"]'
18
+ # Matches a leading schema.org URL prefix on @type values (http or https).
19
+ SCHEMA_ORG_PREFIX_RE = %r{\Ahttps?://schema\.org/}i
20
+
21
+ # Container types that must never be emitted as feed items (walk children only).
22
+ DENIED_CONTAINER_TYPES = Set[
23
+ 'ItemList', 'Blog', 'BreadcrumbList', 'WebPage', 'CollectionPage'
24
+ ].freeze
25
+
26
+ # Canonical Schema.org type names keyed by folded wire forms (case-insensitive lookup).
27
+ CANONICAL_BY_DOWNCASE = begin
28
+ canonical_types = Thing::SUPPORTED_TYPES | ItemList::SUPPORTED_TYPES | DENIED_CONTAINER_TYPES | Set['Product']
29
+ canonical_types.to_h { |type| [::Html2rss::Html::Probe.fold(type), type] }.freeze
30
+ end.freeze
20
31
 
21
32
  # Pre-compiled regex for supported schema types (short name or schema.org URL; string or array @type).
22
33
  # Allows preceding entries in a JSON @type array (e.g. ["WebPage","NewsArticle"]).
23
34
  SUPPORTED_TYPES_RE = begin
24
35
  types = Thing::SUPPORTED_TYPES | ItemList::SUPPORTED_TYPES
25
36
  type_re = Regexp.union(types.to_a)
26
- %r{"@type"\s*:\s*(?:\[\s*(?:"[^"]*"\s*,\s*)*)?"(?:https?://schema\.org/)?(?:#{type_re.source})"}
37
+ %r{(?i)"@type"\s*:\s*(?:\[\s*(?:"[^"]*"\s*,\s*)*)?"(?:https?://schema\.org/)?(?:#{type_re.source})"}
27
38
  end.freeze
28
39
 
29
- # Matches a leading schema.org URL prefix on @type values (http or https).
30
- SCHEMA_ORG_PREFIX_RE = %r{\Ahttps?://schema\.org/}i
31
-
32
40
  # Prefer these keys when recursively walking unsupported container objects.
33
41
  COLLECTION_KEYS = %i[itemListElement blogPost mainEntity hasPart].freeze
34
42
 
35
- # Container types that must never be emitted as feed items (walk children only).
36
- DENIED_CONTAINER_TYPES = Set[
37
- 'ItemList', 'Blog', 'BreadcrumbList', 'WebPage', 'CollectionPage'
38
- ].freeze
43
+ # Shared empty type set for nil/unsupported wire forms (avoids per-call Set.new).
44
+ EMPTY_TYPES = Set.new.freeze
39
45
 
40
46
  # @return [Symbol] scraper config key
41
47
  def self.options_key = :schema
@@ -44,7 +50,8 @@ module Html2rss
44
50
  # @param parsed_body [Nokogiri::HTML::Document] parsed HTML document
45
51
  # @return [Boolean] whether the page includes supported schema types
46
52
  def articles?(parsed_body)
47
- parsed_body.css(TAG_SELECTOR).any? { |script| supported_schema_type?(script) }
53
+ ::Html2rss::Html::Probe.scripts(parsed_body, ::Html2rss::Html::Probe::APPLICATION_LD_JSON)
54
+ .any? { |script| supported_schema_type?(script) }
48
55
  end
49
56
 
50
57
  # @param script [Nokogiri::XML::Element] schema JSON-LD script tag
@@ -105,14 +112,26 @@ module Html2rss
105
112
  case object
106
113
  when Array
107
114
  object.each_with_object(Set.new) { |item, set| set.merge(normalize_types(item)) }
108
- when String, Symbol
109
- short = object.to_s.sub(SCHEMA_ORG_PREFIX_RE, '')
110
- short.empty? ? Set.new : Set[short]
111
115
  else
112
- Set.new
116
+ name = canonicalize_type(object)
117
+ name ? Set[name] : EMPTY_TYPES
113
118
  end
114
119
  end
115
120
 
121
+ # Canonical short Schema.org type name for a scalar wire form.
122
+ #
123
+ # @param object [String, Symbol, nil] raw `@type` / itemtype token
124
+ # @return [String, nil]
125
+ # @api private
126
+ def canonicalize_type(object)
127
+ return unless object.is_a?(String) || object.is_a?(Symbol)
128
+
129
+ short = object.to_s.sub(SCHEMA_ORG_PREFIX_RE, '')
130
+ return if short.empty?
131
+
132
+ CANONICAL_BY_DOWNCASE.fetch(::Html2rss::Html::Probe.fold(short), short)
133
+ end
134
+
116
135
  private
117
136
 
118
137
  # @param hash [Hash] candidate schema object
@@ -150,15 +169,15 @@ module Html2rss
150
169
  ##
151
170
  # @yield [Hash] Each scraped article_hash
152
171
  # @return [Array<Hash>] the scraped article_hashes
153
- def each(&)
172
+ def each
154
173
  return enum_for(:each) unless block_given?
155
174
 
156
- schema_objects.filter_map do |schema_object|
175
+ schema_objects.each do |schema_object|
157
176
  next unless (klass = self.class.scraper_for_schema_object(schema_object))
158
177
  next unless (results = klass.new(schema_object, url:).call)
159
178
 
160
179
  if results.is_a?(Array)
161
- results.each { |result| yield(result) } # rubocop:disable Style/ExplicitBlockArgument
180
+ results.each { yield(_1) }
162
181
  else
163
182
  yield(results)
164
183
  end
@@ -168,9 +187,8 @@ module Html2rss
168
187
  private
169
188
 
170
189
  def schema_objects
171
- @parsed_body.css(TAG_SELECTOR).flat_map do |tag|
172
- Schema.from(tag)
173
- end
190
+ ::Html2rss::Html::Probe.scripts(@parsed_body, ::Html2rss::Html::Probe::APPLICATION_LD_JSON)
191
+ .flat_map { |tag| Schema.from(tag) }
174
192
  end
175
193
 
176
194
  attr_reader :parsed_body, :url
@@ -23,7 +23,9 @@ module Html2rss
23
23
 
24
24
  # Extraction tiers: merge within a tier, then stop when AutoSource has enough articles.
25
25
  # Heuristic scrapers are separate tiers so SemanticHtml can satisfy before Html runs.
26
+ # NativeFeed is tier 0 so syndication wins before structured HTML scrapers.
26
27
  SCRAPER_TIERS = [
28
+ [NativeFeed].freeze,
27
29
  [Schema, Microdata, Microformats2, JsonState, XhrArticles].freeze,
28
30
  [WordpressApi, Sitemap, MetaOembed].freeze,
29
31
  [SemanticHtml].freeze,
@@ -36,7 +38,7 @@ module Html2rss
36
38
  # Heuristic scrapers that share one memoized SST::Document per page.
37
39
  HEURISTIC_SCRAPERS = [SemanticHtml, Html].freeze
38
40
  # Scrapers that accept a shared follow-up +request_session+.
39
- REQUEST_SESSION_SCRAPERS = [WordpressApi, Sitemap, MetaOembed].freeze
41
+ REQUEST_SESSION_SCRAPERS = [NativeFeed, WordpressApi, Sitemap, MetaOembed].freeze
40
42
  # Scrapers that consume browser-captured XHR/fetch JSON bodies.
41
43
  CAPTURED_RESPONSE_SCRAPERS = [XhrArticles].freeze
42
44
 
@@ -83,6 +85,7 @@ module Html2rss
83
85
  # Returns an array of scraper classes that claim to find articles in the parsed body.
84
86
  # @param parsed_body [Nokogiri::HTML::Document] The parsed HTML document.
85
87
  # @param opts [Hash] The options hash.
88
+ # @option opts [Hash] :native_feed scraper toggle and configuration
86
89
  # @option opts [Hash] :wordpress_api scraper toggle and configuration
87
90
  # @option opts [Hash] :schema scraper toggle and configuration
88
91
  # @option opts [Hash] :microdata scraper toggle and configuration
@@ -115,7 +118,7 @@ module Html2rss
115
118
  # @return [SST::Document, nil]
116
119
  def self.normalize_sst(parsed_body)
117
120
  SST::Normalizer.call(parsed_body)
118
- rescue ArgumentError
121
+ rescue SST::Normalizer::EmptyTree
119
122
  nil
120
123
  end
121
124
 
@@ -142,7 +145,7 @@ module Html2rss
142
145
  # @option opts [Hash] :html scraper toggle and configuration
143
146
  # @option opts [Hash] :sitemap scraper toggle and configuration
144
147
  # @return [Object, nil]
145
- # rubocop:disable Metrics/ParameterLists, Metrics/MethodLength -- construction context for structured and heuristic scrapers
148
+ # rubocop:disable-next Metrics/ParameterLists, Metrics/MethodLength -- construction context for structured and heuristic scrapers
146
149
  def self.build_instance(scraper, parsed_body, opts:, url:, request_session: nil, body: nil, document: nil,
147
150
  link_resolver: nil, captured_responses: [])
148
151
  return unless opts.dig(scraper.options_key, :enabled)
@@ -159,7 +162,6 @@ module Html2rss
159
162
  )
160
163
  scraper.new(parsed_body, url:, **kwargs, **scraper_opts)
161
164
  end
162
- # rubocop:enable Metrics/ParameterLists, Metrics/MethodLength
163
165
 
164
166
  ##
165
167
  # @param instance [Object]
@@ -64,7 +64,7 @@ module Html2rss
64
64
  def normalize_class(class_names)
65
65
  return '' if class_names.empty?
66
66
 
67
- class_names.sort.join(' ')
67
+ class_names.map { ::Html2rss::Html::Probe.fold(_1) }.sort.join(' ')
68
68
  end
69
69
  module_function :normalize_class
70
70
  private_class_method :normalize_class
@@ -122,7 +122,7 @@ module Html2rss
122
122
  groups.sort_by { |_key, nodes| -nodes.size }.first(MAX_GROUPS).to_h
123
123
  end
124
124
 
125
- # rubocop:disable Metrics/MethodLength
125
+ # rubocop:disable-next Metrics/MethodLength
126
126
  def container_of?(nodes_a, nodes_b)
127
127
  return false unless @layout_tags.include?(nodes_b.first.name)
128
128
 
@@ -139,7 +139,6 @@ module Html2rss
139
139
  count > 1
140
140
  end
141
141
  end
142
- # rubocop:enable Metrics/MethodLength
143
142
 
144
143
  def resolve_1_to_1_overlap(cls_a, cls_b, groups, discarded)
145
144
  nodes_a = groups[cls_a]
@@ -37,7 +37,7 @@ module Html2rss
37
37
 
38
38
  private
39
39
 
40
- # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
40
+ # rubocop:disable-next Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
41
41
  def candidate_facts(anchor, container)
42
42
  destination = @link_resolver.destination_facts(anchor)
43
43
  return unless destination
@@ -63,7 +63,6 @@ module Html2rss
63
63
 
64
64
  { anchor:, destination: destination.destination, score: }
65
65
  end
66
- # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
67
66
 
68
67
  def first_heading(container)
69
68
  (@headings ||= {}.compare_by_identity)[container] ||= container.find(&:heading?)
@@ -19,11 +19,10 @@ module Html2rss
19
19
  # @option opts [Integer] :use_top_selectors list strategy selector budget
20
20
  # @option opts [Scoring::LinkResolver, nil] :link_resolver page-scoped destination facts cache
21
21
  # @return [Array<Segment>]
22
- # rubocop:disable Style/ArgumentsForwarding -- keep named opts for YARD @option
22
+ # rubocop:disable-next Style/ArgumentsForwarding -- keep named opts for YARD @option
23
23
  def self.call(document, base_url:, strategy:, **opts)
24
24
  new(document, base_url:, strategy:, **opts).call
25
25
  end
26
- # rubocop:enable Style/ArgumentsForwarding
27
26
 
28
27
  # @param document [SST::Document]
29
28
  # @param base_url [String, Html2rss::Url]
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'dry-validation'
4
+ require 'json'
5
+ require 'nokogiri'
4
6
 
5
7
  module Html2rss
6
8
  ##
@@ -20,10 +22,25 @@ module Html2rss
20
22
  # Default max articles to keep (also the short-circuit floor across scraper tiers).
21
23
  DEFAULT_LIMIT = 25
22
24
 
25
+ # Operational scraper failures quarantined within a tier; programmer errors propagate.
26
+ OPERATIONAL_ERRORS = [
27
+ Html2rss::Error,
28
+ ArgumentError,
29
+ ::JSON::ParserError,
30
+ ::Nokogiri::SyntaxError
31
+ ].freeze
32
+
23
33
  # Default auto-source configuration shipped for scraper and cleanup behavior.
24
34
  DEFAULT_CONFIG = {
25
35
  limit: DEFAULT_LIMIT,
36
+ entry_resolution: {
37
+ enabled: true,
38
+ max_probes: 5
39
+ }.freeze,
26
40
  scraper: {
41
+ native_feed: {
42
+ enabled: true
43
+ },
27
44
  wordpress_api: {
28
45
  enabled: true
29
46
  },
@@ -131,7 +148,7 @@ module Html2rss
131
148
 
132
149
  attr_reader :url, :parsed_body, :body, :request_session, :captured_responses
133
150
 
134
- # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
151
+ # rubocop:disable-next Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
135
152
  def extract_articles
136
153
  articles = []
137
154
  matched = false
@@ -176,7 +193,6 @@ module Html2rss
176
193
  @admission_drops = result.drop_tallies
177
194
  result.articles.first(article_limit)
178
195
  end
179
- # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
180
196
 
181
197
  def enough_articles?(articles)
182
198
  return false if articles.size < article_limit
@@ -204,15 +220,20 @@ module Html2rss
204
220
  end
205
221
 
206
222
  def run_scraper(instance)
207
- instance.each.map do |item|
208
- case item
209
- when Article
210
- item
211
- when Hash
212
- Article.new(**item, scraper: instance.class)
213
- else
214
- raise TypeError, "#{instance.class} yielded #{item.class}; expected Article or Hash"
215
- end
223
+ instance.map { |item| article_from_scraper_item(item, instance) }
224
+ rescue *OPERATIONAL_ERRORS => error
225
+ Log.warn("#{self.class}: #{instance.class} quarantined #{error.class}: #{error.message}")
226
+ []
227
+ end
228
+
229
+ def article_from_scraper_item(item, instance)
230
+ case item
231
+ when Article
232
+ item
233
+ when Hash
234
+ Article.new(**item, scraper: instance.class)
235
+ else
236
+ raise TypeError, "#{instance.class} yielded #{item.class}; expected Article or Hash"
216
237
  end
217
238
  end
218
239
 
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ ##
5
+ # Concurrency-bounded batch execution over enumerables with input order preservation
6
+ # and per-item error isolation.
7
+ module Batch
8
+ # Maximum permitted worker threads for batch operations.
9
+ MAX_CONCURRENCY = 10
10
+ # Default worker threads for batch operations.
11
+ DEFAULT_CONCURRENCY = 5
12
+
13
+ # Value object representing the outcome of a batch operation.
14
+ BatchResult = Data.define(:total, :successful, :results) do
15
+ ##
16
+ # @return [Hash{Symbol => Object}]
17
+ def to_h
18
+ { total:, successful:, results: }
19
+ end
20
+ end
21
+
22
+ class << self
23
+ ##
24
+ # Executes a block concurrently across items using a bounded worker pool.
25
+ # Results are returned in input order.
26
+ #
27
+ # @param items [Enumerable<Object>]
28
+ # @param concurrency [Integer] max parallel worker threads (1..10, default: 5)
29
+ # @yieldparam item [Object]
30
+ # @return [Array<Object>]
31
+ def map(items, concurrency: DEFAULT_CONCURRENCY, &)
32
+ list = Array(items)
33
+ return [] if list.empty?
34
+
35
+ worker_count = worker_pool_size(list.size, concurrency)
36
+ process_queue(list, worker_count, &)
37
+ end
38
+
39
+ ##
40
+ # Runs a batch operation that yields items and wraps the results in a {BatchResult}.
41
+ #
42
+ # @param items [Enumerable<Object>]
43
+ # @param concurrency [Integer] max parallel worker threads (1..10, default: 5)
44
+ # @yieldparam item [Object]
45
+ # @return [BatchResult]
46
+ def run(items, concurrency: DEFAULT_CONCURRENCY, &)
47
+ list = Array(items)
48
+ results = map(list, concurrency:, &)
49
+ successful = results.count { |entry| entry.is_a?(Hash) ? entry[:ok] : !entry.nil? }
50
+
51
+ BatchResult.new(total: list.size, successful:, results:)
52
+ end
53
+
54
+ ##
55
+ # Single-URL scrape wire extraction: auto_feed_result → JSON Feed items.
56
+ #
57
+ # @param url [String]
58
+ # @param strategy [Symbol, String]
59
+ # @param limit [Integer]
60
+ # @param items_selector [String, nil]
61
+ # @return [Hash{Symbol => Object}] +:strategy+, +:items+, +:channel_title+, +:admission_drops+
62
+ def scrape_wire(url:, strategy:, limit:, items_selector: nil) # rubocop:disable Metrics/MethodLength
63
+ plan = (strategy || :auto).to_sym
64
+ feed_args = { strategy: plan, limit: }
65
+ feed_args[:items_selector] = items_selector unless items_selector.nil?
66
+ feed_result = Html2rss.auto_feed_result(url, **feed_args)
67
+ feed = feed_result.to_json_feed
68
+ {
69
+ strategy: plan,
70
+ items: feed[:items] || [],
71
+ channel_title: feed[:title],
72
+ admission_drops: feed_result.status.admission_drops
73
+ }
74
+ end
75
+
76
+ ##
77
+ # Scrapes multiple URLs in parallel with per-URL error isolation.
78
+ #
79
+ # @param urls [Enumerable<String>] list of URLs to scrape
80
+ # @param strategy [Symbol, String] request strategy (+:auto+, +:faraday+, +:botasaurus+)
81
+ # @param limit [Integer] max articles to extract per URL
82
+ # @param concurrency [Integer] number of worker threads (default 5, max 10)
83
+ # @return [BatchResult]
84
+ def batch_scrape(urls:, strategy: :auto, limit: 10, concurrency: DEFAULT_CONCURRENCY)
85
+ run(urls, concurrency:) { |url| scrape_single_url(url:, strategy:, limit:) }
86
+ end
87
+
88
+ ##
89
+ # Inspects multiple URLs in parallel with per-URL error isolation.
90
+ #
91
+ # @param urls [Enumerable<String>] list of URLs to inspect
92
+ # @param strategy [Symbol, String] request strategy (+:auto+, +:faraday+, +:botasaurus+)
93
+ # @param concurrency [Integer] number of worker threads (default 5, max 10)
94
+ # @return [BatchResult]
95
+ def batch_inspect(urls:, strategy: :auto, concurrency: DEFAULT_CONCURRENCY)
96
+ reports = PageRecon::Diagnostics.batch(urls:, strategy:, concurrency:)
97
+ results = reports.map(&:to_wire_h)
98
+ successful = reports.count { |report| diagnostic_success?(report) }
99
+
100
+ BatchResult.new(total: results.size, successful:, results:)
101
+ end
102
+
103
+ ##
104
+ # Runs recon across multiple URLs in parallel with per-URL error isolation.
105
+ #
106
+ # @param urls [Enumerable<String>] list of URLs to recon
107
+ # @param strategy [Symbol, String] request strategy (+:auto+, +:faraday+, +:botasaurus+)
108
+ # @param concurrency [Integer] number of worker threads (default 5, max 10)
109
+ # @option options [String, nil] :cache_dir optional HTML cache directory
110
+ # @return [BatchResult]
111
+ def batch_recon(urls:, strategy: :auto, concurrency: DEFAULT_CONCURRENCY, **)
112
+ results = Recon.batch(urls, strategy: (strategy || :auto).to_sym, max_threads: concurrency, **)
113
+ successful = results.count { |r| r.status ? r.status < 400 : false }
114
+ BatchResult.new(total: results.size, successful:, results: results.map(&:to_h))
115
+ end
116
+
117
+ private
118
+
119
+ def diagnostic_success?(report)
120
+ status = report.to_wire_h[:status]
121
+ status ? status < 400 : false
122
+ end
123
+
124
+ def scrape_single_url(url:, strategy:, limit:)
125
+ wire = scrape_wire(url:, strategy:, limit:)
126
+ {
127
+ url: url.to_s, ok: true, items_count: wire[:items].size,
128
+ items: wire[:items], channel_title: wire[:channel_title]
129
+ }
130
+ rescue StandardError => error
131
+ { url: url.to_s, ok: false, error: error.message }
132
+ end
133
+
134
+ def worker_pool_size(item_count, requested_concurrency)
135
+ return 1 if item_count <= 1
136
+
137
+ [[1, requested_concurrency.to_i].max, item_count, MAX_CONCURRENCY].min
138
+ end
139
+
140
+ def process_queue(items, worker_count, &)
141
+ queue = build_work_queue(items)
142
+ results = Array.new(items.size)
143
+
144
+ threads = Array.new(worker_count) do
145
+ Thread.new { drain_queue(queue, results, &) } # rubocop:disable ThreadSafety/NewThread
146
+ end
147
+ threads.each(&:join)
148
+ results
149
+ end
150
+
151
+ def build_work_queue(items)
152
+ Queue.new.tap do |queue|
153
+ items.each_with_index { |item, index| queue << [item, index] }
154
+ end
155
+ end
156
+
157
+ def drain_queue(queue, results)
158
+ until queue.empty?
159
+ begin
160
+ item, index = queue.pop(true)
161
+ rescue ThreadError
162
+ break
163
+ end
164
+ results[index] = yield(item)
165
+ end
166
+ end
167
+ end
168
+ end
169
+ end
@@ -1,6 +1,10 @@
1
1
  # Capture
2
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.
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 list-card item node**.
4
+
5
+ ## API
6
+
7
+ `Html2rss.capture` / `Capture.build` return `Capture::CaptureResult` — use `#yaml` or `#config`, not a bare Hash.
4
8
 
5
9
  ## When to use it
6
10
 
@@ -9,8 +13,9 @@ Point `capture` at a listing URL when you want a first-draft YAML config instead
9
13
  ## Gem API
10
14
 
11
15
  ```ruby
12
- config = Html2rss.capture('https://example.com/articles')
16
+ result = Html2rss.capture('https://example.com/articles')
13
17
 
18
+ # result.config =>
14
19
  # {
15
20
  # channel: { url: "...", title: "...", time_zone: "UTC" },
16
21
  # selectors: {
@@ -18,11 +23,13 @@ config = Html2rss.capture('https://example.com/articles')
18
23
  # }
19
24
  # }
20
25
 
21
- File.write('my-feed.yml', YAML.dump(Html2rss::HashUtil.deep_stringify_keys(config)))
22
- feed = Html2rss.feed(config)
26
+ File.write('my-feed.yml', result.yaml)
27
+ rss = Html2rss.apply(result.config)
23
28
  ```
24
29
 
25
- `Capture.build` returns a `CaptureResult` with quality meta (`has_selectors`, `segment_strategy`, `admission_drops`, `selected_strategy`). `Html2rss.capture` returns only the config hash.
30
+ `Html2rss.capture` and `Capture.build` both return a `CaptureResult` with YAML (`#yaml`, includes the schema modeline) and quality meta (`has_selectors`, `segment_strategy`, `admission_drops`, `selected_strategy`, `native_feed`).
31
+
32
+ **Wire vs internal:** user-facing **`Html2rss.apply`** ships RSS from a config Hash. Pipeline internals **`Html2rss.feed` / `feed_result`** stay unchanged — use `apply` in CLI/MCP/docs examples, not `feed`.
26
33
 
27
34
  ### Options
28
35
 
@@ -30,15 +37,17 @@ feed = Html2rss.feed(config)
30
37
  Html2rss.capture('https://spa-site.com', strategy: :botasaurus)
31
38
  Html2rss.capture('https://example.com', items_selector: '.article-card')
32
39
  Html2rss.capture('https://example.com', strategy: :local_file, local_file_path: './page.html')
40
+ Html2rss.capture('https://example.com', max_redirects: 8, max_requests: 4)
33
41
  ```
34
42
 
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.
43
+ `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.apply(config)` replays the same transport.
36
44
 
37
45
  ## CLI
38
46
 
39
47
  ```bash
40
48
  html2rss capture https://example.com/articles
41
49
  html2rss capture https://example.com --strategy botasaurus
50
+ html2rss capture https://example.com --max-redirects 8 --max-requests 4
42
51
  html2rss capture https://example.com/articles > my-feed.yml
43
52
  html2rss capture https://example.com --input ./page.html
44
53
  html2rss capture https://example.com --explain # quality JSON on stderr; YAML on stdout