Pipeline completo de publicação no LinkedIn: evaluator → redator → editor → art → director → publisher - Seed com 37 posts em _sugestoes.md - Sorteio de formato com N=3 bloqueados (format-history) - Reciclagem mensal de posts com rotação de formato - Revisão via Telegram com chat livre (Gemini 2.5 Flash) - Publicação via LinkedIn API (OAuth2) - Makefile com targets para Windows/Linux/ARM64 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
895 lines
28 KiB
Go
895 lines
28 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/spf13/cobra"
|
|
"ldpost/shared/config"
|
|
"ldpost/shared/formats"
|
|
"ldpost/shared/groq"
|
|
"ldpost/shared/history"
|
|
"ldpost/shared/state"
|
|
"ldpost/shared/telegram"
|
|
"ldpost/shared/workspace"
|
|
)
|
|
|
|
// ─── Domain types ─────────────────────────────────────────────────────────────
|
|
|
|
type Trend struct {
|
|
Tema string `json:"tema"`
|
|
Descricao string `json:"descricao"`
|
|
Mencoes int `json:"mencoes"`
|
|
Momentum string `json:"momentum"` // crescendo|estavel|caindo
|
|
RelevanciaProfile string `json:"relevancia_perfil"` // alta|media|baixa
|
|
CategoriaSugerida string `json:"categoria_sugerida"` // Codigo|Entregavel|Bastidor|Geral
|
|
}
|
|
|
|
type TrendResponse struct {
|
|
Trends []Trend `json:"trends"`
|
|
}
|
|
|
|
type SugestaoPost struct {
|
|
Slug string
|
|
Tema string
|
|
Categoria string
|
|
Formato string
|
|
Funil string
|
|
Resumo string
|
|
Imagem1 string
|
|
Imagem2 string
|
|
// Reciclagem
|
|
IsRecycled bool
|
|
BaseSlug string // slug original do seed
|
|
PreviousFormato string // formato da última publicação
|
|
DaysSincePublished int
|
|
}
|
|
|
|
// ─── Formato rotation ─────────────────────────────────────────────────────────
|
|
|
|
// formatoCycle defines the fixed rotation order for recycled posts.
|
|
var formatoCycle = []string{"como", "porque", "erro", "comparacao", "checklist", "bastidor"}
|
|
|
|
// nextFormato returns the next format in the rotation after current.
|
|
func nextFormato(current string) string {
|
|
for i, f := range formatoCycle {
|
|
if f == current {
|
|
return formatoCycle[(i+1)%len(formatoCycle)]
|
|
}
|
|
}
|
|
return formatoCycle[0]
|
|
}
|
|
|
|
// recycleSlug builds the new slug for a recycled post: baseSlug--newFormato.
|
|
// Strips any existing --format suffix before appending.
|
|
func recycleSlug(baseSlug, newFormato string) string {
|
|
base := baseSlug
|
|
if idx := strings.Index(baseSlug, "--"); idx >= 0 {
|
|
base = baseSlug[:idx]
|
|
}
|
|
return base + "--" + newFormato
|
|
}
|
|
|
|
type PendingCallback struct {
|
|
Trends []Trend `json:"trends"`
|
|
NextPost *SugestaoPost `json:"next_post"`
|
|
FormatoSorteado string `json:"formato_sorteado"`
|
|
FormatosBloqueados []string `json:"formatos_bloqueados"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
// ─── Trend fetching ───────────────────────────────────────────────────────────
|
|
|
|
type hnItem struct {
|
|
Title string `json:"title"`
|
|
Points int `json:"points"`
|
|
URL string `json:"url"`
|
|
CreatedAt string `json:"created_at"`
|
|
}
|
|
|
|
func fetchHN() ([]string, error) {
|
|
sevenDaysAgo := time.Now().AddDate(0, 0, -7).Unix()
|
|
url := fmt.Sprintf(
|
|
"https://hn.algolia.com/api/v1/search?query=AI+LLM+agent&tags=story&numericFilters=points>100,created_at_i>%d&hitsPerPage=10",
|
|
sevenDaysAgo,
|
|
)
|
|
resp, err := http.Get(url)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("HN fetch: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
body, _ := io.ReadAll(resp.Body)
|
|
|
|
var result struct {
|
|
Hits []hnItem `json:"hits"`
|
|
}
|
|
if err := json.Unmarshal(body, &result); err != nil {
|
|
return nil, fmt.Errorf("HN parse: %w", err)
|
|
}
|
|
|
|
var titles []string
|
|
for _, h := range result.Hits {
|
|
titles = append(titles, fmt.Sprintf("[HN %d pts] %s", h.Points, h.Title))
|
|
}
|
|
return titles, nil
|
|
}
|
|
|
|
func fetchReddit(subreddit string) ([]string, error) {
|
|
url := fmt.Sprintf("https://www.reddit.com/r/%s/top.json?t=week&limit=10", subreddit)
|
|
req, _ := http.NewRequest("GET", url, nil)
|
|
req.Header.Set("User-Agent", "ldpost-evaluator/1.0")
|
|
|
|
client := &http.Client{Timeout: 10 * time.Second}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("Reddit %s fetch: %w", subreddit, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode == 429 {
|
|
return nil, fmt.Errorf("rate_limited")
|
|
}
|
|
if resp.StatusCode != 200 {
|
|
return nil, fmt.Errorf("Reddit %s status %d", subreddit, resp.StatusCode)
|
|
}
|
|
|
|
body, _ := io.ReadAll(resp.Body)
|
|
var result struct {
|
|
Data struct {
|
|
Children []struct {
|
|
Data struct {
|
|
Title string `json:"title"`
|
|
Score int `json:"score"`
|
|
CreatedUTC float64 `json:"created_utc"`
|
|
} `json:"data"`
|
|
} `json:"children"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(body, &result); err != nil {
|
|
return nil, fmt.Errorf("Reddit %s parse: %w", subreddit, err)
|
|
}
|
|
|
|
var titles []string
|
|
for _, c := range result.Data.Children {
|
|
titles = append(titles, fmt.Sprintf("[Reddit r/%s %d pts] %s", subreddit, c.Data.Score, c.Data.Title))
|
|
}
|
|
return titles, nil
|
|
}
|
|
|
|
// ─── Consolidation via LLM ────────────────────────────────────────────────────
|
|
|
|
const systemPromptTrends = `Você é um analista de tendências de tecnologia especializado em IA, desenvolvimento de software e arquitetura de sistemas. Seu trabalho é identificar os 3 temas mais relevantes para um Tech Lead sênior brasileiro (22+ anos de experiência em C#/.NET, especialista em Clean Architecture, IA local, agentes, SAP BTP).
|
|
|
|
Retorne APENAS um JSON válido, sem markdown, sem explicação, no formato:
|
|
{
|
|
"trends": [
|
|
{
|
|
"tema": "string curta (máx 8 palavras)",
|
|
"descricao": "string de 1 frase explicando o trend",
|
|
"mencoes": número estimado de posts/threads sobre o tema,
|
|
"momentum": "crescendo|estavel|caindo",
|
|
"relevancia_perfil": "alta|media|baixa",
|
|
"categoria_sugerida": "Codigo|Entregavel|Bastidor|Geral"
|
|
}
|
|
]
|
|
}
|
|
|
|
Retorne exatamente 3 trends, priorizando os de relevancia_perfil=alta.`
|
|
|
|
func consolidateTrends(gc *groq.GroqClient, titles []string) ([]Trend, error) {
|
|
user := strings.Join(titles, "\n---\n")
|
|
var resp TrendResponse
|
|
msgs := groq.TextMessages(systemPromptTrends, user)
|
|
if err := gc.ChatJSON(groq.TextModel, msgs, 0.3, &resp); err != nil {
|
|
return nil, err
|
|
}
|
|
if len(resp.Trends) == 0 {
|
|
return nil, fmt.Errorf("LLM retornou trends vazio")
|
|
}
|
|
return resp.Trends, nil
|
|
}
|
|
|
|
// Fallback: se Groq falhar, criar trends simples a partir dos títulos brutos
|
|
func buildFallbackTrends(titles []string) []Trend {
|
|
var trends []Trend
|
|
for i, t := range titles {
|
|
if i >= 3 {
|
|
break
|
|
}
|
|
trends = append(trends, Trend{
|
|
Tema: truncate(t, 60),
|
|
Descricao: "Tema identificado nas fontes (sem consolidação LLM)",
|
|
Mencoes: 1,
|
|
Momentum: "estavel",
|
|
RelevanciaProfile: "media",
|
|
CategoriaSugerida: "Entregavel",
|
|
})
|
|
}
|
|
return trends
|
|
}
|
|
|
|
// ─── _sugestoes.md parser ─────────────────────────────────────────────────────
|
|
|
|
func parseSugestoes(path string) ([]SugestaoPost, error) {
|
|
f, err := os.Open(path)
|
|
if os.IsNotExist(err) {
|
|
return nil, fmt.Errorf("_sugestoes.md não encontrado em %s\n→ Crie o arquivo com o seed de 30 posts", path)
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer f.Close()
|
|
|
|
var posts []SugestaoPost
|
|
var cur *SugestaoPost
|
|
scanner := bufio.NewScanner(f)
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
if strings.HasPrefix(line, "## ") {
|
|
if cur != nil {
|
|
posts = append(posts, *cur)
|
|
}
|
|
rawSlug := strings.TrimSpace(strings.TrimPrefix(line, "## "))
|
|
cur = &SugestaoPost{Slug: workspace.SlugFromTitle(rawSlug)}
|
|
} else if cur != nil {
|
|
k, v := parseKV(line)
|
|
switch k {
|
|
case "Categoria":
|
|
cur.Categoria = v
|
|
case "Tema":
|
|
cur.Tema = v
|
|
case "Formato":
|
|
cur.Formato = v
|
|
case "Funil":
|
|
cur.Funil = v
|
|
case "Resumo":
|
|
cur.Resumo = v
|
|
case "Imagem 1", "Imagem1":
|
|
cur.Imagem1 = v
|
|
case "Imagem 2", "Imagem2":
|
|
cur.Imagem2 = v
|
|
}
|
|
}
|
|
}
|
|
if cur != nil {
|
|
posts = append(posts, *cur)
|
|
}
|
|
return posts, scanner.Err()
|
|
}
|
|
|
|
func parseKV(line string) (string, string) {
|
|
line = strings.TrimSpace(line)
|
|
line = strings.TrimPrefix(line, "**")
|
|
parts := strings.SplitN(line, ":**", 2)
|
|
if len(parts) != 2 {
|
|
parts = strings.SplitN(line, ": ", 2)
|
|
if len(parts) != 2 {
|
|
return "", ""
|
|
}
|
|
}
|
|
return strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1])
|
|
}
|
|
|
|
// findNextInQueue scans the seed queue and returns the next eligible post.
|
|
//
|
|
// Priority order for each slug:
|
|
// 1. Never created (no work/ folder) → return as new post.
|
|
// 2. In pipeline (exists, not published, not rejected) → skip (don't interrupt).
|
|
// 3. Published < 30 days ago → skip (too soon).
|
|
// 4. Published >= 30 days ago → return recycled with next format in rotation.
|
|
// 5. Rejected → skip entirely.
|
|
func findNextInQueue(ws string, posts []SugestaoPost) *SugestaoPost {
|
|
const recycleDays = 30
|
|
|
|
for i := range posts {
|
|
p := &posts[i]
|
|
if p.Slug == "" {
|
|
continue
|
|
}
|
|
|
|
variants := workspace.FindVariantsByBase(ws, p.Slug)
|
|
if len(variants) == 0 {
|
|
// Never created → new post
|
|
return p
|
|
}
|
|
|
|
// Find the most recently published variant
|
|
var latestFormato string
|
|
var latestPublishedAt time.Time
|
|
hasPublished := false
|
|
|
|
for _, vPath := range variants {
|
|
s, err := state.LoadState(vPath)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if s.IsStatus(state.StatusPublished) && s.PublishedAt != nil {
|
|
if !hasPublished || s.PublishedAt.After(latestPublishedAt) {
|
|
latestPublishedAt = *s.PublishedAt
|
|
latestFormato = s.Formato
|
|
hasPublished = true
|
|
}
|
|
}
|
|
}
|
|
|
|
if !hasPublished {
|
|
// Exists but not yet published → in pipeline or rejected → skip
|
|
continue
|
|
}
|
|
|
|
days := int(time.Since(latestPublishedAt).Hours() / 24)
|
|
if days < recycleDays {
|
|
// Published too recently
|
|
continue
|
|
}
|
|
|
|
// Ready for recycle: advance to next format
|
|
newFmt := nextFormato(latestFormato)
|
|
newSlug := recycleSlug(p.Slug, newFmt)
|
|
|
|
recycled := *p
|
|
recycled.Slug = newSlug
|
|
recycled.Formato = newFmt
|
|
recycled.IsRecycled = true
|
|
recycled.BaseSlug = p.Slug
|
|
recycled.PreviousFormato = latestFormato
|
|
recycled.DaysSincePublished = days
|
|
return &recycled
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ─── Pending callback persistence ────────────────────────────────────────────
|
|
|
|
func pendingPath(ws string) string {
|
|
return filepath.Join(ws, "_inbox", "_pending_callback.json")
|
|
}
|
|
|
|
func savePending(ws string, p *PendingCallback) error {
|
|
dir := filepath.Join(ws, "_inbox")
|
|
os.MkdirAll(dir, 0755)
|
|
data, _ := json.MarshalIndent(p, "", " ")
|
|
return os.WriteFile(pendingPath(ws), data, 0644)
|
|
}
|
|
|
|
func loadPending(ws string) (*PendingCallback, error) {
|
|
data, err := os.ReadFile(pendingPath(ws))
|
|
if os.IsNotExist(err) {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var p PendingCallback
|
|
if err := json.Unmarshal(data, &p); err != nil {
|
|
return nil, err
|
|
}
|
|
return &p, nil
|
|
}
|
|
|
|
func deletePending(ws string) {
|
|
os.Remove(pendingPath(ws))
|
|
}
|
|
|
|
// ─── Telegram messages ────────────────────────────────────────────────────────
|
|
|
|
func momentumEmoji(m string) string {
|
|
switch m {
|
|
case "crescendo":
|
|
return "📈"
|
|
case "estavel":
|
|
return "➡️"
|
|
case "caindo":
|
|
return "📉"
|
|
default:
|
|
return "📊"
|
|
}
|
|
}
|
|
|
|
func sendTrendMessages(bot *telegram.Bot, trends []Trend, next *SugestaoPost, sorteado string, bloqueados []string) error {
|
|
// Mensagem 1: Trends
|
|
var sb strings.Builder
|
|
sb.WriteString("🔍 <b>ldpost-evaluator</b>\n\n")
|
|
sb.WriteString("📊 <b>Trends desta semana:</b>\n\n")
|
|
|
|
labels := []string{"A", "B", "C"}
|
|
for i, t := range trends {
|
|
if i >= 3 {
|
|
break
|
|
}
|
|
fmt.Fprintf(&sb, "<b>%s)</b> %s\n", labels[i], t.Tema)
|
|
fmt.Fprintf(&sb, " <i>%s</i>\n", t.Descricao)
|
|
fmt.Fprintf(&sb, " %s ~%d menções | %s | relevância: %s\n\n",
|
|
momentumEmoji(t.Momentum), t.Mencoes, t.Momentum, t.RelevanciaProfile)
|
|
}
|
|
|
|
if _, err := bot.SendMessage(sb.String()); err != nil {
|
|
return fmt.Errorf("msg 1 trends: %w", err)
|
|
}
|
|
|
|
// Mensagem 2: Fila + formato
|
|
var sb2 strings.Builder
|
|
if next != nil {
|
|
if next.IsRecycled {
|
|
sb2.WriteString("♻️ <b>Reciclagem da sua fila:</b>\n")
|
|
fmt.Fprintf(&sb2, "D) <i>%s</i>\n", next.Tema)
|
|
fmt.Fprintf(&sb2, "Publicado há %d dias como <b>%s</b> → novo ângulo: <b>%s</b>\n",
|
|
next.DaysSincePublished,
|
|
formats.FormatLabel(next.PreviousFormato),
|
|
formats.FormatLabel(next.Formato))
|
|
fmt.Fprintf(&sb2, "<code>%s</code> → <code>%s</code>\n", next.BaseSlug, next.Slug)
|
|
} else {
|
|
sb2.WriteString("📝 <b>Próximo da sua fila:</b>\n")
|
|
fmt.Fprintf(&sb2, "D) <i>%s</i>\n", next.Tema)
|
|
if next.Formato != "" {
|
|
fmt.Fprintf(&sb2, "Formato: %s\n", formats.FormatLabel(next.Formato))
|
|
}
|
|
}
|
|
sb2.WriteString("\n")
|
|
} else {
|
|
sb2.WriteString("📝 <i>Fila vazia — nenhum post pendente em _sugestoes.md</i>\n\n")
|
|
}
|
|
|
|
fmt.Fprintf(&sb2, "🎲 <b>Sorteio sugere:</b> %s (%s)\n", sorteado, formats.FormatLabel(sorteado))
|
|
if len(bloqueados) > 0 {
|
|
fmt.Fprintf(&sb2, "<i>bloqueados: %s</i>\n", strings.Join(bloqueados, ", "))
|
|
}
|
|
|
|
if _, err := bot.SendMessage(sb2.String()); err != nil {
|
|
return fmt.Errorf("msg 2 fila: %w", err)
|
|
}
|
|
|
|
// Mensagem 3: Botões
|
|
buttons := [][]telegram.InlineButton{
|
|
{{Text: "A — " + truncate(trends[0].Tema, 30), CallbackData: "trend_a"}},
|
|
}
|
|
if len(trends) > 1 {
|
|
buttons = append(buttons, []telegram.InlineButton{
|
|
{Text: "B — " + truncate(trends[1].Tema, 30), CallbackData: "trend_b"},
|
|
})
|
|
}
|
|
if len(trends) > 2 {
|
|
buttons = append(buttons, []telegram.InlineButton{
|
|
{Text: "C — " + truncate(trends[2].Tema, 30), CallbackData: "trend_c"},
|
|
})
|
|
}
|
|
if next != nil {
|
|
if next.Formato != "" {
|
|
var labelD string
|
|
if next.IsRecycled {
|
|
labelD = "D — Reciclar (" + formats.FormatLabel(next.PreviousFormato) + " → " + formats.FormatLabel(next.Formato) + ")"
|
|
} else {
|
|
labelD = "D — Fila (formato: " + formats.FormatLabel(next.Formato) + ")"
|
|
}
|
|
buttons = append(buttons, []telegram.InlineButton{
|
|
{Text: labelD, CallbackData: "fila_original"},
|
|
})
|
|
}
|
|
buttons = append(buttons, []telegram.InlineButton{
|
|
{Text: "E — Fila (sorteado: " + formats.FormatLabel(sorteado) + ")", CallbackData: "fila_sorteado"},
|
|
})
|
|
}
|
|
|
|
if _, err := bot.SendMessageWithKeyboard("Qual seguimos?", buttons); err != nil {
|
|
return fmt.Errorf("msg 3 botões: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ─── Workspace creation ───────────────────────────────────────────────────────
|
|
|
|
func buildTextoMD(slug, categoria, formato, funil, trendRef, resumo, img1, img2 string) string {
|
|
var sb strings.Builder
|
|
sb.WriteString("---\n")
|
|
fmt.Fprintf(&sb, "slug: %s\n", slug)
|
|
fmt.Fprintf(&sb, "categoria: %s\n", categoria)
|
|
fmt.Fprintf(&sb, "formato: %s\n", formato)
|
|
if funil != "" {
|
|
fmt.Fprintf(&sb, "funil: %s\n", funil)
|
|
}
|
|
if trendRef != "" {
|
|
fmt.Fprintf(&sb, "trend_referencia: %s\n", trendRef)
|
|
}
|
|
sb.WriteString("---\n\n")
|
|
|
|
if resumo != "" {
|
|
sb.WriteString("## O que fiz\n")
|
|
sb.WriteString(resumo)
|
|
sb.WriteString("\n\n")
|
|
}
|
|
if img1 != "" {
|
|
sb.WriteString("## Imagem 1\n")
|
|
sb.WriteString(img1)
|
|
sb.WriteString("\n\n")
|
|
}
|
|
if img2 != "" {
|
|
sb.WriteString("## Imagem 2\n")
|
|
sb.WriteString(img2)
|
|
sb.WriteString("\n")
|
|
}
|
|
return sb.String()
|
|
}
|
|
|
|
func createPostWorkspace(cfg *config.Config, slug, categoria, formato, funil, trendRef, resumo, img1, img2 string) (string, error) {
|
|
postPath := workspace.PostPath(cfg.Workspace, categoria, slug)
|
|
|
|
if err := workspace.EnsureDirs(postPath); err != nil {
|
|
return "", fmt.Errorf("criar dirs: %w", err)
|
|
}
|
|
|
|
// Escrever input/texto.md
|
|
textoMD := buildTextoMD(slug, categoria, formato, funil, trendRef, resumo, img1, img2)
|
|
if err := os.WriteFile(workspace.InputTextoPath(postPath), []byte(textoMD), 0644); err != nil {
|
|
return "", fmt.Errorf("escrever texto.md: %w", err)
|
|
}
|
|
|
|
// Criar state.json
|
|
s := state.NewPostState(slug, categoria, formato, resumo, trendRef)
|
|
s.Status = state.StatusWaitingRedator
|
|
s.SetEtapa("evaluator", state.EtapaDone)
|
|
s.SetEtapa("redator", state.EtapaWaiting)
|
|
if trendRef != "" {
|
|
s.Aprovacoes.Tema.Aprovado = true
|
|
s.Aprovacoes.Tema.Timestamp = time.Now()
|
|
}
|
|
if err := state.SaveState(postPath, s); err != nil {
|
|
return "", fmt.Errorf("salvar state: %w", err)
|
|
}
|
|
|
|
return postPath, nil
|
|
}
|
|
|
|
// ─── Callback processing ──────────────────────────────────────────────────────
|
|
|
|
func processCallback(cfg *config.Config, cb string, pending *PendingCallback) (slug string, err error) {
|
|
var (
|
|
tema string
|
|
categoria string
|
|
formato string
|
|
funil string
|
|
trendRef string
|
|
resumo string
|
|
img1 string
|
|
img2 string
|
|
)
|
|
|
|
switch cb {
|
|
case "trend_a", "trend_b", "trend_c":
|
|
idx := map[string]int{"trend_a": 0, "trend_b": 1, "trend_c": 2}[cb]
|
|
if idx >= len(pending.Trends) {
|
|
return "", fmt.Errorf("trend index %d fora do range", idx)
|
|
}
|
|
t := pending.Trends[idx]
|
|
tema = t.Tema
|
|
categoria = t.CategoriaSugerida
|
|
if categoria == "" {
|
|
categoria = "Entregavel"
|
|
}
|
|
formato = pending.FormatoSorteado
|
|
trendRef = fmt.Sprintf("%s — %s", t.Tema, t.Descricao)
|
|
slug = workspace.SlugFromTitle(tema)
|
|
|
|
case "fila_original", "fila_sorteado":
|
|
if pending.NextPost == nil {
|
|
return "", fmt.Errorf("fila vazia — sem post para processar")
|
|
}
|
|
p := pending.NextPost
|
|
tema = p.Tema
|
|
categoria = p.Categoria
|
|
if categoria == "" {
|
|
categoria = "Geral"
|
|
}
|
|
funil = p.Funil
|
|
resumo = p.Resumo
|
|
img1 = p.Imagem1
|
|
img2 = p.Imagem2
|
|
slug = p.Slug
|
|
|
|
if cb == "fila_original" && p.Formato != "" {
|
|
formato = p.Formato
|
|
} else {
|
|
formato = pending.FormatoSorteado
|
|
}
|
|
|
|
default:
|
|
return "", fmt.Errorf("callback desconhecido: %q", cb)
|
|
}
|
|
|
|
if slug == "" {
|
|
slug = workspace.SlugFromTitle(tema)
|
|
}
|
|
|
|
postPath, err := createPostWorkspace(cfg, slug, categoria, formato, funil, trendRef, resumo, img1, img2)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
log.Printf("[INFO] workspace criado: %s", postPath)
|
|
|
|
// Atualizar format-history
|
|
hist, _ := history.LoadHistory(cfg.Workspace)
|
|
if hist == nil {
|
|
hist = &history.FormatHistory{}
|
|
}
|
|
hist.AddEntry(slug, formato)
|
|
history.SaveHistory(cfg.Workspace, hist)
|
|
|
|
return slug, nil
|
|
}
|
|
|
|
// ─── Main ─────────────────────────────────────────────────────────────────────
|
|
|
|
func main() {
|
|
log.SetFlags(log.Ltime)
|
|
|
|
var (
|
|
flagDryRun bool
|
|
flagNoReddit bool
|
|
flagForceSlug string
|
|
flagWorkspace string
|
|
)
|
|
|
|
root := &cobra.Command{
|
|
Use: "ldpost-evaluator",
|
|
Short: "Busca trends, identifica fila, sorteia formato e envia para Telegram",
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
cfg := config.Load()
|
|
if flagWorkspace != "" {
|
|
cfg.Workspace = flagWorkspace
|
|
}
|
|
|
|
// ── Verificar se há callback pendente ─────────────────────────
|
|
if !flagDryRun {
|
|
if pending, err := loadPending(cfg.Workspace); err == nil && pending != nil {
|
|
age := time.Since(pending.CreatedAt)
|
|
log.Printf("[INFO] retomando callback pendente (criado há %s)", age.Round(time.Minute))
|
|
return resumeCallback(cfg, pending)
|
|
}
|
|
}
|
|
|
|
// ── 1. Buscar trends ──────────────────────────────────────────
|
|
var allTitles []string
|
|
|
|
log.Printf("[INFO] buscando trends no Hacker News...")
|
|
if !flagDryRun {
|
|
hnTitles, err := fetchHN()
|
|
if err != nil {
|
|
log.Printf("[WARN] HN: %v", err)
|
|
} else {
|
|
allTitles = append(allTitles, hnTitles...)
|
|
log.Printf("[INFO] HN: %d títulos", len(hnTitles))
|
|
}
|
|
}
|
|
|
|
if !flagNoReddit && !flagDryRun {
|
|
for _, sub := range []string{"LocalLLaMA", "MachineLearning"} {
|
|
log.Printf("[INFO] buscando Reddit r/%s...", sub)
|
|
rdTitles, err := fetchReddit(sub)
|
|
if err != nil {
|
|
if strings.Contains(err.Error(), "rate_limited") {
|
|
log.Printf("[WARN] Reddit rate limited, continuing without Reddit")
|
|
} else {
|
|
log.Printf("[WARN] Reddit r/%s: %v", sub, err)
|
|
}
|
|
} else {
|
|
allTitles = append(allTitles, rdTitles...)
|
|
log.Printf("[INFO] Reddit r/%s: %d títulos", sub, len(rdTitles))
|
|
}
|
|
}
|
|
}
|
|
|
|
if flagDryRun {
|
|
allTitles = []string{
|
|
"[HN 450 pts] LLM agents are replacing traditional automation",
|
|
"[HN 320 pts] Local AI with llama.cpp — production guide",
|
|
"[Reddit r/LocalLLaMA 890 pts] Running RAG locally without cloud",
|
|
"[Reddit r/MachineLearning 540 pts] New efficient fine-tuning approach",
|
|
"[HN 210 pts] Clean Architecture for AI systems",
|
|
}
|
|
}
|
|
|
|
// ── 2. Ler fila local ─────────────────────────────────────────
|
|
sugestoesPath := workspace.InboxSugestoes(cfg.Workspace)
|
|
allPosts, err := parseSugestoes(sugestoesPath)
|
|
if err != nil {
|
|
log.Printf("[WARN] %v", err)
|
|
} else {
|
|
log.Printf("[INFO] _sugestoes.md: %d posts", len(allPosts))
|
|
// Adicionar títulos da fila às fontes de trend
|
|
for _, p := range allPosts {
|
|
if p.Tema != "" {
|
|
allTitles = append(allTitles, "[Fila local] "+p.Tema)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Identificar próximo da fila
|
|
var nextPost *SugestaoPost
|
|
if flagForceSlug != "" {
|
|
for i := range allPosts {
|
|
if allPosts[i].Slug == flagForceSlug {
|
|
nextPost = &allPosts[i]
|
|
break
|
|
}
|
|
}
|
|
if nextPost == nil {
|
|
return fmt.Errorf("--force-slug %q não encontrado em _sugestoes.md", flagForceSlug)
|
|
}
|
|
} else {
|
|
nextPost = findNextInQueue(cfg.Workspace, allPosts)
|
|
}
|
|
|
|
if nextPost != nil {
|
|
log.Printf("[INFO] próximo da fila: %s — %s", nextPost.Slug, nextPost.Tema)
|
|
} else {
|
|
log.Printf("[INFO] fila vazia")
|
|
}
|
|
|
|
// ── 3. Consolidar trends via LLM ──────────────────────────────
|
|
var trends []Trend
|
|
if !flagDryRun && cfg.GroqAPIKey != "" {
|
|
log.Printf("[INFO] consolidando %d títulos via Groq...", len(allTitles))
|
|
gc := groq.NewGroqClient(cfg.GroqAPIKey)
|
|
|
|
var groqErr error
|
|
for attempt := 1; attempt <= 3; attempt++ {
|
|
trends, groqErr = consolidateTrends(gc, allTitles)
|
|
if groqErr == nil {
|
|
break
|
|
}
|
|
log.Printf("[WARN] Groq retry %d/3: %v", attempt, groqErr)
|
|
time.Sleep(time.Duration(attempt) * 2 * time.Second)
|
|
}
|
|
if groqErr != nil {
|
|
log.Printf("[WARN] Groq falhou após 3 tentativas, usando títulos brutos")
|
|
trends = buildFallbackTrends(allTitles)
|
|
}
|
|
} else {
|
|
trends = buildFallbackTrends(allTitles)
|
|
}
|
|
|
|
// Garantir 3 trends (preencher com vazios se necessário)
|
|
for len(trends) < 3 {
|
|
trends = append(trends, Trend{
|
|
Tema: fmt.Sprintf("Trend %d (sem dados)", len(trends)+1),
|
|
Descricao: "Dados insuficientes",
|
|
Mencoes: 0,
|
|
Momentum: "estavel",
|
|
RelevanciaProfile: "baixa",
|
|
CategoriaSugerida: "Geral",
|
|
})
|
|
}
|
|
|
|
// ── 4. Sortear formato ────────────────────────────────────────
|
|
hist, _ := history.LoadHistory(cfg.Workspace)
|
|
if hist == nil {
|
|
hist = &history.FormatHistory{}
|
|
}
|
|
bloqueados := hist.LastN(3)
|
|
sorteado := formats.SortearFormato(bloqueados)
|
|
log.Printf("[INFO] formato sorteado: %s (bloqueados: %v)", sorteado, bloqueados)
|
|
|
|
// ── 5. Dry-run ────────────────────────────────────────────────
|
|
if flagDryRun {
|
|
fmt.Printf("=== DRY-RUN — ldpost-evaluator ===\n\n")
|
|
fmt.Printf("TRENDS:\n")
|
|
for i, t := range trends[:3] {
|
|
fmt.Printf(" %s) %s\n %s | %s | %s\n",
|
|
[]string{"A", "B", "C"}[i], t.Tema, t.Descricao, t.Momentum, t.RelevanciaProfile)
|
|
}
|
|
if nextPost != nil {
|
|
fmt.Printf("\nFILA PRÓXIMO: %s — %s (formato: %s)\n", nextPost.Slug, nextPost.Tema, nextPost.Formato)
|
|
} else {
|
|
fmt.Printf("\nFILA: vazia\n")
|
|
}
|
|
fmt.Printf("FORMATO SORTEADO: %s (bloqueados: %v)\n", sorteado, bloqueados)
|
|
return nil
|
|
}
|
|
|
|
// ── 6. Validar Telegram ───────────────────────────────────────
|
|
if err := cfg.Validate("telegram"); err != nil {
|
|
return fmt.Errorf("Telegram não configurado: %w\nUse --dry-run para testar sem Telegram", err)
|
|
}
|
|
bot := telegram.NewBot(cfg.TelegramBotToken, cfg.TelegramChatID)
|
|
|
|
// ── 7. Enviar mensagens Telegram ──────────────────────────────
|
|
log.Printf("[INFO] enviando mensagens Telegram...")
|
|
if err := sendTrendMessages(bot, trends, nextPost, sorteado, bloqueados); err != nil {
|
|
return fmt.Errorf("Telegram: %w", err)
|
|
}
|
|
|
|
// ── 8. Salvar pending callback ────────────────────────────────
|
|
pending := &PendingCallback{
|
|
Trends: trends,
|
|
NextPost: nextPost,
|
|
FormatoSorteado: sorteado,
|
|
FormatosBloqueados: bloqueados,
|
|
CreatedAt: time.Now(),
|
|
}
|
|
if err := savePending(cfg.Workspace, pending); err != nil {
|
|
log.Printf("[WARN] salvar pending: %v", err)
|
|
}
|
|
log.Printf("[INFO] aguardando callback no Telegram (sem timeout)...")
|
|
|
|
// ── 9. Aguardar callback ──────────────────────────────────────
|
|
return resumeCallback(cfg, pending)
|
|
},
|
|
}
|
|
|
|
root.Flags().BoolVar(&flagDryRun, "dry-run", false, "Executa sem chamar APIs nem escrever no disco")
|
|
root.Flags().BoolVar(&flagNoReddit, "no-reddit", false, "Pula Reddit (útil se rate limited)")
|
|
root.Flags().StringVar(&flagForceSlug, "force-slug", "", "Força slug específico da fila (pula seleção de tema)")
|
|
root.Flags().StringVar(&flagWorkspace, "workspace", "", "Override de LDPOST_WORKSPACE")
|
|
|
|
if err := root.Execute(); err != nil {
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
// resumeCallback waits for one of the 5 valid callbacks then processes it.
|
|
func resumeCallback(cfg *config.Config, pending *PendingCallback) error {
|
|
bot := telegram.NewBot(cfg.TelegramBotToken, cfg.TelegramChatID)
|
|
|
|
valid := []string{"trend_a", "trend_b", "trend_c"}
|
|
if pending.NextPost != nil {
|
|
if pending.NextPost.Formato != "" {
|
|
valid = append(valid, "fila_original")
|
|
}
|
|
valid = append(valid, "fila_sorteado")
|
|
}
|
|
|
|
log.Printf("[INFO] esperando callbacks válidos: %v", valid)
|
|
cb, err := bot.WaitForCallback(valid, 0) // 0 = indefinido
|
|
if err != nil {
|
|
return fmt.Errorf("callback: %w", err)
|
|
}
|
|
log.Printf("[INFO] callback recebido: %s", cb)
|
|
|
|
slug, err := processCallback(cfg, cb, pending)
|
|
if err != nil {
|
|
bot.SendMessage(fmt.Sprintf("❌ Erro ao processar escolha: %s", err.Error()))
|
|
return err
|
|
}
|
|
|
|
// Confirmar no Telegram
|
|
var titulo string
|
|
if pending.NextPost != nil && (cb == "fila_original" || cb == "fila_sorteado") {
|
|
titulo = pending.NextPost.Tema
|
|
} else {
|
|
idx := map[string]int{"trend_a": 0, "trend_b": 1, "trend_c": 2}[cb]
|
|
if idx < len(pending.Trends) {
|
|
titulo = pending.Trends[idx].Tema
|
|
}
|
|
}
|
|
|
|
confirmMsg := fmt.Sprintf(
|
|
"✅ <b>Escolha registrada!</b>\n\nPost: <i>%s</i>\nSlug: <code>%s</code>\n\nPróximo passo:\n<code>ldpost-redator --post %s</code>",
|
|
titulo, slug, slug,
|
|
)
|
|
if _, err := bot.SendMessage(confirmMsg); err != nil {
|
|
log.Printf("[WARN] confirmação Telegram: %v", err)
|
|
}
|
|
|
|
// Limpar pending
|
|
deletePending(cfg.Workspace)
|
|
log.Printf("[INFO] done — post slug: %s", slug)
|
|
fmt.Println(slug)
|
|
return nil
|
|
}
|
|
|
|
// ─── Utilities ────────────────────────────────────────────────────────────────
|
|
|
|
func truncate(s string, n int) string {
|
|
if len(s) <= n {
|
|
return s
|
|
}
|
|
return s[:n-1] + "…"
|
|
}
|