feat: articles — публичный блог zeropost.ru

- БД: таблица articles (slug, title, excerpt, content, cover, tags, SEO)
- services/articles.js: slugify (ru→en транслит), reading_time, генерация со встроенным blog-channel
- routes/articles.js: GET list/tags/:slug, POST /generate
- Универсальный blogChannel со стилем для лонгридов: tone:friendly, structure:headers, без эмодзи и хэштегов
- generateAndSaveArticle: вытаскивает title из H1, генерит excerpt, считает время чтения
This commit is contained in:
Alexey Pavlov
2026-05-31 08:45:34 +03:00
parent 5599de59ce
commit 500bb0299e
4 changed files with 230 additions and 0 deletions
+2
View File
@@ -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() });
+25
View File
@@ -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');
+52
View File
@@ -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;
+151
View File
@@ -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,
};