194 lines
5.9 KiB
Go
194 lines
5.9 KiB
Go
package usecase
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"strings"
|
||
"time"
|
||
|
||
"apps/backend/internal/module/scout/domain"
|
||
)
|
||
|
||
// ChromeCrawlerProvider is the private worker-to-browser boundary. The
|
||
// browser service receives decrypted state only for this request.
|
||
type ChromeCrawlerProvider interface {
|
||
SearchChrome(ctx context.Context, storageState string, terms []string, limit int) ([]ThreadSearchResult, error)
|
||
ResolveMediaID(ctx context.Context, storageState, permalink string) (string, error)
|
||
}
|
||
|
||
func (p *HTTPCrawlerProvider) ResolveMediaID(ctx context.Context, storageState, permalink string) (string, error) {
|
||
if p == nil || p.Endpoint == "" || p.Token == "" {
|
||
return "", fmt.Errorf("Chrome crawler is not configured")
|
||
}
|
||
body, err := json.Marshal(map[string]string{"storage_state": storageState, "permalink": permalink})
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.Endpoint+"/v1/threads/resolve", bytes.NewReader(body))
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
req.Header.Set("Content-Type", "application/json")
|
||
req.Header.Set("Authorization", "Bearer "+p.Token)
|
||
client := p.HTTP
|
||
if client == nil {
|
||
client = &http.Client{Timeout: 95 * time.Second}
|
||
}
|
||
res, err := client.Do(req)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
defer res.Body.Close()
|
||
raw, _ := io.ReadAll(io.LimitReader(res.Body, 64<<10))
|
||
if res.StatusCode < 200 || res.StatusCode >= 300 {
|
||
body := truncate(string(raw), 160)
|
||
err := fmt.Errorf("Chrome resolver status %d: %s", res.StatusCode, body)
|
||
if isCrawlerSessionDead(err) {
|
||
return "", fmt.Errorf("%w (%s)", domain.ErrCrawlerSessionExpired, body)
|
||
}
|
||
return "", err
|
||
}
|
||
var out struct {
|
||
MediaID string `json:"media_id"`
|
||
}
|
||
if err := json.Unmarshal(raw, &out); err != nil {
|
||
return "", err
|
||
}
|
||
if !isNumericMediaID(out.MediaID) {
|
||
return "", fmt.Errorf("Chrome resolver returned no numeric media ID")
|
||
}
|
||
return out.MediaID, nil
|
||
}
|
||
|
||
type HTTPCrawlerProvider struct {
|
||
Endpoint string
|
||
Token string
|
||
HTTP *http.Client
|
||
}
|
||
|
||
func NewHTTPCrawlerProvider(endpoint, token string) *HTTPCrawlerProvider {
|
||
return &HTTPCrawlerProvider{Endpoint: strings.TrimRight(strings.TrimSpace(endpoint), "/"), Token: strings.TrimSpace(token)}
|
||
}
|
||
|
||
func (p *HTTPCrawlerProvider) SearchChrome(ctx context.Context, storageState string, terms []string, limit int) ([]ThreadSearchResult, error) {
|
||
if p == nil || p.Endpoint == "" || p.Token == "" {
|
||
return nil, fmt.Errorf("Chrome crawler is not configured")
|
||
}
|
||
if strings.TrimSpace(storageState) == "" {
|
||
return nil, fmt.Errorf("crawler session required")
|
||
}
|
||
if limit < 1 {
|
||
limit = 10
|
||
}
|
||
if limit > 30 {
|
||
limit = 30
|
||
}
|
||
body, err := json.Marshal(map[string]any{"storage_state": storageState, "terms": nonEmptyTerms(terms), "limit": limit})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
// 雙軌(熱門+最新)約兩倍時間
|
||
runCtx, cancel := context.WithTimeout(ctx, 150*time.Second)
|
||
defer cancel()
|
||
req, err := http.NewRequestWithContext(runCtx, http.MethodPost, p.Endpoint+"/v1/threads/search", bytes.NewReader(body))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
req.Header.Set("Content-Type", "application/json")
|
||
req.Header.Set("Authorization", "Bearer "+p.Token)
|
||
client := p.HTTP
|
||
if client == nil {
|
||
client = &http.Client{Timeout: 160 * time.Second}
|
||
}
|
||
res, err := client.Do(req)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("Chrome crawler request failed: %w", err)
|
||
}
|
||
defer res.Body.Close()
|
||
raw, _ := io.ReadAll(io.LimitReader(res.Body, 1<<20))
|
||
if res.StatusCode < 200 || res.StatusCode >= 300 {
|
||
body := truncate(string(raw), 160)
|
||
err := fmt.Errorf("Chrome crawler status %d: %s", res.StatusCode, body)
|
||
if isCrawlerSessionDead(err) {
|
||
return nil, fmt.Errorf("%w (%s)", domain.ErrCrawlerSessionExpired, body)
|
||
}
|
||
return nil, err
|
||
}
|
||
var out struct {
|
||
Posts []struct {
|
||
Permalink string `json:"permalink"`
|
||
Author string `json:"author"`
|
||
Text string `json:"text"`
|
||
Track string `json:"track"`
|
||
SerpRank int `json:"serp_rank"`
|
||
PublishedAt string `json:"published_at"`
|
||
PublishedLabel string `json:"published_label"`
|
||
} `json:"posts"`
|
||
}
|
||
if err := json.Unmarshal(raw, &out); err != nil {
|
||
return nil, fmt.Errorf("Chrome crawler response invalid: %w", err)
|
||
}
|
||
results := make([]ThreadSearchResult, 0, len(out.Posts))
|
||
for _, post := range out.Posts {
|
||
if !isThreadsURL(post.Permalink) || strings.TrimSpace(post.Text) == "" {
|
||
continue
|
||
}
|
||
track := normalizeSearchTrack(post.Track)
|
||
pub := parsePublishedDateNano(post.PublishedAt)
|
||
results = append(results, ThreadSearchResult{
|
||
URL: post.Permalink,
|
||
Title: post.Author,
|
||
Snippet: post.Text,
|
||
PublishedAt: pub,
|
||
Track: track,
|
||
SerpRank: post.SerpRank,
|
||
})
|
||
}
|
||
return results, nil
|
||
}
|
||
|
||
func normalizeSearchTrack(s string) string {
|
||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||
case "both", "top+recent", "top_recent":
|
||
return "both"
|
||
case "recent", "latest", "new":
|
||
return "recent"
|
||
case "top", "hot", "default":
|
||
return "top"
|
||
default:
|
||
return ""
|
||
}
|
||
}
|
||
|
||
// defaultScoutSoftAgeDays:超過此天數只在 score 中降權,不淘汰候選。
|
||
const defaultScoutSoftAgeDays = 45
|
||
|
||
// minCrediblePublishedNano:早於 2020-01-01 的時間戳視為假資料/測試 stub,不套用時效過濾。
|
||
var minCrediblePublishedNano = time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC).UnixNano()
|
||
|
||
func isSoftAged(publishedAtNano int64, softDays int) bool {
|
||
if publishedAtNano < minCrediblePublishedNano || softDays <= 0 {
|
||
return false
|
||
}
|
||
cutoff := time.Now().UTC().AddDate(0, 0, -softDays).UnixNano()
|
||
return publishedAtNano < cutoff
|
||
}
|
||
|
||
func isCrawlerSessionDead(err error) bool {
|
||
if err == nil {
|
||
return false
|
||
}
|
||
if errors.Is(err, domain.ErrNoCrawlerSession) || errors.Is(err, domain.ErrCrawlerSessionExpired) {
|
||
return true
|
||
}
|
||
msg := strings.ToLower(err.Error())
|
||
return strings.Contains(msg, "crawler session expired") ||
|
||
strings.Contains(msg, "crawler session is invalid") ||
|
||
strings.Contains(msg, "crawler session required")
|
||
}
|