feat: autogen service — content_queue, autogen_settings, TOPIC_BANK, cron API

This commit is contained in:
Alexey Pavlov
2026-05-31 14:48:38 +03:00
parent e5e7e9ef98
commit 3372574b32
4 changed files with 255 additions and 3 deletions
+2
View File
@@ -11,6 +11,7 @@ const statsRoutes = require('./src/routes/stats');
const notesRoutes = require('./src/routes/notes');
const seriesRoutes = require('./src/routes/series');
const categoriesRoutes = require('./src/routes/categories');
const autogenRoutes = require('./src/routes/autogen');
// Start queue worker
require('./src/workers/generation');
@@ -44,6 +45,7 @@ app.use('/api/stats', statsRoutes);
app.use('/api/notes', notesRoutes);
app.use('/api/series', seriesRoutes);
app.use('/api/categories', categoriesRoutes);
app.use('/api/autogen', autogenRoutes);
app.get('/health', (req, res) => {
res.json({ ok: true, service: 'zeropost-engine', time: new Date() });
+77
View File
@@ -0,0 +1,77 @@
const express = require('express');
const router = express.Router();
const { query } = require('../config/db');
const { runAutogen, runAutogenForCategory, getAutogenStatus, TOPIC_BANK } = require('../services/autogen');
// GET /api/autogen/status — текущий статус и настройки
router.get('/status', async (_, res) => {
try {
const status = await getAutogenStatus();
res.json(status);
} catch (err) { res.status(500).json({ error: err.message }); }
});
// POST /api/autogen/run — запустить генерацию (cron или ручной запуск)
router.post('/run', async (req, res) => {
try {
const { category } = req.body;
// Запускаем фоново, сразу отвечаем
res.json({ ok: true, message: 'Generation started' });
runAutogen({ forceCategory: category || null }).catch(err =>
console.error('[Autogen] run error:', err.message)
);
} catch (err) { res.status(500).json({ error: err.message }); }
});
// PATCH /api/autogen/settings/:category — обновить настройки
router.patch('/settings/:category', async (req, res) => {
try {
const { enabled, per_day } = req.body;
const fields = []; const vals = []; let i = 1;
if (enabled !== undefined) { fields.push(`enabled=$${i++}`); vals.push(enabled); }
if (per_day !== undefined) { fields.push(`per_day=$${i++}`); vals.push(per_day); }
if (!fields.length) return res.status(400).json({ error: 'Nothing to update' });
vals.push(req.params.category);
await query(`UPDATE autogen_settings SET ${fields.join(',')} WHERE category=$${i}`, vals);
res.json({ ok: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// GET /api/autogen/queue — очередь тем
router.get('/queue', async (_, res) => {
try {
const { rows } = await query(
`SELECT * FROM content_queue ORDER BY priority DESC, created_at ASC LIMIT 100`
);
res.json(rows);
} catch (err) { res.status(500).json({ error: err.message }); }
});
// POST /api/autogen/queue — добавить тему в очередь
router.post('/queue', async (req, res) => {
try {
const { category, topic, tags = [], keywords = [], priority = 5 } = req.body;
if (!category || !topic) return res.status(400).json({ error: 'category and topic required' });
const { rows } = await query(
`INSERT INTO content_queue (category, topic, tags, keywords, priority)
VALUES ($1,$2,$3,$4,$5) RETURNING *`,
[category, topic, JSON.stringify(tags), JSON.stringify(keywords), priority]
);
res.json(rows[0]);
} catch (err) { res.status(500).json({ error: err.message }); }
});
// DELETE /api/autogen/queue/:id
router.delete('/queue/:id', async (req, res) => {
try {
await query(`DELETE FROM content_queue WHERE id=$1`, [req.params.id]);
res.json({ ok: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// GET /api/autogen/topics — банк тем
router.get('/topics', async (_, res) => {
res.json(TOPIC_BANK);
});
module.exports = router;
+4 -3
View File
@@ -70,7 +70,7 @@ async function getAllTags() {
* Генерирует и сохраняет статью.
* @param {object} opts - { topic, keywords, tags, autoPublish }
*/
async function generateAndSaveArticle({ topic, keywords = [], tags = [], autoPublish = true }) {
async function generateAndSaveArticle({ topic, keywords = [], tags = [], autoPublish = true, category = 'ai-tools' }) {
// job
const { rows: jobRows } = await query(
`INSERT INTO generation_jobs (type, topic, status) VALUES ('article',$1,'processing') RETURNING id`,
@@ -116,11 +116,12 @@ async function generateAndSaveArticle({ topic, keywords = [], tags = [], autoPub
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 *`,
`INSERT INTO articles (slug, title, excerpt, content, tags, category, reading_time, status, job_id, seo_title, seo_descr)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) RETURNING *`,
[
slug, title, excerpt, content,
JSON.stringify(tags),
category,
readingTime,
autoPublish ? 'published' : 'draft',
jobId,
+172
View File
@@ -0,0 +1,172 @@
const { query } = require('../config/db');
const { generateAndSaveArticle } = require('./articles');
/**
* Банк тем по категориям — когда очередь пуста, берём отсюда случайную тему.
*/
const TOPIC_BANK = {
'ai-tools': [
'Как использовать Claude для написания технической документации',
'GPT-4o vs Claude 3.5: что выбрать для разных задач',
'Промпт-инжиниринг для копирайтеров: 10 шаблонов',
'Как настроить персонального ИИ-ассистента в Telegram',
'Notion AI vs ChatGPT: что лучше для управления знаниями',
'Как писать промпты для генерации изображений: практическое руководство',
'ИИ для анализа данных: от Excel к Python с Copilot',
'Как использовать ИИ для SEO: инструменты и подходы',
'Голосовые ИИ-ассистенты в 2025: сравнение и кейсы',
'Как создать AI-агента для мониторинга цен конкурентов',
],
'cybersec': [
'Prompt injection: как хакеры атакуют ИИ-системы',
'Как защитить данные при работе с ChatGPT и Claude',
'ИИ в пентестинге: инструменты и методы',
'Deepfake и голосовой фишинг: как распознать и защититься',
'Безопасность LLM в продакшне: основные уязвимости',
'Как ИИ помогает анализировать вредоносный код',
'OSINT с помощью ИИ: возможности и границы',
'Социальная инженерия в эпоху ИИ: новые векторы атак',
'Как автоматизировать аудит безопасности с помощью ИИ',
'Zero-trust архитектура и ИИ: что нужно знать',
],
'automation': [
'n8n vs Make: что выбрать для автоматизации бизнеса',
'Как автоматизировать email-маркетинг с помощью ИИ',
'Make + Claude: создаём умный контент-пайплайн',
'Автоматизация отчётов в Google Sheets с AI',
'Как построить no-code CRM с Airtable и ИИ',
'Zapier AI Actions: что умеют и как применять',
'Автоматический парсинг и анализ данных с ИИ',
'Telegram-бот с ИИ для автоматизации поддержки',
'Как автоматизировать публикацию в соцсети с ИИ',
'ИИ-агенты для автоматизации рутины: обзор инструментов 2025',
],
'ai-dev': [
'Cursor vs GitHub Copilot: честное сравнение в 2025',
'Как использовать Claude API для создания чат-ботов',
'RAG-системы: как построить базу знаний для LLM',
'LangChain vs LlamaIndex: что выбрать для своего проекта',
'Как деплоить LLM на собственном сервере',
'Fine-tuning vs промпты: когда что применять',
'Оптимизация стоимости запросов к GPT API',
'Как тестировать ИИ-приложения: инструменты и подходы',
'MCP протокол: как подключить ИИ к своим инструментам',
'Векторные базы данных: Pinecone, Weaviate, pgvector — сравнение',
],
};
/**
* Берёт следующую тему из очереди или из банка тем.
*/
async function getNextTopic(category) {
// Сначала из очереди (по приоритету)
const { rows } = await query(
`SELECT * FROM content_queue
WHERE category=$1 AND status='pending'
ORDER BY priority DESC, created_at ASC LIMIT 1`,
[category]
);
if (rows.length) {
return { id: rows[0].id, topic: rows[0].topic, tags: rows[0].tags || [], keywords: rows[0].keywords || [] };
}
// Из банка — случайная тема которой ещё не было
const bank = TOPIC_BANK[category] || TOPIC_BANK['ai-tools'];
const { rows: used } = await query(
`SELECT a.title FROM articles a WHERE a.category=$1 AND a.status='published'`,
[category]
);
const usedTitles = used.map(r => r.title.toLowerCase());
const unused = bank.filter(t => !usedTitles.some(u => u.includes(t.slice(0, 20).toLowerCase())));
const pool = unused.length > 0 ? unused : bank;
const topic = pool[Math.floor(Math.random() * pool.length)];
return { id: null, topic, tags: [category], keywords: [] };
}
/**
* Запускает генерацию одной статьи для категории.
*/
async function runAutogenForCategory(category) {
const { id: queueId, topic, tags, keywords } = await getNextTopic(category);
console.log(`[Autogen] category=${category} topic="${topic.slice(0, 60)}"`);
try {
const article = await generateAndSaveArticle({
topic,
tags: [...tags, category],
keywords,
autoPublish: true,
category,
});
// Помечаем очередь выполненной
if (queueId) {
await query(
`UPDATE content_queue SET status='done', article_id=$1, processed_at=NOW() WHERE id=$2`,
[article.id, queueId]
);
}
// Обновляем время последнего запуска
await query(
`UPDATE autogen_settings SET last_run_at=NOW(),
next_run_at=NOW() + (INTERVAL '1 day' / per_day)
WHERE category=$1`,
[category]
);
console.log(`[Autogen] OK category=${category} article=${article.id} slug=${article.slug}`);
return { ok: true, article };
} catch (err) {
console.error(`[Autogen] FAIL category=${category}: ${err.message}`);
if (queueId) {
await query(`UPDATE content_queue SET status='failed' WHERE id=$1`, [queueId]);
}
return { ok: false, error: err.message };
}
}
/**
* Основная функция cron — проверяет какие категории нужно генерировать.
*/
async function runAutogen({ forceCategory = null } = {}) {
const { rows: settings } = await query(
`SELECT * FROM autogen_settings WHERE enabled=true
${forceCategory ? `AND category=$1` : `AND (next_run_at IS NULL OR next_run_at <= NOW())`}
ORDER BY category`,
forceCategory ? [forceCategory] : []
);
if (!settings.length) {
console.log('[Autogen] Nothing to generate');
return { processed: 0, results: [] };
}
const results = [];
for (const s of settings) {
const result = await runAutogenForCategory(s.category);
results.push({ category: s.category, ...result });
// Пауза между категориями чтобы не перегружать API
if (settings.indexOf(s) < settings.length - 1) {
await new Promise(r => setTimeout(r, 5000));
}
}
return { processed: settings.length, results };
}
/**
* Получить статус автогенерации.
*/
async function getAutogenStatus() {
const { rows: settings } = await query(
`SELECT s.*, c.name as cat_name,
(SELECT COUNT(*) FROM content_queue q WHERE q.category=s.category AND q.status='pending') as queue_count,
(SELECT COUNT(*) FROM articles a WHERE a.category=s.category AND a.status='published') as article_count
FROM autogen_settings s
LEFT JOIN categories c ON c.slug=s.category
ORDER BY s.category`
);
return settings;
}
module.exports = { runAutogen, runAutogenForCategory, getAutogenStatus, TOPIC_BANK };