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
@@ -12,7 +12,7 @@ module Html2rss
12
12
  # strategy_attempts: auto attempt hashes (with optional transport_meta); empty outside :auto.
13
13
  PipelineOutcome = Data.define(
14
14
  :response, :articles, :dedup_dropped, :selected_strategy, :attempt_count, :strategy_attempts,
15
- :admission_drops
15
+ :admission_drops, :scrape_target, :entry_resolution
16
16
  )
17
17
 
18
18
  ##
@@ -31,38 +31,39 @@ module Html2rss
31
31
  pipeline_outcome_for(config)
32
32
  end
33
33
 
34
+ ##
35
+ # Runs the pipeline once and returns scrape outcome plus {FeedResult} without a second fetch.
36
+ #
37
+ # @return [Array(PipelineOutcome, FeedResult)]
38
+ def to_outcome_and_result
39
+ config = Config.from_hash(raw_config, params: raw_config[:params])
40
+ outcome = pipeline_outcome_for(config)
41
+ [outcome, feed_result_for(config, outcome)]
42
+ end
43
+
34
44
  ##
35
45
  # Runs the pipeline once and returns an opaque, Marshal-cacheable result.
36
46
  #
37
47
  # @return [Html2rss::FeedResult]
38
- # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- Status kwargs stay co-located with Channel
39
48
  def to_result
40
49
  config = Config.from_hash(raw_config, params: raw_config[:params])
41
50
  outcome = pipeline_outcome_for(config)
42
- channel = Channel.from_response(outcome.response, overrides: config.channel)
43
- status = Status.build(
44
- articles: outcome.articles,
45
- dedup_dropped: outcome.dedup_dropped,
46
- selected_strategy: outcome.selected_strategy,
47
- attempt_count: outcome.attempt_count,
48
- strategy_attempts: outcome.strategy_attempts,
49
- admission_drops: outcome.admission_drops
50
- )
51
- FeedResult.new(channel:, articles: outcome.articles, status:, stylesheets: config.stylesheets)
51
+ feed_result_for(config, outcome)
52
52
  end
53
- # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
54
53
 
55
54
  # @api private Host seam for {AutoFallback} (and single-strategy path).
56
55
  # @param config [Html2rss::Config]
57
56
  # @param strategy [Symbol]
58
57
  # @param resources [Html2rss::FeedPipeline::RuntimePolicy::Resources]
58
+ # @param scrape_url [String, nil] effective fetch URL override for resolved targets
59
59
  # @return [Html2rss::RequestSession]
60
- def request_session_for(config, strategy:, resources:)
60
+ def request_session_for(config, strategy:, resources:, scrape_url: nil)
61
61
  RequestSession.build(
62
62
  config:,
63
63
  strategy:,
64
64
  budget: resources.budget,
65
- policy: resources.policy
65
+ policy: resources.policy,
66
+ scrape_url:
66
67
  )
67
68
  end
68
69
 
@@ -99,19 +100,22 @@ module Html2rss
99
100
 
100
101
  PipelineOutcome.new(
101
102
  response:, articles:, dedup_dropped:, selected_strategy: nil, attempt_count: 0,
102
- strategy_attempts: [], admission_drops:
103
+ strategy_attempts: [], admission_drops:,
104
+ scrape_target: ScrapeTarget.from_config(config), entry_resolution: nil
103
105
  )
104
106
  end
105
107
 
106
108
  def raise_empty_auto_source!(strategy:, response:)
107
109
  raise NoFeedItemsExtracted.new(
108
110
  attempts: [{ strategy:, items_count: 0, error_class: nil }],
109
- surface_category: AutoSource::Scraper.classify_no_scraper_surface(
110
- response.parsed_body, body: response.body
111
- )
111
+ surface_category: empty_auto_source_surface(response)
112
112
  )
113
113
  end
114
114
 
115
+ def empty_auto_source_surface(response)
116
+ PageRecon.surface_category_for(response:, url: response.url)
117
+ end
118
+
115
119
  def run_auto_pipeline(config, resources:)
