292 lines
7.2 KiB
Go
292 lines
7.2 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
|
||
// 一則長句最多收成幾組可搜短詞,避免滑窗把訂閱或需求地圖塞滿半截字。
|
||
MaxSearchableVariantsPerTerm = 3
|
||
MaxSearchableVariants = 8
|
||
)
|
||
|
||
// 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
|
||
}
|
||
|
||
var searchFillers = []string{
|
||
"怎麼辦", "求推薦", "有沒有人", "有人知道", "請問一下", "請問",
|
||
"想問", "想找", "有沒有", "可以嗎", "好不好",
|
||
}
|
||
|
||
// SearchableTermVariants turns a user/product phrase into Threads-searchable queries.
|
||
// If the input already passes IsThreadsSearchable it is the only result.
|
||
// Longer phrases are shortened: adjacent token pairs, filler tails stripped,
|
||
// then first/last 2–4 CJK windows. include=false keeps the broader exclude length.
|
||
func SearchableTermVariants(raw string, include bool) []string {
|
||
raw = NormalizeSearchTerm(raw)
|
||
if raw == "" {
|
||
return nil
|
||
}
|
||
seen := map[string]bool{}
|
||
out := make([]string, 0, MaxSearchableVariants)
|
||
add := func(term string) {
|
||
term = NormalizeSearchTerm(term)
|
||
if term == "" || seen[strings.ToLower(term)] {
|
||
return
|
||
}
|
||
if include {
|
||
if !IsThreadsSearchable(term) {
|
||
return
|
||
}
|
||
} else if n := utf8.RuneCountInString(term); n < MinTermLen || n > MaxTermLen {
|
||
return
|
||
}
|
||
seen[strings.ToLower(term)] = true
|
||
out = append(out, term)
|
||
}
|
||
|
||
if include && IsThreadsSearchable(raw) {
|
||
add(raw)
|
||
return out
|
||
}
|
||
if !include {
|
||
add(raw)
|
||
if len(out) > 0 {
|
||
return out
|
||
}
|
||
}
|
||
|
||
tokens := strings.Fields(raw)
|
||
if len(tokens) > MaxThreadsTokens {
|
||
for i := 0; i+1 < len(tokens) && len(out) < MaxSearchableVariants; i++ {
|
||
add(tokens[i] + " " + tokens[i+1])
|
||
}
|
||
for _, tok := range tokens {
|
||
if len(out) >= MaxSearchableVariants {
|
||
break
|
||
}
|
||
for _, v := range SearchableTermVariants(tok, include) {
|
||
add(v)
|
||
if len(out) >= MaxSearchableVariants {
|
||
break
|
||
}
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
parts := splitSearchParts(raw)
|
||
if len(parts) == 0 {
|
||
parts = []string{raw}
|
||
}
|
||
for _, part := range parts {
|
||
if len(out) >= MaxSearchableVariants {
|
||
break
|
||
}
|
||
if include && IsThreadsSearchable(part) {
|
||
add(part)
|
||
continue
|
||
}
|
||
stripped := stripSearchFillers(part)
|
||
if stripped != "" && stripped != part {
|
||
add(stripped)
|
||
}
|
||
runes := []rune(stripped)
|
||
if len(runes) == 0 {
|
||
runes = []rune(part)
|
||
}
|
||
if len(runes) == 5 {
|
||
add(string(runes[:3]))
|
||
add(string(runes[3:]))
|
||
}
|
||
for _, width := range []int{4, 3, 2} {
|
||
if len(runes) < width {
|
||
continue
|
||
}
|
||
add(string(runes[:width]))
|
||
if len(runes) > width {
|
||
add(string(runes[len(runes)-width:]))
|
||
}
|
||
}
|
||
if len(out) >= 3 {
|
||
continue
|
||
}
|
||
for width := 4; width >= 2; width-- {
|
||
if len(runes) < width {
|
||
continue
|
||
}
|
||
for start := 0; start+width <= len(runes) && len(out) < MaxSearchableVariants; start++ {
|
||
add(string(runes[start : start+width]))
|
||
}
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// ExpandSearchTerms keeps already-searchable include terms and shortens the rest.
|
||
// Used when persisting a watch and when fanning out a sweep.
|
||
func ExpandSearchTerms(terms []string, max int) []string {
|
||
if max <= 0 {
|
||
max = MaxWatchTerms
|
||
}
|
||
out := make([]string, 0, max)
|
||
seen := map[string]bool{}
|
||
add := func(term string) bool {
|
||
term = strings.ToLower(NormalizeSearchTerm(term))
|
||
if term == "" || !IsThreadsSearchable(term) || seen[term] {
|
||
return len(out) < max
|
||
}
|
||
seen[term] = true
|
||
out = append(out, term)
|
||
return len(out) < max
|
||
}
|
||
for _, raw := range terms {
|
||
raw = NormalizeSearchTerm(raw)
|
||
if raw == "" {
|
||
continue
|
||
}
|
||
if IsThreadsSearchable(raw) {
|
||
if !add(raw) {
|
||
return out
|
||
}
|
||
continue
|
||
}
|
||
for i, v := range SearchableTermVariants(raw, true) {
|
||
if i >= MaxSearchableVariantsPerTerm {
|
||
break
|
||
}
|
||
if !add(v) {
|
||
return out
|
||
}
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
func stripSearchFillers(s string) string {
|
||
for _, filler := range searchFillers {
|
||
s = strings.ReplaceAll(s, filler, "")
|
||
}
|
||
return strings.Trim(s, " ,,、。!?!?::的了嗎呢啊喔唷")
|
||
}
|
||
|
||
func splitSearchParts(raw string) []string {
|
||
var b strings.Builder
|
||
parts := make([]string, 0, 4)
|
||
flush := func() {
|
||
if value := strings.TrimSpace(b.String()); value != "" {
|
||
parts = append(parts, value)
|
||
}
|
||
b.Reset()
|
||
}
|
||
for _, r := range raw {
|
||
if unicode.IsLetter(r) || unicode.IsDigit(r) || unicode.Is(unicode.Han, r) || unicode.Is(unicode.Hiragana, r) || unicode.Is(unicode.Katakana, r) {
|
||
b.WriteRune(r)
|
||
} else {
|
||
flush()
|
||
}
|
||
}
|
||
flush()
|
||
return parts
|
||
}
|