html2rss 0.24.0 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +76 -2
  3. data/html2rss.gemspec +4 -1
  4. data/lib/html2rss/auto_source/cleanup.rb +62 -36
  5. data/lib/html2rss/auto_source/scraper/html.rb +68 -145
  6. data/lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb +116 -0
  7. data/lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb +63 -0
  8. data/lib/html2rss/auto_source/scraper/json_state/document_scanner.rb +179 -0
  9. data/lib/html2rss/auto_source/scraper/json_state/value_finder.rb +55 -0
  10. data/lib/html2rss/auto_source/scraper/json_state.rb +31 -401
  11. data/lib/html2rss/auto_source/scraper/schema/item_list.rb +2 -14
  12. data/lib/html2rss/auto_source/scraper/semantic_html/entry_deduplicator.rb +35 -30
  13. data/lib/html2rss/auto_source/scraper/semantic_html.rb +40 -120
  14. data/lib/html2rss/auto_source/scraper/sitemap/parser.rb +161 -0
  15. data/lib/html2rss/auto_source/scraper/sitemap.rb +10 -7
  16. data/lib/html2rss/auto_source/scraper/wordpress_api.rb +6 -2
  17. data/lib/html2rss/auto_source/scraper/xhr_articles.rb +69 -0
  18. data/lib/html2rss/auto_source/scraper.rb +109 -53
  19. data/lib/html2rss/auto_source/segment.rb +29 -0
  20. data/lib/html2rss/auto_source/segmenter/cluster.rb +163 -0
  21. data/lib/html2rss/auto_source/segmenter/list.rb +103 -0
  22. data/lib/html2rss/auto_source/segmenter/primary_link.rb +80 -0
  23. data/lib/html2rss/auto_source/segmenter/semantic.rb +104 -0
  24. data/lib/html2rss/auto_source/segmenter.rb +95 -0
  25. data/lib/html2rss/auto_source.rb +79 -21
  26. data/lib/html2rss/capture.rb +315 -0
  27. data/lib/html2rss/cli.rb +72 -18
  28. data/lib/html2rss/config/auto_source_contract.rb +5 -1
  29. data/lib/html2rss/config/request_controls.rb +33 -0
  30. data/lib/html2rss/config/schema.rb +67 -26
  31. data/lib/html2rss/config/selectors_validator.rb +60 -21
  32. data/lib/html2rss/config/validator.rb +33 -35
  33. data/lib/html2rss/config.rb +6 -2
  34. data/lib/html2rss/error.rb +15 -3
  35. data/lib/html2rss/feed_builder/rss.rb +17 -5
  36. data/lib/html2rss/feed_pipeline/auto_fallback.rb +16 -6
  37. data/lib/html2rss/feed_pipeline/runtime_policy.rb +3 -43
  38. data/lib/html2rss/feed_pipeline.rb +11 -0
  39. data/lib/html2rss/feed_result.rb +1 -1
  40. data/lib/html2rss/hash_util.rb +17 -0
  41. data/lib/html2rss/html/article_extractor/category_extractor.rb +10 -36
  42. data/lib/html2rss/html/article_extractor/date_extractor.rb +2 -7
  43. data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +5 -50
  44. data/lib/html2rss/html/article_extractor/image_extractor.rb +5 -16
  45. data/lib/html2rss/html/article_rules/category.rb +55 -0
  46. data/lib/html2rss/html/article_rules/date.rb +25 -0
  47. data/lib/html2rss/html/article_rules/enclosure.rb +72 -0
  48. data/lib/html2rss/html/article_rules/image.rb +109 -0
  49. data/lib/html2rss/html/article_rules.rb +10 -0
  50. data/lib/html2rss/html/rendering/audio_renderer.rb +2 -12
  51. data/lib/html2rss/html/rendering/escaped_attributes.rb +27 -0
  52. data/lib/html2rss/html/rendering/image_renderer.rb +2 -12
  53. data/lib/html2rss/html/rendering/pdf_renderer.rb +2 -8
  54. data/lib/html2rss/html/rendering/video_renderer.rb +2 -12
  55. data/lib/html2rss/html/sst_article_extractor.rb +308 -0
  56. data/lib/html2rss/link_destination/destination_facts.rb +40 -0
  57. data/lib/html2rss/link_destination/noise_policy.rb +68 -0
  58. data/lib/html2rss/link_destination/path_classifier.rb +208 -0
  59. data/lib/html2rss/link_destination/text_classifier.rb +64 -0
  60. data/lib/html2rss/link_destination.rb +8 -0
  61. data/lib/html2rss/mcp/server.rb +577 -0
  62. data/lib/html2rss/mcp.rb +21 -0
  63. data/lib/html2rss/request_service/blocked_surface.rb +24 -1
  64. data/lib/html2rss/request_service/botasaurus_contract.rb +89 -9
  65. data/lib/html2rss/request_service/botasaurus_strategy.rb +4 -2
  66. data/lib/html2rss/request_service/budget.rb +7 -35
  67. data/lib/html2rss/request_service/context.rb +0 -6
  68. data/lib/html2rss/request_service/faraday_strategy.rb +87 -3
  69. data/lib/html2rss/request_service/network_guard.rb +5 -3
  70. data/lib/html2rss/request_service/policy.rb +1 -1
  71. data/lib/html2rss/request_service/response.rb +15 -1
  72. data/lib/html2rss/request_service/strategy.rb +1 -2
  73. data/lib/html2rss/request_service.rb +4 -9
  74. data/lib/html2rss/scoring/cluster_scorer.rb +87 -0
  75. data/lib/html2rss/scoring/container_assessor.rb +77 -0
  76. data/lib/html2rss/scoring/engine.rb +145 -0
  77. data/lib/html2rss/scoring/link_resolver.rb +84 -0
  78. data/lib/html2rss/scoring/observation.rb +53 -0
  79. data/lib/html2rss/scoring/ranked_segment.rb +33 -0
  80. data/lib/html2rss/scoring/score.rb +11 -0
  81. data/lib/html2rss/scoring.rb +8 -0
  82. data/lib/html2rss/selectors/extractors/attribute.rb +15 -0
  83. data/lib/html2rss/selectors/extractors/href.rb +12 -0
  84. data/lib/html2rss/selectors/extractors/html.rb +12 -0
  85. data/lib/html2rss/selectors/extractors/static.rb +14 -0
  86. data/lib/html2rss/selectors/extractors/text.rb +11 -0
  87. data/lib/html2rss/selectors/post_processors/gsub.rb +18 -0
  88. data/lib/html2rss/selectors/post_processors/html_to_markdown.rb +11 -0
  89. data/lib/html2rss/selectors/post_processors/markdown_to_html.rb +12 -0
  90. data/lib/html2rss/selectors/post_processors/parse_time.rb +11 -0
  91. data/lib/html2rss/selectors/post_processors/parse_uri.rb +11 -0
  92. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +23 -1
  93. data/lib/html2rss/selectors/post_processors/substring.rb +21 -0
  94. data/lib/html2rss/selectors/post_processors/template.rb +20 -0
  95. data/lib/html2rss/selectors/schema_doc.rb +99 -0
  96. data/lib/html2rss/selectors.rb +0 -20
  97. data/lib/html2rss/sst/attrs.rb +91 -0
  98. data/lib/html2rss/sst/document.rb +23 -0
  99. data/lib/html2rss/sst/index.rb +112 -0
  100. data/lib/html2rss/sst/node.rb +147 -0
  101. data/lib/html2rss/sst/normalizer.rb +171 -0
  102. data/lib/html2rss/sst/tags.rb +26 -0
  103. data/lib/html2rss/sst/text.rb +81 -0
  104. data/lib/html2rss/sst.rb +8 -0
  105. data/lib/html2rss/url.rb +11 -7
  106. data/lib/html2rss/version.rb +1 -1
  107. data/lib/html2rss.rb +57 -26
  108. data/schema/html2rss-config.schema.json +428 -85
  109. metadata +93 -27
  110. data/lib/html2rss/auto_source/discovery/dom_clustering/group_scorer.rb +0 -80
  111. data/lib/html2rss/auto_source/discovery/dom_clustering/overlap_resolver.rb +0 -86
  112. data/lib/html2rss/auto_source/discovery/dom_clustering.rb +0 -119
  113. data/lib/html2rss/auto_source/discovery/list_candidates.rb +0 -94
  114. data/lib/html2rss/auto_source/discovery/semantic_anchor_candidates.rb +0 -219
  115. data/lib/html2rss/auto_source/discovery/semantic_containers.rb +0 -71
  116. data/lib/html2rss/auto_source/discovery/sitemap.rb +0 -159
  117. data/lib/html2rss/auto_source/discovery.rb +0 -14
  118. data/lib/html2rss/auto_source/link_heuristics/anchor_signals.rb +0 -28
  119. data/lib/html2rss/auto_source/link_heuristics/container_assessor.rb +0 -106
  120. data/lib/html2rss/auto_source/link_heuristics/container_signals.rb +0 -80
  121. data/lib/html2rss/auto_source/link_heuristics/destination_facts.rb +0 -42
  122. data/lib/html2rss/auto_source/link_heuristics/href_extractor.rb +0 -38
  123. data/lib/html2rss/auto_source/link_heuristics/path_classifier.rb +0 -221
  124. data/lib/html2rss/auto_source/link_heuristics/text_classifier.rb +0 -66
  125. data/lib/html2rss/auto_source/link_heuristics.rb +0 -139
  126. data/lib/html2rss/request_service/browserless_strategy.rb +0 -132
  127. data/lib/html2rss/request_service/puppet_commander/navigation_guards.rb +0 -148
  128. data/lib/html2rss/request_service/puppet_commander/preload_runner.rb +0 -86
  129. data/lib/html2rss/request_service/puppet_commander.rb +0 -95
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ class AutoSource
5
+ class Segmenter
6
+ ##
7
+ # Collects leaf semantic containers (port of Discovery::SemanticContainers).
8
+ module Semantic
9
+ # Tag names collected as leaf semantic containers.
10
+ CANDIDATE_NAMES = %i[article section li tr div].to_set.freeze
11
+
12
+ module_function
13
+
14
+ ##
15
+ # @param segmenter [Segmenter]
16
+ # @return [Array<Segment>]
17
+ def call(segmenter)
18
+ containers = collect_containers(segmenter.index)
19
+ primary = PrimaryLink.new(segmenter)
20
+
21
+ containers.each_with_index.filter_map do |container, position|
22
+ link = primary.select(container)
23
+ next unless link || segmenter.permit_unanchored
24
+
25
+ Segment.build(root_node: container, primary_link: link, strategy: :semantic, position:)
26
+ end
27
+ end
28
+
29
+ def collect_containers(index)
30
+ non_leaf = mark_non_leaf_candidates(index.root)
31
+ candidates = []
32
+ index.each_node do |node|
33
+ next unless CANDIDATE_NAMES.include?(node.name)
34
+ next if non_leaf.include?(node)
35
+ next if index.ignored_chrome?(node)
36
+
37
+ candidates << node
38
+ end
39
+
40
+ candidates = filter_nested(candidates, index)
41
+ sort_by_depth(candidates, index)
42
+ end
43
+ module_function :collect_containers
44
+ private_class_method :collect_containers
45
+
46
+ TAG_MASKS = { article: 1, section: 2, li: 4, tr: 8, div: 16 }.freeze
47
+ private_constant :TAG_MASKS
48
+
49
+ # 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
51
+ non_leaf = Set.new.compare_by_identity
52
+ visit = lambda do |node|
53
+ seen_mask = 0
54
+ node.children.each do |child|
55
+ child_mask = visit.call(child)
56
+ seen_mask |= child_mask | (TAG_MASKS[child.name] || 0)
57
+ end
58
+ node_mask = TAG_MASKS[node.name]
59
+ non_leaf.add(node) if node_mask && seen_mask.anybits?(node_mask)
60
+ seen_mask
61
+ end
62
+ visit.call(root)
63
+ non_leaf
64
+ end
65
+ module_function :mark_non_leaf_candidates
66
+ private_class_method :mark_non_leaf_candidates
67
+
68
+ def filter_nested(candidates, index)
69
+ candidate_set = {}.compare_by_identity
70
+ candidates.each { |c| candidate_set[c] = true }
71
+ rejected = {}.compare_by_identity
72
+
73
+ candidates.each do |candidate|
74
+ next if candidate.name == :div
75
+
76
+ reject_ancestors(candidate, candidate_set, rejected, index)
77
+ end
78
+
79
+ candidates.reject { |c| rejected[c] }
80
+ end
81
+ module_function :filter_nested
82
+ private_class_method :filter_nested
83
+
84
+ def reject_ancestors(node, candidate_set, rejected, index)
85
+ curr = index.parent_of(node)
86
+ while curr && curr.name != :html
87
+ rejected[curr] = true if candidate_set[curr]
88
+ curr = index.parent_of(curr)
89
+ end
90
+ end
91
+ module_function :reject_ancestors
92
+ private_class_method :reject_ancestors
93
+
94
+ def sort_by_depth(candidates, index)
95
+ candidates.each_with_index
96
+ .sort_by { |node, i| [-index.depth_of(node), i] }
97
+ .map(&:first)
98
+ end
99
+ module_function :sort_by_depth
100
+ private_class_method :sort_by_depth
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ class AutoSource
5
+ ##
6
+ # Discovers candidate content segments from an SST::Document.
7
+ #
8
+ # Strategies:
9
+ # - +:semantic+ — leaf article/section/li/tr/div containers with a primary link
10
+ # - +:list+ — repeated tag_path anchors walked to a shared container boundary
11
+ # - +:cluster+ — class / structure clustering for anchorless card grids
12
+ class Segmenter
13
+ # @param document [SST::Document]
14
+ # @param base_url [String, Html2rss::Url]
15
+ # @param strategy [Symbol]
16
+ # @param opts [Hash] segmentation options
17
+ # @option opts [Boolean] :permit_unanchored keep containers without a primary link
18
+ # @option opts [Integer] :minimum_selector_frequency list strategy frequency floor
19
+ # @option opts [Integer] :use_top_selectors list strategy selector budget
20
+ # @option opts [Scoring::LinkResolver, nil] :link_resolver page-scoped destination facts cache
21
+ # @return [Array<Segment>]
22
+ # rubocop:disable Style/ArgumentsForwarding -- keep named opts for YARD @option
23
+ def self.call(document, base_url:, strategy:, **opts)
24
+ new(document, base_url:, strategy:, **opts).call
25
+ end
26
+ # rubocop:enable Style/ArgumentsForwarding
27
+
28
+ # @param document [SST::Document]
29
+ # @param base_url [String, Html2rss::Url]
30
+ # @param strategy [Symbol]
31
+ # @param opts [Hash] segmentation options
32
+ # @option opts [Boolean] :permit_unanchored keep containers without a primary link
33
+ # @option opts [Integer] :minimum_selector_frequency list strategy frequency floor
34
+ # @option opts [Integer] :use_top_selectors list strategy selector budget
35
+ # @option opts [Scoring::LinkResolver, nil] :link_resolver page-scoped destination facts cache
36
+ def initialize(document, base_url:, strategy:, **opts)
37
+ raise ArgumentError, 'document must be SST::Document' unless document.is_a?(SST::Document)
38
+ raise ArgumentError, "unknown strategy: #{strategy.inspect}" unless Segment::STRATEGIES.include?(strategy)
39
+
40
+ @document = document
41
+ @base_url = base_url
42
+ @strategy = strategy
43
+ @permit_unanchored = opts.fetch(:permit_unanchored, false)
44
+ @minimum_selector_frequency = opts.fetch(:minimum_selector_frequency, 2)
45
+ @use_top_selectors = opts.fetch(:use_top_selectors, 5)
46
+ @link_resolver = opts[:link_resolver] || Scoring::LinkResolver.new(base_url)
47
+ @noise_policy = LinkDestination::NoisePolicy.new(link_resolver: @link_resolver)
48
+ end
49
+
50
+ # @return [Array<Segment>]
51
+ def call
52
+ case @strategy
53
+ when :semantic then Semantic.call(self)
54
+ when :list then List.call(self)
55
+ when :cluster then Cluster.call(self)
56
+ end
57
+ end
58
+
59
+ attr_reader :document, :base_url, :permit_unanchored, :minimum_selector_frequency,
60
+ :use_top_selectors, :link_resolver, :noise_policy
61
+
62
+ # @return [SST::Index]
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
93
+ end
94
+ end
95
+ end
@@ -15,17 +15,22 @@ 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
18
19
  class AutoSource
