feat(news): F1 — monitor RSS best-effort + comando /news manual
Build & Deploy ResearchOwl / build-and-push (push) Successful in 7s

src/news/monitor.py: 7 feeds UAP/OVNI, matching normalizado (NFKD sin tildes)
con word-boundary para tokens cortos (uap/ufo/nhi/aaro/ovni) y substring para
frases. poll_feeds aísla cada feed (uno caído nunca tumba el run) y parsea en
hilo aparte para no bloquear el event loop; cold-start por fuente (siembra sin
notificar). format_digest agrupa por fuente y trocea <4000 chars. /news refresca
y muestra novedades 24h. Capa desacoplada: src/news NO importa de src/bot.
No toca el scheduler (F2).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ChemaVX
2026-06-26 20:57:26 +00:00
co-authored by Claude Opus 4.8
parent fd814a3ccf
commit c0b0c1ba0a
4 changed files with 204 additions and 2 deletions
+29
View File
@@ -22,6 +22,7 @@ from src.db.database import get_db, close_db, ResearchDB, ResearchStatus, Output
from src.scraper.exhaustive import ExhaustiveScraper
from src.processor.processor import OllamaClient, ContentProcessor
from src.generator.generator import OutputGenerator
from src.news.monitor import poll_feeds, item_from_row, format_digest
logger = structlog.get_logger()
@@ -503,6 +504,33 @@ async def cmd_outputs(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
await db_conn.close()
async def cmd_news(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
"""Monitor RSS manual (sin scheduler — F2 lo automatiza). Refresca news_seen
(siembra en cold-start) y muestra las novedades matcheadas de las últimas 24h."""
if not is_authorized(update.effective_user.id):
return
db_conn = await get_db()
db = ResearchDB(db_conn)
try:
await update.message.reply_text("📡 Buscando novedades UAP/OVNI…")
try:
await poll_feeds(db, settings) # refresca tabla (siembra en cold-start)
except Exception:
logger.exception("news poll failed") # best-effort: nunca rompe el comando
recent = await db.get_recent_news(24)
if not recent:
await update.message.reply_text("Sin novedades UAP/OVNI en las últimas 24h.")
return
items = [item_from_row(r) for r in recent]
for chunk in format_digest(items):
await update.message.reply_text(chunk, disable_web_page_preview=False)
finally:
await db_conn.close()
async def cmd_costs(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
if not is_authorized(update.effective_user.id):
return
@@ -1292,6 +1320,7 @@ def create_bot() -> Application:
app.add_handler(CommandHandler("generate", cmd_generate))
app.add_handler(CommandHandler("sources", cmd_sources))
app.add_handler(CommandHandler("outputs", cmd_outputs))
app.add_handler(CommandHandler("news", cmd_news))
app.add_handler(CommandHandler("export", cmd_export))
app.add_handler(CommandHandler("costs", cmd_costs))
app.add_handler(CommandHandler("watch", cmd_watch))
+2 -2
View File
@@ -65,8 +65,8 @@ class Settings(BaseSettings):
NEWS_CHAT_ID: Optional[int] = Field(None, env="NEWS_CHAT_ID")
NEWS_KEYWORDS: str = Field(
"ovni,ovnis,uap,ufo,desclasificado,desclasificacion,declassified,"
"avistamiento,sighting,extraterrestre,non-human,nhi,grusch,pentagono,"
"pentagon,aaro,pursue,fenomeno aereo,whistleblower",
"avistamiento,sighting,extraterrestre,non-human,nhi,grusch,"
"aaro,pursue,fenomeno aereo,whistleblower",
env="NEWS_KEYWORDS",
)
NEWS_MAX_ITEMS: int = Field(15, env="NEWS_MAX_ITEMS")
View File
+173
View File
@@ -0,0 +1,173 @@
"""Monitor RSS de noticias UAP/OVNI.
Capa independiente: NUNCA importa de src.bot (el bot importa de aquí). No toca el
scheduler F2. Best-effort por diseño: un feed caído se loguea y se salta; nunca
tumba el run. El estado vive en la tabla news_seen vía los métodos de ResearchDB,
que se inyectan (no se importan) para mantener el desacople.
"""
import asyncio
import re
import unicodedata
from dataclasses import dataclass, field
import feedparser
import structlog
log = structlog.get_logger()
# Reddit devuelve 403 con el UA por defecto de feedparser/python; uno explícito
# (y honesto) pasa. El resto de feeds lo aceptan sin problema.
UA = "ExclusionZone-NewsBot/1.0 (+https://theexclusionzone.com)"
FEEDS: list[dict] = [
{"name": "Gran Misterio", "url": "https://granmisterio.org/feed/", "lang": "es"},
{"name": "Espacio Misterio", "url": "https://www.espaciomisterio.com/feed/", "lang": "es"},
{"name": "Josep Guijarro (YT)", "url": "https://www.youtube.com/feeds/videos.xml?channel_id=UCIlGy26zi-BbX6zDYAMQ9Qg", "lang": "es"},
{"name": "r/UFOs", "url": "https://www.reddit.com/r/UFOs/new/.rss", "lang": "en"},
{"name": "r/UFOB", "url": "https://www.reddit.com/r/UFOB/new/.rss", "lang": "en"},
{"name": "Liberation Times", "url": "https://www.liberationtimes.com/?format=rss", "lang": "en"},
{"name": "The Debrief", "url": "https://thedebrief.org/feed/", "lang": "en"},
]
# Tokens cortos: word-boundary para evitar falsos positivos dentro de palabras
# (p.ej. "aaro" en "aaron", "ufo" en "tufo"). Frases multi-palabra y términos
# largos van por substring. Se decide dinámicamente: sin espacios y len <= 4.
_SHORT_TOKEN_MAXLEN = 4
# Límite de Telegram 4096; troceamos por debajo con margen.
_TG_LIMIT = 4000
@dataclass
class NewsItem:
source: str
guid: str
title: str
link: str
published: str
summary: str
matched: list[str] = field(default_factory=list)
def _normalize(text: str) -> str:
"""minúsculas + NFKD sin marcas combinantes (quita tildes/diacríticos)."""
if not text:
return ""
decomposed = unicodedata.normalize("NFKD", text.lower())
return "".join(c for c in decomposed if not unicodedata.combining(c))
def _is_short_token(kw: str) -> bool:
return " " not in kw and len(kw) <= _SHORT_TOKEN_MAXLEN
def _match(title: str, summary: str, keywords: list[str]) -> list[str]:
"""Devuelve las keywords (en su forma original) que matchean en title+summary,
sobre texto normalizado. Word-boundary para tokens cortos, substring para el
resto. Sin duplicados, preservando el orden de `keywords`."""
text = _normalize(f"{title} {summary}")
if not text:
return []
matched: list[str] = []
for kw in keywords:
nkw = _normalize(kw)
if not nkw:
continue
if _is_short_token(nkw):
if re.search(rf"\b{re.escape(nkw)}\b", text):
matched.append(kw)
else:
if nkw in text:
matched.append(kw)
return matched
async def poll_feeds(db, settings) -> list[NewsItem]:
"""Recorre los feeds, registra items matcheados en news_seen y devuelve los
NUEVOS (no vistos) de fuentes ya sembradas. Cold-start por fuente: el primer
poll de cada feed siembra en silencio (notified=1) y no devuelve nada.
Best-effort: cada feed va en su propio try/except uno caído nunca tumba el
run. feedparser.parse es bloqueante (I/O de red), así que se ejecuta en un
hilo para no congelar el event loop del bot durante el poll.
"""
keywords = [k.strip() for k in settings.NEWS_KEYWORDS.split(",") if k.strip()]
new_items: list[NewsItem] = []
for feed in FEEDS:
try:
parsed = await asyncio.to_thread(feedparser.parse, feed["url"], agent=UA)
if parsed.bozo and not parsed.entries:
log.warning("feed bozo sin entries: %s (%s)",
feed["name"], getattr(parsed, "bozo_exception", ""))
continue
seeded = await db.source_seeded(feed["name"]) # cold-start por fuente
for e in parsed.entries:
guid = getattr(e, "id", None) or getattr(e, "link", "")
if not guid:
continue
matched = _match(e.get("title", ""), e.get("summary", ""), keywords)
if not matched:
continue
notified = 1 if not seeded else 0 # 1er run: sembrar sin notificar
is_new = await db.record_news_item(
feed["name"], guid, e.get("title", ""), e.get("link", ""),
e.get("published", ""), ",".join(matched), notified,
)
if is_new and seeded:
new_items.append(NewsItem(
feed["name"], guid, e.get("title", ""), e.get("link", ""),
e.get("published", ""), e.get("summary", ""), matched,
))
except Exception as ex: # aislado: un feed no tumba el run
log.exception("fallo feed %s: %s", feed["name"], ex)
return new_items
def item_from_row(row: dict) -> NewsItem:
"""Adapta una fila de get_recent_news (dict de news_seen) a NewsItem para que
format_digest sea agnóstico de la fuente (poll vs DB)."""
matched = row.get("matched_keywords") or ""
return NewsItem(
source=row.get("source", ""),
guid=row.get("guid", ""),
title=row.get("title", "") or "",
link=row.get("link", "") or "",
published=row.get("published_at", "") or "",
summary="",
matched=[m for m in matched.split(",") if m],
)
def format_digest(items: list[NewsItem], header: str = "🛸 Novedades UAP/OVNI") -> list[str]:
"""Agrupa por fuente y formatea cada item como '• <title>\\n <link>'. Trocea
en mensajes < _TG_LIMIT chars (devuelve varios si hace falta). Texto plano
(no Markdown): el handler envía sin parse_mode. Lista vacía -> []."""
if not items:
return []
# Agrupar por fuente preservando el orden de primera aparición.
groups: dict[str, list[NewsItem]] = {}
for it in items:
groups.setdefault(it.source, []).append(it)
blocks: list[str] = [] # un bloque por fuente, indivisible salvo que él solo exceda
for source, its in groups.items():
lines = [f"── {source} ──"]
for it in its:
title = (it.title or "(sin título)").strip()
lines.append(f"{title}\n {it.link}".rstrip())
blocks.append("\n".join(lines))
chunks: list[str] = []
cur = header
for block in blocks:
if len(cur) + len(block) + 2 > _TG_LIMIT and cur.strip() != header:
chunks.append(cur)
cur = f"{header} (cont.)"
cur += "\n\n" + block
if cur.strip():
chunks.append(cur)
return chunks