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
|
|
|
|
|
|
}
|