ask-web-fetch 0.6.2 → 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 +1 -1
- 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
|
|
@@ -46,7 +46,7 @@ module Ask
|
|
|
46
46
|
# and whitespace normalization apply everywhere
|
|
47
47
|
# 5. run the extracted page through #guard_page! — the parked-domain
|
|
48
48
|
# and empty-content verdicts are identical in every backend
|
|
49
|
-
# 6. register the class in Ask::
|
|
49
|
+
# 6. register the class in Ask::WebFetch.backends
|
|
50
50
|
#
|
|
51
51
|
# The tool tries each backend in order and returns the first success.
|
|
52
52
|
class Backend
|
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)
|