God Mode Valhalla: Initial Standalone Commit
This commit is contained in:
9
src/pages/admin/content-factory.astro
Normal file
9
src/pages/admin/content-factory.astro
Normal file
@@ -0,0 +1,9 @@
|
||||
---
|
||||
import Layout from '@/layouts/AdminLayout.astro';
|
||||
import ContentFactoryDashboard from '@/components/admin/content/ContentFactoryDashboard';
|
||||
---
|
||||
<Layout title="Factory Command Center">
|
||||
<div class="p-8">
|
||||
<ContentFactoryDashboard client:load />
|
||||
</div>
|
||||
</Layout>
|
||||
180
src/pages/admin/db-console.astro
Normal file
180
src/pages/admin/db-console.astro
Normal file
@@ -0,0 +1,180 @@
|
||||
---
|
||||
// src/pages/admin/db-console.astro
|
||||
import Layout from '@/layouts/BaseLayout.astro'; // Assuming BaseLayout exists, or check existing layout
|
||||
import { MECHANIC_OPS } from '@/lib/db/mechanic';
|
||||
|
||||
// Server-side Logic (runs on build or request in SSR)
|
||||
let health;
|
||||
try {
|
||||
health = await MECHANIC_OPS.getHealth();
|
||||
} catch (e) {
|
||||
console.error('Failed to get health:', e);
|
||||
health = {
|
||||
size: 'Error',
|
||||
connections: [],
|
||||
cache: { ratio: 0 }
|
||||
};
|
||||
}
|
||||
|
||||
const activeConnections = health.connections.find((c: any) => c.state === 'active')?.active || 0;
|
||||
const idleConnections = health.connections.find((c: any) => c.state === 'idle')?.active || 0;
|
||||
const token = import.meta.env.GOD_MODE_TOKEN || '';
|
||||
---
|
||||
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>DB Command Center - Valhalla</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
zinc: { 800: '#27272a', 900: '#18181b' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body class="bg-zinc-900 text-white min-h-screen">
|
||||
<div class="p-8 space-y-8 max-w-7xl mx-auto">
|
||||
|
||||
<header class="flex justify-between items-center mb-8">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-yellow-400 to-red-500">
|
||||
Valhalla DB Command Center
|
||||
</h1>
|
||||
<p class="text-gray-400">Direct PostgreSQL Interface</p>
|
||||
</div>
|
||||
<a href="/" class="text-gray-400 hover:text-white">Back to Dashboard</a>
|
||||
</header>
|
||||
|
||||
<!-- Stats Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div class="bg-zinc-800 p-6 rounded-lg border-l-4 border-green-500 shadow-lg">
|
||||
<h3 class="text-gray-400 text-sm uppercase font-semibold">DB Size</h3>
|
||||
<p class="text-4xl font-bold mt-2">{health.size}</p>
|
||||
</div>
|
||||
<div class="bg-zinc-800 p-6 rounded-lg border-l-4 border-blue-500 shadow-lg">
|
||||
<h3 class="text-gray-400 text-sm uppercase font-semibold">Connections</h3>
|
||||
<div class="flex items-end gap-2 mt-2">
|
||||
<p class="text-4xl font-bold">{activeConnections}</p>
|
||||
<span class="text-xl text-gray-500">active</span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 mt-1">{idleConnections} idle</p>
|
||||
</div>
|
||||
<div class="bg-zinc-800 p-6 rounded-lg border-l-4 border-purple-500 shadow-lg">
|
||||
<h3 class="text-gray-400 text-sm uppercase font-semibold">Cache Efficiency</h3>
|
||||
<p class="text-4xl font-bold mt-2">{Math.round(health.cache.ratio * 100)}%</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
|
||||
<!-- Emergency Controls -->
|
||||
<div class="bg-zinc-800/50 border border-zinc-700 p-6 rounded-xl">
|
||||
<h2 class="text-xl mb-6 text-red-400 flex items-center gap-2">
|
||||
<span>🚨</span> Emergency Fixes
|
||||
</h2>
|
||||
<div class="space-y-4">
|
||||
<button onclick="runOp('vacuum')" class="w-full group relative overflow-hidden bg-yellow-600 hover:bg-yellow-500 transition-all p-4 rounded-lg font-bold text-left flex justify-between items-center">
|
||||
<div>
|
||||
<div class="text-white">Run Vacuum</div>
|
||||
<div class="text-xs text-yellow-200 opacity-70">Optimize dead rows</div>
|
||||
</div>
|
||||
<span class="text-2xl opacity-50 group-hover:opacity-100">🧹</span>
|
||||
</button>
|
||||
|
||||
<button onclick="runOp('reindex')" class="w-full group relative overflow-hidden bg-orange-600 hover:bg-orange-500 transition-all p-4 rounded-lg font-bold text-left flex justify-between items-center">
|
||||
<div>
|
||||
<div class="text-white">Reindex Database</div>
|
||||
<div class="text-xs text-orange-200 opacity-70">Fix corrupted indexes</div>
|
||||
</div>
|
||||
<span class="text-2xl opacity-50 group-hover:opacity-100">📑</span>
|
||||
</button>
|
||||
|
||||
<button onclick="runOp('kill_locks')" class="w-full group relative overflow-hidden bg-red-700 hover:bg-red-600 transition-all p-4 rounded-lg font-bold text-left flex justify-between items-center">
|
||||
<div>
|
||||
<div class="text-white">Kill Stuck Queries</div>
|
||||
<div class="text-xs text-red-200 opacity-70">Terminate > 5min processes</div>
|
||||
</div>
|
||||
<span class="text-2xl opacity-50 group-hover:opacity-100">💀</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Throttles -->
|
||||
<div class="lg:col-span-2 bg-zinc-800/50 border border-zinc-700 p-6 rounded-xl">
|
||||
<h2 class="text-xl mb-6 text-blue-400 flex items-center gap-2">
|
||||
<span>🎚️</span> Factory Throttles
|
||||
</h2>
|
||||
|
||||
<form id="throttleForm" class="space-y-8">
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between">
|
||||
<label class="font-medium text-gray-300">Batch Size (Items per Chunk)</label>
|
||||
<span class="text-blue-400 font-mono" id="batchOutput">100</span>
|
||||
</div>
|
||||
<input type="range" min="10" max="1000" value="100" step="10"
|
||||
class="w-full h-2 bg-zinc-700 rounded-lg appearance-none cursor-pointer accent-blue-500"
|
||||
oninput="document.getElementById('batchOutput').textContent = this.value + ' rows'">
|
||||
<p class="text-sm text-gray-500">Higher values use more memory but process faster.</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between">
|
||||
<label class="font-medium text-gray-300">Concurrency (Parallel Workers)</label>
|
||||
<span class="text-blue-400 font-mono" id="concurrencyOutput">5</span>
|
||||
</div>
|
||||
<input type="range" min="1" max="50" value="5" step="1"
|
||||
class="w-full h-2 bg-zinc-700 rounded-lg appearance-none cursor-pointer accent-blue-500"
|
||||
oninput="document.getElementById('concurrencyOutput').textContent = this.value + ' threads'">
|
||||
<p class="text-sm text-gray-500">Higher values allow more simultaneous DB connections.</p>
|
||||
</div>
|
||||
|
||||
<div class="pt-4 border-t border-zinc-700">
|
||||
<button type="button" class="bg-blue-600 hover:bg-blue-500 text-white px-8 py-3 rounded-lg font-bold transition-colors">
|
||||
Update Runtime Engine
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hidden Token for Client Script -->
|
||||
<div id="god-token" data-token={token} style="display:none;"></div>
|
||||
|
||||
<script is:inline>
|
||||
async function runOp(operation) {
|
||||
const token = document.getElementById('god-token')?.dataset.token;
|
||||
if(!confirm(`⚠️ Are you sure you want to RUN ${operation.toUpperCase()}? This may affect database performance.`)) return;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/god/db-ops', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({ operation })
|
||||
});
|
||||
|
||||
const text = await res.text();
|
||||
if (res.ok) {
|
||||
alert('✅ Success: ' + text);
|
||||
location.reload();
|
||||
} else {
|
||||
alert('❌ Error: ' + text);
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Connection Error: ' + err.message);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
20
src/pages/admin/posts/[id].astro
Normal file
20
src/pages/admin/posts/[id].astro
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
import Layout from '@/layouts/AdminLayout.astro';
|
||||
import PostEditor from '@/components/admin/posts/PostEditor';
|
||||
|
||||
const { id } = Astro.params;
|
||||
---
|
||||
|
||||
<Layout title="Edit Post">
|
||||
<div class="p-6">
|
||||
<div class="mb-6">
|
||||
<a href="/admin/posts" class="text-slate-400 hover:text-white flex items-center gap-2 mb-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" /></svg>
|
||||
Back to Posts
|
||||
</a>
|
||||
<h1 class="text-3xl font-bold text-slate-100">Edit Post</h1>
|
||||
</div>
|
||||
|
||||
<PostEditor id={id} client:only="react" />
|
||||
</div>
|
||||
</Layout>
|
||||
21
src/pages/admin/posts/index.astro
Normal file
21
src/pages/admin/posts/index.astro
Normal file
@@ -0,0 +1,21 @@
|
||||
---
|
||||
import Layout from '@/layouts/AdminLayout.astro';
|
||||
import PostList from '@/components/admin/posts/PostList';
|
||||
---
|
||||
|
||||
<Layout title="Post Management">
|
||||
<div class="p-6 space-y-6">
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-slate-100">Posts</h1>
|
||||
<p class="text-slate-400">Manage blog posts and articles.</p>
|
||||
</div>
|
||||
<a href="/admin/posts/new" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg font-medium transition-colors flex items-center gap-2">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" /></svg>
|
||||
New Post
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<PostList client:load />
|
||||
</div>
|
||||
</Layout>
|
||||
20
src/pages/admin/sites/[id].astro
Normal file
20
src/pages/admin/sites/[id].astro
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
import Layout from '@/layouts/AdminLayout.astro';
|
||||
import SiteEditor from '@/components/admin/sites/SiteEditor';
|
||||
|
||||
const { id } = Astro.params;
|
||||
---
|
||||
|
||||
<Layout title="Edit Site">
|
||||
<div class="p-6">
|
||||
<div class="mb-6">
|
||||
<a href="/admin/sites" class="text-slate-400 hover:text-white flex items-center gap-2 mb-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" /></svg>
|
||||
Back to Sites
|
||||
</a>
|
||||
<h1 class="text-3xl font-bold text-slate-100">Configure Site</h1>
|
||||
</div>
|
||||
|
||||
<SiteEditor id={id} client:only="react" />
|
||||
</div>
|
||||
</Layout>
|
||||
28
src/pages/admin/sites/[siteId]/index.astro
Normal file
28
src/pages/admin/sites/[siteId]/index.astro
Normal file
@@ -0,0 +1,28 @@
|
||||
---
|
||||
import Layout from '@/layouts/AdminLayout.astro';
|
||||
import SiteDashboard from '@/components/admin/sites/SiteDashboard';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
|
||||
const { siteId } = Astro.params;
|
||||
---
|
||||
|
||||
<Layout title="Manage Site | Spark Launchpad">
|
||||
<div class="h-screen flex flex-col">
|
||||
<div class="border-b border-zinc-800 bg-zinc-950 p-4 flex items-center gap-4">
|
||||
<a href="/admin/sites">
|
||||
<Button variant="ghost" size="icon" class="text-zinc-400 hover:text-white">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</a>
|
||||
<div>
|
||||
<h1 class="text-xl font-bold text-white tracking-tight">Site Management</h1>
|
||||
<p class="text-xs text-zinc-500 font-mono">ID: {siteId}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-auto p-8">
|
||||
<SiteDashboard client:only="react" siteId={siteId!} />
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
10
src/pages/admin/sites/editor/[pageId].astro
Normal file
10
src/pages/admin/sites/editor/[pageId].astro
Normal file
@@ -0,0 +1,10 @@
|
||||
---
|
||||
import Layout from '@/layouts/AdminLayout.astro';
|
||||
import PageEditor from '@/components/admin/sites/PageEditor';
|
||||
|
||||
const { pageId } = Astro.params;
|
||||
---
|
||||
|
||||
<Layout title="Page Editor | Spark Launchpad" hideSidebar={true}>
|
||||
<PageEditor client:only="react" pageId={pageId!} onBack={() => history.back()} />
|
||||
</Layout>
|
||||
18
src/pages/admin/sites/import.astro
Normal file
18
src/pages/admin/sites/import.astro
Normal file
@@ -0,0 +1,18 @@
|
||||
---
|
||||
import Layout from '@/layouts/AdminLayout.astro';
|
||||
import WPImporter from '@/components/admin/wordpress/WPImporter';
|
||||
---
|
||||
|
||||
<Layout title="Import WordPress Site">
|
||||
<div class="p-6 space-y-6">
|
||||
<div class="flex items-center gap-2 text-slate-400 text-sm">
|
||||
<a href="/admin/sites" class="hover:text-blue-400">Sites</a>
|
||||
<span>/</span>
|
||||
<span class="text-white">Import Wizard</span>
|
||||
</div>
|
||||
|
||||
<h1 class="text-3xl font-bold text-slate-100">Content Import & Refactor</h1>
|
||||
|
||||
<WPImporter client:load />
|
||||
</div>
|
||||
</Layout>
|
||||
22
src/pages/admin/sites/index.astro
Normal file
22
src/pages/admin/sites/index.astro
Normal file
@@ -0,0 +1,22 @@
|
||||
---
|
||||
import Layout from '@/layouts/AdminLayout.astro';
|
||||
import SitesManager from '@/components/admin/sites/SitesManager';
|
||||
import { CoreProvider } from '@/components/providers/CoreProviders';
|
||||
---
|
||||
|
||||
<Layout title="Sites | Spark Launchpad">
|
||||
<div class="p-8 space-y-6">
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-white tracking-tight">🚀 Launchpad</h1>
|
||||
<p class="text-zinc-400 mt-2 max-w-2xl">
|
||||
Deploy and manage multiple websites from a single dashboard.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CoreProvider client:load>
|
||||
<SitesManager client:only="react" />
|
||||
</CoreProvider>
|
||||
</div>
|
||||
</Layout>
|
||||
10
src/pages/admin/sites/jumpstart.astro
Normal file
10
src/pages/admin/sites/jumpstart.astro
Normal file
@@ -0,0 +1,10 @@
|
||||
---
|
||||
import Layout from '@/layouts/AdminLayout.astro';
|
||||
import JumpstartWizard from '@/components/admin/jumpstart/JumpstartWizard';
|
||||
---
|
||||
|
||||
<Layout title="Guided Jumpstart Test">
|
||||
<div class="p-8">
|
||||
<JumpstartWizard client:load />
|
||||
</div>
|
||||
</Layout>
|
||||
425
src/pages/api/god/[...action].ts
Normal file
425
src/pages/api/god/[...action].ts
Normal file
@@ -0,0 +1,425 @@
|
||||
/**
|
||||
* 🔱 GOD MODE BACKDOOR - Direct PostgreSQL Access
|
||||
*
|
||||
* This endpoint bypasses Directus entirely and connects directly to PostgreSQL.
|
||||
* Works even when Directus is crashed/frozen.
|
||||
*
|
||||
* Endpoints:
|
||||
* GET /api/god/health - Full system health check
|
||||
* GET /api/god/services - Quick service status (all 4 containers)
|
||||
* GET /api/god/db-status - Database connection test
|
||||
* POST /api/god/sql - Execute raw SQL (dangerous!)
|
||||
* GET /api/god/tables - List all tables
|
||||
* GET /api/god/logs - Recent work_log entries
|
||||
*/
|
||||
|
||||
import type { APIRoute } from 'astro';
|
||||
import { pool } from '@/lib/db';
|
||||
import Redis from 'ioredis';
|
||||
|
||||
// Direct PostgreSQL connection (Strict Connection String)
|
||||
// God Mode requires Superuser access (postgres) to effectively diagnose and fix the DB.
|
||||
// Pool is now shared from @/lib/dbr: process.env.DB_USER || 'postgres',
|
||||
// password: process.env.DB_PASSWORD || 'Idk@2026lolhappyha232',
|
||||
// max: 3,
|
||||
// idleTimeoutMillis: 30000,
|
||||
// connectionTimeoutMillis: 5000,
|
||||
// });
|
||||
|
||||
// Directus URL
|
||||
const DIRECTUS_URL = process.env.PUBLIC_DIRECTUS_URL || 'http://directus:8055';
|
||||
|
||||
// God Mode Token validation
|
||||
function validateGodToken(request: Request): boolean {
|
||||
const token = request.headers.get('X-God-Token') ||
|
||||
request.headers.get('Authorization')?.replace('Bearer ', '') ||
|
||||
new URL(request.url).searchParams.get('token');
|
||||
|
||||
const godToken = process.env.GOD_MODE_TOKEN || import.meta.env.GOD_MODE_TOKEN;
|
||||
|
||||
if (!godToken) {
|
||||
console.warn('⚠️ GOD_MODE_TOKEN not set - backdoor is open!');
|
||||
return true; // Allow access if no token configured (dev mode)
|
||||
}
|
||||
|
||||
return token === godToken;
|
||||
}
|
||||
|
||||
// JSON response helper
|
||||
function json(data: object, status = 200) {
|
||||
return new Response(JSON.stringify(data, null, 2), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
// GET /api/god/health - Full system health
|
||||
export const GET: APIRoute = async ({ request, url }) => {
|
||||
if (!validateGodToken(request)) {
|
||||
return json({ error: 'Unauthorized - Invalid God Mode Token' }, 401);
|
||||
}
|
||||
|
||||
const action = url.pathname.split('/').pop();
|
||||
|
||||
try {
|
||||
switch (action) {
|
||||
case 'health':
|
||||
return await getHealth();
|
||||
case 'services':
|
||||
return await getServices();
|
||||
case 'db-status':
|
||||
return await getDbStatus();
|
||||
case 'tables':
|
||||
return await getTables();
|
||||
case 'logs':
|
||||
return await getLogs();
|
||||
default:
|
||||
return json({
|
||||
message: '🔱 God Mode Backdoor Active',
|
||||
frontend: 'RUNNING ✅',
|
||||
endpoints: {
|
||||
'GET /api/god/health': 'Full system health check',
|
||||
'GET /api/god/services': 'Quick status of all 4 containers',
|
||||
'GET /api/god/db-status': 'Database connection test',
|
||||
'GET /api/god/tables': 'List all tables',
|
||||
'GET /api/god/logs': 'Recent work_log entries',
|
||||
'POST /api/god/sql': 'Execute raw SQL (body: { query: "..." })',
|
||||
},
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
return json({ error: error.message, stack: error.stack }, 500);
|
||||
}
|
||||
};
|
||||
|
||||
// POST /api/god/sql - Execute raw SQL
|
||||
export const POST: APIRoute = async ({ request, url }) => {
|
||||
if (!validateGodToken(request)) {
|
||||
return json({ error: 'Unauthorized - Invalid God Mode Token' }, 401);
|
||||
}
|
||||
|
||||
const action = url.pathname.split('/').pop();
|
||||
|
||||
if (action !== 'sql') {
|
||||
return json({ error: 'POST only supported for /api/god/sql' }, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { query } = body;
|
||||
|
||||
if (!query) {
|
||||
return json({ error: 'Missing query in request body' }, 400);
|
||||
}
|
||||
|
||||
const result = await pool.query(query);
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
command: result.command,
|
||||
rowCount: result.rowCount,
|
||||
rows: result.rows,
|
||||
fields: result.fields?.map(f => f.name)
|
||||
});
|
||||
} catch (error: any) {
|
||||
return json({ error: error.message, code: error.code }, 500);
|
||||
}
|
||||
};
|
||||
|
||||
// Quick service status check
|
||||
async function getServices() {
|
||||
const services: Record<string, any> = {
|
||||
timestamp: new Date().toISOString(),
|
||||
frontend: { status: '✅ RUNNING', note: 'You are seeing this response' }
|
||||
};
|
||||
|
||||
// Check PostgreSQL
|
||||
try {
|
||||
const start = Date.now();
|
||||
await pool.query('SELECT 1');
|
||||
services.postgresql = {
|
||||
status: '✅ RUNNING',
|
||||
latency_ms: Date.now() - start
|
||||
};
|
||||
} catch (error: any) {
|
||||
services.postgresql = {
|
||||
status: '❌ DOWN',
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
|
||||
// Check Redis
|
||||
try {
|
||||
const redis = new Redis({
|
||||
host: process.env.REDIS_HOST || 'redis',
|
||||
port: 6379,
|
||||
connectTimeout: 3000,
|
||||
maxRetriesPerRequest: 1
|
||||
});
|
||||
const start = Date.now();
|
||||
await redis.ping();
|
||||
services.redis = {
|
||||
status: '✅ RUNNING',
|
||||
latency_ms: Date.now() - start
|
||||
};
|
||||
redis.disconnect();
|
||||
} catch (error: any) {
|
||||
services.redis = {
|
||||
status: '❌ DOWN',
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
|
||||
// Check Directus
|
||||
try {
|
||||
const start = Date.now();
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 5000);
|
||||
|
||||
const response = await fetch(`${DIRECTUS_URL}/server/health`, {
|
||||
signal: controller.signal
|
||||
});
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
services.directus = {
|
||||
status: '✅ RUNNING',
|
||||
latency_ms: Date.now() - start,
|
||||
health: data.status
|
||||
};
|
||||
} else {
|
||||
services.directus = {
|
||||
status: '⚠️ UNHEALTHY',
|
||||
http_status: response.status
|
||||
};
|
||||
}
|
||||
} catch (error: any) {
|
||||
services.directus = {
|
||||
status: '❌ DOWN',
|
||||
error: error.name === 'AbortError' ? 'Timeout (5s)' : error.message
|
||||
};
|
||||
}
|
||||
|
||||
// Summary
|
||||
const allUp = services.postgresql.status.includes('✅') &&
|
||||
services.redis.status.includes('✅') &&
|
||||
services.directus.status.includes('✅');
|
||||
|
||||
services.summary = allUp ? '✅ ALL SERVICES HEALTHY' : '⚠️ SOME SERVICES DOWN';
|
||||
|
||||
return json(services);
|
||||
}
|
||||
|
||||
// Health check implementation
|
||||
async function getHealth() {
|
||||
const start = Date.now();
|
||||
|
||||
const checks: Record<string, any> = {
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime_seconds: Math.round(process.uptime()),
|
||||
memory: {
|
||||
rss_mb: Math.round(process.memoryUsage().rss / 1024 / 1024),
|
||||
heap_used_mb: Math.round(process.memoryUsage().heapUsed / 1024 / 1024),
|
||||
heap_total_mb: Math.round(process.memoryUsage().heapTotal / 1024 / 1024),
|
||||
},
|
||||
};
|
||||
|
||||
// PostgreSQL check
|
||||
try {
|
||||
const dbStart = Date.now();
|
||||
const result = await pool.query('SELECT NOW() as time, current_database() as db, current_user as user');
|
||||
checks.postgresql = {
|
||||
status: '✅ healthy',
|
||||
latency_ms: Date.now() - dbStart,
|
||||
...result.rows[0]
|
||||
};
|
||||
} catch (error: any) {
|
||||
checks.postgresql = {
|
||||
status: '❌ unhealthy',
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
|
||||
// Connection pool status
|
||||
checks.pg_pool = {
|
||||
total: pool.totalCount,
|
||||
idle: pool.idleCount,
|
||||
waiting: pool.waitingCount
|
||||
};
|
||||
|
||||
// Redis check
|
||||
try {
|
||||
const redis = new Redis({
|
||||
host: process.env.REDIS_HOST || 'redis',
|
||||
port: 6379,
|
||||
connectTimeout: 3000,
|
||||
maxRetriesPerRequest: 1
|
||||
});
|
||||
const redisStart = Date.now();
|
||||
const info = await redis.info('server');
|
||||
checks.redis = {
|
||||
status: '✅ healthy',
|
||||
latency_ms: Date.now() - redisStart,
|
||||
version: info.match(/redis_version:([^\r\n]+)/)?.[1]
|
||||
};
|
||||
redis.disconnect();
|
||||
} catch (error: any) {
|
||||
checks.redis = {
|
||||
status: '❌ unhealthy',
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
|
||||
// Directus check
|
||||
try {
|
||||
const directusStart = Date.now();
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 5000);
|
||||
|
||||
const response = await fetch(`${DIRECTUS_URL}/server/health`, {
|
||||
signal: controller.signal
|
||||
});
|
||||
clearTimeout(timeout);
|
||||
|
||||
checks.directus = {
|
||||
status: response.ok ? '✅ healthy' : '⚠️ unhealthy',
|
||||
latency_ms: Date.now() - directusStart,
|
||||
http_status: response.status
|
||||
};
|
||||
} catch (error: any) {
|
||||
checks.directus = {
|
||||
status: '❌ unreachable',
|
||||
error: error.name === 'AbortError' ? 'Timeout (5s)' : error.message
|
||||
};
|
||||
}
|
||||
|
||||
// Directus tables check
|
||||
try {
|
||||
const tables = await pool.query(`
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name LIKE 'directus_%'
|
||||
ORDER BY table_name
|
||||
`);
|
||||
checks.directus_tables = tables.rows.length;
|
||||
} catch (error: any) {
|
||||
checks.directus_tables = 0;
|
||||
}
|
||||
|
||||
// Custom tables check
|
||||
try {
|
||||
const tables = await pool.query(`
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name NOT LIKE 'directus_%'
|
||||
ORDER BY table_name
|
||||
`);
|
||||
checks.custom_tables = {
|
||||
count: tables.rows.length,
|
||||
tables: tables.rows.map(r => r.table_name)
|
||||
};
|
||||
} catch (error: any) {
|
||||
checks.custom_tables = { count: 0, error: error.message };
|
||||
}
|
||||
|
||||
checks.total_latency_ms = Date.now() - start;
|
||||
|
||||
return json(checks);
|
||||
}
|
||||
|
||||
// Database status
|
||||
async function getDbStatus() {
|
||||
try {
|
||||
const result = await pool.query(`
|
||||
SELECT
|
||||
pg_database_size(current_database()) as db_size_bytes,
|
||||
(SELECT count(*) FROM pg_stat_activity) as active_connections,
|
||||
(SELECT count(*) FROM pg_stat_activity WHERE state = 'active') as running_queries,
|
||||
(SELECT max(query_start) FROM pg_stat_activity WHERE state = 'active') as oldest_query_start,
|
||||
current_database() as database,
|
||||
version() as version
|
||||
`);
|
||||
|
||||
return json({
|
||||
status: 'connected',
|
||||
...result.rows[0],
|
||||
db_size_mb: Math.round(result.rows[0].db_size_bytes / 1024 / 1024)
|
||||
});
|
||||
} catch (error: any) {
|
||||
return json({ status: 'error', error: error.message }, 500);
|
||||
}
|
||||
}
|
||||
|
||||
// List all tables
|
||||
async function getTables() {
|
||||
try {
|
||||
const result = await pool.query(`
|
||||
SELECT
|
||||
table_name,
|
||||
(SELECT count(*) FROM information_schema.columns c WHERE c.table_name = t.table_name) as column_count
|
||||
FROM information_schema.tables t
|
||||
WHERE table_schema = 'public'
|
||||
ORDER BY table_name
|
||||
`);
|
||||
|
||||
// Get row counts for each table
|
||||
const tables = [];
|
||||
for (const row of result.rows) {
|
||||
try {
|
||||
const countResult = await pool.query(`SELECT count(*) as count FROM "${row.table_name}"`);
|
||||
tables.push({
|
||||
name: row.table_name,
|
||||
columns: row.column_count,
|
||||
rows: parseInt(countResult.rows[0].count)
|
||||
});
|
||||
} catch {
|
||||
tables.push({
|
||||
name: row.table_name,
|
||||
columns: row.column_count,
|
||||
rows: 'error'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return json({
|
||||
total: tables.length,
|
||||
tables
|
||||
});
|
||||
} catch (error: any) {
|
||||
return json({ error: error.message }, 500);
|
||||
}
|
||||
}
|
||||
|
||||
// Get recent logs
|
||||
async function getLogs() {
|
||||
try {
|
||||
// Check if work_log table exists
|
||||
const exists = await pool.query(`
|
||||
SELECT EXISTS (
|
||||
SELECT FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name = 'work_log'
|
||||
)
|
||||
`);
|
||||
|
||||
if (!exists.rows[0].exists) {
|
||||
return json({ message: 'work_log table does not exist', logs: [] });
|
||||
}
|
||||
|
||||
const result = await pool.query(`
|
||||
SELECT * FROM work_log
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT 50
|
||||
`);
|
||||
|
||||
return json({
|
||||
count: result.rows.length,
|
||||
logs: result.rows
|
||||
});
|
||||
} catch (error: any) {
|
||||
return json({ error: error.message }, 500);
|
||||
}
|
||||
}
|
||||
42
src/pages/api/god/db-ops.ts
Normal file
42
src/pages/api/god/db-ops.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { MECHANIC_OPS } from '@/lib/db/mechanic';
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
// 1. Security Check (The Token)
|
||||
const authHeader = request.headers.get('Authorization');
|
||||
const token = import.meta.env.GOD_MODE_TOKEN || process.env.GOD_MODE_TOKEN;
|
||||
|
||||
// Allow either Bearer token or exact match (for flexibility)
|
||||
if (authHeader !== `Bearer ${token}` && request.headers.get('X-God-Token') !== token) {
|
||||
return new Response('Unauthorized: You are not God.', { status: 401 });
|
||||
}
|
||||
|
||||
// 2. Parse the Command
|
||||
const body = await request.json();
|
||||
const op = body.operation; // 'vacuum', 'reindex', 'kill_locks'
|
||||
|
||||
try {
|
||||
let result = '';
|
||||
|
||||
// 3. Execute the Mechanic
|
||||
switch (op) {
|
||||
case 'vacuum':
|
||||
result = await MECHANIC_OPS.maintenance.vacuum();
|
||||
break;
|
||||
case 'reindex':
|
||||
result = await MECHANIC_OPS.maintenance.reindex();
|
||||
break;
|
||||
case 'kill_locks':
|
||||
result = await MECHANIC_OPS.maintenance.kill_locks();
|
||||
break;
|
||||
default:
|
||||
return new Response('Unknown Operation', { status: 400 });
|
||||
}
|
||||
|
||||
// 4. Return Success
|
||||
return new Response(result, { status: 200 });
|
||||
|
||||
} catch (error) {
|
||||
return new Response(`Error: ${(error as Error).message}`, { status: 500 });
|
||||
}
|
||||
};
|
||||
37
src/pages/api/god/proxy.ts
Normal file
37
src/pages/api/god/proxy.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { executeCommand } from '@/lib/directus/client';
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
// 1. Security (Token)
|
||||
const authHeader = request.headers.get('Authorization');
|
||||
const token = import.meta.env.GOD_MODE_TOKEN || process.env.GOD_MODE_TOKEN;
|
||||
|
||||
// We can also accept X-God-Token header for flexibility
|
||||
const headerToken = request.headers.get('X-God-Token');
|
||||
|
||||
if (authHeader !== `Bearer ${token}` && headerToken !== token) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// 2. Parse Command
|
||||
const command = await request.json();
|
||||
|
||||
// 3. Execute via Shim
|
||||
const data = await executeCommand(command);
|
||||
|
||||
return new Response(JSON.stringify(data), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
return new Response(JSON.stringify({ error: (error as Error).message }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
};
|
||||
88
src/pages/api/seo/approve-batch.ts
Normal file
88
src/pages/api/seo/approve-batch.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
// @ts-ignore - Astro types available at build time
|
||||
import type { APIRoute } from 'astro';
|
||||
import { getDirectusClient, readItem, updateItem, createItem } from '@/lib/directus/client';
|
||||
|
||||
/**
|
||||
* Approve Batch API
|
||||
*
|
||||
* Approves test batch and unlocks full production run.
|
||||
*
|
||||
* POST /api/seo/approve-batch
|
||||
*/
|
||||
export const POST: APIRoute = async ({ request }: { request: Request }) => {
|
||||
try {
|
||||
const data = await request.json();
|
||||
const { queue_id, approved = true } = data;
|
||||
|
||||
if (!queue_id) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'queue_id is required' }),
|
||||
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
|
||||
const directus = getDirectusClient();
|
||||
|
||||
// Get queue
|
||||
const queue = await directus.request(readItem('production_queue', queue_id)) as any;
|
||||
|
||||
if (!queue) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Queue not found' }),
|
||||
{ status: 404, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
|
||||
if (queue.status !== 'test_batch') {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Queue is not in test_batch status' }),
|
||||
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
|
||||
const newStatus = approved ? 'approved' : 'pending';
|
||||
|
||||
// Update queue
|
||||
await directus.request(
|
||||
updateItem('production_queue', queue_id, {
|
||||
status: newStatus
|
||||
})
|
||||
);
|
||||
|
||||
// Update campaign
|
||||
await directus.request(
|
||||
updateItem('campaign_masters', queue.campaign, {
|
||||
test_batch_status: approved ? 'approved' : 'rejected'
|
||||
})
|
||||
);
|
||||
|
||||
// Log work
|
||||
await directus.request(
|
||||
createItem('work_log', {
|
||||
site: queue.site,
|
||||
action: approved ? 'approved' : 'rejected',
|
||||
entity_type: 'production_queue',
|
||||
entity_id: queue_id,
|
||||
details: { approved }
|
||||
})
|
||||
);
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
queue_id,
|
||||
status: newStatus,
|
||||
next_step: approved
|
||||
? 'Queue approved. Call /api/seo/process-queue to start full generation.'
|
||||
: 'Queue rejected. Modify campaign and resubmit test batch.'
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error approving batch:', error);
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Failed to approve batch' }),
|
||||
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
};
|
||||
43
src/pages/api/seo/articles.ts
Normal file
43
src/pages/api/seo/articles.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { getDirectusClient, readItems } from '@/lib/directus/client';
|
||||
|
||||
export const GET: APIRoute = async ({ locals }) => {
|
||||
try {
|
||||
const directus = getDirectusClient();
|
||||
const siteId = locals.siteId;
|
||||
|
||||
const filter: Record<string, any> = {};
|
||||
if (siteId) {
|
||||
filter.site = { _eq: siteId };
|
||||
}
|
||||
|
||||
const articles = await directus.request(
|
||||
readItems('generated_articles', {
|
||||
filter,
|
||||
sort: ['-date_created'],
|
||||
limit: 100,
|
||||
fields: [
|
||||
'id',
|
||||
'headline',
|
||||
'meta_title',
|
||||
'word_count',
|
||||
'is_published',
|
||||
'location_city',
|
||||
'location_state',
|
||||
'date_created'
|
||||
]
|
||||
})
|
||||
);
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({ articles }),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error fetching articles:', error);
|
||||
return new Response(
|
||||
JSON.stringify({ articles: [], error: 'Failed to fetch articles' }),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
};
|
||||
224
src/pages/api/seo/assemble-article.ts
Normal file
224
src/pages/api/seo/assemble-article.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
// @ts-ignore - Astro types available at build time
|
||||
import type { APIRoute } from 'astro';
|
||||
import { getDirectusClient, readItems, createItem, updateItem } from '@/lib/directus/client';
|
||||
import { replaceYearTokens } from '@/lib/seo/velocity-scheduler';
|
||||
|
||||
/**
|
||||
* Assemble Article API
|
||||
*
|
||||
* Builds a full article from content modules based on campaign recipe.
|
||||
* Uses lowest usage_count modules to ensure variety.
|
||||
*
|
||||
* POST /api/seo/assemble-article
|
||||
*/
|
||||
export const POST: APIRoute = async ({ request }: { request: Request }) => {
|
||||
try {
|
||||
const data = await request.json();
|
||||
const {
|
||||
campaign_id,
|
||||
location, // { city, state, county }
|
||||
publish_date,
|
||||
modified_date
|
||||
} = data;
|
||||
|
||||
if (!campaign_id || !location) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'campaign_id and location required' }),
|
||||
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
|
||||
const directus = getDirectusClient();
|
||||
|
||||
// Get campaign with recipe
|
||||
const campaigns = await directus.request(readItems('campaign_masters', {
|
||||
filter: { id: { _eq: campaign_id } },
|
||||
limit: 1
|
||||
})) as any[];
|
||||
|
||||
const campaign = campaigns[0];
|
||||
if (!campaign) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Campaign not found' }),
|
||||
{ status: 404, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
|
||||
const recipe = campaign.content_recipe || ['intro', 'benefits', 'howto', 'conclusion'];
|
||||
const pubDate = publish_date ? new Date(publish_date) : new Date();
|
||||
const modDate = modified_date ? new Date(modified_date) : new Date();
|
||||
|
||||
// Build context for token replacement
|
||||
const context = {
|
||||
city: location.city || '',
|
||||
state: location.state || '',
|
||||
county: location.county || '',
|
||||
state_code: getStateCode(location.state) || '',
|
||||
year: pubDate.getFullYear()
|
||||
};
|
||||
|
||||
// Fetch and assemble modules
|
||||
const assembledParts: string[] = [];
|
||||
const modulesUsed: string[] = [];
|
||||
|
||||
for (const moduleType of recipe) {
|
||||
// Get modules of this type, prefer lowest usage_count
|
||||
const modules = await directus.request(readItems('content_modules', {
|
||||
filter: {
|
||||
site: { _eq: campaign.site },
|
||||
module_type: { _eq: moduleType },
|
||||
is_active: { _eq: true }
|
||||
},
|
||||
sort: ['usage_count', 'id'], // Lowest usage first
|
||||
limit: 1
|
||||
})) as any[];
|
||||
|
||||
if (modules.length > 0) {
|
||||
const module = modules[0];
|
||||
|
||||
// Process spintax
|
||||
let content = module.content_spintax || '';
|
||||
|
||||
// Replace location tokens
|
||||
content = content
|
||||
.replace(/\{City\}/gi, context.city)
|
||||
.replace(/\{State\}/gi, context.state)
|
||||
.replace(/\{County\}/gi, context.county)
|
||||
.replace(/\{State_Code\}/gi, context.state_code)
|
||||
.replace(/\{Location_City\}/gi, context.city)
|
||||
.replace(/\{Location_State\}/gi, context.state);
|
||||
|
||||
// Replace year tokens
|
||||
content = replaceYearTokens(content, pubDate);
|
||||
|
||||
// Process spintax syntax
|
||||
content = processSpintax(content);
|
||||
|
||||
assembledParts.push(content);
|
||||
modulesUsed.push(module.id);
|
||||
|
||||
// Increment usage count
|
||||
await directus.request(
|
||||
updateItem('content_modules', module.id, {
|
||||
usage_count: (module.usage_count || 0) + 1
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const fullContent = assembledParts.join('\n\n');
|
||||
|
||||
// Generate headline from intro
|
||||
const headline = generateHeadline(campaign.spintax_title, context, pubDate) ||
|
||||
`${context.city} ${campaign.name || 'Guide'}`;
|
||||
|
||||
// Generate meta
|
||||
const metaTitle = headline.substring(0, 60);
|
||||
const metaDescription = stripHtml(fullContent).substring(0, 155) + '...';
|
||||
|
||||
// Count words
|
||||
const wordCount = stripHtml(fullContent).split(/\s+/).length;
|
||||
|
||||
// Create article
|
||||
const article = await directus.request(
|
||||
createItem('generated_articles', {
|
||||
site: campaign.site,
|
||||
campaign: campaign_id,
|
||||
headline: headline,
|
||||
meta_title: metaTitle,
|
||||
meta_description: metaDescription,
|
||||
full_html_body: fullContent,
|
||||
word_count: wordCount,
|
||||
is_published: false,
|
||||
is_test_batch: false,
|
||||
date_published: pubDate.toISOString(),
|
||||
date_modified: modDate.toISOString(),
|
||||
sitemap_status: 'ghost',
|
||||
location_city: context.city,
|
||||
location_county: context.county,
|
||||
location_state: context.state,
|
||||
modules_used: modulesUsed
|
||||
})
|
||||
) as any;
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
article_id: article.id,
|
||||
headline,
|
||||
word_count: wordCount,
|
||||
modules_used: modulesUsed.length,
|
||||
dates: {
|
||||
published: pubDate.toISOString(),
|
||||
modified: modDate.toISOString()
|
||||
}
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error assembling article:', error);
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Failed to assemble article' }),
|
||||
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Process spintax syntax: {option1|option2|option3}
|
||||
*/
|
||||
function processSpintax(text: string): string {
|
||||
// Match nested spintax from innermost to outermost
|
||||
let result = text;
|
||||
let maxIterations = 100;
|
||||
|
||||
while (result.includes('{') && maxIterations > 0) {
|
||||
result = result.replace(/\{([^{}]+)\}/g, (match, options) => {
|
||||
const choices = options.split('|');
|
||||
return choices[Math.floor(Math.random() * choices.length)];
|
||||
});
|
||||
maxIterations--;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate headline with spintax and tokens
|
||||
*/
|
||||
function generateHeadline(template: string | null, context: any, date: Date): string {
|
||||
if (!template) return '';
|
||||
|
||||
let headline = template
|
||||
.replace(/\{City\}/gi, context.city)
|
||||
.replace(/\{State\}/gi, context.state)
|
||||
.replace(/\{County\}/gi, context.county);
|
||||
|
||||
headline = replaceYearTokens(headline, date);
|
||||
headline = processSpintax(headline);
|
||||
|
||||
return headline;
|
||||
}
|
||||
|
||||
function stripHtml(html: string): string {
|
||||
return html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function getStateCode(state: string): string {
|
||||
const codes: Record<string, string> = {
|
||||
'Alabama': 'AL', 'Alaska': 'AK', 'Arizona': 'AZ', 'Arkansas': 'AR',
|
||||
'California': 'CA', 'Colorado': 'CO', 'Connecticut': 'CT', 'Delaware': 'DE',
|
||||
'Florida': 'FL', 'Georgia': 'GA', 'Hawaii': 'HI', 'Idaho': 'ID',
|
||||
'Illinois': 'IL', 'Indiana': 'IN', 'Iowa': 'IA', 'Kansas': 'KS',
|
||||
'Kentucky': 'KY', 'Louisiana': 'LA', 'Maine': 'ME', 'Maryland': 'MD',
|
||||
'Massachusetts': 'MA', 'Michigan': 'MI', 'Minnesota': 'MN', 'Mississippi': 'MS',
|
||||
'Missouri': 'MO', 'Montana': 'MT', 'Nebraska': 'NE', 'Nevada': 'NV',
|
||||
'New Hampshire': 'NH', 'New Jersey': 'NJ', 'New Mexico': 'NM', 'New York': 'NY',
|
||||
'North Carolina': 'NC', 'North Dakota': 'ND', 'Ohio': 'OH', 'Oklahoma': 'OK',
|
||||
'Oregon': 'OR', 'Pennsylvania': 'PA', 'Rhode Island': 'RI', 'South Carolina': 'SC',
|
||||
'South Dakota': 'SD', 'Tennessee': 'TN', 'Texas': 'TX', 'Utah': 'UT',
|
||||
'Vermont': 'VT', 'Virginia': 'VA', 'Washington': 'WA', 'West Virginia': 'WV',
|
||||
'Wisconsin': 'WI', 'Wyoming': 'WY'
|
||||
};
|
||||
return codes[state] || '';
|
||||
}
|
||||
318
src/pages/api/seo/generate-article.ts
Normal file
318
src/pages/api/seo/generate-article.ts
Normal file
@@ -0,0 +1,318 @@
|
||||
// @ts-ignore - Astro types available at build time
|
||||
import type { APIRoute } from 'astro';
|
||||
import { getDirectusClient, readItems, readItem, createItem, updateItem } from '@/lib/directus/client';
|
||||
import { parseSpintaxRandom, injectVariables } from '@/lib/seo/cartesian';
|
||||
import { generateFeaturedImage, type ImageTemplate } from '@/lib/seo/image-generator';
|
||||
import type { VariableMap } from '@/types/cartesian';
|
||||
|
||||
/**
|
||||
* Fragment types for the 6-pillar content structure + intro and FAQ
|
||||
*/
|
||||
const DEFAULT_STRUCTURE = [
|
||||
'intro_hook',
|
||||
'pillar_1_keyword',
|
||||
'pillar_2_uniqueness',
|
||||
'pillar_3_relevance',
|
||||
'pillar_4_quality',
|
||||
'pillar_5_authority',
|
||||
'pillar_6_backlinks',
|
||||
'faq_section'
|
||||
];
|
||||
|
||||
/**
|
||||
* Count words in text (strip HTML first)
|
||||
*/
|
||||
function countWords(text: string): number {
|
||||
return text.replace(/<[^>]*>/g, '').split(/\s+/).filter(Boolean).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate Article API
|
||||
*
|
||||
* Assembles SEO articles by:
|
||||
* 1. Pulling an available headline from inventory
|
||||
* 2. Fetching location data for variable injection
|
||||
* 3. Selecting random fragments for each 6-pillar section
|
||||
* 4. Processing spintax within fragments (random selection)
|
||||
* 5. Injecting all variables (niche + location)
|
||||
* 6. Stitching into full HTML body
|
||||
*/
|
||||
export const POST: APIRoute = async ({ request, locals }) => {
|
||||
try {
|
||||
const data = await request.json();
|
||||
const { campaign_id, batch_size = 1 } = data;
|
||||
const siteId = locals.siteId;
|
||||
|
||||
if (!campaign_id) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Campaign ID is required' }),
|
||||
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
|
||||
const directus = getDirectusClient();
|
||||
|
||||
// Get campaign configuration
|
||||
const campaigns = await directus.request(
|
||||
readItems('campaign_masters', {
|
||||
filter: { id: { _eq: campaign_id } },
|
||||
limit: 1,
|
||||
fields: ['*']
|
||||
})
|
||||
);
|
||||
|
||||
if (!campaigns?.length) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Campaign not found' }),
|
||||
{ status: 404, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
|
||||
const campaign = campaigns[0] as any;
|
||||
const nicheVariables: VariableMap = campaign.niche_variables || {};
|
||||
const generatedArticles = [];
|
||||
const effectiveBatchSize = Math.min(batch_size, 50);
|
||||
|
||||
for (let i = 0; i < effectiveBatchSize; i++) {
|
||||
// Get next available headline
|
||||
const headlines = await directus.request(
|
||||
readItems('headline_inventory', {
|
||||
filter: {
|
||||
campaign: { _eq: campaign_id },
|
||||
status: { _eq: 'available' }
|
||||
},
|
||||
limit: 1,
|
||||
fields: ['id', 'final_title_text', 'location_data']
|
||||
})
|
||||
);
|
||||
|
||||
if (!headlines?.length) {
|
||||
break; // No more headlines available
|
||||
}
|
||||
|
||||
const headline = headlines[0] as any;
|
||||
|
||||
// Get location variables (from headline or fetch fresh)
|
||||
let locationVars: VariableMap = {};
|
||||
|
||||
if (headline.location_data) {
|
||||
// Use location from headline (set during headline generation)
|
||||
const loc = headline.location_data;
|
||||
locationVars = {
|
||||
city: loc.city || '',
|
||||
county: loc.county || '',
|
||||
state: loc.state || '',
|
||||
state_code: loc.stateCode || ''
|
||||
};
|
||||
} else if (campaign.location_mode === 'city') {
|
||||
// Fetch random city
|
||||
const cities = await directus.request(
|
||||
readItems('locations_cities', {
|
||||
limit: 1,
|
||||
offset: Math.floor(Math.random() * 100),
|
||||
fields: ['name', 'population', { county: ['name'] }, { state: ['name', 'code'] }]
|
||||
})
|
||||
);
|
||||
|
||||
if (cities?.length) {
|
||||
const city = cities[0] as any;
|
||||
locationVars = {
|
||||
city: city.name,
|
||||
county: city.county?.name || '',
|
||||
state: city.state?.name || '',
|
||||
state_code: city.state?.code || '',
|
||||
population: String(city.population || '')
|
||||
};
|
||||
}
|
||||
} else if (campaign.location_mode === 'county') {
|
||||
const counties = await directus.request(
|
||||
readItems('locations_counties', {
|
||||
limit: 1,
|
||||
offset: Math.floor(Math.random() * 100),
|
||||
fields: ['name', { state: ['name', 'code'] }]
|
||||
})
|
||||
);
|
||||
|
||||
if (counties?.length) {
|
||||
const county = counties[0] as any;
|
||||
locationVars = {
|
||||
county: county.name,
|
||||
state: county.state?.name || '',
|
||||
state_code: county.state?.code || ''
|
||||
};
|
||||
}
|
||||
} else if (campaign.location_mode === 'state') {
|
||||
const states = await directus.request(
|
||||
readItems('locations_states', {
|
||||
limit: 1,
|
||||
offset: Math.floor(Math.random() * 50),
|
||||
fields: ['name', 'code']
|
||||
})
|
||||
);
|
||||
|
||||
if (states?.length) {
|
||||
const state = states[0] as any;
|
||||
locationVars = {
|
||||
state: state.name,
|
||||
state_code: state.code
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Merge all variables for injection
|
||||
const allVariables: VariableMap = { ...nicheVariables, ...locationVars };
|
||||
|
||||
// Assemble article from fragments
|
||||
const fragments: string[] = [];
|
||||
|
||||
// Determine Structure (Blueprint)
|
||||
let structure: string[] = DEFAULT_STRUCTURE;
|
||||
if (campaign.article_template) {
|
||||
try {
|
||||
const template = await directus.request(readItem('article_templates', campaign.article_template));
|
||||
if (template && Array.isArray(template.structure_json)) {
|
||||
structure = template.structure_json;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`Failed to load template ${campaign.article_template}, using default.`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const fragmentType of structure) {
|
||||
const typeFragments = await directus.request(
|
||||
readItems('content_fragments', {
|
||||
filter: {
|
||||
fragment_type: { _eq: fragmentType },
|
||||
_or: [
|
||||
{ campaign: { _eq: campaign_id } },
|
||||
{ campaign: { name: { _eq: 'Master Content Library' } } }
|
||||
]
|
||||
},
|
||||
fields: ['content_body']
|
||||
})
|
||||
);
|
||||
|
||||
if (typeFragments?.length) {
|
||||
// Pick random fragment for variation
|
||||
const randomFragment = typeFragments[
|
||||
Math.floor(Math.random() * typeFragments.length)
|
||||
] as any;
|
||||
|
||||
let content = randomFragment.content_body;
|
||||
|
||||
// Process spintax (random selection within fragments)
|
||||
content = parseSpintaxRandom(content);
|
||||
|
||||
// Inject all variables
|
||||
content = injectVariables(content, allVariables);
|
||||
|
||||
fragments.push(content);
|
||||
}
|
||||
}
|
||||
|
||||
// Assemble full article HTML
|
||||
const fullHtmlBody = fragments.join('\n\n');
|
||||
const wordCount = countWords(fullHtmlBody);
|
||||
|
||||
// Generate meta title and description
|
||||
const processedHeadline = injectVariables(headline.final_title_text, allVariables);
|
||||
const metaTitle = processedHeadline.substring(0, 70);
|
||||
const metaDescription = fragments[0]
|
||||
? fragments[0].replace(/<[^>]*>/g, '').substring(0, 155)
|
||||
: metaTitle;
|
||||
|
||||
// Generate featured image from template
|
||||
const featuredImage = generateFeaturedImage({
|
||||
title: processedHeadline,
|
||||
subtitle: locationVars.city
|
||||
? `${locationVars.city}, ${locationVars.state_code || locationVars.state}`
|
||||
: undefined
|
||||
});
|
||||
|
||||
// Generate JSON-LD Schema
|
||||
const schemaJson = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Article",
|
||||
"headline": processedHeadline,
|
||||
"description": metaDescription,
|
||||
"wordCount": wordCount,
|
||||
"datePublished": new Date().toISOString(),
|
||||
"author": {
|
||||
"@type": "Organization",
|
||||
"name": locationVars.state ? `${locationVars.state} Services` : "Local Service Provider"
|
||||
},
|
||||
"image": featuredImage.filename ? `/assets/content/${featuredImage.filename}` : undefined
|
||||
};
|
||||
|
||||
// Check Word Count Goal
|
||||
const targetWordCount = campaign.target_word_count || 1500;
|
||||
const wordCountStatus = wordCount >= targetWordCount ? 'optimal' : 'under_target';
|
||||
|
||||
// Create article record with featured image and schema
|
||||
const article = await directus.request(
|
||||
createItem('generated_articles', {
|
||||
site: siteId || campaign.site,
|
||||
campaign: campaign_id,
|
||||
headline: processedHeadline,
|
||||
meta_title: metaTitle,
|
||||
meta_description: metaDescription,
|
||||
full_html_body: fullHtmlBody,
|
||||
word_count: wordCount,
|
||||
word_count_status: wordCountStatus,
|
||||
is_published: false,
|
||||
location_city: locationVars.city || null,
|
||||
location_county: locationVars.county || null,
|
||||
location_state: locationVars.state || null,
|
||||
featured_image_svg: featuredImage.svg,
|
||||
featured_image_filename: featuredImage.filename,
|
||||
featured_image_alt: featuredImage.alt,
|
||||
schema_json: schemaJson
|
||||
})
|
||||
);
|
||||
|
||||
// Mark headline as used
|
||||
await directus.request(
|
||||
updateItem('headline_inventory', headline.id, {
|
||||
status: 'used',
|
||||
used_on_article: (article as any).id
|
||||
})
|
||||
);
|
||||
|
||||
generatedArticles.push({
|
||||
id: (article as any).id,
|
||||
headline: processedHeadline,
|
||||
word_count: wordCount,
|
||||
location: locationVars.city || locationVars.county || locationVars.state || null
|
||||
});
|
||||
}
|
||||
|
||||
// Get remaining available headlines count
|
||||
const remainingHeadlines = await directus.request(
|
||||
readItems('headline_inventory', {
|
||||
filter: {
|
||||
campaign: { _eq: campaign_id },
|
||||
status: { _eq: 'available' }
|
||||
},
|
||||
aggregate: { count: '*' }
|
||||
})
|
||||
);
|
||||
|
||||
const remainingCount = (remainingHeadlines as any)?.[0]?.count || 0;
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
generated: generatedArticles.length,
|
||||
articles: generatedArticles,
|
||||
remaining_headlines: parseInt(remainingCount, 10)
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error generating article:', error);
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Failed to generate article' }),
|
||||
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
};
|
||||
380
src/pages/api/seo/generate-headlines.ts
Normal file
380
src/pages/api/seo/generate-headlines.ts
Normal file
@@ -0,0 +1,380 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { getDirectusClient, readItems, createItem } from '@/lib/directus/client';
|
||||
import {
|
||||
extractSpintaxSlots,
|
||||
calculateTotalCombinations,
|
||||
generateWithLocations,
|
||||
getCartesianMetadata,
|
||||
explodeSpintax
|
||||
} from '@/lib/seo/cartesian';
|
||||
import type { LocationEntry, CartesianResult } from '@/types/cartesian';
|
||||
|
||||
/**
|
||||
* Generate Headlines API
|
||||
*
|
||||
* Generates all Cartesian product combinations from:
|
||||
* - Campaign spintax template
|
||||
* - Location data (if location_mode is set)
|
||||
*
|
||||
* Uses the n^k formula where:
|
||||
* - n = number of options per spintax slot
|
||||
* - k = number of slots
|
||||
* - Final total = (n₁ × n₂ × ... × nₖ) × location_count
|
||||
*/
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
try {
|
||||
const data = await request.json();
|
||||
const {
|
||||
campaign_id,
|
||||
max_headlines = 10000,
|
||||
batch_size = 500,
|
||||
offset = 0
|
||||
} = data;
|
||||
|
||||
if (!campaign_id) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Campaign ID is required' }),
|
||||
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
|
||||
const directus = getDirectusClient();
|
||||
|
||||
// Get campaign
|
||||
const campaigns = await directus.request(
|
||||
readItems('campaign_masters', {
|
||||
filter: { id: { _eq: campaign_id } },
|
||||
limit: 1,
|
||||
fields: [
|
||||
'id',
|
||||
'headline_spintax_root',
|
||||
'niche_variables',
|
||||
'location_mode',
|
||||
'location_target'
|
||||
]
|
||||
})
|
||||
);
|
||||
|
||||
if (!campaigns?.length) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Campaign not found' }),
|
||||
{ status: 404, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
|
||||
const campaign = campaigns[0] as any;
|
||||
const spintax = campaign.headline_spintax_root;
|
||||
const nicheVariables = campaign.niche_variables || {};
|
||||
const locationMode = campaign.location_mode || 'none';
|
||||
|
||||
// Fetch locations based on mode
|
||||
let locations: LocationEntry[] = [];
|
||||
|
||||
if (locationMode !== 'none') {
|
||||
locations = await fetchLocations(
|
||||
directus,
|
||||
locationMode,
|
||||
campaign.location_target
|
||||
);
|
||||
}
|
||||
|
||||
// Calculate metadata BEFORE generation
|
||||
const metadata = getCartesianMetadata(
|
||||
spintax,
|
||||
locations.length,
|
||||
max_headlines
|
||||
);
|
||||
|
||||
// Check existing headlines to avoid duplicates
|
||||
const existing = await directus.request(
|
||||
readItems('headline_inventory', {
|
||||
filter: { campaign: { _eq: campaign_id } },
|
||||
fields: ['final_title_text']
|
||||
})
|
||||
);
|
||||
const existingTitles = new Set(
|
||||
existing?.map((h: any) => h.final_title_text) || []
|
||||
);
|
||||
|
||||
// Generate Cartesian product headlines
|
||||
const generator = generateWithLocations(
|
||||
spintax,
|
||||
locations,
|
||||
nicheVariables,
|
||||
{ maxCombinations: max_headlines, offset }
|
||||
);
|
||||
|
||||
// Insert new headlines in batches
|
||||
let insertedCount = 0;
|
||||
let skippedCount = 0;
|
||||
let processedCount = 0;
|
||||
|
||||
const batch: CartesianResult[] = [];
|
||||
|
||||
for (const result of generator) {
|
||||
processedCount++;
|
||||
|
||||
if (existingTitles.has(result.text)) {
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
batch.push(result);
|
||||
|
||||
// Insert batch when full
|
||||
if (batch.length >= batch_size) {
|
||||
insertedCount += await insertHeadlineBatch(
|
||||
directus,
|
||||
campaign_id,
|
||||
batch
|
||||
);
|
||||
batch.length = 0; // Clear batch
|
||||
}
|
||||
|
||||
// Safety limit
|
||||
if (insertedCount >= max_headlines) break;
|
||||
}
|
||||
|
||||
// Insert remaining batch
|
||||
if (batch.length > 0) {
|
||||
insertedCount += await insertHeadlineBatch(
|
||||
directus,
|
||||
campaign_id,
|
||||
batch
|
||||
);
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
metadata: {
|
||||
template: spintax,
|
||||
slotCount: metadata.slotCount,
|
||||
spintaxCombinations: metadata.totalSpintaxCombinations,
|
||||
locationCount: locations.length,
|
||||
totalPossible: metadata.totalPossibleCombinations,
|
||||
wasTruncated: metadata.wasTruncated
|
||||
},
|
||||
results: {
|
||||
processed: processedCount,
|
||||
inserted: insertedCount,
|
||||
skipped: skippedCount,
|
||||
alreadyExisted: existingTitles.size
|
||||
}
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error generating headlines:', error);
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Failed to generate headlines' }),
|
||||
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch locations based on mode and optional target filter
|
||||
*/
|
||||
async function fetchLocations(
|
||||
directus: any,
|
||||
mode: string,
|
||||
targetId?: string
|
||||
): Promise<LocationEntry[]> {
|
||||
try {
|
||||
switch (mode) {
|
||||
case 'state': {
|
||||
const filter: any = targetId
|
||||
? { id: { _eq: targetId } }
|
||||
: {};
|
||||
|
||||
const states = await directus.request(
|
||||
readItems('locations_states', {
|
||||
filter,
|
||||
fields: ['id', 'name', 'code'],
|
||||
limit: 100
|
||||
})
|
||||
);
|
||||
|
||||
return (states || []).map((s: any) => ({
|
||||
id: s.id,
|
||||
state: s.name,
|
||||
stateCode: s.code
|
||||
}));
|
||||
}
|
||||
|
||||
case 'county': {
|
||||
const filter: any = targetId
|
||||
? { state: { _eq: targetId } }
|
||||
: {};
|
||||
|
||||
const counties = await directus.request(
|
||||
readItems('locations_counties', {
|
||||
filter,
|
||||
fields: ['id', 'name', 'population', { state: ['name', 'code'] }],
|
||||
sort: ['-population'],
|
||||
limit: 500
|
||||
})
|
||||
);
|
||||
|
||||
return (counties || []).map((c: any) => ({
|
||||
id: c.id,
|
||||
county: c.name,
|
||||
state: c.state?.name || '',
|
||||
stateCode: c.state?.code || '',
|
||||
population: c.population
|
||||
}));
|
||||
}
|
||||
|
||||
case 'city': {
|
||||
const filter: any = {};
|
||||
|
||||
// If target is set, filter to that state's cities
|
||||
if (targetId) {
|
||||
// Check if target is a state or county
|
||||
const states = await directus.request(
|
||||
readItems('locations_states', {
|
||||
filter: { id: { _eq: targetId } },
|
||||
limit: 1
|
||||
})
|
||||
);
|
||||
|
||||
if (states?.length) {
|
||||
filter.state = { _eq: targetId };
|
||||
} else {
|
||||
filter.county = { _eq: targetId };
|
||||
}
|
||||
}
|
||||
|
||||
const cities = await directus.request(
|
||||
readItems('locations_cities', {
|
||||
filter,
|
||||
fields: [
|
||||
'id',
|
||||
'name',
|
||||
'population',
|
||||
{ county: ['name'] },
|
||||
{ state: ['name', 'code'] }
|
||||
],
|
||||
sort: ['-population'],
|
||||
limit: 1000 // Top 1000 cities
|
||||
})
|
||||
);
|
||||
|
||||
return (cities || []).map((c: any) => ({
|
||||
id: c.id,
|
||||
city: c.name,
|
||||
county: c.county?.name || '',
|
||||
state: c.state?.name || '',
|
||||
stateCode: c.state?.code || '',
|
||||
population: c.population
|
||||
}));
|
||||
}
|
||||
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching locations:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a batch of headlines into the database
|
||||
*/
|
||||
async function insertHeadlineBatch(
|
||||
directus: any,
|
||||
campaignId: string,
|
||||
batch: CartesianResult[]
|
||||
): Promise<number> {
|
||||
let count = 0;
|
||||
|
||||
for (const result of batch) {
|
||||
try {
|
||||
await directus.request(
|
||||
createItem('headline_inventory', {
|
||||
campaign: campaignId,
|
||||
final_title_text: result.text,
|
||||
status: 'available',
|
||||
location_data: result.location || null
|
||||
})
|
||||
);
|
||||
count++;
|
||||
} catch (error) {
|
||||
// Skip duplicates or errors
|
||||
console.error('Failed to insert headline:', error);
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview endpoint - shows what WOULD be generated without inserting
|
||||
*/
|
||||
export const GET: APIRoute = async ({ url }) => {
|
||||
try {
|
||||
const campaignId = url.searchParams.get('campaign_id');
|
||||
const previewCount = parseInt(url.searchParams.get('preview') || '10');
|
||||
|
||||
if (!campaignId) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'campaign_id is required' }),
|
||||
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
|
||||
const directus = getDirectusClient();
|
||||
|
||||
// Get campaign
|
||||
const campaigns = await directus.request(
|
||||
readItems('campaign_masters', {
|
||||
filter: { id: { _eq: campaignId } },
|
||||
limit: 1,
|
||||
fields: ['headline_spintax_root', 'location_mode', 'location_target']
|
||||
})
|
||||
);
|
||||
|
||||
if (!campaigns?.length) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Campaign not found' }),
|
||||
{ status: 404, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
|
||||
const campaign = campaigns[0] as any;
|
||||
const spintax = campaign.headline_spintax_root;
|
||||
|
||||
// Get location count
|
||||
let locationCount = 1;
|
||||
if (campaign.location_mode !== 'none') {
|
||||
const locations = await fetchLocations(
|
||||
directus,
|
||||
campaign.location_mode,
|
||||
campaign.location_target
|
||||
);
|
||||
locationCount = locations.length;
|
||||
}
|
||||
|
||||
// Get metadata
|
||||
const metadata = getCartesianMetadata(spintax, locationCount);
|
||||
|
||||
// Generate preview samples
|
||||
const samples = explodeSpintax(spintax, previewCount);
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
metadata,
|
||||
preview: samples
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error previewing headlines:', error);
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Failed to preview headlines' }),
|
||||
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
};
|
||||
177
src/pages/api/seo/generate-test-batch.ts
Normal file
177
src/pages/api/seo/generate-test-batch.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
// @ts-ignore - Astro types available at build time
|
||||
import type { APIRoute } from 'astro';
|
||||
import { getDirectusClient, readItem, readItems, createItem, updateItem } from '@/lib/directus/client';
|
||||
import { replaceYearTokens } from '@/lib/seo/velocity-scheduler';
|
||||
|
||||
/**
|
||||
* Generate Test Batch API
|
||||
*
|
||||
* Creates a small batch of articles for review before mass production.
|
||||
*
|
||||
* POST /api/seo/generate-test-batch
|
||||
*/
|
||||
export const POST: APIRoute = async ({ request }: { request: Request }) => {
|
||||
try {
|
||||
const data = await request.json();
|
||||
const { queue_id, campaign_id, batch_size = 10 } = data;
|
||||
|
||||
if (!queue_id && !campaign_id) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'queue_id or campaign_id is required' }),
|
||||
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
|
||||
const directus = getDirectusClient();
|
||||
|
||||
// Get queue entry
|
||||
let queue: any;
|
||||
if (queue_id) {
|
||||
queue = await directus.request(readItem('production_queue', queue_id));
|
||||
} else {
|
||||
const queues = await directus.request(readItems('production_queue', {
|
||||
filter: { campaign: { _eq: campaign_id }, status: { _eq: 'test_batch' } },
|
||||
limit: 1
|
||||
}));
|
||||
queue = (queues as any[])?.[0];
|
||||
}
|
||||
|
||||
if (!queue) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Queue not found' }),
|
||||
{ status: 404, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
|
||||
// Get campaign
|
||||
const campaign = await directus.request(
|
||||
readItem('campaign_masters', queue.campaign)
|
||||
) as any;
|
||||
|
||||
if (!campaign) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Campaign not found' }),
|
||||
{ status: 404, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
|
||||
// Get schedule data (first N for test batch)
|
||||
const scheduleData = queue.schedule_data || [];
|
||||
const testSchedule = scheduleData.slice(0, batch_size);
|
||||
|
||||
if (testSchedule.length === 0) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'No schedule data found' }),
|
||||
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
|
||||
// Get headline inventory for this campaign
|
||||
const headlines = await directus.request(readItems('headline_inventory', {
|
||||
filter: { campaign: { _eq: campaign.id }, is_used: { _eq: false } },
|
||||
limit: batch_size
|
||||
})) as any[];
|
||||
|
||||
if (headlines.length === 0) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'No unused headlines available. Generate headlines first.' }),
|
||||
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
|
||||
// Generate test articles
|
||||
const generatedArticles: any[] = [];
|
||||
|
||||
for (let i = 0; i < Math.min(batch_size, headlines.length, testSchedule.length); i++) {
|
||||
const headline = headlines[i];
|
||||
const schedule = testSchedule[i];
|
||||
const publishDate = new Date(schedule.publish_date);
|
||||
const modifiedDate = new Date(schedule.modified_date);
|
||||
|
||||
// Apply year tokens to headline
|
||||
const processedHeadline = replaceYearTokens(headline.headline, publishDate);
|
||||
|
||||
// Generate article content (simplified - in production, use full content generation)
|
||||
const article = await directus.request(
|
||||
createItem('generated_articles', {
|
||||
site: queue.site,
|
||||
campaign: campaign.id,
|
||||
headline: processedHeadline,
|
||||
meta_title: processedHeadline.substring(0, 60),
|
||||
meta_description: `Learn about ${processedHeadline}. Expert guide with actionable tips.`,
|
||||
full_html_body: `<h1>${processedHeadline}</h1><p>Test batch article content. Full content will be generated on approval.</p>`,
|
||||
word_count: 100,
|
||||
is_published: false,
|
||||
is_test_batch: true,
|
||||
date_published: publishDate.toISOString(),
|
||||
date_modified: modifiedDate.toISOString(),
|
||||
sitemap_status: 'ghost',
|
||||
location_city: headline.location_city || null,
|
||||
location_state: headline.location_state || null
|
||||
})
|
||||
);
|
||||
|
||||
// Mark headline as used
|
||||
await directus.request(
|
||||
updateItem('headline_inventory', headline.id, { is_used: true })
|
||||
);
|
||||
|
||||
generatedArticles.push(article);
|
||||
}
|
||||
|
||||
// Update queue status
|
||||
await directus.request(
|
||||
updateItem('production_queue', queue.id, {
|
||||
status: 'test_batch',
|
||||
completed_count: generatedArticles.length
|
||||
})
|
||||
);
|
||||
|
||||
// Create review URL
|
||||
const reviewUrl = `/admin/review-batch?queue=${queue.id}`;
|
||||
|
||||
// Update campaign with review URL
|
||||
await directus.request(
|
||||
updateItem('campaign_masters', campaign.id, {
|
||||
test_batch_status: 'ready',
|
||||
test_batch_review_url: reviewUrl
|
||||
})
|
||||
);
|
||||
|
||||
// Log work
|
||||
await directus.request(
|
||||
createItem('work_log', {
|
||||
site: queue.site,
|
||||
action: 'test_generated',
|
||||
entity_type: 'production_queue',
|
||||
entity_id: queue.id,
|
||||
details: {
|
||||
articles_created: generatedArticles.length,
|
||||
review_url: reviewUrl
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
queue_id: queue.id,
|
||||
articles_created: generatedArticles.length,
|
||||
review_url: reviewUrl,
|
||||
articles: generatedArticles.map(a => ({
|
||||
id: a.id,
|
||||
headline: a.headline,
|
||||
date_published: a.date_published
|
||||
})),
|
||||
next_step: `Review articles at ${reviewUrl}, then call /api/seo/approve-batch to start full production`
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error generating test batch:', error);
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Failed to generate test batch' }),
|
||||
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
};
|
||||
150
src/pages/api/seo/get-nearby.ts
Normal file
150
src/pages/api/seo/get-nearby.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
// @ts-ignore - Astro types available at build time
|
||||
import type { APIRoute } from 'astro';
|
||||
import { getDirectusClient, readItem, readItems } from '@/lib/directus/client';
|
||||
|
||||
/**
|
||||
* Get Nearby API
|
||||
*
|
||||
* Returns related articles in same county/state for "Nearby Locations" footer.
|
||||
* Only returns articles that are indexed (not ghost).
|
||||
*
|
||||
* GET /api/seo/get-nearby?article_id={id}&limit=10
|
||||
*/
|
||||
export const GET: APIRoute = async ({ url }: { url: URL }) => {
|
||||
try {
|
||||
const articleId = url.searchParams.get('article_id');
|
||||
const limit = parseInt(url.searchParams.get('limit') || '10', 10);
|
||||
|
||||
if (!articleId) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'article_id is required' }),
|
||||
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
|
||||
const directus = getDirectusClient();
|
||||
|
||||
// Get the source article
|
||||
const article = await directus.request(readItem('generated_articles', articleId)) as any;
|
||||
|
||||
if (!article) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Article not found' }),
|
||||
{ status: 404, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
|
||||
const nearbyArticles: any[] = [];
|
||||
|
||||
// Strategy 1: Find articles in same county (if article has county)
|
||||
if (article.location_county) {
|
||||
const countyArticles = await directus.request(readItems('generated_articles', {
|
||||
filter: {
|
||||
site: { _eq: article.site },
|
||||
location_county: { _eq: article.location_county },
|
||||
id: { _neq: articleId },
|
||||
sitemap_status: { _eq: 'indexed' }, // PATCH 3: Only indexed articles
|
||||
is_published: { _eq: true }
|
||||
},
|
||||
sort: ['-date_published'],
|
||||
limit: limit,
|
||||
fields: ['id', 'headline', 'location_city', 'location_county', 'location_state']
|
||||
})) as any[];
|
||||
|
||||
nearbyArticles.push(...countyArticles);
|
||||
}
|
||||
|
||||
// Strategy 2: If not enough, find articles in same state
|
||||
if (nearbyArticles.length < limit && article.location_state) {
|
||||
const remaining = limit - nearbyArticles.length;
|
||||
const existingIds = [articleId, ...nearbyArticles.map(a => a.id)];
|
||||
|
||||
const stateArticles = await directus.request(readItems('generated_articles', {
|
||||
filter: {
|
||||
site: { _eq: article.site },
|
||||
location_state: { _eq: article.location_state },
|
||||
id: { _nin: existingIds },
|
||||
sitemap_status: { _eq: 'indexed' },
|
||||
is_published: { _eq: true }
|
||||
},
|
||||
sort: ['location_city'], // Alphabetical by city
|
||||
limit: remaining,
|
||||
fields: ['id', 'headline', 'location_city', 'location_county', 'location_state']
|
||||
})) as any[];
|
||||
|
||||
nearbyArticles.push(...stateArticles);
|
||||
}
|
||||
|
||||
// Get parent hub if exists
|
||||
let parentHub = null;
|
||||
if (article.parent_hub) {
|
||||
const hub = await directus.request(readItem('hub_pages', article.parent_hub)) as any;
|
||||
if (hub && hub.sitemap_status === 'indexed') {
|
||||
parentHub = {
|
||||
id: hub.id,
|
||||
title: hub.title_template,
|
||||
slug: hub.slug_pattern,
|
||||
level: hub.level
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Get state hub for breadcrumb
|
||||
let stateHub = null;
|
||||
if (article.location_state) {
|
||||
const hubs = await directus.request(readItems('hub_pages', {
|
||||
filter: {
|
||||
site: { _eq: article.site },
|
||||
level: { _eq: 'state' },
|
||||
sitemap_status: { _eq: 'indexed' }
|
||||
},
|
||||
limit: 1
|
||||
})) as any[];
|
||||
|
||||
if (hubs.length > 0) {
|
||||
stateHub = {
|
||||
id: hubs[0].id,
|
||||
title: hubs[0].title_template?.replace('{State}', article.location_state),
|
||||
slug: hubs[0].slug_pattern?.replace('{state-slug}', slugify(article.location_state))
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
article_id: articleId,
|
||||
location: {
|
||||
city: article.location_city,
|
||||
county: article.location_county,
|
||||
state: article.location_state
|
||||
},
|
||||
nearby: nearbyArticles.map(a => ({
|
||||
id: a.id,
|
||||
headline: a.headline,
|
||||
city: a.location_city,
|
||||
county: a.location_county
|
||||
})),
|
||||
parent_hub: parentHub,
|
||||
state_hub: stateHub,
|
||||
breadcrumb: [
|
||||
{ name: 'Home', url: '/' },
|
||||
stateHub ? { name: stateHub.title, url: stateHub.slug } : null,
|
||||
parentHub ? { name: parentHub.title, url: parentHub.slug } : null,
|
||||
{ name: article.headline, url: null }
|
||||
].filter(Boolean)
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error getting nearby:', error);
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Failed to get nearby articles' }),
|
||||
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
function slugify(str: string): string {
|
||||
return str.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
|
||||
}
|
||||
166
src/pages/api/seo/insert-links.ts
Normal file
166
src/pages/api/seo/insert-links.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
// @ts-ignore - Astro types available at build time
|
||||
import type { APIRoute } from 'astro';
|
||||
import { getDirectusClient, readItem, readItems, updateItem, createItem } from '@/lib/directus/client';
|
||||
|
||||
/**
|
||||
* Insert Links API
|
||||
*
|
||||
* Scans article content and inserts internal links based on link_targets rules.
|
||||
* Respects temporal linking (2023 articles can't link to 2025 articles).
|
||||
*
|
||||
* POST /api/seo/insert-links
|
||||
*/
|
||||
export const POST: APIRoute = async ({ request }: { request: Request }) => {
|
||||
try {
|
||||
const data = await request.json();
|
||||
const { article_id, max_links = 5 } = data;
|
||||
|
||||
if (!article_id) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'article_id is required' }),
|
||||
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
|
||||
const directus = getDirectusClient();
|
||||
|
||||
// Get article
|
||||
const article = await directus.request(readItem('generated_articles', article_id)) as any;
|
||||
|
||||
if (!article) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Article not found' }),
|
||||
{ status: 404, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
|
||||
const articleDate = new Date(article.date_published);
|
||||
const articleModified = article.date_modified ? new Date(article.date_modified) : null;
|
||||
|
||||
// Get link targets for this site, sorted by priority
|
||||
const linkTargets = await directus.request(readItems('link_targets', {
|
||||
filter: {
|
||||
site: { _eq: article.site },
|
||||
is_active: { _eq: true }
|
||||
},
|
||||
sort: ['-priority']
|
||||
})) as any[];
|
||||
|
||||
if (linkTargets.length === 0) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
links_inserted: 0,
|
||||
message: 'No link targets defined for this site'
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
|
||||
let content = article.full_html_body || '';
|
||||
let linksInserted = 0;
|
||||
const insertedAnchors: string[] = [];
|
||||
|
||||
for (const target of linkTargets) {
|
||||
if (linksInserted >= max_links) break;
|
||||
|
||||
// Check temporal linking rules
|
||||
if (!target.is_hub && target.target_post) {
|
||||
// Get the target post's date
|
||||
const targetPost = await directus.request(readItem('posts', target.target_post)) as any;
|
||||
if (targetPost) {
|
||||
const targetDate = new Date(targetPost.date_published || targetPost.date_created);
|
||||
|
||||
// Can't link to posts "published" after this article
|
||||
// Unless this article has a recent modified date
|
||||
const recentModified = articleModified &&
|
||||
(new Date().getTime() - articleModified.getTime()) < 30 * 24 * 60 * 60 * 1000; // 30 days
|
||||
|
||||
if (targetDate > articleDate && !recentModified) {
|
||||
continue; // Skip this link target
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build anchor variations
|
||||
const anchors = [target.anchor_text];
|
||||
if (target.anchor_variations && Array.isArray(target.anchor_variations)) {
|
||||
anchors.push(...target.anchor_variations);
|
||||
}
|
||||
|
||||
// Find and replace anchors in content
|
||||
let insertedForThisTarget = 0;
|
||||
const maxPerArticle = target.max_per_article || 2;
|
||||
|
||||
for (const anchor of anchors) {
|
||||
if (insertedForThisTarget >= maxPerArticle) break;
|
||||
if (linksInserted >= max_links) break;
|
||||
|
||||
// Case-insensitive regex that doesn't match already-linked text
|
||||
// Negative lookbehind for existing links
|
||||
const regex = new RegExp(
|
||||
`(?<!<a[^>]*>)\\b(${escapeRegex(anchor)})\\b(?![^<]*</a>)`,
|
||||
'i'
|
||||
);
|
||||
|
||||
if (regex.test(content)) {
|
||||
const targetUrl = target.target_url ||
|
||||
(target.target_post ? `/posts/${target.target_post}` : null);
|
||||
|
||||
if (targetUrl) {
|
||||
content = content.replace(regex, `<a href="${targetUrl}">$1</a>`);
|
||||
linksInserted++;
|
||||
insertedForThisTarget++;
|
||||
insertedAnchors.push(anchor);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update article with linked content
|
||||
if (linksInserted > 0) {
|
||||
await directus.request(
|
||||
updateItem('generated_articles', article_id, {
|
||||
full_html_body: content
|
||||
})
|
||||
);
|
||||
|
||||
// Log work
|
||||
await directus.request(
|
||||
createItem('work_log', {
|
||||
site: article.site,
|
||||
action: 'links_inserted',
|
||||
entity_type: 'generated_article',
|
||||
entity_id: article_id,
|
||||
details: {
|
||||
links_inserted: linksInserted,
|
||||
anchors: insertedAnchors
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
article_id,
|
||||
links_inserted: linksInserted,
|
||||
anchors_used: insertedAnchors
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error inserting links:', error);
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Failed to insert links' }),
|
||||
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Escape special regex characters
|
||||
*/
|
||||
function escapeRegex(str: string): string {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
290
src/pages/api/seo/process-queue.ts
Normal file
290
src/pages/api/seo/process-queue.ts
Normal file
@@ -0,0 +1,290 @@
|
||||
// @ts-ignore - Astro types available at build time
|
||||
import type { APIRoute } from 'astro';
|
||||
import { getDirectusClient, readItem, readItems, updateItem, createItem } from '@/lib/directus/client';
|
||||
import { replaceYearTokens } from '@/lib/seo/velocity-scheduler';
|
||||
|
||||
/**
|
||||
* Process Queue API
|
||||
*
|
||||
* Runs the factory: generates all scheduled articles for an approved queue.
|
||||
* Can be called by cron or manually (with limits per call).
|
||||
*
|
||||
* POST /api/seo/process-queue
|
||||
*/
|
||||
export const POST: APIRoute = async ({ request }: { request: Request }) => {
|
||||
try {
|
||||
const data = await request.json();
|
||||
const { queue_id, batch_limit = 100 } = data;
|
||||
|
||||
if (!queue_id) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'queue_id is required' }),
|
||||
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
|
||||
const directus = getDirectusClient();
|
||||
|
||||
// Get queue
|
||||
const queue = await directus.request(readItem('production_queue', queue_id)) as any;
|
||||
|
||||
if (!queue) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Queue not found' }),
|
||||
{ status: 404, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
|
||||
if (queue.status !== 'approved' && queue.status !== 'running') {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Queue must be approved to process' }),
|
||||
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
|
||||
// Mark as running
|
||||
await directus.request(
|
||||
updateItem('production_queue', queue_id, {
|
||||
status: 'running',
|
||||
started_at: queue.started_at || new Date().toISOString()
|
||||
})
|
||||
);
|
||||
|
||||
// Get campaign
|
||||
const campaign = await directus.request(
|
||||
readItem('campaign_masters', queue.campaign)
|
||||
) as any;
|
||||
|
||||
// Get schedule data
|
||||
const scheduleData = queue.schedule_data || [];
|
||||
const startIndex = queue.completed_count || 0;
|
||||
const endIndex = Math.min(startIndex + batch_limit, scheduleData.length);
|
||||
const batchSchedule = scheduleData.slice(startIndex, endIndex);
|
||||
|
||||
if (batchSchedule.length === 0) {
|
||||
// All done!
|
||||
await directus.request(
|
||||
updateItem('production_queue', queue_id, {
|
||||
status: 'done',
|
||||
completed_at: new Date().toISOString()
|
||||
})
|
||||
);
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
message: 'Queue complete',
|
||||
total_generated: queue.completed_count
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
|
||||
// Get locations based on filter
|
||||
const locationFilter = campaign.target_locations_filter || {};
|
||||
const locations = await directus.request(readItems('locations_cities', {
|
||||
filter: locationFilter,
|
||||
limit: batchSchedule.length,
|
||||
offset: startIndex
|
||||
})) as any[];
|
||||
|
||||
// Get recipe
|
||||
const recipe = campaign.content_recipe || ['intro', 'benefits', 'howto', 'conclusion'];
|
||||
|
||||
let generated = 0;
|
||||
const errors: string[] = [];
|
||||
|
||||
for (let i = 0; i < batchSchedule.length; i++) {
|
||||
const schedule = batchSchedule[i];
|
||||
const location = locations[i] || locations[i % locations.length];
|
||||
|
||||
if (!location) continue;
|
||||
|
||||
try {
|
||||
const pubDate = new Date(schedule.publish_date);
|
||||
const modDate = new Date(schedule.modified_date);
|
||||
|
||||
const context = {
|
||||
city: location.city || location.name || '',
|
||||
state: location.state || '',
|
||||
county: location.county || '',
|
||||
state_code: getStateCode(location.state) || ''
|
||||
};
|
||||
|
||||
// Assemble content from modules
|
||||
const { content, modulesUsed } = await assembleFromModules(
|
||||
directus, campaign.site, recipe, context, pubDate
|
||||
);
|
||||
|
||||
// Generate headline
|
||||
const headline = generateHeadline(campaign.spintax_title, context, pubDate) ||
|
||||
`${context.city} ${campaign.name || 'Guide'}`;
|
||||
|
||||
const wordCount = content.replace(/<[^>]*>/g, ' ').split(/\s+/).length;
|
||||
|
||||
// Create article
|
||||
await directus.request(
|
||||
createItem('generated_articles', {
|
||||
site: queue.site,
|
||||
campaign: campaign.id,
|
||||
headline: headline,
|
||||
meta_title: headline.substring(0, 60),
|
||||
meta_description: content.replace(/<[^>]*>/g, ' ').substring(0, 155) + '...',
|
||||
full_html_body: content,
|
||||
word_count: wordCount,
|
||||
is_published: true, // Ghost published
|
||||
is_test_batch: false,
|
||||
date_published: pubDate.toISOString(),
|
||||
date_modified: modDate.toISOString(),
|
||||
sitemap_status: 'ghost',
|
||||
location_city: context.city,
|
||||
location_county: context.county,
|
||||
location_state: context.state,
|
||||
modules_used: modulesUsed
|
||||
})
|
||||
);
|
||||
|
||||
generated++;
|
||||
} catch (err: any) {
|
||||
errors.push(`Article ${i}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Update queue progress
|
||||
const newCompleted = startIndex + generated;
|
||||
const isComplete = newCompleted >= scheduleData.length;
|
||||
|
||||
await directus.request(
|
||||
updateItem('production_queue', queue_id, {
|
||||
completed_count: newCompleted,
|
||||
status: isComplete ? 'done' : 'running',
|
||||
completed_at: isComplete ? new Date().toISOString() : null,
|
||||
error_log: errors.length > 0 ? errors.join('\n') : null
|
||||
})
|
||||
);
|
||||
|
||||
// Update site factory status
|
||||
await directus.request(
|
||||
updateItem('sites', queue.site, {
|
||||
factory_status: isComplete ? 'publishing' : 'generating'
|
||||
})
|
||||
);
|
||||
|
||||
// Log work
|
||||
await directus.request(
|
||||
createItem('work_log', {
|
||||
site: queue.site,
|
||||
action: 'batch_generated',
|
||||
entity_type: 'production_queue',
|
||||
entity_id: queue_id,
|
||||
details: {
|
||||
generated,
|
||||
errors: errors.length,
|
||||
progress: `${newCompleted}/${scheduleData.length}`
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
generated,
|
||||
errors: errors.length,
|
||||
progress: {
|
||||
completed: newCompleted,
|
||||
total: scheduleData.length,
|
||||
percent: Math.round((newCompleted / scheduleData.length) * 100)
|
||||
},
|
||||
status: isComplete ? 'done' : 'running',
|
||||
next_step: isComplete
|
||||
? 'Queue complete! Run sitemap-drip cron to start indexing.'
|
||||
: 'Call process-queue again to continue.'
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error processing queue:', error);
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Failed to process queue' }),
|
||||
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
async function assembleFromModules(
|
||||
directus: any,
|
||||
siteId: string,
|
||||
recipe: string[],
|
||||
context: any,
|
||||
pubDate: Date
|
||||
): Promise<{ content: string; modulesUsed: string[] }> {
|
||||
const parts: string[] = [];
|
||||
const modulesUsed: string[] = [];
|
||||
|
||||
for (const moduleType of recipe) {
|
||||
const modules = await directus.request(readItems('content_modules', {
|
||||
filter: {
|
||||
site: { _eq: siteId },
|
||||
module_type: { _eq: moduleType },
|
||||
is_active: { _eq: true }
|
||||
},
|
||||
sort: ['usage_count'],
|
||||
limit: 1
|
||||
})) as any[];
|
||||
|
||||
if (modules.length > 0) {
|
||||
const mod = modules[0];
|
||||
let content = mod.content_spintax || '';
|
||||
|
||||
// Replace tokens
|
||||
content = content
|
||||
.replace(/\{City\}/gi, context.city)
|
||||
.replace(/\{State\}/gi, context.state)
|
||||
.replace(/\{County\}/gi, context.county)
|
||||
.replace(/\{State_Code\}/gi, context.state_code);
|
||||
|
||||
content = replaceYearTokens(content, pubDate);
|
||||
content = processSpintax(content);
|
||||
|
||||
parts.push(content);
|
||||
modulesUsed.push(mod.id);
|
||||
|
||||
// Increment usage
|
||||
await directus.request(updateItem('content_modules', mod.id, {
|
||||
usage_count: (mod.usage_count || 0) + 1
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
return { content: parts.join('\n\n'), modulesUsed };
|
||||
}
|
||||
|
||||
function processSpintax(text: string): string {
|
||||
let result = text;
|
||||
let iterations = 100;
|
||||
while (result.includes('{') && iterations > 0) {
|
||||
result = result.replace(/\{([^{}]+)\}/g, (_, opts) => {
|
||||
const choices = opts.split('|');
|
||||
return choices[Math.floor(Math.random() * choices.length)];
|
||||
});
|
||||
iterations--;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function generateHeadline(template: string | null, context: any, date: Date): string {
|
||||
if (!template) return '';
|
||||
let h = template
|
||||
.replace(/\{City\}/gi, context.city)
|
||||
.replace(/\{State\}/gi, context.state);
|
||||
h = replaceYearTokens(h, date);
|
||||
return processSpintax(h);
|
||||
}
|
||||
|
||||
function getStateCode(state: string): string {
|
||||
const codes: Record<string, string> = {
|
||||
'Florida': 'FL', 'Texas': 'TX', 'California': 'CA', 'New York': 'NY',
|
||||
'Arizona': 'AZ', 'Nevada': 'NV', 'Georgia': 'GA', 'North Carolina': 'NC'
|
||||
};
|
||||
return codes[state] || state?.substring(0, 2).toUpperCase() || '';
|
||||
}
|
||||
198
src/pages/api/seo/publish-article.ts
Normal file
198
src/pages/api/seo/publish-article.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
// @ts-ignore - Astro types available at build time
|
||||
import type { APIRoute } from 'astro';
|
||||
import { getDirectusClient, readItem, readItems, createItem, updateItem } from '@/lib/directus/client';
|
||||
import { generateFeaturedImage } from '@/lib/seo/image-generator';
|
||||
|
||||
/**
|
||||
* Publish Article to Site API
|
||||
*
|
||||
* Takes a generated article from the SEO engine and creates a post on the target site.
|
||||
*
|
||||
* POST /api/seo/publish-article
|
||||
*/
|
||||
export const POST: APIRoute = async ({ request }: { request: Request }) => {
|
||||
try {
|
||||
const data = await request.json();
|
||||
const { article_id, site_id, status = 'draft' } = data;
|
||||
|
||||
if (!article_id) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'article_id is required' }),
|
||||
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
|
||||
const directus = getDirectusClient();
|
||||
|
||||
// Get the generated article
|
||||
const article = await directus.request(
|
||||
readItem('generated_articles', article_id)
|
||||
) as any;
|
||||
|
||||
if (!article) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Article not found' }),
|
||||
{ status: 404, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if already published
|
||||
if (article.published_to_post) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: 'Article already published',
|
||||
post_id: article.published_to_post
|
||||
}),
|
||||
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
|
||||
// Use provided site_id or fall back to article's site
|
||||
const targetSiteId = site_id || article.site;
|
||||
|
||||
// Generate slug from headline
|
||||
const slug = article.headline
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s-]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.substring(0, 100);
|
||||
|
||||
// Create the post
|
||||
const post = await directus.request(
|
||||
createItem('posts', {
|
||||
site: targetSiteId,
|
||||
title: article.headline,
|
||||
slug: slug,
|
||||
status: status, // 'draft' or 'published'
|
||||
content: article.full_html_body,
|
||||
excerpt: article.meta_description,
|
||||
meta_title: article.meta_title,
|
||||
meta_description: article.meta_description,
|
||||
featured_image_alt: article.featured_image_alt,
|
||||
source: 'seo_engine',
|
||||
source_article_id: article_id,
|
||||
robots: 'index,follow',
|
||||
schema_type: 'BlogPosting',
|
||||
// Location data for local SEO
|
||||
meta_keywords: [
|
||||
article.location_city,
|
||||
article.location_state
|
||||
].filter(Boolean).join(', ')
|
||||
})
|
||||
) as any;
|
||||
|
||||
// Update the generated article with publish info
|
||||
await directus.request(
|
||||
updateItem('generated_articles', article_id, {
|
||||
publish_status: status === 'published' ? 'published' : 'ready',
|
||||
published_to_post: post.id,
|
||||
published_at: new Date().toISOString(),
|
||||
published_url: `/${slug}`
|
||||
})
|
||||
);
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
post_id: post.id,
|
||||
slug: slug,
|
||||
status: status,
|
||||
message: `Article published as ${status} post`
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error publishing article:', error);
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Failed to publish article' }),
|
||||
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Bulk publish multiple articles
|
||||
*
|
||||
* POST /api/seo/publish-article with { article_ids: [...] }
|
||||
*/
|
||||
export const PUT: APIRoute = async ({ request }: { request: Request }) => {
|
||||
try {
|
||||
const data = await request.json();
|
||||
const { article_ids, site_id, status = 'draft' } = data;
|
||||
|
||||
if (!article_ids || !Array.isArray(article_ids) || article_ids.length === 0) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'article_ids array is required' }),
|
||||
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
|
||||
const directus = getDirectusClient();
|
||||
const results: { article_id: string; post_id?: string; error?: string }[] = [];
|
||||
|
||||
for (const articleId of article_ids) {
|
||||
try {
|
||||
const article = await directus.request(
|
||||
readItem('generated_articles', articleId)
|
||||
) as any;
|
||||
|
||||
if (!article || article.published_to_post) {
|
||||
results.push({ article_id: articleId, error: 'Already published or not found' });
|
||||
continue;
|
||||
}
|
||||
|
||||
const targetSiteId = site_id || article.site;
|
||||
const slug = article.headline
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s-]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.substring(0, 100);
|
||||
|
||||
const post = await directus.request(
|
||||
createItem('posts', {
|
||||
site: targetSiteId,
|
||||
title: article.headline,
|
||||
slug: slug,
|
||||
status: status,
|
||||
content: article.full_html_body,
|
||||
excerpt: article.meta_description,
|
||||
meta_title: article.meta_title,
|
||||
meta_description: article.meta_description,
|
||||
source: 'seo_engine',
|
||||
source_article_id: articleId
|
||||
})
|
||||
) as any;
|
||||
|
||||
await directus.request(
|
||||
updateItem('generated_articles', articleId, {
|
||||
publish_status: 'published',
|
||||
published_to_post: post.id,
|
||||
published_at: new Date().toISOString()
|
||||
})
|
||||
);
|
||||
|
||||
results.push({ article_id: articleId, post_id: post.id });
|
||||
} catch (err) {
|
||||
results.push({ article_id: articleId, error: 'Failed to publish' });
|
||||
}
|
||||
}
|
||||
|
||||
const successCount = results.filter(r => r.post_id).length;
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
published: successCount,
|
||||
total: article_ids.length,
|
||||
results
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error bulk publishing:', error);
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Failed to bulk publish' }),
|
||||
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
};
|
||||
170
src/pages/api/seo/scan-duplicates.ts
Normal file
170
src/pages/api/seo/scan-duplicates.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
// @ts-ignore - Astro types available at build time
|
||||
import type { APIRoute } from 'astro';
|
||||
import { getDirectusClient, readItems, createItem } from '@/lib/directus/client';
|
||||
|
||||
/**
|
||||
* Scan Duplicates API
|
||||
*
|
||||
* Uses shingle hashing to detect duplicate N-gram sequences across articles.
|
||||
* Flags any articles that share 7+ word sequences.
|
||||
*
|
||||
* POST /api/seo/scan-duplicates
|
||||
*/
|
||||
export const POST: APIRoute = async ({ request }: { request: Request }) => {
|
||||
try {
|
||||
const data = await request.json();
|
||||
const { queue_id, batch_ids, ngram_size = 7, threshold = 3 } = data;
|
||||
|
||||
if (!queue_id && !batch_ids) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'queue_id or batch_ids required' }),
|
||||
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
|
||||
const directus = getDirectusClient();
|
||||
|
||||
// Get articles to scan
|
||||
let articles: any[];
|
||||
if (batch_ids && Array.isArray(batch_ids)) {
|
||||
articles = await directus.request(readItems('generated_articles', {
|
||||
filter: { id: { _in: batch_ids } },
|
||||
fields: ['id', 'site', 'headline', 'full_html_body']
|
||||
})) as any[];
|
||||
} else {
|
||||
// Get test batch articles from queue
|
||||
articles = await directus.request(readItems('generated_articles', {
|
||||
filter: { is_test_batch: { _eq: true } },
|
||||
sort: ['-date_created'],
|
||||
limit: 20,
|
||||
fields: ['id', 'site', 'headline', 'full_html_body']
|
||||
})) as any[];
|
||||
}
|
||||
|
||||
if (articles.length < 2) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
message: 'Need at least 2 articles to compare',
|
||||
flags_created: 0
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
|
||||
// Build shingle sets for each article
|
||||
const articleShingles: Map<string, Set<string>> = new Map();
|
||||
|
||||
for (const article of articles) {
|
||||
const text = stripHtml(article.full_html_body || '');
|
||||
const shingles = generateShingles(text, ngram_size);
|
||||
articleShingles.set(article.id, shingles);
|
||||
}
|
||||
|
||||
// Compare all pairs
|
||||
const collisions: Array<{
|
||||
articleA: string;
|
||||
articleB: string;
|
||||
sharedShingles: string[];
|
||||
similarity: number;
|
||||
}> = [];
|
||||
|
||||
const articleIds = Array.from(articleShingles.keys());
|
||||
|
||||
for (let i = 0; i < articleIds.length; i++) {
|
||||
for (let j = i + 1; j < articleIds.length; j++) {
|
||||
const idA = articleIds[i];
|
||||
const idB = articleIds[j];
|
||||
const setA = articleShingles.get(idA)!;
|
||||
const setB = articleShingles.get(idB)!;
|
||||
|
||||
// Find intersection
|
||||
const shared = [...setA].filter(s => setB.has(s));
|
||||
|
||||
if (shared.length >= threshold) {
|
||||
// Calculate Jaccard similarity
|
||||
const union = new Set([...setA, ...setB]);
|
||||
const similarity = (shared.length / union.size) * 100;
|
||||
|
||||
collisions.push({
|
||||
articleA: idA,
|
||||
articleB: idB,
|
||||
sharedShingles: shared.slice(0, 5), // Just first 5 examples
|
||||
similarity
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create quality flags for collisions
|
||||
const siteId = articles[0]?.site;
|
||||
let flagsCreated = 0;
|
||||
|
||||
for (const collision of collisions) {
|
||||
await directus.request(
|
||||
createItem('quality_flags', {
|
||||
site: siteId,
|
||||
batch_id: queue_id || null,
|
||||
article_a: collision.articleA,
|
||||
article_b: collision.articleB,
|
||||
collision_text: collision.sharedShingles.join(' | '),
|
||||
similarity_score: collision.similarity,
|
||||
status: 'pending'
|
||||
})
|
||||
);
|
||||
flagsCreated++;
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
articles_scanned: articles.length,
|
||||
collisions_found: collisions.length,
|
||||
flags_created: flagsCreated,
|
||||
details: collisions.map(c => ({
|
||||
article_a: c.articleA,
|
||||
article_b: c.articleB,
|
||||
similarity: c.similarity.toFixed(1) + '%',
|
||||
examples: c.sharedShingles
|
||||
}))
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error scanning duplicates:', error);
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Failed to scan duplicates' }),
|
||||
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Strip HTML tags and normalize text
|
||||
*/
|
||||
function stripHtml(html: string): string {
|
||||
return html
|
||||
.replace(/<[^>]*>/g, ' ')
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate N-gram shingles from text
|
||||
*/
|
||||
function generateShingles(text: string, n: number): Set<string> {
|
||||
const words = text.split(/\s+/).filter(w => w.length > 2);
|
||||
const shingles = new Set<string>();
|
||||
|
||||
for (let i = 0; i <= words.length - n; i++) {
|
||||
const shingle = words.slice(i, i + n).join(' ');
|
||||
shingles.add(shingle);
|
||||
}
|
||||
|
||||
return shingles;
|
||||
}
|
||||
176
src/pages/api/seo/schedule-production.ts
Normal file
176
src/pages/api/seo/schedule-production.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
// @ts-ignore - Astro types available at build time
|
||||
import type { APIRoute } from 'astro';
|
||||
import { getDirectusClient, readItem, createItem, updateItem } from '@/lib/directus/client';
|
||||
import {
|
||||
generateNaturalSchedule,
|
||||
getMaxBackdateStart,
|
||||
type VelocityConfig
|
||||
} from '@/lib/seo/velocity-scheduler';
|
||||
|
||||
/**
|
||||
* Schedule Production API
|
||||
*
|
||||
* Generates a natural velocity schedule for article production.
|
||||
* Uses Gaussian distribution with weekend throttling and time jitter.
|
||||
*
|
||||
* POST /api/seo/schedule-production
|
||||
*/
|
||||
export const POST: APIRoute = async ({ request }: { request: Request }) => {
|
||||
try {
|
||||
const data = await request.json();
|
||||
const {
|
||||
campaign_id,
|
||||
site_id,
|
||||
total_articles,
|
||||
date_range,
|
||||
velocity,
|
||||
test_batch_first = true
|
||||
} = data;
|
||||
|
||||
if (!campaign_id || !total_articles) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'campaign_id and total_articles are required' }),
|
||||
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
|
||||
const directus = getDirectusClient();
|
||||
|
||||
// Get campaign details
|
||||
const campaign = await directus.request(
|
||||
readItem('campaign_masters', campaign_id)
|
||||
) as any;
|
||||
|
||||
if (!campaign) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Campaign not found' }),
|
||||
{ status: 404, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
|
||||
const targetSiteId = site_id || campaign.site;
|
||||
|
||||
// Get site to check domain age
|
||||
const site = await directus.request(
|
||||
readItem('sites', targetSiteId)
|
||||
) as any;
|
||||
|
||||
const domainAgeYears = site?.domain_age_years || 1;
|
||||
|
||||
// Parse dates
|
||||
let startDate: Date;
|
||||
let endDate: Date = new Date();
|
||||
|
||||
if (date_range?.start) {
|
||||
startDate = new Date(date_range.start);
|
||||
} else if (campaign.backdate_start) {
|
||||
startDate = new Date(campaign.backdate_start);
|
||||
} else {
|
||||
// Default: use domain age to determine max backdate
|
||||
startDate = getMaxBackdateStart(domainAgeYears);
|
||||
}
|
||||
|
||||
if (date_range?.end) {
|
||||
endDate = new Date(date_range.end);
|
||||
} else if (campaign.backdate_end) {
|
||||
endDate = new Date(campaign.backdate_end);
|
||||
}
|
||||
|
||||
// Validate startDate isn't before domain existed
|
||||
const maxBackdate = getMaxBackdateStart(domainAgeYears);
|
||||
if (startDate < maxBackdate) {
|
||||
startDate = maxBackdate;
|
||||
}
|
||||
|
||||
// Build velocity config
|
||||
const velocityConfig: VelocityConfig = {
|
||||
mode: velocity?.mode || campaign.velocity_mode || 'RAMP_UP',
|
||||
weekendThrottle: velocity?.weekend_throttle ?? campaign.weekend_throttle ?? true,
|
||||
jitterMinutes: velocity?.jitter_minutes ?? campaign.time_jitter_minutes ?? 120,
|
||||
businessHoursOnly: velocity?.business_hours ?? campaign.business_hours_only ?? true
|
||||
};
|
||||
|
||||
// Generate schedule
|
||||
const schedule = generateNaturalSchedule(
|
||||
startDate,
|
||||
endDate,
|
||||
total_articles,
|
||||
velocityConfig
|
||||
);
|
||||
|
||||
// Create production queue entry
|
||||
const queueEntry = await directus.request(
|
||||
createItem('production_queue', {
|
||||
site: targetSiteId,
|
||||
campaign: campaign_id,
|
||||
status: test_batch_first ? 'test_batch' : 'pending',
|
||||
total_requested: total_articles,
|
||||
completed_count: 0,
|
||||
velocity_mode: velocityConfig.mode,
|
||||
schedule_data: schedule.map(s => ({
|
||||
publish_date: s.publishDate.toISOString(),
|
||||
modified_date: s.modifiedDate.toISOString()
|
||||
}))
|
||||
})
|
||||
) as any;
|
||||
|
||||
// Update campaign with test batch status if applicable
|
||||
if (test_batch_first) {
|
||||
await directus.request(
|
||||
updateItem('campaign_masters', campaign_id, {
|
||||
test_batch_status: 'pending'
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Log work
|
||||
await directus.request(
|
||||
createItem('work_log', {
|
||||
site: targetSiteId,
|
||||
action: 'schedule_created',
|
||||
entity_type: 'production_queue',
|
||||
entity_id: queueEntry.id,
|
||||
details: {
|
||||
total_articles,
|
||||
start_date: startDate.toISOString(),
|
||||
end_date: endDate.toISOString(),
|
||||
velocity_mode: velocityConfig.mode,
|
||||
test_batch_first
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Return summary
|
||||
const dateDistribution: Record<string, number> = {};
|
||||
schedule.forEach(s => {
|
||||
const key = s.publishDate.toISOString().split('T')[0];
|
||||
dateDistribution[key] = (dateDistribution[key] || 0) + 1;
|
||||
});
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
queue_id: queueEntry.id,
|
||||
total_scheduled: schedule.length,
|
||||
date_range: {
|
||||
start: startDate.toISOString(),
|
||||
end: endDate.toISOString()
|
||||
},
|
||||
velocity: velocityConfig,
|
||||
next_step: test_batch_first
|
||||
? 'Call /api/seo/generate-test-batch to create review batch'
|
||||
: 'Call /api/seo/process-queue to start generation',
|
||||
sample_distribution: Object.entries(dateDistribution)
|
||||
.slice(0, 10)
|
||||
.reduce((acc, [k, v]) => ({ ...acc, [k]: v }), {})
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error scheduling production:', error);
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Failed to schedule production' }),
|
||||
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
};
|
||||
126
src/pages/api/seo/sitemap-drip.ts
Normal file
126
src/pages/api/seo/sitemap-drip.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
// @ts-ignore - Astro types available at build time
|
||||
import type { APIRoute } from 'astro';
|
||||
import { getDirectusClient, readItems, updateItem, createItem } from '@/lib/directus/client';
|
||||
|
||||
/**
|
||||
* Sitemap Drip API (Cron Job)
|
||||
*
|
||||
* Processes ghost articles and adds them to sitemap at controlled rate.
|
||||
* Should be called daily by a cron job.
|
||||
*
|
||||
* GET /api/seo/sitemap-drip?site_id={id}
|
||||
*/
|
||||
export const GET: APIRoute = async ({ url }: { url: URL }) => {
|
||||
try {
|
||||
const siteId = url.searchParams.get('site_id');
|
||||
|
||||
if (!siteId) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'site_id is required' }),
|
||||
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
|
||||
const directus = getDirectusClient();
|
||||
|
||||
// Get site drip rate
|
||||
const sites = await directus.request(readItems('sites', {
|
||||
filter: { id: { _eq: siteId } },
|
||||
limit: 1
|
||||
})) as any[];
|
||||
|
||||
const site = sites[0];
|
||||
const dripRate = site?.sitemap_drip_rate || 50;
|
||||
|
||||
// Get ghost articles sorted by priority (hubs first) then date
|
||||
const ghostArticles = await directus.request(readItems('generated_articles', {
|
||||
filter: {
|
||||
site: { _eq: siteId },
|
||||
sitemap_status: { _eq: 'ghost' },
|
||||
is_published: { _eq: true }
|
||||
},
|
||||
sort: ['-date_published'], // Newest first within ghosts
|
||||
limit: dripRate
|
||||
})) as any[];
|
||||
|
||||
if (ghostArticles.length === 0) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
message: 'No ghost articles to index',
|
||||
indexed: 0
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
|
||||
// Update to indexed
|
||||
const indexedIds: string[] = [];
|
||||
for (const article of ghostArticles) {
|
||||
await directus.request(
|
||||
updateItem('generated_articles', article.id, {
|
||||
sitemap_status: 'indexed'
|
||||
})
|
||||
);
|
||||
indexedIds.push(article.id);
|
||||
}
|
||||
|
||||
// Also check hub pages
|
||||
const ghostHubs = await directus.request(readItems('hub_pages', {
|
||||
filter: {
|
||||
site: { _eq: siteId },
|
||||
sitemap_status: { _eq: 'ghost' }
|
||||
},
|
||||
limit: 10 // Hubs get priority
|
||||
})) as any[];
|
||||
|
||||
for (const hub of ghostHubs) {
|
||||
await directus.request(
|
||||
updateItem('hub_pages', hub.id, {
|
||||
sitemap_status: 'indexed'
|
||||
})
|
||||
);
|
||||
indexedIds.push(hub.id);
|
||||
}
|
||||
|
||||
// Log work
|
||||
await directus.request(
|
||||
createItem('work_log', {
|
||||
site: siteId,
|
||||
action: 'sitemap_drip',
|
||||
entity_type: 'batch',
|
||||
entity_id: null,
|
||||
details: {
|
||||
articles_indexed: ghostArticles.length,
|
||||
hubs_indexed: ghostHubs.length,
|
||||
ids: indexedIds
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Update site factory status
|
||||
await directus.request(
|
||||
updateItem('sites', siteId, {
|
||||
factory_status: 'dripping'
|
||||
})
|
||||
);
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
articles_indexed: ghostArticles.length,
|
||||
hubs_indexed: ghostHubs.length,
|
||||
total_indexed: indexedIds.length,
|
||||
drip_rate: dripRate,
|
||||
message: `Added ${indexedIds.length} URLs to sitemap`
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error in sitemap drip:', error);
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Failed to process sitemap drip' }),
|
||||
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
};
|
||||
59
src/pages/api/seo/stats.ts
Normal file
59
src/pages/api/seo/stats.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
// @ts-ignore
|
||||
import type { APIRoute } from 'astro';
|
||||
import { getDirectusClient, readItems, readItem } from '@/lib/directus/client';
|
||||
|
||||
/**
|
||||
* SEO Stats API
|
||||
* Returns article counts by status for dashboard KPIs.
|
||||
*
|
||||
* GET /api/seo/stats?site_id={id}
|
||||
*/
|
||||
export const GET: APIRoute = async ({ url }: { url: URL }) => {
|
||||
try {
|
||||
const siteId = url.searchParams.get('site_id');
|
||||
const directus = getDirectusClient();
|
||||
|
||||
// Build filter
|
||||
const filter: any = {};
|
||||
if (siteId) {
|
||||
filter.site = { _eq: siteId };
|
||||
}
|
||||
|
||||
// Get all articles
|
||||
const articles = await directus.request(readItems('generated_articles', {
|
||||
filter,
|
||||
fields: ['id', 'sitemap_status', 'is_published'],
|
||||
limit: -1
|
||||
})) as any[];
|
||||
|
||||
const total = articles.length;
|
||||
const ghost = articles.filter(a => a.sitemap_status === 'ghost').length;
|
||||
const indexed = articles.filter(a => a.sitemap_status === 'indexed').length;
|
||||
const queued = articles.filter(a => a.sitemap_status === 'queued').length;
|
||||
const published = articles.filter(a => a.is_published).length;
|
||||
const draft = articles.filter(a => !a.is_published).length;
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
total,
|
||||
ghost,
|
||||
indexed,
|
||||
queued,
|
||||
published,
|
||||
draft,
|
||||
breakdown: {
|
||||
sitemap: { ghost, indexed, queued },
|
||||
publish: { published, draft }
|
||||
}
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error getting stats:', error);
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Failed to get stats', total: 0, ghost: 0, indexed: 0 }),
|
||||
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
};
|
||||
22
src/pages/api/system/health.ts
Normal file
22
src/pages/api/system/health.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
|
||||
import type { APIRoute } from 'astro';
|
||||
|
||||
export const GET: APIRoute = async () => {
|
||||
// This is a minimal endpoint to verify the frontend server itself is running.
|
||||
// In a real health check, you might also ping the database or external services here
|
||||
// and return a composite status (e.g. { frontend: 'ok', db: 'ok', directus: 'ok' })
|
||||
|
||||
const healthStatus = {
|
||||
status: 'ok',
|
||||
service: 'spark-frontend',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: process.uptime()
|
||||
};
|
||||
|
||||
return new Response(JSON.stringify(healthStatus), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
});
|
||||
};
|
||||
0
src/pages/api/testing/check-links.ts
Normal file
0
src/pages/api/testing/check-links.ts
Normal file
0
src/pages/api/testing/detect-duplicates.ts
Normal file
0
src/pages/api/testing/detect-duplicates.ts
Normal file
0
src/pages/api/testing/validate-seo.ts
Normal file
0
src/pages/api/testing/validate-seo.ts
Normal file
367
src/pages/index.astro
Normal file
367
src/pages/index.astro
Normal file
@@ -0,0 +1,367 @@
|
||||
---
|
||||
/**
|
||||
* 🔱 GOD PANEL - System Diagnostics Dashboard
|
||||
*
|
||||
* This page is COMPLETELY STANDALONE:
|
||||
* - No middleware
|
||||
* - No Directus dependency
|
||||
* - No redirects
|
||||
* - Works even when everything else is broken
|
||||
*/
|
||||
export const prerender = false;
|
||||
---
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>🔱 🔱 Valhalla - Spark God Mode</title>
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
god: {
|
||||
gold: '#FFD700',
|
||||
dark: '#0a0a0a',
|
||||
card: '#111111',
|
||||
border: '#333333'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
@keyframes pulse-gold {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(255, 215, 0, 0.4); }
|
||||
50% { box-shadow: 0 0 20px 10px rgba(255, 215, 0, 0.1); }
|
||||
}
|
||||
.pulse-gold { animation: pulse-gold 2s infinite; }
|
||||
.status-healthy { color: #22c55e; }
|
||||
.status-unhealthy { color: #ef4444; }
|
||||
.status-warning { color: #eab308; }
|
||||
pre { white-space: pre-wrap; word-wrap: break-word; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-god-dark text-white min-h-screen">
|
||||
<div id="god-panel"></div>
|
||||
|
||||
<script type="module">
|
||||
import React from 'https://esm.sh/react@18';
|
||||
import ReactDOM from 'https://esm.sh/react-dom@18/client';
|
||||
|
||||
const { useState, useEffect, useCallback } = React;
|
||||
const h = React.createElement;
|
||||
|
||||
// API Helper
|
||||
const api = {
|
||||
async get(endpoint) {
|
||||
const token = localStorage.getItem('godToken') || '';
|
||||
const res = await fetch(`/api/god/${endpoint}`, {
|
||||
headers: { 'X-God-Token': token }
|
||||
});
|
||||
return res.json();
|
||||
},
|
||||
async post(endpoint, data) {
|
||||
const token = localStorage.getItem('godToken') || '';
|
||||
const res = await fetch(`/api/god/${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-God-Token': token
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
};
|
||||
|
||||
// Status Badge Component
|
||||
function StatusBadge({ status }) {
|
||||
const isHealthy = status?.includes('✅') || status === 'healthy' || status === 'connected';
|
||||
const isWarning = status?.includes('⚠️');
|
||||
const className = isHealthy ? 'status-healthy' : isWarning ? 'status-warning' : 'status-unhealthy';
|
||||
return h('span', { className: `font-bold ${className}` }, status || 'Unknown');
|
||||
}
|
||||
|
||||
// Service Card Component
|
||||
function ServiceCard({ name, data, icon }) {
|
||||
const status = data?.status || 'Unknown';
|
||||
const isHealthy = status?.includes('✅') || status?.includes('healthy');
|
||||
|
||||
return h('div', {
|
||||
className: `bg-god-card border border-god-border rounded-xl p-4 ${isHealthy ? '' : 'border-red-500/50'}`
|
||||
}, [
|
||||
h('div', { className: 'flex items-center justify-between mb-2' }, [
|
||||
h('div', { className: 'flex items-center gap-2' }, [
|
||||
h('span', { className: 'text-2xl' }, icon),
|
||||
h('span', { className: 'font-semibold text-lg' }, name)
|
||||
]),
|
||||
h(StatusBadge, { status })
|
||||
]),
|
||||
data?.latency_ms && h('div', { className: 'text-sm text-gray-400' },
|
||||
`Latency: ${data.latency_ms}ms`
|
||||
),
|
||||
data?.error && h('div', { className: 'text-sm text-red-400 mt-1' },
|
||||
`Error: ${data.error}`
|
||||
)
|
||||
]);
|
||||
}
|
||||
|
||||
// SQL Console Component
|
||||
function SQLConsole() {
|
||||
const [query, setQuery] = useState('SELECT * FROM sites LIMIT 5;');
|
||||
const [result, setResult] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const execute = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await api.post('sql', { query });
|
||||
setResult(data);
|
||||
} catch (err) {
|
||||
setResult({ error: err.message });
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return h('div', { className: 'bg-god-card border border-god-border rounded-xl p-4' }, [
|
||||
h('h3', { className: 'text-lg font-semibold mb-3 flex items-center gap-2' }, [
|
||||
'🗄️ SQL Console'
|
||||
]),
|
||||
h('textarea', {
|
||||
className: 'w-full bg-black border border-god-border rounded-lg p-3 font-mono text-sm text-green-400 mb-3',
|
||||
rows: 4,
|
||||
value: query,
|
||||
onChange: e => setQuery(e.target.value),
|
||||
placeholder: 'Enter SQL query...'
|
||||
}),
|
||||
h('button', {
|
||||
className: 'bg-god-gold text-black font-bold px-4 py-2 rounded-lg hover:bg-yellow-400 disabled:opacity-50',
|
||||
onClick: execute,
|
||||
disabled: loading
|
||||
}, loading ? 'Executing...' : 'Execute SQL'),
|
||||
result && h('div', { className: 'mt-4' }, [
|
||||
result.error ?
|
||||
h('div', { className: 'text-red-400 font-mono text-sm' }, `Error: ${result.error}`) :
|
||||
h('div', {}, [
|
||||
h('div', { className: 'text-sm text-gray-400 mb-2' },
|
||||
`${result.rowCount || 0} rows returned`
|
||||
),
|
||||
h('pre', {
|
||||
className: 'bg-black rounded-lg p-3 overflow-auto max-h-64 text-xs font-mono text-gray-300'
|
||||
}, JSON.stringify(result.rows, null, 2))
|
||||
])
|
||||
])
|
||||
]);
|
||||
}
|
||||
|
||||
// Tables List Component
|
||||
function TablesList({ tables }) {
|
||||
if (!tables?.tables) return null;
|
||||
|
||||
const customTables = tables.tables.filter(t => !t.name.startsWith('directus_'));
|
||||
const systemTables = tables.tables.filter(t => t.name.startsWith('directus_'));
|
||||
|
||||
return h('div', { className: 'bg-god-card border border-god-border rounded-xl p-4' }, [
|
||||
h('h3', { className: 'text-lg font-semibold mb-3' },
|
||||
`📊 Database Tables (${tables.total})`
|
||||
),
|
||||
h('div', { className: 'grid grid-cols-2 gap-4' }, [
|
||||
h('div', {}, [
|
||||
h('h4', { className: 'text-sm font-semibold text-god-gold mb-2' },
|
||||
`Custom Tables (${customTables.length})`
|
||||
),
|
||||
h('div', { className: 'space-y-1 max-h-48 overflow-auto' },
|
||||
customTables.map(t =>
|
||||
h('div', {
|
||||
key: t.name,
|
||||
className: 'text-xs font-mono flex justify-between bg-black/50 px-2 py-1 rounded'
|
||||
}, [
|
||||
h('span', {}, t.name),
|
||||
h('span', { className: 'text-gray-500' }, `${t.rows} rows`)
|
||||
])
|
||||
)
|
||||
)
|
||||
]),
|
||||
h('div', {}, [
|
||||
h('h4', { className: 'text-sm font-semibold text-gray-400 mb-2' },
|
||||
`Directus System (${systemTables.length})`
|
||||
),
|
||||
h('div', { className: 'text-xs text-gray-500' },
|
||||
systemTables.length + ' system tables'
|
||||
)
|
||||
])
|
||||
])
|
||||
]);
|
||||
}
|
||||
|
||||
// Quick Actions Component
|
||||
function QuickActions() {
|
||||
const actions = [
|
||||
{ label: 'Check Sites', query: 'SELECT id, name, url, status FROM sites LIMIT 10' },
|
||||
{ label: 'Count Articles', query: 'SELECT COUNT(*) as count FROM generated_articles' },
|
||||
{ label: 'Active Connections', query: 'SELECT count(*) FROM pg_stat_activity' },
|
||||
{ label: 'DB Size', query: "SELECT pg_size_pretty(pg_database_size(current_database())) as size" },
|
||||
];
|
||||
|
||||
const [result, setResult] = useState(null);
|
||||
|
||||
const run = async (query) => {
|
||||
const data = await api.post('sql', { query });
|
||||
setResult(data);
|
||||
};
|
||||
|
||||
return h('div', { className: 'bg-god-card border border-god-border rounded-xl p-4' }, [
|
||||
h('h3', { className: 'text-lg font-semibold mb-3' }, '⚡ Quick Actions'),
|
||||
h('div', { className: 'flex flex-wrap gap-2' },
|
||||
actions.map(a =>
|
||||
h('button', {
|
||||
key: a.label,
|
||||
className: 'bg-god-border hover:bg-god-gold hover:text-black px-3 py-1 rounded text-sm transition-colors',
|
||||
onClick: () => run(a.query)
|
||||
}, a.label)
|
||||
)
|
||||
),
|
||||
result && h('pre', {
|
||||
className: 'mt-3 bg-black rounded-lg p-3 text-xs font-mono text-gray-300 overflow-auto max-h-32'
|
||||
}, JSON.stringify(result.rows || result, null, 2))
|
||||
]);
|
||||
}
|
||||
|
||||
// Main God Panel Component
|
||||
function GodPanel() {
|
||||
const [services, setServices] = useState(null);
|
||||
const [health, setHealth] = useState(null);
|
||||
const [tables, setTables] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [autoRefresh, setAutoRefresh] = useState(false);
|
||||
const [lastUpdate, setLastUpdate] = useState(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const [svc, hlth, tbl] = await Promise.all([
|
||||
api.get('services'),
|
||||
api.get('health'),
|
||||
api.get('tables')
|
||||
]);
|
||||
setServices(svc);
|
||||
setHealth(hlth);
|
||||
setTables(tbl);
|
||||
setLastUpdate(new Date().toLocaleTimeString());
|
||||
} catch (err) {
|
||||
console.error('Refresh failed:', err);
|
||||
}
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
if (autoRefresh) {
|
||||
const interval = setInterval(refresh, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [refresh, autoRefresh]);
|
||||
|
||||
return h('div', { className: 'max-w-6xl mx-auto p-6' }, [
|
||||
// Header
|
||||
h('div', { className: 'flex items-center justify-between mb-8' }, [
|
||||
h('div', {}, [
|
||||
h('h1', { className: 'text-3xl font-bold flex items-center gap-3' }, [
|
||||
h('span', { className: 'text-god-gold pulse-gold inline-block' }, '🔱'),
|
||||
'God Panel'
|
||||
]),
|
||||
h('p', { className: 'text-gray-400 mt-1' },
|
||||
'System Diagnostics & Emergency Access'
|
||||
)
|
||||
]),
|
||||
h('div', { className: 'flex items-center gap-4' }, [
|
||||
h('label', { className: 'flex items-center gap-2 text-sm' }, [
|
||||
h('input', {
|
||||
type: 'checkbox',
|
||||
checked: autoRefresh,
|
||||
onChange: e => setAutoRefresh(e.target.checked),
|
||||
className: 'rounded'
|
||||
}),
|
||||
'Auto-refresh (5s)'
|
||||
]),
|
||||
h('button', {
|
||||
className: 'bg-god-gold text-black font-bold px-4 py-2 rounded-lg hover:bg-yellow-400',
|
||||
onClick: refresh
|
||||
}, '🔄 Refresh'),
|
||||
lastUpdate && h('span', { className: 'text-xs text-gray-500' },
|
||||
`Last: ${lastUpdate}`
|
||||
)
|
||||
])
|
||||
]),
|
||||
|
||||
// Summary Banner
|
||||
services?.summary && h('div', {
|
||||
className: `rounded-xl p-4 mb-6 text-center font-bold text-lg ${
|
||||
services.summary.includes('✅') ? 'bg-green-900/30 border border-green-500/50' :
|
||||
'bg-red-900/30 border border-red-500/50'
|
||||
}`
|
||||
}, services.summary),
|
||||
|
||||
// Service Grid
|
||||
h('div', { className: 'grid grid-cols-2 md:grid-cols-4 gap-4 mb-6' }, [
|
||||
h(ServiceCard, { name: 'Frontend', data: services?.frontend, icon: '🌐' }),
|
||||
h(ServiceCard, { name: 'PostgreSQL', data: services?.postgresql, icon: '🐘' }),
|
||||
h(ServiceCard, { name: 'Redis', data: services?.redis, icon: '🔴' }),
|
||||
h(ServiceCard, { name: 'Directus', data: services?.directus, icon: '📦' }),
|
||||
]),
|
||||
|
||||
// Memory & Performance
|
||||
health && h('div', { className: 'grid grid-cols-3 gap-4 mb-6' }, [
|
||||
h('div', { className: 'bg-god-card border border-god-border rounded-xl p-4 text-center' }, [
|
||||
h('div', { className: 'text-3xl font-bold text-god-gold' },
|
||||
health.uptime_seconds ? Math.round(health.uptime_seconds / 60) + 'm' : '-'
|
||||
),
|
||||
h('div', { className: 'text-sm text-gray-400' }, 'Uptime')
|
||||
]),
|
||||
h('div', { className: 'bg-god-card border border-god-border rounded-xl p-4 text-center' }, [
|
||||
h('div', { className: 'text-3xl font-bold text-god-gold' },
|
||||
health.memory?.heap_used_mb ? health.memory.heap_used_mb + 'MB' : '-'
|
||||
),
|
||||
h('div', { className: 'text-sm text-gray-400' }, 'Memory Used')
|
||||
]),
|
||||
h('div', { className: 'bg-god-card border border-god-border rounded-xl p-4 text-center' }, [
|
||||
h('div', { className: 'text-3xl font-bold text-god-gold' },
|
||||
health.total_latency_ms ? health.total_latency_ms + 'ms' : '-'
|
||||
),
|
||||
h('div', { className: 'text-sm text-gray-400' }, 'Health Check')
|
||||
])
|
||||
]),
|
||||
|
||||
// Main Content Grid
|
||||
h('div', { className: 'grid md:grid-cols-2 gap-6' }, [
|
||||
h(SQLConsole, {}),
|
||||
h('div', { className: 'space-y-6' }, [
|
||||
h(QuickActions, {}),
|
||||
h(TablesList, { tables })
|
||||
])
|
||||
]),
|
||||
|
||||
// Raw Health Data
|
||||
health && h('details', { className: 'mt-6' }, [
|
||||
h('summary', { className: 'cursor-pointer text-gray-400 hover:text-white' },
|
||||
'📋 Raw Health Data'
|
||||
),
|
||||
h('pre', {
|
||||
className: 'mt-2 bg-god-card border border-god-border rounded-xl p-4 text-xs font-mono overflow-auto max-h-64'
|
||||
}, JSON.stringify(health, null, 2))
|
||||
])
|
||||
]);
|
||||
}
|
||||
|
||||
// Render
|
||||
const root = ReactDOM.createRoot(document.getElementById('god-panel'));
|
||||
root.render(h(GodPanel));
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user