Code
    Shopify AI Product OptimizerUpdated Jul 13, 2026TS

    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
    1. /**
    2. * Deterministic SEO Analyzer
    3. *
    4. * Runs a set of deterministic checks on product data to produce
    5. * a normalized SEO score and list of issues.
    6. */
    7. export interface SEOIssue {
    8. code: string;
    9. severity: "critical" | "warning" | "info";
    10. field: string;
    11. message: string;
    12. currentValue?: string;
    13. }
    14. export interface SEOAnalysis {
    15. score: number;
    16. issues: SEOIssue[];
    17. checks: {
    18. passed: string[];
    19. failed: string[];
    20. };
    21. }
    22. interface ProductData {
    23. id: string;
    24. title?: string;
    25. handle?: string;
    26. descriptionHtml?: string;
    27. seo?: {
    28. title?: string;
    29. description?: string;
    30. };
    31. productType?: string;
    32. vendor?: string;
    33. tags?: string[];
    34. options?: Array<{ name: string; values: string[] }>;
    35. variants?: {
    36. edges?: Array<{
    37. node: {
    38. sku?: string;
    39. barcode?: string;
    40. };
    41. }>;
    42. };
    43. }
    44. const CHECKS = [
    45. {
    46. code: "title_length",
    47. field: "title",
    48. severity: "warning" as const,
    49. weight: 5,
    50. check: (p: ProductData) => {
    51. const title = p.title || "";
    52. return title.length >= 20 && title.length <= 70;
    53. },
    54. message: (p: ProductData) => {
    55. const len = (p.title || "").length;
    56. if (len < 20) return `Title is too short (${len} chars). Aim for 20-70 characters.`;
    57. return `Title is too long (${len} chars). Aim for 20-70 characters.`;
    58. },
    59. },
    60. {
    61. code: "title_keywords",
    62. field: "title",
    63. severity: "info" as const,
    64. weight: 3,
    65. check: (p: ProductData) => {
    66. const title = (p.title || "").toLowerCase();
    67. // Check if title contains product type or meaningful keywords
    68. const productType = (p.productType || "").toLowerCase();
    69. return productType ? title.includes(productType) : true;
    70. },
    71. message: () => "Consider including the product type in the title for better SEO.",
    72. },
    73. {
    74. code: "seo_title_set",
    75. field: "seoTitle",
    76. severity: "critical" as const,
    77. weight: 10,
    78. check: (p: ProductData) => !!p.seo?.title?.trim(),
    79. message: () => "SEO title is not set. This will use the product title as the meta title.",
    80. },
    81. {
    82. code: "seo_title_length",
    83. field: "seoTitle",
    84. severity: "warning" as const,
    85. weight: 5,
    86. check: (p: ProductData) => {
    87. const title = p.seo?.title || "";
    88. if (!title) return true; // Skip if not set
    89. return title.length >= 30 && title.length <= 60;
    90. },
    91. message: (p: ProductData) => {
    92. const len = (p.seo?.title || "").length;
    93. if (len < 30) return `SEO title is too short (${len} chars). Aim for 30-60 characters.`;
    94. return `SEO title is too long (${len} chars). May be truncated in search results.`;
    95. },
    96. },
    97. {
    98. code: "seo_description_set",
    99. field: "seoDescription",
    100. severity: "critical" as const,
    101. weight: 10,
    102. check: (p: ProductData) => !!p.seo?.description?.trim(),
    103. message: () => "Meta description is not set. Search engines will use auto-generated text.",
    104. },
    105. {
    106. code: "seo_description_length",
    107. field: "seoDescription",
    108. severity: "warning" as const,
    109. weight: 5,
    110. check: (p: ProductData) => {
    111. const desc = p.seo?.description || "";
    112. if (!desc) return true; // Skip if not set
    113. return desc.length >= 120 && desc.length <= 160;
    114. },
    115. message: (p: ProductData) => {
    116. const len = (p.seo?.description || "").length;
    117. if (len < 120) return `Meta description is too short (${len} chars). Aim for 120-160 characters.`;
    118. return `Meta description is too long (${len} chars). Will be truncated in search results.`;
    119. },
    120. },
    121. {
    122. code: "handle_quality",
    123. field: "handle",
    124. severity: "warning" as const,
    125. weight: 5,
    126. check: (p: ProductData) => {
    127. const handle = p.handle || "";
    128. // Check for good handle practices
    129. return (
    130. handle.length > 3 &&
    131. handle.length < 100 &&
    132. !handle.includes("--") &&
    133. !/\d{10,}/.test(handle) // No long number sequences
    134. );
    135. },
    136. message: () => "Product handle could be improved for better URL structure.",
    137. },
    138. {
    139. code: "description_exists",
    140. field: "description",
    141. severity: "critical" as const,
    142. weight: 10,
    143. check: (p: ProductData) => {
    144. const desc = p.descriptionHtml || "";
    145. const textContent = desc.replace(/<[^>]*>/g, "").trim();
    146. return textContent.length > 0;
    147. },
    148. message: () => "Product description is empty. Add a description to improve SEO.",
    149. },
    150. {
    151. code: "description_length",
    152. field: "description",
    153. severity: "warning" as const,
    154. weight: 5,
    155. check: (p: ProductData) => {
    156. const desc = p.descriptionHtml || "";
    157. const textContent = desc.replace(/<[^>]*>/g, "").trim();
    158. return textContent.length >= 100;
    159. },
    160. message: (p: ProductData) => {
    161. const len = (p.descriptionHtml || "").replace(/<[^>]*>/g, "").trim().length;
    162. return `Description is too short (${len} chars). Aim for at least 100 characters.`;
    163. },
    164. },
    165. {
    166. code: "description_structure",
    167. field: "description",
    168. severity: "info" as const,
    169. weight: 3,
    170. check: (p: ProductData) => {
    171. const desc = p.descriptionHtml || "";
    172. // Check for some structure (headings, lists, paragraphs)
    173. return (
    174. desc.includes("<p>") ||
    175. desc.includes("<ul>") ||
    176. desc.includes("<h")
    177. );
    178. },
    179. message: () => "Consider using structured HTML (paragraphs, lists) in the description.",
    180. },
    181. {
    182. code: "has_tags",
    183. field: "tags",
    184. severity: "info" as const,
    185. weight: 3,
    186. check: (p: ProductData) => (p.tags?.length || 0) >= 3,
    187. message: () => "Add more tags to improve internal categorization and discoverability.",
    188. },
    189. {
    190. code: "has_product_type",
    191. field: "productType",
    192. severity: "warning" as const,
    193. weight: 5,
    194. check: (p: ProductData) => !!p.productType?.trim(),
    195. message: () => "Product type is not set. This helps with categorization and filtering.",
    196. },
    197. {
    198. code: "has_vendor",
    199. field: "vendor",
    200. severity: "info" as const,
    201. weight: 2,
    202. check: (p: ProductData) => !!p.vendor?.trim(),
    203. message: () => "Vendor is not set.",
    204. },
    205. {
    206. code: "has_sku",
    207. field: "variants",
    208. severity: "info" as const,
    209. weight: 3,
    210. check: (p: ProductData) => {
    211. const variants = p.variants?.edges || [];
    212. return variants.every((v) => !!v.node.sku?.trim());
    213. },
    214. message: () => "Some variants are missing SKUs. SKUs help with inventory and search.",
    215. },
    216. {
    217. code: "consistent_options",
    218. field: "options",
    219. severity: "info" as const,
    220. weight: 2,
    221. check: (p: ProductData) => {
    222. const options = p.options || [];
    223. // Check for consistent option naming (capitalization)
    224. return options.every((opt) =>
    225. opt.name === opt.name.charAt(0).toUpperCase() + opt.name.slice(1)
    226. );
    227. },
    228. message: () => "Option names should be consistently capitalized.",
    229. },
    230. ];
    231. export function analyzeProductSEO(product: ProductData): SEOAnalysis {
    232. const issues: SEOIssue[] = [];
    233. const passed: string[] = [];
    234. const failed: string[] = [];
    235. let totalWeight = 0;
    236. let earnedWeight = 0;
    237. for (const check of CHECKS) {
    238. totalWeight += check.weight;
    239. const result = check.check(product);
    240. if (result) {
    241. passed.push(check.code);
    242. earnedWeight += check.weight;
    243. } else {
    244. failed.push(check.code);
    245. issues.push({
    246. code: check.code,
    247. severity: check.severity,
    248. field: check.field,
    249. message: check.message(product),
    250. currentValue: getFieldValue(product, check.field),
    251. });
    252. }
    253. }
    254. // Calculate score as percentage
    255. const score = Math.round((earnedWeight / totalWeight) * 100);
    256. return {
    257. score,
    258. issues,
    259. checks: { passed, failed },
    260. };
    261. }
    262. function getFieldValue(product: ProductData, field: string): string | undefined {
    263. switch (field) {
    264. case "title":
    265. return product.title;
    266. case "seoTitle":
    267. return product.seo?.title;
    268. case "seoDescription":
    269. return product.seo?.description;
    270. case "description":
    271. return product.descriptionHtml;
    272. case "handle":
    273. return product.handle;
    274. case "productType":
    275. return product.productType;
    276. case "vendor":
    277. return product.vendor;
    278. case "tags":
    279. return product.tags?.join(", ");
    280. default:
    281. return undefined;
    282. }
    283. }