html2rss 0.32.3 → 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 (108) 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 +54 -1
  45. data/lib/html2rss/feed_builder/rss.rb +1 -1
  46. data/lib/html2rss/feed_pipeline/README.md +1 -1
  47. data/lib/html2rss/feed_pipeline/auto_fallback.rb +58 -55
  48. data/lib/html2rss/feed_resolution/candidate_generator.rb +3 -4
  49. data/lib/html2rss/feed_resolution/options.rb +3 -7
  50. data/lib/html2rss/feed_resolution/policy.rb +29 -33
  51. data/lib/html2rss/feed_resolution/probe.rb +3 -3
  52. data/lib/html2rss/feed_resolution/scorer.rb +29 -29
  53. data/lib/html2rss/feed_resolution.rb +5 -9
  54. data/lib/html2rss/feed_result.rb +4 -3
  55. data/lib/html2rss/hash_util.rb +91 -90
  56. data/lib/html2rss/html/article_extractor/category_extractor.rb +6 -9
  57. data/lib/html2rss/html/article_extractor/date_extractor.rb +1 -1
  58. data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +1 -3
  59. data/lib/html2rss/html/article_extractor/id_generator.rb +1 -1
  60. data/lib/html2rss/html/probe.rb +51 -51
  61. data/lib/html2rss/link_destination/path_classifier.rb +11 -33
  62. data/lib/html2rss/mcp/config_argument.rb +9 -2
  63. data/lib/html2rss/mcp/contract.rb +7 -5
  64. data/lib/html2rss/mcp/outcome.rb +4 -5
  65. data/lib/html2rss/mcp/runtime.rb +17 -24
  66. data/lib/html2rss/mcp/server/tools.rb +9 -9
  67. data/lib/html2rss/option.rb +48 -0
  68. data/lib/html2rss/page_recon/diagnostics.rb +7 -9
  69. data/lib/html2rss/page_recon.rb +0 -4
  70. data/lib/html2rss/recon.rb +10 -12
  71. data/lib/html2rss/request_service/blocked_surface.rb +3 -7
  72. data/lib/html2rss/request_service/botasaurus_strategy.rb +2 -2
  73. data/lib/html2rss/request_service/budget.rb +3 -24
  74. data/lib/html2rss/request_service/compressed_body.rb +36 -48
  75. data/lib/html2rss/request_service/context.rb +1 -3
  76. data/lib/html2rss/request_service/local_file_strategy.rb +3 -1
  77. data/lib/html2rss/request_service/network_guard.rb +3 -2
  78. data/lib/html2rss/request_service/policy.rb +8 -26
  79. data/lib/html2rss/request_service/response.rb +3 -6
  80. data/lib/html2rss/request_service/strategy.rb +2 -2
  81. data/lib/html2rss/request_service.rb +26 -2
  82. data/lib/html2rss/request_session/pager/base.rb +3 -3
  83. data/lib/html2rss/request_session/pager/custom_selector.rb +4 -4
  84. data/lib/html2rss/request_session/pager/offset.rb +1 -7
  85. data/lib/html2rss/request_session/pager/rel_next.rb +1 -1
  86. data/lib/html2rss/request_session/pager/url_template.rb +1 -7
  87. data/lib/html2rss/scoring/engine.rb +4 -32
  88. data/lib/html2rss/scoring/link_resolver.rb +2 -7
  89. data/lib/html2rss/selectors/extractors/href.rb +2 -4
  90. data/lib/html2rss/selectors/json_xml.rb +33 -34
  91. data/lib/html2rss/selectors/post_processors/gsub.rb +2 -3
  92. data/lib/html2rss/selectors/post_processors/parse_time.rb +4 -5
  93. data/lib/html2rss/selectors/schema_export.rb +60 -60
  94. data/lib/html2rss/selectors.rb +14 -18
  95. data/lib/html2rss/sst/node.rb +9 -3
  96. data/lib/html2rss/sst/normalizer.rb +25 -24
  97. data/lib/html2rss/sst/text.rb +53 -57
  98. data/lib/html2rss/status.rb +23 -30
  99. data/lib/html2rss/syndication/alternate_links.rb +39 -0
  100. data/lib/html2rss/syndication/discovery.rb +117 -187
  101. data/lib/html2rss/syndication/parser.rb +92 -115
  102. data/lib/html2rss/test/enhance_audit.rb +13 -15
  103. data/lib/html2rss/test/policy.rb +9 -11
  104. data/lib/html2rss/test.rb +29 -48
  105. data/lib/html2rss/url.rb +53 -53
  106. data/lib/html2rss/version.rb +1 -1
  107. data/lib/html2rss.rb +2 -1
  108. metadata +4 -2
