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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 15d4465ae7ca7805003043af5b97e96ddc8c2ba19618daab313377f4a529bd54
4
- data.tar.gz: 9f83f8c2258dc973457158e7d7e2288afc43403221453545a4d19c97440b85ee
3
+ metadata.gz: 6f73de0a29e2d37986a2b05a930640143f5566011782b5d68eed318bd82db746
4
+ data.tar.gz: 86ee4e5a800bf7fa67df0a652f40f0058ba5e1c1fa00e77a63b737017e61fbf0
5
5
  SHA512:
6
- metadata.gz: b34dfd5f3d45b827cf8882d3c9c3b47e05ea8c16622849b2a41fc51b8c5f005f0b149ef83a87db57f4167470e85911ec58f72830afded9a84ab707ea08dcbc68
7
- data.tar.gz: c8245c574d480179cd3b8ad67f1049b096fdf298f0886a790cb557f698c11e8dbaed9bb4336de4ccf84882e03eb8cf716830db069988f310c35cf4202cf01d60
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. register the class in Ask::Tools::WebFetch.backends
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 parked-domain detector catches it here; the distinct
205
- # ParkedDomainError lets the pipeline classify (never retry) it.
206
- raise ParkedDomainError, "parked domain at #{url} registrar parking page, not site content" if parked_domain?(body)
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
- raise EmptyContentError, "no readable content at #{url}" unless usable_content?(result[:content])
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 detector (0.5.7) catches the text markers that
60
- # survive conversion, so the ad is rejected, not returned as
61
- # the site's content. Crawl4AI leads the default chain, so this
62
- # check is what keeps parked domains out of every result.
63
- raise ParkedDomainError, "parked domain at #{url} registrar parking page, not site content" if parked_domain?(page[:content])
64
- raise EmptyContentError, "no readable content at #{url}" unless usable_content?(page[:content])
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 detector (0.5.7) catches the text markers that
45
- # survive conversion, so the ad is rejected, not returned as
46
- # the site's content.
47
- content = Markdown.clean(body)
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. Checked BEFORE the
52
- # content-minimum a parking page can render as "content" above
53
- # the minimum (puncta.ai: 395c of Namecheap auction ads), and
54
- # must still be rejected. Detectable from the server HTML —
55
- # parked pages are fully server-rendered, so both Local and
56
- # Browser see the same ad.
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.
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ask
4
4
  module WebFetch
5
- VERSION = '0.6.1'
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.1
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)