6d92024b63
- 修复API测试认证问题:创建全局认证设置,更新Playwright配置 - 优化回归测试稳定性:增加超时时间到15秒,修复定位器 - 创建Woodpecker CI工作流:CI、部署和质量门禁配置 - 添加Jest配置和测试脚本 - 移除登录页面的默认账号密码显示(安全问题修复)
135 lines
3.6 KiB
TypeScript
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);
|
|
}
|
|
}
|