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:
@@ -7,6 +7,7 @@ const generateRoutes = require('./src/routes/generate');
|
|||||||
const channelsRoutes = require('./src/routes/channels');
|
const channelsRoutes = require('./src/routes/channels');
|
||||||
const postsRoutes = require('./src/routes/posts');
|
const postsRoutes = require('./src/routes/posts');
|
||||||
const articlesRoutes = require('./src/routes/articles');
|
const articlesRoutes = require('./src/routes/articles');
|
||||||
|
const statsRoutes = require('./src/routes/stats');
|
||||||
|
|
||||||
// Start queue worker
|
// Start queue worker
|
||||||
require('./src/workers/generation');
|
require('./src/workers/generation');
|
||||||
@@ -14,6 +15,15 @@ require('./src/workers/generation');
|
|||||||
const app = express();
|
const app = express();
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
|
||||||
|
// Раздача загруженных файлов (обложки статей и т.п.)
|
||||||
|
const path = require('path');
|
||||||
|
const UPLOADS_DIR = process.env.UPLOADS_DIR || '/var/www/zeropost-uploads';
|
||||||
|
require('fs').mkdirSync(UPLOADS_DIR, { recursive: true });
|
||||||
|
|
||||||
|
// Public uploads — ДО auth-middleware, без секрета
|
||||||
|
app.use('/uploads', express.static(UPLOADS_DIR, { maxAge: '7d', immutable: true }));
|
||||||
|
|
||||||
|
|
||||||
// Simple internal auth middleware
|
// Simple internal auth middleware
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
const secret = req.headers['x-internal-secret'];
|
const secret = req.headers['x-internal-secret'];
|
||||||
@@ -27,6 +37,7 @@ app.use('/api/generate', generateRoutes);
|
|||||||
app.use('/api/channels', channelsRoutes);
|
app.use('/api/channels', channelsRoutes);
|
||||||
app.use('/api/posts', postsRoutes);
|
app.use('/api/posts', postsRoutes);
|
||||||
app.use('/api/articles', articlesRoutes);
|
app.use('/api/articles', articlesRoutes);
|
||||||
|
app.use('/api/stats', statsRoutes);
|
||||||
|
|
||||||
app.get('/health', (req, res) => {
|
app.get('/health', (req, res) => {
|
||||||
res.json({ ok: true, service: 'zeropost-engine', time: new Date() });
|
res.json({ ok: true, service: 'zeropost-engine', time: new Date() });
|
||||||
|
|||||||
@@ -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;
|
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 { query } = require('../config/db');
|
||||||
const ai = require('./ai');
|
const ai = require('./ai');
|
||||||
|
const covers = require('./covers');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Slug из заголовка — транслит для русского.
|
* Slug из заголовка — транслит для русского.
|
||||||
@@ -132,6 +133,15 @@ async function generateAndSaveArticle({ topic, keywords = [], tags = [], autoPub
|
|||||||
[content, articleRes.usage?.prompt_tokens, articleRes.usage?.completion_tokens, jobId]
|
[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];
|
return artRows[0];
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await query(
|
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