Surtidito — Recetario de retrieval (cómo se extrae la información)

Fecha: 2026-09-13 · Contexto: mapa reproducible del pipeline de retrieval de ~/nef/surtido para exportarlo a otro proyecto. De dónde sale cada dato, cómo se normaliza y guarda, cómo se enriquece y cómo se exporta. Refs archivo:línea.

Idea fuerza: el retrieval es local-first Python (fetch → normalizar → guardar JSON/DB → enriquecer con LLM/NLP → render → exportar), con una capa delgada de lectura (asgi.py, FastAPI en Vercel) y un Worker aparte para trainability de YouTube. El artefacto público real es reporte/feed-todo.rss.xml.


Las 6 etapas

0. Registro de fuentes   (config estática)
1. Fetch                 (cada fuente tiene su fetcher)
2. Normalizar + guardar  (JSON local + Supabase)
3. Enriquecer            (traducir, extraer, triage, NLP, sentimiento)
4. Consolidar / exportar (feed-todo.json + feed-todo.rss.xml)
5. Servir                (asgi.py: /, /feed.rss.xml, /leer*)

Etapa 0 — Registro de fuentes (config, no código)

RecursoArchivoContenido
Canales YouTube + metadata editorialchannels.jsonchannel_id, nombre, categoria, tipo_medio, sesgo, credibilidad_10, mbfc_url
Listas de fuentes X / RSS / Telegramfeed.py:45 FUENTES, :84 CUENTAS_X, :421 TECH_RSS, :471 HERMANAS_RSS, :504 HERMANAS_X, :524 TECH_X, :536 TELEGRAMhard-coded
Perfiles/sitios de búsqueda webreferencias.py:45-60PERFILES, SITIOS, PAYWALL
Términos diseño/MLarchivo.py:32-48; terminos_dsml.txt (26 KB)filtro + catálogo generado por scripts/terminos_desde_second_brain.py
Trendingfeeds.jsonregión MX, max 20
Fuentes propuestas por usuariosSupabase tabla fuentes (supabase.sql:27)estado pendiente/aprobada/rechazada

Etapa 1 — Fetch: fuente → función

FuenteFetcherNotas
X/Twitter timelinefeed.obtener_timeline():259 → _rsshub_url():253RSSHub local :1200; env RSSHUB_URL, TWITTER_AUTH_TOKEN
YouTube videosfeed._fetch_yt():315 / _fetch_yt_rss():350; ingest.rss_entries():120Atom oficial; Invidious de respaldo
YouTube transcripcióningest.fetch_transcript():171; leer._transcripto_youtube():161youtube-transcript-api, es>en
YouTube audio→STTingest.fallback_whisper():237yt-dlp -x + faster-whisper
Comentarios YTsentimiento.collect_yt():565yt-dlp --write-comments
RSS tech EN+CNfeed._fetch_tech_rss():807arXiv, HF, HN, 36kr, QbitAI, Ars, Verge…
RSS hermanas + paywallfeed._fetch_hermanas_rss():757Pie de Página, Mongabay, NYT, WaPo…
X tech / hermanasfeed._fetch_tech_x():727 / _fetch_hermanas_x():789vía RSSHub
Telegramfeed._fetch_tech_tg():546RSSHub :1200/telegram/channel/<c>
Links destino de posts X (“silo”)feed.generar_silo():636URLs externas, términos DS/ML
Prensa nocturna (RSS)scripts/prensa_noche.py:54Página 12, Contralínea, Río Doce…
NYT/WaPo con cookiesprensa_sesion._trae():161env NYT_COOKIE/WAPO_COOKIE
NYT/WaPo con navegadorprensa_selenium._trae():117Brave + Selenium, perfil data/prensa-perfil
Búsqueda webreferencias._buscar():82ddgs y/o SerpAPI
Wayback / archive.todayreferencias._wayback():107 / _archive_today():128snapshot de links muertos
Papers CNpapers_cn.buscar():129OpenAlex, institutions.country_code:CN
Reddit / Bluesky / Mastodon / Talkwalker / NewsAPI / Google Newssentimiento.collect_*():185-638; pulso.pycorpus social

Etapa 2 — Normalizar y guardar

Normalizadores: feed._atom_entries():198 (RSS/Atom→dict), feed._normalizar_todo():906, lecturas._unifica():93 (dedupe por URL vía _norm_url():43), feed._fusionar_con_existente():398 (acumula histórico, dedup por URL).

Storage local (reporte/, versionado)