@@ -7,118 +7,86 @@ module Html2rss
7
7
  # Builds repeated-list article segments by grouping cards under a shared parent.
8
8
  # When the document has a +main+ or +[role=main]+, anchors outside it are dropped.
9
9
  module List
10
- module_function
11
-
12
10
  ##
13
11
  # @param segmenter [Segmenter]
14
12
  # @return [Array<Segment>]
15
- def call(segmenter)
16
- primary = PrimaryLink.new(segmenter)
17
- pairs = article_pairs(segmenter)
18
- seen = {}.compare_by_identity
19
-
20
- pairs.each_with_index.filter_map do |(article_tag, selected_anchor), position|
21
- next unless seen[article_tag].nil?
22
-
23
- seen[article_tag] = true
24
-
25
- link = selected_anchor || primary.select(article_tag)
26
- next unless link || segmenter.permit_unanchored
27
-
13
+ def self.call(segmenter)
14
+ article_pairs(segmenter).each_with_index.map do |(article_tag, link), position|
28
15
  Segment.build(root_node: article_tag, primary_link: link, strategy: :list, position:)
29
16
  end
30
17
  end
31
18
 
32
- def article_pairs(segmenter)
19
+ def self.article_pairs(segmenter)
33
20
  top_groups(segmenter).flatten(1)
34
21
  end
35
- module_function :article_pairs
36
22
  private_class_method :article_pairs
37
23
 
38
- def top_groups(segmenter)
24
+ def self.top_groups(segmenter)
39
25
  cards_by_parent(segmenter)
40
26
  .select { |_parent, cards| cards.size >= segmenter.minimum_selector_frequency }
41
27
  .max_by(segmenter.use_top_selectors) { |_parent, cards| cards.size }
42
28
  .map(&:last)
43
29
  end
44
- module_function :top_groups
45
30
  private_class_method :top_groups
46
31
 
47
- def cards_by_parent(segmenter) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
32
+ def self.cards_by_parent(segmenter)
48
33
  index = segmenter.index
49
34
  main = main_landmark(index)
50
35
  groups = {}.compare_by_identity
51
36
 
52
- index.each_node do |node|
53
- next unless node.link?
54
- next if index.ignored_chrome?(node)
55
- next unless relevant_anchor?(segmenter, node)
56
- next if main && !inside_main?(index, node, main)
57
-
37
+ index.each_node.select { |node| card_anchor?(segmenter, index, main, node) }.each do |node|
58
38
  card = parent_until_boundary(segmenter, node)
59
- next unless card
60
-
61
- parent = index.parent_of(card)
62
- next unless parent
63
-
64
- (groups[parent] ||= []) << [card, node]
39
+ (groups[index.parent_of(card)] ||= []) << [card, node]
65
40
  end
66
41
  groups
67
42
  end
68
- module_function :cards_by_parent
69
43
  private_class_method :cards_by_parent
70
44
 
71
- def main_landmark(index)
45
+ def self.card_anchor?(segmenter, index, main, node)
46
+ node.link? && !index.ignored_chrome?(node) && relevant_anchor?(segmenter, node) &&
47
+ (main.nil? || inside_main?(index, node, main))
48
+ end
49
+ private_class_method :card_anchor?
50
+
51
+ def self.main_landmark(index)
72
52
  index.each_node.find { |node| node.name == :main || node.attrs.raw['role'] == 'main' }
73
53
  end
74
- module_function :main_landmark
75
54
  private_class_method :main_landmark
76
55
 
77
- def inside_main?(index, node, main)
56
+ def self.inside_main?(index, node, main)
78
57
  node.equal?(main) || index.descendant_of?(node, main)
79
58
  end
80
- module_function :inside_main?
81
59
  private_class_method :inside_main?
82
60
 
