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

183 lines
5.8 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"
"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
}
2026-08-09 07:57:35 +00:00
// 雙軌(熱門+最新)約兩倍時間
runCtx, cancel := context.WithTimeout(ctx, 150*time.Second)
2026-07-13 08:59:13 +00:00
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 {
2026-08-09 07:57:35 +00:00
client = &http.Client{Timeout: 160 * time.Second}
2026-07-13 08:59:13 +00:00
}
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 {
2026-08-09 07:57:35 +00:00
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"`
2026-07-13 08:59:13 +00:00
} `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
}
2026-08-09 07:57:35 +00:00
track := normalizeSearchTrack(post.Track)
pub := parsePublishedDateNano(post.PublishedAt)
// 硬擋僅極舊(預設 180 天45 天軟降權在 persist 做,避免誤殺
if pub > 0 && isStalePublished(pub, defaultScoutHardMaxAgeDays) {
continue
}
results = append(results, ThreadSearchResult{
URL: post.Permalink,
Title: post.Author,
Snippet: post.Text,
PublishedAt: pub,
Track: track,
SerpRank: post.SerpRank,
})
2026-07-13 08:59:13 +00:00
}
return results, nil
}
2026-08-09 07:57:35 +00:00
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 ""
}
}
// defaultScoutHardMaxAgeDays僅極舊文硬擋對齊 crawler SCOUT_MAX_AGE_DAYS 預設 180
const defaultScoutHardMaxAgeDays = 180
// defaultScoutSoftAgeDays超過此天數降權但不丟對齊「防 2024」與「別砍稍舊好文」
const defaultScoutSoftAgeDays = 45
// minCrediblePublishedNano早於 2020-01-01 的時間戳視為假資料/測試 stub不套用時效過濾。
var minCrediblePublishedNano = time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC).UnixNano()
func isStalePublished(publishedAtNano int64, maxAgeDays int) bool {
if publishedAtNano < minCrediblePublishedNano || maxAgeDays <= 0 {
return false
}
cutoff := time.Now().UTC().AddDate(0, 0, -maxAgeDays).UnixNano()
return publishedAtNano < cutoff
}
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
}