惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

V
V2EX
aimingoo的专栏
aimingoo的专栏
S
SegmentFault 最新的问题
博客园_首页
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
IT之家
IT之家
博客园 - 【当耐特】
月光博客
月光博客
C
Check Point Blog
T
The Blog of Author Tim Ferriss
罗磊的独立博客
博客园 - Franky
MongoDB | Blog
MongoDB | Blog
H
Help Net Security
Microsoft Security Blog
Microsoft Security Blog
B
Blog
阮一峰的网络日志
阮一峰的网络日志
腾讯CDC
美团技术团队
N
Netflix TechBlog - Medium
Stack Overflow Blog
Stack Overflow Blog
Y
Y Combinator Blog
L
LangChain Blog
The Cloudflare Blog

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant
SEO en 2026, parte 2: la captura rankea — ahora haz que c...
Franchesco Romero · 2026-06-14 · via DEV Community

El post anterior terminó donde terminan la mayoría de los posts de "ya hicimos SEO": la página es indexable.

Dos cosas se rompen justo después de esa línea de meta, y ninguna aparece en un puntaje de Lighthouse:

  1. La captura rankea pero no convierte nada. El HTML que el rastreador indexó y el HTML al que aterriza un humano sin sesión, tiene el contenido pero ninguna llamada a la acción, porque la llamada a la acción vive en el paquete de JavaScript que el rastreador nunca corrió. Rankeaste para la búsqueda y luego no dijiste nada.
  2. El siguiente despliegue des indexa la página durante unos minutos. Una SPA con hash de contenido que borra sus fragmentos viejos al desplegar le entrega a cada pestaña abierta y a cada re rastreo a media propagación, un 404 sobre un módulo que todavía espera. La página que rankeaba ayer hoy lanza Failed to fetch dynamically imported module.

TL;DR

Capa Antes (fin de la parte 1) Después
CTA en un reporte indexado renderizado solo por la SPA — ausente de la captura que lee el rastreador y del primer pintado sin sesión renderizado en el servidor dentro de la captura, derivado de los datos de la propia página, indexable
Texto del CTA / enlaces de destino fijos en el código → un re-despliegue para cambiar una palabra configuración en tiempo de ejecución en una bolsa de ajustes JSONB; editable desde el admin, sin re-despliegue; los pre-renderizados se refrescan al guardar
CTA por audiencia una sola variante anónimo → unirse; autenticado → enlaces directos al siguiente paso
Despliegue de assets aws s3 sync --delete — los fragmentos viejos con hash se borran en el instante en que aterriza el paquete nuevo sync aditivo (sin --delete); los fragmentos viejos + nuevos coexisten; una pestaña abierta sigue funcionando
Limpieza del paquete viejo implícita (el delete) regla explícita de ciclo de vida de S3 — assets/ expira 30 días después de que un paquete deja de servirse
Invalidación de CloudFront "/*" (desperdicio; los assets son inmutables) solo "/index.html" — el único objeto que cambia un despliegue
Falla al cargar un fragmento la pantalla roja de error de React Router vite:preloadError → una sola recarga acotada hacia el paquete fresco

El hilo conductor: una captura pre-renderizada es necesaria pero no
suficiente.
La parte 1 hizo que la captura existiera. La parte 2 hace
que haga su trabajo y que sobreviva al siguiente git push.

El punto de partida

La fase 6 de la parte 1 nos dejó con un parchador de HTML por ruta: para
cada reporte público, renderiza su markdown a HTML sanitizado del lado
del servidor, lo inyecta dentro del <div id="root"> de la cáscara de la
SPA, parcha el <title> / description / canonical, y agrega el JSON-LD de
Article. Los rastreadores leen el HTML inyectado; la SPA en vivo hidrata
encima de él al montarse.

myapp/backend/app/services/report_seo.py   # markdown → HTML + JSON-LD, inyectado en la cáscara
myapp/backend/app/services/prerender.py     # parche genérico del head + inyección en #root
myapp/backend/app/jobs/render_reports.py    # itera los reportes publicados, escribe un index.html por ruta

Funcionó. Los reportes rankearon. Luego vimos qué recibía de verdad el visitante que rankeaba, y qué le hacía el siguiente despliegue.

Fase 1: la trampa del CTA solo-en-la-SPA

