Files
zeropost-engine/src/services/autogen.js
T
Aleksei Pavlov 90f6b474a1 fix(autogen): remove sort_order from ORDER BY — column doesn't exist in autogen_settings
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.
2026-06-23 10:11:51 +03:00

327 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 };