226 lines
6.4 KiB
Go
226 lines
6.4 KiB
Go
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
|
||
}
|