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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/lib/html2rss/auto_source/README.md +4 -1
  3. data/lib/html2rss/auto_source/scraper/native_feed.rb +90 -0
  4. data/lib/html2rss/auto_source/scraper.rb +4 -1
  5. data/lib/html2rss/auto_source.rb +7 -0
  6. data/lib/html2rss/config/auto_source_contract.rb +7 -0
  7. data/lib/html2rss/config.rb +10 -1
  8. data/lib/html2rss/feed_pipeline/README.md +2 -0
  9. data/lib/html2rss/feed_pipeline/auto_fallback.rb +53 -13
  10. data/lib/html2rss/feed_pipeline/runtime_policy.rb +1 -0
  11. data/lib/html2rss/feed_pipeline.rb +24 -8
  12. data/lib/html2rss/feed_resolution/README.md +52 -0
  13. data/lib/html2rss/feed_resolution/candidate_generator.rb +139 -0
  14. data/lib/html2rss/feed_resolution/diag.rb +32 -0
  15. data/lib/html2rss/feed_resolution/options.rb +31 -0
  16. data/lib/html2rss/feed_resolution/policy.rb +47 -0
  17. data/lib/html2rss/feed_resolution/probe.rb +59 -0
  18. data/lib/html2rss/feed_resolution/scorer.rb +49 -0
  19. data/lib/html2rss/feed_resolution.rb +274 -0
  20. data/lib/html2rss/mcp/inspect.rb +18 -121
  21. data/lib/html2rss/mcp/outcome.rb +4 -2
  22. data/lib/html2rss/page_recon.rb +243 -0
  23. data/lib/html2rss/request_service/botasaurus_contract.rb +24 -47
  24. data/lib/html2rss/request_service/botasaurus_strategy.rb +34 -12
  25. data/lib/html2rss/request_service/response.rb +26 -2
  26. data/lib/html2rss/request_service.rb +13 -1
  27. data/lib/html2rss/request_session.rb +5 -2
  28. data/lib/html2rss/scrape_target.rb +24 -0
  29. data/lib/html2rss/status.rb +49 -22
  30. data/lib/html2rss/surface_category.rb +63 -0
  31. data/lib/html2rss/syndication/README.md +21 -0
  32. data/lib/html2rss/syndication/candidate_catalog.rb +26 -0
  33. data/lib/html2rss/syndication/discovery.rb +217 -0
  34. data/lib/html2rss/syndication/parser.rb +137 -0
  35. data/lib/html2rss/syndication.rb +10 -0
  36. data/lib/html2rss/url.rb +22 -0
  37. data/lib/html2rss/version.rb +1 -1
  38. data/schema/html2rss-config.schema.json +39 -1
  39. metadata +19 -2
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Html2rss
3
+ module Html2rss # rubocop:disable Metrics/ModuleLength -- Status Data.define + marshal stay co-located
4
4
  ##
5
5
  # Shared RSS +generator+ / JSON Feed +user_comment+ formatter string.
6
6
  #
@@ -9,7 +9,7 @@ module Html2rss
9
9
  # Tallies and counters are validated and frozen at construction (including Marshal load).
10
10
  Status = Data.define(
11
11
  :version, :scraper_tallies, :dedup_dropped, :selected_strategy, :attempt_count,
12
- :strategy_attempts, :admission_drops
12
+ :strategy_attempts, :admission_drops, :entry_url, :scrape_url, :entry_resolution
13
13
  ) do
14
14
  class << self
15
15
  ##
@@ -21,10 +21,15 @@ module Html2rss
21
21
  # @param attempt_count [Integer] auto-fallback attempt count (0 when not under +:auto+)
22
22
  # @param strategy_attempts [Array<Hash>] auto-fallback attempt hashes (empty outside +:auto+)
23
23
  # @param admission_drops [Hash{String => Integer}] Cleanup reason → count (empty when unused)
24
+ # @param scrape_target [Html2rss::ScrapeTarget, nil] domain URL pair (mapped to wire strings)
25
+ # @param entry_url [String, nil] original channel URL when entry resolution ran
26
+ # @param scrape_url [String, nil] effective scrape URL after resolution
27
+ # @param entry_resolution [Html2rss::FeedResolution::Diag, Hash, nil] resolution diagnostics
24
28
  # @return [Html2rss::Status]
