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
+133
View File
@@ -0,0 +1,133 @@
// Автоматическое добавление статей в серии.
//
// Логика:
// 1. При публикации статьи — Claude haiku анализирует заголовок + excerpt
// 2. Определяет наиболее подходящую серию (или ни одну)
// 3. Добавляет article.id в series.article_ids если его там ещё нет
//
// Серии и их описания для Claude:
// prompts — промпты, инструкции, генерация текста/изображений, работа с LLM как инструментом
// mcp-agents — RAG, агенты, MCP, Telegram/API боты, интеграции ИИ с внешними системами
// cases — автоматизация рабочих процессов, реальные кейсы, Make/Zapier/n8n, CRM, email
const axios = require('axios');
const { query } = require('../config/db');
const config = require('../config');
const SERIES_DESCRIPTIONS = [
{
slug: 'prompts',
name: 'Промпт-инжиниринг',
keywords: 'промпты, инструкции для ИИ, генерация текста, генерация изображений, работа с ChatGPT/Claude как инструментом, техники промптинга, few-shot, chain-of-thought, техдокументация с ИИ',
},
{
slug: 'mcp-agents',
name: 'MCP и агенты',
keywords: 'RAG, векторные базы данных, ИИ-агенты, MCP, автономные боты, Telegram-бот с ИИ, интеграции ИИ с API, LangChain, LlamaIndex, инструменты для агентов',
},
{
slug: 'cases',
name: 'Кейсы и автоматизации',
keywords: 'автоматизация рабочих процессов, Make, Zapier, n8n, CRM, email-маркетинг, реальные кейсы применения ИИ в работе, экономия времени, пайплайны',
},
{
slug: 'ai-security',
name: 'Безопасность в эпоху ИИ',
keywords: 'кибербезопасность с ИИ, prompt injection, OSINT, социальная инженерия, атаки на LLM, безопасность продакшна, анализ малвари, защита данных, LLM уязвимости',
},
];
/**
* Определить подходящую серию для статьи через Claude haiku.
* Возвращает slug серии или null если статья ни к одной не подходит.
*/
async function detectSeries(article) {
const seriesList = SERIES_DESCRIPTIONS.map(s =>
`- "${s.slug}" (${s.name}): ${s.keywords}`
).join('\n');
const prompt = `Ты — редактор блога ZeroPost. Определи, подходит ли эта статья к одной из серий блога.
СТАТЬЯ:
Заголовок: ${article.title}
Описание: ${article.excerpt || ''}
Категория: ${article.category || ''}
СЕРИИ БЛОГА:
${seriesList}
Отвечай ТОЛЬКО одним словом — slug серии (prompts / mcp-agents / cases) или "none" если статья ни к одной не подходит достаточно хорошо.
Выбирай серию только если уверен на 80%+. Лучше "none" чем неточное попадание.`;
try {
const res = await axios.post(
`${config.ai.baseUrl}/messages`,
{
model: config.ai.models?.post || 'claude-haiku-4-5-20251001',
max_tokens: 10,
messages: [{ role: 'user', content: prompt }],
},
{
headers: { Authorization: `Bearer ${config.ai.apiKey}` },
timeout: 15000,
}
);
const raw = res.data?.content?.[0]?.text?.trim().toLowerCase() || 'none';
// Извлекаем только slug без лишнего текста
const valid = SERIES_DESCRIPTIONS.map(s => s.slug);
const found = valid.find(s => raw.includes(s));
return found || null;
} catch (err) {
console.warn('[AutoSeries] Claude detection failed:', err.message.slice(0, 100));
return null;
}
}
/**
* Добавить статью в подходящую серию.
* Идемпотентно — не добавляет дубли.
* @returns { slug, seriesTitle } или null
*/
async function addToSeries(articleId) {
// Загружаем статью
const { rows: arts } = await query(
`SELECT id, title, excerpt, category, status FROM articles WHERE id=$1`,
[articleId]
);
if (!arts.length || arts[0].status !== 'published') return null;
const article = arts[0];
// Определяем серию
const slug = await detectSeries(article);
if (!slug) {
console.log(`[AutoSeries] article=${articleId} → no suitable series`);
return null;
}
// Загружаем серию
const { rows: series } = await query(
`SELECT id, title, article_ids FROM series WHERE slug=$1`,
[slug]
);
if (!series.length) return null;
const s = series[0];
const currentIds = (s.article_ids || []).map(Number);
if (currentIds.includes(articleId)) {
console.log(`[AutoSeries] article=${articleId} already in series "${slug}"`);
return { slug, seriesTitle: s.title, alreadyIn: true };
}
// Добавляем в конец
const newIds = [...currentIds, articleId];
await query(
`UPDATE series SET article_ids=$1::jsonb, updated_at=NOW() WHERE id=$2`,
[JSON.stringify(newIds), s.id]
);
console.log(`[AutoSeries] article=${articleId} "${article.title.slice(0,40)}" → series "${slug}" (${newIds.length} total)`);
return { slug, seriesTitle: s.title, articleCount: newIds.length };
}
module.exports = { addToSeries, detectSeries, SERIES_DESCRIPTIONS };