↑↓ mover↵ abrir⌘K abrir/cerrar

    ← Todas las notas

    Por qué las capturas de página completa de tiendas modernas salen en blanco

    Las secciones que aparecen con el scroll y las imágenes lazy no se pintan en una captura de página completa. Este es el script que uso en su lugar.

    5 de noviembre de 2026 · 5 min de lectura

    • Playwright
    • Herramientas
    • Capturas
    • Node.js

    Los proyectos de este sitio muestran capturas de las tiendas en vivo: home, PDP y carrito lateral, en desktop y móvil. Cuando las automaticé con fullPage: true de Playwright, casi todo bajo el hero salió en blanco. El script que lo reemplazó está en recursos.

    Por qué una sola captura sale en blanco

    Una captura de página completa pinta todo el documento de una vez, sin scroll. Y los temas modernos viven del scroll:

    • Las secciones arrancan en opacity: 0 y entran animadas cuando un IntersectionObserver las detecta. En una sola pasada la mayoría nunca entra al viewport y siguen invisibles.
    • Las imágenes usan loading="lazy" o un lazy loader atado al scroll. Bajo el fold siguen como placeholders.
    • Algunas páginas renderizan secciones solo al acercarte: el documento es más corto al cargar que tras un scroll real.

    Queda un header, un hero, un hueco y un footer. El contexto usa reducedMotion: 'reduce', que ayuda en los temas que lo respetan; muchos no.

    Bajar una pantalla a la vez y después unir

    La solución es imitar a un visitante: bajar un viewport, dejar que la página reaccione, capturar y repetir. Antes, cada img[loading="lazy"] pasa a eager y se apaga el scroll suave, porque con scroll-behavior: smooth la llamada retorna antes de que la página se mueva.

    for (let i = 0; i < 30; i++) {
      await page.evaluate((top) => window.scrollTo(0, top), y);
      await page.waitForTimeout(500);
      // Wait (briefly) for images now in view to finish loading
      await page.evaluate(() => Promise.race([
        Promise.all([...document.images].filter((img) => { const r = img.getBoundingClientRect(); return r.bottom > 0 && r.top < innerHeight && !img.complete; })
          .map((img) => new Promise((res) => { img.addEventListener('load', res, { once: true }); img.addEventListener('error', res, { once: true }); }))),
        new Promise((res) => setTimeout(res, 2000)),
      ]));
      const actual = await page.evaluate(() => Math.round(window.scrollY));
      if (shots.length) {
        if (actual === shots[shots.length - 1].y) break; // scrolling is locked or we reached the end
        // ocultar las barras fijas de arriba (siguiente sección)
      }
      shots.push({ buf: await page.screenshot(), y: actual });
      // Pages that render sections as you scroll grow while we go, so re-measure every step
      total = await page.evaluate((max) => Math.min(document.documentElement.scrollHeight, max), maxCss);
      if (actual + vh >= total - 2) break;
      y = actual + vh;
    }

    La espera de imágenes tiene un tope de dos segundos para que una imagen rota no cuelgue nada. Cada captura guarda el scrollY real, no el pedido: el último scroll suele quedarse corto, y el solapamiento debe caer donde corresponde. Luego sharp compone las pantallas en un lienzo, escalado por el device pixel ratio (el perfil móvil va a 2x):

    const height = Math.min(total, shots[shots.length - 1].y + vh);
    return sharp({ create: { width: Math.round(vw * dpr), height: Math.round(height * dpr), channels: 3, background: '#ffffff' } })
      .composite(shots.map((s) => ({ input: s.buf, left: 0, top: Math.round(s.y * dpr) })))
      .png().toBuffer();

    Headers fijos y sticky

    Unir capturas trae otro problema: lo que tiene position: fixed sale en cada pantalla, y el header y la barra de anuncios se repiten. Tras la primera captura, el script marca los elementos fijos cerca del borde superior (a menos de 160px del top y de menos de 240px de alto) y los oculta con visibility: hidden, no display: none, para que el layout no se corra.

    Con sticky aprendí a ser específico: mi primera versión lo trataba como fixed y lo ocultaba tras la primera pantalla. En un sitio de Webflow desapareció el hero completo: era una sección alta cuyo contenido vivía en un wrapper con position: sticky que movía una animación de scroll. Ahora sticky no se oculta, se aplana en el flujo normal:

    for (const el of document.querySelectorAll('body *')) {
      if (getComputedStyle(el).position === 'sticky') el.setAttribute('data-capture-unstick', '');
    }
    const style = document.createElement('style');
    style.textContent = 'html{scroll-behavior:auto!important}[data-capture-stuck]{visibility:hidden!important}[data-capture-unstick]{position:relative!important;top:auto!important}';
    document.head.appendChild(style);

    Así un header sticky sale una vez arriba y una sección sticky queda donde está en el documento. Al terminar se quitan los marcadores.

    Popups y bloqueos de scroll

    Antes de capturar, settle() recorre la página en pasos del 60% del viewport para despertar el contenido lazy y oculta los overlays fijos restantes: lo que cubra más de un cuarto del viewport o tenga chat, popup, modal, newsletter, cookie o consent en su class o id. Los headers se salvan.

    Un popup oculto deja su bloqueo de scroll. El override obvio, body { overflow: auto }, me dio el peor bug del script. Si el body tiene altura fija, overflow: auto lo convierte en el contenedor de scroll: window.scrollTo no hace nada, scrollY se queda en 0, el loop no ve movimiento y para, y la “página completa” mide una pantalla. La solución es desbloquear html y no tocar el body:

    // The body stays overflow:visible: forcing it to auto turns it into
    // the scroller on themes where it has a fixed height, and then the window can't move.
    style.textContent = '[data-capture-hidden]{display:none!important}html{overflow-y:auto!important}body{overflow:visible!important}';

    Ocultar es la segunda línea de defensa. La primera es no cargar los popups. Cada contexto aborta las peticiones a una lista de dominios (Klaviyo, Attentive, Gorgias, OneTrust, Hotjar, tag managers y otros). Bloqueado en la red, un popup no aparece después de la limpieza, y la página llega antes a network idle.

    Abrir el carrito lateral

    No simulo el carrito con un POST a /cart/add.js y un cambio de clase. Cada tema lo abre a su manera, así que openCart() hace clic en el Add to Cart de la tienda y el tema ejecuta su código real. Muestra lo que ve un comprador, con upsells y barra de envío gratis.

    Busca primero un botón submit dentro de form[action*="/cart/add"], luego button[name="add"] y al final cualquier botón cuyo nombre accesible coincida con “Add to cart” o variantes en inglés, español y francés. Dos detalles: settle() ocultó los elementos fijos grandes, carrito incluido, así que primero se quitan esos marcadores; y el chat y los popups de newsletter suelen volver tras el clic, así que se ocultan de nuevo. Para temas raros, capture.cart en el frontmatter acepta un selector CSS.

    Conclusiones

    • No confíes en fullPage: true en temas que animan al hacer scroll: baja un viewport a la vez, espera las imágenes visibles, vuelve a medir y une en el scrollY real.
    • Oculta las barras fijas superiores tras la primera pantalla, pero aplana los sticky en el flujo en vez de ocultarlos.
    • Desbloquea el scroll en html, nunca hagas del body el contenedor de scroll, bloquea los popups en la red y abre el carrito lateral con el Add to Cart de la tienda.

    Más notas

    sections/contact.liquid

    Construyamosel siguiente.

    ¿Buscas a alguien senior en Shopify, necesitas rehacer una tienda, construir una app o automatizar un proceso? Escríbeme.