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: 3ad48d09aa8869b4193a703ad6a760753c2767c9e26a6919b1405a5fb186c7a4
4
- data.tar.gz: 52960016a3d4740f4913d4879cbf80edacf95e4ffc07601e6dde0d80515ffe81
3
+ metadata.gz: 412118d0b9d8d15d1677f877dcdd06cc3ca3d64f6afb79be80ae7fb404bdc8c5
4
+ data.tar.gz: 18a022bec912f32ecd7f1afca82c11e7e5f016199779b92fec86ec5deb1428a7
5
5
  SHA512:
6
- metadata.gz: fa6d778abc6918bb7ae839d4f9c96dafe215014a8b2f4efc8027ab1e49d287e3ca57204f54f013531e38dff7393ac23f6da9619b3afd48efae8b6f7e42dfa0d9
7
- data.tar.gz: 7d4e5fe0e70b28cc3349ec96d071d4e1cc7a46c2c21ca8f41c35968d8b15221854c2418fc72ae2a8e56e97c07930592b2ee5b79da921c4a4ccb4fe6990c7e938
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
  #
@@ -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
- CHALLENGE_RE = /just a moment|checking your browser|cf-chl/i
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.match?(CHALLENGE_RE)
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
- 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
@@ -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
- body, content_type, redirect = fetch_html(url)
43
- raise FetchError, "expected HTML from #{url}, got #{content_type}" unless content_type.include?('html')
44
- raise FetchError, "challenge page at #{url}" if challenge_page?(body)
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 when the
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
- 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
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ask
4
4
  module WebFetch
5
- VERSION = '0.7.2'
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.2
4
+ version: 0.7.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto