html2rss 0.27.1 → 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 +24 -47
- data/lib/html2rss/request_service/botasaurus_strategy.rb +34 -12
- 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 +39 -1
- metadata +19 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 38cdc153241c05684ee40477e4df06ca2c88f013c971387ddd0ee6d8163c178a
|
|
4
|
+
data.tar.gz: eda1c02f0199568f8f71d24b22480b5eaa9a83c7e48c23678e9695e98e2dab8f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5e063256aa063027d53986e38703cc0065d12e7aaee45cdfa7724a724f599a0f645e836a0573c9633865d007532bb82c3c2a6632984c5385dcc6fafd5ed0409d
|
|
7
|
+
data.tar.gz: 146ab91ee7925fc678ecdbe16895bd830bf9d7620bfc5239ccf12e109ef0913142da27f77c1be34f839cce350f51552ebcf163a5c92448c0b836b6a9fd8d7e4f
|
|
@@ -10,9 +10,10 @@ Entry: `FeedPipeline` → `AutoSource#articles` → `Scraper.build_instance` →
|
|
|
10
10
|
|
|
11
11
|
## Live flow
|
|
12
12
|
|
|
13
|
-
1. **Request** — `RequestSession` returns a `Response` with `body` (String) and `parsed_body` (Nokogiri HTML).
|
|
13
|
+
1. **Request** — `RequestSession` returns a `Response` with `body` (String) and `parsed_body` (Nokogiri HTML). Direct syndication responses skip HTML scrapers and parse via `Syndication::Parser`.
|
|
14
14
|
2. **Scraper tiers** — Enabled scrapers that claim the page (shallow `articles?` or instance `extractable?`) run in `Scraper::SCRAPER_TIERS` order. Merge within a tier, then stop when enough articles survive Cleanup:
|
|
15
15
|
|
|
16
|
+
0. Native feed: NativeFeed (head alternates + path discovery → RSS/Atom parse)
|
|
16
17
|
1. In-page structured: Schema, Microdata, Microformats2, JsonState, XhrArticles
|
|
17
18
|
2. Follow-up IO: WordPress API, Sitemap, MetaOembed
|
|
18
19
|
3. Heuristic: SemanticHtml
|
|
@@ -25,6 +26,8 @@ Entry: `FeedPipeline` → `AutoSource#articles` → `Scraper.build_instance` →
|
|
|
25
26
|
|
|
26
27
|
5. **Cleanup** — Merge, dedupe, hard-exclude non-article destinations (via `PathClassifier` facts), drop junk titles, and trim to `limit`. Html is skipped when earlier tiers already admitted clean items.
|
|
27
28
|
|
|
29
|
+
6. **Entry URL resolution** (pipeline, not this class) — when AutoFallback sees a weak homepage extract, `FeedResolution` may rewrite the scrape URL to a listing/feed before escalating strategies. See {Html2rss::FeedPipeline} and {Html2rss::FeedResolution}.
|
|
30
|
+
|
|
28
31
|
Segmenter strategies: `:semantic` (leaf containers + primary link), `:list` (repeated tag paths), `:cluster` (class/structure grids for anchorless cards). Scoring ranks and demotes; `LinkDestination::NoisePolicy` owns content-anchor eligibility. Cleanup owns feed-item admission.
|
|
29
32
|
|
|
30
33
|
## Nokogiri vs SST boundaries
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
class AutoSource
|
|
5
|
+
module Scraper
|
|
6
|
+
##
|
|
7
|
+
# Promotes a page's native RSS/Atom feed via {Syndication::Discovery} + {Syndication::Parser}.
|
|
8
|
+
class NativeFeed
|
|
9
|
+
include Enumerable
|
|
10
|
+
|
|
11
|
+
# @return [Symbol] scraper config key
|
|
12
|
+
def self.options_key = :native_feed
|
|
13
|
+
|
|
14
|
+
##
|
|
15
|
+
# @param _opts [Hash] unused options
|
|
16
|
+
# @return [Integer] follow-up request slots (discovery probes + feed fetch share the budget)
|
|
17
|
+
def self.request_slots(_opts = {})
|
|
18
|
+
1
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
##
|
|
22
|
+
# Shallow claim: head advertises a syndication alternate, or body looks feed-capable
|
|
23
|
+
# enough that path discovery is worth a follow-up slot.
|
|
24
|
+
#
|
|
25
|
+
# @param parsed_body [Nokogiri::HTML::Document, nil]
|
|
26
|
+
# @return [Boolean]
|
|
27
|
+
def self.articles?(parsed_body)
|
|
28
|
+
return false unless parsed_body.is_a?(Nokogiri::HTML::Document)
|
|
29
|
+
|
|
30
|
+
return true if ::Html2rss::Html::FeedLink.from_document(parsed_body).any?
|
|
31
|
+
|
|
32
|
+
parsed_body.css('head link[rel~="alternate"][href]').any? do |node|
|
|
33
|
+
href = node['href'].to_s
|
|
34
|
+
type = node['type'].to_s.downcase
|
|
35
|
+
type.include?('rss') || type.include?('atom') || href.match?(/rss|atom|feed|\.xml/i)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
##
|
|
40
|
+
# @param parsed_body [Nokogiri::HTML::Document]
|
|
41
|
+
# @param url [String, Html2rss::Url]
|
|
42
|
+
# @param request_session [Html2rss::RequestSession, nil]
|
|
43
|
+
# @param _opts [Hash]
|
|
44
|
+
# @option _opts [Object] :_reserved reserved for future scraper-specific options
|
|
45
|
+
def initialize(parsed_body, url:, request_session: nil, **_opts)
|
|
46
|
+
@parsed_body = parsed_body
|
|
47
|
+
@url = Html2rss::Url.from_absolute(url)
|
|
48
|
+
@request_session = request_session
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
##
|
|
52
|
+
# @yieldparam article [Hash{Symbol => Object}]
|
|
53
|
+
# @return [Enumerator, void]
|
|
54
|
+
def each(&)
|
|
55
|
+
return enum_for(:each) unless block_given?
|
|
56
|
+
|
|
57
|
+
articles = fetch_articles
|
|
58
|
+
if articles.empty?
|
|
59
|
+
Log.info("#{self.class}: host=#{url.host} item_count=0 fallback=true")
|
|
60
|
+
return
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
Log.info("#{self.class}: host=#{url.host} item_count=#{articles.size} fallback=false")
|
|
64
|
+
articles.each(&)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
attr_reader :parsed_body, :url, :request_session
|
|
70
|
+
|
|
71
|
+
def fetch_articles # rubocop:disable Metrics/MethodLength -- discovery + parse path
|
|
72
|
+
return [] unless request_session
|
|
73
|
+
|
|
74
|
+
response = Syndication::Discovery.best_feed_response(
|
|
75
|
+
page_url: url,
|
|
76
|
+
request_session:,
|
|
77
|
+
parsed_body:,
|
|
78
|
+
max_probes: self.class.request_slots
|
|
79
|
+
)
|
|
80
|
+
return [] unless response
|
|
81
|
+
|
|
82
|
+
Syndication::Parser.parse_response(response)
|
|
83
|
+
rescue Html2rss::Error, ArgumentError => error
|
|
84
|
+
Log.warn("#{self.class}: host=#{url.host} failed (#{error.class}: #{error.message})")
|
|
85
|
+
[]
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -23,7 +23,9 @@ module Html2rss
|
|
|
23
23
|
|
|
24
24
|
# Extraction tiers: merge within a tier, then stop when AutoSource has enough articles.
|
|
25
25
|
# Heuristic scrapers are separate tiers so SemanticHtml can satisfy before Html runs.
|
|
26
|
+
# NativeFeed is tier 0 so syndication wins before structured HTML scrapers.
|
|
26
27
|
SCRAPER_TIERS = [
|
|
28
|
+
[NativeFeed].freeze,
|
|
27
29
|
[Schema, Microdata, Microformats2, JsonState, XhrArticles].freeze,
|
|
28
30
|
[WordpressApi, Sitemap, MetaOembed].freeze,
|
|
29
31
|
[SemanticHtml].freeze,
|
|
@@ -36,7 +38,7 @@ module Html2rss
|
|
|
36
38
|
# Heuristic scrapers that share one memoized SST::Document per page.
|
|
37
39
|
HEURISTIC_SCRAPERS = [SemanticHtml, Html].freeze
|
|
38
40
|
# Scrapers that accept a shared follow-up +request_session+.
|
|
39
|
-
REQUEST_SESSION_SCRAPERS = [WordpressApi, Sitemap, MetaOembed].freeze
|
|
41
|
+
REQUEST_SESSION_SCRAPERS = [NativeFeed, WordpressApi, Sitemap, MetaOembed].freeze
|
|
40
42
|
# Scrapers that consume browser-captured XHR/fetch JSON bodies.
|
|
41
43
|
CAPTURED_RESPONSE_SCRAPERS = [XhrArticles].freeze
|
|
42
44
|
|
|
@@ -83,6 +85,7 @@ module Html2rss
|
|
|
83
85
|
# Returns an array of scraper classes that claim to find articles in the parsed body.
|
|
84
86
|
# @param parsed_body [Nokogiri::HTML::Document] The parsed HTML document.
|
|
85
87
|
# @param opts [Hash] The options hash.
|
|
88
|
+
# @option opts [Hash] :native_feed scraper toggle and configuration
|
|
86
89
|
# @option opts [Hash] :wordpress_api scraper toggle and configuration
|
|
87
90
|
# @option opts [Hash] :schema scraper toggle and configuration
|
|
88
91
|
# @option opts [Hash] :microdata scraper toggle and configuration
|
data/lib/html2rss/auto_source.rb
CHANGED
|
@@ -23,7 +23,14 @@ module Html2rss
|
|
|
23
23
|
# Default auto-source configuration shipped for scraper and cleanup behavior.
|
|
24
24
|
DEFAULT_CONFIG = {
|
|
25
25
|
limit: DEFAULT_LIMIT,
|
|
26
|
+
entry_resolution: {
|
|
27
|
+
enabled: true,
|
|
28
|
+
max_probes: 5
|
|
29
|
+
}.freeze,
|
|
26
30
|
scraper: {
|
|
31
|
+
native_feed: {
|
|
32
|
+
enabled: true
|
|
33
|
+
},
|
|
27
34
|
wordpress_api: {
|
|
28
35
|
enabled: true
|
|
29
36
|
},
|
|
@@ -7,8 +7,15 @@ module Html2rss
|
|
|
7
7
|
# Runtime source of truth for validating auto-source config values.
|
|
8
8
|
AutoSourceContract = Dry::Schema.Params do # rubocop:disable Metrics/BlockLength
|
|
9
9
|
optional(:limit).filled(:integer, gt?: 0)
|
|
10
|
+
optional(:entry_resolution).hash do
|
|
11
|
+
optional(:enabled).filled(:bool)
|
|
12
|
+
optional(:max_probes).filled(:integer, gt?: 0)
|
|
13
|
+
end
|
|
10
14
|
|
|
11
15
|
optional(:scraper).hash do # rubocop:disable Metrics/BlockLength
|
|
16
|
+
optional(:native_feed).hash do
|
|
17
|
+
optional(:enabled).filled(:bool)
|
|
18
|
+
end
|
|
12
19
|
optional(:wordpress_api).hash do
|
|
13
20
|
optional(:enabled).filled(:bool)
|
|
14
21
|
end
|
data/lib/html2rss/config.rb
CHANGED
|
@@ -264,8 +264,17 @@ module Html2rss
|
|
|
264
264
|
def headers = config[:headers]
|
|
265
265
|
# @return [Hash{Symbol => Object}] channel configuration
|
|
266
266
|
def channel = config[:channel]
|
|
267
|
-
|
|
267
|
+
##
|
|
268
|
+
# Source channel URL (also the default scrape URL).
|
|
269
|
+
#
|
|
270
|
+
# @return [String]
|
|
268
271
|
def url = config.dig(:channel, :url)
|
|
272
|
+
|
|
273
|
+
##
|
|
274
|
+
# @deprecated Use {ScrapeTarget} for entry vs effective URLs after resolution.
|
|
275
|
+
# @return [String]
|
|
276
|
+
def scrape_url = url
|
|
277
|
+
|
|
269
278
|
# @return [String, nil] configured channel time zone
|
|
270
279
|
def time_zone = config.dig(:channel, :time_zone)
|
|
271
280
|
|
|
@@ -11,6 +11,8 @@ Use `:auto` when you want Faraday first and a browser-backed hop only if that fe
|
|
|
11
11
|
1. **Faraday** — plain HTTP (faster, cheaper).
|
|
12
12
|
2. **Botasaurus** — attempted when Faraday raises a fallback-eligible error (for example `BlockedSurfaceDetected` or timeout) or extracts zero feed items.
|
|
13
13
|
|
|
14
|
+
Before escalating Faraday → Botasaurus on a **weak** auto-source extract (empty / below floor / high-entropy / app-shell / unsupported — not blocked), `FeedResolution` may probe up to five same-origin listing or native-feed candidates and rewrite the effective scrape URL via {ScrapeTarget}. Botasaurus then uses the resolved URL. Direct RSS/Atom entry URLs are parsed via `Syndication::Parser` without HTML AutoSource.
|
|
15
|
+
|
|
14
16
|
There is no Browserless / Puppeteer-in-gem tier. Pin `botasaurus` when you want browser rendering without Faraday first. Botasaurus needs `BOTASAURUS_SCRAPER_URL`.
|
|
15
17
|
|
|
16
18
|
## Surfaces
|
|
@@ -24,17 +24,33 @@ module Html2rss
|
|
|
24
24
|
##
|
|
25
25
|
# Mutable run state for one auto-fallback chain: attempts plus selected result.
|
|
26
26
|
class AttemptState
|
|
27
|
-
attr_reader :attempts, :result, :last_response
|
|
27
|
+
attr_reader :attempts, :result, :last_response, :entry_resolution
|
|
28
28
|
|
|
29
29
|
def initialize
|
|
30
30
|
@attempts = []
|
|
31
31
|
@result = nil
|
|
32
32
|
@last_response = nil
|
|
33
|
+
@entry_resolution = nil
|
|
34
|
+
@resolution_tried = false
|
|
33
35
|
end
|
|
34
36
|
|
|
35
37
|
# @return [Boolean] true when a strategy already yielded items
|
|
36
38
|
def succeeded? = !result.nil?
|
|
37
39
|
|
|
40
|
+
# @return [Boolean] whether entry resolution already ran for this chain
|
|
41
|
+
def resolution_tried? = @resolution_tried
|
|
42
|
+
|
|
43
|
+
# @return [void]
|
|
44
|
+
def mark_resolution_tried!
|
|
45
|
+
@resolution_tried = true
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# @param result [Html2rss::FeedResolution::Result]
|
|
49
|
+
# @return [void]
|
|
50
|
+
def remember_entry_resolution(result)
|
|
51
|
+
@entry_resolution = FeedResolution::Diag.from_result(result)
|
|
52
|
+
end
|
|
53
|
+
|
|
38
54
|
# @param strategy [Symbol] strategy that raised
|
|
39
55
|
# @param error [Exception] caught error
|
|
40
56
|
# @return [void]
|
|
@@ -64,10 +80,11 @@ module Html2rss
|
|
|
64
80
|
# @param selected_strategy [Symbol] concrete strategy that produced items
|
|
65
81
|
# @param attempt_count [Integer] number of attempts recorded for this chain
|
|
66
82
|
# @param admission_drops [Hash{String => Integer}] Cleanup drop tallies
|
|
83
|
+
# @param scrape_target [Html2rss::ScrapeTarget]
|
|
67
84
|
# @return [void]
|
|
68
|
-
# rubocop:disable Metrics/ParameterLists -- PipelineOutcome kwargs stay co-located
|
|
85
|
+
# rubocop:disable Metrics/ParameterLists, Metrics/MethodLength -- PipelineOutcome kwargs stay co-located
|
|
69
86
|
def succeed!(response:, articles:, dedup_dropped:, selected_strategy:, attempt_count:,
|
|
70
|
-
admission_drops: {})
|
|
87
|
+
scrape_target:, admission_drops: {})
|
|
71
88
|
@result = PipelineOutcome.new(
|
|
72
89
|
response:,
|
|
73
90
|
articles:,
|
|
@@ -75,10 +92,12 @@ module Html2rss
|
|
|
75
92
|
selected_strategy:,
|
|
76
93
|
attempt_count:,
|
|
77
94
|
strategy_attempts: attempts,
|
|
78
|
-
admission_drops
|
|
95
|
+
admission_drops:,
|
|
96
|
+
scrape_target:,
|
|
97
|
+
entry_resolution:
|
|
79
98
|
)
|
|
80
99
|
end
|
|
81
|
-
# rubocop:enable Metrics/ParameterLists
|
|
100
|
+
# rubocop:enable Metrics/ParameterLists, Metrics/MethodLength
|
|
82
101
|
end
|
|
83
102
|
|
|
84
103
|
##
|
|
@@ -94,6 +113,7 @@ module Html2rss
|
|
|
94
113
|
@pipeline = pipeline
|
|
95
114
|
@config = config
|
|
96
115
|
@resources = resources
|
|
116
|
+
@scrape_target = ScrapeTarget.from_config(config)
|
|
97
117
|
end
|
|
98
118
|
|
|
99
119
|
##
|
|
@@ -107,7 +127,7 @@ module Html2rss
|
|
|
107
127
|
|
|
108
128
|
private
|
|
109
129
|
|
|
110
|
-
attr_reader :strategies, :budget, :pipeline, :config, :resources
|
|
130
|
+
attr_reader :strategies, :budget, :pipeline, :config, :resources, :scrape_target
|
|
111
131
|
|
|
112
132
|
def run_attempts
|
|
113
133
|
AttemptState.new.tap do |state|
|
|
@@ -119,7 +139,9 @@ module Html2rss
|
|
|
119
139
|
end
|
|
120
140
|
|
|
121
141
|
def attempt(strategy:, next_strategy:, state:)
|
|
122
|
-
request_session = pipeline.request_session_for(
|
|
142
|
+
request_session = pipeline.request_session_for(
|
|
143
|
+
config, strategy:, resources:, scrape_url: scrape_target.effective_url
|
|
144
|
+
)
|
|
123
145
|
response = fetch_response(request_session:, strategy:, next_strategy:, state:)
|
|
124
146
|
return unless response
|
|
125
147
|
|
|
@@ -139,7 +161,8 @@ module Html2rss
|
|
|
139
161
|
nil
|
|
140
162
|
end
|
|
141
163
|
|
|
142
|
-
|
|
164
|
+
# rubocop:disable Metrics/MethodLength, Metrics/AbcSize -- extract + resolution delegate
|
|
165
|
+
def process_response(response:, strategy:, next_strategy:, request_session:, state:)
|
|
143
166
|
articles, dedup_dropped, admission_drops = articles_for(response:, request_session:)
|
|
144
167
|
state.remember_response(response)
|
|
145
168
|
items_count = articles.size
|
|
@@ -147,6 +170,15 @@ module Html2rss
|
|
|
147
170
|
Log.debug("#{self.class}: strategy=#{strategy} items=#{items_count} " \
|
|
148
171
|
"host=#{response.url.host} elapsed=#{format('%.3f', budget.elapsed_seconds)}s " \
|
|
149
172
|
"budget_remaining=#{budget_remaining_label}")
|
|
173
|
+
|
|
174
|
+
if (outcome = FeedResolution.try_apply!(
|
|
175
|
+
pipeline:, config:, response:, session: request_session, strategy:, resources:,
|
|
176
|
+
articles:, scrape_target:, state:, budget:
|
|
177
|
+
))
|
|
178
|
+
@scrape_target = outcome.scrape_target
|
|
179
|
+
return
|
|
180
|
+
end
|
|
181
|
+
|
|
150
182
|
if items_count.positive?
|
|
151
183
|
return record_success(response:, strategy:, articles:, dedup_dropped:, admission_drops:,
|
|
152
184
|
state:)
|
|
@@ -154,6 +186,7 @@ module Html2rss
|
|
|
154
186
|
|
|
155
187
|
log_info_fallback_zero_items(strategy:, next_strategy:, response:) if next_strategy
|
|
156
188
|
end
|
|
189
|
+
# rubocop:enable Metrics/MethodLength, Metrics/AbcSize
|
|
157
190
|
|
|
158
191
|
def articles_for(response:, request_session:)
|
|
159
192
|
pipeline.deduplicated_articles(config:, response:, request_session:)
|
|
@@ -162,8 +195,11 @@ module Html2rss
|
|
|
162
195
|
# rubocop:disable Metrics/ParameterLists -- success kwargs match PipelineOutcome
|
|
163
196
|
def record_success(response:, strategy:, articles:, dedup_dropped:, admission_drops:, state:)
|
|
164
197
|
attempt_count = state.attempts.size
|
|
165
|
-
state.succeed!(
|
|
166
|
-
|
|
198
|
+
state.succeed!(
|
|
199
|
+
response:, articles:, dedup_dropped:, selected_strategy: strategy,
|
|
200
|
+
attempt_count:, admission_drops:,
|
|
201
|
+
scrape_target:
|
|
202
|
+
)
|
|
167
203
|
return unless attempt_count > 1
|
|
168
204
|
|
|
169
205
|
Log.info("#{self.class}: auto selected strategy=#{strategy} after attempts=#{attempt_count} " \
|
|
@@ -173,12 +209,16 @@ module Html2rss
|
|
|
173
209
|
# rubocop:enable Metrics/ParameterLists
|
|
174
210
|
|
|
175
211
|
def finalize_failure(attempts:, response:)
|
|
176
|
-
surface_category = response
|
|
177
|
-
response.parsed_body, body: response.body
|
|
178
|
-
)
|
|
212
|
+
surface_category = surface_category_for(response)
|
|
179
213
|
raise NoFeedItemsExtracted.new(attempts:, surface_category:)
|
|
180
214
|
end
|
|
181
215
|
|
|
216
|
+
def surface_category_for(response)
|
|
217
|
+
return unless response
|
|
218
|
+
|
|
219
|
+
PageRecon.surface_category_for(response:, url: response.url)
|
|
220
|
+
end
|
|
221
|
+
|
|
182
222
|
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
183
223
|
def log_fallback_error(strategy:, next_strategy:, error:, request_session:)
|
|
184
224
|
host = request_session.url.host
|
|
@@ -60,6 +60,7 @@ module Html2rss
|
|
|
60
60
|
1 +
|
|
61
61
|
RequestSession::Pager.request_slots_for(config.selectors&.dig(:items, :pagination)) +
|
|
62
62
|
AutoSource.request_slots_for(config.auto_source) +
|
|
63
|
+
FeedResolution.request_slots_for(config.auto_source) +
|
|
63
64
|
StrategyPlan.resolve(config.strategy).request_slots
|
|
64
65
|
end
|
|
65
66
|
end
|
|
@@ -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
|
##
|
|
@@ -46,7 +46,9 @@ module Html2rss
|
|
|
46
46
|
selected_strategy: outcome.selected_strategy,
|
|
47
47
|
attempt_count: outcome.attempt_count,
|
|
48
48
|
strategy_attempts: outcome.strategy_attempts,
|
|
49
|
-
admission_drops: outcome.admission_drops
|
|
49
|
+
admission_drops: outcome.admission_drops,
|
|
50
|
+
scrape_target: outcome.scrape_target,
|
|
51
|
+
entry_resolution: outcome.entry_resolution
|
|
50
52
|
)
|
|
51
53
|
FeedResult.new(channel:, articles: outcome.articles, status:, stylesheets: config.stylesheets)
|
|
52
54
|
end
|
|
@@ -56,13 +58,15 @@ module Html2rss
|
|
|
56
58
|
# @param config [Html2rss::Config]
|
|
57
59
|
# @param strategy [Symbol]
|
|
58
60
|
# @param resources [Html2rss::FeedPipeline::RuntimePolicy::Resources]
|
|
61
|
+
# @param scrape_url [String, nil] effective fetch URL override for resolved targets
|
|
59
62
|
# @return [Html2rss::RequestSession]
|
|
60
|
-
def request_session_for(config, strategy:, resources:)
|
|
63
|
+
def request_session_for(config, strategy:, resources:, scrape_url: nil)
|
|
61
64
|
RequestSession.build(
|
|
62
65
|
config:,
|
|
63
66
|
strategy:,
|
|
64
67
|
budget: resources.budget,
|
|
65
|
-
policy: resources.policy
|
|
68
|
+
policy: resources.policy,
|
|
69
|
+
scrape_url:
|
|
66
70
|
)
|
|
67
71
|
end
|
|
68
72
|
|
|
@@ -99,19 +103,22 @@ module Html2rss
|
|
|
99
103
|
|
|
100
104
|
PipelineOutcome.new(
|
|
101
105
|
response:, articles:, dedup_dropped:, selected_strategy: nil, attempt_count: 0,
|
|
102
|
-
strategy_attempts: [], admission_drops
|
|
106
|
+
strategy_attempts: [], admission_drops:,
|
|
107
|
+
scrape_target: ScrapeTarget.from_config(config), entry_resolution: nil
|
|
103
108
|
)
|
|
104
109
|
end
|
|
105
110
|
|
|
106
111
|
def raise_empty_auto_source!(strategy:, response:)
|
|
107
112
|
raise NoFeedItemsExtracted.new(
|
|
108
113
|
attempts: [{ strategy:, items_count: 0, error_class: nil }],
|
|
109
|
-
surface_category:
|
|
110
|
-
response.parsed_body, body: response.body
|
|
111
|
-
)
|
|
114
|
+
surface_category: empty_auto_source_surface(response)
|
|
112
115
|
)
|
|
113
116
|
end
|
|
114
117
|
|
|
118
|
+
def empty_auto_source_surface(response)
|
|
119
|
+
PageRecon.surface_category_for(response:, url: response.url)
|
|
120
|
+
end
|
|
121
|
+
|
|
115
122
|
def run_auto_pipeline(config, resources:)
|
|
116
123
|
AutoFallback.new(
|
|
117
124
|
strategies: AutoFallback::CHAIN,
|
|
@@ -131,6 +138,7 @@ module Html2rss
|
|
|
131
138
|
# rubocop:disable Metrics/MethodLength
|
|
132
139
|
def selector_articles(config:, response:, request_session:)
|
|
133
140
|
return [] unless (selectors = config.selectors)
|
|
141
|
+
return [] if response.feed_response?
|
|
134
142
|
|
|
135
143
|
page_responses = request_session.page_responses(
|
|
136
144
|
response,
|
|
@@ -151,9 +159,17 @@ module Html2rss
|
|
|
151
159
|
# @return [Array(Array<Html2rss::Article>, Hash{String => Integer})]
|
|
152
160
|
def auto_source_articles(config:, response:, request_session:)
|
|
153
161
|
return [[], {}] unless (auto_source = config.auto_source)
|
|
162
|
+
return syndication_articles(response) if response.feed_response?
|
|
154
163
|
|
|
155
164
|
source = AutoSource.new(response, auto_source, request_session:)
|
|
156
165
|
[source.articles, source.admission_drops]
|
|
157
166
|
end
|
|
167
|
+
|
|
168
|
+
def syndication_articles(response)
|
|
169
|
+
articles = Syndication::Parser.parse_response(response).map do |hash|
|
|
170
|
+
Article.new(**hash, scraper: AutoSource::Scraper::NativeFeed)
|
|
171
|
+
end
|
|
172
|
+
[articles, {}]
|
|
173
|
+
end
|
|
158
174
|
end
|
|
159
175
|
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
|