html2rss 0.22.2 → 0.24.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/README.md +11 -108
- data/lib/html2rss/{articles → article}/deduplicator.rb +3 -4
- data/lib/html2rss/{rss_builder → article}/enclosure.rb +6 -22
- data/lib/html2rss/article.rb +213 -0
- data/lib/html2rss/auto_source/discovery/dom_clustering/group_scorer.rb +80 -0
- data/lib/html2rss/auto_source/discovery/dom_clustering/overlap_resolver.rb +86 -0
- data/lib/html2rss/auto_source/discovery/dom_clustering.rb +119 -0
- data/lib/html2rss/auto_source/discovery/list_candidates.rb +94 -0
- data/lib/html2rss/auto_source/discovery/semantic_anchor_candidates.rb +219 -0
- data/lib/html2rss/auto_source/discovery/semantic_containers.rb +71 -0
- data/lib/html2rss/auto_source/discovery/sitemap.rb +159 -0
- data/lib/html2rss/auto_source/discovery.rb +14 -0
- data/lib/html2rss/auto_source/link_heuristics/anchor_signals.rb +28 -0
- data/lib/html2rss/auto_source/link_heuristics/container_assessor.rb +106 -0
- data/lib/html2rss/auto_source/link_heuristics/container_signals.rb +80 -0
- data/lib/html2rss/auto_source/link_heuristics/destination_facts.rb +42 -0
- data/lib/html2rss/auto_source/link_heuristics/href_extractor.rb +38 -0
- data/lib/html2rss/auto_source/link_heuristics/path_classifier.rb +221 -0
- data/lib/html2rss/auto_source/link_heuristics/text_classifier.rb +66 -0
- data/lib/html2rss/auto_source/link_heuristics.rb +139 -0
- data/lib/html2rss/auto_source/scraper/html.rb +22 -33
- data/lib/html2rss/auto_source/scraper/json_state.rb +1 -1
- data/lib/html2rss/auto_source/scraper/meta_oembed.rb +163 -0
- data/lib/html2rss/auto_source/scraper/microformats2.rb +146 -0
- data/lib/html2rss/auto_source/scraper/schema/category_extractor.rb +29 -114
- data/lib/html2rss/auto_source/scraper/schema/item_list.rb +50 -11
- data/lib/html2rss/auto_source/scraper/schema/thing.rb +35 -7
- data/lib/html2rss/auto_source/scraper/schema.rb +61 -18
- data/lib/html2rss/auto_source/scraper/semantic_html/{deduplicator.rb → entry_deduplicator.rb} +3 -3
- data/lib/html2rss/auto_source/scraper/semantic_html.rb +23 -171
- data/lib/html2rss/auto_source/scraper/sitemap.rb +153 -0
- data/lib/html2rss/auto_source/scraper/wordpress_api/page_scope/date_archive_range.rb +110 -0
- data/lib/html2rss/auto_source/scraper/wordpress_api/page_scope.rb +2 -78
- data/lib/html2rss/auto_source/scraper/wordpress_api.rb +9 -0
- data/lib/html2rss/auto_source/scraper.rb +10 -3
- data/lib/html2rss/auto_source.rb +30 -35
- data/lib/html2rss/channel.rb +198 -0
- data/lib/html2rss/cli.rb +5 -6
- data/lib/html2rss/config/auto_source_contract.rb +51 -0
- data/lib/html2rss/config/request_controls.rb +136 -0
- data/lib/html2rss/config/request_headers.rb +1 -1
- data/lib/html2rss/config/schema.rb +65 -114
- data/lib/html2rss/{selectors/config.rb → config/selectors_validator.rb} +56 -7
- data/lib/html2rss/config/validator.rb +6 -6
- data/lib/html2rss/config.rb +176 -19
- data/lib/html2rss/{configuration.rb → defaults.rb} +12 -10
- data/lib/html2rss/feed_builder/item_presentation.rb +42 -0
- data/lib/html2rss/feed_builder/json_feed/item.rb +112 -0
- data/lib/html2rss/feed_builder/json_feed.rb +81 -0
- data/lib/html2rss/feed_builder/rss/stylesheet.rb +65 -0
- data/lib/html2rss/feed_builder/rss.rb +116 -0
- data/lib/html2rss/feed_builder.rb +15 -0
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +120 -60
- data/lib/html2rss/feed_pipeline/runtime_policy.rb +108 -0
- data/lib/html2rss/feed_pipeline/strategy_plan.rb +64 -0
- data/lib/html2rss/feed_pipeline.rb +83 -89
- data/lib/html2rss/feed_result.rb +92 -0
- data/lib/html2rss/html/article_extractor/category_extractor.rb +123 -0
- data/lib/html2rss/html/article_extractor/date_extractor.rb +22 -0
- data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +95 -0
- data/lib/html2rss/html/article_extractor/heading_extractor.rb +54 -0
- data/lib/html2rss/html/article_extractor/id_generator.rb +69 -0
- data/lib/html2rss/html/article_extractor/image_extractor.rb +60 -0
- data/lib/html2rss/html/article_extractor.rb +134 -0
- data/lib/html2rss/html/navigator/text_extractor.rb +79 -0
- data/lib/html2rss/html/navigator.rb +146 -0
- data/lib/html2rss/html/rendering/audio_renderer.rb +38 -0
- data/lib/html2rss/html/rendering/description_builder.rb +89 -0
- data/lib/html2rss/html/rendering/image_renderer.rb +43 -0
- data/lib/html2rss/html/rendering/media_renderer.rb +39 -0
- data/lib/html2rss/html/rendering/pdf_renderer.rb +36 -0
- data/lib/html2rss/html/rendering/video_renderer.rb +38 -0
- data/lib/html2rss/html/rendering.rb +25 -0
- data/lib/html2rss/html.rb +8 -0
- data/lib/html2rss/request_service/blocked_surface.rb +67 -0
- data/lib/html2rss/request_service/botasaurus_contract.rb +33 -9
- data/lib/html2rss/request_service/botasaurus_strategy.rb +21 -30
- data/lib/html2rss/request_service/browserless_strategy.rb +2 -23
- data/lib/html2rss/request_service/budget.rb +97 -18
- data/lib/html2rss/request_service/context.rb +1 -3
- data/lib/html2rss/request_service/faraday_strategy.rb +12 -26
- data/lib/html2rss/request_service/local_file_strategy.rb +12 -4
- data/lib/html2rss/request_service/network_guard.rb +125 -0
- data/lib/html2rss/request_service/policy.rb +10 -82
- data/lib/html2rss/request_service/puppet_commander/navigation_guards.rb +148 -0
- data/lib/html2rss/request_service/puppet_commander/preload_runner.rb +86 -0
- data/lib/html2rss/request_service/puppet_commander.rb +22 -191
- data/lib/html2rss/request_service/response.rb +9 -1
- data/lib/html2rss/request_service/response_guard.rb +1 -1
- data/lib/html2rss/request_service/strategy.rb +123 -5
- data/lib/html2rss/request_service.rb +7 -2
- data/lib/html2rss/request_session/pager/base.rb +83 -0
- data/lib/html2rss/request_session/pager/custom_selector.rb +39 -0
- data/lib/html2rss/request_session/pager/json_cursor.rb +78 -0
- data/lib/html2rss/request_session/pager/offset.rb +48 -0
- data/lib/html2rss/request_session/pager/rel_next.rb +23 -0
- data/lib/html2rss/request_session/pager/url_template.rb +48 -0
- data/lib/html2rss/request_session/pager.rb +75 -0
- data/lib/html2rss/request_session.rb +29 -16
- data/lib/html2rss/selectors/item_scope.rb +30 -0
- data/lib/html2rss/selectors/post_processors/base.rb +1 -1
- data/lib/html2rss/selectors/post_processors/markdown_to_html.rb +0 -1
- data/lib/html2rss/selectors/post_processors/sanitize_html.rb +0 -2
- data/lib/html2rss/selectors/post_processors/template.rb +7 -3
- data/lib/html2rss/selectors.rb +84 -64
- data/lib/html2rss/status.rb +132 -0
- data/lib/html2rss/version.rb +1 -3
- data/lib/html2rss.rb +34 -22
- data/schema/html2rss-config.schema.json +109 -9
- metadata +71 -43
- data/lib/html2rss/auto_source/scraper/html/class_clustering.rb +0 -196
- data/lib/html2rss/auto_source/scraper/link_heuristics.rb +0 -401
- data/lib/html2rss/auto_source/scraper/schema/list_item.rb +0 -28
- data/lib/html2rss/auto_source/scraper/semantic_html/anchor_selector.rb +0 -49
- data/lib/html2rss/blocked_surface.rb +0 -65
- data/lib/html2rss/category_extractor.rb +0 -116
- data/lib/html2rss/config/class_methods.rb +0 -183
- data/lib/html2rss/html_extractor/date_extractor.rb +0 -20
- data/lib/html2rss/html_extractor/enclosure_extractor.rb +0 -91
- data/lib/html2rss/html_extractor/heading_extractor.rb +0 -50
- data/lib/html2rss/html_extractor/id_generator.rb +0 -67
- data/lib/html2rss/html_extractor/image_extractor.rb +0 -58
- data/lib/html2rss/html_extractor/list_candidates.rb +0 -88
- data/lib/html2rss/html_extractor/semantic_anchor_candidates.rb +0 -260
- data/lib/html2rss/html_extractor/semantic_containers.rb +0 -69
- data/lib/html2rss/html_extractor/text_extractor.rb +0 -77
- data/lib/html2rss/html_extractor.rb +0 -190
- data/lib/html2rss/html_navigator.rb +0 -71
- data/lib/html2rss/json_feed_builder/item.rb +0 -94
- data/lib/html2rss/json_feed_builder.rb +0 -59
- data/lib/html2rss/rendering/audio_renderer.rb +0 -36
- data/lib/html2rss/rendering/description_builder.rb +0 -87
- data/lib/html2rss/rendering/image_renderer.rb +0 -41
- data/lib/html2rss/rendering/media_renderer.rb +0 -37
- data/lib/html2rss/rendering/pdf_renderer.rb +0 -34
- data/lib/html2rss/rendering/video_renderer.rb +0 -36
- data/lib/html2rss/rendering.rb +0 -23
- data/lib/html2rss/request_controls.rb +0 -133
- data/lib/html2rss/request_session/rel_next_pager.rb +0 -70
- data/lib/html2rss/request_session/runtime_input.rb +0 -71
- data/lib/html2rss/request_session/runtime_policy.rb +0 -84
- data/lib/html2rss/rss_builder/article.rb +0 -208
- data/lib/html2rss/rss_builder/channel.rb +0 -118
- data/lib/html2rss/rss_builder/stylesheet.rb +0 -63
- data/lib/html2rss/rss_builder.rb +0 -101
|
@@ -1,71 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Html2rss
|
|
4
|
-
class RequestSession
|
|
5
|
-
##
|
|
6
|
-
# Carries the runtime request inputs needed to build a RequestSession.
|
|
7
|
-
class RuntimeInput
|
|
8
|
-
##
|
|
9
|
-
# @param config [Html2rss::Config] validated feed config
|
|
10
|
-
# @return [RuntimeInput] runtime request inputs derived from the config
|
|
11
|
-
def self.from_config(config)
|
|
12
|
-
new(
|
|
13
|
-
url: config.url,
|
|
14
|
-
headers: config.headers,
|
|
15
|
-
request: config.request,
|
|
16
|
-
strategy: config.strategy,
|
|
17
|
-
request_policy: RuntimePolicy.from_config(config)
|
|
18
|
-
)
|
|
19
|
-
end
|
|
20
|
-
|
|
21
|
-
##
|
|
22
|
-
# @param url [String, Html2rss::Url] initial request URL
|
|
23
|
-
# @param headers [Hash] normalized request headers
|
|
24
|
-
# @param request [Hash] validated request options for strategies
|
|
25
|
-
# @param strategy [Symbol] request strategy to use for the session
|
|
26
|
-
# @param request_policy [RequestService::Policy] request policy for the session
|
|
27
|
-
def initialize(url:, headers:, request:, strategy:, request_policy:)
|
|
28
|
-
@url = Html2rss::Url.from_absolute(url)
|
|
29
|
-
@headers = normalize_headers(headers).freeze
|
|
30
|
-
@request = normalize_request(request).freeze
|
|
31
|
-
@strategy = strategy
|
|
32
|
-
@request_policy = request_policy
|
|
33
|
-
freeze
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
##
|
|
37
|
-
# @return [Html2rss::Url] initial request URL
|
|
38
|
-
attr_reader :url
|
|
39
|
-
|
|
40
|
-
##
|
|
41
|
-
# @return [Hash] normalized request headers
|
|
42
|
-
attr_reader :headers
|
|
43
|
-
|
|
44
|
-
##
|
|
45
|
-
# @return [Hash] validated request options for strategies
|
|
46
|
-
attr_reader :request
|
|
47
|
-
|
|
48
|
-
##
|
|
49
|
-
# @return [Symbol] request strategy to use for the session
|
|
50
|
-
attr_reader :strategy
|
|
51
|
-
|
|
52
|
-
##
|
|
53
|
-
# @return [RequestService::Policy] policy derived from the runtime request inputs
|
|
54
|
-
attr_reader :request_policy
|
|
55
|
-
|
|
56
|
-
private
|
|
57
|
-
|
|
58
|
-
def normalize_headers(headers)
|
|
59
|
-
headers.to_h do |key, value|
|
|
60
|
-
[key.to_s, value]
|
|
61
|
-
end
|
|
62
|
-
end
|
|
63
|
-
|
|
64
|
-
def normalize_request(request)
|
|
65
|
-
normalized = HashUtil.deep_symbolize_keys(request, context: 'request')
|
|
66
|
-
HashUtil.assert_symbol_keys!(normalized, context: 'request')
|
|
67
|
-
normalized
|
|
68
|
-
end
|
|
69
|
-
end
|
|
70
|
-
end
|
|
71
|
-
end
|
|
@@ -1,84 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Html2rss
|
|
4
|
-
class RequestSession
|
|
5
|
-
##
|
|
6
|
-
# Builds the runtime request policy for a feed run.
|
|
7
|
-
class RuntimePolicy
|
|
8
|
-
##
|
|
9
|
-
# @param config [Html2rss::Config] validated feed config
|
|
10
|
-
# @return [Html2rss::RequestService::Policy] request policy derived from runtime config
|
|
11
|
-
def self.from_config(config)
|
|
12
|
-
RequestService::Policy.new(
|
|
13
|
-
max_requests: effective_max_requests_for(config),
|
|
14
|
-
max_redirects: config.max_redirects,
|
|
15
|
-
total_timeout_seconds: config.total_timeout_seconds || RequestService::Policy::DEFAULTS[:total_timeout_seconds]
|
|
16
|
-
)
|
|
17
|
-
end
|
|
18
|
-
|
|
19
|
-
class << self
|
|
20
|
-
private
|
|
21
|
-
|
|
22
|
-
def effective_max_requests_for(config)
|
|
23
|
-
return config.max_requests if config.explicit_max_requests?
|
|
24
|
-
|
|
25
|
-
[baseline_request_budget_for(config), config.max_requests].max
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
# Reserve enough budget for the initial request plus predictable follow-ups
|
|
29
|
-
# that the top-level pipeline may trigger during a normal feed build.
|
|
30
|
-
def baseline_request_budget_for(config)
|
|
31
|
-
1 + pagination_follow_up_budget_for(config) +
|
|
32
|
-
known_auto_source_follow_up_budget_for(config) +
|
|
33
|
-
auto_strategy_fallback_budget_for(config) +
|
|
34
|
-
browserless_preload_budget_for(config)
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
def auto_strategy_fallback_budget_for(config)
|
|
38
|
-
return 0 unless config.strategy == :auto
|
|
39
|
-
|
|
40
|
-
[FeedPipeline::AutoFallback::CHAIN.size - 1, 0].max
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
def pagination_follow_up_budget_for(config)
|
|
44
|
-
[config.selectors&.dig(:items, :pagination, :max_pages).to_i - 1, 0].max
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
def known_auto_source_follow_up_budget_for(config)
|
|
48
|
-
config.auto_source&.dig(:scraper, :wordpress_api, :enabled) ? 1 : 0
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
def browserless_preload_budget_for(config)
|
|
52
|
-
preload = config.request.dig(:browserless, :preload)
|
|
53
|
-
return 0 unless preload
|
|
54
|
-
|
|
55
|
-
top_level_preload_wait_budget(preload) +
|
|
56
|
-
click_selector_preload_budget(preload) +
|
|
57
|
-
scroll_preload_budget(preload)
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
def top_level_preload_wait_budget(preload)
|
|
61
|
-
preload[:wait_after_ms] ? 2 : 0
|
|
62
|
-
end
|
|
63
|
-
|
|
64
|
-
def click_selector_preload_budget(preload)
|
|
65
|
-
preload.fetch(:click_selectors, []).sum { preload_action_budget(_1, :max_clicks) }
|
|
66
|
-
end
|
|
67
|
-
|
|
68
|
-
def scroll_preload_budget(preload)
|
|
69
|
-
scroll = preload[:scroll_down]
|
|
70
|
-
return 0 unless scroll
|
|
71
|
-
|
|
72
|
-
preload_action_budget(scroll, :iterations)
|
|
73
|
-
end
|
|
74
|
-
|
|
75
|
-
def preload_action_budget(config, count_key)
|
|
76
|
-
action_count = config.fetch(count_key, 1)
|
|
77
|
-
wait_budget = config[:wait_after_ms] ? action_count : 0
|
|
78
|
-
|
|
79
|
-
action_count + wait_budget
|
|
80
|
-
end
|
|
81
|
-
end
|
|
82
|
-
end
|
|
83
|
-
end
|
|
84
|
-
end
|
|
@@ -1,208 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require 'zlib'
|
|
4
|
-
require 'sanitize'
|
|
5
|
-
require 'nokogiri'
|
|
6
|
-
|
|
7
|
-
module Html2rss
|
|
8
|
-
class RssBuilder
|
|
9
|
-
##
|
|
10
|
-
# Article is a simple data object representing an article extracted from a page.
|
|
11
|
-
# It is enumerable and responds to all keys specified in PROVIDED_KEYS.
|
|
12
|
-
# rubocop:disable Metrics/ClassLength
|
|
13
|
-
class Article
|
|
14
|
-
include Enumerable
|
|
15
|
-
include Comparable
|
|
16
|
-
|
|
17
|
-
# Allowed article attributes accepted by the value object constructor.
|
|
18
|
-
PROVIDED_KEYS = %i[id title description url image author guid published_at enclosures categories scraper].freeze
|
|
19
|
-
# Separator used to build deterministic deduplication fingerprints.
|
|
20
|
-
DEDUP_FINGERPRINT_SEPARATOR = '#!/'
|
|
21
|
-
# Sentinel object used to pre-initialize instance variables in the constructor.
|
|
22
|
-
# This ensures all Article instances share the exact same object shape (Ruby 3.3+ optimization),
|
|
23
|
-
# preventing performance warnings and slower instance variable access due to shape transitions
|
|
24
|
-
# when attributes are lazily/conditionally accessed in different sequences.
|
|
25
|
-
NOT_SET = Object.new.freeze
|
|
26
|
-
|
|
27
|
-
# @param options [Hash{Symbol => String}]
|
|
28
|
-
# @option options [String] :id stable article identifier
|
|
29
|
-
# @option options [String] :title article title
|
|
30
|
-
# @option options [String] :description article description/content
|
|
31
|
-
# @option options [String, Html2rss::Url] :url canonical article URL
|
|
32
|
-
# @option options [String, Html2rss::Url] :image image URL for fallback enclosure rendering
|
|
33
|
-
# @option options [String] :author author name
|
|
34
|
-
# @option options [String] :guid explicit GUID override
|
|
35
|
-
# @option options [String, Time, DateTime] :published_at publication timestamp
|
|
36
|
-
# @option options [Array<Hash{Symbol => Object}>] :enclosures enclosure attribute hashes
|
|
37
|
-
# @option options [Array<String>] :categories category labels
|
|
38
|
-
# @option options [Class] :scraper scraper class that produced the article
|
|
39
|
-
def initialize(**options)
|
|
40
|
-
@to_h = options.each_with_object({}) { |(k, v), h| h[k] = v.freeze if v }.freeze
|
|
41
|
-
|
|
42
|
-
@description = @url = @image = @guid = @enclosures = @enclosure = @categories = @published_at = NOT_SET
|
|
43
|
-
|
|
44
|
-
return unless (unknown_keys = options.keys - PROVIDED_KEYS).any?
|
|
45
|
-
|
|
46
|
-
Log.warn "Article: unknown keys found: #{unknown_keys.join(', ')}"
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
# Checks if the article is valid based on the presence of URL, ID, and either title or description.
|
|
50
|
-
# @return [Boolean] True if the article is valid, otherwise false.
|
|
51
|
-
def valid?
|
|
52
|
-
!url.to_s.empty? && (!title.to_s.empty? || !description.to_s.empty?) && !id.to_s.empty?
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
# @yield [key, value]
|
|
56
|
-
# @return [Enumerator] if no block is given
|
|
57
|
-
def each
|
|
58
|
-
return enum_for(:each) unless block_given?
|
|
59
|
-
|
|
60
|
-
PROVIDED_KEYS.each { |key| yield(key, public_send(key)) }
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
# @return [String, nil] stable article identifier
|
|
64
|
-
def id = blank_string_to_nil(@to_h[:id])
|
|
65
|
-
|
|
66
|
-
# @return [String, nil] article title
|
|
67
|
-
def title = blank_string_to_nil(@to_h[:title])
|
|
68
|
-
|
|
69
|
-
# @return [String] rendered article description
|
|
70
|
-
def description
|
|
71
|
-
return @description unless @description == NOT_SET
|
|
72
|
-
|
|
73
|
-
@description = Rendering::DescriptionBuilder.new(
|
|
74
|
-
base: @to_h[:description],
|
|
75
|
-
title:,
|
|
76
|
-
url:,
|
|
77
|
-
enclosures:,
|
|
78
|
-
image:
|
|
79
|
-
).call
|
|
80
|
-
end
|
|
81
|
-
|
|
82
|
-
# @return [Url, nil]
|
|
83
|
-
def url
|
|
84
|
-
return @url unless @url == NOT_SET
|
|
85
|
-
|
|
86
|
-
@url = Url.sanitize(@to_h[:url])
|
|
87
|
-
end
|
|
88
|
-
|
|
89
|
-
# @return [Url, nil]
|
|
90
|
-
def image
|
|
91
|
-
return @image unless @image == NOT_SET
|
|
92
|
-
|
|
93
|
-
@image = Url.sanitize(@to_h[:image])
|
|
94
|
-
end
|
|
95
|
-
|
|
96
|
-
# @return [String, nil]
|
|
97
|
-
def author = blank_string_to_nil(@to_h[:author])
|
|
98
|
-
|
|
99
|
-
# Generates a unique identifier based on the URL and ID using CRC32.
|
|
100
|
-
# @return [String]
|
|
101
|
-
def guid
|
|
102
|
-
return @guid unless @guid == NOT_SET
|
|
103
|
-
|
|
104
|
-
@guid = Zlib.crc32(fetch_guid).to_s(36).encode('utf-8')
|
|
105
|
-
end
|
|
106
|
-
|
|
107
|
-
##
|
|
108
|
-
# Returns a deterministic fingerprint used to detect duplicate articles.
|
|
109
|
-
#
|
|
110
|
-
# @return [String, Integer]
|
|
111
|
-
def deduplication_fingerprint
|
|
112
|
-
dedup_from_url || dedup_from_id || dedup_from_guid || hash
|
|
113
|
-
end
|
|
114
|
-
|
|
115
|
-
# @return [Array<Html2rss::RssBuilder::Enclosure>] normalized enclosure objects
|
|
116
|
-
def enclosures
|
|
117
|
-
return @enclosures unless @enclosures == NOT_SET
|
|
118
|
-
|
|
119
|
-
@enclosures = Array(@to_h[:enclosures])
|
|
120
|
-
.map { |enclosure| Html2rss::RssBuilder::Enclosure.new(**enclosure) }
|
|
121
|
-
end
|
|
122
|
-
|
|
123
|
-
# @return [Html2rss::RssBuilder::Enclosure, nil]
|
|
124
|
-
def enclosure
|
|
125
|
-
return @enclosure unless @enclosure == NOT_SET
|
|
126
|
-
|
|
127
|
-
@enclosure = case (object = @to_h[:enclosures]&.first)
|
|
128
|
-
when Hash
|
|
129
|
-
Html2rss::RssBuilder::Enclosure.new(**object)
|
|
130
|
-
when nil
|
|
131
|
-
Html2rss::RssBuilder::Enclosure.new(url: image) if image
|
|
132
|
-
else
|
|
133
|
-
Log.warn "Article: unknown enclosure type: #{object.class}"
|
|
134
|
-
nil
|
|
135
|
-
end
|
|
136
|
-
end
|
|
137
|
-
|
|
138
|
-
# @return [Array<String>] normalized, unique category names
|
|
139
|
-
def categories
|
|
140
|
-
return @categories unless @categories == NOT_SET
|
|
141
|
-
|
|
142
|
-
@categories = @to_h[:categories].dup.to_a.tap do |categories|
|
|
143
|
-
categories.map! { |category| category.to_s.strip }
|
|
144
|
-
categories.reject!(&:empty?)
|
|
145
|
-
categories.uniq!
|
|
146
|
-
end
|
|
147
|
-
end
|
|
148
|
-
|
|
149
|
-
# Parses and returns the published_at time.
|
|
150
|
-
# @return [DateTime, nil]
|
|
151
|
-
def published_at
|
|
152
|
-
return @published_at unless @published_at == NOT_SET
|
|
153
|
-
|
|
154
|
-
string = @to_h[:published_at].to_s.strip
|
|
155
|
-
@published_at = string.empty? ? nil : DateTime.parse(string)
|
|
156
|
-
rescue ArgumentError
|
|
157
|
-
@published_at = nil
|
|
158
|
-
end
|
|
159
|
-
|
|
160
|
-
# @return [Class, nil] scraper class that produced this article
|
|
161
|
-
def scraper
|
|
162
|
-
@to_h[:scraper]
|
|
163
|
-
end
|
|
164
|
-
|
|
165
|
-
# @param other [Object] value compared against this article
|
|
166
|
-
# @return [Integer, nil] comparison result for compatible Article values
|
|
167
|
-
def <=>(other)
|
|
168
|
-
return nil unless other.is_a?(Article)
|
|
169
|
-
|
|
170
|
-
0 if other.all? { |key, value| value == public_send(key) ? public_send(key) <=> value : false }
|
|
171
|
-
end
|
|
172
|
-
|
|
173
|
-
private
|
|
174
|
-
|
|
175
|
-
def dedup_from_url
|
|
176
|
-
return unless (value = url)
|
|
177
|
-
|
|
178
|
-
[value.to_s, id].compact.join(DEDUP_FINGERPRINT_SEPARATOR)
|
|
179
|
-
end
|
|
180
|
-
|
|
181
|
-
def dedup_from_id
|
|
182
|
-
return if id.to_s.empty?
|
|
183
|
-
|
|
184
|
-
id
|
|
185
|
-
end
|
|
186
|
-
|
|
187
|
-
def dedup_from_guid
|
|
188
|
-
value = guid
|
|
189
|
-
return if value.to_s.empty?
|
|
190
|
-
|
|
191
|
-
[value, title, description].compact.join(DEDUP_FINGERPRINT_SEPARATOR)
|
|
192
|
-
end
|
|
193
|
-
|
|
194
|
-
def fetch_guid
|
|
195
|
-
guid = @to_h[:guid].map { |s| s.to_s.strip }.reject(&:empty?).join if @to_h[:guid].is_a?(Array)
|
|
196
|
-
|
|
197
|
-
guid || [url, id].join('#!/')
|
|
198
|
-
end
|
|
199
|
-
|
|
200
|
-
def blank_string_to_nil(value)
|
|
201
|
-
return if value.is_a?(String) && value.strip.empty?
|
|
202
|
-
|
|
203
|
-
value
|
|
204
|
-
end
|
|
205
|
-
end
|
|
206
|
-
# rubocop:enable Metrics/ClassLength
|
|
207
|
-
end
|
|
208
|
-
end
|
|
@@ -1,118 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Html2rss
|
|
4
|
-
class RssBuilder
|
|
5
|
-
##
|
|
6
|
-
# Extracts channel information from
|
|
7
|
-
# 1. the HTML document's <head>.
|
|
8
|
-
# 2. the HTTP response
|
|
9
|
-
class Channel
|
|
10
|
-
# Fallback RSS ttl (in minutes) when no cache directives are present.
|
|
11
|
-
DEFAULT_TTL_IN_MINUTES = 360
|
|
12
|
-
# Description template used when no explicit or discovered description exists.
|
|
13
|
-
DEFAULT_DESCRIPTION_TEMPLATE = 'Latest items from %<url>s'
|
|
14
|
-
|
|
15
|
-
##
|
|
16
|
-
# @param response [Html2rss::RequestService::Response]
|
|
17
|
-
# @param overrides [Hash{Symbol => String}] optional overrides for channel attributes
|
|
18
|
-
def initialize(response, overrides: {})
|
|
19
|
-
@response = response
|
|
20
|
-
@overrides = overrides
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
# @return [String] channel title derived from overrides, document title, or URL
|
|
24
|
-
def title
|
|
25
|
-
@title ||= fetch_title
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
# @return [Html2rss::Url] canonical channel URL
|
|
29
|
-
def url = @url ||= Html2rss::Url.from_absolute(@response.url)
|
|
30
|
-
|
|
31
|
-
# @return [String] channel description text
|
|
32
|
-
def description
|
|
33
|
-
return overrides[:description] unless overrides[:description].to_s.empty?
|
|
34
|
-
|
|
35
|
-
description = parsed_body.at_css('meta[name="description"]')&.[]('content') if html_response?
|
|
36
|
-
|
|
37
|
-
return format(DEFAULT_DESCRIPTION_TEMPLATE, url:) if description.to_s.empty?
|
|
38
|
-
|
|
39
|
-
description
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
# @return [Integer] cache time-to-live in minutes
|
|
43
|
-
def ttl
|
|
44
|
-
calculated = if overrides[:ttl]
|
|
45
|
-
overrides[:ttl].to_i
|
|
46
|
-
elsif (max_age = headers['cache-control']&.match(/max-age=(\d+)/)&.[](1))
|
|
47
|
-
max_age.to_i.fdiv(60).ceil
|
|
48
|
-
else
|
|
49
|
-
DEFAULT_TTL_IN_MINUTES
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
min_ttl = Html2rss.configuration.min_ttl
|
|
53
|
-
min_ttl ? [calculated, min_ttl].max : calculated
|
|
54
|
-
end
|
|
55
|
-
|
|
56
|
-
# @return [String, nil] ISO-like language code when available
|
|
57
|
-
def language
|
|
58
|
-
return overrides[:language] if overrides[:language]
|
|
59
|
-
|
|
60
|
-
if (language_code = headers['content-language']&.match(/^([a-z]{2})/))
|
|
61
|
-
return language_code[0]
|
|
62
|
-
end
|
|
63
|
-
|
|
64
|
-
return unless html_response?
|
|
65
|
-
|
|
66
|
-
parsed_body['lang'] || parsed_body.at_css('[lang]')&.[]('lang')
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
# @return [String, nil] channel author metadata
|
|
70
|
-
def author
|
|
71
|
-
return overrides[:author] if overrides[:author]
|
|
72
|
-
|
|
73
|
-
return unless html_response?
|
|
74
|
-
|
|
75
|
-
parsed_body.at_css('meta[name="author"]')&.[]('content')
|
|
76
|
-
end
|
|
77
|
-
|
|
78
|
-
# @return [String, Time] source last-modified timestamp or current time fallback
|
|
79
|
-
def last_build_date = headers['last-modified'] || Time.now
|
|
80
|
-
|
|
81
|
-
# @return [Html2rss::Url, nil] channel image URL
|
|
82
|
-
def image
|
|
83
|
-
return overrides[:image] if overrides[:image]
|
|
84
|
-
|
|
85
|
-
return unless html_response?
|
|
86
|
-
|
|
87
|
-
if (image_url = parsed_body.at_css('meta[property="og:image"]')&.[]('content'))
|
|
88
|
-
Url.sanitize(image_url)
|
|
89
|
-
end
|
|
90
|
-
end
|
|
91
|
-
|
|
92
|
-
private
|
|
93
|
-
|
|
94
|
-
attr_reader :overrides
|
|
95
|
-
|
|
96
|
-
def parsed_body = @parsed_body ||= @response.parsed_body
|
|
97
|
-
def headers = @headers ||= @response.headers
|
|
98
|
-
def html_response? = @html_response ||= @response.html_response?
|
|
99
|
-
|
|
100
|
-
def fetch_title
|
|
101
|
-
override_title = overrides[:title]
|
|
102
|
-
return override_title if override_title
|
|
103
|
-
return parsed_title if parsed_title
|
|
104
|
-
|
|
105
|
-
url.channel_titleized
|
|
106
|
-
end
|
|
107
|
-
|
|
108
|
-
def parsed_title
|
|
109
|
-
return unless html_response?
|
|
110
|
-
|
|
111
|
-
title = parsed_body.at_css('head > title')&.text.to_s
|
|
112
|
-
return if title.empty?
|
|
113
|
-
|
|
114
|
-
title.gsub(/\s+/, ' ').strip
|
|
115
|
-
end
|
|
116
|
-
end
|
|
117
|
-
end
|
|
118
|
-
end
|
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Html2rss
|
|
4
|
-
class RssBuilder
|
|
5
|
-
##
|
|
6
|
-
# Represents a stylesheet.
|
|
7
|
-
class Stylesheet
|
|
8
|
-
class << self
|
|
9
|
-
##
|
|
10
|
-
# Adds the stylesheet XML tags to the RSS.
|
|
11
|
-
#
|
|
12
|
-
# @param maker [RSS::Maker::RSS20] RSS maker object.
|
|
13
|
-
# @param stylesheets [Array<Html2rss::RssBuilder::Stylesheet>] Array of stylesheet configurations.
|
|
14
|
-
# @return [nil]
|
|
15
|
-
def add(maker, stylesheets)
|
|
16
|
-
stylesheets.each do |stylesheet|
|
|
17
|
-
add_stylesheet(maker, stylesheet)
|
|
18
|
-
end
|
|
19
|
-
end
|
|
20
|
-
|
|
21
|
-
private
|
|
22
|
-
|
|
23
|
-
##
|
|
24
|
-
# Adds a single Stylesheet to the RSS.
|
|
25
|
-
#
|
|
26
|
-
# @param maker [RSS::Maker::RSS20] RSS maker object.
|
|
27
|
-
# @param stylesheet [Html2rss::RssBuilder::Stylesheet] Stylesheet configuration.
|
|
28
|
-
# @return [nil]
|
|
29
|
-
def add_stylesheet(maker, stylesheet)
|
|
30
|
-
maker.xml_stylesheets.new_xml_stylesheet do |xss|
|
|
31
|
-
xss.href = stylesheet.href
|
|
32
|
-
xss.type = stylesheet.type
|
|
33
|
-
xss.media = stylesheet.media
|
|
34
|
-
end
|
|
35
|
-
end
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
# Allowed stylesheet MIME types for RSS processing instructions.
|
|
39
|
-
TYPES = ['text/css', 'text/xsl'].to_set.freeze
|
|
40
|
-
|
|
41
|
-
# @param href [String] stylesheet URL
|
|
42
|
-
# @param type [String] MIME type (`text/css` or `text/xsl`)
|
|
43
|
-
# @param media [String] media query hint for the stylesheet
|
|
44
|
-
def initialize(href:, type:, media: 'all')
|
|
45
|
-
raise ArgumentError, 'stylesheet.href must be a String' unless href.is_a?(String)
|
|
46
|
-
raise ArgumentError, 'stylesheet.type invalid' unless TYPES.include?(type)
|
|
47
|
-
raise ArgumentError, 'stylesheet.media must be a String' unless media.is_a?(String)
|
|
48
|
-
|
|
49
|
-
@href = href
|
|
50
|
-
@type = type
|
|
51
|
-
@media = media
|
|
52
|
-
end
|
|
53
|
-
attr_reader :href, :type, :media
|
|
54
|
-
|
|
55
|
-
# @return [String] the XML representation of the stylesheet
|
|
56
|
-
def to_xml
|
|
57
|
-
<<~XML
|
|
58
|
-
<?xml-stylesheet href="#{href}" type="#{type}" media="#{media}"?>
|
|
59
|
-
XML
|
|
60
|
-
end
|
|
61
|
-
end
|
|
62
|
-
end
|
|
63
|
-
end
|
data/lib/html2rss/rss_builder.rb
DELETED
|
@@ -1,101 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require 'rss'
|
|
4
|
-
|
|
5
|
-
module Html2rss
|
|
6
|
-
##
|
|
7
|
-
# Builds an RSS Feed by providing channel, articles and stylesheets.
|
|
8
|
-
class RssBuilder
|
|
9
|
-
class << self
|
|
10
|
-
# @param article [Html2rss::RssBuilder::Article] source article
|
|
11
|
-
# @param item_maker [RSS::Maker::RSS20::ItemsBase::ItemBase] RSS item builder
|
|
12
|
-
# @return [void]
|
|
13
|
-
def add_item(article, item_maker)
|
|
14
|
-
add_item_string_values(article, item_maker)
|
|
15
|
-
add_item_categories(article, item_maker)
|
|
16
|
-
Enclosure.add(article.enclosure, item_maker)
|
|
17
|
-
add_item_guid(article, item_maker)
|
|
18
|
-
end
|
|
19
|
-
|
|
20
|
-
private
|
|
21
|
-
|
|
22
|
-
def add_item_string_values(article, item_maker)
|
|
23
|
-
%i[title description author].each do |attr|
|
|
24
|
-
next unless (value = article.send(attr))
|
|
25
|
-
next if value.empty?
|
|
26
|
-
|
|
27
|
-
item_maker.send(:"#{attr}=", value)
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
item_maker.link = article.url.to_s if article.url
|
|
31
|
-
item_maker.pubDate = article.published_at&.rfc2822
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
def add_item_categories(article, item_maker)
|
|
35
|
-
article.categories.each { |category| item_maker.categories.new_category.content = category }
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
def add_item_guid(article, item_maker)
|
|
39
|
-
item_maker.guid.tap do |guid|
|
|
40
|
-
guid.content = article.guid
|
|
41
|
-
guid.isPermaLink = false
|
|
42
|
-
end
|
|
43
|
-
end
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
##
|
|
47
|
-
# @param channel [Html2rss::RssBuilder::Channel] The channel information for the RSS feed.
|
|
48
|
-
# @param articles [Array<Html2rss::RssBuilder::Article>] The list of articles to include in the RSS feed.
|
|
49
|
-
# @param stylesheets [Array<Hash>] An optional array of stylesheet configurations.
|
|
50
|
-
def initialize(channel:, articles:, stylesheets: [])
|
|
51
|
-
@channel = channel
|
|
52
|
-
@articles = articles
|
|
53
|
-
@stylesheets = stylesheets
|
|
54
|
-
end
|
|
55
|
-
|
|
56
|
-
# @return [RSS::Rss] RSS 2.0 document instance
|
|
57
|
-
def call
|
|
58
|
-
RSS::Maker.make('2.0') do |maker|
|
|
59
|
-
Stylesheet.add(maker, stylesheets)
|
|
60
|
-
|
|
61
|
-
make_channel(maker.channel)
|
|
62
|
-
make_items(maker)
|
|
63
|
-
end
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
private
|
|
67
|
-
|
|
68
|
-
attr_reader :channel, :articles
|
|
69
|
-
|
|
70
|
-
def stylesheets
|
|
71
|
-
@stylesheets.map { |style| Stylesheet.new(**style) }
|
|
72
|
-
end
|
|
73
|
-
|
|
74
|
-
def make_channel(maker)
|
|
75
|
-
%i[language title description ttl].each do |key|
|
|
76
|
-
maker.public_send(:"#{key}=", channel.public_send(key))
|
|
77
|
-
end
|
|
78
|
-
|
|
79
|
-
maker.link = channel.url.to_s
|
|
80
|
-
maker.generator = generator
|
|
81
|
-
maker.updated = channel.last_build_date
|
|
82
|
-
end
|
|
83
|
-
|
|
84
|
-
def make_items(maker)
|
|
85
|
-
articles.each do |article|
|
|
86
|
-
maker.items.new_item { |item_maker| self.class.add_item(article, item_maker) }
|
|
87
|
-
end
|
|
88
|
-
end
|
|
89
|
-
|
|
90
|
-
def generator
|
|
91
|
-
scraper_namespace_regex = /(?<namespace>Html2rss|Scraper)::/
|
|
92
|
-
|
|
93
|
-
scraper_counts = articles.flat_map(&:scraper).tally.map do |klass, count|
|
|
94
|
-
scraper_name = klass.to_s.gsub(scraper_namespace_regex, '')
|
|
95
|
-
"#{scraper_name} (#{count})"
|
|
96
|
-
end
|
|
97
|
-
|
|
98
|
-
"html2rss V. #{Html2rss::VERSION} (scrapers: #{scraper_counts.join(', ')})"
|
|
99
|
-
end
|
|
100
|
-
end
|
|
101
|
-
end
|