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

1471 lines
44 KiB
Go
Raw Normal View History

2026-07-13 01:15:30 +00:00
package usecase
import (
"context"
2026-07-13 08:59:13 +00:00
"encoding/json"
2026-07-13 01:15:30 +00:00
"fmt"
"net/url"
"strings"
2026-07-13 08:59:13 +00:00
"time"
2026-08-09 07:57:35 +00:00
"unicode/utf8"
2026-07-13 01:15:30 +00:00
"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"
2026-08-09 07:57:35 +00:00
usageDomain "apps/backend/internal/module/usage/domain"
usageUC "apps/backend/internal/module/usage/usecase"
2026-07-13 01:15:30 +00:00
"github.com/google/uuid"
2026-08-09 07:57:35 +00:00
"github.com/zeromicro/go-zero/core/logx"
2026-07-13 01:15:30 +00:00
)
// SettingsReader for dev_mode
type SettingsReader interface {
DevModeEnabled(ctx context.Context, uid int64) (bool, error)
}
2026-07-13 08:59:13 +00:00
type ReplyQueue interface {
QueueExternalReply(ctx context.Context, ownerUID int64, accountID, replyToMediaID, text, title string) (outboxID string, err error)
2026-07-13 01:15:30 +00:00
}
2026-07-23 05:56:42 +00:00
// OutreachPublishedHook fires after mark-published / successful outreach send path.
type OutreachPublishedHook func(ctx context.Context, ownerUID int64, postID, accountID string)
2026-08-13 02:22:24 +00:00
// 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)
}
2026-07-13 01:15:30 +00:00
type Service struct {
2026-07-13 08:59:13 +00:00
Repo domain.Repository
Settings SettingsReader
// Transport is retained only for test construction compatibility. Scout never publishes directly.
Transport studioPublish.Transport
2026-08-09 07:57:35 +00:00
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 // 可空:單元測試不扣點
2026-07-13 08:59:13 +00:00
ReplyQueue ReplyQueue
Provider ThreadSearchProvider
Crawler ChromeCrawlerProvider
SessionSecret string
2026-07-23 05:56:42 +00:00
// OnOutreachPublished optional growth-loop outcome hook.
OnOutreachPublished OutreachPublishedHook
2026-08-13 02:22:24 +00:00
RadarLifecycle ProductWatchLifecycle
2026-07-13 01:15:30 +00:00
}
func New(repo domain.Repository) *Service {
2026-07-13 08:59:13 +00:00
return &Service{Repo: repo, Provider: newDefaultExaThreadsProvider()}
2026-07-13 01:15:30 +00:00
}
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
}
2026-08-13 02:22:24 +00:00
if s.RadarLifecycle != nil {
if _, err := s.RadarLifecycle.PauseProductWatches(ctx, ownerUID, id); err != nil {
return err
}
}
2026-07-13 01:15:30 +00:00
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,
2026-08-03 07:58:07 +00:00
// 痛點/標籤要用「飼主正在煩惱的話」,不是產品賣點;匯入後請再改成真痛點
ProductContext: "從 " + raw + " 推估的產品情境(請改寫成實際賣點與使用場景)",
PainPoints: []string{"不知道怎麼選", "用了沒感覺", "擔心不適合/有副作用"},
MatchTags: []string{"求推薦", "有沒有人用過", "怎麼辦"},
2026-07-13 01:15:30 +00:00
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" {
2026-07-13 01:15:30 +00:00
return nil, fmt.Errorf("%w: intent required", domain.ErrValidation)
}
2026-08-09 07:57:35 +00:00
// 話題靈感:真正「產」關鍵字(規則變體 + 可選 AI不再只拆使用者原句。
2026-07-13 01:15:30 +00:00
if purpose == "activity" {
2026-08-09 07:57:35 +00:00
return s.prepareActivityBrief(ctx, ownerUID, intent, deep)
2026-07-13 01:15:30 +00:00
}
2026-08-09 07:57:35 +00:00
mode := domain.ModeTheme
2026-07-13 01:15:30 +00:00
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
}
2026-07-13 01:15:30 +00:00
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
}
2026-07-13 01:15:30 +00:00
if len(brief.Pains) == 0 {
2026-08-03 07:58:07 +00:00
// 預設用「求助/求推」語感,避免把產品賣點當搜尋詞
brief.Pains = []string{intent}
2026-07-13 01:15:30 +00:00
}
if len(brief.Tags) == 0 {
brief.Tags = tokenize(intent)
}
2026-08-03 07:58:07 +00:00
// periphery 只當作業備註,不進 scan_terms否則會污染 Exa 查詢)
2026-07-13 01:15:30 +00:00
brief.Periphery = []string{"使用情境", "替代方案", "成分/規格"}
2026-07-13 08:59:13 +00:00
brief.ScanTerms = planScanTerms(brief)
2026-07-13 01:15:30 +00:00
brief.ThemeLabel = truncate(intent, 36)
brief.ThemeKey = mode + "|" + productID + "|" + truncate(intent, 48)
brief.ResponseStance = "先共鳴再給建議"
return brief, nil
}
2026-08-09 07:57:35 +00:00
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
}
2026-08-03 05:52:02 +00:00
// 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).
2026-08-09 07:57:35 +00:00
//
// 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).
2026-08-03 05:52:02 +00:00
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
}
2026-08-09 07:57:35 +00:00
// 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
}
2026-08-03 05:52:02 +00:00
path = domain.PathAPI
devMode := false
if s.Settings != nil {
if d, derr := s.Settings.DevModeEnabled(ctx, ownerUID); derr == nil {
devMode = d
}
}
if devMode {
2026-08-13 07:54:25 +00:00
var storageState string
var serr error
if s.Repo != nil {
storageState, serr = s.GetCrawlerSessionToken(ctx, ownerUID)
} else {
serr = domain.ErrNoCrawlerSession
2026-08-03 05:52:02 +00:00
}
2026-08-13 07:54:25 +00:00
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)
2026-08-09 07:57:35 +00:00
}
2026-08-03 05:52:02 +00:00
}
if s.Provider == nil {
return nil, path, fmt.Errorf("scout search provider is not configured")
}
2026-08-13 07:54:25 +00:00
path = domain.PathAPI
2026-08-09 07:57:35 +00:00
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
}
2026-08-13 07:54:25 +00:00
hits = fillSearchHitsToTarget(ctx, s, terms, hits, limit, path, "")
2026-08-09 07:57:35 +00:00
return capHits(hits, limit), path, nil
}
func capHits(hits []ThreadSearchResult, limit int) []ThreadSearchResult {
if limit > 0 && len(hits) > limit {
return hits[:limit]
}
return hits
2026-08-03 05:52:02 +00:00
}
2026-07-13 01:15:30 +00:00
func (s *Service) RunScanFromBrief(ctx context.Context, ownerUID int64, brief *domain.RunBrief) ([]*domain.Post, error) {
2026-08-13 02:22:24 +00:00
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) {
2026-07-13 01:15:30 +00:00
if brief == nil {
return nil, fmt.Errorf("%w: nil brief", domain.ErrValidation)
}
2026-08-03 07:58:07 +00:00
terms := nonEmptyTerms(brief.ScanTerms)
if len(terms) == 0 {
2026-07-13 01:15:30 +00:00
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)
}
2026-07-13 01:15:30 +00:00
path := domain.PathAPI
devMode := false
if s.Settings != nil {
if d, err := s.Settings.DevModeEnabled(ctx, ownerUID); err == nil {
devMode = d
}
}
2026-08-03 07:58:07 +00:00
// 每條關鍵字獨立搜尋再合併(使用者已審過的 scan_terms
perQuery := 8
if len(terms) == 1 {
perQuery = 20
} else if len(terms) >= 6 {
perQuery = 5
}
2026-08-09 07:57:35 +00:00
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
}
}
2026-08-03 07:58:07 +00:00
var err error
2026-08-09 07:57:35 +00:00
var storageState string
2026-07-13 01:15:30 +00:00
if devMode {
2026-08-09 07:57:35 +00:00
storageState, err = s.GetCrawlerSessionToken(ctx, ownerUID)
if err != nil {
2026-07-13 01:15:30 +00:00
return nil, domain.ErrNoCrawlerSession
}
path = domain.PathCrawler
2026-07-13 08:59:13 +00:00
if s.Crawler == nil {
return nil, fmt.Errorf("Chrome crawler is not configured")
}
2026-08-03 07:58:07 +00:00
} else {
if s.Provider == nil {
return nil, fmt.Errorf("scout search provider is not configured")
2026-07-13 08:59:13 +00:00
}
2026-07-13 01:15:30 +00:00
}
2026-08-13 02:22:24 +00:00
pipeline, err := s.searchEligiblePipeline(ctx, ownerUID, terms, brief, target, path, storageState, perQuery)
2026-07-13 08:59:13 +00:00
if err != nil {
return nil, err
}
2026-08-13 02:22:24 +00:00
return s.persistSearchHitsWithRun(ctx, ownerUID, brief, path, runID, pipeline.Hits)
2026-07-13 08:59:13 +00:00
}
2026-08-09 07:57:35 +00:00
// 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
}
2026-08-13 02:22:24 +00:00
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)
2026-08-09 07:57:35 +00:00
}
}
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)
}
2026-08-03 07:58:07 +00:00
// 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
}
2026-08-13 07:54:25 +00:00
if isCrawlerSessionDead(err) {
break
}
2026-08-03 07:58:07 +00:00
continue
}
for _, hit := range hits {
2026-08-13 02:22:24 +00:00
permalink := canonicalPermalink(hit.URL)
identity := canonicalPostIdentity(hit.URL)
if identity == "" || permalink == "" {
2026-08-03 07:58:07 +00:00
continue
}
2026-08-13 02:22:24 +00:00
if _, ok := seen[identity]; ok {
2026-08-03 07:58:07 +00:00
continue
}
2026-08-13 02:22:24 +00:00
seen[identity] = struct{}{}
hit.URL = permalink
2026-08-03 07:58:07 +00:00
// 記住是哪條 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
}
2026-07-13 08:59:13 +00:00
func (s *Service) persistSearchHits(ctx context.Context, ownerUID int64, brief *domain.RunBrief, path string, hits []ThreadSearchResult) ([]*domain.Post, error) {
2026-08-13 02:22:24 +00:00
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) {
2026-07-13 01:15:30 +00:00
now := domain.NowNano()
2026-08-13 02:22:24 +00:00
// Provider 原始順序只用於 deterministic created_at最終結果由
// evaluator score 排序不能再被來源的時間track 順序覆蓋。
hasTrack := hitsHaveTrack(hits)
2026-07-13 08:59:13 +00:00
out := make([]*domain.Post, 0, len(hits))
2026-08-13 02:22:24 +00:00
evaluator := NewCandidateEvaluator(brief)
evaluator.SetHistoricalSeenLookup(func(identity string) (bool, error) {
return s.Repo.HasSeenIdentity(ctx, ownerUID, identity)
})
2026-07-13 08:59:13 +00:00
for i, hit := range hits {
2026-08-13 02:22:24 +00:00
evaluation := evaluator.EvaluateAt(hit, now)
if evaluation.Decision != CandidateEligible {
2026-08-09 07:57:35 +00:00
continue
}
2026-08-13 02:22:24 +00:00
text := evaluation.Text
permalink := evaluation.Permalink
postedAt := evaluation.PostedAt
2026-08-09 07:57:35 +00:00
// created_at保 crawler 回傳序i 越小越前);有發文時間仍寫 PostedAt 供 UI
2026-08-03 07:58:07 +00:00
createdAt := now - int64(i)*1000
2026-08-13 02:22:24 +00:00
if postedAt > 0 && !hasTrack {
2026-08-09 07:57:35 +00:00
// 非 crawler 路徑仍用發文時間當 created 序
2026-08-03 07:58:07 +00:00
createdAt = postedAt
}
2026-07-13 01:15:30 +00:00
p := &domain.Post{
2026-07-13 08:59:13 +00:00
ID: permalinkID(ownerUID, permalink), ExternalID: permalink, Permalink: permalink,
2026-08-13 02:22:24 +00:00
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,
2026-07-13 01:15:30 +00:00
ThemeKey: brief.ThemeKey, ThemeLabel: brief.ThemeLabel, ScanPath: path,
2026-08-03 07:58:07 +00:00
PostedAt: postedAt, CreatedAt: createdAt,
2026-07-13 01:15:30 +00:00
}
2026-08-13 02:22:24 +00:00
if runID == "" {
if err := s.Repo.SavePost(ctx, p); err != nil {
return nil, err
}
2026-07-13 01:15:30 +00:00
}
out = append(out, p)
}
2026-08-13 02:22:24 +00:00
// 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)
2026-07-13 01:15:30 +00:00
return out, nil
}
2026-08-13 02:22:24 +00:00
func sortPostsByScore(posts []*domain.Post) {
for i := 0; i < len(posts); i++ {
for j := i + 1; j < len(posts); j++ {
a, b := posts[i], posts[j]
shouldSwap := false
if a.Score != b.Score {
shouldSwap = b.Score > a.Score
} else if (a.PostedAt > 0) != (b.PostedAt > 0) {
shouldSwap = b.PostedAt > 0
} else if a.PostedAt > 0 && a.PostedAt != b.PostedAt {
shouldSwap = b.PostedAt > a.PostedAt
} else if a.CreatedAt != b.CreatedAt {
shouldSwap = b.CreatedAt > a.CreatedAt
} else {
shouldSwap = b.ID > a.ID
}
if shouldSwap {
posts[i], posts[j] = posts[j], posts[i]
}
}
}
}
// sortPostsByResultTime is kept as a package-local compatibility name for
// older tests/callers; result ordering now intentionally delegates to score.
func sortPostsByResultTime(posts []*domain.Post) { sortPostsByScore(posts) }
2026-08-09 07:57:35 +00:00
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,
2026-08-13 02:22:24 +00:00
"熱門": true, "最新": true, "近期": true,
2026-08-09 07:57:35 +00:00
}
// 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, " ", ""))
}
}
2026-08-13 02:22:24 +00:00
// 有主題核:每個主題核都要命中;錨點只是搜尋修飾,不要求正文出現。
// 「後端 外包」不能因為只提到「外包」就混入其他職種。
2026-08-09 07:57:35 +00:00
if len(content) > 0 {
for _, c := range content {
2026-08-13 02:22:24 +00:00
if !strings.Contains(body, c) {
return false
2026-08-09 07:57:35 +00:00
}
}
2026-08-13 02:22:24 +00:00
return true
2026-08-09 07:57:35 +00:00
}
// 只有錨點:放寬(使用者刻意只搜「求推薦」)
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) {}
2026-08-03 07:58:07 +00:00
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]
}
}
}
}
2026-08-09 07:57:35 +00:00
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
}
2026-07-13 08:59:13 +00:00
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 ""
}
2026-07-13 01:15:30 +00:00
func (s *Service) ListPosts(ctx context.Context, ownerUID int64, brandID string) ([]*domain.Post, error) {
2026-08-13 02:22:24 +00:00
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
2026-07-13 01:15:30 +00:00
}
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)
}
2026-07-13 08:59:13 +00:00
draft := "嗨 @" + p.Author + ",看到你提到「" + p.SearchTag + "」,我也遇過類似情況。若你願意,想聽聽你後來怎麼處理。"
if p.ScoutMode == domain.ModeActivity {
draft = "嗨 @" + p.Author + ",這個「" + p.SearchTag + "」很有意思。你自己最在意哪一部分?"
}
2026-07-13 01:15:30 +00:00
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) {
2026-07-20 06:33:14 +00:00
p, err := s.getPostOwned(ctx, ownerUID, postID)
2026-07-13 01:15:30 +00:00
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)
}
2026-07-20 06:33:14 +00:00
p.OutreachStatus = domain.OutreachPublished
if err := s.Repo.SavePost(ctx, p); err != nil {
return nil, err
}
2026-07-23 05:56:42 +00:00
// 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, "")
}
2026-07-20 06:33:14 +00:00
return p, nil
2026-07-13 01:15:30 +00:00
}
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)
}
2026-07-13 01:15:30 +00:00
text = strings.TrimSpace(text)
if text == "" {
text = p.DraftText
}
if text == "" {
return nil, fmt.Errorf("%w: empty outreach text", domain.ErrValidation)
}
2026-07-13 08:59:13 +00:00
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
2026-07-13 01:15:30 +00:00
}
2026-07-13 08:59:13 +00:00
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)
2026-07-13 01:15:30 +00:00
}
}
2026-07-13 08:59:13 +00:00
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
}
2026-07-13 01:15:30 +00:00
p.DraftText = text
2026-07-13 08:59:13 +00:00
p.OutboxID = outboxID
p.OutreachStatus = domain.OutreachQueued
2026-07-13 01:15:30 +00:00
if err := s.Repo.SavePost(ctx, p); err != nil {
return nil, err
}
return p, nil
}
2026-08-09 07:57:35 +00:00
/*
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
}
2026-07-13 08:59:13 +00:00
func isNumericMediaID(value string) bool {
if value == "" {
return false
}
for _, ch := range value {
if ch < '0' || ch > '9' {
return false
}
}
return true
}
2026-07-13 01:15:30 +00:00
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 {
2026-07-13 08:59:13 +00:00
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
2026-07-13 01:15:30 +00:00
}
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)
}
2026-07-13 08:59:13 +00:00
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()
2026-07-13 01:15:30 +00:00
return s.Repo.SetCrawlerSession(ctx, &domain.CrawlerSession{
2026-07-13 08:59:13 +00:00
OwnerUID: ownerUID, StorageStateEnc: storageStateEnc, UpdatedAt: now, ExpiresAt: expiresAt,
2026-07-13 01:15:30 +00:00
})
}
2026-07-13 08:59:13 +00:00
// GetCrawlerSessionToken returns decrypted Playwright storageState JSON for browser crawl.
2026-07-13 01:15:30 +00:00
func (s *Service) GetCrawlerSessionToken(ctx context.Context, ownerUID int64) (string, error) {
sess, err := s.Repo.GetCrawlerSession(ctx, ownerUID)
if err != nil {
return "", err
}
2026-07-13 08:59:13 +00:00
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
}
2026-07-13 01:15:30 +00:00
}
2026-07-13 08:59:13 +00:00
return false
2026-07-13 01:15:30 +00:00
}
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
}
2026-08-03 05:52:02 +00:00
// 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)
}
2026-07-13 01:15:30 +00:00
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])
}