Files
andaquenosabesna/build.py
T

315 lines
14 KiB
Python

"""Genera index.html a partir del feed RSS del canal (se ejecuta cada 15 min en el contenedor)."""
import html
import os
import re
import unicodedata
from collections import Counter
import shutil
import sys
import urllib.request
import xml.etree.ElementTree as ET
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]
def sync_thumbs(vids):
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)
continue
keep.add(v['id'] + '.jpg')
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)
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',
}
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']}"
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['id']}.jpg" alt="" loading="lazy" width="480" 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>'''
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="meta"><strong>{esc(v['title'])}</strong>{'<time>' + vistas(v) + '</time>' if show_views and v['views'] else ''}</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.thax.es/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="#shorts">Shorts</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['id']}.jpg" alt="" width="480" height="360"><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>
<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&amp;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 Alejandro 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>Alejandro</b><em>Da forma a las ideas</em><span>Arquitecto de formación y líder de equipos de ingeniería. Construye productos digitales y herramientas para equipos técnicos, 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. Ayuda a las empresas de la arquitectura, la ingeniería y la construcción a dar el salto digital con mejor calidad y menos costes.</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>
<a class="btn primary" href="contacto.html">Escríbenos</a>
</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></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'])