pikuri-core 0.0.7 → 0.1.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 +4 -4
- data/README.md +1 -1
- data/lib/pikuri/agent/chat_transport.rb +73 -93
- data/lib/pikuri/agent/configurator.rb +46 -106
- data/lib/pikuri/agent/context_window_detector.rb +44 -85
- data/lib/pikuri/agent/control/cancellable.rb +87 -66
- data/lib/pikuri/agent/control/interloper.rb +127 -105
- data/lib/pikuri/agent/control/step_limit.rb +25 -41
- data/lib/pikuri/agent/control.rb +14 -34
- data/lib/pikuri/agent/event.rb +123 -188
- data/lib/pikuri/agent/extension.rb +118 -94
- data/lib/pikuri/agent/extension_context.rb +50 -77
- data/lib/pikuri/agent/history.rb +653 -0
- data/lib/pikuri/agent/listener/rate_limited.rb +40 -66
- data/lib/pikuri/agent/listener/terminal.rb +143 -117
- data/lib/pikuri/agent/listener/token_log.rb +101 -140
- data/lib/pikuri/agent/listener.rb +23 -43
- data/lib/pikuri/agent/listener_list.rb +26 -47
- data/lib/pikuri/agent/synthesizer.rb +45 -87
- data/lib/pikuri/agent.rb +816 -474
- data/lib/pikuri/bundler_env.rb +68 -0
- data/lib/pikuri/extractor/html.rb +63 -110
- data/lib/pikuri/extractor/passthrough.rb +20 -30
- data/lib/pikuri/extractor.rb +93 -154
- data/lib/pikuri/file_type.rb +63 -135
- data/lib/pikuri/finalizers.rb +32 -47
- data/lib/pikuri/paths.rb +104 -13
- data/lib/pikuri/ruby_llm_patches.rb +106 -0
- data/lib/pikuri/sanitizer.rb +45 -67
- data/lib/pikuri/subprocess.rb +75 -119
- data/lib/pikuri/testing.rb +296 -0
- data/lib/pikuri/tool/calculator.rb +56 -66
- data/lib/pikuri/tool/execute_context.rb +42 -0
- data/lib/pikuri/tool/fetch.rb +51 -77
- data/lib/pikuri/tool/parameters.rb +21 -29
- data/lib/pikuri/tool/scraper.rb +55 -97
- data/lib/pikuri/tool/search/brave.rb +52 -80
- data/lib/pikuri/tool/search/duckduckgo.rb +59 -91
- data/lib/pikuri/tool/search/engines.rb +230 -97
- data/lib/pikuri/tool/search/exa.rb +56 -90
- data/lib/pikuri/tool/search/rate_limiter.rb +61 -38
- data/lib/pikuri/tool/search/result.rb +10 -15
- data/lib/pikuri/tool/trifecta_legs.rb +217 -0
- data/lib/pikuri/tool/web_scrape.rb +38 -54
- data/lib/pikuri/tool/web_search.rb +100 -24
- data/lib/pikuri/tool.rb +140 -65
- data/lib/pikuri/trifecta/contribution.rb +43 -0
- data/lib/pikuri/trifecta/node.rb +47 -0
- data/lib/pikuri/trifecta/report.rb +230 -0
- data/lib/pikuri/trifecta.rb +127 -0
- data/lib/pikuri/url_cache.rb +33 -49
- data/lib/pikuri/version.rb +1 -1
- data/lib/pikuri-core.rb +72 -88
- data/prompts/agent-loop.txt +5 -0
- data/prompts/pikuri-chat.txt +3 -12
- metadata +14 -3
data/lib/pikuri/tool/scraper.rb
CHANGED
|
@@ -6,58 +6,43 @@ require 'uri'
|
|
|
6
6
|
|
|
7
7
|
module Pikuri
|
|
8
8
|
class Tool
|
|
9
|
-
# HTTP side of the web tools ({Tool::WEB_SCRAPE} and {Tool::FETCH}):
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
# remaining types raise {FetchError} so the LLM observes the
|
|
18
|
-
# failure instead of receiving an empty rendering.
|
|
9
|
+
# HTTP side of the web tools ({Tool::WEB_SCRAPE} and {Tool::FETCH}): GET the
|
|
10
|
+
# URL with a real-browser User-Agent, follow redirects, and hand the body to
|
|
11
|
+
# {Pikuri::Extractor.extract} with the response +Content-Type+ as the hint.
|
|
12
|
+
# HTML/XHTML renders via {Extractor::HTML}, other +text/*+ passes through
|
|
13
|
+
# verbatim, plug-in extractors extend the set (with pikuri-pdf,
|
|
14
|
+
# +application/pdf+ extracts — by header or +%PDF-+ magic, so a lying header
|
|
15
|
+
# still works); remaining types raise {FetchError} so the LLM observes the
|
|
16
|
+
# failure rather than an empty rendering.
|
|
19
17
|
#
|
|
20
|
-
# Split into a thin HTTP fetch ({.fetch}) and the extraction
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
# module own caching and truncation and turn rendered Markdown (or
|
|
25
|
-
# {FetchError}) into the next observation.
|
|
18
|
+
# Split into a thin HTTP fetch ({.fetch}) and the extraction wrapper
|
|
19
|
+
# ({.visit}) so tests drive each in isolation and {Tool::Fetch} can reuse the
|
|
20
|
+
# HTTP half. Nothing here knows about the LLM — the wrapping tools own
|
|
21
|
+
# caching, truncation, and turning the result into an observation.
|
|
26
22
|
module Scraper
|
|
27
|
-
# Raised when a URL
|
|
28
|
-
#
|
|
29
|
-
#
|
|
30
|
-
#
|
|
31
|
-
# {Tool::WEB_SCRAPE} / {Tool::FETCH} turns the failure into an
|
|
32
|
-
# +"Error: ..."+ observation; anything else bubbles up so genuine
|
|
33
|
-
# bugs stay visible.
|
|
23
|
+
# Raised when a URL can't be rendered to Markdown — HTTP non-2xx, network
|
|
24
|
+
# failure, redirect loop, missing +Location+, unsupported content-type, or
|
|
25
|
+
# a parse failure. The web tools rescue this into an +"Error: ..."+
|
|
26
|
+
# observation; anything else bubbles up so genuine bugs stay visible.
|
|
34
27
|
class FetchError < StandardError; end
|
|
35
28
|
|
|
36
|
-
# @return [String] User-Agent sent with each request; many sites
|
|
37
|
-
#
|
|
29
|
+
# @return [String] User-Agent sent with each request; many sites reject a
|
|
30
|
+
# missing or obviously-bot UA.
|
|
38
31
|
USER_AGENT = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 ' \
|
|
39
32
|
'(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
|
40
|
-
# @return [String] +Accept+ header
|
|
41
|
-
#
|
|
42
|
-
# rendered HTML first, +application/pdf+ for hosts with a PDF
|
|
43
|
-
# extractor registered, then any +text/*+ for the verbatim
|
|
44
|
-
# pass-through arm.
|
|
33
|
+
# @return [String] +Accept+ header, so content-negotiating servers hand
|
|
34
|
+
# back something usable: HTML first, then +application/pdf+, then +text/*+.
|
|
45
35
|
ACCEPT = 'text/html,application/xhtml+xml,application/pdf,text/*;q=0.8'
|
|
46
|
-
# @return [Integer] maximum
|
|
47
|
-
# before giving up
|
|
36
|
+
# @return [Integer] maximum HTTP redirects to follow before giving up.
|
|
48
37
|
MAX_REDIRECTS = 5
|
|
49
|
-
# @return [Integer] connect timeout
|
|
50
|
-
# Faraday request
|
|
38
|
+
# @return [Integer] connect timeout (s) for the Faraday request.
|
|
51
39
|
OPEN_TIMEOUT = 10
|
|
52
|
-
# @return [Integer] read timeout
|
|
53
|
-
# Faraday request
|
|
40
|
+
# @return [Integer] read timeout (s) for the Faraday request.
|
|
54
41
|
READ_TIMEOUT = 20
|
|
55
42
|
|
|
56
|
-
# @return [Integer]
|
|
57
|
-
#
|
|
58
|
-
#
|
|
59
|
-
# interstitial, etc.); a short excerpt tells the LLM what kind of
|
|
60
|
-
# page came back without flooding the next observation.
|
|
43
|
+
# @return [Integer] max chars of an error response body in a {FetchError}
|
|
44
|
+
# message — enough to show what came back (a Cloudflare/WAF page) without
|
|
45
|
+
# flooding the observation.
|
|
61
46
|
ERROR_BODY_EXCERPT = 200
|
|
62
47
|
|
|
63
48
|
# Result of a successful {Scraper.fetch}: the response body, the
|
|
@@ -65,46 +50,30 @@ module Pikuri
|
|
|
65
50
|
# parameters stripped), and the final URL after redirects.
|
|
66
51
|
Fetched = Data.define(:body, :content_type, :url)
|
|
67
52
|
|
|
68
|
-
# Fetch +url+ and render its main content as Markdown.
|
|
53
|
+
# Fetch +url+ and render its main content as Markdown. No caching — every
|
|
54
|
+
# call hits the network (wrap it to memoize; {Tool::WebScrape.visit}
|
|
55
|
+
# does). The output is +strip+'d (leading/trailing blank lines are common
|
|
56
|
+
# from PDF page-feeds and trailing newlines) but interior whitespace is
|
|
57
|
+
# preserved (paragraph breaks and code indentation are load-bearing).
|
|
69
58
|
#
|
|
70
|
-
#
|
|
71
|
-
#
|
|
72
|
-
#
|
|
73
|
-
#
|
|
74
|
-
#
|
|
75
|
-
# a body that opens or closes with blank lines — common with
|
|
76
|
-
# extracted PDFs' page-feed whitespace and with text bodies that
|
|
77
|
-
# carry a trailing newline. Interior whitespace is preserved
|
|
78
|
-
# because Markdown paragraph breaks and source-code indentation
|
|
79
|
-
# are load-bearing.
|
|
80
|
-
#
|
|
81
|
-
# @param url [String] absolute HTTP(S) URL of the page to download
|
|
82
|
-
# @return [String] full Markdown representation of the page with
|
|
83
|
-
# leading/trailing whitespace trimmed, uncapped otherwise —
|
|
84
|
-
# caller is responsible for any size limiting before feeding
|
|
85
|
-
# the result back to the LLM
|
|
86
|
-
# @raise [FetchError] on HTTP non-2xx, network failure, redirect
|
|
87
|
-
# loop, a 3xx without a +Location+ header, a response no
|
|
88
|
-
# extractor recognizes, or an extraction failure (malformed
|
|
89
|
-
# PDF, ...)
|
|
59
|
+
# @param url [String] absolute HTTP(S) URL
|
|
60
|
+
# @return [String] Markdown, whitespace-trimmed, otherwise uncapped —
|
|
61
|
+
# caller size-limits before feeding the LLM
|
|
62
|
+
# @raise [FetchError] on HTTP non-2xx, network failure, redirect loop, a
|
|
63
|
+
# 3xx without +Location+, an unrecognized response, or extraction failure
|
|
90
64
|
def self.visit(url)
|
|
91
65
|
extract(fetch(url)).strip
|
|
92
66
|
end
|
|
93
67
|
|
|
94
|
-
# Render a {Fetched} response as Markdown via
|
|
95
|
-
#
|
|
96
|
-
#
|
|
97
|
-
#
|
|
98
|
-
#
|
|
99
|
-
# transport metadata is refused, not sniffed; only a strong magic
|
|
100
|
-
# sniff like pikuri-pdf's +%PDF-+ overrides a wrong or missing
|
|
101
|
-
# header, because such a sniff never misfires on text).
|
|
68
|
+
# Render a {Fetched} response as Markdown via {Pikuri::Extractor.extract},
|
|
69
|
+
# re-raising both extraction failure modes as {FetchError}. The
|
|
70
|
+
# content-type is passed verbatim (including +""+ for a missing header,
|
|
71
|
+
# which matches no text arm — a body without transport metadata is
|
|
72
|
+
# refused, not sniffed; only a strong magic sniff like +%PDF-+ overrides).
|
|
102
73
|
#
|
|
103
74
|
# @param fetched [Fetched]
|
|
104
|
-
# @return [String] Markdown
|
|
105
|
-
#
|
|
106
|
-
# @raise [FetchError] when no extractor matches the response's
|
|
107
|
-
# content-type, or when extraction fails
|
|
75
|
+
# @return [String] Markdown from the matched extractor
|
|
76
|
+
# @raise [FetchError] when no extractor matches, or extraction fails
|
|
108
77
|
def self.extract(fetched)
|
|
109
78
|
Pikuri::Extractor.extract(StringIO.new(fetched.body), content_type: fetched.content_type)
|
|
110
79
|
rescue Pikuri::Extractor::Unsupported
|
|
@@ -113,26 +82,17 @@ module Pikuri
|
|
|
113
82
|
raise FetchError, e.message
|
|
114
83
|
end
|
|
115
84
|
|
|
116
|
-
# Download
|
|
117
|
-
#
|
|
118
|
-
#
|
|
119
|
-
#
|
|
120
|
-
#
|
|
121
|
-
# All recoverable failures — HTTP 4xx/5xx, +Faraday::Error+ network
|
|
122
|
-
# blips, exhausted redirect budget, 3xx without a +Location+ —
|
|
123
|
-
# surface as {FetchError} so the caller has a single exception type
|
|
124
|
-
# to rescue. Error bodies are trimmed to {ERROR_BODY_EXCERPT}
|
|
125
|
-
# characters with whitespace collapsed, so a Cloudflare-challenge
|
|
126
|
-
# response doesn't dump kilobytes of inline HTML into the next LLM
|
|
127
|
-
# observation.
|
|
85
|
+
# Download +url+, manually following up to {MAX_REDIRECTS} redirects. All
|
|
86
|
+
# recoverable failures (HTTP 4xx/5xx, +Faraday::Error+, exhausted redirect
|
|
87
|
+
# budget, 3xx without +Location+) surface as {FetchError}. Error bodies
|
|
88
|
+
# are trimmed to {ERROR_BODY_EXCERPT} so a challenge page doesn't dump
|
|
89
|
+
# kilobytes into the observation.
|
|
128
90
|
#
|
|
129
|
-
# @param url [String] absolute HTTP(S) URL
|
|
130
|
-
# @param limit [Integer] redirects remaining; recurses with
|
|
131
|
-
#
|
|
132
|
-
# @
|
|
133
|
-
#
|
|
134
|
-
# @raise [FetchError] on non-2xx/3xx responses, network errors,
|
|
135
|
-
# redirect-loop exhaustion, or 3xx without a +Location+ header
|
|
91
|
+
# @param url [String] absolute HTTP(S) URL
|
|
92
|
+
# @param limit [Integer] redirects remaining; recurses with +limit - 1+ on 3xx
|
|
93
|
+
# @return [Fetched] body, normalized content-type, final URL
|
|
94
|
+
# @raise [FetchError] on non-2xx/3xx, network error, redirect-loop
|
|
95
|
+
# exhaustion, or 3xx without +Location+
|
|
136
96
|
def self.fetch(url, limit: MAX_REDIRECTS)
|
|
137
97
|
raise FetchError, "too many redirects fetching #{url}" if limit.zero?
|
|
138
98
|
|
|
@@ -169,10 +129,8 @@ module Pikuri
|
|
|
169
129
|
end
|
|
170
130
|
private_class_method :normalize_content_type
|
|
171
131
|
|
|
172
|
-
# Whitespace-collapse +body+ and clip to {ERROR_BODY_EXCERPT}
|
|
173
|
-
#
|
|
174
|
-
# line even when the server returned a multi-KB HTML challenge
|
|
175
|
-
# page.
|
|
132
|
+
# Whitespace-collapse +body+ and clip to {ERROR_BODY_EXCERPT}, keeping the
|
|
133
|
+
# {FetchError} message a single readable line.
|
|
176
134
|
#
|
|
177
135
|
# @param body [String, nil]
|
|
178
136
|
# @return [String]
|
|
@@ -7,44 +7,33 @@ require 'nokogiri'
|
|
|
7
7
|
module Pikuri
|
|
8
8
|
class Tool
|
|
9
9
|
module Search
|
|
10
|
-
# Performs a Brave Search via the official Web Search API
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
# cascade in {Engines#search} owns the final Markdown rendering.
|
|
10
|
+
# Performs a Brave Search via the official Web Search API, returning hits
|
|
11
|
+
# as {Result} rows. Split into a thin HTTP fetch (#search) and a pure
|
|
12
|
+
# parser (.parse) so tests exercise the parser against fixture JSON.
|
|
13
|
+
# {Engines#search} owns the final Markdown rendering.
|
|
15
14
|
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
# store"). Get a key at https://api-dashboard.search.brave.com — the
|
|
22
|
-
# free "Data for Search" tier allows 1 query/sec and ~2k queries/month.
|
|
15
|
+
# Constructed with its API key (+Brave.new(api_key:)+); {Engines} builds
|
|
16
|
+
# one only when a Brave key was configured. pikuri reads no key from the
|
|
17
|
+
# environment (CLAUDE.md "Environment is not a secret store"). Key at
|
|
18
|
+
# https://api-dashboard.search.brave.com — the free "Data for Search" tier
|
|
19
|
+
# allows 1 query/sec, ~2k/month.
|
|
23
20
|
#
|
|
24
21
|
# == Privacy posture
|
|
25
22
|
#
|
|
26
|
-
# Brave's API
|
|
27
|
-
#
|
|
28
|
-
#
|
|
29
|
-
#
|
|
30
|
-
#
|
|
31
|
-
#
|
|
32
|
-
#
|
|
33
|
-
#
|
|
34
|
-
# Bottom line: of pikuri's three providers Brave has the cleanest
|
|
35
|
-
# API-level posture — no training-on-queries, no IP linkage, capped
|
|
36
|
-
# 90-day retention by default, real ZDR if you pay for it. Still a
|
|
37
|
-
# logged 90-day window on the cheap tier, so not a substitute for
|
|
38
|
-
# ZDR for genuinely sensitive queries.
|
|
23
|
+
# Brave's API notice retains Search Query Logs 90 days and collects no
|
|
24
|
+
# identifiers linking a query to a person; it publicly commits not to train
|
|
25
|
+
# on query data, and offers Zero Data Retention — but only on Enterprise,
|
|
26
|
+
# not the free tier pikuri defaults to. Bottom line: the cleanest
|
|
27
|
+
# API-level posture of pikuri's three providers (no training, no IP
|
|
28
|
+
# linkage, capped 90-day retention), but a logged 90-day window on the
|
|
29
|
+
# cheap tier, so not a ZDR substitute for genuinely sensitive queries.
|
|
39
30
|
class Brave
|
|
40
31
|
# @return [String] Web Search endpoint
|
|
41
32
|
ENDPOINT = 'https://api.search.brave.com/res/v1/web/search'
|
|
42
|
-
# @return [Integer] default number of results returned
|
|
43
|
-
# {DuckDuckGo::DEFAULT_MAX_RESULTS}
|
|
33
|
+
# @return [Integer] default number of results returned.
|
|
44
34
|
DEFAULT_MAX_RESULTS = 10
|
|
45
|
-
# @return [RateLimiter] free-tier Brave caps at 1 req/sec; the
|
|
46
|
-
#
|
|
47
|
-
# being burned on doomed retries when a 429 hits.
|
|
35
|
+
# @return [RateLimiter] free-tier Brave caps at 1 req/sec; the 5-minute
|
|
36
|
+
# cooldown protects the monthly quota from doomed retries on a 429.
|
|
48
37
|
LIMITER = RateLimiter.new(min_interval: 1.0, cooldown: 300.0)
|
|
49
38
|
|
|
50
39
|
# @param api_key [String] Brave Search subscription token. Required
|
|
@@ -58,33 +47,28 @@ module Pikuri
|
|
|
58
47
|
@api_key = api_key
|
|
59
48
|
end
|
|
60
49
|
|
|
61
|
-
# @return [String] short provider label for {Engines}
|
|
62
|
-
# fallback messages.
|
|
50
|
+
# @return [String] short provider label for {Engines}.
|
|
63
51
|
def label
|
|
64
52
|
'Brave'
|
|
65
53
|
end
|
|
66
54
|
|
|
67
|
-
# Fetch results for +query+
|
|
68
|
-
#
|
|
69
|
-
#
|
|
70
|
-
# The caller (typically {Engines#search}) is expected to have
|
|
71
|
-
# already normalized the query and to wrap this in a result cache.
|
|
55
|
+
# Fetch results for +query+ as an +Array<Result>+. Throttled to 1/sec and
|
|
56
|
+
# circuit-broken 5 min on rate-limit/quota (see {LIMITER}). The caller
|
|
57
|
+
# ({Engines#search}) normalizes the query and wraps this in a cache.
|
|
72
58
|
#
|
|
73
59
|
# @param query [String] search query (already normalized)
|
|
74
|
-
# @param max_results [Integer]
|
|
75
|
-
#
|
|
76
|
-
#
|
|
77
|
-
#
|
|
78
|
-
# @raise [Engines::Unavailable]
|
|
79
|
-
#
|
|
80
|
-
#
|
|
81
|
-
#
|
|
82
|
-
#
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
def search(query, max_results: DEFAULT_MAX_RESULTS)
|
|
87
|
-
LIMITER.call do
|
|
60
|
+
# @param max_results [Integer] max entries; Brave's +count+ (1..20)
|
|
61
|
+
# @param cancellable [Pikuri::Agent::Control::Cancellable] makes
|
|
62
|
+
# {LIMITER}'s pacing wait interruptible; see {RateLimiter#call}.
|
|
63
|
+
# @return [Array<Result>] hits, possibly empty when Brave matched nothing
|
|
64
|
+
# @raise [Engines::Unavailable] on HTTP 429 or 5xx (the cascade falls
|
|
65
|
+
# back), or if {LIMITER} is in cooldown. Other non-2xx (401/403 bad
|
|
66
|
+
# key) bubble up as +RuntimeError+.
|
|
67
|
+
# @raise [RuntimeError] for non-rate-limit HTTP failures, or a
|
|
68
|
+
# zero-result response that isn't a recognized empty-results payload.
|
|
69
|
+
def search(query, max_results: DEFAULT_MAX_RESULTS,
|
|
70
|
+
cancellable: Pikuri::Agent::Control::Cancellable::NEVER)
|
|
71
|
+
LIMITER.call(cancellable: cancellable) do
|
|
88
72
|
response = Faraday.get(
|
|
89
73
|
ENDPOINT,
|
|
90
74
|
{ q: query, count: max_results },
|
|
@@ -102,24 +86,16 @@ module Pikuri
|
|
|
102
86
|
end
|
|
103
87
|
end
|
|
104
88
|
|
|
105
|
-
# Parse a Brave
|
|
106
|
-
#
|
|
107
|
-
#
|
|
108
|
-
#
|
|
109
|
-
# When the response yields zero result nodes, two cases are
|
|
110
|
-
# distinguished: a genuine "no results" payload (recognized search
|
|
111
|
-
# shape with empty +mixed.main+/+top+/+side+ — typically a too-narrow
|
|
112
|
-
# query Brave couldn't match) returns an empty array instead of
|
|
113
|
-
# raising, so {Engines.search} can render its standard no-results
|
|
114
|
-
# stub. Anything else (unknown layout, structured error) raises
|
|
115
|
-
# with a diagnostic so the failure surfaces.
|
|
89
|
+
# Parse a Brave JSON response into {Result} rows (+<strong>+ highlights
|
|
90
|
+
# stripped). A genuine "no results" payload (recognized search shape,
|
|
91
|
+
# empty +mixed.main/top/side+) returns +[]+ so {Engines.search} renders
|
|
92
|
+
# its stub; any other zero-result shape raises.
|
|
116
93
|
#
|
|
117
94
|
# @param json [String] response body from {ENDPOINT}
|
|
118
|
-
# @param max_results [Integer]
|
|
95
|
+
# @param max_results [Integer] max entries
|
|
119
96
|
# @return [Array<Result>] hits, possibly empty on a recognized
|
|
120
97
|
# empty-results payload
|
|
121
|
-
# @raise [RuntimeError]
|
|
122
|
-
# is not recognized as a genuine empty-results payload
|
|
98
|
+
# @raise [RuntimeError] on an unrecognized zero-result response
|
|
123
99
|
def self.parse(json, max_results: DEFAULT_MAX_RESULTS)
|
|
124
100
|
data = JSON.parse(json)
|
|
125
101
|
results = Array(data.dig('web', 'results')).take(max_results).filter_map do |r|
|
|
@@ -142,11 +118,11 @@ module Pikuri
|
|
|
142
118
|
results
|
|
143
119
|
end
|
|
144
120
|
|
|
145
|
-
# Strip HTML markup (notably +<strong>+ highlights Brave wraps around
|
|
146
|
-
# query terms
|
|
121
|
+
# Strip HTML markup (notably the +<strong>+ highlights Brave wraps around
|
|
122
|
+
# query terms) and collapse whitespace.
|
|
147
123
|
#
|
|
148
|
-
# @param html [String, nil]
|
|
149
|
-
# @return [String] plain text
|
|
124
|
+
# @param html [String, nil]
|
|
125
|
+
# @return [String] plain text; empty string for nil
|
|
150
126
|
def self.strip_html(html)
|
|
151
127
|
return '' if html.nil?
|
|
152
128
|
|
|
@@ -154,13 +130,10 @@ module Pikuri
|
|
|
154
130
|
end
|
|
155
131
|
private_class_method :strip_html
|
|
156
132
|
|
|
157
|
-
# True when a
|
|
158
|
-
#
|
|
159
|
-
#
|
|
160
|
-
#
|
|
161
|
-
# +mixed+ block — Brave populates +mixed.main/top/side+ with whichever
|
|
162
|
-
# verticals matched, so all three being empty arrays is the canonical
|
|
163
|
-
# signal that the search itself succeeded but found nothing.
|
|
133
|
+
# True when a zero-+web.results+ response is Brave's own "ran, matched
|
|
134
|
+
# nothing" payload, not an error. Markers: the +type: "search"+ envelope
|
|
135
|
+
# and an empty +mixed+ block (Brave fills +mixed.main/top/side+ with
|
|
136
|
+
# matched verticals, so all three empty = succeeded-but-found-nothing).
|
|
164
137
|
#
|
|
165
138
|
# @param data [Hash, Object] parsed response
|
|
166
139
|
# @return [Boolean]
|
|
@@ -174,13 +147,12 @@ module Pikuri
|
|
|
174
147
|
end
|
|
175
148
|
private_class_method :genuine_no_results?
|
|
176
149
|
|
|
177
|
-
# Build an error message for a
|
|
178
|
-
#
|
|
179
|
-
# truncates the raw body so the caller can see the actual payload.
|
|
150
|
+
# Build an error message for a zero-result response: quote Brave's
|
|
151
|
+
# +error.detail+ if present, else a truncated raw body.
|
|
180
152
|
#
|
|
181
153
|
# @param data [Hash, Object] parsed response
|
|
182
154
|
# @param raw [String] raw response body
|
|
183
|
-
# @return [String]
|
|
155
|
+
# @return [String] diagnostic to feed to +raise+
|
|
184
156
|
def self.diagnose_empty(data, raw)
|
|
185
157
|
if data.is_a?(Hash) && data['error'].is_a?(Hash)
|
|
186
158
|
err = data['error']
|
|
@@ -7,73 +7,56 @@ require 'uri'
|
|
|
7
7
|
module Pikuri
|
|
8
8
|
class Tool
|
|
9
9
|
module Search
|
|
10
|
-
# Performs a DuckDuckGo search by scraping +html.duckduckgo.com
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
# A class (constructed with no arguments) so it shares the uniform
|
|
17
|
-
# provider shape with the keyed {Brave} / {Exa}: {Engines} holds a
|
|
18
|
-
# list of provider *instances* and calls +#search+ / +#label+ on each
|
|
19
|
-
# without caring which is which.
|
|
10
|
+
# Performs a DuckDuckGo search by scraping +html.duckduckgo.com+, returning
|
|
11
|
+
# hits as {Result} rows. Split into a thin HTTP fetch (#search) and a pure
|
|
12
|
+
# parser (.parse) so tests exercise the parser against fixture HTML.
|
|
13
|
+
# {Engines#search} owns the final Markdown rendering. Constructed with no
|
|
14
|
+
# arguments, sharing the uniform +#search+/+#label+ provider shape with the
|
|
15
|
+
# keyed {Brave} / {Exa}.
|
|
20
16
|
#
|
|
21
17
|
# == Privacy posture
|
|
22
18
|
#
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
-
#
|
|
26
|
-
#
|
|
27
|
-
#
|
|
28
|
-
#
|
|
29
|
-
# reaches Microsoft for fulfillment even though DDG strips
|
|
30
|
-
# identifying info on the way out.
|
|
31
|
-
#
|
|
32
|
-
# Bottom line: DDG is a genuine privacy improvement over hitting
|
|
33
|
-
# Bing directly (your IP isn't tied to the query, no per-user
|
|
34
|
-
# profile is built on DDG's side), but query content still lands at
|
|
35
|
-
# Microsoft, who has no comparable no-training pledge. Better than
|
|
36
|
-
# Exa for sensitive queries, worse than Brave; for anything
|
|
37
|
-
# genuinely embarrassing, don't search the web at all.
|
|
19
|
+
# DDG doesn't save your IP or tie a per-user profile to queries, and
|
|
20
|
+
# proxies requests so downstream providers can't build a search history —
|
|
21
|
+
# real, but DDG relays web results over Bing, so *query content* still
|
|
22
|
+
# reaches Microsoft (who has no no-training pledge). Better than Exa for
|
|
23
|
+
# sensitive queries, worse than Brave; for anything genuinely embarrassing,
|
|
24
|
+
# don't search the web at all.
|
|
38
25
|
class DuckDuckGo
|
|
39
26
|
# @return [String] HTML search endpoint
|
|
40
27
|
ENDPOINT = 'https://html.duckduckgo.com/html/'
|
|
41
28
|
# @return [String] User-Agent sent with each request; DDG often rejects
|
|
42
|
-
#
|
|
29
|
+
# a missing or obviously-bot UA.
|
|
43
30
|
USER_AGENT = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 ' \
|
|
44
31
|
'(KHTML, like Gecko) Chrome/120.0 Safari/537.36'
|
|
45
|
-
# @return [Integer] default number of results returned
|
|
32
|
+
# @return [Integer] default number of results returned.
|
|
46
33
|
DEFAULT_MAX_RESULTS = 10
|
|
47
34
|
# @return [RateLimiter] paces calls (DDG bans IPs that hammer the HTML
|
|
48
|
-
# endpoint) and circuit-breaks on {Engines::Unavailable}
|
|
49
|
-
# soft-block response doesn't get retried for the next 5 minutes
|
|
35
|
+
# endpoint) and circuit-breaks 5 min on {Engines::Unavailable}.
|
|
50
36
|
LIMITER = RateLimiter.new(min_interval: 2.0, cooldown: 300.0)
|
|
51
37
|
|
|
52
|
-
# @return [String] short provider label for {Engines}
|
|
53
|
-
# fallback messages. Uniform across providers (see {Brave#label}).
|
|
38
|
+
# @return [String] short provider label for {Engines}.
|
|
54
39
|
def label
|
|
55
40
|
'DuckDuckGo'
|
|
56
41
|
end
|
|
57
42
|
|
|
58
|
-
# Fetch results for +query+
|
|
59
|
-
#
|
|
60
|
-
#
|
|
61
|
-
# {Engines#search}) is expected to have already normalized the
|
|
62
|
-
# query and to wrap this in a result cache.
|
|
43
|
+
# Fetch results for +query+ as an +Array<Result>+. Throttled to one every
|
|
44
|
+
# 2s and circuit-broken 5 min after a soft-block (see {LIMITER}). The
|
|
45
|
+
# caller ({Engines#search}) normalizes the query and wraps this in a cache.
|
|
63
46
|
#
|
|
64
47
|
# @param query [String] search query (already normalized)
|
|
65
|
-
# @param max_results [Integer]
|
|
66
|
-
# @
|
|
67
|
-
#
|
|
68
|
-
# @
|
|
69
|
-
#
|
|
70
|
-
#
|
|
71
|
-
#
|
|
72
|
-
#
|
|
73
|
-
#
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
LIMITER.call do
|
|
48
|
+
# @param max_results [Integer] max result entries
|
|
49
|
+
# @param cancellable [Pikuri::Agent::Control::Cancellable] makes
|
|
50
|
+
# {LIMITER}'s pacing wait interruptible; see {RateLimiter#call}.
|
|
51
|
+
# @return [Array<Result>] hits, possibly empty when DDG matched nothing
|
|
52
|
+
# @raise [Engines::Unavailable] on a soft-block (anomaly/CAPTCHA page) or
|
|
53
|
+
# HTTP 429/5xx (the cascade falls back), or if {LIMITER} is in cooldown.
|
|
54
|
+
# @raise [RuntimeError] on other HTTP failures or an unrecognized
|
|
55
|
+
# empty-results layout. A genuine empty-results page is not an error;
|
|
56
|
+
# see {.parse}.
|
|
57
|
+
def search(query, max_results: DEFAULT_MAX_RESULTS,
|
|
58
|
+
cancellable: Pikuri::Agent::Control::Cancellable::NEVER)
|
|
59
|
+
LIMITER.call(cancellable: cancellable) do
|
|
77
60
|
response = Faraday.get(ENDPOINT, { q: query }, { 'User-Agent' => USER_AGENT })
|
|
78
61
|
unless response.success?
|
|
79
62
|
if response.status == 429 || response.status >= 500
|
|
@@ -87,28 +70,18 @@ module Pikuri
|
|
|
87
70
|
end
|
|
88
71
|
end
|
|
89
72
|
|
|
90
|
-
# Parse a +html.duckduckgo.com+ result page into
|
|
91
|
-
#
|
|
92
|
-
#
|
|
93
|
-
#
|
|
94
|
-
# a
|
|
95
|
-
# found" indicator) returns an empty array instead of raising, so
|
|
96
|
-
# {Engines.search} can render its standard no-results stub.
|
|
97
|
-
# Anything else (anomaly modal, CAPTCHA, service-unavailable page,
|
|
98
|
-
# unknown layout) raises with the diagnostic text extracted from
|
|
99
|
-
# the body, so an IP soft-block is surfaced rather than silently
|
|
73
|
+
# Parse a +html.duckduckgo.com+ result page into {Result} rows (+<b>+
|
|
74
|
+
# highlights stripped). On zero result nodes: a genuine "no results" page
|
|
75
|
+
# returns +[]+ (so {Engines.search} renders its stub); the anomaly/CAPTCHA
|
|
76
|
+
# modal raises {Engines::Unavailable}; anything else raises {RuntimeError}
|
|
77
|
+
# (likely a layout change) — so an IP soft-block surfaces rather than
|
|
100
78
|
# masquerading as an empty search.
|
|
101
79
|
#
|
|
102
|
-
# @param html [String] HTML
|
|
103
|
-
# @param max_results [Integer]
|
|
104
|
-
# @return [Array<Result>] hits, possibly empty on a genuine
|
|
105
|
-
#
|
|
106
|
-
# @raise [
|
|
107
|
-
# anomaly/CAPTCHA modal (IP soft-block) — a "try again later" the
|
|
108
|
-
# cascade can fall back from.
|
|
109
|
-
# @raise [RuntimeError] when the page contains no result nodes and is
|
|
110
|
-
# not recognized as either a genuine no-results page or the
|
|
111
|
-
# anomaly modal (likely a layout change worth surfacing loudly).
|
|
80
|
+
# @param html [String] HTML from html.duckduckgo.com
|
|
81
|
+
# @param max_results [Integer] max result entries
|
|
82
|
+
# @return [Array<Result>] hits, possibly empty on a genuine no-results page
|
|
83
|
+
# @raise [Engines::Unavailable] on the anomaly/CAPTCHA modal (IP soft-block)
|
|
84
|
+
# @raise [RuntimeError] on zero results with an unrecognized layout
|
|
112
85
|
def self.parse(html, max_results: DEFAULT_MAX_RESULTS)
|
|
113
86
|
doc = Nokogiri::HTML(html)
|
|
114
87
|
results = doc.css('div.result.web-result').take(max_results).filter_map do |node|
|
|
@@ -133,25 +106,22 @@ module Pikuri
|
|
|
133
106
|
results
|
|
134
107
|
end
|
|
135
108
|
|
|
136
|
-
# True when the page
|
|
137
|
-
#
|
|
138
|
-
# {
|
|
139
|
-
# provider) and {RuntimeError} (unknown layout, surface loudly).
|
|
109
|
+
# True when the page is DDG's anomaly/CAPTCHA modal (IP soft-blocked).
|
|
110
|
+
# {.parse} uses it to pick {Engines::Unavailable} (recoverable) over
|
|
111
|
+
# {RuntimeError} (unknown layout).
|
|
140
112
|
#
|
|
141
|
-
# @param doc [Nokogiri::HTML::Document]
|
|
113
|
+
# @param doc [Nokogiri::HTML::Document]
|
|
142
114
|
# @return [Boolean]
|
|
143
115
|
def self.anomaly_modal?(doc)
|
|
144
116
|
!!(doc.at_css('.anomaly-modal__title') || doc.at_css('.anomaly-modal__description'))
|
|
145
117
|
end
|
|
146
118
|
private_class_method :anomaly_modal?
|
|
147
119
|
|
|
148
|
-
# True when a
|
|
149
|
-
#
|
|
150
|
-
#
|
|
151
|
-
# are present we never treat the page as a genuine empty result, even
|
|
152
|
-
# if the surrounding text happens to mention "No results".
|
|
120
|
+
# True when a zero-node page is DDG's own "no results found" page, not an
|
|
121
|
+
# anomaly/CAPTCHA. Anomaly wins: if the modal divs are present it's never
|
|
122
|
+
# a genuine empty result, even if the text mentions "No results".
|
|
153
123
|
#
|
|
154
|
-
# @param doc [Nokogiri::HTML::Document]
|
|
124
|
+
# @param doc [Nokogiri::HTML::Document]
|
|
155
125
|
# @return [Boolean]
|
|
156
126
|
def self.genuine_no_results?(doc)
|
|
157
127
|
return false if doc.at_css('.anomaly-modal__title') || doc.at_css('.anomaly-modal__description')
|
|
@@ -161,14 +131,12 @@ module Pikuri
|
|
|
161
131
|
end
|
|
162
132
|
private_class_method :genuine_no_results?
|
|
163
133
|
|
|
164
|
-
# Build an error message for a
|
|
165
|
-
#
|
|
166
|
-
#
|
|
167
|
-
# +<script>+/+<style>+/+<noscript>+ stripped and whitespace collapsed)
|
|
168
|
-
# and includes a truncated copy so the caller can see what came back.
|
|
134
|
+
# Build an error message for a zero-match page: quote the DDG anomaly
|
|
135
|
+
# modal's title/description if present, else a truncated copy of the
|
|
136
|
+
# body's visible text (+<script>/<style>/<noscript>+ stripped).
|
|
169
137
|
#
|
|
170
|
-
# @param doc [Nokogiri::HTML::Document]
|
|
171
|
-
# @return [String]
|
|
138
|
+
# @param doc [Nokogiri::HTML::Document]
|
|
139
|
+
# @return [String] diagnostic to feed to +raise+
|
|
172
140
|
def self.diagnose_empty(doc)
|
|
173
141
|
title = doc.at_css('.anomaly-modal__title')&.text&.strip
|
|
174
142
|
desc = doc.at_css('.anomaly-modal__description')&.text&.strip
|
|
@@ -184,12 +152,12 @@ module Pikuri
|
|
|
184
152
|
end
|
|
185
153
|
private_class_method :diagnose_empty
|
|
186
154
|
|
|
187
|
-
# Decode
|
|
188
|
-
#
|
|
155
|
+
# Decode DDG's +//duckduckgo.com/l/?uddg=<encoded>+ redirect wrapper to
|
|
156
|
+
# the real target URL.
|
|
189
157
|
#
|
|
190
|
-
# @param href [String, nil] href
|
|
191
|
-
# @return [String] the decoded target
|
|
192
|
-
#
|
|
158
|
+
# @param href [String, nil] href from the result page
|
|
159
|
+
# @return [String] the decoded target, or +href+ unchanged when not a
|
|
160
|
+
# recognized DDG redirect or unparseable
|
|
193
161
|
def self.extract_url(href)
|
|
194
162
|
return href if href.nil? || href.empty?
|
|
195
163
|
|