Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d0280cab9 |
@@ -1,62 +0,0 @@
|
||||
# ResearchOwl — Known Issues & Operational Gotchas
|
||||
|
||||
## Brotli / aiohttp incompatibility
|
||||
|
||||
Do NOT install any brotli backend (`Brotli`, `brotlicffi`) while on aiohttp 3.14.x.
|
||||
aiohttp's incremental brotli decompressor is broken and fails intermittently
|
||||
depending on stream chunking ("Can not decode content-encoding: br" on VALID
|
||||
streams — the same bytes decompress fine offline; httpx is unaffected).
|
||||
|
||||
Extra trap: merely *installing* a backend makes aiohttp advertise `br` in the
|
||||
default `Accept-Encoding` of every session that doesn't set one explicitly.
|
||||
On 2026-07-04 a scraper fix installed brotlicffi and inadvertently enabled br
|
||||
project-wide: Ghost/Cloudflare responded in brotli, the decode blew up AFTER
|
||||
Ghost had already accepted the POST, and the publish fallback re-published →
|
||||
duplicate drafts + silently lost SEO notices.
|
||||
|
||||
Fix applied (commits `397546a` + `76c927f` + `7d07375`, guard relocated
|
||||
2026-07-05):
|
||||
- no brotli backend in requirements.txt (comment there explains why),
|
||||
- explicit `Accept-Encoding: gzip, deflate` on all Ghost/autofill aiohttp
|
||||
sessions and on the scraper HEADERS (`57f341f`),
|
||||
- duplicate guard INSIDE `GhostPublisher.publish_draft` (covers every caller,
|
||||
including `/publish`): if the POST is accepted (2xx) but reading the response
|
||||
fails, it recovers the just-created draft via `find_draft_by_title(title,
|
||||
since=attempt_start)` instead of raising. The guard only fires after an
|
||||
accepted POST — a pre-POST failure (Ollama down, menu fetch, links) never
|
||||
triggers it, so a stale same-title draft from a previous run can no longer
|
||||
swallow freshly generated content.
|
||||
|
||||
Note: with no backend installed there is NO brotli fallback at all — a server
|
||||
that responds `Content-Encoding: br` without it being advertised (misbehaving
|
||||
CDN) fails undecodable and that source is lost. Accepted trade-off.
|
||||
|
||||
The header value lives in one place: `SAFE_ACCEPT_ENCODING` in `src/config.py`.
|
||||
Every aiohttp session/request must use it explicitly — never rely on aiohttp's
|
||||
default Accept-Encoding, which silently grows `br` if a backend appears.
|
||||
|
||||
Re-test with disclosure.org before ever re-enabling br (e.g. after an aiohttp
|
||||
upgrade).
|
||||
|
||||
## SQLite WAL mode + read-only mounts
|
||||
|
||||
The database runs in WAL mode, so even read-only access needs the `-shm` file
|
||||
writable, or must open with `sqlite3 "file:...?immutable=1"`. Backups (daily
|
||||
CronJob `researchowl-db-backup`, 03:00 Europe/Madrid, PVC `researchowl-backups`,
|
||||
7-day retention) inherit WAL mode: to inspect one from a read-only mount use
|
||||
`?immutable=1`; to restore, copy it to a writable location first.
|
||||
|
||||
## DDG (duckduckgo_search) blocks the event loop
|
||||
|
||||
`DDGS()` is synchronous (blocking requests inside). Never call it directly from
|
||||
async code — always wrap in `loop.run_in_executor()` (see `_ddg_text_sync` /
|
||||
`_ddg_videos_sync` in `src/scraper/exhaustive.py`). Direct calls froze the
|
||||
entire Telegram bot during searches until fixed on 2026-07-04 (`8dfd011`).
|
||||
|
||||
## Google News RSS is a dead end from this infrastructure
|
||||
|
||||
`news.google.com/rss` entry links point to `/rss/articles/CBMi…` redirects that
|
||||
hit a consent wall from EU IPs, and the inner `AU_yqL` id is only resolvable via
|
||||
Google's private batchexecute API. Do not retry. The news seed uses Bing News
|
||||
RSS instead (`ENABLE_NEWS_SEED`, real publisher URL in the `?url=` param of
|
||||
apiclick.aspx — unwrapped by `_unwrap_news_link`).
|
||||
+15
-18
@@ -1,25 +1,22 @@
|
||||
# Core
|
||||
python-telegram-bot==22.8
|
||||
httpx==0.28.1
|
||||
aiohttp==3.14.1
|
||||
# NO instalar backend brotli (Brotli/brotlicffi): el decode incremental br de
|
||||
# aiohttp 3.14 está roto (falla con streams válidos según el troceo), y con un
|
||||
# backend presente aiohttp anuncia br POR DEFECTO en toda sesión sin
|
||||
# Accept-Encoding explícito — el 2026-07-04 eso rompió publish_draft/autofill
|
||||
# contra Ghost/Cloudflare (drafts duplicados). Sin backend, nada anuncia br.
|
||||
fastapi==0.139.0
|
||||
uvicorn==0.30.0
|
||||
python-telegram-bot==21.5
|
||||
httpx==0.27.0
|
||||
aiohttp==3.10.0
|
||||
|
||||
# Scraping
|
||||
beautifulsoup4==4.15.0
|
||||
lxml==5.4.0
|
||||
beautifulsoup4==4.12.3
|
||||
lxml==5.2.2
|
||||
trafilatura==1.12.2
|
||||
youtube-transcript-api==0.6.3
|
||||
pdfplumber==0.11.10
|
||||
pdfplumber==0.11.9
|
||||
feedparser==6.0.12
|
||||
duckduckgo-search==6.4.2
|
||||
duckduckgo-search==6.2.6
|
||||
|
||||
# Storage & Embeddings
|
||||
sqlite-vec==0.1.9
|
||||
aiosqlite==0.22.1
|
||||
aiosqlite==0.20.0
|
||||
|
||||
# Processing
|
||||
tiktoken==0.7.0
|
||||
@@ -30,12 +27,12 @@ scikit-learn==1.5.1
|
||||
anthropic>=0.40.0
|
||||
|
||||
# PDF export
|
||||
markdown==3.10.2
|
||||
reportlab==4.5.1
|
||||
markdown==3.7
|
||||
reportlab==4.2.5
|
||||
|
||||
# Utilities
|
||||
pydantic==2.13.4
|
||||
pydantic-settings==2.14.2
|
||||
pydantic==2.8.0
|
||||
pydantic-settings==2.4.0
|
||||
tenacity==9.0.0
|
||||
structlog==24.4.0
|
||||
python-dotenv==1.2.2
|
||||
python-dotenv==1.0.1
|
||||
|
||||
+4
-7
@@ -10,7 +10,7 @@ from typing import Optional
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import structlog
|
||||
from telegram import Update, Message, LinkPreviewOptions
|
||||
from telegram import Update, Message
|
||||
from telegram.ext import (
|
||||
Application, CommandHandler, MessageHandler,
|
||||
filters, ContextTypes
|
||||
@@ -397,7 +397,7 @@ async def cmd_generate(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||||
await update.message.reply_text(
|
||||
generator.last_publish_notice,
|
||||
parse_mode=ParseMode.MARKDOWN,
|
||||
link_preview_options=LinkPreviewOptions(is_disabled=True),
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to send SEO summary message", error=str(e))
|
||||
@@ -526,10 +526,7 @@ async def cmd_news(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||||
|
||||
items = [item_from_row(r) for r in recent]
|
||||
for chunk in format_digest(items):
|
||||
# Previews activos a propósito (el digest gana con la tarjeta del link).
|
||||
await update.message.reply_text(
|
||||
chunk, link_preview_options=LinkPreviewOptions(is_disabled=False)
|
||||
)
|
||||
await update.message.reply_text(chunk, disable_web_page_preview=False)
|
||||
finally:
|
||||
await db_conn.close()
|
||||
|
||||
@@ -972,7 +969,7 @@ async def _scheduler_loop(app: Application):
|
||||
for c in chunks:
|
||||
await app.bot.send_message(
|
||||
chat_id=news_chat_id, text=c,
|
||||
link_preview_options=LinkPreviewOptions(is_disabled=False),
|
||||
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])
|
||||
|
||||
+36
-52
@@ -1,70 +1,52 @@
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
from pydantic_settings import BaseSettings
|
||||
from pydantic import Field
|
||||
from typing import Optional
|
||||
|
||||
# Accept-Encoding para TODA petición aiohttp del proyecto. Nunca anunciar "br":
|
||||
# el descompresor incremental de aiohttp 3.14 está roto, y con un backend brotli
|
||||
# instalado aiohttp lo anunciaría POR DEFECTO en cualquier sesión sin
|
||||
# Accept-Encoding explícito (incidente 2026-07-04, ver KNOWN-ISSUES.md).
|
||||
# Toda sesión/petición nueva debe usar esta constante, no un literal.
|
||||
SAFE_ACCEPT_ENCODING = "gzip, deflate"
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
# Telegram
|
||||
telegram_bot_token: str = Field(...)
|
||||
telegram_allowed_users: str = Field("") # comma-separated user IDs
|
||||
telegram_bot_token: str = Field(..., env="TELEGRAM_BOT_TOKEN")
|
||||
telegram_allowed_users: str = Field("", env="TELEGRAM_ALLOWED_USERS") # comma-separated user IDs
|
||||
|
||||
# Ollama
|
||||
ollama_url: str = Field("http://ollama.chemavx.xyz")
|
||||
ollama_model: str = Field("qwen2.5:3b")
|
||||
ollama_embed_model: str = Field("qwen2.5:3b")
|
||||
ollama_url: str = Field("http://ollama.chemavx.xyz", env="OLLAMA_URL")
|
||||
ollama_model: str = Field("qwen2.5:3b", env="OLLAMA_MODEL")
|
||||
ollama_embed_model: str = Field("qwen2.5:3b", env="OLLAMA_EMBED_MODEL")
|
||||
|
||||
# Claude fallback (optional)
|
||||
anthropic_api_key: Optional[str] = Field(None)
|
||||
claude_model: str = Field("claude-haiku-4-5")
|
||||
anthropic_api_key: Optional[str] = Field(None, env="ANTHROPIC_API_KEY")
|
||||
claude_model: str = Field("claude-haiku-4-5", env="CLAUDE_MODEL")
|
||||
|
||||
# Database
|
||||
db_path: str = Field("/data/researchowl.db")
|
||||
db_path: str = Field("/data/researchowl.db", env="DB_PATH")
|
||||
|
||||
# Scraping
|
||||
max_depth: int = Field(3) # recursion depth
|
||||
max_sources: int = Field(150) # hard cap
|
||||
max_pages_per_search: int = Field(5)
|
||||
max_depth: int = Field(3, env="MAX_DEPTH") # recursion depth
|
||||
max_sources: int = Field(150, env="MAX_SOURCES") # hard cap
|
||||
max_pages_per_search: int = Field(5, env="MAX_PAGES_PER_SEARCH")
|
||||
searxng_url: str = Field(
|
||||
"http://searxng-svc.researchowl.svc.cluster.local:8080/search",
|
||||
env="SEARXNG_URL"
|
||||
)
|
||||
# Motores que el scraper pide a SearXNG (todos vienen configurados por los
|
||||
# defaults de la instancia, use_default_settings: true). Verificados en vivo
|
||||
# 2026-07-03: la lista ampliada duplica resultados y dominios únicos (~2x)
|
||||
# por ~+2s/query, y da redundancia cuando brave/DDG caen en rate limit o
|
||||
# CAPTCHA. Excluidos: qwant (access denied), yahoo (roto), wikidata (0
|
||||
# resultados en queries de texto), wayback machine (duplica contenido vivo).
|
||||
searxng_engines: str = Field(
|
||||
"duckduckgo,google,bing,brave,startpage,mojeek,presearch,"
|
||||
"google news,bing news,internet archive scholar"
|
||||
)
|
||||
request_timeout: int = Field(30)
|
||||
request_delay: float = Field(1.0) # seconds between requests
|
||||
min_content_length: int = Field(200) # chars
|
||||
request_timeout: int = Field(30, env="REQUEST_TIMEOUT")
|
||||
request_delay: float = Field(1.0, env="REQUEST_DELAY") # seconds between requests
|
||||
min_content_length: int = Field(200, env="MIN_CONTENT_LENGTH") # chars
|
||||
|
||||
# Fuentes opcionales — desactivadas por defecto: la IP del homelab está
|
||||
# bloqueada por Reddit (403) y YouTube (transcripts vacíos), eran peso muerto.
|
||||
enable_youtube: bool = Field(False)
|
||||
enable_reddit: bool = Field(False)
|
||||
# Bing News RSS como semilla de actualidad — off hasta activarlo a propósito
|
||||
enable_news_seed: bool = Field(False)
|
||||
enable_youtube: bool = Field(False, env="ENABLE_YOUTUBE")
|
||||
enable_reddit: bool = Field(False, env="ENABLE_REDDIT")
|
||||
|
||||
# Processing
|
||||
chunk_size: int = Field(800) # tokens per chunk
|
||||
chunk_overlap: int = Field(100)
|
||||
quality_threshold: float = Field(0.3) # 0-1, chunks below discarded
|
||||
chunk_size: int = Field(800, env="CHUNK_SIZE") # tokens per chunk
|
||||
chunk_overlap: int = Field(100, env="CHUNK_OVERLAP")
|
||||
quality_threshold: float = Field(0.3, env="QUALITY_THRESHOLD") # 0-1, chunks below discarded
|
||||
|
||||
# Ghost CMS
|
||||
ghost_url: Optional[str] = Field(None)
|
||||
ghost_api_key: Optional[str] = Field(None)
|
||||
ghost_url_en: str = Field("")
|
||||
ghost_api_key_en: str = Field("")
|
||||
ghost_url: Optional[str] = Field(None, env="GHOST_URL")
|
||||
ghost_api_key: Optional[str] = Field(None, env="GHOST_API_KEY")
|
||||
ghost_url_en: str = Field("", env="GHOST_URL_EN")
|
||||
ghost_api_key_en: str = Field("", env="GHOST_API_KEY_EN")
|
||||
|
||||
# SEO autofill — "off" | "on" | "dryrun" (default off).
|
||||
# off = today's exact behavior (bare draft, no second LLM call).
|
||||
@@ -73,27 +55,28 @@ class Settings(BaseSettings):
|
||||
# dryrun = runs the full pipeline + reports the proposed SEO to Telegram, but
|
||||
# writes a BARE draft (no seo fields), for inspection before trusting
|
||||
# the write path. A `/generate blog en dry` arg forces dryrun per-call.
|
||||
seo_autofill: str = Field("off")
|
||||
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)
|
||||
NEWS_POLL_INTERVAL_HOURS: int = Field(6)
|
||||
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)
|
||||
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)
|
||||
NEWS_MAX_ITEMS: int = Field(15, env="NEWS_MAX_ITEMS")
|
||||
|
||||
# Alerts
|
||||
cost_alert_threshold: float = Field(0.15)
|
||||
cost_alert_threshold: float = Field(0.15, env="COST_ALERT_THRESHOLD")
|
||||
|
||||
# App
|
||||
log_level: str = Field("INFO")
|
||||
timezone: str = Field("Europe/Madrid")
|
||||
log_level: str = Field("INFO", env="LOG_LEVEL")
|
||||
timezone: str = Field("Europe/Madrid", env="TIMEZONE")
|
||||
|
||||
@property
|
||||
def allowed_user_ids(self) -> list[int]:
|
||||
@@ -119,7 +102,8 @@ class Settings(BaseSettings):
|
||||
def seo_autofill_dryrun(self) -> bool:
|
||||
return (self.seo_autofill or "").strip().lower() == "dryrun"
|
||||
|
||||
model_config = SettingsConfigDict(env_file=".env")
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
+10
-110
@@ -9,10 +9,9 @@ import json
|
||||
import re
|
||||
import time
|
||||
|
||||
import aiohttp
|
||||
import structlog
|
||||
|
||||
from src.config import settings, SAFE_ACCEPT_ENCODING
|
||||
from src.config import settings
|
||||
from src.llm import get_anthropic_client
|
||||
from src.processor.processor import OllamaClient, ContentProcessor
|
||||
from src.db.database import ResearchDB, OutputType
|
||||
@@ -386,24 +385,6 @@ def _seo_dryrun_message(ghost: "GhostPublisher", post: dict, seo: dict, inserted
|
||||
)
|
||||
|
||||
|
||||
def _norm_title(t: str) -> str:
|
||||
"""Normaliza un título para compararlo con lo que Ghost almacenó: colapsa
|
||||
whitespace y trunca a 255 chars (límite de posts.title en Ghost — un H1
|
||||
largo del LLM puede volver truncado/normalizado, y la igualdad exacta
|
||||
fallaría justo en los casos que el guard debe cubrir)."""
|
||||
return " ".join((t or "").split())[:255]
|
||||
|
||||
|
||||
def _parse_ghost_ts(ts: str) -> float | None:
|
||||
"""created_at de la Admin API (ISO 8601, p.ej. 2026-07-04T20:26:00.000+00:00
|
||||
o sufijo Z) → epoch. None si no parsea."""
|
||||
try:
|
||||
from datetime import datetime
|
||||
return datetime.fromisoformat(ts.replace("Z", "+00:00")).timestamp()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
class GhostPublisher:
|
||||
def __init__(self, lang: str = "es"):
|
||||
self.lang = lang
|
||||
@@ -438,64 +419,6 @@ class GhostPublisher:
|
||||
)
|
||||
return f"{signing}.{sig}"
|
||||
|
||||
def _admin_headers(self) -> dict:
|
||||
"""Headers canónicos de la Admin API (token JWT fresco por llamada).
|
||||
Único sitio donde se construyen: cualquier cambio (Accept-Version,
|
||||
encoding) se hace aquí y cubre todas las llamadas a Ghost."""
|
||||
return {
|
||||
"Authorization": f"Ghost {self._make_token()}",
|
||||
"Accept-Version": "v5.0",
|
||||
# Nunca heredar el default de aiohttp: con un backend brotli
|
||||
# instalado anunciaría br y el decode roto de aiohttp 3.14 rompe
|
||||
# la lectura de respuestas (incidente 2026-07-04, KNOWN-ISSUES.md).
|
||||
"Accept-Encoding": SAFE_ACCEPT_ENCODING,
|
||||
}
|
||||
|
||||
async def _admin_get(self, query: str, timeout: float = 15) -> dict | None:
|
||||
"""GET a {url}/ghost/api/admin/{query} con auth + headers canónicos.
|
||||
None si non-200 (logueado); las excepciones de red/parseo suben tal
|
||||
cual para que cada caller aplique su propia política best-effort."""
|
||||
async with aiohttp.ClientSession(
|
||||
timeout=aiohttp.ClientTimeout(total=timeout)) as sess:
|
||||
async with sess.get(f"{self.url}/ghost/api/admin/{query}",
|
||||
headers=self._admin_headers()) as resp:
|
||||
if resp.status != 200:
|
||||
body = await resp.text()
|
||||
logger.warning("Ghost admin GET non-200", query=query[:80],
|
||||
status=resp.status, body=body[:200])
|
||||
return None
|
||||
return await resp.json()
|
||||
|
||||
async def find_draft_by_title(self, title: str,
|
||||
since: float | None = None) -> dict | None:
|
||||
"""Busca entre los drafts recientes uno con título exacto.
|
||||
|
||||
Mitad de recuperación del guard anti-duplicados de publish_draft: si el
|
||||
POST fue aceptado por Ghost pero la lectura de la respuesta falló
|
||||
(p.ej. decode roto), el draft existe aunque el caller viera una
|
||||
excepción. Con `since` (epoch) solo casan drafts creados en este
|
||||
intento — un draft viejo del mismo título de una run anterior NO cuenta
|
||||
como "ya publicado". La comparación usa _norm_title (whitespace
|
||||
colapsado + truncado a 255) para sobrevivir la normalización de Ghost.
|
||||
Best-effort: None si no hay match o si falla."""
|
||||
data = await self._admin_get(
|
||||
"posts/?filter=status:draft&order=created_at%20desc&limit=15"
|
||||
"&fields=id,title,slug,created_at"
|
||||
)
|
||||
if data is None:
|
||||
return None
|
||||
wanted = _norm_title(title)
|
||||
for p in data.get("posts", []):
|
||||
if _norm_title(p.get("title") or "") != wanted:
|
||||
continue
|
||||
if since is not None:
|
||||
created = _parse_ghost_ts(p.get("created_at") or "")
|
||||
# 60s de margen por posible desfase de reloj Ghost/bot.
|
||||
if created is None or created < since - 60:
|
||||
continue
|
||||
return p
|
||||
return None
|
||||
|
||||
async def publish_draft(self, title: str, markdown_content: str,
|
||||
tags: list[str] | None = None,
|
||||
seo: dict | None = None,
|
||||
@@ -509,6 +432,8 @@ class GhostPublisher:
|
||||
* seo — a generate_seo_fields dict; when given, its meta/OG/Twitter fields
|
||||
are added to the post and its (allow-list) tags are used.
|
||||
"""
|
||||
import aiohttp as _aio
|
||||
|
||||
# Caller-supplied linked HTML wins; otherwise convert markdown as before.
|
||||
html = body_html if body_html is not None else self._build_html(markdown_content)
|
||||
|
||||
@@ -529,6 +454,7 @@ class GhostPublisher:
|
||||
"sections": [[10, 0]],
|
||||
})
|
||||
|
||||
token = self._make_token()
|
||||
# Language-aware default tag (fixes the hardcoded "investigacion" for EN).
|
||||
tag_names = tags or [_DEFAULT_TAG.get(self.lang, "investigation")]
|
||||
post_obj = {
|
||||
@@ -548,40 +474,19 @@ class GhostPublisher:
|
||||
"twitter_description": seo["twitter_description"],
|
||||
})
|
||||
body = {"posts": [post_obj]}
|
||||
attempt_start = time.time()
|
||||
async with aiohttp.ClientSession() as sess:
|
||||
async with _aio.ClientSession() as sess:
|
||||
async with sess.post(
|
||||
f"{self.url}/ghost/api/admin/posts/",
|
||||
json=body,
|
||||
headers=self._admin_headers(),
|
||||
headers={
|
||||
"Authorization": f"Ghost {token}",
|
||||
"Accept-Version": "v5.0",
|
||||
},
|
||||
) as resp:
|
||||
if resp.status not in (200, 201):
|
||||
text = await resp.text()
|
||||
raise ValueError(f"Ghost API {resp.status}: {text[:300]}")
|
||||
# Guard anti-duplicados (incidente 2026-07-04): el 2xx confirma
|
||||
# que Ghost YA creó el draft; si la lectura del body falla
|
||||
# (p.ej. decode br roto), recuperamos el draft recién creado en
|
||||
# vez de propagar — propagar hacía que el caller (o el usuario
|
||||
# con /publish) reintentara y duplicara. El guard vive AQUÍ para
|
||||
# cubrir a todos los callers, y solo se activa tras un POST
|
||||
# aceptado: un fallo pre-POST jamás lo dispara.
|
||||
try:
|
||||
return await resp.json()
|
||||
except Exception as read_err:
|
||||
existing = None
|
||||
try:
|
||||
existing = await self.find_draft_by_title(
|
||||
title, since=attempt_start)
|
||||
except Exception as find_err:
|
||||
logger.warning("publish_draft: draft-exists check failed",
|
||||
error=str(find_err))
|
||||
if existing:
|
||||
logger.warning(
|
||||
"publish_draft: POST aceptado pero lectura de la "
|
||||
"respuesta falló; draft recuperado sin re-publicar",
|
||||
post_id=existing["id"], error=str(read_err))
|
||||
return {"posts": [existing]}
|
||||
raise
|
||||
return await resp.json()
|
||||
|
||||
|
||||
# ─── Output generation ────────────────────────────────────────────────────────
|
||||
@@ -649,11 +554,6 @@ class OutputGenerator:
|
||||
except Exception as e:
|
||||
logger.warning("SEO autofill failed; falling back to bare draft",
|
||||
error=str(e))
|
||||
# Sin guard anti-duplicados aquí: vive DENTRO de publish_draft
|
||||
# (solo se activa tras un POST aceptado). Un fallo pre-POST
|
||||
# (Ollama caído, menú, links) cae aquí y DEBE publicar el
|
||||
# contenido nuevo — un guard por título casaba con drafts
|
||||
# viejos del mismo topic y lo descartaba en silencio.
|
||||
# fall through to the bare-draft publish below
|
||||
|
||||
# OFF mode, or autofill failed → today's exact bare-draft publish.
|
||||
|
||||
+1
-35
@@ -21,12 +21,7 @@ UA = "ExclusionZone-NewsBot/1.0 (+https://theexclusionzone.com)"
|
||||
|
||||
FEEDS: list[dict] = [
|
||||
{"name": "Gran Misterio", "url": "https://granmisterio.org/feed/", "lang": "es"},
|
||||
# Reemplazos ES tras retirar Espacio Misterio (XML roto). Verificados 2026-07-03:
|
||||
# MysteryPlanet y Marcianitos publican a diario/casi a diario; El Ojo Crítico es
|
||||
# mensual (poco volumen a propósito). Ufopolis/MundoEsoterico descartados (muerto/roto).
|
||||
{"name": "MysteryPlanet", "url": "https://mysteryplanet.com.ar/site/feed/", "lang": "es"},
|
||||
{"name": "Marcianitos Verdes", "url": "https://marcianitosverdes.haaan.com/feed/", "lang": "es"},
|
||||
{"name": "El Ojo Crítico", "url": "https://elojocritico.info/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"},
|
||||
@@ -42,16 +37,6 @@ _SHORT_TOKEN_MAXLEN = 4
|
||||
# Límite de Telegram 4096; troceamos por debajo con margen.
|
||||
_TG_LIMIT = 4000
|
||||
|
||||
# Reddit sin OAuth ratelimita por IP con ventana larga (>30s verificado): la 2ª
|
||||
# petición seguida a reddit.com devuelve 429 SIEMPRE, así que solo se pollea un
|
||||
# feed de reddit por run, rotando. Contador por proceso: tras un reinicio se
|
||||
# empieza por el primero, lo cual es inocuo.
|
||||
_reddit_turn = 0
|
||||
|
||||
|
||||
def _is_reddit(feed: dict) -> bool:
|
||||
return "reddit.com" in feed["url"]
|
||||
|
||||
|
||||
@dataclass
|
||||
class NewsItem:
|
||||
@@ -105,32 +90,13 @@ async def poll_feeds(db, settings) -> list[NewsItem]:
|
||||
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.
|
||||
|
||||
Reddit: solo se pollea UN feed de reddit.com por run, rotando entre ellos
|
||||
(ver _reddit_turn) — dos peticiones seguidas garantizan un 429 en la 2ª.
|
||||
"""
|
||||
global _reddit_turn
|
||||
keywords = [k.strip() for k in settings.NEWS_KEYWORDS.split(",") if k.strip()]
|
||||
new_items: list[NewsItem] = []
|
||||
|
||||
reddit_feeds = [f for f in FEEDS if _is_reddit(f)]
|
||||
skip: set[str] = set()
|
||||
if len(reddit_feeds) > 1:
|
||||
chosen = reddit_feeds[_reddit_turn % len(reddit_feeds)]
|
||||
_reddit_turn += 1
|
||||
skip = {f["name"] for f in reddit_feeds if f["name"] != chosen["name"]}
|
||||
|
||||
for feed in FEEDS:
|
||||
if feed["name"] in skip:
|
||||
continue
|
||||
try:
|
||||
parsed = await asyncio.to_thread(feedparser.parse, feed["url"], agent=UA)
|
||||
status = getattr(parsed, "status", None)
|
||||
# Un 429/403 llega con bozo=False y 0 entries: sin este guard se
|
||||
# tragaba en silencio (así estuvo r/UFOB sin sembrar desde F1).
|
||||
if status and status >= 400:
|
||||
log.warning("feed %s HTTP %s; salto", feed["name"], status)
|
||||
continue
|
||||
if parsed.bozo and not parsed.entries:
|
||||
log.warning("feed bozo sin entries: %s (%s)",
|
||||
feed["name"], getattr(parsed, "bozo_exception", ""))
|
||||
|
||||
+37
-187
@@ -7,7 +7,7 @@ import random
|
||||
import re
|
||||
import time
|
||||
from typing import Optional
|
||||
from urllib.parse import urljoin, urlparse, parse_qs, quote, quote_plus
|
||||
from urllib.parse import urljoin, urlparse, quote_plus
|
||||
|
||||
import aiohttp
|
||||
import feedparser
|
||||
@@ -18,7 +18,7 @@ from duckduckgo_search import DDGS
|
||||
from youtube_transcript_api import YouTubeTranscriptApi, NoTranscriptFound
|
||||
from tenacity import retry, stop_after_attempt, wait_exponential
|
||||
|
||||
from src.config import settings, SAFE_ACCEPT_ENCODING
|
||||
from src.config import settings
|
||||
from src.db.database import ResearchDB
|
||||
|
||||
logger = structlog.get_logger()
|
||||
@@ -27,13 +27,7 @@ HEADERS = {
|
||||
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
|
||||
"Accept-Language": "en-US,en;q=0.9,es;q=0.8",
|
||||
# Sin "br": el descompresor incremental de aiohttp 3.14 falla con streams
|
||||
# brotli válidos según el troceo de chunks (disclosure.org, todaywhy.com;
|
||||
# los mismos bytes descomprimen bien offline). Con gzip el camino zlib es
|
||||
# sólido. OJO: ya NO hay ningún backend brotli instalado (397546a quitó
|
||||
# brotlicffi) — si un servidor responde br sin que se haya anunciado,
|
||||
# aiohttp falla sin recuperación posible y esa fuente se pierde.
|
||||
"Accept-Encoding": SAFE_ACCEPT_ENCODING,
|
||||
"Accept-Encoding": "gzip, deflate, br",
|
||||
"DNT": "1",
|
||||
}
|
||||
|
||||
@@ -42,7 +36,7 @@ REDDIT_HEADERS = {
|
||||
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
|
||||
"Accept": "application/json, text/javascript, */*; q=0.01",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
"Accept-Encoding": SAFE_ACCEPT_ENCODING, # sin "br", ver HEADERS
|
||||
"Accept-Encoding": "gzip, deflate, br",
|
||||
"Referer": "https://www.reddit.com/",
|
||||
"X-Requested-With": "XMLHttpRequest",
|
||||
}
|
||||
@@ -59,10 +53,6 @@ YOUTUBE_RE = re.compile(r"(?:youtube\.com/watch\?v=|youtu\.be/)([a-zA-Z0-9_-]{11
|
||||
PDF_RE = re.compile(r"\.pdf(\?|$)", re.IGNORECASE)
|
||||
REDDIT_RE = re.compile(r"reddit\.com/(r/\w+/comments/\w+)")
|
||||
WIKIPEDIA_RE = re.compile(r"wikipedia\.org/wiki/(.+)")
|
||||
# Segmentos de path típicos de feeds, con límite de palabra: el substring puro
|
||||
# clasificaba /feedback o /atomic como rss.
|
||||
RSS_RE = re.compile(r"/(rss|feeds?|atom)(/|\.xml|\.rss|$)|\.rss$|format=rss",
|
||||
re.IGNORECASE)
|
||||
|
||||
|
||||
async def fetch_with_retry(fetch_fn, source_name: str, max_retries: int = 3):
|
||||
@@ -92,7 +82,7 @@ def detect_source_type(url: str) -> str:
|
||||
return "wikipedia"
|
||||
if "arxiv.org" in url:
|
||||
return "arxiv"
|
||||
if RSS_RE.search(url):
|
||||
if any(d in url for d in ["rss", "feed", "atom"]):
|
||||
return "rss"
|
||||
return "web"
|
||||
|
||||
@@ -110,25 +100,6 @@ def is_blacklisted(url: str) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def _unwrap_news_link(link: str) -> str:
|
||||
"""Bing News envuelve los links de entry en apiclick.aspx; la URL real del
|
||||
publisher viene en texto plano en el parámetro ?url=. Cualquier otro link
|
||||
pasa sin tocar. Si es un apiclick pero no trae URL usable, devuelve "" —
|
||||
el caller lo descarta por startswith("http") — en vez de dejar pasar el
|
||||
redirect de bing.com, que se rasparía como página de consent/basura."""
|
||||
try:
|
||||
parsed = urlparse(link)
|
||||
if (parsed.netloc.lower().endswith("bing.com")
|
||||
and parsed.path == "/news/apiclick.aspx"):
|
||||
real = parse_qs(parsed.query).get("url", [""])[0].strip()
|
||||
if real.startswith("//"): # protocol-relative
|
||||
real = "https:" + real
|
||||
return real if real.startswith("http") else ""
|
||||
except Exception:
|
||||
return ""
|
||||
return link
|
||||
|
||||
|
||||
def normalize_url(url: str) -> str:
|
||||
# Strip only the fragment. NO borrar el query string: rompía URLs de
|
||||
# YouTube (watch?v=...) y artículos que enrutan por query (?id=, ?p=).
|
||||
@@ -196,14 +167,11 @@ class ExhaustiveScraper:
|
||||
tasks = [
|
||||
self._seed_search(),
|
||||
self._seed_wikipedia(),
|
||||
self._seed_archive(),
|
||||
]
|
||||
if settings.enable_reddit:
|
||||
tasks.append(self._seed_reddit())
|
||||
if settings.enable_youtube:
|
||||
tasks.append(self._seed_youtube())
|
||||
if settings.enable_news_seed:
|
||||
tasks.append(self._seed_news())
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
async def _generate_ddg_queries(self) -> list[str]:
|
||||
@@ -261,17 +229,13 @@ class ExhaustiveScraper:
|
||||
params = {
|
||||
"q": query,
|
||||
"format": "json",
|
||||
"engines": settings.searxng_engines,
|
||||
"engines": "duckduckgo,google,bing,brave",
|
||||
"language": "all",
|
||||
}
|
||||
headers = {
|
||||
"Accept": "application/json",
|
||||
"X-Forwarded-For": "127.0.0.1",
|
||||
"User-Agent": "ResearchOwl/1.0",
|
||||
# Sin esto la sesión hereda el default de aiohttp, que anunciaría
|
||||
# br si algún día se reinstala un backend brotli — y el decode roto
|
||||
# de 3.14 tumbaría TODAS las búsquedas SearXNG (el camino primario).
|
||||
"Accept-Encoding": SAFE_ACCEPT_ENCODING,
|
||||
}
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
@@ -297,21 +261,6 @@ class ExhaustiveScraper:
|
||||
logger.warning("SearXNG failed", query=query, error=str(e))
|
||||
return []
|
||||
|
||||
# DDGS es síncrono (requests bloqueante por dentro): llamarlo directamente
|
||||
# desde código async congela el event loop entero — bot de Telegram incluido —
|
||||
# mientras dura la búsqueda. Estos helpers deben ejecutarse SIEMPRE vía
|
||||
# run_in_executor.
|
||||
|
||||
@staticmethod
|
||||
def _ddg_text_sync(query: str) -> list[dict]:
|
||||
with DDGS() as ddgs:
|
||||
return list(ddgs.text(query, max_results=settings.max_pages_per_search))
|
||||
|
||||
@staticmethod
|
||||
def _ddg_videos_sync(query: str) -> list[dict]:
|
||||
with DDGS() as ddgs:
|
||||
return list(ddgs.videos(query, max_results=10))
|
||||
|
||||
async def _seed_search(self):
|
||||
"""SearXNG primary + DDG fallback per query"""
|
||||
queries = await self._generate_ddg_queries()
|
||||
@@ -322,10 +271,12 @@ class ExhaustiveScraper:
|
||||
if not results:
|
||||
logger.info("SearXNG vacío, usando DDG", query=query)
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
results = await loop.run_in_executor(
|
||||
None, self._ddg_text_sync, query
|
||||
)
|
||||
with DDGS() as ddgs:
|
||||
ddg_results = list(ddgs.text(
|
||||
query,
|
||||
max_results=settings.max_pages_per_search
|
||||
))
|
||||
results = ddg_results
|
||||
logger.info("DDG fallback ok", query=query, results=len(results))
|
||||
except Exception as e:
|
||||
logger.warning("DDG fallback failed", query=query, error=str(e))
|
||||
@@ -343,92 +294,30 @@ class ExhaustiveScraper:
|
||||
await asyncio.sleep(random.uniform(1, 3))
|
||||
|
||||
async def _seed_wikipedia(self):
|
||||
"""Siembra Wikipedia en AMBOS idiomas siempre, con búsqueda full-text
|
||||
(list=search). El opensearch anterior era prefix-de-título y devolvía 0
|
||||
para topics verbosos ("incidente ovni de Manises 1979") aunque el
|
||||
artículo exista; y el break tras en dejaba es solo como fallback.
|
||||
Cada idioma va aislado: un fallo no afecta al otro."""
|
||||
"""Search Wikipedia API for correct article URLs.
|
||||
Tries English first, falls back to Spanish if no results found."""
|
||||
http = await self._get_http()
|
||||
added = 0
|
||||
|
||||
for lang in ("en", "es"):
|
||||
try:
|
||||
api_url = (
|
||||
f"https://{lang}.wikipedia.org/w/api.php?action=query"
|
||||
f"&list=search&srsearch={quote_plus(self.topic)}"
|
||||
f"&srlimit=8&format=json"
|
||||
f"https://{lang}.wikipedia.org/w/api.php?action=opensearch"
|
||||
f"&search={quote_plus(self.topic)}&limit=10&format=json"
|
||||
)
|
||||
async with http.get(api_url) as resp:
|
||||
data = await resp.json()
|
||||
hits = data.get("query", {}).get("search", [])
|
||||
added = 0
|
||||
for h in hits:
|
||||
title = h.get("title")
|
||||
if not title:
|
||||
continue
|
||||
url = (f"https://{lang}.wikipedia.org/wiki/"
|
||||
f"{quote(title.replace(' ', '_'))}")
|
||||
await self.db.add_source(self.session_id, url, "wikipedia",
|
||||
depth=0, title=title)
|
||||
added += 1
|
||||
logger.info("Wikipedia seed", lang=lang, found=added)
|
||||
urls = data[3] if len(data) > 3 else []
|
||||
for url in urls:
|
||||
if url:
|
||||
await self.db.add_source(self.session_id, url, "wikipedia", depth=0)
|
||||
added += 1
|
||||
logger.info("Wikipedia seed", lang=lang, found=len(urls))
|
||||
if added > 0:
|
||||
break # English results found — no need to try Spanish
|
||||
except Exception as e:
|
||||
logger.warning("Wikipedia API seed failed", lang=lang, error=str(e))
|
||||
|
||||
async def _seed_archive(self):
|
||||
"""Siembra Internet Archive (advancedsearch, mediatype:texts): documentos
|
||||
FOIA, informes y libros históricos. Se siembra la página /details/<id>;
|
||||
sus PDFs los descubre la recursión (la propia página los enlaza) y los
|
||||
extrae _extract_pdf. Sin fallback de query: el AND estricto de archive.org
|
||||
con el topic completo prima precisión — relajarlo mete ruido (verificado:
|
||||
'Manises' a secas devuelve loza valenciana). Best-effort: nunca rompe el seed."""
|
||||
try:
|
||||
http = await self._get_http()
|
||||
q = quote_plus(f"({self.topic}) AND mediatype:(texts)")
|
||||
url = (
|
||||
f"https://archive.org/advancedsearch.php?q={q}"
|
||||
"&fl[]=identifier&fl[]=title&rows=12&page=1&output=json"
|
||||
)
|
||||
async with http.get(url) as resp:
|
||||
if resp.status != 200:
|
||||
logger.warning("Archive.org seed non-200", status=resp.status)
|
||||
return
|
||||
data = await resp.json(content_type=None)
|
||||
docs = data.get("response", {}).get("docs", [])
|
||||
added = 0
|
||||
for d in docs:
|
||||
ident = d.get("identifier")
|
||||
if not ident:
|
||||
continue
|
||||
item_url = f"https://archive.org/details/{ident}"
|
||||
title = d.get("title")
|
||||
await self.db.add_source(
|
||||
self.session_id, item_url, "web", depth=0,
|
||||
title=title if isinstance(title, str) else None
|
||||
)
|
||||
added += 1
|
||||
logger.info("Archive.org seed", found=len(docs), added=added)
|
||||
except Exception as e:
|
||||
logger.warning("Archive.org seed failed", error=str(e))
|
||||
|
||||
async def _seed_news(self):
|
||||
"""Siembra Bing News RSS para cobertura de actualidad. Solo registra
|
||||
el feed como fuente tipo rss: _extract_rss (dispatch del pipeline) lo
|
||||
parsea y siembra sus entries — sin duplicar lógica de parseo aquí.
|
||||
Bing y no Google News: los links de Google (news.google.com/rss/articles/
|
||||
CBMi…) van a un muro de consent + redirect cifrado solo resoluble vía su
|
||||
API interna batchexecute (verificado 2026-07-04); los de Bing llevan la
|
||||
URL real del publisher en ?url= (ver _unwrap_news_link).
|
||||
Best-effort: nunca rompe el seed."""
|
||||
try:
|
||||
url = f"https://www.bing.com/news/search?q={quote_plus(self.topic)}&format=rss"
|
||||
await self.db.add_source(
|
||||
self.session_id, url, "rss", depth=0,
|
||||
title=f"Bing News: {self.topic}"
|
||||
)
|
||||
logger.info("News seed added", topic=self.topic)
|
||||
except Exception as e:
|
||||
logger.warning("News seed failed", error=str(e))
|
||||
|
||||
async def _seed_reddit(self):
|
||||
"""Search Reddit — sequential to avoid rate limiting"""
|
||||
try:
|
||||
@@ -458,18 +347,18 @@ class ExhaustiveScraper:
|
||||
async def _seed_youtube(self):
|
||||
"""Search YouTube via DDG for video transcripts"""
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
results = await loop.run_in_executor(
|
||||
None, self._ddg_videos_sync,
|
||||
f"{self.topic} documentary explanation"
|
||||
)
|
||||
for r in results:
|
||||
url = r.get("content", "")
|
||||
if "youtube.com" in url or "youtu.be" in url:
|
||||
await self.db.add_source(
|
||||
self.session_id, url, "youtube", depth=0,
|
||||
title=r.get("title")
|
||||
)
|
||||
with DDGS() as ddgs:
|
||||
results = list(ddgs.videos(
|
||||
f"{self.topic} documentary explanation",
|
||||
max_results=10
|
||||
))
|
||||
for r in results:
|
||||
url = r.get("content", "")
|
||||
if "youtube.com" in url or "youtu.be" in url:
|
||||
await self.db.add_source(
|
||||
self.session_id, url, "youtube", depth=0,
|
||||
title=r.get("title")
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("YouTube seed failed", error=str(e))
|
||||
|
||||
@@ -583,9 +472,6 @@ class ExhaustiveScraper:
|
||||
lambda: self._extract_reddit(url), url
|
||||
)
|
||||
await asyncio.sleep(settings.request_delay)
|
||||
elif source_type == "rss":
|
||||
# El feed no tiene contenido propio: es puro descubrimiento.
|
||||
return await self._extract_rss(source_id, url, source["depth"])
|
||||
elif source_type == "pdf":
|
||||
content, title = await fetch_with_retry(
|
||||
lambda: self._extract_pdf(url), url
|
||||
@@ -783,42 +669,6 @@ class ExhaustiveScraper:
|
||||
logger.warning("Reddit extraction failed", url=url, error=str(e))
|
||||
return None, None
|
||||
|
||||
async def _extract_rss(self, source_id: int, url: str, depth: int) -> int:
|
||||
"""RSS/Atom: siembra las entries del feed como fuentes nuevas (filtradas
|
||||
por relevancia de título/URL) y marca el feed como skipped — no tiene
|
||||
contenido propio que trocear. feedparser.parse es bloqueante: va en hilo,
|
||||
igual que en el monitor de noticias. Devuelve nº de fuentes añadidas."""
|
||||
parsed = await asyncio.to_thread(feedparser.parse, url,
|
||||
agent=HEADERS["User-Agent"])
|
||||
entries = parsed.entries or []
|
||||
added = 0
|
||||
if depth < settings.max_depth:
|
||||
# Se escanean TODAS las entries y el cap aplica a las añadidas:
|
||||
# capar antes de filtrar perdía las relevantes que no caen entre
|
||||
# las 20 más recientes (verificado con thedebrief: 0/20 vs 3/100).
|
||||
for e in entries:
|
||||
if added >= 20: # cap, como los 30 links/página de web
|
||||
break
|
||||
link = normalize_url(_unwrap_news_link(getattr(e, "link", "") or ""))
|
||||
title = getattr(e, "title", "") or ""
|
||||
if (not link.startswith("http") or is_blacklisted(link)
|
||||
or not self._url_is_relevant(link, title)):
|
||||
continue
|
||||
if await self.db.source_exists(self.session_id, link):
|
||||
continue
|
||||
await self.db.add_source(
|
||||
self.session_id, link, detect_source_type(link),
|
||||
depth=depth + 1, title=title or None
|
||||
)
|
||||
added += 1
|
||||
await self.db.update_source(
|
||||
source_id, status="skipped",
|
||||
error=f"rss feed: {added} entries sembradas"
|
||||
)
|
||||
logger.info("RSS feed seeded", url=url[:60],
|
||||
entries=len(entries), added=added)
|
||||
return added
|
||||
|
||||
async def _extract_pdf(self, url: str) -> tuple[Optional[str], Optional[str]]:
|
||||
"""Download and extract PDF text"""
|
||||
import pdfplumber
|
||||
|
||||
+24
-20
@@ -37,19 +37,14 @@ SITE_BY_LANG = {
|
||||
|
||||
# Tag allow-list — the model may ONLY pick from these; invented tags are dropped
|
||||
# deterministically after the call (never trust the LLM to self-limit). EN is the
|
||||
# live vocabulary Jose confirmed; ES is the live core taxonomy on zonadeexclusion
|
||||
# (fetched 2026-07-03), mirroring EN. Deliberately excluded from ES: the one-post
|
||||
# long-tail tags the model invented before constraining, and "investigacion-2"
|
||||
# (a Ghost case-collision duplicate of "investigacion").
|
||||
# live vocabulary Jose confirmed; ES is not yet constrained, so we don't enforce
|
||||
# it (model picks freely there until an ES vocab is pinned down).
|
||||
ALLOWED_TAGS = {
|
||||
"en": [
|
||||
"uap", "declassified", "military-cases", "classic-cases",
|
||||
"investigation", "spain-cases", "latin-america",
|
||||
],
|
||||
"es": [
|
||||
"uap", "desclasificados", "casos-militares", "casos-clasicos",
|
||||
"investigacion", "casos-espana",
|
||||
],
|
||||
# "es": [...] # TODO: pin the live ES tag vocabulary before constraining.
|
||||
}
|
||||
|
||||
# Language-aware default tag when nothing from the allow-list fits / the model
|
||||
@@ -75,20 +70,33 @@ async def fetch_published_menu(lang: str) -> list[dict]:
|
||||
try:
|
||||
# Lazy import to avoid a heavy/circular import at module load.
|
||||
from src.generator.generator import GhostPublisher
|
||||
import aiohttp as _aio
|
||||
|
||||
pub = GhostPublisher(lang=lang)
|
||||
if not pub.is_configured():
|
||||
logger.warning("seo.menu: Ghost not configured for lang", lang=lang)
|
||||
return []
|
||||
|
||||
# _admin_get lleva los headers canónicos (auth, Accept-Version y el
|
||||
# Accept-Encoding sin br — ver KNOWN-ISSUES.md) y loguea los non-200.
|
||||
data = await pub._admin_get(
|
||||
"posts/?filter=status:published&fields=id,slug,title&limit=all",
|
||||
timeout=30,
|
||||
token = pub._make_token()
|
||||
url = (
|
||||
f"{pub.url}/ghost/api/admin/posts/"
|
||||
"?filter=status:published&fields=id,slug,title&limit=all"
|
||||
)
|
||||
if data is None:
|
||||
return []
|
||||
timeout = _aio.ClientTimeout(total=30)
|
||||
async with _aio.ClientSession(timeout=timeout) as sess:
|
||||
async with sess.get(
|
||||
url,
|
||||
headers={
|
||||
"Authorization": f"Ghost {token}",
|
||||
"Accept-Version": "v5.0",
|
||||
},
|
||||
) as resp:
|
||||
if resp.status != 200:
|
||||
body = await resp.text()
|
||||
logger.warning("seo.menu: non-200 from Ghost",
|
||||
status=resp.status, body=body[:200])
|
||||
return []
|
||||
data = await resp.json()
|
||||
|
||||
menu = [
|
||||
{"slug": p["slug"], "title": p.get("title", "")}
|
||||
@@ -108,13 +116,9 @@ async def fetch_published_menu(lang: str) -> list[dict]:
|
||||
def _system_prompt(lang: str) -> str:
|
||||
allow = ", ".join(ALLOWED_TAGS.get(lang, []))
|
||||
out_lang = "SPANISH" if lang == "es" else "ENGLISH"
|
||||
# The anti-legacy "never use investigacion" rule is EN-only: on the ES site
|
||||
# "investigacion" IS the canonical tag (and the allow-list already excludes
|
||||
# the "investigacion-2" duplicate).
|
||||
legacy_clause = ", never use \"investigacion\"" if lang == "en" else ""
|
||||
tag_clause = (
|
||||
f"TAGS: choose 2-4 tags ONLY from this exact list (never invent a tag, "
|
||||
f"never translate it{legacy_clause}): {allow}.\n"
|
||||
f"never translate it, never use \"investigacion\"): {allow}.\n"
|
||||
if allow else
|
||||
"TAGS: 2-4 lowercase-hyphenated topical tags appropriate to the article.\n"
|
||||
)
|
||||
|
||||
@@ -11,16 +11,6 @@ def test_detect_source_type():
|
||||
assert detect_source_type("https://example.com/article") == "web"
|
||||
|
||||
|
||||
def test_detect_source_type_rss():
|
||||
assert detect_source_type("https://thedebrief.org/feed/") == "rss"
|
||||
assert detect_source_type("https://example.com/rss") == "rss"
|
||||
assert detect_source_type("https://example.com/feeds/all.atom.xml") == "rss"
|
||||
assert detect_source_type("https://www.liberationtimes.com/?format=rss") == "rss"
|
||||
# Falsos positivos del substring viejo: no son feeds.
|
||||
assert detect_source_type("https://example.com/feedback") == "web"
|
||||
assert detect_source_type("https://example.com/atomic-theory") == "web"
|
||||
|
||||
|
||||
def test_is_blacklisted():
|
||||
assert is_blacklisted("https://facebook.com/something") == True
|
||||
assert is_blacklisted("https://en.wikipedia.org/wiki/Test") == False
|
||||
@@ -36,22 +26,3 @@ def test_simple_chunk():
|
||||
chunks = simple_chunk(text, chunk_size=100, overlap=20)
|
||||
assert len(chunks) > 1
|
||||
assert all(isinstance(c, str) for c in chunks)
|
||||
|
||||
|
||||
def test_unwrap_news_link():
|
||||
from src.scraper.exhaustive import _unwrap_news_link
|
||||
wrapped = ("http://www.bing.com/news/apiclick.aspx?ref=FexRss&aid=&tid=x"
|
||||
"&url=https://example.com/article&c=1&mkt=es-es")
|
||||
assert _unwrap_news_link(wrapped) == "https://example.com/article"
|
||||
# Links normales pasan intactos
|
||||
assert _unwrap_news_link("https://thedebrief.org/foo") == "https://thedebrief.org/foo"
|
||||
# url= protocol-relative se normaliza a https
|
||||
assert _unwrap_news_link(
|
||||
"http://www.bing.com/news/apiclick.aspx?url=//example.com/article"
|
||||
) == "https://example.com/article"
|
||||
# apiclick sin url= usable se descarta ("" → el caller lo salta), nunca se
|
||||
# deja pasar el redirect de bing.com como fuente
|
||||
assert _unwrap_news_link("http://www.bing.com/news/apiclick.aspx?ref=x") == ""
|
||||
assert _unwrap_news_link(
|
||||
"http://www.bing.com/news/apiclick.aspx?url=javascript:alert(1)"
|
||||
) == ""
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
from src.seo.autofill import ALLOWED_TAGS, DEFAULT_TAG, _coerce, _system_prompt
|
||||
|
||||
BASE = {
|
||||
"meta_title": "t",
|
||||
"meta_description": "d",
|
||||
"custom_excerpt": "e",
|
||||
"image_query": "q",
|
||||
"image_context": "c",
|
||||
}
|
||||
|
||||
|
||||
def test_coerce_es_drops_invented_tags():
|
||||
obj = dict(BASE, tags=["uap", "humanoides", "Desclasificados", "investigacion-2"])
|
||||
out = _coerce(obj, "es")
|
||||
assert out["tags"] == ["uap", "desclasificados"]
|
||||
|
||||
|
||||
def test_coerce_es_falls_back_to_default():
|
||||
obj = dict(BASE, tags=["pentagono", "encuentros-cercanos"])
|
||||
out = _coerce(obj, "es")
|
||||
assert out["tags"] == [DEFAULT_TAG["es"]]
|
||||
|
||||
|
||||
def test_coerce_en_still_constrained():
|
||||
obj = dict(BASE, tags=["uap", "investigacion"])
|
||||
out = _coerce(obj, "en")
|
||||
assert out["tags"] == ["uap"]
|
||||
|
||||
|
||||
def test_system_prompt_lists_allowed_tags_per_lang():
|
||||
es = _system_prompt("es")
|
||||
en = _system_prompt("en")
|
||||
for tag in ALLOWED_TAGS["es"]:
|
||||
assert tag in es
|
||||
# La regla anti-legacy 'never use "investigacion"' es solo para EN: en ES
|
||||
# "investigacion" es el tag canónico del allow-list.
|
||||
assert 'never use "investigacion"' in en
|
||||
assert 'never use "investigacion"' not in es
|
||||
assert "ONLY from this exact list" in es
|
||||
Reference in New Issue
Block a user