952 lines
29 KiB
Go
952 lines
29 KiB
Go
package usecase
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"net/url"
|
||
"strings"
|
||
"time"
|
||
|
||
"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"
|
||
|
||
"github.com/google/uuid"
|
||
)
|
||
|
||
// 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 // Retained for service wiring; Scout drafts never call AI.
|
||
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) {
|
||
_ = deep
|
||
intent = strings.TrimSpace(intent)
|
||
if intent == "" && purpose != "provider" && purpose != "demand" {
|
||
return nil, fmt.Errorf("%w: intent required", domain.ErrValidation)
|
||
}
|
||
mode := domain.ModeTheme
|
||
if purpose == "activity" {
|
||
mode = domain.ModeActivity
|
||
}
|
||
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
|
||
if purpose == "activity" {
|
||
mode = domain.ModeActivity
|
||
}
|
||
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
|
||
}
|
||
|
||
// 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).
|
||
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
|
||
}
|
||
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 = s.Crawler.SearchChrome(ctx, storageState, terms, limit)
|
||
return hits, path, err
|
||
}
|
||
if s.Provider == nil {
|
||
return nil, path, fmt.Errorf("scout search provider is not configured")
|
||
}
|
||
hits, err = s.Provider.SearchThreads(ctx, terms, limit)
|
||
return hits, path, err
|
||
}
|
||
|
||
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
|
||
}
|
||
var hits []ThreadSearchResult
|
||
var err error
|
||
if devMode {
|
||
storageState, serr := s.GetCrawlerSessionToken(ctx, ownerUID)
|
||
if serr != 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
|
||
}
|
||
return s.persistSearchHits(ctx, ownerUID, brief, path, hits)
|
||
}
|
||
|
||
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()
|
||
// 先依原文發文時間新→舊;無時間的排後面,再以陣列序
|
||
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
|
||
}
|
||
term := strings.TrimSpace(hit.MatchedQuery)
|
||
if term == "" {
|
||
term = matchingTerm(text+" "+hit.Title, brief.ScanTerms)
|
||
}
|
||
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
|
||
}
|
||
// 痛點回覆(product/theme)要找「有困擾的人」,略過同業硬廣/服務洽詢
|
||
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
|
||
}
|
||
postedAt := hit.PublishedAt
|
||
// created_at:有發文時間則對齊發文序;否則用掃入時間並微調保序
|
||
createdAt := now - int64(i)*1000
|
||
if postedAt > 0 {
|
||
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: classified.score, Classification: classified.classification, MatchedProductID: brief.ProductID, MatchedProductLabel: brief.ProductLabel,
|
||
MatchReason: classified.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)
|
||
}
|
||
// 回傳列表:待處理優先不在這裡做,純按發文時間新→舊
|
||
sortPostsByPostedAt(out)
|
||
return out, nil
|
||
}
|
||
|
||
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 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
|
||
}
|
||
|
||
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])
|
||
}
|