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

112 lines
3.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}