164 lines
4.0 KiB
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 ""
|
||
|
|
}
|