thread-master/internal/worker/job/scan_viral.go

374 lines
11 KiB
Go
Raw Permalink Normal View History

2026-06-26 08:37:04 +00:00
package job
import (
"context"
"fmt"
"strings"
app "haixun-backend/internal/library/errors"
"haixun-backend/internal/library/errors/code"
"haixun-backend/internal/library/placement"
libviral "haixun-backend/internal/library/viral"
domai "haixun-backend/internal/model/ai/domain/usecase"
aiusecase "haixun-backend/internal/model/ai/usecase"
copydraftusecase "haixun-backend/internal/model/copy_draft/domain/usecase"
missionentity "haixun-backend/internal/model/copy_mission/domain/entity"
missiondomain "haixun-backend/internal/model/copy_mission/domain/usecase"
jobdom "haixun-backend/internal/model/job/domain/usecase"
personaentity "haixun-backend/internal/model/persona/domain/entity"
personadomain "haixun-backend/internal/model/persona/domain/usecase"
placementusecase "haixun-backend/internal/model/placement/usecase"
scanpostusecase "haixun-backend/internal/model/scan_post/domain/usecase"
threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase"
)
type ScanViralDeps struct {
Jobs jobdom.UseCase
CopyMission missiondomain.UseCase
Persona personadomain.UseCase
ScanPost scanpostusecase.UseCase
CopyDraft copydraftusecase.UseCase
ThreadsAccount threadsaccountdomain.UseCase
Placement placementusecase.UseCase
AI aiusecase.UseCase
}
func RegisterScanViralHandler(runner *Runner, deps ScanViralDeps) {
if runner == nil {
return
}
runner.RegisterStepHandler("viral_crawl", func(ctx context.Context, step StepContext) error {
return runScanViral(ctx, step, deps)
})
}
func runScanViral(ctx context.Context, step StepContext, deps ScanViralDeps) error {
payload := step.Run.Payload
tenantID, ownerUID := runActorFromPayload(payload, step.Run)
personaID := personaIDFromPayload(payload)
missionID := copyMissionIDFromPayload(payload)
if tenantID == "" || ownerUID == "" || personaID == "" {
return fmt.Errorf("scan-viral payload missing tenant_id, owner_uid, or persona_id")
}
persona, err := deps.Persona.Get(ctx, tenantID, ownerUID, personaID)
if err != nil {
return err
}
var mission *missiondomain.MissionSummary
if missionID != "" {
item, err := deps.CopyMission.Get(ctx, tenantID, ownerUID, personaID, missionID)
if err != nil {
return err
}
mission = item
}
research, err := deps.Placement.ResearchSettings(ctx, tenantID, ownerUID)
if err != nil {
return err
}
memberCtx, err := deps.ThreadsAccount.ResolveMemberPlacementContext(ctx, tenantID, ownerUID, research)
if err != nil {
return err
}
if !memberCtx.HasDiscoverPath() {
return fmt.Errorf("爆款掃描需要 Threads API、Chrome Session 或 Web Search API請檢查連線模式與搜尋來源")
}
updateProgress := func(summary string, percentage int) {
_ = step.Heartbeat(ctx)
_, _ = deps.Jobs.UpdateProgress(ctx, jobdom.UpdateProgressRequest{
JobID: step.JobID,
WorkerID: step.WorkerID,
Phase: "viral_crawl",
Summary: summary,
Percentage: percentage,
})
}
bootstrap := boolField(payload, "bootstrap")
if mission == nil && bootstrap && persona.CopyResearchMap.AudienceSummary == "" && len(persona.CopyResearchMap.SuggestedTags) == 0 {
updateProgress("產生拷貝忍者研究地圖…", 8)
if err := ensureCopyResearchMap(ctx, deps, tenantID, ownerUID, persona, memberCtx, updateProgress); err != nil {
return err
}
persona, err = deps.Persona.Get(ctx, tenantID, ownerUID, personaID)
if err != nil {
return err
}
}
keywords := stringSliceField(payload, "keywords")
exclusions := persona.CopyResearchMap.Exclusions
missionScan := mission != nil
if missionScan {
if len(keywords) == 0 {
keywords = append([]string(nil), mission.SelectedTags...)
}
exclusions = mission.ResearchMap.Exclusions
if len(keywords) == 0 {
return fmt.Errorf("請先產生研究地圖並勾選搜尋標籤")
}
} else if len(keywords) == 0 {
keywords = deriveViralKeywords(persona)
}
if len(keywords) == 0 {
return fmt.Errorf("請提供爆款掃描關鍵字,或先完成研究地圖/對標帳號")
}
if missionScan && missionID != "" {
updateProgress("清除舊爆款與產文草稿…", 8)
if deps.ScanPost != nil {
if err := deps.ScanPost.ClearCopyMissionViralScan(ctx, tenantID, ownerUID, personaID, missionID); err != nil {
return err
}
}
if deps.CopyDraft != nil {
if err := deps.CopyDraft.ClearByMission(ctx, tenantID, ownerUID, personaID, missionID); err != nil {
return err
}
}
}
updateProgress("準備爆款掃描…", 12)
crawlerFn := makeCrawlerSearchFn(ScanPlacementDeps{ThreadsAccount: deps.ThreadsAccount}, tenantID, ownerUID)
candidates, err := libviral.RunDiscover(ctx, libviral.DiscoverInput{
Keywords: keywords,
Exclusions: exclusions,
Member: memberCtx,
Crawler: crawlerFn,
MissionScan: missionScan,
}, updateProgress)
if err != nil {
return err
}
if missionScan && memberCtx.ScrapeReplies && memberCtx.ApiConnected {
updateProgress("收集高互動留言樣本…", 88)
candidates = placement.AttachReplies(ctx, placement.ScrapeRepliesInput{
Posts: candidates,
Member: memberCtx,
RepliesPerPost: memberCtx.RepliesPerPost,
MaxPosts: 6,
})
}
updateProgress(fmt.Sprintf("寫入 %d 篇爆款候選…", len(candidates)), 92)
count, err := deps.ScanPost.ReplaceFromViralScan(ctx, scanpostusecase.ViralReplaceRequest{
TenantID: tenantID,
OwnerUID: ownerUID,
PersonaID: personaID,
CopyMissionID: missionID,
ScanJobID: step.JobID,
Posts: candidates,
})
if err != nil {
return err
}
if missionScan && missionID != "" && mission != nil {
scanned := missionentity.StatusScanned
jobID := step.JobID
referenceAccounts := libviral.BuildReferenceAccountsFromScan(libviral.ReferenceAccountInput{
SeedQuery: mission.SeedQuery,
Label: mission.Label,
Posts: candidates,
Limit: libviral.MaxSimilarAccounts,
})
entityTags := make([]missionentity.SuggestedTag, 0, len(mission.ResearchMap.SuggestedTags))
for _, tag := range mission.ResearchMap.SuggestedTags {
entityTags = append(entityTags, missionentity.SuggestedTag{
Tag: tag.Tag,
Reason: tag.Reason,
SearchIntent: tag.SearchIntent,
SearchType: tag.SearchType,
})
}
updatedMap := missionentity.ResearchMap{
AudienceSummary: mission.ResearchMap.AudienceSummary,
ContentGoal: mission.ResearchMap.ContentGoal,
Questions: append([]string(nil), mission.ResearchMap.Questions...),
Pillars: append([]string(nil), mission.ResearchMap.Pillars...),
Exclusions: append([]string(nil), mission.ResearchMap.Exclusions...),
SuggestedTags: entityTags,
SimilarAccounts: referenceAccounts,
BenchmarkNotes: mission.ResearchMap.BenchmarkNotes,
}
_, _ = deps.CopyMission.Update(ctx, missiondomain.UpdateRequest{
TenantID: tenantID,
OwnerUID: ownerUID,
PersonaID: personaID,
MissionID: missionID,
Patch: missiondomain.MissionPatch{
LastScanJobID: &jobID,
Status: &scanned,
ResearchMap: &updatedMap,
},
})
}
nextRoute := "/matrix"
if missionID != "" {
nextRoute = fmt.Sprintf("/matrix/missions/%s", missionID)
}
handoff := map[string]any{
"flow": "copy",
"persona_id": personaID,
"copy_mission_id": missionID,
"summary": fmt.Sprintf("爆款掃描完成:%d 篇候選", count),
"next_route": nextRoute,
}
_, err = deps.Jobs.CompleteRun(ctx, jobdom.CompleteRunRequest{
JobID: step.JobID,
WorkerID: step.WorkerID,
Result: map[string]any{
"post_count": count,
"handoff": handoff,
},
})
return err
}
func ensureCopyResearchMap(
ctx context.Context,
deps ScanViralDeps,
tenantID, ownerUID string,
persona *personadomain.PersonaSummary,
memberCtx placement.MemberContext,
updateProgress func(string, int),
) error {
if persona == nil {
return fmt.Errorf("copy research map: missing persona")
}
credential, err := deps.ThreadsAccount.ResolveMemberAiCredential(ctx, tenantID, ownerUID)
if err != nil {
return err
}
providerID, err := aiusecase.MapWorkerProvider(credential.Provider)
if err != nil {
return err
}
label := strings.TrimSpace(persona.DisplayName)
if label == "" {
label = "拷貝主題"
}
seed := strings.TrimSpace(persona.SeedQuery)
if seed == "" {
seed = strings.TrimPrefix(strings.TrimSpace(persona.StyleBenchmark), "@")
}
if seed == "" {
for _, line := range strings.Split(strings.TrimSpace(persona.Brief), "\n") {
line = strings.TrimSpace(line)
if line != "" {
seed = line
break
}
}
}
result, err := deps.AI.GenerateText(ctx, domai.GenerateRequest{
Provider: providerID,
Model: credential.Model,
Credential: domai.Credential{
APIKey: credential.APIKey,
},
System: libviral.BuildCopyResearchMapSystemPrompt(),
Messages: []domai.Message{
{
Role: "user",
Content: libviral.BuildCopyResearchMapUserPrompt(libviral.CopyResearchMapInput{
Label: label,
SeedQuery: seed,
Brief: persona.Brief,
Persona: persona.Persona,
StyleBenchmark: persona.StyleBenchmark,
}),
},
},
})
if err != nil {
return err
}
parsed, err := libviral.ParseCopyResearchMapOutput(result.Text)
if err != nil {
return app.For(code.AI).SvcThirdParty("拷貝研究地圖 LLM 回傳無法解析:" + err.Error())
}
entityMap := personaentity.CopyResearchMap{
AudienceSummary: parsed.AudienceSummary,
ContentGoal: parsed.ContentGoal,
Questions: parsed.Questions,
Pillars: parsed.Pillars,
Exclusions: parsed.Exclusions,
SuggestedTags: parsed.SuggestedTags,
BenchmarkNotes: parsed.BenchmarkNotes,
}
patch := personadomain.PersonaPatch{
CopyResearchMap: &entityMap,
}
if seed != "" && strings.TrimSpace(persona.SeedQuery) == "" {
patch.SeedQuery = &seed
}
_, err = deps.Persona.Update(ctx, personadomain.UpdateRequest{
TenantID: tenantID,
OwnerUID: ownerUID,
PersonaID: persona.ID,
Patch: patch,
})
if err != nil {
return err
}
if updateProgress != nil {
updateProgress("研究地圖已就緒", 10)
}
_ = memberCtx
return nil
}
func personaIDFromPayload(payload map[string]any) string {
if id := stringField(payload, "persona_id"); id != "" {
return id
}
return stringField(payload, "scope_id")
}
func deriveViralKeywords(persona *personadomain.PersonaSummary) []string {
if persona == nil {
return nil
}
out := []string{}
seen := map[string]struct{}{}
add := func(kw string) {
kw = strings.TrimSpace(kw)
if kw == "" {
return
}
if _, ok := seen[kw]; ok {
return
}
seen[kw] = struct{}{}
out = append(out, kw)
}
for _, tag := range persona.CopyResearchMap.SuggestedTags {
add(tag)
}
for _, q := range persona.CopyResearchMap.Questions {
add(q)
}
if bench := strings.TrimPrefix(strings.TrimSpace(persona.StyleBenchmark), "@"); bench != "" {
add(bench)
}
for _, line := range strings.Split(strings.TrimSpace(persona.Brief), "\n") {
line = strings.TrimSpace(line)
if line != "" {
add(line)
break
}
}
return out
}