improve scout search workflow
This commit is contained in:
parent
031ba7769e
commit
15f42a0c27
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 條完整 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"},
|
||||
"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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
// 痛點回覆(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)) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);舊資料可能沒有 */
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
},
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Reference in New Issue