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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5e7b287b5b62f821cb0b3085082b385583d3f1a7ef2dfc0816eb311931d1e218
4
- data.tar.gz: 534bce458033fb046914c169b7bb7bad982f8fcd3ffb999d6feb2b495953d90c
3
+ metadata.gz: 6f73de0a29e2d37986a2b05a930640143f5566011782b5d68eed318bd82db746
4
+ data.tar.gz: 86ee4e5a800bf7fa67df0a652f40f0058ba5e1c1fa00e77a63b737017e61fbf0
5
5
  SHA512:
6
- metadata.gz: 48e47dd345f498f1a50b6dc3ddb09a5f55078691ee34a617b02936fb9b7d639c2ff55c309f6fc2ecec2a4d067c487edd0975c983b4ed301471d50536e73e6bd0
7
- data.tar.gz: 7bf1c58c8fb17a9c5575b41f8b2d44c6deb1c01a8b82b56b3d73f5d1df4c5d72fadf09ca60de1fc3995b8dc62581611f4aa50bcb4c47ceb9894f5bb6749490fc
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::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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ask
4
4
  module WebFetch
5
- VERSION = '0.6.2'
5
+ VERSION = '0.7.0'
6
6
  end
7
7
  end
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
- require_relative 'web_fetch/tool'
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.6.2
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: 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
+ 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 tool for the ask-rb ecosystem
161
+ summary: Web fetch library for the ask-rb ecosystem
177
162
  test_files: []
@@ -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)