210 lines
5.3 KiB
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"
|
|
}
|