112 lines
3.0 KiB
Go
112 lines
3.0 KiB
Go
package domain
|
||
|
||
import (
|
||
"strings"
|
||
"unicode"
|
||
"unicode/utf8"
|
||
)
|
||
|
||
// Threads 搜尋短詞硬約束(中文斷詞差、長字串常查無結果)。
|
||
// 一組查詢 = 最多 2 個 token(半形空格分隔);中文 token 2–4 字;整組去掉空格後 ≤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: 2–4 runes; Latin tokens: 2–12 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
|
||
}
|