ask-web-fetch 0.7.2 → 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
|
#
|
|
@@ -66,7 +87,14 @@ module Ask
|
|
|
66
87
|
# challenge/interstitial pages carry these markers, while legitimate
|
|
67
88
|
# pages can contain the word "captcha" in unrelated config/JS (e.g.
|
|
68
89
|
# Wikipedia embeds an hcaptcha edit-config flag on every page).
|
|
69
|
-
|
|
90
|
+
# Cf-chl: a Turnstile form widget uses `cf-chl-widget-*` + `cf-turnstile-response`
|
|
91
|
+
# (a per-form CAPTCHA, not the `cf-chl` managed challenge that gates
|
|
92
|
+
# the whole page), so matching bare `cf-chl` on the body misclassifies
|
|
93
|
+
# every Turnstile form (openai.com/form/codex-for-oss) as a challenge.
|
|
94
|
+
# Accept both the classic managed-challenge markers (`challenge-platform`,
|
|
95
|
+
# `_cf_chl_opt`) and the bare `cf-chl` id, but the plain widget id is
|
|
96
|
+
# explicitly NOT a challenge — see challenge_page? below.
|
|
97
|
+
CHALLENGE_RE = /just a moment|checking your browser|cf-chl|challenge-platform|_cf_chl_opt/i
|
|
70
98
|
|
|
71
99
|
# Registrar parking-page markers: the page is an ad for a parked
|
|
72
100
|
# (for-sale) domain, not the site's content. A content company must
|
|
@@ -159,7 +187,17 @@ module Ask
|
|
|
159
187
|
private
|
|
160
188
|
|
|
161
189
|
def challenge_page?(body)
|
|
162
|
-
body.to_s
|
|
190
|
+
text = body.to_s
|
|
191
|
+
return false unless text.match?(CHALLENGE_RE)
|
|
192
|
+
# Bare `cf-chl-widget-*` is a Turnstile form widget, not the
|
|
193
|
+
# Cloudflare managed challenge interstitial. The interstitial's
|
|
194
|
+
# `cf-chl` comes with `challenge-platform` / `_cf_chl_opt` /
|
|
195
|
+
# "just a moment" next to it; a page whose only hit is the widget
|
|
196
|
+
# id (openai.com form pages) is NOT a challenge.
|
|
197
|
+
return false if text.include?('cf-chl-widget') &&
|
|
198
|
+
!text.match?(/challenge-platform|_cf_chl_opt|just a moment|checking your browser/i)
|
|
199
|
+
|
|
200
|
+
true
|
|
163
201
|
end
|
|
164
202
|
|
|
165
203
|
def parked_domain?(body)
|
|
@@ -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
|
|
@@ -38,13 +38,42 @@ module Ask
|
|
|
38
38
|
end
|
|
39
39
|
end
|
|
40
40
|
|
|
41
|
+
# Agent-first content negotiation: many sites now serve clean
|
|
42
|
+
# markdown when asked via Accept: text/markdown, a .md URL twin,
|
|
43
|
+
# or an /llms.txt manifest. We probe these low-cost paths before
|
|
44
|
+
# falling back to full HTML scrape + DOM conversion.
|
|
45
|
+
#
|
|
46
|
+
# Order: (1) Accept: text/markdown on the original URL — the
|
|
47
|
+
# cheapest probe, one extra GET; (2) the .md twin — Mintlify-
|
|
48
|
+
# style sites redirect .md with content-type text/plain; (3) the
|
|
49
|
+
# full HTML scrape. llms.txt manifests are upstream of individual
|
|
50
|
+
# pages (they index the site) and are tried by the MCP tool
|
|
51
|
+
# layer, not per-URL — that avoids duplicate fetches when the
|
|
52
|
+
# same manifest covers multiple URLs.
|
|
41
53
|
def fetch(url)
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
54
|
+
# Probe 1: server content negotiation
|
|
55
|
+
md_body, md_ct, md_redirect = fetch_markdown(url)
|
|
56
|
+
if md_body && !md_body.empty?
|
|
57
|
+
return assemble_page(md_body, url, md_redirect, source: :accept_header)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Probe 2: .md URL twin (Mintlify, Docusaurus, some Hugo sites)
|
|
61
|
+
twin = "#{url.chomp('/')}.md"
|
|
62
|
+
md_body, md_ct, md_redirect = fetch_markdown(twin)
|
|
63
|
+
if md_body && !md_body.empty?
|
|
64
|
+
return assemble_page(md_body, url, md_redirect, source: :md_twin, twin_url: twin)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Probe 3: full HTML scrape (legacy path)
|
|
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)
|
|
45
73
|
|
|
46
74
|
page = to_markdown(body, url)
|
|
47
75
|
page[:redirected] = redirect
|
|
76
|
+
page[:status] = status
|
|
48
77
|
# Parked-domain pages are not content: the domain owner parked it
|
|
49
78
|
# with a registrar and the page is an ad for buying the domain
|
|
50
79
|
# (GoDaddy/Namecheap/Sedo parking). A content company must never
|
|
@@ -53,6 +82,14 @@ module Ask
|
|
|
53
82
|
# server-rendered — the HTML-only markers live in scripts and
|
|
54
83
|
# assets), then the content minimum, then the JS-shell
|
|
55
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
|
+
|
|
56
93
|
guard_page!(url, page[:content], raw_body: body)
|
|
57
94
|
# The completeness signal: a JS-app shell whose server HTML
|
|
58
95
|
# renders little is a TRUNCATED page, not a complete one — the
|
|
@@ -157,8 +194,8 @@ module Ask
|
|
|
157
194
|
end
|
|
158
195
|
|
|
159
196
|
# GET with redirect following (max MAX_REDIRECTS hops). Returns
|
|
160
|
-
# [body, content_type, redirect] where redirect is nil
|
|
161
|
-
# 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,
|
|
162
199
|
# url: final destination} — the chain the crawler followed.
|
|
163
200
|
def fetch_html(url)
|
|
164
201
|
uri = URI(url)
|
|
@@ -166,7 +203,9 @@ module Ask
|
|
|
166
203
|
first_hop_status = nil
|
|
167
204
|
loop do
|
|
168
205
|
response = self.class.http.get(uri.to_s, headers: { 'accept' => 'text/html,application/xhtml+xml' })
|
|
169
|
-
|
|
206
|
+
if (200..299).cover?(response.status)
|
|
207
|
+
return [response.body, response.content_type, redirect_info(first_hop_status, uri), response.status]
|
|
208
|
+
end
|
|
170
209
|
|
|
171
210
|
unless (300..399).cover?(response.status) && !response.location.empty?
|
|
172
211
|
# 4xx (other than 429) = the URL is dead; 429/5xx = transient.
|
|
@@ -183,6 +222,60 @@ module Ask
|
|
|
183
222
|
def redirect_info(status, uri)
|
|
184
223
|
status && { status: status, url: uri.to_s }
|
|
185
224
|
end
|
|
225
|
+
|
|
226
|
+
# Probes +url+ with Accept: text/markdown. Returns
|
|
227
|
+
# [body, content_type, redirect] on a text/markdown response, or
|
|
228
|
+
# nils when the server returned HTML (or anything else the caller
|
|
229
|
+
# shouldn't treat as agent-native). Follows one redirect hop —
|
|
230
|
+
# enough for Mintlify's 307 → .md twin.
|
|
231
|
+
def fetch_markdown(url)
|
|
232
|
+
uri = URI(url)
|
|
233
|
+
response = self.class.http.get(
|
|
234
|
+
uri.to_s,
|
|
235
|
+
headers: { 'accept' => 'text/markdown' }
|
|
236
|
+
)
|
|
237
|
+
return [nil, nil, nil] unless response
|
|
238
|
+
return [nil, nil, nil] if response.status >= 400
|
|
239
|
+
|
|
240
|
+
redirect_info = nil
|
|
241
|
+
|
|
242
|
+
# Follow a single redirect (Mintlify 307 → .md twin)
|
|
243
|
+
if (300..399).cover?(response.status) && !response.location.empty?
|
|
244
|
+
redirect_uri = URI.join(uri, response.location)
|
|
245
|
+
redirect_info = { status: response.status, url: redirect_uri.to_s }
|
|
246
|
+
response = self.class.http.get(
|
|
247
|
+
redirect_uri.to_s,
|
|
248
|
+
headers: { 'accept' => 'text/markdown' }
|
|
249
|
+
)
|
|
250
|
+
return [nil, nil, nil] unless response && response.status == 200
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
ct = response.content_type.to_s.downcase
|
|
254
|
+
return [nil, nil, nil] unless ct.include?('text/markdown') && response.status == 200
|
|
255
|
+
|
|
256
|
+
[response.body, ct, redirect_info]
|
|
257
|
+
rescue StandardError
|
|
258
|
+
[nil, nil, nil]
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Assembles a page hash from agent-native markdown (Accept or .md
|
|
262
|
+
# twin), skipping the HTML→markdown conversion pipeline. Runs the
|
|
263
|
+
# shared guards (parked domain, minimum content) so downstream
|
|
264
|
+
# behavior is identical regardless of source.
|
|
265
|
+
def assemble_page(markdown, url, redirect, source:, twin_url: nil)
|
|
266
|
+
source_url = twin_url || url
|
|
267
|
+
page = {
|
|
268
|
+
title: nil,
|
|
269
|
+
description: nil,
|
|
270
|
+
content: Markdown.clean(markdown),
|
|
271
|
+
redirected: redirect,
|
|
272
|
+
licenses: [],
|
|
273
|
+
outlinks: markdown_outlinks(markdown, source_url)
|
|
274
|
+
}
|
|
275
|
+
guard_page!(url, page[:content])
|
|
276
|
+
|
|
277
|
+
page
|
|
278
|
+
end
|
|
186
279
|
end
|
|
187
280
|
end
|
|
188
281
|
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
|
-
|
|
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)
|