From 3372574b32d65eab18124932756eab3fc92af2cd Mon Sep 17 00:00:00 2001 From: Alexey Pavlov Date: Sun, 31 May 2026 14:48:38 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20autogen=20service=20=E2=80=94=20content?= =?UTF-8?q?=5Fqueue,=20autogen=5Fsettings,=20TOPIC=5FBANK,=20cron=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- index.js | 2 + src/routes/autogen.js | 77 ++++++++++++++++++ src/services/articles.js | 7 +- src/services/autogen.js | 172 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 255 insertions(+), 3 deletions(-) create mode 100644 src/routes/autogen.js create mode 100644 src/services/autogen.js diff --git a/index.js b/index.js index a035d8c..0c3a2f2 100644 --- a/index.js +++ b/index.js @@ -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() }); diff --git a/src/routes/autogen.js b/src/routes/autogen.js new file mode 100644 index 0000000..b83fa13 --- /dev/null +++ b/src/routes/autogen.js @@ -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; diff --git a/src/services/articles.js b/src/services/articles.js index 8fd5d08..dc7f3d1 100644 --- a/src/services/articles.js +++ b/src/services/articles.js @@ -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, diff --git a/src/services/autogen.js b/src/services/autogen.js new file mode 100644 index 0000000..c26349b --- /dev/null +++ b/src/services/autogen.js @@ -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 };