feat: Spark AI Factory SEO - Complete content factory with Gaussian scheduling, sitemap drip, auto-linking

This commit is contained in:
cawcenter
2025-12-12 11:24:45 -05:00
parent d3d09c8a35
commit 0576967bd5
7 changed files with 1078 additions and 0 deletions

View 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());
}

View 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' } }
);
}
};

View 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' } }
);
}
};

View 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, '');
}

View 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, '\\$&');
}

View 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' } }
);
}
};

View 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' } }
);
}
};