Files
novalon-website/test-framework/shared/utils/seo/SEOValidator.ts
T
2026-03-06 12:09:18 +08:00

135 lines
3.6 KiB
TypeScript

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);
}
}