html2rss 0.22.2 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +11 -108
- data/lib/html2rss/{articles → article}/deduplicator.rb +3 -4
- data/lib/html2rss/{rss_builder → article}/enclosure.rb +6 -22
- data/lib/html2rss/article.rb +213 -0
- data/lib/html2rss/auto_source/discovery/dom_clustering/group_scorer.rb +80 -0
- data/lib/html2rss/auto_source/discovery/dom_clustering/overlap_resolver.rb +86 -0
- data/lib/html2rss/auto_source/discovery/dom_clustering.rb +119 -0
- data/lib/html2rss/auto_source/discovery/list_candidates.rb +94 -0
- data/lib/html2rss/auto_source/discovery/semantic_anchor_candidates.rb +219 -0
- data/lib/html2rss/auto_source/discovery/semantic_containers.rb +71 -0
- data/lib/html2rss/auto_source/discovery/sitemap.rb +159 -0
- data/lib/html2rss/auto_source/discovery.rb +14 -0
- data/lib/html2rss/auto_source/link_heuristics/anchor_signals.rb +28 -0
- data/lib/html2rss/auto_source/link_heuristics/container_assessor.rb +106 -0
- data/lib/html2rss/auto_source/link_heuristics/container_signals.rb +80 -0
- data/lib/html2rss/auto_source/link_heuristics/destination_facts.rb +42 -0
- data/lib/html2rss/auto_source/link_heuristics/href_extractor.rb +38 -0
- data/lib/html2rss/auto_source/link_heuristics/path_classifier.rb +221 -0
- data/lib/html2rss/auto_source/link_heuristics/text_classifier.rb +66 -0
- data/lib/html2rss/auto_source/link_heuristics.rb +139 -0
- data/lib/html2rss/auto_source/scraper/html.rb +22 -33
- data/lib/html2rss/auto_source/scraper/json_state.rb +1 -1
- data/lib/html2rss/auto_source/scraper/meta_oembed.rb +163 -0
- data/lib/html2rss/auto_source/scraper/microformats2.rb +146 -0
- data/lib/html2rss/auto_source/scraper/schema/category_extractor.rb +29 -114
- data/lib/html2rss/auto_source/scraper/schema/item_list.rb +50 -11
- data/lib/html2rss/auto_source/scraper/schema/thing.rb +35 -7
- data/lib/html2rss/auto_source/scraper/schema.rb +61 -18
- data/lib/html2rss/auto_source/scraper/semantic_html/{deduplicator.rb → entry_deduplicator.rb} +3 -3
- data/lib/html2rss/auto_source/scraper/semantic_html.rb +23 -171
- data/lib/html2rss/auto_source/scraper/sitemap.rb +153 -0
- data/lib/html2rss/auto_source/scraper/wordpress_api/page_scope/date_archive_range.rb +110 -0
- data/lib/html2rss/auto_source/scraper/wordpress_api/page_scope.rb +2 -78
- data/lib/html2rss/auto_source/scraper/wordpress_api.rb +9 -0
- data/lib/html2rss/auto_source/scraper.rb +10 -3
- data/lib/html2rss/auto_source.rb +30 -35
- data/lib/html2rss/channel.rb +198 -0
- data/lib/html2rss/cli.rb +5 -6
- data/lib/html2rss/config/auto_source_contract.rb +51 -0
- data/lib/html2rss/config/request_controls.rb +136 -0
- data/lib/html2rss/config/request_headers.rb +1 -1
- data/lib/html2rss/config/schema.rb +65 -114
- data/lib/html2rss/{selectors/config.rb → config/selectors_validator.rb} +56 -7
- data/lib/html2rss/config/validator.rb +6 -6
- data/lib/html2rss/config.rb +176 -19
- data/lib/html2rss/{configuration.rb → defaults.rb} +12 -10
- data/lib/html2rss/feed_builder/item_presentation.rb +42 -0
- data/lib/html2rss/feed_builder/json_feed/item.rb +112 -0
- data/lib/html2rss/feed_builder/json_feed.rb +81 -0
- data/lib/html2rss/feed_builder/rss/stylesheet.rb +65 -0
- data/lib/html2rss/feed_builder/rss.rb +116 -0
- data/lib/html2rss/feed_builder.rb +15 -0
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +120 -60
- data/lib/html2rss/feed_pipeline/runtime_policy.rb +108 -0
- data/lib/html2rss/feed_pipeline/strategy_plan.rb +64 -0
- data/lib/html2rss/feed_pipeline.rb +83 -89
- data/lib/html2rss/feed_result.rb +92 -0
- data/lib/html2rss/html/article_extractor/category_extractor.rb +123 -0
- data/lib/html2rss/html/article_extractor/date_extractor.rb +22 -0
- data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +95 -0
- data/lib/html2rss/html/article_extractor/heading_extractor.rb +54 -0
- data/lib/html2rss/html/article_extractor/id_generator.rb +69 -0
- data/lib/html2rss/html/article_extractor/image_extractor.rb +60 -0
- data/lib/html2rss/html/article_extractor.rb +134 -0
- data/lib/html2rss/html/navigator/text_extractor.rb +79 -0
- data/lib/html2rss/html/navigator.rb +146 -0
- data/lib/html2rss/html/rendering/audio_renderer.rb +38 -0
- data/lib/html2rss/html/rendering/description_builder.rb +89 -0
- data/lib/html2rss/html/rendering/image_renderer.rb +43 -0
- data/lib/html2rss/html/rendering/media_renderer.rb +39 -0
- data/lib/html2rss/html/rendering/pdf_renderer.rb +36 -0
- data/lib/html2rss/html/rendering/video_renderer.rb +38 -0
- data/lib/html2rss/html/rendering.rb +25 -0
- data/lib/html2rss/html.rb +8 -0
- data/lib/html2rss/request_service/blocked_surface.rb +67 -0
- data/lib/html2rss/request_service/botasaurus_contract.rb +33 -9
- data/lib/html2rss/request_service/botasaurus_strategy.rb +21 -30
- data/lib/html2rss/request_service/browserless_strategy.rb +2 -23
- data/lib/html2rss/request_service/budget.rb +97 -18
- data/lib/html2rss/request_service/context.rb +1 -3
- data/lib/html2rss/request_service/faraday_strategy.rb +12 -26
- data/lib/html2rss/request_service/local_file_strategy.rb +12 -4
- data/lib/html2rss/request_service/network_guard.rb +125 -0
- data/lib/html2rss/request_service/policy.rb +10 -82
- data/lib/html2rss/request_service/puppet_commander/navigation_guards.rb +148 -0
- data/lib/html2rss/request_service/puppet_commander/preload_runner.rb +86 -0
- data/lib/html2rss/request_service/puppet_commander.rb +22 -191
- data/lib/html2rss/request_service/response.rb +9 -1
- data/lib/html2rss/request_service/response_guard.rb +1 -1
- data/lib/html2rss/request_service/strategy.rb +123 -5
- data/lib/html2rss/request_service.rb +7 -2
- data/lib/html2rss/request_session/pager/base.rb +83 -0
- data/lib/html2rss/request_session/pager/custom_selector.rb +39 -0
- data/lib/html2rss/request_session/pager/json_cursor.rb +78 -0
- data/lib/html2rss/request_session/pager/offset.rb +48 -0
- data/lib/html2rss/request_session/pager/rel_next.rb +23 -0
- data/lib/html2rss/request_session/pager/url_template.rb +48 -0
- data/lib/html2rss/request_session/pager.rb +75 -0
- data/lib/html2rss/request_session.rb +29 -16
- data/lib/html2rss/selectors/item_scope.rb +30 -0
- data/lib/html2rss/selectors/post_processors/base.rb +1 -1
- data/lib/html2rss/selectors/post_processors/markdown_to_html.rb +0 -1
- data/lib/html2rss/selectors/post_processors/sanitize_html.rb +0 -2
- data/lib/html2rss/selectors/post_processors/template.rb +7 -3
- data/lib/html2rss/selectors.rb +84 -64
- data/lib/html2rss/status.rb +132 -0
- data/lib/html2rss/version.rb +1 -3
- data/lib/html2rss.rb +34 -22
- data/schema/html2rss-config.schema.json +109 -9
- metadata +71 -43
- data/lib/html2rss/auto_source/scraper/html/class_clustering.rb +0 -196
- data/lib/html2rss/auto_source/scraper/link_heuristics.rb +0 -401
- data/lib/html2rss/auto_source/scraper/schema/list_item.rb +0 -28
- data/lib/html2rss/auto_source/scraper/semantic_html/anchor_selector.rb +0 -49
- data/lib/html2rss/blocked_surface.rb +0 -65
- data/lib/html2rss/category_extractor.rb +0 -116
- data/lib/html2rss/config/class_methods.rb +0 -183
- data/lib/html2rss/html_extractor/date_extractor.rb +0 -20
- data/lib/html2rss/html_extractor/enclosure_extractor.rb +0 -91
- data/lib/html2rss/html_extractor/heading_extractor.rb +0 -50
- data/lib/html2rss/html_extractor/id_generator.rb +0 -67
- data/lib/html2rss/html_extractor/image_extractor.rb +0 -58
- data/lib/html2rss/html_extractor/list_candidates.rb +0 -88
- data/lib/html2rss/html_extractor/semantic_anchor_candidates.rb +0 -260
- data/lib/html2rss/html_extractor/semantic_containers.rb +0 -69
- data/lib/html2rss/html_extractor/text_extractor.rb +0 -77
- data/lib/html2rss/html_extractor.rb +0 -190
- data/lib/html2rss/html_navigator.rb +0 -71
- data/lib/html2rss/json_feed_builder/item.rb +0 -94
- data/lib/html2rss/json_feed_builder.rb +0 -59
- data/lib/html2rss/rendering/audio_renderer.rb +0 -36
- data/lib/html2rss/rendering/description_builder.rb +0 -87
- data/lib/html2rss/rendering/image_renderer.rb +0 -41
- data/lib/html2rss/rendering/media_renderer.rb +0 -37
- data/lib/html2rss/rendering/pdf_renderer.rb +0 -34
- data/lib/html2rss/rendering/video_renderer.rb +0 -36
- data/lib/html2rss/rendering.rb +0 -23
- data/lib/html2rss/request_controls.rb +0 -133
- data/lib/html2rss/request_session/rel_next_pager.rb +0 -70
- data/lib/html2rss/request_session/runtime_input.rb +0 -71
- data/lib/html2rss/request_session/runtime_policy.rb +0 -84
- data/lib/html2rss/rss_builder/article.rb +0 -208
- data/lib/html2rss/rss_builder/channel.rb +0 -118
- data/lib/html2rss/rss_builder/stylesheet.rb +0 -63
- data/lib/html2rss/rss_builder.rb +0 -101
|
@@ -1,94 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Html2rss
|
|
4
|
-
class JsonFeedBuilder
|
|
5
|
-
##
|
|
6
|
-
# Maps an {Html2rss::RssBuilder::Article} to a JSONFeed 1.1 item hash.
|
|
7
|
-
class Item
|
|
8
|
-
##
|
|
9
|
-
# @param article [Html2rss::RssBuilder::Article]
|
|
10
|
-
def initialize(article)
|
|
11
|
-
@article = article
|
|
12
|
-
end
|
|
13
|
-
|
|
14
|
-
##
|
|
15
|
-
# @return [Hash, nil] the JSONFeed-compliant item hash
|
|
16
|
-
def to_h
|
|
17
|
-
content = content_fields
|
|
18
|
-
return if content.empty?
|
|
19
|
-
|
|
20
|
-
item_payload.merge(content).compact
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
private
|
|
24
|
-
|
|
25
|
-
attr_reader :article
|
|
26
|
-
|
|
27
|
-
##
|
|
28
|
-
# @return [Hash]
|
|
29
|
-
def item_payload
|
|
30
|
-
{
|
|
31
|
-
id: article.guid,
|
|
32
|
-
url: article.url&.to_s,
|
|
33
|
-
title: article.title,
|
|
34
|
-
image: article.image&.to_s,
|
|
35
|
-
date_published: article.published_at&.iso8601,
|
|
36
|
-
authors: author_array,
|
|
37
|
-
tags:,
|
|
38
|
-
attachments:
|
|
39
|
-
}
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
##
|
|
43
|
-
# @return [Array<Hash>, nil]
|
|
44
|
-
def author_array
|
|
45
|
-
return unless (name = article.author)
|
|
46
|
-
|
|
47
|
-
[{ name: }]
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
##
|
|
51
|
-
# JSON Feed items must include content_html or content_text.
|
|
52
|
-
# @return [Hash]
|
|
53
|
-
def content_fields
|
|
54
|
-
description = article.description
|
|
55
|
-
return { content_html: description } if description
|
|
56
|
-
|
|
57
|
-
title = article.title
|
|
58
|
-
return { content_text: title } if title
|
|
59
|
-
|
|
60
|
-
{}
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
##
|
|
64
|
-
# @return [Array<String>, nil]
|
|
65
|
-
def tags
|
|
66
|
-
cats = article.categories
|
|
67
|
-
cats.empty? ? nil : cats
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
##
|
|
71
|
-
# Maps enclosures to JSONFeed attachment objects.
|
|
72
|
-
# @return [Array<Hash>, nil]
|
|
73
|
-
def attachments
|
|
74
|
-
enclosures = article.enclosures
|
|
75
|
-
return nil if enclosures.empty?
|
|
76
|
-
|
|
77
|
-
enclosures.map { |enc| attachment_hash(enc) }
|
|
78
|
-
end
|
|
79
|
-
|
|
80
|
-
##
|
|
81
|
-
# @param enclosure [Html2rss::RssBuilder::Article::Enclosure]
|
|
82
|
-
# @return [Hash]
|
|
83
|
-
def attachment_hash(enclosure)
|
|
84
|
-
size = enclosure.bits_length
|
|
85
|
-
|
|
86
|
-
{
|
|
87
|
-
url: enclosure.url.to_s,
|
|
88
|
-
mime_type: enclosure.type,
|
|
89
|
-
size_in_bytes: size&.positive? ? size : nil
|
|
90
|
-
}.compact
|
|
91
|
-
end
|
|
92
|
-
end
|
|
93
|
-
end
|
|
94
|
-
end
|
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Html2rss
|
|
4
|
-
##
|
|
5
|
-
# Builds a JSONFeed 1.1 hash from channel metadata and articles.
|
|
6
|
-
#
|
|
7
|
-
# @see https://www.jsonfeed.org/version/1.1/
|
|
8
|
-
class JsonFeedBuilder
|
|
9
|
-
# Official JSON Feed 1.1 schema version URL.
|
|
10
|
-
VERSION_URL = 'https://jsonfeed.org/version/1.1'
|
|
11
|
-
|
|
12
|
-
##
|
|
13
|
-
# @param channel [Html2rss::RssBuilder::Channel]
|
|
14
|
-
# @param articles [Array<Html2rss::RssBuilder::Article>]
|
|
15
|
-
def initialize(channel:, articles:)
|
|
16
|
-
@channel = channel
|
|
17
|
-
@articles = articles
|
|
18
|
-
end
|
|
19
|
-
|
|
20
|
-
##
|
|
21
|
-
# Builds and returns the JSONFeed hash.
|
|
22
|
-
#
|
|
23
|
-
# @return [Hash] the JSONFeed-compliant hash
|
|
24
|
-
def call
|
|
25
|
-
base_payload.merge(authors: author_array, items: item_hashes).compact
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
private
|
|
29
|
-
|
|
30
|
-
attr_reader :channel, :articles
|
|
31
|
-
|
|
32
|
-
##
|
|
33
|
-
# @return [Hash]
|
|
34
|
-
def base_payload
|
|
35
|
-
{
|
|
36
|
-
version: VERSION_URL,
|
|
37
|
-
title: channel.title,
|
|
38
|
-
home_page_url: channel.url.to_s,
|
|
39
|
-
description: channel.description,
|
|
40
|
-
language: channel.language,
|
|
41
|
-
icon: channel.image&.to_s
|
|
42
|
-
}
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
##
|
|
46
|
-
# @return [Array<Hash>]
|
|
47
|
-
def item_hashes
|
|
48
|
-
articles.filter_map { |article| Item.new(article).to_h }
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
##
|
|
52
|
-
# @return [Array<Hash>, nil]
|
|
53
|
-
def author_array
|
|
54
|
-
return unless (name = channel.author)
|
|
55
|
-
|
|
56
|
-
[{ name: }]
|
|
57
|
-
end
|
|
58
|
-
end
|
|
59
|
-
end
|
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require 'cgi'
|
|
4
|
-
|
|
5
|
-
module Html2rss
|
|
6
|
-
module Rendering
|
|
7
|
-
# Renders an HTML <audio> tag from a URL and type.
|
|
8
|
-
class AudioRenderer
|
|
9
|
-
# @param url [String, Html2rss::Url] media URL for the audio source
|
|
10
|
-
# @param type [String] MIME type for the audio source
|
|
11
|
-
def initialize(url:, type:)
|
|
12
|
-
@url = url
|
|
13
|
-
@type = type
|
|
14
|
-
end
|
|
15
|
-
|
|
16
|
-
# @return [String] HTML audio snippet for article rendering
|
|
17
|
-
def to_html
|
|
18
|
-
[
|
|
19
|
-
'<audio controls preload="none" referrerpolicy="no-referrer" crossorigin="anonymous">',
|
|
20
|
-
%(<source src="#{escaped_url}" type="#{escaped_type}">),
|
|
21
|
-
'</audio>'
|
|
22
|
-
].join
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
private
|
|
26
|
-
|
|
27
|
-
def escaped_url
|
|
28
|
-
CGI.escapeHTML(@url.to_s)
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
def escaped_type
|
|
32
|
-
CGI.escapeHTML(@type.to_s)
|
|
33
|
-
end
|
|
34
|
-
end
|
|
35
|
-
end
|
|
36
|
-
end
|
|
@@ -1,87 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Html2rss
|
|
4
|
-
module Rendering
|
|
5
|
-
# Builds a sanitized article description from the base text, title, and optional media.
|
|
6
|
-
#
|
|
7
|
-
# Combines media elements (images, audio, video, PDFs) with sanitized text content
|
|
8
|
-
# to create rich RSS descriptions that reveal more scraped information.
|
|
9
|
-
#
|
|
10
|
-
# @example Basic usage
|
|
11
|
-
# builder = DescriptionBuilder.new(
|
|
12
|
-
# base: "Article content",
|
|
13
|
-
# title: "Article Title",
|
|
14
|
-
# url: "https://example.com",
|
|
15
|
-
# enclosures: [enclosure_object],
|
|
16
|
-
# image: "https://example.com/image.jpg"
|
|
17
|
-
# )
|
|
18
|
-
# description = builder.call
|
|
19
|
-
class DescriptionBuilder
|
|
20
|
-
# Removes the specified pattern from the beginning of the text
|
|
21
|
-
# within a given range if the pattern occurs before the range's end.
|
|
22
|
-
#
|
|
23
|
-
# @param text [String]
|
|
24
|
-
# @param pattern [String]
|
|
25
|
-
# @param end_of_range [Integer] Optional, defaults to half the text length
|
|
26
|
-
# @return [String]
|
|
27
|
-
def self.remove_pattern_from_start(text, pattern, end_of_range: (text.size * 0.5).to_i)
|
|
28
|
-
return text unless text.is_a?(String) && pattern.is_a?(String) && !pattern.empty?
|
|
29
|
-
|
|
30
|
-
index = text.index(pattern)
|
|
31
|
-
return text if index.nil? || index > end_of_range
|
|
32
|
-
|
|
33
|
-
"#{text[0, index]}#{text[(index + pattern.size)..]}"
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
# @param base [String] The base text content for the description
|
|
37
|
-
# @param title [String] The article title (used for alt text and title removal)
|
|
38
|
-
# @param url [String, Html2rss::Url] The article URL (used for sanitization)
|
|
39
|
-
# @param enclosures [Array<Html2rss::RssBuilder::Enclosure>, nil] Media enclosures
|
|
40
|
-
# @param image [String, Html2rss::Url, nil] Fallback image URL
|
|
41
|
-
def initialize(base:, title:, url:, enclosures:, image:)
|
|
42
|
-
@base = base.to_s
|
|
43
|
-
@title = title
|
|
44
|
-
@url = url
|
|
45
|
-
@enclosures = Array(enclosures)
|
|
46
|
-
@image = image
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
# Generates the complete description with media and sanitized text.
|
|
50
|
-
#
|
|
51
|
-
# @return [String, nil] The complete description or nil if empty
|
|
52
|
-
def call
|
|
53
|
-
fragments = []
|
|
54
|
-
fragments.concat(Array(rendered_media))
|
|
55
|
-
fragments << processed_base_description
|
|
56
|
-
|
|
57
|
-
result = fragments.compact.join("\n\n").strip
|
|
58
|
-
result.empty? ? nil : result
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
private
|
|
62
|
-
|
|
63
|
-
def rendered_media
|
|
64
|
-
rendered = render_enclosures
|
|
65
|
-
return rendered if rendered.any?
|
|
66
|
-
return render_fallback_image if @image
|
|
67
|
-
|
|
68
|
-
[]
|
|
69
|
-
end
|
|
70
|
-
|
|
71
|
-
def render_enclosures
|
|
72
|
-
@enclosures.filter_map do |enclosure|
|
|
73
|
-
MediaRenderer.for(enclosure:, image: @image, title: @title)&.to_html
|
|
74
|
-
end
|
|
75
|
-
end
|
|
76
|
-
|
|
77
|
-
def render_fallback_image
|
|
78
|
-
[MediaRenderer.for(enclosure: nil, image: @image, title: @title)&.to_html]
|
|
79
|
-
end
|
|
80
|
-
|
|
81
|
-
def processed_base_description
|
|
82
|
-
text = self.class.remove_pattern_from_start(@base, @title)
|
|
83
|
-
Html2rss::Selectors::PostProcessors::SanitizeHtml.get(text, @url)
|
|
84
|
-
end
|
|
85
|
-
end
|
|
86
|
-
end
|
|
87
|
-
end
|
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require 'cgi'
|
|
4
|
-
|
|
5
|
-
module Html2rss
|
|
6
|
-
module Rendering
|
|
7
|
-
# Renders an HTML <img> tag from a URL and title.
|
|
8
|
-
class ImageRenderer
|
|
9
|
-
# @param url [String, Html2rss::Url] image URL for the src attribute
|
|
10
|
-
# @param title [String, nil] title/alt text for the image
|
|
11
|
-
def initialize(url:, title:)
|
|
12
|
-
@url = url
|
|
13
|
-
@title = title
|
|
14
|
-
end
|
|
15
|
-
|
|
16
|
-
# @return [String] HTML image snippet for article rendering
|
|
17
|
-
def to_html
|
|
18
|
-
attributes = [
|
|
19
|
-
%(src="#{escaped_url}"),
|
|
20
|
-
%(alt="#{escaped_title}"),
|
|
21
|
-
%(title="#{escaped_title}"),
|
|
22
|
-
'loading="lazy"',
|
|
23
|
-
'referrerpolicy="no-referrer"',
|
|
24
|
-
'decoding="async"',
|
|
25
|
-
'crossorigin="anonymous"'
|
|
26
|
-
]
|
|
27
|
-
"<img #{attributes.join(' ')}>"
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
private
|
|
31
|
-
|
|
32
|
-
def escaped_url
|
|
33
|
-
CGI.escapeHTML(@url.to_s)
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
def escaped_title
|
|
37
|
-
CGI.escapeHTML(@title.to_s)
|
|
38
|
-
end
|
|
39
|
-
end
|
|
40
|
-
end
|
|
41
|
-
end
|
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Html2rss
|
|
4
|
-
module Rendering
|
|
5
|
-
# Factory: picks the appropriate renderer for a given enclosure or fallback image.
|
|
6
|
-
class MediaRenderer
|
|
7
|
-
# @param enclosure [Html2rss::RssBuilder::Enclosure, nil]
|
|
8
|
-
# @param image [String, Html2rss::Url, nil] Fallback image URL
|
|
9
|
-
# @param title [String]
|
|
10
|
-
# @return [ImageRenderer, VideoRenderer, AudioRenderer, PdfRenderer, nil]
|
|
11
|
-
def self.for(enclosure:, image:, title:)
|
|
12
|
-
return ImageRenderer.new(url: image, title:) if enclosure.nil? && image
|
|
13
|
-
return nil unless enclosure
|
|
14
|
-
|
|
15
|
-
create_renderer_for_type(enclosure.type, url: enclosure.url, title:)
|
|
16
|
-
end
|
|
17
|
-
|
|
18
|
-
# @private
|
|
19
|
-
# @param type [String, nil] enclosure MIME type
|
|
20
|
-
# @param url [String, Html2rss::Url] enclosure URL
|
|
21
|
-
# @param title [String, nil] title used by image renderer
|
|
22
|
-
# @return [ImageRenderer, VideoRenderer, AudioRenderer, PdfRenderer, nil]
|
|
23
|
-
def self.create_renderer_for_type(type, url:, title:)
|
|
24
|
-
case type
|
|
25
|
-
when %r{^image/}
|
|
26
|
-
ImageRenderer.new(url:, title:)
|
|
27
|
-
when %r{^video/}
|
|
28
|
-
VideoRenderer.new(url:, type:)
|
|
29
|
-
when %r{^audio/}
|
|
30
|
-
AudioRenderer.new(url:, type:)
|
|
31
|
-
when 'application/pdf'
|
|
32
|
-
PdfRenderer.new(url:)
|
|
33
|
-
end
|
|
34
|
-
end
|
|
35
|
-
end
|
|
36
|
-
end
|
|
37
|
-
end
|
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require 'cgi'
|
|
4
|
-
|
|
5
|
-
module Html2rss
|
|
6
|
-
module Rendering
|
|
7
|
-
# Renders an HTML <iframe> for PDF documents.
|
|
8
|
-
class PdfRenderer
|
|
9
|
-
# @param url [String, Html2rss::Url] PDF URL rendered in the iframe
|
|
10
|
-
def initialize(url:)
|
|
11
|
-
@url = url
|
|
12
|
-
end
|
|
13
|
-
|
|
14
|
-
# @return [String] HTML iframe snippet for PDF rendering
|
|
15
|
-
def to_html
|
|
16
|
-
attributes = [
|
|
17
|
-
%(src="#{escaped_url}"),
|
|
18
|
-
'width="100%"',
|
|
19
|
-
'height="75vh"',
|
|
20
|
-
'sandbox=""',
|
|
21
|
-
'referrerpolicy="no-referrer"',
|
|
22
|
-
'loading="lazy"'
|
|
23
|
-
]
|
|
24
|
-
"<iframe #{attributes.join(' ')}></iframe>"
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
private
|
|
28
|
-
|
|
29
|
-
def escaped_url
|
|
30
|
-
CGI.escapeHTML(@url.to_s)
|
|
31
|
-
end
|
|
32
|
-
end
|
|
33
|
-
end
|
|
34
|
-
end
|
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require 'cgi'
|
|
4
|
-
|
|
5
|
-
module Html2rss
|
|
6
|
-
module Rendering
|
|
7
|
-
# Renders an HTML <video> tag from a URL and type.
|
|
8
|
-
class VideoRenderer
|
|
9
|
-
# @param url [String, Html2rss::Url] media URL for the video source
|
|
10
|
-
# @param type [String] MIME type for the video source
|
|
11
|
-
def initialize(url:, type:)
|
|
12
|
-
@url = url
|
|
13
|
-
@type = type
|
|
14
|
-
end
|
|
15
|
-
|
|
16
|
-
# @return [String] HTML video snippet for article rendering
|
|
17
|
-
def to_html
|
|
18
|
-
[
|
|
19
|
-
'<video controls preload="none" referrerpolicy="no-referrer" crossorigin="anonymous" playsinline>',
|
|
20
|
-
%(<source src="#{escaped_url}" type="#{escaped_type}">),
|
|
21
|
-
'</video>'
|
|
22
|
-
].join
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
private
|
|
26
|
-
|
|
27
|
-
def escaped_url
|
|
28
|
-
CGI.escapeHTML(@url.to_s)
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
def escaped_type
|
|
32
|
-
CGI.escapeHTML(@type.to_s)
|
|
33
|
-
end
|
|
34
|
-
end
|
|
35
|
-
end
|
|
36
|
-
end
|
data/lib/html2rss/rendering.rb
DELETED
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Html2rss
|
|
4
|
-
# Namespace for HTML rendering logic, used to generate rich content such as
|
|
5
|
-
# images, audio, video, or embedded documents for feed descriptions.
|
|
6
|
-
#
|
|
7
|
-
# @see Html2rss::Rendering::DescriptionBuilder
|
|
8
|
-
#
|
|
9
|
-
# @example
|
|
10
|
-
# Html2rss::Rendering::ImageRenderer.new(
|
|
11
|
-
# url: "https://example.com/image.jpg",
|
|
12
|
-
# title: "Example"
|
|
13
|
-
# ).to_html
|
|
14
|
-
#
|
|
15
|
-
# @example
|
|
16
|
-
# Html2rss::Rendering::MediaRenderer.for(
|
|
17
|
-
# enclosure: nil,
|
|
18
|
-
# image: "https://example.com/image.jpg",
|
|
19
|
-
# title: "Example"
|
|
20
|
-
# )
|
|
21
|
-
module Rendering
|
|
22
|
-
end
|
|
23
|
-
end
|
|
@@ -1,133 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Html2rss
|
|
4
|
-
##
|
|
5
|
-
# Tracks runtime request controls together with whether each value was explicitly set.
|
|
6
|
-
class RequestControls
|
|
7
|
-
# Request-control keys accepted at the top level of feed config.
|
|
8
|
-
TOP_LEVEL_KEYS = %i[strategy].freeze
|
|
9
|
-
# Request-control keys accepted under the nested `request` config.
|
|
10
|
-
REQUEST_KEYS = %i[max_redirects max_requests total_timeout_seconds].freeze
|
|
11
|
-
|
|
12
|
-
##
|
|
13
|
-
# @param config [Hash{Symbol => Object}] raw config input
|
|
14
|
-
# @return [RequestControls] request controls extracted from the config hash
|
|
15
|
-
def self.from_config(config)
|
|
16
|
-
HashUtil.assert_symbol_keys!(config, context: 'config', deep: false)
|
|
17
|
-
HashUtil.assert_symbol_keys!(config[:request], context: 'config[:request]') if config[:request].is_a?(Hash)
|
|
18
|
-
|
|
19
|
-
new(
|
|
20
|
-
strategy: config[:strategy],
|
|
21
|
-
max_redirects: request_value_for(config, :max_redirects),
|
|
22
|
-
max_requests: request_value_for(config, :max_requests),
|
|
23
|
-
total_timeout_seconds: request_value_for(config, :total_timeout_seconds),
|
|
24
|
-
explicit_keys: explicit_keys_for(config)
|
|
25
|
-
)
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
def self.explicit_keys_for(config)
|
|
29
|
-
TOP_LEVEL_KEYS.filter { config.key?(_1) } +
|
|
30
|
-
REQUEST_KEYS.filter { request_key?(config, _1) }
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
def self.request_value_for(config, key)
|
|
34
|
-
request_config = config[:request]
|
|
35
|
-
return nil unless request_config.is_a?(Hash)
|
|
36
|
-
|
|
37
|
-
request_config[key]
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
def self.request_key?(config, key)
|
|
41
|
-
request_config = config[:request]
|
|
42
|
-
request_config.is_a?(Hash) && request_config.key?(key)
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
private_class_method :explicit_keys_for, :request_value_for, :request_key?
|
|
46
|
-
|
|
47
|
-
##
|
|
48
|
-
# @param strategy [Symbol, nil] effective request strategy
|
|
49
|
-
# @param max_redirects [Integer, nil] effective redirect limit
|
|
50
|
-
# @param max_requests [Integer, nil] effective request budget
|
|
51
|
-
# @param total_timeout_seconds [Integer, nil] effective request timeout
|
|
52
|
-
# @param explicit_keys [Array<Symbol>] controls explicitly supplied by the caller
|
|
53
|
-
def initialize(strategy: nil, max_redirects: nil, max_requests: nil, total_timeout_seconds: nil, explicit_keys: [])
|
|
54
|
-
@strategy = strategy
|
|
55
|
-
@max_redirects = max_redirects
|
|
56
|
-
@max_requests = max_requests
|
|
57
|
-
@total_timeout_seconds = total_timeout_seconds
|
|
58
|
-
@explicit_keys = explicit_keys.map(&:to_sym).uniq.freeze
|
|
59
|
-
freeze
|
|
60
|
-
end
|
|
61
|
-
|
|
62
|
-
##
|
|
63
|
-
# @return [Symbol, nil] effective request strategy
|
|
64
|
-
attr_reader :strategy
|
|
65
|
-
|
|
66
|
-
##
|
|
67
|
-
# @return [Integer, nil] effective redirect limit
|
|
68
|
-
attr_reader :max_redirects
|
|
69
|
-
|
|
70
|
-
##
|
|
71
|
-
# @return [Integer, nil] effective request budget
|
|
72
|
-
attr_reader :max_requests
|
|
73
|
-
|
|
74
|
-
##
|
|
75
|
-
# @return [Integer, nil] effective request timeout
|
|
76
|
-
attr_reader :total_timeout_seconds
|
|
77
|
-
|
|
78
|
-
##
|
|
79
|
-
# @param name [Symbol, String] request control name
|
|
80
|
-
# @return [Boolean] whether the control was explicitly supplied
|
|
81
|
-
def explicit?(name)
|
|
82
|
-
explicit_keys.include?(name.to_sym)
|
|
83
|
-
end
|
|
84
|
-
|
|
85
|
-
##
|
|
86
|
-
# @param strategy [Symbol, nil] validated request strategy
|
|
87
|
-
# @param max_redirects [Integer, nil] validated redirect limit
|
|
88
|
-
# @param max_requests [Integer, nil] validated request budget
|
|
89
|
-
# @param total_timeout_seconds [Integer, nil] validated request timeout
|
|
90
|
-
# @return [RequestControls] controls updated with validated effective values
|
|
91
|
-
def with_effective_values(strategy:, max_redirects:, max_requests:, total_timeout_seconds:)
|
|
92
|
-
self.class.new(
|
|
93
|
-
strategy:,
|
|
94
|
-
max_redirects:,
|
|
95
|
-
max_requests:,
|
|
96
|
-
total_timeout_seconds:,
|
|
97
|
-
explicit_keys:
|
|
98
|
-
)
|
|
99
|
-
end
|
|
100
|
-
|
|
101
|
-
##
|
|
102
|
-
# Applies only explicitly set controls to the provided config hash.
|
|
103
|
-
#
|
|
104
|
-
# @param config [Hash{Symbol => Object}] mutable config hash
|
|
105
|
-
# @return [Hash{Symbol => Object}] the same hash with explicit controls written
|
|
106
|
-
def apply_to(config)
|
|
107
|
-
config[:strategy] = strategy if explicit?(:strategy)
|
|
108
|
-
apply_request_value(config, :max_redirects, max_redirects)
|
|
109
|
-
apply_request_value(config, :max_requests, max_requests)
|
|
110
|
-
apply_request_value(config, :total_timeout_seconds, total_timeout_seconds)
|
|
111
|
-
config
|
|
112
|
-
end
|
|
113
|
-
|
|
114
|
-
private
|
|
115
|
-
|
|
116
|
-
attr_reader :explicit_keys
|
|
117
|
-
|
|
118
|
-
def apply_request_value(config, key, value)
|
|
119
|
-
return unless explicit?(key)
|
|
120
|
-
|
|
121
|
-
ensure_request_config!(config)
|
|
122
|
-
config[:request][key] = value
|
|
123
|
-
end
|
|
124
|
-
|
|
125
|
-
def ensure_request_config!(config)
|
|
126
|
-
request_config = config[:request]
|
|
127
|
-
return config[:request] = {} if request_config.nil?
|
|
128
|
-
return if request_config.is_a?(Hash)
|
|
129
|
-
|
|
130
|
-
raise ArgumentError, 'request config must be a hash'
|
|
131
|
-
end
|
|
132
|
-
end
|
|
133
|
-
end
|
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Html2rss
|
|
4
|
-
class RequestSession
|
|
5
|
-
##
|
|
6
|
-
# Traverses a rel=next pagination chain for selector-driven extraction.
|
|
7
|
-
class RelNextPager
|
|
8
|
-
include Enumerable
|
|
9
|
-
|
|
10
|
-
##
|
|
11
|
-
# @param session [RequestSession] request session used to execute follow-ups
|
|
12
|
-
# @param initial_response [RequestService::Response] first page response
|
|
13
|
-
# @param max_pages [Integer] configured page budget, including the initial page
|
|
14
|
-
# @param logger [Logger] logger used for pagination stop reasons
|
|
15
|
-
def initialize(session:, initial_response:, max_pages:, logger: Html2rss::Log)
|
|
16
|
-
@session = session
|
|
17
|
-
@initial_response = initial_response
|
|
18
|
-
@max_pages = max_pages
|
|
19
|
-
@logger = logger
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
##
|
|
23
|
-
# Iterates over all paginated responses, beginning with the initial response.
|
|
24
|
-
#
|
|
25
|
-
# @yield [RequestService::Response] each page response
|
|
26
|
-
# @return [Enumerator] enumerator when no block is given
|
|
27
|
-
def each
|
|
28
|
-
return enum_for(:each) unless block_given?
|
|
29
|
-
|
|
30
|
-
yield initial_response
|
|
31
|
-
|
|
32
|
-
current_response = initial_response
|
|
33
|
-
session.effective_page_budget(max_pages).pred.times do
|
|
34
|
-
next_url = next_page_url(current_response)
|
|
35
|
-
break unless follow_up_allowed?(next_url)
|
|
36
|
-
|
|
37
|
-
current_response = fetch_follow_up_response_or_stop(next_url, current_response.url)
|
|
38
|
-
break unless current_response
|
|
39
|
-
|
|
40
|
-
yield current_response
|
|
41
|
-
end
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
private
|
|
45
|
-
|
|
46
|
-
attr_reader :session, :initial_response, :max_pages, :logger
|
|
47
|
-
|
|
48
|
-
def next_page_url(page_response)
|
|
49
|
-
href = page_response.parsed_body.at_css('link[rel~="next"][href], a[rel~="next"][href]')&.[]('href')
|
|
50
|
-
return nil if href.nil? || href.empty?
|
|
51
|
-
|
|
52
|
-
Html2rss::Url.from_relative(href, page_response.url)
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
def follow_up_allowed?(next_url)
|
|
56
|
-
next_url && !session.visited?(next_url)
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
def fetch_follow_up_response_or_stop(next_url, origin_url)
|
|
60
|
-
session.follow_up(url: next_url, relation: :pagination, origin_url:)
|
|
61
|
-
rescue RequestService::RequestBudgetExceeded => error
|
|
62
|
-
logger.warn(
|
|
63
|
-
"#{self.class}: pagination stopped at #{next_url} - #{error.message}. " \
|
|
64
|
-
"Retry with --max-requests #{session.max_requests + 1} or increase request.max_requests in the config."
|
|
65
|
-
)
|
|
66
|
-
nil
|
|
67
|
-
end
|
|
68
|
-
end
|
|
69
|
-
end
|
|
70
|
-
end
|