83
- def relevant_anchor?(segmenter, node)
84
- facts = segmenter.link_resolver.destination_facts(node)
85
- return false unless facts
86
-
87
- text = node.visible_text.to_s.strip
61
+ def self.relevant_anchor?(segmenter, node)
88
62
  !segmenter.noise_policy.noise_anchor?(
89
- text:, destination_facts: facts, anchor: node, utility_landmark_ancestor: false
63
+ text: node.visible_text, destination_facts: segmenter.link_resolver.destination_facts(node),
64
+ anchor: node
90
65
  )
91
66
  end
92
- module_function :relevant_anchor?
93
67
  private_class_method :relevant_anchor?
94
68
 
95
69
  BOUNDARY_TAGS = Set[:body, :html].freeze
96
70
  private_constant :BOUNDARY_TAGS
97
71
 
98
- def parent_until_boundary(segmenter, node)
72
+ def self.parent_until_boundary(segmenter, node)
99
73
  index = segmenter.index
100
74
  link_counts = Hash.new { |hash, curr| hash[curr] = count_relevant_links(segmenter, curr) }
101
75
 
102
76
  index.parent_until(node, lambda { |curr|
103
77
  return true if BOUNDARY_TAGS.include?(curr.name)
104
- return false if index.ignored_chrome?(curr)
105
78
 
106
- parent = index.parent_of(curr)
107
- parent && link_counts[parent] > link_counts[curr]
79
+ link_counts[index.parent_of(curr)] > link_counts[curr]
108
80
  })
109
81
  end
110
- module_function :parent_until_boundary
111
82
  private_class_method :parent_until_boundary
112
83
 
113
- def count_relevant_links(segmenter, node)
114
- # Chrome inside a card must not stop the walk before the shared list parent.
115
- if node.link?
116
- relevant_anchor?(segmenter, node) ? 1 : 0
117
- else
118
- node.count_descendants { |child| child.link? && relevant_anchor?(segmenter, child) }
119
- end
84
+ # Walk nodes are relevant anchors or their non-link ancestors, so a link counts as one.
85
+ def self.count_relevant_links(segmenter, node)
86
+ return 1 if node.link?
87
+
88
+ node.count_descendants { |child| child.link? && relevant_anchor?(segmenter, child) }
120
89
  end
121
- module_function :count_relevant_links
122
90
  private_class_method :count_relevant_links
123
91
  end
124
92
  end
@@ -9,12 +9,10 @@ module Html2rss
9
9
  # Tag names collected as leaf semantic containers.
10
10
  CANDIDATE_NAMES = %i[article section li tr div].to_set.freeze
11
11
 
12
- module_function
13
-
14
12
  ##
15
13
  # @param segmenter [Segmenter]
16
14
  # @return [Array<Segment>]
17
- def call(segmenter)
15
+ def self.call(segmenter)
18
16
  containers = collect_containers(segmenter.index)
19
17
  primary = PrimaryLink.new(segmenter)
20
18
 
@@ -26,7 +24,7 @@ module Html2rss
26
24
  end
27
25
  end
28
26
 
29
- def collect_containers(index)
27
+ def self.collect_containers(index)
30
28
  non_leaf = mark_non_leaf_candidates(index.root)
31
29
  candidates = []
32
30
  index.each_node do |node|
@@ -40,14 +38,13 @@ module Html2rss
40
38
  candidates = filter_nested(candidates, index)
41
39
  sort_by_depth(candidates, index)
42
40
  end
43
- module_function :collect_containers
44
41
  private_class_method :collect_containers
45
42
 
46
43
  TAG_MASKS = { article: 1, section: 2, li: 4, tr: 8, div: 16 }.freeze
47
44
  private_constant :TAG_MASKS
48
45
 
49
46
  # Single O(N) post-order pass: mark candidate nodes that contain a same-name candidate.
50
- def mark_non_leaf_candidates(root) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
47
+ def self.mark_non_leaf_candidates(root) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
51
48
  non_leaf = Set.new.compare_by_identity
52
49
  visit = lambda do |node|
53
50
  seen_mask = 0
@@ -62,10 +59,9 @@ module Html2rss
62
59
  visit.call(root)
63
60
  non_leaf
64
61
  end
65
- module_function :mark_non_leaf_candidates
66
62
  private_class_method :mark_non_leaf_candidates
67
63
 
68
- def filter_nested(candidates, index)
64
+ def self.filter_nested(candidates, index)
69
65
  candidate_set = {}.compare_by_identity
70
66
  candidates.each { |c| candidate_set[c] = true }
71
67
  rejected = {}.compare_by_identity
