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) } if brief.Mode == domain.ModeProvider { return planProviderTerms(brief.Pains, brief.Tags) } if brief.Mode == domain.ModeDemand { return planDemandTerms(brief.Pains) } 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) } func planDemandTerms(pains []string) []string { pains = compactProviderTerms(pains) var queries []string for _, pain := range pains { queries = append(queries, pain+" 怎麼辦", pain+" 推薦", pain+" 有人也這樣嗎") } return capTerms(dedupeTerms(queries), 6) } func planProviderTerms(pains, capabilities []string) []string { pains = compactProviderTerms(pains) capabilities = compactProviderTerms(capabilities) var queries []string for _, pain := range pains { for _, capability := range capabilities { if strings.EqualFold(pain, capability) { queries = append(queries, capability+" 推薦", capability+" 專業") continue } if utf8.RuneCountInString(pain)+utf8.RuneCountInString(capability)+1 <= 24 { queries = append(queries, pain+" "+capability) } } queries = append(queries, pain+" 推薦") } for _, capability := range capabilities { queries = append(queries, capability+" 推薦", capability+" 專業") } return capTerms(dedupeTerms(queries), 8) } func compactProviderTerms(terms []string) []string { var out []string for _, term := range terms { term = strings.Join(strings.Fields(strings.TrimSpace(term)), " ") for _, filler := range []string{"不知道怎麼辦", "怎麼辦", "有沒有推薦", "求推薦", "請問", "想問", "我想找", "需要"} { term = strings.ReplaceAll(term, filler, "") } term = strings.Trim(term, " ,,、。!?!?::") if utf8.RuneCountInString(term) > 18 { term = truncateRunes(term, 18) } if term != "" { out = append(out, term) } } return filterSeekablePains(out) } // 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 classifyProvider(text string, pains, capabilities, excludes []string) classifiedPost { lower := strings.ToLower(text) if hasMatchingTerm(lower, excludes) { return classifiedPost{domain.ClassificationNoise, 0, "same-category exclusion"} } if hasAny(lower, "dm me", "私訊我", "服務洽詢", "立即購買", "限時優惠", "團購", "業配") { return classifiedPost{domain.ClassificationNoise, 0, "sales signal"} } matchedPains := matchedSignals(lower, pains) matchedCapabilities := matchedSignals(lower, capabilities) if len(matchedPains) == 0 || len(matchedCapabilities) == 0 { return classifiedPost{domain.ClassificationNoise, 0, "missing pain or capability evidence"} } base := 65 classification := domain.ClassificationProviderDirect reason := "pain and capability evidence" if hasAny(lower, "推薦", "介紹", "找", "口碑") { base = 75 classification = domain.ClassificationProviderRecommended reason = "recommended provider evidence" } else if !hasAny(lower, "案例", "專業", "預約", "諮詢", "服務", "協助", "聯絡", "工作室", "診所", "顧問") { return classifiedPost{domain.ClassificationNoise, 0, "missing provider proof"} } signals := append(matchedPains, matchedCapabilities...) return scored(classification, base, signals, reason) } func classifyDemand(text string, pains, excludes []string) classifiedPost { lower := strings.ToLower(text) if hasMatchingTerm(lower, excludes) || hasAny(lower, "dm me", "私訊我", "服務洽詢", "立即購買", "限時優惠", "團購", "業配") { return classifiedPost{domain.ClassificationNoise, 0, "excluded sales or category signal"} } signals := matchedSignals(lower, pains) if len(signals) == 0 { return classifiedPost{domain.ClassificationNoise, 0, "missing pain evidence"} } if hasAny(lower, "推薦", "求推", "有沒有推薦", "有人也", "哪裡", "找不到") { return scored(domain.ClassificationSeekingRecommendation, 75, signals, "pain and recommendation signal") } if strings.Contains(lower, "?") || strings.Contains(lower, "?") || hasAny(lower, "怎麼", "如何", "請問", "求助", "help", "沒用", "無效", "失敗", "困擾") { return scored(domain.ClassificationSeekingHelp, 70, signals, "pain and help signal") } return classifiedPost{domain.ClassificationNoise, 0, "pain mentioned without demand signal"} } func hasMatchingTerm(text string, terms []string) bool { return len(matchedSignals(text, terms)) > 0 } 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 text = normalizeSignal(text) for _, term := range dedupeTerms(terms) { if normalized := normalizeSignal(term); normalized != "" && strings.Contains(text, normalized) { signals = append(signals, term) } } return signals } func normalizeSignal(text string) string { text = strings.ToLower(text) for _, filler := range []string{"一直", "真的", "有點", "又", "很", "都"} { text = strings.ReplaceAll(text, filler, "") } return strings.Join(strings.Fields(text), "") } 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) }