2026-06-24 10:02:42 +00:00
|
|
|
|
package viral
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"encoding/json"
|
|
|
|
|
|
"fmt"
|
|
|
|
|
|
"regexp"
|
|
|
|
|
|
"strings"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
type CopyResearchMap struct {
|
|
|
|
|
|
AudienceSummary string `json:"audienceSummary"`
|
|
|
|
|
|
ContentGoal string `json:"contentGoal"`
|
|
|
|
|
|
Questions []string `json:"questions"`
|
|
|
|
|
|
Pillars []string `json:"pillars"`
|
|
|
|
|
|
Exclusions []string `json:"exclusions"`
|
|
|
|
|
|
SuggestedTags []string `json:"suggestedTags"`
|
|
|
|
|
|
BenchmarkNotes string `json:"benchmarkNotes"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type CopyResearchMapInput struct {
|
2026-06-25 08:20:03 +00:00
|
|
|
|
Label string
|
|
|
|
|
|
SeedQuery string
|
|
|
|
|
|
Brief string
|
|
|
|
|
|
Persona string
|
|
|
|
|
|
StyleBenchmark string
|
|
|
|
|
|
PersonaAudienceSummary string
|
|
|
|
|
|
PersonaContentGoal string
|
|
|
|
|
|
PersonaQuestions []string
|
|
|
|
|
|
PersonaPillars []string
|
2026-06-24 10:02:42 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var copyMapFenceRE = regexp.MustCompile("(?s)```(?:json)?\\s*([\\s\\S]*?)```")
|
|
|
|
|
|
|
|
|
|
|
|
func BuildCopyResearchMapSystemPrompt() string {
|
|
|
|
|
|
return strings.TrimSpace(`你是 Threads 爆款/對標研究顧問。目標是幫創作者找到「高互動、值得仿寫」的參考貼文與對標方向。
|
|
|
|
|
|
|
|
|
|
|
|
規則:
|
2026-06-25 08:20:03 +00:00
|
|
|
|
1. audienceSummary:必填,2~4 句描述「受眾是誰」(情境、痛點、會搜什麼)
|
|
|
|
|
|
2. contentGoal 要寫:找到近期互動佳、結構可模仿的爆款貼文,分析 hook/節奏/文案公式
|
|
|
|
|
|
3. pillars:可模仿的內容方向(語錄型、故事型、清單型等),至少 4 個
|
|
|
|
|
|
4. questions:受眾會搜尋/關心的短問題,5+ 個,適合當爆款掃描關鍵字
|
|
|
|
|
|
5. exclusions:不要模仿的內容(業配、純晒照、無結構閒聊等),至少 4 個
|
|
|
|
|
|
6. suggestedTags:2~4 字短詞,10 個左右,用於 Threads 搜尋爆款
|
|
|
|
|
|
7. benchmarkNotes:一句話說明怎樣算「值得仿的爆款」(互動、留言品質、hook 清楚)
|
|
|
|
|
|
8. 繁體中文;只回傳 JSON:audienceSummary, contentGoal, questions, pillars, exclusions, suggestedTags, benchmarkNotes`)
|
2026-06-24 10:02:42 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func BuildCopyResearchMapUserPrompt(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【Brief】\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")
|
|
|
|
|
|
}
|
|
|
|
|
|
b.WriteString("\n請產出拷貝忍者研究地圖 JSON。")
|
|
|
|
|
|
return b.String()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func ParseCopyResearchMapOutput(raw string) (CopyResearchMap, error) {
|
|
|
|
|
|
payload, err := extractCopyMapJSON(raw)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return CopyResearchMap{}, err
|
|
|
|
|
|
}
|
2026-06-25 08:20:03 +00:00
|
|
|
|
var root map[string]json.RawMessage
|
|
|
|
|
|
if err := json.Unmarshal(payload, &root); err != nil {
|
2026-06-24 10:02:42 +00:00
|
|
|
|
return CopyResearchMap{}, fmt.Errorf("parse copy research map: %w", err)
|
|
|
|
|
|
}
|
2026-06-25 08:20:03 +00:00
|
|
|
|
tagObjs := parseFlexibleSuggestedTags(pickRawMessage(root, "suggestedTags", "suggested_tags"))
|
|
|
|
|
|
tagStrs := make([]string, 0, len(tagObjs))
|
|
|
|
|
|
for _, item := range tagObjs {
|
|
|
|
|
|
if item.Tag != "" {
|
|
|
|
|
|
tagStrs = append(tagStrs, item.Tag)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
out := CopyResearchMap{
|
|
|
|
|
|
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: cleanLines(tagStrs),
|
|
|
|
|
|
}
|
|
|
|
|
|
if strings.TrimSpace(out.AudienceSummary) == "" {
|
|
|
|
|
|
return CopyResearchMap{}, fmt.Errorf("copy research map missing audienceSummary")
|
2026-06-24 10:02:42 +00:00
|
|
|
|
}
|
|
|
|
|
|
return out, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func ToEntityResearchMap(m CopyResearchMap) map[string]any {
|
|
|
|
|
|
return map[string]any{
|
|
|
|
|
|
"audience_summary": m.AudienceSummary,
|
|
|
|
|
|
"content_goal": m.ContentGoal,
|
|
|
|
|
|
"questions": m.Questions,
|
|
|
|
|
|
"pillars": m.Pillars,
|
|
|
|
|
|
"exclusions": m.Exclusions,
|
|
|
|
|
|
"suggested_tags": m.SuggestedTags,
|
|
|
|
|
|
"benchmark_notes": m.BenchmarkNotes,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func cleanLines(items []string) []string {
|
|
|
|
|
|
out := make([]string, 0, len(items))
|
|
|
|
|
|
seen := map[string]struct{}{}
|
|
|
|
|
|
for _, item := range items {
|
|
|
|
|
|
item = strings.TrimSpace(item)
|
|
|
|
|
|
if item == "" {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
if _, ok := seen[item]; ok {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
seen[item] = struct{}{}
|
|
|
|
|
|
out = append(out, item)
|
|
|
|
|
|
}
|
|
|
|
|
|
return out
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func extractCopyMapJSON(raw string) ([]byte, error) {
|
|
|
|
|
|
raw = strings.TrimSpace(raw)
|
|
|
|
|
|
if m := copyMapFenceRE.FindStringSubmatch(raw); len(m) == 2 {
|
|
|
|
|
|
raw = strings.TrimSpace(m[1])
|
|
|
|
|
|
}
|
|
|
|
|
|
start := strings.Index(raw, "{")
|
|
|
|
|
|
end := strings.LastIndex(raw, "}")
|
|
|
|
|
|
if start < 0 || end <= start {
|
|
|
|
|
|
return nil, fmt.Errorf("copy research map missing json")
|
|
|
|
|
|
}
|
|
|
|
|
|
return []byte(raw[start : end+1]), nil
|
|
|
|
|
|
}
|