"""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 = 'contacto@andaquenosabesna.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íno
    Anda que no sabes ná
    Anda 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.

    Últimos vídeos

    Todos los vídeos →
    {cards}

    Últimos Shorts

    Todos los Shorts →
    {scards}

    Vídeos

    {pcards}

    Shorts

    {pscards}

    De qué va

    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.

    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.

    ¿Quieres venir al programa?

    Cuéntanos quién eres y de qué te gustaría hablar.

    Síguenos

    ''' 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'])