Pular para conteúdo

Agendador de posts Instagram

Última revisão: 2026-04-29

Sistema que organiza posts semanais das contas @juniormaia e @grupo.gita pelo admin e publica automaticamente via cron.

Quer publicar uma peça do squad agora? Veja INSTAGRAM-PUBLISH.md (runbook end-to-end + script publish-junior-carousel.sh). Este doc cobre a infraestrutura subjacente.

URL do servidor. O canônico planejado é apiclickup.gita.work mas o DNS atualmente não resolve. Use o hostname Easypanel direto: gita-apiclickup.ewzc9p.easypanel.host. TODO: configurar o subdomínio canônico no Cloudflare/DNS.

Arquitetura

Admin /instagram ──► Supabase.instagram_posts (status=scheduled)
                     Supabase Storage: instagram-media/{account}/{yyyy-mm}/{uuid}.{ext}
                           │
                           ▼
Server apiclickup cron "instagram-publisher" (*/1 * * * *)
    1. SELECT * WHERE status='scheduled' AND scheduled_at<=now() LIMIT 5
    2. Marca 'publishing' (reserva atômica)
    3. Chama Graph API → publica via fluxo de 2 etapas
    4. Grava 'published' + ig_media_id ou 'failed' + error
    5. Audit em cron.instagram-publisher

Componentes

Peça Local
Tabela Supabase instagram_posts (migration 023_instagram_posts.sql)
Bucket Storage instagram-media (criar via console Supabase)
Helpers Graph API server/lib/instagram.js
Cron server/automations/instagram-scheduler.js
Registry server/crons.js (id instagram-publisher)
Admin UI admin/src/app/(authed)/instagram/
Upload API POST /api/instagram/upload (service key no server)

Fluxo Graph API (Content Publishing)

Feed (imagem única)

POST /{ig-user-id}/media?image_url=...&caption=...          → {id: creation_id}
GET  /{creation_id}?fields=status_code                       → polling até FINISHED
POST /{ig-user-id}/media_publish?creation_id=...             → {id: ig_media_id}

Reel (vídeo)

Mesmo fluxo, mas media_type=REELS&video_url=...&share_to_feed=true. Polling pode levar 30–60s.

Carrossel (2–10 mídias)

  1. Para cada mídia: POST /{ig-user-id}/media?is_carousel_item=true&image_url=... → N children
  2. Aguarda todos os children ficarem FINISHED
  3. POST /{ig-user-id}/media?media_type=CAROUSEL&children=id1,id2,...&caption=... → carousel container
  4. POST /{ig-user-id}/media_publish?creation_id=<carousel_id>

Story

POST /{ig-user-id}/media?media_type=STORIES&image_url=... (ou video_url). Sem stickers/menções/hashtags via API.

Setup Meta Developers (uma vez, manual)

Passo a passo no business.facebook.com e developers.facebook.com:

1. Business Manager

  • Acessar business.facebook.com
  • Confirmar que o Business do Grupo Gita existe e está verificado
  • Adicionar ambas as Páginas FB (uma para cada Instagram) ao Business

2. Instagram Business Account

Em cada conta (Junior + Gita), no app do Instagram: - Configurações → Conta → Mudar para conta profissional → Business - Configurações → Central de Contas → Conectar à Página FB correspondente

Verificar se conectou:

curl "https://graph.facebook.com/v21.0/{page-id}?fields=instagram_business_account&access_token={page-token}"
# Deve retornar { "instagram_business_account": { "id": "17841..." } }

3. Criar App

Em developers.facebook.com → My Apps → Create App: - Tipo: Business - Nome: Gita Social Publisher - Adicionar produtos: - Instagram Graph API - Facebook Login for Business

4. Permissões

Adicionar ao app (em Instagram Graph API → Permissions): - instagram_basic - instagram_content_publish - pages_show_list - pages_read_engagement - business_management

