thread-master/backend/internal/library/viral/discover_accounts.go

210 lines
5.3 KiB
Go

package viral
import (
"context"
"regexp"
"sort"
"strings"
libthreads "haixun-backend/internal/library/threadsapi"
"haixun-backend/internal/library/websearch"
)
const (
// Single web-search query per supplement pass — fewer API calls, same recall
// via a combined site: + seed + brief/pillar hint in one request.
maxAccountDiscoverQueries = 1
MaxSimilarAccounts = 10
// Skip web-search supplement when scan already surfaced enough reference authors.
MinSimilarAccountsBeforeWebSupplement = 5
)
var threadsProfileRE = regexp.MustCompile(`(?i)threads\.(?:com|net)/@([a-zA-Z0-9._]+)`)
var reservedUsernames = map[string]struct{}{
"login": {}, "signup": {}, "search": {}, "explore": {}, "home": {},
"help": {}, "about": {}, "privacy": {}, "terms": {}, "settings": {},
"threads": {}, "thread": {}, "instagram": {}, "meta": {}, "www": {},
}
type SimilarAccount struct {
Username string `json:"username"`
Reason string `json:"reason"`
Source string `json:"source"`
MatchedSource []string `json:"matchedSource,omitempty"`
Confidence string `json:"confidence"`
ProfileURL string `json:"profileUrl"`
}
type DiscoverAccountsInput struct {
SeedQuery string
Brief string
Pillars []string
}
type accountCandidate struct {
username string
score int
reason string
source string
permalink string
}
func DiscoverSimilarAccounts(ctx context.Context, client websearch.Client, input DiscoverAccountsInput) ([]SimilarAccount, error) {
if client == nil || !client.Enabled() {
return nil, nil
}
seed := strings.TrimSpace(input.SeedQuery)
if seed == "" {
return nil, nil
}
queries := buildAccountDiscoverQueries(seed, input.Brief, input.Pillars)
if len(queries) == 0 {
return nil, nil
}
seen := map[string]accountCandidate{}
for _, query := range queries {
res, err := client.Search(ctx, websearch.SearchOptions{
Query: query,
Limit: 12,
Mode: websearch.ModeThreadsDiscover,
})
if err != nil || res.Status != "success" {
continue
}
for _, item := range res.Results {
blob := strings.TrimSpace(item.URL + " " + item.Title + " " + item.Snippet)
for _, username := range extractUsernames(blob) {
weight := 2
if strings.Contains(strings.ToLower(item.URL), "/@"+strings.ToLower(username)) {
weight = 4
}
reason := strings.TrimSpace(item.Snippet)
if reason == "" {
reason = strings.TrimSpace(item.Title)
}
if reason == "" {
reason = "在「" + seed + "」相關搜尋結果中找到"
}
if len([]rune(reason)) > 120 {
reason = string([]rune(reason)[:120])
}
key := strings.ToLower(username)
prev, ok := seen[key]
permalink := strings.TrimSpace(item.URL)
if !ok || weight > prev.score {
seen[key] = accountCandidate{
username: username,
score: weight,
reason: reason,
source: "web",
permalink: permalink,
}
} else if ok {
prev.score += 1
if prev.permalink == "" && permalink != "" {
prev.permalink = permalink
}
seen[key] = prev
}
}
}
}
out := make([]accountCandidate, 0, len(seen))
for _, item := range seen {
out = append(out, item)
}
sort.Slice(out, func(i, j int) bool { return out[i].score > out[j].score })
if len(out) > MaxSimilarAccounts {
out = out[:MaxSimilarAccounts]
}
accounts := make([]SimilarAccount, 0, len(out))
for _, item := range out {
profileURL := libthreads.ProfileURLFromPermalink(item.permalink, item.username)
accounts = append(accounts, SimilarAccount{
Username: item.username,
Reason: item.reason,
Source: item.source,
MatchedSource: []string{item.source},
Confidence: accountConfidence(item.score),
ProfileURL: profileURL,
})
}
return accounts, nil
}
func buildAccountDiscoverQueries(seed, brief string, pillars []string) []string {
seed = strings.TrimSpace(seed)
if seed == "" {
return nil
}
quoted := `"` + seed + `"`
query := `site:threads.net ` + quoted
if hint := strings.TrimSpace(brief); len([]rune(hint)) >= 4 && len([]rune(hint)) <= 24 {
query += ` ` + hint
}
for _, pillar := range pillars {
pillar = strings.TrimSpace(pillar)
if len([]rune(pillar)) >= 4 {
query += ` "` + pillar + `"`
break
}
}
query = strings.TrimSpace(query)
if query == "" {
return nil
}
return []string{query}
}
func extractUsernames(blob string) []string {
matches := threadsProfileRE.FindAllStringSubmatch(blob, -1)
out := []string{}
seen := map[string]struct{}{}
for _, match := range matches {
if len(match) < 2 {
continue
}
user := strings.TrimSpace(match[1])
if !isValidUsername(user) {
continue
}
key := strings.ToLower(user)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, user)
}
return out
}
func isValidUsername(username string) bool {
if username == "" || len(username) < 2 || len(username) > 30 {
return false
}
if _, ok := reservedUsernames[strings.ToLower(username)]; ok {
return false
}
for _, r := range username {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '.' || r == '_' {
continue
}
return false
}
return true
}
func accountConfidence(score int) string {
if score >= 5 {
return "high"
}
if score >= 3 {
return "medium"
}
return "low"
}