From b6084ec01a0dd34ddee04250471f1df83f12f5fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E7=BF=94?= Date: Fri, 6 Mar 2026 12:09:18 +0800 Subject: [PATCH] feat: add SEO validation utility --- .../shared/utils/seo/SEOValidator.ts | 134 ++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 test-framework/shared/utils/seo/SEOValidator.ts diff --git a/test-framework/shared/utils/seo/SEOValidator.ts b/test-framework/shared/utils/seo/SEOValidator.ts new file mode 100644 index 0000000..ae6e6d4 --- /dev/null +++ b/test-framework/shared/utils/seo/SEOValidator.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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); + } +}