Busqué mi nombre y mi sitio no aparecía. Esto es lo que cambié en tres días para que lo encuentren Google y Bing, y para que ChatGPT, Claude y Perplexity lo lean, lo entiendan y lo citen.


Hace unos días busqué «Rubén Rangel» en la web. El primer resultado era un ciclista venezolano en Wikipedia. Mi sitio, con doce artículos publicados en dos idiomas, no aparecía ni buscando el dominio.

El problema ya no es solo Google. Cada vez más gente le pregunta a un chat en lugar de a un buscador, y esos chats responden con lo que sus crawlers pudieron leer y entender. A eso se le llama GEO (Generative Engine Optimization): optimizar para que un motor generativo te encuentre y te cite, del mismo modo que el SEO optimiza para aparecer en una lista de resultados.

Me puse un objetivo con dos mitades: que el sitio posicione en buscadores y que los chats de IA encuentren, entiendan y citen a Rubén Rangel. Este artículo cuenta qué hice, en qué orden y qué todavía no sé.

El punto de partida

El blog es un Laravel con los posts en Markdown dentro del repo, sin panel de administración (lo conté en este artículo). Funcionaba bien para quien lo visitaba. Para una máquina, no tanto. La primera auditoría encontró esto:

  1. El blog, el demo del agente y la página de insights declaraban como canonical la URL del home. Le estaban diciendo a Google que eran copias de otra página.
  2. El sitio respondía en www.rubenrangel.org y en rubenrangel.org, dos versiones de cada URL.
  3. El sitemap incluía la raíz, que redirige, y todas las URLs tenían como lastmod la fecha de hoy.
  4. Un post de prueba estaba indexado.
  5. Ninguna página tenía imagen para Open Graph y el home no tenía datos estructurados.

En la segunda pasada, ya pensando en las IA, aparecieron más: dos <h1> por post, ninguna frase factual que dijera quién soy y dónde trabajo, y nada pensado para que un agente leyera el contenido sin pelearse con el HTML.

Parte 1: lo que un buscador necesita ver una sola vez

Lo primero fue dejar de mandar señales contradictorias:

  • Canonical propio en cada página, con hreflang para /en y /es y un x-default. Las URLs se generan desde APP_URL, así que no hay forma de que se cuele otro host.
  • Un redirect 301 de www al dominio principal, en Cloudflare.
  • noindex en las búsquedas del blog y en la página de insights. La paginación conserva ?page en el canonical, para que la página 2 no se declare copia de la página 1.
  • Un solo <h1> por post, con la firma «por Rubén Rangel» enlazada con rel="author".
  • Títulos de máximo 60 caracteres. Bing marcaba «Title too long» en 20 URLs, una de ellas con 125 caracteres. Agregué un seo_title opcional por idioma en el meta.json de cada post: el <title> usa la versión corta y agrega « | Rubén Rangel» solo si cabe, mientras el <h1>, el og:title y el headline mantienen el título completo.
  • Meta descriptions de máximo 155 caracteres, con copy nuevo en el home y recorte automático en los posts. Estaban largas en el home y en 18 posts.
  • Imagen Open Graph por post: una tarjeta de 1200×630 que se genera con GD en /og/{lang}/{slug} y se cachea en disco. Al desplegarla, todas daban 500 en producción porque el servidor no tenía la extensión GD. Ahora está declarada en composer.json y, si una tarjeta falla, se usa el retrato.

El lastmod que mentía

El caso más interesante fue el del sitemap. Todas las URLs tenían la fecha de hoy porque el contador de lecturas tocaba updated_at: cada visita a un post lo marcaba como modificado. Para un buscador, eso significaba que los doce artículos cambiaban todos los días, y una señal que cambia siempre no aporta nada.

El arreglo en el código fue pequeño:

// A read is not a content change: keep updated_at (dateModified, sitemap lastmod) untouched.
Post::withoutTimestamps(fn () => $post->increment("reads_{$lang}"));

