forked from admin/zeropost-engine
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:
@@ -6,6 +6,7 @@ const { migrate } = require('./src/config/db');
|
|||||||
const generateRoutes = require('./src/routes/generate');
|
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');
|
||||||
|
|
||||||
// Start queue worker
|
// Start queue worker
|
||||||
require('./src/workers/generation');
|
require('./src/workers/generation');
|
||||||
@@ -25,6 +26,7 @@ app.use((req, res, next) => {
|
|||||||
app.use('/api/generate', generateRoutes);
|
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.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() });
|
||||||
|
|||||||
@@ -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(`
|
await query(`
|
||||||
CREATE INDEX IF NOT EXISTS idx_channels_user ON channels(user_id);
|
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_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_status ON generation_jobs(status);
|
||||||
CREATE INDEX IF NOT EXISTS idx_jobs_user ON generation_jobs(user_id);
|
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');
|
console.log('[DB] Migrations applied');
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user