Miniaturas HD, formulario de patrocinadores y sección Lo más visto

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSyR5Zw5eGj6oPTRVjvP1b
This commit is contained in:
Alex
2026-10-20 16:51:36 +02:00
co-authored by Claude Sonnet 5.5
parent e8b1f765c8
commit c8afac0f6d
7 changed files with 207 additions and 93 deletions
+53 -18
View File
@@ -1,5 +1,6 @@
"""Genera index.html a partir del feed RSS del canal (se ejecuta cada 15 min en el contenedor)."""
import html
import io
import os
import re
import unicodedata
@@ -9,6 +10,8 @@ import sys
import urllib.request
import xml.etree.ElementTree as ET
from PIL import Image
OUT = os.environ.get('SITE_DIR', 'public')
CHANNEL = 'UC8gVtzB3Lqm7nnfMhMvXGxQ'
# YouTube separa cada tipo en su propia lista: UULF = solo vídeos, UUSH = solo Shorts
@@ -62,28 +65,60 @@ pop_videos = sorted(pop_videos, key=lambda v: -v['views'])[:POP_VIDEOS_SHOWN]
pop_shorts = sorted(pop_shorts, key=lambda v: -v['views'])[:POP_SHORTS_SHOWN]
def sync_thumbs(vids):
KEEP = {'avatar.jpg', 'banner.jpg'}
# (nombre en YouTube, ancho máx. al guardar) por orden de preferencia
SOURCES = {
'video': [('maxresdefault', 1280), ('sddefault', 960), ('hqdefault', 960)],
'short': [('oar2', 540), ('hqdefault', 480)], # oar2 = miniatura vertical 1080x1920
}
def grab(vid, kind):
"""Devuelve (bytes JPEG recomprimido, es_alta_calidad) con la mejor miniatura disponible."""
for i, (name, width) in enumerate(SOURCES[kind]):
try:
im = Image.open(io.BytesIO(fetch(f'https://i.ytimg.com/vi/{vid}/{name}.jpg'))).convert('RGB')
except Exception:
continue # 404 u otro fallo: se prueba la siguiente
if im.width < 200: # YouTube devuelve un gif de 120x90 cuando no existe
continue
if im.width > width:
im = im.resize((width, round(im.height * width / im.width)), Image.LANCZOS)
buf = io.BytesIO()
im.save(buf, 'JPEG', quality=82, optimize=True, progressive=True)
return buf.getvalue(), i == 0
return None, False
def sync_thumbs(vids, kind):
"""Descarga la miniatura HD de cada vídeo (…-hd.jpg). Si aún no existe la HD, usa una
de respaldo (…-lo.jpg) y en la siguiente ejecución vuelve a intentar la HD."""
img_dir = os.path.join(OUT, 'img')
os.makedirs(img_dir, exist_ok=True)
keep = {'avatar.jpg', 'banner.jpg'}
ok = []
for v in vids:
path = os.path.join(img_dir, v['id'] + '.jpg')
if not os.path.exists(path):
try:
open(path, 'wb').write(fetch(f"https://i.ytimg.com/vi/{v['id']}/hqdefault.jpg"))
except Exception as exc:
print('miniatura no descargada', v['id'], exc, file=sys.stderr)
hd, lo = f"{v['id']}-hd.jpg", f"{v['id']}-lo.jpg"
if not os.path.exists(os.path.join(img_dir, hd)):
data, is_hd = grab(v['id'], kind)
if data is None:
print('miniatura no descargada', v['id'], file=sys.stderr)
continue
keep.add(v['id'] + '.jpg')
open(os.path.join(img_dir, hd if is_hd else lo), 'wb').write(data)
v['img'] = hd if os.path.exists(os.path.join(img_dir, hd)) else lo
KEEP.add(v['img'])
ok.append(v)
for f in os.listdir(img_dir):
if f not in keep:
os.remove(os.path.join(img_dir, f))
return ok
ok = sync_thumbs(videos + shorts + pop_videos + pop_shorts)
def prune_thumbs():
img_dir = os.path.join(OUT, 'img')
for f in os.listdir(img_dir):
if f not in KEEP:
os.remove(os.path.join(img_dir, f))
ok = sync_thumbs(videos + pop_videos, 'video') + sync_thumbs(shorts + pop_shorts, 'short')
prune_thumbs()
okids = {v['id'] for v in ok}
videos = [v for v in videos if v['id'] in okids]
shorts = [v for v in shorts if v['id'] in okids]
@@ -147,7 +182,7 @@ def vistas(v):
def card(v, n, show_views=False):
return f'''
<a class="card reveal" style="--d:{(n % 3) * 70}ms" href="{url(v)}" target="_blank" rel="noopener">
<span class="thumb"><img src="img/{v['id']}.jpg" alt="" loading="lazy" width="480" height="360"><span class="play" aria-hidden="true"></span></span>
<span class="thumb"><img src="img/{v['img']}" alt="" loading="lazy" width="640" height="360"><span class="play" aria-hidden="true"></span></span>
<span class="meta"><time>{esc(v['date'])}{' · ' + vistas(v) if show_views and v['views'] else ''}</time><strong>{esc(v['title'])}</strong></span>
</a>'''
@@ -155,7 +190,7 @@ def card(v, n, show_views=False):
def short_card(v, n, show_views=False):
return f'''
<a class="scard reveal" style="--d:{(n % 6) * 50}ms" href="https://www.youtube.com/shorts/{v['id']}" target="_blank" rel="noopener">
<span class="thumb"><img src="img/{v['id']}.jpg" alt="" loading="lazy" width="480" height="360"><span class="play" aria-hidden="true"></span></span>
<span class="thumb"><img src="img/{v['img']}" alt="" loading="lazy" width="540" height="960"><span class="play" aria-hidden="true"></span></span>
<span class="meta"><strong>{esc(v['title'])}</strong>{'<time>' + vistas(v) + '</time>' if show_views and v['views'] else ''}</span>
</a>'''
@@ -213,7 +248,7 @@ page = f'''<!doctype html>
<section id="destacado" class="featured wrap reveal">
<a class="feat" href="{url(featured)}" target="_blank" rel="noopener">
<span class="thumb"><img src="img/{featured['id']}.jpg" alt="" width="480" height="360"><span class="play big" aria-hidden="true"></span></span>
<span class="thumb"><img src="img/{featured['img']}" alt="" width="1280" height="720"><span class="play big" aria-hidden="true"></span></span>
<span class="feat-text">
<span class="tag">Último vídeo</span>
<strong>{esc(featured['title'])}</strong>
@@ -272,7 +307,7 @@ page = f'''<!doctype html>
<section class="wrap reveal" aria-labelledby="venir">
<div class="invite">
<div><h2 id="venir">¿Quieres venir al programa?</h2><p>Cuéntanos quién eres y de qué te gustaría hablar.</p></div>
<a class="btn primary" href="contacto.html">Escríbenos</a>
<div class="cta"><a class="btn primary" href="contacto.html">Escríbenos</a><a class="btn" href="patrocinadores.html">Quiero anunciarme</a></div>
</div>
</section>
@@ -289,7 +324,7 @@ page = f'''<!doctype html>
<footer>
<div class="wrap foot">
<p>© 2026 Anda Que No Sabes Ná · Granada</p>
<p class="links"><a href="privacidad.html">Política de privacidad</a><a href="terminos.html">Condiciones del servicio</a><a href="contacto.html">Contacto</a></p>
<p class="links"><a href="privacidad.html">Política de privacidad</a><a href="terminos.html">Condiciones del servicio</a><a href="contacto.html">Contacto</a><a href="patrocinadores.html">Patrocinadores</a></p>
</div>
</footer>