Arreglar los datos llevó más trabajo. Restauré cada updated_at desde la fecha del último commit de su archivo .md en git. Al verificarlo aparecieron seis posts con dateModified anterior a datePublished, porque su fecha de publicación era la hora de la carga inicial. Ahora Post::lastModifiedAt() nunca devuelve una fecha anterior a la publicación, y la usan el JSON-LD, el sitemap, el RSS y el texto «Last updated».

Parte 2: decirle a la máquina quién soy

Volvamos al ciclista. Si alguien le pregunta a un chat por «Rubén Rangel», el modelo tiene que decidir de qué Rubén Rangel le hablan. Mi trabajo es darle una entidad clara y sin ambigüedad.

En el home hay un bloque JSON-LD con ProfilePage, Person y WebSite. El Person tiene un @id estable (https://rubenrangel.org/#person), y los posts lo referencian como autor en lugar de repetir los datos. Además lleva:

  • alternateName con 11 variantes: con y sin tilde, con la inicial del segundo nombre, con el nombre completo y con la inicial del segundo apellido. La gente escribe mi nombre de muchas formas, y cada una debería llegar a la misma persona.
  • knowsLanguage, ubicación (Medellín, Colombia), cargo y sameAs hacia GitHub y LinkedIn.

Sobre el <h1> del home agregué una línea simple: «Rubén Rangel · Senior Full-Stack Software Engineer · Medellín, Colombia». Es la frase que quiero que un modelo pueda copiar tal cual. Y al revés, los bloques decorativos de la sección «now» llevan data-nosnippet, para que no terminen en un resumen.

La entidad no se construye solo desde mi sitio. Actualicé la bio y la ubicación de GitHub, publiqué un README de perfil, enlacé GitHub con LinkedIn y agregué el sitio a la información de contacto de LinkedIn. Que varias fuentes coincidan es lo que le da confianza al modelo.

Parte 3: darle a las IA algo fácil de leer

Un crawler de IA puede leer HTML, pero no tiene por qué ser difícil. Agregué tres formatos pensados para máquinas:

  1. /llms.txt, un archivo dinámico en Markdown con quién soy, mis servicios, proyectos, demos y la lista de todos los posts en los dos idiomas, cada uno con su resumen.
  2. /llms-full.txt, el texto completo de todos los artículos en un solo archivo.
  3. Una versión .md de cada post, agregando .md a la URL. Cada página la enlaza con <link rel="alternate" type="text/markdown">.

La versión Markdown no debe competir con la página en los buscadores, así que responde así:

return response()
    ->view('blog._markdown', compact('post'))
    ->header('Content-Type', 'text/markdown; charset=UTF-8')
    ->header('X-Robots-Tag', 'noindex')
    ->header('Link', '<'.$post->url.'>; rel="canonical"');

La página HTML es la que se indexa, y el Markdown es para quien lee en vivo.

También agregué RSS por idioma (/en/blog/feed.xml y /es/blog/feed.xml), con el texto completo de cada post y URLs absolutas. Sirve para los lectores de feeds y, como cuento más abajo, para distribuir el contenido.

Parte 4: avisar en lugar de esperar

Publicar y esperar a que un crawler pase puede tardar semanas. Bing y los productos que dependen de su índice, como Copilot y la búsqueda de ChatGPT, aceptan IndexNow: un POST con la lista de URLs que cambiaron. Lo agregué al comando blog:sync, que corre en cada deploy, y solo notifica los posts que de verdad cambiaron.

Google ya no acepta pings. Para Google la señal es el sitemap con un lastmod honesto, y por eso me importaba tanto el arreglo de la parte 1.

Este mismo post usa la última pieza que agregué: la programación de posts. En el meta.json puse "published_at": "2026-10-29 11:05", en hora de Colombia. Se desplegó ayer, pero no apareció en el listado, ni en el sitemap, ni en el feed, ni en llms.txt hasta esa hora. Un comando que corre cada cinco minutos detecta que ya es visible, avisa a IndexNow y deja listo el borrador para LinkedIn, una sola vez.

Parte 5: dejar pasar a los crawlers y ver quién llega

De nada sirve todo lo anterior si el CDN bloquea a los bots. En Cloudflare dejé los crawlers de IA en Allow y apagué «Block AI bots» y el robots.txt administrado, para que se sirva el de la aplicación, que no bloquea nada.

El panel de AI Crawl Control mostró 979 peticiones de crawlers de IA en siete días. Pasaron 459, y Anthropic fue el que más pidió (128), seguido de Meta (97), Microsoft (49), Google (48) y Huawei (48).

Lo interesante estaba en las otras 520. Exporté las rutas a CSV y casi todas eran escáneres de vulnerabilidades con user agent de crawler de IA buscando secretos: .env, .ssh, terraform.tfstate, credenciales de nube y source maps. Todas respondían 403 o 404. Ninguna dio 200 ni 500. Sobre páginas reales, ningún crawler legítimo había fallado.

La lección es que el user agent no es una identidad. Dejar pasar a los crawlers de IA no reemplaza la seguridad del servidor; solo hace que el tráfico legítimo llegue.

Parte 6: medir algo que todavía no tiene tablero

Medir GEO todavía es artesanal. Lo resolví con cuatro fuentes:

Qué Dónde
Visitas que llegan desde un chat de IA GA4, con un grupo de canales propio
Citas en respuestas de IA de Bing Bing Webmaster Tools, AI Performance
Crawlers que pasan Cloudflare, AI Crawl Control
Si me nombran y me citan Prueba manual cada dos semanas

En GA4 copié el grupo de canales predeterminado y agregué un canal «AI Assistants» por encima de Referral, con esta regex sobre la fuente:

.*(chatgpt|openai|perplexity|claude\.ai|copilot|gemini|deepseek|grok|you\.com).*

Google ya trae su propio canal «AI Assistant», en singular, así que en los informes sumo las dos filas.

La prueba manual son cuatro preguntas fijas que hago en ChatGPT, Perplexity, Claude y Copilot: quién es Rubén Rangel, ingeniero en Medellín; una búsqueda por nombre y tema; y dos preguntas técnicas que responden artículos míos. Anoto si aparezco y si citan el sitio.

Distribuir sin duplicar

Publicar el mismo artículo en otras plataformas suma alcance, pero sin un canonical se convierte en contenido duplicado que compite con el original.

  • dev.to importa el RSS en inglés y marca el canonical hacia mi sitio. No muestra SVG, así que los diagramas van en PNG.
  • Medium importa con medium.com/p/import y pone el canonical solo. Tampoco acepta SVG ni tablas.
  • LinkedIn no admite canonical en artículos, así que ahí publico un resumen con el enlace, nunca el artículo completo.

Convertí la auditoría en una verificación

Todo esto vive en un archivo, docs/seo/estado.md, con un checklist, una bitácora y nueve comprobaciones que corro contra producción: endpoints en 200, redirect de www, canonical, entidad en el home, un <h1> por post, descriptions y títulos dentro del rango, y lastmod contra git.

Esa última me dio una sorpresa. Seis posts parecían tener un lastmod un día adelantado. No era un bug: eran commits hechos después de las 19:00 en Colombia, y el sitemap publica en UTC. La comprobación ahora compara en UTC.

Sin el archivo, cada revisión empezaría de cero. Con él, «revisa SEO» es correr un bloque de comandos y comparar.

Lo que todavía no sé

No tengo resultados que mostrar, y prefiero decirlo. Mientras escribo esto, el sitio todavía no aparece cuando busco mi nombre. El envío manual a IndexNow fue aceptado, pero el panel de Bing todavía no muestra envíos. Las citas en respuestas de IA de Bing las empiezo a revisar en noviembre.

Lo que sí sé es que antes el sitio mandaba señales contradictorias: canonicals equivocados, fechas falsas y dos versiones de cada URL. Ahora manda señales consistentes y verificables. El SEO y el GEO no garantizan que te citen, pero sin ellos es muy difícil que lo hagan.

Si en unas semanas los números dicen algo, lo contaré aquí.


Herramientas: Laravel, JSON-LD de schema.org, llms.txt, IndexNow, Cloudflare AI Crawl Control, Google Search Console, Bing Webmaster Tools y GA4.