forked from admin/zeropost-engine
feat: AI-генерация обложек + /api/stats + раздача /uploads
- services/covers.js: gpt-image-1, фиксированный стиль emerald-geometric, fallback на ошибки шлюза - articles.generateAndSaveArticle: запускает обложку в setImmediate (не блокирует ответ) - routes/articles: POST /backfill-covers для досгенерации - routes/stats: статистика блога (статьи, слова, токены, просмотры) - index.js: express.static на /uploads БЕЗ авторизации (публичные картинки)
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const axios = require('axios');
|
||||
const config = require('../config');
|
||||
const { query } = require('../config/db');
|
||||
|
||||
const UPLOADS_DIR = process.env.UPLOADS_DIR || '/var/www/zeropost-uploads';
|
||||
|
||||
// Гарантируем что директория есть
|
||||
if (!fs.existsSync(UPLOADS_DIR)) {
|
||||
fs.mkdirSync(UPLOADS_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Генерирует промпт для обложки на основе темы и тегов.
|
||||
* Стиль фиксированный — абстрактная геометрия в emerald-палитре.
|
||||
*/
|
||||
function buildCoverPrompt({ title, tags = [] }) {
|
||||
const subject = title.replace(/[«»":?!.]/g, '').slice(0, 80);
|
||||
const tagHint = tags.slice(0, 2).join(', ');
|
||||
return `Abstract minimalist editorial cover illustration for an article titled "${subject}".
|
||||
Style: flat geometric shapes, smooth flowing curves, isometric or layered planes.
|
||||
Color palette: emerald green (#10b981, #34d399), soft teal, warm off-white background (#fafaf9), subtle dark accents.
|
||||
Mood: clean, modern, calm, intellectual.
|
||||
Composition: balanced, plenty of negative space, no text, no letters, no people, no logos.
|
||||
${tagHint ? `Theme cues: ${tagHint}.` : ''}
|
||||
High quality vector-like editorial illustration in the style of Stripe Press, Linear, Notion blog covers.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Запрашивает картинку у gpt-image модели, сохраняет файл локально.
|
||||
* Возвращает публичный URL.
|
||||
*/
|
||||
async function generateCover({ articleId, title, tags = [] }) {
|
||||
const prompt = buildCoverPrompt({ title, tags });
|
||||
const model = config.ai.models.image || 'gpt-image-1';
|
||||
|
||||
let imgData;
|
||||
try {
|
||||
const res = await axios.post(
|
||||
`${config.ai.baseUrl}/images/generations`,
|
||||
{
|
||||
model,
|
||||
prompt,
|
||||
n: 1,
|
||||
size: '1536x1024',
|
||||
},
|
||||
{
|
||||
headers: { Authorization: `Bearer ${config.ai.imageApiKey}` },
|
||||
timeout: 120000,
|
||||
}
|
||||
);
|
||||
imgData = res.data.data?.[0];
|
||||
} catch (err) {
|
||||
const msg = err.response?.data?.error?.message || err.message;
|
||||
console.warn(`[Cover] generation failed for article ${articleId}:`, msg.slice(0, 200));
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
if (!imgData) throw new Error('Empty image response');
|
||||
|
||||
// Получаем bytes — либо из b64, либо скачиваем по URL
|
||||
let bytes;
|
||||
if (imgData.b64_json) {
|
||||
bytes = Buffer.from(imgData.b64_json, 'base64');
|
||||
} else if (imgData.url) {
|
||||
const resp = await axios.get(imgData.url, { responseType: 'arraybuffer', timeout: 60000 });
|
||||
bytes = Buffer.from(resp.data);
|
||||
} else {
|
||||
throw new Error('No image data in response');
|
||||
}
|
||||
|
||||
const filename = `cover-${articleId}-${Date.now()}.png`;
|
||||
const filepath = path.join(UPLOADS_DIR, filename);
|
||||
fs.writeFileSync(filepath, bytes);
|
||||
|
||||
const publicUrl = `/uploads/${filename}`;
|
||||
await query(`UPDATE articles SET cover_url=$1, updated_at=NOW() WHERE id=$2`, [publicUrl, articleId]);
|
||||
|
||||
console.log(`[Cover] saved ${publicUrl} (${(bytes.length / 1024).toFixed(0)} KB)`);
|
||||
return publicUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Дофоновая попытка сгенерировать обложки для статей без cover_url.
|
||||
* Запускается периодически — если шлюз картинок снова доступен, всё подтянется.
|
||||
*/
|
||||
async function backfillCovers({ limit = 3 } = {}) {
|
||||
const { rows } = await query(
|
||||
`SELECT id, title, tags FROM articles
|
||||
WHERE cover_url IS NULL AND status='published'
|
||||
ORDER BY published_at DESC LIMIT $1`,
|
||||
[limit]
|
||||
);
|
||||
let ok = 0, fail = 0;
|
||||
for (const a of rows) {
|
||||
try {
|
||||
await generateCover({ articleId: a.id, title: a.title, tags: a.tags || [] });
|
||||
ok++;
|
||||
} catch {
|
||||
fail++;
|
||||
}
|
||||
}
|
||||
return { processed: rows.length, ok, fail };
|
||||
}
|
||||
|
||||
module.exports = { generateCover, backfillCovers, buildCoverPrompt, UPLOADS_DIR };
|
||||
Reference in New Issue
Block a user