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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 318ff9c594a7d5c52a710507efd3da2917bbe3319471a3e0bac8bce3a01ed30e
4
- data.tar.gz: f5ae0d23b351044ecc21f4e2c4d2ec66a13d8258cd9a86d6947f0c7698c6e703
3
+ metadata.gz: 0f49a52ecb57c57ee29ce475ad791f70d30fe52946c94c6bd6842dcccc477392
4
+ data.tar.gz: b67e97adb54db5b6a775d1ece43df51c0e5443a6ef9e2cd3a17d547af5f7d1dc
5
5
  SHA512:
6
- metadata.gz: 775322ca3144aa69da6d27e20664c5a18aa836baaf10d5431acc5cbc7dd893aff5e09c2c40751d3f19b6ea86410ad92cf992ad35b2283554b31196ba52fdea29
7
- data.tar.gz: ae5f935d3b48522330b5a01f9319813bd84d157076f65ee2a17cdbd82fb3fb65916f5312ea03fefdc51e8b7a84792c9558ede959e195e386f2713fb1c31e437a
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). The chain then handles ordering and fallback
72
- for you. For tests, `Ask::Tools::WebFetch.backends = [...]` can be swapped
73
- and restored.
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. register the class in Ask::Tools::WebFetch.backends
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
- MIN_CONTENT_LENGTH = 100
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: String|nil, content: String }.
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
- { title: nil, description: nil, content: body.strip }
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 Net::HTTP + Nokogiri + reverse_markdown.
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 Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNREFUSED,
45
- Errno::ECONNRESET, SocketError, URI::InvalidURIError => e
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: } 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.
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
- http = Net::HTTP.new(uri.host, uri.port)
69
- http.open_timeout = OPEN_TIMEOUT
70
- http.read_timeout = READ_TIMEOUT
71
- http.use_ssl = uri.scheme == 'https'
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 = res.code.to_i
81
- raise(code == 429 || code >= 500 ? ServerError : FetchError, "got #{res.code} from #{url}")
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 ||= res.code.to_i
86
- uri = URI.join(uri, res['location'])
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ask
4
4
  module WebFetch
5
- VERSION = '0.4.1'
5
+ VERSION = '0.5.1'
6
6
  end
7
7
  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.1
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
- (Net::HTTP + Nokogiri + reverse_markdown) with a Jina Reader fallback for JS-rendered
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