mirror of
https://github.com/AgentDeskAI/browser-tools-mcp.git
synced 2025-06-27 00:41:26 +00:00
364 lines
10 KiB
TypeScript
364 lines
10 KiB
TypeScript
import { Result as LighthouseResult } from "lighthouse";
|
|
import { AuditCategory, LighthouseReport } from "./types.js";
|
|
import { runLighthouseAudit } from "./index.js";
|
|
|
|
// === SEO Report Types ===
|
|
|
|
/**
|
|
* SEO-specific report content structure
|
|
*/
|
|
export interface SEOReportContent {
|
|
score: number; // Overall score (0-100)
|
|
audit_counts: {
|
|
// Counts of different audit types
|
|
failed: number;
|
|
passed: number;
|
|
manual: number;
|
|
informative: number;
|
|
not_applicable: number;
|
|
};
|
|
issues: AISEOIssue[];
|
|
categories: {
|
|
[category: string]: {
|
|
score: number;
|
|
issues_count: number;
|
|
};
|
|
};
|
|
prioritized_recommendations?: string[]; // Ordered list of recommendations
|
|
}
|
|
|
|
/**
|
|
* Full SEO report implementing the base LighthouseReport interface
|
|
*/
|
|
export type AIOptimizedSEOReport = LighthouseReport<SEOReportContent>;
|
|
|
|
/**
|
|
* AI-optimized SEO issue
|
|
*/
|
|
interface AISEOIssue {
|
|
id: string; // e.g., "meta-description"
|
|
title: string; // e.g., "Document has a meta description"
|
|
impact: "critical" | "serious" | "moderate" | "minor";
|
|
category: string; // e.g., "content", "mobile", "crawlability"
|
|
details?: {
|
|
selector?: string; // CSS selector if applicable
|
|
value?: string; // Current value
|
|
issue?: string; // Description of the issue
|
|
}[];
|
|
score: number | null; // 0-1 or null
|
|
}
|
|
|
|
// Original interfaces for backward compatibility
|
|
interface SEOAudit {
|
|
id: string; // e.g., "meta-description"
|
|
title: string; // e.g., "Document has a meta description"
|
|
description: string; // e.g., "Meta descriptions improve SEO..."
|
|
score: number | null; // 0-1 or null
|
|
scoreDisplayMode: string; // e.g., "binary"
|
|
details?: SEOAuditDetails; // Optional, structured details
|
|
weight?: number; // For prioritization
|
|
}
|
|
|
|
interface SEOAuditDetails {
|
|
items?: Array<{
|
|
selector?: string; // e.g., "meta[name='description']"
|
|
issue?: string; // e.g., "Meta description is missing"
|
|
value?: string; // e.g., Current meta description text
|
|
}>;
|
|
type?: string; // e.g., "table"
|
|
}
|
|
|
|
// This ensures we always include critical issues while limiting less important ones
|
|
const DETAIL_LIMITS = {
|
|
critical: Number.MAX_SAFE_INTEGER, // No limit for critical issues
|
|
serious: 15, // Up to 15 items for serious issues
|
|
moderate: 10, // Up to 10 items for moderate issues
|
|
minor: 3, // Up to 3 items for minor issues
|
|
};
|
|
|
|
/**
|
|
* Runs an SEO audit on the specified URL
|
|
* @param url The URL to audit
|
|
* @returns Promise resolving to AI-optimized SEO audit results
|
|
*/
|
|
export async function runSEOAudit(url: string): Promise<AIOptimizedSEOReport> {
|
|
try {
|
|
const lhr = await runLighthouseAudit(url, [AuditCategory.SEO]);
|
|
return extractAIOptimizedData(lhr, url);
|
|
} catch (error) {
|
|
throw new Error(
|
|
`SEO audit failed: ${
|
|
error instanceof Error ? error.message : String(error)
|
|
}`
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Extract AI-optimized SEO data from Lighthouse results
|
|
*/
|
|
const extractAIOptimizedData = (
|
|
lhr: LighthouseResult,
|
|
url: string
|
|
): AIOptimizedSEOReport => {
|
|
const categoryData = lhr.categories[AuditCategory.SEO];
|
|
const audits = lhr.audits || {};
|
|
|
|
// Add metadata
|
|
const metadata = {
|
|
url,
|
|
timestamp: lhr.fetchTime || new Date().toISOString(),
|
|
device: "desktop", // This could be made configurable
|
|
lighthouseVersion: lhr.lighthouseVersion,
|
|
};
|
|
|
|
// Initialize variables
|
|
const issues: AISEOIssue[] = [];
|
|
const categories: {
|
|
[category: string]: { score: number; issues_count: number };
|
|
} = {
|
|
content: { score: 0, issues_count: 0 },
|
|
mobile: { score: 0, issues_count: 0 },
|
|
crawlability: { score: 0, issues_count: 0 },
|
|
other: { score: 0, issues_count: 0 },
|
|
};
|
|
|
|
// Count audits by type
|
|
let failedCount = 0;
|
|
let passedCount = 0;
|
|
let manualCount = 0;
|
|
let informativeCount = 0;
|
|
let notApplicableCount = 0;
|
|
|
|
// Process audit refs
|
|
const auditRefs = categoryData?.auditRefs || [];
|
|
|
|
// First pass: count audits by type and initialize categories
|
|
auditRefs.forEach((ref) => {
|
|
const audit = audits[ref.id];
|
|
if (!audit) return;
|
|
|
|
// Count by scoreDisplayMode
|
|
if (audit.scoreDisplayMode === "manual") {
|
|
manualCount++;
|
|
} else if (audit.scoreDisplayMode === "informative") {
|
|
informativeCount++;
|
|
} else if (audit.scoreDisplayMode === "notApplicable") {
|
|
notApplicableCount++;
|
|
} else if (audit.score !== null) {
|
|
// Binary pass/fail
|
|
if (audit.score >= 0.9) {
|
|
passedCount++;
|
|
} else {
|
|
failedCount++;
|
|
}
|
|
}
|
|
|
|
// Categorize the issue
|
|
let category = "other";
|
|
if (
|
|
ref.id.includes("crawl") ||
|
|
ref.id.includes("http") ||
|
|
ref.id.includes("redirect") ||
|
|
ref.id.includes("robots")
|
|
) {
|
|
category = "crawlability";
|
|
} else if (
|
|
ref.id.includes("viewport") ||
|
|
ref.id.includes("font-size") ||
|
|
ref.id.includes("tap-targets")
|
|
) {
|
|
category = "mobile";
|
|
} else if (
|
|
ref.id.includes("document") ||
|
|
ref.id.includes("meta") ||
|
|
ref.id.includes("description") ||
|
|
ref.id.includes("canonical") ||
|
|
ref.id.includes("title") ||
|
|
ref.id.includes("link")
|
|
) {
|
|
category = "content";
|
|
}
|
|
|
|
// Update category score and issues count
|
|
if (audit.score !== null && audit.score < 0.9) {
|
|
categories[category].issues_count++;
|
|
}
|
|
});
|
|
|
|
// Second pass: process failed audits into AI-friendly format
|
|
auditRefs
|
|
.filter((ref) => {
|
|
const audit = audits[ref.id];
|
|
return audit && audit.score !== null && audit.score < 0.9;
|
|
})
|
|
.sort((a, b) => (b.weight || 0) - (a.weight || 0))
|
|
// No limit on failed audits - we'll filter dynamically based on impact
|
|
.forEach((ref) => {
|
|
const audit = audits[ref.id];
|
|
|
|
// Determine impact level based on score and weight
|
|
let impact: "critical" | "serious" | "moderate" | "minor" = "moderate";
|
|
if (audit.score === 0) {
|
|
impact = "critical";
|
|
} else if (audit.score !== null && audit.score <= 0.5) {
|
|
impact = "serious";
|
|
} else if (audit.score !== null && audit.score > 0.7) {
|
|
impact = "minor";
|
|
}
|
|
|
|
// Categorize the issue
|
|
let category = "other";
|
|
if (
|
|
ref.id.includes("crawl") ||
|
|
ref.id.includes("http") ||
|
|
ref.id.includes("redirect") ||
|
|
ref.id.includes("robots")
|
|
) {
|
|
category = "crawlability";
|
|
} else if (
|
|
ref.id.includes("viewport") ||
|
|
ref.id.includes("font-size") ||
|
|
ref.id.includes("tap-targets")
|
|
) {
|
|
category = "mobile";
|
|
} else if (
|
|
ref.id.includes("document") ||
|
|
ref.id.includes("meta") ||
|
|
ref.id.includes("description") ||
|
|
ref.id.includes("canonical") ||
|
|
ref.id.includes("title") ||
|
|
ref.id.includes("link")
|
|
) {
|
|
category = "content";
|
|
}
|
|
|
|
// Extract details
|
|
const details: { selector?: string; value?: string; issue?: string }[] =
|
|
[];
|
|
|
|
if (audit.details) {
|
|
const auditDetails = audit.details as any;
|
|
if (auditDetails.items && Array.isArray(auditDetails.items)) {
|
|
// Determine item limit based on impact
|
|
const itemLimit = DETAIL_LIMITS[impact];
|
|
|
|
auditDetails.items.slice(0, itemLimit).forEach((item: any) => {
|
|
const detail: {
|
|
selector?: string;
|
|
value?: string;
|
|
issue?: string;
|
|
} = {};
|
|
|
|
if (item.selector) {
|
|
detail.selector = item.selector;
|
|
}
|
|
|
|
if (item.value !== undefined) {
|
|
detail.value = item.value;
|
|
}
|
|
|
|
if (item.issue) {
|
|
detail.issue = item.issue;
|
|
}
|
|
|
|
if (Object.keys(detail).length > 0) {
|
|
details.push(detail);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// Create the issue
|
|
const issue: AISEOIssue = {
|
|
id: ref.id,
|
|
title: audit.title,
|
|
impact,
|
|
category,
|
|
details: details.length > 0 ? details : undefined,
|
|
score: audit.score,
|
|
};
|
|
|
|
issues.push(issue);
|
|
});
|
|
|
|
// Calculate overall score
|
|
const score = Math.round((categoryData?.score || 0) * 100);
|
|
|
|
// Generate prioritized recommendations
|
|
const prioritized_recommendations: string[] = [];
|
|
|
|
// Add category-specific recommendations
|
|
Object.entries(categories)
|
|
.filter(([_, data]) => data.issues_count > 0)
|
|
.sort(([_, a], [__, b]) => b.issues_count - a.issues_count)
|
|
.forEach(([category, data]) => {
|
|
if (data.issues_count === 0) return;
|
|
|
|
let recommendation = "";
|
|
|
|
switch (category) {
|
|
case "content":
|
|
recommendation = `Improve SEO content (${data.issues_count} issues): titles, descriptions, and headers`;
|
|
break;
|
|
case "mobile":
|
|
recommendation = `Optimize for mobile devices (${data.issues_count} issues)`;
|
|
break;
|
|
case "crawlability":
|
|
recommendation = `Fix crawlability issues (${data.issues_count} issues): robots.txt, sitemaps, and redirects`;
|
|
break;
|
|
default:
|
|
recommendation = `Fix ${data.issues_count} SEO issues in category: ${category}`;
|
|
}
|
|
|
|
prioritized_recommendations.push(recommendation);
|
|
});
|
|
|
|
// Add specific high-impact recommendations
|
|
if (issues.some((issue) => issue.id === "meta-description")) {
|
|
prioritized_recommendations.push(
|
|
"Add a meta description to improve click-through rate"
|
|
);
|
|
}
|
|
|
|
if (issues.some((issue) => issue.id === "document-title")) {
|
|
prioritized_recommendations.push(
|
|
"Add a descriptive page title with keywords"
|
|
);
|
|
}
|
|
|
|
if (issues.some((issue) => issue.id === "hreflang")) {
|
|
prioritized_recommendations.push(
|
|
"Fix hreflang implementation for international SEO"
|
|
);
|
|
}
|
|
|
|
if (issues.some((issue) => issue.id === "canonical")) {
|
|
prioritized_recommendations.push("Implement proper canonical tags");
|
|
}
|
|
|
|
// Create the report content
|
|
const reportContent: SEOReportContent = {
|
|
score,
|
|
audit_counts: {
|
|
failed: failedCount,
|
|
passed: passedCount,
|
|
manual: manualCount,
|
|
informative: informativeCount,
|
|
not_applicable: notApplicableCount,
|
|
},
|
|
issues,
|
|
categories,
|
|
prioritized_recommendations:
|
|
prioritized_recommendations.length > 0
|
|
? prioritized_recommendations
|
|
: undefined,
|
|
};
|
|
|
|
// Return the full report following the LighthouseReport interface
|
|
return {
|
|
metadata,
|
|
report: reportContent,
|
|
};
|
|
};
|