html2rss 0.22.2 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +11 -108
- data/lib/html2rss/{articles → article}/deduplicator.rb +3 -4
- data/lib/html2rss/{rss_builder → article}/enclosure.rb +6 -22
- data/lib/html2rss/article.rb +213 -0
- data/lib/html2rss/auto_source/discovery/dom_clustering/group_scorer.rb +80 -0
- data/lib/html2rss/auto_source/discovery/dom_clustering/overlap_resolver.rb +86 -0
- data/lib/html2rss/auto_source/discovery/dom_clustering.rb +119 -0
- data/lib/html2rss/auto_source/discovery/list_candidates.rb +94 -0
- data/lib/html2rss/auto_source/discovery/semantic_anchor_candidates.rb +219 -0
- data/lib/html2rss/auto_source/discovery/semantic_containers.rb +71 -0
- data/lib/html2rss/auto_source/discovery/sitemap.rb +159 -0
- data/lib/html2rss/auto_source/discovery.rb +14 -0
- data/lib/html2rss/auto_source/link_heuristics/anchor_signals.rb +28 -0
- data/lib/html2rss/auto_source/link_heuristics/container_assessor.rb +106 -0
- data/lib/html2rss/auto_source/link_heuristics/container_signals.rb +80 -0
- data/lib/html2rss/auto_source/link_heuristics/destination_facts.rb +42 -0
- data/lib/html2rss/auto_source/link_heuristics/href_extractor.rb +38 -0
- data/lib/html2rss/auto_source/link_heuristics/path_classifier.rb +221 -0
- data/lib/html2rss/auto_source/link_heuristics/text_classifier.rb +66 -0
- data/lib/html2rss/auto_source/link_heuristics.rb +139 -0
- data/lib/html2rss/auto_source/scraper/html.rb +22 -33
- data/lib/html2rss/auto_source/scraper/json_state.rb +1 -1
- data/lib/html2rss/auto_source/scraper/meta_oembed.rb +163 -0
- data/lib/html2rss/auto_source/scraper/microformats2.rb +146 -0
- data/lib/html2rss/auto_source/scraper/schema/category_extractor.rb +29 -114
- data/lib/html2rss/auto_source/scraper/schema/item_list.rb +50 -11
- data/lib/html2rss/auto_source/scraper/schema/thing.rb +35 -7
- data/lib/html2rss/auto_source/scraper/schema.rb +61 -18
- data/lib/html2rss/auto_source/scraper/semantic_html/{deduplicator.rb → entry_deduplicator.rb} +3 -3
- data/lib/html2rss/auto_source/scraper/semantic_html.rb +23 -171
- data/lib/html2rss/auto_source/scraper/sitemap.rb +153 -0
- data/lib/html2rss/auto_source/scraper/wordpress_api/page_scope/date_archive_range.rb +110 -0
- data/lib/html2rss/auto_source/scraper/wordpress_api/page_scope.rb +2 -78
- data/lib/html2rss/auto_source/scraper/wordpress_api.rb +9 -0
- data/lib/html2rss/auto_source/scraper.rb +10 -3
- data/lib/html2rss/auto_source.rb +30 -35
- data/lib/html2rss/channel.rb +198 -0
- data/lib/html2rss/cli.rb +5 -6
- data/lib/html2rss/config/auto_source_contract.rb +51 -0
- data/lib/html2rss/config/request_controls.rb +136 -0
- data/lib/html2rss/config/request_headers.rb +1 -1
- data/lib/html2rss/config/schema.rb +65 -114
- data/lib/html2rss/{selectors/config.rb → config/selectors_validator.rb} +56 -7
- data/lib/html2rss/config/validator.rb +6 -6
- data/lib/html2rss/config.rb +176 -19
- data/lib/html2rss/{configuration.rb → defaults.rb} +12 -10
- data/lib/html2rss/feed_builder/item_presentation.rb +42 -0
- data/lib/html2rss/feed_builder/json_feed/item.rb +112 -0
- data/lib/html2rss/feed_builder/json_feed.rb +81 -0
- data/lib/html2rss/feed_builder/rss/stylesheet.rb +65 -0
- data/lib/html2rss/feed_builder/rss.rb +116 -0
- data/lib/html2rss/feed_builder.rb +15 -0
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +120 -60
- data/lib/html2rss/feed_pipeline/runtime_policy.rb +108 -0
- data/lib/html2rss/feed_pipeline/strategy_plan.rb +64 -0
- data/lib/html2rss/feed_pipeline.rb +83 -89
- data/lib/html2rss/feed_result.rb +92 -0
- data/lib/html2rss/html/article_extractor/category_extractor.rb +123 -0
- data/lib/html2rss/html/article_extractor/date_extractor.rb +22 -0
- data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +95 -0
- data/lib/html2rss/html/article_extractor/heading_extractor.rb +54 -0
- data/lib/html2rss/html/article_extractor/id_generator.rb +69 -0
- data/lib/html2rss/html/article_extractor/image_extractor.rb +60 -0
- data/lib/html2rss/html/article_extractor.rb +134 -0
- data/lib/html2rss/html/navigator/text_extractor.rb +79 -0
- data/lib/html2rss/html/navigator.rb +146 -0
- data/lib/html2rss/html/rendering/audio_renderer.rb +38 -0
- data/lib/html2rss/html/rendering/description_builder.rb +89 -0
- data/lib/html2rss/html/rendering/image_renderer.rb +43 -0
- data/lib/html2rss/html/rendering/media_renderer.rb +39 -0
- data/lib/html2rss/html/rendering/pdf_renderer.rb +36 -0
- data/lib/html2rss/html/rendering/video_renderer.rb +38 -0
- data/lib/html2rss/html/rendering.rb +25 -0
- data/lib/html2rss/html.rb +8 -0
- data/lib/html2rss/request_service/blocked_surface.rb +67 -0
- data/lib/html2rss/request_service/botasaurus_contract.rb +33 -9
- data/lib/html2rss/request_service/botasaurus_strategy.rb +21 -30
- data/lib/html2rss/request_service/browserless_strategy.rb +2 -23
- data/lib/html2rss/request_service/budget.rb +97 -18
- data/lib/html2rss/request_service/context.rb +1 -3
- data/lib/html2rss/request_service/faraday_strategy.rb +12 -26
- data/lib/html2rss/request_service/local_file_strategy.rb +12 -4
- data/lib/html2rss/request_service/network_guard.rb +125 -0
- data/lib/html2rss/request_service/policy.rb +10 -82
- data/lib/html2rss/request_service/puppet_commander/navigation_guards.rb +148 -0
- data/lib/html2rss/request_service/puppet_commander/preload_runner.rb +86 -0
- data/lib/html2rss/request_service/puppet_commander.rb +22 -191
- data/lib/html2rss/request_service/response.rb +9 -1
- data/lib/html2rss/request_service/response_guard.rb +1 -1
- data/lib/html2rss/request_service/strategy.rb +123 -5
- data/lib/html2rss/request_service.rb +7 -2
- data/lib/html2rss/request_session/pager/base.rb +83 -0
- data/lib/html2rss/request_session/pager/custom_selector.rb +39 -0
- data/lib/html2rss/request_session/pager/json_cursor.rb +78 -0
- data/lib/html2rss/request_session/pager/offset.rb +48 -0
- data/lib/html2rss/request_session/pager/rel_next.rb +23 -0
- data/lib/html2rss/request_session/pager/url_template.rb +48 -0
- data/lib/html2rss/request_session/pager.rb +75 -0
- data/lib/html2rss/request_session.rb +29 -16
- data/lib/html2rss/selectors/item_scope.rb +30 -0
- data/lib/html2rss/selectors/post_processors/base.rb +1 -1
- data/lib/html2rss/selectors/post_processors/markdown_to_html.rb +0 -1
- data/lib/html2rss/selectors/post_processors/sanitize_html.rb +0 -2
- data/lib/html2rss/selectors/post_processors/template.rb +7 -3
- data/lib/html2rss/selectors.rb +84 -64
- data/lib/html2rss/status.rb +132 -0
- data/lib/html2rss/version.rb +1 -3
- data/lib/html2rss.rb +34 -22
- data/schema/html2rss-config.schema.json +109 -9
- metadata +71 -43
- data/lib/html2rss/auto_source/scraper/html/class_clustering.rb +0 -196
- data/lib/html2rss/auto_source/scraper/link_heuristics.rb +0 -401
- data/lib/html2rss/auto_source/scraper/schema/list_item.rb +0 -28
- data/lib/html2rss/auto_source/scraper/semantic_html/anchor_selector.rb +0 -49
- data/lib/html2rss/blocked_surface.rb +0 -65
- data/lib/html2rss/category_extractor.rb +0 -116
- data/lib/html2rss/config/class_methods.rb +0 -183
- data/lib/html2rss/html_extractor/date_extractor.rb +0 -20
- data/lib/html2rss/html_extractor/enclosure_extractor.rb +0 -91
- data/lib/html2rss/html_extractor/heading_extractor.rb +0 -50
- data/lib/html2rss/html_extractor/id_generator.rb +0 -67
- data/lib/html2rss/html_extractor/image_extractor.rb +0 -58
- data/lib/html2rss/html_extractor/list_candidates.rb +0 -88
- data/lib/html2rss/html_extractor/semantic_anchor_candidates.rb +0 -260
- data/lib/html2rss/html_extractor/semantic_containers.rb +0 -69
- data/lib/html2rss/html_extractor/text_extractor.rb +0 -77
- data/lib/html2rss/html_extractor.rb +0 -190
- data/lib/html2rss/html_navigator.rb +0 -71
- data/lib/html2rss/json_feed_builder/item.rb +0 -94
- data/lib/html2rss/json_feed_builder.rb +0 -59
- data/lib/html2rss/rendering/audio_renderer.rb +0 -36
- data/lib/html2rss/rendering/description_builder.rb +0 -87
- data/lib/html2rss/rendering/image_renderer.rb +0 -41
- data/lib/html2rss/rendering/media_renderer.rb +0 -37
- data/lib/html2rss/rendering/pdf_renderer.rb +0 -34
- data/lib/html2rss/rendering/video_renderer.rb +0 -36
- data/lib/html2rss/rendering.rb +0 -23
- data/lib/html2rss/request_controls.rb +0 -133
- data/lib/html2rss/request_session/rel_next_pager.rb +0 -70
- data/lib/html2rss/request_session/runtime_input.rb +0 -71
- data/lib/html2rss/request_session/runtime_policy.rb +0 -84
- data/lib/html2rss/rss_builder/article.rb +0 -208
- data/lib/html2rss/rss_builder/channel.rb +0 -118
- data/lib/html2rss/rss_builder/stylesheet.rb +0 -63
- data/lib/html2rss/rss_builder.rb +0 -101
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
class FeedPipeline
|
|
5
|
+
##
|
|
6
|
+
# Planner for the runtime request policy and budgets of a feed run.
|
|
7
|
+
#
|
|
8
|
+
# HTTP request slots (pagination, document GETs, strategy fallback) are planned
|
|
9
|
+
# separately from Browserless preload interaction budget so preload cannot steal
|
|
10
|
+
# pagination slots.
|
|
11
|
+
class RuntimePolicy
|
|
12
|
+
# Policy plus Budget from one config expansion (shared meters for a feed run).
|
|
13
|
+
Resources = Data.define(:policy, :budget)
|
|
14
|
+
|
|
15
|
+
##
|
|
16
|
+
# @param config [Html2rss::Config] validated feed config
|
|
17
|
+
# @return [Html2rss::RequestService::Policy] request policy derived from runtime config
|
|
18
|
+
def self.from_config(config)
|
|
19
|
+
RequestService::Policy.new(
|
|
20
|
+
max_requests: effective_max_requests_for(config),
|
|
21
|
+
max_redirects: config.max_redirects,
|
|
22
|
+
total_timeout_seconds: config.total_timeout_seconds || RequestService::Policy::DEFAULTS[:total_timeout_seconds]
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
##
|
|
27
|
+
# Builds policy and budget from one `from_config` so FeedPipeline does not dual-own expansion.
|
|
28
|
+
#
|
|
29
|
+
# @param config [Html2rss::Config] validated feed config
|
|
30
|
+
# @return [Resources] shared policy + budget for the feed build
|
|
31
|
+
def self.resources_for(config)
|
|
32
|
+
policy = from_config(config)
|
|
33
|
+
Resources.new(
|
|
34
|
+
policy:,
|
|
35
|
+
budget: RequestService::Budget.new(
|
|
36
|
+
max_requests: policy.max_requests,
|
|
37
|
+
max_interactions: interaction_budget_for(config),
|
|
38
|
+
total_timeout_seconds: policy.total_timeout_seconds
|
|
39
|
+
)
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
##
|
|
44
|
+
# Builds a Budget with separate request and interaction slot pools.
|
|
45
|
+
#
|
|
46
|
+
# @param config [Html2rss::Config] validated feed config
|
|
47
|
+
# @return [Html2rss::RequestService::Budget] shared budget for the feed build
|
|
48
|
+
def self.budget_for(config) = resources_for(config).budget
|
|
49
|
+
|
|
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
|
+
class << self
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def effective_max_requests_for(config)
|
|
61
|
+
return config.max_requests if config.explicit_max_requests?
|
|
62
|
+
|
|
63
|
+
[baseline_request_budget_for(config), config.max_requests].max
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Reserve enough HTTP request slots for the initial request plus predictable
|
|
67
|
+
# follow-ups. Preload interactions are planned separately.
|
|
68
|
+
def baseline_request_budget_for(config)
|
|
69
|
+
1 +
|
|
70
|
+
RequestSession::Pager.request_slots_for(config.selectors&.dig(:items, :pagination)) +
|
|
71
|
+
AutoSource.request_slots_for(config.auto_source) +
|
|
72
|
+
StrategyPlan.resolve(config.strategy).request_slots
|
|
73
|
+
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
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
class FeedPipeline
|
|
5
|
+
##
|
|
6
|
+
# Feed-level request plan: +:auto+ (fallback chain) or a concrete transport strategy.
|
|
7
|
+
#
|
|
8
|
+
# {RequestService} executes concrete adapters only. +:auto+ is resolved here before
|
|
9
|
+
# any session or adapter call — plan vs transport locality.
|
|
10
|
+
class StrategyPlan
|
|
11
|
+
# Feed-level auto plan: try {AutoFallback::CHAIN} until items are extracted.
|
|
12
|
+
Auto = Data.define do
|
|
13
|
+
# @return [Integer]
|
|
14
|
+
def request_slots
|
|
15
|
+
[AutoFallback::CHAIN.size - 1, 0].max
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Concrete transport strategy name for {RequestService}.
|
|
20
|
+
Concrete = Data.define(:strategy) do
|
|
21
|
+
# @return [Integer]
|
|
22
|
+
def request_slots = 0
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Symbol used in config / configure for the auto plan.
|
|
26
|
+
AUTO_NAME = :auto
|
|
27
|
+
|
|
28
|
+
class << self
|
|
29
|
+
##
|
|
30
|
+
# @param name [Symbol, String] config or configure strategy value
|
|
31
|
+
# @return [Auto, Concrete] resolved feed-level plan
|
|
32
|
+
# @raise [ArgumentError] when +name+ is neither +:auto+ nor a registered strategy
|
|
33
|
+
def resolve(name)
|
|
34
|
+
normalized = name.to_sym
|
|
35
|
+
return Auto.new if normalized == AUTO_NAME
|
|
36
|
+
|
|
37
|
+
unless RequestService.strategy_registered?(normalized)
|
|
38
|
+
raise ArgumentError, "unknown strategy plan: #{name.inspect}"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
Concrete.new(strategy: normalized)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
##
|
|
45
|
+
# @param name [Symbol, String, nil] candidate plan name
|
|
46
|
+
# @return [Boolean] whether +name+ is a valid feed-level strategy plan
|
|
47
|
+
def valid?(name)
|
|
48
|
+
return false unless name.is_a?(Symbol) || name.is_a?(String)
|
|
49
|
+
|
|
50
|
+
resolve(name)
|
|
51
|
+
true
|
|
52
|
+
rescue ArgumentError
|
|
53
|
+
false
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
##
|
|
57
|
+
# @return [Array<Symbol>] accepted plan names (+:auto+ plus registered strategies)
|
|
58
|
+
def accepted_names
|
|
59
|
+
[AUTO_NAME, *RequestService.strategy_names.map(&:to_sym)].uniq
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -3,7 +3,15 @@
|
|
|
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
|
+
# 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
|
+
)
|
|
14
|
+
|
|
7
15
|
##
|
|
8
16
|
# @param raw_config [Hash{Symbol => Object}] user-provided feed config
|
|
9
17
|
def initialize(raw_config)
|
|
@@ -11,123 +19,109 @@ module Html2rss
|
|
|
11
19
|
end
|
|
12
20
|
|
|
13
21
|
##
|
|
14
|
-
#
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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)
|
|
38
|
+
end
|
|
39
|
+
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
|
|
40
|
+
|
|
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
|
+
)
|
|
20
53
|
end
|
|
21
54
|
|
|
22
|
-
|
|
23
|
-
# @
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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]
|
|
29
64
|
end
|
|
30
65
|
|
|
31
66
|
private
|
|
32
67
|
|
|
33
68
|
attr_reader :raw_config
|
|
34
69
|
|
|
35
|
-
def
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
def pipeline_state_for(config)
|
|
42
|
-
if config.strategy == :auto
|
|
43
|
-
run_auto_pipeline(config)
|
|
70
|
+
def pipeline_outcome_for(config)
|
|
71
|
+
plan = StrategyPlan.resolve(config.strategy)
|
|
72
|
+
resources = RuntimePolicy.resources_for(config)
|
|
73
|
+
if plan.is_a?(StrategyPlan::Auto)
|
|
74
|
+
run_auto_pipeline(config, resources:)
|
|
44
75
|
else
|
|
45
|
-
run_pipeline_for_strategy(config, strategy:
|
|
76
|
+
run_pipeline_for_strategy(config, strategy: plan.strategy, resources:)
|
|
46
77
|
end
|
|
47
78
|
end
|
|
48
79
|
|
|
49
|
-
def run_pipeline_for_strategy(config, strategy:,
|
|
50
|
-
request_session = request_session_for(config, strategy:,
|
|
80
|
+
def run_pipeline_for_strategy(config, strategy:, resources:)
|
|
81
|
+
request_session = request_session_for(config, strategy:, resources:)
|
|
51
82
|
response = request_session.fetch_initial_response
|
|
52
|
-
articles = deduplicated_articles(
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
def request_session_for(config, strategy:, budget: nil)
|
|
57
|
-
RequestSession.from_runtime_input(runtime_input_for(config, strategy:), budget:)
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
def runtime_input_for(config, strategy:)
|
|
61
|
-
RequestSession::RuntimeInput.new(
|
|
62
|
-
url: config.url,
|
|
63
|
-
headers: config.headers,
|
|
64
|
-
request: config.request,
|
|
65
|
-
strategy:,
|
|
66
|
-
request_policy: RequestSession::RuntimePolicy.from_config(config)
|
|
83
|
+
articles, dedup_dropped = deduplicated_articles(config:, response:, request_session:)
|
|
84
|
+
PipelineOutcome.new(
|
|
85
|
+
response:, articles:, dedup_dropped:, selected_strategy: nil, attempt_count: 0, strategy_attempts: []
|
|
67
86
|
)
|
|
68
87
|
end
|
|
69
88
|
|
|
70
|
-
def
|
|
71
|
-
|
|
72
|
-
|
|
89
|
+
def run_auto_pipeline(config, resources:)
|
|
90
|
+
AutoFallback.new(
|
|
91
|
+
strategies: AutoFallback::CHAIN,
|
|
92
|
+
budget: resources.budget,
|
|
93
|
+
pipeline: self,
|
|
94
|
+
config:,
|
|
95
|
+
resources:
|
|
73
96
|
).call
|
|
74
97
|
end
|
|
75
98
|
|
|
76
|
-
def
|
|
77
|
-
|
|
99
|
+
def collect_articles(config:, response:, request_session:)
|
|
100
|
+
selector_articles(config:, response:, request_session:) +
|
|
101
|
+
auto_source_articles(config:, response:, request_session:)
|
|
78
102
|
end
|
|
79
103
|
|
|
80
104
|
# rubocop:disable Metrics/MethodLength
|
|
81
|
-
def
|
|
82
|
-
|
|
83
|
-
strategies: AutoFallback::CHAIN,
|
|
84
|
-
budget: auto_pipeline_budget(config),
|
|
85
|
-
session_for: lambda do |strategy:, budget:|
|
|
86
|
-
if budget.remaining_timeout_seconds && budget.remaining_timeout_seconds <= 0
|
|
87
|
-
raise RequestService::RequestTimedOut, 'Request timed out'
|
|
88
|
-
end
|
|
89
|
-
|
|
90
|
-
request_session_for(config, strategy:, budget:)
|
|
91
|
-
end,
|
|
92
|
-
articles_for: lambda do |response:, request_session:|
|
|
93
|
-
deduplicated_articles(response:, config:, request_session:)
|
|
94
|
-
end
|
|
95
|
-
)
|
|
96
|
-
end
|
|
97
|
-
# rubocop:enable Metrics/MethodLength
|
|
105
|
+
def selector_articles(config:, response:, request_session:)
|
|
106
|
+
return [] unless (selectors = config.selectors)
|
|
98
107
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
max_requests: policy.max_requests,
|
|
103
|
-
total_timeout_seconds: policy.total_timeout_seconds
|
|
108
|
+
page_responses = request_session.page_responses(
|
|
109
|
+
response,
|
|
110
|
+
pagination_config: selectors.dig(:items, :pagination)
|
|
104
111
|
)
|
|
105
|
-
end
|
|
106
112
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
113
|
+
articles = []
|
|
114
|
+
page_responses.each do |page_response|
|
|
115
|
+
page_articles = Selectors.new(page_response, selectors:, time_zone: config.time_zone).articles
|
|
116
|
+
break if page_articles.empty? && articles.any?
|
|
111
117
|
|
|
112
|
-
|
|
113
|
-
return [] unless (selectors = config.selectors)
|
|
114
|
-
|
|
115
|
-
page_responses = if (max_pages = selectors.dig(:items, :pagination, :max_pages))
|
|
116
|
-
RequestSession::RelNextPager.new(
|
|
117
|
-
session: request_session,
|
|
118
|
-
initial_response: response,
|
|
119
|
-
max_pages:
|
|
120
|
-
).to_a
|
|
121
|
-
else
|
|
122
|
-
[response]
|
|
123
|
-
end
|
|
124
|
-
|
|
125
|
-
page_responses.flat_map do |page_response|
|
|
126
|
-
Selectors.new(page_response, selectors:, time_zone: config.time_zone).articles
|
|
118
|
+
articles.concat(page_articles)
|
|
127
119
|
end
|
|
120
|
+
articles
|
|
128
121
|
end
|
|
122
|
+
# rubocop:enable Metrics/MethodLength
|
|
129
123
|
|
|
130
|
-
def auto_source_articles(
|
|
124
|
+
def auto_source_articles(config:, response:, request_session:)
|
|
131
125
|
return [] unless (auto_source = config.auto_source)
|
|
132
126
|
|
|
133
127
|
AutoSource.new(response, auto_source, request_session:).articles
|
|
@@ -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
|
+
# {#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
|
|
@@ -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
|