La página de reporte es un imán de tráfico orgánico buenísimo: una tabla de datos que la gente busca por nombre. Así que agregamos un bloque de conversión al final, "así es como puedes actuar sobre esto", como un componente de React en la ruta del reporte. Lo publicamos, se veía increíble en el navegador.

Luego: le haces curl a la URL pública como la ve un rastreador.

curl -s https://myapp.example/reports/<slug> | grep -c "Quieres llegar"
# 0

El CTA no estaba en ningún lado del HTML entregado. Obvio en
retrospectiva — el pre-renderizado (report_seo.py) renderiza el cuerpo
del markdown
dentro de la captura; el CTA era un componente de React,
montado solo después de que carga el paquete. Entonces:

  • El rastreador indexó el cuerpo del reporte y nunca vio el CTA. La captura que cacheó para los motores de respuestas de IA es un callejón sin salida.
  • El humano sin sesión — la mayoría del tráfico orgánico, vio el CTA un instante tarde (después de la hidratación), y si rebotaba durante la carga del JS, nunca.

Un CTA que solo existe del lado del cliente es un CTA que no existe para exactamente la audiencia que una captura está hecha para servir. La solución es renderizarlo dentro de la captura, igual que el cuerpo — pero una cadena estática pegada en cada reporte es débil. La versión interesante está derivada de los datos de la propia página.

Fase 2: un CTA construido con los datos de la propia página

Cada reporte ya calcula un agregado ordenado antes de escribirse, las filas de las que el reporte trata. Habíamos estado tirando esa
estructura después de generar la prosa. En lugar de eso, conserva el
top-N y persístelo junto al reporte (una columna JSONB key_findings sin usar ya estaba en el modelo, sin migración):

# myapp/backend/app/agents/report/generator.py (extracto)
def _top_findings(rows: list[dict], limit: int = 3) -> list[dict]:
    """Top-N de filas para el CTA de conversión. Deduplicado por categoría
    canónica para que el CTA muestre variedad, no la misma tres veces.
    Persistido en ``Report.key_findings`` y leído por el pre renderizado ypor la SPA.
    """
    valid = [r for r in rows if r.get("value")]
    valid.sort(key=lambda r: r["value"], reverse=True)
    seen, top = set(), []
    for r in valid:
        cat = canonical_category(r["category"])
        if cat in seen:
            continue
        seen.add(cat)
        top.append({"category": cat, "label": label_for(cat),
                    "region": r["region"], "value": r["value"]})
        if len(top) >= limit:
            break
    return top

Luego el pre renderizado crece un bloque de CTA, inyectado en la captura entre el cuerpo y el footer, pura construcción de cadenas, sin navegador:

# myapp/backend/app/services/report_seo.py (extracto)
def _render_cta(cta_config: dict | None, findings: list[dict] | None) -> str:
    if not cta_config or not cta_config.get("enabled", True):
        return ""
    hook = ""
    if findings:
        t = findings[0]
        hook = (
            '<p class="cta-hook">This month, '
            f"<strong>{escape(t['label'])}</strong> leads in "
            f"{escape(region_name(t['region']))} at "
            f"<strong>${int(t['value']):,}</strong>.</p>"
        )
    items = "".join(
        f'<li><a href="{escape(s["route"])}">{escape(s["label"])}</a>'
        + (f"{escape(s['blurb'])}" if s.get("blurb") else "")
        + "</li>"
        for s in cta_config.get("services", []) if s.get("label")
    )
    return (
        '<aside class="report-cta">'
        f"{hook}"
        f"<h2>{escape(cta_config['headline'])}</h2>"
        f"<p>{escape(cta_config['subcopy'])}</p>"
        f"<ul>{items}</ul>"
        f'<p><a class="btn" href="{escape(cta_config['join_route'])}">'
        f"{escape(cta_config['join_label'])}</a></p>"
        "</aside>"
    )

La línea del gancho, "This month, X leads at $N" ,cambia por reporte y por mes, calculada desde los datos para los que el reporte ya rankea. El rastreador la indexa. El visitante sin sesión la ve en el primer pintado.

Ahora curl regresa 1.

Las pruebas amarran la forma, todas puras (sin base de datos, sin red):
test_report_page_renders_indexable_cta (el gancho + un enlace de
servicio real + el botón de unirse, todos aterrizan dentro del
<div id="root">), test_report_page_cta_disabled_renders_nothing,
test_report_page_cta_without_top_data_still_shows_block (un reporte
heredado sin key_findings igual recibe la propuesta de valor, nada más sin gancho), y test_report_page_no_cta_config_is_backward_compatible (el
pre renderizado llamado a la antigua, sin argumento de CTA, igual
renderiza).

