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.
Files changed (105) 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 +7 -3
  45. data/lib/html2rss/feed_builder/rss.rb +1 -1
  46. data/lib/html2rss/feed_pipeline/auto_fallback.rb +3 -3
  47. data/lib/html2rss/feed_resolution/candidate_generator.rb +3 -4
  48. data/lib/html2rss/feed_resolution/options.rb +3 -7
  49. data/lib/html2rss/feed_resolution/policy.rb +29 -33
  50. data/lib/html2rss/feed_resolution/probe.rb +3 -3
  51. data/lib/html2rss/feed_resolution/scorer.rb +29 -29
  52. data/lib/html2rss/feed_resolution.rb +5 -9
  53. data/lib/html2rss/feed_result.rb +4 -3
  54. data/lib/html2rss/hash_util.rb +91 -90
  55. data/lib/html2rss/html/article_extractor/category_extractor.rb +6 -9
  56. data/lib/html2rss/html/article_extractor/date_extractor.rb +1 -1
  57. data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +1 -3
  58. data/lib/html2rss/html/article_extractor/id_generator.rb +1 -1
  59. data/lib/html2rss/html/probe.rb +51 -51
  60. data/lib/html2rss/link_destination/path_classifier.rb +11 -33
  61. data/lib/html2rss/mcp/config_argument.rb +9 -2
  62. data/lib/html2rss/mcp/contract.rb +7 -5
  63. data/lib/html2rss/mcp/outcome.rb +4 -5
  64. data/lib/html2rss/mcp/runtime.rb +17 -24
  65. data/lib/html2rss/mcp/server/tools.rb +9 -9
  66. data/lib/html2rss/option.rb +48 -0
  67. data/lib/html2rss/page_recon/diagnostics.rb +7 -9
  68. data/lib/html2rss/page_recon.rb +0 -4
  69. data/lib/html2rss/recon.rb +10 -12
  70. data/lib/html2rss/request_service/blocked_surface.rb +3 -7
  71. data/lib/html2rss/request_service/botasaurus_strategy.rb +2 -2
  72. data/lib/html2rss/request_service/budget.rb +3 -24
  73. data/lib/html2rss/request_service/compressed_body.rb +36 -48
  74. data/lib/html2rss/request_service/context.rb +1 -3
  75. data/lib/html2rss/request_service/local_file_strategy.rb +0 -1
  76. data/lib/html2rss/request_service/network_guard.rb +3 -2
  77. data/lib/html2rss/request_service/policy.rb +8 -26
  78. data/lib/html2rss/request_service/response.rb +3 -6
  79. data/lib/html2rss/request_session/pager/base.rb +3 -3
  80. data/lib/html2rss/request_session/pager/custom_selector.rb +4 -4
  81. data/lib/html2rss/request_session/pager/offset.rb +1 -7
  82. data/lib/html2rss/request_session/pager/rel_next.rb +1 -1
  83. data/lib/html2rss/request_session/pager/url_template.rb +1 -7
  84. data/lib/html2rss/scoring/engine.rb +4 -32
  85. data/lib/html2rss/scoring/link_resolver.rb +2 -7
  86. data/lib/html2rss/selectors/extractors/href.rb +2 -4
  87. data/lib/html2rss/selectors/json_xml.rb +33 -34
  88. data/lib/html2rss/selectors/post_processors/gsub.rb +2 -3
  89. data/lib/html2rss/selectors/post_processors/parse_time.rb +4 -5
  90. data/lib/html2rss/selectors/schema_export.rb +60 -60
  91. data/lib/html2rss/selectors.rb +14 -18
  92. data/lib/html2rss/sst/node.rb +9 -3
  93. data/lib/html2rss/sst/normalizer.rb +25 -24
  94. data/lib/html2rss/sst/text.rb +53 -57
  95. data/lib/html2rss/status.rb +23 -30
  96. data/lib/html2rss/syndication/alternate_links.rb +39 -0
  97. data/lib/html2rss/syndication/discovery.rb +117 -187
  98. data/lib/html2rss/syndication/parser.rb +92 -115
  99. data/lib/html2rss/test/enhance_audit.rb +13 -15
  100. data/lib/html2rss/test/policy.rb +9 -11
  101. data/lib/html2rss/test.rb +29 -48
  102. data/lib/html2rss/url.rb +53 -53
  103. data/lib/html2rss/version.rb +1 -1
  104. data/lib/html2rss.rb +2 -1
  105. metadata +4 -2
@@ -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 = Integer(dedup_dropped)
75
- attempts = Integer(attempt_count)
76
- validate_counters!(dedup:, attempts:, selected_strategy:)
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: admission_drops || {}, entry_url:, scrape_url:,
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.transform_keys(&:to_s).transform_values { |count| Integer(count) }.freeze
141
+ tallies.to_h { |name, count| [name.to_s, count] }.freeze
142
142
  end
143
143
 
144
144
  def freeze_attempts(attempts)
145
- Array(attempts).map { |attempt| attempt.to_h.freeze }.freeze
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 validate_counters!(dedup:, attempts:, selected_strategy:)
165
- raise ArgumentError, 'dedup_dropped must be >= 0' if dedup.negative?
166
- raise ArgumentError, 'attempt_count must be >= 0' if attempts.negative?
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 # 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