Files
zeropost-engine/scripts/send-welcome-post.js
T
Nik (Claude) a370b8f7d8 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
2026-06-07 14:03:56 +03:00

144 lines
6.2 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.
// Одноразовый скрипт: генерирует обложку для welcome-поста и отправляет в TG.
// Запуск: node scripts/send-welcome-post.js
//
// Шлём файл через multipart напрямую — иначе TG прокси (CF Worker) не вытягивает
// файлы с zeropost.ru и валится с "Bad Request: failed to get HTTP URL content".
const fs = require('fs');
const path = require('path');
const axios = require('axios');
const FormData = require('form-data');
const ROOT = '/var/www/zeropost-engine';
process.chdir(ROOT);
require('dotenv').config({ path: path.join(ROOT, '.env') });
const config = require(path.join(ROOT, 'src/config'));
const settings = require(path.join(ROOT, 'src/services/settings'));
const { query } = require(path.join(ROOT, 'src/config/db'));
const UPLOADS_DIR = process.env.UPLOADS_DIR || '/var/www/zeropost-uploads';
let sharp = null;
try { sharp = require('sharp'); } catch {}
const WELCOME_PROMPT = `Abstract editorial cover illustration for a technology blog about AI, cybersecurity, automation and development.
Style: flat geometric shapes, smooth flowing curves, layered planes, vector-clean lines. Modern editorial illustration.
Color palette: emerald green (#10b981, #34d399) as primary brand color, soft teal accents, warm off-white background (#fafaf9), subtle dark navy accents. Sophisticated and clean.
Mood: clean, modern, calm, intellectual, welcoming — Stripe Press / Linear / Anthropic brand aesthetic.
Composition: balanced asymmetry with four implied zones representing four content pillars, flowing diagonal forms, generous negative space, sense of intellectual depth and curiosity. Wide 16:9 format.
Strictly: no text, no letters, no logos, no human faces, no robots, no brains, no glowing nodes, no circuit boards, no clocks, no screens.`;
async function generateCover() {
console.log('[welcome] generating cover via /v1/responses + image_generation...');
const model = process.env.AI_MODEL_IMAGE_VIA_RESPONSES || 'gpt-5.2';
const wrappedInput = `Use the image_generation tool to create the following illustration. Do not write any text response, only call the tool.\n\n${WELCOME_PROMPT}`;
const res = await axios.post(
`${config.ai.baseUrl}/responses`,
{
model,
input: wrappedInput,
tools: [{ type: 'image_generation' }],
tool_choice: { type: 'image_generation' },
},
{
headers: { Authorization: `Bearer ${config.ai.imageApiKey}` },
timeout: 300_000,
}
);
const output = res.data?.output || [];
const imgCall = output.find(o => o.type === 'image_generation_call');
if (!imgCall || !imgCall.result) {
throw new Error('No image returned from /responses');
}
const bytes = Buffer.from(imgCall.result, 'base64');
console.log(`[welcome] got ${(bytes.length / 1024).toFixed(0)}KB image`);
const ts = Date.now();
const ext = imgCall.output_format || 'png';
const origName = `welcome-${ts}.${ext}`;
const origPath = path.join(UPLOADS_DIR, origName);
fs.writeFileSync(origPath, bytes);
let finalLocal = origPath;
if (sharp) {
try {
const webpName = `welcome-${ts}.webp`;
const webpPath = path.join(UPLOADS_DIR, webpName);
await sharp(bytes)
.resize(1600, null, { withoutEnlargement: true })
.webp({ quality: 86 })
.toFile(webpPath);
const size = fs.statSync(webpPath).size;
console.log(`[welcome] optimized → ${webpName} (${(size / 1024).toFixed(0)}KB)`);
finalLocal = webpPath;
} catch (e) {
console.warn(`[welcome] sharp skipped: ${e.message}`);
}
}
return { localPath: finalLocal };
}
const WELCOME_TEXT = `👋 Это *ZeroPost* — блог про ИИ, кибербезопасность, автоматизацию и разработку.
Эксперимент: контент пишет ИИ, а человек только держит курс. Получается технологический блог без воды и хайпа — разборы инструментов, рабочие промпты, реальные кейсы.
В канале — анонсы новых материалов с сайта. Каждая заметка отрабатывает один практический вопрос: «как сделать X в инструменте Y и не пожалеть».
🌐 Все материалы: [zeropost.ru](https://zeropost.ru)
📌 Закрепите этот пост, чтобы не потерять`;
async function sendToTelegram(localPath) {
const { rows: chs } = await query(`SELECT * FROM channels WHERE id=1`);
if (!chs.length) throw new Error('Channel 1 not found');
const channel = chs[0];
const tgBase = await settings.get('TELEGRAM_API_BASE', 'https://api.telegram.org');
const reply_markup = {
inline_keyboard: [[{ text: '🌐 Открыть сайт', url: 'https://zeropost.ru' }]],
};
console.log(`[welcome] uploading ${localPath} to TG via multipart...`);
const form = new FormData();
form.append('chat_id', String(channel.tg_channel_id));
form.append('caption', WELCOME_TEXT.slice(0, 1024));
form.append('parse_mode', 'Markdown');
form.append('reply_markup', JSON.stringify(reply_markup));
form.append('photo', fs.createReadStream(localPath));
const res = await axios.post(`${tgBase}/bot${channel.bot_token}/sendPhoto`, form, {
headers: form.getHeaders(),
timeout: 60000,
maxContentLength: Infinity,
maxBodyLength: Infinity,
});
const messageId = res.data?.result?.message_id;
console.log(`[welcome] sent, message_id=${messageId}`);
await query(
`INSERT INTO posts (channel_id, content, status, published_at, tg_message_id)
VALUES ($1,$2,'published',NOW(),$3)`,
[channel.id, WELCOME_TEXT, messageId]
);
return messageId;
}
(async () => {
try {
const { localPath } = await generateCover();
console.log(`[welcome] cover ready at ${localPath}`);
const messageId = await sendToTelegram(localPath);
console.log(`[welcome] DONE. TG message_id=${messageId}. Длина caption=${WELCOME_TEXT.length}`);
process.exit(0);
} catch (err) {
console.error('[welcome] FAILED:', err.response?.data || err.message);
process.exit(1);
}
})();