2026-06-24 10:02:42 +00:00
|
|
|
|
package viral
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"encoding/json"
|
|
|
|
|
|
"fmt"
|
|
|
|
|
|
"regexp"
|
|
|
|
|
|
"strings"
|
|
|
|
|
|
|
2026-06-25 08:20:03 +00:00
|
|
|
|
"haixun-backend/internal/library/threadspost"
|
|
|
|
|
|
)
|
2026-06-24 10:02:42 +00:00
|
|
|
|
|
|
|
|
|
|
var codeFenceRE = regexp.MustCompile("(?s)```(?:json)?\\s*([\\s\\S]*?)```")
|
|
|
|
|
|
|
|
|
|
|
|
type ReplicateInput struct {
|
2026-06-25 08:20:03 +00:00
|
|
|
|
TopicLabel string
|
|
|
|
|
|
TopicBrief string
|
|
|
|
|
|
Persona string
|
|
|
|
|
|
StyleProfile string
|
|
|
|
|
|
OriginalText string
|
|
|
|
|
|
AuthorName string
|
|
|
|
|
|
StructureAnalysis string
|
2026-06-24 10:02:42 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type ReplicateResult struct {
|
|
|
|
|
|
Angle string `json:"angle"`
|
|
|
|
|
|
Hook string `json:"hook"`
|
|
|
|
|
|
Text string `json:"text"`
|
|
|
|
|
|
Rationale string `json:"rationale"`
|
|
|
|
|
|
StructureNotes string `json:"structureNotes"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func BuildSystemPrompt() string {
|
|
|
|
|
|
return strings.TrimSpace(`你是 Threads 爆款複製策略師。根據參考爆文,為使用者撰寫「同結構、同節奏、但完全原創」的貼文。
|
|
|
|
|
|
|
|
|
|
|
|
規則:
|
|
|
|
|
|
- 複製的是爆款公式(hook 手法、情緒節奏),不是抄襲原文
|
|
|
|
|
|
- 文筆必須像創作者本人,套用其 8D 風格策略
|
2026-06-25 08:20:03 +00:00
|
|
|
|
- text 主文 ≤ 500 字(Threads API 硬上限,含 #話題標籤)
|
|
|
|
|
|
- 爆款互動最佳 80~220 字:前 1~2 行強 hook,口語精簡;超過 300 字互動通常下降
|
2026-06-24 10:02:42 +00:00
|
|
|
|
- 只回傳一個 JSON 物件,欄位:angle, hook, text, rationale, structureNotes`)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func BuildUserPrompt(input ReplicateInput) string {
|
|
|
|
|
|
var b strings.Builder
|
|
|
|
|
|
b.WriteString("主題:")
|
|
|
|
|
|
b.WriteString(strings.TrimSpace(input.TopicLabel))
|
|
|
|
|
|
b.WriteString("\n")
|
|
|
|
|
|
if brief := strings.TrimSpace(input.TopicBrief); brief != "" {
|
|
|
|
|
|
b.WriteString("Brief:")
|
|
|
|
|
|
b.WriteString(brief)
|
|
|
|
|
|
b.WriteString("\n")
|
|
|
|
|
|
}
|
|
|
|
|
|
if persona := strings.TrimSpace(input.Persona); persona != "" {
|
|
|
|
|
|
b.WriteString("\n人設與語氣:\n")
|
|
|
|
|
|
b.WriteString(persona)
|
|
|
|
|
|
b.WriteString("\n")
|
|
|
|
|
|
}
|
|
|
|
|
|
if style := strings.TrimSpace(input.StyleProfile); style != "" {
|
|
|
|
|
|
b.WriteString("\n8D 風格策略:\n")
|
|
|
|
|
|
b.WriteString(style)
|
|
|
|
|
|
b.WriteString("\n")
|
|
|
|
|
|
}
|
|
|
|
|
|
author := strings.TrimSpace(input.AuthorName)
|
|
|
|
|
|
if author == "" {
|
|
|
|
|
|
author = "匿名"
|
|
|
|
|
|
}
|
2026-06-25 08:20:03 +00:00
|
|
|
|
if analysis := strings.TrimSpace(input.StructureAnalysis); analysis != "" {
|
|
|
|
|
|
b.WriteString("\n爆款結構分析(仿寫時套用):\n")
|
|
|
|
|
|
b.WriteString(analysis)
|
|
|
|
|
|
b.WriteString("\n")
|
|
|
|
|
|
}
|
2026-06-24 10:02:42 +00:00
|
|
|
|
b.WriteString("\n原文參考(@")
|
|
|
|
|
|
b.WriteString(author)
|
|
|
|
|
|
b.WriteString(",只學結構不抄內容):\n")
|
|
|
|
|
|
b.WriteString(strings.TrimSpace(input.OriginalText))
|
|
|
|
|
|
b.WriteString("\n\n請產出一篇可發布的複製版貼文 JSON。")
|
|
|
|
|
|
return b.String()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func ParseReplicateOutput(raw string) (ReplicateResult, error) {
|
|
|
|
|
|
payload, err := extractJSONObject(raw)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return ReplicateResult{}, err
|
|
|
|
|
|
}
|
|
|
|
|
|
var out ReplicateResult
|
|
|
|
|
|
if err := json.Unmarshal(payload, &out); err != nil {
|
|
|
|
|
|
return ReplicateResult{}, fmt.Errorf("parse viral replica json: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
out.Text = trimText(out.Text)
|
|
|
|
|
|
if out.Text == "" {
|
|
|
|
|
|
return ReplicateResult{}, fmt.Errorf("replica text missing")
|
|
|
|
|
|
}
|
|
|
|
|
|
out.Angle = strings.TrimSpace(out.Angle)
|
|
|
|
|
|
out.Hook = strings.TrimSpace(out.Hook)
|
|
|
|
|
|
out.Rationale = strings.TrimSpace(out.Rationale)
|
|
|
|
|
|
out.StructureNotes = strings.TrimSpace(out.StructureNotes)
|
|
|
|
|
|
return out, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func trimText(text string) string {
|
|
|
|
|
|
text = strings.TrimSpace(text)
|
|
|
|
|
|
runes := []rune(text)
|
2026-06-25 08:20:03 +00:00
|
|
|
|
if len(runes) > threadspost.MaxPublishRunes {
|
|
|
|
|
|
return string(runes[:threadspost.MaxPublishRunes])
|
2026-06-24 10:02:42 +00:00
|
|
|
|
}
|
|
|
|
|
|
return text
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func extractJSONObject(raw string) ([]byte, error) {
|
|
|
|
|
|
raw = strings.TrimSpace(raw)
|
|
|
|
|
|
if m := codeFenceRE.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("replica output missing json object")
|
|
|
|
|
|
}
|
|
|
|
|
|
return []byte(raw[start : end+1]), nil
|
|
|
|
|
|
}
|