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

168 lines
5.2 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"
"strings"
"time"
)
// 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 {
return "", fmt.Errorf("Chrome resolver status %d: %s", res.StatusCode, truncate(string(raw), 160))
}
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 {
return nil, fmt.Errorf("Chrome crawler status %d: %s", res.StatusCode, truncate(string(raw), 160))
}
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
}