Files
andaquenosabesna/build.py
T

243 lines
9.7 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
from PIL import Image, ImageStat
OUT = os.environ.get('SITE_DIR', 'public')
FEED = os.environ.get('FEED_URL', 'https://www.youtube.com/feeds/videos.xml?channel_id=UC8gVtzB3Lqm7nnfMhMvXGxQ')
MAX_VIDEOS = 15
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()
try:
root = ET.fromstring(fetch(FEED))
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 = []
for e in root.findall('a:entry', NS)[:MAX_VIDEOS]:
d = e.find('a:published', NS).text[:10].split('-')
videos.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 '',
})
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
videos = sync_thumbs(videos)
if not videos:
sys.exit(0)
def is_short(vid):
"""Los Shorts llevan barras laterales desenfocadas: poca textura en los laterales."""
im = Image.open(os.path.join(OUT, 'img', vid + '.jpg')).convert('L')
w, h = im.size
return ImageStat.Stat(im.crop((0, 0, int(w * .18), h))).stddev[0] < 25
# --- 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))
esc = html.escape
featured, rest = videos[0], videos[1:] # el feed viene del más nuevo al más antiguo
def url(v):
return f"https://www.youtube.com/watch?v={v['id']}"
def card(v, n):
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"{' class="short"' if is_short(v['id']) else ''}><span class="play" aria-hidden="true"></span></span>
<span class="meta"><time>{esc(v['date'])}</time><strong>{esc(v['title'])}</strong></span>
</a>'''
cards = ''.join(card(v, i) for i, v in enumerate(rest))
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">Programas</a>
<a href="#programa">El programa</a>
<a href="#seguir">Seguir</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">Directo desde Granada, de noche</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 programas</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="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="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="mailto:{MAIL}">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'])