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
|
|||
|
|
}
|