thread-master/apps/backend/internal/module/radar/domain/term.go

112 lines
3.0 KiB
Go
Raw Normal View History

2026-08-09 07:57:35 +00:00
package domain
import (
"strings"
"unicode"
"unicode/utf8"
)
// Threads 搜尋短詞硬約束(中文斷詞差、長字串常查無結果)。
// 一組查詢 = 最多 2 個 token半形空格分隔中文 token 24 字;整組去掉空格後 ≤12 字元。
const (
MaxThreadsTokens = 2
MinCJKTokenRunes = 2
MaxCJKTokenRunes = 4
MaxThreadsTermRunes = 12 // 去掉空白後
MaxExploreTerms = 6
)
// NormalizeSearchTerm trims, converts full-width spaces to half-width, and collapses whitespace.
func NormalizeSearchTerm(raw string) string {
s := strings.ReplaceAll(raw, "\u3000", " ")
return strings.Join(strings.Fields(strings.TrimSpace(s)), " ")
}
// IsThreadsSearchable reports whether a (already-normalized or raw) term meets Threads short-term rules.
//
// Rules:
// - after normalize: non-empty
// - ≤2 tokens (space-separated)
// - CJK tokens: 24 runes; Latin tokens: 212 runes (single token)
// - total runes without spaces ≤12
// - no punctuation, quotes, boolean operators, emoji, or #
func IsThreadsSearchable(term string) bool {
term = NormalizeSearchTerm(term)
if term == "" {
return false
}
// total runes without spaces
compact := strings.ReplaceAll(term, " ", "")
if utf8.RuneCountInString(compact) > MaxThreadsTermRunes {
return false
}
tokens := strings.Fields(term)
if len(tokens) == 0 || len(tokens) > MaxThreadsTokens {
return false
}
for _, tok := range tokens {
if !isAllowedToken(tok) {
return false
}
}
return true
}
func isAllowedToken(tok string) bool {
if tok == "" {
return false
}
// reject common boolean / operators whole-token
upper := strings.ToUpper(tok)
switch upper {
case "AND", "OR", "NOT":
return false
}
hasCJK := false
hasLetter := false
for _, r := range tok {
if r == '#' || r == '"' || r == '\'' || r == '「' || r == '」' || r == '『' || r == '』' {
return false
}
if r == '-' || r == '+' || r == '*' || r == '(' || r == ')' || r == '|' || r == '&' {
return false
}
if unicode.IsPunct(r) || unicode.IsSymbol(r) {
return false
}
// emoji / other symbols often in Symbol or So; also catch common ranges
if r >= 0x1F300 && r <= 0x1FAFF {
return false
}
if r >= 0x2600 && r <= 0x27BF {
return false
}
if isCJK(r) {
hasCJK = true
continue
}
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
hasLetter = true
continue
}
// allow middle dot / common CJK connector? no — keep strict
return false
}
n := utf8.RuneCountInString(tok)
if hasCJK {
// mixed CJK+latin in one token: count as CJK rule by total length
return n >= MinCJKTokenRunes && n <= MaxCJKTokenRunes
}
if hasLetter {
return n >= 2 && n <= MaxThreadsTermRunes
}
return false
}
func isCJK(r rune) bool {
return unicode.Is(unicode.Han, r) ||
unicode.Is(unicode.Hiragana, r) ||
unicode.Is(unicode.Katakana, r) ||
(r >= 0x3000 && r <= 0x303F) // CJK punctuation block — treated as CJK char class but punct rejected above
}