Files
zeropost-engine/scripts/test-zero-character.js
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

148 lines
6.0 KiB
JavaScript
Raw Permalink 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.
// Тест: генерируем 3 ракурса Зеро. Каждая картинка — до 5 попыток (GPT-5.2 капризничает с tool_choice).
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 CHARACTER_BASE = `
Character: a small, friendly mascot named Zero.
Body: a soft rounded square shape (like a plump pixel or a chubby tetris block), about the size of a small companion creature.
Color: bright emerald green (#10b981) with a slight gradient to teal, soft and matte.
Face: two simple round black dot eyes, a small understated mouth that expresses the current emotion.
Personality: friendly, curious, enthusiastic, a bit nerdy.
Style: clean modern vector illustration, flat design with soft shading, no outlines.
Background: warm off-white (#fafaf9) with subtle geometric shapes in light teal/emerald, like Notion/Linear/Anthropic editorial illustrations.
Composition: 1:1 square format with comfortable padding, character is the main focus.
Strictly: no text, no letters, no logos, no humans, no realistic robots, no glowing nodes, no circuits.
`.trim();
const POSES = [
{
name: 'avatar',
desc: `Front-facing portrait pose. Zero is looking directly at the viewer with curious wide-open eyes and a small confident smile. Sits centered in the frame. Used as channel avatar.`,
},
{
name: 'laptop',
desc: `Zero sits at a tiny laptop (the laptop is also stylized geometric, in cream/emerald colors). Eyes focused on the screen, small thoughtful expression. Cozy setup with a small plant nearby. The mascot is enthusiastic about coding.`,
},
{
name: 'eureka',
desc: `Zero has just discovered something exciting. One small stick arm raised in the air with a soft glowing dot above the head representing an idea. Eyes wide and sparkly, big enthusiastic smile. Confetti or stylized geometric sparkles around.`,
},
];
async function generateOne(prompt, attempt = 1) {
const MAX_ATTEMPTS = 5;
const model = process.env.AI_MODEL_IMAGE_VIA_RESPONSES || 'gpt-5.2';
const wrapped = `Use the image_generation tool to create the following illustration. Do not write any text response, only call the tool.\n\n${prompt}`;
try {
const res = await axios.post(
`${config.ai.baseUrl}/responses`,
{
model, input: wrapped,
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) {
if (attempt < MAX_ATTEMPTS) {
console.log(`[zero] retry attempt=${attempt + 1} (no image_generation_call)`);
return generateOne(prompt, attempt + 1);
}
throw new Error(`No image after ${MAX_ATTEMPTS} attempts`);
}
return Buffer.from(imgCall.result, 'base64');
} catch (err) {
if (attempt < MAX_ATTEMPTS) {
const msg = err.response?.data?.error?.message || err.message;
console.log(`[zero] retry attempt=${attempt + 1} (${msg.slice(0, 60)})`);
return generateOne(prompt, attempt + 1);
}
throw err;
}
}
async function processBytes(bytes, name) {
const ts = Date.now();
const webpName = `zero-${name}-${ts}.webp`;
const webpPath = path.join(UPLOADS_DIR, webpName);
if (sharp) {
await sharp(bytes)
.resize(1024, 1024, { fit: 'cover' })
.webp({ quality: 88 })
.toFile(webpPath);
} else {
fs.writeFileSync(webpPath, bytes);
}
return webpPath;
}
(async () => {
try {
console.log('[zero] generating 3 poses sequentially (with retries)...');
const results = [];
for (const p of POSES) {
console.log(`[zero] starting ${p.name}...`);
const prompt = `${CHARACTER_BASE}\n\nPose: ${p.desc}`;
const bytes = await generateOne(prompt);
const localPath = await processBytes(bytes, p.name);
console.log(`[zero] ${p.name}${localPath}`);
results.push({ name: p.name, path: localPath });
}
// Шлём все 3 в TG как media group с подписью
const { rows: chs } = await query(`SELECT * FROM channels WHERE id=1`);
const channel = chs[0];
const tgBase = await settings.get('TELEGRAM_API_BASE', 'https://api.telegram.org');
const form = new FormData();
form.append('chat_id', String(channel.tg_channel_id));
const media = results.map((r, i) => ({
type: 'photo',
media: `attach://photo${i}`,
caption: i === 0
? `🧪 Тест Зеро — три позы:\n1. аватар 2. за ноутом 3. эврика`
: undefined,
}));
form.append('media', JSON.stringify(media));
results.forEach((r, i) => {
form.append(`photo${i}`, fs.createReadStream(r.path));
});
const res = await axios.post(`${tgBase}/bot${channel.bot_token}/sendMediaGroup`, form, {
headers: form.getHeaders(),
timeout: 60000,
maxContentLength: Infinity,
maxBodyLength: Infinity,
});
const msgIds = (res.data?.result || []).map(m => m.message_id);
console.log(`[zero] sent media group, message_ids=${msgIds.join(',')}`);
console.log('[zero] DONE');
process.exit(0);
} catch (err) {
console.error('[zero] FAILED:', err.response?.data || err.message);
process.exit(1);
}
})();