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
|
@@ -10,7 +10,7 @@ module Html2rss
|
|
|
10
10
|
# Default Botasaurus scrape options when no explicit config is provided.
|
|
11
11
|
DEFAULT_OPTIONS = {
|
|
12
12
|
navigation_mode: 'auto',
|
|
13
|
-
max_retries:
|
|
13
|
+
max_retries: 1,
|
|
14
14
|
headless: false
|
|
15
15
|
}.freeze
|
|
16
16
|
|
|
@@ -30,6 +30,17 @@ module Html2rss
|
|
|
30
30
|
lang
|
|
31
31
|
].freeze
|
|
32
32
|
|
|
33
|
+
# Allowlisted upstream response keys exposed as Response#transport_meta.
|
|
34
|
+
META_KEYS = %w[
|
|
35
|
+
request_id strategy_used render_ms challenge_detected blocked_detected attempts error_category
|
|
36
|
+
].freeze
|
|
37
|
+
|
|
38
|
+
# Remaining seconds at or below which Botasaurus retries are disabled.
|
|
39
|
+
TIGHT_BUDGET_SECONDS = 12
|
|
40
|
+
|
|
41
|
+
# Seconds reserved from remaining budget before setting wait_timeout_seconds.
|
|
42
|
+
BUDGET_WAIT_RESERVE_SECONDS = 2
|
|
43
|
+
|
|
33
44
|
# Parsed Botasaurus response wrapper.
|
|
34
45
|
class ParsedResponse
|
|
35
46
|
# Fallback headers when upstream omits response headers.
|
|
@@ -90,6 +101,9 @@ module Html2rss
|
|
|
90
101
|
# @return [String, nil] final URL reported by upstream
|
|
91
102
|
def final_url = payload['final_url']
|
|
92
103
|
|
|
104
|
+
# @return [Hash{String => Object}] allowlisted upstream telemetry (frozen)
|
|
105
|
+
def transport_meta = payload.slice(*META_KEYS).compact.freeze
|
|
106
|
+
|
|
93
107
|
private
|
|
94
108
|
|
|
95
109
|
attr_reader :payload, :transport_status
|
|
@@ -109,6 +123,7 @@ module Html2rss
|
|
|
109
123
|
##
|
|
110
124
|
# @param url [Html2rss::Url] canonical URL to scrape
|
|
111
125
|
# @param options [Hash] validated request.botasaurus options
|
|
126
|
+
# @param remaining_timeout_seconds [Numeric, nil] shared request budget remainder for clamps
|
|
112
127
|
# @option options [String] :navigation_mode
|
|
113
128
|
# @option options [Integer] :max_retries
|
|
114
129
|
# @option options [String] :wait_for_selector
|
|
@@ -121,14 +136,15 @@ module Html2rss
|
|
|
121
136
|
# @option options [String] :user_agent
|
|
122
137
|
# @option options [Array<Integer>] :window_size
|
|
123
138
|
# @option options [String] :lang
|
|
124
|
-
def initialize(url:, options: {})
|
|
139
|
+
def initialize(url:, options: {}, remaining_timeout_seconds: nil)
|
|
125
140
|
@url = url
|
|
126
141
|
@options = options
|
|
142
|
+
@remaining_timeout_seconds = remaining_timeout_seconds
|
|
127
143
|
end
|
|
128
144
|
|
|
129
|
-
# @return [Hash] payload for POST /scrape
|
|
145
|
+
# @return [Hash] payload for POST /scrape (budget-clamped when remaining is known)
|
|
130
146
|
def request_payload
|
|
131
|
-
DEFAULT_OPTIONS.merge(filtered_options).merge(url: url.to_s)
|
|
147
|
+
DEFAULT_OPTIONS.merge(filtered_options).merge(url: url.to_s).then { clamp_for_budget(_1) }
|
|
132
148
|
end
|
|
133
149
|
|
|
134
150
|
# @param transport_response [Faraday::Response] upstream HTTP response
|
|
@@ -145,13 +161,25 @@ module Html2rss
|
|
|
145
161
|
|
|
146
162
|
private
|
|
147
163
|
|
|
148
|
-
attr_reader :url, :options
|
|
164
|
+
attr_reader :url, :options, :remaining_timeout_seconds
|
|
149
165
|
|
|
150
166
|
def filtered_options
|
|
151
167
|
OPTION_KEYS.each_with_object({}) do |key, normalized|
|
|
152
168
|
normalized[key] = options[key] if options.key?(key)
|
|
153
169
|
end
|
|
154
170
|
end
|
|
171
|
+
|
|
172
|
+
def clamp_for_budget(payload)
|
|
173
|
+
remaining = remaining_timeout_seconds
|
|
174
|
+
return payload if remaining.nil?
|
|
175
|
+
|
|
176
|
+
clamped = payload.dup
|
|
177
|
+
clamped[:max_retries] = 0 if remaining <= TIGHT_BUDGET_SECONDS
|
|
178
|
+
budget_wait = [1, (remaining - BUDGET_WAIT_RESERVE_SECONDS).floor].max
|
|
179
|
+
configured = clamped[:wait_timeout_seconds]
|
|
180
|
+
clamped[:wait_timeout_seconds] = configured ? [configured, budget_wait].min : budget_wait
|
|
181
|
+
clamped
|
|
182
|
+
end
|
|
155
183
|
end
|
|
156
184
|
end
|
|
157
185
|
end
|
|
@@ -27,7 +27,8 @@ module Html2rss
|
|
|
27
27
|
body: parsed_response.html,
|
|
28
28
|
headers: parsed_response.headers,
|
|
29
29
|
url: response_url(parsed_response.final_url),
|
|
30
|
-
status: parsed_response.status
|
|
30
|
+
status: parsed_response.status,
|
|
31
|
+
transport_meta: parsed_response.transport_meta
|
|
31
32
|
)
|
|
32
33
|
end
|
|
33
34
|
|
|
@@ -52,7 +53,11 @@ module Html2rss
|
|
|
52
53
|
end
|
|
53
54
|
|
|
54
55
|
def contract
|
|
55
|
-
@contract ||= BotasaurusContract.new(
|
|
56
|
+
@contract ||= BotasaurusContract.new(
|
|
57
|
+
url: ctx.url,
|
|
58
|
+
options: ctx.request.fetch(:botasaurus, {}),
|
|
59
|
+
remaining_timeout_seconds: attempt_timeout_seconds
|
|
60
|
+
)
|
|
56
61
|
end
|
|
57
62
|
|
|
58
63
|
def client
|
|
@@ -60,9 +65,13 @@ module Html2rss
|
|
|
60
65
|
end
|
|
61
66
|
|
|
62
67
|
def request_options
|
|
63
|
-
timeout
|
|
68
|
+
{ timeout: attempt_timeout_seconds.to_i }
|
|
69
|
+
end
|
|
64
70
|
|
|
65
|
-
|
|
71
|
+
def attempt_timeout_seconds
|
|
72
|
+
@attempt_timeout_seconds ||= ctx.budget.effective_timeout_seconds(
|
|
73
|
+
fallback: ctx.policy.total_timeout_seconds
|
|
74
|
+
)
|
|
66
75
|
end
|
|
67
76
|
|
|
68
77
|
def content_type_header
|
|
@@ -82,11 +82,16 @@ module Html2rss
|
|
|
82
82
|
def remaining_timeout_seconds
|
|
83
83
|
return unless @total_timeout_seconds
|
|
84
84
|
|
|
85
|
-
|
|
86
|
-
remaining = @total_timeout_seconds - elapsed
|
|
85
|
+
remaining = @total_timeout_seconds - elapsed_seconds
|
|
87
86
|
[remaining, 0.0].max
|
|
88
87
|
end
|
|
89
88
|
|
|
89
|
+
##
|
|
90
|
+
# @return [Float] seconds since this budget was created
|
|
91
|
+
def elapsed_seconds
|
|
92
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC) - @start_time
|
|
93
|
+
end
|
|
94
|
+
|
|
90
95
|
##
|
|
91
96
|
# Resolves wall-clock seconds for the next adapter attempt.
|
|
92
97
|
#
|
|
@@ -66,14 +66,55 @@ module Html2rss
|
|
|
66
66
|
faraday_request(response_guard, deadline:, streaming_buffer: false, consume_budget: false)
|
|
67
67
|
end
|
|
68
68
|
|
|
69
|
+
##
|
|
70
|
+
# Validates the remote socket peer IP during Net::HTTP start.
|
|
71
|
+
module PeerIpValidator
|
|
72
|
+
module_function
|
|
73
|
+
|
|
74
|
+
# @param http [Net::HTTP] connection to configure
|
|
75
|
+
# @param policy [Policy] request policy
|
|
76
|
+
# @return [void]
|
|
77
|
+
def install!(http, policy:)
|
|
78
|
+
orig_start = http.method(:start)
|
|
79
|
+
http.define_singleton_method(:start) do |&block|
|
|
80
|
+
orig_start.call do |opened_http|
|
|
81
|
+
PeerIpValidator.validate!(opened_http, policy:)
|
|
82
|
+
block.call(opened_http)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# @param opened_http [Net::HTTP] active connection
|
|
88
|
+
# @param policy [Policy] request policy
|
|
89
|
+
# @return [void]
|
|
90
|
+
def validate!(opened_http, policy:)
|
|
91
|
+
scheme = opened_http.use_ssl? ? 'https' : 'http'
|
|
92
|
+
url = Html2rss::Url.from_absolute("#{scheme}://#{opened_http.address}:#{opened_http.port}")
|
|
93
|
+
policy.validate_remote_ip!(ip: peer_ip_for(opened_http), url:)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# @param opened_http [Net::HTTP]
|
|
97
|
+
# @return [String, nil]
|
|
98
|
+
def peer_ip_for(opened_http)
|
|
99
|
+
sock = opened_http.instance_variable_get(:@socket)
|
|
100
|
+
io = sock.respond_to?(:io) ? sock.io : sock
|
|
101
|
+
peeraddr = io.respond_to?(:peeraddr) ? io.peeraddr : nil
|
|
102
|
+
peeraddr&.[](3) || peeraddr&.[](2)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# rubocop:disable Metrics/AbcSize
|
|
69
107
|
def client
|
|
70
108
|
@client ||= Faraday.new(url: ctx.url.to_s, headers: ctx.headers) do |faraday|
|
|
71
109
|
faraday.use Faraday::FollowRedirects::Middleware, limit: ctx.policy.max_redirects, callback: redirect_callback
|
|
72
110
|
faraday.request :gzip
|
|
73
111
|
faraday.use StreamingBodyMiddleware
|
|
74
|
-
faraday.adapter Faraday.default_adapter
|
|
112
|
+
faraday.adapter Faraday.default_adapter do |http|
|
|
113
|
+
PeerIpValidator.install!(http, policy: ctx.policy)
|
|
114
|
+
end
|
|
75
115
|
end
|
|
76
116
|
end
|
|
117
|
+
# rubocop:enable Metrics/AbcSize
|
|
77
118
|
|
|
78
119
|
def apply_timeouts(request, deadline:)
|
|
79
120
|
remaining_timeout = remaining_timeout_seconds(deadline)
|
|
@@ -48,7 +48,10 @@ module Html2rss
|
|
|
48
48
|
def enforce_public_network!(url)
|
|
49
49
|
host = url.host
|
|
50
50
|
return if allow_private_networks?
|
|
51
|
-
|
|
51
|
+
raise PrivateNetworkDenied, "Private network target denied for #{url}" if blocked_host?(host)
|
|
52
|
+
|
|
53
|
+
addresses = resolved_ip_addresses(host)
|
|
54
|
+
return unless addresses.empty? || addresses.any? { |address| blocked_ip?(address) }
|
|
52
55
|
|
|
53
56
|
raise PrivateNetworkDenied, "Private network target denied for #{url}"
|
|
54
57
|
end
|
|
@@ -62,9 +65,8 @@ module Html2rss
|
|
|
62
65
|
# @raise [PrivateNetworkDenied] if the response came from a blocked address
|
|
63
66
|
def validate_remote_ip!(ip:, url:)
|
|
64
67
|
return if allow_private_networks?
|
|
65
|
-
return if ip.nil? || ip.empty?
|
|
66
68
|
|
|
67
|
-
parsed_ip = parse_ip(ip)
|
|
69
|
+
parsed_ip = ip && !ip.to_s.empty? ? parse_ip(ip) : nil
|
|
68
70
|
raise PrivateNetworkDenied, "Remote IP could not be validated for #{url}" unless parsed_ip
|
|
69
71
|
return unless blocked_ip?(parsed_ip)
|
|
70
72
|
|
|
@@ -7,12 +7,16 @@ module Html2rss
|
|
|
7
7
|
##
|
|
8
8
|
# To be used by strategies to provide their response.
|
|
9
9
|
class Response
|
|
10
|
+
# Default when a strategy does not attach transport telemetry.
|
|
11
|
+
EMPTY_TRANSPORT_META = {}.freeze
|
|
12
|
+
|
|
10
13
|
##
|
|
11
14
|
# @param body [String] the body of the response
|
|
12
15
|
# @param url [Html2rss::Url] the final request URL
|
|
13
16
|
# @param headers [Hash] the headers of the response
|
|
14
17
|
# @param status [Integer, nil] the HTTP status code when available
|
|
15
|
-
|
|
18
|
+
# @param transport_meta [Hash] allowlisted upstream telemetry (frozen when present)
|
|
19
|
+
def initialize(body:, url:, headers: {}, status: nil, transport_meta: EMPTY_TRANSPORT_META)
|
|
16
20
|
@body = body
|
|
17
21
|
|
|
18
22
|
headers = headers.dup
|
|
@@ -22,6 +26,7 @@ module Html2rss
|
|
|
22
26
|
@headers = headers
|
|
23
27
|
@status = status
|
|
24
28
|
@url = url
|
|
29
|
+
@transport_meta = transport_meta.nil? || transport_meta.empty? ? EMPTY_TRANSPORT_META : transport_meta.freeze
|
|
25
30
|
end
|
|
26
31
|
|
|
27
32
|
# @return [String] the raw body of the response
|
|
@@ -36,6 +41,9 @@ module Html2rss
|
|
|
36
41
|
# @return [Html2rss::Url] the URL of the response
|
|
37
42
|
attr_reader :url
|
|
38
43
|
|
|
44
|
+
# @return [Hash] allowlisted upstream transport telemetry
|
|
45
|
+
attr_reader :transport_meta
|
|
46
|
+
|
|
39
47
|
# @return [String] normalized content type header value
|
|
40
48
|
def content_type = header('content-type').to_s
|
|
41
49
|
|
|
@@ -86,6 +86,9 @@ module Html2rss
|
|
|
86
86
|
|
|
87
87
|
def check_timeout!
|
|
88
88
|
ctx.budget.effective_timeout_seconds(fallback: ctx.policy.total_timeout_seconds)
|
|
89
|
+
rescue RequestTimedOut
|
|
90
|
+
log_timeout!(reason: 'budget_exhausted')
|
|
91
|
+
raise
|
|
89
92
|
end
|
|
90
93
|
|
|
91
94
|
# @return [ResponseGuard, nil]
|
|
@@ -96,6 +99,8 @@ module Html2rss
|
|
|
96
99
|
# @raise [StandardError]
|
|
97
100
|
def handle_error(error)
|
|
98
101
|
if timeout_error?(error)
|
|
102
|
+
log_timeout!(reason: 'transport')
|
|
103
|
+
Log.debug("#{self.class}: transport timeout message=#{error.message}")
|
|
99
104
|
raise RequestTimedOut, error.message
|
|
100
105
|
elsif connection_error?(error)
|
|
101
106
|
translate_connection_error(error)
|
|
@@ -104,6 +109,23 @@ module Html2rss
|
|
|
104
109
|
end
|
|
105
110
|
end
|
|
106
111
|
|
|
112
|
+
# @param reason [String] timeout classification (budget_exhausted / transport)
|
|
113
|
+
# @return [void]
|
|
114
|
+
# rubocop:disable Metrics/AbcSize -- structured timeout fields stay in one log line
|
|
115
|
+
def log_timeout!(reason:)
|
|
116
|
+
remaining = ctx.budget.remaining_timeout_seconds
|
|
117
|
+
remaining_label = remaining.nil? ? 'untracked' : format('%.3f', remaining)
|
|
118
|
+
detail = [
|
|
119
|
+
"strategy=#{self.class.name}",
|
|
120
|
+
"host=#{ctx.url.host}",
|
|
121
|
+
"elapsed=#{format('%.3f', ctx.budget.elapsed_seconds)}s",
|
|
122
|
+
"budget_remaining=#{remaining_label}",
|
|
123
|
+
"reason=#{reason}"
|
|
124
|
+
]
|
|
125
|
+
Log.info("#{self.class}: request timeout #{detail.join(' ')}")
|
|
126
|
+
end
|
|
127
|
+
# rubocop:enable Metrics/AbcSize
|
|
128
|
+
|
|
107
129
|
# @param error [StandardError]
|
|
108
130
|
# @return [void]
|
|
109
131
|
# @raise [StandardError]
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
module Scoring
|
|
5
|
+
##
|
|
6
|
+
# Primary-link ranking weights (sole home; FeatureId-aligned).
|
|
7
|
+
module AnchorScore
|
|
8
|
+
# Signal → integer weight map for primary-link ranking.
|
|
9
|
+
WEIGHTS = {
|
|
10
|
+
heading_anchor: 100,
|
|
11
|
+
heading_text_match: 20,
|
|
12
|
+
meaningful_text: 10,
|
|
13
|
+
content_like_destination: 10
|
|
14
|
+
}.freeze
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
##
|
|
19
|
+
# @param heading_anchor [Boolean]
|
|
20
|
+
# @param heading_text_match [Boolean]
|
|
21
|
+
# @param meaningful_text [Boolean]
|
|
22
|
+
# @param content_like_destination [Boolean]
|
|
23
|
+
# @return [Integer]
|
|
24
|
+
def score(heading_anchor:, heading_text_match:, meaningful_text:, content_like_destination:)
|
|
25
|
+
total = 0
|
|
26
|
+
total += WEIGHTS[:heading_anchor] if heading_anchor
|
|
27
|
+
total += WEIGHTS[:heading_text_match] if heading_text_match
|
|
28
|
+
total += WEIGHTS[:meaningful_text] if meaningful_text
|
|
29
|
+
total += WEIGHTS[:content_like_destination] if content_like_destination
|
|
30
|
+
total
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
module Scoring
|
|
5
|
+
##
|
|
6
|
+
# Scores DomClustering-style node groups (heading/time/date/word density).
|
|
7
|
+
# Sole home for cluster-group ranking weights.
|
|
8
|
+
class ClusterScorer
|
|
9
|
+
# Minimum average visible words before a group is eligible.
|
|
10
|
+
MIN_AVG_WORDS = 5
|
|
11
|
+
# Bonus when any node has a heading or bold title class.
|
|
12
|
+
HEADING_BONUS = 20
|
|
13
|
+
# Bonus when any node has a time/datetime marker.
|
|
14
|
+
TIME_BONUS = 20
|
|
15
|
+
# Bonus when any node has date-like visible text.
|
|
16
|
+
DATE_BONUS = 40
|
|
17
|
+
|
|
18
|
+
def initialize
|
|
19
|
+
@text_words = {}.compare_by_identity
|
|
20
|
+
@has_date = {}.compare_by_identity
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
##
|
|
24
|
+
# @param groups [Hash{String => Array<SST::Node>}]
|
|
25
|
+
# @return [Array<SST::Node>]
|
|
26
|
+
def select_best_group(groups)
|
|
27
|
+
best_nodes = []
|
|
28
|
+
best_score = -1
|
|
29
|
+
|
|
30
|
+
groups.each_value do |nodes|
|
|
31
|
+
score = score_group(nodes)
|
|
32
|
+
next if score.negative?
|
|
33
|
+
|
|
34
|
+
(best_nodes = nodes) && (best_score = score) if score > best_score
|
|
35
|
+
end
|
|
36
|
+
best_nodes
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
##
|
|
40
|
+
# @param nodes [Array<SST::Node>]
|
|
41
|
+
# @return [Float]
|
|
42
|
+
def avg_words(nodes)
|
|
43
|
+
nodes.sum { |n| text_words(n) } / nodes.size.to_f
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def score_group(nodes)
|
|
49
|
+
avg_w = avg_words(nodes)
|
|
50
|
+
return -1 if avg_w < MIN_AVG_WORDS
|
|
51
|
+
|
|
52
|
+
score = nodes.size + (avg_w / 5.0)
|
|
53
|
+
score += HEADING_BONUS if nodes_heading?(nodes)
|
|
54
|
+
score += TIME_BONUS if nodes_time?(nodes)
|
|
55
|
+
score += DATE_BONUS if nodes_date?(nodes)
|
|
56
|
+
score
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def nodes_heading?(nodes)
|
|
60
|
+
nodes.any? do |n|
|
|
61
|
+
n.find(&:heading?) ||
|
|
62
|
+
n.attrs.class_names.any? { |c| c.match?(/font-bold|font-semibold/) }
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def nodes_time?(nodes)
|
|
67
|
+
nodes.any? { |n| n.find { |d| d.name == :time || d.attrs.datetime } }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def nodes_date?(nodes)
|
|
71
|
+
nodes.any? { |n| date?(n) }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def text_words(node)
|
|
75
|
+
@text_words[node] ||= node.word_count
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def date?(node)
|
|
79
|
+
@has_date[node] ||= begin
|
|
80
|
+
text = node.visible_text.to_s
|
|
81
|
+
text.match?(%r{\b\d{4}[-/]\d{2}[-/]\d{2}\b}) ||
|
|
82
|
+
text.match?(/\b(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\b/i)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
module Scoring
|
|
5
|
+
##
|
|
6
|
+
# Observes an SST container and builds typed {Observation} score inputs.
|
|
7
|
+
class ContainerAssessor
|
|
8
|
+
# Microdata itemprop values treated as publish/update markers.
|
|
9
|
+
PUBLISH_ITEMPROPS = %w[datePublished dateModified].freeze
|
|
10
|
+
|
|
11
|
+
# Matches content-like tokens in class/id strings.
|
|
12
|
+
CONTENT_TOKEN_REGEXP = begin
|
|
13
|
+
words = LinkDestination::PathClassifier::SEGMENT_SETS.fetch(:content)
|
|
14
|
+
/(?:^|\s|[-_])(#{Regexp.union(words.to_a).source})(?:\s|[-_]|$)/i
|
|
15
|
+
end.freeze
|
|
16
|
+
|
|
17
|
+
# Matches utility/junk tokens in class/id strings.
|
|
18
|
+
JUNK_TOKEN_REGEXP = begin
|
|
19
|
+
words = LinkDestination::PathClassifier::SEGMENT_SETS.fetch(:utility)
|
|
20
|
+
/(?:^|\s|[-_])(#{Regexp.union(words.to_a).source})(?:\s|[-_]|$)/i
|
|
21
|
+
end.freeze
|
|
22
|
+
|
|
23
|
+
# @param text_classifier [LinkDestination::TextClassifier]
|
|
24
|
+
def initialize(text_classifier: LinkDestination::TextClassifier.new)
|
|
25
|
+
@text_classifier = text_classifier
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
##
|
|
29
|
+
# @param container [SST::Node]
|
|
30
|
+
# @param selected_anchor [SST::Node, nil]
|
|
31
|
+
# @param destination_facts [LinkDestination::DestinationFacts, nil]
|
|
32
|
+
# @return [Observation]
|
|
33
|
+
def call(container, selected_anchor, destination_facts:) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
34
|
+
title = entry_title(container, selected_anchor)
|
|
35
|
+
tokens = "#{container.attrs.class_attr} #{container.attrs.id}"
|
|
36
|
+
|
|
37
|
+
Observation.new(
|
|
38
|
+
title_word_count: word_count(title),
|
|
39
|
+
path_length: destination_facts&.url&.path.to_s.length,
|
|
40
|
+
content_path: destination_facts&.content_path,
|
|
41
|
+
publish_marker: publish_marker?(container),
|
|
42
|
+
descriptive_context: descriptive_context?(container.visible_text, title),
|
|
43
|
+
article_container: container.name == :article,
|
|
44
|
+
content_tokens: tokens.match?(CONTENT_TOKEN_REGEXP),
|
|
45
|
+
junk_tokens: tokens.match?(JUNK_TOKEN_REGEXP),
|
|
46
|
+
utility_prefix_title: @text_classifier.utility_prefix?(title),
|
|
47
|
+
recommended_title: @text_classifier.recommended?(title),
|
|
48
|
+
utility_path: destination_facts&.utility_path,
|
|
49
|
+
strong_post_suffix: destination_facts&.strong_post_suffix,
|
|
50
|
+
shallow: destination_facts&.shallow,
|
|
51
|
+
high_confidence_junk_path: destination_facts&.high_confidence_junk_path,
|
|
52
|
+
high_confidence_utility_destination: destination_facts&.high_confidence_utility_destination,
|
|
53
|
+
selected_anchor_present: !selected_anchor.nil?
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def publish_marker?(container)
|
|
60
|
+
!!container.find do |n|
|
|
61
|
+
n.name == :time ||
|
|
62
|
+
n.attrs.datetime ||
|
|
63
|
+
PUBLISH_ITEMPROPS.include?(n.attrs.itemprop.to_s)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def descriptive_context?(container_text, title)
|
|
68
|
+
snippet = container_text.to_s.sub(/\A#{Regexp.escape(title.to_s)}/i, '')
|
|
69
|
+
snippet.length > 30 && word_count(snippet) >= 8
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def entry_title(container, selected_anchor)
|
|
73
|
+
heading = container.find(&:heading?)
|
|
74
|
+
source = heading || selected_anchor
|
|
75
|
+
source ? source.visible_text.to_s.strip : ''
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def word_count(text)
|
|
79
|
+
text.to_s.scan(/\p{Alnum}+/).size
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
module Scoring
|
|
5
|
+
##
|
|
6
|
+
# Ranks AutoSource::Segment instances with an explicit feature registry (no send).
|
|
7
|
+
class Engine
|
|
8
|
+
# Soft cap after score-floor filtering (lazy extraction guard; breadth > blind top-50).
|
|
9
|
+
TOP_K = 99
|
|
10
|
+
# Minimum composite score retained by {#rank_top} (precision floor).
|
|
11
|
+
SCORE_FLOOR = 0.0
|
|
12
|
+
|
|
13
|
+
# Quality feature triples: [FeatureId, predicate, weight].
|
|
14
|
+
QUALITY_RULES = [
|
|
15
|
+
[:title_word_count_ge3, ->(o) { o.title_word_count >= 3 }, 40],
|
|
16
|
+
[:title_word_count_ge7, ->(o) { o.title_word_count >= 7 }, 15],
|
|
17
|
+
[:path_length_gt6, ->(o) { o.path_length > 6 }, 20],
|
|
18
|
+
[:content_path, lambda(&:content_path), 15],
|
|
19
|
+
[:publish_marker, lambda(&:publish_marker), 15],
|
|
20
|
+
[:descriptive_context, lambda(&:descriptive_context), 10],
|
|
21
|
+
[:article_container, lambda(&:article_container), 10],
|
|
22
|
+
[:content_tokens, lambda(&:content_tokens), 10]
|
|
23
|
+
].freeze
|
|
24
|
+
|
|
25
|
+
# Junk feature triples: [FeatureId, predicate, weight].
|
|
26
|
+
JUNK_RULES = [
|
|
27
|
+
[:non_content_utility_path, ->(o) { o.utility_path && !o.content_path && !o.strong_post_suffix }, 25],
|
|
28
|
+
[:utility_prefix_title_short, ->(o) { o.utility_prefix_title && o.title_word_count <= 6 }, 15],
|
|
29
|
+
[:shallow, lambda(&:shallow), 10],
|
|
30
|
+
[:weak_container, ->(o) { !o.publish_marker && !o.descriptive_context }, 10],
|
|
31
|
+
[:recommended_title_non_content, ->(o) { o.recommended_title && !o.content_path }, 10],
|
|
32
|
+
[:high_confidence_junk_path, lambda(&:high_confidence_junk_path), 5],
|
|
33
|
+
[:junk_tokens, lambda(&:junk_tokens), 15]
|
|
34
|
+
].freeze
|
|
35
|
+
|
|
36
|
+
(QUALITY_RULES + JUNK_RULES).each { |feature_id, _, _| FeatureId.assert!(feature_id) }
|
|
37
|
+
|
|
38
|
+
# @param link_resolver [LinkResolver]
|
|
39
|
+
def initialize(link_resolver:)
|
|
40
|
+
@link_resolver = link_resolver
|
|
41
|
+
@assessor = ContainerAssessor.new(text_classifier: link_resolver.text_classifier)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
##
|
|
45
|
+
# @param segments [Array<Html2rss::AutoSource::Segment>]
|
|
46
|
+
# @return [Array<RankedSegment>] sorted by composite desc, position asc; hard-junk dropped
|
|
47
|
+
def rank(segments)
|
|
48
|
+
segments.filter_map { |segment| rank_one(segment) }
|
|
49
|
+
.sort_by { |ranked| [-ranked.final_score, ranked.position] }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
##
|
|
53
|
+
# Filters hard-junk but preserves discovery order (list/cluster scrapers).
|
|
54
|
+
#
|
|
55
|
+
# @param segments [Array<Html2rss::AutoSource::Segment>]
|
|
56
|
+
# @param limit [Integer]
|
|
57
|
+
# @return [Array<RankedSegment>]
|
|
58
|
+
def select_eligible(segments, limit: TOP_K)
|
|
59
|
+
segments.filter_map { |segment| rank_one(segment, hard_junk_mode: :lenient) }
|
|
60
|
+
.sort_by(&:position)
|
|
61
|
+
.first(limit)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
##
|
|
65
|
+
# @param segments [Array<Html2rss::AutoSource::Segment>]
|
|
66
|
+
# @param limit [Integer]
|
|
67
|
+
# @return [Array<RankedSegment>] score-floor filtered, then capped
|
|
68
|
+
def rank_top(segments, limit: TOP_K)
|
|
69
|
+
rank(segments).select { |ranked| ranked.final_score >= SCORE_FLOOR }.first(limit)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def rank_one(segment, hard_junk_mode: :strict) # rubocop:disable Metrics/MethodLength
|
|
75
|
+
facts = segment.primary_link ? @link_resolver.destination_facts(segment.primary_link) : nil
|
|
76
|
+
obs = @assessor.call(segment.root_node, segment.primary_link, destination_facts: facts)
|
|
77
|
+
return if obs.hard_junk?(mode: hard_junk_mode)
|
|
78
|
+
|
|
79
|
+
quality, quality_parts = apply_rules(QUALITY_RULES, obs)
|
|
80
|
+
junk, junk_parts = apply_rules(JUNK_RULES, obs)
|
|
81
|
+
RankedSegment.build(
|
|
82
|
+
segment:,
|
|
83
|
+
score: Score.build(
|
|
84
|
+
composite: quality - junk,
|
|
85
|
+
quality:,
|
|
86
|
+
junk:,
|
|
87
|
+
breakdown: quality_parts.merge(junk_parts)
|
|
88
|
+
)
|
|
89
|
+
)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def apply_rules(rules, obs)
|
|
93
|
+
parts = {}
|
|
94
|
+
rules.each do |feature_id, predicate, weight|
|
|
95
|
+
parts[feature_id] = weight if predicate.call(obs)
|
|
96
|
+
end
|
|
97
|
+
[parts.values.sum, parts]
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|