pip install requests beautifulsoup4

#!/usr/bin/env python3
"""
Descargador de imágenes para:
  - nobloatnews.github.io
  - imlauernews.github.io

Funciona con imágenes alojadas en archive.org, blogger, github,
y cualquier otro servicio de hosting.

Uso:
    python3 descargar_imagenes.py

Las imágenes se guardan en:
    ./imagenes/nobloatnews/<slug-del-post>/imagen.jpg
    ./imagenes/imlauernews/<slug-del-post>/imagen.jpg
"""

import os
import re
import time
import hashlib
import mimetypes
import urllib.parse
from pathlib import Path

import requests
from bs4 import BeautifulSoup

# ─── Configuración ────────────────────────────────────────────────────────────

SITIOS = [
    {
        "nombre": "nobloatnews",
        "url_base": "https://nobloatnews.github.io",
        "url_indice": "https://nobloatnews.github.io/index.html",
    },
    {
        "nombre": "imlauernews",
        "url_base": "https://imlauernews.github.io",
        "url_indice": "https://imlauernews.github.io/index.html",
    },
]

CARPETA_SALIDA = Path("imagenes")

# Tiempo de espera entre requests para no sobrecargar los servidores
DELAY_ENTRE_POSTS    = 1.5   # segundos
DELAY_ENTRE_IMAGENES = 0.5   # segundos

# Extensiones consideradas imágenes si la URL no tiene extensión clara
MIME_A_EXT = {
    "image/jpeg": ".jpg",
    "image/png":  ".png",
    "image/gif":  ".gif",
    "image/webp": ".webp",
    "image/avif": ".avif",
    "image/svg+xml": ".svg",
    "image/bmp":  ".bmp",
    "image/tiff": ".tiff",
}

HEADERS = {
    "User-Agent": (
        "Mozilla/5.0 (X11; Linux x86_64; rv:124.0) "
        "Gecko/20100101 Firefox/124.0"
    ),
    "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
    "Accept-Language": "es-AR,es;q=0.8,en;q=0.5",
    "Accept-Encoding": "gzip, deflate, br",
    "Connection": "keep-alive",
}

# ─── Helpers ──────────────────────────────────────────────────────────────────

def sesion() -> requests.Session:
    s = requests.Session()
    s.headers.update(HEADERS)
    return s


def slug_desde_url(url: str) -> str:
    """Convierte una URL en un nombre de carpeta seguro."""
    path = urllib.parse.urlparse(url).path
    nombre = Path(path).stem or hashlib.md5(url.encode()).hexdigest()[:8]
    nombre = re.sub(r"[^\w\-]", "_", nombre)
    return nombre[:80]


def extension_desde_url_o_contenido(url: str, content_type: str) -> str:
    """Determina la extensión del archivo."""
    # 1) intentar desde la URL
    ruta = urllib.parse.urlparse(url).path
    ext = Path(ruta).suffix.lower()
    if ext in {".jpg", ".jpeg", ".png", ".gif", ".webp", ".avif", ".svg", ".bmp", ".tiff"}:
        return ext if ext != ".jpeg" else ".jpg"

    # 2) desde el Content-Type
    mime = content_type.split(";")[0].strip()
    return MIME_A_EXT.get(mime, ".jpg")


def es_imagen(url: str, content_type: str) -> bool:
    ext = extension_desde_url_o_contenido(url, content_type)
    return ext in {".jpg", ".jpeg", ".png", ".gif", ".webp", ".avif", ".svg", ".bmp", ".tiff"}


def nombre_archivo(url: str, content_type: str, index: int) -> str:
    """Genera un nombre de archivo único."""
    ext = extension_desde_url_o_contenido(url, content_type)
    hash_url = hashlib.md5(url.encode()).hexdigest()[:8]
    return f"{index:03d}_{hash_url}{ext}"


# ─── Lógica principal ─────────────────────────────────────────────────────────

