fetch_util 0.5.2 → 0.6.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/CHANGELOG.md +49 -0
- data/README.md +3 -1
- data/Rakefile +0 -2
- data/lib/fetch_util/assets/extract.js +1 -1
- data/lib/fetch_util/browser/interaction_helpers/consent_helpers.rb +12 -17
- data/lib/fetch_util/browser/interaction_helpers/timing_helpers.rb +42 -15
- data/lib/fetch_util/browser/site_stabilization/azure_devops_pr_commit_details_script.rb +48 -0
- data/lib/fetch_util/browser/site_stabilization/azure_devops_pr_product_state_script.rb +80 -0
- data/lib/fetch_util/browser/site_stabilization/azure_devops_pr_request_helpers_script.rb +53 -0
- data/lib/fetch_util/browser/site_stabilization/azure_devops_pr_request_state_script.rb +118 -0
- data/lib/fetch_util/browser/site_stabilization/azure_devops_pr_stabilization.rb +54 -0
- data/lib/fetch_util/browser/site_stabilization/azure_devops_pr_state.rb +27 -0
- data/lib/fetch_util/browser/site_stabilization/bitbucket_cloud_pull_diff_product_state.rb +55 -0
- data/lib/fetch_util/browser/site_stabilization/bitbucket_cloud_pull_diff_request_state.rb +100 -0
- data/lib/fetch_util/browser/site_stabilization/bitbucket_cloud_pull_diff_state.rb +59 -0
- data/lib/fetch_util/browser/site_stabilization/bitbucket_cloud_pull_resource_modules.rb +26 -0
- data/lib/fetch_util/browser/site_stabilization/bitbucket_cloud_pull_resource_state.rb +93 -0
- data/lib/fetch_util/browser/site_stabilization/bitbucket_cloud_pull_resources.rb +73 -0
- data/lib/fetch_util/browser/site_stabilization/bitbucket_cloud_thread_state.rb +92 -0
- data/lib/fetch_util/browser/site_stabilization/bitbucket_cloud_threads.rb +71 -0
- data/lib/fetch_util/browser/site_stabilization/community_and_marketplace.rb +9 -10
- data/lib/fetch_util/browser/site_stabilization/facebook_stabilization.rb +56 -0
- data/lib/fetch_util/browser/site_stabilization/gerrit_change_product_state_script.rb +97 -0
- data/lib/fetch_util/browser/site_stabilization/gerrit_change_request_state_script.rb +75 -0
- data/lib/fetch_util/browser/site_stabilization/gerrit_change_stabilization.rb +37 -0
- data/lib/fetch_util/browser/site_stabilization/gerrit_change_state.rb +39 -0
- data/lib/fetch_util/browser/site_stabilization/gerrit_file_resource_fetch_state_script.rb +31 -0
- data/lib/fetch_util/browser/site_stabilization/gerrit_file_resource_product_state_script.rb +76 -0
- data/lib/fetch_util/browser/site_stabilization/gerrit_file_resource_request_state_script.rb +111 -0
- data/lib/fetch_util/browser/site_stabilization/gerrit_file_resource_route_state_script.rb +71 -0
- data/lib/fetch_util/browser/site_stabilization/gerrit_file_resource_stabilization.rb +37 -0
- data/lib/fetch_util/browser/site_stabilization/gerrit_file_resource_state.rb +42 -0
- data/lib/fetch_util/browser/site_stabilization/gerrit_file_resource_validation_state_script.rb +49 -0
- data/lib/fetch_util/browser/site_stabilization/gitea_family_pull_resource_product_state.rb +88 -0
- data/lib/fetch_util/browser/site_stabilization/gitea_family_pull_resource_state.rb +109 -0
- data/lib/fetch_util/browser/site_stabilization/gitea_family_pull_resources.rb +30 -0
- data/lib/fetch_util/browser/site_stabilization/gitea_family_stabilization.rb +60 -0
- data/lib/fetch_util/browser/site_stabilization/gitea_family_thread_product_state.rb +74 -0
- data/lib/fetch_util/browser/site_stabilization/gitea_family_thread_timeline_state.rb +69 -0
- data/lib/fetch_util/browser/site_stabilization/gitea_family_threads.rb +28 -0
- data/lib/fetch_util/browser/site_stabilization/gitea_family_visibility_script.rb +28 -0
- data/lib/fetch_util/browser/site_stabilization/github_pull_resource_stabilization.rb +31 -0
- data/lib/fetch_util/browser/site_stabilization/github_pull_resources.rb +105 -0
- data/lib/fetch_util/browser/site_stabilization/github_threads.rb +82 -0
- data/lib/fetch_util/browser/site_stabilization/gitlab_merge_request_resource_state.rb +17 -0
- data/lib/fetch_util/browser/site_stabilization/gitlab_merge_request_resource_state_script.rb +98 -0
- data/lib/fetch_util/browser/site_stabilization/gitlab_merge_request_resource_visibility_script.rb +43 -0
- data/lib/fetch_util/browser/site_stabilization/gitlab_merge_request_resources.rb +28 -0
- data/lib/fetch_util/browser/site_stabilization/gitlab_repo.rb +3 -11
- data/lib/fetch_util/browser/site_stabilization/gitlab_stabilization.rb +33 -0
- data/lib/fetch_util/browser/site_stabilization/gitlab_threads.rb +92 -0
- data/lib/fetch_util/browser/site_stabilization/social_platforms.rb +19 -38
- data/lib/fetch_util/browser/site_stabilization/travel_and_lodging.rb +3 -3
- data/lib/fetch_util/browser/site_stabilization.rb +89 -0
- data/lib/fetch_util/browser/stabilization/anubis.rb +4 -5
- data/lib/fetch_util/browser/stabilization/page_flow.rb +71 -27
- data/lib/fetch_util/browser/stabilization/spa_hydration.rb +7 -5
- data/lib/fetch_util/browser.rb +88 -17
- data/lib/fetch_util/cli.rb +69 -23
- data/lib/fetch_util/extractor.rb +8 -2
- data/lib/fetch_util/fetcher.rb +144 -43
- data/lib/fetch_util/http_redirect_client.rb +161 -0
- data/lib/fetch_util/parallel_fetcher.rb +125 -28
- data/lib/fetch_util/raw_docs_fallback.rb +158 -21
- data/lib/fetch_util/regulatory/cache_store.rb +47 -14
- data/lib/fetch_util/regulatory/fetch_records.rb +10 -7
- data/lib/fetch_util/regulatory/headers.rb +8 -1
- data/lib/fetch_util/regulatory/http_client.rb +2 -97
- data/lib/fetch_util/regulatory/orchestration.rb +10 -6
- data/lib/fetch_util/regulatory/page.rb +8 -11
- data/lib/fetch_util/regulatory/robots.rb +2 -10
- data/lib/fetch_util/regulatory/source_selection.rb +13 -3
- data/lib/fetch_util/regulatory/tdm_page.rb +9 -4
- data/lib/fetch_util/regulatory/tdm_policy.rb +16 -9
- data/lib/fetch_util/regulatory/tdm_rep.rb +4 -3
- data/lib/fetch_util/regulatory/tdm_support.rb +10 -6
- data/lib/fetch_util/regulatory.rb +5 -1
- data/lib/fetch_util/request_log.rb +24 -3
- data/lib/fetch_util/result.rb +37 -89
- data/lib/fetch_util/search_transport.rb +102 -35
- data/lib/fetch_util/searcher.rb +15 -13
- data/lib/fetch_util/version.rb +1 -1
- data/lib/fetch_util.rb +4 -1
- metadata +66 -2
data/lib/fetch_util/result.rb
CHANGED
|
@@ -13,86 +13,32 @@ module FetchUtil
|
|
|
13
13
|
|
|
14
14
|
class << self
|
|
15
15
|
def from_payload(url:, final_url:, payload:, canonical_url:, content_type:, warnings:, suspect:)
|
|
16
|
-
content_completeness_ratio = payload["contentCompletenessRatio"]&.to_f || 1.0
|
|
17
|
-
content_format = payload["contentFormat"]
|
|
18
|
-
paywall_state = payload["paywallState"]
|
|
19
16
|
metadata = payload_metadata(
|
|
20
17
|
payload,
|
|
21
18
|
canonical_url: canonical_url,
|
|
22
19
|
final_url: final_url,
|
|
23
20
|
content_type: content_type,
|
|
24
21
|
suspect: suspect,
|
|
25
|
-
warnings: warnings
|
|
26
|
-
content_completeness_ratio: content_completeness_ratio,
|
|
27
|
-
content_format: content_format,
|
|
28
|
-
paywall_state: paywall_state,
|
|
29
|
-
name: payload["name"],
|
|
30
|
-
company: payload["company"],
|
|
31
|
-
location: payload["location"],
|
|
32
|
-
description: payload["description"],
|
|
33
|
-
ingredients: payload["ingredients"],
|
|
34
|
-
instructions: payload["instructions"],
|
|
35
|
-
bedrooms: payload["bedrooms"],
|
|
36
|
-
bathrooms: payload["bathrooms"],
|
|
37
|
-
area_sqft: payload["areaSqft"],
|
|
38
|
-
price: payload["price"],
|
|
39
|
-
rating: payload["rating"],
|
|
40
|
-
address: payload["address"],
|
|
41
|
-
social_kind: payload["socialKind"],
|
|
42
|
-
platform: payload["platform"],
|
|
43
|
-
handle: payload["handle"],
|
|
44
|
-
reply_count: payload["replyCount"],
|
|
45
|
-
community: payload["community"],
|
|
46
|
-
score: payload["score"]
|
|
22
|
+
warnings: warnings
|
|
47
23
|
)
|
|
48
24
|
|
|
49
25
|
new(
|
|
50
26
|
url: url,
|
|
51
27
|
final_url: final_url,
|
|
52
|
-
title: payload["title"],
|
|
53
|
-
byline: payload["byline"],
|
|
54
|
-
excerpt: payload["excerpt"],
|
|
55
|
-
site_name: payload["siteName"],
|
|
56
|
-
published_time: payload["publishedTime"],
|
|
57
|
-
canonical_url: canonical_url,
|
|
58
|
-
language: payload["language"],
|
|
59
|
-
name: payload["name"],
|
|
60
|
-
company: payload["company"],
|
|
61
|
-
location: payload["location"],
|
|
62
|
-
description: payload["description"],
|
|
63
|
-
ingredients: payload["ingredients"],
|
|
64
|
-
instructions: payload["instructions"],
|
|
65
|
-
bedrooms: payload["bedrooms"],
|
|
66
|
-
bathrooms: payload["bathrooms"],
|
|
67
|
-
area_sqft: payload["areaSqft"],
|
|
68
28
|
html: payload["html"],
|
|
69
29
|
markdown: payload["markdown"],
|
|
70
30
|
metadata: metadata,
|
|
71
|
-
|
|
72
|
-
content_type: content_type,
|
|
73
|
-
suspect: suspect,
|
|
74
|
-
warnings: warnings,
|
|
75
|
-
content_completeness_ratio: content_completeness_ratio,
|
|
76
|
-
content_format: content_format,
|
|
77
|
-
paywall_state: paywall_state,
|
|
78
|
-
price: payload["price"],
|
|
79
|
-
rating: payload["rating"],
|
|
80
|
-
address: payload["address"],
|
|
81
|
-
social_kind: payload["socialKind"],
|
|
82
|
-
platform: payload["platform"],
|
|
83
|
-
handle: payload["handle"],
|
|
84
|
-
reply_count: payload["replyCount"],
|
|
85
|
-
community: payload["community"],
|
|
86
|
-
score: payload["score"]
|
|
31
|
+
**metadata.except(:content_url)
|
|
87
32
|
)
|
|
88
33
|
end
|
|
89
34
|
|
|
90
35
|
def error(url:, warning:, message:)
|
|
36
|
+
warnings = [warning]
|
|
91
37
|
metadata = {
|
|
92
38
|
content_url: url,
|
|
93
39
|
content_type: "error",
|
|
94
40
|
suspect: true,
|
|
95
|
-
warnings:
|
|
41
|
+
warnings: warnings,
|
|
96
42
|
error_message: message,
|
|
97
43
|
social_kind: nil,
|
|
98
44
|
platform: nil,
|
|
@@ -127,18 +73,14 @@ module FetchUtil
|
|
|
127
73
|
reader_mode: nil,
|
|
128
74
|
content_type: "error",
|
|
129
75
|
suspect: true,
|
|
130
|
-
warnings:
|
|
76
|
+
warnings: warnings,
|
|
131
77
|
error_message: message
|
|
132
78
|
)
|
|
133
79
|
end
|
|
134
80
|
|
|
135
81
|
private
|
|
136
82
|
|
|
137
|
-
def payload_metadata(payload, canonical_url:, final_url:, content_type:, suspect:, warnings
|
|
138
|
-
content_completeness_ratio:, content_format:, paywall_state:,
|
|
139
|
-
name:, company:, location:, description:, ingredients:, instructions:,
|
|
140
|
-
bedrooms:, bathrooms:, area_sqft:, price:, rating:, address:,
|
|
141
|
-
social_kind:, platform:, handle:, reply_count:, community:, score:)
|
|
83
|
+
def payload_metadata(payload, canonical_url:, final_url:, content_type:, suspect:, warnings:)
|
|
142
84
|
{
|
|
143
85
|
title: payload["title"],
|
|
144
86
|
byline: payload["byline"],
|
|
@@ -147,32 +89,32 @@ module FetchUtil
|
|
|
147
89
|
published_time: payload["publishedTime"],
|
|
148
90
|
canonical_url: canonical_url,
|
|
149
91
|
language: payload["language"],
|
|
150
|
-
name: name,
|
|
151
|
-
company: company,
|
|
152
|
-
location: location,
|
|
153
|
-
description: description,
|
|
154
|
-
ingredients: ingredients,
|
|
155
|
-
instructions: instructions,
|
|
156
|
-
bedrooms: bedrooms,
|
|
157
|
-
bathrooms: bathrooms,
|
|
158
|
-
area_sqft:
|
|
92
|
+
name: payload["name"],
|
|
93
|
+
company: payload["company"],
|
|
94
|
+
location: payload["location"],
|
|
95
|
+
description: payload["description"],
|
|
96
|
+
ingredients: payload["ingredients"],
|
|
97
|
+
instructions: payload["instructions"],
|
|
98
|
+
bedrooms: payload["bedrooms"],
|
|
99
|
+
bathrooms: payload["bathrooms"],
|
|
100
|
+
area_sqft: payload["areaSqft"],
|
|
159
101
|
content_url: final_url,
|
|
160
102
|
reader_mode: payload["readerMode"],
|
|
161
103
|
content_type: content_type,
|
|
162
104
|
suspect: suspect,
|
|
163
105
|
warnings: warnings,
|
|
164
|
-
content_completeness_ratio:
|
|
165
|
-
content_format:
|
|
166
|
-
paywall_state:
|
|
167
|
-
price: price,
|
|
168
|
-
rating: rating,
|
|
169
|
-
address: address,
|
|
170
|
-
social_kind:
|
|
171
|
-
platform: platform,
|
|
172
|
-
handle: handle,
|
|
173
|
-
reply_count:
|
|
174
|
-
community: community,
|
|
175
|
-
score: score
|
|
106
|
+
content_completeness_ratio: payload["contentCompletenessRatio"]&.to_f || 1.0,
|
|
107
|
+
content_format: payload["contentFormat"],
|
|
108
|
+
paywall_state: payload["paywallState"],
|
|
109
|
+
price: payload["price"],
|
|
110
|
+
rating: payload["rating"],
|
|
111
|
+
address: payload["address"],
|
|
112
|
+
social_kind: payload["socialKind"],
|
|
113
|
+
platform: payload["platform"],
|
|
114
|
+
handle: payload["handle"],
|
|
115
|
+
reply_count: payload["replyCount"],
|
|
116
|
+
community: payload["community"],
|
|
117
|
+
score: payload["score"]
|
|
176
118
|
}.freeze
|
|
177
119
|
end
|
|
178
120
|
end
|
|
@@ -184,6 +126,12 @@ module FetchUtil
|
|
|
184
126
|
content_completeness_ratio: 1.0, content_format: nil, paywall_state: nil,
|
|
185
127
|
price: nil, rating: nil, address: nil, social_kind: nil, platform: nil,
|
|
186
128
|
handle: nil, reply_count: nil, community: nil, score: nil, error_message: nil)
|
|
129
|
+
owned_warnings = warnings.map { |warning| warning.to_s.dup.freeze }.freeze
|
|
130
|
+
owned_content_format = content_format&.dup&.freeze
|
|
131
|
+
owned_paywall_state = paywall_state&.dup&.freeze
|
|
132
|
+
owned_metadata = metadata.merge(warnings: owned_warnings)
|
|
133
|
+
owned_metadata[:content_format] = owned_content_format if owned_metadata.key?(:content_format)
|
|
134
|
+
owned_metadata[:paywall_state] = owned_paywall_state if owned_metadata.key?(:paywall_state)
|
|
187
135
|
@url = url
|
|
188
136
|
@final_url = final_url
|
|
189
137
|
@title = title
|
|
@@ -204,14 +152,14 @@ module FetchUtil
|
|
|
204
152
|
@area_sqft = area_sqft
|
|
205
153
|
@html = html
|
|
206
154
|
@markdown = markdown
|
|
207
|
-
@metadata =
|
|
155
|
+
@metadata = owned_metadata.freeze
|
|
208
156
|
@reader_mode = reader_mode
|
|
209
157
|
@content_type = content_type
|
|
210
158
|
@suspect = suspect
|
|
211
|
-
@warnings =
|
|
159
|
+
@warnings = owned_warnings
|
|
212
160
|
@content_completeness_ratio = content_completeness_ratio
|
|
213
|
-
@content_format =
|
|
214
|
-
@paywall_state =
|
|
161
|
+
@content_format = owned_content_format
|
|
162
|
+
@paywall_state = owned_paywall_state
|
|
215
163
|
@price = price
|
|
216
164
|
@rating = rating
|
|
217
165
|
@address = address
|
|
@@ -13,22 +13,47 @@ module FetchUtil
|
|
|
13
13
|
class SearchTransport
|
|
14
14
|
Candidate = Data.define(:source, :title, :url, :snippet, :source_rank) do
|
|
15
15
|
def initialize(source:, title:, url:, source_rank:, snippet: nil)
|
|
16
|
-
super(source: source.to_s.freeze, title: title.to_s.freeze, url: url.to_s.freeze,
|
|
17
|
-
snippet: snippet
|
|
16
|
+
super(source: source.to_s.dup.freeze, title: title.to_s.dup.freeze, url: url.to_s.dup.freeze,
|
|
17
|
+
snippet: snippet.nil? ? nil : snippet.to_s.dup.freeze, source_rank: Integer(source_rank))
|
|
18
18
|
end
|
|
19
19
|
end
|
|
20
20
|
|
|
21
21
|
SourceResponse = Data.define(:source, :transport, :status, :candidates, :elapsed_ms, :final_url, :reason) do
|
|
22
22
|
def initialize(source:, status:, elapsed_ms:, transport: "http", candidates: [], final_url: nil, reason: nil)
|
|
23
|
-
super(source: source.to_s.freeze, transport: transport.to_s.freeze, status: status.to_s.freeze,
|
|
24
|
-
candidates: candidates.freeze, elapsed_ms: Integer(elapsed_ms),
|
|
25
|
-
|
|
23
|
+
super(source: source.to_s.dup.freeze, transport: transport.to_s.dup.freeze, status: status.to_s.dup.freeze,
|
|
24
|
+
candidates: candidates.dup.freeze, elapsed_ms: Integer(elapsed_ms),
|
|
25
|
+
final_url: final_url.nil? ? nil : final_url.to_s.dup.freeze,
|
|
26
|
+
reason: reason.nil? ? nil : reason.to_s.dup.freeze)
|
|
26
27
|
end
|
|
27
28
|
end
|
|
28
29
|
|
|
29
30
|
HttpResponse = Data.define(:status, :headers, :body, :final_url)
|
|
30
31
|
HttpFailure = Data.define(:reason, :final_url)
|
|
31
32
|
|
|
33
|
+
class MonotonicClock
|
|
34
|
+
def initialize(source)
|
|
35
|
+
@source = source
|
|
36
|
+
@mutex = Mutex.new
|
|
37
|
+
@latest = nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def call
|
|
41
|
+
@mutex.synchronize do
|
|
42
|
+
current = @source.call
|
|
43
|
+
begin
|
|
44
|
+
current = Float(current)
|
|
45
|
+
rescue ArgumentError, TypeError
|
|
46
|
+
raise ArgumentError, "clock must return a finite number"
|
|
47
|
+
end
|
|
48
|
+
raise ArgumentError, "clock must return a finite number" unless current.finite?
|
|
49
|
+
|
|
50
|
+
@latest = current if @latest.nil? || current > @latest
|
|
51
|
+
@latest
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
private_constant :MonotonicClock
|
|
56
|
+
|
|
32
57
|
SOURCES = {
|
|
33
58
|
"brave" => { url: "https://search.brave.com/search?q=%{query}", hosts: %w[search.brave.com] },
|
|
34
59
|
"bing" => { url: "https://www.bing.com/search?q=%{query}&setlang=en-US&cc=US", hosts: %w[www.bing.com cn.bing.com] },
|
|
@@ -55,15 +80,18 @@ module FetchUtil
|
|
|
55
80
|
}.freeze
|
|
56
81
|
|
|
57
82
|
def initialize(sources: SOURCES.keys, timeout: DEFAULT_TIMEOUT, clock: nil, http_client: nil, html_parser: nil)
|
|
58
|
-
@sources = sources.map
|
|
83
|
+
@sources = sources.map { |source| source.to_s.dup.freeze }.freeze
|
|
59
84
|
unknown = @sources - SOURCES.keys
|
|
60
|
-
raise
|
|
85
|
+
raise InputError, "unknown search sources: #{unknown.join(", ")}" if unknown.any?
|
|
61
86
|
|
|
62
|
-
@timeout =
|
|
63
|
-
raise ArgumentError, "timeout must be positive" unless @timeout.positive?
|
|
87
|
+
@timeout = validated_timeout(timeout)
|
|
64
88
|
|
|
65
|
-
@clock = clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
|
|
66
|
-
@http_client = http_client
|
|
89
|
+
@clock = MonotonicClock.new(clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
|
|
90
|
+
@http_client = if http_client.is_a?(HttpClient)
|
|
91
|
+
http_client.send(:with_clock, @clock)
|
|
92
|
+
else
|
|
93
|
+
http_client || HttpClient.new(clock: @clock)
|
|
94
|
+
end
|
|
67
95
|
@html_parser = html_parser || ->(body) { Nokogiri::HTML(body) }
|
|
68
96
|
end
|
|
69
97
|
|
|
@@ -83,15 +111,20 @@ module FetchUtil
|
|
|
83
111
|
|
|
84
112
|
def search(query, timeout: @timeout)
|
|
85
113
|
query = query.to_s.strip
|
|
86
|
-
raise
|
|
114
|
+
raise InputError, "query must not be empty" if query.empty?
|
|
87
115
|
|
|
88
|
-
request_timeout =
|
|
89
|
-
raise ArgumentError, "timeout must be positive" unless request_timeout.positive?
|
|
116
|
+
request_timeout = validated_timeout(timeout)
|
|
90
117
|
|
|
91
118
|
deadline = clock.call + request_timeout
|
|
92
119
|
responses = Array.new(sources.length)
|
|
93
|
-
threads =
|
|
94
|
-
|
|
120
|
+
threads = []
|
|
121
|
+
begin
|
|
122
|
+
sources.each_with_index do |source, index|
|
|
123
|
+
threads << Thread.new { responses[index] = search_source(source, query, deadline) }
|
|
124
|
+
end
|
|
125
|
+
rescue ThreadError
|
|
126
|
+
threads.each(&:join)
|
|
127
|
+
raise
|
|
95
128
|
end
|
|
96
129
|
threads.each(&:join)
|
|
97
130
|
responses
|
|
@@ -101,6 +134,13 @@ module FetchUtil
|
|
|
101
134
|
|
|
102
135
|
attr_reader :clock, :html_parser, :http_client, :sources, :timeout
|
|
103
136
|
|
|
137
|
+
def validated_timeout(value)
|
|
138
|
+
timeout = Float(value)
|
|
139
|
+
raise InputError, "timeout must be positive" unless timeout.finite? && timeout.positive?
|
|
140
|
+
|
|
141
|
+
timeout
|
|
142
|
+
end
|
|
143
|
+
|
|
104
144
|
def search_source(source, query, deadline)
|
|
105
145
|
started_at = clock.call
|
|
106
146
|
url = build_url(source, query)
|
|
@@ -110,9 +150,10 @@ module FetchUtil
|
|
|
110
150
|
end
|
|
111
151
|
outcome = within_deadline(deadline) do
|
|
112
152
|
document = html_parser.call(result.body)
|
|
113
|
-
|
|
153
|
+
shell_text = search_shell_text(source, document)
|
|
154
|
+
next [:failed, "challenge"] if challenge?(source, result, shell_text)
|
|
114
155
|
next [:failed, "http_status"] unless result.status.between?(200, 299)
|
|
115
|
-
next [:empty] if no_results?(source,
|
|
156
|
+
next [:empty] if no_results?(source, shell_text)
|
|
116
157
|
|
|
117
158
|
candidates = parse_candidates(source, document)
|
|
118
159
|
next [:ok, candidates] if candidates.empty?
|
|
@@ -359,19 +400,16 @@ module FetchUtil
|
|
|
359
400
|
node ? node.text.encode("UTF-8", invalid: :replace, undef: :replace, replace: " ").gsub(/\s+/, " ").strip : ""
|
|
360
401
|
end
|
|
361
402
|
|
|
362
|
-
def challenge?(source, response,
|
|
403
|
+
def challenge?(source, response, shell_text)
|
|
363
404
|
return true if source == "google" && URI.parse(response.final_url).path.start_with?("/sorry")
|
|
364
405
|
return true if source == "duckduckgo" && response.status == 202
|
|
365
406
|
|
|
366
|
-
|
|
367
|
-
visible_text = visible_document_text(document)
|
|
368
|
-
"#{title} #{visible_text}".match?(/captcha|unusual traffic|verify you are human|consent/)
|
|
407
|
+
shell_text.match?(/captcha|unusual traffic|verify you are human|consent/)
|
|
369
408
|
rescue URI::InvalidURIError
|
|
370
409
|
true
|
|
371
410
|
end
|
|
372
411
|
|
|
373
|
-
def no_results?(source,
|
|
374
|
-
text = visible_document_text(document)
|
|
412
|
+
def no_results?(source, text)
|
|
375
413
|
patterns = {
|
|
376
414
|
"brave" => /no results|did not match any documents/,
|
|
377
415
|
"bing" => /there are no results|no results found/,
|
|
@@ -383,9 +421,10 @@ module FetchUtil
|
|
|
383
421
|
text.match?(patterns.fetch(source))
|
|
384
422
|
end
|
|
385
423
|
|
|
386
|
-
def
|
|
424
|
+
def search_shell_text(source, document)
|
|
387
425
|
visible = document.dup
|
|
388
|
-
|
|
426
|
+
result_nodes(source, visible).each(&:remove)
|
|
427
|
+
visible.css("title, script, style, template, noscript").remove
|
|
389
428
|
normalized_text(visible).downcase
|
|
390
429
|
end
|
|
391
430
|
|
|
@@ -427,8 +466,8 @@ module FetchUtil
|
|
|
427
466
|
|
|
428
467
|
def initialize(clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }, max_response_bytes: MAX_RESPONSE_BYTES,
|
|
429
468
|
net_http: nil)
|
|
430
|
-
@clock = clock
|
|
431
|
-
@max_response_bytes =
|
|
469
|
+
@clock = clock.is_a?(MonotonicClock) ? clock : MonotonicClock.new(clock)
|
|
470
|
+
@max_response_bytes = positive_max_response_bytes(max_response_bytes)
|
|
432
471
|
@net_http = net_http || ->(uri) { Net::HTTP.new(uri.host, uri.port) }
|
|
433
472
|
end
|
|
434
473
|
|
|
@@ -442,6 +481,21 @@ module FetchUtil
|
|
|
442
481
|
|
|
443
482
|
attr_reader :clock, :max_response_bytes, :net_http
|
|
444
483
|
|
|
484
|
+
def with_clock(shared_clock)
|
|
485
|
+
copy = dup
|
|
486
|
+
copy.instance_variable_set(:@clock, shared_clock)
|
|
487
|
+
copy
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
def positive_max_response_bytes(value)
|
|
491
|
+
bytes = Integer(value)
|
|
492
|
+
return bytes if bytes.positive?
|
|
493
|
+
|
|
494
|
+
raise ArgumentError
|
|
495
|
+
rescue ArgumentError, TypeError, FloatDomainError
|
|
496
|
+
raise InputError, "max_response_bytes must be positive"
|
|
497
|
+
end
|
|
498
|
+
|
|
445
499
|
def fetch(uri, deadline, allowed_hosts, redirects_left)
|
|
446
500
|
return HttpFailure.new(reason: "host", final_url: uri.to_s) unless allowed_uri?(uri, allowed_hosts)
|
|
447
501
|
return HttpFailure.new(reason: "timeout", final_url: uri.to_s) unless remaining(deadline).positive?
|
|
@@ -456,6 +510,9 @@ module FetchUtil
|
|
|
456
510
|
rescue URI::InvalidURIError
|
|
457
511
|
return HttpFailure.new(reason: "redirect", final_url: uri.to_s)
|
|
458
512
|
end
|
|
513
|
+
if uri.scheme == "https" && target.scheme != "https"
|
|
514
|
+
return HttpFailure.new(reason: "redirect", final_url: target.to_s)
|
|
515
|
+
end
|
|
459
516
|
return HttpFailure.new(reason: "host", final_url: target.to_s) unless allowed_uri?(target, allowed_hosts)
|
|
460
517
|
|
|
461
518
|
return fetch(target, deadline, allowed_hosts, redirects_left - 1)
|
|
@@ -470,7 +527,7 @@ module FetchUtil
|
|
|
470
527
|
HttpFailure.new(reason: "size", final_url: uri.to_s)
|
|
471
528
|
rescue Zlib::Error
|
|
472
529
|
HttpFailure.new(reason: "parse", final_url: uri.to_s)
|
|
473
|
-
rescue
|
|
530
|
+
rescue IOError, SocketError, SystemCallError
|
|
474
531
|
HttpFailure.new(reason: "failed", final_url: uri.to_s)
|
|
475
532
|
end
|
|
476
533
|
|
|
@@ -494,8 +551,7 @@ module FetchUtil
|
|
|
494
551
|
http.request(request) do |incoming|
|
|
495
552
|
incoming.read_body do |chunk|
|
|
496
553
|
ensure_remaining!(deadline)
|
|
497
|
-
body
|
|
498
|
-
raise ResponseTooLarge if body.bytesize > max_response_bytes
|
|
554
|
+
append_bounded(body, chunk)
|
|
499
555
|
end
|
|
500
556
|
end
|
|
501
557
|
end
|
|
@@ -526,18 +582,29 @@ module FetchUtil
|
|
|
526
582
|
while offset < body.bytesize
|
|
527
583
|
ensure_remaining!(deadline)
|
|
528
584
|
chunk = body.byteslice(offset, INFLATE_CHUNK_BYTES)
|
|
529
|
-
|
|
585
|
+
inflater.inflate(chunk) do |piece|
|
|
586
|
+
ensure_remaining!(deadline)
|
|
587
|
+
append_decoded(decoded, piece)
|
|
588
|
+
end
|
|
530
589
|
offset += chunk.bytesize
|
|
531
590
|
end
|
|
532
|
-
|
|
591
|
+
inflater.finish do |piece|
|
|
592
|
+
ensure_remaining!(deadline)
|
|
593
|
+
append_decoded(decoded, piece)
|
|
594
|
+
end
|
|
533
595
|
decoded
|
|
534
596
|
ensure
|
|
535
597
|
inflater&.close
|
|
536
598
|
end
|
|
537
599
|
|
|
538
600
|
def append_decoded(decoded, chunk)
|
|
539
|
-
decoded
|
|
540
|
-
|
|
601
|
+
append_bounded(decoded, chunk)
|
|
602
|
+
end
|
|
603
|
+
|
|
604
|
+
def append_bounded(buffer, chunk)
|
|
605
|
+
raise ResponseTooLarge if buffer.bytesize + chunk.bytesize > max_response_bytes
|
|
606
|
+
|
|
607
|
+
buffer << chunk
|
|
541
608
|
end
|
|
542
609
|
|
|
543
610
|
def charset_from(content_type, body)
|
data/lib/fetch_util/searcher.rb
CHANGED
|
@@ -16,9 +16,9 @@ module FetchUtil
|
|
|
16
16
|
timeout: SearchTransport::DEFAULT_TIMEOUT)
|
|
17
17
|
@request_log = request_log
|
|
18
18
|
@sources_explicit = !sources.nil?
|
|
19
|
-
@sources = Array(sources || DEFAULT_SOURCES).map
|
|
19
|
+
@sources = Array(sources || DEFAULT_SOURCES).map { |source| source.to_s.dup.freeze }.uniq.freeze
|
|
20
20
|
unknown = @sources - SearchTransport::SOURCES.keys
|
|
21
|
-
raise
|
|
21
|
+
raise InputError, "unsupported search source: #{unknown.first}" if unknown.any?
|
|
22
22
|
|
|
23
23
|
validate_limit!(limit)
|
|
24
24
|
@limit = limit
|
|
@@ -28,16 +28,18 @@ module FetchUtil
|
|
|
28
28
|
|
|
29
29
|
def search(query)
|
|
30
30
|
encoded_query = query.to_s.strip
|
|
31
|
-
raise
|
|
31
|
+
raise InputError, "query must not be empty" if encoded_query.empty?
|
|
32
32
|
|
|
33
33
|
@request_log.append(search_request_uri(encoded_query))
|
|
34
34
|
responses = @transport.search(encoded_query)
|
|
35
|
+
response_index = {}
|
|
36
|
+
responses.each { |response| response_index[response.source] ||= response }
|
|
35
37
|
|
|
36
38
|
payload = {
|
|
37
39
|
query: encoded_query,
|
|
38
|
-
results: formatted_results(apply_limit(aggregate(
|
|
40
|
+
results: formatted_results(apply_limit(aggregate(response_index, encoded_query)))
|
|
39
41
|
}
|
|
40
|
-
payload[:diagnostics] = diagnostics(
|
|
42
|
+
payload[:diagnostics] = diagnostics(response_index) if @verbose
|
|
41
43
|
payload
|
|
42
44
|
end
|
|
43
45
|
|
|
@@ -53,23 +55,23 @@ module FetchUtil
|
|
|
53
55
|
return if value.nil?
|
|
54
56
|
return if value.is_a?(Integer) && value >= 0
|
|
55
57
|
|
|
56
|
-
raise
|
|
58
|
+
raise InputError, "limit must be a nonnegative integer"
|
|
57
59
|
end
|
|
58
60
|
|
|
59
61
|
def search_request_uri(query)
|
|
60
62
|
"search://#{@sources.join(",")}?q=#{CGI.escape(query)}"
|
|
61
63
|
end
|
|
62
64
|
|
|
63
|
-
def aggregate(
|
|
65
|
+
def aggregate(response_index, query)
|
|
64
66
|
parsed = {}
|
|
65
67
|
structured_query = query.match?(STRUCTURED_QUERY)
|
|
66
68
|
|
|
67
69
|
@sources.each do |source|
|
|
68
|
-
response =
|
|
70
|
+
response = response_index[source]
|
|
69
71
|
parsed[source] = response ? response.candidates.filter_map { |candidate| normalized_candidate(candidate) } : []
|
|
70
72
|
end
|
|
71
73
|
|
|
72
|
-
apply_structured_query_authority!(parsed,
|
|
74
|
+
apply_structured_query_authority!(parsed, response_index) if structured_query
|
|
73
75
|
max_size = parsed.values.map(&:length).max || 0
|
|
74
76
|
|
|
75
77
|
items = []
|
|
@@ -95,11 +97,11 @@ module FetchUtil
|
|
|
95
97
|
items
|
|
96
98
|
end
|
|
97
99
|
|
|
98
|
-
def apply_structured_query_authority!(parsed,
|
|
100
|
+
def apply_structured_query_authority!(parsed, response_index)
|
|
99
101
|
return if @sources_explicit
|
|
100
102
|
return unless @sources.include?(STRUCTURED_QUERY_AUTHORITY)
|
|
101
103
|
|
|
102
|
-
authority =
|
|
104
|
+
authority = response_index[STRUCTURED_QUERY_AUTHORITY]
|
|
103
105
|
return unless authority&.status == "ok" && authority.reason.nil?
|
|
104
106
|
|
|
105
107
|
authority_urls = parsed.fetch(STRUCTURED_QUERY_AUTHORITY).map { |item| item[:url] }
|
|
@@ -146,9 +148,9 @@ module FetchUtil
|
|
|
146
148
|
end
|
|
147
149
|
end
|
|
148
150
|
|
|
149
|
-
def diagnostics(
|
|
151
|
+
def diagnostics(response_index)
|
|
150
152
|
@sources.filter_map do |source|
|
|
151
|
-
response =
|
|
153
|
+
response = response_index[source]
|
|
152
154
|
next unless response
|
|
153
155
|
|
|
154
156
|
diagnostic = {
|
data/lib/fetch_util/version.rb
CHANGED
data/lib/fetch_util.rb
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "addressable/uri"
|
|
4
|
+
require "ferrum"
|
|
5
|
+
require "public_suffix"
|
|
4
6
|
require "uri"
|
|
5
7
|
|
|
6
8
|
require_relative "fetch_util/version"
|
|
7
9
|
|
|
8
10
|
module FetchUtil
|
|
9
11
|
class Error < StandardError; end
|
|
12
|
+
class InputError < ArgumentError; end
|
|
10
13
|
class BrowserError < Error; end
|
|
11
14
|
class ExtractionError < Error; end
|
|
12
15
|
|
|
@@ -69,7 +72,7 @@ module FetchUtil
|
|
|
69
72
|
module_function
|
|
70
73
|
|
|
71
74
|
def fetch(url, **options)
|
|
72
|
-
Fetcher.
|
|
75
|
+
Fetcher.fetch_once(url, **options)
|
|
73
76
|
end
|
|
74
77
|
|
|
75
78
|
def fetch_many(urls, **options)
|