Fase 3: editable sin re despliegue

Un CTA fijo en el código significa un despliegue para arreglar una errata.
Peor, los enlaces a los que apunta el CTA son decisiones de producto que cambian más rápido que el código. Así que el texto, las rutas de destino, y el interruptor de encendido/apagado viven en una configuración de tiempo de ejecución la misma bolsa de ajustes JSONB por agente que el resto del admin ya usaba.

# myapp/backend/app/services/report_cta_config.py (extracto)
DEFAULTS = {
    "enabled": True,
    "headline": "Want to get there?",
    "subcopy": "Here's how to close the gap:",
    "join_label": "Join", "join_route": "/register",
    "services": [ ... ],   # [{key, label, route, blurb}, ...]
}

async def resolve_cta_config(db) -> CtaConfig:
    row = await _load(db)                      # una sola búsqueda por PK
    overrides = dict(row.config_json or {}) if row else {}
    return CtaConfig(**{k: overrides.get(k, v) for k, v in DEFAULTS.items()})

El endpoint GET es público — es texto y rutas internas, sin secretos, así que la SPA y el pre renderizado leen la misma configuración resuelta.
El PATCH está protegido para admin y validado. La única regla que vale la pena imponer en la orilla: cada enlace es una ruta interna.

def _clean_route(key, raw):
    value = _clean_str(key, raw)
    if not value.startswith("/"):
        raise HTTPException(400, detail=f"{key} must be an internal route (/...)")
    return value

Sin ese chequeo, "edita el CTA" se vuelve "pega un https:// en
cualquier lado de tus páginas indexadas de mayor tráfico", un punto de apoyo de redirección abierta / enlace fuera del sitio en exactamente las superficies en las que más confían los rastreadores.

Pruebas:
test_validate_cta_config_rejects_external_route,
test_cta_config_patch_merges_and_persists (el PATCH se fusiona sobre los
defaults, no aplasta las llaves que no tocó), test_cta_config_reset,
test_cta_config_defaults_when_unset.

Un detalle de cableado que importa para el SEO: un cambio de configuración tiene que refrescar las capturas, o el rastreador sigue viendo el CTA viejo. El CTA está en cada reporte, así que el PATCH del admin dispara un re renderizado en segundo plano de todas las páginas de reporte publicadas no solo la que se está editando.

# myapp/backend/app/api/v1/admin_reports.py (extracto)
@router.patch("/reports/cta-config")
async def update_cta(body, background, _admin = Depends(require_admin)):
    result = await client.update_cta_config(body.config)
    background.add_task(prerender_publish.refresh_all_reports)  # re-captura todos
    return result

Fase 4: el espejo en la SPA, anónimo vs autenticado

El CTA de la captura es la variante anónima — eso es lo que son un
rastreador y un visitante sin sesión. Pero una vez que la SPA hidrata para un miembro con sesión, el mismo bloque debe hacer algo distinto: no "unirse", sino enlaces directos al siguiente paso. Misma configuración, dos renderizados.

// myapp/frontend/src/components/report/ReportCTA.tsx (extracto)
export default function ReportCTA({ topFindings }: Props) {
  const { data: cfg } = useReportCtaConfig();
  const { isAuthenticated } = useAuth();
  if (!cfg || !cfg.enabled) return null;

  const top = topFindings?.[0] ?? null;
  return (
    <aside className="report-cta">
      {top && (
        <p>This month, <strong>{top.label}</strong> leads in{" "}
          {regionName(top.region)} at <strong>${top.value.toLocaleString()}</strong>.</p>
      )}
      <h2>{cfg.headline}</h2>
      <ul>
        {cfg.services.map((s) =>
          isAuthenticated
            ? <li key={s.key}><Link to={s.route}>{s.label}</Link>{s.blurb && ` — ${s.blurb}`}</li>
            : <li key={s.key}><span>{s.label}</span>{s.blurb && ` — ${s.blurb}`}</li>,
        )}
      </ul>
      {isAuthenticated
        ? <Link className="btn" to={cfg.services[0]?.route}>Continue</Link>
        : <Link className="btn" to={cfg.join_route}>{cfg.join_label}</Link>}
    </aside>
  );
}

