123 lines
3.0 KiB
Go
123 lines
3.0 KiB
Go
|
|
package knowledge
|
||
|
|
|
||
|
|
import (
|
||
|
|
"strings"
|
||
|
|
"unicode/utf8"
|
||
|
|
)
|
||
|
|
|
||
|
|
const (
|
||
|
|
maxThreadsSearchRunes = 14
|
||
|
|
maxWebRecencyCoreRunes = 12
|
||
|
|
maxWebRelevanceTagRunes = 16
|
||
|
|
)
|
||
|
|
|
||
|
|
var searchIntentMarkers = []string{"推薦", "請問", "怎麼辦", "好用嗎", "有人", "求助", "請益", "有推薦"}
|
||
|
|
|
||
|
|
// ShortPatrolSearchCore compresses a patrol tag for Threads API / recency Search API.
|
||
|
|
// Keeps pain + product tokens; drops filler and redundant intent words (RECENT/TOP handles freshness).
|
||
|
|
func ShortPatrolSearchCore(tag string) string {
|
||
|
|
tag = strings.TrimSpace(tag)
|
||
|
|
if tag == "" {
|
||
|
|
return ""
|
||
|
|
}
|
||
|
|
tokens := selectSearchTokens(tag, 3)
|
||
|
|
if len(tokens) == 0 {
|
||
|
|
return truncateRunes(stripSearchIntent(tag), maxThreadsSearchRunes)
|
||
|
|
}
|
||
|
|
core := strings.Join(tokens, " ")
|
||
|
|
return truncateRunes(core, maxWebRecencyCoreRunes)
|
||
|
|
}
|
||
|
|
|
||
|
|
// ThreadsAPIKeyword is the plain keyword sent to official Threads keyword search.
|
||
|
|
func ThreadsAPIKeyword(tag string) string {
|
||
|
|
core := ShortPatrolSearchCore(tag)
|
||
|
|
if core != "" {
|
||
|
|
return core
|
||
|
|
}
|
||
|
|
return truncateRunes(stripSearchIntent(tag), maxThreadsSearchRunes)
|
||
|
|
}
|
||
|
|
|
||
|
|
// FullPatrolWebSearchTag keeps a slightly longer phrase for relevance Search API (exact-match quotes).
|
||
|
|
func FullPatrolWebSearchTag(tag string) string {
|
||
|
|
tag = strings.TrimSpace(tag)
|
||
|
|
if tag == "" {
|
||
|
|
return ""
|
||
|
|
}
|
||
|
|
if !hasSearchIntentMarker(tag) {
|
||
|
|
candidate := strings.TrimSpace(tag + " 推薦")
|
||
|
|
if utf8.RuneCountInString(candidate) <= maxWebRelevanceTagRunes {
|
||
|
|
tag = candidate
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if utf8.RuneCountInString(tag) <= maxWebRelevanceTagRunes {
|
||
|
|
return tag
|
||
|
|
}
|
||
|
|
tokens := selectSearchTokens(tag, 4)
|
||
|
|
if len(tokens) == 0 {
|
||
|
|
return truncateRunes(tag, maxWebRelevanceTagRunes)
|
||
|
|
}
|
||
|
|
full := strings.Join(tokens, " ")
|
||
|
|
if !hasSearchIntentMarker(full) {
|
||
|
|
withIntent := strings.TrimSpace(full + " 推薦")
|
||
|
|
if utf8.RuneCountInString(withIntent) <= maxWebRelevanceTagRunes {
|
||
|
|
return withIntent
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return truncateRunes(full, maxWebRelevanceTagRunes)
|
||
|
|
}
|
||
|
|
|
||
|
|
func selectSearchTokens(tag string, maxTokens int) []string {
|
||
|
|
tag = stripSearchIntent(tag)
|
||
|
|
parts := []string{}
|
||
|
|
seen := map[string]struct{}{}
|
||
|
|
add := func(token string) {
|
||
|
|
token = strings.TrimSpace(token)
|
||
|
|
if isPatrolFiller(token) || len([]rune(token)) < 2 {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if _, ok := seen[token]; ok {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
seen[token] = struct{}{}
|
||
|
|
parts = append(parts, token)
|
||
|
|
}
|
||
|
|
|
||
|
|
for _, anchor := range patrolTopicAnchors {
|
||
|
|
if strings.Contains(tag, anchor) {
|
||
|
|
add(anchor)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
for _, hint := range productFormHints {
|
||
|
|
if strings.Contains(tag, hint) {
|
||
|
|
add(hint)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
for _, token := range intentTokenize(tag) {
|
||
|
|
add(token)
|
||
|
|
if len(parts) >= maxTokens {
|
||
|
|
break
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if len(parts) > maxTokens {
|
||
|
|
parts = parts[:maxTokens]
|
||
|
|
}
|
||
|
|
return parts
|
||
|
|
}
|
||
|
|
|
||
|
|
func stripSearchIntent(tag string) string {
|
||
|
|
tag = strings.TrimSpace(tag)
|
||
|
|
for _, marker := range searchIntentMarkers {
|
||
|
|
tag = strings.ReplaceAll(tag, marker, " ")
|
||
|
|
}
|
||
|
|
return strings.Join(strings.Fields(tag), " ")
|
||
|
|
}
|
||
|
|
|
||
|
|
func hasSearchIntentMarker(tag string) bool {
|
||
|
|
for _, marker := range searchIntentMarkers {
|
||
|
|
if strings.Contains(tag, marker) {
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return false
|
||
|
|
}
|