196 lines
4.2 KiB
Go
196 lines
4.2 KiB
Go
|
|
package search
|
||
|
|
|
||
|
|
import (
|
||
|
|
"bytes"
|
||
|
|
"context"
|
||
|
|
"encoding/json"
|
||
|
|
"fmt"
|
||
|
|
"io"
|
||
|
|
"net/http"
|
||
|
|
"strings"
|
||
|
|
"time"
|
||
|
|
)
|
||
|
|
|
||
|
|
const defaultBaseURL = "https://api.exa.ai/search"
|
||
|
|
|
||
|
|
type Hit struct {
|
||
|
|
Title string `json:"title"`
|
||
|
|
URL string `json:"url"`
|
||
|
|
Snippet string `json:"snippet"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type Client interface {
|
||
|
|
Search(ctx context.Context, apiKey, query string, limit int) ([]Hit, error)
|
||
|
|
}
|
||
|
|
|
||
|
|
// FakeClient for tests / no platform key.
|
||
|
|
type FakeClient struct {
|
||
|
|
Fail bool
|
||
|
|
}
|
||
|
|
|
||
|
|
func (f *FakeClient) Search(_ context.Context, apiKey, query string, limit int) ([]Hit, error) {
|
||
|
|
if f.Fail || apiKey == "" {
|
||
|
|
return nil, fmt.Errorf("search failed: no key")
|
||
|
|
}
|
||
|
|
if limit <= 0 {
|
||
|
|
limit = 3
|
||
|
|
}
|
||
|
|
var hits []Hit
|
||
|
|
for i := 0; i < limit; i++ {
|
||
|
|
hits = append(hits, Hit{
|
||
|
|
Title: fmt.Sprintf("Result %d for %s", i+1, strings.TrimSpace(query)),
|
||
|
|
URL: fmt.Sprintf("https://example.com/r/%d", i+1),
|
||
|
|
Snippet: "fake exa snippet",
|
||
|
|
})
|
||
|
|
}
|
||
|
|
return hits, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// ExaClient calls api.exa.ai (real web search).
|
||
|
|
type ExaClient struct {
|
||
|
|
BaseURL string
|
||
|
|
HTTP *http.Client
|
||
|
|
// IncludeDomains optional host filter (e.g. threads.net).
|
||
|
|
IncludeDomains []string
|
||
|
|
}
|
||
|
|
|
||
|
|
// NewExa creates a ready HTTP client.
|
||
|
|
func NewExa() *ExaClient {
|
||
|
|
return &ExaClient{
|
||
|
|
BaseURL: defaultBaseURL,
|
||
|
|
HTTP: &http.Client{Timeout: 25 * time.Second},
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// NewExaThreads prefers results from Threads domains.
|
||
|
|
func NewExaThreads() *ExaClient {
|
||
|
|
c := NewExa()
|
||
|
|
c.IncludeDomains = []string{"threads.net", "threads.com"}
|
||
|
|
return c
|
||
|
|
}
|
||
|
|
|
||
|
|
func (c *ExaClient) Search(ctx context.Context, apiKey, query string, limit int) ([]Hit, error) {
|
||
|
|
apiKey = strings.TrimSpace(apiKey)
|
||
|
|
query = strings.TrimSpace(query)
|
||
|
|
if apiKey == "" || strings.HasPrefix(apiKey, "fake") {
|
||
|
|
return nil, fmt.Errorf("search failed: no key")
|
||
|
|
}
|
||
|
|
if query == "" {
|
||
|
|
return nil, fmt.Errorf("search query required")
|
||
|
|
}
|
||
|
|
if limit <= 0 {
|
||
|
|
limit = 5
|
||
|
|
}
|
||
|
|
if limit > 20 {
|
||
|
|
limit = 20
|
||
|
|
}
|
||
|
|
base := c.BaseURL
|
||
|
|
if base == "" {
|
||
|
|
base = defaultBaseURL
|
||
|
|
}
|
||
|
|
httpClient := c.HTTP
|
||
|
|
if httpClient == nil {
|
||
|
|
httpClient = &http.Client{Timeout: 25 * time.Second}
|
||
|
|
}
|
||
|
|
|
||
|
|
body := map[string]any{
|
||
|
|
"query": query,
|
||
|
|
"type": "auto",
|
||
|
|
"numResults": limit,
|
||
|
|
"userLocation": "TW",
|
||
|
|
"contents": map[string]any{
|
||
|
|
"highlights": true,
|
||
|
|
"text": map[string]any{"maxCharacters": 400},
|
||
|
|
},
|
||
|
|
}
|
||
|
|
if len(c.IncludeDomains) > 0 {
|
||
|
|
body["includeDomains"] = c.IncludeDomains
|
||
|
|
}
|
||
|
|
|
||
|
|
payload, err := json.Marshal(body)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, base, bytes.NewReader(payload))
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
req.Header.Set("Accept", "application/json")
|
||
|
|
req.Header.Set("Content-Type", "application/json")
|
||
|
|
req.Header.Set("x-api-key", apiKey)
|
||
|
|
|
||
|
|
res, err := httpClient.Do(req)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
defer res.Body.Close()
|
||
|
|
raw, err := io.ReadAll(io.LimitReader(res.Body, 1<<20))
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
if res.StatusCode != http.StatusOK {
|
||
|
|
return nil, fmt.Errorf("exa status %d: %s", res.StatusCode, truncateRunes(string(raw), 120))
|
||
|
|
}
|
||
|
|
|
||
|
|
var parsed struct {
|
||
|
|
Results []struct {
|
||
|
|
Title string `json:"title"`
|
||
|
|
URL string `json:"url"`
|
||
|
|
Highlights []string `json:"highlights"`
|
||
|
|
Text string `json:"text"`
|
||
|
|
Author string `json:"author"`
|
||
|
|
} `json:"results"`
|
||
|
|
}
|
||
|
|
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
|
||
|
|
out := make([]Hit, 0, len(parsed.Results))
|
||
|
|
for _, item := range parsed.Results {
|
||
|
|
u := strings.TrimSpace(item.URL)
|
||
|
|
if u == "" {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
snippet := firstNonEmpty(item.Highlights...)
|
||
|
|
if snippet == "" {
|
||
|
|
snippet = strings.TrimSpace(item.Text)
|
||
|
|
}
|
||
|
|
if snippet == "" {
|
||
|
|
snippet = strings.TrimSpace(item.Title)
|
||
|
|
}
|
||
|
|
title := strings.TrimSpace(item.Title)
|
||
|
|
if title == "" {
|
||
|
|
title = strings.TrimSpace(item.Author)
|
||
|
|
}
|
||
|
|
if title == "" {
|
||
|
|
title = u
|
||
|
|
}
|
||
|
|
out = append(out, Hit{
|
||
|
|
Title: title,
|
||
|
|
URL: u,
|
||
|
|
Snippet: truncateRunes(snippet, 200),
|
||
|
|
})
|
||
|
|
if len(out) >= limit {
|
||
|
|
break
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return out, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func firstNonEmpty(ss ...string) string {
|
||
|
|
for _, s := range ss {
|
||
|
|
if t := strings.TrimSpace(s); t != "" {
|
||
|
|
return t
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return ""
|
||
|
|
}
|
||
|
|
|
||
|
|
func truncateRunes(s string, n int) string {
|
||
|
|
r := []rune(s)
|
||
|
|
if len(r) <= n {
|
||
|
|
return s
|
||
|
|
}
|
||
|
|
return string(r[:n]) + "…"
|
||
|
|
}
|