feat: Зеро-персонаж, auto-publish, auto-series, channel-stats, fallback covers

- Персонаж Зеро: 23 позы (zeroCharacter.js), скрипты генерации
- Auto-publish статей в TG: multipart upload, кнопки, режим alternating Zero/cover
- Fallback цепочка обложек: aiprimetech gpt-5.5 → Pollinations → local SVG (6 палитр)
- Auto-series: Claude haiku определяет серию для каждой статьи автоматически
- Channel stats: подписчики, история, delta 24h/7d
- Photo-search: Yandex API, профили доменов, Redis лимиты
- Scheduled posts runner: backfill, preview, queue, cancel
- promptBuilder: author_persona Зеро, голос от первого лица
- Fixes: dollar-placeholder bugs в PATCH channels/autogen, listArticles фильтры
- AI model: gpt-5.5 для image generation
This commit is contained in:
Nik (Claude)
2026-06-07 14:03:56 +03:00
parent 8968eed3e0
commit a370b8f7d8
33 changed files with 2695 additions and 147 deletions
+113 -12
View File
@@ -1,6 +1,8 @@
const { query } = require('../config/db');
const ai = require('./ai');
const covers = require('./covers');
// Ленивый импорт чтобы избежать circular dependency
function getAutoPublish() { return require('./articleAutoPublish'); }
/**
* Slug из заголовка — транслит для русского.
@@ -35,8 +37,8 @@ async function listArticles({ limit = 20, offset = 0, tag = null, category = nul
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) { sql += ` AND tags ? ${params.length + 1}`; params.push(tag); }
if (category) { sql += ` AND category=${params.length + 1}`; params.push(category); }
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);
@@ -79,23 +81,40 @@ async function generateAndSaveArticle({ topic, keywords = [], tags = [], autoPub
const jobId = jobRows[0].id;
try {
// Универсальный "channel" для блога — с правилами человечности и нашим стилем
// ZeroPost — блог от лица персонажа «Зеро».
// Дружелюбный энтузиаст, делится тем что попробовал. От первого лица.
const blogChannel = {
name: 'ZeroPost',
niche: 'Практические материалы про ИИ для людей, которые применяют его в работе',
audience: 'Маркетологи, продакты, разработчики, основатели — те, кто хочет применять ИИ практически',
goal: 'expert',
niche: 'ИИ, кибербезопасность, автоматизация и разработка — то что Зеро попробовал и хочет рассказать',
audience: 'Технари, разработчики, продакты, IT-специалисты — те кому интересны рабочие приёмы, а не теория',
goal: 'personal',
language: 'ru',
region: 'ru',
author_persona: {
name: 'Зеро',
identity: 'Я — Зеро, ИИ-маскот канала ZeroPost. Дружелюбный любопытный энтузиаст, который пробует разные инструменты и делится тем что узнал. Открыто говорю что я ИИ, не скрываю.',
voice: 'От первого лица. Конкретные истории > абстрактные советы. Признаю когда что-то не получилось.',
forbidden_phrases: ['как ИИ, я', 'я как языковая модель', 'в современном мире', 'революционный', 'уникальный', 'важно отметить', 'стоит подчеркнуть', 'поистине', 'давайте рассмотрим', 'итак', 'таким образом', 'в данной статье'],
},
style: {
tone: 'friendly',
tone: 'friendly_enthusiast',
formality: 'informal',
humor: 'dry',
post_length: 'long',
post_length: 'medium-long',
structure: 'headers',
emoji_level: 'none',
emoji_level: 'minimal',
hashtags_mode: 'none',
banned_words: ['революционный','уникальный','в современном мире','важно отметить','стоит подчеркнуть','поистине'],
first_person: true,
rules: [
'Пиши от первого лица: «я попробовал», «у меня сломалось», «обжёгся», «нашёл штуку».',
'Конкретные истории и примеры > общие фразы. Лучше «вчера запустил X и получил Y» чем «X помогает делать Y».',
'Признавай ошибки и тупики: «сначала пошёл не туда», «потратил час на ерунду».',
'Сухой юмор допускается, но без клоунады.',
'Не пиши «статья», «материал», «данная публикация» — пиши «пост», «разбор», «история», «заметка».',
'Никогда не упоминай что текст пишется автоматически или массово.',
'Не начинай с «В современном мире...» / «Итак...» / «Давайте...» — начинай с конкретики или с мини-истории.',
'Не давай советов в стиле «вам нужно сделать X» — пиши «я делаю X, потому что Y».',
],
},
};
@@ -115,12 +134,20 @@ async function generateAndSaveArticle({ topic, keywords = [], tags = [], autoPub
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(tags),
JSON.stringify(cleanTags),
category,
readingTime,
autoPublish ? 'published' : 'draft',
@@ -135,13 +162,23 @@ async function generateAndSaveArticle({ topic, keywords = [], tags = [], autoPub
[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 || [],
}).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];
@@ -154,6 +191,68 @@ async function generateAndSaveArticle({ topic, keywords = [], tags = [], autoPub
}
}
/**
* Собирает данные для главной страницы в одном вызове.
* - 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,
@@ -161,3 +260,5 @@ module.exports = {
getAllTags,
generateAndSaveArticle,
};
module.exports.getHomeArticles = getHomeArticles;