fix find post
This commit is contained in:
parent
a66a3d81ee
commit
d0da1a1103
|
|
@ -1 +1 @@
|
|||
91942
|
||||
26382
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
92316
|
||||
26440
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
92317
|
||||
26441
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -22,6 +22,7 @@ import (
|
|||
"permission.api"
|
||||
"threads_account.api"
|
||||
"persona.api"
|
||||
"copy_mission.api"
|
||||
"brand.api"
|
||||
"placement_topic.api"
|
||||
"worker_internal.api"
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 廣度補充是否再打第二輪 Brave(hybrid 改由 LLM 補廣度以省 API)。
|
||||
func (s ExpandStrategy) UsesSupplementalBrave() bool {
|
||||
return s == ExpandStrategyBrave
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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("海巡關鍵字格式無效,請改用 2~8 字的真人搜尋短句")
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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` 補充痛點)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
你是 Threads 內容矩陣策劃師,為人設帳號產出多篇可發佈草稿。
|
||||
|
||||
規則:
|
||||
- 只回傳 JSON,格式為 {"rows":[...]}。
|
||||
- 每篇必須角度不同,避免重複 hook。
|
||||
- 套用人設語氣與 8D,不要寫成品牌廣告或硬銷。
|
||||
- 參考爆款樣本只學結構與節奏,不抄原文。
|
||||
- 繁體中文,口語自然,適合 Threads。
|
||||
- 每篇 text 主文 ≤ 500 字(Threads API 硬上限,含 #話題標籤)。
|
||||
- 爆款互動最佳 80~220 字:前 1~2 行強 hook,一句一重點;超過 300 字互動通常下降。
|
||||
|
|
@ -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。
|
||||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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 D1–D8 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"))
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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, 2024–2026).
|
||||
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 ""
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 爆款結構分析師。拆解貼文為什麼會紅、怎麼仿寫結構,不要建議抄襲原文。
|
||||
|
||||
只回傳 JSON:hookPattern, structurePattern, emotionalTrigger, replicationStrategy, keyTakeaways(字串陣列 3~5 項)。繁體中文。`)
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
|
|
@ -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++ {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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:任務名稱,6~18 字,像企劃案標題,不要標點堆疊
|
||||
4. seedQuery:種子關鍵字/近期熱詞,2~6 個詞用頓號或空格分隔,適合當 Threads 搜尋起點
|
||||
5. brief:這次想找什麼,2~4 句,說明要海巡哪類爆款、語氣或格式偏好
|
||||
6. trendReason:1~2 句,為何選這個趨勢(可引用訊號來源大意,不要貼 URL)
|
||||
7. trendKeywords:3~6 個相關熱詞(字串陣列)
|
||||
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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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:必填,2~4 句描述「受眾是誰」(年齡/情境/痛點/會在 Threads 搜什麼),不要只寫人設本人
|
||||
2. 聚焦「最近會在 Threads 被搜、被討論」的話題,不要寫成學術報告
|
||||
3. contentGoal:找到近期互動佳、結構可模仿的爆款貼文
|
||||
4. pillars:可模仿方向(語錄型、故事型、清單型等),至少 4 個;**必須是字串陣列**,不要用 {title:...} 物件
|
||||
5. questions:受眾會搜的短問題,5+ 個;字串陣列
|
||||
6. exclusions:不要模仿的內容,至少 4 個;字串陣列
|
||||
7. suggestedTags:6~8 個「像真人會在 Threads 搜尋框打的字」
|
||||
- 每個含 tag, reason, searchIntent(痛點|知識|經驗|對比|工具|語錄), searchType(短詞|情境|語錄)
|
||||
- tag 2~10 字,不要標點、不要完整句子、不要像文章標題
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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 "本次海巡高互動作者"
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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
Loading…
Reference in New Issue