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

226 lines
6.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}