Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed2ee9af40 | ||
|
|
b1c5bc737d | ||
|
|
c0b0c1ba0a | ||
|
|
fd814a3ccf | ||
|
|
727662294c |
+1
-1
@@ -1,5 +1,5 @@
|
||||
# Core
|
||||
fastapi==0.138.1
|
||||
fastapi==0.138.2
|
||||
uvicorn==0.30.0
|
||||
python-telegram-bot==21.5
|
||||
httpx==0.27.0
|
||||
|
||||
+67
-1
@@ -5,7 +5,7 @@ Main user interface — all commands handled here
|
||||
import asyncio
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
@@ -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
|
||||
@@ -851,6 +879,9 @@ async def _safe_send(bot, chat_id, text: str):
|
||||
|
||||
|
||||
async def _scheduler_loop(app: Application):
|
||||
# Estado en memoria (no en DB): en un reinicio re-polla una vez, inofensivo
|
||||
# porque solo se empujan items notified=0 y se marcan tras enviarlos.
|
||||
_last_news_poll: Optional[datetime] = None
|
||||
while True:
|
||||
db_conn = None
|
||||
try:
|
||||
@@ -910,6 +941,40 @@ async def _scheduler_loop(app: Application):
|
||||
app.bot, chat_id,
|
||||
f"🔄 Investigación automática iniciada: `{topic}`"
|
||||
)
|
||||
|
||||
# --- Monitor de noticias (F2) — inerte si NEWS_ENABLED=False.
|
||||
# Best-effort: un fallo del news-poll NUNCA tumba el scheduler de
|
||||
# watched_topics (va en su propio try/except).
|
||||
if settings.NEWS_ENABLED:
|
||||
now = datetime.now(timezone.utc)
|
||||
interval = timedelta(hours=settings.NEWS_POLL_INTERVAL_HOURS)
|
||||
due_news = (_last_news_poll is None
|
||||
or (now - _last_news_poll) >= interval)
|
||||
if due_news:
|
||||
_last_news_poll = now # marca antes de pollear: evita reintentos en bucle si falla
|
||||
news_chat_id = settings.news_chat_id
|
||||
if not news_chat_id:
|
||||
logger.warning("NEWS_ENABLED pero sin chat destino; salto poll")
|
||||
else:
|
||||
try:
|
||||
await poll_feeds(db, settings) # inserta novedades notified=0
|
||||
pending = await db.get_unnotified() # TODOS los pendientes
|
||||
if pending:
|
||||
shown = pending[:settings.NEWS_MAX_ITEMS]
|
||||
extra = len(pending) - len(shown)
|
||||
items = [item_from_row(r) for r in shown]
|
||||
chunks = format_digest(items, header="🛸 Novedades UAP/OVNI")
|
||||
if extra > 0 and chunks:
|
||||
chunks[-1] += f"\n…y {extra} más."
|
||||
for c in chunks:
|
||||
await app.bot.send_message(
|
||||
chat_id=news_chat_id, text=c,
|
||||
disable_web_page_preview=False,
|
||||
)
|
||||
# Marca todos los pendientes (incluidos los "…y N más")
|
||||
await db.mark_news_notified([r["id"] for r in pending])
|
||||
except Exception as e:
|
||||
logger.warning("News poll/notify falló", error=str(e))
|
||||
except Exception as e:
|
||||
logger.warning("Scheduler loop error", error=str(e))
|
||||
finally:
|
||||
@@ -1292,6 +1357,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))
|
||||
|
||||
@@ -57,6 +57,20 @@ class Settings(BaseSettings):
|
||||
# the write path. A `/generate blog en dry` arg forces dryrun per-call.
|
||||
seo_autofill: str = Field("off", env="SEO_AUTOFILL")
|
||||
|
||||
# News monitor (RSS de noticias UAP/OVNI) — inerte hasta NEWS_ENABLED=true.
|
||||
NEWS_ENABLED: bool = Field(False, env="NEWS_ENABLED")
|
||||
NEWS_POLL_INTERVAL_HOURS: int = Field(6, env="NEWS_POLL_INTERVAL_HOURS")
|
||||
# Chat al que el monitor empuja novedades; si None -> 1er TELEGRAM_ALLOWED_USERS
|
||||
# (ver propiedad news_chat_id).
|
||||
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,"
|
||||
"aaro,pursue,fenomeno aereo,whistleblower",
|
||||
env="NEWS_KEYWORDS",
|
||||
)
|
||||
NEWS_MAX_ITEMS: int = Field(15, env="NEWS_MAX_ITEMS")
|
||||
|
||||
# Alerts
|
||||
cost_alert_threshold: float = Field(0.15, env="COST_ALERT_THRESHOLD")
|
||||
|
||||
@@ -70,6 +84,15 @@ class Settings(BaseSettings):
|
||||
return []
|
||||
return [int(uid.strip()) for uid in self.telegram_allowed_users.split(",") if uid.strip()]
|
||||
|
||||
@property
|
||||
def news_chat_id(self) -> Optional[int]:
|
||||
"""Chat destino del monitor de noticias: NEWS_CHAT_ID explícito o, si no
|
||||
está definido, el primer TELEGRAM_ALLOWED_USERS."""
|
||||
if self.NEWS_CHAT_ID is not None:
|
||||
return self.NEWS_CHAT_ID
|
||||
ids = self.allowed_user_ids
|
||||
return ids[0] if ids else None
|
||||
|
||||
@property
|
||||
def seo_autofill_enabled(self) -> bool:
|
||||
"""True when autofill should run (either live 'on' or 'dryrun')."""
|
||||
|
||||
@@ -115,6 +115,22 @@ CREATE TABLE IF NOT EXISTS watched_topics (
|
||||
created_at REAL NOT NULL,
|
||||
UNIQUE(topic, chat_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS news_seen (
|
||||
id INTEGER PRIMARY KEY,
|
||||
source TEXT NOT NULL,
|
||||
guid TEXT NOT NULL,
|
||||
title TEXT,
|
||||
link TEXT,
|
||||
published_at TEXT,
|
||||
matched_keywords TEXT,
|
||||
seen_at TEXT DEFAULT (datetime('now')),
|
||||
notified INTEGER DEFAULT 0,
|
||||
UNIQUE(source, guid)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_news_seen_source ON news_seen(source);
|
||||
CREATE INDEX IF NOT EXISTS idx_news_seen_seen ON news_seen(seen_at);
|
||||
"""
|
||||
|
||||
|
||||
@@ -496,6 +512,73 @@ class ResearchDB:
|
||||
)
|
||||
await self.db.commit()
|
||||
|
||||
# --- News monitor ---
|
||||
|
||||
async def source_seeded(self, source: str) -> bool:
|
||||
"""True si ya hemos visto algún item de esta fuente (cold-start por fuente:
|
||||
el primer poll siembra sin notificar)."""
|
||||
cursor = await self.db.execute(
|
||||
"SELECT 1 FROM news_seen WHERE source = ? LIMIT 1", (source,)
|
||||
)
|
||||
return await cursor.fetchone() is not None
|
||||
|
||||
async def record_news_item(self, source: str, guid: str, title: str,
|
||||
link: str, published_at: str, matched: str,
|
||||
notified: int) -> bool:
|
||||
"""INSERT OR IGNORE de un item. Devuelve True solo si es una inserción
|
||||
real (item nuevo); False si ya existía (UNIQUE(source, guid))."""
|
||||
cursor = await self.db.execute(
|
||||
"""INSERT OR IGNORE INTO news_seen
|
||||
(source, guid, title, link, published_at, matched_keywords, notified)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
||||
(source, guid, title, link, published_at, matched, notified)
|
||||
)
|
||||
await self.db.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
async def mark_news_notified(self, ids: list[int]):
|
||||
if not ids:
|
||||
return
|
||||
placeholders = ",".join("?" for _ in ids)
|
||||
await self.db.execute(
|
||||
f"UPDATE news_seen SET notified = 1 WHERE id IN ({placeholders})",
|
||||
tuple(ids)
|
||||
)
|
||||
await self.db.commit()
|
||||
|
||||
async def get_recent_news(self, hours: int = 24) -> list[dict]:
|
||||
"""Items matcheados cuya seen_at (o published_at parseable) cae en la
|
||||
ventana. Orden por published_at desc, con seen_at como desempate."""
|
||||
window = f"-{int(hours)} hours"
|
||||
cursor = await self.db.execute(
|
||||
"""SELECT * FROM news_seen
|
||||
WHERE seen_at >= datetime('now', ?)
|
||||
OR (published_at IS NOT NULL AND published_at != ''
|
||||
AND datetime(published_at) >= datetime('now', ?))
|
||||
ORDER BY published_at DESC, seen_at DESC
|
||||
LIMIT 50""",
|
||||
(window, window)
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
async def get_unnotified(self, limit: Optional[int] = None) -> list[dict]:
|
||||
"""Items aún sin notificar (notified=0), más recientes primero. El
|
||||
scheduler (F2) los empuja a Telegram y luego los marca con
|
||||
mark_news_notified. Aplica LIMIT solo si `limit` no es None."""
|
||||
sql = (
|
||||
"SELECT id, source, title, link, published_at, matched_keywords "
|
||||
"FROM news_seen WHERE notified = 0 "
|
||||
"ORDER BY published_at DESC NULLS LAST, seen_at DESC"
|
||||
)
|
||||
params: tuple = ()
|
||||
if limit is not None:
|
||||
sql += " LIMIT ?"
|
||||
params = (int(limit),)
|
||||
cursor = await self.db.execute(sql, params)
|
||||
rows = await cursor.fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
# --- Maintenance ---
|
||||
|
||||
async def purge_old_sessions(self, max_age_days: int = 30) -> dict:
|
||||
|
||||
@@ -284,8 +284,8 @@ def _seo_checklist(slug: str) -> str:
|
||||
be all-fail noise. Jose runs `seo-check <slug>` after filling these in."""
|
||||
return (
|
||||
"\n\n⚠️ Antes de publicar, añade: meta title, meta description (≤145), "
|
||||
"custom excerpt, OG/Twitter, feature image + alt, 2-3 internal links "
|
||||
"(donde sea natural)\n"
|
||||
"custom excerpt, OG/Twitter, feature image + alt (campo Alt, NO Caption), "
|
||||
"2-3 internal links (donde sea natural)\n"
|
||||
f"Luego valida: seo-check {slug}"
|
||||
)
|
||||
|
||||
@@ -312,7 +312,7 @@ def _seo_checklist_slim(slug: str) -> str:
|
||||
"""Slimmed checklist for the autofill path: meta/OG/excerpt/tags/links are
|
||||
already on the draft, so only the human-only bits remain."""
|
||||
return (
|
||||
"⚠️ Antes de publicar: añade *feature image + alt*, luego:\n"
|
||||
"⚠️ Antes de publicar: añade *feature image + alt* (campo Alt, NO Caption), luego:\n"
|
||||
f"`seo-check {slug}`"
|
||||
)
|
||||
|
||||
|
||||
@@ -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