package usecase import ( "context" "encoding/json" "fmt" "net/url" "sort" "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) // ProductWatchLifecycle is a narrow bridge to Radar; Scout owns product CRUD, // while Radar owns watch state and historical sweep semantics. type ProductWatchLifecycle interface { PauseProductWatches(ctx context.Context, ownerUID int64, productID string) (int, error) } 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 RadarLifecycle ProductWatchLifecycle } 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 } if s.RadarLifecycle != nil { if _, err := s.RadarLifecycle.PauseProductWatches(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_copy/source=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 { var storageState string var serr error if s.Repo != nil { storageState, serr = s.GetCrawlerSessionToken(ctx, ownerUID) } else { serr = domain.ErrNoCrawlerSession } if serr != nil || strings.TrimSpace(storageState) == "" { if s.Provider == nil { return nil, domain.PathCrawler, domain.ErrNoCrawlerSession } logx.Infof("scout SearchHitsOnly: no crawler session uid=%d; falling back to api", ownerUID) } else if s.Crawler == nil { if s.Provider == nil { return nil, domain.PathCrawler, fmt.Errorf("Chrome crawler is not configured") } logx.Infof("scout SearchHitsOnly: crawler not configured uid=%d; falling back to api", ownerUID) } else { path = domain.PathCrawler 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 { hits = fillSearchHitsToTarget(ctx, s, terms, hits, limit, path, storageState) return capHits(hits, limit), path, nil } if s.Provider == nil { return nil, path, err } logx.Errorf("scout SearchHitsOnly: crawler failed uid=%d: %v; falling back to api", ownerUID, err) } } if s.Provider == nil { return nil, path, fmt.Errorf("scout search provider is not configured") } path = domain.PathAPI 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 } hits = fillSearchHitsToTarget(ctx, s, terms, hits, limit, path, "") 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) { return s.runScanFromBrief(ctx, ownerUID, brief, "") } // RunScanForRun executes the same search pipeline without making results // visible through the legacy posts collection. Results are staged under the // running run; PublishRun opens the visibility barrier after the whole scan. func (s *Service) RunScanForRun(ctx context.Context, ownerUID int64, runID string, brief *domain.RunBrief) ([]*domain.Post, error) { if runID == "" { return nil, domain.ErrValidation } posts, err := s.runScanFromBrief(ctx, ownerUID, brief, runID) if err != nil { return nil, err } if err := s.StageRunPosts(ctx, ownerUID, runID, posts); err != nil { return nil, err } return posts, nil } func (s *Service) runScanFromBrief(ctx context.Context, ownerUID int64, brief *domain.RunBrief, runID string) ([]*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 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") } } else { if s.Provider == nil { return nil, fmt.Errorf("scout search provider is not configured") } } pipeline, err := s.searchEligiblePipeline(ctx, ownerUID, terms, brief, target, path, storageState, perQuery) if err != nil { return nil, err } return s.persistSearchHitsWithRun(ctx, ownerUID, brief, path, runID, pipeline.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(groups ...[]ThreadSearchResult) []ThreadSearchResult { seen := make(map[string]struct{}) var out []ThreadSearchResult for _, group := range groups { for _, hit := range group { permalink := canonicalPermalink(hit.URL) key := canonicalPostIdentity(hit.URL) if key == "" { key = permalink } if key == "" { continue } if _, ok := seen[key]; ok { continue } seen[key] = struct{}{} if permalink != "" { hit.URL = permalink } out = append(out, hit) } } 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 } if isCrawlerSessionDead(err) { break } continue } for _, hit := range hits { permalink := canonicalPermalink(hit.URL) identity := canonicalPostIdentity(hit.URL) if identity == "" || permalink == "" { continue } if _, ok := seen[identity]; ok { continue } seen[identity] = struct{}{} hit.URL = permalink // 記住是哪條 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) { return s.persistSearchHitsWithRun(ctx, ownerUID, brief, path, "", hits) } func (s *Service) persistSearchHitsWithRun(ctx context.Context, ownerUID int64, brief *domain.RunBrief, path, runID string, hits []ThreadSearchResult) ([]*domain.Post, error) { now := domain.NowNano() // Provider 原始順序只用於 deterministic created_at;最終結果由 // evaluator score 排序,不能再被來源的時間/track 順序覆蓋。 hasTrack := hitsHaveTrack(hits) out := make([]*domain.Post, 0, len(hits)) evaluator := NewCandidateEvaluator(brief) evaluator.SetHistoricalSeenLookup(func(identity string) (bool, error) { return s.Repo.HasSeenIdentity(ctx, ownerUID, identity) }) for i, hit := range hits { evaluation := evaluator.EvaluateAt(hit, now) if evaluation.Decision != CandidateEligible { continue } text := evaluation.Text permalink := evaluation.Permalink postedAt := evaluation.PostedAt // created_at:保 crawler 回傳序(i 越小越前);有發文時間仍寫 PostedAt 供 UI createdAt := now - int64(i)*1000 if postedAt > 0 && !hasTrack { // 非 crawler 路徑仍用發文時間當 created 序 createdAt = postedAt } p := &domain.Post{ ID: permalinkID(ownerUID, permalink), ExternalID: permalink, Permalink: permalink, OwnerUID: ownerUID, RunID: runID, BrandID: brief.BrandID, Author: authorFromThreadsURL(permalink), Text: text, SearchTag: evaluation.SearchTag, Opportunity: "", OutreachStatus: domain.OutreachNew, Score: evaluation.Score, Classification: evaluation.Classification, MatchedProductID: brief.ProductID, MatchedProductLabel: brief.ProductLabel, MatchReason: evaluation.Reason, ScoutMode: brief.Mode, IntentSnippet: brief.Intent, ThemeKey: brief.ThemeKey, ThemeLabel: brief.ThemeLabel, ScanPath: path, PostedAt: postedAt, CreatedAt: createdAt, } if runID == "" { if err := s.Repo.SavePost(ctx, p); err != nil { return nil, err } } out = append(out, p) } // Score is the primary quality order. Posted/created time and ID only make // equal scores deterministic; an old but highly relevant post can therefore // still be returned ahead of a newer weak match. sortPostsByScore(out) return out, nil } func sortPostsByScore(posts []*domain.Post) { sort.SliceStable(posts, func(i, j int) bool { a, b := posts[i], posts[j] if a.Score != b.Score { return a.Score > b.Score } // 有發文時間者優先,其次新到舊;ID 收尾保證結果穩定可重現 if (a.PostedAt > 0) != (b.PostedAt > 0) { return a.PostedAt > 0 } if a.PostedAt > 0 && a.PostedAt != b.PostedAt { return a.PostedAt > b.PostedAt } if a.CreatedAt != b.CreatedAt { return a.CreatedAt > b.CreatedAt } return a.ID > b.ID }) } 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, "熱門": 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 false } } return true } // 只有錨點:放寬(使用者刻意只搜「求推薦」) 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 } } sort.SliceStable(hits, func(i, j int) bool { ri, rj := trackRank(hits[i].Track), trackRank(hits[j].Track) if ri != rj { return ri < rj } // 同軌內新到舊;沒有發布時間的排最後 ai, aj := hits[i].PublishedAt, hits[j].PublishedAt if ai == 0 || aj == 0 { return ai != 0 } return ai > aj }) } 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) { posts, err := s.Repo.ListPosts(ctx, ownerUID, brandID) if err != nil { return nil, err } return dedupePostsByIdentity(posts), nil } // dedupePostsByIdentity hides legacy duplicates that were stored before IDs // were based on Threads post shortcodes. It is read-only: no user history is deleted. func dedupePostsByIdentity(posts []*domain.Post) []*domain.Post { seen := make(map[string]int, len(posts)) out := make([]*domain.Post, 0, len(posts)) for _, post := range posts { if post == nil { continue } key := canonicalPostIdentity(post.Permalink) if key == "" { key = canonicalPostIdentity(post.ExternalID) } if key == "" { key = "id:" + post.ID } if idx, ok := seen[key]; ok { if preferScoutPost(post, out[idx]) { out[idx] = post } continue } seen[key] = len(out) out = append(out, post) } sortPostsByScore(out) return out } func preferScoutPost(candidate, current *domain.Post) bool { statusRank := func(status string) int { switch status { case domain.OutreachPublished: return 5 case domain.OutreachQueued: return 4 case domain.OutreachDrafted, domain.OutreachSkipped: return 3 case domain.OutreachNew: return 1 default: return 0 } } if a, b := statusRank(candidate.OutreachStatus), statusRank(current.OutreachStatus); a != b { return a > b } if candidate.CreatedAt != current.CreatedAt { return candidate.CreatedAt > current.CreatedAt } return candidate.Score > current.Score } 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]) }