
Este artículo detalla la creación de una copia estática de un blog de WordPress mediante un plugin personalizado para generar archivos HTML y descargar imágenes localmente. El proceso aborda desafíos técnicos como la gestión de memoria durante descargas pesadas, la reescritura de enlaces internos y la compatibilidad con dominios históricos.
La optimización final emplea scripts para convertir imágenes a formato WebP, reduciendo significativamente el peso total del archivo. El flujo concluye con la organización de la subida al servidor y la configuración de seguridad en el archivo .htaccess para prevenir la indexación por buscadores y garantizar la eficiencia del sitio.
Hace unas semanas me planteé un objetivo aparentemente sencillo: tener una copia estática de Blogpocket. Un espejo navegable del blog que pudiera servir como archivo, respaldo o incluso como punto de partida si algún día decidiera migrar a otra plataforma. No quería pagar un servicio de exportación ni depender de plugins comerciales: quería entender el proceso de punta a punta y dejarlo documentado.
Lo que parecía una tarde acabó siendo un viaje con varias paradas instructivas: un plugin de WordPress que escribí ad-hoc, un error fatal de memoria provocado por una imagen olvidada, un JPG en CMYK que no se dejaba convertir, un ZIP que pasaba del límite de subida del hosting, enlaces a un dominio anterior hardcodeados dentro del contenido… y, al final, una copia estática publicada en mi propio dominio, optimizada con WebP, que ocupa el 15% de lo que ocupaba originalmente.
Este post documenta el proceso completo. Te servirá si quieres hacer lo mismo con tu blog, y a mí me servirá como referencia si tengo que replicarlo en otro entorno.
Qué hace el plugin «Copia Estática Local»
El plugin se instala en WordPress y añade un menú lateral desde el que puedes generar versiones estáticas de tu contenido. Lo importante es entender qué hace exactamente, porque difiere de la mayoría de exportadores que encontrarás:
- Genera archivos HTML planos de tus posts y páginas. Un
.htmlpor entrada, organizados en carpetasaño/mes/slug.htmlpara posts ypages/slug.htmlpara páginas. - Descarga las imágenes referenciadas en el contenido a una carpeta local
/img/dentro de la exportación. No deja enlaces apuntando awp-content/uploads/del sitio en vivo: las copia y las renombra con un hash MD5 único para evitar colisiones. - Reescribe los enlaces internos entre posts y páginas. Si en un artículo enlazabas a otro de tu propio blog, el HTML estático apuntará al fichero local correspondiente, no a la URL pública. Funciona también con dominios históricos si tu blog ha cambiado de URL en algún momento.
- Construye índices automáticos: un
index.htmlraíz, uno por año, uno por mes, y uno general de páginas. Todo navegable sin servidor dinámico. - Aplica un CSS minimalista (
style.css) común para todo el archivo, con tipografía legible y diseño centrado.
La copia resultante se deposita en /wp-content/uploads/copia-estatica-html/, accesible desde tu hosting. Es 100% estática: no requiere PHP, ni base de datos, ni JavaScript. Puede servirse desde cualquier hosting plano, desde GitHub Pages, desde un subdominio de tu propio cPanel o desde un servicio como Netlify o Cloudflare Pages.
Dónde descargar el plugin «Copia Estática»
El plugin «Copia Estática» lo puedes descargar desde mi cuenta de GitHub. Desde el apartado de releases, obtendrás tanto el zip del plugin como el zip con todo el código fuente, incluyendo las dos herramientas auxiliares. Pruébalo antes desde un entorno de ensayo.
Detalles técnicos que importan
Si alguna vez te toca depurarlo o adaptarlo, estos son los puntos sensibles.
Detección de imágenes flexible. El plugin usa una expresión regular que captura URLs en cualquier formato: absolutas (https://midominio.com/wp-content/uploads/...), protocol-relative (//midominio.com/...), o relativas al dominio (/wp-content/uploads/...). También reconoce atributos de lazy-loading como data-src o data-lazy-src. Esto es importante porque las primeras versiones del plugin fallaban silenciosamente cuando las URLs no empezaban exactamente por el wp_upload_dir()['baseurl'].
Descarga en streaming. Una versión anterior del plugin usaba wp_remote_get() de forma directa, lo que carga la respuesta HTTP entera en memoria antes de devolverla. Con imágenes grandes (en mi caso, una de 165 MB enterrada en un post de 2022) eso provoca un Allowed memory size exhausted y el proceso muere. La solución fue pasarle los parámetros 'stream' => true y 'filename' => ruta_temporal, que hacen que la imagen se escriba directamente a disco sin pasar por la RAM.
Comprobación previa de tamaño. Antes de descargar cualquier imagen, el plugin hace un wp_remote_head() para mirar el Content-Length. Si supera un umbral configurable (30 MB por defecto, ajustable con la constante CEL_MAX_IMG_SIZE), salta esa imagen y lo registra en el log. Esto evita tirar minutos descargando archivos absurdamente grandes que probablemente sean errores de subida.
Procesamiento defensivo. Cada post se procesa dentro de un try/catch (\Throwable $e). Si uno falla por cualquier razón, el plugin lo registra en wp-content/debug.log con su ID y slug, y continúa con el siguiente. Al terminar, el aviso del panel de administración muestra cuántos posts se procesaron correctamente y cuáles fallaron.
Eficiencia en memoria. En lugar de cargar todos los WP_Post de un mes a la vez (lo que con muchos posts grandes puede saturar la memoria), se piden solo los IDs con 'fields' => 'ids' y cada post se carga individualmente, se procesa y se libera (unset + wp_cache_flush) antes de pasar al siguiente.
Reescritura de enlaces internos con doble estrategia. Si un post enlaza a otro post o página de tu propio blog, el HTML estático debería apuntar al fichero local correspondiente, no a la URL pública del blog en vivo. El plugin lo resuelve combinando dos vías: primero intenta con url_to_postid() de WordPress, normalizando previamente el dominio del enlace al actual con un preg_replace, para que funcione aunque la URL del enlace use un dominio histórico distinto del activo; si eso no resuelve nada (típicamente porque la estructura de permalinks cambió en algún momento), recurre a un fallback: un índice precalculado slug → ruta_local con todos los posts y páginas publicados. Si el último segmento del path coincide con algún slug del índice, lo enlaza al fichero local. URLs claramente «no contenido» (categorías, tags, autores, feeds, paths que empiezan por wp-) se saltan explícitamente para no reescribirlas por error.
Dominios históricos vía constante. Si tu blog ha cambiado de dominio en algún momento (algo más común de lo que parece), es probable que dentro del contenido de posts antiguos haya enlaces hardcodeados al dominio viejo. El plugin solo conoce el dominio actual mediante get_site_url(), así que esos enlaces históricos se le escaparían. Para solucionarlo hay una constante opcional, CEL_EXTRA_INTERNAL_HOSTS, que se declara en wp-config.php y le indica al plugin qué dominios adicionales debe considerar internos. En mi caso, el blog estuvo en blogpocket.com antes de mudarse a lanzatu.blog, así que añadí:
define( 'CEL_EXTRA_INTERNAL_HOSTS', 'blogpocket.com' );
Para varios dominios históricos, sepáralos con coma: 'dominio-uno.com,dominio-dos.es'. Si tu blog ha vivido siempre en el mismo dominio, esta constante no se declara y todo funciona automáticamente.
El problema del peso
Cuando terminó la generación, comprimí la carpeta en un ZIP para moverlo donde lo necesitara. El resultado: 1,41 GB. Demasiado para GitHub Pages, que tiene un límite duro de 1 GB. También demasiado para subirlo cómodamente por el File Manager de muchos hostings.
La solución obvia para ese tamaño cuando lo dominante son imágenes es convertir a WebP, el formato de Google que mejora la compresión de JPEG/PNG entre un 40% y un 70% manteniendo calidad equivalente. En mi caso el ahorro real fue del 86%: las imágenes pasaron de 1,31 GB a 179,6 MB.
A continuación, los pasos exactos que ejecuté en mi Mac. Funcionan en cualquier macOS razonablemente moderno y, con ajustes mínimos, en Linux.
Paso 1: instalar Homebrew y las herramientas WebP
Si vienes de un Mac recién comprado o sin entorno de desarrollo, lo más probable es que no tengas Homebrew. Se instala con un único comando desde la terminal:
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
Te pedirá la contraseña de tu usuario administrador (no verás caracteres mientras escribes, es normal). Si no tienes las Command Line Tools de Xcode, las descargará automáticamente. Calcula entre 5 y 15 minutos según conexión.
Cuando termine, te imprimirá una sección titulada «Next steps» con tres comandos que añaden brew al PATH de tu shell. Son imprescindibles. En un Mac con Apple Silicon y zsh (el shell por defecto desde macOS Catalina) son exactamente estos, sustituyendo TU_USUARIO por el tuyo:
echo >> /Users/TU_USUARIO/.zprofile
echo 'eval "$(/opt/homebrew/bin/brew shellenv zsh)"' >> /Users/TU_USUARIO/.zprofile
eval "$(/opt/homebrew/bin/brew shellenv zsh)"
Verifica con:
brew --version
Si te devuelve Homebrew 5.x.x, está listo. Ahora instala las utilidades WebP:
brew install webp
Esto te deja disponibles cwebp (para JPG/PNG/BMP), dwebp (para descomprimir), gif2webp (preserva animaciones de GIF), y otras herramientas relacionadas. Comprueba con:
cwebp -version
Debe responder con un número de versión (en mi caso, 1.6.0).
Paso 2: descargar la copia estática y prepararla
Desde el panel de WordPress, en el menú «Copia Estática», genera los años y páginas que quieras incluir. El plugin los deposita en wp-content/uploads/copia-estatica-html/ de tu servidor. Descárgalo como ZIP (por FTP, por File Manager de cPanel, o como prefieras) y descomprímelo en una carpeta de trabajo en tu Mac. Yo usé ~/Downloads/blog-estatico/.
La estructura resultante debe verse así:
blog-estatico/
└── copia-estatica-html/
├── index.html
├── style.css
├── img/
│ ├── 00a1b2c3...d4e5f6.jpg
│ ├── 11a2b3c4...d5e6f7.png
│ └── ... (cientos o miles de archivos con nombres de hash MD5)
├── 2018/
│ ├── index.html
│ └── 01/, 02/, ...
├── 2019/
├── ...
└── pages/
├── index.html
└── ...
Paso 3: el script de conversión a WebP
Este es el corazón del proceso. Es un script bash que recorre /img/, convierte cada imagen a WebP con cwebp (o gif2webp para GIFs animados), borra el original solo si la conversión fue correcta, y después reescribe todas las referencias en los archivos .html para que apunten a los nuevos .webp.
He cuidado dos detalles importantes que pueden parecer triviales pero ahorran disgustos:
- Compatibilidad con Bash 3.2 de macOS. Apple sigue trayendo de serie Bash 3.2 (de 2007) por motivos de licencia. Eso significa que features como
mapfileo${var,,}(expansión a minúsculas) no están disponibles. El script las evita y usa alternativas portables (while readytr). - Patrón de reemplazo estricto. El
sedque actualiza los HTML solo toca rutas con el formato exacto del plugin (img/HASH_MD5_DE_32_CHARS.extensión). Esto evita que rompa enlaces externos casualmente parecidos.
Guarda este contenido como convertir-img-a-webp.sh en ~/Downloads/blog-estatico/:
#!/usr/bin/env bash
#
# convertir-img-a-webp.sh
# Convierte todas las imágenes de /img/ a WebP y actualiza referencias en HTML.
# Uso: ./convertir-img-a-webp.sh /ruta/a/copia-estatica-html [--quality 82]
set -euo pipefail
ROOT="${1:-}"
QUALITY=82
while [[ $# -gt 0 ]]; do
case "$1" in
--quality) QUALITY="$2"; shift 2 ;;
*) if [[ -z "${ROOT_SET:-}" ]]; then ROOT="$1"; ROOT_SET=1; fi; shift ;;
esac
done
[[ -z "$ROOT" ]] && { echo "Uso: $0 /ruta/a/copia-estatica-html"; exit 1; }
[[ ! -d "$ROOT/img" ]] && { echo "ERROR: no encuentro $ROOT/img"; exit 1; }
# Detectar herramienta
if command -v cwebp >/dev/null 2>&1; then
TOOL="cwebp"
elif command -v convert >/dev/null 2>&1; then
TOOL="convert"
else
echo "ERROR: necesito cwebp o ImageMagick. Instala con: brew install webp"
exit 1
fi
HAS_GIF2WEBP=0
command -v gif2webp >/dev/null 2>&1 && HAS_GIF2WEBP=1
echo "Herramienta: $TOOL (calidad $QUALITY)"
[[ $HAS_GIF2WEBP -eq 1 ]] && echo "gif2webp disponible para GIFs animados"
echo ""
filesize() { stat -c%s "$1" 2>/dev/null || stat -f%z "$1"; }
IMG_DIR="$ROOT/img"
TOTAL_BEFORE=0; TOTAL_AFTER=0
COUNT_OK=0; COUNT_FAIL=0; COUNT_SKIP=0
FILES=()
while IFS= read -r f; do FILES+=("$f"); done < <(find "$IMG_DIR" -maxdepth 1 -type f \
\( -iname "*.jpg" -o -iname "*.jpeg" -o -iname "*.png" \
-o -iname "*.gif" -o -iname "*.bmp" \) | sort)
TOTAL=${#FILES[@]}
[[ $TOTAL -eq 0 ]] && { echo "No hay imágenes que convertir."; exit 0; }
echo "Imágenes a convertir: $TOTAL"
echo "Progreso cada 50 archivos:"
echo ""
i=0
for src in "${FILES[@]}"; do
i=$((i + 1))
base="${src%.*}"
dst="${base}.webp"
[[ -f "$dst" ]] && { COUNT_SKIP=$((COUNT_SKIP + 1)); continue; }
size_before=$(filesize "$src")
ok=0
ext_lower=$(printf '%s' "${src##*.}" | tr 'A-Z' 'a-z')
if [ "$ext_lower" = "gif" ] && [ $HAS_GIF2WEBP -eq 1 ]; then
gif2webp -q "$QUALITY" "$src" -o "$dst" >/dev/null 2>&1 && ok=1
else
case "$TOOL" in
cwebp) cwebp -quiet -q "$QUALITY" "$src" -o "$dst" 2>/dev/null && ok=1 ;;
convert) convert "$src" -quality "$QUALITY" "$dst" 2>/dev/null && ok=1 ;;
esac
fi
if [[ $ok -eq 1 && -f "$dst" && $(filesize "$dst") -gt 0 ]]; then
TOTAL_BEFORE=$((TOTAL_BEFORE + size_before))
TOTAL_AFTER=$((TOTAL_AFTER + $(filesize "$dst")))
rm -f "$src"
COUNT_OK=$((COUNT_OK + 1))
else
rm -f "$dst" 2>/dev/null || true
echo " ⚠ Falló: $(basename "$src")"
COUNT_FAIL=$((COUNT_FAIL + 1))
fi
(( i % 50 == 0 )) && echo " [$i/$TOTAL] $(( i * 100 / TOTAL ))%"
done
human() {
local b=$1
if (( b > 1073741824 )); then awk -v b=$b 'BEGIN{printf "%.2f GB", b/1073741824}'
elif (( b > 1048576 )); then awk -v b=$b 'BEGIN{printf "%.1f MB", b/1048576}'
elif (( b > 1024 )); then awk -v b=$b 'BEGIN{printf "%.1f KB", b/1024}'
else echo "$b B"; fi
}
echo ""
echo "==========================================="
echo " CONVERSIÓN TERMINADA"
echo "==========================================="
echo "Convertidas: $COUNT_OK"
echo "Saltadas: $COUNT_SKIP"
echo "Fallidas: $COUNT_FAIL"
if (( TOTAL_BEFORE > 0 )); then
saved=$(( TOTAL_BEFORE - TOTAL_AFTER ))
pct=$(( saved * 100 / TOTAL_BEFORE ))
echo "Antes: $(human $TOTAL_BEFORE) → Después: $(human $TOTAL_AFTER)"
echo "Ahorrado: $(human $saved) (${pct}%)"
fi
echo ""
echo "Actualizando referencias en HTML..."
HTML_COUNT=0; HTML_CHANGED=0
SED_EXPR='s#(img/[a-f0-9]{32})\.(jpe?g|png|gif|bmp)#\1.webp#gi'
while IFS= read -r -d '' html; do
HTML_COUNT=$((HTML_COUNT + 1))
sed -i.bak -E "$SED_EXPR" "$html"
cmp -s "$html" "$html.bak" || HTML_CHANGED=$((HTML_CHANGED + 1))
rm -f "$html.bak"
done < <(find "$ROOT" -type f -name "*.html" -print0)
echo "HTML revisados: $HTML_COUNT, modificados: $HTML_CHANGED"
echo "✓ Listo. Tamaño total: $(du -sh "$ROOT" | cut -f1)"
Dale permiso de ejecución y lánzalo:
cd ~/Downloads/blog-estatico
chmod +x convertir-img-a-webp.sh
./convertir-img-a-webp.sh ./copia-estatica-html
Para una carpeta con dos mil imágenes y 1,3 GB de peso, calcula entre 10 y 25 minutos según tu Mac. Verás líneas de progreso cada 50 archivos, y al final un resumen con cuánto has ahorrado.
Paso 4: imágenes problemáticas (el caso CMYK)
En mi caso una imagen falló: un JPG de 719 KB. cwebp solo entiende RGB, así que cuando le pasas un JPEG en espacio CMYK (típico de exportaciones desde Photoshop con perfil de imprenta) te suelta:
libjpeg error: Unsupported color conversion request
Error! Could not process file ...
La solución es convertir el archivo a sRGB antes de pasarlo por cwebp. Y aquí hay un detalle elegante de macOS: trae sips (Scriptable Image Processing System) preinstalado, sin necesidad de ImageMagick. Estas tres líneas resuelven el caso:
sips -s format jpeg \
--matchTo "/System/Library/ColorSync/Profiles/sRGB Profile.icc" \
ruta/al/archivo.jpg \
--out /tmp/fixed.jpg
cwebp -q 82 /tmp/fixed.jpg -o ruta/al/archivo.webp
rm ruta/al/archivo.jpg /tmp/fixed.jpg
Te recuerdo que la ruta al archivo es del tipo: ./blog-estatico/copia-estatica-html/img
Si encuentras varias imágenes así, lo más práctico es lanzar sips en bucle sobre los .jpg que sigan en /img/ después de la conversión principal.
Paso 5: subir al hosting
Una vez optimizada, mi copia ocupaba 209 MB. Para subirla por el File Manager de cPanel tropecé con otro límite: el hosting solo permite ZIPs de hasta 150 MB por subida. La solución fue trocear en tres ZIPs, no como un multivolumen tradicional (que da más problemas que soluciones), sino como tres ZIPs independientes que descomprimo uno tras otro en el mismo destino.
El criterio de partición que mejor funcionó fue por contenido: uno con todo lo que no son imágenes (HTML, CSS y carpetas de años), y los otros dos partiendo la carpeta img/ por el primer carácter del hash MD5. Como los MD5 son uniformes, dividir en 0-7 y 8-f da dos mitades casi exactas:
cd ~/Downloads/blog-estatico/copia-estatica-html
# Todo lo que no es /img/
zip -r ~/Downloads/parte2-resto.zip . -x "img/*"
# Las dos mitades de /img/
zip -r ~/Downloads/parte1a-img.zip img/[0-7]*
zip -r ~/Downloads/parte1b-img.zip img/[89a-f]*
ls -lh ~/Downloads/parte*.zip
Verifica que las tres partes estén bajo el límite de tu hosting. Si alguna sigue siendo demasiado grande, particiona más fino (img/[0-3]*, img/[4-7]*, etc.).
En cPanel:
- Crea la carpeta destino, por ejemplo
/public_html/copia-estatica/. - Sube
parte2-resto.zip, extráelo en su sitio, bórralo. Aquí ya puedes abrirhttps://tu-dominio.com/copia-estatica/y comprobar que el sitio se ve (sin imágenes todavía). - Sube
parte1a-img.zip, extrae, borra. - Sube
parte1b-img.zip, extrae, borra.
Los dos ZIPs de imágenes se descomprimen sobre la misma carpeta img/ y se complementan: cada uno aporta archivos distintos (los hashes son únicos), no hay riesgo de sobrescritura.
Paso 6: limpiar archivos basura del Mac
Si comprimiste con Finder o sin las opciones de exclusión adecuadas, en cada carpeta del servidor habrá quedado un .DS_Store: archivos invisibles que macOS crea en cada carpeta que abres. En tu Mac no los ves porque están ocultos, pero en Linux quedan a la vista y delatan el origen. No rompen nada, pero conviene quitarlos.
Si tienes acceso a Terminal en cPanel o SSH:
find /home/TU_USUARIO/public_html/copia-estatica -name ".DS_Store" -delete
Y para evitarlo en el futuro, comprime desde Terminal añadiendo exclusiones:
zip -r archivo.zip carpeta -x "*.DS_Store" -x "__MACOSX/*"
Paso 7: configurar .htaccess
Una copia estática del blog publicada en tu dominio crea un problema potencial de SEO: contenido duplicado con el blog en vivo. Conviene decirle a Google que no la indexe. También conviene asegurarse de que el MIME image/webp se sirve correctamente y de bloquear el acceso a archivos ocultos por higiene.
Crea (con . delante, en File Manager activa «Show Hidden Files») un archivo .htaccess dentro de la carpeta copia-estatica/, con este contenido:
# Bloquear indexación por buscadores
<IfModule mod_headers.c>
Header set X-Robots-Tag "noindex, nofollow"
</IfModule>
# Tipo MIME correcto para WebP
<IfModule mod_mime.c>
AddType image/webp .webp
</IfModule>
# Servir index.html al entrar en una carpeta
DirectoryIndex index.html
# Bloquear acceso a archivos ocultos (.DS_Store, .git, .htaccess mismo, etc.)
<FilesMatch "^\.">
Require all denied
</FilesMatch>
Paso 8: verificar que todo está como debe
Desde la terminal de tu Mac, comprueba el estado final con tres curl:
curl -I https://tu-dominio.com/copia-estatica/
curl -I https://tu-dominio.com/copia-estatica/img/UN_HASH_REAL.webp
curl -I https://tu-dominio.com/copia-estatica/.htaccess
Sustituye UN_HASH_REAL por uno cualquiera de los nombres de tus webp. Lo que tienes que ver:
- El primero:
HTTP/2 200con la líneax-robots-tag: noindex, nofollow. - El segundo:
HTTP/2 200concontent-type: image/webpy la misma cabecera noindex. - El tercero:
HTTP/2 403— el propio.htaccessqueda protegido contra lectura externa.
Si los tres responden como toca, has terminado.
Lo que aprendí por el camino
Cuatro lecciones que me llevo y comparto:
Las funciones cómodas tienen letra pequeña. wp_remote_get() parece inocua hasta que descubres que carga la respuesta entera en memoria. Para descargas pesadas, stream => true y filename no son una optimización, son una necesidad.
Lo pequeño se mide después. Un fichero suelto roto (el JPG en CMYK), un archivo invisible olvidado (los .DS_Store), un dominio mal escrito al testar: los fallos pequeños son los que más fricción introducen al final del proceso. Conviene revisar dos veces y tener herramientas de diagnóstico a mano (curl -I es tu amigo).
Los límites del hosting son rara vez negociables, pero siempre sorteables. El límite de 150 MB del File Manager me obligó a partir el ZIP en tres. Lejos de ser un problema, esa partición me permitió ir comprobando el sitio incrementalmente: primero la estructura sin imágenes, luego una mitad, luego la otra.
Las migraciones de dominio dejan huella en el contenido. Cuando WordPress cambia de dominio desde Ajustes → Generales, actualiza la configuración y los enlaces dinámicos, pero las URLs hardcodeadas dentro del HTML de los posts antiguos quedan tal cual estaban. Cualquier herramienta que solo mire get_site_url() se las salta sin darse cuenta. Esto importa no solo para este plugin, sino para SEO, para auditorías de enlaces rotos, para exportaciones… La solución limpia es declarar explícitamente los dominios históricos para que la herramienta los considere internos, como hace la constante CEL_EXTRA_INTERNAL_HOSTS de este plugin.
Y el resultado, medido objetivamente: una copia estática del blog, optimizada con WebP, publicada en mi propio dominio, ocupando un 15% del tamaño original. Aproximadamente 200 MB de archivo navegable que sobrevive a lo que le pase a la instalación de WordPress de aquí en adelante.
Si te animas a replicarlo y te encuentras con algún tropiezo distinto de los que documento, escríbeme en comentarios. Cada caso enseña algo.
