ask-web-fetch 0.4.1 → 0.5.0

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: 706e625574f2001a28ca673c198c2f6b58db55e5558a1afdb94dc1ae36090db0
4
+ data.tar.gz: 559ea380079f5ab52022da161159b69b3396bc962cd9b3a96e7c3505d8307bf3
5
5
  SHA512:
6
- metadata.gz: 775322ca3144aa69da6d27e20664c5a18aa836baaf10d5431acc5cbc7dd893aff5e09c2c40751d3f19b6ea86410ad92cf992ad35b2283554b31196ba52fdea29
7
- data.tar.gz: ae5f935d3b48522330b5a01f9319813bd84d157076f65ee2a17cdbd82fb3fb65916f5312ea03fefdc51e8b7a84792c9558ede959e195e386f2713fb1c31e437a
6
+ metadata.gz: 7babb287ffae5ee1f93a8ecda397b15edd161c1919baa7509904baf67016210eb8456fe23af6e98667f9da6111a03e12a2faf5f835ab2751921f31ab366da8af
7
+ data.tar.gz: 25e75756da81358ab07e505ce6ec611d85ae7d14d9a73ea16deee421cd3e7f102d19bcfe7a02bd633299fc6f341f4e8badd1413fbd388d323d92d5ff7539a6d4
@@ -42,8 +42,11 @@ module Ask
42
42
  "ask-web-fetch/#{Ask::WebFetch::VERSION}".freeze
43
43
 
44
44
  # 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
45
+ # content (e.g. a JS-rendered shell with nothing server-side). Low on
46
+ # purpose: the content hub keeps everything that is a real page, and
47
+ # the crawler's soft-404 detection (not length) is what separates
48
+ # pages from error shells.
49
+ MIN_CONTENT_LENGTH = 40
47
50
 
48
51
  # Cloudflare-style anti-bot signatures. Deliberately narrow:
49
52
  # challenge/interstitial pages carry these markers, while legitimate
@@ -55,12 +58,55 @@ module Ask
55
58
  name.split('::').last
56
59
  end
57
60
 
58
- # Fetches +url+ and returns { title: String|nil, content: String }.
61
+ # Fetches +url+ and returns { title:, description:, content:,
62
+ # redirected:, licenses:, outlinks: } — licenses being the page's
63
+ # declared license signals (hrefs/values) and outlinks the page's
64
+ # raw link set ([] when the backend can't see any), both consumed by
65
+ # the crawler's classification/discovery layers.
59
66
  # Raises FetchError or EmptyContentError on failure.
60
67
  def fetch(url)
61
68
  raise NotImplementedError, "#{self.class} must implement #fetch(url)"
62
69
  end
63
70
 
71
+ # --- outlinks (crawler discovery) ---
72
+
73
+ # The page's raw outlinks from HTML: every <a href> resolved against
74
+ # the base URL and scheme-filtered to absolute http(s). Nav and
75
+ # footer are included — a crawler's discovery reads the full link set
76
+ # even when the stored content is pruned by the ContentFilter. Shared
77
+ # by every backend that holds the page's HTML.
78
+ def outlink_urls(html, base_url)
79
+ Nokogiri::HTML(html).css('a[href]').filter_map do |anchor|
80
+ href = anchor['href'].to_s.strip
81
+ next if href.empty? || href.start_with?('javascript:', 'mailto:', 'tel:', '#', 'data:')
82
+
83
+ uri = URI.join(base_url, href)
84
+ next unless %w[http https].include?(uri.scheme)
85
+
86
+ uri.to_s
87
+ rescue URI::InvalidURIError
88
+ next
89
+ end.uniq
90
+ end
91
+
92
+ # Fallback for backends that only see rendered markdown (Jina,
93
+ # Crawl4AI's markdown output): the markdown's [text](url) links,
94
+ # resolved and scheme-filtered. Same shape as #outlink_urls, one
95
+ # implementation for every backend that lacks the raw HTML.
96
+ def markdown_outlinks(content, base_url)
97
+ content.to_s.scan(/\]\(([^)\s]+)\)/).filter_map do |match|
98
+ dest = match[0]
99
+ next if dest.start_with?('javascript:', 'mailto:', 'tel:', '#', 'data:')
100
+
101
+ uri = URI.join(base_url, dest)
102
+ next unless %w[http https].include?(uri.scheme)
103
+
104
+ uri.to_s
105
+ rescue URI::InvalidURIError
106
+ next
107
+ end.uniq
108
+ end
109
+
64
110
  private
65
111
 
66
112
  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
@@ -120,6 +120,9 @@ module Ask
120
120
  description: result.dig('metadata', 'description') ||
121
121
  result.dig('metadata', 'og_description'),
122
122
  content: markdown,
123
+ # Raw markdown keeps the nav links the ContentFilter prunes —
124
+ # discovery reads these even when the stored content is lean.
125
+ outlinks: markdown_outlinks(markdown, url),
123
126
  # The page's own redirect, if the crawl followed one — lets
124
127
  # consumers record permanent redirects on their ledger instead
125
128
  # of silently indexing under the original URL.
@@ -35,7 +35,9 @@ 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, description: nil, content: body.strip }
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
41
  when '429'
40
42
  raise ServerError, 'rate limited by Jina (429)'
41
43
  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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ask
4
4
  module WebFetch
5
- VERSION = '0.4.1'
5
+ VERSION = '0.5.0'
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.0
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,6 +144,7 @@ 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
148
149
  - lib/ask/web_fetch/tool.rb
149
150
  - lib/ask/web_fetch/version.rb