From 15f42a0c27678f7e3a20bb34b2d715ff113fd150 Mon Sep 17 00:00:00 2001 From: Daniel Date: Mon, 3 Aug 2026 07:58:07 +0000 Subject: [PATCH] improve scout search workflow --- apps/backend/generate/api/m5.api | 2 + .../internal/module/scout/domain/domain.go | 4 +- .../internal/module/scout/repository/mongo.go | 6 +- .../scout/usecase/exa_threads_provider.go | 71 ++++-- .../internal/module/scout/usecase/planner.go | 102 +++++++- .../module/scout/usecase/planner_test.go | 36 +-- .../internal/module/scout/usecase/service.go | 138 +++++++++-- apps/backend/internal/types/m5_convert.go | 3 +- apps/backend/internal/types/types.go | 1 + apps/web/src/data/live/m5Repos.ts | 1 + apps/web/src/domain/types.ts | 2 + apps/web/src/lib/i18n/messages.ts | 40 +++- apps/web/src/lib/nav.ts | 6 +- apps/web/src/pages/ScoutPage.tsx | 219 ++++++++++++++++-- 14 files changed, 550 insertions(+), 81 deletions(-) diff --git a/apps/backend/generate/api/m5.api b/apps/backend/generate/api/m5.api index 60a2cc3..a6388c7 100644 --- a/apps/backend/generate/api/m5.api +++ b/apps/backend/generate/api/m5.api @@ -270,6 +270,8 @@ type ( ScanPath string `json:"scan_path,optional"` Classification string `json:"classification,optional"` Permalink string `json:"permalink,optional"` + // 原文發文時間(unix nanoseconds);未知時可為 0 + PostedAt int64 `json:"posted_at,optional"` CreatedAt int64 `json:"created_at"` } ScoutPostListData { diff --git a/apps/backend/internal/module/scout/domain/domain.go b/apps/backend/internal/module/scout/domain/domain.go index ac280c1..977bf21 100644 --- a/apps/backend/internal/module/scout/domain/domain.go +++ b/apps/backend/internal/module/scout/domain/domain.go @@ -114,7 +114,9 @@ type Post struct { ThemeKey string `bson:"theme_key,omitempty" json:"theme_key,omitempty"` ThemeLabel string `bson:"theme_label,omitempty" json:"theme_label,omitempty"` ScanPath string `bson:"scan_path,omitempty" json:"scan_path,omitempty"` // api|crawler - CreatedAt int64 `bson:"created_at" json:"created_at"` + // PostedAt = 原文發文時間(unix ns);未知時為 0,列表以 PostedAt 優先、再 CreatedAt + PostedAt int64 `bson:"posted_at,omitempty" json:"posted_at,omitempty"` + CreatedAt int64 `bson:"created_at" json:"created_at"` } type Homework struct { diff --git a/apps/backend/internal/module/scout/repository/mongo.go b/apps/backend/internal/module/scout/repository/mongo.go index ae41c7b..c21af32 100644 --- a/apps/backend/internal/module/scout/repository/mongo.go +++ b/apps/backend/internal/module/scout/repository/mongo.go @@ -136,7 +136,11 @@ func (s *MonStore) ListPosts(ctx context.Context, ownerUID int64, brandID string filter["brand_id"] = brandID } var list []*domain.Post - err := s.posts.Find(ctx, &list, filter, options.Find().SetSort(bson.D{{Key: "created_at", Value: -1}})) + // 發文時間優先(新→舊),再掃入時間 + err := s.posts.Find(ctx, &list, filter, options.Find().SetSort(bson.D{ + {Key: "posted_at", Value: -1}, + {Key: "created_at", Value: -1}, + })) return list, err } func (s *MonStore) DeletePost(ctx context.Context, id string) error { diff --git a/apps/backend/internal/module/scout/usecase/exa_threads_provider.go b/apps/backend/internal/module/scout/usecase/exa_threads_provider.go index 6e5fd8b..0873879 100644 --- a/apps/backend/internal/module/scout/usecase/exa_threads_provider.go +++ b/apps/backend/internal/module/scout/usecase/exa_threads_provider.go @@ -21,9 +21,13 @@ type ThreadSearchProvider interface { } type ThreadSearchResult struct { - URL string - Title string - Snippet string + URL string + Title string + Snippet string + // PublishedAt unix nanoseconds when known (Exa publishedDate). + PublishedAt int64 + // MatchedQuery is set by fan-out search to the query that found this hit. + MatchedQuery string } // ExaThreadsProvider searches only Threads-owned domains through Exa. @@ -54,21 +58,31 @@ func (p *ExaThreadsProvider) SearchThreads(ctx context.Context, terms []string, return nil, fmt.Errorf("exa Threads search is not configured") } if limit <= 0 { - limit = 5 + limit = 10 } if limit > 20 { limit = 20 } - query := strings.Join(nonEmptyTerms(terms), " ") - if query == "" { + // 單次呼叫:建議只傳 1 條完整 query(fan-out 在上層) + clean := nonEmptyTerms(terms) + if len(clean) == 0 { return nil, fmt.Errorf("exa Threads search query required") } + query := clean[0] + if len(clean) > 1 { + // 相容舊呼叫:仍可 join,但 fan-out 路徑不會走這裡 + query = strings.Join(clean, " ") + } + + // 近 30 天,減少舊硬廣/過期活動 + startPublished := time.Now().UTC().AddDate(0, 0, -30).Format(time.RFC3339) payload, err := json.Marshal(map[string]any{ - "query": query, - "type": "auto", - "numResults": limit, - "includeDomains": []string{"threads.net", "threads.com"}, + "query": query, + "type": "auto", + "numResults": limit, + "includeDomains": []string{"threads.net", "threads.com"}, + "startPublishedDate": startPublished, "contents": map[string]any{ "highlights": true, "text": map[string]any{"maxCharacters": 400}, @@ -107,10 +121,11 @@ func (p *ExaThreadsProvider) SearchThreads(ctx context.Context, terms []string, var response struct { Results []struct { - Title string `json:"title"` - URL string `json:"url"` - Highlights []string `json:"highlights"` - Text string `json:"text"` + Title string `json:"title"` + URL string `json:"url"` + PublishedDate string `json:"publishedDate"` + Highlights []string `json:"highlights"` + Text string `json:"text"` } `json:"results"` } if err := json.Unmarshal(raw, &response); err != nil { @@ -129,11 +144,37 @@ func (p *ExaThreadsProvider) SearchThreads(ctx context.Context, terms []string, if snippet == "" { snippet = strings.TrimSpace(hit.Title) } - results = append(results, ThreadSearchResult{URL: url, Title: strings.TrimSpace(hit.Title), Snippet: truncate(snippet, 400)}) + results = append(results, ThreadSearchResult{ + URL: url, + Title: strings.TrimSpace(hit.Title), + Snippet: truncate(snippet, 400), + PublishedAt: parsePublishedDateNano(hit.PublishedDate), + }) } return results, nil } +func parsePublishedDateNano(raw string) int64 { + raw = strings.TrimSpace(raw) + if raw == "" { + return 0 + } + // Exa 常見 RFC3339 / date-only + layouts := []string{ + time.RFC3339, + time.RFC3339Nano, + "2006-01-02T15:04:05.000Z", + "2006-01-02T15:04:05Z", + "2006-01-02", + } + for _, layout := range layouts { + if t, err := time.Parse(layout, raw); err == nil { + return t.UTC().UnixNano() + } + } + return 0 +} + func isThreadsURL(raw string) bool { u, err := url.Parse(raw) if err != nil { diff --git a/apps/backend/internal/module/scout/usecase/planner.go b/apps/backend/internal/module/scout/usecase/planner.go index 89682e0..385b949 100644 --- a/apps/backend/internal/module/scout/usecase/planner.go +++ b/apps/backend/internal/module/scout/usecase/planner.go @@ -6,6 +6,7 @@ import ( "net/url" "strconv" "strings" + "unicode/utf8" "apps/backend/internal/module/scout/domain" ) @@ -16,8 +17,86 @@ type classifiedPost struct { reason string } +// planScanTerms builds independent search queries (one query per term). +// Callers fan out: each ScanTerms[i] is searched separately then merged. +// Avoids dumping all keywords into one diluted Exa query. func planScanTerms(brief *domain.RunBrief) []string { - return dedupeTerms(brief.Pains, brief.Tags, brief.Periphery, []string{brief.Intent}) + if brief == nil { + return nil + } + if brief.Mode == domain.ModeActivity { + return capTerms(dedupeTerms([]string{brief.Intent}, tokenizeIntent(brief.Intent)), 6) + } + + pains := filterSeekablePains(brief.Pains) + tags := filterSeekablePains(brief.Tags) + var queries []string + + intent := strings.TrimSpace(brief.Intent) + if intent != "" { + if utf8.RuneCountInString(intent) > 48 { + intent = truncateRunes(intent, 48) + } + queries = append(queries, intent) + } + for _, p := range pains { + if !strings.EqualFold(p, intent) { + queries = append(queries, p) + } + } + for _, tag := range tags { + if !strings.EqualFold(tag, intent) { + queries = append(queries, tag) + } + } + // 對前幾個短痛點加求助語感 variant(整句當一 query,不是單獨搜「求推薦」) + for _, p := range capTerms(pains, 3) { + if utf8.RuneCountInString(p) <= 18 { + queries = append(queries, p+" 求推薦") + } + } + return capTerms(dedupeTerms(queries), 8) +} + +// filterSeekablePains drops marketing slogans / long product blurbs that pull competitor posts. +func filterSeekablePains(in []string) []string { + var out []string + for _, raw := range in { + term := strings.Join(strings.Fields(strings.TrimSpace(raw)), " ") + if term == "" { + continue + } + if isMarketingPhrase(term) { + continue + } + out = append(out, term) + } + return out +} + +func isMarketingPhrase(term string) bool { + if utf8.RuneCountInString(term) > 28 { + return true + } + lower := strings.ToLower(term) + bad := []string{ + "✔", "✓", "x1", "×1", "任選", "內含", "官網", "現折", "折扣碼", + "完整保養流程", "快速帶走", "滋潤修護", "天然植萃配方", "溫和不刺激", + "服務洽詢", "限時優惠", "立即購買", "旗艦店", + } + for _, b := range bad { + if strings.Contains(lower, strings.ToLower(b)) { + return true + } + } + return false +} + +func capTerms(terms []string, max int) []string { + if max <= 0 || len(terms) <= max { + return terms + } + return terms[:max] } func dedupeTerms(groups ...[]string) []string { @@ -40,6 +119,27 @@ func dedupeTerms(groups ...[]string) []string { return out } +func tokenizeIntent(intent string) []string { + // 簡單空白/標點切詞,活躍模式備援 + fields := strings.FieldsFunc(intent, func(r rune) bool { + switch r { + case ' ', '\t', '\n', ',', ',', '、', '/', '|', '·', '。', '!', '?', '!', '?': + return true + default: + return false + } + }) + return fields +} + +func truncateRunes(s string, max int) string { + if max <= 0 || utf8.RuneCountInString(s) <= max { + return s + } + runes := []rune(s) + return string(runes[:max]) +} + func classifyPost(mode, text string, terms []string) classifiedPost { lower := strings.ToLower(text) signals := matchedSignals(lower, terms) diff --git a/apps/backend/internal/module/scout/usecase/planner_test.go b/apps/backend/internal/module/scout/usecase/planner_test.go index bcf2bcd..41c4579 100644 --- a/apps/backend/internal/module/scout/usecase/planner_test.go +++ b/apps/backend/internal/module/scout/usecase/planner_test.go @@ -12,8 +12,9 @@ import ( ) type capturedSearchProvider struct { - terms []string - hits []usecase.ThreadSearchResult + // queries records each fan-out call (one term per call) + queries []string + hits []usecase.ThreadSearchResult } func TestPrepareBriefPlansDeduplicatedTermsFromAllInputs(t *testing.T) { @@ -26,21 +27,25 @@ func TestPrepareBriefPlansDeduplicatedTermsFromAllInputs(t *testing.T) { require.NoError(t, err) brief, err := svc.PrepareBrief(context.Background(), 1, "敏感肌保養", brand.ID, product.ID, "value", false) require.NoError(t, err) - require.Equal(t, []string{"敏感肌", "保養", "使用情境", "替代方案", "成分/規格", "敏感肌保養"}, brief.ScanTerms) + // 每條是獨立 query;含 intent/痛點/標籤與求助 variant;不含 periphery + require.Equal(t, []string{"敏感肌保養", "敏感肌", "保養", "敏感肌 求推薦"}, brief.ScanTerms) + require.NotContains(t, brief.ScanTerms, "使用情境") } func (p *capturedSearchProvider) SearchThreads(_ context.Context, terms []string, _ int) ([]usecase.ThreadSearchResult, error) { - p.terms = append([]string(nil), terms...) + if len(terms) > 0 { + p.queries = append(p.queries, terms[0]) + } return p.hits, nil } func TestPlannerProductSignalsAndDeterministicPersistence(t *testing.T) { provider := &capturedSearchProvider{hits: []usecase.ThreadSearchResult{ - {URL: "https://www.threads.net/@a/post/1?utm_source=test", Snippet: "請問敏感肌怎麼舒緩?"}, - {URL: "https://www.threads.net/@b/post/2", Snippet: "敏感肌保養有沒有推薦?"}, - {URL: "https://www.threads.net/@c/post/3", Snippet: "敏感肌服務洽詢,現在限時優惠"}, - {URL: "https://www.threads.net/@d/post/4", Snippet: "分享敏感肌保養的使用心得"}, - {URL: "https://www.threads.net/@e/post/5", Snippet: "敏感肌抽獎,互追拿好禮"}, + {URL: "https://www.threads.net/@a/post/1?utm_source=test", Snippet: "請問敏感肌怎麼舒緩?", PublishedAt: 300}, + {URL: "https://www.threads.net/@b/post/2", Snippet: "敏感肌保養有沒有推薦?", PublishedAt: 200}, + {URL: "https://www.threads.net/@c/post/3", Snippet: "敏感肌服務洽詢,現在限時優惠", PublishedAt: 100}, + {URL: "https://www.threads.net/@d/post/4", Snippet: "分享敏感肌保養的使用心得", PublishedAt: 50}, + {URL: "https://www.threads.net/@e/post/5", Snippet: "敏感肌抽獎,互追拿好禮", PublishedAt: 10}, }} store := repository.NewMemory() svc := usecase.New(store) @@ -53,8 +58,13 @@ func TestPlannerProductSignalsAndDeterministicPersistence(t *testing.T) { posts, err := svc.RunScanFromBrief(context.Background(), 42, brief) require.NoError(t, err) - require.Equal(t, []string{"敏感肌", "保養", "使用情境", "敏感肌保養"}, provider.terms) - require.Len(t, posts, 4) + // 多 query 扇出:每條 scan_term 各搜一次 + require.Equal(t, []string{"敏感肌", "保養", "使用情境", "敏感肌保養"}, provider.queries) + // product 模式略過 provider_offer(硬廣),noise 也略過 → 剩 3 + require.Len(t, posts, 3) + // 依發文時間新→舊 + require.Equal(t, "https://www.threads.net/@a/post/1", posts[0].Permalink) + require.Equal(t, int64(300), posts[0].PostedAt) byClass := map[string]*domain.Post{} for _, post := range posts { byClass[post.Classification] = post @@ -62,10 +72,10 @@ func TestPlannerProductSignalsAndDeterministicPersistence(t *testing.T) { require.Equal(t, post.Permalink, post.ExternalID) require.InDelta(t, 0, post.Score, 100) require.NotEmpty(t, post.MatchReason) + require.NotEqual(t, domain.ClassificationProviderOffer, post.Classification) } require.Contains(t, byClass, domain.ClassificationSeekingHelp) require.Contains(t, byClass, domain.ClassificationSeekingRecommendation) - require.Contains(t, byClass, domain.ClassificationProviderOffer) require.Contains(t, byClass, domain.ClassificationDiscussion) require.Contains(t, byClass[domain.ClassificationSeekingHelp].MatchReason, "敏感肌") require.Equal(t, "https://www.threads.net/@a/post/1", byClass[domain.ClassificationSeekingHelp].Permalink) @@ -75,7 +85,7 @@ func TestPlannerProductSignalsAndDeterministicPersistence(t *testing.T) { require.Equal(t, byClass[domain.ClassificationSeekingHelp].ID, again[0].ID) persisted, err := svc.ListPosts(context.Background(), 42, "") require.NoError(t, err) - require.Len(t, persisted, 4) + require.Len(t, persisted, 3) } func TestPlannerActivityClassifiesWithNeutralRecency(t *testing.T) { diff --git a/apps/backend/internal/module/scout/usecase/service.go b/apps/backend/internal/module/scout/usecase/service.go index ed25bc7..c7616a5 100644 --- a/apps/backend/internal/module/scout/usecase/service.go +++ b/apps/backend/internal/module/scout/usecase/service.go @@ -211,9 +211,10 @@ func (s *Service) ImportProductFromURL(_ context.Context, raw string) (*domain.I } return &domain.ImportDraft{ Label: label, - ProductContext: "從 " + raw + " 推估的產品情境(live 可接真爬頁)", - PainPoints: []string{"找不到適合的", "價格猶豫", "不確定是否適合自己"}, - MatchTags: []string{"好物", "推薦", "踩雷"}, + // 痛點/標籤要用「飼主正在煩惱的話」,不是產品賣點;匯入後請再改成真痛點 + ProductContext: "從 " + raw + " 推估的產品情境(請改寫成實際賣點與使用場景)", + PainPoints: []string{"不知道怎麼選", "用了沒感覺", "擔心不適合/有副作用"}, + MatchTags: []string{"求推薦", "有沒有人用過", "怎麼辦"}, PlacementURL: raw, SourceNote: "importProductFromUrl · " + host, }, nil @@ -250,11 +251,13 @@ func (s *Service) PrepareBrief(ctx context.Context, ownerUID int64, intent, bran } } if len(brief.Pains) == 0 { - brief.Pains = []string{intent, "相關困擾"} + // 預設用「求助/求推」語感,避免把產品賣點當搜尋詞 + 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) @@ -307,7 +310,8 @@ func (s *Service) RunScanFromBrief(ctx context.Context, ownerUID int64, brief *d if brief == nil { return nil, fmt.Errorf("%w: nil brief", domain.ErrValidation) } - if len(brief.ScanTerms) == 0 { + terms := nonEmptyTerms(brief.ScanTerms) + if len(terms) == 0 { return nil, fmt.Errorf("%w: need scan_terms", domain.ErrValidation) } path := domain.PathAPI @@ -317,33 +321,84 @@ func (s *Service) RunScanFromBrief(ctx context.Context, ownerUID int64, brief *d 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, err := s.GetCrawlerSessionToken(ctx, ownerUID) - if err != nil { + 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 := s.Crawler.SearchChrome(ctx, storageState, brief.ScanTerms, 20) - if err != nil { - return nil, err + 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") } - return s.persistSearchHits(ctx, ownerUID, brief, path, hits) + 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 s.Provider == nil { - return nil, fmt.Errorf("scout search provider is not configured") - } - hits, err := s.Provider.SearchThreads(ctx, brief.ScanTerms, 5) 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) @@ -354,11 +409,25 @@ func (s *Service) persistSearchHits(ctx context.Context, ownerUID int64, brief * if permalink == "" { continue } - term := matchingTerm(text+" "+hit.Title, brief.ScanTerms) + 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 } + // 痛點回覆(product/theme)要找「有困擾的人」,略過同業硬廣/服務洽詢 + 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, @@ -366,16 +435,51 @@ func (s *Service) persistSearchHits(ctx context.Context, ownerUID int64, brief * 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, - CreatedAt: now - int64(i)*1000, + 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)) { diff --git a/apps/backend/internal/types/m5_convert.go b/apps/backend/internal/types/m5_convert.go index 84e439e..6111e98 100644 --- a/apps/backend/internal/types/m5_convert.go +++ b/apps/backend/internal/types/m5_convert.go @@ -117,7 +117,8 @@ func ScoutPostFromDomain(p *scoutDomain.Post) ScoutPostPublic { Score: p.Score, MatchedProductId: p.MatchedProductID, MatchedProductLabel: p.MatchedProductLabel, MatchReason: p.MatchReason, ScoutMode: p.ScoutMode, IntentSnippet: p.IntentSnippet, ThemeKey: p.ThemeKey, ThemeLabel: p.ThemeLabel, ScanPath: p.ScanPath, - Classification: p.Classification, Permalink: p.Permalink, CreatedAt: p.CreatedAt, + Classification: p.Classification, Permalink: p.Permalink, + PostedAt: p.PostedAt, CreatedAt: p.CreatedAt, } } diff --git a/apps/backend/internal/types/types.go b/apps/backend/internal/types/types.go index b31c473..5aa2238 100644 --- a/apps/backend/internal/types/types.go +++ b/apps/backend/internal/types/types.go @@ -1825,6 +1825,7 @@ type ScoutPostPublic struct { ScanPath string `json:"scan_path,optional"` Classification string `json:"classification,optional"` Permalink string `json:"permalink,optional"` + PostedAt int64 `json:"posted_at,optional"` CreatedAt int64 `json:"created_at"` } diff --git a/apps/web/src/data/live/m5Repos.ts b/apps/web/src/data/live/m5Repos.ts index 0cbf092..e5a6d9e 100644 --- a/apps/web/src/data/live/m5Repos.ts +++ b/apps/web/src/data/live/m5Repos.ts @@ -186,6 +186,7 @@ function mapScoutPost(raw: Record): ScoutPost { scan_path: raw.scan_path != null ? String(raw.scan_path) : undefined, classification: raw.classification != null ? String(raw.classification) : undefined, permalink: raw.permalink != null ? String(raw.permalink) : undefined, + posted_at: raw.posted_at != null ? Number(raw.posted_at) : undefined, created_at: raw.created_at != null ? Number(raw.created_at) : undefined, published_at: raw.published_at != null ? Number(raw.published_at) : undefined, }; diff --git a/apps/web/src/domain/types.ts b/apps/web/src/domain/types.ts index 1d38b96..22db000 100644 --- a/apps/web/src/domain/types.ts +++ b/apps/web/src/domain/types.ts @@ -727,6 +727,8 @@ export type ScoutPost = { classification?: string; /** 原始 Threads 貼文連結 */ permalink?: string; + /** 原文發文時間(UTC unix nanoseconds);未知時可為 0/省略 */ + posted_at?: number; /** 掃描命中的建立時間(UTC unix nanoseconds) */ created_at?: number; /** 實際發出回覆的時間(UTC unix nanoseconds);舊資料可能沒有 */ diff --git a/apps/web/src/lib/i18n/messages.ts b/apps/web/src/lib/i18n/messages.ts index 5a9f5df..1636d57 100644 --- a/apps/web/src/lib/i18n/messages.ts +++ b/apps/web/src/lib/i18n/messages.ts @@ -97,11 +97,11 @@ export const zhTW: MessageDict = { "help.page.studio.tips": "創作頁不直接等同已發佈;真正送出在 Outbox。", "help.page.scout.title": "海巡(手動掃場)", - "help.page.scout.what": "你現在按一次、掃一輪:找痛點/話題對話、寫草稿、回完就結。不是「每天自動來名單」——那是側欄「商機」。", - "help.page.scout.step1": "設定今日目標與想找的內容,按開始掃一輪。", - "help.page.scout.step2": "在佇列選貼文、寫草稿、開原文手動回。", + "help.page.scout.what": "先練關鍵字、確認後再搜:找痛點/話題對話、寫草稿、回完就結。不是「每天自動來名單」——那是側欄「商機」。", + "help.page.scout.step1": "寫意圖(可選產品),按「產出關鍵字」檢視/增刪 query。", + "help.page.scout.step2": "確認後「用這些詞開始搜」,在佇列依發文時間處理。", "help.page.scout.step3": "值得長期跟進的,按「收進商機」複製到每日商機/名單(不影響這則海巡狀態)。", - "help.page.scout.tips": "海巡=出擊掃場;商機=訂閱後每天自動收「正在找你的人」。兩者可並用。", + "help.page.scout.tips": "海巡=出擊掃場;商機=訂閱後每天自動收「正在找你的人」。兩者可並用。關鍵字要寫「對方的困擾」,不要寫產品賣點。", "help.page.radar_today.title": "今日商機(自動名單)", "help.page.radar_today.what": "依你訂的關鍵字,系統每天自動整理的需求名單(高/中/低意向)。和海巡不同:不用每次手動掃。", @@ -1412,6 +1412,18 @@ export const zhTW: MessageDict = { "scout.start": "開始", "scout.startMore": "再撈一批", "scout.fetching": "撈取中…", + "scout.planKeywords": "產出關鍵字", + "scout.planning": "整理關鍵字…", + "scout.workshop": "搜尋關鍵字(可改)", + "scout.workshopHint": "確認後才會搜尋。每條是一組獨立 query;刪掉不準的、加上你知道有效的說法。", + "scout.workshopEmpty": "至少保留一條關鍵字才能搜尋", + "scout.addTerm": "新增", + "scout.addTermPh": "再加一條搜尋關鍵字", + "scout.removeTerm": "移除", + "scout.confirmScan": "用這些詞開始搜", + "scout.replan": "重新產出", + "scout.clearWorkshop": "取消", + "scout.termsReady": "已產出 {n} 條關鍵字,請確認後再搜。", "scout.runs": "海巡批次", "scout.runCount": "批次({n})", "scout.runSelectAria": "切換海巡批次", @@ -2409,11 +2421,11 @@ export const en: MessageDict = { "help.page.studio.tips": "Studio drafts are not published until Outbox succeeds.", "help.page.scout.title": "Patrol (manual sweep)", - "help.page.scout.what": "You hit start and sweep one batch of pain/topic threads, draft, reply, done. Not the daily auto list — that is Demand in the nav.", - "help.page.scout.step1": "Set today’s goal and what you want to find, then run a scan.", - "help.page.scout.step2": "Pick a post, draft, open the original, reply manually.", + "help.page.scout.what": "Plan keywords first, confirm, then search: find pain/topic threads, draft, reply, done. Not the daily auto list — that is Demand in the nav.", + "help.page.scout.step1": "Write your intent (optional product), generate keywords, then edit the queries.", + "help.page.scout.step2": "Confirm search, then work the queue sorted by post time.", "help.page.scout.step3": "Worth long-term follow-up? Use “Save to Demand” to copy into today’s opportunities (scout status stays unchanged).", - "help.page.scout.tips": "Patrol = one-off sortie. Demand = subscribed keywords, refreshed every day. Use both.", + "help.page.scout.tips": "Patrol = one-off sortie. Demand = subscribed keywords, refreshed every day. Keywords should describe their problem, not your product pitch.", "help.page.radar_today.title": "Today's demand (auto list)", "help.page.radar_today.what": "A daily demand list from your keyword watches, scored high/mid/low. Unlike Patrol, you don’t re-scan by hand each time.", @@ -3723,6 +3735,18 @@ export const en: MessageDict = { "scout.start": "Start", "scout.startMore": "Fetch more", "scout.fetching": "Fetching…", + "scout.planKeywords": "Generate keywords", + "scout.planning": "Planning keywords…", + "scout.workshop": "Search keywords (editable)", + "scout.workshopHint": "Search runs only after you confirm. Each line is its own query — remove weak ones and add phrases that work.", + "scout.workshopEmpty": "Keep at least one keyword to search", + "scout.addTerm": "Add", + "scout.addTermPh": "Add another search phrase", + "scout.removeTerm": "Remove", + "scout.confirmScan": "Search with these", + "scout.replan": "Regenerate", + "scout.clearWorkshop": "Cancel", + "scout.termsReady": "{n} keywords ready — review before searching.", "scout.runs": "Patrol batches", "scout.runCount": "Batch ({n})", "scout.runSelectAria": "Switch patrol batch", diff --git a/apps/web/src/lib/nav.ts b/apps/web/src/lib/nav.ts index 9aaae33..11af8f1 100644 --- a/apps/web/src/lib/nav.ts +++ b/apps/web/src/lib/nav.ts @@ -69,10 +69,9 @@ export const mobileDockMoreKeys: NavKey[] = [ "jobs", "brands", "crm", - "playbooks", "insights", - "benchmark", "utm", + // 暫隱藏:playbooks(市集)、benchmark(基準) ]; export type NavGroupKey = "workflow" | "accounts" | "growth"; @@ -91,7 +90,8 @@ export const navGroups: NavGroup[] = [ { key: "growth", labelKey: "navGroup.growth", - keys: ["insights", "benchmark", "playbooks", "utm"], + // 暫隱藏:benchmark(基準)、playbooks(市集) + keys: ["insights", "utm"], }, ]; diff --git a/apps/web/src/pages/ScoutPage.tsx b/apps/web/src/pages/ScoutPage.tsx index 0535768..0e0fa48 100644 --- a/apps/web/src/pages/ScoutPage.tsx +++ b/apps/web/src/pages/ScoutPage.tsx @@ -120,8 +120,21 @@ function shortRunLabel(label: string, max = 20): string { return `${t.slice(0, Math.max(1, max - 1))}…`; } +/** 原文發文時間優先,其次掃入時間;新→舊 */ +function postTimeMs(p: ScoutPost): number { + const n = Number(p.posted_at || p.created_at || 0); + return n > 0 ? n : 0; +} + +/** 佇列排序:待回優先,再依發文時間新→舊 */ +function compareQueuePosts(a: ScoutPost, b: ScoutPost): number { + const pendingDiff = Number(isPending(b)) - Number(isPending(a)); + if (pendingDiff !== 0) return pendingDiff; + return postTimeMs(b) - postTimeMs(a); +} + /** - * 海巡:今日目標 + 現在這一則 + 收合功課/佇列 + * 海巡:先練關鍵字 → 確認搜尋 → 佇列依發文時間 */ export function ScoutPage() { const repos = useRepos(); @@ -158,6 +171,11 @@ export function ScoutPage() { const [crawlerSessionRequired, setCrawlerSessionRequired] = useState(false); const [loadError, setLoadError] = useState(""); + /** 關鍵字工坊:prepareBrief 後停在這裡,確認才 scan */ + const [workshopBrief, setWorkshopBrief] = useState(null); + const [workshopTerms, setWorkshopTerms] = useState([]); + const [newTerm, setNewTerm] = useState(""); + useEffect(() => { const t = loadScoutToday(); setTodayDone(t.done); @@ -178,7 +196,7 @@ export function ScoutPage() { setAllProducts(prods); setPosts(postList); - const pending = postList.filter(isPending).sort((a, b) => (b.score || 0) - (a.score || 0)); + const pending = postList.filter(isPending).sort(compareQueuePosts); if (pending[0]) setActiveRunKey((cur) => cur || postRunKey(pending[0]!)); setLoadError(""); } catch (e) { @@ -251,7 +269,7 @@ export function ScoutPage() { return posts .filter((p) => !activeRunKey || postRunKey(p) === activeRunKey) .slice() - .sort((a, b) => Number(isPending(b)) - Number(isPending(a)) || (b.score || 0) - (a.score || 0)); + .sort(compareQueuePosts); }, [posts, activeRunKey]); const pendingQueue = useMemo(() => runQueue.filter(isPending), [runQueue]); @@ -285,10 +303,25 @@ export function ScoutPage() { if (cancelled()) return; setPosts(list); const completedThemeKey = scanJobThemeKey || job.ref_id || activeRunKey; + // 掃完立刻切到這一批並選第一則,避免還停在舊批次像「沒結果」要手動重整 + if (completedThemeKey) { + setActiveRunKey(completedThemeKey); + setValueQueuePage(1); + setActivityQueuePage(1); + } const pending = list - .filter((p) => postRunKey(p) === completedThemeKey && isPending(p)) - .sort((a, b) => (b.score || 0) - (a.score || 0)); + .filter((p) => (!completedThemeKey || postRunKey(p) === completedThemeKey) && isPending(p)) + .sort(compareQueuePosts); + setCurrentId(pending[0]?.id ?? null); setMessage(t("scout.scanReady", { n: pending.length })); + // 掃完收起工坊,下一輪重新練詞 + setWorkshopBrief(null); + setWorkshopTerms([]); + try { + setHomeworkList(await repos.scout.listHomework()); + } catch { + /* homework 刷新失敗不擋主流程 */ + } } else if (job.status === "failed" || job.status === "cancelled") { setMessage(job.error || t("scout.patrolFail")); setCrawlerSessionRequired(/crawler.?session|chrome session/i.test(job.error || "")); @@ -332,7 +365,7 @@ export function ScoutPage() { const key = runKey === undefined ? activeRunKey : runKey; let src = (list || posts).filter(isPending); if (key) src = src.filter((p) => postRunKey(p) === key); - src = src.slice().sort((a, b) => (b.score || 0) - (a.score || 0)); + src = src.slice().sort(compareQueuePosts); if (!src.length) { setCurrentId(null); return; @@ -352,7 +385,7 @@ export function ScoutPage() { setActivityQueuePage(1); const pending = posts .filter((p) => postRunKey(p) === key && isPending(p)) - .sort((a, b) => (b.score || 0) - (a.score || 0)); + .sort(compareQueuePosts); setCurrentId(pending[0]?.id || null); } @@ -395,13 +428,14 @@ export function ScoutPage() { } } - async function startPatrol() { + /** ① 產出可審關鍵字(不搜尋) */ + async function planKeywords() { const text = intent.trim(); if (!text) { setMessage(purpose === "activity" ? t("scout.needKeyword") : t("scout.needIntent")); return; } - setBusy("run"); + setBusy("plan"); setMessage(""); setCrawlerSessionRequired(false); try { @@ -412,7 +446,6 @@ export function ScoutPage() { if (purpose === "value" && productId && !selected) { throw new Error(t("scout.productMissing")); } - // ① 輕量 brief → 立刻海巡;每次按開始 = 獨立批次(unique theme_key) const brief = await repos.scout.prepareBrief({ intent: text, brandId: purpose === "activity" ? null : selected?.brand_id || null, @@ -420,16 +453,83 @@ export function ScoutPage() { purpose, deep: false, }); + const terms = (brief.scan_terms || []).map((s) => s.trim()).filter(Boolean); + setWorkshopBrief(brief); + setWorkshopTerms(terms.length ? terms : [text]); + setNewTerm(""); + setMessage(t("scout.termsReady", { n: terms.length || 1 })); + } catch (e) { + setMessage(e instanceof Error ? e.message : t("scout.patrolFail")); + } finally { + setBusy(""); + } + } + + function removeWorkshopTerm(idx: number) { + setWorkshopTerms((prev) => prev.filter((_, i) => i !== idx)); + } + + function addWorkshopTerm() { + const term = newTerm.trim(); + if (!term) return; + setWorkshopTerms((prev) => { + if (prev.some((p) => p.toLowerCase() === term.toLowerCase())) return prev; + return [...prev, term]; + }); + setNewTerm(""); + } + + function updateWorkshopTerm(idx: number, value: string) { + setWorkshopTerms((prev) => prev.map((t, i) => (i === idx ? value : t))); + } + + function clearWorkshop() { + setWorkshopBrief(null); + setWorkshopTerms([]); + setNewTerm(""); + } + + /** ② 確認關鍵字後才 enqueue scan */ + async function confirmScan() { + const text = intent.trim(); + if (!text) { + setMessage(purpose === "activity" ? t("scout.needKeyword") : t("scout.needIntent")); + return; + } + const terms = workshopTerms.map((s) => s.trim()).filter(Boolean); + if (!terms.length) { + setMessage(t("scout.workshopEmpty")); + return; + } + setBusy("run"); + setMessage(""); + setCrawlerSessionRequired(false); + try { + const base = + workshopBrief || + (await repos.scout.prepareBrief({ + intent: text, + brandId: + purpose === "activity" + ? null + : allProducts.find((p) => p.id === productId)?.brand_id || null, + productId: purpose === "activity" ? null : productId || null, + purpose, + deep: false, + })); const baseLabel = - brief.theme_label || brief.product_label || brief.intent.slice(0, 36) || t("scout.defaultLabel"); + base.theme_label || base.product_label || base.intent.slice(0, 36) || t("scout.defaultLabel"); const theme_key = newId("run"); const theme_label = `${baseLabel} · ${runTimeSuffix()}`; - const briefSaved: ScoutRunBrief = { ...brief, theme_key, theme_label }; + const briefSaved: ScoutRunBrief = { + ...base, + intent: text, + scan_terms: terms, + theme_key, + theme_label, + }; - const { job } = await repos.scout.runScanFromBrief({ - ...briefSaved, - scan_terms: briefSaved.scan_terms, - }); + const { job } = await repos.scout.runScanFromBrief(briefSaved); await repos.scout.saveHomework({ theme_key, theme_label, @@ -438,17 +538,19 @@ export function ScoutPage() { created_at: nowUnixNano(), }); setHomeworkList(await repos.scout.listHomework()); + setActiveRunKey(theme_key); + setCurrentId(null); + setValueQueuePage(1); + setActivityQueuePage(1); setScanJob(job); setScanJobThemeKey(theme_key); void reloadJobs(); setMessage(t("scout.scanQueued", { label: theme_label })); - } catch (e) { setMessage(e instanceof Error ? e.message : t("scout.patrolFail")); setCrawlerSessionRequired( typeof e === "object" && e !== null && "code" in e && (e as { code?: number }).code === 400061, ); - setBusy(""); } finally { setBusy(""); } @@ -716,15 +818,90 @@ export function ScoutPage() {
+ {workshopBrief ? ( + +
+

+ {t("scout.workshopHint")} +

+
+ {workshopTerms.map((term, idx) => ( +
+
+ updateWorkshopTerm(idx, e.target.value)} + aria-label={`${t("scout.workshop")} ${idx + 1}`} + /> +
+ +
+ ))} +
+
+
+ setNewTerm(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + addWorkshopTerm(); + } + }} + /> +
+ +
+
+ + +
+
+
+ ) : null} + {visibleScanJob ? (