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,143 @@
|
||||
// Одноразовый скрипт: генерирует обложку для 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);
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user