Surtidito → Alt RSS (retrieval → RSS para el blog)

Fecha: 2026-09-12 · Decision: sí, Surtidito se simplifica a un RSS 2.0 que el blog consume. Driver: el producto se siente sobredimensionado; la fragilidad está en el contorno de entrega, no en las fuentes.

Hallazgo de la investigación (sondeo en vivo, 2026-09-12)

74 endpoints probados → ~70 devuelven XML válido hoy:

BloqueVivasRota
Tech RSS (45)44Zhihu (localhost, fuera del corte)
Hermanas RSS (20)17WaPo ×2 (timeout), Desinformémonos (HTML, no XML)
Medios MX /feed/ (7)6Río Doce 500 (temporal)
YouTube RSS oficial100%— (sin cuota; Invidious solo respaldo)

Conclusión: los “problemas” eran X (RSSHub + TWITTER_AUTH_TOKEN) y la capa de entrega (Vercel crons, Supabase, newsletter), no el corpus RSS.

Decisiones

  1. RSS 2.0 real emitido por feed.py junto a feed.json/feed.html:
    • reporte/feed.rss.xml (feed unificado) y reporte/feed-tech.rss.xml (tech).
    • Corte para consumo externo: recencia 3 días, cuota 5 items/medio, tope 40 → cuerpo estable de ~30-40/día para el blog.
    • category inferida cuando el item no la trae: VIDEO (YT), NACIONAL (X de medios MX independientes).
  2. X se mantiene en medida que no lo bloqueen: RSSHub local (Docker) primero, fallback al /feed/ del sitio del medio (ya implementado en obtener_timeline). El sondeo confirma X funciona hoy (7/7 medios + 12/12 hermanas).
  3. Cron local scripts/feed_rss.sh: regenera el feed + XML; define RSS_PUSH_URL (R2/Pages) para publicarlo.
  4. Poda del contorno (segundo paso, NO tocado aquí): asgi.py web app, crons Vercel, Supabase artefactos, newsletter vía Resend — evaluar en sesión aparte si mueren o quedan como producto (radio/salto/boletín).

Archivos tocados (commit propuesto: “feed: alt-rss RSS 2.0”)

  • feed.py: _render_rss / _items_alt_rss / _link_rss / _categoria_rss / _fecha_parse; emisión en generar() y generar_tech().
  • scripts/feed_rss.sh: nuevo, cron local.

Demo (mismo día, agregada)

Prueba de que la home solo consume el artefacto RSS, con el scraper por detrás:

  • GET /demo — home con el esqueleto existente (_pagina + _nav, “Demo” en nav) que no junta ni scrapea: lee reporte/feed.rss.xml y pinta las filas del feed; JS polling cada 25 s a /demo/feed.rss.xml para refrescarse conforme scripts/feed_rss.sh regenere por detrás.
  • GET /demo/feed.rss.xml — artefacto crudo (application/rss+xml, no-store), lo mismo que consumirá el blog.
  • _rss_rows / demo_alt_rss / demo_rss_xml en asgi.py.

Consolidación (mismo día, después)

“Ya solo existe una página.” / ahora es el feed único, IRC-design, texto plano, que consume todo lo extraído:

  • feed.py generar_todo (--todo) junta y dedupa por URL: feed (292) + feed-tech (819) + archivo diseño/ML (277) + referencias (231) + silo X (88) + papers CN (15) + notas de las 17 ediciones (144) = 1575 items, más reciente arriba, sin fechas al fondo. Emite reporte/feed-todo.json + reporte/feed-todo.rss.xml (full, sin cuotas).
  • asgi.py / — la única página: <surtido> meta + una línea por item (≤140c, tipo tuit), click = /salto, carga progresiva (80 server + 120/montón con IntersectionObserver), refresco 45 s si cambia lastBuildDate.
  • GET /feed.rss.xml — raw consolidado (710 KB, full history); /demo* y /hn → 301 a /.
  • scripts/feed_rss.sh regenera corte fresco + consolidado.
  • _fecha_parse/_ordenar normalizan a UTC (mezcla naive/aware cross-fuentes).

Tamaño del corte servido por el demo: 40 items (recencia 3 d, cuota 5/medio), categorías VIDEO/NACIONAL/INTERNACIONAL/TECH.

Acordeón: leer la nota (mismo día, después)

“Ya tenemos el silo, ahora quiero leerlas, despleguemos acordeón.” La página pasa de solo-texto a lista-acordeón: click en una línea la expande y lee la noticia inline, sin salir de la página.

  • leer.py lectura_json(url) — JSON de lectura rápida cacheado. Prioridad: previa ya traducida (leer_cache/{h}.json, texto ES) → extracción original cacheada ({h}.lectura.json) → fetch+trafilatura en vivo (cacheada). Devuelve titulo, resumen extractivo (sin LLM), parrafos (formato lectura), es (¿texto en español?) con heurística _parece_es (marcadores EN).
  • GET /leer/lectura?u= — endpoint que consume el acordeón (cache no-store).
  • Página /: cada item = div.fila (▸, línea tuitera) + div.panel oculto. Click = expandir y pedir /leer/lectura; panel con título, ”· resumen ·”, párrafos en serif/lectura rápida (columna ~62ch, fondo ligeramente más claro), footer “abrir origen ↗” (salto) y, si es=False, “versión en español ↗” (/leer). Delegación por closest('.fila'), accesible por teclado.
  • _origen_permitido suavizado: permite si al menos una dirección DNS resuelve a IP pública (registros mixtos/CDN daban 400 falsos); bloquear solo si todas son reservadas.
  • Datos reales: extracción live de aje.news OK (live blog, EN → pill español).

Leer en IRC (mismo día, después)

