improve scout search workflow
This commit is contained in:
parent
031ba7769e
commit
15f42a0c27
|
|
@ -270,6 +270,8 @@ type (
|
||||||
ScanPath string `json:"scan_path,optional"`
|
ScanPath string `json:"scan_path,optional"`
|
||||||
Classification string `json:"classification,optional"`
|
Classification string `json:"classification,optional"`
|
||||||
Permalink string `json:"permalink,optional"`
|
Permalink string `json:"permalink,optional"`
|
||||||
|
// 原文發文時間(unix nanoseconds);未知時可為 0
|
||||||
|
PostedAt int64 `json:"posted_at,optional"`
|
||||||
CreatedAt int64 `json:"created_at"`
|
CreatedAt int64 `json:"created_at"`
|
||||||
}
|
}
|
||||||
ScoutPostListData {
|
ScoutPostListData {
|
||||||
|
|
|
||||||
|
|
@ -114,7 +114,9 @@ type Post struct {
|
||||||
ThemeKey string `bson:"theme_key,omitempty" json:"theme_key,omitempty"`
|
ThemeKey string `bson:"theme_key,omitempty" json:"theme_key,omitempty"`
|
||||||
ThemeLabel string `bson:"theme_label,omitempty" json:"theme_label,omitempty"`
|
ThemeLabel string `bson:"theme_label,omitempty" json:"theme_label,omitempty"`
|
||||||
ScanPath string `bson:"scan_path,omitempty" json:"scan_path,omitempty"` // api|crawler
|
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 {
|
type Homework struct {
|
||||||
|
|
|
||||||
|
|
@ -136,7 +136,11 @@ func (s *MonStore) ListPosts(ctx context.Context, ownerUID int64, brandID string
|
||||||
filter["brand_id"] = brandID
|
filter["brand_id"] = brandID
|
||||||
}
|
}
|
||||||
var list []*domain.Post
|
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
|
return list, err
|
||||||
}
|
}
|
||||||
func (s *MonStore) DeletePost(ctx context.Context, id string) error {
|
func (s *MonStore) DeletePost(ctx context.Context, id string) error {
|
||||||
|
|
|
||||||
|
|
@ -21,9 +21,13 @@ type ThreadSearchProvider interface {
|
||||||
}
|
}
|
||||||
|
|
||||||
type ThreadSearchResult struct {
|
type ThreadSearchResult struct {
|
||||||
URL string
|
URL string
|
||||||
Title string
|
Title string
|
||||||
Snippet 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.
|
// 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")
|
return nil, fmt.Errorf("exa Threads search is not configured")
|
||||||
}
|
}
|
||||||
if limit <= 0 {
|
if limit <= 0 {
|
||||||
limit = 5
|
limit = 10
|
||||||
}
|
}
|
||||||
if limit > 20 {
|
if limit > 20 {
|
||||||
limit = 20
|
limit = 20
|
||||||
}
|
}
|
||||||
query := strings.Join(nonEmptyTerms(terms), " ")
|
// 單次呼叫:建議只傳 1 條完整 query(fan-out 在上層)
|
||||||
if query == "" {
|
clean := nonEmptyTerms(terms)
|
||||||
|
if len(clean) == 0 {
|
||||||
return nil, fmt.Errorf("exa Threads search query required")
|
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{
|
payload, err := json.Marshal(map[string]any{
|
||||||
"query": query,
|
"query": query,
|
||||||
"type": "auto",
|
"type": "auto",
|
||||||
"numResults": limit,
|
"numResults": limit,
|
||||||
"includeDomains": []string{"threads.net", "threads.com"},
|
"includeDomains": []string{"threads.net", "threads.com"},
|
||||||
|
"startPublishedDate": startPublished,
|
||||||
"contents": map[string]any{
|
"contents": map[string]any{
|
||||||
"highlights": true,
|
"highlights": true,
|
||||||
"text": map[string]any{"maxCharacters": 400},
|
"text": map[string]any{"maxCharacters": 400},
|
||||||
|
|
@ -107,10 +121,11 @@ func (p *ExaThreadsProvider) SearchThreads(ctx context.Context, terms []string,
|
||||||
|
|
||||||
var response struct {
|
var response struct {
|
||||||
Results []struct {
|
Results []struct {
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
Highlights []string `json:"highlights"`
|
PublishedDate string `json:"publishedDate"`
|
||||||
Text string `json:"text"`
|
Highlights []string `json:"highlights"`
|
||||||
|
Text string `json:"text"`
|
||||||
} `json:"results"`
|
} `json:"results"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(raw, &response); err != nil {
|
if err := json.Unmarshal(raw, &response); err != nil {
|
||||||
|
|
@ -129,11 +144,37 @@ func (p *ExaThreadsProvider) SearchThreads(ctx context.Context, terms []string,
|
||||||
if snippet == "" {
|
if snippet == "" {
|
||||||
snippet = strings.TrimSpace(hit.Title)
|
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
|
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 {
|
func isThreadsURL(raw string) bool {
|
||||||
u, err := url.Parse(raw)
|
u, err := url.Parse(raw)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"net/url"
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"apps/backend/internal/module/scout/domain"
|
"apps/backend/internal/module/scout/domain"
|
||||||
)
|
)
|
||||||
|
|
@ -16,8 +17,86 @@ type classifiedPost struct {
|
||||||
reason string
|
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 {
|
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 {
|
func dedupeTerms(groups ...[]string) []string {
|
||||||
|
|
@ -40,6 +119,27 @@ func dedupeTerms(groups ...[]string) []string {
|
||||||
return out
|
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 {
|
func classifyPost(mode, text string, terms []string) classifiedPost {
|
||||||
lower := strings.ToLower(text)
|
lower := strings.ToLower(text)
|
||||||
signals := matchedSignals(lower, terms)
|
signals := matchedSignals(lower, terms)
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,9 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
type capturedSearchProvider struct {
|
type capturedSearchProvider struct {
|
||||||
terms []string
|
// queries records each fan-out call (one term per call)
|
||||||
hits []usecase.ThreadSearchResult
|
queries []string
|
||||||
|
hits []usecase.ThreadSearchResult
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPrepareBriefPlansDeduplicatedTermsFromAllInputs(t *testing.T) {
|
func TestPrepareBriefPlansDeduplicatedTermsFromAllInputs(t *testing.T) {
|
||||||
|
|
@ -26,21 +27,25 @@ func TestPrepareBriefPlansDeduplicatedTermsFromAllInputs(t *testing.T) {
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
brief, err := svc.PrepareBrief(context.Background(), 1, "敏感肌保養", brand.ID, product.ID, "value", false)
|
brief, err := svc.PrepareBrief(context.Background(), 1, "敏感肌保養", brand.ID, product.ID, "value", false)
|
||||||
require.NoError(t, err)
|
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) {
|
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
|
return p.hits, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPlannerProductSignalsAndDeterministicPersistence(t *testing.T) {
|
func TestPlannerProductSignalsAndDeterministicPersistence(t *testing.T) {
|
||||||
provider := &capturedSearchProvider{hits: []usecase.ThreadSearchResult{
|
provider := &capturedSearchProvider{hits: []usecase.ThreadSearchResult{
|
||||||
{URL: "https://www.threads.net/@a/post/1?utm_source=test", Snippet: "請問敏感肌怎麼舒緩?"},
|
{URL: "https://www.threads.net/@a/post/1?utm_source=test", Snippet: "請問敏感肌怎麼舒緩?", PublishedAt: 300},
|
||||||
{URL: "https://www.threads.net/@b/post/2", Snippet: "敏感肌保養有沒有推薦?"},
|
{URL: "https://www.threads.net/@b/post/2", Snippet: "敏感肌保養有沒有推薦?", PublishedAt: 200},
|
||||||
{URL: "https://www.threads.net/@c/post/3", Snippet: "敏感肌服務洽詢,現在限時優惠"},
|
{URL: "https://www.threads.net/@c/post/3", Snippet: "敏感肌服務洽詢,現在限時優惠", PublishedAt: 100},
|
||||||
{URL: "https://www.threads.net/@d/post/4", Snippet: "分享敏感肌保養的使用心得"},
|
{URL: "https://www.threads.net/@d/post/4", Snippet: "分享敏感肌保養的使用心得", PublishedAt: 50},
|
||||||
{URL: "https://www.threads.net/@e/post/5", Snippet: "敏感肌抽獎,互追拿好禮"},
|
{URL: "https://www.threads.net/@e/post/5", Snippet: "敏感肌抽獎,互追拿好禮", PublishedAt: 10},
|
||||||
}}
|
}}
|
||||||
store := repository.NewMemory()
|
store := repository.NewMemory()
|
||||||
svc := usecase.New(store)
|
svc := usecase.New(store)
|
||||||
|
|
@ -53,8 +58,13 @@ func TestPlannerProductSignalsAndDeterministicPersistence(t *testing.T) {
|
||||||
|
|
||||||
posts, err := svc.RunScanFromBrief(context.Background(), 42, brief)
|
posts, err := svc.RunScanFromBrief(context.Background(), 42, brief)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, []string{"敏感肌", "保養", "使用情境", "敏感肌保養"}, provider.terms)
|
// 多 query 扇出:每條 scan_term 各搜一次
|
||||||
require.Len(t, posts, 4)
|
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{}
|
byClass := map[string]*domain.Post{}
|
||||||
for _, post := range posts {
|
for _, post := range posts {
|
||||||
byClass[post.Classification] = post
|
byClass[post.Classification] = post
|
||||||
|
|
@ -62,10 +72,10 @@ func TestPlannerProductSignalsAndDeterministicPersistence(t *testing.T) {
|
||||||
require.Equal(t, post.Permalink, post.ExternalID)
|
require.Equal(t, post.Permalink, post.ExternalID)
|
||||||
require.InDelta(t, 0, post.Score, 100)
|
require.InDelta(t, 0, post.Score, 100)
|
||||||
require.NotEmpty(t, post.MatchReason)
|
require.NotEmpty(t, post.MatchReason)
|
||||||
|
require.NotEqual(t, domain.ClassificationProviderOffer, post.Classification)
|
||||||
}
|
}
|
||||||
require.Contains(t, byClass, domain.ClassificationSeekingHelp)
|
require.Contains(t, byClass, domain.ClassificationSeekingHelp)
|
||||||
require.Contains(t, byClass, domain.ClassificationSeekingRecommendation)
|
require.Contains(t, byClass, domain.ClassificationSeekingRecommendation)
|
||||||
require.Contains(t, byClass, domain.ClassificationProviderOffer)
|
|
||||||
require.Contains(t, byClass, domain.ClassificationDiscussion)
|
require.Contains(t, byClass, domain.ClassificationDiscussion)
|
||||||
require.Contains(t, byClass[domain.ClassificationSeekingHelp].MatchReason, "敏感肌")
|
require.Contains(t, byClass[domain.ClassificationSeekingHelp].MatchReason, "敏感肌")
|
||||||
require.Equal(t, "https://www.threads.net/@a/post/1", byClass[domain.ClassificationSeekingHelp].Permalink)
|
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)
|
require.Equal(t, byClass[domain.ClassificationSeekingHelp].ID, again[0].ID)
|
||||||
persisted, err := svc.ListPosts(context.Background(), 42, "")
|
persisted, err := svc.ListPosts(context.Background(), 42, "")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Len(t, persisted, 4)
|
require.Len(t, persisted, 3)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPlannerActivityClassifiesWithNeutralRecency(t *testing.T) {
|
func TestPlannerActivityClassifiesWithNeutralRecency(t *testing.T) {
|
||||||
|
|
|
||||||
|
|
@ -211,9 +211,10 @@ func (s *Service) ImportProductFromURL(_ context.Context, raw string) (*domain.I
|
||||||
}
|
}
|
||||||
return &domain.ImportDraft{
|
return &domain.ImportDraft{
|
||||||
Label: label,
|
Label: label,
|
||||||
ProductContext: "從 " + raw + " 推估的產品情境(live 可接真爬頁)",
|
// 痛點/標籤要用「飼主正在煩惱的話」,不是產品賣點;匯入後請再改成真痛點
|
||||||
PainPoints: []string{"找不到適合的", "價格猶豫", "不確定是否適合自己"},
|
ProductContext: "從 " + raw + " 推估的產品情境(請改寫成實際賣點與使用場景)",
|
||||||
MatchTags: []string{"好物", "推薦", "踩雷"},
|
PainPoints: []string{"不知道怎麼選", "用了沒感覺", "擔心不適合/有副作用"},
|
||||||
|
MatchTags: []string{"求推薦", "有沒有人用過", "怎麼辦"},
|
||||||
PlacementURL: raw,
|
PlacementURL: raw,
|
||||||
SourceNote: "importProductFromUrl · " + host,
|
SourceNote: "importProductFromUrl · " + host,
|
||||||
}, nil
|
}, nil
|
||||||
|
|
@ -250,11 +251,13 @@ func (s *Service) PrepareBrief(ctx context.Context, ownerUID int64, intent, bran
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(brief.Pains) == 0 {
|
if len(brief.Pains) == 0 {
|
||||||
brief.Pains = []string{intent, "相關困擾"}
|
// 預設用「求助/求推」語感,避免把產品賣點當搜尋詞
|
||||||
|
brief.Pains = []string{intent}
|
||||||
}
|
}
|
||||||
if len(brief.Tags) == 0 {
|
if len(brief.Tags) == 0 {
|
||||||
brief.Tags = tokenize(intent)
|
brief.Tags = tokenize(intent)
|
||||||
}
|
}
|
||||||
|
// periphery 只當作業備註,不進 scan_terms(否則會污染 Exa 查詢)
|
||||||
brief.Periphery = []string{"使用情境", "替代方案", "成分/規格"}
|
brief.Periphery = []string{"使用情境", "替代方案", "成分/規格"}
|
||||||
brief.ScanTerms = planScanTerms(brief)
|
brief.ScanTerms = planScanTerms(brief)
|
||||||
brief.ThemeLabel = truncate(intent, 36)
|
brief.ThemeLabel = truncate(intent, 36)
|
||||||
|
|
@ -307,7 +310,8 @@ func (s *Service) RunScanFromBrief(ctx context.Context, ownerUID int64, brief *d
|
||||||
if brief == nil {
|
if brief == nil {
|
||||||
return nil, fmt.Errorf("%w: nil brief", domain.ErrValidation)
|
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)
|
return nil, fmt.Errorf("%w: need scan_terms", domain.ErrValidation)
|
||||||
}
|
}
|
||||||
path := domain.PathAPI
|
path := domain.PathAPI
|
||||||
|
|
@ -317,33 +321,84 @@ func (s *Service) RunScanFromBrief(ctx context.Context, ownerUID int64, brief *d
|
||||||
devMode = 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 {
|
if devMode {
|
||||||
storageState, err := s.GetCrawlerSessionToken(ctx, ownerUID)
|
storageState, serr := s.GetCrawlerSessionToken(ctx, ownerUID)
|
||||||
if err != nil {
|
if serr != nil {
|
||||||
return nil, domain.ErrNoCrawlerSession
|
return nil, domain.ErrNoCrawlerSession
|
||||||
}
|
}
|
||||||
path = domain.PathCrawler
|
path = domain.PathCrawler
|
||||||
if s.Crawler == nil {
|
if s.Crawler == nil {
|
||||||
return nil, fmt.Errorf("Chrome crawler is not configured")
|
return nil, fmt.Errorf("Chrome crawler is not configured")
|
||||||
}
|
}
|
||||||
hits, err := s.Crawler.SearchChrome(ctx, storageState, brief.ScanTerms, 20)
|
hits, err = fanOutSearch(ctx, terms, perQuery, func(ctx context.Context, q string, limit int) ([]ThreadSearchResult, error) {
|
||||||
if err != nil {
|
return s.Crawler.SearchChrome(ctx, storageState, []string{q}, limit)
|
||||||
return nil, err
|
})
|
||||||
|
} 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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return s.persistSearchHits(ctx, ownerUID, brief, path, hits)
|
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) {
|
func (s *Service) persistSearchHits(ctx context.Context, ownerUID int64, brief *domain.RunBrief, path string, hits []ThreadSearchResult) ([]*domain.Post, error) {
|
||||||
now := domain.NowNano()
|
now := domain.NowNano()
|
||||||
|
// 先依原文發文時間新→舊;無時間的排後面,再以陣列序
|
||||||
|
sortHitsByPostedAt(hits)
|
||||||
out := make([]*domain.Post, 0, len(hits))
|
out := make([]*domain.Post, 0, len(hits))
|
||||||
for i, hit := range hits {
|
for i, hit := range hits {
|
||||||
text := strings.TrimSpace(hit.Snippet)
|
text := strings.TrimSpace(hit.Snippet)
|
||||||
|
|
@ -354,11 +409,25 @@ func (s *Service) persistSearchHits(ctx context.Context, ownerUID int64, brief *
|
||||||
if permalink == "" {
|
if permalink == "" {
|
||||||
continue
|
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)
|
classified := classifyPost(brief.Mode, text+" "+hit.Title, brief.ScanTerms)
|
||||||
if classified.classification == domain.ClassificationNoise {
|
if classified.classification == domain.ClassificationNoise {
|
||||||
continue
|
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{
|
p := &domain.Post{
|
||||||
ID: permalinkID(ownerUID, permalink), ExternalID: permalink, Permalink: permalink,
|
ID: permalinkID(ownerUID, permalink), ExternalID: permalink, Permalink: permalink,
|
||||||
OwnerUID: ownerUID, BrandID: brief.BrandID, Author: authorFromThreadsURL(permalink), Text: text,
|
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,
|
Score: classified.score, Classification: classified.classification, MatchedProductID: brief.ProductID, MatchedProductLabel: brief.ProductLabel,
|
||||||
MatchReason: classified.reason, ScoutMode: brief.Mode, IntentSnippet: brief.Intent,
|
MatchReason: classified.reason, ScoutMode: brief.Mode, IntentSnippet: brief.Intent,
|
||||||
ThemeKey: brief.ThemeKey, ThemeLabel: brief.ThemeLabel, ScanPath: path,
|
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 {
|
if err := s.Repo.SavePost(ctx, p); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
out = append(out, p)
|
out = append(out, p)
|
||||||
}
|
}
|
||||||
|
// 回傳列表:待處理優先不在這裡做,純按發文時間新→舊
|
||||||
|
sortPostsByPostedAt(out)
|
||||||
return out, nil
|
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 {
|
func matchingTerm(text string, terms []string) string {
|
||||||
for _, term := range terms {
|
for _, term := range terms {
|
||||||
if term = strings.TrimSpace(term); term != "" && strings.Contains(strings.ToLower(text), strings.ToLower(term)) {
|
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,
|
Score: p.Score, MatchedProductId: p.MatchedProductID, MatchedProductLabel: p.MatchedProductLabel,
|
||||||
MatchReason: p.MatchReason, ScoutMode: p.ScoutMode, IntentSnippet: p.IntentSnippet,
|
MatchReason: p.MatchReason, ScoutMode: p.ScoutMode, IntentSnippet: p.IntentSnippet,
|
||||||
ThemeKey: p.ThemeKey, ThemeLabel: p.ThemeLabel, ScanPath: p.ScanPath,
|
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"`
|
ScanPath string `json:"scan_path,optional"`
|
||||||
Classification string `json:"classification,optional"`
|
Classification string `json:"classification,optional"`
|
||||||
Permalink string `json:"permalink,optional"`
|
Permalink string `json:"permalink,optional"`
|
||||||
|
PostedAt int64 `json:"posted_at,optional"`
|
||||||
CreatedAt int64 `json:"created_at"`
|
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,
|
scan_path: raw.scan_path != null ? String(raw.scan_path) : undefined,
|
||||||
classification: raw.classification != null ? String(raw.classification) : undefined,
|
classification: raw.classification != null ? String(raw.classification) : undefined,
|
||||||
permalink: raw.permalink != null ? String(raw.permalink) : 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,
|
created_at: raw.created_at != null ? Number(raw.created_at) : undefined,
|
||||||
published_at: raw.published_at != null ? Number(raw.published_at) : undefined,
|
published_at: raw.published_at != null ? Number(raw.published_at) : undefined,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -727,6 +727,8 @@ export type ScoutPost = {
|
||||||
classification?: string;
|
classification?: string;
|
||||||
/** 原始 Threads 貼文連結 */
|
/** 原始 Threads 貼文連結 */
|
||||||
permalink?: string;
|
permalink?: string;
|
||||||
|
/** 原文發文時間(UTC unix nanoseconds);未知時可為 0/省略 */
|
||||||
|
posted_at?: number;
|
||||||
/** 掃描命中的建立時間(UTC unix nanoseconds) */
|
/** 掃描命中的建立時間(UTC unix nanoseconds) */
|
||||||
created_at?: number;
|
created_at?: number;
|
||||||
/** 實際發出回覆的時間(UTC unix nanoseconds);舊資料可能沒有 */
|
/** 實際發出回覆的時間(UTC unix nanoseconds);舊資料可能沒有 */
|
||||||
|
|
|
||||||
|
|
@ -97,11 +97,11 @@ export const zhTW: MessageDict = {
|
||||||
"help.page.studio.tips": "創作頁不直接等同已發佈;真正送出在 Outbox。",
|
"help.page.studio.tips": "創作頁不直接等同已發佈;真正送出在 Outbox。",
|
||||||
|
|
||||||
"help.page.scout.title": "海巡(手動掃場)",
|
"help.page.scout.title": "海巡(手動掃場)",
|
||||||
"help.page.scout.what": "你現在按一次、掃一輪:找痛點/話題對話、寫草稿、回完就結。不是「每天自動來名單」——那是側欄「商機」。",
|
"help.page.scout.what": "先練關鍵字、確認後再搜:找痛點/話題對話、寫草稿、回完就結。不是「每天自動來名單」——那是側欄「商機」。",
|
||||||
"help.page.scout.step1": "設定今日目標與想找的內容,按開始掃一輪。",
|
"help.page.scout.step1": "寫意圖(可選產品),按「產出關鍵字」檢視/增刪 query。",
|
||||||
"help.page.scout.step2": "在佇列選貼文、寫草稿、開原文手動回。",
|
"help.page.scout.step2": "確認後「用這些詞開始搜」,在佇列依發文時間處理。",
|
||||||
"help.page.scout.step3": "值得長期跟進的,按「收進商機」複製到每日商機/名單(不影響這則海巡狀態)。",
|
"help.page.scout.step3": "值得長期跟進的,按「收進商機」複製到每日商機/名單(不影響這則海巡狀態)。",
|
||||||
"help.page.scout.tips": "海巡=出擊掃場;商機=訂閱後每天自動收「正在找你的人」。兩者可並用。",
|
"help.page.scout.tips": "海巡=出擊掃場;商機=訂閱後每天自動收「正在找你的人」。兩者可並用。關鍵字要寫「對方的困擾」,不要寫產品賣點。",
|
||||||
|
|
||||||
"help.page.radar_today.title": "今日商機(自動名單)",
|
"help.page.radar_today.title": "今日商機(自動名單)",
|
||||||
"help.page.radar_today.what": "依你訂的關鍵字,系統每天自動整理的需求名單(高/中/低意向)。和海巡不同:不用每次手動掃。",
|
"help.page.radar_today.what": "依你訂的關鍵字,系統每天自動整理的需求名單(高/中/低意向)。和海巡不同:不用每次手動掃。",
|
||||||
|
|
@ -1412,6 +1412,18 @@ export const zhTW: MessageDict = {
|
||||||
"scout.start": "開始",
|
"scout.start": "開始",
|
||||||
"scout.startMore": "再撈一批",
|
"scout.startMore": "再撈一批",
|
||||||
"scout.fetching": "撈取中…",
|
"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.runs": "海巡批次",
|
||||||
"scout.runCount": "批次({n})",
|
"scout.runCount": "批次({n})",
|
||||||
"scout.runSelectAria": "切換海巡批次",
|
"scout.runSelectAria": "切換海巡批次",
|
||||||
|
|
@ -2409,11 +2421,11 @@ export const en: MessageDict = {
|
||||||
"help.page.studio.tips": "Studio drafts are not published until Outbox succeeds.",
|
"help.page.studio.tips": "Studio drafts are not published until Outbox succeeds.",
|
||||||
|
|
||||||
"help.page.scout.title": "Patrol (manual sweep)",
|
"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.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": "Set today’s goal and what you want to find, then run a scan.",
|
"help.page.scout.step1": "Write your intent (optional product), generate keywords, then edit the queries.",
|
||||||
"help.page.scout.step2": "Pick a post, draft, open the original, reply manually.",
|
"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.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.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.",
|
"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.start": "Start",
|
||||||
"scout.startMore": "Fetch more",
|
"scout.startMore": "Fetch more",
|
||||||
"scout.fetching": "Fetching…",
|
"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.runs": "Patrol batches",
|
||||||
"scout.runCount": "Batch ({n})",
|
"scout.runCount": "Batch ({n})",
|
||||||
"scout.runSelectAria": "Switch patrol batch",
|
"scout.runSelectAria": "Switch patrol batch",
|
||||||
|
|
|
||||||
|
|
@ -69,10 +69,9 @@ export const mobileDockMoreKeys: NavKey[] = [
|
||||||
"jobs",
|
"jobs",
|
||||||
"brands",
|
"brands",
|
||||||
"crm",
|
"crm",
|
||||||
"playbooks",
|
|
||||||
"insights",
|
"insights",
|
||||||
"benchmark",
|
|
||||||
"utm",
|
"utm",
|
||||||
|
// 暫隱藏:playbooks(市集)、benchmark(基準)
|
||||||
];
|
];
|
||||||
|
|
||||||
export type NavGroupKey = "workflow" | "accounts" | "growth";
|
export type NavGroupKey = "workflow" | "accounts" | "growth";
|
||||||
|
|
@ -91,7 +90,8 @@ export const navGroups: NavGroup[] = [
|
||||||
{
|
{
|
||||||
key: "growth",
|
key: "growth",
|
||||||
labelKey: "navGroup.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))}…`;
|
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() {
|
export function ScoutPage() {
|
||||||
const repos = useRepos();
|
const repos = useRepos();
|
||||||
|
|
@ -158,6 +171,11 @@ export function ScoutPage() {
|
||||||
const [crawlerSessionRequired, setCrawlerSessionRequired] = useState(false);
|
const [crawlerSessionRequired, setCrawlerSessionRequired] = useState(false);
|
||||||
const [loadError, setLoadError] = useState("");
|
const [loadError, setLoadError] = useState("");
|
||||||
|
|
||||||
|
/** 關鍵字工坊:prepareBrief 後停在這裡,確認才 scan */
|
||||||
|
const [workshopBrief, setWorkshopBrief] = useState<ScoutRunBrief | null>(null);
|
||||||
|
const [workshopTerms, setWorkshopTerms] = useState<string[]>([]);
|
||||||
|
const [newTerm, setNewTerm] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const t = loadScoutToday();
|
const t = loadScoutToday();
|
||||||
setTodayDone(t.done);
|
setTodayDone(t.done);
|
||||||
|
|
@ -178,7 +196,7 @@ export function ScoutPage() {
|
||||||
setAllProducts(prods);
|
setAllProducts(prods);
|
||||||
setPosts(postList);
|
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]!));
|
if (pending[0]) setActiveRunKey((cur) => cur || postRunKey(pending[0]!));
|
||||||
setLoadError("");
|
setLoadError("");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -251,7 +269,7 @@ export function ScoutPage() {
|
||||||
return posts
|
return posts
|
||||||
.filter((p) => !activeRunKey || postRunKey(p) === activeRunKey)
|
.filter((p) => !activeRunKey || postRunKey(p) === activeRunKey)
|
||||||
.slice()
|
.slice()
|
||||||
.sort((a, b) => Number(isPending(b)) - Number(isPending(a)) || (b.score || 0) - (a.score || 0));
|
.sort(compareQueuePosts);
|
||||||
}, [posts, activeRunKey]);
|
}, [posts, activeRunKey]);
|
||||||
|
|
||||||
const pendingQueue = useMemo(() => runQueue.filter(isPending), [runQueue]);
|
const pendingQueue = useMemo(() => runQueue.filter(isPending), [runQueue]);
|
||||||
|
|
@ -285,10 +303,25 @@ export function ScoutPage() {
|
||||||
if (cancelled()) return;
|
if (cancelled()) return;
|
||||||
setPosts(list);
|
setPosts(list);
|
||||||
const completedThemeKey = scanJobThemeKey || job.ref_id || activeRunKey;
|
const completedThemeKey = scanJobThemeKey || job.ref_id || activeRunKey;
|
||||||
|
// 掃完立刻切到這一批並選第一則,避免還停在舊批次像「沒結果」要手動重整
|
||||||
|
if (completedThemeKey) {
|
||||||
|
setActiveRunKey(completedThemeKey);
|
||||||
|
setValueQueuePage(1);
|
||||||
|
setActivityQueuePage(1);
|
||||||
|
}
|
||||||
const pending = list
|
const pending = list
|
||||||
.filter((p) => postRunKey(p) === completedThemeKey && isPending(p))
|
.filter((p) => (!completedThemeKey || postRunKey(p) === completedThemeKey) && isPending(p))
|
||||||
.sort((a, b) => (b.score || 0) - (a.score || 0));
|
.sort(compareQueuePosts);
|
||||||
|
setCurrentId(pending[0]?.id ?? null);
|
||||||
setMessage(t("scout.scanReady", { n: pending.length }));
|
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") {
|
} else if (job.status === "failed" || job.status === "cancelled") {
|
||||||
setMessage(job.error || t("scout.patrolFail"));
|
setMessage(job.error || t("scout.patrolFail"));
|
||||||
setCrawlerSessionRequired(/crawler.?session|chrome session/i.test(job.error || ""));
|
setCrawlerSessionRequired(/crawler.?session|chrome session/i.test(job.error || ""));
|
||||||
|
|
@ -332,7 +365,7 @@ export function ScoutPage() {
|
||||||
const key = runKey === undefined ? activeRunKey : runKey;
|
const key = runKey === undefined ? activeRunKey : runKey;
|
||||||
let src = (list || posts).filter(isPending);
|
let src = (list || posts).filter(isPending);
|
||||||
if (key) src = src.filter((p) => postRunKey(p) === key);
|
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) {
|
if (!src.length) {
|
||||||
setCurrentId(null);
|
setCurrentId(null);
|
||||||
return;
|
return;
|
||||||
|
|
@ -352,7 +385,7 @@ export function ScoutPage() {
|
||||||
setActivityQueuePage(1);
|
setActivityQueuePage(1);
|
||||||
const pending = posts
|
const pending = posts
|
||||||
.filter((p) => postRunKey(p) === key && isPending(p))
|
.filter((p) => postRunKey(p) === key && isPending(p))
|
||||||
.sort((a, b) => (b.score || 0) - (a.score || 0));
|
.sort(compareQueuePosts);
|
||||||
setCurrentId(pending[0]?.id || null);
|
setCurrentId(pending[0]?.id || null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -395,13 +428,14 @@ export function ScoutPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startPatrol() {
|
/** ① 產出可審關鍵字(不搜尋) */
|
||||||
|
async function planKeywords() {
|
||||||
const text = intent.trim();
|
const text = intent.trim();
|
||||||
if (!text) {
|
if (!text) {
|
||||||
setMessage(purpose === "activity" ? t("scout.needKeyword") : t("scout.needIntent"));
|
setMessage(purpose === "activity" ? t("scout.needKeyword") : t("scout.needIntent"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setBusy("run");
|
setBusy("plan");
|
||||||
setMessage("");
|
setMessage("");
|
||||||
setCrawlerSessionRequired(false);
|
setCrawlerSessionRequired(false);
|
||||||
try {
|
try {
|
||||||
|
|
@ -412,7 +446,6 @@ export function ScoutPage() {
|
||||||
if (purpose === "value" && productId && !selected) {
|
if (purpose === "value" && productId && !selected) {
|
||||||
throw new Error(t("scout.productMissing"));
|
throw new Error(t("scout.productMissing"));
|
||||||
}
|
}
|
||||||
// ① 輕量 brief → 立刻海巡;每次按開始 = 獨立批次(unique theme_key)
|
|
||||||
const brief = await repos.scout.prepareBrief({
|
const brief = await repos.scout.prepareBrief({
|
||||||
intent: text,
|
intent: text,
|
||||||
brandId: purpose === "activity" ? null : selected?.brand_id || null,
|
brandId: purpose === "activity" ? null : selected?.brand_id || null,
|
||||||
|
|
@ -420,16 +453,83 @@ export function ScoutPage() {
|
||||||
purpose,
|
purpose,
|
||||||
deep: false,
|
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 =
|
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_key = newId("run");
|
||||||
const theme_label = `${baseLabel} · ${runTimeSuffix()}`;
|
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({
|
const { job } = await repos.scout.runScanFromBrief(briefSaved);
|
||||||
...briefSaved,
|
|
||||||
scan_terms: briefSaved.scan_terms,
|
|
||||||
});
|
|
||||||
await repos.scout.saveHomework({
|
await repos.scout.saveHomework({
|
||||||
theme_key,
|
theme_key,
|
||||||
theme_label,
|
theme_label,
|
||||||
|
|
@ -438,17 +538,19 @@ export function ScoutPage() {
|
||||||
created_at: nowUnixNano(),
|
created_at: nowUnixNano(),
|
||||||
});
|
});
|
||||||
setHomeworkList(await repos.scout.listHomework());
|
setHomeworkList(await repos.scout.listHomework());
|
||||||
|
setActiveRunKey(theme_key);
|
||||||
|
setCurrentId(null);
|
||||||
|
setValueQueuePage(1);
|
||||||
|
setActivityQueuePage(1);
|
||||||
setScanJob(job);
|
setScanJob(job);
|
||||||
setScanJobThemeKey(theme_key);
|
setScanJobThemeKey(theme_key);
|
||||||
void reloadJobs();
|
void reloadJobs();
|
||||||
setMessage(t("scout.scanQueued", { label: theme_label }));
|
setMessage(t("scout.scanQueued", { label: theme_label }));
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setMessage(e instanceof Error ? e.message : t("scout.patrolFail"));
|
setMessage(e instanceof Error ? e.message : t("scout.patrolFail"));
|
||||||
setCrawlerSessionRequired(
|
setCrawlerSessionRequired(
|
||||||
typeof e === "object" && e !== null && "code" in e && (e as { code?: number }).code === 400061,
|
typeof e === "object" && e !== null && "code" in e && (e as { code?: number }).code === 400061,
|
||||||
);
|
);
|
||||||
setBusy("");
|
|
||||||
} finally {
|
} finally {
|
||||||
setBusy("");
|
setBusy("");
|
||||||
}
|
}
|
||||||
|
|
@ -716,15 +818,90 @@ export function ScoutPage() {
|
||||||
<div className="hb-wizard-actions">
|
<div className="hb-wizard-actions">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void startPatrol()}
|
onClick={() => void planKeywords()}
|
||||||
disabled={busy === "run" || !intent.trim()}
|
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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</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 ? (
|
{visibleScanJob ? (
|
||||||
<Card title={t("scout.scanJob")}>
|
<Card title={t("scout.scanJob")}>
|
||||||
<div className="hb-inline-badges" role="status" aria-live="polite">
|
<div className="hb-inline-badges" role="status" aria-live="polite">
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue