feat(news): F0 — schema, config y métodos DB del monitor RSS (inerte)

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 <noreply@anthropic.com>
This commit is contained in:
ChemaVX
2026-06-26 20:46:38 +00:00
co-authored by Claude Opus 4.8
parent 727662294c
commit fd814a3ccf
2 changed files with 89 additions and 0 deletions
+66
View File
@@ -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: