html2rss 0.22.2 → 0.23.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 +206 -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/thing.rb +21 -2
- 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 +118 -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/json_feed/item.rb +96 -0
- data/lib/html2rss/feed_builder/json_feed.rb +61 -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 +23 -0
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +59 -42
- 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 +52 -65
- 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 +0 -4
- data/lib/html2rss/request_service/botasaurus_strategy.rb +9 -27
- data/lib/html2rss/request_service/browserless_strategy.rb +2 -23
- data/lib/html2rss/request_service/budget.rb +90 -16
- 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_guard.rb +1 -1
- data/lib/html2rss/request_service/strategy.rb +102 -6
- 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 +25 -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 +63 -65
- data/lib/html2rss/version.rb +1 -3
- data/lib/html2rss.rb +19 -19
- data/schema/html2rss-config.schema.json +109 -9
- metadata +68 -42
- 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/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,96 @@
|
|
|
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
|
+
# @return [Hash]
|
|
54
|
+
def content_fields
|
|
55
|
+
description = article.description
|
|
56
|
+
return { content_html: description } if description
|
|
57
|
+
|
|
58
|
+
title = article.title
|
|
59
|
+
return { content_text: title } if title
|
|
60
|
+
|
|
61
|
+
{}
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
##
|
|
65
|
+
# @return [Array<String>, nil]
|
|
66
|
+
def tags
|
|
67
|
+
cats = article.categories
|
|
68
|
+
cats.empty? ? nil : cats
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
##
|
|
72
|
+
# Maps enclosures to JSONFeed attachment objects.
|
|
73
|
+
# @return [Array<Hash>, nil]
|
|
74
|
+
def attachments
|
|
75
|
+
enclosures = article.enclosures
|
|
76
|
+
return nil if enclosures.empty?
|
|
77
|
+
|
|
78
|
+
enclosures.map { |enc| attachment_hash(enc) }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
##
|
|
82
|
+
# @param enclosure [Html2rss::Article::Enclosure]
|
|
83
|
+
# @return [Hash]
|
|
84
|
+
def attachment_hash(enclosure)
|
|
85
|
+
size = enclosure.bytes_length
|
|
86
|
+
|
|
87
|
+
{
|
|
88
|
+
url: enclosure.url.to_s,
|
|
89
|
+
mime_type: enclosure.type,
|
|
90
|
+
size_in_bytes: size&.positive? ? size : nil
|
|
91
|
+
}.compact
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
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
|
+
def initialize(channel:, articles:)
|
|
17
|
+
@channel = channel
|
|
18
|
+
@articles = articles
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
##
|
|
22
|
+
# Builds and returns the JSONFeed hash.
|
|
23
|
+
#
|
|
24
|
+
# @return [Hash] the JSONFeed-compliant hash
|
|
25
|
+
def call
|
|
26
|
+
base_payload.merge(authors: author_array, items: item_hashes).compact
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
attr_reader :channel, :articles
|
|
32
|
+
|
|
33
|
+
##
|
|
34
|
+
# @return [Hash]
|
|
35
|
+
def base_payload
|
|
36
|
+
{
|
|
37
|
+
version: VERSION_URL,
|
|
38
|
+
title: channel.title,
|
|
39
|
+
home_page_url: channel.url.to_s,
|
|
40
|
+
description: channel.description,
|
|
41
|
+
language: channel.language,
|
|
42
|
+
icon: channel.image&.to_s
|
|
43
|
+
}
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
##
|
|
47
|
+
# @return [Array<Hash>]
|
|
48
|
+
def item_hashes
|
|
49
|
+
articles.filter_map { |article| Item.new(article).to_h }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
##
|
|
53
|
+
# @return [Array<Hash>, nil]
|
|
54
|
+
def author_array
|
|
55
|
+
return unless (name = channel.author)
|
|
56
|
+
|
|
57
|
+
[{ name: }]
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
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(article.enclosure, 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
|
+
%i[title description author].each do |attr|
|
|
38
|
+
next unless (value = article.send(attr))
|
|
39
|
+
next if value.empty?
|
|
40
|
+
|
|
41
|
+
item_maker.send(:"#{attr}=", value)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
item_maker.link = article.url.to_s if article.url
|
|
45
|
+
item_maker.pubDate = article.published_at&.rfc2822
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def add_item_categories(article, item_maker)
|
|
49
|
+
article.categories.each { |category| item_maker.categories.new_category.content = category }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def add_item_guid(article, item_maker)
|
|
53
|
+
item_maker.guid.tap do |guid|
|
|
54
|
+
guid.content = article.guid
|
|
55
|
+
guid.isPermaLink = false
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
##
|
|
61
|
+
# @param channel [Html2rss::Channel] The channel information for the RSS feed.
|
|
62
|
+
# @param articles [Array<Html2rss::Article>] The list of articles to include in the RSS feed.
|
|
63
|
+
# @param stylesheets [Array<Hash>] An optional array of stylesheet configurations.
|
|
64
|
+
def initialize(channel:, articles:, stylesheets: [])
|
|
65
|
+
@channel = channel
|
|
66
|
+
@articles = articles
|
|
67
|
+
@stylesheets = stylesheets
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# @return [RSS::Rss] RSS 2.0 document instance
|
|
71
|
+
def call
|
|
72
|
+
RSS::Maker.make('2.0') do |maker|
|
|
73
|
+
Stylesheet.add(maker, stylesheets)
|
|
74
|
+
|
|
75
|
+
make_channel(maker.channel)
|
|
76
|
+
make_items(maker)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
attr_reader :channel, :articles
|
|
83
|
+
|
|
84
|
+
def stylesheets
|
|
85
|
+
@stylesheets.map { |style| Stylesheet.new(**style) }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def make_channel(maker)
|
|
89
|
+
%i[language title description ttl].each do |key|
|
|
90
|
+
maker.public_send(:"#{key}=", channel.public_send(key))
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
maker.link = channel.url.to_s
|
|
94
|
+
maker.generator = generator
|
|
95
|
+
maker.updated = channel.last_build_date
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def make_items(maker)
|
|
99
|
+
articles.each do |article|
|
|
100
|
+
maker.items.new_item { |item_maker| self.class.add_item(article, item_maker) }
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def generator
|
|
105
|
+
scraper_namespace_regex = /(?<namespace>Html2rss|Scraper)::/
|
|
106
|
+
|
|
107
|
+
scraper_counts = articles.flat_map(&:scraper).tally.map do |klass, count|
|
|
108
|
+
scraper_name = klass.to_s.gsub(scraper_namespace_regex, '')
|
|
109
|
+
"#{scraper_name} (#{count})"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
"html2rss V. #{Html2rss::VERSION} (scrapers: #{scraper_counts.join(', ')})"
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
# Entrypoint and namespace for feed building and formatting (RSS 2.0 / JSON Feed 1.1).
|
|
5
|
+
module FeedBuilder
|
|
6
|
+
# Builds the requested feed type from the channel and article list.
|
|
7
|
+
#
|
|
8
|
+
# @param type [Symbol] :rss or :json_feed
|
|
9
|
+
# @param channel [Html2rss::Channel]
|
|
10
|
+
# @param articles [Array<Html2rss::Article>]
|
|
11
|
+
# @return [RSS::Rss, Hash] format-compliant representation of the feed
|
|
12
|
+
def self.build(type, channel:, articles:, **)
|
|
13
|
+
case type
|
|
14
|
+
when :rss
|
|
15
|
+
Rss.new(channel:, articles:, **).call
|
|
16
|
+
when :json_feed
|
|
17
|
+
JsonFeed.new(channel:, articles:).call
|
|
18
|
+
else
|
|
19
|
+
raise ArgumentError, "Unknown feed type: #{type}"
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -1,14 +1,12 @@
|
|
|
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+.
|
|
10
8
|
class AutoFallback
|
|
11
|
-
# Ordered list of concrete request strategies attempted by auto
|
|
9
|
+
# Ordered list of concrete request strategies attempted by the :auto plan.
|
|
12
10
|
CHAIN = %i[faraday botasaurus browserless].freeze
|
|
13
11
|
|
|
14
12
|
# Error classes that should abort auto fallback immediately.
|
|
@@ -24,6 +22,41 @@ module Html2rss
|
|
|
24
22
|
RequestService::BrowserlessConfigurationError
|
|
25
23
|
].freeze
|
|
26
24
|
|
|
25
|
+
##
|
|
26
|
+
# Mutable run state for one auto-fallback chain: attempts plus selected result.
|
|
27
|
+
class AttemptState
|
|
28
|
+
attr_reader :attempts, :result
|
|
29
|
+
|
|
30
|
+
def initialize
|
|
31
|
+
@attempts = []
|
|
32
|
+
@result = nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# @return [Boolean] true when a strategy already yielded items
|
|
36
|
+
def succeeded? = !result.nil?
|
|
37
|
+
|
|
38
|
+
# @param strategy [Symbol] strategy that raised
|
|
39
|
+
# @param error [Exception] caught error
|
|
40
|
+
# @return [void]
|
|
41
|
+
def record_error(strategy:, error:)
|
|
42
|
+
@attempts << { strategy:, items_count: nil, error_class: error.class.name }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# @param strategy [Symbol] strategy that returned a response
|
|
46
|
+
# @param items_count [Integer] extracted article count
|
|
47
|
+
# @return [void]
|
|
48
|
+
def record_items(strategy:, items_count:)
|
|
49
|
+
@attempts << { strategy:, items_count:, error_class: nil }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# @param response [RequestService::Response] successful response
|
|
53
|
+
# @param articles [Array] extracted articles
|
|
54
|
+
# @return [void]
|
|
55
|
+
def succeed!(response:, articles:)
|
|
56
|
+
@result = { response:, articles: }
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
27
60
|
##
|
|
28
61
|
# @param strategies [Array<Symbol>] ordered concrete strategies for fallback
|
|
29
62
|
# @param budget [RequestService::Budget] shared request budget across retries
|
|
@@ -40,10 +73,10 @@ module Html2rss
|
|
|
40
73
|
##
|
|
41
74
|
# @return [Hash{Symbol => Object}] pipeline state containing :response and :articles
|
|
42
75
|
def call
|
|
43
|
-
state
|
|
44
|
-
return state if state
|
|
76
|
+
state = run_attempts
|
|
77
|
+
return state.result if state.succeeded?
|
|
45
78
|
|
|
46
|
-
finalize_failure(attempts:)
|
|
79
|
+
finalize_failure(attempts: state.attempts)
|
|
47
80
|
end
|
|
48
81
|
|
|
49
82
|
private
|
|
@@ -51,64 +84,48 @@ module Html2rss
|
|
|
51
84
|
attr_reader :strategies, :budget, :session_for, :articles_for
|
|
52
85
|
|
|
53
86
|
def run_attempts
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
87
|
+
AttemptState.new.tap do |state|
|
|
88
|
+
strategies.each_with_index do |strategy, index|
|
|
89
|
+
attempt(strategy:, next_strategy: strategies[index + 1], state:)
|
|
90
|
+
break if state.succeeded?
|
|
91
|
+
end
|
|
58
92
|
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
93
|
end
|
|
71
94
|
|
|
72
95
|
def attempt(strategy:, next_strategy:, state:)
|
|
73
96
|
request_session = session_for.call(strategy:, budget:)
|
|
74
|
-
response
|
|
75
|
-
|
|
76
|
-
strategy:,
|
|
77
|
-
next_strategy:,
|
|
78
|
-
state:
|
|
79
|
-
)
|
|
80
|
-
return [nil, state.fetch(:attempts)] unless response
|
|
97
|
+
response = fetch_response(request_session:, strategy:, next_strategy:, state:)
|
|
98
|
+
return unless response
|
|
81
99
|
|
|
82
100
|
process_response(response:, strategy:, next_strategy:, request_session:, state:)
|
|
83
101
|
end
|
|
84
102
|
|
|
85
103
|
def fetch_response(request_session:, strategy:, next_strategy:, state:)
|
|
86
|
-
|
|
104
|
+
request_session.fetch_initial_response
|
|
87
105
|
rescue *NON_FALLBACK_ERRORS
|
|
88
106
|
raise
|
|
89
107
|
rescue StandardError => error
|
|
90
|
-
state
|
|
108
|
+
state.record_error(strategy:, error:)
|
|
91
109
|
log_warn_fallback_error(strategy:, next_strategy:, error:) if next_strategy
|
|
92
110
|
Log.debug("#{self.class}: strategy=#{strategy} error=#{error.class}: #{error.message}")
|
|
93
|
-
|
|
111
|
+
nil
|
|
94
112
|
end
|
|
95
113
|
|
|
96
114
|
def process_response(response:, strategy:, next_strategy:, request_session:, state:)
|
|
97
115
|
articles = articles_for.call(response:, request_session:)
|
|
98
116
|
items_count = articles.size
|
|
99
|
-
state
|
|
117
|
+
state.record_items(strategy:, items_count:)
|
|
100
118
|
Log.debug("#{self.class}: strategy=#{strategy} items=#{items_count}")
|
|
101
|
-
return
|
|
119
|
+
return record_success(response:, strategy:, articles:, state:) if items_count.positive?
|
|
102
120
|
|
|
103
121
|
log_info_fallback_zero_items(strategy:, next_strategy:) if next_strategy
|
|
104
|
-
[nil, state.fetch(:attempts)]
|
|
105
122
|
end
|
|
106
123
|
|
|
107
|
-
def
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
124
|
+
def record_success(response:, strategy:, articles:, state:)
|
|
125
|
+
state.succeed!(response:, articles:)
|
|
126
|
+
return unless state.attempts.size > 1
|
|
127
|
+
|
|
128
|
+
Log.info("#{self.class}: auto selected strategy=#{strategy} after attempts=#{state.attempts.size}")
|
|
112
129
|
end
|
|
113
130
|
|
|
114
131
|
def finalize_failure(attempts:)
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
class FeedPipeline
|
|
5
|
+
##
|
|
6
|
+
# Planner for the runtime request policy and budgets of a feed run.
|
|
7
|
+
#
|
|
8
|
+
# HTTP request slots (pagination, document GETs, strategy fallback) are planned
|
|
9
|
+
# separately from Browserless preload interaction budget so preload cannot steal
|
|
10
|
+
# pagination slots.
|
|
11
|
+
class RuntimePolicy
|
|
12
|
+
# Policy plus Budget from one config expansion (shared meters for a feed run).
|
|
13
|
+
Resources = Data.define(:policy, :budget)
|
|
14
|
+
|
|
15
|
+
##
|
|
16
|
+
# @param config [Html2rss::Config] validated feed config
|
|
17
|
+
# @return [Html2rss::RequestService::Policy] request policy derived from runtime config
|
|
18
|
+
def self.from_config(config)
|
|
19
|
+
RequestService::Policy.new(
|
|
20
|
+
max_requests: effective_max_requests_for(config),
|
|
21
|
+
max_redirects: config.max_redirects,
|
|
22
|
+
total_timeout_seconds: config.total_timeout_seconds || RequestService::Policy::DEFAULTS[:total_timeout_seconds]
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
##
|
|
27
|
+
# Builds policy and budget from one `from_config` so FeedPipeline does not dual-own expansion.
|
|
28
|
+
#
|
|
29
|
+
# @param config [Html2rss::Config] validated feed config
|
|
30
|
+
# @return [Resources] shared policy + budget for the feed build
|
|
31
|
+
def self.resources_for(config)
|
|
32
|
+
policy = from_config(config)
|
|
33
|
+
Resources.new(
|
|
34
|
+
policy:,
|
|
35
|
+
budget: RequestService::Budget.new(
|
|
36
|
+
max_requests: policy.max_requests,
|
|
37
|
+
max_interactions: interaction_budget_for(config),
|
|
38
|
+
total_timeout_seconds: policy.total_timeout_seconds
|
|
39
|
+
)
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
##
|
|
44
|
+
# Builds a Budget with separate request and interaction slot pools.
|
|
45
|
+
#
|
|
46
|
+
# @param config [Html2rss::Config] validated feed config
|
|
47
|
+
# @return [Html2rss::RequestService::Budget] shared budget for the feed build
|
|
48
|
+
def self.budget_for(config) = resources_for(config).budget
|
|
49
|
+
|
|
50
|
+
##
|
|
51
|
+
# @param config [Html2rss::Config] validated feed config
|
|
52
|
+
# @return [Integer] preload interaction slots derived from browserless preload config
|
|
53
|
+
def self.interaction_budget_for(config)
|
|
54
|
+
browserless_preload_budget_for(config)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
class << self
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def effective_max_requests_for(config)
|
|
61
|
+
return config.max_requests if config.explicit_max_requests?
|
|
62
|
+
|
|
63
|
+
[baseline_request_budget_for(config), config.max_requests].max
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Reserve enough HTTP request slots for the initial request plus predictable
|
|
67
|
+
# follow-ups. Preload interactions are planned separately.
|
|
68
|
+
def baseline_request_budget_for(config)
|
|
69
|
+
1 +
|
|
70
|
+
RequestSession::Pager.request_slots_for(config.selectors&.dig(:items, :pagination)) +
|
|
71
|
+
AutoSource.request_slots_for(config.auto_source) +
|
|
72
|
+
StrategyPlan.resolve(config.strategy).request_slots
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def browserless_preload_budget_for(config)
|
|
76
|
+
preload = config.request.dig(:browserless, :preload)
|
|
77
|
+
return 0 unless preload
|
|
78
|
+
|
|
79
|
+
top_level_preload_wait_budget(preload) +
|
|
80
|
+
click_selector_preload_budget(preload) +
|
|
81
|
+
scroll_preload_budget(preload)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def top_level_preload_wait_budget(preload)
|
|
85
|
+
preload[:wait_after_ms] ? 2 : 0
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def click_selector_preload_budget(preload)
|
|
89
|
+
preload.fetch(:click_selectors, []).sum { preload_action_budget(_1, :max_clicks) }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def scroll_preload_budget(preload)
|
|
93
|
+
scroll = preload[:scroll_down]
|
|
94
|
+
return 0 unless scroll
|
|
95
|
+
|
|
96
|
+
preload_action_budget(scroll, :iterations)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def preload_action_budget(config, count_key)
|
|
100
|
+
action_count = config.fetch(count_key, 1)
|
|
101
|
+
wait_budget = config[:wait_after_ms] ? action_count : 0
|
|
102
|
+
|
|
103
|
+
action_count + wait_budget
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
class FeedPipeline
|
|
5
|
+
##
|
|
6
|
+
# Feed-level request plan: +:auto+ (fallback chain) or a concrete transport strategy.
|
|
7
|
+
#
|
|
8
|
+
# {RequestService} executes concrete adapters only. +:auto+ is resolved here before
|
|
9
|
+
# any session or adapter call — plan vs transport locality.
|
|
10
|
+
class StrategyPlan
|
|
11
|
+
# Feed-level auto plan: try {AutoFallback::CHAIN} until items are extracted.
|
|
12
|
+
Auto = Data.define do
|
|
13
|
+
# @return [Integer]
|
|
14
|
+
def request_slots
|
|
15
|
+
[AutoFallback::CHAIN.size - 1, 0].max
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Concrete transport strategy name for {RequestService}.
|
|
20
|
+
Concrete = Data.define(:strategy) do
|
|
21
|
+
# @return [Integer]
|
|
22
|
+
def request_slots = 0
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Symbol used in config / configure for the auto plan.
|
|
26
|
+
AUTO_NAME = :auto
|
|
27
|
+
|
|
28
|
+
class << self
|
|
29
|
+
##
|
|
30
|
+
# @param name [Symbol, String] config or configure strategy value
|
|
31
|
+
# @return [Auto, Concrete] resolved feed-level plan
|
|
32
|
+
# @raise [ArgumentError] when +name+ is neither +:auto+ nor a registered strategy
|
|
33
|
+
def resolve(name)
|
|
34
|
+
normalized = name.to_sym
|
|
35
|
+
return Auto.new if normalized == AUTO_NAME
|
|
36
|
+
|
|
37
|
+
unless RequestService.strategy_registered?(normalized)
|
|
38
|
+
raise ArgumentError, "unknown strategy plan: #{name.inspect}"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
Concrete.new(strategy: normalized)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
##
|
|
45
|
+
# @param name [Symbol, String, nil] candidate plan name
|
|
46
|
+
# @return [Boolean] whether +name+ is a valid feed-level strategy plan
|
|
47
|
+
def valid?(name)
|
|
48
|
+
return false unless name.is_a?(Symbol) || name.is_a?(String)
|
|
49
|
+
|
|
50
|
+
resolve(name)
|
|
51
|
+
true
|
|
52
|
+
rescue ArgumentError
|
|
53
|
+
false
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
##
|
|
57
|
+
# @return [Array<Symbol>] accepted plan names (+:auto+ plus registered strategies)
|
|
58
|
+
def accepted_names
|
|
59
|
+
[AUTO_NAME, *RequestService.strategy_names.map(&:to_sym)].uniq
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|