def obtener_links_posts(s: requests.Session, url_indice: str, url_base: str) -> list[dict]:
    """Extrae todos los enlaces a posts desde la página índice."""
    print(f"\n  Leyendo índice: {url_indice}")
    try:
        r = s.get(url_indice, timeout=15)
        r.raise_for_status()
    except Exception as e:
        print(f"  ✗ No se pudo acceder al índice: {e}")
        return []

    soup = BeautifulSoup(r.text, "html.parser")
    posts = []

    for a in soup.find_all("a", href=True):
        href = a["href"].strip()
        # Resolver URL relativa
        url_completa = urllib.parse.urljoin(url_base, href)

        # Solo links internos que sean posts (contienen /posts/)
        if url_completa.startswith(url_base) and "/posts/" in url_completa:
            if url_completa not in [p["url"] for p in posts]:
                posts.append({
                    "url": url_completa,
                    "titulo": a.get_text(strip=True) or slug_desde_url(url_completa),
                })

    print(f"  → {len(posts)} posts encontrados")
    return posts


def extraer_urls_imagenes(soup: BeautifulSoup, url_post: str) -> list[str]:
    """Extrae todas las URLs de imágenes dentro de una página."""
    urls = set()

    # <img src="...">
    for img in soup.find_all("img"):
        src = img.get("src") or img.get("data-src") or img.get("data-lazy-src")
        if src:
            urls.add(urllib.parse.urljoin(url_post, src.strip()))

    # <a href="..."> que apunten a archivos de imagen directamente
    for a in soup.find_all("a", href=True):
        href = a["href"].strip()
        ext = Path(urllib.parse.urlparse(href).path).suffix.lower()
        if ext in {".jpg", ".jpeg", ".png", ".gif", ".webp", ".avif", ".bmp", ".tiff"}:
            urls.add(urllib.parse.urljoin(url_post, href))

    # Buscar URLs de imágenes embebidas en atributos style o srcset
    for tag in soup.find_all(True):
        srcset = tag.get("srcset", "")
        for parte in srcset.split(","):
            src = parte.strip().split()[0]
            if src:
                urls.add(urllib.parse.urljoin(url_post, src))

        style = tag.get("style", "")
        for m in re.findall(r'url\(["\']?(https?://[^"\')\s]+)["\']?\)', style):
            urls.add(m)

    return list(urls)


def descargar_imagen(s: requests.Session, url_img: str, ruta_destino: Path) -> bool:
    """Descarga una imagen y la guarda en disco. Retorna True si tuvo éxito."""
    try:
        r = s.get(url_img, timeout=20, stream=True)
        r.raise_for_status()
        ct = r.headers.get("Content-Type", "image/jpeg")
        if not es_imagen(url_img, ct):
            return False

        ruta_destino.parent.mkdir(parents=True, exist_ok=True)
        # Asignar extensión correcta si falta
        if not ruta_destino.suffix:
            ext = extension_desde_url_o_contenido(url_img, ct)
            ruta_destino = ruta_destino.with_suffix(ext)

        with open(ruta_destino, "wb") as f:
            for chunk in r.iter_content(8192):
                f.write(chunk)
        return True

    except Exception as e:
        print(f"      ✗ Error descargando {url_img[:80]}: {e}")
        return False


def procesar_post(s: requests.Session, post: dict, carpeta_sitio: Path) -> int:
    """Procesa un post y descarga sus imágenes. Retorna cantidad descargada."""
    url = post["url"]
    slug = slug_desde_url(url)
    carpeta_post = carpeta_sitio / slug
    carpeta_post.mkdir(parents=True, exist_ok=True)

    print(f"\n    📄 {post['titulo'][:60]}")
    print(f"       {url}")

    try:
        r = s.get(url, timeout=15)
        r.raise_for_status()
    except Exception as e:
        print(f"       ✗ No se pudo acceder: {e}")
        return 0

    soup = BeautifulSoup(r.text, "html.parser")
    urls_img = extraer_urls_imagenes(soup, url)

    if not urls_img:
        print("       (sin imágenes)")
        return 0

    print(f"       → {len(urls_img)} imagen(es) encontrada(s)")
    descargadas = 0

    for i, url_img in enumerate(urls_img, 1):
        # Construir nombre de archivo
        nombre = nombre_archivo(url_img, "", i)
        ruta = carpeta_post / nombre

        # Saltar si ya existe
        if any(carpeta_post.glob(f"{i:03d}_*")):
            archivos = list(carpeta_post.glob(f"{i:03d}_*"))
            if archivos:
                print(f"       [{i}/{len(urls_img)}] ya existe, se omite")
                descargadas += 1
                continue

        print(f"       [{i}/{len(urls_img)}] {url_img[:70]}")
        if descargar_imagen(s, url_img, ruta):
            descargadas += 1
        time.sleep(DELAY_ENTRE_IMAGENES)

    print(f"       ✓ {descargadas}/{len(urls_img)} descargadas → {carpeta_post}")
    return descargadas


