ask-web-fetch 0.5.0 → 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: 706e625574f2001a28ca673c198c2f6b58db55e5558a1afdb94dc1ae36090db0
4
- data.tar.gz: 559ea380079f5ab52022da161159b69b3396bc962cd9b3a96e7c3505d8307bf3
3
+ metadata.gz: 0f49a52ecb57c57ee29ce475ad791f70d30fe52946c94c6bd6842dcccc477392
4
+ data.tar.gz: b67e97adb54db5b6a775d1ece43df51c0e5443a6ef9e2cd3a17d547af5f7d1dc
5
5
  SHA512:
6
- metadata.gz: 7babb287ffae5ee1f93a8ecda397b15edd161c1919baa7509904baf67016210eb8456fe23af6e98667f9da6111a03e12a2faf5f835ab2751921f31ab366da8af
7
- data.tar.gz: 25e75756da81358ab07e505ce6ec611d85ae7d14d9a73ea16deee421cd3e7f102d19bcfe7a02bd633299fc6f341f4e8badd1413fbd388d323d92d5ff7539a6d4
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
@@ -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,6 +116,10 @@ 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') ||
@@ -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,11 +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.
40
- { title: nil, description: nil, content: body.strip, outlinks: markdown_outlinks(body, url) }
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) }
41
47
  when '429'
42
48
  raise ServerError, 'rate limited by Jina (429)'
43
49
  when '401', '403'
@@ -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.5.0'
5
+ VERSION = '0.5.1'
6
6
  end
7
7
  end
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.5.0
4
+ version: 0.5.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto
@@ -146,6 +146,7 @@ files:
146
146
  - lib/ask/web_fetch/content_filter.rb
147
147
  - lib/ask/web_fetch/http.rb
148
148
  - lib/ask/web_fetch/markdown.rb
149
+ - lib/ask/web_fetch/noise_filter.rb
149
150
  - lib/ask/web_fetch/tool.rb
150
151
  - lib/ask/web_fetch/version.rb
151
152
  homepage: https://github.com/ask-rb/ask-web-fetch