1ef770b5fc
- ai.js: generateArticle принимает customPrompt (от юзера) или channel.ai_style_prompt - articles.js + routes/articles.js: проброс customPrompt через цепочку - postImages.js: channel.image_quality='hd' → gpt-5.4-image-2+medium, иначе gpt-5-image-mini+low - aiUsage.js: правильные цены routerai (RUB/token), gpt-5-image-mini и gpt-5.4-image-2 - channels.js: updateChannel сохраняет ai_style_prompt и image_quality - DB: channels.ai_style_prompt TEXT, channels.image_quality VARCHAR(16) DEFAULT standard
266 lines
12 KiB
JavaScript
266 lines
12 KiB
JavaScript
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;
|