html2rss 0.22.2 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +11 -108
- data/lib/html2rss/{articles → article}/deduplicator.rb +3 -4
- data/lib/html2rss/{rss_builder → article}/enclosure.rb +6 -22
- data/lib/html2rss/article.rb +213 -0
- data/lib/html2rss/auto_source/discovery/dom_clustering/group_scorer.rb +80 -0
- data/lib/html2rss/auto_source/discovery/dom_clustering/overlap_resolver.rb +86 -0
- data/lib/html2rss/auto_source/discovery/dom_clustering.rb +119 -0
- data/lib/html2rss/auto_source/discovery/list_candidates.rb +94 -0
- data/lib/html2rss/auto_source/discovery/semantic_anchor_candidates.rb +219 -0
- data/lib/html2rss/auto_source/discovery/semantic_containers.rb +71 -0
- data/lib/html2rss/auto_source/discovery/sitemap.rb +159 -0
- data/lib/html2rss/auto_source/discovery.rb +14 -0
- data/lib/html2rss/auto_source/link_heuristics/anchor_signals.rb +28 -0
- data/lib/html2rss/auto_source/link_heuristics/container_assessor.rb +106 -0
- data/lib/html2rss/auto_source/link_heuristics/container_signals.rb +80 -0
- data/lib/html2rss/auto_source/link_heuristics/destination_facts.rb +42 -0
- data/lib/html2rss/auto_source/link_heuristics/href_extractor.rb +38 -0
- data/lib/html2rss/auto_source/link_heuristics/path_classifier.rb +221 -0
- data/lib/html2rss/auto_source/link_heuristics/text_classifier.rb +66 -0
- data/lib/html2rss/auto_source/link_heuristics.rb +139 -0
- data/lib/html2rss/auto_source/scraper/html.rb +22 -33
- data/lib/html2rss/auto_source/scraper/json_state.rb +1 -1
- data/lib/html2rss/auto_source/scraper/meta_oembed.rb +163 -0
- data/lib/html2rss/auto_source/scraper/microformats2.rb +146 -0
- data/lib/html2rss/auto_source/scraper/schema/category_extractor.rb +29 -114
- data/lib/html2rss/auto_source/scraper/schema/item_list.rb +50 -11
- data/lib/html2rss/auto_source/scraper/schema/thing.rb +35 -7
- data/lib/html2rss/auto_source/scraper/schema.rb +61 -18
- data/lib/html2rss/auto_source/scraper/semantic_html/{deduplicator.rb → entry_deduplicator.rb} +3 -3
- data/lib/html2rss/auto_source/scraper/semantic_html.rb +23 -171
- data/lib/html2rss/auto_source/scraper/sitemap.rb +153 -0
- data/lib/html2rss/auto_source/scraper/wordpress_api/page_scope/date_archive_range.rb +110 -0
- data/lib/html2rss/auto_source/scraper/wordpress_api/page_scope.rb +2 -78
- data/lib/html2rss/auto_source/scraper/wordpress_api.rb +9 -0
- data/lib/html2rss/auto_source/scraper.rb +10 -3
- data/lib/html2rss/auto_source.rb +30 -35
- data/lib/html2rss/channel.rb +198 -0
- data/lib/html2rss/cli.rb +5 -6
- data/lib/html2rss/config/auto_source_contract.rb +51 -0
- data/lib/html2rss/config/request_controls.rb +136 -0
- data/lib/html2rss/config/request_headers.rb +1 -1
- data/lib/html2rss/config/schema.rb +65 -114
- data/lib/html2rss/{selectors/config.rb → config/selectors_validator.rb} +56 -7
- data/lib/html2rss/config/validator.rb +6 -6
- data/lib/html2rss/config.rb +176 -19
- data/lib/html2rss/{configuration.rb → defaults.rb} +12 -10
- data/lib/html2rss/feed_builder/item_presentation.rb +42 -0
- data/lib/html2rss/feed_builder/json_feed/item.rb +112 -0
- data/lib/html2rss/feed_builder/json_feed.rb +81 -0
- data/lib/html2rss/feed_builder/rss/stylesheet.rb +65 -0
- data/lib/html2rss/feed_builder/rss.rb +116 -0
- data/lib/html2rss/feed_builder.rb +15 -0
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +120 -60
- data/lib/html2rss/feed_pipeline/runtime_policy.rb +108 -0
- data/lib/html2rss/feed_pipeline/strategy_plan.rb +64 -0
- data/lib/html2rss/feed_pipeline.rb +83 -89
- data/lib/html2rss/feed_result.rb +92 -0
- data/lib/html2rss/html/article_extractor/category_extractor.rb +123 -0
- data/lib/html2rss/html/article_extractor/date_extractor.rb +22 -0
- data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +95 -0
- data/lib/html2rss/html/article_extractor/heading_extractor.rb +54 -0
- data/lib/html2rss/html/article_extractor/id_generator.rb +69 -0
- data/lib/html2rss/html/article_extractor/image_extractor.rb +60 -0
- data/lib/html2rss/html/article_extractor.rb +134 -0
- data/lib/html2rss/html/navigator/text_extractor.rb +79 -0
- data/lib/html2rss/html/navigator.rb +146 -0
- data/lib/html2rss/html/rendering/audio_renderer.rb +38 -0
- data/lib/html2rss/html/rendering/description_builder.rb +89 -0
- data/lib/html2rss/html/rendering/image_renderer.rb +43 -0
- data/lib/html2rss/html/rendering/media_renderer.rb +39 -0
- data/lib/html2rss/html/rendering/pdf_renderer.rb +36 -0
- data/lib/html2rss/html/rendering/video_renderer.rb +38 -0
- data/lib/html2rss/html/rendering.rb +25 -0
- data/lib/html2rss/html.rb +8 -0
- data/lib/html2rss/request_service/blocked_surface.rb +67 -0
- data/lib/html2rss/request_service/botasaurus_contract.rb +33 -9
- data/lib/html2rss/request_service/botasaurus_strategy.rb +21 -30
- data/lib/html2rss/request_service/browserless_strategy.rb +2 -23
- data/lib/html2rss/request_service/budget.rb +97 -18
- data/lib/html2rss/request_service/context.rb +1 -3
- data/lib/html2rss/request_service/faraday_strategy.rb +12 -26
- data/lib/html2rss/request_service/local_file_strategy.rb +12 -4
- data/lib/html2rss/request_service/network_guard.rb +125 -0
- data/lib/html2rss/request_service/policy.rb +10 -82
- data/lib/html2rss/request_service/puppet_commander/navigation_guards.rb +148 -0
- data/lib/html2rss/request_service/puppet_commander/preload_runner.rb +86 -0
- data/lib/html2rss/request_service/puppet_commander.rb +22 -191
- data/lib/html2rss/request_service/response.rb +9 -1
- data/lib/html2rss/request_service/response_guard.rb +1 -1
- data/lib/html2rss/request_service/strategy.rb +123 -5
- data/lib/html2rss/request_service.rb +7 -2
- data/lib/html2rss/request_session/pager/base.rb +83 -0
- data/lib/html2rss/request_session/pager/custom_selector.rb +39 -0
- data/lib/html2rss/request_session/pager/json_cursor.rb +78 -0
- data/lib/html2rss/request_session/pager/offset.rb +48 -0
- data/lib/html2rss/request_session/pager/rel_next.rb +23 -0
- data/lib/html2rss/request_session/pager/url_template.rb +48 -0
- data/lib/html2rss/request_session/pager.rb +75 -0
- data/lib/html2rss/request_session.rb +29 -16
- data/lib/html2rss/selectors/item_scope.rb +30 -0
- data/lib/html2rss/selectors/post_processors/base.rb +1 -1
- data/lib/html2rss/selectors/post_processors/markdown_to_html.rb +0 -1
- data/lib/html2rss/selectors/post_processors/sanitize_html.rb +0 -2
- data/lib/html2rss/selectors/post_processors/template.rb +7 -3
- data/lib/html2rss/selectors.rb +84 -64
- data/lib/html2rss/status.rb +132 -0
- data/lib/html2rss/version.rb +1 -3
- data/lib/html2rss.rb +34 -22
- data/schema/html2rss-config.schema.json +109 -9
- metadata +71 -43
- data/lib/html2rss/auto_source/scraper/html/class_clustering.rb +0 -196
- data/lib/html2rss/auto_source/scraper/link_heuristics.rb +0 -401
- data/lib/html2rss/auto_source/scraper/schema/list_item.rb +0 -28
- data/lib/html2rss/auto_source/scraper/semantic_html/anchor_selector.rb +0 -49
- data/lib/html2rss/blocked_surface.rb +0 -65
- data/lib/html2rss/category_extractor.rb +0 -116
- data/lib/html2rss/config/class_methods.rb +0 -183
- data/lib/html2rss/html_extractor/date_extractor.rb +0 -20
- data/lib/html2rss/html_extractor/enclosure_extractor.rb +0 -91
- data/lib/html2rss/html_extractor/heading_extractor.rb +0 -50
- data/lib/html2rss/html_extractor/id_generator.rb +0 -67
- data/lib/html2rss/html_extractor/image_extractor.rb +0 -58
- data/lib/html2rss/html_extractor/list_candidates.rb +0 -88
- data/lib/html2rss/html_extractor/semantic_anchor_candidates.rb +0 -260
- data/lib/html2rss/html_extractor/semantic_containers.rb +0 -69
- data/lib/html2rss/html_extractor/text_extractor.rb +0 -77
- data/lib/html2rss/html_extractor.rb +0 -190
- data/lib/html2rss/html_navigator.rb +0 -71
- data/lib/html2rss/json_feed_builder/item.rb +0 -94
- data/lib/html2rss/json_feed_builder.rb +0 -59
- data/lib/html2rss/rendering/audio_renderer.rb +0 -36
- data/lib/html2rss/rendering/description_builder.rb +0 -87
- data/lib/html2rss/rendering/image_renderer.rb +0 -41
- data/lib/html2rss/rendering/media_renderer.rb +0 -37
- data/lib/html2rss/rendering/pdf_renderer.rb +0 -34
- data/lib/html2rss/rendering/video_renderer.rb +0 -36
- data/lib/html2rss/rendering.rb +0 -23
- data/lib/html2rss/request_controls.rb +0 -133
- data/lib/html2rss/request_session/rel_next_pager.rb +0 -70
- data/lib/html2rss/request_session/runtime_input.rb +0 -71
- data/lib/html2rss/request_session/runtime_policy.rb +0 -84
- data/lib/html2rss/rss_builder/article.rb +0 -208
- data/lib/html2rss/rss_builder/channel.rb +0 -118
- data/lib/html2rss/rss_builder/stylesheet.rb +0 -63
- data/lib/html2rss/rss_builder.rb +0 -101
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
module Html
|
|
5
|
+
class ArticleExtractor
|
|
6
|
+
##
|
|
7
|
+
# Extracts enclosures from HTML tags using various strategies.
|
|
8
|
+
class EnclosureExtractor
|
|
9
|
+
# CSS union query covering images, media, PDFs, iframes, and archives.
|
|
10
|
+
SELECTOR = [
|
|
11
|
+
'img[src]:not([src^="data"])',
|
|
12
|
+
'video source[src]',
|
|
13
|
+
'audio source[src]',
|
|
14
|
+
'audio[src]',
|
|
15
|
+
'a[href$=".pdf"]',
|
|
16
|
+
'iframe[src]',
|
|
17
|
+
'a[href$=".zip"]',
|
|
18
|
+
'a[href$=".tar.gz"]',
|
|
19
|
+
'a[href$=".tgz"]'
|
|
20
|
+
].join(',').freeze
|
|
21
|
+
|
|
22
|
+
# @param article_tag [Nokogiri::XML::Element] article container node
|
|
23
|
+
# @param base_url [String, Html2rss::Url] base URL for relative enclosure links
|
|
24
|
+
# @return [Array<Hash{Symbol => Object}>] normalized enclosure hashes
|
|
25
|
+
def self.call(article_tag, base_url)
|
|
26
|
+
return [] unless article_tag
|
|
27
|
+
|
|
28
|
+
article_tag.css(SELECTOR).filter_map do |element|
|
|
29
|
+
extract_from_element(element, base_url)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.extract_from_element(element, base_url)
|
|
34
|
+
case element.name
|
|
35
|
+
when 'img'
|
|
36
|
+
extract_image(element, base_url)
|
|
37
|
+
when 'video', 'audio', 'source'
|
|
38
|
+
extract_media(element, base_url)
|
|
39
|
+
when 'iframe'
|
|
40
|
+
extract_iframe(element, base_url)
|
|
41
|
+
when 'a'
|
|
42
|
+
extract_a(element, base_url)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.extract_image(img, base_url)
|
|
47
|
+
src = img['src'].to_s
|
|
48
|
+
return if src.empty?
|
|
49
|
+
|
|
50
|
+
abs_url = Url.from_relative(src, base_url)
|
|
51
|
+
{
|
|
52
|
+
url: abs_url,
|
|
53
|
+
type: Article::Enclosure.guess_content_type_from_url(abs_url, default: 'image/jpeg')
|
|
54
|
+
}
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def self.extract_media(element, base_url)
|
|
58
|
+
src = element['src'].to_s
|
|
59
|
+
return if src.empty?
|
|
60
|
+
|
|
61
|
+
{
|
|
62
|
+
url: Url.from_relative(src, base_url),
|
|
63
|
+
type: element['type']
|
|
64
|
+
}
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def self.extract_iframe(iframe, base_url)
|
|
68
|
+
src = iframe['src'].to_s
|
|
69
|
+
return if src.empty?
|
|
70
|
+
|
|
71
|
+
abs_url = Url.from_relative(src, base_url)
|
|
72
|
+
{
|
|
73
|
+
url: abs_url,
|
|
74
|
+
type: Article::Enclosure.guess_content_type_from_url(abs_url, default: 'text/html')
|
|
75
|
+
}
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def self.extract_a(link, base_url)
|
|
79
|
+
href = link['href'].to_s
|
|
80
|
+
return if href.empty?
|
|
81
|
+
|
|
82
|
+
abs_url = Url.from_relative(href, base_url)
|
|
83
|
+
|
|
84
|
+
if href.end_with?('.pdf')
|
|
85
|
+
{ url: abs_url, type: Article::Enclosure.guess_content_type_from_url(abs_url) }
|
|
86
|
+
else
|
|
87
|
+
{ url: abs_url, type: 'application/zip' }
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private_class_method :extract_from_element, :extract_image, :extract_media, :extract_iframe, :extract_a
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
module Html
|
|
5
|
+
class ArticleExtractor
|
|
6
|
+
##
|
|
7
|
+
# HeadingExtractor identifies and returns the best heading element within a container.
|
|
8
|
+
class HeadingExtractor
|
|
9
|
+
# Heading tags used to prioritize title extraction.
|
|
10
|
+
HEADING_TAGS = Navigator::HEADING_TAGS
|
|
11
|
+
|
|
12
|
+
class << self
|
|
13
|
+
##
|
|
14
|
+
# @param article_tag [Nokogiri::XML::Element] container node
|
|
15
|
+
# @param fallback_anchorless [Boolean] whether to use fallback search
|
|
16
|
+
# @param selected_anchor [Nokogiri::XML::Node, nil] anchor element
|
|
17
|
+
# @return [Nokogiri::XML::Node, nil] the heading node, if found
|
|
18
|
+
def call(article_tag, fallback_anchorless:, selected_anchor:)
|
|
19
|
+
tags = article_tag.css(HEADING_TAGS.join(','))
|
|
20
|
+
if tags.any?
|
|
21
|
+
select_best_heading(tags)
|
|
22
|
+
elsif fallback_anchorless && selected_anchor.nil?
|
|
23
|
+
fallback_heading(article_tag)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def select_best_heading(tags)
|
|
30
|
+
min_tag_name = tags.map(&:name).min
|
|
31
|
+
best_tag = nil
|
|
32
|
+
max_size = -1
|
|
33
|
+
|
|
34
|
+
tags.each do |tag|
|
|
35
|
+
next if tag.name != min_tag_name
|
|
36
|
+
|
|
37
|
+
size = Navigator::TextExtractor.call(tag)&.size.to_i
|
|
38
|
+
(best_tag = tag) && (max_size = size) if size > max_size
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
best_tag
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def fallback_heading(article_tag)
|
|
45
|
+
fallback_tags = article_tag.css(
|
|
46
|
+
'strong, b, [class*="title"], [class*="font-bold"], [class*="font-semibold"]'
|
|
47
|
+
)
|
|
48
|
+
fallback_tags.find { |t| !Navigator::TextExtractor.call(t).to_s.strip.empty? }
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'zlib'
|
|
4
|
+
|
|
5
|
+
module Html2rss
|
|
6
|
+
module Html
|
|
7
|
+
class ArticleExtractor
|
|
8
|
+
##
|
|
9
|
+
# IdGenerator determines the unique ID for an article container node.
|
|
10
|
+
class IdGenerator
|
|
11
|
+
class << self
|
|
12
|
+
##
|
|
13
|
+
# @param article_tag [Nokogiri::XML::Element] container node
|
|
14
|
+
# @param heading [Nokogiri::XML::Node, nil] heading node
|
|
15
|
+
# @param url [Html2rss::Url, nil] absolute article URL
|
|
16
|
+
# @param selected_anchor [Nokogiri::XML::Node, nil] anchor element
|
|
17
|
+
# @param fallback_anchorless [Boolean] whether to use fallback hashing
|
|
18
|
+
# @return [String, nil] the generated ID, if any
|
|
19
|
+
def call(article_tag, heading:, url:, selected_anchor:, fallback_anchorless:)
|
|
20
|
+
id_from_dom = parse_id_from_dom(article_tag, url, selected_anchor)
|
|
21
|
+
return id_from_dom if id_from_dom
|
|
22
|
+
|
|
23
|
+
heading_text = resolve_heading_text(article_tag, heading, fallback_anchorless)
|
|
24
|
+
if heading_text && !heading_text.strip.empty?
|
|
25
|
+
generate_slug(heading_text)
|
|
26
|
+
elsif fallback_anchorless
|
|
27
|
+
generate_content_hash(article_tag)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def parse_id_from_dom(article_tag, url, selected_anchor)
|
|
34
|
+
candidates = [article_tag['id'], article_tag.at_css('[id]')&.attr('id')]
|
|
35
|
+
candidates += [url&.path, url&.query] if selected_anchor
|
|
36
|
+
candidates.compact.reject(&:empty?).first
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def resolve_heading_text(article_tag, heading, fallback_anchorless)
|
|
40
|
+
text = heading ? Navigator::TextExtractor.call(heading) : nil
|
|
41
|
+
if text.nil? || text.strip.empty?
|
|
42
|
+
fallback_text_node_content(article_tag, fallback_anchorless)
|
|
43
|
+
else
|
|
44
|
+
text
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def fallback_text_node_content(article_tag, fallback_anchorless)
|
|
49
|
+
return unless fallback_anchorless
|
|
50
|
+
|
|
51
|
+
article_tag.xpath('.//text()').find { |t| !t.text.strip.empty? }&.text&.strip
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def generate_slug(text)
|
|
55
|
+
slug = text.downcase.gsub(/[^a-z0-9]+/, '-')
|
|
56
|
+
slug = slug[1..] if slug.start_with?('-')
|
|
57
|
+
slug = slug[0..-2] if slug.end_with?('-')
|
|
58
|
+
slug unless slug.empty?
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def generate_content_hash(article_tag)
|
|
62
|
+
text = Navigator::TextExtractor.call(article_tag).to_s.strip
|
|
63
|
+
Zlib.crc32(text).to_s(36) unless text.empty?
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
module Html
|
|
5
|
+
class ArticleExtractor
|
|
6
|
+
##
|
|
7
|
+
# Image is responsible for extracting image URLs the article_tag.
|
|
8
|
+
class ImageExtractor
|
|
9
|
+
# @param article_tag [Nokogiri::XML::Element] article container node
|
|
10
|
+
# @param base_url [String, Html2rss::Url] base URL for relative image URLs
|
|
11
|
+
# @return [Html2rss::Url, nil] best candidate image URL
|
|
12
|
+
def self.call(article_tag, base_url:)
|
|
13
|
+
img_src = from_source(article_tag) ||
|
|
14
|
+
from_img(article_tag) ||
|
|
15
|
+
from_style(article_tag)
|
|
16
|
+
|
|
17
|
+
Url.from_relative(img_src, base_url) if img_src
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# @param article_tag [Nokogiri::XML::Element] article container node
|
|
21
|
+
# @return [String, nil] src attribute from first matching image tag
|
|
22
|
+
def self.from_img(article_tag)
|
|
23
|
+
article_tag.at_css('img[src]:not([src^="data"])')&.[]('src')
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
##
|
|
27
|
+
# Extracts the largest image source from the srcset attribute
|
|
28
|
+
# of an img tag or a source tag inside a picture tag.
|
|
29
|
+
#
|
|
30
|
+
# @param article_tag [Nokogiri::XML::Element] article container node
|
|
31
|
+
# @return [String, nil] largest srcset URL candidate
|
|
32
|
+
# @see <https://developer.mozilla.org/en-US/docs/Learn/HTML/Multimedia_and_embedding/Responsive_images>
|
|
33
|
+
# @see <https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img#srcset>
|
|
34
|
+
# @see <https://developer.mozilla.org/en-US/docs/Web/HTML/Element/picture>
|
|
35
|
+
def self.from_source(article_tag) # rubocop:disable Metrics/AbcSize
|
|
36
|
+
hash = article_tag.css('img[srcset], picture > source[srcset]').flat_map do |source|
|
|
37
|
+
source['srcset'].to_s.scan(/(\S+)\s+(\d+w|\d+h)[\s,]?/).map do |url, width|
|
|
38
|
+
next if url.nil? || url.start_with?('data:')
|
|
39
|
+
|
|
40
|
+
width_value = width.to_i.zero? ? 0 : width.scan(/\d+/).first.to_i
|
|
41
|
+
|
|
42
|
+
[width_value, url.strip]
|
|
43
|
+
end
|
|
44
|
+
end.compact.to_h
|
|
45
|
+
|
|
46
|
+
hash[hash.keys.max]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# @param article_tag [Nokogiri::XML::Element] article container node
|
|
50
|
+
# @return [String, nil] best style-based background image URL
|
|
51
|
+
def self.from_style(article_tag)
|
|
52
|
+
article_tag.css('[style*="url"]')
|
|
53
|
+
.filter_map { |tag| tag['style'][/url\(['"]?(.*?)['"]?\)/, 1] }
|
|
54
|
+
.reject { |src| src.start_with?('data:') }
|
|
55
|
+
.max_by(&:size)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -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
|