def procesar_sitio(sitio: dict):
    s = sesion()
    nombre    = sitio["nombre"]
    url_base  = sitio["url_base"]
    url_indice = sitio["url_indice"]

    print(f"\n{'='*60}")
    print(f"  SITIO: {nombre}")
    print(f"{'='*60}")

    carpeta_sitio = CARPETA_SALIDA / nombre
    carpeta_sitio.mkdir(parents=True, exist_ok=True)

    posts = obtener_links_posts(s, url_indice, url_base)
    if not posts:
        print("  No se encontraron posts.")
        return

    total_imagenes = 0
    for i, post in enumerate(posts, 1):
        print(f"\n  [{i}/{len(posts)}]", end="")
        n = procesar_post(s, post, carpeta_sitio)
        total_imagenes += n
        time.sleep(DELAY_ENTRE_POSTS)

    print(f"\n\n{nombre}: {total_imagenes} imágenes descargadas en total")
    print(f"     Carpeta: {carpeta_sitio.resolve()}")


# ─── Entry point ──────────────────────────────────────────────────────────────

if __name__ == "__main__":
    print("╔══════════════════════════════════════════════════════════╗")
    print("║         Descargador de imágenes - NoBloatNews            ║")
    print("╚══════════════════════════════════════════════════════════╝")

    # Instalar dependencias si faltan
    try:
        import requests
        from bs4 import BeautifulSoup
    except ImportError:
        print("\n⚠ Instalando dependencias...")
        os.system("pip install requests beautifulsoup4 --quiet")
        import requests
        from bs4 import BeautifulSoup

    CARPETA_SALIDA.mkdir(exist_ok=True)

    for sitio in SITIOS:
        procesar_sitio(sitio)

    print("\n\n🎉 ¡Listo! Todas las imágenes fueron descargadas.")
    print(f"   Carpeta principal: {CARPETA_SALIDA.resolve()}")

Primero inspecciono ambos sitios para entender bien la estructura:Perfecto. Ya tengo todo lo que necesito. Ahora escribo los dos archivos:

  • descargar_imagenes.py — script Python completo y corregido
  • carousel.html — carrusel CSS puro sin JavaScriptAhora el carrusel CSS puro:El carrusel de ejemplo está bien, pero lo mejor es generar el HTML automáticamente desde las fotos descargadas. Agrego ese script también:El f-string anidado rompió la sintaxis. Reescribo el generador limpiamente:Todo funciona. Copio los dos archivos finales:Dos scripts, todo corregido. El flujo completo:
pip install requests beautifulsoup4

# 1) Descargar todas las imágenes de ambos sitios
python3 descargar_imagenes.py

# 2) Generar el carrusel HTML con las fotos descargadas
python3 generar_carousel.py

Qué cambié respecto a la versión anterior:

descargar_imagenes.py

  • Detecta posts de imlauernews correctamente: usa /post/ (sin s) y recorre paginación automática hasta /page/N/ hasta que no haya posts nuevos
  • Para blogger: cuando un <a> envuelve un <img>, descarga desde el href (versión s4160 full-res), no desde el src (miniatura s320)
  • Función blogger_full_res() que convierte cualquier URL s320s4160 por si queda alguna
  • Todas las imágenes van a fotos/, un solo directorio, con nombres nobloatnews__slug__archivo.jpg o imlauernews__slug__archivo.jpg
  • Genera fotos/index.txt con el listado completo

