feat: Spark AI Factory SEO - Complete content factory with Gaussian scheduling, sitemap drip, auto-linking
This commit is contained in:
195
frontend/src/lib/seo/velocity-scheduler.ts
Normal file
195
frontend/src/lib/seo/velocity-scheduler.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* Gaussian Velocity Scheduler
|
||||
*
|
||||
* Distributes articles over a date range using natural velocity patterns
|
||||
* to simulate organic content growth and avoid spam footprints.
|
||||
*/
|
||||
|
||||
export type VelocityMode = 'RAMP_UP' | 'RANDOM_SPIKES' | 'STEADY';
|
||||
|
||||
export interface VelocityConfig {
|
||||
mode: VelocityMode;
|
||||
weekendThrottle: boolean;
|
||||
jitterMinutes: number;
|
||||
businessHoursOnly: boolean;
|
||||
}
|
||||
|
||||
export interface ScheduleEntry {
|
||||
publishDate: Date;
|
||||
modifiedDate: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a natural schedule for article publication
|
||||
*
|
||||
* @param startDate - Earliest backdate
|
||||
* @param endDate - Latest date (usually today)
|
||||
* @param totalArticles - Number of articles to schedule
|
||||
* @param config - Velocity configuration
|
||||
* @returns Array of scheduled dates
|
||||
*/
|
||||
export function generateNaturalSchedule(
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
totalArticles: number,
|
||||
config: VelocityConfig
|
||||
): ScheduleEntry[] {
|
||||
const now = new Date();
|
||||
const totalDays = Math.floor((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (totalDays <= 0 || totalArticles <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Build probability weights for each day
|
||||
const dayWeights: { date: Date; weight: number }[] = [];
|
||||
|
||||
for (let dayOffset = 0; dayOffset < totalDays; dayOffset++) {
|
||||
const currentDate = new Date(startDate);
|
||||
currentDate.setDate(currentDate.getDate() + dayOffset);
|
||||
|
||||
const dayOfWeek = currentDate.getDay();
|
||||
const isWeekend = dayOfWeek === 0 || dayOfWeek === 6;
|
||||
|
||||
let weight = 1.0;
|
||||
|
||||
// Apply velocity mode
|
||||
switch (config.mode) {
|
||||
case 'RAMP_UP':
|
||||
// Weight grows from 0.2 (20% volume) to 1.0 (100% volume)
|
||||
const progress = dayOffset / totalDays;
|
||||
weight = 0.2 + (0.8 * progress);
|
||||
break;
|
||||
|
||||
case 'RANDOM_SPIKES':
|
||||
// 5% chance of a content sprint (3x volume)
|
||||
if (Math.random() < 0.05) {
|
||||
weight = 3.0;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'STEADY':
|
||||
default:
|
||||
weight = 1.0;
|
||||
break;
|
||||
}
|
||||
|
||||
// Add human noise (±15% randomness)
|
||||
weight *= 0.85 + (Math.random() * 0.30);
|
||||
|
||||
// Weekend throttle (reduce by 80%)
|
||||
if (config.weekendThrottle && isWeekend) {
|
||||
weight *= 0.2;
|
||||
}
|
||||
|
||||
dayWeights.push({ date: currentDate, weight });
|
||||
}
|
||||
|
||||
// Normalize and distribute articles
|
||||
const totalWeight = dayWeights.reduce((sum, d) => sum + d.weight, 0);
|
||||
const scheduleQueue: ScheduleEntry[] = [];
|
||||
|
||||
for (const dayEntry of dayWeights) {
|
||||
// Calculate how many articles for this day
|
||||
const rawCount = (dayEntry.weight / totalWeight) * totalArticles;
|
||||
|
||||
// Probabilistic rounding
|
||||
let count = Math.floor(rawCount);
|
||||
if (Math.random() < (rawCount - count)) {
|
||||
count += 1;
|
||||
}
|
||||
|
||||
// Generate timestamps with jitter
|
||||
for (let i = 0; i < count; i++) {
|
||||
let hour: number;
|
||||
|
||||
if (config.businessHoursOnly) {
|
||||
// Gaussian centered at 2 PM, clamped to 9-18
|
||||
hour = Math.round(gaussianRandom(14, 2));
|
||||
hour = Math.max(9, Math.min(18, hour));
|
||||
} else {
|
||||
// Any hour with slight bias toward afternoon
|
||||
hour = Math.round(gaussianRandom(14, 4));
|
||||
hour = Math.max(0, Math.min(23, hour));
|
||||
}
|
||||
|
||||
const minute = Math.floor(Math.random() * 60);
|
||||
|
||||
// Apply jitter to the base hour
|
||||
const jitterOffset = Math.floor((Math.random() - 0.5) * 2 * config.jitterMinutes);
|
||||
|
||||
const publishDate = new Date(dayEntry.date);
|
||||
publishDate.setHours(hour, minute, 0, 0);
|
||||
publishDate.setMinutes(publishDate.getMinutes() + jitterOffset);
|
||||
|
||||
// SEO TRICK: If older than 6 months, set modified date to today
|
||||
const sixMonthsAgo = new Date(now);
|
||||
sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 6);
|
||||
|
||||
const modifiedDate = publishDate < sixMonthsAgo
|
||||
? randomDateWithin7Days(now) // Set to recent date for freshness signal
|
||||
: new Date(publishDate);
|
||||
|
||||
scheduleQueue.push({ publishDate, modifiedDate });
|
||||
}
|
||||
}
|
||||
|
||||
// Sort chronologically
|
||||
scheduleQueue.sort((a, b) => a.publishDate.getTime() - b.publishDate.getTime());
|
||||
|
||||
return scheduleQueue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a Gaussian random number
|
||||
* Uses Box-Muller transform
|
||||
*/
|
||||
function gaussianRandom(mean: number, stdDev: number): number {
|
||||
let u = 0, v = 0;
|
||||
while (u === 0) u = Math.random();
|
||||
while (v === 0) v = Math.random();
|
||||
const z = Math.sqrt(-2.0 * Math.log(u)) * Math.cos(2.0 * Math.PI * v);
|
||||
return z * stdDev + mean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random date within 7 days of target
|
||||
*/
|
||||
function randomDateWithin7Days(target: Date): Date {
|
||||
const offset = Math.floor(Math.random() * 7);
|
||||
const result = new Date(target);
|
||||
result.setDate(result.getDate() - offset);
|
||||
result.setHours(
|
||||
Math.floor(Math.random() * 10) + 9, // 9 AM - 7 PM
|
||||
Math.floor(Math.random() * 60),
|
||||
0, 0
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate max backdate based on domain age
|
||||
*
|
||||
* @param domainAgeYears - How old the domain is
|
||||
* @returns Earliest date that's safe to backdate to
|
||||
*/
|
||||
export function getMaxBackdateStart(domainAgeYears: number): Date {
|
||||
const now = new Date();
|
||||
// Can only backdate to when domain existed, minus a small buffer
|
||||
const maxYears = Math.max(0, domainAgeYears - 0.25); // 3 month buffer
|
||||
const result = new Date(now);
|
||||
result.setFullYear(result.getFullYear() - maxYears);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a context-aware year token replacer
|
||||
* Replaces {Current_Year} and {Next_Year} based on publish date
|
||||
*/
|
||||
export function replaceYearTokens(content: string, publishDate: Date): string {
|
||||
const year = publishDate.getFullYear();
|
||||
return content
|
||||
.replace(/\{Current_Year\}/g, year.toString())
|
||||
.replace(/\{Next_Year\}/g, (year + 1).toString())
|
||||
.replace(/\{Last_Year\}/g, (year - 1).toString());
|
||||
}
|
||||
88
frontend/src/pages/api/seo/approve-batch.ts
Normal file
88
frontend/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' } }
|
||||
);
|
||||
}
|
||||
};
|
||||
177
frontend/src/pages/api/seo/generate-test-batch.ts
Normal file
177
frontend/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
frontend/src/pages/api/seo/get-nearby.ts
Normal file
150
frontend/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
frontend/src/pages/api/seo/insert-links.ts
Normal file
166
frontend/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, '\\$&');
|
||||
}
|
||||
176
frontend/src/pages/api/seo/schedule-production.ts
Normal file
176
frontend/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
frontend/src/pages/api/seo/sitemap-drip.ts
Normal file
126
frontend/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' } }
|
||||
);
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user