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:
@@ -49,4 +49,17 @@ router.post('/generate', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// POST /api/articles/backfill-covers — досгенерировать обложки для статей без них
|
||||
router.post('/backfill-covers', async (req, res) => {
|
||||
try {
|
||||
const covers = require('../services/covers');
|
||||
const limit = parseInt(req.body?.limit) || 3;
|
||||
const result = await covers.backfillCovers({ limit });
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { query } = require('../config/db');
|
||||
|
||||
// GET /api/stats — публичная статистика блога
|
||||
router.get('/', async (_, res) => {
|
||||
try {
|
||||
const { rows: countRow } = await query(
|
||||
`SELECT
|
||||
COUNT(*)::int as articles_count,
|
||||
COALESCE(SUM(LENGTH(content) - LENGTH(REPLACE(content, ' ', '')) + 1), 0)::int as total_words,
|
||||
COALESCE(SUM(reading_time), 0)::int as total_reading_min,
|
||||
COALESCE(SUM(views), 0)::int as total_views
|
||||
FROM articles WHERE status='published'`
|
||||
);
|
||||
|
||||
const { rows: jobsRow } = await query(
|
||||
`SELECT
|
||||
COALESCE(SUM(tokens_in), 0)::int as tokens_in,
|
||||
COALESCE(SUM(tokens_out), 0)::int as tokens_out,
|
||||
COUNT(*)::int as jobs_done
|
||||
FROM generation_jobs WHERE status='done' AND type='article'`
|
||||
);
|
||||
|
||||
const { rows: latestRow } = await query(
|
||||
`SELECT MAX(published_at) as latest FROM articles WHERE status='published'`
|
||||
);
|
||||
|
||||
res.json({
|
||||
...countRow[0],
|
||||
...jobsRow[0],
|
||||
latest_published: latestRow[0]?.latest || null,
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,5 +1,6 @@
|
||||
const { query } = require('../config/db');
|
||||
const ai = require('./ai');
|
||||
const covers = require('./covers');
|
||||
|
||||
/**
|
||||
* Slug из заголовка — транслит для русского.
|
||||
@@ -132,6 +133,15 @@ async function generateAndSaveArticle({ topic, keywords = [], tags = [], autoPub
|
||||
[content, articleRes.usage?.prompt_tokens, articleRes.usage?.completion_tokens, jobId]
|
||||
);
|
||||
|
||||
// Фоновая генерация обложки — не блокирует возврат статьи
|
||||
setImmediate(() => {
|
||||
covers.generateCover({
|
||||
articleId: artRows[0].id,
|
||||
title: artRows[0].title,
|
||||
tags: artRows[0].tags || [],
|
||||
}).catch(err => console.warn('[Article] cover bg failed:', err.message.slice(0,200)));
|
||||
});
|
||||
|
||||
return artRows[0];
|
||||
} catch (err) {
|
||||
await query(
|
||||
|
||||
@@ -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