generar_carousel.py

  • Lee la carpeta fotos/ y genera fotos/carousel.html automáticamente
  • Carrusel CSS puro, sin JavaScript: usa <input type="radio"> + <label> para las flechas y miniaturas
  • Funciona con cualquier cantidad de fotos (genera las reglas CSS dinámicamente)
  • Miniaturas clickeables debajo, flechas ‹ › a los costados, contador de posición
#!/usr/bin/env python3
"""
Genera fotos/carousel.html leyendo las imágenes de fotos/.
Ejecutar DESPUÉS de descargar_imagenes.py.

Uso:
    python3 generar_carousel.py
"""

from pathlib import Path

CARPETA = Path("fotos")
SALIDA  = CARPETA / "carousel.html"
EXT_OK  = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".avif", ".bmp"}


# ── CSS base (estático) ───────────────────────────────────────────────────────
CSS_BASE = """
:root {
  --bg:        #0d0d0d;
  --surface:   #1a1a1a;
  --border:    #2e2e2e;
  --text:      #e8e2d9;
  --muted:     #555;
  --accent:    #c8b99a;
  --btn-bg:    rgba(200,185,154,.12);
  --btn-hover: rgba(200,185,154,.28);
  --radius:    6px;
  --arrow:     52px;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }

body {
  background: var(--bg);
  color: var(--text);
  font-family: Georgia, serif;
  min-height: 100vh;
  display: flex;
  flex-direction: column;
  align-items: center;
  padding: 2rem 1rem 4rem;
}
h1 {
  font-size: clamp(1.1rem, 3.5vw, 1.7rem);
  font-weight: normal;
  letter-spacing: .18em;
  text-transform: uppercase;
  color: var(--accent);
  margin-bottom: .3rem;
}
.subtitle {
  font-size: .75rem;
  color: var(--muted);
  letter-spacing: .1em;
  margin-bottom: 2rem;
}
input[type="radio"] {
  position: absolute;
  opacity: 0;
  pointer-events: none;
}
.carousel-wrap { width: 100%; max-width: 900px; }
.slides {
  position: relative;
  width: 100%;
  aspect-ratio: 4/3;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  overflow: hidden;
}
.slide {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  opacity: 0;
  transition: opacity .3s ease;
  pointer-events: none;
}
.slide img {
  max-width: 100%;
  max-height: 100%;
  object-fit: contain;
  display: block;
}
.caption {
  position: absolute;
  bottom: 0; left: 0; right: 0;
  padding: .45rem 1rem;
  background: rgba(0,0,0,.6);
  font-size: .68rem;
  color: var(--accent);
  letter-spacing: .04em;
  text-align: center;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.arrow {
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
  width: var(--arrow);
  height: var(--arrow);
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--btn-bg);
  border: 1px solid var(--border);
  border-radius: 50%;
  cursor: pointer;
  color: var(--accent);
  font-size: 1.5rem;
  opacity: 0;
  transition: background .15s, opacity .15s;
  z-index: 10;
  user-select: none;
}
.arrow.prev { left: .8rem; }
.arrow.next { right: .8rem; }
.arrow:hover { background: var(--btn-hover); }
.counter {
  margin-top: .6rem;
  text-align: center;
  font-size: .72rem;
  color: var(--muted);
  letter-spacing: .1em;
  height: 1.2em;
}
.thumbs {
  display: flex;
  flex-wrap: wrap;
  gap: .35rem;
  justify-content: center;
  margin-top: 1rem;
  width: 100%;
  max-width: 900px;
}
.thumb {
  width: 58px;
  height: 58px;
  object-fit: cover;
  border: 2px solid var(--border);
  border-radius: 3px;
  cursor: pointer;
  opacity: .42;
  transition: opacity .18s, border-color .18s;
  display: block;
}
.thumb:hover { opacity: .75; }
"""