20
+ # Default max articles to keep (also the short-circuit floor across scraper tiers).
21
+ DEFAULT_LIMIT = 25
22
+
19
23
  # Default auto-source configuration shipped for scraper and cleanup behavior.
20
24
  DEFAULT_CONFIG = {
25
+ limit: DEFAULT_LIMIT,
21
26
  scraper: {
22
27
  wordpress_api: {
23
28
  enabled: true
24
29
  },
25
30
  sitemap: {
26
31
  enabled: true,
27
- min_priority: Discovery::Sitemap::DEFAULT_MIN_PRIORITY,
28
- max_age_days: Discovery::Sitemap::DEFAULT_MAX_AGE_DAYS
32
+ min_priority: Scraper::Sitemap::Parser::DEFAULT_MIN_PRIORITY,
33
+ max_age_days: Scraper::Sitemap::Parser::DEFAULT_MAX_AGE_DAYS
29
34
  },
30
35
  schema: {
31
36
  enabled: true
@@ -39,6 +44,9 @@ module Html2rss
39
44
  json_state: {
40
45
  enabled: true
41
46
  },
47
+ xhr_articles: {
48
+ enabled: true
49
+ },
42
50
  meta_oembed: {
43
51
  enabled: true
44
52
  },
@@ -80,28 +88,25 @@ module Html2rss
80
88
  # @param response [Html2rss::RequestService::Response] initial page response
81
89
  # @param opts [Hash] validated auto-source options
82
90
  # @param request_session [Html2rss::RequestSession, nil] shared request session for follow-up fetches
91
+ # @option opts [Integer] :limit max articles to keep; later tiers stop once this many survive Cleanup
83
92
  # @option opts [Hash] :scraper scraper configuration map
84
93
  # @option opts [Hash] :cleanup cleanup configuration map
85
94
  # @return [void]
86
95
  def initialize(response, opts = DEFAULT_CONFIG, request_session: nil)
87
96
  @parsed_body = response.parsed_body
97
+ @body = response.body
88
98
  @url = response.url
99
+ @captured_responses = response.captured_responses
89
100
  @opts = opts
90
101
  @request_session = request_session
91
102
  end
92
103
 
93
104
  ##
94
- # Extracts article candidates by selecting every scraper that can explain the
95
- # page shape, running those scrapers, and normalizing the resulting hashes
96
- # into `Article` objects.
105
+ # Extracts articles by running scraper tiers until a sufficient set is found.
97
106
  #
98
- # The contributor-facing flow is:
99
- # 1. choose scraper instances that match the page
100
- # 2. let each scraper collect its own candidates
101
- # 3. clean and deduplicate the merged article list
102
- #
103
- # Scrapers with expensive precomputation, such as `SemanticHtml`, keep that
104
- # state on the instance so detection and extraction can reuse the same work.
107
+ # Tiers: in-page structured → follow-up IO → SemanticHtml → Html.
108
+ # SST is built only when a heuristic tier runs. Later tiers are skipped once
109
+ # +limit+ articles with url+title remain after Cleanup; the result is capped to +limit+.
105
110
  #
106
111
  # @return [Array<Html2rss::Article>] extracted articles
107
112
  def articles
@@ -113,22 +118,74 @@ module Html2rss
113
118
 
114
119
  private
115
120
 
116
- attr_reader :url, :parsed_body, :request_session
121
+ attr_reader :url, :parsed_body, :body, :request_session, :captured_responses
117
122
 
123
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
118
124
  def extract_articles
119
- scraper_instances = Scraper.instances_for(parsed_body, url:, request_session:, opts: @opts[:scraper])
120
- return [] if scraper_instances.empty?
125
+ articles = []
126
+ matched = false
127
+ document = nil
128
+ link_resolver = nil
129
+ scraper_opts = @opts.fetch(:scraper, {})
130
+
131
+ Scraper::SCRAPER_TIERS.each do |tier|
132
+ break if enough_articles?(articles)
133
+
134
+ if Scraper.heuristic_tier?(tier)
135
+ document ||= Scraper.normalize_sst(parsed_body)
136
+ next unless document
137
+
138
+ link_resolver ||= ::Html2rss::Scoring::LinkResolver.new(url)
139
+ end
140
+
141
+ tier.each do |scraper_class|
142
+ instance = Scraper.build_instance(
143
+ scraper_class,
144
+ parsed_body,
145
+ opts: scraper_opts,
146
+ url:,
147
+ request_session:,
148
+ body:,
149
+ document:,
150
+ link_resolver:,
151
+ captured_responses:
152
+ )
153
+ next unless instance
154
+ next unless Scraper.extractable_instance?(instance, parsed_body)
121
155
 
122
- # Scrapers are run sequentially.
123
- articles = scraper_instances.flat_map do |instance|
124
- run_scraper(instance)
156
+ matched = true
157
+ articles.concat(run_scraper(instance))
158
+ end
125
159
  end
126
- Cleanup.call(articles, url:, **cleanup_options)
160
+
161
+ raise Scraper.no_scraper_found_for(parsed_body, body:) unless matched
162
+
163
+ Cleanup.call(articles, url:, **cleanup_options).first(article_limit)
164
+ end
165
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
166
+
167
+ def enough_articles?(articles)
168
+ threshold = article_limit
169
+ return false if articles.size < threshold
170
+
171
+ cleaned = Cleanup.call(articles.dup, url:, **cleanup_options)
172
+ cleaned.count { |article| article.url && !article.title.to_s.empty? } >= threshold
173
+ end
174
+
175
+ def article_limit
176
+ @opts.fetch(:limit, DEFAULT_LIMIT)
127
177
  end
128
178
 
129
179
  def run_scraper(instance)
130
- instance.each.map do |article_hash|
131
- Article.new(**article_hash, scraper: instance.class)
180
+ instance.each.map do |item|
181
+ case item
182
+ when Article
183
+ item
184
+ when Hash
185
+ Article.new(**item, scraper: instance.class)
186
+ else
187
+ raise TypeError, "#{instance.class} yielded #{item.class}; expected Article or Hash"
188
+ end
132
189
  end
133
190
  end
134
191
 
@@ -136,4 +193,5 @@ module Html2rss
136
193
  @opts.fetch(:cleanup, {})
137
194
  end
138
195
  end
196
+ # rubocop:enable Metrics/ClassLength
139
197
  end
@@ -0,0 +1,315 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ ##
5
+ # Analyzes a URL and produces a reusable feed config hash with derived CSS selectors.
6
+ #
7
+ # Uses the auto-source pipeline to extract articles, then traces back through
8
+ # SST segments to build items + attribute selectors suitable for a static feed config.
9
+ class Capture # rubocop:disable Metrics/ClassLength
10
+ LEADING_TRIM_TAGS = %w[html body].freeze
11
+ private_constant :LEADING_TRIM_TAGS
12
+
13
+ ##
14
+ # Result of a capture operation.
15
+ # @!attribute config [Hash] feed config hash with +:channel+ and +:selectors+
16
+ # @!attribute articles_count [Integer] number of articles extracted
17
+ # @!attribute channel_title [String] derived channel title
18
+ CaptureResult = Data.define(:config, :articles_count, :channel_title)
19
+
20
+ class << self
21
+ ##
22
+ # Analyzes a URL and builds a reusable feed config.
23
+ #
24
+ # @param url [String] source page URL
25
+ # @param strategy [Symbol] request strategy (+:auto+, +:faraday+, +:botasaurus+)
26
+ # @option options [String, nil] :items_selector optional selector hint
27
+ # @option options [Integer, nil] :max_redirects optional redirect limit override
28
+ # @option options [Integer, nil] :max_requests optional request budget override
29
+ # @option options [Integer, nil] :limit max articles to keep
30
+ # @option options [String, nil] :local_file_path optional local HTML file path
31
+ # @return [CaptureResult]
32
+ def build(url, strategy: :auto, **)
33
+ new(url, strategy:, **).build
34
+ end
35
+ end
36
+
37
+ ##
38
+ # @param url [String] source page URL
39
+ # @param strategy [Symbol] request strategy
40
+ # @param options [Hash] additional options
41
+ # @option options [String, nil] :items_selector optional selector hint
42
+ # @option options [Integer, nil] :max_redirects optional redirect limit override
43
+ # @option options [Integer, nil] :max_requests optional request budget override
44
+ # @option options [Integer, nil] :limit max articles to keep
45
+ # @option options [String, nil] :local_file_path optional local HTML file path
46
+ def initialize(url, strategy: :auto, **options)
47
+ @url = url
48
+ @strategy = strategy
49
+ @items_selector_hint = options.delete(:items_selector)
50
+ @max_redirects = options.delete(:max_redirects)
51
+ @max_requests = options.delete(:max_requests)
52
+ @limit = options.delete(:limit)
53
+ @local_file_path = options.delete(:local_file_path)
54
+ end
55
+
56
+ ##
57
+ # Runs the capture pipeline.
58
+ #
59
+ # @return [CaptureResult]
60
+ def build # rubocop:disable Metrics/MethodLength
61
+ response = fetch_response
62
+ articles = extract_articles(response)
63
+ selectors = derive_selectors(response, articles)
64
+
65
+ config = {
66
+ channel: build_channel(response),
67
+ selectors: selectors.empty? ? nil : selectors,
68
+ **local_file_request_overlay
69
+ }.compact
70
+
71
+ CaptureResult.new(
72
+ config:,
73
+ articles_count: articles.size,
74
+ channel_title: channel_title_from(response)
75
+ )
76
+ end
77
+
78
+ private
79
+
80
+ def raw_config
81
+ @raw_config ||= build_raw_config
82
+ end
83
+
84
+ def build_raw_config # rubocop:disable Metrics/MethodLength
85
+ Config.auto_source_config(
86
+ url: @url,
87
+ items_selector: @items_selector_hint,
88
+ request_controls: Config::RequestControls.from_shortcut(
89
+ strategy: @strategy,
90
+ max_redirects: @max_redirects,
91
+ max_requests: @max_requests
92
+ ),
93
+ limit: @limit
94
+ ).tap do |config|
95
+ config[:strategy] = resolve_strategy(config[:strategy] || @strategy)
96
+ apply_local_file_path!(config)
97
+ end
98
+ end
99
+
100
+ def apply_local_file_path!(config)
101
+ return unless @local_file_path
102
+
103
+ config[:strategy] = :local_file
104
+ config[:request] ||= {}
105
+ config[:request][:local_file_path] = @local_file_path
106
+ end
107
+
108
+ def local_file_request_overlay
109
+ return {} unless @local_file_path
110
+
111
+ { strategy: :local_file, request: { local_file_path: @local_file_path } }
112
+ end
113
+
114
+ def resolve_strategy(strategy)
115
+ plan = FeedPipeline::StrategyPlan.resolve(strategy)
116
+ plan.is_a?(FeedPipeline::StrategyPlan::Auto) ? :faraday : plan.strategy
117
+ end
118
+
119
+ def fetch_response
120
+ config = Config.from_hash(raw_config)
121
+ resources = FeedPipeline::RuntimePolicy.resources_for(config)
122
+ session = RequestSession.build(
123
+ config:,
124
+ strategy: config.strategy,
125
+ budget: resources.budget,
126
+ policy: resources.policy
127
+ )
128
+ session.fetch_initial_response
129
+ end
130
+
131
+ def extract_articles(response)
132
+ auto_source_opts = raw_config[:auto_source] || AutoSource::DEFAULT_CONFIG
133
+ AutoSource.new(response, auto_source_opts).articles
134
+ rescue AutoSource::Scraper::NoScraperFound
135
+ []
136
+ end
137
+
138
+ def derive_selectors(response, articles) # rubocop:disable Metrics/MethodLength
139
+ return {} if articles.empty? || !response.html_response?
140
+
141
+ sst = normalize_sst(response)
142
+ return {} unless sst
143
+
144
+ segments = discover_segments(sst)
145
+ return {} if segments.empty?
146
+
147
+ matched = match_segments_to_articles(segments, articles)
148
+ return {} if matched.empty?
149
+
150
+ build_selector_hash(matched, sst)
151
+ rescue ArgumentError => error
152
+ Log.warn("Capture selector derivation failed: #{error.message}")
153
+ {}
154
+ end
155
+
156
+ def normalize_sst(response)
157
+ SST::Normalizer.call(response.body)
158
+ end
159
+
160
+ def discover_segments(sst)
161
+ link_resolver = Scoring::LinkResolver.new(@url)
162
+
163
+ AutoSource::Segmenter.call(
164
+ sst,
165
+ base_url: @url,
166
+ strategy: :list,
167
+ permit_unanchored: false,
168
+ link_resolver:
169
+ )
170
+ end
171
+
172
+ def match_segments_to_articles(segments, articles)
173
+ articles_by_url = articles.each_with_object({}) do |article, hash|
174
+ url_str = article.url.to_s
175
+ hash[url_str] = article unless url_str.empty?
176
+ end
177
+
178
+ segments.filter_map do |segment|
179
+ next unless segment.primary_link
180
+
181
+ article = find_matching_article(segment, articles_by_url)
182
+ { segment:, article: } if article
183
+ end
184
+ end
185
+
186
+ def find_matching_article(segment, articles_by_url) # rubocop:disable Metrics/AbcSize
187
+ href = segment.primary_link.attrs.href.to_s
188
+ return articles_by_url.values.find { |a| a.title == title_from_segment(segment) } if href.empty?
189
+
190
+ resolved = Url.from_relative(href, @url)
191
+ articles_by_url[resolved.to_s] || articles_by_url.values.find { |a| fuzzy_url_match?(a.url, resolved) }
192
+ end
193
+
194
+ def fuzzy_url_match?(article_url, resolved)
195
+ article_url.to_s.end_with?(resolved.path.to_s)
196
+ rescue StandardError
197
+ false
198
+ end
199
+
200
+ def title_from_segment(segment)
201
+ segment.root_node.visible_text.to_s.strip
202
+ end
203
+
204
+ def build_selector_hash(matched, _sst)
205
+ items_sel = items_selector(matched)
206
+ return {} unless items_sel
207
+
208
+ first = matched.first
209
+ root = first[:segment].root_node
210
+
211
+ attrs = {}.tap do |a|
212
+ a[:title] = title_selector(root)
213
+ a[:url] = url_selector(first[:segment])
214
+ a[:description] = description_selector(root, first[:segment])
215
+ end.compact
216
+
217
+ { items: { selector: items_sel }, **attrs }
218
+ end
219
+
220
+ def items_selector(matched)
221
+ roots = matched.map { |m| m[:segment].root_node }
222
+ shared = shared_class_items_selector(roots)
223
+ return shared if shared
224
+
225
+ paths = roots.map(&:tag_path)
226
+ common = common_path_prefix(paths)
227
+ tag_path = common.empty? ? paths.first.to_s : common
228
+ return nil if tag_path.empty?
229
+
230
+ css_from_trimmed_tag_path(tag_path)
231
+ end
232
+
233
+ def shared_class_items_selector(roots)
234
+ shared = roots.map { |root| root.attrs.class_names }.reduce { |left, right| left & right }
235
+ return nil if shared.nil? || shared.empty?
236
+
237
+ "#{roots.first.name}.#{shared.min}"
238
+ end
239
+
240
+ def css_from_trimmed_tag_path(tag_path)
241
+ segments = tag_path.split('/').reject(&:empty?)
242
+ trimmed = trim_leading_items_segments(segments)
243
+ trimmed.join(' > ')
244
+ end
245
+
246
+ def trim_leading_items_segments(segments)
247
+ return segments if segments.empty?
248
+
249
+ trimmed = segments.dup
250
+ trimmed.shift while trimmed.any? && LEADING_TRIM_TAGS.include?(trimmed.first)
251
+ trimmed.shift while trimmed.length > 1 && trimmed.first == 'div'
252
+ trimmed.empty? ? [segments.last] : trimmed
253
+ end
254
+
255
+ def common_path_prefix(paths) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity
256
+ return '' if paths.empty?
257
+
258
+ segments = paths.map { |p| p.split('/').reject(&:empty?) }
259
+ prefix = segments.first
260
+ segments.each do |seg|
261
+ i = 0
262
+ i += 1 while i < prefix.length && i < seg.length && prefix[i] == seg[i]
263
+ prefix = prefix[0...i]
264
+ end
265
+ "/#{prefix.join('/')}"
266
+ end
267
+
268
+ def title_selector(root) # rubocop:disable Metrics/CyclomaticComplexity
269
+ heading = root.find(&:heading?)
270
+ relative = css_for_path(heading.tag_path, root.tag_path) if heading
271
+ relative ||= begin
272
+ link = root.find { |n| n.link? && n.visible_text.to_s.strip.length > 3 }
273
+ css_for_path(link.tag_path, root.tag_path) if link
274
+ end
275
+ return nil unless relative
276
+
277
+ { selector: relative }
278
+ end
279
+
280
+ def url_selector(segment)
281
+ return nil unless segment.primary_link
282
+
283
+ relative = css_for_path(segment.primary_link.tag_path, segment.root_node.tag_path)
284
+ { selector: relative, extractor: 'href' }
285
+ end
286
+
287
+ def description_selector(root, segment)
288
+ relative = css_for_path(root.tag_path, segment.root_node.tag_path)
289
+ return nil if relative.nil? || relative.empty? || relative == '.'
290
+
291
+ { selector: relative }
292
+ end
293
+
294
+ def css_for_path(full_path, root_path)
295
+ relative = full_path.delete_prefix(root_path)
296
+ return '.' if relative.empty?
297
+
298
+ relative.split('/').reject(&:empty?).join(' > ')
299
+ end
300
+
301
+ def build_channel(response)
302
+ {
303
+ url: @url,
304
+ title: channel_title_from(response),
305
+ time_zone: 'UTC'
306
+ }.compact
307
+ end
308
+
309
+ def channel_title_from(response)
310
+ Channel.from_response(response).title
311
+ rescue StandardError
312
+ nil
313
+ end
314
+ end
315
+ end