Files
Portfolio/scripts/generate-blog-images.mjs
T
damjan_savicandClaude Opus 4.5 43484c5023 Add blog posts, cleanup unused files, update components
- 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>
2026-01-25 11:42:11 +01:00

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);