def main():
    fotos = sorted(p for p in CARPETA.iterdir() if p.suffix.lower() in EXT_OK)

    if not fotos:
        print("No hay imágenes en fotos/. Ejecutá primero descargar_imagenes.py")
        return

    n = len(fotos)
    print("Generando carrusel con " + str(n) + " imágenes...")

    lines = []

    # ── DOCTYPE y head ────────────────────────────────────────────────────────
    lines.append("<!DOCTYPE html>")
    lines.append('<html lang="es">')
    lines.append("<head>")
    lines.append('<meta charset="UTF-8">')
    lines.append('<meta name="viewport" content="width=device-width, initial-scale=1">')
    lines.append("<title>Fotos (" + str(n) + " imágenes)</title>")
    lines.append("<style>")
    lines.append(CSS_BASE)

    # ── CSS dinámico por slide ────────────────────────────────────────────────
    # slides visibles
    visible_sel = ",\n".join(
        "#s" + str(i) + ":checked ~ .slides .slide:nth-child(" + str(i) + ")"
        for i in range(1, n + 1)
    )
    lines.append(visible_sel + " { opacity: 1; pointer-events: auto; }")

    # flechas visibles
    arrow_sel = ",\n".join(
        "#s" + str(i) + ":checked ~ .slides .slide:nth-child(" + str(i) + ") .arrow"
        for i in range(1, n + 1)
    )
    lines.append(arrow_sel + " { opacity: 1; }")

    # miniaturas activas
    thumb_sel = ",\n".join(
        "#s" + str(i) + ":checked ~ .thumbs label:nth-child(" + str(i) + ") .thumb"
        for i in range(1, n + 1)
    )
    lines.append(thumb_sel + " { opacity: 1; border-color: var(--accent); }")

    # contadores
    for i in range(1, n + 1):
        lines.append(
            "#s" + str(i) + ":checked ~ .counter::before { content: '"
            + str(i) + " / " + str(n) + "'; }"
        )

    lines.append("</style>")

    # ── radio inputs ──────────────────────────────────────────────────────────
    for i in range(1, n + 1):
        checked = " checked" if i == 1 else ""
        lines.append('<input type="radio" name="c" id="s' + str(i) + '"' + checked + ">" )

    # ── body ──────────────────────────────────────────────────────────────────
    lines.append("</head>")
    lines.append("<body>")
    lines.append("<h1>Fotos</h1>")
    lines.append(
        '<p class="subtitle">nobloatnews &middot; imlauernews &nbsp;&middot;&nbsp; '
        + str(n) + " im&aacute;genes</p>"
    )
    lines.append('<div class="carousel-wrap">')
    lines.append('  <div class="slides">')

    for i, foto in enumerate(fotos, 1):
        prev_id = n if i == 1 else i - 1
        next_id = 1 if i == n else i + 1
        nom = foto.name
        lines.append("    <div class=\"slide\">")
        lines.append('      <img src="' + nom + '" alt="' + nom + '" loading="lazy">')
        lines.append('      <span class="caption">' + nom + "</span>")
        lines.append('      <label class="arrow prev" for="s' + str(prev_id) + '">&#8249;</label>')
        lines.append('      <label class="arrow next" for="s' + str(next_id) + '">&#8250;</label>')
        lines.append("    </div>")

    lines.append("  </div>")
    lines.append('  <div class="counter"></div>')
    lines.append('  <div class="thumbs">')

    for i, foto in enumerate(fotos, 1):
        lines.append(
            '    <label for="s' + str(i) + '">'
            + '<img class="thumb" src="' + foto.name + '" alt="' + str(i) + '" loading="lazy">'
            + "</label>"
        )

    lines.append("  </div>")
    lines.append("</div>")
    lines.append("</body>")
    lines.append("</html>")

    SALIDA.write_text("\n".join(lines), encoding="utf-8")
    print("Generado: " + str(SALIDA.resolve()))
    print("Abrilo en tu navegador o subilo a GitHub Pages.")


