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
|
||||||
|
|
||||||
|
|
||||||
VITE v6.4.3 ready in 111 ms
|
VITE v6.4.3 ready in 174 ms
|
||||||
|
|
||||||
➜ Local: http://localhost:5173/
|
➜ Local: http://localhost:5173/
|
||||||
➜ Network: use --host to expose
|
➜ Network: use --host to expose
|
||||||
|
|
|
||||||
|
|
@ -2,4 +2,4 @@
|
||||||
> haixun-master@0.1.0 worker:style-8d
|
> haixun-master@0.1.0 worker:style-8d
|
||||||
> . scripts/playwright-env.sh && npx playwright install chromium && tsx haixun-backend/worker/style-8d-worker.ts
|
> . 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 {
|
GenerateOutreachDraftsReq {
|
||||||
ScanPostID string `json:"scan_post_id" validate:"required"`
|
ScanPostID string `json:"scan_post_id" validate:"required"`
|
||||||
|
TopicID string `json:"topic_id,optional"`
|
||||||
Count int `json:"count,optional"`
|
Count int `json:"count,optional"`
|
||||||
VoicePersonaID string `json:"voice_persona_id,optional"`
|
VoicePersonaID string `json:"voice_persona_id,optional"`
|
||||||
ProductID string `json:"product_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"
|
"permission.api"
|
||||||
"threads_account.api"
|
"threads_account.api"
|
||||||
"persona.api"
|
"persona.api"
|
||||||
|
"copy_mission.api"
|
||||||
"brand.api"
|
"brand.api"
|
||||||
"placement_topic.api"
|
"placement_topic.api"
|
||||||
"worker_internal.api"
|
"worker_internal.api"
|
||||||
|
|
|
||||||
|
|
@ -30,18 +30,25 @@ type (
|
||||||
}
|
}
|
||||||
|
|
||||||
MemberPlacementSettingsData {
|
MemberPlacementSettingsData {
|
||||||
BraveAPIKey string `json:"brave_api_key,omitempty"`
|
WebSearchProvider string `json:"web_search_provider"` // brave | exa
|
||||||
BraveAPIKeyConfigured bool `json:"brave_api_key_configured"`
|
BraveAPIKey string `json:"brave_api_key,omitempty"`
|
||||||
BraveCountry string `json:"brave_country"`
|
BraveAPIKeyConfigured bool `json:"brave_api_key_configured"`
|
||||||
BraveSearchLang string `json:"brave_search_lang"`
|
ExaAPIKey string `json:"exa_api_key,omitempty"`
|
||||||
ExpandStrategy string `json:"expand_strategy"` // brave | llm | hybrid
|
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 {
|
UpdateMemberPlacementSettingsReq {
|
||||||
BraveAPIKey *string `json:"brave_api_key,optional"`
|
WebSearchProvider *string `json:"web_search_provider,optional"`
|
||||||
BraveCountry *string `json:"brave_country,optional"`
|
BraveAPIKey *string `json:"brave_api_key,optional"`
|
||||||
BraveSearchLang *string `json:"brave_search_lang,optional"`
|
ExaAPIKey *string `json:"exa_api_key,optional"`
|
||||||
ExpandStrategy *string `json:"expand_strategy,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 {
|
UpdatePersonaReq {
|
||||||
DisplayName *string `json:"display_name,optional"`
|
DisplayName *string `json:"display_name,optional"`
|
||||||
Persona *string `json:"persona,optional"`
|
Persona *string `json:"persona,optional"`
|
||||||
|
Brief *string `json:"brief,optional"`
|
||||||
StyleProfile *string `json:"style_profile,optional"`
|
StyleProfile *string `json:"style_profile,optional"`
|
||||||
StyleBenchmark *string `json:"style_benchmark,optional"`
|
StyleBenchmark *string `json:"style_benchmark,optional"`
|
||||||
}
|
}
|
||||||
|
|
@ -83,17 +84,18 @@ type (
|
||||||
}
|
}
|
||||||
|
|
||||||
ViralScanPostData {
|
ViralScanPostData {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
SearchTag string `json:"search_tag"`
|
SearchTag string `json:"search_tag"`
|
||||||
Permalink string `json:"permalink"`
|
Permalink string `json:"permalink"`
|
||||||
Author string `json:"author"`
|
Author string `json:"author"`
|
||||||
Text string `json:"text"`
|
Text string `json:"text"`
|
||||||
LikeCount int `json:"like_count"`
|
LikeCount int `json:"like_count"`
|
||||||
ReplyCount int `json:"reply_count"`
|
ReplyCount int `json:"reply_count"`
|
||||||
EngagementScore int `json:"engagement_score"`
|
EngagementScore int `json:"engagement_score"`
|
||||||
Source string `json:"source"`
|
Source string `json:"source"`
|
||||||
ScanJobID string `json:"scan_job_id"`
|
ScanJobID string `json:"scan_job_id"`
|
||||||
CreateAt int64 `json:"create_at"`
|
Replies []ScanReplyData `json:"replies,omitempty"`
|
||||||
|
CreateAt int64 `json:"create_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
ListPersonaViralScanPostsData {
|
ListPersonaViralScanPostsData {
|
||||||
|
|
@ -107,18 +109,23 @@ type (
|
||||||
}
|
}
|
||||||
|
|
||||||
CopyDraftData {
|
CopyDraftData {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
PersonaID string `json:"persona_id"`
|
PersonaID string `json:"persona_id"`
|
||||||
ScanPostID string `json:"scan_post_id,omitempty"`
|
CopyMissionID string `json:"copy_mission_id,omitempty"`
|
||||||
DraftType string `json:"draft_type"`
|
ScanPostID string `json:"scan_post_id,omitempty"`
|
||||||
|
DraftType string `json:"draft_type"`
|
||||||
|
SortOrder int `json:"sort_order,omitempty"`
|
||||||
Text string `json:"text"`
|
Text string `json:"text"`
|
||||||
Angle string `json:"angle,omitempty"`
|
Angle string `json:"angle,omitempty"`
|
||||||
Hook string `json:"hook,omitempty"`
|
Hook string `json:"hook,omitempty"`
|
||||||
Rationale string `json:"rationale,omitempty"`
|
Rationale string `json:"rationale,omitempty"`
|
||||||
ReferenceNotes string `json:"reference_notes,omitempty"`
|
ReferenceNotes string `json:"reference_notes,omitempty"`
|
||||||
Sources []string `json:"sources,omitempty"`
|
Sources []string `json:"sources,omitempty"`
|
||||||
Status string `json:"status,omitempty"`
|
Status string `json:"status,omitempty"`
|
||||||
CreateAt int64 `json:"create_at"`
|
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 {
|
ListPersonaCopyDraftsData {
|
||||||
|
|
@ -139,6 +146,41 @@ type (
|
||||||
Draft CopyDraftData `json:"draft"`
|
Draft CopyDraftData `json:"draft"`
|
||||||
Message string `json:"message"`
|
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(
|
@server(
|
||||||
|
|
@ -178,4 +220,10 @@ service gateway {
|
||||||
|
|
||||||
@handler generatePersonaCopyDraft
|
@handler generatePersonaCopyDraft
|
||||||
post /:id/copy-drafts/generate (GeneratePersonaCopyDraftHandlerReq) returns (GeneratePersonaCopyDraftData)
|
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 (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/rest/httpx"
|
|
||||||
"haixun-backend/internal/logic/brand"
|
"haixun-backend/internal/logic/brand"
|
||||||
"haixun-backend/internal/response"
|
"haixun-backend/internal/response"
|
||||||
"haixun-backend/internal/svc"
|
"haixun-backend/internal/svc"
|
||||||
"haixun-backend/internal/types"
|
"haixun-backend/internal/types"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/rest/httpx"
|
||||||
)
|
)
|
||||||
|
|
||||||
func CreateBrandProductHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
func CreateBrandProductHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,12 @@ package brand
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/rest/httpx"
|
|
||||||
"haixun-backend/internal/logic/brand"
|
"haixun-backend/internal/logic/brand"
|
||||||
"haixun-backend/internal/response"
|
"haixun-backend/internal/response"
|
||||||
"haixun-backend/internal/svc"
|
"haixun-backend/internal/svc"
|
||||||
"haixun-backend/internal/types"
|
"haixun-backend/internal/types"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/rest/httpx"
|
||||||
)
|
)
|
||||||
|
|
||||||
func DeleteBrandProductHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
func DeleteBrandProductHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,12 @@ package brand
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/rest/httpx"
|
|
||||||
"haixun-backend/internal/logic/brand"
|
"haixun-backend/internal/logic/brand"
|
||||||
"haixun-backend/internal/response"
|
"haixun-backend/internal/response"
|
||||||
"haixun-backend/internal/svc"
|
"haixun-backend/internal/svc"
|
||||||
"haixun-backend/internal/types"
|
"haixun-backend/internal/types"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/rest/httpx"
|
||||||
)
|
)
|
||||||
|
|
||||||
func ListBrandProductsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
func ListBrandProductsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,12 @@ package brand
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/rest/httpx"
|
|
||||||
"haixun-backend/internal/logic/brand"
|
"haixun-backend/internal/logic/brand"
|
||||||
"haixun-backend/internal/response"
|
"haixun-backend/internal/response"
|
||||||
"haixun-backend/internal/svc"
|
"haixun-backend/internal/svc"
|
||||||
"haixun-backend/internal/types"
|
"haixun-backend/internal/types"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/rest/httpx"
|
||||||
)
|
)
|
||||||
|
|
||||||
func UpdateBrandProductHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
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 (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/rest/httpx"
|
|
||||||
"haixun-backend/internal/logic/placement_topic"
|
"haixun-backend/internal/logic/placement_topic"
|
||||||
"haixun-backend/internal/response"
|
"haixun-backend/internal/response"
|
||||||
"haixun-backend/internal/svc"
|
"haixun-backend/internal/svc"
|
||||||
"haixun-backend/internal/types"
|
"haixun-backend/internal/types"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/rest/httpx"
|
||||||
)
|
)
|
||||||
|
|
||||||
func BatchDeletePlacementTopicScanPostsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
func BatchDeletePlacementTopicScanPostsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,12 @@ package placement_topic
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/rest/httpx"
|
|
||||||
"haixun-backend/internal/logic/placement_topic"
|
"haixun-backend/internal/logic/placement_topic"
|
||||||
"haixun-backend/internal/response"
|
"haixun-backend/internal/response"
|
||||||
"haixun-backend/internal/svc"
|
"haixun-backend/internal/svc"
|
||||||
"haixun-backend/internal/types"
|
"haixun-backend/internal/types"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/rest/httpx"
|
||||||
)
|
)
|
||||||
|
|
||||||
func CreatePlacementTopicHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
func CreatePlacementTopicHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,12 @@ package placement_topic
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/rest/httpx"
|
|
||||||
"haixun-backend/internal/logic/placement_topic"
|
"haixun-backend/internal/logic/placement_topic"
|
||||||
"haixun-backend/internal/response"
|
"haixun-backend/internal/response"
|
||||||
"haixun-backend/internal/svc"
|
"haixun-backend/internal/svc"
|
||||||
"haixun-backend/internal/types"
|
"haixun-backend/internal/types"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/rest/httpx"
|
||||||
)
|
)
|
||||||
|
|
||||||
func DeletePlacementTopicHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
func DeletePlacementTopicHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,12 @@ package placement_topic
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/rest/httpx"
|
|
||||||
"haixun-backend/internal/logic/placement_topic"
|
"haixun-backend/internal/logic/placement_topic"
|
||||||
"haixun-backend/internal/response"
|
"haixun-backend/internal/response"
|
||||||
"haixun-backend/internal/svc"
|
"haixun-backend/internal/svc"
|
||||||
"haixun-backend/internal/types"
|
"haixun-backend/internal/types"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/rest/httpx"
|
||||||
)
|
)
|
||||||
|
|
||||||
func DeletePlacementTopicScanPostHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
func DeletePlacementTopicScanPostHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,12 @@ package placement_topic
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/rest/httpx"
|
|
||||||
"haixun-backend/internal/logic/placement_topic"
|
"haixun-backend/internal/logic/placement_topic"
|
||||||
"haixun-backend/internal/response"
|
"haixun-backend/internal/response"
|
||||||
"haixun-backend/internal/svc"
|
"haixun-backend/internal/svc"
|
||||||
"haixun-backend/internal/types"
|
"haixun-backend/internal/types"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/rest/httpx"
|
||||||
)
|
)
|
||||||
|
|
||||||
func ExpandPlacementTopicGraphHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
func ExpandPlacementTopicGraphHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,12 @@ package placement_topic
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/rest/httpx"
|
|
||||||
"haixun-backend/internal/logic/placement_topic"
|
"haixun-backend/internal/logic/placement_topic"
|
||||||
"haixun-backend/internal/response"
|
"haixun-backend/internal/response"
|
||||||
"haixun-backend/internal/svc"
|
"haixun-backend/internal/svc"
|
||||||
"haixun-backend/internal/types"
|
"haixun-backend/internal/types"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/rest/httpx"
|
||||||
)
|
)
|
||||||
|
|
||||||
func GeneratePlacementTopicContentMatrixHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
func GeneratePlacementTopicContentMatrixHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,12 @@ package placement_topic
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/rest/httpx"
|
|
||||||
"haixun-backend/internal/logic/placement_topic"
|
"haixun-backend/internal/logic/placement_topic"
|
||||||
"haixun-backend/internal/response"
|
"haixun-backend/internal/response"
|
||||||
"haixun-backend/internal/svc"
|
"haixun-backend/internal/svc"
|
||||||
"haixun-backend/internal/types"
|
"haixun-backend/internal/types"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/rest/httpx"
|
||||||
)
|
)
|
||||||
|
|
||||||
func GeneratePlacementTopicOutreachDraftsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
func GeneratePlacementTopicOutreachDraftsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,12 @@ package placement_topic
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/rest/httpx"
|
|
||||||
"haixun-backend/internal/logic/placement_topic"
|
"haixun-backend/internal/logic/placement_topic"
|
||||||
"haixun-backend/internal/response"
|
"haixun-backend/internal/response"
|
||||||
"haixun-backend/internal/svc"
|
"haixun-backend/internal/svc"
|
||||||
"haixun-backend/internal/types"
|
"haixun-backend/internal/types"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/rest/httpx"
|
||||||
)
|
)
|
||||||
|
|
||||||
func GetPlacementTopicContentMatrixHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
func GetPlacementTopicContentMatrixHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,12 @@ package placement_topic
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/rest/httpx"
|
|
||||||
"haixun-backend/internal/logic/placement_topic"
|
"haixun-backend/internal/logic/placement_topic"
|
||||||
"haixun-backend/internal/response"
|
"haixun-backend/internal/response"
|
||||||
"haixun-backend/internal/svc"
|
"haixun-backend/internal/svc"
|
||||||
"haixun-backend/internal/types"
|
"haixun-backend/internal/types"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/rest/httpx"
|
||||||
)
|
)
|
||||||
|
|
||||||
func GetPlacementTopicGraphHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
func GetPlacementTopicGraphHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,12 @@ package placement_topic
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/rest/httpx"
|
|
||||||
"haixun-backend/internal/logic/placement_topic"
|
"haixun-backend/internal/logic/placement_topic"
|
||||||
"haixun-backend/internal/response"
|
"haixun-backend/internal/response"
|
||||||
"haixun-backend/internal/svc"
|
"haixun-backend/internal/svc"
|
||||||
"haixun-backend/internal/types"
|
"haixun-backend/internal/types"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/rest/httpx"
|
||||||
)
|
)
|
||||||
|
|
||||||
func GetPlacementTopicHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
func GetPlacementTopicHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,12 @@ package placement_topic
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/rest/httpx"
|
|
||||||
"haixun-backend/internal/logic/placement_topic"
|
"haixun-backend/internal/logic/placement_topic"
|
||||||
"haixun-backend/internal/response"
|
"haixun-backend/internal/response"
|
||||||
"haixun-backend/internal/svc"
|
"haixun-backend/internal/svc"
|
||||||
"haixun-backend/internal/types"
|
"haixun-backend/internal/types"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/rest/httpx"
|
||||||
)
|
)
|
||||||
|
|
||||||
func GetPlacementTopicScanScheduleHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
func GetPlacementTopicScanScheduleHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,12 @@ package placement_topic
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/rest/httpx"
|
|
||||||
"haixun-backend/internal/logic/placement_topic"
|
"haixun-backend/internal/logic/placement_topic"
|
||||||
"haixun-backend/internal/response"
|
"haixun-backend/internal/response"
|
||||||
"haixun-backend/internal/svc"
|
"haixun-backend/internal/svc"
|
||||||
"haixun-backend/internal/types"
|
"haixun-backend/internal/types"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/rest/httpx"
|
||||||
)
|
)
|
||||||
|
|
||||||
func ListPlacementTopicScanPostsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
func ListPlacementTopicScanPostsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,12 @@ package placement_topic
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/rest/httpx"
|
|
||||||
"haixun-backend/internal/logic/placement_topic"
|
"haixun-backend/internal/logic/placement_topic"
|
||||||
"haixun-backend/internal/response"
|
"haixun-backend/internal/response"
|
||||||
"haixun-backend/internal/svc"
|
"haixun-backend/internal/svc"
|
||||||
"haixun-backend/internal/types"
|
"haixun-backend/internal/types"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/rest/httpx"
|
||||||
)
|
)
|
||||||
|
|
||||||
func PatchPlacementTopicGraphNodesHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
func PatchPlacementTopicGraphNodesHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,12 @@ package placement_topic
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/rest/httpx"
|
|
||||||
"haixun-backend/internal/logic/placement_topic"
|
"haixun-backend/internal/logic/placement_topic"
|
||||||
"haixun-backend/internal/response"
|
"haixun-backend/internal/response"
|
||||||
"haixun-backend/internal/svc"
|
"haixun-backend/internal/svc"
|
||||||
"haixun-backend/internal/types"
|
"haixun-backend/internal/types"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/rest/httpx"
|
||||||
)
|
)
|
||||||
|
|
||||||
func PatchPlacementTopicScanPostOutreachHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
func PatchPlacementTopicScanPostOutreachHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,12 @@ package placement_topic
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/rest/httpx"
|
|
||||||
"haixun-backend/internal/logic/placement_topic"
|
"haixun-backend/internal/logic/placement_topic"
|
||||||
"haixun-backend/internal/response"
|
"haixun-backend/internal/response"
|
||||||
"haixun-backend/internal/svc"
|
"haixun-backend/internal/svc"
|
||||||
"haixun-backend/internal/types"
|
"haixun-backend/internal/types"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/rest/httpx"
|
||||||
)
|
)
|
||||||
|
|
||||||
func PublishPlacementTopicOutreachDraftHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
func PublishPlacementTopicOutreachDraftHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,12 @@ package placement_topic
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/rest/httpx"
|
|
||||||
"haixun-backend/internal/logic/placement_topic"
|
"haixun-backend/internal/logic/placement_topic"
|
||||||
"haixun-backend/internal/response"
|
"haixun-backend/internal/response"
|
||||||
"haixun-backend/internal/svc"
|
"haixun-backend/internal/svc"
|
||||||
"haixun-backend/internal/types"
|
"haixun-backend/internal/types"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/rest/httpx"
|
||||||
)
|
)
|
||||||
|
|
||||||
func StartPlacementTopicScanJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
func StartPlacementTopicScanJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,12 @@ package placement_topic
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/rest/httpx"
|
|
||||||
"haixun-backend/internal/logic/placement_topic"
|
"haixun-backend/internal/logic/placement_topic"
|
||||||
"haixun-backend/internal/response"
|
"haixun-backend/internal/response"
|
||||||
"haixun-backend/internal/svc"
|
"haixun-backend/internal/svc"
|
||||||
"haixun-backend/internal/types"
|
"haixun-backend/internal/types"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/rest/httpx"
|
||||||
)
|
)
|
||||||
|
|
||||||
func UpdatePlacementTopicHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
func UpdatePlacementTopicHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,12 @@ package placement_topic
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/rest/httpx"
|
|
||||||
"haixun-backend/internal/logic/placement_topic"
|
"haixun-backend/internal/logic/placement_topic"
|
||||||
"haixun-backend/internal/response"
|
"haixun-backend/internal/response"
|
||||||
"haixun-backend/internal/svc"
|
"haixun-backend/internal/svc"
|
||||||
"haixun-backend/internal/types"
|
"haixun-backend/internal/types"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/rest/httpx"
|
||||||
)
|
)
|
||||||
|
|
||||||
func UpsertPlacementTopicScanScheduleHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
func UpsertPlacementTopicScanScheduleHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import (
|
||||||
ai "haixun-backend/internal/handler/ai"
|
ai "haixun-backend/internal/handler/ai"
|
||||||
auth "haixun-backend/internal/handler/auth"
|
auth "haixun-backend/internal/handler/auth"
|
||||||
brand "haixun-backend/internal/handler/brand"
|
brand "haixun-backend/internal/handler/brand"
|
||||||
|
copy_mission "haixun-backend/internal/handler/copy_mission"
|
||||||
job "haixun-backend/internal/handler/job"
|
job "haixun-backend/internal/handler/job"
|
||||||
member "haixun-backend/internal/handler/member"
|
member "haixun-backend/internal/handler/member"
|
||||||
normal "haixun-backend/internal/handler/normal"
|
normal "haixun-backend/internal/handler/normal"
|
||||||
|
|
@ -223,61 +224,86 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
|
||||||
|
|
||||||
server.AddRoutes(
|
server.AddRoutes(
|
||||||
rest.WithMiddlewares(
|
rest.WithMiddlewares(
|
||||||
[]rest.Middleware{serverCtx.WorkerSecret},
|
[]rest.Middleware{serverCtx.AuthJWT},
|
||||||
[]rest.Route{
|
[]rest.Route{
|
||||||
{
|
{
|
||||||
Method: http.MethodPost,
|
Method: http.MethodGet,
|
||||||
Path: "/workers/jobs/:id/analyze-style8d",
|
Path: "/:personaId/copy-missions",
|
||||||
Handler: job.AnalyzeStyle8DFromWorkerHandler(serverCtx),
|
Handler: copy_mission.ListCopyMissionsHandler(serverCtx),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Method: http.MethodPost,
|
Method: http.MethodPost,
|
||||||
Path: "/workers/jobs/:id/cancel-ack",
|
Path: "/:personaId/copy-mission-inspiration",
|
||||||
Handler: job.AckWorkerJobCancelHandler(serverCtx),
|
Handler: copy_mission.InspireCopyMissionHandler(serverCtx),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Method: http.MethodPost,
|
Method: http.MethodPost,
|
||||||
Path: "/workers/jobs/:id/cancel-check",
|
Path: "/:personaId/copy-missions",
|
||||||
Handler: job.CheckWorkerJobCancelHandler(serverCtx),
|
Handler: copy_mission.CreateCopyMissionHandler(serverCtx),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Method: http.MethodPost,
|
Method: http.MethodGet,
|
||||||
Path: "/workers/jobs/:id/complete",
|
Path: "/:personaId/copy-missions/:id",
|
||||||
Handler: job.CompleteWorkerJobHandler(serverCtx),
|
Handler: copy_mission.GetCopyMissionHandler(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,
|
Method: http.MethodPatch,
|
||||||
Path: "/workers/personas/:id/style-profile",
|
Path: "/:personaId/copy-missions/:id",
|
||||||
Handler: job.StorePersonaStyleProfileFromWorkerHandler(serverCtx),
|
Handler: copy_mission.UpdateCopyMissionHandler(serverCtx),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Method: http.MethodDelete,
|
||||||
|
Path: "/:personaId/copy-missions/:id",
|
||||||
|
Handler: copy_mission.DeleteCopyMissionHandler(serverCtx),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Method: http.MethodPost,
|
Method: http.MethodPost,
|
||||||
Path: "/workers/threads-accounts/:id/session",
|
Path: "/:personaId/copy-missions/:id/analyze-jobs",
|
||||||
Handler: job.GetWorkerThreadsAccountSessionHandler(serverCtx),
|
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(
|
server.AddRoutes(
|
||||||
|
|
@ -359,6 +385,65 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
|
||||||
rest.WithPrefix("/api/v1"),
|
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(
|
server.AddRoutes(
|
||||||
rest.WithMiddlewares(
|
rest.WithMiddlewares(
|
||||||
[]rest.Middleware{serverCtx.AuthJWT},
|
[]rest.Middleware{serverCtx.AuthJWT},
|
||||||
|
|
@ -452,6 +537,16 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
|
||||||
Path: "/:id/copy-drafts",
|
Path: "/:id/copy-drafts",
|
||||||
Handler: persona.ListPersonaCopyDraftsHandler(serverCtx),
|
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,
|
Method: http.MethodPost,
|
||||||
Path: "/:id/copy-drafts/generate",
|
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"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
|
||||||
libbrave "haixun-backend/internal/library/brave"
|
"haixun-backend/internal/library/websearch"
|
||||||
)
|
)
|
||||||
|
|
||||||
type BraveSearchLocale struct {
|
type BraveSearchLocale struct {
|
||||||
Country string
|
Country string
|
||||||
SearchLang string
|
SearchLang string
|
||||||
|
UserLocation string
|
||||||
}
|
}
|
||||||
|
|
||||||
type BraveCollectConfig struct {
|
type BraveCollectConfig struct {
|
||||||
|
|
@ -53,7 +54,19 @@ func DefaultBraveCollectConfig() BraveCollectConfig {
|
||||||
|
|
||||||
func CollectBraveSources(
|
func CollectBraveSources(
|
||||||
ctx context.Context,
|
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,
|
locale BraveSearchLocale,
|
||||||
queries []string,
|
queries []string,
|
||||||
cfg BraveCollectConfig,
|
cfg BraveCollectConfig,
|
||||||
|
|
@ -64,14 +77,14 @@ func CollectBraveSources(
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if cfg.Concurrency <= 1 {
|
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,
|
ctx context.Context,
|
||||||
client *libbrave.Client,
|
client websearch.Client,
|
||||||
locale BraveSearchLocale,
|
locale BraveSearchLocale,
|
||||||
queries []string,
|
queries []string,
|
||||||
cfg BraveCollectConfig,
|
cfg BraveCollectConfig,
|
||||||
|
|
@ -94,7 +107,7 @@ func collectBraveSourcesSequential(
|
||||||
return out
|
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 {
|
if onProgress != nil {
|
||||||
onProgress(i, len(queries))
|
onProgress(i, len(queries))
|
||||||
}
|
}
|
||||||
|
|
@ -136,9 +149,9 @@ func (s *braveCollectState) appendResults(query string, items []BraveSource) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func collectBraveSourcesParallel(
|
func collectWebSourcesParallel(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
client *libbrave.Client,
|
client websearch.Client,
|
||||||
locale BraveSearchLocale,
|
locale BraveSearchLocale,
|
||||||
queries []string,
|
queries []string,
|
||||||
cfg BraveCollectConfig,
|
cfg BraveCollectConfig,
|
||||||
|
|
@ -180,7 +193,7 @@ func collectBraveSourcesParallel(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
query := queries[i]
|
query := queries[i]
|
||||||
items := searchBraveQuery(ctx, client, locale, query, cfg.ResultsPerQuery)
|
items := searchWebQuery(ctx, client, locale, query, cfg.ResultsPerQuery)
|
||||||
state.appendResults(query, items)
|
state.appendResults(query, items)
|
||||||
done := int(atomic.AddInt32(&state.completed, 1))
|
done := int(atomic.AddInt32(&state.completed, 1))
|
||||||
if onProgress != nil {
|
if onProgress != nil {
|
||||||
|
|
@ -200,19 +213,20 @@ func shouldStopCollect(out []BraveSource, cfg BraveCollectConfig) bool {
|
||||||
return len(out) >= cfg.MinSourcesBeforeStop && uniqueSourceCount(out) >= cfg.MinSourcesBeforeStop
|
return len(out) >= cfg.MinSourcesBeforeStop && uniqueSourceCount(out) >= cfg.MinSourcesBeforeStop
|
||||||
}
|
}
|
||||||
|
|
||||||
func searchBraveQuery(
|
func searchWebQuery(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
client *libbrave.Client,
|
client websearch.Client,
|
||||||
locale BraveSearchLocale,
|
locale BraveSearchLocale,
|
||||||
query string,
|
query string,
|
||||||
limit int,
|
limit int,
|
||||||
) []BraveSource {
|
) []BraveSource {
|
||||||
res, _ := client.Search(ctx, libbrave.SearchOptions{
|
res, _ := client.Search(ctx, websearch.SearchOptions{
|
||||||
Query: query,
|
Query: query,
|
||||||
Limit: limit,
|
Limit: limit,
|
||||||
Mode: libbrave.ModeKnowledgeExpand,
|
Mode: websearch.ModeKnowledgeExpand,
|
||||||
Country: locale.Country,
|
Country: locale.Country,
|
||||||
SearchLang: locale.SearchLang,
|
SearchLang: locale.SearchLang,
|
||||||
|
UserLocation: locale.UserLocation,
|
||||||
})
|
})
|
||||||
items := make([]BraveSource, 0, len(res.Results))
|
items := make([]BraveSource, 0, len(res.Results))
|
||||||
for _, item := range 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
|
return s == ExpandStrategyBrave || s == ExpandStrategyHybrid
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s ExpandStrategy) RequiresBrave() bool {
|
||||||
|
return s.RequiresWebSearch()
|
||||||
|
}
|
||||||
|
|
||||||
// UsesSupplementalBrave 廣度補充是否再打第二輪 Brave(hybrid 改由 LLM 補廣度以省 API)。
|
// UsesSupplementalBrave 廣度補充是否再打第二輪 Brave(hybrid 改由 LLM 補廣度以省 API)。
|
||||||
func (s ExpandStrategy) UsesSupplementalBrave() bool {
|
func (s ExpandStrategy) UsesSupplementalBrave() bool {
|
||||||
return s == ExpandStrategyBrave
|
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"
|
"strings"
|
||||||
|
|
||||||
libprompt "haixun-backend/internal/library/prompt"
|
libprompt "haixun-backend/internal/library/prompt"
|
||||||
|
"haixun-backend/internal/library/threadspost"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Row struct {
|
type Row struct {
|
||||||
|
|
@ -121,8 +122,8 @@ func ParseGenerateOutput(raw string) (GenerateResult, error) {
|
||||||
func trimText(text string) string {
|
func trimText(text string) string {
|
||||||
text = strings.TrimSpace(text)
|
text = strings.TrimSpace(text)
|
||||||
runes := []rune(text)
|
runes := []rune(text)
|
||||||
if len(runes) > 500 {
|
if len(runes) > threadspost.MaxPublishRunes {
|
||||||
return string(runes[:500])
|
return string(runes[:threadspost.MaxPublishRunes])
|
||||||
}
|
}
|
||||||
return text
|
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
|
package placement
|
||||||
|
|
||||||
import "strings"
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"haixun-backend/internal/library/websearch"
|
||||||
|
)
|
||||||
|
|
||||||
// ConnectionPrefsInput mirrors persisted account connection prefs without importing threads_account.
|
// ConnectionPrefsInput mirrors persisted account connection prefs without importing threads_account.
|
||||||
type ConnectionPrefsInput struct {
|
type ConnectionPrefsInput struct {
|
||||||
|
|
@ -18,9 +23,12 @@ type MemberContext struct {
|
||||||
AllowsThreadsAPI bool
|
AllowsThreadsAPI bool
|
||||||
AllowsBrave bool
|
AllowsBrave bool
|
||||||
AllowsCrawler bool
|
AllowsCrawler bool
|
||||||
|
WebSearchProvider string
|
||||||
BraveAPIKey string
|
BraveAPIKey string
|
||||||
|
ExaAPIKey string
|
||||||
BraveCountry string
|
BraveCountry string
|
||||||
BraveSearchLang string
|
BraveSearchLang string
|
||||||
|
ExaUserLocation string
|
||||||
ApiConnected bool
|
ApiConnected bool
|
||||||
BrowserConnected bool
|
BrowserConnected bool
|
||||||
ThreadsAPIAccessToken string
|
ThreadsAPIAccessToken string
|
||||||
|
|
@ -29,10 +37,13 @@ type MemberContext struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type ResearchSettings struct {
|
type ResearchSettings struct {
|
||||||
BraveAPIKey string
|
WebSearchProvider string
|
||||||
BraveCountry string
|
BraveAPIKey string
|
||||||
BraveSearchLang string
|
ExaAPIKey string
|
||||||
ExpandStrategy string
|
BraveCountry string
|
||||||
|
BraveSearchLang string
|
||||||
|
ExaUserLocation string
|
||||||
|
ExpandStrategy string
|
||||||
}
|
}
|
||||||
|
|
||||||
func BuildMemberContext(
|
func BuildMemberContext(
|
||||||
|
|
@ -44,20 +55,13 @@ func BuildMemberContext(
|
||||||
repliesPerPost int,
|
repliesPerPost int,
|
||||||
) MemberContext {
|
) MemberContext {
|
||||||
mode := ParseSearchSourceMode(prefs.SearchSourceMode)
|
mode := ParseSearchSourceMode(prefs.SearchSourceMode)
|
||||||
|
if prefs.DevMode && strings.TrimSpace(prefs.SearchSourceMode) == "" {
|
||||||
|
mode = SearchSourceCrawler
|
||||||
|
}
|
||||||
allowsCrawler := ModeAllowsCrawler(mode)
|
allowsCrawler := ModeAllowsCrawler(mode)
|
||||||
allowsThreads := ModeAllowsThreadsAPI(mode)
|
allowsThreads := ModeAllowsThreadsAPI(mode)
|
||||||
allowsBrave := ModeAllowsBrave(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)
|
country := strings.TrimSpace(research.BraveCountry)
|
||||||
if country == "" {
|
if country == "" {
|
||||||
country = "tw"
|
country = "tw"
|
||||||
|
|
@ -66,45 +70,103 @@ func BuildMemberContext(
|
||||||
if lang == "" {
|
if lang == "" {
|
||||||
lang = "zh-hant"
|
lang = "zh-hant"
|
||||||
}
|
}
|
||||||
|
userLocation := strings.TrimSpace(research.ExaUserLocation)
|
||||||
|
if userLocation == "" {
|
||||||
|
userLocation = "TW"
|
||||||
|
}
|
||||||
|
|
||||||
if repliesPerPost <= 0 {
|
if repliesPerPost <= 0 {
|
||||||
repliesPerPost = 10
|
repliesPerPost = 10
|
||||||
}
|
}
|
||||||
|
|
||||||
return MemberContext{
|
return MemberContext{
|
||||||
TenantID: tenantID,
|
TenantID: tenantID,
|
||||||
OwnerUID: ownerUID,
|
OwnerUID: ownerUID,
|
||||||
ActiveAccountID: activeAccountID,
|
ActiveAccountID: activeAccountID,
|
||||||
DevMode: prefs.DevMode,
|
DevMode: prefs.DevMode,
|
||||||
SearchSourceMode: mode,
|
SearchSourceMode: mode,
|
||||||
AllowsThreadsAPI: allowsThreads,
|
AllowsThreadsAPI: allowsThreads,
|
||||||
AllowsBrave: allowsBrave,
|
AllowsBrave: allowsBrave,
|
||||||
AllowsCrawler: allowsCrawler,
|
AllowsCrawler: allowsCrawler,
|
||||||
BraveAPIKey: strings.TrimSpace(research.BraveAPIKey),
|
WebSearchProvider: string(websearch.ParseProvider(research.WebSearchProvider)),
|
||||||
BraveCountry: country,
|
BraveAPIKey: strings.TrimSpace(research.BraveAPIKey),
|
||||||
BraveSearchLang: lang,
|
ExaAPIKey: strings.TrimSpace(research.ExaAPIKey),
|
||||||
ApiConnected: apiConnected,
|
BraveCountry: country,
|
||||||
BrowserConnected: browserConnected,
|
BraveSearchLang: lang,
|
||||||
ScrapeReplies: scrapeReplies,
|
ExaUserLocation: userLocation,
|
||||||
RepliesPerPost: repliesPerPost,
|
ApiConnected: apiConnected,
|
||||||
|
BrowserConnected: browserConnected,
|
||||||
|
ScrapeReplies: scrapeReplies,
|
||||||
|
RepliesPerPost: repliesPerPost,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c MemberContext) PayloadFields() map[string]any {
|
func (c MemberContext) PayloadFields() map[string]any {
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"tenant_id": c.TenantID,
|
"tenant_id": c.TenantID,
|
||||||
"owner_uid": c.OwnerUID,
|
"owner_uid": c.OwnerUID,
|
||||||
"threads_account_id": c.ActiveAccountID,
|
"threads_account_id": c.ActiveAccountID,
|
||||||
"dev_mode": c.DevMode,
|
"dev_mode": c.DevMode,
|
||||||
"search_source_mode": string(c.SearchSourceMode),
|
"search_source_mode": string(c.SearchSourceMode),
|
||||||
"allows_threads_api": c.AllowsThreadsAPI,
|
"allows_threads_api": c.AllowsThreadsAPI,
|
||||||
"allows_brave": c.AllowsBrave,
|
"allows_brave": c.AllowsBrave,
|
||||||
"allows_crawler": c.AllowsCrawler,
|
"allows_crawler": c.AllowsCrawler,
|
||||||
"brave_country": c.BraveCountry,
|
"web_search_provider": c.WebSearchProvider,
|
||||||
"brave_search_lang": c.BraveSearchLang,
|
"brave_country": c.BraveCountry,
|
||||||
"api_connected": c.ApiConnected,
|
"brave_search_lang": c.BraveSearchLang,
|
||||||
"browser_connected": c.BrowserConnected,
|
"exa_user_location": c.ExaUserLocation,
|
||||||
"scrape_replies": c.ScrapeReplies,
|
"api_connected": c.ApiConnected,
|
||||||
"replies_per_post": c.RepliesPerPost,
|
"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"`
|
Permalink string `json:"permalink"`
|
||||||
ExternalID string `json:"externalId"`
|
ExternalID string `json:"externalId"`
|
||||||
AuthorName string `json:"authorName"`
|
AuthorName string `json:"authorName"`
|
||||||
LikeCount int `json:"likeCount"`
|
LikeCount int `json:"likeCount"`
|
||||||
ReplyCount int `json:"replyCount"`
|
ReplyCount int `json:"replyCount"`
|
||||||
|
AuthorVerified bool `json:"authorVerified"`
|
||||||
|
FollowerCount int `json:"followerCount"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type execCrawlerOutput struct {
|
type execCrawlerOutput struct {
|
||||||
|
|
@ -94,13 +96,15 @@ func RunExecCrawlerSearch(ctx context.Context, storageState, keyword string, lim
|
||||||
permalink := strings.TrimSpace(item.Permalink)
|
permalink := strings.TrimSpace(item.Permalink)
|
||||||
extID := strings.TrimSpace(item.ExternalID)
|
extID := strings.TrimSpace(item.ExternalID)
|
||||||
posts = append(posts, DiscoverPost{
|
posts = append(posts, DiscoverPost{
|
||||||
Text: text,
|
Text: text,
|
||||||
Permalink: permalink,
|
Permalink: permalink,
|
||||||
ExternalID: extID,
|
ExternalID: extID,
|
||||||
Author: author,
|
Author: author,
|
||||||
LikeCount: item.LikeCount,
|
AuthorVerified: item.AuthorVerified,
|
||||||
ReplyCount: item.ReplyCount,
|
FollowerCount: item.FollowerCount,
|
||||||
Source: DiscoverCrawler,
|
LikeCount: item.LikeCount,
|
||||||
|
ReplyCount: item.ReplyCount,
|
||||||
|
Source: DiscoverCrawler,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return posts, nil
|
return posts, nil
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ type DiscoverChannel string
|
||||||
const (
|
const (
|
||||||
DiscoverThreadsAPI DiscoverChannel = "threads_api"
|
DiscoverThreadsAPI DiscoverChannel = "threads_api"
|
||||||
DiscoverBrave DiscoverChannel = "brave"
|
DiscoverBrave DiscoverChannel = "brave"
|
||||||
|
DiscoverExa DiscoverChannel = "exa"
|
||||||
DiscoverCrawler DiscoverChannel = "crawler"
|
DiscoverCrawler DiscoverChannel = "crawler"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -25,57 +26,84 @@ type DiscoverRequest struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type DiscoverPost struct {
|
type DiscoverPost struct {
|
||||||
Text string
|
Text string
|
||||||
Permalink string
|
Permalink string
|
||||||
ExternalID string
|
ExternalID string
|
||||||
Author string
|
Author string
|
||||||
PostedAt string
|
PostedAt string
|
||||||
LikeCount int
|
AuthorVerified bool
|
||||||
ReplyCount int
|
FollowerCount int
|
||||||
Source DiscoverChannel
|
LikeCount int
|
||||||
|
ReplyCount int
|
||||||
|
Source DiscoverChannel
|
||||||
}
|
}
|
||||||
|
|
||||||
// Discover runs keyword discovery respecting the member's connection prefs.
|
// Discover runs keyword discovery respecting search_source_mode and available connections.
|
||||||
// Formal mode (dev_mode=false) never falls back to crawler.
|
// 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) {
|
func Discover(ctx context.Context, req DiscoverRequest) ([]DiscoverPost, DiscoverChannel, error) {
|
||||||
m := req.Member
|
m := req.Member
|
||||||
if m.DevMode {
|
if !m.HasDiscoverPath() {
|
||||||
if !m.BrowserConnected {
|
return nil, "", discoverMissingPathError(m)
|
||||||
return nil, "", fmt.Errorf("開發模式需先同步 Chrome Session")
|
}
|
||||||
|
|
||||||
|
if ShouldTryCrawlerFirst(m) {
|
||||||
|
posts, err := runCrawlerDiscover(ctx, req)
|
||||||
|
if err == nil && len(posts) > 0 {
|
||||||
|
return posts, DiscoverCrawler, nil
|
||||||
}
|
}
|
||||||
if req.Crawler == nil {
|
if m.SearchSourceMode == SearchSourceCrawler {
|
||||||
return nil, DiscoverCrawler, fmt.Errorf("crawler search not configured")
|
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 {
|
if err != nil {
|
||||||
return nil, DiscoverCrawler, err
|
return nil, DiscoverCrawler, err
|
||||||
}
|
}
|
||||||
return posts, DiscoverCrawler, nil
|
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)
|
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"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
libbrave "haixun-backend/internal/library/brave"
|
|
||||||
libkg "haixun-backend/internal/library/knowledge"
|
libkg "haixun-backend/internal/library/knowledge"
|
||||||
|
"haixun-backend/internal/library/websearch"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|
@ -28,6 +28,8 @@ type ScanCandidate struct {
|
||||||
HasRelevance bool
|
HasRelevance bool
|
||||||
HasRecency bool
|
HasRecency bool
|
||||||
Priority string
|
Priority string
|
||||||
|
AuthorVerified bool
|
||||||
|
FollowerCount int
|
||||||
LikeCount int
|
LikeCount int
|
||||||
ReplyCount int
|
ReplyCount int
|
||||||
EngagementScore int
|
EngagementScore int
|
||||||
|
|
@ -42,7 +44,7 @@ type DualTrackInput struct {
|
||||||
PatrolKeywords []string
|
PatrolKeywords []string
|
||||||
Exclusions []string
|
Exclusions []string
|
||||||
Member MemberContext
|
Member MemberContext
|
||||||
Client *libbrave.Client
|
WebSearch websearch.Client
|
||||||
Crawler CrawlerSearchFn
|
Crawler CrawlerSearchFn
|
||||||
Limit int // max queries budget; 0 = default
|
Limit int // max queries budget; 0 = default
|
||||||
OnCheckpoint func(candidates []ScanCandidate) error
|
OnCheckpoint func(candidates []ScanCandidate) error
|
||||||
|
|
@ -51,7 +53,7 @@ type DualTrackInput struct {
|
||||||
type DualTrackProgress func(message string, pct int)
|
type DualTrackProgress func(message string, pct int)
|
||||||
|
|
||||||
// CollectTagQueries builds crawl jobs from selected graph nodes.
|
// 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)
|
out := make([]TagQuery, 0, len(nodes)*4)
|
||||||
for _, node := range nodes {
|
for _, node := range nodes {
|
||||||
if !node.SelectedForScan {
|
if !node.SelectedForScan {
|
||||||
|
|
@ -67,7 +69,7 @@ func CollectTagQueries(nodes []libkg.Node) []TagQuery {
|
||||||
if tag == "" {
|
if tag == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
q := BuildRelevanceQuery(tag)
|
q := BuildRelevanceQuery(provider, tag)
|
||||||
if q == "" {
|
if q == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
@ -84,7 +86,7 @@ func CollectTagQueries(nodes []libkg.Node) []TagQuery {
|
||||||
if tag == "" {
|
if tag == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
q7 := BuildRecencyQuery(tag, IdealMaxPostAgeDays)
|
q7 := BuildRecencyQuery(provider, tag, IdealMaxPostAgeDays)
|
||||||
if q7 != "" {
|
if q7 != "" {
|
||||||
out = append(out, TagQuery{
|
out = append(out, TagQuery{
|
||||||
Tag: tag,
|
Tag: tag,
|
||||||
|
|
@ -95,7 +97,7 @@ func CollectTagQueries(nodes []libkg.Node) []TagQuery {
|
||||||
RecencyDays: IdealMaxPostAgeDays,
|
RecencyDays: IdealMaxPostAgeDays,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
q30 := BuildRecencyQuery(tag, MaxPostAgeDays)
|
q30 := BuildRecencyQuery(provider, tag, MaxPostAgeDays)
|
||||||
if q30 != "" && q30 != q7 {
|
if q30 != "" && q30 != q7 {
|
||||||
out = append(out, TagQuery{
|
out = append(out, TagQuery{
|
||||||
Tag: tag,
|
Tag: tag,
|
||||||
|
|
@ -113,7 +115,7 @@ func CollectTagQueries(nodes []libkg.Node) []TagQuery {
|
||||||
|
|
||||||
// RunDualTrackDiscover executes relevance + recency queries and merges by permalink.
|
// RunDualTrackDiscover executes relevance + recency queries and merges by permalink.
|
||||||
func RunDualTrackDiscover(ctx context.Context, input DualTrackInput, onProgress DualTrackProgress) ([]ScanCandidate, error) {
|
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(queries) == 0 {
|
||||||
if len(input.PatrolKeywords) > 0 {
|
if len(input.PatrolKeywords) > 0 {
|
||||||
return nil, fmt.Errorf("海巡關鍵字格式無效,請改用 2~8 字的真人搜尋短句")
|
return nil, fmt.Errorf("海巡關鍵字格式無效,請改用 2~8 字的真人搜尋短句")
|
||||||
|
|
@ -165,6 +167,8 @@ func RunDualTrackDiscover(ctx context.Context, input DualTrackInput, onProgress
|
||||||
Permalink: post.Permalink,
|
Permalink: post.Permalink,
|
||||||
ExternalID: extID,
|
ExternalID: extID,
|
||||||
Author: post.Author,
|
Author: post.Author,
|
||||||
|
AuthorVerified: post.AuthorVerified,
|
||||||
|
FollowerCount: post.FollowerCount,
|
||||||
Text: post.Text,
|
Text: post.Text,
|
||||||
SearchTag: tq.Tag,
|
SearchTag: tq.Tag,
|
||||||
QueryDimension: tq.Dimension,
|
QueryDimension: tq.Dimension,
|
||||||
|
|
@ -174,6 +178,8 @@ func RunDualTrackDiscover(ctx context.Context, input DualTrackInput, onProgress
|
||||||
HasRelevance: tq.Dimension == QueryRelevance,
|
HasRelevance: tq.Dimension == QueryRelevance,
|
||||||
HasRecency: tq.Dimension == QueryRecency,
|
HasRecency: tq.Dimension == QueryRecency,
|
||||||
Priority: priority,
|
Priority: priority,
|
||||||
|
LikeCount: post.LikeCount,
|
||||||
|
ReplyCount: post.ReplyCount,
|
||||||
PlacementScore: computePlacementScore(post.Text, tq.ProductFitScore, tq.Dimension == QueryRecency),
|
PlacementScore: computePlacementScore(post.Text, tq.ProductFitScore, tq.Dimension == QueryRecency),
|
||||||
SolvedByProduct: tq.ProductFitScore >= 55,
|
SolvedByProduct: tq.ProductFitScore >= 55,
|
||||||
PostedAt: strings.TrimSpace(post.PostedAt),
|
PostedAt: strings.TrimSpace(post.PostedAt),
|
||||||
|
|
@ -217,7 +223,7 @@ func RunDualTrackDiscover(ctx context.Context, input DualTrackInput, onProgress
|
||||||
return nil, err
|
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 {
|
if err := politeDiscoverPause(ctx); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -244,29 +250,31 @@ func discoverForQuery(ctx context.Context, input DualTrackInput, tq TagQuery, li
|
||||||
if err == nil && len(posts) > 0 {
|
if err == nil && len(posts) > 0 {
|
||||||
return posts, channel, nil
|
return posts, channel, nil
|
||||||
}
|
}
|
||||||
if input.Client == nil || !input.Client.Enabled() {
|
if input.WebSearch == nil || !input.WebSearch.Enabled() {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", err
|
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)
|
webPosts, werr := discoverViaWebSearch(ctx, input.WebSearch, input.Member, tq, limit)
|
||||||
if berr != nil {
|
if werr != nil {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", err
|
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) {
|
func discoverViaWebSearch(ctx context.Context, client websearch.Client, member MemberContext, tq TagQuery, limit int) ([]DiscoverPost, error) {
|
||||||
res, err := client.Search(ctx, libbrave.SearchOptions{
|
res, err := client.Search(ctx, websearch.SearchOptions{
|
||||||
Query: query,
|
Query: tq.Query,
|
||||||
Limit: limit,
|
Limit: limit,
|
||||||
Mode: libbrave.ModeThreadsDiscover,
|
Mode: websearch.ModeThreadsDiscover,
|
||||||
Country: member.BraveCountry,
|
Country: member.BraveCountry,
|
||||||
SearchLang: member.BraveSearchLang,
|
SearchLang: member.BraveSearchLang,
|
||||||
|
UserLocation: member.ExaUserLocation,
|
||||||
|
StartPublishedDate: PublishedAfterForRecency(member.WebSearchProviderEnum(), tq.RecencyDays),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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 {
|
if res.Status != "success" || len(res.Results) == 0 {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
source := member.WebSearchDiscoverChannel()
|
||||||
out := make([]DiscoverPost, 0, len(res.Results))
|
out := make([]DiscoverPost, 0, len(res.Results))
|
||||||
for _, item := range res.Results {
|
for _, item := range res.Results {
|
||||||
parsed, ok := ParseThreadsPostFromWebResult(item.Title, item.Snippet, item.URL)
|
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,
|
Permalink: parsed.Permalink,
|
||||||
ExternalID: parsed.ExternalID,
|
ExternalID: parsed.ExternalID,
|
||||||
Author: parsed.Author,
|
Author: parsed.Author,
|
||||||
Source: DiscoverBrave,
|
Source: source,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return out, nil
|
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"
|
"strings"
|
||||||
|
|
||||||
libkg "haixun-backend/internal/library/knowledge"
|
libkg "haixun-backend/internal/library/knowledge"
|
||||||
|
"haixun-backend/internal/library/websearch"
|
||||||
)
|
)
|
||||||
|
|
||||||
const defaultPatrolProductFit = 78
|
const defaultPatrolProductFit = 78
|
||||||
|
|
||||||
// CollectPatrolTagQueries builds dual-track crawl jobs from user-edited patrol keywords only.
|
// 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)
|
keywords = libkg.NormalizePatrolKeywordList(keywords)
|
||||||
if len(keywords) == 0 {
|
if len(keywords) == 0 {
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -18,7 +19,7 @@ func CollectPatrolTagQueries(keywords []string, nodes []libkg.Node) []TagQuery {
|
||||||
out := make([]TagQuery, 0, len(keywords)*3)
|
out := make([]TagQuery, 0, len(keywords)*3)
|
||||||
for _, tag := range keywords {
|
for _, tag := range keywords {
|
||||||
fit := productFitForPatrolTag(tag, nodes)
|
fit := productFitForPatrolTag(tag, nodes)
|
||||||
if q := BuildRelevanceQuery(tag); q != "" {
|
if q := BuildRelevanceQuery(provider, tag); q != "" {
|
||||||
out = append(out, TagQuery{
|
out = append(out, TagQuery{
|
||||||
Tag: tag,
|
Tag: tag,
|
||||||
Query: q,
|
Query: q,
|
||||||
|
|
@ -26,7 +27,7 @@ func CollectPatrolTagQueries(keywords []string, nodes []libkg.Node) []TagQuery {
|
||||||
ProductFitScore: fit,
|
ProductFitScore: fit,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if q7 := BuildRecencyQuery(tag, IdealMaxPostAgeDays); q7 != "" {
|
if q7 := BuildRecencyQuery(provider, tag, IdealMaxPostAgeDays); q7 != "" {
|
||||||
out = append(out, TagQuery{
|
out = append(out, TagQuery{
|
||||||
Tag: tag,
|
Tag: tag,
|
||||||
Query: q7,
|
Query: q7,
|
||||||
|
|
@ -35,8 +36,8 @@ func CollectPatrolTagQueries(keywords []string, nodes []libkg.Node) []TagQuery {
|
||||||
RecencyDays: IdealMaxPostAgeDays,
|
RecencyDays: IdealMaxPostAgeDays,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if q30 := BuildRecencyQuery(tag, MaxPostAgeDays); q30 != "" {
|
if q30 := BuildRecencyQuery(provider, tag, MaxPostAgeDays); q30 != "" {
|
||||||
if q7 := BuildRecencyQuery(tag, IdealMaxPostAgeDays); q30 != q7 {
|
if q7 := BuildRecencyQuery(provider, tag, IdealMaxPostAgeDays); q30 != q7 {
|
||||||
out = append(out, TagQuery{
|
out = append(out, TagQuery{
|
||||||
Tag: tag,
|
Tag: tag,
|
||||||
Query: q30,
|
Query: q30,
|
||||||
|
|
@ -93,9 +94,9 @@ func patrolTagMatchKey(tag string) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResolveTagQueries prefers explicit patrol keywords over graph node selection.
|
// 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 {
|
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"
|
"testing"
|
||||||
|
|
||||||
libkg "haixun-backend/internal/library/knowledge"
|
libkg "haixun-backend/internal/library/knowledge"
|
||||||
|
"haixun-backend/internal/library/websearch"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestCollectPatrolTagQueriesManualOnly(t *testing.T) {
|
func TestCollectPatrolTagQueriesManualOnly(t *testing.T) {
|
||||||
queries := CollectPatrolTagQueries([]string{"化療 沐浴乳"}, nil)
|
queries := CollectPatrolTagQueries([]string{"化療 沐浴乳"}, nil, websearch.ProviderBrave)
|
||||||
if len(queries) < 2 {
|
if len(queries) < 2 {
|
||||||
t.Fatalf("expected relevance + recency queries, got %d", len(queries))
|
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 {
|
if len(queries) == 0 || queries[0].ProductFitScore != 92 {
|
||||||
t.Fatalf("expected graph fit 92, got %+v", queries)
|
t.Fatalf("expected graph fit 92, got %+v", queries)
|
||||||
}
|
}
|
||||||
|
|
@ -40,7 +41,7 @@ func TestResolveTagQueriesPrefersPatrolKeywords(t *testing.T) {
|
||||||
nodes := []libkg.Node{
|
nodes := []libkg.Node{
|
||||||
{ID: "n1", Label: "ignored", SelectedForScan: true, DerivedTags: libkg.DerivedTags{Relevance: []string{"ignored"}}},
|
{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 != "手動 關鍵字" {
|
if len(queries) == 0 || queries[0].Tag != "手動 關鍵字" {
|
||||||
t.Fatalf("expected patrol keyword query, got %+v", queries)
|
t.Fatalf("expected patrol keyword query, got %+v", queries)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
package placement
|
package placement
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"haixun-backend/internal/library/websearch"
|
||||||
)
|
)
|
||||||
|
|
||||||
type QueryDimension string
|
type QueryDimension string
|
||||||
|
|
@ -21,23 +24,36 @@ type TagQuery struct {
|
||||||
RecencyDays int // 0 = no after filter; 7 or 30 for recency track
|
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)
|
tag = strings.TrimSpace(tag)
|
||||||
if tag == "" {
|
if tag == "" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
if websearch.ParseProvider(string(provider)) == websearch.ProviderExa {
|
||||||
|
return fmt.Sprintf("Threads 貼文 繁體中文 %s", tag)
|
||||||
|
}
|
||||||
return `site:threads.net "` + 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)
|
tag = strings.TrimSpace(tag)
|
||||||
if tag == "" {
|
if tag == "" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
if websearch.ParseProvider(string(provider)) == websearch.ProviderExa {
|
||||||
|
return fmt.Sprintf("Threads 近期貼文 繁體中文 %s", tag)
|
||||||
|
}
|
||||||
after := FormatAfterDate(maxAgeDays, timeNow())
|
after := FormatAfterDate(maxAgeDays, timeNow())
|
||||||
return `site:threads.net "` + tag + `" 請問 after:` + after
|
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() }
|
var timeNow = func() time.Time { return time.Now() }
|
||||||
|
|
||||||
// SetTimeNowForTest overrides time source in tests.
|
// 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()
|
date := now.AddDate(0, 0, -maxAgeDays).UTC()
|
||||||
return date.Format("2006-01-02")
|
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.
|
// MemberNeedsWebSearchKey reports whether placement scan should require a web search API key.
|
||||||
func MemberNeedsBraveKey(ctx MemberContext) bool {
|
func MemberNeedsWebSearchKey(ctx MemberContext) bool {
|
||||||
if !ctx.AllowsBrave || ctx.DevMode {
|
if !ctx.AllowsBrave || ctx.DevMode {
|
||||||
return false
|
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.
|
// WithoutCrawler returns a mode that never uses Playwright, for formal API-only routing.
|
||||||
func WithoutCrawler(mode SearchSourceMode) SearchSourceMode {
|
func WithoutCrawler(mode SearchSourceMode) SearchSourceMode {
|
||||||
switch mode {
|
switch mode {
|
||||||
|
|
|
||||||
|
|
@ -26,16 +26,16 @@ func TestMemberNeedsBraveKey(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildMemberContextFormalModeNeverAllowsCrawler(t *testing.T) {
|
func TestBuildMemberContextFormalModeRespectsSearchSource(t *testing.T) {
|
||||||
prefs := ConnectionPrefsInput{
|
prefs := ConnectionPrefsInput{
|
||||||
DevMode: false,
|
DevMode: false,
|
||||||
SearchSourceMode: string(SearchSourceMixed),
|
SearchSourceMode: string(SearchSourceMixed),
|
||||||
}
|
}
|
||||||
ctx := BuildMemberContext("t", "u", "acc", prefs, true, false, ResearchSettings{}, false, 10)
|
ctx := BuildMemberContext("t", "u", "acc", prefs, true, false, ResearchSettings{}, false, 10)
|
||||||
if ctx.AllowsCrawler {
|
if !ctx.AllowsCrawler {
|
||||||
t.Fatal("formal mode must not allow crawler")
|
t.Fatal("mixed mode should allow crawler when browser is connected")
|
||||||
}
|
}
|
||||||
if ctx.SearchSourceMode != SearchSourceThreadsBrave {
|
if ctx.SearchSourceMode != SearchSourceMixed {
|
||||||
t.Fatalf("expected threads_brave, got %s", ctx.SearchSourceMode)
|
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
|
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.
|
// AIChatSystem composes the outgoing system prompt for console AI chat.
|
||||||
func AIChatSystem(clientSystem string) (string, error) {
|
func AIChatSystem(clientSystem string) (string, error) {
|
||||||
base := strings.TrimSpace(clientSystem)
|
base := strings.TrimSpace(clientSystem)
|
||||||
|
|
|
||||||
|
|
@ -44,18 +44,18 @@
|
||||||
|
|
||||||
## 兩條工作流(必讀,勿混淆)
|
## 兩條工作流(必讀,勿混淆)
|
||||||
|
|
||||||
| 流程 | 入口 | 目的 | 關鍵實體 |
|
| 工作流 | 入口 | 目的 | 關鍵實體 |
|
||||||
|------|------|------|----------|
|
|------|------|------|----------|
|
||||||
| **A 拷貝忍者** | `/matrix` | 海巡爆款、學對標風格、產**仿寫**草稿 | 人設 + 8D 對標帳號 |
|
| **拷貝忍者** | `/matrix` | 海巡爆款、學對標風格、產**仿寫**草稿 | 人設 + 8D 對標帳號 |
|
||||||
| **B 找 TA** | `/outreach`(子步驟:研究→找TA留言) | 找痛點、productFit、產**獲客留言** | 品牌 + 人設語氣 |
|
| **找 TA** | `/outreach`(子步驟:研究→找TA留言) | 找痛點、productFit、產**獲客留言** | 品牌 + 人設語氣 |
|
||||||
|
|
||||||
分流規則:
|
分流規則:
|
||||||
- 使用者在 `/matrix` 或問仿寫/爆款/對標 → **只談流程 A**,navigate 人設庫或拷貝忍者;**禁止** `expandKnowledgeGraph`、`startScan`、`generateOutreachReply`
|
- 使用者在 `/matrix` 或問仿寫/爆款/對標 → **只談拷貝忍者**,navigate 人設庫或拷貝忍者;**禁止** `expandKnowledgeGraph`、`startScan`、`generateOutreachReply`
|
||||||
- 使用者在 `/research`、`/outreach` 或問痛點/產品置入 → **只談流程 B**;**禁止**建議 8D 對標當主要解法
|
- 使用者在 `/research`、`/outreach` 或問痛點/產品置入 → **只談找 TA**;**禁止**建議 8D 對標當主要解法
|
||||||
- 原創矩陣屬於流程 A 的 `/matrix`,不要在找 TA/流程 B 裡推薦或顯示 `/brand-matrix`
|
- 原創矩陣屬於拷貝忍者的 `/matrix`,不要在找 TA 裡推薦或顯示 `/brand-matrix`
|
||||||
- 「海巡來源模式」(search_source_mode)是 API/爬蟲管道,**不是** A/B 流程
|
- 「海巡來源模式」(search_source_mode)是 API/爬蟲管道,**不是**拷貝忍者/找 TA 的區分
|
||||||
|
|
||||||
## 流程 A — 拷貝忍者
|
## 拷貝忍者
|
||||||
|
|
||||||
- 入口:`/matrix`(仿寫草稿庫 + 爆款海巡)
|
- 入口:`/matrix`(仿寫草稿庫 + 爆款海巡)
|
||||||
- 對標與 8D:人設詳情 `/personas/:id#style-8d`
|
- 對標與 8D:人設詳情 `/personas/:id#style-8d`
|
||||||
|
|
@ -74,7 +74,7 @@
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
## 流程 B — 海巡獲客(研究頁 / 獲客台)
|
## 找 TA(研究頁 / 獲客台)
|
||||||
|
|
||||||
### 海巡研究頁(`/research`)
|
### 海巡研究頁(`/research`)
|
||||||
- 擴展圖譜:`expandKnowledgeGraph`(`seed_query` 可省略=用頁面種子詞;`supplemental=true` 補充痛點)
|
- 擴展圖譜:`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"
|
fileOutreachPlacementUser = "files/outreach_placement.user.md"
|
||||||
fileMatrixPlacementSystem = "files/matrix_placement.system.md"
|
fileMatrixPlacementSystem = "files/matrix_placement.system.md"
|
||||||
fileMatrixPlacementUser = "files/matrix_placement.user.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.
|
// Keys identify prompt slots loaded from internal/library/prompt/files/*.md.
|
||||||
|
|
@ -43,6 +45,8 @@ const (
|
||||||
KeyOutreachPlacementUser = "outreach_placement.user"
|
KeyOutreachPlacementUser = "outreach_placement.user"
|
||||||
KeyMatrixPlacementSystem = "matrix_placement.system"
|
KeyMatrixPlacementSystem = "matrix_placement.system"
|
||||||
KeyMatrixPlacementUser = "matrix_placement.user"
|
KeyMatrixPlacementUser = "matrix_placement.user"
|
||||||
|
KeyMatrixCopySystem = "matrix_copy.system"
|
||||||
|
KeyMatrixCopyUser = "matrix_copy.user"
|
||||||
)
|
)
|
||||||
|
|
||||||
var slotFiles = map[string]string{
|
var slotFiles = map[string]string{
|
||||||
|
|
@ -59,6 +63,8 @@ var slotFiles = map[string]string{
|
||||||
KeyOutreachPlacementUser: fileOutreachPlacementUser,
|
KeyOutreachPlacementUser: fileOutreachPlacementUser,
|
||||||
KeyMatrixPlacementSystem: fileMatrixPlacementSystem,
|
KeyMatrixPlacementSystem: fileMatrixPlacementSystem,
|
||||||
KeyMatrixPlacementUser: fileMatrixPlacementUser,
|
KeyMatrixPlacementUser: fileMatrixPlacementUser,
|
||||||
|
KeyMatrixCopySystem: fileMatrixCopySystem,
|
||||||
|
KeyMatrixCopyUser: fileMatrixCopyUser,
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
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"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
|
||||||
|
|
||||||
const maxPublishChars = 500
|
"haixun-backend/internal/library/threadspost"
|
||||||
|
)
|
||||||
|
|
||||||
type PublishResult struct {
|
type PublishResult struct {
|
||||||
MediaID string
|
MediaID string
|
||||||
Permalink string
|
Permalink string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PublishTextInput struct {
|
||||||
|
ThreadsUserID string
|
||||||
|
AccessToken string
|
||||||
|
Text string
|
||||||
|
}
|
||||||
|
|
||||||
type PublishReplyInput struct {
|
type PublishReplyInput struct {
|
||||||
ThreadsUserID string
|
ThreadsUserID string
|
||||||
AccessToken string
|
AccessToken string
|
||||||
|
|
@ -25,6 +31,36 @@ type PublishReplyInput struct {
|
||||||
Text string
|
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.
|
// PublishReply posts a text reply to an existing Threads media via Graph API.
|
||||||
func PublishReply(ctx context.Context, in PublishReplyInput) (*PublishResult, error) {
|
func PublishReply(ctx context.Context, in PublishReplyInput) (*PublishResult, error) {
|
||||||
userID := strings.TrimSpace(in.ThreadsUserID)
|
userID := strings.TrimSpace(in.ThreadsUserID)
|
||||||
|
|
@ -40,8 +76,8 @@ func PublishReply(ctx context.Context, in PublishReplyInput) (*PublishResult, er
|
||||||
if text == "" {
|
if text == "" {
|
||||||
return nil, fmt.Errorf("reply text is required")
|
return nil, fmt.Errorf("reply text is required")
|
||||||
}
|
}
|
||||||
if len([]rune(text)) > maxPublishChars {
|
if err := threadspost.ValidateReply(text); err != nil {
|
||||||
return nil, fmt.Errorf("reply exceeds %d characters", maxPublishChars)
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
containerID, err := createReplyContainer(ctx, userID, token, replyTo, text)
|
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
|
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) {
|
func createReplyContainer(ctx context.Context, userID, token, replyTo, text string) (string, error) {
|
||||||
params := url.Values{}
|
params := url.Values{}
|
||||||
params.Set("access_token", token)
|
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 (
|
const (
|
||||||
defaultLimitPerKeyword = 15
|
defaultLimitPerKeyword = 15
|
||||||
|
missionLimitPerKeyword = 10
|
||||||
maxKeywords = 6
|
maxKeywords = 6
|
||||||
maxMergedPosts = 60
|
maxMergedPosts = 60
|
||||||
|
missionMaxMergedPosts = 40
|
||||||
|
missionQualityTarget = 12 // stop scanning extra keywords once enough quality posts
|
||||||
)
|
)
|
||||||
|
|
||||||
type DiscoverInput struct {
|
type DiscoverInput struct {
|
||||||
Keywords []string
|
Keywords []string
|
||||||
Exclusions []string
|
Exclusions []string
|
||||||
Member placement.MemberContext
|
Member placement.MemberContext
|
||||||
Crawler placement.CrawlerSearchFn
|
Crawler placement.CrawlerSearchFn
|
||||||
Limit int // per keyword; 0 = default
|
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)
|
type ProgressFn func(message string, pct int)
|
||||||
|
|
@ -32,26 +37,58 @@ func RunDiscover(ctx context.Context, input DiscoverInput, progress ProgressFn)
|
||||||
}
|
}
|
||||||
perKeyword := input.Limit
|
perKeyword := input.Limit
|
||||||
if perKeyword <= 0 {
|
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{}
|
merged := map[string]placement.ScanCandidate{}
|
||||||
|
relaxed := map[string]placement.ScanCandidate{}
|
||||||
total := len(keywords)
|
total := len(keywords)
|
||||||
|
pathLabel := input.Member.DiscoverPathLabel()
|
||||||
|
var lastErr error
|
||||||
|
keywordsAttempted := 0
|
||||||
|
|
||||||
for i, keyword := range keywords {
|
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 {
|
if progress != nil {
|
||||||
pct := 10 + (i*70)/total
|
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{
|
posts, _, err := placement.Discover(ctx, placement.DiscoverRequest{
|
||||||
Query: keyword,
|
Query: keyword,
|
||||||
Keyword: keyword,
|
Keyword: keyword,
|
||||||
Limit: perKeyword,
|
Limit: limit,
|
||||||
Member: input.Member,
|
Member: input.Member,
|
||||||
Crawler: input.Crawler,
|
Crawler: input.Crawler,
|
||||||
})
|
})
|
||||||
if err != nil {
|
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 {
|
for _, post := range posts {
|
||||||
key := strings.TrimSpace(post.Permalink)
|
key := strings.TrimSpace(post.Permalink)
|
||||||
if key == "" {
|
if key == "" {
|
||||||
|
|
@ -61,13 +98,12 @@ func RunDiscover(ctx context.Context, input DiscoverInput, progress ProgressFn)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
score := ScorePost(post.LikeCount, post.ReplyCount)
|
score := ScorePost(post.LikeCount, post.ReplyCount)
|
||||||
if !PassesViralCandidate(post.Text, post.LikeCount, post.ReplyCount, score, input.Exclusions) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
candidate := placement.ScanCandidate{
|
candidate := placement.ScanCandidate{
|
||||||
Permalink: post.Permalink,
|
Permalink: post.Permalink,
|
||||||
ExternalID: post.ExternalID,
|
ExternalID: post.ExternalID,
|
||||||
Author: post.Author,
|
Author: post.Author,
|
||||||
|
AuthorVerified: post.AuthorVerified,
|
||||||
|
FollowerCount: post.FollowerCount,
|
||||||
Text: post.Text,
|
Text: post.Text,
|
||||||
SearchTag: keyword,
|
SearchTag: keyword,
|
||||||
Source: post.Source,
|
Source: post.Source,
|
||||||
|
|
@ -77,19 +113,42 @@ func RunDiscover(ctx context.Context, input DiscoverInput, progress ProgressFn)
|
||||||
PlacementScore: score,
|
PlacementScore: score,
|
||||||
Priority: PriorityLabel(score),
|
Priority: PriorityLabel(score),
|
||||||
}
|
}
|
||||||
if prev, ok := merged[key]; !ok || candidate.EngagementScore > prev.EngagementScore {
|
if input.MissionScan {
|
||||||
merged[key] = candidate
|
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))
|
if input.MissionScan && len(merged) == 0 && len(relaxed) > 0 {
|
||||||
for _, item := range merged {
|
merged = relaxed
|
||||||
out = append(out, item)
|
if progress != nil {
|
||||||
|
progress("未取得藍勾等延伸資料,改以互動門檻收斂爆款候選", 82)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
out := candidatesFromMap(merged)
|
||||||
sortByEngagement(out)
|
sortByEngagement(out)
|
||||||
if len(out) > maxMergedPosts {
|
if len(out) > maxMerged {
|
||||||
out = out[:maxMergedPosts]
|
out = out[:maxMerged]
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
if keywordsAttempted == 0 && lastErr != nil {
|
||||||
|
return nil, fmt.Errorf("所有標籤搜尋均失敗:%w", lastErr)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if progress != nil {
|
if progress != nil {
|
||||||
progress(fmt.Sprintf("合併 %d 篇爆款候選", len(out)), 85)
|
progress(fmt.Sprintf("合併 %d 篇爆款候選", len(out)), 85)
|
||||||
|
|
@ -97,11 +156,37 @@ func RunDiscover(ctx context.Context, input DiscoverInput, progress ProgressFn)
|
||||||
return out, nil
|
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 {
|
func normalizeKeywords(raw []string) []string {
|
||||||
seen := map[string]struct{}{}
|
seen := map[string]struct{}{}
|
||||||
out := make([]string, 0, len(raw))
|
out := make([]string, 0, len(raw))
|
||||||
for _, item := range raw {
|
for _, item := range raw {
|
||||||
kw := strings.TrimSpace(item)
|
kw := DiscoverKeywordFromTag(item)
|
||||||
if kw == "" {
|
if kw == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
@ -117,6 +202,26 @@ func normalizeKeywords(raw []string) []string {
|
||||||
return out
|
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) {
|
func sortByEngagement(items []placement.ScanCandidate) {
|
||||||
for i := 0; i < len(items); i++ {
|
for i := 0; i < len(items); i++ {
|
||||||
for j := i + 1; j < len(items); j++ {
|
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