@@ -78,25 +74,22 @@ module Html2rss
78
74
 
79
75
  candidates.reject { |c| rejected[c] }
80
76
  end
81
- module_function :filter_nested
82
77
  private_class_method :filter_nested
83
78
 
84
- def reject_ancestors(node, candidate_set, rejected, index)
79
+ def self.reject_ancestors(node, candidate_set, rejected, index)
85
80
  curr = index.parent_of(node)
86
81
  while curr && curr.name != :html
87
82
  rejected[curr] = true if candidate_set[curr]
88
83
  curr = index.parent_of(curr)
89
84
  end
90
85
  end
91
- module_function :reject_ancestors
92
86
  private_class_method :reject_ancestors
93
87
 
94
- def sort_by_depth(candidates, index)
88
+ def self.sort_by_depth(candidates, index)
95
89
  candidates.each_with_index
96
90
  .sort_by { |node, i| [-index.depth_of(node), i] }
97
91
  .map(&:first)
98
92
  end
99
- module_function :sort_by_depth
100
93
  private_class_method :sort_by_depth
101
94
  end
102
95
  end
@@ -172,7 +172,7 @@ module Html2rss
172
172
  break if skip_html_padding?(tier, articles)
173
173
 
174
174
  if Scraper.heuristic_tier?(tier)
175
- document ||= Scraper.normalize_sst(parsed_body)
175
+ document ||= SST::Normalizer.call(parsed_body)
176
176
  next unless document
177
177
 
178
178
  link_resolver ||= ::Html2rss::Scoring::LinkResolver.new(url)
@@ -10,17 +10,9 @@ module Html2rss
10
10
  # Default worker threads for batch operations.
11
11
  DEFAULT_CONCURRENCY = 5
12
12
 
13
+ # Outcome of a batch operation; +to_h+ is provided by Data.
13
14
  BatchResult = Data.define(:total, :successful, :results)
14
15
 
15
- # Value object representing the outcome of a batch operation.
16
- class BatchResult
17
- ##
18
- # @return [Hash{Symbol => Object}]
19
- def to_h
20
- { total:, successful:, results: }
21
- end
22
- end
23
-
24
16
  class << self
25
17
  ##
26
18
  # Executes a block concurrently across items using a bounded worker pool.
@@ -31,11 +23,8 @@ module Html2rss
31
23
  # @yieldparam item [Object]
32
24
  # @return [Array<Object>]
33
25
  def map(items, concurrency: DEFAULT_CONCURRENCY, &)
34
- list = Array(items)
35
- return [] if list.empty?
36
-
37
- worker_count = worker_pool_size(list.size, concurrency)
38
- process_queue(list, worker_count, &)
26
+ Option.integer(:concurrency, concurrency, min: 1, max: MAX_CONCURRENCY)
27
+ process_queue(Option.list(:items, items), [concurrency, items.size].min, &)
39
28
  end
40
29
 
41
30
  ##
@@ -46,11 +35,10 @@ module Html2rss
46
35
  # @yieldparam item [Object]
47
36
  # @return [BatchResult]
48
37
  def run(items, concurrency: DEFAULT_CONCURRENCY, &)
49
- list = Array(items)
50
- results = map(list, concurrency:, &)
38
+ results = map(items, concurrency:, &)
51
39
  successful = results.count { |entry| entry.is_a?(Hash) ? entry[:ok] : !entry.nil? }
52
40
 
53
- BatchResult.new(total: list.size, successful:, results:)
41
+ BatchResult.new(total: items.size, successful:, results:)
54
42
  end
55
43
 
56
44
  ##
@@ -62,7 +50,7 @@ module Html2rss
62
50
  # @param items_selector [String, nil]
63
51
  # @return [Hash{Symbol => Object}] +:strategy+, +:items+, +:channel_title+, +:admission_drops+
64
52
  def scrape_wire(url:, strategy:, limit:, items_selector: nil) # rubocop:disable Metrics/MethodLength
65
- plan = (strategy || :auto).to_sym
53
+ plan = Option.strategy(strategy)
66
54
  feed_args = { strategy: plan, limit: }
67
55
  feed_args[:items_selector] = items_selector unless items_selector.nil?
68
56
  feed_result = Html2rss.auto_feed_result(url, **feed_args)
@@ -84,7 +72,9 @@ module Html2rss
84
72
  # @param concurrency [Integer] number of worker threads (default 5, max 10)
