2026-07-13 08:59:13 +00:00
|
|
|
|
package usecase
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"crypto/sha256"
|
|
|
|
|
|
"encoding/hex"
|
|
|
|
|
|
"net/url"
|
|
|
|
|
|
"strconv"
|
|
|
|
|
|
"strings"
|
2026-08-03 07:58:07 +00:00
|
|
|
|
"unicode/utf8"
|
2026-07-13 08:59:13 +00:00
|
|
|
|
|
|
|
|
|
|
"apps/backend/internal/module/scout/domain"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
type classifiedPost struct {
|
|
|
|
|
|
classification string
|
|
|
|
|
|
score int
|
|
|
|
|
|
reason string
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-03 07:58:07 +00:00
|
|
|
|
// 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.
|
2026-07-13 08:59:13 +00:00
|
|
|
|
func planScanTerms(brief *domain.RunBrief) []string {
|
2026-08-03 07:58:07 +00:00
|
|
|
|
if brief == nil {
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
if brief.Mode == domain.ModeActivity {
|
|
|
|
|
|
return capTerms(dedupeTerms([]string{brief.Intent}, tokenizeIntent(brief.Intent)), 6)
|
|
|
|
|
|
}
|
2026-08-06 14:16:49 +00:00
|
|
|
|
if brief.Mode == domain.ModeProvider {
|
|
|
|
|
|
return planProviderTerms(brief.Pains, brief.Tags)
|
|
|
|
|
|
}
|
|
|
|
|
|
if brief.Mode == domain.ModeDemand {
|
|
|
|
|
|
return planDemandTerms(brief.Pains)
|
|
|
|
|
|
}
|
2026-08-03 07:58:07 +00:00
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-06 14:16:49 +00:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-03 07:58:07 +00:00
|
|
|
|
// 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]
|
2026-07-13 08:59:13 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-03 07:58:07 +00:00
|
|
|
|
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])
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-13 08:59:13 +00:00
|
|
|
|
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")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-06 14:16:49 +00:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-13 08:59:13 +00:00
|
|
|
|
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
|
2026-08-06 14:16:49 +00:00
|
|
|
|
text = normalizeSignal(text)
|
2026-07-13 08:59:13 +00:00
|
|
|
|
for _, term := range dedupeTerms(terms) {
|
2026-08-06 14:16:49 +00:00
|
|
|
|
if normalized := normalizeSignal(term); normalized != "" && strings.Contains(text, normalized) {
|
2026-07-13 08:59:13 +00:00
|
|
|
|
signals = append(signals, term)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return signals
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-06 14:16:49 +00:00
|
|
|
|
func normalizeSignal(text string) string {
|
|
|
|
|
|
text = strings.ToLower(text)
|
|
|
|
|
|
for _, filler := range []string{"一直", "真的", "有點", "又", "很", "都"} {
|
|
|
|
|
|
text = strings.ReplaceAll(text, filler, "")
|
|
|
|
|
|
}
|
|
|
|
|
|
return strings.Join(strings.Fields(text), "")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-13 08:59:13 +00:00
|
|
|
|
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)
|
|
|
|
|
|
}
|