const { query } = require('../config/db'); const ai = require('./ai'); const covers = require('./covers'); // Ленивый импорт чтобы избежать circular dependency function getAutoPublish() { return require('./articleAutoPublish'); } /** * Slug из заголовка — транслит для русского. */ function slugify(title) { const map = { а:'a',б:'b',в:'v',г:'g',д:'d',е:'e',ё:'yo',ж:'zh',з:'z',и:'i',й:'y', к:'k',л:'l',м:'m',н:'n',о:'o',п:'p',р:'r',с:'s',т:'t',у:'u',ф:'f', х:'h',ц:'c',ч:'ch',ш:'sh',щ:'sch',ъ:'',ы:'y',ь:'',э:'e',ю:'yu',я:'ya', }; return title .toLowerCase() .split('') .map(c => map[c] !== undefined ? map[c] : c) .join('') .replace(/[^a-z0-9\s-]/g, '') .trim() .replace(/\s+/g, '-') .replace(/-+/g, '-') .substring(0, 100); } function estimateReadingTime(text) { const words = text.split(/\s+/).length; return Math.max(1, Math.round(words / 200)); } /** * Список опубликованных статей. */ async function listArticles({ limit = 20, offset = 0, tag = null, category = null } = {}) { let sql = `SELECT id, slug, title, excerpt, cover_url, tags, category, author, reading_time, published_at FROM articles WHERE status='published'`; const params = []; if (tag) { params.push(tag); sql += ` AND tags ? $${params.length}`; } if (category) { params.push(category); sql += ` AND category=$${params.length}`; } sql += ` ORDER BY published_at DESC LIMIT $${params.length + 1} OFFSET $${params.length + 2}`; params.push(limit, offset); const { rows } = await query(sql, params); return rows; } async function getArticleBySlug(slug) { const { rows } = await query( `SELECT a.*, j.tokens_in, j.tokens_out FROM articles a LEFT JOIN generation_jobs j ON j.id = a.job_id WHERE a.slug=$1 AND a.status='published'`, [slug] ); if (!rows.length) return null; // считаем просмотр await query(`UPDATE articles SET views=views+1 WHERE id=$1`, [rows[0].id]); return rows[0]; } async function getAllTags() { const { rows } = await query( `SELECT DISTINCT jsonb_array_elements_text(tags) as tag, COUNT(*) as cnt FROM articles WHERE status='published' GROUP BY tag ORDER BY cnt DESC LIMIT 30` ); return rows; } /** * Генерирует и сохраняет статью. * @param {object} opts - { topic, keywords, tags, autoPublish } */ async function generateAndSaveArticle({ topic, keywords = [], tags = [], autoPublish = true, category = 'ai-tools', customPrompt }) { // job const { rows: jobRows } = await query( `INSERT INTO generation_jobs (type, topic, status) VALUES ('article',$1,'processing') RETURNING id`, [topic] ); const jobId = jobRows[0].id; try { // ZeroPost — блог от лица персонажа «Зеро». // Дружелюбный энтузиаст, делится тем что попробовал. От первого лица. const blogChannel = { name: 'ZeroPost', niche: 'ИИ, кибербезопасность, автоматизация и разработка — то что Зеро попробовал и хочет рассказать', audience: 'Технари, разработчики, продакты, IT-специалисты — те кому интересны рабочие приёмы, а не теория', goal: 'personal', language: 'ru', region: 'ru', author_persona: { name: 'Зеро', identity: 'Я — Зеро, ИИ-маскот канала ZeroPost. Дружелюбный любопытный энтузиаст, который пробует разные инструменты и делится тем что узнал. Открыто говорю что я ИИ, не скрываю.', voice: 'От первого лица. Конкретные истории > абстрактные советы. Признаю когда что-то не получилось.', forbidden_phrases: ['как ИИ, я', 'я как языковая модель', 'в современном мире', 'революционный', 'уникальный', 'важно отметить', 'стоит подчеркнуть', 'поистине', 'давайте рассмотрим', 'итак', 'таким образом', 'в данной статье'], }, style: { tone: 'friendly_enthusiast', formality: 'informal', humor: 'dry', post_length: 'medium-long', structure: 'headers', emoji_level: 'minimal', hashtags_mode: 'none', first_person: true, rules: [ 'Пиши от первого лица: «я попробовал», «у меня сломалось», «обжёгся», «нашёл штуку».', 'Конкретные истории и примеры > общие фразы. Лучше «вчера запустил X и получил Y» чем «X помогает делать Y».', 'Признавай ошибки и тупики: «сначала пошёл не туда», «потратил час на ерунду».', 'Сухой юмор допускается, но без клоунады.', 'Не пиши «статья», «материал», «данная публикация» — пиши «пост», «разбор», «история», «заметка».', 'Никогда не упоминай что текст пишется автоматически или массово.', 'Не начинай с «В современном мире...» / «Итак...» / «Давайте...» — начинай с конкретики или с мини-истории.', 'Не давай советов в стиле «вам нужно сделать X» — пиши «я делаю X, потому что Y».', ], }, }; const articleRes = await ai.generateArticle(blogChannel, { topic, keywords, customPrompt }); const content = articleRes.content; // вытаскиваю title (первый H1 или первая строка) и excerpt const lines = content.split('\n').filter(Boolean); let title = topic; const h1 = lines.find(l => l.startsWith('# ')); if (h1) title = h1.replace(/^#\s+/, '').trim(); // excerpt — первый параграф без заголовков const firstPara = lines.find(l => l.length > 80 && !l.startsWith('#')); const excerpt = firstPara ? firstPara.substring(0, 200) + (firstPara.length > 200 ? '...' : '') : ''; const slug = `${slugify(title)}-${jobId}`; const readingTime = estimateReadingTime(content); // Дедуп тегов + удаление category-slug из tags (он живёт в отдельной колонке). const cleanTags = Array.from(new Set( (tags || []) .filter(t => t && typeof t === 'string') .map(t => t.trim()) .filter(t => t.length > 0 && t.toLowerCase() !== category.toLowerCase()) )); const { rows: artRows } = await query( `INSERT INTO articles (slug, title, excerpt, content, tags, category, reading_time, status, job_id, seo_title, seo_descr) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) RETURNING *`, [ slug, title, excerpt, content, JSON.stringify(cleanTags), category, readingTime, autoPublish ? 'published' : 'draft', jobId, title.substring(0, 60), excerpt.substring(0, 160), ] ); await query( `UPDATE generation_jobs SET status='done', result=$1, tokens_in=$2, tokens_out=$3, updated_at=NOW() WHERE id=$4`, [content, articleRes.usage?.prompt_tokens, articleRes.usage?.completion_tokens, jobId] ); // Фоновые задачи после сохранения — не блокируют возврат статьи setImmediate(() => { // Генерация обложки covers.generateCover({ articleId: artRows[0].id, title: artRows[0].title, tags: artRows[0].tags || [], channelId: 1, // системный блог-канал zeropost.ru — использует его image-настройки }).catch(err => console.warn('[Article] cover bg failed:', err.message.slice(0,200))); // Авто-публикация в каналы (если статья опубликована) if (artRows[0].status === 'published') { getAutoPublish().scheduleForArticle(artRows[0].id) .catch(err => console.error('[Article] auto-publish hook failed:', err.message)); // Авто-добавление в серию require('./articleAutoSeries').addToSeries(artRows[0].id) .catch(err => console.error('[Article] auto-series hook failed:', err.message)); } }); return artRows[0]; } catch (err) { await query( `UPDATE generation_jobs SET status='failed', error=$1 WHERE id=$2`, [err.message, jobId] ); throw err; } } /** * Собирает данные для главной страницы в одном вызове. * - hero: 1 свежая статья (с обложкой) * - byCategory: по 3 свежих на каждую из 4 категорий, исключая hero * - popular: до 3 статей по views за последние 30 дней (если есть просмотры) * - recent: 6 свежих, исключая hero и byCategory */ async function getHomeArticles() { const select = `SELECT id, slug, title, excerpt, cover_url, tags, category, reading_time, views, published_at`; // Hero — самая свежая опубликованная статья с обложкой const heroRes = await query( `${select} FROM articles WHERE status='published' AND cover_url IS NOT NULL ORDER BY published_at DESC LIMIT 1` ); const hero = heroRes.rows[0] || null; const heroId = hero ? hero.id : 0; // По 3 на каждую категорию (DISTINCT ON), исключая hero const catRes = await query( `SELECT * FROM ( SELECT ${select.replace('SELECT ', '')}, ROW_NUMBER() OVER (PARTITION BY category ORDER BY published_at DESC) AS rn FROM articles WHERE status='published' AND id <> $1 ) t WHERE rn <= 3 ORDER BY category, rn`, [heroId] ); const byCategory = {}; for (const row of catRes.rows) { const { rn, ...rest } = row; if (!byCategory[row.category]) byCategory[row.category] = []; byCategory[row.category].push(rest); } // Популярное за 30 дней: топ-3 по views (только если views > 0) const popRes = await query( `${select} FROM articles WHERE status='published' AND views > 0 AND published_at > NOW() - INTERVAL '30 days' ORDER BY views DESC, published_at DESC LIMIT 3` ); const popular = popRes.rows; const popularIds = popular.map(p => p.id); // Recent — 6 свежих, исключая hero и попавшие в byCategory и popular const usedIds = new Set([heroId, ...popularIds]); for (const arr of Object.values(byCategory)) for (const a of arr) usedIds.add(a.id); const usedArr = Array.from(usedIds).filter(Boolean); const recentRes = await query( `${select} FROM articles WHERE status='published' AND id <> ALL($1::int[]) ORDER BY published_at DESC LIMIT 6`, [usedArr.length ? usedArr : [0]] ); const recent = recentRes.rows; return { hero, byCategory, popular, recent }; } module.exports = { slugify, listArticles, getArticleBySlug, getAllTags, generateAndSaveArticle, }; module.exports.getHomeArticles = getHomeArticles;