ask-web-fetch 0.1.0 → 0.3.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: e750ba3efd9f7947a21e35e92ae5e99d639bc028dd8c2be2a8581707a0fdd908
4
- data.tar.gz: 5c8d428a526da1e0cc7ac1397adbb73e22eb4714c3576cf2b6ec927bbe5630f8
3
+ metadata.gz: efd68d22906c21155fe37405f68ef05185732ff6f1ce39767e50c87e4d535994
4
+ data.tar.gz: 7d06e49acbc527f06badb034933d792203910637c0e2671f133ec2fc159be41d
5
5
  SHA512:
6
- metadata.gz: f770807fc25707aae50934aa6ebe4b2137fea07cda5e4204f9cf34ab441bf3f426a526c61c92edc467e011c32b712d00bf5c948cefafc110bb2f7c9139177725
7
- data.tar.gz: f6d780234851e1cd40d96ab2f3f75636054e2cff4a13f6591d50caa7c621d09b833021831244e3df90eb4869facae6d50fa664fd8f53386f653a4692b00c1eac
6
+ metadata.gz: 7736807de8ff9c7b1bad298af12abf67af77f254090532f39f080d0dd98c2bb3a7fed60aac5cf8bbeaeb977b03d3d38900525a4a85b012dc94ae171f18f38b3e
7
+ data.tar.gz: 2edae586d7baeb9114e29d23540ccbaa331e920a8fe83b7acea12c612b8491116fc635c948d437a157c3193b90d63b97e84857b7714f73ff5f6d19d6c907472b
data/README.md CHANGED
@@ -4,19 +4,47 @@
4
4
 
5
5
  A web fetch tool for the ask-rb ecosystem. It provides
6
6
  `Ask::Tools::WebFetch`, which fetches a URL and returns its content as clean
7
- markdown for LLM consumption. It has no Rails dependencies, no external
8
- service, and no API key required — pure Ruby (`Net::HTTP` + Nokogiri +
9
- reverse_markdown).
7
+ markdown for LLM consumption. It has no Rails dependencies and no API key
8
+ required.
10
9
 
11
10
  ## How it works
12
11
 
13
- 1. `Net::HTTP` GET with a browser-like User-Agent (redirects followed, up to 5)
14
- 2. Nokogiri parses the HTML and picks the main content
15
- (`<article>` → `<main>` → `<body>`)
16
- 3. Navigation, scripts, and other chrome are stripped
17
- 4. reverse_markdown converts the content to markdown (tables become markdown
18
- tables, links become `[text](url)`)
19
- 5. Output is truncated to `max_chars` (default 20000)
12
+ `Ask::Tools::WebFetch` runs a chain of pluggable backends and returns the
13
+ first success:
14
+
15
+ 1. **Local** (default) pure Ruby `Net::HTTP` + Nokogiri + reverse_markdown:
16
+ browser-like User-Agent, redirects followed, main content extracted
17
+ (`<article>` → `<main>` → `<body>`), navigation/scripts stripped, tables
18
+ become markdown tables, links become `[text](url)`.
19
+ 2. **Jina** — Jina Reader free tier (`https://r.jina.ai/<url>`). It runs
20
+ headless Chromium, so it renders JS pages the Local backend can't. Free
21
+ without a key (~20 req/min per IP); set `JINA_API_KEY` for higher limits.
22
+
23
+ The tool falls back automatically: if Local fails (blocked, timeout,
24
+ non-HTML, anti-bot challenge, or a JS page with no server-side content), it
25
+ tries Jina. If Jina fails too (rate limit, access error, challenge page),
26
+ the call returns a failure result listing each backend's error.
27
+
28
+ ### Adding a backend
29
+
30
+ Backends subclass `Ask::WebFetch::Backend` and implement one method:
31
+
32
+ ```ruby
33
+ class Crawl4ai < Ask::WebFetch::Backend
34
+ def fetch(url)
35
+ # return { title: "Page Title", content: "markdown..." }
36
+ # or raise Ask::WebFetch::FetchError / EmptyContentError
37
+ end
38
+ end
39
+
40
+ Ask::Tools::WebFetch.backends = [Crawl4ai, Ask::WebFetch::Backends::Local]
41
+ ```
42
+
43
+ `#fetch` must return `{ title: String|nil, content: String }` and raise
44
+ `Ask::WebFetch::FetchError` (hard failure) or `EmptyContentError` (page
45
+ fetched but nothing usable). The chain then handles ordering and fallback
46
+ for you. For tests, `Ask::Tools::WebFetch.backends = [...]` can be swapped
47
+ and restored.
20
48
 
