Compare commits

...
51 Commits
Author SHA1 Message Date
alexandrump 7191591648 fix: eliminar make_nuevo_invitado.py (auth ahora server-side) 2026-10-29 21:14:28 +00:00
alexandrump b2c45e821c fix: eliminar COPY make_nuevo_invitado.py 2026-10-29 21:14:27 +00:00
alexandrump 1e497d5884 fix: eliminar make_nuevo_invitado.py del arranque 2026-10-29 21:14:26 +00:00
alexandrump 3761ae2f2f fix: auth server-side, sin hash en el HTML 2026-10-29 21:14:25 +00:00
alexandrump 3871bb6cd2 fix: auth server-side para nuevo-invitado; sin hash en HTML 2026-10-29 21:14:24 +00:00
alexandrump f8711dd464 feat: quitar imagen de fondo fondo.jpg del hero 2026-10-29 21:09:13 +00:00
alexandrump 1b63d808f5 feat: pagina generadora de enlaces de invitado protegida por contrasena 2026-10-29 20:53:39 +00:00
alexandrump fd1c49d469 feat: generador de nuevo-invitado.html con hash de contrasena 2026-10-29 20:53:37 +00:00
alexandrump 39bb53a9c0 feat: copia make_nuevo_invitado.py al contenedor 2026-10-29 20:53:35 +00:00
alexandrump a3f8096288 feat: llama a make_nuevo_invitado al arrancar 2026-10-29 20:53:33 +00:00
alexandrump ed6d140a35 feat: guest .check hereda estilos de radio/checkbox 2026-10-29 20:53:26 +00:00
alexandrump 1de9431883 feat: selector de dia en plantilla HTML 2026-10-29 20:53:23 +00:00
alexandrump 2876197ed9 feat: selector de dia en ficha de invitado 2026-10-29 20:53:22 +00:00
alexandrump 97fe393e15 feat: selector de dia en ficha de invitado 2026-10-29 20:53:21 +00:00
alexandrump e407e5c626 feat: soporte dias propuestos (param d) en formulario de invitados 2026-10-29 20:53:20 +00:00
AlexandClaude Sonnet 5.5 3222393019 Fondo del hero sin espejar y Juan a la izquierda / Álex a la derecha en Quiénes somos
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSyR5Zw5eGj6oPTRVjvP1b
2026-10-21 21:40:26 +02:00
AlexandClaude Sonnet 5.5 abc1e2028a Fondo del hero espejado
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSyR5Zw5eGj6oPTRVjvP1b
2026-10-21 21:36:34 +02:00
AlexandClaude Sonnet 5.5 911c7dd7e8 Fondo del hero muy oscurecido, como el anterior
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSyR5Zw5eGj6oPTRVjvP1b
2026-10-21 21:33:28 +02:00
AlexandClaude Sonnet 5.5 dac9449075 Nuevo fondo del hero: foto de Álex y Juan con el neón
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSyR5Zw5eGj6oPTRVjvP1b
2026-10-21 21:28:44 +02:00
AlexandClaude Sonnet 5.5 9835fe190f Aviso al enviar la ficha: revisar spam y añadir contacto@ a contactos
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSyR5Zw5eGj6oPTRVjvP1b
2026-10-21 00:57:20 +02:00
AlexandClaude Sonnet 5.5 4fec80c301 Correo público unificado en contacto@ y enlace inválido remite al formulario de contacto
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSyR5Zw5eGj6oPTRVjvP1b
2026-10-21 00:48:25 +02:00
AlexandClaude Sonnet 5.5 e6d43d0eb5 El enlace de Maps del correo viene de STUDIO_MAP (solo en el servidor)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSyR5Zw5eGj6oPTRVjvP1b
2026-10-21 00:33:28 +02:00
AlexandClaude Sonnet 5.5 584e1c3c72 El correo al invitado incluye enlace de Google Maps a la dirección
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSyR5Zw5eGj6oPTRVjvP1b
2026-10-21 00:30:23 +02:00
AlexandClaude Sonnet 5.5 0132492941 Correo al invitado con tono más fresco: "¡Grabamos el ...!"
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSyR5Zw5eGj6oPTRVjvP1b
2026-10-21 00:29:02 +02:00
AlexandClaude Sonnet 5.5 1a9c9e54ac El correo al invitado incluye la dirección del estudio desde STUDIO_ADDRESS (solo en el servidor)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSyR5Zw5eGj6oPTRVjvP1b
2026-10-21 00:27:16 +02:00
AlexandClaude Sonnet 5.5 28f167e074 Estilos del campo de teléfono igual que el resto de campos
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSyR5Zw5eGj6oPTRVjvP1b
2026-10-21 00:19:49 +02:00
AlexandClaude Sonnet 5.5 a25c7c256e Ficha de invitados: teléfono móvil obligatorio y aviso de envío por correo electrónico
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSyR5Zw5eGj6oPTRVjvP1b
2026-10-21 00:16:56 +02:00
AlexandClaude Sonnet 5.5 6c3a572cdd Quita "hora de Madrid" de la cita
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSyR5Zw5eGj6oPTRVjvP1b
2026-10-21 00:15:43 +02:00
AlexandClaude Sonnet 5.5 632dc9c139 Texto de la cita más cercano: recordatorio y aviso de la dirección del estudio
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSyR5Zw5eGj6oPTRVjvP1b
2026-10-21 00:14:01 +02:00
AlexandClaude Sonnet 5.5 f5f16ac55b Ficha por pasos, cita visible y recordatorio por correo al invitado
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSyR5Zw5eGj6oPTRVjvP1b
2026-10-21 00:09:57 +02:00
AlexandClaude Sonnet 5.5 caecb951d8 Ficha de invitados: asterisco junto a la pregunta y condiciones sin duplicar
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSyR5Zw5eGj6oPTRVjvP1b
2026-10-21 00:05:47 +02:00
AlexandClaude Sonnet 5.5 9f90c75f9c La fecha del enlace de invitado pasa a marca de tiempo Unix
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSyR5Zw5eGj6oPTRVjvP1b
2026-10-21 00:03:51 +02:00
AlexandClaude Sonnet 5.5 1b82ae24a7 Enlace de invitado = nombre + fecha de grabación (sin códigos)
Válido de 14 días antes a 10 días después de la fecha.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSyR5Zw5eGj6oPTRVjvP1b
2026-10-20 23:59:01 +02:00
AlexandClaude Sonnet 5.5 da8cc01fad Ficha privada de invitados y condiciones de participación
- /invitados.html (noindex) con enlace personal ?k=<código>; las respuestas
  llegan por correo desde contacto@ en lugar de Google Forms.
