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

292 lines
7.2 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
// 一則長句最多收成幾組可搜短詞,避免滑窗把訂閱或需求地圖塞滿半截字。
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: 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
}
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 24 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
}