ask-web-fetch 0.6.2 → 0.7.1

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: 5e7b287b5b62f821cb0b3085082b385583d3f1a7ef2dfc0816eb311931d1e218
4
- data.tar.gz: 534bce458033fb046914c169b7bb7bad982f8fcd3ffb999d6feb2b495953d90c
3
+ metadata.gz: efa999aac8d4321c2e8e6d463ace6c3f5ec5e95a62973ad06593c4714e1ffab1
4
+ data.tar.gz: 9a9e59e8da81bb4238bf1a08b0ba84634553dfd3caac99763a7bb52a54d9ac6e
5
5
  SHA512:
6
- metadata.gz: 48e47dd345f498f1a50b6dc3ddb09a5f55078691ee34a617b02936fb9b7d639c2ff55c309f6fc2ecec2a4d067c487edd0975c983b4ed301471d50536e73e6bd0
7
- data.tar.gz: 7bf1c58c8fb17a9c5575b41f8b2d44c6deb1c01a8b82b56b3d73f5d1df4c5d72fadf09ca60de1fc3995b8dc62581611f4aa50bcb4c47ceb9894f5bb6749490fc
6
+ metadata.gz: af1e312f91b9e5ea72679b7e877232cc3e4e9639b3fdf3e98291810377a450a5ce180bb4664ca651c8ccd2084220632890da92fe4eaf8c1ca472e6ba464d60ff
7
+ data.tar.gz: 3951989986d476f6bc008712e6451ccb148c9fbe33537544061486d15c79443e0368dd668ef40b35287173ad41f569fc519130a9cb5368e78da16eafbcdf2c91
@@ -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::Tools::WebFetch.backends
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
@@ -1,77 +1,26 @@
1
1
  # frozen_string_literal: true
2
2
 
3
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
4
 
9
5
  module Ask
10
6
  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.
7
+ # The native agent tool for web fetch the Ask::Tool framing of the
8
+ # library. Consumed WITHOUT MCP by agent frameworks (ask-agent's
9
+ # `tool: :web_fetch`, ask-app-server, llm-proxy) that resolve tools
10
+ # from the Ask::Tools registry. It is a pure adapter: the capability
11
+ # (chain, collapse, format) lives in Ask::WebFetch, and this file is
12
+ # loaded only when ask-tools is present (see lib/ask/web_fetch.rb), so
13
+ # the library itself never depends on it.
21
14
  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).
15
+ # Chain configuration delegates to the library — swap or extend
16
+ # backends here or via Ask::WebFetch.backends.
36
17
  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
18
+ Ask::WebFetch.backends
45
19
  end
46
20
 
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)
21
+ def self.backends=(chain)
22
+ Ask::WebFetch.backends = chain
73
23
  end
74
- end
75
24
 
76
25
  description 'Fetch a URL and return its content as clean markdown for LLM consumption. ' \
77
26
  'Use this to read web pages, articles, and documentation.'
@@ -85,41 +34,11 @@ module Ask
85
34
  required: ['url']
86
35
  )
87
36
 
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)"
37
+ def execute(url:, max_chars: Ask::WebFetch::DEFAULT_MAX_CHARS)
38
+ Ask::Result.ok(data: Ask::WebFetch.fetch(url, max_chars: max_chars))
120
39
  end
121
40
  end
122
41
  end
123
42
  end
124
43
 
125
- Ask::Tools.register(Ask::Tools::WebFetch) if defined?(Ask::Tools)
44
+ Ask::Tools.register(Ask::Tools::WebFetch)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ask
4
4
  module WebFetch
5
- VERSION = '0.6.2'
5
+ VERSION = '0.7.1'
6
6
  end
7
7
  end
