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.
- checksums.yaml +4 -4
- data/lib/html2rss/auto_source/README.md +4 -1
- data/lib/html2rss/auto_source/scraper/native_feed.rb +90 -0
- data/lib/html2rss/auto_source/scraper.rb +4 -1
- data/lib/html2rss/auto_source.rb +7 -0
- data/lib/html2rss/config/auto_source_contract.rb +7 -0
- data/lib/html2rss/config.rb +10 -1
- data/lib/html2rss/feed_pipeline/README.md +2 -0
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +53 -13
- data/lib/html2rss/feed_pipeline/runtime_policy.rb +1 -0
- data/lib/html2rss/feed_pipeline.rb +24 -8
- data/lib/html2rss/feed_resolution/README.md +52 -0
- data/lib/html2rss/feed_resolution/candidate_generator.rb +139 -0
- data/lib/html2rss/feed_resolution/diag.rb +32 -0
- data/lib/html2rss/feed_resolution/options.rb +31 -0
- data/lib/html2rss/feed_resolution/policy.rb +47 -0
- data/lib/html2rss/feed_resolution/probe.rb +59 -0
- data/lib/html2rss/feed_resolution/scorer.rb +49 -0
- data/lib/html2rss/feed_resolution.rb +274 -0
- data/lib/html2rss/mcp/inspect.rb +18 -121
- data/lib/html2rss/mcp/outcome.rb +4 -2
- data/lib/html2rss/page_recon.rb +243 -0
- data/lib/html2rss/request_service/botasaurus_contract.rb +24 -47
- data/lib/html2rss/request_service/botasaurus_strategy.rb +34 -12
- data/lib/html2rss/request_service/response.rb +26 -2
- data/lib/html2rss/request_service.rb +13 -1
- data/lib/html2rss/request_session.rb +5 -2
- data/lib/html2rss/scrape_target.rb +24 -0
- data/lib/html2rss/status.rb +49 -22
- data/lib/html2rss/surface_category.rb +63 -0
- data/lib/html2rss/syndication/README.md +21 -0
- data/lib/html2rss/syndication/candidate_catalog.rb +26 -0
- data/lib/html2rss/syndication/discovery.rb +217 -0
- data/lib/html2rss/syndication/parser.rb +137 -0
- data/lib/html2rss/syndication.rb +10 -0
- data/lib/html2rss/url.rb +22 -0
- data/lib/html2rss/version.rb +1 -1
- data/schema/html2rss-config.schema.json +39 -1
- metadata +19 -2
data/lib/html2rss/status.rb
CHANGED
|
@@ -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}]
|
|
85
|
-
#
|
|
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(
|
|
122
|
-
|
|
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
|
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?
|