ask-web-fetch 0.4.1 → 0.5.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/README.md +18 -3
- data/lib/ask/web_fetch/backend.rb +55 -4
- data/lib/ask/web_fetch/backends/browser.rb +1 -0
- data/lib/ask/web_fetch/backends/crawl4ai.rb +8 -0
- data/lib/ask/web_fetch/backends/jina.rb +10 -2
- data/lib/ask/web_fetch/backends/local.rb +55 -26
- data/lib/ask/web_fetch/http.rb +85 -0
- data/lib/ask/web_fetch/markdown.rb +6 -0
- data/lib/ask/web_fetch/noise_filter.rb +143 -0
- data/lib/ask/web_fetch/version.rb +1 -1
- data/lib/ask/web_fetch.rb +1 -0
- metadata +18 -16
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0f49a52ecb57c57ee29ce475ad791f70d30fe52946c94c6bd6842dcccc477392
|
|
4
|
+
data.tar.gz: b67e97adb54db5b6a775d1ece43df51c0e5443a6ef9e2cd3a17d547af5f7d1dc
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 28dd03ea3e7aae930925ea2d582ff074beba7d3030d762830024e41506a23fd671bb166fa5809417a824e8d2c1729679d00f3d710fa82de05b2ca41038dd39de
|
|
7
|
+
data.tar.gz: 84815a2d6504c81df9d81aab2fd679d5bb334f64893b05264cc4185be0e7d7439f357461494c65811cbdb16e0e4548b0b2db290f52b747412678eb953c5f40b3
|
data/README.md
CHANGED
|
@@ -26,6 +26,14 @@ first success:
|
|
|
26
26
|
headless Chromium, so it renders JS pages the Local backend can't. Free
|
|
27
27
|
without a key (~20 req/min per IP); set `JINA_API_KEY` for higher limits.
|
|
28
28
|
|
|
29
|
+
Every backend's markdown runs through a shared cleanup (`Markdown.clean`):
|
|
30
|
+
decorative symbol noise — the long, letter-free, repetitive character
|
|
31
|
+
streams some pages render as animated backgrounds or section dividers — is
|
|
32
|
+
stripped, and whitespace is normalized. The filter is conservative: code
|
|
33
|
+
blocks, tables, headings, blockquotes, inline code, and short ASCII-art
|
|
34
|
+
fragments always survive. Tunable via `Ask::WebFetch::NoiseFilter.filter(
|
|
35
|
+
markdown, min_length:, max_entropy:)`.
|
|
36
|
+
|
|
29
37
|
The tool falls back automatically: if Crawl4AI is absent or fails, Local is
|
|
30
38
|
tried (blocked, timeout, non-HTML, anti-bot challenge, or a JS page with no
|
|
31
39
|
server-side content), then Jina. If every backend fails (rate limit, access
|
|
@@ -68,9 +76,13 @@ Ask::Tools::WebFetch.backends = [MyBackend, Ask::WebFetch::Backends::Local]
|
|
|
68
76
|
|
|
69
77
|
`#fetch` must return `{ title: String|nil, content: String }` and raise
|
|
70
78
|
`Ask::WebFetch::FetchError` (hard failure) or `EmptyContentError` (page
|
|
71
|
-
fetched but nothing usable).
|
|
72
|
-
|
|
73
|
-
|
|
79
|
+
fetched but nothing usable). Run the returned markdown through
|
|
80
|
+
`Ask::WebFetch::Markdown.clean` (backends that convert HTML get this from
|
|
81
|
+
`Markdown.generate`; backends fed pre-converted markdown must call it
|
|
82
|
+
explicitly) so the shared noise removal and whitespace normalization apply
|
|
83
|
+
everywhere. The chain then handles ordering and fallback for you. For
|
|
84
|
+
tests, `Ask::Tools::WebFetch.backends = [...]` can be swapped and
|
|
85
|
+
restored.
|
|
74
86
|
|
|
75
87
|
## Installation
|
|
76
88
|
|
|
@@ -125,6 +137,9 @@ No configuration required for the default chain. Optional knobs:
|
|
|
125
137
|
no content unless Crawl4AI is configured — set `CRAWL4AI_URL` to handle
|
|
126
138
|
them with a self-hosted renderer.
|
|
127
139
|
- Some sites block non-browser requests regardless of User-Agent.
|
|
140
|
+
- Symbol streams that a converter merges *into* a content line (rather
|
|
141
|
+
than leaving them as their own lines) are out of scope for the
|
|
142
|
+
markdown-level NoiseFilter — that would need a DOM-level pass.
|
|
128
143
|
|
|
129
144
|
## Full documentation
|
|
130
145
|
|
|
@@ -32,7 +32,12 @@ module Ask
|
|
|
32
32
|
# 2. #fetch must return { title: String|nil, content: String }
|
|
33
33
|
# 3. #fetch must raise FetchError (hard failure) or
|
|
34
34
|
# EmptyContentError (page fetched but nothing usable) on failure
|
|
35
|
-
# 4.
|
|
35
|
+
# 4. run the page's markdown through Ask::WebFetch::Markdown.clean
|
|
36
|
+
# before returning it — backends that hold HTML get this from
|
|
37
|
+
# Markdown.generate, backends fed pre-converted markdown (Jina,
|
|
38
|
+
# Crawl4AI) must call it explicitly so the shared noise removal
|
|
39
|
+
# and whitespace normalization apply everywhere
|
|
40
|
+
# 5. register the class in Ask::Tools::WebFetch.backends
|
|
36
41
|
#
|
|
37
42
|
# The tool tries each backend in order and returns the first success.
|
|
38
43
|
class Backend
|
|
@@ -42,8 +47,11 @@ module Ask
|
|
|
42
47
|
"ask-web-fetch/#{Ask::WebFetch::VERSION}".freeze
|
|
43
48
|
|
|
44
49
|
# 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
|
-
|
|
50
|
+
# content (e.g. a JS-rendered shell with nothing server-side). Low on
|
|
51
|
+
# purpose: the content hub keeps everything that is a real page, and
|
|
52
|
+
# the crawler's soft-404 detection (not length) is what separates
|
|
53
|
+
# pages from error shells.
|
|
54
|
+
MIN_CONTENT_LENGTH = 40
|
|
47
55
|
|
|
48
56
|
# Cloudflare-style anti-bot signatures. Deliberately narrow:
|
|
49
57
|
# challenge/interstitial pages carry these markers, while legitimate
|
|
@@ -55,12 +63,55 @@ module Ask
|
|
|
55
63
|
name.split('::').last
|
|
56
64
|
end
|
|
57
65
|
|
|
58
|
-
# Fetches +url+ and returns { title
|
|
66
|
+
# Fetches +url+ and returns { title:, description:, content:,
|
|
67
|
+
# redirected:, licenses:, outlinks: } — licenses being the page's
|
|
68
|
+
# declared license signals (hrefs/values) and outlinks the page's
|
|
69
|
+
# raw link set ([] when the backend can't see any), both consumed by
|
|
70
|
+
# the crawler's classification/discovery layers.
|
|
59
71
|
# Raises FetchError or EmptyContentError on failure.
|
|
60
72
|
def fetch(url)
|
|
61
73
|
raise NotImplementedError, "#{self.class} must implement #fetch(url)"
|
|
62
74
|
end
|
|
63
75
|
|
|
76
|
+
# --- outlinks (crawler discovery) ---
|
|
77
|
+
|
|
78
|
+
# The page's raw outlinks from HTML: every <a href> resolved against
|
|
79
|
+
# the base URL and scheme-filtered to absolute http(s). Nav and
|
|
80
|
+
# footer are included — a crawler's discovery reads the full link set
|
|
81
|
+
# even when the stored content is pruned by the ContentFilter. Shared
|
|
82
|
+
# by every backend that holds the page's HTML.
|
|
83
|
+
def outlink_urls(html, base_url)
|
|
84
|
+
Nokogiri::HTML(html).css('a[href]').filter_map do |anchor|
|
|
85
|
+
href = anchor['href'].to_s.strip
|
|
86
|
+
next if href.empty? || href.start_with?('javascript:', 'mailto:', 'tel:', '#', 'data:')
|
|
87
|
+
|
|
88
|
+
uri = URI.join(base_url, href)
|
|
89
|
+
next unless %w[http https].include?(uri.scheme)
|
|
90
|
+
|
|
91
|
+
uri.to_s
|
|
92
|
+
rescue URI::InvalidURIError
|
|
93
|
+
next
|
|
94
|
+
end.uniq
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Fallback for backends that only see rendered markdown (Jina,
|
|
98
|
+
# Crawl4AI's markdown output): the markdown's [text](url) links,
|
|
99
|
+
# resolved and scheme-filtered. Same shape as #outlink_urls, one
|
|
100
|
+
# implementation for every backend that lacks the raw HTML.
|
|
101
|
+
def markdown_outlinks(content, base_url)
|
|
102
|
+
content.to_s.scan(/\]\(([^)\s]+)\)/).filter_map do |match|
|
|
103
|
+
dest = match[0]
|
|
104
|
+
next if dest.start_with?('javascript:', 'mailto:', 'tel:', '#', 'data:')
|
|
105
|
+
|
|
106
|
+
uri = URI.join(base_url, dest)
|
|
107
|
+
next unless %w[http https].include?(uri.scheme)
|
|
108
|
+
|
|
109
|
+
uri.to_s
|
|
110
|
+
rescue URI::InvalidURIError
|
|
111
|
+
next
|
|
112
|
+
end.uniq
|
|
113
|
+
end
|
|
114
|
+
|
|
64
115
|
private
|
|
65
116
|
|
|
66
117
|
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
|
|
@@ -4,6 +4,7 @@ require 'net/http'
|
|
|
4
4
|
require 'uri'
|
|
5
5
|
require 'json'
|
|
6
6
|
require_relative '../backend'
|
|
7
|
+
require_relative '../markdown'
|
|
7
8
|
|
|
8
9
|
module Ask
|
|
9
10
|
module WebFetch
|
|
@@ -115,11 +116,18 @@ module Ask
|
|
|
115
116
|
|
|
116
117
|
markdown = result.dig('markdown', 'fit_markdown').to_s
|
|
117
118
|
markdown = result.dig('markdown', 'raw_markdown').to_s if markdown.strip.empty?
|
|
119
|
+
# The service's markdown bypasses Markdown.generate, so it runs
|
|
120
|
+
# through the same shared clean (noise removal + whitespace) as
|
|
121
|
+
# the converting backends.
|
|
122
|
+
markdown = Markdown.clean(markdown)
|
|
118
123
|
{
|
|
119
124
|
title: result.dig('metadata', 'title'),
|
|
120
125
|
description: result.dig('metadata', 'description') ||
|
|
121
126
|
result.dig('metadata', 'og_description'),
|
|
122
127
|
content: markdown,
|
|
128
|
+
# Raw markdown keeps the nav links the ContentFilter prunes —
|
|
129
|
+
# discovery reads these even when the stored content is lean.
|
|
130
|
+
outlinks: markdown_outlinks(markdown, url),
|
|
123
131
|
# The page's own redirect, if the crawl followed one — lets
|
|
124
132
|
# consumers record permanent redirects on their ledger instead
|
|
125
133
|
# of silently indexing under the original URL.
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require 'net/http'
|
|
4
4
|
require 'uri'
|
|
5
5
|
require_relative '../backend'
|
|
6
|
+
require_relative '../markdown'
|
|
6
7
|
|
|
7
8
|
module Ask
|
|
8
9
|
module WebFetch
|
|
@@ -33,9 +34,16 @@ module Ask
|
|
|
33
34
|
when '200'
|
|
34
35
|
body = res.body.to_s
|
|
35
36
|
raise FetchError, 'challenge page from Jina' if challenge_page?(body)
|
|
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. Content runs
|
|
40
|
+
# through the same Markdown.clean as the converting backends,
|
|
41
|
+
# so decorative symbol noise is stripped here too; a page
|
|
42
|
+
# whose only "content" was noise falls through as empty.
|
|
43
|
+
content = Markdown.clean(body)
|
|
44
|
+
raise EmptyContentError, 'empty response from Jina' unless usable_content?(content)
|
|
45
|
+
|
|
46
|
+
{ title: nil, description: nil, content: content, outlinks: markdown_outlinks(body, url) }
|
|
39
47
|
when '429'
|
|
40
48
|
raise ServerError, 'rate limited by Jina (429)'
|
|
41
49
|
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
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require 'nokogiri'
|
|
4
4
|
require 'reverse_markdown'
|
|
5
5
|
require 'uri'
|
|
6
|
+
require_relative 'noise_filter'
|
|
6
7
|
|
|
7
8
|
module Ask
|
|
8
9
|
module WebFetch
|
|
@@ -126,7 +127,12 @@ module Ask
|
|
|
126
127
|
[parts.join, references.join]
|
|
127
128
|
end
|
|
128
129
|
|
|
130
|
+
# Shared post-conversion cleanup: drop decorative symbol noise
|
|
131
|
+
# (NoiseFilter), then normalize whitespace. Every backend's content
|
|
132
|
+
# runs through this — Local and Browser via generate, Jina and
|
|
133
|
+
# Crawl4AI explicitly — so the same noise rules apply to all.
|
|
129
134
|
def clean(markdown)
|
|
135
|
+
markdown = NoiseFilter.filter(markdown)
|
|
130
136
|
markdown.gsub(/[ \t]+\n/, "\n")
|
|
131
137
|
.gsub(/\n{3,}/, "\n\n")
|
|
132
138
|
.strip
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module WebFetch
|
|
5
|
+
# Strips decorative symbol noise from markdown: the long, letter-free,
|
|
6
|
+
# repetitive character streams pages render as animated backgrounds,
|
|
7
|
+
# marquees and section dividers — e.g. Hugging Face's storage page
|
|
8
|
+
# ships a "+ = · ( ~ @ # % & * ? / : ; < > [ ] { } | ^ $ !" stream as
|
|
9
|
+
# its page background. Runs on CONVERTED markdown, so every backend
|
|
10
|
+
# benefits: Local and Browser already convert through Markdown, and
|
|
11
|
+
# Jina and Crawl4AI hand the gem pre-converted markdown — the
|
|
12
|
+
# DOM-level ContentFilter never sees either case.
|
|
13
|
+
#
|
|
14
|
+
# Conservative by design. A line is dropped only when ALL hold:
|
|
15
|
+
#
|
|
16
|
+
# * it is long enough to matter (>= min_length characters)
|
|
17
|
+
# * it contains no letters or digits at all
|
|
18
|
+
# * it is repetitive — at least two distinct characters, with a
|
|
19
|
+
# distinct/length ratio below max_entropy (a repeated stream,
|
|
20
|
+
# not prose punctuation)
|
|
21
|
+
# * it is not markdown structure: fenced or indented code, table
|
|
22
|
+
# rows, headings, blockquotes, inline code, raw HTML, math
|
|
23
|
+
#
|
|
24
|
+
# Short decorative fragments (an ASCII-art header like "*****"),
|
|
25
|
+
# single-character runs ("-----" dividers), and everything containing
|
|
26
|
+
# words survive. Lines whose only content is invisible characters
|
|
27
|
+
# (zero-width spaces, combining marks) are always dropped — they carry
|
|
28
|
+
# nothing. Blank lines are untouched.
|
|
29
|
+
class NoiseFilter
|
|
30
|
+
# Longest line that is never touched, whatever its contents.
|
|
31
|
+
DEFAULT_MIN_LENGTH = 32
|
|
32
|
+
|
|
33
|
+
# Highest distinct-chars/length ratio a line may have and still
|
|
34
|
+
# count as repetitive. Below this the line reads as a repeated
|
|
35
|
+
# stream; above it, as prose punctuation (kept).
|
|
36
|
+
DEFAULT_MAX_ENTROPY = 0.3
|
|
37
|
+
|
|
38
|
+
# Characters that never render: zero-width space/joiner and bidi
|
|
39
|
+
# controls, the BOM, and combining marks.
|
|
40
|
+
INVISIBLE_RE = /[\u200B-\u200F\uFEFF\u2060\u00AD\p{Mn}]/.freeze
|
|
41
|
+
|
|
42
|
+
# A line starting with one of these is structure, not noise:
|
|
43
|
+
# headings, blockquotes, inline code, raw HTML, math, table rows.
|
|
44
|
+
STRUCTURE_PREFIX_RE = /\A[#>`<$|]/.freeze
|
|
45
|
+
|
|
46
|
+
# GFM table separator rows — "| --- | --- |" or the pipe-only
|
|
47
|
+
# "--- | ---" variant — are dashes, pipes, colons and spaces only.
|
|
48
|
+
# Kept as structure; the noise streams this filter targets always
|
|
49
|
+
# mix in other symbol types (+ = · ~ @ # % …), which this narrow
|
|
50
|
+
# pattern cannot match, so it is safe to exempt the whole class.
|
|
51
|
+
TABLE_SEPARATOR_RE = /\A\|?[\s\-:|]+\|?\z/.freeze
|
|
52
|
+
|
|
53
|
+
# Fenced code opener/closer: three or more backticks or tildes,
|
|
54
|
+
# optionally with an info string.
|
|
55
|
+
FENCE_RE = /\A(?:`{3,}|~{3,})/.freeze
|
|
56
|
+
|
|
57
|
+
class << self
|
|
58
|
+
# Returns +markdown+ with decorative noise lines removed. The
|
|
59
|
+
# options override the conservative defaults.
|
|
60
|
+
def filter(markdown, min_length: DEFAULT_MIN_LENGTH, max_entropy: DEFAULT_MAX_ENTROPY)
|
|
61
|
+
new(min_length: min_length, max_entropy: max_entropy).filter(markdown)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def initialize(min_length: DEFAULT_MIN_LENGTH, max_entropy: DEFAULT_MAX_ENTROPY)
|
|
66
|
+
@min_length = min_length
|
|
67
|
+
@max_entropy = max_entropy
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def filter(markdown)
|
|
71
|
+
out = +''
|
|
72
|
+
in_fence = false
|
|
73
|
+
in_indented_code = false
|
|
74
|
+
prev_blank = false
|
|
75
|
+
|
|
76
|
+
markdown.each_line do |line|
|
|
77
|
+
stripped = line.strip
|
|
78
|
+
|
|
79
|
+
# Fenced code: flip on any fence opener/closer, then pass the
|
|
80
|
+
# whole block through untouched — code may legitimately be
|
|
81
|
+
# nothing but symbols.
|
|
82
|
+
if stripped.match?(FENCE_RE)
|
|
83
|
+
in_fence = !in_fence
|
|
84
|
+
out << line
|
|
85
|
+
next
|
|
86
|
+
end
|
|
87
|
+
if in_fence
|
|
88
|
+
out << line
|
|
89
|
+
next
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Indented code (GFM-ish: 4+ leading spaces, ends at a blank
|
|
93
|
+
# line). Passed through untouched for the same reason.
|
|
94
|
+
if in_indented_code && stripped.empty?
|
|
95
|
+
in_indented_code = false
|
|
96
|
+
out << line
|
|
97
|
+
next
|
|
98
|
+
end
|
|
99
|
+
indented = line.start_with?(' ', "\t")
|
|
100
|
+
if indented && (in_indented_code || prev_blank)
|
|
101
|
+
in_indented_code = true
|
|
102
|
+
out << line
|
|
103
|
+
next
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
prev_blank = stripped.empty?
|
|
107
|
+
out << line unless noise_line?(stripped)
|
|
108
|
+
end
|
|
109
|
+
out
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
private
|
|
113
|
+
|
|
114
|
+
def noise_line?(stripped)
|
|
115
|
+
# Blank lines are structure — never touched.
|
|
116
|
+
return false if stripped.empty?
|
|
117
|
+
|
|
118
|
+
# Nothing but invisible characters renders as blank: pure waste.
|
|
119
|
+
return true if stripped.gsub(INVISIBLE_RE, '').empty?
|
|
120
|
+
|
|
121
|
+
# Words and numbers are content, whatever surrounds them.
|
|
122
|
+
return false if stripped.match?(/[A-Za-z0-9]/)
|
|
123
|
+
|
|
124
|
+
# Structural markers and anything too short to matter survive
|
|
125
|
+
# even when symbol-only.
|
|
126
|
+
return false if stripped.match?(STRUCTURE_PREFIX_RE)
|
|
127
|
+
return false if stripped.length < @min_length
|
|
128
|
+
|
|
129
|
+
# Table separator rows ("--- | ---") are structure even without a
|
|
130
|
+
# leading pipe — dash/pipe/colon-only lines are dividers either
|
|
131
|
+
# way, and real noise streams never match their narrow alphabet.
|
|
132
|
+
return false if stripped.match?(TABLE_SEPARATOR_RE)
|
|
133
|
+
|
|
134
|
+
chars = stripped.gsub(/\s/, '')
|
|
135
|
+
distinct = chars.chars.uniq.length
|
|
136
|
+
return false if distinct < 2
|
|
137
|
+
return false if distinct.fdiv(chars.length) >= @max_entropy
|
|
138
|
+
|
|
139
|
+
true
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
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.1
|
|
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,7 +144,9 @@ 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
|
|
149
|
+
- lib/ask/web_fetch/noise_filter.rb
|
|
148
150
|
- lib/ask/web_fetch/tool.rb
|
|
149
151
|
- lib/ask/web_fetch/version.rb
|
|
150
152
|
homepage: https://github.com/ask-rb/ask-web-fetch
|