Files
postcast-engine/src/services/scheduledPostsRunner.js
T
Alexey Pavlov 5e075ac81d fix(max): migrate to platform-api2.max.ru + Russian Trusted CA bundle
MAX API URL updated to platform-api2.max.ru (mandatory before 2026-07-19).
Added Russian Trusted Root + Sub CA 2024 bundle as repo file — to be loaded
via NODE_EXTRA_CA_CERTS=/app/russian_trusted_bundle.pem (set in Coolify env).
2026-06-24 15:43:22 +03:00

460 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Раннер 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 не настроен');
}
const groupId = String(channel.vk_group_id).replace(/^-/, '');
const ownerId = '-' + groupId;
const token = channel.vk_access_token;
const v = '5.199';
// Добавляем ссылку на статью в конец текста
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}`;
}
}
// 2-step upload картинки
let attachments = '';
if (photoUrl) {
try {
// Шаг 1: получаем upload URL
const uploadServerRes = await axios.get('https://api.vk.com/method/photos.getWallUploadServer', {
params: { group_id: groupId, access_token: token, v },
timeout: 10_000,
});
if (uploadServerRes.data?.error) throw new Error(`VK getWallUploadServer: ${uploadServerRes.data.error.error_msg}`);
const uploadUrl = uploadServerRes.data.response.upload_url;
// Шаг 2: загружаем файл
const localPath = resolveLocalPhoto(photoUrl);
let fileBuffer, fileName;
if (localPath) {
fileBuffer = fs.readFileSync(localPath);
fileName = path.basename(localPath);
} else {
// Внешний URL — скачиваем
const dl = await axios.get(photoUrl, { responseType: 'arraybuffer', timeout: 30_000 });
fileBuffer = Buffer.from(dl.data);
fileName = 'photo.jpg';
}
const form = new FormData();
form.append('photo', fileBuffer, { filename: fileName, contentType: 'image/jpeg' });
const uploadRes = await axios.post(uploadUrl, form, {
headers: form.getHeaders(),
timeout: 60_000,
});
const { server, photo: photoData, hash } = uploadRes.data;
if (!photoData) throw new Error('VK upload: пустой ответ');
// Шаг 3: сохраняем фото
const saveRes = await axios.get('https://api.vk.com/method/photos.saveWallPhoto', {
params: { group_id: groupId, server, photo: photoData, hash, access_token: token, v },
timeout: 10_000,
});
if (saveRes.data?.error) throw new Error(`VK saveWallPhoto: ${saveRes.data.error.error_msg}`);
const saved = saveRes.data.response?.[0];
if (!saved) throw new Error('VK saveWallPhoto: нет данных');
attachments = `photo${saved.owner_id}_${saved.id}`;
} catch (photoErr) {
console.warn(`[VK] photo upload failed, posting without photo: ${photoErr.message}`);
// Публикуем без картинки если загрузка упала
}
}
// Шаг 4: публикуем пост
const params = new URLSearchParams({
owner_id: ownerId,
from_group: '1',
message: finalText,
access_token: token,
v,
});
if (attachments) params.set('attachments', attachments);
const res = await axios.post('https://api.vk.com/method/wall.post', params, { timeout: 15_000 });
if (res.data?.error) throw new Error(`VK wall.post: ${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 не настроен');
}
const BASE = 'https://platform-api2.max.ru';
const token = channel.max_access_token;
const chatId = channel.max_channel_id;
const headers = { Authorization: token, 'Content-Type': 'application/json' };
// Добавляем ссылку на статью в конец текста
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 body = { text: finalText, attachments: [] };
// Загрузка фото через 2-step upload
if (photoUrl) {
try {
// Шаг 1: получаем presigned URL для загрузки
const uploadUrlRes = await axios.post(`${BASE}/uploads?type=image`, null, {
headers: { Authorization: token },
timeout: 10_000,
});
const uploadUrl = uploadUrlRes.data?.url;
if (!uploadUrl) throw new Error('MAX: нет upload URL');
// Шаг 2: загружаем файл (multipart)
const localPath = resolveLocalPhoto(photoUrl);
let fileBuffer, fileName;
if (localPath) {
fileBuffer = fs.readFileSync(localPath);
fileName = path.basename(localPath);
} else {
const dl = await axios.get(photoUrl, { responseType: 'arraybuffer', timeout: 30_000 });
fileBuffer = Buffer.from(dl.data);
fileName = 'photo.jpg';
}
const form = new FormData();
form.append('file', fileBuffer, { filename: fileName, contentType: 'image/jpeg' });
const uploadRes = await axios.post(uploadUrl, form, {
headers: form.getHeaders(),
timeout: 60_000,
});
const token_img = uploadRes.data?.token;
if (token_img) {
body.attachments.push({ type: 'image', payload: { token: token_img } });
}
} catch (photoErr) {
console.warn(`[MAX] photo upload failed, posting without photo: ${photoErr.message}`);
}
}
if (!body.attachments.length) delete body.attachments;
const res = await axios.post(`${BASE}/messages?chat_id=${chatId}`, body, {
headers,
timeout: 15_000,
});
if (res.data?.error) throw new Error(`MAX: ${res.data.error.message || res.data.error}`);
return res.data?.message?.body?.mid;
}
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) {
// Проверяем что обложка реальная, а не SVG-заглушка (< 30KB)
const localCoverPath = path.join(UPLOADS_DIR, article.cover_url.replace(/^\/uploads\//, ''));
let coverIsReal = true;
try {
const stat = fs.statSync(localCoverPath);
if (stat.size < 30 * 1024) {
coverIsReal = false;
console.log(`[scheduled-runner] SVG stub (${Math.round(stat.size/1024)}KB), regen article=${article.id}`);
try {
const covers = require('./covers');
await covers.generateCover({ articleId: article.id, title: article.title, tags: article.tags || [], channelId: channel.id });
const { rows: refreshed } = await query('SELECT cover_url FROM articles WHERE id=$1', [article.id]);
if (refreshed[0]?.cover_url) article.cover_url = refreshed[0].cover_url;
coverIsReal = true;
console.log(`[scheduled-runner] regen OK article=${article.id}`);
} catch(regenErr) {
console.warn(`[scheduled-runner] regen failed, using Zero: ${regenErr.message.slice(0,80)}`);
}
}
} catch(_) { /* внешний URL или файл не найден — считаем реальным */ }
if (coverIsReal) {
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 {
const attempts = scheduledPost.cover_regen_attempts || 0;
const MAX_REGEN_ATTEMPTS = 3; // 3 × 15 мин = 45 мин максимум ждём
if (attempts < MAX_REGEN_ATTEMPTS) {
// Откладываем пост на 15 минут, не публикуем
const retryAt = new Date(Date.now() + 15 * 60_000);
await query(
`UPDATE scheduled_posts SET scheduled_at=$1, cover_regen_attempts=$2 WHERE id=$3`,
[retryAt, attempts + 1, scheduledPost.id]
);
console.log(`[scheduled-runner] SVG stub — delayed 15min (attempt ${attempts+1}/${MAX_REGEN_ATTEMPTS}) post=${scheduledPost.id} article=${article.id}`);
return; // не публикуем сейчас
}
// Исчерпали попытки — публикуем с Зеро чтобы пост не завис навсегда
console.log(`[scheduled-runner] SVG stub max attempts reached, using Zero article=${article.id}`);
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 fallback pose=${picked.pose} 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 && scheduledPost.post_type !== 'poll') throw new Error('Empty text and no article');
let messageId;
// Опрос — отдельная ветка
if (scheduledPost.post_type === 'poll') {
if (channel.platform !== 'telegram') throw new Error('Опросы только в Telegram');
const { sendPoll } = require('../routes/polls');
const meta = scheduledPost.meta || {};
messageId = await sendPoll({
channel,
question: meta.question || scheduledPost.text,
options: meta.options || [],
is_anonymous: meta.is_anonymous ?? true,
allows_multiple_answers: meta.allows_multiple_answers ?? false,
type: meta.type || 'regular',
correct_option_id: meta.correct_option_id,
explanation: meta.explanation,
});
} else 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 };