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
|
@@ -22,9 +22,12 @@ module Html2rss
|
|
|
22
22
|
# Ordered scraper classes considered during auto-source extraction.
|
|
23
23
|
SCRAPERS = [
|
|
24
24
|
WordpressApi,
|
|
25
|
+
Sitemap,
|
|
25
26
|
Schema,
|
|
26
27
|
Microdata,
|
|
28
|
+
Microformats2,
|
|
27
29
|
JsonState,
|
|
30
|
+
MetaOembed,
|
|
28
31
|
SemanticHtml,
|
|
29
32
|
Html
|
|
30
33
|
].freeze
|
|
@@ -67,12 +70,14 @@ module Html2rss
|
|
|
67
70
|
|
|
68
71
|
##
|
|
69
72
|
# Returns an array of scraper classes that claim to find articles in the parsed body.
|
|
70
|
-
# @param parsed_body [Nokogiri::HTML::Document] The parsed HTML
|
|
73
|
+
# @param parsed_body [Nokogiri::HTML::Document] The parsed HTML document.
|
|
71
74
|
# @param opts [Hash] The options hash.
|
|
72
75
|
# @option opts [Hash] :wordpress_api scraper toggle and configuration
|
|
73
76
|
# @option opts [Hash] :schema scraper toggle and configuration
|
|
74
77
|
# @option opts [Hash] :microdata scraper toggle and configuration
|
|
78
|
+
# @option opts [Hash] :microformats2 scraper toggle and configuration
|
|
75
79
|
# @option opts [Hash] :json_state scraper toggle and configuration
|
|
80
|
+
# @option opts [Hash] :meta_oembed scraper toggle and configuration
|
|
76
81
|
# @option opts [Hash] :semantic_html scraper toggle and configuration
|
|
77
82
|
# @option opts [Hash] :html scraper toggle and configuration
|
|
78
83
|
# @return [Array<Class>] An array of scraper classes that can handle the parsed body.
|
|
@@ -86,14 +91,16 @@ module Html2rss
|
|
|
86
91
|
end
|
|
87
92
|
|
|
88
93
|
# Returns scraper instances ready for extraction.
|
|
89
|
-
# @param parsed_body [Nokogiri::HTML::Document] The parsed HTML
|
|
94
|
+
# @param parsed_body [Nokogiri::HTML::Document] The parsed HTML document.
|
|
90
95
|
# @param url [String, Html2rss::Url] The page url.
|
|
91
96
|
# @param request_session [Html2rss::RequestSession, nil] Shared follow-up session.
|
|
92
97
|
# @param opts [Hash] The options hash.
|
|
93
98
|
# @option opts [Hash] :wordpress_api scraper toggle and configuration
|
|
94
99
|
# @option opts [Hash] :schema scraper toggle and configuration
|
|
95
100
|
# @option opts [Hash] :microdata scraper toggle and configuration
|
|
101
|
+
# @option opts [Hash] :microformats2 scraper toggle and configuration
|
|
96
102
|
# @option opts [Hash] :json_state scraper toggle and configuration
|
|
103
|
+
# @option opts [Hash] :meta_oembed scraper toggle and configuration
|
|
97
104
|
# @option opts [Hash] :semantic_html scraper toggle and configuration
|
|
98
105
|
# @option opts [Hash] :html scraper toggle and configuration
|
|
99
106
|
# @return [Array<Object>] An array of scraper instances that can handle the parsed body.
|
|
@@ -139,7 +146,7 @@ module Html2rss
|
|
|
139
146
|
private_class_method :classify_no_scraper_surface
|
|
140
147
|
|
|
141
148
|
def self.blocked_surface?(parsed_body)
|
|
142
|
-
Html2rss::BlockedSurface.interstitial?(parsed_body.to_html)
|
|
149
|
+
Html2rss::RequestService::BlockedSurface.interstitial?(parsed_body.to_html)
|
|
143
150
|
end
|
|
144
151
|
private_class_method :blocked_surface?
|
|
145
152
|
|
data/lib/html2rss/auto_source.rb
CHANGED
|
@@ -22,15 +22,26 @@ module Html2rss
|
|
|
22
22
|
wordpress_api: {
|
|
23
23
|
enabled: true
|
|
24
24
|
},
|
|
25
|
+
sitemap: {
|
|
26
|
+
enabled: true,
|
|
27
|
+
min_priority: Discovery::Sitemap::DEFAULT_MIN_PRIORITY,
|
|
28
|
+
max_age_days: Discovery::Sitemap::DEFAULT_MAX_AGE_DAYS
|
|
29
|
+
},
|
|
25
30
|
schema: {
|
|
26
31
|
enabled: true
|
|
27
32
|
},
|
|
28
33
|
microdata: {
|
|
29
34
|
enabled: true
|
|
30
35
|
},
|
|
36
|
+
microformats2: {
|
|
37
|
+
enabled: true
|
|
38
|
+
},
|
|
31
39
|
json_state: {
|
|
32
40
|
enabled: true
|
|
33
41
|
},
|
|
42
|
+
meta_oembed: {
|
|
43
|
+
enabled: true
|
|
44
|
+
},
|
|
34
45
|
semantic_html: {
|
|
35
46
|
enabled: true,
|
|
36
47
|
fallback_anchorless: true
|
|
@@ -45,39 +56,23 @@ module Html2rss
|
|
|
45
56
|
cleanup: Cleanup::DEFAULT_CONFIG
|
|
46
57
|
}.freeze
|
|
47
58
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
optional(:enabled).filled(:bool)
|
|
57
|
-
end
|
|
58
|
-
optional(:json_state).hash do
|
|
59
|
-
optional(:enabled).filled(:bool)
|
|
60
|
-
end
|
|
61
|
-
optional(:semantic_html).hash do
|
|
62
|
-
optional(:enabled).filled(:bool)
|
|
63
|
-
optional(:fallback_anchorless).filled(:bool)
|
|
64
|
-
end
|
|
65
|
-
optional(:html).hash do
|
|
66
|
-
optional(:enabled).filled(:bool)
|
|
67
|
-
optional(:minimum_selector_frequency).filled(:integer, gt?: 0)
|
|
68
|
-
optional(:use_top_selectors).filled(:integer, gt?: 0)
|
|
69
|
-
optional(:fallback_anchorless).filled(:bool)
|
|
70
|
-
end
|
|
71
|
-
end.freeze
|
|
72
|
-
private_constant :SCRAPER_CONFIG
|
|
73
|
-
|
|
74
|
-
# Runtime schema used to validate auto-source config values.
|
|
75
|
-
Config = Dry::Schema.Params do
|
|
76
|
-
optional(:scraper).hash(&SCRAPER_CONFIG)
|
|
59
|
+
class << self
|
|
60
|
+
##
|
|
61
|
+
# Returns the sum of required request slots for all enabled scrapers in the config.
|
|
62
|
+
#
|
|
63
|
+
# @param config [Hash, nil] auto_source configuration hash
|
|
64
|
+
# @return [Integer] total request slots required by scrapers
|
|
65
|
+
def request_slots_for(config)
|
|
66
|
+
return 0 unless config
|
|
77
67
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
68
|
+
Scraper::SCRAPERS.sum do |scraper|
|
|
69
|
+
if config.dig(:scraper, scraper.options_key, :enabled)
|
|
70
|
+
opts = config.dig(:scraper, scraper.options_key)
|
|
71
|
+
scraper.respond_to?(:request_slots) ? scraper.request_slots(opts) : 0
|
|
72
|
+
else
|
|
73
|
+
0
|
|
74
|
+
end
|
|
75
|
+
end
|
|
81
76
|
end
|
|
82
77
|
end
|
|
83
78
|
|
|
@@ -98,7 +93,7 @@ module Html2rss
|
|
|
98
93
|
##
|
|
99
94
|
# Extracts article candidates by selecting every scraper that can explain the
|
|
100
95
|
# page shape, running those scrapers, and normalizing the resulting hashes
|
|
101
|
-
# into `
|
|
96
|
+
# into `Article` objects.
|
|
102
97
|
#
|
|
103
98
|
# The contributor-facing flow is:
|
|
104
99
|
# 1. choose scraper instances that match the page
|
|
@@ -108,7 +103,7 @@ module Html2rss
|
|
|
108
103
|
# Scrapers with expensive precomputation, such as `SemanticHtml`, keep that
|
|
109
104
|
# state on the instance so detection and extraction can reuse the same work.
|
|
110
105
|
#
|
|
111
|
-
# @return [Array<Html2rss::
|
|
106
|
+
# @return [Array<Html2rss::Article>] extracted articles
|
|
112
107
|
def articles
|
|
113
108
|
@articles ||= extract_articles
|
|
114
109
|
rescue Html2rss::AutoSource::Scraper::NoScraperFound => error
|
|
@@ -133,7 +128,7 @@ module Html2rss
|
|
|
133
128
|
|
|
134
129
|
def run_scraper(instance)
|
|
135
130
|
instance.each.map do |article_hash|
|
|
136
|
-
|
|
131
|
+
Article.new(**article_hash, scraper: instance.class)
|
|
137
132
|
end
|
|
138
133
|
end
|
|
139
134
|
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'time'
|
|
4
|
+
|
|
5
|
+
# rubocop:disable Metrics/ModuleLength -- Channel value object owns extract + freeze + Marshal
|
|
6
|
+
module Html2rss
|
|
7
|
+
##
|
|
8
|
+
# Materialized channel metadata for feed builders and {FeedResult}.
|
|
9
|
+
#
|
|
10
|
+
# Extract once via {Channel.from_response}; builders consume this value object and
|
|
11
|
+
# do not own channel extraction. Safe to Marshal (no Response / Nokogiri retained).
|
|
12
|
+
# Strings and image URL are normalized and frozen at construction (including Marshal load).
|
|
13
|
+
#
|
|
14
|
+
# @!attribute [r] title
|
|
15
|
+
# @return [String]
|
|
16
|
+
# @!attribute [r] url
|
|
17
|
+
# @return [Html2rss::Url]
|
|
18
|
+
# @!attribute [r] description
|
|
19
|
+
# @return [String]
|
|
20
|
+
# @!attribute [r] language
|
|
21
|
+
# @return [String, nil]
|
|
22
|
+
# @!attribute [r] ttl
|
|
23
|
+
# @return [Integer]
|
|
24
|
+
# @!attribute [r] last_build_date
|
|
25
|
+
# @return [Time]
|
|
26
|
+
# @!attribute [r] image
|
|
27
|
+
# @return [Html2rss::Url, nil]
|
|
28
|
+
# @!attribute [r] author
|
|
29
|
+
# @return [String, nil]
|
|
30
|
+
Channel = Data.define(:title, :url, :description, :language, :ttl, :last_build_date, :image, :author) do
|
|
31
|
+
class << self
|
|
32
|
+
##
|
|
33
|
+
# Materializes channel attributes from an HTTP response and optional overrides.
|
|
34
|
+
#
|
|
35
|
+
# @param response [Html2rss::RequestService::Response]
|
|
36
|
+
# @param overrides [Hash{Symbol => Object}] optional overrides for channel attributes
|
|
37
|
+
# @return [Html2rss::Channel]
|
|
38
|
+
def from_response(response, overrides: {})
|
|
39
|
+
new(
|
|
40
|
+
title: title_for(response, overrides),
|
|
41
|
+
url: url_for(response),
|
|
42
|
+
description: description_for(response, overrides),
|
|
43
|
+
language: language_for(response, overrides),
|
|
44
|
+
ttl: ttl_for(response, overrides),
|
|
45
|
+
last_build_date: last_build_date_for(response),
|
|
46
|
+
image: image_for(response, overrides),
|
|
47
|
+
author: author_for(response, overrides)
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
# Fallback RSS ttl (in minutes) when no cache directives are present.
|
|
54
|
+
def default_ttl_in_minutes = 360
|
|
55
|
+
|
|
56
|
+
# Description template used when no explicit or discovered description exists.
|
|
57
|
+
def default_description_template = 'Latest items from %<url>s'
|
|
58
|
+
|
|
59
|
+
def url_for(response) = Html2rss::Url.from_absolute(response.url)
|
|
60
|
+
|
|
61
|
+
def title_for(response, overrides)
|
|
62
|
+
return overrides[:title] if overrides[:title]
|
|
63
|
+
|
|
64
|
+
title = parsed_title(response)
|
|
65
|
+
return title if title
|
|
66
|
+
|
|
67
|
+
url_for(response).channel_titleized
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def parsed_title(response)
|
|
71
|
+
return unless html_response?(response)
|
|
72
|
+
|
|
73
|
+
title = parsed_body(response).at_css('head > title')&.text.to_s
|
|
74
|
+
return if title.empty?
|
|
75
|
+
|
|
76
|
+
title.gsub(/\s+/, ' ').strip
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def description_for(response, overrides)
|
|
80
|
+
return overrides[:description] unless overrides[:description].to_s.empty?
|
|
81
|
+
|
|
82
|
+
description = meta_description(response)
|
|
83
|
+
return format(default_description_template, url: url_for(response)) if description.to_s.empty?
|
|
84
|
+
|
|
85
|
+
description
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def meta_description(response)
|
|
89
|
+
return unless html_response?(response)
|
|
90
|
+
|
|
91
|
+
parsed_body(response).at_css('meta[name="description"]')&.[]('content')
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def ttl_for(response, overrides)
|
|
95
|
+
calculated = if overrides[:ttl]
|
|
96
|
+
overrides[:ttl].to_i
|
|
97
|
+
elsif (max_age = headers(response)['cache-control']&.match(/max-age=(\d+)/)&.[](1))
|
|
98
|
+
max_age.to_i.fdiv(60).ceil
|
|
99
|
+
else
|
|
100
|
+
default_ttl_in_minutes
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
min_ttl = Html2rss.defaults.min_ttl
|
|
104
|
+
min_ttl ? [calculated, min_ttl].max : calculated
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def language_for(response, overrides)
|
|
108
|
+
return overrides[:language] if overrides[:language]
|
|
109
|
+
|
|
110
|
+
if (language_code = headers(response)['content-language']&.match(/^([a-z]{2})/))
|
|
111
|
+
return language_code[0]
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
return unless html_response?(response)
|
|
115
|
+
|
|
116
|
+
parsed_body(response)['lang'] || parsed_body(response).at_css('[lang]')&.[]('lang')
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def author_for(response, overrides)
|
|
120
|
+
return overrides[:author] if overrides[:author]
|
|
121
|
+
return unless html_response?(response)
|
|
122
|
+
|
|
123
|
+
parsed_body(response).at_css('meta[name="author"]')&.[]('content')
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def last_build_date_for(response)
|
|
127
|
+
header = headers(response)['last-modified']
|
|
128
|
+
return Time.now unless header
|
|
129
|
+
|
|
130
|
+
Time.httpdate(header)
|
|
131
|
+
rescue ArgumentError
|
|
132
|
+
Time.now
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def image_for(response, overrides)
|
|
136
|
+
return overrides[:image] if overrides[:image]
|
|
137
|
+
return unless html_response?(response)
|
|
138
|
+
|
|
139
|
+
if (image_url = parsed_body(response).at_css('meta[property="og:image"]')&.[]('content'))
|
|
140
|
+
Url.sanitize(image_url)
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def parsed_body(response) = response.parsed_body
|
|
145
|
+
def headers(response) = response.headers
|
|
146
|
+
def html_response?(response) = response.html_response?
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
##
|
|
150
|
+
# @param title [String]
|
|
151
|
+
# @param url [Html2rss::Url, String]
|
|
152
|
+
# @param description [String]
|
|
153
|
+
# @param language [String, nil]
|
|
154
|
+
# @param ttl [Integer]
|
|
155
|
+
# @param last_build_date [Time, String]
|
|
156
|
+
# @param image [Html2rss::Url, String, nil]
|
|
157
|
+
# @param author [String, nil]
|
|
158
|
+
# rubocop:disable Metrics/ParameterLists -- Data.define members are the channel contract
|
|
159
|
+
def initialize(title:, url:, description:, language:, ttl:, last_build_date:, image:, author:)
|
|
160
|
+
super(
|
|
161
|
+
title: freeze_string(title),
|
|
162
|
+
url: url.is_a?(Url) ? url : Url.from_absolute(url),
|
|
163
|
+
description: freeze_string(description),
|
|
164
|
+
language: language && freeze_string(language),
|
|
165
|
+
ttl: Integer(ttl),
|
|
166
|
+
last_build_date: normalize_last_build_date(last_build_date),
|
|
167
|
+
image: normalize_image(image),
|
|
168
|
+
author: author && freeze_string(author)
|
|
169
|
+
)
|
|
170
|
+
end
|
|
171
|
+
# rubocop:enable Metrics/ParameterLists
|
|
172
|
+
|
|
173
|
+
private
|
|
174
|
+
|
|
175
|
+
def marshal_dump = [title, url, description, language, ttl, last_build_date, image, author]
|
|
176
|
+
|
|
177
|
+
def marshal_load((title, url, description, language, ttl, last_build_date, image, author))
|
|
178
|
+
initialize(title:, url:, description:, language:, ttl:, last_build_date:, image:, author:)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def freeze_string(value) = value.to_s.dup.freeze
|
|
182
|
+
|
|
183
|
+
def normalize_image(value)
|
|
184
|
+
return value if value.is_a?(Url)
|
|
185
|
+
return if value.nil?
|
|
186
|
+
|
|
187
|
+
Url.sanitize(value.to_s)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def normalize_last_build_date(value)
|
|
191
|
+
return value if value.is_a?(Time)
|
|
192
|
+
raise ArgumentError, 'last_build_date must be a Time or HTTP-date String' unless value.is_a?(String)
|
|
193
|
+
|
|
194
|
+
Time.httpdate(value)
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
# rubocop:enable Metrics/ModuleLength
|
|
198
|
+
end
|
data/lib/html2rss/cli.rb
CHANGED
|
@@ -4,21 +4,20 @@ require 'fileutils'
|
|
|
4
4
|
require 'json'
|
|
5
5
|
require 'thor'
|
|
6
6
|
|
|
7
|
-
##
|
|
8
|
-
# The Html2rss namespace / command line interface.
|
|
9
7
|
module Html2rss
|
|
10
8
|
##
|
|
11
9
|
# The Html2rss command line interface.
|
|
12
10
|
class CLI < Thor # rubocop:disable Metrics/ClassLength
|
|
13
11
|
check_unknown_options!
|
|
14
|
-
# Ordered fallback chain attempted by auto
|
|
12
|
+
# Ordered fallback chain attempted by the feed-level :auto plan.
|
|
15
13
|
#
|
|
16
14
|
# @return [Array<Symbol>]
|
|
17
15
|
AUTO_FALLBACK_CHAIN = Html2rss::FeedPipeline::AutoFallback::CHAIN.freeze
|
|
18
|
-
# Supported CLI strategy option values.
|
|
16
|
+
# Supported CLI strategy plan option values (:auto plus concrete strategies).
|
|
19
17
|
#
|
|
20
18
|
# @return [Array<String>]
|
|
21
|
-
STRATEGY_OPTION_ENUM =
|
|
19
|
+
STRATEGY_OPTION_ENUM = Html2rss::FeedPipeline::StrategyPlan.accepted_names.map(&:to_s).freeze
|
|
20
|
+
|
|
22
21
|
# User-facing strategy help text that reflects the current fallback chain.
|
|
23
22
|
#
|
|
24
23
|
# @return [String]
|
|
@@ -188,7 +187,7 @@ module Html2rss
|
|
|
188
187
|
end
|
|
189
188
|
|
|
190
189
|
def request_controls
|
|
191
|
-
Html2rss::RequestControls.new(
|
|
190
|
+
Html2rss::Config::RequestControls.new(
|
|
192
191
|
strategy: options[:strategy]&.to_sym,
|
|
193
192
|
max_redirects: options[:max_redirects],
|
|
194
193
|
max_requests: options[:max_requests],
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'dry-validation'
|
|
4
|
+
|
|
5
|
+
module Html2rss
|
|
6
|
+
class Config
|
|
7
|
+
# Runtime source of truth for validating auto-source config values.
|
|
8
|
+
AutoSourceContract = Dry::Schema.Params do # rubocop:disable Metrics/BlockLength
|
|
9
|
+
optional(:scraper).hash do # rubocop:disable Metrics/BlockLength
|
|
10
|
+
optional(:wordpress_api).hash do
|
|
11
|
+
optional(:enabled).filled(:bool)
|
|
12
|
+
end
|
|
13
|
+
optional(:sitemap).hash do
|
|
14
|
+
optional(:enabled).filled(:bool)
|
|
15
|
+
optional(:min_priority).filled(:float)
|
|
16
|
+
optional(:max_age_days).filled(:integer, gt?: 0)
|
|
17
|
+
end
|
|
18
|
+
optional(:schema).hash do
|
|
19
|
+
optional(:enabled).filled(:bool)
|
|
20
|
+
end
|
|
21
|
+
optional(:microdata).hash do
|
|
22
|
+
optional(:enabled).filled(:bool)
|
|
23
|
+
end
|
|
24
|
+
optional(:microformats2).hash do
|
|
25
|
+
optional(:enabled).filled(:bool)
|
|
26
|
+
end
|
|
27
|
+
optional(:json_state).hash do
|
|
28
|
+
optional(:enabled).filled(:bool)
|
|
29
|
+
end
|
|
30
|
+
optional(:meta_oembed).hash do
|
|
31
|
+
optional(:enabled).filled(:bool)
|
|
32
|
+
end
|
|
33
|
+
optional(:semantic_html).hash do
|
|
34
|
+
optional(:enabled).filled(:bool)
|
|
35
|
+
optional(:fallback_anchorless).filled(:bool)
|
|
36
|
+
end
|
|
37
|
+
optional(:html).hash do
|
|
38
|
+
optional(:enabled).filled(:bool)
|
|
39
|
+
optional(:minimum_selector_frequency).filled(:integer, gt?: 0)
|
|
40
|
+
optional(:use_top_selectors).filled(:integer, gt?: 0)
|
|
41
|
+
optional(:fallback_anchorless).filled(:bool)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
optional(:cleanup).hash do
|
|
46
|
+
optional(:keep_different_domain).filled(:bool)
|
|
47
|
+
optional(:min_words_title).filled(:integer, gt?: 0)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
class Config
|
|
5
|
+
##
|
|
6
|
+
# Wire provenance for request control values (explicit vs default).
|
|
7
|
+
class RequestControls
|
|
8
|
+
# Request-control keys accepted at the top level of feed config.
|
|
9
|
+
TOP_LEVEL_KEYS = %i[strategy].freeze
|
|
10
|
+
# Request-control keys accepted under the nested `request` config.
|
|
11
|
+
REQUEST_KEYS = %i[max_redirects max_requests total_timeout_seconds].freeze
|
|
12
|
+
|
|
13
|
+
##
|
|
14
|
+
# @param config [Hash{Symbol => Object}] raw config input
|
|
15
|
+
# @return [RequestControls] request controls extracted from the config hash
|
|
16
|
+
def self.from_config(config)
|
|
17
|
+
HashUtil.assert_symbol_keys!(config, context: 'config', deep: false)
|
|
18
|
+
HashUtil.assert_symbol_keys!(config[:request], context: 'config[:request]') if config[:request].is_a?(Hash)
|
|
19
|
+
|
|
20
|
+
new(
|
|
21
|
+
strategy: config[:strategy],
|
|
22
|
+
max_redirects: request_value_for(config, :max_redirects),
|
|
23
|
+
max_requests: request_value_for(config, :max_requests),
|
|
24
|
+
total_timeout_seconds: request_value_for(config, :total_timeout_seconds),
|
|
25
|
+
explicit_keys: explicit_keys_for(config)
|
|
26
|
+
)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.explicit_keys_for(config)
|
|
30
|
+
TOP_LEVEL_KEYS.filter { config.key?(_1) } +
|
|
31
|
+
REQUEST_KEYS.filter { request_key?(config, _1) }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.request_value_for(config, key)
|
|
35
|
+
request_config = config[:request]
|
|
36
|
+
return nil unless request_config.is_a?(Hash)
|
|
37
|
+
|
|
38
|
+
request_config[key]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.request_key?(config, key)
|
|
42
|
+
request_config = config[:request]
|
|
43
|
+
request_config.is_a?(Hash) && request_config.key?(key)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private_class_method :explicit_keys_for, :request_value_for, :request_key?
|
|
47
|
+
|
|
48
|
+
##
|
|
49
|
+
# @param strategy [Symbol, nil] effective request strategy
|
|
50
|
+
# @param max_redirects [Integer, nil] effective redirect limit
|
|
51
|
+
# @param max_requests [Integer, nil] effective request budget
|
|
52
|
+
# @param total_timeout_seconds [Integer, nil] effective request timeout
|
|
53
|
+
# @param explicit_keys [Array<Symbol>] controls explicitly supplied by the caller
|
|
54
|
+
def initialize(strategy: nil, max_redirects: nil, max_requests: nil,
|
|
55
|
+
total_timeout_seconds: nil, explicit_keys: [])
|
|
56
|
+
@strategy = strategy
|
|
57
|
+
@max_redirects = max_redirects
|
|
58
|
+
@max_requests = max_requests
|
|
59
|
+
@total_timeout_seconds = total_timeout_seconds
|
|
60
|
+
@explicit_keys = explicit_keys.map(&:to_sym).uniq.freeze
|
|
61
|
+
freeze
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
##
|
|
65
|
+
# @return [Symbol, nil] effective request strategy
|
|
66
|
+
attr_reader :strategy
|
|
67
|
+
|
|
68
|
+
##
|
|
69
|
+
# @return [Integer, nil] effective redirect limit
|
|
70
|
+
attr_reader :max_redirects
|
|
71
|
+
|
|
72
|
+
##
|
|
73
|
+
# @return [Integer, nil] effective request budget
|
|
74
|
+
attr_reader :max_requests
|
|
75
|
+
|
|
76
|
+
##
|
|
77
|
+
# @return [Integer, nil] effective request timeout
|
|
78
|
+
attr_reader :total_timeout_seconds
|
|
79
|
+
|
|
80
|
+
##
|
|
81
|
+
# @param name [Symbol, String] request control name
|
|
82
|
+
# @return [Boolean] whether the control was explicitly supplied
|
|
83
|
+
def explicit?(name)
|
|
84
|
+
explicit_keys.include?(name.to_sym)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
##
|
|
88
|
+
# @param strategy [Symbol, nil] validated request strategy
|
|
89
|
+
# @param max_redirects [Integer, nil] validated redirect limit
|
|
90
|
+
# @param max_requests [Integer, nil] validated request budget
|
|
91
|
+
# @param total_timeout_seconds [Integer, nil] validated request timeout
|
|
92
|
+
# @return [RequestControls] controls updated with validated effective values
|
|
93
|
+
def with_effective_values(strategy:, max_redirects:, max_requests:, total_timeout_seconds:)
|
|
94
|
+
self.class.new(
|
|
95
|
+
strategy:,
|
|
96
|
+
max_redirects:,
|
|
97
|
+
max_requests:,
|
|
98
|
+
total_timeout_seconds:,
|
|
99
|
+
explicit_keys:
|
|
100
|
+
)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
##
|
|
104
|
+
# Applies only explicitly set controls to the provided config hash.
|
|
105
|
+
#
|
|
106
|
+
# @param config [Hash{Symbol => Object}] mutable config hash
|
|
107
|
+
# @return [Hash{Symbol => Object}] the same hash with explicit controls written
|
|
108
|
+
def apply_to(config)
|
|
109
|
+
config[:strategy] = strategy if explicit?(:strategy)
|
|
110
|
+
apply_request_value(config, :max_redirects, max_redirects)
|
|
111
|
+
apply_request_value(config, :max_requests, max_requests)
|
|
112
|
+
apply_request_value(config, :total_timeout_seconds, total_timeout_seconds)
|
|
113
|
+
config
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
private
|
|
117
|
+
|
|
118
|
+
attr_reader :explicit_keys
|
|
119
|
+
|
|
120
|
+
def apply_request_value(config, key, value)
|
|
121
|
+
return unless explicit?(key)
|
|
122
|
+
|
|
123
|
+
ensure_request_config!(config)
|
|
124
|
+
config[:request][key] = value
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def ensure_request_config!(config)
|
|
128
|
+
request_config = config[:request]
|
|
129
|
+
return config[:request] = {} if request_config.nil?
|
|
130
|
+
return if request_config.is_a?(Hash)
|
|
131
|
+
|
|
132
|
+
raise ArgumentError, 'request config must be a hash'
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -41,7 +41,7 @@ module Html2rss
|
|
|
41
41
|
# @return [Hash{String => String}] the default header set merged with global defaults
|
|
42
42
|
def browser_defaults
|
|
43
43
|
defaults = DEFAULT_HEADERS.dup
|
|
44
|
-
global_headers = Html2rss.
|
|
44
|
+
global_headers = Html2rss.defaults.headers
|
|
45
45
|
global_headers = global_headers.call if global_headers.respond_to?(:call)
|
|
46
46
|
|
|
47
47
|
if global_headers.is_a?(Hash)
|