From fd814a3ccfdac6ca0512bd0c1b8dea88a7bdff40 Mon Sep 17 00:00:00 2001 From: ChemaVX Date: Fri, 26 Jun 2026 20:46:38 +0000 Subject: [PATCH] =?UTF-8?q?feat(news):=20F0=20=E2=80=94=20schema,=20config?= =?UTF-8?q?=20y=20m=C3=A9todos=20DB=20del=20monitor=20RSS=20(inerte)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit news_seen table (CREATE TABLE IF NOT EXISTS, sin migración) + índices. Config NEWS_* (NEWS_ENABLED=false por defecto) + propiedad news_chat_id (fallback al 1er TELEGRAM_ALLOWED_USERS). Métodos ResearchDB: source_seeded, record_news_item, mark_news_notified, get_recent_news. Todo inerte: nada lo invoca aún. feedparser ya estaba en requirements (6.0.12 >= 6.0). Co-Authored-By: Claude Opus 4.8 --- src/config.py | 23 ++++++++++++++++ src/db/database.py | 66 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+) diff --git a/src/config.py b/src/config.py index f29730c..75b3c8b 100644 --- a/src/config.py +++ b/src/config.py @@ -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,pentagono," + "pentagon,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').""" diff --git a/src/db/database.py b/src/db/database.py index 084b798..2f92595 100644 --- a/src/db/database.py +++ b/src/db/database.py @@ -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,56 @@ 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] + # --- Maintenance --- async def purge_old_sessions(self, max_age_days: int = 30) -> dict: