forked from admin/zeropost-engine
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:
@@ -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');
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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 };
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user