Files
cannaiq/backend/src/seo/template-engine.ts
Kelly 2f483b3084 feat: SEO template library, discovery pipeline, and orchestrator enhancements
## SEO Template Library
- Add complete template library with 7 page types (state, city, category, brand, product, search, regeneration)
- Add Template Library tab in SEO Orchestrator with accordion-based editors
- Add template preview, validation, and variable injection engine
- Add API endpoints: /api/seo/templates, preview, validate, generate, regenerate

## Discovery Pipeline
- Add promotion.ts for discovery location validation and promotion
- Add discover-all-states.ts script for multi-state discovery
- Add promotion log migration (067)
- Enhance discovery routes and types

## Orchestrator & Admin
- Add crawl_enabled filter to stores page
- Add API permissions page
- Add job queue management
- Add price analytics routes
- Add markets and intelligence routes
- Enhance dashboard and worker monitoring

## Infrastructure
- Add migrations for worker definitions, SEO settings, field alignment
- Add canonical pipeline for scraper v2
- Update hydration and sync orchestrator
- Enhance multi-state query service

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-09 00:05:34 -07:00

370 lines
10 KiB
TypeScript

/**
* SEO Template Engine
*
* Handles template selection, variable injection, and content generation
* for different page types (state, city, category, brand, product, search).
*/
import { getAllSettings, getSetting } from './settings';
// Page types supported by the template engine
export type PageType = 'state' | 'city' | 'category' | 'brand' | 'product' | 'search';
// Template keys mapping
export const TEMPLATE_KEYS: Record<PageType, string> = {
state: 'state_page_template',
city: 'city_page_template',
category: 'category_page_template',
brand: 'brand_page_template',
product: 'product_page_template',
search: 'search_results_template',
};
// Sample mock data for previews
export const MOCK_DATA: Record<PageType, Record<string, any>> = {
state: {
state_name: 'Arizona',
state_code: 'AZ',
state_code_lower: 'az',
dispensary_count: 156,
product_count: 12450,
brand_count: 287,
category_count: 8,
top_brands: ['Raw Garden', 'Stiiizy', 'Select', 'Pax', 'Bloom'],
top_categories: ['Flower', 'Vape', 'Edibles', 'Concentrate', 'Pre-rolls'],
avg_price: 42.50,
last_updated: new Date().toISOString().split('T')[0],
},
city: {
city_name: 'Phoenix',
state_name: 'Arizona',
state_code: 'AZ',
dispensary_count: 45,
product_count: 3200,
brand_count: 120,
nearby_cities: ['Scottsdale', 'Tempe', 'Mesa', 'Glendale'],
popular_dispensaries: ['Harvest', 'Curaleaf', 'Zen Leaf'],
avg_price: 40.00,
},
category: {
category_name: 'Flower',
category_slug: 'flower',
product_count: 4500,
brand_count: 95,
state_name: 'Arizona',
avg_price: 35.00,
top_strains: ['Blue Dream', 'OG Kush', 'Girl Scout Cookies'],
subcategories: ['Indica', 'Sativa', 'Hybrid'],
},
brand: {
brand_name: 'Raw Garden',
brand_slug: 'raw-garden',
product_count: 156,
state_presence: ['AZ', 'CA', 'NV', 'CO'],
store_count: 89,
avg_price: 45.00,
categories: ['Concentrate', 'Vape', 'Live Resin'],
description: 'Premium cannabis products from California',
},
product: {
product_name: 'Blue Dream Cartridge',
brand_name: 'Select',
category: 'Vape',
thc_percent: 85.5,
cbd_percent: 0.5,
price: 45.00,
dispensary_name: 'Harvest HOC',
dispensary_city: 'Phoenix',
state_name: 'Arizona',
in_stock: true,
},
search: {
query: 'live resin',
result_count: 245,
product_results: 180,
dispensary_results: 45,
brand_results: 20,
state_name: 'Arizona',
top_categories: ['Concentrate', 'Vape'],
},
};
/**
* Apply template variables to a template string
* Replaces {{variable}} with values from data object
*
* Rules:
* - Replace {{variable}} occurrences
* - Leave unknown variables unchanged
* - Prevent undefined values (replace with empty string)
* - Support arrays by joining with comma
*/
export function applyTemplateVariables(
template: string,
data: Record<string, any>
): string {
if (!template) return '';
let result = template;
// Find all {{variable}} patterns
const variablePattern = /\{\{(\w+)\}\}/g;
let match;
while ((match = variablePattern.exec(template)) !== null) {
const fullMatch = match[0];
const variableName = match[1];
if (variableName in data) {
let value = data[variableName];
// Handle different value types
if (value === undefined || value === null) {
value = '';
} else if (Array.isArray(value)) {
value = value.join(', ');
} else if (typeof value === 'object') {
value = JSON.stringify(value);
} else {
value = String(value);
}
// Replace all occurrences of this variable
result = result.split(fullMatch).join(value);
}
// Leave unknown variables unchanged
}
return result;
}
/**
* Get the correct template for a page type
* Uses case-insensitive matching
*/
export async function getTemplateForPageType(pageType: string): Promise<string> {
const normalizedType = pageType.toLowerCase().trim() as PageType;
const templateKey = TEMPLATE_KEYS[normalizedType];
if (!templateKey) {
console.warn(`[TemplateEngine] Unknown page type: ${pageType}, falling back to state template`);
return getSetting('state_page_template');
}
return getSetting(templateKey);
}
/**
* Get regeneration template
*/
export async function getRegenerationTemplate(): Promise<string> {
return getSetting('regeneration_template');
}
/**
* Generate content for a page using the appropriate template
*/
export async function generatePageContent(
pageType: string,
data: Record<string, any>
): Promise<{
content: string;
templateUsed: string;
variablesApplied: string[];
}> {
const template = await getTemplateForPageType(pageType);
const content = applyTemplateVariables(template, data);
// Extract which variables were actually used
const variablePattern = /\{\{(\w+)\}\}/g;
const variablesInTemplate: string[] = [];
let match;
while ((match = variablePattern.exec(template)) !== null) {
if (!variablesInTemplate.includes(match[1])) {
variablesInTemplate.push(match[1]);
}
}
const variablesApplied = variablesInTemplate.filter(v => v in data);
return {
content,
templateUsed: TEMPLATE_KEYS[pageType.toLowerCase() as PageType] || 'state_page_template',
variablesApplied,
};
}
/**
* Generate a preview with mock data
*/
export async function generatePreview(
pageType: string,
customTemplate?: string
): Promise<{
preview: string;
template: string;
mockData: Record<string, any>;
availableVariables: string[];
}> {
const normalizedType = (pageType?.toLowerCase().trim() || 'state') as PageType;
const template = customTemplate || await getTemplateForPageType(normalizedType);
const mockData = MOCK_DATA[normalizedType] || MOCK_DATA.state;
const preview = applyTemplateVariables(template, mockData);
return {
preview,
template,
mockData,
availableVariables: Object.keys(mockData),
};
}
/**
* Regenerate content using regeneration template
*/
export async function regenerateContent(
pageType: string,
originalContent: string,
newData: Record<string, any>,
improvementAreas?: string[]
): Promise<{
content: string;
regenerationPrompt: string;
}> {
const regenerationTemplate = await getRegenerationTemplate();
const settings = await getAllSettings();
// Build regeneration context
const regenerationData = {
...newData,
original_content: originalContent,
page_type: pageType,
improvement_areas: improvementAreas?.join(', ') || 'SEO keywords, local relevance, data freshness',
tone: settings.tone_voice || 'informational',
length: settings.default_content_length || 'medium',
};
const regenerationPrompt = applyTemplateVariables(regenerationTemplate, regenerationData);
// Generate new content using the page template
const pageTemplate = await getTemplateForPageType(pageType);
const content = applyTemplateVariables(pageTemplate, newData);
return {
content,
regenerationPrompt,
};
}
/**
* Get all available templates and their metadata
*/
export async function getAllTemplates(): Promise<Record<string, {
key: string;
template: string;
description: string;
availableVariables: string[];
}>> {
const settings = await getAllSettings();
return {
state: {
key: 'state_page_template',
template: settings.state_page_template || '',
description: 'Template for state landing pages (e.g., "Arizona Dispensaries")',
availableVariables: Object.keys(MOCK_DATA.state),
},
city: {
key: 'city_page_template',
template: settings.city_page_template || '',
description: 'Template for city landing pages (e.g., "Phoenix Dispensaries")',
availableVariables: Object.keys(MOCK_DATA.city),
},
category: {
key: 'category_page_template',
template: settings.category_page_template || '',
description: 'Template for category pages (e.g., "Flower", "Edibles")',
availableVariables: Object.keys(MOCK_DATA.category),
},
brand: {
key: 'brand_page_template',
template: settings.brand_page_template || '',
description: 'Template for brand pages (e.g., "Raw Garden Products")',
availableVariables: Object.keys(MOCK_DATA.brand),
},
product: {
key: 'product_page_template',
template: settings.product_page_template || '',
description: 'Template for individual product pages',
availableVariables: Object.keys(MOCK_DATA.product),
},
search: {
key: 'search_results_template',
template: settings.search_results_template || '',
description: 'Template for search results pages',
availableVariables: Object.keys(MOCK_DATA.search),
},
regeneration: {
key: 'regeneration_template',
template: settings.regeneration_template || '',
description: 'Template used when regenerating/improving existing content',
availableVariables: ['original_content', 'page_type', 'improvement_areas', 'tone', 'length', '...page-specific variables'],
},
};
}
/**
* Validate a template string
*/
export function validateTemplate(template: string): {
valid: boolean;
variables: string[];
unknownVariables: string[];
errors: string[];
} {
const errors: string[] = [];
const variables: string[] = [];
// Find all variables
const variablePattern = /\{\{(\w+)\}\}/g;
let match;
while ((match = variablePattern.exec(template)) !== null) {
if (!variables.includes(match[1])) {
variables.push(match[1]);
}
}
// Check for unclosed brackets
const openBrackets = (template.match(/\{\{/g) || []).length;
const closeBrackets = (template.match(/\}\}/g) || []).length;
if (openBrackets !== closeBrackets) {
errors.push('Mismatched template brackets: {{ and }} counts do not match');
}
// Check for empty variable names
if (template.includes('{{}}')) {
errors.push('Empty variable name found: {{}}');
}
// Get all known variables
const allKnownVariables = new Set<string>();
Object.values(MOCK_DATA).forEach(data => {
Object.keys(data).forEach(key => allKnownVariables.add(key));
});
allKnownVariables.add('original_content');
allKnownVariables.add('page_type');
allKnownVariables.add('improvement_areas');
allKnownVariables.add('tone');
allKnownVariables.add('length');
const unknownVariables = variables.filter(v => !allKnownVariables.has(v));
return {
valid: errors.length === 0,
variables,
unknownVariables,
errors,
};
}