forked from admin/zeropost-engine
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:
@@ -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 };
|
||||
Reference in New Issue
Block a user