fetch_util 0.3.2 → 0.5.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.
@@ -5,77 +5,66 @@ require "uri"
5
5
 
6
6
  module FetchUtil
7
7
  class Searcher
8
- MAX_SNIPPET_LENGTH = 180
9
-
10
- SOURCES = {
11
- "duckduckgo" => "https://duckduckgo.com/?q=%<query>s&ia=web&kl=us-en",
12
- "google" => "https://www.google.com/search?hl=en&q=%<query>s",
13
- "bing" => "https://www.bing.com/search?setlang=en-US&q=%<query>s",
14
- "ecosia" => "https://www.ecosia.org/search?q=%<query>s",
15
- "brave" => "https://search.brave.com/search?q=%<query>s"
16
- }.freeze
17
-
18
- DEFAULT_SOURCES = %w[duckduckgo google].freeze
8
+ DEFAULT_SOURCES = %w[brave bing].freeze
19
9
 
20
10
  autoload :ResultFiltering, "fetch_util/searcher/result_filtering"
21
11
  include ResultFiltering
22
12
  private_constant :ResultFiltering
23
13
 
24
- def initialize(fetcher: nil, request_log: RequestLog.new, sources: nil, limit: 10, concurrency: 2, verbose: false, **fetch_options)
14
+ def initialize(transport: nil, request_log: RequestLog.new, sources: nil, limit: nil, verbose: false,
15
+ timeout: SearchTransport::DEFAULT_TIMEOUT)
25
16
  @request_log = request_log
26
- @sources = Array(sources || DEFAULT_SOURCES).map(&:to_s)
27
- @limit = limit.to_i
17
+ @sources = Array(sources || DEFAULT_SOURCES).map(&:to_s).uniq
18
+ unknown = @sources - SearchTransport::SOURCES.keys
19
+ raise ArgumentError, "unsupported search source: #{unknown.first}" if unknown.any?
20
+
21
+ validate_limit!(limit)
22
+ @limit = limit
28
23
  @verbose = verbose
29
- @fetcher = fetcher || ParallelFetcher.new(concurrency: concurrency, request_log: request_log, **fetch_options)
24
+ @transport = transport || SearchTransport.new(sources: @sources, timeout: timeout)
30
25
  end
31
26
 
32
27
  def search(query)
33
28
  encoded_query = query.to_s.strip
34
29
  raise ArgumentError, "query must not be empty" if encoded_query.empty?
35
30
 
36
- urls = search_urls(encoded_query)
37
31
  @request_log.append(search_request_uri(encoded_query))
38
- fetched = begin
39
- @fetcher.fetch(urls.values)
40
- rescue ParallelFetcher::ParallelFetchError => e
41
- raise unless e.results&.compact&.any?
42
-
43
- e.results
44
- end
32
+ responses = @transport.search(encoded_query)
45
33
 
46
- {
34
+ payload = {
47
35
  query: encoded_query,
48
- results: formatted_results(aggregate(urls.keys, fetched).first(limit))
36
+ results: formatted_results(apply_limit(aggregate(responses)))
49
37
  }
38
+ payload[:diagnostics] = diagnostics(responses) if @verbose
39
+ payload
50
40
  end
51
41
 
52
42
  private
53
43
 
54
44
  attr_reader :limit
55
45
 
56
- def search_urls(query)
57
- urls = {}
46
+ def apply_limit(results)
47
+ limit.nil? ? results : results.first(limit)
48
+ end
58
49
 
59
- @sources.each do |source|
60
- template = SOURCES.fetch(source) do
61
- raise ArgumentError, "unsupported search source: #{source}"
62
- end
63
- urls[source] = format(template, query: CGI.escape(query))
64
- end
50
+ def validate_limit!(value)
51
+ return if value.nil?
52
+ return if value.is_a?(Integer) && value >= 0
65
53
 
66
- urls
54
+ raise ArgumentError, "limit must be a nonnegative integer"
67
55
  end
68
56
 
69
57
  def search_request_uri(query)
70
58
  "search://#{@sources.join(",")}?q=#{CGI.escape(query)}"
71
59
  end
72
60
 
73
- def aggregate(sources, fetched)
61
+ def aggregate(responses)
74
62
  parsed = {}
75
63
  max_size = 0
76
64
 
77
- sources.zip(fetched).each do |source, result|
78
- items = parse_markdown(result.markdown)
65
+ @sources.each do |source|
66
+ response = responses.find { |item| item.source == source }
67
+ items = response ? response.candidates.filter_map { |candidate| normalized_candidate(candidate) } : []
79
68
  parsed[source] = items
80
69
  max_size = [max_size, items.length].max
81
70
  end
@@ -84,12 +73,10 @@ module FetchUtil
84
73
  seen = {}
85
74
 
86
75
  max_size.times do |index|
87
- sources.each do |source|
76
+ @sources.each do |source|
88
77
  item = parsed.fetch(source)[index]
89
78
  next unless item
90
79
 
91
- item = item.merge(rank: index + 1)
92
-
93
80
  existing = seen[item[:url]]
94
81
  if existing
95
82
  merge_result!(existing, source, item)
@@ -119,7 +106,7 @@ module FetchUtil
119
106
  def merge_result!(result, source, item)
120
107
  result[:sources] << source unless result[:sources].include?(source)
121
108
  result[:ranks][source] ||= item[:rank]
122
- return if !item[:snippet] || (result[:snippet] && result[:snippet].length >= item[:snippet].length)
109
+ return if result[:snippet] || !item[:snippet]
123
110
 
124
111
  result[:snippet] = item[:snippet]
125
112
  end
@@ -131,7 +118,7 @@ module FetchUtil
131
118
  url: result[:url]
132
119
  }
133
120
  item[:snippet] = result[:snippet] if result[:snippet]