ArchivoProductorContenido
feed.json / feed.rss.xmlfeed.generar():1022corte fresco, 3 d / 5 por medio / 40 tope
feed-tech.json/.rss.xmlfeed.generar_tech():835tech (añade en, categoria)
feed-todo.json / feed-todo.rss.xmlfeed.generar_todo():927todo lo extraído + ediciones, full history, sin cuotas
x-silos.jsonfeed.generar_silo():636post_partida, url_destino, dominio, medio, terminos[]
lecturas.json/.htmllecturas.generar():123timeline unificado por secciones
archivo-diseno-ml.json/.htmlarchivo.acumular():117titulo,url,medio,grupos,terminos,paywall,texto_local
referencias.json/.htmlreferencias.buscar():246perfiles,queries,buscador,wayback{},archive_today{}
papers-cn.jsonpapers_cn.main():186openalex_id,titulo,abstract,citas,instituciones
analisis.json / nube.htmlnlp.analizar():267nube, temas, emergentes
sentimiento.jsonsentimiento.puntuar():657balance por fuente/tema
pulso.jsonpulso._documento():190pulso social
YYYY-MM-DD.json/.html/.mdproponer.armar_propuesta():412 / reporte.pyediciones
leer_cache/{hash}.jsonleer._guardar_cache()artículo traducido cacheado
leer_cache/{hash}.lectura.jsonleer._guardar_lectura()extracción cruda cacheada

Storage de trabajo (NO exportar)

