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 }