Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
933976e310 |
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
# Core
|
# Core
|
||||||
fastapi==0.138.2
|
fastapi==0.138.1
|
||||||
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
|
||||||
|
|||||||
+1
-67
@@ -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, timezone
|
from datetime import datetime, timedelta
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
@@ -22,7 +22,6 @@ 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()
|
||||||
|
|
||||||
@@ -504,33 +503,6 @@ 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
|
||||||
@@ -879,9 +851,6 @@ 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:
|
||||||
@@ -941,40 +910,6 @@ 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:
|
||||||
@@ -1357,7 +1292,6 @@ 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,20 +57,6 @@ 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")
|
||||||
|
|
||||||
@@ -84,15 +70,6 @@ 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,22 +115,6 @@ 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);
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
@@ -512,73 +496,6 @@ 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 (campo Alt, NO Caption), "
|
"custom excerpt, OG/Twitter, feature image + alt, 2-3 internal links "
|
||||||
"2-3 internal links (donde sea natural)\n"
|
"(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* (campo Alt, NO Caption), luego:\n"
|
"⚠️ Antes de publicar: añade *feature image + alt*, luego:\n"
|
||||||
f"`seo-check {slug}`"
|
f"`seo-check {slug}`"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,173 +0,0 @@
|
|||||||
"""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