improve scout search workflow

This commit is contained in:
王性驊 2026-08-03 07:58:07 +00:00
parent 031ba7769e
commit 15f42a0c27
14 changed files with 550 additions and 81 deletions

View File

@ -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 {

View File

@ -114,6 +114,8 @@ 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
// PostedAt = 原文發文時間unix ns未知時為 0列表以 PostedAt 優先、再 CreatedAt
PostedAt int64 `bson:"posted_at,omitempty" json:"posted_at,omitempty"`
CreatedAt int64 `bson:"created_at" json:"created_at"`
}

View File

@ -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 {

View File

@ -24,6 +24,10 @@ type ThreadSearchResult struct {
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 條完整 queryfan-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"},
"startPublishedDate": startPublished,
"contents": map[string]any{
"highlights": true,
"text": map[string]any{"maxCharacters": 400},
@ -109,6 +123,7 @@ func (p *ExaThreadsProvider) SearchThreads(ctx context.Context, terms []string,
Results []struct {
Title string `json:"title"`
URL string `json:"url"`
PublishedDate string `json:"publishedDate"`
Highlights []string `json:"highlights"`
Text string `json:"text"`
} `json:"results"`
@ -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 {

View File

@ -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)

View File

@ -12,7 +12,8 @@ import (
)
type capturedSearchProvider struct {
terms []string
// queries records each fan-out call (one term per call)
queries []string
hits []usecase.ThreadSearchResult
}
@ -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) {

View File

@ -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
}
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.Crawler.SearchChrome(ctx, storageState, []string{q}, limit)
})
} else {
if s.Provider == nil {
return nil, fmt.Errorf("scout search provider is not configured")
}
hits, err := s.Provider.SearchThreads(ctx, brief.ScanTerms, 5)
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)
@ -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
}
// 痛點回覆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,
@ -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)) {

View File

@ -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,
}
}

View File

@ -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"`
}

View File

@ -186,6 +186,7 @@ function mapScoutPost(raw: Record<string, unknown>): 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,
};

View File

@ -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舊資料可能沒有 */

View File

@ -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 todays 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 todays 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 dont 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",

View File

@ -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"],
},
];

View File

@ -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<ScoutRunBrief | null>(null);
const [workshopTerms, setWorkshopTerms] = useState<string[]>([]);
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() {
<div className="hb-wizard-actions">
<Button
type="button"
onClick={() => void startPatrol()}
disabled={busy === "run" || !intent.trim()}
onClick={() => void planKeywords()}
disabled={busy === "plan" || busy === "run" || !intent.trim()}
>
{busy === "run" ? t("scout.fetching") : pendingQueue.length ? t("scout.startMore") : t("scout.start")}
{busy === "plan"
? t("scout.planning")
: workshopBrief
? t("scout.replan")
: pendingQueue.length
? t("scout.planKeywords")
: t("scout.planKeywords")}
</Button>
</div>
</div>
</Card>
{workshopBrief ? (
<Card title={t("scout.workshop")}>
<div className="hb-stack">
<p className="text-muted" style={{ margin: 0, fontSize: "var(--hb-text-sm)" }}>
{t("scout.workshopHint")}
</p>
<div className="hb-stack" style={{ gap: "0.5rem" }}>
{workshopTerms.map((term, idx) => (
<div
key={`term-${idx}`}
style={{ display: "flex", gap: "0.5rem", alignItems: "flex-end" }}
>
<div style={{ flex: 1 }}>
<Input
label={`${idx + 1}`}
value={term}
onChange={(e) => updateWorkshopTerm(idx, e.target.value)}
aria-label={`${t("scout.workshop")} ${idx + 1}`}
/>
</div>
<Button
type="button"
variant="ghost"
onClick={() => removeWorkshopTerm(idx)}
disabled={busy === "run"}
>
{t("scout.removeTerm")}
</Button>
</div>
))}
</div>
<div style={{ display: "flex", gap: "0.5rem", alignItems: "flex-end" }}>
<div style={{ flex: 1 }}>
<Input
label={t("scout.addTermPh")}
value={newTerm}
onChange={(e) => setNewTerm(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addWorkshopTerm();
}
}}
/>
</div>
<Button type="button" variant="ghost" onClick={addWorkshopTerm} disabled={!newTerm.trim()}>
{t("scout.addTerm")}
</Button>
</div>
<div className="hb-wizard-actions">
<Button
type="button"
onClick={() => void confirmScan()}
disabled={
busy === "run" ||
busy === "plan" ||
workshopTerms.map((s) => s.trim()).filter(Boolean).length === 0
}
>
{busy === "run" ? t("scout.fetching") : t("scout.confirmScan")}
</Button>
<Button type="button" variant="ghost" onClick={clearWorkshop} disabled={busy === "run"}>
{t("scout.clearWorkshop")}
</Button>
</div>
</div>
</Card>
) : null}
{visibleScanJob ? (
<Card title={t("scout.scanJob")}>
<div className="hb-inline-badges" role="status" aria-live="polite">