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
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
module FeedBuilder
|
|
5
|
+
class JsonFeed
|
|
6
|
+
##
|
|
7
|
+
# Maps an {Html2rss::Article} to a JSONFeed 1.1 item hash.
|
|
8
|
+
class Item
|
|
9
|
+
##
|
|
10
|
+
# @param article [Html2rss::Article]
|
|
11
|
+
def initialize(article)
|
|
12
|
+
@article = article
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
##
|
|
16
|
+
# @return [Hash, nil] the JSONFeed-compliant item hash
|
|
17
|
+
def to_h
|
|
18
|
+
content = content_fields
|
|
19
|
+
return if content.empty?
|
|
20
|
+
|
|
21
|
+
item_payload.merge(content).compact
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
attr_reader :article
|
|
27
|
+
|
|
28
|
+
##
|
|
29
|
+
# @return [Hash]
|
|
30
|
+
def item_payload
|
|
31
|
+
{
|
|
32
|
+
id: article.guid,
|
|
33
|
+
url: article.url&.to_s,
|
|
34
|
+
title: article.title,
|
|
35
|
+
image: article.image&.to_s,
|
|
36
|
+
date_published: article.published_at&.iso8601,
|
|
37
|
+
authors: author_array,
|
|
38
|
+
tags:,
|
|
39
|
+
attachments:
|
|
40
|
+
}
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
##
|
|
44
|
+
# @return [Array<Hash>, nil]
|
|
45
|
+
def author_array
|
|
46
|
+
return unless (name = article.author)
|
|
47
|
+
|
|
48
|
+
[{ name: }]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
##
|
|
52
|
+
# JSON Feed items must include content_html or content_text.
|
|
53
|
+
# Title is a fallback only when description is absent.
|
|
54
|
+
#
|
|
55
|
+
# @return [Hash]
|
|
56
|
+
def content_fields
|
|
57
|
+
if (description = ItemPresentation.description_for(article))
|
|
58
|
+
return content_for(description)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
return { content_text: article.title } if article.title
|
|
62
|
+
|
|
63
|
+
{}
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
##
|
|
67
|
+
# @param description [String]
|
|
68
|
+
# @return [Hash]
|
|
69
|
+
def content_for(description)
|
|
70
|
+
html?(description) ? { content_html: description } : { content_text: description }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
##
|
|
74
|
+
# @param value [String]
|
|
75
|
+
# @return [Boolean]
|
|
76
|
+
def html?(value)
|
|
77
|
+
Nokogiri::HTML.fragment(value).children.any?(&:element?)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
##
|
|
81
|
+
# @return [Array<String>, nil]
|
|
82
|
+
def tags
|
|
83
|
+
cats = article.categories
|
|
84
|
+
cats.empty? ? nil : cats
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
##
|
|
88
|
+
# Maps enclosures to JSONFeed attachment objects.
|
|
89
|
+
# @return [Array<Hash>, nil]
|
|
90
|
+
def attachments
|
|
91
|
+
enclosures = article.enclosures
|
|
92
|
+
return nil if enclosures.empty?
|
|
93
|
+
|
|
94
|
+
enclosures.map { |enc| attachment_hash(enc) }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
##
|
|
98
|
+
# @param enclosure [Html2rss::Article::Enclosure]
|
|
99
|
+
# @return [Hash]
|
|
100
|
+
def attachment_hash(enclosure)
|
|
101
|
+
size = enclosure.bytes_length
|
|
102
|
+
|
|
103
|
+
{
|
|
104
|
+
url: enclosure.url.to_s,
|
|
105
|
+
mime_type: enclosure.type,
|
|
106
|
+
size_in_bytes: size&.positive? ? size : nil
|
|
107
|
+
}.compact
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
module FeedBuilder
|
|
5
|
+
##
|
|
6
|
+
# Builds a JSONFeed 1.1 hash from channel metadata and articles.
|
|
7
|
+
#
|
|
8
|
+
# @see https://www.jsonfeed.org/version/1.1/
|
|
9
|
+
class JsonFeed
|
|
10
|
+
# Official JSON Feed 1.1 schema version URL.
|
|
11
|
+
VERSION_URL = 'https://jsonfeed.org/version/1.1'
|
|
12
|
+
|
|
13
|
+
##
|
|
14
|
+
# @param channel [Html2rss::Channel]
|
|
15
|
+
# @param articles [Array<Html2rss::Article>]
|
|
16
|
+
# @param user_comment [String] required generator comment (from {Status})
|
|
17
|
+
# @param feed_url [String, nil] optional absolute self URL for the feed (JSON Feed feed_url)
|
|
18
|
+
def initialize(channel:, articles:, user_comment:, feed_url: nil)
|
|
19
|
+
raise ArgumentError, 'user_comment must be a non-blank String' if blank_string?(user_comment)
|
|
20
|
+
|
|
21
|
+
@channel = channel
|
|
22
|
+
@articles = articles
|
|
23
|
+
@feed_url = normalize_feed_url(feed_url)
|
|
24
|
+
@user_comment = user_comment
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
##
|
|
28
|
+
# Builds and returns the JSONFeed hash.
|
|
29
|
+
#
|
|
30
|
+
# @return [Hash] the JSONFeed-compliant hash
|
|
31
|
+
def call
|
|
32
|
+
base_payload.merge(authors: author_array, items: item_hashes).compact
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
attr_reader :channel, :articles, :feed_url, :user_comment
|
|
38
|
+
|
|
39
|
+
def blank_string?(value)
|
|
40
|
+
!value.is_a?(String) || value.strip.empty?
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def normalize_feed_url(value)
|
|
44
|
+
return if value.nil?
|
|
45
|
+
raise ArgumentError, 'feed_url must be a String or nil' unless value.is_a?(String)
|
|
46
|
+
return if value.strip.empty?
|
|
47
|
+
|
|
48
|
+
Url.from_absolute(value).to_s
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
##
|
|
52
|
+
# @return [Hash]
|
|
53
|
+
def base_payload
|
|
54
|
+
{
|
|
55
|
+
version: VERSION_URL,
|
|
56
|
+
title: channel.title,
|
|
57
|
+
home_page_url: channel.url.to_s,
|
|
58
|
+
feed_url:,
|
|
59
|
+
description: channel.description,
|
|
60
|
+
user_comment:,
|
|
61
|
+
language: channel.language,
|
|
62
|
+
icon: channel.image&.to_s
|
|
63
|
+
}
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
##
|
|
67
|
+
# @return [Array<Hash>]
|
|
68
|
+
def item_hashes
|
|
69
|
+
articles.filter_map { |article| Item.new(article).to_h }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
##
|
|
73
|
+
# @return [Array<Hash>, nil]
|
|
74
|
+
def author_array
|
|
75
|
+
return unless (name = channel.author)
|
|
76
|
+
|
|
77
|
+
[{ name: }]
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
module FeedBuilder
|
|
5
|
+
class Rss
|
|
6
|
+
##
|
|
7
|
+
# Represents a stylesheet.
|
|
8
|
+
class Stylesheet
|
|
9
|
+
class << self
|
|
10
|
+
##
|
|
11
|
+
# Adds the stylesheet XML tags to the RSS.
|
|
12
|
+
#
|
|
13
|
+
# @param maker [RSS::Maker::RSS20] RSS maker object.
|
|
14
|
+
# @param stylesheets [Array<Html2rss::FeedBuilder::Rss::Stylesheet>] Array of stylesheet configurations.
|
|
15
|
+
# @return [nil]
|
|
16
|
+
def add(maker, stylesheets)
|
|
17
|
+
stylesheets.each do |stylesheet|
|
|
18
|
+
add_stylesheet(maker, stylesheet)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
##
|
|
25
|
+
# Adds a single Stylesheet to the RSS.
|
|
26
|
+
#
|
|
27
|
+
# @param maker [RSS::Maker::RSS20] RSS maker object.
|
|
28
|
+
# @param stylesheet [Html2rss::FeedBuilder::Rss::Stylesheet] Stylesheet configuration.
|
|
29
|
+
# @return [nil]
|
|
30
|
+
def add_stylesheet(maker, stylesheet)
|
|
31
|
+
maker.xml_stylesheets.new_xml_stylesheet do |xss|
|
|
32
|
+
xss.href = stylesheet.href
|
|
33
|
+
xss.type = stylesheet.type
|
|
34
|
+
xss.media = stylesheet.media
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Allowed stylesheet MIME types for RSS processing instructions.
|
|
40
|
+
TYPES = ['text/css', 'text/xsl'].to_set.freeze
|
|
41
|
+
|
|
42
|
+
# @param href [String] stylesheet URL
|
|
43
|
+
# @param type [String] MIME type (`text/css` or `text/xsl`)
|
|
44
|
+
# @param media [String] media query hint for the stylesheet
|
|
45
|
+
def initialize(href:, type:, media: 'all')
|
|
46
|
+
raise ArgumentError, 'stylesheet.href must be a String' unless href.is_a?(String)
|
|
47
|
+
raise ArgumentError, 'stylesheet.type invalid' unless TYPES.include?(type)
|
|
48
|
+
raise ArgumentError, 'stylesheet.media must be a String' unless media.is_a?(String)
|
|
49
|
+
|
|
50
|
+
@href = href
|
|
51
|
+
@type = type
|
|
52
|
+
@media = media
|
|
53
|
+
end
|
|
54
|
+
attr_reader :href, :type, :media
|
|
55
|
+
|
|
56
|
+
# @return [String] the XML representation of the stylesheet
|
|
57
|
+
def to_xml
|
|
58
|
+
<<~XML
|
|
59
|
+
<?xml-stylesheet href="#{href}" type="#{type}" media="#{media}"?>
|
|
60
|
+
XML
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rss'
|
|
4
|
+
|
|
5
|
+
module Html2rss
|
|
6
|
+
module FeedBuilder
|
|
7
|
+
##
|
|
8
|
+
# Builds an RSS Feed by providing channel, articles and stylesheets.
|
|
9
|
+
class Rss
|
|
10
|
+
class << self
|
|
11
|
+
# @param article [Html2rss::Article] source article
|
|
12
|
+
# @param item_maker [RSS::Maker::RSS20::ItemsBase::ItemBase] RSS item builder
|
|
13
|
+
# @return [void]
|
|
14
|
+
def add_item(article, item_maker)
|
|
15
|
+
add_item_string_values(article, item_maker)
|
|
16
|
+
add_item_categories(article, item_maker)
|
|
17
|
+
add_enclosure(ItemPresentation.rss_enclosure_for(article), item_maker)
|
|
18
|
+
add_item_guid(article, item_maker)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# @param enclosure [Html2rss::Article::Enclosure, nil] built enclosure object for the current RSS item
|
|
22
|
+
# @param maker [RSS::Maker::RSS20::ItemsBase::ItemBase] RSS item builder
|
|
23
|
+
# @return [void]
|
|
24
|
+
def add_enclosure(enclosure, maker)
|
|
25
|
+
return unless enclosure
|
|
26
|
+
|
|
27
|
+
maker.enclosure.tap do |enclosure_maker|
|
|
28
|
+
enclosure_maker.url = enclosure.url.to_s
|
|
29
|
+
enclosure_maker.type = enclosure.type
|
|
30
|
+
enclosure_maker.length = enclosure.bytes_length
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def add_item_string_values(article, item_maker)
|
|
37
|
+
{
|
|
38
|
+
title: article.title,
|
|
39
|
+
description: ItemPresentation.description_for(article),
|
|
40
|
+
author: article.author
|
|
41
|
+
}.each do |attr, value|
|
|
42
|
+
next if value.nil? || value.empty?
|
|
43
|
+
|
|
44
|
+
item_maker.public_send(:"#{attr}=", value)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
item_maker.link = article.url.to_s if article.url
|
|
48
|
+
item_maker.pubDate = article.published_at&.rfc2822
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def add_item_categories(article, item_maker)
|
|
52
|
+
article.categories.each { |category| item_maker.categories.new_category.content = category }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def add_item_guid(article, item_maker)
|
|
56
|
+
item_maker.guid.tap do |guid|
|
|
57
|
+
guid.content = article.guid
|
|
58
|
+
guid.isPermaLink = false
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
##
|
|
64
|
+
# @param channel [Html2rss::Channel] The channel information for the RSS feed.
|
|
65
|
+
# @param articles [Array<Html2rss::Article>] The list of articles to include in the RSS feed.
|
|
66
|
+
# @param stylesheets [Array<Hash>] An optional array of stylesheet configurations.
|
|
67
|
+
# @param generator [String] required preformatted generator comment (from {Status})
|
|
68
|
+
def initialize(channel:, articles:, generator:, stylesheets: [])
|
|
69
|
+
raise ArgumentError, 'generator must be a non-blank String' if blank_string?(generator)
|
|
70
|
+
|
|
71
|
+
@channel = channel
|
|
72
|
+
@articles = articles
|
|
73
|
+
@stylesheets = stylesheets
|
|
74
|
+
@generator = generator
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# @return [RSS::Rss] RSS 2.0 document instance
|
|
78
|
+
def call
|
|
79
|
+
RSS::Maker.make('2.0') do |maker|
|
|
80
|
+
Stylesheet.add(maker, stylesheets)
|
|
81
|
+
|
|
82
|
+
make_channel(maker.channel)
|
|
83
|
+
make_items(maker)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
attr_reader :channel, :articles, :generator
|
|
90
|
+
|
|
91
|
+
def blank_string?(value)
|
|
92
|
+
!value.is_a?(String) || value.strip.empty?
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def stylesheets
|
|
96
|
+
@stylesheets.map { |style| Stylesheet.new(**style) }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def make_channel(maker)
|
|
100
|
+
%i[language title description ttl].each do |key|
|
|
101
|
+
maker.public_send(:"#{key}=", channel.public_send(key))
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
maker.link = channel.url.to_s
|
|
105
|
+
maker.generator = generator
|
|
106
|
+
maker.updated = channel.last_build_date
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def make_items(maker)
|
|
110
|
+
articles.each do |article|
|
|
111
|
+
maker.items.new_item { |item_maker| self.class.add_item(article, item_maker) }
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
# Namespace for feed format adapters (RSS 2.0 / JSON Feed 1.1).
|
|
5
|
+
#
|
|
6
|
+
# {FeedResult} constructs {Rss} / {JsonFeed} directly — there is no dispatcher
|
|
7
|
+
# entrypoint on this module. Stylesheets are a scrape-time artifact carried on
|
|
8
|
+
# FeedResult; +feed_url+ is a render-time JSON Feed-only option.
|
|
9
|
+
#
|
|
10
|
+
# Channel field projection:
|
|
11
|
+
# - Rss: language, title, description, ttl, link, updated
|
|
12
|
+
# - JsonFeed: title, home_page_url, description, language, icon, authors (+ feed_url)
|
|
13
|
+
module FeedBuilder
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
##
|
|
4
|
-
# The Html2rss namespace.
|
|
5
3
|
module Html2rss
|
|
6
|
-
##
|
|
7
|
-
# Coordinates feed generation pipeline stages.
|
|
8
4
|
class FeedPipeline
|
|
9
|
-
# Retries feed extraction across concrete request strategies for :auto
|
|
5
|
+
# Retries feed extraction across concrete request strategies for the :auto plan.
|
|
6
|
+
#
|
|
7
|
+
# Owned by {FeedPipeline}; invoked only after {StrategyPlan} resolves +:auto+.
|
|
8
|
+
# Hosted by the pipeline instance: session + extract call back into FeedPipeline.
|
|
10
9
|
class AutoFallback
|
|
11
|
-
# Ordered list of concrete request strategies attempted by auto
|
|
12
|
-
CHAIN = %i[faraday botasaurus
|
|
10
|
+
# Ordered list of concrete request strategies attempted by the :auto plan.
|
|
11
|
+
CHAIN = %i[faraday botasaurus].freeze
|
|
13
12
|
|
|
14
13
|
# Error classes that should abort auto fallback immediately.
|
|
15
14
|
NON_FALLBACK_ERRORS = [
|
|
@@ -24,103 +23,164 @@ module Html2rss
|
|
|
24
23
|
RequestService::BrowserlessConfigurationError
|
|
25
24
|
].freeze
|
|
26
25
|
|
|
26
|
+
##
|
|
27
|
+
# Mutable run state for one auto-fallback chain: attempts plus selected result.
|
|
28
|
+
class AttemptState
|
|
29
|
+
attr_reader :attempts, :result
|
|
30
|
+
|
|
31
|
+
def initialize
|
|
32
|
+
@attempts = []
|
|
33
|
+
@result = nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @return [Boolean] true when a strategy already yielded items
|
|
37
|
+
def succeeded? = !result.nil?
|
|
38
|
+
|
|
39
|
+
# @param strategy [Symbol] strategy that raised
|
|
40
|
+
# @param error [Exception] caught error
|
|
41
|
+
# @return [void]
|
|
42
|
+
def record_error(strategy:, error:)
|
|
43
|
+
@attempts << { strategy:, items_count: nil, error_class: error.class.name }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# @param strategy [Symbol] strategy that returned a response
|
|
47
|
+
# @param items_count [Integer] extracted article count
|
|
48
|
+
# @param transport_meta [Hash, nil] optional allowlisted upstream telemetry
|
|
49
|
+
# @return [void]
|
|
50
|
+
def record_items(strategy:, items_count:, transport_meta: nil)
|
|
51
|
+
attempt = { strategy:, items_count:, error_class: nil }
|
|
52
|
+
attempt[:transport_meta] = transport_meta if transport_meta && !transport_meta.empty?
|
|
53
|
+
@attempts << attempt
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# @param response [RequestService::Response] successful response
|
|
57
|
+
# @param articles [Array] extracted articles
|
|
58
|
+
# @param dedup_dropped [Integer] articles removed by deduplication
|
|
59
|
+
# @param selected_strategy [Symbol] concrete strategy that produced items
|
|
60
|
+
# @param attempt_count [Integer] number of attempts recorded for this chain
|
|
61
|
+
# @return [void]
|
|
62
|
+
def succeed!(response:, articles:, dedup_dropped:, selected_strategy:, attempt_count:)
|
|
63
|
+
@result = PipelineOutcome.new(
|
|
64
|
+
response:,
|
|
65
|
+
articles:,
|
|
66
|
+
dedup_dropped:,
|
|
67
|
+
selected_strategy:,
|
|
68
|
+
attempt_count:,
|
|
69
|
+
strategy_attempts: attempts
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
27
74
|
##
|
|
28
75
|
# @param strategies [Array<Symbol>] ordered concrete strategies for fallback
|
|
29
76
|
# @param budget [RequestService::Budget] shared request budget across retries
|
|
30
|
-
# @param
|
|
31
|
-
# @param
|
|
77
|
+
# @param pipeline [Html2rss::FeedPipeline] host for session + article extraction
|
|
78
|
+
# @param config [Html2rss::Config] validated feed config
|
|
79
|
+
# @param resources [Html2rss::FeedPipeline::RuntimePolicy::Resources] budget + policy
|
|
32
80
|
# @return [void]
|
|
33
|
-
def initialize(strategies:, budget:,
|
|
81
|
+
def initialize(strategies:, budget:, pipeline:, config:, resources:)
|
|
34
82
|
@strategies = strategies
|
|
35
83
|
@budget = budget
|
|
36
|
-
@
|
|
37
|
-
@
|
|
84
|
+
@pipeline = pipeline
|
|
85
|
+
@config = config
|
|
86
|
+
@resources = resources
|
|
38
87
|
end
|
|
39
88
|
|
|
40
89
|
##
|
|
41
|
-
# @return [
|
|
90
|
+
# @return [Html2rss::FeedPipeline::PipelineOutcome] scrape-finished state for materialization
|
|
42
91
|
def call
|
|
43
|
-
state
|
|
44
|
-
return state if state
|
|
92
|
+
state = run_attempts
|
|
93
|
+
return state.result if state.succeeded?
|
|
45
94
|
|
|
46
|
-
finalize_failure(attempts:)
|
|
95
|
+
finalize_failure(attempts: state.attempts)
|
|
47
96
|
end
|
|
48
97
|
|
|
49
98
|
private
|
|
50
99
|
|
|
51
|
-
attr_reader :strategies, :budget, :
|
|
100
|
+
attr_reader :strategies, :budget, :pipeline, :config, :resources
|
|
52
101
|
|
|
53
102
|
def run_attempts
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
103
|
+
AttemptState.new.tap do |state|
|
|
104
|
+
strategies.each_with_index do |strategy, index|
|
|
105
|
+
attempt(strategy:, next_strategy: strategies[index + 1], state:)
|
|
106
|
+
break if state.succeeded?
|
|
107
|
+
end
|
|
58
108
|
end
|
|
59
|
-
[state.fetch(:result), state.fetch(:attempts)]
|
|
60
|
-
end
|
|
61
|
-
|
|
62
|
-
def run_attempt_for(strategy:, next_strategy:, state:)
|
|
63
|
-
result, attempts = attempt(
|
|
64
|
-
strategy:,
|
|
65
|
-
next_strategy:,
|
|
66
|
-
state: { attempts: state.fetch(:attempts) }
|
|
67
|
-
)
|
|
68
|
-
state[:result] = result
|
|
69
|
-
state[:attempts] = attempts
|
|
70
109
|
end
|
|
71
110
|
|
|
72
111
|
def attempt(strategy:, next_strategy:, state:)
|
|
73
|
-
request_session =
|
|
74
|
-
response
|
|
75
|
-
|
|
76
|
-
strategy:,
|
|
77
|
-
next_strategy:,
|
|
78
|
-
state:
|
|
79
|
-
)
|
|
80
|
-
return [nil, state.fetch(:attempts)] unless response
|
|
112
|
+
request_session = pipeline.request_session_for(config, strategy:, resources:)
|
|
113
|
+
response = fetch_response(request_session:, strategy:, next_strategy:, state:)
|
|
114
|
+
return unless response
|
|
81
115
|
|
|
82
116
|
process_response(response:, strategy:, next_strategy:, request_session:, state:)
|
|
83
117
|
end
|
|
84
118
|
|
|
85
119
|
def fetch_response(request_session:, strategy:, next_strategy:, state:)
|
|
86
|
-
|
|
120
|
+
request_session.fetch_initial_response
|
|
87
121
|
rescue *NON_FALLBACK_ERRORS
|
|
88
122
|
raise
|
|
89
123
|
rescue StandardError => error
|
|
90
|
-
state
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
[nil, state]
|
|
124
|
+
state.record_error(strategy:, error:)
|
|
125
|
+
log_fallback_error(strategy:, next_strategy:, error:, request_session:) if next_strategy
|
|
126
|
+
nil
|
|
94
127
|
end
|
|
95
128
|
|
|
96
129
|
def process_response(response:, strategy:, next_strategy:, request_session:, state:)
|
|
97
|
-
articles = articles_for
|
|
130
|
+
articles, dedup_dropped = articles_for(response:, request_session:)
|
|
98
131
|
items_count = articles.size
|
|
99
|
-
state
|
|
100
|
-
Log.debug("#{self.class}: strategy=#{strategy} items=#{items_count}"
|
|
101
|
-
|
|
132
|
+
state.record_items(strategy:, items_count:, transport_meta: response.transport_meta)
|
|
133
|
+
Log.debug("#{self.class}: strategy=#{strategy} items=#{items_count} " \
|
|
134
|
+
"host=#{response.url.host} elapsed=#{format('%.3f', budget.elapsed_seconds)}s " \
|
|
135
|
+
"budget_remaining=#{budget_remaining_label}")
|
|
136
|
+
return record_success(response:, strategy:, articles:, dedup_dropped:, state:) if items_count.positive?
|
|
102
137
|
|
|
103
|
-
log_info_fallback_zero_items(strategy:, next_strategy:) if next_strategy
|
|
104
|
-
[nil, state.fetch(:attempts)]
|
|
138
|
+
log_info_fallback_zero_items(strategy:, next_strategy:, response:) if next_strategy
|
|
105
139
|
end
|
|
106
140
|
|
|
107
|
-
def
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
141
|
+
def articles_for(response:, request_session:)
|
|
142
|
+
pipeline.deduplicated_articles(config:, response:, request_session:)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def record_success(response:, strategy:, articles:, dedup_dropped:, state:)
|
|
146
|
+
attempt_count = state.attempts.size
|
|
147
|
+
state.succeed!(response:, articles:, dedup_dropped:, selected_strategy: strategy, attempt_count:)
|
|
148
|
+
return unless attempt_count > 1
|
|
149
|
+
|
|
150
|
+
Log.info("#{self.class}: auto selected strategy=#{strategy} after attempts=#{attempt_count} " \
|
|
151
|
+
"host=#{response.url.host} elapsed=#{format('%.3f', budget.elapsed_seconds)}s " \
|
|
152
|
+
"budget_remaining=#{budget_remaining_label}")
|
|
112
153
|
end
|
|
113
154
|
|
|
114
155
|
def finalize_failure(attempts:)
|
|
115
156
|
raise NoFeedItemsExtracted.new(attempts:)
|
|
116
157
|
end
|
|
117
158
|
|
|
118
|
-
|
|
119
|
-
|
|
159
|
+
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
160
|
+
def log_fallback_error(strategy:, next_strategy:, error:, request_session:)
|
|
161
|
+
host = request_session.url.host
|
|
162
|
+
detail = "host=#{host} elapsed=#{format('%.3f', budget.elapsed_seconds)}s " \
|
|
163
|
+
"budget_remaining=#{budget_remaining_label}"
|
|
164
|
+
if error.is_a?(RequestService::RequestTimedOut)
|
|
165
|
+
Log.info("#{self.class}: auto fallback #{strategy} -> #{next_strategy} " \
|
|
166
|
+
"after timeout=#{error.class} #{detail}")
|
|
167
|
+
else
|
|
168
|
+
Log.warn("#{self.class}: auto fallback #{strategy} -> #{next_strategy} " \
|
|
169
|
+
"after error=#{error.class} #{detail}")
|
|
170
|
+
end
|
|
171
|
+
Log.debug("#{self.class}: strategy=#{strategy} error=#{error.class}: #{error.message} #{detail}")
|
|
172
|
+
end
|
|
173
|
+
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
|
|
174
|
+
|
|
175
|
+
def log_info_fallback_zero_items(strategy:, next_strategy:, response:)
|
|
176
|
+
Log.info("#{self.class}: auto fallback #{strategy} -> #{next_strategy} after zero extracted items " \
|
|
177
|
+
"host=#{response.url.host} elapsed=#{format('%.3f', budget.elapsed_seconds)}s " \
|
|
178
|
+
"budget_remaining=#{budget_remaining_label}")
|
|
120
179
|
end
|
|
121
180
|
|
|
122
|
-
def
|
|
123
|
-
|
|
181
|
+
def budget_remaining_label
|
|
182
|
+
remaining = budget.remaining_timeout_seconds
|
|
183
|
+
remaining.nil? ? 'untracked' : format('%.3f', remaining)
|
|
124
184
|
end
|
|
125
185
|
end
|
|
126
186
|
end
|