116
120
  AutoFallback.new(
117
121
  strategies: AutoFallback::CHAIN,
@@ -128,9 +132,10 @@ module Html2rss
128
132
  [selector + auto, admission_drops]
129
133
  end
130
134
 
131
- # rubocop:disable Metrics/MethodLength
135
+ # rubocop:disable-next Metrics/MethodLength
132
136
  def selector_articles(config:, response:, request_session:)
133
137
  return [] unless (selectors = config.selectors)
138
+ return [] if response.feed_response?
134
139
 
135
140
  page_responses = request_session.page_responses(
136
141
  response,
@@ -146,14 +151,36 @@ module Html2rss
146
151
  end
147
152
  articles
148
153
  end
149
- # rubocop:enable Metrics/MethodLength
150
154
 
151
155
  # @return [Array(Array<Html2rss::Article>, Hash{String => Integer})]
152
156
  def auto_source_articles(config:, response:, request_session:)
153
157
  return [[], {}] unless (auto_source = config.auto_source)
158
+ return syndication_articles(response) if response.feed_response?
154
159
 
155
160
  source = AutoSource.new(response, auto_source, request_session:)
156
161
  [source.articles, source.admission_drops]
157
162
  end
163
+
164
+ def syndication_articles(response)
165
+ articles = Syndication::Parser.parse_response(response).map do |hash|
166
+ Article.new(**hash, scraper: AutoSource::Scraper::NativeFeed)
167
+ end
168
+ [articles, {}]
169
+ end
170
+
171
+ def feed_result_for(config, outcome) # rubocop:disable Metrics/MethodLength
172
+ channel = Channel.from_response(outcome.response, overrides: config.channel)
173
+ status = Status.build(
174
+ articles: outcome.articles,
175
+ dedup_dropped: outcome.dedup_dropped,
176
+ selected_strategy: outcome.selected_strategy,
177
+ attempt_count: outcome.attempt_count,
178
+ strategy_attempts: outcome.strategy_attempts,
179
+ admission_drops: outcome.admission_drops,
180
+ scrape_target: outcome.scrape_target,
181
+ entry_resolution: outcome.entry_resolution
182
+ )
183
+ FeedResult.new(channel:, articles: outcome.articles, status:, stylesheets: config.stylesheets)
184
+ end
158
185
  end
159
186
  end
@@ -0,0 +1,52 @@
1
+ # FeedResolution
2
+
3
+ Backend-only entry URL tournament: when the pasted URL is a weak homepage/hub
4
+ (or NativeFeed-majority extract), probe same-origin listing/feed candidates and
5
+ sticky-rewrite the scrape URL only when the retry extract yields items
6
+ (`try_apply!` `:succeeded`) before AutoFallback escalates strategies.
7
+
8
+ Goal: best **article listing** for AutoSource. A native feed may still win the
9
+ tournament on item count (feed-as-means) — intentional.
10
+
11
+ ## Ownership
12
+
13
+ | Concern | Owner |
14
+ | ------------------------------------------------------------------------------------------ | ---------------------------------------------------------- |
15
+ | When to resolve (`articles:` required; floor / weak / NativeFeed ≥50%) | `FeedResolution::Policy` |
16
+ | Candidate mix (1 feed + up to 4 listing; taxonomy nav; segment first-wins) | `FeedResolution::CandidateGenerator` |
17
+ | Listing path lexicon (`LISTING_PATHS`) | `Syndication::CandidateCatalog` (consumed, not owned here) |
18
+ | Cheap probe + score + winner pick | `FeedResolution::Probe` + `FeedResolution::Scorer` |
19
+ | Typed entry_resolution options | `FeedResolution::Options` |
20
+ | Wire-safe resolution diag (`applied` = tournament win, not sticky URL) | `FeedResolution::Diag` |
21
+ | Public `call` → `Result`; retry orchestration (sticky `ScrapeTarget` only on `:succeeded`) | `FeedResolution` (`try_apply!`) |
22
+ | Page surface for policy/scoring | `PageRecon::Assessment` + `Html2rss::SurfaceCategory` |
23
+ | Native feed discover/parse | `Syndication` (not this module) |
24
+
25
+ ### CandidateGenerator mix
26
+
27
+ - **Feed slot:** first `Syndication::Discovery.candidate_urls` only (alternates precede path guesses).
28
+ - **Listing fill** (cap = `max - 1` when `max > 1`; `max == 1` → feed only): nav (`taxonomy_path +3`, `content_path +1`) → segment first-wins (`:list` → `:cluster` → `:semantic`, ≥2 same-origin primary links) → `LISTING_PATHS`.
29
+ - Same-page / same-registrable-domain filters; feed never pads into listing slots.
30
+
31
+ ### Policy gates
32
+
33
+ Resolve when eligible config **and** not blocked **and** any of:
34
+
35
+ - `articles.size < ARTICLE_FLOOR` (3), or
36
+ - surface `weak?`, or
37
+ - ≥50% of articles have `scraper == AutoSource::Scraper::NativeFeed` (`native * 2 >= size`; empty → false).
38
+
39
+ `FeedResolution.call` / `Runner` take **`articles:`** (Array) — not `articles_count:`.
40
+
41
+ ## Non-goals
42
+
43
+ - Scorer weight overhaul / ranked multi-feed bake-off
44
+ - Cross-origin probes
45
+ - JSON Feed ingest; Sitemap/WP claim-gates
46
+ - Create UI / Site Explorer
47
+ - Changing `PageRecon` `:list`-only `discover_segments` (CandidateGenerator owns its own first-wins)
48
+ - Syndication parse inside `FeedBuilder`
49
+
50
+ ## Config
51
+
52
+ `auto_source.entry_resolution: { enabled: true, max_probes: 5 }`
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ module FeedResolution
5
+ ##
6
+ # Builds same-origin entry-resolution candidates: one feed slot plus listing seeds.
7
+ #
8
+ # Mix is 1 feed + up to +listing_cap+ listing URLs (nav → segment first-wins →
9
+ # {Syndication::CandidateCatalog::LISTING_PATHS}). Feed never pads into listing slots.
10
+ class CandidateGenerator
11
+ # Max candidates returned to the probe stage.
12
+ DEFAULT_MAX = 5
13
+ # Nav/header/footer anchor sources for DestinationFacts ranking.
14
+ NAV_SELECTORS = 'header a[href], nav a[href], footer a[href]'
15
+ # Segment strategies tried first-wins (≥2 same-origin primary links).
16
+ SEGMENT_STRATEGIES = %i[list cluster semantic].freeze
17
+
18
+ ##
19
+ # @param entry_url [String, Html2rss::Url]
20
+ # @param response [Html2rss::RequestService::Response]
21
+ # @param max [Integer]
22
+ # @return [Array<Html2rss::Url>]
23
+ def self.call(entry_url:, response:, max: DEFAULT_MAX)
24
+ new(entry_url:, response:, max:).call
25
+ end
26
+
27
+ ##
28
+ # @param entry_url [String, Html2rss::Url]
29
+ # @param response [Html2rss::RequestService::Response]
30
+ # @param max [Integer]
31
+ def initialize(entry_url:, response:, max: DEFAULT_MAX)
32
+ @entry_url = Html2rss::Url.from_absolute(entry_url)
33
+ @response = response
34
+ @max = max
35
+ end
36
+
37
+ ##
38
+ # @return [Array<Html2rss::Url>]
39
+ def call
40
+ [feed_slot, *listing_urls.first(listing_cap)].compact.uniq
41
+ end
42
+
43
+ private
44
+
45
+ attr_reader :entry_url, :response, :max
46
+
47
+ def listing_cap = max > 1 ? max - 1 : 0
48
+
49
+ def feed_slot
50
+ return unless parsed_html
51
+
52
+ url = Syndication::Discovery.candidate_urls(page_url: entry_url, parsed_body: parsed_html).first
53
+ return unless url
54
+ return unless eligible_url?(url)
55
+
56
+ url
57
+ end
58
+
59
+ def listing_urls
60
+ (nav_candidates + segment_seed_urls + listing_path_candidates)
61
+ .uniq
62
+ .select { eligible_url?(_1) }
63
+ end
64
+
65
+ def nav_candidates
66
+ return [] unless parsed_html
67
+
68
+ parsed_html.css(NAV_SELECTORS)
69
+ .filter_map { |anchor| ranked_anchor_url(anchor) }
70
+ .sort_by { |(_url, rank)| -rank }
71
+ .map(&:first)
72
+ end
73
+
74
+ def ranked_anchor_url(anchor)
75
+ url = absolute_same_origin(anchor['href'])
76
+ return unless url
77
+
78
+ facts = LinkDestination::DestinationFacts.build(url)
79
+ rank = 0
80
+ rank += 3 if facts.taxonomy_path
81
+ rank += 1 if facts.content_path
82
+ [url, rank] if rank.positive?
83
+ end
84
+
85
+ def segment_seed_urls
86
+ return [] unless parsed_html
87
+
88
+ sst = SST::Normalizer.call(parsed_html)
89
+ link_resolver = Scoring::LinkResolver.new(entry_url)
90
+
91
+ SEGMENT_STRATEGIES.each do |strategy|
92
+ urls = segment_urls_for(sst, strategy, link_resolver)
93
+ return urls if urls.size >= 2
94
+ end
95
+ []
96
+ rescue StandardError
97
+ []
98
+ end
99
+
100
+ def segment_urls_for(sst, strategy, link_resolver)
101
+ segmenter = AutoSource::Segmenter.new(sst, base_url: entry_url, strategy:, link_resolver:)
102
+ primary = AutoSource::Segmenter::PrimaryLink.new(segmenter)
103
+
104
+ segmenter.call.filter_map do |segment|
105
+ link = segment.primary_link || primary.select(segment.root_node)
106
+ absolute_same_origin(link&.attrs&.href)
107
+ end.uniq
108
+ end
109
+
110
+ def listing_path_candidates
111
+ Syndication::CandidateCatalog::LISTING_PATHS.filter_map { |path| absolute_same_origin(path) }
112
+ end
113
+
114
+ def parsed_html
115
+ return @parsed_html if defined?(@parsed_html)
116
+
117
+ @parsed_html = response.html_response? ? response.parsed_body : nil
118
+ rescue RequestService::UnsupportedResponseContentType
119
+ @parsed_html = nil
120
+ end
121
+
122
+ def absolute_same_origin(href)
123
+ return if href.nil? || href.empty? || href.start_with?('#', 'mailto:', 'javascript:')
124
+
125
+ Html2rss::Url.from_relative(href, entry_url)
126
+ rescue ArgumentError
127
+ nil
128
+ end
129
+
130
+ def eligible_url?(url)
131
+ !entry_url.same_document?(url) && same_registrable_domain?(url)
132
+ end
133
+
134
+ def same_registrable_domain?(url)
135
+ url.domain == entry_url.domain
136
+ end
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ module FeedResolution
5
+ ##
6
+ # Wire-safe diagnostics for one entry-resolution attempt (Status / Marshal edge).
7
+ Diag = Data.define(:applied, :probe_count, :reason, :winner_score) do
8
+ ##
9
+ # @param result [Html2rss::FeedResolution::Result]
10
+ # @return [Diag]
11
+ def self.from_result(result)
12
+ new(
13
+ applied: result.applied,
14
+ probe_count: result.probe_count,
15
+ reason: result.reason,
16
+ winner_score: result.winner_score
17
+ )
18
+ end
19
+
20
+ ##
21
+ # @return [Hash{Symbol => Object}]
22
+ def to_h
23
+ {
24
+ applied:,
25
+ probe_count:,
26
+ reason:,
27
+ **(winner_score.nil? ? {} : { winner_score: })
28
+ }
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ module FeedResolution
5
+ ##
6
+ # Typed +auto_source.entry_resolution+ options (one expansion from config Hash).
7
+ Options = Data.define(:enabled, :max_probes) do
8
+ ##
9
+ # @param auto_source [Hash, nil]
10
+ # @return [Options]
11
+ def self.from_auto_source(auto_source)
12
+ raw = auto_source.is_a?(Hash) ? auto_source[:entry_resolution] : nil
13
+ raw = {} unless raw.is_a?(Hash)
14
+ new(
15
+ enabled: raw.fetch(:enabled, FeedResolution::DEFAULT_CONFIG[:enabled]) != false,
16
+ max_probes: Integer(raw.fetch(:max_probes, FeedResolution::DEFAULT_CONFIG[:max_probes]))
17
+ )
18
+ end
19
+
20
+ ##
21
+ # @return [Boolean]
22
+ def enabled? = enabled
23
+
24
+ ##
25
+ # Probe slots plus one Orchestrator retry GET when enabled.
26
+ #
27
+ # @return [Integer]
28
+ def request_slots = enabled? ? max_probes + 1 : 0
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ module FeedResolution
5
+ ##
6
+ # Decides whether an entry URL tournament should run after a weak extract.
7
+ module Policy
8
+ # Minimum admitted articles before the entry URL is considered strong enough.
9
+ ARTICLE_FLOOR = 3
10
+
11
+ module_function
12
+
13
+ ##
14
+ # @param config [Html2rss::Config]
15
+ # @param articles [Array<Html2rss::Article>] typed extract results (count derived)
16
+ # @param surface_category [Html2rss::SurfaceCategory, Symbol, nil]
17
+ # @return [Boolean]
18
+ def resolve?(config:, articles:, surface_category:)
19
+ raise ArgumentError, 'articles must be an Array' unless articles.is_a?(Array)
20
+ return false unless eligible_config?(config)
21
+
22
+ category = SurfaceCategory.coerce(surface_category)
23
+ return false if category.blocked?
24
+
25
+ articles.size < ARTICLE_FLOOR || category.weak? || native_feed_majority?(articles)
26
+ end
27
+
28
+ def eligible_config?(config)
29
+ return false unless config.auto_source
30
+ return false if config.selectors
31
+
32
+ Options.from_auto_source(config.auto_source).enabled?
33
+ end
34
+ module_function :eligible_config?
35
+ private_class_method :eligible_config?
36
+
37
+ def native_feed_majority?(articles)
38
+ return false if articles.empty?
39
+
40
+ native = articles.count { |article| article.scraper == AutoSource::Scraper::NativeFeed }
41
+ native * 2 >= articles.size
42
+ end
43
+ module_function :native_feed_majority?
44
+ private_class_method :native_feed_majority?
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ module FeedResolution
5
+ ##
6
+ # Cheap follow-up probe that scores a candidate via {PageRecon.assess} or syndication parse.
7
+ class Probe
8
+ ##
9
+ # @param url [Html2rss::Url]
10
+ # @param score [Numeric]
11
+ # @param articles_count [Integer]
12
+ Scored = Data.define(:url, :score, :articles_count)
13
+
14
+ ##
15
+ # @param request_session [Html2rss::RequestSession]
16
+ # @param origin_url [String, Html2rss::Url]
17
+ def initialize(request_session:, origin_url:)
18
+ @request_session = request_session
19
+ @origin_url = Html2rss::Url.from_absolute(origin_url)
20
+ end
21
+
22
+ ##
23
+ # @param url [Html2rss::Url]
24
+ # @return [Scored, nil]
25
+ def call(url)
26
+ response = request_session.follow_up(
27
+ url:,
28
+ relation: :entry_resolution,
29
+ origin_url:
30
+ )
31
+ score_response(url, response)
32
+ rescue Html2rss::Error
33
+ nil
34
+ end
35
+
36
+ private
37
+
38
+ attr_reader :request_session, :origin_url
39
+
40
+ def score_response(_url, response) # rubocop:disable Metrics/MethodLength -- feed vs HTML branches
41
+ if response.feed_response?
42
+ count = Syndication::Parser.parse_response(response).size
43
+ return Scored.new(
44
+ url: response.url,
45
+ score: Scorer.score_feed(articles_count: count),
46
+ articles_count: count
47
+ )
48
+ end
49
+
50
+ assessment = PageRecon.assess(response:, url: response.url)
51
+ Scored.new(
52
+ url: response.url,
53
+ score: Scorer.score_assessment(assessment),
54
+ articles_count: assessment.articles_count
55
+ )
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ module FeedResolution
5
+ ##
6
+ # Scores probe targets and picks winners from cheap {PageRecon::Assessment} facts
7
+ # or syndication item counts.
8
+ module Scorer
9
+ # Weight for admitted article counts on HTML probes.
10
+ ARTICLE_WEIGHT = 10
11
+ # Bonus when the surface looks like a listing rather than a hub/shell.
12
+ LISTING_SURFACE_BONUS = 5
13
+
14
+ module_function
15
+
16
+ ##
17
+ # @param articles_count [Integer]
18
+ # @return [Integer]
19
+ def score_feed(articles_count:)
20
+ articles_count.to_i * ARTICLE_WEIGHT
21
+ end
22
+
23
+ ##
24
+ # @param assessment [Html2rss::PageRecon::Assessment]
25
+ # @return [Integer]
26
+ def score_assessment(assessment)
27
+ score = assessment.articles_count * ARTICLE_WEIGHT
28
+ score += LISTING_SURFACE_BONUS if assessment.listing_bonus?
29
+ drops = assessment.admission_drops.values.sum
30
+ total = assessment.articles_count + drops
31
+ score -= ((drops.to_f / total) * ARTICLE_WEIGHT).round if total.positive?
32
+ score
33
+ end
34
+
35
+ ##
36
+ # @param scored [Array<Probe::Scored>]
37
+ # @param entry_articles_count [Integer]
38
+ # @return [Probe::Scored, nil]
39
+ def pick_winner(scored:, entry_articles_count:)
40
+ winner = scored.max_by(&:score)
41
+ return unless winner
42
+ return if winner.articles_count <= entry_articles_count.to_i
43
+ return if winner.score <= 0
44
+
45
+ winner
46
+ end
47
+ end
48
+ end
49
+ end