package viral import ( "strings" "unicode" "haixun-backend/internal/library/placement" ) type topicMatchTerms struct { Anchors []string Terms []string } func missionTopicMatchTerms(seed, label string, hints []string) topicMatchTerms { seenAnchors := map[string]struct{}{} seenTerms := map[string]struct{}{} out := topicMatchTerms{} addTerm := func(term string, anchor bool) { term = normaliseTopicToken(term) if term == "" || isGenericTopicToken(term) { return } if _, ok := seenTerms[term]; !ok { seenTerms[term] = struct{}{} out.Terms = append(out.Terms, term) } if anchor { if _, ok := seenAnchors[term]; !ok { seenAnchors[term] = struct{}{} out.Anchors = append(out.Anchors, term) } } } addSource := func(raw string, anchor bool) { raw = strings.TrimSpace(raw) if raw == "" { return } compact := compactTopicPhrase(raw) addTerm(compact, anchor) for _, token := range splitTopicTokens(raw) { addTerm(token, anchor) } } addSource(seed, true) addSource(label, true) anchorHints := len(out.Anchors) == 0 for _, hint := range hints { addSource(hint, anchorHints) } return out } // missionPostMatchesTopic is intentionally stricter than topicTopicHits: // mission scan candidates must match the post body, not only the query/tag // that produced the crawl result. This prevents crawler/search drift from // admitting high-engagement but unrelated posts. func missionPostMatchesTopic(post placement.ScanCandidate, terms topicMatchTerms) bool { text := strings.ToLower(strings.TrimSpace(post.Text)) if len(terms.Anchors) == 0 && len(terms.Terms) == 0 { return text != "" } if text == "" { return false } for _, term := range terms.Anchors { if term != "" && strings.Contains(text, term) { return true } } hits := 0 for _, term := range terms.Terms { if term != "" && strings.Contains(text, term) { hits++ if hits >= 2 { return true } } } return false } func splitTopicTokens(raw string) []string { return strings.FieldsFunc(raw, func(r rune) bool { if unicode.IsSpace(r) || unicode.IsPunct(r) || unicode.IsSymbol(r) { return true } switch r { case ',', '。', '、', ':', ';', '!', '?', '「', '」', '『', '』', '(', ')', '【', '】': return true default: return false } }) } func compactTopicPhrase(raw string) string { var b strings.Builder for _, r := range raw { if unicode.IsSpace(r) || unicode.IsPunct(r) || unicode.IsSymbol(r) { continue } b.WriteRune(r) } return b.String() } func normaliseTopicToken(raw string) string { token := strings.ToLower(strings.TrimSpace(raw)) if token == "" { return "" } token = strings.Trim(token, "##,,.。::;;!!??()()[]【】\"'「」『』") if len([]rune(token)) < 2 { return "" } return token } func isGenericTopicToken(token string) bool { switch token { case "分享", "心得", "推薦", "請問", "求助", "問題", "方法", "技巧", "經驗", "熱門", "話題", "最近", "大家", "有人", "可以", "如何", "怎麼", "為什麼", "品質", "閱讀", "更多", "多閱讀", "老公", "老婆", "男友", "女友": return true default: return false } }