Files
novalon-website/test-framework/shared/utils/seo/SEOValidator.ts
T
zhangxiang 602ed6a671 test(hooks): finalize mutation test improvements and release acceptance
- Raise use-swipe-gesture mutation score to 66.13% (target 65%+)
- Maintain use-reduced-motion mutation score at 76.32% (target 50%+)
- Fix use-reduced-motion.ts ESLint set-state-in-effect warning
- Add UJ-10 deep searcher journey (category browse → article read → content discovery)
- Add 40 new test files (analytics, detail, sections, ui, lib components)
- Update test-strategy-plan.md to v2.0 (sync test count to 1591)
- Sync README.md with final release metrics

Quality gates: TS 0 errors, ESLint 0 errors, 121 suites / 1591 tests passed
2026-08-02 19:39:27 +08:00

136 lines
3.6 KiB
TypeScript

// @ts-nocheck
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;
const 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);
}
}