
Una herramienta que te pide que pegues mensajes sospechosos tiene un problema de raíz. Los mensajes sospechosos muchas veces llevan datos personales, IBAN real, DNI, teléfonos, direcciones, códigos de un solo uso. Si el usuario se plantea "¿esto dónde acaba?" durante medio segundo, ya lo perdiste. No importa cuánto presuman de IA otros productos, la barrera de entrada es la confianza, y la confianza en 2026 se construye con decisiones técnicas verificables, no con un banner diciendo "nos importa tu privacidad".
Después del endurecimiento de seguridad y las alertas push vía ntfy, dediqué un bloque entero a mover ScamDetector hacia una postura de privacidad que pudiera defender si alguien me pregunta, sin palabrería. Cuatro cambios que van desde el routing del modelo hasta qué fuentes carga el frontend. Todo encaja entre sí.
ScamDetector llama a Gemini a través de OpenRouter. Por defecto, OpenRouter elige el proveedor subyacente de Gemini (AI Studio o Vertex AI) según disponibilidad y precio. La diferencia no es menor. AI Studio, en sus condiciones para el tier estándar, permite a Google utilizar los prompts para mejorar sus modelos. Vertex AI, con Zero Data Retention activado, no retiene nada y no usa los datos para entrenamiento. Son dos contratos distintos para el mismo modelo.
Si el prompt puede caer en cualquiera de los dos sin control, no puedo decirle al usuario "tus datos no entrenan a nadie". La respuesta honesta sería "depende del día". Así que endurecí el routing con un bloque de provider explícito en cada petición:
body: {
model: 'google/gemini-2.5-flash',
messages: [...],
provider: {
order: ['google-vertex'],
allow_fallbacks: false,
zdr: true,
},
}La combinación de las tres claves es lo que importa. order fuerza el proveedor preferido, allow_fallbacks: false impide que OpenRouter caiga silenciosamente a AI Studio si Vertex no responde, y zdr: true exige que el proveedor seleccionado tenga Zero Data Retention activo. Si Vertex está caído, el usuario recibe un error 503 con mensaje claro en lugar de una respuesta que haya viajado por donde no debía.
Es una concesión consciente entre disponibilidad y privacidad. Podría haber tolerado fallback a AI Studio con un flag que lo marcara en los logs, pero me parecía dar la espalda a lo que había prometido. Una herramienta que analiza intentos de estafa no puede tener una puerta lateral de privacidad. Prefiero un "vuélvelo a intentar en un minuto" antes que un "ah, por cierto, este mensaje sí se puede usar para entrenar".
El mismo bloque se aplica en la extracción de URLs desde imágenes. La búsqueda de reputación de teléfonos se quedó fuera porque Perplexity y Relace no están en Vertex y forzar ZDR ahí rompería el fallback. Es un trade-off explícito y documentado, no un olvido. Los números que se consultan son teléfonos que aparecen en mensajes sospechosos, lo cual en el peor caso es información ya propagada por el estafador.
Forzar Vertex+ZDR cubre la capa contractual, pero no quita la fricción psicológica de pegar un IBAN real. Aunque técnicamente el dato no se retenga, el usuario sigue leyendo "voy a enviar esto a una IA". Algunos no lo envían. Otros borran los datos sensibles a mano antes, con el riesgo de que la ofuscación manual cambie la forma del mensaje y deje al modelo con menos contexto del que necesita.
La solución fue ofrecer un modo de envío explícito con ofuscación server-side. Escribí sensitive-patterns.js, un módulo UMD que detecta cinco categorías con expresiones regulares calibradas para español, IBAN (prefijo ES de 24 caracteres), tarjetas (13 a 19 dígitos con algoritmo Luhn), DNI y NIE, teléfonos españoles con y sin prefijo +34, y emails. La detección va en orden y trabaja sobre una copia del texto para evitar el doble match. Un IBAN español empieza por dos letras y un par de dígitos de control, pero sus últimos dieciocho dígitos se pueden confundir con una tarjeta si los buscas en paralelo. Ejecutar los patrones secuencialmente y marcar los tramos ya consumidos hace que IBAN y tarjeta no se pisen.
function detectSensitive(text) {
let remaining = text;
const findings = [];
for (const { type, re } of PATTERNS) {
remaining = remaining.replace(re, (match) => {
findings.push({ type, match });
return '\0'.repeat(match.length); // hueco que los siguientes patrones ignoran
});
}
return findings;
}La ofuscación reemplaza el match por una marca del tipo [IBAN], [TARJETA] o [DNI]. Preserva la estructura del mensaje (las comas, los saltos de línea, el contexto alrededor) porque lo que el modelo necesita para detectar una estafa casi nunca es el número exacto, sino el patrón de "te piden urgentemente tus datos bancarios". Con marcadores el modelo ve la petición tal cual y decide igual. Sin los números.
En el backend, api/analyze.js lee fields.sendMode del formulario. Si vale 'obfuscated', pasa el mensaje por obfuscateSensitive antes de sanitizar y escapar. Si vale 'full' (o no viene), el mensaje va tal cual, sin tocar. El log estructurado registra qué modo se usó, qué tipos de datos sensibles se detectaron y si la ofuscación aplicó realmente. Poder mirar en agregado cuántos usuarios eligen ofuscar me dice si la opción tiene tracción real o si la gente confía por defecto.
Un selector binario permanente sería ruido visual. Si el usuario solo pega texto sin datos sensibles, no hay nada que ofuscar. Si solo sube una imagen, la ofuscación textual no aplica. Si solo consulta un teléfono, lo mismo. El selector tiene que aparecer cuando hay algo que elegir y desaparecer cuando no.
La lógica del frontend evalúa el estado del formulario en cada cambio. Si el texto contiene algún match de detectSensitive, o si hay imagen adjunta, o si hay teléfono rellenado, aparece una tarjeta con dos radios ("Envío completo" y "Envío ofuscado"). Si el texto disparó el match, la tarjeta toma un estilo prominente con un badge "Modo: ofuscado" que se ilumina solo cuando la elección tiene efecto real. Si las únicas razones son imagen o teléfono (donde la ofuscación no actúa), la tarjeta colapsa a una nota compacta neutral explicando qué ha detectado y qué no hace el modo ofuscado en ese caso.
El default es siempre 'full' y no se persiste entre sesiones. No quise un checkbox que recordara la preferencia del usuario porque la decisión depende del mensaje concreto. Un email de trabajo que pega alguien puede no llevar nada sensible y otro pegado cinco minutos después sí. Que cada análisis se decida solo es más molesto un segundo y más seguro el resto del tiempo. Accesibilidad cuidada con aria-live en el título, aria-label en el badge y manejo de teclado para Enter y Espacio.
La política original decía lo típico, "no almacenamos datos personales, las IPs se hashean, el texto se envía al modelo". Técnicamente correcto pero genérico. Si alguien con criterio de auditoría la lee se da cuenta de que faltan todas las secciones que exige el RGPD.
Reescribí el modal de privacidad siguiendo los criterios que usa la AEPD en sus guías públicas. Ahora incluye quién es el responsable del tratamiento (con nombre y forma de contacto), la base legitimadora del tratamiento (consentimiento explícito al enviar el formulario, artículo 6.1.a del RGPD), qué se hace con cada campo por separado (mensaje, imagen, teléfono, URL), transferencias internacionales (las Cláusulas Contractuales Tipo de la Comisión Europea y la adhesión de Google al EU-US Data Privacy Framework), los derechos de acceso, rectificación, supresión, oposición, limitación y portabilidad, y la puerta a la AEPD con su URL de reclamación.
Un detalle que me obligó el ejercicio fue revisar cómo describía los hashes. Antes decía "no guardamos tu IP, guardamos un hash". Eso sigue siendo un dato identificativo según la AEPD si el hash es reversible o permite re-identificación. Lo correcto es llamarlo seudonimización, que es lo que realmente hace, y declarar que los hashes se calculan con SHA-256 más un salt persistente por instalación. Redactarlo como seudonimización es más honesto y me evita un problema si alguien me pregunta técnicamente. La fecha de última actualización está visible al pie del modal y el dateModified del JSON-LD se sincroniza con cada cambio de texto.
Nada de esto lo lee la mayoría de usuarios. Pero los dos o tres que lo leen son justo los que más probable tienen de recomendar la herramienta a otros, porque son los que saben valorar que exista.
Este me lo cacé yo mismo mientras escribía la política. Decía "ningún dato del formulario sale a terceros más allá del modelo de análisis" y en el <head> del HTML había un preconnect a fonts.googleapis.com y un link al CSS de cinco fuentes distintas. Cada usuario que cargara la página enviaba una petición con su IP, el Referer y los User-Agent headers a un servicio de Google ajeno al análisis. La IP del usuario salía a Google antes siquiera de pulsar ningún botón. La contradicción era exacta.
Descargué las cinco fuentes exactamente como Google las servía a mi sitio. Orbitron en pesos 700 y 900, Share Tech Mono 400, Space Grotesk en 400, 500, 600 y 700. Son ficheros woff2 pequeños, todos juntos suman poco más del peso de una imagen media. Los guardé en /fonts, reemplacé el CSS externo por @font-face locales con font-display: swap y eliminé los preconnect y link externos. La CSP del servidor se simplificó a font-src 'self' y style-src 'self', sin excepciones para terceros.
El efecto colateral agradable es que la primera carga es más rápida. Sin round-trip a Google, las fuentes viajan en la misma conexión ya calentita del HTML. Y el service worker, que ya cacheaba los assets del shell de la aplicación, ahora mete también las fuentes y el módulo sensitive-patterns.js. Subí el nombre del cache (scamdetector-v10) para forzar invalidación limpia en navegadores con la versión anterior.
Estos cuatro cambios parecen inconexos vistos por separado. Un bloque provider en una petición. Una UI con dos radios. Un modal de texto. Cinco ficheros woff2. Pero miran todos hacia el mismo sitio. El usuario que llega a ScamDetector no se pregunta si el código es elegante ni si los tests pasan, se pregunta dónde acaban sus datos.
Lo que el ejercicio me demostró es que esa pregunta se responde en el código, no en la página de privacidad. Si mi política dice ZDR y mi provider no lo fuerza, estoy mintiendo. Si mi política dice "no datos a terceros" y mi HTML carga fuentes de Google, estoy mintiendo. Y la mentira no es deliberada, es que la mayoría de páginas web llevan tantas capas por defecto que si no te sientas a revisarlas una por una acabas diciendo cosas que no son ciertas sin darte cuenta.
Cerrar esas brechas a la vez fue mucho menos trabajo del que parecía antes de empezar. Un rato largo para el provider y los tests, una tarde para los patrones de ofuscación y la UI, otro rato largo para la política y las fuentes. Tres o cuatro sesiones. El resultado es que ahora puedo describir exactamente qué pasa con cada tipo de dato y el código respalda la descripción.
Si tienes un proyecto que toca datos personales, el ejercicio de sentarte una tarde a preguntarte "¿qué prometo y qué hago realmente?" probablemente te va a devolver un puñado de tareas pequeñas como estas. Son el tipo de trabajo que no se vende bien en un README pero que separa a una herramienta seria de un prototipo con un copy generoso.
Pruébalo en scamdetector.josemanuelortega.dev.

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.

Qué cambió en ScamDetector después de publicar. Tercer backend de IA, rate limiting persistente, logging con privacidad, pasos de acción por tipo de estafa, compartir resultados como imagen y PWA.

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.