Pipeline para montar um acervo navegável de criativos de anúncios que estão no ar — coletados da Biblioteca de Anúncios da Meta, classificados automaticamente e publicados como uma página estática única, com filtros e análise.
Serve para media buyer, estrategista de criativo e dono de e-commerce que quer parar de "achar" o que o concorrente está fazendo e passar a ler o que o mercado bancou com dinheiro.
A Biblioteca de Anúncios da Meta é pública, mas é péssima de analisar: sem filtro cruzado, sem comparação entre marcas, sem histórico, e o scroll infinito derrete depois de 30 cards. Espiar 50 concorrentes na mão é inviável.
Este pipeline varre dezenas de páginas de anunciante, extrai o JSON por trás dos cards, classifica cada criativo em vários eixos e gera uma página onde dá para perguntar coisas como:
- que criativos de short os concorrentes mantêm no ar há mais de 60 dias?
- quem está vendendo (link para PDP) e quem só está levando gente para o perfil?
- marcas do meu porte duram mais ou menos que os gigantes?
- esse nicho vende com desconto ou com desejo?
Isto é a parte mais importante do projeto. Leia antes de usar.
A Meta não publica gasto, impressões, alcance nem CTR de anúncio comercial. Os campos
existem na resposta e vêm vazios (spend: null, impressions_index: -1). Qualquer
ferramenta que diga "o concorrente gastou R$ X" está estimando.
Este pipeline usa só os dois sinais que são reais:
| Sinal | O que é | Por que importa |
|---|---|---|
| Dias no ar | o criativo está ligado há X dias e continua ativo | ninguém deixa criativo ruim pagando CPM — longevidade é a única prova pública de que a peça se paga |
| Variações | quantas versões do mesmo criativo rodam em paralelo (collation_count) |
ninguém produz 8 variações de uma peça que não performa — mede verba alocada |
Há um terceiro sinal, mais sutil: a varredura pede à Biblioteca ordenação por
total_impressions, e o Meta ordena no servidor. O número é secreto, mas a ordem é
dele — a posição na lista vira um ranking de impressões utilizável (comparável apenas
dentro da mesma marca, e enviesado para o criativo mais antigo, porque é acumulado).
Ressalva que evita conclusão errada: longevidade sozinha não prova vencedor. Anúncio antigo com 1 variação só, numa conta com pouquíssimos anúncios ativos, costuma ser peça esquecida ligada com verba mínima. O sinal forte é longevidade + variações + conta com volume, os três juntos.
descobre_ids.py ──► cdp.py ──► descobre_ids.py extrai ──► monta_marcas.py
(gera buscas) (coleta) (page_id por marca) (universo final)
│
┌─────────────────────────────────┘
▼
varre.py gera ──► cdp.py ──► varre.py parseia ──► analisa.py
(URLs/marca) (coleta) (extrai os ads) (classifica + thumbs)
│
┌─────────────────────────────────┘
▼
sintetiza.py ──► escreve_analise.py ──► gera_pagina.py
(estatística) (tabelas + textos) (HTML final)
A Biblioteca não cede a curl (403 / login wall) e não cede mais ao
--dump-dom do Chrome headless: a partir do Chrome ~150 o novo headless ignora
--virtual-time-budget e trava no polling da página — o dump nunca sai.
O cdp.py sobe uma instância de Chrome com --remote-debugging-port e conversa por
CDP (Chrome DevTools Protocol) via WebSocket: navega, espera, rola para disparar o scroll
infinito, e lê o DOM na hora que quiser. Cerca de 15s por busca e 30s por página,
contra 90–150s do método antigo — e uma instância só serve todas as marcas.
Não faz scraping de HTML renderizado. Ancora em "ad_archive_id" dentro dos <script> e
recupera o objeto JSON inteiro por brace matching. De lá saem page_id, is_active,
start_date, collation_count, snapshot.display_format, cta_text, cta_type,
title, body.text, link_url, publisher_platform, page_like_count…
Cada criativo é etiquetado em:
- Ângulo — Oferta & Desconto, Produto & Qualidade, Identidade & Comunidade, Lançamento…
- Hook — o mecanismo da primeira linha (pergunta, comando, prova, dor…)
- Produção — creator/UGC, produção de marca, catálogo automatizado, voz de cliente
- Objetivo aparente — conversão-venda, tráfego-site, tráfego-perfil do Instagram,
mensagem, loja física… inferido de
cta_type+ destino do link (a Biblioteca não publica o objetivo da campanha) - Placement — só Instagram / Instagram+Facebook / todos (Advantage+)
- Validação — Comprovado / Consolidado / Em tração / Recente
- Produto (opcional) — categoria da peça, detectada pelo slug do link do anúncio
HTML estático único, sem build e sem dependência de CDN além das fontes. Nav sticky com
scrollspy, filtros cruzados client-side, toolbar com os filtros ativos removíveis, modal
de detalhe, ordenação (impressões / dias / variações / recentes) e as thumbs baixadas
localmente — as URLs do fbcdn expiram em horas, então sem cópia local o acervo fica
sem imagem em poucos dias.
?noanim=1 desliga animações e content-visibility (útil para QA e print).
- Python 3.9+
- Google Chrome instalado
pip install websockets- macOS/Linux (o caminho do Chrome está no topo do
cdp.py)
# 0. estrutura: o pipeline grava em ../<saida>/criativos/
# acervo/ <- este repositório (scripts)
# meu-cliente/ <- pasta irmã, criada automaticamente
cp exemplo/textos.exemplo.py acervo/textos.py
cp exemplo/tiers.exemplo.json acervo/tiers.json
# edite CONFIG no topo de acervo/gera_pagina.py (nome do cliente e pasta de saída)
# 1. descoberta: edite CANDIDATAS e CATEGORIAS em descobre_ids.py com o SEU nicho
python3 acervo/descobre_ids.py gera /tmp/buscas.json
python3 acervo/cdp.py /tmp/buscas.json /tmp/dumps
python3 acervo/descobre_ids.py extrai /tmp/dumps
# 2. universo: classifique cada marca em tiers.json (par/escala/pequena/tema/voce)
python3 acervo/monta_marcas.py
# 3. varredura
python3 acervo/varre.py gera /tmp/alvos.json
python3 acervo/cdp.py /tmp/alvos.json /tmp/dumps
python3 acervo/varre.py parseia /tmp/dumps
# 4. análise e página
python3 acervo/analisa.py # classifica + baixa e otimiza as thumbs
python3 acervo/sintetiza.py # estatísticas -> stats.json (LEIA a saída!)
python3 acervo/escreve_analise.py
python3 acervo/gera_pagina.pyEntre o passo 3 e o 4, vale rodar garimpa_pequenas.py sobre os dumps: ele encontra
marcas do seu porte que não estavam em lista nenhuma, filtrando por
page_like_count e por vocabulário do nicho.
Escreva textos.py depois de ler a saída do sintetiza.py, nunca antes. As tabelas
são geradas dos dados; os textos são só a interpretação. Número escrito à mão em
textos.py não se atualiza na próxima varredura e vira mentira silenciosa.
Estão aqui para você não repetir:
-
Chrome headless em paralelo devolve dump de 0 byte. O coletor é sequencial de propósito. Se você "otimizar" isso, os dumps voltam vazios e parece bug da técnica.
-
until ! pgrep -f "cdp.py"nunca termina — a linha de comando do próprio shell contém a string e opgrepacha a si mesmo. Usepgrep -f "[c]dp.py". -
Busca por keyword é ruidosa.
q=<marca>casa com o texto do anúncio, não com o anunciante. Sempre confira opage_nameretornado contra seus aliases. -
Antes de reportar "essa marca não anuncia", faça teste de controle. Dump vazio e dump de estado-vazio têm tamanho parecido. Procure a string "Nenhum anúncio corresponde" e rode uma página sabidamente ativa com os mesmos parâmetros.
E se o teste de controle passar mesmo assim, suspeite do
country. A busca porpage_idfiltra por país de veiculação, não só por anunciante — uma marca internacional pode não ter nenhuma campanha voltada pro país que você consultou hoje e ainda assim estar rodando globalmente.country=BRnuma marca só-brasileira é seguro; a mesma query numa marca de outro país pode devolver zero anúncios com uma marca gigante e ativa do outro lado. Já aconteceu: 12 de 14 marcas internacionais "zeraram" comcountry=BRe voltaram normais comcountry=ALL— nenhuma delas tinha parado de anunciar. Pra marca de fora, sempre reconfira comcountry=ALLantes de concluir "inativa". -
display:flexnuma classe anula o atributo[hidden](odisplay:nonedo user-agent perde para qualquer classe). Sem.cr[hidden]{display:none!important}o filtro marca tudo certo e nada some da tela. -
overflow-x:hiddennobodyquebrawindow.scrollTo, âncoras e scrollspy (promove o body a scroll container). Eoverflow-x:clipé pior: clip num eixo força o outro a clip e o viewport perde o scroll inteiro. -
aspect-ratioperde para o flex semin-heightforauto(o padrão do flex item): criativo 9:16 estoura enquanto o 1:1 fica certo. Precisa demin-height:0. -
html.escape(str(s or ""))engole zero —0.0é falsy e vira"", exibindo "%" sem número. E truncar depois do escape corta entidade no meio (&→&am). -
scrollIntoView({block:'nearest'})num link dentro do nav sticky briga com o scroll da página. O scrollspy usa isso pra manter o item ativo visível no nav horizontal (.tn-links{overflow-x:auto}) — mas o link também está dentro deposition:sticky, e nesse caso o navegador não tratablock:'nearest'como "não mexe": ele tenta reposicionar o ancestral sticky no eixo vertical a cada troca de seção, e isso trava/pula o scroll da página inteira (mais perceptível em mobile, onde o nav sempre precisa rolar horizontalmente). Fix: não usarscrollIntoViewnisso — calcular o delta comgetBoundingClientRect()e rolar só o container do nav viatnLinks.scrollTo({left:...}), nuncawindow/body.
E a regra que vale mais que todas: ao verificar um filtro, meça o que RENDERIZA
(offsetParent !== null), não a propriedade que o seu código acabou de escrever
(!el.hidden). Um teste que só confirma a linha anterior do próprio código não pode
falhar — e por isso não prova nada.
- Dados de clientes. Os acervos gerados (marcas, criativos, análises, thumbs) são trabalho de cliente e ficam de fora.
- Credenciais. Os scripts de deploy (FTP) não estão aqui. Publique como preferir — é
só um diretório com
index.html,acervo.jsonemedia/.
Os criativos coletados pertencem aos respectivos anunciantes e vêm de uma base pública
(a Biblioteca de Anúncios da Meta). O acervo gerado é material de referência e análise
— publique com noindex e sem sugerir endosso das marcas exibidas. Respeite os Termos da
Meta ao coletar.
MIT — veja LICENSE.