html2rss 0.32.4 → 0.33.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/article/enclosure.rb +2 -6
- data/lib/html2rss/auto_source/cleanup.rb +1 -3
- data/lib/html2rss/auto_source/scraper/html.rb +2 -8
- data/lib/html2rss/auto_source/scraper/json_document.rb +2 -3
- data/lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb +128 -128
- data/lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb +37 -37
- data/lib/html2rss/auto_source/scraper/json_state/document_scanner.rb +123 -123
- data/lib/html2rss/auto_source/scraper/json_state/value_finder.rb +34 -34
- data/lib/html2rss/auto_source/scraper/json_state.rb +2 -6
- data/lib/html2rss/auto_source/scraper/meta_oembed.rb +32 -2
- data/lib/html2rss/auto_source/scraper/microdata.rb +228 -245
- data/lib/html2rss/auto_source/scraper/microformats2.rb +9 -1
- data/lib/html2rss/auto_source/scraper/native_feed.rb +1 -1
- data/lib/html2rss/auto_source/scraper/schema/category_extractor.rb +7 -2
- data/lib/html2rss/auto_source/scraper/schema/item_list.rb +2 -2
- data/lib/html2rss/auto_source/scraper/schema/thing.rb +98 -21
- data/lib/html2rss/auto_source/scraper/schema.rb +49 -2
- data/lib/html2rss/auto_source/scraper/semantic_html.rb +12 -16
- data/lib/html2rss/auto_source/scraper/sitemap/parser.rb +1 -4
- data/lib/html2rss/auto_source/scraper/sitemap.rb +3 -11
- data/lib/html2rss/auto_source/scraper/wordpress_api/page_scope.rb +2 -2
- data/lib/html2rss/auto_source/scraper/wordpress_api/posts_endpoint.rb +1 -3
- data/lib/html2rss/auto_source/scraper/wordpress_api.rb +4 -4
- data/lib/html2rss/auto_source/scraper/xhr_articles.rb +1 -1
- data/lib/html2rss/auto_source/scraper.rb +0 -11
- data/lib/html2rss/auto_source/segmenter/cluster.rb +5 -11
- data/lib/html2rss/auto_source/segmenter/list.rb +25 -57
- data/lib/html2rss/auto_source/segmenter/semantic.rb +6 -13
- data/lib/html2rss/auto_source.rb +1 -1
- data/lib/html2rss/batch.rb +22 -32
- data/lib/html2rss/capture/README.md +1 -1
- data/lib/html2rss/capture.rb +7 -27
- data/lib/html2rss/cli/render.rb +97 -96
- data/lib/html2rss/cli/validate.rb +2 -4
- data/lib/html2rss/cli.rb +33 -27
- data/lib/html2rss/config/request_controls.rb +10 -2
- data/lib/html2rss/config/schema.rb +8 -10
- data/lib/html2rss/config/selectors_validator.rb +1 -0
- data/lib/html2rss/config/validator.rb +3 -3
- data/lib/html2rss/config.rb +1 -1
- data/lib/html2rss/defaults.rb +5 -16
- data/lib/html2rss/doctor/botasaurus.rb +11 -19
- data/lib/html2rss/error.rb +7 -3
- data/lib/html2rss/feed_builder/rss.rb +1 -1
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +3 -3
- data/lib/html2rss/feed_resolution/candidate_generator.rb +3 -4
- data/lib/html2rss/feed_resolution/options.rb +3 -7
- data/lib/html2rss/feed_resolution/policy.rb +29 -33
- data/lib/html2rss/feed_resolution/probe.rb +3 -3
- data/lib/html2rss/feed_resolution/scorer.rb +29 -29
- data/lib/html2rss/feed_resolution.rb +5 -9
- data/lib/html2rss/feed_result.rb +4 -3
- data/lib/html2rss/hash_util.rb +91 -90
- data/lib/html2rss/html/article_extractor/category_extractor.rb +6 -9
- data/lib/html2rss/html/article_extractor/date_extractor.rb +1 -1
- data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +1 -3
- data/lib/html2rss/html/article_extractor/id_generator.rb +1 -1
- data/lib/html2rss/html/probe.rb +51 -51
- data/lib/html2rss/link_destination/path_classifier.rb +11 -33
- data/lib/html2rss/mcp/config_argument.rb +9 -2
- data/lib/html2rss/mcp/contract.rb +7 -5
- data/lib/html2rss/mcp/outcome.rb +4 -5
- data/lib/html2rss/mcp/runtime.rb +17 -24
- data/lib/html2rss/mcp/server/tools.rb +9 -9
- data/lib/html2rss/option.rb +48 -0
- data/lib/html2rss/page_recon/diagnostics.rb +7 -9
- data/lib/html2rss/page_recon.rb +0 -4
- data/lib/html2rss/recon.rb +10 -12
- data/lib/html2rss/request_service/blocked_surface.rb +3 -7
- data/lib/html2rss/request_service/botasaurus_strategy.rb +2 -2
- data/lib/html2rss/request_service/budget.rb +3 -24
- data/lib/html2rss/request_service/compressed_body.rb +36 -48
- data/lib/html2rss/request_service/context.rb +1 -3
- data/lib/html2rss/request_service/local_file_strategy.rb +0 -1
- data/lib/html2rss/request_service/network_guard.rb +3 -2
- data/lib/html2rss/request_service/policy.rb +8 -26
- data/lib/html2rss/request_service/response.rb +3 -6
- data/lib/html2rss/request_session/pager/base.rb +3 -3
- data/lib/html2rss/request_session/pager/custom_selector.rb +4 -4
- data/lib/html2rss/request_session/pager/offset.rb +1 -7
- data/lib/html2rss/request_session/pager/rel_next.rb +1 -1
- data/lib/html2rss/request_session/pager/url_template.rb +1 -7
- data/lib/html2rss/scoring/engine.rb +4 -32
- data/lib/html2rss/scoring/link_resolver.rb +2 -7
- data/lib/html2rss/selectors/extractors/href.rb +2 -4
- data/lib/html2rss/selectors/json_xml.rb +33 -34
- data/lib/html2rss/selectors/post_processors/gsub.rb +2 -3
- data/lib/html2rss/selectors/post_processors/parse_time.rb +4 -5
- data/lib/html2rss/selectors/schema_export.rb +60 -60
- data/lib/html2rss/selectors.rb +14 -18
- data/lib/html2rss/sst/node.rb +9 -3
- data/lib/html2rss/sst/normalizer.rb +25 -24
- data/lib/html2rss/sst/text.rb +53 -57
- data/lib/html2rss/status.rb +23 -30
- data/lib/html2rss/syndication/alternate_links.rb +39 -0
- data/lib/html2rss/syndication/discovery.rb +117 -187
- data/lib/html2rss/syndication/parser.rb +92 -115
- data/lib/html2rss/test/enhance_audit.rb +13 -15
- data/lib/html2rss/test/policy.rb +9 -11
- data/lib/html2rss/test.rb +29 -48
- data/lib/html2rss/url.rb +53 -53
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss.rb +2 -1
- metadata +4 -2
data/lib/html2rss/status.rb
CHANGED
|
@@ -13,6 +13,10 @@ module Html2rss
|
|
|
13
13
|
# Stable telemetry payload for cross-repo consumers (e.g. html2rss-web observability).
|
|
14
14
|
# Tallies and counters are validated and frozen at construction (including Marshal load).
|
|
15
15
|
class Status
|
|
16
|
+
# Keys kept in {#to_h} even when zero or empty.
|
|
17
|
+
ALWAYS_REPORTED = %i[version dedup_dropped].freeze
|
|
18
|
+
private_constant :ALWAYS_REPORTED
|
|
19
|
+
|
|
16
20
|
class << self
|
|
17
21
|
##
|
|
18
22
|
# Builds status from extracted articles and scrape telemetry.
|
|
@@ -71,9 +75,9 @@ module Html2rss
|
|
|
71
75
|
version:, scraper_tallies:, dedup_dropped:, selected_strategy: nil, attempt_count: 0,
|
|
72
76
|
strategy_attempts: [], admission_drops: {}, entry_url: nil, scrape_url: nil, entry_resolution: nil
|
|
73
77
|
)
|
|
74
|
-
dedup =
|
|
75
|
-
attempts =
|
|
76
|
-
|
|
78
|
+
dedup = Option.integer(:dedup_dropped, dedup_dropped, min: 0)
|
|
79
|
+
attempts = Option.integer(:attempt_count, attempt_count, min: 0)
|
|
80
|
+
validate_selected_strategy!(selected_strategy, attempts)
|
|
77
81
|
|
|
78
82
|
super(
|
|
79
83
|
version: version.to_s.dup.freeze,
|
|
@@ -93,20 +97,8 @@ module Html2rss
|
|
|
93
97
|
# Observability hash for web (+scraper_status+). Omits empty/absent optional keys.
|
|
94
98
|
#
|
|
95
99
|
# @return [Hash{Symbol => Object}]
|
|
96
|
-
# rubocop:disable-next Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity -- omit-empty optional keys stay explicit
|
|
97
100
|
def to_h
|
|
98
|
-
{
|
|
99
|
-
version:,
|
|
100
|
-
dedup_dropped:,
|
|
101
|
-
**(scraper_tallies.any? ? { scraper_tallies: } : {}),
|
|
102
|
-
**(selected_strategy.nil? ? {} : { selected_strategy: }),
|
|
103
|
-
**(attempt_count.positive? ? { attempt_count: } : {}),
|
|
104
|
-
**(strategy_attempts.any? ? { strategy_attempts: } : {}),
|
|
105
|
-
**(admission_drops.any? ? { admission_drops: } : {}),
|
|
106
|
-
**(entry_url ? { entry_url: } : {}),
|
|
107
|
-
**(scrape_url ? { scrape_url: } : {}),
|
|
108
|
-
**(entry_resolution ? { entry_resolution: } : {})
|
|
109
|
-
}
|
|
101
|
+
super.reject { |key, value| !ALWAYS_REPORTED.include?(key) && omit?(value) }
|
|
110
102
|
end
|
|
111
103
|
|
|
112
104
|
##
|
|
@@ -124,6 +116,14 @@ module Html2rss
|
|
|
124
116
|
|
|
125
117
|
private
|
|
126
118
|
|
|
119
|
+
def omit?(value)
|
|
120
|
+
case value
|
|
121
|
+
when nil then true
|
|
122
|
+
when Integer then value.zero?
|
|
123
|
+
else value.empty?
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
127
|
def marshal_dump
|
|
128
128
|
[version, scraper_tallies, dedup_dropped, selected_strategy, attempt_count, strategy_attempts,
|
|
129
129
|
admission_drops, entry_url, scrape_url, entry_resolution]
|
|
@@ -133,16 +133,16 @@ module Html2rss
|
|
|
133
133
|
version, scraper_tallies, dedup_dropped, selected_strategy, attempt_count,
|
|
134
134
|
strategy_attempts, admission_drops, entry_url, scrape_url, entry_resolution = payload
|
|
135
135
|
initialize(version:, scraper_tallies:, dedup_dropped:, selected_strategy:, attempt_count:,
|
|
136
|
-
strategy_attempts:, admission_drops
|
|
136
|
+
strategy_attempts:, admission_drops:, entry_url:, scrape_url:,
|
|
137
137
|
entry_resolution:)
|
|
138
138
|
end
|
|
139
139
|
|
|
140
140
|
def freeze_tallies(tallies)
|
|
141
|
-
tallies.to_h
|
|
141
|
+
tallies.to_h { |name, count| [name.to_s, count] }.freeze
|
|
142
142
|
end
|
|
143
143
|
|
|
144
144
|
def freeze_attempts(attempts)
|
|
145
|
-
|
|
145
|
+
attempts.map { |attempt| attempt.dup.freeze }.freeze
|
|
146
146
|
end
|
|
147
147
|
|
|
148
148
|
def freeze_optional_string(value)
|
|
@@ -161,17 +161,10 @@ module Html2rss
|
|
|
161
161
|
end.transform_keys(&:to_sym).freeze
|
|
162
162
|
end
|
|
163
163
|
|
|
164
|
-
def
|
|
165
|
-
|
|
166
|
-
raise ArgumentError, '
|
|
167
|
-
|
|
168
|
-
unless selected_strategy.nil? || selected_strategy.is_a?(Symbol)
|
|
169
|
-
raise ArgumentError, 'selected_strategy must be a Symbol or nil'
|
|
170
|
-
end
|
|
171
|
-
|
|
172
|
-
return unless selected_strategy && attempts < 1
|
|
173
|
-
|
|
174
|
-
raise ArgumentError, 'attempt_count must be >= 1 when selected_strategy is set'
|
|
164
|
+
def validate_selected_strategy!(selected_strategy, attempts)
|
|
165
|
+
return if selected_strategy.nil?
|
|
166
|
+
raise ArgumentError, 'selected_strategy must be a Symbol or nil' unless selected_strategy.is_a?(Symbol)
|
|
167
|
+
raise ArgumentError, 'attempt_count must be >= 1 when selected_strategy is set' if attempts < 1
|
|
175
168
|
end
|
|
176
169
|
end
|
|
177
170
|
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
module Syndication
|
|
5
|
+
##
|
|
6
|
+
# Scans raw HTML for +<link rel="alternate">+ hints that point at RSS/Atom feeds.
|
|
7
|
+
module AlternateLinks
|
|
8
|
+
LINK_TAG_RE = /<link\b[^>]*>/i
|
|
9
|
+
HREF_RE = /\bhref\s*=\s*["']([^"']+)["']/i
|
|
10
|
+
TYPE_RE = /\btype\s*=\s*["']([^"']+)["']/i
|
|
11
|
+
REL_RE = /\brel\s*=\s*["']([^"']+)["']/i
|
|
12
|
+
private_constant :LINK_TAG_RE, :HREF_RE, :TYPE_RE, :REL_RE
|
|
13
|
+
|
|
14
|
+
class << self
|
|
15
|
+
##
|
|
16
|
+
# @param html [String] raw HTML
|
|
17
|
+
# @return [Array<String>] hrefs of alternate links that look like feeds
|
|
18
|
+
def hrefs(html)
|
|
19
|
+
html.scan(LINK_TAG_RE).filter_map { |tag| feed_href(tag) }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def feed_href(tag)
|
|
25
|
+
return unless tag[REL_RE, 1].to_s.downcase.split(/\s+/).include?('alternate')
|
|
26
|
+
|
|
27
|
+
href = tag[HREF_RE, 1]
|
|
28
|
+
return if href.nil? || href.empty?
|
|
29
|
+
|
|
30
|
+
href.strip if syndication_type?(tag[TYPE_RE, 1]) || href.match?(/rss|atom|feed|\.xml/i)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def syndication_type?(type)
|
|
34
|
+
type.to_s.downcase.match?(/rss|atom/)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -6,214 +6,144 @@ module Html2rss
|
|
|
6
6
|
# Finds a same-origin RSS/Atom URL for a page via head alternates and path guesses.
|
|
7
7
|
#
|
|
8
8
|
# Ports configs +probe_rss+ path/alternate logic onto {RequestSession#follow_up}.
|
|
9
|
-
module Discovery
|
|
9
|
+
module Discovery
|
|
10
10
|
# Common feed path suffixes probed after head +rel=alternate+ hints.
|
|
11
11
|
DEFAULT_PATHS = CandidateCatalog::FEED_PATHS
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
# @return [Html2rss::Url, nil]
|
|
31
|
-
# rubocop:disable-next Metrics/ParameterLists -- discovery kwargs stay co-located
|
|
32
|
-
def best_feed_url(page_url:, request_session:, parsed_body: nil, html: nil, extra_paths: [],
|
|
33
|
-
max_probes: nil)
|
|
34
|
-
best_feed_response(
|
|
35
|
-
page_url:, request_session:, parsed_body:, html:, extra_paths:, max_probes:
|
|
36
|
-
)&.url
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
##
|
|
40
|
-
# Like {#best_feed_url} but returns the validated syndication response for parsing.
|
|
41
|
-
#
|
|
42
|
-
# @param page_url [String, Html2rss::Url]
|
|
43
|
-
# @param request_session [Html2rss::RequestSession]
|
|
44
|
-
# @param parsed_body [Nokogiri::HTML::Document, nil]
|
|
45
|
-
# @param html [String, nil]
|
|
46
|
-
# @param extra_paths [Array<String>]
|
|
47
|
-
# @param max_probes [Integer, nil] max candidate GETs (nil = uncapped)
|
|
48
|
-
# @return [Html2rss::RequestService::Response, nil]
|
|
49
|
-
# rubocop:disable-next Metrics/ParameterLists, Metrics/MethodLength -- discovery kwargs stay co-located
|
|
50
|
-
def best_feed_response(page_url:, request_session:, parsed_body: nil, html: nil, extra_paths: [],
|
|
51
|
-
max_probes: nil)
|
|
52
|
-
page = Html2rss::Url.from_absolute(page_url)
|
|
53
|
-
candidates = candidate_urls(page_url: page, parsed_body:, html:, extra_paths:)
|
|
54
|
-
Log.debug(
|
|
55
|
-
"Syndication::Discovery: host=#{page.host} candidate_count=#{candidates.size}"
|
|
56
|
-
)
|
|
57
|
-
|
|
58
|
-
first_feedish_response(
|
|
59
|
-
candidates, request_session:, origin_url: page, max_probes:
|
|
60
|
-
).tap do |response|
|
|
61
|
-
next unless response
|
|
62
|
-
|
|
63
|
-
Log.info({
|
|
64
|
-
event_name: 'syndication.discovery.selected',
|
|
65
|
-
host: page.host,
|
|
66
|
-
message: "Syndication::Discovery: host=#{page.host} selected_feed_url=#{response.url}"
|
|
67
|
-
}.to_json)
|
|
13
|
+
class << self
|
|
14
|
+
##
|
|
15
|
+
# Probes candidates lazily and returns the first feedish URL.
|
|
16
|
+
#
|
|
17
|
+
# @param page_url [String, Html2rss::Url] page or origin URL
|
|
18
|
+
# @param request_session [Html2rss::RequestSession] shared session for follow-ups
|
|
19
|
+
# @param parsed_body [Nokogiri::HTML::Document, nil] parsed HTML when available
|
|
20
|
+
# @param html [String, nil] raw HTML when a document is unavailable
|
|
21
|
+
# @param extra_paths [Array<String>] additional path guesses
|
|
22
|
+
# @param max_probes [Integer, nil] max candidate GETs (nil = uncapped)
|
|
23
|
+
# @return [Html2rss::Url, nil]
|
|
24
|
+
# rubocop:disable-next Metrics/ParameterLists -- discovery kwargs stay co-located
|
|
25
|
+
def best_feed_url(page_url:, request_session:, parsed_body: nil, html: nil, extra_paths: [],
|
|
26
|
+
max_probes: nil)
|
|
27
|
+
best_feed_response(
|
|
28
|
+
page_url:, request_session:, parsed_body:, html:, extra_paths:, max_probes:
|
|
29
|
+
)&.url
|
|
68
30
|
end
|
|
69
|
-
end
|
|
70
|
-
|
|
71
|
-
##
|
|
72
|
-
# Ordered candidate feed URLs (head alternates first, then path guesses).
|
|
73
|
-
#
|
|
74
|
-
# @param page_url [String, Html2rss::Url]
|
|
75
|
-
# @param parsed_body [Nokogiri::HTML::Document, nil]
|
|
76
|
-
# @param html [String, nil]
|
|
77
|
-
# @param extra_paths [Array<String>]
|
|
78
|
-
# @return [Array<Html2rss::Url>]
|
|
79
|
-
def candidate_urls(page_url:, parsed_body: nil, html: nil, extra_paths: [])
|
|
80
|
-
page = Html2rss::Url.from_absolute(page_url)
|
|
81
|
-
(alternate_feed_urls(page, parsed_body:, html:) +
|
|
82
|
-
path_guess_urls(page, extra_paths:)).uniq
|
|
83
|
-
end
|
|
84
31
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
scoped = max_probes.nil? ? urls.lazy : urls.lazy.take(max_probes)
|
|
117
|
-
scoped.filter_map do |url|
|
|
118
|
-
response = probe(url, request_session:, origin_url:)
|
|
119
|
-
response if feedish?(response)
|
|
120
|
-
end.first
|
|
121
|
-
end
|
|
122
|
-
module_function :first_feedish_response
|
|
123
|
-
private_class_method :first_feedish_response
|
|
124
|
-
|
|
125
|
-
def first_feedish_url(urls, request_session:, origin_url:, max_probes: nil)
|
|
126
|
-
first_feedish_response(urls, request_session:, origin_url:, max_probes:)&.url
|
|
127
|
-
end
|
|
128
|
-
module_function :first_feedish_url
|
|
129
|
-
private_class_method :first_feedish_url
|
|
130
|
-
|
|
131
|
-
def probe(url, request_session:, origin_url:)
|
|
132
|
-
request_session.follow_up(url:, relation: :auto_source, origin_url:)
|
|
133
|
-
rescue Html2rss::Error => error
|
|
134
|
-
Log.debug(
|
|
135
|
-
"Syndication::Discovery: probe skipped host=#{origin_url.host} " \
|
|
136
|
-
"(#{error.class})"
|
|
137
|
-
)
|
|
138
|
-
nil
|
|
139
|
-
end
|
|
140
|
-
module_function :probe
|
|
141
|
-
private_class_method :probe
|
|
142
|
-
|
|
143
|
-
def alternate_feed_urls(page, parsed_body:, html:)
|
|
144
|
-
from_document = feed_links_from_document(parsed_body, page)
|
|
145
|
-
return from_document if from_document.any?
|
|
32
|
+
##
|
|
33
|
+
# Like {#best_feed_url} but returns the validated syndication response for parsing.
|
|
34
|
+
#
|
|
35
|
+
# @param page_url [String, Html2rss::Url]
|
|
36
|
+
# @param request_session [Html2rss::RequestSession]
|
|
37
|
+
# @param parsed_body [Nokogiri::HTML::Document, nil]
|
|
38
|
+
# @param html [String, nil]
|
|
39
|
+
# @param extra_paths [Array<String>]
|
|
40
|
+
# @param max_probes [Integer, nil] max candidate GETs (nil = uncapped)
|
|
41
|
+
# @return [Html2rss::RequestService::Response, nil]
|
|
42
|
+
# rubocop:disable-next Metrics/ParameterLists, Metrics/MethodLength -- discovery kwargs stay co-located
|
|
43
|
+
def best_feed_response(page_url:, request_session:, parsed_body: nil, html: nil, extra_paths: [],
|
|
44
|
+
max_probes: nil)
|
|
45
|
+
page = Html2rss::Url.from_absolute(page_url)
|
|
46
|
+
candidates = candidate_urls(page_url: page, parsed_body:, html:, extra_paths:)
|
|
47
|
+
Log.debug(
|
|
48
|
+
"Syndication::Discovery: host=#{page.host} candidate_count=#{candidates.size}"
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
first_feedish_response(
|
|
52
|
+
candidates, request_session:, origin_url: page, max_probes:
|
|
53
|
+
).tap do |response|
|
|
54
|
+
next unless response
|
|
55
|
+
|
|
56
|
+
Log.info({
|
|
57
|
+
event_name: 'syndication.discovery.selected',
|
|
58
|
+
host: page.host,
|
|
59
|
+
message: "Syndication::Discovery: host=#{page.host} selected_feed_url=#{response.url}"
|
|
60
|
+
}.to_json)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
146
63
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
64
|
+
##
|
|
65
|
+
# Ordered candidate feed URLs (head alternates first, then path guesses).
|
|
66
|
+
#
|
|
67
|
+
# @param page_url [String, Html2rss::Url]
|
|
68
|
+
# @param parsed_body [Nokogiri::HTML::Document, nil]
|
|
69
|
+
# @param html [String, nil]
|
|
70
|
+
# @param extra_paths [Array<String>]
|
|
71
|
+
# @return [Array<Html2rss::Url>]
|
|
72
|
+
def candidate_urls(page_url:, parsed_body: nil, html: nil, extra_paths: [])
|
|
73
|
+
page = Html2rss::Url.from_absolute(page_url)
|
|
74
|
+
(alternate_feed_urls(page, parsed_body:, html:) +
|
|
75
|
+
path_guess_urls(page, extra_paths:)).uniq
|
|
76
|
+
end
|
|
151
77
|
|
|
152
|
-
|
|
153
|
-
|
|
78
|
+
##
|
|
79
|
+
# @param response [Html2rss::RequestService::Response, nil]
|
|
80
|
+
# @return [Boolean]
|
|
81
|
+
def feedish?(response)
|
|
82
|
+
return false unless response
|
|
83
|
+
return false unless successful_status?(response)
|
|
154
84
|
|
|
155
|
-
|
|
156
|
-
absolute_url(page, link.href)
|
|
85
|
+
response.feed_response?
|
|
157
86
|
end
|
|
158
|
-
end
|
|
159
|
-
module_function :feed_links_from_document
|
|
160
|
-
private_class_method :feed_links_from_document
|
|
161
87
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
88
|
+
##
|
|
89
|
+
# Directory-relative feed path guesses for a page URL.
|
|
90
|
+
#
|
|
91
|
+
# @param page_url [String, Html2rss::Url]
|
|
92
|
+
# @return [Array<String>] relative paths
|
|
93
|
+
def page_dir_paths(page_url)
|
|
94
|
+
path = Html2rss::Url.from_absolute(page_url).path
|
|
95
|
+
return [] if path == '/'
|
|
96
|
+
|
|
97
|
+
dir = path.sub(%r{/[^/]*$}, '/')
|
|
98
|
+
["#{dir}feed", "#{dir}rss.xml", "#{dir}atom.xml"]
|
|
99
|
+
end
|
|
168
100
|
|
|
169
|
-
|
|
170
|
-
return [] if html.nil? || html.empty?
|
|
101
|
+
private
|
|
171
102
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
103
|
+
def successful_status?(response)
|
|
104
|
+
status = response.status
|
|
105
|
+
status.nil? || status.between?(200, 299)
|
|
106
|
+
end
|
|
176
107
|
|
|
177
|
-
|
|
178
|
-
|
|
108
|
+
def first_feedish_response(urls, request_session:, origin_url:, max_probes: nil)
|
|
109
|
+
scoped = max_probes.nil? ? urls.lazy : urls.lazy.take(max_probes)
|
|
110
|
+
scoped.filter_map do |url|
|
|
111
|
+
response = probe(url, request_session:, origin_url:)
|
|
112
|
+
response if feedish?(response)
|
|
113
|
+
end.first
|
|
114
|
+
end
|
|
179
115
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
116
|
+
def probe(url, request_session:, origin_url:)
|
|
117
|
+
request_session.follow_up(url:, relation: :auto_source, origin_url:)
|
|
118
|
+
rescue Html2rss::Error => error
|
|
119
|
+
Log.debug("Syndication::Discovery: probe skipped host=#{origin_url.host} (#{error.class})")
|
|
120
|
+
nil
|
|
121
|
+
end
|
|
183
122
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
private_class_method :href_from_alternate_link
|
|
123
|
+
def alternate_feed_urls(page, parsed_body:, html:)
|
|
124
|
+
from_document = feed_links_from_document(parsed_body, page)
|
|
125
|
+
return from_document if from_document.any?
|
|
188
126
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
end
|
|
192
|
-
module_function :alternate_rel?
|
|
193
|
-
private_class_method :alternate_rel?
|
|
127
|
+
AlternateLinks.hrefs(html.to_s).filter_map { |href| absolute_url(page, href) }.uniq
|
|
128
|
+
end
|
|
194
129
|
|
|
195
|
-
|
|
196
|
-
|
|
130
|
+
def feed_links_from_document(parsed_body, page)
|
|
131
|
+
return [] unless parsed_body.is_a?(Nokogiri::HTML::Document)
|
|
197
132
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
end
|
|
201
|
-
module_function :syndication_type?
|
|
202
|
-
private_class_method :syndication_type?
|
|
133
|
+
Html::FeedLink.from_document(parsed_body).filter_map { |link| absolute_url(page, link.href) }
|
|
134
|
+
end
|
|
203
135
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
private_class_method :feed_like_href?
|
|
136
|
+
def path_guess_urls(page, extra_paths:)
|
|
137
|
+
paths = (page_dir_paths(page) + DEFAULT_PATHS + Array(extra_paths)).uniq
|
|
138
|
+
paths.filter_map { |path| absolute_url(page, path) }
|
|
139
|
+
end
|
|
209
140
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
141
|
+
def absolute_url(base, path)
|
|
142
|
+
Html2rss::Url.from_relative(path, base)
|
|
143
|
+
rescue Html2rss::Url::InvalidUrlError
|
|
144
|
+
nil
|
|
145
|
+
end
|
|
214
146
|
end
|
|
215
|
-
module_function :absolute_url
|
|
216
|
-
private_class_method :absolute_url
|
|
217
147
|
end
|
|
218
148
|
end
|
|
219
149
|
end
|