thread-master/apps/backend/internal/module/scout/usecase/service.go

1404 lines
43 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"
"encoding/json"
"fmt"
"net/url"
"strings"
"time"
"unicode/utf8"
"apps/backend/internal/module/ai"
"apps/backend/internal/module/scout/domain"
studioPublish "apps/backend/internal/module/studio/publish"
threadsDomain "apps/backend/internal/module/threads/domain"
usageDomain "apps/backend/internal/module/usage/domain"
usageUC "apps/backend/internal/module/usage/usecase"
"github.com/google/uuid"
"github.com/zeromicro/go-zero/core/logx"
)
// SettingsReader for dev_mode
type SettingsReader interface {
DevModeEnabled(ctx context.Context, uid int64) (bool, error)
}
type ReplyQueue interface {
QueueExternalReply(ctx context.Context, ownerUID int64, accountID, replyToMediaID, text, title string) (outboxID string, err error)
}
// OutreachPublishedHook fires after mark-published / successful outreach send path.
type OutreachPublishedHook func(ctx context.Context, ownerUID int64, postID, accountID string)
type Service struct {
Repo domain.Repository
Settings SettingsReader
// Transport is retained only for test construction compatibility. Scout never publishes directly.
Transport studioPublish.Transport
AI ai.Client // drafts 不用;話題關鍵字 AI 擴充可走此 fallback
AIRegistry *ai.Registry
ResolveAI func(ctx context.Context, uid int64) (provider, model, apiKey string, err error)
Usage *usageUC.Service // 可空:單元測試不扣點
ReplyQueue ReplyQueue
Provider ThreadSearchProvider
Crawler ChromeCrawlerProvider
SessionSecret string
// OnOutreachPublished optional growth-loop outcome hook.
OnOutreachPublished OutreachPublishedHook
}
func New(repo domain.Repository) *Service {
return &Service{Repo: repo, Provider: newDefaultExaThreadsProvider()}
}
func (s *Service) ListBrands(ctx context.Context, ownerUID int64) ([]*domain.Brand, error) {
return s.Repo.ListBrands(ctx, ownerUID)
}
func (s *Service) GetBrand(ctx context.Context, ownerUID int64, id string) (*domain.Brand, error) {
b, err := s.Repo.GetBrand(ctx, id)
if err != nil {
return nil, err
}
if b.OwnerUID != ownerUID {
return nil, domain.ErrForbidden
}
return b, nil
}
func (s *Service) CreateBrand(ctx context.Context, ownerUID int64, name, brief string) (*domain.Brand, error) {
now := domain.NowNano()
if name == "" {
name = "未命名品牌"
}
b := &domain.Brand{
ID: "br_" + uuid.NewString()[:10], OwnerUID: ownerUID,
DisplayName: name, Brief: brief, CreatedAt: now, UpdatedAt: now,
}
if err := s.Repo.SaveBrand(ctx, b); err != nil {
return nil, err
}
aid, _ := s.Repo.GetActiveBrandID(ctx, ownerUID)
if aid == "" {
_ = s.Repo.SetActiveBrandID(ctx, ownerUID, b.ID)
}
return b, nil
}
func (s *Service) SaveBrand(ctx context.Context, ownerUID int64, b *domain.Brand) (*domain.Brand, error) {
now := domain.NowNano()
if b.ID == "" {
return s.CreateBrand(ctx, ownerUID, b.DisplayName, b.Brief)
}
ex, err := s.GetBrand(ctx, ownerUID, b.ID)
if err != nil {
return nil, err
}
b.OwnerUID = ownerUID
b.CreatedAt = ex.CreatedAt
b.UpdatedAt = now
if err := s.Repo.SaveBrand(ctx, b); err != nil {
return nil, err
}
return b, nil
}
func (s *Service) RemoveBrand(ctx context.Context, ownerUID int64, id string) error {
if _, err := s.GetBrand(ctx, ownerUID, id); err != nil {
return err
}
prods, _ := s.Repo.ListProducts(ctx, ownerUID, id)
if len(prods) > 0 {
return domain.ErrHasProducts
}
if err := s.Repo.DeleteBrand(ctx, id); err != nil {
return err
}
aid, _ := s.Repo.GetActiveBrandID(ctx, ownerUID)
if aid == id {
list, _ := s.Repo.ListBrands(ctx, ownerUID)
next := ""
if len(list) > 0 {
next = list[0].ID
}
_ = s.Repo.SetActiveBrandID(ctx, ownerUID, next)
}
return nil
}
func (s *Service) GetActiveBrandID(ctx context.Context, ownerUID int64) (string, error) {
return s.Repo.GetActiveBrandID(ctx, ownerUID)
}
func (s *Service) SetActiveBrandID(ctx context.Context, ownerUID int64, id string) error {
if id != "" {
if _, err := s.GetBrand(ctx, ownerUID, id); err != nil {
return err
}
}
return s.Repo.SetActiveBrandID(ctx, ownerUID, id)
}
func (s *Service) ListProducts(ctx context.Context, ownerUID int64, brandID string) ([]*domain.Product, error) {
return s.Repo.ListProducts(ctx, ownerUID, brandID)
}
func (s *Service) ListAllProducts(ctx context.Context, ownerUID int64) ([]*domain.Product, error) {
return s.Repo.ListProducts(ctx, ownerUID, "")
}
func (s *Service) GetProduct(ctx context.Context, ownerUID int64, id string) (*domain.Product, error) {
p, err := s.Repo.GetProduct(ctx, id)
if err != nil {
return nil, err
}
if p.OwnerUID != ownerUID {
return nil, domain.ErrForbidden
}
return p, nil
}
func (s *Service) SaveProduct(ctx context.Context, ownerUID int64, p *domain.Product) (*domain.Product, error) {
now := domain.NowNano()
if p.BrandID == "" {
return nil, fmt.Errorf("%w: brand_id required", domain.ErrValidation)
}
if _, err := s.GetBrand(ctx, ownerUID, p.BrandID); err != nil {
return nil, err
}
if p.ID == "" {
p.ID = "prd_" + uuid.NewString()[:10]
p.CreatedAt = now
} else {
ex, err := s.GetProduct(ctx, ownerUID, p.ID)
if err == nil {
p.CreatedAt = ex.CreatedAt
} else if err != domain.ErrNotFound {
return nil, err
} else {
p.CreatedAt = now
}
}
p.OwnerUID = ownerUID
p.UpdatedAt = now
if p.Label == "" {
p.Label = "未命名產品"
}
if err := s.Repo.SaveProduct(ctx, p); err != nil {
return nil, err
}
return p, nil
}
func (s *Service) RemoveProduct(ctx context.Context, ownerUID int64, id string) error {
if _, err := s.GetProduct(ctx, ownerUID, id); err != nil {
return err
}
return s.Repo.DeleteProduct(ctx, id)
}
func (s *Service) ImportProductFromURL(_ context.Context, raw string) (*domain.ImportDraft, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, fmt.Errorf("%w: empty url", domain.ErrValidation)
}
u, err := url.Parse(raw)
if err != nil || (u.Host == "" && !strings.HasPrefix(raw, "http")) {
return nil, fmt.Errorf("%w: bad url", domain.ErrValidation)
}
host := ""
if u != nil {
host = u.Host
}
label := "匯入商品"
if host != "" {
label = host + " 商品"
}
return &domain.ImportDraft{
Label: label,
// 痛點/標籤要用「飼主正在煩惱的話」,不是產品賣點;匯入後請再改成真痛點
ProductContext: "從 " + raw + " 推估的產品情境(請改寫成實際賣點與使用場景)",
PainPoints: []string{"不知道怎麼選", "用了沒感覺", "擔心不適合/有副作用"},
MatchTags: []string{"求推薦", "有沒有人用過", "怎麼辦"},
PlacementURL: raw,
SourceNote: "importProductFromUrl · " + host,
}, nil
}
func (s *Service) PrepareBrief(ctx context.Context, ownerUID int64, intent, brandID, productID, purpose string, deep bool) (*domain.RunBrief, error) {
intent = strings.TrimSpace(intent)
if intent == "" && purpose != "provider" && purpose != "demand" {
return nil, fmt.Errorf("%w: intent required", domain.ErrValidation)
}
// 話題靈感:真正「產」關鍵字(規則變體 + 可選 AI不再只拆使用者原句。
if purpose == "activity" {
return s.prepareActivityBrief(ctx, ownerUID, intent, deep)
}
mode := domain.ModeTheme
brief := &domain.RunBrief{
Intent: intent, Mode: mode, BrandID: brandID, ProductID: productID,
Pains: []string{}, Tags: []string{}, Periphery: []string{}, ScanTerms: []string{},
}
if productID != "" {
p, err := s.GetProduct(ctx, ownerUID, productID)
if err != nil && purpose == "provider" {
return nil, err
}
if err == nil {
mode = domain.ModeProduct
brief.Mode = mode
brief.ProductLabel = p.Label
brief.ProductContext = p.ProductContext
brief.Pains = append([]string(nil), p.PainPoints...)
brief.Tags = append([]string(nil), p.MatchTags...)
brief.BrandID = p.BrandID
brief.PlacementNote = "軟性經驗分享,避免硬廣"
}
}
if purpose == "provider" {
if productID == "" {
return nil, fmt.Errorf("%w: product required for provider matching", domain.ErrValidation)
}
p, err := s.GetProduct(ctx, ownerUID, productID)
if err != nil {
return nil, err
}
pains, capabilities, excludes := providerMatchingFields(p)
brief.Mode = domain.ModeProvider
brief.BrandID = p.BrandID
brief.ProductLabel = p.Label
brief.ProductContext = p.ProductContext
brief.Pains = pains
brief.Tags = capabilities
brief.Periphery = excludes
brief.Intent = p.Label
brief.ResponseStance = "找可驗證的解法提供者,不推產品"
brief.ScanTerms = planScanTerms(brief)
brief.ThemeLabel = truncate(p.Label+" 解法媒合", 36)
brief.ThemeKey = brief.Mode + "|" + productID
return brief, nil
}
if purpose == "demand" {
if productID == "" {
return nil, fmt.Errorf("%w: product required for demand matching", domain.ErrValidation)
}
p, err := s.GetProduct(ctx, ownerUID, productID)
if err != nil {
return nil, err
}
pains, excludes := demandMatchingFields(p)
brief.Mode = domain.ModeDemand
brief.BrandID = p.BrandID
brief.ProductLabel = p.Label
brief.ProductContext = p.ProductContext
brief.Pains = pains
brief.Periphery = excludes
brief.Intent = p.Label
brief.ResponseStance = "找正在求助的需求貼文,再決定如何回應"
brief.ScanTerms = planScanTerms(brief)
brief.ThemeLabel = truncate(p.Label+" 需求痛點", 36)
brief.ThemeKey = brief.Mode + "|" + productID
return brief, nil
}
if len(brief.Pains) == 0 {
// 預設用「求助/求推」語感,避免把產品賣點當搜尋詞
brief.Pains = []string{intent}
}
if len(brief.Tags) == 0 {
brief.Tags = tokenize(intent)
}
// periphery 只當作業備註,不進 scan_terms否則會污染 Exa 查詢)
brief.Periphery = []string{"使用情境", "替代方案", "成分/規格"}
brief.ScanTerms = planScanTerms(brief)
brief.ThemeLabel = truncate(intent, 36)
brief.ThemeKey = mode + "|" + productID + "|" + truncate(intent, 48)
brief.ResponseStance = "先共鳴再給建議"
return brief, nil
}
func (s *Service) prepareActivityBrief(ctx context.Context, ownerUID int64, intent string, deep bool) (*domain.RunBrief, error) {
ruleTerms := planActivityTerms(intent)
// 預設就試 AI 產詞deep 預留給前端「再想一輪」等同義,不額外收費路徑分叉。
_ = deep
aiTerms, err := s.suggestActivityTermsAI(ctx, ownerUID, intent, 8)
if err != nil {
// AI 不可用只降級規則,不擋話題工坊。
logx.Infof("scout activity terms AI unavailable uid=%d: %v", ownerUID, err)
}
terms := mergeActivityTerms(aiTerms, ruleTerms, 8)
if len(terms) == 0 {
// 最後防線:至少給可搜的核或原句截斷
if c := compactCore(intent); c != "" {
terms = []string{c}
} else {
terms = []string{truncateRunes(intent, 4)}
}
}
return &domain.RunBrief{
Intent: intent,
Mode: domain.ModeActivity,
Pains: []string{intent},
Tags: extractTopicCores(intent),
Periphery: []string{},
ScanTerms: terms,
ThemeLabel: truncate(intent, 36),
ThemeKey: domain.ModeActivity + "||" + truncate(intent, 48),
ResponseStance: "先共鳴再給建議",
}, nil
}
/*
suggestActivityTermsAI 請模型依意圖產生 Threads 短詞;失敗回 error呼叫端降級規則。
計費:有 Usage 時走 ai_copysource=scout.topic.suggest無 Usage測試不扣點。
*/
func (s *Service) suggestActivityTermsAI(ctx context.Context, ownerUID int64, intent string, limit int) (_ []string, err error) {
if s == nil || (s.ResolveAI == nil && s.AI == nil) {
return nil, fmt.Errorf("ai not configured")
}
if limit <= 0 {
limit = 8
}
// 預留點數Usage 可空)
var charged bool
var mode string
if s.Usage != nil {
m, berr := s.Usage.PrepareCall(ctx, ownerUID, usageDomain.MeterAICopy)
if berr != nil {
return nil, berr
}
mode = m
charged = true
defer func() {
if !charged {
return
}
if err != nil {
_ = s.Usage.ReleaseCall(ctx, ownerUID, usageDomain.MeterAICopy, mode)
return
}
if _, rerr := s.Usage.RecordCall(ctx, ownerUID, usageDomain.MeterAICopy, mode, "話題關鍵字建議", "scout.topic.suggest"); rerr != nil {
logx.Errorf("scout topic suggest record uid=%d: %v", ownerUID, rerr)
}
}()
}
raw, cerr := s.completeActivityAI(ctx, ownerUID, activityTermsPrompt(intent, limit))
if cerr != nil {
err = cerr
return nil, err
}
parsed := filterThreadsSearchableTerms(parseActivityAITerms(raw))
if len(parsed) == 0 {
err = fmt.Errorf("ai returned no searchable terms")
return nil, err
}
return capTerms(parsed, limit), nil
}
func (s *Service) completeActivityAI(ctx context.Context, ownerUID int64, prompt string) (string, error) {
if s.ResolveAI != nil && s.AIRegistry != nil {
provider, model, apiKey, rerr := s.ResolveAI(ctx, ownerUID)
if rerr != nil {
return "", rerr
}
if strings.TrimSpace(apiKey) == "" || strings.HasPrefix(strings.ToLower(apiKey), "fake") {
return "", fmt.Errorf("ai key empty")
}
c, cerr := s.AIRegistry.Client(provider)
if cerr != nil {
return "", cerr
}
return c.Complete(ctx, apiKey, model, prompt)
}
// 單元測試路徑:未接 ResolveAI 時才用注入的 AI client
if s.AI != nil {
return s.AI.Complete(ctx, "test-key", "grok-3", prompt)
}
return "", fmt.Errorf("ai not configured")
}
// providerMatchingFields keeps existing products usable: their established match
// tags become matching terms until the more specific provider terms are added.
func providerMatchingFields(p *domain.Product) (pains, capabilities, excludes []string) {
pains = nonEmptyTerms(p.PainPoints)
if len(pains) == 0 {
pains = nonEmptyTerms(p.MatchTags)
}
capabilities = nonEmptyTerms(p.ProviderCapabilityTerms)
if len(capabilities) == 0 {
capabilities = nonEmptyTerms(p.MatchTags)
}
if label := strings.TrimSpace(p.Label); label != "" {
if len(pains) == 0 {
pains = []string{label}
}
if len(capabilities) == 0 {
capabilities = []string{label}
}
}
excludes = nonEmptyTerms(p.ProviderExcludeTerms)
if label := strings.TrimSpace(p.Label); label != "" && !containsTerm(pains, label) && !containsTerm(capabilities, label) {
excludes = dedupeTerms(excludes, []string{label})
}
return pains, capabilities, excludes
}
func containsTerm(terms []string, want string) bool {
for _, term := range terms {
if strings.EqualFold(strings.TrimSpace(term), want) {
return true
}
}
return false
}
func demandMatchingFields(p *domain.Product) (pains, excludes []string) {
pains = nonEmptyTerms(p.PainPoints)
if len(pains) == 0 {
pains = nonEmptyTerms(p.MatchTags)
}
if len(pains) == 0 && strings.TrimSpace(p.Label) != "" {
pains = []string{strings.TrimSpace(p.Label)}
}
excludes = nonEmptyTerms(p.ProviderExcludeTerms)
return pains, excludes
}
// SearchHitsOnly runs the dual-path Threads search without persisting Scout posts.
// Radar reuses this so the crawl split (api vs crawler via dev_mode) stays one code path (RG-01).
//
// Terms are searched one-by-one (fan-out) then deduped by canonical permalink — joining all
// terms into a single query dilutes Threads/Exa recall (daily radar sweep bug).
func (s *Service) SearchHitsOnly(ctx context.Context, ownerUID int64, terms []string, limit int) (hits []ThreadSearchResult, path string, err error) {
terms = nonEmptyTerms(terms)
if len(terms) == 0 {
return nil, "", fmt.Errorf("%w: need search terms", domain.ErrValidation)
}
if limit <= 0 {
limit = 10
}
if limit > 40 {
limit = 40
}
// per-query budget mirrors RunScanFromBrief / fanOutSearch.
perQuery := 8
if len(terms) == 1 {
perQuery = 20
} else if len(terms) >= 6 {
perQuery = 5
}
if perQuery > limit {
perQuery = limit
}
path = domain.PathAPI
devMode := false
if s.Settings != nil {
if d, derr := s.Settings.DevModeEnabled(ctx, ownerUID); derr == nil {
devMode = d
}
}
if devMode {
storageState, serr := s.GetCrawlerSessionToken(ctx, ownerUID)
if serr != nil {
return nil, domain.PathCrawler, domain.ErrNoCrawlerSession
}
path = domain.PathCrawler
if s.Crawler == nil {
return nil, path, fmt.Errorf("Chrome crawler is not configured")
}
hits, err = fanOutSearch(ctx, terms, perQuery, func(ctx context.Context, q string, n int) ([]ThreadSearchResult, error) {
return s.Crawler.SearchChrome(ctx, storageState, []string{q}, n)
})
if err != nil {
return nil, path, err
}
return capHits(hits, limit), path, nil
}
if s.Provider == nil {
return nil, path, fmt.Errorf("scout search provider is not configured")
}
hits, err = fanOutSearch(ctx, terms, perQuery, func(ctx context.Context, q string, n int) ([]ThreadSearchResult, error) {
return s.Provider.SearchThreads(ctx, []string{q}, n)
})
if err != nil {
return nil, path, err
}
return capHits(hits, limit), path, nil
}
func capHits(hits []ThreadSearchResult, limit int) []ThreadSearchResult {
if limit > 0 && len(hits) > limit {
return hits[:limit]
}
return hits
}
func (s *Service) RunScanFromBrief(ctx context.Context, ownerUID int64, brief *domain.RunBrief) ([]*domain.Post, error) {
if brief == nil {
return nil, fmt.Errorf("%w: nil brief", domain.ErrValidation)
}
terms := nonEmptyTerms(brief.ScanTerms)
if len(terms) == 0 {
return nil, fmt.Errorf("%w: need scan_terms", domain.ErrValidation)
}
if brief.Mode == domain.ModeProvider {
p, err := s.GetProduct(ctx, ownerUID, brief.ProductID)
if err != nil {
return nil, err
}
pains, capabilities, excludes := providerMatchingFields(p)
brief.BrandID = p.BrandID
brief.ProductLabel = p.Label
brief.Pains = pains
brief.Tags = capabilities
brief.Periphery = excludes
terms = filterProviderScanTerms(terms, p)
if len(terms) == 0 {
return nil, fmt.Errorf("%w: provider scan terms cannot be product/category exclusions", domain.ErrValidation)
}
}
if brief.Mode == domain.ModeDemand {
p, err := s.GetProduct(ctx, ownerUID, brief.ProductID)
if err != nil {
return nil, err
}
brief.BrandID = p.BrandID
brief.ProductLabel = p.Label
brief.Pains, brief.Periphery = demandMatchingFields(p)
}
path := domain.PathAPI
devMode := false
if s.Settings != nil {
if d, err := s.Settings.DevModeEnabled(ctx, ownerUID); err == nil {
devMode = d
}
}
// 每條關鍵字獨立搜尋再合併(使用者已審過的 scan_terms
perQuery := 8
if len(terms) == 1 {
perQuery = 20
} else if len(terms) >= 6 {
perQuery = 5
}
target := brief.TargetCount
if target < 0 {
target = 0
}
if target > 40 {
target = 40
}
// 有目標時略抬高 per-query減少第一輪就差很多則。
if target > 0 {
needPerTerm := (target + len(terms) - 1) / len(terms)
if needPerTerm > perQuery {
perQuery = needPerTerm
}
if perQuery > 20 {
perQuery = 20
}
}
var hits []ThreadSearchResult
var err error
var storageState string
if devMode {
storageState, err = s.GetCrawlerSessionToken(ctx, ownerUID)
if err != nil {
return nil, domain.ErrNoCrawlerSession
}
path = domain.PathCrawler
if s.Crawler == nil {
return nil, fmt.Errorf("Chrome crawler is not configured")
}
hits, err = fanOutSearch(ctx, terms, perQuery, func(ctx context.Context, q string, limit int) ([]ThreadSearchResult, error) {
return s.Crawler.SearchChrome(ctx, storageState, []string{q}, limit)
})
} else {
if s.Provider == nil {
return nil, fmt.Errorf("scout search provider is not configured")
}
hits, err = fanOutSearch(ctx, terms, perQuery, func(ctx context.Context, q string, limit int) ([]ThreadSearchResult, error) {
return s.Provider.SearchThreads(ctx, []string{q}, limit)
})
}
if err != nil {
return nil, err
}
// 今日目標補抓:主路徑不足 → 同路徑加碼 → 次路徑crawler 不足時用 search/Exa只補缺口。
if target > 0 && len(hits) < target {
hits = fillSearchHitsToTarget(ctx, s, terms, hits, target, path, storageState)
}
if target > 0 {
hits = capHits(hits, target)
}
return s.persistSearchHits(ctx, ownerUID, brief, path, hits)
}
// fillSearchHitsToTarget tops up hits when the first fan-out misses the daily goal.
// 1) Same path with higher per-query 2) If primary is crawler, secondary is search Provider.
func fillSearchHitsToTarget(
ctx context.Context,
s *Service,
terms []string,
hits []ThreadSearchResult,
target int,
primaryPath string,
crawlerState string,
) []ThreadSearchResult {
if target <= 0 || len(hits) >= target {
return hits
}
// Stage A: same path, boost per-query (cap 20).
boost := 20
need := target - len(hits)
if need < boost {
// still request up to boost so sparse terms can contribute
_ = need
}
var more []ThreadSearchResult
var err error
if primaryPath == domain.PathCrawler && s.Crawler != nil && crawlerState != "" {
more, err = fanOutSearch(ctx, terms, boost, func(ctx context.Context, q string, limit int) ([]ThreadSearchResult, error) {
return s.Crawler.SearchChrome(ctx, crawlerState, []string{q}, limit)
})
} else if s.Provider != nil {
more, err = fanOutSearch(ctx, terms, boost, func(ctx context.Context, q string, limit int) ([]ThreadSearchResult, error) {
return s.Provider.SearchThreads(ctx, []string{q}, limit)
})
}
if err == nil && len(more) > 0 {
hits = mergeHitsDedupe(hits, more)
}
if len(hits) >= target {
return hits
}
// Stage B: crawler primary → top up with search provider (Exa / Threads-domain search).
if primaryPath == domain.PathCrawler && s.Provider != nil {
topup, terr := fanOutSearch(ctx, terms, boost, func(ctx context.Context, q string, limit int) ([]ThreadSearchResult, error) {
return s.Provider.SearchThreads(ctx, []string{q}, limit)
})
if terr == nil && len(topup) > 0 {
hits = mergeHitsDedupe(hits, topup)
}
}
return hits
}
func mergeHitsDedupe(base, extra []ThreadSearchResult) []ThreadSearchResult {
seen := make(map[string]struct{}, len(base)+len(extra))
out := make([]ThreadSearchResult, 0, len(base)+len(extra))
for _, h := range base {
key := canonicalPermalink(h.URL)
if key == "" {
key = strings.TrimSpace(h.URL)
}
if key == "" {
continue
}
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, h)
}
for _, h := range extra {
key := canonicalPermalink(h.URL)
if key == "" {
key = strings.TrimSpace(h.URL)
}
if key == "" {
continue
}
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, h)
}
return out
}
func filterProviderScanTerms(terms []string, p *domain.Product) []string {
var out []string
for _, term := range terms {
term = strings.TrimSpace(term)
if term == "" || strings.EqualFold(term, p.Label) || hasMatchingTerm(strings.ToLower(term), p.ProviderExcludeTerms) {
continue
}
out = append(out, term)
}
return dedupeTerms(out)
}
// fanOutSearch runs one search per query term and dedupes by canonical permalink.
func fanOutSearch(ctx context.Context, terms []string, perQuery int, search func(context.Context, string, int) ([]ThreadSearchResult, error)) ([]ThreadSearchResult, error) {
if perQuery < 1 {
perQuery = 5
}
seen := make(map[string]struct{})
out := make([]ThreadSearchResult, 0, len(terms)*perQuery)
var firstErr error
for _, term := range terms {
hits, err := search(ctx, term, perQuery)
if err != nil {
if firstErr == nil {
firstErr = err
}
continue
}
for _, hit := range hits {
key := canonicalPermalink(hit.URL)
if key == "" {
continue
}
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
hit.URL = key
// 記住是哪條 query 命中,方便 search_tag
if hit.MatchedQuery == "" {
hit.MatchedQuery = term
}
out = append(out, hit)
}
}
if len(out) == 0 && firstErr != nil {
return nil, firstErr
}
return out, nil
}
func (s *Service) persistSearchHits(ctx context.Context, ownerUID int64, brief *domain.RunBrief, path string, hits []ThreadSearchResult) ([]*domain.Post, error) {
now := domain.NowNano()
// 保真crawler 已依 Recent 主序回傳,不再 both-first 重排蓋掉平台相關性。
// 無 track 的 Exa 結果仍依發文時間新→舊。
if !hitsHaveTrack(hits) {
sortHitsByPostedAt(hits)
}
out := make([]*domain.Post, 0, len(hits))
for i, hit := range hits {
text := strings.TrimSpace(hit.Snippet)
if text == "" {
continue
}
permalink := canonicalPermalink(hit.URL)
if permalink == "" {
continue
}
postedAt := hit.PublishedAt
// 硬擋僅極舊180 天);測試 stub 時間戳不套用
if postedAt > 0 && isStalePublished(postedAt, defaultScoutHardMaxAgeDays) {
continue
}
term := strings.TrimSpace(hit.MatchedQuery)
// 相關性硬閘:所有來源的正文都必須含查詢主題核,避免 provider
// 回傳的語意擴展/空 SERP 雜訊混進話題佇列。
matchedTerm := matchingSearchTerm(text+" "+hit.Title, brief.ScanTerms)
if matchedTerm == "" {
continue
}
// fan-out records the query that returned a hit. Prefer the term actually
// found in its text so a result from a different query is not mislabeled.
if term == "" || !textMatchesSearchTerm(text+" "+hit.Title, term) {
term = matchedTerm
}
classified := classifyPost(brief.Mode, text+" "+hit.Title, brief.ScanTerms)
if brief.Mode == domain.ModeProvider {
classified = classifyProvider(text+" "+hit.Title, brief.Pains, brief.Tags, brief.Periphery)
}
if brief.Mode == domain.ModeDemand {
classified = classifyDemand(text+" "+hit.Title, brief.Pains, brief.Periphery)
}
if classified.classification == domain.ClassificationNoise {
continue
}
// 痛點回覆producttheme要找「有困擾的人」略過同業硬廣服務洽詢
if (brief.Mode == domain.ModeProduct || brief.Mode == domain.ModeTheme) &&
classified.classification == domain.ClassificationProviderOffer {
continue
}
if brief.Mode == domain.ModeProvider && (classified.classification != domain.ClassificationProviderDirect && classified.classification != domain.ClassificationProviderRecommended) {
continue
}
score := classified.score
reason := classified.reason
// track 只標記輕加分不重排both 表示熱門+最新都有,可信度稍高)
switch hit.Track {
case "both":
score = minInt(100, score+6)
reason = reason + "; track: both"
case "recent":
score = minInt(100, score+3)
reason = reason + "; track: recent"
case "top":
reason = reason + "; track: top"
}
if hit.SerpRank > 0 {
reason = reason + fmt.Sprintf("; serp_rank: %d", hit.SerpRank)
}
// 軟時效:>45 天降權並標註,不刪(避免「準的稍舊文」消失)
if postedAt > 0 && isSoftAged(postedAt, defaultScoutSoftAgeDays) {
score = maxInt(1, score-12)
reason = reason + "; soft_aged"
}
// created_at保 crawler 回傳序i 越小越前);有發文時間仍寫 PostedAt 供 UI
createdAt := now - int64(i)*1000
if postedAt > 0 && !hitsHaveTrack(hits) {
// 非 crawler 路徑仍用發文時間當 created 序
createdAt = postedAt
}
p := &domain.Post{
ID: permalinkID(ownerUID, permalink), ExternalID: permalink, Permalink: permalink,
OwnerUID: ownerUID, BrandID: brief.BrandID, Author: authorFromThreadsURL(permalink), Text: text,
SearchTag: term, Opportunity: "", OutreachStatus: domain.OutreachNew,
Score: score, Classification: classified.classification, MatchedProductID: brief.ProductID, MatchedProductLabel: brief.ProductLabel,
MatchReason: reason, ScoutMode: brief.Mode, IntentSnippet: brief.Intent,
ThemeKey: brief.ThemeKey, ThemeLabel: brief.ThemeLabel, ScanPath: path,
PostedAt: postedAt, CreatedAt: createdAt,
}
if err := s.Repo.SavePost(ctx, p); err != nil {
return nil, err
}
out = append(out, p)
}
// 話題優先顯示近期討論動能track問答訊號已計入分數再以發文時間決勝。
// 其他模式維持既有的時間排序,避免改變商機/解法媒合的行為。
if brief.Mode == domain.ModeActivity {
sortActivityPostsByMomentum(out)
} else if !hitsHaveTrack(hits) {
sortPostsByPostedAt(out)
}
return out, nil
}
func hitsHaveTrack(hits []ThreadSearchResult) bool {
for _, h := range hits {
if h.Track != "" {
return true
}
}
return false
}
// searchAnchors 是產詞常用的口語後綴,不能單獨當「主題相關」依據。
var searchAnchors = map[string]bool{
"求推薦": true, "推薦": true, "分享": true, "心得": true,
"活動": true, "怎麼辦": true, "詢問": true, "討論": true,
"有人知道": true, "請問": true,
}
// textMatchesSearchTerm正文須含查詢的主題核非口語錨點
// 「外包 求推薦」→ 必須含「外包」;純「求推薦」才允許只命中錨點。
func textMatchesSearchTerm(text, term string) bool {
body := normalizeSignal(text)
if body == "" || strings.TrimSpace(term) == "" {
return false
}
tokens := strings.Fields(strings.TrimSpace(term))
if len(tokens) == 0 {
tokens = []string{strings.TrimSpace(term)}
}
var content, anchors []string
for _, tok := range tokens {
t := strings.ToLower(strings.TrimSpace(tok))
if t == "" || utf8.RuneCountInString(t) < 2 {
continue
}
if searchAnchors[t] {
anchors = append(anchors, t)
} else {
content = append(content, strings.ReplaceAll(t, " ", ""))
}
}
// 有主題核:任一主題核命中即可(不要求錨點)
if len(content) > 0 {
for _, c := range content {
if strings.Contains(body, c) {
return true
}
}
return false
}
// 只有錨點:放寬(使用者刻意只搜「求推薦」)
for _, a := range anchors {
if strings.Contains(body, strings.ReplaceAll(a, " ", "")) {
return true
}
}
// 無可用 token不誤殺
return true
}
func matchingSearchTerm(text string, terms []string) string {
for _, term := range terms {
if term = strings.TrimSpace(term); term != "" && textMatchesSearchTerm(text, term) {
return term
}
}
return ""
}
func maxInt(a, b int) int {
if a > b {
return a
}
return b
}
func minInt(a, b int) int {
if a < b {
return a
}
return b
}
func sortHitsByTrackAndPostedAt(hits []ThreadSearchResult) {
trackRank := func(t string) int {
switch t {
case "both":
return 0
case "recent":
return 1
case "top":
return 2
default:
return 3
}
}
for i := 0; i < len(hits); i++ {
for j := i + 1; j < len(hits); j++ {
ri, rj := trackRank(hits[i].Track), trackRank(hits[j].Track)
if rj < ri {
hits[i], hits[j] = hits[j], hits[i]
continue
}
if rj > ri {
continue
}
ai, aj := hits[i].PublishedAt, hits[j].PublishedAt
if ai == 0 && aj == 0 {
continue
}
if ai == 0 || (aj > 0 && aj > ai) {
hits[i], hits[j] = hits[j], hits[i]
}
}
}
}
// sortPostsByTrackAndPostedAt 保留相容:目前先 track 再時間在 sortHits 已做;此函式 no-op 佔位避免誤用。
func sortPostsByTrackAndPostedAt(_ []*domain.Post, _ []ThreadSearchResult) {}
func sortHitsByPostedAt(hits []ThreadSearchResult) {
// newest first; unknown published time last
for i := 0; i < len(hits); i++ {
for j := i + 1; j < len(hits); j++ {
ai, aj := hits[i].PublishedAt, hits[j].PublishedAt
if ai == 0 && aj == 0 {
continue
}
if ai == 0 || (aj > 0 && aj > ai) {
hits[i], hits[j] = hits[j], hits[i]
}
}
}
}
func sortPostsByPostedAt(posts []*domain.Post) {
for i := 0; i < len(posts); i++ {
for j := i + 1; j < len(posts); j++ {
ai := posts[i].PostedAt
if ai == 0 {
ai = posts[i].CreatedAt
}
aj := posts[j].PostedAt
if aj == 0 {
aj = posts[j].CreatedAt
}
if aj > ai {
posts[i], posts[j] = posts[j], posts[i]
}
}
}
}
func sortActivityPostsByMomentum(posts []*domain.Post) {
for i := 0; i < len(posts); i++ {
for j := i + 1; j < len(posts); j++ {
if posts[j].Score > posts[i].Score ||
(posts[j].Score == posts[i].Score && postTime(posts[j]) > postTime(posts[i])) {
posts[i], posts[j] = posts[j], posts[i]
}
}
}
}
func postTime(post *domain.Post) int64 {
if post.PostedAt > 0 {
return post.PostedAt
}
return post.CreatedAt
}
func matchingTerm(text string, terms []string) string {
for _, term := range terms {
if term = strings.TrimSpace(term); term != "" && strings.Contains(strings.ToLower(text), strings.ToLower(term)) {
return term
}
}
return ""
}
func authorFromThreadsURL(raw string) string {
u, err := url.Parse(raw)
if err != nil {
return ""
}
for _, segment := range strings.Split(u.Path, "/") {
segment = strings.TrimPrefix(strings.TrimSpace(segment), "@")
if segment != "" {
return segment
}
}
return ""
}
func (s *Service) ListPosts(ctx context.Context, ownerUID int64, brandID string) ([]*domain.Post, error) {
return s.Repo.ListPosts(ctx, ownerUID, brandID)
}
func (s *Service) DraftOutreach(ctx context.Context, ownerUID int64, postID, personaID string) (*domain.Post, error) {
_ = personaID
p, err := s.getPostOwned(ctx, ownerUID, postID)
if err != nil {
return nil, err
}
if p.ScoutMode == domain.ModeProvider {
return nil, fmt.Errorf("%w: provider matches do not support outreach drafts", domain.ErrValidation)
}
draft := "嗨 @" + p.Author + ",看到你提到「" + p.SearchTag + "」,我也遇過類似情況。若你願意,想聽聽你後來怎麼處理。"
if p.ScoutMode == domain.ModeActivity {
draft = "嗨 @" + p.Author + ",這個「" + p.SearchTag + "」很有意思。你自己最在意哪一部分?"
}
p.DraftText = draft
p.OutreachStatus = domain.OutreachDrafted
if err := s.Repo.SavePost(ctx, p); err != nil {
return nil, err
}
return p, nil
}
func (s *Service) SkipOutreach(ctx context.Context, ownerUID int64, postID string) (*domain.Post, error) {
p, err := s.getPostOwned(ctx, ownerUID, postID)
if err != nil {
return nil, err
}
p.OutreachStatus = domain.OutreachSkipped
if err := s.Repo.SavePost(ctx, p); err != nil {
return nil, err
}
return p, nil
}
func (s *Service) MarkPublished(ctx context.Context, ownerUID int64, postID string) (*domain.Post, error) {
p, err := s.getPostOwned(ctx, ownerUID, postID)
if err != nil {
return nil, err
}
if p.ScoutMode == domain.ModeProvider {
return nil, fmt.Errorf("%w: provider matches cannot be marked as outreach", domain.ErrValidation)
}
p.OutreachStatus = domain.OutreachPublished
if err := s.Repo.SavePost(ctx, p); err != nil {
return nil, err
}
// growth-loop: brand learning bump when brand known
if p.BrandID != "" {
if b, berr := s.Repo.GetBrand(ctx, p.BrandID); berr == nil && b != nil && b.OwnerUID == ownerUID {
b.LearningVersion++
b.LearnedFromPostsCount++
b.LastLearnedAt = domain.NowNano()
b.LearningSummary = fmt.Sprintf("海巡外展累積 %d 則 · 知識 v%d", b.LearnedFromPostsCount, b.LearningVersion)
b.UpdatedAt = domain.NowNano()
_ = s.Repo.SaveBrand(ctx, b)
}
}
if s.OnOutreachPublished != nil {
s.OnOutreachPublished(ctx, ownerUID, postID, "")
}
return p, nil
}
func (s *Service) SendOutreach(ctx context.Context, ownerUID int64, postID, text, accountID string) (*domain.Post, error) {
p, err := s.getPostOwned(ctx, ownerUID, postID)
if err != nil {
return nil, err
}
if p.ScoutMode == domain.ModeProvider {
return nil, fmt.Errorf("%w: provider matches do not support outreach", domain.ErrValidation)
}
text = strings.TrimSpace(text)
if text == "" {
text = p.DraftText
}
if text == "" {
return nil, fmt.Errorf("%w: empty outreach text", domain.ErrValidation)
}
if s.ReplyQueue == nil {
return nil, fmt.Errorf("Scout reply Outbox is not configured")
}
mediaID := p.ExternalID
// A permalink can be resolved to a real Threads media ID before the official
// API sends. This also refreshes any legacy IDs that were not Graph media IDs.
if strings.TrimSpace(p.Permalink) != "" && s.Crawler != nil {
state, err := s.GetCrawlerSessionToken(ctx, ownerUID)
if err != nil {
return nil, domain.ErrNoCrawlerSession
}
mediaID, err = s.Crawler.ResolveMediaID(ctx, state, p.Permalink)
if err != nil {
return nil, fmt.Errorf("%w: unable to resolve the target Threads post: %v", domain.ErrValidation, err)
}
}
if !isNumericMediaID(mediaID) {
if s.Crawler == nil {
return nil, fmt.Errorf("%w: target Threads media ID is not resolved; configure Chrome crawler", domain.ErrValidation)
}
state, err := s.GetCrawlerSessionToken(ctx, ownerUID)
if err != nil {
return nil, domain.ErrNoCrawlerSession
}
mediaID, err = s.Crawler.ResolveMediaID(ctx, state, p.Permalink)
if err != nil {
return nil, fmt.Errorf("%w: unable to resolve the target Threads post: %v", domain.ErrValidation, err)
}
}
p.ExternalID = mediaID
outboxID, err := s.ReplyQueue.QueueExternalReply(ctx, ownerUID, accountID, mediaID, text, "Scout 回覆 · "+truncate(p.Author, 30))
if err != nil {
return nil, err
}
p.DraftText = text
p.OutboxID = outboxID
p.OutreachStatus = domain.OutreachQueued
if err := s.Repo.SavePost(ctx, p); err != nil {
return nil, err
}
return p, nil
}
/*
ResolveMediaID turns a Threads permalink into a numeric Graph media ID via the
configured Chrome crawler. Already-numeric input is returned unchanged.
Exported so other modules (radar 商機回覆一鍵送出) can reuse the same resolver
instead of re-implementing crawler session handling.
*/
func (s *Service) ResolveMediaID(ctx context.Context, ownerUID int64, permalink string) (string, error) {
if isNumericMediaID(permalink) {
return permalink, nil
}
if s.Crawler == nil {
return "", fmt.Errorf("%w: target Threads media ID is not resolved; configure Chrome crawler", domain.ErrValidation)
}
state, err := s.GetCrawlerSessionToken(ctx, ownerUID)
if err != nil {
return "", domain.ErrNoCrawlerSession
}
mediaID, err := s.Crawler.ResolveMediaID(ctx, state, permalink)
if err != nil {
return "", fmt.Errorf("%w: unable to resolve the target Threads post: %v", domain.ErrValidation, err)
}
return mediaID, nil
}
func isNumericMediaID(value string) bool {
if value == "" {
return false
}
for _, ch := range value {
if ch < '0' || ch > '9' {
return false
}
}
return true
}
func (s *Service) RemovePost(ctx context.Context, ownerUID int64, postID string) error {
if _, err := s.getPostOwned(ctx, ownerUID, postID); err != nil {
return err
}
return s.Repo.DeletePost(ctx, postID)
}
func (s *Service) RemoveTheme(ctx context.Context, ownerUID int64, themeKey string) error {
if err := s.Repo.DeletePostsByTheme(ctx, ownerUID, themeKey); err != nil {
return err
}
// A patrol batch owns both its hits and its research snapshot.
if err := s.Repo.DeleteHomework(ctx, ownerUID, themeKey); err != nil && err != domain.ErrNotFound {
return err
}
return nil
}
func (s *Service) ListHomework(ctx context.Context, ownerUID int64) ([]*domain.Homework, error) {
return s.Repo.ListHomework(ctx, ownerUID)
}
func (s *Service) SaveHomework(ctx context.Context, ownerUID int64, h *domain.Homework) (*domain.Homework, error) {
if h.ThemeKey == "" {
return nil, fmt.Errorf("%w: theme_key required", domain.ErrValidation)
}
h.OwnerUID = ownerUID
if h.CreatedAt == 0 {
h.CreatedAt = domain.NowNano()
}
if err := s.Repo.SaveHomework(ctx, h); err != nil {
return nil, err
}
return h, nil
}
func (s *Service) GetHomework(ctx context.Context, ownerUID int64, themeKey string) (*domain.Homework, error) {
return s.Repo.GetHomework(ctx, ownerUID, themeKey)
}
func (s *Service) RemoveHomework(ctx context.Context, ownerUID int64, themeKey string) error {
return s.Repo.DeleteHomework(ctx, ownerUID, themeKey)
}
func (s *Service) SetCrawlerSession(ctx context.Context, ownerUID int64, storageState string) error {
expiresAt, err := validateStorageState(storageState)
if err != nil {
return err
}
if s.SessionSecret == "" {
return fmt.Errorf("%w: crawler session secret is required", domain.ErrValidation)
}
storageStateEnc, err := threadsDomain.Seal(s.SessionSecret, storageState)
if err != nil {
return err
}
now := domain.NowNano()
return s.Repo.SetCrawlerSession(ctx, &domain.CrawlerSession{
OwnerUID: ownerUID, StorageStateEnc: storageStateEnc, UpdatedAt: now, ExpiresAt: expiresAt,
})
}
// GetCrawlerSessionToken returns decrypted Playwright storageState JSON for browser crawl.
func (s *Service) GetCrawlerSessionToken(ctx context.Context, ownerUID int64) (string, error) {
sess, err := s.Repo.GetCrawlerSession(ctx, ownerUID)
if err != nil {
return "", err
}
if sess == nil || sess.StorageStateEnc == "" || (sess.ExpiresAt > 0 && sess.ExpiresAt <= domain.NowNano()) {
return "", domain.ErrNoCrawlerSession
}
if s.SessionSecret == "" {
return "", fmt.Errorf("%w: crawler session secret is required", domain.ErrValidation)
}
storageState, err := threadsDomain.Open(s.SessionSecret, sess.StorageStateEnc)
if err != nil {
return "", err
}
return storageState, nil
}
type storageStateCookie struct {
Domain string `json:"domain"`
Expires float64 `json:"expires"`
}
func validateStorageState(storageState string) (int64, error) {
if len(storageState) > 256*1024 {
return 0, fmt.Errorf("%w: storage state exceeds 256KB", domain.ErrValidation)
}
var state map[string]json.RawMessage
if err := json.Unmarshal([]byte(storageState), &state); err != nil || state == nil {
return 0, fmt.Errorf("%w: storage state must be a JSON object", domain.ErrValidation)
}
rawCookies, ok := state["cookies"]
if !ok {
return 0, fmt.Errorf("%w: cookies required", domain.ErrValidation)
}
var cookies []storageStateCookie
if err := json.Unmarshal(rawCookies, &cookies); err != nil || len(cookies) == 0 {
return 0, fmt.Errorf("%w: cookies must be a nonempty array", domain.ErrValidation)
}
var expiresAt int64
for _, cookie := range cookies {
domainName := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(cookie.Domain)), ".")
if !allowedCookieDomain(domainName) {
return 0, fmt.Errorf("%w: cookie domain %q is not allowed", domain.ErrValidation, cookie.Domain)
}
if cookie.Expires > 0 {
expires := int64(cookie.Expires * float64(time.Second))
if expiresAt == 0 || expires < expiresAt {
expiresAt = expires
}
}
}
return expiresAt, nil
}
func allowedCookieDomain(domainName string) bool {
for _, allowed := range []string{"threads.net", "threads.com", "instagram.com", "facebook.com"} {
if domainName == allowed || strings.HasSuffix(domainName, "."+allowed) {
return true
}
}
return false
}
func (s *Service) ClearCrawlerSession(ctx context.Context, ownerUID int64) error {
return s.Repo.ClearCrawlerSession(ctx, ownerUID)
}
// TopicRemoved — SC: ScoutTopic CRUD not implemented
func (s *Service) TopicRemoved() error { return domain.ErrTopicRemoved }
func (s *Service) getPostOwned(ctx context.Context, ownerUID int64, id string) (*domain.Post, error) {
p, err := s.Repo.GetPost(ctx, id)
if err != nil {
return nil, err
}
if p.OwnerUID != ownerUID {
return nil, domain.ErrForbidden
}
return p, nil
}
// GetPost returns one owned scout post (promote / detail).
func (s *Service) GetPost(ctx context.Context, ownerUID int64, id string) (*domain.Post, error) {
return s.getPostOwned(ctx, ownerUID, id)
}
func tokenize(s string) []string {
parts := strings.FieldsFunc(s, func(r rune) bool {
return r == ' ' || r == '、' || r == ',' || r == '/'
})
if len(parts) == 0 {
return []string{s}
}
if len(parts) > 4 {
parts = parts[:4]
}
return parts
}
func truncate(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
return string(r[:n])
}