How to Build a Local SEO Audit Agent with Browser Use and Claude API

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.json incrementalmente – seguro para interromper e retomar

  • Gera um inglês simples report-summary.txt na conclusão

O código completo está no GitHub em dannwaneri / agente de SEO.

Pré-requisitos

Índice

  1. Por que usar o navegador em vez de um raspador

  2. Estrutura do Projeto

  3. Configurar

  4. Módulo 1: Gestão do Estado

  5. Módulo 2: Integração com Navegador

  6. Módulo 3: Camada de Extração Claude

  7. Módulo 4: Verificador de link quebrado

  8. Módulo 5: Humano no Loop

  9. Módulo 6: Redator de Relatórios

  10. Módulo 7: O Loop Principal

  11. Executando o Agente

  12. Agendamento para uso da agência

  13. Como são os resultados

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.

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:

  1. Crie uma nova tarefa básica

  2. Defina o acionador como semanalmente, segunda-feira às 7h

  3. Defina a ação para “Iniciar um programa”

  4. Navegue até o seu run-audit.bat arquivo

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.txt via SMTP após a conclusão da execução

  • Suporte multicliente – separado input.csv arquivos 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!

Deixe um comentário

Translate »