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: b2dd2d3ee4ae5d53f7e1bf441d3b598be5195d3aafd58119213b7b739ad900db
4
- data.tar.gz: 49d8aa803e8f4871e278d6fbe7ad71b6a6826c5afe62c8dc8f38abc2b8b4cb8e
3
+ metadata.gz: 412118d0b9d8d15d1677f877dcdd06cc3ca3d64f6afb79be80ae7fb404bdc8c5
4
+ data.tar.gz: 18a022bec912f32ecd7f1afca82c11e7e5f016199779b92fec86ec5deb1428a7
5
5
  SHA512:
6
- metadata.gz: 95f40993df27de4c059f085ece2d71b5ea8c1e72c2d3d3dc42e96d25a1d5d87a400f5905cbaf6685675eb064ddebe95fa110609f16b93f586a5a31e02e17917c
7
- data.tar.gz: a5b8513eef5cad48fbed247a2578e662c42a401b7b8e16028e5b6fbfa90d6b28da9e3db5786c20d43182ececf5a490ff98338a4fbf3ba286b0e4f9123210dea2
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 — 4xx,
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; end
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; end
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
- raise FetchError, "got #{status} at #{url}" if status && status >= 400
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, 'rate limited by Jina (429)'
52
+ raise ServerError.new('rate limited by Jina', status: 429)
53
53
  when '401', '403'
54
- raise FetchError, "Jina access error (#{res.code})"
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
- raise(res.code.to_i >= 500 ? ServerError : FetchError, "Jina returned #{res.code}")
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
- raise FetchError, "expected HTML from #{url}, got #{content_type}" unless content_type.include?('html')
70
- raise FetchError, "challenge page at #{url}" if challenge_page?(body)
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 when the
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
- return [response.body, response.content_type, redirect_info(first_hop_status, uri)] if (200..299).cover?(response.status)
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.
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ask
4
4
  module WebFetch
5
- VERSION = '0.7.3'
5
+ VERSION = '0.7.4'
6
6
  end
7
7
  end
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
- detail = failures.map { |backend, e| "#{backend.backend_name}: #{e.message}" }.join('; ')
87
- message = "all web fetch backends failed for #{url} (#{detail})"
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)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ask-web-fetch
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.3
4
+ version: 0.7.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto