205 lines
5.1 KiB
Go
205 lines
5.1 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
|
||
// 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 條完整 query(fan-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 ""
|
||
}
|