fix find post

This commit is contained in:
王性驊 2026-06-25 16:20:03 +08:00
parent a66a3d81ee
commit d0da1a1103
230 changed files with 12783 additions and 2060 deletions

View File

@ -1 +1 @@
91942
26382

File diff suppressed because it is too large Load Diff

View File

@ -3,7 +3,7 @@
> vite
VITE v6.4.3 ready in 111 ms
VITE v6.4.3 ready in 174 ms
➜ Local: http://localhost:5173/
➜ Network: use --host to expose

View File

@ -2,4 +2,4 @@
> haixun-master@0.1.0 worker:style-8d
> . scripts/playwright-env.sh && npx playwright install chromium && tsx haixun-backend/worker/style-8d-worker.ts
[8d-worker] started id=local-style-8d-node-92403 api=http://127.0.0.1:8890
[8d-worker] started id=local-style-8d-node-26520 api=http://127.0.0.1:8890

View File

@ -1 +1 @@
92316
26440

View File

@ -1 +1 @@
92317
26441

View File

@ -231,6 +231,7 @@ type (
GenerateOutreachDraftsReq {
ScanPostID string `json:"scan_post_id" validate:"required"`
TopicID string `json:"topic_id,optional"`
Count int `json:"count,optional"`
VoicePersonaID string `json:"voice_persona_id,optional"`
ProductID string `json:"product_id,optional"`

View File

@ -0,0 +1,250 @@
syntax = "v1"
type (
CopySuggestedTagData {
Tag string `json:"tag"`
Reason string `json:"reason,omitempty"`
SearchIntent string `json:"search_intent,omitempty"`
SearchType string `json:"search_type,omitempty"`
}
CopySimilarAccountData {
Username string `json:"username"`
Reason string `json:"reason,omitempty"`
Source string `json:"source,omitempty"`
Confidence string `json:"confidence,omitempty"`
ProfileUrl string `json:"profile_url,omitempty"`
}
CopyMissionResearchMapData {
AudienceSummary string `json:"audience_summary,omitempty"`
ContentGoal string `json:"content_goal,omitempty"`
Questions []string `json:"questions,omitempty"`
Pillars []string `json:"pillars,omitempty"`
Exclusions []string `json:"exclusions,omitempty"`
SuggestedTags []CopySuggestedTagData `json:"suggested_tags,omitempty"`
SimilarAccounts []CopySimilarAccountData `json:"similar_accounts,omitempty"`
BenchmarkNotes string `json:"benchmark_notes,omitempty"`
}
CopyMissionData {
ID string `json:"id"`
PersonaID string `json:"persona_id"`
Label string `json:"label,omitempty"`
SeedQuery string `json:"seed_query,omitempty"`
Brief string `json:"brief,omitempty"`
ResearchMap CopyMissionResearchMapData `json:"research_map,omitempty"`
SelectedTags []string `json:"selected_tags,omitempty"`
LastScanJobID string `json:"last_scan_job_id,omitempty"`
Status string `json:"status,omitempty"`
CreateAt int64 `json:"create_at"`
UpdateAt int64 `json:"update_at"`
}
ListCopyMissionsData {
List []CopyMissionData `json:"list"`
}
CreateCopyMissionReq {
Label string `json:"label" validate:"required"`
SeedQuery string `json:"seed_query" validate:"required"`
Brief string `json:"brief" validate:"required"`
}
UpdateCopyMissionReq {
Label *string `json:"label,optional"`
SeedQuery *string `json:"seed_query,optional"`
Brief *string `json:"brief,optional"`
AudienceSummary *string `json:"audience_summary,optional"`
ContentGoal *string `json:"content_goal,optional"`
Questions []string `json:"questions,optional"`
Pillars []string `json:"pillars,optional"`
Exclusions []string `json:"exclusions,optional"`
BenchmarkNotes *string `json:"benchmark_notes,optional"`
SelectedTags []string `json:"selected_tags,optional"`
Status *string `json:"status,optional"`
}
CopyMissionScanScheduleData {
ID string `json:"id,omitempty"`
PersonaID string `json:"persona_id"`
MissionID string `json:"mission_id"`
Cron string `json:"cron"`
Timezone string `json:"timezone"`
Enabled bool `json:"enabled"`
NextRunAt int64 `json:"next_run_at,omitempty"`
LastRunAt int64 `json:"last_run_at,omitempty"`
}
UpsertCopyMissionScanScheduleReq {
Cron string `json:"cron,optional"`
Timezone string `json:"timezone,optional"`
Enabled bool `json:"enabled"`
}
UpsertCopyMissionScanScheduleHandlerReq {
CopyMissionPath
UpsertCopyMissionScanScheduleReq
}
CopyMissionPath {
PersonaID string `path:"personaId" validate:"required"`
ID string `path:"id" validate:"required"`
}
PersonaCopyMissionsPath {
PersonaID string `path:"personaId" validate:"required"`
}
CreateCopyMissionHandlerReq {
PersonaCopyMissionsPath
CreateCopyMissionReq
}
UpdateCopyMissionHandlerReq {
CopyMissionPath
UpdateCopyMissionReq
}
StartCopyMissionAnalyzeJobData {
JobID string `json:"job_id"`
Status string `json:"status"`
Message string `json:"message"`
}
StartCopyMissionScanJobData {
JobID string `json:"job_id"`
Status string `json:"status"`
Message string `json:"message"`
}
StartCopyMissionMatrixJobReq {
Count int `json:"count,optional"`
}
StartCopyMissionMatrixJobHandlerReq {
CopyMissionPath
StartCopyMissionMatrixJobReq
}
StartCopyMissionMatrixJobData {
JobID string `json:"job_id"`
Status string `json:"status"`
Message string `json:"message"`
}
StartCopyMissionCopyDraftJobReq {
ScanPostID string `json:"scan_post_id" validate:"required"`
}
StartCopyMissionCopyDraftJobHandlerReq {
CopyMissionPath
StartCopyMissionCopyDraftJobReq
}
StartCopyMissionCopyDraftJobData {
JobID string `json:"job_id"`
Status string `json:"status"`
Message string `json:"message"`
}
ListCopyMissionScanPostsReq {
Limit int `form:"limit,optional"`
}
ListCopyMissionScanPostsHandlerReq {
CopyMissionPath
ListCopyMissionScanPostsReq
}
GenerateCopyMissionMatrixReq {
Count int `json:"count,optional"`
}
GenerateCopyMissionMatrixHandlerReq {
CopyMissionPath
GenerateCopyMissionMatrixReq
}
GenerateCopyMissionMatrixData {
Drafts []CopyDraftData `json:"drafts"`
Message string `json:"message"`
}
ListCopyMissionCopyDraftsData {
List []CopyDraftData `json:"list"`
Total int `json:"total"`
}
CopyMissionInspirationSourceData {
Query string `json:"query,omitempty"`
Title string `json:"title,omitempty"`
Snippet string `json:"snippet,omitempty"`
URL string `json:"url,omitempty"`
}
CopyMissionInspirationData {
Label string `json:"label"`
SeedQuery string `json:"seed_query"`
Brief string `json:"brief"`
TrendReason string `json:"trend_reason,omitempty"`
TrendKeywords []string `json:"trend_keywords,omitempty"`
Sources []CopyMissionInspirationSourceData `json:"sources,omitempty"`
WebSearchUsed bool `json:"web_search_used"`
Message string `json:"message"`
}
)
@server(
group: copy_mission
prefix: /api/v1/personas
middleware: AuthJWT
tags: "CopyMission"
summary: "Copy ninja missions (Flow A). Requires Bearer JWT."
)
service gateway {
@handler listCopyMissions
get /:personaId/copy-missions (PersonaCopyMissionsPath) returns (ListCopyMissionsData)
@handler inspireCopyMission
post /:personaId/copy-mission-inspiration (PersonaCopyMissionsPath) returns (CopyMissionInspirationData)
@handler createCopyMission
post /:personaId/copy-missions (CreateCopyMissionHandlerReq) returns (CopyMissionData)
@handler getCopyMission
get /:personaId/copy-missions/:id (CopyMissionPath) returns (CopyMissionData)
@handler updateCopyMission
patch /:personaId/copy-missions/:id (UpdateCopyMissionHandlerReq) returns (CopyMissionData)
@handler deleteCopyMission
delete /:personaId/copy-missions/:id (CopyMissionPath)
@handler startCopyMissionAnalyzeJob
post /:personaId/copy-missions/:id/analyze-jobs (CopyMissionPath) returns (StartCopyMissionAnalyzeJobData)
@handler startCopyMissionScanJob
post /:personaId/copy-missions/:id/scan-jobs (CopyMissionPath) returns (StartCopyMissionScanJobData)
@handler listCopyMissionScanPosts
get /:personaId/copy-missions/:id/scan-posts (ListCopyMissionScanPostsHandlerReq) returns (ListPersonaViralScanPostsData)
@handler generateCopyMissionMatrix
post /:personaId/copy-missions/:id/matrix-drafts (GenerateCopyMissionMatrixHandlerReq) returns (GenerateCopyMissionMatrixData)
@handler startCopyMissionMatrixJob
post /:personaId/copy-missions/:id/matrix-jobs (StartCopyMissionMatrixJobHandlerReq) returns (StartCopyMissionMatrixJobData)
@handler startCopyMissionCopyDraftJob
post /:personaId/copy-missions/:id/copy-draft-jobs (StartCopyMissionCopyDraftJobHandlerReq) returns (StartCopyMissionCopyDraftJobData)
@handler listCopyMissionCopyDrafts
get /:personaId/copy-missions/:id/copy-drafts (CopyMissionPath) returns (ListCopyMissionCopyDraftsData)
@handler getCopyMissionScanSchedule
get /:personaId/copy-missions/:id/scan-schedule (CopyMissionPath) returns (CopyMissionScanScheduleData)
@handler upsertCopyMissionScanSchedule
put /:personaId/copy-missions/:id/scan-schedule (UpsertCopyMissionScanScheduleHandlerReq) returns (CopyMissionScanScheduleData)
}

View File

@ -22,6 +22,7 @@ import (
"permission.api"
"threads_account.api"
"persona.api"
"copy_mission.api"
"brand.api"
"placement_topic.api"
"worker_internal.api"

View File

@ -30,18 +30,25 @@ type (
}
MemberPlacementSettingsData {
BraveAPIKey string `json:"brave_api_key,omitempty"`
BraveAPIKeyConfigured bool `json:"brave_api_key_configured"`
BraveCountry string `json:"brave_country"`
BraveSearchLang string `json:"brave_search_lang"`
ExpandStrategy string `json:"expand_strategy"` // brave | llm | hybrid
WebSearchProvider string `json:"web_search_provider"` // brave | exa
BraveAPIKey string `json:"brave_api_key,omitempty"`
BraveAPIKeyConfigured bool `json:"brave_api_key_configured"`
ExaAPIKey string `json:"exa_api_key,omitempty"`
ExaAPIKeyConfigured bool `json:"exa_api_key_configured"`
BraveCountry string `json:"brave_country"`
BraveSearchLang string `json:"brave_search_lang"`
ExaUserLocation string `json:"exa_user_location"`
ExpandStrategy string `json:"expand_strategy"` // brave | llm | hybrid
}
UpdateMemberPlacementSettingsReq {
BraveAPIKey *string `json:"brave_api_key,optional"`
BraveCountry *string `json:"brave_country,optional"`
BraveSearchLang *string `json:"brave_search_lang,optional"`
ExpandStrategy *string `json:"expand_strategy,optional"`
WebSearchProvider *string `json:"web_search_provider,optional"`
BraveAPIKey *string `json:"brave_api_key,optional"`
ExaAPIKey *string `json:"exa_api_key,optional"`
BraveCountry *string `json:"brave_country,optional"`
BraveSearchLang *string `json:"brave_search_lang,optional"`
ExaUserLocation *string `json:"exa_user_location,optional"`
ExpandStrategy *string `json:"expand_strategy,optional"`
}
)

View File

@ -39,6 +39,7 @@ type (
UpdatePersonaReq {
DisplayName *string `json:"display_name,optional"`
Persona *string `json:"persona,optional"`
Brief *string `json:"brief,optional"`
StyleProfile *string `json:"style_profile,optional"`
StyleBenchmark *string `json:"style_benchmark,optional"`
}
@ -83,17 +84,18 @@ type (
}
ViralScanPostData {
ID string `json:"id"`
SearchTag string `json:"search_tag"`
Permalink string `json:"permalink"`
Author string `json:"author"`
Text string `json:"text"`
LikeCount int `json:"like_count"`
ReplyCount int `json:"reply_count"`
EngagementScore int `json:"engagement_score"`
Source string `json:"source"`
ScanJobID string `json:"scan_job_id"`
CreateAt int64 `json:"create_at"`
ID string `json:"id"`
SearchTag string `json:"search_tag"`
Permalink string `json:"permalink"`
Author string `json:"author"`
Text string `json:"text"`
LikeCount int `json:"like_count"`
ReplyCount int `json:"reply_count"`
EngagementScore int `json:"engagement_score"`
Source string `json:"source"`
ScanJobID string `json:"scan_job_id"`
Replies []ScanReplyData `json:"replies,omitempty"`
CreateAt int64 `json:"create_at"`
}
ListPersonaViralScanPostsData {
@ -107,18 +109,23 @@ type (
}
CopyDraftData {
ID string `json:"id"`
PersonaID string `json:"persona_id"`
ScanPostID string `json:"scan_post_id,omitempty"`
DraftType string `json:"draft_type"`
ID string `json:"id"`
PersonaID string `json:"persona_id"`
CopyMissionID string `json:"copy_mission_id,omitempty"`
ScanPostID string `json:"scan_post_id,omitempty"`
DraftType string `json:"draft_type"`
SortOrder int `json:"sort_order,omitempty"`
Text string `json:"text"`
Angle string `json:"angle,omitempty"`
Hook string `json:"hook,omitempty"`
Rationale string `json:"rationale,omitempty"`
ReferenceNotes string `json:"reference_notes,omitempty"`
Sources []string `json:"sources,omitempty"`
Status string `json:"status,omitempty"`
CreateAt int64 `json:"create_at"`
Status string `json:"status,omitempty"`
PublishedMediaID string `json:"published_media_id,omitempty"`
PublishedPermalink string `json:"published_permalink,omitempty"`
PublishedAt int64 `json:"published_at,omitempty"`
CreateAt int64 `json:"create_at"`
}
ListPersonaCopyDraftsData {
@ -139,6 +146,41 @@ type (
Draft CopyDraftData `json:"draft"`
Message string `json:"message"`
}
CopyDraftPath {
ID string `path:"id" validate:"required"`
DraftID string `path:"draftId" validate:"required"`
}
UpdateCopyDraftReq {
Text *string `json:"text,optional"`
Hook *string `json:"hook,optional"`
Angle *string `json:"angle,optional"`
Status *string `json:"status,optional"`
}
UpdateCopyDraftHandlerReq {
CopyDraftPath
UpdateCopyDraftReq
}
PublishCopyDraftReq {
Text string `json:"text,optional"`
Confirm bool `json:"confirm"`
}
PublishCopyDraftHandlerReq {
CopyDraftPath
PublishCopyDraftReq
}
PublishCopyDraftData {
DraftID string `json:"draft_id"`
MediaID string `json:"media_id"`
Permalink string `json:"permalink,omitempty"`
Status string `json:"status"`
Message string `json:"message"`
}
)
@server(
@ -178,4 +220,10 @@ service gateway {
@handler generatePersonaCopyDraft
post /:id/copy-drafts/generate (GeneratePersonaCopyDraftHandlerReq) returns (GeneratePersonaCopyDraftData)
@handler updatePersonaCopyDraft
patch /:id/copy-drafts/:draftId (UpdateCopyDraftHandlerReq) returns (CopyDraftData)
@handler publishPersonaCopyDraft
post /:id/copy-drafts/:draftId/publish (PublishCopyDraftHandlerReq) returns (PublishCopyDraftData)
}

View File

@ -6,11 +6,12 @@ package brand
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/brand"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func CreateBrandProductHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {

View File

@ -6,11 +6,12 @@ package brand
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/brand"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func DeleteBrandProductHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {

View File

@ -6,11 +6,12 @@ package brand
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/brand"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func ListBrandProductsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {

View File

@ -6,11 +6,12 @@ package brand
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/brand"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func UpdateBrandProductHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {

View File

@ -0,0 +1,33 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package copy_mission
import (
"net/http"
"haixun-backend/internal/logic/copy_mission"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func CreateCopyMissionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.CreateCopyMissionHandlerReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := copy_mission.NewCreateCopyMissionLogic(r.Context(), svcCtx)
data, err := l.CreateCopyMission(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,33 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package copy_mission
import (
"net/http"
"haixun-backend/internal/logic/copy_mission"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func DeleteCopyMissionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.CopyMissionPath
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := copy_mission.NewDeleteCopyMissionLogic(r.Context(), svcCtx)
err := l.DeleteCopyMission(&req)
response.Write(r.Context(), w, nil, err)
}
}

View File

@ -0,0 +1,33 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package copy_mission
import (
"net/http"
"haixun-backend/internal/logic/copy_mission"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func GenerateCopyMissionMatrixHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.GenerateCopyMissionMatrixHandlerReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := copy_mission.NewGenerateCopyMissionMatrixLogic(r.Context(), svcCtx)
data, err := l.GenerateCopyMissionMatrix(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,33 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package copy_mission
import (
"net/http"
"haixun-backend/internal/logic/copy_mission"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func GetCopyMissionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.CopyMissionPath
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := copy_mission.NewGetCopyMissionLogic(r.Context(), svcCtx)
data, err := l.GetCopyMission(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,33 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package copy_mission
import (
"net/http"
"haixun-backend/internal/logic/copy_mission"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func GetCopyMissionScanScheduleHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.CopyMissionPath
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := copy_mission.NewGetCopyMissionScanScheduleLogic(r.Context(), svcCtx)
data, err := l.GetCopyMissionScanSchedule(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,30 @@
package copy_mission
import (
"net/http"
"haixun-backend/internal/logic/copy_mission"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func InspireCopyMissionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.PersonaCopyMissionsPath
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := copy_mission.NewInspireCopyMissionLogic(r.Context(), svcCtx)
data, err := l.InspireCopyMission(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,33 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package copy_mission
import (
"net/http"
"haixun-backend/internal/logic/copy_mission"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func ListCopyMissionCopyDraftsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.CopyMissionPath
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := copy_mission.NewListCopyMissionCopyDraftsLogic(r.Context(), svcCtx)
data, err := l.ListCopyMissionCopyDrafts(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,33 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package copy_mission
import (
"net/http"
"haixun-backend/internal/logic/copy_mission"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func ListCopyMissionScanPostsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.ListCopyMissionScanPostsHandlerReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := copy_mission.NewListCopyMissionScanPostsLogic(r.Context(), svcCtx)
data, err := l.ListCopyMissionScanPosts(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,33 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package copy_mission
import (
"net/http"
"haixun-backend/internal/logic/copy_mission"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func ListCopyMissionsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.PersonaCopyMissionsPath
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := copy_mission.NewListCopyMissionsLogic(r.Context(), svcCtx)
data, err := l.ListCopyMissions(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,33 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package copy_mission
import (
"net/http"
"haixun-backend/internal/logic/copy_mission"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func StartCopyMissionAnalyzeJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.CopyMissionPath
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := copy_mission.NewStartCopyMissionAnalyzeJobLogic(r.Context(), svcCtx)
data, err := l.StartCopyMissionAnalyzeJob(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,30 @@
package copy_mission
import (
"net/http"
"haixun-backend/internal/logic/copy_mission"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func StartCopyMissionCopyDraftJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.StartCopyMissionCopyDraftJobHandlerReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := copy_mission.NewStartCopyMissionCopyDraftJobLogic(r.Context(), svcCtx)
data, err := l.StartCopyMissionCopyDraftJob(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,30 @@
package copy_mission
import (
"net/http"
"haixun-backend/internal/logic/copy_mission"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func StartCopyMissionMatrixJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.StartCopyMissionMatrixJobHandlerReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := copy_mission.NewStartCopyMissionMatrixJobLogic(r.Context(), svcCtx)
data, err := l.StartCopyMissionMatrixJob(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,33 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package copy_mission
import (
"net/http"
"haixun-backend/internal/logic/copy_mission"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func StartCopyMissionScanJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.CopyMissionPath
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := copy_mission.NewStartCopyMissionScanJobLogic(r.Context(), svcCtx)
data, err := l.StartCopyMissionScanJob(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,33 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package copy_mission
import (
"net/http"
"haixun-backend/internal/logic/copy_mission"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func UpdateCopyMissionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.UpdateCopyMissionHandlerReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := copy_mission.NewUpdateCopyMissionLogic(r.Context(), svcCtx)
data, err := l.UpdateCopyMission(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,33 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package copy_mission
import (
"net/http"
"haixun-backend/internal/logic/copy_mission"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func UpsertCopyMissionScanScheduleHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.UpsertCopyMissionScanScheduleHandlerReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := copy_mission.NewUpsertCopyMissionScanScheduleLogic(r.Context(), svcCtx)
data, err := l.UpsertCopyMissionScanSchedule(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,33 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package persona
import (
"net/http"
"haixun-backend/internal/logic/persona"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func PublishPersonaCopyDraftHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.PublishCopyDraftHandlerReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := persona.NewPublishPersonaCopyDraftLogic(r.Context(), svcCtx)
data, err := l.PublishPersonaCopyDraft(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,33 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package persona
import (
"net/http"
"haixun-backend/internal/logic/persona"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func UpdatePersonaCopyDraftHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.UpdateCopyDraftHandlerReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := persona.NewUpdatePersonaCopyDraftLogic(r.Context(), svcCtx)
data, err := l.UpdatePersonaCopyDraft(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -6,11 +6,12 @@ package placement_topic
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/placement_topic"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func BatchDeletePlacementTopicScanPostsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {

View File

@ -6,11 +6,12 @@ package placement_topic
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/placement_topic"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func CreatePlacementTopicHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {

View File

@ -6,11 +6,12 @@ package placement_topic
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/placement_topic"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func DeletePlacementTopicHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {

View File

@ -6,11 +6,12 @@ package placement_topic
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/placement_topic"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func DeletePlacementTopicScanPostHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {

View File

@ -6,11 +6,12 @@ package placement_topic
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/placement_topic"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func ExpandPlacementTopicGraphHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {

View File

@ -6,11 +6,12 @@ package placement_topic
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/placement_topic"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func GeneratePlacementTopicContentMatrixHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {

View File

@ -6,11 +6,12 @@ package placement_topic
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/placement_topic"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func GeneratePlacementTopicOutreachDraftsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {

View File

@ -6,11 +6,12 @@ package placement_topic
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/placement_topic"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func GetPlacementTopicContentMatrixHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {

View File

@ -6,11 +6,12 @@ package placement_topic
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/placement_topic"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func GetPlacementTopicGraphHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {

View File

@ -6,11 +6,12 @@ package placement_topic
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/placement_topic"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func GetPlacementTopicHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {

View File

@ -6,11 +6,12 @@ package placement_topic
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/placement_topic"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func GetPlacementTopicScanScheduleHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {

View File

@ -6,11 +6,12 @@ package placement_topic
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/placement_topic"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func ListPlacementTopicScanPostsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {

View File

@ -6,11 +6,12 @@ package placement_topic
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/placement_topic"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func PatchPlacementTopicGraphNodesHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {

View File

@ -6,11 +6,12 @@ package placement_topic
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/placement_topic"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func PatchPlacementTopicScanPostOutreachHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {

View File

@ -6,11 +6,12 @@ package placement_topic
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/placement_topic"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func PublishPlacementTopicOutreachDraftHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {

View File

@ -6,11 +6,12 @@ package placement_topic
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/placement_topic"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func StartPlacementTopicScanJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {

View File

@ -6,11 +6,12 @@ package placement_topic
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/placement_topic"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func UpdatePlacementTopicHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {

View File

@ -6,11 +6,12 @@ package placement_topic
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/placement_topic"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func UpsertPlacementTopicScanScheduleHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {

View File

@ -9,6 +9,7 @@ import (
ai "haixun-backend/internal/handler/ai"
auth "haixun-backend/internal/handler/auth"
brand "haixun-backend/internal/handler/brand"
copy_mission "haixun-backend/internal/handler/copy_mission"
job "haixun-backend/internal/handler/job"
member "haixun-backend/internal/handler/member"
normal "haixun-backend/internal/handler/normal"
@ -223,61 +224,86 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
server.AddRoutes(
rest.WithMiddlewares(
[]rest.Middleware{serverCtx.WorkerSecret},
[]rest.Middleware{serverCtx.AuthJWT},
[]rest.Route{
{
Method: http.MethodPost,
Path: "/workers/jobs/:id/analyze-style8d",
Handler: job.AnalyzeStyle8DFromWorkerHandler(serverCtx),
Method: http.MethodGet,
Path: "/:personaId/copy-missions",
Handler: copy_mission.ListCopyMissionsHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/workers/jobs/:id/cancel-ack",
Handler: job.AckWorkerJobCancelHandler(serverCtx),
Path: "/:personaId/copy-mission-inspiration",
Handler: copy_mission.InspireCopyMissionHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/workers/jobs/:id/cancel-check",
Handler: job.CheckWorkerJobCancelHandler(serverCtx),
Path: "/:personaId/copy-missions",
Handler: copy_mission.CreateCopyMissionHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/workers/jobs/:id/complete",
Handler: job.CompleteWorkerJobHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/workers/jobs/:id/fail",
Handler: job.FailWorkerJobHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/workers/jobs/:id/heartbeat",
Handler: job.RefreshWorkerJobLockHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/workers/jobs/:id/progress",
Handler: job.UpdateWorkerJobProgressHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/workers/jobs/claim",
Handler: job.ClaimWorkerJobHandler(serverCtx),
Method: http.MethodGet,
Path: "/:personaId/copy-missions/:id",
Handler: copy_mission.GetCopyMissionHandler(serverCtx),
},
{
Method: http.MethodPatch,
Path: "/workers/personas/:id/style-profile",
Handler: job.StorePersonaStyleProfileFromWorkerHandler(serverCtx),
Path: "/:personaId/copy-missions/:id",
Handler: copy_mission.UpdateCopyMissionHandler(serverCtx),
},
{
Method: http.MethodDelete,
Path: "/:personaId/copy-missions/:id",
Handler: copy_mission.DeleteCopyMissionHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/workers/threads-accounts/:id/session",
Handler: job.GetWorkerThreadsAccountSessionHandler(serverCtx),
Path: "/:personaId/copy-missions/:id/analyze-jobs",
Handler: copy_mission.StartCopyMissionAnalyzeJobHandler(serverCtx),
},
{
Method: http.MethodGet,
Path: "/:personaId/copy-missions/:id/copy-drafts",
Handler: copy_mission.ListCopyMissionCopyDraftsHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/:personaId/copy-missions/:id/matrix-drafts",
Handler: copy_mission.GenerateCopyMissionMatrixHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/:personaId/copy-missions/:id/matrix-jobs",
Handler: copy_mission.StartCopyMissionMatrixJobHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/:personaId/copy-missions/:id/copy-draft-jobs",
Handler: copy_mission.StartCopyMissionCopyDraftJobHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/:personaId/copy-missions/:id/scan-jobs",
Handler: copy_mission.StartCopyMissionScanJobHandler(serverCtx),
},
{
Method: http.MethodGet,
Path: "/:personaId/copy-missions/:id/scan-posts",
Handler: copy_mission.ListCopyMissionScanPostsHandler(serverCtx),
},
{
Method: http.MethodGet,
Path: "/:personaId/copy-missions/:id/scan-schedule",
Handler: copy_mission.GetCopyMissionScanScheduleHandler(serverCtx),
},
{
Method: http.MethodPut,
Path: "/:personaId/copy-missions/:id/scan-schedule",
Handler: copy_mission.UpsertCopyMissionScanScheduleHandler(serverCtx),
},
}...,
),
rest.WithPrefix("/api/v1/internal"),
rest.WithPrefix("/api/v1/personas"),
)
server.AddRoutes(
@ -359,6 +385,65 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
rest.WithPrefix("/api/v1"),
)
server.AddRoutes(
rest.WithMiddlewares(
[]rest.Middleware{serverCtx.WorkerSecret},
[]rest.Route{
{
Method: http.MethodPost,
Path: "/workers/jobs/:id/analyze-style8d",
Handler: job.AnalyzeStyle8DFromWorkerHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/workers/jobs/:id/cancel-ack",
Handler: job.AckWorkerJobCancelHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/workers/jobs/:id/cancel-check",
Handler: job.CheckWorkerJobCancelHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/workers/jobs/:id/complete",
Handler: job.CompleteWorkerJobHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/workers/jobs/:id/fail",
Handler: job.FailWorkerJobHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/workers/jobs/:id/heartbeat",
Handler: job.RefreshWorkerJobLockHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/workers/jobs/:id/progress",
Handler: job.UpdateWorkerJobProgressHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/workers/jobs/claim",
Handler: job.ClaimWorkerJobHandler(serverCtx),
},
{
Method: http.MethodPatch,
Path: "/workers/personas/:id/style-profile",
Handler: job.StorePersonaStyleProfileFromWorkerHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/workers/threads-accounts/:id/session",
Handler: job.GetWorkerThreadsAccountSessionHandler(serverCtx),
},
}...,
),
rest.WithPrefix("/api/v1/internal"),
)
server.AddRoutes(
rest.WithMiddlewares(
[]rest.Middleware{serverCtx.AuthJWT},
@ -452,6 +537,16 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
Path: "/:id/copy-drafts",
Handler: persona.ListPersonaCopyDraftsHandler(serverCtx),
},
{
Method: http.MethodPatch,
Path: "/:id/copy-drafts/:draftId",
Handler: persona.UpdatePersonaCopyDraftHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/:id/copy-drafts/:draftId/publish",
Handler: persona.PublishPersonaCopyDraftHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/:id/copy-drafts/generate",

View File

@ -0,0 +1,191 @@
package exa
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
const defaultBaseURL = "https://api.exa.ai/search"
type Mode string
const (
ModeKnowledgeExpand Mode = "knowledge_expand"
ModeThreadsDiscover Mode = "threads_discover"
)
type SearchResult struct {
Title string
Snippet string
URL string
PublishedDate string
Author string
HighlightScore float64
}
type SearchResponse struct {
Results []SearchResult
Query string
Status string // success | unavailable
}
type Client struct {
apiKey string
baseURL string
http *http.Client
}
func NewClient(apiKey string) *Client {
return &Client{
apiKey: strings.TrimSpace(apiKey),
baseURL: defaultBaseURL,
http: &http.Client{
Timeout: 25 * time.Second,
},
}
}
func (c *Client) Enabled() bool {
return c != nil && c.apiKey != ""
}
type SearchOptions struct {
Query string
Limit int
Mode Mode
UserLocation string
StartPublishedDate string
}
func (c *Client) Search(ctx context.Context, opts SearchOptions) (SearchResponse, error) {
out := SearchResponse{Query: strings.TrimSpace(opts.Query), Status: "unavailable"}
if !c.Enabled() {
return out, nil
}
if out.Query == "" {
return out, fmt.Errorf("exa search query is required")
}
limit := opts.Limit
if limit <= 0 {
limit = 5
}
if limit > 20 {
limit = 20
}
userLocation := strings.TrimSpace(opts.UserLocation)
if userLocation == "" {
userLocation = "TW"
}
body := map[string]any{
"query": out.Query,
"type": "auto",
"numResults": limit,
"userLocation": userLocation,
"contents": map[string]any{
"highlights": true,
},
}
if opts.Mode == ModeThreadsDiscover {
body["includeDomains"] = []string{"threads.net", "threads.com"}
}
if start := strings.TrimSpace(opts.StartPublishedDate); start != "" {
body["startPublishedDate"] = start
}
payload, err := json.Marshal(body)
if err != nil {
return out, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL, bytes.NewReader(payload))
if err != nil {
return out, err
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/json")
req.Header.Set("x-api-key", c.apiKey)
res, err := c.http.Do(req)
if err != nil {
return out, nil
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return out, nil
}
raw, err := io.ReadAll(io.LimitReader(res.Body, 1<<20))
if err != nil {
return out, nil
}
var parsed struct {
Results []struct {
Title string `json:"title"`
URL string `json:"url"`
PublishedDate string `json:"publishedDate"`
Author string `json:"author"`
Highlights []string `json:"highlights"`
HighlightScores []float64 `json:"highlightScores"`
} `json:"results"`
}
if err := json.Unmarshal(raw, &parsed); err != nil {
return out, nil
}
threadsOnly := opts.Mode == ModeThreadsDiscover
for _, item := range parsed.Results {
rawURL := strings.TrimSpace(item.URL)
if rawURL == "" {
continue
}
if threadsOnly && !isThreadsURL(rawURL) {
continue
}
snippet := firstHighlight(item.Highlights)
if snippet == "" {
snippet = strings.TrimSpace(item.Title)
}
score := 0.0
if len(item.HighlightScores) > 0 {
score = item.HighlightScores[0]
}
out.Results = append(out.Results, SearchResult{
Title: strings.TrimSpace(item.Title),
Snippet: snippet,
URL: rawURL,
PublishedDate: strings.TrimSpace(item.PublishedDate),
Author: strings.TrimSpace(item.Author),
HighlightScore: score,
})
if len(out.Results) >= limit {
break
}
}
out.Status = "success"
return out, nil
}
func firstHighlight(items []string) string {
for _, item := range items {
if trimmed := strings.TrimSpace(item); trimmed != "" {
return trimmed
}
}
return ""
}
func isThreadsURL(raw string) bool {
lower := strings.ToLower(raw)
return strings.Contains(lower, "threads.com") || strings.Contains(lower, "threads.net")
}

View File

@ -0,0 +1,46 @@
package exa
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestSearchParsesHighlights(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("x-api-key") != "test-key" {
t.Fatalf("missing api key header")
}
_ = json.NewEncoder(w).Encode(map[string]any{
"results": []map[string]any{
{
"title": "Threads post",
"url": "https://www.threads.net/@alice/post/abc123",
"highlights": []string{"這是一則測試貼文內容"},
"highlightScores": []float64{0.82},
},
},
})
}))
defer server.Close()
client := NewClient("test-key")
client.baseURL = server.URL
res, err := client.Search(context.Background(), SearchOptions{
Query: "Threads 貼文",
Limit: 5,
Mode: ModeThreadsDiscover,
})
if err != nil {
t.Fatalf("search failed: %v", err)
}
if res.Status != "success" || len(res.Results) != 1 {
t.Fatalf("unexpected response: %+v", res)
}
if res.Results[0].Snippet != "這是一則測試貼文內容" {
t.Fatalf("unexpected snippet: %q", res.Results[0].Snippet)
}
}

View File

@ -6,12 +6,13 @@ import (
"sync"
"sync/atomic"
libbrave "haixun-backend/internal/library/brave"
"haixun-backend/internal/library/websearch"
)
type BraveSearchLocale struct {
Country string
SearchLang string
Country string
SearchLang string
UserLocation string
}
type BraveCollectConfig struct {
@ -53,7 +54,19 @@ func DefaultBraveCollectConfig() BraveCollectConfig {
func CollectBraveSources(
ctx context.Context,
client *libbrave.Client,
client websearch.Client,
locale BraveSearchLocale,
queries []string,
cfg BraveCollectConfig,
onProgress func(i, total int),
heartbeat func() error,
) []BraveSource {
return CollectWebSources(ctx, client, locale, queries, cfg, onProgress, heartbeat)
}
func CollectWebSources(
ctx context.Context,
client websearch.Client,
locale BraveSearchLocale,
queries []string,
cfg BraveCollectConfig,
@ -64,14 +77,14 @@ func CollectBraveSources(
return nil
}
if cfg.Concurrency <= 1 {
return collectBraveSourcesSequential(ctx, client, locale, queries, cfg, onProgress, heartbeat)
return collectWebSourcesSequential(ctx, client, locale, queries, cfg, onProgress, heartbeat)
}
return collectBraveSourcesParallel(ctx, client, locale, queries, cfg, onProgress, heartbeat)
return collectWebSourcesParallel(ctx, client, locale, queries, cfg, onProgress, heartbeat)
}
func collectBraveSourcesSequential(
func collectWebSourcesSequential(
ctx context.Context,
client *libbrave.Client,
client websearch.Client,
locale BraveSearchLocale,
queries []string,
cfg BraveCollectConfig,
@ -94,7 +107,7 @@ func collectBraveSourcesSequential(
return out
}
}
appendBraveResults(&out, seenURL, query, searchBraveQuery(ctx, client, locale, query, cfg.ResultsPerQuery))
appendBraveResults(&out, seenURL, query, searchWebQuery(ctx, client, locale, query, cfg.ResultsPerQuery))
if onProgress != nil {
onProgress(i, len(queries))
}
@ -136,9 +149,9 @@ func (s *braveCollectState) appendResults(query string, items []BraveSource) {
}
}
func collectBraveSourcesParallel(
func collectWebSourcesParallel(
ctx context.Context,
client *libbrave.Client,
client websearch.Client,
locale BraveSearchLocale,
queries []string,
cfg BraveCollectConfig,
@ -180,7 +193,7 @@ func collectBraveSourcesParallel(
}
}
query := queries[i]
items := searchBraveQuery(ctx, client, locale, query, cfg.ResultsPerQuery)
items := searchWebQuery(ctx, client, locale, query, cfg.ResultsPerQuery)
state.appendResults(query, items)
done := int(atomic.AddInt32(&state.completed, 1))
if onProgress != nil {
@ -200,19 +213,20 @@ func shouldStopCollect(out []BraveSource, cfg BraveCollectConfig) bool {
return len(out) >= cfg.MinSourcesBeforeStop && uniqueSourceCount(out) >= cfg.MinSourcesBeforeStop
}
func searchBraveQuery(
func searchWebQuery(
ctx context.Context,
client *libbrave.Client,
client websearch.Client,
locale BraveSearchLocale,
query string,
limit int,
) []BraveSource {
res, _ := client.Search(ctx, libbrave.SearchOptions{
Query: query,
Limit: limit,
Mode: libbrave.ModeKnowledgeExpand,
Country: locale.Country,
SearchLang: locale.SearchLang,
res, _ := client.Search(ctx, websearch.SearchOptions{
Query: query,
Limit: limit,
Mode: websearch.ModeKnowledgeExpand,
Country: locale.Country,
SearchLang: locale.SearchLang,
UserLocation: locale.UserLocation,
})
items := make([]BraveSource, 0, len(res.Results))
for _, item := range res.Results {

View File

@ -21,10 +21,14 @@ func ParseExpandStrategy(raw string) ExpandStrategy {
}
}
func (s ExpandStrategy) RequiresBrave() bool {
func (s ExpandStrategy) RequiresWebSearch() bool {
return s == ExpandStrategyBrave || s == ExpandStrategyHybrid
}
func (s ExpandStrategy) RequiresBrave() bool {
return s.RequiresWebSearch()
}
// UsesSupplementalBrave 廣度補充是否再打第二輪 Bravehybrid 改由 LLM 補廣度以省 API
func (s ExpandStrategy) UsesSupplementalBrave() bool {
return s == ExpandStrategyBrave

View File

@ -0,0 +1,84 @@
package matrix
import (
"fmt"
"strings"
libprompt "haixun-backend/internal/library/prompt"
)
type CopyGenerateInput struct {
Count int
TopicLabel string
TopicBrief string
ResearchMap string
SelectedTags []string
ViralSamples string
PersonaBlock string
}
func BuildCopyUserPrompt(in CopyGenerateInput) (string, error) {
count := in.Count
if count <= 0 {
count = 5
}
if count > 12 {
count = 12
}
return libprompt.MatrixCopyUser(map[string]string{
"count": fmt.Sprintf("%d", count),
"topic_label": strings.TrimSpace(in.TopicLabel),
"topic_brief": strings.TrimSpace(in.TopicBrief),
"research_map_block": strings.TrimSpace(in.ResearchMap),
"selected_tags_block": formatTagList(in.SelectedTags),
"viral_samples_block": strings.TrimSpace(in.ViralSamples),
"persona_block": strings.TrimSpace(in.PersonaBlock),
})
}
func formatTagList(tags []string) string {
if len(tags) == 0 {
return "(尚未選擇)"
}
lines := make([]string, 0, len(tags))
for _, tag := range tags {
tag = strings.TrimSpace(tag)
if tag == "" {
continue
}
lines = append(lines, "- "+tag)
}
if len(lines) == 0 {
return "(尚未選擇)"
}
return strings.Join(lines, "\n")
}
func FormatCopyResearchMapBlock(audience, goal string, questions, pillars, exclusions []string) string {
var b strings.Builder
if audience = strings.TrimSpace(audience); audience != "" {
b.WriteString("受眾:")
b.WriteString(audience)
b.WriteString("\n")
}
if goal = strings.TrimSpace(goal); goal != "" {
b.WriteString("內容目標:")
b.WriteString(goal)
b.WriteString("\n")
}
if len(pillars) > 0 {
b.WriteString("支柱:")
b.WriteString(strings.Join(pillars, "、"))
b.WriteString("\n")
}
if len(questions) > 0 {
b.WriteString("受眾問題:")
b.WriteString(strings.Join(questions, ""))
b.WriteString("\n")
}
if len(exclusions) > 0 {
b.WriteString("排除:")
b.WriteString(strings.Join(exclusions, ""))
}
return strings.TrimSpace(b.String())
}

View File

@ -7,6 +7,7 @@ import (
"strings"
libprompt "haixun-backend/internal/library/prompt"
"haixun-backend/internal/library/threadspost"
)
type Row struct {
@ -121,8 +122,8 @@ func ParseGenerateOutput(raw string) (GenerateResult, error) {
func trimText(text string) string {
text = strings.TrimSpace(text)
runes := []rune(text)
if len(runes) > 500 {
return string(runes[:500])
if len(runes) > threadspost.MaxPublishRunes {
return string(runes[:threadspost.MaxPublishRunes])
}
return text
}

View File

@ -0,0 +1,55 @@
package matrix
import (
"fmt"
"strings"
)
type ViralReplySample struct {
Author string
Text string
}
type ViralPostSample struct {
Author string
LikeCount int
SearchTag string
Text string
Replies []ViralReplySample
}
func FormatViralSamples(posts []ViralPostSample) string {
if len(posts) == 0 {
return "(尚無海巡樣本,請依研究地圖與標籤發揮)"
}
var b strings.Builder
limit := 8
if len(posts) < limit {
limit = len(posts)
}
for i := 0; i < limit; i++ {
post := posts[i]
b.WriteString(fmt.Sprintf("\n[%d] @%s · %d讚 · 標籤:%s\n", i+1, post.Author, post.LikeCount, post.SearchTag))
text := strings.TrimSpace(post.Text)
if len([]rune(text)) > 160 {
text = string([]rune(text)[:160])
}
b.WriteString(text)
b.WriteString("\n")
if len(post.Replies) > 0 {
b.WriteString(" 熱門留言:")
for j, reply := range post.Replies {
if j >= 3 {
break
}
rt := strings.TrimSpace(reply.Text)
if len([]rune(rt)) > 60 {
rt = string([]rune(rt)[:60])
}
b.WriteString(fmt.Sprintf("\n - @%s: %s", reply.Author, rt))
}
b.WriteString("\n")
}
}
return strings.TrimSpace(b.String())
}

View File

@ -1,6 +1,11 @@
package placement
import "strings"
import (
"fmt"
"strings"
"haixun-backend/internal/library/websearch"
)
// ConnectionPrefsInput mirrors persisted account connection prefs without importing threads_account.
type ConnectionPrefsInput struct {
@ -18,9 +23,12 @@ type MemberContext struct {
AllowsThreadsAPI bool
AllowsBrave bool
AllowsCrawler bool
WebSearchProvider string
BraveAPIKey string
ExaAPIKey string
BraveCountry string
BraveSearchLang string
ExaUserLocation string
ApiConnected bool
BrowserConnected bool
ThreadsAPIAccessToken string
@ -29,10 +37,13 @@ type MemberContext struct {
}
type ResearchSettings struct {
BraveAPIKey string
BraveCountry string
BraveSearchLang string
ExpandStrategy string
WebSearchProvider string
BraveAPIKey string
ExaAPIKey string
BraveCountry string
BraveSearchLang string
ExaUserLocation string
ExpandStrategy string
}
func BuildMemberContext(
@ -44,20 +55,13 @@ func BuildMemberContext(
repliesPerPost int,
) MemberContext {
mode := ParseSearchSourceMode(prefs.SearchSourceMode)
if prefs.DevMode && strings.TrimSpace(prefs.SearchSourceMode) == "" {
mode = SearchSourceCrawler
}
allowsCrawler := ModeAllowsCrawler(mode)
allowsThreads := ModeAllowsThreadsAPI(mode)
allowsBrave := ModeAllowsBrave(mode)
if !prefs.DevMode {
mode = WithoutCrawler(mode)
allowsCrawler = false
} else {
mode = SearchSourceCrawler
allowsCrawler = true
allowsThreads = false
allowsBrave = false
}
country := strings.TrimSpace(research.BraveCountry)
if country == "" {
country = "tw"
@ -66,45 +70,103 @@ func BuildMemberContext(
if lang == "" {
lang = "zh-hant"
}
userLocation := strings.TrimSpace(research.ExaUserLocation)
if userLocation == "" {
userLocation = "TW"
}
if repliesPerPost <= 0 {
repliesPerPost = 10
}
return MemberContext{
TenantID: tenantID,
OwnerUID: ownerUID,
ActiveAccountID: activeAccountID,
DevMode: prefs.DevMode,
SearchSourceMode: mode,
AllowsThreadsAPI: allowsThreads,
AllowsBrave: allowsBrave,
AllowsCrawler: allowsCrawler,
BraveAPIKey: strings.TrimSpace(research.BraveAPIKey),
BraveCountry: country,
BraveSearchLang: lang,
ApiConnected: apiConnected,
BrowserConnected: browserConnected,
ScrapeReplies: scrapeReplies,
RepliesPerPost: repliesPerPost,
TenantID: tenantID,
OwnerUID: ownerUID,
ActiveAccountID: activeAccountID,
DevMode: prefs.DevMode,
SearchSourceMode: mode,
AllowsThreadsAPI: allowsThreads,
AllowsBrave: allowsBrave,
AllowsCrawler: allowsCrawler,
WebSearchProvider: string(websearch.ParseProvider(research.WebSearchProvider)),
BraveAPIKey: strings.TrimSpace(research.BraveAPIKey),
ExaAPIKey: strings.TrimSpace(research.ExaAPIKey),
BraveCountry: country,
BraveSearchLang: lang,
ExaUserLocation: userLocation,
ApiConnected: apiConnected,
BrowserConnected: browserConnected,
ScrapeReplies: scrapeReplies,
RepliesPerPost: repliesPerPost,
}
}
func (c MemberContext) PayloadFields() map[string]any {
return map[string]any{
"tenant_id": c.TenantID,
"owner_uid": c.OwnerUID,
"threads_account_id": c.ActiveAccountID,
"dev_mode": c.DevMode,
"search_source_mode": string(c.SearchSourceMode),
"allows_threads_api": c.AllowsThreadsAPI,
"allows_brave": c.AllowsBrave,
"allows_crawler": c.AllowsCrawler,
"brave_country": c.BraveCountry,
"brave_search_lang": c.BraveSearchLang,
"api_connected": c.ApiConnected,
"browser_connected": c.BrowserConnected,
"scrape_replies": c.ScrapeReplies,
"replies_per_post": c.RepliesPerPost,
"tenant_id": c.TenantID,
"owner_uid": c.OwnerUID,
"threads_account_id": c.ActiveAccountID,
"dev_mode": c.DevMode,
"search_source_mode": string(c.SearchSourceMode),
"allows_threads_api": c.AllowsThreadsAPI,
"allows_brave": c.AllowsBrave,
"allows_crawler": c.AllowsCrawler,
"web_search_provider": c.WebSearchProvider,
"brave_country": c.BraveCountry,
"brave_search_lang": c.BraveSearchLang,
"exa_user_location": c.ExaUserLocation,
"api_connected": c.ApiConnected,
"browser_connected": c.BrowserConnected,
"scrape_replies": c.ScrapeReplies,
"replies_per_post": c.RepliesPerPost,
}
}
func (c MemberContext) WebSearchProviderEnum() websearch.Provider {
return websearch.ParseProvider(c.WebSearchProvider)
}
func (c MemberContext) WebSearchAPIKey() string {
if c.WebSearchProviderEnum() == websearch.ProviderExa {
return strings.TrimSpace(c.ExaAPIKey)
}
return strings.TrimSpace(c.BraveAPIKey)
}
func (c MemberContext) WebSearchConfig() websearch.Config {
return websearch.ConfigFromMember(
c.BraveAPIKey,
c.ExaAPIKey,
c.WebSearchProvider,
c.BraveCountry,
c.BraveSearchLang,
c.ExaUserLocation,
)
}
func (c MemberContext) WebSearchProviderLabel() string {
return websearch.ProviderLabel(c.WebSearchProviderEnum())
}
func (c MemberContext) WebSearchDiscoverChannel() DiscoverChannel {
if c.WebSearchProviderEnum() == websearch.ProviderExa {
return DiscoverExa
}
return DiscoverBrave
}
func MissingWebSearchKey(research ResearchSettings) bool {
return strings.TrimSpace(websearch.ActiveAPIKey(websearch.ConfigFromMember(
research.BraveAPIKey,
research.ExaAPIKey,
research.WebSearchProvider,
research.BraveCountry,
research.BraveSearchLang,
research.ExaUserLocation,
))) == ""
}
func WebSearchKeyRequiredMessage(research ResearchSettings) string {
provider := websearch.ParseProvider(research.WebSearchProvider)
return fmt.Sprintf("請在設定頁設定 %s Search API key跟隨此登入帳號", websearch.ProviderLabel(provider))
}

View File

@ -26,8 +26,10 @@ type execCrawlerPost struct {
Permalink string `json:"permalink"`
ExternalID string `json:"externalId"`
AuthorName string `json:"authorName"`
LikeCount int `json:"likeCount"`
ReplyCount int `json:"replyCount"`
LikeCount int `json:"likeCount"`
ReplyCount int `json:"replyCount"`
AuthorVerified bool `json:"authorVerified"`
FollowerCount int `json:"followerCount"`
}
type execCrawlerOutput struct {
@ -94,13 +96,15 @@ func RunExecCrawlerSearch(ctx context.Context, storageState, keyword string, lim
permalink := strings.TrimSpace(item.Permalink)
extID := strings.TrimSpace(item.ExternalID)
posts = append(posts, DiscoverPost{
Text: text,
Permalink: permalink,
ExternalID: extID,
Author: author,
LikeCount: item.LikeCount,
ReplyCount: item.ReplyCount,
Source: DiscoverCrawler,
Text: text,
Permalink: permalink,
ExternalID: extID,
Author: author,
AuthorVerified: item.AuthorVerified,
FollowerCount: item.FollowerCount,
LikeCount: item.LikeCount,
ReplyCount: item.ReplyCount,
Source: DiscoverCrawler,
})
}
return posts, nil

View File

@ -11,6 +11,7 @@ type DiscoverChannel string
const (
DiscoverThreadsAPI DiscoverChannel = "threads_api"
DiscoverBrave DiscoverChannel = "brave"
DiscoverExa DiscoverChannel = "exa"
DiscoverCrawler DiscoverChannel = "crawler"
)
@ -25,57 +26,84 @@ type DiscoverRequest struct {
}
type DiscoverPost struct {
Text string
Permalink string
ExternalID string
Author string
PostedAt string
LikeCount int
ReplyCount int
Source DiscoverChannel
Text string
Permalink string
ExternalID string
Author string
PostedAt string
AuthorVerified bool
FollowerCount int
LikeCount int
ReplyCount int
Source DiscoverChannel
}
// Discover runs keyword discovery respecting the member's connection prefs.
// Formal mode (dev_mode=false) never falls back to crawler.
// Discover runs keyword discovery respecting search_source_mode and available connections.
// Crawler-first modes skip Threads API when the browser session returns posts (saves API quota).
func Discover(ctx context.Context, req DiscoverRequest) ([]DiscoverPost, DiscoverChannel, error) {
m := req.Member
if m.DevMode {
if !m.BrowserConnected {
return nil, "", fmt.Errorf("開發模式需先同步 Chrome Session")
if !m.HasDiscoverPath() {
return nil, "", discoverMissingPathError(m)
}
if ShouldTryCrawlerFirst(m) {
posts, err := runCrawlerDiscover(ctx, req)
if err == nil && len(posts) > 0 {
return posts, DiscoverCrawler, nil
}
if req.Crawler == nil {
return nil, DiscoverCrawler, fmt.Errorf("crawler search not configured")
if m.SearchSourceMode == SearchSourceCrawler {
if err != nil {
return nil, DiscoverCrawler, err
}
return posts, DiscoverCrawler, nil
}
keyword := CrawlerKeywordFromQuery(req.Query, req.Keyword)
if keyword == "" {
return nil, DiscoverCrawler, fmt.Errorf("crawler keyword is empty")
}
if m.AllowsThreadsAPI {
if !m.ApiConnected {
if !m.CrawlerFallbackAllowed() {
return nil, "", fmt.Errorf("正式模式需先完成 Threads API 連線")
}
} else {
posts, err := keywordSearchViaThreadsAPI(ctx, req)
if err == nil && len(posts) > 0 {
return posts, DiscoverThreadsAPI, nil
}
if err != nil {
if m.CrawlerFallbackAllowed() {
cPosts, cErr := runCrawlerDiscover(ctx, req)
if cErr == nil && len(cPosts) > 0 {
return cPosts, DiscoverCrawler, nil
}
}
if !m.AllowsBrave && !m.CrawlerFallbackAllowed() {
// Optional API field gaps must not fail the whole patrol; return empty for this keyword.
return []DiscoverPost{}, DiscoverThreadsAPI, nil
}
}
}
posts, err := req.Crawler(ctx, m, keyword, req.Limit)
}
if m.AllowsBrave {
if m.WebSearchAPIKey() == "" {
if m.CrawlerFallbackAllowed() {
posts, err := runCrawlerDiscover(ctx, req)
if err == nil {
return posts, DiscoverCrawler, nil
}
}
return nil, "", fmt.Errorf("請在設定頁設定 %s Search API key跟隨此登入帳號", m.WebSearchProviderLabel())
}
return nil, m.WebSearchDiscoverChannel(), fmt.Errorf("web search threads discover delegated to worker")
}
if m.CrawlerFallbackAllowed() {
posts, err := runCrawlerDiscover(ctx, req)
if err != nil {
return nil, DiscoverCrawler, err
}
return posts, DiscoverCrawler, nil
}
if m.AllowsThreadsAPI {
if !m.ApiConnected {
return nil, "", fmt.Errorf("正式模式需先完成 Threads API 連線")
}
posts, err := keywordSearchViaThreadsAPI(ctx, req)
if err == nil && len(posts) > 0 {
return posts, DiscoverThreadsAPI, nil
}
if err != nil && !m.AllowsBrave {
return nil, "", err
}
}
if m.AllowsBrave {
if m.BraveAPIKey == "" {
return nil, "", fmt.Errorf("請在設定頁設定 Brave Search API key跟隨此登入帳號")
}
return nil, DiscoverBrave, fmt.Errorf("brave threads discover delegated to worker")
}
return nil, "", fmt.Errorf("目前搜尋來源模式無可用管道:%s", m.SearchSourceMode)
}

View File

@ -0,0 +1,113 @@
package placement
import (
"context"
"fmt"
)
// ShouldTryCrawlerFirst reports whether discover should attempt Playwright before Threads API
// to minimize official API calls when a browser session is available.
func ShouldTryCrawlerFirst(m MemberContext) bool {
if !m.AllowsCrawler || !m.BrowserConnected || m.CrawlerBlocked() {
return false
}
switch m.SearchSourceMode {
case SearchSourceCrawler, SearchSourceThreadsCrawler, SearchSourceBraveCrawler:
return true
case SearchSourceMixed, SearchSourceThreadsBrave:
return true
default:
return false
}
}
// CrawlerBlocked returns true when the mode is API-only or web-search-only.
func (m MemberContext) CrawlerBlocked() bool {
switch m.SearchSourceMode {
case SearchSourceThreads, SearchSourceBrave:
return true
default:
return false
}
}
// CrawlerFallbackAllowed returns true when crawler may be used after API/web search fails.
func (m MemberContext) CrawlerFallbackAllowed() bool {
if !m.AllowsCrawler || !m.BrowserConnected {
return false
}
switch m.SearchSourceMode {
case SearchSourceThreadsCrawler, SearchSourceBraveCrawler, SearchSourceMixed:
return true
default:
return false
}
}
// HasDiscoverPath reports whether at least one discover backend is configured and connected.
func (m MemberContext) HasDiscoverPath() bool {
if m.AllowsCrawler && m.BrowserConnected {
return true
}
if m.AllowsThreadsAPI && m.ApiConnected {
return true
}
if m.AllowsBrave && m.WebSearchAPIKey() != "" {
return true
}
return false
}
// DiscoverPathLabel summarizes the active routing for job progress UI.
func (m MemberContext) DiscoverPathLabel() string {
if ShouldTryCrawlerFirst(m) {
if m.AllowsThreadsAPI && m.ApiConnected {
return "爬蟲優先(不足再 API"
}
return "爬蟲"
}
if m.SearchSourceMode == SearchSourceCrawler {
return "爬蟲"
}
if m.AllowsThreadsAPI && m.ApiConnected {
return "Threads API"
}
if m.AllowsBrave {
return m.WebSearchProviderLabel()
}
return string(m.SearchSourceMode)
}
func discoverMissingPathError(m MemberContext) error {
switch m.SearchSourceMode {
case SearchSourceCrawler:
return fmt.Errorf("請先同步 Chrome Session 以使用爬蟲搜尋")
case SearchSourceThreadsCrawler, SearchSourceBraveCrawler:
if !m.BrowserConnected && !m.ApiConnected && m.WebSearchAPIKey() == "" {
return fmt.Errorf("請同步 Chrome Session 或完成 Threads API / Web Search 連線")
}
if !m.BrowserConnected {
return fmt.Errorf("爬蟲優先模式建議先同步 Chrome Session亦可改用僅 Threads API")
}
return fmt.Errorf("請完成 Threads API 或 Web Search 連線作為備援")
case SearchSourceThreads, SearchSourceThreadsBrave:
return fmt.Errorf("請先完成 Threads API 連線")
case SearchSourceBrave:
return fmt.Errorf("請在設定頁設定 %s Search API key", m.WebSearchProviderLabel())
case SearchSourceMixed:
return fmt.Errorf("請同步 Chrome Session、完成 Threads API 或設定 Web Search API key 至少一項")
default:
return fmt.Errorf("目前搜尋來源模式無可用管道:%s", m.SearchSourceMode)
}
}
func runCrawlerDiscover(ctx context.Context, req DiscoverRequest) ([]DiscoverPost, error) {
if req.Crawler == nil {
return nil, fmt.Errorf("crawler search not configured")
}
keyword := CrawlerKeywordFromQuery(req.Query, req.Keyword)
if keyword == "" {
return nil, fmt.Errorf("crawler keyword is empty")
}
return req.Crawler(ctx, req.Member, keyword, req.Limit)
}

View File

@ -0,0 +1,41 @@
package placement
import "testing"
func TestShouldTryCrawlerFirst_mixedWithBrowser(t *testing.T) {
m := MemberContext{
AllowsCrawler: true,
BrowserConnected: true,
SearchSourceMode: SearchSourceMixed,
AllowsThreadsAPI: true,
ApiConnected: true,
}
if !ShouldTryCrawlerFirst(m) {
t.Fatal("mixed + browser should try crawler first to save API")
}
}
func TestShouldTryCrawlerFirst_threadsOnly(t *testing.T) {
m := MemberContext{
AllowsCrawler: true,
BrowserConnected: true,
SearchSourceMode: SearchSourceThreads,
}
if ShouldTryCrawlerFirst(m) {
t.Fatal("threads-only must not use crawler first")
}
}
func TestBuildMemberContextFormalModeKeepsCrawlerMode(t *testing.T) {
prefs := ConnectionPrefsInput{
DevMode: false,
SearchSourceMode: string(SearchSourceThreadsCrawler),
}
ctx := BuildMemberContext("t", "u", "acc", prefs, true, true, ResearchSettings{}, false, 10)
if !ctx.AllowsCrawler {
t.Fatal("threads_crawler in formal mode should allow crawler")
}
if !ctx.AllowsThreadsAPI {
t.Fatal("threads_crawler should still allow API fallback")
}
}

View File

@ -6,8 +6,8 @@ import (
"strings"
"time"
libbrave "haixun-backend/internal/library/brave"
libkg "haixun-backend/internal/library/knowledge"
"haixun-backend/internal/library/websearch"
)
const (
@ -28,6 +28,8 @@ type ScanCandidate struct {
HasRelevance bool
HasRecency bool
Priority string
AuthorVerified bool
FollowerCount int
LikeCount int
ReplyCount int
EngagementScore int
@ -42,7 +44,7 @@ type DualTrackInput struct {
PatrolKeywords []string
Exclusions []string
Member MemberContext
Client *libbrave.Client
WebSearch websearch.Client
Crawler CrawlerSearchFn
Limit int // max queries budget; 0 = default
OnCheckpoint func(candidates []ScanCandidate) error
@ -51,7 +53,7 @@ type DualTrackInput struct {
type DualTrackProgress func(message string, pct int)
// CollectTagQueries builds crawl jobs from selected graph nodes.
func CollectTagQueries(nodes []libkg.Node) []TagQuery {
func CollectTagQueries(nodes []libkg.Node, provider websearch.Provider) []TagQuery {
out := make([]TagQuery, 0, len(nodes)*4)
for _, node := range nodes {
if !node.SelectedForScan {
@ -67,7 +69,7 @@ func CollectTagQueries(nodes []libkg.Node) []TagQuery {
if tag == "" {
continue
}
q := BuildRelevanceQuery(tag)
q := BuildRelevanceQuery(provider, tag)
if q == "" {
continue
}
@ -84,7 +86,7 @@ func CollectTagQueries(nodes []libkg.Node) []TagQuery {
if tag == "" {
continue
}
q7 := BuildRecencyQuery(tag, IdealMaxPostAgeDays)
q7 := BuildRecencyQuery(provider, tag, IdealMaxPostAgeDays)
if q7 != "" {
out = append(out, TagQuery{
Tag: tag,
@ -95,7 +97,7 @@ func CollectTagQueries(nodes []libkg.Node) []TagQuery {
RecencyDays: IdealMaxPostAgeDays,
})
}
q30 := BuildRecencyQuery(tag, MaxPostAgeDays)
q30 := BuildRecencyQuery(provider, tag, MaxPostAgeDays)
if q30 != "" && q30 != q7 {
out = append(out, TagQuery{
Tag: tag,
@ -113,7 +115,7 @@ func CollectTagQueries(nodes []libkg.Node) []TagQuery {
// RunDualTrackDiscover executes relevance + recency queries and merges by permalink.
func RunDualTrackDiscover(ctx context.Context, input DualTrackInput, onProgress DualTrackProgress) ([]ScanCandidate, error) {
queries := ResolveTagQueries(input.Nodes, input.PatrolKeywords)
queries := ResolveTagQueries(input.Nodes, input.PatrolKeywords, input.Member.WebSearchProviderEnum())
if len(queries) == 0 {
if len(input.PatrolKeywords) > 0 {
return nil, fmt.Errorf("海巡關鍵字格式無效,請改用 28 字的真人搜尋短句")
@ -165,6 +167,8 @@ func RunDualTrackDiscover(ctx context.Context, input DualTrackInput, onProgress
Permalink: post.Permalink,
ExternalID: extID,
Author: post.Author,
AuthorVerified: post.AuthorVerified,
FollowerCount: post.FollowerCount,
Text: post.Text,
SearchTag: tq.Tag,
QueryDimension: tq.Dimension,
@ -174,6 +178,8 @@ func RunDualTrackDiscover(ctx context.Context, input DualTrackInput, onProgress
HasRelevance: tq.Dimension == QueryRelevance,
HasRecency: tq.Dimension == QueryRecency,
Priority: priority,
LikeCount: post.LikeCount,
ReplyCount: post.ReplyCount,
PlacementScore: computePlacementScore(post.Text, tq.ProductFitScore, tq.Dimension == QueryRecency),
SolvedByProduct: tq.ProductFitScore >= 55,
PostedAt: strings.TrimSpace(post.PostedAt),
@ -217,7 +223,7 @@ func RunDualTrackDiscover(ctx context.Context, input DualTrackInput, onProgress
return nil, err
}
}
if input.Member.AllowsCrawler && input.Member.DevMode && i < total-1 {
if input.Member.AllowsCrawler && input.Member.BrowserConnected && i < total-1 {
if err := politeDiscoverPause(ctx); err != nil {
return nil, err
}
@ -244,29 +250,31 @@ func discoverForQuery(ctx context.Context, input DualTrackInput, tq TagQuery, li
if err == nil && len(posts) > 0 {
return posts, channel, nil
}
if input.Client == nil || !input.Client.Enabled() {
if input.WebSearch == nil || !input.WebSearch.Enabled() {
if err != nil {
return nil, "", err
}
return nil, "", fmt.Errorf("Brave 未設定且 Threads API 無結果")
return nil, "", fmt.Errorf("%s 未設定且 Threads API 無結果", input.Member.WebSearchProviderLabel())
}
bravePosts, berr := discoverViaBrave(ctx, input.Client, input.Member, tq.Query, limit)
if berr != nil {
webPosts, werr := discoverViaWebSearch(ctx, input.WebSearch, input.Member, tq, limit)
if werr != nil {
if err != nil {
return nil, "", err
}
return nil, "", berr
return nil, "", werr
}
return bravePosts, DiscoverBrave, nil
return webPosts, input.Member.WebSearchDiscoverChannel(), nil
}
func discoverViaBrave(ctx context.Context, client *libbrave.Client, member MemberContext, query string, limit int) ([]DiscoverPost, error) {
res, err := client.Search(ctx, libbrave.SearchOptions{
Query: query,
Limit: limit,
Mode: libbrave.ModeThreadsDiscover,
Country: member.BraveCountry,
SearchLang: member.BraveSearchLang,
func discoverViaWebSearch(ctx context.Context, client websearch.Client, member MemberContext, tq TagQuery, limit int) ([]DiscoverPost, error) {
res, err := client.Search(ctx, websearch.SearchOptions{
Query: tq.Query,
Limit: limit,
Mode: websearch.ModeThreadsDiscover,
Country: member.BraveCountry,
SearchLang: member.BraveSearchLang,
UserLocation: member.ExaUserLocation,
StartPublishedDate: PublishedAfterForRecency(member.WebSearchProviderEnum(), tq.RecencyDays),
})
if err != nil {
return nil, err
@ -274,6 +282,7 @@ func discoverViaBrave(ctx context.Context, client *libbrave.Client, member Membe
if res.Status != "success" || len(res.Results) == 0 {
return nil, nil
}
source := member.WebSearchDiscoverChannel()
out := make([]DiscoverPost, 0, len(res.Results))
for _, item := range res.Results {
parsed, ok := ParseThreadsPostFromWebResult(item.Title, item.Snippet, item.URL)
@ -285,7 +294,7 @@ func discoverViaBrave(ctx context.Context, client *libbrave.Client, member Membe
Permalink: parsed.Permalink,
ExternalID: parsed.ExternalID,
Author: parsed.Author,
Source: DiscoverBrave,
Source: source,
})
}
return out, nil

View File

@ -0,0 +1,16 @@
package placement
import libkg "haixun-backend/internal/library/knowledge"
// EffectiveExpandStrategy returns LLM when web search is required but no API key is configured.
func EffectiveExpandStrategy(research ResearchSettings) libkg.ExpandStrategy {
strategy := libkg.ParseExpandStrategy(research.ExpandStrategy)
if strategy.RequiresWebSearch() && MissingWebSearchKey(research) {
return libkg.ExpandStrategyLLM
}
return strategy
}
func WebSearchAvailable(research ResearchSettings) bool {
return !MissingWebSearchKey(research)
}

View File

@ -0,0 +1,38 @@
package placement
import (
"testing"
libkg "haixun-backend/internal/library/knowledge"
)
func TestEffectiveExpandStrategyFallsBackToLLMWithoutKey(t *testing.T) {
research := ResearchSettings{
ExpandStrategy: string(libkg.ExpandStrategyBrave),
WebSearchProvider: "brave",
BraveAPIKey: "",
}
if got := EffectiveExpandStrategy(research); got != libkg.ExpandStrategyLLM {
t.Fatalf("expected llm fallback, got %s", got)
}
}
func TestEffectiveExpandStrategyKeepsBraveWithKey(t *testing.T) {
research := ResearchSettings{
ExpandStrategy: string(libkg.ExpandStrategyBrave),
WebSearchProvider: "brave",
BraveAPIKey: "test-key",
}
if got := EffectiveExpandStrategy(research); got != libkg.ExpandStrategyBrave {
t.Fatalf("expected brave, got %s", got)
}
}
func TestWebSearchAvailable(t *testing.T) {
if WebSearchAvailable(ResearchSettings{WebSearchProvider: "brave"}) {
t.Fatal("expected unavailable without brave key")
}
if !WebSearchAvailable(ResearchSettings{WebSearchProvider: "brave", BraveAPIKey: "k"}) {
t.Fatal("expected available with brave key")
}
}

View File

@ -4,12 +4,13 @@ import (
"strings"
libkg "haixun-backend/internal/library/knowledge"
"haixun-backend/internal/library/websearch"
)
const defaultPatrolProductFit = 78
// CollectPatrolTagQueries builds dual-track crawl jobs from user-edited patrol keywords only.
func CollectPatrolTagQueries(keywords []string, nodes []libkg.Node) []TagQuery {
func CollectPatrolTagQueries(keywords []string, nodes []libkg.Node, provider websearch.Provider) []TagQuery {
keywords = libkg.NormalizePatrolKeywordList(keywords)
if len(keywords) == 0 {
return nil
@ -18,7 +19,7 @@ func CollectPatrolTagQueries(keywords []string, nodes []libkg.Node) []TagQuery {
out := make([]TagQuery, 0, len(keywords)*3)
for _, tag := range keywords {
fit := productFitForPatrolTag(tag, nodes)
if q := BuildRelevanceQuery(tag); q != "" {
if q := BuildRelevanceQuery(provider, tag); q != "" {
out = append(out, TagQuery{
Tag: tag,
Query: q,
@ -26,7 +27,7 @@ func CollectPatrolTagQueries(keywords []string, nodes []libkg.Node) []TagQuery {
ProductFitScore: fit,
})
}
if q7 := BuildRecencyQuery(tag, IdealMaxPostAgeDays); q7 != "" {
if q7 := BuildRecencyQuery(provider, tag, IdealMaxPostAgeDays); q7 != "" {
out = append(out, TagQuery{
Tag: tag,
Query: q7,
@ -35,8 +36,8 @@ func CollectPatrolTagQueries(keywords []string, nodes []libkg.Node) []TagQuery {
RecencyDays: IdealMaxPostAgeDays,
})
}
if q30 := BuildRecencyQuery(tag, MaxPostAgeDays); q30 != "" {
if q7 := BuildRecencyQuery(tag, IdealMaxPostAgeDays); q30 != q7 {
if q30 := BuildRecencyQuery(provider, tag, MaxPostAgeDays); q30 != "" {
if q7 := BuildRecencyQuery(provider, tag, IdealMaxPostAgeDays); q30 != q7 {
out = append(out, TagQuery{
Tag: tag,
Query: q30,
@ -93,9 +94,9 @@ func patrolTagMatchKey(tag string) string {
}
// ResolveTagQueries prefers explicit patrol keywords over graph node selection.
func ResolveTagQueries(nodes []libkg.Node, patrolKeywords []string) []TagQuery {
func ResolveTagQueries(nodes []libkg.Node, patrolKeywords []string, provider websearch.Provider) []TagQuery {
if len(patrolKeywords) > 0 {
return CollectPatrolTagQueries(patrolKeywords, nodes)
return CollectPatrolTagQueries(patrolKeywords, nodes, provider)
}
return CollectTagQueries(nodes)
return CollectTagQueries(nodes, provider)
}

View File

@ -4,10 +4,11 @@ import (
"testing"
libkg "haixun-backend/internal/library/knowledge"
"haixun-backend/internal/library/websearch"
)
func TestCollectPatrolTagQueriesManualOnly(t *testing.T) {
queries := CollectPatrolTagQueries([]string{"化療 沐浴乳"}, nil)
queries := CollectPatrolTagQueries([]string{"化療 沐浴乳"}, nil, websearch.ProviderBrave)
if len(queries) < 2 {
t.Fatalf("expected relevance + recency queries, got %d", len(queries))
}
@ -30,7 +31,7 @@ func TestCollectPatrolTagQueriesUsesGraphFit(t *testing.T) {
},
},
}
queries := CollectPatrolTagQueries([]string{"化療 沐浴乳"}, nodes)
queries := CollectPatrolTagQueries([]string{"化療 沐浴乳"}, nodes, websearch.ProviderBrave)
if len(queries) == 0 || queries[0].ProductFitScore != 92 {
t.Fatalf("expected graph fit 92, got %+v", queries)
}
@ -40,7 +41,7 @@ func TestResolveTagQueriesPrefersPatrolKeywords(t *testing.T) {
nodes := []libkg.Node{
{ID: "n1", Label: "ignored", SelectedForScan: true, DerivedTags: libkg.DerivedTags{Relevance: []string{"ignored"}}},
}
queries := ResolveTagQueries(nodes, []string{"手動 關鍵字"})
queries := ResolveTagQueries(nodes, []string{"手動 關鍵字"}, websearch.ProviderBrave)
if len(queries) == 0 || queries[0].Tag != "手動 關鍵字" {
t.Fatalf("expected patrol keyword query, got %+v", queries)
}

View File

@ -1,8 +1,11 @@
package placement
import (
"fmt"
"strings"
"time"
"haixun-backend/internal/library/websearch"
)
type QueryDimension string
@ -21,23 +24,36 @@ type TagQuery struct {
RecencyDays int // 0 = no after filter; 7 or 30 for recency track
}
func BuildRelevanceQuery(tag string) string {
func BuildRelevanceQuery(provider websearch.Provider, tag string) string {
tag = strings.TrimSpace(tag)
if tag == "" {
return ""
}
if websearch.ParseProvider(string(provider)) == websearch.ProviderExa {
return fmt.Sprintf("Threads 貼文 繁體中文 %s", tag)
}
return `site:threads.net "` + tag + `"`
}
func BuildRecencyQuery(tag string, maxAgeDays int) string {
func BuildRecencyQuery(provider websearch.Provider, tag string, maxAgeDays int) string {
tag = strings.TrimSpace(tag)
if tag == "" {
return ""
}
if websearch.ParseProvider(string(provider)) == websearch.ProviderExa {
return fmt.Sprintf("Threads 近期貼文 繁體中文 %s", tag)
}
after := FormatAfterDate(maxAgeDays, timeNow())
return `site:threads.net "` + tag + `" 請問 after:` + after
}
func PublishedAfterForRecency(provider websearch.Provider, maxAgeDays int) string {
if maxAgeDays <= 0 || websearch.ParseProvider(string(provider)) != websearch.ProviderExa {
return ""
}
return FormatPublishedAfterISO(maxAgeDays, timeNow())
}
var timeNow = func() time.Time { return time.Now() }
// SetTimeNowForTest overrides time source in tests.

View File

@ -15,3 +15,11 @@ func FormatAfterDate(maxAgeDays int, now time.Time) string {
date := now.AddDate(0, 0, -maxAgeDays).UTC()
return date.Format("2006-01-02")
}
func FormatPublishedAfterISO(maxAgeDays int, now time.Time) string {
if now.IsZero() {
now = time.Now()
}
date := now.AddDate(0, 0, -maxAgeDays).UTC()
return date.Format("2006-01-02T15:04:05.000Z")
}

View File

@ -54,8 +54,8 @@ func ModeAllowsCrawler(mode SearchSourceMode) bool {
}
}
// MemberNeedsBraveKey reports whether placement scan should require a Brave API key.
func MemberNeedsBraveKey(ctx MemberContext) bool {
// MemberNeedsWebSearchKey reports whether placement scan should require a web search API key.
func MemberNeedsWebSearchKey(ctx MemberContext) bool {
if !ctx.AllowsBrave || ctx.DevMode {
return false
}
@ -69,6 +69,11 @@ func MemberNeedsBraveKey(ctx MemberContext) bool {
}
}
// MemberNeedsBraveKey is deprecated; use MemberNeedsWebSearchKey.
func MemberNeedsBraveKey(ctx MemberContext) bool {
return MemberNeedsWebSearchKey(ctx)
}
// WithoutCrawler returns a mode that never uses Playwright, for formal API-only routing.
func WithoutCrawler(mode SearchSourceMode) SearchSourceMode {
switch mode {

View File

@ -26,16 +26,16 @@ func TestMemberNeedsBraveKey(t *testing.T) {
}
}
func TestBuildMemberContextFormalModeNeverAllowsCrawler(t *testing.T) {
func TestBuildMemberContextFormalModeRespectsSearchSource(t *testing.T) {
prefs := ConnectionPrefsInput{
DevMode: false,
SearchSourceMode: string(SearchSourceMixed),
}
ctx := BuildMemberContext("t", "u", "acc", prefs, true, false, ResearchSettings{}, false, 10)
if ctx.AllowsCrawler {
t.Fatal("formal mode must not allow crawler")
if !ctx.AllowsCrawler {
t.Fatal("mixed mode should allow crawler when browser is connected")
}
if ctx.SearchSourceMode != SearchSourceThreadsBrave {
t.Fatalf("expected threads_brave, got %s", ctx.SearchSourceMode)
if ctx.SearchSourceMode != SearchSourceMixed {
t.Fatalf("expected mixed, got %s", ctx.SearchSourceMode)
}
}

View File

@ -148,6 +148,24 @@ func MatrixPlacementUser(vars map[string]string) (string, error) {
return renderTemplate(base, vars), nil
}
// MatrixCopySystem composes copy-mission matrix system prompt.
func MatrixCopySystem() (string, error) {
base, err := Slot(KeyMatrixCopySystem)
if err != nil {
return "", err
}
return ComposeSystem(base)
}
// MatrixCopyUser renders copy matrix user prompt from template slot.
func MatrixCopyUser(vars map[string]string) (string, error) {
base, err := Slot(KeyMatrixCopyUser)
if err != nil {
return "", err
}
return renderTemplate(base, vars), nil
}
// AIChatSystem composes the outgoing system prompt for console AI chat.
func AIChatSystem(clientSystem string) (string, error) {
base := strings.TrimSpace(clientSystem)

View File

@ -44,18 +44,18 @@
## 兩條工作流(必讀,勿混淆)
| 流 | 入口 | 目的 | 關鍵實體 |
| 工作流 | 入口 | 目的 | 關鍵實體 |
|------|------|------|----------|
| **A 拷貝忍者** | `/matrix` | 海巡爆款、學對標風格、產**仿寫**草稿 | 人設 + 8D 對標帳號 |
| **B 找 TA** | `/outreach`子步驟研究→找TA留言 | 找痛點、productFit、產**獲客留言** | 品牌 + 人設語氣 |
| **拷貝忍者** | `/matrix` | 海巡爆款、學對標風格、產**仿寫**草稿 | 人設 + 8D 對標帳號 |
| **找 TA** | `/outreach`子步驟研究→找TA留言 | 找痛點、productFit、產**獲客留言** | 品牌 + 人設語氣 |
分流規則:
- 使用者在 `/matrix` 或問仿寫/爆款/對標 → **只談流程 A**navigate 人設庫或拷貝忍者;**禁止** `expandKnowledgeGraph`、`startScan`、`generateOutreachReply`
- 使用者在 `/research`、`/outreach` 或問痛點/產品置入 → **只談流程 B****禁止**建議 8D 對標當主要解法
- 原創矩陣屬於流程 A 的 `/matrix`,不要在找 TA流程 B 裡推薦或顯示 `/brand-matrix`
- 「海巡來源模式」search_source_mode是 API/爬蟲管道,**不是** A/B 流程
- 使用者在 `/matrix` 或問仿寫/爆款/對標 → **只談拷貝忍者**navigate 人設庫或拷貝忍者;**禁止** `expandKnowledgeGraph`、`startScan`、`generateOutreachReply`
- 使用者在 `/research`、`/outreach` 或問痛點/產品置入 → **只談找 TA****禁止**建議 8D 對標當主要解法
- 原創矩陣屬於拷貝忍者的 `/matrix`,不要在找 TA 裡推薦或顯示 `/brand-matrix`
- 「海巡來源模式」search_source_mode是 API/爬蟲管道,**不是**拷貝忍者/找 TA 的區分
## 流程 A — 拷貝忍者
## 拷貝忍者
- 入口:`/matrix`(仿寫草稿庫 + 爆款海巡)
- 對標與 8D人設詳情 `/personas/:id#style-8d`
@ -74,7 +74,7 @@
```
## 流程 B — 海巡獲客(研究頁 / 獲客台)
## 找 TA(研究頁 / 獲客台)
### 海巡研究頁(`/research`
- 擴展圖譜:`expandKnowledgeGraph``seed_query` 可省略=用頁面種子詞;`supplemental=true` 補充痛點)

View File

@ -0,0 +1,10 @@
你是 Threads 內容矩陣策劃師,為人設帳號產出多篇可發佈草稿。
規則:
- 只回傳 JSON格式為 {"rows":[...]}。
- 每篇必須角度不同,避免重複 hook。
- 套用人設語氣與 8D不要寫成品牌廣告或硬銷。
- 參考爆款樣本只學結構與節奏,不抄原文。
- 繁體中文,口語自然,適合 Threads。
- 每篇 text 主文 ≤ 500 字Threads API 硬上限,含 #話題標籤)。
- 爆款互動最佳 80220 字:前 12 行強 hook一句一重點超過 300 字互動通常下降。

View File

@ -0,0 +1,18 @@
請為以下拷貝任務產出 {{count}} 篇 Threads 草稿。
任務主題:{{topic_label}}
Brief{{topic_brief}}
研究地圖:
{{research_map_block}}
已選海巡標籤:
{{selected_tags_block}}
爆款樣本(只學結構):
{{viral_samples_block}}
人設 8D
{{persona_block}}
回傳 JSON rows每筆含 sort_order、search_tag、angle、hook、text、reference_notes、source_permalinks、rationale。

View File

@ -25,6 +25,8 @@ const (
fileOutreachPlacementUser = "files/outreach_placement.user.md"
fileMatrixPlacementSystem = "files/matrix_placement.system.md"
fileMatrixPlacementUser = "files/matrix_placement.user.md"
fileMatrixCopySystem = "files/matrix_copy.system.md"
fileMatrixCopyUser = "files/matrix_copy.user.md"
)
// Keys identify prompt slots loaded from internal/library/prompt/files/*.md.
@ -43,6 +45,8 @@ const (
KeyOutreachPlacementUser = "outreach_placement.user"
KeyMatrixPlacementSystem = "matrix_placement.system"
KeyMatrixPlacementUser = "matrix_placement.user"
KeyMatrixCopySystem = "matrix_copy.system"
KeyMatrixCopyUser = "matrix_copy.user"
)
var slotFiles = map[string]string{
@ -59,6 +63,8 @@ var slotFiles = map[string]string{
KeyOutreachPlacementUser: fileOutreachPlacementUser,
KeyMatrixPlacementSystem: fileMatrixPlacementSystem,
KeyMatrixPlacementUser: fileMatrixPlacementUser,
KeyMatrixCopySystem: fileMatrixCopySystem,
KeyMatrixCopyUser: fileMatrixCopyUser,
}
var (

View File

@ -0,0 +1,104 @@
package style8d
import (
"encoding/json"
"strings"
)
var dimensionLabels = map[string]string{
"d1Tone": "D1 語氣人格",
"d2Structure": "D2 結構模板",
"d3Interaction": "D3 互動方式",
"d4Topics": "D4 主題分布",
"d5Rhythm": "D5 發文節奏",
"d6Visual": "D6 視覺語法",
"d7Conversion": "D7 轉換方式",
"d8Risk": "D8 風險紅線",
}
var dimensionOrder = []string{
"d1Tone", "d2Structure", "d3Interaction", "d4Topics",
"d5Rhythm", "d6Visual", "d7Conversion", "d8Risk",
}
// ParseStoredProfile decodes the persona.style_profile JSON blob.
func ParseStoredProfile(raw string) (*StoredProfile, bool) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, false
}
var profile StoredProfile
if err := json.Unmarshal([]byte(raw), &profile); err != nil {
return nil, false
}
if len(profile.Analysis) == 0 && strings.TrimSpace(profile.PersonaDraft) == "" {
return nil, false
}
return &profile, true
}
// HasReady8D returns true when 8D analysis exists and can drive copy generation.
func HasReady8D(personaText, styleProfileJSON string) bool {
if profile, ok := ParseStoredProfile(styleProfileJSON); ok {
if strings.TrimSpace(profile.PersonaDraft) != "" {
return true
}
for _, key := range dimensionOrder {
if summary := strings.TrimSpace(profile.Analysis[key].Summary); summary != "" {
return true
}
}
}
return strings.TrimSpace(personaText) != "" && strings.TrimSpace(styleProfileJSON) != ""
}
// BuildStyle8DPromptBlock formats D1D8 summaries for LLM prompts.
func BuildStyle8DPromptBlock(styleProfileJSON string) string {
profile, ok := ParseStoredProfile(styleProfileJSON)
if !ok {
return ""
}
lines := make([]string, 0, len(dimensionOrder))
for _, key := range dimensionOrder {
summary := strings.TrimSpace(profile.Analysis[key].Summary)
if summary == "" {
continue
}
label := dimensionLabels[key]
if label == "" {
label = key
}
lines = append(lines, label+""+summary)
}
if len(lines) == 0 {
return ""
}
var b strings.Builder
b.WriteString("【8D 風格策略】\n產文必須遵守\n")
b.WriteString(strings.Join(lines, "\n"))
return b.String()
}
// ResolvePersonaBlock picks the best persona voice block for copy generation.
// Priority: explicit persona field → 8D personaDraft → brief fallback; always append 8D strategy when present.
func ResolvePersonaBlock(personaText, styleProfileJSON, brief string) string {
var parts []string
voice := strings.TrimSpace(personaText)
if voice == "" {
if profile, ok := ParseStoredProfile(styleProfileJSON); ok {
voice = strings.TrimSpace(profile.PersonaDraft)
}
}
if voice == "" {
voice = strings.TrimSpace(brief)
}
if voice != "" {
parts = append(parts, "【人設語氣】\n"+voice)
}
if block := BuildStyle8DPromptBlock(styleProfileJSON); block != "" {
parts = append(parts, block)
}
return strings.TrimSpace(strings.Join(parts, "\n\n"))
}

View File

@ -0,0 +1,26 @@
package style8d
import (
"strings"
"testing"
)
func TestResolvePersonaBlockUsesPersonaDraftWhenPersonaEmpty(t *testing.T) {
raw := `{"username":"demo","analysis":{"d1Tone":{"summary":"口語親近","evidence":[]}},"personaDraft":"【我是誰】\n生活觀察者"}`
block := ResolvePersonaBlock("", raw, "")
if block == "" {
t.Fatal("expected non-empty block")
}
for _, part := range []string{"生活觀察者", "D1 語氣人格", "口語親近"} {
if !strings.Contains(block, part) {
t.Fatalf("block missing %q: %q", part, block)
}
}
}
func TestHasReady8DFromAnalysisOnly(t *testing.T) {
raw := `{"analysis":{"d2Structure":{"summary":"短句開場","evidence":[]}}}`
if !HasReady8D("", raw) {
t.Fatal("expected ready from analysis summary")
}
}

View File

@ -9,15 +9,21 @@ import (
"net/url"
"strings"
"time"
)
const maxPublishChars = 500
"haixun-backend/internal/library/threadspost"
)
type PublishResult struct {
MediaID string
Permalink string
}
type PublishTextInput struct {
ThreadsUserID string
AccessToken string
Text string
}
type PublishReplyInput struct {
ThreadsUserID string
AccessToken string
@ -25,6 +31,36 @@ type PublishReplyInput struct {
Text string
}
// PublishText posts a new text thread via Graph API.
func PublishText(ctx context.Context, in PublishTextInput) (*PublishResult, error) {
userID := strings.TrimSpace(in.ThreadsUserID)
token := strings.TrimSpace(in.AccessToken)
text := strings.TrimSpace(in.Text)
if userID == "" || token == "" {
return nil, fmt.Errorf("threads api credentials incomplete")
}
if text == "" {
return nil, fmt.Errorf("post text is required")
}
if err := threadspost.ValidatePublish(text); err != nil {
return nil, err
}
containerID, err := createTextContainer(ctx, userID, token, text)
if err != nil {
return nil, err
}
if err := waitForContainerReady(ctx, containerID, token); err != nil {
return nil, err
}
mediaID, err := publishContainer(ctx, userID, token, containerID)
if err != nil {
return nil, err
}
permalink, _ := fetchPermalink(ctx, mediaID, token)
return &PublishResult{MediaID: mediaID, Permalink: permalink}, nil
}
// PublishReply posts a text reply to an existing Threads media via Graph API.
func PublishReply(ctx context.Context, in PublishReplyInput) (*PublishResult, error) {
userID := strings.TrimSpace(in.ThreadsUserID)
@ -40,8 +76,8 @@ func PublishReply(ctx context.Context, in PublishReplyInput) (*PublishResult, er
if text == "" {
return nil, fmt.Errorf("reply text is required")
}
if len([]rune(text)) > maxPublishChars {
return nil, fmt.Errorf("reply exceeds %d characters", maxPublishChars)
if err := threadspost.ValidateReply(text); err != nil {
return nil, err
}
containerID, err := createReplyContainer(ctx, userID, token, replyTo, text)
@ -59,6 +95,35 @@ func PublishReply(ctx context.Context, in PublishReplyInput) (*PublishResult, er
return &PublishResult{MediaID: mediaID, Permalink: permalink}, nil
}
func createTextContainer(ctx context.Context, userID, token, text string) (string, error) {
params := url.Values{}
params.Set("access_token", token)
params.Set("media_type", "TEXT")
params.Set("text", text)
endpoint := graphBaseURL + "/" + userID + "/threads?" + params.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, nil)
if err != nil {
return "", err
}
body, status, err := doRequest(req)
if err != nil {
return "", err
}
if status != http.StatusOK {
return "", parseAPIError(body, status)
}
var payload struct {
ID string `json:"id"`
}
if err := json.Unmarshal(body, &payload); err != nil {
return "", err
}
if strings.TrimSpace(payload.ID) == "" {
return "", fmt.Errorf("threads api did not return container id")
}
return payload.ID, nil
}
func createReplyContainer(ctx context.Context, userID, token, replyTo, text string) (string, error) {
params := url.Values{}
params.Set("access_token", token)

View File

@ -0,0 +1,100 @@
package threadspost
import (
"fmt"
"strings"
)
// Threads Graph API text post hard limit (main post body).
const MaxPublishRunes = 500
// Threads reply/comment limit (outreach, not main post).
const MaxReplyRunes = 1000
// ViralSweetMin/Max: engagement sweet spot on Threads feed (Buffer / creator data, 20242026).
const ViralSweetMin = 80
const ViralSweetMax = 220
// ViralSoftWarn: engagement tends to drop beyond this length in the main visible post.
const ViralSoftWarn = 300
type LengthBand string
const (
BandEmpty LengthBand = "empty"
BandTooShort LengthBand = "too_short"
BandSweet LengthBand = "sweet"
BandLong LengthBand = "long"
BandOverSoft LengthBand = "over_soft"
BandOverHard LengthBand = "over_hard"
)
func RuneLen(text string) int {
return len([]rune(strings.TrimSpace(text)))
}
func ClampPublish(text string) string {
text = strings.TrimSpace(text)
runes := []rune(text)
if len(runes) > MaxPublishRunes {
return string(runes[:MaxPublishRunes])
}
return text
}
func PublishBand(text string) LengthBand {
n := RuneLen(text)
switch {
case n == 0:
return BandEmpty
case n < ViralSweetMin:
return BandTooShort
case n <= ViralSweetMax:
return BandSweet
case n <= ViralSoftWarn:
return BandLong
case n <= MaxPublishRunes:
return BandOverSoft
default:
return BandOverHard
}
}
func ValidatePublish(text string) error {
if strings.TrimSpace(text) == "" {
return fmt.Errorf("貼文內文不可為空")
}
n := RuneLen(text)
if n > MaxPublishRunes {
return fmt.Errorf("貼文超過 Threads 上限 %d 字(目前 %d 字)", MaxPublishRunes, n)
}
return nil
}
func ValidateReply(text string) error {
if strings.TrimSpace(text) == "" {
return fmt.Errorf("留言內文不可為空")
}
n := RuneLen(text)
if n > MaxReplyRunes {
return fmt.Errorf("留言超過 Threads 上限 %d 字(目前 %d 字)", MaxReplyRunes, n)
}
return nil
}
func PublishHint(text string) string {
switch PublishBand(text) {
case BandTooShort:
return fmt.Sprintf("偏短(爆款常見 %d%d 字)", ViralSweetMin, ViralSweetMax)
case BandSweet:
return fmt.Sprintf("長度適中(爆款常見 %d%d 字)", ViralSweetMin, ViralSweetMax)
case BandLong:
return fmt.Sprintf("略長(超過 %d 字互動可能下降)", ViralSoftWarn)
case BandOverSoft:
return fmt.Sprintf("接近 Threads 主文上限 %d 字", MaxPublishRunes)
case BandOverHard:
return fmt.Sprintf("超過 Threads 上限 %d 字,請縮短", MaxPublishRunes)
default:
return ""
}
}

View File

@ -0,0 +1,49 @@
package threadspost
import "testing"
func TestClampPublish(t *testing.T) {
var long string
for range MaxPublishRunes + 10 {
long += "字"
}
clamped := ClampPublish(long)
if RuneLen(clamped) != MaxPublishRunes {
t.Fatalf("expected %d runes, got %d", MaxPublishRunes, RuneLen(clamped))
}
}
func TestValidatePublish(t *testing.T) {
if err := ValidatePublish(""); err == nil {
t.Fatal("expected empty error")
}
if err := ValidatePublish(string(make([]rune, MaxPublishRunes+1))); err == nil {
t.Fatal("expected over-limit error")
}
if err := ValidatePublish("爆款貼文"); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestPublishBand(t *testing.T) {
cases := []struct {
len int
want LengthBand
}{
{0, BandEmpty},
{50, BandTooShort},
{120, BandSweet},
{250, BandLong},
{450, BandOverSoft},
{501, BandOverHard},
}
for _, c := range cases {
text := ""
for range c.len {
text += "字"
}
if got := PublishBand(text); got != c.want {
t.Fatalf("len %d: got %s want %s", c.len, got, c.want)
}
}
}

View File

@ -0,0 +1,101 @@
package viral
import (
"encoding/json"
"fmt"
"strings"
)
type ViralAnalysis struct {
HookPattern string `json:"hookPattern"`
StructurePattern string `json:"structurePattern"`
EmotionalTrigger string `json:"emotionalTrigger"`
ReplicationStrategy string `json:"replicationStrategy"`
KeyTakeaways []string `json:"keyTakeaways"`
}
type AnalyzeViralInput struct {
PostText string
AuthorName string
LikeCount int
ReplyCount int
SearchTag string
TopicLabel string
TopicBrief string
Persona string
}
func BuildAnalyzeViralSystemPrompt() string {
return strings.TrimSpace(`你是 Threads 爆款結構分析師拆解貼文為什麼會紅怎麼仿寫結構不要建議抄襲原文
只回傳 JSONhookPattern, structurePattern, emotionalTrigger, replicationStrategy, keyTakeaways字串陣列 35 繁體中文`)
}
func BuildAnalyzeViralUserPrompt(in AnalyzeViralInput) string {
var b strings.Builder
b.WriteString("主題:")
b.WriteString(strings.TrimSpace(in.TopicLabel))
b.WriteString("\n")
if brief := strings.TrimSpace(in.TopicBrief); brief != "" {
b.WriteString("Brief")
b.WriteString(brief)
b.WriteString("\n")
}
if tag := strings.TrimSpace(in.SearchTag); tag != "" {
b.WriteString("搜尋標籤:")
b.WriteString(tag)
b.WriteString("\n")
}
author := strings.TrimSpace(in.AuthorName)
if author == "" {
author = "匿名"
}
b.WriteString(fmt.Sprintf("\n作者 @%s · %d 讚 · %d 留言\n", author, in.LikeCount, in.ReplyCount))
b.WriteString("\n貼文\n")
b.WriteString(strings.TrimSpace(in.PostText))
return b.String()
}
func ParseAnalyzeViralOutput(raw string) (ViralAnalysis, error) {
payload, err := extractCopyMapJSON(raw)
if err != nil {
return ViralAnalysis{}, err
}
var out ViralAnalysis
if err := json.Unmarshal(payload, &out); err != nil {
return ViralAnalysis{}, fmt.Errorf("parse viral analysis: %w", err)
}
if strings.TrimSpace(out.HookPattern) == "" && strings.TrimSpace(out.StructurePattern) == "" {
return ViralAnalysis{}, fmt.Errorf("viral analysis missing content")
}
return out, nil
}
func FormatAnalysisForReplicate(analysis ViralAnalysis) string {
var b strings.Builder
if hook := strings.TrimSpace(analysis.HookPattern); hook != "" {
b.WriteString("Hook 模式:")
b.WriteString(hook)
b.WriteString("\n")
}
if structure := strings.TrimSpace(analysis.StructurePattern); structure != "" {
b.WriteString("結構節奏:")
b.WriteString(structure)
b.WriteString("\n")
}
if emotion := strings.TrimSpace(analysis.EmotionalTrigger); emotion != "" {
b.WriteString("情緒觸發:")
b.WriteString(emotion)
b.WriteString("\n")
}
if strategy := strings.TrimSpace(analysis.ReplicationStrategy); strategy != "" {
b.WriteString("仿寫策略:")
b.WriteString(strategy)
b.WriteString("\n")
}
if len(analysis.KeyTakeaways) > 0 {
b.WriteString("重點:")
b.WriteString(strings.Join(analysis.KeyTakeaways, ""))
}
return strings.TrimSpace(b.String())
}

View File

@ -10,16 +10,21 @@ import (
const (
defaultLimitPerKeyword = 15
missionLimitPerKeyword = 10
maxKeywords = 6
maxMergedPosts = 60
missionMaxMergedPosts = 40
missionQualityTarget = 12 // stop scanning extra keywords once enough quality posts
)
type DiscoverInput struct {
Keywords []string
Exclusions []string
Member placement.MemberContext
Crawler placement.CrawlerSearchFn
Limit int // per keyword; 0 = default
Keywords []string
Exclusions []string
Member placement.MemberContext
Crawler placement.CrawlerSearchFn
Limit int // per keyword; 0 = default
MaxMerged int // total cap; 0 = default
MissionScan bool // leaner defaults to save search API quota
}
type ProgressFn func(message string, pct int)
@ -32,26 +37,58 @@ func RunDiscover(ctx context.Context, input DiscoverInput, progress ProgressFn)
}
perKeyword := input.Limit
if perKeyword <= 0 {
perKeyword = defaultLimitPerKeyword
if input.MissionScan {
perKeyword = missionLimitPerKeyword
} else {
perKeyword = defaultLimitPerKeyword
}
}
maxMerged := input.MaxMerged
if maxMerged <= 0 {
if input.MissionScan {
maxMerged = missionMaxMergedPosts
} else {
maxMerged = maxMergedPosts
}
}
merged := map[string]placement.ScanCandidate{}
relaxed := map[string]placement.ScanCandidate{}
total := len(keywords)
pathLabel := input.Member.DiscoverPathLabel()
var lastErr error
keywordsAttempted := 0
for i, keyword := range keywords {
if input.MissionScan && countMissionQuality(merged) >= missionQualityTarget {
if progress != nil {
progress(fmt.Sprintf("已收足 %d 篇品質候選,略過剩餘標籤以節省搜尋次數", missionQualityTarget), 10+(i*70)/max(total, 1))
}
break
}
if progress != nil {
pct := 10 + (i*70)/total
progress(fmt.Sprintf("掃描關鍵字「%s」…", keyword), pct)
progress(fmt.Sprintf("掃描「%s」%s…", keyword, pathLabel), pct)
}
limit := perKeyword
if input.MissionScan && len(merged) > 0 {
limit = min(perKeyword, 8)
}
posts, _, err := placement.Discover(ctx, placement.DiscoverRequest{
Query: keyword,
Keyword: keyword,
Limit: perKeyword,
Limit: limit,
Member: input.Member,
Crawler: input.Crawler,
})
if err != nil {
return nil, fmt.Errorf("關鍵字「%s」%w", keyword, err)
lastErr = err
if progress != nil {
progress(fmt.Sprintf("「%s」搜尋略過%s", keyword, shortenDiscoverErr(err)), 10+(i*70)/max(total, 1))
}
continue
}
keywordsAttempted++
for _, post := range posts {
key := strings.TrimSpace(post.Permalink)
if key == "" {
@ -61,13 +98,12 @@ func RunDiscover(ctx context.Context, input DiscoverInput, progress ProgressFn)
continue
}
score := ScorePost(post.LikeCount, post.ReplyCount)
if !PassesViralCandidate(post.Text, post.LikeCount, post.ReplyCount, score, input.Exclusions) {
continue
}
candidate := placement.ScanCandidate{
Permalink: post.Permalink,
ExternalID: post.ExternalID,
Author: post.Author,
AuthorVerified: post.AuthorVerified,
FollowerCount: post.FollowerCount,
Text: post.Text,
SearchTag: keyword,
Source: post.Source,
@ -77,19 +113,42 @@ func RunDiscover(ctx context.Context, input DiscoverInput, progress ProgressFn)
PlacementScore: score,
Priority: PriorityLabel(score),
}
if prev, ok := merged[key]; !ok || candidate.EngagementScore > prev.EngagementScore {
merged[key] = candidate
if input.MissionScan {
if PassesMissionQualityCandidate(
post.Text, post.LikeCount, post.ReplyCount, score,
post.AuthorVerified, post.FollowerCount, input.Exclusions,
) {
mergeCandidate(merged, key, candidate)
continue
}
if PassesViralCandidate(post.Text, post.LikeCount, post.ReplyCount, score, input.Exclusions) {
mergeCandidate(relaxed, key, candidate)
}
continue
}
if !PassesViralCandidate(post.Text, post.LikeCount, post.ReplyCount, score, input.Exclusions) {
continue
}
mergeCandidate(merged, key, candidate)
}
}
out := make([]placement.ScanCandidate, 0, len(merged))
for _, item := range merged {
out = append(out, item)
if input.MissionScan && len(merged) == 0 && len(relaxed) > 0 {
merged = relaxed
if progress != nil {
progress("未取得藍勾等延伸資料,改以互動門檻收斂爆款候選", 82)
}
}
out := candidatesFromMap(merged)
sortByEngagement(out)
if len(out) > maxMergedPosts {
out = out[:maxMergedPosts]
if len(out) > maxMerged {
out = out[:maxMerged]
}
if len(out) == 0 {
if keywordsAttempted == 0 && lastErr != nil {
return nil, fmt.Errorf("所有標籤搜尋均失敗:%w", lastErr)
}
}
if progress != nil {
progress(fmt.Sprintf("合併 %d 篇爆款候選", len(out)), 85)
@ -97,11 +156,37 @@ func RunDiscover(ctx context.Context, input DiscoverInput, progress ProgressFn)
return out, nil
}
func mergeCandidate(merged map[string]placement.ScanCandidate, key string, candidate placement.ScanCandidate) {
if prev, ok := merged[key]; !ok {
merged[key] = candidate
} else if candidate.EngagementScore > prev.EngagementScore {
merged[key] = MergeAuthorSignals(candidate, prev)
} else {
merged[key] = MergeAuthorSignals(prev, candidate)
}
}
func candidatesFromMap(merged map[string]placement.ScanCandidate) []placement.ScanCandidate {
out := make([]placement.ScanCandidate, 0, len(merged))
for _, item := range merged {
out = append(out, item)
}
return out
}
func shortenDiscoverErr(err error) string {
msg := strings.TrimSpace(err.Error())
if len(msg) > 80 {
return msg[:80] + "…"
}
return msg
}
func normalizeKeywords(raw []string) []string {
seen := map[string]struct{}{}
out := make([]string, 0, len(raw))
for _, item := range raw {
kw := strings.TrimSpace(item)
kw := DiscoverKeywordFromTag(item)
if kw == "" {
continue
}
@ -117,6 +202,26 @@ func normalizeKeywords(raw []string) []string {
return out
}
func countMissionQuality(merged map[string]placement.ScanCandidate) int {
n := 0
for _, item := range merged {
if PassesMissionQualityCandidate(
item.Text, item.LikeCount, item.ReplyCount, item.EngagementScore,
item.AuthorVerified, item.FollowerCount, nil,
) {
n++
}
}
return n
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
func sortByEngagement(items []placement.ScanCandidate) {
for i := 0; i < len(items); i++ {
for j := i + 1; j < len(items); j++ {

View File

@ -0,0 +1,205 @@
package viral
import (
"context"
"regexp"
"sort"
"strings"
"haixun-backend/internal/library/websearch"
)
const (
maxAccountDiscoverQueries = 2
MaxSimilarAccounts = 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"`
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
}
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]
if !ok || weight > prev.score {
seen[key] = accountCandidate{
username: username,
score: weight,
reason: reason,
source: "web",
}
} else if ok {
prev.score += 1
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 {
accounts = append(accounts, SimilarAccount{
Username: item.username,
Reason: item.reason,
Source: item.source,
Confidence: accountConfidence(item.score),
ProfileURL: "https://www.threads.net/@" + item.username,
})
}
return accounts, nil
}
func buildAccountDiscoverQueries(seed, brief string, pillars []string) []string {
quoted := `"` + seed + `"`
queries := []string{
`site:threads.net ` + quoted,
`threads ` + quoted + ` 創作者`,
}
if hint := strings.TrimSpace(brief); len([]rune(hint)) >= 4 && len([]rune(hint)) <= 24 {
queries = append(queries, `site:threads.net `+quoted+` `+hint)
}
for _, pillar := range pillars {
pillar = strings.TrimSpace(pillar)
if len([]rune(pillar)) >= 4 && len(queries) < maxAccountDiscoverQueries+1 {
queries = append(queries, `site:threads.net "`+pillar+`"`)
}
}
unique := []string{}
seen := map[string]struct{}{}
for _, q := range queries {
q = strings.TrimSpace(q)
if q == "" {
continue
}
if _, ok := seen[q]; ok {
continue
}
seen[q] = struct{}{}
unique = append(unique, q)
if len(unique) >= maxAccountDiscoverQueries {
break
}
}
return unique
}
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"
}

View File

@ -0,0 +1,27 @@
package viral
import "testing"
func TestExtractUsernames(t *testing.T) {
blob := `See https://www.threads.net/@creator_one and threads.com/@creator_two/posts/abc`
got := extractUsernames(blob)
if len(got) != 2 {
t.Fatalf("expected 2 usernames, got %d (%v)", len(got), got)
}
}
func TestIsValidUsernameRejectsReserved(t *testing.T) {
if isValidUsername("login") {
t.Fatal("reserved username should be rejected")
}
if !isValidUsername("real_creator") {
t.Fatal("valid username rejected")
}
}
func TestBuildAccountDiscoverQueriesCapsAtTwo(t *testing.T) {
queries := buildAccountDiscoverQueries("轉職", "想找語錄", []string{"支柱A", "支柱B", "支柱C"})
if len(queries) > maxAccountDiscoverQueries {
t.Fatalf("expected at most %d queries, got %d", maxAccountDiscoverQueries, len(queries))
}
}

View File

@ -0,0 +1,17 @@
package viral
import (
"testing"
"haixun-backend/internal/library/placement"
)
func TestCountMissionQuality(t *testing.T) {
merged := map[string]placement.ScanCandidate{
"a": {Text: "轉職技巧分享", LikeCount: 25, ReplyCount: 4, EngagementScore: 65},
"b": {Text: "轉職", LikeCount: 3, ReplyCount: 0, EngagementScore: 8},
}
if got := countMissionQuality(merged); got != 1 {
t.Fatalf("expected 1 quality post, got %d", got)
}
}

View File

@ -0,0 +1,72 @@
package viral
import (
"context"
"errors"
"testing"
"haixun-backend/internal/library/placement"
)
func TestRunDiscover_skipsFailedKeyword(t *testing.T) {
calls := 0
crawler := func(ctx context.Context, m placement.MemberContext, keyword string, limit int) ([]placement.DiscoverPost, error) {
calls++
if keyword == "bad" {
return nil, errors.New("api timeout")
}
return []placement.DiscoverPost{{
Text: "轉職面試技巧分享心得", Author: "ok_user", LikeCount: 30, ReplyCount: 4,
Permalink: "https://www.threads.net/@ok_user/post/1", Source: placement.DiscoverCrawler,
}}, nil
}
member := placement.MemberContext{
AllowsCrawler: true,
BrowserConnected: true,
SearchSourceMode: placement.SearchSourceCrawler,
}
out, err := RunDiscover(context.Background(), DiscoverInput{
Keywords: []string{"bad", "轉職"},
Member: member,
Crawler: crawler,
MissionScan: true,
}, nil)
if err != nil {
t.Fatalf("expected partial success, got err: %v", err)
}
if len(out) != 1 {
t.Fatalf("expected 1 candidate, got %d", len(out))
}
if calls != 2 {
t.Fatalf("expected 2 keyword attempts, got %d", calls)
}
}
func TestRunDiscover_missionRelaxedFallbackWithoutVerified(t *testing.T) {
crawler := func(ctx context.Context, m placement.MemberContext, keyword string, limit int) ([]placement.DiscoverPost, error) {
return []placement.DiscoverPost{{
Text: "轉職心得分享", Author: "plain_user", LikeCount: 12, ReplyCount: 2,
Permalink: "https://www.threads.net/@plain_user/post/2", Source: placement.DiscoverThreadsAPI,
}}, nil
}
member := placement.MemberContext{
AllowsCrawler: true,
BrowserConnected: true,
SearchSourceMode: placement.SearchSourceCrawler,
}
out, err := RunDiscover(context.Background(), DiscoverInput{
Keywords: []string{"轉職"},
Member: member,
Crawler: crawler,
MissionScan: true,
}, nil)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if len(out) != 1 {
t.Fatalf("expected relaxed fallback candidate, got %d", len(out))
}
if out[0].AuthorVerified {
t.Fatal("verified should remain false when API omits it")
}
}

View File

@ -0,0 +1,128 @@
package viral
import (
"sort"
"strings"
"haixun-backend/internal/library/placement"
missionentity "haixun-backend/internal/model/copy_mission/domain/entity"
)
func EnrichSimilarAccounts(existing []missionentity.SimilarAccount, posts []placement.ScanCandidate, limit int) []missionentity.SimilarAccount {
if limit <= 0 {
limit = MaxSimilarAccounts
}
byUser := map[string]missionentity.SimilarAccount{}
order := []string{}
for _, item := range existing {
user := strings.ToLower(strings.TrimSpace(item.Username))
if user == "" {
continue
}
if _, ok := byUser[user]; !ok {
order = append(order, user)
}
byUser[user] = item
}
type authorScore struct {
username string
score int
text string
}
authors := map[string]authorScore{}
for _, post := range posts {
user := strings.TrimSpace(post.Author)
if user == "" || !isValidUsername(user) {
continue
}
key := strings.ToLower(user)
prev := authors[key]
prev.username = user
prev.score += post.EngagementScore
if prev.text == "" && strings.TrimSpace(post.Text) != "" {
prev.text = strings.TrimSpace(post.Text)
}
authors[key] = prev
}
ranked := make([]authorScore, 0, len(authors))
for _, item := range authors {
ranked = append(ranked, item)
}
sort.Slice(ranked, func(i, j int) bool { return ranked[i].score > ranked[j].score })
for _, item := range ranked {
key := strings.ToLower(item.username)
if _, ok := byUser[key]; ok {
continue
}
reason := "本次海巡高互動作者"
if item.text != "" {
runes := []rune(item.text)
if len(runes) > 80 {
reason = string(runes[:80])
} else {
reason = item.text
}
}
conf := "medium"
if item.score >= 200 {
conf = "high"
}
byUser[key] = missionentity.SimilarAccount{
Username: item.username,
Reason: reason,
Source: "scan",
Confidence: conf,
ProfileURL: "https://www.threads.net/@" + item.username,
}
order = append(order, key)
}
out := make([]missionentity.SimilarAccount, 0, len(order))
for _, key := range order {
if acc, ok := byUser[key]; ok {
out = append(out, acc)
}
}
if len(out) > limit {
out = out[:limit]
}
return out
}
func AccountTagsFromSimilar(accounts []missionentity.SimilarAccount, max int) []SuggestedTag {
if max <= 0 {
max = 2
}
out := []SuggestedTag{}
for _, acc := range accounts {
user := strings.TrimSpace(acc.Username)
if user == "" {
continue
}
out = append(out, SuggestedTag{
Tag: "@" + user,
Reason: acc.Reason,
SearchType: "帳號",
})
if len(out) >= max {
break
}
}
return out
}
func ToEntitySimilarAccounts(items []SimilarAccount) []missionentity.SimilarAccount {
out := make([]missionentity.SimilarAccount, 0, len(items))
for _, item := range items {
out = append(out, missionentity.SimilarAccount{
Username: item.Username,
Reason: item.Reason,
Source: item.Source,
Confidence: item.Confidence,
ProfileURL: item.ProfileURL,
})
}
return out
}

View File

@ -0,0 +1,141 @@
package viral
import (
"encoding/json"
"strings"
)
func firstJSONString(obj map[string]json.RawMessage, keys ...string) string {
for _, key := range keys {
raw, ok := obj[key]
if !ok {
continue
}
var value string
if err := json.Unmarshal(raw, &value); err == nil {
if trimmed := strings.TrimSpace(value); trimmed != "" {
return trimmed
}
}
}
return ""
}
func flexibleStringFromItem(raw json.RawMessage) string {
if len(raw) == 0 {
return ""
}
var value string
if err := json.Unmarshal(raw, &value); err == nil {
return strings.TrimSpace(value)
}
var obj map[string]json.RawMessage
if err := json.Unmarshal(raw, &obj); err != nil {
return ""
}
if line := firstJSONString(obj, "title", "name", "label", "pillar", "tag", "text", "summary", "description", "value", "content"); line != "" {
return line
}
parts := []string{}
for _, key := range []string{"title", "name", "label"} {
if part := firstJSONString(obj, key); part != "" {
parts = append(parts, part)
break
}
}
if detail := firstJSONString(obj, "description", "summary", "detail", "reason"); detail != "" {
parts = append(parts, detail)
}
return strings.TrimSpace(strings.Join(parts, ""))
}
func parseFlexibleStringList(raw json.RawMessage) []string {
if len(raw) == 0 || string(raw) == "null" {
return nil
}
var items []string
if err := json.Unmarshal(raw, &items); err == nil {
return cleanLines(items)
}
var single string
if err := json.Unmarshal(raw, &single); err == nil && strings.TrimSpace(single) != "" {
return cleanLines([]string{single})
}
var arr []json.RawMessage
if err := json.Unmarshal(raw, &arr); err != nil {
return nil
}
out := make([]string, 0, len(arr))
for _, item := range arr {
if line := flexibleStringFromItem(item); line != "" {
out = append(out, line)
}
}
return cleanLines(out)
}
func parseFlexibleSuggestedTags(raw json.RawMessage) []SuggestedTag {
if len(raw) == 0 || string(raw) == "null" {
return nil
}
var tags []SuggestedTag
if err := json.Unmarshal(raw, &tags); err == nil && len(tags) > 0 {
return cleanSuggestedTags(tags)
}
var strs []string
if err := json.Unmarshal(raw, &strs); err == nil {
out := make([]SuggestedTag, 0, len(strs))
for _, item := range strs {
item = strings.TrimSpace(item)
if item != "" {
out = append(out, SuggestedTag{Tag: item})
}
}
return cleanSuggestedTags(out)
}
var arr []json.RawMessage
if err := json.Unmarshal(raw, &arr); err != nil {
return nil
}
out := make([]SuggestedTag, 0, len(arr))
for _, item := range arr {
if tag := decodeSuggestedTagItem(item); tag.Tag != "" {
out = append(out, tag)
}
}
return cleanSuggestedTags(out)
}
func decodeSuggestedTagItem(raw json.RawMessage) SuggestedTag {
var tag SuggestedTag
if err := json.Unmarshal(raw, &tag); err == nil && strings.TrimSpace(tag.Tag) != "" {
return tag
}
var snake struct {
Tag string `json:"tag"`
Reason string `json:"reason"`
SearchIntent string `json:"search_intent"`
SearchType string `json:"search_type"`
}
if err := json.Unmarshal(raw, &snake); err == nil && strings.TrimSpace(snake.Tag) != "" {
return SuggestedTag{
Tag: strings.TrimSpace(snake.Tag),
Reason: strings.TrimSpace(snake.Reason),
SearchIntent: strings.TrimSpace(snake.SearchIntent),
SearchType: strings.TrimSpace(snake.SearchType),
}
}
if line := flexibleStringFromItem(raw); line != "" {
return SuggestedTag{Tag: line}
}
return SuggestedTag{}
}
func pickRawMessage(root map[string]json.RawMessage, keys ...string) json.RawMessage {
for _, key := range keys {
if raw, ok := root[key]; ok && len(raw) > 0 && string(raw) != "null" {
return raw
}
}
return nil
}

View File

@ -0,0 +1,178 @@
package viral
import (
"encoding/json"
"fmt"
"strings"
)
type MissionInspireInput struct {
PersonaDisplayName string
PersonaBrief string
PersonaBlock string
StyleBenchmark string
PersonaAudience string
PersonaContentGoal string
PersonaQuestions []string
PersonaPillars []string
RecentMissionLabels []string
RecentSeedQueries []string
TrendSnippets []MissionInspireTrendSnippet
WebSearchProvider string
LLMOnly bool
}
type MissionInspireTrendSnippet struct {
Query string
Title string
Snippet string
URL string
}
type MissionInspireOutput struct {
Label string
SeedQuery string
Brief string
TrendReason string
TrendKeywords []string
}
func BuildMissionInspireSystemPrompt() string {
return strings.TrimSpace(`你是 Threads 拷貝忍者的靈感骰子顧問根據近期網路熱搜Google Trends 類訊號與創作者人設產出一組**全新**拷貝任務草稿
規則
1. 近期趨勢訊號有內容從中挑一個適合在 Threads 海巡的方向不要編造不存在的時事
2. 若趨勢訊號為空未連線網路搜尋**必須**改依人設受眾痛點與常見 Threads 討論型態推測近期可能被搜尋的話題並在 trendReason 說明推測理由不要假裝有外部熱搜來源
3. label任務名稱618 像企劃案標題不要標點堆疊
4. seedQuery種子關鍵字近期熱詞26 個詞用頓號或空格分隔適合當 Threads 搜尋起點
5. brief這次想找什麼24 說明要海巡哪類爆款語氣或格式偏好
6. trendReason12 為何選這個趨勢可引用訊號來源大意不要貼 URL
7. trendKeywords36 個相關熱詞字串陣列
8. 避開近期已做過的任務相同題材
9. 繁體中文只回傳 JSON
{"label":"","seedQuery":"","brief":"","trendReason":"","trendKeywords":[]}`)
}
func BuildMissionInspireUserPrompt(in MissionInspireInput) string {
var b strings.Builder
if name := strings.TrimSpace(in.PersonaDisplayName); name != "" {
b.WriteString("【人設名稱】")
b.WriteString(name)
b.WriteString("\n")
}
if p := strings.TrimSpace(in.PersonaBlock); p != "" {
b.WriteString("【人設摘要】\n")
b.WriteString(p)
b.WriteString("\n")
}
if bench := strings.TrimSpace(in.StyleBenchmark); bench != "" {
b.WriteString("【對標帳號】@")
b.WriteString(strings.TrimPrefix(bench, "@"))
b.WriteString("\n")
}
if aud := strings.TrimSpace(in.PersonaAudience); aud != "" {
b.WriteString("【受眾方向】")
b.WriteString(aud)
b.WriteString("\n")
}
if goal := strings.TrimSpace(in.PersonaContentGoal); goal != "" {
b.WriteString("【內容目標】")
b.WriteString(goal)
b.WriteString("\n")
}
if len(in.PersonaPillars) > 0 {
b.WriteString("【內容支柱】")
b.WriteString(strings.Join(in.PersonaPillars, "、"))
b.WriteString("\n")
}
if len(in.RecentMissionLabels) > 0 || len(in.RecentSeedQueries) > 0 {
b.WriteString("【近期已做過的任務(請避開)】\n")
for _, label := range in.RecentMissionLabels {
if label = strings.TrimSpace(label); label != "" {
b.WriteString("- ")
b.WriteString(label)
b.WriteString("\n")
}
}
for _, seed := range in.RecentSeedQueries {
if seed = strings.TrimSpace(seed); seed != "" {
b.WriteString("- 種子:")
b.WriteString(seed)
b.WriteString("\n")
}
}
}
if in.LLMOnly {
b.WriteString("【模式】未設定 Web Search API key請純依人設與受眾推測靈感勿假裝有 Google 熱搜)\n")
} else if provider := strings.TrimSpace(in.WebSearchProvider); provider != "" {
b.WriteString("【趨勢來源】")
b.WriteString(provider)
b.WriteString(" 網路搜尋\n")
}
b.WriteString("【近期趨勢訊號】\n")
if len(in.TrendSnippets) == 0 {
b.WriteString("(無外部趨勢結果,請改用人設推測近期 Threads 可能熱議方向)\n")
} else {
for i, item := range in.TrendSnippets {
b.WriteString(fmt.Sprintf("[%d] 查詢:%s\n", i+1, strings.TrimSpace(item.Query)))
if title := strings.TrimSpace(item.Title); title != "" {
b.WriteString("標題:")
b.WriteString(title)
b.WriteString("\n")
}
if snippet := strings.TrimSpace(item.Snippet); snippet != "" {
b.WriteString(snippet)
b.WriteString("\n")
}
b.WriteString("\n")
}
}
b.WriteString("請產出拷貝任務靈感 JSON。")
return b.String()
}
func InspireTrendSearchQueries(personaBrief, styleBenchmark string) []string {
queries := []string{
"Google Trends 台灣 今日 熱門搜尋 關鍵字",
"Threads 台灣 熱門 話題 最近 一週",
"近期 網路 熱搜 關鍵字 台灣",
}
context := strings.TrimSpace(personaBrief + " " + styleBenchmark)
if context != "" {
trimmed := context
if len([]rune(trimmed)) > 24 {
trimmed = string([]rune(trimmed)[:24])
}
queries = append(queries, trimmed+" 熱門 話題 最近")
}
return queries
}
func ParseMissionInspireOutput(raw string) (MissionInspireOutput, error) {
payload, err := extractCopyMapJSON(raw)
if err != nil {
return MissionInspireOutput{}, err
}
var root map[string]json.RawMessage
if err := json.Unmarshal(payload, &root); err != nil {
return MissionInspireOutput{}, err
}
out := MissionInspireOutput{
Label: firstJSONString(root, "label", "title", "mission_label"),
SeedQuery: firstJSONString(root, "seedQuery", "seed_query", "seed", "keywords"),
Brief: firstJSONString(root, "brief", "description", "goal"),
TrendReason: firstJSONString(root, "trendReason", "trend_reason", "reason"),
}
if out.Label == "" || out.SeedQuery == "" || out.Brief == "" {
return MissionInspireOutput{}, fmt.Errorf("missing label, seedQuery, or brief")
}
if rawKW, ok := root["trendKeywords"]; ok {
out.TrendKeywords = parseFlexibleStringList(rawKW)
}
if len(out.TrendKeywords) == 0 {
if rawKW, ok := root["trend_keywords"]; ok {
out.TrendKeywords = parseFlexibleStringList(rawKW)
}
}
return out, nil
}

View File

@ -0,0 +1,66 @@
package viral
import (
"context"
"strings"
"time"
"haixun-backend/internal/library/placement"
"haixun-backend/internal/library/websearch"
)
const maxInspireTrendSnippets = 14
func CollectMissionInspireTrends(
ctx context.Context,
client websearch.Client,
member placement.MemberContext,
personaBrief, styleBenchmark string,
) []MissionInspireTrendSnippet {
if client == nil || !client.Enabled() {
return nil
}
queries := InspireTrendSearchQueries(personaBrief, styleBenchmark)
out := make([]MissionInspireTrendSnippet, 0, maxInspireTrendSnippets)
seen := map[string]struct{}{}
for _, query := range queries {
if len(out) >= maxInspireTrendSnippets {
break
}
res, err := client.Search(ctx, websearch.SearchOptions{
Query: query,
Limit: 5,
Mode: websearch.ModeKnowledgeExpand,
Country: member.BraveCountry,
SearchLang: member.BraveSearchLang,
UserLocation: member.ExaUserLocation,
StartPublishedDate: placement.FormatPublishedAfterISO(7, time.Now().UTC()),
})
if err != nil || res.Status != "success" || len(res.Results) == 0 {
continue
}
for _, item := range res.Results {
if len(out) >= maxInspireTrendSnippets {
break
}
title := strings.TrimSpace(item.Title)
snippet := strings.TrimSpace(item.Snippet)
if title == "" && snippet == "" {
continue
}
key := strings.ToLower(title + "|" + snippet)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, MissionInspireTrendSnippet{
Query: query,
Title: title,
Snippet: snippet,
URL: strings.TrimSpace(item.URL),
})
}
}
return out
}

View File

@ -0,0 +1,34 @@
package viral
import (
"strings"
"testing"
)
func TestParseMissionInspireOutput(t *testing.T) {
raw := `{"label":"轉職焦慮語錄","seedQuery":"轉職 被裁 焦慮","brief":"想找最近 Threads 上互動高的轉職心情貼文。","trendReason":"Google 熱搜近期職場話題升溫","trendKeywords":["轉職","裁員","面試"]}`
out, err := ParseMissionInspireOutput(raw)
if err != nil {
t.Fatalf("parse: %v", err)
}
if out.Label != "轉職焦慮語錄" || out.SeedQuery == "" || out.Brief == "" {
t.Fatalf("unexpected output: %+v", out)
}
if len(out.TrendKeywords) != 3 {
t.Fatalf("expected 3 trend keywords, got %d", len(out.TrendKeywords))
}
}
func TestBuildMissionInspireUserPromptLLMOnly(t *testing.T) {
prompt := BuildMissionInspireUserPrompt(MissionInspireInput{
PersonaDisplayName: "測試人設",
PersonaBrief: "職場焦慮",
LLMOnly: true,
})
if !strings.Contains(prompt, "未設定 Web Search API key") {
t.Fatalf("expected llm-only hint in prompt: %s", prompt)
}
if !strings.Contains(prompt, "無外部趨勢結果") {
t.Fatalf("expected empty trend fallback in prompt: %s", prompt)
}
}

View File

@ -0,0 +1,143 @@
package viral
import (
"encoding/json"
"fmt"
"strings"
)
// MissionResearchMap is the structured copy-mission research map (single LLM call).
type MissionResearchMap struct {
AudienceSummary string `json:"audienceSummary"`
ContentGoal string `json:"contentGoal"`
Questions []string `json:"questions"`
Pillars []string `json:"pillars"`
Exclusions []string `json:"exclusions"`
SuggestedTags []SuggestedTag `json:"suggestedTags"`
BenchmarkNotes string `json:"benchmarkNotes"`
}
func BuildMissionResearchMapSystemPrompt() string {
return strings.TrimSpace(`你是 Threads 爆款對標研究顧問目標幫創作者找到近期熱門高互動值得仿寫的話題與搜尋方向
規則
1. audienceSummary必填24 句描述受眾是誰年齡情境痛點會在 Threads 搜什麼不要只寫人設本人
2. 聚焦最近會在 Threads 被搜被討論的話題不要寫成學術報告
3. contentGoal找到近期互動佳結構可模仿的爆款貼文
4. pillars可模仿方向語錄型故事型清單型等至少 4 **必須是字串陣列**不要用 {title:...} 物件
5. questions受眾會搜的短問題5+ 字串陣列
6. exclusions不要模仿的內容至少 4 字串陣列
7. suggestedTags68 像真人會在 Threads 搜尋框打的字
- 每個含 tag, reason, searchIntent痛點|知識|經驗|對比|工具|語錄, searchType短詞|情境|語錄
- tag 210 不要標點不要完整句子不要像文章標題
8. benchmarkNotes怎樣算值得仿的爆款互動hook 清楚
9. 繁體中文只回傳 JSON audienceSummary`)
}
func BuildMissionResearchMapUserPrompt(in CopyResearchMapInput) string {
var b strings.Builder
b.WriteString("【任務名稱】")
b.WriteString(strings.TrimSpace(in.Label))
b.WriteString("\n【種子關鍵字近期熱詞】")
b.WriteString(strings.TrimSpace(in.SeedQuery))
b.WriteString("\n【這次想找什麼】\n")
b.WriteString(strings.TrimSpace(in.Brief))
if p := strings.TrimSpace(in.Persona); p != "" {
b.WriteString("\n【我的人設仿寫時會套用地圖請對準受眾與話題不要只寫我自己】\n")
b.WriteString(p)
}
if bench := strings.TrimSpace(in.StyleBenchmark); bench != "" {
b.WriteString("\n【長期對標帳號參考】@")
b.WriteString(strings.TrimPrefix(bench, "@"))
b.WriteString("\n")
}
if aud := strings.TrimSpace(in.PersonaAudienceSummary); aud != "" {
b.WriteString("\n【人設層級受眾研究請延伸為本次任務受眾勿只複製】\n")
b.WriteString(aud)
if goal := strings.TrimSpace(in.PersonaContentGoal); goal != "" {
b.WriteString("\n內容目標參考")
b.WriteString(goal)
}
if len(in.PersonaQuestions) > 0 {
b.WriteString("\n受眾提問參考")
b.WriteString(strings.Join(in.PersonaQuestions, "、"))
}
if len(in.PersonaPillars) > 0 {
b.WriteString("\n內容支柱參考")
b.WriteString(strings.Join(in.PersonaPillars, "、"))
}
b.WriteString("\n")
}
b.WriteString("\n請產出拷貝任務研究地圖 JSON必填 audienceSummary 與 suggestedTags 陣列)。")
return b.String()
}
func ParseMissionResearchMapOutput(raw string) (MissionResearchMap, error) {
payload, err := extractCopyMapJSON(raw)
if err != nil {
return MissionResearchMap{}, err
}
var root map[string]json.RawMessage
if err := json.Unmarshal(payload, &root); err != nil {
return MissionResearchMap{}, fmt.Errorf("parse mission research map: %w", err)
}
out := MissionResearchMap{
AudienceSummary: firstJSONString(root, "audienceSummary", "audience_summary"),
ContentGoal: firstJSONString(root, "contentGoal", "content_goal"),
BenchmarkNotes: firstJSONString(root, "benchmarkNotes", "benchmark_notes"),
Questions: parseFlexibleStringList(pickRawMessage(root, "questions")),
Pillars: parseFlexibleStringList(pickRawMessage(root, "pillars")),
Exclusions: parseFlexibleStringList(pickRawMessage(root, "exclusions")),
SuggestedTags: parseFlexibleSuggestedTags(pickRawMessage(root, "suggestedTags", "suggested_tags")),
}
if strings.TrimSpace(out.AudienceSummary) == "" {
return MissionResearchMap{}, fmt.Errorf("mission research map missing audienceSummary")
}
if len(out.SuggestedTags) < 4 {
return MissionResearchMap{}, fmt.Errorf("mission research map needs at least 4 suggested tags")
}
return out, nil
}
func cleanSuggestedTags(tags []SuggestedTag) []SuggestedTag {
out := []SuggestedTag{}
seen := map[string]struct{}{}
for _, item := range tags {
tag := strings.TrimSpace(item.Tag)
if tag == "" {
continue
}
if _, ok := seen[tag]; ok {
continue
}
seen[tag] = struct{}{}
out = append(out, SuggestedTag{
Tag: tag,
Reason: strings.TrimSpace(item.Reason),
SearchIntent: strings.TrimSpace(item.SearchIntent),
SearchType: strings.TrimSpace(item.SearchType),
})
}
return out
}
func ToEntityMissionResearchMap(m MissionResearchMap) map[string]any {
tags := make([]map[string]any, 0, len(m.SuggestedTags))
for _, item := range m.SuggestedTags {
tags = append(tags, map[string]any{
"tag": item.Tag,
"reason": item.Reason,
"search_intent": item.SearchIntent,
"search_type": item.SearchType,
})
}
return map[string]any{
"audience_summary": m.AudienceSummary,
"content_goal": m.ContentGoal,
"questions": m.Questions,
"pillars": m.Pillars,
"exclusions": m.Exclusions,
"suggested_tags": tags,
"benchmark_notes": m.BenchmarkNotes,
}
}

View File

@ -0,0 +1,55 @@
package viral
import "testing"
func TestParseMissionResearchMapOutput_ObjectPillars(t *testing.T) {
raw := `{
"audienceSummary": "轉職焦慮的上班族",
"contentGoal": "找近期高互動轉職心情貼文",
"questions": ["轉職會後悔嗎", "被裁怎麼辦"],
"pillars": [
{"title": "語錄型", "description": "一句話戳中焦慮"},
{"title": "故事型", "description": "親身轉職經驗"}
],
"exclusions": ["純業配", "無結構閒聊"],
"suggestedTags": [
{"tag": "轉職焦慮", "reason": "近期熱詞", "searchIntent": "痛點", "searchType": "短詞"},
{"tag": "被裁", "reason": "高互動", "searchIntent": "痛點", "searchType": "短詞"},
{"tag": "面試失敗", "reason": "共鳴", "searchIntent": "經驗", "searchType": "情境"},
{"tag": "裸辭", "reason": "討論多", "searchIntent": "語錄", "searchType": "短詞"}
],
"benchmarkNotes": "互動高且 hook 清楚"
}`
out, err := ParseMissionResearchMapOutput(raw)
if err != nil {
t.Fatalf("ParseMissionResearchMapOutput: %v", err)
}
if len(out.Pillars) < 2 {
t.Fatalf("expected parsed pillars, got %#v", out.Pillars)
}
if out.Pillars[0] == "" {
t.Fatal("first pillar should not be empty")
}
if len(out.SuggestedTags) < 4 {
t.Fatalf("expected suggested tags, got %#v", out.SuggestedTags)
}
}
func TestParseMissionResearchMapOutput_StringSuggestedTags(t *testing.T) {
raw := `{
"audienceSummary": "新手媽媽",
"contentGoal": "找育兒爆款",
"questions": ["哄睡", "副食品"],
"pillars": ["清單型", "故事型", "語錄型", "問答型"],
"exclusions": ["業配", "晒娃", "無重點", "抄襲"],
"suggestedTags": ["哄睡", "副食品", "崩潰", "育兒"],
"benchmarkNotes": "留言多"
}`
out, err := ParseMissionResearchMapOutput(raw)
if err != nil {
t.Fatalf("ParseMissionResearchMapOutput: %v", err)
}
if len(out.SuggestedTags) != 4 {
t.Fatalf("expected 4 tags, got %#v", out.SuggestedTags)
}
}

View File

@ -0,0 +1,39 @@
package viral
import "haixun-backend/internal/library/placement"
// Mission-quality gates for copy-mission viral patrol (stricter than generic patrol).
const (
MissionQualityMinLikes = 18
MissionQualityMinEngagement = 50
MissionVerifiedMinLikes = 10
MissionVerifiedMinEngagement = 35
MissionInfluencerMinFollowers = 5000
)
// PassesMissionQualityCandidate filters copy-mission scan results toward higher-signal posts.
// AuthorVerified and FollowerCount are optional enrichments (often missing on Threads API);
// when absent the default engagement bar still applies — callers should fall back to
// PassesViralCandidate rather than treating empty optional signals as failure.
func PassesMissionQualityCandidate(text string, likes, replies, engagement int, verified bool, followerCount int, exclusions []string) bool {
if !PassesViralCandidate(text, likes, replies, engagement, exclusions) {
return false
}
if verified {
return likes >= MissionVerifiedMinLikes && engagement >= MissionVerifiedMinEngagement
}
if followerCount >= MissionInfluencerMinFollowers {
return likes >= 12 && engagement >= 40
}
return likes >= MissionQualityMinLikes && engagement >= MissionQualityMinEngagement
}
func MergeAuthorSignals(prev, next placement.ScanCandidate) placement.ScanCandidate {
if next.AuthorVerified {
prev.AuthorVerified = true
}
if next.FollowerCount > prev.FollowerCount {
prev.FollowerCount = next.FollowerCount
}
return prev
}

View File

@ -0,0 +1,18 @@
package viral
import "testing"
func TestPassesMissionQualityCandidate_verifiedLowerBar(t *testing.T) {
if !PassesMissionQualityCandidate("轉職面試技巧分享心得", 12, 2, 40, true, 0, nil) {
t.Fatal("verified author should pass with moderate engagement")
}
}
func TestPassesMissionQualityCandidate_unverifiedStricter(t *testing.T) {
if PassesMissionQualityCandidate("轉職面試技巧分享心得", 12, 2, 40, false, 0, nil) {
t.Fatal("unverified author should not pass with low engagement")
}
if !PassesMissionQualityCandidate("轉職面試技巧分享心得", 25, 4, 65, false, 0, nil) {
t.Fatal("unverified author should pass with strong engagement")
}
}

View File

@ -0,0 +1,201 @@
package viral
import (
"fmt"
"sort"
"strings"
"haixun-backend/internal/library/placement"
missionentity "haixun-backend/internal/model/copy_mission/domain/entity"
)
const (
RefAccountMinBestEngagement = 50
RefAccountMinBestLikes = 18
RefAccountMinTotalEngagement = 80
RefVerifiedMinBestEngagement = 35
RefVerifiedMinBestLikes = 10
)
type ReferenceAccountInput struct {
SeedQuery string
Label string
Posts []placement.ScanCandidate
Limit int
}
type referenceAuthorAgg struct {
username string
verified bool
followerCount int
totalEngagement int
bestEngagement int
bestLikes int
bestReplies int
postCount int
sampleText string
sampleSearchTag string
}
// BuildReferenceAccountsFromScan lists authors from patrol posts that match the
// mission topic and pass quality gates. Verified/follower are optional bonuses;
// if strict gates yield nothing, falls back to baseline viral engagement.
func BuildReferenceAccountsFromScan(in ReferenceAccountInput) []missionentity.SimilarAccount {
out := buildReferenceAccountsFromScan(in, true)
if len(out) > 0 {
return out
}
return buildReferenceAccountsFromScan(in, false)
}
func buildReferenceAccountsFromScan(in ReferenceAccountInput, strictQuality bool) []missionentity.SimilarAccount {
limit := in.Limit
if limit <= 0 {
limit = MaxSimilarAccounts
}
byUser := map[string]referenceAuthorAgg{}
for _, post := range in.Posts {
user := strings.TrimSpace(post.Author)
if user == "" || !isValidUsername(user) {
continue
}
if !postTopicRelevant(post, in.SeedQuery, in.Label) {
continue
}
if strictQuality {
if !PassesMissionQualityCandidate(
post.Text, post.LikeCount, post.ReplyCount, post.EngagementScore,
post.AuthorVerified, post.FollowerCount, nil,
) {
continue
}
} else if !PassesViralCandidate(
post.Text, post.LikeCount, post.ReplyCount, post.EngagementScore, nil,
) {
continue
}
key := strings.ToLower(user)
prev := byUser[key]
prev.username = user
if post.AuthorVerified {
prev.verified = true
}
if post.FollowerCount > prev.followerCount {
prev.followerCount = post.FollowerCount
}
prev.postCount++
prev.totalEngagement += post.EngagementScore
if post.EngagementScore > prev.bestEngagement {
prev.bestEngagement = post.EngagementScore
prev.bestLikes = post.LikeCount
prev.bestReplies = post.ReplyCount
text := strings.TrimSpace(post.Text)
if len([]rune(text)) > 80 {
text = string([]rune(text)[:80])
}
prev.sampleText = text
prev.sampleSearchTag = strings.TrimSpace(post.SearchTag)
}
byUser[key] = prev
}
ranked := make([]referenceAuthorAgg, 0, len(byUser))
for _, item := range byUser {
if qualifiesReferenceAuthor(item, strictQuality) {
ranked = append(ranked, item)
}
}
sort.Slice(ranked, func(i, j int) bool {
if ranked[i].verified != ranked[j].verified {
return ranked[i].verified
}
if ranked[i].followerCount != ranked[j].followerCount {
return ranked[i].followerCount > ranked[j].followerCount
}
if ranked[i].totalEngagement != ranked[j].totalEngagement {
return ranked[i].totalEngagement > ranked[j].totalEngagement
}
return ranked[i].bestEngagement > ranked[j].bestEngagement
})
if len(ranked) > limit {
ranked = ranked[:limit]
}
out := make([]missionentity.SimilarAccount, 0, len(ranked))
for _, item := range ranked {
conf := "medium"
if item.verified {
conf = "high"
} else if item.bestEngagement >= HotEngagementScore || item.totalEngagement >= 120 {
conf = "high"
}
out = append(out, missionentity.SimilarAccount{
Username: item.username,
Reason: formatReferenceReason(item),
Source: "scan",
Confidence: conf,
ProfileURL: "https://www.threads.net/@" + item.username,
AuthorVerified: item.verified,
FollowerCount: item.followerCount,
EngagementScore: item.bestEngagement,
LikeCount: item.bestLikes,
ReplyCount: item.bestReplies,
PostCount: item.postCount,
})
}
return out
}
func qualifiesReferenceAuthor(item referenceAuthorAgg, strictQuality bool) bool {
if item.postCount == 0 {
return false
}
if item.verified {
return item.bestLikes >= RefVerifiedMinBestLikes &&
(item.bestEngagement >= RefVerifiedMinBestEngagement || item.totalEngagement >= 60)
}
if strictQuality {
if item.bestLikes < RefAccountMinBestLikes {
return false
}
return item.bestEngagement >= RefAccountMinBestEngagement || item.totalEngagement >= RefAccountMinTotalEngagement
}
return item.bestLikes >= 8 && item.bestEngagement >= MinEngagementScore
}
func postTopicRelevant(post placement.ScanCandidate, seed, label string) bool {
text := strings.ToLower(strings.TrimSpace(post.Text))
tag := strings.ToLower(strings.TrimSpace(post.SearchTag))
terms := topicTerms(seed, label)
if len(terms) == 0 {
return text != "" || tag != ""
}
for _, term := range terms {
term = strings.ToLower(term)
if strings.Contains(text, term) || strings.Contains(tag, term) {
return true
}
}
return false
}
func topicTerms(seed, label string) []string {
out := []string{}
if s := strings.TrimSpace(seed); s != "" {
out = append(out, s)
}
if l := strings.TrimSpace(label); l != "" {
out = append(out, l)
}
return out
}
func formatReferenceReason(item referenceAuthorAgg) string {
if item.sampleText != "" {
return item.sampleText
}
if item.sampleSearchTag != "" {
return fmt.Sprintf("標籤「%s」高互動作者", item.sampleSearchTag)
}
return "本次海巡高互動作者"
}

View File

@ -0,0 +1,24 @@
package viral
import (
"testing"
"haixun-backend/internal/library/placement"
)
func TestBuildReferenceAccountsFromScan_relaxedFallback(t *testing.T) {
got := BuildReferenceAccountsFromScan(ReferenceAccountInput{
SeedQuery: "轉職",
Label: "轉職",
Posts: []placement.ScanCandidate{
{Author: "warm_user", Text: "轉職心得分享", SearchTag: "轉職", LikeCount: 10, ReplyCount: 2, EngagementScore: 32},
},
Limit: 5,
})
if len(got) != 1 {
t.Fatalf("expected relaxed fallback account, got %d", len(got))
}
if got[0].AuthorVerified {
t.Fatal("verified must stay false when not provided")
}
}

View File

@ -0,0 +1,58 @@
package viral
import (
"testing"
"haixun-backend/internal/library/placement"
)
func TestBuildReferenceAccountsFromScan_filtersLowEngagement(t *testing.T) {
got := BuildReferenceAccountsFromScan(ReferenceAccountInput{
SeedQuery: "轉職",
Label: "轉職語錄",
Posts: []placement.ScanCandidate{
{Author: "weak_user", Text: "轉職心得", SearchTag: "轉職", LikeCount: 2, ReplyCount: 0, EngagementScore: 10},
{Author: "hot_user", Text: "轉職面試技巧分享", SearchTag: "轉職", LikeCount: 30, ReplyCount: 5, EngagementScore: 85},
},
Limit: 5,
})
if len(got) != 1 {
t.Fatalf("expected 1 account, got %d", len(got))
}
if got[0].Username != "hot_user" {
t.Fatalf("unexpected username %q", got[0].Username)
}
}
func TestBuildReferenceAccountsFromScan_prefersVerified(t *testing.T) {
got := BuildReferenceAccountsFromScan(ReferenceAccountInput{
SeedQuery: "轉職",
Label: "轉職",
Posts: []placement.ScanCandidate{
{Author: "plain_user", Text: "轉職技巧", SearchTag: "轉職", LikeCount: 22, ReplyCount: 3, EngagementScore: 55},
{Author: "blue_user", Text: "轉職分享", SearchTag: "轉職", LikeCount: 14, ReplyCount: 2, EngagementScore: 42, AuthorVerified: true},
},
Limit: 5,
})
if len(got) < 2 {
t.Fatalf("expected 2 accounts, got %d", len(got))
}
if got[0].Username != "blue_user" || !got[0].AuthorVerified {
t.Fatalf("verified author should rank first, got %+v", got[0])
}
}
func TestBuildReferenceAccountsFromScan_requiresTopicMatch(t *testing.T) {
posts := []placement.ScanCandidate{
{Author: "off_topic", Text: "今天天氣真好", SearchTag: "天氣", LikeCount: 40, ReplyCount: 8, EngagementScore: 90},
}
got := BuildReferenceAccountsFromScan(ReferenceAccountInput{
SeedQuery: "轉職",
Label: "轉職",
Posts: posts,
Limit: 5,
})
if len(got) != 0 {
t.Fatalf("expected no accounts for off-topic post, got %d", len(got))
}
}

Some files were not shown because too many files have changed in this diff Show More