5. Gerar tokens

  1. Abrir Graph API Explorer
  2. Selecionar o app Gita Social Publisher
  3. Get User Access Token com as 5 permissões acima
  4. Trocar por Long-Lived Token (60 dias):
    curl "https://graph.facebook.com/v21.0/oauth/access_token?\
    grant_type=fb_exchange_token&\
    client_id={META_APP_ID}&\
    client_secret={META_APP_SECRET}&\
    fb_exchange_token={USER_TOKEN}"
    
  5. Pegar Page Access Token de cada página (nunca expira se a Page pertence ao Business verificado):
    curl "https://graph.facebook.com/v21.0/me/accounts?access_token={LONG_LIVED_USER_TOKEN}"
    # Retorna array com { access_token, id, name, ... } por página
    

6. Descobrir ig_user_id

curl "https://graph.facebook.com/v21.0/{page-id}?fields=instagram_business_account&access_token={page-token}"

7. App Review

Se ambas as contas IG pertencem ao Business que é dono do App → funciona em Dev mode sem review.

Se não → submeter App Review para instagram_content_publish (demora ~1 semana).

8. Configurar no Easypanel

No serviço apiclickup → Environment:

META_APP_ID=...
META_APP_SECRET=...
META_IG_TOKEN_JUNIOR=EAA...       # Page token da @juniormaia
META_IG_USER_ID_JUNIOR=17841...
META_IG_TOKEN_GITA=EAA...
META_IG_USER_ID_GITA=17841...
ENABLE_INSTAGRAM_PUBLISHER=true

Depois: redeploy do apiclickup.

9. Bucket Supabase Storage

No console Supabase → Storage → New bucket: - Nome: instagram-media - Public bucket: sim (o Graph API precisa acessar as URLs publicamente) - Não precisa de RLS policies (admin usa service key pra escrever)

Operação dia-a-dia

Agendar post

  1. Admin → Instagram+ Novo post
  2. Escolher conta (Junior/Gita), tipo (feed/reel/carrossel/story)
  3. Fazer upload das mídias
  4. Escrever legenda
  5. Definir data/hora
  6. Clicar em Agendar

Pausar o publisher

  1. Admin → Crons → toggle off no card "Publicador Instagram"
  2. Posts agendados ficam presos (status=scheduled), ninguém publica
  3. Religar o toggle quando quiser retomar

Investigar falha

  1. Admin → Instagram → card vermelho "Falhas"
  2. Clicar no post → seção Último erro mostra a mensagem da Graph API
  3. Corrigir (trocar mídia, ajustar caption) e clicar Tentar novamente

Limites da Meta

  • 25 publicações por conta / 24h (rolling). Bate teto → erros code=4.
  • Caption máx: 2200 chars.
  • Feed: 1 imagem. JPG/PNG, ≤ 8 MB.
  • Reel: 1 vídeo MP4, H.264+AAC, 3–60s, ≥ 720p.
  • Carrossel: 2–10 itens. Cada item seguindo regras acima.
  • Story: 1 mídia. Imagem ou vídeo ≤ 60s.
  • URLs de mídia: precisam ser publicamente acessíveis (daí o bucket público).

Troubleshooting

Erro Causa Solução
code=190 Token expirou / revogado Regerar Page Token (passo 5.5 acima), atualizar META_IG_TOKEN_* no Easypanel + redeploy
code=4 Rate limit atingido (25/24h) Esperar. O cron naturalmente tenta no próximo tick — não precisa intervir.
code=9004 URL de mídia inacessível Verificar se o bucket Supabase é público. Testar abrir a URL no browser anônimo.
status_code=ERROR no container Meta rejeitou a mídia (formato/tamanho) Ver limites acima. Usuário precisa re-uploadar com formato correto.
Container EXPIRED Demorou >24h pra publicar Improvável (cron roda 1/min). Pode indicar que o cron parou — checar /instagram/scheduler/status.
Timeout no polling Vídeo grande demorando pra processar Lib aguarda até 5min (reels) ou 3min (demais). Se passar disso, Meta não processou — post cai em failed.

Rotação de token

Page Access Tokens "não expiram" — mas podem ser revogados se: - O dono removeu o app - O usuário que gerou o User Token original perdeu acesso à Page - Meta revogou por inatividade (>60 dias sem uso da API)

Plano de rotação: quando o primeiro code=190 aparecer no audit: 1. Gerar novo User Access Token via Graph API Explorer 2. Trocar por Long-Lived 3. Pegar novos Page Tokens via /me/accounts 4. Atualizar META_IG_TOKEN_* no Easypanel → redeploy

