Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed2ee9af40 | ||
|
|
b1c5bc737d | ||
|
|
c0b0c1ba0a | ||
|
|
fd814a3ccf | ||
|
|
727662294c |
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
# Core
|
# Core
|
||||||
fastapi==0.138.1
|
fastapi==0.138.2
|
||||||
uvicorn==0.30.0
|
uvicorn==0.30.0
|
||||||
python-telegram-bot==21.5
|
python-telegram-bot==21.5
|
||||||
httpx==0.27.0
|
httpx==0.27.0
|
||||||
|
|||||||
+67
-1
@@ -5,7 +5,7 @@ Main user interface — all commands handled here
|
|||||||
import asyncio
|
import asyncio
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from zoneinfo import ZoneInfo
|
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.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
|
||||||
@@ -851,6 +879,9 @@ async def _safe_send(bot, chat_id, text: str):
|
|||||||
|
|
||||||
|
|
||||||
async def _scheduler_loop(app: Application):
|
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:
|
while True:
|
||||||
db_conn = None
|
db_conn = None
|
||||||
try:
|
try:
|
||||||
@@ -910,6 +941,40 @@ async def _scheduler_loop(app: Application):
|
|||||||
app.bot, chat_id,
|
app.bot, chat_id,
|
||||||
f"🔄 Investigación automática iniciada: `{topic}`"
|
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:
|
except Exception as e:
|
||||||
logger.warning("Scheduler loop error", error=str(e))
|
logger.warning("Scheduler loop error", error=str(e))
|
||||||
finally:
|
finally:
|
||||||
@@ -1292,6 +1357,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))
|
||||||
|
|||||||
@@ -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,"
|
||||||
|
"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,73 @@ 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]
|
||||||
|
|
||||||
|
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 ---
|
# --- 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:
|
||||||
|
|||||||
@@ -284,8 +284,8 @@ def _seo_checklist(slug: str) -> str:
|
|||||||
be all-fail noise. Jose runs `seo-check <slug>` after filling these in."""
|
be all-fail noise. Jose runs `seo-check <slug>` after filling these in."""
|
||||||
return (
|
return (
|
||||||
"\n\n⚠️ Antes de publicar, añade: meta title, meta description (≤145), "
|
"\n\n⚠️ Antes de publicar, añade: meta title, meta description (≤145), "
|
||||||
"custom excerpt, OG/Twitter, feature image + alt, 2-3 internal links "
|
"custom excerpt, OG/Twitter, feature image + alt (campo Alt, NO Caption), "
|
||||||
"(donde sea natural)\n"
|
"2-3 internal links (donde sea natural)\n"
|
||||||
f"Luego valida: seo-check {slug}"
|
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
|
"""Slimmed checklist for the autofill path: meta/OG/excerpt/tags/links are
|
||||||
already on the draft, so only the human-only bits remain."""
|
already on the draft, so only the human-only bits remain."""
|
||||||
return (
|
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}`"
|
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