El componente lee topFindings directo de la carga del reporte (el mismo key_findings que usó el pre-renderizado), sin una segunda petición, sin recalcular del lado del cliente. Las pruebas cubren los tres estados: anónimo muestra el botón de unirse y los servicios como texto plano; autenticado muestra los servicios como enlaces más una acción primaria de "continuar"; la configuración deshabilitada no renderiza nada.

Eso cierra el hueco de conversión. La otra falla estaba esperando en el CI.

Fase 5: el despliegue que des indexa tu página

Esta la atrapamos con el monitoreo de usuarios reales de la parte 1. El recolector de Web Vitals de la fase 5 del primer post también muestrea los errores no atrapados, y después de cada despliegue de frontend había una ráfaga chica y confiable en el canal de errores:

TypeError: Failed to fetch dynamically imported module:
https://myapp.example/assets/ReportPage-BuE6Rmpx.js

Siempre un fragmento de ruta, siempre en los minutos justo después de un despliegue, siempre un hash que ya no existía. La causa raíz era una sola bandera en el despliegue:

# antes — myapp/.github/workflows/deploy-frontend.yml
aws s3 sync frontend/dist s3://$BUCKET --cache-control "...immutable" --delete

--delete quita cada objeto que no está en el dist/ nuevo, incluyendo los fragmentos con hash de la compilación anterior. Secuencia:

  1. Un visitante carga la app. Su pestaña guarda un index.html que referencia ReportPage-BuE6Rmpx.js.
  2. Aterriza un despliegue. Compilación nueva → ReportPage-OTHERHASH.js. El sync borra ReportPage-BuE6Rmpx.js.
  3. El visitante hace clic hacia una ruta diferida → la SPA importa el fragmento que todavía recuerda → 404Failed to fetch dynamically imported module → la pantalla roja de error de React Router.

Y no son solo las pestañas abiertas: un rastreador que vuelve a pedir la URL en vivo durante la ventana de propagación de CloudFront puede hidratar contra un paquete reemplazado a medias. La página que rankeaba está, por esos minutos, rota.

Los headers de caché ya estaban bien — los assets con hash immutable por un año, el index.html en no-cache. El bug era puramente que los assets viejos desaparecían. Los paquetes con hash de contenido están direccionados por contenido: el viejo y el nuevo pueden y deben coexistir.

Solución 1, la cura: desplegar los assets de manera aditiva. Quita el --delete.

# después — aditivo: los fragmentos con hash viejos + nuevos coexisten
aws s3 sync frontend/dist s3://$BUCKET --cache-control "...immutable"

Solución 2, la limpieza: una regla de ciclo de vida de S3 para que "nunca borrar" no signifique "acumular por siempre". Los paquetes
reemplazados envejecen 30 días después de que dejan de servirse mucho después de cualquier sesión en vivo, lo bastante corto como para no amontonarse.

// myapp/infra/lib/frontend-stack.ts (extracto)
new s3.Bucket(this, "SpaBucket", {
  // ...
  lifecycleRules: [{
    id: "expire-old-build-assets",
    prefix: "assets/",                       // solo el paquete con hash
    expiration: cdk.Duration.days(30),
  }],
});

