# Construindo um ranking da Copa pareando com o Claude Code: o relato de uma sessão

> Source: <https://dev.to/asouza/construindo-um-ranking-da-copa-pareando-com-o-claude-code-o-relato-de-uma-sessao-509c>
> Published: 2026-06-19 00:54:16+00:00

Este texto é o relato de uma sessão real de trabalho minha com o Claude Code. O objetivo era montar um ranking dos melhores jogadores por posição a cada rodada da Copa do Mundo de 2026. O resultado final desse trabalho — a seleção da 1ª rodada e a régua de cada posição — foi publicado em ["Cada posição tem a sua régua: a seleção da 1ª rodada da Copa"](https://www.oporquedojogo.com.br/cada-posicao-tem-a-sua-regua-a-selecao-da-1a-rodada-da-copa/). O que segue é o bastidor: reconstrói o que aconteceu na sessão, na ordem em que aconteceu, com os becos sem saída, os bugs e as decisões tomadas ao longo do caminho.

O ponto de partida que dei ao agente foi: criar uma estrutura de dados que permita avaliar os melhores jogadores em cada posição da rodada e montar a seleção, de forma reaproveitável para as rodadas seguintes.

Na prática, isso virou um pipeline: pegar os relatórios oficiais da FIFA da primeira rodada, cruzar com estatísticas individuais de jogadores, classificar cada um na posição que de fato ocupou em campo, criar fórmulas de avaliação por posição e, no fim, montar a seleção da rodada. Não foi linear. O relato abaixo segue o que de fato aconteceu.

A primeira tarefa era trivial no papel: baixar os 24 relatórios em PDF da fase de grupos do hub do FIFA Training Centre. O agente fez o fetch da página, listou os 24 jogos da rodada corretamente e partiu para o download. E aí veio o primeiro muro.

Todos os arquivos retornavam código HTTP `000`

. A primeira leitura do agente foi razoável: código `000`

costuma significar conexão bloqueada por sandbox de rede. Mas a hipótese estava incompleta. O agente seguiu investigando, e o diagnóstico real só apareceu quando ele rodou um `nslookup`

no host de onde estava tentando baixar: `media.fifatrainingcentre.com`

retornava `NXDOMAIN`

. O subdomínio simplesmente não existia.

O que tinha acontecido é instrutivo: a URL dos PDFs tinha sido inferida a partir do resumo da página, e o host `media.`

foi essencialmente alucinado. A correção foi voltar ao HTML cru com `curl`

, fazer um grep nos `href`

e descobrir que os links reais estavam no mesmo host da página (`www.fifatrainingcentre.com/media/...`

), inclusive com espaços nos nomes dos arquivos. A partir daí, os 24 PDFs baixaram a 200, cada um com seus cinco e poucos megabytes, renomeados para um esquema limpo do tipo `M07-BRA-V-MAR.pdf`

.

Atribuição: tudo nesta etapa foi do agente. Minha única instrução tinha sido "criei uma pasta chamada copa-2026/rodada-1, baixe os relatórios e coloque lá". O código

`000`

, o`nslookup`

, o diagnóstico do`NXDOMAIN`

e a descoberta das URLs reais aconteceram sem nenhuma intervenção minha.

O PDF da FIFA é rico em estatísticas de equipe, mas pobre em dados individuais detalhados. Para avaliar jogador por jogador eu precisava de outra fonte. Em vez de eu decidir no escuro, fizemos um pequeno bake-off entre bibliotecas.

Testamos primeiro a `soccerdata`

. O agente, para crédito dele, reconheceu um erro próprio no meio do caminho: tinha afirmado que a 1.9.0 trazia um reader do FotMob, e não trazia. Partimos para o reader do FBref, que funcionou, encontrou a partida e devolveu 32 jogadores. Mas a limitação ficou clara: para a Copa, o FBref expõe só o box score básico, sem xG, sem rating, sem mapa de calor. E ele roda em cima de Selenium com chromedriver, o que torna a primeira execução lenta.

Aí pedi para testar a `mobfot`

. Deu 404. O agente foi sondar os endpoints e descobriu que o FotMob tinha migrado a API para outro caminho (`/api/data/...`

), que respondia 200 e, melhor ainda, sem exigir token de anti-bot. O JSON de detalhes da partida vinha com tudo que eu queria: rating por jogador, xG, xGOT, xA, estatísticas categorizadas por bloco (ataque, defesa, duelos, passes), shotmap e coordenadas no campo.

Decisão tomada: FotMob via um cliente próprio e fino, escrito à mão sobre `requests`

, em vez da biblioteca pronta e quebrada. Um cliente de cerca de 400 linhas que eu controlo, em vez de uma dependência de terceiro que já tinha mostrado que quebra quando o provedor muda a rota.

Atribuição: misto. Eu defini quais bibliotecas testar ("me faça um teste utilizando a soccerdata", depois "teste o fluxo usando agora o mobfot"). Toda a parte investigativa foi do agente: reconhecer que a soccerdata não tinha reader do FotMob, mapear a limitação do FBref, levar o 404 da mobfot e descobrir que o FotMob tinha migrado para

`/api/data/...`

. A escolha final pelo cliente próprio também partiu do agente. Eu apenas confirmei que era uma boa ideia.

Quando pedi o cliente do FotMob, fui explícito em uma coisa: ele recebe os parâmetros de busca e devolve os dados categorizados dos jogadores, e não acopla com os PDFs. O resultado foi um módulo independente, com um schema próprio para o jogador (identidade, posição, contexto de jogo, estatísticas categorizadas, shotmap, coordenadas), testável pela linha de comando, sem nenhum conhecimento sobre a FIFA.

Esse cuidado pagou de novo logo adiante. Ao construir o extrator do relatório da FIFA, o agente começou a querer reaproveitar um script de extração que já existia no projeto. Eu interrompi mais de uma vez para cravar a fronteira: aquele script era o extrator do Wyscout, e não devia ser tocado; o relatório Post-Match da FIFA é outra coisa e precisa do seu próprio pipeline. Sem essa intervenção, dois formatos de relatório muito diferentes teriam colidido no mesmo código.

Atribuição: intervenção minha. As duas regras desta seção saíram de prompts diretos meus: "esse cliente recebe os parametros... não acople o nosso client com os pdfs" e "mantenha esse extrair_relatorio, ainda vamos ter wyscout, este é um novo pipeline". Sem isso, o agente teria reusado o script existente.

Extrair as tabelas de dados individuais do PDF da FIFA foi a parte mais áspera. Alguns dos problemas concretos que enfrentamos com `pdfplumber`

:

`extract_table()`

enxergava a caixa do cabeçalho, que tem linhas de grade, mas não as linhas de dados, que não têm grade. E era instável de página para página: em uma página ele pegava um subtítulo de seção em vez do cabeçalho de fato.`\x00`

. A palavra "Offers" virava `O\x00ers`

. Tivemos que casar por substrings em vez do texto completo.A solução que se mostrou robusta foi abandonar a tentativa de inferir a estrutura página a página e cravar os schemas de cada seção em código, já que o template da FIFA é estável. Cada seção passou a ter sua lista exata de colunas, e cada linha só era aceita se batesse a contagem de tokens esperada e os valores fossem numéricos por regex. A linha espúria da data morreu nesse filtro. Resultado final: 32 jogadores por jogo, três seções completas.

Atribuição: do agente. Dentro da fronteira que eu já tinha cravado (pipeline novo, separado do Wyscout), toda a engenharia de extração — o diagnóstico do

`extract_table()`

, a ligadura`\x00`

, a decisão de cravar os schemas em código e o filtro por contagem de tokens — foi do agente, sem direcionamento meu.

Com o jogo do Brasil funcionando, mandei rodar para os 24 PDFs. A extração da FIFA passou em todos. O cruzamento com o FotMob falhou em 12. Dois bugs explicavam quase tudo:

Primeiro, um off-by-one de fuso horário. A data na capa do relatório da FIFA estava um dia antes da data que o FotMob usa para a mesma partida. A correção foi fazer a busca da partida com uma janela de mais ou menos um dia em torno da data informada.

Segundo, nomes de seleções divergentes entre as fontes. "Korea Republic" contra "South Korea", "IR Iran" contra "Iran", "Côte d'Ivoire" contra "Ivory Coast", "Cabo Verde" contra "Cape Verde". Construímos um mapa de aliases com normalização. E aí um detalhe fino quase passou: o apóstrofo de "Côte d'Ivoire" normaliza para espaço, então a chave do alias precisava ser exatamente "cote d ivoire", com o espaço, não "cote divoire". Sem perceber esse detalhe, a Costa do Marfim continuava falhando depois de todo o resto consertado.

Fechamos em 24 de 24 jogos, 753 jogadores, 100% casados, zero avisos. E aqui entrou uma etapa que eu valorizo muito: a validação. O agente cruzou os sobrenomes da FIFA contra os do FotMob em todos os jogadores casados e reportou "123 jogadores casados, zero cruzamentos de nome". O casamento era feito por (lado, número da camisa), e essa checagem independente confirmou que a junção estava correta.

Atribuição: do agente. Eu só dei a partida ("próxima etapa do pipeline é rodar a extração do json para todos os jogos") e deleguei explicitamente a decisão de paralelizar — o agente escolheu rodar sequencial. O off-by-one de fuso, o mapa de aliases, o detalhe do apóstrofo de "Côte d'Ivoire" e a validação cruzada de sobrenomes foram todos do agente.

A "posição" do FotMob é grossa demais para o meu objetivo: ela só diferencia goleiro, defensor, meio e atacante. Eu queria a seleção por posição fina, (GK, CB, LB, RB, DM, CM, AM, W, CF). A estratégia que combinamos foi em camadas: um motor que decodifica a posição fina a partir do `position_id`

e das coordenadas reais dos jogadores; um fallback por zona do campo para casos não vistos; e uma camada de override editorial, um JSON onde eu corrijo manualmente o que a máquina errou, com a maior precedência.

O ponto mais interessante foi a fronteira entre primeiro volante (DM) e meia (CM). A lógica puramente posicional colocava só 14 jogadores como volantes, e jogava verdadeiros primeiros volantes, como Casemiro, na categoria de meia, porque eles jogam adiantados no campo. A ideia de como resolver isso foi minha, mas a execução foi do agente: e se a gente escolher os jogadores de meio de campo e fizer a inferência via LLM? Para cada um, perguntar se ele é primeiro volante, segundo volante ou armador, com base no dossiê de ações dele naquele jogo, e gravar esse mapeamento em um arquivo de consulta.

Foi o que fizemos. Geramos um dossiê por meio-campista (coordenadas, ações defensivas, quebras de linha, chances criadas, xA, toques) e despachamos seis subagentes em paralelo para classificar cada um em DM, CM ou AM, com nível de confiança e justificativa. A distribuição saiu realista: 46 volantes, 74 meias, 45 armadores. A precedência final do classificador ficou: override manual, depois papel inferido por LLM, depois `position_id`

, depois zona, depois a linha grossa.

Tem uma indecisão minha aí que eu acho honesto registrar. Em um momento perguntei se o mapeamento de papéis não valeria para a Copa inteira em vez de por rodada. O agente mudou para Copa inteira. Eu repensei e voltei atrás: melhor por rodada, porque o papel de um jogador pode mudar de jogo para jogo. O agente reverteu sem ruído, e o arquivo de papéis passou a viver dentro da pasta da rodada.

Atribuição: misto, e este é o ponto. A taxonomia de siglas foi proposta pelo agente e aprovada por mim. A estratégia em camadas era uma recomendação que o próprio agente tinha feito antes e que eu resgatei ("lá atrás você tinha sugerido isso aqui: A como motor, B para validar, C para correções"). A ideia de inferir o papel do meio-campo via LLM foi minha ("e se a gente escolher esses jogadores de meio de campo e fizermos a inferência via llm"), assim como a definição do escopo e a escolha de fazer por rodada. A implementação dos dossiês e dos subagentes foi do agente.

Esse episódio é o que melhor resume a tese. Em algum momento eu perguntei, simplesmente: "Paquetá ficou onde?". O agente foi olhar e ele tinha sido classificado como lateral-direito, ranqueado entre os laterais. Paquetá é ponta. Errado.

A investigação revelou que não era um caso isolado. O slot largo da direita misturava laterais (defensores) e pontas (meias e atacantes) sob o mesmo `position_id`

. Trinta e seis jogadores estavam no balde errado, incluindo Raphinha classificado como lateral-esquerdo. A correção foi desambiguar os slots largos ambíguos pela linha do jogador: se a linha é meio ou ataque, é ponta; se é defesa, é lateral. O pool de pontas saltou de 80 para 116, e Hakimi, que joga de fato como lateral, corretamente continuou lateral.

A natureza do erro vale o registro. Não era um crash nem um teste vermelho. Era um ranking que estava perfeitamente "verde", rodando, gerando JSON bonito, e silenciosamente errado para 36 jogadores. Nenhum assert teria pego isso. O que pegou foi uma pergunta de sanidade sobre um caso que eu conhecia.

Atribuição: misto, com papéis bem separados. Quem expôs o bug fui eu, com uma única pergunta ("paquetá ficou onde?"). Quem descobriu que eram 36 jogadores no balde errado e implementou a desambiguação por linha foi o agente.

Para a avaliação, a arquitetura que combinamos separa de propósito duas coisas. A camada editorial fica em JSON: os pesos de cada métrica e o tempo mínimo de jogo por posição, que são decisões de gosto e podem mudar a cada conversa. A mecânica fica em Python: o registro de métricas e o motor de pontuação, que não muda.

A normalização padrão é por percentil dentro do grupo da posição naquela rodada, com soma ponderada de 0 a 100. O agente foi honesto sobre o trade-off do percentil já na explicação: ele mede ordem, não magnitude, e por isso achata os extremos. Construímos as fórmulas uma posição de cada vez, e cada uma foi calibrada contra o ranking real, comigo olhando se o resultado fazia sentido.

O momento mais didático foi o dos pontas. Eu reclamei: "Messi fez 3 gols. Como é que alguém fica na frente dele no ranking?". O agente investigou e encontrou uma falha de método, não de peso. Sob percentil, três gols ficavam quase empatados com um gol, porque o percentil só enxerga a ordem: quem fez mais, fez mais, sem importar o quanto a mais. A correção foi tornar a normalização configurável por métrica e aplicar min-max (que preserva magnitude) a gols e assistências, mantendo percentil no resto. Messi subiu para primeiro. E o agente apontou o custo simétrico da escolha: quem fez um gol só perdeu posições.

Essa sequência teve várias intervenções minhas que mudaram o método, não só os números. Quando sugeri valorizar drible, e depois percebemos que drible isolado não diz muita coisa porque o que importa é o progresso do jogo, tiramos a métrica de drible solto. Quando perguntei se existia métrica para bola perdida no drible, adicionamos uma taxa de insucesso de drible contando negativamente.

Atribuição: misto. As decisões editoriais foram minhas, uma a uma — a divisão de pesos por posição, valorizar gol feito, tirar o drible solto, punir bola perdida. A análise que mostrou por que três gols quase empatavam com um gol sob percentil, e que levou ao min-max, foi do agente, em resposta à minha reclamação sobre o Messi. A mecânica do motor (registro de métricas, normalização configurável) foi do agente.

Perto do fim, ao olhar Casemiro ranqueado entre os volantes, desconfiei de um efeito de amostra: ele jogou 45 minutos, e as métricas por 90 inflavam o volume dele, projetando seis ações defensivas como doze. Pedi para a seleção operar em duas óticas: por 90 minutos e considerando só os dados brutos. Implementamos um modo global em que só as métricas de volume mudam entre as óticas; taxas como porcentagem de passe e totais absolutos como gols evitados não mudam. Na ótica bruta, Casemiro caiu para 27 de 41 e Enzo Fernández assumiu a vaga de volante, confirmando a suspeita.

Atribuição: a desconfiança e o pedido foram meus, a partir de "casemiro tá em 5 de 41?" e "tem como a gente ter o script que extrai os jogadores da seleção operando por duas óticas". A implementação do modo global e a decisão de quais métricas mudam entre as óticas foram do agente.

No fim, transformamos o pipeline inteiro em skills reaproveitáveis do Claude Code, porque o objetivo sempre foi servir as próximas rodadas, não só a primeira. Ficaram três: uma para preparar a rodada (que orquestra a extração, a classificação de posição, o dossiê do meio e a inferência de papéis por LLM), uma para gerar o ranking dado o minuto de corte e a ótica, e uma para filtrar um time específico de um ranking. A configuração editável ficou em JSON; a mecânica, em Python; o extrator do Wyscout, intocado.

A seleção da primeira rodada, com corte de 45 minutos, saiu com Beach no gol, Hakimi e Castagne nas laterais, Singo e Olivera na zaga, Schlager de volante, Bentancur de meia, Pedri de armador, e Messi, Haaland e Luis Díaz na frente.

Atribuição: o pedido de transformar o pipeline em skills foi meu, skill por skill. A estrutura de cada uma e o código foram do agente.

Se eu tivesse que resumir onde o meu trabalho foi insubstituível nessa jornada, não seria em escrever código. O agente escreveu mais e mais rápido do que eu escreveria, e diagnosticou problemas de rede e de API numa velocidade que eu não tenho. O meu papel foi outro:

Nenhuma dessas coisas é sobre digitar código. Todas são sobre julgamento, fronteira e taste. É exatamente esse tipo de habilidade que fica em primeiro plano quando se trabalha sério pareando com um agente de código: você para de ser quem digita e passa a ser quem decide, interroga e responde por estar certo. E essa, para mim, é a competência que vale a pena treinar de propósito agora.

Abraço,

Alberto

PS: este post foi gerado pelo agente de marketing a partir de um log exportado de uma sessão de trabalho minha com o Claude Code, e revisado por mim.