85
73
  # @return [BatchResult]
86
74
  def batch_scrape(urls:, strategy: :auto, limit: 10, concurrency: DEFAULT_CONCURRENCY)
87
- run(urls, concurrency:) { |url| scrape_single_url(url:, strategy:, limit:) }
75
+ strategy = Option.strategy(strategy)
76
+ Option.integer(:limit, limit, min: 1)
77
+ run(Option.list(:urls, urls), concurrency:) { |url| scrape_single_url(url:, strategy:, limit:) }
88
78
  end
89
79
 
90
80
  ##
@@ -95,7 +85,8 @@ module Html2rss
95
85
  # @param concurrency [Integer] number of worker threads (default 5, max 10)
96
86
  # @return [BatchResult]
97
87
  def batch_inspect(urls:, strategy: :auto, concurrency: DEFAULT_CONCURRENCY)
98
- reports = PageRecon::Diagnostics.batch(urls:, strategy:, concurrency:)
88
+ reports = PageRecon::Diagnostics.batch(urls: Option.list(:urls, urls), strategy: Option.strategy(strategy),
89
+ concurrency:)
99
90
  results = reports.map(&:to_wire_h)
100
91
  successful = reports.count { |report| diagnostic_success?(report) }
101
92
 
@@ -111,16 +102,21 @@ module Html2rss
111
102
  # @option options [String, nil] :cache_dir optional HTML cache directory
112
103
  # @return [BatchResult]
113
104
  def batch_recon(urls:, strategy: :auto, concurrency: DEFAULT_CONCURRENCY, **)
114
- results = Recon.batch(urls, strategy: (strategy || :auto).to_sym, max_threads: concurrency, **)
115
- successful = results.count { |r| r.status ? r.status < 400 : false }
105
+ Option.integer(:concurrency, concurrency, min: 1, max: MAX_CONCURRENCY)
106
+ results = Recon.batch(Option.list(:urls, urls), strategy: Option.strategy(strategy),
107
+ max_threads: concurrency, **)
108
+ successful = results.count { |result| http_success?(result.status) }
116
109
  BatchResult.new(total: results.size, successful:, results: results.map(&:to_h))
117
110
  end
118
111
 
119
112
  private
120
113
 
121
114
  def diagnostic_success?(report)
122
- status = report.to_wire_h[:status]
123
- status ? status < 400 : false
115
+ http_success?(report.to_wire_h[:status])
116
+ end
117
+
118
+ def http_success?(status)
119
+ !status.nil? && status < 400
124
120
  end
125
121
 
126
122
  def scrape_single_url(url:, strategy:, limit:)
@@ -129,16 +125,10 @@ module Html2rss
129
125
  url: url.to_s, ok: true, items_count: wire[:items].size,
130
126
  items: wire[:items], channel_title: wire[:channel_title]
131
127
  }
132
- rescue StandardError => error
128
+ rescue Html2rss::Error => error
133
129
  { url: url.to_s, ok: false, error: error.message }
134
130
  end
135
131
 
136
- def worker_pool_size(item_count, requested_concurrency)
137
- return 1 if item_count <= 1
138
-
139
- [[1, requested_concurrency.to_i].max, item_count, MAX_CONCURRENCY].min
140
- end
141
-
142
132
  def process_queue(items, worker_count, &)
143
133
  queue = build_work_queue(items)
144
134
  results = Array.new(items.size)
@@ -157,7 +147,7 @@ module Html2rss
157
147
  end
158
148
 
159
149
  def drain_queue(queue, results)
160
- until queue.empty?
150
+ loop do
161
151
  begin
162
152
  item, index = queue.pop(true)
163
153
  rescue ThreadError
@@ -66,7 +66,7 @@ html2rss capture https://example.com --explain # quality JSON on stderr; YAML
66
66
  ## Constraints
67
67
 
68
68
  - Selector quality depends on page structure; treat output as a first draft.
69
- - When HTML derivation misses (no articles, no SST document, fewer than `MIN_SELECTOR_MATCHES` pairs, or a rescued `ArgumentError`), Capture emits the default items selector with `segment_strategy: :default`. `enhance` still follows admission evidence (false when chrome drops are high). Non-HTML responses still omit selectors.
69
+ - When HTML derivation misses (no articles, no SST document, fewer than `MIN_SELECTOR_MATCHES` pairs), Capture emits the default items selector with `segment_strategy: :default`. `enhance` still follows admission evidence (false when chrome drops are high). Non-HTML responses still omit selectors.
70
70
  - Botasaurus hops need `BOTASAURUS_SCRAPER_URL`.
