Jobmaker-LdPost/publisher/main.go
Ricardo Carneiro ea532659b0 feat: pipeline inicial ldpost-squad (6 agentes)
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>
2026-05-03 18:55:39 -03:00

600 lines
20 KiB
Go
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/spf13/cobra"
"ldpost/shared/config"
"ldpost/shared/history"
"ldpost/shared/state"
"ldpost/shared/telegram"
"ldpost/shared/workspace"
)
const linkedInAPIBase = "https://api.linkedin.com/v2"
// ─── LinkedIn types ───────────────────────────────────────────────────────────
type ugcPost struct {
Author string `json:"author"`
LifecycleState string `json:"lifecycleState"`
SpecificContent specificContent `json:"specificContent"`
Visibility visibility `json:"visibility"`
}
type specificContent struct {
ShareContent shareContent `json:"com.linkedin.ugc.ShareContent"`
}
type shareContent struct {
ShareCommentary shareCommentary `json:"shareCommentary"`
ShareMediaCategory string `json:"shareMediaCategory"`
Media []ugcMedia `json:"media,omitempty"`
}
type shareCommentary struct {
Text string `json:"text"`
}
type ugcMedia struct {
Status string `json:"status"`
Description localText `json:"description"`
Media string `json:"media,omitempty"`
Title localText `json:"title"`
}
type localText struct {
Text string `json:"text"`
}
type visibility struct {
MemberNetworkVisibility string `json:"com.linkedin.ugc.MemberNetworkVisibility"`
}
// ─── LinkedIn helpers ─────────────────────────────────────────────────────────
// checkToken returns (personID, nil) on 200, ("", err) on failure or 401.
// Uses /v2/userinfo (OpenID Connect) — requires openid+profile scopes.
// The "sub" field contains the member ID used for urn:li:person:{id}.
func checkToken(token string) (string, error) {
req, _ := http.NewRequest("GET", linkedInAPIBase+"/userinfo", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", fmt.Errorf("GET /userinfo: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == 401 {
return "", fmt.Errorf("token inválido ou expirado (401)")
}
body, _ := io.ReadAll(resp.Body)
var result struct {
Sub string `json:"sub"` // OpenID Connect member ID
}
if err := json.Unmarshal(body, &result); err != nil || result.Sub == "" {
return "", fmt.Errorf("LinkedIn /userinfo inválido: %s", string(body))
}
return result.Sub, nil
}
func uploadImage(token, personURN, imgPath, slideLabel string) (string, error) {
regReq := map[string]any{
"registerUploadRequest": map[string]any{
"recipes": []string{"urn:li:digitalmediaRecipe:feedshare-document"},
"owner": personURN,
"serviceRelationships": []map[string]string{
{"relationshipType": "OWNER", "identifier": "urn:li:userGeneratedContent"},
},
},
}
regData, _ := json.Marshal(regReq)
req, _ := http.NewRequest("POST", linkedInAPIBase+"/assets?action=registerUpload", bytes.NewReader(regData))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Restli-Protocol-Version", "2.0.0")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", fmt.Errorf("registerUpload %s: %w", slideLabel, err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var regResp struct {
Value struct {
UploadMechanism struct {
Req struct {
UploadURL string `json:"uploadUrl"`
Headers map[string]string `json:"headers"`
} `json:"com.linkedin.digitalmedia.uploading.MediaUploadHttpRequest"`
} `json:"uploadMechanism"`
Asset string `json:"asset"`
} `json:"value"`
}
if err := json.Unmarshal(body, &regResp); err != nil {
return "", fmt.Errorf("parsear registerUpload %s: %w (body: %s)", slideLabel, err, string(body))
}
uploadURL := regResp.Value.UploadMechanism.Req.UploadURL
assetURN := regResp.Value.Asset
if uploadURL == "" {
return "", fmt.Errorf("uploadURL vazia para %s: %s", slideLabel, string(body))
}
imgData, err := os.ReadFile(imgPath)
if err != nil {
return "", fmt.Errorf("ler %s: %w", slideLabel, err)
}
putReq, _ := http.NewRequest("PUT", uploadURL, bytes.NewReader(imgData))
for k, v := range regResp.Value.UploadMechanism.Req.Headers {
putReq.Header.Set(k, v)
}
putReq.Header.Set("Content-Type", "application/octet-stream")
putResp, err := http.DefaultClient.Do(putReq)
if err != nil {
return "", fmt.Errorf("PUT %s: %w", slideLabel, err)
}
defer putResp.Body.Close()
if putResp.StatusCode >= 300 {
b, _ := io.ReadAll(putResp.Body)
return "", fmt.Errorf("upload %s status %d: %s", slideLabel, putResp.StatusCode, string(b))
}
log.Printf("[INFO] %s uploaded → %s", slideLabel, assetURN)
return assetURN, nil
}
func publishPost(token, personURN, text string, assetURNs []string) (string, error) {
var mediaList []ugcMedia
for i, urn := range assetURNs {
label := fmt.Sprintf("Slide %d", i+1)
mediaList = append(mediaList, ugcMedia{
Status: "READY",
Description: localText{label},
Media: urn,
Title: localText{label},
})
}
cat := "NONE"
if len(mediaList) > 0 {
cat = "IMAGE"
}
post := ugcPost{
Author: personURN,
LifecycleState: "PUBLISHED",
SpecificContent: specificContent{
ShareContent: shareContent{
ShareCommentary: shareCommentary{Text: text},
ShareMediaCategory: cat,
Media: mediaList,
},
},
Visibility: visibility{MemberNetworkVisibility: "PUBLIC"},
}
postData, _ := json.Marshal(post)
req, _ := http.NewRequest("POST", linkedInAPIBase+"/ugcPosts", bytes.NewReader(postData))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Restli-Protocol-Version", "2.0.0")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", fmt.Errorf("POST /ugcPosts: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 300 {
return "", fmt.Errorf("ugcPosts status %d: %s", resp.StatusCode, string(body))
}
var result struct {
ID string `json:"id"`
}
if err := json.Unmarshal(body, &result); err != nil || result.ID == "" {
return "", fmt.Errorf("parsear ugcPosts: %w (body: %s)", err, string(body))
}
return result.ID, nil
}
// ─── Markdown cleanup ─────────────────────────────────────────────────────────
var headerRe = regexp.MustCompile(`(?m)^#{1,6}\s+`)
func cleanMarkdown(text string) string {
// Remove header markers
text = headerRe.ReplaceAllString(text, "")
// Remove bold/italic markers
text = strings.ReplaceAll(text, "**", "")
text = strings.ReplaceAll(text, "__", "")
// Remove YAML front-matter if present
if strings.HasPrefix(text, "---") {
if end := strings.Index(text[3:], "---"); end >= 0 {
text = text[end+6:]
}
}
return strings.TrimSpace(text)
}
// ─── OAuth2 flow ──────────────────────────────────────────────────────────────
func runOAuthFlow(cfg *config.Config) error {
if cfg.LinkedInClientID == "" || cfg.LinkedInClientSecret == "" {
return fmt.Errorf("LINKEDIN_CLIENT_ID e LINKEDIN_CLIENT_SECRET são obrigatórios para --auth")
}
redirectURI := "http://localhost:8080/callback"
authURL := fmt.Sprintf(
"https://www.linkedin.com/oauth/v2/authorization?response_type=code&client_id=%s&redirect_uri=%s&scope=%s",
cfg.LinkedInClientID,
url.QueryEscape(redirectURI),
url.QueryEscape("w_member_social openid profile"),
)
fmt.Println("Token LinkedIn não encontrado. Configurando OAuth2.")
fmt.Println()
fmt.Println("1. Abra esta URL no navegador:")
fmt.Println(" ", authURL)
fmt.Println()
fmt.Println("2. Aguardando callback em", redirectURI, "...")
codeCh := make(chan string, 1)
errCh := make(chan error, 1)
srv := &http.Server{Addr: ":8080"}
http.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) {
code := r.URL.Query().Get("code")
if code == "" {
errCh <- fmt.Errorf("código ausente no callback: %s", r.URL.RawQuery)
fmt.Fprintf(w, "Erro: código ausente.")
return
}
codeCh <- code
fmt.Fprintf(w, "Autorização recebida! Pode fechar esta janela.")
})
ln, err := net.Listen("tcp", ":8080")
if err != nil {
return fmt.Errorf("abrir porta 8080: %w", err)
}
go srv.Serve(ln)
var code string
select {
case code = <-codeCh:
case err = <-errCh:
srv.Shutdown(context.Background())
return err
case <-time.After(5 * time.Minute):
srv.Shutdown(context.Background())
return fmt.Errorf("timeout aguardando autorização (5 min)")
}
srv.Shutdown(context.Background())
// Exchange code for token
params := url.Values{
"grant_type": {"authorization_code"},
"code": {code},
"redirect_uri": {redirectURI},
"client_id": {cfg.LinkedInClientID},
"client_secret": {cfg.LinkedInClientSecret},
}
resp, err := http.PostForm("https://www.linkedin.com/oauth/v2/accessToken", params)
if err != nil {
return fmt.Errorf("trocar código por token: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var tokenResp struct {
AccessToken string `json:"access_token"`
}
if err := json.Unmarshal(body, &tokenResp); err != nil || tokenResp.AccessToken == "" {
return fmt.Errorf("parsear token response: %w (body: %s)", err, string(body))
}
// Append to .env
envEntry := fmt.Sprintf("\nLINKEDIN_ACCESS_TOKEN=%s\n", tokenResp.AccessToken)
f, err := os.OpenFile(".env", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
if err != nil {
fmt.Printf("Token obtido! Adicione manualmente ao .env:\nLINKEDIN_ACCESS_TOKEN=%s\n", tokenResp.AccessToken)
return nil
}
f.WriteString(envEntry)
f.Close()
fmt.Println("✅ Token salvo em .env — rode ldpost-publisher novamente.")
return nil
}
// ─── Manual mode ─────────────────────────────────────────────────────────────
func runManualMode(postPath string, s *state.PostState, postText string, outputImgs []string, reason string) error {
sep := strings.Repeat("═", 51)
fmt.Println(sep)
fmt.Println("ldpost-publisher | MODO MANUAL")
fmt.Println(sep)
fmt.Println()
fmt.Printf("⚠️ %s\n Modo manual ativado — você vai postar manualmente.\n\n", reason)
fmt.Println("TEXTO DO POST (copie tudo abaixo da linha):")
fmt.Println(strings.Repeat("─", 51))
fmt.Println(postText)
fmt.Println(strings.Repeat("─", 51))
fmt.Println()
if len(outputImgs) > 0 {
fmt.Println("IMAGENS:")
for i, img := range outputImgs {
fmt.Printf(" Slide %d: %s\n", i+1, img)
}
fmt.Println()
}
fmt.Println("INSTRUÇÕES:")
fmt.Println(" 1. Abra o LinkedIn no navegador")
fmt.Println(" 2. Clique em \"Criar post\" → \"Adicionar fotos\"")
fmt.Println(" 3. Selecione slide1.png e slide2.png (nessa ordem)")
fmt.Println(" 4. Cole o texto acima na área de texto")
fmt.Println(" 5. Clique em \"Publicar\"")
fmt.Println()
fmt.Print("Após publicar, cole o link do post aqui para registrar:\nURL do post: ")
scanner := bufio.NewScanner(os.Stdin)
if !scanner.Scan() {
return fmt.Errorf("leitura cancelada")
}
postURL := strings.TrimSpace(scanner.Text())
if postURL == "" {
log.Printf("[WARN] URL não fornecida — state não atualizado")
return nil
}
// Extract post ID from URL
postID := extractPostID(postURL)
return finalizePublished(postPath, s, postID, postURL)
}
// extractPostID attempts to pull the URN from a LinkedIn post URL.
func extractPostID(rawURL string) string {
// e.g. https://www.linkedin.com/feed/update/urn:li:ugcPost:123/
parts := strings.Split(rawURL, "/")
for _, p := range parts {
if strings.HasPrefix(p, "urn:li:") {
return p
}
}
return rawURL // fallback: store the URL itself as ID
}
// ─── State finalization ───────────────────────────────────────────────────────
func finalizePublished(postPath string, s *state.PostState, postID, postURL string) error {
now := time.Now()
s.Status = state.StatusPublished
s.SetEtapa("publisher", state.EtapaDone)
s.LinkedInPostID = postID
s.PublishedAt = &now
if err := state.SaveState(postPath, s); err != nil {
return fmt.Errorf("state: %w", err)
}
fmt.Println()
fmt.Println("✅ POST PUBLICADO!")
fmt.Println()
fmt.Printf("LinkedIn: %s\n", postURL)
fmt.Printf("Slug: %s\n", s.Slug)
fmt.Printf("Publicado em: %s\n", now.Format("02/01/2006 15:04"))
fmt.Println()
fmt.Println("Parabéns. Agora atualize as métricas em 48h para o evaluator.")
return nil
}
// ─── Main ─────────────────────────────────────────────────────────────────────
func main() {
log.SetFlags(log.Ltime)
var (
flagPost string
flagWorkspace string
flagDryRun bool
flagManual bool
flagAuth bool
)
root := &cobra.Command{
Use: "ldpost-publisher --post <slug>",
Short: "Publica o post aprovado no LinkedIn",
RunE: func(cmd *cobra.Command, args []string) error {
cfg := config.Load()
if flagWorkspace != "" {
cfg.Workspace = flagWorkspace
}
// ── --auth: OAuth2 setup flow ─────────────────────────────────
if flagAuth {
return runOAuthFlow(cfg)
}
if flagPost == "" {
return fmt.Errorf("--post é obrigatório")
}
// ── 1. Encontrar post e validar state ─────────────────────────
postPath, err := workspace.FindPostBySlug(cfg.Workspace, flagPost)
if err != nil {
return fmt.Errorf("post %q: %w", flagPost, err)
}
s, err := state.LoadState(postPath)
if err != nil {
return fmt.Errorf("ler state: %w", err)
}
if !s.IsStatus(state.StatusWaitingPublisher) {
log.Printf("[ERROR] status atual: %q — esperado: %q", s.Status, state.StatusWaitingPublisher)
return fmt.Errorf("estado incorreto — rode ldpost-director --post %s primeiro", flagPost)
}
if !s.Aprovacoes.Final.Aprovado && !flagDryRun && !flagManual {
return fmt.Errorf("post sem aprovação final — rode ldpost-director --post %s", flagPost)
}
// ── 2. Ler conteúdo ───────────────────────────────────────────
postText := ""
if data, err := os.ReadFile(workspace.OutputPostPath(postPath)); err == nil {
postText = cleanMarkdown(string(data))
} else if data, err := os.ReadFile(filepath.Join(workspace.WorkPath(postPath), "editor-final.md")); err == nil {
postText = cleanMarkdown(string(data))
} else {
return fmt.Errorf("output/post.md e work/editor-final.md não encontrados")
}
slide1 := filepath.Join(workspace.OutputPath(postPath), "slide1.png")
slide2 := filepath.Join(workspace.OutputPath(postPath), "slide2.png")
outputImgs := []string{}
for _, p := range []string{slide1, slide2} {
if _, err := os.Stat(p); err == nil {
outputImgs = append(outputImgs, p)
}
}
log.Printf("[INFO] post=%s chars=%d imagens=%d", s.Slug, len(postText), len(outputImgs))
// ── 3. Dry-run ────────────────────────────────────────────────
if flagDryRun {
fmt.Println("⚠️ DRY-RUN — NENHUMA CHAMADA À API LINKEDIN SERÁ FEITA")
fmt.Println(strings.Repeat("═", 51))
fmt.Printf("Post: %s | Chars: %d | Imagens: %d\n\n", flagPost, len(postText), len(outputImgs))
fmt.Println("── TEXTO LIMPO ──────────────────────────────────────")
fmt.Println(postText)
fmt.Println(strings.Repeat("─", 51))
fmt.Printf("Aprovado: %v\n", s.Aprovacoes.Final.Aprovado)
return nil
}
// ── 4. Verificar token / decidir modo ─────────────────────────
var personID string
manualReason := ""
if flagManual {
manualReason = "Modo manual solicitado via --manual."
} else if cfg.LinkedInAccessToken == "" {
manualReason = "Token LinkedIn não configurado."
if cfg.LinkedInClientID != "" {
manualReason += " Configure com: ldpost-publisher --auth"
}
} else {
id, err := checkToken(cfg.LinkedInAccessToken)
if err != nil {
manualReason = fmt.Sprintf("Token LinkedIn inválido: %v", err)
} else {
personID = id
}
}
if manualReason != "" {
return runManualMode(postPath, s, postText, outputImgs, manualReason)
}
personURN := "urn:li:person:" + personID
log.Printf("[INFO] person URN: %s", personURN)
// ── 5. Confirmação antes de publicar ──────────────────────────
fmt.Printf("\n⚠ PUBLICAÇÃO IRREVERSÍVEL\n")
fmt.Printf("Post: %s | Formato: %s\n", s.Slug, s.Formato)
fmt.Printf("Confirmar publicação? (s/N): ")
scanner := bufio.NewScanner(os.Stdin)
if !scanner.Scan() || strings.ToLower(strings.TrimSpace(scanner.Text())) != "s" {
fmt.Println("Publicação cancelada.")
return nil
}
// ── 6. Upload imagens ─────────────────────────────────────────
var assetURNs []string
for i, imgPath := range outputImgs {
label := fmt.Sprintf("slide%d", i+1)
log.Printf("[INFO] upload %s...", label)
urn, err := uploadImage(cfg.LinkedInAccessToken, personURN, imgPath, label)
if err != nil {
log.Printf("[ERROR] upload %s: %v", label, err)
if len(assetURNs) > 0 {
log.Printf("[INFO] asset URNs já obtidos (para retry): %v", assetURNs)
}
return fmt.Errorf("falha no upload de %s: %w", label, err)
}
assetURNs = append(assetURNs, urn)
}
// ── 7. Publicar post ──────────────────────────────────────────
log.Printf("[INFO] publicando post no LinkedIn...")
postID, err := publishPost(cfg.LinkedInAccessToken, personURN, postText, assetURNs)
if err != nil {
log.Printf("[INFO] asset URNs para retry: %v", assetURNs)
return fmt.Errorf("publicar: %w", err)
}
postURL := fmt.Sprintf("https://www.linkedin.com/feed/update/%s/", postID)
log.Printf("[INFO] publicado: %s", postURL)
// ── 8. Finalizar state e histórico ────────────────────────────
if err := finalizePublished(postPath, s, postID, postURL); err != nil {
return err
}
hist, err := history.LoadHistory(cfg.Workspace)
if err != nil {
hist = &history.FormatHistory{}
}
hist.AddEntry(s.Slug, s.Formato)
if err := history.SaveHistory(cfg.Workspace, hist); err != nil {
log.Printf("[WARN] format-history: %v", err)
}
// Ensure output/post.md exists with clean text
if _, err := os.Stat(workspace.OutputPostPath(postPath)); os.IsNotExist(err) {
os.WriteFile(workspace.OutputPostPath(postPath), []byte(postText), 0644)
}
// ── 9. Notificação Telegram ───────────────────────────────────
if cfg.TelegramBotToken != "" && cfg.TelegramChatID != "" {
bot := telegram.NewBot(cfg.TelegramBotToken, cfg.TelegramChatID)
msg := fmt.Sprintf(
"🚀 <b>Post publicado!</b>\n\n<code>%s</code>\n🔗 %s\n📅 %s",
s.Slug, postURL, time.Now().Format("02/01/2006 15:04"),
)
if _, err := bot.SendMessage(msg); err != nil {
log.Printf("[WARN] Telegram: %v", err)
}
}
return nil
},
}
root.Flags().StringVar(&flagPost, "post", "", "Slug do post (obrigatório, exceto com --auth)")
root.Flags().StringVar(&flagWorkspace, "workspace", "", "Override de LDPOST_WORKSPACE")
root.Flags().BoolVar(&flagDryRun, "dry-run", false, "Prepara tudo sem chamar LinkedIn API")
root.Flags().BoolVar(&flagManual, "manual", false, "Força modo manual mesmo com token configurado")
root.Flags().BoolVar(&flagAuth, "auth", false, "Inicia fluxo OAuth2 para configurar token")
if err := root.Execute(); err != nil {
os.Exit(1)
}
}