import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { Type, type Static } from "typebox"; const lighthouseSchema = Type.Object({ url: Type.String({ description: "Public website URL to audit with Google Lighthouse." }), categories: Type.Optional( Type.Array(Type.String(), { description: "Lighthouse categories to run. Defaults to seo, performance, accessibility, and best-practices.", }), ), formFactor: Type.Optional( Type.String({ description: "Audit form factor: mobile or desktop. Defaults to mobile." }), ), includeRawReport: Type.Optional( Type.Boolean({ description: "Include the full Lighthouse JSON report in details. Defaults to false." }), ), }); const seoCheckSchema = Type.Object({ url: Type.Optional(Type.String({ description: "URL to fetch and analyze. If omitted, pass page metadata fields manually." })), title: Type.Optional(Type.String({ description: "Page title, if already known." })), description: Type.Optional(Type.String({ description: "Meta description, if already known." })), h1: Type.Optional(Type.String({ description: "Primary H1 text, if already known." })), canonical: Type.Optional(Type.String({ description: "Canonical URL, if already known." })), robots: Type.Optional(Type.String({ description: "Meta robots content, if already known." })), }); type LighthouseInput = Static; type SeoCheckInput = Static; type PageMeta = Required> & { url?: string; }; type LighthouseAudit = { id: string; title: string; description?: string; score: number | null; displayValue?: string; numericValue?: number; details?: { overallSavingsMs?: number; overallSavingsBytes?: number }; }; type LighthouseCategory = { id: string; title: string; score: number | null; auditRefs?: Array<{ id: string; weight?: number }>; }; type LighthouseResult = { finalDisplayedUrl?: string; requestedUrl?: string; lighthouseVersion?: string; fetchTime?: string; categories: Record; audits: Record; }; export default function (pi: ExtensionAPI) { pi.on("session_start", async (_event, ctx) => { ctx.ui.setStatus("pi-seo", "Lighthouse SEO ready"); }); pi.registerCommand("seo", { description: "Show pi-seo usage tips", handler: async (_args, ctx) => { ctx.ui.notify( "pi-seo loaded. Ask pi to run seo_lighthouse_report for a URL, then use the report to improve SEO.", "info", ); }, }); pi.registerTool({ name: "seo_lighthouse_report", label: "Lighthouse SEO Report", description: "Run Google Lighthouse in headless Chrome and generate an extensive SEO-focused website improvement report.", promptSnippet: "Run Google Lighthouse audits and produce a prioritized SEO improvement report for a website.", promptGuidelines: [ "Use seo_lighthouse_report as the main pi-seo tool when the user asks to audit, improve, optimize, or fix a website for SEO.", "After seo_lighthouse_report returns, use its action plan to make concrete website improvements when the user wants implementation help.", ], parameters: lighthouseSchema, async execute(_toolCallId, params, signal, onUpdate) { onUpdate?.({ content: [{ type: "text", text: "Launching Chrome and running Google Lighthouse..." }] }); const result = await runLighthouse(params, signal); const report = formatLighthouseReport(result); return { content: [{ type: "text", text: report }], details: { url: result.finalDisplayedUrl ?? result.requestedUrl ?? params.url, lighthouseVersion: result.lighthouseVersion, fetchTime: result.fetchTime, scores: categoryScores(result), topIssues: topActionableAudits(result), ...(params.includeRawReport ? { rawLhr: result } : {}), }, }; }, }); pi.registerTool({ name: "seo_check", label: "SEO Metadata Check", description: "Analyze basic on-page SEO metadata and return actionable recommendations.", promptSnippet: "Check page title, meta description, H1, canonical, and robots metadata.", promptGuidelines: [ "Use seo_check for quick metadata-only checks. Prefer seo_lighthouse_report for full SEO audits and website improvement plans.", ], parameters: seoCheckSchema, async execute(_toolCallId, params, signal, onUpdate) { onUpdate?.({ content: [{ type: "text", text: "Running SEO metadata checks..." }] }); const meta = params.url ? await fetchPageMeta(params.url, signal) : normalizeMeta(params); const checks = analyzeMeta(meta); const summary = formatChecks(meta, checks); return { content: [{ type: "text", text: summary }], details: { meta, checks }, }; }, }); } async function runLighthouse(params: LighthouseInput, signal?: AbortSignal): Promise { const [{ default: lighthouse }, chromeLauncher] = await Promise.all([ import("lighthouse"), import("chrome-launcher"), ]); const chrome = await chromeLauncher.launch({ chromeFlags: ["--headless", "--no-sandbox", "--disable-gpu", "--disable-dev-shm-usage"], }); const killChrome = () => void chrome.kill(); signal?.addEventListener("abort", killChrome, { once: true }); try { if (signal?.aborted) throw new Error("Lighthouse audit cancelled"); const formFactor = params.formFactor === "desktop" ? "desktop" : "mobile"; const onlyCategories = params.categories?.length ? params.categories : ["seo", "performance", "accessibility", "best-practices"]; const runnerResult = await lighthouse(params.url, { port: chrome.port, output: "json", logLevel: "info", onlyCategories, formFactor, screenEmulation: formFactor === "desktop" ? { mobile: false, width: 1350, height: 940, deviceScaleFactor: 1, disabled: false } : undefined, }); if (!runnerResult?.lhr) throw new Error("Lighthouse did not return a report"); return runnerResult.lhr as LighthouseResult; } finally { signal?.removeEventListener("abort", killChrome); await chrome.kill(); } } function formatLighthouseReport(lhr: LighthouseResult) { const scores = categoryScores(lhr); const topIssues = topActionableAudits(lhr); const seoIssues = categoryAudits(lhr, "seo").filter((audit) => isProblemAudit(audit)); return [ `# Lighthouse SEO improvement report`, "", `**URL:** ${lhr.finalDisplayedUrl ?? lhr.requestedUrl ?? "unknown"}`, lhr.lighthouseVersion ? `**Lighthouse:** ${lhr.lighthouseVersion}` : undefined, lhr.fetchTime ? `**Run time:** ${lhr.fetchTime}` : undefined, "", "## Scores", "", ...Object.entries(scores).map(([name, score]) => `- **${name}:** ${score}`), "", "## Priority action plan", "", ...(topIssues.length ? topIssues.map((audit, index) => `${index + 1}. **${audit.title}**${audit.displayValue ? ` (${audit.displayValue})` : ""}\n - ${cleanDescription(audit.description)}\n - Fix this first if it affects crawling, indexing, page speed, accessibility, or user trust.`) : ["No major failing Lighthouse audits found in the selected categories."]), "", "## SEO-specific findings", "", ...(seoIssues.length ? seoIssues.map((audit) => `- **${audit.title}:** ${cleanDescription(audit.description)}${audit.displayValue ? ` (${audit.displayValue})` : ""}`) : ["No failing SEO-category audits found."]), "", "## Recommended implementation workflow", "", "1. Fix indexing/crawling blockers first: `robots.txt`, `noindex`, invalid canonical URLs, missing hreflang, or non-crawlable links.", "2. Improve metadata and content quality: unique titles, meta descriptions, headings, image alt text, and structured data where relevant.", "3. Improve performance issues that affect SEO and UX: LCP, render-blocking assets, oversized images, unused JS/CSS, caching, and compression.", "4. Re-run `seo_lighthouse_report` after changes and compare scores and remaining failing audits.", ].filter(Boolean).join("\n"); } function categoryScores(lhr: LighthouseResult) { return Object.fromEntries( Object.values(lhr.categories).map((category) => [category.title, formatScore(category.score)]), ); } function topActionableAudits(lhr: LighthouseResult) { return Object.values(lhr.audits) .filter(isProblemAudit) .sort((a, b) => auditPriority(b) - auditPriority(a)) .slice(0, 12); } function categoryAudits(lhr: LighthouseResult, categoryId: string) { const category = lhr.categories[categoryId]; if (!category?.auditRefs?.length) return []; return category.auditRefs .map((ref) => lhr.audits[ref.id]) .filter((audit): audit is LighthouseAudit => Boolean(audit)); } function isProblemAudit(audit: LighthouseAudit) { return audit.score !== null && audit.score < 0.9; } function auditPriority(audit: LighthouseAudit) { const scorePenalty = audit.score === null ? 0 : 1 - audit.score; const timeSavings = (audit.details?.overallSavingsMs ?? 0) / 1000; const byteSavings = (audit.details?.overallSavingsBytes ?? 0) / 100_000; return scorePenalty * 10 + timeSavings + byteSavings; } function formatScore(score: number | null) { return score === null ? "n/a" : `${Math.round(score * 100)}/100`; } function cleanDescription(description = "") { return description.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1").replace(/\s+/g, " ").trim(); } async function fetchPageMeta(url: string, signal?: AbortSignal): Promise { const response = await fetch(url, { signal }); if (!response.ok) { throw new Error(`Failed to fetch ${url}: HTTP ${response.status}`); } const html = await response.text(); return normalizeMeta({ url, title: textContent(matchFirst(html, /]*>([\s\S]*?)<\/title>/i)), description: attrContent(html, "description"), h1: textContent(matchFirst(html, /]*>([\s\S]*?)<\/h1>/i)), canonical: linkHref(html, "canonical"), robots: attrContent(html, "robots"), }); } function normalizeMeta(input: SeoCheckInput): PageMeta { return { url: input.url, title: input.title?.trim() ?? "", description: input.description?.trim() ?? "", h1: input.h1?.trim() ?? "", canonical: input.canonical?.trim() ?? "", robots: input.robots?.trim() ?? "", }; } function analyzeMeta(meta: PageMeta) { const checks = [] as Array<{ name: string; status: "pass" | "warn" | "fail"; message: string }>; checks.push(checkLength("Title", meta.title, 30, 60, "Keep titles descriptive and close to 50–60 characters.")); checks.push(checkLength("Meta description", meta.description, 70, 160, "Write a unique, compelling description around 120–155 characters.")); checks.push({ name: "H1", status: meta.h1 ? "pass" : "fail", message: meta.h1 ? `Found: ${meta.h1}` : "Missing H1. Add one clear primary heading.", }); checks.push({ name: "Canonical", status: meta.canonical ? "pass" : "warn", message: meta.canonical ? `Found: ${meta.canonical}` : "No canonical URL found. Add one when duplicate URL variants are possible.", }); const noindex = /(^|,)\s*noindex\b/i.test(meta.robots); checks.push({ name: "Robots", status: noindex ? "fail" : "pass", message: meta.robots ? `robots=${meta.robots}${noindex ? " blocks indexing." : ""}` : "No robots meta tag found; default indexing is usually allowed.", }); return checks; } function checkLength(name: string, value: string, min: number, max: number, guidance: string) { if (!value) return { name, status: "fail" as const, message: `Missing. ${guidance}` }; const length = value.length; if (length < min) return { name, status: "warn" as const, message: `${length} chars; likely short. ${guidance}` }; if (length > max) return { name, status: "warn" as const, message: `${length} chars; likely long. ${guidance}` }; return { name, status: "pass" as const, message: `${length} chars. ${value}` }; } function formatChecks(meta: PageMeta, checks: ReturnType) { const icon = { pass: "✅", warn: "⚠️", fail: "❌" } as const; const target = meta.url ? ` for ${meta.url}` : ""; return [ `# SEO metadata check${target}`, "", ...checks.map((check) => `${icon[check.status]} **${check.name}** — ${check.message}`), ].join("\n"); } function attrContent(html: string, name: string) { return decodeHtml( matchFirst( html, new RegExp(`]+(?:name|property)=["']${escapeRegExp(name)}["'][^>]+content=["']([^"']*)["'][^>]*>`, "i"), ) || matchFirst( html, new RegExp(`]+content=["']([^"']*)["'][^>]+(?:name|property)=["']${escapeRegExp(name)}["'][^>]*>`, "i"), ), ); } function linkHref(html: string, rel: string) { return decodeHtml( matchFirst( html, new RegExp(`]+rel=["']${escapeRegExp(rel)}["'][^>]+href=["']([^"']*)["'][^>]*>`, "i"), ) || matchFirst( html, new RegExp(`]+href=["']([^"']*)["'][^>]+rel=["']${escapeRegExp(rel)}["'][^>]*>`, "i"), ), ); } function matchFirst(input: string, regex: RegExp) { return input.match(regex)?.[1] ?? ""; } function textContent(input: string) { return decodeHtml(input.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim()); } function decodeHtml(input: string) { return input .replace(/&/g, "&") .replace(/</g, "<") .replace(/>/g, ">") .replace(/"/g, '"') .replace(/'/g, "'"); } function escapeRegExp(input: string) { return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }