
Publiqué ScamDetector hace dos semanas y al día siguiente ya quería cambiar cosas. No porque estuviera roto, sino porque usarlo en producción con tráfico real te enseña cosas que no ves mientras desarrollas en local. Este artículo va de eso, de las decisiones que tomé después de lanzar y por qué cada una me pareció más valiosa que añadir funcionalidades nuevas.
Si no conoces el proyecto, en el primer artículo explico qué hace ScamDetector y en el segundo cómo está construido por dentro.
ScamDetector empezó con dos backends de IA intercambiables, n8n y Vercel AI Gateway. n8n me permitía ajustar prompts y modelos desde una interfaz visual sin redesplegar nada, lo cual era cómodo para iterar rápido. Pero n8n es una pieza pesada. Es un servicio completo con su propia base de datos, sus workers y su consumo de memoria. Para un proyecto que básicamente hace un par de llamadas a una API de IA, tener n8n en medio empezó a parecerme desproporcionado.
Así que añadí un tercer path, OpenRouter directo. Un fetch() contra la API de OpenRouter desde el servidor Node.js, sin intermediarios, sin SDK, sin workflow engine. Gemini para mensajes, Perplexity Sonar para teléfonos, Relace Search como fallback si Perplexity no responde, reintentos con backoff exponencial. En unas 200 líneas de código hace lo mismo que el workflow completo de n8n.
Al final retiré n8n de la arquitectura. OpenRouter directo es más simple, más rápido de arrancar y tiene una dependencia menos que mantener. Vercel AI Gateway se queda como alternativa para quien quiera desplegar en serverless. De los tres backends que coexistieron durante unas semanas, solo sobreviven dos, que es lo que debería haber tenido desde el principio.
Lo que hizo viable mantener múltiples caminos en paralelo sin que se convirtiera en un dolor de cabeza fue extraer todos los system prompts a un único fichero, prompts.js. Antes cada backend tenía su propia copia del prompt, lo cual significaba que cualquier ajuste había que replicarlo en dos o tres sitios. Ahora hay una sola fuente de verdad para el prompt de análisis de mensajes, el de imágenes sin texto, el de reputación de teléfonos y el de extracción de URLs. Los dos backends que quedan importan los mismos prompts y pasan las respuestas por la misma función normalizeResponse(). El frontend no distingue quién le responde.
El rate limiting original funcionaba bien mientras el contenedor no se reiniciara. Cada vez que Dokploy redesplegaba la aplicación, los contadores se reseteaban y cualquiera volvía a tener sus 10 peticiones limpias. En un proyecto con poco tráfico esto no era un problema grave, pero me molestaba como principio.
La solución fue persistir los contadores a ficheros JSON en /app/data/, un directorio montado como volumen nombrado en Dokploy. Cada 10 minutos (y al recibir SIGTERM) el servidor escribe el mapa de rate limits a disco. Al arrancar, lo lee de vuelta y descarta las entradas caducadas. Las escrituras son asíncronas con callback vacío para no bloquear el handling de peticiones.
Mientras hacía esto añadí una segunda capa, un rate limit global de 100 peticiones cada 10 minutos para todas las IPs combinadas. El límite por IP protege contra un usuario abusando del servicio, pero no protege contra alguien que rota IPs con una VPN o una botnet. El global sí. Si se supera el límite individual el servidor devuelve 429 (Too Many Requests), si se supera el global devuelve 503 (Service Unavailable). El frontend muestra mensajes diferentes en cada caso para que el usuario entienda qué está pasando.
Después de unos días en producción me di cuenta de que no tenía forma de saber cómo se estaba usando la herramienta. No cuántas visitas, que eso lo puedo ver en los logs de Traefik, sino qué tipo de análisis se pedían, cuánto tardaban y qué resultados devolvían. Sin esa información cualquier decisión sobre qué mejorar era a ciegas.
Implementé un log estructurado en formato JSONL (una línea JSON por entrada) que registra cada operación procesada por todos los endpoints: análisis de mensajes, escaneo de URLs y extracción de URLs de imágenes. Lo que se registra es el hash de la IP (nunca la IP real), el hash del teléfono si se consultó, el número de imágenes (sin las imágenes en sí), el gateway usado, la duración en milisegundos y el resultado del análisis. Los hashes usan SHA-256 con un salt aleatorio que se genera en el primer arranque y se persiste en el volumen. Esto hace que los hashes sean consistentes entre reinicios del mismo contenedor (el salt no cambia), pero incomparables entre instalaciones distintas.
El fichero se purga automáticamente cada 7 días. La limpieza corre en el mismo setInterval que persiste los rate limits, cada 10 minutos. No quise montar un servicio de logging aparte ni enviar datos a terceros, así que un fichero JSONL local me pareció lo justo para un proyecto de este tamaño. Si necesito consultar algo, un cat y jq son suficientes.
ScamDetector tiene un formulario con tres entradas principales, el mensaje de texto, las URLs y las imágenes. En las primeras pruebas noté que la gente copiaba un SMS sospechoso, llegaba a la página y se paraba a buscar dónde pegarlo. El campo de texto, el campo de URL, el botón de subir imagen... Hay que elegir.
Añadí un listener global de pegado que detecta automáticamente qué hay en el portapapeles y lo enruta al campo correcto. Si pegas una imagen, va directamente al área de subida de ficheros. Si pegas texto que parece una URL (tiene protocolo o aspecto de dominio), va al campo de URLs. Si es texto normal, va al campo de mensaje. En los tres casos aparece un toast confirmando qué se pegó y dónde.
El detalle importante es que el listener solo actúa cuando no estás dentro de un campo de texto (no secuestra el pegado nativo) y solo rellena campos vacíos. Si ya escribiste algo en el campo de mensaje y pegas, no te lo machaca. Son unas 40 líneas de código que cambian cómo se siente usar la herramienta.
Cuando compartes una herramienta con gente que no es técnica, la primera pregunta no es "cómo funciona por dentro" sino "qué puedo hacer con esto". Añadí un panel de ayuda deslizable que se abre desde un botón en la esquina y explica cada funcionalidad con una captura de pantalla. Análisis de mensajes, capturas, teléfonos, URLs, el pegado inteligente, el historial y los temas de color. Se implementó como un drawer con foco atrapado (tab trap), tecla Escape para cerrar y ARIA completo para lectores de pantalla.
También escribí una política de privacidad que explica exactamente qué datos se procesan, dónde y durante cuánto tiempo. No la típica página legal interminable, sino una explicación clara. Que las IPs se hashean y no se almacenan en claro. Que las imágenes se procesan en memoria y se descartan inmediatamente. Que el texto se envía a modelos de IA a través de OpenRouter. Que el log se purga a los 7 días. Que no hay cookies, ni analytics, ni tracking de ningún tipo.
No sé cuánta gente la lee, pero estando ahí cumple dos funciones. La primera es transparencia real para quien la consulte. La segunda es que escribirla me obligó a revisar exactamente qué datos tocaba cada parte del código, y eso por sí solo mereció la pena.
Al principio ScamDetector te daba un resultado y ahí se acababa la cosa. Nivel de riesgo, explicación, recomendación genérica. Pero si alguien recibe un SMS de phishing bancario y la herramienta le dice "riesgo alto, probablemente phishing", la pregunta inmediata es "vale, ¿y ahora qué hago?".
Añadí pasos de acción concretos adaptados a cada tipo de estafa. Si detecta phishing bancario, los pasos son contactar con el banco por su canal oficial, cambiar contraseñas y activar verificación en dos pasos. Si es Bizum inverso, verificar si te piden dinero en vez de enviártelo. Si es paquetería falsa, comprobar el seguimiento en la web oficial del transportista. Hay trece tipos de estafa con sus propios pasos, más fallbacks genéricos por nivel de riesgo para los casos que no encajan en ninguna categoría concreta.
También añadí la opción de compartir el resultado como imagen. El navegador genera un PNG estilizado con Canvas API a 2x de resolución que incluye el nivel de riesgo, el tipo de estafa, la explicación y la recomendación, todo con la estética cyberpunk de la herramienta y adaptado al tema claro u oscuro. Si tu navegador soporta la Web Share API, puedes enviarlo directamente por WhatsApp o guardarlo. Si no, se descarga como fichero. La idea es que si alguien analiza un mensaje sospechoso que le llegó a un familiar, pueda reenviar el resultado de forma visual sin tener que explicar qué dice la herramienta.
ScamDetector se puede instalar ahora como aplicación en el móvil. Un service worker con estrategia de cache híbrida (cache-first para assets estáticos, network-only para las llamadas a la API) hace que la interfaz cargue aunque no haya conexión. No vas a poder analizar mensajes sin internet porque las llamadas a los modelos de IA necesitan red, pero sí puedes consultar el historial local y tener la app lista para cuando recuperes la conexión.
En el lado del build, añadí una etapa de minificación con esbuild al Dockerfile multi-stage. La primera etapa minifica JavaScript, CSS y el service worker, la segunda copia los ficheros resultantes encima de los originales. El resultado es una reducción de alrededor del 35% en el tamaño de los assets servidos, sin cambiar el flujo de desarrollo (en local sigues editando los ficheros sin minificar).
Y un cambio que no se ve pero que evita problemas reales. El servidor ahora gestiona el apagado de forma ordenada. Cuando Dokploy redespliega y envía SIGTERM, el proceso deja de aceptar peticiones nuevas, espera a que terminen las que están en vuelo (hasta 10 segundos) y solo entonces cierra. Antes, un redespliegue podía cortar respuestas a medias si coincidía con un análisis en curso.
Hay cambios que no aparecen en una lista de features pero que cualquiera que use la herramienta dos días nota al tercero. Una parte grande de las iteraciones recientes fue puro refinamiento de interfaz, y merece la pena contarlo porque esos detalles suman más experiencia percibida que cualquier funcionalidad nueva.
El campo de teléfono vivía en un marco genérico de estilo cyberpunk. Funcionaba, pero el teléfono del usuario es el lugar desde el que copia los SMS sospechosos, y tenía sentido reforzar esa metáfora. Rediseñé el input como un marco de iPhone realista con Dynamic Island, barra de estado con iconos de WiFi y batería, botones laterales y barra de gesto. El hint explicativo dentro del marco va estilizado como una nota de sistema de iMessage, con el typography y el tono gris claro que usa Apple para este tipo de mensajes. El bisel usa un degradado metálico azul que recoge el glow cian de la web. El resultado es que cuando abres la página el campo de teléfono ya te está diciendo qué pegar ahí sin necesidad de texto adicional.
Al mismo tiempo añadí botones de limpiar (X) en el textarea de mensaje, el input de teléfono, el input de URL y, más tarde, el textarea de URLs en batch. Son elementos que aparecen solo cuando el usuario ha escrito algo, quedan fuera del orden de tabulación con visibility: hidden cuando están ocultos (no solo display: none, que lectores de pantalla pueden tratar distinto) y respetan el target táctil de 44 píxeles que exige WCAG 2.2 vía padding en content-box. Antes, corregir un error de pegado requería seleccionar todo y pulsar suprimir. Ahora es un toque. La diferencia se siente cada vez que alguien pega mal por primera vez.
El botón "Escanear URL" era un botón independiente junto al de analizar. Con el tiempo me di cuenta de que cuando alguien solo pegaba una URL y pulsaba cualquiera de los dos recibía salida casi idéntica, solo que la del botón principal llevaba el análisis IA por encima. Dos botones para la misma cosa sobra uno. Moví el escaneo de URL a una acción inline dentro del propio input, una lupa con fondo sólido y glow que indica que es pulsable. Cambia de estado cuando la URL es válida, tiene aria-label descriptivo y respeta el target táctil. En modo lote (textarea con varias URLs) el botón original reaparece abajo como "Escanear todas las URLs", porque un textarea no admite acciones inline sin complicaciones. Una decisión de diseño que simplifica el modo principal sin romper el avanzado.
El último ajuste vino de un bug que llevaba ahí sin que me diera cuenta. La búsqueda de reputación de teléfonos devolvía a veces un riskLevel de "low" o "unknown" acompañado de un informe que describía claramente una estafa, con frases como "clasificado como estafa", "8/10 de peligrosidad" o "no fiable". El usuario veía un badge verde tranquilizador junto a un texto alarmante, que es exactamente la peor forma de mostrar un resultado. Añadí un guardrail determinista que detecta señales de estafa en el texto del informe (palabras clave como estafa, fraude, phishing, no fiable, spam, scores entre 7 y 10, clasificaciones negativas) y sube el badge de low o unknown a medium cuando hay conflicto. High nunca se baja y un texto limpio nunca se sube. Son cuatro tests unitarios pequeños que cubren los casos esperados. Es el tipo de cambio que nadie pide pero que evita que alguien ignore una señal importante porque el color le dijo lo contrario.
La tentación después de publicar un proyecto es pasar al siguiente. Pero el tiempo que invertí en estos cambios mejoró ScamDetector más que las semanas anteriores al lanzamiento. El rate limiting persistente, los prompts compartidos, el logging, los pasos de acción y la PWA no son features que vendan en un README, pero son las que hacen que la herramienta funcione bien de verdad cuando alguien la usa.
El código sigue siendo público en el repositorio, y si quieres probarlo está en scamdetector.josemanuelortega.dev.
Lo que vino después de estas iteraciones fue una ronda de endurecimiento de seguridad con Cloudflare Turnstile y session tokens efímeros, detección de inyecciones de prompt, sanitización Unicode, penalizaciones progresivas y más de 240 tests (unitarios, integración y end-to-end) con el runner nativo de Node.js. Todo eso está en el siguiente artículo.

Cuatro cambios para que la privacidad de ScamDetector deje de ser un banner y esté en el código. Routing forzado a Google Vertex con Zero Data Retention, selector de envío con ofuscación server-side, política RGPD reescrita desde cero y fuentes autohospedadas para no filtrar la IP del usuario a Google.

Añadí notificaciones push al móvil para ScamDetector con ntfy self-hosted. Cuatro alertas en tiempo real (inyección, ban, brute-force, backend caído) y tres monitores periódicos (digest diario, uso de disco, pico de tráfico). Un módulo de 90 líneas sin dependencias.

Cómo está construido ScamDetector por dentro, desde el proxy server-side que protege las claves de API hasta los tres backends de IA intercambiables y las medidas de seguridad.