data/lib/ask/web_fetch.rb CHANGED
@@ -9,4 +9,120 @@ 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
- require_relative 'web_fetch/tool'
12
+
13
+ # The native agent tool (Ask::Tools::WebFetch) is an OPTIONAL integration:
14
+ # it loads and registers only when ask-tools is present. The library works
15
+ # standalone — backend-only consumers (crawlers, pipelines) pay nothing —
16
+ # while agent frameworks (ask-agent, ask-app-server, llm-proxy) all ship
17
+ # ask-tools and get the registry tool with no extra step. Only the
18
+ # ask-tools miss is swallowed; any other LoadError is real.
19
+ begin
20
+ require 'ask-tools'
21
+ require_relative 'web_fetch/tool'
22
+ rescue LoadError => e
23
+ raise unless e.path == 'ask-tools'
24
+ end
25
+
26
+ module Ask
27
+ # Fetches a URL and returns its content as clean markdown for LLM
28
+ # consumption. The capability layer: a pluggable backend chain, a
29
+ # failure collapse, and one entry point. Tool framing — name,
30
+ # parameter schema, result wrapping — lives with the consumers (the
31
+ # MCP servers, the agents) that call this library, not here.
32
+ module WebFetch
33
+ DEFAULT_MAX_CHARS = 20_000
34
+
35
+ # Errors that mean "the URL is dead" — no amount of retrying changes
36
+ # the answer. When EVERY backend failed this way, the aggregate
37
+ # re-raises as FetchError so callers can fail fast; any transient
38
+ # failure in the mix (timeout, 5xx, empty render) keeps the base
39
+ # Error, which recovers on retry.
40
+ DETERMINISTIC = [Ask::WebFetch::FetchError, Ask::WebFetch::EmptyContentError].freeze
41
+
42
+ # Backend chain, tried in order. Crawl4AI leads when configured
43
+ # (CRAWL4AI_URL), so a present self-hosted renderer is preferred;
44
+ # otherwise Local, with Jina as the last resort, and Browser appended
45
+ # when Chrome is available. Swap or extend for future backends; each
46
+ # must subclass Ask::WebFetch::Backend and implement #fetch(url).
47
+ def self.backends
48
+ @backends ||= begin
49
+ chain = [Ask::WebFetch::Backends::Local, Ask::WebFetch::Backends::Jina]
50
+ if Ask::WebFetch::Backends::Crawl4Ai.configured?
51
+ chain.unshift(Ask::WebFetch::Backends::Crawl4Ai)
52
+ end
53
+ chain << Ask::WebFetch::Backends::Browser if Ask::WebFetch::Backends::Browser.configured?
54
+ chain
55
+ end
56
+ end
57
+
58
+ class << self
59
+ attr_writer :backends
60
+ end
61
+
62
+ # Fetches +url+ through the chain and returns the first success as
63
+ # { title:, description:, content:, outlinks:, redirected: }. Raises
64
+ # when every backend fails; the raised class carries the verdict (see
65
+ # #collapse) and the message lists every backend and what it said.
66
+ def self.fetch_page(url)
67
+ failures = []
68
+ backends.each do |backend_class|
69
+ return backend_class.new.fetch(url)
70
+ rescue Ask::WebFetch::Error => e
71
+ failures << [backend_class, e]
72
+ end
73
+ collapse(failures, url)
74
+ end
75
+
76
+ # Collapses every backend's failure into ONE error whose class
77
+ # carries the best explanation. Precedence, most definitive first: a
78
+ # parked domain beats an empty shell (the shell IS the parking ad's
79
+ # shell — Local sees the JS redirect stub, Browser the lander),
80
+ # empty beats a dead 4xx (the page existed, it just had no content),
81
+ # and any deterministic explanation beats a transient one (transient
82
+ # keeps the retryable base Error). Clients read the class:
83
+ # ParkedDomainError / EmptyContentError / FetchError are terminal —
84
+ # retrying never changes the answer; Error may recover on retry.
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})"
88
+
89
+ classes = failures.map { |_, e| e.class }
90
+ if classes.any? { |k| k <= Ask::WebFetch::ParkedDomainError }
91
+ raise Ask::WebFetch::ParkedDomainError, message
92
+ end
93
+ if classes.any? { |k| k <= Ask::WebFetch::EmptyContentError }
94
+ raise Ask::WebFetch::EmptyContentError, message
95
+ end
96
+
97
+ deterministic = failures.all? { |_, e| DETERMINISTIC.any? { |klass| e.is_a?(klass) } }
98
+ raise(deterministic ? Ask::WebFetch::FetchError : Ask::WebFetch::Error, message)
99
+ end
100
+
101
+ # Fetches +url+ and returns LLM-ready markdown — "# Title\n\nSource:
102
+ # url\n\ncontent" — capped at +max_chars+ (default 20000; pass nil to
103
+ # skip the cap). The single entry point for "give me this page as
104
+ # markdown"; the raw page hash is #fetch_page.
105
+ def self.fetch(url, max_chars: DEFAULT_MAX_CHARS)
106
+ page = fetch_page(url)
107
+ markdown = format(page, url)
108
+ markdown = truncate(markdown, max_chars) if max_chars&.positive?
109
+ markdown
110
+ end
111
+
112
+ def self.format(page, url)
113
+ header = +''
114
+ title = page[:title]
115
+ header << "# #{title}\n\n" unless title.to_s.empty?
116
+ header << "Source: #{url}\n\n"
117
+ header + page[:content]
118
+ end
119
+ private_class_method :format
120
+
121
+ def self.truncate(markdown, max_chars)
122
+ return markdown if markdown.length <= max_chars
123
+
124
+ "#{markdown[0, max_chars].rstrip}\n\n…(truncated)"
125
+ end
126
+ private_class_method :truncate
127
+ end
128
+ 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.6.2
4
+ version: 0.7.1
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,26 @@ dependencies:
121
107
  - - "~>"
122
108
  - !ruby/object:Gem::Version
123
109
  version: '3.26'
124
- description: Provides Ask::Tools::WebFetch, a tool that fetches a URL and converts
125
- its content to clean markdown for LLM consumption. Defaults to a pure Ruby backend
126
- (httpx + Nokogiri + reverse_markdown) with a Jina Reader fallback for JS-rendered
127
- or blocked pages, and a real-Chrome fallback (Ferrum) that renders JavaScript and
128
- lets auto-solving Cloudflare challenges complete. Works with any ask-rb chat or
129
- agent.
110
+ - !ruby/object:Gem::Dependency
111
+ name: ask-tools
112
+ requirement: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - ">="
115
+ - !ruby/object:Gem::Version
116
+ version: '0.1'
117
+ type: :development
118
+ prerelease: false
119
+ version_requirements: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - ">="
122
+ - !ruby/object:Gem::Version
123
+ version: '0.1'
124
+ description: 'Fetches a URL and converts its content to clean markdown for LLM consumption.
125
+ A pluggable backend chain: pure Ruby httpx + Nokogiri + reverse_markdown by default,
126
+ a Jina Reader fallback for JS-rendered or blocked pages, and a real-Chrome fallback
127
+ (Ferrum) that renders JavaScript and lets auto-solving Cloudflare challenges complete.
128
+ The capability layer only — tool framing (MCP servers, agent tools) is provided
129
+ by the consumers.'
130
130
  email:
131
131
  - kaka@myrrlabs.com
132
132
  executables: []
@@ -173,5 +173,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
173
173
  requirements: []
174
174
  rubygems_version: 4.0.3
175
175
  specification_version: 4
176
- summary: Web fetch tool for the ask-rb ecosystem
176
+ summary: Web fetch library for the ask-rb ecosystem
177
177
  test_files: []