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

374 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}