← Files Willy SEOARCHIVED FILE
src/rules/onpage.ts
8.21 KB · Oct 2, 2026 · 00:34 UTC
import { AuditCheck, ExtractedPageData } from '../types.js';
/**
* Estimación aproximada de ancho de píxeles para Google Desktop (fuente Arial 20px)
*/
export function estimateTitlePixelWidth(text: string): number {
let width = 0;
for (let i = 0; i < text.length; i++) {
const char = text[i];
if (/[ijlI1\.,\s\':;!]/.test(char)) width += 4.5;
else if (/[mwWM@#%&]/.test(char)) width += 13.5;
else if (/[A-Z]/.test(char)) width += 10.5;
else width += 8.5;
}
return Math.round(width);
}
export function auditOnPage(data: ExtractedPageData, keyword?: string): AuditCheck[] {
const checks: AuditCheck[] = [];
const cleanKw = keyword ? keyword.toLowerCase().trim() : undefined;
// 1. Title Tag
if (!data.title) {
checks.push({
id: 'onpage-title-missing',
category: 'onpage',
name: 'Etiqueta <title>',
status: 'critical',
score: 0,
weight: 25,
message: 'Falta la etiqueta <title> en la página.',
recommendation: 'Añade una etiqueta <title> única, descriptiva y optimizada para la palabra clave principal.',
});
} else {
const titleLen = data.title.length;
const pxWidth = estimateTitlePixelWidth(data.title);
if (titleLen < 30) {
checks.push({
id: 'onpage-title-short',
category: 'onpage',
name: 'Longitud del <title>',
status: 'warning',
score: 60,
weight: 15,
message: `El título es demasiado corto (${titleLen} caracteres / ~${pxWidth}px).`,
recommendation: 'Aprovecha el espacio (50-60 caracteres) para incluir propuesta de valor o marca.',
snippet: `<title>${data.title}</title>`,
});
} else if (pxWidth > 580 || titleLen > 65) {
checks.push({
id: 'onpage-title-long',
category: 'onpage',
name: 'Longitud del <title>',
status: 'warning',
score: 70,
weight: 15,
message: `El título es demasiado largo (${titleLen} caracteres / ~${pxWidth}px) y podría truncarse en Google.`,
recommendation: 'Reduce el título a menos de 580px (~60 caracteres) para evitar que Google corte el mensaje.',
snippet: `<title>${data.title}</title>`,
});
} else {
checks.push({
id: 'onpage-title-optimal',
category: 'onpage',
name: 'Longitud del <title>',
status: 'good',
score: 100,
weight: 15,
message: `Título con longitud óptima (${titleLen} caracteres / ~${pxWidth}px).`,
snippet: `<title>${data.title}</title>`,
});
}
if (cleanKw) {
const titleLower = data.title.toLowerCase();
if (titleLower.includes(cleanKw)) {
const isAtStart = titleLower.indexOf(cleanKw) < 20;
checks.push({
id: 'onpage-title-kw',
category: 'onpage',
name: 'Keyword en <title>',
status: 'good',
score: 100,
weight: 10,
message: `La keyword objetivo "${keyword}" aparece en el título${isAtStart ? ' al principio (excelente para CTR y relevancia).' : '.'}`,
});
} else {
checks.push({
id: 'onpage-title-kw-missing',
category: 'onpage',
name: 'Keyword en <title>',
status: 'warning',
score: 40,
weight: 10,
message: `La palabra clave "${keyword}" no se encuentra en el <title>.`,
recommendation: `Incluye "${keyword}" preferiblemente al inicio del título.`,
});
}
}
}
// 2. Meta Description
if (!data.metaDescription) {
checks.push({
id: 'onpage-metadesc-missing',
category: 'onpage',
name: 'Meta Description',
status: 'warning',
score: 30,
weight: 15,
message: 'No se ha definido meta description.',
recommendation: 'Añade una meta description de 130-155 caracteres con gancho comercial y llamada a la acción (CTA).',
});
} else {
const descLen = data.metaDescription.length;
if (descLen < 70) {
checks.push({
id: 'onpage-metadesc-short',
category: 'onpage',
name: 'Meta Description',
status: 'warning',
score: 65,
weight: 10,
message: `La meta description es muy corta (${descLen} caracteres).`,
recommendation: 'Amplía la descripción hasta 130-155 caracteres para mejorar el CTR en la SERP.',
snippet: data.metaDescription,
});
} else if (descLen > 165) {
checks.push({
id: 'onpage-metadesc-long',
category: 'onpage',
name: 'Meta Description',
status: 'warning',
score: 75,
weight: 10,
message: `La meta description es extensa (${descLen} caracteres) y podría ser truncada en dispositivos móviles.`,
recommendation: 'Mantén la descripción entre 130 y 155 caracteres con el mensaje clave al principio.',
snippet: data.metaDescription,
});
} else {
checks.push({
id: 'onpage-metadesc-optimal',
category: 'onpage',
name: 'Meta Description',
status: 'good',
score: 100,
weight: 15,
message: `Meta description con extensión óptima (${descLen} caracteres).`,
snippet: data.metaDescription,
});
}
}
// 3. Encabezados H1
if (data.h1.length === 0) {
checks.push({
id: 'onpage-h1-missing',
category: 'onpage',
name: 'Encabezado principal (H1)',
status: 'critical',
score: 0,
weight: 20,
message: 'No existe ningún encabezado <h1> en la página.',
recommendation: 'Añade exactamente un encabezado <h1> que describa el tema principal de la URL.',
});
} else if (data.h1.length > 1) {
checks.push({
id: 'onpage-h1-multiple',
category: 'onpage',
name: 'Múltiples H1 detectados',
status: 'warning',
score: 60,
weight: 10,
message: `Se han detectado ${data.h1.length} etiquetas <h1>.`,
recommendation: 'Es recomendable mantener un único <h1> por página para una jerarquía semántica clara.',
snippet: data.h1.map(h => `<h1>${h}</h1>`).join('\n'),
});
} else {
checks.push({
id: 'onpage-h1-optimal',
category: 'onpage',
name: 'Encabezado H1',
status: 'good',
score: 100,
weight: 20,
message: `H1 único detectado: "${data.h1[0]}"`,
snippet: `<h1>${data.h1[0]}</h1>`,
});
}
// 4. Jerarquía H2/H3
if (data.h2.length === 0 && data.wordCount > 300) {
checks.push({
id: 'onpage-h2-missing',
category: 'onpage',
name: 'Estructura de subtítulos (H2)',
status: 'warning',
score: 50,
weight: 10,
message: 'No se han detectado encabezados <h2> a pesar de tener contenido extenso.',
recommendation: 'Estructura el contenido con secciones <h2> y <h3> para mejorar la legibilidad y escaneabilidad de Google.',
});
} else if (data.h2.length > 0) {
checks.push({
id: 'onpage-h2-good',
category: 'onpage',
name: 'Estructura H2/H3',
status: 'good',
score: 100,
weight: 10,
message: `Estructura semántica rica: ${data.h2.length} H2 y ${data.h3.length} H3.`,
});
}
// 5. Imágenes y Atributos Alt
if (data.images.length > 0) {
const missingAlt = data.images.filter(img => !img.hasAlt);
if (missingAlt.length > 0) {
const percentageMissing = Math.round((missingAlt.length / data.images.length) * 100);
checks.push({
id: 'onpage-images-missing-alt',
category: 'onpage',
name: 'Texto alternativo (Alt) en imágenes',
status: percentageMissing > 50 ? 'warning' : 'info',
score: Math.max(0, 100 - percentageMissing),
weight: 10,
message: `${missingAlt.length} de ${data.images.length} imágenes no tienen atributo alt descriptivo (${percentageMissing}% sin alt).`,
recommendation: 'Añade textos alternativos descriptivos en todas las imágenes clave para accesibilidad y posicionamiento en Google Imágenes.',
});
} else {
checks.push({
id: 'onpage-images-alt-good',
category: 'onpage',
name: 'Atributos Alt en imágenes',
status: 'good',
score: 100,
weight: 10,
message: `Todas las imágenes (${data.images.length}) cuentan con atributo alt.`,
});
}
}
return checks;
}
SHA-256: 1ef31a55f00cdd090f0cd208d5cd43062015fc711f1e825e773885660ad053cf