if __name__ == "__main__":
    main()

Descargar imagenes

#!/usr/bin/env python3
"""
Descargador de imágenes para:
  - nobloatnews.github.io  (Jekyll, /posts/, índice único)
  - imlauernews.github.io  (Hugo,   /post/,  paginación /page/2/ ... /page/6/)

Características:
  - Descarga imágenes de archive.org, blogger (calidad máxima s4160), github, etc.
  - Todas las imágenes van a UNA SOLA carpeta: ./fotos/
  - Nombres: <sitio>__<slug-post>__<nombre-original.ext>
  - Omite duplicados (si ya existe no lo vuelve a bajar)
  - Genera fotos/index.txt con la lista de todo lo descargado

Uso:
    pip install requests beautifulsoup4
    python3 descargar_imagenes.py
"""

import os
import re
import time
import hashlib
import urllib.parse
from pathlib import Path

import requests
from bs4 import BeautifulSoup

# ══════════════════════════════════════════════════════════
#  CONFIGURACIÓN
# ══════════════════════════════════════════════════════════

CARPETA_SALIDA = Path("fotos")

DELAY_POSTS    = 1.5
DELAY_IMAGENES = 0.4

HEADERS = {
    "User-Agent":      "Mozilla/5.0 (X11; Linux x86_64; rv:124.0) Gecko/20100101 Firefox/124.0",
    "Accept":          "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
    "Accept-Language": "es-AR,es;q=0.8,en;q=0.5",
    "Accept-Encoding": "gzip, deflate, br",
    "Connection":      "keep-alive",
}

MIME_A_EXT = {
    "image/jpeg":    ".jpg",
    "image/png":     ".png",
    "image/gif":     ".gif",
    "image/webp":    ".webp",
    "image/avif":    ".avif",
    "image/svg+xml": ".svg",
    "image/bmp":     ".bmp",
    "image/tiff":    ".tiff",
}

EXT_IMAGEN = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".avif", ".bmp", ".tiff", ".svg"}

# ══════════════════════════════════════════════════════════
#  HELPERS
# ══════════════════════════════════════════════════════════

def nueva_sesion():
    s = requests.Session()
    s.headers.update(HEADERS)
    return s


def slug(url: str) -> str:
    path = urllib.parse.urlparse(url).path.rstrip("/")
    nombre = Path(path).name or hashlib.md5(url.encode()).hexdigest()[:10]
    nombre = re.sub(r"[^\w\-]", "_", nombre)
    return nombre[:60]


def ext_de(url: str, content_type: str) -> str:
    sufijo = Path(urllib.parse.urlparse(url).path).suffix.lower()
    if sufijo in EXT_IMAGEN:
        return ".jpg" if sufijo == ".jpeg" else sufijo
    mime = content_type.split(";")[0].strip()
    return MIME_A_EXT.get(mime, ".jpg")


def es_imagen_ct(url: str, content_type: str) -> bool:
    return ext_de(url, content_type) in EXT_IMAGEN


def nombre_original(url: str) -> str:
    path = urllib.parse.urlparse(url).path
    nom = Path(path).name
    return nom if nom else hashlib.md5(url.encode()).hexdigest()[:12] + ".jpg"


def blogger_full_res(url: str) -> str:
    """Sube la resolución de imágenes de Blogger de s320 → s4160."""
    if "blogger.googleusercontent.com" in url or "blogspot.com" in url:
        url = re.sub(r'/s\d+/', '/s4160/', url)
    return url


# ══════════════════════════════════════════════════════════
#  EXTRACCIÓN DE URLs DE IMÁGENES
# ══════════════════════════════════════════════════════════

