feat(news): F1 — monitor RSS best-effort + comando /news manual
Build & Deploy ResearchOwl / build-and-push (push) Successful in 7s
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:
co-authored by
Claude Opus 4.8
parent
fd814a3ccf
commit
c0b0c1ba0a
@@ -22,6 +22,7 @@ from src.db.database import get_db, close_db, ResearchDB, ResearchStatus, Output
|
|||||||
from src.scraper.exhaustive import ExhaustiveScraper
|
from src.scraper.exhaustive import ExhaustiveScraper
|
||||||
from src.processor.processor import OllamaClient, ContentProcessor
|
from src.processor.processor import OllamaClient, ContentProcessor
|
||||||
from src.generator.generator import OutputGenerator
|
from src.generator.generator import OutputGenerator
|
||||||
|
from src.news.monitor import poll_feeds, item_from_row, format_digest
|
||||||
|
|
||||||
logger = structlog.get_logger()
|
logger = structlog.get_logger()
|
||||||
|
|
||||||
@@ -503,6 +504,33 @@ async def cmd_outputs(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
|||||||
await db_conn.close()
|
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):
|
async def cmd_costs(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||||||
if not is_authorized(update.effective_user.id):
|
if not is_authorized(update.effective_user.id):
|
||||||
return
|
return
|
||||||
@@ -1292,6 +1320,7 @@ def create_bot() -> Application:
|
|||||||
app.add_handler(CommandHandler("generate", cmd_generate))
|
app.add_handler(CommandHandler("generate", cmd_generate))
|
||||||
app.add_handler(CommandHandler("sources", cmd_sources))
|
app.add_handler(CommandHandler("sources", cmd_sources))
|
||||||
app.add_handler(CommandHandler("outputs", cmd_outputs))
|
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("export", cmd_export))
|
||||||
app.add_handler(CommandHandler("costs", cmd_costs))
|
app.add_handler(CommandHandler("costs", cmd_costs))
|
||||||
app.add_handler(CommandHandler("watch", cmd_watch))
|
app.add_handler(CommandHandler("watch", cmd_watch))
|
||||||
|
|||||||
+2
-2
@@ -65,8 +65,8 @@ class Settings(BaseSettings):
|
|||||||
NEWS_CHAT_ID: Optional[int] = Field(None, env="NEWS_CHAT_ID")
|
NEWS_CHAT_ID: Optional[int] = Field(None, env="NEWS_CHAT_ID")
|
||||||
NEWS_KEYWORDS: str = Field(
|
NEWS_KEYWORDS: str = Field(
|
||||||
"ovni,ovnis,uap,ufo,desclasificado,desclasificacion,declassified,"
|
"ovni,ovnis,uap,ufo,desclasificado,desclasificacion,declassified,"
|
||||||
"avistamiento,sighting,extraterrestre,non-human,nhi,grusch,pentagono,"
|
"avistamiento,sighting,extraterrestre,non-human,nhi,grusch,"
|
||||||
"pentagon,aaro,pursue,fenomeno aereo,whistleblower",
|
"aaro,pursue,fenomeno aereo,whistleblower",
|
||||||
env="NEWS_KEYWORDS",
|
env="NEWS_KEYWORDS",
|
||||||
)
|
)
|
||||||
NEWS_MAX_ITEMS: int = Field(15, env="NEWS_MAX_ITEMS")
|
NEWS_MAX_ITEMS: int = Field(15, env="NEWS_MAX_ITEMS")
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user