mirror of
https://github.com/AgentDeskAI/browser-tools-mcp.git
synced 2025-11-26 07:01:15 +00:00
refactor: standardize Lighthouse audit reports with AI-optimized data extraction
This commit is contained in:
parent
085aabc765
commit
7a7bf8c137
@ -351,12 +351,11 @@ enum AuditCategory {
|
|||||||
// Add tool for accessibility audits, launches a headless browser instance
|
// Add tool for accessibility audits, launches a headless browser instance
|
||||||
server.tool(
|
server.tool(
|
||||||
"runAccessibilityAudit",
|
"runAccessibilityAudit",
|
||||||
"Run a WCAG-compliant accessibility audit on the current page",
|
"Run an accessibility audit on the current page",
|
||||||
{},
|
{},
|
||||||
async () => {
|
async () => {
|
||||||
return await withServerConnection(async () => {
|
return await withServerConnection(async () => {
|
||||||
try {
|
try {
|
||||||
// Simplified approach - let the browser connector handle the current tab and URL
|
|
||||||
console.log(
|
console.log(
|
||||||
`Sending POST request to http://${discoveredHost}:${discoveredPort}/accessibility-audit`
|
`Sending POST request to http://${discoveredHost}:${discoveredPort}/accessibility-audit`
|
||||||
);
|
);
|
||||||
@ -369,24 +368,39 @@ server.tool(
|
|||||||
Accept: "application/json",
|
Accept: "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
category: AuditCategory.ACCESSIBILITY,
|
|
||||||
source: "mcp_tool",
|
source: "mcp_tool",
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Log the response status
|
// Check for errors
|
||||||
console.log(`Accessibility audit response status: ${response.status}`);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorText = await response.text();
|
const errorText = await response.text();
|
||||||
console.error(`Accessibility audit error: ${errorText}`);
|
|
||||||
throw new Error(`Server returned ${response.status}: ${errorText}`);
|
throw new Error(`Server returned ${response.status}: ${errorText}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const json = await response.json();
|
const json = await response.json();
|
||||||
|
|
||||||
|
// If the response is in the new format with a nested 'report',
|
||||||
|
// flatten it by merging metadata with the report contents
|
||||||
|
if (json.report) {
|
||||||
|
const { metadata, report } = json;
|
||||||
|
const flattened = {
|
||||||
|
...metadata,
|
||||||
|
...report,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: JSON.stringify(flattened, null, 2),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// Return as-is if it's not in the new format
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
@ -395,6 +409,7 @@ server.tool(
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage =
|
const errorMessage =
|
||||||
error instanceof Error ? error.message : String(error);
|
error instanceof Error ? error.message : String(error);
|
||||||
@ -484,7 +499,6 @@ server.tool(
|
|||||||
async () => {
|
async () => {
|
||||||
return await withServerConnection(async () => {
|
return await withServerConnection(async () => {
|
||||||
try {
|
try {
|
||||||
// Simplified approach - let the browser connector handle the current tab and URL
|
|
||||||
console.log(
|
console.log(
|
||||||
`Sending POST request to http://${discoveredHost}:${discoveredPort}/seo-audit`
|
`Sending POST request to http://${discoveredHost}:${discoveredPort}/seo-audit`
|
||||||
);
|
);
|
||||||
@ -497,24 +511,39 @@ server.tool(
|
|||||||
Accept: "application/json",
|
Accept: "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
category: AuditCategory.SEO,
|
|
||||||
source: "mcp_tool",
|
source: "mcp_tool",
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Log the response status
|
// Check for errors
|
||||||
console.log(`SEO audit response status: ${response.status}`);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorText = await response.text();
|
const errorText = await response.text();
|
||||||
console.error(`SEO audit error: ${errorText}`);
|
|
||||||
throw new Error(`Server returned ${response.status}: ${errorText}`);
|
throw new Error(`Server returned ${response.status}: ${errorText}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const json = await response.json();
|
const json = await response.json();
|
||||||
|
|
||||||
|
// If the response is in the new format with a nested 'report',
|
||||||
|
// flatten it by merging metadata with the report contents
|
||||||
|
if (json.report) {
|
||||||
|
const { metadata, report } = json;
|
||||||
|
const flattened = {
|
||||||
|
...metadata,
|
||||||
|
...report,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: JSON.stringify(flattened, null, 2),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// Return as-is if it's not in the new format
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
@ -523,6 +552,7 @@ server.tool(
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage =
|
const errorMessage =
|
||||||
error instanceof Error ? error.message : String(error);
|
error instanceof Error ? error.message : String(error);
|
||||||
|
|||||||
@ -890,7 +890,7 @@ export class BrowserConnector {
|
|||||||
console.log("No valid URL available yet, waiting for navigation...");
|
console.log("No valid URL available yet, waiting for navigation...");
|
||||||
|
|
||||||
// Wait up to 10 seconds for a URL to be set (20 attempts x 500ms)
|
// Wait up to 10 seconds for a URL to be set (20 attempts x 500ms)
|
||||||
const maxAttempts = 20;
|
const maxAttempts = 50;
|
||||||
const waitTime = 500; // ms
|
const waitTime = 500; // ms
|
||||||
|
|
||||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||||
|
|||||||
@ -2,6 +2,62 @@ import { Result as LighthouseResult } from "lighthouse";
|
|||||||
import { AuditCategory, LighthouseReport } from "./types.js";
|
import { AuditCategory, LighthouseReport } from "./types.js";
|
||||||
import { runLighthouseAudit } from "./index.js";
|
import { runLighthouseAudit } from "./index.js";
|
||||||
|
|
||||||
|
// === Accessibility Report Types ===
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accessibility-specific report content structure
|
||||||
|
*/
|
||||||
|
export interface AccessibilityReportContent {
|
||||||
|
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: AIAccessibilityIssue[];
|
||||||
|
categories: {
|
||||||
|
[category: string]: {
|
||||||
|
score: number;
|
||||||
|
issues_count: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
critical_elements: AIAccessibilityElement[];
|
||||||
|
prioritized_recommendations?: string[]; // Ordered list of recommendations
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full accessibility report implementing the base LighthouseReport interface
|
||||||
|
*/
|
||||||
|
export type AIOptimizedAccessibilityReport =
|
||||||
|
LighthouseReport<AccessibilityReportContent>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI-optimized accessibility issue
|
||||||
|
*/
|
||||||
|
interface AIAccessibilityIssue {
|
||||||
|
id: string; // e.g., "color-contrast"
|
||||||
|
title: string; // e.g., "Color contrast is sufficient"
|
||||||
|
impact: "critical" | "serious" | "moderate" | "minor";
|
||||||
|
category: string; // e.g., "contrast", "aria", "forms", "keyboard"
|
||||||
|
elements?: AIAccessibilityElement[]; // Elements with issues
|
||||||
|
score: number | null; // 0-1 or null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accessibility element with issues
|
||||||
|
*/
|
||||||
|
interface AIAccessibilityElement {
|
||||||
|
selector: string; // CSS selector
|
||||||
|
snippet?: string; // HTML snippet
|
||||||
|
label?: string; // Element label
|
||||||
|
issue_description?: string; // Description of the issue
|
||||||
|
value?: string | number; // Current value (e.g., contrast ratio)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Original interfaces for backward compatibility
|
||||||
interface AccessibilityAudit {
|
interface AccessibilityAudit {
|
||||||
id: string; // e.g., "color-contrast"
|
id: string; // e.g., "color-contrast"
|
||||||
title: string; // e.g., "Color contrast is sufficient"
|
title: string; // e.g., "Color contrast is sufficient"
|
||||||
@ -27,24 +83,26 @@ type AuditDetails = {
|
|||||||
[key: string]: any; // Flexible for other detail types (tables, etc.)
|
[key: string]: any; // Flexible for other detail types (tables, etc.)
|
||||||
};
|
};
|
||||||
|
|
||||||
const FAILED_AUDITS_LIMIT = 5;
|
// Original limits were optimized for human consumption
|
||||||
|
// This ensures we always include critical issues while limiting less important ones
|
||||||
// Define a maximum number of items to include in the audit details
|
const DETAIL_LIMITS = {
|
||||||
const MAX_ITEMS_IN_DETAILS = 3;
|
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 accessibility audit on the specified URL
|
* Runs an accessibility audit on the specified URL
|
||||||
* @param url The URL to audit
|
* @param url The URL to audit
|
||||||
* @param limit Maximum number of issues to return
|
* @returns Promise resolving to AI-optimized accessibility audit results
|
||||||
* @returns Promise resolving to simplified accessibility audit results
|
|
||||||
*/
|
*/
|
||||||
export async function runAccessibilityAudit(
|
export async function runAccessibilityAudit(
|
||||||
url: string
|
url: string
|
||||||
): Promise<LighthouseReport> {
|
): Promise<AIOptimizedAccessibilityReport> {
|
||||||
try {
|
try {
|
||||||
const lhr = await runLighthouseAudit(url, [AuditCategory.ACCESSIBILITY]);
|
const lhr = await runLighthouseAudit(url, [AuditCategory.ACCESSIBILITY]);
|
||||||
const accessibilityReport = extractLhrResult(lhr, url);
|
return extractAIOptimizedData(lhr, url);
|
||||||
return accessibilityReport;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Accessibility audit failed: ${
|
`Accessibility audit failed: ${
|
||||||
@ -54,145 +112,219 @@ export async function runAccessibilityAudit(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const extractLhrResult = (
|
/**
|
||||||
|
* Extract AI-optimized accessibility data from Lighthouse results
|
||||||
|
*/
|
||||||
|
const extractAIOptimizedData = (
|
||||||
lhr: LighthouseResult,
|
lhr: LighthouseResult,
|
||||||
url: string
|
url: string
|
||||||
): LighthouseReport => {
|
): AIOptimizedAccessibilityReport => {
|
||||||
const categoryData = lhr.categories[AuditCategory.ACCESSIBILITY];
|
const categoryData = lhr.categories[AuditCategory.ACCESSIBILITY];
|
||||||
|
const audits = lhr.audits || {};
|
||||||
|
|
||||||
|
// Add metadata
|
||||||
const metadata = {
|
const metadata = {
|
||||||
url,
|
url,
|
||||||
timestamp: lhr.fetchTime
|
timestamp: lhr.fetchTime || new Date().toISOString(),
|
||||||
? new Date(lhr.fetchTime).toISOString()
|
device: "desktop", // This could be made configurable
|
||||||
: new Date().toISOString(),
|
|
||||||
device: "desktop", // TODO: pass device from the request instead of hardcoding
|
|
||||||
lighthouseVersion: lhr.lighthouseVersion,
|
lighthouseVersion: lhr.lighthouseVersion,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!categoryData)
|
// Initialize variables
|
||||||
return {
|
const issues: AIAccessibilityIssue[] = [];
|
||||||
metadata,
|
const criticalElements: AIAccessibilityElement[] = [];
|
||||||
failedAudits: [],
|
const categories: {
|
||||||
overallScore: 0,
|
[category: string]: { score: number; issues_count: number };
|
||||||
failedAuditsCount: 0,
|
} = {};
|
||||||
passedAuditsCount: 0,
|
|
||||||
manualAuditsCount: 0,
|
|
||||||
informativeAuditsCount: 0,
|
|
||||||
notApplicableAuditsCount: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
const overallScore = Math.round((categoryData.score || 0) * 100);
|
// Count audits by type
|
||||||
const auditRefs = categoryData.auditRefs || [];
|
let failedCount = 0;
|
||||||
const audits = lhr.audits || {};
|
let passedCount = 0;
|
||||||
|
let manualCount = 0;
|
||||||
|
let informativeCount = 0;
|
||||||
|
let notApplicableCount = 0;
|
||||||
|
|
||||||
const accessibilityAudits: AccessibilityAudit[] = auditRefs.map((ref) => {
|
// 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++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process categories
|
||||||
|
if (ref.group) {
|
||||||
|
// Initialize category if not exists
|
||||||
|
if (!categories[ref.group]) {
|
||||||
|
categories[ref.group] = { score: 0, issues_count: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update category score and issues count
|
||||||
|
if (audit.score !== null && audit.score < 0.9) {
|
||||||
|
categories[ref.group].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 number of failed audits - we'll show them all
|
||||||
|
.forEach((ref) => {
|
||||||
const audit = audits[ref.id];
|
const audit = audits[ref.id];
|
||||||
|
|
||||||
// Create a simplified version of the audit details
|
// Determine impact level based on score and weight
|
||||||
let simplifiedDetails: AuditDetails | undefined;
|
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";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create elements array
|
||||||
|
const elements: AIAccessibilityElement[] = [];
|
||||||
|
|
||||||
if (audit.details) {
|
if (audit.details) {
|
||||||
simplifiedDetails = {};
|
const details = audit.details as any;
|
||||||
|
if (details.items && Array.isArray(details.items)) {
|
||||||
// Only copy the items array if it exists
|
const items = details.items;
|
||||||
if (
|
// Apply limits based on impact level
|
||||||
(audit.details as any).items &&
|
const itemLimit = DETAIL_LIMITS[impact];
|
||||||
Array.isArray((audit.details as any).items)
|
items.slice(0, itemLimit).forEach((item: any) => {
|
||||||
) {
|
|
||||||
// Limit the number of items to MAX_ITEMS_IN_DETAILS
|
|
||||||
const limitedItems = (audit.details as any).items.slice(
|
|
||||||
0,
|
|
||||||
MAX_ITEMS_IN_DETAILS
|
|
||||||
);
|
|
||||||
|
|
||||||
simplifiedDetails.items = limitedItems.map((item: any) => {
|
|
||||||
const simplifiedItem: any = {};
|
|
||||||
|
|
||||||
// Only include node with selector if they exist
|
|
||||||
if (item.node) {
|
if (item.node) {
|
||||||
// Include the node with all its properties
|
const element: AIAccessibilityElement = {
|
||||||
simplifiedItem.node = {
|
selector: item.node.selector,
|
||||||
selector: item.node.selector || null,
|
snippet: item.node.snippet,
|
||||||
nodeLabel: item.node.nodeLabel || null,
|
label: item.node.nodeLabel,
|
||||||
snippet: item.node.snippet || null,
|
issue_description: item.node.explanation || item.explanation,
|
||||||
};
|
};
|
||||||
// Include explanation if it exists
|
|
||||||
if (item.node.explanation) {
|
|
||||||
simplifiedItem.node.explanation = item.node.explanation;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Include value if it exists
|
|
||||||
if (item.value !== undefined) {
|
if (item.value !== undefined) {
|
||||||
simplifiedItem.value = item.value;
|
element.value = item.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Include explanation at the item level if it exists
|
elements.push(element);
|
||||||
if (item.explanation) {
|
|
||||||
simplifiedItem.explanation = item.explanation;
|
|
||||||
}
|
|
||||||
|
|
||||||
return simplifiedItem;
|
// Add to critical elements if impact is critical or serious
|
||||||
|
if (impact === "critical" || impact === "serious") {
|
||||||
|
criticalElements.push(element);
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copy any other essential properties that might be needed
|
|
||||||
if ((audit.details as any).type) {
|
|
||||||
simplifiedDetails.type = (audit.details as any).type;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Include debugData if it exists
|
// Create the issue
|
||||||
if ((audit.details as any).debugData) {
|
const issue: AIAccessibilityIssue = {
|
||||||
simplifiedDetails.debugData = (audit.details as any).debugData;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: ref.id,
|
id: ref.id,
|
||||||
title: audit.title || "Untitled",
|
title: audit.title,
|
||||||
description: audit.description || "No description",
|
impact,
|
||||||
score: audit.score, // Individual audit score (0-1 or null)
|
category: ref.group || "other",
|
||||||
scoreDisplayMode: audit.scoreDisplayMode || "numeric",
|
elements: elements.length > 0 ? elements : undefined,
|
||||||
details: simplifiedDetails,
|
score: audit.score,
|
||||||
weight: ref.weight || 1,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
issues.push(issue);
|
||||||
});
|
});
|
||||||
|
|
||||||
const failedAudits = accessibilityAudits
|
// Calculate overall score
|
||||||
.filter((audit) => audit.score !== null && audit.score < 1)
|
const score = Math.round((categoryData?.score || 0) * 100);
|
||||||
.sort(
|
|
||||||
(a, b) =>
|
|
||||||
b.weight! * (1 - (b.score || 0)) - a.weight! * (1 - (a.score || 0))
|
|
||||||
)
|
|
||||||
.slice(0, FAILED_AUDITS_LIMIT);
|
|
||||||
|
|
||||||
const passedAudits = accessibilityAudits.filter(
|
// Generate prioritized recommendations
|
||||||
(audit) => audit.score !== null && audit.score >= 1
|
const prioritized_recommendations: string[] = [];
|
||||||
);
|
|
||||||
const manualAudits = accessibilityAudits.filter(
|
|
||||||
(audit) => audit.scoreDisplayMode === "manual"
|
|
||||||
);
|
|
||||||
const informativeAudits = accessibilityAudits.filter(
|
|
||||||
(audit) => audit.scoreDisplayMode === "informative"
|
|
||||||
);
|
|
||||||
const notApplicableAudits = accessibilityAudits.filter(
|
|
||||||
(audit) => audit.scoreDisplayMode === "notApplicable"
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = {
|
// 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]) => {
|
||||||
|
let recommendation = "";
|
||||||
|
|
||||||
|
switch (category) {
|
||||||
|
case "a11y-color-contrast":
|
||||||
|
recommendation = "Improve color contrast for better readability";
|
||||||
|
break;
|
||||||
|
case "a11y-names-labels":
|
||||||
|
recommendation = "Add proper labels to all interactive elements";
|
||||||
|
break;
|
||||||
|
case "a11y-aria":
|
||||||
|
recommendation = "Fix ARIA attributes and roles";
|
||||||
|
break;
|
||||||
|
case "a11y-navigation":
|
||||||
|
recommendation = "Improve keyboard navigation and focus management";
|
||||||
|
break;
|
||||||
|
case "a11y-language":
|
||||||
|
recommendation = "Add proper language attributes to HTML";
|
||||||
|
break;
|
||||||
|
case "a11y-tables-lists":
|
||||||
|
recommendation = "Fix table and list structures for screen readers";
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
recommendation = `Fix ${data.issues_count} issues in ${category}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
prioritized_recommendations.push(recommendation);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add specific high-impact recommendations
|
||||||
|
if (issues.some((issue) => issue.id === "color-contrast")) {
|
||||||
|
prioritized_recommendations.push(
|
||||||
|
"Fix low contrast text for better readability"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (issues.some((issue) => issue.id === "document-title")) {
|
||||||
|
prioritized_recommendations.push("Add a descriptive page title");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (issues.some((issue) => issue.id === "image-alt")) {
|
||||||
|
prioritized_recommendations.push("Add alt text to all images");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the report content
|
||||||
|
const reportContent: AccessibilityReportContent = {
|
||||||
|
score,
|
||||||
|
audit_counts: {
|
||||||
|
failed: failedCount,
|
||||||
|
passed: passedCount,
|
||||||
|
manual: manualCount,
|
||||||
|
informative: informativeCount,
|
||||||
|
not_applicable: notApplicableCount,
|
||||||
|
},
|
||||||
|
issues,
|
||||||
|
categories,
|
||||||
|
critical_elements: criticalElements,
|
||||||
|
prioritized_recommendations:
|
||||||
|
prioritized_recommendations.length > 0
|
||||||
|
? prioritized_recommendations
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Return the full report following the LighthouseReport interface
|
||||||
|
return {
|
||||||
metadata,
|
metadata,
|
||||||
overallScore,
|
report: reportContent,
|
||||||
failedAuditsCount: failedAudits.length,
|
};
|
||||||
passedAuditsCount: passedAudits.length,
|
|
||||||
manualAuditsCount: manualAudits.length,
|
|
||||||
informativeAuditsCount: informativeAudits.length,
|
|
||||||
notApplicableAuditsCount: notApplicableAudits.length,
|
|
||||||
failedAudits,
|
|
||||||
} as LighthouseReport;
|
|
||||||
|
|
||||||
console.log(result);
|
|
||||||
failedAudits.forEach((audit) => {
|
|
||||||
console.log(JSON.stringify(audit.details, null, 2));
|
|
||||||
});
|
|
||||||
return result;
|
|
||||||
};
|
};
|
||||||
|
|||||||
@ -2,16 +2,33 @@ import { Result as LighthouseResult } from "lighthouse";
|
|||||||
import { AuditCategory, LighthouseReport } from "./types.js";
|
import { AuditCategory, LighthouseReport } from "./types.js";
|
||||||
import { runLighthouseAudit } from "./index.js";
|
import { runLighthouseAudit } from "./index.js";
|
||||||
|
|
||||||
interface PerformanceAuditDetails {
|
// === Performance Report Types ===
|
||||||
items?: Array<{
|
|
||||||
resourceUrl?: string; // e.g., "https://example.com/script.js" (for render-blocking resources)
|
/**
|
||||||
wastedMs?: number; // e.g., 150 (potential savings)
|
* Performance-specific report content structure
|
||||||
elementSelector?: string; // e.g., "img.hero" (for LCP element)
|
*/
|
||||||
timing?: number; // e.g., 2.5 (specific timing value)
|
export interface PerformanceReportContent {
|
||||||
}>;
|
score: number; // Overall score (0-100)
|
||||||
type?: string; // e.g., "opportunity" or "table"
|
audit_counts: {
|
||||||
|
// Counts of different audit types
|
||||||
|
failed: number;
|
||||||
|
passed: number;
|
||||||
|
manual: number;
|
||||||
|
informative: number;
|
||||||
|
not_applicable: number;
|
||||||
|
};
|
||||||
|
metrics: AIOptimizedMetric[];
|
||||||
|
opportunities: AIOptimizedOpportunity[];
|
||||||
|
page_stats?: AIPageStats; // Optional page statistics
|
||||||
|
prioritized_recommendations?: string[]; // Ordered list of recommendations
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full performance report implementing the base LighthouseReport interface
|
||||||
|
*/
|
||||||
|
export type AIOptimizedPerformanceReport =
|
||||||
|
LighthouseReport<PerformanceReportContent>;
|
||||||
|
|
||||||
// AI-optimized performance metric format
|
// AI-optimized performance metric format
|
||||||
interface AIOptimizedMetric {
|
interface AIOptimizedMetric {
|
||||||
id: string; // Short ID like "lcp", "fcp"
|
id: string; // Short ID like "lcp", "fcp"
|
||||||
@ -28,7 +45,7 @@ interface AIOptimizedMetric {
|
|||||||
interface AIOptimizedOpportunity {
|
interface AIOptimizedOpportunity {
|
||||||
id: string; // Like "render_blocking", "http2"
|
id: string; // Like "render_blocking", "http2"
|
||||||
savings_ms: number; // Time savings in ms
|
savings_ms: number; // Time savings in ms
|
||||||
severity?: "critical" | "major" | "minor"; // Severity classification
|
severity?: "critical" | "serious" | "moderate" | "minor"; // Severity classification
|
||||||
resources: Array<{
|
resources: Array<{
|
||||||
url: string; // Resource URL
|
url: string; // Resource URL
|
||||||
savings_ms?: number; // Individual resource savings
|
savings_ms?: number; // Individual resource savings
|
||||||
@ -54,28 +71,13 @@ interface AIPageStats {
|
|||||||
main_thread_blocking_time_ms: number; // Time spent blocking the main thread
|
main_thread_blocking_time_ms: number; // Time spent blocking the main thread
|
||||||
}
|
}
|
||||||
|
|
||||||
// AI-optimized performance report
|
// This ensures we always include critical issues while limiting less important ones
|
||||||
interface AIOptimizedPerformanceReport {
|
const DETAIL_LIMITS = {
|
||||||
metadata: {
|
critical: Number.MAX_SAFE_INTEGER, // No limit for critical issues
|
||||||
url: string; // Page URL
|
serious: 15, // Up to 15 items for serious issues
|
||||||
timestamp: string; // ISO timestamp
|
moderate: 10, // Up to 10 items for moderate issues
|
||||||
device: string; // Device type (desktop, mobile)
|
minor: 3, // Up to 3 items for minor issues
|
||||||
lighthouseVersion: string; // Lighthouse version used
|
};
|
||||||
};
|
|
||||||
score: number; // Overall score (0-100)
|
|
||||||
audit_counts: {
|
|
||||||
// Counts of different audit types
|
|
||||||
failed: number;
|
|
||||||
passed: number;
|
|
||||||
manual: number;
|
|
||||||
informative: number;
|
|
||||||
not_applicable: number;
|
|
||||||
};
|
|
||||||
metrics: AIOptimizedMetric[];
|
|
||||||
opportunities: AIOptimizedOpportunity[];
|
|
||||||
page_stats?: AIPageStats; // Optional page statistics
|
|
||||||
prioritized_recommendations?: string[]; // Ordered list of recommendations
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Performance audit adapted for AI consumption
|
* Performance audit adapted for AI consumption
|
||||||
@ -84,7 +86,9 @@ interface AIOptimizedPerformanceReport {
|
|||||||
* - Key metrics and opportunities clearly structured
|
* - Key metrics and opportunities clearly structured
|
||||||
* - Only actionable data that an AI can use for recommendations
|
* - Only actionable data that an AI can use for recommendations
|
||||||
*/
|
*/
|
||||||
export async function runPerformanceAudit(url: string): Promise<any> {
|
export async function runPerformanceAudit(
|
||||||
|
url: string
|
||||||
|
): Promise<AIOptimizedPerformanceReport> {
|
||||||
try {
|
try {
|
||||||
const lhr = await runLighthouseAudit(url, [AuditCategory.PERFORMANCE]);
|
const lhr = await runLighthouseAudit(url, [AuditCategory.PERFORMANCE]);
|
||||||
return extractAIOptimizedData(lhr, url);
|
return extractAIOptimizedData(lhr, url);
|
||||||
@ -495,15 +499,34 @@ const extractAIOptimizedData = (
|
|||||||
// Extract opportunities
|
// Extract opportunities
|
||||||
if (audits["render-blocking-resources"]) {
|
if (audits["render-blocking-resources"]) {
|
||||||
const rbrAudit = audits["render-blocking-resources"];
|
const rbrAudit = audits["render-blocking-resources"];
|
||||||
|
|
||||||
|
// Determine impact level based on potential savings
|
||||||
|
let impact: "critical" | "serious" | "moderate" | "minor" = "moderate";
|
||||||
|
const savings = Math.round(rbrAudit.numericValue || 0);
|
||||||
|
|
||||||
|
if (savings > 2000) {
|
||||||
|
impact = "critical";
|
||||||
|
} else if (savings > 1000) {
|
||||||
|
impact = "serious";
|
||||||
|
} else if (savings < 300) {
|
||||||
|
impact = "minor";
|
||||||
|
}
|
||||||
|
|
||||||
const opportunity: AIOptimizedOpportunity = {
|
const opportunity: AIOptimizedOpportunity = {
|
||||||
id: "render_blocking_resources",
|
id: "render_blocking_resources",
|
||||||
savings_ms: Math.round(rbrAudit.numericValue || 0),
|
savings_ms: savings,
|
||||||
|
severity: impact,
|
||||||
resources: [],
|
resources: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
const rbrDetails = rbrAudit.details as any;
|
const rbrDetails = rbrAudit.details as any;
|
||||||
if (rbrDetails && rbrDetails.items && Array.isArray(rbrDetails.items)) {
|
if (rbrDetails && rbrDetails.items && Array.isArray(rbrDetails.items)) {
|
||||||
rbrDetails.items.forEach((item: { url?: string; wastedMs?: number }) => {
|
// Determine how many items to include based on impact
|
||||||
|
const itemLimit = DETAIL_LIMITS[impact];
|
||||||
|
|
||||||
|
rbrDetails.items
|
||||||
|
.slice(0, itemLimit)
|
||||||
|
.forEach((item: { url?: string; wastedMs?: number }) => {
|
||||||
if (item.url) {
|
if (item.url) {
|
||||||
// Extract file name from full URL
|
// Extract file name from full URL
|
||||||
const fileName = item.url.split("/").pop() || item.url;
|
const fileName = item.url.split("/").pop() || item.url;
|
||||||
@ -522,9 +545,23 @@ const extractAIOptimizedData = (
|
|||||||
|
|
||||||
if (audits["uses-http2"]) {
|
if (audits["uses-http2"]) {
|
||||||
const http2Audit = audits["uses-http2"];
|
const http2Audit = audits["uses-http2"];
|
||||||
|
|
||||||
|
// Determine impact level based on potential savings
|
||||||
|
let impact: "critical" | "serious" | "moderate" | "minor" = "moderate";
|
||||||
|
const savings = Math.round(http2Audit.numericValue || 0);
|
||||||
|
|
||||||
|
if (savings > 2000) {
|
||||||
|
impact = "critical";
|
||||||
|
} else if (savings > 1000) {
|
||||||
|
impact = "serious";
|
||||||
|
} else if (savings < 300) {
|
||||||
|
impact = "minor";
|
||||||
|
}
|
||||||
|
|
||||||
const opportunity: AIOptimizedOpportunity = {
|
const opportunity: AIOptimizedOpportunity = {
|
||||||
id: "http2",
|
id: "http2",
|
||||||
savings_ms: Math.round(http2Audit.numericValue || 0),
|
savings_ms: savings,
|
||||||
|
severity: impact,
|
||||||
resources: [],
|
resources: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -534,7 +571,12 @@ const extractAIOptimizedData = (
|
|||||||
http2Details.items &&
|
http2Details.items &&
|
||||||
Array.isArray(http2Details.items)
|
Array.isArray(http2Details.items)
|
||||||
) {
|
) {
|
||||||
http2Details.items.forEach((item: { url?: string }) => {
|
// Determine how many items to include based on impact
|
||||||
|
const itemLimit = DETAIL_LIMITS[impact];
|
||||||
|
|
||||||
|
http2Details.items
|
||||||
|
.slice(0, itemLimit)
|
||||||
|
.forEach((item: { url?: string }) => {
|
||||||
if (item.url) {
|
if (item.url) {
|
||||||
// Extract file name from full URL
|
// Extract file name from full URL
|
||||||
const fileName = item.url.split("/").pop() || item.url;
|
const fileName = item.url.split("/").pop() || item.url;
|
||||||
@ -705,9 +747,8 @@ const extractAIOptimizedData = (
|
|||||||
prioritized_recommendations.push("Reduce JavaScript execution time");
|
prioritized_recommendations.push("Reduce JavaScript execution time");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add to the return object
|
// Create the performance report content
|
||||||
return {
|
const reportContent: PerformanceReportContent = {
|
||||||
metadata,
|
|
||||||
score,
|
score,
|
||||||
audit_counts,
|
audit_counts,
|
||||||
metrics,
|
metrics,
|
||||||
@ -718,4 +759,10 @@ const extractAIOptimizedData = (
|
|||||||
? prioritized_recommendations
|
? prioritized_recommendations
|
||||||
: undefined,
|
: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Return the full report following the LighthouseReport interface
|
||||||
|
return {
|
||||||
|
metadata,
|
||||||
|
report: reportContent,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@ -2,6 +2,53 @@ import { Result as LighthouseResult } from "lighthouse";
|
|||||||
import { AuditCategory, LighthouseReport } from "./types.js";
|
import { AuditCategory, LighthouseReport } from "./types.js";
|
||||||
import { runLighthouseAudit } from "./index.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 {
|
interface SEOAudit {
|
||||||
id: string; // e.g., "meta-description"
|
id: string; // e.g., "meta-description"
|
||||||
title: string; // e.g., "Document has a meta description"
|
title: string; // e.g., "Document has a meta description"
|
||||||
@ -21,13 +68,23 @@ interface SEOAuditDetails {
|
|||||||
type?: string; // e.g., "table"
|
type?: string; // e.g., "table"
|
||||||
}
|
}
|
||||||
|
|
||||||
const FAILED_AUDITS_LIMIT = 5;
|
// This ensures we always include critical issues while limiting less important ones
|
||||||
const MAX_ITEMS_IN_DETAILS = 3;
|
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
|
||||||
|
};
|
||||||
|
|
||||||
export async function runSEOAudit(url: string): Promise<LighthouseReport> {
|
/**
|
||||||
|
* 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 {
|
try {
|
||||||
const lhr = await runLighthouseAudit(url, [AuditCategory.SEO]);
|
const lhr = await runLighthouseAudit(url, [AuditCategory.SEO]);
|
||||||
return extractSEOResult(lhr, url);
|
return extractAIOptimizedData(lhr, url);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`SEO audit failed: ${
|
`SEO audit failed: ${
|
||||||
@ -37,103 +94,270 @@ export async function runSEOAudit(url: string): Promise<LighthouseReport> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const extractSEOResult = (
|
/**
|
||||||
|
* Extract AI-optimized SEO data from Lighthouse results
|
||||||
|
*/
|
||||||
|
const extractAIOptimizedData = (
|
||||||
lhr: LighthouseResult,
|
lhr: LighthouseResult,
|
||||||
url: string
|
url: string
|
||||||
): LighthouseReport => {
|
): AIOptimizedSEOReport => {
|
||||||
const categoryData = lhr.categories[AuditCategory.SEO];
|
const categoryData = lhr.categories[AuditCategory.SEO];
|
||||||
|
const audits = lhr.audits || {};
|
||||||
|
|
||||||
|
// Add metadata
|
||||||
const metadata = {
|
const metadata = {
|
||||||
url,
|
url,
|
||||||
timestamp: lhr.fetchTime
|
timestamp: lhr.fetchTime || new Date().toISOString(),
|
||||||
? new Date(lhr.fetchTime).toISOString()
|
device: "desktop", // This could be made configurable
|
||||||
: new Date().toISOString(),
|
|
||||||
device: "desktop", // TODO: pass device from the request instead of hardcoding
|
|
||||||
lighthouseVersion: lhr.lighthouseVersion,
|
lighthouseVersion: lhr.lighthouseVersion,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!categoryData) {
|
// Initialize variables
|
||||||
return {
|
const issues: AISEOIssue[] = [];
|
||||||
metadata,
|
const categories: {
|
||||||
failedAudits: [],
|
[category: string]: { score: number; issues_count: number };
|
||||||
overallScore: 0,
|
} = {
|
||||||
failedAuditsCount: 0,
|
content: { score: 0, issues_count: 0 },
|
||||||
passedAuditsCount: 0,
|
mobile: { score: 0, issues_count: 0 },
|
||||||
manualAuditsCount: 0,
|
crawlability: { score: 0, issues_count: 0 },
|
||||||
informativeAuditsCount: 0,
|
other: { score: 0, issues_count: 0 },
|
||||||
notApplicableAuditsCount: 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++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const overallScore = Math.round((categoryData.score || 0) * 100);
|
// Categorize the issue
|
||||||
const auditRefs = categoryData.auditRefs || [];
|
let category = "other";
|
||||||
const audits = lhr.audits || {};
|
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";
|
||||||
|
}
|
||||||
|
|
||||||
const seoAudits: SEOAudit[] = auditRefs.map((ref) => {
|
// 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];
|
const audit = audits[ref.id];
|
||||||
let simplifiedDetails: SEOAuditDetails | undefined;
|
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) {
|
if (audit.details) {
|
||||||
simplifiedDetails = {};
|
const auditDetails = audit.details as any;
|
||||||
if (
|
if (auditDetails.items && Array.isArray(auditDetails.items)) {
|
||||||
(audit.details as any).items &&
|
// Determine item limit based on impact
|
||||||
Array.isArray((audit.details as any).items)
|
const itemLimit = DETAIL_LIMITS[impact];
|
||||||
) {
|
|
||||||
const limitedItems = (audit.details as any).items.slice(
|
auditDetails.items.slice(0, itemLimit).forEach((item: any) => {
|
||||||
0,
|
const detail: {
|
||||||
MAX_ITEMS_IN_DETAILS
|
selector?: string;
|
||||||
);
|
value?: string;
|
||||||
simplifiedDetails.items = limitedItems.map((item: any) => {
|
issue?: string;
|
||||||
const simplifiedItem: any = {};
|
} = {};
|
||||||
if (item.node?.selector) simplifiedItem.selector = item.node.selector;
|
|
||||||
if (item.explanation)
|
if (item.selector) {
|
||||||
simplifiedItem.issue = item.explanation.split("\n")[0]; // First line for brevity
|
detail.selector = item.selector;
|
||||||
if (item.value) simplifiedItem.value = item.value; // e.g., meta description text
|
|
||||||
return simplifiedItem;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (audit.details.type) simplifiedDetails.type = audit.details.type;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
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,
|
id: ref.id,
|
||||||
title: audit.title || "Untitled",
|
title: audit.title,
|
||||||
description: audit.description || "No description",
|
impact,
|
||||||
|
category,
|
||||||
|
details: details.length > 0 ? details : undefined,
|
||||||
score: audit.score,
|
score: audit.score,
|
||||||
scoreDisplayMode: audit.scoreDisplayMode || "numeric",
|
|
||||||
details: simplifiedDetails,
|
|
||||||
weight: ref.weight || 1,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
issues.push(issue);
|
||||||
});
|
});
|
||||||
|
|
||||||
const failedAudits = seoAudits
|
// Calculate overall score
|
||||||
.filter((audit) => audit.score !== null && audit.score < 1)
|
const score = Math.round((categoryData?.score || 0) * 100);
|
||||||
.sort(
|
|
||||||
(a, b) =>
|
|
||||||
b.weight! * (1 - (b.score || 0)) - a.weight! * (1 - (a.score || 0))
|
|
||||||
)
|
|
||||||
.slice(0, FAILED_AUDITS_LIMIT);
|
|
||||||
|
|
||||||
const passedAudits = seoAudits.filter(
|
// Generate prioritized recommendations
|
||||||
(audit) => audit.score !== null && audit.score >= 1
|
const prioritized_recommendations: string[] = [];
|
||||||
);
|
|
||||||
const manualAudits = seoAudits.filter(
|
|
||||||
(audit) => audit.scoreDisplayMode === "manual"
|
|
||||||
);
|
|
||||||
const informativeAudits = seoAudits.filter(
|
|
||||||
(audit) => audit.scoreDisplayMode === "informative"
|
|
||||||
);
|
|
||||||
const notApplicableAudits = seoAudits.filter(
|
|
||||||
(audit) => audit.scoreDisplayMode === "notApplicable"
|
|
||||||
);
|
|
||||||
|
|
||||||
|
// 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 {
|
return {
|
||||||
metadata,
|
metadata,
|
||||||
overallScore,
|
report: reportContent,
|
||||||
failedAuditsCount: failedAudits.length,
|
|
||||||
passedAuditsCount: passedAudits.length,
|
|
||||||
manualAuditsCount: manualAudits.length,
|
|
||||||
informativeAuditsCount: informativeAudits.length,
|
|
||||||
notApplicableAuditsCount: notApplicableAudits.length,
|
|
||||||
failedAudits,
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@ -12,20 +12,25 @@ export enum AuditCategory {
|
|||||||
/**
|
/**
|
||||||
* Base interface for Lighthouse report metadata
|
* Base interface for Lighthouse report metadata
|
||||||
*/
|
*/
|
||||||
export interface LighthouseReport {
|
export interface LighthouseReport<T = any> {
|
||||||
metadata: {
|
metadata: {
|
||||||
url: string;
|
url: string;
|
||||||
timestamp: string; // ISO 8601, e.g., "2025-02-27T14:30:00Z"
|
timestamp: string; // ISO 8601, e.g., "2025-02-27T14:30:00Z"
|
||||||
device: string; // e.g., "mobile", "desktop"
|
device: string; // e.g., "mobile", "desktop"
|
||||||
lighthouseVersion: string; // e.g., "10.4.0"
|
lighthouseVersion: string; // e.g., "10.4.0"
|
||||||
};
|
};
|
||||||
overallScore: number;
|
|
||||||
failedAuditsCount: number;
|
// For backward compatibility with existing report formats
|
||||||
passedAuditsCount: number;
|
overallScore?: number;
|
||||||
manualAuditsCount: number;
|
failedAuditsCount?: number;
|
||||||
informativeAuditsCount: number;
|
passedAuditsCount?: number;
|
||||||
notApplicableAuditsCount: number;
|
manualAuditsCount?: number;
|
||||||
failedAudits: any[];
|
informativeAuditsCount?: number;
|
||||||
|
notApplicableAuditsCount?: number;
|
||||||
|
failedAudits?: any[];
|
||||||
|
|
||||||
|
// New format for specialized reports
|
||||||
|
report?: T; // Generic report data that will be specialized by each audit type
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user