O que já está de pé
Tudo isto está no ar em produção e não precisa ser refeito. A lista existe para evitar trabalho duplicado — a tentação de reimplementar dedupe e conversão de preço no coletor é grande, e é justamente o que faz as duas pontas divergirem.
O que não existe: geração de link curto de afiliado do Mercado Livre, e qualquer coleta que dependa de navegador. É esse o buraco que o serviço em Python preenche.
O contrato
Autenticação
Token no cabeçalho. Também é aceito Authorization: Bearer <token>, que é o padrão da maioria das bibliotecas HTTP.
Content-Type: application/json X-LDP-Token: SEU_TOKEN
Quem tem esse token escreve no banco do site. Ele vai em cabeçalho, nunca em query string — query string entra em log de servidor, em histórico de proxy e no Referer.
Guarde em variável de ambiente ou cofre, fora do código e fora do git. Se vazar, é só gerar outro na tela de configurações: o antigo morre na hora.
Corpo
Aceita {"ofertas": [...]}, uma lista pura ou um objeto único. O teto é de 200 ofertas por lote.
{
"ofertas": [
{
"marketplace": "mercadolivre",
"externo_id": "MLB3901234567",
"titulo": "Fone Bluetooth JBL Tune 520BT Preto",
"preco_atual": 199.90,
"preco_antigo": 399.00,
"desconto": 50,
"url_produto": "https://produto.mercadolivre.com.br/MLB-3901234567-...",
"url_afiliado": "https://mercadolivre.com/sec/1AbCdEf",
"url_imagem": "https://http2.mlstatic.com/D_NQ_NP_123456-MLB.jpg",
"categoria": "Eletrônicos",
"avaliacao": 4.7,
"tem_variacao": true,
"rotulo_variacao": "Cor",
"loja": "JBL Oficial",
"expira_em": "2026-08-21T23:59:00-03:00",
"extras": { "comissao": 5.5, "vendas": 1420 }
}
]
}
Campos da oferta
| Campo | Tipo | Obrig. | Observação |
|---|---|---|---|
marketplace | texto | sim | mercadolivre, shopee, aliexpress, amazon, magalu. Variações são reconhecidas (meli, ML, Magazine Luiza); o que não for vira outro e continua aparecendo. |
titulo | texto | sim | Máx. 400 caracteres. HTML é removido. |
preco_atual | número/texto | sim | Em reais. Aceita 199.9, "199,90" e "R$ 1.299,90" — a conversão para centavos é do servidor. |
preco_antigo | número/texto | não | Ausente ou menor que o atual? É recomposto a partir do desconto. |
desconto | número | não | Aceita 35 e 0.35. Se não vier, é calculado dos dois preços. |
url_produto | URL | um dos dois | Link cru do produto. |
url_afiliado | URL | um dos dois | É este que o /go/ usa. Sem ele, cai no url_produto. |
url_imagem | URL | não | Sem imagem o cartão fica sem foto e o disparo vai como texto puro. |
externo_id | texto | recomendado | ID do item no marketplace. É o que impede duplicata. |
categoria | texto | não | Só entra como reserva — o título classifica primeiro. |
avaliacao | número | não | 0 a 5. |
tem_variacao | booleano | não | Liga o selo de variação no cartão. |
rotulo_variacao | texto | não | "Cor", "Modelo", "A partir de"… |
loja | texto | não | Nome do vendedor. |
expira_em | data | não | ISO 8601 ou Y-m-d H:i:s. Ao passar da data a oferta some da vitrine, mesmo antes da faxina. |
capturado_em | data | não | Padrão: agora. Define a idade — e é a idade que faz expirar em 48 h. |
extras | objeto | não | JSON livre: comissão, número de vendas, o que for. |
O que o servidor faz com cada oferta
Não é só gravar. Toda oferta — venha do conector interno ou da rota de ingestão — passa por ldp_salvar_oferta(), nesta ordem:
- Título — tira HTML, corta em 400 caracteres. Vazio ⇒ recusa.
- Preço — converte para centavos aceitando as três notações. Zero ou negativo ⇒ recusa.
- Desconto — normaliza a escala (0,35 vira 35); se não veio, calcula pelos preços.
- Preço antigo — se for menor ou igual ao atual, recompõe pelo desconto. Evita "de R$ 0,00 por R$ 89,90".
- Corte — abaixo do desconto mínimo configurado ⇒ recusa com
ldp_desconto_baixo. - Categoria — classifica pelo título com dicionário de palavras-chave; a categoria enviada só entra se o título não resolver e se ela parecer uma categoria (curta, sem trilha
>, sem "Ver mais"). - Digital —
sha1(marketplace + externo_id), ou o título normalizado quando não há id. É índice UNIQUE no banco. - Grava ou atualiza — digital já existente ⇒ atualiza preço, imagem e prazo, sem mexer em
capturado_em, em cliques nem no status de envio.
Reenviar a mesma oferta é seguro. Ela atualiza o preço sem duplicar e sem rejuvenescer — por isso dá para rodar de 30 em 30 minutos sem controle de estado do lado do coletor.
Resposta e erros
Um item ruim não derruba o lote: ele volta em detalhes com o motivo, e o resto entra.
{
"recebidas": 50,
"criadas": 37,
"atualizadas": 11,
"recusadas": 2,
"detalhes": [
{ "indice": 12, "codigo": "ldp_desconto_baixo",
"motivo": "Desconto de 6% abaixo do mínimo de 10%." }
]
}
Recusas por item
| Código | Significa |
|---|---|
ldp_sem_titulo | título vazio |
ldp_sem_preco | preco_atual ausente, zero ou ilegível |
ldp_sem_link | nenhum dos dois links veio |
ldp_desconto_baixo | abaixo do mínimo configurado — comportamento esperado, não é erro |
ldp_falha_insert | erro do banco (raro; olhar o diário de execuções no painel) |
Erros da requisição inteira
| HTTP | Quando | O que fazer |
|---|---|---|
401 | token ausente ou errado | parar; não adianta repetir |
403 | ingestão desligada nas configurações | parar e avisar |
400 | corpo não é JSON, ou não é uma lista | corrigir o payload |
413 | lote acima de 200 ofertas | dividir em partes |
5xx | o servidor tropeçou | tentar de novo com espera crescente |
Exemplos
curl -X POST https://loucuradepromo.com.br/wp-json/ldp/v1/ofertas \ -H "Content-Type: application/json" \ -H "X-LDP-Token: SEU_TOKEN" \ -d '{"ofertas":[{"marketplace":"shopee","externo_id":"123456", "titulo":"Teste","preco_atual":49.9,"preco_antigo":99.9, "url_afiliado":"https://s.shopee.com.br/abc"}]}'
import os, time, requests SITE = "https://loucuradepromo.com.br" TOKEN = os.environ["LDP_TOKEN"] # nunca no código, nunca no git LOTE = 100 # o teto do servidor é 200 sessao = requests.Session() sessao.headers.update({ "Content-Type": "application/json", "X-LDP-Token": TOKEN, "User-Agent": "coletor-ldp/1.0", }) def enviar(ofertas): """Manda em lotes. Devolve o total criado e o total atualizado.""" criadas = atualizadas = 0 for inicio in range(0, len(ofertas), LOTE): fatia = ofertas[inicio:inicio + LOTE] for tentativa in range(3): try: r = sessao.post(f"{SITE}/wp-json/ldp/v1/ofertas", json={"ofertas": fatia}, timeout=60) if r.status_code == 200: dados = r.json() criadas += dados["criadas"] atualizadas += dados["atualizadas"] for recusa in dados["detalhes"]: # desconto baixo é rotina, não vale log if recusa["codigo"] != "ldp_desconto_baixo": print("recusada:", recusa) break if r.status_code in (401, 403, 413): raise SystemExit(f"parar: {r.status_code} {r.text[:200]}") time.sleep(2 ** tentativa) # 5xx: tenta de novo except requests.RequestException: if tentativa == 2: raise time.sleep(2 ** tentativa) return criadas, atualizadas
Ping e status
GET /wp-json/ldp/v1/ping responde {"ok": true} — serve para o serviço testar a credencial antes de coletar qualquer coisa.
GET /wp-json/ldp/v1/status devolve o espelho do estado, útil para decidir se precisa coletar mais e para descobrir de fora que a vitrine esvaziou:
{
"total_ofertas": 613,
"por_marketplace": [
{ "marketplace": "shopee", "total": 415, "ultima": "2026-08-19 22:14:03" }
],
"fila_whatsapp": 27,
"execucoes": [
{ "tarefa": "ingestao", "situacao": "ok", "encontradas": 50, "novas": 37 }
],
"horas_de_vida": 48,
"desconto_minimo": 10
}
Os dois exigem o mesmo token.
Banco de dados
Três tabelas no MySQL do próprio WordPress, prefixo wp_. Não é preciso escrever nelas — a rota faz isso —, mas ajuda a entender o que acontece com o que você manda.
| Tabela | O que guarda | Vida |
|---|---|---|
wp_ldp_ofertas | a oferta viva; preços em centavos (inteiro), impressao_hash UNIQUE, status de envio | 48 h |
wp_ldp_cliques | um registro por clique no /go/; sem IP — só um hash com sal do site | 90 dias |
wp_ldp_execucoes | diário de bordo de coleta, ingestão, disparo e faxina | 30 dias |
Dinheiro é inteiro em centavos, do banco à tela. 0.1 + 0.2 não dá 0.3 em nenhuma linguagem com IEEE 754, e num somatório de comissão o erro aparece.
A duplicata é barrada pelo banco, não pela aplicação. Regra em PHP falha quando dois conectores gravam o mesmo item ao mesmo tempo; índice único não.
Ambiente
O agendador do WordPress está desligado no wp-config.php e quem acorda o site é um cron real do painel, a cada 5 minutos: /usr/bin/php /home/u695862969/public_html/wp-cron.php. Três tarefas rodam por ele — coleta (30 min), disparo (5 min) e faxina (1 h).
A rota /go/ manda três cabeçalhos de no-cache mais o aviso ao LiteSpeed. Sem isso o servidor guardaria a primeira resposta e serviria a mesma para todo mundo — o clique pararia de contar e a prévia sairia errada. Se mexer nessa rota, mantenha os cabeçalhos.
Onde mexer no código
Plugin ldp-ofertas, em wp-content/plugins/. O código-fonte completo vai no pacote junto com este documento.
| Arquivo | O quê |
|---|---|
inc/ingestao.php | as rotas REST e a autorização |
inc/oferta.php | ldp_salvar_oferta() — todas as regras da seção anterior |
inc/categorias.php | o dicionário de classificação por palavra-chave |
inc/banco.php | o schema das três tabelas |
inc/conectores.php | o registro de conectores e o contrato de um conector novo |
inc/go.php | a rota /go/, prévia e contagem de clique |
inc/whatsapp.php | fila e disparo pela Z-API |
inc/agenda.php | os três agendamentos |
Se preferir rodar dentro do WordPress
Dá para registrar um conector em PHP em vez de empurrar de fora — mesmo destino, sem rede no meio. O contrato é curto: uma função ativo e uma coletar que devolve a lista no mesmo formato desta página, ou um WP_Error. O conector não grava, não decide desconto mínimo, não classifica categoria e não checa duplicata — isso é tudo de ldp_salvar_oferta(), num lugar só.
O que falta
O site está no ar e a porta está aberta. O que ainda não existe é oferta entrando:
- Mercado Livre — é o único dos três sem API de afiliado aberta. Há um conector interno que lê a lista pública, mas ele quebra quando o ML mexe no HTML, e o link curto oficial (
mercadolivre.com/sec/…) não é gerável por HTTP simples. Quando o Python assumir a geração desse link, ele manda a oferta pronta pela rota e o conector interno pode ser desligado sem tocar em mais nada. - Shopee — Affiliate Open API (GraphQL). Falta AppId e Secret. Assinatura é
sha256(AppId + timestamp + corpo + Secret), timestamp em segundos: "invalid signature" com credencial certa costuma ser relógio do servidor. - AliExpress — Open Platform. Falta app_key, app_secret e tracking_id. Sem o tracking_id o
promotion_linkvolta vazio e a oferta vira link sem comissão. - Z-API — instância, token, Client-Token e o ID do grupo, para o disparo automático começar.
Mande externo_id sempre que houver. Sem ele a duplicata é barrada pelo título normalizado, que erra quando o vendedor muda uma palavra.
Não filtre desconto do seu lado. Mande tudo; o corte é do servidor, num lugar só. Se a régua mudar, muda na tela e o coletor não precisa de deploy.
Trate 401 e 403 como parada, não como retentativa.
O token de ingestão fica no painel do WordPress, em Loucura de Promo → Ingestão externa. Ele não está escrito nesta página de propósito — peça ao responsável pelo site e guarde fora do código.