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

800 lines
24 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package usecase
import (
"context"
"encoding/json"
"fmt"
"net/url"
"strings"
"time"
"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 == "" {
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 {
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 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
}
// 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)
}
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)
}
// 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 classified.classification == domain.ClassificationNoise {
continue
}
// 痛點回覆producttheme要找「有困擾的人」略過同業硬廣服務洽詢
if (brief.Mode == domain.ModeProduct || brief.Mode == domain.ModeTheme) &&
classified.classification == domain.ClassificationProviderOffer {
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
}
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
}
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
}
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])
}