Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TSyR5Zw5eGj6oPTRVjvP1b
361 lines
16 KiB
Python
361 lines
16 KiB
Python
"""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
|
|
from collections import Counter
|
|
import shutil
|
|
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
|
|
FEED_VIDEOS = os.environ.get('FEED_VIDEOS_URL', f'https://www.youtube.com/feeds/videos.xml?playlist_id=UULF{CHANNEL[2:]}')
|
|
FEED_SHORTS = os.environ.get('FEED_SHORTS_URL', f'https://www.youtube.com/feeds/videos.xml?playlist_id=UUSH{CHANNEL[2:]}')
|
|
FEED_POP_VIDEOS = os.environ.get('FEED_POP_VIDEOS_URL', f'https://www.youtube.com/feeds/videos.xml?playlist_id=UULP{CHANNEL[2:]}')
|
|
FEED_POP_SHORTS = os.environ.get('FEED_POP_SHORTS_URL', f'https://www.youtube.com/feeds/videos.xml?playlist_id=UUPS{CHANNEL[2:]}')
|
|
POP_VIDEOS_SHOWN = 4
|
|
POP_SHORTS_SHOWN = 6
|
|
VIDEOS_SHOWN = 8 # además del destacado
|
|
SHORTS_SHOWN = 12
|
|
NS = {'a': 'http://www.w3.org/2005/Atom', 'y': 'http://www.youtube.com/xml/schemas/2015',
|
|
'm': 'http://search.yahoo.com/mrss/'}
|
|
MESES = ['ene', 'feb', 'mar', 'abr', 'may', 'jun', 'jul', 'ago', 'sep', 'oct', 'nov', 'dic']
|
|
YT = 'https://www.youtube.com/@andaquenosabesna'
|
|
TT = 'https://www.tiktok.com/@andaquenosabesna'
|
|
IG = 'https://www.instagram.com/andaquenosabesna'
|
|
MAIL = 'andaquenosabesnada@gmail.com'
|
|
|
|
|
|
def fetch(url, timeout=20):
|
|
req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0 (aqnsn-site)'})
|
|
return urllib.request.urlopen(req, timeout=timeout).read()
|
|
|
|
|
|
def read_feed(url):
|
|
root = ET.fromstring(fetch(url))
|
|
out = []
|
|
for e in root.findall('a:entry', NS)[:15]:
|
|
d = e.find('a:published', NS).text[:10].split('-')
|
|
out.append({
|
|
'id': e.find('y:videoId', NS).text,
|
|
'title': e.find('a:title', NS).text,
|
|
'date': f'{int(d[2])} {MESES[int(d[1]) - 1]} {d[0]}',
|
|
'desc': e.find('m:group/m:description', NS).text or '',
|
|
'views': int((e.find('m:group/m:community/m:statistics', NS).attrib.get('views') or 0)
|
|
if e.find('m:group/m:community/m:statistics', NS) is not None else 0),
|
|
})
|
|
return out
|
|
|
|
|
|
try:
|
|
videos, shorts = read_feed(FEED_VIDEOS), read_feed(FEED_SHORTS)
|
|
pop_videos, pop_shorts = read_feed(FEED_POP_VIDEOS), read_feed(FEED_POP_SHORTS)
|
|
except Exception as exc: # sin red o feed caído: se conserva la web anterior
|
|
print('feed no disponible, se mantiene index.html:', exc, file=sys.stderr)
|
|
sys.exit(0)
|
|
videos, shorts = videos[:VIDEOS_SHOWN + 1], shorts[:SHORTS_SHOWN]
|
|
# la lista de populares de YouTube no viene ordenada por vistas: se ordena aquí
|
|
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]
|
|
|
|
|
|
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)
|
|
ok = []
|
|
for v in vids:
|
|
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
|
|
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)
|
|
return ok
|
|
|
|
|
|
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]
|
|
pop_videos = [v for v in pop_videos if v['id'] in okids]
|
|
pop_shorts = [v for v in pop_shorts if v['id'] in okids]
|
|
if not videos:
|
|
sys.exit(0)
|
|
|
|
|
|
# --- Temas: hashtags de las descripciones de YouTube -------------------------------------------
|
|
STOP = {'podcast', 'podcastclips', 'andaquenosabesna', 'andaquenosabesna', 'aqnsn', 'shorts', 'short', 'fyp',
|
|
'viral', 'parati', 'galaxoide', 'youtube', 'video', 'clips'}
|
|
LABELS = { # clave normalizada -> texto a mostrar (sinónimos incluidos)
|
|
'egipto': 'Egipto', 'egiptomania': 'Egipto', 'educacion': 'Educación', 'arabe': 'Lengua árabe',
|
|
'lenguas': 'Lengua árabe', 'religion': 'Religiones', 'religiones': 'Religiones', 'magia': 'Magia',
|
|
'burocracia': 'Burocracia', 'poligamia': 'Poligamia', 'economia': 'Economía',
|
|
'gastronomia': 'Gastronomía', 'comidaegipcia': 'Comida egipcia', 'robotica': 'Robótica',
|
|
'tecnologia': 'Tecnología', 'agricultura': 'Agricultura', 'curiosidades': 'Curiosidades',
|
|
'antiguoegipto': 'Egipto', 'egiptoantiguo': 'Egipto', 'cairo': 'El Cairo', 'elcairo': 'El Cairo',
|
|
'arqueologia': 'Arqueología', 'historia': 'Historia',
|
|
}
|
|
PINNED = ['Cultura granaína', 'Entrevistas'] # siempre presentes
|
|
MAX_TOPICS = 12
|
|
|
|
|
|
def norm(tag):
|
|
t = unicodedata.normalize('NFKD', tag.lower())
|
|
return ''.join(c for c in t if not unicodedata.combining(c))
|
|
|
|
|
|
def topics(vids):
|
|
count = Counter()
|
|
for v in vids:
|
|
seen = set()
|
|
for tag in re.findall(r'#(\w+)', v['desc']):
|
|
k = norm(tag)
|
|
if k in STOP:
|
|
continue
|
|
label = LABELS.get(k) or (tag[0].upper() + tag[1:])
|
|
if label in seen: # sinónimos del mismo vídeo cuentan una sola vez
|
|
continue
|
|
seen.add(label)
|
|
count[label] += 1
|
|
ranked = [t for t, _ in sorted(count.items(), key=lambda kv: (-kv[1], kv[0]))]
|
|
out = [t for t in ranked if t not in PINNED][:max(MAX_TOPICS - len(PINNED), 0)]
|
|
return out + PINNED
|
|
|
|
|
|
chips = ''.join(f'<li>{html.escape(t)}</li>' for t in topics(videos + shorts))
|
|
|
|
esc = html.escape
|
|
featured, rest = videos[0], videos[1:] # el destacado es el último vídeo (no Short)
|
|
|
|
|
|
def url(v):
|
|
return f"https://www.youtube.com/watch?v={v['id']}"
|
|
|
|
|
|
EYE = ('<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true"><path fill="currentColor" d="M12 5C6.5 5 2.3 8.6 1 12c1.3 3.4 5.5 7 11 7s9.7-3.6 11-7c-1.3-3.4-5.5-7-11-7zm0 11.5A4.5 4.5 0 1 1 12 7.5a4.5 4.5 0 0 1 0 9zm0-7a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5z"/></svg>')
|
|
|
|
|
|
def badge(v):
|
|
if not v['views']:
|
|
return ''
|
|
n = f"{v['views']:,}".replace(',', '.')
|
|
return f'<span class="views" title="{n} vistas">{EYE}<b>{n}</b></span>'
|
|
|
|
|
|
def vistas(v):
|
|
return f"{v['views']:,}".replace(',', '.') + ' vistas' if v['views'] else ''
|
|
|
|
|
|
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['img']}" alt="" loading="lazy" width="640" height="360"><span class="play" aria-hidden="true"></span>{badge(v)}</span>
|
|
<span class="meta"><time>{esc(v['date'])}</time><strong>{esc(v['title'])}</strong></span>
|
|
</a>'''
|
|
|
|
|
|
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['img']}" alt="" loading="lazy" width="540" height="960"><span class="play" aria-hidden="true"></span>{badge(v)}</span>
|
|
<span class="meta"><strong>{esc(v['title'])}</strong></span>
|
|
</a>'''
|
|
|
|
|
|
cards = ''.join(card(v, i) for i, v in enumerate(rest))
|
|
scards = ''.join(short_card(v, i) for i, v in enumerate(shorts))
|
|
pcards = ''.join(card(v, i, True) for i, v in enumerate(pop_videos))
|
|
pscards = ''.join(short_card(v, i, True) for i, v in enumerate(pop_shorts))
|
|
|
|
page = f'''<!doctype html>
|
|
<html lang="es">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Anda Que No Sabes Ná · El programa nocturno 100% granaíno</title>
|
|
<meta name="description" content="Anda Que No Sabes Ná: el programa nocturno 100% granaíno. Conversaciones sobre educación, Egipto, lenguas, religiones y mucho más.">
|
|
<meta name="theme-color" content="#06080d">
|
|
<meta property="og:title" content="Anda Que No Sabes Ná">
|
|
<meta property="og:description" content="El programa nocturno 100% granaíno.">
|
|
<meta property="og:image" content="https://andaquenosabesna.com/img/banner.jpg">
|
|
<meta property="og:type" content="website">
|
|
<link rel="icon" href="img/avatar.jpg">
|
|
<link rel="stylesheet" href="styles.css">
|
|
</head>
|
|
<body>
|
|
<a class="skip" href="#programas">Saltar al contenido</a>
|
|
|
|
<header class="top">
|
|
<a class="brand" href="/" aria-label="Anda Que No Sabes Ná, inicio"><img src="img/avatar.jpg" alt="" width="40" height="40"><span>Anda que no sabes ná</span></a>
|
|
<nav aria-label="Principal">
|
|
<a href="#programas">Vídeos</a>
|
|
<a href="#programa">El programa</a>
|
|
<a href="#quienes">Quiénes somos</a>
|
|
<a href="#seguir">Seguir</a>
|
|
<a class="navcta" href="contacto.html">Contacto</a>
|
|
</nav>
|
|
</header>
|
|
|
|
<main>
|
|
<section class="hero">
|
|
<div class="hero-bg" aria-hidden="true"></div>
|
|
<div class="hero-inner">
|
|
<img class="neon" src="img/avatar.jpg" alt="Anda Que No Sabes Ná" width="220" height="220">
|
|
<p class="kicker">Con invitados que están aquí y otros que están pa allá</p>
|
|
<h1>El programa nocturno<br><em>100% granaíno</em></h1>
|
|
<p class="lead">Charlas sobre lo que sabemos, lo que no, y lo que creemos saber.</p>
|
|
<div class="cta">
|
|
<a class="btn primary" href="{YT}" target="_blank" rel="noopener">Ver en YouTube</a>
|
|
<a class="btn" href="#programas">Últimos vídeos</a>
|
|
</div>
|
|
</div>
|
|
<a class="scroll" href="#destacado" aria-label="Bajar"><span></span></a>
|
|
</section>
|
|
|
|
<section id="destacado" class="featured wrap reveal">
|
|
<a class="feat" href="{url(featured)}" target="_blank" rel="noopener">
|
|
<span class="thumb"><img src="img/{featured['img']}" alt="" width="1280" height="720"><span class="play big" aria-hidden="true"></span>{badge(featured)}</span>
|
|
<span class="feat-text">
|
|
<span class="tag">Último vídeo</span>
|
|
<strong>{esc(featured['title'])}</strong>
|
|
<time>{esc(featured['date'])}</time>
|
|
<span class="go">Ver el programa <i aria-hidden="true">→</i></span>
|
|
</span>
|
|
</a>
|
|
</section>
|
|
|
|
<section id="programas" class="wrap">
|
|
<div class="sec-head reveal">
|
|
<h2>Últimos <em>vídeos</em></h2>
|
|
<a class="more" href="{YT}/videos" target="_blank" rel="noopener">Todos los vídeos →</a>
|
|
</div>
|
|
<div class="grid">{cards}
|
|
</div>
|
|
</section>
|
|
|
|
<section id="shorts" class="wrap">
|
|
<div class="sec-head reveal">
|
|
<h2>Últimos <em>Shorts</em></h2>
|
|
<a class="more" href="{YT}/shorts" target="_blank" rel="noopener">Todos los Shorts →</a>
|
|
</div>
|
|
<div class="shorts">{scards}
|
|
</div>
|
|
</section>
|
|
|
|
<section id="populares" class="wrap">
|
|
<div class="sec-head reveal">
|
|
<h2>Lo más <em>visto</em></h2>
|
|
<a class="more" href="{YT}/videos?view=0&sort=p" target="_blank" rel="noopener">Ver populares en YouTube →</a>
|
|
</div>
|
|
<h3 class="sub reveal">Vídeos</h3>
|
|
<div class="grid">{pcards}
|
|
</div>
|
|
<h3 class="sub reveal">Shorts</h3>
|
|
<div class="shorts">{pscards}
|
|
</div>
|
|
</section>
|
|
|
|
<section id="programa" class="about wrap reveal">
|
|
<h2>De qué va</h2>
|
|
<p>Un programa de noche, con tertulia y sin prisa. Cada semana entra gente distinta, con su oficio y su manía, y se habla de lo que sale: de cómo se educa a un adolescente, de la vida en las calles de El Cairo, de si el árabe es tan antiguo como dicen, de qué tienen en común las religiones o de cómo se hace un truco de cartas.</p>
|
|
<ul class="chips" aria-label="Temas">{chips}</ul>
|
|
</section>
|
|
|
|
<section id="quienes" class="who wrap reveal">
|
|
<h2>Quiénes somos</h2>
|
|
<p>Somos Álex y Juan, las dos personas detrás de Anda Que No Sabes Ná: un programa hecho en Granada, de noche, con ganas de aprender de quien se sienta a charlar.</p>
|
|
<div class="team">
|
|
<div class="person"><b>Álex</b><em>Da forma a las ideas</em><span>Arquitecto de formación y líder de equipos de ingeniería. Es quien desarrolla y automatiza todo para que el podcast funcione: pone el orden y el guion para que todo salga rodado. Y anda metido en cómo usar la IA en el día a día.</span></div>
|
|
<div class="person"><b>Juan</b><em>Captura la realidad</em><span>Consultor en metodología BIM y escáner láser 3D. Juan lleva años escaneando entornos y edificios, capturando cada detalle. Pero hay algo que se le da casi tan bien como escanear un lugar: «escanear» a las personas. Observador, curioso y con buen ojo para leer gestos, actitudes y personalidades.</span></div>
|
|
</div>
|
|
</section>
|
|
|
|
<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>
|
|
<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>
|
|
|
|
<section id="seguir" class="follow wrap reveal">
|
|
<h2>Síguenos</h2>
|
|
<div class="socials">
|
|
<a href="{YT}" target="_blank" rel="noopener"><b>YouTube</b><span>@andaquenosabesna</span></a>
|
|
<a href="{TT}" target="_blank" rel="noopener"><b>TikTok</b><span>@andaquenosabesna</span></a>
|
|
<a href="{IG}" target="_blank" rel="noopener"><b>Instagram</b><span>@andaquenosabesna</span></a>
|
|
</div>
|
|
</section>
|
|
</main>
|
|
|
|
<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><a href="patrocinadores.html">Patrocinadores</a></p>
|
|
</div>
|
|
</footer>
|
|
|
|
<script>
|
|
(function () {{
|
|
var els = document.querySelectorAll('.reveal');
|
|
if (!('IntersectionObserver' in window) || matchMedia('(prefers-reduced-motion: reduce)').matches) {{
|
|
els.forEach(function (e) {{ e.classList.add('in'); }}); return;
|
|
}}
|
|
var io = new IntersectionObserver(function (items) {{
|
|
items.forEach(function (i) {{ if (i.isIntersecting) {{ i.target.classList.add('in'); io.unobserve(i.target); }} }});
|
|
}}, {{ threshold: 0.12 }});
|
|
els.forEach(function (e) {{ io.observe(e); }});
|
|
}})();
|
|
</script>
|
|
</body>
|
|
</html>
|
|
'''
|
|
tmp = os.path.join(OUT, 'index.html.tmp')
|
|
open(tmp, 'w', encoding='utf-8').write(page)
|
|
shutil.move(tmp, os.path.join(OUT, 'index.html'))
|
|
print('index.html', len(page), 'bytes;', len(videos), 'videos; destacado:', featured['id'])
|