diff --git a/index.js b/index.js index 596b40a..650a605 100644 --- a/index.js +++ b/index.js @@ -6,6 +6,7 @@ const { migrate } = require('./src/config/db'); const generateRoutes = require('./src/routes/generate'); const channelsRoutes = require('./src/routes/channels'); const postsRoutes = require('./src/routes/posts'); +const articlesRoutes = require('./src/routes/articles'); // Start queue worker require('./src/workers/generation'); @@ -25,6 +26,7 @@ app.use((req, res, next) => { app.use('/api/generate', generateRoutes); app.use('/api/channels', channelsRoutes); app.use('/api/posts', postsRoutes); +app.use('/api/articles', articlesRoutes); app.get('/health', (req, res) => { res.json({ ok: true, service: 'zeropost-engine', time: new Date() }); diff --git a/src/config/db.js b/src/config/db.js index b93d07f..c17b444 100644 --- a/src/config/db.js +++ b/src/config/db.js @@ -119,6 +119,29 @@ const migrate = async () => { ); `); + // articles — статьи для публичного блога zeropost.ru + await query(` + CREATE TABLE IF NOT EXISTS articles ( + id SERIAL PRIMARY KEY, + slug VARCHAR(255) UNIQUE NOT NULL, + title VARCHAR(500) NOT NULL, + excerpt TEXT, + content TEXT NOT NULL, + cover_url TEXT, + tags JSONB DEFAULT '[]'::jsonb, + author VARCHAR(100) DEFAULT 'ZeroPost AI', + reading_time INTEGER, + status VARCHAR(20) DEFAULT 'published', + views INTEGER DEFAULT 0, + seo_title VARCHAR(500), + seo_descr TEXT, + job_id INTEGER REFERENCES generation_jobs(id) ON DELETE SET NULL, + published_at TIMESTAMPTZ DEFAULT NOW(), + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() + ); + `); + // индексы await query(` CREATE INDEX IF NOT EXISTS idx_channels_user ON channels(user_id); @@ -126,6 +149,8 @@ const migrate = async () => { CREATE INDEX IF NOT EXISTS idx_posts_scheduled ON posts(scheduled_at) WHERE status='scheduled'; CREATE INDEX IF NOT EXISTS idx_jobs_status ON generation_jobs(status); CREATE INDEX IF NOT EXISTS idx_jobs_user ON generation_jobs(user_id); + CREATE INDEX IF NOT EXISTS idx_articles_slug ON articles(slug); + CREATE INDEX IF NOT EXISTS idx_articles_status_pub ON articles(status, published_at DESC); `); console.log('[DB] Migrations applied'); diff --git a/src/routes/articles.js b/src/routes/articles.js new file mode 100644 index 0000000..0ccb3ba --- /dev/null +++ b/src/routes/articles.js @@ -0,0 +1,52 @@ +const express = require('express'); +const router = express.Router(); +const articlesSvc = require('../services/articles'); + +// GET /api/articles — список +router.get('/', async (req, res) => { + try { + const limit = Math.min(parseInt(req.query.limit) || 20, 100); + const offset = parseInt(req.query.offset) || 0; + const tag = req.query.tag || null; + const list = await articlesSvc.listArticles({ limit, offset, tag }); + res.json(list); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// GET /api/articles/tags — топ тегов +router.get('/tags', async (_, res) => { + try { + const tags = await articlesSvc.getAllTags(); + res.json(tags); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// GET /api/articles/:slug — одна +router.get('/:slug', async (req, res) => { + try { + const a = await articlesSvc.getArticleBySlug(req.params.slug); + if (!a) return res.status(404).json({ error: 'Not found' }); + res.json(a); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// POST /api/articles/generate — сгенерировать и сохранить (синхронно, для cron) +router.post('/generate', async (req, res) => { + try { + const { topic, keywords = [], tags = [], autoPublish = true } = req.body; + if (!topic) return res.status(400).json({ error: 'topic is required' }); + const article = await articlesSvc.generateAndSaveArticle({ topic, keywords, tags, autoPublish }); + res.json(article); + } catch (err) { + console.error('[Articles] generate', err); + res.status(500).json({ error: err.message }); + } +}); + +module.exports = router; diff --git a/src/services/articles.js b/src/services/articles.js new file mode 100644 index 0000000..2365ec5 --- /dev/null +++ b/src/services/articles.js @@ -0,0 +1,151 @@ +const { query } = require('../config/db'); +const ai = require('./ai'); + +/** + * Slug из заголовка — транслит для русского. + */ +function slugify(title) { + const map = { + а:'a',б:'b',в:'v',г:'g',д:'d',е:'e',ё:'yo',ж:'zh',з:'z',и:'i',й:'y', + к:'k',л:'l',м:'m',н:'n',о:'o',п:'p',р:'r',с:'s',т:'t',у:'u',ф:'f', + х:'h',ц:'c',ч:'ch',ш:'sh',щ:'sch',ъ:'',ы:'y',ь:'',э:'e',ю:'yu',я:'ya', + }; + return title + .toLowerCase() + .split('') + .map(c => map[c] !== undefined ? map[c] : c) + .join('') + .replace(/[^a-z0-9\s-]/g, '') + .trim() + .replace(/\s+/g, '-') + .replace(/-+/g, '-') + .substring(0, 100); +} + +function estimateReadingTime(text) { + const words = text.split(/\s+/).length; + return Math.max(1, Math.round(words / 200)); +} + +/** + * Список опубликованных статей. + */ +async function listArticles({ limit = 20, offset = 0, tag = null } = {}) { + let sql = `SELECT id, slug, title, excerpt, cover_url, tags, author, reading_time, published_at + FROM articles WHERE status='published'`; + const params = []; + if (tag) { + sql += ` AND tags ?? $${params.length + 1}`; + params.push(tag); + } + sql += ` ORDER BY published_at DESC LIMIT $${params.length + 1} OFFSET $${params.length + 2}`; + params.push(limit, offset); + const { rows } = await query(sql, params); + return rows; +} + +async function getArticleBySlug(slug) { + const { rows } = await query( + `SELECT * FROM articles WHERE slug=$1 AND status='published'`, + [slug] + ); + if (!rows.length) return null; + // считаем просмотр + await query(`UPDATE articles SET views=views+1 WHERE id=$1`, [rows[0].id]); + return rows[0]; +} + +async function getAllTags() { + const { rows } = await query( + `SELECT DISTINCT jsonb_array_elements_text(tags) as tag, COUNT(*) as cnt + FROM articles WHERE status='published' + GROUP BY tag ORDER BY cnt DESC LIMIT 30` + ); + return rows; +} + +/** + * Генерирует и сохраняет статью. + * @param {object} opts - { topic, keywords, tags, autoPublish } + */ +async function generateAndSaveArticle({ topic, keywords = [], tags = [], autoPublish = true }) { + // job + const { rows: jobRows } = await query( + `INSERT INTO generation_jobs (type, topic, status) VALUES ('article',$1,'processing') RETURNING id`, + [topic] + ); + const jobId = jobRows[0].id; + + try { + // Универсальный "channel" для блога — с правилами человечности и нашим стилем + const blogChannel = { + name: 'ZeroPost', + niche: 'Практические материалы про ИИ для людей, которые применяют его в работе', + audience: 'Маркетологи, продакты, разработчики, основатели — те, кто хочет применять ИИ практически', + goal: 'expert', + language: 'ru', + region: 'ru', + style: { + tone: 'friendly', + formality: 'informal', + humor: 'dry', + post_length: 'long', + structure: 'headers', + emoji_level: 'none', + hashtags_mode: 'none', + banned_words: ['революционный','уникальный','в современном мире','важно отметить','стоит подчеркнуть','поистине'], + }, + }; + + const articleRes = await ai.generateArticle(blogChannel, { topic, keywords }); + const content = articleRes.content; + + // вытаскиваю title (первый H1 или первая строка) и excerpt + const lines = content.split('\n').filter(Boolean); + let title = topic; + const h1 = lines.find(l => l.startsWith('# ')); + if (h1) title = h1.replace(/^#\s+/, '').trim(); + + // excerpt — первый параграф без заголовков + const firstPara = lines.find(l => l.length > 80 && !l.startsWith('#')); + const excerpt = firstPara ? firstPara.substring(0, 200) + (firstPara.length > 200 ? '...' : '') : ''; + + const slug = `${slugify(title)}-${jobId}`; + const readingTime = estimateReadingTime(content); + + const { rows: artRows } = await query( + `INSERT INTO articles (slug, title, excerpt, content, tags, reading_time, status, job_id, seo_title, seo_descr) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) RETURNING *`, + [ + slug, title, excerpt, content, + JSON.stringify(tags), + readingTime, + autoPublish ? 'published' : 'draft', + jobId, + title.substring(0, 60), + excerpt.substring(0, 160), + ] + ); + + await query( + `UPDATE generation_jobs SET status='done', result=$1, tokens_in=$2, tokens_out=$3, updated_at=NOW() WHERE id=$4`, + [content, articleRes.usage?.prompt_tokens, articleRes.usage?.completion_tokens, jobId] + ); + + return artRows[0]; + } catch (err) { + await query( + `UPDATE generation_jobs SET status='failed', error=$1 WHERE id=$2`, + [err.message, jobId] + ); + throw err; + } +} + +module.exports = { + slugify, + listArticles, + getArticleBySlug, + getAllTags, + generateAndSaveArticle, +};