- Add 100 blog posts covering AI, development, and tech topics - Add .env.example for environment configuration - Add accessibility and lighthouse audit scripts - Remove obsolete SEO reports and temporary files - Remove dev-dist build artifacts and backup files - Remove unused portrait images (moved/consolidated elsewhere) - Update contact form and component improvements Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
407 lines
12 KiB
JavaScript
407 lines
12 KiB
JavaScript
import { readdir, readFile, mkdir, writeFile } from 'fs/promises';
|
|
import { join, dirname, basename } from 'path';
|
|
import { fileURLToPath } from 'url';
|
|
import { existsSync } from 'fs';
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = dirname(__filename);
|
|
|
|
// Website CI/Style Guide
|
|
const CI_STYLE = {
|
|
colors: {
|
|
primary: 'sage green (#697565)',
|
|
background: 'dark moss (#181C14)',
|
|
accent: 'dark sage (#465B50)',
|
|
text: 'cream/off-white (#ECDFCC)'
|
|
},
|
|
style: 'minimalist, glass-morphism, organic, professional, modern',
|
|
aesthetic: 'dark background with sage green and cream accents, subtle gradients, clean lines'
|
|
};
|
|
|
|
// Directories
|
|
const BLOG_POSTS_DIR = join(__dirname, '../blog-posts');
|
|
const OUTPUT_DIR = join(__dirname, '../source-images/posts');
|
|
|
|
/**
|
|
* Call Google Gemini / Nano Banana Pro Image Generation API
|
|
*/
|
|
async function callGeminiImageAPI(prompt, apiKey) {
|
|
const model = 'gemini-2.0-flash-exp'; // Nano Banana model
|
|
const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${apiKey}`;
|
|
|
|
const response = await fetch(url, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({
|
|
contents: [{
|
|
parts: [{ text: prompt }]
|
|
}],
|
|
generationConfig: {
|
|
responseModalities: ['TEXT', 'IMAGE']
|
|
}
|
|
})
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({}));
|
|
const errorMessage = error.error?.message || response.statusText;
|
|
throw new Error(`Gemini API Error: ${response.status} - ${errorMessage}`);
|
|
}
|
|
|
|
return response.json();
|
|
}
|
|
|
|
/**
|
|
* Call OpenAI Image Generation API directly using fetch
|
|
*/
|
|
async function callOpenAIImageAPI(prompt, apiKey) {
|
|
const response = await fetch('https://api.openai.com/v1/images/generations', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': `Bearer ${apiKey}`,
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({
|
|
model: 'gpt-image-1.5',
|
|
prompt: prompt,
|
|
n: 1,
|
|
size: '1536x1024', // 3:2 landscape (closest to 16:9 for gpt-image-1.5)
|
|
quality: 'high'
|
|
})
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({}));
|
|
throw new Error(`OpenAI API Error: ${response.status} - ${error.error?.message || response.statusText}`);
|
|
}
|
|
|
|
return response.json();
|
|
}
|
|
|
|
/**
|
|
* Extract image prompts from a blog post markdown file
|
|
*/
|
|
function extractImagePrompts(content, filename) {
|
|
const prompts = [];
|
|
|
|
// Find the "Bildprompts" section
|
|
const bildpromptsMatch = content.match(/## Bildprompts[\s\S]*?(?=##|---|\n\n\n|$)/i);
|
|
|
|
if (bildpromptsMatch) {
|
|
const section = bildpromptsMatch[0];
|
|
|
|
// Extract prompts in quotes
|
|
const promptMatches = section.matchAll(/"([^"]+)"/g);
|
|
|
|
for (const match of promptMatches) {
|
|
prompts.push(match[1]);
|
|
}
|
|
}
|
|
|
|
// Fallback: look for image prompt patterns
|
|
if (prompts.length === 0) {
|
|
const fallbackMatches = content.matchAll(/\*\*Bild \d+[^*]*\*\*[:\s]*"?([^"\n]+)"?/gi);
|
|
for (const match of fallbackMatches) {
|
|
prompts.push(match[1]);
|
|
}
|
|
}
|
|
|
|
return prompts;
|
|
}
|
|
|
|
/**
|
|
* Adapt a prompt to match the website's CI
|
|
*/
|
|
function adaptPromptToCI(originalPrompt, isHeroImage = false) {
|
|
// Extract the core concept from the original prompt
|
|
let concept = originalPrompt
|
|
.replace(/dark blue and cyan/gi, '')
|
|
.replace(/blue accents/gi, '')
|
|
.replace(/white background/gi, '')
|
|
.replace(/bright colors/gi, '')
|
|
.replace(/neon/gi, '')
|
|
.replace(/vibrant/gi, '')
|
|
.replace(/cyan/gi, '')
|
|
.replace(/8k ultra-realistic/gi, '')
|
|
.replace(/cinematic lighting/gi, '')
|
|
.replace(/color scheme/gi, '')
|
|
.trim();
|
|
|
|
// Build CI-compliant prompt - REALISTIC style with CI colors
|
|
const ciPrompt = `
|
|
Photorealistic, cinematic image for a professional tech blog. 16:9 wide format, 4K quality.
|
|
|
|
COLOR GRADING (apply as color filter/mood):
|
|
- Overall mood: Dark, moody atmosphere with deep shadows
|
|
- Dominant tones: Dark greenish-black (#181C14), muted sage green (#697565)
|
|
- Accent lighting: Warm cream/off-white (#ECDFCC) highlights
|
|
- Color temperature: Slightly warm, earthy undertones
|
|
- NO bright blue, NO cyan, NO neon colors
|
|
|
|
PHOTOGRAPHY STYLE:
|
|
- Ultra-realistic, photographic quality
|
|
- Cinematic wide-angle composition
|
|
- Professional lighting with dramatic shadows
|
|
- Shallow depth of field where appropriate
|
|
- High-end commercial photography look
|
|
- Shot on Sony A7R IV, 24-70mm f/2.8 lens
|
|
|
|
SCENE: ${concept}
|
|
|
|
Create a realistic, professional photograph. The lighting should create a moody, sophisticated atmosphere using the sage green and cream color palette as accent lighting or environmental color.
|
|
|
|
NO text overlays, NO watermarks, NO logos.
|
|
`.trim();
|
|
|
|
return ciPrompt;
|
|
}
|
|
|
|
/**
|
|
* Generate an image using OpenAI's gpt-image-1.5
|
|
*/
|
|
async function generateImage(prompt, outputPath, apiKey, provider = 'gemini', retries = 3) {
|
|
for (let attempt = 1; attempt <= retries; attempt++) {
|
|
try {
|
|
console.log(` Generating image (attempt ${attempt}/${retries}) via ${provider}...`);
|
|
console.log(` Prompt: ${prompt.substring(0, 100)}...`);
|
|
|
|
let imageBuffer;
|
|
|
|
if (provider === 'gemini') {
|
|
// Use Google Gemini / Nano Banana Pro
|
|
const response = await callGeminiImageAPI(prompt, apiKey);
|
|
|
|
// Extract image from Gemini response
|
|
const candidate = response.candidates?.[0];
|
|
if (!candidate) {
|
|
throw new Error('No candidates in response');
|
|
}
|
|
|
|
const parts = candidate.content?.parts || [];
|
|
const imagePart = parts.find(p => p.inlineData);
|
|
|
|
if (!imagePart?.inlineData?.data) {
|
|
// Check if there's text response (might be safety block)
|
|
const textPart = parts.find(p => p.text);
|
|
if (textPart) {
|
|
console.log(` Response text: ${textPart.text.substring(0, 100)}...`);
|
|
}
|
|
throw new Error('No image data in Gemini response');
|
|
}
|
|
|
|
imageBuffer = Buffer.from(imagePart.inlineData.data, 'base64');
|
|
|
|
} else {
|
|
// Use OpenAI
|
|
const response = await callOpenAIImageAPI(prompt, apiKey);
|
|
const imageResult = response.data[0];
|
|
|
|
if (imageResult.b64_json) {
|
|
imageBuffer = Buffer.from(imageResult.b64_json, 'base64');
|
|
} else if (imageResult.url) {
|
|
console.log(` Downloading from URL...`);
|
|
const imageResponse = await fetch(imageResult.url);
|
|
if (!imageResponse.ok) {
|
|
throw new Error(`Failed to download image: ${imageResponse.status}`);
|
|
}
|
|
const arrayBuffer = await imageResponse.arrayBuffer();
|
|
imageBuffer = Buffer.from(arrayBuffer);
|
|
} else {
|
|
throw new Error('No image data in response');
|
|
}
|
|
}
|
|
|
|
// Save the image
|
|
await writeFile(outputPath, imageBuffer);
|
|
console.log(` ✓ Saved: ${outputPath}`);
|
|
|
|
return true;
|
|
} catch (error) {
|
|
console.error(` ✗ Attempt ${attempt} failed:`, error.message);
|
|
|
|
if (attempt === retries) {
|
|
throw error;
|
|
}
|
|
|
|
// Wait before retry
|
|
await new Promise(resolve => setTimeout(resolve, 2000 * attempt));
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Process a single blog post
|
|
*/
|
|
async function processBlogPost(filename, apiKey, options = {}) {
|
|
const { dryRun = false, forceRegenerate = false, onlyHero = true, provider = 'gemini' } = options;
|
|
|
|
const filePath = join(BLOG_POSTS_DIR, filename);
|
|
const content = await readFile(filePath, 'utf-8');
|
|
|
|
// Extract slug from filename (e.g., "01-agentic-ai-2026.md" -> "01-agentic-ai-2026")
|
|
const slug = basename(filename, '.md');
|
|
|
|
// Extract image prompts
|
|
const prompts = extractImagePrompts(content, filename);
|
|
|
|
if (prompts.length === 0) {
|
|
console.log(`⚠ No image prompts found in ${filename}`);
|
|
return { filename, slug, generated: 0, skipped: 0 };
|
|
}
|
|
|
|
console.log(`\n📄 ${filename}`);
|
|
console.log(` Found ${prompts.length} image prompt(s)`);
|
|
|
|
// Create output directory
|
|
const postOutputDir = join(OUTPUT_DIR, slug);
|
|
if (!dryRun) {
|
|
await mkdir(postOutputDir, { recursive: true });
|
|
}
|
|
|
|
let generated = 0;
|
|
let skipped = 0;
|
|
|
|
// Process prompts (only hero image by default)
|
|
const promptsToProcess = onlyHero ? [prompts[0]] : prompts;
|
|
|
|
for (let i = 0; i < promptsToProcess.length; i++) {
|
|
const prompt = promptsToProcess[i];
|
|
const imageName = i === 0 ? 'cover.jpg' : `image-${i + 1}.jpg`;
|
|
const outputPath = join(postOutputDir, imageName);
|
|
|
|
// Check if image already exists
|
|
if (existsSync(outputPath) && !forceRegenerate) {
|
|
console.log(` ⏭ Skipping ${imageName} (already exists)`);
|
|
skipped++;
|
|
continue;
|
|
}
|
|
|
|
// Adapt prompt to CI
|
|
const adaptedPrompt = adaptPromptToCI(prompt, i === 0);
|
|
|
|
if (dryRun) {
|
|
console.log(` [DRY RUN] Would generate: ${imageName}`);
|
|
console.log(` Original: ${prompt.substring(0, 80)}...`);
|
|
console.log(` Adapted: ${adaptedPrompt.substring(0, 80)}...`);
|
|
} else {
|
|
try {
|
|
await generateImage(adaptedPrompt, outputPath, apiKey, provider);
|
|
generated++;
|
|
|
|
// Rate limiting: wait between requests
|
|
if (i < promptsToProcess.length - 1) {
|
|
console.log(' Waiting 2s before next image...');
|
|
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
}
|
|
} catch (error) {
|
|
console.error(` ✗ Failed to generate ${imageName}:`, error.message);
|
|
}
|
|
}
|
|
}
|
|
|
|
return { filename, slug, generated, skipped };
|
|
}
|
|
|
|
/**
|
|
* Main function
|
|
*/
|
|
async function main() {
|
|
console.log('🎨 Blog Image Generator');
|
|
console.log('========================\n');
|
|
|
|
// Get API key from environment or command line
|
|
const apiKey = process.env.OPENAI_API_KEY ||
|
|
process.argv.find(a => a.startsWith('--api-key='))?.split('=')[1];
|
|
|
|
// Check for API key
|
|
if (!apiKey) {
|
|
console.error('❌ Error: OPENAI_API_KEY not provided');
|
|
console.log('\nUsage:');
|
|
console.log(' export OPENAI_API_KEY=sk-your-key-here');
|
|
console.log(' node scripts/generate-blog-images.js');
|
|
console.log('\nOr:');
|
|
console.log(' node scripts/generate-blog-images.js --api-key=sk-your-key-here');
|
|
process.exit(1);
|
|
}
|
|
|
|
// Detect provider based on API key format
|
|
const provider = apiKey.startsWith('AIzaSy') ? 'gemini' : 'openai';
|
|
console.log(`Provider: ${provider === 'gemini' ? 'Google Gemini (Nano Banana)' : 'OpenAI'}\n`);
|
|
|
|
// Parse command line arguments
|
|
const args = process.argv.slice(2);
|
|
const options = {
|
|
dryRun: args.includes('--dry-run'),
|
|
forceRegenerate: args.includes('--force'),
|
|
onlyHero: !args.includes('--all-images'),
|
|
single: args.find(a => a.startsWith('--file='))?.split('=')[1],
|
|
range: args.find(a => a.startsWith('--range='))?.split('=')[1],
|
|
provider
|
|
};
|
|
|
|
if (options.dryRun) {
|
|
console.log('🔍 DRY RUN MODE - No images will be generated\n');
|
|
}
|
|
|
|
console.log(`CI Style: ${CI_STYLE.style}`);
|
|
console.log(`Colors: ${Object.values(CI_STYLE.colors).join(', ')}\n`);
|
|
|
|
// Get list of blog posts
|
|
let files;
|
|
|
|
if (options.single) {
|
|
files = [options.single];
|
|
} else {
|
|
files = (await readdir(BLOG_POSTS_DIR))
|
|
.filter(f => f.endsWith('.md'))
|
|
.sort();
|
|
|
|
// Filter by range if specified (e.g., --range=1-10)
|
|
if (options.range) {
|
|
const [start, end] = options.range.split('-').map(Number);
|
|
files = files.filter(f => {
|
|
const num = parseInt(f.split('-')[0], 10);
|
|
return num >= start && num <= end;
|
|
});
|
|
}
|
|
}
|
|
|
|
console.log(`Found ${files.length} blog post(s) to process\n`);
|
|
|
|
// Process each blog post
|
|
const results = [];
|
|
|
|
for (const file of files) {
|
|
try {
|
|
const result = await processBlogPost(file, apiKey, options);
|
|
results.push(result);
|
|
} catch (error) {
|
|
console.error(`❌ Error processing ${file}:`, error.message);
|
|
results.push({ filename: file, error: error.message });
|
|
}
|
|
}
|
|
|
|
// Summary
|
|
console.log('\n========================');
|
|
console.log('📊 Summary\n');
|
|
|
|
const totalGenerated = results.reduce((sum, r) => sum + (r.generated || 0), 0);
|
|
const totalSkipped = results.reduce((sum, r) => sum + (r.skipped || 0), 0);
|
|
const totalErrors = results.filter(r => r.error).length;
|
|
|
|
console.log(`✓ Generated: ${totalGenerated} images`);
|
|
console.log(`⏭ Skipped: ${totalSkipped} images (already exist)`);
|
|
console.log(`✗ Errors: ${totalErrors}`);
|
|
|
|
if (!options.dryRun && totalGenerated > 0) {
|
|
console.log('\n📝 Next steps:');
|
|
console.log('1. Review generated images in source-images/posts/');
|
|
console.log('2. Run `npm run build:images` to optimize images');
|
|
}
|
|
}
|
|
|
|
// Run
|
|
main().catch(console.error);
|