"""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'
{html.escape(t)}
' 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 = ('')
def badge(v):
if not v['views']:
return ''
n = f"{v['views']:,}".replace(',', '.')
return f'{EYE}{n}'
def vistas(v):
return f"{v['views']:,}".replace(',', '.') + ' vistas' if v['views'] else ''
def card(v, n, show_views=False):
return f'''
{badge(v)}{esc(v['title'])}'''
def short_card(v, n, show_views=False):
return f'''
{badge(v)}{esc(v['title'])}'''
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'''
Anda Que No Sabes Ná · El programa nocturno 100% granaínoSaltar al contenidoAnda que no sabes ná
Con invitados que están aquí y otros que están pa allá
El programa nocturno 100% granaíno
Charlas sobre lo que sabemos, lo que no, y lo que creemos saber.
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.
{chips}
Quiénes somos
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.
ÁlexDa forma a las ideasArquitecto 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.
JuanCaptura la realidadConsultor 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.