haixunMaster/haixun-backend/internal/worker/job/scan_viral.go

284 lines
7.8 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"
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
Persona personadomain.UseCase
ScanPost scanpostusecase.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 := stringField(payload, "tenant_id")
ownerUID := stringField(payload, "owner_uid")
personaID := personaIDFromPayload(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
}
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.AllowsThreadsAPI && !memberCtx.AllowsCrawler {
return fmt.Errorf("爆款掃描需要 Threads API 或 Chrome Session開發模式")
}
if memberCtx.DevMode && !memberCtx.BrowserConnected {
return fmt.Errorf("開發模式需先同步 Chrome Session")
}
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 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")
if len(keywords) == 0 {
keywords = deriveViralKeywords(persona)
}
if len(keywords) == 0 {
return fmt.Errorf("請提供爆款掃描關鍵字,或先完成研究地圖/對標帳號")
}
exclusions := persona.CopyResearchMap.Exclusions
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,
}, updateProgress)
if err != nil {
return err
}
updateProgress(fmt.Sprintf("寫入 %d 篇爆款候選…", len(candidates)), 92)
count, err := deps.ScanPost.ReplaceFromViralScan(ctx, scanpostusecase.ViralReplaceRequest{
TenantID: tenantID,
OwnerUID: ownerUID,
PersonaID: personaID,
ScanJobID: step.JobID,
Posts: candidates,
})
if err != nil {
return err
}
handoff := map[string]any{
"flow": "copy",
"persona_id": personaID,
"summary": fmt.Sprintf("爆款掃描完成:%d 篇候選", count),
"next_route": "/matrix",
}
_, 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
}