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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9286a544b4924f7dcde61b273e367240a81f8702c62510228e2d97f330c3617e
4
- data.tar.gz: '08f55182a88cbc0c62e77f2acbc0570dc09cd28476f15caa78868b4e6519afd8'
3
+ metadata.gz: 318ff9c594a7d5c52a710507efd3da2917bbe3319471a3e0bac8bce3a01ed30e
4
+ data.tar.gz: f5ae0d23b351044ecc21f4e2c4d2ec66a13d8258cd9a86d6947f0c7698c6e703
5
5
  SHA512:
6
- metadata.gz: c868cc6a828fea7d421064199991e054c57fb85d02a2d7713ea043d567960060da687a998095ee64e6ae0e6e1424a2219e7046b193077dfac7128e99256c485b
7
- data.tar.gz: b071c880ba9594cd21778e4debff5894a36eb8d2fbddd7aabbf88efcf0195c922d7a1aa84b957ddcecdc4364aafa6c87328166ebe7e1d533f5b3d4e3dfacbd4d
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 (network error, non-2xx, challenge
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 FetchError, "Crawl4AI #{e.class}: #{e.message}"
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
- raise FetchError, "Crawl4AI returned #{res.code}"
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
- { title: result.dig('metadata', 'title'), content: markdown }
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 FetchError, 'rate limited by Jina (429)'
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
- raise FetchError, "Jina returned #{res.code}"
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 FetchError, "Jina #{e.class}: #{e.message}"
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
- # 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
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 FetchError, "#{e.class}: #{e.message}"
46
+ raise TimeoutError, "#{e.class}: #{e.message}"
39
47
  end
40
48
 
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 }
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
- raise FetchError, "got #{res.code} from #{url}" unless res.code.start_with?('3') && res['location']
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 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
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
@@ -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, with Jina Reader as the last resort.
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. Swap or extend for
24
- # future backends; each must subclass Ask::WebFetch::Backend and
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ask
4
4
  module WebFetch
5
- VERSION = '0.3.1'
5
+ VERSION = '0.4.1'
6
6
  end
7
7
  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.3.1
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 an automatic Jina Reader fallback
113
- for JS-rendered or blocked pages. Works with any ask-rb chat or agent.
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