From 4ec3239dc32fbdc64fd3dd3a482f2d304a617618 Mon Sep 17 00:00:00 2001 From: Aleksei Pavlov Date: Wed, 24 Jun 2026 19:22:36 +0300 Subject: [PATCH] =?UTF-8?q?feat(postcast):=20=D1=81=D0=B8=D1=81=D1=82?= =?UTF-8?q?=D0=B5=D0=BC=D0=B0=20=D0=BA=D0=B0=D1=82=D0=B5=D0=B3=D0=BE=D1=80?= =?UTF-8?q?=D0=B8=D0=B9=20+=20=D0=B1=D0=B0=D0=BD=D0=BA=20=D1=82=D0=B5?= =?UTF-8?q?=D0=BC=20+=20=D0=B3=D0=B8=D0=B1=D0=BA=D0=B0=D1=8F=20=D1=80?= =?UTF-8?q?=D0=BE=D1=82=D0=B0=D1=86=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Новая архитектура автогенерации (перенос и доработка из ZeroPost): БД (3 новые таблицы + поля в posts): channel_categories — категории принадлежат каналу пользователя. CRUD по slug (уникален в рамках канала), цвет, иконка, sort_order. category_topics — банк тем с жанровыми маркерами: [ТУТОРИАЛ][СРАВНЕНИЕ][МНЕНИЕ][ДАЙДЖЕСТ][КЕЙС][НОВОСТЬ] genre: detected auto или задан явно. Атомарный захват через UPDATE...FOR UPDATE SKIP LOCKED (нет дублей). channel_autogen_settings — настройки per-канал: posts_per_day: 1-20 (пользователь выбирает сам, 3 по умолчанию) run_hour/run_minute, rotation_mode, last_run_at best_time_stats — заготовка под аналитику лучшего времени. posts: +source_topic, +source_category_id, +genre Ротация (src/services/autogenNew.js): getTodayCategoryIds: скользящее окно размером posts_per_day. Если категорий <= posts_per_day — берём все. Если больше — сдвиг на 1 каждый день (dayOfYear % total). Пример: 8 категорий, 3 поста/день → каждый день другие 3 категории. Предпросмотр: GET /api/channels/:id/autogen/rotation?days=7 Фиксы из ZeroPost (не будет тех же ошибок): pg_advisory_lock по (channel_id, category_id) — нет параллельных дублей Двойная проверка после lock: уже генерировали сегодня? Промпт учитывает жанр ([ТУТОРИАЛ] → пошаговый гайд и т.д.) generateTopicsForCategory: AI генерит N тем с равномерным распределением жанров API routes: GET/POST/PATCH/DELETE /api/channels/:id/categories GET/POST/PATCH/DELETE /api/channels/:id/categories/:catId/topics POST /api/channels/:id/categories/:catId/topics/generate (AI, async) GET/POST/PATCH /api/channels/:id/autogen POST /api/channels/:id/autogen/run GET /api/channels/:id/autogen/today (черновики за сегодня) GET /api/channels/:id/autogen/rotation (preview на N дней) --- index.js | 2 + src/config/db.js | 67 ++++++ src/routes/channelAutogen.js | 124 ++++++++++ src/routes/channelCategories.js | 183 +++++++++++++++ src/services/autogen.old.js | 211 +++++++++++++++++ src/services/autogenNew.js | 379 +++++++++++++++++++++++++++++++ src/services/autogenNew_final.js | 379 +++++++++++++++++++++++++++++++ 7 files changed, 1345 insertions(+) create mode 100644 src/routes/channelAutogen.js create mode 100644 src/routes/channelCategories.js create mode 100644 src/services/autogen.old.js create mode 100644 src/services/autogenNew.js create mode 100644 src/services/autogenNew_final.js diff --git a/index.js b/index.js index bf9c72b..8ac33c3 100644 --- a/index.js +++ b/index.js @@ -18,6 +18,8 @@ const photoSearchRoutes = require('./src/routes/photo-search'); const scheduledPostsRoutes = require('./src/routes/scheduledPosts'); const channelStatsRoutes = require('./src/routes/channelStats'); const calendarRoutes = require('./src/routes/calendar'); +const channelCategoriesRoutes = require('./src/routes/channelCategories'); +const channelAutogenRoutes = require('./src/routes/channelAutogen'); const metricsRoutes = require('./src/routes/metrics'); const usageRoutes = require('./src/routes/usage'); diff --git a/src/config/db.js b/src/config/db.js index 8843af1..1b19a39 100644 --- a/src/config/db.js +++ b/src/config/db.js @@ -187,6 +187,73 @@ const migrate = async () => { CREATE INDEX IF NOT EXISTS idx_series_slug ON series(slug); `); + + // channel_categories — категории канала пользователя + await query(` + CREATE TABLE IF NOT EXISTS channel_categories ( + id SERIAL PRIMARY KEY, + channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + slug VARCHAR(60) NOT NULL, + name VARCHAR(100) NOT NULL, + description TEXT, + icon VARCHAR(10) DEFAULT '📝', + color VARCHAR(20) DEFAULT 'emerald', + sort_order INTEGER DEFAULT 99, + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(channel_id, slug) + ) + `); + await query(`CREATE INDEX IF NOT EXISTS idx_ch_cats_channel ON channel_categories(channel_id, is_active)`); + + // category_topics — банк тем для категорий + await query(` + CREATE TABLE IF NOT EXISTS category_topics ( + id BIGSERIAL PRIMARY KEY, + channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + category_id INTEGER NOT NULL REFERENCES channel_categories(id) ON DELETE CASCADE, + topic TEXT NOT NULL, + genre VARCHAR(20), + priority INTEGER DEFAULT 5, + is_used BOOLEAN DEFAULT false, + used_at TIMESTAMPTZ, + source VARCHAR(20) DEFAULT 'manual', + created_at TIMESTAMPTZ DEFAULT NOW() + ) + `); + await query(`CREATE INDEX IF NOT EXISTS idx_cat_topics_cat ON category_topics(category_id, is_used, priority DESC)`); + + // channel_autogen_settings — настройки автогена per-канал + await query(` + CREATE TABLE IF NOT EXISTS channel_autogen_settings ( + id SERIAL PRIMARY KEY, + channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE UNIQUE, + enabled BOOLEAN DEFAULT false, + posts_per_day INTEGER DEFAULT 3, + run_hour INTEGER DEFAULT 10, + run_minute INTEGER DEFAULT 0, + rotation_mode VARCHAR(20) DEFAULT 'sequential', + last_run_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW() + ) + `); + + // best_time_stats — агрегированная статистика лучшего времени публикации + await query(` + CREATE TABLE IF NOT EXISTS best_time_stats ( + id SERIAL PRIMARY KEY, + channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + hour_utc SMALLINT NOT NULL CHECK (hour_utc BETWEEN 0 AND 23), + day_of_week SMALLINT CHECK (day_of_week BETWEEN 0 AND 6), + avg_views NUMERIC(10,2) DEFAULT 0, + avg_reactions NUMERIC(10,2) DEFAULT 0, + post_count INTEGER DEFAULT 0, + updated_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(channel_id, hour_utc, day_of_week) + ) + `); + await query(`CREATE INDEX IF NOT EXISTS idx_best_time_ch ON best_time_stats(channel_id)`); + console.log('[DB] Migrations applied'); }; diff --git a/src/routes/channelAutogen.js b/src/routes/channelAutogen.js new file mode 100644 index 0000000..96711b6 --- /dev/null +++ b/src/routes/channelAutogen.js @@ -0,0 +1,124 @@ +/** + * /api/channels/:channelId/autogen — настройки и управление автогенерацией канала. + */ +const express = require('express'); +const router = express.Router({ mergeParams: true }); +const { query } = require('../config/db'); +const { runAutogen, getAutogenStatus, getTodayCategoryIds } = require('../services/autogenNew'); + +async function checkChannelOwner(req, res, next) { + const userId = req.user?.id || req.headers['x-user-id']; + const channelId = parseInt(req.params.channelId, 10); + if (!userId || !channelId) return res.status(400).json({ error: 'channelId required' }); + const { rows } = await query('SELECT id FROM channels WHERE id=$1 AND user_id=$2', [channelId, userId]); + if (!rows.length) return res.status(403).json({ error: 'Channel not found or access denied' }); + req.channelId = channelId; + next(); +} + +// GET /api/channels/:channelId/autogen — статус + категории + today_active +router.get('/', checkChannelOwner, async (req, res) => { + try { + const status = await getAutogenStatus(req.channelId); + if (!status) { + // autogen не настроен — возвращаем пустой статус + return res.json({ ok: true, enabled: false, posts_per_day: 3, categories: [] }); + } + res.json({ ok: true, ...status }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +// POST /api/channels/:channelId/autogen/enable — включить/настроить +router.post('/enable', checkChannelOwner, async (req, res) => { + try { + const { enabled=true, posts_per_day=3, run_hour=10, run_minute=0, rotation_mode='sequential' } = req.body; + await query(` + INSERT INTO channel_autogen_settings (channel_id, enabled, posts_per_day, run_hour, run_minute, rotation_mode) + VALUES ($1,$2,$3,$4,$5,$6) + ON CONFLICT (channel_id) DO UPDATE SET + enabled=$2, posts_per_day=$3, run_hour=$4, run_minute=$5, + rotation_mode=$6 + `, [req.channelId, enabled, Math.min(posts_per_day,20), run_hour, run_minute, rotation_mode]); + res.json({ ok: true }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +// PATCH /api/channels/:channelId/autogen/settings — обновить настройки +router.patch('/settings', checkChannelOwner, async (req, res) => { + try { + const allowed = ['enabled','posts_per_day','run_hour','run_minute','rotation_mode']; + const fields = []; const vals = []; let i = 1; + for (const key of allowed) { + if (req.body[key] !== undefined) { + let val = req.body[key]; + if (key === 'posts_per_day') val = Math.min(parseInt(val,10)||3, 20); + fields.push(`${key}=$${i++}`); vals.push(val); + } + } + if (!fields.length) return res.status(400).json({ error: 'nothing to update' }); + vals.push(req.channelId); + await query(`UPDATE channel_autogen_settings SET ${fields.join(',')} WHERE channel_id=$${i}`, vals); + res.json({ ok: true }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +// POST /api/channels/:channelId/autogen/run — ручной запуск +router.post('/run', checkChannelOwner, async (req, res) => { + try { + const categoryId = req.body.category_id ? parseInt(req.body.category_id, 10) : null; + res.json({ ok: true, message: 'Generation started' }); + runAutogen({ channelId: req.channelId, categoryId }).catch(err => + console.error('[Autogen/run] error:', err.message) + ); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +// GET /api/channels/:channelId/autogen/today — черновики сегодняшней генерации +router.get('/today', checkChannelOwner, async (req, res) => { + try { + const { rows } = await query(` + SELECT p.id, p.content, p.status, p.genre, p.source_topic, p.created_at, + cc.id AS category_id, cc.name AS category_name, cc.icon AS category_icon, cc.color AS category_color + FROM posts p + LEFT JOIN channel_categories cc ON cc.id=p.source_category_id + WHERE p.channel_id=$1 AND p.status='draft' AND p.created_at >= CURRENT_DATE + ORDER BY p.created_at DESC + `, [req.channelId]); + res.json({ ok: true, drafts: rows }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +// GET /api/channels/:channelId/autogen/rotation — preview ротации на N дней +router.get('/rotation', checkChannelOwner, async (req, res) => { + try { + const days = Math.min(parseInt(req.query.days,10)||7, 30); + const { rows: [settings] } = await query( + 'SELECT posts_per_day FROM channel_autogen_settings WHERE channel_id=$1', + [req.channelId] + ); + const postsPerDay = settings?.posts_per_day || 3; + + const { rows: cats } = await query( + 'SELECT id, name, icon, color FROM channel_categories WHERE channel_id=$1 AND is_active=true ORDER BY sort_order, id', + [req.channelId] + ); + + const now = new Date(); + const preview = []; + for (let d = 0; d < days; d++) { + const date = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + d)); + const dayOfYear = Math.floor((date - Date.UTC(date.getUTCFullYear(), 0, 0)) / 86400000); + const offset = cats.length > postsPerDay ? dayOfYear % cats.length : 0; + const todayCats = cats.length <= postsPerDay + ? cats + : Array.from({ length: postsPerDay }, (_, i) => cats[(offset + i) % cats.length]); + preview.push({ + date: date.toISOString().slice(0, 10), + categories: todayCats.map(c => ({ id: c.id, name: c.name, icon: c.icon, color: c.color })), + }); + } + res.json({ ok: true, posts_per_day: postsPerDay, preview }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +module.exports = router; diff --git a/src/routes/channelCategories.js b/src/routes/channelCategories.js new file mode 100644 index 0000000..39b598d --- /dev/null +++ b/src/routes/channelCategories.js @@ -0,0 +1,183 @@ +/** + * /api/channel-categories — CRUD категорий канала пользователя. + * + * Все операции привязаны к channel_id и user_id (через auth middleware). + */ +const express = require('express'); +const router = express.Router({ mergeParams: true }); // :channelId из parent +const { query } = require('../config/db'); +const { generateTopicsForCategory } = require('../services/autogenNew'); + +const ALLOWED_COLORS = ['emerald','red','amber','blue','purple','pink','cyan','orange','lime','rose','slate','neutral']; + +// Middleware: проверяем что канал принадлежит пользователю +async function checkChannelOwner(req, res, next) { + const userId = req.user?.id || req.headers['x-user-id']; + const channelId = parseInt(req.params.channelId, 10); + if (!userId || !channelId) return res.status(400).json({ error: 'channelId required' }); + const { rows } = await query('SELECT id FROM channels WHERE id=$1 AND user_id=$2', [channelId, userId]); + if (!rows.length) return res.status(403).json({ error: 'Channel not found or access denied' }); + req.channelId = channelId; + next(); +} + +// GET /api/channels/:channelId/categories +router.get('/', checkChannelOwner, async (req, res) => { + try { + const { rows } = await query(` + SELECT cc.*, + (SELECT COUNT(*) FROM category_topics ct WHERE ct.category_id=cc.id AND ct.is_used=false) AS topics_free, + (SELECT COUNT(*) FROM category_topics ct WHERE ct.category_id=cc.id) AS topics_total, + (SELECT ct.topic FROM category_topics ct WHERE ct.category_id=cc.id AND ct.is_used=false + ORDER BY ct.priority DESC, ct.created_at ASC LIMIT 1) AS next_topic + FROM channel_categories cc + WHERE cc.channel_id=$1 + ORDER BY cc.sort_order, cc.id + `, [req.channelId]); + res.json({ ok: true, items: rows }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +// POST /api/channels/:channelId/categories +router.post('/', checkChannelOwner, async (req, res) => { + try { + const { slug, name, description, icon='📝', color='emerald', sort_order=99 } = req.body; + if (!slug || !name) return res.status(400).json({ error: 'slug and name required' }); + if (!/^[a-z0-9][a-z0-9-]{0,48}[a-z0-9]$/.test(slug)) { + return res.status(400).json({ error: 'slug: only lowercase letters, digits, hyphens' }); + } + const safeColor = ALLOWED_COLORS.includes(color) ? color : 'emerald'; + const { rows: [cat] } = await query(` + INSERT INTO channel_categories (channel_id, slug, name, description, icon, color, sort_order) + VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING * + `, [req.channelId, slug, name, description||null, icon, safeColor, sort_order]); + res.status(201).json({ ok: true, category: cat }); + } catch (err) { + if (err.code === '23505') return res.status(409).json({ error: `Slug "${req.body.slug}" already exists` }); + res.status(500).json({ error: err.message }); + } +}); + +// PATCH /api/channels/:channelId/categories/:id +router.patch('/:id', checkChannelOwner, async (req, res) => { + try { + const id = parseInt(req.params.id, 10); + const allowed = ['name','description','icon','color','sort_order','is_active']; + const fields = []; const vals = []; let i = 1; + for (const key of allowed) { + if (req.body[key] !== undefined) { + let val = req.body[key]; + if (key === 'color') val = ALLOWED_COLORS.includes(val) ? val : 'emerald'; + fields.push(`${key}=$${i++}`); vals.push(val); + } + } + if (!fields.length) return res.status(400).json({ error: 'nothing to update' }); + vals.push(id, req.channelId); + const { rows: [cat] } = await query( + `UPDATE channel_categories SET ${fields.join(',')} WHERE id=$${i} AND channel_id=$${i+1} RETURNING *`, + vals + ); + if (!cat) return res.status(404).json({ error: 'Category not found' }); + res.json({ ok: true, category: cat }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +// DELETE /api/channels/:channelId/categories/:id +router.delete('/:id', checkChannelOwner, async (req, res) => { + try { + const id = parseInt(req.params.id, 10); + const force = req.query.force === 'true'; + + if (force) { + const { rows: [{ cnt }] } = await query( + 'SELECT COUNT(*)::int AS cnt FROM posts WHERE source_category_id=$1', [id] + ); + if (cnt > 0 && !req.query.confirm) { + return res.status(409).json({ error: `${cnt} posts linked to this category. Add ?confirm=true to force.` }); + } + await query('DELETE FROM channel_categories WHERE id=$1 AND channel_id=$2', [id, req.channelId]); + return res.json({ ok: true, deleted: 'hard' }); + } + + await query('UPDATE channel_categories SET is_active=false WHERE id=$1 AND channel_id=$2', [id, req.channelId]); + res.json({ ok: true, deleted: 'soft' }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +// ── Topics ─────────────────────────────────────────────────────────────────── + +// GET /api/channels/:channelId/categories/:id/topics +router.get('/:id/topics', checkChannelOwner, async (req, res) => { + try { + const id = parseInt(req.params.id, 10); + const free = req.query.free !== 'false'; + const { rows } = await query(` + SELECT id, topic, genre, priority, is_used, source, created_at + FROM category_topics + WHERE category_id=$1 AND channel_id=$2 ${free ? 'AND is_used=false' : ''} + ORDER BY priority DESC, created_at ASC + LIMIT 100 + `, [id, req.channelId]); + res.json({ ok: true, topics: rows }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +// POST /api/channels/:channelId/categories/:id/topics — добавить тему вручную +router.post('/:id/topics', checkChannelOwner, async (req, res) => { + try { + const catId = parseInt(req.params.id, 10); + const { topic, genre, priority = 5 } = req.body; + if (!topic?.trim()) return res.status(400).json({ error: 'topic required' }); + const { rows: [row] } = await query(` + INSERT INTO category_topics (channel_id, category_id, topic, genre, priority, source) + VALUES ($1,$2,$3,$4,$5,'manual') ON CONFLICT DO NOTHING RETURNING * + `, [req.channelId, catId, topic.trim(), genre||null, priority]); + res.status(201).json({ ok: true, topic: row }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +// POST /api/channels/:channelId/categories/:id/topics/generate — AI генерация тем +router.post('/:id/topics/generate', checkChannelOwner, async (req, res) => { + try { + const catId = parseInt(req.params.id, 10); + const count = Math.min(parseInt(req.body.count, 10) || 15, 50); + res.json({ ok: true, message: `Генерирую ${count} тем...` }); + generateTopicsForCategory(req.channelId, catId, count).catch(err => + console.error('[TopicsGenerate] error:', err.message) + ); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +// PATCH /api/channels/:channelId/categories/:id/topics/:topicId +router.patch('/:id/topics/:topicId', checkChannelOwner, async (req, res) => { + try { + const { topic, genre, priority, is_used } = req.body; + const fields = []; const vals = []; let i = 1; + if (topic !== undefined) { fields.push(`topic=$${i++}`); vals.push(topic.trim()); } + if (genre !== undefined) { fields.push(`genre=$${i++}`); vals.push(genre); } + if (priority !== undefined) { fields.push(`priority=$${i++}`); vals.push(parseInt(priority,10)||5); } + if (is_used !== undefined) { + fields.push(`is_used=$${i++}`); vals.push(!!is_used); + if (!is_used) { fields.push(`used_at=NULL`); } + } + if (!fields.length) return res.status(400).json({ error: 'nothing to update' }); + vals.push(parseInt(req.params.topicId,10), req.channelId); + const { rows: [row] } = await query( + `UPDATE category_topics SET ${fields.join(',')} WHERE id=$${i} AND channel_id=$${i+1} RETURNING *`, + vals + ); + if (!row) return res.status(404).json({ error: 'Topic not found' }); + res.json({ ok: true, topic: row }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +// DELETE /api/channels/:channelId/categories/:id/topics/:topicId +router.delete('/:id/topics/:topicId', checkChannelOwner, async (req, res) => { + try { + await query('DELETE FROM category_topics WHERE id=$1 AND channel_id=$2', + [parseInt(req.params.topicId,10), req.channelId]); + res.json({ ok: true }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +module.exports = router; diff --git a/src/services/autogen.old.js b/src/services/autogen.old.js new file mode 100644 index 0000000..71ab274 --- /dev/null +++ b/src/services/autogen.old.js @@ -0,0 +1,211 @@ +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) { + // 1. Приоритетная очередь (content_queue) + 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 || [] }; + } + + // 2. DB-банк тем — неиспользованные + const { rows: dbTopics } = await query(` + SELECT bt.id, bt.topic FROM blog_topics bt + WHERE bt.category = $1 + AND bt.is_used = false + AND NOT EXISTS ( + SELECT 1 FROM articles a + WHERE a.source_topic = bt.topic AND a.category = $1 + ) + ORDER BY bt.priority DESC, bt.created_at ASC + LIMIT 1 + `, [category]); + + if (dbTopics.length) { + return { id: null, topic: dbTopics[0].topic, tags: [], keywords: [], blog_topic_id: dbTopics[0].id }; + } + + // 3. Fallback: хардкод если DB пустой + const bank = TOPIC_BANK[category] || TOPIC_BANK['ai-tools']; + const { rows: usedTopics } = await query( + `SELECT source_topic FROM articles WHERE category=$1 AND source_topic IS NOT NULL`, + [category] + ); + const usedSet = new Set(usedTopics.map(r => r.source_topic?.toLowerCase().trim()).filter(Boolean)); + const unused = bank.filter(t => !usedSet.has(t.toLowerCase().trim())); + const pool = unused.length > 0 ? unused : bank; + const topic = pool[Math.floor(Math.random() * pool.length)]; + return { id: null, topic, tags: [], 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, + 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 } = {}) { + let whereClause, params = []; + + if (forceCategory) { + // Ручной запуск — игнорируем время + whereClause = `WHERE enabled=true AND category=$1`; + params = [forceCategory]; + } else { + // Автоматический запуск — проверяем время по расписанию + // Берём текущий час/минуту в московском времени (UTC+3) + const now = new Date(); + const mskOffset = 3 * 60; // UTC+3 + const mskTime = new Date(now.getTime() + mskOffset * 60000); + const currentHour = mskTime.getUTCHours(); + const currentMinute = mskTime.getUTCMinutes(); + + console.log(`[Autogen] Check time MSK ${String(currentHour).padStart(2,'0')}:${String(currentMinute).padStart(2,'0')}`); + + whereClause = `WHERE enabled=true + AND run_hour=$1 + AND run_minute BETWEEN $2 AND $3 + AND (last_run_at IS NULL OR last_run_at < NOW() - INTERVAL '6 hours')`; + params = [currentHour, currentMinute - 5, currentMinute + 5]; + } + + const { rows: settings } = await query( + `SELECT * FROM autogen_settings ${whereClause} ORDER BY category`, + params + ); + + if (!settings.length) { + console.log('[Autogen] Nothing to generate at this time'); + return { processed: 0, results: [] }; + } + + const results = []; + for (const s of settings) { + const result = await runAutogenForCategory(s.category); + results.push({ category: s.category, ...result }); + 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 }; diff --git a/src/services/autogenNew.js b/src/services/autogenNew.js new file mode 100644 index 0000000..569aba7 --- /dev/null +++ b/src/services/autogenNew.js @@ -0,0 +1,379 @@ +/** + * PostCast autogen service — генерация контента для каналов пользователей. + * + * Ключевые отличия от ZeroPost: + * - категории и темы принадлежат каналу (не системе) + * - posts_per_day настраивается per-канал (3, 5, 10 — любое) + * - ротация: берём posts_per_day категорий из всего списка по скользящему окну + * - pg_advisory_lock устраняет race condition при параллельных запусках + */ + +const { query } = require('../config/db'); +const ai = require('./ai'); +const settings = require('./settings'); + +// ───────────────────────────────────────────────────────────────────────────── +// Выбор категорий на сегодня (ротация) +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Возвращает ids категорий для генерации сегодня. + * Алгоритм: скользящее окно размером posts_per_day сдвигается на 1 каждый день. + * Если категорий <= posts_per_day — берём все. + * + * @param {number} channelId + * @param {number} postsPerDay + * @returns {Promise} массив category_id + */ +async function getTodayCategoryIds(channelId, postsPerDay) { + const { rows: cats } = await query( + `SELECT id FROM channel_categories + WHERE channel_id=$1 AND is_active=true + ORDER BY sort_order, id`, + [channelId] + ); + if (!cats.length) return []; + if (cats.length <= postsPerDay) return cats.map(c => c.id); + + const now = new Date(); + const dayOfYear = Math.floor( + (Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()) - + Date.UTC(now.getUTCFullYear(), 0, 0)) / 86400000 + ); + const offset = dayOfYear % cats.length; + return Array.from({ length: postsPerDay }, (_, i) => + cats[(offset + i) % cats.length].id + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Выбор следующей темы (атомарно) +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Атомарно захватывает следующую свободную тему категории. + * FOR UPDATE SKIP LOCKED + немедленный UPDATE is_used=true → нет дублей. + */ +async function pickNextTopic(categoryId, channelId) { + // Сначала смотрим category_topics + const { rows } = await query(` + UPDATE category_topics + SET is_used=true, used_at=NOW() + WHERE id = ( + SELECT id FROM category_topics + WHERE category_id=$1 + AND channel_id=$2 + AND is_used=false + AND NOT EXISTS ( + SELECT 1 FROM posts p + WHERE p.source_topic=category_topics.topic + AND p.channel_id=$2 + ) + ORDER BY priority DESC, created_at ASC + LIMIT 1 + FOR UPDATE SKIP LOCKED + ) + RETURNING id, topic, genre + `, [categoryId, channelId]); + + if (rows.length) { + const { topic, genre } = rows[0]; + // Убираем жанровый маркер из темы если он есть — он уже в genre + const cleanTopic = topic.replace(/^\[(ТУТОРИАЛ|СРАВНЕНИЕ|МНЕНИЕ|ДАЙДЖЕСТ|КЕЙС|НОВОСТЬ)[^\]]*\]\s*/i, ''); + return { topic: cleanTopic, rawTopic: topic, genre: genre || detectGenre(topic) }; + } + + // Fallback: случайная свежая тема без ограничений + const { rows: any } = await query(` + SELECT topic, genre FROM category_topics + WHERE category_id=$1 AND channel_id=$2 + ORDER BY RANDOM() LIMIT 1 + `, [categoryId, channelId]); + + if (any.length) { + return { topic: any[0].topic, rawTopic: any[0].topic, genre: any[0].genre }; + } + + return null; // тем нет совсем +} + +/** + * Определяет жанр по маркеру в теме. + */ +function detectGenre(topic) { + const m = topic.match(/^\[([^\]]+)\]/); + if (!m) return null; + const map = { ТУТОРИАЛ:'tutorial', СРАВНЕНИЕ:'comparison', МНЕНИЕ:'opinion', ДАЙДЖЕСТ:'digest', КЕЙС:'case', НОВОСТЬ:'news' }; + return map[m[1].toUpperCase()] || null; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Генерация поста +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Генерирует пост через AI и сохраняет как draft. + */ +async function generatePostForCategory(channelId, categoryId) { + const lockKey = Math.abs((channelId * 1000 + categoryId) & 0x7fffffff); + await query('SELECT pg_advisory_lock($1)', [lockKey]); + + try { + // Двойная проверка: уже генерировали сегодня для этой категории? + const { rows: today } = await query(` + SELECT id FROM posts + WHERE channel_id=$1 + AND source_category_id=$2 + AND status='draft' + AND created_at >= CURRENT_DATE + LIMIT 1 + `, [channelId, categoryId]); + + if (today.length) { + console.log(`[Autogen] channel=${channelId} cat=${categoryId}: already generated today, skip`); + return { ok: false, skipped: true }; + } + + // Берём тему + const topicData = await pickNextTopic(categoryId, channelId); + if (!topicData) { + console.log(`[Autogen] channel=${channelId} cat=${categoryId}: no topics available`); + return { ok: false, reason: 'no topics' }; + } + + // Данные канала и категории для промпта + const { rows: [channel] } = await query( + 'SELECT name, niche, audience, goal, language, platform FROM channels WHERE id=$1', + [channelId] + ); + const { rows: [cat] } = await query( + 'SELECT name, description, icon FROM channel_categories WHERE id=$1', + [categoryId] + ); + + // Строим промпт с учётом жанра + const genreHints = { + tutorial: 'Формат: пошаговый туториал с конкретными примерами и кодом/скриншотами.', + comparison: 'Формат: честное сравнение X vs Y. Таблица плюсов/минусов, личный вывод.', + opinion: 'Формат: личное мнение / hot take. Смело, конкретно, с аргументами.', + digest: 'Формат: дайджест — 5 пунктов, каждый с кратким описанием и ссылкой.', + case: 'Формат: личный кейс. Задача → решение → результат + цифры.', + news: 'Формат: новость. Что случилось, почему важно, что делать читателю.', + }; + const genreHint = genreHints[topicData.genre] || ''; + + const platform = channel?.platform || 'telegram'; + const platformRules = { + telegram: 'Оптимальная длина 800–1500 символов. Markdown: **жирный**, _курсив_. Emoji уместны.', + vk: 'Длина до 4096 символов. HTML-разметка. Хэштеги в конце.', + max: 'Длина до 2000 символов. Эмодзи и короткие абзацы.', + }; + + const system = `Ты контент-менеджер канала "${channel?.name || 'канал'}" в ${platform}. +Ниша: ${channel?.niche || cat?.name || 'общая'}. Аудитория: ${channel?.audience || 'широкая'}. +Цель: ${channel?.goal || 'informational'}. +${cat ? `Категория: ${cat.icon} ${cat.name}${cat.description ? ` — ${cat.description}` : ''}.` : ''} +${genreHint} +Правила платформы: ${platformRules[platform] || platformRules.telegram} +Пиши живо, от первого лица, с конкретикой. Без воды и клише. Без объяснений — только пост.`; + + const userMsg = `Напиши пост на тему: "${topicData.topic}"`; + + const model = await settings.get('AUTOGEN_MODEL', 'claude-haiku-4-5-20251001'); + const aiResult = await ai.chat(model, system, userMsg, 0.85, 1200); + const content = typeof aiResult === 'string' ? aiResult : (aiResult?.text || aiResult); + + if (!content || content.length < 50) { + throw new Error('AI вернул пустой или слишком короткий пост'); + } + + // Сохраняем как draft + const { rows: [post] } = await query(` + INSERT INTO posts (channel_id, content, status, source_topic, source_category_id, genre, platform, created_at) + VALUES ($1,$2,'draft',$3,$4,$5,$6,NOW()) + RETURNING id, content, status + `, [channelId, content.trim(), topicData.rawTopic, categoryId, topicData.genre, platform]); + + console.log(`[Autogen] channel=${channelId} cat=${categoryId} → post#${post.id} (${topicData.genre || 'generic'}) "${topicData.topic.slice(0,50)}"`); + return { ok: true, post }; + + } finally { + await query('SELECT pg_advisory_unlock($1)', [lockKey]).catch(() => {}); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Главный runner — запускается по расписанию +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Запускает генерацию для канала (или всех каналов с включённым autogen). + * @param {object} opts + * @param {number} [opts.channelId] — если задан, только этот канал + * @param {number} [opts.categoryId] — если задан, только эта категория + */ +async function runAutogen({ channelId = null, categoryId = null } = {}) { + const now = new Date(); + const msk = new Date(now.getTime() + 3 * 3600_000); + const hourMsk = msk.getUTCHours(); + const minMsk = msk.getUTCMinutes(); + + let channels; + + if (channelId) { + // Принудительный запуск для конкретного канала + const { rows } = await query( + `SELECT s.*, c.id as channel_id FROM channel_autogen_settings s + JOIN channels c ON c.id=s.channel_id + WHERE s.channel_id=$1 AND c.is_active=true`, + [channelId] + ); + channels = rows; + } else { + // Плановый запуск: берём все каналы с enabled=true у которых сейчас их час + const { rows } = await query(` + SELECT s.*, c.id as channel_id + FROM channel_autogen_settings s + JOIN channels c ON c.id=s.channel_id + WHERE s.enabled=true + AND c.is_active=true + AND s.run_hour=$1 + AND s.run_minute BETWEEN $2 AND $3 + AND (s.last_run_at IS NULL OR s.last_run_at < NOW() - INTERVAL '6 hours') + `, [hourMsk, minMsk - 5, minMsk + 5]); + channels = rows; + } + + if (!channels.length) { + if (!channelId) console.log('[Autogen] Nothing to generate at this time'); + return { processed: 0, results: [] }; + } + + const results = []; + for (const ch of channels) { + const postsPerDay = categoryId ? 1 : (ch.posts_per_day || 3); + const catIds = categoryId ? [categoryId] : await getTodayCategoryIds(ch.channel_id, postsPerDay); + + for (const catId of catIds) { + const result = await generatePostForCategory(ch.channel_id, catId); + results.push({ channel_id: ch.channel_id, category_id: catId, ...result }); + await new Promise(r => setTimeout(r, 3000)); // пауза между генерациями + } + + // Обновляем last_run_at + await query( + 'UPDATE channel_autogen_settings SET last_run_at=NOW() WHERE channel_id=$1', + [ch.channel_id] + ); + } + + return { processed: channels.length, results }; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Статус для UI +// ───────────────────────────────────────────────────────────────────────────── + +async function getAutogenStatus(channelId) { + const { rows } = await query(` + SELECT + s.*, + (SELECT COUNT(*) FROM channel_categories cc WHERE cc.channel_id=s.channel_id AND cc.is_active=true) AS category_count, + (SELECT COUNT(*) FROM category_topics ct + JOIN channel_categories cc ON cc.id=ct.category_id + WHERE cc.channel_id=s.channel_id AND ct.is_used=false) AS topics_free, + (SELECT COUNT(*) FROM posts p WHERE p.channel_id=s.channel_id AND p.status='draft' AND p.created_at>=CURRENT_DATE) AS drafts_today + FROM channel_autogen_settings s + WHERE s.channel_id=$1 + `, [channelId]); + + if (!rows.length) return null; + const st = rows[0]; + + // Категории с флагом today_active + const postsPerDay = st.posts_per_day || 3; + const todayIds = new Set(await getTodayCategoryIds(channelId, postsPerDay)); + + const { rows: cats } = await query(` + SELECT cc.*, ct_free.free_count, ct_next.next_topic + FROM channel_categories cc + LEFT JOIN LATERAL ( + SELECT COUNT(*) AS free_count FROM category_topics ct + WHERE ct.category_id=cc.id AND ct.is_used=false + ) ct_free ON true + LEFT JOIN LATERAL ( + SELECT topic AS next_topic FROM category_topics ct + WHERE ct.category_id=cc.id AND ct.is_used=false + ORDER BY priority DESC, created_at ASC LIMIT 1 + ) ct_next ON true + WHERE cc.channel_id=$1 AND cc.is_active=true + ORDER BY cc.sort_order, cc.id + `, [channelId]); + + return { + ...st, + categories: cats.map(c => ({ + ...c, + today_active: todayIds.has(c.id), + })), + }; +} + +/** + * AI-генерация тем для категории. + */ +async function generateTopicsForCategory(channelId, categoryId, count = 15) { + const { rows: [cat] } = await query( + 'SELECT * FROM channel_categories WHERE id=$1 AND channel_id=$2', + [categoryId, channelId] + ); + if (!cat) throw new Error('Category not found'); + + const { rows: [channel] } = await query( + 'SELECT name, niche, audience, language FROM channels WHERE id=$1', + [channelId] + ); + + const system = `Ты контент-стратег. Генерируй темы для постов. +Отвечай ТОЛЬКО JSON массивом строк, без пояснений и без markdown-блоков.`; + + const genres = ['[ТУТОРИАЛ]', '[СРАВНЕНИЕ]', '[МНЕНИЕ]', '[ДАЙДЖЕСТ]', '[КЕЙС]']; + const userMsg = `Канал: "${channel?.name || ''}" в нише "${channel?.niche || cat.name}". +Категория: "${cat.name}"${cat.description ? ` — ${cat.description}` : ''}. +Аудитория: ${channel?.audience || 'широкая'}. + +Сгенерируй ${count} разных тем. Равномерно распредели по жанрам: ${genres.join(', ')}. +Каждая тема = строка с жанровым маркером в начале. +Пример: ["[ТУТОРИАЛ] Как настроить X за 30 минут", "[СРАВНЕНИЕ] X vs Y: честный разбор", ...] + +Темы должны быть конкретными, цепляющими и актуальными.`; + + const model = await settings.get('AUTOGEN_MODEL', 'claude-haiku-4-5-20251001'); + const aiResult = await ai.chat(model, system, userMsg, 0.9, 800); + const raw = typeof aiResult === 'string' ? aiResult : (aiResult?.text || ''); + const topics = JSON.parse(raw.replace(/```json|```/g, '').trim()); + + let added = 0; + for (const topic of topics.slice(0, count)) { + if (!topic?.trim()) continue; + const genre = detectGenre(topic); + const { rows: [row] } = await query(` + INSERT INTO category_topics (channel_id, category_id, topic, genre, source) + VALUES ($1,$2,$3,$4,'ai') + ON CONFLICT DO NOTHING RETURNING id + `, [channelId, categoryId, topic.trim(), genre]); + if (row) added++; + } + + console.log(`[Autogen] Generated ${added} topics for cat#${categoryId} channel#${channelId}`); + return added; +} + +module.exports = { + runAutogen, + generatePostForCategory, + getAutogenStatus, + getTodayCategoryIds, + generateTopicsForCategory, + detectGenre, +}; diff --git a/src/services/autogenNew_final.js b/src/services/autogenNew_final.js new file mode 100644 index 0000000..569aba7 --- /dev/null +++ b/src/services/autogenNew_final.js @@ -0,0 +1,379 @@ +/** + * PostCast autogen service — генерация контента для каналов пользователей. + * + * Ключевые отличия от ZeroPost: + * - категории и темы принадлежат каналу (не системе) + * - posts_per_day настраивается per-канал (3, 5, 10 — любое) + * - ротация: берём posts_per_day категорий из всего списка по скользящему окну + * - pg_advisory_lock устраняет race condition при параллельных запусках + */ + +const { query } = require('../config/db'); +const ai = require('./ai'); +const settings = require('./settings'); + +// ───────────────────────────────────────────────────────────────────────────── +// Выбор категорий на сегодня (ротация) +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Возвращает ids категорий для генерации сегодня. + * Алгоритм: скользящее окно размером posts_per_day сдвигается на 1 каждый день. + * Если категорий <= posts_per_day — берём все. + * + * @param {number} channelId + * @param {number} postsPerDay + * @returns {Promise} массив category_id + */ +async function getTodayCategoryIds(channelId, postsPerDay) { + const { rows: cats } = await query( + `SELECT id FROM channel_categories + WHERE channel_id=$1 AND is_active=true + ORDER BY sort_order, id`, + [channelId] + ); + if (!cats.length) return []; + if (cats.length <= postsPerDay) return cats.map(c => c.id); + + const now = new Date(); + const dayOfYear = Math.floor( + (Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()) - + Date.UTC(now.getUTCFullYear(), 0, 0)) / 86400000 + ); + const offset = dayOfYear % cats.length; + return Array.from({ length: postsPerDay }, (_, i) => + cats[(offset + i) % cats.length].id + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Выбор следующей темы (атомарно) +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Атомарно захватывает следующую свободную тему категории. + * FOR UPDATE SKIP LOCKED + немедленный UPDATE is_used=true → нет дублей. + */ +async function pickNextTopic(categoryId, channelId) { + // Сначала смотрим category_topics + const { rows } = await query(` + UPDATE category_topics + SET is_used=true, used_at=NOW() + WHERE id = ( + SELECT id FROM category_topics + WHERE category_id=$1 + AND channel_id=$2 + AND is_used=false + AND NOT EXISTS ( + SELECT 1 FROM posts p + WHERE p.source_topic=category_topics.topic + AND p.channel_id=$2 + ) + ORDER BY priority DESC, created_at ASC + LIMIT 1 + FOR UPDATE SKIP LOCKED + ) + RETURNING id, topic, genre + `, [categoryId, channelId]); + + if (rows.length) { + const { topic, genre } = rows[0]; + // Убираем жанровый маркер из темы если он есть — он уже в genre + const cleanTopic = topic.replace(/^\[(ТУТОРИАЛ|СРАВНЕНИЕ|МНЕНИЕ|ДАЙДЖЕСТ|КЕЙС|НОВОСТЬ)[^\]]*\]\s*/i, ''); + return { topic: cleanTopic, rawTopic: topic, genre: genre || detectGenre(topic) }; + } + + // Fallback: случайная свежая тема без ограничений + const { rows: any } = await query(` + SELECT topic, genre FROM category_topics + WHERE category_id=$1 AND channel_id=$2 + ORDER BY RANDOM() LIMIT 1 + `, [categoryId, channelId]); + + if (any.length) { + return { topic: any[0].topic, rawTopic: any[0].topic, genre: any[0].genre }; + } + + return null; // тем нет совсем +} + +/** + * Определяет жанр по маркеру в теме. + */ +function detectGenre(topic) { + const m = topic.match(/^\[([^\]]+)\]/); + if (!m) return null; + const map = { ТУТОРИАЛ:'tutorial', СРАВНЕНИЕ:'comparison', МНЕНИЕ:'opinion', ДАЙДЖЕСТ:'digest', КЕЙС:'case', НОВОСТЬ:'news' }; + return map[m[1].toUpperCase()] || null; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Генерация поста +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Генерирует пост через AI и сохраняет как draft. + */ +async function generatePostForCategory(channelId, categoryId) { + const lockKey = Math.abs((channelId * 1000 + categoryId) & 0x7fffffff); + await query('SELECT pg_advisory_lock($1)', [lockKey]); + + try { + // Двойная проверка: уже генерировали сегодня для этой категории? + const { rows: today } = await query(` + SELECT id FROM posts + WHERE channel_id=$1 + AND source_category_id=$2 + AND status='draft' + AND created_at >= CURRENT_DATE + LIMIT 1 + `, [channelId, categoryId]); + + if (today.length) { + console.log(`[Autogen] channel=${channelId} cat=${categoryId}: already generated today, skip`); + return { ok: false, skipped: true }; + } + + // Берём тему + const topicData = await pickNextTopic(categoryId, channelId); + if (!topicData) { + console.log(`[Autogen] channel=${channelId} cat=${categoryId}: no topics available`); + return { ok: false, reason: 'no topics' }; + } + + // Данные канала и категории для промпта + const { rows: [channel] } = await query( + 'SELECT name, niche, audience, goal, language, platform FROM channels WHERE id=$1', + [channelId] + ); + const { rows: [cat] } = await query( + 'SELECT name, description, icon FROM channel_categories WHERE id=$1', + [categoryId] + ); + + // Строим промпт с учётом жанра + const genreHints = { + tutorial: 'Формат: пошаговый туториал с конкретными примерами и кодом/скриншотами.', + comparison: 'Формат: честное сравнение X vs Y. Таблица плюсов/минусов, личный вывод.', + opinion: 'Формат: личное мнение / hot take. Смело, конкретно, с аргументами.', + digest: 'Формат: дайджест — 5 пунктов, каждый с кратким описанием и ссылкой.', + case: 'Формат: личный кейс. Задача → решение → результат + цифры.', + news: 'Формат: новость. Что случилось, почему важно, что делать читателю.', + }; + const genreHint = genreHints[topicData.genre] || ''; + + const platform = channel?.platform || 'telegram'; + const platformRules = { + telegram: 'Оптимальная длина 800–1500 символов. Markdown: **жирный**, _курсив_. Emoji уместны.', + vk: 'Длина до 4096 символов. HTML-разметка. Хэштеги в конце.', + max: 'Длина до 2000 символов. Эмодзи и короткие абзацы.', + }; + + const system = `Ты контент-менеджер канала "${channel?.name || 'канал'}" в ${platform}. +Ниша: ${channel?.niche || cat?.name || 'общая'}. Аудитория: ${channel?.audience || 'широкая'}. +Цель: ${channel?.goal || 'informational'}. +${cat ? `Категория: ${cat.icon} ${cat.name}${cat.description ? ` — ${cat.description}` : ''}.` : ''} +${genreHint} +Правила платформы: ${platformRules[platform] || platformRules.telegram} +Пиши живо, от первого лица, с конкретикой. Без воды и клише. Без объяснений — только пост.`; + + const userMsg = `Напиши пост на тему: "${topicData.topic}"`; + + const model = await settings.get('AUTOGEN_MODEL', 'claude-haiku-4-5-20251001'); + const aiResult = await ai.chat(model, system, userMsg, 0.85, 1200); + const content = typeof aiResult === 'string' ? aiResult : (aiResult?.text || aiResult); + + if (!content || content.length < 50) { + throw new Error('AI вернул пустой или слишком короткий пост'); + } + + // Сохраняем как draft + const { rows: [post] } = await query(` + INSERT INTO posts (channel_id, content, status, source_topic, source_category_id, genre, platform, created_at) + VALUES ($1,$2,'draft',$3,$4,$5,$6,NOW()) + RETURNING id, content, status + `, [channelId, content.trim(), topicData.rawTopic, categoryId, topicData.genre, platform]); + + console.log(`[Autogen] channel=${channelId} cat=${categoryId} → post#${post.id} (${topicData.genre || 'generic'}) "${topicData.topic.slice(0,50)}"`); + return { ok: true, post }; + + } finally { + await query('SELECT pg_advisory_unlock($1)', [lockKey]).catch(() => {}); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Главный runner — запускается по расписанию +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Запускает генерацию для канала (или всех каналов с включённым autogen). + * @param {object} opts + * @param {number} [opts.channelId] — если задан, только этот канал + * @param {number} [opts.categoryId] — если задан, только эта категория + */ +async function runAutogen({ channelId = null, categoryId = null } = {}) { + const now = new Date(); + const msk = new Date(now.getTime() + 3 * 3600_000); + const hourMsk = msk.getUTCHours(); + const minMsk = msk.getUTCMinutes(); + + let channels; + + if (channelId) { + // Принудительный запуск для конкретного канала + const { rows } = await query( + `SELECT s.*, c.id as channel_id FROM channel_autogen_settings s + JOIN channels c ON c.id=s.channel_id + WHERE s.channel_id=$1 AND c.is_active=true`, + [channelId] + ); + channels = rows; + } else { + // Плановый запуск: берём все каналы с enabled=true у которых сейчас их час + const { rows } = await query(` + SELECT s.*, c.id as channel_id + FROM channel_autogen_settings s + JOIN channels c ON c.id=s.channel_id + WHERE s.enabled=true + AND c.is_active=true + AND s.run_hour=$1 + AND s.run_minute BETWEEN $2 AND $3 + AND (s.last_run_at IS NULL OR s.last_run_at < NOW() - INTERVAL '6 hours') + `, [hourMsk, minMsk - 5, minMsk + 5]); + channels = rows; + } + + if (!channels.length) { + if (!channelId) console.log('[Autogen] Nothing to generate at this time'); + return { processed: 0, results: [] }; + } + + const results = []; + for (const ch of channels) { + const postsPerDay = categoryId ? 1 : (ch.posts_per_day || 3); + const catIds = categoryId ? [categoryId] : await getTodayCategoryIds(ch.channel_id, postsPerDay); + + for (const catId of catIds) { + const result = await generatePostForCategory(ch.channel_id, catId); + results.push({ channel_id: ch.channel_id, category_id: catId, ...result }); + await new Promise(r => setTimeout(r, 3000)); // пауза между генерациями + } + + // Обновляем last_run_at + await query( + 'UPDATE channel_autogen_settings SET last_run_at=NOW() WHERE channel_id=$1', + [ch.channel_id] + ); + } + + return { processed: channels.length, results }; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Статус для UI +// ───────────────────────────────────────────────────────────────────────────── + +async function getAutogenStatus(channelId) { + const { rows } = await query(` + SELECT + s.*, + (SELECT COUNT(*) FROM channel_categories cc WHERE cc.channel_id=s.channel_id AND cc.is_active=true) AS category_count, + (SELECT COUNT(*) FROM category_topics ct + JOIN channel_categories cc ON cc.id=ct.category_id + WHERE cc.channel_id=s.channel_id AND ct.is_used=false) AS topics_free, + (SELECT COUNT(*) FROM posts p WHERE p.channel_id=s.channel_id AND p.status='draft' AND p.created_at>=CURRENT_DATE) AS drafts_today + FROM channel_autogen_settings s + WHERE s.channel_id=$1 + `, [channelId]); + + if (!rows.length) return null; + const st = rows[0]; + + // Категории с флагом today_active + const postsPerDay = st.posts_per_day || 3; + const todayIds = new Set(await getTodayCategoryIds(channelId, postsPerDay)); + + const { rows: cats } = await query(` + SELECT cc.*, ct_free.free_count, ct_next.next_topic + FROM channel_categories cc + LEFT JOIN LATERAL ( + SELECT COUNT(*) AS free_count FROM category_topics ct + WHERE ct.category_id=cc.id AND ct.is_used=false + ) ct_free ON true + LEFT JOIN LATERAL ( + SELECT topic AS next_topic FROM category_topics ct + WHERE ct.category_id=cc.id AND ct.is_used=false + ORDER BY priority DESC, created_at ASC LIMIT 1 + ) ct_next ON true + WHERE cc.channel_id=$1 AND cc.is_active=true + ORDER BY cc.sort_order, cc.id + `, [channelId]); + + return { + ...st, + categories: cats.map(c => ({ + ...c, + today_active: todayIds.has(c.id), + })), + }; +} + +/** + * AI-генерация тем для категории. + */ +async function generateTopicsForCategory(channelId, categoryId, count = 15) { + const { rows: [cat] } = await query( + 'SELECT * FROM channel_categories WHERE id=$1 AND channel_id=$2', + [categoryId, channelId] + ); + if (!cat) throw new Error('Category not found'); + + const { rows: [channel] } = await query( + 'SELECT name, niche, audience, language FROM channels WHERE id=$1', + [channelId] + ); + + const system = `Ты контент-стратег. Генерируй темы для постов. +Отвечай ТОЛЬКО JSON массивом строк, без пояснений и без markdown-блоков.`; + + const genres = ['[ТУТОРИАЛ]', '[СРАВНЕНИЕ]', '[МНЕНИЕ]', '[ДАЙДЖЕСТ]', '[КЕЙС]']; + const userMsg = `Канал: "${channel?.name || ''}" в нише "${channel?.niche || cat.name}". +Категория: "${cat.name}"${cat.description ? ` — ${cat.description}` : ''}. +Аудитория: ${channel?.audience || 'широкая'}. + +Сгенерируй ${count} разных тем. Равномерно распредели по жанрам: ${genres.join(', ')}. +Каждая тема = строка с жанровым маркером в начале. +Пример: ["[ТУТОРИАЛ] Как настроить X за 30 минут", "[СРАВНЕНИЕ] X vs Y: честный разбор", ...] + +Темы должны быть конкретными, цепляющими и актуальными.`; + + const model = await settings.get('AUTOGEN_MODEL', 'claude-haiku-4-5-20251001'); + const aiResult = await ai.chat(model, system, userMsg, 0.9, 800); + const raw = typeof aiResult === 'string' ? aiResult : (aiResult?.text || ''); + const topics = JSON.parse(raw.replace(/```json|```/g, '').trim()); + + let added = 0; + for (const topic of topics.slice(0, count)) { + if (!topic?.trim()) continue; + const genre = detectGenre(topic); + const { rows: [row] } = await query(` + INSERT INTO category_topics (channel_id, category_id, topic, genre, source) + VALUES ($1,$2,$3,$4,'ai') + ON CONFLICT DO NOTHING RETURNING id + `, [channelId, categoryId, topic.trim(), genre]); + if (row) added++; + } + + console.log(`[Autogen] Generated ${added} topics for cat#${categoryId} channel#${channelId}`); + return added; +} + +module.exports = { + runAutogen, + generatePostForCategory, + getAutogenStatus, + getTodayCategoryIds, + generateTopicsForCategory, + detectGenre, +};