ask-web-fetch 0.6.1 → 0.7.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/lib/ask/web_fetch/backend.rb +26 -1
- data/lib/ask/web_fetch/backends/browser.rb +4 -5
- data/lib/ask/web_fetch/backends/crawl4ai.rb +6 -6
- data/lib/ask/web_fetch/backends/jina.rb +5 -6
- data/lib/ask/web_fetch/backends/local.rb +6 -10
- data/lib/ask/web_fetch/version.rb +1 -1
- data/lib/ask/web_fetch.rb +104 -1
- metadata +8 -23
- data/lib/ask/web_fetch/tool.rb +0 -125
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6f73de0a29e2d37986a2b05a930640143f5566011782b5d68eed318bd82db746
|
|
4
|
+
data.tar.gz: 86ee4e5a800bf7fa67df0a652f40f0058ba5e1c1fa00e77a63b737017e61fbf0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9b3fe951669c892269d18304d4242786637df6b4f1ddd39aa4bfbc70d2241f90193701665f9141f5c7e816ba12e1a7bd5c48b98d2f0748ce1e093cfc70c5cd16
|
|
7
|
+
data.tar.gz: f372adee62b1ab72695509aa6bac61d105a9dc64e5209d368273c8667fc62145075b978bd572b426bd54a42770a31f0ff192245f9f37b8f9c1c9e121a3fa0360
|
|
@@ -44,7 +44,9 @@ module Ask
|
|
|
44
44
|
# Markdown.generate, backends fed pre-converted markdown (Jina,
|
|
45
45
|
# Crawl4AI) must call it explicitly so the shared noise removal
|
|
46
46
|
# and whitespace normalization apply everywhere
|
|
47
|
-
# 5.
|
|
47
|
+
# 5. run the extracted page through #guard_page! — the parked-domain
|
|
48
|
+
# and empty-content verdicts are identical in every backend
|
|
49
|
+
# 6. register the class in Ask::WebFetch.backends
|
|
48
50
|
#
|
|
49
51
|
# The tool tries each backend in order and returns the first success.
|
|
50
52
|
class Backend
|
|
@@ -92,6 +94,29 @@ module Ask
|
|
|
92
94
|
raise NotImplementedError, "#{self.class} must implement #fetch(url)"
|
|
93
95
|
end
|
|
94
96
|
|
|
97
|
+
# The shared page guard, run by EVERY backend at the same point in
|
|
98
|
+
# its flow — after extraction, before returning: a registrar parking
|
|
99
|
+
# page raises ParkedDomainError, content below the minimum raises
|
|
100
|
+
# EmptyContentError. Same verdicts, same messages, everywhere; a
|
|
101
|
+
# backend's only job is to pass the strings it has.
|
|
102
|
+
#
|
|
103
|
+
# raw_body: the raw HTML the backend saw, where it saw it (Local,
|
|
104
|
+
# Browser) — the HTML-only markers (ap:"parking", parking-lander,
|
|
105
|
+
# LANDER_SYSTEM="PW") live in scripts and assets that never survive
|
|
106
|
+
# conversion to markdown. content: what the backend would return
|
|
107
|
+
# (all four) — the prose markers survive conversion, so a backend
|
|
108
|
+
# that only ever sees rendered text (Jina, Crawl4AI) still rejects
|
|
109
|
+
# the ad.
|
|
110
|
+
#
|
|
111
|
+
# Parked is checked BEFORE the content minimum on purpose: a parking
|
|
112
|
+
# page can render above it (puncta.ai: 395c of Namecheap auction
|
|
113
|
+
# ads) and must still be rejected.
|
|
114
|
+
def guard_page!(url, content, raw_body: nil)
|
|
115
|
+
raise ParkedDomainError,
|
|
116
|
+
"parked domain at #{url} — registrar parking page, not site content" if parked_domain?(raw_body) || parked_domain?(content)
|
|
117
|
+
raise EmptyContentError, "no readable content at #{url}" unless usable_content?(content)
|
|
118
|
+
end
|
|
119
|
+
|
|
95
120
|
# --- outlinks (crawler discovery) ---
|
|
96
121
|
|
|
97
122
|
# The page's raw outlinks from HTML: every <a href> resolved against
|
|
@@ -201,13 +201,12 @@ module Ask
|
|
|
201
201
|
end
|
|
202
202
|
# Browser renders the parked page a JS redirect lands on (the
|
|
203
203
|
# server shell hands /lander to JS) — Local never sees it. The
|
|
204
|
-
# shared
|
|
205
|
-
#
|
|
206
|
-
|
|
207
|
-
|
|
204
|
+
# shared guard catches it on the rendered HTML (raw_body) and
|
|
205
|
+
# the converted content; the distinct ParkedDomainError lets the
|
|
206
|
+
# pipeline classify (never retry) it.
|
|
208
207
|
result = Markdown.generate(body, base_url: url, filter: self.class.content_filter)
|
|
209
208
|
result[:outlinks] = outlink_urls(body, url)
|
|
210
|
-
|
|
209
|
+
guard_page!(url, result[:content], raw_body: body)
|
|
211
210
|
|
|
212
211
|
result
|
|
213
212
|
end
|
|
@@ -56,12 +56,12 @@ module Ask
|
|
|
56
56
|
|
|
57
57
|
page = to_page(body, url)
|
|
58
58
|
# A registrar parking page renders fine in headless Chrome too —
|
|
59
|
-
# the shared
|
|
60
|
-
#
|
|
61
|
-
# the site's content. Crawl4AI leads
|
|
62
|
-
#
|
|
63
|
-
|
|
64
|
-
|
|
59
|
+
# the shared guard's prose markers catch it (the HTML-only
|
|
60
|
+
# markers never reach a markdown-only backend), so the ad is
|
|
61
|
+
# rejected, not returned as the site's content. Crawl4AI leads
|
|
62
|
+
# the default chain, so this guard is what keeps parked domains
|
|
63
|
+
# out of every result.
|
|
64
|
+
guard_page!(url, page[:content])
|
|
65
65
|
|
|
66
66
|
page
|
|
67
67
|
rescue Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNREFUSED,
|
|
@@ -35,18 +35,17 @@ module Ask
|
|
|
35
35
|
body = res.body.to_s
|
|
36
36
|
raise FetchError, 'challenge page from Jina' if challenge_page?(body)
|
|
37
37
|
|
|
38
|
+
content = Markdown.clean(body)
|
|
38
39
|
# Jina only sees rendered markdown — outlinks come from its
|
|
39
40
|
# links, resolved against the requested URL. Content runs
|
|
40
41
|
# through the same Markdown.clean as the converting backends,
|
|
41
42
|
# so decorative symbol noise is stripped here too; a page
|
|
42
43
|
# whose only "content" was noise falls through as empty. A
|
|
43
44
|
# registrar parking page renders fine through Jina — the
|
|
44
|
-
# shared
|
|
45
|
-
#
|
|
46
|
-
# the site's content.
|
|
47
|
-
content
|
|
48
|
-
raise ParkedDomainError, "parked domain at #{url} — registrar parking page, not site content" if parked_domain?(content)
|
|
49
|
-
raise EmptyContentError, 'empty response from Jina' unless usable_content?(content)
|
|
45
|
+
# shared guard's prose markers catch it (the HTML-only
|
|
46
|
+
# markers never reach a markdown-only backend), so the ad is
|
|
47
|
+
# rejected, not returned as the site's content.
|
|
48
|
+
guard_page!(url, content)
|
|
50
49
|
|
|
51
50
|
{ title: nil, description: nil, content: content, outlinks: markdown_outlinks(body, url) }
|
|
52
51
|
when '429'
|
|
@@ -48,16 +48,12 @@ module Ask
|
|
|
48
48
|
# Parked-domain pages are not content: the domain owner parked it
|
|
49
49
|
# with a registrar and the page is an ad for buying the domain
|
|
50
50
|
# (GoDaddy/Namecheap/Sedo parking). A content company must never
|
|
51
|
-
# store these as if they were the site.
|
|
52
|
-
#
|
|
53
|
-
#
|
|
54
|
-
#
|
|
55
|
-
#
|
|
56
|
-
|
|
57
|
-
if parked_domain?(body)
|
|
58
|
-
raise ParkedDomainError, "parked domain at #{url} — registrar parking page, not site content"
|
|
59
|
-
end
|
|
60
|
-
raise EmptyContentError, "no readable content at #{url}" unless usable_content?(page[:content])
|
|
51
|
+
# store these as if they were the site. The shared guard checks
|
|
52
|
+
# the raw server HTML first (parked pages are fully
|
|
53
|
+
# server-rendered — the HTML-only markers live in scripts and
|
|
54
|
+
# assets), then the content minimum, then the JS-shell
|
|
55
|
+
# completeness signal below.
|
|
56
|
+
guard_page!(url, page[:content], raw_body: body)
|
|
61
57
|
# The completeness signal: a JS-app shell whose server HTML
|
|
62
58
|
# renders little is a TRUNCATED page, not a complete one — the
|
|
63
59
|
# real content awaits client-side JS that Local cannot run.
|
data/lib/ask/web_fetch.rb
CHANGED
|
@@ -9,4 +9,107 @@ require_relative 'web_fetch/backends/local'
|
|
|
9
9
|
require_relative 'web_fetch/backends/crawl4ai'
|
|
10
10
|
require_relative 'web_fetch/backends/jina'
|
|
11
11
|
require_relative 'web_fetch/backends/browser'
|
|
12
|
-
|
|
12
|
+
|
|
13
|
+
module Ask
|
|
14
|
+
# Fetches a URL and returns its content as clean markdown for LLM
|
|
15
|
+
# consumption. The capability layer: a pluggable backend chain, a
|
|
16
|
+
# failure collapse, and one entry point. Tool framing — name,
|
|
17
|
+
# parameter schema, result wrapping — lives with the consumers (the
|
|
18
|
+
# MCP servers, the agents) that call this library, not here.
|
|
19
|
+
module WebFetch
|
|
20
|
+
DEFAULT_MAX_CHARS = 20_000
|
|
21
|
+
|
|
22
|
+
# Errors that mean "the URL is dead" — no amount of retrying changes
|
|
23
|
+
# the answer. When EVERY backend failed this way, the aggregate
|
|
24
|
+
# re-raises as FetchError so callers can fail fast; any transient
|
|
25
|
+
# failure in the mix (timeout, 5xx, empty render) keeps the base
|
|
26
|
+
# Error, which recovers on retry.
|
|
27
|
+
DETERMINISTIC = [Ask::WebFetch::FetchError, Ask::WebFetch::EmptyContentError].freeze
|
|
28
|
+
|
|
29
|
+
# Backend chain, tried in order. Crawl4AI leads when configured
|
|
30
|
+
# (CRAWL4AI_URL), so a present self-hosted renderer is preferred;
|
|
31
|
+
# otherwise Local, with Jina as the last resort, and Browser appended
|
|
32
|
+
# when Chrome is available. Swap or extend for future backends; each
|
|
33
|
+
# must subclass Ask::WebFetch::Backend and implement #fetch(url).
|
|
34
|
+
def self.backends
|
|
35
|
+
@backends ||= begin
|
|
36
|
+
chain = [Ask::WebFetch::Backends::Local, Ask::WebFetch::Backends::Jina]
|
|
37
|
+
if Ask::WebFetch::Backends::Crawl4Ai.configured?
|
|
38
|
+
chain.unshift(Ask::WebFetch::Backends::Crawl4Ai)
|
|
39
|
+
end
|
|
40
|
+
chain << Ask::WebFetch::Backends::Browser if Ask::WebFetch::Backends::Browser.configured?
|
|
41
|
+
chain
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
class << self
|
|
46
|
+
attr_writer :backends
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Fetches +url+ through the chain and returns the first success as
|
|
50
|
+
# { title:, description:, content:, outlinks:, redirected: }. Raises
|
|
51
|
+
# when every backend fails; the raised class carries the verdict (see
|
|
52
|
+
# #collapse) and the message lists every backend and what it said.
|
|
53
|
+
def self.fetch_page(url)
|
|
54
|
+
failures = []
|
|
55
|
+
backends.each do |backend_class|
|
|
56
|
+
return backend_class.new.fetch(url)
|
|
57
|
+
rescue Ask::WebFetch::Error => e
|
|
58
|
+
failures << [backend_class, e]
|
|
59
|
+
end
|
|
60
|
+
collapse(failures, url)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Collapses every backend's failure into ONE error whose class
|
|
64
|
+
# carries the best explanation. Precedence, most definitive first: a
|
|
65
|
+
# parked domain beats an empty shell (the shell IS the parking ad's
|
|
66
|
+
# shell — Local sees the JS redirect stub, Browser the lander),
|
|
67
|
+
# empty beats a dead 4xx (the page existed, it just had no content),
|
|
68
|
+
# and any deterministic explanation beats a transient one (transient
|
|
69
|
+
# keeps the retryable base Error). Clients read the class:
|
|
70
|
+
# ParkedDomainError / EmptyContentError / FetchError are terminal —
|
|
71
|
+
# retrying never changes the answer; Error may recover on retry.
|
|
72
|
+
def self.collapse(failures, url)
|
|
73
|
+
detail = failures.map { |backend, e| "#{backend.backend_name}: #{e.message}" }.join('; ')
|
|
74
|
+
message = "all web fetch backends failed for #{url} (#{detail})"
|
|
75
|
+
|
|
76
|
+
classes = failures.map { |_, e| e.class }
|
|
77
|
+
if classes.any? { |k| k <= Ask::WebFetch::ParkedDomainError }
|
|
78
|
+
raise Ask::WebFetch::ParkedDomainError, message
|
|
79
|
+
end
|
|
80
|
+
if classes.any? { |k| k <= Ask::WebFetch::EmptyContentError }
|
|
81
|
+
raise Ask::WebFetch::EmptyContentError, message
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
deterministic = failures.all? { |_, e| DETERMINISTIC.any? { |klass| e.is_a?(klass) } }
|
|
85
|
+
raise(deterministic ? Ask::WebFetch::FetchError : Ask::WebFetch::Error, message)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Fetches +url+ and returns LLM-ready markdown — "# Title\n\nSource:
|
|
89
|
+
# url\n\ncontent" — capped at +max_chars+ (default 20000; pass nil to
|
|
90
|
+
# skip the cap). The single entry point for "give me this page as
|
|
91
|
+
# markdown"; the raw page hash is #fetch_page.
|
|
92
|
+
def self.fetch(url, max_chars: DEFAULT_MAX_CHARS)
|
|
93
|
+
page = fetch_page(url)
|
|
94
|
+
markdown = format(page, url)
|
|
95
|
+
markdown = truncate(markdown, max_chars) if max_chars&.positive?
|
|
96
|
+
markdown
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def self.format(page, url)
|
|
100
|
+
header = +''
|
|
101
|
+
title = page[:title]
|
|
102
|
+
header << "# #{title}\n\n" unless title.to_s.empty?
|
|
103
|
+
header << "Source: #{url}\n\n"
|
|
104
|
+
header + page[:content]
|
|
105
|
+
end
|
|
106
|
+
private_class_method :format
|
|
107
|
+
|
|
108
|
+
def self.truncate(markdown, max_chars)
|
|
109
|
+
return markdown if markdown.length <= max_chars
|
|
110
|
+
|
|
111
|
+
"#{markdown[0, max_chars].rstrip}\n\n…(truncated)"
|
|
112
|
+
end
|
|
113
|
+
private_class_method :truncate
|
|
114
|
+
end
|
|
115
|
+
end
|
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.
|
|
4
|
+
version: 0.7.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Kaka Ruto
|
|
@@ -9,20 +9,6 @@ bindir: bin
|
|
|
9
9
|
cert_chain: []
|
|
10
10
|
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
|
-
- !ruby/object:Gem::Dependency
|
|
13
|
-
name: ask-tools
|
|
14
|
-
requirement: !ruby/object:Gem::Requirement
|
|
15
|
-
requirements:
|
|
16
|
-
- - ">="
|
|
17
|
-
- !ruby/object:Gem::Version
|
|
18
|
-
version: '0.1'
|
|
19
|
-
type: :runtime
|
|
20
|
-
prerelease: false
|
|
21
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
-
requirements:
|
|
23
|
-
- - ">="
|
|
24
|
-
- !ruby/object:Gem::Version
|
|
25
|
-
version: '0.1'
|
|
26
12
|
- !ruby/object:Gem::Dependency
|
|
27
13
|
name: ferrum
|
|
28
14
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -121,12 +107,12 @@ dependencies:
|
|
|
121
107
|
- - "~>"
|
|
122
108
|
- !ruby/object:Gem::Version
|
|
123
109
|
version: '3.26'
|
|
124
|
-
description:
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
110
|
+
description: 'Fetches a URL and converts its content to clean markdown for LLM consumption.
|
|
111
|
+
A pluggable backend chain: pure Ruby httpx + Nokogiri + reverse_markdown by default,
|
|
112
|
+
a Jina Reader fallback for JS-rendered or blocked pages, and a real-Chrome fallback
|
|
113
|
+
(Ferrum) that renders JavaScript and lets auto-solving Cloudflare challenges complete.
|
|
114
|
+
The capability layer only — tool framing (MCP servers, agent tools) is provided
|
|
115
|
+
by the consumers.'
|
|
130
116
|
email:
|
|
131
117
|
- kaka@myrrlabs.com
|
|
132
118
|
executables: []
|
|
@@ -147,7 +133,6 @@ files:
|
|
|
147
133
|
- lib/ask/web_fetch/http.rb
|
|
148
134
|
- lib/ask/web_fetch/markdown.rb
|
|
149
135
|
- lib/ask/web_fetch/noise_filter.rb
|
|
150
|
-
- lib/ask/web_fetch/tool.rb
|
|
151
136
|
- lib/ask/web_fetch/version.rb
|
|
152
137
|
homepage: https://github.com/ask-rb/ask-web-fetch
|
|
153
138
|
licenses:
|
|
@@ -173,5 +158,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
173
158
|
requirements: []
|
|
174
159
|
rubygems_version: 4.0.3
|
|
175
160
|
specification_version: 4
|
|
176
|
-
summary: Web fetch
|
|
161
|
+
summary: Web fetch library for the ask-rb ecosystem
|
|
177
162
|
test_files: []
|
data/lib/ask/web_fetch/tool.rb
DELETED
|
@@ -1,125 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require 'ask-tools'
|
|
4
|
-
require_relative '../web_fetch/backend'
|
|
5
|
-
require_relative '../web_fetch/backends/local'
|
|
6
|
-
require_relative '../web_fetch/backends/crawl4ai'
|
|
7
|
-
require_relative '../web_fetch/backends/jina'
|
|
8
|
-
|
|
9
|
-
module Ask
|
|
10
|
-
module Tools
|
|
11
|
-
# Fetches a URL and returns its content as clean markdown for LLM
|
|
12
|
-
# consumption. Tries each configured backend in order and returns the
|
|
13
|
-
# first success.
|
|
14
|
-
#
|
|
15
|
-
# Chain: Crawl4AI first (self-hosted headless-Chromium renderer; used
|
|
16
|
-
# when the CRAWL4AI_URL service is present, fails fast when it isn't),
|
|
17
|
-
# then the local fetcher, Jina Reader as the last resort, and a real
|
|
18
|
-
# Chrome (via Ferrum) at the very end for pages whose Cloudflare-style
|
|
19
|
-
# challenges the others cannot pass — appended only when a browser
|
|
20
|
-
# binary is present.
|
|
21
|
-
class WebFetch < Ask::Tool
|
|
22
|
-
DEFAULT_MAX_CHARS = 20_000
|
|
23
|
-
|
|
24
|
-
# Errors that mean "the URL is dead" — no amount of retrying changes
|
|
25
|
-
# the answer. When EVERY backend failed this way, the aggregate
|
|
26
|
-
# re-raises as FetchError so callers can fail fast; any transient
|
|
27
|
-
# failure in the mix (timeout, 5xx, empty render) keeps the base
|
|
28
|
-
# Error, which recovers on retry.
|
|
29
|
-
DETERMINISTIC = [Ask::WebFetch::FetchError, Ask::WebFetch::EmptyContentError].freeze
|
|
30
|
-
|
|
31
|
-
# Backend chain, tried in order. Crawl4AI leads when configured
|
|
32
|
-
# (CRAWL4AI_URL), so a present self-hosted renderer is preferred;
|
|
33
|
-
# otherwise Local, with Jina as the last resort, and Browser appended
|
|
34
|
-
# when Chrome is available. Swap or extend for future backends; each
|
|
35
|
-
# must subclass Ask::WebFetch::Backend and implement #fetch(url).
|
|
36
|
-
def self.backends
|
|
37
|
-
@backends ||= begin
|
|
38
|
-
chain = [Ask::WebFetch::Backends::Local, Ask::WebFetch::Backends::Jina]
|
|
39
|
-
if Ask::WebFetch::Backends::Crawl4Ai.configured?
|
|
40
|
-
chain.unshift(Ask::WebFetch::Backends::Crawl4Ai)
|
|
41
|
-
end
|
|
42
|
-
chain << Ask::WebFetch::Backends::Browser if Ask::WebFetch::Backends::Browser.configured?
|
|
43
|
-
chain
|
|
44
|
-
end
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
class << self
|
|
48
|
-
attr_writer :backends
|
|
49
|
-
|
|
50
|
-
# Collapses every backend's failure into ONE error whose class
|
|
51
|
-
# carries the best explanation. Precedence, most definitive first: a
|
|
52
|
-
# parked domain beats an empty shell (the shell IS the parking ad's
|
|
53
|
-
# shell — Local sees the JS redirect stub, Browser the lander),
|
|
54
|
-
# empty beats a dead 4xx (the page existed, it just had no content),
|
|
55
|
-
# and any deterministic explanation beats a transient one (transient
|
|
56
|
-
# keeps the retryable base Error). Clients read the class:
|
|
57
|
-
# ParkedDomainError / EmptyContentError / FetchError are terminal —
|
|
58
|
-
# retrying never changes the answer; Error may recover on retry.
|
|
59
|
-
def collapse(failures, url)
|
|
60
|
-
detail = failures.map { |backend, e| "#{backend.backend_name}: #{e.message}" }.join('; ')
|
|
61
|
-
message = "all web fetch backends failed for #{url} (#{detail})"
|
|
62
|
-
|
|
63
|
-
classes = failures.map { |_, e| e.class }
|
|
64
|
-
if classes.any? { |k| k <= Ask::WebFetch::ParkedDomainError }
|
|
65
|
-
raise Ask::WebFetch::ParkedDomainError, message
|
|
66
|
-
end
|
|
67
|
-
if classes.any? { |k| k <= Ask::WebFetch::EmptyContentError }
|
|
68
|
-
raise Ask::WebFetch::EmptyContentError, message
|
|
69
|
-
end
|
|
70
|
-
|
|
71
|
-
deterministic = failures.all? { |_, e| DETERMINISTIC.any? { |klass| e.is_a?(klass) } }
|
|
72
|
-
raise(deterministic ? Ask::WebFetch::FetchError : Ask::WebFetch::Error, message)
|
|
73
|
-
end
|
|
74
|
-
end
|
|
75
|
-
|
|
76
|
-
description 'Fetch a URL and return its content as clean markdown for LLM consumption. ' \
|
|
77
|
-
'Use this to read web pages, articles, and documentation.'
|
|
78
|
-
|
|
79
|
-
params(
|
|
80
|
-
type: 'object',
|
|
81
|
-
properties: {
|
|
82
|
-
url: { type: 'string', description: 'The URL to fetch' },
|
|
83
|
-
max_chars: { type: 'integer', description: 'Maximum number of characters to return (default 20000)' }
|
|
84
|
-
},
|
|
85
|
-
required: ['url']
|
|
86
|
-
)
|
|
87
|
-
|
|
88
|
-
def execute(url:, max_chars: DEFAULT_MAX_CHARS)
|
|
89
|
-
page = fetch_page(url)
|
|
90
|
-
markdown = format(page, url)
|
|
91
|
-
markdown = truncate(markdown, max_chars) if max_chars&.positive?
|
|
92
|
-
Ask::Result.ok(data: markdown)
|
|
93
|
-
end
|
|
94
|
-
|
|
95
|
-
private
|
|
96
|
-
|
|
97
|
-
# Try each configured backend in order; return the first success.
|
|
98
|
-
def fetch_page(url)
|
|
99
|
-
failures = []
|
|
100
|
-
self.class.backends.each do |backend_class|
|
|
101
|
-
return backend_class.new.fetch(url)
|
|
102
|
-
rescue Ask::WebFetch::Error => e
|
|
103
|
-
failures << [backend_class, e]
|
|
104
|
-
end
|
|
105
|
-
self.class.collapse(failures, url)
|
|
106
|
-
end
|
|
107
|
-
|
|
108
|
-
def format(page, url)
|
|
109
|
-
header = +''
|
|
110
|
-
title = page[:title]
|
|
111
|
-
header << "# #{title}\n\n" unless title.to_s.empty?
|
|
112
|
-
header << "Source: #{url}\n\n"
|
|
113
|
-
header + page[:content]
|
|
114
|
-
end
|
|
115
|
-
|
|
116
|
-
def truncate(markdown, max_chars)
|
|
117
|
-
return markdown if markdown.length <= max_chars
|
|
118
|
-
|
|
119
|
-
"#{markdown[0, max_chars].rstrip}\n\n…(truncated)"
|
|
120
|
-
end
|
|
121
|
-
end
|
|
122
|
-
end
|
|
123
|
-
end
|
|
124
|
-
|
|
125
|
-
Ask::Tools.register(Ask::Tools::WebFetch) if defined?(Ask::Tools)
|