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.
Files changed (108) hide show
  1. checksums.yaml +4 -4
  2. data/lib/html2rss/article/enclosure.rb +2 -6
  3. data/lib/html2rss/auto_source/cleanup.rb +1 -3
  4. data/lib/html2rss/auto_source/scraper/html.rb +2 -8
  5. data/lib/html2rss/auto_source/scraper/json_document.rb +2 -3
  6. data/lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb +128 -128
  7. data/lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb +37 -37
  8. data/lib/html2rss/auto_source/scraper/json_state/document_scanner.rb +123 -123
  9. data/lib/html2rss/auto_source/scraper/json_state/value_finder.rb +34 -34
  10. data/lib/html2rss/auto_source/scraper/json_state.rb +2 -6
  11. data/lib/html2rss/auto_source/scraper/meta_oembed.rb +32 -2
  12. data/lib/html2rss/auto_source/scraper/microdata.rb +228 -245
  13. data/lib/html2rss/auto_source/scraper/microformats2.rb +9 -1
  14. data/lib/html2rss/auto_source/scraper/native_feed.rb +1 -1
  15. data/lib/html2rss/auto_source/scraper/schema/category_extractor.rb +7 -2
  16. data/lib/html2rss/auto_source/scraper/schema/item_list.rb +2 -2
  17. data/lib/html2rss/auto_source/scraper/schema/thing.rb +98 -21
  18. data/lib/html2rss/auto_source/scraper/schema.rb +49 -2
  19. data/lib/html2rss/auto_source/scraper/semantic_html.rb +12 -16
  20. data/lib/html2rss/auto_source/scraper/sitemap/parser.rb +1 -4
  21. data/lib/html2rss/auto_source/scraper/sitemap.rb +3 -11
  22. data/lib/html2rss/auto_source/scraper/wordpress_api/page_scope.rb +2 -2
  23. data/lib/html2rss/auto_source/scraper/wordpress_api/posts_endpoint.rb +1 -3
  24. data/lib/html2rss/auto_source/scraper/wordpress_api.rb +4 -4
  25. data/lib/html2rss/auto_source/scraper/xhr_articles.rb +1 -1
  26. data/lib/html2rss/auto_source/scraper.rb +0 -11
  27. data/lib/html2rss/auto_source/segmenter/cluster.rb +5 -11
  28. data/lib/html2rss/auto_source/segmenter/list.rb +25 -57
  29. data/lib/html2rss/auto_source/segmenter/semantic.rb +6 -13
  30. data/lib/html2rss/auto_source.rb +1 -1
  31. data/lib/html2rss/batch.rb +22 -32
  32. data/lib/html2rss/capture/README.md +1 -1
  33. data/lib/html2rss/capture.rb +7 -27
  34. data/lib/html2rss/cli/render.rb +97 -96
  35. data/lib/html2rss/cli/validate.rb +2 -4
  36. data/lib/html2rss/cli.rb +33 -27
  37. data/lib/html2rss/config/request_controls.rb +10 -2
  38. data/lib/html2rss/config/schema.rb +8 -10
  39. data/lib/html2rss/config/selectors_validator.rb +1 -0
  40. data/lib/html2rss/config/validator.rb +3 -3
  41. data/lib/html2rss/config.rb +1 -1
  42. data/lib/html2rss/defaults.rb +5 -16
  43. data/lib/html2rss/doctor/botasaurus.rb +11 -19
  44. data/lib/html2rss/error.rb +54 -1
  45. data/lib/html2rss/feed_builder/rss.rb +1 -1
  46. data/lib/html2rss/feed_pipeline/README.md +1 -1
  47. data/lib/html2rss/feed_pipeline/auto_fallback.rb +58 -55
  48. data/lib/html2rss/feed_resolution/candidate_generator.rb +3 -4
  49. data/lib/html2rss/feed_resolution/options.rb +3 -7
  50. data/lib/html2rss/feed_resolution/policy.rb +29 -33
  51. data/lib/html2rss/feed_resolution/probe.rb +3 -3
  52. data/lib/html2rss/feed_resolution/scorer.rb +29 -29
  53. data/lib/html2rss/feed_resolution.rb +5 -9
  54. data/lib/html2rss/feed_result.rb +4 -3
  55. data/lib/html2rss/hash_util.rb +91 -90
  56. data/lib/html2rss/html/article_extractor/category_extractor.rb +6 -9
  57. data/lib/html2rss/html/article_extractor/date_extractor.rb +1 -1
  58. data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +1 -3
  59. data/lib/html2rss/html/article_extractor/id_generator.rb +1 -1
  60. data/lib/html2rss/html/probe.rb +51 -51
  61. data/lib/html2rss/link_destination/path_classifier.rb +11 -33
  62. data/lib/html2rss/mcp/config_argument.rb +9 -2
  63. data/lib/html2rss/mcp/contract.rb +7 -5
  64. data/lib/html2rss/mcp/outcome.rb +4 -5
  65. data/lib/html2rss/mcp/runtime.rb +17 -24
  66. data/lib/html2rss/mcp/server/tools.rb +9 -9
  67. data/lib/html2rss/option.rb +48 -0
  68. data/lib/html2rss/page_recon/diagnostics.rb +7 -9
  69. data/lib/html2rss/page_recon.rb +0 -4
  70. data/lib/html2rss/recon.rb +10 -12
  71. data/lib/html2rss/request_service/blocked_surface.rb +3 -7
  72. data/lib/html2rss/request_service/botasaurus_strategy.rb +2 -2
  73. data/lib/html2rss/request_service/budget.rb +3 -24
  74. data/lib/html2rss/request_service/compressed_body.rb +36 -48
  75. data/lib/html2rss/request_service/context.rb +1 -3
  76. data/lib/html2rss/request_service/local_file_strategy.rb +3 -1
  77. data/lib/html2rss/request_service/network_guard.rb +3 -2
  78. data/lib/html2rss/request_service/policy.rb +8 -26
  79. data/lib/html2rss/request_service/response.rb +3 -6
  80. data/lib/html2rss/request_service/strategy.rb +2 -2
  81. data/lib/html2rss/request_service.rb +26 -2
  82. data/lib/html2rss/request_session/pager/base.rb +3 -3
  83. data/lib/html2rss/request_session/pager/custom_selector.rb +4 -4
  84. data/lib/html2rss/request_session/pager/offset.rb +1 -7
  85. data/lib/html2rss/request_session/pager/rel_next.rb +1 -1
  86. data/lib/html2rss/request_session/pager/url_template.rb +1 -7
  87. data/lib/html2rss/scoring/engine.rb +4 -32
  88. data/lib/html2rss/scoring/link_resolver.rb +2 -7
  89. data/lib/html2rss/selectors/extractors/href.rb +2 -4
  90. data/lib/html2rss/selectors/json_xml.rb +33 -34
  91. data/lib/html2rss/selectors/post_processors/gsub.rb +2 -3
  92. data/lib/html2rss/selectors/post_processors/parse_time.rb +4 -5
  93. data/lib/html2rss/selectors/schema_export.rb +60 -60
  94. data/lib/html2rss/selectors.rb +14 -18
  95. data/lib/html2rss/sst/node.rb +9 -3
  96. data/lib/html2rss/sst/normalizer.rb +25 -24
  97. data/lib/html2rss/sst/text.rb +53 -57
  98. data/lib/html2rss/status.rb +23 -30
  99. data/lib/html2rss/syndication/alternate_links.rb +39 -0
  100. data/lib/html2rss/syndication/discovery.rb +117 -187
  101. data/lib/html2rss/syndication/parser.rb +92 -115
  102. data/lib/html2rss/test/enhance_audit.rb +13 -15
  103. data/lib/html2rss/test/policy.rb +9 -11
  104. data/lib/html2rss/test.rb +29 -48
  105. data/lib/html2rss/url.rb +53 -53
  106. data/lib/html2rss/version.rb +1 -1
  107. data/lib/html2rss.rb +2 -1
  108. 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 # rubocop:disable Metrics/ModuleLength -- discovery + path probes stay co-located
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
- LINK_TAG_RE = /<link\b[^>]*>/i
14
- HREF_RE = /\bhref\s*=\s*["']([^"']+)["']/i
15
- TYPE_RE = /\btype\s*=\s*["']([^"']+)["']/i
16
- REL_RE = /\brel\s*=\s*["']([^"']+)["']/i
17
- private_constant :LINK_TAG_RE, :HREF_RE, :TYPE_RE, :REL_RE
18
-
19
- module_function
20
-
21
- ##
22
- # Probes candidates lazily and returns the first feedish URL.
23
- #
24
- # @param page_url [String, Html2rss::Url] page or origin URL
25
- # @param request_session [Html2rss::RequestSession] shared session for follow-ups
26
- # @param parsed_body [Nokogiri::HTML::Document, nil] parsed HTML when available
27
- # @param html [String, nil] raw HTML when a document is unavailable
28
- # @param extra_paths [Array<String>] additional path guesses
29
- # @param max_probes [Integer, nil] max candidate GETs (nil = uncapped)
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
- # @param response [Html2rss::RequestService::Response, nil]
87
- # @return [Boolean]
88
- def feedish?(response)
89
- return false unless response
90
- return false unless successful_status?(response)
91
-
92
- response.feed_response?
93
- end
94
-
95
- def successful_status?(response)
96
- status = response.status
97
- status.nil? || status.between?(200, 299)
98
- end
99
- module_function :successful_status?
100
- private_class_method :successful_status?
101
-
102
- ##
103
- # Directory-relative feed path guesses for a page URL.
104
- #
105
- # @param page_url [String, Html2rss::Url]
106
- # @return [Array<String>] relative paths
107
- def page_dir_paths(page_url)
108
- path = Html2rss::Url.from_absolute(page_url).path
109
- return [] if path.nil? || path.empty? || path == '/'
110
-
111
- dir = path.sub(%r{/[^/]*$}, '/')
112
- ["#{dir}feed", "#{dir}rss.xml", "#{dir}atom.xml"]
113
- end
114
-
115
- def first_feedish_response(urls, request_session:, origin_url:, max_probes: nil)
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
- alternate_feed_hrefs(html.to_s, page)
148
- end
149
- module_function :alternate_feed_urls
150
- private_class_method :alternate_feed_urls
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
- def feed_links_from_document(parsed_body, page)
153
- return [] unless parsed_body.is_a?(Nokogiri::HTML::Document)
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
- Html::FeedLink.from_document(parsed_body).filter_map do |link|
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
- def path_guess_urls(page, extra_paths:)
163
- paths = (page_dir_paths(page) + DEFAULT_PATHS + Array(extra_paths)).uniq
164
- paths.filter_map { |path| absolute_url(page, path) }
165
- end
166
- module_function :path_guess_urls
167
- private_class_method :path_guess_urls
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
- def alternate_feed_hrefs(html, page)
170
- return [] if html.nil? || html.empty?
101
+ private
171
102
 
172
- html.scan(LINK_TAG_RE).filter_map { |tag| href_from_alternate_link(tag, page) }.uniq
173
- end
174
- module_function :alternate_feed_hrefs
175
- private_class_method :alternate_feed_hrefs
103
+ def successful_status?(response)
104
+ status = response.status
105
+ status.nil? || status.between?(200, 299)
106
+ end
176
107
 
177
- def href_from_alternate_link(tag, page)
178
- return unless alternate_rel?(tag)
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
- href = tag[HREF_RE, 1]
181
- return if href.nil? || href.empty?
182
- return unless syndication_type?(tag[TYPE_RE, 1]) || feed_like_href?(href)
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
- absolute_url(page, href.strip)
185
- end
186
- module_function :href_from_alternate_link
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
- def alternate_rel?(tag)
190
- tag[REL_RE, 1].to_s.downcase.split(/\s+/).include?('alternate')
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
- def syndication_type?(type)
196
- return false if type.nil? || type.empty?
130
+ def feed_links_from_document(parsed_body, page)
131
+ return [] unless parsed_body.is_a?(Nokogiri::HTML::Document)
197
132
 
198
- t = type.downcase
199
- t.include?('rss+xml') || t.include?('atom+xml') || t.include?('rss') || t.include?('atom')
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
- def feed_like_href?(href)
205
- href.match?(/rss|atom|feed|\.xml/i)
206
- end
207
- module_function :feed_like_href?
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
- def absolute_url(base, path)
211
- Html2rss::Url.from_relative(path, base)
212
- rescue ArgumentError
213
- nil
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 # rubocop:disable Metrics/ModuleLength -- feed formats + structured parse warn
10
- module_function
11
-
12
- ##
13
- # @param response [Html2rss::RequestService::Response]
14
- # @return [Array<Hash{Symbol => Object}>] article hashes (may be empty)
15
- # @raise [ArgumentError] when the response is not a syndication feed
16
- def parse_response(response)
17
- raise ArgumentError, 'response is not a syndication feed' unless response.feed_response?
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
- parse(response.body, base_url: response.url)
20
- end
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
- def items_from(feed, base_url:) # rubocop:disable Lint/UnusedMethodArgument -- base reserved for callers
41
- case feed
42
- when RSS::Rss then Array(feed.items)
43
- when RSS::Atom::Feed then Array(feed.entries)
44
- else []
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
- def article_hash(item, base_url:)
51
- case item
52
- when RSS::Rss::Channel::Item then rss_item_hash(item, base_url:)
53
- when RSS::Atom::Entry, RSS::Atom::Feed::Entry then atom_entry_hash(item, base_url:)
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
- def atom_link(entry, base_url:)
94
- links = Array(entry.links)
95
- link = links.find { |node| node.rel.nil? || node.rel == 'alternate' } || links.first
96
- absolute_url(link&.href, base_url:)
97
- end
98
- module_function :atom_link
99
- private_class_method :atom_link
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
- def atom_text(node)
102
- return unless node.is_a?(RSS::Atom::TextConstruct) || node.is_a?(RSS::Atom::Feed::Entry::Content)
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
- node.content
105
- end
106
- module_function :atom_text
107
- private_class_method :atom_text
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
- def atom_author(entry)
110
- author = Array(entry.author).first
111
- return unless author
91
+ def atom_author(entry)
92
+ author = entry.author
93
+ return unless author
112
94
 
113
- present(author.name&.content) || present(author.email&.content)
114
- end
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
- if base_url
123
- Html2rss::Url.from_relative(value, base_url)
124
- else
125
- Html2rss::Url.from_absolute(value)
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
- def present(value)
134
- text = value.to_s.strip
135
- text unless text.empty?
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