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-банк тем — атомарно захватываем следующую свободную тему. // FOR UPDATE SKIP LOCKED + немедленный UPDATE is_used=true устраняет race condition: // параллельные генерации не могут выбрать одну и ту же тему. const { rows: dbTopics } = await query(` UPDATE blog_topics SET is_used=true, used_at=NOW() WHERE id = ( SELECT bt.id 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 FOR UPDATE SKIP LOCKED ) RETURNING id, topic `, [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 shuffled = [...pool].sort(() => Math.random() - 0.5); const topic = shuffled[0]; return { id: null, topic, tags: [], keywords: [] }; } /** * Запускает генерацию одной статьи для категории. */ async function runAutogenForCategory(category) { // pg_advisory_lock: транзакционный lock по ключу категории. // Гарантирует что только один процесс генерирует статью для данной категории. // Устраняет race condition когда несколько тиков/запросов запускаются одновременно. const lockKey = Math.abs(category.split('').reduce((h, c) => (Math.imul(31, h) + c.charCodeAt(0)) | 0, 0)); await query('SELECT pg_advisory_lock($1)', [lockKey]); // После получения lock — проверяем ещё раз что за сегодня ещё не генерировали const { rows: alreadyToday } = await query( `SELECT id FROM articles WHERE category=$1 AND status='draft' AND created_at >= CURRENT_DATE LIMIT 1`, [category] ); if (alreadyToday.length) { await query('SELECT pg_advisory_unlock($1)', [lockKey]).catch(() => {}); console.log(`[Autogen] category=${category}: already generated today, skipping`); return { ok: false, skipped: true, reason: 'already generated today' }; } 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: false, // draft review flow 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 }; } finally { await query('SELECT pg_advisory_unlock($1)', [lockKey]).catch(() => {}); } } /** * Основная функция 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]; } // Сначала берём ВСЕ активные категории (независимо от времени), // затем применяем ротацию — выбираем 4 из 8 по дню года. const { rows: allEnabled } = await query( `SELECT * FROM autogen_settings WHERE enabled=true ORDER BY category`, [] ); // Ротация: скользящее окно из 4 категорий сдвигается на 1 каждый день. // Это гарантирует что за 8 дней каждая категория выйдет минимум 4 раза, // и каждый день читатель видит другой набор. const DAILY_COUNT = 4; const total = allEnabled.length; let categoriesForToday; if (total <= DAILY_COUNT) { // Категорий меньше или равно 4 — берём все categoriesForToday = allEnabled.map(s => s.category); } else { // День года (0..364) определяет сдвиг окна const now = new Date(); const start = Date.UTC(now.getUTCFullYear(), 0, 0); const dayOfYear = Math.floor((now - start) / 86400000); const offset = dayOfYear % total; // Берём 4 категории начиная со сдвига (с wrap-around) categoriesForToday = Array.from({ length: DAILY_COUNT }, (_, i) => allEnabled[(offset + i) % total].category ); console.log( '[Autogen] Ротация дня ' + dayOfYear + ' (offset=' + offset + '): ' + categoriesForToday.join(', ') ); } // Теперь фильтруем по расписанию (если не forceCategory) — категория // должна быть в списке дня И соответствовать текущему времени. const { rows: allSettings } = await query( `SELECT * FROM autogen_settings WHERE enabled=true ORDER BY run_hour, run_minute`, [] ); let settings; if (forceCategory) { settings = allSettings.filter(s => s.category === forceCategory); } else { // Время окна ±5 мин уже применено в whereClause — переиспользуем const { rows: timeFiltered } = await query( `SELECT * FROM autogen_settings ${whereClause} ORDER BY run_hour, run_minute`, params ); // Оставляем только категории дня из сработавших по времени const todaySet = new Set(categoriesForToday); settings = timeFiltered.filter(s => todaySet.has(s.category)); } if (!settings.length) { console.log('[Autogen] Nothing to generate at this time'); return { processed: 0, results: [] }; } const results = []; for (let i = 0; i < settings.length; i++) { const s = settings[i]; const result = await runAutogenForCategory(s.category); results.push({ category: s.category, ...result }); if (i < settings.length - 1) { await new Promise(r => setTimeout(r, 5000)); } } return { processed: settings.length, results }; } /** * Получить статус автогенерации. */ /** * Возвращает категории которые активны сегодня по ротации (4 из 8). */ function getTodayCategories(allCategories, dailyCount = 4) { if (allCategories.length <= dailyCount) return allCategories.map(c => c.category || c); const now = new Date(); const start = Date.UTC(now.getUTCFullYear(), 0, 0); const dayOfYear = Math.floor((now - start) / 86400000); const offset = dayOfYear % allCategories.length; return Array.from({ length: dailyCount }, (_, i) => (allCategories[(offset + i) % allCategories.length].category || allCategories[(offset + i) % allCategories.length]) ); } async function getAutogenStatus() { const { rows: settings } = await query( `SELECT s.*, c.name as cat_name, c.icon as cat_icon, c.color as cat_color, (SELECT COUNT(*) FROM articles a WHERE a.category=s.category AND a.status='published') AS article_count, (SELECT COUNT(*) FROM blog_topics bt WHERE bt.category=s.category AND bt.is_used=false) AS topic_count_free, (SELECT COUNT(*) FROM blog_topics bt WHERE bt.category=s.category) AS topic_count, (SELECT COUNT(*) FROM articles a WHERE a.category=s.category AND a.status='draft' AND a.created_at >= NOW() - INTERVAL '24 hours') AS drafts_today, -- следующая тема которую возьмёт генерация (SELECT bt.topic FROM blog_topics bt WHERE bt.category=s.category AND bt.is_used=false AND NOT EXISTS ( SELECT 1 FROM articles a WHERE a.source_topic=bt.topic AND a.category=s.category ) ORDER BY bt.priority DESC, bt.created_at ASC LIMIT 1) AS next_topic FROM autogen_settings s LEFT JOIN categories c ON c.slug=s.category ORDER BY s.run_hour, s.category` ); // Добавим флаг today_active — входит ли категория в сегодняшнюю ротацию const todaySet = new Set(getTodayCategories(settings)); return settings.map(s => ({ ...s, today_active: todaySet.has(s.category), })); } module.exports = { runAutogen, runAutogenForCategory, getAutogenStatus, getTodayCategories, TOPIC_BANK };