Files
zeropost-engine/src/services/articles.js
T
Ник (Claude) 1ef770b5fc feat: custom prompt for articles + HD image quality per channel
- 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
2026-06-11 15:11:18 +03:00

266 lines
12 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 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;