PathContenido
candidatas/YYYY-MM-DD.json (+ transcripciones.json)candidatas YouTube con triage LLM (ingest.main():352)
candidatas/prensa-YYYY-MM-DD.jsonnotas prensa nocturna
data/corpus/*.jsonlcorpus social puntuado (sentimiento.guardar())
data/prensa/*.jsontexto completo de prensa, local-only/gitignored
data/prensa-perfil/perfil Brave con cookies de sesión

Supabase (Postgres/PostgREST, supabase.py; schema supabase.sql)

  • suscriptores (:8) — newsletter.
  • fuentes (:27) — fuentes propuestas (RLS: público lee aprobada).
  • salidas (:60) — clicks de salida, sin IP/UA (service_role).
  • artefactos (:77) — blobs jsonb (feed, analisis, nube, leer:{hash}); lectura pública.
  • Helpers: disponible():22, insertar():37, listar():48, actualizar():63, upsert_artefacto():77, leer_artefacto():98.
  • Usado por leer.py:52/70 (cache de traducción) y proponer.py:467.

Otros

Cloudflare D1 worker/schema.sql (canales/videos/estado, leído por trainability.py:33), Tinybird (src/tinybird/), Cloudinary (imagen.py:143), Matomo (analitica.py).


Etapa 3 — Enriquecer

FunciónQué hace
traducciones.traducir/deepl(), leer._traducir():410ES↔EN (DeepL o LLM)
leer._fetch():94 / _extraer():123fetch + trafilatura del artículo
leer._resumen_extractivo():316resumen sin LLM
ingest.triage():263, triage_rapido.main():85triage de candidatas por LLM (Ollama/DeepSeek)
nlp.analizar():267nube de palabras, temas, emergentes
sentimiento.puntuar():657sentimiento (robertuito local)
proponer.generar_terna():371 / _llm_texto():231propuesta de edición (terna)

Etapa 4 — Consolidar / exportar

  1. RSS (export principal) — feed._render_rss():1258 construye RSS 2.0 desde cualquier doc. Corte curado con _items_alt_rss():1212 (limite=40, por_medio=5, max_dias=3; None desactiva límites = full history). URL absoluta en _link_rss():1234; categoría inferida en _categoria_rss():1244. Salidas: reporte/feed.rss.xml (corte) y reporte/feed-todo.rss.xml (todo).
  2. JSON — feed-todo.json, feed.json, feed-tech.json, lecturas.json, referencias.json, archivo-diseno-ml.json, papers-cn.json, analisis.json, sentimiento.json, pulso.json, x-silos.json, candidatas/*.json, ediciones.
  3. HTML — feed.html, hn-*.html (hn.py), lecturas/archivo/referencias.html, reporte/YYYY-MM-DD.html (reporte.render_html():62).
  4. Markdown — reporte.render_md():276; GET /leer/descargar → leer.render_md():591 con Content-Disposition: attachment.
  5. Email (Resend) — mailing.py (cmd_send():826, cmd_broadcast():886, enviar_propuesta():785). (En limpieza 2026-09-12 se sacó del sitio; el motor queda como infra compartida de /leer.)
  6. Push externo — scripts/feed_rss.sh:22 sube el consolidado a RSS_PUSH_URL con Content-Type: application/rss+xml.

Etapa 5 — Servir (asgi.py, FastAPI)

RutaLíneaQué hace
/:398consola ámbar, feed acordeón
/feed.rss.xml:633raw; prefiere feed-todo.rss.xml (_feed_rss_path():210)
/leer:255versión traducida (IRC)
/leer/lectura:325JSON de lectura rápida (leer.lectura_json():659)
/leer/descargar:286descarga .md
/salto:244redirección anónima a origen
/health:695salud

reporte/** se incluye en el bundle de la función (vercel.json, includeFiles).


Receta rápida (comandos en orden)

cd ~/nef/surtido
 
# 1) corpus local (lecturas + archivo diseño/ML + referencias web)
./scripts/local_news.sh
 
# 2) prensa nocturna (NYT/WaPo + medios MX/AR)
.venv/bin/python scripts/prensa_noche.py
 
# 3) social / señales (opcional)
.venv/bin/python sentimiento.py
.venv/bin/python pulso.py
 
# 4) consolidar y exportar RSS (feed fresco + feed-todo + warm cache lectura)
./scripts/feed_rss.sh --limite 40
#    con RSS_PUSH_URL definido, además empuja el XML a R2/Pages
 
# 5) servidor local de lectura
.venv/bin/python -m uvicorn asgi:app --reload

Schedulers declarados: vercel.json crons /api/proponer (03:00 UTC) y /api/cron (08:00 UTC) — esos endpoints no están en este checkout. worker/index.js:161 (Cloudflare) hace trending cada 15 min.


Gotchas (importantes al exportar)

  1. Prensa completa es local-only (data/, gitignored) y nunca se exporta; las transcripciones no se citan, solo se triage (ingest.py:38).
  2. feed-todo.rss.xml es el output público real — se carga a la función de Vercel vía includeFiles: reporte/**.
  3. artefactos (Supabase) es la caché cloud de /leer; el espejo local es reporte/leer_cache/ (gitignored).
  4. Límites del corte (_items_alt_rss) existen para que un flood de una sola fuente no desplace a las demás; el consolidado los desactiva.
  5. VERCEL_OIDC_TOKEN hay que quitarlo antes de correr hn.py/feed en local (scripts/actualizar_hn.sh:46-51).
  6. X es el eslabón frágil (RSSHub + TWITTER_AUTH_TOKEN); el fallback al /feed/ del sitio ya está en obtener_timeline. El corpus RSS es lo estable.
  7. Sin fechas van al fondo en el consolidado (_ordenar + sin_fecha); _fecha_parse/_ordenar normalizan a UTC (mezcla naive/aware cross-fuentes).

Variables de entorno (solo nombres)

  • Core: SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY/SUPABASE_ANON_KEY, LLM_BACKEND (ollama|gateway|deepseek), OLLAMA_MODEL, OLLAMA_URL, DEEPSEEK_API_KEY, AI_GATEWAY_*, WEB_URL, RSS_URL, RSS_PUSH_URL.
  • Fetch local: RSSHUB_URL, TWITTER_AUTH_TOKEN, NEWSAPI_KEY, BSKY_IDENTIFIER, BSKY_APP_PASSWORD, TALKWALKER_RSS, YOUTUBE_API_KEY, YT_DATA_API_KEY, YT_WORKER_URL, SERPAPI_KEY, SERPAPI_ENGINE, SERPAPI_BUDGET, OPENALEX_MAILTO.
  • Prensa: NYT_COOKIE, WAPO_COOKIE, PRENSA_MAX_DIA, PRENSA_ESPERA_MIN/MAX.
  • Extra: LINGVA_URL, DEEPL_API_KEY, WHISPER_MODEL, TRIAGE_CHARS, GBRIDGE_URL, IMAGEN_PROVEEDOR, RAPIDAPI_KEY, CLOUDINARY_URL, RESEND_API_KEY/RESEND_FROM/EDITOR_EMAIL, MATOMO_*, TINYBIRD_*.

Para re-evaluar

  • Si X deja de responder → quitar X del cuerpo, dejar sólo RSS.
  • Si el blog consume el RSS: ajustar limite/por_medio/max_dias en _items_alt_rss.
  • Al portar a otro proyecto: llevarse feed.py (fetch+consolidación+RSS) como núcleo; asgi.py sólo si se quiere la capa de lectura.