thread-master/apps/backend/internal/module/radar/usecase/sweep_fetch.go

226 lines
6.4 KiB
Go
Raw Normal View History

2026-08-03 05:52:02 +00:00
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
}