feat: add SEO validation utility
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
import { Page } from '@playwright/test';
|
||||
import { SEOResult, MetaTagResult, HeadingResult, LinkResult, ImageResult } from '../../types';
|
||||
|
||||
export class SEOValidator {
|
||||
constructor(private page: Page) {}
|
||||
|
||||
async validateSEO(): Promise<SEOResult> {
|
||||
const metaTags = await this.validateMetaTags();
|
||||
const headings = await this.validateHeadings();
|
||||
const links = await this.validateLinks();
|
||||
const images = await this.validateImages();
|
||||
|
||||
const score = this.calculateScore(metaTags, headings, links, images);
|
||||
|
||||
return {
|
||||
score,
|
||||
metaTags,
|
||||
headings,
|
||||
links,
|
||||
images
|
||||
};
|
||||
}
|
||||
|
||||
async validateMetaTags(): Promise<MetaTagResult> {
|
||||
const title = await this.page.title();
|
||||
const description = await this.page.getAttribute('meta[name="description"]', 'content');
|
||||
const keywords = await this.page.getAttribute('meta[name="keywords"]', 'content');
|
||||
const ogTitle = await this.page.getAttribute('meta[property="og:title"]', 'content');
|
||||
const ogDescription = await this.page.getAttribute('meta[property="og:description"]', 'content');
|
||||
const canonical = await this.page.getAttribute('link[rel="canonical"]', 'href');
|
||||
|
||||
return {
|
||||
title: !!title && title.length >= 10 && title.length <= 60,
|
||||
description: !!description && description.length >= 50 && description.length <= 160,
|
||||
keywords: !!keywords,
|
||||
ogTitle: !!ogTitle,
|
||||
ogDescription: !!ogDescription,
|
||||
canonical: !!canonical
|
||||
};
|
||||
}
|
||||
|
||||
async validateHeadings(): Promise<HeadingResult> {
|
||||
const h1Count = await this.page.locator('h1').count();
|
||||
const hasH1 = h1Count > 0;
|
||||
const multipleH1 = h1Count > 1;
|
||||
|
||||
const headings = await this.page.evaluate(() => {
|
||||
const elements = document.querySelectorAll('h1, h2, h3, h4, h5, h6');
|
||||
return Array.from(elements).map(el => el.tagName);
|
||||
});
|
||||
|
||||
let headingStructure = true;
|
||||
let previousLevel = 0;
|
||||
for (const heading of headings) {
|
||||
const level = parseInt(heading.charAt(1));
|
||||
if (level > previousLevel + 1) {
|
||||
headingStructure = false;
|
||||
break;
|
||||
}
|
||||
previousLevel = level;
|
||||
}
|
||||
|
||||
return {
|
||||
hasH1,
|
||||
headingStructure,
|
||||
multipleH1
|
||||
};
|
||||
}
|
||||
|
||||
async validateLinks(): Promise<LinkResult> {
|
||||
const links = await this.page.locator('a').all();
|
||||
let internal = 0;
|
||||
let external = 0;
|
||||
let broken = 0;
|
||||
|
||||
for (const link of links) {
|
||||
const href = await link.getAttribute('href');
|
||||
if (!href) continue;
|
||||
|
||||
if (href.startsWith('http')) {
|
||||
external++;
|
||||
} else {
|
||||
internal++;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
total: links.length,
|
||||
broken,
|
||||
internal,
|
||||
external
|
||||
};
|
||||
}
|
||||
|
||||
async validateImages(): Promise<ImageResult> {
|
||||
const images = await this.page.locator('img').all();
|
||||
let withAlt = 0;
|
||||
let withoutAlt = 0;
|
||||
|
||||
for (const image of images) {
|
||||
const alt = await image.getAttribute('alt');
|
||||
if (alt && alt.trim() !== '') {
|
||||
withAlt++;
|
||||
} else {
|
||||
withoutAlt++;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
total: images.length,
|
||||
withAlt,
|
||||
withoutAlt
|
||||
};
|
||||
}
|
||||
|
||||
private calculateScore(metaTags: MetaTagResult, headings: HeadingResult, links: LinkResult, images: ImageResult): number {
|
||||
let score = 0;
|
||||
let total = 0;
|
||||
|
||||
const metaTagValues = Object.values(metaTags);
|
||||
score += metaTagValues.filter(v => v).length;
|
||||
total += metaTagValues.length;
|
||||
|
||||
if (headings.hasH1) score++;
|
||||
if (headings.headingStructure) score++;
|
||||
if (!headings.multipleH1) score++;
|
||||
total += 3;
|
||||
|
||||
if (images.withoutAlt === 0) score++;
|
||||
total++;
|
||||
|
||||
return Math.round((score / total) * 100);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user