25
- # rubocop:disable Metrics/ParameterLists -- Status kwargs stay co-located
29
+ # rubocop:disable Metrics/ParameterLists, Metrics/MethodLength -- Status kwargs stay co-located
26
30
  def build(articles:, dedup_dropped: 0, selected_strategy: nil, attempt_count: 0,
27
- strategy_attempts: [], admission_drops: {})
31
+ strategy_attempts: [], admission_drops: {}, scrape_target: nil,
32
+ entry_url: nil, scrape_url: nil, entry_resolution: nil)
28
33
  tallies = articles.filter_map(&:scraper).tally.transform_keys { |klass| scraper_name(klass) }
29
34
  new(
30
35
  version: Html2rss::VERSION,
@@ -33,10 +38,13 @@ module Html2rss
33
38
  selected_strategy:,
34
39
  attempt_count:,
35
40
  strategy_attempts:,
36
- admission_drops:
41
+ admission_drops:,
42
+ entry_url: entry_url || scrape_target&.entry_url,
43
+ scrape_url: scrape_url || scrape_target&.effective_url,
44
+ entry_resolution:
37
45
  )
38
46
  end
39
- # rubocop:enable Metrics/ParameterLists
47
+ # rubocop:enable Metrics/ParameterLists, Metrics/MethodLength
40
48
 
41
49
  ##
42
50
  # @param klass [Class, #to_s] scraper class
@@ -54,10 +62,13 @@ module Html2rss
54
62
  # @param attempt_count [Integer]
55
63
  # @param strategy_attempts [Array<Hash>]
56
64
  # @param admission_drops [Hash{String => Integer}]
65
+ # @param entry_url [String, nil]
66
+ # @param scrape_url [String, nil]
67
+ # @param entry_resolution [Hash, nil]
57
68
  # rubocop:disable Metrics/ParameterLists, Metrics/MethodLength -- Status Data.define members
58
69
  def initialize(
59
70
  version:, scraper_tallies:, dedup_dropped:, selected_strategy: nil, attempt_count: 0,
60
- strategy_attempts: [], admission_drops: {}
71
+ strategy_attempts: [], admission_drops: {}, entry_url: nil, scrape_url: nil, entry_resolution: nil
61
72
  )
62
73
  dedup = Integer(dedup_dropped)
63
74
  attempts = Integer(attempt_count)
@@ -70,21 +81,19 @@ module Html2rss
70
81
  selected_strategy:,
71
82
  attempt_count: attempts,
72
83
  strategy_attempts: freeze_attempts(strategy_attempts),
73
- admission_drops: freeze_tallies(admission_drops)
84
+ admission_drops: freeze_tallies(admission_drops),
85
+ entry_url: freeze_optional_string(entry_url),
86
+ scrape_url: freeze_optional_string(scrape_url),
87
+ entry_resolution: freeze_entry_resolution(entry_resolution)
74
88
  )
75
89
  end
76
90
  # rubocop:enable Metrics/ParameterLists, Metrics/MethodLength
77
91
 
78
92
  ##
79
- # Observability hash for web (+scraper_status+). Omits empty/absent optional keys:
80
- # +:scraper_tallies+ when empty, +:selected_strategy+ when +nil+, +:attempt_count+ when zero,
81
- # +:strategy_attempts+ / +:admission_drops+ when empty.
82
- # Data members remain available via readers even when omitted here.
93
+ # Observability hash for web (+scraper_status+). Omits empty/absent optional keys.
83
94
  #
84
- # @return [Hash{Symbol => Object}] always +:version+ (String), +:dedup_dropped+ (Integer);
85
- # optionally +:scraper_tallies+, +:selected_strategy+, +:attempt_count+,
86
- # +:strategy_attempts+, +:admission_drops+
87
- # rubocop:disable Metrics/AbcSize -- omit-empty optional keys stay explicit
95
+ # @return [Hash{Symbol => Object}]
96
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity -- omit-empty optional keys stay explicit
88
97
  def to_h
