html2rss 0.24.0 → 0.26.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 +76 -2
- data/html2rss.gemspec +4 -1
- data/lib/html2rss/auto_source/cleanup.rb +62 -36
- data/lib/html2rss/auto_source/scraper/html.rb +68 -145
- data/lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb +116 -0
- data/lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb +63 -0
- data/lib/html2rss/auto_source/scraper/json_state/document_scanner.rb +179 -0
- data/lib/html2rss/auto_source/scraper/json_state/value_finder.rb +55 -0
- data/lib/html2rss/auto_source/scraper/json_state.rb +31 -401
- data/lib/html2rss/auto_source/scraper/schema/item_list.rb +2 -14
- data/lib/html2rss/auto_source/scraper/semantic_html/entry_deduplicator.rb +35 -30
- data/lib/html2rss/auto_source/scraper/semantic_html.rb +40 -120
- data/lib/html2rss/auto_source/scraper/sitemap/parser.rb +161 -0
- data/lib/html2rss/auto_source/scraper/sitemap.rb +10 -7
- data/lib/html2rss/auto_source/scraper/wordpress_api.rb +6 -2
- data/lib/html2rss/auto_source/scraper/xhr_articles.rb +69 -0
- data/lib/html2rss/auto_source/scraper.rb +109 -53
- data/lib/html2rss/auto_source/segment.rb +29 -0
- data/lib/html2rss/auto_source/segmenter/cluster.rb +163 -0
- data/lib/html2rss/auto_source/segmenter/list.rb +103 -0
- data/lib/html2rss/auto_source/segmenter/primary_link.rb +80 -0
- data/lib/html2rss/auto_source/segmenter/semantic.rb +104 -0
- data/lib/html2rss/auto_source/segmenter.rb +95 -0
- data/lib/html2rss/auto_source.rb +79 -21
- data/lib/html2rss/capture.rb +315 -0
- data/lib/html2rss/cli.rb +72 -18
- data/lib/html2rss/config/auto_source_contract.rb +5 -1
- data/lib/html2rss/config/request_controls.rb +33 -0
- data/lib/html2rss/config/schema.rb +67 -26
- data/lib/html2rss/config/selectors_validator.rb +60 -21
- data/lib/html2rss/config/validator.rb +33 -35
- data/lib/html2rss/config.rb +6 -2
- data/lib/html2rss/error.rb +15 -3
- data/lib/html2rss/feed_builder/rss.rb +17 -5
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +16 -6
- data/lib/html2rss/feed_pipeline/runtime_policy.rb +3 -43
- data/lib/html2rss/feed_pipeline.rb +11 -0
- data/lib/html2rss/feed_result.rb +1 -1
- data/lib/html2rss/hash_util.rb +17 -0
- data/lib/html2rss/html/article_extractor/category_extractor.rb +10 -36
- data/lib/html2rss/html/article_extractor/date_extractor.rb +2 -7
- data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +5 -50
- data/lib/html2rss/html/article_extractor/image_extractor.rb +5 -16
- data/lib/html2rss/html/article_rules/category.rb +55 -0
- data/lib/html2rss/html/article_rules/date.rb +25 -0
- data/lib/html2rss/html/article_rules/enclosure.rb +72 -0
- data/lib/html2rss/html/article_rules/image.rb +109 -0
- data/lib/html2rss/html/article_rules.rb +10 -0
- data/lib/html2rss/html/rendering/audio_renderer.rb +2 -12
- data/lib/html2rss/html/rendering/escaped_attributes.rb +27 -0
- data/lib/html2rss/html/rendering/image_renderer.rb +2 -12
- data/lib/html2rss/html/rendering/pdf_renderer.rb +2 -8
- data/lib/html2rss/html/rendering/video_renderer.rb +2 -12
- data/lib/html2rss/html/sst_article_extractor.rb +308 -0
- data/lib/html2rss/link_destination/destination_facts.rb +40 -0
- data/lib/html2rss/link_destination/noise_policy.rb +68 -0
- data/lib/html2rss/link_destination/path_classifier.rb +208 -0
- data/lib/html2rss/link_destination/text_classifier.rb +64 -0
- data/lib/html2rss/link_destination.rb +8 -0
- data/lib/html2rss/mcp/server.rb +577 -0
- data/lib/html2rss/mcp.rb +21 -0
- data/lib/html2rss/request_service/blocked_surface.rb +24 -1
- data/lib/html2rss/request_service/botasaurus_contract.rb +89 -9
- data/lib/html2rss/request_service/botasaurus_strategy.rb +4 -2
- data/lib/html2rss/request_service/budget.rb +7 -35
- data/lib/html2rss/request_service/context.rb +0 -6
- data/lib/html2rss/request_service/faraday_strategy.rb +87 -3
- data/lib/html2rss/request_service/network_guard.rb +5 -3
- data/lib/html2rss/request_service/policy.rb +1 -1
- data/lib/html2rss/request_service/response.rb +15 -1
- data/lib/html2rss/request_service/strategy.rb +1 -2
- data/lib/html2rss/request_service.rb +4 -9
- data/lib/html2rss/scoring/cluster_scorer.rb +87 -0
- data/lib/html2rss/scoring/container_assessor.rb +77 -0
- data/lib/html2rss/scoring/engine.rb +145 -0
- data/lib/html2rss/scoring/link_resolver.rb +84 -0
- data/lib/html2rss/scoring/observation.rb +53 -0
- data/lib/html2rss/scoring/ranked_segment.rb +33 -0
- data/lib/html2rss/scoring/score.rb +11 -0
- data/lib/html2rss/scoring.rb +8 -0
- data/lib/html2rss/selectors/extractors/attribute.rb +15 -0
- data/lib/html2rss/selectors/extractors/href.rb +12 -0
- data/lib/html2rss/selectors/extractors/html.rb +12 -0
- data/lib/html2rss/selectors/extractors/static.rb +14 -0
- data/lib/html2rss/selectors/extractors/text.rb +11 -0
- data/lib/html2rss/selectors/post_processors/gsub.rb +18 -0
- data/lib/html2rss/selectors/post_processors/html_to_markdown.rb +11 -0
- data/lib/html2rss/selectors/post_processors/markdown_to_html.rb +12 -0
- data/lib/html2rss/selectors/post_processors/parse_time.rb +11 -0
- data/lib/html2rss/selectors/post_processors/parse_uri.rb +11 -0
- data/lib/html2rss/selectors/post_processors/sanitize_html.rb +23 -1
- data/lib/html2rss/selectors/post_processors/substring.rb +21 -0
- data/lib/html2rss/selectors/post_processors/template.rb +20 -0
- data/lib/html2rss/selectors/schema_doc.rb +99 -0
- data/lib/html2rss/selectors.rb +0 -20
- data/lib/html2rss/sst/attrs.rb +91 -0
- data/lib/html2rss/sst/document.rb +23 -0
- data/lib/html2rss/sst/index.rb +112 -0
- data/lib/html2rss/sst/node.rb +147 -0
- data/lib/html2rss/sst/normalizer.rb +171 -0
- data/lib/html2rss/sst/tags.rb +26 -0
- data/lib/html2rss/sst/text.rb +81 -0
- data/lib/html2rss/sst.rb +8 -0
- data/lib/html2rss/url.rb +11 -7
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss.rb +57 -26
- data/schema/html2rss-config.schema.json +428 -85
- metadata +93 -27
- data/lib/html2rss/auto_source/discovery/dom_clustering/group_scorer.rb +0 -80
- data/lib/html2rss/auto_source/discovery/dom_clustering/overlap_resolver.rb +0 -86
- data/lib/html2rss/auto_source/discovery/dom_clustering.rb +0 -119
- data/lib/html2rss/auto_source/discovery/list_candidates.rb +0 -94
- data/lib/html2rss/auto_source/discovery/semantic_anchor_candidates.rb +0 -219
- data/lib/html2rss/auto_source/discovery/semantic_containers.rb +0 -71
- data/lib/html2rss/auto_source/discovery/sitemap.rb +0 -159
- data/lib/html2rss/auto_source/discovery.rb +0 -14
- data/lib/html2rss/auto_source/link_heuristics/anchor_signals.rb +0 -28
- data/lib/html2rss/auto_source/link_heuristics/container_assessor.rb +0 -106
- data/lib/html2rss/auto_source/link_heuristics/container_signals.rb +0 -80
- data/lib/html2rss/auto_source/link_heuristics/destination_facts.rb +0 -42
- data/lib/html2rss/auto_source/link_heuristics/href_extractor.rb +0 -38
- data/lib/html2rss/auto_source/link_heuristics/path_classifier.rb +0 -221
- data/lib/html2rss/auto_source/link_heuristics/text_classifier.rb +0 -66
- data/lib/html2rss/auto_source/link_heuristics.rb +0 -139
- data/lib/html2rss/request_service/browserless_strategy.rb +0 -132
- data/lib/html2rss/request_service/puppet_commander/navigation_guards.rb +0 -148
- data/lib/html2rss/request_service/puppet_commander/preload_runner.rb +0 -86
- data/lib/html2rss/request_service/puppet_commander.rb +0 -95
|
@@ -79,7 +79,7 @@ module Html2rss
|
|
|
79
79
|
RSS::Maker.make('2.0') do |maker|
|
|
80
80
|
Stylesheet.add(maker, stylesheets)
|
|
81
81
|
|
|
82
|
-
make_channel(maker
|
|
82
|
+
make_channel(maker)
|
|
83
83
|
make_items(maker)
|
|
84
84
|
end
|
|
85
85
|
end
|
|
@@ -96,14 +96,26 @@ module Html2rss
|
|
|
96
96
|
@stylesheets.map { |style| Stylesheet.new(**style) }
|
|
97
97
|
end
|
|
98
98
|
|
|
99
|
+
# rubocop:disable Metrics/AbcSize
|
|
99
100
|
def make_channel(maker)
|
|
101
|
+
channel_maker = maker.channel
|
|
100
102
|
%i[language title description ttl].each do |key|
|
|
101
|
-
|
|
103
|
+
channel_maker.public_send(:"#{key}=", channel.public_send(key))
|
|
102
104
|
end
|
|
103
105
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
106
|
+
channel_maker.managingEditor = channel.author if channel.author
|
|
107
|
+
channel_maker.author = channel.author if channel.author
|
|
108
|
+
channel_maker.link = channel.url.to_s
|
|
109
|
+
channel_maker.generator = generator
|
|
110
|
+
channel_maker.updated = channel.last_build_date
|
|
111
|
+
|
|
112
|
+
make_image(maker.image) if channel.image
|
|
113
|
+
end
|
|
114
|
+
# rubocop:enable Metrics/AbcSize
|
|
115
|
+
|
|
116
|
+
def make_image(image_maker)
|
|
117
|
+
image_maker.url = channel.image.to_s
|
|
118
|
+
image_maker.title = channel.title.to_s
|
|
107
119
|
end
|
|
108
120
|
|
|
109
121
|
def make_items(maker)
|
|
@@ -19,18 +19,18 @@ module Html2rss
|
|
|
19
19
|
RequestService::RequestBudgetExceeded,
|
|
20
20
|
RequestService::PrivateNetworkDenied,
|
|
21
21
|
RequestService::CrossOriginFollowUpDenied,
|
|
22
|
-
RequestService::ResponseTooLarge
|
|
23
|
-
RequestService::BrowserlessConfigurationError
|
|
22
|
+
RequestService::ResponseTooLarge
|
|
24
23
|
].freeze
|
|
25
24
|
|
|
26
25
|
##
|
|
27
26
|
# Mutable run state for one auto-fallback chain: attempts plus selected result.
|
|
28
27
|
class AttemptState
|
|
29
|
-
attr_reader :attempts, :result
|
|
28
|
+
attr_reader :attempts, :result, :last_response
|
|
30
29
|
|
|
31
30
|
def initialize
|
|
32
31
|
@attempts = []
|
|
33
32
|
@result = nil
|
|
33
|
+
@last_response = nil
|
|
34
34
|
end
|
|
35
35
|
|
|
36
36
|
# @return [Boolean] true when a strategy already yielded items
|
|
@@ -53,6 +53,12 @@ module Html2rss
|
|
|
53
53
|
@attempts << attempt
|
|
54
54
|
end
|
|
55
55
|
|
|
56
|
+
# @param response [RequestService::Response] last response that reached extraction
|
|
57
|
+
# @return [void]
|
|
58
|
+
def remember_response(response)
|
|
59
|
+
@last_response = response
|
|
60
|
+
end
|
|
61
|
+
|
|
56
62
|
# @param response [RequestService::Response] successful response
|
|
57
63
|
# @param articles [Array] extracted articles
|
|
58
64
|
# @param dedup_dropped [Integer] articles removed by deduplication
|
|
@@ -92,7 +98,7 @@ module Html2rss
|
|
|
92
98
|
state = run_attempts
|
|
93
99
|
return state.result if state.succeeded?
|
|
94
100
|
|
|
95
|
-
finalize_failure(attempts: state.attempts)
|
|
101
|
+
finalize_failure(attempts: state.attempts, response: state.last_response)
|
|
96
102
|
end
|
|
97
103
|
|
|
98
104
|
private
|
|
@@ -127,6 +133,7 @@ module Html2rss
|
|
|
127
133
|
end
|
|
128
134
|
|
|
129
135
|
def process_response(response:, strategy:, next_strategy:, request_session:, state:)
|
|
136
|
+
state.remember_response(response)
|
|
130
137
|
articles, dedup_dropped = articles_for(response:, request_session:)
|
|
131
138
|
items_count = articles.size
|
|
132
139
|
state.record_items(strategy:, items_count:, transport_meta: response.transport_meta)
|
|
@@ -152,8 +159,11 @@ module Html2rss
|
|
|
152
159
|
"budget_remaining=#{budget_remaining_label}")
|
|
153
160
|
end
|
|
154
161
|
|
|
155
|
-
def finalize_failure(attempts:)
|
|
156
|
-
|
|
162
|
+
def finalize_failure(attempts:, response:)
|
|
163
|
+
surface_category = response && AutoSource::Scraper.classify_no_scraper_surface(
|
|
164
|
+
response.parsed_body, body: response.body
|
|
165
|
+
)
|
|
166
|
+
raise NoFeedItemsExtracted.new(attempts:, surface_category:)
|
|
157
167
|
end
|
|
158
168
|
|
|
159
169
|
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
@@ -6,8 +6,7 @@ module Html2rss
|
|
|
6
6
|
# Planner for the runtime request policy and budgets of a feed run.
|
|
7
7
|
#
|
|
8
8
|
# HTTP request slots (pagination, document GETs, strategy fallback) are planned
|
|
9
|
-
#
|
|
10
|
-
# pagination slots.
|
|
9
|
+
# from configuration options.
|
|
11
10
|
class RuntimePolicy
|
|
12
11
|
# Policy plus Budget from one config expansion (shared meters for a feed run).
|
|
13
12
|
Resources = Data.define(:policy, :budget)
|
|
@@ -34,26 +33,18 @@ module Html2rss
|
|
|
34
33
|
policy:,
|
|
35
34
|
budget: RequestService::Budget.new(
|
|
36
35
|
max_requests: policy.max_requests,
|
|
37
|
-
max_interactions: interaction_budget_for(config),
|
|
38
36
|
total_timeout_seconds: policy.total_timeout_seconds
|
|
39
37
|
)
|
|
40
38
|
)
|
|
41
39
|
end
|
|
42
40
|
|
|
43
41
|
##
|
|
44
|
-
# Builds a Budget with
|
|
42
|
+
# Builds a Budget with request slot pools.
|
|
45
43
|
#
|
|
46
44
|
# @param config [Html2rss::Config] validated feed config
|
|
47
45
|
# @return [Html2rss::RequestService::Budget] shared budget for the feed build
|
|
48
46
|
def self.budget_for(config) = resources_for(config).budget
|
|
49
47
|
|
|
50
|
-
##
|
|
51
|
-
# @param config [Html2rss::Config] validated feed config
|
|
52
|
-
# @return [Integer] preload interaction slots derived from browserless preload config
|
|
53
|
-
def self.interaction_budget_for(config)
|
|
54
|
-
browserless_preload_budget_for(config)
|
|
55
|
-
end
|
|
56
|
-
|
|
57
48
|
class << self
|
|
58
49
|
private
|
|
59
50
|
|
|
@@ -64,44 +55,13 @@ module Html2rss
|
|
|
64
55
|
end
|
|
65
56
|
|
|
66
57
|
# Reserve enough HTTP request slots for the initial request plus predictable
|
|
67
|
-
# follow-ups.
|
|
58
|
+
# follow-ups.
|
|
68
59
|
def baseline_request_budget_for(config)
|
|
69
60
|
1 +
|
|
70
61
|
RequestSession::Pager.request_slots_for(config.selectors&.dig(:items, :pagination)) +
|
|
71
62
|
AutoSource.request_slots_for(config.auto_source) +
|
|
72
63
|
StrategyPlan.resolve(config.strategy).request_slots
|
|
73
64
|
end
|
|
74
|
-
|
|
75
|
-
def browserless_preload_budget_for(config)
|
|
76
|
-
preload = config.request.dig(:browserless, :preload)
|
|
77
|
-
return 0 unless preload
|
|
78
|
-
|
|
79
|
-
top_level_preload_wait_budget(preload) +
|
|
80
|
-
click_selector_preload_budget(preload) +
|
|
81
|
-
scroll_preload_budget(preload)
|
|
82
|
-
end
|
|
83
|
-
|
|
84
|
-
def top_level_preload_wait_budget(preload)
|
|
85
|
-
preload[:wait_after_ms] ? 2 : 0
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
def click_selector_preload_budget(preload)
|
|
89
|
-
preload.fetch(:click_selectors, []).sum { preload_action_budget(_1, :max_clicks) }
|
|
90
|
-
end
|
|
91
|
-
|
|
92
|
-
def scroll_preload_budget(preload)
|
|
93
|
-
scroll = preload[:scroll_down]
|
|
94
|
-
return 0 unless scroll
|
|
95
|
-
|
|
96
|
-
preload_action_budget(scroll, :iterations)
|
|
97
|
-
end
|
|
98
|
-
|
|
99
|
-
def preload_action_budget(config, count_key)
|
|
100
|
-
action_count = config.fetch(count_key, 1)
|
|
101
|
-
wait_budget = config[:wait_after_ms] ? action_count : 0
|
|
102
|
-
|
|
103
|
-
action_count + wait_budget
|
|
104
|
-
end
|
|
105
65
|
end
|
|
106
66
|
end
|
|
107
67
|
end
|
|
@@ -81,11 +81,22 @@ module Html2rss
|
|
|
81
81
|
request_session = request_session_for(config, strategy:, resources:)
|
|
82
82
|
response = request_session.fetch_initial_response
|
|
83
83
|
articles, dedup_dropped = deduplicated_articles(config:, response:, request_session:)
|
|
84
|
+
raise_empty_auto_source!(strategy:, response:) if config.auto_source && articles.empty?
|
|
85
|
+
|
|
84
86
|
PipelineOutcome.new(
|
|
85
87
|
response:, articles:, dedup_dropped:, selected_strategy: nil, attempt_count: 0, strategy_attempts: []
|
|
86
88
|
)
|
|
87
89
|
end
|
|
88
90
|
|
|
91
|
+
def raise_empty_auto_source!(strategy:, response:)
|
|
92
|
+
raise NoFeedItemsExtracted.new(
|
|
93
|
+
attempts: [{ strategy:, items_count: 0, error_class: nil }],
|
|
94
|
+
surface_category: AutoSource::Scraper.classify_no_scraper_surface(
|
|
95
|
+
response.parsed_body, body: response.body
|
|
96
|
+
)
|
|
97
|
+
)
|
|
98
|
+
end
|
|
99
|
+
|
|
89
100
|
def run_auto_pipeline(config, resources:)
|
|
90
101
|
AutoFallback.new(
|
|
91
102
|
strategies: AutoFallback::CHAIN,
|
data/lib/html2rss/feed_result.rb
CHANGED
|
@@ -69,7 +69,7 @@ module Html2rss
|
|
|
69
69
|
|
|
70
70
|
##
|
|
71
71
|
# @return [Html2rss::Status] frozen scraper tallies, dedup count, and generator formatter.
|
|
72
|
-
# {#to_h} on +status+ is a stable consumer contract for observability payloads.
|
|
72
|
+
# {Status#to_h} on +status+ is a stable consumer contract for observability payloads.
|
|
73
73
|
attr_reader :status
|
|
74
74
|
|
|
75
75
|
private
|
data/lib/html2rss/hash_util.rb
CHANGED
|
@@ -54,6 +54,23 @@ module Html2rss
|
|
|
54
54
|
end
|
|
55
55
|
end
|
|
56
56
|
|
|
57
|
+
# Converts hash keys (and Symbol values) to strings recursively.
|
|
58
|
+
#
|
|
59
|
+
# @param object [Object] value to normalize
|
|
60
|
+
# @return [Object] normalized value with string keys
|
|
61
|
+
def deep_stringify_keys(object)
|
|
62
|
+
case object
|
|
63
|
+
when Hash
|
|
64
|
+
object.to_h { |key, value| [key.to_s, deep_stringify_keys(value)] }
|
|
65
|
+
when Array
|
|
66
|
+
object.map { deep_stringify_keys(_1) }
|
|
67
|
+
when Symbol
|
|
68
|
+
object.to_s
|
|
69
|
+
else
|
|
70
|
+
object
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
57
74
|
# Validates that hash keys are symbols.
|
|
58
75
|
#
|
|
59
76
|
# @param value [Object] candidate hash container whose keys must be symbols
|
|
@@ -7,20 +7,16 @@ module Html2rss
|
|
|
7
7
|
# CategoryExtractor is responsible for extracting categories from HTML elements
|
|
8
8
|
# by looking for CSS class names containing common category-related terms.
|
|
9
9
|
class CategoryExtractor
|
|
10
|
-
#
|
|
11
|
-
CATEGORY_TERMS =
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
].freeze
|
|
10
|
+
# Shared category vocabulary (owned by {ArticleRules::Category}).
|
|
11
|
+
CATEGORY_TERMS = ArticleRules::Category::CATEGORY_TERMS
|
|
12
|
+
# Shared category attribute/class pattern (owned by {ArticleRules::Category}).
|
|
13
|
+
CATEGORY_ATTR_PATTERN = ArticleRules::Category::CATEGORY_ATTR_PATTERN
|
|
15
14
|
|
|
16
15
|
# CSS selectors to find elements with category-related class names or data attributes
|
|
17
16
|
CATEGORY_SELECTORS = CATEGORY_TERMS.flat_map do |term|
|
|
18
17
|
["[class*=\"#{term}\"]", "[data-#{term}]", "[#{term}]"]
|
|
19
18
|
end.freeze
|
|
20
19
|
|
|
21
|
-
# Regex pattern for matching category-related attribute names
|
|
22
|
-
CATEGORY_ATTR_PATTERN = /#{CATEGORY_TERMS.join('|')}/i
|
|
23
|
-
|
|
24
20
|
##
|
|
25
21
|
# Extracts categories from the given article tag by looking for elements
|
|
26
22
|
# with class names containing common category-related terms.
|
|
@@ -30,7 +26,6 @@ module Html2rss
|
|
|
30
26
|
def self.call(article_tag)
|
|
31
27
|
return [] unless article_tag
|
|
32
28
|
|
|
33
|
-
# Single optimized traversal that extracts all category types
|
|
34
29
|
extract_all_categories(article_tag)
|
|
35
30
|
.map(&:strip)
|
|
36
31
|
.reject(&:empty?)
|
|
@@ -44,10 +39,7 @@ module Html2rss
|
|
|
44
39
|
def self.extract_all_categories(article_tag)
|
|
45
40
|
Set.new.tap do |categories|
|
|
46
41
|
article_tag.css(CATEGORY_SELECTORS.join(',')).each do |element|
|
|
47
|
-
|
|
48
|
-
extract_text_categories!(categories, element) if element['class']&.match?(CATEGORY_ATTR_PATTERN)
|
|
49
|
-
|
|
50
|
-
# Extract data categories from all elements
|
|
42
|
+
extract_text_categories!(categories, element) if ArticleRules::Category.class_match?(element['class'])
|
|
51
43
|
extract_element_data_categories!(categories, element)
|
|
52
44
|
end
|
|
53
45
|
end
|
|
@@ -61,10 +53,9 @@ module Html2rss
|
|
|
61
53
|
# @return [void]
|
|
62
54
|
def self.extract_element_data_categories!(categories, element)
|
|
63
55
|
element.attributes.each_value do |attr|
|
|
64
|
-
next unless attr.name
|
|
56
|
+
next unless ArticleRules::Category.attr_name_match?(attr.name)
|
|
65
57
|
|
|
66
|
-
|
|
67
|
-
categories.add(value) if value && !value.empty?
|
|
58
|
+
ArticleRules::Category.add_text!(categories, attr.value)
|
|
68
59
|
end
|
|
69
60
|
end
|
|
70
61
|
|
|
@@ -85,7 +76,7 @@ module Html2rss
|
|
|
85
76
|
if anchors.any?
|
|
86
77
|
anchors.each { |node| add_text_to_categories!(categories, node) }
|
|
87
78
|
else
|
|
88
|
-
|
|
79
|
+
ArticleRules::Category.add_split_text!(categories, element.text)
|
|
89
80
|
end
|
|
90
81
|
end
|
|
91
82
|
|
|
@@ -96,27 +87,10 @@ module Html2rss
|
|
|
96
87
|
# @param element [Nokogiri::XML::Element] The element to extract text from
|
|
97
88
|
# @return [void]
|
|
98
89
|
def self.add_text_to_categories!(categories, element)
|
|
99
|
-
|
|
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
|
|
90
|
+
ArticleRules::Category.add_text!(categories, Navigator.extract_visible_text(element))
|
|
117
91
|
end
|
|
118
92
|
|
|
119
|
-
private_class_method :add_text_to_categories
|
|
93
|
+
private_class_method :add_text_to_categories!
|
|
120
94
|
end
|
|
121
95
|
end
|
|
122
96
|
end
|
|
@@ -8,13 +8,8 @@ module Html2rss
|
|
|
8
8
|
# @param article_tag [Nokogiri::XML::Element] article container node
|
|
9
9
|
# @return [DateTime, nil]
|
|
10
10
|
def self.call(article_tag)
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
rescue ArgumentError, TypeError
|
|
14
|
-
nil
|
|
15
|
-
end
|
|
16
|
-
|
|
17
|
-
times.min
|
|
11
|
+
datetimes = article_tag.css('[datetime]').map { |tag| tag['datetime'] }
|
|
12
|
+
ArticleRules::Date.earliest(datetimes)
|
|
18
13
|
end
|
|
19
14
|
end
|
|
20
15
|
end
|
|
@@ -33,62 +33,17 @@ module Html2rss
|
|
|
33
33
|
def self.extract_from_element(element, base_url)
|
|
34
34
|
case element.name
|
|
35
35
|
when 'img'
|
|
36
|
-
|
|
36
|
+
ArticleRules::Enclosure.from_image(element['src'], base_url)
|
|
37
37
|
when 'video', 'audio', 'source'
|
|
38
|
-
|
|
38
|
+
ArticleRules::Enclosure.from_media(element['src'], element['type'], base_url)
|
|
39
39
|
when 'iframe'
|
|
40
|
-
|
|
40
|
+
ArticleRules::Enclosure.from_iframe(element['src'], base_url)
|
|
41
41
|
when 'a'
|
|
42
|
-
|
|
42
|
+
ArticleRules::Enclosure.from_anchor(element['href'], base_url)
|
|
43
43
|
end
|
|
44
44
|
end
|
|
45
45
|
|
|
46
|
-
|
|
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
|
|
46
|
+
private_class_method :extract_from_element
|
|
92
47
|
end
|
|
93
48
|
end
|
|
94
49
|
end
|
|
@@ -32,27 +32,16 @@ module Html2rss
|
|
|
32
32
|
# @see <https://developer.mozilla.org/en-US/docs/Learn/HTML/Multimedia_and_embedding/Responsive_images>
|
|
33
33
|
# @see <https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img#srcset>
|
|
34
34
|
# @see <https://developer.mozilla.org/en-US/docs/Web/HTML/Element/picture>
|
|
35
|
-
def self.from_source(article_tag)
|
|
36
|
-
|
|
37
|
-
|
|
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]
|
|
35
|
+
def self.from_source(article_tag)
|
|
36
|
+
srcsets = article_tag.css('img[srcset], picture > source[srcset]').map { |source| source['srcset'] }
|
|
37
|
+
ArticleRules::Image.largest_from_srcsets(srcsets)
|
|
47
38
|
end
|
|
48
39
|
|
|
49
40
|
# @param article_tag [Nokogiri::XML::Element] article container node
|
|
50
41
|
# @return [String, nil] best style-based background image URL
|
|
51
42
|
def self.from_style(article_tag)
|
|
52
|
-
article_tag.css('[style*="url"]')
|
|
53
|
-
|
|
54
|
-
.reject { |src| src.start_with?('data:') }
|
|
55
|
-
.max_by(&:size)
|
|
43
|
+
styles = article_tag.css('[style*="url"]').map { |tag| tag['style'] }
|
|
44
|
+
ArticleRules::Image.best_from_styles(styles)
|
|
56
45
|
end
|
|
57
46
|
end
|
|
58
47
|
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
module Html
|
|
5
|
+
module ArticleRules
|
|
6
|
+
##
|
|
7
|
+
# Shared category term vocabulary and text accumulation (DOM walk stays in adapters).
|
|
8
|
+
module Category
|
|
9
|
+
# Class/data attribute tokens that mark category metadata.
|
|
10
|
+
CATEGORY_TERMS = %w[
|
|
11
|
+
category categories tag tags topic topics section sections
|
|
12
|
+
label labels theme themes subject subjects
|
|
13
|
+
].freeze
|
|
14
|
+
|
|
15
|
+
# Regex matching category-related attribute or class names.
|
|
16
|
+
CATEGORY_ATTR_PATTERN = /#{CATEGORY_TERMS.join('|')}/i
|
|
17
|
+
|
|
18
|
+
class << self
|
|
19
|
+
##
|
|
20
|
+
# @param name [String]
|
|
21
|
+
# @return [Boolean]
|
|
22
|
+
def attr_name_match?(name)
|
|
23
|
+
name.to_s.match?(CATEGORY_ATTR_PATTERN)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
##
|
|
27
|
+
# @param class_attr [String, nil]
|
|
28
|
+
# @return [Boolean]
|
|
29
|
+
def class_match?(class_attr)
|
|
30
|
+
class_attr.to_s.match?(CATEGORY_ATTR_PATTERN)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
##
|
|
34
|
+
# @param categories [Set<String>]
|
|
35
|
+
# @param text [String, nil]
|
|
36
|
+
# @return [void]
|
|
37
|
+
def add_text!(categories, text)
|
|
38
|
+
value = text.to_s.strip
|
|
39
|
+
categories.add(value) unless value.empty?
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
##
|
|
43
|
+
# @param categories [Set<String>]
|
|
44
|
+
# @param text [String, nil]
|
|
45
|
+
# @return [void]
|
|
46
|
+
def add_split_text!(categories, text)
|
|
47
|
+
return unless text
|
|
48
|
+
|
|
49
|
+
text.split(/\n+/).each { |line| add_text!(categories, line) }
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
module Html
|
|
5
|
+
module ArticleRules
|
|
6
|
+
##
|
|
7
|
+
# DOM-agnostic datetime aggregation for article published_at.
|
|
8
|
+
module Date
|
|
9
|
+
class << self
|
|
10
|
+
##
|
|
11
|
+
# @param datetime_strings [Array<String, nil>] raw datetime attribute values
|
|
12
|
+
# @return [DateTime, nil] earliest successfully parsed instant
|
|
13
|
+
def earliest(datetime_strings)
|
|
14
|
+
times = Array(datetime_strings).filter_map do |value|
|
|
15
|
+
DateTime.parse(value.to_s)
|
|
16
|
+
rescue ArgumentError, TypeError
|
|
17
|
+
nil
|
|
18
|
+
end
|
|
19
|
+
times.min
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
module Html
|
|
5
|
+
module ArticleRules
|
|
6
|
+
##
|
|
7
|
+
# DOM-agnostic enclosure URL/type normalization.
|
|
8
|
+
module Enclosure
|
|
9
|
+
# Href path suffixes treated as download/archive enclosure links.
|
|
10
|
+
ARCHIVE_HREF_SUFFIXES = %w[.pdf .zip .tar.gz .tgz].freeze
|
|
11
|
+
|
|
12
|
+
class << self
|
|
13
|
+
##
|
|
14
|
+
# @param href [String]
|
|
15
|
+
# @return [Boolean]
|
|
16
|
+
def archive_href?(href)
|
|
17
|
+
ARCHIVE_HREF_SUFFIXES.any? { |suffix| href.to_s.end_with?(suffix) }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
##
|
|
21
|
+
# @param src [String]
|
|
22
|
+
# @param base_url [String, Html2rss::Url]
|
|
23
|
+
# @return [Hash{Symbol => Object}, nil]
|
|
24
|
+
def from_image(src, base_url)
|
|
25
|
+
return if src.to_s.empty?
|
|
26
|
+
|
|
27
|
+
abs = Url.from_relative(src, base_url)
|
|
28
|
+
{ url: abs, type: Article::Enclosure.guess_content_type_from_url(abs, default: 'image/jpeg') }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
##
|
|
32
|
+
# @param src [String]
|
|
33
|
+
# @param type [String, nil]
|
|
34
|
+
# @param base_url [String, Html2rss::Url]
|
|
35
|
+
# @return [Hash{Symbol => Object}, nil]
|
|
36
|
+
def from_media(src, type, base_url)
|
|
37
|
+
return if src.to_s.empty?
|
|
38
|
+
|
|
39
|
+
{ url: Url.from_relative(src, base_url), type: }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
##
|
|
43
|
+
# @param src [String]
|
|
44
|
+
# @param base_url [String, Html2rss::Url]
|
|
45
|
+
# @return [Hash{Symbol => Object}, nil]
|
|
46
|
+
def from_iframe(src, base_url)
|
|
47
|
+
return if src.to_s.empty?
|
|
48
|
+
|
|
49
|
+
abs = Url.from_relative(src, base_url)
|
|
50
|
+
{ url: abs, type: Article::Enclosure.guess_content_type_from_url(abs, default: 'text/html') }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
##
|
|
54
|
+
# @param href [String]
|
|
55
|
+
# @param base_url [String, Html2rss::Url]
|
|
56
|
+
# @return [Hash{Symbol => Object}, nil]
|
|
57
|
+
def from_anchor(href, base_url)
|
|
58
|
+
return if href.to_s.empty?
|
|
59
|
+
|
|
60
|
+
abs = Url.from_relative(href, base_url)
|
|
61
|
+
type = if href.end_with?('.pdf')
|
|
62
|
+
Article::Enclosure.guess_content_type_from_url(abs)
|
|
63
|
+
else
|
|
64
|
+
'application/zip'
|
|
65
|
+
end
|
|
66
|
+
{ url: abs, type: }
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|