ask-web-fetch 0.4.1 → 0.5.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/backend.rb +49 -3
- data/lib/ask/web_fetch/backends/browser.rb +1 -0
- data/lib/ask/web_fetch/backends/crawl4ai.rb +3 -0
- data/lib/ask/web_fetch/backends/jina.rb +3 -1
- data/lib/ask/web_fetch/backends/local.rb +55 -26
- data/lib/ask/web_fetch/http.rb +85 -0
- data/lib/ask/web_fetch/version.rb +1 -1
- data/lib/ask/web_fetch.rb +1 -0
- metadata +17 -16
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 706e625574f2001a28ca673c198c2f6b58db55e5558a1afdb94dc1ae36090db0
|
|
4
|
+
data.tar.gz: 559ea380079f5ab52022da161159b69b3396bc962cd9b3a96e7c3505d8307bf3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7babb287ffae5ee1f93a8ecda397b15edd161c1919baa7509904baf67016210eb8456fe23af6e98667f9da6111a03e12a2faf5f835ab2751921f31ab366da8af
|
|
7
|
+
data.tar.gz: 25e75756da81358ab07e505ce6ec611d85ae7d14d9a73ea16deee421cd3e7f102d19bcfe7a02bd633299fc6f341f4e8badd1413fbd388d323d92d5ff7539a6d4
|
|
@@ -42,8 +42,11 @@ module Ask
|
|
|
42
42
|
"ask-web-fetch/#{Ask::WebFetch::VERSION}".freeze
|
|
43
43
|
|
|
44
44
|
# Content shorter than this is treated as a page with no usable
|
|
45
|
-
# content (e.g. a JS-rendered shell with nothing server-side).
|
|
46
|
-
|
|
45
|
+
# content (e.g. a JS-rendered shell with nothing server-side). Low on
|
|
46
|
+
# purpose: the content hub keeps everything that is a real page, and
|
|
47
|
+
# the crawler's soft-404 detection (not length) is what separates
|
|
48
|
+
# pages from error shells.
|
|
49
|
+
MIN_CONTENT_LENGTH = 40
|
|
47
50
|
|
|
48
51
|
# Cloudflare-style anti-bot signatures. Deliberately narrow:
|
|
49
52
|
# challenge/interstitial pages carry these markers, while legitimate
|
|
@@ -55,12 +58,55 @@ module Ask
|
|
|
55
58
|
name.split('::').last
|
|
56
59
|
end
|
|
57
60
|
|
|
58
|
-
# Fetches +url+ and returns { title
|
|
61
|
+
# Fetches +url+ and returns { title:, description:, content:,
|
|
62
|
+
# redirected:, licenses:, outlinks: } — licenses being the page's
|
|
63
|
+
# declared license signals (hrefs/values) and outlinks the page's
|
|
64
|
+
# raw link set ([] when the backend can't see any), both consumed by
|
|
65
|
+
# the crawler's classification/discovery layers.
|
|
59
66
|
# Raises FetchError or EmptyContentError on failure.
|
|
60
67
|
def fetch(url)
|
|
61
68
|
raise NotImplementedError, "#{self.class} must implement #fetch(url)"
|
|
62
69
|
end
|
|
63
70
|
|
|
71
|
+
# --- outlinks (crawler discovery) ---
|
|
72
|
+
|
|
73
|
+
# The page's raw outlinks from HTML: every <a href> resolved against
|
|
74
|
+
# the base URL and scheme-filtered to absolute http(s). Nav and
|
|
75
|
+
# footer are included — a crawler's discovery reads the full link set
|
|
76
|
+
# even when the stored content is pruned by the ContentFilter. Shared
|
|
77
|
+
# by every backend that holds the page's HTML.
|
|
78
|
+
def outlink_urls(html, base_url)
|
|
79
|
+
Nokogiri::HTML(html).css('a[href]').filter_map do |anchor|
|
|
80
|
+
href = anchor['href'].to_s.strip
|
|
81
|
+
next if href.empty? || href.start_with?('javascript:', 'mailto:', 'tel:', '#', 'data:')
|
|
82
|
+
|
|
83
|
+
uri = URI.join(base_url, href)
|
|
84
|
+
next unless %w[http https].include?(uri.scheme)
|
|
85
|
+
|
|
86
|
+
uri.to_s
|
|
87
|
+
rescue URI::InvalidURIError
|
|
88
|
+
next
|
|
89
|
+
end.uniq
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Fallback for backends that only see rendered markdown (Jina,
|
|
93
|
+
# Crawl4AI's markdown output): the markdown's [text](url) links,
|
|
94
|
+
# resolved and scheme-filtered. Same shape as #outlink_urls, one
|
|
95
|
+
# implementation for every backend that lacks the raw HTML.
|
|
96
|
+
def markdown_outlinks(content, base_url)
|
|
97
|
+
content.to_s.scan(/\]\(([^)\s]+)\)/).filter_map do |match|
|
|
98
|
+
dest = match[0]
|
|
99
|
+
next if dest.start_with?('javascript:', 'mailto:', 'tel:', '#', 'data:')
|
|
100
|
+
|
|
101
|
+
uri = URI.join(base_url, dest)
|
|
102
|
+
next unless %w[http https].include?(uri.scheme)
|
|
103
|
+
|
|
104
|
+
uri.to_s
|
|
105
|
+
rescue URI::InvalidURIError
|
|
106
|
+
next
|
|
107
|
+
end.uniq
|
|
108
|
+
end
|
|
109
|
+
|
|
64
110
|
private
|
|
65
111
|
|
|
66
112
|
def challenge_page?(body)
|
|
@@ -164,6 +164,7 @@ module Ask
|
|
|
164
164
|
raise FetchError, "challenge page at #{url}" if challenge_page?(body)
|
|
165
165
|
|
|
166
166
|
result = Markdown.generate(body, base_url: url, filter: self.class.content_filter)
|
|
167
|
+
result[:outlinks] = outlink_urls(body, url)
|
|
167
168
|
raise EmptyContentError, "no readable content at #{url}" unless usable_content?(result[:content])
|
|
168
169
|
|
|
169
170
|
result
|
|
@@ -120,6 +120,9 @@ module Ask
|
|
|
120
120
|
description: result.dig('metadata', 'description') ||
|
|
121
121
|
result.dig('metadata', 'og_description'),
|
|
122
122
|
content: markdown,
|
|
123
|
+
# Raw markdown keeps the nav links the ContentFilter prunes —
|
|
124
|
+
# discovery reads these even when the stored content is lean.
|
|
125
|
+
outlinks: markdown_outlinks(markdown, url),
|
|
123
126
|
# The page's own redirect, if the crawl followed one — lets
|
|
124
127
|
# consumers record permanent redirects on their ledger instead
|
|
125
128
|
# of silently indexing under the original URL.
|
|
@@ -35,7 +35,9 @@ module Ask
|
|
|
35
35
|
raise FetchError, 'challenge page from Jina' if challenge_page?(body)
|
|
36
36
|
raise EmptyContentError, 'empty response from Jina' unless usable_content?(body)
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
# Jina only sees rendered markdown — outlinks come from its
|
|
39
|
+
# links, resolved against the requested URL.
|
|
40
|
+
{ title: nil, description: nil, content: body.strip, outlinks: markdown_outlinks(body, url) }
|
|
39
41
|
when '429'
|
|
40
42
|
raise ServerError, 'rate limited by Jina (429)'
|
|
41
43
|
when '401', '403'
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'net/http'
|
|
4
3
|
require 'uri'
|
|
5
4
|
require_relative '../backend'
|
|
6
5
|
require_relative '../content_filter'
|
|
7
6
|
require_relative '../markdown'
|
|
7
|
+
require_relative '../http'
|
|
8
8
|
|
|
9
9
|
module Ask
|
|
10
10
|
module WebFetch
|
|
11
11
|
module Backends
|
|
12
|
-
# Default backend: pure Ruby
|
|
12
|
+
# Default backend: pure Ruby httpx + Nokogiri + reverse_markdown.
|
|
13
13
|
# No external service or API key; mirrors ask-web-search's self-hosted
|
|
14
14
|
# SearXNG approach.
|
|
15
15
|
#
|
|
@@ -18,8 +18,6 @@ module Ask
|
|
|
18
18
|
# than by keyword — is what comes back.
|
|
19
19
|
class Local < Backend
|
|
20
20
|
MAX_REDIRECTS = 5
|
|
21
|
-
OPEN_TIMEOUT = 5
|
|
22
|
-
READ_TIMEOUT = 15
|
|
23
21
|
|
|
24
22
|
class << self
|
|
25
23
|
# The ContentFilter applied to every page by default. Set to nil to
|
|
@@ -29,6 +27,15 @@ module Ask
|
|
|
29
27
|
def content_filter
|
|
30
28
|
@content_filter ||= ContentFilter.default
|
|
31
29
|
end
|
|
30
|
+
|
|
31
|
+
# The single-hop HTTP transport, Ask::WebFetch::Http by default:
|
|
32
|
+
# a pooled keep-alive httpx session. Swappable in tests — and the
|
|
33
|
+
# seam any future transport swap goes through.
|
|
34
|
+
attr_writer :http
|
|
35
|
+
|
|
36
|
+
def http
|
|
37
|
+
@http ||= Http
|
|
38
|
+
end
|
|
32
39
|
end
|
|
33
40
|
|
|
34
41
|
def fetch(url)
|
|
@@ -41,21 +48,50 @@ module Ask
|
|
|
41
48
|
raise EmptyContentError, "no readable content at #{url}" unless usable_content?(page[:content])
|
|
42
49
|
|
|
43
50
|
page
|
|
44
|
-
rescue
|
|
45
|
-
|
|
51
|
+
rescue Errno::ECONNREFUSED, Errno::ECONNRESET, SocketError, URI::InvalidURIError => e
|
|
52
|
+
# The transport maps its own failures to TimeoutError; this guard
|
|
53
|
+
# keeps the "only Ask::WebFetch errors escape" invariant even if
|
|
54
|
+
# a transport bug lets a raw socket error through.
|
|
46
55
|
raise TimeoutError, "#{e.class}: #{e.message}"
|
|
47
56
|
end
|
|
48
57
|
|
|
49
|
-
# Parses +html+ and returns { title:, description:, content
|
|
50
|
-
#
|
|
51
|
-
#
|
|
52
|
-
#
|
|
58
|
+
# Parses +html+ and returns { title:, description:, content:,
|
|
59
|
+
# licenses:, outlinks: } where content is clean markdown (pruned by
|
|
60
|
+
# the ContentFilter), licenses are the page's declared license
|
|
61
|
+
# signals ([] when it declares none), and outlinks are the page's
|
|
62
|
+
# RAW hrefs, resolved and scheme-filtered — nav and footer included,
|
|
63
|
+
# because a crawler's discovery layer reads these even when the
|
|
64
|
+
# stored content is pruned.
|
|
53
65
|
def to_markdown(html, url)
|
|
54
66
|
Markdown.generate(html, base_url: url, filter: self.class.content_filter)
|
|
67
|
+
.merge(licenses: license_signals(html), outlinks: outlink_urls(html, url))
|
|
55
68
|
end
|
|
56
69
|
|
|
57
70
|
private
|
|
58
71
|
|
|
72
|
+
# The page's declared license signals: <link rel="license">, the
|
|
73
|
+
# license meta tags (meta name/property license, cc:license,
|
|
74
|
+
# dc.rights), and schema.org [itemprop=license] — hrefs first, then
|
|
75
|
+
# contents. Best-effort and conservative: nothing here decides what
|
|
76
|
+
# the page IS licensed under, it only reports what the page says
|
|
77
|
+
# about itself. The consumer (the crawler's license classifier)
|
|
78
|
+
# maps known markers; unknown signals are ignored there, never here.
|
|
79
|
+
def license_signals(html)
|
|
80
|
+
doc = Nokogiri::HTML(html)
|
|
81
|
+
selectors = [
|
|
82
|
+
'link[rel~="license"]',
|
|
83
|
+
'meta[name="license"], meta[property="license"]',
|
|
84
|
+
'meta[name="cc:license"], meta[property="cc:license"]',
|
|
85
|
+
'meta[name="dc.rights"], meta[property="dc.rights"]',
|
|
86
|
+
'[itemprop="license"]'
|
|
87
|
+
]
|
|
88
|
+
doc.css(selectors.join(',')).filter_map do |node|
|
|
89
|
+
value = node['href'] || node['content'] || node.text
|
|
90
|
+
value = value.to_s.strip
|
|
91
|
+
value unless value.empty?
|
|
92
|
+
end.uniq
|
|
93
|
+
end
|
|
94
|
+
|
|
59
95
|
# GET with redirect following (max MAX_REDIRECTS hops). Returns
|
|
60
96
|
# [body, content_type, redirect] where redirect is nil when the
|
|
61
97
|
# URL answered directly, else {status: first hop's status,
|
|
@@ -65,30 +101,23 @@ module Ask
|
|
|
65
101
|
hops = 0
|
|
66
102
|
first_hop_status = nil
|
|
67
103
|
loop do
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
req = Net::HTTP::Get.new(uri)
|
|
73
|
-
req['User-Agent'] = USER_AGENT
|
|
74
|
-
req['Accept'] = 'text/html,application/xhtml+xml'
|
|
75
|
-
res = http.request(req)
|
|
76
|
-
return [res.body, res['content-type'].to_s, redirect_info(first_hop_status, uri)] if res.code.start_with?('2')
|
|
77
|
-
|
|
78
|
-
unless res.code.start_with?('3') && res['location']
|
|
104
|
+
response = self.class.http.get(uri.to_s, headers: { 'accept' => 'text/html,application/xhtml+xml' })
|
|
105
|
+
return [response.body, response.content_type, redirect_info(first_hop_status, uri)] if (200..299).cover?(response.status)
|
|
106
|
+
|
|
107
|
+
unless (300..399).cover?(response.status) && !response.location.empty?
|
|
79
108
|
# 4xx (other than 429) = the URL is dead; 429/5xx = transient.
|
|
80
|
-
code =
|
|
81
|
-
raise(code == 429 || code >= 500 ? ServerError : FetchError, "got #{
|
|
109
|
+
code = response.status
|
|
110
|
+
raise(code == 429 || code >= 500 ? ServerError : FetchError, "got #{code} from #{url}")
|
|
82
111
|
end
|
|
83
112
|
raise FetchError, "hit a redirect loop at #{url}" if (hops += 1) > MAX_REDIRECTS
|
|
84
113
|
|
|
85
|
-
first_hop_status ||=
|
|
86
|
-
uri = URI.join(uri,
|
|
114
|
+
first_hop_status ||= response.status
|
|
115
|
+
uri = URI.join(uri, response.location)
|
|
87
116
|
end
|
|
88
117
|
end
|
|
89
118
|
|
|
90
119
|
def redirect_info(status, uri)
|
|
91
|
-
status && {status: status, url: uri.to_s}
|
|
120
|
+
status && { status: status, url: uri.to_s }
|
|
92
121
|
end
|
|
93
122
|
end
|
|
94
123
|
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'httpx'
|
|
4
|
+
|
|
5
|
+
module Ask
|
|
6
|
+
module WebFetch
|
|
7
|
+
# Minimal pooled HTTP client for the Local backend. Wraps a per-thread
|
|
8
|
+
# httpx session so every page fetch in a thread reuses its keep-alive
|
|
9
|
+
# connection to the host — no fresh TCP+TLS handshake per page (the
|
|
10
|
+
# thing that made the Net::HTTP crawler cost ~1.25s per page) — with
|
|
11
|
+
# HTTP/2 when the server negotiates it, retries with backoff on
|
|
12
|
+
# transient failures, and automatic gzip/deflate decoding.
|
|
13
|
+
#
|
|
14
|
+
# Deliberately single-hop: the backend follows redirects itself, so the
|
|
15
|
+
# hop-by-hop chain it reports is exactly what happened. Error responses
|
|
16
|
+
# (4xx/5xx) are not errors to the transport — the backend decides what
|
|
17
|
+
# they mean. Transport-level failures (timeout, refused, reset, DNS,
|
|
18
|
+
# TLS) all surface as Ask::WebFetch::TimeoutError, the transient
|
|
19
|
+
# bucket, whatever their underlying class.
|
|
20
|
+
class Http
|
|
21
|
+
CONNECT_TIMEOUT = 5
|
|
22
|
+
READ_TIMEOUT = 15
|
|
23
|
+
WRITE_TIMEOUT = 15
|
|
24
|
+
# Whole-request cap. A page that can't be read in a minute is a
|
|
25
|
+
# problem page, not a stall worth a crawl worker.
|
|
26
|
+
OPERATION_TIMEOUT = 60
|
|
27
|
+
# Retries per request, on top of the crawl ledger's own auto-heal
|
|
28
|
+
# rounds. GETs are idempotent; a couple of cheap retries beat a full
|
|
29
|
+
# ledger round-trip for transient flakiness.
|
|
30
|
+
MAX_RETRIES = 2
|
|
31
|
+
RETRY_ON_STATUS = [429, 500, 502, 503, 504].freeze
|
|
32
|
+
|
|
33
|
+
# One hop's answer. Redirects stay the backend's job, so `location`
|
|
34
|
+
# rides along for the backend to resolve.
|
|
35
|
+
Response = Data.define(:status, :body, :content_type, :location)
|
|
36
|
+
|
|
37
|
+
def self.get(url, headers: {})
|
|
38
|
+
response = session.get(url, headers: headers)
|
|
39
|
+
return raise_timeout(response) if response.is_a?(HTTPX::ErrorResponse)
|
|
40
|
+
|
|
41
|
+
Response.new(
|
|
42
|
+
status: response.status,
|
|
43
|
+
body: response.body.to_s,
|
|
44
|
+
content_type: response.headers['content-type'].to_s,
|
|
45
|
+
location: response.headers['location'].to_s
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# One pooled session per thread — httpx sessions are not thread-safe,
|
|
50
|
+
# and a crawl worker thread reusing its session across every page it
|
|
51
|
+
# fetches is what makes the pooling pay off. Sessions idle-close
|
|
52
|
+
# themselves after keep-alive timeout, so nothing to reap.
|
|
53
|
+
def self.session
|
|
54
|
+
Thread.current[SESSION_KEY] ||= build_session
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def self.build_session
|
|
58
|
+
# :persistent is what makes the pooling real — without it httpx
|
|
59
|
+
# opens a fresh connection per request. It loads fiber_concurrency
|
|
60
|
+
# and the retries plugin internally.
|
|
61
|
+
HTTPX.plugin(:persistent).with(
|
|
62
|
+
timeout: {
|
|
63
|
+
connect_timeout: CONNECT_TIMEOUT,
|
|
64
|
+
read_timeout: READ_TIMEOUT,
|
|
65
|
+
write_timeout: WRITE_TIMEOUT,
|
|
66
|
+
operation_timeout: OPERATION_TIMEOUT
|
|
67
|
+
},
|
|
68
|
+
headers: { 'user-agent' => Backend::USER_AGENT },
|
|
69
|
+
max_retries: MAX_RETRIES,
|
|
70
|
+
retry_on: ->(response) { response.respond_to?(:status) && RETRY_ON_STATUS.include?(response.status) }
|
|
71
|
+
)
|
|
72
|
+
end
|
|
73
|
+
private_class_method :build_session
|
|
74
|
+
|
|
75
|
+
def self.raise_timeout(response)
|
|
76
|
+
error = response.error
|
|
77
|
+
raise TimeoutError, "#{error.class}: #{error.message}"
|
|
78
|
+
end
|
|
79
|
+
private_class_method :raise_timeout
|
|
80
|
+
|
|
81
|
+
SESSION_KEY = :ask_web_fetch_http_session
|
|
82
|
+
private_constant :SESSION_KEY
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
data/lib/ask/web_fetch.rb
CHANGED
|
@@ -4,6 +4,7 @@ require_relative 'web_fetch/version'
|
|
|
4
4
|
require_relative 'web_fetch/backend'
|
|
5
5
|
require_relative 'web_fetch/content_filter'
|
|
6
6
|
require_relative 'web_fetch/markdown'
|
|
7
|
+
require_relative 'web_fetch/http'
|
|
7
8
|
require_relative 'web_fetch/backends/local'
|
|
8
9
|
require_relative 'web_fetch/backends/crawl4ai'
|
|
9
10
|
require_relative 'web_fetch/backends/jina'
|
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.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Kaka Ruto
|
|
@@ -37,6 +37,20 @@ dependencies:
|
|
|
37
37
|
- - ">="
|
|
38
38
|
- !ruby/object:Gem::Version
|
|
39
39
|
version: '0.14'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: httpx
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - ">="
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '1.0'
|
|
47
|
+
type: :runtime
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - ">="
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '1.0'
|
|
40
54
|
- !ruby/object:Gem::Dependency
|
|
41
55
|
name: nokogiri
|
|
42
56
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -93,20 +107,6 @@ dependencies:
|
|
|
93
107
|
- - "~>"
|
|
94
108
|
- !ruby/object:Gem::Version
|
|
95
109
|
version: '13.0'
|
|
96
|
-
- !ruby/object:Gem::Dependency
|
|
97
|
-
name: vcr
|
|
98
|
-
requirement: !ruby/object:Gem::Requirement
|
|
99
|
-
requirements:
|
|
100
|
-
- - "~>"
|
|
101
|
-
- !ruby/object:Gem::Version
|
|
102
|
-
version: '6.0'
|
|
103
|
-
type: :development
|
|
104
|
-
prerelease: false
|
|
105
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
106
|
-
requirements:
|
|
107
|
-
- - "~>"
|
|
108
|
-
- !ruby/object:Gem::Version
|
|
109
|
-
version: '6.0'
|
|
110
110
|
- !ruby/object:Gem::Dependency
|
|
111
111
|
name: webmock
|
|
112
112
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -123,7 +123,7 @@ dependencies:
|
|
|
123
123
|
version: '3.26'
|
|
124
124
|
description: Provides Ask::Tools::WebFetch, a tool that fetches a URL and converts
|
|
125
125
|
its content to clean markdown for LLM consumption. Defaults to a pure Ruby backend
|
|
126
|
-
(
|
|
126
|
+
(httpx + Nokogiri + reverse_markdown) with a Jina Reader fallback for JS-rendered
|
|
127
127
|
or blocked pages, and a real-Chrome fallback (Ferrum) that renders JavaScript and
|
|
128
128
|
lets auto-solving Cloudflare challenges complete. Works with any ask-rb chat or
|
|
129
129
|
agent.
|
|
@@ -144,6 +144,7 @@ files:
|
|
|
144
144
|
- lib/ask/web_fetch/backends/jina.rb
|
|
145
145
|
- lib/ask/web_fetch/backends/local.rb
|
|
146
146
|
- lib/ask/web_fetch/content_filter.rb
|
|
147
|
+
- lib/ask/web_fetch/http.rb
|
|
147
148
|
- lib/ask/web_fetch/markdown.rb
|
|
148
149
|
- lib/ask/web_fetch/tool.rb
|
|
149
150
|
- lib/ask/web_fetch/version.rb
|