From da8cc01fad7eae75c6018fc5131f8e3af9e73118 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 20 Oct 2026 23:50:53 +0200 Subject: [PATCH] =?UTF-8?q?Ficha=20privada=20de=20invitados=20y=20condicio?= =?UTF-8?q?nes=20de=20participaci=C3=B3n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /invitados.html (noindex) con enlace personal ?k=; 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 Claude-Session: https://claude.ai/code/session_01TSyR5Zw5eGj6oPTRVjvP1b --- Dockerfile | 1 + guest_form.json | 183 +++++++++++++++++++++++++++++++++++++++++ make_guest_form.py | 88 ++++++++++++++++++++ nginx.conf | 10 +++ public/invitados.html | 123 +++++++++++++++++++++++++++ public/invitados.js | 52 ++++++++++++ public/privacidad.html | 4 +- public/styles.css | 9 ++ public/terminos.html | 22 +++-- server.py | 104 ++++++++++++++++++++++- 10 files changed, 589 insertions(+), 7 deletions(-) create mode 100644 guest_form.json create mode 100644 make_guest_form.py create mode 100644 public/invitados.html create mode 100644 public/invitados.js diff --git a/Dockerfile b/Dockerfile index f942aac..e40fe21 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,6 +3,7 @@ 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 diff --git a/guest_form.json b/guest_form.json new file mode 100644 index 0000000..ed86b8a --- /dev/null +++ b/guest_form.json @@ -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." + ] + } + ] +} \ No newline at end of file diff --git a/make_guest_form.py b/make_guest_form.py new file mode 100644 index 0000000..4319e2b --- /dev/null +++ b/make_guest_form.py @@ -0,0 +1,88 @@ +"""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 = ' *' if q['req'] else '' + campo = (f'' + if q['largo'] else + f'') + d = f'{esc(q["desc"])}' if q['desc'] else '' + return f' ' + +bloques = [] +for s in secciones: + nota = f'\n

{esc(s["nota"])}

' if s['nota'] else '' + bloques.append(f'
\n {esc(s["titulo"])}{nota}\n' + + '\n'.join(pregunta(q) for q in s['preguntas']) + '\n
') + +page = f''' + + + + + +Ficha del invitado · Anda Que No Sabes Ná + + + + + +
+ Anda que no sabes ná +
+ +
+

Ficha del invitado

