From 0576967bd58f11dc67bdcd0e5cb05cd80a6f246d Mon Sep 17 00:00:00 2001 From: cawcenter Date: Fri, 12 Dec 2025 11:24:45 -0500 Subject: [PATCH] feat: Spark AI Factory SEO - Complete content factory with Gaussian scheduling, sitemap drip, auto-linking --- frontend/src/lib/seo/velocity-scheduler.ts | 195 ++++++++++++++++++ frontend/src/pages/api/seo/approve-batch.ts | 88 ++++++++ .../src/pages/api/seo/generate-test-batch.ts | 177 ++++++++++++++++ frontend/src/pages/api/seo/get-nearby.ts | 150 ++++++++++++++ frontend/src/pages/api/seo/insert-links.ts | 166 +++++++++++++++ .../src/pages/api/seo/schedule-production.ts | 176 ++++++++++++++++ frontend/src/pages/api/seo/sitemap-drip.ts | 126 +++++++++++ 7 files changed, 1078 insertions(+) create mode 100644 frontend/src/lib/seo/velocity-scheduler.ts create mode 100644 frontend/src/pages/api/seo/approve-batch.ts create mode 100644 frontend/src/pages/api/seo/generate-test-batch.ts create mode 100644 frontend/src/pages/api/seo/get-nearby.ts create mode 100644 frontend/src/pages/api/seo/insert-links.ts create mode 100644 frontend/src/pages/api/seo/schedule-production.ts create mode 100644 frontend/src/pages/api/seo/sitemap-drip.ts diff --git a/frontend/src/lib/seo/velocity-scheduler.ts b/frontend/src/lib/seo/velocity-scheduler.ts new file mode 100644 index 0000000..ed7743c --- /dev/null +++ b/frontend/src/lib/seo/velocity-scheduler.ts @@ -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()); +} diff --git a/frontend/src/pages/api/seo/approve-batch.ts b/frontend/src/pages/api/seo/approve-batch.ts new file mode 100644 index 0000000..ccabff7 --- /dev/null +++ b/frontend/src/pages/api/seo/approve-batch.ts @@ -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' } } + ); + } +}; diff --git a/frontend/src/pages/api/seo/generate-test-batch.ts b/frontend/src/pages/api/seo/generate-test-batch.ts new file mode 100644 index 0000000..9796926 --- /dev/null +++ b/frontend/src/pages/api/seo/generate-test-batch.ts @@ -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: `

${processedHeadline}

Test batch article content. Full content will be generated on approval.

`, + 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' } } + ); + } +}; diff --git a/frontend/src/pages/api/seo/get-nearby.ts b/frontend/src/pages/api/seo/get-nearby.ts new file mode 100644 index 0000000..00bc971 --- /dev/null +++ b/frontend/src/pages/api/seo/get-nearby.ts @@ -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, ''); +} diff --git a/frontend/src/pages/api/seo/insert-links.ts b/frontend/src/pages/api/seo/insert-links.ts new file mode 100644 index 0000000..5c0781e --- /dev/null +++ b/frontend/src/pages/api/seo/insert-links.ts @@ -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( + `(?]*>)\\b(${escapeRegex(anchor)})\\b(?![^<]*)`, + 'i' + ); + + if (regex.test(content)) { + const targetUrl = target.target_url || + (target.target_post ? `/posts/${target.target_post}` : null); + + if (targetUrl) { + content = content.replace(regex, `$1`); + 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, '\\$&'); +} diff --git a/frontend/src/pages/api/seo/schedule-production.ts b/frontend/src/pages/api/seo/schedule-production.ts new file mode 100644 index 0000000..add6595 --- /dev/null +++ b/frontend/src/pages/api/seo/schedule-production.ts @@ -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 = {}; + 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' } } + ); + } +}; diff --git a/frontend/src/pages/api/seo/sitemap-drip.ts b/frontend/src/pages/api/seo/sitemap-drip.ts new file mode 100644 index 0000000..7eaac2a --- /dev/null +++ b/frontend/src/pages/api/seo/sitemap-drip.ts @@ -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' } } + ); + } +};