html2rss 0.27.2 → 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 +8 -1
  24. data/lib/html2rss/request_service/botasaurus_strategy.rb +1 -1
  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 +38 -0
  39. metadata +19 -2
@@ -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.2'
5
+ VERSION = '0.28.0'
6
6
  public_constant :VERSION
7
7
  end
@@ -157,9 +157,40 @@
157
157
  },
158
158
  "exclusiveMinimum": 0
159
159
  },
160
+ "entry_resolution": {
161
+ "type": "object",
162
+ "properties": {
163
+ "enabled": {
164
+ "type": "boolean",
165
+ "not": {
166
+ "type": "null"
167
+ }
168
+ },
169
+ "max_probes": {
170
+ "type": "integer",
171
+ "not": {
172
+ "type": "null"
173
+ },
174
+ "exclusiveMinimum": 0
175
+ }
176
+ },
177
+ "required": []
178
+ },
160
179
  "scraper": {
161
180
  "type": "object",
162
181
  "properties": {
182
+ "native_feed": {
183
+ "type": "object",
184
+ "properties": {
185
+ "enabled": {
186
+ "type": "boolean",
187
+ "not": {
188
+ "type": "null"
189
+ }
190
+ }
191
+ },
192
+ "required": []
193
+ },
163
194
  "wordpress_api": {
164
195
  "type": "object",
165
196
  "properties": {
@@ -338,7 +369,14 @@
338
369
  "required": [],
339
370
  "default": {
340
371
  "limit": 25,
372
+ "entry_resolution": {
373
+ "enabled": true,
374
+ "max_probes": 5
375
+ },
341
376
  "scraper": {
377
+ "native_feed": {
378
+ "enabled": true
379
+ },
342
380
  "wordpress_api": {
343
381
  "enabled": true
344
382
  },
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: html2rss
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.27.2
4
+ version: 0.28.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gil Desmarais
@@ -331,6 +331,7 @@ files:
331
331
  - lib/html2rss/auto_source/scraper/meta_oembed.rb
332
332
  - lib/html2rss/auto_source/scraper/microdata.rb
333
333
  - lib/html2rss/auto_source/scraper/microformats2.rb
334
+ - lib/html2rss/auto_source/scraper/native_feed.rb
334
335
  - lib/html2rss/auto_source/scraper/schema.rb
335
336
  - lib/html2rss/auto_source/scraper/schema/category_extractor.rb
336
337
  - lib/html2rss/auto_source/scraper/schema/item_list.rb
@@ -376,6 +377,14 @@ files:
376
377
  - lib/html2rss/feed_pipeline/auto_fallback.rb
377
378
  - lib/html2rss/feed_pipeline/runtime_policy.rb
378
379
  - lib/html2rss/feed_pipeline/strategy_plan.rb
380
+ - lib/html2rss/feed_resolution.rb
381
+ - lib/html2rss/feed_resolution/README.md
382
+ - lib/html2rss/feed_resolution/candidate_generator.rb
383
+ - lib/html2rss/feed_resolution/diag.rb
384
+ - lib/html2rss/feed_resolution/options.rb
385
+ - lib/html2rss/feed_resolution/policy.rb
386
+ - lib/html2rss/feed_resolution/probe.rb
387
+ - lib/html2rss/feed_resolution/scorer.rb
379
388
  - lib/html2rss/feed_result.rb
380
389
  - lib/html2rss/hash_util.rb
381
390
  - lib/html2rss/html.rb
@@ -416,6 +425,7 @@ files:
416
425
  - lib/html2rss/mcp/inspect.rb
417
426
  - lib/html2rss/mcp/outcome.rb
418
427
  - lib/html2rss/mcp/server.rb
428
+ - lib/html2rss/page_recon.rb
419
429
  - lib/html2rss/request_service.rb
420
430
  - lib/html2rss/request_service/blocked_surface.rb
421
431
  - lib/html2rss/request_service/botasaurus_contract.rb
@@ -446,6 +456,7 @@ files:
446
456
  - lib/html2rss/scoring/observation.rb
447
457
  - lib/html2rss/scoring/ranked_segment.rb
448
458
  - lib/html2rss/scoring/score.rb
459
+ - lib/html2rss/scrape_target.rb
449
460
  - lib/html2rss/selectors.rb
450
461
  - lib/html2rss/selectors/extractors.rb
451
462
  - lib/html2rss/selectors/extractors/attribute.rb
@@ -477,6 +488,12 @@ files:
477
488
  - lib/html2rss/sst/tags.rb
478
489
  - lib/html2rss/sst/text.rb
479
490
  - lib/html2rss/status.rb
491
+ - lib/html2rss/surface_category.rb
492
+ - lib/html2rss/syndication.rb
493
+ - lib/html2rss/syndication/README.md
494
+ - lib/html2rss/syndication/candidate_catalog.rb
495
+ - lib/html2rss/syndication/discovery.rb
496
+ - lib/html2rss/syndication/parser.rb
480
497
  - lib/html2rss/url.rb
481
498
  - lib/html2rss/version.rb
482
499
  - lib/tasks/config_schema.rake
@@ -486,7 +503,7 @@ licenses:
486
503
  - MIT
487
504
  metadata:
488
505
  allowed_push_host: https://rubygems.org
489
- changelog_uri: https://github.com/html2rss/html2rss/releases/tag/v0.27.2
506
+ changelog_uri: https://github.com/html2rss/html2rss/releases/tag/v0.28.0
490
507
  rubygems_mfa_required: 'true'
491
508
  rdoc_options: []
492
509
  require_paths: