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
|
@@ -3,7 +3,10 @@
|
|
|
3
3
|
module Html2rss
|
|
4
4
|
##
|
|
5
5
|
# Builds feeds from validated config through request, extraction, and rendering stages.
|
|
6
|
-
class FeedPipeline
|
|
6
|
+
class FeedPipeline
|
|
7
|
+
# Bundle of inputs shared by selector and auto-source article collection.
|
|
8
|
+
ExtractionContext = Data.define(:config, :response, :request_session)
|
|
9
|
+
|
|
7
10
|
##
|
|
8
11
|
# @param raw_config [Hash{Symbol => Object}] user-provided feed config
|
|
9
12
|
def initialize(raw_config)
|
|
@@ -14,8 +17,8 @@ module Html2rss
|
|
|
14
17
|
# @return [RSS::Rss] generated RSS feed
|
|
15
18
|
def to_rss
|
|
16
19
|
run do |response:, config:, articles:|
|
|
17
|
-
channel =
|
|
18
|
-
|
|
20
|
+
channel = Html2rss::Channel.new(response, overrides: config.channel)
|
|
21
|
+
FeedBuilder.build(:rss, channel:, articles:, stylesheets: config.stylesheets)
|
|
19
22
|
end
|
|
20
23
|
end
|
|
21
24
|
|
|
@@ -23,8 +26,8 @@ module Html2rss
|
|
|
23
26
|
# @return [Hash] generated JSONFeed 1.1 payload
|
|
24
27
|
def to_json_feed
|
|
25
28
|
run do |response:, config:, articles:|
|
|
26
|
-
channel =
|
|
27
|
-
|
|
29
|
+
channel = Html2rss::Channel.new(response, overrides: config.channel)
|
|
30
|
+
FeedBuilder.build(:json_feed, channel:, articles:)
|
|
28
31
|
end
|
|
29
32
|
end
|
|
30
33
|
|
|
@@ -39,98 +42,82 @@ module Html2rss
|
|
|
39
42
|
end
|
|
40
43
|
|
|
41
44
|
def pipeline_state_for(config)
|
|
42
|
-
|
|
43
|
-
|
|
45
|
+
plan = StrategyPlan.resolve(config.strategy)
|
|
46
|
+
resources = RuntimePolicy.resources_for(config)
|
|
47
|
+
if plan.is_a?(StrategyPlan::Auto)
|
|
48
|
+
run_auto_pipeline(config, resources:)
|
|
44
49
|
else
|
|
45
|
-
run_pipeline_for_strategy(config, strategy:
|
|
50
|
+
run_pipeline_for_strategy(config, strategy: plan.strategy, resources:)
|
|
46
51
|
end
|
|
47
52
|
end
|
|
48
53
|
|
|
49
|
-
def run_pipeline_for_strategy(config, strategy:,
|
|
50
|
-
request_session = request_session_for(config, strategy:,
|
|
54
|
+
def run_pipeline_for_strategy(config, strategy:, resources:)
|
|
55
|
+
request_session = request_session_for(config, strategy:, resources:)
|
|
51
56
|
response = request_session.fetch_initial_response
|
|
52
|
-
articles = deduplicated_articles(
|
|
57
|
+
articles = deduplicated_articles(
|
|
58
|
+
ExtractionContext.new(config:, response:, request_session:)
|
|
59
|
+
)
|
|
53
60
|
{ response:, articles: }
|
|
54
61
|
end
|
|
55
62
|
|
|
56
|
-
def request_session_for(config, strategy:,
|
|
57
|
-
RequestSession.
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
def runtime_input_for(config, strategy:)
|
|
61
|
-
RequestSession::RuntimeInput.new(
|
|
62
|
-
url: config.url,
|
|
63
|
-
headers: config.headers,
|
|
64
|
-
request: config.request,
|
|
63
|
+
def request_session_for(config, strategy:, resources:)
|
|
64
|
+
RequestSession.build(
|
|
65
|
+
config:,
|
|
65
66
|
strategy:,
|
|
66
|
-
|
|
67
|
+
budget: resources.budget,
|
|
68
|
+
policy: resources.policy
|
|
67
69
|
)
|
|
68
70
|
end
|
|
69
71
|
|
|
70
|
-
def deduplicated_articles(
|
|
71
|
-
|
|
72
|
-
collect_articles(response:, config:, request_session:)
|
|
73
|
-
).call
|
|
74
|
-
end
|
|
75
|
-
|
|
76
|
-
def run_auto_pipeline(config)
|
|
77
|
-
auto_fallback_for(config).call
|
|
72
|
+
def deduplicated_articles(extraction)
|
|
73
|
+
Article::Deduplicator.new(collect_articles(extraction)).call
|
|
78
74
|
end
|
|
79
75
|
|
|
80
76
|
# rubocop:disable Metrics/MethodLength
|
|
81
|
-
def
|
|
77
|
+
def run_auto_pipeline(config, resources:)
|
|
82
78
|
AutoFallback.new(
|
|
83
79
|
strategies: AutoFallback::CHAIN,
|
|
84
|
-
budget:
|
|
80
|
+
budget: resources.budget,
|
|
85
81
|
session_for: lambda do |strategy:, budget:|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
end
|
|
89
|
-
|
|
90
|
-
request_session_for(config, strategy:, budget:)
|
|
82
|
+
budget.effective_timeout_seconds(fallback: resources.policy.total_timeout_seconds)
|
|
83
|
+
request_session_for(config, strategy:, resources:)
|
|
91
84
|
end,
|
|
92
85
|
articles_for: lambda do |response:, request_session:|
|
|
93
|
-
deduplicated_articles(
|
|
86
|
+
deduplicated_articles(ExtractionContext.new(config:, response:, request_session:))
|
|
94
87
|
end
|
|
95
|
-
)
|
|
88
|
+
).call
|
|
96
89
|
end
|
|
97
90
|
# rubocop:enable Metrics/MethodLength
|
|
98
91
|
|
|
99
|
-
def
|
|
100
|
-
|
|
101
|
-
RequestService::Budget.new(
|
|
102
|
-
max_requests: policy.max_requests,
|
|
103
|
-
total_timeout_seconds: policy.total_timeout_seconds
|
|
104
|
-
)
|
|
92
|
+
def collect_articles(extraction)
|
|
93
|
+
selector_articles(extraction) + auto_source_articles(extraction)
|
|
105
94
|
end
|
|
106
95
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
end
|
|
111
|
-
|
|
112
|
-
def selector_articles(response:, config:, request_session:) # rubocop:disable Metrics/MethodLength
|
|
96
|
+
# rubocop:disable Metrics/MethodLength
|
|
97
|
+
def selector_articles(extraction)
|
|
98
|
+
config = extraction.config
|
|
113
99
|
return [] unless (selectors = config.selectors)
|
|
114
100
|
|
|
115
|
-
page_responses =
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
Selectors.new(page_response, selectors:, time_zone: config.time_zone).articles
|
|
101
|
+
page_responses = extraction.request_session.page_responses(
|
|
102
|
+
extraction.response,
|
|
103
|
+
pagination_config: selectors.dig(:items, :pagination)
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
articles = []
|
|
107
|
+
page_responses.each do |page_response|
|
|
108
|
+
page_articles = Selectors.new(page_response, selectors:, time_zone: config.time_zone).articles
|
|
109
|
+
break if page_articles.empty? && articles.any?
|
|
110
|
+
|
|
111
|
+
articles.concat(page_articles)
|
|
127
112
|
end
|
|
113
|
+
articles
|
|
128
114
|
end
|
|
115
|
+
# rubocop:enable Metrics/MethodLength
|
|
129
116
|
|
|
130
|
-
def auto_source_articles(
|
|
131
|
-
return [] unless (auto_source = config.auto_source)
|
|
117
|
+
def auto_source_articles(extraction)
|
|
118
|
+
return [] unless (auto_source = extraction.config.auto_source)
|
|
132
119
|
|
|
133
|
-
AutoSource.new(response, auto_source, request_session:).articles
|
|
120
|
+
AutoSource.new(extraction.response, auto_source, request_session: extraction.request_session).articles
|
|
134
121
|
end
|
|
135
122
|
end
|
|
136
123
|
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
module Html
|
|
5
|
+
class ArticleExtractor
|
|
6
|
+
##
|
|
7
|
+
# CategoryExtractor is responsible for extracting categories from HTML elements
|
|
8
|
+
# by looking for CSS class names containing common category-related terms.
|
|
9
|
+
class CategoryExtractor
|
|
10
|
+
# Common category-related terms to look for in class names
|
|
11
|
+
CATEGORY_TERMS = %w[
|
|
12
|
+
category categories tag tags topic topics section sections
|
|
13
|
+
label labels theme themes subject subjects
|
|
14
|
+
].freeze
|
|
15
|
+
|
|
16
|
+
# CSS selectors to find elements with category-related class names or data attributes
|
|
17
|
+
CATEGORY_SELECTORS = CATEGORY_TERMS.flat_map do |term|
|
|
18
|
+
["[class*=\"#{term}\"]", "[data-#{term}]", "[#{term}]"]
|
|
19
|
+
end.freeze
|
|
20
|
+
|
|
21
|
+
# Regex pattern for matching category-related attribute names
|
|
22
|
+
CATEGORY_ATTR_PATTERN = /#{CATEGORY_TERMS.join('|')}/i
|
|
23
|
+
|
|
24
|
+
##
|
|
25
|
+
# Extracts categories from the given article tag by looking for elements
|
|
26
|
+
# with class names containing common category-related terms.
|
|
27
|
+
#
|
|
28
|
+
# @param article_tag [Nokogiri::XML::Element] The article element to extract categories from
|
|
29
|
+
# @return [Array<String>] Array of category strings, empty if none found
|
|
30
|
+
def self.call(article_tag)
|
|
31
|
+
return [] unless article_tag
|
|
32
|
+
|
|
33
|
+
# Single optimized traversal that extracts all category types
|
|
34
|
+
extract_all_categories(article_tag)
|
|
35
|
+
.map(&:strip)
|
|
36
|
+
.reject(&:empty?)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
##
|
|
40
|
+
# Optimized single DOM traversal that extracts all category types.
|
|
41
|
+
#
|
|
42
|
+
# @param article_tag [Nokogiri::XML::Element] The article element
|
|
43
|
+
# @return [Set<String>] Set of category strings
|
|
44
|
+
def self.extract_all_categories(article_tag)
|
|
45
|
+
Set.new.tap do |categories|
|
|
46
|
+
article_tag.css(CATEGORY_SELECTORS.join(',')).each do |element|
|
|
47
|
+
# Extract text categories from elements with category-related class names
|
|
48
|
+
extract_text_categories!(categories, element) if element['class']&.match?(CATEGORY_ATTR_PATTERN)
|
|
49
|
+
|
|
50
|
+
# Extract data categories from all elements
|
|
51
|
+
extract_element_data_categories!(categories, element)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
##
|
|
57
|
+
# Extracts categories from data attributes of a single element.
|
|
58
|
+
#
|
|
59
|
+
# @param categories [Set<String>] Accumulator set
|
|
60
|
+
# @param element [Nokogiri::XML::Element] metadata element that may contain category links
|
|
61
|
+
# @return [void]
|
|
62
|
+
def self.extract_element_data_categories!(categories, element)
|
|
63
|
+
element.attributes.each_value do |attr|
|
|
64
|
+
next unless attr.name.match?(CATEGORY_ATTR_PATTERN)
|
|
65
|
+
|
|
66
|
+
value = attr.value&.strip
|
|
67
|
+
categories.add(value) if value && !value.empty?
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
##
|
|
72
|
+
# Extracts text-based categories from elements, splitting content into discrete values.
|
|
73
|
+
#
|
|
74
|
+
# @param categories [Set<String>] Accumulator set
|
|
75
|
+
# @param element [Nokogiri::XML::Element] metadata element whose text may contain delimiters
|
|
76
|
+
# @return [void]
|
|
77
|
+
def self.extract_text_categories!(categories, element)
|
|
78
|
+
if element.name == 'a'
|
|
79
|
+
add_text_to_categories!(categories, element)
|
|
80
|
+
return
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
anchors = element.css('a')
|
|
84
|
+
|
|
85
|
+
if anchors.any?
|
|
86
|
+
anchors.each { |node| add_text_to_categories!(categories, node) }
|
|
87
|
+
else
|
|
88
|
+
extract_split_text_categories!(categories, element)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
##
|
|
93
|
+
# Adds the visible text of the given element to the categories set.
|
|
94
|
+
#
|
|
95
|
+
# @param categories [Set<String>] Accumulator set
|
|
96
|
+
# @param element [Nokogiri::XML::Element] The element to extract text from
|
|
97
|
+
# @return [void]
|
|
98
|
+
def self.add_text_to_categories!(categories, element)
|
|
99
|
+
text = Navigator.extract_visible_text(element)
|
|
100
|
+
categories.add(text) if text && !text.empty?
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
##
|
|
104
|
+
# Extracts categories from the element's text by splitting on newlines.
|
|
105
|
+
#
|
|
106
|
+
# @param categories [Set<String>] Accumulator set
|
|
107
|
+
# @param element [Nokogiri::XML::Element] The element to extract text from
|
|
108
|
+
# @return [void]
|
|
109
|
+
def self.extract_split_text_categories!(categories, element)
|
|
110
|
+
text = element.text
|
|
111
|
+
return unless text
|
|
112
|
+
|
|
113
|
+
text.split(/\n+/).each do |line|
|
|
114
|
+
line = line.strip
|
|
115
|
+
categories.add(line) unless line.empty?
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
private_class_method :add_text_to_categories!, :extract_split_text_categories!
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
module Html
|
|
5
|
+
class ArticleExtractor
|
|
6
|
+
# Extracts the earliest date from an article_tag.
|
|
7
|
+
class DateExtractor
|
|
8
|
+
# @param article_tag [Nokogiri::XML::Element] article container node
|
|
9
|
+
# @return [DateTime, nil]
|
|
10
|
+
def self.call(article_tag)
|
|
11
|
+
times = article_tag.css('[datetime]').filter_map do |tag|
|
|
12
|
+
DateTime.parse(tag['datetime'])
|
|
13
|
+
rescue ArgumentError, TypeError
|
|
14
|
+
nil
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
times.min
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -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
|