90f6b474a1
Critical: every runAutogen tick was failing with 'column sort_order does not exist' since deploy of rotation feature. This means NO articles were generated on Jun 22 and draftAutoApprove also failed on Jun 23 07:00. autogen_settings has no sort_order column — use ORDER BY category instead.
327 lines
16 KiB
JavaScript
327 lines
16 KiB
JavaScript
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 };
|