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,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
module Html
|
|
5
|
+
##
|
|
6
|
+
# ArticleExtractor is responsible for extracting details (headline, url, images, etc.)
|
|
7
|
+
# from an article_tag DOM node. DOM chrome helpers live on {Navigator}.
|
|
8
|
+
class ArticleExtractor
|
|
9
|
+
class << self
|
|
10
|
+
##
|
|
11
|
+
# Extracts article attributes from a DOM element.
|
|
12
|
+
#
|
|
13
|
+
# @param article_tag [Nokogiri::XML::Node] article-like container to extract from
|
|
14
|
+
# @param base_url [String, Html2rss::Url] base url used to resolve relative links
|
|
15
|
+
# @param selected_anchor [Nokogiri::XML::Node, nil] explicit primary anchor for the container
|
|
16
|
+
# @param fallback_anchorless [Boolean] whether to fall back to anchorless extraction
|
|
17
|
+
# @return [Hash{Symbol => Object}] extracted article attributes
|
|
18
|
+
def call(article_tag, base_url:, selected_anchor: nil, fallback_anchorless: false)
|
|
19
|
+
new(article_tag, base_url:, selected_anchor:, fallback_anchorless:).call
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
##
|
|
24
|
+
# @param article_tag [Nokogiri::XML::Node] article-like container to extract from
|
|
25
|
+
# @param base_url [String, Html2rss::Url] base url used to resolve relative links
|
|
26
|
+
# @param selected_anchor [Nokogiri::XML::Node, nil] explicit primary anchor for the container
|
|
27
|
+
# @param fallback_anchorless [Boolean] whether to fall back to anchorless extraction
|
|
28
|
+
def initialize(article_tag, base_url:, selected_anchor: nil, fallback_anchorless: false)
|
|
29
|
+
raise ArgumentError, 'article_tag is required' unless article_tag
|
|
30
|
+
|
|
31
|
+
@article_tag = article_tag
|
|
32
|
+
@base_url = base_url
|
|
33
|
+
@selected_anchor = selected_anchor
|
|
34
|
+
@fallback_anchorless = fallback_anchorless
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @return [Hash{Symbol => Object}] extracted article attributes
|
|
38
|
+
def call
|
|
39
|
+
{
|
|
40
|
+
title: extract_title,
|
|
41
|
+
url: extract_url,
|
|
42
|
+
image: extract_image,
|
|
43
|
+
description: extract_description,
|
|
44
|
+
id: generate_id,
|
|
45
|
+
published_at: extract_published_at,
|
|
46
|
+
enclosures: extract_enclosures,
|
|
47
|
+
categories: extract_categories
|
|
48
|
+
}
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
attr_reader :article_tag, :base_url, :selected_anchor
|
|
54
|
+
|
|
55
|
+
def extract_url
|
|
56
|
+
@extract_url ||= begin
|
|
57
|
+
href = selected_anchor&.[]('href').to_s
|
|
58
|
+
|
|
59
|
+
if href.empty?
|
|
60
|
+
anchorless_url_fallback
|
|
61
|
+
else
|
|
62
|
+
Url.from_relative(href.split('#').first.strip, base_url)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def anchorless_url_fallback
|
|
68
|
+
return unless @fallback_anchorless
|
|
69
|
+
|
|
70
|
+
id = generate_id
|
|
71
|
+
Url.from_relative("##{id}", base_url) if id
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# rubocop:disable Metrics/CyclomaticComplexity
|
|
75
|
+
def extract_title
|
|
76
|
+
source = heading || selected_anchor
|
|
77
|
+
title_text = source ? Navigator.extract_visible_text(source) : fallback_anchorless_title
|
|
78
|
+
return unless title_text
|
|
79
|
+
|
|
80
|
+
kicker = kicker_node ? Navigator.extract_visible_text(kicker_node).to_s.strip : nil
|
|
81
|
+
kicker && !kicker.empty? && !title_text.include?(kicker) ? "#{kicker}: #{title_text}" : title_text
|
|
82
|
+
end
|
|
83
|
+
# rubocop:enable Metrics/CyclomaticComplexity
|
|
84
|
+
|
|
85
|
+
def fallback_anchorless_title
|
|
86
|
+
return unless @fallback_anchorless && selected_anchor.nil?
|
|
87
|
+
|
|
88
|
+
text_node = article_tag.xpath('.//text()').find { |t| !t.text.strip.empty? }
|
|
89
|
+
text_node&.text&.strip
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def heading
|
|
93
|
+
@heading ||= HeadingExtractor.call(
|
|
94
|
+
article_tag,
|
|
95
|
+
fallback_anchorless: @fallback_anchorless,
|
|
96
|
+
selected_anchor:
|
|
97
|
+
)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def kicker_node
|
|
101
|
+
@kicker_node ||= begin
|
|
102
|
+
selector = '[data-tb-kicker], [class*="kicker"], [class*="eyebrow"], ' \
|
|
103
|
+
'[class*="pre-title"], [class*="pretitle"], [class*="overline"]'
|
|
104
|
+
node = article_tag.at_css(selector)
|
|
105
|
+
node && heading && (node == heading || Navigator.descendant_of?(node, heading)) ? nil : node
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def extract_description
|
|
110
|
+
exclude = [heading, selected_anchor, kicker_node].compact
|
|
111
|
+
description = Navigator.extract_visible_text(article_tag, exclude_nodes: exclude)
|
|
112
|
+
return if description.nil?
|
|
113
|
+
|
|
114
|
+
desc = description.strip
|
|
115
|
+
desc.empty? ? nil : desc
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def generate_id
|
|
119
|
+
@generate_id ||= IdGenerator.call(
|
|
120
|
+
article_tag,
|
|
121
|
+
heading:,
|
|
122
|
+
url: (selected_anchor ? extract_url : nil),
|
|
123
|
+
selected_anchor:,
|
|
124
|
+
fallback_anchorless: @fallback_anchorless
|
|
125
|
+
)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def extract_image = ImageExtractor.call(article_tag, base_url:)
|
|
129
|
+
def extract_published_at = DateExtractor.call(article_tag)
|
|
130
|
+
def extract_enclosures = EnclosureExtractor.call(article_tag, base_url)
|
|
131
|
+
def extract_categories = CategoryExtractor.call(article_tag)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
module Html
|
|
5
|
+
class Navigator
|
|
6
|
+
##
|
|
7
|
+
# TextExtractor extracts visible text from DOM elements, preserving lists
|
|
8
|
+
# and block spacing while sanitizing white spaces.
|
|
9
|
+
class TextExtractor
|
|
10
|
+
# HTML block elements that trigger line breaks or special formatting.
|
|
11
|
+
BLOCK_TAGS = %w[p div li ul ol h1 h2 h3 h4 h5 h6 tr br].to_set.freeze
|
|
12
|
+
# Tags ignored when extracting visible text content.
|
|
13
|
+
INVISIBLE_CONTENT_TAGS = %w[svg script noscript style template].to_set.freeze
|
|
14
|
+
|
|
15
|
+
class << self
|
|
16
|
+
##
|
|
17
|
+
# @param tag [Nokogiri::XML::Node] the node from which to extract visible text
|
|
18
|
+
# @param separator [String] separator used to join text fragments (default is a space)
|
|
19
|
+
# @param exclude_nodes [Array<Nokogiri::XML::Node>, nil] nodes to exclude from extraction
|
|
20
|
+
# @return [String, nil] the concatenated visible text, or nil if none is found
|
|
21
|
+
def call(tag, separator: ' ', exclude_nodes: nil)
|
|
22
|
+
return tag.text.gsub(/\s+/, ' ').strip if tag.respond_to?(:text?) && tag.text?
|
|
23
|
+
|
|
24
|
+
parts = iterate_children(tag, separator, exclude_nodes)
|
|
25
|
+
return if parts.empty?
|
|
26
|
+
|
|
27
|
+
parts.join.squeeze(' ').strip
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def iterate_children(tag, separator, exclude_nodes)
|
|
33
|
+
last = false
|
|
34
|
+
tag.children.each_with_object([]) do |c, p|
|
|
35
|
+
next if exclude_nodes&.include?(c) || !visible_child?(c)
|
|
36
|
+
|
|
37
|
+
text, block = process_child_node(c, separator, exclude_nodes)
|
|
38
|
+
next if text.empty?
|
|
39
|
+
|
|
40
|
+
append_separator!(p, separator, block, last)
|
|
41
|
+
(p << text) && (last = block)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def process_child_node(child, separator, exclude_nodes)
|
|
46
|
+
child_text = get_child_text(child, separator, exclude_nodes)
|
|
47
|
+
return ['', false] if child_text.empty?
|
|
48
|
+
|
|
49
|
+
child_text = "- #{child_text}" if child.name == 'li'
|
|
50
|
+
[child_text, BLOCK_TAGS.include?(child.name)]
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def get_child_text(child, separator, exclude_nodes)
|
|
54
|
+
if child.children.empty?
|
|
55
|
+
child.text.to_s.gsub(/\s+/, ' ').strip
|
|
56
|
+
else
|
|
57
|
+
call(child, separator:, exclude_nodes:).to_s.strip
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def append_separator!(parts, separator, is_block, last_was_block)
|
|
62
|
+
return if parts.empty?
|
|
63
|
+
|
|
64
|
+
parts << if is_block || last_was_block
|
|
65
|
+
(separator == ' ' ? "\n" : separator)
|
|
66
|
+
else
|
|
67
|
+
' '
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def visible_child?(node)
|
|
72
|
+
!INVISIBLE_CONTENT_TAGS.include?(node.name) &&
|
|
73
|
+
!(node.name == 'a' && node['href']&.start_with?('#'))
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
module Html
|
|
5
|
+
##
|
|
6
|
+
# Navigator owns DOM chrome recognition and node traversal helpers.
|
|
7
|
+
class Navigator
|
|
8
|
+
# Heading tags used to prioritize title extraction and container assessment.
|
|
9
|
+
HEADING_TAGS = %w[h1 h2 h3 h4 h5 h6].freeze
|
|
10
|
+
|
|
11
|
+
# Element tags that indicate ignored DOM chrome when found in a container path.
|
|
12
|
+
IGNORED_CONTAINER_TAGS = %w[nav footer header svg script style].to_set.freeze
|
|
13
|
+
|
|
14
|
+
# Layout roots and chrome tags excluded from class-clustering candidate nodes.
|
|
15
|
+
CLUSTER_EXCLUDED_TAGS = Set['html', 'body', 'nav', 'footer', 'header', 'svg', 'script', 'style'].freeze
|
|
16
|
+
|
|
17
|
+
# Ancestor tags that usually indicate navigation/utility regions inside a content container.
|
|
18
|
+
UTILITY_LANDMARK_TAGS = %w[nav aside footer menu].to_set.freeze
|
|
19
|
+
|
|
20
|
+
# Anchor selector used to identify the canonical article link element.
|
|
21
|
+
MAIN_ANCHOR_SELECTOR = begin
|
|
22
|
+
buf = +'a[href]:not([href=""])'
|
|
23
|
+
%w[# javascript: mailto: tel: file:// sms: data:].each do |prefix|
|
|
24
|
+
buf << %[:not([href^="#{prefix}"])]
|
|
25
|
+
end
|
|
26
|
+
buf.freeze
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
class << self
|
|
30
|
+
##
|
|
31
|
+
# Extracts visible text from a given node and its children.
|
|
32
|
+
#
|
|
33
|
+
# @param tag [Nokogiri::XML::Node] the node from which to extract visible text
|
|
34
|
+
# @param separator [String] separator used to join text fragments (default is a space)
|
|
35
|
+
# @param exclude_nodes [Array<Nokogiri::XML::Node>, nil] nodes to exclude from extraction
|
|
36
|
+
# @return [String, nil] the concatenated visible text, or nil if none is found
|
|
37
|
+
def extract_visible_text(tag, separator: ' ', exclude_nodes: nil)
|
|
38
|
+
TextExtractor.call(tag, separator:, exclude_nodes:)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
##
|
|
42
|
+
# @param article_tag [Nokogiri::XML::Node] article-like container to search within
|
|
43
|
+
# @return [Nokogiri::XML::Node, nil] first eligible descendant anchor
|
|
44
|
+
def main_anchor_for(article_tag)
|
|
45
|
+
return article_tag if article_tag.name == 'a' && article_tag.matches?(MAIN_ANCHOR_SELECTOR)
|
|
46
|
+
|
|
47
|
+
article_tag.at_css(MAIN_ANCHOR_SELECTOR)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
##
|
|
51
|
+
# @param node [Nokogiri::XML::Node]
|
|
52
|
+
# @param cache [Hash, nil] identity cache used to store results (must use compare_by_identity)
|
|
53
|
+
# @return [Boolean] true when the node belongs to ignored DOM chrome
|
|
54
|
+
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
55
|
+
def ignored_container_path?(node, cache = nil)
|
|
56
|
+
return cache[node] if cache&.key?(node)
|
|
57
|
+
|
|
58
|
+
curr = node
|
|
59
|
+
visited = []
|
|
60
|
+
is_ignored = false
|
|
61
|
+
|
|
62
|
+
while curr.respond_to?(:parent) && curr
|
|
63
|
+
if cache&.key?(curr)
|
|
64
|
+
is_ignored = cache[curr]
|
|
65
|
+
break
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
if IGNORED_CONTAINER_TAGS.include?(curr.name)
|
|
69
|
+
is_ignored = true
|
|
70
|
+
break
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
visited << curr
|
|
74
|
+
curr = curr.parent
|
|
75
|
+
end
|
|
76
|
+
visited.each { |n| cache[n] = is_ignored } if cache
|
|
77
|
+
|
|
78
|
+
is_ignored
|
|
79
|
+
end
|
|
80
|
+
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
81
|
+
|
|
82
|
+
##
|
|
83
|
+
# Returns the first parent that satisfies the condition.
|
|
84
|
+
# If the condition is met, it returns the node itself.
|
|
85
|
+
#
|
|
86
|
+
# @param node [Nokogiri::XML::Node] The node to start the search from.
|
|
87
|
+
# @param condition [Proc] The condition to be met.
|
|
88
|
+
# @return [Nokogiri::XML::Node, nil] The first parent that satisfies the condition.
|
|
89
|
+
def parent_until_condition(node, condition)
|
|
90
|
+
while node && !node.document? && node.name != 'html'
|
|
91
|
+
return node if condition.call(node)
|
|
92
|
+
|
|
93
|
+
node = node.parent
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
##
|
|
98
|
+
# Think of it as `css_upwards` method.
|
|
99
|
+
# It searches for the closest parent that matches the given selector.
|
|
100
|
+
#
|
|
101
|
+
# @param current_tag [Nokogiri::XML::Node, nil] starting node
|
|
102
|
+
# @param selector [String] CSS selector to search upwards for
|
|
103
|
+
# @return [Nokogiri::XML::Node, nil] first matching node in upward traversal
|
|
104
|
+
def find_closest_selector_upwards(current_tag, selector)
|
|
105
|
+
while current_tag
|
|
106
|
+
found = current_tag.at_css(selector)
|
|
107
|
+
return found if found
|
|
108
|
+
|
|
109
|
+
return nil unless current_tag.respond_to?(:parent)
|
|
110
|
+
|
|
111
|
+
current_tag = current_tag.parent
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
##
|
|
116
|
+
# Searches for the closest parent that matches the given tag name.
|
|
117
|
+
#
|
|
118
|
+
# @param current_tag [Nokogiri::XML::Node] starting node
|
|
119
|
+
# @param tag_name [String] tag name to find in ancestors
|
|
120
|
+
# @return [Nokogiri::XML::Node, nil] matching ancestor node
|
|
121
|
+
def find_tag_in_ancestors(current_tag, tag_name)
|
|
122
|
+
return current_tag if current_tag.name == tag_name
|
|
123
|
+
|
|
124
|
+
current_tag.ancestors(tag_name).first
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
##
|
|
128
|
+
# Returns true if child_node is a descendant of parent_node.
|
|
129
|
+
# Walks up using parent pointers to avoid NodeSet allocations.
|
|
130
|
+
#
|
|
131
|
+
# @param child_node [Nokogiri::XML::Node] potential descendant
|
|
132
|
+
# @param parent_node [Nokogiri::XML::Node] potential ancestor
|
|
133
|
+
# @return [Boolean] true when child_node is a descendant of parent_node
|
|
134
|
+
def descendant_of?(child_node, parent_node)
|
|
135
|
+
curr = child_node.respond_to?(:parent) ? child_node.parent : nil
|
|
136
|
+
while curr
|
|
137
|
+
return true if curr == parent_node
|
|
138
|
+
|
|
139
|
+
curr = curr.respond_to?(:parent) ? curr.parent : nil
|
|
140
|
+
end
|
|
141
|
+
false
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'cgi'
|
|
4
|
+
|
|
5
|
+
module Html2rss
|
|
6
|
+
module Html
|
|
7
|
+
module Rendering
|
|
8
|
+
# Renders an HTML <audio> tag from a URL and type.
|
|
9
|
+
class AudioRenderer
|
|
10
|
+
# @param url [String, Html2rss::Url] media URL for the audio source
|
|
11
|
+
# @param type [String] MIME type for the audio source
|
|
12
|
+
def initialize(url:, type:)
|
|
13
|
+
@url = url
|
|
14
|
+
@type = type
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# @return [String] HTML audio snippet for article rendering
|
|
18
|
+
def to_html
|
|
19
|
+
[
|
|
20
|
+
'<audio controls preload="none" referrerpolicy="no-referrer" crossorigin="anonymous">',
|
|
21
|
+
%(<source src="#{escaped_url}" type="#{escaped_type}">),
|
|
22
|
+
'</audio>'
|
|
23
|
+
].join
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def escaped_url
|
|
29
|
+
CGI.escapeHTML(@url.to_s)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def escaped_type
|
|
33
|
+
CGI.escapeHTML(@type.to_s)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
module Html
|
|
5
|
+
module Rendering
|
|
6
|
+
# Builds a sanitized article description from the base text, title, and optional media.
|
|
7
|
+
#
|
|
8
|
+
# Combines media elements (images, audio, video, PDFs) with sanitized text content
|
|
9
|
+
# to create rich RSS descriptions that reveal more scraped information.
|
|
10
|
+
#
|
|
11
|
+
# @example Basic usage
|
|
12
|
+
# builder = DescriptionBuilder.new(
|
|
13
|
+
# base: "Article content",
|
|
14
|
+
# title: "Article Title",
|
|
15
|
+
# url: "https://example.com",
|
|
16
|
+
# enclosures: [enclosure_object],
|
|
17
|
+
# image: "https://example.com/image.jpg"
|
|
18
|
+
# )
|
|
19
|
+
# description = builder.call
|
|
20
|
+
class DescriptionBuilder
|
|
21
|
+
# Removes the specified pattern from the beginning of the text
|
|
22
|
+
# within a given range if the pattern occurs before the range's end.
|
|
23
|
+
#
|
|
24
|
+
# @param text [String]
|
|
25
|
+
# @param pattern [String]
|
|
26
|
+
# @param end_of_range [Integer] Optional, defaults to half the text length
|
|
27
|
+
# @return [String]
|
|
28
|
+
def self.remove_pattern_from_start(text, pattern, end_of_range: (text.size * 0.5).to_i)
|
|
29
|
+
return text unless text.is_a?(String) && pattern.is_a?(String) && !pattern.empty?
|
|
30
|
+
|
|
31
|
+
index = text.index(pattern)
|
|
32
|
+
return text if index.nil? || index > end_of_range
|
|
33
|
+
|
|
34
|
+
"#{text[0, index]}#{text[(index + pattern.size)..]}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @param base [String] The base text content for the description
|
|
38
|
+
# @param title [String] The article title (used for alt text and title removal)
|
|
39
|
+
# @param url [String, Html2rss::Url] The article URL (used for sanitization)
|
|
40
|
+
# @param enclosures [Array<Html2rss::Article::Enclosure>, nil] Media enclosures
|
|
41
|
+
# @param image [String, Html2rss::Url, nil] Fallback image URL
|
|
42
|
+
def initialize(base:, title:, url:, enclosures:, image:)
|
|
43
|
+
@base = base.to_s
|
|
44
|
+
@title = title
|
|
45
|
+
@url = url
|
|
46
|
+
@enclosures = Array(enclosures)
|
|
47
|
+
@image = image
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Generates the complete description with media and sanitized text.
|
|
51
|
+
#
|
|
52
|
+
# @return [String, nil] The complete description or nil if empty
|
|
53
|
+
def call
|
|
54
|
+
fragments = []
|
|
55
|
+
fragments.concat(Array(rendered_media))
|
|
56
|
+
fragments << processed_base_description
|
|
57
|
+
|
|
58
|
+
result = fragments.compact.join("\n\n").strip
|
|
59
|
+
result.empty? ? nil : result
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def rendered_media
|
|
65
|
+
rendered = render_enclosures
|
|
66
|
+
return rendered if rendered.any?
|
|
67
|
+
return render_fallback_image if @image
|
|
68
|
+
|
|
69
|
+
[]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def render_enclosures
|
|
73
|
+
@enclosures.filter_map do |enclosure|
|
|
74
|
+
MediaRenderer.for(enclosure:, image: @image, title: @title)&.to_html
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def render_fallback_image
|
|
79
|
+
[MediaRenderer.for(enclosure: nil, image: @image, title: @title)&.to_html]
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def processed_base_description
|
|
83
|
+
text = self.class.remove_pattern_from_start(@base, @title)
|
|
84
|
+
Html2rss::Selectors::PostProcessors::SanitizeHtml.get(text, @url)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'cgi'
|
|
4
|
+
|
|
5
|
+
module Html2rss
|
|
6
|
+
module Html
|
|
7
|
+
module Rendering
|
|
8
|
+
# Renders an HTML <img> tag from a URL and title.
|
|
9
|
+
class ImageRenderer
|
|
10
|
+
# @param url [String, Html2rss::Url] image URL for the src attribute
|
|
11
|
+
# @param title [String, nil] title/alt text for the image
|
|
12
|
+
def initialize(url:, title:)
|
|
13
|
+
@url = url
|
|
14
|
+
@title = title
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# @return [String] HTML image snippet for article rendering
|
|
18
|
+
def to_html
|
|
19
|
+
attributes = [
|
|
20
|
+
%(src="#{escaped_url}"),
|
|
21
|
+
%(alt="#{escaped_title}"),
|
|
22
|
+
%(title="#{escaped_title}"),
|
|
23
|
+
'loading="lazy"',
|
|
24
|
+
'referrerpolicy="no-referrer"',
|
|
25
|
+
'decoding="async"',
|
|
26
|
+
'crossorigin="anonymous"'
|
|
27
|
+
]
|
|
28
|
+
"<img #{attributes.join(' ')}>"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def escaped_url
|
|
34
|
+
CGI.escapeHTML(@url.to_s)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def escaped_title
|
|
38
|
+
CGI.escapeHTML(@title.to_s)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
module Html
|
|
5
|
+
module Rendering
|
|
6
|
+
# Factory: picks the appropriate renderer for a given enclosure or fallback image.
|
|
7
|
+
class MediaRenderer
|
|
8
|
+
# @param enclosure [Html2rss::Article::Enclosure, nil]
|
|
9
|
+
# @param image [String, Html2rss::Url, nil] Fallback image URL
|
|
10
|
+
# @param title [String]
|
|
11
|
+
# @return [ImageRenderer, VideoRenderer, AudioRenderer, PdfRenderer, nil]
|
|
12
|
+
def self.for(enclosure:, image:, title:)
|
|
13
|
+
return ImageRenderer.new(url: image, title:) if enclosure.nil? && image
|
|
14
|
+
return nil unless enclosure
|
|
15
|
+
|
|
16
|
+
create_renderer_for_type(enclosure.type, url: enclosure.url, title:)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# @private
|
|
20
|
+
# @param type [String, nil] enclosure MIME type
|
|
21
|
+
# @param url [String, Html2rss::Url] enclosure URL
|
|
22
|
+
# @param title [String, nil] title used by image renderer
|
|
23
|
+
# @return [ImageRenderer, VideoRenderer, AudioRenderer, PdfRenderer, nil]
|
|
24
|
+
def self.create_renderer_for_type(type, url:, title:)
|
|
25
|
+
case type
|
|
26
|
+
when %r{^image/}
|
|
27
|
+
ImageRenderer.new(url:, title:)
|
|
28
|
+
when %r{^video/}
|
|
29
|
+
VideoRenderer.new(url:, type:)
|
|
30
|
+
when %r{^audio/}
|
|
31
|
+
AudioRenderer.new(url:, type:)
|
|
32
|
+
when 'application/pdf'
|
|
33
|
+
PdfRenderer.new(url:)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'cgi'
|
|
4
|
+
|
|
5
|
+
module Html2rss
|
|
6
|
+
module Html
|
|
7
|
+
module Rendering
|
|
8
|
+
# Renders an HTML <iframe> for PDF documents.
|
|
9
|
+
class PdfRenderer
|
|
10
|
+
# @param url [String, Html2rss::Url] PDF URL rendered in the iframe
|
|
11
|
+
def initialize(url:)
|
|
12
|
+
@url = url
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# @return [String] HTML iframe snippet for PDF rendering
|
|
16
|
+
def to_html
|
|
17
|
+
attributes = [
|
|
18
|
+
%(src="#{escaped_url}"),
|
|
19
|
+
'width="100%"',
|
|
20
|
+
'height="75vh"',
|
|
21
|
+
'sandbox=""',
|
|
22
|
+
'referrerpolicy="no-referrer"',
|
|
23
|
+
'loading="lazy"'
|
|
24
|
+
]
|
|
25
|
+
"<iframe #{attributes.join(' ')}></iframe>"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def escaped_url
|
|
31
|
+
CGI.escapeHTML(@url.to_s)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'cgi'
|
|
4
|
+
|
|
5
|
+
module Html2rss
|
|
6
|
+
module Html
|
|
7
|
+
module Rendering
|
|
8
|
+
# Renders an HTML <video> tag from a URL and type.
|
|
9
|
+
class VideoRenderer
|
|
10
|
+
# @param url [String, Html2rss::Url] media URL for the video source
|
|
11
|
+
# @param type [String] MIME type for the video source
|
|
12
|
+
def initialize(url:, type:)
|
|
13
|
+
@url = url
|
|
14
|
+
@type = type
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# @return [String] HTML video snippet for article rendering
|
|
18
|
+
def to_html
|
|
19
|
+
[
|
|
20
|
+
'<video controls preload="none" referrerpolicy="no-referrer" crossorigin="anonymous" playsinline>',
|
|
21
|
+
%(<source src="#{escaped_url}" type="#{escaped_type}">),
|
|
22
|
+
'</video>'
|
|
23
|
+
].join
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def escaped_url
|
|
29
|
+
CGI.escapeHTML(@url.to_s)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def escaped_type
|
|
33
|
+
CGI.escapeHTML(@type.to_s)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
module Html
|
|
5
|
+
# Namespace for HTML rendering logic, used to generate rich content such as
|
|
6
|
+
# images, audio, video, or embedded documents for feed descriptions.
|
|
7
|
+
#
|
|
8
|
+
# @see Html2rss::Html::Rendering::DescriptionBuilder
|
|
9
|
+
#
|
|
10
|
+
# @example
|
|
11
|
+
# Html2rss::Html::Rendering::ImageRenderer.new(
|
|
12
|
+
# url: "https://example.com/image.jpg",
|
|
13
|
+
# title: "Example"
|
|
14
|
+
# ).to_html
|
|
15
|
+
#
|
|
16
|
+
# @example
|
|
17
|
+
# Html2rss::Html::Rendering::MediaRenderer.for(
|
|
18
|
+
# enclosure: nil,
|
|
19
|
+
# image: "https://example.com/image.jpg",
|
|
20
|
+
# title: "Example"
|
|
21
|
+
# )
|
|
22
|
+
module Rendering
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|