Spaces:
Sleeping
Sleeping
| """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). | |
| 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 | |