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
+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,
};