Toda agência de marketing digital tem alguém cujo trabalho envolve abrir uma planilha, visitar a URL de cada cliente, verificar a tag de título, meta descrição e H1, anotar links quebrados e colar tudo em um relatório. Então faça isso novamente na próxima semana.
Esse trabalho é determinístico. Um agente pode fazer isso.
Neste tutorial, você construirá um agente de auditoria de SEO local do zero usando Python, uso de navegador e a API Claude. O agente visita páginas reais em uma janela visível do navegador, extrai sinais de SEO usando Claude, verifica links quebrados de forma assíncrona, lida com casos extremos com uma pausa humana e escreve um relatório estruturado – tudo retomável se interrompido.
No final, você terá um agente funcional que poderá executar em qualquer lista de URLs. Custa menos de US$ 0,01 por URL para ser executado.
O que você construirá
Um agente Python de sete módulos que:
Lê uma lista de URLs de um arquivo CSV
Visita cada URL em um navegador Chromium real (não em um raspador sem cabeça)
Extrai título, meta descrição, H1s e tag canônica via API Claude
Verifica links quebrados de forma assíncrona usando httpx
Detecta casos extremos (404s, paredes de login, redirecionamentos) e pausa para entrada humana
Grava resultados em
report.jsonincrementalmente – seguro para interromper e retomarGera um inglês simples
report-summary.txtna conclusão
O código completo está no GitHub em dannwaneri / agente de SEO.
Pré-requisitos
Índice
Por que usar o navegador em vez de um raspador
A abordagem padrão para auditoria de SEO é buscar o HTML da página com requests e analise-o com BeautifulSoup. Isso funciona em páginas estáticas. Ele quebra no conteúdo renderizado por JavaScript, perde metatags injetadas dinamicamente e falha totalmente em páginas autenticadas.
O uso do navegador (mais de 84.000 estrelas do GitHub, licença MIT) adota uma abordagem diferente. Ele controla um navegador Chromium real, lê o DOM após a execução do JavaScript e expõe a página por meio da árvore de acessibilidade do Playwright. O agente vê o que um humano veria.
A diferença prática: um raspador baseado em solicitações pode perder uma meta descrição injetada por um componente React. O uso do navegador não.
A outra diferença que vale a pena citar: o uso do navegador lê as páginas semanticamente. Um script Playwright é interrompido quando a classe CSS de um botão muda de btn-primary para button-main. O uso do navegador identifica que ainda é um botão “Enviar” e age de acordo. A lógica de extração reside no prompt do Claude, não em seletores CSS frágeis.
Estrutura do Projeto
seo-agent/
├── index.py # Main audit loop
├── browser.py # Browser Use / Playwright page driver
├── extractor.py # Claude API extraction layer
├── linkchecker.py # Async broken link checker
├── hitl.py # Human-in-the-loop pause logic
├── reporter.py # Report writer
├── state.py # State persistence (resume on interrupt)
├── input.csv # Your URL list
├── requirements.txt
├── .env.example
└── .gitignore
Configurar
Crie uma pasta de projeto e instale as dependências:
mkdir seo-agent && cd seo-agent
pip install browser-use anthropic playwright httpx
playwright install chromium
Criar input.csv com seus URLs:
url
https://example.com
https://example.com/about
https://example.com/contact
Criar .env.example:
ANTHROPIC_API_KEY=your-key-here
Defina sua chave de API como uma variável de ambiente antes de executar:
# macOS/Linux
export ANTHROPIC_API_KEY="sk-ant-..."
# Windows PowerShell
$env:ANTHROPIC_API_KEY = "sk-ant-..."
Criar .gitignore:
state.json
report.json
report-summary.txt
.env
__pycache__/
*.pyc
Módulo 1: Gestão do Estado
O agente precisa rastrear quais URLs já auditou. Se a execução for interrompida – corte de energia, interrupção do teclado, erro de rede – ela deverá continuar de onde parou, e não recomeçar.
state.py lida com isso com um arquivo JSON simples:
import json
import os
STATE_FILE = os.path.join(os.path.dirname(__file__), "state.json")
_DEFAULT_STATE = {"audited": (), "pending": (), "needs_human": ()}
def load_state() -> dict:
if not os.path.exists(STATE_FILE):
save_state(_DEFAULT_STATE.copy())
with open(STATE_FILE, encoding="utf-8") as f:
return json.load(f)
def save_state(state: dict) -> None:
with open(STATE_FILE, "w", encoding="utf-8") as f:
json.dump(state, f, indent=2)
def is_audited(url: str) -> bool:
return url in load_state()("audited")
def mark_audited(url: str) -> None:
state = load_state()
if url not in state("audited"):
state("audited").append(url)
save_state(state)
def add_to_needs_human(url: str) -> None:
state = load_state()
if url not in state("needs_human"):
state("needs_human").append(url)
save_state(state)
O design é intencional: mark_audited() é chamado imediatamente após um URL ser processado e gravado no relatório. Se o agente travar no meio da execução, ele perderá no máximo o trabalho de uma URL.
Módulo 2: Integração com Navegador
browser.py faz a navegação real da página. Ele usa o Playwright diretamente (que o Browser Use instala como uma dependência) para abrir uma janela visível do Chromium, navegar até o URL, capturar o status HTTP e redirecionar informações e extrair os sinais brutos de SEO do DOM.
As principais decisões de design:
Navegador visível, não sem cabeça. Definir headless=False para que você possa assistir o agente trabalhar. Isso é importante para a demonstração e para a depuração.
Captura de status via ouvinte de resposta. O dramaturgo levanta uma exceção nas respostas 4xx/5xx, mas o on("response", ...) manipulador é acionado antes da exceção. Capturamos status lá.
Atraso de 2 segundos entre visitas. Impede o acionamento de limitação de taxa ou detecção de bot em sites de clientes de agências.
Aqui está a função principal de navegação:
import asyncio
import sys
import time
from playwright.sync_api import sync_playwright, TimeoutError as PlaywrightTimeout
TIMEOUT = 20_000 # 20 seconds
def fetch_page(url: str) -> dict:
result = {
"final_url": url,
"status_code": None,
"title": None,
"meta_description": None,
"h1s": (),
"canonical": None,
"raw_links": (),
}
first_status = {"code": None}
with sync_playwright() as p:
browser = p.chromium.launch(headless=False)
page = browser.new_page()
def on_response(response):
if first_status("code") is None:
first_status("code") = response.status
page.on("response", on_response)
try:
page.goto(url, wait_until="domcontentloaded", timeout=TIMEOUT)
result("status_code") = first_status("code") or 200
result("final_url") = page.url
# Extract SEO signals from DOM
result("title") = page.title() or None
result("meta_description") = page.evaluate(
"() => { const m = document.querySelector('meta(name="description")'); "
"return m ? m.getAttribute('content') : null; }"
)
result("h1s") = page.evaluate(
"() => Array.from(document.querySelectorAll('h1')).map(h => h.innerText.trim())"
)
result("canonical") = page.evaluate(
"() => { const c = document.querySelector('link(rel="canonical")'); "
"return c ? c.getAttribute('href') : null; }"
)
result("raw_links") = page.evaluate(
"() => Array.from(document.querySelectorAll('a(href)'))"
".map(a => a.href).filter(Boolean).slice(0, 100)"
)
except PlaywrightTimeout:
result("status_code") = first_status("code") or 408
except Exception as exc:
print(f"(browser) Error: {exc}", file=sys.stderr)
result("status_code") = first_status("code")
finally:
browser.close()
time.sleep(2)
return result
Algumas coisas dignas de nota:
O raw_links limite de 100 é deliberado. As páginas de perfil DEV.to têm centenas de links – você não precisa de todos eles para detectar links quebrados.
O wait_until="domcontentloaded" a configuração é mais rápida do que networkidle e suficiente para extração de meta tags. O conteúdo renderizado em JavaScript precisa que o DOM esteja pronto, e nem todas as solicitações de rede sejam concluídas.
extractor.py tira o instantâneo da página bruta de browser.py e chama Claude para produzir um resultado estruturado de auditoria de SEO.
É aqui que a maioria dos tutoriais erram. Eles escrevem lógica de análise complexa em Python (frágil) ou pedem a Claude uma resposta de formato livre e tentam analisar a prosa (não confiável). A abordagem correta: forneça a Claude um esquema JSON estrito e diga para ele não retornar mais nada.
A engenharia imediata que torna isso confiável:
import json
import os
import sys
from datetime import datetime, timezone
import anthropic
MODEL = "claude-sonnet-4-20250514"
client = anthropic.Anthropic(api_key=os.environ.get("ANTHROPIC_API_KEY"))
def _strip_fences(text: str) -> str:
"""Remove accidental markdown code fences from Claude's response."""
text = text.strip()
if text.startswith("```"):
lines = text.splitlines()
# Drop opening fence
lines = lines(1:) if lines(0).startswith("```") else lines
# Drop closing fence
if lines and lines(-1).strip() == "```":
lines = lines(:-1)
text = "n".join(lines).strip()
return text
def extract(snapshot: dict) -> dict:
if not os.environ.get("ANTHROPIC_API_KEY"):
raise OSError("ANTHROPIC_API_KEY is not set.")
prompt = f"""You are an SEO auditor. Analyze this page snapshot and return ONLY a JSON object.
No prose. No explanation. No markdown fences. Raw JSON only.
Page data:
- URL: {snapshot.get('final_url')}
- Status code: {snapshot.get('status_code')}
- Title: {snapshot.get('title')}
- Meta description: {snapshot.get('meta_description')}
- H1 tags: {snapshot.get('h1s')}
- Canonical: {snapshot.get('canonical')}
Return this exact schema:
{{
"url": "string",
"final_url": "string",
"status_code": number,
"title": {{"value": "string or null", "length": number, "status": "PASS or FAIL"}},
"description": {{"value": "string or null", "length": number, "status": "PASS or FAIL"}},
"h1": {{"count": number, "value": "string or null", "status": "PASS or FAIL"}},
"canonical": {{"value": "string or null", "status": "PASS or FAIL"}},
"flags": ("array of strings describing specific issues"),
"human_review": false,
"audited_at": "ISO timestamp"
}}
PASS/FAIL rules:
- title: FAIL if null or length > 60 characters
- description: FAIL if null or length > 160 characters
- h1: FAIL if count is 0 (missing) or count > 1 (multiple)
- canonical: FAIL if null
- flags: list every failing field with a clear description
- audited_at: use current UTC time in ISO 8601 format"""
response = client.messages.create(
model=MODEL,
max_tokens=1000,
messages=({"role": "user", "content": prompt}),
)
raw = response.content(0).text
clean = _strip_fences(raw)
try:
return json.loads(clean)
except json.JSONDecodeError as exc:
print(f"(extractor) JSON parse error: {exc}", file=sys.stderr)
return _error_result(snapshot, str(exc))
def _error_result(snapshot: dict, reason: str) -> dict:
return {
"url": snapshot.get("final_url", ""),
"final_url": snapshot.get("final_url", ""),
"status_code": snapshot.get("status_code"),
"title": {"value": None, "length": 0, "status": "ERROR"},
"description": {"value": None, "length": 0, "status": "ERROR"},
"h1": {"count": 0, "value": None, "status": "ERROR"},
"canonical": {"value": None, "status": "ERROR"},
"flags": (f"Extraction error: {reason}"),
"human_review": True,
"audited_at": datetime.now(timezone.utc).isoformat(),
}
Duas coisas tornam isso confiável na produção:
Primeiro, _strip_fences() lida com o caso em que Claude envolve sua resposta em ```json cercas, apesar de terem sido instruídos a não fazê-lo. Isso acontece ocasionalmente com o Sonnet e quebra consistentemente json.loads() se você não lidar com isso.
Em segundo lugar, o _error_result() fallback significa que o agente nunca trava com uma resposta incorreta de Claude – ele registra o erro e marca o URL para revisão humana e, em seguida, continua para o próximo URL.
Custo: Claude Sonnet 4 custa (3 por milhão de tokens de entrada e )15 por milhão de tokens de saída. Um instantâneo de página típico tem cerca de 500 tokens de entrada; a resposta JSON estruturada tem cerca de 300 tokens de saída. Isso equivale a aproximadamente (0,006 por URL – cerca de )0,12 para uma auditoria de 20 URLs.
Módulo 4: Verificador de link quebrado
linkchecker.py leva o raw_links list do instantâneo do navegador e verifica links do mesmo domínio em busca de status quebrado usando solicitações HEAD assíncronas.
As opções de design:
Somente no mesmo domínio. Verificar todos os links externos em uma página levaria alguns minutos e não é o que os clientes da agência precisam. Filtre links no mesmo domínio da página que está sendo auditada.
Solicitações HEAD, não GET. Largura de banda mais rápida e menor, suficiente para detecção de código de status.
Limite de 50 links. Páginas como listagens de artigos DEV.to têm centenas de links internos. Verificar todos eles dominaria o tempo de execução.
Solicitações simultâneas via assíncio. Todos os links são verificados em paralelo, não sequencialmente.
import asyncio
import logging
from urllib.parse import urlparse
import httpx
CAP = 50
TIMEOUT = 5.0
logger = logging.getLogger(__name__)
def _same_domain(link: str, final_url: str) -> bool:
if not link:
return False
lower = link.strip().lower()
if lower.startswith(("#", "mailto:", "javascript:", "tel:", "data:")):
return False
try:
page_host = urlparse(final_url).netloc.lower()
parsed = urlparse(link)
return parsed.scheme in ("http", "https") and parsed.netloc.lower() == page_host
except Exception:
return False
async def _check_link(client: httpx.AsyncClient, url: str) -> tuple(str, bool):
try:
resp = await client.head(url, follow_redirects=True, timeout=TIMEOUT)
return url, resp.status_code != 200
except Exception:
return url, True # Timeout or connection error = broken
async def _run_checks(links: list(str)) -> list(str):
async with httpx.AsyncClient() as client:
results = await asyncio.gather(*(_check_link(client, url) for url in links))
return (url for url, broken in results if broken)
def check_links(raw_links: list(str), final_url: str) -> dict:
same_domain = (l for l in raw_links if _same_domain(l, final_url))
capped = len(same_domain) > CAP
if capped:
logger.warning("Page has %d same-domain links — capping at %d.", len(same_domain), CAP)
same_domain = same_domain(:CAP)
broken = asyncio.run(_run_checks(same_domain))
return {
"broken": broken,
"count": len(broken),
"status": "FAIL" if broken else "PASS",
"capped": capped,
}
Módulo 5: Humano no Loop
Esta é a parte que a maioria dos tutoriais de automação ignora. O que acontece quando o agente atinge uma parede de login? Uma página que retorna 403? Um URL que redireciona para uma página “Inscreva-se para continuar lendo”?
A maioria dos scripts trava ou pula silenciosamente. Nenhum dos dois é aceitável no contexto de uma agência.
hitl.py lida com isso com duas funções: uma que detecta se uma pausa é necessária e outra que trata da pausa em si.
from state import add_to_needs_human
LOGIN_KEYWORDS = {"login", "sign in", "sign-in", "access denied", "log in", "unauthorized"}
REDIRECT_CODES = {301, 302, 307, 308}
def should_pause(snapshot: dict) -> bool:
code = snapshot.get("status_code")
# Navigation failed entirely
if code is None:
return True
# Non-200, non-redirect
if code != 200 and code not in REDIRECT_CODES:
return True
# Login wall detection
title = (snapshot.get("title") or "").lower()
h1s = (h.lower() for h in (snapshot.get("h1s") or ()))
if any(kw in title for kw in LOGIN_KEYWORDS):
return True
if any(kw in h1 for kw in LOGIN_KEYWORDS for h1 in h1s):
return True
return False
def pause_reason(snapshot: dict) -> str:
code = snapshot.get("status_code")
if code is None:
return "Navigation failed (None status)"
if code != 200 and code not in REDIRECT_CODES:
return f"Unexpected status code: {code}"
return "Possible login wall detected"
def pause_and_prompt(url: str, reason: str) -> str:
print(f"n⚠️ HUMAN REVIEW NEEDED")
print(f" URL: {url}")
print(f" Reason: {reason}")
print(f" Options: (s) skip (r) retry (q) quitn")
while True:
choice = input("Your choice: ").strip().lower()
if choice in ("s", "r", "q"):
return {"s": "skip", "r": "retry", "q": "quit"}(choice)
print(" Enter s, r, or q.")
O should_pause() A função captura quatro casos: falha de navegação, status HTTP inesperado, palavras-chave de login no título e palavras-chave de login em tags H1. A verificação da palavra-chave de login é o que captura as páginas “Faça login para continuar” que retornam 200, mas estão efetivamente inacessíveis.
Em --auto modo (para execuções agendadas), o loop principal ignora o pause_and_prompt() chamada e lida automaticamente com esses casos registrando o URL em needs_human() no estado e continuando.
Módulo 6: Redator de Relatórios
reporter.py grava os resultados de forma incremental. Isso é importante: os resultados são gravados após a auditoria de cada URL, e não em lote no final. Se a execução for interrompida, você não perderá o trabalho concluído.
import json
import os
from datetime import datetime, timezone
REPORT_JSON = os.path.join(os.path.dirname(__file__), "report.json")
REPORT_TXT = os.path.join(os.path.dirname(__file__), "report-summary.txt")
def _load_report() -> list:
if not os.path.exists(REPORT_JSON):
return ()
with open(REPORT_JSON, encoding="utf-8") as f:
return json.load(f)
def write_result(result: dict) -> None:
"""Append or update a result in report.json."""
entries = _load_report()
url = result.get("url", "")
# Update existing entry if URL already present (handles retries)
for i, entry in enumerate(entries):
if entry.get("url") == url:
entries(i) = result
break
else:
entries.append(result)
with open(REPORT_JSON, "w", encoding="utf-8") as f:
json.dump(entries, f, indent=2, ensure_ascii=False)
def _is_overall_pass(result: dict) -> bool:
fields = ("title", "description", "h1", "canonical")
for field in fields:
if result.get(field, {}).get("status") not in ("PASS",):
return False
if result.get("broken_links", {}).get("status") == "FAIL":
return False
return True
def write_summary() -> None:
entries = _load_report()
passed = sum(1 for e in entries if _is_overall_pass(e))
lines = ()
for entry in entries:
overall = "PASS" if _is_overall_pass(entry) else "FAIL"
failed_fields = (
f for f in ("title", "description", "h1", "canonical", "broken_links")
if entry.get(f, {}).get("status") == "FAIL"
)
suffix = f" ({', '.join(failed_fields)})" if failed_fields else ""
lines.append(f"{entry.get('url', 'unknown'):<60} | {overall}{suffix}")
lines.append("")
lines.append(f"{passed}/{len(entries)} URLs passed")
with open(REPORT_TXT, "w", encoding="utf-8") as f:
f.write("n".join(lines))
A desduplicação em write_result() lida com novas tentativas de forma limpa. Se uma URL for tentada novamente depois que um humano revisar um mural de login e autenticar, o novo resultado substituirá o antigo em vez de criar uma entrada duplicada.
Módulo 7: O Loop Principal
index.py conecta tudo junto. Ele lê a lista de URLs, carrega o estado, ignora URLs já auditados e executa o loop de auditoria.
import csv
import os
import sys
import time
import argparse
from state import load_state, is_audited, mark_audited, add_to_needs_human
from browser import fetch_page
from extractor import extract
from linkchecker import check_links
from hitl import should_pause, pause_reason, pause_and_prompt
from reporter import write_result, write_summary
INPUT_CSV = os.path.join(os.path.dirname(__file__), "input.csv")
def read_urls(path: str) -> list(str):
with open(path, newline="", encoding="utf-8") as f:
return (row("url").strip() for row in csv.DictReader(f) if row.get("url", "").strip())
def run(auto: bool = False):
if not os.environ.get("ANTHROPIC_API_KEY"):
print("Error: ANTHROPIC_API_KEY environment variable is not set.")
sys.exit(1)
urls = read_urls(INPUT_CSV)
pending = (u for u in urls if not is_audited(u))
print(f"Starting audit: {len(pending)} pending, {len(urls) - len(pending)} already done.n")
total = len(urls)
try:
for i, url in enumerate(pending, start=1):
position = urls.index(url) + 1
print(f"({position}/{total}) {url}", end=" -> ", flush=True)
# Browser navigation
snapshot = fetch_page(url)
# Human-in-the-loop check
if should_pause(snapshot):
reason = pause_reason(snapshot)
if auto:
print(f"AUTO-SKIPPED ({reason})")
add_to_needs_human(url)
mark_audited(url)
continue
action = pause_and_prompt(url, reason)
if action == "quit":
print("Exiting.")
break
elif action == "skip":
add_to_needs_human(url)
mark_audited(url)
continue
# "retry" falls through to re-fetch below
snapshot = fetch_page(url)
# Claude extraction
result = extract(snapshot)
# Broken link check
links = check_links(snapshot.get("raw_links", ()), snapshot.get("final_url", url))
result("broken_links") = links
# Write result immediately
write_result(result)
mark_audited(url)
overall = "PASS" if all(
result.get(f, {}).get("status") == "PASS"
for f in ("title", "description", "h1", "canonical")
) and links("status") == "PASS" else "FAIL"
print(overall)
except KeyboardInterrupt:
print("nnInterrupted. Progress saved. Re-run to continue.")
return
write_summary()
passed = sum(
1 for e in (r for r in ())
if all(e.get(f, {}).get("status") == "PASS" for f in ("title", "description", "h1", "canonical"))
)
print(f"nAudit complete. Report saved to report.json and report-summary.txt")
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--auto", action="store_true", help="Auto-skip URLs requiring human review")
args = parser.parse_args()
run(auto=args.auto)
O KeyboardInterrupt manipulador é o mecanismo de currículo. Quando você pressiona Ctrl+C, o manipulador imprime uma mensagem e sai corretamente. Porque mark_audited() é chamado depois write_result() para cada URL, a próxima execução ignora tudo o que já foi processado.
Executando o Agente
Modo interativo (pausa em casos extremos):
python index.py
Modo automático (ignora casos extremos, adiciona needs_human()):
python index.py --auto
Quando for executado, você verá a janela do navegador aberta para cada URL e o progresso da impressão do terminal:
Starting audit: 7 pending, 0 already done.
(1/7) https://example.com -> PASS
(2/7) https://example.com/about -> FAIL
(3/7) https://example.com/contact -> AUTO-SKIPPED (Unexpected status code: 404)
...
Audit complete. Report saved to report.json and report-summary.txt
Para retomar após uma interrupção:
python index.py --auto
# Starting audit: 4 pending, 3 already done.
Agendamento para uso da agência
Para auditorias semanais recorrentes, crie um arquivo em lote e agende-o com o Agendador de Tarefas do Windows.
Criar run-audit.bat:
@echo off
set ANTHROPIC_API_KEY=your-key-here
cd /d C:UsersyournameDesktopseo-agent
python index.py --auto
No Agendador de Tarefas do Windows:
Crie uma nova tarefa básica
Defina o acionador como semanalmente, segunda-feira às 7h
Defina a ação para “Iniciar um programa”
Navegue até o seu
run-audit.batarquivo
Verificar report-summary.txt na manhã de segunda-feira. URLs em needs_human() em state.json precisam de revisão manual – logins, paywalls ou páginas que retornaram códigos de status inesperados.
Para macOS/Linux, use cron:
# Run every Monday at 7am
0 7 * * 1 cd /path/to/seo-agent && ANTHROPIC_API_KEY=your-key python index.py --auto
Como são os resultados
Executei esse agente em sete de minhas próprias páginas publicadas em Hashnode, freeCodeCamp e DEV.to. Cada um deles falhou.
https://hashnode.com/@dannwaneri | FAIL (h1)
https://freecodecamp.org/news/claude-code-skill | FAIL (description)
https://freecodecamp.org/news/stop-letting-ai-guess | FAIL (description)
https://freecodecamp.org/news/rag-system-handbook | FAIL (title, description)
https://freecodecamp.org/news/author/dannwaneri | FAIL (description)
https://dev.to/dannwaneri/gatekeeping-panic | FAIL (title)
https://dev.to/dannwaneri/production-rag-system | FAIL (title)
0/7 URLs passed
Os problemas de descrição do freeCodeCamp são parcialmente no nível da plataforma – o modelo do freeCodeCamp às vezes trunca ou omite meta descrições para páginas de listagem de artigos. Os problemas do título DEV.to são meus. Os títulos dos artigos que funcionam como manchetes geralmente excedem 60 caracteres no marcação.
Uma observação sobre a regra do título de 60 caracteres: este é um limite de exibição, não uma penalidade de classificação. O Google indexa títulos de qualquer tamanho. A diretriz de 60 caracteres reflete aproximadamente quantos caracteres cabem em um resultado SERP de desktop antes do truncamento. Títulos com mais de 60 caracteres geralmente ainda são classificados – eles apenas são cortados nos resultados de pesquisa, o que pode prejudicar a taxa de cliques. Os sinalizadores do agente exibem risco, não uma violação de classificação.
Próximas etapas
O agente construído lida com o fluxo de trabalho principal da auditoria de SEO. Extensões óbvias:
Métricas de desempenho – adicione uma chamada de API Lighthouse ou PageSpeed Insights por URL
Validação de dados estruturados — verifique a marcação do esquema JSON-LD e valide-a
Entrega de e-mail – enviar
report-summary.txtvia SMTP após a conclusão da execuçãoSuporte multicliente – separado
input.csvarquivos por cliente, diretórios de relatórios separados
O código completo incluindo todos os sete módulos está em dannwaneri / agente de SEO. Clone-o, adicione seus URLs e execute-o.
Se você achou isso útil, escrevo sobre configurações práticas de agentes de IA para desenvolvedores e agências em DEV.to/@dannwaneri. A peça complementar do DEV.to cobre as decisões de design por trás do agente – por que o HITL é importante, por que o uso do navegador em vez dos scrapers e o que os resultados da auditoria significam para o seu próprio conteúdo publicado.
Deseja saber mais sobre Programação e Desenvolvimento Clique Aqui!

Perito em Computação Forense e Crimes Cibernéticos
Investigação Digital | Laudos Técnicos | Resposta a Incidentes
Bacharel em Sistemas da Informação, Certificado Microsoft Azure IA e MOS. Trabalho como Administrador de Redes, Firewall e Servidores Windows e Linux!
Minhas atividades favoritas são: Caminhar, Fazer Trilhas, Natureza, Insetos e claro ler sobre Tecnologia.

