html2rss 0.25.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/json_state.rb +31 -22
- data/lib/html2rss/auto_source/scraper/schema/item_list.rb +2 -14
- data/lib/html2rss/auto_source/scraper/xhr_articles.rb +69 -0
- data/lib/html2rss/auto_source/scraper.rb +34 -15
- data/lib/html2rss/auto_source/segmenter/list.rb +3 -1
- data/lib/html2rss/auto_source/segmenter/primary_link.rb +6 -7
- data/lib/html2rss/auto_source/segmenter.rb +30 -1
- data/lib/html2rss/auto_source.rb +7 -2
- data/lib/html2rss/capture.rb +315 -0
- data/lib/html2rss/cli.rb +64 -5
- data/lib/html2rss/config/auto_source_contract.rb +3 -1
- data/lib/html2rss/config/schema.rb +67 -26
- data/lib/html2rss/config/selectors_validator.rb +60 -21
- data/lib/html2rss/config/validator.rb +18 -27
- data/lib/html2rss/error.rb +15 -3
- 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 +10 -3
- data/lib/html2rss/hash_util.rb +17 -0
- data/lib/html2rss/html/sst_article_extractor.rb +36 -7
- data/lib/html2rss/link_destination/noise_policy.rb +7 -18
- data/lib/html2rss/link_destination/path_classifier.rb +3 -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 +45 -2
- 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/container_assessor.rb +9 -15
- data/lib/html2rss/scoring/engine.rb +49 -5
- data/lib/html2rss/scoring/link_resolver.rb +12 -0
- data/lib/html2rss/scoring/ranked_segment.rb +0 -12
- data/lib/html2rss/scoring/score.rb +1 -20
- data/lib/html2rss/scoring.rb +0 -25
- 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 +13 -0
- 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/url.rb +11 -7
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss.rb +32 -1
- data/schema/html2rss-config.schema.json +408 -85
- metadata +54 -12
- 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
- data/lib/html2rss/scoring/anchor_score.rb +0 -34
|
@@ -75,6 +75,9 @@ module Html2rss
|
|
|
75
75
|
##
|
|
76
76
|
# Validates the configuration of a single selector.
|
|
77
77
|
class Selector < Dry::Validation::Contract
|
|
78
|
+
# Extractor Options members supplied at runtime, not from feed YAML.
|
|
79
|
+
RUNTIME_EXTRACTOR_FIELDS = %i[selector channel].to_set.freeze
|
|
80
|
+
|
|
78
81
|
params do
|
|
79
82
|
optional(:selector)
|
|
80
83
|
optional(:extractor).filled(:string)
|
|
@@ -88,33 +91,69 @@ module Html2rss
|
|
|
88
91
|
end
|
|
89
92
|
|
|
90
93
|
rule(:extractor) do
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
94
|
+
next unless value
|
|
95
|
+
|
|
96
|
+
klass = Selectors::Extractors::NAME_TO_CLASS[value.to_sym]
|
|
97
|
+
next key(:extractor).failure("unknown extractor: #{value}") unless klass
|
|
98
|
+
|
|
99
|
+
klass::Options.members.each do |field|
|
|
100
|
+
next if RUNTIME_EXTRACTOR_FIELDS.include?(field)
|
|
101
|
+
next if values[field]
|
|
102
|
+
|
|
103
|
+
key(field).failure("`#{field}` is required for extractor `#{value}`")
|
|
97
104
|
end
|
|
98
105
|
end
|
|
99
106
|
|
|
100
107
|
rule(:post_process).each do
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
key(
|
|
110
|
-
when 'html_to_markdown', 'markdown_to_html', 'parse_time', 'parse_uri', 'sanitize_html'
|
|
111
|
-
# nothing to validate
|
|
112
|
-
when nil
|
|
113
|
-
key(:post_process).failure('Missing post_processor `name`')
|
|
114
|
-
else
|
|
115
|
-
key(:post_process).failure("Unknown post_processor `name`: #{name}")
|
|
108
|
+
name = value[:name]
|
|
109
|
+
next key(:post_process).failure('Missing post_processor `name`') if name.nil?
|
|
110
|
+
|
|
111
|
+
klass = Selectors::PostProcessors::NAME_TO_CLASS[name.to_sym]
|
|
112
|
+
next key(:post_process).failure("Unknown post_processor `name`: #{name}") unless klass
|
|
113
|
+
next unless klass.const_defined?(:Options)
|
|
114
|
+
|
|
115
|
+
post_process_option_type_errors(klass, value).each do |field, message|
|
|
116
|
+
key(field).failure(message)
|
|
116
117
|
end
|
|
117
118
|
end
|
|
119
|
+
|
|
120
|
+
private
|
|
121
|
+
|
|
122
|
+
def post_process_option_type_errors(klass, value)
|
|
123
|
+
required_option_type_errors(klass, value) + optional_option_type_errors(klass, value)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def required_option_type_errors(klass, value)
|
|
127
|
+
option_types = klass.const_defined?(:OPTION_TYPES) ? klass::OPTION_TYPES : {}
|
|
128
|
+
|
|
129
|
+
klass::Options.members.filter_map do |field|
|
|
130
|
+
expected = option_types.fetch(field, String)
|
|
131
|
+
next if value[field].is_a?(expected)
|
|
132
|
+
|
|
133
|
+
[field, option_type_failure(field, expected)]
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def optional_option_type_errors(klass, value)
|
|
138
|
+
return [] unless klass.const_defined?(:OPTIONAL_OPTION_TYPES)
|
|
139
|
+
|
|
140
|
+
klass::OPTIONAL_OPTION_TYPES.filter_map do |field, expected|
|
|
141
|
+
actual = value[field]
|
|
142
|
+
next if actual.nil? || actual.is_a?(expected)
|
|
143
|
+
|
|
144
|
+
[field, option_type_failure(field, expected, optional: true)]
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def option_type_failure(field, expected, optional: false)
|
|
149
|
+
label = {
|
|
150
|
+
Integer => 'an integer',
|
|
151
|
+
String => 'a string',
|
|
152
|
+
Hash => 'a hash'
|
|
153
|
+
}.fetch(expected) { "a #{expected}" }
|
|
154
|
+
suffix = optional ? ' or omitted' : ''
|
|
155
|
+
"`#{field}` must be #{label}#{suffix}"
|
|
156
|
+
end
|
|
118
157
|
end
|
|
119
158
|
|
|
120
159
|
##
|
|
@@ -15,6 +15,11 @@ module Html2rss
|
|
|
15
15
|
LANGUAGE_FORMAT_REGEX = /\A[a-z]{2}(-[A-Z]{2})?\z/
|
|
16
16
|
# Baseline strategy-plan enum (:auto plus concrete RequestService strategies).
|
|
17
17
|
BASE_STRATEGY_OPTIONS = Html2rss::FeedPipeline::StrategyPlan.accepted_names.freeze
|
|
18
|
+
# Controlled vocabulary for catalog-only `directory.topics` (not RSS channel fields).
|
|
19
|
+
DIRECTORY_TOPICS = %w[
|
|
20
|
+
sports energy tech science news entertainment jobs finance
|
|
21
|
+
security travel environment consumer civic product research
|
|
22
|
+
].freeze
|
|
18
23
|
|
|
19
24
|
# Contract for the top-level `channel` section.
|
|
20
25
|
ChannelConfig = Dry::Schema.Params do
|
|
@@ -28,6 +33,11 @@ module Html2rss
|
|
|
28
33
|
optional(:image).maybe(:string, format?: URI_REGEXP)
|
|
29
34
|
end
|
|
30
35
|
|
|
36
|
+
# Contract for catalog-only `directory` metadata (topics for feed directories).
|
|
37
|
+
DirectoryConfig = Dry::Schema.Params do
|
|
38
|
+
optional(:topics).value(:array, min_size?: 1).each(:string, included_in?: DIRECTORY_TOPICS)
|
|
39
|
+
end
|
|
40
|
+
|
|
31
41
|
# Contract for a stylesheet entry in `stylesheets`.
|
|
32
42
|
StylesheetConfig = Dry::Schema.Params do
|
|
33
43
|
required(:href).filled(:string)
|
|
@@ -35,47 +45,28 @@ module Html2rss
|
|
|
35
45
|
optional(:media).maybe(:string)
|
|
36
46
|
end
|
|
37
47
|
|
|
38
|
-
# Contract for Browserless click-preload options.
|
|
39
|
-
BrowserlessPreloadClickSelectorConfig = Dry::Schema.Params do
|
|
40
|
-
required(:selector).filled(:string)
|
|
41
|
-
optional(:max_clicks).filled(:integer, gt?: 0)
|
|
42
|
-
optional(:wait_after_ms).filled(:integer, gteq?: 0)
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
# Contract for Browserless scroll-preload options.
|
|
46
|
-
BrowserlessPreloadScrollConfig = Dry::Schema.Params do
|
|
47
|
-
optional(:iterations).filled(:integer, gt?: 0)
|
|
48
|
-
optional(:wait_after_ms).filled(:integer, gteq?: 0)
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
# Contract for Browserless preload orchestration options.
|
|
52
|
-
BrowserlessPreloadConfig = Dry::Schema.Params do
|
|
53
|
-
optional(:wait_after_ms).filled(:integer, gteq?: 0)
|
|
54
|
-
optional(:click_selectors).array(BrowserlessPreloadClickSelectorConfig)
|
|
55
|
-
optional(:scroll_down).hash(BrowserlessPreloadScrollConfig)
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
# Contract for Browserless-specific request options.
|
|
59
|
-
BrowserlessRequestConfig = Dry::Schema.Params do
|
|
60
|
-
optional(:preload).hash(BrowserlessPreloadConfig)
|
|
61
|
-
end
|
|
62
|
-
|
|
63
48
|
# Contract for Botasaurus-specific request options.
|
|
64
49
|
BotasaurusRequestConfig = Dry::Schema.Params do
|
|
65
50
|
config.validate_keys = true
|
|
66
51
|
|
|
67
|
-
optional(:
|
|
52
|
+
optional(:execution_mode).filled(:string, included_in?: %w[auto request browser])
|
|
53
|
+
optional(:navigation_mode).filled(:string, included_in?: %w[auto get google_get google_get_bypass organic_get])
|
|
68
54
|
optional(:max_retries).filled(:integer, gteq?: 0, lteq?: 3)
|
|
69
55
|
optional(:wait_for_selector).maybe(:string)
|
|
70
56
|
optional(:wait_timeout_seconds).filled(:integer, gt?: 0)
|
|
57
|
+
optional(:scroll).filled(:bool)
|
|
58
|
+
optional(:scroll_to_bottom).filled(:bool)
|
|
71
59
|
optional(:block_images).filled(:bool)
|
|
72
60
|
optional(:block_images_and_css).filled(:bool)
|
|
61
|
+
optional(:block_trackers).filled(:bool)
|
|
73
62
|
optional(:wait_for_complete_page_load).filled(:bool)
|
|
74
63
|
optional(:headless).filled(:bool)
|
|
75
64
|
optional(:proxy).filled(:string)
|
|
76
65
|
optional(:user_agent).filled(:string)
|
|
77
66
|
optional(:window_size).value(:array, min_size?: 2, max_size?: 2).each(:integer, gt?: 0)
|
|
78
67
|
optional(:lang).filled(:string)
|
|
68
|
+
optional(:cookies).hash
|
|
69
|
+
optional(:headers).hash
|
|
79
70
|
end
|
|
80
71
|
|
|
81
72
|
# Contract for the top-level `request` section.
|
|
@@ -83,7 +74,6 @@ module Html2rss
|
|
|
83
74
|
optional(:max_redirects).filled(:integer, gteq?: 0)
|
|
84
75
|
optional(:max_requests).filled(:integer, gt?: 0)
|
|
85
76
|
optional(:total_timeout_seconds).filled(:integer, gt?: 0)
|
|
86
|
-
optional(:browserless).hash(BrowserlessRequestConfig)
|
|
87
77
|
optional(:botasaurus).hash(BotasaurusRequestConfig)
|
|
88
78
|
optional(:local_file_path).filled(:string)
|
|
89
79
|
end
|
|
@@ -91,6 +81,7 @@ module Html2rss
|
|
|
91
81
|
params do
|
|
92
82
|
optional(:strategy).filled(:symbol)
|
|
93
83
|
required(:channel).hash(ChannelConfig)
|
|
84
|
+
optional(:directory).hash(DirectoryConfig)
|
|
94
85
|
optional(:headers).hash
|
|
95
86
|
optional(:stylesheets).array(StylesheetConfig)
|
|
96
87
|
optional(:auto_source).hash(Config::AutoSourceContract)
|
data/lib/html2rss/error.rb
CHANGED
|
@@ -6,16 +6,24 @@ module Html2rss
|
|
|
6
6
|
|
|
7
7
|
# Raised when auto fallback exhausts all concrete tiers and extractors find no feed items.
|
|
8
8
|
class NoFeedItemsExtracted < Error
|
|
9
|
+
# Categories that append shared surface guidance to the empty-feed message.
|
|
10
|
+
SURFACE_HINT_CATEGORIES = %i[app_shell blocked_surface].freeze
|
|
11
|
+
|
|
9
12
|
##
|
|
10
13
|
# @param attempts [Array<Hash{Symbol => Object}>] tier attempt diagnostics
|
|
11
|
-
|
|
14
|
+
# @param surface_category [Symbol, nil] optional Scraper surface classification
|
|
15
|
+
def initialize(attempts:, surface_category: nil)
|
|
12
16
|
@attempts = attempts
|
|
17
|
+
@surface_category = surface_category
|
|
13
18
|
super(build_message)
|
|
14
19
|
end
|
|
15
20
|
|
|
16
21
|
# @return [Array<Hash{Symbol => Object}>] tier attempt diagnostics
|
|
17
22
|
attr_reader :attempts
|
|
18
23
|
|
|
24
|
+
# @return [Symbol, nil] surface classification when a response body was available
|
|
25
|
+
attr_reader :surface_category
|
|
26
|
+
|
|
19
27
|
private
|
|
20
28
|
|
|
21
29
|
def build_message
|
|
@@ -24,8 +32,12 @@ module Html2rss
|
|
|
24
32
|
"#{attempt[:strategy]} (#{details})"
|
|
25
33
|
end.join(', ')
|
|
26
34
|
|
|
27
|
-
"No feed items extracted after auto fallback across strategies: #{summaries}. " \
|
|
28
|
-
|
|
35
|
+
message = "No feed items extracted after auto fallback across strategies: #{summaries}. " \
|
|
36
|
+
'Try a more specific listing URL or provide explicit selectors.'
|
|
37
|
+
return message unless SURFACE_HINT_CATEGORIES.include?(surface_category)
|
|
38
|
+
|
|
39
|
+
guidance = AutoSource::Scraper::NoScraperFound::CATEGORY_MESSAGES.fetch(surface_category)
|
|
40
|
+
"#{message} #{guidance}"
|
|
29
41
|
end
|
|
30
42
|
end
|
|
31
43
|
end
|
|
@@ -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,15 +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
|
-
if config.auto_source && articles.empty?
|
|
85
|
-
raise NoFeedItemsExtracted.new(attempts: [{ strategy:, items_count: 0, error_class: nil }])
|
|
86
|
-
end
|
|
84
|
+
raise_empty_auto_source!(strategy:, response:) if config.auto_source && articles.empty?
|
|
87
85
|
|
|
88
86
|
PipelineOutcome.new(
|
|
89
87
|
response:, articles:, dedup_dropped:, selected_strategy: nil, attempt_count: 0, strategy_attempts: []
|
|
90
88
|
)
|
|
91
89
|
end
|
|
92
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
|
+
|
|
93
100
|
def run_auto_pipeline(config, resources:)
|
|
94
101
|
AutoFallback.new(
|
|
95
102
|
strategies: AutoFallback::CHAIN,
|
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
|
|
@@ -80,19 +80,48 @@ module Html2rss
|
|
|
80
80
|
Url.from_relative("##{id}", @base_url) if id
|
|
81
81
|
end
|
|
82
82
|
|
|
83
|
-
def extract_title
|
|
84
|
-
|
|
85
|
-
title_text = if source
|
|
86
|
-
source.visible_text
|
|
87
|
-
elsif @fallback_anchorless && @selected_anchor.nil?
|
|
88
|
-
first_nonempty_own_text
|
|
89
|
-
end
|
|
83
|
+
def extract_title
|
|
84
|
+
title_text = title_from_in_card_sources
|
|
90
85
|
return unless title_text
|
|
91
86
|
|
|
92
87
|
kicker = kicker_node&.visible_text.to_s.strip
|
|
93
88
|
kicker && !kicker.empty? && !title_text.include?(kicker) ? "#{kicker}: #{title_text}" : title_text
|
|
94
89
|
end
|
|
95
90
|
|
|
91
|
+
# Prefer heading over credit-shaped visible text; never emit agency-only titles.
|
|
92
|
+
#
|
|
93
|
+
# @return [String, nil]
|
|
94
|
+
def title_from_in_card_sources
|
|
95
|
+
title_candidates.find { |text| !credit_shaped_title?(text) }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# @return [Array<String>] ordered in-card title candidates (heading → anchor → fallback)
|
|
99
|
+
def title_candidates
|
|
100
|
+
[].tap do |candidates|
|
|
101
|
+
add_title_candidate!(candidates, heading&.visible_text)
|
|
102
|
+
add_title_candidate!(candidates, @selected_anchor&.visible_text)
|
|
103
|
+
next unless candidates.empty? && @fallback_anchorless && @selected_anchor.nil?
|
|
104
|
+
|
|
105
|
+
add_title_candidate!(candidates, first_nonempty_own_text)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# @param candidates [Array<String>]
|
|
110
|
+
# @param text [String, nil]
|
|
111
|
+
# @return [void]
|
|
112
|
+
def add_title_candidate!(candidates, text)
|
|
113
|
+
stripped = text.to_s.strip
|
|
114
|
+
return if stripped.empty? || candidates.include?(stripped)
|
|
115
|
+
|
|
116
|
+
candidates << stripped
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# @param text [String]
|
|
120
|
+
# @return [Boolean]
|
|
121
|
+
def credit_shaped_title?(text)
|
|
122
|
+
AutoSource::Cleanup::CREDIT_TITLE.match?(text)
|
|
123
|
+
end
|
|
124
|
+
|
|
96
125
|
def heading
|
|
97
126
|
@heading ||= begin
|
|
98
127
|
tags = @root.find_all(&:heading?)
|
|
@@ -9,10 +9,8 @@ module Html2rss
|
|
|
9
9
|
ICON_NAMES = %i[img svg].freeze
|
|
10
10
|
|
|
11
11
|
# @param link_resolver [LinkResolver]
|
|
12
|
-
|
|
13
|
-
def initialize(link_resolver:, index:)
|
|
12
|
+
def initialize(link_resolver:)
|
|
14
13
|
@link_resolver = link_resolver
|
|
15
|
-
@index = index
|
|
16
14
|
end
|
|
17
15
|
|
|
18
16
|
##
|
|
@@ -21,8 +19,11 @@ module Html2rss
|
|
|
21
19
|
# @param anchor [SST::Node, nil]
|
|
22
20
|
# @param container [SST::Node, nil]
|
|
23
21
|
# @param heading_anchor [Boolean]
|
|
22
|
+
# @param utility_landmark_ancestor [Boolean] pre-computed by Segmenter tree walk
|
|
24
23
|
# @return [Boolean]
|
|
25
|
-
|
|
24
|
+
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/ParameterLists, Metrics/PerceivedComplexity, Lint/UnusedMethodArgument
|
|
25
|
+
def noise_anchor?(text:, destination_facts:, anchor: nil, container: nil,
|
|
26
|
+
heading_anchor: false, utility_landmark_ancestor: false)
|
|
26
27
|
return true unless destination_facts
|
|
27
28
|
|
|
28
29
|
destination_facts.taxonomy_path ||
|
|
@@ -32,8 +33,9 @@ module Html2rss
|
|
|
32
33
|
(@link_resolver.utility_text?(text) && destination_facts.vanity_path) ||
|
|
33
34
|
utility_text_chrome?(text, destination_facts, heading_anchor:) ||
|
|
34
35
|
icon_only_anchor?(anchor, text) ||
|
|
35
|
-
utility_landmark_ancestor
|
|
36
|
+
utility_landmark_ancestor
|
|
36
37
|
end
|
|
38
|
+
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/ParameterLists, Metrics/PerceivedComplexity, Lint/UnusedMethodArgument
|
|
37
39
|
|
|
38
40
|
private
|
|
39
41
|
|
|
@@ -61,19 +63,6 @@ module Html2rss
|
|
|
61
63
|
|
|
62
64
|
!!anchor.find { |n| ICON_NAMES.include?(n.name) }
|
|
63
65
|
end
|
|
64
|
-
|
|
65
|
-
def utility_landmark_ancestor?(anchor, container) # rubocop:disable Metrics/CyclomaticComplexity
|
|
66
|
-
return false unless anchor && container
|
|
67
|
-
|
|
68
|
-
curr = @index.parent_of(anchor)
|
|
69
|
-
while curr && curr.name != :html
|
|
70
|
-
return true if curr != container && curr.utility_landmark?
|
|
71
|
-
return false if curr.equal?(container)
|
|
72
|
-
|
|
73
|
-
curr = @index.parent_of(curr)
|
|
74
|
-
end
|
|
75
|
-
false
|
|
76
|
-
end
|
|
77
66
|
end
|
|
78
67
|
end
|
|
79
68
|
end
|
|
@@ -48,6 +48,9 @@ module Html2rss
|
|
|
48
48
|
sinscrire inscription compte s-abonner saboner lettre-information confidentialite
|
|
49
49
|
mentions-legales cgu menu sidebar widget social modal popup banner promo ad ads
|
|
50
50
|
related recommendation recommendations pagination pager
|
|
51
|
+
dating jobs job career careers deals deal shopping shop trading broker
|
|
52
|
+
versicherung tierversicherung insurance vergleich comparison
|
|
53
|
+
partnerboerse singleboerse krypto crypto
|
|
51
54
|
] + SOFT_UTILITY.to_a
|
|
52
55
|
).to_set.freeze
|
|
53
56
|
{
|