projeto · 25 de out. de 2026
stackdigest: uma redação de shorts de tech que roda sozinha
Um pipeline que lê dezenas de fontes, decide o que vira vídeo, escreve e revisa o roteiro com LLM e renderiza MP4 vertical num PC em casa.
- Cloudflare Workers
- Workflows
- D1
- R2
- KV
- Queues
- Hono
- Drizzle
- OpenRouter
- ElevenLabs
- HyperFrames
- FFmpeg
- React
- TanStack
Eu queria um canal de shorts em português sobre bastidores de engenharia: postmortems, decisões de arquitetura de big tech, o que os bancos e fintechs brasileiros andam publicando. O conteúdo existe, espalhado em blogs de engenharia, no Hacker News e no TabNews. O que me faltava era tempo pra ler tudo, escolher, escrever roteiro, gravar locução e editar vídeo toda semana.
O stackdigest faz esse trabalho. A cada 4 horas ele lê 26 fontes e dá uma nota de potencial viral pra cada notícia. De manhã escolhe as melhores, escreve o roteiro, manda outro modelo criticar, gera a locução e deixa um job na fila. Um PC em casa pega o job, renderiza o MP4 vertical e devolve pra nuvem. Eu só entro quando o sistema não tem certeza.
Este texto acompanha um item do feed RSS até o vídeo tocando no painel.
O desenho geral
São dois ambientes. Tudo que precisa rodar 24/7 vive num único Worker da Cloudflare. O render não cabe lá: gerar vídeo a partir de HTML exige Chrome headless e FFmpeg, então essa parte roda numa máquina local.
O repositório é um monorepo pnpm com quatro pacotes:
| Pacote | O que é |
|---|---|
apps/worker |
O Worker: crons, consumer da fila, Workflow, API da bridge e do painel |
apps/cockpit |
SPA React + Vite + TanStack Router/Query, servida como static assets do próprio Worker |
apps/render-worker |
Processo Node 22 que roda em casa e faz o render |
packages/shared |
Os tipos que os três compartilham, que funcionam como contrato do pipeline |
Esse último pacote foi a decisão que mais me poupou dor de cabeça. O SceneSpec que o Worker grava no R2 é o mesmo tipo que o render-worker lê. Se eu adiciono um campo numa cena e esqueço do outro lado, o TypeScript reclama antes do deploy.
1. Ingestão
As fontes ficam numa tabela do D1, fora do código. Cada uma tem um kind que escolhe o parser: rss (RSS 2.0 e Atom, com fast-xml-parser), hn_algolia, reddit_json e tabnews. Adicionar fonte é um INSERT, sem redeploy.
O cron dispara runIngestion, que busca tudo em paralelo com Promise.allSettled. Uma fonte fora do ar vira um console.warn e a rodada segue.
Antes de entrar no banco, cada URL perde utm_*, fbclid, fragmento e barra final, e o host vai pra minúsculo. O SHA-256 do resultado vira a chave primária:
export async function itemIdFromUrl(raw: string) {
const url = canonicalizeUrl(raw); // sem utm_, fbclid, #hash, trailing slash
return { id: await sha256Hex(url), url };
}
Com isso a deduplicação sai de graça: INSERT ... ON CONFLICT DO NOTHING. A mesma matéria aparecendo no Techmeme e no HN com parâmetros de tracking diferentes cai na mesma linha.
O bug que travou tudo
A primeira versão fazia um INSERT por item. Funcionou no teste local e quebrou em produção sem erro visível: nada era classificado e a seleção encontrava zero candidatos.
Alguns feeds (Vercel, OpenAI, Hugging Face) não devolvem só as últimas 20 entradas. Devolvem o arquivo histórico inteiro, uns 3.600 itens somando todos. Cada INSERT no D1 conta como um subrequest, e o Worker tem um teto de subrequests por invocação. A ingestão estourava esse teto no meio do loop e morria antes de chegar no sendBatch da fila. Os itens já inseridos ficavam com status new e ninguém os enfileirava de novo, então o sistema parava em silêncio.
A correção teve três partes:
- descartar itens com mais de 7 dias e limitar a 50 por fonte;
- agrupar os inserts com
db.batchem blocos de 40, que gastam um subrequest por bloco; - no fim de toda rodada, enfileirar todo item que ainda está
new, e não só os que acabaram de entrar.
A terceira é a que eu mais gosto. Se uma rodada falhar depois de inserir e antes de enfileirar, a próxima recupera os órfãos sozinha. O consumer ignora quem já foi classificado, então enfileirar repetido não custa nada.
2. Classificação
Os itens novos chegam no consumer da Queue em lotes de até 15. Cada lote vira uma chamada ao OpenRouter (Gemini 2.5 Flash por padrão, que é barato), com os 15 itens numerados no prompt. O modelo devolve JSON com categoria, relevância pro nicho, cinco fatores virais, um resumo de uma linha e um ângulo de gancho:
{
"category": "incident",
"relevance": 0.86,
"viral_factors": { "novelty": 0.9, "drama": 0.85, "scale": 0.8, "relatability": 0.7, "controversy": 0.4 },
"one_liner": "Cloudflare caiu por um regex com backtracking catastrófico",
"hook_angle": "Uma linha de regex derrubou metade da internet"
}
Se o lote falhar, batch.retryAll({ delaySeconds: 120 }). Depois de 3 tentativas a mensagem vai pra uma dead letter queue.
Nos primeiros testes eu usava a nota do LLM direto, e quase tudo saía entre 0.7 e 0.9. Modelo de linguagem acha qualquer coisa interessante. Então o viral_score final mistura o julgamento do modelo com sinais que ele não controla:
| Componente | Peso | De onde vem |
|---|---|---|
| LLM | 0.35 | média ponderada dos fatores × relevância |
| Tração | 0.30 | pontos por hora no HN, Reddit ou TabNews, em escala log |
| Categoria | 0.20 | tabela fixa: incident +3, marketing −3 etc. |
| Recência | 0.15 | decaimento exponencial com meia-vida de 48h |
Tração é o maior peso depois do LLM, de propósito. Se 300 pessoas votaram num post em 5 horas, isso diz mais sobre interesse real do que qualquer nota que o modelo invente. Post de blog sem contador de votos recebe 0.35 de tração, um valor neutro.
As duas curvas que seguram o otimismo do LLM
Recência cai pela metade a cada 48h. Tração cresce em log e satura perto de 30 pontos por hora. Passe o cursor.
- recência em 42h = 0.55
- tração com 30 pts/h = 1.00
O simulador abaixo roda a mesma função do score.ts e começa com o exemplo do JSON acima: um postmortem publicado no blog da Cloudflare há 10 horas, sem votos. Dá 66, acima do corte de seleção (62) e abaixo do corte de autopilot (78). Esse vídeo seria escrito, mas pararia pra eu aprovar.
Agora marque a caixa de sinal objetivo com 300 pontos. É a mesma história chegando pelo Hacker News, a 60 pontos por hora. A tração satura em 1.0 e o score vai pra 86. Mesma notícia, mesmo julgamento do LLM, e agora o sistema confia o bastante pra seguir sem mim.
Simulador do viral_score
A mesma fórmula do score.ts. Mexa nos sinais e veja onde o item cai em relação aos cortes padrão (62 e 78).
o que o LLM devolveu
category
sinais objetivos
3. Seleção
Todo dia às 11h UTC (8h em Brasília) o cron de seleção pega os itens classificados da última semana com score acima do corte, ordena e aplica três filtros:
- no máximo 2 por categoria, pra não virar um canal só de lançamento de modelo;
- no máximo 5 vídeos por rodada;
- se o título tem 60% ou mais de palavras relevantes em comum com um já escolhido, é a mesma história e fica de fora.
O dedup por sobreposição de título é simples e às vezes erra. Com o volume atual, dá conta. Dedup semântico com embeddings está no roadmap.
Cada item escolhido vira uma linha em videos e uma instância de Workflow, e o id da instância é o id do vídeo:
await env.VIDEO_WF.create({ id: videoId, params: { videoId, itemId: item.id } });
Parece detalhe, mas é isso que deixa o botão de aprovar do painel achar o workflow certo sem tabela de mapeamento.
4. O VideoWorkflow
Essa parte usa Cloudflare Workflows, que é execução durável: o resultado de cada step.do fica persistido. Se o OpenRouter cair no meio da revisão, o workflow retoma da revisão com o roteiro que já tinha, sem pagar de novo pela chamada anterior. Os passos que chamam API externa têm 4 retries com backoff exponencial começando em 30 segundos.
Roteiro. O prompt pede uma voz de “engenheiro contando um causo pro colega no café”. A saída é JSON: um hook, de 4 a 7 cenas e duração alvo de 30 a 75 segundos. Cada cena tem um type de um catálogo fechado e um campo vo com a narração. A função validateScript rejeita roteiro com menos de 3 cenas, cena sem narração ou tipo fora do catálogo, e o erro dispara o retry do passo.
Revisão. Um segundo prompt faz o papel de crítico. Ele devolve notas de força do hook, clareza, risco factual e risco de retenção, mais um veredito. As regras são objetivas: hook abaixo de 0.6 pede reescrita, risco factual acima de 0.6 manda pra humano. O hook pode ser reescrito uma vez. Se a segunda revisão ainda reclamar, o roteiro segue com review_pass = false e cai no portão.
Portão de autonomia. O painel tem três modos. full só para o que a revisão reprovou. hitl para tudo. borderline, o padrão, para o que foi reprovado ou tem score abaixo de 78. Um veredito needs_human para em qualquer modo, porque não quero vídeo com fato técnico inventado saindo sem alguém ler.
Quem decide se precisa de humano
Combine o modo do cockpit, o veredito do revisor e o score do item.
autonomy_mode
verdict do revisor
Score 70 abaixo do auto_threshold (78).
Quando precisa de aprovação, o workflow chama step.waitForEvent('approval', { timeout: '7 days' }) e dorme. O 👍 no painel faz instance.sendEvent(...) e pode mandar junto um hook reescrito por mim. Se ninguém responder em uma semana, o vídeo é descartado: notícia de tech com uma semana já esfriou.
Incremento. Se o veredito foi approve_with_edits, um último passo reescreve o roteiro aplicando as críticas, com a instrução explícita de não adicionar fato que não esteja no material-fonte.
Empacotamento. A narração de cada cena vai pra ElevenLabs separadamente e cada MP3 vira um objeto no R2. Cada cena é um step.do próprio, então se a cena 4 falhar as três primeiras não são cobradas de novo. O TTS fica atrás de uma interface TTSProvider, pra eu poder trocar por Workers AI se a qualidade em pt-BR melhorar. No fim o workflow monta o SceneSpec, grava em specs/{id}.json e cria a linha em render_jobs.
Toda chamada de LLM e de TTS grava um registro na tabela traces com modelo, entrada, saída e custo. O OpenRouter devolve o custo real da chamada quando a requisição leva usage: { include: true }, então o painel mostra quanto cada vídeo custou de verdade. A meta é ficar abaixo de US$ 0,15 por vídeo em LLM.
5. A ponte até a máquina de render
Aqui tive que mudar o plano original. A ideia era publicar o job numa Cloudflare Queue e o PC consumir de lá. Só que Queues só entregam mensagem pra Workers. Não existe consumer HTTP pra um processo qualquer.
A fila de render virou a própria tabela render_jobs no D1, e o PC busca trabalho por HTTP. O claim precisa ser atômico pra duas máquinas nunca pegarem o mesmo job, e o SQLite resolve isso numa instrução só:
UPDATE render_jobs
SET status = 'claimed', claimed_by = ?1, claimed_at = ?2,
heartbeat_at = ?2, attempts = attempts + 1
WHERE job_id = (
SELECT job_id FROM render_jobs
WHERE status = 'queued' ORDER BY created_at LIMIT 1
)
RETURNING job_id, video_id, spec_key
Duas decisões nesse protocolo:
O PC não tem credencial do R2. Spec, áudio e MP4 passam pelo Worker, autenticados por um RENDER_TOKEN. Se a máquina for comprometida, o estrago fica limitado ao que a bridge expõe.
A máquina pode morrer a qualquer momento. É um PC de casa: desliga, reinicia pra atualizar, perde a internet. Cada job tem heartbeat_at, e todo claim começa varrendo jobs em claimed ou rendering sem heartbeat há mais de 10 minutos, que voltam pra fila. Depois de 3 tentativas o job vira failed e sai da roda. Com o PC desligado, os jobs só esperam: a nuvem continua produzindo e o painel mostra quantos estão parados.
Simulador da fila de render
Você é a máquina local. Desligue ela no meio de um render e peça outro claim.
- Workflow criou o job → queued
6. Render
O HyperFrames é um framework open source da HeyGen que transforma uma página HTML em MP4. Ele abre a página num Chrome headless, avança a timeline frame a frame e encoda com FFmpeg. Mesmo HTML, mesmo vídeo.
A tentação era deixar o LLM escrever esse HTML. Não deixei. Em autopilot, HTML livre gerado por modelo quebra de jeitos imprevisíveis: elemento fora da tela, animação que não termina, texto estourando o card. O LLM só escolhe entre sete tipos de cena e preenche os campos. O render-worker tem um template fixo pra cada tipo, testado uma vez.
Catálogo de cenas
O LLM escolhe o type e preenche os campos. O HTML sai sempre do mesmo template.
{
"type": "headline",
"tag": "incidente",
"text": "Um regex derrubou a Cloudflare",
"vo": "Uma linha de regex tirou metade da internet do ar."
}O render de um job segue esta ordem:
- Baixa os MP3s e mede cada um com
ffprobe. Cada cena dura o tempo real da narração mais meio segundo, com mínimo de 2,5s. O roteiro estima “uns 58 segundos”, mas quem manda é o áudio que a ElevenLabs gerou. - Monta a composição. Cada cena vira um
<div>comdata-startedata-durationcalculados no passo anterior. As animações ficam numa timeline GSAP pausada que o HyperFrames controla. A narração também aparece como legenda queimada, porque muita gente assiste short sem som. hyperframes rendergera um MP4 mudo de 1080×1920 a 30 fps.- FFmpeg junta o áudio. Cada narração entra com um
adelayigual ao início da sua cena, e tudo é misturado comamix. Se houver trilha de fundo configurada, ela entra em loop com volume 0.15. O vídeo é copiado sem reencode. - Upload em stream pro Worker, que grava no R2, e depois
complete.
O render-worker roda como unit do systemd no Fedora ou como tarefa agendada no logon do Windows. O Windows trouxe uma surpresa: lá o npx é um .cmd, e o execFile do Node não executa .cmd sem shell: true. O FFmpeg é .exe e funciona sem shell. Liguei o shell só na chamada do HyperFrames e passei a pôr aspas à mão nos argumentos com espaço.
7. Serve: um Worker, uma URL
O painel não tem hospedagem separada. O build do Vite vai pra apps/cockpit/dist e o Worker serve como static assets:
"assets": {
"directory": "../cockpit/dist",
"not_found_handling": "single-page-application",
"run_worker_first": ["/api/*"]
}
Só /api/* passa pelo código do Worker. O resto vai direto pros assets, e rota desconhecida devolve o index.html pro TanStack Router resolver no cliente. Um wrangler deploy publica API, painel, crons, consumer e Workflow juntos.
O painel tem quatro telas:
- Feed: itens da semana ordenados por score, com o detalhamento de cada componente.
- Vídeos: status de cada workflow, roteiro, crítica do revisor, traces com custo e os botões de aprovar ou rejeitar, com edição do hook.
- Render: jobs na fila, progresso, último heartbeat da máquina e o player do MP4 pronto.
- Config: cortes de score, modo de autonomia, modelo de cada estágio e voz do TTS. Fica tudo no KV, então mudar não exige deploy.
O vídeo sai do R2 pelo mesmo Worker, em /api/media/videos/{id}.mp4. Como <video src> não manda header Authorization, a autenticação aceita o token também por query string. Hoje é um token único, porque só eu uso. O middleware está isolado o bastante pra trocar por BetterAuth sem mexer nas rotas.
O ciclo de vida de um vídeo, do jeito que o painel enxerga:
O que ainda falta
- O digest de sexta. O cron existe e só escreve no log. A ideia é um vídeo com as 5 histórias da semana.
- Publicação automática. O sistema entrega MP4 e legenda prontos. Postar no Shorts, Reels e TikTok ainda é manual, porque cada plataforma tem seu próprio processo de OAuth.
- Dedup semântico. A sobreposição de título não percebe que “Cloudflare outage” e “CF caiu ontem” são a mesma história.
- Fechar o loop. Os pesos do score são heurística que ajustei no olho. Com dados de retenção dos vídeos publicados, eles poderiam sair de uma regressão.
O que eu levaria pra outro projeto
Não confie só na nota do LLM. Um modelo dando nota de 0 a 1 tende a dar nota alta pra tudo. Misturar essa nota com um sinal externo que o modelo não controla, mesmo que seja só uma contagem de votos, melhorou a seleção mais do que qualquer ajuste no prompt.
Deixe o LLM escolher e o código executar. O modelo escolhe entre sete tipos de cena e nunca escreve o HTML. É isso que deixa o render previsível o bastante pra rodar sem ninguém olhando.
Projete pra recuperação. O enfileiramento que recupera itens órfãos, o heartbeat que devolve job abandonado pra fila e os passos duráveis do Workflow resolvem o mesmo problema: alguma coisa vai cair no meio do caminho, e o sistema precisa continuar de onde parou sem eu descobrir três dias depois.