API REST

Todos os endpoints abaixo ficam no servidor apiclickup. Auth via header Authorization: Bearer <DASHBOARD_TOKEN> ou query param ?token=<DASHBOARD_TOKEN>.

Gerenciamento de posts

GET /instagram/posts

Lista posts agendados/publicados.

Query params opcionais: - status — filtrar por status: draft|scheduled|publishing|published|failed - account — filtrar por conta: junior|gita - limit — máximo de resultados (default 50, max 200)

curl "https://gita-apiclickup.ewzc9p.easypanel.host/instagram/posts?status=scheduled" \
  -H "Authorization: Bearer $DASHBOARD_TOKEN"

Resposta:

{ "posts": [...], "count": 3 }

POST /instagram/posts

Cria um novo post (agendado ou rascunho).

Body JSON:

{
  "account": "junior",
  "post_type": "feed",
  "caption": "Legenda do post...",
  "media_items": [
    { "url": "https://...", "type": "image" }
  ],
  "scheduled_at": "2026-04-25T17:00:00Z",
  "status": "scheduled"
}

  • account: junior | gita
  • post_type: feed | reel | carousel | story
  • media_items: array de {url, type: 'image'|'video'}. A URL precisa ser publicamente acessível (usar Supabase Storage público).
  • scheduled_at: ISO 8601 UTC. O cron publica quando scheduled_at <= now().
  • status: scheduled (publica no horário) ou draft (não publica até mudar para scheduled)
curl -X POST "https://gita-apiclickup.ewzc9p.easypanel.host/instagram/posts" \
  -H "Authorization: Bearer $DASHBOARD_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "account": "junior",
    "post_type": "feed",
    "caption": "Novo post via API!",
    "media_items": [{"url": "https://cycuvtfxvdblplseyidk.supabase.co/storage/v1/object/public/instagram-media/junior/2026-04/abc.jpg", "type": "image"}],
    "scheduled_at": "2026-04-25T17:00:00Z",
    "status": "scheduled"
  }'

Resposta: o objeto instagram_posts criado (201 Created).

Scheduler

  • GET /instagram/scheduler/status{ enabled, envFlag, pendingCount, cronExpression }
  • POST /instagram/publish-pending?limit=5 — força tick do cron agora. Útil pra debug.

Script: automação Drive → agendamento

O script server/scripts/instagram-from-drive.js lê uma planilha Google Sheets como calendário editorial, baixa as mídias do Drive, sobe para o Supabase Storage e chama POST /instagram/posts para cada linha pendente.

Estrutura da planilha

A: account B: post_type C: caption D: scheduled_at (BRT) E: drive_file_ids F: status G: post_id
junior feed Legenda... 25/04/2026 14:00 1Abc... (vazio) (vazio)
gita carousel ... 26/04/2026 09:00 1Def...,1Ghi...
  • F (status): deixar vazio para processar. O script preenche scheduled ou error: <motivo>.
  • G (post_id): preenchido automaticamente com o UUID do post criado.
  • drive_file_ids: IDs do Drive separados por vírgula, ou URLs completas de drive.google.com.
  • scheduled_at: aceita DD/MM/YYYY HH:MM (BRT) ou ISO 8601.

Env vars necessárias

INSTAGRAM_SHEET_ID=<ID da planilha>
DASHBOARD_TOKEN=<token do apiclickup>
SERVER_URL=https://gita-apiclickup.ewzc9p.easypanel.host   # default: http://localhost:3000
# + as vars Google OAuth2 e Supabase já existentes

Executar

cd ~/gita/server
node scripts/instagram-from-drive.js

# Override de planilha sem mudar .env:
SHEET_ID=<outro-id> node scripts/instagram-from-drive.js

Adicionar conta nova (futuro)

Hoje é hardcoded junior/gita. Para virar multi-tenant, trocar o ENUM ig_account por tabela instagram_accounts com tokens e ig_user_ids próprios, adicionar seletor dinâmico no admin, migrar creds() em server/lib/instagram.js para ler da tabela em vez do env.