89
98
  {
90
99
  version:,
@@ -93,10 +102,13 @@ module Html2rss
93
102
  **(selected_strategy.nil? ? {} : { selected_strategy: }),
94
103
  **(attempt_count.positive? ? { attempt_count: } : {}),
95
104
  **(strategy_attempts.any? ? { strategy_attempts: } : {}),
96
- **(admission_drops.any? ? { admission_drops: } : {})
105
+ **(admission_drops.any? ? { admission_drops: } : {}),
106
+ **(entry_url ? { entry_url: } : {}),
107
+ **(scrape_url ? { scrape_url: } : {}),
108
+ **(entry_resolution ? { entry_resolution: } : {})
97
109
  }
98
110
  end
99
- # rubocop:enable Metrics/AbcSize
111
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
100
112
 
101
113
  ##
102
114
  # Formats the RSS +generator+ string and JSON Feed +user_comment+.
@@ -115,13 +127,15 @@ module Html2rss
115
127
 
116
128
  def marshal_dump
117
129
  [version, scraper_tallies, dedup_dropped, selected_strategy, attempt_count, strategy_attempts,
118
- admission_drops]
130
+ admission_drops, entry_url, scrape_url, entry_resolution]
119
131
  end
120
132
 
121
- def marshal_load((version, scraper_tallies, dedup_dropped, selected_strategy, attempt_count,
122
- strategy_attempts, admission_drops))
133
+ def marshal_load(payload)
134
+ version, scraper_tallies, dedup_dropped, selected_strategy, attempt_count,
135
+ strategy_attempts, admission_drops, entry_url, scrape_url, entry_resolution = payload
123
136
  initialize(version:, scraper_tallies:, dedup_dropped:, selected_strategy:, attempt_count:,
124
- strategy_attempts:, admission_drops: admission_drops || {})
137
+ strategy_attempts:, admission_drops: admission_drops || {}, entry_url:, scrape_url:,
138
+ entry_resolution:)
125
139
  end
126
140
 
127
141
  def freeze_tallies(tallies)
@@ -132,6 +146,19 @@ module Html2rss
132
146
  Array(attempts).map { |attempt| attempt.to_h.freeze }.freeze
133
147
  end
134
148
 
149
+ def freeze_optional_string(value)
150
+ return if value.nil?
151
+
152
+ value.to_s.dup.freeze
153
+ end
154
+
155
+ def freeze_entry_resolution(value)
156
+ return if value.nil?
157
+
158
+ hash = value.respond_to?(:to_h) ? value.to_h : value
159
+ hash.to_h.transform_keys(&:to_sym).freeze
160
+ end
161
+
135
162
  def validate_counters!(dedup:, attempts:, selected_strategy:)
136
163
  raise ArgumentError, 'dedup_dropped must be >= 0' if dedup.negative?