71
71
 
72
72
  See also {Html2rss::AutoSource} for article discovery and {Html2rss::FeedPipeline} for the `:auto` request chain.
@@ -147,7 +147,6 @@ module Html2rss
147
147
 
148
148
  CaptureResult.new(
149
149
  config:,
150
- yaml: "#{SCHEMA_MODELINE}\n#{Config.to_yaml(config)}",
151
150
  articles_count: outcome.articles.size,
152
151
  channel_title: ch_title,
153
152
  has_selectors: !selectors.empty?,
@@ -181,7 +180,7 @@ module Html2rss
181
180
  parsed_body: (response.parsed_body if response.html_response?),
182
181
  html: response.body
183
182
  )&.to_s
184
- rescue StandardError
183
+ rescue Html2rss::Error
185
184
  nil
186
185
  end
187
186
 
@@ -270,9 +269,6 @@ module Html2rss
270
269
  return [*default_selectors, SelectorCandidates.empty] unless sst
271
270
 
272
271
  select_enhance_selectors(sst, articles)
273
- rescue ArgumentError => error
274
- Log.warn("Capture selector derivation failed: #{error.message}")
275
- [*default_selectors, SelectorCandidates.empty]
276
272
  end
277
273
 
278
274
  def hint_selectors
@@ -338,22 +334,13 @@ module Html2rss
338
334
  end
339
335
  end
340
336
 
341
- def find_matching_article(segment, articles_by_url) # rubocop:disable Metrics/AbcSize
342
- href = segment.primary_link.attrs.href.to_s
343
- return articles_by_url.values.find { |a| a.title == title_from_segment(segment) } if href.empty?
344
-
345
- resolved = Url.from_relative(href, @url)
337
+ def find_matching_article(segment, articles_by_url)
338
+ resolved = Url.from_relative(segment.primary_link.attrs.href.to_s, @url)
346
339
  articles_by_url[resolved.to_s] || articles_by_url.values.find { |a| fuzzy_url_match?(a.url, resolved) }
347
340
  end
348
341
 
349
342
  def fuzzy_url_match?(article_url, resolved)
350
343
  article_url.to_s.end_with?(resolved.path.to_s)
351
- rescue StandardError
352
- false
353
- end
354
-
355
- def title_from_segment(segment)
356
- segment.root_node.visible_text.to_s.strip
357
344
  end
358
345
 
359
346
  def lift_heading_link_roots(roots)
@@ -420,12 +407,7 @@ module Html2rss
420
407
  end
421
408
 
422
409
  def path_items_selector(roots)
423
- paths = roots.map(&:tag_path)
424
- common = common_path_prefix(paths)
425
- tag_path = common.empty? ? paths.first.to_s : common
426
- return if tag_path.empty?
427
-
428
- css_from_trimmed_tag_path(tag_path)
410
+ css_from_trimmed_tag_path(common_path_prefix(roots.map(&:tag_path)))
429
411
  end
430
412
 
431
413
  def css_from_trimmed_tag_path(tag_path)
@@ -443,9 +425,7 @@ module Html2rss
443
425
  trimmed.empty? ? [segments.last] : trimmed
444
426
  end
445
427
 
446
- def common_path_prefix(paths) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity
447
- return '' if paths.empty?
448
-
428
+ def common_path_prefix(paths)
449
429
  segments = paths.map { |p| p.split('/').reject(&:empty?) }
450
430
  prefix = segments.first
451
431
  segments.each do |seg|
@@ -466,7 +446,7 @@ module Html2rss
466
446
 
467
447
  def channel_title_from(response)
468
448
  Channel.from_response(response).title
469
- rescue StandardError
449
+ rescue Html2rss::Error
470
450
  nil
471
451
  end
472
452
 
@@ -486,7 +466,7 @@ module Html2rss
486
466
  return final unless Url.from_absolute(@url).same_document?(Url.from_absolute(final))
487
467
 
488
468
  nil
489
- rescue ArgumentError
469
+ rescue Html2rss::Url::InvalidUrlError
490
470
  nil
491
471
  end
492
472
  end