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.
- checksums.yaml +4 -4
- data/README.md +82 -25
- data/html2rss.gemspec +0 -1
- data/lib/html2rss/article.rb +1 -2
- data/lib/html2rss/auto_source/README.md +12 -9
- data/lib/html2rss/auto_source/cleanup.rb +92 -1
- data/lib/html2rss/auto_source/scraper/json_state/document_scanner.rb +3 -5
- data/lib/html2rss/auto_source/scraper/meta_oembed.rb +15 -5
- data/lib/html2rss/auto_source/scraper/microdata.rb +8 -6
- data/lib/html2rss/auto_source/scraper/native_feed.rb +93 -0
- data/lib/html2rss/auto_source/scraper/schema.rb +39 -21
- data/lib/html2rss/auto_source/scraper.rb +6 -4
- data/lib/html2rss/auto_source/segmenter/cluster.rb +2 -3
- data/lib/html2rss/auto_source/segmenter/primary_link.rb +1 -2
- data/lib/html2rss/auto_source/segmenter.rb +1 -2
- data/lib/html2rss/auto_source.rb +32 -11
- data/lib/html2rss/batch.rb +169 -0
- data/lib/html2rss/capture/README.md +15 -6
- data/lib/html2rss/capture.rb +158 -15
- data/lib/html2rss/channel.rb +1 -2
- data/lib/html2rss/cli/probe_view.rb +66 -0
- data/lib/html2rss/cli/render.rb +124 -0
- data/lib/html2rss/cli/validate.rb +91 -0
- data/lib/html2rss/cli.rb +318 -193
- data/lib/html2rss/config/auto_source_contract.rb +7 -0
- data/lib/html2rss/config/request_headers.rb +4 -14
- data/lib/html2rss/config/schema.rb +7 -5
- data/lib/html2rss/config/validator.rb +1 -0
- data/lib/html2rss/config.rb +64 -18
- data/lib/html2rss/doctor/botasaurus.rb +130 -0
- data/lib/html2rss/feed_builder/rss.rb +1 -2
- data/lib/html2rss/feed_pipeline/README.md +7 -5
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +78 -19
- data/lib/html2rss/feed_pipeline/runtime_policy.rb +1 -0
- data/lib/html2rss/feed_pipeline.rb +48 -21
- 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 +268 -0
- data/lib/html2rss/html/article_extractor/heading_extractor.rb +24 -14
- data/lib/html2rss/html/article_extractor.rb +4 -9
- data/lib/html2rss/html/feed_link.rb +4 -2
- data/lib/html2rss/html/navigator.rb +4 -39
- data/lib/html2rss/html/probe.rb +82 -0
- data/lib/html2rss/html/sst_article_extractor.rb +1 -2
- data/lib/html2rss/link_destination/noise_policy.rb +1 -2
- data/lib/html2rss/link_destination/path_classifier.rb +20 -19
- data/lib/html2rss/mcp/README.md +80 -0
- data/lib/html2rss/mcp/contract.rb +135 -13
- data/lib/html2rss/mcp/outcome/playbook.rb +122 -0
- data/lib/html2rss/mcp/outcome.rb +112 -40
- data/lib/html2rss/mcp/runtime.rb +45 -0
- data/lib/html2rss/mcp/server/tools.rb +277 -0
- data/lib/html2rss/mcp/server.rb +91 -232
- data/lib/html2rss/mcp.rb +3 -0
- data/lib/html2rss/page_recon/README.md +69 -0
- data/lib/html2rss/page_recon/diagnostics.rb +211 -0
- data/lib/html2rss/page_recon.rb +297 -0
- data/lib/html2rss/recon.rb +252 -0
- data/lib/html2rss/request_service/blocked_surface.rb +1 -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/faraday_strategy.rb +11 -7
- data/lib/html2rss/request_service/policy.rb +1 -2
- data/lib/html2rss/request_service/response.rb +27 -4
- data/lib/html2rss/request_service/strategy.rb +1 -2
- data/lib/html2rss/request_service.rb +15 -1
- data/lib/html2rss/request_session.rb +4 -2
- data/lib/html2rss/scrape_target.rb +24 -0
- data/lib/html2rss/selectors/post_processors/sanitize_html.rb +2 -4
- data/lib/html2rss/selectors/post_processors.rb +0 -1
- data/lib/html2rss/selectors.rb +33 -10
- data/lib/html2rss/sst/attrs.rb +1 -2
- data/lib/html2rss/sst/normalizer.rb +49 -11
- data/lib/html2rss/status.rb +48 -24
- 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 +215 -0
- data/lib/html2rss/syndication/parser.rb +137 -0
- data/lib/html2rss/syndication.rb +10 -0
- data/lib/html2rss/test/enhance_audit.rb +192 -0
- data/lib/html2rss/test.rb +458 -0
- data/lib/html2rss/url.rb +23 -2
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss.rb +157 -35
- data/schema/html2rss-config.schema.json +47 -29
- metadata +34 -18
- data/lib/html2rss/mcp/inspect.rb +0 -241
- 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
|
-
|
|
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:
|
|
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
|