137
164
  raise ArgumentError, 'attempt_count must be >= 0' if attempts.negative?
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ ##
5
+ # Closed surface class for no-scraper / page-assessment gates.
6
+ #
7
+ # Construction: {AutoSource::Scraper.classify_no_scraper_surface} and {PageRecon.assess}.
8
+ # Predicates own weak/blocked/listing-bonus decisions — do not re-list WEAK sets in Policy/Scorer.
9
+ class SurfaceCategory
10
+ # Surfaces that warrant listing/feed resolution (hubs / shells — not blocked).
11
+ WEAK = Set[:high_entropy_surface, :app_shell, :unsupported_surface].freeze
12
+
13
+ class << self
14
+ ##
15
+ # @param value [SurfaceCategory, Symbol, String, nil]
16
+ # @return [SurfaceCategory]
17
+ def coerce(value)
18
+ return value if value.is_a?(self)
19
+ return new(name: nil) if value.nil?
20
+
21
+ new(name: value.to_sym)
22
+ end
23
+ end
24
+
25
+ ##
26
+ # @return [Symbol, nil]
27
+ attr_reader :name
28
+
29
+ ##
30
+ # @param name [Symbol, nil]
31
+ def initialize(name:)
32
+ @name = name
33
+ end
34
+
35
+ ##
36
+ # @return [Boolean]
37
+ def weak? = WEAK.include?(name)
38
+
39
+ ##
40
+ # @return [Boolean]
41
+ def blocked? = name == :blocked_surface
42
+
43
+ ##
44
+ # @return [Boolean] non-weak, non-blocked surface eligible for listing bonus
45
+ def listing_bonus? = !name.nil? && !weak? && !blocked?
46
+
47
+ ##
48
+ # @return [Symbol, nil]
49
+ def to_sym = name
50
+
51
+ ##
52
+ # @param other [Object]
53
+ # @return [Boolean]
54
+ def ==(other)
55
+ other.is_a?(self.class) && name == other.name
56
+ end
57
+ alias eql? ==
58
+
59
+ ##
60
+ # @return [Integer]
61
+ def hash = [self.class, name].hash
62
+ end
63
+ end
@@ -0,0 +1,21 @@
1
+ # Syndication
2
+
3
+ Native RSS/Atom discovery and parse for auto-source promotion and direct feed URLs.
4
+
5
+ ## Ownership
6
+
7
+ | Concern | Owner |
8
+ | --- | --- |
9
+ | Head `rel=alternate` + common path probes + status-gated feedish | `Syndication::Discovery` |
10
+ | RSS 2.0 / Atom → article hashes | `Syndication::Parser` |
11
+ | Strict head-only link parse (no path guessing) | `Html::FeedLink` |
12
+ | Response Content-Type / body feed sniff | `RequestService::Response#feed_response?` (sole sniff owner; Discovery only adds HTTP status gate) |
13
+
14
+ ## Non-goals
15
+
16
+ - Entry-URL tournament / listing resolution (`FeedResolution`)
17
+ - Page recon / surface classification (`PageRecon`)
18
+ - Feed channel metadata for output (`Channel` / `FeedBuilder`)
19
+ - Ranking or quality scoring of competing feeds beyond first feedish hit
20
+
21
+ Discovery stops at the first same-origin feedish URL (lazy probe). Path guessing mirrors the legacy configs `probe_rss` script; that script should eventually thin-wrap this module.
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ module Syndication
5
+ ##
6
+ # Shared path lexicon for syndication discovery and entry-resolution candidates.
7
+ module CandidateCatalog
8
+ # Common feed path suffixes probed after head +rel=alternate+ hints.
9
+ FEED_PATHS = %w[
10
+ /feed
11
+ /feed.xml
12
+ /rss
13
+ /rss.xml
14
+ /atom.xml
15
+ /index.xml
16
+ /news/rss
17
+ /news/feed
18
+ /blog/feed
19
+ /blog/rss.xml
20
+ ].freeze
21
+
22
+ # HTML listing paths probed only by {FeedResolution::CandidateGenerator}.
23
+ LISTING_PATHS = %w[/news /blog /releases /changelog /updates].freeze
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,217 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ module Syndication
5
+ ##
6
+ # Finds a same-origin RSS/Atom URL for a page via head alternates and path guesses.
7
+ #
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
10
+ # Common feed path suffixes probed after head +rel=alternate+ hints.
11
+ DEFAULT_PATHS = CandidateCatalog::FEED_PATHS
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 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
+ # rubocop:enable Metrics/ParameterLists
39
+
40
+ ##
41
+ # Like {#best_feed_url} but returns the validated syndication response for parsing.
42
+ #
43
+ # @param page_url [String, Html2rss::Url]
44
+ # @param request_session [Html2rss::RequestSession]
45
+ # @param parsed_body [Nokogiri::HTML::Document, nil]
46
+ # @param html [String, nil]
47
+ # @param extra_paths [Array<String>]
48
+ # @param max_probes [Integer, nil] max candidate GETs (nil = uncapped)
49
+ # @return [Html2rss::RequestService::Response, nil]
50
+ # rubocop:disable Metrics/ParameterLists, Metrics/MethodLength -- discovery kwargs stay co-located
51
+ def best_feed_response(page_url:, request_session:, parsed_body: nil, html: nil, extra_paths: [],
52
+ max_probes: nil)
53
+ page = Html2rss::Url.from_absolute(page_url)
54
+ candidates = candidate_urls(page_url: page, parsed_body:, html:, extra_paths:)
55
+ Log.debug(
56
+ "Syndication::Discovery: host=#{page.host} candidate_count=#{candidates.size}"
57
+ )
58
+
59
+ first_feedish_response(
60
+ candidates, request_session:, origin_url: page, max_probes:
61
+ ).tap do |response|
62
+ next unless response
63
+
64
+ Log.info("Syndication::Discovery: host=#{page.host} selected_feed_url=#{response.url}")
65
+ end
66
+ end
67
+ # rubocop:enable Metrics/ParameterLists, Metrics/MethodLength
68
+
69
+ ##
70
+ # Ordered candidate feed URLs (head alternates first, then path guesses).
71
+ #
72
+ # @param page_url [String, Html2rss::Url]
73
+ # @param parsed_body [Nokogiri::HTML::Document, nil]
74
+ # @param html [String, nil]
75
+ # @param extra_paths [Array<String>]
76
+ # @return [Array<Html2rss::Url>]
77
+ def candidate_urls(page_url:, parsed_body: nil, html: nil, extra_paths: [])
78
+ page = Html2rss::Url.from_absolute(page_url)
79
+ (alternate_feed_urls(page, parsed_body:, html:) +
80
+ path_guess_urls(page, extra_paths:)).uniq
81
+ end
82
+
83
+ ##
84
+ # @param response [Html2rss::RequestService::Response, nil]
85
+ # @return [Boolean]
86
+ def feedish?(response)
87
+ return false unless response
88
+ return false unless successful_status?(response)
89
+
90
+ response.feed_response?
91
+ end
92
+
93
+ def successful_status?(response)
94
+ status = response.status
95
+ status.nil? || status.between?(200, 299)
96
+ end
97
+ module_function :successful_status?
98
+ private_class_method :successful_status?
99
+
100
+ ##
101
+ # Directory-relative feed path guesses for a page URL.
102
+ #
103
+ # @param page_url [String, Html2rss::Url]
104
+ # @return [Array<String>] relative paths
105
+ def page_dir_paths(page_url)
106
+ path = Html2rss::Url.from_absolute(page_url).path
107
+ return [] if path.nil? || path.empty? || path == '/'
108
+
109
+ dir = path.sub(%r{/[^/]*$}, '/')
110
+ ["#{dir}feed", "#{dir}rss.xml", "#{dir}atom.xml"]
111
+ end
112
+
113
+ def first_feedish_response(urls, request_session:, origin_url:, max_probes: nil)
114
+ scoped = max_probes.nil? ? urls.lazy : urls.lazy.take(max_probes)
115
+ scoped.filter_map do |url|
116
+ response = probe(url, request_session:, origin_url:)
117
+ response if feedish?(response)
118
+ end.first
119
+ end
120
+ module_function :first_feedish_response
121
+ private_class_method :first_feedish_response
122
+
123
+ def first_feedish_url(urls, request_session:, origin_url:, max_probes: nil)
124
+ first_feedish_response(urls, request_session:, origin_url:, max_probes:)&.url
125
+ end
126
+ module_function :first_feedish_url
127
+ private_class_method :first_feedish_url
128
+
129
+ def probe(url, request_session:, origin_url:)
130
+ request_session.follow_up(url:, relation: :auto_source, origin_url:)
131
+ rescue Html2rss::Error => error
132
+ Log.debug(
133
+ "Syndication::Discovery: probe skipped host=#{origin_url.host} " \
134
+ "(#{error.class})"
135
+ )
136
+ nil
137
+ end
138
+ module_function :probe
139
+ private_class_method :probe
140
+
141
+ def alternate_feed_urls(page, parsed_body:, html:)
142
+ from_document = feed_links_from_document(parsed_body, page)
143
+ return from_document if from_document.any?
144
+
145
+ alternate_feed_hrefs(html.to_s, page)
146
+ end
147
+ module_function :alternate_feed_urls
148
+ private_class_method :alternate_feed_urls
149
+
150
+ def feed_links_from_document(parsed_body, page)
151
+ return [] unless parsed_body.is_a?(Nokogiri::HTML::Document)
152
+
153
+ Html::FeedLink.from_document(parsed_body).filter_map do |link|
154
+ absolute_url(page, link.href)
155
+ end
156
+ end
157
+ module_function :feed_links_from_document
158
+ private_class_method :feed_links_from_document
159
+
160
+ def path_guess_urls(page, extra_paths:)
161
+ paths = (page_dir_paths(page) + DEFAULT_PATHS + Array(extra_paths)).uniq
162
+ paths.filter_map { |path| absolute_url(page, path) }
163
+ end
164
+ module_function :path_guess_urls
165
+ private_class_method :path_guess_urls
166
+
167
+ def alternate_feed_hrefs(html, page)
168
+ return [] if html.nil? || html.empty?
169
+
170
+ html.scan(LINK_TAG_RE).filter_map { |tag| href_from_alternate_link(tag, page) }.uniq
171
+ end
172
+ module_function :alternate_feed_hrefs
173
+ private_class_method :alternate_feed_hrefs
174
+
175
+ def href_from_alternate_link(tag, page)
176
+ return unless alternate_rel?(tag)
177
+
178
+ href = tag[HREF_RE, 1]
179
+ return if href.nil? || href.empty?
180
+ return unless syndication_type?(tag[TYPE_RE, 1]) || feed_like_href?(href)
181
+
182
+ absolute_url(page, href.strip)
183
+ end
184
+ module_function :href_from_alternate_link
185
+ private_class_method :href_from_alternate_link
186
+
187
+ def alternate_rel?(tag)
188
+ tag[REL_RE, 1].to_s.downcase.split(/\s+/).include?('alternate')
189
+ end
190
+ module_function :alternate_rel?
191
+ private_class_method :alternate_rel?
192
+
193
+ def syndication_type?(type)
194
+ return false if type.nil? || type.empty?
195
+
196
+ t = type.downcase
197
+ t.include?('rss+xml') || t.include?('atom+xml') || t.include?('rss') || t.include?('atom')
198
+ end
199
+ module_function :syndication_type?
200
+ private_class_method :syndication_type?
201
+
202
+ def feed_like_href?(href)
203
+ href.match?(/rss|atom|feed|\.xml/i)
204
+ end
205
+ module_function :feed_like_href?
206
+ private_class_method :feed_like_href?
207
+
208
+ def absolute_url(base, path)
209
+ Html2rss::Url.from_relative(path, base)
210
+ rescue ArgumentError
211
+ nil
212
+ end
213
+ module_function :absolute_url
214
+ private_class_method :absolute_url
215
+ end
216
+ end
217
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rss'
4
+
5
+ module Html2rss
6
+ module Syndication
7
+ ##
8
+ # Parses RSS 2.0 and Atom documents into AutoSource article hashes.
9
+ module Parser
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?
18
+
19
+ parse(response.body, base_url: response.url)
20
+ end
21
+
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("Syndication::Parser: failed to parse feed (#{error.class}: #{error.message})")
33
+ []
34
+ end
35
+
36
+ def items_from(feed, base_url:) # rubocop:disable Lint/UnusedMethodArgument -- base reserved for callers
37
+ case feed
38
+ when RSS::Rss then Array(feed.items)
39
+ when RSS::Atom::Feed then Array(feed.entries)
40
+ else []
41
+ end
42
+ end
43
+ module_function :items_from
44
+ private_class_method :items_from
45
+
46
+ def article_hash(item, base_url:)
47
+ case item
48
+ when RSS::Rss::Channel::Item then rss_item_hash(item, base_url:)
49
+ when RSS::Atom::Entry, RSS::Atom::Feed::Entry then atom_entry_hash(item, base_url:)
50
+ end
51
+ end
52
+ module_function :article_hash
53
+ private_class_method :article_hash
54
+
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.date || item.pubDate,
65
+ author: present(item.author),
66
+ categories: Array(item.categories).filter_map { |cat| present(cat.content) }
67
+ }.compact
68
+ end
69
+ module_function :rss_item_hash
70
+ private_class_method :rss_item_hash
71
+
72
+ def atom_entry_hash(entry, base_url:) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity -- Atom entry mapping
73
+ url = atom_link(entry, base_url:)
74
+ return unless url
75
+
76
+ {
77
+ id: present(entry.id&.content) || url.to_s,
78
+ title: present(atom_text(entry.title)),
79
+ description: present(atom_text(entry.content) || atom_text(entry.summary)),
80
+ url:,
81
+ published_at: entry.published&.content || entry.updated&.content,
82
+ author: present(atom_author(entry)),
83
+ categories: Array(entry.categories).filter_map { |cat| present(cat.term) }
84
+ }.compact
85
+ end
86
+ module_function :atom_entry_hash
87
+ private_class_method :atom_entry_hash
88
+
89
+ def atom_link(entry, base_url:)
90
+ links = Array(entry.links)
91
+ link = links.find { |node| node.rel.nil? || node.rel == 'alternate' } || links.first
92
+ absolute_url(link&.href, base_url:)
93
+ end
94
+ module_function :atom_link
95
+ private_class_method :atom_link
96
+
97
+ def atom_text(node)
98
+ return if node.nil?
99
+
100
+ node.respond_to?(:content) ? node.content : node.to_s
101
+ end
102
+ module_function :atom_text
103
+ private_class_method :atom_text
104
+
105
+ def atom_author(entry)
106
+ author = Array(entry.author).first
107
+ return unless author
108
+
109
+ present(author.name&.content) || present(author.email&.content)
110
+ end
111
+ module_function :atom_author
112
+ private_class_method :atom_author
113
+
114
+ def absolute_url(raw, base_url:)
115
+ value = present(raw)
116
+ return unless value
117
+
118
+ if base_url
119
+ Html2rss::Url.from_relative(value, base_url)
120
+ else
121
+ Html2rss::Url.from_absolute(value)
122
+ end
123
+ rescue ArgumentError
124
+ nil
125
+ end
126
+ module_function :absolute_url
127
+ private_class_method :absolute_url
128
+
129
+ def present(value)
130
+ text = value.to_s.strip
131
+ text unless text.empty?
132
+ end
133
+ module_function :present
134
+ private_class_method :present
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ ##
5
+ # Native RSS/Atom discovery and parse (not HTML AutoSource ownership).
6
+ #
7
+ # {include:file:lib/html2rss/syndication/README.md}
8
+ module Syndication
9
+ end
10
+ end
data/lib/html2rss/url.rb CHANGED
@@ -281,6 +281,18 @@ module Html2rss
281
281
  def ==(other) = other.is_a?(Url) && to_s == other.to_s