+ + + + + +
+ + + + +''' +open('public/invitados.html', 'w', encoding='utf-8').write(page) +print('invitados.html:', len(page), 'bytes;', nq, 'preguntas;', len(secciones), 'secciones') diff --git a/nginx.conf b/nginx.conf index 86f6b90..4939282 100644 --- a/nginx.conf +++ b/nginx.conf @@ -10,6 +10,16 @@ server { 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; diff --git a/public/invitados.html b/public/invitados.html new file mode 100644 index 0000000..a656d33 --- /dev/null +++ b/public/invitados.html @@ -0,0 +1,123 @@ + + + + + + +Ficha del invitado · Anda Que No Sabes Ná + + + + + +
+ Anda que no sabes ná +
+ +
+

Ficha del invitado

+ + + + + +
+ + + + diff --git a/public/invitados.js b/public/invitados.js new file mode 100644 index 0000000..c41a65a --- /dev/null +++ b/public/invitados.js @@ -0,0 +1,52 @@ +(function () { + var $ = function (id) { return document.getElementById(id); }; + var k = (new URLSearchParams(location.search).get('k') || '').trim(); + var f = $('g'), st = $('st'), hola = $('hola'), malo = $('malo'), intro = $('intro'); + var t0 = Date.now(), busy = false, KEY = 'aqnsn-ficha-' + k.slice(0, 10); + 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; } } + if (!k) { malo.hidden = false; return; } + + fetch('/api/invitados/check?k=' + encodeURIComponent(k), { 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; intro.hidden = false; f.hidden = false; + try { var d = JSON.parse(store('get') || '{}'); Object.keys(d).forEach(function (n) { if (f.elements[n] && f.elements[n].type !== 'checkbox') f.elements[n].value = d[n]; }); } catch (e) {} + }) + .catch(function () { malo.hidden = false; }); + + 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; + var resp = {}, falta = null; + Array.prototype.forEach.call(f.elements, function (el) { + if (/^q\d+$/.test(el.name)) { resp[el.name] = el.value.trim(); if (el.required && !resp[el.name] && !falta) falta = el; } + }); + if (falta) { falta.focus(); say('Faltan respuestas obligatorias (las marcadas con *).', false); return; } + if (!f.consent.checked || !f.privacy.checked) { say('Marca las dos casillas finales para poder enviar la ficha.', false); return; } + var email = (f.email.value || '').trim(); + if (email && !/^[^@\s]+@[^@\s]+\.[^@\s]{2,}$/.test(email)) { say('El correo no parece válido.', false); return; } + busy = true; say('Enviando…', true); + fetch('/api/invitados', { method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ k: k, email: email, 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(); Array.prototype.forEach.call(f.querySelectorAll('fieldset,label,button,.note'), function (n) { n.hidden = true; }); say(f.getAttribute('data-ok'), true); } + else say((x.j && x.j.error) || 'No se pudo enviar. Escríbenos a andaquenosabesnada@gmail.com.', false); + }) + .catch(function () { say('No se pudo enviar. Escríbenos a andaquenosabesnada@gmail.com.', false); }) + .then(function () { busy = false; }); + }); +})(); diff --git a/public/privacidad.html b/public/privacidad.html index 9e2509d..983fbf9 100644 --- a/public/privacidad.html +++ b/public/privacidad.html @@ -28,10 +28,12 @@

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.

Los enlaces a YouTube, TikTok e Instagram llevan a servicios de terceros, que tienen sus propias políticas de privacidad.

-

3. Formularios de contacto y de patrocinio

+

3. Formularios de contacto, patrocinio y ficha de invitados

Si nos escribes desde la página de contacto o de patrocinadores, 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 únicamente para responderte. La base legal es tu consentimiento, que das al marcar la casilla del formulario.

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.

+

Ficha del invitado. Si participas como invitado y rellenas la ficha (a la que se accede solo mediante un enlace personal), recogemos 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 condiciones de participación y esta política, y puedes pedirnos que las borremos en cualquier momento.

+

4. Nuestra aplicación de publicación

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: solo la utiliza el propietario de las cuentas y no está abierta al público.

Para funcionar, la aplicación solicita autorización a las cuentas del propio programa y accede a:

diff --git a/public/styles.css b/public/styles.css index 4e94f06..1e31879 100644 --- a/public/styles.css +++ b/public/styles.css @@ -239,3 +239,12 @@ footer { margin-top: 40px; border-top: 1px solid var(--line); background: var(-- .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; } diff --git a/public/terminos.html b/public/terminos.html index bf47e39..3a67253 100644 --- a/public/terminos.html +++ b/public/terminos.html @@ -36,19 +36,31 @@
  • Condiciones de uso de Instagram (Meta)
  • -

    5. Propiedad intelectual

    +

    5. Condiciones de participación de invitados

    +

    Estas condiciones se aplican a las personas que, tras ser invitadas, participan en el programa Anda Que No Sabes Ná y rellenan la ficha del invitado. Se aceptan marcando la casilla correspondiente al final de dicha ficha.

    +
      +
    • Proyecto altruista y colaborativo. El programa se realiza de forma altruista y colaborativa, con la intención de compartir conocimiento, experiencias e historias.
    • +
    • Sin remuneración económica. Ni la preparación, ni la grabación, ni la publicación del episodio contemplan remuneración económica.
    • +
    • Participación voluntaria y de buena fe. La participación es voluntaria y se realiza de buena fe.
    • +
    • Ficha previa. 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.
    • +
    • Límites temáticos. 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.
    • +
    • Aceptación. 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.
    • +
    +

    El tratamiento de los datos que se faciliten en la ficha se describe en la política de privacidad. Para cualquier duda sobre estas condiciones, escríbenos a contacto@andaquenosabesna.com.

    + +

    6. Propiedad intelectual

    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.

    -

    6. Enlaces externos

    +

    7. Enlaces externos

    La web contiene enlaces a sitios de terceros. No controlamos su contenido ni somos responsables de sus políticas o prácticas.

    -

    7. Limitación de responsabilidad

    +

    8. Limitación de responsabilidad

    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.

    -

    8. Cambios

    +

    9. Cambios

    Podemos actualizar estas condiciones. La versión vigente es la publicada en esta página, con su fecha de actualización.

    -

    9. Ley aplicable y contacto

    +

    10. Ley aplicable y contacto

    Estas condiciones se rigen por la legislación española. Para cualquier duda, escríbenos a andaquenosabesnada@gmail.com.

    diff --git a/server.py b/server.py index 5c9aa0e..72e2e22 100644 --- a/server.py +++ b/server.py @@ -7,6 +7,7 @@ No guarda los mensajes en disco: solo los reenvía por correo. """ import json import os +from urllib.parse import urlparse, parse_qs import re import smtplib import ssl @@ -56,7 +57,8 @@ def send(subject, reply_to, body): msg['Subject'] = subject msg['From'] = os.environ.get('MAIL_FROM', user) msg['To'] = os.environ['MAIL_TO'] - msg['Reply-To'] = reply_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: @@ -73,6 +75,50 @@ def send(subject, reply_to, body): 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 + + +def invites(): + """INVITE_TOKENS="codigo=Nombre;codigo2=Nombre2" (códigos de 16+ caracteres).""" + d = {} + for part in os.environ.get('INVITE_TOKENS', '').split(';'): + if '=' in part: + k, v = part.split('=', 1) + if len(k.strip()) >= 16 and v.strip(): + d[k.strip()] = v.strip() + return d + + class H(BaseHTTPRequestHandler): server_version = 'aqnsn' @@ -88,7 +134,63 @@ class H(BaseHTTPRequestHandler): 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): + if urlparse(self.path).path != '/api/invitados/check': + return self.reply(404, {'ok': False}) + if limited(check_hits, self.client_ip(), 40): + return self.reply(429, {'ok': False}) + k = (parse_qs(urlparse(self.path).query).get('k') or [''])[0].strip() + nombre = invites().get(k) + return self.reply(200, {'ok': True, 'nombre': nombre} if nombre else {'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.'}) + nombre = invites().get(str(d.get('k') or '').strip()) + 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 email and not EMAIL_RE.match(email): + return self.reply(400, {'ok': False, 'error': 'El correo 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'Correo (opcional): {email or "—"}', + '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.'}) + self.reply(200, {'ok': True}) + def do_POST(self): + if self.path.split('?')[0] == '/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'