package usecase import ( "crypto/sha256" "encoding/hex" "net/url" "strconv" "strings" "unicode/utf8" "apps/backend/internal/module/scout/domain" ) type classifiedPost struct { classification string score int 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 { 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 { seen := make(map[string]struct{}) var out []string for _, group := range groups { for _, term := range group { term = strings.Join(strings.Fields(strings.TrimSpace(term)), " ") key := strings.ToLower(term) if term == "" { continue } if _, ok := seen[key]; ok { continue } seen[key] = struct{}{} out = append(out, term) } } 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) if hasAny(lower, "giveaway", "抽獎", "follow for follow", "互追", "crypto", "賺錢") { return classifiedPost{domain.ClassificationNoise, 0, "noise signal"} } if mode == domain.ModeActivity { return classifyActivity(lower, signals) } if hasAny(lower, "dm me", "私訊我", "服務洽詢", "立即購買", "限時優惠", "團購", "業配") { return scored(domain.ClassificationProviderOffer, 35, signals, "provider-offer signal") } if hasAny(lower, "推薦", "求推", "有沒有推薦", "any recommendation", "what do you recommend") { return scored(domain.ClassificationSeekingRecommendation, 60, signals, "recommendation signal") } if strings.Contains(lower, "?") || strings.Contains(lower, "?") || hasAny(lower, "怎麼", "如何", "請問", "求助", "help") { return scored(domain.ClassificationSeekingHelp, 55, signals, "help-seeking signal") } return scored(domain.ClassificationDiscussion, 40, signals, "discussion signal") } func classifyActivity(text string, signals []string) classifiedPost { if hasAny(text, "dm me", "私訊我", "服務洽詢", "立即購買", "限時優惠", "團購", "業配") { return scored(domain.ClassificationProviderOffer, 35, signals, "provider-offer signal; recency unavailable (neutral)") } if strings.Contains(text, "?") || strings.Contains(text, "?") || hasAny(text, "請問", "怎麼", "如何", "有人知道", "求") { return scored(domain.ClassificationAsking, 60, signals, "asking signal; recency unavailable (neutral)") } if hasAny(text, "活動", "event", "開幕", "launch", "發布", "報名", "登記", "公告") { return scored(domain.ClassificationAnnouncement, 50, signals, "announcement signal; recency unavailable (neutral)") } return scored(domain.ClassificationDiscussion, 40, signals, "discussion signal; recency unavailable (neutral)") } func scored(classification string, base int, signals []string, label string) classifiedPost { score := base + len(signals)*15 if score > 100 { score = 100 } reason := label if len(signals) > 0 { reason += "; matched: " + strings.Join(signals, ", ") } return classifiedPost{classification, score, reason} } func matchedSignals(text string, terms []string) []string { var signals []string for _, term := range dedupeTerms(terms) { if strings.Contains(text, strings.ToLower(term)) { signals = append(signals, term) } } return signals } func hasAny(text string, signals ...string) bool { for _, signal := range signals { if strings.Contains(text, signal) { return true } } return false } func canonicalPermalink(raw string) string { u, err := url.Parse(strings.TrimSpace(raw)) if err != nil || u.Host == "" { return "" } u.Scheme = "https" u.Host = strings.ToLower(u.Host) u.RawQuery = "" u.Fragment = "" u.Path = strings.TrimRight(u.Path, "/") return u.String() } func permalinkID(ownerUID int64, permalink string) string { sum := sha256.Sum256([]byte(strings.TrimSpace(permalink) + "|" + formatOwnerUID(ownerUID))) return "sp_" + hex.EncodeToString(sum[:])[:20] } func formatOwnerUID(ownerUID int64) string { return strconv.FormatInt(ownerUID, 10) }