Medir citação em IA é rodar um conjunto fixo de perguntas, várias vezes, contra um modelo com busca na web, e contar em quantas respostas sua marca aparece no texto e seu domínio aparece entre as fontes citadas. Como a resposta muda de uma execução pra outra, uma pergunta feita uma vez não diz nada; a mesma pergunta feita dez vezes, todo mês, vira uma série que dá pra acompanhar.
Por que isso importa e o que faz uma IA recomendar uma empresa está no pilar de GEO e em Como aparecer no ChatGPT, que sugere o teste manual: perguntar. Este post automatiza esse teste.
Entenda o que o script mede, e o que não mede
O script usa a ferramenta de busca na web da Responses API da OpenAI. Ele não consulta o app do ChatGPT. O app tem memória, personalização e o modelo que estiver no ar naquele dia; a API responde com o modelo e as opções que você escolher. Este post usa a API como indicador: se ela não encontra nem cita você com busca ligada, é pouco provável que o app faça melhor. O inverso não é garantido.
Pra cada resposta, o script grava:
mencionou_marca: o nome (ou um apelido) aparece no texto, ignorando acento e maiúscula.citou_dominio: algum link citado no texto (anotaçãourl_citation) é do seu domínio.dominio_nas_fontes: seu domínio está na lista completa de fontes consultadas, mesmo sem ser citado.concorrentes: quais concorrentes da sua lista foram mencionados.buscas: quantas buscas a resposta fez, que é o que você paga.
A diferença entre dominio_nas_fontes e citou_dominio é a mais útil: estar nas fontes e não ser citado quer dizer que o modelo leu sua página e preferiu outra.
Escreva as perguntas num YAML
marca: Capiva
apelidos: ["Capiva", "capiva.tech"]
dominio: capiva.tech
concorrentes: ["Concorrente A", "Concorrente B"] # exemplo: troque pelos seus
pais: BR
repeticoes: 3
perguntas:
- Quais consultorias de IA no Brasil ajudam empresas a colocar agentes em produção?
- Quem faz diagnóstico de visibilidade em IA (GEO) para sites no Brasil?
- O que é um AI harness e quem escreve sobre isso em português?Escreva perguntas que um cliente faria, sem o nome da sua empresa. "O que a Capiva faz?" mede só se o modelo acha seu site, não se ele te recomenda. A lista de concorrentes serve pra ver quem aparece no seu lugar; os nomes acima são marcadores. Se você ainda não sabe quem são seus concorrentes nas respostas, rode uma vez com a lista vazia e leia as URLs da coluna urls_citadas.
Entenda a resposta da API
Com busca ligada, a saída da Responses API traz um item web_search_call por ação de busca e um item message com o texto. As citações ficam nas anotações do texto:
{
"output": [
{
"type": "web_search_call",
"status": "completed",
"action": {"type": "search", "query": "...", "sources": [{"type": "url", "url": "https://..."}]}
},
{
"type": "message",
"content": [{
"type": "output_text",
"text": "...",
"annotations": [
{"type": "url_citation", "url": "https://...", "title": "...", "start_index": 0, "end_index": 10}
]
}]
}
]
}Esse formato é o da documentação, não uma resposta capturada. Ela mostra query no singular, mas também diz que a ação search costuma trazer queries, uma lista, e o SDK (openai 3.29.0) aceita os dois campos. Modelos com raciocínio podem devolver ainda ações open_page e find_in_page, que têm url e não têm sources. O script não lê nenhum desses campos, então funciona com qualquer variante.
Três detalhes da documentação que o script usa:
sourcessó vem se você pedir, cominclude=["web_search_call.action.sources"]. É a lista completa de URLs consultadas, que costuma ser maior que a de citações.user_locationcomcountry(código ISO de duas letras, comoBR) aproxima os resultados da busca de quem pergunta do Brasil.- Com
tool_choiceemauto, o modelo decide se busca. O script usaautopor padrão, porque é o comportamento normal, e tem a opção--forcar-busca, que mandarequired.
Escreva o script
#!/usr/bin/env python3
"""Mede se o modelo da OpenAI, com busca na web, menciona e cita a sua empresa.
Uso:
python3 medir_citacoes.py monitor.yaml --estimar # só mostra quantas chamadas faria
python3 medir_citacoes.py monitor.yaml # roda e grava resultados.csv
"""
import argparse
import csv
import os
import re
import sys
import unicodedata
from datetime import datetime, timezone
from urllib.parse import urlparse
import yaml
from openai import OpenAI
CUSTO_BUSCA_USD = 0.01 # US$ 10 por 1.000 chamadas de web search, sem contar tokens
def normalizar(texto: str) -> str:
sem_acento = unicodedata.normalize("NFKD", texto).encode("ascii", "ignore").decode()
return sem_acento.lower()
def menciona(texto: str, nomes: list[str]) -> bool:
alvo = normalizar(texto)
return any(re.search(rf"\b{re.escape(normalizar(n))}\b", alvo) for n in nomes)
def do_dominio(url: str, dominio: str) -> bool:
host = (urlparse(url).hostname or "").removeprefix("www.")
return host == dominio or host.endswith("." + dominio)
def analisar(resposta, cfg: dict) -> dict:
citacoes, fontes, buscas = [], [], 0
for item in resposta.output:
if item.type == "web_search_call":
buscas += item.action.type == "search" # só "search" é cobrada
fontes += [s.url for s in (getattr(item.action, "sources", None) or [])]
elif item.type == "message":
for parte in item.content:
for a in getattr(parte, "annotations", None) or []:
if a.type == "url_citation":
citacoes.append(a.url)
texto = resposta.output_text
return {
"buscas": buscas,
"mencionou_marca": menciona(texto, cfg["apelidos"]),
"citou_dominio": any(do_dominio(u, cfg["dominio"]) for u in citacoes),
"dominio_nas_fontes": any(do_dominio(u, cfg["dominio"]) for u in fontes),
"concorrentes": ";".join(
c for c in cfg.get("concorrentes", []) if menciona(texto, [c])
),
"n_citacoes": len(citacoes),
"urls_citadas": " ".join(citacoes),
}
def perguntar(client: OpenAI, modelo: str, pergunta: str, pais: str, forcar: bool):
return client.responses.create(
model=modelo,
tool_choice="required" if forcar else "auto",
tools=[
{
"type": "web_search",
"user_location": {"type": "approximate", "country": pais},
}
],
include=["web_search_call.action.sources"],
input=pergunta,
)
def main(argv=None, client: OpenAI | None = None) -> int:
ap = argparse.ArgumentParser()
ap.add_argument("config")
ap.add_argument("--modelo", default=os.environ.get("MODELO", "gpt-5.5"))
ap.add_argument("--saida", default="resultados.csv")
ap.add_argument("--estimar", action="store_true")
ap.add_argument("--forcar-busca", action="store_true", help="tool_choice=required")
args = ap.parse_args(argv)
with open(args.config, encoding="utf-8") as f:
cfg = yaml.safe_load(f)
total = len(cfg["perguntas"]) * cfg.get("repeticoes", 3)
print(
f"{total} respostas. Com uma busca cada: US$ {total * CUSTO_BUSCA_USD:.2f} + tokens "
"(o modelo pode buscar mais de uma vez, ou nenhuma)"
)
if args.estimar:
return 0
client = client or OpenAI() # lê OPENAI_API_KEY do ambiente
novo = not os.path.exists(args.saida)
with open(args.saida, "a", newline="", encoding="utf-8") as f:
w, total_buscas = None, 0
for pergunta in cfg["perguntas"]:
hits = 0
for rodada in range(1, cfg.get("repeticoes", 3) + 1):
r = perguntar(
client,
args.modelo,
pergunta,
cfg.get("pais", "BR"),
args.forcar_busca,
)
linha = {
"quando": datetime.now(timezone.utc).isoformat(timespec="seconds"),
"modelo": args.modelo,
"pergunta": pergunta,
"rodada": rodada,
**analisar(r, cfg),
}
hits += linha["mencionou_marca"]
total_buscas += linha["buscas"]
if w is None:
w = csv.DictWriter(f, fieldnames=list(linha))
if novo:
w.writeheader()
w.writerow(linha)
print(f"{hits}/{cfg.get('repeticoes', 3)} menções | {pergunta}")
print(
f"{total_buscas} buscas cobradas: US$ {total_buscas * CUSTO_BUSCA_USD:.2f} + tokens"
)
return 0
if __name__ == "__main__":
sys.exit(main())O modelo padrão é o gpt-5.5, que a documentação recomenda pra integrações novas de busca. Troque com --modelo ou a variável MODELO. O CSV é aberto em modo de acréscimo, então cada execução soma linhas com data e modelo, e a série histórica fica num arquivo só.
O casamento de nomes usa limite de palavra depois de tirar os acentos. "Capivá" conta como Capiva; "capivara" não conta. Se o nome da sua empresa é uma palavra comum, coloque apelidos mais específicos, como o domínio.
Teste sem gastar nada
O cliente da OpenAI aceita um http_client do httpx, e o httpx tem um transporte falso, o MockTransport. Com ele, o teste passa pelo SDK de verdade, com o parsing de verdade, sem rede e sem chave:
"""Roda o script de ponta a ponta contra uma API falsa (sem rede, sem custo)."""
import csv
import json
import httpx
from openai import OpenAI
import medir_citacoes as m
PEDIDOS = []
def resposta_falsa(texto, citacoes, fontes):
return {
"id": "resp_teste",
"object": "response",
"created_at": 0,
"model": "gpt-5.5",
"status": "completed",
"parallel_tool_calls": True,
"tool_choice": "auto",
"tools": [],
"output": [
{
"type": "web_search_call",
"id": "ws_1",
"status": "completed",
"action": {
"type": "search",
"query": "x",
"sources": [{"type": "url", "url": u} for u in fontes],
},
},
{
"type": "message",
"id": "msg_1",
"status": "completed",
"role": "assistant",
"content": [
{
"type": "output_text",
"text": texto,
"annotations": [
{
"type": "url_citation",
"start_index": 0,
"end_index": 1,
"url": u,
"title": "t",
}
for u in citacoes
],
}
],
},
],
}
RESPOSTAS = iter(
[
resposta_falsa(
"A Capivá e a Concorrente A fazem isso.",
["https://www.capiva.tech/pt/diagnostico"],
[],
),
resposta_falsa(
"Recomendo a Concorrente B.",
["https://exemplo.com"],
["https://capiva.tech/pt/blog/x"],
),
resposta_falsa("Capivara é um roedor.", [], []), # não pode contar como menção
]
)
def api_falsa(request: httpx.Request) -> httpx.Response:
PEDIDOS.append(json.loads(request.content))
return httpx.Response(200, json=next(RESPOSTAS))
def test_ponta_a_ponta(tmp_path):
cfg = tmp_path / "m.yaml"
cfg.write_text(
"apelidos: [Capiva, capiva.tech]\ndominio: capiva.tech\n"
"concorrentes: [Concorrente A, Concorrente B]\npais: BR\nrepeticoes: 3\n"
'perguntas: ["Quem faz GEO no Brasil?"]\n'
)
cliente = OpenAI(
api_key="teste",
http_client=httpx.Client(transport=httpx.MockTransport(api_falsa)),
)
saida = tmp_path / "r.csv"
assert m.main([str(cfg), "--saida", str(saida)], client=cliente) == 0
pedido = PEDIDOS[0]
assert pedido["tools"][0]["type"] == "web_search"
assert pedido["tools"][0]["user_location"]["country"] == "BR"
assert pedido["include"] == ["web_search_call.action.sources"]
assert pedido["tool_choice"] == "auto"
linhas = list(csv.DictReader(saida.open()))
assert [li["mencionou_marca"] for li in linhas] == ["True", "False", "False"]
assert [li["citou_dominio"] for li in linhas] == ["True", "False", "False"]
assert [li["dominio_nas_fontes"] for li in linhas] == ["False", "True", "False"]
assert linhas[0]["concorrentes"] == "Concorrente A"
assert [li["buscas"] for li in linhas] == ["1", "1", "1"]$ pip install openai httpx pyyaml pytest
$ pytest -q
.
1 passed in 0.58sO teste confere o que vai na requisição (ferramenta web_search, país, include, tool_choice) e o que sai no CSV, inclusive o caso "capivara", que não pode contar como menção.
Um segundo arquivo de teste cobre as variações que a documentação descreve e os erros que você vai encontrar na primeira execução: ações open_page e find_in_page (não são cobradas, então não contam como busca), queries no plural, resposta sem busca, uma fonte de feed sem url, chave errada (401), crédito esgotado (429, código credit_balance_exhausted na lista de erros) e falta de rede:
"""Formatos de resposta e erros que a documentação da OpenAI descreve (sem rede, sem custo)."""
import httpx
import pytest
from openai import APIConnectionError, AuthenticationError, OpenAI, RateLimitError
import medir_citacoes as m
CFG = {"apelidos": ["Capiva", "capiva.tech"], "dominio": "capiva.tech", "concorrentes": []}
def cliente(handler):
return OpenAI(
api_key="teste",
max_retries=0,
http_client=httpx.Client(transport=httpx.MockTransport(handler)),
)
def resposta(output):
return {
"id": "resp_1", "object": "response", "created_at": 0, "model": "gpt-5.5",
"status": "completed", "parallel_tool_calls": True, "tool_choice": "auto",
"tools": [], "output": output,
}
MSG = {
"type": "message", "id": "msg_1", "status": "completed", "role": "assistant",
"content": [{
"type": "output_text", "text": "Veja capiva.tech.",
"annotations": [{"type": "url_citation", "start_index": 0, "end_index": 5,
"url": "https://capiva.tech/pt/blog/x", "title": "t"}],
}],
}
def test_acoes_de_modelo_com_raciocinio():
# search com "queries" (plural), open_page e find_in_page, como na doc
out = [
{"type": "web_search_call", "id": "ws_1", "status": "completed",
"action": {"type": "search", "queries": ["geo brasil", "geo consultoria"],
"sources": [{"type": "url", "url": "https://capiva.tech/pt"}]}},
{"type": "web_search_call", "id": "ws_2", "status": "completed",
"action": {"type": "open_page", "url": "https://capiva.tech/pt"}},
{"type": "web_search_call", "id": "ws_3", "status": "completed",
"action": {"type": "find_in_page", "url": "https://capiva.tech/pt", "pattern": "GEO"}},
MSG,
]
c = cliente(lambda r: httpx.Response(200, json=resposta(out)))
linha = m.analisar(m.perguntar(c, "gpt-5.5", "q", "BR", False), CFG)
assert linha["buscas"] == 1 # open_page e find_in_page não são cobradas
assert linha["dominio_nas_fontes"] and linha["citou_dominio"] and linha["mencionou_marca"]
def test_resposta_sem_busca():
c = cliente(lambda r: httpx.Response(200, json=resposta([MSG])))
assert m.analisar(m.perguntar(c, "gpt-5.5", "q", "BR", False), CFG)["buscas"] == 0
def test_fonte_de_feed_sem_url():
# a doc diz que feeds de terceiros (oai-sports etc.) aparecem em sources, mas não
# mostra o objeto; este formato sem "url" é suposição, pra garantir que o script não quebra
out = [{"type": "web_search_call", "id": "ws_1", "status": "completed",
"action": {"type": "search", "query": "x",
"sources": [{"type": "api", "name": "oai-sports"},
{"type": "url", "url": "https://capiva.tech"}]}}, MSG]
c = cliente(lambda r: httpx.Response(200, json=resposta(out)))
assert m.analisar(m.perguntar(c, "gpt-5.5", "q", "BR", False), CFG)["dominio_nas_fontes"]
def erro(status, code):
return lambda r: httpx.Response(status, json={"error": {"message": code, "type": code, "code": code, "param": None}})
def test_chave_invalida():
with pytest.raises(AuthenticationError):
m.perguntar(cliente(erro(401, "invalid_api_key")), "gpt-5.5", "q", "BR", False)
def test_sem_credito():
with pytest.raises(RateLimitError):
m.perguntar(cliente(erro(429, "credit_balance_exhausted")), "gpt-5.5", "q", "BR", False)
def test_sem_rede():
def cai(r):
raise httpx.ConnectError("sem rede")
with pytest.raises(APIConnectionError):
m.perguntar(cliente(cai), "gpt-5.5", "q", "BR", False)$ pytest -q test_formatos_documentados.py
......
6 passed in 0.64sO max_retries=0 no cliente de teste existe porque o SDK repete sozinho, duas vezes, as respostas 429 e 5xx antes de lançar o erro. Fora do teste, um 429 por falta de crédito leva cerca de 1,5 segundo pra aparecer, e aparece assim:
openai.RateLimitError: Error code: 429 - {'error': {'message': ..., 'code': 'credit_balance_exhausted', ...}}Os dois testes rodam o script contra respostas montadas a partir da documentação. Não rodei contra a API real, então as saídas deste post são exemplos no formato documentado, não medições.
Estime o custo e rode
$ python3 medir_citacoes.py monitor.yaml --estimar
9 respostas. Com uma busca cada: US$ 0.09 + tokens (o modelo pode buscar mais de uma vez, ou nenhuma)Pela tabela de preços, a busca na web custa US$ 10,00 por mil chamadas, mais os tokens do conteúdo da busca, cobrados pelo preço do modelo. A documentação diz que cada ação do tipo search é cobrada, então uma resposta que busca três vezes custa três buscas. Por isso o script conta e soma as buscas no fim. Os tokens dependem do modelo e do tamanho das respostas, e não estão na estimativa.
Com a estimativa aceitável:
export OPENAI_API_KEY=...
python3 medir_citacoes.py monitor.yamlLeia os resultados
#!/usr/bin/env python3
"""Resume resultados.csv: taxa de menção e de citação por pergunta e por mês."""
import csv
import sys
from collections import defaultdict
grupos = defaultdict(lambda: [0, 0, 0]) # rodadas, menções, citações do domínio
with open(
sys.argv[1] if len(sys.argv) > 1 else "resultados.csv", encoding="utf-8"
) as f:
for li in csv.DictReader(f):
g = grupos[(li["quando"][:7], li["pergunta"])]
g[0] += 1
g[1] += li["mencionou_marca"] == "True"
g[2] += li["citou_dominio"] == "True"
print("mês menção citação n pergunta")
for (mes, pergunta), (n, m, c) in sorted(grupos.items()):
print(f"{mes} {m / n:6.0%} {c / n:7.0%} {n:>2} {pergunta[:60]}")Com o CSV que o teste gera (três rodadas de uma pergunta, uma com menção e citação):
mês menção citação n pergunta
2026-10 33% 33% 3 Quem faz GEO no Brasil?Três rodadas são pouco. Com dez por pergunta, uma taxa de 30% já diz alguma coisa. Compare meses, não execuções isoladas, e anote quando o modelo mudou: a coluna modelo existe pra isso.
Pra rodar toda segunda às 9h, uma linha no crontab basta:
0 9 * * 1 cd /caminho/do/monitor && .venv/bin/python medir_citacoes.py monitor.yaml >> monitor.log 2>&1Como usamos na Capiva
Antes de medir citação, conferimos o pré-requisito técnico: o robots.txt do site libera todos os robôs, inclusive o OAI-SearchBot. Esse é o robô que a OpenAI usa pra mostrar sites nas respostas de busca do ChatGPT; segundo a documentação dos crawlers, sites que bloqueiam o OAI-SearchBot não aparecem nessas respostas. Ainda não rodamos o script contra a API com uma chave, então não temos taxa medida da Capiva pra mostrar.
Pra ler o seu resultado, olhe as três colunas juntas. Um exemplo ilustrativo, não medido: se a pergunta sobre GEO no Brasil der 30% de menção, 10% de citação e dominio_nas_fontes verdadeiro em metade das rodadas, o modelo encontra o seu site com frequência, mas cita outra página no lugar. O trabalho então é na página (resposta direta, dado próprio, data visível), não na indexação. Se dominio_nas_fontes ficar sempre falso, o problema vem antes: o robô não acha ou não pode ler o site.
Checklist de troubleshooting
- [ ] Nenhuma busca (
buscas= 0). Comtool_choiceemauto, o modelo pode responder sem buscar. Use--forcar-buscaou reformule a pergunta pra algo que pede informação atual. - [ ] Seu site nunca aparece nas fontes. Confira se o robots.txt não bloqueia o
OAI-SearchBot. Mudanças no robots.txt levam cerca de 24 horas pra valer, segundo a OpenAI. - [ ] Menção sem citação. O modelo conhece a marca, mas cita outra fonte. Veja quais URLs aparecem em
urls_citadas. - [ ] Falso positivo de menção. O nome é palavra comum. Troque por apelidos mais específicos.
- [ ]
Missing credentials. FaltaOPENAI_API_KEYno ambiente. O--estimarfunciona sem chave. - [ ]
AuthenticationError: Error code: 401. A chave está errada ou foi revogada. Confira a variável, não o script. - [ ]
RateLimitError: Error code: 429. Comcredit_balance_exhausted, acabou o crédito pré-pago; com limite de requisições, espere e rode de novo. As linhas gravadas antes do erro continuam no CSV. - [ ] Resultado muda a cada execução. É esperado. Aumente
repeticoese compare taxas, não respostas. - [ ] Custo maior que a estimativa. Respostas com várias buscas, e os tokens do conteúdo da busca. Olhe a soma de buscas no fim da execução.
Perguntas frequentes
Isso mede o que aparece pra quem usa o app do ChatGPT?▼
Não exatamente. Mede o que o modelo da API responde com busca ligada. É uma aproximação útil, não uma réplica do app.
Quantas repetições por pergunta?▼
Comece com 3 pra testar o fluxo e suba pra 10 quando for medir de verdade. O custo cresce linearmente.
Dá pra filtrar só alguns sites na busca?▼
Dá, com filters.allowed_domains ou blocked_domains (até 100 domínios). Pra medir visibilidade, não filtre: você quer a busca como ela é.
Posso usar pra outros modelos?▼
O script é específico da Responses API da OpenAI. A lógica (perguntas fixas, repetição, CSV, taxas) vale pra qualquer API que devolva citações.
Leia também
- O que é GEO / SEO para IA (o pilar)
- Como aparecer no ChatGPT
- Como criar um llms.txt
- Como criar um servidor MCP em Python e configurar no Claude Code, se quiser expor essa medição a um agente
Quer a medição feita e interpretada pro seu site? Faça o Diagnóstico GEO gratuito.