Spaces:
Sleeping
Sleeping
File size: 6,009 Bytes
70e641d 5118fc3 70e641d 5118fc3 70e641d 5118fc3 70e641d 5118fc3 70e641d 5118fc3 70e641d 5118fc3 70e641d 5118fc3 70e641d 5118fc3 70e641d | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 | """GET /api/papers — búsqueda en PubMed con caché en disco.
Porta la lógica de papers_proxy.php (esearch + esummary + caché 30 min) pero ahora
protegida con sesión y rate limiting.
"""
from __future__ import annotations
import hashlib
import json
import os
import re
import tempfile
import time
from pathlib import Path
import httpx
from fastapi import APIRouter, HTTPException, Query, Request, status
from ..config import obtener_config
from ..security.rate_limit import limiter
router = APIRouter()
# El uid en el nombre evita el caso en que el directorio ya existe creado por OTRO usuario del
# host compartido: `mkdir(mode=0o700)` sólo protege si lo creamos nosotros, y con `exist_ok=True`
# un directorio ajeno (o un enlace simbólico plantado ahí) se habría usado tal cual.
_DIR_CACHE = Path(tempfile.gettempdir()) / f"morphos_papers_cache_{os.getuid()}"
_TTL_S = 1800
_MAX_ENTRADAS = 500
_CABECERAS = {"User-Agent": "Morphos/1.0 (mailto:ceo@equipamed.net)", "Accept": "application/json"}
def _ruta(clave: str) -> Path:
return _DIR_CACHE / f"{hashlib.sha256(clave.encode()).hexdigest()}.json"
def _leer_cache(clave: str) -> dict | None:
"""Entrada viva de la caché, o None. Un fichero ilegible es un fallo de caché, no un 500.
Antes, `json.loads` sobre un fichero a medio escribir tumbaba la petición con 500. Con la
escritura atómica de abajo eso ya no debería ocurrir, pero la caché vive en un directorio
temporal que puede truncarse por otras razones (disco lleno, limpieza del host), y un fallo
de caché siempre es recuperable: se vuelve a pedir a NCBI.
"""
archivo = _ruta(clave)
try:
if (time.time() - archivo.stat().st_mtime) >= _TTL_S:
return None
return json.loads(archivo.read_text(encoding="utf-8"))
except FileNotFoundError:
return None
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
archivo.unlink(missing_ok=True) # corrupta: que no se relea eternamente
return None
def _podar_cache() -> None:
"""Borra lo caducado y, si aún sobran entradas, lo más antiguo.
Sin esto el directorio sólo crecía: el TTL se comprobaba al LEER, así que una consulta que
no se repite nunca dejaba su fichero para siempre.
"""
try:
entradas = list(_DIR_CACHE.glob("*.json"))
except OSError:
return
ahora = time.time()
vivas = []
for f in entradas:
try:
if ahora - f.stat().st_mtime >= _TTL_S:
f.unlink(missing_ok=True)
else:
vivas.append(f)
except OSError:
continue
if len(vivas) > _MAX_ENTRADAS:
vivas.sort(key=lambda f: f.stat().st_mtime)
for f in vivas[: len(vivas) - _MAX_ENTRADAS]:
f.unlink(missing_ok=True)
def _escribir_cache(clave: str, datos: dict) -> None:
"""Escritura ATÓMICA: fichero temporal en el mismo directorio y `os.replace`.
`write_text` no es atómico. Dos fallos de caché simultáneos sobre la misma consulta se
entrelazaban y un lector veía un JSON truncado. `os.replace` dentro del mismo sistema de
ficheros es atómico: el lector ve el contenido viejo o el nuevo, nunca uno a medias.
"""
_DIR_CACHE.mkdir(mode=0o700, parents=True, exist_ok=True)
destino = _ruta(clave)
fd, temporal = tempfile.mkstemp(dir=_DIR_CACHE, suffix=".tmp")
try:
with os.fdopen(fd, "w", encoding="utf-8") as fh:
json.dump(datos, fh, ensure_ascii=False)
os.replace(temporal, destino)
except OSError:
Path(temporal).unlink(missing_ok=True) # la caché es opcional: nunca romper la petición
return
_podar_cache()
# Sin guarda de sesión a propósito: la búsqueda en PubMed no es sensible ni consume la
# cuota de IA. Basta con rate limiting para evitar abuso (ver PLAN_MODERNIZACION.md, Fase 5).
@router.get("/papers")
@limiter.limit(obtener_config().limite_papers)
async def get_papers(
request: Request,
query: str = Query(..., min_length=1, max_length=300),
) -> dict:
consulta = query.strip()
clave = f"pm:{consulta}"
if (cacheado := _leer_cache(clave)) is not None:
return cacheado
base = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils"
async with httpx.AsyncClient(timeout=15, headers=_CABECERAS) as cliente:
try:
r1 = await cliente.get(
f"{base}/esearch.fcgi",
params={"db": "pubmed", "retmode": "json", "retmax": 100, "term": consulta},
)
r1.raise_for_status()
ids = r1.json().get("esearchresult", {}).get("idlist", [])
if not ids:
salida = {"total": 0, "data": []}
_escribir_cache(clave, salida)
return salida
r2 = await cliente.get(
f"{base}/esummary.fcgi",
params={"db": "pubmed", "retmode": "json", "id": ",".join(ids)},
)
r2.raise_for_status()
except httpx.HTTPError as exc:
raise HTTPException(status.HTTP_502_BAD_GATEWAY, "No se pudo contactar PubMed.") from exc
resultado = r2.json().get("result", {})
papers = []
for uid in resultado.get("uids", ids):
p = resultado.get(uid)
if not p:
continue
anio = ""
if p.get("pubdate"):
m = re.search(r"\d{4}", p["pubdate"])
anio = m.group(0) if m else ""
doi = next((a["value"] for a in p.get("articleids", []) if a.get("idtype") == "doi"), "")
papers.append(
{
"pmid": uid,
"title": p.get("title", "Sin título"),
"authors": [{"name": a["name"]} for a in p.get("authors", [])],
"year": anio,
"doi": doi,
"journal": p.get("source", ""),
}
)
salida = {"total": len(papers), "data": papers}
_escribir_cache(clave, salida)
return salida
|