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 }