thread-master/apps/backend/internal/module/scout/usecase/exa_threads_provider.go

164 lines
4.0 KiB
Go

package usecase
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"time"
)
const exaSearchURL = "https://api.exa.ai/search"
// ThreadSearchProvider finds public Threads posts for a Scout scan.
type ThreadSearchProvider interface {
SearchThreads(ctx context.Context, terms []string, limit int) ([]ThreadSearchResult, error)
}
type ThreadSearchResult struct {
URL string
Title string
Snippet string
}
// ExaThreadsProvider searches only Threads-owned domains through Exa.
type ExaThreadsProvider struct {
APIKey string
BaseURL string
HTTP *http.Client
}
func NewExaThreadsProvider(apiKey string) *ExaThreadsProvider {
return &ExaThreadsProvider{
APIKey: strings.TrimSpace(apiKey),
BaseURL: exaSearchURL,
HTTP: &http.Client{Timeout: 25 * time.Second},
}
}
func newDefaultExaThreadsProvider() *ExaThreadsProvider {
key := os.Getenv("EXA_API_KEY")
if strings.TrimSpace(key) == "" {
key = os.Getenv("EXA_KEY")
}
return NewExaThreadsProvider(key)
}
func (p *ExaThreadsProvider) SearchThreads(ctx context.Context, terms []string, limit int) ([]ThreadSearchResult, error) {
if p == nil || strings.TrimSpace(p.APIKey) == "" {
return nil, fmt.Errorf("exa Threads search is not configured")
}
if limit <= 0 {
limit = 5
}
if limit > 20 {
limit = 20
}
query := strings.Join(nonEmptyTerms(terms), " ")
if query == "" {
return nil, fmt.Errorf("exa Threads search query required")
}
payload, err := json.Marshal(map[string]any{
"query": query,
"type": "auto",
"numResults": limit,
"includeDomains": []string{"threads.net", "threads.com"},
"contents": map[string]any{
"highlights": true,
"text": map[string]any{"maxCharacters": 400},
},
})
if err != nil {
return nil, err
}
baseURL := p.BaseURL
if baseURL == "" {
baseURL = exaSearchURL
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL, 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", p.APIKey)
httpClient := p.HTTP
if httpClient == nil {
httpClient = &http.Client{Timeout: 25 * time.Second}
}
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, truncate(string(raw), 120))
}
var response struct {
Results []struct {
Title string `json:"title"`
URL string `json:"url"`
Highlights []string `json:"highlights"`
Text string `json:"text"`
} `json:"results"`
}
if err := json.Unmarshal(raw, &response); err != nil {
return nil, err
}
results := make([]ThreadSearchResult, 0, len(response.Results))
for _, hit := range response.Results {
url := strings.TrimSpace(hit.URL)
if !isThreadsURL(url) {
continue
}
snippet := firstNonEmpty(hit.Highlights)
if snippet == "" {
snippet = strings.TrimSpace(hit.Text)
}
if snippet == "" {
snippet = strings.TrimSpace(hit.Title)
}
results = append(results, ThreadSearchResult{URL: url, Title: strings.TrimSpace(hit.Title), Snippet: truncate(snippet, 400)})
}
return results, nil
}
func isThreadsURL(raw string) bool {
u, err := url.Parse(raw)
if err != nil {
return false
}
host := strings.ToLower(u.Hostname())
return host == "threads.net" || strings.HasSuffix(host, ".threads.net") || host == "threads.com" || strings.HasSuffix(host, ".threads.com")
}
func nonEmptyTerms(terms []string) []string {
out := make([]string, 0, len(terms))
for _, term := range terms {
if term = strings.TrimSpace(term); term != "" {
out = append(out, term)
}
}
return out
}
func firstNonEmpty(values []string) string {
for _, value := range values {
if value = strings.TrimSpace(value); value != "" {
return value
}
}
return ""
}