html2rss 0.27.2 → 0.28.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.
- checksums.yaml +4 -4
- data/lib/html2rss/auto_source/README.md +4 -1
- data/lib/html2rss/auto_source/scraper/native_feed.rb +90 -0
- data/lib/html2rss/auto_source/scraper.rb +4 -1
- data/lib/html2rss/auto_source.rb +7 -0
- data/lib/html2rss/config/auto_source_contract.rb +7 -0
- data/lib/html2rss/config.rb +10 -1
- data/lib/html2rss/feed_pipeline/README.md +2 -0
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +53 -13
- data/lib/html2rss/feed_pipeline/runtime_policy.rb +1 -0
- data/lib/html2rss/feed_pipeline.rb +24 -8
- data/lib/html2rss/feed_resolution/README.md +52 -0
- data/lib/html2rss/feed_resolution/candidate_generator.rb +139 -0
- data/lib/html2rss/feed_resolution/diag.rb +32 -0
- data/lib/html2rss/feed_resolution/options.rb +31 -0
- data/lib/html2rss/feed_resolution/policy.rb +47 -0
- data/lib/html2rss/feed_resolution/probe.rb +59 -0
- data/lib/html2rss/feed_resolution/scorer.rb +49 -0
- data/lib/html2rss/feed_resolution.rb +274 -0
- data/lib/html2rss/mcp/inspect.rb +18 -121
- data/lib/html2rss/mcp/outcome.rb +4 -2
- data/lib/html2rss/page_recon.rb +243 -0
- data/lib/html2rss/request_service/botasaurus_contract.rb +8 -1
- data/lib/html2rss/request_service/botasaurus_strategy.rb +1 -1
- data/lib/html2rss/request_service/response.rb +26 -2
- data/lib/html2rss/request_service.rb +13 -1
- data/lib/html2rss/request_session.rb +5 -2
- data/lib/html2rss/scrape_target.rb +24 -0
- data/lib/html2rss/status.rb +49 -22
- data/lib/html2rss/surface_category.rb +63 -0
- data/lib/html2rss/syndication/README.md +21 -0
- data/lib/html2rss/syndication/candidate_catalog.rb +26 -0
- data/lib/html2rss/syndication/discovery.rb +217 -0
- data/lib/html2rss/syndication/parser.rb +137 -0
- data/lib/html2rss/syndication.rb +10 -0
- data/lib/html2rss/url.rb +22 -0
- data/lib/html2rss/version.rb +1 -1
- data/schema/html2rss-config.schema.json +38 -0
- metadata +19 -2
|
@@ -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
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
##
|
|
5
|
+
# Entry URL tournament for weak homepage/hub extracts.
|
|
6
|
+
#
|
|
7
|
+
# {include:file:lib/html2rss/feed_resolution/README.md}
|
|
8
|
+
module FeedResolution
|
|
9
|
+
# Default entry-resolution options merged into AutoSource::DEFAULT_CONFIG.
|
|
10
|
+
DEFAULT_CONFIG = {
|
|
11
|
+
enabled: true,
|
|
12
|
+
max_probes: 5
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
##
|
|
16
|
+
# Tournament outcome for one entry-resolution attempt.
|
|
17
|
+
Result = Data.define(:entry_url, :scrape_url, :applied, :reason, :probe_count, :winner_score)
|
|
18
|
+
|
|
19
|
+
##
|
|
20
|
+
# @param auto_source [Hash, nil]
|
|
21
|
+
# @return [Integer] entry-resolution budget slots (probes + retry GET when enabled)
|
|
22
|
+
def self.request_slots_for(auto_source)
|
|
23
|
+
return 0 unless auto_source
|
|
24
|
+
|
|
25
|
+
Options.from_auto_source(auto_source).request_slots
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
##
|
|
29
|
+
# @param entry_url [String, Html2rss::Url]
|
|
30
|
+
# @param response [Html2rss::RequestService::Response]
|
|
31
|
+
# @param session [Html2rss::RequestSession]
|
|
32
|
+
# @param config [Html2rss::Config]
|
|
33
|
+
# @param articles [Array<Html2rss::Article>]
|
|
34
|
+
# @param surface_category [Symbol, nil]
|
|
35
|
+
# @return [Result]
|
|
36
|
+
# rubocop:disable Metrics/ParameterLists -- tournament kwargs stay co-located
|
|
37
|
+
def self.call(entry_url:, response:, session:, config:, articles:, surface_category: nil)
|
|
38
|
+
Runner.new(
|
|
39
|
+
entry_url:, response:, session:, config:, articles:, surface_category:
|
|
40
|
+
).call
|
|
41
|
+
end
|
|
42
|
+
# rubocop:enable Metrics/ParameterLists
|
|
43
|
+
|
|
44
|
+
##
|
|
45
|
+
# Outcome of {FeedResolution.try_apply!} for the auto-fallback chain.
|
|
46
|
+
ApplyOutcome = Data.define(:scrape_target, :status)
|
|
47
|
+
|
|
48
|
+
##
|
|
49
|
+
# Runs the tournament and, when a winner applies, re-fetches and re-extracts articles.
|
|
50
|
+
#
|
|
51
|
+
# @param pipeline [Html2rss::FeedPipeline]
|
|
52
|
+
# @param config [Html2rss::Config]
|
|
53
|
+
# @param response [Html2rss::RequestService::Response]
|
|
54
|
+
# @param session [Html2rss::RequestSession]
|
|
55
|
+
# @param strategy [Symbol]
|
|
56
|
+
# @param resources [Html2rss::FeedPipeline::RuntimePolicy::Resources]
|
|
57
|
+
# @param articles [Array]
|
|
58
|
+
# @param scrape_target [Html2rss::ScrapeTarget]
|
|
59
|
+
# @param state [Html2rss::FeedPipeline::AutoFallback::AttemptState]
|
|
60
|
+
# @param budget [Html2rss::RequestService::Budget]
|
|
61
|
+
# @return [ApplyOutcome, nil] `:succeeded` sticky scrape target when retry extract yielded items;
|
|
62
|
+
# `nil` when no winner or retry was empty (entry scrape target kept; Diag.applied may still be true)
|
|
63
|
+
# rubocop:disable Metrics/ParameterLists -- orchestration kwargs stay co-located
|
|
64
|
+
def self.try_apply!(pipeline:, config:, response:, session:, strategy:, resources:, articles:,
|
|
65
|
+
scrape_target:, state:, budget:)
|
|
66
|
+
Orchestrator.new(
|
|
67
|
+
pipeline:, config:, response:, session:, strategy:, resources:, articles:,
|
|
68
|
+
scrape_target:, state:, budget:
|
|
69
|
+
).call
|
|
70
|
+
end
|
|
71
|
+
# rubocop:enable Metrics/ParameterLists
|
|
72
|
+
|
|
73
|
+
# Tournament + optional retry orchestration for {FeedPipeline::AutoFallback}.
|
|
74
|
+
class Orchestrator
|
|
75
|
+
# Minimum remaining request budget required before probing candidates.
|
|
76
|
+
MIN_BUDGET_REMAINING = 3
|
|
77
|
+
|
|
78
|
+
# Error classes that abort resolution instead of falling through (mirrors AutoFallback).
|
|
79
|
+
NON_FALLBACK_ERRORS = FeedPipeline::AutoFallback::NON_FALLBACK_ERRORS
|
|
80
|
+
|
|
81
|
+
##
|
|
82
|
+
# @param pipeline [Html2rss::FeedPipeline]
|
|
83
|
+
# @param config [Html2rss::Config]
|
|
84
|
+
# @param response [Html2rss::RequestService::Response]
|
|
85
|
+
# @param session [Html2rss::RequestSession]
|
|
86
|
+
# @param strategy [Symbol]
|
|
87
|
+
# @param resources [Html2rss::FeedPipeline::RuntimePolicy::Resources]
|
|
88
|
+
# @param articles [Array]
|
|
89
|
+
# @param scrape_target [Html2rss::ScrapeTarget]
|
|
90
|
+
# @param state [Html2rss::FeedPipeline::AutoFallback::AttemptState]
|
|
91
|
+
# @param budget [Html2rss::RequestService::Budget]
|
|
92
|
+
# rubocop:disable Metrics/ParameterLists -- orchestration context stays co-located
|
|
93
|
+
def initialize(pipeline:, config:, response:, session:, strategy:, resources:, articles:,
|
|
94
|
+
scrape_target:, state:, budget:)
|
|
95
|
+
@pipeline = pipeline
|
|
96
|
+
@config = config
|
|
97
|
+
@response = response
|
|
98
|
+
@session = session
|
|
99
|
+
@strategy = strategy
|
|
100
|
+
@resources = resources
|
|
101
|
+
@articles = articles
|
|
102
|
+
@scrape_target = scrape_target
|
|
103
|
+
@state = state
|
|
104
|
+
@budget = budget
|
|
105
|
+
end
|
|
106
|
+
# rubocop:enable Metrics/ParameterLists
|
|
107
|
+
|
|
108
|
+
##
|
|
109
|
+
# @return [ApplyOutcome, nil]
|
|
110
|
+
# rubocop:disable Metrics/MethodLength -- eligibility + tournament + retry path
|
|
111
|
+
def call
|
|
112
|
+
return unless eligible?
|
|
113
|
+
|
|
114
|
+
state.mark_resolution_tried!
|
|
115
|
+
resolution = run_tournament
|
|
116
|
+
state.remember_entry_resolution(resolution)
|
|
117
|
+
return unless resolution.applied
|
|
118
|
+
|
|
119
|
+
effective = scrape_target.with_effective(resolution.scrape_url)
|
|
120
|
+
return ApplyOutcome.new(scrape_target: effective, status: :succeeded) if retry_with(
|
|
121
|
+
resolution, effective:
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
nil
|
|
125
|
+
rescue RequestService::RequestBudgetExceeded
|
|
126
|
+
Log.warn('FeedResolution: entry resolution skipped (request budget exhausted)')
|
|
127
|
+
nil
|
|
128
|
+
rescue *NON_FALLBACK_ERRORS
|
|
129
|
+
raise
|
|
130
|
+
rescue StandardError => error
|
|
131
|
+
Log.warn("FeedResolution: entry resolution retry failed (#{error.class})")
|
|
132
|
+
nil
|
|
133
|
+
end
|
|
134
|
+
# rubocop:enable Metrics/MethodLength
|
|
135
|
+
|
|
136
|
+
private
|
|
137
|
+
|
|
138
|
+
attr_reader :pipeline, :config, :response, :session, :strategy, :resources, :articles,
|
|
139
|
+
:scrape_target, :state, :budget, :assessment
|
|
140
|
+
|
|
141
|
+
def eligible?
|
|
142
|
+
return false if state.resolution_tried?
|
|
143
|
+
return false if response.feed_response?
|
|
144
|
+
return false if budget.remaining_requests < MIN_BUDGET_REMAINING
|
|
145
|
+
|
|
146
|
+
@assessment = PageRecon.assess(response:, url: config.url)
|
|
147
|
+
Policy.resolve?(
|
|
148
|
+
config:, articles:, surface_category: assessment.surface_category
|
|
149
|
+
)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def run_tournament
|
|
153
|
+
FeedResolution.call(
|
|
154
|
+
entry_url: config.url,
|
|
155
|
+
response:,
|
|
156
|
+
session:,
|
|
157
|
+
config:,
|
|
158
|
+
articles:,
|
|
159
|
+
surface_category: assessment.surface_category
|
|
160
|
+
)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def retry_with(_resolution, effective:) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- retry extract path
|
|
164
|
+
retry_session = pipeline.request_session_for(
|
|
165
|
+
config, strategy:, resources:, scrape_url: effective.effective_url
|
|
166
|
+
)
|
|
167
|
+
retry_response = retry_session.fetch_initial_response
|
|
168
|
+
retry_articles, dedup_dropped, admission_drops = pipeline.deduplicated_articles(
|
|
169
|
+
config:, response: retry_response, request_session: retry_session
|
|
170
|
+
)
|
|
171
|
+
state.remember_response(retry_response)
|
|
172
|
+
state.record_items(
|
|
173
|
+
strategy:, items_count: retry_articles.size, transport_meta: retry_response.transport_meta
|
|
174
|
+
)
|
|
175
|
+
return unless retry_articles.size.positive?
|
|
176
|
+
|
|
177
|
+
state.succeed!(
|
|
178
|
+
response: retry_response, articles: retry_articles, dedup_dropped:,
|
|
179
|
+
selected_strategy: strategy, attempt_count: state.attempts.size, admission_drops:,
|
|
180
|
+
scrape_target: effective
|
|
181
|
+
)
|
|
182
|
+
true
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Internal tournament runner (keeps {FeedResolution} a Zeitwerk namespace module).
|
|
187
|
+
class Runner
|
|
188
|
+
##
|
|
189
|
+
# @param entry_url [String, Html2rss::Url]
|
|
190
|
+
# @param response [Html2rss::RequestService::Response]
|
|
191
|
+
# @param session [Html2rss::RequestSession]
|
|
192
|
+
# @param config [Html2rss::Config]
|
|
193
|
+
# @param articles [Array<Html2rss::Article>]
|
|
194
|
+
# @param surface_category [Symbol, nil]
|
|
195
|
+
# rubocop:disable Metrics/ParameterLists -- tournament context stays co-located
|
|
196
|
+
def initialize(entry_url:, response:, session:, config:, articles:, surface_category: nil)
|
|
197
|
+
raise ArgumentError, 'articles must be an Array' unless articles.is_a?(Array)
|
|
198
|
+
|
|
199
|
+
@entry_url = Html2rss::Url.from_absolute(entry_url)
|
|
200
|
+
@response = response
|
|
201
|
+
@session = session
|
|
202
|
+
@config = config
|
|
203
|
+
@articles = articles
|
|
204
|
+
@surface_category = surface_category
|
|
205
|
+
end
|
|
206
|
+
# rubocop:enable Metrics/ParameterLists
|
|
207
|
+
|
|
208
|
+
##
|
|
209
|
+
# @return [Result]
|
|
210
|
+
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- policy → candidates → probe → select
|
|
211
|
+
def call
|
|
212
|
+
return skip(:policy_skip) unless Policy.resolve?(
|
|
213
|
+
config:, articles:, surface_category:
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
candidates = CandidateGenerator.call(
|
|
217
|
+
entry_url:,
|
|
218
|
+
response:,
|
|
219
|
+
max: max_probes
|
|
220
|
+
)
|
|
221
|
+
return skip(:no_candidates) if candidates.empty?
|
|
222
|
+
|
|
223
|
+
scored = probe_candidates(candidates)
|
|
224
|
+
winner = Scorer.pick_winner(scored:, entry_articles_count: articles.size)
|
|
225
|
+
return skip(:no_winner, probe_count: scored.size) unless winner
|
|
226
|
+
|
|
227
|
+
apply(winner, probe_count: scored.size)
|
|
228
|
+
end
|
|
229
|
+
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
|
|
230
|
+
|
|
231
|
+
private
|
|
232
|
+
|
|
233
|
+
attr_reader :entry_url, :response, :session, :config, :articles, :surface_category
|
|
234
|
+
|
|
235
|
+
def max_probes
|
|
236
|
+
Options.from_auto_source(config.auto_source).max_probes
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def probe_candidates(candidates)
|
|
240
|
+
probe = Probe.new(request_session: session, origin_url: entry_url)
|
|
241
|
+
candidates.filter_map { |url| probe.call(url) }
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def apply(winner, probe_count:)
|
|
245
|
+
result(
|
|
246
|
+
applied: true,
|
|
247
|
+
reason: :winner,
|
|
248
|
+
scrape_url: winner.url.to_s,
|
|
249
|
+
probe_count:,
|
|
250
|
+
winner_score: winner.score
|
|
251
|
+
)
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def skip(reason, probe_count: 0)
|
|
255
|
+
result(
|
|
256
|
+
applied: false,
|
|
257
|
+
reason:,
|
|
258
|
+
scrape_url: entry_url.to_s,
|
|
259
|
+
probe_count:,
|
|
260
|
+
winner_score: nil
|
|
261
|
+
)
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def result(applied:, reason:, scrape_url:, probe_count:, winner_score:)
|
|
265
|
+
scrape_host = Html2rss::Url.from_absolute(scrape_url).host
|
|
266
|
+
Log.info(
|
|
267
|
+
"FeedResolution: entry_host=#{entry_url.host} scrape_host=#{scrape_host} " \
|
|
268
|
+
"applied=#{applied} probe_count=#{probe_count} winner_score=#{winner_score.inspect} reason=#{reason}"
|
|
269
|
+
)
|
|
270
|
+
Result.new(entry_url: entry_url.to_s, scrape_url:, applied:, reason:, probe_count:, winner_score:)
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
end
|
data/lib/html2rss/mcp/inspect.rb
CHANGED
|
@@ -3,65 +3,28 @@
|
|
|
3
3
|
module Html2rss
|
|
4
4
|
module MCP
|
|
5
5
|
##
|
|
6
|
-
# Diagnostic inspect path (not Capture ownership). Fetches once
|
|
7
|
-
#
|
|
8
|
-
module Inspect
|
|
6
|
+
# Diagnostic inspect path (not Capture ownership). Fetches once, then delegates
|
|
7
|
+
# shared recon to {Html2rss::PageRecon}; adds MCP-only scraper/XHR diagnostics.
|
|
8
|
+
module Inspect
|
|
9
9
|
module_function
|
|
10
10
|
|
|
11
11
|
##
|
|
12
12
|
# @param url [String]
|
|
13
13
|
# @param strategy [String, Symbol]
|
|
14
14
|
# @return [Hash]
|
|
15
|
-
def call(url:, strategy: :auto)
|
|
15
|
+
def call(url:, strategy: :auto)
|
|
16
16
|
resolved = FeedPipeline::StrategyPlan.concrete_for_diagnostic(strategy)
|
|
17
17
|
response = fetch_response(url, resolved)
|
|
18
|
-
|
|
18
|
+
recon = PageRecon.call(response:, url:, strategy: resolved)
|
|
19
19
|
|
|
20
|
-
result =
|
|
20
|
+
result = recon.to_h.merge(
|
|
21
21
|
strategy: resolved,
|
|
22
|
-
|
|
23
|
-
html_response: response.html_response?,
|
|
24
|
-
scraper_eligibility: scraper_info(parsed),
|
|
25
|
-
sst_stats: sst_stats_from(response)
|
|
22
|
+
scraper_eligibility: scraper_info(safe_parsed_body(response))
|
|
26
23
|
)
|
|
27
|
-
|
|
28
|
-
if response.html_response?
|
|
29
|
-
sst = sst_document(response)
|
|
30
|
-
if sst
|
|
31
|
-
result[:sst] = {
|
|
32
|
-
node_count: sst.node_count,
|
|
33
|
-
degraded: sst.degraded,
|
|
34
|
-
segment_stats: segment_stats(sst, url)
|
|
35
|
-
}
|
|
36
|
-
end
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
blocked = Html2rss::RequestService::BlockedSurface.interstitial_signature_for(response.body)
|
|
40
|
-
result[:blocked_surface] = blocked[:key].to_s if blocked
|
|
41
24
|
result[:xhr_capture] = xhr_capture_info(response) if resolved == :botasaurus
|
|
42
|
-
merge_admission_diagnostics!(result, response)
|
|
43
|
-
|
|
44
25
|
result
|
|
45
26
|
end
|
|
46
27
|
|
|
47
|
-
##
|
|
48
|
-
# Surfaces Cleanup admission_drops without re-running full AutoSource discovery.
|
|
49
|
-
# Uses articles already extractable from a cheap AutoSource pass only when HTML.
|
|
50
|
-
#
|
|
51
|
-
# @param result [Hash]
|
|
52
|
-
# @param response [Html2rss::RequestService::Response]
|
|
53
|
-
# @return [void]
|
|
54
|
-
def merge_admission_diagnostics!(result, response)
|
|
55
|
-
return unless response.html_response?
|
|
56
|
-
|
|
57
|
-
source = AutoSource.new(response, AutoSource::DEFAULT_CONFIG.merge(limit: 10))
|
|
58
|
-
articles = source.articles
|
|
59
|
-
result[:articles_count] = articles.size
|
|
60
|
-
drops = source.admission_drops
|
|
61
|
-
result[:admission_drops] = drops if drops.any?
|
|
62
|
-
end
|
|
63
|
-
module_function :merge_admission_diagnostics!
|
|
64
|
-
|
|
65
28
|
##
|
|
66
29
|
# @param response [Html2rss::RequestService::Response]
|
|
67
30
|
# @return [Hash] redacted XHR capture diagnostics (no query strings)
|
|
@@ -147,95 +110,29 @@ module Html2rss
|
|
|
147
110
|
def sst_stats_from(response)
|
|
148
111
|
return nil unless response.html_response?
|
|
149
112
|
|
|
150
|
-
|
|
151
|
-
return nil unless
|
|
113
|
+
recon = PageRecon.call(response:, url: response.url)
|
|
114
|
+
return nil unless recon.sst
|
|
152
115
|
|
|
153
|
-
{ node_count:
|
|
154
|
-
rescue StandardError
|
|
155
|
-
nil
|
|
116
|
+
{ node_count: recon.sst[:node_count], degraded: recon.sst[:degraded] }
|
|
156
117
|
end
|
|
157
118
|
module_function :sst_stats_from
|
|
158
119
|
|
|
159
|
-
##
|
|
160
|
-
# @param response [Html2rss::RequestService::Response]
|
|
161
|
-
# @return [Html2rss::SST::Document, nil]
|
|
162
|
-
def sst_document(response)
|
|
163
|
-
Html2rss::SST::Normalizer.call(response.body)
|
|
164
|
-
rescue ArgumentError
|
|
165
|
-
nil
|
|
166
|
-
end
|
|
167
|
-
module_function :sst_document
|
|
168
|
-
|
|
169
|
-
##
|
|
170
|
-
# @param sst [Html2rss::SST::Document]
|
|
171
|
-
# @param url [String]
|
|
172
|
-
# @return [Hash]
|
|
173
|
-
def segment_stats(sst, url)
|
|
174
|
-
segments = discover_segments(sst, url)
|
|
175
|
-
return { found: 0 } if segments.empty?
|
|
176
|
-
|
|
177
|
-
{
|
|
178
|
-
found: segments.size,
|
|
179
|
-
strategies: segments.map(&:strategy).uniq,
|
|
180
|
-
sample_paths: segments.first(5).map { |s| s.root_node.tag_path }
|
|
181
|
-
}
|
|
182
|
-
end
|
|
183
|
-
module_function :segment_stats
|
|
184
|
-
|
|
185
120
|
##
|
|
186
121
|
# @param sst [Html2rss::SST::Document]
|
|
187
122
|
# @param url [String]
|
|
188
123
|
# @return [Array]
|
|
189
|
-
def discover_segments(sst, url)
|
|
190
|
-
link_resolver = Scoring::LinkResolver.new(url)
|
|
191
|
-
AutoSource::Segmenter.call(
|
|
192
|
-
sst,
|
|
193
|
-
base_url: url,
|
|
194
|
-
strategy: :list,
|
|
195
|
-
link_resolver:
|
|
196
|
-
)
|
|
197
|
-
rescue StandardError
|
|
198
|
-
[]
|
|
199
|
-
end
|
|
124
|
+
def discover_segments(sst, url) = PageRecon.discover_segments(sst, url)
|
|
200
125
|
module_function :discover_segments
|
|
201
126
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
# @param response [Html2rss::RequestService::Response]
|
|
205
|
-
# @param parsed [Object]
|
|
206
|
-
# @return [Hash]
|
|
207
|
-
def recon_fields(requested_url, response, parsed)
|
|
208
|
-
requested = Url.from_absolute(requested_url)
|
|
209
|
-
final = response.url
|
|
210
|
-
|
|
211
|
-
{
|
|
212
|
-
requested_url: requested.to_s,
|
|
213
|
-
final_url: final.to_s,
|
|
214
|
-
status: response.status,
|
|
215
|
-
scheme_downgrade: scheme_downgrade?(requested, final),
|
|
216
|
-
alternate_feeds: alternate_feeds_from(parsed)
|
|
217
|
-
}
|
|
218
|
-
end
|
|
219
|
-
module_function :recon_fields
|
|
220
|
-
|
|
221
|
-
##
|
|
222
|
-
# @param requested [Html2rss::Url]
|
|
223
|
-
# @param final [Html2rss::Url]
|
|
224
|
-
# @return [Boolean] true when the fetch downgraded https to http
|
|
225
|
-
def scheme_downgrade?(requested, final)
|
|
226
|
-
requested.scheme == 'https' && final.scheme == 'http'
|
|
227
|
-
end
|
|
228
|
-
module_function :scheme_downgrade?
|
|
229
|
-
|
|
230
|
-
##
|
|
231
|
-
# @param parsed [Object]
|
|
232
|
-
# @return [Array<Hash{Symbol => String}>]
|
|
233
|
-
def alternate_feeds_from(parsed)
|
|
234
|
-
return [] unless parsed.is_a?(Nokogiri::HTML::Document)
|
|
127
|
+
def safe_parsed_body(response)
|
|
128
|
+
return unless response.html_response?
|
|
235
129
|
|
|
236
|
-
|
|
130
|
+
response.parsed_body
|
|
131
|
+
rescue RequestService::UnsupportedResponseContentType
|
|
132
|
+
nil
|
|
237
133
|
end
|
|
238
|
-
module_function :
|
|
134
|
+
module_function :safe_parsed_body
|
|
135
|
+
private_class_method :safe_parsed_body
|
|
239
136
|
end
|
|
240
137
|
end
|
|
241
138
|
end
|
data/lib/html2rss/mcp/outcome.rb
CHANGED
|
@@ -24,7 +24,8 @@ module Html2rss
|
|
|
24
24
|
'scheme_downgrade, alternate_feeds).',
|
|
25
25
|
validate_config: 'Call validate_config with payload.yaml or a config hash (XOR, not both).',
|
|
26
26
|
apply_config: 'Call apply_config next. Confirm payload.item_count before shipping.',
|
|
27
|
-
scrape_url: 'Call scrape_url for articles now. strategy auto already runs Faraday then Botasaurus
|
|
27
|
+
scrape_url: 'Call scrape_url for articles now. strategy auto already runs Faraday then Botasaurus ' \
|
|
28
|
+
'and promotes native RSS/Atom when present.',
|
|
28
29
|
capture_config: 'Call capture_config for a reusable YAML draft, then follow next_step.',
|
|
29
30
|
read_runtime: 'Read html2rss://runtime. Set BOTASAURUS_SCRAPER_URL on the MCP process ' \
|
|
30
31
|
'if botasaurus_configured is false.'
|
|
@@ -150,7 +151,8 @@ module Html2rss
|
|
|
150
151
|
end
|
|
151
152
|
|
|
152
153
|
def inspect_next_step(payload)
|
|
153
|
-
|
|
154
|
+
# Runtime scrape_url now consumes native alternates (NativeFeed / direct feed parse).
|
|
155
|
+
return NextStep.scrape_url if Array(payload[:alternate_feeds]).any?
|
|
154
156
|
return NextStep.capture_config if payload[:articles_count].to_i.positive?
|
|
155
157
|
|
|
156
158
|
NextStep.scrape_url
|