129 lines
4.0 KiB
Go
129 lines
4.0 KiB
Go
|
|
package usecase
|
|||
|
|
|
|||
|
|
import (
|
|||
|
|
"crypto/sha256"
|
|||
|
|
"encoding/hex"
|
|||
|
|
"net/url"
|
|||
|
|
"strconv"
|
|||
|
|
"strings"
|
|||
|
|
|
|||
|
|
"apps/backend/internal/module/scout/domain"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
type classifiedPost struct {
|
|||
|
|
classification string
|
|||
|
|
score int
|
|||
|
|
reason string
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func planScanTerms(brief *domain.RunBrief) []string {
|
|||
|
|
return dedupeTerms(brief.Pains, brief.Tags, brief.Periphery, []string{brief.Intent})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func dedupeTerms(groups ...[]string) []string {
|
|||
|
|
seen := make(map[string]struct{})
|
|||
|
|
var out []string
|
|||
|
|
for _, group := range groups {
|
|||
|
|
for _, term := range group {
|
|||
|
|
term = strings.Join(strings.Fields(strings.TrimSpace(term)), " ")
|
|||
|
|
key := strings.ToLower(term)
|
|||
|
|
if term == "" {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
if _, ok := seen[key]; ok {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
seen[key] = struct{}{}
|
|||
|
|
out = append(out, term)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return out
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func classifyPost(mode, text string, terms []string) classifiedPost {
|
|||
|
|
lower := strings.ToLower(text)
|
|||
|
|
signals := matchedSignals(lower, terms)
|
|||
|
|
if hasAny(lower, "giveaway", "抽獎", "follow for follow", "互追", "crypto", "賺錢") {
|
|||
|
|
return classifiedPost{domain.ClassificationNoise, 0, "noise signal"}
|
|||
|
|
}
|
|||
|
|
if mode == domain.ModeActivity {
|
|||
|
|
return classifyActivity(lower, signals)
|
|||
|
|
}
|
|||
|
|
if hasAny(lower, "dm me", "私訊我", "服務洽詢", "立即購買", "限時優惠", "團購", "業配") {
|
|||
|
|
return scored(domain.ClassificationProviderOffer, 35, signals, "provider-offer signal")
|
|||
|
|
}
|
|||
|
|
if hasAny(lower, "推薦", "求推", "有沒有推薦", "any recommendation", "what do you recommend") {
|
|||
|
|
return scored(domain.ClassificationSeekingRecommendation, 60, signals, "recommendation signal")
|
|||
|
|
}
|
|||
|
|
if strings.Contains(lower, "?") || strings.Contains(lower, "?") || hasAny(lower, "怎麼", "如何", "請問", "求助", "help") {
|
|||
|
|
return scored(domain.ClassificationSeekingHelp, 55, signals, "help-seeking signal")
|
|||
|
|
}
|
|||
|
|
return scored(domain.ClassificationDiscussion, 40, signals, "discussion signal")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func classifyActivity(text string, signals []string) classifiedPost {
|
|||
|
|
if hasAny(text, "dm me", "私訊我", "服務洽詢", "立即購買", "限時優惠", "團購", "業配") {
|
|||
|
|
return scored(domain.ClassificationProviderOffer, 35, signals, "provider-offer signal; recency unavailable (neutral)")
|
|||
|
|
}
|
|||
|
|
if strings.Contains(text, "?") || strings.Contains(text, "?") || hasAny(text, "請問", "怎麼", "如何", "有人知道", "求") {
|
|||
|
|
return scored(domain.ClassificationAsking, 60, signals, "asking signal; recency unavailable (neutral)")
|
|||
|
|
}
|
|||
|
|
if hasAny(text, "活動", "event", "開幕", "launch", "發布", "報名", "登記", "公告") {
|
|||
|
|
return scored(domain.ClassificationAnnouncement, 50, signals, "announcement signal; recency unavailable (neutral)")
|
|||
|
|
}
|
|||
|
|
return scored(domain.ClassificationDiscussion, 40, signals, "discussion signal; recency unavailable (neutral)")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func scored(classification string, base int, signals []string, label string) classifiedPost {
|
|||
|
|
score := base + len(signals)*15
|
|||
|
|
if score > 100 {
|
|||
|
|
score = 100
|
|||
|
|
}
|
|||
|
|
reason := label
|
|||
|
|
if len(signals) > 0 {
|
|||
|
|
reason += "; matched: " + strings.Join(signals, ", ")
|
|||
|
|
}
|
|||
|
|
return classifiedPost{classification, score, reason}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func matchedSignals(text string, terms []string) []string {
|
|||
|
|
var signals []string
|
|||
|
|
for _, term := range dedupeTerms(terms) {
|
|||
|
|
if strings.Contains(text, strings.ToLower(term)) {
|
|||
|
|
signals = append(signals, term)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return signals
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func hasAny(text string, signals ...string) bool {
|
|||
|
|
for _, signal := range signals {
|
|||
|
|
if strings.Contains(text, signal) {
|
|||
|
|
return true
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func canonicalPermalink(raw string) string {
|
|||
|
|
u, err := url.Parse(strings.TrimSpace(raw))
|
|||
|
|
if err != nil || u.Host == "" {
|
|||
|
|
return ""
|
|||
|
|
}
|
|||
|
|
u.Scheme = "https"
|
|||
|
|
u.Host = strings.ToLower(u.Host)
|
|||
|
|
u.RawQuery = ""
|
|||
|
|
u.Fragment = ""
|
|||
|
|
u.Path = strings.TrimRight(u.Path, "/")
|
|||
|
|
return u.String()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func permalinkID(ownerUID int64, permalink string) string {
|
|||
|
|
sum := sha256.Sum256([]byte(strings.TrimSpace(permalink) + "|" + formatOwnerUID(ownerUID)))
|
|||
|
|
return "sp_" + hex.EncodeToString(sum[:])[:20]
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func formatOwnerUID(ownerUID int64) string {
|
|||
|
|
return strconv.FormatInt(ownerUID, 10)
|
|||
|
|
}
|