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

247 lines
6.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"
libbrave "haixun-backend/internal/library/brave"
libkg "haixun-backend/internal/library/knowledge"
"haixun-backend/internal/library/placement"
branddomain "haixun-backend/internal/model/brand/domain/usecase"
jobdom "haixun-backend/internal/model/job/domain/usecase"
kgusecase "haixun-backend/internal/model/knowledge_graph/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 ScanPlacementDeps struct {
Jobs jobdom.UseCase
Brand branddomain.UseCase
KnowledgeGraph kgusecase.UseCase
ScanPost scanpostusecase.UseCase
ThreadsAccount threadsaccountdomain.UseCase
Placement placementusecase.UseCase
}
func RegisterScanPlacementHandler(runner *Runner, deps ScanPlacementDeps) {
if runner == nil {
return
}
runner.RegisterStepHandler("crawl", func(ctx context.Context, step StepContext) error {
return runScanPlacement(ctx, step, deps)
})
}
func runScanPlacement(ctx context.Context, step StepContext, deps ScanPlacementDeps) error {
payload := step.Run.Payload
tenantID := stringField(payload, "tenant_id")
ownerUID := stringField(payload, "owner_uid")
brandID := brandIDFromPayload(payload)
graphID := stringField(payload, "graph_id")
if tenantID == "" || ownerUID == "" || brandID == "" {
return fmt.Errorf("placement-scan payload missing tenant_id, owner_uid, or brand_id")
}
graph, err := deps.KnowledgeGraph.Get(ctx, tenantID, ownerUID, brandID)
if err != nil {
return err
}
if graphID == "" {
graphID = graph.ID
}
nodes := graph.Nodes
if ids := stringSliceField(payload, "node_ids"); len(ids) > 0 {
nodes = filterNodesByIDs(graph.Nodes, ids)
} else {
nodes = selectedNodes(graph.Nodes)
}
if len(nodes) == 0 {
return fmt.Errorf("請先在研究頁勾選至少一個節點")
}
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.AllowsBrave && !memberCtx.AllowsThreadsAPI && !memberCtx.AllowsCrawler {
return fmt.Errorf("目前連線模式無法海巡,請確認 Threads API、Brave 或 Chrome Session 設定")
}
if memberCtx.AllowsBrave && strings.TrimSpace(memberCtx.BraveAPIKey) == "" {
return fmt.Errorf("請在設定頁設定 Brave Search API key跟隨此登入帳號")
}
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: "crawl",
Summary: summary,
Percentage: percentage,
})
}
exclusions := []string{}
if brand, brandErr := deps.Brand.Get(ctx, tenantID, ownerUID, brandID); brandErr == nil && brand != nil {
exclusions = append(exclusions, brand.ResearchMap.Exclusions...)
}
updateProgress("準備置入海巡…", 5)
braveClient := libbrave.NewClient(memberCtx.BraveAPIKey)
crawlerFn := makeCrawlerSearchFn(deps, tenantID, ownerUID)
candidates, err := placement.RunDualTrackDiscover(ctx, placement.DualTrackInput{
Nodes: nodes,
Exclusions: exclusions,
Member: memberCtx,
Client: braveClient,
Crawler: crawlerFn,
}, updateProgress)
if err != nil {
return err
}
scrapeReplies := memberCtx.ScrapeReplies
if v, ok := payload["scrape_replies"].(bool); ok {
scrapeReplies = v
} else if memberCtx.ApiConnected && strings.TrimSpace(memberCtx.ThreadsAPIAccessToken) != "" {
// Formal Threads API mode can fetch replies without browser session.
scrapeReplies = true
}
if scrapeReplies {
updateProgress("抓取高優先貼文留言…", 88)
candidates = placement.AttachReplies(ctx, placement.ScrapeRepliesInput{
Posts: candidates,
Member: memberCtx,
RepliesPerPost: memberCtx.RepliesPerPost,
})
}
updateProgress(fmt.Sprintf("寫入 %d 篇海巡結果…", len(candidates)), 92)
count, err := deps.ScanPost.ReplaceFromScan(ctx, scanpostusecase.ReplaceRequest{
TenantID: tenantID,
OwnerUID: ownerUID,
BrandID: brandID,
GraphID: graphID,
ScanJobID: step.JobID,
Posts: candidates,
})
if err != nil {
return err
}
gold := 0
recent := 0
solved := 0
replyCount := 0
for _, item := range candidates {
replyCount += len(item.Replies)
if item.Priority == "gold" {
gold++
}
if item.Priority == "gold" || item.Priority == "recent" {
recent++
}
if item.SolvedByProduct {
solved++
}
}
handoff := map[string]any{
"flow": "placement",
"brand_id": brandID,
"summary": fmt.Sprintf(
"雙軌海巡完成:%d 篇gold %d、近期軌 %d、產品可解 %d",
count, gold, recent, solved,
),
"pain_breakdown": map[string]any{
"posts": count,
"gold": gold,
"recent_7d": recent,
"solved_by_prod": solved,
"replies": replyCount,
},
"next_route": "/outreach?brand=" + brandID,
"needs_supplemental_expand": false,
"search_source_mode": string(memberCtx.SearchSourceMode),
"dev_mode": memberCtx.DevMode,
}
_, err = deps.Jobs.CompleteRun(ctx, jobdom.CompleteRunRequest{
JobID: step.JobID,
WorkerID: step.WorkerID,
Result: map[string]any{
"post_count": count,
"gold_count": gold,
"recent_count": recent,
"solved_count": solved,
"reply_count": replyCount,
"search_source_mode": string(memberCtx.SearchSourceMode),
"handoff": handoff,
},
})
return err
}
func selectedNodes(nodes []libkg.Node) []libkg.Node {
out := make([]libkg.Node, 0, len(nodes))
for _, node := range nodes {
if node.SelectedForScan {
out = append(out, node)
}
}
return out
}
func filterNodesByIDs(nodes []libkg.Node, ids []string) []libkg.Node {
allowed := map[string]struct{}{}
for _, id := range ids {
id = strings.TrimSpace(id)
if id != "" {
allowed[id] = struct{}{}
}
}
out := make([]libkg.Node, 0, len(ids))
for _, node := range nodes {
if _, ok := allowed[node.ID]; ok {
out = append(out, node)
}
}
return out
}
func stringSliceField(payload map[string]any, key string) []string {
if payload == nil {
return nil
}
raw, ok := payload[key]
if !ok || raw == nil {
return nil
}
switch v := raw.(type) {
case []string:
return v
case []any:
out := make([]string, 0, len(v))
for _, item := range v {
if s, ok := item.(string); ok {
out = append(out, s)
}
}
return out
default:
return nil
}
}