- server.py: /api/invitados/check y /api/invitados (códigos en INVITE_TOKENS).
- terminos.html: sección 5 con las condiciones de participación.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSyR5Zw5eGj6oPTRVjvP1b
2026-10-20 23:50:53 +02:00
AlexandClaude Sonnet 5.5 f0682d7de8 Archivo de verificación de TikTok for Developers
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSyR5Zw5eGj6oPTRVjvP1b
2026-10-20 22:03:35 +02:00
AlexandClaude Sonnet 5.5 fe1d0e6808 Une AntiguoEgipto con Egipto y normaliza Cairo/Arqueología en los temas
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSyR5Zw5eGj6oPTRVjvP1b
2026-10-20 21:49:03 +02:00
AlexandClaude Sonnet 5.5 5ef0578889 Alinea Quiénes somos y De qué va al ancho de las demás secciones
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSyR5Zw5eGj6oPTRVjvP1b
2026-10-20 21:45:28 +02:00
AlexandClaude Sonnet 5.5 691a3ccb22 Textos de Álex y Juan, menú sin Shorts y contador de vistas en las miniaturas
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSyR5Zw5eGj6oPTRVjvP1b
2026-10-20 21:43:25 +02:00
AlexandClaude Sonnet 5.5 bc87da15b1 Dominio principal andaquenosabesna.com; thax.es redirige al .com
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSyR5Zw5eGj6oPTRVjvP1b
2026-10-20 17:18:52 +02:00
AlexandClaude Sonnet 5.5 c8afac0f6d Miniaturas HD, formulario de patrocinadores y sección Lo más visto
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSyR5Zw5eGj6oPTRVjvP1b
2026-10-20 16:51:36 +02:00
AlexandClaude Sonnet 5.5 e8b1f765c8 Sección "Lo más visto": vídeos y Shorts populares con contador de vistas
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSyR5Zw5eGj6oPTRVjvP1b
2026-10-20 16:47:31 +02:00
AlexandClaude Sonnet 5.5 7b4c8a4d27 Nueva coletilla: invitados que están aquí y otros pa allá
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSyR5Zw5eGj6oPTRVjvP1b
2026-10-20 16:43:43 +02:00
AlexandClaude Sonnet 5.5 1b93a2e2aa Separa vídeos y Shorts con los feeds por tipo de YouTube; destacado = último vídeo
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSyR5Zw5eGj6oPTRVjvP1b
2026-10-20 16:38:18 +02:00
AlexandClaude Sonnet 5.5 7e1ae9841b Tarjetas de Alejandro y Juan con lema y descripción
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSyR5Zw5eGj6oPTRVjvP1b
2026-10-20 16:33:29 +02:00
AlexandClaude Sonnet 5.5 89bb7b4d34 Formulario de contacto (SMTP vía Poste), Quiénes somos e invitación a venir al programa
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSyR5Zw5eGj6oPTRVjvP1b
2026-10-20 16:04:00 +02:00
Alex 4be49b0c91 Quita "sin guion" del lema y fuerza el logo a círculo perfecto 2026-10-20 15:01:17 +02:00
Alex 5d53ec049b Sinónimos de un mismo vídeo cuentan una vez; lenguas -> Lengua árabe 2026-10-20 14:59:36 +02:00
Alex c8189269e6 Temas generados a partir de los hashtags de YouTube 2026-10-20 14:58:01 +02:00
Alex de95532a89 Incluye logo y fondo (img/avatar.jpg, img/banner.jpg) 2026-10-20 14:52:28 +02:00
Alex a636eca3bd Integra commit inicial del repo 2026-10-20 14:52:19 +02:00
Alex 84a4ea0ac3 Web inicial de Anda Que No Sabes Ná 2026-10-20 14:52:03 +02:00
23 changed files with 2361 additions and 2 deletions
+7
View File
@@ -0,0 +1,7 @@
/chan.html
/feed.xml
/thumb_*.jpg
/avatar.jpg
/banner.jpg
__pycache__/
public/index.html.tmp
+13
View File
@@ -0,0 +1,13 @@
FROM nginx:1.27-alpine
RUN apk add --no-cache python3 py3-pillow
COPY public/ /usr/share/nginx/html/
COPY build.py /app/build.py
COPY server.py /app/server.py
COPY guest_form.json /app/guest_form.json
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENV SITE_DIR=/usr/share/nginx/html
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=3s CMD wget -qO- http://127.0.0.1/ >/dev/null || exit 1
ENTRYPOINT ["/entrypoint.sh"]
+6 -2
View File
@@ -1,3 +1,7 @@
# andaquenosabesna
# andaquenosabesna.thax.es
Web de Anda Que No Sabes Ná (andaquenosabesna.thax.es)
Web estática de *Anda Que No Sabes Ná*. Un contenedor nginx regenera `index.html`
cada 15 min leyendo el feed RSS de YouTube (`build.py`) y cachea las miniaturas en `img/`.
- Local: `SITE_DIR=public python3 build.py && python3 -m http.server -d public 8000`
- Contenedor: `docker build -t aqnsn-site . && docker run -p 8080:80 aqnsn-site`
+360
View File
@@ -0,0 +1,360 @@
"""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', 'fondo.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&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 Á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>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 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>
</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'])
+6
View File
@@ -0,0 +1,6 @@
#!/bin/sh
# Primer build al arrancar y después cada 15 minutos; si el feed falla se conserva la web anterior.
python3 /app/build.py || true
python3 /app/server.py &
( while true; do sleep 900; python3 /app/build.py || true; done ) &
exec nginx -g 'daemon off;'
+183
View File
@@ -0,0 +1,183 @@
{
"title": "Anda Que No Sabes Ná Podcast",
"description": "Queremos conocerte un poco antes de darle al REC. No hace falta que prepares respuestas perfectas: esto nos sirve para encontrar buenas historias, preguntas interesantes y, sobre todo, temas sobre los que merezca la pena hablar.",
"items": [
{
"type": 11,
"title": "",
"desc": ""
},
{
"type": 8,
"title": "👤 Tú",
"desc": ""
},
{
"type": 0,
"title": "Nombre y cómo prefieres que te presentemos",
"desc": "",
"required": true,
"options": []
},
{
"type": 1,
"title": "¿A qué te dedicas actualmente?",
"desc": "",
"required": true,
"options": []
},
{
"type": 1,
"title": "¿Cuál es tu proyecto, empresa o actividad principal?",
"desc": "",
"required": false,
"options": []
},
{
"type": 0,
"title": "LinkedIn, web o cualquier enlace que nos ayude a conocerte mejor",
"desc": "",
"required": false,
"options": []
},
{
"type": 1,
"title": "Tus redes sociales que quieras que mencionemos o utilicemos para promocionar el episodio",
"desc": "",
"required": false,
"options": []
},
{
"type": 8,
"title": "🎙️ Para conocerte un poco mejor",
"desc": ""
},
{
"type": 1,
"title": "Si tuvieras que contarnos tu historia en dos minutos, ¿por dónde empezarías?",
"desc": "",
"required": true,
"options": []
},
{
"type": 1,
"title": "¿Qué tema podrías estar hablando durante horas sin aburrirte?",
"desc": "",
"required": true,
"options": []
},
{
"type": 1,
"title": "¿Hay alguna experiencia, proyecto, fracaso, éxito o anécdota que te parezca especialmente buena para contar en el podcast?",
"desc": "",
"required": true,
"options": []
},
{
"type": 1,
"title": "¿Qué idea, mito o tópico de tu sector te gustaría desmontar?",
"desc": "",
"required": false,
"options": []
},
{
"type": 8,
"title": "🔥 Vamos a meternos en harina",
"desc": "Contestar en esta sección es opcional"
},
{
"type": 1,
"title": "¿Hay alguna opinión tuya sobre tu sector que pueda generar debate?",
"desc": "",
"required": false,
"options": []
},
{
"type": 1,
"title": "¿Qué pregunta te hacen siempre y estás cansado/a de responder?",
"desc": "",
"required": false,
"options": []
},
{
"type": 1,
"title": "¿Hay algo que normalmente no se cuenta cuando se habla de tu profesión, sector o trayectoria y que crees que deberíamos contar?",
"desc": "",
"required": false,
"options": []
},
{
"type": 1,
"title": "Si pudiéramos conseguir que después de escucharte alguien cambiara de opinión sobre una cosa, ¿qué te gustaría que fuera?",
"desc": "",
"required": false,
"options": []
},
{
"type": 8,
"title": "🚧 Territorio delicado",
"desc": "No hace falta justificarlo. Queremos saber dónde están tus límites antes de empezar."
},
{
"type": 1,
"title": "¿Hay algún tema que prefieras que no toquemos?",
"desc": "",
"required": true,
"options": []
},
{
"type": 1,
"title": "¿Hay alguna persona, empresa, experiencia o asunto sobre el que prefieras que no hagamos preguntas?",
"desc": "",
"required": false,
"options": []
},
{
"type": 1,
"title": "Y... ¿Hay algún otro tema de tu vida que quieras contarnos?",
"desc": "",
"required": false,
"options": []
},
{
"type": 8,
"title": "📣 Y para terminar...",
"desc": "Contestar en esta sección es opcional"
},
{
"type": 1,
"title": "¿Hay algún proyecto, libro, producto, evento o iniciativa que tengas ahora mismo y que te gustaría dar a conocer durante la entrevista?",
"desc": "",
"required": false,
"options": []
},
{
"type": 1,
"title": "¿Hay alguna pregunta que te encantaría que te hiciéramos y que normalmente nadie te hace?",
"desc": "",
"required": false,
"options": []
},
{
"type": 1,
"title": "¿Hay alguna historia que te gustaría contar sí o sí?",
"desc": "",
"required": false,
"options": []
},
{
"type": 8,
"title": "Antes de enviar",
"desc": "Gracias por tu tiempo y tu disposición. Este podcast se realiza de forma altruista y colaborativa. Ni la preparación, ni la grabación, ni la publicación contemplan remuneración económica. La participación es voluntaria y con buena fe, con la intención de compartir conocimiento, experiencias e historias. Al aceptar participar, entiendes y estás de acuerdo con estas condiciones."
},
{
"type": 4,
"title": "Aceptación de condiciones",
"desc": "",
"required": true,
"options": [
"He leído y acepto las condiciones de participación descritas arriba."
]
}
]
}
+114
View File
@@ -0,0 +1,114 @@
"""Genera public/invitados.html a partir de guest_form.json (copia de las preguntas del formulario de Google)."""
import html, json
esc = html.escape
F = json.load(open('guest_form.json', encoding='utf-8'))
secciones, actual, nq = [], None, 0
consent = None
for it in F['items']:
t = it['type']
if t == 8:
actual = {'titulo': it['title'], 'nota': it['desc'], 'preguntas': []}
secciones.append(actual)
elif t in (0, 1):
nq += 1
actual['preguntas'].append({'id': f'q{nq:02d}', 'titulo': it['title'], 'desc': it['desc'],
'req': it['required'], 'largo': t == 1})
elif t == 4:
consent = {'titulo': it['title'], 'opcion': it['options'][0], 'req': it['required']}
consent_text = secciones[-1]['nota']
secciones.pop() # la última sección solo contiene las condiciones
def pregunta(q):
mark = ' <span class="req" title="Obligatoria">*</span>' if q['req'] else ''
campo = (f'<textarea name="{q["id"]}" rows="4" maxlength="3000"{" required" if q["req"] else ""}></textarea>'
if q['largo'] else
f'<input name="{q["id"]}" type="text" maxlength="300"{" required" if q["req"] else ""}>')
d = f'<small>{esc(q["desc"])}</small>' if q['desc'] else ''
return f' <label><span>{esc(q["titulo"])}{mark}</span>{d}\n {campo}\n </label>'
EMAIL = ''' <label><span>Tu correo electrónico <span class="req" title="Obligatoria">*</span></span><small>Te enviaremos un recordatorio de tu cita y así podemos responderte.</small>
<input name="email" type="email" autocomplete="email" maxlength="200" required>
</label>
<label><span>Tu teléfono móvil <span class="req" title="Obligatoria">*</span></span><small>Solo para contactarte el día de la grabación si surge algún imprevisto.</small>
<input name="tel" type="tel" autocomplete="tel" inputmode="tel" maxlength="20" required>
</label>'''
bloques = []
for s in secciones:
nota = f'\n <p class="note">{esc(s["nota"])}</p>' if s['nota'] else ''
bloques.append(f' <fieldset class="sec">\n <legend>{esc(s["titulo"])}</legend>{nota}\n' +
'\n'.join(pregunta(q) for q in s['preguntas']) + '\n </fieldset>')
bloques[0] = bloques[0].replace('</fieldset>', EMAIL + '\n </fieldset>')
def nav(i, total):
ant = '<button type="button" class="btn ghost prev">← Anterior</button>' if i else ''
sig = ('<button class="btn primary" type="submit">Enviar mi ficha</button>' if i == total - 1
else '<button type="button" class="btn primary next">Siguiente →</button>')
return f' <div class="nav">{ant}{sig}</div>\n </fieldset>'
FINAL = ''' <fieldset class="sec">
<legend>Antes de enviar</legend>
<p class="note">Este podcast se realiza de forma altruista y colaborativa.</p>
<div class="trap" aria-hidden="true"><label>No rellenes este campo <input name="web" type="text" tabindex="-1" autocomplete="off"></label></div>
<label class="check">
<input name="consent" type="checkbox" required>
<span>He leído y acepto las <a href="terminos.html#participacion" target="_blank" rel="noopener">condiciones de participación</a> descritas. <span class="req">*</span></span>
</label>
<label class="check">
<input name="privacy" type="checkbox" required>
<span>He leído la <a href="privacidad.html" target="_blank" rel="noopener">política de privacidad</a> y acepto que usen estos datos para preparar el episodio. <span class="req">*</span></span>
</label>
</fieldset>'''
pasos = bloques + [FINAL]
pasos = [p[:p.rindex(' </fieldset>')] + nav(i, len(pasos)) for i, p in enumerate(pasos)]
CUERPO = chr(10).join(pasos)
ELIGE_DIA = '''
<div id="elige-dia" hidden>
<fieldset id="dias-fs" class="sec">
<legend>¿Qué día te viene mejor?</legend>
</fieldset>
<p id="elige-err" class="status err" hidden>Selecciona un día para continuar.</p>
<div class="nav"><button id="elige-ok" type="button" class="btn primary">Continuar →</button></div>
</div>
'''
page = f'''<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow, noarchive">
<title>Ficha del invitado · Anda Que No Sabes Ná</title>
<meta name="theme-color" content="#06080d">
<link rel="icon" href="img/avatar.jpg">
<link rel="stylesheet" href="styles.css">
</head>
<body>
<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>
</header>
<main class="legal contact guest">
<h1>Ficha del <em>invitado</em></h1>
<p id="hola" class="lead-c" hidden></p>
<p id="malo" class="status err" hidden>Este enlace no es válido o ha caducado. Cuéntanos qué ha pasado desde el <a href="contacto.html">formulario de contacto</a> y te mandamos uno nuevo.</p>
<p id="cita" class="cita" hidden></p>
{ELIGE_DIA}
<p id="intro" class="lead-c" hidden>{esc(F['description'])}</p>
<form id="g" class="form" novalidate hidden data-ok="¡Gracias! Hemos recibido tu ficha y te hemos enviado un correo con los datos de la grabación. Si no lo ves en unos minutos, mira en la carpeta de spam y añade contacto@andaquenosabesna.com a tus contactos.">
{CUERPO}
<p id="paso" class="paso" aria-live="polite"></p>
<p id="st" class="status" role="status" aria-live="polite"></p>
<p class="note">Tus respuestas se guardan en este navegador mientras escribes, por si cierras la página. Se borran al enviarlas.</p>
</form>
</main>
<script src="invitados.js"></script>
</body>
</html>
'''
open('public/invitados.html', 'w', encoding='utf-8').write(page)
print('invitados.html:', len(page), 'bytes;', nq, 'preguntas;', len(secciones), 'secciones')
+29
View File
@@ -0,0 +1,29 @@
server {
listen 80;
server_name _;
if ($host = andaquenosabesna.thax.es) { return 301 https://andaquenosabesna.com$request_uri; }
root /usr/share/nginx/html;
index index.html;
add_header X-Content-Type-Options nosniff;
add_header Referrer-Policy strict-origin-when-cross-origin;
location = /index.html { add_header Cache-Control "no-cache"; }
location = / { add_header Cache-Control "no-cache"; }
location /img/ { expires 7d; add_header Cache-Control "public"; }
location ~* \.(css)$ { add_header Cache-Control "no-cache"; }
location = /invitados.html {
add_header X-Robots-Tag "noindex, nofollow, noarchive" always;
add_header Cache-Control "no-store";
add_header X-Content-Type-Options nosniff;
}
location = /api/invitados {
client_max_body_size 128k;
proxy_pass http://127.0.0.1:8081;
proxy_set_header X-Forwarded-For $http_x_forwarded_for;
}
location /api/ {
client_max_body_size 16k;
proxy_pass http://127.0.0.1:8081;
proxy_set_header X-Forwarded-For $http_x_forwarded_for;
}
location / { try_files $uri $uri/ =404; }
}
+52
View File
@@ -0,0 +1,52 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Contacto · Anda Que No Sabes Ná</title>
<meta name="description" content="¿Quieres venir al programa, proponer un tema o decirnos algo? Escríbenos.">
<meta name="theme-color" content="#06080d">
<link rel="icon" href="img/avatar.jpg">
<link rel="stylesheet" href="styles.css">
</head>
<body>
<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="/">Inicio</a><a href="/#quienes">Quiénes somos</a><a href="patrocinadores.html">Patrocinadores</a></nav>
</header>
<main class="legal contact">
<h1>¿Quieres venir al <em>programa</em>?</h1>
<p class="lead-c">Cuéntanos quién eres y de qué te gustaría hablar. También puedes proponer un tema o simplemente escribirnos. Te respondemos por correo.</p>
<form id="f" class="form" novalidate data-endpoint="/api/contacto" data-mail="contacto@andaquenosabesna.com" data-ok="¡Gracias! Hemos recibido tu mensaje y te responderemos pronto.">
<label>Nombre
<input name="nombre" type="text" autocomplete="name" maxlength="100" required>
</label>
<label>Correo electrónico
<input name="email" type="email" autocomplete="email" maxlength="200" required>
</label>
<label>Motivo
<select name="motivo">
<option value="venir">Quiero venir al programa</option>
<option value="tema">Propongo un tema</option>
<option value="otro">Otro asunto</option>
</select>
</label>
<label>Mensaje
<textarea name="mensaje" rows="6" maxlength="3000" required placeholder="Cuéntanos a qué te dedicas y qué te gustaría contar…"></textarea>
</label>
<div class="trap" aria-hidden="true"><label>No rellenes este campo <input name="web" type="text" tabindex="-1" autocomplete="off"></label></div>
<label class="check">
<input name="consent" type="checkbox" required>
<span>He leído la <a href="privacidad.html" target="_blank" rel="noopener">política de privacidad</a> y acepto que usen mis datos para responderme.</span>
</label>
<button class="btn primary" type="submit">Enviar mensaje</button>
<p id="st" class="status" role="status" aria-live="polite"></p>
</form>
<noscript><p>Necesitas JavaScript para usar el formulario. Mientras tanto, escríbenos a <a href="mailto:contacto@andaquenosabesna.com">contacto@andaquenosabesna.com</a>.</p></noscript>
</main>
<script src="form.js"></script>
</body>
</html>
+29
View File
@@ -0,0 +1,29 @@
(function () {
var f = document.querySelector('form[data-endpoint]');
if (!f) return;
var st = document.getElementById('st'), t0 = Date.now(), busy = false, mail = f.getAttribute('data-mail');
function say(msg, ok) { st.textContent = msg; st.className = 'status ' + (ok ? 'ok' : 'err'); }
f.addEventListener('submit', function (e) {
e.preventDefault();
if (busy) return;
var body = { consent: f.consent.checked, t: Date.now() - t0 };
Array.prototype.forEach.call(f.elements, function (el) {
if (el.name && el.name !== 'consent') body[el.name] = (el.value || '').trim();
});
if (!body.nombre || !/^[^@\s]+@[^@\s]+\.[^@\s]{2,}$/.test(body.email || '') || (body.mensaje || '').length < 10 ||
!body.consent || (f.empresa && !body.empresa)) {
say('Revisa los campos obligatorios, el correo y que el mensaje tenga al menos 10 caracteres, y marca la casilla de privacidad.', false);
return;
}
if (body.sitio && !/^https?:\/\//.test(body.sitio)) { say('La web debe empezar por http:// o https://', false); return; }
busy = true; say('Enviando…', true);
fetch(f.getAttribute('data-endpoint'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
.then(function (r) { return r.json().catch(function () { return {}; }).then(function (j) { return { ok: r.ok && j.ok, j: j }; }); })
.then(function (x) {
if (x.ok) { f.reset(); say(f.getAttribute('data-ok'), true); }
else { say((x.j && x.j.error) || 'No se pudo enviar. Escríbenos a ' + mail + '.', false); }
})
.catch(function () { say('No se pudo enviar. Escríbenos a ' + mail + '.', false); })
.then(function () { busy = false; });
});
})();
Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

+261
View File
@@ -0,0 +1,261 @@
<!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="https://www.youtube.com/@andaquenosabesna" 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="https://www.youtube.com/watch?v=4WtlNKB3xwI" target="_blank" rel="noopener">
<span class="thumb"><img src="img/4WtlNKB3xwI-hd.jpg" alt="" width="1280" height="720"><span class="play big" aria-hidden="true"></span><span class="views" title="8 vistas"><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><b>8</b></span></span>
<span class="feat-text">
<span class="tag">Último vídeo</span>
<strong>¿Están perdiendo AUTORIDAD los Profesores en las aulas?</strong>
<time>20 oct 2026</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="https://www.youtube.com/@andaquenosabesna/videos" target="_blank" rel="noopener">Todos los vídeos →</a>
</div>
<div class="grid">
<a class="card reveal" style="--d:0ms" href="https://www.youtube.com/watch?v=_-VaTy2fazY" target="_blank" rel="noopener">
<span class="thumb"><img src="img/_-VaTy2fazY-hd.jpg" alt="" loading="lazy" width="640" height="360"><span class="play" aria-hidden="true"></span><span class="views" title="421 vistas"><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><b>421</b></span></span>
<span class="meta"><time>18 oct 2026</time><strong>DOMESTICAR ADOLESCENTES, PLANTAR TOMATES Y CONSTRUIR MINIATURAS | AQNSN Programa 3 - Galaxoide</strong></span>
</a>
<a class="card reveal" style="--d:70ms" href="https://www.youtube.com/watch?v=7zY2ey_ERlQ" target="_blank" rel="noopener">
<span class="thumb"><img src="img/7zY2ey_ERlQ-hd.jpg" alt="" loading="lazy" width="640" height="360"><span class="play" aria-hidden="true"></span><span class="views" title="27 vistas"><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><b>27</b></span></span>
<span class="meta"><time>10 oct 2026</time><strong>Un Simple Teletransporte de Cartas...</strong></span>
</a>
<a class="card reveal" style="--d:140ms" href="https://www.youtube.com/watch?v=Q7qYQbvjMxQ" target="_blank" rel="noopener">
<span class="thumb"><img src="img/Q7qYQbvjMxQ-hd.jpg" alt="" loading="lazy" width="640" height="360"><span class="play" aria-hidden="true"></span><span class="views" title="29 vistas"><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><b>29</b></span></span>
<span class="meta"><time>9 oct 2026</time><strong>El Mahshi y otras RECETAS EXÓTICAS de Egipto que PODRÍAN PERDERSE</strong></span>
</a>
<a class="card reveal" style="--d:0ms" href="https://www.youtube.com/watch?v=mxP0dvn2HD4" target="_blank" rel="noopener">
<span class="thumb"><img src="img/mxP0dvn2HD4-hd.jpg" alt="" loading="lazy" width="640" height="360"><span class="play" aria-hidden="true"></span><span class="views" title="196 vistas"><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><b>196</b></span></span>
<span class="meta"><time>6 oct 2026</time><strong>Árabe formal VS Árabe de la calle, ¿qué diferencias hay?</strong></span>
</a>
<a class="card reveal" style="--d:70ms" href="https://www.youtube.com/watch?v=f6UO6jtUgrU" target="_blank" rel="noopener">
<span class="thumb"><img src="img/f6UO6jtUgrU-hd.jpg" alt="" loading="lazy" width="640" height="360"><span class="play" aria-hidden="true"></span><span class="views" title="65 vistas"><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><b>65</b></span></span>
<span class="meta"><time>5 oct 2026</time><strong>&quot;Así robaron el busto de Nefertiti...&quot; - El problema del EXPOLIO DE TESOROS de Egipto</strong></span>
</a>
<a class="card reveal" style="--d:140ms" href="https://www.youtube.com/watch?v=A2v_T2CCupo" target="_blank" rel="noopener">
<span class="thumb"><img src="img/A2v_T2CCupo-hd.jpg" alt="" loading="lazy" width="640" height="360"><span class="play" aria-hidden="true"></span><span class="views" title="252 vistas"><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><b>252</b></span></span>
<span class="meta"><time>4 oct 2026</time><strong>¿Cómo es la vida nocturna y callejera en Egipto?</strong></span>
</a>
<a class="card reveal" style="--d:0ms" href="https://www.youtube.com/watch?v=H_yHEe6skW4" target="_blank" rel="noopener">
<span class="thumb"><img src="img/H_yHEe6skW4-hd.jpg" alt="" loading="lazy" width="640" height="360"><span class="play" aria-hidden="true"></span><span class="views" title="139 vistas"><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><b>139</b></span></span>
<span class="meta"><time>3 oct 2026</time><strong>EGIPTO NO ES COMO TE LO HAN CONTADO | AQNSN Programa 2 con Sara Yasser</strong></span>
</a>
<a class="card reveal" style="--d:70ms" href="https://www.youtube.com/watch?v=Ei_VnfM-uLY" target="_blank" rel="noopener">
<span class="thumb"><img src="img/Ei_VnfM-uLY-hd.jpg" alt="" loading="lazy" width="640" height="360"><span class="play" aria-hidden="true"></span><span class="views" title="124 vistas"><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><b>124</b></span></span>
<span class="meta"><time>30 sep 2026</time><strong>TANATOPRACTOR DE DÍA Y ANIMADOR TUPPER SEX DE NOCHE | AQNSN Programa 1 con Javi (Parte 2)</strong></span>
</a>
</div>
</section>
<section id="shorts" class="wrap">
<div class="sec-head reveal">
<h2>Últimos <em>Shorts</em></h2>
<a class="more" href="https://www.youtube.com/@andaquenosabesna/shorts" target="_blank" rel="noopener">Todos los Shorts →</a>
</div>
<div class="shorts">
<a class="scard reveal" style="--d:0ms" href="https://www.youtube.com/shorts/PxQHPd5YDLs" target="_blank" rel="noopener">
<span class="thumb"><img src="img/PxQHPd5YDLs-hd.jpg" alt="" loading="lazy" width="540" height="960"><span class="play" aria-hidden="true"></span><span class="views" title="198 vistas"><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><b>198</b></span></span>
<span class="meta"><strong>La Tediosa Burocracia en la Educación</strong></span>
</a>
<a class="scard reveal" style="--d:50ms" href="https://www.youtube.com/shorts/rLFSEqe77OY" target="_blank" rel="noopener">
<span class="thumb"><img src="img/rLFSEqe77OY-hd.jpg" alt="" loading="lazy" width="540" height="960"><span class="play" aria-hidden="true"></span><span class="views" title="584 vistas"><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><b>584</b></span></span>
<span class="meta"><strong>¿Cómo dejas de comerte las uñas? El trauma útil de Palín</strong></span>
</a>
<a class="scard reveal" style="--d:100ms" href="https://www.youtube.com/shorts/jmV_Ao85RvI" target="_blank" rel="noopener">
<span class="thumb"><img src="img/jmV_Ao85RvI-hd.jpg" alt="" loading="lazy" width="540" height="960"><span class="play" aria-hidden="true"></span><span class="views" title="16 vistas"><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><b>16</b></span></span>
<span class="meta"><strong>¿Cómo de antiguo es el Árabe?</strong></span>
</a>
<a class="scard reveal" style="--d:150ms" href="https://www.youtube.com/shorts/DLcn8i9lV6Q" target="_blank" rel="noopener">
<span class="thumb"><img src="img/DLcn8i9lV6Q-hd.jpg" alt="" loading="lazy" width="540" height="960"><span class="play" aria-hidden="true"></span><span class="views" title="546 vistas"><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><b>546</b></span></span>
<span class="meta"><strong>Todas las religiones son similares...</strong></span>
</a>
<a class="scard reveal" style="--d:200ms" href="https://www.youtube.com/shorts/E_eULZSlWJs" target="_blank" rel="noopener">
<span class="thumb"><img src="img/E_eULZSlWJs-hd.jpg" alt="" loading="lazy" width="540" height="960"><span class="play" aria-hidden="true"></span><span class="views" title="467 vistas"><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><b>467</b></span></span>
<span class="meta"><strong>La Vida de las Mujeres Egipcias en las zonas costeras</strong></span>
</a>
<a class="scard reveal" style="--d:250ms" href="https://www.youtube.com/shorts/600_XwAHkv0" target="_blank" rel="noopener">
<span class="thumb"><img src="img/600_XwAHkv0-hd.jpg" alt="" loading="lazy" width="540" height="960"><span class="play" aria-hidden="true"></span><span class="views" title="847 vistas"><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><b>847</b></span></span>
<span class="meta"><strong>Clases religiosas separadas, la política de las escuelas egipcias</strong></span>
</a>
<a class="scard reveal" style="--d:0ms" href="https://www.youtube.com/shorts/XKSVhuk_lQI" target="_blank" rel="noopener">
<span class="thumb"><img src="img/XKSVhuk_lQI-hd.jpg" alt="" loading="lazy" width="540" height="960"><span class="play" aria-hidden="true"></span><span class="views" title="1.207 vistas"><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><b>1.207</b></span></span>
<span class="meta"><strong>Poligamia en Egipto, de la antigüedad a la actualidad</strong></span>
</a>
<a class="scard reveal" style="--d:50ms" href="https://www.youtube.com/shorts/voK4g7lIv4E" target="_blank" rel="noopener">
<span class="thumb"><img src="img/voK4g7lIv4E-hd.jpg" alt="" loading="lazy" width="540" height="960"><span class="play" aria-hidden="true"></span><span class="views" title="497 vistas"><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><b>497</b></span></span>
<span class="meta"><strong>El Barrio Pijo de Egipto, Masr El Gedida, y sus tiendas de Oro</strong></span>
</a>
<a class="scard reveal" style="--d:100ms" href="https://www.youtube.com/shorts/yzTMyAKQnDQ" target="_blank" rel="noopener">
<span class="thumb"><img src="img/yzTMyAKQnDQ-hd.jpg" alt="" loading="lazy" width="540" height="960"><span class="play" aria-hidden="true"></span><span class="views" title="221 vistas"><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><b>221</b></span></span>
<span class="meta"><strong>Las Franquicias se extienden en EGIPTO...</strong></span>
</a>
<a class="scard reveal" style="--d:150ms" href="https://www.youtube.com/shorts/DgTa9qX4xH0" target="_blank" rel="noopener">
<span class="thumb"><img src="img/DgTa9qX4xH0-hd.jpg" alt="" loading="lazy" width="540" height="960"><span class="play" aria-hidden="true"></span><span class="views" title="466 vistas"><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><b>466</b></span></span>
<span class="meta"><strong>El barrio cristiano del Cairo que RECICLA BASURA</strong></span>
</a>
<a class="scard reveal" style="--d:200ms" href="https://www.youtube.com/shorts/oAoEz2Kwmk8" target="_blank" rel="noopener">
<span class="thumb"><img src="img/oAoEz2Kwmk8-hd.jpg" alt="" loading="lazy" width="540" height="960"><span class="play" aria-hidden="true"></span><span class="views" title="308 vistas"><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><b>308</b></span></span>
<span class="meta"><strong>Tiendas QUE NO CIERRAN en Egipto 🌇</strong></span>
</a>
<a class="scard reveal" style="--d:250ms" href="https://www.youtube.com/shorts/05m1CMyGyUA" target="_blank" rel="noopener">
<span class="thumb"><img src="img/05m1CMyGyUA-hd.jpg" alt="" loading="lazy" width="540" height="960"><span class="play" aria-hidden="true"></span><span class="views" title="172 vistas"><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><b>172</b></span></span>
<span class="meta"><strong>¿Se parece la comida EGIPCIA a la ESPAÑOLA?</strong></span>
</a>
</div>
</section>
<section id="populares" class="wrap">
<div class="sec-head reveal">
<h2>Lo más <em>visto</em></h2>
<a class="more" href="https://www.youtube.com/@andaquenosabesna/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">
<a class="card reveal" style="--d:0ms" href="https://www.youtube.com/watch?v=_-VaTy2fazY" target="_blank" rel="noopener">
<span class="thumb"><img src="img/_-VaTy2fazY-hd.jpg" alt="" loading="lazy" width="640" height="360"><span class="play" aria-hidden="true"></span><span class="views" title="421 vistas"><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><b>421</b></span></span>
<span class="meta"><time>18 oct 2026</time><strong>DOMESTICAR ADOLESCENTES, PLANTAR TOMATES Y CONSTRUIR MINIATURAS | AQNSN Programa 3 - Galaxoide</strong></span>
</a>
<a class="card reveal" style="--d:70ms" href="https://www.youtube.com/watch?v=A2v_T2CCupo" target="_blank" rel="noopener">
<span class="thumb"><img src="img/A2v_T2CCupo-hd.jpg" alt="" loading="lazy" width="640" height="360"><span class="play" aria-hidden="true"></span><span class="views" title="252 vistas"><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><b>252</b></span></span>
<span class="meta"><time>4 oct 2026</time><strong>¿Cómo es la vida nocturna y callejera en Egipto?</strong></span>
</a>
<a class="card reveal" style="--d:140ms" href="https://www.youtube.com/watch?v=rp9j_Ph9XvI" target="_blank" rel="noopener">
<span class="thumb"><img src="img/rp9j_Ph9XvI-hd.jpg" alt="" loading="lazy" width="640" height="360"><span class="play" aria-hidden="true"></span><span class="views" title="219 vistas"><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><b>219</b></span></span>
<span class="meta"><time>16 sep 2026</time><strong>ISA BECERRIL NOS PRESENTA SU NOVELA &quot;HIALURÓNICO&quot; | AQNSN Episodio Piloto 0</strong></span>
</a>
<a class="card reveal" style="--d:0ms" href="https://www.youtube.com/watch?v=mxP0dvn2HD4" target="_blank" rel="noopener">
<span class="thumb"><img src="img/mxP0dvn2HD4-hd.jpg" alt="" loading="lazy" width="640" height="360"><span class="play" aria-hidden="true"></span><span class="views" title="196 vistas"><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><b>196</b></span></span>
<span class="meta"><time>6 oct 2026</time><strong>Árabe formal VS Árabe de la calle, ¿qué diferencias hay?</strong></span>
</a>
</div>
<h3 class="sub reveal">Shorts</h3>
<div class="shorts">
<a class="scard reveal" style="--d:0ms" href="https://www.youtube.com/shorts/zJ_x4iA8OSs" target="_blank" rel="noopener">
<span class="thumb"><img src="img/zJ_x4iA8OSs-hd.jpg" alt="" loading="lazy" width="540" height="960"><span class="play" aria-hidden="true"></span><span class="views" title="1.547 vistas"><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><b>1.547</b></span></span>
<span class="meta"><strong>El árabe que se estudia y el que se habla en las calles son diferentes</strong></span>
</a>
<a class="scard reveal" style="--d:50ms" href="https://www.youtube.com/shorts/nvF7P_S081o" target="_blank" rel="noopener">
<span class="thumb"><img src="img/nvF7P_S081o-hd.jpg" alt="" loading="lazy" width="540" height="960"><span class="play" aria-hidden="true"></span><span class="views" title="1.545 vistas"><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><b>1.545</b></span></span>
<span class="meta"><strong>😌 &quot;Solo vi a una persona morir sonriendo...&quot;</strong></span>
</a>
<a class="scard reveal" style="--d:100ms" href="https://www.youtube.com/shorts/Wfq5vjJ6sQU" target="_blank" rel="noopener">
<span class="thumb"><img src="img/Wfq5vjJ6sQU-hd.jpg" alt="" loading="lazy" width="540" height="960"><span class="play" aria-hidden="true"></span><span class="views" title="1.257 vistas"><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><b>1.257</b></span></span>
<span class="meta"><strong>&quot;Recoger a un niño ahogado de dos años, eso...&quot;</strong></span>
</a>
<a class="scard reveal" style="--d:150ms" href="https://www.youtube.com/shorts/XKSVhuk_lQI" target="_blank" rel="noopener">
<span class="thumb"><img src="img/XKSVhuk_lQI-hd.jpg" alt="" loading="lazy" width="540" height="960"><span class="play" aria-hidden="true"></span><span class="views" title="1.207 vistas"><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><b>1.207</b></span></span>
<span class="meta"><strong>Poligamia en Egipto, de la antigüedad a la actualidad</strong></span>
</a>
<a class="scard reveal" style="--d:200ms" href="https://www.youtube.com/shorts/p5trUANcq_c" target="_blank" rel="noopener">
<span class="thumb"><img src="img/p5trUANcq_c-hd.jpg" alt="" loading="lazy" width="540" height="960"><span class="play" aria-hidden="true"></span><span class="views" title="1.043 vistas"><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><b>1.043</b></span></span>
<span class="meta"><strong>El número 13 y su relación con la muerte #podcast #granada #misterio #latenight #andaquenosabesna</strong></span>
</a>
<a class="scard reveal" style="--d:250ms" href="https://www.youtube.com/shorts/7PRSukQTCwI" target="_blank" rel="noopener">
<span class="thumb"><img src="img/7PRSukQTCwI-hd.jpg" alt="" loading="lazy" width="540" height="960"><span class="play" aria-hidden="true"></span><span class="views" title="886 vistas"><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><b>886</b></span></span>
<span class="meta"><strong>¿Cómo puede meterte miedo un Tanatopractor? 😖</strong></span>
</a>
</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"><li>Egipto</li><li>Curiosidades</li><li>Arqueología</li><li>Educación</li><li>Gastronomía</li><li>Lengua árabe</li><li>Agricultura</li><li>Burocracia</li><li>Comida egipcia</li><li>CulturaEgipcia</li><li>Cultura granaína</li><li>Entrevistas</li></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="https://www.youtube.com/@andaquenosabesna" target="_blank" rel="noopener"><b>YouTube</b><span>@andaquenosabesna</span></a>
<a href="https://www.tiktok.com/@andaquenosabesna" target="_blank" rel="noopener"><b>TikTok</b><span>@andaquenosabesna</span></a>
<a href="https://www.instagram.com/andaquenosabesna" 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>
+142
View File
@@ -0,0 +1,142 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow, noarchive">
<title>Ficha del invitado · Anda Que No Sabes Ná</title>
<meta name="theme-color" content="#06080d">
<link rel="icon" href="img/avatar.jpg">
<link rel="stylesheet" href="styles.css">
</head>
<body>
<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>
</header>
<main class="legal contact guest">
<h1>Ficha del <em>invitado</em></h1>
<p id="hola" class="lead-c" hidden></p>
<p id="malo" class="status err" hidden>Este enlace no es válido o ha caducado. Cuéntanos qué ha pasado desde el <a href="contacto.html">formulario de contacto</a> y te mandamos uno nuevo.</p>
<p id="cita" class="cita" hidden></p>
<div id="elige-dia" hidden>
<fieldset id="dias-fs" class="sec">
<legend>¿Qué día te viene mejor?</legend>
</fieldset>
<p id="elige-err" class="status err" hidden>Selecciona un día para continuar.</p>
<div class="nav"><button id="elige-ok" type="button" class="btn primary">Continuar →</button></div>
</div>
<p id="intro" class="lead-c" hidden>Queremos conocerte un poco antes de darle al REC. No hace falta que prepares respuestas perfectas: esto nos sirve para encontrar buenas historias, preguntas interesantes y, sobre todo, temas sobre los que merezca la pena hablar.</p>
<form id="g" class="form" novalidate hidden data-ok="¡Gracias! Hemos recibido tu ficha y te hemos enviado un correo con los datos de la grabación. Si no lo ves en unos minutos, mira en la carpeta de spam y añade contacto@andaquenosabesna.com a tus contactos.">
<fieldset class="sec">
<legend>👤 Tú</legend>
<label><span>Nombre y cómo prefieres que te presentemos <span class="req" title="Obligatoria">*</span></span>
<input name="q01" type="text" maxlength="300" required>
</label>
<label><span>¿A qué te dedicas actualmente? <span class="req" title="Obligatoria">*</span></span>
<textarea name="q02" rows="4" maxlength="3000" required></textarea>
</label>
<label><span>¿Cuál es tu proyecto, empresa o actividad principal?</span>
<textarea name="q03" rows="4" maxlength="3000"></textarea>
</label>
<label><span>LinkedIn, web o cualquier enlace que nos ayude a conocerte mejor</span>
<input name="q04" type="text" maxlength="300">
</label>
<label><span>Tus redes sociales que quieras que mencionemos o utilicemos para promocionar el episodio</span>
<textarea name="q05" rows="4" maxlength="3000"></textarea>
</label>
<label><span>Tu correo electrónico <span class="req" title="Obligatoria">*</span></span><small>Te enviaremos un recordatorio de tu cita y así podemos responderte.</small>
<input name="email" type="email" autocomplete="email" maxlength="200" required>
</label>
<label><span>Tu teléfono móvil <span class="req" title="Obligatoria">*</span></span><small>Solo para contactarte el día de la grabación si surge algún imprevisto.</small>
<input name="tel" type="tel" autocomplete="tel" inputmode="tel" maxlength="20" required>
</label>
<div class="nav"><button type="button" class="btn primary next">Siguiente →</button></div>
</fieldset>
<fieldset class="sec">
<legend>🎙️ Para conocerte un poco mejor</legend>
<label><span>Si tuvieras que contarnos tu historia en dos minutos, ¿por dónde empezarías? <span class="req" title="Obligatoria">*</span></span>
<textarea name="q06" rows="4" maxlength="3000" required></textarea>
</label>
<label><span>¿Qué tema podrías estar hablando durante horas sin aburrirte? <span class="req" title="Obligatoria">*</span></span>
<textarea name="q07" rows="4" maxlength="3000" required></textarea>
</label>
<label><span>¿Hay alguna experiencia, proyecto, fracaso, éxito o anécdota que te parezca especialmente buena para contar en el podcast? <span class="req" title="Obligatoria">*</span></span>
<textarea name="q08" rows="4" maxlength="3000" required></textarea>
</label>
<label><span>¿Qué idea, mito o tópico de tu sector te gustaría desmontar?</span>
<textarea name="q09" rows="4" maxlength="3000"></textarea>
</label>
<div class="nav"><button type="button" class="btn ghost prev">← Anterior</button><button type="button" class="btn primary next">Siguiente →</button></div>
</fieldset>
<fieldset class="sec">
<legend>🔥 Vamos a meternos en harina</legend>
<p class="note">Contestar en esta sección es opcional</p>
<label><span>¿Hay alguna opinión tuya sobre tu sector que pueda generar debate?</span>
<textarea name="q10" rows="4" maxlength="3000"></textarea>
</label>
<label><span>¿Qué pregunta te hacen siempre y estás cansado/a de responder?</span>
<textarea name="q11" rows="4" maxlength="3000"></textarea>
</label>
<label><span>¿Hay algo que normalmente no se cuenta cuando se habla de tu profesión, sector o trayectoria y que crees que deberíamos contar?</span>
<textarea name="q12" rows="4" maxlength="3000"></textarea>
</label>
<label><span>Si pudiéramos conseguir que después de escucharte alguien cambiara de opinión sobre una cosa, ¿qué te gustaría que fuera?</span>
<textarea name="q13" rows="4" maxlength="3000"></textarea>
</label>
<div class="nav"><button type="button" class="btn ghost prev">← Anterior</button><button type="button" class="btn primary next">Siguiente →</button></div>
</fieldset>
<fieldset class="sec">
<legend>🚧 Territorio delicado</legend>
<p class="note">No hace falta justificarlo. Queremos saber dónde están tus límites antes de empezar.</p>
<label><span>¿Hay algún tema que prefieras que no toquemos? <span class="req" title="Obligatoria">*</span></span>
<textarea name="q14" rows="4" maxlength="3000" required></textarea>
</label>
<label><span>¿Hay alguna persona, empresa, experiencia o asunto sobre el que prefieras que no hagamos preguntas?</span>
<textarea name="q15" rows="4" maxlength="3000"></textarea>
</label>
<label><span>Y... ¿Hay algún otro tema de tu vida que quieras contarnos?</span>
<textarea name="q16" rows="4" maxlength="3000"></textarea>
</label>
<div class="nav"><button type="button" class="btn ghost prev">← Anterior</button><button type="button" class="btn primary next">Siguiente →</button></div>
</fieldset>
<fieldset class="sec">
<legend>📣 Y para terminar...</legend>
<p class="note">Contestar en esta sección es opcional</p>
<label><span>¿Hay algún proyecto, libro, producto, evento o iniciativa que tengas ahora mismo y que te gustaría dar a conocer durante la entrevista?</span>
<textarea name="q17" rows="4" maxlength="3000"></textarea>
</label>
<label><span>¿Hay alguna pregunta que te encantaría que te hiciéramos y que normalmente nadie te hace?</span>
<textarea name="q18" rows="4" maxlength="3000"></textarea>
</label>
<label><span>¿Hay alguna historia que te gustaría contar sí o sí?</span>
<textarea name="q19" rows="4" maxlength="3000"></textarea>
</label>
<div class="nav"><button type="button" class="btn ghost prev">← Anterior</button><button type="button" class="btn primary next">Siguiente →</button></div>
</fieldset>
<fieldset class="sec">
<legend>Antes de enviar</legend>
<p class="note">Este podcast se realiza de forma altruista y colaborativa.</p>
<div class="trap" aria-hidden="true"><label>No rellenes este campo <input name="web" type="text" tabindex="-1" autocomplete="off"></label></div>
<label class="check">
<input name="consent" type="checkbox" required>
<span>He leído y acepto las <a href="terminos.html#participacion" target="_blank" rel="noopener">condiciones de participación</a> descritas. <span class="req">*</span></span>
</label>
<label class="check">
<input name="privacy" type="checkbox" required>
<span>He leído la <a href="privacidad.html" target="_blank" rel="noopener">política de privacidad</a> y acepto que usen estos datos para preparar el episodio. <span class="req">*</span></span>
</label>
<div class="nav"><button type="button" class="btn ghost prev">← Anterior</button><button class="btn primary" type="submit">Enviar mi ficha</button></div>
</fieldset>
<p id="paso" class="paso" aria-live="polite"></p>
<p id="st" class="status" role="status" aria-live="polite"></p>
<p class="note">Tus respuestas se guardan en este navegador mientras escribes, por si cierras la página. Se borran al enviarlas.</p>
</form>
</main>
<script src="invitados.js"></script>
</body>
</html>
+124
View File
@@ -0,0 +1,124 @@
(function () {
var $ = function (id) { return document.getElementById(id); };
var P = new URLSearchParams(location.search);
var n = (P.get('n') || '').trim();
var fe = (P.get('f') || '').trim();
var de = (P.get('d') || '').trim();
var k = n && (fe || de) ? n + (fe || de) : '';
var diaElegido = null;
var f = $('g'), st = $('st'), hola = $('hola'), malo = $('malo'), intro = $('intro'), cita = $('cita'), paso = $('paso');
var eligeDia = $('elige-dia'), diasFs = $('dias-fs'), eligeErr = $('elige-err'), eligeOk = $('elige-ok');
var steps = Array.prototype.slice.call(f.querySelectorAll('fieldset.sec'));
var cur = 0, t0 = Date.now(), busy = false, KEY = 'aqnsn-ficha-' + k.slice(0, 40);
function say(m, ok) { st.textContent = m; st.className = 'status ' + (ok ? 'ok' : 'err'); }
function store(op, v) { try { return op === 'get' ? localStorage.getItem(KEY) : op === 'set' ? localStorage.setItem(KEY, v) : localStorage.removeItem(KEY); } catch (e) { return null; } }
var okMail = function (v) { return /^[^@\s]+@[^@\s]+\.[^@\s]{2,}$/.test(v); };
if (!k) { malo.hidden = false; return; }
function show(i) {
cur = i;
steps.forEach(function (s, j) { s.hidden = j !== i; });
paso.textContent = 'Paso ' + (i + 1) + ' de ' + steps.length;
say('', true);
var top = hola.getBoundingClientRect().top + window.scrollY - 12;
window.scrollTo({ top: Math.max(0, top), behavior: 'smooth' });
}
function validar(i) {
var s = steps[i], els = s.querySelectorAll('input[required],textarea[required]');
for (var j = 0; j < els.length; j++) {
var el = els[j], bad = el.type === 'checkbox' ? !el.checked : !el.value.trim();
if (bad) { el.focus(); say(el.type === 'checkbox' ? 'Marca las dos casillas finales para poder enviar la ficha.' : 'Faltan respuestas obligatorias (las marcadas con *).', false); return false; }
}
var t = s.querySelector('input[type=tel]');
if (t && !/^\+?[\d ().-]{7,20}$/.test(t.value.trim())) { t.focus(); say('El teléfono no parece válido.', false); return false; }
var m = s.querySelector('input[type=email]');
if (m && !okMail(m.value.trim())) { m.focus(); say('El correo no parece válido.', false); return false; }
return true;
}
f.addEventListener('click', function (e) {
var b = e.target.closest && e.target.closest('button.next,button.prev');
if (!b) return;
if (b.classList.contains('prev')) show(cur - 1);
else if (validar(cur)) show(cur + 1);
});
f.addEventListener('keydown', function (e) {
if (e.key === 'Enter' && e.target.tagName === 'INPUT' && e.target.type !== 'checkbox' && cur < steps.length - 1) { e.preventDefault(); if (validar(cur)) show(cur + 1); }
});
function mostrarFormulario() {
intro.hidden = false; f.hidden = false;
try { var s = JSON.parse(store('get') || '{}'); Object.keys(s).forEach(function (nm) { if (f.elements[nm] && f.elements[nm].type !== 'checkbox') f.elements[nm].value = s[nm]; }); } catch (e) {}
show(0);
window.scrollTo(0, 0);
}
var checkUrl = '/api/invitados/check?n=' + encodeURIComponent(n) + (fe ? '&f=' + encodeURIComponent(fe) : '&d=' + encodeURIComponent(de));
fetch(checkUrl, { cache: 'no-store' })
.then(function (r) { return r.json(); })
.then(function (j) {
if (!j.ok) { malo.hidden = false; return; }
hola.textContent = 'Hola, ' + j.nombre + '. ¡Gracias por querer venir al programa!';
hola.hidden = false;
if (j.cita) {
var d = new Date(j.cita * 1000), o = { timeZone: 'Europe/Madrid' };
var dia = d.toLocaleDateString('es-ES', Object.assign({ weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' }, o)).replace(',', '');
var hora = d.toLocaleTimeString('es-ES', Object.assign({ hour: '2-digit', minute: '2-digit' }, o));
cita.textContent = '📅 Recuerda que grabamos el ' + dia + ' a las ' + hora + ', y te enviaremos por correo electrónico la dirección del estudio.';
cita.hidden = false;
mostrarFormulario();
} else if (j.dias && j.dias.length) {
j.dias.forEach(function (op) {
var lbl = document.createElement('label');
lbl.className = 'check';
var inp = document.createElement('input');
inp.type = 'radio'; inp.name = 'dia-opcion'; inp.value = op.ts;
var span = document.createElement('span');
span.textContent = op.txt;
lbl.appendChild(inp); lbl.appendChild(span);
diasFs.appendChild(lbl);
});
eligeDia.hidden = false;
}
})
.catch(function () { malo.hidden = false; });
eligeOk.addEventListener('click', function () {
var r = diasFs.querySelector('input[name="dia-opcion"]:checked');
if (!r) { eligeErr.hidden = false; return; }
eligeErr.hidden = true;
diaElegido = r.value;
var lbl = r.closest('label');
cita.textContent = '📅 Has elegido grabar el ' + (lbl ? lbl.querySelector('span').textContent : '') + '. Te enviaremos por correo la dirección del estudio.';
cita.hidden = false;
eligeDia.hidden = true;
mostrarFormulario();
});
var timer;
f.addEventListener('input', function () {
clearTimeout(timer);
timer = setTimeout(function () {
var d = {};
Array.prototype.forEach.call(f.elements, function (el) { if (el.name && el.type !== 'checkbox' && el.name !== 'web' && el.value) d[el.name] = el.value; });
store('set', JSON.stringify(d));
}, 600);
});
f.addEventListener('submit', function (e) {
e.preventDefault();
if (busy) return;
for (var i = 0; i < steps.length; i++) { if (!validar(i)) { if (i !== cur) show(i); return; } }
var resp = {};
Array.prototype.forEach.call(f.elements, function (el) { if (/^q\d+$/.test(el.name)) resp[el.name] = el.value.trim(); });
busy = true; say('Enviando…', true);
fetch('/api/invitados', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ n: n, f: fe, d: de, dia: diaElegido || '', email: f.email.value.trim(), tel: f.tel.value.trim(), resp: resp, consent: true, web: f.web.value || '', t: Date.now() - t0 }) })
.then(function (r) { return r.json().catch(function () { return {}; }).then(function (j) { return { ok: r.ok && j.ok, j: j }; }); })
.then(function (x) {
if (x.ok) { store('del'); f.reset(); steps.forEach(function (s) { s.hidden = true; }); paso.hidden = true; f.querySelector('.note:last-of-type').hidden = true; say(f.getAttribute('data-ok'), true); }
else say((x.j && x.j.error) || 'No se pudo enviar. Cuéntanos qué ha pasado en andaquenosabesna.com/contacto.html', false);
})
.catch(function () { say('No se pudo enviar. Cuéntanos qué ha pasado en andaquenosabesna.com/contacto.html', false); })
.then(function () { busy = false; });
});
})();
+222
View File
@@ -0,0 +1,222 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow, noarchive">
<title>Nuevo enlace de invitado · AQNSN</title>
<meta name="theme-color" content="#06080d">
<link rel="icon" href="img/avatar.jpg">
<link rel="stylesheet" href="styles.css">
<style>
.gen-form { display: grid; gap: 20px; max-width: 560px; }
.gen-form label { display: grid; gap: 6px; font-weight: 600; font-size: .95rem; }
.gen-form input[type=text], .gen-form input[type=password], .gen-form input[type=datetime-local] {
width: 100%; padding: 13px 15px; border-radius: 12px; border: 1px solid var(--line);
background: var(--card); color: var(--ink); font: inherit; font-weight: 400; transition: .2s;
}
.gen-form input:focus { outline: none; border-color: var(--neon); box-shadow: 0 0 0 3px rgba(46,230,255,.18); }
.dias-list { display: grid; gap: 10px; }
.dia-row { display: flex; gap: 10px; align-items: center; }
.dia-row input { flex: 1; }
.dia-row .del-dia { background: transparent; border: 1px solid var(--line); color: var(--muted);
padding: 8px 13px; border-radius: 8px; font: inherit; cursor: pointer; transition: .2s; flex: none; }
.dia-row .del-dia:hover { border-color: #ff8a8a; color: #ff8a8a; }
.add-dia { background: transparent; border: 1px dashed var(--line); color: var(--muted);
padding: 11px 18px; border-radius: 12px; font: inherit; cursor: pointer; width: 100%; transition: .2s; }
.add-dia:hover { border-color: var(--neon); color: var(--neon); }
.result-box { display: none; margin-top: 8px; padding: 16px 18px; border: 1px solid var(--line);
border-left: 3px solid var(--neon); border-radius: 12px; background: var(--card); }
.result-box .url { word-break: break-all; font-size: .92rem; color: var(--neon); margin: 0 0 12px; }
.copy-btn { padding: 9px 20px; border-radius: 999px; border: 1px solid var(--neon);
background: transparent; color: var(--neon); font: inherit; font-weight: 700; cursor: pointer; transition: .2s; }
.copy-btn:hover { background: var(--neon); color: #001a20; }
.lock { max-width: 340px; }
small { color: var(--muted); font-weight: 400; font-size: .88rem; }
#login-err { color: #ff8a8a; font-size: .92rem; min-height: 1.4em; }
</style>
</head>
<body>
<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>
</header>
<main class="legal contact">
<h1>Nuevo <em>enlace</em> de invitado</h1>
<div id="login-panel" hidden>
<p class="lead-c">Solo para el equipo del programa.</p>
<form id="login-form" class="gen-form lock" novalidate>
<label>Contraseña
<input id="pwd" type="password" autocomplete="current-password" required>
</label>
<p id="login-err"></p>
<button class="btn primary" type="submit">Entrar</button>
</form>
</div>
<div id="gen-panel" hidden>
<p class="lead-c">Rellena el nombre del invitado y los días propuestos. Se generará un enlace que podrás enviarle.</p>
<form id="gen-form" class="gen-form" novalidate autocomplete="off">
<label>Nombre del invitado <span class="req">*</span>
<input id="nombre" type="text" maxlength="60" placeholder="Ej: Ana García" required>
<small>Tal como quieres presentarle en el programa.</small>
</label>
<div>
<div style="font-weight:600;font-size:.95rem;margin-bottom:8px">Días propuestos <span class="req">*</span></div>
<small style="display:block;margin-bottom:12px">Añade entre 1 y 5 opciones. La invitada elegirá la que mejor le venga.</small>
<div id="dias-list" class="dias-list"></div>
<button type="button" id="add-dia" class="add-dia">+ Añadir otro día</button>
</div>
<div>
<div style="font-weight:600;font-size:.95rem;margin-bottom:8px">Modo de enlace</div>
<label class="check" style="margin-bottom:8px">
<input type="radio" name="modo" value="propuestos" checked>
<span>Días propuestos — la invitada elige</span>
</label>
<label class="check">
<input type="radio" name="modo" value="fijo">
<span>Fecha fija — ya está confirmada</span>
</label>
</div>
<p id="gen-err" class="status err" hidden></p>
<button class="btn primary" type="submit">Generar enlace</button>
</form>
<div id="result" class="result-box">
<p class="url" id="result-url"></p>
<button class="copy-btn" id="copy-btn">Copiar enlace</button>
</div>
</div>
<div id="loading" style="color:var(--muted);font-size:.95rem">Cargando…</div>
</main>
<script>
(function () {
var SESSION_KEY = 'aqnsn-gen-token';
var loginPanel = document.getElementById('login-panel');
var genPanel = document.getElementById('gen-panel');
var loginForm = document.getElementById('login-form');
var loginErr = document.getElementById('login-err');
var pwd = document.getElementById('pwd');
var loading = document.getElementById('loading');
function showLogin() { loading.hidden = true; loginPanel.hidden = false; }
function showGen() { loading.hidden = true; loginPanel.hidden = true; genPanel.hidden = false; }
var stored = '';
try { stored = sessionStorage.getItem(SESSION_KEY) || ''; } catch (e) {}
if (stored) {
fetch('/api/gen-verify?t=' + encodeURIComponent(stored), { cache: 'no-store' })
.then(function (r) { return r.json(); })
.then(function (j) { j.ok ? showGen() : showLogin(); })
.catch(function () { showLogin(); });
} else {
showLogin();
}
loginForm.addEventListener('submit', function (e) {
e.preventDefault();
loginErr.textContent = 'Verificando…';
fetch('/api/gen-auth', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pass: pwd.value })
})
.then(function (r) { return r.json(); })
.then(function (j) {
if (j.ok) {
try { sessionStorage.setItem(SESSION_KEY, j.token); } catch (e) {}
showGen();
} else {
loginErr.textContent = j.error || 'Contraseña incorrecta.';
pwd.value = ''; pwd.focus();
}
})
.catch(function () { loginErr.textContent = 'Error de conexión.'; });
});
// --- Generador ---
var diasList = document.getElementById('dias-list');
var addDiaBtn = document.getElementById('add-dia');
var genForm = document.getElementById('gen-form');
var genErr = document.getElementById('gen-err');
var result = document.getElementById('result');
var resultUrl = document.getElementById('result-url');
var copyBtn = document.getElementById('copy-btn');
function addDia() {
var row = document.createElement('div');
row.className = 'dia-row';
var inp = document.createElement('input');
inp.type = 'datetime-local'; inp.className = 'dia-inp';
var del = document.createElement('button');
del.type = 'button'; del.className = 'del-dia'; del.textContent = '✕';
del.addEventListener('click', function () { row.remove(); actualizarAddBtn(); });
row.appendChild(inp); row.appendChild(del);
diasList.appendChild(row);
actualizarAddBtn();
}
function actualizarAddBtn() {
addDiaBtn.style.display = diasList.children.length >= 5 ? 'none' : '';
}
addDia();
addDiaBtn.addEventListener('click', function () { addDia(); });
genForm.addEventListener('submit', function (e) {
e.preventDefault();
genErr.hidden = true;
var nombre = document.getElementById('nombre').value.trim();
if (!nombre) { genErr.textContent = 'Escribe el nombre del invitado.'; genErr.hidden = false; return; }
var modo = genForm.querySelector('input[name="modo"]:checked').value;
var inps = Array.prototype.slice.call(diasList.querySelectorAll('input.dia-inp'));
var timestamps = [];
for (var i = 0; i < inps.length; i++) {
var v = inps[i].value;
if (!v) continue;
var ts = Math.round(new Date(v).getTime() / 1000);
if (!isNaN(ts)) timestamps.push(ts);
}
if (timestamps.length === 0) { genErr.textContent = 'Añade al menos un día.'; genErr.hidden = false; return; }
var base = location.origin + '/invitados.html';
var url;
if (modo === 'fijo') {
if (timestamps.length > 1) { genErr.textContent = 'En modo fecha fija solo puedes indicar un día.'; genErr.hidden = false; return; }
url = base + '?n=' + encodeURIComponent(nombre) + '&f=' + timestamps[0];
} else {
url = base + '?n=' + encodeURIComponent(nombre) + '&d=' + timestamps.join(',');
}
resultUrl.textContent = url;
result.style.display = 'block';
result.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
});
copyBtn.addEventListener('click', function () {
var url = resultUrl.textContent;
if (navigator.clipboard) {
navigator.clipboard.writeText(url).then(function () {
copyBtn.textContent = '¡Copiado!';
setTimeout(function () { copyBtn.textContent = 'Copiar enlace'; }, 2000);
});
} else {
var ta = document.createElement('textarea');
ta.value = url; document.body.appendChild(ta); ta.select(); document.execCommand('copy');
document.body.removeChild(ta);
copyBtn.textContent = '¡Copiado!';
setTimeout(function () { copyBtn.textContent = 'Copiar enlace'; }, 2000);
}
});
})();
</script>
</body>
</html>
+59
View File
@@ -0,0 +1,59 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Patrocinadores · Anda Que No Sabes Ná</title>
<meta name="description" content="¿Quieres que tu marca suene en Anda Que No Sabes Ná? Cuéntanos qué tienes en mente.">
<meta name="theme-color" content="#06080d">
<link rel="icon" href="img/avatar.jpg">
<link rel="stylesheet" href="styles.css">
</head>
<body>
<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="/">Inicio</a><a href="contacto.html">Contacto</a></nav>
</header>
<main class="legal contact">
<h1>¿Quieres <em>anunciarte</em> en el programa?</h1>
<p class="lead-c">Si tu marca quiere sonar en Anda Que No Sabes Ná, escríbenos. Cuéntanos quién eres, qué tienes en mente y qué te interesa: una mención, patrocinar episodios, una colaboración… Lo hablamos con calma.</p>
<form id="f" class="form" novalidate data-endpoint="/api/patrocinio" data-mail="contacto@andaquenosabesna.com" data-ok="¡Gracias! Hemos recibido tu propuesta y te escribiremos pronto.">
<label>Empresa o marca
<input name="empresa" type="text" autocomplete="organization" maxlength="120" required>
</label>
<label>Tu nombre
<input name="nombre" type="text" autocomplete="name" maxlength="100" required>
</label>
<label>Correo electrónico
<input name="email" type="email" autocomplete="email" maxlength="200" required>
</label>
<label>Web (opcional)
<input name="sitio" type="text" inputmode="url" maxlength="200" placeholder="https://" autocomplete="url">
</label>
<label>¿Qué te interesa?
<select name="tipo">
<option value="mencion">Anunciarme / que me mencionéis en el programa</option>
<option value="patrocinio">Patrocinar episodios o una temporada</option>
<option value="colab">Colaboración o canje</option>
<option value="otro">Otra propuesta</option>
</select>
</label>
<label>Cuéntanos tu propuesta
<textarea name="mensaje" rows="6" maxlength="3000" required placeholder="Qué hacéis, a quién queréis llegar, qué tenéis en mente…"></textarea>
</label>
<div class="trap" aria-hidden="true"><label>No rellenes este campo <input name="web" type="text" tabindex="-1" autocomplete="off"></label></div>
<label class="check">
<input name="consent" type="checkbox" required>
<span>He leído la <a href="privacidad.html" target="_blank" rel="noopener">política de privacidad</a> y acepto que usen mis datos para responderme.</span>
</label>
<button class="btn primary" type="submit">Enviar propuesta</button>
<p id="st" class="status" role="status" aria-live="polite"></p>
</form>
<noscript><p>Necesitas JavaScript para usar el formulario. Mientras tanto, escríbenos a <a href="mailto:contacto@andaquenosabesna.com">contacto@andaquenosabesna.com</a>.</p></noscript>
</main>
<script src="form.js"></script>
</body>
</html>
+65
View File
@@ -0,0 +1,65 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Política de privacidad · Anda Que No Sabes Ná</title>
<meta name="description" content="Política de privacidad de Anda Que No Sabes Ná y de su aplicación de publicación en YouTube, TikTok e Instagram.">
<meta name="theme-color" content="#06080d">
<link rel="icon" href="img/avatar.jpg">
<link rel="stylesheet" href="styles.css">
</head>
<body>
<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="/">Inicio</a><a href="terminos.html">Condiciones</a></nav>
</header>
<main class="legal">
<h1>Política de privacidad</h1>
<p class="upd">Última actualización: 20 de octubre de 2026</p>
<h2>1. Quién somos</h2>
<p><strong>Anda Que No Sabes Ná</strong> es un programa de pódcast y vídeo producido en Granada (España). Esta política cubre la web <a href="https://andaquenosabesna.com">andaquenosabesna.com</a> y la aplicación privada que usamos para publicar nuestros propios vídeos en YouTube, TikTok e Instagram.</p>
<p>Contacto para cualquier asunto de privacidad: <a href="mailto:contacto@andaquenosabesna.com">contacto@andaquenosabesna.com</a>.</p>
<h2>2. Esta web</h2>
<p>La web es estática. <strong>No usa cookies, no tiene analítica, no tiene formularios ni cuentas de usuario</strong> y no carga scripts, fuentes ni imágenes de terceros: las miniaturas están alojadas en este mismo sitio.</p>
<p>Como cualquier servidor web, nuestro servidor puede registrar de forma temporal la dirección IP, la fecha y la página solicitada, únicamente por seguridad y para detectar abusos. No usamos esos registros para identificar a nadie ni los compartimos.</p>
<p>Los enlaces a YouTube, TikTok e Instagram llevan a servicios de terceros, que tienen sus propias políticas de privacidad.</p>
<h2>3. Formularios de contacto, patrocinio y ficha de invitados</h2>
<p>Si nos escribes desde la página de <a href="contacto.html">contacto</a> o de <a href="patrocinadores.html">patrocinadores</a>, recogemos los datos que tú mismo introduces: nombre, correo electrónico, mensaje y motivo o tipo de colaboración y, en el formulario de patrocinio, también el nombre de la empresa y su web si la indicas. Los usamos <strong>únicamente para responderte</strong>. La base legal es tu consentimiento, que das al marcar la casilla del formulario.</p>
<p>La web no guarda los mensajes: el formulario los reenvía por correo electrónico, a través de nuestro propio servidor de correo, a la cuenta del programa. Los conservamos solo el tiempo necesario para atender tu consulta y no los cedemos a terceros ni los usamos para otra cosa. Los formularios incluyen medidas antispam que no identifican a nadie. Puedes pedirnos que borremos tus datos escribiendo al correo de contacto.</p>
<p><strong>Ficha del invitado.</strong> Si participas como invitado y rellenas la ficha (a la que se accede solo mediante un enlace personal), recogemos tu correo electrónico (para enviarte el recordatorio de la cita y la dirección del estudio), tu teléfono móvil (solo para contactarte el día de la grabación si surge algún imprevisto) y las respuestas que escribes, que pueden incluir datos sobre tu actividad profesional, tu trayectoria y, si tú decides contarlo, aspectos personales. Las usamos únicamente para preparar la entrevista y el episodio, y se envían por correo electrónico al equipo del programa; la web no las guarda. No las cedemos a terceros. La base legal es tu consentimiento, que das al aceptar las <a href="terminos.html#participacion">condiciones de participación</a> y esta política, y puedes pedirnos que las borremos en cualquier momento.</p>
<h2>4. Nuestra aplicación de publicación</h2>
<p>Usamos una aplicación de gestión de redes sociales de código abierto (Postiz), instalada en un servidor propio, para programar y publicar el contenido del programa. La aplicación está pensada para uso interno: <strong>solo la utiliza el propietario de las cuentas</strong> y no está abierta al público.</p>
<p>Para funcionar, la aplicación solicita autorización a las cuentas del propio programa y accede a:</p>
<ul>
<li><strong>YouTube / Google:</strong> información básica del perfil y del canal, y permiso para subir y gestionar vídeos y consultar estadísticas del canal propio.</li>
<li><strong>TikTok:</strong> información básica del perfil y permiso para publicar vídeos en la cuenta propia.</li>
<li><strong>Instagram:</strong> información básica del perfil y permiso para publicar contenido en la cuenta propia.</li>
</ul>
<p>Los tokens de acceso se guardan en una base de datos de nuestro servidor. <strong>No vendemos, alquilamos ni compartimos estos datos con nadie, no los usamos para publicidad ni los transferimos a terceros.</strong> Usamos los datos solo para publicar y programar el contenido del programa en sus propias cuentas.</p>
<h2>5. Servicios de API de YouTube</h2>
<p>La aplicación usa los Servicios de API de YouTube. Al utilizarla, aceptas quedar sujeto a los <a href="https://www.youtube.com/t/terms" target="_blank" rel="noopener">Términos del Servicio de YouTube</a>. La política de privacidad de Google está disponible en <a href="https://policies.google.com/privacy" target="_blank" rel="noopener">policies.google.com/privacy</a>.</p>
<p>El uso que hace la aplicación de la información recibida de las API de Google se ajusta a la <a href="https://developers.google.com/terms/api-services-user-data-policy" target="_blank" rel="noopener">Política de datos de usuario de los Servicios de API de Google</a>, incluidos los requisitos de uso limitado.</p>
<p>Puedes revocar en cualquier momento el acceso de la aplicación a tu cuenta de Google desde <a href="https://myaccount.google.com/connections" target="_blank" rel="noopener">myaccount.google.com/connections</a>.</p>
<h2>6. TikTok e Instagram</h2>
<p>El acceso a TikTok se rige también por los <a href="https://www.tiktok.com/legal/page/row/terms-of-service/es" target="_blank" rel="noopener">Términos del Servicio de TikTok</a> y su política de privacidad. El acceso a Instagram, por las condiciones y la política de datos de Meta. Puedes revocar el acceso de la aplicación desde la configuración de seguridad de cada plataforma.</p>
<h2>7. Conservación y eliminación</h2>
<p>Conservamos los tokens solo mientras la cuenta esté conectada. Si desconectamos una cuenta o revocas el acceso, el token deja de ser válido y se elimina de nuestra base de datos. Si quieres que borremos cualquier dato asociado a ti, escríbenos al correo de contacto.</p>
<h2>8. Tus derechos</h2>
<p>Conforme al Reglamento General de Protección de Datos (RGPD) y a la normativa española, puedes ejercer tus derechos de acceso, rectificación, supresión, oposición, limitación y portabilidad escribiendo a <a href="mailto:contacto@andaquenosabesna.com">contacto@andaquenosabesna.com</a>. También puedes reclamar ante la Agencia Española de Protección de Datos (<a href="https://www.aepd.es" target="_blank" rel="noopener">aepd.es</a>).</p>
<h2>9. Cambios</h2>
<p>Si cambiamos esta política, publicaremos la nueva versión en esta misma página con su fecha de actualización.</p>
</main>
</body>
</html>
+258
View File
@@ -0,0 +1,258 @@
:root {
--bg: #06080d;
--bg2: #0b0f18;
--card: #0e1420;
--line: #1b2535;
--ink: #eaf6ff;
--muted: #8fa3b8;
--neon: #2ee6ff;
--neon-d: #12a9c2;
--amber: #ffb45a;
--radius: 16px;
--glow: 0 0 6px rgba(46, 230, 255, .8), 0 0 22px rgba(46, 230, 255, .45), 0 0 60px rgba(46, 230, 255, .25);
}
* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body {
margin: 0;
background: var(--bg);
color: var(--ink);
font: 16px/1.6 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
-webkit-font-smoothing: antialiased;
}
img { max-width: 100%; display: block; }
a { color: inherit; text-decoration: none; }
.wrap { width: min(1120px, 100% - 32px); margin-inline: auto; }
.skip { position: absolute; left: -999px; top: 8px; background: var(--neon); color: #001; padding: 8px 14px; border-radius: 8px; z-index: 99; }
.skip:focus { left: 8px; }
:focus-visible { outline: 2px solid var(--neon); outline-offset: 3px; border-radius: 6px; }
/* header */
.top {
position: fixed; inset: 0 0 auto 0; z-index: 20;
display: flex; align-items: center; justify-content: space-between; gap: 16px;
padding: 10px max(16px, calc((100% - 1120px) / 2));
background: linear-gradient(#06080dd9, #06080d00);
backdrop-filter: blur(6px);
}
.brand { display: flex; align-items: center; gap: 10px; font-weight: 600; letter-spacing: .02em; }
.brand img { aspect-ratio: 1 / 1; object-fit: cover; border-radius: 50%; box-shadow: 0 0 14px rgba(46, 230, 255, .5); }
.top nav { display: flex; gap: 22px; font-size: .95rem; color: var(--muted); }
.top nav a:hover { color: var(--neon); }
.top nav .navcta { color: var(--neon); border: 1px solid rgba(46, 230, 255, .4); padding: 2px 14px; border-radius: 999px; }
@media (max-width: 640px) { .brand span { display: none; } .top nav { gap: 16px; } }
/* hero */
.hero {
position: relative; min-height: 100svh; display: grid; place-items: center; text-align: center;
overflow: hidden; padding: 110px 16px 80px;
}
.hero-bg {
position: absolute; inset: 0;
background:
radial-gradient(60% 50% at 50% 38%, rgba(46, 230, 255, .18), transparent 70%),
linear-gradient(180deg, rgba(6, 8, 13, .60) 0%, var(--bg) 100%);
}
@media (max-width: 760px) {
.hero-bg {
background:
radial-gradient(70% 38% at 50% 40%, rgba(46, 230, 255, .16), transparent 70%),
linear-gradient(180deg, rgba(6, 8, 13, .60) 0%, var(--bg) 60%);
}
}
@keyframes drift { to { transform: scale(1.08) translateY(-1%); } }
.hero-inner { position: relative; max-width: 760px; }
.neon {
width: clamp(130px, 22vw, 190px); height: auto; aspect-ratio: 1 / 1; object-fit: cover; margin: 0 auto 18px; border-radius: 50%;
border: 1px solid rgba(46, 230, 255, .35);
animation: flicker 6s infinite;
}
@keyframes flicker {
0%, 18%, 22%, 62%, 64%, 100% { opacity: 1; box-shadow: 0 0 22px rgba(46, 230, 255, .55), 0 0 70px rgba(46, 230, 255, .3); }
20%, 63% { opacity: .8; box-shadow: 0 0 8px rgba(46, 230, 255, .3), 0 0 24px rgba(46, 230, 255, .12); }
}
.kicker { margin: 0 0 10px; text-transform: uppercase; letter-spacing: .28em; font-size: .78rem; color: var(--amber); }
h1 { margin: 0 0 18px; font-size: clamp(2rem, 6.4vw, 4rem); line-height: 1.08; letter-spacing: -.02em; }
h1 em { font-style: normal; color: var(--neon); text-shadow: var(--glow); }
.lead { margin: 0 auto 30px; max-width: 520px; color: #c3d3e3; font-size: 1.1rem; }
.cta { display: flex; gap: 12px; justify-content: center; flex-wrap: wrap; }
.btn {
padding: 13px 26px; border-radius: 999px; border: 1px solid var(--line); background: #0e1420b3;
font-weight: 600; transition: .25s;
}
.btn:hover { border-color: var(--neon); color: var(--neon); transform: translateY(-2px); }
.btn.primary { background: var(--neon); color: #001a20; border-color: var(--neon); box-shadow: 0 0 24px rgba(46, 230, 255, .35); }
.btn.primary:hover { color: #001a20; box-shadow: 0 0 38px rgba(46, 230, 255, .6); }
.scroll { position: absolute; bottom: 24px; left: 50%; width: 26px; height: 42px; margin-left: -13px; border: 2px solid #ffffff55; border-radius: 14px; }
.scroll span { position: absolute; left: 50%; top: 8px; width: 4px; height: 8px; margin-left: -2px; border-radius: 2px; background: var(--neon); animation: wheel 1.8s infinite; }
@keyframes wheel { 0% { opacity: 1; transform: translateY(0); } 100% { opacity: 0; transform: translateY(14px); } }
/* sections */
section { padding-block: 56px; }
h2 { margin: 0 0 22px; font-size: clamp(1.5rem, 3.6vw, 2.2rem); letter-spacing: -.01em; }
h2 em { font-style: normal; color: var(--neon); text-shadow: 0 0 18px rgba(46, 230, 255, .5); }
.sec-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
.more { color: var(--muted); font-size: .95rem; }
.more:hover { color: var(--neon); }
/* thumbs */
.thumb { position: relative; display: block; overflow: hidden; border-radius: 12px; aspect-ratio: 16 / 9; background: #000; }
.thumb img { width: 100%; height: 100%; object-fit: cover; transform: scale(var(--z, 1)); transition: transform .5s; }
.thumb img.short { --z: 2.15; transform-origin: 50% 32%; }
.play {
position: absolute; inset: 0; display: grid; place-items: center; opacity: 0; transition: .3s;
background: radial-gradient(circle, rgba(6, 8, 13, .1), rgba(6, 8, 13, .55));
}
.play::after {
content: ""; width: 54px; height: 54px; border-radius: 50%;
background: var(--neon) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23001a20' d='M9 6.5v11l9-5.5z'/%3E%3C/svg%3E") center / 60% no-repeat;
box-shadow: var(--glow);
}
.play.big::after { width: 76px; height: 76px; }
/* featured */
.feat {
display: grid; grid-template-columns: 1.3fr 1fr; gap: clamp(18px, 4vw, 40px); align-items: center;
padding: clamp(14px, 2.4vw, 22px); border: 1px solid var(--line); border-radius: 22px;
background: linear-gradient(135deg, var(--card), var(--bg2));
box-shadow: 0 30px 80px -40px rgba(46, 230, 255, .25);
}
.feat-text { display: grid; gap: 10px; }
.tag { justify-self: start; padding: 4px 12px; border-radius: 999px; font-size: .78rem; letter-spacing: .12em; text-transform: uppercase; color: #001a20; background: var(--amber); font-weight: 700; }
.feat-text strong { font-size: clamp(1.25rem, 2.8vw, 1.9rem); line-height: 1.2; }
.feat-text time, .meta time { color: var(--muted); font-size: .88rem; }
.go { color: var(--neon); font-weight: 600; }
.go i { display: inline-block; transition: transform .25s; font-style: normal; }
.feat:hover .go i { transform: translateX(6px); }
.feat:hover img, .card:hover img { transform: scale(calc(var(--z, 1) * 1.06)); }
.feat:hover .play, .card:hover .play { opacity: 1; }
@media (max-width: 760px) { .feat { grid-template-columns: 1fr; } }
/* grid */
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 22px; }
.card {
display: grid; gap: 12px; padding: 10px 10px 16px; border-radius: var(--radius);
border: 1px solid transparent; transition: .3s; background: transparent;
}
.card:hover { background: var(--card); border-color: var(--line); transform: translateY(-4px); box-shadow: 0 18px 40px -22px rgba(46, 230, 255, .35); }
.meta { display: grid; gap: 4px; padding-inline: 4px; }
.meta strong { font-size: 1rem; line-height: 1.3; font-weight: 600; }
/* about */
.about { max-width: none; }
.about p { color: #c3d3e3; font-size: 1.1rem; max-width: 780px; }
.chips { list-style: none; display: flex; flex-wrap: wrap; gap: 10px; padding: 0; margin: 26px 0 0; }
.chips li { padding: 7px 16px; border-radius: 999px; border: 1px solid var(--line); background: var(--card); color: var(--muted); font-size: .92rem; transition: .25s; }
.chips li:hover { border-color: var(--neon); color: var(--neon); box-shadow: 0 0 16px rgba(46, 230, 255, .25); }
/* follow */
.socials { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 16px; }
.socials a {
display: grid; gap: 2px; padding: 22px 24px; border-radius: var(--radius);
border: 1px solid var(--line); background: linear-gradient(135deg, var(--card), var(--bg2)); transition: .3s;
}
.socials a:hover { border-color: var(--neon); transform: translateY(-4px); box-shadow: 0 0 28px rgba(46, 230, 255, .25); }
.socials b { font-size: 1.15rem; }
.socials span { color: var(--muted); font-size: .92rem; }
/* footer */
footer { margin-top: 40px; border-top: 1px solid var(--line); background: var(--bg2); padding-block: 28px; color: var(--muted); font-size: .92rem; }
.foot { display: flex; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
.foot p { margin: 0; }
.links { display: flex; gap: 20px; flex-wrap: wrap; }
.links a:hover { color: var(--neon); }
/* reveal */
.reveal { opacity: 0; transform: translateY(18px); transition: opacity .7s ease var(--d, 0ms), transform .7s ease var(--d, 0ms); }
.reveal.in { opacity: 1; transform: none; }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation: none !important; transition: none !important; }
.reveal { opacity: 1; transform: none; }
html { scroll-behavior: auto; }
}
/* páginas legales */
.legal { max-width: 760px; margin: 0 auto; padding: 120px 16px 60px; }
.legal h1 { font-size: clamp(1.8rem, 5vw, 2.6rem); text-align: left; }
.legal h2 { margin-top: 36px; font-size: 1.3rem; color: var(--neon); }
.legal p, .legal li { color: #c3d3e3; }
.legal a { color: var(--neon); text-decoration: underline; text-underline-offset: 3px; }
.legal .upd { color: var(--muted); font-size: .9rem; }
/* contacto */
.contact h1 em { font-style: normal; color: var(--neon); text-shadow: var(--glow); }
.lead-c { color: #c3d3e3; font-size: 1.08rem; margin-bottom: 28px; }
.form { display: grid; gap: 18px; }
.form label { display: grid; gap: 6px; font-weight: 600; font-size: .95rem; }
.form input[type=text], .form input[type=email], .form input[type=tel], .form select, .form textarea {
width: 100%; padding: 13px 15px; border-radius: 12px; border: 1px solid var(--line);
background: var(--card); color: var(--ink); font: inherit; font-weight: 400; transition: .2s;
}
.form textarea { resize: vertical; min-height: 140px; }
.form input:focus, .form select:focus, .form textarea:focus { outline: none; border-color: var(--neon); box-shadow: 0 0 0 3px rgba(46, 230, 255, .18); }
.form .check, .guest .check { display: flex; align-items: flex-start; gap: 10px; font-weight: 400; color: var(--muted); font-size: .92rem; }
.form .check input, .guest .check input { margin-top: 4px; accent-color: var(--neon); width: 18px; height: 18px; flex: none; }
.form .check a, .guest .check a { color: var(--neon); text-decoration: underline; text-underline-offset: 3px; }
.form .btn { justify-self: start; cursor: pointer; font: inherit; font-weight: 700; }
.trap { position: absolute; left: -9999px; width: 1px; height: 1px; overflow: hidden; }
.status { min-height: 1.4em; margin: 0; font-size: .95rem; }
.status.ok { color: #6ee7a8; }
.status.err { color: #ff8a8a; }
/* quiénes somos + invitación */
.who { max-width: none; }
.who > p { color: #c3d3e3; font-size: 1.1rem; max-width: 720px; }
.team { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 16px; margin-top: 26px; }
.person { padding: 22px 24px; border-radius: var(--radius); border: 1px solid var(--line); background: linear-gradient(135deg, var(--card), var(--bg2)); transition: .3s; }
.person:hover { border-color: var(--neon); box-shadow: 0 0 24px rgba(46, 230, 255, .2); }
.person b { display: block; font-size: 1.3rem; color: var(--neon); text-shadow: 0 0 14px rgba(46, 230, 255, .4); }
.person em { display: block; margin: 2px 0 10px; font-style: normal; font-size: .82rem; letter-spacing: .14em; text-transform: uppercase; color: var(--amber); }
.person span { display: block; color: #b4c4d4; font-size: .98rem; line-height: 1.55; }
.invite {
display: flex; align-items: center; justify-content: space-between; gap: 20px; flex-wrap: wrap;
padding: clamp(22px, 4vw, 38px); border-radius: 22px; border: 1px solid var(--line);
background: radial-gradient(80% 140% at 0% 0%, rgba(46, 230, 255, .12), transparent 60%), linear-gradient(135deg, var(--card), var(--bg2));
}
.invite h2 { margin: 0 0 4px; }
.invite p { margin: 0; color: #c3d3e3; }
/* shorts */
.shorts { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 16px; }
.scard { display: grid; gap: 8px; align-content: start; transition: transform .3s; }
.scard:hover { transform: translateY(-4px); }
.scard .thumb { aspect-ratio: 9 / 16; border-radius: 14px; }
.scard .meta strong { font-size: .88rem; line-height: 1.3; font-weight: 600; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.scard:hover img { transform: scale(1.06); }
.scard:hover .play { opacity: 1; }
.scard .play::after { width: 44px; height: 44px; }
/* lo más visto */
.sub { margin: 26px 0 14px; font-size: .8rem; letter-spacing: .22em; text-transform: uppercase; color: var(--amber); font-weight: 700; }
.scard .meta time { color: var(--muted); font-size: .8rem; }
/* contador de vistas: misma etiqueta que "Último vídeo" (fondo naranja, texto oscuro) */
.views {
position: absolute; left: 10px; bottom: 10px; z-index: 2;
display: inline-flex; align-items: center; gap: 6px; padding: 3px 11px 3px 9px;
border-radius: 999px; background: var(--amber); color: #001a20;
font-size: .78rem; font-weight: 700; letter-spacing: .04em; line-height: 1.5;
box-shadow: 0 2px 12px rgba(0, 0, 0, .45); font-variant-numeric: tabular-nums;
}
.views svg { flex: none; }
.views b { font-weight: 700; }
.scard .views { left: 6px; bottom: 6px; padding: 2px 9px 2px 7px; font-size: .72rem; }
.feat .views { left: 14px; bottom: 14px; font-size: .9rem; padding: 4px 13px 4px 11px; }
/* ficha del invitado */
[hidden] { display: none !important; }
.guest .sec { display: grid; gap: 18px; margin: 0 0 22px; padding: 20px 22px 24px; border: 1px solid var(--line); border-radius: 16px; background: linear-gradient(135deg, var(--card), var(--bg2)); }
.guest .sec legend { padding: 0 10px; font-weight: 700; font-size: 1.1rem; color: var(--neon); }
.guest .form small { display: block; margin-top: 2px; color: var(--muted); font-weight: 400; }
.guest .note, .note { margin: 0; color: var(--muted); font-size: .92rem; }
.guest .note a { color: var(--neon); text-decoration: underline; text-underline-offset: 3px; }
.req { color: var(--amber); font-weight: 700; }
.cita { margin: 0 0 14px; padding: 12px 16px; border: 1px solid var(--line); border-left: 3px solid var(--amber); border-radius: 12px; background: var(--card); font-weight: 600; }
.paso { margin: 0; color: var(--muted); font-size: .9rem; }
.guest .nav { display: flex; justify-content: space-between; gap: 12px; margin-top: 4px; }
.guest .nav .next, .guest .nav button[type=submit] { margin-left: auto; }
.btn.ghost { background: transparent; color: var(--muted); }
+67
View File
@@ -0,0 +1,67 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Condiciones del servicio · Anda Que No Sabes Ná</title>
<meta name="description" content="Condiciones del servicio de Anda Que No Sabes Ná y de su aplicación de publicación.">
<meta name="theme-color" content="#06080d">
<link rel="icon" href="img/avatar.jpg">
<link rel="stylesheet" href="styles.css">
</head>
<body>
<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="/">Inicio</a><a href="privacidad.html">Privacidad</a></nav>
</header>
<main class="legal">
<h1>Condiciones del servicio</h1>
<p class="upd">Última actualización: 20 de octubre de 2026</p>
<h2>1. Objeto</h2>
<p>Estas condiciones regulan el uso de la web <a href="https://andaquenosabesna.com">andaquenosabesna.com</a>, del programa <strong>Anda Que No Sabes Ná</strong> y de la aplicación privada con la que publicamos nuestro contenido en YouTube, TikTok e Instagram. Al usar la web aceptas estas condiciones.</p>
<h2>2. La web</h2>
<p>La web es informativa: presenta el programa y enlaza a nuestros vídeos y redes sociales. No requiere registro y no ofrece servicios de pago. Intentamos que la información sea correcta, pero puede cambiar o contener errores.</p>
<h2>3. La aplicación de publicación</h2>
<p>La aplicación es una herramienta de uso interno, instalada en un servidor propio, que sirve para programar y publicar el contenido del programa en las cuentas del propio programa. <strong>No está abierta al público ni ofrece a terceros ninguna funcionalidad.</strong> El tratamiento de datos asociado se describe en la <a href="privacidad.html">política de privacidad</a>.</p>
<h2>4. Servicios de terceros</h2>
<p>La publicación se realiza a través de plataformas de terceros, cuyas condiciones se aplican además de estas:</p>
<ul>
<li><a href="https://www.youtube.com/t/terms" target="_blank" rel="noopener">Términos del Servicio de YouTube</a></li>
<li><a href="https://www.tiktok.com/legal/page/row/terms-of-service/es" target="_blank" rel="noopener">Términos del Servicio de TikTok</a></li>
<li>Condiciones de uso de Instagram (Meta)</li>
</ul>
<h2 id="participacion">5. Condiciones de participación de invitados</h2>
<p>Estas condiciones se aplican a las personas que, tras ser invitadas, participan en el programa <strong>Anda Que No Sabes Ná</strong> y rellenan la <em>ficha del invitado</em>. Se aceptan marcando la casilla correspondiente al final de dicha ficha.</p>
<ul>
<li><strong>Proyecto altruista y colaborativo.</strong> El programa se realiza de forma altruista y colaborativa, con la intención de compartir conocimiento, experiencias e historias.</li>
<li><strong>Sin remuneración económica.</strong> Ni la preparación, ni la grabación, ni la publicación del episodio contemplan remuneración económica.</li>
<li><strong>Participación voluntaria y de buena fe.</strong> La participación es voluntaria y se realiza de buena fe.</li>
<li><strong>Ficha previa.</strong> La ficha sirve para conocer al invitado antes de la grabación y encontrar historias, preguntas y temas sobre los que merezca la pena hablar. No es necesario preparar respuestas perfectas, y las secciones indicadas como opcionales pueden dejarse en blanco.</li>
<li><strong>Límites temáticos.</strong> En la ficha, el invitado puede indicar los temas, personas, empresas o asuntos sobre los que prefiere que no se hable, sin necesidad de justificarlo. El equipo del programa tendrá en cuenta estas indicaciones al preparar la entrevista.</li>
<li><strong>Aceptación.</strong> Al marcar la casilla de aceptación y enviar la ficha, el invitado declara haber leído y entendido estas condiciones y estar de acuerdo con ellas.</li>
</ul>
<p>El tratamiento de los datos que se faciliten en la ficha se describe en la <a href="privacidad.html">política de privacidad</a>. Para cualquier duda sobre estas condiciones, escríbenos a <a href="mailto:contacto@andaquenosabesna.com">contacto@andaquenosabesna.com</a>.</p>
<h2>6. Propiedad intelectual</h2>
<p>Los vídeos, textos, imágenes, el nombre y el logotipo de Anda Que No Sabes Ná pertenecen a sus autores y a las personas que participan en el programa. No está permitido reproducirlos, distribuirlos ni usarlos con fines comerciales sin autorización previa por escrito. Puedes compartir los enlaces a nuestros vídeos libremente.</p>
<h2>7. Enlaces externos</h2>
<p>La web contiene enlaces a sitios de terceros. No controlamos su contenido ni somos responsables de sus políticas o prácticas.</p>
<h2>8. Limitación de responsabilidad</h2>
<p>La web se ofrece «tal cual», sin garantías de disponibilidad continua. En la medida permitida por la ley, no seremos responsables de los daños derivados de su uso o de la imposibilidad de acceder a ella. Las opiniones expresadas en el programa son de quienes las dicen.</p>
<h2>9. Cambios</h2>
<p>Podemos actualizar estas condiciones. La versión vigente es la publicada en esta página, con su fecha de actualización.</p>
<h2>10. Ley aplicable y contacto</h2>
<p>Estas condiciones se rigen por la legislación española. Para cualquier duda, escríbenos a <a href="mailto:contacto@andaquenosabesna.com">contacto@andaquenosabesna.com</a>.</p>
</main>
</body>
</html>
@@ -0,0 +1 @@
tiktok-developers-site-verification=t8ZeNlFqbY0yUukJkPfgjKdxPkFxoOg6
+363
View File
@@ -0,0 +1,363 @@
"""Endpoint del formulario de contacto: POST /api/contacto -> correo por SMTP.
Configuración solo por variables de entorno (nunca en el repo):
SMTP_HOST, SMTP_PORT (587), SMTP_USER, SMTP_PASS, SMTP_TLS_NAME (nombre del certificado, por defecto SMTP_HOST),
MAIL_FROM, MAIL_TO
No guarda los mensajes en disco: solo los reenvía por correo.
"""
import datetime
from zoneinfo import ZoneInfo
import hmac as _hmac
import json
import os
from urllib.parse import urlparse, parse_qs, quote
import re
import smtplib
import ssl
import sys
import time
from collections import defaultdict, deque
from email.message import EmailMessage
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
MOTIVOS = {
'venir': 'Quiero venir al programa',
'tema': 'Propongo un tema',
'otro': 'Otro asunto',
}
TIPOS = {
'mencion': 'Anunciarme / que me mencionéis en el programa',
'patrocinio': 'Patrocinar episodios o una temporada',
'colab': 'Colaboración o canje',
'otro': 'Otra propuesta',
}
EMAIL_RE = re.compile(r'^[^@\s]{1,64}@[^@\s]{1,190}\.[^@\s]{2,}$')
MAX_BODY = 16 * 1024
RATE_MAX, RATE_WINDOW = 5, 3600 # 5 mensajes por IP y hora
hits = defaultdict(deque)
def clean(s, n):
return re.sub(r'[\r\n\t]+', ' ', str(s or '')).strip()[:n]
def rate_ok(ip):
now = time.time()
q = hits[ip]
while q and now - q[0] > RATE_WINDOW:
q.popleft()
if len(q) >= RATE_MAX:
return False
q.append(now)
return True
def send(subject, reply_to, body, to=None):
host, user, pwd = os.environ.get('SMTP_HOST'), os.environ.get('SMTP_USER'), os.environ.get('SMTP_PASS')
if not (host and user and pwd and os.environ.get('MAIL_TO')):
raise RuntimeError('SMTP no configurado')
msg = EmailMessage()
msg['Subject'] = subject
msg['From'] = os.environ.get('MAIL_FROM', user)
msg['To'] = to or os.environ['MAIL_TO']
if reply_to:
msg['Reply-To'] = reply_to
msg.set_content(body)
s = smtplib.SMTP(host, int(os.environ.get('SMTP_PORT', '587')), timeout=20)
try:
s.ehlo()
s._host = os.environ.get('SMTP_TLS_NAME', host) # el certificado se valida contra este nombre
s.starttls(context=ssl.create_default_context())
s.ehlo()
s.login(user, pwd)
s.send_message(msg)
finally:
try:
s.quit()
except Exception:
pass
def load_guest():
"""Preguntas de la ficha (copia del formulario de Google) -> [{id, sec, t, req}]."""
p = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'guest_form.json')
try:
form = json.load(open(p, encoding='utf-8'))
except Exception:
return []
out, sec, n = [], '', 0
for it in form['items']:
if it['type'] == 8:
sec = it['title']
elif it['type'] in (0, 1):
n += 1
out.append({'id': f'q{n:02d}', 'sec': sec, 't': it['title'], 'req': it['required']})
return out
GUEST_Q = load_guest()
check_hits = defaultdict(deque)
guest_hits = defaultdict(deque)
def limited(bucket, ip, maxn, window=3600):
now = time.time()
q = bucket[ip]
while q and now - q[0] > window:
q.popleft()
if len(q) >= maxn:
return True
q.append(now)
return False
confirmados = set()
DIAS = ['lunes', 'martes', 'miércoles', 'jueves', 'viernes', 'sábado', 'domingo']
MESES = ['enero', 'febrero', 'marzo', 'abril', 'mayo', 'junio', 'julio', 'agosto', 'septiembre', 'octubre', 'noviembre', 'diciembre']
def cita_txt(ts):
d = datetime.datetime.fromtimestamp(ts, ZoneInfo('Europe/Madrid'))
return f'{DIAS[d.weekday()]} {d.day} de {MESES[d.month - 1]} de {d.year}, a las {d:%H:%M}'
def cita_corta(ts):
d = datetime.datetime.fromtimestamp(ts, ZoneInfo('Europe/Madrid'))
return f'el {DIAS[d.weekday()]} {d.day} de {MESES[d.month - 1]} a las {d:%H:%M}'
NAME_RE = re.compile(r"^[^\W\d_][\w .'\-]{1,59}$", re.UNICODE)
def invitado(n, f):
"""Enlace = nombre + fecha de grabación como marca de tiempo Unix (segundos). Válido de 14 días antes a 10 días después."""
n = ' '.join(str(n or '').split())
if not NAME_RE.match(n):
return None
try:
ts = int(str(f).strip())
dia = datetime.datetime.fromtimestamp(ts, datetime.timezone.utc).date()
except (ValueError, OverflowError, OSError):
return None
delta = (dia - datetime.date.today()).days
return (n, ts) if -10 <= delta <= 14 else None
def _gen_token(offset=0):
"""Token HMAC diario para la página generadora. Válido 48h (slot actual + anterior)."""
key = (os.environ.get('LINK_PASS') or '').encode()
if not key:
return None
slot = str(int(time.time()) // 86400 - offset).encode()
return _hmac.new(key, slot, 'sha256').hexdigest()
def parse_dias(d_str):
"""Valida una lista de timestamps separados por comas. Devuelve lista de ints o None."""
if not d_str:
return None
parts = str(d_str).replace(' ', '').split(',')
if not 1 <= len(parts) <= 10:
return None
today = datetime.date.today()
result = []
for p in parts:
try:
ts = int(p)
day = datetime.datetime.fromtimestamp(ts, datetime.timezone.utc).date()
if not (-1 <= (day - today).days <= 60):
return None
result.append(ts)
except (ValueError, OverflowError, OSError):
return None
return result if result else None
class H(BaseHTTPRequestHandler):
server_version = 'aqnsn'
def log_message(self, fmt, *a): # sin IPs ni contenido en los logs
pass
def reply(self, code, obj):
body = json.dumps(obj).encode()
self.send_response(code)
self.send_header('Content-Type', 'application/json')
self.send_header('Content-Length', str(len(body)))
self.send_header('Cache-Control', 'no-store')
self.end_headers()
self.wfile.write(body)
def client_ip(self):
return (self.headers.get('X-Forwarded-For') or self.client_address[0]).split(',')[0].strip()
def do_GET(self):
path = urlparse(self.path).path
if path == '/api/gen-verify':
tok = parse_qs(urlparse(self.path).query).get('t', [''])[0]
valid = tok and tok in filter(None, [_gen_token(0), _gen_token(1)])
return self.reply(200, {'ok': bool(valid)})
if path != '/api/invitados/check':
return self.reply(404, {'ok': False})
if limited(check_hits, self.client_ip(), 40):
return self.reply(429, {'ok': False})
q = parse_qs(urlparse(self.path).query)
n_raw = q.get('n', [''])[0]
f_raw = q.get('f', [''])[0]
d_raw = q.get('d', [''])[0]
if f_raw:
inv = invitado(n_raw, f_raw)
return self.reply(200, {'ok': True, 'nombre': inv[0], 'cita': inv[1]} if inv else {'ok': False})
if d_raw:
n = ' '.join(str(n_raw or '').split())
if not n or not NAME_RE.match(n):
return self.reply(200, {'ok': False})
dias = parse_dias(d_raw)
if not dias:
return self.reply(200, {'ok': False})
return self.reply(200, {'ok': True, 'nombre': n,
'dias': [{'ts': ts, 'txt': cita_txt(ts)} for ts in dias]})
return self.reply(200, {'ok': False})
def invitados(self):
try:
n = int(self.headers.get('Content-Length', '0'))
if n <= 0 or n > 128 * 1024:
return self.reply(413, {'ok': False, 'error': 'La ficha es demasiado larga.'})
d = json.loads(self.rfile.read(n))
except Exception:
return self.reply(400, {'ok': False, 'error': 'Petición no válida.'})
if d.get('web') or (isinstance(d.get('t'), (int, float)) and d['t'] < 20000):
return self.reply(200, {'ok': True}) # trampa o demasiado rápido: no se envía
if limited(guest_hits, self.client_ip(), 8):
return self.reply(429, {'ok': False, 'error': 'Demasiados envíos. Inténtalo más tarde.'})
f_raw = str(d.get('f') or '').strip()
d_raw = str(d.get('d') or '').strip()
dia_raw = str(d.get('dia') or '').strip()
if f_raw:
inv = invitado(d.get('n'), f_raw)
nombre, ts = inv if inv else (None, None)
elif d_raw and dia_raw:
n_val = ' '.join(str(d.get('n') or '').split())
if not n_val or not NAME_RE.match(n_val):
nombre, ts = None, None
else:
dias = parse_dias(d_raw)
try:
dia_ts = int(dia_raw)
except (ValueError, TypeError):
dia_ts = None
if dias and dia_ts is not None and dia_ts in dias:
nombre, ts = n_val, dia_ts
else:
nombre, ts = None, None
else:
nombre, ts = None, None
if not nombre:
return self.reply(403, {'ok': False, 'error': 'Enlace no válido o caducado.'})
if d.get('consent') is not True:
return self.reply(400, {'ok': False, 'error': 'Debes aceptar las condiciones de participación.'})
resp = d.get('resp') if isinstance(d.get('resp'), dict) else {}
email = clean(d.get('email'), 200)
if not email or not EMAIL_RE.match(email):
return self.reply(400, {'ok': False, 'error': 'El correo no parece válido.'})
tel = ' '.join(str(d.get('tel') or '').split())
if not re.match(r'^\+?[\d ().-]{7,20}$', tel):
return self.reply(400, {'ok': False, 'error': 'El teléfono no parece válido.'})
lineas, seccion = [], None
for q in GUEST_Q:
ans = str(resp.get(q['id']) or '').replace('\r', '').strip()[:3000]
if q['req'] and not ans:
return self.reply(400, {'ok': False, 'error': 'Faltan respuestas obligatorias.'})
if q['sec'] != seccion:
seccion = q['sec']
lineas += ['', f'== {seccion} ==']
lineas += ['', f"• {q['t']}", ' ' + (ans.replace('\n', '\n ') if ans else '(sin respuesta)')]
cabecera = [f'Ficha del invitado: {nombre}', 'Recibida: ' + time.strftime('%d/%m/%Y %H:%M', time.localtime()),
f'Cita: {cita_txt(ts)}', f'Correo del invitado: {email}', f'Teléfono móvil: {tel}',
'Aceptó las condiciones de participación y la política de privacidad: sí']
try:
send(f'[Invitado] {clean(nombre, 80)} · ficha del podcast', email, '\n'.join(cabecera + lineas) + '\n')
except RuntimeError:
return self.reply(503, {'ok': False, 'error': 'El formulario no está disponible ahora mismo.'})
except Exception as exc:
print('error enviando ficha:', type(exc).__name__, file=sys.stderr, flush=True)
return self.reply(502, {'ok': False, 'error': 'No se pudo enviar. Inténtalo más tarde.'})
key = (nombre, ts)
lugar = os.environ.get('STUDIO_ADDRESS', '').strip()
mapa = os.environ.get('STUDIO_MAP', '').strip() or 'https://www.google.com/maps/search/?api=1&query=' + quote(lugar) # solo en el servidor, nunca en el código
if key not in confirmados: # un solo recordatorio por invitado y cita
confirmados.add(key)
try:
send(f'¡Grabamos {cita_corta(ts)}!', None,
f'Hola, {nombre}:\n\n¡Ya tenemos tu ficha, gracias! Ahora toca lo bueno: grabamos {cita_corta(ts)}.\n\n'
+ (f'Nos vemos en {lugar}.\nCómo llegar: {mapa}\n\n' if lugar else 'En breve te mandamos por correo electrónico la dirección del estudio.\n\n') +
'Ven con ganas de contar historias, que de lo demás nos encargamos nosotros. '
'Si te surge cualquier imprevisto, responde a este correo y lo arreglamos.\n\n'
'Un abrazo,\nÁlex y Juan\nAnda Que No Sabes Ná\nhttps://andaquenosabesna.com\n', to=email)
except Exception as exc:
confirmados.discard(key)
print('error enviando recordatorio:', type(exc).__name__, file=sys.stderr, flush=True)
self.reply(200, {'ok': True})
def do_POST(self):
p = self.path.split('?')[0]
if p == '/api/gen-auth':
try:
n = int(self.headers.get('Content-Length', '0'))
d = json.loads(self.rfile.read(n)) if 0 < n <= 1024 else {}
except Exception:
return self.reply(400, {'ok': False})
link_pass = os.environ.get('LINK_PASS', '')
if not link_pass:
return self.reply(503, {'ok': False, 'error': 'No configurado.'})
if _hmac.compare_digest(str(d.get('pass', '')), link_pass):
return self.reply(200, {'ok': True, 'token': _gen_token(0)})
return self.reply(401, {'ok': False, 'error': 'Contraseña incorrecta.'})
if p == '/api/invitados':
return self.invitados()
if self.path not in ('/api/contacto', '/api/patrocinio'):
return self.reply(404, {'ok': False})
patro = self.path == '/api/patrocinio'
try:
n = int(self.headers.get('Content-Length', '0'))
if n <= 0 or n > MAX_BODY:
return self.reply(413, {'ok': False, 'error': 'Mensaje demasiado largo.'})
d = json.loads(self.rfile.read(n))
except Exception:
return self.reply(400, {'ok': False, 'error': 'Petición no válida.'})
ip = (self.headers.get('X-Forwarded-For') or self.client_address[0]).split(',')[0].strip()
# antispam: campo trampa relleno o envío en menos de 3 s => se responde "ok" sin enviar
if d.get('web') or (isinstance(d.get('t'), (int, float)) and d['t'] < 3000):
return self.reply(200, {'ok': True})
if not rate_ok(ip):
return self.reply(429, {'ok': False, 'error': 'Demasiados mensajes. Inténtalo más tarde.'})
nombre, email = clean(d.get('nombre'), 100), clean(d.get('email'), 200)
mensaje = str(d.get('mensaje') or '').replace('\r', '').strip()[:3000]
bad = {'ok': False, 'error': 'Revisa los campos del formulario.'}
if not nombre or not EMAIL_RE.match(email) or len(mensaje) < 10 or d.get('consent') is not True:
return self.reply(400, bad)
if patro:
empresa, sitio = clean(d.get('empresa'), 120), clean(d.get('sitio'), 200)
tipo = d.get('tipo') if d.get('tipo') in TIPOS else 'otro'
if not empresa or (sitio and not re.match(r'^https?://', sitio)):
return self.reply(400, bad)
subject = f'[Web AQNSN · Patrocinio] {empresa} · {nombre}'
body = (f'Empresa: {empresa}\nContacto: {nombre}\nEmail: {email}\nWeb: {sitio or "—"}\n'
f'Interés: {TIPOS[tipo]}\n\n{mensaje}\n')
else:
motivo = d.get('motivo') if d.get('motivo') in MOTIVOS else 'otro'
subject = f'[Web AQNSN] {MOTIVOS[motivo]} · {nombre}'
body = f'Motivo: {MOTIVOS[motivo]}\nNombre: {nombre}\nEmail: {email}\n\n{mensaje}\n'
try:
send(subject, email, body)
except RuntimeError:
return self.reply(503, {'ok': False, 'error': 'El formulario no está disponible ahora mismo.'})
except Exception as exc:
print('error enviando correo:', type(exc).__name__, file=sys.stderr, flush=True)
return self.reply(502, {'ok': False, 'error': 'No se pudo enviar. Inténtalo más tarde.'})
self.reply(200, {'ok': True})
if __name__ == '__main__':
ThreadingHTTPServer(('127.0.0.1', 8081), H).serve_forever()