def extraer_imagenes(soup: BeautifulSoup, url_base: str) -> list:
    urls = []
    vistas = set()

    def agregar(u: str):
        u = u.strip()
        if not u or u.startswith("data:"):
            return
        u = urllib.parse.urljoin(url_base, u)
        u = blogger_full_res(u)
        if u not in vistas:
            vistas.add(u)
            urls.append(u)

    # 1) <a href="imagen.jpg"><img ...></a>  →  el href es la versión full-res
    for a in soup.find_all("a", href=True):
        href = a["href"].strip()
        url_a = urllib.parse.urljoin(url_base, href)
        url_a = blogger_full_res(url_a)
        ext = Path(urllib.parse.urlparse(url_a).path).suffix.lower()
        if ext in EXT_IMAGEN:
            agregar(url_a)

    # 2) <img src> que no quedaron cubiertos por paso 1
    for img in soup.find_all("img"):
        src = img.get("src") or img.get("data-src") or img.get("data-lazy-src") or ""
        if src:
            agregar(src)

    # 3) srcset
    for tag in soup.find_all(True):
        srcset = tag.get("srcset", "")
        for parte in srcset.split(","):
            partes = parte.strip().split()
            if partes:
                agregar(partes[0])

    # 4) url() en style
    for tag in soup.find_all(True):
        style = tag.get("style", "")
        for m in re.findall(r'url\(["\']?(https?://[^"\')\s]+)["\']?\)', style):
            agregar(m)

    return urls


# ══════════════════════════════════════════════════════════
#  DESCARGA DE UNA IMAGEN
# ══════════════════════════════════════════════════════════

def descargar(s, url_img: str, ruta: Path) -> bool:
    try:
        r = s.get(url_img, timeout=30, stream=True)
        r.raise_for_status()
        ct = r.headers.get("Content-Type", "image/jpeg")
        if not es_imagen_ct(url_img, ct):
            return False
        # Corregir extensión según Content-Type si hace falta
        ext = ext_de(url_img, ct)
        if ruta.suffix.lower() not in EXT_IMAGEN:
            ruta = ruta.with_suffix(ext)
        if ruta.exists():
            return True
        with open(ruta, "wb") as f:
            for chunk in r.iter_content(16_384):
                f.write(chunk)
        return True
    except Exception as e:
        print(f"      ✗ {url_img[:75]}  →  {e}")
        return False


# ══════════════════════════════════════════════════════════
#  PROCESAR UN POST
# ══════════════════════════════════════════════════════════

def procesar_post(s, url_post: str, titulo: str,
                  prefijo: str, carpeta: Path, log: list) -> int:
    print(f"\n    📄 {titulo[:65]}")
    print(f"       {url_post}")

    try:
        r = s.get(url_post, timeout=15)
        r.raise_for_status()
    except Exception as e:
        print(f"       ✗ No se pudo acceder: {e}")
        return 0

    soup = BeautifulSoup(r.text, "html.parser")
    imgs = extraer_imagenes(soup, url_post)

    if not imgs:
        print("       (sin imágenes)")
        return 0

    print(f"       → {len(imgs)} imágenes encontradas")
    slug_post = slug(url_post)
    ok = 0

    for url_img in imgs:
        nom = nombre_original(url_img)
        nombre_final = re.sub(r"[^\w\-\.]", "_", f"{prefijo}__{slug_post}__{nom}")
        ruta = carpeta / nombre_final

        if ruta.exists():
            print(f"      ↷ ya existe: {nombre_final[:70]}")
            log.append(ruta.name)
            ok += 1
            continue

        print(f"      ↓ {url_img[:75]}")
        if descargar(s, url_img, ruta):
            log.append(ruta.name)
            ok += 1
        time.sleep(DELAY_IMAGENES)

    print(f"       ✓ {ok}/{len(imgs)} descargadas")
    return ok


# ══════════════════════════════════════════════════════════
#  RECOLECTAR POSTS — NOBLOATNEWS (Jekyll)
# ══════════════════════════════════════════════════════════

def posts_nobloatnews(s) -> list:
    url_base = "https://nobloatnews.github.io"
    print(f"\n  Leyendo índice: {url_base}/")
    try:
        r = s.get(f"{url_base}/", timeout=15)
        r.raise_for_status()
    except Exception as e:
        print(f"  ✗ Error: {e}")
        return []

    soup = BeautifulSoup(r.text, "html.parser")
    posts, vistos = [], set()

    for a in soup.find_all("a", href=True):
        url = urllib.parse.urljoin(url_base, a["href"].strip())
        if "/posts/" in url and url not in vistos:
            vistos.add(url)
            posts.append({"url": url, "titulo": a.get_text(strip=True)})

    print(f"  → {len(posts)} posts encontrados")
    return posts


