Portal de notícias do universo nerd (games, cinema/séries, anime/mangá, HQs, tech, eventos) com um pipeline de curadoria automática que monitora fontes externas, calcula um score de popularidade de 0 a 100 para cada assunto e aciona a redação quando algo começa a explodir.
Estado atual: esqueleto funcional completo. O motor de score, o pipeline de conectores, o modelo de dados e as páginas principais estão implementados e testados. Os conectores externos operam em modo simulado até que as credenciais sejam configuradas — de propósito (ver Conectores).
- A ideia em uma página
- Como rodar localmente
- Deploy em produção (VPS)
- Arquitetura
- O algoritmo de score
- O pipeline de curadoria
- Conectores de sinal
- Modelo de dados
- Front-end e SEO
- Funil de audiência
- KPIs e instrumentação
- Segurança
- Decisões de arquitetura (ADRs)
- Plano de implementação em fases
- Pendências e decisões em aberto
Portais de nicho competem por minutos. Quando a Rockstar anuncia um atraso de GTA VI, quem publica primeiro leva a maior parte do tráfego orgânico e social; quem publica em quarto lugar leva uma fração.
A Ortus Pixel ataca isso com um serviço que roda continuamente e responde três perguntas:
| Pergunta | Resposta do sistema |
|---|---|
| O que está acontecendo agora? | Descoberta via feeds RSS de estúdios, publishers e imprensa |
| Isso vai bombar? | Score de 0 a 100 combinando 9 sinais, com peso maior para aceleração |
| O que a redação faz com isso? | Faixas de ação: alerta imediato, meta de publicação, push, posição na home |
A decisão de produto mais importante: o sinal de maior peso é a velocidade de crescimento do interesse, não o volume absoluto. Volume alto todo mundo enxerga — a vantagem competitiva está em detectar aceleração antes dos outros.
A segunda decisão mais importante: o algoritmo nunca decide sozinho. Ele prioriza; quem publica é gente. Override humano sempre vence, e temas sensíveis (vazamento, polêmica, morte de personagem) bloqueiam qualquer automação.
- Node.js 20.9+ (o projeto foi desenvolvido e testado no Node 24)
- PostgreSQL 16+ — via Docker ou instalação local
- Redis é opcional no MVP (ver Dependências opcionais)
# 1. Instalar dependências (npm workspaces resolve o monorepo inteiro)
npm install
# 2. Subir Postgres e Redis
docker compose up -d
# 3. Configurar ambiente
cp .env.example .env
# O .env.example já vem com valores que funcionam com o docker-compose.
# Nenhuma chave de API é necessária para rodar.
# 4. Criar o schema no banco e gerar o client
npm run db:generate
npm run db:push
# 5. Popular com dados realistas (categorias, franquias, artigos, scores)
npm run db:seed
# 6. Subir o site
npm run devAbra http://localhost:3000.
# Um ciclo e encerra — ideal para ver o que acontece
npm run curator:once
# Modo contínuo (descoberta a cada 15 min, recálculo a cada 5 min)
npm run dev:curatorSem credenciais de API, os conectores externos entram em modo simulado: geram sinais sintéticos determinísticos e com confiança baixa. O pipeline roda de ponta a ponta, o painel editorial se popula e nada quebra.
http://localhost:3000/admin — entra-se com conta individual (e-mail e senha).
Na primeira vez, crie a conta de administrador pelo terminal:
npm run staff:create -- --email=voce@dominio.com --nome="Seu Nome"
npm run staff:create -- --listar # quem tem acesso hojeO comando pede a senha sem ecoar na tela. Depois disso, as demais contas são criadas
pela própria tela Contas do painel. Não existe rota pública de setup, de propósito —
o racional está no cabeçalho de packages/db/scripts/staff-account.ts.
Dois níveis de acesso, definidos em packages/core/src/staff.ts:
| Admin | Redator | |
|---|---|---|
| Criar/editar matéria | tudo | só as que assina |
| Fila de pautas | ver, assumir, sobrepor score, descartar | ver e assumir |
| Comentários, afiliados, relatórios, contas | sim | não |
Cada pauta da fila tem, ao lado de Criar matéria, o botão Gerar sugestão com IA. Ele chama a API da Anthropic com o que o tópico já sabe (título, resumo, fonte, nível de autoridade da fonte, editoria e franquias) e devolve um rascunho — manchete, resumo, corpo em blocos, TL;DR e a lista do que ainda falta apurar. O rascunho abre no mesmo formulário de sempre, com tudo editável; publicar continua sendo um clique humano.
ANTHROPIC_API_KEY="sk-ant-..." # sem ela, o botão não aparece e nada quebra
ANTHROPIC_MODEL="" # vazio = claude-sonnet-5Três limites que são de projeto, não de implementação:
- Não gera nem busca imagem. A capa continua sendo escolha humana (upload ou URL).
- Não apura nada. A página da fonte NUNCA é baixada — seria SSRF com URL de terceiro, e reescrever a matéria alheia inteira não é apuração. O prompt exige atribuição (“segundo o veículo”), proíbe inventar dado específico e proíbe fala entre aspas.
- Nunca publica. Nasce rascunho, com aviso no topo do formulário, e a matéria fica
marcada com
contentOrigin = 'ai-assisted'(packages/core/src/content-origin.ts) para auditoria. A geração em si vira uma linhatopic.ai_suggestionnoAuditLog.
Custo da ordem de US$ 0,02 por rascunho, com teto de tokens por chamada e limite de 8
gerações por conta a cada 10 minutos. Detalhes e racional em apps/web/src/server/ai-draft.ts.
npm run typecheck # verificação de tipos em todos os workspaces
npm test # testes do motor de score, da deduplicação e da apresentação
npm run check:classes # markup servido × folha do design (precisa do dev server no ar)
npm run db:studio # inspeção visual do banco (Prisma Studio)Sobre o check:classes: o produto não escreve CSS próprio — ele carrega a folha do
design system. Isso mantém protótipo e produto alinhados e cria exatamente um risco:
escrever uma classe que não existe é um erro silencioso. Não há falha de build nem
aviso no console; o elemento só renderiza sem o estilo que deveria ter. Este script busca
as páginas no servidor de desenvolvimento e falha se alguma classe do HTML não existir no
CSS — inclusive as montadas em tempo de execução, que são justamente as que erram. Sem
servidor no ar, npm run check:classes -- --skip-runtime roda só as checagens estáticas.
| Serviço | Necessário? | O que acontece sem ele |
|---|---|---|
| PostgreSQL | Sim | Nada funciona |
| Redis | Não (MVP) | O agendador roda em processo; rate limit e orçamento de API ficam em memória. Necessário ao escalar para múltiplas réplicas |
| Chaves de API | Não | Conectores em modo simulado, com confiança reduzida |
| Provedor de e-mail | Não | E-mails são impressos no terminal (EMAIL_PROVIDER=console) |
| Chaves VAPID | Não | O opt-in de push não é oferecido |
Essa tolerância a ausência de configuração é intencional: as APIs de sinal são caras, restritas (o Reddit leva de 2 a 4 semanas para aprovar acesso comercial) ou ainda em alpha (Google Trends). Amarrar o desenvolvimento à disponibilidade delas travaria o time.
Alvo: VPS Ubuntu/Debian da Hostinger, domínio ortuspixel.com.
Quatro escolhas foram fechadas pelo dono do site. O resto desta seção assume todas elas.
| Decisão | Escolha | Consequência prática |
|---|---|---|
| Execução do app | PM2 no VPS | Docker é a opção B, documentada para quando houver escala |
| Banco de dados | Postgres gerenciado (Hostinger ou outro provedor) | O VPS não roda Postgres — nem nativo, nem em container. sslmode=require é obrigatório |
| Schema | Migrations versionadas | prisma migrate deploy a partir de 20260806120000_init. db push saiu do fluxo de produção |
| Domínio canônico | ortuspixel.com (apex, sem www) |
www existe no DNS e redireciona 301 por HTTPS |
O VPS, portanto, tem uma responsabilidade só: rodar a aplicação e o Nginx. Estado persistente vive fora dele. Isso melhora três coisas de uma vez — backup e point-in-time recovery passam a ser problema do provedor, recriar o VPS deixa de ter risco de perda de dados, e o recurso mais escasso da máquina (RAM) fica inteiro para o Node.
Sobre os nomes internos. Os pacotes ainda se chamam
@subcarioca/*— o rebrand do código-fonte é uma tarefa separada. Os artefatos de deploy (domínio, nomes de processo, nomes de container, URLs de callback) já usam a marca final, porque trocá-los depois significaria reemitir certificado, reeditar o Nginx, refazer opm2 savee recadastrar os redirect URIs de OAuth. Trocar o nome do pacote npm não afeta nenhum desses arquivos.
No painel de DNS do registrador do ortuspixel.com, aponte dois registros para o IP
do VPS:
| Tipo | Nome | Valor | TTL |
|---|---|---|---|
A |
@ (ou ortuspixel.com) |
IP IPv4 do VPS | 3600 |
A |
www |
IP IPv4 do VPS | 3600 |
O www precisa existir mesmo sendo redirecionado: sem registro, quem digitar
www.ortuspixel.com recebe erro de DNS — e o certificado não pode ser emitido para um
nome que não resolve. Se o VPS tiver IPv6, acrescente os AAAA equivalentes.
Antes de seguir, confirme a propagação (pode levar de minutos a algumas horas):
dig +short ortuspixel.com
dig +short www.ortuspixel.comAmbos usam o mesmo Nginx — os dois entregam a aplicação em 127.0.0.1:3000 —, então
migrar de um para o outro no futuro não exige refazer TLS, DNS nem o Nginx.
| Caminho A — PM2 (escolhido) | Caminho B — Docker | |
|---|---|---|
| Arquivos | ecosystem.config.js, scripts/deploy.sh |
Dockerfile, docker-compose.prod.yml |
| Postgres | gerenciado, fora do VPS | gerenciado ou container (--profile do serviço) |
| Deploy | ./scripts/deploy.sh |
docker compose --env-file .env.production -f docker-compose.prod.yml up -d --build |
O trade-off, em quatro linhas: o PM2 usa menos RAM (não há camada de virtualização),
faz deploy mais rápido (rebuild incremental do Next em vez de imagem nova) e é mais simples
de depurar — mas o VPS vira um floco de neve, com Node e Nginx instalados à mão, e
"funciona na minha máquina" volta a ser possível. O Docker entrega ambiente reproduzível e
rollback trivial (docker compose up -d apontando para a imagem anterior), ao custo de
~300 MB de RAM, builds mais lentos e uma camada a mais para diagnosticar. Para o
lançamento venceu o caminho A: o projeto tem um processo de cada tipo, e o principal
argumento do Docker — isolar o estado — perdeu força quando o banco saiu do VPS.
Se você usar o caminho B: o serviço
postgresdodocker-compose.prod.ymlestá num perfil desativado (local-db) justamente para não subir por acidente. Ouppadrão levanta sówebecurator, apontando para o banco gerenciado. Subir o container junto com o banco gerenciado seria o pior dos mundos: dois bancos no ar e o risco real de a aplicação gravar no errado.
O VPS não roda Postgres. No painel do provedor:
- Crie a instância Postgres (16+) e o banco
ortuspixel. - Restrinja o acesso ao IP do VPS. Quase todo provedor bloqueia por padrão, mas
confira: um Postgres aberto para
0.0.0.0/0é varrido por bots em horas. - Copie a string de conexão e acrescente
sslmode=require:
postgresql://USUARIO:SENHA@HOST_DO_PROVEDOR:5432/ortuspixel?schema=public&sslmode=require
sslmode=require é obrigatório e é a diferença central em relação ao ambiente local:
aqui o tráfego sai do VPS e atravessa a rede do provedor. Sem TLS, a senha e o conteúdo
das consultas viajam em texto claro. Se o provedor oferecer a CA, prefira
sslmode=verify-full — require cifra, mas não prova com quem você está falando, o que
ainda deixa espaço para um intermediário.
# Node 22 LTS (o projeto exige >= 20.9)
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt-get install -y nodejs nginx
sudo npm install -g pm2
# Firewall: só SSH e HTTP(S) entram. A porta 3000 NUNCA é aberta — o Next
# escuta em 127.0.0.1 e quem fala com a internet é o Nginx.
sudo ufw allow OpenSSH && sudo ufw allow 'Nginx Full' && sudo ufw enableNão há Postgres para instalar, e a porta 5432 não é aberta em nenhuma direção: quem inicia a conexão é o VPS.
Se um dia adotar Docker aqui: ele escreve regras de
iptablesque passam por cima do ufw. É por isso que odocker-compose.prod.ymlnão publica porta de banco e amarra a do site em127.0.0.1.
git clone <url-do-repo> /var/www/ortuspixel && cd /var/www/ortuspixel
cp .env.production.example .env.production
nano .env.production # preencha (o arquivo explica cada variável)
chmod 600 .env.production # obrigatório: contém a senha do banco e o token do painel
chmod +x scripts/deploy.sh
./scripts/deploy.sh # o primeiro deploy usa o mesmo comando dos demaisO script para na primeira etapa que falhar e não reinicia nada depois disso — se o build quebrar, a versão anterior continua no ar.
Migrations versionadas. A migration inicial (
packages/db/prisma/migrations/20260806120000_init) já está no repositório e cobre as 28 tabelas, 68 índices e 28 chaves estrangeiras. Num banco vazio,prisma migrate deploycria o schema inteiro — inclusive no primeiro deploy.db pushsaiu do fluxo de produção: ele resolve certas mudanças destruindo dados (renomear coluna vira "apaga a antiga, cria a nova, vazia"), não guarda histórico e não é reversível. Odeploy.shainda aceita--db=pushcomo escape de emergência, mas grita antes; se ele parecer necessário, o problema real costuma ser outro — alguém alterou oschema.prismasem gerar a migration correspondente.
Ao alterar o schema daqui em diante, gere a migration em desenvolvimento e commite-a junto com a mudança:
npx prisma migrate dev --name descricao-curta --schema=packages/db/prisma/schema.prisma
git add packages/db/prisma/migrationsO deploy aplica sozinho o que estiver pendente. migrate deploy é seguro de rodar
repetidamente: sem migration pendente, ele não faz nada.
A ordem importa: o nginx/ortuspixel.conf referencia arquivos de certificado que ainda não
existem, então instalá-lo antes de emitir o certificado faz o nginx -t falhar.
sudo apt-get install -y certbot python3-certbot-nginx
sudo mkdir -p /var/www/certbot
# 1) Emite o certificado para os DOIS nomes (com o Nginx ainda no default).
# `certonly` = só emite, não reescreve config — assim nosso arquivo continua
# sendo a fonte da verdade e não é editado por robô.
sudo certbot certonly --nginx \
-d ortuspixel.com -d www.ortuspixel.com \
--agree-tos -m contato@ortuspixel.com --no-eff-email
# 2) Só agora instala a configuração do site.
sudo cp nginx/ortuspixel.conf /etc/nginx/sites-available/ortuspixel.conf
sudo ln -s /etc/nginx/sites-available/ortuspixel.conf /etc/nginx/sites-enabled/
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t && sudo systemctl reload nginx
# 3) Confirme que a renovação automática funciona (não renova de verdade).
sudo certbot renew --dry-runO certificado cobre os dois nomes porque o www é redirecionado por HTTPS: um
certificado só para o apex mostraria aviso de segurança em www antes mesmo do redirect.
contato@ortuspixel.comé placeholder. Troque pelo endereço real antes de rodar — é para ele que o Let's Encrypt escreve quando a renovação falha, com 20 dias de antecedência. Um e-mail que ninguém lê transforma um aviso em queda do site por certificado vencido, num sábado, sem ninguém ter mudado nada.
Os dois provedores exigem que a URI de retorno seja cadastrada exatamente, caractere
por caractere — sem barra no final, com https, no domínio canônico (apex, sem www).
Divergir de NEXT_PUBLIC_SITE_URL produz redirect_uri mismatch.
| Provedor | Onde | O que cadastrar |
|---|---|---|
| Discord | Developer Portal → sua aplicação → OAuth2 → Redirects | https://ortuspixel.com/api/auth/discord/callback |
| Cloud Console → Criar credenciais → ID do cliente OAuth → Aplicativo da Web | Origens JavaScript autorizadas: https://ortuspixel.comURIs de redirecionamento autorizados: https://ortuspixel.com/api/auth/google/callback |
Escopos: Discord identify, Google openid profile. Não peça e-mail — o projeto não
o armazena, e pedir um escopo que não se usa reduz a taxa de aceite na tela de consentimento.
No Google, enquanto o app estiver em modo Testing, só contas listadas conseguem entrar; publicar exige preencher a tela de consentimento. Como não pedimos escopos sensíveis, a verificação é simples, mas não é instantânea — comece antes do lançamento.
pm2 list # estado dos processos
pm2 logs ortuspixel-web --lines 100
pm2 logs ortuspixel-curator
pm2 monit # CPU e memória em tempo real
sudo pm2 startup # registra no systemd (executar UMA vez)Rodar pm2 startup uma vez é o que faz o site voltar sozinho depois de um reboot do VPS.
Sem ele, um reinício deixa tudo fora do ar até alguém entrar por SSH.
Nada aqui é opcional. Os quatro primeiros itens são bloqueadores de lançamento.
-
robots.tstravado no domínio antigo — BLOQUEADOR, CORRIGIDO no rebrand. A rota decidia se permite indexação comNEXT_PUBLIC_SITE_URL?.includes('canalnerd.com.br'). Com o domínio novo essa condição seria falsa, e o site entraria no ar servindoDisallow: /para todos os buscadores — silenciosamente, sem erro em log nenhum. O sintoma seria "o site está no ar há três semanas e não indexou nada". A condição agora descreve o estado real ("produção + host público HTTPS não-local") e não cita domínio nenhum, então trocar de domínio não volta a quebrar isso. Ainda assim, verifiquecurl https://ortuspixel.com/robots.txtantes de divulgar o site — é um teste de 2 segundos contra um erro de 3 semanas. - Conta de administrador criada NO SERVIDOR, com senha forte. O painel não tem
mais segredo compartilhado em variável de ambiente: rode
npm run staff:create -- --email=... --nome="..."depois do deploy. Enquanto não existir nenhuma conta, ninguém entra — o que é a falha segura correta, mas também significa que este passo não pode ser esquecido. -
REVALIDATE_SECRETeNEWSLETTER_TOKEN_SECRETgerados novos. Mesmo raciocínio: os placeholders são públicos. Com o primeiro, um terceiro força invalidação de cache à vontade e derruba o site sob pico. - Backup do banco verificado e testado. Com banco gerenciado, o provedor já faz
snapshots — confirme no painel a frequência e a janela de retenção, porque o
padrão costuma ser curto (7 dias) e um erro de dados descoberto na segunda semana
não teria para onde voltar. Mantenha também um
pg_dumppróprio: snapshot do provedor não protege contra encerramento de conta nem contraDELETEacidental descoberto tarde.bash # exemplo, via cron às 3h, a partir do VPS ou de outra máquina pg_dump "$DATABASE_URL" | gzip > /backup/ortuspixel-$(date +\%F).sql.gzE o passo que quase todo mundo pula: faça um restore de teste. Backup nunca restaurado não é backup, é esperança. (O destino do dump ainda não foi definido — ver "Pendências de operação".) -
ads.txtcom o Publisher ID real. PreenchaADSENSE_PUBLISHER_ID(pub-..., sem o prefixoca-) eNEXT_PUBLIC_ADSENSE_CLIENT_ID(ca-pub-...). Enquanto estiverem vazios, nenhum anúncio é renderizado e/ads.txtresponde 404 — ambos de propósito. Depois do deploy, confirme:curl https://ortuspixel.com/ads.txt. - CSP escrita, com os domínios de
ortuspixel.com. Segue pendente (item 3 de Segurança). Ela deve entrar nonext.config.ts, não no Nginx — precisa do hash do script inline de tema, que só o build conhece. Suba primeiro emContent-Security-Policy-Report-Onlypor duas semanas: CSP de anúncio costuma quebrar só em um formato de criativo, semanas depois. -
NEXT_PUBLIC_SITE_URL=https://ortuspixel.com, sem barra no final e semwww. Odeploy.shvalida as três coisas e aborta se estiverem erradas. - Chave do IndexNow publicada. Além de definir
INDEXNOW_KEY, hospede{CHAVE}.txtna raiz do site com a própria chave como conteúdo. Sem esse arquivo, os envios são recusados em silêncio. - Domínio de e-mail verificado no provedor, com SPF, DKIM e DMARC no DNS. Sem isso, a confirmação de newsletter cai em spam e a reputação do domínio novo é queimada na primeira campanha.
-
chmod 600 .env.productione dono correto. O padrão docpé 644 — qualquer usuário do VPS leria a senha do banco. - Search Console e sitemap. Cadastre a propriedade
https://ortuspixel.come enviehttps://ortuspixel.com/sitemap.xml. - Conexão com o banco em TLS. Confirme que a
DATABASE_URLtermina comsslmode=require(ouverify-full) e que só o IP do VPS está liberado no painel do provedor. - Migrations aplicadas.
npx prisma migrate statusdeve responder "Database schema is up to date" apontando para o banco de produção. - Teste do fluxo completo em produção: comentar com Discord e com Google, inscrever-se na newsletter e confirmar pelo e-mail recebido.
Três pontos ainda dependem de decisão do dono do site. Nenhum impede subir a infraestrutura, mas todos precisam de resposta antes de divulgar o site.
| Pendência | O que está valendo hoje | O que muda quando decidir |
|---|---|---|
| Endereços de e-mail reais | contato@ortuspixel.com e noticias@ortuspixel.com estão como placeholder no .env.production.example e no comando do Certbot |
Ajustar NEWSLETTER_FROM, VAPID_SUBJECT, o -m do Certbot e o REDDIT_USER_AGENT. As caixas precisam existir e ser lidas: é para elas que vão os avisos de expiração de certificado e os problemas de entrega de push |
Restrição de IP no /admin |
Só rate limit (2 req/s por IP) no nginx/ortuspixel.conf |
Se a redação tiver IP fixo, acrescentar allow/deny no bloco location /admin. Não foi presumido: um deny errado tranca a própria equipe do lado de fora |
| Destino do backup | Não configurado | Definir onde o pg_dump é guardado (bucket S3/R2, outro provedor) e criar o cron. A regra que importa: não pode ser o mesmo provedor do banco — senão um incidente de conta leva banco e backup juntos |
Registradas para não virarem descoberta cara depois:
- Revalidação de cache dá a volta pela internet. O curator monta a chamada a
/api/revalidatea partir deNEXT_PUBLIC_SITE_URL, então o pedido sai do VPS, volta pelo Nginx e paga TLS — para dois processos vizinhos. Funciona, mas uma variável de URL interna (http://127.0.0.1:3000no PM2,http://web:3000no Docker) eliminaria o desvio. Exige mudança de código no curator, por isso não foi feita aqui. - Uma instância do site, modo
fork.instances: 'max'só é seguro depois do Redis para rate limit distribuído e de umcacheHandlercompartilhado — hoje N processos multiplicariam o limite de login por N e revalidariam a mesma página em duplicidade. O racional completo está emecosystem.config.js. - Sem CDN na frente. O Nginx serve tudo. Para um lançamento está correto; num breaking news com dezenas de milhares de leitores simultâneos, colocar um CDN à frente do domínio é a primeira alavanca — e o cache de duas velocidades já foi desenhado para isso.
- Latência até o banco gerenciado. Cada consulta agora atravessa a rede, em vez do soquete local. Some ~1–3 ms por consulta se o banco estiver na mesma região do VPS — irrelevante. Em regiões diferentes vira 30–80 ms por consulta e, numa página que faz várias, o efeito é visível. Provisione banco e VPS na mesma região; se não for possível, o caminho é reduzir o número de idas ao banco (menos consultas por página), não trocar de arquitetura.
- Sem pool de conexões externo. O Prisma mantém o seu próprio pool, o que basta para um processo. Se um dia houver várias réplicas, bancos gerenciados têm limite de conexões relativamente baixo e o caminho é um PgBouncer (ou o pooler do provedor).
FONTES EXTERNAS
RSS (estúdios, imprensa) · Google Trends · Reddit · YouTube · X
|
v
+-------------------------------+
| services/curator | Node + TypeScript
| |
| descoberta -> dedupe -> |
| triagem -> corte -> |
| enriquecimento -> ações |
+-------------------------------+
| |
grava scores | | invalida cache (webhook)
v v
+-----------------+ +--------------------------+
| PostgreSQL | | apps/web (Next.js) |
| |<--| site + painel editorial |
+-----------------+ +--------------------------+
|
v
CDN (páginas estáticas)
R:\Claudio
├── packages/
│ ├── core/ Tipos, contratos e vocabulário de domínio (zero dependências)
│ ├── scoring/ Motor de score — função PURA, testável, sem I/O
│ └── db/ Schema Prisma, client e seed
├── services/
│ └── curator/ Serviço de curadoria
│ ├── connectors/ Conectores plugáveis de sinal
│ ├── discovery/ Descoberta de tópicos via RSS
│ ├── pipeline/ Orquestrador, deduplicação, ciclo principal
│ └── actions/ Alertas, invalidação de cache, instrumentação
├── apps/
│ └── web/ Next.js 15 (App Router) — site público + painel
└── design/ Protótipos de alta fidelidade (entregues pelo time de design)
As decisões de arquitetura estão consolidadas na seção ADRs deste documento, e o racional detalhado de cada uma vive em comentário no próprio arquivo que a implementa — perto de onde importa.
| Módulo | Responsabilidade única | Por que isolado |
|---|---|---|
core |
Vocabulário compartilhado | Sem dependências — pode ser importado por qualquer camada, inclusive pelo navegador |
scoring |
Calcular score | É função pura: testável sem mocks e reexecutável sobre o histórico para recalibração |
db |
Persistência | Isola o Prisma; a UI conversa com tipos de domínio, não com linhas de banco |
curator |
Coletar e decidir | É um processo longevo, com ciclo de vida próprio, que escala separado do site |
web |
Renderizar e converter | Escala com a CDN; não pode ser derrubado por um laço de coleta |
A regra que sustenta tudo: core e scoring não conhecem nem banco nem rede. É isso
que permite testar o algoritmo sem infraestrutura e reprocessar meses de histórico com
pesos novos.
| Sinal | Peso | Racional |
|---|---|---|
| Aceleração de busca | 0,26 | Único sinal que chega antes da concorrência. É o produto |
| Repercussão em redes | 0,16 | Onde o fandom vive; acende antes do volume de busca |
| Autoridade da fonte | 0,12 | Separa fato publicável de rumor que exige apuração |
| Volume de busca | 0,10 | Baixo de propósito: volume sem aceleração é interesse permanente, não notícia |
| Trending nativo | 0,10 | Alta qualidade quando presente, mas esparso |
| Janela de SERP | 0,09 | Chegar em 4º numa notícia saturada rende uma fração de chegar em 1º |
| Afinidade da audiência | 0,08 | Sinal proprietário — nenhum concorrente consegue reproduzir |
| Proximidade de lançamento | 0,07 | Amplificador de contexto |
| Gatilho emocional | 0,02 | Quase irrelevante no número de propósito (ver abaixo) |
Por que gatilho emocional pesa 0,02: peso alto transformaria o sistema numa máquina de caçar tragédia e polêmica — ótimo para clique imediato, destrutivo para marca e retenção. O papel desses gatilhos é levantar a mão para um humano, não inflar a nota.
O briefing pede um score de 0 a 100. Ao modelar os sinais, ficou claro que um número só não resolve, por causa do item "cauda longa vs. cabeça":
score(urgência) → "isso precisa ir ao ar agora?" Define faixas, push e home.seoOpportunity(valor de pauta) → "vale escrever, mesmo sem pressa?"
Um tópico com score 35 e SEO 82 não é lixo: é uma excelente pauta de cauda longa que simplesmente não é breaking news. Com um número só, ele seria descartado — e é justamente esse tipo de conteúdo que sustenta o tráfego orgânico entre um breaking e outro.
| Faixa | Score | Meta | Automação | Home |
|---|---|---|---|---|
| QUENTE / BREAKING | 80–100 | 30 min | Alerta imediato + candidato a push | Hero |
| EM ALTA | 60–79 | poucas horas | — | Seção Trending |
| RELEVANTE | 40–59 | sem meta | — | Feed |
| EVERGREEN | 0–39 | — | — | Blocos de guia |
Quando um conector falha, a abordagem ingênua trata a dimensão ausente como zero. O efeito é devastador e silencioso: se o conector de velocidade (peso 0,26) cai, todo tópico perde até 26 pontos, nada mais atinge a faixa QUENTE e o sistema para de detectar breaking news sem registrar erro algum.
A solução implementada: calcular a média ponderada apenas sobre as dimensões disponíveis, dividindo pela soma dos pesos disponíveis. O score permanece na escala 0–100 e comparável. O preço da informação faltante é pago na confiança, não no score — que é o lugar honesto de pagá-lo.
Há um teste dedicado a isso (packages/scoring/src/engine.test.ts): um tópico com todos os
sinais em 0,8 pontua praticamente igual quando metade das fontes some — mas com confiança
menor.
A confiança combina cobertura (quanto do peso total foi medido) e qualidade (quão confiantes estavam os conectores), por média geométrica. Ela é o que separa "score 85, dispare push para 200 mil pessoas" de "score 85, mostre ao editor".
Push automático exige, cumulativamente:
- faixa QUENTE;
- confiança ≥ 55%;
- ausência de gatilho sensível.
[1] DESCOBERTA feeds RSS -> ~800 itens/dia
[2] DEDUPLICAÇÃO 5 veículos, 1 anúncio -> 1 tópico
[3] TRIAGEM conectores gratuitos -> score preliminar
[4] CORTE score >= 35, no máximo 40 por ciclo <-- decisão crítica
[5] ENRIQUECIMENTO conectores pagos, só para quem passou
[6] AÇÕES alerta / push / hero, conforme a faixa
[7] INSTRUMENTAÇÃO eventos internos para os KPIs editoriais
É o que torna o produto economicamente viável. Com os preços de 2026:
| Cenário | Conta mensal |
|---|---|
| Consultar todas as APIs para todo item descoberto | ~US$ 72.000 |
| Só enriquecer quem passa da triagem | ~US$ 225 |
A diferença não está na qualidade do código dos conectores: está na topologia do
pipeline. Foi por isso que stage (discovery | enrichment) virou parte do contrato de
conector, e o registry se recusa a inicializar se um conector pago for declarado como
discovery.
| Camada | Cobre o modo de falha |
|---|---|
| Timeout por conector | API que aceita a conexão e nunca responde |
Promise.allSettled |
API que devolve erro ou JSON malformado |
| Circuit breaker | API fora do ar há horas (evita gastar 8s por tópico, por ciclo) |
| Renormalização de pesos | A consequência final: o score continua válido |
O circuito abre após 3 falhas consecutivas e testa novamente após 5 minutos.
Duas camadas: hash canônico (rápido, exato) e similaridade de Jaccard sobre tokens (tolerante). Jaccard foi escolhido em vez de distância de edição porque interessa o conjunto de conceitos, não a grafia: por Levenshtein, "GTA VI adiado" e "GTA VI confirmado" parecem quase iguais — sendo notícias opostas.
Bug encontrado pelos testes durante o desenvolvimento: sem tratamento morfológico, a deduplicação falhava no caso mais comum da redação:
"Rockstar adia GTA VI" -> {rockstar, adia, gta}
"GTA VI adiado pela Rockstar" -> {rockstar, adiado, gta}
Jaccard = 0,5 -> abaixo do limiar -> DOIS tópicos para o mesmo anúncio
O português é muito flexionado, então comparar formas de superfície não funciona. Foi
implementado um stemmer leve (15 linhas, zero dependências) que reduz ao radical:
adia, adiado e adiaram viram adi. Sem isso, o mesmo fato ocuparia a home várias
vezes, gastaria o orçamento de API em duplicidade e — pior — diluiria o score entre
registros, impedindo a detecção do breaking news.
O limiar de 0,6 é calibrado a favor do falso negativo: duplicata gera ruído visível que o editor corrige em um clique; fusão errada faz uma notícia desaparecer, e ninguém percebe o que não apareceu.
- Crie
services/curator/src/connectors/tiktok.tsexportando umSignalConnector. - Adicione ao array
ALL_CONNECTORSemconnectors/registry.ts. - Pronto.
Não há passo 4. Nenhum arquivo do motor de score, do orquestrador ou do front-end precisa ser tocado. Isso funciona porque o núcleo conhece apenas a interface e a semântica das dimensões — nunca uma fonte concreta.
A peça que faz isso funcionar de verdade é a normalização: todo conector é obrigado a devolver valores em [0,1] com significado único ("0 = irrelevante, 1 = tão grande quanto isso costuma ficar"). O motor soma peras com peras; o conector, que entende a escala da sua plataforma, faz a tradução.
| Conector | Dimensões | Custo | Estágio |
|---|---|---|---|
source-authority |
Autoridade | grátis | descoberta |
release-calendar |
Sazonalidade | grátis | descoberta |
audience-affinity |
Afinidade | grátis | descoberta |
emotional-triggers |
Gatilhos | grátis | descoberta |
youtube |
Social + trending | quota | descoberta |
google-trends |
Volume + velocidade | pago | enriquecimento |
reddit |
Social + trending | pago | enriquecimento |
serp-competition |
Janela de SERP | grátis | enriquecimento |
x-twitter |
Social | pago | enriquecimento |
Quatro conectores são internos e gratuitos. Isso é estratégico: eles funcionam sem nenhuma API externa, custam zero e são os que a concorrência não consegue copiar. Mesmo com todas as APIs externas fora do ar, o pipeline continua produzindo scores úteis.
Esta pesquisa mudou decisões de arquitetura e vale registrar:
| API | Situação | Impacto no projeto |
|---|---|---|
| Google Trends | Oficial existe, mas segue em alpha por convite | Conector com 3 estratégias: oficial / SerpApi / simulado |
| Free tier de 100 QPM proíbe uso comercial; aprovação leva 2–4 semanas | Tratado como pago, no estágio de enriquecimento, com cache agressivo | |
| X (Twitter) | Sem free tier desde fev/2026; ~US$ 0,005 por post lido | Conector mais caro; guarda de orçamento que se autodesliga aos 95% |
| YouTube | 10.000 unidades/dia grátis; videos.list custa 1 |
Melhor custo-benefício — único externo na descoberta |
Atenção jurídica: um portal monetizado é uso comercial. Operar o Reddit no free tier violaria os termos, com risco de banimento da chave em produção. O processo de aprovação comercial deve começar junto com o desenvolvimento.
Para medir concorrência de SERP, o caminho óbvio seria uma API paga de busca. Ela tem dois problemas: custa por consulta e sofre de latência de indexação — o Google leva minutos a horas para indexar uma matéria nova, justamente na primeira hora, quando a decisão de publicar rápido precisa ser tomada.
A solução adotada: monitorar os feeds RSS dos concorrentes (Omelete, JovemNerd, IGN Brasil, Legião dos Heróis, EiNerd, The Enemy). Custo zero, dentro dos termos de uso, latência quase nula e precisão maior — sabemos exatamente quem publicou e quando.
PostgreSQL + Prisma. As entidades principais:
| Entidade | Papel |
|---|---|
Category |
Editoria. URL estável (/categoria/games), prioridade de monitoramento |
Franchise |
Fandom (Star Wars, Zelda). Motor de retenção + índice de afinidade |
Topic |
Um fato do mundo detectado pelo pipeline |
Article |
Nosso conteúdo sobre um tópico |
ScoreSnapshot |
Série temporal de scores — permite reprocessar o passado |
SignalReading |
Medição bruta por conector — permite recalibrar sem viés dos pesos antigos |
ReleaseEvent |
Calendário próprio de lançamentos (ativo proprietário) |
Subscriber / PushSubscription |
Funil de audiência |
Subcategory |
Sub-seção roteável de uma editoria (hoje: Hardware ⊂ Tech) |
AffiliateOffer / ArticleAffiliateOffer |
Ofertas de afiliado e o vínculo N:N com artigos |
CommentAuthor / CommentSession / Comment |
Comentários com identidade social (Discord/Google) |
PipelineEvent / AuditLog |
KPIs editoriais e rastreabilidade |
A rede de afiliados ainda não foi escolhida. Isso muda a modelagem: em vez de
desenhar em cima da primeira integração (amazonAsin, awinMerchantId), modelamos o
dado editorial — produto, loja, preço, link. Os campos network e externalId já
existem, vazios, esperando a fase 2. Resultado: cadastro manual (fase 1) e sincronização
por API (fase 2) usam as mesmas tabelas, sem redesenho.
Três detalhes que valem a leitura do schema:
- Preço em centavos inteiros. Nunca
Float: 0,1 + 0,2 ≠ 0,3 em ponto flutuante binário, e o erro aparece na hora errada. priceUpdatedAté separado deupdatedAt. São perguntas diferentes: "quando a linha mudou" e "esse preço é de quando". Juntas, uma correção de digitação rejuvenesceria um preço de três semanas.Article.hasAffiliateLinksé derivado, mantido porsyncAffiliateFlag()dentro da mesma transação que altera o vínculo — e recalculado a partir da relação na leitura. Ver ADR 0013.
| Dado do provedor | Guardamos? | Por quê |
|---|---|---|
| ID da conta | Só o hash | Precisamos reconhecer a mesma pessoa, não identificá-la fora do site |
| Nome de exibição | Sim, em claro | Aparece publicamente no comentário — é a finalidade |
| Não | Nem pedimos o escopo. Discord: identify; Google: openid profile |
|
| Avatar | Não | Renderizar imagem do CDN do provedor entregaria o IP de todo leitor a terceiros. Exibimos iniciais |
| Token de acesso | Não | Usado uma vez para ler o perfil e descartado. Guardar token é guardar acesso à conta alheia |
Um tópico é um fato ("a Rockstar anunciou o atraso"); um artigo é o nosso conteúdo sobre ele. Um tópico pode gerar três artigos (notícia, análise, repercussão) — ou nenhum. Uni-los produziria linhas "fantasma" de artigos nunca escritos poluindo o CMS e o sitemap.
currentScore e scoreDelta1h são duplicados em Topic/Article, mesmo existindo em
ScoreSnapshot. A home e a página Em Alta ordenam por score a cada requisição não
cacheada; fazer isso com subquery no histórico seria caro exatamente no pico de tráfego.
Gravamos os dois: histórico para análise, atual para leitura.
scoreAtPublish é congelado e nunca atualizado — é o "previsto" do KPI de precisão.
Atualizá-lo destruiria a capacidade de avaliar o algoritmo.
Next.js 15 com App Router. Quase tudo são Server Components: os únicos componentes de cliente são o formulário de newsletter, o opt-in de push, a barra de compartilhamento, o bloco de spoiler e as ações do painel. Isso mantém o JavaScript enviado ao navegador próximo de zero nas páginas de conteúdo — decisivo para o LCP no celular, que é o público majoritário.
| Superfície | Estratégia | Motivo |
|---|---|---|
| Artigo | Cache longo (1h) + invalidação na edição | Conteúdo quase imutável. 50 mil leitores simultâneos num breaking news são servidos da CDN, sem tocar no banco |
| Home / Em Alta | Cache curto (60s) + invalidação por evento | Mudam de verdade quando um score muda de faixa |
A invalidação por evento é o ponto central: com revalidate: 30, a home pagaria
regeneração a cada 30s o dia inteiro, inclusive às 4h da manhã. Com invalidação por evento,
ela fica em cache indefinidamente e é regenerada no instante em que algo relevante
acontece — mais fresca quando importa, mais barata quando não importa.
Só invalidamos quando a faixa muda, não a cada variação de score: invalidar por 0,3 ponto destruiria a taxa de acerto do cache justamente durante um pico.
O tema claro é o padrão (design v0.2) e o escuro é uma opção — não o contrário, e nenhum dos dois é forçado. São três estados: Sistema (padrão, segue o aparelho), Claro e Escuro, num controle no rodapé.
Mecanicamente é quase nada: todos os tokens usam light-dark(claro, escuro) e trocar de
tema muda uma propriedade (color-scheme) no <html>. Nenhuma paleta duplicada,
nenhum componente sabendo que existe tema.
A preferência vive no localStorage e é aplicada por um script inline de ~230 bytes no
<head>, antes da primeira pintura. Sem ele, quem usa tema escuro veria a página clara
por alguns décimos de segundo — um flash branco em tela cheia às 2h da manhã. Cookie lido
no servidor resolveria o flash e tornaria toda página dinâmica, matando o cache de
CDN (ADR 0010).
Nenhuma página pública exibe 97/100, +38 ou score 74. No lugar entraram:
badge textual, heatbar (termômetro de 4 blocos, com aria-label) e tendência
qualitativa ("Disparando", "Subindo", "Esfriando", "Novo").
Ganho colateral que não estava no pedido: "Disparando" comunica melhor que "+38" para quem não conhece a escala — e a escala é justamente o que não queremos publicar. O número continua no banco, no ranqueamento, na API interna e na tela da redação (ADR 0009).
A regra que organiza tudo, vinda do design (§7.1): quanto mais quente e mais factual, menos comercial; quanto mais evergreen e mais perto de uma decisão de compra, mais tolerância.
| Superfície | Anúncios | Afiliado |
|---|---|---|
| Breaking / cobertura ao vivo | 1 slot, depois do último parágrafo | Proibido |
| Notícia, trailer | 1–2 slots no corpo + trilho lateral | Só link contextual |
| Review, comparativo, guia, listicle | 2 slots + trilho | Permitido (.buybox) |
| Em Alta | Nenhum no fluxo do ranking | Proibido |
| Home | 0 no hero; slots depois do ranking | Nenhum |
Detalhes de implementação que sustentam as Core Web Vitals e a conta do AdSense:
- Altura reservada por CSS (
--ad-h) em todo slot: CLS zero mesmo com o anúncio bloqueado — e boa parte da audiência bloqueia. strategy="lazyOnload"no carregador eIntersectionObserverpor slot: o script de 100+ KB não disputa banda com a imagem de capa na janela em que o LCP é medido.- Sem Publisher ID, nada é renderizado — nem o espaço reservado, nem a requisição. Em desenvolvimento, ninguém gera impressão inválida navegando pelo site.
/ads.txté rota do Next, montada a partir de variável de ambiente. Sem ID configurado, responde 404 de propósito: umads.txtcom linha inválida bloqueia a venda legítima do inventário, enquanto a ausência do arquivo é neutra.
O selo é injetado no Server Component do artigo a partir de hasAffiliateLinks, que é
derivado da relação artigo↔oferta. As ofertas e o selo saem da mesma variável — não
existe caminho de código em que um apareça sem o outro (ADR 0013). Todo link comercial
sai com rel="sponsored nofollow noopener" e etiqueta textual "afiliado" ao lado, e a
URL passa pela mesma função de sanitização que protege os links do corpo do artigo
(lib/safe-url.ts), na variante que exige HTTPS.
NewsArticle— pré-requisito para o Google Notícias e o carrossel "Principais notícias"Product+Offer/AggregateOffer— só em review e comparativo, e só com preço verificado nas últimas 24h. É a família "product snippet" (páginas onde não se compra), a única elegível ao rich result de prós e contras — que o projeto já tem emreviewData. Preço obsoleto não é marcado (ADR 0016)VideoObject— miniatura na busca; central num nicho onde trailer é conteúdo-chavePerson+NewsMediaOrganization— E-E-A-T (autor com credenciais verificáveis)BreadcrumbList— trilha na SERP e hierarquia do siteCollectionPage+ItemList— categorias e hubs
Emitimos um único grafo com @graph, para que as entidades se referenciem por @id — em
blocos <script> soltos, a ligação entre artigo e autor se perde.
Sitemap dinâmico com lastModified real (o Google ignora priority e changeFrequency,
mas o Bing não). Invalidado por evento a cada publicação. Conteúdo com score alto recebe
prioridade maior, como sinal extra para o rastreador.
Sobre o "ping automático para Google/Bing" pedido no briefing — o mecanismo mudou e vale registrar, para que ninguém "conserte" isso no futuro sem contexto:
| Caminho | Situação | O que fizemos |
|---|---|---|
Ping clássico de sitemap (google.com/ping) |
Descontinuado pelo Google em 2023 | Não implementado — seria código que não faz nada |
| IndexNow (Bing, Yandex, Seznam, Naver) | Ativo, gratuito, um envio se propaga entre todos | Implementado, disparado só para conteúdo QUENTE |
| Google Indexing API | Restrita a JobPosting e BroadcastEvent; usar para notícia viola os termos |
Não implementado |
| Sitemap fresco + autoridade | É o que de fato funciona para o Google | Invalidação por evento a cada publicação |
O ping roda apenas na faixa QUENTE: em breaking news, minutos de antecedência na indexação valem tráfego; no fluxo normal, o sitemap dá conta.
Anônimo -> Leitor engajado -> Inscrito -> Comunidade -> Advocate
| Etapa | Mecanismo | Regra |
|---|---|---|
| Engajamento | TL;DR, relacionadas por franquia, hubs | Sempre |
| Newsletter | CTA no corpo do artigo | Após o 3º parágrafo; só o e-mail |
| Push | Opt-in contextualizado | Só em artigo com score ≥ 60; nunca no 1º pageview; 30 dias de carência se recusado |
| Comentário | Login social (Discord ou Google) | Sem comentário anônimo; 1º comentário de cada conta passa por moderação |
| Comunidade | Discord por fandom | Canal específico (#gta-6), não convite genérico |
Moderação tem duas soluções clássicas, ruins em pontas opostas. Pré-moderação total mata a conversa (ninguém volta 6 horas depois) e cria fila infinita para a redação; pós-moderação total funciona até o primeiro ataque coordenado — e aí o estrago já foi lido.
O que fazemos: o primeiro comentário de uma conta entra como pendente; a partir do primeiro aprovado, os seguintes publicam direto.
Funciona porque o abuso quase sempre vem de conta descartável, criada para uma investida só — ela esbarra na moderação e nunca chega à segunda mensagem. O leitor recorrente paga o pedágio uma vez na vida. O custo operacional passa a ser proporcional a contas novas, não a comentários.
Complementos: exigir conta real transfere o custo do antiabuso para Discord e Google (que já o pagam); o limite de taxa é por autor, não por IP (um IP pune a escola inteira, e o abusador troca de IP em segundos); bloquear um autor revoga as sessões dele na hora e remove os comentários anteriores — é o caso de uso que motivou a sessão opaca em vez de JWT (ADR 0011).
Comentário é renderizado como texto puro: sem Markdown, sem HTML e sem autolink.
URLs aparecem como texto — transformá-las em <a> num campo aberto é o convite mais
direto que existe para spam de SEO.
Não é firula de marketing. É (a) prova de consentimento exigida pela LGPD, (b) proteção contra cadastro de e-mails de terceiros e (c) o que preserva a reputação de envio — sem ele, um bot inscreve mil endereços inválidos, os bounces disparam e todo o domínio vai para spam, um dano lento e caro de reverter.
Implementado com token de 256 bits, armazenado em hash, de uso único, com validade de 48h. O descadastro de um clique (RFC 8058) é obrigatório na prática desde 2024 — Gmail e Yahoo filtram remetentes em massa que não o oferecem.
Pedir Notification.requestPermission() ao carregar a página é a razão nº 1 de bloqueio
permanente — e "permanente" é literal: uma vez que o usuário clica em "Bloquear", o
navegador nunca mais mostra o pedido para o domínio, e não há nada que o site possa fazer.
Cada pedido malfeito é um assinante perdido para sempre.
Por isso: primeiro um convite em HTML explicando o motivo e a frequência ("no máximo 2 por dia"), e só depois do clique chamamos o navegador. O botão "Agora não" tem o mesmo tamanho do "Ativar" — dar proeminência desproporcional ao "sim" converte pior no médio prazo.
O service worker tem escopo deliberadamente mínimo: só push e clique. Um SW que cacheia HTML é a causa mais comum de "o site não atualiza para alguns usuários" — inaceitável num portal de notícias.
O GA4 mede o leitor: sessões, origem, scroll, rejeição. Ele não sabe que o score de um tópico cruzou 80 às 14h03 e que a matéria saiu às 14h31 — esses fatos acontecem dentro do pipeline e nunca chegam ao navegador de ninguém.
Por isso há instrumentação própria (PipelineEvent, AuditLog).
| KPI | Fonte |
|---|---|
| Sessões por origem, scroll, rejeição | GA4 |
| Time-to-publish (score cruzou 80 → publicado) | PipelineEvent |
| % de QUENTES publicados em 30 min | PipelineEvent |
| Precisão do score (previsto × pageviews reais) | Article.scoreAtPublish × pageviews24h |
| Saúde dos conectores, custo por ciclo | ConnectorHealth, PipelineRun |
O claimedAt separa "demoramos a ver o alerta" de "demoramos a escrever" — são
gargalos diferentes, com soluções diferentes.
Ninguém espera que score 80 renda exatamente o dobro de score 40. Pageviews têm distribuição de cauda longa, e um punhado de virais dominaria uma correlação linear, deixando o número instável a ponto de ser inútil.
O que importa é ordenação: se o algoritmo diz que A é mais quente que B, A rendeu mais tráfego? Isso é correlação de postos (Spearman), robusta a outliers.
O relatório se recusa a exibir um número com menos de 20 amostras: correlação sobre 5 pontos é numerologia, e levaria alguém a mexer nos pesos por engano.
Tratamos o OWASP Top 10 como checklist mínimo.
| Risco | Mitigação |
|---|---|
| A01 Controle de acesso | Segredo comparado em tempo constante (timingSafeEqual); cookie httpOnly + sameSite=strict; painel e /api/ bloqueados no robots.txt |
| A02 Falhas criptográficas | Tokens de 256 bits com randomBytes (nunca Math.random); armazenados em hash; IP em hash com sal |
| A03 Injeção / XSS | Markdown renderizado como elementos React, nunca dangerouslySetInnerHTML; links com protocolo validado (bloqueia javascript:); JSON-LD com escape de <, > e & |
| A04 Design inseguro | Double opt-in; rate limiting; orçamento de API com autodesligamento; automação de push com três travas cumulativas |
| A05 Configuração incorreta | Cabeçalhos de segurança no next.config.ts (HSTS, nosniff, X-Frame-Options, Permissions-Policy); robots.txt bloqueia tudo fora de produção |
| A07 Falhas de autenticação | Rate limit agressivo no login (5/15min); resposta genérica sem revelar se o token existe |
| A10 SSRF | Endpoint de push validado contra lista fechada de domínios, com casamento por sufixo com ponto (não includes) |
Cuidados adicionais específicos deste domínio:
- Enumeração de usuários: a inscrição responde sempre a mesma mensagem, tenha o e-mail sido cadastrado ou não. Revelar "esse e-mail já está inscrito" permitiria mapear a base.
- Host header injection: URLs de e-mail vêm de variável de ambiente, nunca do cabeçalho
Host— senão o sistema viraria ferramenta de phishing com o nosso próprio remetente. - XXE: o parser de RSS usa regex e não resolve entidades externas, então o vetor não existe.
- Vazamento em logs: URLs são limpas de query string antes de logar (chaves de API costumam viajar como parâmetro).
- LGPD: IP e e-mail em hash; auditoria de consentimento; descadastro de um clique.
-
Autenticação do painel— RESOLVIDO. O segredo compartilhado saiu; cada pessoa tem conta individual (e-mail + senha com scrypt, sessão opaca emStaffSession) e oAuditLogpassou a gravaractorId. O que ficou de fora, conscientemente: recuperação de senha por e-mail (a redação é pequena; um admin redefine pela tela de contas) e segundo fator. Verapps/web/src/server/staff-auth.ts. -
Rate limiting distribuído — hoje em memória. Com múltiplas réplicas, o limite real vira N × o configurado. Migrar para Redis (
INCR+EXPIRE). -
Content-Security-Policy — não configurada ainda. O inventário de domínios cresceu com a camada de monetização e o login social; segue abaixo o que a política precisará liberar. Antes de escrever a CSP, leia a lista inteira: uma política restritiva demais quebra anúncio (receita) ou login (comentários) de forma difícil de diagnosticar.
Diretiva Domínios Motivo script-srcpagead2.googlesyndication.com,*.googlesyndication.com,*.googletagservices.com,*.google.com,*.googleadservices.comAdSense carrega scripts em cascata a partir do loader script-srchash do script inline de tema (ou nonce)O anti-FOUC é inline por necessidade (ADR 0010). Sem o hash, o site abre no tema errado script-srcwww.googletagmanager.comGA4 frame-srcgoogleads.g.doubleclick.net,*.safeframe.googlesyndication.com,www.youtube-nocookie.comCriativos rodam em iframe; embeds de trailer img-src*.googlesyndication.com,*.doubleclick.net,*.g.doubleclick.net, CDN de imagem próprio,i.ytimg.comCriativos e miniaturas connect-src*.google-analytics.com,*.googlesyndication.comBeacons style-srcfonts.googleapis.com+'unsafe-inline'O AdSense injeta estilo inline; sem isso, o slot não pinta font-srcfonts.gstatic.comform-actiondiscord.com,accounts.google.comInício do fluxo OAuth frame-ancestors'none'Já coberto pelo X-Frame-Optionsa definir domínios da rede de afiliados Depende da rede escolhida. Se ela usar redirecionador próprio ou pixel de conversão, entram em img-src/connect-srcRecomendação de implantação: subir primeiro em
Content-Security-Policy-Report-Onlypor duas semanas e ler os relatórios. CSP de anúncio quebra de formas que não aparecem em teste manual — costuma falhar só em um formato de criativo, semanas depois. -
Autenticação individual nas telas de afiliados e moderação— RESOLVIDO junto com o item 1: as duas passaram a exigir níveladmine registramactorIdnoAuditLog, então uma remoção de comentário contestada tem responsável identificável. -
Proxy de imagem de produto — as fotos das ofertas virão do CDN das lojas. Servi-las direto entrega o IP do leitor ao varejista e nos deixa reféns de link quebrado. O
next/imagecomremotePatternsresolve o segundo problema; o primeiro exige proxy. -
Limpeza de sessões expiradas — hoje a sessão de leitor expirada é apagada de forma oportunista, no primeiro uso. Quem nunca mais voltar deixa uma linha para trás; um job semanal (
DELETE WHERE expiresAt < now()) resolve quando o volume justificar.
| # | Decisão | Alternativa descartada | Por quê |
|---|---|---|---|
| 0001 | Next.js para o site | Astro, Remix | ISR + invalidação por tag resolve exatamente o requisito "artigo estático + home dinâmica"; RSC minimiza JS no mobile |
| 0002 | PostgreSQL + Prisma | MongoDB, Drizzle | Domínio fortemente relacional (N:N artigo↔franquia) e séries temporais de score; jsonb cobre o que é sem esquema, evitando um segundo banco |
| 0003 | TypeScript no pipeline | Python | Compartilhar tipos e schema com o front elimina drift de contrato; uma linguagem só para o time. Fronteira limpa para extrair um serviço Python de recalibração na Fase 3 |
| 0004 | Pipeline em dois estágios | Consultar tudo para todos | Diferença entre US$ 72.000 e US$ 225 por mês |
| 0005 | Markdown no corpo | HTML, editor de blocos | Renderizado como elementos React: XSS impossível por construção, sem depender de sanitizador bem configurado |
| 0006 | Conectores como plugins | switch no núcleo |
Fonte nova = arquivo novo + 1 linha no registry; zero alteração no núcleo |
| 0007 | npm workspaces | Turborepo, Nx | 5 pacotes não justificam cache distribuído; adicionar Turborepo depois é incremental |
| 0008 | Dois scores (urgência + SEO) | Score único | Com um número só, conteúdo de cauda longa sempre perde — e é ele que sustenta o orgânico entre breakings |
| 0009 | Score numérico só no /admin |
Flag SHOW_NUMERIC_SCORE no componente compartilhado |
Uma flag pode ser reativada por descuido em qualquer tela nova. Removemos a capacidade: HeatBadge não aceita mais score, e exibir o número exige importar components/admin/score-value.tsx — um import que grita "isto é do painel" em revisão de código. A /api/trending também parou de expor o número: escondê-lo na tela e mantê-lo num endpoint público com CORS aberto seria teatro |
| 0010 | Tema claro/escuro com light-dark() + data-theme |
next-themes; classe .dark em cada componente; cookie lido no servidor |
Um token, dois valores: trocar de tema muda uma propriedade CSS (color-scheme) e nenhuma paleta é duplicada. Cookie no servidor tornaria toda página dinâmica e destruiria o cache de CDN — trocaríamos 300ms de flash por um site inteiro mais lento. O custo é um script inline de ~230 bytes no <head> |
| 0011 | Cliente OAuth próprio + sessão opaca no banco | Auth.js (NextAuth) v5 | Três motivos: a v5 segue em beta (a v4 é do Pages Router); o adaptador do Auth.js guarda nome, e-mail e tokens em claro, contra a política de minimização do projeto; e sessão em JWT não se revoga — o caso de uso central da moderação é "bane esse sujeito agora". Somos apenas o cliente de um fluxo padronizado (nada de criptografia própria), com state + PKCE construídos sobre as mesmas primitivas já usadas nos tokens de newsletter |
| 0012 | Modelo de afiliados genérico (loja como texto livre; network/externalId reservados) |
Modelar sobre a API da primeira rede escolhida | A rede ainda não foi definida. Modelar em cima de amazonAsin/awinMerchantId obrigaria a remodelar metade do schema depois, com dados em produção. Assim, a fase 1 (cadastro manual) e a fase 2 (sincronização por API) usam as mesmas tabelas |
| 0013 | hasAffiliateLinks derivado da relação; disclosure automático |
Checkbox "tem afiliado" no CMS | O checkbox falha em silêncio: ninguém marca às 23h publicando às pressas, nada quebra, nenhum teste falha — e o site veicula link comercial sem aviso (CDC art. 36, Código do CONAR, políticas do Google). Derivando da relação, "ter link" e "mostrar o selo" são o mesmo fato |
| 0014 | Preço fresco por invalidação por evento + regra de 24h | Baixar o revalidate do artigo para 5 min; buscar preço no cliente |
Encurtar o cache custaria 12 regenerações por hora em todos os artigos para atualizar preço em alguns — e ainda exibiria preço de 5 minutos atrás. A garantia real não é o cache: passadas 24h sem conferência, o número sai da página. Funciona mesmo se toda a invalidação falhar |
| 0015 | Política de anúncios como dado estático no front | Tabela de densidade no banco, editável por painel | Densidade de anúncio é decisão editorial; decisão editorial editável por formulário acaba editada por quem tem meta de receita numa sexta à noite. Morar em apps/web também torna estruturalmente impossível o motor de score enxergá-la (pacote nunca importa quem o consome) |
| 0016 | Product/Offer só em review e comparativo, com preço fresco |
Emitir em todo artigo com oferta; não emitir nada | "Product snippet" é a família de dado estruturado para páginas onde não se compra — e a única elegível ao rich result de prós e contras, que o projeto já tem em reviewData. Marcar preço obsoleto viola política do Google e é caminho conhecido para ação manual, então oferta vencida simplesmente não entra no @graph |
| 0017 | Hardware como sub-categoria roteável | Tag livre "hardware" | Tag não tem URL estável, não entra no menu e é filtrada por texto. Sub-categoria tem chave estrangeira com índice, /categoria/tech/hardware indexável e intenção de busca própria ("melhor placa de vídeo custo-benefício" não é "notícias de tecnologia") |
| 0018 | Verificador de risco editorial por regras estáticas, que AVISA e registra — nunca bloqueia | Classificador por LLM; trava de publicação; capacidade nova só para admin | Regra explícita é auditável ("qual linha sinalizou, e ela deve continuar existindo?"), custa zero por publicação e não manda matéria embargada para servidor de terceiro. Bloquear seria pôr um verificador heurístico como editor-chefe: ele erra nos dois sentidos, e uma trava contornável treina a redação a contorná-la. A proteção real é o par aviso visível + reconhecimento gravado em AuditLog (com justificativa obrigatória no risco alto) — nenhuma capacidade nova foi criada, porque o redator já publica o que assina e uma trava a mais não impediria a mesma acusação escrita com outras palavras. Ver packages/core/src/editorial-risk.ts |
Objetivo: provar que o pipeline detecta breaking news antes da concorrência.
| Frente | Entrega |
|---|---|
| Coleta | 11 fontes RSS (estúdios + imprensa tier 1) |
| Score | Todos os 9 sinais com os pesos v1; conectores internos reais, externos conforme credenciais saírem |
| Site | Home, Em Alta, categoria, hub de franquia, artigo |
| Funil | Newsletter com double opt-in; push |
| Redação | Painel com fila por score, override manual e alerta no Slack |
| Métricas | Time-to-publish, % na meta de 30 min, GA4 |
Critérios de saída:
- pipeline rodando 24/7 com menos de 1% de ciclos falhos;
- ao menos 20 matérias publicadas a partir de tópicos QUENTE;
- primeira medição de precisão do score (mínimo de 20 amostras);
- p75 de LCP abaixo de 2,5s no mobile.
Não entra no MVP: colecionáveis, app, feed personalizado.
Antecipados para esta rodada (decisão do dono do site, fora do plano original): tema claro/escuro, comentários com login social, camada de monetização (AdSense + afiliados) e a sub-seção Hardware. O que puxou a antecipação foi a monetização: ela muda o modelo de dados, e mexer em schema depois de indexado custa mais caro.
- Ativar as fontes de fase 2 (
CURATOR_PHASE=2) — as categorias já existem no modelo desde o dia 1, porque mudar URL depois de indexado custa autoridade. - Primeira recalibração dos pesos com dados reais das 8–12 semanas anteriores, via shadow scoring: calcular em paralelo com pesos candidatos e só promover se a correlação de Spearman melhorar.
- Elevar o peso de
audienceAffinity— ele começa baixo por falta de histórico, e é o sinal proprietário que mais deve crescer. - Sincronização de preço por API da rede de afiliados escolhida — as colunas
network/externalIdjá existem para isso (o cadastro manual da fase 1 continua valendo como caminho de exceção). - Comentários: respostas em thread, denúncia pelo leitor e métricas de moderação. A base (identidade, sessão revogável, escada de confiança) já está no ar.
- Redis para rate limiting distribuído e fila BullMQ.
- Páginas de evento com cobertura ao vivo (CCXP, Game Awards, Gamescom).
- Recalibração automática: job que roda o shadow scoring semanalmente e sugere ajustes.
É o momento natural para extrair um serviço Python (pandas/scikit-learn), com a fronteira
já isolada em
packages/scoring. - Classificador de gatilhos emocionais por LLM, substituindo as palavras-chave — se os dados rotulados mostrarem que o ganho compensa o custo e a perda de determinismo.
- Novos conectores: TikTok, Instagram, Twitch (o registry já suporta).
- Particionamento do sitemap (índice + arquivos por mês) ao aproximar de 50 mil URLs.
- Feed personalizado a partir dos hubs seguidos.
Itens que precisam de decisão do cliente antes do lançamento:
- Estrutura de URL — o briefing pede
/categoria/gamese/franquia/x; o design propõe/gamese/f/x. Implementado o formato do briefing como canônico, com redirecionamento 301 do outro. Trocar é alterar constantes emcore/routes.ts— mas só até a indexação começar; depois custa autoridade. - Acesso comercial ao Reddit — iniciar o processo agora; leva de 2 a 4 semanas.
- Orçamento mensal de API — o padrão está conservador (US$ 100/mês no X). Definir o
teto real ajusta
X_MONTHLY_READ_BUDGETe o limite de enriquecimento por ciclo. - Rede de afiliados — a única decisão comercial que ainda trava receita. O sistema
funciona inteiro sem ela (cadastro manual no
/admin/afiliados); quando a rede sair, entramnetwork/externalIde um job de sincronização de preço. Também define os domínios que faltam na CSP. - Publisher ID do AdSense — enquanto
NEXT_PUBLIC_ADSENSE_CLIENT_IDestiver vazio, nenhum anúncio é renderizado e/ads.txtresponde 404 (ambos de propósito). - Credenciais de OAuth (Discord e Google) — sem elas, os comentários ficam em modo
leitura. Cadastrar as URLs de retorno exatamente como
{SITE_URL}/api/auth/{provider}/callback.
| Antes em aberto | Decisão do dono do site |
|---|---|
| Exibir o score numérico publicamente | Não. Só no /admin (ADR 0009) |
| Tema escuro como padrão | Não. Claro por padrão, escuro por escolha, com opção "Sistema" (ADR 0010) |
| Comentários nativos × Discord | Ambos como login: Discord e Google, com moderação por escada de confiança (ADR 0011) |
| Paleta de marca | Resolvida no design v0.2: marca em carmim/grafite; vermelho vivo e laranja seguem exclusivos da temperatura, e a paleta comercial é cinza |
light-dark()exige navegador de 2024+ (Chrome 123, Safari 17.5, Firefox 120). Cobertura hoje é alta, mas num navegador antigo os tokens ficam inválidos. O caminho de saída já está documentado pelo design: compilar os tokens em dois blocos (:roote[data-theme=dark]). A arquitetura não muda, só a saída do CSS.Re-skin para o design v0.2.Concluído — ver a seção abaixo.
O produto não escreve CSS próprio: ele carrega a folha do design system
(apps/web/src/app/ortuspixel.css), que é cópia verbatim de design/assets/*.css
até a seção 17. O que estava desalinhado, portanto, nunca foi o estilo — era o
markup, que citava classes de versões anteriores ou classes que nunca existiram.
O que foi sincronizado
| Área | O que mudou |
|---|---|
| Vocabulário v0.1/v0.2 → v0.3 | .cd-box → .side-box, .rank-list__* → .rank__*, .section__head → .section-head, .card__foot → .meta, .article__body → .prose, .share-bar → .share, .fandom-list → .fandoms, .trending-head → .hub-hero |
| Grades | .grid sozinho não define coluna nenhuma no design; os feeds ganharam .g-sm-2 / .g-md-3 / .g-lg-4 |
| Layout de artigo | .article-layout → .layout-2col. A primeira é grade de três colunas (56px │ 1fr │ 320px) e a coluna de 56px é o trilho de compartilhamento, que o produto não renderiza — acima de 1180px o texto da matéria caía nela |
| Slug de editoria × token do design | cat--${slug} gerava .cat--cinema-e-series, .cat--anime-e-manga e .cat--hqs; agora passa por catModifier() / catClass() |
| Modificadores de temperatura | heatbar--${heat} gerava .heatbar--base em quase todo card. base é o estado padrão e não tem modificador. Centralizado em heatClass() |
| Bloco de afiliados | A variante summary reutilizava o markup da buybox; passou a usar .aff-summary__list + .prod, que é o cartão de produto (a .buybox compara lojas) |
| Newsletter | .cta-inline é grade de duas colunas e distribui pela ordem dos filhos diretos; o bloco emitia cinco elementos soltos e saía em zigue-zague |
O que garante que não volta
npm run check:classes compara o HTML realmente servido por 15 páginas com o CSS
do design e falha se aparecer classe inexistente ou vocabulário morto. Precisa do dev
server no ar; sem ele, npm run check:classes -- --skip-runtime mantém as checagens
estáticas. packages/core/src/presentation.test.ts cobre os dois mapas de tradução
(slug → token de editoria, temperatura → modificador) sem depender de servidor nem de
dados.
O que ficou de fora — e por quê
Nada disso é dívida de re-skin: são recursos do protótipo que o produto ainda não tem. Estão listados para não serem confundidos com divergência de estilo.
| Do protótipo | Por que não entrou |
|---|---|
.share-rail, .icon-btn, .nav-toggle, .thumb__play, .trend com seta |
Não há sistema de ícones. O protótipo usa <svg><use href="#i-…">; sem os ícones, esses componentes sairiam como círculos e quadrados vazios. É a maior lacuna visual restante e vale como próximo passo |
.verdict / .verdict__score / .pros-cons |
O veredito de review (nota, prós e contras) já existe no banco (reviewData), mas nenhuma página o renderiza |
.toc |
Sumário de artigo longo — os id dos títulos já são gerados por article-body.tsx, falta a lista |
.progress / .sticky-bar |
Barra de progresso de leitura: exigiria transformar a página de artigo em Client Component |
.shop, .deal-strip |
Vitrine de produtos no hub de franquia e faixa de ofertas em Hardware — dependem de dado que o pipeline ainda não coleta |
.social-proof / .avatars / .online |
Indicadores de presença ao vivo. Não temos o dado, e um indicador que não reflete estado nenhum é pior que nenhum |
.load-more, .chip__count, .editoria |
Paginação, contadores por filtro e blocos por editoria na home — mudanças de consulta, não de apresentação |
.table-wrap / .cmp |
Tabelas comparativas: o parser de Markdown ainda não suporta tabelas (decisão deliberada, ver article-body.tsx) |
O rebrand de "CanalNerd" para "Ortus Pixel" foi aplicado primeiro em design/ e
depois no código do app, em tarefas separadas de propósito: misturar renomeação de marca
com refactor de markup tornaria os dois impossíveis de revisar.
Status: concluído. Continuam com o nome antigo, por decisão explícita e isolada:
o escopo dos pacotes npm (@subcarioca/*) e as credenciais do Postgres de
desenvolvimento (docker-compose.yml), que renomear quebraria o volume local sem
benefício nenhum. Ambos são trabalho de uma tarefa futura, com o site já estável no ar.
- Protótipos e arquitetura de informação:
design/README.md - Contrato de sinais:
packages/core/src/signals.ts - Pesos e racional:
packages/scoring/src/weights.ts - Testes do motor:
packages/scoring/src/engine.test.ts