134
- if verbose?
121
+ if @verbose
135
122
  item[:sources] = result[:sources]
136
123
  item[:ranks] = result[:ranks]
137
124
  end
@@ -139,75 +126,45 @@ module FetchUtil
139
126
  end
140
127
  end
141
128
 
142
- def parse_markdown(markdown)
143
- markdown.to_s.lines.filter_map do |line|
144
- parsed = parse_markdown_line(line)
145
- next unless parsed
146
-
147
- normalized_item(parsed[:title], parsed[:url], parsed[:snippet])
148
- end
149
- end
150
-
151
- def parse_markdown_line(line)
152
- stripped = line.to_s.strip
153
- return nil unless stripped.start_with?("- [")
154
-
155
- title_end = stripped.index("](")
156
- return nil unless title_end
157
-
158
- url_start = title_end + 2
159
- url_end = markdown_url_end_index(stripped, url_start)
160
- return nil unless url_end
161
-
162
- title = stripped[3...title_end]
163
- url = stripped[url_start...url_end]
164
- remainder = stripped[(url_end + 1)..].to_s
165
- snippet = remainder.start_with?(" - ") ? remainder[3..] : nil
166
-
167
- { title: title, url: url, snippet: snippet }
168
- end
169
-
170
- def markdown_url_end_index(line, url_start)
171
- depth = 0
172
-
173
- url_start.upto(line.length - 1) do |index|
174
- char = line[index]
175
- if char == "("
176
- depth += 1
177
- elsif char == ")"
178
- return index if depth.zero?
179
-
180
- depth -= 1
181
- end
129
+ def diagnostics(responses)
130
+ @sources.filter_map do |source|
131
+ response = responses.find { |item| item.source == source }
132
+ next unless response
133
+
134
+ diagnostic = {
135
+ source: response.source,
136
+ transport: response.transport,
137
+ status: response.status,
138
+ result_count: response.candidates.length,
139
+ elapsed_ms: response.elapsed_ms
140
+ }
141
+ diagnostic[:final_url] = response.final_url if response.final_url
142
+ diagnostic[:reason] = response.reason if response.reason
143
+ diagnostic
182
144
  end
183
-
184
- nil
185
145
  end
186
146
 
187
- def normalized_item(title, url, snippet)
188
- normalized_url = normalize_url(url)
147
+ def normalized_candidate(candidate)
148
+ normalized_url = normalize_url(candidate.url)
189
149
  return nil unless normalized_url
190
150
 
191
- normalized_title = normalize_title(title, normalized_url)
151
+ normalized_title = normalize_title(candidate.title, normalized_url)
192
152
  return nil if normalized_title.empty? || generic_title?(normalized_title, normalized_url)
193
153
 
194
- normalized_snippet = normalize_snippet(snippet, normalized_title, normalized_url)
154
+ normalized_snippet = normalize_snippet(candidate.snippet, normalized_title, normalized_url)
195
155
  return nil if search_engine_self_link?(normalized_title, normalized_url, normalized_snippet)
196
156
  return nil if low_value_result?(normalized_title, normalized_url, normalized_snippet)
197
157
 
198
158
  item = {
199
159
  title: normalized_title,
200
- url: normalized_url
160
+ url: normalized_url,
161
+ rank: candidate.source_rank
201
162
  }
202
163
 
203
164
  item[:snippet] = normalized_snippet if normalized_snippet
204
165
  item
205
166
  end
206
167
 
207
- def verbose?
208
- @verbose
209
- end
210
-
211
168
  def compact_text(value)
212
169
  FetchUtil.normalize_whitespace(value)
213
170
  end
@@ -296,13 +253,7 @@ module FetchUtil
296
253
  return nil if metadata_only_snippet?(text)
297
254
  return nil if jammed_navigation_text?(text)
298
255
 
299
- truncate(text, MAX_SNIPPET_LENGTH)
300
- end
301
-
302
- def truncate(text, max_length)
303
- return text if text.length <= max_length
304
-
305
- "#{text[0, max_length - 3].rstrip}..."
256
+ text
306
257
  end
307
258
 
308
259
  def domain_only?(text, host)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module FetchUtil
4
- VERSION = "0.3.2"
4
+ VERSION = "0.5.0"
5
5
  end
data/lib/fetch_util.rb CHANGED
@@ -64,6 +64,7 @@ module FetchUtil
64
64
  autoload :RequestLog, "fetch_util/request_log"
65
65
  autoload :Result, "fetch_util/result"
66
66
  autoload :Searcher, "fetch_util/searcher"
67
+ autoload :SearchTransport, "fetch_util/search_transport"
67
68
 
68
69
  module_function
69
70
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fetch_util
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.2
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - hmdne
@@ -84,6 +84,7 @@ files:
84
84
  - lib/fetch_util/browser/site_stabilization/community_and_marketplace.rb
85
85
  - lib/fetch_util/browser/site_stabilization/gitlab_repo.rb
86
86
  - lib/fetch_util/browser/site_stabilization/social_platforms.rb
87
+ - lib/fetch_util/browser/site_stabilization/travel_and_lodging.rb
87
88
  - lib/fetch_util/browser/stabilization.rb
88
89
  - lib/fetch_util/browser/stabilization/page_flow.rb
89
90
  - lib/fetch_util/browser/stabilization/spa_hydration.rb
@@ -113,6 +114,7 @@ files:
113
114
  - lib/fetch_util/regulatory/usage_preferences.rb
114
115
  - lib/fetch_util/request_log.rb
115
116
  - lib/fetch_util/result.rb
117
+ - lib/fetch_util/search_transport.rb
116
118
  - lib/fetch_util/searcher.rb
117
119
  - lib/fetch_util/searcher/result_filtering.rb
118
120
  - lib/fetch_util/version.rb