package usecase import ( "context" "fmt" "strings" "time" "apps/backend/internal/module/radar/domain" usageDomain "apps/backend/internal/module/usage/domain" ) // ThreadHit is one raw search result from the dual-path fetch layer. type ThreadHit struct { URL string Title string Snippet string } // ThreadSearcher is the API-path search (Exa etc.). type ThreadSearcher interface { SearchThreads(ctx context.Context, terms []string, limit int) ([]ThreadHit, error) } // ChromeSearcher is the crawler path. type ChromeSearcher interface { SearchChrome(ctx context.Context, storageState string, terms []string, limit int) ([]ThreadHit, error) } // DevModeReader reports whether the member uses the crawler path. type DevModeReader interface { DevModeEnabled(ctx context.Context, ownerUID int64) (bool, error) } // CrawlerSessionReader returns decrypted Playwright storage state. type CrawlerSessionReader interface { CrawlerSessionToken(ctx context.Context, ownerUID int64) (string, error) } // HitFetcher is the preferred injected dual-path entry (scout.SearchHitsOnly adapter). type HitFetcher interface { SearchHits(ctx context.Context, ownerUID int64, terms []string, limit int) (hits []ThreadHit, path string, err error) } // HitFetcherFunc adapts a function to HitFetcher. type HitFetcherFunc func(ctx context.Context, ownerUID int64, terms []string, limit int) ([]ThreadHit, string, error) func (f HitFetcherFunc) SearchHits(ctx context.Context, ownerUID int64, terms []string, limit int) ([]ThreadHit, string, error) { return f(ctx, ownerUID, terms, limit) } /* FetchCandidates runs dual-path fetch for a watch. dev_mode=false → path=api; true + session → path=crawler. exclude_terms filtered after fetch. Meter: web_search / radar.sweep for API path. */ func (s *Service) FetchCandidates(ctx context.Context, ownerUID int64, w *domain.RadarWatch, limit int) (cands []*domain.CandidatePost, path string, credits int, err error) { if w == nil { return nil, "", 0, fmt.Errorf("%w: watch required", domain.ErrValidation) } terms := w.Terms if len(terms) == 0 { return nil, "", 0, fmt.Errorf("%w: watch has no terms", domain.ErrValidation) } if limit <= 0 { limit = 20 } var hits []ThreadHit if s.HitFetch != nil { hits, path, err = s.HitFetch.SearchHits(ctx, ownerUID, terms, limit) } else { hits, path, err = s.fetchViaProviders(ctx, ownerUID, terms, limit) } if err != nil { return nil, path, 0, err } // Bill API path search once per sweep (crawler uses member session — still record sweep credit as web_search when platform path). if path == domain.SweepPathAPI || path == "api" { charge, berr := s.bill(ctx, ownerUID, usageDomain.MeterWebSearch, "雷達巡檢抓取", "radar.sweep") if berr != nil { return nil, path, 0, berr } // Commit immediately — hits already returned. charge.Commit(ctx) credits = usageDomain.MeterCost(usageDomain.MeterWebSearch) } if path == "" { path = domain.SweepPathAPI } if path == "api" { path = domain.SweepPathAPI } if path == "crawler" { path = domain.SweepPathCrawler } exclude := map[string]bool{} for _, e := range w.ExcludeTerms { exclude[strings.ToLower(strings.TrimSpace(e))] = true } now := domain.NowNano() out := make([]*domain.CandidatePost, 0, len(hits)) for _, h := range hits { text := strings.TrimSpace(h.Snippet) if text == "" { text = strings.TrimSpace(h.Title) } if text == "" { continue } blob := strings.ToLower(text + " " + h.Title) skip := false for ex := range exclude { if ex != "" && strings.Contains(blob, ex) { skip = true break } } if skip { continue } permalink := strings.TrimSpace(h.URL) if permalink == "" { continue } term := matchingWatchTerm(blob, terms) class := classifyCandidate(blob) out = append(out, &domain.CandidatePost{ ExternalID: permalink, Permalink: permalink, AuthorHandle: authorFromURL(permalink), Text: text, Title: h.Title, PostedAt: now - int64(time.Hour), // unknown recency → treat as ~1h (not hard-reject) MatchedTerm: term, Classification: class, }) } return out, path, credits, nil } func (s *Service) fetchViaProviders(ctx context.Context, ownerUID int64, terms []string, limit int) ([]ThreadHit, string, error) { path := domain.SweepPathAPI devMode := false if s.DevMode != nil { if d, err := s.DevMode.DevModeEnabled(ctx, ownerUID); err == nil { devMode = d } } if devMode { path = domain.SweepPathCrawler if s.CrawlerSession == nil || s.Chrome == nil { return nil, path, fmt.Errorf("crawler path unavailable: session or chrome not configured") } state, err := s.CrawlerSession.CrawlerSessionToken(ctx, ownerUID) if err != nil || state == "" { return nil, path, fmt.Errorf("crawler path unavailable: no browser session") } hits, err := s.Chrome.SearchChrome(ctx, state, terms, limit) return hits, path, err } if s.Search == nil { return nil, path, fmt.Errorf("api path unavailable: search provider not configured") } hits, err := s.Search.SearchThreads(ctx, terms, limit) return hits, path, err } func matchingWatchTerm(text string, terms []string) string { for _, t := range terms { t = strings.TrimSpace(t) if t != "" && strings.Contains(text, strings.ToLower(t)) { return t } } if len(terms) > 0 { return terms[0] } return "" } func authorFromURL(raw string) string { raw = strings.TrimSpace(raw) // https://www.threads.net/@handle/post/... if i := strings.Index(raw, "/@"); i >= 0 { rest := raw[i+2:] if j := strings.IndexAny(rest, "/?"); j >= 0 { return rest[:j] } return rest } return "" } func classifyCandidate(lower string) string { if hasAnySub(lower, "giveaway", "抽獎", "crypto", "賺錢", "互追") { return "noise" } if hasAnySub(lower, "dm me", "私訊我", "服務洽詢", "立即購買", "限時優惠", "業配", "團購") { return "provider_offer" } if hasAnySub(lower, "公告", "開幕", "報名", "活動資訊") { return "announcement" } if hasAnySub(lower, "推薦", "求推", "有沒有推薦", "求推薦") { return "seeking_recommendation" } if strings.Contains(lower, "?") || strings.Contains(lower, "?") || hasAnySub(lower, "怎麼", "如何", "請問", "求助") { return "seeking_help" } return "discussion" } func hasAnySub(text string, signals ...string) bool { for _, s := range signals { if strings.Contains(text, s) { return true } } return false }