“la de lectura, que tenga un diseño que vaya con la principal: irc-style, muy adhoc con solo leer (un solo bloque), descarga persiste y el mensaje de ‘apoya’”

leer.py render_html reescrito: se quitó el estilo Wikiwand (serif claro, barra sticky, tabs Resumen/Cifras/Artículo) y las capas de cifras/nombres. Ahora /leer (versión traducida) es:

  • Fondo negro, mono, <surtido> leer · versión traducida · un solo bloque
    • [ORIGINAL EN → ES] · N párrafos · M min + fuente (link salto).
  • Un solo bloque de lectura (borde izquierdo + fondo #0d0d0d): resumen como entradilla (· resumen ·) + artículo con subtítulos en ámbar.
  • Barra de progreso fina (acento #5ee0a0).
  • Mensaje apoya persistido: “<surtido> no olvides apoyar al medio — Surtidito es un puente, no el destino… (abrir original →)“.
  • Descarga persistida: ↓ descargar versión traducida (.md) + salida anónima + original.
  • _con_analitica sigue inyectando Matomo al final.

Limpieza — solo feed + leer + scraper (mismo día)

“limpia y deja lo esencial” → elegido: solo feed+leer+scraper, se elimina radio, cursos, lecturas/fuentes (páginas), y el boletín por email.

  • asgi.py reescrito: 1517 → 539 líneas. Quedan solo: / (feed acordeón), /feed.rss.xml, /leer, /leer/lectura, /leer/descargar, /salto, /robots.txt, /llms.txt, /health, /demo* /hn* → 301 a /. Caído: /fuentes, /lecturas, /cursos/*, /radio*, /feed legacy, /subscribe, /api/proponer, /aprobar/*, /webhook/resend, /api/cron y todo su helper (nav, CSS viejo, resend, turnstile, ediciones, mailing). Errores de /salvo y /leer ahora en IRC (_irc_error).
  • Borrados: cursos.py, fuentes.py, miniaturas.py, scripts/radio_parrilla.py, scripts/tanda_radio.sh, scripts/radio_cron.sh, scripts/radio_push.sh, scripts/pronunciacion.py.
  • Cron: eliminada la línea tanda_radio.sh; queda prensa_noche.py (03:00), la fuente del feed.
  • El núcleo vivo se conserva: prensa_noche.py (nocturno) + local_news.sh (lecturas→archivo→referencias) + feed_rss.sh → feed-todo.rss.xml.
  • Motor del boletín (supabase/mailing/proponer/nlp/pulso/reporte/sentimiento/ traducciones) se queda: son infra compartida con /leer (traducción LLM vía proponer) — sacarlos requiere cirugía en lazy-imports. Queda para otra sesión si se quiere purgar.

Versión alternativa — consola ámbar (AmberConsole) (mismo día)

  • Pivote: el usuario citó https://github.com/ggerganov/hnterm (ImTui C++ que lee la HN API) y luego https://github.com/DutchDiederik/AmberConsole. Decisión final: no portar C++; retomar estructura/teclado de hnterm y pintarla con AmberConsole (CSS sólo, 0 deps, BSD-3). Back sigue siendo nuestro silo (we drink our own koolaid).
  • AmberConsole venderedo local: static/amber-console.css + fonts/ (VT323 + Silkscreen, OFL-1.1). Montado en app.mount("/static", StaticFiles(...)).
  • / ahora es <html data-ac-tech="crt" data-ac-emitter="p3"> = fósforo ámbar P3 dentro de .ac-screen ac-crt ac-afterglow (scanlines, retrace, persistencia).
  • Comportamiento hnterm conservado: j/k/↑/↓ mover · Enter leer · o origen · Tab cicla modo (categoría del feed) · g/G · esc/q cierra · r refresh · s status.
  • Lector: Enter → /leer/lectura?u= → noticia completa, SIN resumen; botón ▶ ABRIR EN EL MEDIO ORIGINAL (.ac-btn--filled ac-btn--lg) es lo más visible, siempre vía /salto (noreferrer); link “versión en español” sutil cuando es=false.
  • Modos (commit 65a1be3): ALL → MX → TECH → INTL → OTROS (tabs pequeñas, 12px). La clave es agrupar por categoría: cuidado con que “INTERNACIONAL” contiene “NACIONAL” → usar \bNACIONAL\b (word boundary).
  • Lector = overlay modal fijo (fuera de .ac-screen, z-index 60, box centrado min(780px,96vw), sans-serif system para el cuerpo, max-width:66ch, alineación centro en caja). Navegación dentro de la lectura: n/p (o j/k y flechas) cambian de noticia sin cerrar; esc/q cierra. Botones ‹ anterior / siguiente › en el pie junto al botón de origen.
  • Reseña orgánica (rss-ish): cuando la extracción es pobre (parrafos <700 chars) se muestra primero resumen como · RESEÑA ORGÁNICA · + el texto extraído que haya; si no hay nada, aviso + botón al original. El <description> del feed solo guarda el medio (no resumen) → no hay summary en el RSS silo.
  • Commits: 65a1be3 (modos + lector modal), e3d1715 (front ámbar), da16e84 (feed consolidado + lector), 7a8f13f (limpieza). reporte/leer_cache/ está en .gitignore (caché).
  • Mistake que vale recordar: Edit gigante de ~250 líneas corrompió el JSON de la herramienta → la lección es reescribir el archivo completo con Write + Read previo, y verificar JS embebido con node --check antes de reiniciar.

Re-evaluar cuando:

  • X deje de responder (bloqueos) → el fallback a sitio ya está; re-considerar quitar X del cuerpo.
  • El blog consuma Y entrada: ajustar limite/por_medio/max_dias de _items_alt_rss.