html2rss 0.23.0 → 0.25.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 +2 -2
- data/lib/html2rss/article.rb +42 -35
- 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 +0 -379
- 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 +14 -5
- data/lib/html2rss/auto_source/scraper/schema.rb +61 -18
- 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.rb +81 -44
- 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 +101 -0
- data/lib/html2rss/auto_source/segmenter/primary_link.rb +81 -0
- data/lib/html2rss/auto_source/segmenter/semantic.rb +104 -0
- data/lib/html2rss/auto_source/segmenter.rb +66 -0
- data/lib/html2rss/auto_source.rb +74 -21
- data/lib/html2rss/channel.rb +159 -79
- data/lib/html2rss/cli.rb +8 -13
- data/lib/html2rss/config/auto_source_contract.rb +2 -0
- data/lib/html2rss/config/request_controls.rb +33 -0
- data/lib/html2rss/config/validator.rb +15 -8
- data/lib/html2rss/config.rb +6 -2
- data/lib/html2rss/feed_builder/item_presentation.rb +42 -0
- data/lib/html2rss/feed_builder/json_feed/item.rb +20 -4
- data/lib/html2rss/feed_builder/json_feed.rb +22 -2
- data/lib/html2rss/feed_builder/rss.rb +36 -24
- data/lib/html2rss/feed_builder.rb +9 -17
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +71 -28
- data/lib/html2rss/feed_pipeline.rb +66 -55
- data/lib/html2rss/feed_result.rb +92 -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 +279 -0
- data/lib/html2rss/link_destination/destination_facts.rb +40 -0
- data/lib/html2rss/link_destination/noise_policy.rb +79 -0
- data/lib/html2rss/link_destination/path_classifier.rb +205 -0
- data/lib/html2rss/link_destination/text_classifier.rb +64 -0
- data/lib/html2rss/link_destination.rb +8 -0
- data/lib/html2rss/request_service/botasaurus_contract.rb +33 -5
- data/lib/html2rss/request_service/botasaurus_strategy.rb +13 -4
- data/lib/html2rss/request_service/budget.rb +7 -2
- data/lib/html2rss/request_service/faraday_strategy.rb +42 -1
- data/lib/html2rss/request_service/network_guard.rb +5 -3
- data/lib/html2rss/request_service/response.rb +9 -1
- data/lib/html2rss/request_service/strategy.rb +22 -0
- data/lib/html2rss/request_session.rb +4 -0
- data/lib/html2rss/scoring/anchor_score.rb +34 -0
- data/lib/html2rss/scoring/cluster_scorer.rb +87 -0
- data/lib/html2rss/scoring/container_assessor.rb +83 -0
- data/lib/html2rss/scoring/engine.rb +101 -0
- data/lib/html2rss/scoring/link_resolver.rb +72 -0
- data/lib/html2rss/scoring/observation.rb +53 -0
- data/lib/html2rss/scoring/ranked_segment.rb +45 -0
- data/lib/html2rss/scoring/score.rb +30 -0
- data/lib/html2rss/scoring.rb +33 -0
- data/lib/html2rss/selectors/post_processors/sanitize_html.rb +10 -1
- data/lib/html2rss/selectors.rb +26 -24
- 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/status.rb +132 -0
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss.rb +41 -29
- data/schema/html2rss-config.schema.json +20 -0
- metadata +45 -19
- 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/auto_source/scraper/schema/list_item.rb +0 -28
|
@@ -1,23 +1,15 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Html2rss
|
|
4
|
-
#
|
|
4
|
+
# Namespace for feed format adapters (RSS 2.0 / JSON Feed 1.1).
|
|
5
|
+
#
|
|
6
|
+
# {FeedResult} constructs {Rss} / {JsonFeed} directly — there is no dispatcher
|
|
7
|
+
# entrypoint on this module. Stylesheets are a scrape-time artifact carried on
|
|
8
|
+
# FeedResult; +feed_url+ is a render-time JSON Feed-only option.
|
|
9
|
+
#
|
|
10
|
+
# Channel field projection:
|
|
11
|
+
# - Rss: language, title, description, ttl, link, updated
|
|
12
|
+
# - JsonFeed: title, home_page_url, description, language, icon, authors (+ feed_url)
|
|
5
13
|
module FeedBuilder
|
|
6
|
-
# Builds the requested feed type from the channel and article list.
|
|
7
|
-
#
|
|
8
|
-
# @param type [Symbol] :rss or :json_feed
|
|
9
|
-
# @param channel [Html2rss::Channel]
|
|
10
|
-
# @param articles [Array<Html2rss::Article>]
|
|
11
|
-
# @return [RSS::Rss, Hash] format-compliant representation of the feed
|
|
12
|
-
def self.build(type, channel:, articles:, **)
|
|
13
|
-
case type
|
|
14
|
-
when :rss
|
|
15
|
-
Rss.new(channel:, articles:, **).call
|
|
16
|
-
when :json_feed
|
|
17
|
-
JsonFeed.new(channel:, articles:).call
|
|
18
|
-
else
|
|
19
|
-
raise ArgumentError, "Unknown feed type: #{type}"
|
|
20
|
-
end
|
|
21
|
-
end
|
|
22
14
|
end
|
|
23
15
|
end
|
|
@@ -5,9 +5,10 @@ module Html2rss
|
|
|
5
5
|
# Retries feed extraction across concrete request strategies for the :auto plan.
|
|
6
6
|
#
|
|
7
7
|
# Owned by {FeedPipeline}; invoked only after {StrategyPlan} resolves +:auto+.
|
|
8
|
+
# Hosted by the pipeline instance: session + extract call back into FeedPipeline.
|
|
8
9
|
class AutoFallback
|
|
9
10
|
# Ordered list of concrete request strategies attempted by the :auto plan.
|
|
10
|
-
CHAIN = %i[faraday botasaurus
|
|
11
|
+
CHAIN = %i[faraday botasaurus].freeze
|
|
11
12
|
|
|
12
13
|
# Error classes that should abort auto fallback immediately.
|
|
13
14
|
NON_FALLBACK_ERRORS = [
|
|
@@ -44,34 +45,49 @@ module Html2rss
|
|
|
44
45
|
|
|
45
46
|
# @param strategy [Symbol] strategy that returned a response
|
|
46
47
|
# @param items_count [Integer] extracted article count
|
|
48
|
+
# @param transport_meta [Hash, nil] optional allowlisted upstream telemetry
|
|
47
49
|
# @return [void]
|
|
48
|
-
def record_items(strategy:, items_count:)
|
|
49
|
-
|
|
50
|
+
def record_items(strategy:, items_count:, transport_meta: nil)
|
|
51
|
+
attempt = { strategy:, items_count:, error_class: nil }
|
|
52
|
+
attempt[:transport_meta] = transport_meta if transport_meta && !transport_meta.empty?
|
|
53
|
+
@attempts << attempt
|
|
50
54
|
end
|
|
51
55
|
|
|
52
56
|
# @param response [RequestService::Response] successful response
|
|
53
57
|
# @param articles [Array] extracted articles
|
|
58
|
+
# @param dedup_dropped [Integer] articles removed by deduplication
|
|
59
|
+
# @param selected_strategy [Symbol] concrete strategy that produced items
|
|
60
|
+
# @param attempt_count [Integer] number of attempts recorded for this chain
|
|
54
61
|
# @return [void]
|
|
55
|
-
def succeed!(response:, articles:)
|
|
56
|
-
@result =
|
|
62
|
+
def succeed!(response:, articles:, dedup_dropped:, selected_strategy:, attempt_count:)
|
|
63
|
+
@result = PipelineOutcome.new(
|
|
64
|
+
response:,
|
|
65
|
+
articles:,
|
|
66
|
+
dedup_dropped:,
|
|
67
|
+
selected_strategy:,
|
|
68
|
+
attempt_count:,
|
|
69
|
+
strategy_attempts: attempts
|
|
70
|
+
)
|
|
57
71
|
end
|
|
58
72
|
end
|
|
59
73
|
|
|
60
74
|
##
|
|
61
75
|
# @param strategies [Array<Symbol>] ordered concrete strategies for fallback
|
|
62
76
|
# @param budget [RequestService::Budget] shared request budget across retries
|
|
63
|
-
# @param
|
|
64
|
-
# @param
|
|
77
|
+
# @param pipeline [Html2rss::FeedPipeline] host for session + article extraction
|
|
78
|
+
# @param config [Html2rss::Config] validated feed config
|
|
79
|
+
# @param resources [Html2rss::FeedPipeline::RuntimePolicy::Resources] budget + policy
|
|
65
80
|
# @return [void]
|
|
66
|
-
def initialize(strategies:, budget:,
|
|
81
|
+
def initialize(strategies:, budget:, pipeline:, config:, resources:)
|
|
67
82
|
@strategies = strategies
|
|
68
83
|
@budget = budget
|
|
69
|
-
@
|
|
70
|
-
@
|
|
84
|
+
@pipeline = pipeline
|
|
85
|
+
@config = config
|
|
86
|
+
@resources = resources
|
|
71
87
|
end
|
|
72
88
|
|
|
73
89
|
##
|
|
74
|
-
# @return [
|
|
90
|
+
# @return [Html2rss::FeedPipeline::PipelineOutcome] scrape-finished state for materialization
|
|
75
91
|
def call
|
|
76
92
|
state = run_attempts
|
|
77
93
|
return state.result if state.succeeded?
|
|
@@ -81,7 +97,7 @@ module Html2rss
|
|
|
81
97
|
|
|
82
98
|
private
|
|
83
99
|
|
|
84
|
-
attr_reader :strategies, :budget, :
|
|
100
|
+
attr_reader :strategies, :budget, :pipeline, :config, :resources
|
|
85
101
|
|
|
86
102
|
def run_attempts
|
|
87
103
|
AttemptState.new.tap do |state|
|
|
@@ -93,7 +109,7 @@ module Html2rss
|
|
|
93
109
|
end
|
|
94
110
|
|
|
95
111
|
def attempt(strategy:, next_strategy:, state:)
|
|
96
|
-
request_session =
|
|
112
|
+
request_session = pipeline.request_session_for(config, strategy:, resources:)
|
|
97
113
|
response = fetch_response(request_session:, strategy:, next_strategy:, state:)
|
|
98
114
|
return unless response
|
|
99
115
|
|
|
@@ -106,38 +122,65 @@ module Html2rss
|
|
|
106
122
|
raise
|
|
107
123
|
rescue StandardError => error
|
|
108
124
|
state.record_error(strategy:, error:)
|
|
109
|
-
|
|
110
|
-
Log.debug("#{self.class}: strategy=#{strategy} error=#{error.class}: #{error.message}")
|
|
125
|
+
log_fallback_error(strategy:, next_strategy:, error:, request_session:) if next_strategy
|
|
111
126
|
nil
|
|
112
127
|
end
|
|
113
128
|
|
|
114
129
|
def process_response(response:, strategy:, next_strategy:, request_session:, state:)
|
|
115
|
-
articles = articles_for
|
|
130
|
+
articles, dedup_dropped = articles_for(response:, request_session:)
|
|
116
131
|
items_count = articles.size
|
|
117
|
-
state.record_items(strategy:, items_count:)
|
|
118
|
-
Log.debug("#{self.class}: strategy=#{strategy} items=#{items_count}"
|
|
119
|
-
|
|
132
|
+
state.record_items(strategy:, items_count:, transport_meta: response.transport_meta)
|
|
133
|
+
Log.debug("#{self.class}: strategy=#{strategy} items=#{items_count} " \
|
|
134
|
+
"host=#{response.url.host} elapsed=#{format('%.3f', budget.elapsed_seconds)}s " \
|
|
135
|
+
"budget_remaining=#{budget_remaining_label}")
|
|
136
|
+
return record_success(response:, strategy:, articles:, dedup_dropped:, state:) if items_count.positive?
|
|
120
137
|
|
|
121
|
-
log_info_fallback_zero_items(strategy:, next_strategy:) if next_strategy
|
|
138
|
+
log_info_fallback_zero_items(strategy:, next_strategy:, response:) if next_strategy
|
|
122
139
|
end
|
|
123
140
|
|
|
124
|
-
def
|
|
125
|
-
|
|
126
|
-
|
|
141
|
+
def articles_for(response:, request_session:)
|
|
142
|
+
pipeline.deduplicated_articles(config:, response:, request_session:)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def record_success(response:, strategy:, articles:, dedup_dropped:, state:)
|
|
146
|
+
attempt_count = state.attempts.size
|
|
147
|
+
state.succeed!(response:, articles:, dedup_dropped:, selected_strategy: strategy, attempt_count:)
|
|
148
|
+
return unless attempt_count > 1
|
|
127
149
|
|
|
128
|
-
Log.info("#{self.class}: auto selected strategy=#{strategy} after attempts=#{
|
|
150
|
+
Log.info("#{self.class}: auto selected strategy=#{strategy} after attempts=#{attempt_count} " \
|
|
151
|
+
"host=#{response.url.host} elapsed=#{format('%.3f', budget.elapsed_seconds)}s " \
|
|
152
|
+
"budget_remaining=#{budget_remaining_label}")
|
|
129
153
|
end
|
|
130
154
|
|
|
131
155
|
def finalize_failure(attempts:)
|
|
132
156
|
raise NoFeedItemsExtracted.new(attempts:)
|
|
133
157
|
end
|
|
134
158
|
|
|
135
|
-
|
|
136
|
-
|
|
159
|
+
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
160
|
+
def log_fallback_error(strategy:, next_strategy:, error:, request_session:)
|
|
161
|
+
host = request_session.url.host
|
|
162
|
+
detail = "host=#{host} elapsed=#{format('%.3f', budget.elapsed_seconds)}s " \
|
|
163
|
+
"budget_remaining=#{budget_remaining_label}"
|
|
164
|
+
if error.is_a?(RequestService::RequestTimedOut)
|
|
165
|
+
Log.info("#{self.class}: auto fallback #{strategy} -> #{next_strategy} " \
|
|
166
|
+
"after timeout=#{error.class} #{detail}")
|
|
167
|
+
else
|
|
168
|
+
Log.warn("#{self.class}: auto fallback #{strategy} -> #{next_strategy} " \
|
|
169
|
+
"after error=#{error.class} #{detail}")
|
|
170
|
+
end
|
|
171
|
+
Log.debug("#{self.class}: strategy=#{strategy} error=#{error.class}: #{error.message} #{detail}")
|
|
172
|
+
end
|
|
173
|
+
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
|
|
174
|
+
|
|
175
|
+
def log_info_fallback_zero_items(strategy:, next_strategy:, response:)
|
|
176
|
+
Log.info("#{self.class}: auto fallback #{strategy} -> #{next_strategy} after zero extracted items " \
|
|
177
|
+
"host=#{response.url.host} elapsed=#{format('%.3f', budget.elapsed_seconds)}s " \
|
|
178
|
+
"budget_remaining=#{budget_remaining_label}")
|
|
137
179
|
end
|
|
138
180
|
|
|
139
|
-
def
|
|
140
|
-
|
|
181
|
+
def budget_remaining_label
|
|
182
|
+
remaining = budget.remaining_timeout_seconds
|
|
183
|
+
remaining.nil? ? 'untracked' : format('%.3f', remaining)
|
|
141
184
|
end
|
|
142
185
|
end
|
|
143
186
|
end
|
|
@@ -4,8 +4,13 @@ module Html2rss
|
|
|
4
4
|
##
|
|
5
5
|
# Builds feeds from validated config through request, extraction, and rendering stages.
|
|
6
6
|
class FeedPipeline
|
|
7
|
-
#
|
|
8
|
-
|
|
7
|
+
# Scrape-finished facts after request + extraction + dedup (before Channel/Status materialize).
|
|
8
|
+
# selected_strategy: set on :auto success; nil otherwise.
|
|
9
|
+
# attempt_count: auto attempts attempted; 0 outside :auto.
|
|
10
|
+
# strategy_attempts: auto attempt hashes (with optional transport_meta); empty outside :auto.
|
|
11
|
+
PipelineOutcome = Data.define(
|
|
12
|
+
:response, :articles, :dedup_dropped, :selected_strategy, :attempt_count, :strategy_attempts
|
|
13
|
+
)
|
|
9
14
|
|
|
10
15
|
##
|
|
11
16
|
# @param raw_config [Hash{Symbol => Object}] user-provided feed config
|
|
@@ -14,34 +19,55 @@ module Html2rss
|
|
|
14
19
|
end
|
|
15
20
|
|
|
16
21
|
##
|
|
17
|
-
#
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
22
|
+
# Runs the pipeline once and returns an opaque, Marshal-cacheable result.
|
|
23
|
+
#
|
|
24
|
+
# @return [Html2rss::FeedResult]
|
|
25
|
+
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- Status kwargs stay co-located with Channel
|
|
26
|
+
def to_result
|
|
27
|
+
config = Config.from_hash(raw_config, params: raw_config[:params])
|
|
28
|
+
outcome = pipeline_outcome_for(config)
|
|
29
|
+
channel = Channel.from_response(outcome.response, overrides: config.channel)
|
|
30
|
+
status = Status.build(
|
|
31
|
+
articles: outcome.articles,
|
|
32
|
+
dedup_dropped: outcome.dedup_dropped,
|
|
33
|
+
selected_strategy: outcome.selected_strategy,
|
|
34
|
+
attempt_count: outcome.attempt_count,
|
|
35
|
+
strategy_attempts: outcome.strategy_attempts
|
|
36
|
+
)
|
|
37
|
+
FeedResult.new(channel:, articles: outcome.articles, status:, stylesheets: config.stylesheets)
|
|
23
38
|
end
|
|
39
|
+
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
|
|
24
40
|
|
|
25
|
-
|
|
26
|
-
# @
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
41
|
+
# @api private Host seam for {AutoFallback} (and single-strategy path).
|
|
42
|
+
# @param config [Html2rss::Config]
|
|
43
|
+
# @param strategy [Symbol]
|
|
44
|
+
# @param resources [Html2rss::FeedPipeline::RuntimePolicy::Resources]
|
|
45
|
+
# @return [Html2rss::RequestSession]
|
|
46
|
+
def request_session_for(config, strategy:, resources:)
|
|
47
|
+
RequestSession.build(
|
|
48
|
+
config:,
|
|
49
|
+
strategy:,
|
|
50
|
+
budget: resources.budget,
|
|
51
|
+
policy: resources.policy
|
|
52
|
+
)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @api private Host seam for {AutoFallback} (and single-strategy path).
|
|
56
|
+
# @param config [Html2rss::Config]
|
|
57
|
+
# @param response [Html2rss::RequestService::Response]
|
|
58
|
+
# @param request_session [Html2rss::RequestSession]
|
|
59
|
+
# @return [Array(Array<Html2rss::Article>, Integer)] unique articles and drop count
|
|
60
|
+
def deduplicated_articles(config:, response:, request_session:)
|
|
61
|
+
collected = collect_articles(config:, response:, request_session:)
|
|
62
|
+
unique = Article::Deduplicator.new(collected).call
|
|
63
|
+
[unique, collected.size - unique.size]
|
|
32
64
|
end
|
|
33
65
|
|
|
34
66
|
private
|
|
35
67
|
|
|
36
68
|
attr_reader :raw_config
|
|
37
69
|
|
|
38
|
-
def
|
|
39
|
-
config = Config.from_hash(raw_config, params: raw_config[:params])
|
|
40
|
-
state = pipeline_state_for(config)
|
|
41
|
-
yield response: state.fetch(:response), config:, articles: state.fetch(:articles)
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
def pipeline_state_for(config)
|
|
70
|
+
def pipeline_outcome_for(config)
|
|
45
71
|
plan = StrategyPlan.resolve(config.strategy)
|
|
46
72
|
resources = RuntimePolicy.resources_for(config)
|
|
47
73
|
if plan.is_a?(StrategyPlan::Auto)
|
|
@@ -54,52 +80,37 @@ module Html2rss
|
|
|
54
80
|
def run_pipeline_for_strategy(config, strategy:, resources:)
|
|
55
81
|
request_session = request_session_for(config, strategy:, resources:)
|
|
56
82
|
response = request_session.fetch_initial_response
|
|
57
|
-
articles = deduplicated_articles(
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
end
|
|
83
|
+
articles, dedup_dropped = deduplicated_articles(config:, response:, request_session:)
|
|
84
|
+
if config.auto_source && articles.empty?
|
|
85
|
+
raise NoFeedItemsExtracted.new(attempts: [{ strategy:, items_count: 0, error_class: nil }])
|
|
86
|
+
end
|
|
62
87
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
config:,
|
|
66
|
-
strategy:,
|
|
67
|
-
budget: resources.budget,
|
|
68
|
-
policy: resources.policy
|
|
88
|
+
PipelineOutcome.new(
|
|
89
|
+
response:, articles:, dedup_dropped:, selected_strategy: nil, attempt_count: 0, strategy_attempts: []
|
|
69
90
|
)
|
|
70
91
|
end
|
|
71
92
|
|
|
72
|
-
def deduplicated_articles(extraction)
|
|
73
|
-
Article::Deduplicator.new(collect_articles(extraction)).call
|
|
74
|
-
end
|
|
75
|
-
|
|
76
|
-
# rubocop:disable Metrics/MethodLength
|
|
77
93
|
def run_auto_pipeline(config, resources:)
|
|
78
94
|
AutoFallback.new(
|
|
79
95
|
strategies: AutoFallback::CHAIN,
|
|
80
96
|
budget: resources.budget,
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
end,
|
|
85
|
-
articles_for: lambda do |response:, request_session:|
|
|
86
|
-
deduplicated_articles(ExtractionContext.new(config:, response:, request_session:))
|
|
87
|
-
end
|
|
97
|
+
pipeline: self,
|
|
98
|
+
config:,
|
|
99
|
+
resources:
|
|
88
100
|
).call
|
|
89
101
|
end
|
|
90
|
-
# rubocop:enable Metrics/MethodLength
|
|
91
102
|
|
|
92
|
-
def collect_articles(
|
|
93
|
-
selector_articles(
|
|
103
|
+
def collect_articles(config:, response:, request_session:)
|
|
104
|
+
selector_articles(config:, response:, request_session:) +
|
|
105
|
+
auto_source_articles(config:, response:, request_session:)
|
|
94
106
|
end
|
|
95
107
|
|
|
96
108
|
# rubocop:disable Metrics/MethodLength
|
|
97
|
-
def selector_articles(
|
|
98
|
-
config = extraction.config
|
|
109
|
+
def selector_articles(config:, response:, request_session:)
|
|
99
110
|
return [] unless (selectors = config.selectors)
|
|
100
111
|
|
|
101
|
-
page_responses =
|
|
102
|
-
|
|
112
|
+
page_responses = request_session.page_responses(
|
|
113
|
+
response,
|
|
103
114
|
pagination_config: selectors.dig(:items, :pagination)
|
|
104
115
|
)
|
|
105
116
|
|
|
@@ -114,10 +125,10 @@ module Html2rss
|
|
|
114
125
|
end
|
|
115
126
|
# rubocop:enable Metrics/MethodLength
|
|
116
127
|
|
|
117
|
-
def auto_source_articles(
|
|
118
|
-
return [] unless (auto_source =
|
|
128
|
+
def auto_source_articles(config:, response:, request_session:)
|
|
129
|
+
return [] unless (auto_source = config.auto_source)
|
|
119
130
|
|
|
120
|
-
AutoSource.new(
|
|
131
|
+
AutoSource.new(response, auto_source, request_session:).articles
|
|
121
132
|
end
|
|
122
133
|
end
|
|
123
134
|
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
##
|
|
5
|
+
# Closed Marshal-cacheable handle for one scrape.
|
|
6
|
+
#
|
|
7
|
+
# Frozen consumer query/render set (do not grow without an explicit contract change):
|
|
8
|
+
# {#empty?}, {#channel_title}, {#to_rss}, {#to_json_feed}, and {#status}.
|
|
9
|
+
#
|
|
10
|
+
# Non-goals: no Channel reader, no Articles reader, no peephole into scrape internals.
|
|
11
|
+
# html2rss-web and other consumers must use only this surface.
|
|
12
|
+
#
|
|
13
|
+
# @note Gem contract: instances must round-trip through +Marshal.dump+ / +Marshal.load+
|
|
14
|
+
# so web can cache one scrape and render RSS or JSON Feed on read. Loaded results
|
|
15
|
+
# are re-frozen via private {#marshal_load}. For trusted cache reads only,
|
|
16
|
+
# +Marshal.load(payload, freeze: true)+ is also safe.
|
|
17
|
+
class FeedResult
|
|
18
|
+
##
|
|
19
|
+
# @param channel [Html2rss::Channel] channel metadata (internal; not exposed on the public API)
|
|
20
|
+
# @param articles [Array<Html2rss::Article>] extracted articles (deduplicated; not exposed)
|
|
21
|
+
# @param status [Html2rss::Status] pipeline telemetry
|
|
22
|
+
# @param stylesheets [Array<Hash>] optional RSS stylesheet configs
|
|
23
|
+
def initialize(channel:, articles:, status:, stylesheets: [])
|
|
24
|
+
raise ArgumentError, 'channel must be a Html2rss::Channel' unless channel.is_a?(Channel)
|
|
25
|
+
raise ArgumentError, 'status must be a Html2rss::Status' unless status.is_a?(Status)
|
|
26
|
+
|
|
27
|
+
articles = Array(articles)
|
|
28
|
+
raise ArgumentError, 'articles must all be Html2rss::Article' unless articles.all?(Article)
|
|
29
|
+
|
|
30
|
+
@channel = channel
|
|
31
|
+
@articles = articles.dup.freeze
|
|
32
|
+
@status = status
|
|
33
|
+
@stylesheets = freeze_stylesheets(stylesheets)
|
|
34
|
+
freeze
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
##
|
|
38
|
+
# @return [Boolean] true when the scrape produced no items
|
|
39
|
+
def empty? = @articles.empty?
|
|
40
|
+
|
|
41
|
+
##
|
|
42
|
+
# Channel title string only — does not expose the channel object.
|
|
43
|
+
#
|
|
44
|
+
# @return [String] title from the materialized channel
|
|
45
|
+
def channel_title = @channel.title
|
|
46
|
+
|
|
47
|
+
##
|
|
48
|
+
# @return [RSS::Rss] RSS 2.0 document
|
|
49
|
+
def to_rss
|
|
50
|
+
FeedBuilder::Rss.new(
|
|
51
|
+
channel: @channel,
|
|
52
|
+
articles: @articles,
|
|
53
|
+
stylesheets: @stylesheets,
|
|
54
|
+
generator: status.to_generator_comment
|
|
55
|
+
).call
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
##
|
|
59
|
+
# @param feed_url [String, nil] optional self URL for JSON Feed (+feed_url+)
|
|
60
|
+
# @return [Hash] JSON Feed 1.1 hash
|
|
61
|
+
def to_json_feed(feed_url: nil)
|
|
62
|
+
FeedBuilder::JsonFeed.new(
|
|
63
|
+
channel: @channel,
|
|
64
|
+
articles: @articles,
|
|
65
|
+
feed_url:,
|
|
66
|
+
user_comment: status.to_generator_comment
|
|
67
|
+
).call
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
##
|
|
71
|
+
# @return [Html2rss::Status] frozen scraper tallies, dedup count, and generator formatter.
|
|
72
|
+
# {Status#to_h} on +status+ is a stable consumer contract for observability payloads.
|
|
73
|
+
attr_reader :status
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
# Constructor payload for Marshal (avoids default ivar thaw). Rebuilds via {#initialize}.
|
|
78
|
+
def marshal_dump = [@channel, @articles, @status, @stylesheets]
|
|
79
|
+
|
|
80
|
+
def marshal_load((channel, articles, status, stylesheets))
|
|
81
|
+
initialize(channel:, articles:, status:, stylesheets:)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def freeze_stylesheets(stylesheets)
|
|
85
|
+
Array(stylesheets).map do |sheet|
|
|
86
|
+
sheet.to_h.transform_keys(&:to_sym).transform_values do |value|
|
|
87
|
+
value.is_a?(String) ? value.dup.freeze : value
|
|
88
|
+
end.freeze
|
|
89
|
+
end.freeze
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -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
|