# ══════════════════════════════════════════════════════════
#  RECOLECTAR POSTS — IMLAUERNEWS (Hugo, paginado)
# ══════════════════════════════════════════════════════════

def posts_imlauernews(s) -> list:
    url_base = "https://imlauernews.github.io"
    posts, vistos = [], set()

    # Página 1 = /, luego /page/2/, /page/3/, ...
    paginas = [f"{url_base}/"] + [f"{url_base}/page/{n}/" for n in range(2, 30)]

    for url_pag in paginas:
        print(f"  Leyendo: {url_pag}")
        try:
            r = s.get(url_pag, timeout=15)
            if r.status_code == 404:
                print("    → fin de paginación")
                break
            r.raise_for_status()
        except Exception as e:
            print(f"  ✗ Error: {e}")
            break

        soup = BeautifulSoup(r.text, "html.parser")
        nuevos = 0

        for a in soup.find_all("a", href=True):
            url = urllib.parse.urljoin(url_base, a["href"].strip())
            # Hugo: /post/YYYY/MM/DD/slug/ o /post/1/01/01/slug/
            if re.search(r"/post/\d+/\d+/\d+/[^/]+", url) and url not in vistos:
                vistos.add(url)
                posts.append({"url": url, "titulo": a.get_text(strip=True)})
                nuevos += 1

        print(f"    → {nuevos} posts nuevos (total: {len(posts)})")
        if nuevos == 0:
            break
        time.sleep(0.5)

    return posts


# ══════════════════════════════════════════════════════════
#  MAIN
# ══════════════════════════════════════════════════════════

def main():
    print("╔══════════════════════════════════════════════════════╗")
    print("║   Descargador de imágenes — nobloat + imlauer        ║")
    print("╠══════════════════════════════════════════════════════╣")
    print(f"║   Carpeta destino: fotos/                            ║")
    print("╚══════════════════════════════════════════════════════╝")

    CARPETA_SALIDA.mkdir(exist_ok=True)
    s = nueva_sesion()
    log_global = []
    total = 0

    # ── nobloatnews ──
    print("\n" + "═"*54)
    print("  SITIO 1: nobloatnews.github.io")
    print("═"*54)
    posts = posts_nobloatnews(s)
    for i, post in enumerate(posts, 1):
        print(f"\n  [{i}/{len(posts)}]", end="")
        total += procesar_post(s, post["url"], post["titulo"],
                               "nobloatnews", CARPETA_SALIDA, log_global)
        time.sleep(DELAY_POSTS)

    # ── imlauernews ──
    print("\n\n" + "═"*54)
    print("  SITIO 2: imlauernews.github.io")
    print("═"*54)
    posts = posts_imlauernews(s)
    for i, post in enumerate(posts, 1):
        print(f"\n  [{i}/{len(posts)}]", end="")
        total += procesar_post(s, post["url"], post["titulo"],
                               "imlauernews", CARPETA_SALIDA, log_global)
        time.sleep(DELAY_POSTS)

    # ── índice de texto ──
    with open(CARPETA_SALIDA / "index.txt", "w", encoding="utf-8") as f:
        f.write(f"Total: {total} imágenes\n\n")
        for nom in sorted(log_global):
            f.write(nom + "\n")

    print(f"\n\n{''*54}")
    print(f"  🎉  {total} imágenes descargadas en: fotos/")
    print(f"  📋  Listado en: fotos/index.txt")
    print(""*54)


if __name__ == "__main__":
    try:
        import requests
        from bs4 import BeautifulSoup
    except ImportError:
        print("Instalando dependencias...")
        os.system("pip install requests beautifulsoup4")
        import requests
        from bs4 import BeautifulSoup

    main()