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

205 lines
5.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
// PublishedAt unix nanoseconds when known (Exa publishedDate / crawler parse).
PublishedAt int64
// MatchedQuery is set by fan-out search to the query that found this hit.
MatchedQuery string
// Track: top | recent | both — crawler dual-track; empty for Exa/API.
Track string
// SerpRank is 1-based rank within the source SERP track (crawler); 0 = unknown.
SerpRank int
}
// 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 = 10
}
if limit > 20 {
limit = 20
}
// 單次呼叫:建議只傳 1 條完整 queryfan-out 在上層)
clean := nonEmptyTerms(terms)
if len(clean) == 0 {
return nil, fmt.Errorf("exa Threads search query required")
}
query := clean[0]
if len(clean) > 1 {
// 相容舊呼叫:仍可 join但 fan-out 路徑不會走這裡
query = strings.Join(clean, " ")
}
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"`
PublishedDate string `json:"publishedDate"`
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),
PublishedAt: parsePublishedDateNano(hit.PublishedDate),
})
}
return results, nil
}
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
}
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 ""
}