ask-web-fetch 0.7.3 → 0.7.4
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
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 412118d0b9d8d15d1677f877dcdd06cc3ca3d64f6afb79be80ae7fb404bdc8c5
|
|
4
|
+
data.tar.gz: 18a022bec912f32ecd7f1afca82c11e7e5f016199779b92fec86ec5deb1428a7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b12c4fbcc8e2f76d1b76b07836e1c7aeaa735e22c75b085571a1a8f710425637b461a97f2ec5c073d6e88b1d843aeca9da50d2ecba36d72ad21fcd4da7926852
|
|
7
|
+
data.tar.gz: 054750f93da3d1af328fb312867d26cbf4f7631133d9e79f76eb759816fd376e30b1a8df1b1b3f52d5752f8eac15aa38143c1c9484222543a20301c947ea2b4d
|
|
@@ -8,10 +8,23 @@ module Ask
|
|
|
8
8
|
# next backend in the chain.
|
|
9
9
|
class Error < StandardError; end
|
|
10
10
|
|
|
11
|
-
# A backend that failed to fetch because the URL itself is bad —
|
|
11
|
+
# A backend that failed to fetch because the URL itself is bad —
|
|
12
12
|
# challenge page, non-HTML response, redirect loop. Deterministic:
|
|
13
13
|
# retrying won't change the outcome.
|
|
14
|
-
class FetchError < Error
|
|
14
|
+
class FetchError < Error
|
|
15
|
+
attr_reader :status
|
|
16
|
+
|
|
17
|
+
def initialize(message = nil, status: nil)
|
|
18
|
+
@status = status
|
|
19
|
+
msg = status ? "[#{status}] #{message}" : message
|
|
20
|
+
super(msg)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# HTTP 404 — the URL does not exist. Some 404 pages still carry
|
|
25
|
+
# usable content (custom error pages with navigation, suggestions);
|
|
26
|
+
# the backend tries to extract it before giving up.
|
|
27
|
+
class NotFoundError < FetchError; end
|
|
15
28
|
|
|
16
29
|
# A backend that fetched the page but found nothing usable in it.
|
|
17
30
|
class EmptyContentError < Error; end
|
|
@@ -29,7 +42,15 @@ module Ask
|
|
|
29
42
|
|
|
30
43
|
# The service or the target server answered 5xx/429. Transient:
|
|
31
44
|
# retrying after backoff may succeed.
|
|
32
|
-
class ServerError < Error
|
|
45
|
+
class ServerError < Error
|
|
46
|
+
attr_reader :status
|
|
47
|
+
|
|
48
|
+
def initialize(message = nil, status: nil)
|
|
49
|
+
@status = status
|
|
50
|
+
msg = status ? "[#{status}] #{message}" : message
|
|
51
|
+
super(msg)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
33
54
|
|
|
34
55
|
# Base class for fetch backends, plus the errors they raise.
|
|
35
56
|
#
|
|
@@ -206,7 +206,10 @@ module Ask
|
|
|
206
206
|
wait_for_idle(page)
|
|
207
207
|
|
|
208
208
|
status = page.network.status
|
|
209
|
-
|
|
209
|
+
if status && status >= 400
|
|
210
|
+
error_class = status == 404 ? NotFoundError : FetchError
|
|
211
|
+
raise error_class.new("got #{status} at #{url}", status: status)
|
|
212
|
+
end
|
|
210
213
|
|
|
211
214
|
body = page.body
|
|
212
215
|
if challenge_page?(body)
|
|
@@ -49,12 +49,14 @@ module Ask
|
|
|
49
49
|
|
|
50
50
|
{ title: nil, description: nil, content: content, outlinks: markdown_outlinks(body, url) }
|
|
51
51
|
when '429'
|
|
52
|
-
raise ServerError
|
|
52
|
+
raise ServerError.new('rate limited by Jina', status: 429)
|
|
53
53
|
when '401', '403'
|
|
54
|
-
raise FetchError
|
|
54
|
+
raise FetchError.new("Jina access error", status: res.code.to_i)
|
|
55
55
|
else
|
|
56
56
|
# 5xx = Jina-side blip (transient); other 4xx = the URL is dead.
|
|
57
|
-
|
|
57
|
+
code = res.code.to_i
|
|
58
|
+
error_class = code == 404 ? NotFoundError : (code >= 500 ? ServerError : FetchError)
|
|
59
|
+
raise error_class.new("Jina returned #{res.code}", status: code)
|
|
58
60
|
end
|
|
59
61
|
rescue Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNREFUSED,
|
|
60
62
|
Errno::ECONNRESET, SocketError, URI::InvalidURIError => e
|
|
@@ -65,12 +65,15 @@ module Ask
|
|
|
65
65
|
end
|
|
66
66
|
|
|
67
67
|
# Probe 3: full HTML scrape (legacy path)
|
|
68
|
-
body, content_type, redirect = fetch_html(url)
|
|
69
|
-
|
|
70
|
-
|
|
68
|
+
body, content_type, redirect, status = fetch_html(url)
|
|
69
|
+
unless content_type.include?('html')
|
|
70
|
+
raise FetchError.new("expected HTML from #{url}, got #{content_type}", status: status)
|
|
71
|
+
end
|
|
72
|
+
raise FetchError.new("challenge page at #{url}", status: status) if challenge_page?(body)
|
|
71
73
|
|
|
72
74
|
page = to_markdown(body, url)
|
|
73
75
|
page[:redirected] = redirect
|
|
76
|
+
page[:status] = status
|
|
74
77
|
# Parked-domain pages are not content: the domain owner parked it
|
|
75
78
|
# with a registrar and the page is an ad for buying the domain
|
|
76
79
|
# (GoDaddy/Namecheap/Sedo parking). A content company must never
|
|
@@ -79,6 +82,14 @@ module Ask
|
|
|
79
82
|
# server-rendered — the HTML-only markers live in scripts and
|
|
80
83
|
# assets), then the content minimum, then the JS-shell
|
|
81
84
|
# completeness signal below.
|
|
85
|
+
# 404 pages sometimes carry usable content (custom error pages
|
|
86
|
+
# with navigation, suggestions). Try to extract it before giving
|
|
87
|
+
# up — only raise NotFoundError if the content is genuinely empty.
|
|
88
|
+
if status == 404
|
|
89
|
+
return page if usable_content?(page[:content])
|
|
90
|
+
raise NotFoundError.new("not found at #{url}", status: 404)
|
|
91
|
+
end
|
|
92
|
+
|
|
82
93
|
guard_page!(url, page[:content], raw_body: body)
|
|
83
94
|
# The completeness signal: a JS-app shell whose server HTML
|
|
84
95
|
# renders little is a TRUNCATED page, not a complete one — the
|
|
@@ -183,8 +194,8 @@ module Ask
|
|
|
183
194
|
end
|
|
184
195
|
|
|
185
196
|
# GET with redirect following (max MAX_REDIRECTS hops). Returns
|
|
186
|
-
# [body, content_type, redirect] where redirect is nil
|
|
187
|
-
# URL answered directly, else {status: first hop's status,
|
|
197
|
+
# [body, content_type, redirect, status] where redirect is nil
|
|
198
|
+
# when the URL answered directly, else {status: first hop's status,
|
|
188
199
|
# url: final destination} — the chain the crawler followed.
|
|
189
200
|
def fetch_html(url)
|
|
190
201
|
uri = URI(url)
|
|
@@ -192,7 +203,9 @@ module Ask
|
|
|
192
203
|
first_hop_status = nil
|
|
193
204
|
loop do
|
|
194
205
|
response = self.class.http.get(uri.to_s, headers: { 'accept' => 'text/html,application/xhtml+xml' })
|
|
195
|
-
|
|
206
|
+
if (200..299).cover?(response.status)
|
|
207
|
+
return [response.body, response.content_type, redirect_info(first_hop_status, uri), response.status]
|
|
208
|
+
end
|
|
196
209
|
|
|
197
210
|
unless (300..399).cover?(response.status) && !response.location.empty?
|
|
198
211
|
# 4xx (other than 429) = the URL is dead; 429/5xx = transient.
|
data/lib/ask/web_fetch.rb
CHANGED
|
@@ -37,7 +37,7 @@ module Ask
|
|
|
37
37
|
# re-raises as FetchError so callers can fail fast; any transient
|
|
38
38
|
# failure in the mix (timeout, 5xx, empty render) keeps the base
|
|
39
39
|
# Error, which recovers on retry.
|
|
40
|
-
DETERMINISTIC = [Ask::WebFetch::FetchError, Ask::WebFetch::EmptyContentError].freeze
|
|
40
|
+
DETERMINISTIC = [Ask::WebFetch::FetchError, Ask::WebFetch::NotFoundError, Ask::WebFetch::EmptyContentError].freeze
|
|
41
41
|
|
|
42
42
|
# Backend chain, tried in order. Crawl4AI leads when configured
|
|
43
43
|
# (CRAWL4AI_URL), so a present self-hosted renderer is preferred;
|
|
@@ -83,8 +83,17 @@ module Ask
|
|
|
83
83
|
# ParkedDomainError / EmptyContentError / FetchError are terminal —
|
|
84
84
|
# retrying never changes the answer; Error may recover on retry.
|
|
85
85
|
def self.collapse(failures, url)
|
|
86
|
-
|
|
87
|
-
|
|
86
|
+
# When all backends agree on the same root cause (same HTTP status),
|
|
87
|
+
# say so cleanly instead of listing every backend's echo of the same
|
|
88
|
+
# problem. Status is now on the error object itself (FetchError#status,
|
|
89
|
+
# NotFoundError#status), so we don't parse messages.
|
|
90
|
+
statuses = failures.filter_map { |_, e| e.respond_to?(:status) && e.status }
|
|
91
|
+
detail = if statuses.uniq.size == 1 && statuses.size == failures.size
|
|
92
|
+
"[#{statuses.first}]"
|
|
93
|
+
else
|
|
94
|
+
failures.map { |backend, e| "#{backend.backend_name}: #{e.message}" }.join('; ')
|
|
95
|
+
end
|
|
96
|
+
message = "#{detail} #{url}"
|
|
88
97
|
|
|
89
98
|
classes = failures.map { |_, e| e.class }
|
|
90
99
|
if classes.any? { |k| k <= Ask::WebFetch::ParkedDomainError }
|
|
@@ -93,6 +102,9 @@ module Ask
|
|
|
93
102
|
if classes.any? { |k| k <= Ask::WebFetch::EmptyContentError }
|
|
94
103
|
raise Ask::WebFetch::EmptyContentError, message
|
|
95
104
|
end
|
|
105
|
+
if classes.any? { |k| k == Ask::WebFetch::NotFoundError }
|
|
106
|
+
raise Ask::WebFetch::NotFoundError, message
|
|
107
|
+
end
|
|
96
108
|
|
|
97
109
|
deterministic = failures.all? { |_, e| DETERMINISTIC.any? { |klass| e.is_a?(klass) } }
|
|
98
110
|
raise(deterministic ? Ask::WebFetch::FetchError : Ask::WebFetch::Error, message)
|