forked from admin/zeropost-engine
feat: autogen service — content_queue, autogen_settings, TOPIC_BANK, cron API
This commit is contained in:
@@ -11,6 +11,7 @@ const statsRoutes = require('./src/routes/stats');
|
|||||||
const notesRoutes = require('./src/routes/notes');
|
const notesRoutes = require('./src/routes/notes');
|
||||||
const seriesRoutes = require('./src/routes/series');
|
const seriesRoutes = require('./src/routes/series');
|
||||||
const categoriesRoutes = require('./src/routes/categories');
|
const categoriesRoutes = require('./src/routes/categories');
|
||||||
|
const autogenRoutes = require('./src/routes/autogen');
|
||||||
|
|
||||||
// Start queue worker
|
// Start queue worker
|
||||||
require('./src/workers/generation');
|
require('./src/workers/generation');
|
||||||
@@ -44,6 +45,7 @@ app.use('/api/stats', statsRoutes);
|
|||||||
app.use('/api/notes', notesRoutes);
|
app.use('/api/notes', notesRoutes);
|
||||||
app.use('/api/series', seriesRoutes);
|
app.use('/api/series', seriesRoutes);
|
||||||
app.use('/api/categories', categoriesRoutes);
|
app.use('/api/categories', categoriesRoutes);
|
||||||
|
app.use('/api/autogen', autogenRoutes);
|
||||||
|
|
||||||
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() });
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -70,7 +70,7 @@ async function getAllTags() {
|
|||||||
* Генерирует и сохраняет статью.
|
* Генерирует и сохраняет статью.
|
||||||
* @param {object} opts - { topic, keywords, tags, autoPublish }
|
* @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
|
// job
|
||||||
const { rows: jobRows } = await query(
|
const { rows: jobRows } = await query(
|
||||||
`INSERT INTO generation_jobs (type, topic, status) VALUES ('article',$1,'processing') RETURNING id`,
|
`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 readingTime = estimateReadingTime(content);
|
||||||
|
|
||||||
const { rows: artRows } = await query(
|
const { rows: artRows } = await query(
|
||||||
`INSERT INTO articles (slug, title, excerpt, content, tags, reading_time, status, job_id, seo_title, seo_descr)
|
`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) RETURNING *`,
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) RETURNING *`,
|
||||||
[
|
[
|
||||||
slug, title, excerpt, content,
|
slug, title, excerpt, content,
|
||||||
JSON.stringify(tags),
|
JSON.stringify(tags),
|
||||||
|
category,
|
||||||
readingTime,
|
readingTime,
|
||||||
autoPublish ? 'published' : 'draft',
|
autoPublish ? 'published' : 'draft',
|
||||||
jobId,
|
jobId,
|
||||||
|
|||||||
@@ -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 };
|
||||||
Reference in New Issue
Block a user