html2rss 0.32.3 → 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 +54 -1
- data/lib/html2rss/feed_builder/rss.rb +1 -1
- data/lib/html2rss/feed_pipeline/README.md +1 -1
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +58 -55
- 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 +3 -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_service/strategy.rb +2 -2
- data/lib/html2rss/request_service.rb +26 -2
- 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
|
@@ -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
|
|
@@ -6,136 +6,113 @@ module Html2rss
|
|
|
6
6
|
module Syndication
|
|
7
7
|
##
|
|
8
8
|
# Parses RSS 2.0 and Atom documents into AutoSource article hashes.
|
|
9
|
-
module Parser
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
9
|
+
module Parser
|
|
10
|
+
class << self
|
|
11
|
+
##
|
|
12
|
+
# @param response [Html2rss::RequestService::Response]
|
|
13
|
+
# @return [Array<Hash{Symbol => Object}>] article hashes (may be empty)
|
|
14
|
+
# @raise [ArgumentError] when the response is not a syndication feed
|
|
15
|
+
def parse_response(response)
|
|
16
|
+
raise ArgumentError, 'response is not a syndication feed' unless response.feed_response?
|
|
17
|
+
|
|
18
|
+
parse(response.body, base_url: response.url)
|
|
19
|
+
end
|
|
18
20
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
+
##
|
|
22
|
+
# @param body [String] raw RSS/Atom document
|
|
23
|
+
# @param base_url [String, Html2rss::Url, nil] fallback base for relative item links
|
|
24
|
+
# @return [Array<Hash{Symbol => Object}>]
|
|
25
|
+
def parse(body, base_url: nil)
|
|
26
|
+
feed = RSS::Parser.parse(body.to_s, false)
|
|
27
|
+
|
|
28
|
+
items_from(feed).filter_map { |item| article_hash(item, base_url:) }
|
|
29
|
+
rescue RSS::Error => error
|
|
30
|
+
Log.warn({
|
|
31
|
+
event_name: 'syndication.parse_failed',
|
|
32
|
+
error_class: error.class.name,
|
|
33
|
+
message: "Syndication::Parser: failed to parse feed (#{error.class}: #{error.message})"
|
|
34
|
+
}.to_json)
|
|
35
|
+
[]
|
|
36
|
+
end
|
|
21
37
|
|
|
22
|
-
|
|
23
|
-
# @param body [String] raw RSS/Atom document
|
|
24
|
-
# @param base_url [String, Html2rss::Url, nil] fallback base for relative item links
|
|
25
|
-
# @return [Array<Hash{Symbol => Object}>]
|
|
26
|
-
def parse(body, base_url: nil)
|
|
27
|
-
feed = RSS::Parser.parse(body.to_s, false)
|
|
28
|
-
return [] unless feed
|
|
29
|
-
|
|
30
|
-
items_from(feed, base_url:).filter_map { |item| article_hash(item, base_url:) }
|
|
31
|
-
rescue RSS::Error => error
|
|
32
|
-
Log.warn({
|
|
33
|
-
event_name: 'syndication.parse_failed',
|
|
34
|
-
error_class: error.class.name,
|
|
35
|
-
message: "Syndication::Parser: failed to parse feed (#{error.class}: #{error.message})"
|
|
36
|
-
}.to_json)
|
|
37
|
-
[]
|
|
38
|
-
end
|
|
38
|
+
private
|
|
39
39
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
40
|
+
def items_from(feed)
|
|
41
|
+
case feed
|
|
42
|
+
when RSS::Rss then feed.items
|
|
43
|
+
when RSS::Atom::Feed then feed.entries
|
|
44
|
+
else []
|
|
45
|
+
end
|
|
45
46
|
end
|
|
46
|
-
end
|
|
47
|
-
module_function :items_from
|
|
48
|
-
private_class_method :items_from
|
|
49
47
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
48
|
+
def article_hash(item, base_url:)
|
|
49
|
+
case item
|
|
50
|
+
when RSS::Rss::Channel::Item then rss_item_hash(item, base_url:)
|
|
51
|
+
when RSS::Atom::Feed::Entry then atom_entry_hash(item, base_url:)
|
|
52
|
+
end
|
|
54
53
|
end
|
|
55
|
-
end
|
|
56
|
-
module_function :article_hash
|
|
57
|
-
private_class_method :article_hash
|
|
58
|
-
|
|
59
|
-
def rss_item_hash(item, base_url:) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- RSS item mapping
|
|
60
|
-
url = absolute_url(item.link, base_url:)
|
|
61
|
-
return unless url
|
|
62
|
-
|
|
63
|
-
{
|
|
64
|
-
id: present(item.guid&.content) || url.to_s,
|
|
65
|
-
title: present(item.title),
|
|
66
|
-
description: present(item.description),
|
|
67
|
-
url:,
|
|
68
|
-
published_at: item.date || item.pubDate,
|
|
69
|
-
author: present(item.author),
|
|
70
|
-
categories: Array(item.categories).filter_map { |cat| present(cat.content) }
|
|
71
|
-
}.compact
|
|
72
|
-
end
|
|
73
|
-
module_function :rss_item_hash
|
|
74
|
-
private_class_method :rss_item_hash
|
|
75
|
-
|
|
76
|
-
def atom_entry_hash(entry, base_url:) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity -- Atom entry mapping
|
|
77
|
-
url = atom_link(entry, base_url:)
|
|
78
|
-
return unless url
|
|
79
|
-
|
|
80
|
-
{
|
|
81
|
-
id: present(entry.id&.content) || url.to_s,
|
|
82
|
-
title: present(atom_text(entry.title)),
|
|
83
|
-
description: present(atom_text(entry.content) || atom_text(entry.summary)),
|
|
84
|
-
url:,
|
|
85
|
-
published_at: entry.published&.content || entry.updated&.content,
|
|
86
|
-
author: present(atom_author(entry)),
|
|
87
|
-
categories: Array(entry.categories).filter_map { |cat| present(cat.term) }
|
|
88
|
-
}.compact
|
|
89
|
-
end
|
|
90
|
-
module_function :atom_entry_hash
|
|
91
|
-
private_class_method :atom_entry_hash
|
|
92
54
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
55
|
+
def rss_item_hash(item, base_url:) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- RSS item mapping
|
|
56
|
+
url = absolute_url(item.link, base_url:)
|
|
57
|
+
return unless url
|
|
58
|
+
|
|
59
|
+
{
|
|
60
|
+
id: present(item.guid&.content) || url.to_s,
|
|
61
|
+
title: present(item.title),
|
|
62
|
+
description: present(item.description),
|
|
63
|
+
url:,
|
|
64
|
+
published_at: item.pubDate,
|
|
65
|
+
author: present(item.author),
|
|
66
|
+
categories: item.categories.filter_map { |cat| present(cat.content) }
|
|
67
|
+
}.compact
|
|
68
|
+
end
|
|
100
69
|
|
|
101
|
-
|
|
102
|
-
|
|
70
|
+
def atom_entry_hash(entry, base_url:) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity -- Atom entry mapping
|
|
71
|
+
url = atom_link(entry, base_url:)
|
|
72
|
+
return unless url
|
|
73
|
+
|
|
74
|
+
{
|
|
75
|
+
id: present(entry.id&.content) || url.to_s,
|
|
76
|
+
title: present(entry.title&.content),
|
|
77
|
+
description: present(entry.content&.content || entry.summary&.content),
|
|
78
|
+
url:,
|
|
79
|
+
published_at: entry.published&.content || entry.updated&.content,
|
|
80
|
+
author: atom_author(entry),
|
|
81
|
+
categories: entry.categories.filter_map { |cat| present(cat.term) }
|
|
82
|
+
}.compact
|
|
83
|
+
end
|
|
103
84
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
85
|
+
def atom_link(entry, base_url:)
|
|
86
|
+
links = entry.links
|
|
87
|
+
link = links.find { |node| node.rel.nil? || node.rel == 'alternate' } || links.first
|
|
88
|
+
absolute_url(link&.href, base_url:)
|
|
89
|
+
end
|
|
108
90
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
91
|
+
def atom_author(entry)
|
|
92
|
+
author = entry.author
|
|
93
|
+
return unless author
|
|
112
94
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
module_function :atom_author
|
|
116
|
-
private_class_method :atom_author
|
|
117
|
-
|
|
118
|
-
def absolute_url(raw, base_url:)
|
|
119
|
-
value = present(raw)
|
|
120
|
-
return unless value
|
|
95
|
+
present(author.name&.content) || present(author.email&.content)
|
|
96
|
+
end
|
|
121
97
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
98
|
+
def absolute_url(raw, base_url:)
|
|
99
|
+
value = present(raw)
|
|
100
|
+
return unless value
|
|
101
|
+
|
|
102
|
+
if base_url
|
|
103
|
+
Html2rss::Url.from_relative(value, base_url)
|
|
104
|
+
else
|
|
105
|
+
Html2rss::Url.from_absolute(value)
|
|
106
|
+
end
|
|
107
|
+
rescue Html2rss::Url::InvalidUrlError
|
|
108
|
+
nil
|
|
126
109
|
end
|
|
127
|
-
rescue ArgumentError
|
|
128
|
-
nil
|
|
129
|
-
end
|
|
130
|
-
module_function :absolute_url
|
|
131
|
-
private_class_method :absolute_url
|
|
132
110
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
111
|
+
def present(value)
|
|
112
|
+
text = value.to_s.strip
|
|
113
|
+
text unless text.empty?
|
|
114
|
+
end
|
|
136
115
|
end
|
|
137
|
-
module_function :present
|
|
138
|
-
private_class_method :present
|
|
139
116
|
end
|
|
140
117
|
end
|
|
141
118
|
end
|