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
+277
View File
@@ -0,0 +1,277 @@
// Раннер scheduled_posts (системные публикации статей в каналы).
// Дёргается cron'ом раз в минуту.
//
// Логика:
// - article + channel.auto_publish_template → текст-тизер
// - в TG: inline-кнопка «Читать на сайте →» (без URL в тексте)
// - в VK/MAX: URL автоматически подмешивается в конец текста (кнопок нет)
// - cover статьи прикрепляется если auto_publish_with_cover=true
const axios = require('axios');
const fs = require('fs');
const path = require('path');
const FormData = require('form-data');
const { query } = require('../config/db');
const settings = require('./settings');
const zeroChar = require('./zeroCharacter');
const UPLOADS_DIR = process.env.UPLOADS_DIR || '/var/www/zeropost-uploads';
/**
* Если photoUrl указывает на наш собственный /uploads — открываем файл
* с диска и шлём как multipart. Это надёжнее, чем заставлять Telegram-прокси
* самостоятельно тянуть файл с zeropost.ru (бывают таймауты / sandbox-ограничения CF Worker'а).
*/
function resolveLocalPhoto(photoUrl) {
if (!photoUrl) return null;
// Формы: /uploads/x.webp, https://zeropost.ru/uploads/x.webp
let pathname = photoUrl;
try {
const u = new URL(photoUrl);
pathname = u.pathname;
} catch {}
if (!pathname.startsWith('/uploads/')) return null;
const filename = pathname.replace(/^\/uploads\//, '');
// Защита от path traversal
if (filename.includes('..') || filename.includes('/')) return null;
const local = path.join(UPLOADS_DIR, filename);
if (!fs.existsSync(local)) return null;
return local;
}
const DEFAULT_TEMPLATE = '{categoryEmoji} *{categoryLabel}*\n\n*{title}*\n\n{excerpt}';
const DEFAULT_BUTTON_TEXT = '📖 Читать на сайте →';
// Маппинг slug → emoji + русское название для плейсхолдеров
const CATEGORY_META = {
'ai-tools': { emoji: '🤖', label: 'AI Tools' },
'cybersec': { emoji: '🔒', label: 'Cybersec' },
'automation': { emoji: '⚡', label: 'Automation' },
'ai-dev': { emoji: '💻', label: 'AI Dev' },
};
function articleUrl(article) {
return `https://zeropost.ru/blog/${article.slug}`;
}
function renderTemplate(template, article) {
const tpl = (template && template.trim()) || DEFAULT_TEMPLATE;
const url = articleUrl(article);
const meta = CATEGORY_META[article.category] || { emoji: '📝', label: article.category || '' };
return tpl
.replaceAll('{title}', article.title || '')
.replaceAll('{excerpt}', article.excerpt || '')
.replaceAll('{url}', url)
.replaceAll('{category}', article.category || '')
.replaceAll('{categoryEmoji}', meta.emoji)
.replaceAll('{categoryLabel}', meta.label);
}
/**
* Telegram. Если есть article — добавляем inline-кнопку «Читать на сайте».
* Если caption длиннее 1024 — режется (TG hard-limit для sendPhoto). Для длинных постов лучше посылать без cover (sendMessage до 4096).
*/
async function publishToTelegram({ channel, text, photoUrl, article }) {
const base = await settings.get('TELEGRAM_API_BASE', 'https://api.telegram.org');
// Inline-кнопка — только если есть статья и кнопка не отключена
const buttonText = channel.auto_publish_button_text === null
? null
: (channel.auto_publish_button_text || DEFAULT_BUTTON_TEXT);
let reply_markup = undefined;
if (article && buttonText) {
reply_markup = {
inline_keyboard: [[{ text: buttonText, url: articleUrl(article) }]],
};
}
if (photoUrl) {
const localPath = resolveLocalPhoto(photoUrl);
if (localPath) {
// Шлём файл напрямую через multipart — TG не пойдёт сам ходить за URL
const form = new FormData();
form.append('chat_id', String(channel.tg_channel_id));
form.append('caption', text.slice(0, 1024));
form.append('parse_mode', 'Markdown');
if (reply_markup) form.append('reply_markup', JSON.stringify(reply_markup));
form.append('photo', fs.createReadStream(localPath));
const res = await axios.post(`${base}/bot${channel.bot_token}/sendPhoto`, form, {
headers: form.getHeaders(),
timeout: 60000,
maxContentLength: Infinity,
maxBodyLength: Infinity,
});
return res.data?.result?.message_id;
}
// Внешний URL — оставляем старое поведение
const res = await axios.post(`${base}/bot${channel.bot_token}/sendPhoto`, {
chat_id: channel.tg_channel_id,
photo: photoUrl,
caption: text.slice(0, 1024),
parse_mode: 'Markdown',
reply_markup,
}, { timeout: 30000 });
return res.data?.result?.message_id;
}
const res = await axios.post(`${base}/bot${channel.bot_token}/sendMessage`, {
chat_id: channel.tg_channel_id,
text: text.slice(0, 4096),
parse_mode: 'Markdown',
disable_web_page_preview: !article, // если есть кнопка — превью сайта не нужно
reply_markup,
}, { timeout: 15000 });
return res.data?.result?.message_id;
}
async function publishToVK({ channel, text, photoUrl, article }) {
if (!channel.vk_group_id || !channel.vk_access_token) {
throw new Error('VK не настроен');
}
// VK не поддерживает кнопки в постах — добавляем ссылку в конец текста, если её там ещё нет
let finalText = text;
if (article) {
const url = articleUrl(article);
if (!finalText.includes(url)) {
const buttonText = channel.auto_publish_button_text || DEFAULT_BUTTON_TEXT;
finalText = `${finalText}\n\n${buttonText}\n${url}`;
}
}
const params = new URLSearchParams({
owner_id: '-' + String(channel.vk_group_id).replace(/^-/, ''),
from_group: '1',
message: finalText,
access_token: channel.vk_access_token,
v: '5.199',
});
const res = await axios.post('https://api.vk.com/method/wall.post', params, { timeout: 15000 });
if (res.data?.error) throw new Error(`VK: ${res.data.error.error_msg}`);
return res.data?.response?.post_id;
}
async function publishToMax({ channel, text, photoUrl, article }) {
if (!channel.max_channel_id || !channel.max_access_token) {
throw new Error('MAX не настроен');
}
// Заглушка — точный endpoint MAX заполним когда подключим живой канал
throw new Error('MAX публикация не реализована');
}
async function publishOne(scheduledPost) {
const { rows: chRows } = await query(`SELECT * FROM channels WHERE id=$1`, [scheduledPost.channel_id]);
if (!chRows.length) throw new Error('Channel not found');
const channel = chRows[0];
let text = scheduledPost.custom_text;
let photoUrl = null;
let article = null;
if (scheduledPost.article_id) {
const { rows: arts } = await query(`SELECT * FROM articles WHERE id=$1`, [scheduledPost.article_id]);
if (!arts.length) throw new Error('Article not found');
article = arts[0];
if (!text) text = renderTemplate(channel.auto_publish_template, article);
// Выбор картинки:
// image_source='zero' — иллюстрация Зеро по позе
// image_source='cover' — обложка статьи (старое поведение)
// image_source='none' — без картинки
const imgSource = channel.auto_publish_image_source || 'cover';
// 'alternating' — чётные article_id = обложка статьи, нечётные = Зеро.
// Это даёт визуальное разнообразие без ручного управления.
const useZero = imgSource === 'zero'
|| (imgSource === 'alternating' && article.id % 2 === 1);
const useCover = imgSource === 'cover'
|| (imgSource === 'alternating' && article.id % 2 === 0);
if (useZero && channel.auto_publish_with_cover !== false) {
const picked = zeroChar.pickPose({
title: article.title,
excerpt: article.excerpt,
category: article.category,
});
if (picked.exists) {
photoUrl = `/uploads/zero-${picked.pose}.webp`;
console.log(`[scheduled-runner] Zero pose=${picked.pose} (${picked.source}) article=${article.id}`);
} else if (article.cover_url) {
// Fallback на обложку если поза ещё не сгенерирована
photoUrl = article.cover_url.startsWith('http')
? article.cover_url
: `https://zeropost.ru${article.cover_url}`;
console.log(`[scheduled-runner] Zero fallback to cover (pose not ready) article=${article.id}`);
}
} else if (useCover && channel.auto_publish_with_cover) {
if (article.cover_url) {
photoUrl = article.cover_url.startsWith('http')
? article.cover_url
: `https://zeropost.ru${article.cover_url}`;
console.log(`[scheduled-runner] cover=${article.cover_url.split('/').pop()} article=${article.id}`);
} else {
// Обложки нет (ещё генерируется) — fallback на Зеро
const picked = zeroChar.pickPose({
title: article.title,
excerpt: article.excerpt,
category: article.category,
});
if (picked.exists) {
photoUrl = `/uploads/zero-${picked.pose}.webp`;
console.log(`[scheduled-runner] cover fallback → Zero pose=${picked.pose} article=${article.id}`);
}
}
}
// imgSource === 'none' → photoUrl остаётся null
}
if (!text) throw new Error('Empty text and no article');
let messageId;
if (channel.platform === 'telegram' || !channel.platform) {
messageId = await publishToTelegram({ channel, text, photoUrl, article });
} else if (channel.platform === 'vk') {
messageId = await publishToVK({ channel, text, photoUrl, article });
} else if (channel.platform === 'max') {
messageId = await publishToMax({ channel, text, photoUrl, article });
} else {
throw new Error(`Платформа ${channel.platform} не поддерживается`);
}
// Логируем в posts
await query(
`INSERT INTO posts (channel_id, content, status, published_at, tg_message_id)
VALUES ($1,$2,'published',NOW(),$3)`,
[channel.id, text, channel.platform === 'telegram' ? (messageId || null) : null]
);
return { messageId, channel, article };
}
async function runScheduled() {
const { rows } = await query(
`SELECT * FROM scheduled_posts
WHERE status='pending' AND scheduled_at <= NOW()
ORDER BY scheduled_at ASC LIMIT 20`
);
const results = [];
for (const sp of rows) {
try {
const { messageId } = await publishOne(sp);
await query(
`UPDATE scheduled_posts SET status='sent', published_at=NOW(), error=NULL WHERE id=$1`,
[sp.id]
);
results.push({ id: sp.id, ok: true, message_id: messageId });
console.log(`[scheduled-runner] sent id=${sp.id} channel=${sp.channel_id} article=${sp.article_id}`);
} catch (err) {
const msg = err.response?.data?.description || err.response?.data?.error?.error_msg || err.message;
await query(
`UPDATE scheduled_posts SET status='failed', error=$1 WHERE id=$2`,
[String(msg).slice(0, 1000), sp.id]
);
results.push({ id: sp.id, ok: false, error: msg });
console.error(`[scheduled-runner] failed id=${sp.id}: ${msg}`);
}
}
return { processed: rows.length, results };
}
module.exports = { runScheduled, publishOne, renderTemplate, DEFAULT_TEMPLATE, DEFAULT_BUTTON_TEXT };