282
282
  alias eql? ==
283
283
 
284
+ ##
285
+ # Whether +other+ names the same document (trailing slash and fragment ignored).
286
+ # Differs from {#==}: `/blog` and `/blog/` match; query strings must still match.
287
+ #
288
+ # @param other [Object]
289
+ # @return [Boolean]
290
+ def same_document?(other)
291
+ return false unless other.is_a?(Url)
292
+
293
+ document_identity == other.document_identity
294
+ end
295
+
284
296
  ##
285
297
  # Returns the hash code for this URL.
286
298
  #
@@ -293,6 +305,16 @@ module Html2rss
293
305
  # @return [String] the debug representation
294
306
  def inspect = "#<#{self.class}:#{object_id} @uri=#{@uri.inspect}>"
295
307
 
308
+ protected
309
+
310
+ # Scheme, authority, slash-stripped path, and query (fragment omitted).
311
+ #
312
+ # @return [String]
313
+ def document_identity
314
+ uri = @uri.omit(:fragment)
315
+ "#{uri.scheme}\0#{uri.authority}\0#{uri.path.to_s.chomp('/')}\0#{uri.query}"
316
+ end
317
+
296
318
  private
297
319
 
298
320
  def ip_address_host?
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Html2rss
4
4
  # Current application version.
5
- VERSION = '0.27.1'
5
+ VERSION = '0.28.0'
6
6
  public_constant :VERSION
7
7
  end