132 lines
3.1 KiB
Go
132 lines
3.1 KiB
Go
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
|
||
}
|
||
}
|