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 corregidocarousel.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
/post/ (sin s) y recorre paginación automática hasta /page/N/ hasta que no haya posts nuevos<a> envuelve un <img>, descarga desde el href (versión s4160 full-res), no desde el src (miniatura s320)blogger_full_res() que convierte cualquier URL s320 → s4160 por si queda algunafotos/, un solo directorio, con nombres nobloatnews__slug__archivo.jpg o imlauernews__slug__archivo.jpgfotos/index.txt con el listado completogenerar_carousel.py
fotos/ y genera fotos/carousel.html automáticamente<input type="radio"> + <label> para las flechas y miniaturas#!/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 · imlauernews · '
+ str(n) + " imá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) + '">‹</label>')
lines.append(' <label class="arrow next" for="s' + str(next_id) + '">›</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()
#!/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()