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
|
|||
|
|
}
|