Entiscore es un agente que audita la presencia digital de un sitio web, evaluando datos estructurados, consistencia de identidad, señales de autoridad y accesibilidad técnica antes de devolver un reporte con puntuación. El pipeline que lo impulsa fue construido durante el hackathon Kiro powered by AWS organizado por Código Facilito, con dos días reales de desarrollo. Este post documenta las decisiones de diseño que dieron forma al orquestador, con foco en cómo el sistema sigue funcionando cuando partes individuales de él fallan.
Antes de que la URL llegue a los analizadores
Lo primero que hace el orquestador con una URL entrante no es fetchearla sino validarla contra server-side request forgery. El endpoint resuelve el hostname mediante DNS y rechaza la solicitud si esa resolución cae dentro de rangos de IP privados o reservados, o si el hostname corresponde a localhost. Esto evita que el sistema pueda usarse para que el servidor acceda a direcciones internas que no debería alcanzar. Solo una URL que pasa esta validación avanza al flujo de análisis.
async function validateUrl(url: string): Promise<void> {
const { hostname } = new URL(url);
const addresses = await dns.resolve4(hostname);
for (const address of addresses) {
if (isPrivateIp(address) || isLoopback(address)) {
throw new Error(`La URL resuelve a una dirección restringida: ${address}`);
}
}
}
El check de SSRF ocurre antes de que se haga cualquier request de red al destino. Es un límite deliberado: el orquestador trata las URLs enviadas por usuarios como input no confiable y hace cumplir ese límite antes de hacer cualquier otra cosa con ellas.
Construyendo el contexto de análisis
Una vez que la URL pasa la validación, el orquestador obtiene el HTML completo del sitio y su robots.txt usando tres herramientas que siguen el contrato de MCP: fetchPage, fetchRobotsTxt y checkUrlAccessibility. Cada una respeta el formato de input y output que MCP define para sus tools, lo que significa que el orquestador las invoca como funciones TypeScript directas en producción mientras que la misma interfaz está disponible a través de un servidor MCP real durante el desarrollo con Kiro.
Con esos dos inputs el orquestador construye un único objeto de contexto de análisis:
interface AnalysisContext {
html: string;
statusCode: number;
responseTimeMs: number;
headers: Record<string, string>;
robotsTxt: string | null;
}
Ese contexto es lo único que recibe cada uno de los cuatro analizadores. Ninguno hace requests de red propios ni fetchea datos adicionales. Reciben lo que el orquestador ya obtuvo y evalúan desde ahí de forma determinística.
Ejecutando los cuatro analizadores en paralelo
El orquestador despacha los cuatro analizadores simultáneamente usando Promise.allSettled. La elección entre Promise.allSettled y Promise.all vale la pena explicarla porque determina cómo se comporta el sistema cuando algo falla.
Con Promise.all, si un solo analizador lanza una excepción la promesa completa se rechaza y todo el análisis se detiene. Un analizador con problema arrastra todo lo demás. Con Promise.allSettled, cada analizador se resuelve o falla de forma independiente, y el orquestador recibe los cuatro resultados sin importar si alguno tuvo un problema. El eje que falló queda marcado con un status distinto mientras los otros tres siguen entregando su resultado normalmente.
const [structured, identity, authority, technical] = await Promise.allSettled([
analyzeStructuredData(context),
analyzeIdentityConsistency(context),
analyzeAuthoritySigns(context),
analyzeTechnicalAccessibility(context),
]);
const results = {
structuredData: structured.status === "fulfilled"
? structured.value
: { status: "failed", findings: [], score: 0 },
identityConsistency: identity.status === "fulfilled"
? identity.value
: { status: "failed", findings: [], score: 0 },
authoritySigns: authority.status === "fulfilled"
? authority.value
: { status: "failed", findings: [], score: 0 },
technicalAccessibility: technical.status === "fulfilled"
? technical.value
: { status: "failed", findings: [], score: 0 },
};
Cada analizador evalúa una dimensión específica. El de datos estructurados busca schema markup en JSON-LD, Microdata y RDFa, identifica qué tipos declara el sitio y revisa la completitud de sus campos según lo que schema.org recomienda para ese tipo. El de consistencia de identidad compara el nombre declarado en el schema markup contra el og:title y la etiqueta title del HTML, y verifica que los enlaces del array sameAs y los perfiles externos declarados respondan correctamente. El de señales de autoridad detecta enlaces salientes hacia plataformas reconocidas y busca metadata de autoría y menciones de certificaciones o contribuciones open source en el texto del sitio. El de accesibilidad técnica revisa códigos de respuesta HTTP, tiempo de respuesta, presencia de metadatos esenciales como title, meta description y etiquetas Open Graph, comportamiento de bloqueo del robots.txt, y si el sitio depende exclusivamente de JavaScript del lado del cliente sin contenido renderizado en el HTML inicial.
Scoring con resultados parciales
Una vez que los cuatro terminan, el orquestador pasa sus resultados al motor de scoring. Los pesos base son fijos: 30% para datos estructurados, 20% para consistencia de identidad, 20% para señales de autoridad y 30% para accesibilidad técnica. Cuando uno o más ejes terminaron con status failed o partial, esos ejes se excluyen del cálculo y sus pesos se redistribuyen proporcionalmente entre los ejes que sí se evaluaron correctamente.
function calculateScore(results: AnalyzerResults): ScoringResult {
const weights = {
structuredData: 0.30,
identityConsistency: 0.20,
authoritySigns: 0.20,
technicalAccessibility: 0.30,
};
const active = Object.entries(results).filter(
([, result]) => result.status !== "failed"
);
const totalWeight = active.reduce(
(sum, [key]) => sum + weights[key as keyof typeof weights],
0
);
const normalizedScore = active.reduce((sum, [key, result]) => {
const weight = weights[key as keyof typeof weights] / totalWeight;
return sum + result.score * weight;
}, 0);
return {
score: Math.round(normalizedScore),
maturityLevel: getMaturityLevel(normalizedScore),
activeAxes: active.length,
};
}
Esto significa que el puntaje final siempre refleja únicamente lo que se pudo medir. Un reporte donde un analizador falló no produce un cero para ese eje ni deflacta artificialmente el puntaje general, sino que recalcula en base a lo que tuvo éxito y etiqueta el eje faltante claramente en el output.
Dónde entra Claude en el pipeline
Claude API entra en tres puntos específicos, todos después de que la evaluación determinística está completa.
El primero es el resumen ejecutivo, un párrafo en lenguaje claro que describe el estado general del sitio a partir de los cuatro resultados ya calculados, pensado para alguien sin conocimiento técnico de SEO.
El segundo es el plan de acción. Claude toma los hallazgos de tipo warning y critical de los cuatro ejes y genera recomendaciones priorizadas, cada una con un nivel de esfuerzo estimado y en varios casos un fragmento de código listo para usar, como el JSON-LD exacto que resolvería un campo faltante.
El tercero es el asistente conversacional, que recibe el reporte completo generado como contexto y responde preguntas acotadas específicamente a ese análisis.
Lo que importa arquitectónicamente es que el puntaje, el nivel de madurez y los hallazgos concretos siempre provienen de la evaluación determinística. Claude solo toca la capa de presentación.
La generación del plan de acción tiene un fallback explícito. Si la llamada a Claude falla por cualquier motivo, el orquestador cambia automáticamente a un sistema basado en reglas que genera el mismo tipo de recomendaciones priorizadas a partir de los hallazgos sin la redacción más elaborada de la IA. El usuario recibe un reporte completo y accionable en cualquier caso.
async function generateActionPlan(
findings: Finding[]
): Promise<ActionPlan> {
try {
return await generateWithClaude(findings);
} catch {
return generateWithRules(findings);
}
}
Persistencia y compartir resultados
Antes de devolver el reporte completo a quien lo pidió, el orquestador lo persiste en Supabase junto con un código único corto y legible generado en ese momento. Ese código es lo que permite acceder al mismo reporte después a través de una ruta pública sin repetir el análisis, y lo que habilita que el resultado pueda compartirse por enlace directo.
La funcionalidad de comparación entre dos URLs reutiliza el mismo orquestador sin cambios. Para una comparación, el orquestador corre dos veces en paralelo, una por cada URL ingresada, cada ejecución siguiendo el flujo completo de principio a fin. Cuando ambas terminan, se genera un resumen comparativo a partir de los dos resultados completos y se persiste con su propio código único siguiendo el mismo mecanismo que un análisis individual.
Entiscore está disponible en entiscore.vercel.app. Construido con Next.js, TypeScript, Supabase y Claude API para el hackathon Kiro powered by AWS de Código Facilito.

