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:
co-authored by
Claude Opus 4.8
parent
727662294c
commit
fd814a3ccf
@@ -57,6 +57,20 @@ class Settings(BaseSettings):
|
|||||||
# the write path. A `/generate blog en dry` arg forces dryrun per-call.
|
# the write path. A `/generate blog en dry` arg forces dryrun per-call.
|
||||||
seo_autofill: str = Field("off", env="SEO_AUTOFILL")
|
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
|
# Alerts
|
||||||
cost_alert_threshold: float = Field(0.15, env="COST_ALERT_THRESHOLD")
|
cost_alert_threshold: float = Field(0.15, env="COST_ALERT_THRESHOLD")
|
||||||
|
|
||||||
@@ -70,6 +84,15 @@ class Settings(BaseSettings):
|
|||||||
return []
|
return []
|
||||||
return [int(uid.strip()) for uid in self.telegram_allowed_users.split(",") if uid.strip()]
|
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
|
@property
|
||||||
def seo_autofill_enabled(self) -> bool:
|
def seo_autofill_enabled(self) -> bool:
|
||||||
"""True when autofill should run (either live 'on' or 'dryrun')."""
|
"""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,
|
created_at REAL NOT NULL,
|
||||||
UNIQUE(topic, chat_id)
|
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()
|
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 ---
|
# --- Maintenance ---
|
||||||
|
|
||||||
async def purge_old_sessions(self, max_age_days: int = 30) -> dict:
|
async def purge_old_sessions(self, max_age_days: int = 30) -> dict:
|
||||||
|
|||||||
Reference in New Issue
Block a user