21
49
  ## Installation
22
50
 
@@ -51,13 +79,16 @@ You can cap the output size:
51
79
  result = tool.execute(url: "https://example.com", max_chars: 5000)
52
80
  ```
53
81
 
54
- If the page has no readable content, returns
55
- `"No readable content found at <url>."`
82
+ If every backend fails (e.g. the page is behind an anti-bot challenge and
83
+ Jina is also rate-limited), the call returns a failure result with each
84
+ backend's error.
56
85
 
57
86
  ## Configuration
58
87
 
59
- No configuration required. The only knob is the optional `max_chars`
60
- parameter.
88
+ No configuration required for the default chain. Optional knobs:
89
+
90
+ - `JINA_API_KEY` — enables the Jina fallback with higher rate limits
91
+ - `max_chars` parameter — caps output length (default 20000)
61
92
 
62
93
  ## Known limitations
63
94
 
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'version'
4
+
5
+ module Ask
6
+ module WebFetch
7
+ # Raised by backends on any failure; the tool catches it and tries the
8
+ # next backend in the chain.
9
+ class Error < StandardError; end
10
+
11
+ # A backend that failed to fetch (network error, non-2xx, challenge
12
+ # page, non-HTML response).
13
+ class FetchError < Error; end
14
+
15
+ # A backend that fetched the page but found nothing usable in it.
16
+ class EmptyContentError < Error; end
17
+
18
+ # Base class for fetch backends, plus the errors they raise.
19
+ #
20
+ # A backend turns a URL into LLM-ready markdown. To add a new backend:
21
+ #
22
+ # 1. subclass Backend and implement #fetch(url)
23
+ # 2. #fetch must return { title: String|nil, content: String }
24
+ # 3. #fetch must raise FetchError (hard failure) or
25
+ # EmptyContentError (page fetched but nothing usable) on failure
26
+ # 4. register the class in Ask::Tools::WebFetch.backends
27
+ #
28
+ # The tool tries each backend in order and returns the first success.
29
+ class Backend
30
+ # Identity sent on every request, browser-like plus a gem tag.
31
+ USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) ' \
32
+ 'AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36 ' \
33
+ "ask-web-fetch/#{Ask::WebFetch::VERSION}".freeze
34
+
35
+ # Content shorter than this is treated as a page with no usable
36
+ # content (e.g. a JS-rendered shell with nothing server-side).
37
+ MIN_CONTENT_LENGTH = 100
38
+
39
+ # Cloudflare-style anti-bot signatures. Deliberately narrow:
40
+ # challenge/interstitial pages carry these markers, while legitimate
41
+ # pages can contain the word "captcha" in unrelated config/JS (e.g.
42
+ # Wikipedia embeds an hcaptcha edit-config flag on every page).
43
+ CHALLENGE_RE = /just a moment|checking your browser|cf-chl/i
44
+
45
+ def self.backend_name
46
+ name.split('::').last
47
+ end
48
+
49
+ # Fetches +url+ and returns { title: String|nil, content: String }.
50
+ # Raises FetchError or EmptyContentError on failure.
51
+ def fetch(url)
52
+ raise NotImplementedError, "#{self.class} must implement #fetch(url)"
53
+ end
54
+
55
+ private
56
+
57
+ def challenge_page?(body)
58
+ body.to_s.match?(CHALLENGE_RE)
59
+ end
60
+
61
+ def usable_content?(content)
62
+ content.to_s.strip.length >= MIN_CONTENT_LENGTH
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'uri'
5
+ require 'json'
6
+ require_relative '../backend'
7
+
8
+ module Ask
9
+ module WebFetch
10
+ module Backends
11
+ # Self-hosted Crawl4AI (https://docs.crawl4ai.com) — a headless
12
+ # Chromium crawler that renders JavaScript and returns clean markdown.
13
+ # Runs as its own Docker service (default http://localhost:11235), the
14
+ # same self-hosted pattern as ask-web-search's SearXNG. No API key;
15
+ # configure via CRAWL4AI_URL (and CRAWL4AI_TOKEN for 0.9+ JWT-protected
16
+ # servers).
17
+ #
18
+ # Kept FIRST in the default chain: when the service is present it
19
+ # handles the JS-rendered pages the Local backend can't. When it isn't
20
+ # configured — or is unreachable — it fails fast and the chain falls
21
+ # through to Local, with Jina as the last resort.
22
+ class Crawl4Ai < Backend
23
+ DEFAULT_URL = 'http://localhost:11235'
24
+ OPEN_TIMEOUT = 5
25
+ READ_TIMEOUT = 30
26
+ CRAWL_TIMEOUT = 60
27
+
28
+ class << self
29
+ attr_writer :url, :token
30
+
31
+ def url
32
+ @url || ENV['CRAWL4AI_URL']
33
+ end
34
+
35
+ def token
36
+ @token || ENV['CRAWL4AI_TOKEN']
37
+ end
38
+
39
+ # Presence = configuration. The tool's default chain only includes
40
+ # this backend when CRAWL4AI_URL is set, so consumers without a
41
+ # Crawl4AI service see zero behavior change (Local -> Jina).
42
+ def configured?
43
+ !url.to_s.empty?
44
+ end
45
+ end
46
+
47
+ def fetch(url)
48
+ raise FetchError, 'Crawl4AI not configured (set CRAWL4AI_URL)' if self.class.url.to_s.empty?
49
+
50
+ body = crawl(url)
51
+ raise FetchError, "challenge page at #{url}" if challenge_page?(body)
52
+
53
+ page = to_page(body, url)
54
+ raise EmptyContentError, "no readable content at #{url}" unless usable_content?(page[:content])
55
+
56
+ page
57
+ rescue Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNREFUSED,
58
+ Errno::ECONNRESET, SocketError, URI::InvalidURIError => e
59
+ raise FetchError, "Crawl4AI #{e.class}: #{e.message}"
60
+ end
61
+
62
+ private
63
+
64
+ def crawl(url)
65
+ uri = URI("#{self.class.url.chomp('/')}/crawl")
66
+ http = Net::HTTP.new(uri.host, uri.port)
67
+ http.use_ssl = uri.scheme == 'https'
68
+ http.open_timeout = OPEN_TIMEOUT
69
+ http.read_timeout = READ_TIMEOUT
70
+
71
+ req = Net::HTTP::Post.new(uri)
72
+ req['Content-Type'] = 'application/json'
73
+ req['Accept'] = 'application/json'
74
+ req['User-Agent'] = USER_AGENT
75
+ req['Authorization'] = "Bearer #{self.class.token}" if self.class.token
76
+ req.body = JSON.generate(
77
+ urls: [url],
78
+ crawler_config: { cache_mode: 'bypass', timeout: CRAWL_TIMEOUT }
79
+ )
80
+
81
+ res = http.request(req)
82
+ case res.code
83
+ when '200'
84
+ res.body.to_s
85
+ when '401', '403'
86
+ raise FetchError, "Crawl4AI auth error (#{res.code})"
87
+ else
88
+ raise FetchError, "Crawl4AI returned #{res.code}"
89
+ end
90
+ end
91
+
92
+ # The /crawl response is {success:, results: [CrawlResult...]} where
93
+ # each result carries markdown (fit_markdown preferred, raw_markdown
94
+ # fallback) and metadata (title, description, ...).
95
+ def to_page(body, url)
96
+ data = JSON.parse(body)
97
+ result = Array(data['results']).first || {}
98
+ if result['success'] == false
99
+ raise FetchError, "Crawl4AI crawl failed: #{result['error_message'] || 'unknown error'}"
100
+ end
101
+
102
+ markdown = result.dig('markdown', 'fit_markdown').to_s
103
+ markdown = result.dig('markdown', 'raw_markdown').to_s if markdown.strip.empty?
104
+ { title: result.dig('metadata', 'title'), content: markdown }
105
+ rescue JSON::ParserError => e
106
+ raise FetchError, "Crawl4AI bad JSON response: #{e.message}"
107
+ end
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'uri'
5
+ require_relative '../backend'
6
+
7
+ module Ask
8
+ module WebFetch
9
+ module Backends
10
+ # Jina Reader free tier: GET https://r.jina.ai/<url>.
11
+ #
12
+ # Free without a key (~20 req/min per IP). Set JINA_API_KEY for higher
13
+ # rate limits. The endpoint runs headless Chromium, so it renders JS
14
+ # pages that the Local backend cannot.
15
+ class Jina < Backend
16
+ BASE_URL = 'https://r.jina.ai'
17
+ OPEN_TIMEOUT = 5
18
+ READ_TIMEOUT = 30
19
+
20
+ def fetch(url)
21
+ uri = URI("#{BASE_URL}/#{url}")
22
+ http = Net::HTTP.new(uri.host, uri.port)
23
+ http.use_ssl = true
24
+ http.open_timeout = OPEN_TIMEOUT
25
+ http.read_timeout = READ_TIMEOUT
26
+ req = Net::HTTP::Get.new(uri)
27
+ req['User-Agent'] = USER_AGENT
28
+ req['Accept'] = 'text/markdown, text/plain, text/html'
29
+ req['Authorization'] = "Bearer #{ENV['JINA_API_KEY']}" if ENV['JINA_API_KEY']
30
+
31
+ res = http.request(req)
32
+ case res.code
33
+ when '200'
34
+ body = res.body.to_s
35
+ raise FetchError, 'challenge page from Jina' if challenge_page?(body)
36
+ raise EmptyContentError, 'empty response from Jina' unless usable_content?(body)
37
+
38
+ { title: nil, content: body.strip }
39
+ when '429'
40
+ raise FetchError, 'rate limited by Jina (429)'
41
+ when '401', '403'
42
+ raise FetchError, "Jina access error (#{res.code})"
43
+ else
44
+ raise FetchError, "Jina returned #{res.code}"
45
+ end
46
+ rescue Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNREFUSED,
47
+ Errno::ECONNRESET, SocketError, URI::InvalidURIError => e
48
+ raise FetchError, "Jina #{e.class}: #{e.message}"
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'uri'
5
+ require 'nokogiri'
6
+ require 'reverse_markdown'
7
+ require_relative '../backend'
8
+
9
+ module Ask
10
+ module WebFetch
11
+ module Backends
12
+ # Default backend: pure Ruby Net::HTTP + Nokogiri + reverse_markdown.
13
+ # No external service or API key; mirrors ask-web-search's self-hosted
14
+ # SearXNG approach.
15
+ class Local < Backend
16
+ MAX_REDIRECTS = 5
17
+ OPEN_TIMEOUT = 5
18
+ READ_TIMEOUT = 15
19
+
20
+ # Class/id fragments that mark navigation chrome worth dropping, e.g.
21
+ # "vector-page-toolbar", "sidebar", "toc".
22
+ NAV_CHROME_RE = /
23
+ (^|[\s_-])(nav|menu|toolbar|breadcrumb|sidebar|toc|footer|header|
24
+ banner|pagination|search|cookie|modal|popup)([\s_-]|$)
25
+ /ix
26
+
27
+ def fetch(url)
28
+ body, content_type = fetch_html(url)
29
+ raise FetchError, "expected HTML from #{url}, got #{content_type}" unless content_type.include?('html')
30
+ raise FetchError, "challenge page at #{url}" if challenge_page?(body)
31
+
32
+ page = to_markdown(body, url)
33
+ raise EmptyContentError, "no readable content at #{url}" unless usable_content?(page[:content])
34
+
35
+ page
36
+ rescue Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNREFUSED,
37
+ Errno::ECONNRESET, SocketError, URI::InvalidURIError => e
38
+ raise FetchError, "#{e.class}: #{e.message}"
39
+ end
40
+
41
+ # Parses +html+ and returns { title:, content: } where content is
42
+ # clean markdown.
43
+ def to_markdown(html, _url)
44
+ doc = Nokogiri::HTML(html)
45
+ candidate = extract_main(doc)
46
+ scrub(candidate)
47
+ markdown = ReverseMarkdown.convert(candidate.to_html, unknown_tags: :bypass, github_flavored: true)
48
+ markdown = clean(markdown)
49
+ title = doc.at('title')&.text&.strip
50
+ { title: title, content: markdown }
51
+ end
52
+
53
+ private
54
+
55
+ # GET with redirect following (max MAX_REDIRECTS hops).
56
+ def fetch_html(url)
57
+ uri = URI(url)
58
+ hops = 0
59
+ loop do
60
+ http = Net::HTTP.new(uri.host, uri.port)
61
+ http.open_timeout = OPEN_TIMEOUT
62
+ http.read_timeout = READ_TIMEOUT
63
+ http.use_ssl = uri.scheme == 'https'
64
+ req = Net::HTTP::Get.new(uri)
65
+ req['User-Agent'] = USER_AGENT
66
+ req['Accept'] = 'text/html,application/xhtml+xml'
67
+ res = http.request(req)
68
+ return [res.body, res['content-type'].to_s] if res.code.start_with?('2')
69
+
70
+ raise FetchError, "got #{res.code} from #{url}" unless res.code.start_with?('3') && res['location']
71
+ raise FetchError, "hit a redirect loop at #{url}" if (hops += 1) > MAX_REDIRECTS
72
+
73
+ uri = URI.join(uri, res['location'])
74
+ end
75
+ end
76
+
77
+ def extract_main(doc)
78
+ doc.at('article') || doc.at('main') || doc.at('[role="main"]') || doc.at('body') || doc
79
+ end
80
+
81
+ # Remove chrome INSIDE the candidate only, never ancestors.
82
+ def scrub(candidate)
83
+ candidate.css('script, style, noscript, nav, footer, header, iframe, form, svg, aside').each(&:remove)
84
+ candidate.css('*[id], *[class]').each do |el|
85
+ next if el.equal?(candidate)
86
+
87
+ id_cls = [el['id'], el['class']].compact.join(' ')
88
+ el.remove if id_cls.match?(NAV_CHROME_RE)
89
+ end
90
+ candidate
91
+ end
92
+
93
+ def clean(markdown)
94
+ markdown.gsub(/[ \t]+\n/, "\n")
95
+ .gsub(/\n{3,}/, "\n\n")
96
+ .strip
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
@@ -1,31 +1,41 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'ask-tools'
4
- require 'net/http'
5
- require 'uri'
6
- require 'nokogiri'
7
- require 'reverse_markdown'
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
8
 
9
9
  module Ask
10
10
  module Tools
11
11
  # Fetches a URL and returns its content as clean markdown for LLM
12
- # consumption. Pure Ruby: Net::HTTP + Nokogiri + reverse_markdown.
13
- # No external service or API key required.
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, with Jina Reader as the last resort.
14
18
  class WebFetch < Ask::Tool
15
- USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) ' \
16
- 'AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36 ' \
17
- "ask-web-fetch/#{Ask::WebFetch::VERSION}".freeze
18
19
  DEFAULT_MAX_CHARS = 20_000
19
- MAX_REDIRECTS = 5
20
- OPEN_TIMEOUT = 5
21
- READ_TIMEOUT = 15
22
20
 
23
- # Class/id fragments that mark navigation chrome worth dropping, e.g.
24
- # "vector-page-toolbar", "sidebar", "toc".
25
- NAV_CHROME_RE = /
26
- (^|[\s_-])(nav|menu|toolbar|breadcrumb|sidebar|toc|footer|header|
27
- banner|pagination|search|cookie|modal|popup)([\s_-]|$)
28
- /ix
21
+ # Backend chain, tried in order. Crawl4AI leads when configured
22
+ # (CRAWL4AI_URL), so a present self-hosted renderer is preferred;
23
+ # otherwise Local, with Jina as the last resort. Swap or extend for
24
+ # future backends; each must subclass Ask::WebFetch::Backend and
25
+ # implement #fetch(url).
26
+ def self.backends
27
+ @backends ||= begin
28
+ chain = [Ask::WebFetch::Backends::Local, Ask::WebFetch::Backends::Jina]
29
+ if Ask::WebFetch::Backends::Crawl4Ai.configured?
30
+ chain.unshift(Ask::WebFetch::Backends::Crawl4Ai)
31
+ end
32
+ chain
33
+ end
34
+ end
35
+
36
+ class << self
37
+ attr_writer :backends
38
+ end
29
39
 
30
40
  description 'Fetch a URL and return its content as clean markdown for LLM consumption. ' \
31
41
  'Use this to read web pages, articles, and documentation.'
@@ -40,74 +50,32 @@ module Ask
40
50
  )
41
51
 
42
52
  def execute(url:, max_chars: DEFAULT_MAX_CHARS)
43
- body, content_type = fetch(url)
44
- raise "WebFetch expected HTML from #{url}, got #{content_type}" unless content_type.include?('html')
45
-
46
- markdown = to_markdown(body, url)
53
+ page = fetch_page(url)
54
+ markdown = format(page, url)
47
55
  markdown = truncate(markdown, max_chars) if max_chars&.positive?
48
56
  Ask::Result.ok(data: markdown)
49
57
  end
50
58
 
51
59
  private
52
60
 
53
- # GET with redirect following (max MAX_REDIRECTS hops). Raises on
54
- # non-2xx responses; Ask::Tool#call wraps exceptions into a failure.
55
- def fetch(url)
56
- uri = URI(url)
57
- hops = 0
58
- loop do
59
- http = Net::HTTP.new(uri.host, uri.port)
60
- http.open_timeout = OPEN_TIMEOUT
61
- http.read_timeout = READ_TIMEOUT
62
- http.use_ssl = uri.scheme == 'https'
63
- req = Net::HTTP::Get.new(uri)
64
- req['User-Agent'] = USER_AGENT
65
- req['Accept'] = 'text/html,application/xhtml+xml'
66
- res = http.request(req)
67
- return [res.body, res['content-type'].to_s] if res.code.start_with?('2')
68
-
69
- raise "WebFetch got #{res.code} from #{url}" unless res.code.start_with?('3') && res['location']
70
- raise "WebFetch hit a redirect loop at #{url}" if (hops += 1) > MAX_REDIRECTS
71
-
72
- uri = URI.join(uri, res['location'])
61
+ # Try each configured backend in order; return the first success.
62
+ def fetch_page(url)
63
+ failures = []
64
+ self.class.backends.each do |backend_class|
65
+ return backend_class.new.fetch(url)
66
+ rescue Ask::WebFetch::Error => e
67
+ failures << "#{backend_class.backend_name}: #{e.message}"
73
68
  end
69
+ raise Ask::WebFetch::Error,
70
+ "all web fetch backends failed for #{url} (#{failures.join('; ')})"
74
71
  end
75
72
 
76
- def to_markdown(html, url)
77
- doc = Nokogiri::HTML(html)
78
- candidate = extract_main(doc)
79
- scrub(candidate)
80
- markdown = ReverseMarkdown.convert(candidate.to_html, unknown_tags: :bypass, github_flavored: true)
81
- markdown = clean(markdown)
82
- return "No readable content found at #{url}." if markdown.empty?
83
-
73
+ def format(page, url)
84
74
  header = +''
85
- title = doc.at('title')&.text&.strip
75
+ title = page[:title]
86
76
  header << "# #{title}\n\n" unless title.to_s.empty?
87
77
  header << "Source: #{url}\n\n"
88
- header + markdown
89
- end
90
-
91
- def extract_main(doc)
92
- doc.at('article') || doc.at('main') || doc.at('[role="main"]') || doc.at('body') || doc
93
- end
94
-
95
- # Remove chrome INSIDE the candidate only, never ancestors.
96
- def scrub(candidate)
97
- candidate.css('script, style, noscript, nav, footer, header, iframe, form, svg, aside').each(&:remove)
98
- candidate.css('*[id], *[class]').each do |el|
99
- next if el.equal?(candidate)
100
-
101
- id_cls = [el['id'], el['class']].compact.join(' ')
102
- el.remove if id_cls.match?(NAV_CHROME_RE)
103
- end
104
- candidate
105
- end
106
-
107
- def clean(markdown)
108
- markdown.gsub(/[ \t]+\n/, "\n")
109
- .gsub(/\n{3,}/, "\n\n")
110
- .strip
78
+ header + page[:content]
111
79
  end
112
80
 
113
81
  def truncate(markdown, max_chars)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ask
4
4
  module WebFetch
5
- VERSION = '0.1.0'
5
+ VERSION = '0.3.0'
6
6
  end
7
7
  end
data/lib/ask/web_fetch.rb CHANGED
@@ -1,4 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'web_fetch/version'
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'
4
8
  require_relative 'web_fetch/tool'
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.1.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto
@@ -107,10 +107,10 @@ dependencies:
107
107
  - - "~>"
108
108
  - !ruby/object:Gem::Version
109
109
  version: '3.26'
110
- description: 'Provides Ask::Tools::WebFetch, a tool that fetches a URL and converts
111
- its content to clean markdown for LLM consumption. Pure Ruby (Net::HTTP + Nokogiri
112
- + reverse_markdown): no external service or API key required. Works with any ask-rb
113
- chat or agent.'
110
+ description: Provides Ask::Tools::WebFetch, a tool that fetches a URL and converts
111
+ its content to clean markdown for LLM consumption. Defaults to a pure Ruby backend
112
+ (Net::HTTP + Nokogiri + reverse_markdown) with an automatic Jina Reader fallback
113
+ for JS-rendered or blocked pages. Works with any ask-rb chat or agent.
114
114
  email:
115
115
  - kaka@myrrlabs.com
116
116
  executables: []
@@ -121,6 +121,10 @@ files:
121
121
  - README.md
122
122
  - lib/ask-web-fetch.rb
123
123
  - lib/ask/web_fetch.rb
124
+ - lib/ask/web_fetch/backend.rb
125
+ - lib/ask/web_fetch/backends/crawl4ai.rb
126
+ - lib/ask/web_fetch/backends/jina.rb
127
+ - lib/ask/web_fetch/backends/local.rb
124
128
  - lib/ask/web_fetch/tool.rb
125
129
  - lib/ask/web_fetch/version.rb
126
130
  homepage: https://github.com/ask-rb/ask-web-fetch