ask-web-fetch 0.2.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 +4 -4
- data/lib/ask/web_fetch/backends/crawl4ai.rb +111 -0
- data/lib/ask/web_fetch/tool.rb +18 -6
- data/lib/ask/web_fetch/version.rb +1 -1
- data/lib/ask/web_fetch.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: efd68d22906c21155fe37405f68ef05185732ff6f1ce39767e50c87e4d535994
|
|
4
|
+
data.tar.gz: 7d06e49acbc527f06badb034933d792203910637c0e2671f133ec2fc159be41d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7736807de8ff9c7b1bad298af12abf67af77f254090532f39f080d0dd98c2bb3a7fed60aac5cf8bbeaeb977b03d3d38900525a4a85b012dc94ae171f18f38b3e
|
|
7
|
+
data.tar.gz: 2edae586d7baeb9114e29d23540ccbaa331e920a8fe83b7acea12c612b8491116fc635c948d437a157c3193b90d63b97e84857b7714f73ff5f6d19d6c907472b
|
|
@@ -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
|
data/lib/ask/web_fetch/tool.rb
CHANGED
|
@@ -3,22 +3,34 @@
|
|
|
3
3
|
require 'ask-tools'
|
|
4
4
|
require_relative '../web_fetch/backend'
|
|
5
5
|
require_relative '../web_fetch/backends/local'
|
|
6
|
+
require_relative '../web_fetch/backends/crawl4ai'
|
|
6
7
|
require_relative '../web_fetch/backends/jina'
|
|
7
8
|
|
|
8
9
|
module Ask
|
|
9
10
|
module Tools
|
|
10
11
|
# Fetches a URL and returns its content as clean markdown for LLM
|
|
11
12
|
# consumption. Tries each configured backend in order and returns the
|
|
12
|
-
# first success
|
|
13
|
-
#
|
|
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
19
|
DEFAULT_MAX_CHARS = 20_000
|
|
16
20
|
|
|
17
|
-
# Backend chain, tried in order.
|
|
18
|
-
# (
|
|
19
|
-
#
|
|
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).
|
|
20
26
|
def self.backends
|
|
21
|
-
@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
|
|
22
34
|
end
|
|
23
35
|
|
|
24
36
|
class << self
|
data/lib/ask/web_fetch.rb
CHANGED
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.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Kaka Ruto
|
|
@@ -122,6 +122,7 @@ files:
|
|
|
122
122
|
- lib/ask-web-fetch.rb
|
|
123
123
|
- lib/ask/web_fetch.rb
|
|
124
124
|
- lib/ask/web_fetch/backend.rb
|
|
125
|
+
- lib/ask/web_fetch/backends/crawl4ai.rb
|
|
125
126
|
- lib/ask/web_fetch/backends/jina.rb
|
|
126
127
|
- lib/ask/web_fetch/backends/local.rb
|
|
127
128
|
- lib/ask/web_fetch/tool.rb
|