diff --git a/index.js b/index.js index 650a605..a8f88d6 100644 --- a/index.js +++ b/index.js @@ -7,6 +7,7 @@ const generateRoutes = require('./src/routes/generate'); const channelsRoutes = require('./src/routes/channels'); const postsRoutes = require('./src/routes/posts'); const articlesRoutes = require('./src/routes/articles'); +const statsRoutes = require('./src/routes/stats'); // Start queue worker require('./src/workers/generation'); @@ -14,6 +15,15 @@ require('./src/workers/generation'); const app = express(); 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 app.use((req, res, next) => { const secret = req.headers['x-internal-secret']; @@ -27,6 +37,7 @@ app.use('/api/generate', generateRoutes); app.use('/api/channels', channelsRoutes); app.use('/api/posts', postsRoutes); app.use('/api/articles', articlesRoutes); +app.use('/api/stats', statsRoutes); app.get('/health', (req, res) => { res.json({ ok: true, service: 'zeropost-engine', time: new Date() }); diff --git a/src/routes/articles.js b/src/routes/articles.js index 0ccb3ba..531e7d5 100644 --- a/src/routes/articles.js +++ b/src/routes/articles.js @@ -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; diff --git a/src/routes/stats.js b/src/routes/stats.js new file mode 100644 index 0000000..abc6cfa --- /dev/null +++ b/src/routes/stats.js @@ -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; diff --git a/src/services/articles.js b/src/services/articles.js index 2365ec5..953f573 100644 --- a/src/services/articles.js +++ b/src/services/articles.js @@ -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( diff --git a/src/services/covers.js b/src/services/covers.js new file mode 100644 index 0000000..e06a23f --- /dev/null +++ b/src/services/covers.js @@ -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 };