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:
+113
-12
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user