ask-web-fetch 0.3.1 → 0.4.1
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 +11 -2
- data/lib/ask/web_fetch/backends/attached_browser.rb +148 -0
- data/lib/ask/web_fetch/backends/browser.rb +216 -0
- data/lib/ask/web_fetch/backends/crawl4ai.rb +34 -3
- data/lib/ask/web_fetch/backends/jina.rb +5 -4
- data/lib/ask/web_fetch/backends/local.rb +38 -43
- data/lib/ask/web_fetch/content_filter.rb +183 -0
- data/lib/ask/web_fetch/markdown.rb +159 -0
- data/lib/ask/web_fetch/tool.rb +8 -4
- data/lib/ask/web_fetch/version.rb +1 -1
- data/lib/ask/web_fetch.rb +3 -0
- metadata +23 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 318ff9c594a7d5c52a710507efd3da2917bbe3319471a3e0bac8bce3a01ed30e
|
|
4
|
+
data.tar.gz: f5ae0d23b351044ecc21f4e2c4d2ec66a13d8258cd9a86d6947f0c7698c6e703
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 775322ca3144aa69da6d27e20664c5a18aa836baaf10d5431acc5cbc7dd893aff5e09c2c40751d3f19b6ea86410ad92cf992ad35b2283554b31196ba52fdea29
|
|
7
|
+
data.tar.gz: ae5f935d3b48522330b5a01f9319813bd84d157076f65ee2a17cdbd82fb3fb65916f5312ea03fefdc51e8b7a84792c9558ede959e195e386f2713fb1c31e437a
|
|
@@ -8,13 +8,22 @@ module Ask
|
|
|
8
8
|
# next backend in the chain.
|
|
9
9
|
class Error < StandardError; end
|
|
10
10
|
|
|
11
|
-
# A backend that failed to fetch
|
|
12
|
-
# page, non-HTML response
|
|
11
|
+
# A backend that failed to fetch because the URL itself is bad — 4xx,
|
|
12
|
+
# challenge page, non-HTML response, redirect loop. Deterministic:
|
|
13
|
+
# retrying won't change the outcome.
|
|
13
14
|
class FetchError < Error; end
|
|
14
15
|
|
|
15
16
|
# A backend that fetched the page but found nothing usable in it.
|
|
16
17
|
class EmptyContentError < Error; end
|
|
17
18
|
|
|
19
|
+
# Network-level failure — timeout, connection refused/reset, bad
|
|
20
|
+
# socket. Transient: the same URL may succeed on retry.
|
|
21
|
+
class TimeoutError < Error; end
|
|
22
|
+
|
|
23
|
+
# The service or the target server answered 5xx/429. Transient:
|
|
24
|
+
# retrying after backoff may succeed.
|
|
25
|
+
class ServerError < Error; end
|
|
26
|
+
|
|
18
27
|
# Base class for fetch backends, plus the errors they raise.
|
|
19
28
|
#
|
|
20
29
|
# A backend turns a URL into LLM-ready markdown. To add a new backend:
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'ferrum'
|
|
4
|
+
|
|
5
|
+
module Ask
|
|
6
|
+
module WebFetch
|
|
7
|
+
module Backends
|
|
8
|
+
# Drives an already-running Chrome — one started with
|
|
9
|
+
# `--remote-debugging-port=9222` — over the Chrome DevTools Protocol,
|
|
10
|
+
# reusing Ferrum's battle-tested CDP WebSocket client.
|
|
11
|
+
#
|
|
12
|
+
# This is the mode the Browser backend uses when ASK_WEB_FETCH_CDP_URL
|
|
13
|
+
# is set (e.g. http://127.0.0.1:9222). It is the *trusted context* a
|
|
14
|
+
# freshly launched automation browser can never be: a long-lived Chrome
|
|
15
|
+
# with a mature profile and any cookies (like cf_clearance) it has
|
|
16
|
+
# already earned, so sites whose invisible challenges soft-block fresh
|
|
17
|
+
# profiles load normally here.
|
|
18
|
+
#
|
|
19
|
+
# Each page is its own CDP target, created and closed by us — existing
|
|
20
|
+
# tabs are never touched.
|
|
21
|
+
class AttachedBrowser
|
|
22
|
+
def initialize(ws_url, timeout:, client: nil)
|
|
23
|
+
@ws_url = ws_url
|
|
24
|
+
@timeout = timeout
|
|
25
|
+
@client = client
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Creates a page as a fresh tab in the attached browser.
|
|
29
|
+
def create_page
|
|
30
|
+
Page.new(client, timeout: @timeout)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def client
|
|
36
|
+
@client ||= Ferrum::Client.new(
|
|
37
|
+
Addressable::URI.parse(@ws_url),
|
|
38
|
+
Ferrum::Browser::Options.new(timeout: @timeout, ws_max_receive_size: 20 * 1024 * 1024)
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# One tab in the attached browser, closed with #close. Speaks just
|
|
43
|
+
# enough CDP for the Browser backend: navigate, read title/URL/HTML,
|
|
44
|
+
# and report the main-document HTTP status.
|
|
45
|
+
class Page
|
|
46
|
+
def initialize(client, timeout:)
|
|
47
|
+
@client = client
|
|
48
|
+
@timeout = timeout
|
|
49
|
+
@target_id = @client.command('Target.createTarget', url: 'about:blank')['targetId']
|
|
50
|
+
@session = @client.session(
|
|
51
|
+
@client.command('Target.attachToTarget', targetId: @target_id, flatten: true)['sessionId']
|
|
52
|
+
)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Navigates and does not return until the document has actually
|
|
56
|
+
# loaded — Page.navigate only *starts* the navigation, and reading
|
|
57
|
+
# the page before it commits yields an empty document (the very
|
|
58
|
+
# failure mode this class exists to avoid).
|
|
59
|
+
def go_to(url)
|
|
60
|
+
result = @session.command('Page.navigate', url: url)
|
|
61
|
+
error = result['errorText']
|
|
62
|
+
if error && error != 'net::ERR_ABORTED'
|
|
63
|
+
raise Ferrum::StatusError, "Request to #{url} failed (#{error})"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
wait_for_load
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def title
|
|
70
|
+
evaluate('document.title')
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def url
|
|
74
|
+
evaluate('location.href')
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def body
|
|
78
|
+
evaluate('document.documentElement.outerHTML') || ''
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Minimal network facade for the Browser backend. The status comes
|
|
82
|
+
# from the Navigation Timing API (the main document's HTTP status);
|
|
83
|
+
# attached pages need no idle wait — they only exist once loaded.
|
|
84
|
+
def network
|
|
85
|
+
@network ||= Network.new(self)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def close
|
|
89
|
+
@client.command('Target.closeTarget', targetId: @target_id)
|
|
90
|
+
rescue Ferrum::Error
|
|
91
|
+
nil
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def evaluate(expression)
|
|
95
|
+
response = @session.command('Runtime.evaluate', expression: expression, returnByValue: true)
|
|
96
|
+
# Ferrum's command() unwraps CDP's outer "result", leaving
|
|
97
|
+
# { "result" => RemoteObject, "exceptionDetails" => ... }.
|
|
98
|
+
response.dig('result', 'value')
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
# Wait for the DOM to be parsed, not for the load event: many
|
|
104
|
+
# pages (lazy images, analytics, keep-alive connections) never
|
|
105
|
+
# reach readyState "complete", but the content is fully readable
|
|
106
|
+
# at "interactive".
|
|
107
|
+
def wait_for_load
|
|
108
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @timeout
|
|
109
|
+
while Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
|
|
110
|
+
state = ready_state
|
|
111
|
+
return if state && state != 'loading'
|
|
112
|
+
|
|
113
|
+
sleep 0.1
|
|
114
|
+
end
|
|
115
|
+
raise Ferrum::TimeoutError, "page did not finish loading within #{@timeout}s"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def ready_state
|
|
119
|
+
evaluate('document.readyState')
|
|
120
|
+
rescue Ferrum::Error
|
|
121
|
+
# Mid-navigation: the execution context is gone, keep waiting.
|
|
122
|
+
nil
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Status + no-op idle for the Browser backend's fetch flow.
|
|
126
|
+
class Network
|
|
127
|
+
def initialize(page)
|
|
128
|
+
@page = page
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def status
|
|
132
|
+
@page.evaluate(
|
|
133
|
+
"performance.getEntriesByType('navigation')[0] ? " \
|
|
134
|
+
"performance.getEntriesByType('navigation')[0].responseStatus : 0"
|
|
135
|
+
) || 0
|
|
136
|
+
rescue Ferrum::Error
|
|
137
|
+
0
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def wait_for_idle(*)
|
|
141
|
+
nil
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'ferrum'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'net/http'
|
|
6
|
+
require 'uri'
|
|
7
|
+
require_relative '../backend'
|
|
8
|
+
require_relative '../content_filter'
|
|
9
|
+
require_relative '../markdown'
|
|
10
|
+
require_relative 'attached_browser'
|
|
11
|
+
|
|
12
|
+
module Ask
|
|
13
|
+
module WebFetch
|
|
14
|
+
module Backends
|
|
15
|
+
# Last-resort backend: a real Chrome driven over CDP via Ferrum.
|
|
16
|
+
# Renders JavaScript, so it reads SPAs and client-side pages that
|
|
17
|
+
# Local's plain HTTP cannot, and it lets Cloudflare-style *managed*
|
|
18
|
+
# challenges that auto-solve for real browsers complete themselves —
|
|
19
|
+
# neither Local (no JS engine) nor Jina (known datacenter renderer)
|
|
20
|
+
# can do either.
|
|
21
|
+
#
|
|
22
|
+
# Two modes:
|
|
23
|
+
#
|
|
24
|
+
# * Launched — a fresh Chrome the backend starts itself (default when
|
|
25
|
+
# a binary is found). Honest limitation, measured in the wild: sites
|
|
26
|
+
# running aggressive bot protection (patronview.com, npmjs.com,
|
|
27
|
+
# stackoverflow.com all soft-block a freshly launched browser from a
|
|
28
|
+
# datacenter IP) never clear their invisible challenge for a fresh
|
|
29
|
+
# automation profile, however real the Chrome.
|
|
30
|
+
# * Attached — connects to an already-running Chrome via CDP
|
|
31
|
+
# (ASK_WEB_FETCH_CDP_URL, e.g. http://127.0.0.1:9222). That browser
|
|
32
|
+
# is a *trusted context*: long-lived, mature profile, any cookies it
|
|
33
|
+
# has already earned (cf_clearance). Sites that soft-block fresh
|
|
34
|
+
# profiles load normally there. See AttachedBrowser.
|
|
35
|
+
#
|
|
36
|
+
# Opt-in, like Crawl4AI: joins the chain only when a Chrome/Chromium
|
|
37
|
+
# binary is found or ASK_WEB_FETCH_CDP_URL is set. Configure the
|
|
38
|
+
# binary with ASK_WEB_FETCH_CHROME_PATH and a persistent profile
|
|
39
|
+
# directory with ASK_WEB_FETCH_PROFILE (a profile keeps solved cookies
|
|
40
|
+
# across restarts; within one process the browser is reused anyway).
|
|
41
|
+
#
|
|
42
|
+
# HTML is converted through the same Markdown pipeline as Local, with
|
|
43
|
+
# the same default adaptive ContentFilter.
|
|
44
|
+
class Browser < Backend
|
|
45
|
+
# Seconds to let a Cloudflare-style challenge auto-solve before
|
|
46
|
+
# giving up (tunable via Browser.challenge_timeout).
|
|
47
|
+
CHALLENGE_TIMEOUT = 30
|
|
48
|
+
|
|
49
|
+
# Seconds to wait for the network to go quiet after the page loads,
|
|
50
|
+
# so lazy-loaded content is in before we read the DOM.
|
|
51
|
+
IDLE_TIMEOUT = 5
|
|
52
|
+
|
|
53
|
+
# How often to poll for the challenge to clear.
|
|
54
|
+
POLL_INTERVAL = 0.5
|
|
55
|
+
|
|
56
|
+
DEFAULT_PATHS = [
|
|
57
|
+
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
58
|
+
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
|
59
|
+
'/usr/bin/google-chrome',
|
|
60
|
+
'/usr/bin/google-chrome-stable',
|
|
61
|
+
'/usr/bin/chromium',
|
|
62
|
+
'/usr/bin/chromium-browser',
|
|
63
|
+
'/opt/google/chrome/chrome'
|
|
64
|
+
].freeze
|
|
65
|
+
|
|
66
|
+
class << self
|
|
67
|
+
# The shared browser (or a test double). Reuse keeps the solved
|
|
68
|
+
# challenge cookie warm across fetches within one process.
|
|
69
|
+
attr_writer :browser
|
|
70
|
+
|
|
71
|
+
# The ContentFilter applied to every page by default.
|
|
72
|
+
attr_writer :content_filter
|
|
73
|
+
|
|
74
|
+
# Absolute path to a Chrome/Chromium binary; nil when not found.
|
|
75
|
+
attr_writer :path
|
|
76
|
+
|
|
77
|
+
# CDP endpoint of an already-running Chrome ("http://host:port",
|
|
78
|
+
# "…/json/version", or a ws:// browser URL) to attach to instead.
|
|
79
|
+
attr_writer :cdp_url
|
|
80
|
+
|
|
81
|
+
attr_writer :challenge_timeout, :poll_interval
|
|
82
|
+
|
|
83
|
+
# A shared browser, built once and reused. Ferrum is loaded lazily
|
|
84
|
+
# so consumers who never hit this backend pay nothing for it.
|
|
85
|
+
def browser
|
|
86
|
+
return @browser if @browser
|
|
87
|
+
|
|
88
|
+
browser_mutex.synchronize { @browser ||= build_browser }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def content_filter
|
|
92
|
+
@content_filter ||= ContentFilter.default
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def path
|
|
96
|
+
@path || ENV['ASK_WEB_FETCH_CHROME_PATH'] || DEFAULT_PATHS.find { |p| File.exist?(p) }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def cdp_url
|
|
100
|
+
@cdp_url || ENV['ASK_WEB_FETCH_CDP_URL']
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def challenge_timeout
|
|
104
|
+
@challenge_timeout || CHALLENGE_TIMEOUT
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def poll_interval
|
|
108
|
+
@poll_interval || POLL_INTERVAL
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def configured?
|
|
112
|
+
!path.to_s.empty? || !cdp_url.to_s.empty?
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Turns an ASK_WEB_FETCH_CDP_URL into the browser-level WebSocket
|
|
116
|
+
# URL. Accepts a ws:// URL as-is, or an HTTP endpoint
|
|
117
|
+
# ("http://127.0.0.1:9222" or "…/json/version") which is probed
|
|
118
|
+
# for its webSocketDebuggerUrl — the same discovery puppeteer's
|
|
119
|
+
# connect does.
|
|
120
|
+
def ws_url_for(cdp_url)
|
|
121
|
+
return cdp_url if cdp_url.start_with?('ws://', 'wss://')
|
|
122
|
+
|
|
123
|
+
version_url = cdp_url.end_with?('/json/version') ? cdp_url : "#{cdp_url.chomp('/')}/json/version"
|
|
124
|
+
body = Net::HTTP.get(URI(version_url))
|
|
125
|
+
JSON.parse(body)['webSocketDebuggerUrl']
|
|
126
|
+
rescue Errno::ECONNREFUSED, SocketError => e
|
|
127
|
+
raise FetchError, "cannot reach CDP endpoint #{version_url}: #{e.message}"
|
|
128
|
+
rescue JSON::ParserError => e
|
|
129
|
+
raise FetchError, "bad CDP version response from #{version_url}: #{e.message}"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
private
|
|
133
|
+
|
|
134
|
+
def browser_mutex
|
|
135
|
+
@browser_mutex ||= Mutex.new
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def build_browser
|
|
139
|
+
return AttachedBrowser.new(ws_url_for(cdp_url), timeout: CHALLENGE_TIMEOUT + IDLE_TIMEOUT) if cdp_url
|
|
140
|
+
|
|
141
|
+
Ferrum::Browser.new(
|
|
142
|
+
browser_path: path,
|
|
143
|
+
headless: true,
|
|
144
|
+
user_data_dir: ENV['ASK_WEB_FETCH_PROFILE'],
|
|
145
|
+
timeout: CHALLENGE_TIMEOUT + IDLE_TIMEOUT
|
|
146
|
+
)
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def fetch(url)
|
|
151
|
+
page = nil
|
|
152
|
+
raise FetchError, 'no Chrome/Chromium found and no CDP endpoint set' unless self.class.configured?
|
|
153
|
+
raise FetchError, 'ferrum gem unavailable' unless self.class.browser
|
|
154
|
+
|
|
155
|
+
page = self.class.browser.create_page
|
|
156
|
+
page.go_to(url)
|
|
157
|
+
wait_for_challenge(page)
|
|
158
|
+
wait_for_idle(page)
|
|
159
|
+
|
|
160
|
+
status = page.network.status
|
|
161
|
+
raise FetchError, "got #{status} at #{url}" if status && status >= 400
|
|
162
|
+
|
|
163
|
+
body = page.body
|
|
164
|
+
raise FetchError, "challenge page at #{url}" if challenge_page?(body)
|
|
165
|
+
|
|
166
|
+
result = Markdown.generate(body, base_url: url, filter: self.class.content_filter)
|
|
167
|
+
raise EmptyContentError, "no readable content at #{url}" unless usable_content?(result[:content])
|
|
168
|
+
|
|
169
|
+
result
|
|
170
|
+
rescue Ferrum::TimeoutError, Ferrum::ProcessTimeoutError, Ferrum::DeadBrowserError => e
|
|
171
|
+
raise TimeoutError, "#{e.class}: #{e.message}"
|
|
172
|
+
rescue Ferrum::StatusError => e
|
|
173
|
+
raise FetchError, "browser could not load #{url}: #{e.message}"
|
|
174
|
+
rescue Ferrum::Error => e
|
|
175
|
+
raise ServerError, "browser #{e.class}: #{e.message}"
|
|
176
|
+
rescue Errno::ECONNREFUSED, SocketError => e
|
|
177
|
+
raise TimeoutError, "browser connection #{e.class}: #{e.message}"
|
|
178
|
+
ensure
|
|
179
|
+
page&.close
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
private
|
|
183
|
+
|
|
184
|
+
# Cloudflare's managed challenge solves itself in a real browser (JS
|
|
185
|
+
# proof-of-work -> cf_clearance cookie -> reload). We just wait for
|
|
186
|
+
# the interstitial's title to leave the page. If it never does, the
|
|
187
|
+
# challenge is one we can't pass — deterministic, so FetchError.
|
|
188
|
+
def wait_for_challenge(page)
|
|
189
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + self.class.challenge_timeout
|
|
190
|
+
while Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
|
|
191
|
+
return unless challenge_active?(page)
|
|
192
|
+
|
|
193
|
+
sleep self.class.poll_interval
|
|
194
|
+
end
|
|
195
|
+
raise FetchError, "challenge did not auto-solve for #{page.url}"
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def challenge_active?(page)
|
|
199
|
+
challenge_page?(page.title.to_s)
|
|
200
|
+
rescue Ferrum::Error
|
|
201
|
+
# Mid-navigation (the post-solve reload): the JS context is gone,
|
|
202
|
+
# keep waiting.
|
|
203
|
+
true
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Give lazy-loading pages a moment to settle; never fail the fetch
|
|
207
|
+
# on it — some pages stream or poll forever and never go quiet.
|
|
208
|
+
def wait_for_idle(page)
|
|
209
|
+
page.network.wait_for_idle(duration: 0.5, timeout: IDLE_TIMEOUT)
|
|
210
|
+
rescue Ferrum::Error
|
|
211
|
+
nil
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
@@ -59,7 +59,7 @@ module Ask
|
|
|
59
59
|
page
|
|
60
60
|
rescue Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNREFUSED,
|
|
61
61
|
Errno::ECONNRESET, SocketError, URI::InvalidURIError => e
|
|
62
|
-
raise
|
|
62
|
+
raise TimeoutError, "Crawl4AI #{e.class}: #{e.message}"
|
|
63
63
|
end
|
|
64
64
|
|
|
65
65
|
private
|
|
@@ -88,7 +88,9 @@ module Ask
|
|
|
88
88
|
when '401', '403'
|
|
89
89
|
raise FetchError, "Crawl4AI auth error (#{res.code})"
|
|
90
90
|
else
|
|
91
|
-
|
|
91
|
+
# The /crawl service itself answering 5xx (or 4xx beyond auth)
|
|
92
|
+
# is a service-side problem — transient, retrying may succeed.
|
|
93
|
+
raise ServerError, "Crawl4AI returned #{res.code}"
|
|
92
94
|
end
|
|
93
95
|
end
|
|
94
96
|
|
|
@@ -102,12 +104,41 @@ module Ask
|
|
|
102
104
|
raise FetchError, "Crawl4AI crawl failed: #{result['error_message'] || 'unknown error'}"
|
|
103
105
|
end
|
|
104
106
|
|
|
107
|
+
# A rendered page is only content when it actually loaded — a 4xx
|
|
108
|
+
# error page renders fine but is not the page. Crawl4AI reports
|
|
109
|
+
# the target's status on the result; without this check a 404
|
|
110
|
+
# shell passes as a successful fetch.
|
|
111
|
+
status = result['status_code'].to_i
|
|
112
|
+
if status >= 400
|
|
113
|
+
raise(status == 429 || status >= 500 ? ServerError : FetchError, "Crawl4AI got #{status} at #{url}")
|
|
114
|
+
end
|
|
115
|
+
|
|
105
116
|
markdown = result.dig('markdown', 'fit_markdown').to_s
|
|
106
117
|
markdown = result.dig('markdown', 'raw_markdown').to_s if markdown.strip.empty?
|
|
107
|
-
{
|
|
118
|
+
{
|
|
119
|
+
title: result.dig('metadata', 'title'),
|
|
120
|
+
description: result.dig('metadata', 'description') ||
|
|
121
|
+
result.dig('metadata', 'og_description'),
|
|
122
|
+
content: markdown,
|
|
123
|
+
# The page's own redirect, if the crawl followed one — lets
|
|
124
|
+
# consumers record permanent redirects on their ledger instead
|
|
125
|
+
# of silently indexing under the original URL.
|
|
126
|
+
redirected: redirect_of(result)
|
|
127
|
+
}
|
|
108
128
|
rescue JSON::ParserError => e
|
|
109
129
|
raise FetchError, "Crawl4AI bad JSON response: #{e.message}"
|
|
110
130
|
end
|
|
131
|
+
|
|
132
|
+
# A redirect the browser followed: redirected_status_code is the
|
|
133
|
+
# status that started the chain (301/302/308...), redirected_url
|
|
134
|
+
# the final destination.
|
|
135
|
+
def redirect_of(result)
|
|
136
|
+
status = result['redirected_status_code'].to_i
|
|
137
|
+
url = result['redirected_url'].to_s
|
|
138
|
+
return nil if status.zero? || url.empty?
|
|
139
|
+
|
|
140
|
+
{status: status, url: url}
|
|
141
|
+
end
|
|
111
142
|
end
|
|
112
143
|
end
|
|
113
144
|
end
|
|
@@ -35,17 +35,18 @@ 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
|
-
{ title: nil, content: body.strip }
|
|
38
|
+
{ title: nil, description: nil, content: body.strip }
|
|
39
39
|
when '429'
|
|
40
|
-
raise
|
|
40
|
+
raise ServerError, 'rate limited by Jina (429)'
|
|
41
41
|
when '401', '403'
|
|
42
42
|
raise FetchError, "Jina access error (#{res.code})"
|
|
43
43
|
else
|
|
44
|
-
|
|
44
|
+
# 5xx = Jina-side blip (transient); other 4xx = the URL is dead.
|
|
45
|
+
raise(res.code.to_i >= 500 ? ServerError : FetchError, "Jina returned #{res.code}")
|
|
45
46
|
end
|
|
46
47
|
rescue Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNREFUSED,
|
|
47
48
|
Errno::ECONNRESET, SocketError, URI::InvalidURIError => e
|
|
48
|
-
raise
|
|
49
|
+
raise TimeoutError, "Jina #{e.class}: #{e.message}"
|
|
49
50
|
end
|
|
50
51
|
end
|
|
51
52
|
end
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
require 'net/http'
|
|
4
4
|
require 'uri'
|
|
5
|
-
require 'nokogiri'
|
|
6
|
-
require 'reverse_markdown'
|
|
7
5
|
require_relative '../backend'
|
|
6
|
+
require_relative '../content_filter'
|
|
7
|
+
require_relative '../markdown'
|
|
8
8
|
|
|
9
9
|
module Ask
|
|
10
10
|
module WebFetch
|
|
@@ -12,50 +12,58 @@ module Ask
|
|
|
12
12
|
# Default backend: pure Ruby Net::HTTP + Nokogiri + reverse_markdown.
|
|
13
13
|
# No external service or API key; mirrors ask-web-search's self-hosted
|
|
14
14
|
# SearXNG approach.
|
|
15
|
+
#
|
|
16
|
+
# HTML is converted through Ask::WebFetch::Markdown with a default
|
|
17
|
+
# ContentFilter, so the "fit" content — pruned by text density rather
|
|
18
|
+
# than by keyword — is what comes back.
|
|
15
19
|
class Local < Backend
|
|
16
20
|
MAX_REDIRECTS = 5
|
|
17
21
|
OPEN_TIMEOUT = 5
|
|
18
22
|
READ_TIMEOUT = 15
|
|
19
23
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
class << self
|
|
25
|
+
# The ContentFilter applied to every page by default. Set to nil to
|
|
26
|
+
# convert the article/main region without pruning.
|
|
27
|
+
attr_writer :content_filter
|
|
28
|
+
|
|
29
|
+
def content_filter
|
|
30
|
+
@content_filter ||= ContentFilter.default
|
|
31
|
+
end
|
|
32
|
+
end
|
|
26
33
|
|
|
27
34
|
def fetch(url)
|
|
28
|
-
body, content_type = fetch_html(url)
|
|
35
|
+
body, content_type, redirect = fetch_html(url)
|
|
29
36
|
raise FetchError, "expected HTML from #{url}, got #{content_type}" unless content_type.include?('html')
|
|
30
37
|
raise FetchError, "challenge page at #{url}" if challenge_page?(body)
|
|
31
38
|
|
|
32
39
|
page = to_markdown(body, url)
|
|
40
|
+
page[:redirected] = redirect
|
|
33
41
|
raise EmptyContentError, "no readable content at #{url}" unless usable_content?(page[:content])
|
|
34
42
|
|
|
35
43
|
page
|
|
36
44
|
rescue Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNREFUSED,
|
|
37
45
|
Errno::ECONNRESET, SocketError, URI::InvalidURIError => e
|
|
38
|
-
raise
|
|
46
|
+
raise TimeoutError, "#{e.class}: #{e.message}"
|
|
39
47
|
end
|
|
40
48
|
|
|
41
|
-
# Parses +html+ and returns { title:, content: } where
|
|
42
|
-
# clean markdown
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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 }
|
|
49
|
+
# Parses +html+ and returns { title:, description:, content: } where
|
|
50
|
+
# content is clean markdown and description is the page's own meta
|
|
51
|
+
# description (meta name=description, then og:description) — what the
|
|
52
|
+
# site says about itself, free and authoritative.
|
|
53
|
+
def to_markdown(html, url)
|
|
54
|
+
Markdown.generate(html, base_url: url, filter: self.class.content_filter)
|
|
51
55
|
end
|
|
52
56
|
|
|
53
57
|
private
|
|
54
58
|
|
|
55
|
-
# GET with redirect following (max MAX_REDIRECTS hops).
|
|
59
|
+
# GET with redirect following (max MAX_REDIRECTS hops). Returns
|
|
60
|
+
# [body, content_type, redirect] where redirect is nil when the
|
|
61
|
+
# URL answered directly, else {status: first hop's status,
|
|
62
|
+
# url: final destination} — the chain the crawler followed.
|
|
56
63
|
def fetch_html(url)
|
|
57
64
|
uri = URI(url)
|
|
58
65
|
hops = 0
|
|
66
|
+
first_hop_status = nil
|
|
59
67
|
loop do
|
|
60
68
|
http = Net::HTTP.new(uri.host, uri.port)
|
|
61
69
|
http.open_timeout = OPEN_TIMEOUT
|
|
@@ -65,35 +73,22 @@ module Ask
|
|
|
65
73
|
req['User-Agent'] = USER_AGENT
|
|
66
74
|
req['Accept'] = 'text/html,application/xhtml+xml'
|
|
67
75
|
res = http.request(req)
|
|
68
|
-
return [res.body, res['content-type'].to_s] if res.code.start_with?('2')
|
|
76
|
+
return [res.body, res['content-type'].to_s, redirect_info(first_hop_status, uri)] if res.code.start_with?('2')
|
|
69
77
|
|
|
70
|
-
|
|
78
|
+
unless res.code.start_with?('3') && res['location']
|
|
79
|
+
# 4xx (other than 429) = the URL is dead; 429/5xx = transient.
|
|
80
|
+
code = res.code.to_i
|
|
81
|
+
raise(code == 429 || code >= 500 ? ServerError : FetchError, "got #{res.code} from #{url}")
|
|
82
|
+
end
|
|
71
83
|
raise FetchError, "hit a redirect loop at #{url}" if (hops += 1) > MAX_REDIRECTS
|
|
72
84
|
|
|
85
|
+
first_hop_status ||= res.code.to_i
|
|
73
86
|
uri = URI.join(uri, res['location'])
|
|
74
87
|
end
|
|
75
88
|
end
|
|
76
89
|
|
|
77
|
-
def
|
|
78
|
-
|
|
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
|
|
90
|
+
def redirect_info(status, uri)
|
|
91
|
+
status && {status: status, url: uri.to_s}
|
|
97
92
|
end
|
|
98
93
|
end
|
|
99
94
|
end
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'nokogiri'
|
|
4
|
+
|
|
5
|
+
module Ask
|
|
6
|
+
module WebFetch
|
|
7
|
+
# Density-based content pruning, ported from crawl4ai's
|
|
8
|
+
# PruningContentFilter (Apache-2.0, https://github.com/unclecode/crawl4ai).
|
|
9
|
+
#
|
|
10
|
+
# Scores every element in the page by how much of it is real text versus
|
|
11
|
+
# markup and links, then removes elements scoring below a threshold. Where
|
|
12
|
+
# a keyword scraper fails ("remove anything with 'nav' in the class"),
|
|
13
|
+
# this keeps content that scores well regardless of its label and drops
|
|
14
|
+
# link-farms that happen to dodge the keywords.
|
|
15
|
+
#
|
|
16
|
+
# Two deliberate deviations from crawl4ai, both adding tags crawl4ai's own
|
|
17
|
+
# included_tags list marks as content but whose tag_weights map left out
|
|
18
|
+
# (they defaulted to 0.5 and could be pruned despite being article
|
|
19
|
+
# content): <main> joins <article>/<section> at the top tier, and the
|
|
20
|
+
# table/pre/code family gets content-worthy weights. Third: the class/id
|
|
21
|
+
# chrome penalty actually subtracts from the score — crawl4ai floors it at
|
|
22
|
+
# zero (max(0, ...)), which makes its negative-patterns metric inert.
|
|
23
|
+
# Fourth: <svg> is excluded outright — chart text would otherwise leak
|
|
24
|
+
# into the markdown as concatenated axis labels ("01M2M3M", "10Apr15Apr").
|
|
25
|
+
class ContentFilter
|
|
26
|
+
# Structural boilerplate, removed before scoring ever runs. The
|
|
27
|
+
# preserve_* whitelist cannot save these.
|
|
28
|
+
EXCLUDED_TAGS = %w[nav footer header aside script style form iframe noscript svg].freeze
|
|
29
|
+
|
|
30
|
+
# Class/id fragments that mark non-content chrome; matching one knocks
|
|
31
|
+
# 0.5 off the node's score.
|
|
32
|
+
NEGATIVE_PATTERNS = /nav|footer|header|sidebar|ads|comment|promo|advert|social|share/i.freeze
|
|
33
|
+
|
|
34
|
+
# Semantic tag weights: how likely a tag is to carry article content.
|
|
35
|
+
TAG_WEIGHTS = {
|
|
36
|
+
'div' => 0.5, 'p' => 1.0, 'article' => 1.5, 'section' => 1.0, 'main' => 1.4,
|
|
37
|
+
'span' => 0.3, 'li' => 0.5, 'ul' => 0.5, 'ol' => 0.5,
|
|
38
|
+
'h1' => 1.2, 'h2' => 1.1, 'h3' => 1.0, 'h4' => 0.9, 'h5' => 0.8, 'h6' => 0.7,
|
|
39
|
+
'table' => 1.0, 'tr' => 0.8, 'td' => 0.8, 'th' => 0.8,
|
|
40
|
+
'pre' => 1.0, 'code' => 0.9, 'blockquote' => 1.0
|
|
41
|
+
}.freeze
|
|
42
|
+
|
|
43
|
+
# Used only by the dynamic threshold, which loosens the bar for tags
|
|
44
|
+
# that usually carry content.
|
|
45
|
+
TAG_IMPORTANCE = {
|
|
46
|
+
'article' => 1.5, 'main' => 1.4, 'section' => 1.3, 'p' => 1.2,
|
|
47
|
+
'h1' => 1.4, 'h2' => 1.3, 'h3' => 1.2, 'div' => 0.7, 'span' => 0.6
|
|
48
|
+
}.freeze
|
|
49
|
+
|
|
50
|
+
METRIC_WEIGHTS = {
|
|
51
|
+
text_density: 0.4, link_density: 0.2, tag_weight: 0.2,
|
|
52
|
+
class_id_weight: 0.1, text_length: 0.1
|
|
53
|
+
}.freeze
|
|
54
|
+
|
|
55
|
+
DEFAULT_THRESHOLD = 0.48
|
|
56
|
+
|
|
57
|
+
# The filter the backends use by default: the adaptive threshold, which
|
|
58
|
+
# loosens the bar for content-carrying tags and text-heavy nodes and
|
|
59
|
+
# tightens it for link-heavy ones — catching the classic
|
|
60
|
+
# sidebar-of-links that the fixed bar lets through.
|
|
61
|
+
def self.default
|
|
62
|
+
new(threshold_type: :dynamic)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# threshold:: score below this removes the element
|
|
66
|
+
# threshold_type:: :fixed or :dynamic — dynamic loosens the bar for
|
|
67
|
+
# important tags and text-heavy, link-light nodes
|
|
68
|
+
# min_word_threshold:: elements with fewer words are removed outright
|
|
69
|
+
# preserve_classes:: class names never pruned, regardless of score
|
|
70
|
+
# preserve_tags:: tag names never pruned, regardless of score
|
|
71
|
+
def initialize(threshold: DEFAULT_THRESHOLD, threshold_type: :fixed,
|
|
72
|
+
min_word_threshold: nil, preserve_classes: [], preserve_tags: [])
|
|
73
|
+
@threshold = threshold
|
|
74
|
+
@threshold_type = threshold_type.to_sym
|
|
75
|
+
@min_word_threshold = min_word_threshold
|
|
76
|
+
@preserve_classes = preserve_classes
|
|
77
|
+
@preserve_tags = preserve_tags
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# html -> [String] the surviving top-level blocks, as HTML fragments.
|
|
81
|
+
# Empty input yields an empty array; malformed HTML is parsed with
|
|
82
|
+
# Nokogiri's recover mode and never raises.
|
|
83
|
+
def filter_content(html)
|
|
84
|
+
return [] if html.nil? || html.empty?
|
|
85
|
+
|
|
86
|
+
doc = Nokogiri::HTML(html)
|
|
87
|
+
body = doc.at_css('body') || doc.at_css('html')
|
|
88
|
+
return [] unless body
|
|
89
|
+
|
|
90
|
+
remove_comments(doc)
|
|
91
|
+
body.css(EXCLUDED_TAGS.join(',')).each(&:remove)
|
|
92
|
+
body.element_children.each { |child| prune(child) }
|
|
93
|
+
|
|
94
|
+
body.element_children.select { |el| el.text.strip.length.positive? }.map(&:to_html)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# html -> String, the surviving blocks wrapped in <div>s, ready for
|
|
98
|
+
# markdown conversion. Mirrors crawl4ai's generator, which wraps the
|
|
99
|
+
# filtered blocks before converting them.
|
|
100
|
+
def fit_html(html)
|
|
101
|
+
filter_content(html).map { |block| "<div>#{block}</div>" }.join
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
def prune(node)
|
|
107
|
+
if preserved?(node)
|
|
108
|
+
# A whitelisted node survives whole — no scoring, no child pruning.
|
|
109
|
+
return
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
text_len = node.text.gsub(/\s+/, '').length
|
|
113
|
+
tag_len = node.inner_html.length
|
|
114
|
+
link_text_len = node.element_children
|
|
115
|
+
.select { |child| child.name == 'a' }
|
|
116
|
+
.sum { |a| a.text.strip.length }
|
|
117
|
+
|
|
118
|
+
if should_remove?(node, text_len, tag_len, link_text_len)
|
|
119
|
+
node.remove
|
|
120
|
+
else
|
|
121
|
+
node.element_children.each { |child| prune(child) }
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def preserved?(node)
|
|
126
|
+
return true if @preserve_tags.include?(node.name)
|
|
127
|
+
|
|
128
|
+
classes = node['class']
|
|
129
|
+
classes && @preserve_classes.any? { |c| classes.split.include?(c) }
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def should_remove?(node, text_len, tag_len, link_text_len)
|
|
133
|
+
if @threshold_type == :dynamic
|
|
134
|
+
threshold = @threshold
|
|
135
|
+
importance = TAG_IMPORTANCE.fetch(node.name, 0.7)
|
|
136
|
+
text_ratio = tag_len.positive? ? text_len.to_f / tag_len : 0.0
|
|
137
|
+
link_ratio = text_len.positive? ? link_text_len.to_f / text_len : 1.0
|
|
138
|
+
threshold *= 0.8 if importance > 1.0
|
|
139
|
+
threshold *= 0.9 if text_ratio > 0.4
|
|
140
|
+
threshold *= 1.2 if link_ratio > 0.6
|
|
141
|
+
score(node, text_len, tag_len, link_text_len) < threshold
|
|
142
|
+
else
|
|
143
|
+
score(node, text_len, tag_len, link_text_len) < @threshold
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Weighted composite in [0, ~1.4]: text density, link density, semantic
|
|
148
|
+
# tag weight, class/id chrome penalty, and log-scaled text length.
|
|
149
|
+
#
|
|
150
|
+
# text_len is measured with all whitespace removed, matching
|
|
151
|
+
# BeautifulSoup's get_text(strip=True) that crawl4ai uses — stripping
|
|
152
|
+
# only the ends would let inter-tag whitespace inflate the density and
|
|
153
|
+
# length metrics and keep link-farms alive.
|
|
154
|
+
def score(node, text_len, tag_len, link_text_len)
|
|
155
|
+
return -1.0 if @min_word_threshold && node.text.split.size < @min_word_threshold
|
|
156
|
+
|
|
157
|
+
density = tag_len.positive? ? text_len.to_f / tag_len : 0.0
|
|
158
|
+
link_density = text_len.positive? ? 1.0 - (link_text_len.to_f / text_len) : 0.0
|
|
159
|
+
tag_score = TAG_WEIGHTS.fetch(node.name, 0.5)
|
|
160
|
+
class_score = class_id_score(node)
|
|
161
|
+
|
|
162
|
+
weighted = 0.0
|
|
163
|
+
weighted += METRIC_WEIGHTS[:text_density] * density
|
|
164
|
+
weighted += METRIC_WEIGHTS[:link_density] * link_density
|
|
165
|
+
weighted += METRIC_WEIGHTS[:tag_weight] * tag_score
|
|
166
|
+
weighted += METRIC_WEIGHTS[:class_id_weight] * class_score
|
|
167
|
+
weighted += METRIC_WEIGHTS[:text_length] * Math.log(text_len + 1)
|
|
168
|
+
weighted / METRIC_WEIGHTS.values.sum
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def class_id_score(node)
|
|
172
|
+
score = 0.0
|
|
173
|
+
score -= 0.5 if node['class'].to_s.match?(NEGATIVE_PATTERNS)
|
|
174
|
+
score -= 0.5 if node['id'].to_s.match?(NEGATIVE_PATTERNS)
|
|
175
|
+
score
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def remove_comments(doc)
|
|
179
|
+
doc.xpath('//comment()').each(&:remove)
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'nokogiri'
|
|
4
|
+
require 'reverse_markdown'
|
|
5
|
+
require 'uri'
|
|
6
|
+
|
|
7
|
+
module Ask
|
|
8
|
+
module WebFetch
|
|
9
|
+
# HTML -> clean markdown, shared by the backends. Owns the conversion
|
|
10
|
+
# pipeline that used to live inside Local:
|
|
11
|
+
#
|
|
12
|
+
# 1. pick the content region — either a ContentFilter prunes the whole
|
|
13
|
+
# page by text density, or the article/main/body region is taken
|
|
14
|
+
# 2. drop chrome (scripts, nav, forms, keyword-matched junk)
|
|
15
|
+
# 3. convert with reverse_markdown
|
|
16
|
+
# 4. optionally rewrite inline links as numbered citations (ported from
|
|
17
|
+
# crawl4ai's DefaultMarkdownGenerator, Apache-2.0)
|
|
18
|
+
module Markdown
|
|
19
|
+
# Class/id fragments that mark navigation chrome worth dropping, e.g.
|
|
20
|
+
# "vector-page-toolbar", "sidebar", "toc".
|
|
21
|
+
NAV_CHROME_RE = /
|
|
22
|
+
(^|[\s_-])(nav|menu|toolbar|breadcrumb|sidebar|toc|footer|header|
|
|
23
|
+
banner|pagination|search|cookie|modal|popup)([\s_-]|$)
|
|
24
|
+
/ix
|
|
25
|
+
|
|
26
|
+
# Tags that never carry article content, dropped before conversion.
|
|
27
|
+
EXCLUDED_TAGS = %w[script style noscript nav footer header aside form iframe svg].freeze
|
|
28
|
+
|
|
29
|
+
# Matches markdown links, optionally with a title, for citation
|
|
30
|
+
# rewriting. Ported from crawl4ai's LINK_PATTERN.
|
|
31
|
+
LINK_PATTERN = /
|
|
32
|
+
!?\[((?:[^\[\]]|\[(?:[^\[\]]|\[[^\]]*\])*\])*)\]
|
|
33
|
+
\(((?:[^()\s]|\([^()]*\))*)(?:\s+"([^"]*)")?\)
|
|
34
|
+
/x
|
|
35
|
+
|
|
36
|
+
module_function
|
|
37
|
+
|
|
38
|
+
# html -> { title:, description:, content: }.
|
|
39
|
+
#
|
|
40
|
+
# When +filter+ is given (a ContentFilter), content is the pruned "fit"
|
|
41
|
+
# version. If pruning empties the page the article/main/body region is
|
|
42
|
+
# used instead, so a sparse page never degrades to empty content. When
|
|
43
|
+
# +citations+ is true, inline links become numbered citations with a
|
|
44
|
+
# trailing References section.
|
|
45
|
+
def generate(html, base_url: '', filter: nil, citations: false)
|
|
46
|
+
html = html.to_s
|
|
47
|
+
doc = Nokogiri::HTML(html)
|
|
48
|
+
input = filtered(html, doc, filter)
|
|
49
|
+
markdown = ReverseMarkdown.convert(input, unknown_tags: :bypass, github_flavored: true)
|
|
50
|
+
markdown = clean(markdown)
|
|
51
|
+
markdown = cite(markdown, base_url) if citations
|
|
52
|
+
|
|
53
|
+
{
|
|
54
|
+
title: doc.at('title')&.text&.strip,
|
|
55
|
+
description: meta_description(doc),
|
|
56
|
+
content: markdown
|
|
57
|
+
}
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Picks the article/main/body region and returns it scrubbed, as HTML.
|
|
61
|
+
def cleaned_html(doc)
|
|
62
|
+
candidate = doc.at('article') || doc.at('main') || doc.at('[role="main"]') || doc.at('body') || doc
|
|
63
|
+
scrub(candidate)
|
|
64
|
+
candidate.to_html
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Remove chrome INSIDE the candidate only, never ancestors.
|
|
68
|
+
def scrub(candidate)
|
|
69
|
+
candidate.css(EXCLUDED_TAGS.join(',')).each(&:remove)
|
|
70
|
+
candidate.css('*[id], *[class]').each do |el|
|
|
71
|
+
next if el.equal?(candidate)
|
|
72
|
+
|
|
73
|
+
id_cls = [el['id'], el['class']].compact.join(' ')
|
|
74
|
+
el.remove if id_cls.match?(NAV_CHROME_RE)
|
|
75
|
+
end
|
|
76
|
+
candidate
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def meta_description(doc)
|
|
80
|
+
desc = doc.at('meta[name="description"]')&.[]('content')&.strip
|
|
81
|
+
desc = doc.at('meta[property="og:description"]')&.[]('content')&.strip if desc.to_s.empty?
|
|
82
|
+
desc
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Converts inline links to numbered citations and returns
|
|
86
|
+
# [converted, references] where references is a "## References" block
|
|
87
|
+
# listing each unique URL once — empty when the markdown had no links.
|
|
88
|
+
# Ported from crawl4ai.
|
|
89
|
+
def convert_links_to_citations(markdown, base_url = '')
|
|
90
|
+
link_map = {} # url => [number, description]
|
|
91
|
+
parts = []
|
|
92
|
+
last_end = 0
|
|
93
|
+
counter = 1
|
|
94
|
+
|
|
95
|
+
pos = 0
|
|
96
|
+
while (match = LINK_PATTERN.match(markdown, pos))
|
|
97
|
+
parts << markdown[last_end...match.begin(0)]
|
|
98
|
+
text, url, title = match.captures
|
|
99
|
+
|
|
100
|
+
absolute = url.start_with?('http://', 'https://', 'mailto:')
|
|
101
|
+
url = fast_urljoin(base_url, url) if !base_url.to_s.empty? && !absolute
|
|
102
|
+
|
|
103
|
+
unless link_map.key?(url)
|
|
104
|
+
description = +''
|
|
105
|
+
description << title.to_s if title
|
|
106
|
+
if text && text != title
|
|
107
|
+
description << (description.empty? ? text.to_s : " - #{text}")
|
|
108
|
+
end
|
|
109
|
+
link_map[url] = [counter, description.empty? ? '' : ": #{description}"]
|
|
110
|
+
counter += 1
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
number = link_map[url][0]
|
|
114
|
+
parts << (match[0].start_with?('!') ? "![#{text}⟨#{number}⟩]" : "#{text}⟨#{number}⟩")
|
|
115
|
+
last_end = match.end(0)
|
|
116
|
+
pos = match.end(0)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
return [markdown, ''] if link_map.empty?
|
|
120
|
+
|
|
121
|
+
parts << markdown[last_end..].to_s
|
|
122
|
+
references = ["\n\n## References\n\n"]
|
|
123
|
+
link_map.sort_by { |_, (number, _)| number }.each do |url, (number, description)|
|
|
124
|
+
references << "⟨#{number}⟩ #{url}#{description}\n"
|
|
125
|
+
end
|
|
126
|
+
[parts.join, references.join]
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def clean(markdown)
|
|
130
|
+
markdown.gsub(/[ \t]+\n/, "\n")
|
|
131
|
+
.gsub(/\n{3,}/, "\n\n")
|
|
132
|
+
.strip
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Resolves +url+ against +base+ without re-parsing absolute URLs.
|
|
136
|
+
# crawl4ai's version of this concatenates root-absolute paths onto the
|
|
137
|
+
# base ("/stats" against "https://x.com/news/2026" becomes
|
|
138
|
+
# "…/news/2026/stats"), which mangles root-relative links; URI.join has
|
|
139
|
+
# correct URL semantics and the passthrough covers the hot path.
|
|
140
|
+
def fast_urljoin(base, url)
|
|
141
|
+
return url if url.start_with?('http://', 'https://', 'mailto:', '//')
|
|
142
|
+
|
|
143
|
+
URI.join(base, url).to_s
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def filtered(html, doc, filter)
|
|
147
|
+
return cleaned_html(doc) unless filter
|
|
148
|
+
|
|
149
|
+
fit = filter.fit_html(html)
|
|
150
|
+
fit.empty? ? cleaned_html(doc) : fit
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def cite(markdown, base_url)
|
|
154
|
+
converted, references = convert_links_to_citations(markdown, base_url)
|
|
155
|
+
"#{converted}#{references}"
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
data/lib/ask/web_fetch/tool.rb
CHANGED
|
@@ -14,21 +14,25 @@ module Ask
|
|
|
14
14
|
#
|
|
15
15
|
# Chain: Crawl4AI first (self-hosted headless-Chromium renderer; used
|
|
16
16
|
# when the CRAWL4AI_URL service is present, fails fast when it isn't),
|
|
17
|
-
# then the local fetcher,
|
|
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.
|
|
18
21
|
class WebFetch < Ask::Tool
|
|
19
22
|
DEFAULT_MAX_CHARS = 20_000
|
|
20
23
|
|
|
21
24
|
# Backend chain, tried in order. Crawl4AI leads when configured
|
|
22
25
|
# (CRAWL4AI_URL), so a present self-hosted renderer is preferred;
|
|
23
|
-
# otherwise Local, with Jina as the last resort
|
|
24
|
-
# future backends; each
|
|
25
|
-
# implement #fetch(url).
|
|
26
|
+
# otherwise Local, with Jina as the last resort, and Browser appended
|
|
27
|
+
# when Chrome is available. Swap or extend for future backends; each
|
|
28
|
+
# must subclass Ask::WebFetch::Backend and implement #fetch(url).
|
|
26
29
|
def self.backends
|
|
27
30
|
@backends ||= begin
|
|
28
31
|
chain = [Ask::WebFetch::Backends::Local, Ask::WebFetch::Backends::Jina]
|
|
29
32
|
if Ask::WebFetch::Backends::Crawl4Ai.configured?
|
|
30
33
|
chain.unshift(Ask::WebFetch::Backends::Crawl4Ai)
|
|
31
34
|
end
|
|
35
|
+
chain << Ask::WebFetch::Backends::Browser if Ask::WebFetch::Backends::Browser.configured?
|
|
32
36
|
chain
|
|
33
37
|
end
|
|
34
38
|
end
|
data/lib/ask/web_fetch.rb
CHANGED
|
@@ -2,7 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative 'web_fetch/version'
|
|
4
4
|
require_relative 'web_fetch/backend'
|
|
5
|
+
require_relative 'web_fetch/content_filter'
|
|
6
|
+
require_relative 'web_fetch/markdown'
|
|
5
7
|
require_relative 'web_fetch/backends/local'
|
|
6
8
|
require_relative 'web_fetch/backends/crawl4ai'
|
|
7
9
|
require_relative 'web_fetch/backends/jina'
|
|
10
|
+
require_relative 'web_fetch/backends/browser'
|
|
8
11
|
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.
|
|
4
|
+
version: 0.4.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Kaka Ruto
|
|
@@ -23,6 +23,20 @@ dependencies:
|
|
|
23
23
|
- - ">="
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
25
|
version: '0.1'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: ferrum
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '0.14'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '0.14'
|
|
26
40
|
- !ruby/object:Gem::Dependency
|
|
27
41
|
name: nokogiri
|
|
28
42
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -109,8 +123,10 @@ dependencies:
|
|
|
109
123
|
version: '3.26'
|
|
110
124
|
description: Provides Ask::Tools::WebFetch, a tool that fetches a URL and converts
|
|
111
125
|
its content to clean markdown for LLM consumption. Defaults to a pure Ruby backend
|
|
112
|
-
(Net::HTTP + Nokogiri + reverse_markdown) with
|
|
113
|
-
|
|
126
|
+
(Net::HTTP + 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.
|
|
114
130
|
email:
|
|
115
131
|
- kaka@myrrlabs.com
|
|
116
132
|
executables: []
|
|
@@ -122,9 +138,13 @@ files:
|
|
|
122
138
|
- lib/ask-web-fetch.rb
|
|
123
139
|
- lib/ask/web_fetch.rb
|
|
124
140
|
- lib/ask/web_fetch/backend.rb
|
|
141
|
+
- lib/ask/web_fetch/backends/attached_browser.rb
|
|
142
|
+
- lib/ask/web_fetch/backends/browser.rb
|
|
125
143
|
- lib/ask/web_fetch/backends/crawl4ai.rb
|
|
126
144
|
- lib/ask/web_fetch/backends/jina.rb
|
|
127
145
|
- lib/ask/web_fetch/backends/local.rb
|
|
146
|
+
- lib/ask/web_fetch/content_filter.rb
|
|
147
|
+
- lib/ask/web_fetch/markdown.rb
|
|
128
148
|
- lib/ask/web_fetch/tool.rb
|
|
129
149
|
- lib/ask/web_fetch/version.rb
|
|
130
150
|
homepage: https://github.com/ask-rb/ask-web-fetch
|