Solución 3, el recorte de costo: angostar la invalidación.
index.html es el único objeto que cambia un despliegue (está en
no-cache, así que la orilla lo revalida). Los assets con hash son
inmutables, invalidar /* pagaba por desalojar entradas de caché que nunca pueden quedar viejas.

# antes: --paths "/*"      # desperdicio; los assets son inmutables
# después:
aws cloudfront create-invalidation --distribution-id "$ID" --paths "/index.html"

Prueba de que funcionó, directo del bucket después del primer despliegue aditivo, el mismo fragmento de dos compilaciones, lado a lado:

$ aws s3 ls s3://$BUCKET/assets/ | grep ReportPage
ReportPage-BuE6Rmpx.js   # la compilación que recuerda la pestaña abierta
ReportPage-CF69kraa.js   # la compilación que acaba de salir

El import de la pestaña abierta ahora resuelve a un 200, no a un 404.

Fase 6: recuperación del lado del cliente para la ventana de propagación

Los despliegues aditivos arreglan la causa. Pero una pestaña dejada
abierta más tiempo que el ciclo de vida de 30 días, o una carrera dura durante la propagación, todavía puede perder un fragmento, y "raro" en una página de alto tráfico es "diario" en términos absolutos. Así que la segunda capa es la recuperación: Vite dispara un evento vite:preloadError cuando un import dinámico falla. Atrápalo y recarga una vez hacia el index.html fresco en lugar de mostrar la pantalla de error.

La única sutileza es no entrar en bucle por siempre si el fragmento de verdad ya no está (sin conexión, expirado): acótalo.

// myapp/frontend/src/lib/chunkReload.ts (extracto)
const KEY = "chunkReload", MAX = 2, WINDOW_MS = 60_000;

export function installChunkReloadHandler(win: Window = window): void {
  win.addEventListener("vite:preloadError", (event) => {
    event.preventDefault();                 // suprime el re-lanzamiento default de Vite
    const now = Date.now();
    let s = readState(win.sessionStorage);
    if (now - s.t > WINDOW_MS) s = { t: now, n: 0 };   // reinicia tras una ventana tranquila
    if (s.n >= MAX) return;                 // ya le dimos nuestros intentos; deja que se muestre el error
    win.sessionStorage.setItem(KEY, JSON.stringify({ t: now, n: s.n + 1 }));
    win.location.reload();
  });
}

A lo mucho dos recargas en una ventana de 60 segundos, luego se rinde y deja que la frontera de error renderice, así una caída real no se vuelve un bucle de recargas, pero un despliegue es invisible. Pruebas:
installChunkReloadHandler reloads once on the first preloadError,
stops after 2 reloads inside the window, y
recovers again after the quiet window resets.

El resultado

  • Las capturas de los reportes ahora cargan un CTA indexable y derivado de los datos. El HTML cacheado del rastreador y el primer pintado sin sesión tienen ambos un siguiente paso, la misma superficie que rankea ahora también convierte, sin dependencia del JS del cliente.
  • Los despliegues de frontend dejaron de producir la ráfaga de errores post despliegue en el monitoreo de usuarios reales. La métrica que publicamos en la parte 1 es cómo encontramos el bug y cómo confirmamos la solución, el canal de errores se quedó callado a través de los despliegues.
  • Editar el CTA es un cambio de configuración, no un despliegue, y re captura las páginas públicas de manera automática para que los rastreadores no se queden atrás.

Lo que NO ayudó

  • Renderizar en el servidor toda la pila del CTA. Tienta echar mano del SSR/SSG en el momento en que sale "el rastreador no ve mi componente". Pero el flujo de capturas de la parte 1 ya produce HTML rastreable; el CTA es una cadena más inyectada en él. El SSR habría sido una migración de framework para resolver un += cta_html.
  • Invalidar /* "por si las dudas". Nunca arregló el bug del fragmento (los assets estaban borrados, no viejos) y facturaba por desalojar objetos inmutables. El bug estaba en el bucket, no en la caché.

Lecciones

  • Una captura pre-renderizada es necesaria, no suficiente. El win de la parte 1 fue hacer que la captura existiera. Si la capa de conversión vive solo en el paquete, rankeaste para la búsqueda y luego no le dijiste nada a exactamente la audiencia que la captura sirve.
  • Deriva el CTA de los datos de la propia página. Un CTA estático es un re despliegue y un genérico. Los datos para los que la página ya rankea la fila de arriba, el número del titular, son el gancho más relevante que tienes, y es gratis.
  • Los assets con hash de contenido se tienen que desplegar de manera aditiva. --delete en una SPA con división de código es una caída autoinfligida en cada despliegue. Los fragmentos están direccionados por contenido; deja que el viejo y el nuevo coexistan y deja que una regla de ciclo de vida haga la limpieza con un retraso de 30 días, no de cero segundos.
  • Invalida la única cosa que cambió. index.html está en no-cache y es el único objeto mutable del despliegue. /* es un reflejo que cuesta dinero para desalojar cachés que son inmutables por construcción.
  • Acota tu recuperación. Una recarga ante error de fragmento es la red de seguridad correcta, pero una sin límite convierte una caída real en un bucle de recargas. Dos intentos en una ventana, luego saca el error.
  • La métrica que publicaste el trimestre pasado es cómo encuentras el bug de este trimestre. El monitoreo de usuarios reales de la parte 1 existía para vigilar los Web Vitals; atrapó una regresión de despliegue que nadie estaba buscando. La telemetría se acumula.