Shopify AI Product Optimizer Seo Analyzer source
Imported TypeScript file from worker/services/seo-analyzer.ts.
Source file
worker/services/seo-analyzer.ts
TypeScript294 lines
/*** Deterministic SEO Analyzer** Runs a set of deterministic checks on product data to produce* a normalized SEO score and list of issues.*/export interface SEOIssue {code: string;severity: "critical" | "warning" | "info";field: string;message: string;currentValue?: string;}export interface SEOAnalysis {score: number;issues: SEOIssue[];checks: {passed: string[];failed: string[];};}interface ProductData {id: string;title?: string;handle?: string;descriptionHtml?: string;seo?: {title?: string;description?: string;};productType?: string;vendor?: string;tags?: string[];options?: Array<{ name: string; values: string[] }>;variants?: {edges?: Array<{node: {sku?: string;barcode?: string;};}>;};}const CHECKS = [{code: "title_length",field: "title",severity: "warning" as const,weight: 5,check: (p: ProductData) => {const title = p.title || "";return title.length >= 20 && title.length <= 70;},message: (p: ProductData) => {const len = (p.title || "").length;if (len < 20) return `Title is too short (${len} chars). Aim for 20-70 characters.`;return `Title is too long (${len} chars). Aim for 20-70 characters.`;},},{code: "title_keywords",field: "title",severity: "info" as const,weight: 3,check: (p: ProductData) => {const title = (p.title || "").toLowerCase();// Check if title contains product type or meaningful keywordsconst productType = (p.productType || "").toLowerCase();return productType ? title.includes(productType) : true;},message: () => "Consider including the product type in the title for better SEO.",},{code: "seo_title_set",field: "seoTitle",severity: "critical" as const,weight: 10,check: (p: ProductData) => !!p.seo?.title?.trim(),message: () => "SEO title is not set. This will use the product title as the meta title.",},{code: "seo_title_length",field: "seoTitle",severity: "warning" as const,weight: 5,check: (p: ProductData) => {const title = p.seo?.title || "";if (!title) return true; // Skip if not setreturn title.length >= 30 && title.length <= 60;},message: (p: ProductData) => {const len = (p.seo?.title || "").length;if (len < 30) return `SEO title is too short (${len} chars). Aim for 30-60 characters.`;return `SEO title is too long (${len} chars). May be truncated in search results.`;},},{code: "seo_description_set",field: "seoDescription",severity: "critical" as const,weight: 10,check: (p: ProductData) => !!p.seo?.description?.trim(),message: () => "Meta description is not set. Search engines will use auto-generated text.",},{code: "seo_description_length",field: "seoDescription",severity: "warning" as const,weight: 5,check: (p: ProductData) => {const desc = p.seo?.description || "";if (!desc) return true; // Skip if not setreturn desc.length >= 120 && desc.length <= 160;},message: (p: ProductData) => {const len = (p.seo?.description || "").length;if (len < 120) return `Meta description is too short (${len} chars). Aim for 120-160 characters.`;return `Meta description is too long (${len} chars). Will be truncated in search results.`;},},{code: "handle_quality",field: "handle",severity: "warning" as const,weight: 5,check: (p: ProductData) => {const handle = p.handle || "";// Check for good handle practicesreturn (handle.length > 3 &&handle.length < 100 &&!handle.includes("--") &&!/\d{10,}/.test(handle) // No long number sequences);},message: () => "Product handle could be improved for better URL structure.",},{code: "description_exists",field: "description",severity: "critical" as const,weight: 10,check: (p: ProductData) => {const desc = p.descriptionHtml || "";const textContent = desc.replace(/<[^>]*>/g, "").trim();return textContent.length > 0;},message: () => "Product description is empty. Add a description to improve SEO.",},{code: "description_length",field: "description",severity: "warning" as const,weight: 5,check: (p: ProductData) => {const desc = p.descriptionHtml || "";const textContent = desc.replace(/<[^>]*>/g, "").trim();return textContent.length >= 100;},message: (p: ProductData) => {const len = (p.descriptionHtml || "").replace(/<[^>]*>/g, "").trim().length;return `Description is too short (${len} chars). Aim for at least 100 characters.`;},},{code: "description_structure",field: "description",severity: "info" as const,weight: 3,check: (p: ProductData) => {const desc = p.descriptionHtml || "";// Check for some structure (headings, lists, paragraphs)return (desc.includes("<p>") ||desc.includes("<ul>") ||desc.includes("<h"));},message: () => "Consider using structured HTML (paragraphs, lists) in the description.",},{code: "has_tags",field: "tags",severity: "info" as const,weight: 3,check: (p: ProductData) => (p.tags?.length || 0) >= 3,message: () => "Add more tags to improve internal categorization and discoverability.",},{code: "has_product_type",field: "productType",severity: "warning" as const,weight: 5,check: (p: ProductData) => !!p.productType?.trim(),message: () => "Product type is not set. This helps with categorization and filtering.",},{code: "has_vendor",field: "vendor",severity: "info" as const,weight: 2,check: (p: ProductData) => !!p.vendor?.trim(),message: () => "Vendor is not set.",},{code: "has_sku",field: "variants",severity: "info" as const,weight: 3,check: (p: ProductData) => {const variants = p.variants?.edges || [];return variants.every((v) => !!v.node.sku?.trim());},message: () => "Some variants are missing SKUs. SKUs help with inventory and search.",},{code: "consistent_options",field: "options",severity: "info" as const,weight: 2,check: (p: ProductData) => {const options = p.options || [];// Check for consistent option naming (capitalization)return options.every((opt) =>opt.name === opt.name.charAt(0).toUpperCase() + opt.name.slice(1));},message: () => "Option names should be consistently capitalized.",},];export function analyzeProductSEO(product: ProductData): SEOAnalysis {const issues: SEOIssue[] = [];const passed: string[] = [];const failed: string[] = [];let totalWeight = 0;let earnedWeight = 0;for (const check of CHECKS) {totalWeight += check.weight;const result = check.check(product);if (result) {passed.push(check.code);earnedWeight += check.weight;} else {failed.push(check.code);issues.push({code: check.code,severity: check.severity,field: check.field,message: check.message(product),currentValue: getFieldValue(product, check.field),});}}// Calculate score as percentageconst score = Math.round((earnedWeight / totalWeight) * 100);return {score,issues,checks: { passed, failed },};}function getFieldValue(product: ProductData, field: string): string | undefined {switch (field) {case "title":return product.title;case "seoTitle":return product.seo?.title;case "seoDescription":return product.seo?.description;case "description":return product.descriptionHtml;case "handle":return product.handle;case "productType":return product.productType;case "vendor":return product.vendor;case "tags":return product.tags?.join(", ");default:return undefined;}}