feat: content generation engine - spintax resolver, API endpoints, BullMQ worker
This commit is contained in:
54
migrations/02_content_generation.sql
Normal file
54
migrations/02_content_generation.sql
Normal file
@@ -0,0 +1,54 @@
|
||||
-- Phase 1: Content Generation Schema Updates
|
||||
|
||||
-- Add columns to existing tables
|
||||
ALTER TABLE avatars
|
||||
ADD COLUMN IF NOT EXISTS industry VARCHAR(255),
|
||||
ADD COLUMN IF NOT EXISTS pain_point TEXT,
|
||||
ADD COLUMN IF NOT EXISTS value_prop TEXT;
|
||||
|
||||
ALTER TABLE campaign_masters
|
||||
ADD COLUMN IF NOT EXISTS site_id UUID REFERENCES sites (id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE content_fragments
|
||||
ADD COLUMN IF NOT EXISTS campaign_id UUID REFERENCES campaign_masters (id) ON DELETE CASCADE,
|
||||
ADD COLUMN IF NOT EXISTS content_hash VARCHAR(64) UNIQUE,
|
||||
ADD COLUMN IF NOT EXISTS use_count INTEGER DEFAULT 0;
|
||||
|
||||
-- New table: variation_registry (track unique combinations)
|
||||
CREATE TABLE IF NOT EXISTS variation_registry (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid (),
|
||||
campaign_id UUID NOT NULL REFERENCES campaign_masters (id) ON DELETE CASCADE,
|
||||
variation_hash VARCHAR(64) UNIQUE NOT NULL,
|
||||
resolved_variables JSONB NOT NULL,
|
||||
spintax_choices JSONB NOT NULL,
|
||||
post_id UUID REFERENCES posts (id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_variation_hash ON variation_registry (variation_hash);
|
||||
|
||||
-- New table: block_usage_stats (track how many times each block is used)
|
||||
CREATE TABLE IF NOT EXISTS block_usage_stats (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid (),
|
||||
content_fragment_id UUID REFERENCES content_fragments (id) ON DELETE CASCADE,
|
||||
block_type VARCHAR(255) NOT NULL,
|
||||
total_uses INTEGER DEFAULT 0,
|
||||
last_used_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE (content_fragment_id)
|
||||
);
|
||||
|
||||
-- New table: spintax_variation_stats (track which spintax choices are used most)
|
||||
CREATE TABLE IF NOT EXISTS spintax_variation_stats (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid (),
|
||||
content_fragment_id UUID REFERENCES content_fragments (id) ON DELETE CASCADE,
|
||||
variation_path TEXT NOT NULL, -- e.g., "hero.h1.option_1"
|
||||
variation_text TEXT NOT NULL,
|
||||
use_count INTEGER DEFAULT 0,
|
||||
last_used_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_block_usage_fragment ON block_usage_stats (content_fragment_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_spintax_stats_fragment ON spintax_variation_stats (content_fragment_id);
|
||||
154
src/lib/spintax/resolver.ts
Normal file
154
src/lib/spintax/resolver.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
// Spintax Resolver - Handles {A|B|C} syntax
|
||||
import crypto from 'crypto';
|
||||
|
||||
export interface SpintaxChoice {
|
||||
path: string;
|
||||
chosen: string;
|
||||
allOptions: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves spintax syntax {A|B|C} to a single choice
|
||||
* Tracks which choices were made for uniqueness
|
||||
*/
|
||||
export class SpintaxResolver {
|
||||
private choices: SpintaxChoice[] = [];
|
||||
private seed: string;
|
||||
|
||||
constructor(seed?: string) {
|
||||
this.seed = seed || crypto.randomBytes(16).toString('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve all spintax in text
|
||||
*/
|
||||
resolve(text: string): string {
|
||||
let resolved = text;
|
||||
let iteration = 0;
|
||||
|
||||
// Handle nested spintax with multiple passes
|
||||
while (resolved.includes('{') && resolved.includes('}') && iteration < 10) {
|
||||
resolved = this.resolvePass(resolved, iteration);
|
||||
iteration++;
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
private resolvePass(text: string, iteration: number): string {
|
||||
const regex = /\{([^{}]+)\}/g;
|
||||
let result = text;
|
||||
let match;
|
||||
let offset = 0;
|
||||
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
const fullMatch = match[0];
|
||||
const options = match[1].split('|');
|
||||
|
||||
// Deterministic choice based on seed + position
|
||||
const choiceIndex = this.getChoiceIndex(match.index + iteration, options.length);
|
||||
const chosen = options[choiceIndex].trim();
|
||||
|
||||
// Track the choice
|
||||
this.choices.push({
|
||||
path: `pos_${match.index}_iter_${iteration}`,
|
||||
chosen,
|
||||
allOptions: options
|
||||
});
|
||||
|
||||
// Replace in result
|
||||
const beforeMatch = result.substring(0, match.index + offset);
|
||||
const afterMatch = result.substring(match.index + offset + fullMatch.length);
|
||||
result = beforeMatch + chosen + afterMatch;
|
||||
|
||||
offset += chosen.length - fullMatch.length;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private getChoiceIndex(position: number, optionsCount: number): number {
|
||||
const hash = crypto.createHash('sha256')
|
||||
.update(`${this.seed}_${position}`)
|
||||
.digest('hex');
|
||||
return parseInt(hash.substring(0, 8), 16) % optionsCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all choices made during resolution
|
||||
*/
|
||||
getChoices(): SpintaxChoice[] {
|
||||
return this.choices;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate hash of choices for uniqueness checking
|
||||
*/
|
||||
getChoicesHash(): string {
|
||||
const choiceString = this.choices
|
||||
.map(c => c.chosen)
|
||||
.join('::');
|
||||
return crypto.createHash('sha256')
|
||||
.update(choiceString)
|
||||
.digest('hex')
|
||||
.substring(0, 32);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset for new resolution
|
||||
*/
|
||||
reset(newSeed?: string) {
|
||||
this.choices = [];
|
||||
if (newSeed) this.seed = newSeed;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand variables like {{CITY}} in text
|
||||
*/
|
||||
export function expandVariables(text: string, variables: Record<string, string>): string {
|
||||
let result = text;
|
||||
|
||||
for (const [key, value] of Object.entries(variables)) {
|
||||
const regex = new RegExp(`\\{\\{${key}\\}\\}`, 'g');
|
||||
result = result.replace(regex, value);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate cartesian product of pipe-separated values
|
||||
* Example: { CITY: "A|B", STATE: "X|Y" } => 4 combinations
|
||||
*/
|
||||
export function generateCartesianProduct(
|
||||
variables: Record<string, string>
|
||||
): Array<Record<string, string>> {
|
||||
const keys = Object.keys(variables);
|
||||
const values = keys.map(key => variables[key].split('|').map(v => v.trim()));
|
||||
|
||||
function* cartesian(arrays: string[][]): Generator<string[]> {
|
||||
if (arrays.length === 0) {
|
||||
yield [];
|
||||
return;
|
||||
}
|
||||
|
||||
const [first, ...rest] = arrays;
|
||||
for (const value of first) {
|
||||
for (const combo of cartesian(rest)) {
|
||||
yield [value, ...combo];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const results: Array<Record<string, string>> = [];
|
||||
for (const combo of cartesian(values)) {
|
||||
const obj: Record<string, string> = {};
|
||||
keys.forEach((key, i) => {
|
||||
obj[key] = combo[i];
|
||||
});
|
||||
results.push(obj);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
122
src/pages/api/god/campaigns/create.ts
Normal file
122
src/pages/api/god/campaigns/create.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
// API Endpoint: POST /api/god/campaigns/create
|
||||
import type { APIRoute } from 'astro';
|
||||
import { pool } from '../../../../lib/db/db';
|
||||
import crypto from 'crypto';
|
||||
|
||||
interface CampaignBlueprint {
|
||||
asset_name: string;
|
||||
deployment_target: string;
|
||||
variables: Record<string, string>;
|
||||
content: {
|
||||
url_path: string;
|
||||
meta_description: string;
|
||||
body: Array<{
|
||||
block_type: string;
|
||||
content: string;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
try {
|
||||
// Auth check
|
||||
const godToken = request.headers.get('X-God-Token');
|
||||
if (!godToken || godToken !== import.meta.env.GOD_TOKEN) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { name, blueprint } = body as { name?: string; blueprint: CampaignBlueprint };
|
||||
|
||||
if (!blueprint || !blueprint.content || !blueprint.variables) {
|
||||
return new Response(JSON.stringify({ error: 'Invalid blueprint structure' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
// Get admin site ID
|
||||
const siteResult = await client.query(
|
||||
`SELECT id FROM sites WHERE domain = 'spark.jumpstartscaling.com' LIMIT 1`
|
||||
);
|
||||
|
||||
if (siteResult.rows.length === 0) {
|
||||
throw new Error('Admin site not found');
|
||||
}
|
||||
|
||||
const siteId = siteResult.rows[0].id;
|
||||
|
||||
// Insert campaign
|
||||
const campaignName = name || blueprint.asset_name;
|
||||
const campaignResult = await client.query(
|
||||
`INSERT INTO campaign_masters (site_id, name, blueprint_json, status)
|
||||
VALUES ($1, $2, $3, 'pending')
|
||||
RETURNING id`,
|
||||
[siteId, campaignName, JSON.stringify(blueprint)]
|
||||
);
|
||||
|
||||
const campaignId = campaignResult.rows[0].id;
|
||||
|
||||
// Insert content fragments (blocks)
|
||||
for (const block of blueprint.content.body) {
|
||||
const contentHash = crypto.createHash('sha256')
|
||||
.update(block.content)
|
||||
.digest('hex')
|
||||
.substring(0, 32);
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO content_fragments (
|
||||
site_id, campaign_id, block_type, blueprint_name, content, content_hash, use_count
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, 0)
|
||||
ON CONFLICT (content_hash) DO UPDATE SET
|
||||
campaign_id = EXCLUDED.campaign_id,
|
||||
use_count = content_fragments.use_count
|
||||
`,
|
||||
[
|
||||
siteId,
|
||||
campaignId,
|
||||
block.block_type.replace(/ \(\d+\)$/, ''),
|
||||
campaignName,
|
||||
block.content,
|
||||
contentHash
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
await client.query('COMMIT');
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
success: true,
|
||||
campaignId,
|
||||
message: `Campaign "${campaignName}" created successfully. Use /launch/${campaignId} to generate content.`
|
||||
}), {
|
||||
status: 201,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK');
|
||||
throw err;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('Campaign creation error:', error);
|
||||
return new Response(JSON.stringify({
|
||||
error: 'Campaign creation failed',
|
||||
details: error.message
|
||||
}), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
};
|
||||
71
src/pages/api/god/campaigns/launch/[id].ts
Normal file
71
src/pages/api/god/campaigns/launch/[id].ts
Normal file
@@ -0,0 +1,71 @@
|
||||
// API Endpoint: POST /api/god/campaigns/launch/[id]
|
||||
import type { APIRoute } from 'astro';
|
||||
import { pool } from '../../../../../lib/db/db';
|
||||
import { batchQueue } from '../../../../../lib/queue/config';
|
||||
|
||||
export const POST: APIRoute = async ({ params, request }) => {
|
||||
try {
|
||||
const godToken = request.headers.get('X-God-Token');
|
||||
if (!godToken || godToken !== import.meta.env.GOD_TOKEN) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
const { id } = params;
|
||||
if (!id) {
|
||||
return new Response(JSON.stringify({ error: 'Campaign ID required' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch campaign
|
||||
const result = await pool.query(
|
||||
`SELECT id, name, blueprint_json FROM campaign_masters WHERE id = $1`,
|
||||
[id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return new Response(JSON.stringify({ error: 'Campaign not found' }), {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
const campaign = result.rows[0];
|
||||
|
||||
// Queue the job
|
||||
await batchQueue.add('generate_campaign_content', {
|
||||
campaignId: id,
|
||||
campaignName: campaign.name
|
||||
});
|
||||
|
||||
// Update status
|
||||
await pool.query(
|
||||
`UPDATE campaign_masters SET status = 'processing', updated_at = NOW() WHERE id = $1`,
|
||||
[id]
|
||||
);
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
success: true,
|
||||
campaignId: id,
|
||||
message: 'Campaign queued for generation',
|
||||
status: 'processing'
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('Campaign launch error:', error);
|
||||
return new Response(JSON.stringify({
|
||||
error: 'Campaign launch failed',
|
||||
details: error.message
|
||||
}), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
};
|
||||
75
src/pages/api/god/campaigns/status/[id].ts
Normal file
75
src/pages/api/god/campaigns/status/[id].ts
Normal file
@@ -0,0 +1,75 @@
|
||||
// API Endpoint: GET /api/god/campaigns/status/[id]
|
||||
import type { APIRoute } from 'astro';
|
||||
import { pool } from '../../../../../lib/db/db';
|
||||
|
||||
export const GET: APIRoute = async ({ params, request }) => {
|
||||
try {
|
||||
const godToken = request.headers.get('X-God-Token');
|
||||
if (!godToken || godToken !== import.meta.env.GOD_TOKEN) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
const { id } = params;
|
||||
|
||||
// Get campaign details
|
||||
const campaignResult = await pool.query(
|
||||
`SELECT id, name, status, created_at, updated_at FROM campaign_masters WHERE id = $1`,
|
||||
[id]
|
||||
);
|
||||
|
||||
if (campaignResult.rows.length === 0) {
|
||||
return new Response(JSON.stringify({ error: 'Campaign not found' }), {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
const campaign = campaignResult.rows[0];
|
||||
|
||||
// Count generated posts
|
||||
const postsResult = await pool.query(
|
||||
`SELECT COUNT(*) as count FROM variation_registry WHERE campaign_id = $1`,
|
||||
[id]
|
||||
);
|
||||
|
||||
const postsCreated = parseInt(postsResult.rows[0].count);
|
||||
|
||||
// Get block usage stats
|
||||
const blockStats = await pool.query(
|
||||
`SELECT block_type, total_uses
|
||||
FROM block_usage_stats
|
||||
WHERE content_fragment_id IN (
|
||||
SELECT id FROM content_fragments WHERE campaign_id = $1
|
||||
)
|
||||
ORDER BY total_uses DESC
|
||||
LIMIT 10`,
|
||||
[id]
|
||||
);
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
campaignId: campaign.id,
|
||||
name: campaign.name,
|
||||
status: campaign.status,
|
||||
postsCreated,
|
||||
createdAt: campaign.created_at,
|
||||
updatedAt: campaign.updated_at,
|
||||
blockUsage: blockStats.rows
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('Status check error:', error);
|
||||
return new Response(JSON.stringify({
|
||||
error: 'Status check failed',
|
||||
details: error.message
|
||||
}), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
};
|
||||
251
src/workers/contentGenerator.ts
Normal file
251
src/workers/contentGenerator.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
// BullMQ Worker: Content Generator
|
||||
import { Worker } from 'bullmq';
|
||||
import { pool } from '../lib/db/db';
|
||||
import { redisConnection } from '../lib/queue/config';
|
||||
import { SpintaxResolver, expandVariables, generateCartesianProduct } from '../lib/spintax/resolver';
|
||||
import crypto from 'crypto';
|
||||
|
||||
interface GenerateCampaignJob {
|
||||
campaignId: string;
|
||||
campaignName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main content generation worker
|
||||
* Processes campaigns and generates full articles
|
||||
*/
|
||||
export const contentGeneratorWorker = new Worker(
|
||||
'generate_campaign_content',
|
||||
async (job) => {
|
||||
const { campaignId, campaignName } = job.data as GenerateCampaignJob;
|
||||
|
||||
console.log(`\n🔱 Processing campaign: ${campaignName} (${campaignId})`);
|
||||
|
||||
try {
|
||||
// 1. Fetch campaign blueprint
|
||||
const campaignResult = await pool.query(
|
||||
`SELECT id, blueprint_json, site_id FROM campaign_masters WHERE id = $1`,
|
||||
[campaignId]
|
||||
);
|
||||
|
||||
if (campaignResult.rows.length === 0) {
|
||||
throw new Error(`Campaign ${campaignId} not found`);
|
||||
}
|
||||
|
||||
const campaign = campaignResult.rows[0];
|
||||
const blueprint = campaign.blueprint_json;
|
||||
const siteId = campaign.site_id;
|
||||
|
||||
console.log(`📋 Blueprint loaded: ${blueprint.asset_name}`);
|
||||
console.log(`🔢 Variables: ${Object.keys(blueprint.variables).join(', ')}`);
|
||||
|
||||
// 2. Generate cartesian product of all variables
|
||||
const combinations = generateCartesianProduct(blueprint.variables);
|
||||
console.log(`🎯 Generated ${combinations.length} unique combinations`);
|
||||
|
||||
let created = 0;
|
||||
let skipped = 0;
|
||||
|
||||
// 3. For each combination, generate article
|
||||
for (const vars of combinations) {
|
||||
try {
|
||||
const articleHtml = await generateArticle(blueprint, vars, campaignId, siteId);
|
||||
|
||||
if (articleHtml) {
|
||||
created++;
|
||||
console.log(`✅ Created: ${vars.CITY}, ${vars.STATE} (${created}/${combinations.length})`);
|
||||
} else {
|
||||
skipped++;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`❌ Failed for ${vars.CITY}:`, err);
|
||||
skipped++;
|
||||
}
|
||||
|
||||
// Update progress
|
||||
await job.updateProgress((created + skipped) / combinations.length * 100);
|
||||
}
|
||||
|
||||
// 4. Mark campaign complete
|
||||
await pool.query(
|
||||
`UPDATE campaign_masters SET status = 'completed', updated_at = NOW() WHERE id = $1`,
|
||||
[campaignId]
|
||||
);
|
||||
|
||||
console.log(`\n🎉 Campaign complete! Created: ${created}, Skipped: ${skipped}\n`);
|
||||
|
||||
return { created, skipped, total: combinations.length };
|
||||
|
||||
} catch (error) {
|
||||
console.error(`💥 Campaign processing failed:`, error);
|
||||
|
||||
await pool.query(
|
||||
`UPDATE campaign_masters SET status = 'failed', updated_at = NOW() WHERE id = $1`,
|
||||
[campaignId]
|
||||
);
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
{
|
||||
connection: redisConnection,
|
||||
concurrency: 2
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Generate a single article from blueprint + variables
|
||||
*/
|
||||
async function generateArticle(
|
||||
blueprint: any,
|
||||
vars: Record<string, string>,
|
||||
campaignId: string,
|
||||
siteId: string
|
||||
): Promise<string | null> {
|
||||
|
||||
// Create unique seed for this variation
|
||||
const seed = JSON.stringify(vars);
|
||||
const resolver = new SpintaxResolver(seed);
|
||||
|
||||
// 1. Expand variable placeholders
|
||||
let fullHtml = '';
|
||||
const blocks = blueprint.content.body;
|
||||
|
||||
for (const block of blocks) {
|
||||
let blockContent = block.content;
|
||||
|
||||
// Replace {{VARIABLES}}
|
||||
blockContent = expandVariables(blockContent, vars);
|
||||
|
||||
// Resolve spintax {A|B|C}
|
||||
blockContent = resolver.resolve(blockContent);
|
||||
|
||||
fullHtml += blockContent + '\n\n';
|
||||
}
|
||||
|
||||
// 2. Generate variation hash for uniqueness check
|
||||
const variationHash = resolver.getChoicesHash();
|
||||
const varsHash = crypto.createHash('sha256')
|
||||
.update(JSON.stringify(vars))
|
||||
.digest('hex')
|
||||
.substring(0, 32);
|
||||
const combinedHash = crypto.createHash('sha256')
|
||||
.update(variationHash + varsHash)
|
||||
.digest('hex')
|
||||
.substring(0, 64);
|
||||
|
||||
// 3. Check if this variation already exists
|
||||
const existingCheck = await pool.query(
|
||||
`SELECT id FROM variation_registry WHERE variation_hash = $1`,
|
||||
[combinedHash]
|
||||
);
|
||||
|
||||
if (existingCheck.rows.length > 0) {
|
||||
console.log(`⏭️ Skipping duplicate: ${vars.CITY}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// 4. Generate title and slug
|
||||
const title = expandVariables(resolver.resolve(blueprint.asset_name), vars);
|
||||
const slug = generateSlug(vars);
|
||||
|
||||
// 5. Generate meta description
|
||||
const metaDesc = expandVariables(
|
||||
resolver.resolve(blueprint.meta_description),
|
||||
vars
|
||||
);
|
||||
|
||||
// 6. Insert post
|
||||
const postResult = await pool.query(
|
||||
`INSERT INTO posts (
|
||||
site_id, title, slug, content, excerpt, status,
|
||||
published_at, created_at
|
||||
) VALUES ($1, $2, $3, $4, $5, 'published', NOW(), NOW())
|
||||
RETURNING id`,
|
||||
[
|
||||
siteId,
|
||||
title,
|
||||
slug,
|
||||
fullHtml,
|
||||
metaDesc.substring(0, 300)
|
||||
]
|
||||
);
|
||||
|
||||
const postId = postResult.rows[0].id;
|
||||
|
||||
// 7. Record the variation
|
||||
await pool.query(
|
||||
`INSERT INTO variation_registry (
|
||||
campaign_id, variation_hash, resolved_variables, spintax_choices, post_id
|
||||
) VALUES ($1, $2, $3, $4, $5)`,
|
||||
[
|
||||
campaignId,
|
||||
combinedHash,
|
||||
JSON.stringify(vars),
|
||||
JSON.stringify(resolver.getChoices()),
|
||||
postId
|
||||
]
|
||||
);
|
||||
|
||||
// 8. Update block usage stats
|
||||
await updateBlockUsageStats(blocks, resolver.getChoices());
|
||||
|
||||
return fullHtml;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update usage statistics for blocks and spintax choices
|
||||
*/
|
||||
async function updateBlockUsageStats(blocks: any[], choices: any[]) {
|
||||
// Track which blocks were used
|
||||
for (const block of blocks) {
|
||||
await pool.query(
|
||||
`INSERT INTO block_usage_stats (content_fragment_id, block_type, total_uses, last_used_at)
|
||||
SELECT id, $1, 1, NOW()
|
||||
FROM content_fragments
|
||||
WHERE block_type = $1
|
||||
ON CONFLICT (content_fragment_id) DO UPDATE SET
|
||||
total_uses = block_usage_stats.total_uses + 1,
|
||||
last_used_at = NOW()`,
|
||||
[block.block_type.replace(/ \(\d+\)$/, '')]
|
||||
);
|
||||
}
|
||||
|
||||
// Track spintax variation usage
|
||||
for (const choice of choices) {
|
||||
await pool.query(
|
||||
`INSERT INTO spintax_variation_stats (
|
||||
content_fragment_id, variation_path, variation_text, use_count, last_used_at
|
||||
)
|
||||
SELECT cf.id, $1, $2, 1, NOW()
|
||||
FROM content_fragments cf
|
||||
LIMIT 1
|
||||
ON CONFLICT DO NOTHING`,
|
||||
[choice.path, choice.chosen]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate URL-safe slug from variables
|
||||
*/
|
||||
function generateSlug(vars: Record<string, string>): string {
|
||||
const city = (vars.CITY || 'city').toLowerCase().replace(/[^a-z0-9]+/g, '-');
|
||||
const state = (vars.STATE || 'state').toLowerCase().replace(/[^a-z0-9]+/g, '-');
|
||||
return `${city}-${state}-${Date.now()}`;
|
||||
}
|
||||
|
||||
// Worker event handlers
|
||||
contentGeneratorWorker.on('completed', (job) => {
|
||||
console.log(`✅ Job ${job.id} completed:`, job.returnvalue);
|
||||
});
|
||||
|
||||
contentGeneratorWorker.on('failed', (job, err) => {
|
||||
console.error(`❌ Job ${job?.id} failed:`, err);
|
||||
});
|
||||
|
||||
contentGeneratorWorker.on('error', (err) => {
|
||||
console.error('💥 Worker error:', err);
|
||||
});
|
||||
|
||||
console.log('🚀 Content Generator Worker started');
|
||||
Reference in New Issue
Block a user