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

205 lines
5.1 KiB
Go
Raw Normal View History

2026-07-13 08:59:13 +00:00
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 {
2026-08-03 07:58:07 +00:00
URL string
Title string
Snippet string
// PublishedAt unix nanoseconds when known (Exa publishedDate).
PublishedAt int64
// MatchedQuery is set by fan-out search to the query that found this hit.
MatchedQuery string
2026-07-13 08:59:13 +00:00
}
// 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 {
2026-08-03 07:58:07 +00:00
limit = 10
2026-07-13 08:59:13 +00:00
}
if limit > 20 {
limit = 20
}
2026-08-03 07:58:07 +00:00
// 單次呼叫:建議只傳 1 條完整 queryfan-out 在上層)
clean := nonEmptyTerms(terms)
if len(clean) == 0 {
2026-07-13 08:59:13 +00:00
return nil, fmt.Errorf("exa Threads search query required")
}
2026-08-03 07:58:07 +00:00
query := clean[0]
if len(clean) > 1 {
// 相容舊呼叫:仍可 join但 fan-out 路徑不會走這裡
query = strings.Join(clean, " ")
}
// 近 30 天,減少舊硬廣/過期活動
startPublished := time.Now().UTC().AddDate(0, 0, -30).Format(time.RFC3339)
2026-07-13 08:59:13 +00:00
payload, err := json.Marshal(map[string]any{
2026-08-03 07:58:07 +00:00
"query": query,
"type": "auto",
"numResults": limit,
"includeDomains": []string{"threads.net", "threads.com"},
"startPublishedDate": startPublished,
2026-07-13 08:59:13 +00:00
"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 {
2026-08-03 07:58:07 +00:00
Title string `json:"title"`
URL string `json:"url"`
PublishedDate string `json:"publishedDate"`
Highlights []string `json:"highlights"`
Text string `json:"text"`
2026-07-13 08:59:13 +00:00
} `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)
}
2026-08-03 07:58:07 +00:00
results = append(results, ThreadSearchResult{
URL: url,
Title: strings.TrimSpace(hit.Title),
Snippet: truncate(snippet, 400),
PublishedAt: parsePublishedDateNano(hit.PublishedDate),
})
2026-07-13 08:59:13 +00:00
}
return results, nil
}
2026-08-03 07:58:07 +00:00
func parsePublishedDateNano(raw string) int64 {
raw = strings.TrimSpace(raw)
if raw == "" {
return 0
}
// Exa 常見 RFC3339 / date-only
layouts := []string{
time.RFC3339,
time.RFC3339Nano,
"2006-01-02T15:04:05.000Z",
"2006-01-02T15:04:05Z",
"2006-01-02",
}
for _, layout := range layouts {
if t, err := time.Parse(layout, raw); err == nil {
return t.UTC().UnixNano()
}
}
return 0
}
2026-07-13 08:59:13 +00:00
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 ""
}