webctx 0.2.8 → 0.2.10
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.
- package/AGENTS.md +12 -1
- package/CONTRIBUTORS.md +2 -2
- package/README.md +27 -2
- package/internal/app/app.go +73 -2
- package/internal/app/app_test.go +7 -0
- package/internal/app/providers.go +165 -0
- package/internal/app/providers_test.go +305 -0
- package/internal/app/scrape.go +1 -1
- package/internal/app/search_keys.go +233 -0
- package/internal/app/search_keys_test.go +227 -0
- package/internal/app/tools.go +116 -62
- package/package.json +1 -1
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
package app
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"bytes"
|
|
5
|
+
"context"
|
|
6
|
+
"encoding/json"
|
|
7
|
+
"fmt"
|
|
8
|
+
"io"
|
|
9
|
+
"net/http"
|
|
10
|
+
"os"
|
|
11
|
+
"path/filepath"
|
|
12
|
+
"reflect"
|
|
13
|
+
"strings"
|
|
14
|
+
"testing"
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
func TestSearchKeyOrderDeduplicationAndMissingKeys(t *testing.T) {
|
|
18
|
+
t.Setenv("EXA_API_KEYS", ` ["new","new","old"] `)
|
|
19
|
+
t.Setenv("EXA_API_KEY", "old")
|
|
20
|
+
if got, want := searchKeysForProvider("exa"), []string{"new", "old"}; !reflect.DeepEqual(got, want) {
|
|
21
|
+
t.Fatalf("deduplication: got %v, want %v", got, want)
|
|
22
|
+
}
|
|
23
|
+
t.Setenv("EXA_API_KEYS", "new,another\n old\n")
|
|
24
|
+
if got, want := searchKeysForProvider("exa"), []string{"new", "another", "old"}; !reflect.DeepEqual(got, want) {
|
|
25
|
+
t.Fatalf("CSV/newline parsing: got %v, want %v", got, want)
|
|
26
|
+
}
|
|
27
|
+
t.Setenv("EXA_API_KEYS", `[invalid`)
|
|
28
|
+
t.Setenv("EXA_API_KEY", "")
|
|
29
|
+
if got := searchKeysForProvider("exa"); len(got) != 0 {
|
|
30
|
+
t.Fatalf("malformed JSON should not be used as credential: %v", got)
|
|
31
|
+
}
|
|
32
|
+
if got := searchKeysForProvider("unknown"); len(got) != 0 {
|
|
33
|
+
t.Fatalf("unknown provider should have no keys: %v", got)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
func TestStatusReportsMultipleKeysWithoutRevealingThem(t *testing.T) {
|
|
38
|
+
t.Setenv("WEBCTX_CONFIG_FILE", filepath.Join(t.TempDir(), "webctx", "config.json"))
|
|
39
|
+
t.Setenv("EXA_API_KEYS", "extra-secret-a,extra-secret-b")
|
|
40
|
+
t.Setenv("EXA_API_KEY", "legacy-secret")
|
|
41
|
+
t.Setenv("BRAVE_API_KEY", "")
|
|
42
|
+
t.Setenv("BRAVE_API_KEYS", "")
|
|
43
|
+
t.Setenv("TAVILY_API_KEY", "")
|
|
44
|
+
t.Setenv("TAVILY_API_KEYS", "")
|
|
45
|
+
t.Setenv("FIRECRAWL_API_KEY", "")
|
|
46
|
+
var out, errOut bytes.Buffer
|
|
47
|
+
if code := Run([]string{"status"}, &out, &errOut); code != 0 {
|
|
48
|
+
t.Fatalf("status error: %s", errOut.String())
|
|
49
|
+
}
|
|
50
|
+
if !strings.Contains(out.String(), "exa enabled key configured (3 keys)") {
|
|
51
|
+
t.Fatalf("incorrect key count: %q", out.String())
|
|
52
|
+
}
|
|
53
|
+
for _, secret := range []string{"extra-secret-a", "extra-secret-b", "legacy-secret"} {
|
|
54
|
+
if strings.Contains(out.String()+errOut.String(), secret) {
|
|
55
|
+
t.Fatal("status exposed a configured API key")
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
func TestManagedSearchKeychainAddRemoveAndInputValidation(t *testing.T) {
|
|
61
|
+
oldLookup, oldStore := keychainLookup, keychainStore
|
|
62
|
+
t.Cleanup(func() { keychainLookup, keychainStore = oldLookup, oldStore })
|
|
63
|
+
managed := map[string]string{}
|
|
64
|
+
keychainLookup = func(account string) (string, error) { return managed[account], nil }
|
|
65
|
+
keychainStore = func(account, secret string) error { managed[account] = secret; return nil }
|
|
66
|
+
|
|
67
|
+
for _, candidate := range []string{"", "one two", "line1\nline2", strings.Repeat("z", 4097)} {
|
|
68
|
+
if _, err := addManagedSearchKey("exa", strings.NewReader(candidate)); err == nil {
|
|
69
|
+
t.Fatalf("invalid secret accepted")
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if len(managed) != 0 {
|
|
73
|
+
t.Fatal("invalid keys must not modify Keychain")
|
|
74
|
+
}
|
|
75
|
+
if _, err := addManagedSearchKey("unknown", strings.NewReader("token")); err == nil {
|
|
76
|
+
t.Fatal("unknown provider accepted")
|
|
77
|
+
}
|
|
78
|
+
for _, pair := range []struct {
|
|
79
|
+
key string
|
|
80
|
+
count int
|
|
81
|
+
}{{"older", 1}, {"newer", 2}, {"newer", 2}} {
|
|
82
|
+
count, err := addManagedSearchKey("exa", strings.NewReader(pair.key+"\n"))
|
|
83
|
+
if err != nil || count != pair.count {
|
|
84
|
+
t.Fatalf("add: count=%d error=%v", count, err)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
list, err := readManagedKeys("exa")
|
|
88
|
+
if err != nil || !reflect.DeepEqual(list, []string{"newer", "older"}) {
|
|
89
|
+
t.Fatalf("priority order changed: %v %v", list, err)
|
|
90
|
+
}
|
|
91
|
+
if strings.Contains(managed["EXA_API_KEYS"], "[REDACTED]") {
|
|
92
|
+
t.Fatal("managed keys stored as placeholder instead of actual tokens")
|
|
93
|
+
}
|
|
94
|
+
if _, err := removeManagedSearchKey("exa", "3"); err == nil {
|
|
95
|
+
t.Fatal("out-of-range removal accepted")
|
|
96
|
+
}
|
|
97
|
+
if count, err := removeManagedSearchKey("exa", "1"); err != nil || count != 1 {
|
|
98
|
+
t.Fatalf("remove: count=%d error=%v", count, err)
|
|
99
|
+
}
|
|
100
|
+
if list, err := readManagedKeys("exa"); err != nil || !reflect.DeepEqual(list, []string{"older"}) {
|
|
101
|
+
t.Fatalf("wrong managed key removed: %v %v", list, err)
|
|
102
|
+
}
|
|
103
|
+
managed["EXA_API_KEYS"] = "{invalid"
|
|
104
|
+
if _, err := addManagedSearchKey("exa", strings.NewReader("another")); err == nil {
|
|
105
|
+
t.Fatal("malformed Keychain JSON silently overwritten")
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
func TestManagedSearchKeyCommandNeverPrintsSecrets(t *testing.T) {
|
|
110
|
+
oldLookup, oldStore, oldStdin := keychainLookup, keychainStore, os.Stdin
|
|
111
|
+
t.Cleanup(func() { keychainLookup, keychainStore, os.Stdin = oldLookup, oldStore, oldStdin })
|
|
112
|
+
keychainLookup = func(account string) (string, error) { return "", nil }
|
|
113
|
+
keychainStore = func(account, value string) error { return nil }
|
|
114
|
+
input, writer, err := os.Pipe()
|
|
115
|
+
if err != nil {
|
|
116
|
+
t.Fatal(err)
|
|
117
|
+
}
|
|
118
|
+
if _, err := io.WriteString(writer, "sensitive-api-token\n"); err != nil {
|
|
119
|
+
t.Fatal(err)
|
|
120
|
+
}
|
|
121
|
+
_ = writer.Close()
|
|
122
|
+
os.Stdin = input
|
|
123
|
+
var out, errOut bytes.Buffer
|
|
124
|
+
code := Run([]string{"keys", "add", "exa", "--stdin"}, &out, &errOut)
|
|
125
|
+
_ = input.Close()
|
|
126
|
+
if code != 0 || strings.Contains(out.String()+errOut.String(), "sensitive-api-token") {
|
|
127
|
+
t.Fatalf("unsafe CLI output or command failed: code=%d out=%q err=%q", code, out.String(), errOut.String())
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Exercise the real HTTP request construction and key-selection order for all
|
|
132
|
+
// three providers, without making network requests or disclosing API keys.
|
|
133
|
+
func TestSearchProviderFallbackAcrossKeys(t *testing.T) {
|
|
134
|
+
t.Setenv("BRAVE_API_KEY", "legacy-brave")
|
|
135
|
+
t.Setenv("TAVILY_API_KEY", "legacy-tavily")
|
|
136
|
+
t.Setenv("EXA_API_KEY", "legacy-exa")
|
|
137
|
+
for _, name := range []string{"BRAVE", "TAVILY", "EXA"} {
|
|
138
|
+
t.Setenv(name+"_API_KEYS", "bad-"+strings.ToLower(name)+",good-"+strings.ToLower(name))
|
|
139
|
+
}
|
|
140
|
+
previous := http.DefaultClient.Transport
|
|
141
|
+
requests := map[string][]string{}
|
|
142
|
+
http.DefaultClient.Transport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
143
|
+
provider, key := "", ""
|
|
144
|
+
switch req.URL.Host {
|
|
145
|
+
case "api.search.brave.com":
|
|
146
|
+
provider, key = "brave", req.Header.Get("x-subscription-token")
|
|
147
|
+
case "api.exa.ai":
|
|
148
|
+
provider, key = "exa", req.Header.Get("x-api-key")
|
|
149
|
+
case "api.tavily.com":
|
|
150
|
+
provider = "tavily"
|
|
151
|
+
var payload map[string]any
|
|
152
|
+
_ = json.NewDecoder(req.Body).Decode(&payload)
|
|
153
|
+
key, _ = payload["api_key"].(string)
|
|
154
|
+
default:
|
|
155
|
+
t.Errorf("unexpected host: %s", req.URL.Host)
|
|
156
|
+
}
|
|
157
|
+
requests[provider] = append(requests[provider], key)
|
|
158
|
+
if strings.HasPrefix(key, "bad-") {
|
|
159
|
+
code := http.StatusUnauthorized
|
|
160
|
+
switch provider {
|
|
161
|
+
case "brave":
|
|
162
|
+
return testHTTPResponse(req, 422, `{"error":{"code":"SUBSCRIPTION_TOKEN_INVALID"}}`, nil), nil
|
|
163
|
+
case "tavily":
|
|
164
|
+
code = 432
|
|
165
|
+
case "exa":
|
|
166
|
+
code = http.StatusPaymentRequired
|
|
167
|
+
}
|
|
168
|
+
return testHTTPResponse(req, code, `{"error":"credits exhausted"}`, nil), nil
|
|
169
|
+
}
|
|
170
|
+
if strings.HasPrefix(key, "good-") {
|
|
171
|
+
switch provider {
|
|
172
|
+
case "brave":
|
|
173
|
+
return testHTTPResponse(req, 200, `{"web":{"results":[{"url":"https://example.com/guide","title":"Brave result"}]}}`, nil), nil
|
|
174
|
+
case "tavily", "exa":
|
|
175
|
+
return testHTTPResponse(req, 200, `{"results":[{"url":"https://example.com/guide","title":"API result"}]}`, nil), nil
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return testHTTPResponse(req, 500, "unexpected key", nil), nil
|
|
179
|
+
})
|
|
180
|
+
t.Cleanup(func() { http.DefaultClient.Transport = previous })
|
|
181
|
+
|
|
182
|
+
for _, test := range []struct {
|
|
183
|
+
provider string
|
|
184
|
+
call func() (SearchResult, error)
|
|
185
|
+
}{
|
|
186
|
+
{"brave", func() (SearchResult, error) { return searchWithBrave(context.Background(), "test query") }},
|
|
187
|
+
{"tavily", func() (SearchResult, error) { return searchWithTavily(context.Background(), "test query", nil) }},
|
|
188
|
+
{"exa", func() (SearchResult, error) {
|
|
189
|
+
return searchWithExa(context.Background(), "test query", nil, "exact phrase")
|
|
190
|
+
}},
|
|
191
|
+
} {
|
|
192
|
+
results, err := test.call()
|
|
193
|
+
if err != nil || len(results.Docs) != 1 {
|
|
194
|
+
t.Fatalf("%s: search failed after key rotation: %v %+v", test.provider, err, results)
|
|
195
|
+
}
|
|
196
|
+
want := []string{"bad-" + test.provider, "good-" + test.provider}
|
|
197
|
+
if got := requests[test.provider]; !reflect.DeepEqual(got, want) {
|
|
198
|
+
t.Fatalf("%s: key rotation order got %v, want %v", test.provider, got, want)
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
func TestSearchKeyRetryDoesNotMaskBadInputOrLeakKeys(t *testing.T) {
|
|
204
|
+
t.Setenv("EXA_API_KEYS", "first-secret,second-secret")
|
|
205
|
+
t.Setenv("EXA_API_KEY", "")
|
|
206
|
+
previous := http.DefaultClient.Transport
|
|
207
|
+
count, code := 0, http.StatusBadRequest
|
|
208
|
+
http.DefaultClient.Transport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
209
|
+
count++
|
|
210
|
+
return testHTTPResponse(req, code, fmt.Sprintf(`{"error":"key %s failed"}`, req.Header.Get("x-api-key")), nil), nil
|
|
211
|
+
})
|
|
212
|
+
t.Cleanup(func() { http.DefaultClient.Transport = previous })
|
|
213
|
+
_, err := searchWithExa(context.Background(), "test", nil, "")
|
|
214
|
+
if err == nil || count != 1 || strings.Contains(err.Error(), "first-secret") {
|
|
215
|
+
t.Fatalf("400 should not retry or leak a credential: count=%d error=%v", count, err)
|
|
216
|
+
}
|
|
217
|
+
count, code = 0, http.StatusUnprocessableEntity
|
|
218
|
+
_, err = searchWithExa(context.Background(), "test", nil, "")
|
|
219
|
+
if err == nil || count != 1 {
|
|
220
|
+
t.Fatalf("non-credential 422 should not retry: count=%d error=%v", count, err)
|
|
221
|
+
}
|
|
222
|
+
count, code = 0, http.StatusTooManyRequests
|
|
223
|
+
_, err = searchWithExa(context.Background(), "test", nil, "")
|
|
224
|
+
if err == nil || count != 2 || strings.Contains(err.Error(), "first-secret") || strings.Contains(err.Error(), "second-secret") || !strings.Contains(err.Error(), "up to 2") {
|
|
225
|
+
t.Fatalf("exhausted-key error is unsafe or not actionable: count=%d error=%v", count, err)
|
|
226
|
+
}
|
|
227
|
+
}
|
package/internal/app/tools.go
CHANGED
|
@@ -10,6 +10,7 @@ import (
|
|
|
10
10
|
"net/url"
|
|
11
11
|
"os"
|
|
12
12
|
"sort"
|
|
13
|
+
"strconv"
|
|
13
14
|
"strings"
|
|
14
15
|
"sync"
|
|
15
16
|
"time"
|
|
@@ -37,6 +38,11 @@ type providerDocs struct {
|
|
|
37
38
|
Err error
|
|
38
39
|
}
|
|
39
40
|
|
|
41
|
+
type namedSearch struct {
|
|
42
|
+
name string
|
|
43
|
+
fn func(context.Context) (SearchResult, error)
|
|
44
|
+
}
|
|
45
|
+
|
|
40
46
|
type scoredURL struct {
|
|
41
47
|
Result SearchDoc
|
|
42
48
|
InsertionOrder int
|
|
@@ -47,6 +53,13 @@ type scoredURL struct {
|
|
|
47
53
|
}
|
|
48
54
|
|
|
49
55
|
func Search(params SearchParams) (string, error) {
|
|
56
|
+
config, err := currentProviderConfig()
|
|
57
|
+
if err != nil {
|
|
58
|
+
return "", err
|
|
59
|
+
}
|
|
60
|
+
if !config.enabled("brave") && !config.enabled("tavily") && !config.enabled("exa") {
|
|
61
|
+
return "", errors.New("Error searching the web: all search providers are disabled; run 'webctx providers enable brave', 'webctx providers enable tavily', or 'webctx providers enable exa'")
|
|
62
|
+
}
|
|
50
63
|
defaultExcludedDomains := []string{"youtube.com", "vimeo.com", "dailymotion.com", "twitch.tv", "tiktok.com", "instagram.com", "facebook.com"}
|
|
51
64
|
allExcludedDomains := append(append([]string{}, defaultExcludedDomains...), params.ExcludeDomains...)
|
|
52
65
|
truncatedKeyword := truncateWords(params.IncludeKeyword, 5)
|
|
@@ -56,46 +69,44 @@ func Search(params SearchParams) (string, error) {
|
|
|
56
69
|
alexandriaDone := make(chan string, 1)
|
|
57
70
|
go func() { alexandriaDone <- discoverAlexandria(params.Query) }()
|
|
58
71
|
|
|
59
|
-
type namedSearch struct {
|
|
60
|
-
name string
|
|
61
|
-
fn func(context.Context) (SearchResult, error)
|
|
62
|
-
}
|
|
63
|
-
|
|
64
72
|
searches := []namedSearch{}
|
|
73
|
+
keywordFallback := false
|
|
65
74
|
if strings.TrimSpace(truncatedKeyword) != "" {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
75
|
+
if config.enabled("exa") && len(searchKeysForProvider("exa")) > 0 {
|
|
76
|
+
searches = append(searches, namedSearch{name: "Exa", fn: func(ctx context.Context) (SearchResult, error) {
|
|
77
|
+
return searchWithExa(ctx, params.Query, nil, truncatedKeyword)
|
|
78
|
+
}})
|
|
79
|
+
}
|
|
69
80
|
} else {
|
|
70
|
-
searches = append(searches,
|
|
71
|
-
|
|
72
|
-
namedSearch{name: "
|
|
73
|
-
|
|
74
|
-
|
|
81
|
+
searches = append(searches, alternativeSearches(config, params.Query)...)
|
|
82
|
+
if config.enabled("exa") {
|
|
83
|
+
searches = append(searches, namedSearch{name: "Exa", fn: func(ctx context.Context) (SearchResult, error) {
|
|
84
|
+
return searchWithExa(ctx, params.Query, nil, "")
|
|
85
|
+
}})
|
|
86
|
+
}
|
|
75
87
|
}
|
|
76
88
|
|
|
77
|
-
results :=
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
results
|
|
91
|
-
}
|
|
89
|
+
results := runSearches(searches)
|
|
90
|
+
if truncatedKeyword != "" && (len(searches) == 0 || (len(results) == 1 && results[0].Err != nil)) {
|
|
91
|
+
// Exa's includeText filter has no equivalent in Brave or Tavily. When Exa
|
|
92
|
+
// is disabled, unconfigured, or fails, search for the quoted phrase instead
|
|
93
|
+
// of making keyword searches unusable. Disclose the weaker semantics.
|
|
94
|
+
fallbackQuery := params.Query + " " + strconv.Quote(truncatedKeyword)
|
|
95
|
+
fallbackSearches := alternativeSearches(config, fallbackQuery)
|
|
96
|
+
if len(fallbackSearches) > 0 {
|
|
97
|
+
results = append(results, runSearches(fallbackSearches)...)
|
|
98
|
+
keywordFallback = true
|
|
99
|
+
} else if len(searches) == 0 && config.enabled("exa") {
|
|
100
|
+
// Preserve the helpful missing-key error if Exa is the only enabled
|
|
101
|
+
// provider and it has no key.
|
|
102
|
+
results = append(results, providerDocs{Provider: "Exa", Err: errors.New("missing EXA_API_KEY")})
|
|
103
|
+
}
|
|
92
104
|
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
total := 0
|
|
96
|
-
for _, r := range results {
|
|
97
|
-
total += len(r.Docs)
|
|
105
|
+
if len(results) == 0 {
|
|
106
|
+
return "", errors.New("Error searching the web: no enabled search providers are available for this query; use 'webctx status' to inspect settings")
|
|
98
107
|
}
|
|
108
|
+
|
|
109
|
+
total := countSearchDocs(results)
|
|
99
110
|
if total == 0 {
|
|
100
111
|
missingKeys := uniqueMissingCredentialKeys(results)
|
|
101
112
|
if len(missingKeys) > 0 {
|
|
@@ -123,6 +134,9 @@ func Search(params SearchParams) (string, error) {
|
|
|
123
134
|
}
|
|
124
135
|
|
|
125
136
|
parts := []string{fmt.Sprintf("Total Results: %d\n", len(ranked))}
|
|
137
|
+
if keywordFallback {
|
|
138
|
+
parts = append(parts, "> **Keyword fallback:** Exa was disabled or unavailable. Brave/Tavily searched for the quoted phrase; this is not a strict page-text inclusion filter.", "")
|
|
139
|
+
}
|
|
126
140
|
// The ranked index remains available, but agents also get useful source
|
|
127
141
|
// context without having to issue several follow-up read-link calls.
|
|
128
142
|
if context := enrichSearch(params.Query, ranked); context != "" {
|
|
@@ -144,6 +158,46 @@ func Search(params SearchParams) (string, error) {
|
|
|
144
158
|
return strings.Join(parts, "\n"), nil
|
|
145
159
|
}
|
|
146
160
|
|
|
161
|
+
func alternativeSearches(config providerConfig, query string) []namedSearch {
|
|
162
|
+
searches := []namedSearch{}
|
|
163
|
+
if config.enabled("brave") {
|
|
164
|
+
searches = append(searches, namedSearch{name: "Brave", fn: func(ctx context.Context) (SearchResult, error) { return searchWithBrave(ctx, query) }})
|
|
165
|
+
}
|
|
166
|
+
if config.enabled("tavily") {
|
|
167
|
+
searches = append(searches, namedSearch{name: "Tavily", fn: func(ctx context.Context) (SearchResult, error) { return searchWithTavily(ctx, query, nil) }})
|
|
168
|
+
}
|
|
169
|
+
return searches
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
func countSearchDocs(results []providerDocs) int {
|
|
173
|
+
count := 0
|
|
174
|
+
for _, r := range results {
|
|
175
|
+
count += len(r.Docs)
|
|
176
|
+
}
|
|
177
|
+
return count
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
func runSearches(searches []namedSearch) []providerDocs {
|
|
181
|
+
results := make([]providerDocs, len(searches))
|
|
182
|
+
var wg sync.WaitGroup
|
|
183
|
+
for i, s := range searches {
|
|
184
|
+
wg.Add(1)
|
|
185
|
+
go func(i int, s namedSearch) {
|
|
186
|
+
defer wg.Done()
|
|
187
|
+
ctx, cancel := context.WithTimeout(context.Background(), 40*time.Second)
|
|
188
|
+
defer cancel()
|
|
189
|
+
res, err := s.fn(ctx)
|
|
190
|
+
if err != nil {
|
|
191
|
+
results[i] = providerDocs{Provider: s.name, Docs: []SearchDoc{}, Err: err}
|
|
192
|
+
return
|
|
193
|
+
}
|
|
194
|
+
results[i] = providerDocs{Provider: s.name, Docs: res.Docs}
|
|
195
|
+
}(i, s)
|
|
196
|
+
}
|
|
197
|
+
wg.Wait()
|
|
198
|
+
return results
|
|
199
|
+
}
|
|
200
|
+
|
|
147
201
|
func ReadLink(rawURL string) (string, error) {
|
|
148
202
|
if err := validateSourceURL(rawURL); err != nil {
|
|
149
203
|
return "", err
|
|
@@ -327,20 +381,17 @@ func missingCredentialsError(prefix string, keys []string, detail string) error
|
|
|
327
381
|
}
|
|
328
382
|
|
|
329
383
|
func searchWithBrave(ctx context.Context, query string) (SearchResult, error) {
|
|
330
|
-
apiKey := strings.TrimSpace(os.Getenv("BRAVE_API_KEY"))
|
|
331
|
-
if apiKey == "" {
|
|
332
|
-
return SearchResult{}, errors.New("missing BRAVE_API_KEY")
|
|
333
|
-
}
|
|
334
384
|
params := url.Values{}
|
|
335
385
|
params.Set("q", query)
|
|
336
386
|
params.Set("text_decorations", "false")
|
|
337
387
|
params.Set("result_filter", "web")
|
|
338
388
|
params.Set("limit", "20")
|
|
339
|
-
body, err :=
|
|
340
|
-
"
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
389
|
+
body, err := useSearchKeys(ctx, "brave", func(apiKey string) ([]byte, error) {
|
|
390
|
+
return doRawRequest(ctx, http.MethodGet, "https://api.search.brave.com/res/v1/web/search?"+params.Encode(), map[string]string{
|
|
391
|
+
"Accept": "application/json",
|
|
392
|
+
"x-subscription-token": apiKey,
|
|
393
|
+
}, nil)
|
|
394
|
+
})
|
|
344
395
|
if err != nil {
|
|
345
396
|
return SearchResult{}, err
|
|
346
397
|
}
|
|
@@ -367,22 +418,20 @@ func searchWithBrave(ctx context.Context, query string) (SearchResult, error) {
|
|
|
367
418
|
}
|
|
368
419
|
|
|
369
420
|
func searchWithTavily(ctx context.Context, query string, excludeDomains []string) (SearchResult, error) {
|
|
370
|
-
apiKey := strings.TrimSpace(os.Getenv("TAVILY_API_KEY"))
|
|
371
|
-
if apiKey == "" {
|
|
372
|
-
return SearchResult{}, errors.New("missing TAVILY_API_KEY")
|
|
373
|
-
}
|
|
374
421
|
requestBody := map[string]any{
|
|
375
|
-
"api_key": apiKey,
|
|
376
422
|
"query": query,
|
|
377
423
|
"max_results": 20,
|
|
378
424
|
}
|
|
379
425
|
if len(excludeDomains) > 0 {
|
|
380
426
|
requestBody["exclude_domains"] = excludeDomains
|
|
381
427
|
}
|
|
382
|
-
body, err :=
|
|
383
|
-
"
|
|
384
|
-
"
|
|
385
|
-
|
|
428
|
+
body, err := useSearchKeys(ctx, "tavily", func(apiKey string) ([]byte, error) {
|
|
429
|
+
requestBody["api_key"] = apiKey
|
|
430
|
+
return doJSONRequest(ctx, http.MethodPost, "https://api.tavily.com/search", map[string]string{
|
|
431
|
+
"Content-Type": "application/json",
|
|
432
|
+
"Accept": "application/json",
|
|
433
|
+
}, requestBody)
|
|
434
|
+
})
|
|
386
435
|
if err != nil {
|
|
387
436
|
return SearchResult{}, err
|
|
388
437
|
}
|
|
@@ -407,10 +456,6 @@ func searchWithTavily(ctx context.Context, query string, excludeDomains []string
|
|
|
407
456
|
}
|
|
408
457
|
|
|
409
458
|
func searchWithExa(ctx context.Context, query string, excludeDomains []string, includeKeyword string) (SearchResult, error) {
|
|
410
|
-
apiKey := strings.TrimSpace(os.Getenv("EXA_API_KEY"))
|
|
411
|
-
if apiKey == "" {
|
|
412
|
-
return SearchResult{}, errors.New("missing EXA_API_KEY")
|
|
413
|
-
}
|
|
414
459
|
requestBody := map[string]any{
|
|
415
460
|
"query": query,
|
|
416
461
|
"type": "auto",
|
|
@@ -425,11 +470,13 @@ func searchWithExa(ctx context.Context, query string, excludeDomains []string, i
|
|
|
425
470
|
if strings.TrimSpace(includeKeyword) != "" {
|
|
426
471
|
requestBody["includeText"] = []string{includeKeyword}
|
|
427
472
|
}
|
|
428
|
-
body, err :=
|
|
429
|
-
"
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
473
|
+
body, err := useSearchKeys(ctx, "exa", func(apiKey string) ([]byte, error) {
|
|
474
|
+
return doJSONRequest(ctx, http.MethodPost, "https://api.exa.ai/search", map[string]string{
|
|
475
|
+
"Accept": "application/json",
|
|
476
|
+
"Content-Type": "application/json",
|
|
477
|
+
"x-api-key": apiKey,
|
|
478
|
+
}, requestBody)
|
|
479
|
+
})
|
|
433
480
|
if err != nil {
|
|
434
481
|
return SearchResult{}, err
|
|
435
482
|
}
|
|
@@ -631,13 +678,20 @@ func doRawRequest(ctx context.Context, method, rawURL string, headers map[string
|
|
|
631
678
|
}
|
|
632
679
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
633
680
|
if len(respBody) > 0 {
|
|
634
|
-
return nil, fmt.
|
|
681
|
+
return nil, &httpStatusError{Code: resp.StatusCode, Message: fmt.Sprintf("API request failed: %s - %s", resp.Status, strings.TrimSpace(string(respBody)))}
|
|
635
682
|
}
|
|
636
|
-
return nil,
|
|
683
|
+
return nil, &httpStatusError{Code: resp.StatusCode, Message: "API request failed: " + resp.Status}
|
|
637
684
|
}
|
|
638
685
|
return respBody, nil
|
|
639
686
|
}
|
|
640
687
|
|
|
688
|
+
type httpStatusError struct {
|
|
689
|
+
Code int
|
|
690
|
+
Message string
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
func (e *httpStatusError) Error() string { return e.Message }
|
|
694
|
+
|
|
641
695
|
func stringValue(v any) string {
|
|
642
696
|
if s, ok := v.(string); ok {
|
|
643
697
|
return s
|