feat(postcast): система категорий + банк тем + гибкая ротация

Новая архитектура автогенерации (перенос и доработка из 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 дней)
This commit is contained in:
Aleksei Pavlov
2026-06-24 19:22:36 +03:00
parent 5e075ac81d
commit 4ec3239dc3
7 changed files with 1345 additions and 0 deletions
+2
View File
@@ -18,6 +18,8 @@ const photoSearchRoutes = require('./src/routes/photo-search');
const scheduledPostsRoutes = require('./src/routes/scheduledPosts'); const scheduledPostsRoutes = require('./src/routes/scheduledPosts');
const channelStatsRoutes = require('./src/routes/channelStats'); const channelStatsRoutes = require('./src/routes/channelStats');
const calendarRoutes = require('./src/routes/calendar'); 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 metricsRoutes = require('./src/routes/metrics');
const usageRoutes = require('./src/routes/usage'); const usageRoutes = require('./src/routes/usage');
+67
View File
@@ -187,6 +187,73 @@ const migrate = async () => {
CREATE INDEX IF NOT EXISTS idx_series_slug ON series(slug); 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'); console.log('[DB] Migrations applied');
}; };
+124
View File
@@ -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;
+183
View File
@@ -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;
+211
View File
@@ -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 };
+379
View File
@@ -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<number[]>} массив 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,
};
+379
View File
@@ -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<number[]>} массив 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,
};