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:
Alexey Pavlov
2026-05-31 09:17:08 +03:00
parent 500bb0299e
commit c7b83147f1
5 changed files with 180 additions and 0 deletions
+107
View File
@@ -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 };